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:
@@ -1,4 +1,5 @@
|
||||
import type { GradientMask } from '../types';
|
||||
import { whiteBalanceGain } from './colorUtils';
|
||||
import {
|
||||
CLARITY_GAIN,
|
||||
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 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);
|
||||
|
||||
// The stored masks, made readable: numbers, inside the frame, one kind — the
|
||||
@@ -75,6 +80,8 @@ export type ReadMask = GradientMask & {
|
||||
blacks: number;
|
||||
clarity: number;
|
||||
dehaze: number;
|
||||
temperature: number;
|
||||
tint: number;
|
||||
};
|
||||
|
||||
export function readMasks(masks: GradientMask[] | undefined): ReadMask[] {
|
||||
@@ -103,6 +110,11 @@ export function readMasks(masks: GradientMask[] | undefined): ReadMask[] {
|
||||
blacks: clampA(num(m.blacks, 0)),
|
||||
clarity: clampA(num(m.clarity, 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));
|
||||
}
|
||||
@@ -119,11 +131,12 @@ export function masksHaveSpatial(masks: GradientMask[]): boolean {
|
||||
|
||||
// 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,
|
||||
// then the frame the fractions are of — and, when the caller has one, the
|
||||
// atmospheric light the dehaze reads. Declaration order, arrays expanded — one
|
||||
// buffer is one upload per render, the same shape healUniforms and
|
||||
// mosaicUniforms use. Its length is a function of the list, not a fixed
|
||||
// capacity, because the shader carries exactly the masks the recipe holds.
|
||||
// the white balance gain, then the frame the fractions are of — and, when the
|
||||
// caller has one, the atmospheric light the dehaze reads. Declaration order,
|
||||
// arrays expanded — one buffer is one upload per render, the same shape
|
||||
// healUniforms and mosaicUniforms use. Its length is a function of the list, not
|
||||
// a fixed capacity, because the shader carries exactly the masks the recipe
|
||||
// holds.
|
||||
export function maskUniforms(
|
||||
masks: GradientMask[],
|
||||
width: number,
|
||||
@@ -132,7 +145,7 @@ export function maskUniforms(
|
||||
): Float32Array {
|
||||
const list = readMasks(masks);
|
||||
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++) {
|
||||
const m = list[i];
|
||||
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.
|
||||
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);
|
||||
// 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);
|
||||
if (air) u.set([air[0], air[1], air[2], 0], (5 * n + 1) * 4);
|
||||
u.set([width, height, 0, 0], 6 * n * 4);
|
||||
if (air) u.set([air[0], air[1], air[2], 0], (6 * n + 1) * 4);
|
||||
return u;
|
||||
}
|
||||
|
||||
@@ -173,8 +192,13 @@ export function maskUniforms(
|
||||
// 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).
|
||||
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));
|
||||
// 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);
|
||||
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
|
||||
@@ -270,7 +294,7 @@ ${spatial ? ` // The patch's dark channel for DEHAZE, one tap per DEHAZE_PA
|
||||
}
|
||||
}
|
||||
}` : ' 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 tone[${count}];
|
||||
uniform float4 fx[${count}];
|
||||
uniform float4 wb[${count}];
|
||||
uniform float4 size;
|
||||
${spatial ? 'uniform shader blurred;\nuniform float4 air;' : ''}
|
||||
${adjustFn(spatial)}
|
||||
|
||||
Reference in New Issue
Block a user