One move of the light for the whole app: a mask's EXPOSURE and its four tone knobs stop being a second opinion
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>
This commit is contained in:
@@ -40,7 +40,7 @@ writeFileSync(
|
||||
.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 } from "./toneShader.mjs";',
|
||||
'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(
|
||||
@@ -125,14 +125,14 @@ const CanvasKit = await CanvasKitInit({
|
||||
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) {
|
||||
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(128, 128, 128));
|
||||
fill.setColor(CanvasKit.Color(...fillRGB));
|
||||
src.getCanvas().drawPaint(fill);
|
||||
const grey = src.makeImageSnapshot();
|
||||
const asChild = () =>
|
||||
@@ -174,6 +174,36 @@ assert.ok(spatialPx[0] > 130 && spatialPx[2] < 126, `the spatial pass dropped th
|
||||
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',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user