Files
RecipesCam/docker/frontend/scripts/highlight-knee-check.mjs
T
3dtours e4f5407c19 web: each of the four tonal knobs moves its own band of the ramp and not the guard the four used to share
BLACK, SHADOW, HIGHLIGHT and WHITE were four bumps summed into the identity,
and the sum carried a guard: the two bumps of a half shared a slope, so past a
total of 1 the curve folded backwards, and the ceiling that stopped it was
shared by the amplitudes of a half. A stock already sitting on SHADOW therefore
took BLACK's lift down with it — on the monochrome stock (sh = -0.24) BLACK at
-100 came back with 0.663 of the travel the knob has on its own, which is the
"kéo theo sự thay đổi của thông số khác" report exactly.

thay_doi_thong_so_giong_lightroom.md section 2 asks for four WINDOWS instead:
each knob owns a compact band of the ramp and is exactly zero outside it, and
the four moves are applied ONE AFTER ANOTHER rather than summed. A composition
of monotone maps is monotone by construction, so it needs no guard, and each
knob then measures 1.00 of its travel on every stock. BLACKS is the doc's toe —
u = clamp(1 - L/0.18, 0, 1) cubed, opened by sqrt(L) - L at 0.7 and deepened by
0.85, both of which are exactly zero at L = 0, so (0,0,0) stays (0,0,0): the
grey pedestal that BLACK +100 left on a black was the sum adding its bump's
height at the black point, which is the doc's own "Milky / Foggy". SHADOWS is
the doc's bell over the deep tones, HIGHLIGHTS the bell over the bright ones,
WHITES the doc's Hermite on the shoulder from 0.80. The ramp keeps its two
anchors — 0.00 and 1.00 — at every setting of the four knobs.

One deliberate departure from the doc: HIGHLIGHT carries a (1 - L) the doc's raw
knee does not, because pow(L - 0.5, 1.5) added to L overshoots the cube above
0.94 — 17% of the ramp driven to flat white at +100 before the clamp. Read
against the headroom that is left, the move is zero at L = 1 by construction and
the head rolls instead of clipping.

The windows are read in the sRGB-encoded luma this file already works in, not in
linear light as the doc's section 1 sets out: the doc's own boundaries (0.18,
0.05..0.45, 0.55..0.95, 0.80) land as perceptual positions there, and moving the
whole renderer to the linear domain is a bigger change than this pass. The
divergence is the one the scratchpad compat doc already warns the Android port
about, and it is noted at the windows themselves.

Checked: `tsc --noEmit` clean; `highlight-knee-check.mjs`, `tone-base-check.mjs`
and `mask-wb-check.mjs` updated to the four windows and passing; the twin ramp
over a 1/512 grid is monotone to -0.00119 (0.30 code values, at t = 0.098 with
every knob at full negative), both anchors hold for every combination, and a
knob outside its band is the exact identity.
2026-10-02 08:11:32 +07:00

809 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
// and each one owns a COMPACT WINDOW of the ramp — a black toe that dies on 0.18,
// a shadow bell over the deep tones, a highlight bell over the bright ones, a
// white shoulder from 0.80 — so each is exactly the identity outside its own
// stretch and the four are disjoint, which is what makes them independent
// without a guard. The four moves are applied ONE AFTER THE OTHER
// (BLACK -> SHADOW -> HIGHLIGHT -> WHITE), each clamped on the way to the next,
// and every one of them lands on 0.0 and 1.0 without moving either: a composition
// of monotone maps is monotone by construction, and the two ends of the ramp are
// FIXED POINTS of the whole thing whatever the sliders say. There is no fixed
// midpoint any more — the shadow window spans the middle, so a mid-grey moves
// with SHADOW (0.510636 at +100) where the old sum of bumps left it alone.
//
// 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
// slopes SHADOW and HIGHLIGHT leave the quarters around them 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: the six window edges and
// rates are interpolated, so the template has to be resolved before it can be
// matched — and read off the SOURCE, so the numbers here are the ones the shader
// is built with.
const num = (re) => {
const m = tone.match(re);
assert.ok(m, 'missing ' + re);
return Number(m[1]);
};
const {
TONE_BLACK_EDGE, TONE_WHITE_EDGE, TONE_BLACK_LIFT, TONE_BLACK_CRUSH, TONE_HIGH_GAIN, TONE_WHITE_GAIN,
} = Object.fromEntries(
['TONE_BLACK_EDGE', 'TONE_WHITE_EDGE', 'TONE_BLACK_LIFT', 'TONE_BLACK_CRUSH', 'TONE_HIGH_GAIN', 'TONE_WHITE_GAIN']
.map((name) => [name, num(new RegExp(`export const ${name} = ([0-9.]+);`))]),
);
// The two edges are the doc's and the ramp is drawn in quarters of a code value
// around them: the toe window must die inside the first quarter of the ramp and
// the shoulder must not start below the middle, or a knob reaches past its zone.
assert.ok(TONE_BLACK_EDGE <= 0.25, `TONE_BLACK_EDGE ${TONE_BLACK_EDGE} is wider than a quarter of the ramp`);
assert.ok(TONE_WHITE_EDGE >= 0.5, `TONE_WHITE_EDGE ${TONE_WHITE_EDGE} starts under the middle 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_[A-Z_]+\}/g) ?? []).length, 7, 'a window edge or a rate is pinned to a literal, not to its TONE_ constant');
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, baseLuma\(xy\), bl, sh, hl, wh, dr\);/);
// The BASE layer the ramp is drawn through. It is ONE tap of a blurred child,
// and the blur is the caller's (blurredBase in exportEngine.ts) — a ring of point
// samples in here was the mottle bug: the luma aliased on a textured frame, the
// gain o(base)/base carried the alias, and the reconstruction painted it back.
// So the shader must read `base` once and must NOT grow a sampling loop again,
// and `bx` — the step only a loop ever needed — must stay gone.
assert.match(tmpl, /float baseLuma\(vec2 xy\) \{/);
assert.match(tmpl, /vec3 s = clamp\(base\.eval\(xy\)\.rgb, 0\.0, 1\.0\);/);
assert.match(tmpl, /uniform shader base;/);
assert.doesNotMatch(tmpl, /uniform float2 bx;/, 'the base is a sampling loop again — that is what mottled');
assert.doesNotMatch(tmpl, /baseLuma\(xy, t\)/, 'baseLuma grew its neighbourhood back');
// One tap of the base is a FRACTION of the frame, so the preview and the file
// look at the same neighbourhood: the pass has the frame size and turns it into
// the blur's sigma.
assert.match(tone, /export const TONE_BASE_RADIUS = ([0-9.]+);/);
const baseRadius = Number(tone.match(/export const TONE_BASE_RADIUS = ([0-9.]+);/)[1]);
assert.ok(baseRadius >= 0.02 && baseRadius <= 0.05, `the base reads ${baseRadius} of the frame — the doc asks for 2%..5%`);
assert.match(tone, /export const TONE_BASE_SIGMA = ([0-9.]+);/);
const baseSigma = Number(tone.match(/export const TONE_BASE_SIGMA = ([0-9.]+);/)[1]);
assert.ok(baseSigma > 0 && baseSigma <= 0.5, `TONE_BASE_SIGMA ${baseSigma} is not a sigma under the radius`);
const engine = readFileSync(new URL('../src/engine/exportEngine.ts', import.meta.url), 'utf8');
assert.match(
engine,
/const sigma = width \* TONE_BASE_RADIUS \* TONE_BASE_SIGMA;\s*\n\s*const base = own\(blurredBase\(baseShaderOf, width, height, sigma\)\);/,
'the tone pass no longer blurs a frame-sized base'
);
assert.match(
engine,
/effect\.makeShaderWithChildren\(toneUniformArray\(tone\), \[\s*baseShaderOf\(\),\s*base \? own\(imageShaderChild\(base\)\) : baseShaderOf\(\),\s*\]\)/,
'the blurred base is not handed to the tone pass as its second child'
);
assert.match(
engine,
/function blurredBase\([\s\S]*?Skia\.ImageFilter\.MakeBlur\(sigma, sigma, Skia\.TileMode\.Clamp, null\)/,
'the base is no longer Skia’s own blur'
);
assert.match(engine, /getToneUniforms\(adjustments, recipe\.baseFilter\)/, 'the tone pass still steps a sampling ring by hand');
// The uniform block: the shader's declarations, arrays expanded and in
// declaration order, have to be the numbers `toneUniformArray` writes — a
// mismatch is a silent off-by-one down the whole block.
const declared = [...tmpl.matchAll(/uniform (float2|float) (\w+)(?:\[(\d+)\])?;/g)].reduce(
(n, [, kind, , len]) => n + (len ? Number(len) : kind === 'float2' ? 2 : 1),
0
);
const arrayFn = tone.match(/export function toneUniformArray\(u: ToneUniforms\): number\[\] \{\n return \[([\s\S]*?)\n \];/)?.[1];
assert.ok(arrayFn, 'toneUniformArray is gone');
const written = arrayFn
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.reduce((n, s) => n + (s.startsWith('...u.hsl') ? 8 : 1), 0);
assert.equal(written, declared, `toneUniformArray writes ${written} floats, the pass declares ${declared}`);
const resolve = (s) =>
s
.replace('${TONE_MATH_SKSL}', mathTmpl)
.replaceAll('${TONE_BLACK_EDGE}', String(TONE_BLACK_EDGE))
.replaceAll('${TONE_WHITE_EDGE}', String(TONE_WHITE_EDGE))
.replaceAll('${TONE_BLACK_LIFT}', String(TONE_BLACK_LIFT))
.replaceAll('${TONE_BLACK_CRUSH}', String(TONE_BLACK_CRUSH))
.replaceAll('${TONE_HIGH_GAIN}', String(TONE_HIGH_GAIN))
.replaceAll('${TONE_WHITE_GAIN}', String(TONE_WHITE_GAIN));
const sksl = resolve(tmpl);
const maths = resolve(mathTmpl);
// The four windows, as the resolved maths emits them: each is compactly
// supported on its own stretch and ZERO outside it — a knob is the exact
// identity off its own band, which is what the monochrome stock's BLACK travel
// rides on (see the note in toneShader.ts) — and the black window is zero at
// L = 0 while the white one is zero at L = 1, so the two ends cannot move. The
// edges are matched at the SOURCE's numbers, not at literals of their own.
const text = (s) => new RegExp(s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
assert.match(maths, text(`float toneBlackW(float L) {\n float u = clamp(1.0 - L / ${TONE_BLACK_EDGE}, 0.0, 1.0);`));
assert.match(maths, /return u \* u \* u;\n\}/);
assert.match(maths, text('float toneShadowW(float L) {\n return smoothstep(0.02, 0.12, L) * (1.0 - smoothstep(0.25, 0.55, L));\n}'));
assert.match(maths, text('float toneHighW(float L) {\n return smoothstep(0.45, 0.65, L) * (1.0 - smoothstep(0.92, 1.0, L));\n}'));
assert.match(maths, text(`float toneWhiteW(float L) {\n float u = clamp((L - ${TONE_WHITE_EDGE}) / (1.0 - ${TONE_WHITE_EDGE}), 0.0, 1.0);\n return u * u;\n}`));
// ...and the windows are the ONLY shape: the kernel the four bumps shared, the
// amplitudes they were summed with and the ceiling that guard held them under are
// gone from the CODE. The prose is not read for these — TONE_MATH_SKSL still
// tells the story of the guard it replaced, so a check on the comments would be
// checking the wrong thing.
const codeOf = (s) => s.replace(/\/\/[^\n]*/g, '');
assert.doesNotMatch(codeOf(sksl), /toneBump/, 'the summed kernel is back — the four moves must be sequential');
assert.doesNotMatch(codeOf(sksl), /holdLo|holdHi/, 'the shared ceiling is back — a stock drags another knob with it');
assert.doesNotMatch(tone, /export const TONE_ANCHOR|export const TONE_BUMP_SLOPE/, 'the old anchor and guard constants are still declared');
// The composition, move for move: the black pair (lift through the doc's square
// root, crush by the doc's rate, one or the other — both zero at L = 0, so the
// black point stays the black point and no knob leaves a pedestal), the shadow
// gain, the highlight knee against the headroom that is LEFT (the (1 - L) is the
// one deliberate departure from the doc's raw pow, which overshoots the cube), and
// the white Hermite (1 - L) * L — zero on BOTH ends, so the head cannot move.
assert.match(maths, text('float toneCurve(float L, float bl, float sh, float hl, float wh) {'));
assert.match(maths, text(` q = toneBlackW(L);\n L = bl > 0.0\n ? L + ${TONE_BLACK_LIFT} * bl * q * (sqrt(L) - L)\n : L * max(1.0 + ${TONE_BLACK_CRUSH} * bl * q, 0.0);\n L = clamp(L, 0.0, 1.0);`));
assert.match(maths, text(' q = toneShadowW(L);\n L *= 1.0 + sh * q * pow(1.0 - L, 1.8);\n L = clamp(L, 0.0, 1.0);'));
assert.match(maths, text(` q = toneHighW(L);\n L += ${TONE_HIGH_GAIN} * hl * q * pow(max(L - 0.5, 0.0), 1.5) * (1.0 - L);\n L = clamp(L, 0.0, 1.0);`));
assert.match(maths, text(` q = toneWhiteW(L);\n L += ${TONE_WHITE_GAIN} * wh * q * (1.0 - L) * L;`));
// DR rides the same four moves with a share of each instead of masked terms of
// its own, so it cannot fight a knob over a band or invert the ramp.
assert.match(maths, text('float o = toneCurve(base, clamp(bl + dr * 0.12, -1.0, 1.0), clamp(sh + dr * 0.06, -1.0, 1.0),'));
assert.match(maths, /\n\s*clamp\(hl - dr \* 0\.09, -1\.0, 1\.0\), clamp\(wh - dr \* 0\.18, -1\.0, 1\.0\)\);/);
assert.doesNotMatch(maths, /o = clamp\(base/, 'the ramp clamps the base before the curve again — the ends of the ramp are the anchors');
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\)\);/);
// The mask hands the ramp its OWN pixel as the base, twice over: a shape has no
// neighbourhood of its own, and base == t is a ratio of exactly 1, so what a mask
// does with SHADOW is what it always did. The knob means the same thing on both
// sides of the call; what differs is the neighbourhood, and a mask has none.
assert.match(mask, /c = half3\(toneRamp\(vec3\(c\), lf, 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 composition, and NOT a sum of bumps on the identity: the four moves are
// applied in sequence through their own windows, which is what makes one knob's
// travel independent of another's (the shared ceiling used to take 0.663 of
// BLACK's travel off on the monochrome stock) and the ramp monotone by
// construction rather than by a guard. Nor straight segments between knots: an
// angle in a tone curve is a Mach band — and neither a smoothstep through the
// knots, which bends the ramp by six code values in the quarter-tones with every
// knob on zero, where this pass also runs for the stock split tones and DR alone.
assert.doesNotMatch(sksl, /float blackA = |float whiteA = /, 'the amplitudes are summed again — the moves must be sequential');
assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again — it folds the flat stretch at 0.238');
assert.doesNotMatch(sksl, /float lin\(/, 'straight segments between the knots are back — a knot is an angle in a tone curve');
assert.doesNotMatch(sksl, /clamp\(0\.25 \+ 0\.25/, 'a knob is clamped against a neighbour knot again');
// ...and the pixel rides the neighbourhood's move with its own difference from
// it: Base' + Detail, ADDED and not scaled. Multiplying by the gain o / base is
// what this pass used to do and it takes the detail away exactly where a knob
// takes the base down — at BLACK -100 the picture came back soft, and on a
// monochrome frame (all three channels on the pixel's luma) it came back as the
// blurred base itself. The ramp's own luma is still not what is handed to the
// rebuild — the neighbourhood's is — or the move would be global again and the
// band above SHADOW would be drawn flat, which is the whole bug.
assert.match(sksl, /float target = o \+ \(t - base\);/);
assert.doesNotMatch(sksl, /float target = base > 0\.0004 \? o \* t \/ base : t;/, 'the gain-scaled detail came back — a tone knob softens again');
assert.match(sksl, /return lightMove\(c, t, clamp\(target, 0\.0, 1\.0\)\);/);
assert.doesNotMatch(sksl, /lightMove\(c, t, clamp\(o, 0\.0, 1\.0\)\)/, 'the ramp is read at the pixel again — a global curve');
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\(target, 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 four windows, the same rates and the same
// sequential composition the SkSL above carries, so the shape is measured and
// not described. Each move is monotone for any amount in [-1, 1] and lands on 0
// and 1 without moving either, so the composition is monotone by construction
// and the two ends of the ramp are FIXED POINTS whatever the sliders say.
const clamp01 = (x) => Math.min(1, Math.max(0, x));
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, 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}`);
// The readings below come out of this same arithmetic to five decimals, so the
// tolerance is the rounding of the number written down, well inside the code
// value the cube can see.
const near4 = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-4, `${msg ?? ''} ${a} != ${b}`);
// Exact to the last digit where it matters: a window of zero is not "nearly"
// zero, and that is the claim the independence checks rest on.
const exact = (a, b, msg) => assert.equal(a, b, `${msg ?? ''} ${a} != ${b}`);
const smoothstep = (e0, e1, x) => {
const u = clamp01((x - e0) / (e1 - e0));
return u * u * (3 - 2 * u);
};
// The four windows, as TONE_MATH_SKSL emits them — zero outside their own band.
const toneBlackW = (L) => {
const u = clamp01(1 - L / TONE_BLACK_EDGE);
return u * u * u;
};
const toneShadowW = (L) => smoothstep(0.02, 0.12, L) * (1 - smoothstep(0.25, 0.55, L));
const toneHighW = (L) => smoothstep(0.45, 0.65, L) * (1 - smoothstep(0.92, 1.0, L));
const toneWhiteW = (L) => {
const u = clamp01((L - TONE_WHITE_EDGE) / (1 - TONE_WHITE_EDGE));
return u * u;
};
// toneCurve, move for move, with the clamp the shader puts after each one: BLACK
// (the doc's lift through the square root, or the doc's crush), SHADOW (a gain on
// the light), HIGHLIGHT (the doc's knee against the headroom that is left) and
// WHITE (the Hermite (1 - L) * L). `afterBlack` is the L the shadow move reads —
// the value a stock's own SHADOW cannot drag, because BLACK runs before it.
function tones(t, k) {
const { dr = 0, hl = 0, sh = 0, wh = 0, bl = 0 } = k;
let L = t;
let q = toneBlackW(L);
const blackA = clamp(bl + dr * 0.12, -1, 1);
L = blackA > 0
? L + TONE_BLACK_LIFT * blackA * q * (Math.sqrt(L) - L)
: L * Math.max(1 + TONE_BLACK_CRUSH * blackA * q, 0);
const afterBlack = clamp01(L);
L = afterBlack;
q = toneShadowW(L);
L *= 1 + clamp(sh + dr * 0.06, -1, 1) * q * (1 - L) ** 1.8;
L = clamp01(L);
q = toneHighW(L);
L += TONE_HIGH_GAIN * clamp(hl - dr * 0.09, -1, 1) * q * Math.max(L - 0.5, 0) ** 1.5 * (1 - L);
L = clamp01(L);
q = toneWhiteW(L);
L += TONE_WHITE_GAIN * clamp(wh - dr * 0.18, -1, 1) * q * (1 - L) * L;
return { o: clamp01(L), afterBlack };
}
// ...plus the four ZONE masks, which the ramp does NOT read any more: they are
// the partition the stock split tones ride, one per quarter, each the doc's own
// smoothstep minus the tent before it. They are carried here because the checks
// below measure that partition; `o` is delegated to the shared maths above.
function ramp(t, 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);
return { ...tones(t, k), 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 for the split
// tones, which is all these masks weight. (The RAMP is another matter: nothing
// feeds it from here, and SHADOW's own window spans the middle.)
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}`);
// Neither END of the ramp moves, at any setting: both are fixed points of every
// move above — the black pair is zero at L = 0 and the white pair is zero at
// L = 1 — so no combination of the four sliders can lift the toe or roll the
// head. There is no fixed midpoint any more: SHADOW is a gain on the light and
// its window spans the middle, so at t = 0.5 SHADOW +1 lands on 0.510636 and -1
// on 0.489364, where the old sum of bumps met on zero.
close(ramp(0, { bl: 1 }).o, 0, 'BLACK lifted the toe off the floor');
close(ramp(0, { bl: -1, sh: -1, hl: -1, wh: -1 }).o, 0, 'a knob moved the floor');
close(ramp(1, { wh: 1 }).o, 1, 'WHITE moved the head');
close(ramp(1, { wh: -1 }).o, 1, 'a knob moved the head');
near4(ramp(0.5, { sh: 1 }).o, 0.51064, 'SHADOW no longer spans the middle');
near4(ramp(0.5, { sh: -1 }).o, 0.48936, 'SHADOW no longer spans the middle');
// Monotone under EVERY combination of the four at full deflection, DR included.
// The composition of monotone moves is monotone by construction, but each move is
// only piecewise — a window edge is a slope change — so this is the measurement
// of that. The grid is 1/512 thick, so a step can straddle an edge and backtrack
// inside the width of one cell: the worst measured is -0.00119 at t = 0.0977
// (BLACK -1 with SHADOW -1, the two crush moves meeting under the toe window's
// edge — 0.30 of a code value), and the same sweep at 1/8192 reads -0.0000745,
// so the size of the cell is what the number is. A wider backtrack is a 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;
let worstAt = 0;
let worstCombo = null;
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 && o - prev < worst) {
worst = o - prev;
worstAt = t;
worstCombo = k;
}
prev = o;
}
}
assert.ok(worst >= -2e-3, `worst step ${worst} at ${worstAt} for ${JSON.stringify(worstCombo)} — the ramp is folded`);
// ...and every one of those combinations still lands on the two anchors, exactly.
for (const k of combos) {
exact(ramp(0, k).o, 0, `the toe moved at ${JSON.stringify(k)}`);
exact(ramp(1, k).o, 1, `the head moved at ${JSON.stringify(k)}`);
}
// A knob moves ONLY its own band. The windows are compactly supported, so off
// its own band a knob is the EXACT identity: the monochrome stock's own SHADOW
// (-0.20, or the classic stocks' -0.47) changes nothing where BLACK is working at
// t = 0.01, where the shared ceiling used to take 0.663 of BLACK's travel off that
// stock. That is the "kéo theo sự thay đổi của thông số khác" report, retired.
exact(ramp(0.01, { bl: -1 }).o, ramp(0.01, { bl: -1, sh: -0.47 }).o, 'SHADOW reached into BLACK\'s band');
exact(ramp(0.90, { hl: -1 }).o, ramp(0.90, { hl: -1, sh: 0.47 }).o, 'SHADOW reached into HIGHLIGHT\'s band');
exact(ramp(0.05, { sh: -1 }).o, ramp(0.05, { sh: -1, wh: 1 }).o, 'WHITE reached into SHADOW\'s band');
// The reach of each knob, read off the formula. These are OUTPUT values of the
// ramp (`.o`) to five decimals, so near4 is the rounding of what is written down
// and nothing wider. BLACK - is the doc's crush and + the doc's lift; SHADOW is a
// gain on the light, so it reaches past 0.5 (0.51064 at +100 against 0.48936 at
// -100 — the bell under the window is not symmetric); HIGHLIGHT and WHITE move
// the head and neither clips it.
for (const [knob, sides] of [
['bl', [
[-1, [[0.05, 0.03399], [0.10, 0.09254], [0.15, 0.14941], [0.20, 0.20], [0.50, 0.50]]],
[1, [[0.05, 0.09578], [0.10, 0.11329], [0.18, 0.18]]],
]],
['sh', [
[1, [[0.10, 0.17412], [0.20, 0.33384], [0.50, 0.51064]]],
[-1, [[0.10, 0.02588], [0.20, 0.06616], [0.50, 0.48936]]],
]],
['hl', [
[1, [[0.70, 0.76708], [0.80, 0.88216], [0.90, 0.96325], [0.95, 0.97579]]],
[-1, [[0.70, 0.63292], [0.80, 0.71784], [0.95, 0.92421]]],
]],
['wh', [
[1, [[0.90, 0.93375], [0.95, 0.99008]]],
[-1, [[0.90, 0.86625], [0.95, 0.90992]]],
]],
])
for (const [amount, cases] of sides)
for (const [t, want] of cases)
near4(ramp(t, { [knob]: amount }).o, want, `${knob} at ${amount} on ${t}`);
// Nothing clips inside the ramp: HIGHLIGHT +100 at 0.95 is still under the
// ceiling of the cube, and the only interior value that reaches 1.0 is all four
// at +100 on a pixel already at the head — the anchors are what the clamp holds.
assert.ok(ramp(0.95, { hl: 1 }).o < 1, 'HIGHLIGHT +100 is clipping the head');
exact(ramp(0.99, { bl: 1, sh: 1, hl: 1, wh: 1 }).o, 1, 'a full set of knobs on a bright pixel does not reach white');
// DR rides the same four moves, a share of each (0.12 / 0.06 / -0.09 / -0.18),
// and the two ends are still fixed points: +0.12 of BLACK lifts nothing at
// L = 0 and -0.18 of WHITE rolls nothing at L = 1. The middle moves with the
// SHADOW share DR takes — 0.500638 where the old masked terms summed to a flat
// 0.5 — which is the DR a frame can see.
exact(ramp(0, { dr: 1 }).o, 0, 'DR lifted the toe off the floor');
exact(ramp(1, { dr: 1 }).o, 1, 'DR rolled the head under 1.0');
near4(ramp(0.5, { dr: 1 }).o, 0.50064, 'DR no longer moves the middle the way it did');
// BLACK and SHADOW both at -1 under DR: the two crush moves meet under the toe
// window's edge and the 1/512 cell reads a backtrack there (the monotone sweep
// above), where the old additive terms folded the stretch outright.
exact(ramp(0, { dr: 1, bl: -1, sh: -1 }).o, 0, 'the floor is not the floor');
near4(ramp(0.25, { dr: 1, bl: -1, sh: -1 }).o, 0.10998, 'DR moved off the 0.25 knot');
near4(ramp(0.375, { dr: 1, bl: -1, sh: -1 }).o, 0.28063, 'DR folded the flat stretch');
// A band can only be lifted at the cost of the slope inside it — the report this
// whole design answered — so each knob's own quarters are swept over the whole
// travel and bounded rather than left to a comment. SHADOW's window spans the
// middle, so both its quarters take the move: over the sweep the slope below the
// 0.25 knot reads 1.5958 down to 0.4042 and the band above it 1.5532 down to
// 0.4468, which is the quarter it is allowed to give up (neither may be drawn
// flat, and neither may be stretched past one and a half — a crush that flattens
// the darks into one black is the same defect upside down).
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(below >= 0.40 - 1e-12, `SHADOW drew the quarter below it flat at ${sh}: slope ${below}`);
assert.ok(band >= 0.40 - 1e-12, `SHADOW drew the band above it flat at ${sh}: slope ${band}`);
assert.ok(below <= 1.60 + 1e-12, `SHADOW stretched the quarter below it at ${sh}: slope ${below}`);
assert.ok(band <= 1.60 + 1e-12, `SHADOW stretched the band above it at ${sh}: slope ${band}`);
}
// HIGHLIGHT pays the same way and at BOTH ends — its knot is the head of the top
// quarter, so lifting it draws 0.75..1.00 flat and pulling it draws 0.50..0.75 —
// and its window is the narrower one, so its own spread over the sweep is 0.6875
// to 1.3125 either side of the knot against the half it is allowed.
for (let hl = -1; hl <= 1.0001; hl += 1 / 64) {
const mid = (ramp(0.75, { hl }).o - ramp(0.5, { hl }).o) / 0.25;
const top = (ramp(1, { hl }).o - ramp(0.75, { hl }).o) / 0.25;
assert.ok(mid >= 0.50 - 1e-12, `HIGHLIGHT drew the quarter below it flat at ${hl}: slope ${mid}`);
assert.ok(top >= 0.50 - 1e-12, `HIGHLIGHT drew the top quarter flat at ${hl}: slope ${top}`);
assert.ok(mid <= 1.50 + 1e-12, `HIGHLIGHT stretched the quarter below it at ${hl}: slope ${mid}`);
assert.ok(top <= 1.50 + 1e-12, `HIGHLIGHT stretched the top quarter at ${hl}: slope ${top}`);
}
// The film stocks ride the same windows, so their numbers are their own. The
// knots they were tuned to are 0.18 Classic Chrome/Vivid, 0.22 Acros, 0.17 Acros
// HC, with the head 0.7375 on Acros and 0.815 on Acros HC — and through this
// curve they read 0.179992 / 0.220209 / 0.169565 and 0.7375 / 0.814844, so the
// two Acros toes land 2.1e-4 and 4.3e-4 under the target (the shadow window's own
// bell at 0.25, which the reach checks above have already measured). The entries
// are pinned to the values the ramp is actually read at, so a stock that drifts
// off its look is a red check.
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, hlSrc, hl, head] of [
['classic-chrome', -0.47, 0.17999192],
['classic-vivid', -0.47, 0.17999192],
['monochrome', -0.2, 0.22020933, '-0.16', -0.16, 0.7375],
['mono-high-contrast', -0.54, 0.16956519, '0.83', 0.83, 0.81484375],
]) {
assert.ok(
filmFlat.includes(`${name}: { sh: ${sh}`),
`${name} is not on the ${sh} its crush was solved at`
);
near4(ramp(0.25, { sh }).o, knot, `${name}'s shadow knot moved`);
if (hlSrc) {
assert.ok(filmFlat.includes(`hl: ${hlSrc}`), `${name} is not on the ${hlSrc} its shoulder was solved at`);
near4(ramp(0.75, { hl }).o, head, `${name}'s highlight 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);
}
// The rebuild, with the base layer the shader now draws the ramp through. `base`
// defaults to the pixel itself — the degenerate call, and the one a mask makes —
// which lands `target` back on `o` and is the move this function had before there
// was a base at all. Away from that the pixel's own DIFFERENCE from the base is
// ADDED to the neighbourhood's new luma (Base' + Detail), never scaled by it.
function rebuild(rgb, k, base) {
const t = lumaOf(rgb);
const b = base ?? t;
const o = ramp(b, k).o;
const target = clamp01(o + (t - b));
const out = lightMove(rgb, t, target);
return { out, clamped: out.map((c) => clamp01(c)), o, t, base: b, target };
}
// 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.target / lifted.t - 1) > 1e-3, `the lift left the ramp alone on ${rgb} — the case proves nothing`);
close(grew, lifted.target / lifted.t, `the chroma did not ride the ratio on ${rgb} at ${JSON.stringify(knobs)}`);
}
// THE BASE LAYER. A band with texture in it — SHADOW's own quarter, 0.26 to
// 0.50, at the deflection this was reported at (the full +100, which is +1 here):
//
// read at the pixel every pixel lands on its own o, so the band's spread
// comes out multiplied by the slope the curve has left over
// it — the grey sheet, drawn flat, 0.4163 of its own spread
// at this deflection (the shadow window is a gain whose bell
// is already falling through 0.26..0.50, so the quarter
// above the knot keeps less of itself than a straight
// segment would have kept).
// read at the base every pixel of ONE neighbourhood takes the same move,
// o(base) - base, and its own difference from the base is
// added to it, so the texture inside the region comes out
// at ITS OWN size and the same lift lands on the pixels
// either way.
//
// Those are the two numbers the live probe reads off the deployed bundle (0.57
// before, 0.78 after, over this frame); this is the same claim in arithmetic.
// The spread here is kept WHOLE and not at 0.78 — this twin's band sits on one
// base, where the reconstruction is exact by construction; on a real frame the
// band spans many neighbourhoods and the live number is the one to read.
const band = Array.from({ length: 32 }, (_, i) => 0.26 + 0.24 * (i / 31));
const spread = (xs) => Math.max(...xs) - Math.min(...xs);
const bandBase = band.reduce((a, b) => a + b, 0) / band.length;
const movedGlobally = band.map((t) => rebuild([t, t, t], { sh: 1 }).clamped[0]);
const movedLocally = band.map((t) => rebuild([t, t, t], { sh: 1 }, bandBase).clamped[0]);
near4(spread(movedGlobally) / spread(band), 0.41631, 'the pixel-read ramp no longer draws its own band flat');
const bandGain = ramp(bandBase, { sh: 1 }).o / bandBase;
assert.ok(bandGain > 1.1, `the lift is not worth measuring: gain ${bandGain}`);
close(spread(movedLocally) / spread(band), 1, 'the band lost its texture under the lift — the detail is being scaled again');
assert.ok(
Math.abs(movedLocally[0] - band[0] - (ramp(bandBase, { sh: 1 }).o - bandBase)) < 1e-12,
'the lift is no longer the neighbourhood’s',
);
assert.ok(
spread(movedLocally) / spread(movedGlobally) > 1.5,
`the base is not earning its keep: ${spread(movedLocally) / spread(movedGlobally)}x the global move's spread`
);
// The move belongs to the NEIGHBOURHOOD, not to the pixel: two pixels of one base
// take the same one however far apart they sit, which is exactly what leaves the
// difference between them standing. (Read at the pixel, the move would be the
// pixel's own o - t — the curve where the pixel happens to be.)
for (const [lo, hi] of [[0.28, 0.44], [0.30, 0.48]]) {
const a = rebuild([lo, lo, lo], { sh: 1 }, 0.38).clamped[0] - lo;
const b = rebuild([hi, hi, hi], { sh: 1 }, 0.38).clamped[0] - hi;
close(a, b, 'the move is the pixel’s again, not the neighbourhood’s');
}
// Every knob on zero is the identity through the base path too, whatever base is
// handed in — the ramp at b IS b, so the difference is 0 — and so is a caller
// whose base is its own pixel (bx = 0, the mask, the nine identical taps).
for (const b of [0.01, 0.1, 0.35, 0.7, 0.99])
for (const rgb of [...colourCases, ...greyCases]) {
const g = rebuild(rgb, {}, b);
for (let i = 0; i < 3; i++) close(g.clamped[i], rgb[i], `the base path is not the identity at zero, base ${b}`);
}
// 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');