Files
RecipesCam/docker/frontend/scripts/highlight-knee-check.mjs
T
3dtours c0aaa67361 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>
2026-09-29 17:04:08 +07:00

326 lines
18 KiB
JavaScript

// Highlight roll-off in the develop, and the tonal-range ramp in the tone pass.
//
// THE DEVELOP holds the knee:
//
// L' = L , L < T
// L' = T + (L - T) / (1 + 2 S (L - T)) , L >= T
//
// drawn on the sensor's own levels (T = 0.7, S = 1 / (2 (1 - T)), which puts the
// asymptote on 1.0) so the two stops the sensor holds above its white level are
// COMPRESSED into the frame instead of being thrown away by the old
// fade-to-white — which is also the only reason HIGHLIGHT has detail left at the
// top to move. Measured on DSC03453.ARW, where the camera's own preview is
// clipped, the develop's luma was 253.4 with a standard deviation of 2.4, against
// 251.2 / 10.0 through the knee.
//
// THE TONE PASS builds the luma a new ramp instead. The four knobs
// (HIGHLIGHT, SHADOW, WHITE, BLACK) are the four zones of the tone-mapping doc —
// one tent each, one per quarter of the ramp — and each knob moves the knot it
// owns by TONE_ANCHOR of the ramp, held inside the knot before it. The 0.50
// midpoint is the one value all four leave where it was.
//
// Both are SkSL, so the SHAPE is pinned on the source; the arithmetic is then
// checked against the source's own constants, and the ramp re-run here as a twin
// so monotonicity, the neutral identity and the partition of the four masks are
// checked rather than asserted in a comment.
//
// node scripts/highlight-knee-check.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
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');
// The develop: knee on the sensor's max channel, the channel ratios kept, so the
// hue and the saturation of a blown area survive the pull-down.
const dev = develop.match(/if \(mx > 0\.7\) \{[\s\S]*?\n \}/)?.[0];
assert.ok(dev, 'the develop knee is gone — a blown sky is flat 1.0 again');
assert.match(dev, /float over = mx - 0\.7;/);
assert.match(dev, /rgb \*= \(0\.7 \+ over \/ \(1\.0 \+ over \* 3\.3333\)\) \/ mx;/);
assert.doesNotMatch(develop, /mix\(rgb \/ mx, float3\(1\.0\)/, 'the fade-to-white is back');
// The tone pass, read as the string it actually emits: TONE_ANCHOR is
// interpolated, so the template has to be resolved before it can be matched.
const anchorSrc = tone.match(/export const TONE_ANCHOR = ([0-9.]+);/)?.[1];
assert.ok(anchorSrc, 'TONE_ANCHOR is gone — the four knots no longer share a reach');
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 four tents, one per quarter of the ramp, each clipped by its neighbour so
// no luma is counted by two of them.
assert.match(sksl, /float blMask = 1\.0 - smoothstep\(0\.00, 0\.25, t\);/);
assert.match(sksl, /float shMask = clamp\(1\.0 - smoothstep\(0\.25, 0\.50, t\) - blMask, 0\.0, 1\.0\);/);
assert.match(sksl, /float whMask = smoothstep\(0\.75, 1\.00, t\);/);
assert.match(sksl, /float hlMask = clamp\(smoothstep\(0\.50, 0\.75, t\) - whMask, 0\.0, 1\.0\);/);
// The ramp: five knots, each moved by its own knob and held inside the one
// before it. The 0.50 knot is a literal — nothing may move the midpoint. DR
// moves the same knots, on the toe and the head exactly as it did when it was a
// pair of masked terms (0.12 at t = 0, 0.18 at t = 1) and half of each at the
// knots next to them.
assert.match(sksl, /float a4 = 1\.0 \+ 0\.25 \* wh - dr \* 0\.18;/);
assert.match(sksl, /float a3 = clamp\(0\.75 \+ 0\.25 \* hl - dr \* 0\.09, 0\.5, a4\);/);
assert.match(sksl, /float a1 = clamp\(0\.25 \+ 0\.25 \* sh \+ dr \* 0\.06, 0\.0, 0\.5\);/);
assert.match(sksl, /float a0 = clamp\(0\.25 \* bl \+ dr \* 0\.12, 0\.0, a1\);/);
assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again — it folds the flat stretch at 0.238');
// Straight between the knots, and NOT a smoothstep: an S-curve through the
// knots bends the ramp by six code values in the quarter-tones with every knob
// on zero, and this pass also runs for the stock split tones and for DR alone.
assert.match(sksl, /float lin\(float e0, float e1, float x\) \{\n return clamp\(\(x - e0\) \/ \(e1 - e0\), 0\.0, 1\.0\);\n\}/);
assert.match(sksl, /float o = mix\(a0, a1, lin\(0\.00, 0\.25, t\)\);/);
assert.match(sksl, /o = mix\(o, mix\(a1, 0\.5, lin\(0\.25, 0\.50, t\)\), step\(0\.25, t\)\);/);
assert.match(sksl, /o = mix\(o, mix\(0\.5, a3, lin\(0\.50, 0\.75, t\)\), step\(0\.50, t\)\);/);
assert.match(sksl, /o = mix\(o, mix\(a3, a4, lin\(0\.75, 1\.00, t\)\), step\(0\.75, t\)\);/);
assert.doesNotMatch(sksl, /mix\(a0, a1, smoothstep/, 'the ramp is smoothstepped again');
// The linear-light knee that used to run ahead of all this is GONE from the tone
// pass: HIGHLIGHT is one zone move in both directions now, and a second pass over
// the same knob would double-count it.
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\)\);/);
// The develop's arithmetic. T = 0.7 / S = 1 / (2 (1 - T)) is its pair (S is what
// puts the asymptote on 1.0: T + 1/(2S) = 1).
const knee = (l, T, S) => (l < T ? l : T + (l - T) / (1 + 2 * S * (l - T)));
const T = 0.7;
const S = 1 / (2 * (1 - T));
// Below the knee the frame is untouched, and the curve is continuous and C1 at T
// — slope 1 on both sides — so there is no seam for a later pass to mask.
assert.equal(knee(T - 0.2, T, S), T - 0.2);
assert.equal(knee(T, T, S), T);
const slope = (x) => (knee(x + 1e-6, T, S) - knee(x, T, S)) / 1e-6;
assert.ok(Math.abs(slope(T) - 1) < 1e-3, `seam at T=${T}: slope ${slope(T)}`);
// Monotone, and never a brightening: an inverted pair of pixels is a visible edge.
let prev = -Infinity;
for (let l = 0; l <= 2; l += 1 / 512) {
assert.ok(slope(l) > 0, `inverted at ${l}`);
assert.ok(knee(l, T, S) <= l + 1e-9, `brightened ${l} -> ${knee(l, T, S)}`);
assert.ok(knee(l, T, S) >= prev);
prev = knee(l, T, S);
}
// The asymptote: everything the sensor held above the knee lands under it, on
// exactly 1.0.
assert.ok(Math.abs(knee(1e6, T, S) - (T + 1 / (2 * S))) < 1e-4);
assert.ok(Math.abs(T + 1 / (2 * S) - 1) < 1e-9, 'the develop plateau left 1.0');
// ...and the same pair in the encoded domain, which is the domain the develop
// hands over: mx = 1.0 (the white level) lands on 237, the sensor's own plateau
// (1.93) on 248 — a ramp of a dozen code values where the old fade-to-white left
// nothing above 250 at all. This is the headroom the four tone knobs move.
const enc = (x) => (x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055);
assert.equal(Math.round(enc(knee(1.0, T, S)) * 255), 237);
assert.equal(Math.round(enc(knee(1.93, T, S)) * 255), 248);
// The ramp as arithmetic — the same knots, the same lin() and the same step()
// guards the SkSL above carries, so the shape is measured and not described.
const clamp01 = (x) => Math.min(1, Math.max(0, x));
// Float-exact comparisons are a trap once a value has been through a division
// and a multiply (x / 0.25 * 0.25 is not x) — assert to within a code value.
const close = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-12, `${msg ?? ''} ${a} != ${b}`);
const smoothstep = (e0, e1, x) => {
const u = clamp01((x - e0) / (e1 - e0));
return u * u * (3 - 2 * u);
};
const lin = (e0, e1, x) => clamp01((x - e0) / (e1 - e0));
const step = (edge, x) => (x < edge ? 0 : 1);
const mix = (a, b, t) => a + (b - a) * t;
function ramp(t, k) {
const { dr = 0, hl = 0, sh = 0, wh = 0, bl = 0 } = k;
const blMask = 1 - smoothstep(0, 0.25, t);
const shMask = clamp01(1 - smoothstep(0.25, 0.5, t) - blMask);
const whMask = smoothstep(0.75, 1, t);
const hlMask = clamp01(smoothstep(0.5, 0.75, t) - whMask);
const a4 = 1 + A * wh - dr * 0.18;
const a3 = Math.min(a4, Math.max(0.5, 0.75 + A * hl - dr * 0.09));
const a1 = Math.min(0.5, Math.max(0, 0.25 + A * sh + dr * 0.06));
const a0 = Math.min(a1, Math.max(0, A * bl + dr * 0.12));
let o = mix(a0, a1, lin(0, 0.25, t));
o = mix(o, mix(a1, 0.5, lin(0.25, 0.5, t)), step(0.25, t));
o = mix(o, mix(0.5, a3, lin(0.5, 0.75, t)), step(0.5, t));
o = mix(o, mix(a3, a4, lin(0.75, 1, t)), step(0.75, t));
return { o: clamp01(o), blMask, shMask, hlMask, whMask, maskSum: blMask + shMask + hlMask + whMask };
}
// The tents never overlap — each is the doc's smoothstep minus the tent before
// it, so the four together never count a luma twice — and the middle is the
// quiet part: the ends of the ramp are weighted at 1, the 0.50 midpoint by
// nothing at all. That is what leaves DR and the stock split tones on the two
// ends and the mid-grey still.
for (let t = 0; t <= 1; t += 1 / 512) {
const { maskSum } = ramp(t, {});
assert.ok(maskSum >= -1e-15 && maskSum <= 1 + 1e-15, `masks overlap at ${t}: ${maskSum}`);
if (t <= 0.25 || t >= 0.75) assert.ok(Math.abs(maskSum - 1) < 1e-12, `end of the ramp unweighted at ${t}`);
if (Math.abs(t - 0.5) < 1e-12) assert.equal(maskSum, 0, 'the midpoint is weighted');
}
// The neighbouring tents cross at half weight ON the knot between them, and the
// 0.50 midpoint is where all four are on zero — the quiet value, and the reason
// a mid-grey does not move while the ends do.
assert.equal(ramp(0.125, {}).blMask, 0.5);
assert.equal(ramp(0.125, {}).blMask, ramp(0.125, {}).shMask);
assert.equal(ramp(0.25, {}).shMask, 1);
assert.equal(ramp(0.25, {}).blMask, 0);
assert.equal(ramp(0.875, {}).hlMask, ramp(0.875, {}).whMask);
assert.equal(ramp(0.5, {}).maskSum, 0);
assert.equal(ramp(0.75, {}).hlMask, 1);
// Every knob on zero is EXACTLY the identity — the pass also runs for the stock
// split tones and for DR alone, so a neutral setting must not curve the frame.
for (let t = 0; t <= 1; t += 1 / 256) close(ramp(t, {}).o, t, `identity broke at ${t}`);
// The midpoint is the one value no knob reaches, at any setting.
for (const k of [{ hl: 1, sh: 1, wh: 1, bl: 1 }, { hl: -1, sh: -1, wh: -1, bl: -1 }, { hl: 1, sh: -1, wh: -1, bl: 1 }])
close(ramp(0.5, k).o, 0.5, 'a knob moved the midpoint');
// Monotone under EVERY combination of the four at full deflection, DR included.
// This is the whole reason the knots exist instead of the doc's additive masks,
// which measured a slope of -5 per unit luma on BLACK +1 against SHADOW -1 (an
// inverted band at t = 0.875, scratchpad tone-proto.mjs): every knot is clamped
// inside the one before it, so the ramp cannot fold.
const combos = [];
for (const bl of [-1, 0, 1])
for (const sh of [-1, 0, 1])
for (const hl of [-1, 0, 1])
for (const wh of [-1, 0, 1])
for (const dr of [0, 1]) combos.push({ bl, sh, hl, wh, dr });
let worst = Infinity;
for (const k of combos) {
let prev = null;
for (let t = 0; t <= 1; t += 1 / 512) {
const o = ramp(t, k).o;
if (prev !== null) {
assert.ok(o >= prev - 1e-12, `ramp folded at ${t} for ${JSON.stringify(k)}`);
if (o - prev < worst) worst = o - prev;
}
prev = o;
}
}
assert.ok(worst > -1e-12, `worst step ${worst} — the ramp is folded`);
// A knob moves its own quarter, and only its own: +BLACK takes the toe off the
// floor, +SHADOW puts the 0.25 knot on the midpoint, -HIGHLIGHT pulls the 0.75
// knot onto it, and WHITE - rolls the head under 1.0. That is the reach a
// tonal-range slider has — a quarter of the ramp, so the middle stays a middle.
close(ramp(0, {}).o, 0, 'a neutral toe moved');
close(ramp(0, { bl: 1 }).o, A, 'BLACK no longer reaches a quarter of the ramp');
close(ramp(0.25, { sh: 1 }).o, 0.5, 'SHADOW no longer reaches the midpoint');
close(ramp(0.75, { hl: -1 }).o, 0.5, 'HIGHLIGHT no longer reaches the midpoint');
close(ramp(1, { wh: -1 }).o, 0.75, 'WHITE no longer rolls the head under 1.0');
close(ramp(0.25, {}).o, 0.25, 'a neutral knot moved');
close(ramp(0.75, {}).o, 0.75, 'a neutral knot moved');
// WHITE + is free to pass 1.0 — that is the move that clips a highlight to
// white — and the ramp still runs through a raised knot at 1.25.
assert.ok(1 + A * 1 > 1, 'the white knot can no longer pass 1.0');
close(ramp(1, { wh: 1 }).o, 1, 'a raised white knot left the top of the ramp');
// DR at full is the same curve it was: the toe on 0.12 and the head on 0.82,
// which is what the two masked terms added at t = 0 and t = 1, and the midpoint
// still untouched. Now it is a knot move, so BLACK and SHADOW both at -1 (a flat
// stretch between 0.25 and 0.5, where the old additive lift sloped down and
// folded the ramp at 0.238) stays monotone.
close(ramp(0, { dr: 1 }).o, 0.12, 'DR no longer lifts the toe the way it did');
close(ramp(1, { dr: 1 }).o, 0.82, 'DR no longer rolls the head the way it did');
close(ramp(0.5, { dr: 1 }).o, 0.5, 'DR moved the midpoint');
// Black and shadow both at -1 are the flat stretch DR used to fold: the toe is
// held on the floor by the ordering clamp (BLACK's -0.25 cancels DR's +0.12),
// the 0.25 knot is DR's own +0.06, and the stretch between them is a straight
// line up to the midpoint — never a step down.
assert.equal(ramp(0, { dr: 1, bl: -1, sh: -1 }).o, 0);
assert.equal(ramp(0.25, { dr: 1, bl: -1, sh: -1 }).o, 0.06);
close(ramp(0.375, { dr: 1, bl: -1, sh: -1 }).o, 0.28, 'DR folded the flat stretch');
// The two ends stay ordered even at full deflection against each other: the toe
// can never climb past the head.
for (const bl of [-1, 1])
for (const wh of [-1, 1]) {
const toe = ramp(0, { bl, sh: 1, wh }).o;
const head = ramp(1, { bl, wh, hl: -1 }).o;
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');