Studio: fold WB and FX into LIGHT, and put every develop slider on -100..+100

WB and FX were separate tabs whose only content was the same Lightroom-style
develop column LIGHT already renders, so the rail carried three doors into one
room. LIGHT now owns the whole column: white balance, tone, presence, the
effects group (grain, dehaze, vignette) and the filters, in that order. The
`wb` and `fx` TabIds, their rail entries, their `wbLabel()` helper and the now
dead `tab.wb` / `tab.fx` i18n keys are gone; `ToolRail` documents eight tabs.

Every continuous develop value that the UI exposes as a symmetric knob now
runs -100..+100 instead of -10..+10. `paramDefs` gains the `HUNDRED` key set
and the `deepen` helper, which widens a def's range and scales its accessors by
ten, so the store keeps its -10..+10 internal scale and every stored look,
DEFAULT_RECIPES entry and URL round-trip is byte-identical. Params with a
meaningful physical scale (temperature in kelvin, exposure in EV-ish units,
grain, grain size, hdf, vignette, rotate, crop) keep their own units.

Highlight recovery no longer bends hue. The old knee clamped the per-channel
gain into 0.55..1.35, which is a per-channel operation and therefore a hue
rotation: on the flat skin patch it walked hue from 24 deg to 48 deg at
HIGHLIGHT +100, and on a saturated 30:1 chroma ramp it desaturated toward black
instead of toward white. The shader now caps the pixel's distance from its
undersaturated knee point while preserving the direction of that offset, i.e.
it scales chroma and keeps hue, then clamps into gamut.

Verified:
- `npx tsc --noEmit` clean; `npm run build` clean.
- `node scripts/highlight-knee-check.mjs` (pins the new shader source and
  twin-tests 135 knob combinations) ok.
- Existing checks re-run green: `auto-tone-check`, `half-check`,
  `white-level-check`, `preview-match-check`, `library-check`,
  `scan-nav-check`, `roll-walk-check`.
- Live browser pass: rail shows exactly the seven expected tabs with no WB or
  FX; LIGHT renders 5 panels / 23 `data-key` knobs; 12 knobs report
  `min=-100 max=100`; everything else keeps its own range.
- Highlight hue measured on ten flat colour patches: HIGHLIGHT -100 gives a
  hue delta of 0.00 deg on every chromatic patch; HIGHLIGHT +100 stays within
  0.22 deg (sky) and 0.28 deg (magenta) wherever chroma survives, and the
  patches the ramp intentionally drives to white arrive fully neutral. Greys
  stay neutral (channel spread <= 2/255) at every knob setting. Skin patch
  before/after: 23.94 deg -> 0.00 deg.

ponytail: temperature (2500-10000 K), exposure (+-10 units at 0.25 EV each),
grain, grain size, hdf, vignette, rotate and crop deliberately keep their own
scales rather than the blanket -100..+100; widen them the day a user asks for
more range, not before. Hue assertions live in the flat-patch check because
real-photo measurements pick up 2-4 deg of resample drift from the snapshot
pipeline that has nothing to do with the shader.

Co-authored-by: PenguinHarness <noreply@penguin.local>
This commit is contained in:
2026-09-29 17:04:08 +07:00
parent 34f8601c91
commit c0aaa67361
7 changed files with 178 additions and 106 deletions
@@ -81,6 +81,18 @@ assert.doesNotMatch(sksl, /mix\(a0, a1, smoothstep/, 'the ramp is smoothstepped
assert.doesNotMatch(tone, /if \(hl < 0\.0\) \{/, 'the linear-light recovery came back');
assert.doesNotMatch(sksl, /max\(hl, 0\.0\)/, 'the additive lift came back');
assert.doesNotMatch(sksl, /bl \* 0\.18 \* dk|wh \* 0\.18 \* rgb/, 'WHITE/BLACK are per-channel again');
// The rebuild after the ramp: the chroma difference rides the new luma, scaled
// by one number that only the cube can pull back. The doc's ratio is NOT it any
// more — scaling all three channels by Luma_new / Luma_old is exact until 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.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
// exposure pass), or that pass is drawn in a space that is not linear at all.
assert.match(tone, /return mix\(c \/ 12\.92, pow\(\(c \+ 0\.055\) \/ 1\.055, vec3\(2\.4\)\), step\(vec3\(0\.04045\), c\)\);/);
@@ -238,4 +250,76 @@ for (const bl of [-1, 1])
assert.ok(toe <= head + 1e-12, `toe ${toe} over head ${head}`);
}
// 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]);
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);
return { out, clamped: out.map((c) => clamp01(c)), o, t };
}
function hueOf(c) {
const mx = Math.max(...c), mn = Math.min(...c), d = mx - mn;
if (d < 1e-9) return NaN;
let h;
if (mx === c[0]) h = (c[1] - c[2]) / d + (c[1] < c[2] ? 6 : 0);
else if (mx === c[1]) h = (c[2] - c[0]) / d + 2;
else h = (c[0] - c[1]) / d + 4;
return ((h * 60) % 360 + 360) % 360;
}
const colourCases = [
[0.9, 0.72, 0.6], // skin — the case that moved 24° under the ratio
[1, 0.97, 0.92], // a warm white at the very top of the ramp
[0.45, 0.65, 0.9], // sky
[1, 0.6, 0.2], // orange, one channel already on the ceiling
[0.45, 0.85, 0.4], // green
[0.05, 0.03, 0.02], // a shadow with a cast
[0.01, 0.008, 0.006],// and the same cast with almost no light on it at all
];
const greyCases = [[0.1, 0.1, 0.1], [0.5, 0.5, 0.5], [0.7, 0.7, 0.7], [0.9, 0.9, 0.9], [0.97, 0.97, 0.97]];
const knobSets = [];
for (const hl of [-1, -0.5, 0, 0.5, 1])
for (const wh of [-1, 0, 1])
for (const sh of [-1, 0, 1])
for (const bl of [-1, 0, 1]) knobSets.push({ hl, wh, sh, bl });
for (const k of knobSets) {
for (const rgb of colourCases) {
const { out, clamped, o } = rebuild(rgb, k);
// The clamp is never what saves the pixel: the scale already landed the
// result inside the cube, which is the whole point of it.
for (let i = 0; i < 3; i++)
assert.ok(Math.abs(out[i] - clamped[i]) < 1e-12, `the cube clipped ${i} of ${rgb} at ${JSON.stringify(k)}`);
// Hue cannot move: every channel difference is scaled by the same number.
const dh = hueOf(clamped) - hueOf(rgb);
assert.ok(Number.isNaN(dh) || Math.abs(dh) < 1e-9, `hue moved ${dh} for ${rgb} at ${JSON.stringify(k)}`);
// ...and the new luma is the ramp's, exactly (the differences sum to zero
// in this weighting, so the scale drops out of the luma).
close(lumaOf(clamped), o, `luma ${rgb} at ${JSON.stringify(k)}`);
}
// A grey is a grey: no difference to carry, so it lands on the ramp value and
// picks up no cast on the way.
for (const rgb of greyCases) {
const { clamped, o } = rebuild(rgb, k);
for (const c of clamped) close(c, o, `grey drifted at ${JSON.stringify(k)}`);
}
}
// Every knob on zero is the identity for the colour too, not just the luma.
for (const rgb of [...colourCases, ...greyCases]) {
const { clamped } = rebuild(rgb, {});
for (let i = 0; i < 3; i++) close(clamped[i], rgb[i], 'the colour rebuild is not the identity at zero');
}
// The chroma is CARRIED, not re-scaled: where the cube has room, the channel
// differences come out as they went in (this is where the doc's ratio diverged
// — it multiplied them by Luma_new / Luma_old, which over-saturates a lift and
// drains a pull in the half of the ramp above L = 0.5, the highlight zone).
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');
console.log('highlight-knee-check ok');