// The tone ramp is drawn through a BASE layer, and that base is now a blurred // CHILD of the pass rather than a ring of point samples inside it — see // TONE_BASE_RADIUS in toneShader.ts for the mottle that ring caused. Two things // can go wrong with that and neither is a crash: the shader stops compiling once // it takes a second child (SkSL is only checked at runtime, and no other check // here compiles TONE_SKSL as a whole), or the second child is wired to the wrong // slot and the pass quietly reads the sharp image as its own base — which is // exactly the identity the default (bx = 0) used to give, so a regression would // look like nothing happening. So the pass is compiled and rendered for real: // the frame and the base are held at two different flat values, and the pixel // has to land on the somewhere-between value the ramp over THAT base predicts. // // node scripts/tone-base-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; const dir = mkdtempSync(join(tmpdir(), 'tone-base-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";', ), ); const { TONE_SKSL, TONE_BASE_RADIUS, TONE_BASE_SIGMA, getToneUniforms, toneUniformArray, toneIsActive } = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href); // The shader has to NAME a base child — a ring of taps would not need one. assert.match(TONE_SKSL, /uniform shader base;/); const CanvasKit = await CanvasKitInit({ locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)), }); const SIZE = 8; function flat(value) { const surf = CanvasKit.MakeSurface(SIZE, SIZE); const paint = new CanvasKit.Paint(); paint.setColor(CanvasKit.Color(value, value, value)); surf.getCanvas().drawPaint(paint); return surf.makeImageSnapshot(); } const asChild = (image) => image.makeShaderOptions( CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None, ); // SHADOW full, everything else off — the knob the mottle was reported on. const adjustments = { shadow: 10 }; const uniforms = getToneUniforms(adjustments); assert.ok(toneIsActive(uniforms), 'SHADOW +100 no longer turns the pass on'); function render(srcValue, baseValue) { const effect = CanvasKit.RuntimeEffect.Make(TONE_SKSL); assert.ok(effect, 'TONE_SKSL does not compile'); const src = flat(srcValue); const base = flat(baseValue); const shader = effect.makeShaderWithChildren(uniforms ? toneUniformArray(uniforms) : [], [ asChild(src), asChild(base), ]); assert.ok(shader, 'the pass did not take two children — the base is not wired in'); const out = CanvasKit.MakeSurface(SIZE, SIZE); const paint = new CanvasKit.Paint(); paint.setShader(shader); out.getCanvas().drawPaint(paint); const pixels = out.makeImageSnapshot().readPixels(0, 0, { width: SIZE, height: SIZE, colorType: CanvasKit.ColorType.RGBA_8888, alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB, }); return pixels[0]; } // The ramp, in the same arithmetic the shader runs: SHADOW +100 puts a1 (the // 0.25 knot) on 0.375, and 0.50 is fixed, so a base of b below a half reads // a1 + (0.5 - a1) * (b - 0.25) / 0.25. const ramp = (b) => 0.375 + (0.5 - 0.375) * ((b - 0.25) / 0.25); const srcValue = 128; // the pixel: 0.501961 encoded const baseValue = 76; // its neighbourhood, darker: 0.298039 const t = srcValue / 255; const b = baseValue / 255; const expected = Math.round(255 * ((ramp(b) * t) / b)); const got = render(srcValue, baseValue); assert.ok( Math.abs(got - expected) <= 2, `a base of ${b.toFixed(4)} under a pixel of ${t.toFixed(4)} gave ${got}, the ramp over that base predicts ${expected}`, ); // And the two are NOT the same value: if the pass had quietly read the sharp // image as its base the answer would be the pixel itself, unchanged. assert.ok( Math.abs(got - srcValue) > 8, `the pass returned the pixel (${got}) — it is reading its own sharp image as the base again`, ); // With the base handed in as the sharp image the pass is the global move, which // is what a mask (no neighbourhood of its own) needs it to be. const selfBase = render(srcValue, srcValue); assert.ok( Math.abs(selfBase - srcValue) <= 1, `a base equal to the pixel must be the identity, got ${selfBase} for ${srcValue}`, ); console.log( `tone base ok: pixel ${srcValue} over a base of ${baseValue} -> ${got} ` + `(ramp predicts ${expected}, sharp-base identity ${selfBase}); ` + `radius ${TONE_BASE_RADIUS} of the frame, sigma ${TONE_BASE_SIGMA} of it`, );