701 lines
41 KiB
JavaScript
701 lines
41 KiB
JavaScript
// 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');
|