web: each of the four tonal knobs moves its own band of the ramp and not the guard the four used to share

BLACK, SHADOW, HIGHLIGHT and WHITE were four bumps summed into the identity,
and the sum carried a guard: the two bumps of a half shared a slope, so past a
total of 1 the curve folded backwards, and the ceiling that stopped it was
shared by the amplitudes of a half. A stock already sitting on SHADOW therefore
took BLACK's lift down with it — on the monochrome stock (sh = -0.24) BLACK at
-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.

thay_doi_thong_so_giong_lightroom.md section 2 asks for four WINDOWS instead:
each knob owns a compact band of the ramp and is exactly zero outside it, and
the four moves are applied ONE AFTER ANOTHER rather than summed. A composition
of monotone maps is monotone by construction, so it needs no guard, and each
knob then measures 1.00 of its travel on every stock. BLACKS is the doc's toe —
u = clamp(1 - L/0.18, 0, 1) cubed, opened by sqrt(L) - L at 0.7 and deepened by
0.85, both of which are exactly zero at L = 0, so (0,0,0) stays (0,0,0): the
grey pedestal that BLACK +100 left on a black was the sum adding its bump's
height at the black point, which is the doc's own "Milky / Foggy". SHADOWS is
the doc's bell over the deep tones, HIGHLIGHTS the bell over the bright ones,
WHITES the doc's Hermite on the shoulder from 0.80. The ramp keeps its two
anchors — 0.00 and 1.00 — at every setting of the four knobs.

One deliberate departure from the doc: HIGHLIGHT carries a (1 - L) the doc's raw
knee does not, because pow(L - 0.5, 1.5) added to L overshoots the cube above
0.94 — 17% of the ramp driven to flat white at +100 before the clamp. Read
against the headroom that is left, the move is zero at L = 1 by construction and
the head rolls instead of clipping.

The windows are read in the sRGB-encoded luma this file already works in, not in
linear light as the doc's section 1 sets out: the doc's own boundaries (0.18,
0.05..0.45, 0.55..0.95, 0.80) land as perceptual positions there, and moving the
whole renderer to the linear domain is a bigger change than this pass. The
divergence is the one the scratchpad compat doc already warns the Android port
about, and it is noted at the windows themselves.

Checked: `tsc --noEmit` clean; `highlight-knee-check.mjs`, `tone-base-check.mjs`
and `mask-wb-check.mjs` updated to the four windows and passing; the twin ramp
over a 1/512 grid is monotone to -0.00119 (0.30 code values, at t = 0.098 with
every knob at full negative), both anchors hold for every combination, and a
knob outside its band is the exact identity.
This commit is contained in:
2026-10-02 08:11:32 +07:00
parent bc550569ad
commit e4f5407c19
3 changed files with 470 additions and 379 deletions
+270 -183
View File
@@ -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');
+69 -33
View File
@@ -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`,
);
+131 -163
View File
@@ -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<Record<BaseFilter, Partial<ToneUniforms>>> = {
'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<Record<BaseFilter, Partial<ToneUniforms>>> = {
// 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: