6d60d452e0
A gradient mask had its own tone formula and its own exposure, and both disagreed with the frame's. Before any of this was tidied, the mask ran a smoothstep luma lift with an arbitrary 0.55..1.35 chroma clamp while the frame moved the knots of a four-zone ramp, and the mask's exposure was a stop on sRGB-encoded values while the frame's was a stop on light. Two names, four moves, and the same slider meant different things depending on whether the pixels were inside the shape you drew — the divergence §3.3 of the Android port's compat doc warns about. The maths is one string now (TONE_MATH_SKSL, interpolated by both passes): the ramp the four knots build, the hue-preserving rebuild behind it, the transfer pair, and exposureMove. A mask calls the same functions the frame calls. The rebuild carries the chroma instead of re-scaling it. Lightness takes the curve and the colour rides the difference — the channel differences move by ONE shared scale k, pulled back only where the cube has no room left. The doc's ratio (R_new = R_old * Luma_new / Luma_old) was the old reading and it is exact only while nothing clips: a channel past 1.0 stops being scaled with its neighbours and the hue goes with it. Measured on a flat patch frame, a skin tone at 24.0° came back at 48.0° at HIGHLIGHT +100, a warm white at 37° at 57.4°, and under L = 0.5 the same ratio multiplied a near-black pixel's cast by x30 — colour noise amplified, which is why the 0.55..1.35 clamp was there. The scale is the chroma's own now: over 135 knob combinations on seven colours and five greys, the ramp moves the luma and the hue does not move at all (Δ < 1e-9°). EXPOSURE gets the same treatment, which is what the second half of the request was: the linear domain decides where the luma is going, and the pixel is rebuilt onto it through the same lightMove. The old pass multiplied the three channels in linear light, so +1 EV clipped them by three different amounts: measured on the scratchpad probe, 29.2° of hue drift on a skin tone at +1 EV and 33.3° at +2, against 0.00° here. The stop is applied as a ratio on the pixel's own encoded luma rather than pointed straight at the encoded linear target, which is what makes the knob exactly the identity at 0 EV — the transfer does not commute with the luma weights, so pointing at it brightened a colour by a couple of code values even at zero. A grey is the knob it always was: 128 through +1 EV is 176, the same number the linear per-channel multiply put there, so nothing a user has dialled in moves. Verified: `npx tsc --noEmit` clean, `npm run build` clean. highlight-knee-check now runs EXPOSURE_SKSL for real — compiled with CanvasKit and four pixels pushed through it, agreeing with the twin to a code value on a grey at +1 EV (176), a skin tone at +1 EV and +2 EV, and a shadow at -2 EV; it also pins the hue, the cube, the identity at 0 EV and the black pixel that has no light to move. mask-wb-check compiles the mask pass and pushes the same stops through it: 128 through +1 EV is 176, through -1 EV is 92, +2 EV lands the channel on the ceiling at 255 and holds the hue within 3°. auto-tone-check, preview-match-check, white-level-check, raw-develop-check, half-check and roll-walk-check all pass. Live on the built bundle in a 1440x950 browser: the LIGHT panel's EXPOSURE +1 EV takes the mid grey of a flat patch frame from 0.502 to 0.690 (a stop on light gives 0.686) and moves no patch's hue at -1 EV (Δ 0.00°), and a linear gradient mask's EXPOSURE +1 EV and HIGHLIGHT +100 move the pixels inside the mask (luma 185.9 -> 211.1 and 185.9 -> 197.5) while the corner outside it does not move at all (220.2 -> 220.2), with no console error. Co-authored-by: PenguinHarness <noreply@penguin.local>
210 lines
11 KiB
JavaScript
210 lines
11 KiB
JavaScript
// A gradient mask carries the LIGHT column's WB pair now — COLOR TEMP and TINT,
|
|
// on the mask's own pixels. Two things can go wrong and neither shows up as a
|
|
// crash: the mask's gain drifts from the frame-wide one (two opinions about what
|
|
// 3000K means), or the uniform block and the shader's declarations fall out of
|
|
// step (one buffer, expanded arrays — an off-by-one there reads the frame size as
|
|
// a gain). So this checks the arithmetic and then renders the pass for real:
|
|
// CanvasKit is already a dependency, so the shader is compiled and a mid-grey is
|
|
// pushed through it rather than the source being eyeballed.
|
|
//
|
|
// node scripts/mask-wb-check.mjs
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import ts from 'typescript';
|
|
import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
|
|
|
|
const transpile = (path) =>
|
|
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
|
|
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
|
}).outputText;
|
|
|
|
// gradientMask.ts imports its two helpers, so the graph is transpiled into one
|
|
// temp directory and the specifiers are pointed at the .mjs copies (the
|
|
// convention auto-tone-check.mjs uses). colorUtils and toneShader import types
|
|
// only, so the compiler drops those lines itself.
|
|
const dir = mkdtempSync(join(tmpdir(), 'mask-wb-check-'));
|
|
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
|
|
writeFileSync(
|
|
join(dir, 'toneShader.mjs'),
|
|
transpile('../shared/utils/toneShader.ts').replace(
|
|
/^import .*from ['"]\.\/colorUtils['"];$/m,
|
|
'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";',
|
|
),
|
|
);
|
|
writeFileSync(
|
|
join(dir, 'gradientMask.mjs'),
|
|
transpile('../shared/utils/gradientMask.ts')
|
|
.replace(/^import .*from ['"]\.\/colorUtils['"];$/m, 'import { whiteBalanceGain } from "./colorUtils.mjs";')
|
|
.replace(
|
|
/^import \{[^\n}]*\} from ['"]\.\/toneShader['"];$/m,
|
|
'import { CLARITY_GAIN, DEHAZE_FLOOR_T, DEHAZE_MAX_OMEGA, DEHAZE_PATCH_STEP, DEHAZE_PATCH_TAPS, TONE_MATH_SKSL } from "./toneShader.mjs";',
|
|
),
|
|
);
|
|
const { readMasks, maskUniforms, gradientMaskSkSL } = await import(
|
|
pathToFileURL(join(dir, 'gradientMask.mjs')).href
|
|
);
|
|
const { whiteBalanceGain } = await import(pathToFileURL(join(dir, 'colorUtils.mjs')).href);
|
|
|
|
// --- The gain itself: the one function the frame-wide matrix folds in too.
|
|
const close = (a, b, eps = 1e-12) => assert.ok(Math.abs(a - b) < eps, `${a} != ${b}`);
|
|
const luma = (g) => 0.2126 * g.r + 0.7152 * g.g + 0.0722 * g.b;
|
|
const neutral = whiteBalanceGain(5500, 0);
|
|
close(neutral.r, 1);
|
|
close(neutral.g, 1);
|
|
close(neutral.b, 1);
|
|
// The ruler runs the way the engine does: K names the light the frame is said to
|
|
// have been shot under, and the gain compensates for it, so the picture WARMS as
|
|
// K rises (kelvinToRGB's own doc, and the swatch under the ruler). 10000K up and
|
|
// 2500K down, on the same two ends the frame-wide row has.
|
|
const warm = whiteBalanceGain(10000, 0);
|
|
assert.ok(warm.r > 1 && warm.b < 1, '10000K is not warm');
|
|
const cool = whiteBalanceGain(2500, 0);
|
|
assert.ok(cool.b > 1 && cool.r < 1, '2500K is not cool');
|
|
// The cast moves, the exposure does not: every gain is divided by its own luma.
|
|
for (const g of [warm, cool, whiteBalanceGain(4000, 7), whiteBalanceGain(9000, -7)]) close(luma(g), 1);
|
|
// TINT is the green<->magenta axis, and symmetric about 0.
|
|
const magenta = whiteBalanceGain(5500, 10);
|
|
const green = whiteBalanceGain(5500, -10);
|
|
assert.ok(magenta.r > 1 && magenta.b > 1 && magenta.g < 1, '+TINT is not magenta');
|
|
assert.ok(green.g > 1 && green.r < 1 && green.b < 1, '-TINT is not green');
|
|
// Symmetric in the two channels it moves, and only in those: R and B take the
|
|
// same factor, G its own, and the luma normalisation is what keeps the pair from
|
|
// being a brightness knob (the weights are unequal, so the two ends are not exact
|
|
// mirrors of each other in the third decimal).
|
|
close(magenta.r, magenta.b);
|
|
close(green.r, green.b);
|
|
assert.ok(Math.abs(Math.log(magenta.r) + Math.log(green.r)) < 0.01, 'TINT is not symmetric about 0');
|
|
|
|
// --- readMasks: a mask that never touched the pair reads the neutral, and a
|
|
// hand-written file cannot push the gain outside the ruler.
|
|
const shape = { kind: 'linear', x: 0, y: 0, ex: 1, ey: 1, rx: 0, ry: 0, angle: 0, feather: 0.5 };
|
|
const mask = (extra) => ({ ...shape, exposure: 0, contrast: 0, saturation: 0, ...extra });
|
|
const bare = readMasks([mask({})])[0];
|
|
assert.equal(bare.temperature, 5500, 'an absent WB is not neutral');
|
|
assert.equal(bare.tint, 0, 'an absent TINT is not neutral');
|
|
const high = readMasks([mask({ temperature: 2000, tint: 99 })])[0];
|
|
assert.equal(high.temperature, 2500);
|
|
assert.equal(high.tint, 10);
|
|
const low = readMasks([mask({ temperature: 20000, tint: -99 })])[0];
|
|
assert.equal(low.temperature, 10000);
|
|
assert.equal(low.tint, -10);
|
|
|
|
// --- The uniform block: arrays expanded, declaration order, so the gain has to
|
|
// land on the slot the shader reads as `wb[i]` and the frame size has to stay one
|
|
// array further on.
|
|
const n = 1;
|
|
const one = maskUniforms([mask({ temperature: 10000 })], 64, 64, [0.5, 0.5, 0.5]);
|
|
assert.equal(one.length, (6 * n + 1 + 1) * 4, 'the buffer is not six arrays, the frame and the air');
|
|
const g = whiteBalanceGain(10000, 0);
|
|
close(one[5 * n * 4 + 0], g.r, 1e-6);
|
|
// The gain is the SAME function the frame-wide matrix folds in, so 10000K cannot
|
|
// come to mean one thing on the whole frame and another inside a mask.
|
|
assert.match(
|
|
readFileSync(new URL('../shared/utils/colorUtils.ts', import.meta.url), 'utf8'),
|
|
/const wbGain = whiteBalanceGain\(temperature, tint\);/,
|
|
'the matrix grew a white balance of its own again',
|
|
);
|
|
close(one[5 * n * 4 + 1], g.g, 1e-6);
|
|
close(one[5 * n * 4 + 2], g.b, 1e-6);
|
|
assert.equal(one[6 * n * 4 + 0], 64, 'the width no longer sits where the shader reads `size`');
|
|
assert.equal(one[6 * n * 4 + 1], 64);
|
|
assert.equal(one[(6 * n + 1) * 4 + 0], 0.5, 'the atmospheric light moved');
|
|
const plain = maskUniforms([mask({})], 64, 64);
|
|
for (let c = 0; c < 3; c++) close(plain[5 * n * 4 + c], 1, 1e-6);
|
|
|
|
// --- The pass itself. A radial mask whose semi-axis is four frames wide sits far
|
|
// enough inside its own feather that every pixel has alpha 1, so what comes out
|
|
// is the gain the mask was handed and nothing else: a wrong slot shows up as a
|
|
// wrong pixel rather than as a wrong number in a buffer.
|
|
const CanvasKit = await CanvasKitInit({
|
|
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
|
|
});
|
|
const SIZE = 64;
|
|
const wide = { kind: 'radial', x: 0.5, y: 0.5, ex: 0.5, ey: 0.5, rx: 4, ry: 4, angle: 0, feather: 0.5 };
|
|
|
|
function render(masks, spatial, fillRGB = [128, 128, 128]) {
|
|
const effect = CanvasKit.RuntimeEffect.Make(gradientMaskSkSL(masks.length, spatial));
|
|
assert.ok(effect, `the mask shader for ${masks.length} masks does not compile (spatial ${spatial})`);
|
|
// The frame the pass reads: a flat mid-grey, drawn once and handed in as the
|
|
// first child exactly as exportEngine's replaceThrough hands its snapshot in.
|
|
const src = CanvasKit.MakeSurface(SIZE, SIZE);
|
|
const fill = new CanvasKit.Paint();
|
|
fill.setColor(CanvasKit.Color(...fillRGB));
|
|
src.getCanvas().drawPaint(fill);
|
|
const grey = src.makeImageSnapshot();
|
|
const asChild = () =>
|
|
grey.makeShaderOptions(
|
|
CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None,
|
|
);
|
|
const air = spatial ? [0.5, 0.5, 0.5] : null;
|
|
const children = [asChild()];
|
|
if (spatial) children.push(asChild());
|
|
const paint = new CanvasKit.Paint();
|
|
paint.setShader(effect.makeShaderWithChildren(maskUniforms(masks, SIZE, SIZE, air), children));
|
|
const out = CanvasKit.MakeSurface(SIZE, SIZE);
|
|
out.getCanvas().drawRect(CanvasKit.XYWHRect(0, 0, SIZE, SIZE), paint);
|
|
const px = out.getCanvas().readPixels(0, 0, {
|
|
width: SIZE,
|
|
height: SIZE,
|
|
colorType: CanvasKit.ColorType.RGBA_8888,
|
|
alphaType: CanvasKit.AlphaType.Unpremul,
|
|
colorSpace: CanvasKit.ColorSpace.SRGB,
|
|
});
|
|
const at = (x, y) => {
|
|
const i = (y * SIZE + x) * 4;
|
|
return [px[i], px[i + 1], px[i + 2]];
|
|
};
|
|
return at(32, 32);
|
|
}
|
|
const knob = (extra) => ({ ...wide, exposure: 0, contrast: 0, saturation: 0, ...extra });
|
|
const same = (p, want = 128) => p.every((v) => Math.abs(v - want) <= 1);
|
|
assert.ok(same(render([knob({})], false)), `a neutral mask moved the grey: ${render([knob({})], false)}`);
|
|
const after = render([knob({ temperature: 10000 })], false);
|
|
assert.ok(after[0] > 130 && after[2] < 126, `10000K did not warm the mask's pixels: ${after}`);
|
|
const tinted = render([knob({ tint: 10 })], false);
|
|
assert.ok(tinted[0] > 130 && tinted[2] > 130 && tinted[1] < 126, `+TINT is not magenta: ${tinted}`);
|
|
// The spatial build is a second shader text with a second signature: it has to
|
|
// compile with the same WB and read `wb` all the same.
|
|
const spatialPx = render([knob({ temperature: 10000 })], true);
|
|
assert.ok(spatialPx[0] > 130 && spatialPx[2] < 126, `the spatial pass dropped the WB: ${spatialPx}`);
|
|
// Two masks: the second array's slot, not the first one's, and both applied.
|
|
const two = render([knob({}), knob({ temperature: 10000 })], false);
|
|
assert.ok(two[0] > 130 && two[2] < 126, `the second mask's WB did not land: ${two}`);
|
|
|
|
// --- EXPOSURE inside a mask, which is now the frame's own move (exposureMove, the
|
|
// shared TONE_MATH_SKSL): a stop on LIGHT, decided in the linear domain. The pass
|
|
// this replaced multiplied the three channels in linear light, so a channel that
|
|
// reached the ceiling clipped by a different amount than its neighbours and the
|
|
// hue went with it — 29.2° on a warm skin tone at +1 EV, 33.3° at +2 (scratchpad
|
|
// exp-variant.mjs), while a mid-grey is the same number both ways (0.6858) and
|
|
// cannot tell the two apart.
|
|
const hue = (p) => {
|
|
const [r, g, b] = p.map((v) => v / 255);
|
|
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
|
|
if (d < 1e-9) return NaN;
|
|
const h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
|
return ((h * 60) % 360 + 360) % 360;
|
|
};
|
|
const up = render([knob({ exposure: 1 })], false);
|
|
assert.ok(Math.abs(up[0] - 175) <= 2, `+1 EV is not a stop on light: 128 came out ${up[0]}, not 175`);
|
|
const down = render([knob({ exposure: -1 })], false);
|
|
assert.ok(Math.abs(down[0] - 93) <= 2, `-1 EV is not a stop on light: 128 came out ${down[0]}, not 93`);
|
|
const warm8 = [230, 150, 50];
|
|
const lit = render([knob({ exposure: 1 })], false, warm8);
|
|
assert.ok(lit.every((v, i) => v >= warm8[i]), `+1 EV darkened a channel: ${lit}`);
|
|
assert.ok(Math.abs(hue(lit) - hue(warm8)) < 3, `the exposure moved the hue to ${hue(lit)}° from ${hue(warm8)}°`);
|
|
// A pixel already against the ceiling gives up saturation rather than hue, and
|
|
// the channel on the ceiling lands ON it: 255 out, not three clipped at three
|
|
// different points.
|
|
const hot = render([knob({ exposure: 2 })], false, warm8);
|
|
assert.ok(Math.abs(hue(hot) - hue(warm8)) < 3, `the exposure moved the hue at the ceiling: ${hue(hot)}°`);
|
|
assert.equal(hot[0], 255, 'the channel against the ceiling did not land on it');
|
|
assert.ok(hot[1] > lit[1] && hot[2] > lit[2], `+2 EV did not brighten past +1 EV: ${lit} then ${hot}`);
|
|
|
|
console.log(
|
|
'mask WB: neutral 5500K/0 leaves the pixel, 10000K warms it, +TINT magenta-ises it; the block, the plain and the spatial shaders all read it',
|
|
);
|