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:
2026-09-29 17:35:19 +07:00
parent bd57dd72b3
commit 3b92e4e2d2
5 changed files with 264 additions and 24 deletions
+35 -10
View File
@@ -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)}