web: fix halo artifacts on highlight/shadow/white/black adjustments with edge-aware tone mapping and update docker packages

This commit is contained in:
2026-10-02 15:27:32 +07:00
parent acbb2bba4b
commit e98b4d9d5c
40 changed files with 1833 additions and 762 deletions
+26 -45
View File
@@ -35,7 +35,7 @@ writeFileSync(
);
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,
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.
@@ -85,54 +85,31 @@ function render(srcValue, baseValue) {
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).
// 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 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);
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) {
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 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
@@ -143,7 +120,11 @@ const b = baseValue / 255;
// 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 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(