diff --git a/docker/frontend/scripts/highlight-knee-check.mjs b/docker/frontend/scripts/highlight-knee-check.mjs index 257e557..ac1fcb4 100644 --- a/docker/frontend/scripts/highlight-knee-check.mjs +++ b/docker/frontend/scripts/highlight-knee-check.mjs @@ -60,7 +60,43 @@ const mathTmpl = tone.match(/export const TONE_MATH_SKSL = `([\s\S]*?)`;/)?.[1]; assert.ok(mathTmpl, 'TONE_MATH_SKSL is gone — the frame and a mask no longer share the maths'); assert.equal((mathTmpl.match(/\$\{TONE_ANCHOR\}/g) ?? []).length, 4, 'a knot is pinned to a literal, not to TONE_ANCHOR'); assert.ok(tmpl.includes('${TONE_MATH_SKSL}'), 'the frame pass carries its own copy of the ramp again'); -assert.match(tmpl, /rgb = toneRamp\(rgb, t, bl, sh, hl, wh, dr\);/); +assert.match(tmpl, /rgb = toneRamp\(rgb, t, baseLuma\(xy, t\), bl, sh, hl, wh, dr\);/); +// The BASE layer the ramp is drawn through, and the radius the caller spends on +// it. It reads the CHILD nine times — a caller whose bx is zero reads its own +// pixel nine times and gets the global move back, which is what makes the shared +// maths safe for a mask (see the mask's own call below). +assert.match(tmpl, /float baseLuma\(vec2 xy, float t\) \{/); +assert.match(tmpl, /vec3 s = clamp\(src\.eval\(xy \+ vec2\(float\(i\), float\(j\)\) \* bx\)\.rgb, 0\.0, 1\.0\);/); +assert.match(tmpl, /float rw = exp\(-d \* d \* 24\.0\);/); +assert.doesNotMatch(tmpl, /if \(i == 0 && j == 0\) continue;/, 'the 3x3 grew a branch — bx = 0 no longer returns exactly t'); +assert.match(tmpl, /uniform float2 bx;/); +// 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, the shader has a +// step in its own pixels. +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%`); +const engine = readFileSync(new URL('../src/engine/exportEngine.ts', import.meta.url), 'utf8'); +assert.match( + engine, + /getToneUniforms\(adjustments, recipe\.baseFilter, \[\s*width \* TONE_BASE_RADIUS,\s*height \* TONE_BASE_RADIUS,\s*\]\)/, + 'the tone pass no longer hands the base a frame-sized step' +); +// 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( + (n, [, kind, , len]) => n + (len ? Number(len) : kind === 'float2' ? 2 : 1), + 0 +); +const arrayFn = tone.match(/export function toneUniformArray\(u: ToneUniforms\): number\[\] \{\n return \[([\s\S]*?)\n \];/)?.[1]; +assert.ok(arrayFn, 'toneUniformArray is gone'); +const written = arrayFn + .split(',') + .map((s) => s.trim()) + .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_ANCHOR}', String(A)); const sksl = resolve(tmpl); @@ -69,7 +105,11 @@ assert.doesNotMatch(tmpl, /float a4 = /, 'the ramp is back inside the pass — o 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\)\);/); -assert.match(mask, /c = half3\(toneRamp\(vec3\(c\), lf, tone\.w, tone\.y, tone\.x, tone\.z, 0\.0\)\);/); +// The mask hands the ramp its OWN pixel as the base, twice over: a shape has no +// neighbourhood of its own, and base == t is a ratio of exactly 1, so what a mask +// does with SHADOW is what it always did. The knob means the same thing on both +// sides of the call; what differs is the neighbourhood, and a mask has none. +assert.match(mask, /c = half3\(toneRamp\(vec3\(c\), lf, lf, tone\.w, tone\.y, tone\.x, tone\.z, 0\.0\)\);/); assert.doesNotMatch(mask, /0\.55, 1\.35/, 'the mask kept its own arbitrary saturation clamp'); assert.doesNotMatch(mask, /cg = clamp\(lifted/, 'the mask is back on its own tone formula'); @@ -93,10 +133,17 @@ assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again — // knots bends the ramp by six code values in the quarter-tones with every knob // on zero, and this pass also runs for the stock split tones and for DR alone. assert.match(sksl, /float lin\(float e0, float e1, float x\) \{\n return clamp\(\(x - e0\) \/ \(e1 - e0\), 0\.0, 1\.0\);\n\}/); -assert.match(sksl, /float o = mix\(a0, a1, lin\(0\.00, 0\.25, t\)\);/); -assert.match(sksl, /o = mix\(o, mix\(a1, 0\.5, lin\(0\.25, 0\.50, t\)\), step\(0\.25, t\)\);/); -assert.match(sksl, /o = mix\(o, mix\(0\.5, a3, lin\(0\.50, 0\.75, t\)\), step\(0\.50, t\)\);/); -assert.match(sksl, /o = mix\(o, mix\(a3, a4, lin\(0\.75, 1\.00, t\)\), step\(0\.75, t\)\);/); +assert.match(sksl, /float o = mix\(a0, a1, lin\(0\.00, 0\.25, base\)\);/); +assert.match(sksl, /o = mix\(o, mix\(a1, 0\.5, lin\(0\.25, 0\.50, base\)\), step\(0\.25, base\)\);/); +assert.match(sksl, /o = mix\(o, mix\(0\.5, a3, lin\(0\.50, 0\.75, base\)\), step\(0\.50, base\)\);/); +assert.match(sksl, /o = mix\(o, mix\(a3, a4, lin\(0\.75, 1\.00, base\)\), step\(0\.75, base\)\);/); +// ...and the pixel rides the ratio of that: the DETAIL layer, kept whole. The +// ramp's own luma is not what is handed to the rebuild any more — the pixel's is, +// scaled by the neighbourhood's gain — 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 = base > 0\.0004 \? o \* t \/ base : t;/); +assert.match(sksl, /return lightMove\(c, t, clamp\(target, 0\.0, 1\.0\)\);/); +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 // pass: HIGHLIGHT is one zone move in both directions now, and a second pass over @@ -116,7 +163,7 @@ 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, /return clamp\(vec3\(o\) \+ \(c - vec3\(t\)\) \* k, 0\.0, 1\.0\);/); -assert.match(maths, /return lightMove\(c, t, clamp\(o, 0\.0, 1\.0\)\);/); +assert.match(maths, /return lightMove\(c, t, clamp\(target, 0\.0, 1\.0\)\);/); 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 @@ -353,11 +400,17 @@ function lightMove(rgb, t, o) { if (loC < t) k = Math.min(k, o / (t - loC)); return rgb.map((c) => o + (c - t) * k); } -function rebuild(rgb, k) { +// The rebuild, with the base layer the shader now draws the ramp through. `base` +// defaults to the pixel itself — the degenerate call, and the one a mask makes — +// which lands `target` back on `o` and is the move this function had before there +// was a base at all. +function rebuild(rgb, k, base) { const t = lumaOf(rgb); - const o = ramp(t, k).o; - const out = lightMove(rgb, t, o); - return { out, clamped: out.map((c) => clamp01(c)), o, t }; + const b = base ?? t; + const o = ramp(b, k).o; + const target = clamp01(b > 0.0004 ? (o * t) / b : t); + const out = lightMove(rgb, t, target); + return { out, clamped: out.map((c) => clamp01(c)), o, t, base: b, target }; } // The transfer pair the exposure pass crosses into linear light with, and back. const srgbToLin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); @@ -441,10 +494,54 @@ for (const [rgb, knobs] of [ ]) { const lifted = rebuild(rgb, knobs); const grew = (lifted.clamped[0] - lifted.clamped[1]) / (rgb[0] - rgb[1]); - assert.ok(Math.abs(lifted.o / lifted.t - 1) > 1e-3, `the lift left the ramp alone on ${rgb} — the case proves nothing`); - close(grew, lifted.o / lifted.t, `the chroma did not ride the ratio on ${rgb} at ${JSON.stringify(knobs)}`); + assert.ok(Math.abs(lifted.target / lifted.t - 1) > 1e-3, `the lift left the ramp alone on ${rgb} — the case proves nothing`); + close(grew, lifted.target / lifted.t, `the chroma did not ride the ratio on ${rgb} at ${JSON.stringify(knobs)}`); } +// THE BASE LAYER. A band with texture in it — SHADOW's own quarter, 0.26 to +// 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 HALF slope that quarter has +// left under the knot — the grey sheet, drawn flat, 0.50 of +// its own spread. +// read at the base every pixel of ONE neighbourhood takes the same gain, +// o(base)/base, so the texture inside it rides out whole, +// 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. +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]); +close(spread(movedGlobally) / spread(band), 0.5, 'the pixel-read ramp no longer draws its own band at half slope'); +const bandGain = ramp(bandBase, { sh: 1 }).o / bandBase; +assert.ok(bandGain > 1.1, `the lift is not worth measuring: gain ${bandGain}`); +close(spread(movedLocally) / spread(band), bandGain, 'the band did not keep its texture under the lift'); +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 gain 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 gain would be the +// pixel's own o / t — the slope of the curve where the pixel happens to be.) +for (const [lo, hi] of [[0.28, 0.44], [0.30, 0.48]]) { + const a = rebuild([lo, lo, lo], { sh: 1 }, 0.38).clamped[0] / lo; + const b = rebuild([hi, hi, hi], { sh: 1 }, 0.38).clamped[0] / hi; + close(a, b, 'the gain is the pixel’s again, not the neighbourhood’s'); +} +// Every knob on zero is the identity through the base path too, whatever base is +// handed in — the ramp at b IS b, so the ratio is 1 — and so is a caller whose +// base is its own pixel (bx = 0, the mask, the nine identical taps). +for (const b of [0.01, 0.1, 0.35, 0.7, 0.99]) + for (const rgb of [...colourCases, ...greyCases]) { + const g = rebuild(rgb, {}, b); + for (let i = 0; i < 3; i++) close(g.clamped[i], rgb[i], `the base path is not the identity at zero, base ${b}`); + } + // THE EXPOSURE KNOB, the same move on a different input. Behind it: -2..+2 EV in // half stops, on the frame and inside a gradient mask. // A grey is the knob it always was — a stop on a neutral is a stop on its light, diff --git a/docker/frontend/shared/utils/gradientMask.ts b/docker/frontend/shared/utils/gradientMask.ts index a9eed5d..0ade7ec 100644 --- a/docker/frontend/shared/utils/gradientMask.ts +++ b/docker/frontend/shared/utils/gradientMask.ts @@ -212,8 +212,15 @@ half3 maskAdjust(half3 c, half3 wb, float4 a, float4 tone, float4 fx, half dark$ // ordering clamp, the 0.50 midpoint no knob reaches, and the luma-preserving // rebuild are all the frame's. DR is the one knob the ramp also carries that a // mask does not have, so it is spent as 0 here. + // + // The BASE of that ramp is the mask's own pixel, handed in twice: a mask runs + // on the shape the user drew and has no neighbourhood of its own, and the + // degenerate call is the one the shared maths is written to take — a base equal + // to t is a ratio of exactly 1, so the mask keeps the global move it has always + // made. Give it a base of its own when a mask's SHADOW is reported the way the + // frame's was. float lf = clamp(float(l), 0.0, 1.0); - c = half3(toneRamp(vec3(c), lf, tone.w, tone.y, tone.x, tone.z, 0.0)); + c = half3(toneRamp(vec3(c), lf, lf, tone.w, tone.y, tone.x, tone.z, 0.0)); half nl = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722)); c = mix(half3(nl), c, half(1.0 + a.z)); ${spatial ? ` // DEHAZE before CLARITY, the frame-wide order and for the frame-wide reason: diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index 2f95bed..cd38156 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -43,6 +43,32 @@ import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils'; // other two, so they are the ends this ramp is drawn through, 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 +// 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 +// cannot see it. +// +// So the curve is drawn through what the frame holds AROUND the pixel — a coarse +// edge-aware blur of the luma, TONE_BASE_RADIUS of the frame wide (the +// fix_shadow.md decomposition, Base x Detail) — and the pixel then rides the +// ratio: it takes the neighbourhood's gain `o / base` and keeps the difference +// from it. Base moves, detail stays: the same lift, on the same pixels, with the +// texture inside the region left standing instead of drawn flat. Full deflection +// on the same frame keeps 0.78 of the band's spread where the global move kept +// 0.57 (shadow-live.mjs, before and after, over the deployed pass; shadow-band.py, +// the numpy twin of this maths, put it at 0.78 to 0.80). +// +// It costs nothing where there is no lift to make: with every knob on zero the +// ramp at base IS base, so the ratio is exactly 1 and the pass is the identity +// however coarse the base is. A caller that hands in no neighbourhood at all +// (bx = 0, or a mask, which has none) reads its own pixel nine times and gets the +// global move back — which is why the shared maths can take the base as an +// argument and mean the same thing in both places. +// // Between two knots the ramp is drawn STRAIGHT, and that is deliberate: a // smoothstep there is an S-curve through the knots, so it bends the ramp by up // to six code values in the quarter-tones even with all four knobs on zero — and @@ -115,6 +141,16 @@ const BAND_BLOCK = hslBandGaps() // a whole quarter the band is drawn flat — see the notes on a1 and a3. export const TONE_ANCHOR = 0.25; +// How far out the BASE layer of `toneRamp` reads, as a fraction of the frame's +// own width and height — the fix_shadow.md neighbourhood (it asks for 2%..5% of +// the width). A fraction rather than a pixel count so the preview and the export +// look at the same neighbourhood, and the measurement is flat across the range +// anyway: full deflection on the sample frame keeps 0.77 of the band's spread at +// 0.7%, 0.80 at 2.5%, 0.81 at 4.8%. The low end of the doc's range, because a +// wider base is a wider neighbourhood for a strong edge to be reconstructed +// across. +export const TONE_BASE_RADIUS = 0.025; + // The tone and exposure maths, in ONE copy, because two passes ask it: the // whole-frame passes here and a gradient mask, which moves the same knobs on the // shape the user drew. What "HIGHLIGHT" or "EXPOSURE" means must not depend on @@ -191,7 +227,7 @@ vec3 lightMove(vec3 c, float t, float o) { if (loC < t) k = min(k, o / (t - loC)); return clamp(vec3(o) + (c - vec3(t)) * k, 0.0, 1.0); } -vec3 toneRamp(vec3 c, float t, float bl, float sh, float hl, float wh, float dr) { +vec3 toneRamp(vec3 c, float t, float base, float bl, float sh, float hl, float wh, float dr) { float a4 = 1.0 + ${TONE_ANCHOR} * wh - dr * 0.18; // HIGHLIGHT rides half the anchor for the reason a1 below does, and this knot // is the worse of the two: a3 is the HEAD of the 0.75..1.00 quarter, so a unit @@ -216,11 +252,21 @@ vec3 toneRamp(vec3 c, float t, float bl, float sh, float hl, float wh, float dr) // slider dead — which is why the RATE is halved and not just the ceiling. float a1 = clamp(0.25 + ${TONE_ANCHOR} * 0.5 * sh + dr * 0.06, 0.0, 0.5); float a0 = clamp(${TONE_ANCHOR} * bl + dr * 0.12, 0.0, a1); - float o = mix(a0, a1, lin(0.00, 0.25, t)); - o = mix(o, mix(a1, 0.5, lin(0.25, 0.50, t)), step(0.25, t)); - o = mix(o, mix(0.5, a3, lin(0.50, 0.75, t)), step(0.50, t)); - o = mix(o, mix(a3, a4, lin(0.75, 1.00, t)), step(0.75, t)); - return lightMove(c, t, clamp(o, 0.0, 1.0)); + // The ramp is read at the BASE, so the curve's move is the neighbourhood's and + // the pixel keeps its own difference from it — the ratio below is the whole of + // the detail layer (fix_shadow.md's Reconstructed = Base' * (Input / Base)), + // with lightMove and its caps doing the reconstruction the way every other + // brightness move in this file is made. The pixel's own luma still travels as + // t: it is the value that is being rebuilt, and the one the caps read. + float o = mix(a0, a1, lin(0.00, 0.25, base)); + o = mix(o, mix(a1, 0.5, lin(0.25, 0.50, base)), step(0.25, base)); + o = mix(o, mix(0.5, a3, lin(0.50, 0.75, base)), step(0.50, base)); + o = mix(o, mix(a3, a4, lin(0.75, 1.00, base)), step(0.75, base)); + // Below the pedestal there is no base worth a ratio (the same floor lightMove + // holds its own scale under); the pixel is left where the ramp put its own + // luma, which is what a caller with no neighbourhood hands in anyway. + float target = base > 0.0004 ? o * t / base : t; + return lightMove(c, t, clamp(target, 0.0, 1.0)); } // The accurate sRGB transfer pair (0.04045/12.92 + 2.4, and its inverse): the // same constants colorUtils.planckianLinear uses on the WB side, and the reason @@ -288,6 +334,10 @@ uniform float hslL[8]; uniform float gh; uniform float gs; uniform float gl; +// One tap of the base layer, in the frame's own pixels (TONE_BASE_RADIUS of it). +// Zero is a legal neighbourhood — it is the one a caller with no frame to look +// at hands in, and it reads the pixel itself nine times. +uniform float2 bx; // sRGB <-> HSL. The mixer works in HSL because that is the space the knobs are // named after: a hue shift must not change how light a colour is, and a // lightness move must not change its hue, which is exactly what scaling RGB @@ -333,6 +383,33 @@ float bandW(float hue, float anchor, float gapL, float gapR) { return d <= 0.0 ? max(0.0, 1.0 + d / gapL) : max(0.0, 1.0 - d / gapR); } ${TONE_MATH_SKSL} +// The BASE layer: the light the frame carries where this pixel sits, at the +// radius the caller hands in — a coarse blur of the LUMA, because the luma is the +// one quantity the ramp moves (the colour rides the ratio afterwards). Nine taps +// of the child at plus or minus one bx is not a guided filter; it is the doc's +// own cheap stand-in for one, and the range weight is what keeps it from being a +// blur across an edge: a tap whose luma is nothing like the centre's counts for +// little, so a dark crevice in a sunlit rock keeps its own base instead of being +// reconstructed against the rock. Same weight shape the CLARITY reference blurs +// with (CLARITY_BLUR_SKSL), on the luma rather than the colour. A three-by-three +// that INCLUDES the centre — no branch, and a caller whose bx is zero reads the +// same pixel nine times, which lands the base exactly on t and hands back the +// global move. +float baseLuma(vec2 xy, float t) { + float sum = 0.0; + float wsum = 0.0; + for (int i = -1; i <= 1; i++) { + for (int j = -1; j <= 1; j++) { + vec3 s = clamp(src.eval(xy + vec2(float(i), float(j)) * bx).rgb, 0.0, 1.0); + float ts = dot(s, vec3(0.2126, 0.7152, 0.0722)); + float d = ts - t; + float rw = exp(-d * d * 24.0); + sum += rw * ts; + wsum += rw; + } + } + return sum / wsum; +} vec4 main(vec2 xy) { vec4 c = src.eval(xy); vec3 rgb = clamp(c.rgb, 0.0, 1.0); @@ -354,7 +431,7 @@ vec4 main(vec2 xy) { // same knots, the same hue-preserving rebuild, the same move a gradient mask // makes with the same four sliders. DR is the whole frame's, so it is spent // here and nowhere else. - rgb = toneRamp(rgb, t, bl, sh, hl, wh, dr); + rgb = toneRamp(rgb, t, baseLuma(xy, t), bl, sh, hl, wh, dr); // Split tone (stock look): the shadows and the highlights may each carry // their own tint, so the two ends of the curve can drift opposite ways // (Classic Neg: green-cyan darks, warm brights) without touching mid-greys. @@ -659,6 +736,7 @@ export interface ToneUniforms { gh: number; // -1..1 whole-image hue turn (±30° at full) gs: number; // -1..1 whole-image saturation scale gl: number; // -1..1 whole-image lightness offset (±0.25 at full, ungated) + bx: [number, number]; // one base-layer tap, in the frame's own pixels } // Per-stock tone pass. Fuji's Classic stocks are not a plain colour matrix: @@ -701,7 +779,15 @@ const FILM_TONE: Partial>> = { 'mono-high-contrast': { sh: -0.64, hl: 0.52 }, }; -export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): ToneUniforms { +// `baseStep` is one tap of the base layer in the frame's own pixels; the caller +// is the only one that knows how big the frame is (TONE_BASE_RADIUS of it). Left +// at zero the pass reads no neighbourhood and keeps the global ramp — see the +// note at the head of the file. +export function getToneUniforms( + adj: ColorAdjustments, + baseFilter?: BaseFilter, + baseStep: [number, number] = [0, 0] +): 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)); @@ -766,6 +852,7 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): gh, gs, gl, + bx: baseStep, }; } @@ -776,6 +863,7 @@ export function toneUniformArray(u: ToneUniforms): number[] { u.dr, u.hl, u.sh, u.wh, u.bl, u.vib, u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb, u.hslOn, ...u.hslH, ...u.hslS, ...u.hslL, u.gh, u.gs, u.gl, + u.bx[0], u.bx[1], ]; } diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index 21a8d7c..b9eac28 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -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 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);