Files
RecipesCam/docker/frontend/scripts/highlight-knee-check.mjs
T
3dtours acbb2bba4b web: the four tonal knobs are sized by what the eye can see, and the highlight head is squared so a bigger rate fits inside the cube
The report was "giá trị thay đổi của các thông số quá nhỏ, không thể hiện được
trên thị giác của ảnh" — at the doc's own rates a full +100 was worth 0.060 of
luma on BLACKS, 0.082 on HIGHLIGHTS and 0.042 on WHITES, and the probe that ran
the frame through the pass read BLACKS +100 moving its mean by 0.0001. Every
rate below is now the largest its own move allows, measured rather than
inherited: 0.058 -> 0.080 on BLACKS, 0.082 -> 0.113 on HIGHLIGHTS and 0.042 ->
0.080 on WHITES, with SHADOWS' 0.151 left where it was because it already bit.

  - `TONE_BLACK_LIFT` 0.7 -> 0.93. The ceiling is a FOLD, not a slope: past
    0.9387 the doc's own square root carries luma backwards inside its window
    (0.94 folds 3.4e-6, 0.95 folds 8.9e-5) and a gradient wears it as a band.
    0.93 is the last round rate under it — monotone on the check's 1/32768
    grid, and the 1e-4 of travel between it and 0.94 is not a code value.
  - `TONE_BLACK_CRUSH` 0.85 -> 8.0. The doc's own form — `L * (1 + amount * W
    * 0.85)` — is bounded by its own window, which is 1 only AT the floor, so
    its whole visible travel at full -100 is 0.019 of luma: five code values on
    a black patch, and a rate past 1 drives the product negative and clips the
    toe to a flat black instead of deepening it. The toe's own EXPONENT,
    `L -> W * (L/W)^(1 + rate * W)`, is monotone for ANY rate and worth 0.054,
    while x = 0 stays on 0 and the 0.18 edge stays on 1 — both anchors and the
    compact support kept.
  - `TONE_HIGH_GAIN` 2.5 -> 14.0, with the head term moved from `(1 - L)` to
    `(1 - L)^2`. The linear headroom dies too slowly to keep the rate's own
    ceiling off the clamp: above a gain worth 2.6 the move overshoots 1.0, the
    clamp draws a plateau and the ramp falls back over it by 0.018 — the fold
    the knee check now measures as a drawdown from the running maximum. Squared,
    the move has died out by the time the ramp reaches the clamp: 14.0 is
    fold-free at both signs and still lands 1.44x of the 1.5x the quarter it
    owns is allowed.
  - `TONE_WHITE_GAIN` 1.5 -> 3.0, which takes the top of the ramp TO the
    ceiling from 0.92 up and leaves the clamp to flatten what is left. That is
    the doc's own §2.4, where a WHITE is the frame's clipping point ("giới hạn
    cháy sáng") and not a Hermite that cannot move the head; it is felt only
    above the 0.80 shoulder and the head is still exactly 1.0 on 1.0.

`FILM_TONE` is re-solved for the squared head, which is worth less at the 0.75
knot for the same rate: monochrome -0.16 -> -0.1143 and mono-high-contrast
+0.83 -> +0.5929. The four knots the stocks are tuned to do not move — 0.22 and
0.7375 on Acros, 0.17 and 0.815 on Acros HC — and the check pins each of them.

The knee check's guard is REPLACED. The old one compared the first cell of the
sweep against the second (a slope at 1/512), which is blind to a fold that
starts later: it passed a ramp whose own drawdown was 0.018. The new one walks
1/32768 of the ramp and measures `running max - value` for each knob at both
signs, over the four moves alone and then over a 243-combination sweep, so what
is pinned is the fold itself and where it is.

Two folds are pinned rather than removed, both named in the check:

  - SHADOWS -100 dips 0.018 (4.6 code values) around 0.06..0.11 of its own
    accord. It is the doc's §2.2 formula — `L *= 1 + amount * W * (1 - L)^1.8`
    — where the window rises faster than the light, and it PREDATES this change.
    The monotone rewrite (`L' = 1 - (1 - L)^(1 - SH * |a| * W(L))`) is written
    out beside it and was NOT taken: it is exact but it costs the knob 30-50% of
    its crush.
  - WHITE +100 rests a plateau on the clamp from 0.92 up. That is what §2.4 asks
    of the knob and the ramp is non-decreasing through it, so it is not a fold.

`scripts/tone-base-check.mjs`'s mirror of the pass takes the same three moves
(its own `toneBlack` and the squared head) so the pixel it predicts is still the
pixel the pass draws.

Checked: node scripts/highlight-knee-check.mjs; node scripts/tone-base-check.mjs;
node scripts/auto-tone-check.mjs; node scripts/half-check.mjs; node
scripts/mask-wb-check.mjs; node scripts/preview-match-check.mjs; node
scripts/raw-develop-check.mjs; node scripts/white-level-check.mjs; node
scripts/wb-table-check.mjs; node scripts/sharpen-check.mjs; node
scripts/denoise-check.mjs; npx tsc --noEmit.
2026-10-02 10:54:54 +07:00

859 lines
52 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, 8, '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 toe's own exponent, 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)^2 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(' L = toneBlack(L, bl);\n L = clamp(L, 0.0, 1.0);'));
// ...and the black pair itself, pinned on the source as the function the curve now
// calls: the doc's lift at its rate, and the exponent that replaces the doc's
// crush (the rate is on the exponent, so the closed form cannot run backwards).
assert.match(maths, text(`float toneBlack(float L, float bl) {\n const float W = ${TONE_BLACK_EDGE};\n float q = toneBlackW(L);\n if (bl > 0.0) return L + ${TONE_BLACK_LIFT} * bl * q * (sqrt(L) - L);\n if (L >= W) return L;\n return W * pow(L / W, 1.0 + ${TONE_BLACK_CRUSH} * (-bl) * q);\n}`));
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) * (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;
};
// toneBlack, move for move — the doc's lift through the square root, and the
// toe's own exponent for the crush, which is what the shader carries (see
// toneShader.ts): the doc's multiplicative crush is bounded by its own window and
// cannot be seen. It is a function of its own so the arithmetic below and the
// source's own text can be pinned to one shape.
const toneBlack = (L, bl) => {
const q = toneBlackW(L);
if (bl > 0) return L + TONE_BLACK_LIFT * bl * q * (Math.sqrt(L) - L);
if (L >= TONE_BLACK_EDGE) return L;
return TONE_BLACK_EDGE * (L / TONE_BLACK_EDGE) ** (1 + TONE_BLACK_CRUSH * -bl * q);
};
// toneCurve, move for move, with the clamp the shader puts after each one: BLACK
// (toneBlack), SHADOW (a gain on the light), HIGHLIGHT (the doc's knee against the
// headroom that is left, squared) 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;
const blackA = clamp(bl + dr * 0.12, -1, 1);
L = toneBlack(L, blackA);
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) ** 2;
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, and the measurement is the DROP FROM THE RUNNING MAXIMUM rather than the
// step between two cells. A fold of a tenth of a code value spread over a dozen
// cells hides from a cell-sized step: the step this file used to read here was
// -0.00119 while the drop under SHADOW -100 alone was 0.018, three times the guard
// and the thing a gradient wears as a band. The drop is what the eye reads.
//
// Every rate this pass sets is fold-free: the worst any of them can do is 3.4e-6
// of luma (BLACK's lift, at the square root's own corner). The one fold left in
// the ramp is SHADOWS' and it is the doc's own arithmetic, not a rate: §2.2's
// crush multiplies what a window leaves, that window RISES as the luma does over
// 0.02..0.12, so the move outruns the light and the ramp comes back down by 0.018
// of luma (4.6 code values) around 0.06..0.11 at full -100 — at any rate, since
// scaling it only scales the same shape. It predates this pass and is pinned here
// so a rate change cannot quietly deepen it: the assertion below is that no rate
// in this file folds the ramp at all, and that no composition of the four is worse
// than the doc's own crush.
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 });
const fold = (k, n) => {
let top = -Infinity;
let worst = 0;
let at = 0;
for (let i = 0; i <= n; i++) {
const t = i / n;
const o = ramp(t, k).o;
if (o > top) top = o;
else if (top - o > worst) {
worst = top - o;
at = t;
}
}
return { worst, at };
};
// A knob's own rate, at full deflection either way: the rates are set so this is
// zero to the rounding — 2e-3 is half a code value, under what 8-bit can carry.
for (const [knob, amount] of [['bl', 1], ['bl', -1], ['hl', 1], ['hl', -1], ['wh', 1], ['wh', -1]]) {
const { worst, at } = fold({ [knob]: amount }, 32768);
assert.ok(worst <= 2e-3, `${knob} at ${amount} folds the ramp by ${worst} at ${at} — a rate past what its own shape allows`);
}
// SHADOW's crush, the doc's own — see the note above. Pinned: this number is a
// property of §2.2's form, so a change here means the form changed.
near4(fold({ sh: -1 }, 32768).worst, 0.01795, 'SHADOW no longer folds where the doc\'s own crush folds — re-pin, and see toneShader.ts');
let worstFold = 0;
let foldAt = 0;
let foldCombo = null;
for (const k of combos) {
const { worst, at } = fold(k, 8192);
if (worst > worstFold) {
worstFold = worst;
foldAt = at;
foldCombo = k;
}
}
assert.ok(worstFold <= 0.01795 + 2e-3, `worst fold ${worstFold} at ${foldAt} for ${JSON.stringify(foldCombo)} — a composition deepens the doc's fold`);
// ...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 toe's exponent 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 moves the head
// without reaching it (0.95722 at 0.95 against 0.97579 before this pass — the
// squared headroom spends the move lower down), and WHITE is the one that does
// reach it, from 0.92 up (see the check below).
for (const [knob, sides] of [
['bl', [
[-1, [[0.05, 0.00105], [0.10, 0.06618], [0.15, 0.14899], [0.20, 0.20], [0.50, 0.50]]],
[1, [[0.05, 0.11082], [0.10, 0.11765], [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.81270], [0.80, 0.89202], [0.90, 0.93542], [0.95, 0.95722]]],
[-1, [[0.70, 0.58730], [0.80, 0.70798], [0.95, 0.94278]]],
]],
['wh', [
[1, [[0.90, 0.96750], [0.95, 1.0]]],
[-1, [[0.90, 0.83250], [0.95, 0.86984]]],
]],
])
for (const [amount, cases] of sides)
for (const [t, want] of cases)
near4(ramp(t, { [knob]: amount }).o, want, `${knob} at ${amount} on ${t}`);
// HIGHLIGHT does not clip: +100 at 0.95 is still under the ceiling of the cube
// (0.95722, and 0.93542 at 0.90), because the headroom it reads is squared and the
// move has died out by the top — while WHITE, the frame's clipping point, takes the
// ramp TO 1.0 from 0.92 up, which is what §2.4 asks of it. Either way the only
// interior value a full set of knobs reaches 1.0 on is a pixel already at the head.
assert.ok(ramp(0.95, { hl: 1 }).o < 1, 'HIGHLIGHT +100 is clipping the head');
assert.ok(ramp(0.9, { hl: 1 }).o < 1, 'HIGHLIGHT +100 is clipping the head');
assert.ok(ramp(0.93, { wh: 1 }).o >= 1 - 1e-12, 'WHITE +100 is not reaching the ceiling it is named for');
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.1143', -0.1143, 0.7375],
['mono-high-contrast', -0.54, 0.16956519, '0.5929', 0.5929, 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');