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

701 lines
41 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 four tonal knobs 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.
//
// THE TONE PASS moves the luma by the four knobs (HIGHLIGHT, SHADOW, WHITE,
// BLACK), and each one is the MOVE ITS OWN LIGHTROOM EXPORT MADE, measured:
// TONE_KNOBS in toneShader.ts holds, per knob and per side of zero, the knot
// table of that move on the encoded luma. They are NOT the doc's four compact
// windows any more — the windows were confined to a quarter of the ramp each and
// were worth a few hundredths of luma inside it and the exact identity outside,
// which is the "sau khi chỉnh sửa 4 thông số này không đúng, không thay đổi"
// report: WHITE ±100 was a literal no-op. The reference exports say something
// else: WHITE +100 takes everything above luma 0.50 to 1.0 (mean +0.43 over the
// frame), BLACK -100 takes everything below 0.21 to 0.0 (mean -0.14), HIGHLIGHT
// +100 is a +0.24 lift peaking on luma 0.18 and dead by 0.72.
//
// The four are applied ONE AFTER THE OTHER (BLACK -> SHADOW -> HIGHLIGHT ->
// WHITE), each clamped on the way to the next, so a composition of monotone maps
// is monotone by construction and the ramp needs no guard whatever the four
// sliders say — and the knot tables are cut so that L + move is non-decreasing on
// every segment, which the sweep below measures rather than asserts.
//
// Both are SkSL, so the SHAPE is pinned on the source; the tables are then
// re-run here as a twin and checked against the measurement they were FITTED to
// (the nine exports in the repo root), so a table that drifts off the export is a
// red check. The frame-through-the-real-shader diff lives in the scratchpad
// harness render.mjs, which needs the nine JPEGs.
//
// node scripts/highlight-knee-check.mjs
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import ts from 'typescript';
const read = (p) => readFileSync(new URL(p, import.meta.url), 'utf8');
const develop = read('../src/engine/rawDevelop.ts');
const tone = read('../shared/utils/toneShader.ts');
// The tone shader is IMPORTED, transpiled, so the numbers below are the ones the
// shader is built with and not a regex's reading of them.
const transpile = (path) =>
ts.transpileModule(read(path), {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const dir = mkdtempSync(join(tmpdir(), 'highlight-knee-check-'));
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
writeFileSync(
join(dir, 'toneShader.mjs'),
transpile('../shared/utils/toneShader.ts').replace(
/^import .*from ['"]\.\/colorUtils['"];$/m,
'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";',
),
);
const {
TONE_SKSL: sksl,
TONE_MATH_SKSL: maths,
EXPOSURE_SKSL,
TONE_KNOBS,
TONE_BLACK_EDGE,
TONE_BASE_RADIUS,
TONE_BASE_SIGMA,
toneUniformArray,
} = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href);
const text = (s) => new RegExp(s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const codeOf = (s) => s.replace(/\/\/[^\n]*/g, '');
// THE FOUR KNOBS, as the source declares them. Each curve is a knot table on the
// encoded luma: positions ascending from the floor to the head, values the move
// at +100 (the minus side is read with an amount below zero).
const KNOBS = ['bl', 'sh', 'hl', 'wh'];
for (const k of KNOBS) {
for (const side of ['plus', 'minus']) {
const c = TONE_KNOBS[k][side];
assert.equal(c.p.length, c.v.length, `${k}.${side}: ${c.p.length} knots against ${c.v.length} values`);
assert.equal(c.p[0], 0, `${k}.${side} does not start on the floor`);
assert.equal(c.p[c.p.length - 1], 1, `${k}.${side} does not end on the head`);
for (let i = 1; i < c.p.length; i++)
assert.ok(c.p[i] > c.p[i - 1], `${k}.${side} knot ${i} is not past the one before it`);
}
}
// One knob as the shader gets it: the table drawn STRAIGHT between the knots — a
// hinge per segment, written as a sum of clamps because SkSL indexes arrays by
// constant only — scaled by the amount, added to the luma it reads and clamped.
// The side of zero picks the table, and the clamp is what makes a -- knob's own
// clip a plateau instead of a fold.
for (const k of KNOBS)
assert.match(
maths,
text(`float tone${k}(float L, float amt) {\n return clamp(L + (amt >= 0.0 ? amt * (`),
`${k} is not the measured table read as a polyline`
);
assert.match(maths, /: -amt \* \(/);
// ...pinned at the SOURCE's own numbers: the hinge from knot 0.045 to knot 0.185
// on BLACK's plus side is worth 0.1155 - 0.0876, over a segment 0.140 wide.
assert.match(maths, text(' + (0.0279) * clamp((L - 0.045) / 0.140, 0.0, 1.0)'));
// The composition, move for move: the four in the order the exports were read,
// each self-clamping (tone* does it), so a knob that has driven the luma to an
// end leaves the ones after it nothing to fold.
assert.match(
maths,
text('float toneCurve(float L, float bl, float sh, float hl, float wh) {\n L = tonebl(L, bl);\n L = tonesh(L, sh);\n L = tonehl(L, hl);\n L = tonewh(L, wh);\n return clamp(L, 0.0, 1.0);\n}')
);
// ...and the four COMPACT WINDOWS the knobs used to be are gone from the CODE —
// they were the "không thay đổi" bug. The prose is not read for these:
// TONE_MATH_SKSL still tells the story of what replaced them.
assert.doesNotMatch(codeOf(maths), /toneBlackW|toneBlack\(|toneShadowW|toneHighW|toneWhiteW|toneBump|holdLo|holdHi/);
assert.doesNotMatch(
tone,
/export const TONE_WHITE_EDGE|export const TONE_BLACK_LIFT|export const TONE_BLACK_CRUSH|export const TONE_HIGH_GAIN|export const TONE_WHITE_GAIN|export const TONE_ANCHOR|export const TONE_BUMP_SLOPE/,
'a window edge or rate is still declared'
);
// The BLACK knob's Hunt chroma mask is the one thing that still reads the doc's
// 0.18 — it is a chroma floor, not a knob window.
assert.match(maths, new RegExp(text(`float uBl = clamp(1.0 - effBase / ${TONE_BLACK_EDGE}, 0.0, 1.0);`)));
const knobBlock = maths.slice(maths.indexOf('float tonebl'), maths.indexOf('float toneCurve'));
assert.ok(knobBlock.includes('float tonewh'), 'the four knob functions are gone');
assert.doesNotMatch(
knobBlock,
/smoothstep|pow\(|exp\(/,
'a knob is not the measured table any more — it bends again'
);
// 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(effBase, 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(codeOf(sksl), /float a4 = /, 'the ramp is back inside the pass — one copy, not two');
// 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 = [...sksl.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}`);
// 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');
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. These weight the stock SPLIT TONES now — the
// four knobs no longer ride them, they ride their own measured curves (TONE_KNOBS).
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 MEASURED
// curves are applied one after the other, each self-clamping via its own tone*()
// helper, 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. No
// linear-light knee runs ahead of them either — CURVE_SKSL is a different pass.
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\(/, 'the linear-light knee ahead of the knobs came back');
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 = clamp\(o \+ \(t - effBase\), 0\.0, 1\.0\);/);
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, /vec3 res = lightMove\(c, t, target\);/);
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 safeT = max\(t, 0\.005\);/);
assert.match(maths, /float k = t > 0\.0004 \? min\(o \/ safeT, 3\.5\) : 1\.0;/);
assert.match(maths, /if \(hiC > t\) k = min\(k, \(1\.0 - o\) \/ max\(hiC - t, 0\.0001\)\);/);
assert.match(maths, /if \(loC < t\) k = min\(k, o \/ max\(t - loC, 0\.0001\)\);/);
assert.match(maths, /return clamp\(vec3\(o\) \+ \(c - vec3\(t\)\) \* k, 0\.0, 1\.0\);/);
assert.match(maths, /vec3 res = lightMove\(c, t, target\);/);
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 ramp as arithmetic — the same tables, the same order and the same clamps
// the SkSL above carries, so the shape is measured and not described.
//
// ONE knob's curve, as KNOB_SKSL emits it: the table drawn straight between the
// knots, scaled by the amount, added to the luma it reads and clamped.
const poly = (c, L) => {
let v = c.v[0];
for (let i = 1; i < c.p.length; i++)
v += (c.v[i] - c.v[i - 1]) * clamp((L - c.p[i - 1]) / (c.p[i] - c.p[i - 1]), 0, 1);
return v;
};
const knob = (L, k, amt) =>
clamp01(L + (amt >= 0 ? amt * poly(TONE_KNOBS[k].plus, L) : -amt * poly(TONE_KNOBS[k].minus, L)));
// toneCurve, move for move, with the clamp the shader puts after each one:
// BLACK, then SHADOW, then HIGHLIGHT, then WHITE. `afterBlack` is the luma the
// SHADOW move reads — the value a stock's own SHADOW cannot drag, because BLACK
// runs before it.
function ramp(t, k = {}) {
const { dr = 0, hl = 0, sh = 0, wh = 0, bl = 0 } = k;
const afterBlack = knob(t, 'bl', clamp(bl + dr * 0.12, -1, 1));
const o = knob(
knob(knob(afterBlack, 'sh', clamp(sh + dr * 0.06, -1, 1)), 'hl', clamp(hl - dr * 0.09, -1, 1)),
'wh',
clamp(wh - dr * 0.18, -1, 1)
);
return { o, afterBlack };
}
// THE MEASUREMENT THE TABLES WERE FITTED TO. Nine genuine Lightroom Classic
// 14.2 exports of Main.jpg — the frame at the repo root — each knob at +100 and
// at -100, the per-pixel luma difference against Main.jpg binned by base luma and
// averaged. The row is that move at nine luma positions, off the dense grid the
// tables were cut from (0.008 of luma is the worst the fit is allowed anywhere);
// the tolerance below is 0.01, a little over a code value at ±100.
const MEASURED_L = [0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7];
const MEASURED = {
'hl+': [0.22, 0.2228, 0.2357, 0.2323, 0.1607, 0.1011, 0.078, 0.0452, 0.0108],
'hl-': [-0.0201, -0.0363, -0.0475, -0.0589, -0.0701, -0.0808, -0.1298, -0.1627, -0.1461],
'sh+': [0.0286, 0.0424, 0.0544, 0.066, 0.0716, 0.0899, 0.137, 0.1651, 0.1733],
'sh-': [-0.0434, -0.0874, -0.1285, -0.1583, -0.1359, -0.0912, -0.0734, -0.0473, -0.012],
'wh+': [0.2036, 0.2901, 0.3714, 0.4422, 0.51, 0.5623, 0.4998, 0.3999, 0.3022],
'wh-': [-0.0019, -0.0038, -0.0058, -0.0085, -0.0123, -0.0203, -0.03, -0.0461, -0.0609],
'bl+': [0.0905, 0.1049, 0.1132, 0.1151, 0.099, 0.0843, 0.0702, 0.052, 0.0328],
'bl-': [-0.0501, -0.0997, -0.1498, -0.1983, -0.2429, -0.2247, -0.1574, -0.1141, -0.0773],
};
for (const [name, want] of Object.entries(MEASURED)) {
const k = name.slice(0, 2);
const amt = name.endsWith('+') ? 1 : -1;
want.forEach((move, i) => {
const got = knob(MEASURED_L[i], k, amt);
assert.ok(
Math.abs(got - clamp01(MEASURED_L[i] + move)) <= 0.01,
`${name} at luma ${MEASURED_L[i]}: the table moves to ${got.toFixed(4)}, the export to ${(clamp01(MEASURED_L[i] + move)).toFixed(4)}`
);
});
}
// The film stocks ride these same curves, so their numbers are their own. Each
// stock is written against a toe (the ramp's reading at luma 0.25) and a head
// (its reading at 0.75) — 0.18 / 0.22 / 0.17 and 0.7375 / 0.815 — and the
// measured tables put them at 0.1792 / 0.2126 / 0.1735 and 0.7397 / 0.8151. The
// Acros toe lands 0.0074 under its target because the HIGHLIGHT move reaches down
// to luma 0.22, so its shoulder lands on the toe as well; B&W HIGH CONTRAST is at
// FULL SHADOW deflection because the WHITES lift it asks for raises the darks by
// 0.074 at that luma, and 0.1735 is then the floor. It is the READ values that
// are pinned, next to the source they come from, 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, src, kn, toe, head] of [
['classic-chrome', 'sh: -0.47', { sh: -0.47 }, 0.1791844],
['classic-vivid', 'sh: -0.47', { sh: -0.47 }, 0.1791844],
['monochrome', 'sh: -0.20, hl: -0.1143', { sh: -0.2, hl: -0.1143 }, 0.2126235, 0.7396688],
['mono-high-contrast', 'sh: -1.0, wh: 0.26', { sh: -1.0, wh: 0.26 }, 0.1734727, 0.8150685],
]) {
assert.ok(
filmFlat.includes(`${name}: { ${src} }`),
`${name} is not on the ${src} its ends were solved at`
);
near4(ramp(0.25, kn).o, toe, `${name}'s shadow knot moved`);
if (head) near4(ramp(0.75, kn).o, head, `${name}'s highlight knot moved`);
}
// Non-decreasing on EVERY segment, for every amount in [-1, 1] — not just at the
// ±100 the exports were sampled at. A fold in a tone curve is a band the eye
// reads, and a knob's own shape is what has to rule it out: the tables are cut so
// L + move never turns back.
for (const k of KNOBS)
for (let amt = -1; amt <= 1.0001; amt += 1 / 64) {
let prev = -Infinity;
for (let i = 0; i <= 4000; i++) {
const o = knob(i / 4000, k, amt);
assert.ok(o >= prev - 1e-12, `${k} at ${amt.toFixed(4)} folds the ramp at luma ${i / 4000}`);
prev = o;
}
}
// 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 i = 0; i <= 2000; i++) exact(ramp(i / 2000, {}).o, i / 2000, `identity broke at ${i / 2000}`);
// The ends of the ramp are where the MEASUREMENT put them, and nowhere else. The
// floor is the one that surprises: HIGHLIGHT +100 lifts a true black by 0.084 —
// the biggest move any knob makes on luma 0 — while BLACK +100, the knob named
// for it, is worth 0.0042 there; and the head moves under WHITES -100 alone
// (-0.10 on a true white, which is why its table does not tail off to zero).
// Nothing may drag an end further than the export did.
for (const k of KNOBS)
for (const side of ['plus', 'minus']) {
const c = TONE_KNOBS[k][side];
const amt = side === 'plus' ? 1 : -1;
near4(knob(0, k, amt), clamp01(c.v[0]), `${k}.${side} floor travel`);
near4(knob(1, k, amt), clamp01(1 + c.v[c.v.length - 1]), `${k}.${side} head travel`);
}
for (const k of KNOBS)
for (const side of ['plus', 'minus']) {
const c = TONE_KNOBS[k][side];
assert.ok(Math.abs(c.v[0]) <= 0.1, `${k}.${side} drags the floor by ${c.v[0]} — past what any export did`);
assert.ok(Math.abs(c.v[c.v.length - 1]) <= 0.1, `${k}.${side} drags the head by ${c.v[c.v.length - 1]} — past what any export did`);
}
// The two landmarks the whole change is measured by, read at the export's own
// numbers: WHITES +100 puts everything above luma 0.50 on 1.0 — it rounds to the
// ceiling from there up, which is the "+0.43 mean" a user sees as the frame
// opening — and BLACKS -100 puts everything below 0.21 on 0.0 (under a code
// value), the crushed toe.
for (let L = 0.5; L <= 1.0001; L += 1 / 512)
assert.equal(Math.round(knob(L, 'wh', 1) * 255), 255, `WHITES +100 leaves luma ${L} short of the ceiling`);
for (let L = 0; L <= 0.2; L += 1 / 512)
assert.ok(knob(L, 'bl', -1) < 0.006, `BLACKS -100 leaves luma ${L} above the floor`);
// On a mid-grey the four are all live — the old windows left the 0.50 midpoint to
// nothing but the two ends, which is the "không thay đổi" the report is about.
for (const k of KNOBS) {
assert.ok(Math.abs(knob(0.5, k, 1) - 0.5) > 0.01, `${k} +100 does nothing on a mid-grey`);
assert.ok(Math.abs(knob(0.5, k, -1) - 0.5) > 0.001, `${k} -100 does nothing on a mid-grey`);
}
// ...and no combination of the four at full deflection can drive an end of the
// ramp off the cube, fold the ramp back on itself, or cross the toe over the
// head — the composition is monotone by construction, and this is that
// construction run over all 162 combinations.
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 });
for (const k of combos) {
const lo = ramp(0, k).o;
const hi = ramp(1, k).o;
assert.ok(lo >= 0 && hi <= 1, `the ramp left the cube at ${JSON.stringify(k)}`);
assert.ok(lo <= hi, `the toe ${lo} climbed over the head ${hi} at ${JSON.stringify(k)}`);
let prev = -Infinity;
for (let i = 0; i <= 2000; i++) {
const o = ramp(i / 2000, k).o;
assert.ok(o >= prev - 1e-12, `the ramp folds at ${i / 2000} with ${JSON.stringify(k)}`);
prev = o;
}
}
// 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 ? Math.min(o / Math.max(t, 0.005), 3.5) : 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 / Math.max(t - loC, 0.0001));
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 whatever slope the curve has
// over it — 1.2347 of its own spread at this deflection,
// the SHADOW +100 table steepening through 0.26..0.50.
// 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.
//
// The base is the whole point of the second reading: what SHADOW does to a
// frame's texture is a property of the neighbourhood, not of the pixel. (The
// spread here is kept WHOLE because 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]);
// Read at the PIXEL the band takes the ramp's own slope where it sits and comes
// back at 1.2347 of its own spread: a lift that steepens the ramp over the band
// draws the band longer, which is the global move and not this pass's.
near4(spread(movedGlobally) / spread(band), 1.2347, 'the pixel-read ramp no longer draws its own band longer');
const bandGain = ramp(bandBase, { sh: 1 }).o / bandBase;
assert.ok(bandGain > 1.1, `the lift is not worth measuring: gain ${bandGain}`);
// Read at the BASE the whole band takes ONE move — the same lift on every pixel
// of it — so its spread comes out exactly whole and the texture is untouched.
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',
);
// 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 = EXPOSURE_SKSL;
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');