import type { GradientMask } from '../types'; // 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. // // ponytail: the spec's next rung — Highlights/Shadows isolated with pow(luma, 3) // weight masks — is not here. Its own section calls it an upgrade, and the three // knobs are what "gradient mask" means until a photo shows a sky that needs // rescuing apart from the grass under it. The same rung holds Lightroom's // per-mask invert and colour/tone ranges. 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); 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. export function readMasks(masks: GradientMask[] | undefined): GradientMask[] { 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)), })) .filter((m) => (m.kind === 'linear' ? Math.hypot(m.ex - m.x, m.ey - m.y) > MASK_MIN : m.rx > 0 && m.ry > 0)); } // The uniform block the shader for `n` masks reads: the shapes, the ellipse // parameters, the knobs with the kind, then the frame the fractions are of. // 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): Float32Array { const list = readMasks(masks); const n = list.length; const u = new Float32Array((3 * n + 1) * 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 ); } u.set([width, height, 0, 0], 3 * n * 4); return u; } // The colour inside a mask, in the spec's own order and its 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. 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. const adjustFn = ` half3 maskAdjust(half3 c, float3 a) { c = c * half(pow(2.0, a.x)); c = (c - half(0.5)) * half(1.0 + a.y) + half(0.5); half l = dot(c, half3(0.2126, 0.7152, 0.0722)); c = mix(half3(l), c, half(1.0 + a.z)); 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) => ` { 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) { c.rgb = mix(c.rgb, maskAdjust(c.rgb, adj[${i}].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. export function gradientMaskSkSL(count: number): string { return ` uniform shader img; uniform float4 rects[${count}]; uniform float4 rads[${count}]; uniform float4 adj[${count}]; uniform float4 size; ${adjustFn} half4 main(float2 pos) { half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => maskBlock(i)).join('')} return c; } `; }