light: the tone ramp is drawn through a base layer, not the pixel, so a SHADOW lift moves the region and leaves the texture in it standing — the quarter above the knot came back at 0.57x of its own spread, 0.78x now

The four knots were read at the pixel's own luma, which makes the ramp a
global curve: every pixel at luma t lands on the same o whatever surrounds
it. SHADOW's a1 is the head of the quarter above it, so lifting it squashed
that whole quarter to the half slope left over — measured on a real frame,
0.50 of its spread (shadow-band.py), 0.57 on the deployed bundle — the grey
sheet the knob was reported for. A curve drawn through the pixel cannot see
local contrast; that is what the eye was reading.

So the ramp is read at TONE_BASE_RADIUS (2.5% of the frame) of the luma
around the pixel — a 3x3 range-weighted blur, the fix_shadow.md Base x
Detail split — and the pixel then rides the neighbourhood's gain o/base,
keeping its own difference from it. Base moves, detail stays: the same lift
on the same pixels, with the texture inside the region left standing. Full
deflection keeps 0.78 of the band's spread now.

The base is the same maths in every caller: bx = 0 (a mask, which has no
neighbourhood) reads the pixel nine times and gets the old global move back,
and with every knob on zero the ramp at base IS base, so the ratio is 1 and
the pass is the identity however coarse the base is.

Skipped: no chroma compensation (Hunt). Measured, the ratio held saturation
(0.3184 -> 0.3169), so it is not earned yet. No linear-light ramp either:
the multiply is a uniform gain on encoded values, which is the same stop
exposureMove already argues for.
This commit is contained in:
2026-09-30 18:46:36 +07:00
parent 25b1312e0a
commit e61dccc784
4 changed files with 222 additions and 23 deletions
+8 -1
View File
@@ -34,6 +34,7 @@ import {
DEHAZE_SKSL,
dehazeUniformArray,
getToneUniforms,
TONE_BASE_RADIUS,
toneIsActive,
toneUniformArray,
glowUniformArray,
@@ -796,7 +797,13 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
const matrixIsIdentity = matrix.every((v, i) => v === (i % 6 === 0 ? 1 : 0));
// 3b. Tone shader.
const tone = getToneUniforms(adjustments, recipe.baseFilter);
// The base layer the ramp is drawn through reads a fraction of the FRAME, so
// the preview and the file look at the same neighbourhood — this is the one
// place that knows how big the frame is (TONE_BASE_RADIUS, see toneShader.ts).
const tone = getToneUniforms(adjustments, recipe.baseFilter, [
width * TONE_BASE_RADIUS,
height * TONE_BASE_RADIUS,
]);
let toneShader: any = null;
// 3c. Cinema seasonal grade (cinema → tone → image).
const cinema = getCinemaUniforms(recipe.cinema);