acbb2bba4b
The report was "giá trị thay đổi của các thông số quá nhỏ, không thể hiện được
trên thị giác của ảnh" — at the doc's own rates a full +100 was worth 0.060 of
luma on BLACKS, 0.082 on HIGHLIGHTS and 0.042 on WHITES, and the probe that ran
the frame through the pass read BLACKS +100 moving its mean by 0.0001. Every
rate below is now the largest its own move allows, measured rather than
inherited: 0.058 -> 0.080 on BLACKS, 0.082 -> 0.113 on HIGHLIGHTS and 0.042 ->
0.080 on WHITES, with SHADOWS' 0.151 left where it was because it already bit.
- `TONE_BLACK_LIFT` 0.7 -> 0.93. The ceiling is a FOLD, not a slope: past
0.9387 the doc's own square root carries luma backwards inside its window
(0.94 folds 3.4e-6, 0.95 folds 8.9e-5) and a gradient wears it as a band.
0.93 is the last round rate under it — monotone on the check's 1/32768
grid, and the 1e-4 of travel between it and 0.94 is not a code value.
- `TONE_BLACK_CRUSH` 0.85 -> 8.0. The doc's own form — `L * (1 + amount * W
* 0.85)` — is bounded by its own window, which is 1 only AT the floor, so
its whole visible travel at full -100 is 0.019 of luma: five code values on
a black patch, and a rate past 1 drives the product negative and clips the
toe to a flat black instead of deepening it. The toe's own EXPONENT,
`L -> W * (L/W)^(1 + rate * W)`, is monotone for ANY rate and worth 0.054,
while x = 0 stays on 0 and the 0.18 edge stays on 1 — both anchors and the
compact support kept.
- `TONE_HIGH_GAIN` 2.5 -> 14.0, with the head term moved from `(1 - L)` to
`(1 - L)^2`. The linear headroom dies too slowly to keep the rate's own
ceiling off the clamp: above a gain worth 2.6 the move overshoots 1.0, the
clamp draws a plateau and the ramp falls back over it by 0.018 — the fold
the knee check now measures as a drawdown from the running maximum. Squared,
the move has died out by the time the ramp reaches the clamp: 14.0 is
fold-free at both signs and still lands 1.44x of the 1.5x the quarter it
owns is allowed.
- `TONE_WHITE_GAIN` 1.5 -> 3.0, which takes the top of the ramp TO the
ceiling from 0.92 up and leaves the clamp to flatten what is left. That is
the doc's own §2.4, where a WHITE is the frame's clipping point ("giới hạn
cháy sáng") and not a Hermite that cannot move the head; it is felt only
above the 0.80 shoulder and the head is still exactly 1.0 on 1.0.
`FILM_TONE` is re-solved for the squared head, which is worth less at the 0.75
knot for the same rate: monochrome -0.16 -> -0.1143 and mono-high-contrast
+0.83 -> +0.5929. The four knots the stocks are tuned to do not move — 0.22 and
0.7375 on Acros, 0.17 and 0.815 on Acros HC — and the check pins each of them.
The knee check's guard is REPLACED. The old one compared the first cell of the
sweep against the second (a slope at 1/512), which is blind to a fold that
starts later: it passed a ramp whose own drawdown was 0.018. The new one walks
1/32768 of the ramp and measures `running max - value` for each knob at both
signs, over the four moves alone and then over a 243-combination sweep, so what
is pinned is the fold itself and where it is.
Two folds are pinned rather than removed, both named in the check:
- SHADOWS -100 dips 0.018 (4.6 code values) around 0.06..0.11 of its own
accord. It is the doc's §2.2 formula — `L *= 1 + amount * W * (1 - L)^1.8`
— where the window rises faster than the light, and it PREDATES this change.
The monotone rewrite (`L' = 1 - (1 - L)^(1 - SH * |a| * W(L))`) is written
out beside it and was NOT taken: it is exact but it costs the knob 30-50% of
its crush.
- WHITE +100 rests a plateau on the clamp from 0.92 up. That is what §2.4 asks
of the knob and the ramp is non-decreasing through it, so it is not a fold.
`scripts/tone-base-check.mjs`'s mirror of the pass takes the same three moves
(its own `toneBlack` and the squared head) so the pixel it predicts is still the
pixel the pass draws.
Checked: node scripts/highlight-knee-check.mjs; node scripts/tone-base-check.mjs;
node scripts/auto-tone-check.mjs; node scripts/half-check.mjs; node
scripts/mask-wb-check.mjs; node scripts/preview-match-check.mjs; node
scripts/raw-develop-check.mjs; node scripts/white-level-check.mjs; node
scripts/wb-table-check.mjs; node scripts/sharpen-check.mjs; node
scripts/denoise-check.mjs; npx tsc --noEmit.
194 lines
8.9 KiB
JavaScript
194 lines
8.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_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`,
|
||
);
|