// 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_ANCHOR, TONE_BUMP_SLOPE_HALF, TONE_BUMP_SLOPE_QUARTER, 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: the identity plus one bump // per knob, (1-u^2)^2 with u the distance to the knob's knot in half-widths. // SHADOW carries half the anchor on 0.25 and its bump is the quarter-width one, // so SHADOW +100 reads b + a1 * (1-u^2)^2 with a1 = 0.125 and u = (b-0.25)/0.25. // (The ramp used to be straight between knots; see the note at the head of // toneRamp for the Mach bands that retired it.) const bump = (b, knot, halfWidth) => { const u = (b - knot) / halfWidth; const v = Math.max(0, 1 - u * u); return v * v; }; const ramp = (b) => b + TONE_ANCHOR * 0.5 * bump(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}`, ); // The guard, in the same arithmetic. BLACK and SHADOW at +100 share the lower // half of the ramp and their bumps are read at their own steepest points, so the // pair is the loosest two moves of a half can be: past a total slope of 1 the sum // carries the curve BACKWARDS, which is a worse band than the seams it replaced. // The guard holds the two under one and gives them up together past it. const blackA = TONE_ANCHOR; // BLACK +100 const shadowA = TONE_ANCHOR * 0.5; // SHADOW +100 const lift = (b, k) => b + k * (blackA * bump(b, 0, 0.5) + shadowA * bump(b, 0.25, 0.25)); const hold = TONE_BUMP_SLOPE_HALF * blackA + TONE_BUMP_SLOPE_QUARTER * shadowA; assert.ok(hold > 1, `BLACK and SHADOW +100 no longer reach the guard (hold ${hold})`); const rises = (f) => Array.from({ length: 400 }, (_, i) => f((i + 1) / 400) > f(i / 400)).every(Boolean); // Both controls: the pair really does fold without the guard, and the guard is // what stops it — a guard on a sum that never folded would be dead weight. assert.ok(!rises((b) => lift(b, 1)), `the unguarded pair no longer folds — the guard holds nothing`); assert.ok(rises((b) => lift(b, 1 / hold)), 'BLACK and SHADOW both at +100 fold the ramp back on itself'); 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`, );