// 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, TONE_KNOBS, } = 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: ONE KNOB is the measured // knot table (TONE_KNOBS in toneShader.ts) drawn STRAIGHT between the knots, // scaled by the amount, added to the luma it reads and clamped — the minus side // of zero reads the minus table — and the four are applied ONE AFTER THE OTHER // and clamped on the way to the next, so the four moves compose instead of // summing. SHADOW carries the whole knob on its own, so SHADOW +100 reads // toneCurve(b, 0, 1, 0, 0). const clamp01 = (x) => Math.min(1, Math.max(0, x)); const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x)); const poly = (c, L) => { let v = c.v[0]; for (let i = 1; i < c.p.length; i++) v += (c.v[i] - c.v[i - 1]) * clamp((L - c.p[i - 1]) / (c.p[i] - c.p[i - 1]), 0, 1); return v; }; const knob = (L, k, amt) => clamp01(L + (amt >= 0 ? amt * poly(TONE_KNOBS[k].plus, L) : -amt * poly(TONE_KNOBS[k].minus, L))); // toneCurve, move for move, with the clamp the shader puts after each one. // `afterBlack` is the L the SHADOW move reads. function toneCurve(L, bl, sh, hl, wh) { const afterBlack = knob(L, 'bl', bl); return { o: knob(knob(knob(afterBlack, 'sh', sh), 'hl', hl), 'wh', wh), afterBlack, }; } const ramp = (b, k = {}) => toneCurve(b, k.bl ?? 0, k.sh ?? 0, k.hl ?? 0, k.wh ?? 0); const srcValue = 128; // the pixel: 0.501961 encoded const baseValue = 76; // its neighbourhood, darker: 0.298039 const t = srcValue / 255; const b = baseValue / 255; // The neighbourhood's new luma, plus the pixel's own DIFFERENCE from the base — // Base' + Detail, the reconstruction the shader emits (see the note in // toneRamp). The ratio Base' * (Input / Base) is what this used to predict, and // it is what took the texture out of the frame at BLACK -100. const diff = Math.abs(t - b); const smooth = (x) => x * x * (3 - 2 * x); const expected = Math.round(255 * clamp01(ramp(t, { sh: 1 }).o)); 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 predicts ${expected}`, ); // With the base handed in as the sharp image the difference is exactly zero and // the pass is the GLOBAL move — the ramp at the pixel — which is what a mask (no // neighbourhood of its own) needs it to be. It is NOT the identity any more: // the shadow window spans the middle, so the same knobs move a mid-grey. const globalMove = Math.round(255 * clamp01(ramp(t, { sh: 1 }).o)); const selfBase = render(srcValue, srcValue); assert.ok( Math.abs(selfBase - globalMove) <= 2, `a base equal to the pixel must be the ramp at the pixel (${globalMove}), got ${selfBase} for ${srcValue}`, ); assert.ok( Math.abs(selfBase - srcValue) > 2, `the pass returned the pixel (${selfBase}) — the base is the sharp image again`, ); // There is no guard any more, and nothing to hold under one: the four moves are // each monotone for any amount in [-1, 1] and clamped on the way to the next, so // BLACK and SHADOW both at +100 compose into a ramp that still rises — measured // on a 1/400 grid, where the two used to sum past a slope of 1 and carry the // curve backwards — and BLACK's own travel no longer depends on the stock's // SHADOW, because BLACK runs FIRST: the value SHADOW reads is the same whatever // SHADOW says, which is the "kéo theo sự thay đổi của thông số khác" report. const rises = (f) => Array.from({ length: 400 }, (_, i) => f((i + 1) / 400) > f(i / 400)).every(Boolean); assert.ok(rises((b) => ramp(b, { bl: 1, sh: 1 }).o), 'BLACK and SHADOW both at +100 fold the ramp back on itself'); assert.equal( ramp(0.05, { bl: -1 }).afterBlack, ramp(0.05, { bl: -1, sh: -0.2 }).afterBlack, 'SHADOW drags BLACK’s own travel with it again', ); console.log( `tone base ok: pixel ${srcValue} over a base of ${baseValue} -> ${got} ` + `(ramp predicts ${expected}, global move ${globalMove}, sharp-base ${selfBase}); ` + `radius ${TONE_BASE_RADIUS} of the frame, sigma ${TONE_BASE_SIGMA} of it`, );