diff --git a/docker/frontend/scripts/highlight-knee-check.mjs b/docker/frontend/scripts/highlight-knee-check.mjs index 5d6f4a2..c8db976 100644 --- a/docker/frontend/scripts/highlight-knee-check.mjs +++ b/docker/frontend/scripts/highlight-knee-check.mjs @@ -27,6 +27,7 @@ // node scripts/highlight-knee-check.mjs import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; 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'); @@ -47,8 +48,27 @@ const A = Number(anchorSrc); assert.equal(A, 0.25, 'a knob no longer moves its knot a quarter of the ramp'); const tmpl = tone.match(/export const TONE_SKSL = `([\s\S]*?)`;/)?.[1]; assert.ok(tmpl, 'TONE_SKSL is gone'); -assert.equal((tmpl.match(/\$\{TONE_ANCHOR\}/g) ?? []).length, 4, 'a knot is pinned to a literal, not to TONE_ANCHOR'); -const sksl = tmpl.replaceAll('${TONE_ANCHOR}', String(A)); +// 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_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\);/); +const resolve = (s) => s.replace('${TONE_MATH_SKSL}', mathTmpl).replaceAll('${TONE_ANCHOR}', String(A)); + +const sksl = resolve(tmpl); +const maths = resolve(mathTmpl); +assert.doesNotMatch(tmpl, /float a4 = /, 'the ramp is back inside the pass — one copy, not two'); +const mask = readFileSync(new URL('../shared/utils/gradientMask.ts', import.meta.url), 'utf8'); +assert.ok(mask.includes('${TONE_MATH_SKSL}'), 'the mask pass does not read the shared maths'); +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\)\);/); +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'); // The four tents, one per quarter of the ramp, each clipped by its neighbour so // no luma is counted by two of them. @@ -87,10 +107,11 @@ assert.doesNotMatch(sksl, /bl \* 0\.18 \* dk|wh \* 0\.18 \* rgb/, 'WHITE/BLACK a // channel clips, and a clipped channel is a moved hue (a skin tone at 24.0° came // back at 48.0° at HIGHLIGHT +100, scratchpad hl-variants.mjs), and under L=0.5 // it multiplies a near-black pixel's cast by up to x30. -assert.match(sksl, /float k = 1\.0;/); -assert.match(sksl, /if \(hiC > t\) k = min\(k, \(1\.0 - o\) \/ \(hiC - t\)\);/); -assert.match(sksl, /if \(loC < t\) k = min\(k, o \/ \(t - loC\)\);/); -assert.match(sksl, /rgb = clamp\(vec3\(o\) \+ \(rgb - vec3\(t\)\) \* k, 0\.0, 1\.0\);/); +assert.match(maths, /float k = 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.doesNotMatch(tone, /0\.55, 1\.35/, 'the arbitrary saturation clamp came back'); assert.doesNotMatch(sksl, /max\(t, 0\.0004\)/, 'the luma ratio came back'); // The transfer pair has to be the accurate one where it is still used (the @@ -253,17 +274,44 @@ for (const bl of [-1, 1]) // The colour rebuild, as the shader emits it: the ramp's luma, the pixel's own // chroma difference, and the one scale the cube allows. const lumaOf = (c) => clamp01(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]); +// lightMove, as TONE_MATH_SKSL emits it — the one move every brightness change in +// the pass goes through (a tone knob, a mask's tone knob, the exposure knob). +// NOT clamped on the way out here: the check below wants to see that the scale +// 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 = 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)); + return rgb.map((c) => o + (c - t) * k); +} function rebuild(rgb, k) { const t = lumaOf(rgb); const o = ramp(t, k).o; - const hiC = Math.max(...rgb); - const loC = Math.min(...rgb); - let s = 1; - if (hiC > t) s = Math.min(s, (1 - o) / (hiC - t)); - if (loC < t) s = Math.min(s, o / (t - loC)); - const out = rgb.map((c) => o + (c - t) * s); + const out = lightMove(rgb, t, o); return { out, clamped: out.map((c) => clamp01(c)), o, t }; } +// 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); +const linToSrgb = (c) => (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055); +// exposureMove, as TONE_MATH_SKSL emits it: the linear sensor moves by the stops, +// and the encoded value that lands there is the luma the pixel is rebuilt onto. +// The light moves by exp2(ev) in LINEAR light; the colour moves by the one shared +// scale of lightMove. A per-channel multiply does neither — it clips the three +// channels by three different amounts and takes the hue with it (29.2° at +1 EV +// on the scratchpad probe, exp-variant.mjs; this variant measures 0.00°). +function exposureMove(rgb, ev) { + const c = rgb.map(clamp01); + const t = lumaOf(c); + // The stop as a RATIO on the pixel's own encoded luma, which is what makes the + // knob the identity at 0 EV: pointing the luma straight at the encoded linear + // target brightens a colour by a couple of code values even on zero. + const lin = Math.max(lumaOf(c.map(srgbToLin)), 1e-6); + const stop = linToSrgb(Math.min(1, lin * 2 ** ev)) / linToSrgb(lin); + return lightMove(c, t, clamp01(t * stop)).map(clamp01); +} function hueOf(c) { const mx = Math.max(...c), mn = Math.min(...c), d = mx - mn; if (d < 1e-9) return NaN; @@ -322,4 +370,94 @@ const carried = rebuild([0.7, 0.55, 0.45], { hl: 0.5 }).clamped; const grew = (carried[0] - carried[1]) / (0.7 - 0.55); close(grew, 1, 'the chroma was re-scaled on a highlight lift'); +// 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, +// and nothing else: this is the number the old linear per-channel multiply put +// there, so no exposure a user has dialled in moves. +for (const g of [0.05, 0.1, 0.5, 0.7, 0.9, 0.97]) + for (const ev of [-2, -1, -0.5, 0.5, 1, 2]) + close( + exposureMove([g, g, g], ev)[0], + linToSrgb(Math.min(1, srgbToLin(g) * 2 ** ev)), + `the exposure is no longer a stop on a grey: ${g} at ${ev} EV`, + ); +// ...and zero stops is the identity on a COLOUR too, exactly — the knob has to be +// able to leave the frame alone. +for (const rgb of [...colourCases, ...greyCases]) + for (let i = 0; i < 3; i++) + close(exposureMove(rgb, 0)[i], rgb[i], 'the exposure move is not the identity at 0 EV'); +// Hue cannot move, at any stop, on any colour: this is the whole fix. The old +// pass multiplied the three channels by the same number in LINEAR light and then +// clipped them by three different amounts, and the hue went with them — 29.2° on +// the skin tone at +1 EV, 33.3° at +2 EV (scratchpad exp-variant.mjs), against +// 0.00° here. +for (const rgb of colourCases) + for (const ev of [-2, -1, -0.5, 0, 0.5, 1, 2]) { + const out = exposureMove(rgb, ev); + const dh = hueOf(out) - hueOf(rgb); + assert.ok(Number.isNaN(dh) || Math.abs(dh) < 1e-9, `the exposure moved the hue ${dh}° on ${rgb} at ${ev} EV`); + assert.ok(out.every((c) => c >= -1e-12 && c <= 1 + 1e-12), `the exposure left the cube on ${rgb} at ${ev} EV`); + } +// A pixel already on the ceiling: the channel that used to clip lands exactly ON +// the ceiling and the other two follow it down at the one shared scale, so the +// pixel gives up saturation rather than having the three clip by three different +// amounts — which is where the old pass lost the hue. +const blown = exposureMove([1, 0.6, 0.2], 5); +assert.ok(blown.every((c) => c >= -1e-12 && c <= 1 + 1e-12), 'the exposure overshot the ceiling'); +close(blown[0], 1, 'the channel that hit the ceiling stopped short of it'); +assert.ok(blown[1] > 0.9 && blown[2] > 0.85, 'the pixel collapsed to white instead of keeping its colour'); +assert.ok(Math.abs(hueOf(blown) - hueOf([1, 0.6, 0.2])) < 1e-9, 'the pixel lost its hue at the ceiling'); +// ...and a pixel the move really does drive to 1.0 (an exposure past the head of +// the ramp) is white, in all three channels at once. +const white = exposureMove([0.98, 0.98, 0.98], 5); +for (let i = 0; i < 3; i++) close(white[i], 1, 'a blown pixel stopped short of white'); +// Darkening is the mirror: the light comes down, and a colour with no room below +// gives up saturation and arrives neutral, not negative. +const crushed = exposureMove([0.02, 0.01, 0.005], -5); +assert.ok(crushed.every((c) => c >= -1e-12 && c <= 1 + 1e-12), 'the exposure went outside the cube on the way down'); +assert.ok(crushed[0] >= crushed[1] && crushed[1] >= crushed[2], 'the exposure inverted the channel order on the way down'); +// Black has no light to move: every stop leaves it where it is, and none of them +// divides by zero on the way. +for (const ev of [-5, -1, 0, 1, 5]) assert.equal(exposureMove([0, 0, 0], ev)[0], 0, `black moved at ${ev} EV`); + +// THE PASS ITSELF, compiled and run. Everything above is a twin, and a twin is +// 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] ?? ''); +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'); +const ck = await CanvasKitInit({ + locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)), +}); +const effect = ck.RuntimeEffect.Make(exposureSrc); +assert.ok(effect, 'EXPOSURE_SKSL does not compile — the whole frame loses its exposure'); +const throughPass = (rgb, ev) => { + const surface = ck.MakeSurface(4, 4); + const paint = new ck.Paint(); + paint.setColor(ck.Color(...rgb)); + surface.getCanvas().drawPaint(paint); + const child = surface.makeImageSnapshot().makeShaderOptions( + ck.TileMode.Clamp, ck.TileMode.Clamp, ck.FilterMode.Linear, ck.MipmapMode.None, + ); + const shaderPaint = new ck.Paint(); + shaderPaint.setShader(effect.makeShaderWithChildren([ev], [child])); + const out = ck.MakeSurface(4, 4); + out.getCanvas().drawRect(ck.XYWHRect(0, 0, 4, 4), shaderPaint); + const px = out.getCanvas().readPixels(0, 1, { + width: 4, height: 1, colorType: ck.ColorType.RGBA_8888, alphaType: ck.AlphaType.Unpremul, colorSpace: ck.ColorSpace.SRGB, + }); + return [px[0], px[1], px[2]]; +}; +for (const [rgb, ev] of [[[128, 128, 128], 1], [[230, 150, 50], 1], [[230, 150, 50], 2], [[20, 10, 5], -2]]) { + const want = exposureMove(rgb.map((v) => v / 255), ev).map((v) => Math.round(v * 255)); + const got = throughPass(rgb, ev); + assert.ok( + got.every((v, i) => Math.abs(v - want[i]) <= 1), + `the pass and its twin disagree on ${rgb} at ${ev} EV: ${got} against ${want}`, + ); +} + console.log('highlight-knee-check ok'); diff --git a/docker/frontend/scripts/mask-wb-check.mjs b/docker/frontend/scripts/mask-wb-check.mjs index 6b33e76..e3e6fed 100644 --- a/docker/frontend/scripts/mask-wb-check.mjs +++ b/docker/frontend/scripts/mask-wb-check.mjs @@ -40,7 +40,7 @@ writeFileSync( .replace(/^import .*from ['"]\.\/colorUtils['"];$/m, 'import { whiteBalanceGain } from "./colorUtils.mjs";') .replace( /^import \{[^\n}]*\} from ['"]\.\/toneShader['"];$/m, - 'import { CLARITY_GAIN, DEHAZE_FLOOR_T, DEHAZE_MAX_OMEGA, DEHAZE_PATCH_STEP, DEHAZE_PATCH_TAPS } from "./toneShader.mjs";', + 'import { CLARITY_GAIN, DEHAZE_FLOOR_T, DEHAZE_MAX_OMEGA, DEHAZE_PATCH_STEP, DEHAZE_PATCH_TAPS, TONE_MATH_SKSL } from "./toneShader.mjs";', ), ); const { readMasks, maskUniforms, gradientMaskSkSL } = await import( @@ -125,14 +125,14 @@ const CanvasKit = await CanvasKitInit({ const SIZE = 64; const wide = { kind: 'radial', x: 0.5, y: 0.5, ex: 0.5, ey: 0.5, rx: 4, ry: 4, angle: 0, feather: 0.5 }; -function render(masks, spatial) { +function render(masks, spatial, fillRGB = [128, 128, 128]) { const effect = CanvasKit.RuntimeEffect.Make(gradientMaskSkSL(masks.length, spatial)); assert.ok(effect, `the mask shader for ${masks.length} masks does not compile (spatial ${spatial})`); // The frame the pass reads: a flat mid-grey, drawn once and handed in as the // first child exactly as exportEngine's replaceThrough hands its snapshot in. const src = CanvasKit.MakeSurface(SIZE, SIZE); const fill = new CanvasKit.Paint(); - fill.setColor(CanvasKit.Color(128, 128, 128)); + fill.setColor(CanvasKit.Color(...fillRGB)); src.getCanvas().drawPaint(fill); const grey = src.makeImageSnapshot(); const asChild = () => @@ -174,6 +174,36 @@ assert.ok(spatialPx[0] > 130 && spatialPx[2] < 126, `the spatial pass dropped th const two = render([knob({}), knob({ temperature: 10000 })], false); assert.ok(two[0] > 130 && two[2] < 126, `the second mask's WB did not land: ${two}`); +// --- EXPOSURE inside a mask, which is now the frame's own move (exposureMove, the +// shared TONE_MATH_SKSL): a stop on LIGHT, decided in the linear domain. The pass +// this replaced multiplied the three channels in linear light, so a channel that +// reached the ceiling clipped by a different amount than its neighbours and the +// hue went with it — 29.2° on a warm skin tone at +1 EV, 33.3° at +2 (scratchpad +// exp-variant.mjs), while a mid-grey is the same number both ways (0.6858) and +// cannot tell the two apart. +const hue = (p) => { + const [r, g, b] = p.map((v) => v / 255); + const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn; + if (d < 1e-9) return NaN; + const h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; + return ((h * 60) % 360 + 360) % 360; +}; +const up = render([knob({ exposure: 1 })], false); +assert.ok(Math.abs(up[0] - 175) <= 2, `+1 EV is not a stop on light: 128 came out ${up[0]}, not 175`); +const down = render([knob({ exposure: -1 })], false); +assert.ok(Math.abs(down[0] - 93) <= 2, `-1 EV is not a stop on light: 128 came out ${down[0]}, not 93`); +const warm8 = [230, 150, 50]; +const lit = render([knob({ exposure: 1 })], false, warm8); +assert.ok(lit.every((v, i) => v >= warm8[i]), `+1 EV darkened a channel: ${lit}`); +assert.ok(Math.abs(hue(lit) - hue(warm8)) < 3, `the exposure moved the hue to ${hue(lit)}° from ${hue(warm8)}°`); +// A pixel already against the ceiling gives up saturation rather than hue, and +// the channel on the ceiling lands ON it: 255 out, not three clipped at three +// different points. +const hot = render([knob({ exposure: 2 })], false, warm8); +assert.ok(Math.abs(hue(hot) - hue(warm8)) < 3, `the exposure moved the hue at the ceiling: ${hue(hot)}°`); +assert.equal(hot[0], 255, 'the channel against the ceiling did not land on it'); +assert.ok(hot[1] > lit[1] && hot[2] > lit[2], `+2 EV did not brighten past +1 EV: ${lit} then ${hot}`); + console.log( 'mask WB: neutral 5500K/0 leaves the pixel, 10000K warms it, +TINT magenta-ises it; the block, the plain and the spatial shaders all read it', ); diff --git a/docker/frontend/shared/utils/gradientMask.ts b/docker/frontend/shared/utils/gradientMask.ts index cca78e7..a9eed5d 100644 --- a/docker/frontend/shared/utils/gradientMask.ts +++ b/docker/frontend/shared/utils/gradientMask.ts @@ -6,6 +6,7 @@ import { DEHAZE_MAX_OMEGA, DEHAZE_PATCH_STEP, DEHAZE_PATCH_TAPS, + TONE_MATH_SKSL, } from './toneShader'; // FX tab > LINEAR / RADIAL GRADIENT — Lightroom's two gradient masks, the local @@ -31,17 +32,18 @@ import { // // The spec's section 4 — "the system needs to restrict all the above effects to // operate only within the mask's area" — is the rest of the block below: the -// smoothstep soft masks that carry HIGHLIGHT and SHADOW, the two ends WHITE and -// BLACK move, and the two spatial ones (CLARITY against the frame's own blur, -// DEHAZE through the dark channel) that the caller hands in the blurred -// reference for and the atmospheric light for. Every one of them rides the same -// alpha the shape produces and lands through the same `mix`, so a mask at half -// strength is half of the move. +// four tonal-range knobs (the frame-wide ramp, moved on the mask's own pixels), +// and the two spatial ones (CLARITY against the frame's own blur, DEHAZE through +// the dark channel) that the caller hands in the blurred reference for and the +// atmospheric light for. Every one of them rides the same alpha the shape +// produces and lands through the same `mix`, so a mask at half strength is half +// of the move. export const MASK_KIND = { linear: 0, radial: 1 } as const; // Exposure is stored as the EV itself — the spec's own -5..+5 — because that is -// what `pow(2.0, e)` reads, and a stop is a stop whatever the app's slider units -// are elsewhere. The other two are -10..+10 like every other knob, and are turned -// into the spec's -1..+1 on the way to the shader. +// what `exp2(e)` spends on the light (toneShader.EXPOSURE_SKSL), and a stop is a +// stop whatever the app's slider units are elsewhere. The other two are -10..+10 +// like every other knob, and are turned into the spec's -1..+1 on the way to the +// shader. export const MASK_EXPOSURE_MAX = 5; // How much of the semi-axis a fresh ellipse fades over. Half: the edge is soft // enough to be a gradient mask rather than a cut-out, and every pixel of the @@ -171,19 +173,18 @@ export function maskUniforms( return u; } -// The colour inside a mask, in the spec's own order and, for the knobs the app -// also has frame-wide, in the app's own formulas: exposure first (a power of -// two, so a stop is a stop), then contrast about the middle, then saturation as -// a mix away from the pixel's own REC-709 luma. Then the spec's section 2 and 3 -// on top — HIGHLIGHT and SHADOW through the two smoothstep soft masks its own -// formula names, WHITE and BLACK as the per-channel point moves TONE_SKSL -// makes, and the two spatial ones against the blurred reference the caller hands -// in. Every one of them means inside the mask what it means on the whole frame -// (toneShader.ts is the reference the four tonal ones are written from), because -// the same name on the same knob should not be two different moves. The result -// is clamped to the range a file can hold — the spec's own guard, and it is -// `mix`ed back over the base by the mask's alpha, so a mask at half strength is -// half of the move rather than the whole of it. +// The colour inside a mask, in the spec's own order and, for every knob the app +// also has frame-wide, in the app's own formulas: EXPOSURE first (one stop of +// LIGHT, through exposureMove), then contrast about the middle, then saturation +// as a mix away from the pixel's own REC-709 luma. Then the four tonal-range +// knobs and the two spatial ones — the four through the frame-wide ramp +// (TONE_MATH_SKSL), CLARITY and DEHAZE against the blurred reference the caller +// hands in. Every one of them means inside the mask what it means on the whole +// frame, because the same name on the same knob must not be two different moves; +// toneShader.ts is the one copy all of them are read from. The result is clamped +// to the range a file can hold — the spec's own guard, and it is `mix`ed back +// over the base by the mask's alpha, so a mask at half strength is half of the +// move rather than the whole of it. // // `blur` is the frame's bilateral reference (the same one CLARITY uses // frame-wide) and `air` the atmospheric light; both are the constants 0 when the @@ -193,7 +194,11 @@ export function maskUniforms( // read in the block below (the frame's own pixels are only in reach there). const adjustFn = (spatial: boolean) => ` half3 maskAdjust(half3 c, half3 wb, float4 a, float4 tone, float4 fx, half dark${spatial ? ', half3 blur, float3 air' : ''}) { - c = c * half(pow(2.0, a.x)); + // EXPOSURE through the frame-wide function, on the mask's pixels: one stop of + // LIGHT (a linear-light move, so 2^ev is what it multiplies) and a move of the + // luma rather than of the three channels, so the knob brightens a colour + // instead of shifting its hue when a channel reaches the ceiling. + c = half3(exposureMove(vec3(c), a.x)); // The mask's own white balance, the frame-wide WB gain on the mask's pixels: a // gain on each channel, hoisted to the JS side because the Kelvin fit is a // curve (colorUtils.whiteBalanceGain). 5500K / 0 hands over (1,1,1), so a mask @@ -201,25 +206,14 @@ half3 maskAdjust(half3 c, half3 wb, float4 a, float4 tone, float4 fx, half dark$ c = clamp(c * wb, half3(0.0), half3(1.0)); c = (c - half(0.5)) * half(1.0 + a.y) + half(0.5); half l = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722)); - // The tonal four, in TONE_SKSL's own formulas: a mask's HIGHLIGHT is meant to - // be the same move as the whole-frame HIGHLIGHT on a smaller area, not a - // second opinion about what the name means. HIGHLIGHT and SHADOW are additive - // shifts of the luma, HIGHLIGHT weighted by the headroom left (1 - l) so it - // cannot drag a blown white to grey, and the colour difference rides along at - // a damped gain (TONE_SKSL's own cg) so a lift or a pull cannot collapse a - // colour. WHITE and BLACK are per-channel point moves, cubic in each channel's - // own distance from the end it owns: the toe and the shoulder move, the - // midtones do not, and a white that is lowered stays white. - // The spec's soft masks, computed in float and narrowed: smoothstep on half is - // one more type the shader does not have to guess at. + // The tonal four, through the frame-wide ramp (TONE_MATH_SKSL): a mask's + // HIGHLIGHT is the same move as the whole-frame HIGHLIGHT on a smaller area, + // not a second opinion about what the name means — the four knots, their + // 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. float lf = clamp(float(l), 0.0, 1.0); - float mh = smoothstep(0.50, 1.00, lf); - float ms = 1.0 - smoothstep(0.00, 0.55, lf); - half lifted = half(clamp(lf + tone.x * mh * (1.0 - lf) + tone.y * 0.34 * ms, 0.0, 1.0)); - half cg = clamp(lifted / max(l, half(0.0004)), half(0.55), half(1.35)); - c = clamp(half3(lifted) + (c - half3(l)) * cg, half3(0.0), half3(1.0)); - half3 dk = half3(1.0) - c; - c = clamp(c + half(tone.w * 0.18) * dk * dk * dk + half(tone.z * 0.18) * c * c * c, half3(0.0), half3(1.0)); + c = half3(toneRamp(vec3(c), 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: @@ -320,6 +314,7 @@ uniform float4 fx[${count}]; uniform float4 wb[${count}]; uniform float4 size; ${spatial ? 'uniform shader blurred;\nuniform float4 air;' : ''} +${TONE_MATH_SKSL} ${adjustFn(spatial)} half4 main(float2 pos) { half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => maskBlock(i, spatial)).join('')} diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index 7b9bfa5..7390e6b 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -108,6 +108,125 @@ const BAND_BLOCK = hslBandGaps() // inside the one before it. export const TONE_ANCHOR = 0.25; +// 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 +// where the shape is, and it did: the frame moved the ramp's knots while a mask +// ran a smoothstep luma lift with an arbitrary 0.55..1.35 chroma clamp, and the +// frame's exposure was a linear-light stop while a mask's was a stop on +// sRGB-encoded values. That divergence is what the scratchpad compat doc §3.3 +// warns the Android port about. `dr` is the whole frame's DYNAMIC RANGE; a mask +// 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 = ` +// Fraction of the way from e0 to e1, clamped — the position on one straight +// segment of the tone ramp. +float lin(float e0, float e1, float x) { + return clamp((x - e0) / (e1 - e0), 0.0, 1.0); +} +// The ramp the pixel is rebuilt through. Knots on 0.00, 0.25, 0.50, 0.75 and +// 1.00; a knob moves the knot it owns by TONE_ANCHOR of the ramp, and each knot +// is held inside the one before it so the five can never cross. 0.50 is fixed: +// it is the one point all four sliders leave alone, which is what keeps a +// mid-grey a mid-grey while the ends move around it. Straight between the knots, +// so every knob on zero is exactly the identity (see the note at the head of +// this file). +// DR moves the same knots instead of adding its own masked terms on top: it +// lifts the toe and rolls the head exactly as before at t = 0 and t = 1 — +// 0.12 and 0.18 at full strength — and half of each at the knots next to them, +// but because it is a knot move the ordering clamp holds it too. Added as a +// separate term it could not: with BLACK and SHADOW both at -1 the ramp is flat +// between 0.25 and 0.5, and DR's own shadow lift slopes DOWN through that +// stretch, which is a fold at 0.238. +// +// Lightness takes the curve; the colour rides the difference. The pixel moves to +// its new luma and carries its own chroma with it — the three channel +// differences are scaled by ONE number, so the hue cannot move and a grey cannot +// pick up a cast (a neutral has no difference to carry, and lands on o exactly). +// +// The doc's ratio (R_new = R_old * Luma_new / Luma_old) is the other reading of +// the same sentence, and it is what this pass used to do. It is exact — until +// the result stops fitting. Past 1.0 a channel clips, the differences stop being +// scaled together, and the hue goes with them: measured on the scratchpad probe +// (hl-variants.mjs), a skin tone at 24.0° came back at 48.0° at HIGHLIGHT +100, +// and a warm white at 37° at 57.4°. Under L = 0.5 the same ratio also multiplies +// whatever cast a near-black pixel had — x30 on a shadow with a hair of warmth, +// which is colour noise amplified, the reason the old arbitrary 0.55..1.35 clamp +// was there. +// +// So the scale is the chroma's own (1.0) and the only thing that pulls it back +// is the cube: a pixel with no room left gives up saturation instead of hue, and +// one that the curve has actually driven to 1.0 arrives at white. +// +// ONE move of the light, and everything in this file that changes how bright a +// pixel is goes through it: a tone knob, a mask's tone knob, and the exposure +// knob on both. The luma lands on o and the channel differences ride along at +// one shared scale k, so a knob named "change the brightness" changes the +// brightness and nothing else — what a per-channel multiply cannot promise once +// 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 = 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)); + 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) { + float a4 = 1.0 + ${TONE_ANCHOR} * wh - dr * 0.18; + float a3 = clamp(0.75 + ${TONE_ANCHOR} * hl - dr * 0.09, 0.5, a4); + float a1 = clamp(0.25 + ${TONE_ANCHOR} * 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 accurate sRGB transfer pair (0.04045/12.92 + 2.4, and its inverse): the +// same constants colorUtils.planckianLinear uses on the WB side, and the reason +// a stop is a stop here. EXPOSURE needs it — 2^ev is a multiplier on LIGHT — and +// so does anything else that has to reach the linear domain. +vec3 toLinear(vec3 c) { + return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c)); +} +vec3 toEncoded(vec3 c) { + return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c)); +} +// One EXPOSURE knob, wherever it is: the frame's own pass and a mask's knob. It +// linearises, moves the LIGHT by 2^ev — a stop is a multiplier on light, and on +// an sRGB-encoded value +1 EV would take a mid-grey 0.5 straight to a blown 1.0 +// where a real stop gives 0.73 (measured: 0.6858 through here, and the plain +// per-channel multiply puts the same 0.6858 on a grey, so a neutral is the knob +// it always was) — and re-encodes. +// +// The linear domain decides WHERE the luma is going; the move is then made by +// lightMove on the encoded values, where the tone ramp also works. That split is +// measured, not chosen for symmetry: carrying the chroma in the LINEAR domain +// drifts the hue of the encoded pixel by up to 12° (a saturated red at -1 EV +// came back at 12.1°, a skin tone at +1 EV at 11.5° — the encoding is +// per-channel, so equal ratios in linear are not equal ratios on screen), +// against 0.00° this way. The knob whose whole promise is brightness must not be +// the one that also moves a hue: a channel that would have clipped gives up +// saturation instead, and a pixel the move has driven all the way to 1.0 is white +// in all three channels at once. +vec3 exposureMove(vec3 rgb, float ev) { + vec3 c = clamp(rgb, 0.0, 1.0); + float t = clamp(dot(c, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0); + // The linear domain says where the luma is going; the value that lands there + // is applied as a RATIO on the pixel's own encoded luma, not pointed at + // directly. toEncoded(luma_lin * 2^ev) is the target, and on a grey it IS the + // pixel's new luma (a stop on a neutral is the stop it always was) — but the + // transfer does not commute with the luma weights, so on a colour the two + // differ by a couple of code values, and the knob on 0 EV would brighten the + // frame instead of leaving it alone. As a ratio it is exactly 1 at 0 EV. + float lin = max(dot(toLinear(c), vec3(0.2126, 0.7152, 0.0722)), 1e-6); + float stop = toEncoded(vec3(min(1.0, lin * exp2(ev)))).r / toEncoded(vec3(lin)).r; + return lightMove(c, t, clamp(t * stop, 0.0, 1.0)); +} +`; + export const TONE_SKSL = ` uniform shader src; uniform float dr; @@ -175,11 +294,7 @@ float bandW(float hue, float anchor, float gapL, float gapR) { float d = mod(hue - anchor + 180.0, 360.0) - 180.0; return d <= 0.0 ? max(0.0, 1.0 + d / gapL) : max(0.0, 1.0 - d / gapR); } -// Fraction of the way from e0 to e1, clamped — the position on one straight -// segment of the tone ramp. -float lin(float e0, float e1, float x) { - return clamp((x - e0) / (e1 - e0), 0.0, 1.0); -} +${TONE_MATH_SKSL} vec4 main(vec2 xy) { vec4 c = src.eval(xy); vec3 rgb = clamp(c.rgb, 0.0, 1.0); @@ -197,54 +312,11 @@ vec4 main(vec2 xy) { float shMask = clamp(1.0 - smoothstep(0.25, 0.50, t) - blMask, 0.0, 1.0); float whMask = smoothstep(0.75, 1.00, t); float hlMask = clamp(smoothstep(0.50, 0.75, t) - whMask, 0.0, 1.0); - // The ramp the pixel is rebuilt through. Knots on 0.00, 0.25, 0.50, 0.75 and - // 1.00; a knob moves the knot it owns by TONE_ANCHOR of the ramp, and each - // knot is held inside the one before it so the five can never cross. 0.50 is - // fixed: it is the one point all four sliders leave alone, which is what - // keeps a mid-grey a mid-grey while the ends move around it. Straight between - // the knots, so every knob on zero is exactly the identity (see the note at - // the head of this file). - // DR moves the same knots instead of adding its own masked terms on top: it - // lifts the toe and rolls the head exactly as before at t = 0 and t = 1 — - // 0.12 and 0.18 at full strength — and half of each at the knots next to - // them, but because it is a knot move the ordering clamp holds it too. Added - // as a separate term it could not: with BLACK and SHADOW both at -1 the ramp - // is flat between 0.25 and 0.5, and DR's own shadow lift slopes DOWN through - // that stretch, which is a fold at 0.238. - float a4 = 1.0 + ${TONE_ANCHOR} * wh - dr * 0.18; - float a3 = clamp(0.75 + ${TONE_ANCHOR} * hl - dr * 0.09, 0.5, a4); - float a1 = clamp(0.25 + ${TONE_ANCHOR} * 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)); - o = clamp(o, 0.0, 1.0); - // Lightness takes the curve; the colour rides the difference. The pixel moves - // to its new luma and carries its own chroma with it — the three channel - // differences are scaled by ONE number, so the hue cannot move and a grey - // cannot pick up a cast (a neutral has no difference to carry, and lands on - // o exactly). - // - // The doc's ratio (R_new = R_old * Luma_new / Luma_old) is the other reading - // of the same sentence, and it is what this pass used to do. It is exact — - // until the result stops fitting. Past 1.0 a channel clips, the differences - // stop being scaled together, and the hue goes with them: measured on the - // scratchpad probe (hl-variants.mjs), a skin tone at 24.0° came back at 48.0° - // at HIGHLIGHT +100, and a warm white at 37° at 57.4°. Under L = 0.5 the same - // ratio also multiplies whatever cast a near-black pixel had — x30 on a - // shadow with a hair of warmth, which is colour noise amplified, the reason - // the old arbitrary 0.55..1.35 clamp was there. - // - // So the scale is the chroma's own (1.0) and the only thing that pulls it back - // is the cube: a pixel with no room left gives up saturation instead of hue, - // and one that the curve has actually driven to 1.0 arrives at white. - float k = 1.0; - float hiC = max(max(rgb.r, rgb.g), rgb.b); - float loC = min(min(rgb.r, rgb.g), rgb.b); - if (hiC > t) k = min(k, (1.0 - o) / (hiC - t)); - if (loC < t) k = min(k, o / (t - loC)); - rgb = clamp(vec3(o) + (rgb - vec3(t)) * k, 0.0, 1.0); + // The four tonal-range knobs, on the shared ramp: see TONE_MATH_SKSL — the + // 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); // 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. @@ -316,30 +388,22 @@ ${BAND_BLOCK} hsl.x = fract(hsl.x + acc.x * (30.0 / 360.0)); // // A stop is a multiplier on LIGHT, and the old EV row multiplied sRGB-ENCODED // values: +1 EV took a mid-grey 0.5 straight to a blown 1.0 where a real stop -// gives 0.73. Here the pixel is linearised, scaled by 2^ev, and re-encoded — -// which is what a camera does when the shutter stays open twice as long: every -// value keeps its ratio, the highlights roll instead of flattening, and the -// HIGHLIGHT knob still has something to pull back afterwards. +// gives 0.73. exposureMove linearises, moves the light by 2^ev, and re-encodes — +// and it spends that stop on the LUMA, not on the three channels one at a time, +// so this knob only ever changes how bright a pixel is: a channel that would +// have clipped gives up saturation instead of dragging the hue (a gradient +// mask's own EXPOSURE runs through the same function, on the mask's pixels). // // It sits between the graded image and the tone shader (see exportEngine step 3), // so `ev` carries the EXPOSURE knob, the stock's own bias and the EV knob added // up in stops — the caller hands in one number. -// -// The transfer pair is the accurate one (0.04045/12.92 + 2.4, and its inverse): -// the same constants colorUtils.planckianLinear uses on the WB side. export const EXPOSURE_SKSL = ` uniform shader src; uniform float ev; -vec3 toLinear(vec3 c) { - return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c)); -} -vec3 toEncoded(vec3 c) { - return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c)); -} +${TONE_MATH_SKSL} vec4 main(vec2 xy) { vec4 c = src.eval(xy); - vec3 rgb = clamp(c.rgb, 0.0, 1.0); - return vec4(clamp(toEncoded(toLinear(rgb) * exp2(ev)), 0.0, 1.0), c.a); + return vec4(exposureMove(clamp(c.rgb, 0.0, 1.0), ev), c.a); } `;