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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user