acbb2bba4b
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.
859 lines
52 KiB
JavaScript
859 lines
52 KiB
JavaScript
// Highlight roll-off in the develop, and the tonal-range ramp in the tone pass.
|
||
//
|
||
// THE DEVELOP holds the knee:
|
||
//
|
||
// L' = L , L < T
|
||
// L' = T + (L - T) / (1 + 2 S (L - T)) , L >= T
|
||
//
|
||
// drawn on the sensor's own levels (T = 0.7, S = 1 / (2 (1 - T)), which puts the
|
||
// asymptote on 1.0) so the two stops the sensor holds above its white level are
|
||
// COMPRESSED into the frame instead of being thrown away by the old
|
||
// fade-to-white — which is also the only reason HIGHLIGHT has detail left at the
|
||
// top to move. Measured on DSC03453.ARW, where the camera's own preview is
|
||
// clipped, the develop's luma was 253.4 with a standard deviation of 2.4, against
|
||
// 251.2 / 10.0 through the knee.
|
||
//
|
||
// THE TONE PASS builds the luma a new ramp instead. The four knobs
|
||
// (HIGHLIGHT, SHADOW, WHITE, BLACK) are the four zones of the tone-mapping doc,
|
||
// 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');
|