e4f5407c19
BLACK, SHADOW, HIGHLIGHT and WHITE were four bumps summed into the identity, and the sum carried a guard: the two bumps of a half shared a slope, so past a total of 1 the curve folded backwards, and the ceiling that stopped it was shared by the amplitudes of a half. A stock already sitting on SHADOW therefore took BLACK's lift down with it — on the monochrome stock (sh = -0.24) BLACK at -100 came back with 0.663 of the travel the knob has on its own, which is the "kéo theo sự thay đổi của thông số khác" report exactly. thay_doi_thong_so_giong_lightroom.md section 2 asks for four WINDOWS instead: each knob owns a compact band of the ramp and is exactly zero outside it, and the four moves are applied ONE AFTER ANOTHER rather than summed. A composition of monotone maps is monotone by construction, so it needs no guard, and each knob then measures 1.00 of its travel on every stock. BLACKS is the doc's toe — u = clamp(1 - L/0.18, 0, 1) cubed, opened by sqrt(L) - L at 0.7 and deepened by 0.85, both of which are exactly zero at L = 0, so (0,0,0) stays (0,0,0): the grey pedestal that BLACK +100 left on a black was the sum adding its bump's height at the black point, which is the doc's own "Milky / Foggy". SHADOWS is the doc's bell over the deep tones, HIGHLIGHTS the bell over the bright ones, WHITES the doc's Hermite on the shoulder from 0.80. The ramp keeps its two anchors — 0.00 and 1.00 — at every setting of the four knobs. One deliberate departure from the doc: HIGHLIGHT carries a (1 - L) the doc's raw knee does not, because pow(L - 0.5, 1.5) added to L overshoots the cube above 0.94 — 17% of the ramp driven to flat white at +100 before the clamp. Read against the headroom that is left, the move is zero at L = 1 by construction and the head rolls instead of clipping. The windows are read in the sRGB-encoded luma this file already works in, not in linear light as the doc's section 1 sets out: the doc's own boundaries (0.18, 0.05..0.45, 0.55..0.95, 0.80) land as perceptual positions there, and moving the whole renderer to the linear domain is a bigger change than this pass. The divergence is the one the scratchpad compat doc already warns the Android port about, and it is noted at the windows themselves. Checked: `tsc --noEmit` clean; `highlight-knee-check.mjs`, `tone-base-check.mjs` and `mask-wb-check.mjs` updated to the four windows and passing; the twin ramp over a 1/512 grid is monotone to -0.00119 (0.30 code values, at t = 0.098 with every knob at full negative), both anchors hold for every combination, and a knob outside its band is the exact identity.
186 lines
8.5 KiB
JavaScript
186 lines
8.5 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;
|
||
};
|
||
// 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 = bl > 0
|
||
? L + TONE_BLACK_LIFT * bl * q * (Math.sqrt(L) - L)
|
||
: L * Math.max(1 + TONE_BLACK_CRUSH * bl * q, 0);
|
||
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);
|
||
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`,
|
||
);
|