e61dccc784
The four knots were read at the pixel's own luma, which makes the ramp a global curve: every pixel at luma t lands on the same o whatever surrounds it. SHADOW's a1 is the head of the quarter above it, so lifting it squashed that whole quarter to the half slope left over — measured on a real frame, 0.50 of its spread (shadow-band.py), 0.57 on the deployed bundle — the grey sheet the knob was reported for. A curve drawn through the pixel cannot see local contrast; that is what the eye was reading. So the ramp is read at TONE_BASE_RADIUS (2.5% of the frame) of the luma around the pixel — a 3x3 range-weighted blur, the fix_shadow.md Base x Detail split — and the pixel then rides the neighbourhood's gain o/base, keeping its own difference from it. Base moves, detail stays: the same lift on the same pixels, with the texture inside the region left standing. Full deflection keeps 0.78 of the band's spread now. The base is the same maths in every caller: bx = 0 (a mask, which has no neighbourhood) reads the pixel nine times and gets the old global move back, and with every knob on zero the ramp at base IS base, so the ratio is 1 and the pass is the identity however coarse the base is. Skipped: no chroma compensation (Hunt). Measured, the ratio held saturation (0.3184 -> 0.3169), so it is not earned yet. No linear-light ramp either: the multiply is a uniform gain on encoded values, which is the same stop exposureMove already argues for.
636 lines
38 KiB
JavaScript
636 lines
38 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 tent each, one per quarter of the ramp — and each knob moves the knot it
|
||
// owns by TONE_ANCHOR of the ramp, held inside the knot before it; SHADOW and
|
||
// HIGHLIGHT, whose knots are the HEAD of a quarter rather than an end of the
|
||
// ramp, move half that. The 0.50 midpoint is the one value all four leave where
|
||
// it was.
|
||
//
|
||
// 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, t\), bl, sh, hl, wh, dr\);/);
|
||
// The BASE layer the ramp is drawn through, and the radius the caller spends on
|
||
// it. It reads the CHILD nine times — a caller whose bx is zero reads its own
|
||
// pixel nine times and gets the global move back, which is what makes the shared
|
||
// maths safe for a mask (see the mask's own call below).
|
||
assert.match(tmpl, /float baseLuma\(vec2 xy, float t\) \{/);
|
||
assert.match(tmpl, /vec3 s = clamp\(src\.eval\(xy \+ vec2\(float\(i\), float\(j\)\) \* bx\)\.rgb, 0\.0, 1\.0\);/);
|
||
assert.match(tmpl, /float rw = exp\(-d \* d \* 24\.0\);/);
|
||
assert.doesNotMatch(tmpl, /if \(i == 0 && j == 0\) continue;/, 'the 3x3 grew a branch — bx = 0 no longer returns exactly t');
|
||
assert.match(tmpl, /uniform float2 bx;/);
|
||
// 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, the shader has a
|
||
// step in its own pixels.
|
||
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%`);
|
||
const engine = readFileSync(new URL('../src/engine/exportEngine.ts', import.meta.url), 'utf8');
|
||
assert.match(
|
||
engine,
|
||
/getToneUniforms\(adjustments, recipe\.baseFilter, \[\s*width \* TONE_BASE_RADIUS,\s*height \* TONE_BASE_RADIUS,\s*\]\)/,
|
||
'the tone pass no longer hands the base a frame-sized step'
|
||
);
|
||
// The uniform block: the shader's declarations, arrays expanded and in
|
||
// declaration order, have to be the numbers `toneUniformArray` writes — a
|
||
// mismatch is a silent off-by-one down the whole block.
|
||
const declared = [...tmpl.matchAll(/uniform (float2|float) (\w+)(?:\[(\d+)\])?;/g)].reduce(
|
||
(n, [, kind, , len]) => n + (len ? Number(len) : kind === 'float2' ? 2 : 1),
|
||
0
|
||
);
|
||
const arrayFn = tone.match(/export function toneUniformArray\(u: ToneUniforms\): number\[\] \{\n return \[([\s\S]*?)\n \];/)?.[1];
|
||
assert.ok(arrayFn, 'toneUniformArray is gone');
|
||
const written = arrayFn
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean)
|
||
.reduce((n, s) => n + (s.startsWith('...u.hsl') ? 8 : 1), 0);
|
||
assert.equal(written, declared, `toneUniformArray writes ${written} floats, the pass declares ${declared}`);
|
||
const resolve = (s) => s.replace('${TONE_MATH_SKSL}', mathTmpl).replaceAll('${TONE_ANCHOR}', String(A));
|
||
|
||
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: five knots, each moved by its own knob and held inside the one
|
||
// before it. The 0.50 knot is a literal — nothing may move the midpoint. DR
|
||
// moves the same knots, on the toe and the head exactly 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 a4 = 1\.0 \+ 0\.25 \* wh - dr \* 0\.18;/);
|
||
assert.match(sksl, /float a3 = clamp\(0\.75 \+ 0\.25 \* 0\.5 \* hl - dr \* 0\.09, 0\.5, a4\);/);
|
||
assert.match(sksl, /float a1 = clamp\(0\.25 \+ 0\.25 \* 0\.5 \* sh \+ dr \* 0\.06, 0\.0, 0\.5\);/);
|
||
assert.match(sksl, /float a0 = clamp\(0\.25 \* bl \+ dr \* 0\.12, 0\.0, a1\);/);
|
||
assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again — it folds the flat stretch at 0.238');
|
||
// Straight between the knots, and NOT a smoothstep: an S-curve through the
|
||
// knots 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 lin\(float e0, float e1, float x\) \{\n return clamp\(\(x - e0\) \/ \(e1 - e0\), 0\.0, 1\.0\);\n\}/);
|
||
assert.match(sksl, /float o = mix\(a0, a1, lin\(0\.00, 0\.25, base\)\);/);
|
||
assert.match(sksl, /o = mix\(o, mix\(a1, 0\.5, lin\(0\.25, 0\.50, base\)\), step\(0\.25, base\)\);/);
|
||
assert.match(sksl, /o = mix\(o, mix\(0\.5, a3, lin\(0\.50, 0\.75, base\)\), step\(0\.50, base\)\);/);
|
||
assert.match(sksl, /o = mix\(o, mix\(a3, a4, lin\(0\.75, 1\.00, base\)\), step\(0\.75, base\)\);/);
|
||
// ...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 knots, the same lin() and the same step()
|
||
// guards 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}`);
|
||
const smoothstep = (e0, e1, x) => {
|
||
const u = clamp01((x - e0) / (e1 - e0));
|
||
return u * u * (3 - 2 * u);
|
||
};
|
||
const lin = (e0, e1, x) => clamp01((x - e0) / (e1 - e0));
|
||
const step = (edge, x) => (x < edge ? 0 : 1);
|
||
const mix = (a, b, t) => a + (b - a) * t;
|
||
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);
|
||
const a4 = 1 + A * wh - dr * 0.18;
|
||
const a3 = Math.min(a4, Math.max(0.5, 0.75 + A * 0.5 * hl - dr * 0.09));
|
||
const a1 = Math.min(0.5, Math.max(0, 0.25 + A * 0.5 * sh + dr * 0.06));
|
||
const a0 = Math.min(a1, Math.max(0, A * bl + dr * 0.12));
|
||
let o = mix(a0, a1, lin(0, 0.25, t));
|
||
o = mix(o, mix(a1, 0.5, lin(0.25, 0.5, t)), step(0.25, t));
|
||
o = mix(o, mix(0.5, a3, lin(0.5, 0.75, t)), step(0.5, t));
|
||
o = mix(o, mix(a3, a4, lin(0.75, 1, t)), step(0.75, t));
|
||
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 knots exist 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): every knot is clamped
|
||
// inside the one before it, so the ramp cannot fold.
|
||
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 quarter, and only its own: +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 (a1,
|
||
// a3) are the HEAD of the quarter in front of them, so a whole quarter would draw
|
||
// that quarter flat, which is 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: the toe on 0.12 and the head on 0.82,
|
||
// which is what the two masked terms added at t = 0 and t = 1, and the midpoint
|
||
// still untouched. Now it is a knot 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');
|
||
close(ramp(1, { dr: 1 }).o, 0.82, '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 is
|
||
// held on the floor by the ordering clamp (BLACK's -0.25 cancels DR's +0.12),
|
||
// the 0.25 knot is DR's own +0.06 over SHADOW's half-anchor -0.125, and the
|
||
// stretch between them is a straight line up to the midpoint — never a step
|
||
// down.
|
||
assert.equal(ramp(0, { dr: 1, bl: -1, sh: -1 }).o, 0);
|
||
assert.equal(ramp(0.25, { dr: 1, bl: -1, sh: -1 }).o, 0.185);
|
||
close(ramp(0.375, { dr: 1, bl: -1, sh: -1 }).o, 0.3425, '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 HALF slope that quarter has
|
||
// left under the knot — the grey sheet, drawn flat, 0.50 of
|
||
// its own spread.
|
||
// 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]);
|
||
close(spread(movedGlobally) / spread(band), 0.5, 'the pixel-read ramp no longer draws its own band at half slope');
|
||
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');
|