Files
RecipesCam/docker/frontend/scripts/highlight-knee-check.mjs
T

517 lines
30 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; SHADOW, whose
// knot is the head of the band above it rather than an end of the ramp, moves
// half that. 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, the partition of the four masks and the
// slope SHADOW leaves the band above it 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';
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');
// 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');
// 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.
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 \* 0\.5 \* 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 doc's ratio (R_new = R_old * Luma_new /
// Luma_old), as ONE shared scale o / t, so the differences move with the light
// and neither the hue nor the saturation goes with them. The caps are what make
// it fit — applying the ratio past the ceiling clips a channel outright and the
// hue goes with it (a skin tone at 24.0° came back at 48.0° at HIGHLIGHT +100,
// scratchpad hl-variants.mjs) — and the scale is held at 1.0 only below
// t = 0.0004, where the ratio would multiply a near-black pixel's cast by
// whatever pedestal BLACK has just lifted.
assert.match(maths, /float k = t > 0\.0004 \? o \/ t : 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(maths, /float k = 1\.0;/, 'the chroma-constant scale came back — a shadow lift drains the colour');
// 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 * 0.5 * 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, -HIGHLIGHT pulls the 0.75 knot onto the midpoint, 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. SHADOW is the exception and moves HALF of
// it: its knot is the HEAD of the 0.25..0.50 band, so a whole quarter would draw
// that band flat, which is the wash-out the knob was reported for.
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.375, 'SHADOW no longer stops halfway to the midpoint');
close(ramp(0.25, { sh: -1 }).o, 0.125, 'SHADOW no longer stops halfway to the floor');
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 over SHADOW's half-anchor -0.125, 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.185);
close(ramp(0.375, { dr: 1, bl: -1, sh: -1 }).o, 0.3425, 'DR folded the flat stretch');
// The band above SHADOW is the one that pays for its lift, and HALF its slope is
// the floor the fix is. Measured on a real frame (DSCF1701, 21% of its pixels in
// that band) with SHADOW +90, a whole anchor left 0.10 of the band's own spread
// where half leaves 0.55 — see the note on a1 in toneShader.ts and the sweep in
// scratchpad sh-band.mjs. Swept here over the knob's whole travel: neither
// quarter of the ramp SHADOW touches may be drawn flatter than half its slope,
// and neither may be stretched past one and a half, which is the same defect
// upside down (a crush that flattens the darks into one black).
for (let sh = -1; sh <= 1.0001; sh += 1 / 64) {
const below = (ramp(0.25, { sh }).o - ramp(0, { sh }).o) / 0.25;
const band = (ramp(0.5, { sh }).o - ramp(0.25, { sh }).o) / 0.25;
assert.ok(band >= 0.5 - 1e-12, `SHADOW drew the band above it flat at ${sh}: slope ${band}`);
assert.ok(below >= 0.5 - 1e-12, `SHADOW drew the quarter below it flat at ${sh}: slope ${below}`);
assert.ok(band <= 1.5 + 1e-12, `SHADOW stretched the band above it at ${sh}: slope ${band}`);
}
// The film stocks ride the same knot, so a halved SHADOW would have halved their
// crush with it. They are written at DOUBLE for that reason, and the look they
// were tuned to is the knot, not the unit: these three land where they always
// did (0.18 Classic Chrome/Vivid, 0.22 Acros, 0.17 Acros HC).
const filmTone = tone.match(/const FILM_TONE[\s\S]*?\n};/)?.[0];
assert.ok(filmTone, 'FILM_TONE is gone — the stocks no longer shape the ramp at all');
// The keys are quoted or not depending on whether they are identifiers, so the
// quotes come off before the lookup.
const filmFlat = filmTone.replace(/['"]/g, '');
for (const [name, sh, knot] of [
['classic-chrome', -0.56, 0.18],
['classic-vivid', -0.56, 0.18],
['monochrome', -0.24, 0.22],
['mono-high-contrast', -0.64, 0.17],
]) {
assert.ok(
filmFlat.includes(`${name}: { sh: ${sh}`),
`${name} is not on the doubled ${sh} — the stock's crush moved with the knob's reach`
);
close(ramp(0.25, { sh }).o, knot, `${name}'s shadow knot moved`);
}
// 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 o / t the cube then gets to pull back.
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 = t > 0.0004 ? o / t : 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 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;
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 RIDES THE RATIO: where the cube has room the channel differences
// come out multiplied by the one scale o / t. That is what keeps the saturation —
// an HSL saturation is a ratio of differences and a common scale never touches it
// — and the hue along with it, which is the report behind this move: held at
// k = 1.0 (the chroma carried unchanged) a dark red came back at 0.505 of
// saturation from 0.746 with SHADOW at +100, and at 0.370 with SHADOW and BLACK
// both, which is a colour going grey under a lift.
for (const [rgb, knobs] of [
[[0.7, 0.55, 0.45], { hl: 0.5 }],
[[0.35, 0.12, 0.08], { sh: 1, bl: 1 }],
]) {
const lifted = rebuild(rgb, knobs);
const grew = (lifted.clamped[0] - lifted.clamped[1]) / (rgb[0] - rgb[1]);
assert.ok(Math.abs(lifted.o / lifted.t - 1) > 1e-3, `the lift left the ramp alone on ${rgb} — the case proves nothing`);
close(grew, lifted.o / lifted.t, `the chroma did not ride the ratio on ${rgb} at ${JSON.stringify(knobs)}`);
}
// 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');