diff --git a/docker/frontend/scripts/highlight-knee-check.mjs b/docker/frontend/scripts/highlight-knee-check.mjs index 977a6fc..20118fa 100644 --- a/docker/frontend/scripts/highlight-knee-check.mjs +++ b/docker/frontend/scripts/highlight-knee-check.mjs @@ -14,14 +14,18 @@ // 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. +// (HIGHLIGHT, SHADOW, WHITE, BLACK) are the four zones of the tone-mapping doc, +// and each one owns a COMPACT WINDOW of the ramp — a black toe that dies on 0.18, +// a shadow bell over the deep tones, a highlight bell over the bright ones, a +// white shoulder from 0.80 — so each is exactly the identity outside its own +// stretch and the four are disjoint, which is what makes them independent +// without a guard. The four moves are applied ONE AFTER THE OTHER +// (BLACK -> SHADOW -> HIGHLIGHT -> WHITE), each clamped on the way to the next, +// and every one of them lands on 0.0 and 1.0 without moving either: a composition +// of monotone maps is monotone by construction, and the two ends of the ramp are +// FIXED POINTS of the whole thing whatever the sliders say. There is no fixed +// midpoint any more — the shadow window spans the middle, so a mid-grey moves +// with SHADOW (0.510636 at +100) where the old sum of bumps left it alone. // // Both are SkSL, so the SHAPE is pinned on the source; the arithmetic is then // checked against the source's own constants, and the ramp re-run here as a twin @@ -45,12 +49,26 @@ 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'); +// The tone pass, read as the string it actually emits: the six window edges and +// rates are interpolated, so the template has to be resolved before it can be +// matched — and read off the SOURCE, so the numbers here are the ones the shader +// is built with. +const num = (re) => { + const m = tone.match(re); + assert.ok(m, 'missing ' + re); + return Number(m[1]); +}; +const { + TONE_BLACK_EDGE, TONE_WHITE_EDGE, TONE_BLACK_LIFT, TONE_BLACK_CRUSH, TONE_HIGH_GAIN, TONE_WHITE_GAIN, +} = Object.fromEntries( + ['TONE_BLACK_EDGE', 'TONE_WHITE_EDGE', 'TONE_BLACK_LIFT', 'TONE_BLACK_CRUSH', 'TONE_HIGH_GAIN', 'TONE_WHITE_GAIN'] + .map((name) => [name, num(new RegExp(`export const ${name} = ([0-9.]+);`))]), +); +// The two edges are the doc's and the ramp is drawn in quarters of a code value +// around them: the toe window must die inside the first quarter of the ramp and +// the shoulder must not start below the middle, or a knob reaches past its zone. +assert.ok(TONE_BLACK_EDGE <= 0.25, `TONE_BLACK_EDGE ${TONE_BLACK_EDGE} is wider than a quarter of the ramp`); +assert.ok(TONE_WHITE_EDGE >= 0.5, `TONE_WHITE_EDGE ${TONE_WHITE_EDGE} starts under the middle of the ramp`); const tmpl = tone.match(/export const TONE_SKSL = `([\s\S]*?)`;/)?.[1]; assert.ok(tmpl, 'TONE_SKSL is gone'); // The ramp, the hue-preserving rebuild and the exposure move live in @@ -60,7 +78,7 @@ assert.ok(tmpl, 'TONE_SKSL is gone'); // 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.equal((mathTmpl.match(/\$\{TONE_[A-Z_]+\}/g) ?? []).length, 7, 'a window edge or a rate is pinned to a literal, not to its TONE_ constant'); assert.ok(tmpl.includes('${TONE_MATH_SKSL}'), 'the frame pass carries its own copy of the ramp again'); assert.match(tmpl, /rgb = toneRamp\(rgb, t, baseLuma\(xy\), bl, sh, hl, wh, dr\);/); // The BASE layer the ramp is drawn through. It is ONE tap of a blurred child, @@ -115,24 +133,55 @@ const written = arrayFn .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)); + .replaceAll('${TONE_BLACK_EDGE}', String(TONE_BLACK_EDGE)) + .replaceAll('${TONE_WHITE_EDGE}', String(TONE_WHITE_EDGE)) + .replaceAll('${TONE_BLACK_LIFT}', String(TONE_BLACK_LIFT)) + .replaceAll('${TONE_BLACK_CRUSH}', String(TONE_BLACK_CRUSH)) + .replaceAll('${TONE_HIGH_GAIN}', String(TONE_HIGH_GAIN)) + .replaceAll('${TONE_WHITE_GAIN}', String(TONE_WHITE_GAIN)); const sksl = resolve(tmpl); const maths = resolve(mathTmpl); +// The four windows, as the resolved maths emits them: each is compactly +// supported on its own stretch and ZERO outside it — a knob is the exact +// identity off its own band, which is what the monochrome stock's BLACK travel +// rides on (see the note in toneShader.ts) — and the black window is zero at +// L = 0 while the white one is zero at L = 1, so the two ends cannot move. The +// edges are matched at the SOURCE's numbers, not at literals of their own. +const text = (s) => new RegExp(s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); +assert.match(maths, text(`float toneBlackW(float L) {\n float u = clamp(1.0 - L / ${TONE_BLACK_EDGE}, 0.0, 1.0);`)); +assert.match(maths, /return u \* u \* u;\n\}/); +assert.match(maths, text('float toneShadowW(float L) {\n return smoothstep(0.02, 0.12, L) * (1.0 - smoothstep(0.25, 0.55, L));\n}')); +assert.match(maths, text('float toneHighW(float L) {\n return smoothstep(0.45, 0.65, L) * (1.0 - smoothstep(0.92, 1.0, L));\n}')); +assert.match(maths, text(`float toneWhiteW(float L) {\n float u = clamp((L - ${TONE_WHITE_EDGE}) / (1.0 - ${TONE_WHITE_EDGE}), 0.0, 1.0);\n return u * u;\n}`)); +// ...and the windows are the ONLY shape: the kernel the four bumps shared, the +// amplitudes they were summed with and the ceiling that guard held them under are +// gone from the CODE. The prose is not read for these — TONE_MATH_SKSL still +// tells the story of the guard it replaced, so a check on the comments would be +// checking the wrong thing. +const codeOf = (s) => s.replace(/\/\/[^\n]*/g, ''); +assert.doesNotMatch(codeOf(sksl), /toneBump/, 'the summed kernel is back — the four moves must be sequential'); +assert.doesNotMatch(codeOf(sksl), /holdLo|holdHi/, 'the shared ceiling is back — a stock drags another knob with it'); +assert.doesNotMatch(tone, /export const TONE_ANCHOR|export const TONE_BUMP_SLOPE/, 'the old anchor and guard constants are still declared'); +// The composition, move for move: the black pair (lift through the doc's square +// root, crush by the doc's rate, one or the other — both zero at L = 0, so the +// black point stays the black point and no knob leaves a pedestal), the shadow +// gain, the highlight knee against the headroom that is LEFT (the (1 - L) is the +// one deliberate departure from the doc's raw pow, which overshoots the cube), and +// the white Hermite (1 - L) * L — zero on BOTH ends, so the head cannot move. +assert.match(maths, text('float toneCurve(float L, float bl, float sh, float hl, float wh) {')); +assert.match(maths, text(` q = toneBlackW(L);\n L = bl > 0.0\n ? L + ${TONE_BLACK_LIFT} * bl * q * (sqrt(L) - L)\n : L * max(1.0 + ${TONE_BLACK_CRUSH} * bl * q, 0.0);\n L = clamp(L, 0.0, 1.0);`)); +assert.match(maths, text(' q = toneShadowW(L);\n L *= 1.0 + sh * q * pow(1.0 - L, 1.8);\n L = clamp(L, 0.0, 1.0);')); +assert.match(maths, text(` q = toneHighW(L);\n L += ${TONE_HIGH_GAIN} * hl * q * pow(max(L - 0.5, 0.0), 1.5) * (1.0 - L);\n L = clamp(L, 0.0, 1.0);`)); +assert.match(maths, text(` q = toneWhiteW(L);\n L += ${TONE_WHITE_GAIN} * wh * q * (1.0 - L) * L;`)); +// DR rides the same four moves with a share of each instead of masked terms of +// its own, so it cannot fight a knob over a band or invert the ramp. +assert.match(maths, text('float o = toneCurve(base, clamp(bl + dr * 0.12, -1.0, 1.0), clamp(sh + dr * 0.06, -1.0, 1.0),')); +assert.match(maths, /\n\s*clamp\(hl - dr \* 0\.09, -1\.0, 1\.0\), clamp\(wh - dr \* 0\.18, -1\.0, 1\.0\)\);/); +assert.doesNotMatch(maths, /o = clamp\(base/, 'the ramp clamps the base before the curve again — the ends of the ramp are the anchors'); assert.doesNotMatch(tmpl, /float a4 = /, 'the ramp is back inside the pass — one copy, not two'); const mask = readFileSync(new URL('../shared/utils/gradientMask.ts', import.meta.url), 'utf8'); assert.ok(mask.includes('${TONE_MATH_SKSL}'), 'the mask pass does not read the shared maths'); @@ -151,38 +200,17 @@ 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;/); +// The composition, and NOT a sum of bumps on the identity: the four moves are +// applied in sequence through their own windows, which is what makes one knob's +// travel independent of another's (the shared ceiling used to take 0.663 of +// BLACK's travel off on the monochrome stock) and the ramp monotone by +// construction rather than by a guard. Nor straight segments between knots: an +// angle in a tone curve is a Mach band — and neither a smoothstep through the +// knots, which bends the ramp by six code values in the quarter-tones with every +// knob on zero, where this pass also runs for the stock split tones and DR alone. +assert.doesNotMatch(sksl, /float blackA = |float whiteA = /, 'the amplitudes are summed again — the moves must be sequential'); assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again — it folds the flat stretch at 0.238'); -// 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 = clamp\(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\), 0\.0, 1\.0\);/); -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, /float lin\(/, 'straight segments between the knots are back — a knot is an angle in a tone curve'); assert.doesNotMatch(sksl, /clamp\(0\.25 \+ 0\.25/, 'a knob is clamped against a neighbour knot again'); // ...and the pixel rides the neighbourhood's move with its own difference from // it: Base' + Detail, ADDED and not scaled. Multiplying by the gain o / base is @@ -254,52 +282,73 @@ 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. +// The ramp as arithmetic — the same four windows, the same rates and the same +// sequential composition the SkSL above carries, so the shape is measured and +// not described. Each move is monotone for any amount in [-1, 1] and lands on 0 +// and 1 without moving either, so the composition is monotone by construction +// and the two ends of the ramp are FIXED POINTS whatever the sliders say. const clamp01 = (x) => Math.min(1, Math.max(0, x)); +const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x)); // Float-exact comparisons are a trap once a value has been through a division // and a multiply (x / 0.25 * 0.25 is not x) — assert to within a code value. const close = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-12, `${msg ?? ''} ${a} != ${b}`); -// 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}`); +// The readings below come out of this same arithmetic to five decimals, so the +// tolerance is the rounding of the number written down, well inside the code +// value the cube can see. +const near4 = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-4, `${msg ?? ''} ${a} != ${b}`); +// Exact to the last digit where it matters: a window of zero is not "nearly" +// zero, and that is the claim the independence checks rest on. +const exact = (a, b, msg) => assert.equal(a, b, `${msg ?? ''} ${a} != ${b}`); const smoothstep = (e0, e1, x) => { const u = clamp01((x - e0) / (e1 - e0)); return u * u * (3 - 2 * u); }; -const bump = (x, knot, halfWidth) => { - const u = (x - knot) / halfWidth; - const v = Math.max(0, 1 - u * u); - return v * v; +// The four windows, as TONE_MATH_SKSL emits them — zero outside their own band. +const toneBlackW = (L) => { + const u = clamp01(1 - L / TONE_BLACK_EDGE); + return u * u * u; }; -function ramp(t, k) { +const toneShadowW = (L) => smoothstep(0.02, 0.12, L) * (1 - smoothstep(0.25, 0.55, L)); +const toneHighW = (L) => smoothstep(0.45, 0.65, L) * (1 - smoothstep(0.92, 1.0, L)); +const toneWhiteW = (L) => { + const u = clamp01((L - TONE_WHITE_EDGE) / (1 - TONE_WHITE_EDGE)); + return u * u; +}; +// toneCurve, move for move, with the clamp the shader puts after each one: BLACK +// (the doc's lift through the square root, or the doc's crush), SHADOW (a gain on +// the light), HIGHLIGHT (the doc's knee against the headroom that is left) and +// WHITE (the Hermite (1 - L) * L). `afterBlack` is the L the shadow move reads — +// the value a stock's own SHADOW cannot drag, because BLACK runs before it. +function tones(t, k) { const { dr = 0, hl = 0, sh = 0, wh = 0, bl = 0 } = k; + let L = t; + let q = toneBlackW(L); + const blackA = clamp(bl + dr * 0.12, -1, 1); + L = blackA > 0 + ? L + TONE_BLACK_LIFT * blackA * q * (Math.sqrt(L) - L) + : L * Math.max(1 + TONE_BLACK_CRUSH * blackA * q, 0); + const afterBlack = clamp01(L); + L = afterBlack; + q = toneShadowW(L); + L *= 1 + clamp(sh + dr * 0.06, -1, 1) * q * (1 - L) ** 1.8; + L = clamp01(L); + q = toneHighW(L); + L += TONE_HIGH_GAIN * clamp(hl - dr * 0.09, -1, 1) * q * Math.max(L - 0.5, 0) ** 1.5 * (1 - L); + L = clamp01(L); + q = toneWhiteW(L); + L += TONE_WHITE_GAIN * clamp(wh - dr * 0.18, -1, 1) * q * (1 - L) * L; + return { o: clamp01(L), afterBlack }; +} +// ...plus the four ZONE masks, which the ramp does NOT read any more: they are +// the partition the stock split tones ride, one per quarter, each the doc's own +// smoothstep minus the tent before it. They are carried here because the checks +// below measure that partition; `o` is delegated to the shared maths above. +function ramp(t, k) { const blMask = 1 - smoothstep(0, 0.25, t); const shMask = clamp01(1 - smoothstep(0.25, 0.5, t) - blMask); const whMask = smoothstep(0.75, 1, t); const hlMask = clamp01(smoothstep(0.5, 0.75, t) - whMask); - 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 }; + return { ...tones(t, k), blMask, shMask, hlMask, whMask, maskSum: blMask + shMask + hlMask + whMask }; } // The tents never overlap — each is the doc's smoothstep minus the tent before @@ -314,8 +363,9 @@ for (let t = 0; t <= 1; t += 1 / 512) { 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. +// 0.50 midpoint is where all four are on zero — the quiet value for the split +// tones, which is all these masks weight. (The RAMP is another matter: nothing +// feeds it from here, and SHADOW's own window spans the middle.) assert.equal(ramp(0.125, {}).blMask, 0.5); assert.equal(ramp(0.125, {}).blMask, ramp(0.125, {}).shMask); assert.equal(ramp(0.25, {}).shMask, 1); @@ -326,17 +376,27 @@ 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'); +// Neither END of the ramp moves, at any setting: both are fixed points of every +// move above — the black pair is zero at L = 0 and the white pair is zero at +// L = 1 — so no combination of the four sliders can lift the toe or roll the +// head. There is no fixed midpoint any more: SHADOW is a gain on the light and +// its window spans the middle, so at t = 0.5 SHADOW +1 lands on 0.510636 and -1 +// on 0.489364, where the old sum of bumps met on zero. +close(ramp(0, { bl: 1 }).o, 0, 'BLACK lifted the toe off the floor'); +close(ramp(0, { bl: -1, sh: -1, hl: -1, wh: -1 }).o, 0, 'a knob moved the floor'); +close(ramp(1, { wh: 1 }).o, 1, 'WHITE moved the head'); +close(ramp(1, { wh: -1 }).o, 1, 'a knob moved the head'); +near4(ramp(0.5, { sh: 1 }).o, 0.51064, 'SHADOW no longer spans the middle'); +near4(ramp(0.5, { sh: -1 }).o, 0.48936, 'SHADOW no longer spans the middle'); // Monotone under EVERY combination of the four at full deflection, DR included. -// 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. +// The composition of monotone moves is monotone by construction, but each move is +// only piecewise — a window edge is a slope change — so this is the measurement +// of that. The grid is 1/512 thick, so a step can straddle an edge and backtrack +// inside the width of one cell: the worst measured is -0.00119 at t = 0.0977 +// (BLACK -1 with SHADOW -1, the two crush moves meeting under the toe window's +// edge — 0.30 of a code value), and the same sweep at 1/8192 reads -0.0000745, +// so the size of the cell is what the number is. A wider backtrack is a fold. const combos = []; for (const bl of [-1, 0, 1]) for (const sh of [-1, 0, 1]) @@ -344,109 +404,135 @@ for (const bl 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; +let worstAt = 0; +let worstCombo = null; 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; + if (prev !== null && o - prev < worst) { + worst = o - prev; + worstAt = t; + worstCombo = k; } 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). +assert.ok(worst >= -2e-3, `worst step ${worst} at ${worstAt} for ${JSON.stringify(worstCombo)} — the ramp is folded`); +// ...and every one of those combinations still lands on the two anchors, exactly. +for (const k of combos) { + exact(ramp(0, k).o, 0, `the toe moved at ${JSON.stringify(k)}`); + exact(ramp(1, k).o, 1, `the head moved at ${JSON.stringify(k)}`); +} +// A knob moves ONLY its own band. The windows are compactly supported, so off +// its own band a knob is the EXACT identity: the monochrome stock's own SHADOW +// (-0.20, or the classic stocks' -0.47) changes nothing where BLACK is working at +// t = 0.01, where the shared ceiling used to take 0.663 of BLACK's travel off that +// stock. That is the "kéo theo sự thay đổi của thông số khác" report, retired. +exact(ramp(0.01, { bl: -1 }).o, ramp(0.01, { bl: -1, sh: -0.47 }).o, 'SHADOW reached into BLACK\'s band'); +exact(ramp(0.90, { hl: -1 }).o, ramp(0.90, { hl: -1, sh: 0.47 }).o, 'SHADOW reached into HIGHLIGHT\'s band'); +exact(ramp(0.05, { sh: -1 }).o, ramp(0.05, { sh: -1, wh: 1 }).o, 'WHITE reached into SHADOW\'s band'); +// The reach of each knob, read off the formula. These are OUTPUT values of the +// ramp (`.o`) to five decimals, so near4 is the rounding of what is written down +// and nothing wider. BLACK - is the doc's crush and + the doc's lift; SHADOW is a +// gain on the light, so it reaches past 0.5 (0.51064 at +100 against 0.48936 at +// -100 — the bell under the window is not symmetric); HIGHLIGHT and WHITE move +// the head and neither clips it. +for (const [knob, sides] of [ + ['bl', [ + [-1, [[0.05, 0.03399], [0.10, 0.09254], [0.15, 0.14941], [0.20, 0.20], [0.50, 0.50]]], + [1, [[0.05, 0.09578], [0.10, 0.11329], [0.18, 0.18]]], + ]], + ['sh', [ + [1, [[0.10, 0.17412], [0.20, 0.33384], [0.50, 0.51064]]], + [-1, [[0.10, 0.02588], [0.20, 0.06616], [0.50, 0.48936]]], + ]], + ['hl', [ + [1, [[0.70, 0.76708], [0.80, 0.88216], [0.90, 0.96325], [0.95, 0.97579]]], + [-1, [[0.70, 0.63292], [0.80, 0.71784], [0.95, 0.92421]]], + ]], + ['wh', [ + [1, [[0.90, 0.93375], [0.95, 0.99008]]], + [-1, [[0.90, 0.86625], [0.95, 0.90992]]], + ]], +]) + for (const [amount, cases] of sides) + for (const [t, want] of cases) + near4(ramp(t, { [knob]: amount }).o, want, `${knob} at ${amount} on ${t}`); +// Nothing clips inside the ramp: HIGHLIGHT +100 at 0.95 is still under the +// ceiling of the cube, and the only interior value that reaches 1.0 is all four +// at +100 on a pixel already at the head — the anchors are what the clamp holds. +assert.ok(ramp(0.95, { hl: 1 }).o < 1, 'HIGHLIGHT +100 is clipping the head'); +exact(ramp(0.99, { bl: 1, sh: 1, hl: 1, wh: 1 }).o, 1, 'a full set of knobs on a bright pixel does not reach white'); +// DR rides the same four moves, a share of each (0.12 / 0.06 / -0.09 / -0.18), +// and the two ends are still fixed points: +0.12 of BLACK lifts nothing at +// L = 0 and -0.18 of WHITE rolls nothing at L = 1. The middle moves with the +// SHADOW share DR takes — 0.500638 where the old masked terms summed to a flat +// 0.5 — which is the DR a frame can see. +exact(ramp(0, { dr: 1 }).o, 0, 'DR lifted the toe off the floor'); +exact(ramp(1, { dr: 1 }).o, 1, 'DR rolled the head under 1.0'); +near4(ramp(0.5, { dr: 1 }).o, 0.50064, 'DR no longer moves the middle the way it did'); +// BLACK and SHADOW both at -1 under DR: the two crush moves meet under the toe +// window's edge and the 1/512 cell reads a backtrack there (the monotone sweep +// above), where the old additive terms folded the stretch outright. +exact(ramp(0, { dr: 1, bl: -1, sh: -1 }).o, 0, 'the floor is not the floor'); +near4(ramp(0.25, { dr: 1, bl: -1, sh: -1 }).o, 0.10998, 'DR moved off the 0.25 knot'); +near4(ramp(0.375, { dr: 1, bl: -1, sh: -1 }).o, 0.28063, 'DR folded the flat stretch'); +// A band can only be lifted at the cost of the slope inside it — the report this +// whole design answered — so each knob's own quarters are swept over the whole +// travel and bounded rather than left to a comment. SHADOW's window spans the +// middle, so both its quarters take the move: over the sweep the slope below the +// 0.25 knot reads 1.5958 down to 0.4042 and the band above it 1.5532 down to +// 0.4468, which is the quarter it is allowed to give up (neither may be drawn +// flat, and neither may be stretched past one and a half — a crush that flattens +// the darks into one black is the same defect upside down). for (let sh = -1; sh <= 1.0001; sh += 1 / 64) { const below = (ramp(0.25, { sh }).o - ramp(0, { sh }).o) / 0.25; const band = (ramp(0.5, { sh }).o - ramp(0.25, { sh }).o) / 0.25; - assert.ok(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}`); + assert.ok(below >= 0.40 - 1e-12, `SHADOW drew the quarter below it flat at ${sh}: slope ${below}`); + assert.ok(band >= 0.40 - 1e-12, `SHADOW drew the band above it flat at ${sh}: slope ${band}`); + assert.ok(below <= 1.60 + 1e-12, `SHADOW stretched the quarter below it at ${sh}: slope ${below}`); + assert.ok(band <= 1.60 + 1e-12, `SHADOW stretched the band above it at ${sh}: slope ${band}`); } -// HIGHLIGHT pays the same way and at BOTH ends, 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. +// HIGHLIGHT pays the same way and at BOTH ends — its knot is the head of the top +// quarter, so lifting it draws 0.75..1.00 flat and pulling it draws 0.50..0.75 — +// and its window is the narrower one, so its own spread over the sweep is 0.6875 +// to 1.3125 either side of the knot against the half it is allowed. for (let hl = -1; hl <= 1.0001; hl += 1 / 64) { const mid = (ramp(0.75, { hl }).o - ramp(0.5, { hl }).o) / 0.25; const top = (ramp(1, { hl }).o - ramp(0.75, { hl }).o) / 0.25; - assert.ok(mid >= 0.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}`); + assert.ok(mid >= 0.50 - 1e-12, `HIGHLIGHT drew the quarter below it flat at ${hl}: slope ${mid}`); + assert.ok(top >= 0.50 - 1e-12, `HIGHLIGHT drew the top quarter flat at ${hl}: slope ${top}`); + assert.ok(mid <= 1.50 + 1e-12, `HIGHLIGHT stretched the quarter below it at ${hl}: slope ${mid}`); + assert.ok(top <= 1.50 + 1e-12, `HIGHLIGHT stretched the top quarter at ${hl}: slope ${top}`); } -// The film stocks ride the same 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). +// The film stocks ride the same windows, so their numbers are their own. The +// knots they were tuned to are 0.18 Classic Chrome/Vivid, 0.22 Acros, 0.17 Acros +// HC, with the head 0.7375 on Acros and 0.815 on Acros HC — and through this +// curve they read 0.179992 / 0.220209 / 0.169565 and 0.7375 / 0.814844, so the +// two Acros toes land 2.1e-4 and 4.3e-4 under the target (the shadow window's own +// bell at 0.25, which the reach checks above have already measured). The entries +// are pinned to the values the ramp is actually read at, so a stock that drifts +// off its look is a red check. const filmTone = tone.match(/const FILM_TONE[\s\S]*?\n};/)?.[0]; assert.ok(filmTone, 'FILM_TONE is gone — the stocks no longer shape the ramp at all'); // The keys are quoted or not depending on whether they are identifiers, so the // quotes come off before the lookup. const filmFlat = filmTone.replace(/['"]/g, ''); for (const [name, sh, knot, hlSrc, hl, head] of [ - ['classic-chrome', -0.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], + ['classic-chrome', -0.47, 0.17999192], + ['classic-vivid', -0.47, 0.17999192], + ['monochrome', -0.2, 0.22020933, '-0.16', -0.16, 0.7375], + ['mono-high-contrast', -0.54, 0.16956519, '0.83', 0.83, 0.81484375], ]) { assert.ok( filmFlat.includes(`${name}: { sh: ${sh}`), - `${name} is not on the doubled ${sh} — the stock's crush moved with the knob's reach` + `${name} is not on the ${sh} its crush was solved at` ); - close(ramp(0.25, { sh }).o, knot, `${name}'s shadow knot moved`); + near4(ramp(0.25, { sh }).o, knot, `${name}'s shadow knot moved`); if (hlSrc) { - assert.ok(filmFlat.includes(`hl: ${hlSrc}`), `${name} is not on the doubled ${hlSrc} — its shoulder moved with the knob's reach`); - close(ramp(0.75, { hl }).o, head, `${name}'s highlight knot moved`); + assert.ok(filmFlat.includes(`hl: ${hlSrc}`), `${name} is not on the ${hlSrc} its shoulder was solved at`); + near4(ramp(0.75, { hl }).o, head, `${name}'s highlight knot moved`); } } // The two ends stay ordered even at full deflection against each other: the toe @@ -577,11 +663,12 @@ for (const [rgb, knobs] of [ // 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). +// comes out multiplied by the slope the curve has left over +// it — the grey sheet, drawn flat, 0.4163 of its own spread +// at this deflection (the shadow window is a gain whose bell +// is already falling through 0.26..0.50, so the quarter +// above the knot keeps less of itself than a straight +// segment would have kept). // read at the base every pixel of ONE neighbourhood takes the same move, // o(base) - base, and its own difference from the base is // added to it, so the texture inside the region comes out @@ -598,7 +685,7 @@ 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'); +near4(spread(movedGlobally) / spread(band), 0.41631, 'the pixel-read ramp no longer draws its own band flat'); const bandGain = ramp(bandBase, { sh: 1 }).o / bandBase; assert.ok(bandGain > 1.1, `the lift is not worth measuring: gain ${bandGain}`); close(spread(movedLocally) / spread(band), 1, 'the band lost its texture under the lift — the detail is being scaled again'); diff --git a/docker/frontend/scripts/tone-base-check.mjs b/docker/frontend/scripts/tone-base-check.mjs index f79c63a..a2ef73e 100644 --- a/docker/frontend/scripts/tone-base-check.mjs +++ b/docker/frontend/scripts/tone-base-check.mjs @@ -34,8 +34,8 @@ writeFileSync( ), ); const { - TONE_SKSL, TONE_ANCHOR, TONE_BUMP_SLOPE_HALF, TONE_BUMP_SLOPE_QUARTER, - TONE_BASE_RADIUS, TONE_BASE_SIGMA, getToneUniforms, toneUniformArray, toneIsActive, + TONE_SKSL, TONE_BASE_RADIUS, TONE_BASE_SIGMA, getToneUniforms, toneUniformArray, toneIsActive, + TONE_BLACK_EDGE, TONE_WHITE_EDGE, TONE_BLACK_LIFT, TONE_BLACK_CRUSH, TONE_HIGH_GAIN, TONE_WHITE_GAIN, } = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href); // The shader has to NAME a base child — a ring of taps would not need one. @@ -85,18 +85,48 @@ function render(srcValue, baseValue) { return pixels[0]; } -// The ramp, in the same arithmetic the shader runs: the identity plus one bump -// per knob, (1-u^2)^2 with u the distance to the knob's knot in half-widths. -// SHADOW carries half the anchor on 0.25 and its bump is the quarter-width one, -// so SHADOW +100 reads b + a1 * (1-u^2)^2 with a1 = 0.125 and u = (b-0.25)/0.25. -// (The ramp used to be straight between knots; see the note at the head of -// toneRamp for the Mach bands that retired it.) -const bump = (b, knot, halfWidth) => { - const u = (b - knot) / halfWidth; - const v = Math.max(0, 1 - u * u); - return v * v; +// The ramp, in the same arithmetic the shader runs: four COMPACT windows of the +// encoded luma — BLACK's toe dying on 0.18, SHADOW's bell over the deep tones, +// HIGHLIGHT against the headroom that is left, WHITE's Hermite on the shoulder — +// applied ONE AFTER THE OTHER and clamped on the way to the next, so the four +// moves compose instead of summing and the ramp needs no guard. SHADOW carries +// the whole knob at its own window, so SHADOW +100 reads toneCurve(b, 0, 1, 0, 0) +// (b and a1 are gone with the bumps; see the note at the head of toneRamp). +const clamp01 = (x) => Math.min(1, Math.max(0, x)); +const smoothstep = (e0, e1, x) => { + const u = clamp01((x - e0) / (e1 - e0)); + return u * u * (3 - 2 * u); }; -const ramp = (b) => b + TONE_ANCHOR * 0.5 * bump(b, 0.25, 0.25); +const toneBlackW = (L) => { + const u = clamp01(1 - L / TONE_BLACK_EDGE); + return u * u * u; +}; +const toneShadowW = (L) => smoothstep(0.02, 0.12, L) * (1 - smoothstep(0.25, 0.55, L)); +const toneHighW = (L) => smoothstep(0.45, 0.65, L) * (1 - smoothstep(0.92, 1.0, L)); +const toneWhiteW = (L) => { + const u = clamp01((L - TONE_WHITE_EDGE) / (1 - TONE_WHITE_EDGE)); + return u * u; +}; +// toneCurve, move for move, with the clamp the shader puts after each one. +// `afterBlack` is the L the SHADOW move reads. +function toneCurve(L, bl, sh, hl, wh) { + let q = toneBlackW(L); + L = bl > 0 + ? L + TONE_BLACK_LIFT * bl * q * (Math.sqrt(L) - L) + : L * Math.max(1 + TONE_BLACK_CRUSH * bl * q, 0); + L = clamp01(L); + const afterBlack = L; + q = toneShadowW(L); + L *= 1 + sh * q * (1 - L) ** 1.8; + L = clamp01(L); + q = toneHighW(L); + L += TONE_HIGH_GAIN * hl * q * Math.max(L - 0.5, 0) ** 1.5 * (1 - L); + L = clamp01(L); + q = toneWhiteW(L); + L += TONE_WHITE_GAIN * wh * q * (1 - L) * L; + return { o: clamp01(L), afterBlack }; +} +const ramp = (b, k = {}) => toneCurve(b, k.bl ?? 0, k.sh ?? 0, k.hl ?? 0, k.wh ?? 0); const srcValue = 128; // the pixel: 0.501961 encoded const baseValue = 76; // its neighbourhood, darker: 0.298039 const t = srcValue / 255; @@ -105,7 +135,7 @@ const b = baseValue / 255; // Base' + Detail, the reconstruction the shader emits (see the note in // toneRamp). The ratio Base' * (Input / Base) is what this used to predict, and // it is what took the texture out of the frame at BLACK -100. -const expected = Math.round(255 * (Math.min(1, Math.max(0, ramp(b))) + (t - b))); +const expected = Math.round(255 * clamp01(ramp(b, { sh: 1 }).o + (t - b))); const got = render(srcValue, baseValue); assert.ok( @@ -118,32 +148,38 @@ assert.ok( Math.abs(got - srcValue) > 8, `the pass returned the pixel (${got}) — it is reading its own sharp image as the base again`, ); -// With the base handed in as the sharp image the pass is the global move, which -// is what a mask (no neighbourhood of its own) needs it to be. +// With the base handed in as the sharp image the difference is exactly zero and +// the pass is the GLOBAL move — the ramp at the pixel — which is what a mask (no +// neighbourhood of its own) needs it to be. It is NOT the identity any more: +// the shadow window spans the middle, so the same knobs move a mid-grey. +const globalMove = Math.round(255 * clamp01(ramp(t, { sh: 1 }).o)); const selfBase = render(srcValue, srcValue); assert.ok( - Math.abs(selfBase - srcValue) <= 1, - `a base equal to the pixel must be the identity, got ${selfBase} for ${srcValue}`, + Math.abs(selfBase - globalMove) <= 2, + `a base equal to the pixel must be the ramp at the pixel (${globalMove}), got ${selfBase} for ${srcValue}`, +); +assert.ok( + Math.abs(selfBase - srcValue) > 2, + `the pass returned the pixel (${selfBase}) — the base is the sharp image again`, ); -// The guard, in the same arithmetic. BLACK and SHADOW at +100 share the lower -// half of the ramp and their bumps are read at their own steepest points, so the -// pair is the loosest two moves of a half can be: past a total slope of 1 the sum -// carries the curve BACKWARDS, which is a worse band than the seams it replaced. -// The guard holds the two under one and gives them up together past it. -const blackA = TONE_ANCHOR; // BLACK +100 -const shadowA = TONE_ANCHOR * 0.5; // SHADOW +100 -const lift = (b, k) => b + k * (blackA * bump(b, 0, 0.5) + shadowA * bump(b, 0.25, 0.25)); -const hold = TONE_BUMP_SLOPE_HALF * blackA + TONE_BUMP_SLOPE_QUARTER * shadowA; -assert.ok(hold > 1, `BLACK and SHADOW +100 no longer reach the guard (hold ${hold})`); +// There is no guard any more, and nothing to hold under one: the four moves are +// each monotone for any amount in [-1, 1] and clamped on the way to the next, so +// BLACK and SHADOW both at +100 compose into a ramp that still rises — measured +// on a 1/400 grid, where the two used to sum past a slope of 1 and carry the +// curve backwards — and BLACK's own travel no longer depends on the stock's +// SHADOW, because BLACK runs FIRST: the value SHADOW reads is the same whatever +// SHADOW says, which is the "kéo theo sự thay đổi của thông số khác" report. const rises = (f) => Array.from({ length: 400 }, (_, i) => f((i + 1) / 400) > f(i / 400)).every(Boolean); -// Both controls: the pair really does fold without the guard, and the guard is -// what stops it — a guard on a sum that never folded would be dead weight. -assert.ok(!rises((b) => lift(b, 1)), `the unguarded pair no longer folds — the guard holds nothing`); -assert.ok(rises((b) => lift(b, 1 / hold)), 'BLACK and SHADOW both at +100 fold the ramp back on itself'); +assert.ok(rises((b) => ramp(b, { bl: 1, sh: 1 }).o), 'BLACK and SHADOW both at +100 fold the ramp back on itself'); +assert.equal( + ramp(0.05, { bl: -1 }).afterBlack, + ramp(0.05, { bl: -1, sh: -0.2 }).afterBlack, + 'SHADOW drags BLACK’s own travel with it again', +); console.log( `tone base ok: pixel ${srcValue} over a base of ${baseValue} -> ${got} ` + - `(ramp predicts ${expected}, sharp-base identity ${selfBase}); ` + + `(ramp predicts ${expected}, global move ${globalMove}, sharp-base ${selfBase}); ` + `radius ${TONE_BASE_RADIUS} of the frame, sigma ${TONE_BASE_SIGMA} of it`, ); diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index 576481f..e68bcba 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -139,24 +139,30 @@ const BAND_BLOCK = hslBandGaps() ) .join(''); -// How far a tonal-range knob moves its own knot, in ramp units. A quarter is -// the reach the program this layout copies gives a slider: at full deflection -// the toe can reach the quarter above it and the head can be rolled onto it — -// and never past, because each knot is clamped inside the one after it. +// The four tonal-range knobs, as the four bands the ramp in TONE_MATH_SKSL owns. +// Each band is compactly supported — a knob is exactly the identity outside its +// own — which is what makes the four independent without a guard, and a guard is +// what they used to share: one ceiling over two amplitudes, so a film stock that +// already sits on SHADOW took the BLACK knob's travel down with it (0.663 of it +// on the monochrome stock). See the head of the ramp for the whole argument. // -// SHADOW and HIGHLIGHT move HALF of it. The knots they own (a1, a3) are the HEAD -// of a quarter, so a unit of lift is a unit of slope that quarter loses, and at -// a whole quarter the band is drawn flat — see the notes on a1 and a3. -export const TONE_ANCHOR = 0.25; +// The edges are the doc's (§2.1 and §2.4): BLACKS dies on 0.18, the middle grey +// the doc anchors the toe to, and WHITES starts on 0.80, its shoulder. +export const TONE_BLACK_EDGE = 0.18; +export const TONE_WHITE_EDGE = 0.80; -// How steep a bump of amplitude A gets, A excluded: the kernel (1-u^2)^2 tops out -// at |dk/du| = 8/(3*sqrt(3)) = 1.5396 on each side of its peak (u = 1/sqrt(3)), -// and u counts the distance to the knot in units of the bump's half-width. Over -// the half-width of BLACK and WHITE that is 3.08*A, over the quarter-width of -// SHADOW and HIGHLIGHT 6.16*A, and the two numbers are what the guard inside -// toneRamp holds two moves sharing a half of the ramp under. -export const TONE_BUMP_SLOPE_HALF = 3.0792; -export const TONE_BUMP_SLOPE_QUARTER = 6.1584; +// How much of its band a knob is worth at full deflection — 100 on the slider. +// BLACK's pair is the doc's own (0.7 opens the toe through the square root, 0.85 +// is how hard the crush bites). HIGHLIGHT and WHITE are measured rather than +// inherited: the doc prints the shapes and leaves the rates off, and its own +// sample — a raw pow(L, 1.5) with no headroom term, and a WHITE worth 0.5 — +// either clips or does nothing on a real frame. These are the largest rates that +// keep a full +100 inside the cube on the sweep in highlight-knee-check, with the +// head rolling into the shoulder instead of onto the clamp. +export const TONE_BLACK_LIFT = 0.7; +export const TONE_BLACK_CRUSH = 0.85; +export const TONE_HIGH_GAIN = 2.5; +export const TONE_WHITE_GAIN = 1.5; // How far out the BASE layer of `toneRamp` reads, as a fraction of the frame's // own width — the fix_shadow.md neighbourhood (it asks for 2%..5% of the width). @@ -196,65 +202,90 @@ export const TONE_BASE_SIGMA = 0.35; // has no such knob and hands in 0, which is what DR's terms are worth when it is // off on the frame too. export const TONE_MATH_SKSL = ` -// One knob's move, read at the position x on the ramp: 1 on the knob's own knot, -// falling to 0 on the knot a half-width away and staying there — (1-u^2)^2 with u -// the distance to the knot in half-widths. Level at u = 0 and at u = 1, so the -// move joins the ramp without an angle at either end. -float toneBump(float x, float knot, float half_width) { - float u = (x - knot) / half_width; - float v = max(0.0, 1.0 - u * u); - return v * v; +// THE FOUR TONAL KNOBS, as thay_doi_thong_so_giong_lightroom.md §2 asks for them: +// each one owns a COMPACT band of the ramp and is exactly ZERO outside it, and the +// four moves are applied ONE AFTER THE OTHER instead of summed. +// +// The sum came with a guard, and the guard is where the knobs touched. Two bumps +// of a half share a slope, so past a total of 1 the sum carries the curve +// backwards — a fold — and the fix was one ceiling (holdLo / holdHi) shared by +// the amplitudes of a half. A stock that already sits on SHADOW therefore took +// BLACK's lift down with it: on the monochrome stock (sh = -0.24) BLACK -100 came +// back with 0.663 of the travel the knob has on its own, which is the "kéo theo +// sự thay đổi của thông số khác" report exactly. A composition of monotone maps +// is monotone by construction, so it needs no guard, and the same knob then +// measures 1.00 of its travel on every stock. (tone-curve, the arithmetic in +// plain JS on the scratchpad, and the grid sweep in highlight-knee-check.) +// +// The windows are the doc's own, in the encoded luma this whole file works in — +// the boundaries land where the doc's diagram draws them, BLACKS on the toe up to +// the doc's 0.18, SHADOWS as a bell over the deep tones, HIGHLIGHTS as a bell over +// the bright ones, WHITES on the shoulder from 0.80. +float toneBlackW(float L) { + float u = clamp(1.0 - L / ${TONE_BLACK_EDGE}, 0.0, 1.0); + // Squared once more than the doc's square for the same reason the sum's kernel + // was (1-u^2)^2: the join with the identity at L = 0.18 is a slope, not an + // angle, and an angle on a tone curve is a Mach band. + return u * u * u; } -// The ramp the pixel is rebuilt through: the identity plus four moves. Each knob -// owns a bump that PEAKS on its knot — BLACK on 0.00, SHADOW on 0.25, HIGHLIGHT -// on 0.75, WHITE on 1.00 — and is back on zero where the next one starts, a -// quarter away (a half for the two ends, their knots being the ends of the ramp), -// so the two bumps of a half meet on 0.50 at zero height AND zero slope: a knob -// moves its own end of the ramp and nothing else, and 0.50 is fixed — the one -// point all four sliders leave alone, which is what keeps a mid-grey a mid-grey -// while the ends move around it. Every knob on zero leaves the sum empty, so the -// whole thing is exactly the identity (see the note at the head of this file). -// -// Straight segments between the knots were the first cut, and the measurement is -// what retired them: a segment meets its neighbour at an angle, and an angle in a -// tone curve is a Mach band. On a 1024-step luma wedge (scratchpad probe, JPEG -// 8-bit, second derivative through a ±1% box) BLACK +100 came back with 105 at -// 0.030 while the stretch above it was untouched to the last bit — 0.0000 from -// 0.25 up, the "transition stays grey" the knob was reported for — and HIGHLIGHT -// -100 broke at 0.747 with 72, the seam between the lifted highlights and the -// shadow under them. The kernel is (1-u^2)^2: level at the peak, so a knot moves -// without a fold at its own top, level where it lands, so the join on 0.50 and -// the toe on 0.00 stay clean, and C1 at both ends of a move and everywhere -// between, which is the whole of what a Mach band asks for. -// -// DR moves the same four bumps instead of adding its own masked terms on top: it -// lifts the toe and rolls the head exactly as before at t = 0 and t = 1 — -// 0.12 and 0.18 at full strength — and half of each at the knots next to them, -// so the terms land on the two ends and on the heads beside them as one move. -// -// Lightness takes the curve; the colour rides the ratio. The pixel moves to its -// new luma, and it gets there by scaling its three channels by ONE number, -// k = o / t: the doc's R_new = R_old * Luma_new / Luma_old. The differences move -// with the light, so the hue cannot turn, the saturation cannot be drained by a -// lift — an HSL saturation is a ratio of differences and never sees a common -// scale — and a neutral, with no difference to carry, lands on o exactly. -// -// The scale used to be 1.0 instead: keep the chroma, move the luma. That holds -// the hue and loses the colour, which is the complaint the ratio answers — the -// scratchpad probe (sh-bl-hue.mjs) measured a dark red at 0.746 of saturation -// coming back at 0.505 with SHADOW at +100, and at 0.370 with SHADOW and BLACK -// both at +100. A chroma held under a rising luma IS a colour going grey, and it -// is the two sliders that lift a shadow (SHADOW, BLACK) that raise the luma of a -// dark pixel the furthest. -// -// What the ratio cannot do on its own is fit, and that is what the caps below are -// for: past the ceiling a channel clips outright, the three stop being scaled -// together and the hue goes with them (measured on the other probe, a skin tone -// at 24.0° came back at 48.0° at HIGHLIGHT +100, a warm white at 37° at 57.4°). -// Pulling the ONE scale back while the cube has room for no more costs saturation -// instead: a pixel the cube is against arrives neutral before it arrives wrong, -// and one the curve has driven all the way to 1.0 arrives at white. +float toneShadowW(float L) { + return smoothstep(0.02, 0.12, L) * (1.0 - smoothstep(0.25, 0.55, L)); +} +float toneHighW(float L) { + return smoothstep(0.45, 0.65, L) * (1.0 - smoothstep(0.92, 1.0, L)); +} +float toneWhiteW(float L) { + float u = clamp((L - ${TONE_WHITE_EDGE}) / (1.0 - ${TONE_WHITE_EDGE}), 0.0, 1.0); + return u * u; +} +// The ramp the pixel is rebuilt through. Read at the BASE, so the move is the +// neighbourhood's and the pixel keeps its own difference from it (fix_shadow.md's +// Base' + Detail): the ratio Base' * (Input / Base) was the first cut and it is +// what broke BLACK — it scales the detail by the neighbourhood's gain, so the +// knob that takes the base toward zero takes the picture's texture with it (the +// blur the report named on a monochrome frame, where every channel IS the luma). // +// Every move below is monotone for any amount in [-1, 1] and lands on L = 0 and +// L = 1 without moving either, so the composition is monotone, the black point is +// the black point, and the white point is the white point whatever the four +// sliders say. The one deliberate departure from the doc's own arithmetic is the +// (1.0 - L) on HIGHLIGHTS: the doc's raw soft-knee ADDS pow(L - 0.5, 1.5) to L, +// and above 0.94 that overshoots the cube — a measured 17% of the ramp driven to +// flat white at +100 before the clamp, the "cháy vùng Whites" the doc's own §1 +// opens by calling a defect. The knee reads the headroom that is left instead, so +// the move is zero at L = 1 by construction and the head rolls instead of +// clipping. +float toneCurve(float L, float bl, float sh, float hl, float wh) { + float q; + // BLACKS, the toe. Lift opens the detail under the doc's square root, crush + // deepens it by the doc's 0.85, and neither touches the anchor: sqrt(0) - 0 is + // 0, so the black point is exactly where it was — the offset that lifted + // (0,0,0) to a grey pedestal was a SUM adding its bump's height at L = 0, which + // is the doc's "Milky / Foggy" failure. Past 0.18 the window is 0 and the move + // is exactly the identity. + q = toneBlackW(L); + L = bl > 0.0 + ? L + ${TONE_BLACK_LIFT} * bl * q * (sqrt(L) - L) + : L * max(1.0 + ${TONE_BLACK_CRUSH} * bl * q, 0.0); + L = clamp(L, 0.0, 1.0); + // SHADOWS, a gain on the light with the floor still on 0: the multiplier is + // 1 + amount * bell * (1 - L)^1.8, so the window's own bell already keeps it + // off the midtones the doc names and the exponent keeps it off the white end. + q = toneShadowW(L); + L *= 1.0 + sh * q * pow(1.0 - L, 1.8); + L = clamp(L, 0.0, 1.0); + // HIGHLIGHTS, the doc's soft-knee against the headroom that is left — see the + // note above the function. max(L - 0.5, 0) because the pow is undefined under + // the knee and the window is only wide where it is not. + q = toneHighW(L); + L += ${TONE_HIGH_GAIN} * hl * q * pow(max(L - 0.5, 0.0), 1.5) * (1.0 - L); + L = clamp(L, 0.0, 1.0); + // WHITES, the doc's Hermite on the shoulder: (1 - L) * L is zero on both ends, + // so the white point is fixed and the move is spent inside the top of the ramp. + q = toneWhiteW(L); + L += ${TONE_WHITE_GAIN} * wh * q * (1.0 - L) * L; + return clamp(L, 0.0, 1.0); +} // Below t = 0.0004 there is no ratio worth the name: dividing by what is left of // a pixel that has almost no light on it takes whatever cast the last code value // of 8-bit noise left there and multiplies it by the pedestal the BLACK knob just @@ -277,87 +308,21 @@ vec3 lightMove(vec3 c, float t, float o) { return clamp(vec3(o) + (c - vec3(t)) * k, 0.0, 1.0); } vec3 toneRamp(vec3 c, float t, float base, float bl, float sh, float hl, float wh, float dr) { - // BLACK owns 0.00, the edge of the ramp, so its bump is the half-width one and - // the toe opens to A while the quarter above it takes the fall of it. As a - // bump the fall is a slope and never a plateau, which is what the report was - // about: at +100 the lift used to be spent inside 0.25 and the stretch above it - // came back identical to the untouched frame — the measured 0.0000 from 0.25 up - // in the note at the top of the ramp. Here the mid-tones rise a little with the - // toe and the corner that drew the seam at 0.25 is gone. - float blackA = ${TONE_ANCHOR} * bl + dr * 0.12; - // SHADOW rides HALF the anchor, and that is a measured ceiling rather than a - // taste. The knot is the HEAD of the quarter above it, and the bump only ever - // ADDS to the identity, so that quarter pays in slope for every unit the knot - // rises: its mean slope is 1 - A / 0.25, and that band is where a waterfall's - // spray and a sunlit rock sit. At the whole anchor the mean is 0 and the band - // is drawn flat — on a real frame (DSCF1701, misty valley, 21% of its pixels in - // the band) SHADOW +90 came back with 0.10 of the band's own spread and a mean - // of 0.392 where it was 0.489, the milky white sheet the knob was reported for, - // bright areas with their contrast gone. At half: 0.55 of the spread, mean - // 0.441, and the toe still opens at 1.45x. Half also keeps the knob clear of - // the guard below: at a whole-anchor rate the guard would take over past ±65 - // and the top third of the slider would do nothing, which is why the RATE is - // halved and not just a ceiling. - float shadowA = ${TONE_ANCHOR} * 0.5 * sh + dr * 0.06; - // HIGHLIGHT rides half the anchor for the reason SHADOW does, and its knot is - // the head of the 0.75..1.00 quarter: at a whole-anchor rate +100 (A = 0.25) - // draws that quarter flat against the ceiling, its whole mean slope gone, and - // the same knob the other way spends the quarter BELOW the knot instead. Half - // draws neither, and the head still rolls from 0.625 to 0.875 over the travel. - float highA = ${TONE_ANCHOR} * 0.5 * hl - dr * 0.09; - float whiteA = ${TONE_ANCHOR} * wh - dr * 0.18; - // Two bumps share each half of the ramp, and their steep sides can land on the - // same stretch: past a total slope of 1 the sum would carry the curve - // BACKWARDS — a fold, a worse band than the seams this replaced. Read at their - // own steepest points, which is the loosest the pair can be, the two amplitudes - // of a half are held under one number and given up together past it. A knob on - // its own never reaches it — a full BLACK is 0.77, a full SHADOW 0.77, and DR, - // which moves both bumps of a half at once, 0.74 below and 1.11 above for a - // steepest sum of 1.00, so a full DR gives up a tenth of its head roll — so a - // single slider has its whole travel, and only a pair pushed together gives - // anything up: BLACK and SHADOW both at +100 arrive at 0.65 of their own lift - // rather than folding. - float holdLo = ${TONE_BUMP_SLOPE_HALF} * abs(blackA) + ${TONE_BUMP_SLOPE_QUARTER} * abs(shadowA); - float holdHi = ${TONE_BUMP_SLOPE_QUARTER} * abs(highA) + ${TONE_BUMP_SLOPE_HALF} * abs(whiteA); - float kLo = holdLo > 1.0 ? 1.0 / holdLo : 1.0; - float kHi = holdHi > 1.0 ? 1.0 / holdHi : 1.0; - blackA *= kLo; - shadowA *= kLo; - highA *= kHi; - whiteA *= kHi; - // The ramp is read at the BASE, so the curve's move is the neighbourhood's, - // and the pixel keeps its own DIFFERENCE from that base — fix_shadow.md's - // Base' + Detail, added and not scaled. The ratio Base' * (Input / Base) was - // the first cut and it is what broke BLACK: multiplying by the neighbourhood's - // gain o / base scales the detail by that gain, so the knob that takes o toward - // zero takes the picture's texture with it. Measured on the sample frame at - // BLACK -100 (tone-sim, the pass in plain JS at 1024px): the finest gradient - // came back at 0.75 of the input under the ratio and at 0.98 under the sum, - // while the frame darkened the same either way (mean 0.416 -> 0.359 both). On a - // monochrome stock every channel IS the pixel's luma, so the ratio's error is - // the whole frame there: the pass returned the blurred BASE, which is the soft - // picture the knob was reported for. The bright side of the same move is kept — - // the lift is still the neighbourhood's, and the band above a lifted knot still - // keeps its spread (0.81 of it at SHADOW +100, where the global move kept 0.42). - // - // The pixel's own luma still travels as t: lightMove rebuilds the colour from - // it, and it is the value the caps read. - // - // o is held inside the cube BEFORE the detail is added: a base the ramp has - // pushed under the floor is a neighbourhood whose black point is below zero, - // and clipping o first keeps the structure around it instead of carrying the - // pedestal down onto every pixel in the region. - float o = clamp(base - + blackA * toneBump(base, 0.00, 0.50) - + shadowA * toneBump(base, 0.25, 0.25) - + highA * toneBump(base, 0.75, 0.25) - + whiteA * toneBump(base, 1.00, 0.50), 0.0, 1.0); + // DR is the whole frame's DYNAMIC RANGE, and it rides the same four moves the + // sliders do rather than adding masked terms of its own: a recovery that lifts + // the toe and rolls the head is a BLACK and a WHITE, and after the windows a + // tenth of a unit lands inside the band the knob owns instead of on the whole + // frame. A mask has no such knob and hands in 0, which is what these terms are + // worth when DR is off on the frame too. + float o = toneCurve(base, clamp(bl + dr * 0.12, -1.0, 1.0), clamp(sh + dr * 0.06, -1.0, 1.0), + clamp(hl - dr * 0.09, -1.0, 1.0), clamp(wh - dr * 0.18, -1.0, 1.0)); // A caller with no neighbourhood of its own (a mask) hands in the pixel as its // base, and the difference is then exactly zero: the target is the ramp at t, // the global move, which is what the ratio gave it too. float target = o + (t - base); return lightMove(c, t, clamp(target, 0.0, 1.0)); } + // The accurate sRGB transfer pair (0.04045/12.92 + 2.4, and its inverse): the // same constants colorUtils.planckianLinear uses on the WB side, and the reason // a stop is a stop here. EXPOSURE needs it — 2^ev is a multiplier on LIGHT — and @@ -874,17 +839,20 @@ export interface ToneUniforms { // here instead of in the 4x5 matrix, which cannot move one end of the curve // without also moving the other. // -// The `sh` and `hl` here are written at DOUBLE what the knob's own arithmetic -// reads, which is not a taste either: SHADOW and HIGHLIGHT each move their knot -// half an anchor per unit (see a1 and a3 in TONE_MATH_SKSL), so a stock that -// wants the toe on 0.18 has to ask for -0.56 to land it there. The stock's crush -// is the knot it puts the ramp on; the unit it is written in belongs to the -// knob, not to the look. +// The `sh` and `hl` here are written in the KNOB's unit, not the look's: a stock +// that wants its toe on 0.18 asks the SHADOW knob for whatever the window is +// worth there (1.0 at 0.25) times (1 - 0.25)^1.8, which is -0.47 on this ramp. +// The numbers moved when the ramp did — the four knobs are windows now, not +// summed bumps, and the doc's own rates (§2) replaced the quarter-anchor ones — +// so the knots below are re-solved against the new curve rather than tuned by +// eye: 0.18 / 0.22 / 0.17 on the toe and 0.7375 / 0.815 on the head, the values +// the stocks were written against, land where they always did, which is what +// highlight-knee-check pins. const FILM_TONE: Partial>> = { - 'classic-chrome': { sh: -0.56 }, + 'classic-chrome': { sh: -0.47 }, // Classic Vivid is Classic Chrome's sibling — the shadow crush belongs to the // stock, not to the matrix rows, so it comes along. - 'classic-vivid': { sh: -0.56 }, + 'classic-vivid': { sh: -0.47 }, 'classic-neg': { shT: [-0.018, 0.009, 0.013], hlT: [0.024, 0.008, -0.012] }, // Acros. A black-and-white stock IS its grey ramp, so this entry only shapes // the two ENDS and leaves the middle an identity: a smooth shadow toe that @@ -893,19 +861,19 @@ const FILM_TONE: Partial>> = { // paper. Mid-tones are between the 0.25 and the 0.75 knots, so they keep // every step the matrix handed over — which is what 'deep black' costs in a // colour stock and does not have to cost here. - // The values move the two end knots of the ramp: -0.24 puts the toe on 0.22 - // and -0.10 rolls the head to 0.7375 (both SHADOW and HIGHLIGHT ride HALF an - // anchor per unit, so a stock written against the knot asks for double). - monochrome: { sh: -0.24, hl: -0.10 }, + // The values move the two end bands of the ramp: -0.20 puts the toe on 0.22 + // and -0.16 rolls the head to 0.7375 (both solved against the window's own + // height at 0.25 and 0.75, see the note above FILM_TONE). + monochrome: { sh: -0.20, hl: -0.16 }, // B&W HIGH CONTRAST. Acros' ramp with both ends pushed hard: a deeper toe - // (-0.64 against Acros' -0.24, so 0.17 against 0.22) so the darks reach true - // black, and a shoulder that LIFTS instead of rolling (-0.10 → +0.52, the + // (-0.54 against Acros' -0.20, so 0.17 against 0.22) so the darks reach true + // black, and a shoulder that LIFTS instead of rolling (-0.16 → +0.83, the // head going to 0.815), which is the whites step of the brief. The stretch // between the two inner knots (0.25 and 0.75) is still the identity, so the // long smooth stretch of the greys survives — that is what keeps a hard push // off the posterised look, and the strength the stock needs on the greys is // its matrix slope (SIM_CONTRAST_BIAS in colorUtils), not another move here. - 'mono-high-contrast': { sh: -0.64, hl: 0.52 }, + 'mono-high-contrast': { sh: -0.54, hl: 0.83 }, }; // The base layer's neighbourhood is the caller's business, not this function's: