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',
|
||||||
|
);
|
||||||
@@ -166,6 +166,13 @@ export interface GradientMask {
|
|||||||
blacks?: number;
|
blacks?: number;
|
||||||
clarity?: number;
|
clarity?: number;
|
||||||
dehaze?: number;
|
dehaze?: number;
|
||||||
|
// The LIGHT column's WB pair on the mask's own pixels: the same two rulers,
|
||||||
|
// the same units (kelvin, and the store's ±10 tint), the same gain — a local
|
||||||
|
// white balance, which is what a mask that sits under a mixed light is for.
|
||||||
|
// Absent on a mask stored before they existed, and then the gain is exactly 1
|
||||||
|
// (5500K / 0), so nothing moves and the recipe reads the same.
|
||||||
|
temperature?: number;
|
||||||
|
tint?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ColorAdjustments {
|
export interface ColorAdjustments {
|
||||||
|
|||||||
@@ -117,6 +117,26 @@ export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number }
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The whole white balance in one place — Kelvin AND tint — because it is now
|
||||||
|
// asked for twice: once frame-wide by the colour matrix below, once per gradient
|
||||||
|
// mask by gradientMask.ts, where a mask's WB is meant to be the same move on a
|
||||||
|
// smaller area rather than a second opinion about what the two knobs mean.
|
||||||
|
//
|
||||||
|
// Tint is the green↔magenta axis, and it is symmetric: + pushes magenta (red and
|
||||||
|
// blue up, green down), - pushes green (green up, red and blue down). The old
|
||||||
|
// version only ever ADDED a channel (magenta lifted R+B, green lifted G), so the
|
||||||
|
// knob brightened the frame instead of tinting it. Normalising the product keeps
|
||||||
|
// that at a tint shift, not an exposure one. `tint` is the store's own -10..+10.
|
||||||
|
export function whiteBalanceGain(temperature: number, tint: number): { r: number; g: number; b: number } {
|
||||||
|
const rgbTemp = kelvinToRGB(temperature);
|
||||||
|
const tintMagenta = (tint / 10) * 0.08;
|
||||||
|
return normalizeGainLuma([
|
||||||
|
rgbTemp.r * (1 + tintMagenta),
|
||||||
|
rgbTemp.g * (1 - tintMagenta),
|
||||||
|
rgbTemp.b * (1 + tintMagenta),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// How hard that excursion is allowed to bite. The physical ratio is brutal (the
|
// How hard that excursion is allowed to bite. The physical ratio is brutal (the
|
||||||
// blue channel carries 12x at 2500K) and it lands on sRGB-encoded, already
|
// blue channel carries 12x at 2500K) and it lands on sRGB-encoded, already
|
||||||
// tone-mapped pixels, where the raw value clips the ends flat. 0.5 is the
|
// tone-mapped pixels, where the raw value clips the ends flat. 0.5 is the
|
||||||
@@ -413,18 +433,7 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
|
|||||||
|
|
||||||
// 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage
|
// 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage
|
||||||
if (!isMonochromeBase(baseFilter)) {
|
if (!isMonochromeBase(baseFilter)) {
|
||||||
const rgbTemp = kelvinToRGB(temperature);
|
const wbGain = whiteBalanceGain(temperature, tint);
|
||||||
// Tint is the green↔magenta axis, and it is symmetric: + pushes magenta
|
|
||||||
// (red and blue up, green down), - pushes green (green up, red and blue
|
|
||||||
// down). The old version only ever ADDED a channel (magenta lifted R+B,
|
|
||||||
// green lifted G), so the knob brightened the frame instead of tinting it.
|
|
||||||
// Normalising the product keeps that at a tint shift, not an exposure one.
|
|
||||||
const tintMagenta = (tint / 10) * 0.08;
|
|
||||||
const wbGain = normalizeGainLuma([
|
|
||||||
rgbTemp.r * (1 + tintMagenta),
|
|
||||||
rgbTemp.g * (1 - tintMagenta),
|
|
||||||
rgbTemp.b * (1 + tintMagenta),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const wbMat = [
|
const wbMat = [
|
||||||
wbGain.r, 0, 0, 0, 0,
|
wbGain.r, 0, 0, 0, 0,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { GradientMask } from '../types';
|
import type { GradientMask } from '../types';
|
||||||
|
import { whiteBalanceGain } from './colorUtils';
|
||||||
import {
|
import {
|
||||||
CLARITY_GAIN,
|
CLARITY_GAIN,
|
||||||
DEHAZE_FLOOR_T,
|
DEHAZE_FLOOR_T,
|
||||||
@@ -57,6 +58,10 @@ const num = (v: unknown, fallback: number) => {
|
|||||||
};
|
};
|
||||||
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||||
const clampA = (v: number) => (v < -10 ? -10 : v > 10 ? 10 : v);
|
const clampA = (v: number) => (v < -10 ? -10 : v > 10 ? 10 : v);
|
||||||
|
// The frame-wide WB ruler's own two ends, and its own neutral: a mask that never
|
||||||
|
// touched WB carries 5500K / 0, whose gain is exactly 1.
|
||||||
|
const NEUTRAL_K = 5500;
|
||||||
|
const clampK = (v: number) => (v < 2500 ? 2500 : v > 10000 ? 10000 : v);
|
||||||
const clampEV = (v: number) => (v < -MASK_EXPOSURE_MAX ? -MASK_EXPOSURE_MAX : v > MASK_EXPOSURE_MAX ? MASK_EXPOSURE_MAX : v);
|
const clampEV = (v: number) => (v < -MASK_EXPOSURE_MAX ? -MASK_EXPOSURE_MAX : v > MASK_EXPOSURE_MAX ? MASK_EXPOSURE_MAX : v);
|
||||||
|
|
||||||
// The stored masks, made readable: numbers, inside the frame, one kind — the
|
// The stored masks, made readable: numbers, inside the frame, one kind — the
|
||||||
@@ -75,6 +80,8 @@ export type ReadMask = GradientMask & {
|
|||||||
blacks: number;
|
blacks: number;
|
||||||
clarity: number;
|
clarity: number;
|
||||||
dehaze: number;
|
dehaze: number;
|
||||||
|
temperature: number;
|
||||||
|
tint: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function readMasks(masks: GradientMask[] | undefined): ReadMask[] {
|
export function readMasks(masks: GradientMask[] | undefined): ReadMask[] {
|
||||||
@@ -103,6 +110,11 @@ export function readMasks(masks: GradientMask[] | undefined): ReadMask[] {
|
|||||||
blacks: clampA(num(m.blacks, 0)),
|
blacks: clampA(num(m.blacks, 0)),
|
||||||
clarity: clampA(num(m.clarity, 0)),
|
clarity: clampA(num(m.clarity, 0)),
|
||||||
dehaze: clampA(num(m.dehaze, 0)),
|
dehaze: clampA(num(m.dehaze, 0)),
|
||||||
|
// The WB pair, the two rulers the LIGHT column already carries, read on the
|
||||||
|
// mask's own pixels: kelvin on its own scale, tint in the store's ±10. Both
|
||||||
|
// absent on a mask stored before they existed, and then the gain is 1.
|
||||||
|
temperature: clampK(num(m.temperature, NEUTRAL_K)),
|
||||||
|
tint: clampA(num(m.tint, 0)),
|
||||||
}))
|
}))
|
||||||
.filter((m) => (m.kind === 'linear' ? Math.hypot(m.ex - m.x, m.ey - m.y) > MASK_MIN : m.rx > 0 && m.ry > 0));
|
.filter((m) => (m.kind === 'linear' ? Math.hypot(m.ex - m.x, m.ey - m.y) > MASK_MIN : m.rx > 0 && m.ry > 0));
|
||||||
}
|
}
|
||||||
@@ -119,11 +131,12 @@ export function masksHaveSpatial(masks: GradientMask[]): boolean {
|
|||||||
|
|
||||||
// The uniform block the shader for `n` masks reads: the shapes, the ellipse
|
// The uniform block the shader for `n` masks reads: the shapes, the ellipse
|
||||||
// parameters, the knobs with the kind, the tone soft masks, the spatial pair,
|
// parameters, the knobs with the kind, the tone soft masks, the spatial pair,
|
||||||
// then the frame the fractions are of — and, when the caller has one, the
|
// the white balance gain, then the frame the fractions are of — and, when the
|
||||||
// atmospheric light the dehaze reads. Declaration order, arrays expanded — one
|
// caller has one, the atmospheric light the dehaze reads. Declaration order,
|
||||||
// buffer is one upload per render, the same shape healUniforms and
|
// arrays expanded — one buffer is one upload per render, the same shape
|
||||||
// mosaicUniforms use. Its length is a function of the list, not a fixed
|
// healUniforms and mosaicUniforms use. Its length is a function of the list, not
|
||||||
// capacity, because the shader carries exactly the masks the recipe holds.
|
// a fixed capacity, because the shader carries exactly the masks the recipe
|
||||||
|
// holds.
|
||||||
export function maskUniforms(
|
export function maskUniforms(
|
||||||
masks: GradientMask[],
|
masks: GradientMask[],
|
||||||
width: number,
|
width: number,
|
||||||
@@ -132,7 +145,7 @@ export function maskUniforms(
|
|||||||
): Float32Array {
|
): Float32Array {
|
||||||
const list = readMasks(masks);
|
const list = readMasks(masks);
|
||||||
const n = list.length;
|
const n = list.length;
|
||||||
const u = new Float32Array((5 * n + 1 + (air ? 1 : 0)) * 4);
|
const u = new Float32Array((6 * n + 1 + (air ? 1 : 0)) * 4);
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const m = list[i];
|
const m = list[i];
|
||||||
u.set([m.x, m.y, m.ex, m.ey], i * 4);
|
u.set([m.x, m.y, m.ex, m.ey], i * 4);
|
||||||
@@ -146,9 +159,15 @@ export function maskUniforms(
|
|||||||
// The tone soft masks and the two ends, then the spatial pair.
|
// The tone soft masks and the two ends, then the spatial pair.
|
||||||
u.set([m.highlights / 10, m.shadows / 10, m.whites / 10, m.blacks / 10], (3 * n + i) * 4);
|
u.set([m.highlights / 10, m.shadows / 10, m.whites / 10, m.blacks / 10], (3 * n + i) * 4);
|
||||||
u.set([m.clarity / 10, m.dehaze / 10, 0, 0], (4 * n + i) * 4);
|
u.set([m.clarity / 10, m.dehaze / 10, 0, 0], (4 * n + i) * 4);
|
||||||
|
// The mask's own white balance, resolved here and not in the shader: the
|
||||||
|
// Kelvin fit is a curve, and a curve in SkSL would be a second copy of it
|
||||||
|
// (colorUtils.whiteBalanceGain is the one). Three gains and a zero pad, so
|
||||||
|
// one array of the same shape as the rest.
|
||||||
|
const wb = whiteBalanceGain(m.temperature, m.tint);
|
||||||
|
u.set([wb.r, wb.g, wb.b, 0], (5 * n + i) * 4);
|
||||||
}
|
}
|
||||||
u.set([width, height, 0, 0], 5 * n * 4);
|
u.set([width, height, 0, 0], 6 * n * 4);
|
||||||
if (air) u.set([air[0], air[1], air[2], 0], (5 * n + 1) * 4);
|
if (air) u.set([air[0], air[1], air[2], 0], (6 * n + 1) * 4);
|
||||||
return u;
|
return u;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,8 +192,13 @@ export function maskUniforms(
|
|||||||
// for them (masksHaveSpatial). `dark` is the patch's dark channel for DEHAZE,
|
// for them (masksHaveSpatial). `dark` is the patch's dark channel for DEHAZE,
|
||||||
// read in the block below (the frame's own pixels are only in reach there).
|
// read in the block below (the frame's own pixels are only in reach there).
|
||||||
const adjustFn = (spatial: boolean) => `
|
const adjustFn = (spatial: boolean) => `
|
||||||
half3 maskAdjust(half3 c, float4 a, float4 tone, float4 fx, half dark${spatial ? ', half3 blur, float3 air' : ''}) {
|
half3 maskAdjust(half3 c, half3 wb, float4 a, float4 tone, float4 fx, half dark${spatial ? ', half3 blur, float3 air' : ''}) {
|
||||||
c = c * half(pow(2.0, a.x));
|
c = c * half(pow(2.0, a.x));
|
||||||
|
// The mask's own white balance, the frame-wide WB gain on the mask's pixels: a
|
||||||
|
// gain on each channel, hoisted to the JS side because the Kelvin fit is a
|
||||||
|
// curve (colorUtils.whiteBalanceGain). 5500K / 0 hands over (1,1,1), so a mask
|
||||||
|
// that never touched the pair costs three multiplies by one and nothing else.
|
||||||
|
c = clamp(c * wb, half3(0.0), half3(1.0));
|
||||||
c = (c - half(0.5)) * half(1.0 + a.y) + half(0.5);
|
c = (c - half(0.5)) * half(1.0 + a.y) + half(0.5);
|
||||||
half l = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722));
|
half l = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722));
|
||||||
// The tonal four, in TONE_SKSL's own formulas: a mask's HIGHLIGHT is meant to
|
// The tonal four, in TONE_SKSL's own formulas: a mask's HIGHLIGHT is meant to
|
||||||
@@ -270,7 +294,7 @@ ${spatial ? ` // The patch's dark channel for DEHAZE, one tap per DEHAZE_PA
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}` : ' half dark = half(0.0);'}
|
}` : ' half dark = half(0.0);'}
|
||||||
c.rgb = mix(c.rgb, maskAdjust(c.rgb, adj[${i}], tone[${i}], fx[${i}], dark${spatial ? ', blurred.eval(pos).rgb, air.xyz' : ''}), half(a));
|
c.rgb = mix(c.rgb, maskAdjust(c.rgb, wb[${i}].rgb, adj[${i}], tone[${i}], fx[${i}], dark${spatial ? ', blurred.eval(pos).rgb, air.xyz' : ''}), half(a));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -293,6 +317,7 @@ uniform float4 rads[${count}];
|
|||||||
uniform float4 adj[${count}];
|
uniform float4 adj[${count}];
|
||||||
uniform float4 tone[${count}];
|
uniform float4 tone[${count}];
|
||||||
uniform float4 fx[${count}];
|
uniform float4 fx[${count}];
|
||||||
|
uniform float4 wb[${count}];
|
||||||
uniform float4 size;
|
uniform float4 size;
|
||||||
${spatial ? 'uniform shader blurred;\nuniform float4 air;' : ''}
|
${spatial ? 'uniform shader blurred;\nuniform float4 air;' : ''}
|
||||||
${adjustFn(spatial)}
|
${adjustFn(spatial)}
|
||||||
|
|||||||
@@ -2468,9 +2468,11 @@ export function Workspace() {
|
|||||||
// against the frame's blurred reference, DEHAZE through the dark channel. Each
|
// against the frame's blurred reference, DEHAZE through the dark channel. Each
|
||||||
// one means inside the mask what the row of the same name means on the whole
|
// one means inside the mask what the row of the same name means on the whole
|
||||||
// frame, down to the -100..+100 the frame-wide rows read (the mask keeps the
|
// frame, down to the -100..+100 the frame-wide rows read (the mask keeps the
|
||||||
// look's own ±10 unit, and the ×10 is the slider's — see paramDefs).
|
// look's own ±10 unit, and the ×10 is the slider's — see paramDefs). TINT is on
|
||||||
|
// this helper too: same ±10 unit, same ×10 ruler, same green↔magenta gain, read
|
||||||
|
// on the mask's pixels — a local white balance.
|
||||||
const maskKnobRow = (
|
const maskKnobRow = (
|
||||||
key: 'highlights' | 'shadows' | 'whites' | 'blacks' | 'clarity' | 'dehaze',
|
key: 'highlights' | 'shadows' | 'whites' | 'blacks' | 'clarity' | 'dehaze' | 'tint',
|
||||||
label: string
|
label: string
|
||||||
): SliderDef => {
|
): SliderDef => {
|
||||||
const v = selMask?.[key] ?? 0;
|
const v = selMask?.[key] ?? 0;
|
||||||
@@ -2492,6 +2494,24 @@ export function Workspace() {
|
|||||||
const maskRulers: SliderDef[] = !selMask
|
const maskRulers: SliderDef[] = !selMask
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
|
// The WB pair first, in the LIGHT column's own place and on its own
|
||||||
|
// scales: kelvin 2500..10000 as itself (a temperature is not a percentage,
|
||||||
|
// so it keeps its own ruler), and TINT through the same ×10 the frame-wide
|
||||||
|
// row wears. Same two knobs, same gain, the mask's pixels only.
|
||||||
|
{
|
||||||
|
key: 'mask-temperature',
|
||||||
|
label: 'COLOR TEMP',
|
||||||
|
value: selMask.temperature ?? 5500,
|
||||||
|
min: 2500,
|
||||||
|
max: 10000,
|
||||||
|
step: 100,
|
||||||
|
display: `${selMask.temperature ?? 5500}K`,
|
||||||
|
swatch: temperatureSwatch(selMask.temperature ?? 5500),
|
||||||
|
edited: (selMask.temperature ?? 5500) !== 5500,
|
||||||
|
onChange: (v) => setMaskKnob({ temperature: v }),
|
||||||
|
onReset: () => setMaskKnob({ temperature: 5500 }),
|
||||||
|
},
|
||||||
|
maskKnobRow('tint', 'TINT'),
|
||||||
{
|
{
|
||||||
key: 'mask-exposure',
|
key: 'mask-exposure',
|
||||||
label: 'EXPOSURE',
|
label: 'EXPOSURE',
|
||||||
|
|||||||
Reference in New Issue
Block a user