A gradient mask can carry the LIGHT column's white balance now: COLOR TEMP and
TINT, the same two rulers, read on the mask's own pixels. The pair was asked for as the two rows the develop column already has, so it is the same pair and not a second opinion about what a kelvin means: the gain comes from one function, colorUtils.whiteBalanceGain(temperature, tint), which the frame-wide colour matrix now calls too — the RGB kelvin fit of kelvinToRGB, the symmetric ±0.08 magenta/green tint, and the division by the product's own Rec.709 luma that keeps a cast from being a brightness move. A mask that never touched the pair reads 5500K / 0 (readMasks' own defaults, clamped to the ruler's ends), whose gain is exactly (1,1,1), so nothing moves and every recipe stored before this reads the same. The measured rule holds inside a mask exactly as it does on the whole frame: at 10000K the mask's pixels came out R/B 1.25 -> 1.72 against 0.994 -> 0.994 outside it. The Kelvin fit is a curve, so it is resolved on the JS side and handed over as a gain: maskUniforms grows one more float4 array (wb[i], three gains and a zero pad) between the spatial pair and the frame size, in declaration order like every other array in that buffer, and the shader multiplies the mask's colour by it right after EXPOSURE — one clamp, three multiplies by one for a mask that leaves the pair alone, and no second copy of the fit in SkSL. The spatial build is a second shader text with a second signature and reads the same array. App.tsx draws the rows where the panel's other rows already are, and TINT rides the existing maskKnobRow helper: same store unit (±10), same ±100 slider the frame-wide row wears since the develop knobs were deepened. COLOR TEMP keeps its own scale (2500..10000, step 100, "10000K"), because a temperature is not a percentage, and it carries the same swatch the frame-wide row paints. Verified: npx tsc --noEmit; npm run build; the repo's check set (highlight-knee, auto-tone, half, white-level, preview-match, library, scan-nav, roll-walk) all pass; and scripts/mask-wb-check.mjs, which is new here — it transpiles the gradientMask graph into a temp dir and checks the gain (neutral at 5500K/0, warm at 10000K, cool at 2500K, luma-preserving, ±TINT symmetric), the clamps, the uniform offsets (the gain lands where the shader reads wb[i], the frame size one array further on), and then compiles the real shader through CanvasKit and pushes a mid-grey through it: neutral leaves 128, 10000K warms it, +TINT magenta-ises it, and both the plain and the spatial builds and a two-mask list read it. The UI was driven on the built bundle too (a linear mask dragged on the preview, then the two rulers moved through their own range inputs): the mask column reports 11 rows, opens at 5500K / 0, takes 10000K and +40 ±100-scale TINT, the swatch follows, and the preview's own pixels warm inside the mask and nowhere else. ponytail: the mask's WB is not skipped for monochrome stocks the way the frame-wide matrix skips it — a local kelvin on a B&W frame is a tint someone asked for by hand, not the colour leak that rule exists to stop. Add the same isMonochromeBase guard (and pass the base filter into maskUniforms) only if that turn out to read wrong on a live B&W recipe. Co-authored-by: PenguinHarness <noreply@penguin.local>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
// A gradient mask carries the LIGHT column's WB pair now — COLOR TEMP and TINT,
|
||||
// on the mask's own pixels. Two things can go wrong and neither shows up as a
|
||||
// crash: the mask's gain drifts from the frame-wide one (two opinions about what
|
||||
// 3000K means), or the uniform block and the shader's declarations fall out of
|
||||
// step (one buffer, expanded arrays — an off-by-one there reads the frame size as
|
||||
// a gain). So this checks the arithmetic and then renders the pass for real:
|
||||
// CanvasKit is already a dependency, so the shader is compiled and a mid-grey is
|
||||
// pushed through it rather than the source being eyeballed.
|
||||
//
|
||||
// node scripts/mask-wb-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;
|
||||
|
||||
// gradientMask.ts imports its two helpers, so the graph is transpiled into one
|
||||
// temp directory and the specifiers are pointed at the .mjs copies (the
|
||||
// convention auto-tone-check.mjs uses). colorUtils and toneShader import types
|
||||
// only, so the compiler drops those lines itself.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'mask-wb-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";',
|
||||
),
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, 'gradientMask.mjs'),
|
||||
transpile('../shared/utils/gradientMask.ts')
|
||||
.replace(/^import .*from ['"]\.\/colorUtils['"];$/m, 'import { whiteBalanceGain } from "./colorUtils.mjs";')
|
||||
.replace(
|
||||
/^import \{[^\n}]*\} from ['"]\.\/toneShader['"];$/m,
|
||||
'import { CLARITY_GAIN, DEHAZE_FLOOR_T, DEHAZE_MAX_OMEGA, DEHAZE_PATCH_STEP, DEHAZE_PATCH_TAPS } from "./toneShader.mjs";',
|
||||
),
|
||||
);
|
||||
const { readMasks, maskUniforms, gradientMaskSkSL } = await import(
|
||||
pathToFileURL(join(dir, 'gradientMask.mjs')).href
|
||||
);
|
||||
const { whiteBalanceGain } = await import(pathToFileURL(join(dir, 'colorUtils.mjs')).href);
|
||||
|
||||
// --- The gain itself: the one function the frame-wide matrix folds in too.
|
||||
const close = (a, b, eps = 1e-12) => assert.ok(Math.abs(a - b) < eps, `${a} != ${b}`);
|
||||
const luma = (g) => 0.2126 * g.r + 0.7152 * g.g + 0.0722 * g.b;
|
||||
const neutral = whiteBalanceGain(5500, 0);
|
||||
close(neutral.r, 1);
|
||||
close(neutral.g, 1);
|
||||
close(neutral.b, 1);
|
||||
// The ruler runs the way the engine does: K names the light the frame is said to
|
||||
// have been shot under, and the gain compensates for it, so the picture WARMS as
|
||||
// K rises (kelvinToRGB's own doc, and the swatch under the ruler). 10000K up and
|
||||
// 2500K down, on the same two ends the frame-wide row has.
|
||||
const warm = whiteBalanceGain(10000, 0);
|
||||
assert.ok(warm.r > 1 && warm.b < 1, '10000K is not warm');
|
||||
const cool = whiteBalanceGain(2500, 0);
|
||||
assert.ok(cool.b > 1 && cool.r < 1, '2500K is not cool');
|
||||
// The cast moves, the exposure does not: every gain is divided by its own luma.
|
||||
for (const g of [warm, cool, whiteBalanceGain(4000, 7), whiteBalanceGain(9000, -7)]) close(luma(g), 1);
|
||||
// TINT is the green<->magenta axis, and symmetric about 0.
|
||||
const magenta = whiteBalanceGain(5500, 10);
|
||||
const green = whiteBalanceGain(5500, -10);
|
||||
assert.ok(magenta.r > 1 && magenta.b > 1 && magenta.g < 1, '+TINT is not magenta');
|
||||
assert.ok(green.g > 1 && green.r < 1 && green.b < 1, '-TINT is not green');
|
||||
// Symmetric in the two channels it moves, and only in those: R and B take the
|
||||
// same factor, G its own, and the luma normalisation is what keeps the pair from
|
||||
// being a brightness knob (the weights are unequal, so the two ends are not exact
|
||||
// mirrors of each other in the third decimal).
|
||||
close(magenta.r, magenta.b);
|
||||
close(green.r, green.b);
|
||||
assert.ok(Math.abs(Math.log(magenta.r) + Math.log(green.r)) < 0.01, 'TINT is not symmetric about 0');
|
||||
|
||||
// --- readMasks: a mask that never touched the pair reads the neutral, and a
|
||||
// hand-written file cannot push the gain outside the ruler.
|
||||
const shape = { kind: 'linear', x: 0, y: 0, ex: 1, ey: 1, rx: 0, ry: 0, angle: 0, feather: 0.5 };
|
||||
const mask = (extra) => ({ ...shape, exposure: 0, contrast: 0, saturation: 0, ...extra });
|
||||
const bare = readMasks([mask({})])[0];
|
||||
assert.equal(bare.temperature, 5500, 'an absent WB is not neutral');
|
||||
assert.equal(bare.tint, 0, 'an absent TINT is not neutral');
|
||||
const high = readMasks([mask({ temperature: 2000, tint: 99 })])[0];
|
||||
assert.equal(high.temperature, 2500);
|
||||
assert.equal(high.tint, 10);
|
||||
const low = readMasks([mask({ temperature: 20000, tint: -99 })])[0];
|
||||
assert.equal(low.temperature, 10000);
|
||||
assert.equal(low.tint, -10);
|
||||
|
||||
// --- The uniform block: arrays expanded, declaration order, so the gain has to
|
||||
// land on the slot the shader reads as `wb[i]` and the frame size has to stay one
|
||||
// array further on.
|
||||
const n = 1;
|
||||
const one = maskUniforms([mask({ temperature: 10000 })], 64, 64, [0.5, 0.5, 0.5]);
|
||||
assert.equal(one.length, (6 * n + 1 + 1) * 4, 'the buffer is not six arrays, the frame and the air');
|
||||
const g = whiteBalanceGain(10000, 0);
|
||||
close(one[5 * n * 4 + 0], g.r, 1e-6);
|
||||
// The gain is the SAME function the frame-wide matrix folds in, so 10000K cannot
|
||||
// come to mean one thing on the whole frame and another inside a mask.
|
||||
assert.match(
|
||||
readFileSync(new URL('../shared/utils/colorUtils.ts', import.meta.url), 'utf8'),
|
||||
/const wbGain = whiteBalanceGain\(temperature, tint\);/,
|
||||
'the matrix grew a white balance of its own again',
|
||||
);
|
||||
close(one[5 * n * 4 + 1], g.g, 1e-6);
|
||||
close(one[5 * n * 4 + 2], g.b, 1e-6);
|
||||
assert.equal(one[6 * n * 4 + 0], 64, 'the width no longer sits where the shader reads `size`');
|
||||
assert.equal(one[6 * n * 4 + 1], 64);
|
||||
assert.equal(one[(6 * n + 1) * 4 + 0], 0.5, 'the atmospheric light moved');
|
||||
const plain = maskUniforms([mask({})], 64, 64);
|
||||
for (let c = 0; c < 3; c++) close(plain[5 * n * 4 + c], 1, 1e-6);
|
||||
|
||||
// --- The pass itself. A radial mask whose semi-axis is four frames wide sits far
|
||||
// enough inside its own feather that every pixel has alpha 1, so what comes out
|
||||
// is the gain the mask was handed and nothing else: a wrong slot shows up as a
|
||||
// wrong pixel rather than as a wrong number in a buffer.
|
||||
const CanvasKit = await CanvasKitInit({
|
||||
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
|
||||
});
|
||||
const SIZE = 64;
|
||||
const wide = { kind: 'radial', x: 0.5, y: 0.5, ex: 0.5, ey: 0.5, rx: 4, ry: 4, angle: 0, feather: 0.5 };
|
||||
|
||||
function render(masks, spatial) {
|
||||
const effect = CanvasKit.RuntimeEffect.Make(gradientMaskSkSL(masks.length, spatial));
|
||||
assert.ok(effect, `the mask shader for ${masks.length} masks does not compile (spatial ${spatial})`);
|
||||
// The frame the pass reads: a flat mid-grey, drawn once and handed in as the
|
||||
// first child exactly as exportEngine's replaceThrough hands its snapshot in.
|
||||
const src = CanvasKit.MakeSurface(SIZE, SIZE);
|
||||
const fill = new CanvasKit.Paint();
|
||||
fill.setColor(CanvasKit.Color(128, 128, 128));
|
||||
src.getCanvas().drawPaint(fill);
|
||||
const grey = src.makeImageSnapshot();
|
||||
const asChild = () =>
|
||||
grey.makeShaderOptions(
|
||||
CanvasKit.TileMode.Clamp, CanvasKit.TileMode.Clamp, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None,
|
||||
);
|
||||
const air = spatial ? [0.5, 0.5, 0.5] : null;
|
||||
const children = [asChild()];
|
||||
if (spatial) children.push(asChild());
|
||||
const paint = new CanvasKit.Paint();
|
||||
paint.setShader(effect.makeShaderWithChildren(maskUniforms(masks, SIZE, SIZE, air), children));
|
||||
const out = CanvasKit.MakeSurface(SIZE, SIZE);
|
||||
out.getCanvas().drawRect(CanvasKit.XYWHRect(0, 0, SIZE, SIZE), paint);
|
||||
const px = out.getCanvas().readPixels(0, 0, {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
colorType: CanvasKit.ColorType.RGBA_8888,
|
||||
alphaType: CanvasKit.AlphaType.Unpremul,
|
||||
colorSpace: CanvasKit.ColorSpace.SRGB,
|
||||
});
|
||||
const at = (x, y) => {
|
||||
const i = (y * SIZE + x) * 4;
|
||||
return [px[i], px[i + 1], px[i + 2]];
|
||||
};
|
||||
return at(32, 32);
|
||||
}
|
||||
const knob = (extra) => ({ ...wide, exposure: 0, contrast: 0, saturation: 0, ...extra });
|
||||
const same = (p, want = 128) => p.every((v) => Math.abs(v - want) <= 1);
|
||||
assert.ok(same(render([knob({})], false)), `a neutral mask moved the grey: ${render([knob({})], false)}`);
|
||||
const after = render([knob({ temperature: 10000 })], false);
|
||||
assert.ok(after[0] > 130 && after[2] < 126, `10000K did not warm the mask's pixels: ${after}`);
|
||||
const tinted = render([knob({ tint: 10 })], false);
|
||||
assert.ok(tinted[0] > 130 && tinted[2] > 130 && tinted[1] < 126, `+TINT is not magenta: ${tinted}`);
|
||||
// The spatial build is a second shader text with a second signature: it has to
|
||||
// compile with the same WB and read `wb` all the same.
|
||||
const spatialPx = render([knob({ temperature: 10000 })], true);
|
||||
assert.ok(spatialPx[0] > 130 && spatialPx[2] < 126, `the spatial pass dropped the WB: ${spatialPx}`);
|
||||
// Two masks: the second array's slot, not the first one's, and both applied.
|
||||
const two = render([knob({}), knob({ temperature: 10000 })], false);
|
||||
assert.ok(two[0] > 130 && two[2] < 126, `the second mask's WB did not land: ${two}`);
|
||||
|
||||
console.log(
|
||||
'mask WB: neutral 5500K/0 leaves the pixel, 10000K warms it, +TINT magenta-ises it; the block, the plain and the spatial shaders all read it',
|
||||
);
|
||||
Reference in New Issue
Block a user