166a677590
The ramp was a0..a4 with the pixel's base interpolated straight between them. A segment meets its neighbour at an ANGLE, and the second derivative of a tone curve is what a gradient reads as a band — so a knob left a line across the mid-tones, worst exactly where it was reported: BLACK +100 put its whole lift inside 0.25 and the stretch from 0.25 up came back identical to the untouched frame (the "transition stays grey" it was reported for), and HIGHLIGHT -100 folded a seam into 0.747, between the highlights it pulled and the shadow it left under them. Measured on a 1024-step luma wedge through the exported pass, second difference through a ±1% box: BLACK +100 read 105 at 0.030 against 0.000 from 0.25 up, HIGHLIGHT -100 read 72 at 0.747. Step of the first derivative across the knots: 0.955 at 0.25 and 1.146 at 0.75 — the curve arrived folded, and 1.146 is a sign flip, not a bend. So each knob is now a BUMP on the identity, peaking on its own knot — BLACK on 0.00, SHADOW on 0.25, HIGHLIGHT on 0.75, WHITE on 1.00 — with the kernel (1-u^2)^2 over a half-width (a half of the ramp for the two ends, whose knots ARE the ends, a quarter for the two heads). Level at u = 0, so a knot moves without a fold at its own top; level at u = 1, so a move lands on the identity and on its neighbour without an angle; C1 everywhere between. The two bumps of a half meet on 0.50 both on zero, which is the same fixed midpoint as before, and DR still moves the same knots (0.12 on the toe, 0.18 on the head, half of each on the heads beside them). A sum of bumps can overshoot where two steep sides land on one stretch — past a slope of 1 the curve runs BACKWARDS, a worse band than the seams this replaces, and it is reachable: DR alone was under it, BLACK and SHADOW +100 together were not (unguarded min slope -0.0141). The guard reads each pair at its own steepest points, 8/(3*sqrt(3))/w per unit amplitude (TONE_BUMP_SLOPE_HALF 3.0792, TONE_BUMP_SLOPE_QUARTER 6.1584), holds the two under one and gives them up together past it. A single knob never reaches it (a full BLACK is 0.77, a full SHADOW 0.77), so every slider keeps its whole travel; the worst case is DR at full, which gives up a tenth of its head roll (0.18 -> 0.8376 on the head), and BLACK with SHADOW both at +100, which arrive at 0.65 of their own lift instead of folding. Guarded, the sweep over five levels of all four knobs and DR reads a min slope of +0.0183 and a max of 1.9937, with the largest slope jump 0.00005. After: the same wedge, the same pass. The knot steps are 0.096 at 0.25 and 0.478 at 0.75, with no sign flip — C1 across the knot instead of a fold. BLACK +100 now carries the rework out of its own quarter: +0.139 at 0.25, +0.101 at 0.30, +0.033 at 0.40, 0.000 at 0.50, where it used to read 0.000 from 0.25 all the way up. HIGHLIGHT -100 keeps its lift (-0.126 peak against -0.121 before) and spends it over the quarter instead of into a line. Every knob on zero is the identity to the last bit — the pass also runs for the stock split tones and for DR alone — and 0.50 is still the one value no knob moves. Checks: tone-base-check.mjs now runs the bump and the guard as arithmetic beside the shader (with the negative control: the unguarded pair still folds, the guard is what stops it). highlight-knee-check.mjs reads the kernel and the two slope constants off the source, pins the four amplitudes and the guard, and sweeps the travel of every knob as before. All ten checks that run without a browser pass, build clean. Skipped: the guard's ceiling is a constant, not a search for the widest travel that still clears a band we cannot see. Add when a frame shows a band the deflections in hand cannot explain.
707 lines
43 KiB
JavaScript
707 lines
43 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 —
|
||
// one bump each, one per quarter of the ramp — and each knob moves the knot it
|
||
// owns by TONE_ANCHOR of the ramp; SHADOW and HIGHLIGHT, whose knots are the HEAD
|
||
// of a quarter rather than an end of the ramp, move half that. The kernel is
|
||
// (1-u^2)^2, level at the knot and level a half-width away, so a knob moves its
|
||
// own quarter and meets the next knob's bump on 0.50 flat; the 0.50 midpoint is
|
||
// the one value all four leave where it was. The two bumps of a half are held
|
||
// under a total slope of 1, so the sum can only add to the identity.
|
||
//
|
||
// 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: TONE_ANCHOR is
|
||
// interpolated, so the template has to be resolved before it can be matched.
|
||
const anchorSrc = tone.match(/export const TONE_ANCHOR = ([0-9.]+);/)?.[1];
|
||
assert.ok(anchorSrc, 'TONE_ANCHOR is gone — the four knots no longer share a reach');
|
||
const A = Number(anchorSrc);
|
||
assert.equal(A, 0.25, 'a knob no longer moves its knot a quarter 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_ANCHOR\}/g) ?? []).length, 4, 'a knot is pinned to a literal, not to TONE_ANCHOR');
|
||
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}`);
|
||
// The guard's own constants are the kernel's derivative at its steepest point,
|
||
// 8/(3*sqrt(3)) = 1.5396, in units of the bump's half-width — so they are read
|
||
// off the source and checked against that number rather than taken on trust.
|
||
const halfSlope = Number(tone.match(/export const TONE_BUMP_SLOPE_HALF = ([0-9.]+);/)?.[1]);
|
||
const quarterSlope = Number(tone.match(/export const TONE_BUMP_SLOPE_QUARTER = ([0-9.]+);/)?.[1]);
|
||
assert.ok(halfSlope > 0, 'TONE_BUMP_SLOPE_HALF is gone — the guard has no ceiling to hold to');
|
||
assert.ok(Math.abs(halfSlope - 8 / (3 * Math.sqrt(3)) / 0.5) < 1e-4, `TONE_BUMP_SLOPE_HALF ${halfSlope} is not the kernel's steepest slope over a half-width`);
|
||
assert.ok(Math.abs(quarterSlope - 8 / (3 * Math.sqrt(3)) / 0.25) < 1e-4, `TONE_BUMP_SLOPE_QUARTER ${quarterSlope} is not the same slope over a quarter-width`);
|
||
assert.equal(quarterSlope, 2 * halfSlope, 'the two slopes are no longer the same kernel at two widths');
|
||
const resolve = (s) =>
|
||
s
|
||
.replace('${TONE_MATH_SKSL}', mathTmpl)
|
||
.replaceAll('${TONE_ANCHOR}', String(A))
|
||
.replaceAll('${TONE_BUMP_SLOPE_HALF}', String(halfSlope))
|
||
.replaceAll('${TONE_BUMP_SLOPE_QUARTER}', String(quarterSlope));
|
||
|
||
const sksl = resolve(tmpl);
|
||
const maths = resolve(mathTmpl);
|
||
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 ramp: four bumps on the four knots, each moved by its own knob, added to
|
||
// the identity. The kernel is (1-u^2)^2 — level at the knot, so a knot moves
|
||
// without a fold at its own top, and level a half-width away, so a move lands on
|
||
// the identity and on its neighbour without an angle. The 0.50 midpoint is where
|
||
// the two bumps of a half meet, and both are on zero there: nothing may move it.
|
||
// DR moves the same bumps, on the toe and the head as it did when it was a pair
|
||
// of masked terms (0.12 at t = 0, 0.18 at t = 1) and half of each at the knots
|
||
// next to them.
|
||
assert.match(sksl, /float toneBump\(float x, float knot, float half_width\) \{\n float u = \(x - knot\) \/ half_width;\n float v = max\(0\.0, 1\.0 - u \* u\);\n return v \* v;\n\}/);
|
||
assert.match(sksl, /float blackA = 0\.25 \* bl \+ dr \* 0\.12;/);
|
||
assert.match(sksl, /float shadowA = 0\.25 \* 0\.5 \* sh \+ dr \* 0\.06;/);
|
||
assert.match(sksl, /float highA = 0\.25 \* 0\.5 \* hl - dr \* 0\.09;/);
|
||
assert.match(sksl, /float whiteA = 0\.25 \* wh - dr \* 0\.18;/);
|
||
assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again — it folds the flat stretch at 0.238');
|
||
// The sum, and NOT straight segments between the knots: an angle in a tone curve
|
||
// is a Mach band, and the measurement is what retired them — on a 1024-step luma
|
||
// wedge BLACK +100 left 105 of second difference at 0.030 with the stretch above
|
||
// it identical to the untouched frame, HIGHLIGHT -100 broke at 0.747 with 72.
|
||
// Nor a smoothstep through the knots: an S-curve bends the ramp by six code
|
||
// values in the quarter-tones with every knob on zero, and this pass also runs
|
||
// for the stock split tones and for DR alone.
|
||
assert.match(sksl, /float o = base\n \+ blackA \* toneBump\(base, 0\.00, 0\.50\)\n \+ shadowA \* toneBump\(base, 0\.25, 0\.25\)\n \+ highA \* toneBump\(base, 0\.75, 0\.25\)\n \+ whiteA \* toneBump\(base, 1\.00, 0\.50\);/);
|
||
assert.doesNotMatch(sksl, /float lin\(/, 'the straight segments are back — a knot is an angle in a tone curve');
|
||
// The guard. Two bumps share each half of the ramp and their steep sides can land
|
||
// on the same stretch, so the two amplitudes of a half are held under a total
|
||
// slope of 1 — read at their own steepest points, which is the loosest the pair
|
||
// can be — and given up together past it. Without it a full BLACK and a full
|
||
// SHADOW fold the ramp back on itself; a single knob never reaches the ceiling.
|
||
assert.match(sksl, /float holdLo = 3\.0792 \* abs\(blackA\) \+ 6\.1584 \* abs\(shadowA\);/);
|
||
assert.match(sksl, /float holdHi = 6\.1584 \* abs\(highA\) \+ 3\.0792 \* abs\(whiteA\);/);
|
||
assert.match(sksl, /float kLo = holdLo > 1\.0 \? 1\.0 \/ holdLo : 1\.0;/);
|
||
assert.match(sksl, /float kHi = holdHi > 1\.0 \? 1\.0 \/ holdHi : 1\.0;/);
|
||
assert.doesNotMatch(sksl, /clamp\(0\.25 \+ 0\.25/, 'a knob is clamped against a neighbour knot again');
|
||
// ...and the pixel rides the ratio of that: the DETAIL layer, kept whole. The
|
||
// ramp's own luma is not what is handed to the rebuild any more — the pixel's is,
|
||
// scaled by the neighbourhood's gain — 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 = base > 0\.0004 \? o \* t \/ base : t;/);
|
||
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 kernel, the same four amplitudes and the
|
||
// same guard the SkSL above carries, so the shape is measured and not described.
|
||
const clamp01 = (x) => Math.min(1, Math.max(0, 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}`);
|
||
// A guarded move has been through 1 / hold and a multiply on top of the bump, so
|
||
// it lands near its number rather than on it — a tenth of a code value is the
|
||
// tolerance, well inside the one the cube can see.
|
||
const near = (a, b, msg) => assert.ok(Math.abs(a - b) < 4e-5, `${msg ?? ''} ${a} != ${b}`);
|
||
const smoothstep = (e0, e1, x) => {
|
||
const u = clamp01((x - e0) / (e1 - e0));
|
||
return u * u * (3 - 2 * u);
|
||
};
|
||
const bump = (x, knot, halfWidth) => {
|
||
const u = (x - knot) / halfWidth;
|
||
const v = Math.max(0, 1 - u * u);
|
||
return v * v;
|
||
};
|
||
function ramp(t, k) {
|
||
const { dr = 0, hl = 0, sh = 0, wh = 0, bl = 0 } = 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);
|
||
let blackA = A * bl + dr * 0.12;
|
||
let shadowA = A * 0.5 * sh + dr * 0.06;
|
||
let highA = A * 0.5 * hl - dr * 0.09;
|
||
let whiteA = A * wh - dr * 0.18;
|
||
// The two moves of a half are read at their own steepest points and held under
|
||
// a total slope of 1, together — see the note on the guard in toneShader.ts.
|
||
const holdLo = halfSlope * Math.abs(blackA) + quarterSlope * Math.abs(shadowA);
|
||
const holdHi = quarterSlope * Math.abs(highA) + halfSlope * Math.abs(whiteA);
|
||
const kLo = holdLo > 1 ? 1 / holdLo : 1;
|
||
const kHi = holdHi > 1 ? 1 / holdHi : 1;
|
||
blackA *= kLo;
|
||
shadowA *= kLo;
|
||
highA *= kHi;
|
||
whiteA *= kHi;
|
||
const o =
|
||
t +
|
||
blackA * bump(t, 0, 0.5) +
|
||
shadowA * bump(t, 0.25, 0.25) +
|
||
highA * bump(t, 0.75, 0.25) +
|
||
whiteA * bump(t, 1, 0.5);
|
||
return { o: clamp01(o), 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, and the reason
|
||
// a mid-grey does not move while the ends do.
|
||
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}`);
|
||
// The midpoint is the one value no knob reaches, at any setting.
|
||
for (const k of [{ hl: 1, sh: 1, wh: 1, bl: 1 }, { hl: -1, sh: -1, wh: -1, bl: -1 }, { hl: 1, sh: -1, wh: -1, bl: 1 }])
|
||
close(ramp(0.5, k).o, 0.5, 'a knob moved the midpoint');
|
||
|
||
// Monotone under EVERY combination of the four at full deflection, DR included.
|
||
// This is the whole reason the ramp is bumps on the identity instead of the doc's
|
||
// additive masks, which measured a slope of -5 per unit luma on BLACK +1 against
|
||
// SHADOW -1 (an inverted band at t = 0.875, scratchpad tone-proto.mjs): the two
|
||
// moves of a half are held under a total slope of 1, so the sum can never carry
|
||
// the curve backwards — and a fold at a knot is a worse band than the seams the
|
||
// straight segments drew there.
|
||
const combos = [];
|
||
for (const bl of [-1, 0, 1])
|
||
for (const sh of [-1, 0, 1])
|
||
for (const hl of [-1, 0, 1])
|
||
for (const wh of [-1, 0, 1])
|
||
for (const dr of [0, 1]) combos.push({ bl, sh, hl, wh, dr });
|
||
let worst = Infinity;
|
||
for (const k of combos) {
|
||
let prev = null;
|
||
for (let t = 0; t <= 1; t += 1 / 512) {
|
||
const o = ramp(t, k).o;
|
||
if (prev !== null) {
|
||
assert.ok(o >= prev - 1e-12, `ramp folded at ${t} for ${JSON.stringify(k)}`);
|
||
if (o - prev < worst) worst = o - prev;
|
||
}
|
||
prev = o;
|
||
}
|
||
}
|
||
assert.ok(worst > -1e-12, `worst step ${worst} — the ramp is folded`);
|
||
// A knob moves its own knot by its own reach: +BLACK takes the toe off the
|
||
// floor, -HIGHLIGHT rolls the 0.75 knot down a quarter of the way to the
|
||
// midpoint, and WHITE - rolls the head under 1.0. That is the reach a
|
||
// tonal-range slider has — a quarter of the ramp, so the middle stays a middle.
|
||
// SHADOW and HIGHLIGHT are the exceptions and move HALF of it: their knots are
|
||
// the HEAD of the quarter in front of them, and the bump only ever ADDS to the
|
||
// identity, so the whole anchor would draw that quarter flat — the wash-out both
|
||
// knobs were reported for.
|
||
close(ramp(0, {}).o, 0, 'a neutral toe moved');
|
||
close(ramp(0, { bl: 1 }).o, A, 'BLACK no longer reaches a quarter of the ramp');
|
||
close(ramp(0.25, { sh: 1 }).o, 0.375, 'SHADOW no longer stops halfway to the midpoint');
|
||
close(ramp(0.25, { sh: -1 }).o, 0.125, 'SHADOW no longer stops halfway to the floor');
|
||
close(ramp(0.75, { hl: -1 }).o, 0.625, 'HIGHLIGHT no longer stops halfway to the midpoint');
|
||
close(ramp(0.75, { hl: 1 }).o, 0.875, 'HIGHLIGHT no longer stops halfway to the white knot');
|
||
close(ramp(1, { wh: -1 }).o, 0.75, 'WHITE no longer rolls the head under 1.0');
|
||
close(ramp(0.25, {}).o, 0.25, 'a neutral knot moved');
|
||
close(ramp(0.75, {}).o, 0.75, 'a neutral knot moved');
|
||
// WHITE + is free to pass 1.0 — that is the move that clips a highlight to
|
||
// white — and the ramp still runs through a raised knot at 1.25.
|
||
assert.ok(1 + A * 1 > 1, 'the white knot can no longer pass 1.0');
|
||
close(ramp(1, { wh: 1 }).o, 1, 'a raised white knot left the top of the ramp');
|
||
// DR at full is the same curve it was on the toe — 0.12, what the masked term
|
||
// added at t = 0, and half of it on the knot next to it — and on the head it
|
||
// gives up a tenth of its roll: its 0.18 alone is over the guard (holdHi 1.11),
|
||
// so the head lands on 0.8376 where the two masked terms put 0.82, and the
|
||
// midpoint is still untouched. Now it is a bump move, so BLACK and SHADOW both at
|
||
// -1 (a flat stretch between 0.25 and 0.5, where the old additive lift sloped
|
||
// down and folded the ramp at 0.238) stays monotone.
|
||
close(ramp(0, { dr: 1 }).o, 0.12, 'DR no longer lifts the toe the way it did');
|
||
near(ramp(1, { dr: 1 }).o, 0.8376201610808, 'DR no longer rolls the head the way it did');
|
||
close(ramp(0.5, { dr: 1 }).o, 0.5, 'DR moved the midpoint');
|
||
// Black and shadow both at -1 are the flat stretch DR used to fold: the toe takes
|
||
// BLACK's -0.25 against DR's +0.12 and lands under the floor (clamped there), the
|
||
// 0.25 knot takes SHADOW's -0.125 against DR's own +0.06, and the two bumps carry
|
||
// the stretch between them up to the midpoint — never a step down.
|
||
close(ramp(0, { dr: 1, bl: -1, sh: -1 }).o, 0, 'the floor is not the floor');
|
||
near(ramp(0.25, { dr: 1, bl: -1, sh: -1 }).o, 0.111875, 'DR moved the 0.25 knot');
|
||
near(ramp(0.375, { dr: 1, bl: -1, sh: -1 }).o, 0.3135546875, 'DR folded the flat stretch');
|
||
// The band above SHADOW is the one that pays for its lift, and HALF its slope is
|
||
// the floor the fix is. Measured on a real frame (DSCF1701, 21% of its pixels in
|
||
// that band) with SHADOW +90, a whole anchor left 0.10 of the band's own spread
|
||
// where half leaves 0.55 — see the note on a1 in toneShader.ts and the sweep in
|
||
// scratchpad sh-band.mjs. Swept here over the knob's whole travel: neither
|
||
// quarter of the ramp SHADOW touches may be drawn flatter than half its slope,
|
||
// and neither may be stretched past one and a half, which is the same defect
|
||
// upside down (a crush that flattens the darks into one black).
|
||
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(band >= 0.5 - 1e-12, `SHADOW drew the band above it flat at ${sh}: slope ${band}`);
|
||
assert.ok(below >= 0.5 - 1e-12, `SHADOW drew the quarter below it flat at ${sh}: slope ${below}`);
|
||
assert.ok(band <= 1.5 + 1e-12, `SHADOW stretched the band above it at ${sh}: slope ${band}`);
|
||
}
|
||
// HIGHLIGHT pays the same way and at BOTH ends, which is why its knot is the
|
||
// worse of the two: a3 is the head of the top quarter, so lifting it draws
|
||
// 0.75..1.00 flat (a cloud to paper) and pulling it draws 0.50..0.75 flat. At the
|
||
// whole anchor the sweep in scratchpad/knob-sweep.mjs read the quarter slopes as
|
||
// 1.00 1.00 2.00 0.00 at +100 and 1.00 1.00 0.00 2.00 at -100 — a quarter flat
|
||
// either way. Half draws neither, over the whole travel.
|
||
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.5 - 1e-12, `HIGHLIGHT drew the quarter below it flat at ${hl}: slope ${mid}`);
|
||
assert.ok(top >= 0.5 - 1e-12, `HIGHLIGHT drew the top quarter flat at ${hl}: slope ${top}`);
|
||
assert.ok(mid <= 1.5 + 1e-12, `HIGHLIGHT stretched the quarter below it at ${hl}: slope ${mid}`);
|
||
assert.ok(top <= 1.5 + 1e-12, `HIGHLIGHT stretched the top quarter at ${hl}: slope ${top}`);
|
||
}
|
||
// The film stocks ride the same knots, so a halved SHADOW or HIGHLIGHT would have
|
||
// halved their crush and their shoulder with it. They are written at DOUBLE for
|
||
// that reason, and the look they were tuned to is the knot, not the unit: these
|
||
// land where they always did (0.18 Classic Chrome/Vivid, 0.22 Acros, 0.17 Acros
|
||
// HC; the head 0.7375 on Acros and 0.815 on Acros HC).
|
||
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.56, 0.18],
|
||
['classic-vivid', -0.56, 0.18],
|
||
['monochrome', -0.24, 0.22, '-0.10', -0.1, 0.7375],
|
||
['mono-high-contrast', -0.64, 0.17, '0.52', 0.52, 0.815],
|
||
]) {
|
||
assert.ok(
|
||
filmFlat.includes(`${name}: { sh: ${sh}`),
|
||
`${name} is not on the doubled ${sh} — the stock's crush moved with the knob's reach`
|
||
);
|
||
close(ramp(0.25, { sh }).o, knot, `${name}'s shadow knot moved`);
|
||
if (hlSrc) {
|
||
assert.ok(filmFlat.includes(`hl: ${hlSrc}`), `${name} is not on the doubled ${hlSrc} — its shoulder moved with the knob's reach`);
|
||
close(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.
|
||
function rebuild(rgb, k, base) {
|
||
const t = lumaOf(rgb);
|
||
const b = base ?? t;
|
||
const o = ramp(b, k).o;
|
||
const target = clamp01(b > 0.0004 ? (o * t) / b : t);
|
||
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 quarter has left
|
||
// under the knot — the grey sheet, drawn flat, 0.48 of its
|
||
// own spread at this deflection (0.50 when the ramp was
|
||
// straight segments, the bump adds a little of the lift
|
||
// back into the band).
|
||
// read at the base every pixel of ONE neighbourhood takes the same gain,
|
||
// o(base)/base, so the texture inside it rides out whole,
|
||
// 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.
|
||
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]);
|
||
near(spread(movedGlobally) / spread(band), 0.480832, '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), bandGain, 'the band did not keep its texture under the lift');
|
||
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 gain 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 gain would be the
|
||
// pixel's own o / t — the slope of 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 gain 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 ratio is 1 — 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');
|