// 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_BLACK_EDGE, TONE_WHITE_EDGE, TONE_BLACK_LIFT, TONE_BLACK_CRUSH, TONE_HIGH_GAIN, TONE_WHITE_GAIN, } = 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: four COMPACT windows of the // encoded luma — BLACK's toe dying on 0.18, SHADOW's bell over the deep tones, // HIGHLIGHT against the headroom that is left, WHITE's Hermite on the shoulder — // applied ONE AFTER THE OTHER and clamped on the way to the next, so the four // moves compose instead of summing and the ramp needs no guard. SHADOW carries // the whole knob at its own window, so SHADOW +100 reads toneCurve(b, 0, 1, 0, 0) // (b and a1 are gone with the bumps; see the note at the head of toneRamp). const clamp01 = (x) => Math.min(1, Math.max(0, x)); const smoothstep = (e0, e1, x) => { const u = clamp01((x - e0) / (e1 - e0)); return u * u * (3 - 2 * u); }; const toneBlackW = (L) => { const u = clamp01(1 - L / TONE_BLACK_EDGE); return u * u * u; }; const toneShadowW = (L) => smoothstep(0.02, 0.12, L) * (1 - smoothstep(0.25, 0.55, L)); const toneHighW = (L) => smoothstep(0.45, 0.65, L) * (1 - smoothstep(0.92, 1.0, L)); const toneWhiteW = (L) => { const u = clamp01((L - TONE_WHITE_EDGE) / (1 - TONE_WHITE_EDGE)); return u * u; }; // toneBlack, as the shader emits it: the lift is the doc's own, the crush is the // toe's EXPONENT (monotone for any rate, and the only form that moves a visible // amount inside a window that is 1 only at the floor). const toneBlack = (L, bl) => { const q = toneBlackW(L); if (bl > 0) return L + TONE_BLACK_LIFT * bl * q * (Math.sqrt(L) - L); return L >= TONE_BLACK_EDGE ? L : TONE_BLACK_EDGE * (L / TONE_BLACK_EDGE) ** (1 + TONE_BLACK_CRUSH * -bl * q); }; // 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) { let q = toneBlackW(L); L = toneBlack(L, bl); L = clamp01(L); const afterBlack = L; q = toneShadowW(L); L *= 1 + sh * q * (1 - L) ** 1.8; L = clamp01(L); q = toneHighW(L); L += TONE_HIGH_GAIN * hl * q * Math.max(L - 0.5, 0) ** 1.5 * (1 - L) * (1 - L); L = clamp01(L); q = toneWhiteW(L); L += TONE_WHITE_GAIN * wh * q * (1 - L) * L; return { o: clamp01(L), 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 expected = Math.round(255 * clamp01(ramp(b, { sh: 1 }).o + (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 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`, );