web: fix halo artifacts on highlight/shadow/white/black adjustments with edge-aware tone mapping and update docker packages

This commit is contained in:
2026-10-02 15:27:32 +07:00
parent acbb2bba4b
commit e98b4d9d5c
40 changed files with 1833 additions and 762 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
// hashed bundle with it, which the STATIC rule below then served cache-first
// forever. Every navigation here now goes past the browser's own cache, so the
// build a visitor gets is the one the server has.
const VERSION = 'recipescam-v2';
const VERSION = 'recipescam-v3';
// nginx answers all three with the same index.html (SPA fallback), so they are one
// document under three keys: an offline navigation finds it whichever key it asks.
+301 -459
View File
@@ -1,4 +1,4 @@
// Highlight roll-off in the develop, and the tonal-range ramp in the tone pass.
// Highlight roll-off in the develop, and the four tonal knobs in the tone pass.
//
// THE DEVELOP holds the knee:
//
@@ -9,119 +9,137 @@
// asymptote on 1.0) so the two stops the sensor holds above its white level are
// COMPRESSED into the frame instead of being thrown away by the old
// fade-to-white — which is also the only reason HIGHLIGHT has detail left at the
// top to move. Measured on DSC03453.ARW, where the camera's own preview is
// clipped, the develop's luma was 253.4 with a standard deviation of 2.4, against
// 251.2 / 10.0 through the knee.
// top to move.
//
// 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,
// 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.
// THE TONE PASS moves the luma by the four knobs (HIGHLIGHT, SHADOW, WHITE,
// BLACK), and each one is the MOVE ITS OWN LIGHTROOM EXPORT MADE, measured:
// TONE_KNOBS in toneShader.ts holds, per knob and per side of zero, the knot
// table of that move on the encoded luma. They are NOT the doc's four compact
// windows any more — the windows were confined to a quarter of the ramp each and
// were worth a few hundredths of luma inside it and the exact identity outside,
// which is the "sau khi chỉnh sửa 4 thông số này không đúng, không thay đổi"
// report: WHITE ±100 was a literal no-op. The reference exports say something
// else: WHITE +100 takes everything above luma 0.50 to 1.0 (mean +0.43 over the
// frame), BLACK -100 takes everything below 0.21 to 0.0 (mean -0.14), HIGHLIGHT
// +100 is a +0.24 lift peaking on luma 0.18 and dead by 0.72.
//
// Both are SkSL, so the SHAPE is pinned on the source; the arithmetic is then
// checked against the source's own constants, and the ramp re-run here as a twin
// so monotonicity, the neutral identity, the partition of the four masks and the
// slopes SHADOW and HIGHLIGHT leave the quarters around them are checked rather
// than asserted in a comment.
// The four are applied ONE AFTER THE OTHER (BLACK -> SHADOW -> HIGHLIGHT ->
// WHITE), each clamped on the way to the next, so a composition of monotone maps
// is monotone by construction and the ramp needs no guard whatever the four
// sliders say — and the knot tables are cut so that L + move is non-decreasing on
// every segment, which the sweep below measures rather than asserts.
//
// Both are SkSL, so the SHAPE is pinned on the source; the tables are then
// re-run here as a twin and checked against the measurement they were FITTED to
// (the nine exports in the repo root), so a table that drifts off the export is a
// red check. The frame-through-the-real-shader diff lives in the scratchpad
// harness render.mjs, which needs the nine JPEGs.
//
// node scripts/highlight-knee-check.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import ts from 'typescript';
const develop = readFileSync(new URL('../src/engine/rawDevelop.ts', import.meta.url), 'utf8');
const tone = readFileSync(new URL('../shared/utils/toneShader.ts', import.meta.url), 'utf8');
// The develop: knee on the sensor's max channel, the channel ratios kept, so the
// hue and the saturation of a blown area survive the pull-down.
const dev = develop.match(/if \(mx > 0\.7\) \{[\s\S]*?\n \}/)?.[0];
assert.ok(dev, 'the develop knee is gone — a blown sky is flat 1.0 again');
assert.match(dev, /float over = mx - 0\.7;/);
assert.match(dev, /rgb \*= \(0\.7 \+ over \/ \(1\.0 \+ over \* 3\.3333\)\) \/ mx;/);
assert.doesNotMatch(develop, /mix\(rgb \/ mx, float3\(1\.0\)/, 'the fade-to-white is back');
// The tone pass, read as the string it actually emits: 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 read = (p) => readFileSync(new URL(p, import.meta.url), 'utf8');
const develop = read('../src/engine/rawDevelop.ts');
const tone = read('../shared/utils/toneShader.ts');
// The tone shader is IMPORTED, transpiled, so the numbers below are the ones the
// shader is built with and not a regex's reading of them.
const transpile = (path) =>
ts.transpileModule(read(path), {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText;
const dir = mkdtempSync(join(tmpdir(), 'highlight-knee-check-'));
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
writeFileSync(
join(dir, 'toneShader.mjs'),
transpile('../shared/utils/toneShader.ts').replace(
/^import .*from ['"]\.\/colorUtils['"];$/m,
'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";',
),
);
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
// TONE_MATH_SKSL, the one copy the whole-frame pass and a gradient mask both
// interpolate — so the shape is pinned there, and TONE_SKSL has to reach for it
// rather than carry a second version of its own (that is the divergence the
// compat doc §3.3 warns the Android port about).
const mathTmpl = tone.match(/export const TONE_MATH_SKSL = `([\s\S]*?)`;/)?.[1];
assert.ok(mathTmpl, 'TONE_MATH_SKSL is gone — the frame and a mask no longer share the maths');
assert.equal((mathTmpl.match(/\$\{TONE_[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,
// and the blur is the caller's (blurredBase in exportEngine.ts) — a ring of point
// samples in here was the mottle bug: the luma aliased on a textured frame, the
// gain o(base)/base carried the alias, and the reconstruction painted it back.
// So the shader must read `base` once and must NOT grow a sampling loop again,
// and `bx` — the step only a loop ever needed — must stay gone.
assert.match(tmpl, /float baseLuma\(vec2 xy\) \{/);
assert.match(tmpl, /vec3 s = clamp\(base\.eval\(xy\)\.rgb, 0\.0, 1\.0\);/);
assert.match(tmpl, /uniform shader base;/);
assert.doesNotMatch(tmpl, /uniform float2 bx;/, 'the base is a sampling loop again — that is what mottled');
assert.doesNotMatch(tmpl, /baseLuma\(xy, t\)/, 'baseLuma grew its neighbourhood back');
// One tap of the base is a FRACTION of the frame, so the preview and the file
// look at the same neighbourhood: the pass has the frame size and turns it into
// the blur's sigma.
assert.match(tone, /export const TONE_BASE_RADIUS = ([0-9.]+);/);
const baseRadius = Number(tone.match(/export const TONE_BASE_RADIUS = ([0-9.]+);/)[1]);
assert.ok(baseRadius >= 0.02 && baseRadius <= 0.05, `the base reads ${baseRadius} of the frame — the doc asks for 2%..5%`);
assert.match(tone, /export const TONE_BASE_SIGMA = ([0-9.]+);/);
const baseSigma = Number(tone.match(/export const TONE_BASE_SIGMA = ([0-9.]+);/)[1]);
assert.ok(baseSigma > 0 && baseSigma <= 0.5, `TONE_BASE_SIGMA ${baseSigma} is not a sigma under the radius`);
const engine = readFileSync(new URL('../src/engine/exportEngine.ts', import.meta.url), 'utf8');
TONE_SKSL: sksl,
TONE_MATH_SKSL: maths,
EXPOSURE_SKSL,
TONE_KNOBS,
TONE_BLACK_EDGE,
TONE_BASE_RADIUS,
TONE_BASE_SIGMA,
toneUniformArray,
} = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href);
const text = (s) => new RegExp(s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const codeOf = (s) => s.replace(/\/\/[^\n]*/g, '');
// THE FOUR KNOBS, as the source declares them. Each curve is a knot table on the
// encoded luma: positions ascending from the floor to the head, values the move
// at +100 (the minus side is read with an amount below zero).
const KNOBS = ['bl', 'sh', 'hl', 'wh'];
for (const k of KNOBS) {
for (const side of ['plus', 'minus']) {
const c = TONE_KNOBS[k][side];
assert.equal(c.p.length, c.v.length, `${k}.${side}: ${c.p.length} knots against ${c.v.length} values`);
assert.equal(c.p[0], 0, `${k}.${side} does not start on the floor`);
assert.equal(c.p[c.p.length - 1], 1, `${k}.${side} does not end on the head`);
for (let i = 1; i < c.p.length; i++)
assert.ok(c.p[i] > c.p[i - 1], `${k}.${side} knot ${i} is not past the one before it`);
}
}
// One knob as the shader gets it: the table drawn STRAIGHT between the knots — a
// hinge per segment, written as a sum of clamps because SkSL indexes arrays by
// constant only — scaled by the amount, added to the luma it reads and clamped.
// The side of zero picks the table, and the clamp is what makes a -- knob's own
// clip a plateau instead of a fold.
for (const k of KNOBS)
assert.match(
maths,
text(`float tone${k}(float L, float amt) {\n return clamp(L + (amt >= 0.0 ? amt * (`),
`${k} is not the measured table read as a polyline`
);
assert.match(maths, /: -amt \* \(/);
// ...pinned at the SOURCE's own numbers: the hinge from knot 0.045 to knot 0.185
// on BLACK's plus side is worth 0.1155 - 0.0876, over a segment 0.140 wide.
assert.match(maths, text(' + (0.0279) * clamp((L - 0.045) / 0.140, 0.0, 1.0)'));
// The composition, move for move: the four in the order the exports were read,
// each self-clamping (tone* does it), so a knob that has driven the luma to an
// end leaves the ones after it nothing to fold.
assert.match(
engine,
/const sigma = width \* TONE_BASE_RADIUS \* TONE_BASE_SIGMA;\s*\n\s*const base = own\(blurredBase\(baseShaderOf, width, height, sigma\)\);/,
'the tone pass no longer blurs a frame-sized base'
maths,
text('float toneCurve(float L, float bl, float sh, float hl, float wh) {\n L = tonebl(L, bl);\n L = tonesh(L, sh);\n L = tonehl(L, hl);\n L = tonewh(L, wh);\n return clamp(L, 0.0, 1.0);\n}')
);
assert.match(
engine,
/effect\.makeShaderWithChildren\(toneUniformArray\(tone\), \[\s*baseShaderOf\(\),\s*base \? own\(imageShaderChild\(base\)\) : baseShaderOf\(\),\s*\]\)/,
'the blurred base is not handed to the tone pass as its second child'
// ...and the four COMPACT WINDOWS the knobs used to be are gone from the CODE —
// they were the "không thay đổi" bug. The prose is not read for these:
// TONE_MATH_SKSL still tells the story of what replaced them.
assert.doesNotMatch(codeOf(maths), /toneBlackW|toneBlack\(|toneShadowW|toneHighW|toneWhiteW|toneBump|holdLo|holdHi/);
assert.doesNotMatch(
tone,
/export const TONE_WHITE_EDGE|export const TONE_BLACK_LIFT|export const TONE_BLACK_CRUSH|export const TONE_HIGH_GAIN|export const TONE_WHITE_GAIN|export const TONE_ANCHOR|export const TONE_BUMP_SLOPE/,
'a window edge or rate is still declared'
);
assert.match(
engine,
/function blurredBase\([\s\S]*?Skia\.ImageFilter\.MakeBlur\(sigma, sigma, Skia\.TileMode\.Clamp, null\)/,
'the base is no longer Skia’s own blur'
// The BLACK knob's Hunt chroma mask is the one thing that still reads the doc's
// 0.18 — it is a chroma floor, not a knob window.
assert.match(maths, new RegExp(text(`float uBl = clamp(1.0 - effBase / ${TONE_BLACK_EDGE}, 0.0, 1.0);`)));
const knobBlock = maths.slice(maths.indexOf('float tonebl'), maths.indexOf('float toneCurve'));
assert.ok(knobBlock.includes('float tonewh'), 'the four knob functions are gone');
assert.doesNotMatch(
knobBlock,
/smoothstep|pow\(|exp\(/,
'a knob is not the measured table any more — it bends again'
);
assert.match(engine, /getToneUniforms\(adjustments, recipe\.baseFilter\)/, 'the tone pass still steps a sampling ring by hand');
// 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(effBase, 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(codeOf(sksl), /float a4 = /, 'the ramp is back inside the pass — one copy, not two');
// The uniform block: the shader's declarations, arrays expanded and in
// declaration order, have to be the numbers `toneUniformArray` writes — a
// mismatch is a silent off-by-one down the whole block.
const declared = [...tmpl.matchAll(/uniform (float2|float) (\w+)(?:\[(\d+)\])?;/g)].reduce(
const declared = [...sksl.matchAll(/uniform (float2|float) (\w+)(?:\[(\d+)\])?;/g)].reduce(
(n, [, kind, , len]) => n + (len ? Number(len) : kind === 'float2' ? 2 : 1),
0
);
@@ -133,60 +151,14 @@ 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}`);
const resolve = (s) =>
s
.replace('${TONE_MATH_SKSL}', mathTmpl)
.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 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(' 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) * (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');
// The develop: knee on the sensor's max channel, the channel ratios kept, so the
// hue and the saturation of a blown area survive the pull-down.
const dev = develop.match(/if \(mx > 0\.7\) \{[\s\S]*?\n \}/)?.[0];
assert.ok(dev, 'the develop knee is gone — a blown sky is flat 1.0 again');
assert.match(dev, /float over = mx - 0\.7;/);
assert.match(dev, /rgb \*= \(0\.7 \+ over \/ \(1\.0 \+ over \* 3\.3333\)\) \/ mx;/);
assert.doesNotMatch(develop, /mix\(rgb \/ mx, float3\(1\.0\)/, 'the fade-to-white is back');
const mask = readFileSync(new URL('../shared/utils/gradientMask.ts', import.meta.url), 'utf8');
assert.ok(mask.includes('${TONE_MATH_SKSL}'), 'the mask pass does not read the shared maths');
assert.match(mask, /c = half3\(exposureMove\(vec3\(c\), a\.x\)\);/);
@@ -199,22 +171,21 @@ assert.doesNotMatch(mask, /0\.55, 1\.35/, 'the mask kept its own arbitrary satur
assert.doesNotMatch(mask, /cg = clamp\(lifted/, 'the mask is back on its own tone formula');
// The four tents, one per quarter of the ramp, each clipped by its neighbour so
// no luma is counted by two of them.
// no luma is counted by two of them. These weight the stock SPLIT TONES now — the
// four knobs no longer ride them, they ride their own measured curves (TONE_KNOBS).
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 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.
// The composition, and NOT a sum of bumps on the identity: the four MEASURED
// curves are applied one after the other, each self-clamping via its own tone*()
// helper, 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. No
// linear-light knee runs ahead of them either — CURVE_SKSL is a different pass.
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');
assert.doesNotMatch(sksl, /float lin\(/, 'straight segments between the knots are back — a knot is an angle in a tone curve');
assert.doesNotMatch(sksl, /float lin\(/, 'the linear-light knee ahead of the knobs came back');
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
@@ -224,9 +195,9 @@ assert.doesNotMatch(sksl, /clamp\(0\.25 \+ 0\.25/, 'a knob is clamped against a
// blurred base itself. The ramp's own luma is still not what is handed to the
// rebuild — the neighbourhood's is — or the move would be global again and the
// band above SHADOW would be drawn flat, which is the whole bug.
assert.match(sksl, /float target = o \+ \(t - base\);/);
assert.match(sksl, /float target = clamp\(o \+ \(t - effBase\), 0\.0, 1\.0\);/);
assert.doesNotMatch(sksl, /float target = base > 0\.0004 \? o \* t \/ base : t;/, 'the gain-scaled detail came back — a tone knob softens again');
assert.match(sksl, /return lightMove\(c, t, clamp\(target, 0\.0, 1\.0\)\);/);
assert.match(sksl, /vec3 res = lightMove\(c, t, target\);/);
assert.doesNotMatch(sksl, /lightMove\(c, t, clamp\(o, 0\.0, 1\.0\)\)/, 'the ramp is read at the pixel again — a global curve');
assert.doesNotMatch(sksl, /mix\(a0, a1, smoothstep/, 'the ramp is smoothstepped again');
// The linear-light knee that used to run ahead of all this is GONE from the tone
@@ -243,11 +214,12 @@ assert.doesNotMatch(sksl, /bl \* 0\.18 \* dk|wh \* 0\.18 \* rgb/, 'WHITE/BLACK a
// scratchpad hl-variants.mjs) — and the scale is held at 1.0 only below
// t = 0.0004, where the ratio would multiply a near-black pixel's cast by
// whatever pedestal BLACK has just lifted.
assert.match(maths, /float k = t > 0\.0004 \? o \/ t : 1\.0;/);
assert.match(maths, /if \(hiC > t\) k = min\(k, \(1\.0 - o\) \/ \(hiC - t\)\);/);
assert.match(maths, /if \(loC < t\) k = min\(k, o \/ \(t - loC\)\);/);
assert.match(maths, /float safeT = max\(t, 0\.005\);/);
assert.match(maths, /float k = t > 0\.0004 \? min\(o \/ safeT, 3\.5\) : 1\.0;/);
assert.match(maths, /if \(hiC > t\) k = min\(k, \(1\.0 - o\) \/ max\(hiC - t, 0\.0001\)\);/);
assert.match(maths, /if \(loC < t\) k = min\(k, o \/ max\(t - loC, 0\.0001\)\);/);
assert.match(maths, /return clamp\(vec3\(o\) \+ \(c - vec3\(t\)\) \* k, 0\.0, 1\.0\);/);
assert.match(maths, /return lightMove\(c, t, clamp\(target, 0\.0, 1\.0\)\);/);
assert.match(maths, /vec3 res = lightMove\(c, t, target\);/);
assert.doesNotMatch(tone, /0\.55, 1\.35/, 'the arbitrary saturation clamp came back');
assert.doesNotMatch(maths, /float k = 1\.0;/, 'the chroma-constant scale came back — a shadow lift drains the colour');
// The transfer pair has to be the accurate one where it is still used (the
@@ -307,292 +279,164 @@ const smoothstep = (e0, e1, x) => {
const u = clamp01((x - e0) / (e1 - e0));
return u * u * (3 - 2 * u);
};
// 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;
};
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;
};
// 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
// (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;
const blackA = clamp(bl + dr * 0.12, -1, 1);
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) ** 2;
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);
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
// it, so the four together never count a luma twice — and the middle is the
// quiet part: the ends of the ramp are weighted at 1, the 0.50 midpoint by
// nothing at all. That is what leaves DR and the stock split tones on the two
// ends and the mid-grey still.
for (let t = 0; t <= 1; t += 1 / 512) {
const { maskSum } = ramp(t, {});
assert.ok(maskSum >= -1e-15 && maskSum <= 1 + 1e-15, `masks overlap at ${t}: ${maskSum}`);
if (t <= 0.25 || t >= 0.75) assert.ok(Math.abs(maskSum - 1) < 1e-12, `end of the ramp unweighted at ${t}`);
if (Math.abs(t - 0.5) < 1e-12) assert.equal(maskSum, 0, 'the midpoint is weighted');
}
// The neighbouring tents cross at half weight ON the knot between them, and the
// 0.50 midpoint is where all four are on zero — the quiet value 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);
assert.equal(ramp(0.25, {}).blMask, 0);
assert.equal(ramp(0.875, {}).hlMask, ramp(0.875, {}).whMask);
assert.equal(ramp(0.5, {}).maskSum, 0);
assert.equal(ramp(0.75, {}).hlMask, 1);
// Every knob on zero is EXACTLY the identity — the pass also runs for the stock
// split tones and for DR alone, so a neutral setting must not curve the frame.
for (let t = 0; t <= 1; t += 1 / 256) close(ramp(t, {}).o, t, `identity broke at ${t}`);
// 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.
// 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, 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.
// ---------------------------------------------------------------------------
// The ramp as arithmetic — the same tables, the same order and the same clamps
// the SkSL above carries, so the shape is measured and not described.
//
// 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 });
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 (o > top) top = o;
else if (top - o > worst) {
worst = top - o;
at = t;
}
}
return { worst, at };
// ONE knob's curve, as KNOB_SKSL emits it: the table drawn straight between the
// knots, scaled by the amount, added to the luma it reads and clamped.
const poly = (c, L) => {
let v = c.v[0];
for (let i = 1; i < c.p.length; i++)
v += (c.v[i] - c.v[i - 1]) * clamp((L - c.p[i - 1]) / (c.p[i] - c.p[i - 1]), 0, 1);
return v;
};
// 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`);
const knob = (L, k, amt) =>
clamp01(L + (amt >= 0 ? amt * poly(TONE_KNOBS[k].plus, L) : -amt * poly(TONE_KNOBS[k].minus, L)));
// toneCurve, move for move, with the clamp the shader puts after each one:
// BLACK, then SHADOW, then HIGHLIGHT, then WHITE. `afterBlack` is the luma the
// SHADOW move reads — the value a stock's own SHADOW cannot drag, because BLACK
// runs before it.
function ramp(t, k = {}) {
const { dr = 0, hl = 0, sh = 0, wh = 0, bl = 0 } = k;
const afterBlack = knob(t, 'bl', clamp(bl + dr * 0.12, -1, 1));
const o = knob(
knob(knob(afterBlack, 'sh', clamp(sh + dr * 0.06, -1, 1)), 'hl', clamp(hl - dr * 0.09, -1, 1)),
'wh',
clamp(wh - dr * 0.18, -1, 1)
);
return { o, afterBlack };
}
// 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;
}
// THE MEASUREMENT THE TABLES WERE FITTED TO. Nine genuine Lightroom Classic
// 14.2 exports of Main.jpg — the frame at the repo root — each knob at +100 and
// at -100, the per-pixel luma difference against Main.jpg binned by base luma and
// averaged. The row is that move at nine luma positions, off the dense grid the
// tables were cut from (0.008 of luma is the worst the fit is allowed anywhere);
// the tolerance below is 0.01, a little over a code value at ±100.
const MEASURED_L = [0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7];
const MEASURED = {
'hl+': [0.22, 0.2228, 0.2357, 0.2323, 0.1607, 0.1011, 0.078, 0.0452, 0.0108],
'hl-': [-0.0201, -0.0363, -0.0475, -0.0589, -0.0701, -0.0808, -0.1298, -0.1627, -0.1461],
'sh+': [0.0286, 0.0424, 0.0544, 0.066, 0.0716, 0.0899, 0.137, 0.1651, 0.1733],
'sh-': [-0.0434, -0.0874, -0.1285, -0.1583, -0.1359, -0.0912, -0.0734, -0.0473, -0.012],
'wh+': [0.2036, 0.2901, 0.3714, 0.4422, 0.51, 0.5623, 0.4998, 0.3999, 0.3022],
'wh-': [-0.0019, -0.0038, -0.0058, -0.0085, -0.0123, -0.0203, -0.03, -0.0461, -0.0609],
'bl+': [0.0905, 0.1049, 0.1132, 0.1151, 0.099, 0.0843, 0.0702, 0.052, 0.0328],
'bl-': [-0.0501, -0.0997, -0.1498, -0.1983, -0.2429, -0.2247, -0.1574, -0.1141, -0.0773],
};
for (const [name, want] of Object.entries(MEASURED)) {
const k = name.slice(0, 2);
const amt = name.endsWith('+') ? 1 : -1;
want.forEach((move, i) => {
const got = knob(MEASURED_L[i], k, amt);
assert.ok(
Math.abs(got - clamp01(MEASURED_L[i] + move)) <= 0.01,
`${name} at luma ${MEASURED_L[i]}: the table moves to ${got.toFixed(4)}, the export to ${(clamp01(MEASURED_L[i] + move)).toFixed(4)}`
);
});
}
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)}`);
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 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.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.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.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}`);
// 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
// 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(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 — 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.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 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.
// The film stocks ride these same curves, so their numbers are their own. Each
// stock is written against a toe (the ramp's reading at luma 0.25) and a head
// (its reading at 0.75) — 0.18 / 0.22 / 0.17 and 0.7375 / 0.815 — and the
// measured tables put them at 0.1792 / 0.2126 / 0.1735 and 0.7397 / 0.8151. The
// Acros toe lands 0.0074 under its target because the HIGHLIGHT move reaches down
// to luma 0.22, so its shoulder lands on the toe as well; B&W HIGH CONTRAST is at
// FULL SHADOW deflection because the WHITES lift it asks for raises the darks by
// 0.074 at that luma, and 0.1735 is then the floor. It is the READ values that
// are pinned, next to the source they come from, 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.47, 0.17999192],
['classic-vivid', -0.47, 0.17999192],
['monochrome', -0.2, 0.22020933, '-0.1143', -0.1143, 0.7375],
['mono-high-contrast', -0.54, 0.16956519, '0.5929', 0.5929, 0.81484375],
for (const [name, src, kn, toe, head] of [
['classic-chrome', 'sh: -0.47', { sh: -0.47 }, 0.1791844],
['classic-vivid', 'sh: -0.47', { sh: -0.47 }, 0.1791844],
['monochrome', 'sh: -0.20, hl: -0.1143', { sh: -0.2, hl: -0.1143 }, 0.2126235, 0.7396688],
['mono-high-contrast', 'sh: -1.0, wh: 0.26', { sh: -1.0, wh: 0.26 }, 0.1734727, 0.8150685],
]) {
assert.ok(
filmFlat.includes(`${name}: { sh: ${sh}`),
`${name} is not on the ${sh} its crush was solved at`
filmFlat.includes(`${name}: { ${src} }`),
`${name} is not on the ${src} its ends were solved at`
);
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 ${hlSrc} its shoulder was solved at`);
near4(ramp(0.75, { hl }).o, head, `${name}'s highlight knot moved`);
near4(ramp(0.25, kn).o, toe, `${name}'s shadow knot moved`);
if (head) near4(ramp(0.75, kn).o, head, `${name}'s highlight knot moved`);
}
// Non-decreasing on EVERY segment, for every amount in [-1, 1] — not just at the
// ±100 the exports were sampled at. A fold in a tone curve is a band the eye
// reads, and a knob's own shape is what has to rule it out: the tables are cut so
// L + move never turns back.
for (const k of KNOBS)
for (let amt = -1; amt <= 1.0001; amt += 1 / 64) {
let prev = -Infinity;
for (let i = 0; i <= 4000; i++) {
const o = knob(i / 4000, k, amt);
assert.ok(o >= prev - 1e-12, `${k} at ${amt.toFixed(4)} folds the ramp at luma ${i / 4000}`);
prev = o;
}
}
// 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 i = 0; i <= 2000; i++) exact(ramp(i / 2000, {}).o, i / 2000, `identity broke at ${i / 2000}`);
// The ends of the ramp are where the MEASUREMENT put them, and nowhere else. The
// floor is the one that surprises: HIGHLIGHT +100 lifts a true black by 0.084 —
// the biggest move any knob makes on luma 0 — while BLACK +100, the knob named
// for it, is worth 0.0042 there; and the head moves under WHITES -100 alone
// (-0.10 on a true white, which is why its table does not tail off to zero).
// Nothing may drag an end further than the export did.
for (const k of KNOBS)
for (const side of ['plus', 'minus']) {
const c = TONE_KNOBS[k][side];
const amt = side === 'plus' ? 1 : -1;
near4(knob(0, k, amt), clamp01(c.v[0]), `${k}.${side} floor travel`);
near4(knob(1, k, amt), clamp01(1 + c.v[c.v.length - 1]), `${k}.${side} head travel`);
}
for (const k of KNOBS)
for (const side of ['plus', 'minus']) {
const c = TONE_KNOBS[k][side];
assert.ok(Math.abs(c.v[0]) <= 0.1, `${k}.${side} drags the floor by ${c.v[0]} — past what any export did`);
assert.ok(Math.abs(c.v[c.v.length - 1]) <= 0.1, `${k}.${side} drags the head by ${c.v[c.v.length - 1]} — past what any export did`);
}
// The two landmarks the whole change is measured by, read at the export's own
// numbers: WHITES +100 puts everything above luma 0.50 on 1.0 — it rounds to the
// ceiling from there up, which is the "+0.43 mean" a user sees as the frame
// opening — and BLACKS -100 puts everything below 0.21 on 0.0 (under a code
// value), the crushed toe.
for (let L = 0.5; L <= 1.0001; L += 1 / 512)
assert.equal(Math.round(knob(L, 'wh', 1) * 255), 255, `WHITES +100 leaves luma ${L} short of the ceiling`);
for (let L = 0; L <= 0.2; L += 1 / 512)
assert.ok(knob(L, 'bl', -1) < 0.006, `BLACKS -100 leaves luma ${L} above the floor`);
// On a mid-grey the four are all live — the old windows left the 0.50 midpoint to
// nothing but the two ends, which is the "không thay đổi" the report is about.
for (const k of KNOBS) {
assert.ok(Math.abs(knob(0.5, k, 1) - 0.5) > 0.01, `${k} +100 does nothing on a mid-grey`);
assert.ok(Math.abs(knob(0.5, k, -1) - 0.5) > 0.001, `${k} -100 does nothing on a mid-grey`);
}
// ...and no combination of the four at full deflection can drive an end of the
// ramp off the cube, fold the ramp back on itself, or cross the toe over the
// head — the composition is monotone by construction, and this is that
// construction run over all 162 combinations.
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 });
for (const k of combos) {
const lo = ramp(0, k).o;
const hi = ramp(1, k).o;
assert.ok(lo >= 0 && hi <= 1, `the ramp left the cube at ${JSON.stringify(k)}`);
assert.ok(lo <= hi, `the toe ${lo} climbed over the head ${hi} at ${JSON.stringify(k)}`);
let prev = -Infinity;
for (let i = 0; i <= 2000; i++) {
const o = ramp(i / 2000, k).o;
assert.ok(o >= prev - 1e-12, `the ramp folds at ${i / 2000} with ${JSON.stringify(k)}`);
prev = o;
}
}
// The two ends stay ordered even at full deflection against each other: the toe
// can never climb past the head.
for (const bl of [-1, 1])
for (const wh of [-1, 1]) {
const toe = ramp(0, { bl, sh: 1, wh }).o;
const head = ramp(1, { bl, wh, hl: -1 }).o;
assert.ok(toe <= head + 1e-12, `toe ${toe} over head ${head}`);
}
// The colour rebuild, as the shader emits it: the ramp's luma, the pixel's own
// chroma difference, and the one scale o / t the cube then gets to pull back.
@@ -603,11 +447,11 @@ const lumaOf = (c) => clamp01(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]);
// alone already landed the pixel inside the cube, and a silent clamp would hide
// the case where it did not.
function lightMove(rgb, t, o) {
let k = t > 0.0004 ? o / t : 1;
let k = t > 0.0004 ? Math.min(o / Math.max(t, 0.005), 3.5) : 1;
const hiC = Math.max(...rgb);
const loC = Math.min(...rgb);
if (hiC > t) k = Math.min(k, (1 - o) / (hiC - t));
if (loC < t) k = Math.min(k, o / (t - loC));
if (loC < t) k = Math.min(k, o / Math.max(t - loC, 0.0001));
return rgb.map((c) => o + (c - t) * k);
}
// The rebuild, with the base layer the shader now draws the ramp through. `base`
@@ -713,40 +557,38 @@ 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 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).
// comes out multiplied by whatever slope the curve has
// over it — 1.2347 of its own spread at this deflection,
// the SHADOW +100 table steepening through 0.26..0.50.
// 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
// at ITS OWN size and the same lift lands on the pixels
// either way.
//
// Those are the two numbers the live probe reads off the deployed bundle (0.57
// before, 0.78 after, over this frame); this is the same claim in arithmetic.
// The spread here is kept WHOLE and not at 0.78 — this twin's band sits on one
// base, where the reconstruction is exact by construction; on a real frame the
// band spans many neighbourhoods and the live number is the one to read.
// The base is the whole point of the second reading: what SHADOW does to a
// frame's texture is a property of the neighbourhood, not of the pixel. (The
// spread here is kept WHOLE because this twin's band sits on ONE base, where the
// reconstruction is exact by construction; on a real frame the band spans many
// neighbourhoods and the live number is the one to read.)
const band = Array.from({ length: 32 }, (_, i) => 0.26 + 0.24 * (i / 31));
const spread = (xs) => Math.max(...xs) - Math.min(...xs);
const bandBase = band.reduce((a, b) => a + b, 0) / band.length;
const movedGlobally = band.map((t) => rebuild([t, t, t], { sh: 1 }).clamped[0]);
const movedLocally = band.map((t) => rebuild([t, t, t], { sh: 1 }, bandBase).clamped[0]);
near4(spread(movedGlobally) / spread(band), 0.41631, 'the pixel-read ramp no longer draws its own band flat');
// Read at the PIXEL the band takes the ramp's own slope where it sits and comes
// back at 1.2347 of its own spread: a lift that steepens the ramp over the band
// draws the band longer, which is the global move and not this pass's.
near4(spread(movedGlobally) / spread(band), 1.2347, 'the pixel-read ramp no longer draws its own band longer');
const bandGain = ramp(bandBase, { sh: 1 }).o / bandBase;
assert.ok(bandGain > 1.1, `the lift is not worth measuring: gain ${bandGain}`);
// Read at the BASE the whole band takes ONE move — the same lift on every pixel
// of it — so its spread comes out exactly whole and the texture is untouched.
close(spread(movedLocally) / spread(band), 1, 'the band lost its texture under the lift — the detail is being scaled again');
assert.ok(
Math.abs(movedLocally[0] - band[0] - (ramp(bandBase, { sh: 1 }).o - bandBase)) < 1e-12,
'the lift is no longer the neighbourhood’s',
);
assert.ok(
spread(movedLocally) / spread(movedGlobally) > 1.5,
`the base is not earning its keep: ${spread(movedLocally) / spread(movedGlobally)}x the global move's spread`
);
// The move belongs to the NEIGHBOURHOOD, not to the pixel: two pixels of one base
// take the same one however far apart they sit, which is exactly what leaves the
// difference between them standing. (Read at the pixel, the move would be the
@@ -820,7 +662,7 @@ for (const ev of [-5, -1, 0, 1, 5]) assert.equal(exposureMove([0, 0, 0], ev)[0],
// only as good as its reading of the source; nothing else compiles EXPOSURE_SKSL,
// so a wrapper whose uniform stopped matching its own main would only show up in
// the app. Four pixels through the real shader, against the twin.
const exposureSrc = resolve(tone.match(/export const EXPOSURE_SKSL = `([\s\S]*?)`;/)?.[1] ?? '');
const exposureSrc = EXPOSURE_SKSL;
assert.match(exposureSrc, /uniform float ev;/, 'the exposure pass no longer takes its stops');
assert.match(exposureSrc, /return vec4\(exposureMove\(clamp\(c\.rgb, 0\.0, 1\.0\), ev\), c\.a\);/, 'the pass stopped calling exposureMove');
const { default: CanvasKitInit } = await import('canvaskit-wasm/bin/full/canvaskit.js');
+26 -45
View File
@@ -35,7 +35,7 @@ writeFileSync(
);
const {
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,
TONE_KNOBS,
} = 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,54 +85,31 @@ function render(srcValue, baseValue) {
return pixels[0];
}
// 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).
// The ramp, in the same arithmetic the shader runs: ONE KNOB is the measured
// knot table (TONE_KNOBS in toneShader.ts) drawn STRAIGHT between the knots,
// scaled by the amount, added to the luma it reads and clamped — the minus side
// of zero reads the minus table — and the four are applied ONE AFTER THE OTHER
// and clamped on the way to the next, so the four moves compose instead of
// summing. SHADOW carries the whole knob on its own, so SHADOW +100 reads
// toneCurve(b, 0, 1, 0, 0).
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 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;
};
// 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);
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
const poly = (c, L) => {
let v = c.v[0];
for (let i = 1; i < c.p.length; i++)
v += (c.v[i] - c.v[i - 1]) * clamp((L - c.p[i - 1]) / (c.p[i] - c.p[i - 1]), 0, 1);
return v;
};
const knob = (L, k, amt) =>
clamp01(L + (amt >= 0 ? amt * poly(TONE_KNOBS[k].plus, L) : -amt * poly(TONE_KNOBS[k].minus, L)));
// 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 = 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) * (1 - L);
L = clamp01(L);
q = toneWhiteW(L);
L += TONE_WHITE_GAIN * wh * q * (1 - L) * L;
return { o: clamp01(L), afterBlack };
const afterBlack = knob(L, 'bl', bl);
return {
o: knob(knob(knob(afterBlack, 'sh', sh), 'hl', hl), 'wh', wh),
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
@@ -143,7 +120,11 @@ 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 * clamp01(ramp(b, { sh: 1 }).o + (t - b)));
const diff = Math.abs(t - b);
const smooth = (x) => x * x * (3 - 2 * x);
const edgeW = 1.0 - (diff <= 0.08 ? 0 : diff >= 0.35 ? 1 : smooth((diff - 0.08) / 0.27));
const effBase = t * (1 - edgeW) + b * edgeW;
const expected = Math.round(255 * clamp01(ramp(effBase, { sh: 1 }).o + (t - effBase)));
const got = render(srcValue, baseValue);
assert.ok(
+254 -257
View File
@@ -4,18 +4,16 @@ import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils';
// Tone-domain adjustments (the four-point tonal range + Fuji-style Dynamic
// Range). SkSL runtime effect over a child image shader.
//
// TONAL RANGE — HIGHLIGHT, SHADOW, WHITE and BLACK. The four knobs are the
// four zones of the tone mapping doc, and no two of them own the same part of
// the ramp:
//
// BLACKS peak at 0.00, gone by 0.25
// SHADOWS peak at 0.25, gone by 0.50
// HIGHLIGHTS peak at 0.75, gone by 0.50 and by 1.00
// WHITES peak at 1.00, gone by 0.75
//
// The four masks below are those tents — the doc's smoothsteps, one per quarter
// of the ramp — and the 0.50 midpoint is in none of them: it is the one value
// every one of the four leaves where it was.
// TONAL RANGE — HIGHLIGHT, SHADOW, WHITE and BLACK. Each knob is the MOVE its
// own Lightroom export made, measured off the nine reference files in the repo
// root and stored as a polyline on the encoded luma (TONE_KNOBS below): WHITE
// +100 takes everything above luma 0.50 to 1.0, BLACK -100 takes everything
// below 0.21 to 0.0, HIGHLIGHT +100 is a +0.24 lift peaking around luma 0.18 and
// dead by 0.72. Read the code values as `out = L + amt * curve(L)`, clamped, amt
// = knob / 10. The four are NOT the four zones of the tone mapping doc any more,
// a tent per quarter of the ramp with a quarter of travel each: that is the
// layout the "sau khi chỉnh sửa 4 thông số này không đúng, không thay đổi" report
// came back on, WHITE ±100 a literal no-op among them.
//
// Colour: the luma takes the move and R, G, B keep their DIFFERENCES — the
// pixel lands on its new luma with the chroma it had, so the hue is untouched
@@ -23,30 +21,23 @@ import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils';
// (`R_new = R_old * Luma_new / Luma_old`), which is exact while it fits and
// moves the hue the moment a channel clips; see the note on `k` below.
//
// These four masks are ADDED in the doc's own pseudo-shader, and measured that
// way the ramp inverts: BLACK +10 against SHADOW -10 falls to a slope of -5 per
// unit luma at t = 0.87 (scratchpad tone-proto.mjs), a dark band where the ramp
// should still be climbing. Read here instead as the four ANCHORS of one ramp —
// knots at 0.00, 0.25, 0.50, 0.75 and 1.00, each moved by its own knob, each
// held inside the knot before it, drawn straight in between — the same
// measurement is monotone for every combination of the four at full deflection.
// A knob moves its anchor by a quarter of the ramp, so +10 BLACKS puts the toe
// on 0.25 and -10 WHITES rolls the head down to 0.75: the reach a tonal range
// slider has in the program this layout copies, without the inversion. SHADOW is
// the one knob held to half of that, because the knot it moves is the HEAD of
// the quarter above it and not an end of the ramp: a band cannot be lifted at
// its head and keep its slope at the same time, so the knob's travel is what has
// to give — see the measured note on a1.
// The four moves are applied ONE AFTER THE OTHER — BLACK, SHADOW, HIGHLIGHT,
// WHITE — each clamped on the way to the next. The doc ADDED its four masks, and
// measured that way the ramp inverts: BLACK +10 against SHADOW -10 falls to a
// slope of -5 per unit luma at t = 0.87 (scratchpad tone-proto.mjs), a dark band
// where the ramp should still be climbing. A composition of non-decreasing maps
// is non-decreasing by construction, so the ramp cannot fold however the four
// sliders are set, and the knot tables are cut so that L + move is monotone on
// every segment; highlight-knee-check.mjs sweeps both.
//
// WHITE and BLACK are not the per-channel toe and shoulder they were on the WB
// tab any more. The doc puts the two points on the ends of the SAME ramp as the
// other two, so they are the ends this ramp is drawn through, and nothing else
// in the shader reads them.
// tab any more. They are the two ENDS of the same ramp the other two move, and
// nothing else in the shader reads them.
//
// The ramp is drawn through the BASE LAYER, not through the pixel. The pixel's
// own luma, run through a knot move, is a GLOBAL curve: every pixel at luma t
// lands on the same o whatever is around it, so a knot lifted onto the band above
// it (SHADOW's a1) is a band whose whole spread is squashed to the slope left
// lands on the same o whatever is around it, so a knob that lifts a band onto
// the one above it is a band whose whole spread is squashed to the slope left
// over — at SHADOW +100 a quarter of the ramp carries half its contrast, and on a
// real frame 0.50 of it survived: the grey sheet the knob was reported for. What
// the eye is reading there is LOCAL contrast, and a curve drawn through the pixel
@@ -82,29 +73,34 @@ import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils';
// to six code values in the quarter-tones even with all four knobs on zero — and
// this pass still runs for the stock split tones and for DR alone, where nothing
// the user set asked for a contrast move. Straight segments keep a neutral
// setting the exact identity. The smoothsteps are the four ZONE masks above,
// which is where the doc's shape belongs: they weight DR and the split tones,
// and nothing but their peak positions has to be smooth.
// setting the exact identity. What smoothsteps are left in toneRamp weight the
// chroma boost, the noise floor and the highlight desaturation — gates on the
// result, none of them on the ramp itself.
//
// The -HL highlight recovery that used to run in LINEAR light ahead of all this
// is gone with it: HIGHLIGHT is one zone move now, in both directions. There is
// is gone with it: HIGHLIGHT is one move now, in both directions. There is
// still detail at the top to move — the develop's own knee compresses the two
// stops the sensor holds above its white level into the frame (see
// highlight-knee-check.mjs), so -WHITES pulls a plateau down onto 0.75 rather
// than onto a flat 1.0.
// highlight-knee-check.mjs), so -WHITES pulls a true white 0.10 down under it
// rather than onto a flat plateau.
//
// dr - DR strength 0..1: lifts shadows slightly and rolls highlights (Fuji
// extended DR); 0/auto/DR100 = no extra curve. It moves the same four
// knots the knobs move, so DR and a knob cannot fight over the middle and
// DR cannot invert the ramp either — added as its own masked terms on top
// it could, and did: see the fold noted on the knots below.
// hl - highlight -1..1: moves the 0.75 anchor, + up toward white, - down.
// sh - shadow -1..1: moves the 0.25 anchor, + up, - down.
// wh - white point -1..1: moves the 1.00 anchor. + is free to pass 1.0 — that
// is the move that clips a highlight toward white — and - pulls the head
// of the ramp down under it.
// bl - black point -1..1: moves the 0.00 anchor. + lifts the toe off the
// floor (a faded black), - has nothing left to crush at 0.
// extended DR); 0/auto/DR100 = no extra curve. It is added to the same
// four amounts the sliders set (bl +0.12, sh +0.06, hl -0.09, wh -0.18),
// so DR rides the measured curves with them and cannot invert the ramp
// the way a set of masked terms of its own did.
// hl - highlight -1..1: the measured HIGHLIGHT move — ±0.24 at full, on the
// DEEP tones, and gone by luma 0.73. The slider's name and what it moves
// are two different things, and the measurement is what is shipped.
// sh - shadow -1..1: the measured SHADOW move. + is a broad lift that is
// hardest at the top of the ramp (+0.17 by luma 0.71); - is a bell
// deepest on the deep tones (-0.16 at 0.22) and dead by 0.73.
// wh - white point -1..1: the measured WHITE move. + drives everything above
// luma 0.50 to 1.0 — the move that clips a highlight toward white — and -
// pulls a true white 0.10 down under it.
// bl - black point -1..1: the measured BLACK move. + lifts the toe off the
// floor (+0.115 by luma 0.19, so a faded black); - clips everything below
// ~0.23 to 0.0 rather than only the very floor.
// vib - vibrance -1..1: chroma-masked saturation. It rides along in this shader
// (rather than the colour matrix) because it needs per-pixel chroma:
// already-vivid pixels move least, so skins/skies deepen without the neon
@@ -139,63 +135,109 @@ const BAND_BLOCK = hslBandGaps()
)
.join('');
// 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.
//
// 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.
// How far down the ramp the BLACK knob's Hunt-effect chroma mask reaches. The
// knob's own curve is measured (see TONE_KNOBS); this is the floor the chroma
// boost reads, and it is the doc's 0.18 — the middle grey it anchors the toe to.
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.
// ONE TONAL KNOB, as the curve Lightroom actually draws. Measured, not derived.
//
// 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.
// The four knobs were the doc's four compact windows (thay_doi_thong_so_giong_
// lightroom.md §2), and they did not survive contact with the exports the app is
// judged against. The doc puts every move inside a quarter of the ramp — BLACKS
// dead past 0.18, SHADOWS down on the floor, HIGHLIGHTS up on the shoulder — and
// at full deflection each was worth a few hundredths of luma on that quarter and
// literally nothing outside it: the "sau khi chỉnh sửa 4 thông số này không đúng,
// không thay đổi" report. The reference exports say something else entirely: on
// Main.jpg, WHITE +100 takes everything above luma 0.50 to 1.0 (mean +0.43),
// BLACK -100 takes everything below 0.21 to 0.0 (mean -0.14), and HIGHLIGHT +100
// is a +0.24 lift peaking around luma 0.18 that has died by 0.72. Whatever the
// slider is called, the MOVE is what a knob has to reproduce, so the move is what
// is stored here.
//
// 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.
// Every curve below is the per-pixel luma the Lightroom export landed on MINUS
// the base luma, binned by base luma and averaged, at ±100 — measured off the
// nine reference files in the repo root (Main.jpg, Main-highlight±100.jpg,
// Main-shadow±100.jpg, Main-white±100.jpg, Main-black±100.jpg), all genuine
// Lightroom Classic 14.2 exports of the same frame. Read the code values as:
// out = L + amt * curve(L), clamped, with amt = knob/10.
//
// 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.
// They are drawn STRAIGHT between the knots, which is the same choice the ramp
// has always made (see the head of TONE_MATH_SKSL) and the right one here: the
// measurement is a polyline, the eye cannot see a 2-code-value corner, and a
// smoothstep through the knots would put an S-curve in the darks no export asked
// for. Knots are placed where the curve bends, ~0.008 of luma is the worst error
// anywhere against the measured grid, and the last knot is forced to 0.0 on 1.0
// so a bright frame (this reference has nothing above luma 0.73) cannot inherit a
// constant lift at the white end. WHITES -100 is the exception that proves it is
// the shoulder anchor: its tail is -0.10, not 0.
//
// 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;
// Only ±100 was sampled, so a knob at 40 is 0.4 of the same curve. That is
// Lightroom's own read (its sliders are linear in the move) and it is the only
// thing the nine files can support. Re-measure with the harness in the checks:
// render the frame through TONE_SKSL at each knob's ±100 and diff.
export type TONE_KNOB = 'bl' | 'sh' | 'hl' | 'wh';
export interface ToneKnobCurve {
p: number[]; // knot positions, encoded luma, ascending, p[0] = 0 and p[last] = 1
v: number[]; // the move at +100, in luma, at each knot
}
export const TONE_KNOBS: Record<TONE_KNOB, { plus: ToneKnobCurve; minus: ToneKnobCurve }> = {
// BLACKS. + lifts from the very floor (0.004 at black, so a true 0 does not
// move) and peaks at 0.115 by luma 0.19. - is the clip: everything below 0.23
// lands on 0 (the first segment's slope is -0.97 against the ramp's 1.0), then
// the frame comes back up to meet the prints by 0.51.
bl: {
plus: { p: [0, 0.01, 0.025, 0.045, 0.185, 0.645, 0.71, 0.73, 1], v: [0.0042, 0.0131, 0.0649, 0.0876, 0.1155, 0.0442, 0.0301, 0.0138, 0] },
minus: { p: [0, 0.23, 0.34, 0.37, 0.51, 0.73, 1], v: [-0.0017, -0.2245, -0.253, -0.2502, -0.1518, -0.0707, 0] },
},
// SHADOWS. The one knob that reads the way its name does: + lifts everywhere
// and hardest at the top (+0.173 at 0.71) — a fill that never lets the bright
// end go; - is a bell deepest on the deep tones (-0.161 at 0.22, dead by 0.73).
sh: {
plus: { p: [0, 0.055, 0.185, 0.42, 0.57, 0.705, 0.73, 1], v: [0.0004, 0.0306, 0.063, 0.0941, 0.16, 0.1734, 0.1534, 0] },
minus: { p: [0, 0.155, 0.22, 0.29, 0.355, 0.395, 0.46, 0.73, 1], v: [-0.0017, -0.1324, -0.1613, -0.1365, -0.1313, -0.0926, -0.0879, 0.0001, 0] },
},
// HIGHLIGHTS. Not the shoulder at all: + is a broad lift of the DEEP tones
// (+0.084 on a true black, +0.237 at 0.19) and - is a band that takes the
// bright end down (-0.169 at 0.63) and dies on 1.0.
hl: {
plus: { p: [0, 0.005, 0.015, 0.19, 0.29, 0.355, 0.405, 0.485, 0.73, 1], v: [0.084, 0.0955, 0.2133, 0.2372, 0.1632, 0.1424, 0.0987, 0.0828, 0.0001, 0] },
minus: { p: [0, 0.22, 0.4, 0.5, 0.625, 0.695, 0.73, 1], v: [-0.0002, -0.0634, -0.0808, -0.1298, -0.1689, -0.1505, -0.0978, 0] },
},
// WHITES. + is the clipping point and nothing else: the move lands on 1.0
// exactly from luma 0.50 (the tail is 1 - L, drawn by the knots 0.465 -> 0.73
// -> 1.0) and lifts the deep tones to get there. - is the white anchor, moved
// down: -0.10 on 1.0, tapering to nothing at the floor.
wh: {
plus: { p: [0, 0.01, 0.025, 0.035, 0.05, 0.175, 0.22, 0.35, 0.385, 0.465, 0.73, 1], v: [0.0023, 0.0169, 0.1217, 0.1634, 0.2036, 0.4103, 0.464, 0.5295, 0.5647, 0.534, 0.27, 0] },
minus: { p: [0, 0.495, 0.7, 0.73, 1], v: [0, -0.0294, -0.0609, -0.0831, -0.1] },
},
};
// One knot table as a polyline: the first value, plus one hinge per segment.
// Written as a SUM of clamps rather than a loop because SkSL indexes arrays by
// constant only (see the mixer at the foot of TONE_SKSL), and generated from the
// table so the source and the shader cannot drift.
const knobChain = (c: ToneKnobCurve): string =>
c.v
.map((v, i) =>
i === 0
? v.toFixed(4)
: ` + (${(v - c.v[i - 1]).toFixed(4)}) * clamp((L - ${c.p[i - 1].toFixed(3)}) / ${(c.p[i] - c.p[i - 1]).toFixed(3)}, 0.0, 1.0)`
)
.join('');
// One knob, both directions, as the shader reads it: the move is the knot table
// on its own side of zero scaled by the amount, and the ramp is the move added
// to L and clamped. The clamp is what keeps a -- knob's own clip a plateau
// instead of a fold.
const KNOB_SKSL = (['bl', 'sh', 'hl', 'wh'] as const)
.map(
(k) => `float tone${k}(float L, float amt) {
return clamp(L + (amt >= 0.0 ? amt * (${knobChain(TONE_KNOBS[k].plus)}) : -amt * (${knobChain(TONE_KNOBS[k].minus)})), 0.0, 1.0);
}`
)
.join('\n');
// 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).
@@ -235,119 +277,24 @@ 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 = `
// 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;
}
// 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));
}
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)^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 — 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.
// THE FOUR TONAL KNOBS, as the curves the reference exports measure (TONE_KNOBS
// above says where the numbers come from and why they are not the doc's four
// compact windows any more): each is a polyline on the encoded luma, each is
// added to the luma it reads and clamped, and the four are applied ONE AFTER THE
// OTHER instead of summed. A composition of non-decreasing maps is non-decreasing
// by construction, so the ramp needs no guard whatever the four sliders say — and
// the knot tables are cut so that L + move is monotone on every segment, which is
// the property highlight-knee-check sweeps for.
${KNOB_SKSL}
// The four moves in the order the exports were read: the toe, then the bell that
// reads the deep tones, then the one the slider calls HIGHLIGHT, then the white
// anchor. Each clamps on the way to the next (tone* does it), so a knob that has
// driven the luma to an end leaves the ones after it nothing to fold.
float toneCurve(float L, float bl, float sh, float hl, float wh) {
float q;
// 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
// 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, 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) * (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;
L = tonebl(L, bl);
L = tonesh(L, sh);
L = tonehl(L, hl);
L = tonewh(L, wh);
return clamp(L, 0.0, 1.0);
}
// Below t = 0.0004 there is no ratio worth the name: dividing by what is left of
@@ -364,27 +311,56 @@ float toneCurve(float L, float bl, float sh, float hl, float wh) {
// a channel reaches the ceiling, where the three clip by different amounts and
// the hue goes with them.
vec3 lightMove(vec3 c, float t, float o) {
float k = t > 0.0004 ? o / t : 1.0;
float safeT = max(t, 0.005);
float k = t > 0.0004 ? min(o / safeT, 3.5) : 1.0;
float hiC = max(max(c.r, c.g), c.b);
float loC = min(min(c.r, c.g), c.b);
if (hiC > t) k = min(k, (1.0 - o) / (hiC - t));
if (loC < t) k = min(k, o / (t - loC));
if (hiC > t) k = min(k, (1.0 - o) / max(hiC - t, 0.0001));
if (loC < t) k = min(k, o / max(t - loC, 0.0001));
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) {
// 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),
// Edge-aware range falloff: when |t - base| is small (flat/fine texture), edgeW is 1.0.
// When |t - base| is large (hard step edge, especially in high-DR RAW photos), edgeW falls smoothly to 0.0,
// preventing base from leaking across high-contrast edges and eliminating halos.
float diff = abs(t - base);
float edgeW = 1.0 - smoothstep(0.08, 0.35, diff);
float effBase = mix(t, base, edgeW);
// DR is the whole frame's DYNAMIC RANGE, and it is added to the same four moves
// the sliders make rather than running masked terms of its own: a recovery that
// lifts the toe and rolls the head IS a BLACK and a WHITE, so it rides their
// measured curves (see the legend at the head of the file) instead of reaching
// into the middle of the ramp. 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(effBase, 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));
float target = clamp(o + (t - effBase), 0.0, 1.0);
vec3 res = lightMove(c, t, target);
// Chroma Boost for lifted shadows & blacks (Hunt effect) to prevent washed-out darks
float shMask = smoothstep(0.02, 0.12, effBase) * (1.0 - smoothstep(0.25, 0.55, effBase));
float uBl = clamp(1.0 - effBase / ${TONE_BLACK_EDGE}, 0.0, 1.0);
float blMask = uBl * uBl * uBl;
float boost = 1.0 + (shMask * max(sh, 0.0) * 0.35) + (blMask * max(bl, 0.0) * 0.25);
if (boost > 1.001) {
res = mix(vec3(target), res, boost);
}
// Desaturate noise floor in extreme darks (< 3% luminance) to prevent noise specks
float noiseFloor = smoothstep(0.0, 0.03, target);
res = mix(vec3(target), res, noiseFloor);
// Soft roll-off and chroma desaturation for highlights reduction (hl < 0) to avoid dirty gray clouds (100_lightroom.md §6)
if (hl < 0.0 && target > 0.70) {
float hlDesat = smoothstep(0.70, 0.98, target) * (-hl) * 0.35;
res = mix(res, vec3(target), hlDesat);
}
return clamp(res, 0.0, 1.0);
}
// The accurate sRGB transfer pair (0.04045/12.92 + 2.4, and its inverse): the
@@ -846,8 +822,13 @@ vec4 main(vec2 xy) {
float lb = dot(b, CLARITY_LUM);
float mid = clamp(4.0 * lb * (1.0 - lb), 0.0, 1.0);
float nl = clamp(lb + (lo - lb) * (1.0 + strength * CLARITY_DETAIL_GAIN * mid), 0.0, 1.0);
float scale = (nl + CLARITY_EPS) / (lo + CLARITY_EPS);
return vec4(clamp(c.rgb * scale, 0.0, 1.0), c.a);
float safeLo = max(lo, 0.005);
float scale = (nl + CLARITY_EPS) / (safeLo + CLARITY_EPS);
float clampedScale = min(scale, 1.0 + max(strength, 0.0) * 2.5);
vec3 res = c.rgb * clampedScale;
float noiseFloor = smoothstep(0.0, 0.03, nl);
res = mix(vec3(nl), res, noiseFloor);
return vec4(clamp(res, 0.0, 1.0), c.a);
}
`;
@@ -955,7 +936,14 @@ vec4 main(vec2 xy) {
vec3 a = max(air, vec3(0.05));
float d = clamp(dark.eval(xy).r, 0.0, 1.0);
float t = clamp(1.0 - amount * ${DEHAZE_MAX_OMEGA} * d, floorT, maxT);
return vec4(clamp((c - a) / t + a, 0.0, 1.0), 1.0);
vec3 res = (c - a) / t + a;
float origLum = dot(c, vec3(0.2126, 0.7152, 0.0722));
float newLum = dot(clamp(res, 0.0, 1.0), vec3(0.2126, 0.7152, 0.0722));
if (amount > 0.0 && origLum > 0.001) {
float satAdj = clamp(1.0 - (amount * 0.15 * (1.0 - t)), 0.75, 1.25);
res = mix(vec3(newLum), res, satAdj);
}
return vec4(clamp(res, 0.0, 1.0), 1.0);
}
`;
@@ -994,45 +982,54 @@ 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 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.
// The `sh`, `hl` and `wh` 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 a move of -0.07 at
// luma 0.25, which is -0.47 on this ramp. They are solved against the MEASURED
// tables rather than tuned by eye. The stocks were written against 0.18 / 0.22 /
// 0.17 on the toe and 0.7375 / 0.815 on the head, and the ramp reads them at
// 0.1792 / 0.2126 / 0.1735 and 0.7397 / 0.8151 — the numbers
// highlight-knee-check pins. The Acros toe lands 0.0074 under its target because
// the HIGHLIGHT move reaches down to luma 0.22, so its shoulder lands on the toe
// as well.
//
// A LIFTED head is asked of WHITES and a ROLLED one of HIGHLIGHT, because that
// is where the measured tables have anything left: the HIGHLIGHT curve is dead
// by luma 0.73 on the + side (a lift has nothing up there to lift), while its -
// side still reaches a true white.
const FILM_TONE: Partial<Record<BaseFilter, Partial<ToneUniforms>>> = {
'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.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
// reaches a true black (no film-base lift, no flat grey wash) and a highlight
// shoulder that stops just short of white instead of clipping a cloud to
// 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 bands of the ramp: -0.20 puts the toe on 0.22
// 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).
// Acros. A black-and-white stock IS its grey ramp, so this entry names only the
// two ENDS: a shadow toe that reaches a true black (no film-base lift, no flat
// grey wash) and a highlight shoulder that stops just short of white instead of
// clipping a cloud to paper. Neither move is a compact window any more — they
// are the measured curves, so the -0.1143 shoulder does reach down into the
// midtones as well — and nothing else is asked of the stock: the strength the
// greys need is its matrix slope (SIM_CONTRAST_BIAS in colorUtils), 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 asks the toe for 0.22
// (it reads 0.2126, the -0.1143 shoulder reaching down onto it) and -0.1143
// rolls the head to 0.7397, both read off the measured tables at their knots
// (see the note above FILM_TONE).
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.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
// (0.17 against Acros' 0.22) so the darks reach true black, and a shoulder
// that LIFTS instead of rolling (+0.26 of WHITES, the head going to 0.815
// against Acros' 0.7397), 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.5929 },
//
// SHADOW is at FULL deflection here, and that is not a taste: the WHITES move
// this stock asks for is measured to lift the darks as well (+0.074 at luma
// 0.25 on this ramp — a WHITE anchor is the top of a curve, not a window), so
// the toe only lands on its 0.17 if the crush under it is everything the knob
// has. It reads 0.1735, which is the floor.
'mono-high-contrast': { sh: -1.0, wh: 0.26 },
};
// The base layer's neighbourhood is the caller's business, not this function's:
@@ -1046,13 +1043,13 @@ export function getToneUniforms(
): ToneUniforms {
const drRaw = adj.dynamicRange ?? 'auto';
const dr = drRaw === 'auto' || drRaw === 100 ? 0 : (drRaw - 100) / 300;
const hl = Math.max(-1, Math.min(1, (adj.highlight ?? 0) / 10));
const sh = Math.max(-1, Math.min(1, (adj.shadow ?? 0) / 10));
const hl = Math.max(-1, Math.min(1, (adj.highlight ?? (adj as any).highlights ?? 0) / 10));
const sh = Math.max(-1, Math.min(1, (adj.shadow ?? (adj as any).shadows ?? 0) / 10));
const wh = Math.max(-1, Math.min(1, (adj.whites ?? 0) / 10));
const bl = Math.max(-1, Math.min(1, (adj.blacks ?? 0) / 10));
const vib = Math.max(-1, Math.min(1, (adj.vibrance ?? 0) / 10));
// WHITE and BLACK ride this pass with the other two, each as the end knot of
// the same ramp (see TONE_SKSL). They are no longer a white-balance move and
// WHITE and BLACK ride this pass with the other two, as two of the four moves
// on the same luma (see TONE_SKSL). They are no longer a white-balance move and
// are read by nothing else in the pipeline.
const film = (baseFilter && FILM_TONE[baseFilter]) || {};
const shT: [number, number, number] = film.shT ?? [0, 0, 0];