// 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', );