// The tone ramp is drawn through a BASE layer, and that base is a CHILD of the // pass — the edge-aware pass of TONE_BASE_SKSL, run by the caller — rather than a // ring of point samples inside it or a blur of it: see TONE_BASE_RADIUS in // toneShader.ts for the mottle a ring caused and for the halo a gaussian one // does. 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_SKSL, TONE_BASE_RADIUS, TONE_BASE_SIGMA, TONE_BASE_TAPS, toneBaseStep, 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 — and // the base pass has to take its own two, one per axis. assert.match(TONE_SKSL, /uniform shader base;/); assert.match(TONE_BASE_SKSL, /uniform shader src;/); assert.match(TONE_BASE_SKSL, /uniform float2 dir;/); assert.ok(TONE_BASE_TAPS > 0 && toneBaseStep(1200, 600) * TONE_BASE_TAPS >= 1200 * TONE_BASE_RADIUS - 0.5); 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). Two other reconstructions were here before it: the ramp read at the // PIXEL (a global curve, and the local-contrast report), and Base' * (Input / // Base), the gain-weighted detail that took the texture out of the frame at // BLACK -100. const detail = (pixel, base) => clamp01(ramp(base, { sh: 1 }).o + (pixel - base)); const expected = Math.round(255 * detail(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 predicts ${expected}`, ); // The same instruction on the other side of the pixel: a base BRIGHTER than it // takes the neighbourhood's move and hands the pixel its own difference back, so // the pixel lands BELOW the neighbourhood's new luma. One move per // neighbourhood, not one per pixel — which is exactly what a global curve // cannot say. const brighterValue = 180; const brighter = render(srcValue, brighterValue); const brighterWant = Math.round(255 * detail(t, brighterValue / 255)); assert.ok( Math.abs(brighter - brighterWant) <= 2, `a base of ${(brighterValue / 255).toFixed(4)} over a pixel of ${t.toFixed(4)} gave ${brighter}, Base'+Detail predicts ${brighterWant}`, ); assert.ok( brighter < got, `a brighter base did not pull the pixel down (${brighter} against ${got}) — the base is not in the move`, ); // 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} ` + `(Base'+Detail predicts ${expected}, global move ${globalMove}, sharp-base ${selfBase}); ` + `over a base of ${brighterValue} -> ${brighter} (predicts ${brighterWant}); ` + `radius ${TONE_BASE_RADIUS} of the frame, ${TONE_BASE_TAPS} taps per side, step ` + `${toneBaseStep(1200, 600)} px at 1200, sigma ${TONE_BASE_SIGMA} of the radius`, );