From acbb2bba4b9b62468752bf0c3872816edd6aa8ae Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 2 Oct 2026 10:54:54 +0700 Subject: [PATCH] web: the four tonal knobs are sized by what the eye can see, and the highlight head is squared so a bigger rate fits inside the cube MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report was "giá trị thay đổi của các thông số quá nhỏ, không thể hiện được trên thị giác của ảnh" — at the doc's own rates a full +100 was worth 0.060 of luma on BLACKS, 0.082 on HIGHLIGHTS and 0.042 on WHITES, and the probe that ran the frame through the pass read BLACKS +100 moving its mean by 0.0001. Every rate below is now the largest its own move allows, measured rather than inherited: 0.058 -> 0.080 on BLACKS, 0.082 -> 0.113 on HIGHLIGHTS and 0.042 -> 0.080 on WHITES, with SHADOWS' 0.151 left where it was because it already bit. - `TONE_BLACK_LIFT` 0.7 -> 0.93. The ceiling is a FOLD, not a slope: past 0.9387 the doc's own square root carries luma backwards inside its window (0.94 folds 3.4e-6, 0.95 folds 8.9e-5) and a gradient wears it as a band. 0.93 is the last round rate under it — monotone on the check's 1/32768 grid, and the 1e-4 of travel between it and 0.94 is not a code value. - `TONE_BLACK_CRUSH` 0.85 -> 8.0. The doc's own form — `L * (1 + amount * W * 0.85)` — is bounded by its own window, which is 1 only AT the floor, so its whole visible travel at full -100 is 0.019 of luma: five code values on a black patch, and a rate past 1 drives the product negative and clips the toe to a flat black instead of deepening it. The toe's own EXPONENT, `L -> W * (L/W)^(1 + rate * W)`, is monotone for ANY rate and worth 0.054, while x = 0 stays on 0 and the 0.18 edge stays on 1 — both anchors and the compact support kept. - `TONE_HIGH_GAIN` 2.5 -> 14.0, with the head term moved from `(1 - L)` to `(1 - L)^2`. The linear headroom dies too slowly to keep the rate's own ceiling off the clamp: above a gain worth 2.6 the move overshoots 1.0, the clamp draws a plateau and the ramp falls back over it by 0.018 — the fold the knee check now measures as a drawdown from the running maximum. Squared, the move has died out by the time the ramp reaches the clamp: 14.0 is fold-free at both signs and still lands 1.44x of the 1.5x the quarter it owns is allowed. - `TONE_WHITE_GAIN` 1.5 -> 3.0, which takes the top of the ramp TO the ceiling from 0.92 up and leaves the clamp to flatten what is left. That is the doc's own §2.4, where a WHITE is the frame's clipping point ("giới hạn cháy sáng") and not a Hermite that cannot move the head; it is felt only above the 0.80 shoulder and the head is still exactly 1.0 on 1.0. `FILM_TONE` is re-solved for the squared head, which is worth less at the 0.75 knot for the same rate: monochrome -0.16 -> -0.1143 and mono-high-contrast +0.83 -> +0.5929. The four knots the stocks are tuned to do not move — 0.22 and 0.7375 on Acros, 0.17 and 0.815 on Acros HC — and the check pins each of them. The knee check's guard is REPLACED. The old one compared the first cell of the sweep against the second (a slope at 1/512), which is blind to a fold that starts later: it passed a ramp whose own drawdown was 0.018. The new one walks 1/32768 of the ramp and measures `running max - value` for each knob at both signs, over the four moves alone and then over a 243-combination sweep, so what is pinned is the fold itself and where it is. Two folds are pinned rather than removed, both named in the check: - SHADOWS -100 dips 0.018 (4.6 code values) around 0.06..0.11 of its own accord. It is the doc's §2.2 formula — `L *= 1 + amount * W * (1 - L)^1.8` — where the window rises faster than the light, and it PREDATES this change. The monotone rewrite (`L' = 1 - (1 - L)^(1 - SH * |a| * W(L))`) is written out beside it and was NOT taken: it is exact but it costs the knob 30-50% of its crush. - WHITE +100 rests a plateau on the clamp from 0.92 up. That is what §2.4 asks of the knob and the ramp is non-decreasing through it, so it is not a fold. `scripts/tone-base-check.mjs`'s mirror of the pass takes the same three moves (its own `toneBlack` and the squared head) so the pixel it predicts is still the pixel the pass draws. Checked: node scripts/highlight-knee-check.mjs; node scripts/tone-base-check.mjs; node scripts/auto-tone-check.mjs; node scripts/half-check.mjs; node scripts/mask-wb-check.mjs; node scripts/preview-match-check.mjs; node scripts/raw-develop-check.mjs; node scripts/white-level-check.mjs; node scripts/wb-table-check.mjs; node scripts/sharpen-check.mjs; node scripts/denoise-check.mjs; npx tsc --noEmit. --- .../frontend/scripts/highlight-knee-check.mjs | 146 ++++++++++++------ docker/frontend/scripts/tone-base-check.mjs | 16 +- docker/frontend/shared/utils/toneShader.ts | 130 ++++++++++++---- 3 files changed, 209 insertions(+), 83 deletions(-) diff --git a/docker/frontend/scripts/highlight-knee-check.mjs b/docker/frontend/scripts/highlight-knee-check.mjs index 20118fa..de7a072 100644 --- a/docker/frontend/scripts/highlight-knee-check.mjs +++ b/docker/frontend/scripts/highlight-knee-check.mjs @@ -78,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_[A-Z_]+\}/g) ?? []).length, 7, 'a window edge or a rate is pinned to a literal, not to its TONE_ constant'); +assert.equal((mathTmpl.match(/\$\{TONE_[A-Z_]+\}/g) ?? []).length, 8, '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, @@ -167,15 +167,19 @@ assert.doesNotMatch(codeOf(sksl), /toneBump/, 'the summed kernel is back — the 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 +// root, crush by the toe's own exponent, 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)^2 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(' L = toneBlack(L, bl);\n L = clamp(L, 0.0, 1.0);')); +// ...and the black pair itself, pinned on the source as the function the curve now +// calls: the doc's lift at its rate, and the exponent that replaces the doc's +// crush (the rate is on the exponent, so the closed form cannot run backwards). +assert.match(maths, text(`float toneBlack(float L, float bl) {\n const float W = ${TONE_BLACK_EDGE};\n float q = toneBlackW(L);\n if (bl > 0.0) return L + ${TONE_BLACK_LIFT} * bl * q * (sqrt(L) - L);\n if (L >= W) return L;\n return W * pow(L / W, 1.0 + ${TONE_BLACK_CRUSH} * (-bl) * q);\n}`)); 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 = toneHighW(L);\n L += ${TONE_HIGH_GAIN} * hl * q * pow(max(L - 0.5, 0.0), 1.5) * (1.0 - L) * (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. @@ -314,26 +318,35 @@ const toneWhiteW = (L) => { const u = clamp01((L - TONE_WHITE_EDGE) / (1 - TONE_WHITE_EDGE)); return u * u; }; +// toneBlack, move for move — the doc's lift through the square root, and the +// toe's own exponent for the crush, which is what the shader carries (see +// toneShader.ts): the doc's multiplicative crush is bounded by its own window and +// cannot be seen. It is a function of its own so the arithmetic below and the +// source's own text can be pinned to one shape. +const toneBlack = (L, bl) => { + const q = toneBlackW(L); + if (bl > 0) return L + TONE_BLACK_LIFT * bl * q * (Math.sqrt(L) - L); + if (L >= TONE_BLACK_EDGE) return L; + return TONE_BLACK_EDGE * (L / TONE_BLACK_EDGE) ** (1 + TONE_BLACK_CRUSH * -bl * q); +}; // 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. +// (toneBlack), SHADOW (a gain on the light), HIGHLIGHT (the doc's knee against the +// headroom that is left, squared) 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); + let q; 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); + L = toneBlack(L, blackA); 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 += TONE_HIGH_GAIN * clamp(hl - dr * 0.09, -1, 1) * q * Math.max(L - 0.5, 0) ** 1.5 * (1 - L) ** 2; L = clamp01(L); q = toneWhiteW(L); L += TONE_WHITE_GAIN * clamp(wh - dr * 0.18, -1, 1) * q * (1 - L) * L; @@ -391,34 +404,65 @@ 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. // 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. +// only piecewise — a window edge is a slope change — so this is the measurement of +// that, and the measurement is the DROP FROM THE RUNNING MAXIMUM rather than the +// step between two cells. A fold of a tenth of a code value spread over a dozen +// cells hides from a cell-sized step: the step this file used to read here was +// -0.00119 while the drop under SHADOW -100 alone was 0.018, three times the guard +// and the thing a gradient wears as a band. The drop is what the eye reads. +// +// Every rate this pass sets is fold-free: the worst any of them can do is 3.4e-6 +// of luma (BLACK's lift, at the square root's own corner). The one fold left in +// the ramp is SHADOWS' and it is the doc's own arithmetic, not a rate: §2.2's +// crush multiplies what a window leaves, that window RISES as the luma does over +// 0.02..0.12, so the move outruns the light and the ramp comes back down by 0.018 +// of luma (4.6 code values) around 0.06..0.11 at full -100 — at any rate, since +// scaling it only scales the same shape. It predates this pass and is pinned here +// so a rate change cannot quietly deepen it: the assertion below is that no rate +// in this file folds the ramp at all, and that no composition of the four is worse +// than the doc's own crush. 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; -let worstAt = 0; -let worstCombo = null; -for (const k of combos) { - let prev = null; - for (let t = 0; t <= 1; t += 1 / 512) { +const fold = (k, n) => { + let top = -Infinity; + let worst = 0; + let at = 0; + for (let i = 0; i <= n; i++) { + const t = i / n; const o = ramp(t, k).o; - if (prev !== null && o - prev < worst) { - worst = o - prev; - worstAt = t; - worstCombo = k; + if (o > top) top = o; + else if (top - o > worst) { + worst = top - o; + at = t; } - prev = o; + } + return { worst, at }; +}; +// A knob's own rate, at full deflection either way: the rates are set so this is +// zero to the rounding — 2e-3 is half a code value, under what 8-bit can carry. +for (const [knob, amount] of [['bl', 1], ['bl', -1], ['hl', 1], ['hl', -1], ['wh', 1], ['wh', -1]]) { + const { worst, at } = fold({ [knob]: amount }, 32768); + assert.ok(worst <= 2e-3, `${knob} at ${amount} folds the ramp by ${worst} at ${at} — a rate past what its own shape allows`); +} +// SHADOW's crush, the doc's own — see the note above. Pinned: this number is a +// property of §2.2's form, so a change here means the form changed. +near4(fold({ sh: -1 }, 32768).worst, 0.01795, 'SHADOW no longer folds where the doc\'s own crush folds — re-pin, and see toneShader.ts'); +let worstFold = 0; +let foldAt = 0; +let foldCombo = null; +for (const k of combos) { + const { worst, at } = fold(k, 8192); + if (worst > worstFold) { + worstFold = worst; + foldAt = at; + foldCombo = k; } } -assert.ok(worst >= -2e-3, `worst step ${worst} at ${worstAt} for ${JSON.stringify(worstCombo)} — the ramp is folded`); +assert.ok(worstFold <= 0.01795 + 2e-3, `worst fold ${worstFold} at ${foldAt} for ${JSON.stringify(foldCombo)} — a composition deepens the doc's fold`); // ...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)}`); @@ -434,35 +478,41 @@ exact(ramp(0.90, { hl: -1 }).o, ramp(0.90, { hl: -1, sh: 0.47 }).o, 'SHADOW reac 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. +// and nothing wider. BLACK - is the toe's exponent 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 moves the head +// without reaching it (0.95722 at 0.95 against 0.97579 before this pass — the +// squared headroom spends the move lower down), and WHITE is the one that does +// reach it, from 0.92 up (see the check below). 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]]], + [-1, [[0.05, 0.00105], [0.10, 0.06618], [0.15, 0.14899], [0.20, 0.20], [0.50, 0.50]]], + [1, [[0.05, 0.11082], [0.10, 0.11765], [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]]], + [1, [[0.70, 0.81270], [0.80, 0.89202], [0.90, 0.93542], [0.95, 0.95722]]], + [-1, [[0.70, 0.58730], [0.80, 0.70798], [0.95, 0.94278]]], ]], ['wh', [ - [1, [[0.90, 0.93375], [0.95, 0.99008]]], - [-1, [[0.90, 0.86625], [0.95, 0.90992]]], + [1, [[0.90, 0.96750], [0.95, 1.0]]], + [-1, [[0.90, 0.83250], [0.95, 0.86984]]], ]], ]) 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. +// HIGHLIGHT does not clip: +100 at 0.95 is still under the ceiling of the cube +// (0.95722, and 0.93542 at 0.90), because the headroom it reads is squared and the +// move has died out by the top — while WHITE, the frame's clipping point, takes the +// ramp TO 1.0 from 0.92 up, which is what §2.4 asks of it. Either way the only +// interior value a full set of knobs reaches 1.0 on is a pixel already at the head. assert.ok(ramp(0.95, { hl: 1 }).o < 1, 'HIGHLIGHT +100 is clipping the head'); +assert.ok(ramp(0.9, { hl: 1 }).o < 1, 'HIGHLIGHT +100 is clipping the head'); +assert.ok(ramp(0.93, { wh: 1 }).o >= 1 - 1e-12, 'WHITE +100 is not reaching the ceiling it is named for'); 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 @@ -522,8 +572,8 @@ const filmFlat = filmTone.replace(/['"]/g, ''); for (const [name, sh, knot, hlSrc, hl, head] of [ ['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], + ['monochrome', -0.2, 0.22020933, '-0.1143', -0.1143, 0.7375], + ['mono-high-contrast', -0.54, 0.16956519, '0.5929', 0.5929, 0.81484375], ]) { assert.ok( filmFlat.includes(`${name}: { sh: ${sh}`), diff --git a/docker/frontend/scripts/tone-base-check.mjs b/docker/frontend/scripts/tone-base-check.mjs index a2ef73e..d8bedf4 100644 --- a/docker/frontend/scripts/tone-base-check.mjs +++ b/docker/frontend/scripts/tone-base-check.mjs @@ -107,20 +107,28 @@ const toneWhiteW = (L) => { const u = clamp01((L - TONE_WHITE_EDGE) / (1 - TONE_WHITE_EDGE)); return u * u; }; +// toneBlack, as the shader emits it: the lift is the doc's own, the crush is the +// toe's EXPONENT (monotone for any rate, and the only form that moves a visible +// amount inside a window that is 1 only at the floor). +const toneBlack = (L, bl) => { + const q = toneBlackW(L); + if (bl > 0) return L + TONE_BLACK_LIFT * bl * q * (Math.sqrt(L) - L); + return L >= TONE_BLACK_EDGE + ? L + : TONE_BLACK_EDGE * (L / TONE_BLACK_EDGE) ** (1 + TONE_BLACK_CRUSH * -bl * q); +}; // 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 = toneBlack(L, bl); 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 += TONE_HIGH_GAIN * hl * q * Math.max(L - 0.5, 0) ** 1.5 * (1 - L) * (1 - L); L = clamp01(L); q = toneWhiteW(L); L += TONE_WHITE_GAIN * wh * q * (1 - L) * L; diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index aeef70d..266699f 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -152,17 +152,50 @@ export const TONE_BLACK_EDGE = 0.18; export const TONE_WHITE_EDGE = 0.80; // 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; +// +// Sized by what the EYE can see, not by what the doc's own rates happen to be. At +// the doc's numbers a full +100 was worth 0.060 of luma on BLACKS, 0.082 on +// HIGHLIGHTS and 0.042 on WHITES (the arithmetic sweep on the scratchpad, and the +// probe that ran the frame through the pass: BLACKS moved its mean by 0.0001 in +// either direction), which is the "giá trị thay đổi quá nhỏ, không thể hiện trên +// thị giác" report. Each rate below is now the largest its own move allows under +// the contracts hold in highlight-knee-check: monotone at any amount on the +// slider, both anchors fixed exactly, and a knob still exactly the identity off +// its own band. +// +// BLACK's lift is the doc's square root at the rate monotonicity allows — 0.93, +// the last rate whose own toe stays monotone on the check's grid (0.94 folds +// 3.4e-6 of luma back on itself, 0.9387 is the exact ceiling, and the travel +// between 0.93 and 0.94 is 1e-4, so the round number costs nothing). Its crush +// cannot be the doc's multiplicative rate at all: that form +// is bounded by its own window and is worth 0.019 of luma at full -100, five code +// values on a black patch, invisible. The crush is the toe's own exponent instead +// (see toneBlack), which is monotone for ANY rate, and worth 0.054. +// +// HIGHLIGHT and WHITE are measured rather than inherited — the doc draws 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 blows the frame or does nothing on +// one. Both head terms are only monotone while their own rate stays under a +// ceiling, and the ceiling is a FOLD, not a slope: the move overshoots 1.0, the +// clamp draws a plateau, and the ramp then falls back under it — measured under +// the knee check as the drop from the running maximum, at 1/32768 of the ramp. +// The head term is squared (see the note above toneCurve) because that is what +// buys the room: the move dies out before it can reach the ceiling. +// +// HIGHLIGHT at 14.0 stretches the quarter it owns to 1.44x of the 1.5x the check +// allows and lands +0.113 on the band, 29 code values, with no fold in either +// direction. WHITE at 3.0 is worth +0.080 — and it SATURATES the top of the ramp +// from 0.92: that is the doc's own §2.4, where a WHITE is the frame's clipping +// point ("giới hạn cháy sáng") and not a Hermite that cannot move the head. It is +// felt only above the 0.80 shoulder, it is what the knob is for, and the head +// still lands on 1.0 exactly. The one fold left in the ramp is not from these +// rates: SHADOWS' crush is the doc's own multiplicative form and dips 0.018 of +// luma (4.6 code values) over the 0.06..0.11 band at full -100, measured and +// pinned in the knee check, whatever rate it is given. +export const TONE_BLACK_LIFT = 0.93; +export const TONE_BLACK_CRUSH = 8.0; +export const TONE_HIGH_GAIN = 14.0; +export const TONE_WHITE_GAIN = 3.0; // 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). @@ -228,6 +261,29 @@ float toneBlackW(float L) { // angle, and an angle on a tone curve is a Mach band. return u * u * u; } +// BLACKS, both directions, one window. §2.1's TOE SLOPE, and the two moves it +// names are not the same kind of move: +// +// - the lift is the doc's own (L + amount * W * (sqrt(L) - L)), at the largest +// rate monotonicity allows. The square root's slope at the floor is what makes +// a bigger rate fold the toe back on itself — past 0.93 the ramp carries whole +// hundredths of luma backwards and a gradient wears the fold as a band. +// - the crush cannot be the doc's (L * (1 + amount * W * 0.85)) at any rate that +// can be seen. The window is 1 only AT the floor and this form multiplies what +// the window leaves, so its whole travel is 0.019 of luma at full -100 however +// hard the rate bites (0.85 or 1.0, measured), and a rate past 1 drives the +// product negative and clips the toe to a flat black instead of deepening it. +// The toe's own EXPONENT is the slope §2.1 asks for: x -> x^(1 + rate * W) is +// monotone for every rate, so it deepens what the doc's form cannot reach — +// 0.054 at full -100 — while x = 0 stays on 0 and x = 1 (the 0.18 edge) stays +// on 1, which is the compact support and the two anchors both kept. +float toneBlack(float L, float bl) { + const float W = ${TONE_BLACK_EDGE}; + float q = toneBlackW(L); + if (bl > 0.0) return L + ${TONE_BLACK_LIFT} * bl * q * (sqrt(L) - L); + if (L >= W) return L; + return W * pow(L / W, 1.0 + ${TONE_BLACK_CRUSH} * (-bl) * q); +} float toneShadowW(float L) { return smoothstep(0.02, 0.12, L) * (1.0 - smoothstep(0.25, 0.55, L)); } @@ -249,24 +305,25 @@ float toneWhiteW(float L) { // 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, +// (1.0 - L)^2 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. +// clipping — and squared rather than linear, because one power of the headroom +// dies too slowly to keep the rate's own ceiling off the clamp: at a linear head +// a rate worth 0.113 of luma already overshoots 1.0 near 0.94 and the ramp falls +// back over the plateau by 0.018 (the fold the knee check measures), so the rate +// that shape allows is the 0.082 this whole pass is replacing. 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); + // BLACKS, the toe — see toneBlack. Neither direction touches the anchor: the + // lift is 0 at L = 0 (sqrt(0) - 0), the crush is 0^(anything) = 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, slope and all. + L = toneBlack(L, bl); 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 @@ -276,12 +333,19 @@ float toneCurve(float L, float bl, float sh, float hl, float wh) { 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. + // the knee and the window is only wide where it is not, and the headroom enters + // SQUARED so the move has died out by the time the ramp reaches the clamp. q = toneHighW(L); - L += ${TONE_HIGH_GAIN} * hl * q * pow(max(L - 0.5, 0.0), 1.5) * (1.0 - L); + L += ${TONE_HIGH_GAIN} * hl * q * pow(max(L - 0.5, 0.0), 1.5) * (1.0 - L) * (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. + // At the rate the knob is worth, +100 takes the top of the ramp TO the ceiling + // and the clamp flattens what is left of it: from 0.92 up a pixel is white. + // That is §2.4's clipping point ("giới hạn cháy sáng") raised, and it is the + // only move in this curve that reaches 1.0 short of the white point itself — + // the head is still 1.0 on 1.0, and the ramp is still non-decreasing through + // the flat part, so nothing folds. q = toneWhiteW(L); L += ${TONE_WHITE_GAIN} * wh * q * (1.0 - L) * L; return clamp(L, 0.0, 1.0); @@ -953,18 +1017,22 @@ const FILM_TONE: Partial>> = { // 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 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 }, + // and -0.1143 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 — the head move is + // -0.1143 rather than the -0.16 it used to be because the head term is squared + // now, and a squared headroom is worth less at the 0.75 knot for the same rate). + monochrome: { sh: -0.20, hl: -0.1143 }, // B&W HIGH CONTRAST. Acros' ramp with both ends pushed hard: a deeper toe // (-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 + // black, and a shoulder that LIFTS instead of rolling (-0.16 → +0.5929, the + // head going to 0.815 — re-solved from +0.83 for the squared head, which is + // worth less at the 0.75 knot for the same rate), 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.54, hl: 0.83 }, + 'mono-high-contrast': { sh: -0.54, hl: 0.5929 }, }; // The base layer's neighbourhood is the caller's business, not this function's: