diff --git a/docker/frontend/scripts/mask-wb-check.mjs b/docker/frontend/scripts/mask-wb-check.mjs new file mode 100644 index 0000000..6b33e76 --- /dev/null +++ b/docker/frontend/scripts/mask-wb-check.mjs @@ -0,0 +1,179 @@ +// A gradient mask carries the LIGHT column's WB pair now — COLOR TEMP and TINT, +// on the mask's own pixels. Two things can go wrong and neither shows up as a +// crash: the mask's gain drifts from the frame-wide one (two opinions about what +// 3000K means), or the uniform block and the shader's declarations fall out of +// step (one buffer, expanded arrays — an off-by-one there reads the frame size as +// a gain). So this checks the arithmetic and then renders the pass for real: +// CanvasKit is already a dependency, so the shader is compiled and a mid-grey is +// pushed through it rather than the source being eyeballed. +// +// node scripts/mask-wb-check.mjs +import assert from 'node:assert/strict'; +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'; +import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js'; + +const transpile = (path) => + ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, + }).outputText; + +// gradientMask.ts imports its two helpers, so the graph is transpiled into one +// temp directory and the specifiers are pointed at the .mjs copies (the +// convention auto-tone-check.mjs uses). colorUtils and toneShader import types +// only, so the compiler drops those lines itself. +const dir = mkdtempSync(join(tmpdir(), 'mask-wb-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";', + ), +); +writeFileSync( + join(dir, 'gradientMask.mjs'), + transpile('../shared/utils/gradientMask.ts') + .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";', + ), +); +const { readMasks, maskUniforms, gradientMaskSkSL } = await import( + pathToFileURL(join(dir, 'gradientMask.mjs')).href +); +const { whiteBalanceGain } = await import(pathToFileURL(join(dir, 'colorUtils.mjs')).href); + +// --- The gain itself: the one function the frame-wide matrix folds in too. +const close = (a, b, eps = 1e-12) => assert.ok(Math.abs(a - b) < eps, `${a} != ${b}`); +const luma = (g) => 0.2126 * g.r + 0.7152 * g.g + 0.0722 * g.b; +const neutral = whiteBalanceGain(5500, 0); +close(neutral.r, 1); +close(neutral.g, 1); +close(neutral.b, 1); +// The ruler runs the way the engine does: K names the light the frame is said to +// have been shot under, and the gain compensates for it, so the picture WARMS as +// K rises (kelvinToRGB's own doc, and the swatch under the ruler). 10000K up and +// 2500K down, on the same two ends the frame-wide row has. +const warm = whiteBalanceGain(10000, 0); +assert.ok(warm.r > 1 && warm.b < 1, '10000K is not warm'); +const cool = whiteBalanceGain(2500, 0); +assert.ok(cool.b > 1 && cool.r < 1, '2500K is not cool'); +// The cast moves, the exposure does not: every gain is divided by its own luma. +for (const g of [warm, cool, whiteBalanceGain(4000, 7), whiteBalanceGain(9000, -7)]) close(luma(g), 1); +// TINT is the green<->magenta axis, and symmetric about 0. +const magenta = whiteBalanceGain(5500, 10); +const green = whiteBalanceGain(5500, -10); +assert.ok(magenta.r > 1 && magenta.b > 1 && magenta.g < 1, '+TINT is not magenta'); +assert.ok(green.g > 1 && green.r < 1 && green.b < 1, '-TINT is not green'); +// Symmetric in the two channels it moves, and only in those: R and B take the +// same factor, G its own, and the luma normalisation is what keeps the pair from +// being a brightness knob (the weights are unequal, so the two ends are not exact +// mirrors of each other in the third decimal). +close(magenta.r, magenta.b); +close(green.r, green.b); +assert.ok(Math.abs(Math.log(magenta.r) + Math.log(green.r)) < 0.01, 'TINT is not symmetric about 0'); + +// --- readMasks: a mask that never touched the pair reads the neutral, and a +// hand-written file cannot push the gain outside the ruler. +const shape = { kind: 'linear', x: 0, y: 0, ex: 1, ey: 1, rx: 0, ry: 0, angle: 0, feather: 0.5 }; +const mask = (extra) => ({ ...shape, exposure: 0, contrast: 0, saturation: 0, ...extra }); +const bare = readMasks([mask({})])[0]; +assert.equal(bare.temperature, 5500, 'an absent WB is not neutral'); +assert.equal(bare.tint, 0, 'an absent TINT is not neutral'); +const high = readMasks([mask({ temperature: 2000, tint: 99 })])[0]; +assert.equal(high.temperature, 2500); +assert.equal(high.tint, 10); +const low = readMasks([mask({ temperature: 20000, tint: -99 })])[0]; +assert.equal(low.temperature, 10000); +assert.equal(low.tint, -10); + +// --- The uniform block: arrays expanded, declaration order, so the gain has to +// land on the slot the shader reads as `wb[i]` and the frame size has to stay one +// array further on. +const n = 1; +const one = maskUniforms([mask({ temperature: 10000 })], 64, 64, [0.5, 0.5, 0.5]); +assert.equal(one.length, (6 * n + 1 + 1) * 4, 'the buffer is not six arrays, the frame and the air'); +const g = whiteBalanceGain(10000, 0); +close(one[5 * n * 4 + 0], g.r, 1e-6); +// The gain is the SAME function the frame-wide matrix folds in, so 10000K cannot +// come to mean one thing on the whole frame and another inside a mask. +assert.match( + readFileSync(new URL('../shared/utils/colorUtils.ts', import.meta.url), 'utf8'), + /const wbGain = whiteBalanceGain\(temperature, tint\);/, + 'the matrix grew a white balance of its own again', +); +close(one[5 * n * 4 + 1], g.g, 1e-6); +close(one[5 * n * 4 + 2], g.b, 1e-6); +assert.equal(one[6 * n * 4 + 0], 64, 'the width no longer sits where the shader reads `size`'); +assert.equal(one[6 * n * 4 + 1], 64); +assert.equal(one[(6 * n + 1) * 4 + 0], 0.5, 'the atmospheric light moved'); +const plain = maskUniforms([mask({})], 64, 64); +for (let c = 0; c < 3; c++) close(plain[5 * n * 4 + c], 1, 1e-6); + +// --- The pass itself. A radial mask whose semi-axis is four frames wide sits far +// enough inside its own feather that every pixel has alpha 1, so what comes out +// is the gain the mask was handed and nothing else: a wrong slot shows up as a +// wrong pixel rather than as a wrong number in a buffer. +const CanvasKit = await CanvasKitInit({ + locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)), +}); +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) { + 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)); + src.getCanvas().drawPaint(fill); + const grey = src.makeImageSnapshot(); + const asChild = () => + grey.makeShaderOptions( + CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None, + ); + const air = spatial ? [0.5, 0.5, 0.5] : null; + const children = [asChild()]; + if (spatial) children.push(asChild()); + const paint = new CanvasKit.Paint(); + paint.setShader(effect.makeShaderWithChildren(maskUniforms(masks, SIZE, SIZE, air), children)); + const out = CanvasKit.MakeSurface(SIZE, SIZE); + out.getCanvas().drawRect(CanvasKit.XYWHRect(0, 0, SIZE, SIZE), paint); + const px = out.getCanvas().readPixels(0, 0, { + width: SIZE, + height: SIZE, + colorType: CanvasKit.ColorType.RGBA_8888, + alphaType: CanvasKit.AlphaType.Unpremul, + colorSpace: CanvasKit.ColorSpace.SRGB, + }); + const at = (x, y) => { + const i = (y * SIZE + x) * 4; + return [px[i], px[i + 1], px[i + 2]]; + }; + return at(32, 32); +} +const knob = (extra) => ({ ...wide, exposure: 0, contrast: 0, saturation: 0, ...extra }); +const same = (p, want = 128) => p.every((v) => Math.abs(v - want) <= 1); +assert.ok(same(render([knob({})], false)), `a neutral mask moved the grey: ${render([knob({})], false)}`); +const after = render([knob({ temperature: 10000 })], false); +assert.ok(after[0] > 130 && after[2] < 126, `10000K did not warm the mask's pixels: ${after}`); +const tinted = render([knob({ tint: 10 })], false); +assert.ok(tinted[0] > 130 && tinted[2] > 130 && tinted[1] < 126, `+TINT is not magenta: ${tinted}`); +// The spatial build is a second shader text with a second signature: it has to +// compile with the same WB and read `wb` all the same. +const spatialPx = render([knob({ temperature: 10000 })], true); +assert.ok(spatialPx[0] > 130 && spatialPx[2] < 126, `the spatial pass dropped the WB: ${spatialPx}`); +// Two masks: the second array's slot, not the first one's, and both applied. +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}`); + +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/types/index.ts b/docker/frontend/shared/types/index.ts index 28394f4..f81e926 100644 --- a/docker/frontend/shared/types/index.ts +++ b/docker/frontend/shared/types/index.ts @@ -166,6 +166,13 @@ export interface GradientMask { blacks?: number; clarity?: number; dehaze?: number; + // The LIGHT column's WB pair on the mask's own pixels: the same two rulers, + // the same units (kelvin, and the store's ±10 tint), the same gain — a local + // white balance, which is what a mask that sits under a mixed light is for. + // Absent on a mask stored before they existed, and then the gain is exactly 1 + // (5500K / 0), so nothing moves and the recipe reads the same. + temperature?: number; + tint?: number; } export interface ColorAdjustments { diff --git a/docker/frontend/shared/utils/colorUtils.ts b/docker/frontend/shared/utils/colorUtils.ts index a2d4aa0..b5e64e9 100644 --- a/docker/frontend/shared/utils/colorUtils.ts +++ b/docker/frontend/shared/utils/colorUtils.ts @@ -117,6 +117,26 @@ export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number } ]); } +// The whole white balance in one place — Kelvin AND tint — because it is now +// asked for twice: once frame-wide by the colour matrix below, once per gradient +// mask by gradientMask.ts, where a mask's WB is meant to be the same move on a +// smaller area rather than a second opinion about what the two knobs mean. +// +// Tint is the green↔magenta axis, and it is symmetric: + pushes magenta (red and +// blue up, green down), - pushes green (green up, red and blue down). The old +// version only ever ADDED a channel (magenta lifted R+B, green lifted G), so the +// knob brightened the frame instead of tinting it. Normalising the product keeps +// that at a tint shift, not an exposure one. `tint` is the store's own -10..+10. +export function whiteBalanceGain(temperature: number, tint: number): { r: number; g: number; b: number } { + const rgbTemp = kelvinToRGB(temperature); + const tintMagenta = (tint / 10) * 0.08; + return normalizeGainLuma([ + rgbTemp.r * (1 + tintMagenta), + rgbTemp.g * (1 - tintMagenta), + rgbTemp.b * (1 + tintMagenta), + ]); +} + // How hard that excursion is allowed to bite. The physical ratio is brutal (the // blue channel carries 12x at 2500K) and it lands on sRGB-encoded, already // tone-mapped pixels, where the raw value clips the ends flat. 0.5 is the @@ -413,18 +433,7 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments // 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage if (!isMonochromeBase(baseFilter)) { - const rgbTemp = kelvinToRGB(temperature); - // Tint is the green↔magenta axis, and it is symmetric: + pushes magenta - // (red and blue up, green down), - pushes green (green up, red and blue - // down). The old version only ever ADDED a channel (magenta lifted R+B, - // green lifted G), so the knob brightened the frame instead of tinting it. - // Normalising the product keeps that at a tint shift, not an exposure one. - const tintMagenta = (tint / 10) * 0.08; - const wbGain = normalizeGainLuma([ - rgbTemp.r * (1 + tintMagenta), - rgbTemp.g * (1 - tintMagenta), - rgbTemp.b * (1 + tintMagenta), - ]); + const wbGain = whiteBalanceGain(temperature, tint); const wbMat = [ wbGain.r, 0, 0, 0, 0, diff --git a/docker/frontend/shared/utils/gradientMask.ts b/docker/frontend/shared/utils/gradientMask.ts index 7205c01..cca78e7 100644 --- a/docker/frontend/shared/utils/gradientMask.ts +++ b/docker/frontend/shared/utils/gradientMask.ts @@ -1,4 +1,5 @@ import type { GradientMask } from '../types'; +import { whiteBalanceGain } from './colorUtils'; import { CLARITY_GAIN, DEHAZE_FLOOR_T, @@ -57,6 +58,10 @@ const num = (v: unknown, fallback: number) => { }; const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); const clampA = (v: number) => (v < -10 ? -10 : v > 10 ? 10 : v); +// The frame-wide WB ruler's own two ends, and its own neutral: a mask that never +// touched WB carries 5500K / 0, whose gain is exactly 1. +const NEUTRAL_K = 5500; +const clampK = (v: number) => (v < 2500 ? 2500 : v > 10000 ? 10000 : v); const clampEV = (v: number) => (v < -MASK_EXPOSURE_MAX ? -MASK_EXPOSURE_MAX : v > MASK_EXPOSURE_MAX ? MASK_EXPOSURE_MAX : v); // The stored masks, made readable: numbers, inside the frame, one kind — the @@ -75,6 +80,8 @@ export type ReadMask = GradientMask & { blacks: number; clarity: number; dehaze: number; + temperature: number; + tint: number; }; export function readMasks(masks: GradientMask[] | undefined): ReadMask[] { @@ -103,6 +110,11 @@ export function readMasks(masks: GradientMask[] | undefined): ReadMask[] { blacks: clampA(num(m.blacks, 0)), clarity: clampA(num(m.clarity, 0)), dehaze: clampA(num(m.dehaze, 0)), + // The WB pair, the two rulers the LIGHT column already carries, read on the + // mask's own pixels: kelvin on its own scale, tint in the store's ±10. Both + // absent on a mask stored before they existed, and then the gain is 1. + temperature: clampK(num(m.temperature, NEUTRAL_K)), + tint: clampA(num(m.tint, 0)), })) .filter((m) => (m.kind === 'linear' ? Math.hypot(m.ex - m.x, m.ey - m.y) > MASK_MIN : m.rx > 0 && m.ry > 0)); } @@ -119,11 +131,12 @@ export function masksHaveSpatial(masks: GradientMask[]): boolean { // The uniform block the shader for `n` masks reads: the shapes, the ellipse // parameters, the knobs with the kind, the tone soft masks, the spatial pair, -// then the frame the fractions are of — and, when the caller has one, the -// atmospheric light the dehaze reads. Declaration order, arrays expanded — one -// buffer is one upload per render, the same shape healUniforms and -// mosaicUniforms use. Its length is a function of the list, not a fixed -// capacity, because the shader carries exactly the masks the recipe holds. +// the white balance gain, then the frame the fractions are of — and, when the +// caller has one, the atmospheric light the dehaze reads. Declaration order, +// arrays expanded — one buffer is one upload per render, the same shape +// healUniforms and mosaicUniforms use. Its length is a function of the list, not +// a fixed capacity, because the shader carries exactly the masks the recipe +// holds. export function maskUniforms( masks: GradientMask[], width: number, @@ -132,7 +145,7 @@ export function maskUniforms( ): Float32Array { const list = readMasks(masks); const n = list.length; - const u = new Float32Array((5 * n + 1 + (air ? 1 : 0)) * 4); + const u = new Float32Array((6 * n + 1 + (air ? 1 : 0)) * 4); for (let i = 0; i < n; i++) { const m = list[i]; u.set([m.x, m.y, m.ex, m.ey], i * 4); @@ -146,9 +159,15 @@ export function maskUniforms( // The tone soft masks and the two ends, then the spatial pair. u.set([m.highlights / 10, m.shadows / 10, m.whites / 10, m.blacks / 10], (3 * n + i) * 4); u.set([m.clarity / 10, m.dehaze / 10, 0, 0], (4 * n + i) * 4); + // The mask's own white balance, resolved here and not in the shader: the + // Kelvin fit is a curve, and a curve in SkSL would be a second copy of it + // (colorUtils.whiteBalanceGain is the one). Three gains and a zero pad, so + // one array of the same shape as the rest. + const wb = whiteBalanceGain(m.temperature, m.tint); + u.set([wb.r, wb.g, wb.b, 0], (5 * n + i) * 4); } - u.set([width, height, 0, 0], 5 * n * 4); - if (air) u.set([air[0], air[1], air[2], 0], (5 * n + 1) * 4); + u.set([width, height, 0, 0], 6 * n * 4); + if (air) u.set([air[0], air[1], air[2], 0], (6 * n + 1) * 4); return u; } @@ -173,8 +192,13 @@ export function maskUniforms( // for them (masksHaveSpatial). `dark` is the patch's dark channel for DEHAZE, // read in the block below (the frame's own pixels are only in reach there). const adjustFn = (spatial: boolean) => ` -half3 maskAdjust(half3 c, float4 a, float4 tone, float4 fx, half dark${spatial ? ', half3 blur, float3 air' : ''}) { +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)); + // 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 + // that never touched the pair costs three multiplies by one and nothing else. + 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 @@ -270,7 +294,7 @@ ${spatial ? ` // The patch's dark channel for DEHAZE, one tap per DEHAZE_PA } } }` : ' half dark = half(0.0);'} - c.rgb = mix(c.rgb, maskAdjust(c.rgb, adj[${i}], tone[${i}], fx[${i}], dark${spatial ? ', blurred.eval(pos).rgb, air.xyz' : ''}), half(a)); + c.rgb = mix(c.rgb, maskAdjust(c.rgb, wb[${i}].rgb, adj[${i}], tone[${i}], fx[${i}], dark${spatial ? ', blurred.eval(pos).rgb, air.xyz' : ''}), half(a)); } } `; @@ -293,6 +317,7 @@ uniform float4 rads[${count}]; uniform float4 adj[${count}]; uniform float4 tone[${count}]; uniform float4 fx[${count}]; +uniform float4 wb[${count}]; uniform float4 size; ${spatial ? 'uniform shader blurred;\nuniform float4 air;' : ''} ${adjustFn(spatial)} diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 2cbd0ab..4db7128 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -2468,9 +2468,11 @@ export function Workspace() { // against the frame's blurred reference, DEHAZE through the dark channel. Each // one means inside the mask what the row of the same name means on the whole // frame, down to the -100..+100 the frame-wide rows read (the mask keeps the - // look's own ±10 unit, and the ×10 is the slider's — see paramDefs). + // look's own ±10 unit, and the ×10 is the slider's — see paramDefs). TINT is on + // this helper too: same ±10 unit, same ×10 ruler, same green↔magenta gain, read + // on the mask's pixels — a local white balance. const maskKnobRow = ( - key: 'highlights' | 'shadows' | 'whites' | 'blacks' | 'clarity' | 'dehaze', + key: 'highlights' | 'shadows' | 'whites' | 'blacks' | 'clarity' | 'dehaze' | 'tint', label: string ): SliderDef => { const v = selMask?.[key] ?? 0; @@ -2492,6 +2494,24 @@ export function Workspace() { const maskRulers: SliderDef[] = !selMask ? [] : [ + // The WB pair first, in the LIGHT column's own place and on its own + // scales: kelvin 2500..10000 as itself (a temperature is not a percentage, + // so it keeps its own ruler), and TINT through the same ×10 the frame-wide + // row wears. Same two knobs, same gain, the mask's pixels only. + { + key: 'mask-temperature', + label: 'COLOR TEMP', + value: selMask.temperature ?? 5500, + min: 2500, + max: 10000, + step: 100, + display: `${selMask.temperature ?? 5500}K`, + swatch: temperatureSwatch(selMask.temperature ?? 5500), + edited: (selMask.temperature ?? 5500) !== 5500, + onChange: (v) => setMaskKnob({ temperature: v }), + onReset: () => setMaskKnob({ temperature: 5500 }), + }, + maskKnobRow('tint', 'TINT'), { key: 'mask-exposure', label: 'EXPOSURE',