Files
RecipesCam/docker/frontend/scripts/tone-base-check.mjs
T

175 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 edgeW = 1.0 - (diff <= 0.08 ? 0 : diff >= 0.35 ? 1 : smooth((diff - 0.08) / 0.27));
const effBase = t * (1 - edgeW) + b * edgeW;
const expected = Math.round(255 * clamp01(ramp(effBase, { sh: 1 }).o + (t - effBase)));
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`,
);