Files

332 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,
TONE_MATH_SKSL,
} 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
// four tonal-range knobs (the frame-wide ramp, moved on the mask's own pixels),
// 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 `exp2(e)` spends on the light (toneShader.EXPOSURE_SKSL), 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;
vibrance: 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 ?? (m as any).ev, 0)),
contrast: clampA(num(m.contrast, 0)),
saturation: clampA(num(m.saturation ?? (m as any).color, 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 ?? m.highlight, 0)),
shadows: clampA(num(m.shadows ?? m.shadow, 0)),
whites: clampA(num(m.whites ?? m.white, 0)),
blacks: clampA(num(m.blacks ?? m.black, 0)),
clarity: clampA(num(m.clarity, 0)),
dehaze: clampA(num(m.dehaze, 0)),
vibrance: clampA(num(m.vibrance, 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 & vibrance.
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, m.vibrance / 10, 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 every knob the app
// also has frame-wide, in the app's own formulas: EXPOSURE first (one stop of
// LIGHT, through exposureMove), then contrast about the middle, then saturation
// as a mix away from the pixel's own REC-709 luma. Then the four tonal-range
// knobs and the two spatial ones — the four through the frame-wide ramp
// (TONE_MATH_SKSL), CLARITY and DEHAZE against the blurred reference the caller
// hands in. Every one of them means inside the mask what it means on the whole
// frame, because the same name on the same knob must not be two different moves;
// toneShader.ts is the one copy all of them are read from. 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' : ''}) {
// EXPOSURE through the frame-wide function, on the mask's pixels: one stop of
// LIGHT (a linear-light move, so 2^ev is what it multiplies) and a move of the
// luma rather than of the three channels, so the knob brightens a colour
// instead of shifting its hue when a channel reaches the ceiling.
c = half3(exposureMove(vec3(c), 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, through the frame-wide ramp (TONE_MATH_SKSL): a mask's
// HIGHLIGHT is the same move as the whole-frame HIGHLIGHT on a smaller area,
// not a second opinion about what the name means — the four knots, their
// ordering clamp, the 0.50 midpoint no knob reaches, and the luma-preserving
// rebuild are all the frame's. DR is the one knob the ramp also carries that a
// mask does not have, so it is spent as 0 here.
float lf = clamp(float(l), 0.0, 1.0);
c = half3(toneRamp(vec3(c), lf, lf, tone.w, tone.y, tone.x, tone.z, 0.0));
half nl = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722));
// Saturation & Vibrance matching toneShader's exact chroma-masked formula
half mx = max(max(c.r, c.g), c.b);
half mn = min(min(c.r, c.g), c.b);
half chroma = mx > half(0.0001) ? (mx - mn) / mx : half(0.0);
half kv = half(1.0) + half(fx.z) * half(0.75) * (half(1.0) - chroma);
c = clamp(mix(half3(nl), c, half(1.0 + a.z) * kv), half3(0.0), half3(1.0));
${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;' : ''}
${TONE_MATH_SKSL}
${adjustFn(spatial)}
half4 main(float2 pos) {
half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => maskBlock(i, spatial)).join('')}
return c;
}
`;
}