166a677590
The ramp was a0..a4 with the pixel's base interpolated straight between them. A segment meets its neighbour at an ANGLE, and the second derivative of a tone curve is what a gradient reads as a band — so a knob left a line across the mid-tones, worst exactly where it was reported: BLACK +100 put its whole lift inside 0.25 and the stretch from 0.25 up came back identical to the untouched frame (the "transition stays grey" it was reported for), and HIGHLIGHT -100 folded a seam into 0.747, between the highlights it pulled and the shadow it left under them. Measured on a 1024-step luma wedge through the exported pass, second difference through a ±1% box: BLACK +100 read 105 at 0.030 against 0.000 from 0.25 up, HIGHLIGHT -100 read 72 at 0.747. Step of the first derivative across the knots: 0.955 at 0.25 and 1.146 at 0.75 — the curve arrived folded, and 1.146 is a sign flip, not a bend. So each knob is now a BUMP on the identity, peaking on its own knot — BLACK on 0.00, SHADOW on 0.25, HIGHLIGHT on 0.75, WHITE on 1.00 — with the kernel (1-u^2)^2 over a half-width (a half of the ramp for the two ends, whose knots ARE the ends, a quarter for the two heads). Level at u = 0, so a knot moves without a fold at its own top; level at u = 1, so a move lands on the identity and on its neighbour without an angle; C1 everywhere between. The two bumps of a half meet on 0.50 both on zero, which is the same fixed midpoint as before, and DR still moves the same knots (0.12 on the toe, 0.18 on the head, half of each on the heads beside them). A sum of bumps can overshoot where two steep sides land on one stretch — past a slope of 1 the curve runs BACKWARDS, a worse band than the seams this replaces, and it is reachable: DR alone was under it, BLACK and SHADOW +100 together were not (unguarded min slope -0.0141). The guard reads each pair at its own steepest points, 8/(3*sqrt(3))/w per unit amplitude (TONE_BUMP_SLOPE_HALF 3.0792, TONE_BUMP_SLOPE_QUARTER 6.1584), holds the two under one and gives them up together past it. A single knob never reaches it (a full BLACK is 0.77, a full SHADOW 0.77), so every slider keeps its whole travel; the worst case is DR at full, which gives up a tenth of its head roll (0.18 -> 0.8376 on the head), and BLACK with SHADOW both at +100, which arrive at 0.65 of their own lift instead of folding. Guarded, the sweep over five levels of all four knobs and DR reads a min slope of +0.0183 and a max of 1.9937, with the largest slope jump 0.00005. After: the same wedge, the same pass. The knot steps are 0.096 at 0.25 and 0.478 at 0.75, with no sign flip — C1 across the knot instead of a fold. BLACK +100 now carries the rework out of its own quarter: +0.139 at 0.25, +0.101 at 0.30, +0.033 at 0.40, 0.000 at 0.50, where it used to read 0.000 from 0.25 all the way up. HIGHLIGHT -100 keeps its lift (-0.126 peak against -0.121 before) and spends it over the quarter instead of into a line. Every knob on zero is the identity to the last bit — the pass also runs for the stock split tones and for DR alone — and 0.50 is still the one value no knob moves. Checks: tone-base-check.mjs now runs the bump and the guard as arithmetic beside the shader (with the negative control: the unguarded pair still folds, the guard is what stops it). highlight-knee-check.mjs reads the kernel and the two slope constants off the source, pins the four amplitudes and the guard, and sweeps the travel of every knob as before. All ten checks that run without a browser pass, build clean. Skipped: the guard's ceiling is a constant, not a search for the widest travel that still clears a band we cannot see. Add when a frame shows a band the deflections in hand cannot explain.
147 lines
6.9 KiB
JavaScript
147 lines
6.9 KiB
JavaScript
// 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`,
|
|
);
|