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:
2026-09-29 18:04:50 +07:00
parent 03ec925aec
commit 6d60d452e0
4 changed files with 351 additions and 124 deletions
+150 -12
View File
@@ -27,6 +27,7 @@
// node scripts/highlight-knee-check.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const develop = readFileSync(new URL('../src/engine/rawDevelop.ts', import.meta.url), 'utf8');
const tone = readFileSync(new URL('../shared/utils/toneShader.ts', import.meta.url), 'utf8');
@@ -47,8 +48,27 @@ const A = Number(anchorSrc);
assert.equal(A, 0.25, 'a knob no longer moves its knot a quarter of the ramp');
const tmpl = tone.match(/export const TONE_SKSL = `([\s\S]*?)`;/)?.[1];
assert.ok(tmpl, 'TONE_SKSL is gone');
assert.equal((tmpl.match(/\$\{TONE_ANCHOR\}/g) ?? []).length, 4, 'a knot is pinned to a literal, not to TONE_ANCHOR');
const sksl = tmpl.replaceAll('${TONE_ANCHOR}', String(A));
// The ramp, the hue-preserving rebuild and the exposure move live in
// TONE_MATH_SKSL, the one copy the whole-frame pass and a gradient mask both
// interpolate — so the shape is pinned there, and TONE_SKSL has to reach for it
// rather than carry a second version of its own (that is the divergence the
// compat doc §3.3 warns the Android port about).
const mathTmpl = tone.match(/export const TONE_MATH_SKSL = `([\s\S]*?)`;/)?.[1];
assert.ok(mathTmpl, 'TONE_MATH_SKSL is gone — the frame and a mask no longer share the maths');
assert.equal((mathTmpl.match(/\$\{TONE_ANCHOR\}/g) ?? []).length, 4, 'a knot is pinned to a literal, not to TONE_ANCHOR');
assert.ok(tmpl.includes('${TONE_MATH_SKSL}'), 'the frame pass carries its own copy of the ramp again');
assert.match(tmpl, /rgb = toneRamp\(rgb, t, bl, sh, hl, wh, dr\);/);
const resolve = (s) => s.replace('${TONE_MATH_SKSL}', mathTmpl).replaceAll('${TONE_ANCHOR}', String(A));
const sksl = resolve(tmpl);
const maths = resolve(mathTmpl);
assert.doesNotMatch(tmpl, /float a4 = /, 'the ramp is back inside the pass — one copy, not two');
const mask = readFileSync(new URL('../shared/utils/gradientMask.ts', import.meta.url), 'utf8');
assert.ok(mask.includes('${TONE_MATH_SKSL}'), 'the mask pass does not read the shared maths');
assert.match(mask, /c = half3\(exposureMove\(vec3\(c\), a\.x\)\);/);
assert.match(mask, /c = half3\(toneRamp\(vec3\(c\), lf, tone\.w, tone\.y, tone\.x, tone\.z, 0\.0\)\);/);
assert.doesNotMatch(mask, /0\.55, 1\.35/, 'the mask kept its own arbitrary saturation clamp');
assert.doesNotMatch(mask, /cg = clamp\(lifted/, 'the mask is back on its own tone formula');
// The four tents, one per quarter of the ramp, each clipped by its neighbour so
// no luma is counted by two of them.
@@ -87,10 +107,11 @@ assert.doesNotMatch(sksl, /bl \* 0\.18 \* dk|wh \* 0\.18 \* rgb/, 'WHITE/BLACK a
// channel clips, and a clipped channel is a moved hue (a skin tone at 24.0° came
// back at 48.0° at HIGHLIGHT +100, scratchpad hl-variants.mjs), and under L=0.5
// it multiplies a near-black pixel's cast by up to x30.
assert.match(sksl, /float k = 1\.0;/);
assert.match(sksl, /if \(hiC > t\) k = min\(k, \(1\.0 - o\) \/ \(hiC - t\)\);/);
assert.match(sksl, /if \(loC < t\) k = min\(k, o \/ \(t - loC\)\);/);
assert.match(sksl, /rgb = clamp\(vec3\(o\) \+ \(rgb - vec3\(t\)\) \* k, 0\.0, 1\.0\);/);
assert.match(maths, /float k = 1\.0;/);
assert.match(maths, /if \(hiC > t\) k = min\(k, \(1\.0 - o\) \/ \(hiC - t\)\);/);
assert.match(maths, /if \(loC < t\) k = min\(k, o \/ \(t - loC\)\);/);
assert.match(maths, /return clamp\(vec3\(o\) \+ \(c - vec3\(t\)\) \* k, 0\.0, 1\.0\);/);
assert.match(maths, /return lightMove\(c, t, clamp\(o, 0\.0, 1\.0\)\);/);
assert.doesNotMatch(tone, /0\.55, 1\.35/, 'the arbitrary saturation clamp came back');
assert.doesNotMatch(sksl, /max\(t, 0\.0004\)/, 'the luma ratio came back');
// The transfer pair has to be the accurate one where it is still used (the
@@ -253,17 +274,44 @@ for (const bl of [-1, 1])
// The colour rebuild, as the shader emits it: the ramp's luma, the pixel's own
// chroma difference, and the one scale the cube allows.
const lumaOf = (c) => clamp01(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]);
// lightMove, as TONE_MATH_SKSL emits it — the one move every brightness change in
// the pass goes through (a tone knob, a mask's tone knob, the exposure knob).
// NOT clamped on the way out here: the check below wants to see that the scale
// alone already landed the pixel inside the cube, and a silent clamp would hide
// the case where it did not.
function lightMove(rgb, t, o) {
let k = 1;
const hiC = Math.max(...rgb);
const loC = Math.min(...rgb);
if (hiC > t) k = Math.min(k, (1 - o) / (hiC - t));
if (loC < t) k = Math.min(k, o / (t - loC));
return rgb.map((c) => o + (c - t) * k);
}
function rebuild(rgb, k) {
const t = lumaOf(rgb);
const o = ramp(t, k).o;
const hiC = Math.max(...rgb);
const loC = Math.min(...rgb);
let s = 1;
if (hiC > t) s = Math.min(s, (1 - o) / (hiC - t));
if (loC < t) s = Math.min(s, o / (t - loC));
const out = rgb.map((c) => o + (c - t) * s);
const out = lightMove(rgb, t, o);
return { out, clamped: out.map((c) => clamp01(c)), o, t };
}
// The transfer pair the exposure pass crosses into linear light with, and back.
const srgbToLin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const linToSrgb = (c) => (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055);
// exposureMove, as TONE_MATH_SKSL emits it: the linear sensor moves by the stops,
// and the encoded value that lands there is the luma the pixel is rebuilt onto.
// The light moves by exp2(ev) in LINEAR light; the colour moves by the one shared
// scale of lightMove. A per-channel multiply does neither — it clips the three
// channels by three different amounts and takes the hue with it (29.2° at +1 EV
// on the scratchpad probe, exp-variant.mjs; this variant measures 0.00°).
function exposureMove(rgb, ev) {
const c = rgb.map(clamp01);
const t = lumaOf(c);
// The stop as a RATIO on the pixel's own encoded luma, which is what makes the
// knob the identity at 0 EV: pointing the luma straight at the encoded linear
// target brightens a colour by a couple of code values even on zero.
const lin = Math.max(lumaOf(c.map(srgbToLin)), 1e-6);
const stop = linToSrgb(Math.min(1, lin * 2 ** ev)) / linToSrgb(lin);
return lightMove(c, t, clamp01(t * stop)).map(clamp01);
}
function hueOf(c) {
const mx = Math.max(...c), mn = Math.min(...c), d = mx - mn;
if (d < 1e-9) return NaN;
@@ -322,4 +370,94 @@ const carried = rebuild([0.7, 0.55, 0.45], { hl: 0.5 }).clamped;
const grew = (carried[0] - carried[1]) / (0.7 - 0.55);
close(grew, 1, 'the chroma was re-scaled on a highlight lift');
// THE EXPOSURE KNOB, the same move on a different input. Behind it: -2..+2 EV in
// half stops, on the frame and inside a gradient mask.
// A grey is the knob it always was — a stop on a neutral is a stop on its light,
// and nothing else: this is the number the old linear per-channel multiply put
// there, so no exposure a user has dialled in moves.
for (const g of [0.05, 0.1, 0.5, 0.7, 0.9, 0.97])
for (const ev of [-2, -1, -0.5, 0.5, 1, 2])
close(
exposureMove([g, g, g], ev)[0],
linToSrgb(Math.min(1, srgbToLin(g) * 2 ** ev)),
`the exposure is no longer a stop on a grey: ${g} at ${ev} EV`,
);
// ...and zero stops is the identity on a COLOUR too, exactly — the knob has to be
// able to leave the frame alone.
for (const rgb of [...colourCases, ...greyCases])
for (let i = 0; i < 3; i++)
close(exposureMove(rgb, 0)[i], rgb[i], 'the exposure move is not the identity at 0 EV');
// Hue cannot move, at any stop, on any colour: this is the whole fix. The old
// pass multiplied the three channels by the same number in LINEAR light and then
// clipped them by three different amounts, and the hue went with them — 29.2° on
// the skin tone at +1 EV, 33.3° at +2 EV (scratchpad exp-variant.mjs), against
// 0.00° here.
for (const rgb of colourCases)
for (const ev of [-2, -1, -0.5, 0, 0.5, 1, 2]) {
const out = exposureMove(rgb, ev);
const dh = hueOf(out) - hueOf(rgb);
assert.ok(Number.isNaN(dh) || Math.abs(dh) < 1e-9, `the exposure moved the hue ${dh}° on ${rgb} at ${ev} EV`);
assert.ok(out.every((c) => c >= -1e-12 && c <= 1 + 1e-12), `the exposure left the cube on ${rgb} at ${ev} EV`);
}
// A pixel already on the ceiling: the channel that used to clip lands exactly ON
// the ceiling and the other two follow it down at the one shared scale, so the
// pixel gives up saturation rather than having the three clip by three different
// amounts — which is where the old pass lost the hue.
const blown = exposureMove([1, 0.6, 0.2], 5);
assert.ok(blown.every((c) => c >= -1e-12 && c <= 1 + 1e-12), 'the exposure overshot the ceiling');
close(blown[0], 1, 'the channel that hit the ceiling stopped short of it');
assert.ok(blown[1] > 0.9 && blown[2] > 0.85, 'the pixel collapsed to white instead of keeping its colour');
assert.ok(Math.abs(hueOf(blown) - hueOf([1, 0.6, 0.2])) < 1e-9, 'the pixel lost its hue at the ceiling');
// ...and a pixel the move really does drive to 1.0 (an exposure past the head of
// the ramp) is white, in all three channels at once.
const white = exposureMove([0.98, 0.98, 0.98], 5);
for (let i = 0; i < 3; i++) close(white[i], 1, 'a blown pixel stopped short of white');
// Darkening is the mirror: the light comes down, and a colour with no room below
// gives up saturation and arrives neutral, not negative.
const crushed = exposureMove([0.02, 0.01, 0.005], -5);
assert.ok(crushed.every((c) => c >= -1e-12 && c <= 1 + 1e-12), 'the exposure went outside the cube on the way down');
assert.ok(crushed[0] >= crushed[1] && crushed[1] >= crushed[2], 'the exposure inverted the channel order on the way down');
// Black has no light to move: every stop leaves it where it is, and none of them
// divides by zero on the way.
for (const ev of [-5, -1, 0, 1, 5]) assert.equal(exposureMove([0, 0, 0], ev)[0], 0, `black moved at ${ev} EV`);
// THE PASS ITSELF, compiled and run. Everything above is a twin, and a twin is
// only as good as its reading of the source; nothing else compiles EXPOSURE_SKSL,
// so a wrapper whose uniform stopped matching its own main would only show up in
// the app. Four pixels through the real shader, against the twin.
const exposureSrc = resolve(tone.match(/export const EXPOSURE_SKSL = `([\s\S]*?)`;/)?.[1] ?? '');
assert.match(exposureSrc, /uniform float ev;/, 'the exposure pass no longer takes its stops');
assert.match(exposureSrc, /return vec4\(exposureMove\(clamp\(c\.rgb, 0\.0, 1\.0\), ev\), c\.a\);/, 'the pass stopped calling exposureMove');
const { default: CanvasKitInit } = await import('canvaskit-wasm/bin/full/canvaskit.js');
const ck = await CanvasKitInit({
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
});
const effect = ck.RuntimeEffect.Make(exposureSrc);
assert.ok(effect, 'EXPOSURE_SKSL does not compile — the whole frame loses its exposure');
const throughPass = (rgb, ev) => {
const surface = ck.MakeSurface(4, 4);
const paint = new ck.Paint();
paint.setColor(ck.Color(...rgb));
surface.getCanvas().drawPaint(paint);
const child = surface.makeImageSnapshot().makeShaderOptions(
ck.TileMode.Clamp, ck.TileMode.Clamp, ck.FilterMode.Linear, ck.MipmapMode.None,
);
const shaderPaint = new ck.Paint();
shaderPaint.setShader(effect.makeShaderWithChildren([ev], [child]));
const out = ck.MakeSurface(4, 4);
out.getCanvas().drawRect(ck.XYWHRect(0, 0, 4, 4), shaderPaint);
const px = out.getCanvas().readPixels(0, 1, {
width: 4, height: 1, colorType: ck.ColorType.RGBA_8888, alphaType: ck.AlphaType.Unpremul, colorSpace: ck.ColorSpace.SRGB,
});
return [px[0], px[1], px[2]];
};
for (const [rgb, ev] of [[[128, 128, 128], 1], [[230, 150, 50], 1], [[230, 150, 50], 2], [[20, 10, 5], -2]]) {
const want = exposureMove(rgb.map((v) => v / 255), ev).map((v) => Math.round(v * 255));
const got = throughPass(rgb, ev);
assert.ok(
got.every((v, i) => Math.abs(v - want[i]) <= 1),
`the pass and its twin disagree on ${rgb} at ${ev} EV: ${got} against ${want}`,
);
}
console.log('highlight-knee-check ok');
+33 -3
View File
@@ -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',
);
+36 -41
View File
@@ -6,6 +6,7 @@ import {
DEHAZE_MAX_OMEGA,
DEHAZE_PATCH_STEP,
DEHAZE_PATCH_TAPS,
TONE_MATH_SKSL,
} from './toneShader';
// FX tab > LINEAR / RADIAL GRADIENT — Lightroom's two gradient masks, the local
@@ -31,17 +32,18 @@ import {
//
// The spec's section 4 — "the system needs to restrict all the above effects to
// operate only within the mask's area" — is the rest of the block below: the
// smoothstep soft masks that carry HIGHLIGHT and SHADOW, the two ends WHITE and
// BLACK move, and the two spatial ones (CLARITY against the frame's own blur,
// DEHAZE through the dark channel) that the caller hands in the blurred
// reference for and the atmospheric light for. Every one of them rides the same
// alpha the shape produces and lands through the same `mix`, so a mask at half
// strength is half of the move.
// four tonal-range knobs (the frame-wide ramp, moved on the mask's own pixels),
// and the two spatial ones (CLARITY against the frame's own blur, DEHAZE through
// the dark channel) that the caller hands in the blurred reference for and the
// atmospheric light for. Every one of them rides the same alpha the shape
// produces and lands through the same `mix`, so a mask at half strength is half
// of the move.
export const MASK_KIND = { linear: 0, radial: 1 } as const;
// Exposure is stored as the EV itself — the spec's own -5..+5 — because that is
// what `pow(2.0, e)` reads, and a stop is a stop whatever the app's slider units
// are elsewhere. The other two are -10..+10 like every other knob, and are turned
// into the spec's -1..+1 on the way to the shader.
// what `exp2(e)` spends on the light (toneShader.EXPOSURE_SKSL), and a stop is a
// stop whatever the app's slider units are elsewhere. The other two are -10..+10
// like every other knob, and are turned into the spec's -1..+1 on the way to the
// shader.
export const MASK_EXPOSURE_MAX = 5;
// How much of the semi-axis a fresh ellipse fades over. Half: the edge is soft
// enough to be a gradient mask rather than a cut-out, and every pixel of the
@@ -171,19 +173,18 @@ export function maskUniforms(
return u;
}
// The colour inside a mask, in the spec's own order and, for the knobs the app
// also has frame-wide, in the app's own formulas: exposure first (a power of
// two, so a stop is a stop), then contrast about the middle, then saturation as
// a mix away from the pixel's own REC-709 luma. Then the spec's section 2 and 3
// on top — HIGHLIGHT and SHADOW through the two smoothstep soft masks its own
// formula names, WHITE and BLACK as the per-channel point moves TONE_SKSL
// makes, and the two spatial ones against the blurred reference the caller hands
// in. Every one of them means inside the mask what it means on the whole frame
// (toneShader.ts is the reference the four tonal ones are written from), because
// the same name on the same knob should not be two different moves. The result
// is clamped to the range a file can hold — the spec's own guard, and it is
// `mix`ed back over the base by the mask's alpha, so a mask at half strength is
// half of the move rather than the whole of it.
// The colour inside a mask, in the spec's own order and, for every knob the app
// also has frame-wide, in the app's own formulas: EXPOSURE first (one stop of
// LIGHT, through exposureMove), then contrast about the middle, then saturation
// as a mix away from the pixel's own REC-709 luma. Then the four tonal-range
// knobs and the two spatial ones — the four through the frame-wide ramp
// (TONE_MATH_SKSL), CLARITY and DEHAZE against the blurred reference the caller
// hands in. Every one of them means inside the mask what it means on the whole
// frame, because the same name on the same knob must not be two different moves;
// toneShader.ts is the one copy all of them are read from. The result is clamped
// to the range a file can hold — the spec's own guard, and it is `mix`ed back
// over the base by the mask's alpha, so a mask at half strength is half of the
// move rather than the whole of it.
//
// `blur` is the frame's bilateral reference (the same one CLARITY uses
// frame-wide) and `air` the atmospheric light; both are the constants 0 when the
@@ -193,7 +194,11 @@ export function maskUniforms(
// read in the block below (the frame's own pixels are only in reach there).
const adjustFn = (spatial: boolean) => `
half3 maskAdjust(half3 c, half3 wb, float4 a, float4 tone, float4 fx, half dark${spatial ? ', half3 blur, float3 air' : ''}) {
c = c * half(pow(2.0, a.x));
// EXPOSURE through the frame-wide function, on the mask's pixels: one stop of
// LIGHT (a linear-light move, so 2^ev is what it multiplies) and a move of the
// luma rather than of the three channels, so the knob brightens a colour
// instead of shifting its hue when a channel reaches the ceiling.
c = half3(exposureMove(vec3(c), a.x));
// The mask's own white balance, the frame-wide WB gain on the mask's pixels: a
// gain on each channel, hoisted to the JS side because the Kelvin fit is a
// curve (colorUtils.whiteBalanceGain). 5500K / 0 hands over (1,1,1), so a mask
@@ -201,25 +206,14 @@ half3 maskAdjust(half3 c, half3 wb, float4 a, float4 tone, float4 fx, half dark$
c = clamp(c * wb, half3(0.0), half3(1.0));
c = (c - half(0.5)) * half(1.0 + a.y) + half(0.5);
half l = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722));
// The tonal four, in TONE_SKSL's own formulas: a mask's HIGHLIGHT is meant to
// be the same move as the whole-frame HIGHLIGHT on a smaller area, not a
// second opinion about what the name means. HIGHLIGHT and SHADOW are additive
// shifts of the luma, HIGHLIGHT weighted by the headroom left (1 - l) so it
// cannot drag a blown white to grey, and the colour difference rides along at
// a damped gain (TONE_SKSL's own cg) so a lift or a pull cannot collapse a
// colour. WHITE and BLACK are per-channel point moves, cubic in each channel's
// own distance from the end it owns: the toe and the shoulder move, the
// midtones do not, and a white that is lowered stays white.
// The spec's soft masks, computed in float and narrowed: smoothstep on half is
// one more type the shader does not have to guess at.
// The tonal four, through the frame-wide ramp (TONE_MATH_SKSL): a mask's
// HIGHLIGHT is the same move as the whole-frame HIGHLIGHT on a smaller area,
// not a second opinion about what the name means — the four knots, their
// ordering clamp, the 0.50 midpoint no knob reaches, and the luma-preserving
// rebuild are all the frame's. DR is the one knob the ramp also carries that a
// mask does not have, so it is spent as 0 here.
float lf = clamp(float(l), 0.0, 1.0);
float mh = smoothstep(0.50, 1.00, lf);
float ms = 1.0 - smoothstep(0.00, 0.55, lf);
half lifted = half(clamp(lf + tone.x * mh * (1.0 - lf) + tone.y * 0.34 * ms, 0.0, 1.0));
half cg = clamp(lifted / max(l, half(0.0004)), half(0.55), half(1.35));
c = clamp(half3(lifted) + (c - half3(l)) * cg, half3(0.0), half3(1.0));
half3 dk = half3(1.0) - c;
c = clamp(c + half(tone.w * 0.18) * dk * dk * dk + half(tone.z * 0.18) * c * c * c, half3(0.0), half3(1.0));
c = half3(toneRamp(vec3(c), lf, tone.w, tone.y, tone.x, tone.z, 0.0));
half nl = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722));
c = mix(half3(nl), c, half(1.0 + a.z));
${spatial ? ` // DEHAZE before CLARITY, the frame-wide order and for the frame-wide reason:
@@ -320,6 +314,7 @@ uniform float4 fx[${count}];
uniform float4 wb[${count}];
uniform float4 size;
${spatial ? 'uniform shader blurred;\nuniform float4 air;' : ''}
${TONE_MATH_SKSL}
${adjustFn(spatial)}
half4 main(float2 pos) {
half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => maskBlock(i, spatial)).join('')}
+132 -68
View File
@@ -108,6 +108,125 @@ const BAND_BLOCK = hslBandGaps()
// inside the one before it.
export const TONE_ANCHOR = 0.25;
// The tone and exposure maths, in ONE copy, because two passes ask it: the
// whole-frame passes here and a gradient mask, which moves the same knobs on the
// shape the user drew. What "HIGHLIGHT" or "EXPOSURE" means must not depend on
// where the shape is, and it did: the frame moved the ramp's knots while a mask
// ran a smoothstep luma lift with an arbitrary 0.55..1.35 chroma clamp, and the
// frame's exposure was a linear-light stop while a mask's was a stop on
// sRGB-encoded values. That divergence is what the scratchpad compat doc §3.3
// warns the Android port about. `dr` is the whole frame's DYNAMIC RANGE; a mask
// has no such knob and hands in 0, which is what DR's terms are worth when it is
// off on the frame too.
export const TONE_MATH_SKSL = `
// Fraction of the way from e0 to e1, clamped — the position on one straight
// segment of the tone ramp.
float lin(float e0, float e1, float x) {
return clamp((x - e0) / (e1 - e0), 0.0, 1.0);
}
// The ramp the pixel is rebuilt through. Knots on 0.00, 0.25, 0.50, 0.75 and
// 1.00; a knob moves the knot it owns by TONE_ANCHOR of the ramp, and each knot
// is held inside the one before it so the five can never cross. 0.50 is fixed:
// it is the one point all four sliders leave alone, which is what keeps a
// mid-grey a mid-grey while the ends move around it. Straight between the knots,
// so every knob on zero is exactly the identity (see the note at the head of
// this file).
// DR moves the same knots instead of adding its own masked terms on top: it
// lifts the toe and rolls the head exactly as before at t = 0 and t = 1 —
// 0.12 and 0.18 at full strength — and half of each at the knots next to them,
// but because it is a knot move the ordering clamp holds it too. Added as a
// separate term it could not: with BLACK and SHADOW both at -1 the ramp is flat
// between 0.25 and 0.5, and DR's own shadow lift slopes DOWN through that
// stretch, which is a fold at 0.238.
//
// Lightness takes the curve; the colour rides the difference. The pixel moves to
// its new luma and carries its own chroma with it — the three channel
// differences are scaled by ONE number, so the hue cannot move and a grey cannot
// pick up a cast (a neutral has no difference to carry, and lands on o exactly).
//
// The doc's ratio (R_new = R_old * Luma_new / Luma_old) is the other reading of
// the same sentence, and it is what this pass used to do. It is exact — until
// the result stops fitting. Past 1.0 a channel clips, the differences stop being
// scaled together, and the hue goes with them: measured on the scratchpad probe
// (hl-variants.mjs), a skin tone at 24.0° came back at 48.0° at HIGHLIGHT +100,
// and a warm white at 37° at 57.4°. Under L = 0.5 the same ratio also multiplies
// whatever cast a near-black pixel had — x30 on a shadow with a hair of warmth,
// which is colour noise amplified, the reason the old arbitrary 0.55..1.35 clamp
// was there.
//
// So the scale is the chroma's own (1.0) and the only thing that pulls it back
// is the cube: a pixel with no room left gives up saturation instead of hue, and
// one that the curve has actually driven to 1.0 arrives at white.
//
// ONE move of the light, and everything in this file that changes how bright a
// pixel is goes through it: a tone knob, a mask's tone knob, and the exposure
// knob on both. The luma lands on o and the channel differences ride along at
// one shared scale k, so a knob named "change the brightness" changes the
// brightness and nothing else — what a per-channel multiply cannot promise once
// a channel reaches the ceiling, where the three clip by different amounts and
// the hue goes with them.
vec3 lightMove(vec3 c, float t, float o) {
float k = 1.0;
float hiC = max(max(c.r, c.g), c.b);
float loC = min(min(c.r, c.g), c.b);
if (hiC > t) k = min(k, (1.0 - o) / (hiC - t));
if (loC < t) k = min(k, o / (t - loC));
return clamp(vec3(o) + (c - vec3(t)) * k, 0.0, 1.0);
}
vec3 toneRamp(vec3 c, float t, float bl, float sh, float hl, float wh, float dr) {
float a4 = 1.0 + ${TONE_ANCHOR} * wh - dr * 0.18;
float a3 = clamp(0.75 + ${TONE_ANCHOR} * hl - dr * 0.09, 0.5, a4);
float a1 = clamp(0.25 + ${TONE_ANCHOR} * sh + dr * 0.06, 0.0, 0.5);
float a0 = clamp(${TONE_ANCHOR} * bl + dr * 0.12, 0.0, a1);
float o = mix(a0, a1, lin(0.00, 0.25, t));
o = mix(o, mix(a1, 0.5, lin(0.25, 0.50, t)), step(0.25, t));
o = mix(o, mix(0.5, a3, lin(0.50, 0.75, t)), step(0.50, t));
o = mix(o, mix(a3, a4, lin(0.75, 1.00, t)), step(0.75, t));
return lightMove(c, t, clamp(o, 0.0, 1.0));
}
// The accurate sRGB transfer pair (0.04045/12.92 + 2.4, and its inverse): the
// same constants colorUtils.planckianLinear uses on the WB side, and the reason
// a stop is a stop here. EXPOSURE needs it — 2^ev is a multiplier on LIGHT — and
// so does anything else that has to reach the linear domain.
vec3 toLinear(vec3 c) {
return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c));
}
vec3 toEncoded(vec3 c) {
return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
}
// One EXPOSURE knob, wherever it is: the frame's own pass and a mask's knob. It
// linearises, moves the LIGHT by 2^ev — a stop is a multiplier on light, and on
// an sRGB-encoded value +1 EV would take a mid-grey 0.5 straight to a blown 1.0
// where a real stop gives 0.73 (measured: 0.6858 through here, and the plain
// per-channel multiply puts the same 0.6858 on a grey, so a neutral is the knob
// it always was) — and re-encodes.
//
// The linear domain decides WHERE the luma is going; the move is then made by
// lightMove on the encoded values, where the tone ramp also works. That split is
// measured, not chosen for symmetry: carrying the chroma in the LINEAR domain
// drifts the hue of the encoded pixel by up to 12° (a saturated red at -1 EV
// came back at 12.1°, a skin tone at +1 EV at 11.5° — the encoding is
// per-channel, so equal ratios in linear are not equal ratios on screen),
// against 0.00° this way. The knob whose whole promise is brightness must not be
// the one that also moves a hue: a channel that would have clipped gives up
// saturation instead, and a pixel the move has driven all the way to 1.0 is white
// in all three channels at once.
vec3 exposureMove(vec3 rgb, float ev) {
vec3 c = clamp(rgb, 0.0, 1.0);
float t = clamp(dot(c, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
// The linear domain says where the luma is going; the value that lands there
// is applied as a RATIO on the pixel's own encoded luma, not pointed at
// directly. toEncoded(luma_lin * 2^ev) is the target, and on a grey it IS the
// pixel's new luma (a stop on a neutral is the stop it always was) — but the
// transfer does not commute with the luma weights, so on a colour the two
// differ by a couple of code values, and the knob on 0 EV would brighten the
// frame instead of leaving it alone. As a ratio it is exactly 1 at 0 EV.
float lin = max(dot(toLinear(c), vec3(0.2126, 0.7152, 0.0722)), 1e-6);
float stop = toEncoded(vec3(min(1.0, lin * exp2(ev)))).r / toEncoded(vec3(lin)).r;
return lightMove(c, t, clamp(t * stop, 0.0, 1.0));
}
`;
export const TONE_SKSL = `
uniform shader src;
uniform float dr;
@@ -175,11 +294,7 @@ float bandW(float hue, float anchor, float gapL, float gapR) {
float d = mod(hue - anchor + 180.0, 360.0) - 180.0;
return d <= 0.0 ? max(0.0, 1.0 + d / gapL) : max(0.0, 1.0 - d / gapR);
}
// Fraction of the way from e0 to e1, clamped — the position on one straight
// segment of the tone ramp.
float lin(float e0, float e1, float x) {
return clamp((x - e0) / (e1 - e0), 0.0, 1.0);
}
${TONE_MATH_SKSL}
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
@@ -197,54 +312,11 @@ vec4 main(vec2 xy) {
float shMask = clamp(1.0 - smoothstep(0.25, 0.50, t) - blMask, 0.0, 1.0);
float whMask = smoothstep(0.75, 1.00, t);
float hlMask = clamp(smoothstep(0.50, 0.75, t) - whMask, 0.0, 1.0);
// The ramp the pixel is rebuilt through. Knots on 0.00, 0.25, 0.50, 0.75 and
// 1.00; a knob moves the knot it owns by TONE_ANCHOR of the ramp, and each
// knot is held inside the one before it so the five can never cross. 0.50 is
// fixed: it is the one point all four sliders leave alone, which is what
// keeps a mid-grey a mid-grey while the ends move around it. Straight between
// the knots, so every knob on zero is exactly the identity (see the note at
// the head of this file).
// DR moves the same knots instead of adding its own masked terms on top: it
// lifts the toe and rolls the head exactly as before at t = 0 and t = 1 —
// 0.12 and 0.18 at full strength — and half of each at the knots next to
// them, but because it is a knot move the ordering clamp holds it too. Added
// as a separate term it could not: with BLACK and SHADOW both at -1 the ramp
// is flat between 0.25 and 0.5, and DR's own shadow lift slopes DOWN through
// that stretch, which is a fold at 0.238.
float a4 = 1.0 + ${TONE_ANCHOR} * wh - dr * 0.18;
float a3 = clamp(0.75 + ${TONE_ANCHOR} * hl - dr * 0.09, 0.5, a4);
float a1 = clamp(0.25 + ${TONE_ANCHOR} * sh + dr * 0.06, 0.0, 0.5);
float a0 = clamp(${TONE_ANCHOR} * bl + dr * 0.12, 0.0, a1);
float o = mix(a0, a1, lin(0.00, 0.25, t));
o = mix(o, mix(a1, 0.5, lin(0.25, 0.50, t)), step(0.25, t));
o = mix(o, mix(0.5, a3, lin(0.50, 0.75, t)), step(0.50, t));
o = mix(o, mix(a3, a4, lin(0.75, 1.00, t)), step(0.75, t));
o = clamp(o, 0.0, 1.0);
// Lightness takes the curve; the colour rides the difference. The pixel moves
// to its new luma and carries its own chroma with it — the three channel
// differences are scaled by ONE number, so the hue cannot move and a grey
// cannot pick up a cast (a neutral has no difference to carry, and lands on
// o exactly).
//
// The doc's ratio (R_new = R_old * Luma_new / Luma_old) is the other reading
// of the same sentence, and it is what this pass used to do. It is exact —
// until the result stops fitting. Past 1.0 a channel clips, the differences
// stop being scaled together, and the hue goes with them: measured on the
// scratchpad probe (hl-variants.mjs), a skin tone at 24.0° came back at 48.0°
// at HIGHLIGHT +100, and a warm white at 37° at 57.4°. Under L = 0.5 the same
// ratio also multiplies whatever cast a near-black pixel had — x30 on a
// shadow with a hair of warmth, which is colour noise amplified, the reason
// the old arbitrary 0.55..1.35 clamp was there.
//
// So the scale is the chroma's own (1.0) and the only thing that pulls it back
// is the cube: a pixel with no room left gives up saturation instead of hue,
// and one that the curve has actually driven to 1.0 arrives at white.
float k = 1.0;
float hiC = max(max(rgb.r, rgb.g), rgb.b);
float loC = min(min(rgb.r, rgb.g), rgb.b);
if (hiC > t) k = min(k, (1.0 - o) / (hiC - t));
if (loC < t) k = min(k, o / (t - loC));
rgb = clamp(vec3(o) + (rgb - vec3(t)) * k, 0.0, 1.0);
// The four tonal-range knobs, on the shared ramp: see TONE_MATH_SKSL — the
// same knots, the same hue-preserving rebuild, the same move a gradient mask
// makes with the same four sliders. DR is the whole frame's, so it is spent
// here and nowhere else.
rgb = toneRamp(rgb, t, bl, sh, hl, wh, dr);
// Split tone (stock look): the shadows and the highlights may each carry
// their own tint, so the two ends of the curve can drift opposite ways
// (Classic Neg: green-cyan darks, warm brights) without touching mid-greys.
@@ -316,30 +388,22 @@ ${BAND_BLOCK} hsl.x = fract(hsl.x + acc.x * (30.0 / 360.0));
//
// A stop is a multiplier on LIGHT, and the old EV row multiplied sRGB-ENCODED
// values: +1 EV took a mid-grey 0.5 straight to a blown 1.0 where a real stop
// gives 0.73. Here the pixel is linearised, scaled by 2^ev, and re-encoded —
// which is what a camera does when the shutter stays open twice as long: every
// value keeps its ratio, the highlights roll instead of flattening, and the
// HIGHLIGHT knob still has something to pull back afterwards.
// gives 0.73. exposureMove linearises, moves the light by 2^ev, and re-encodes —
// and it spends that stop on the LUMA, not on the three channels one at a time,
// so this knob only ever changes how bright a pixel is: a channel that would
// have clipped gives up saturation instead of dragging the hue (a gradient
// mask's own EXPOSURE runs through the same function, on the mask's pixels).
//
// It sits between the graded image and the tone shader (see exportEngine step 3),
// so `ev` carries the EXPOSURE knob, the stock's own bias and the EV knob added
// up in stops — the caller hands in one number.
//
// The transfer pair is the accurate one (0.04045/12.92 + 2.4, and its inverse):
// the same constants colorUtils.planckianLinear uses on the WB side.
export const EXPOSURE_SKSL = `
uniform shader src;
uniform float ev;
vec3 toLinear(vec3 c) {
return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c));
}
vec3 toEncoded(vec3 c) {
return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
}
${TONE_MATH_SKSL}
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
return vec4(clamp(toEncoded(toLinear(rgb) * exp2(ev)), 0.0, 1.0), c.a);
return vec4(exposureMove(clamp(c.rgb, 0.0, 1.0), ev), c.a);
}
`;