Files
RecipesCam/docker/frontend/shared/utils/gradientMask.ts
T
3dtours 3b92e4e2d2 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>
2026-09-29 17:35:19 +07:00

330 lines
18 KiB
TypeScript

import type { GradientMask } from '../types';
import { whiteBalanceGain } from './colorUtils';
import {
CLARITY_GAIN,
DEHAZE_FLOOR_T,
DEHAZE_MAX_OMEGA,
DEHAZE_PATCH_STEP,
DEHAZE_PATCH_TAPS,
} from './toneShader';
// FX tab > LINEAR / RADIAL GRADIENT — Lightroom's two gradient masks, the local
// adjustments that are a SHAPE rather than a whole-frame knob.
//
// HEAL and MOSAIC repair and hide; these two grade. A shape is dragged on the
// photo, and inside it (through the alpha the shape's own maths produces) the
// three knobs below move the pixels and nothing outside them does. A linear
// gradient is the shape whose alpha is a straight ramp across the drag; a radial
// one is an ellipse whose alpha holds full until its feather and dies at its
// rim. Two shapes, one shader: the kind is a number in the mask's own uniform
// and the block that reads it branches, because that is the whole difference
// between them.
//
// The maths is the GLSL spec these were asked for (its scratchpad md, sections
// 1 and 2 for the masks and the blend shader for the colour): the linear one
// projects the pixel onto the drag with a dot product and smoothsteps the
// result; the radial one moves the pixel into the ellipse's own frame, rotates
// it back by -angle, divides by the semi-axes and smoothsteps inward from the
// rim. Written in SkSL because that is what this renderer is (Canvaskit, see
// skiaShim.ts), stage for stage: the one pass the spec asks for per mask, in
// the order the user drew them, each reading what the one before it left.
//
// The spec's section 4 — "the system needs to restrict all the above effects to
// operate only within the mask's area" — is the rest of the block below: the
// smoothstep soft masks that carry HIGHLIGHT and SHADOW, the two ends WHITE and
// BLACK move, and the two spatial ones (CLARITY against the frame's own blur,
// DEHAZE through the dark channel) that the caller hands in the blurred
// reference for and the atmospheric light for. Every one of them rides the same
// alpha the shape produces and lands through the same `mix`, so a mask at half
// strength is half of the move.
export const MASK_KIND = { linear: 0, radial: 1 } as const;
// Exposure is stored as the EV itself — the spec's own -5..+5 — because that is
// what `pow(2.0, e)` reads, and a stop is a stop whatever the app's slider units
// are elsewhere. The other two are -10..+10 like every other knob, and are turned
// into the spec's -1..+1 on the way to the shader.
export const MASK_EXPOSURE_MAX = 5;
// How much of the semi-axis a fresh ellipse fades over. Half: the edge is soft
// enough to be a gradient mask rather than a cut-out, and every pixel of the
// ellipse is still on the shape's own side of the rim.
export const MASK_DEFAULT_FEATHER = 0.5;
// The smallest drag that is a shape rather than a press: a hundredth of the
// width. Below this the mask is dropped instead of laid down — the same floor
// readMasks refuses a stored one at.
export const MASK_MIN = 0.01;
const num = (v: unknown, fallback: number) => {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
};
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
// same guard HEAL's and MOSAIC's lists get, so a hand-written or older file
// cannot produce a shape the overlay and the renderer disagree about. A mask
// with no shape (a linear drag of no length, an ellipse of no radius) is
// dropped: it would paint nothing and could never be taken hold of on the photo.
// What readMasks hands back: the stored mask with every knob resolved to a
// number. The two spatial ones and the four tonal ones are optional in the
// stored type (a mask saved before they existed has none), so the reader's
// return type is the one that says they are there.
export type ReadMask = GradientMask & {
highlights: number;
shadows: number;
whites: number;
blacks: number;
clarity: number;
dehaze: number;
temperature: number;
tint: number;
};
export function readMasks(masks: GradientMask[] | undefined): ReadMask[] {
if (!Array.isArray(masks)) return [];
return masks
.filter((m) => m?.kind === 'linear' || m?.kind === 'radial')
.map((m) => ({
kind: m.kind,
x: clamp01(num(m.x, 0.5)),
y: clamp01(num(m.y, 0.5)),
ex: clamp01(num(m.ex, 0.5)),
ey: clamp01(num(m.ey, 0.5)),
rx: Math.max(0, num(m.rx, 0)),
ry: Math.max(0, num(m.ry, 0)),
angle: num(m.angle, 0),
feather: clamp01(num(m.feather, MASK_DEFAULT_FEATHER)),
exposure: clampEV(num(m.exposure, 0)),
contrast: clampA(num(m.contrast, 0)),
saturation: clampA(num(m.saturation, 0)),
// The spec's section 4 knobs: the two tone soft masks, the two ends, and
// the two spatial moves. All -10..+10 like every other knob here, all 0 on
// a mask stored before they existed.
highlights: clampA(num(m.highlights, 0)),
shadows: clampA(num(m.shadows, 0)),
whites: clampA(num(m.whites, 0)),
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));
}
// The two spatial knobs read what the pass cannot build on its own: CLARITY the
// frame's own blurred reference, handed in as a second child, and both of them
// the frame's atmospheric light. One question, asked in one place, so the
// renderer builds the blur for exactly the masks with a spatial knob on them and
// the effect is cached for the same answer, and so the shader is built with the
// spatial block (DEHAZE's own dark channel lives in it).
export function masksHaveSpatial(masks: GradientMask[]): boolean {
return readMasks(masks).some((m) => m.clarity !== 0 || m.dehaze !== 0);
}
// 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,
// 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,
height: number,
air?: [number, number, number] | null
): Float32Array {
const list = readMasks(masks);
const n = list.length;
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);
u.set([m.rx, m.ry, m.angle, m.feather], (n + i) * 4);
// The knobs are handed over in the renderer's own units: EV for exposure,
// -1..1 for the other two, which is what the spec's blend shader reads.
u.set(
[m.exposure, m.contrast / 10, m.saturation / 10, MASK_KIND[m.kind]],
(2 * n + i) * 4
);
// 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], 6 * n * 4);
if (air) u.set([air[0], air[1], air[2], 0], (6 * n + 1) * 4);
return u;
}
// The colour inside a mask, in the spec's own order and, for the knobs the app
// also has frame-wide, in the app's own formulas: exposure first (a power of
// two, so a stop is a stop), then contrast about the middle, then saturation as
// a mix away from the pixel's own REC-709 luma. Then the spec's section 2 and 3
// on top — HIGHLIGHT and SHADOW through the two smoothstep soft masks its own
// formula names, WHITE and BLACK as the per-channel point moves TONE_SKSL
// makes, and the two spatial ones against the blurred reference the caller hands
// in. Every one of them means inside the mask what it means on the whole frame
// (toneShader.ts is the reference the four tonal ones are written from), because
// the same name on the same knob should not be two different moves. The result
// is clamped to the range a file can hold — the spec's own guard, and it is
// `mix`ed back over the base by the mask's alpha, so a mask at half strength is
// half of the move rather than the whole of it.
//
// `blur` is the frame's bilateral reference (the same one CLARITY uses
// frame-wide) and `air` the atmospheric light; both are the constants 0 when the
// shader was built without the second child, and then the two spatial knobs are
// left out of the pass — the caller only builds that child for masks that ask
// 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, 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
// be the same move as the whole-frame HIGHLIGHT on a smaller area, not a
// second opinion about what the name means. HIGHLIGHT and SHADOW are additive
// shifts of the luma, HIGHLIGHT weighted by the headroom left (1 - l) so it
// cannot drag a blown white to grey, and the colour difference rides along at
// a damped gain (TONE_SKSL's own cg) so a lift or a pull cannot collapse a
// colour. WHITE and BLACK are per-channel point moves, cubic in each channel's
// own distance from the end it owns: the toe and the shoulder move, the
// midtones do not, and a white that is lowered stays white.
// The spec's soft masks, computed in float and narrowed: smoothstep on half is
// one more type the shader does not have to guess at.
float lf = clamp(float(l), 0.0, 1.0);
float mh = smoothstep(0.50, 1.00, lf);
float ms = 1.0 - smoothstep(0.00, 0.55, lf);
half lifted = half(clamp(lf + tone.x * mh * (1.0 - lf) + tone.y * 0.34 * ms, 0.0, 1.0));
half cg = clamp(lifted / max(l, half(0.0004)), half(0.55), half(1.35));
c = clamp(half3(lifted) + (c - half3(l)) * cg, half3(0.0), half3(1.0));
half3 dk = half3(1.0) - c;
c = clamp(c + half(tone.w * 0.18) * dk * dk * dk + half(tone.z * 0.18) * c * c * c, half3(0.0), half3(1.0));
half nl = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722));
c = mix(half3(nl), c, half(1.0 + a.z));
${spatial ? ` // DEHAZE before CLARITY, the frame-wide order and for the frame-wide reason:
// sharpening haze only makes it read as detail.
if (fx.y != 0.0) {
// DEHAZE (doc section 3.2), DEHAZE_SKSL's own formula on the mask's pixels:
// dark is the patch's dark channel, the MINIMUM of min(r,g,b)/A over a
// neighbourhood — read by the block below, which is where the frame's pixels
// are — so a patch with anything genuinely dark in it is not haze and is left
// alone (the patch average this used to read called every patch hazy). Signed
// like the frame-wide knob: below zero t rises above 1 and the same
// expression puts the scattered light back.
half3 aa = half3(half(max(air.x, 0.05)), half(max(air.y, 0.05)), half(max(air.z, 0.05)));
half t = clamp(half(1.0 - fx.y * ${DEHAZE_MAX_OMEGA} * clamp(dark, half(0.0), half(1.0))), half(${DEHAZE_FLOOR_T}), half(${1 + DEHAZE_MAX_OMEGA}));
c = clamp((c - aa) / t + aa, half3(0.0), half3(1.0));
}
// CLARITY (doc section 3.1): the pixel against its own blurred surroundings.
// Positive sharpens. Negative SOFTENS toward that same reference — a masked
// CLARITY -10 is a soften of the mask's own detail, which is the property the
// knob is named for, and not the inverted unsharp it used to be (that only
// sank the mask's whites and left its contrast where it was).
// The reference is the bilateral one (CLARITY_BLUR_SPAN, 6% of the frame) and
// the frame-wide negative CLARITY mixes toward that same reference with the
// same factor (CLARITY_BLEND_SKSL), so the two readings of the knob are one
// move on two different areas.
if (fx.x > 0.0) {
c = clamp(c + half3(half(fx.x * ${CLARITY_GAIN.toFixed(1)})) * (c - blur), half3(0.0), half3(1.0));
} else if (fx.x < 0.0) {
c = mix(c, blur, half(clamp(-fx.x, 0.0, 1.0)));
}
` : ''} return clamp(c, half3(0.0), half3(1.0));
}
`;
// One unrolled block per mask, for the reason heal.ts unrolls its own: SkSL
// indexes a uniform array by constant only, so the shader is built for the count
// it is handed rather than for a capacity, and the renderer caches them by count
// (exportEngine's maskEffectFor). A linear mask's alpha is the spec's dot
// product, and its zero-length guard is the filter in readMasks rather than a
// branch here: a mask with no length is not a mask. A radial mask's alpha is
// 1 inside the feather and 0 at the rim.
const maskBlock = (i: number, spatial: boolean) => `
{
float a = 0.0;
if (adj[${i}].w < 0.5) {
float2 d = (rects[${i}].zw - rects[${i}].xy) * size.xy;
float len2 = dot(d, d);
float t = dot(pos - rects[${i}].xy * size.xy, d) / len2;
a = smoothstep(0.0, 1.0, clamp(t, 0.0, 1.0));
} else {
float2 st = (pos - rects[${i}].xy * size.xy) / size.x;
float ca = cos(-rads[${i}].z);
float sa = sin(-rads[${i}].z);
float2 r = float2(st.x * ca - st.y * sa, st.x * sa + st.y * ca);
float d = length(r / max(rads[${i}].xy, float2(1e-5)));
a = 1.0 - smoothstep(max(0.0, 1.0 - rads[${i}].w), 1.0, d);
}
if (a > 0.0) {
${spatial ? ` // The patch's dark channel for DEHAZE, one tap per DEHAZE_PATCH_STEP of
// the frame's width — the same fraction the frame-wide pass reads, so the
// mask's DEHAZE and the frame's are the same neighbourhood on any size of
// render. Skipped when the mask has no DEHAZE, so a CLARITY-only mask
// pays nothing for it.
half dark = half(1.0);
if (fx[${i}].y != 0.0) {
half3 aa = half3(half(max(air.x, 0.05)), half(max(air.y, 0.05)), half(max(air.z, 0.05)));
float stp = max(1.0, size.x * ${DEHAZE_PATCH_STEP});
for (int j = -${DEHAZE_PATCH_TAPS}; j <= ${DEHAZE_PATCH_TAPS}; j++) {
for (int k = -${DEHAZE_PATCH_TAPS}; k <= ${DEHAZE_PATCH_TAPS}; k++) {
half3 p = clamp(img.eval(pos + float2(float(k), float(j)) * stp).rgb, half3(0.0), half3(1.0));
dark = min(dark, min(min(p.r / aa.r, p.g / aa.g), p.b / aa.b));
}
}
}` : ' half dark = half(0.0);'}
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));
}
}
`;
// The pass. It reads the pixels the pipeline has already built — the grade, the
// grain, the vignette — and writes the local adjustments back over them, one
// mask at a time, so a mask over another mask reads the first one's result
// exactly as a stack of local adjustments does. It runs BEFORE HEAL on purpose:
// a repair laid inside a mask then borrows pixels that already carry the mask's
// light, which is what makes it match its surroundings, and a MOSAIC over a mask
// hides what the mask left. The frame the masks are of comes in as `size`, the
// same uniform HEAL and MOSAIC take. `spatial` adds the two things only CLARITY
// and DEHAZE read: the frame's own blurred reference as a second child, whose
// image is in the same coordinates the pass runs in, and the atmospheric light.
export function gradientMaskSkSL(count: number, spatial = false): string {
return `
uniform shader img;
uniform float4 rects[${count}];
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)}
half4 main(float2 pos) {
half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => maskBlock(i, spatial)).join('')}
return c;
}
`;
}