diff --git a/docker/frontend/scripts/highlight-knee-check.mjs b/docker/frontend/scripts/highlight-knee-check.mjs index 2ce85ff..ff51860 100644 --- a/docker/frontend/scripts/highlight-knee-check.mjs +++ b/docker/frontend/scripts/highlight-knee-check.mjs @@ -16,13 +16,15 @@ // 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. The 0.50 -// midpoint is the one value all four leave where it was. +// owns by TONE_ANCHOR of the ramp, held inside the knot before it; SHADOW, whose +// knot is the head of the band above it rather than an end of the ramp, moves +// 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 and the partition of the four masks are -// checked rather than asserted in a comment. +// so monotonicity, the neutral identity, the partition of the four masks and the +// slope SHADOW leaves the band above it are checked rather than asserted in a +// comment. // // node scripts/highlight-knee-check.mjs import assert from 'node:assert/strict'; @@ -83,7 +85,7 @@ assert.match(sksl, /float hlMask = clamp\(smoothstep\(0\.50, 0\.75, t\) - whMask // 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 \* hl - dr \* 0\.09, 0\.5, a4\);/); -assert.match(sksl, /float a1 = clamp\(0\.25 \+ 0\.25 \* sh \+ dr \* 0\.06, 0\.0, 0\.5\);/); +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 @@ -173,7 +175,7 @@ function ramp(t, k) { 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 * hl - dr * 0.09)); - const a1 = Math.min(0.5, Math.max(0, 0.25 + A * sh + dr * 0.06)); + 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)); @@ -235,12 +237,15 @@ for (const k of combos) { } 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, +SHADOW puts the 0.25 knot on the midpoint, -HIGHLIGHT pulls the 0.75 -// knot onto it, 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. +// floor, -HIGHLIGHT pulls the 0.75 knot onto 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 is the exception and moves HALF of +// it: its knot is the HEAD of the 0.25..0.50 band, so a whole quarter would draw +// that band flat, which is the wash-out the knob was 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.5, 'SHADOW no longer reaches the midpoint'); +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.5, 'HIGHLIGHT no longer reaches the midpoint'); 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'); @@ -259,11 +264,48 @@ 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, and the stretch between them is a straight -// line up to the midpoint — never a step down. +// 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.06); -close(ramp(0.375, { dr: 1, bl: -1, sh: -1 }).o, 0.28, 'DR folded the flat stretch'); +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}`); +} +// The film stocks ride the same knot, so a halved SHADOW would have halved their +// crush with it. They are written at DOUBLE for that reason, and the look they +// were tuned to is the knot, not the unit: these three land where they always +// did (0.18 Classic Chrome/Vivid, 0.22 Acros, 0.17 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] of [ + ['classic-chrome', -0.56, 0.18], + ['classic-vivid', -0.56, 0.18], + ['monochrome', -0.24, 0.22], + ['mono-high-contrast', -0.64, 0.17], +]) { + 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`); +} // 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]) diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index 0bc334e..4302578 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -32,7 +32,11 @@ import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils'; // measurement is monotone for every combination of the four at full deflection. // A knob moves its anchor by a quarter of the ramp, so +10 BLACKS puts the toe // on 0.25 and -10 WHITES rolls the head down to 0.75: the reach a tonal range -// slider has in the program this layout copies, without the inversion. +// slider has in the program this layout copies, without the inversion. SHADOW is +// the one knob held to half of that, because the knot it moves is the HEAD of +// the quarter above it and not an end of the ramp: a band cannot be lifted at +// its head and keep its slope at the same time, so the knob's travel is what has +// to give — see the measured note on a1. // // WHITE and BLACK are not the per-channel toe and shoulder they were on the WB // tab any more. The doc puts the two points on the ends of the SAME ramp as the @@ -103,9 +107,12 @@ const BAND_BLOCK = hslBandGaps() // 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 four together can put the toe on the midpoint, the head on it, or either -// end on the quarter next to it — and never past, because each knot is clamped -// inside the one before it. +// 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. +// +// SHADOW is the one knob that moves HALF of it. The knot it owns is the HEAD of +// the 0.25..0.50 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 note on a1. export const TONE_ANCHOR = 0.25; // The tone and exposure maths, in ONE copy, because two passes ask it: the @@ -125,8 +132,9 @@ float lin(float e0, float e1, float x) { return clamp((x - e0) / (e1 - e0), 0.0, 1.0); } // The ramp the pixel is rebuilt through. Knots on 0.00, 0.25, 0.50, 0.75 and -// 1.00; a knob moves the knot it owns by TONE_ANCHOR of the ramp, and each knot -// is held inside the one before it so the five can never cross. 0.50 is fixed: +// 1.00; a knob moves the knot it owns by TONE_ANCHOR of the ramp — SHADOW by +// half of that, its knot being the head of a band — and each knot is held inside +// the one after it so the five can never cross. 0.50 is fixed: // it is the one point all four sliders leave alone, which is what keeps a // mid-grey a mid-grey while the ends move around it. Straight between the knots, // so every knob on zero is exactly the identity (see the note at the head of @@ -186,7 +194,19 @@ vec3 lightMove(vec3 c, float t, float o) { vec3 toneRamp(vec3 c, float t, float bl, float sh, float hl, float wh, float dr) { float a4 = 1.0 + ${TONE_ANCHOR} * wh - dr * 0.18; float a3 = clamp(0.75 + ${TONE_ANCHOR} * hl - dr * 0.09, 0.5, a4); - float a1 = clamp(0.25 + ${TONE_ANCHOR} * sh + dr * 0.06, 0.0, 0.5); + // SHADOW rides HALF the anchor, and that is a measured ceiling rather than a + // taste. a1 is the HEAD of the quarter above it, so the band 0.25..0.50 pays + // in slope for every unit this knot rises — the slope is (0.5 - a1) / 0.25 — + // and that band is where a waterfall's spray and a sunlit rock sit. At the + // whole anchor the knot lands ON the midpoint and draws the band 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 smooth over its whole + // travel — a clamp alone would pin it from +50 on and leave the top of the + // slider dead — which is why the RATE is halved and not just the ceiling. + float a1 = clamp(0.25 + ${TONE_ANCHOR} * 0.5 * sh + dr * 0.06, 0.0, 0.5); float a0 = clamp(${TONE_ANCHOR} * bl + dr * 0.12, 0.0, a1); float o = mix(a0, a1, lin(0.00, 0.25, t)); o = mix(o, mix(a1, 0.5, lin(0.25, 0.50, t)), step(0.25, t)); @@ -638,11 +658,17 @@ export interface ToneUniforms { // Chrome crushes the shadows hard while muting colour. Those two parts live // here instead of in the 4x5 matrix, which cannot move one end of the curve // without also moving the other. +// +// The `sh` here is written at DOUBLE what the knob's own arithmetic reads, which +// is not a taste either: SHADOW moves its knot half an anchor per unit (see a1 +// 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. const FILM_TONE: Partial>> = { - 'classic-chrome': { sh: -0.28 }, + 'classic-chrome': { sh: -0.56 }, // 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.28 }, + 'classic-vivid': { sh: -0.56 }, '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 @@ -651,18 +677,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.12 puts the toe on 0.22 - // and -0.05 rolls the head to 0.7375 (each is TONE_ANCHOR = 0.25 per unit). - monochrome: { sh: -0.12, hl: -0.05 }, + // The values move the two end knots of the ramp: -0.24 puts the toe on 0.22 + // and -0.05 rolls the head to 0.7375 (HIGHLIGHT is TONE_ANCHOR per unit, the + // doubled SHADOW half of it). + monochrome: { sh: -0.24, hl: -0.05 }, // B&W HIGH CONTRAST. Acros' ramp with both ends pushed hard: a deeper toe - // (-0.32 against Acros' -0.12, so 0.17 against 0.22) so the darks reach true + // (-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.05 → +0.26, 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.32, hl: 0.26 }, + 'mono-high-contrast': { sh: -0.64, hl: 0.26 }, }; export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): ToneUniforms {