// 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');