6d60d452e0
A gradient mask had its own tone formula and its own exposure, and both disagreed with the frame's. Before any of this was tidied, the mask ran a smoothstep luma lift with an arbitrary 0.55..1.35 chroma clamp while the frame moved the knots of a four-zone ramp, and the mask's exposure was a stop on sRGB-encoded values while the frame's was a stop on light. Two names, four moves, and the same slider meant different things depending on whether the pixels were inside the shape you drew — the divergence §3.3 of the Android port's compat doc warns about. The maths is one string now (TONE_MATH_SKSL, interpolated by both passes): the ramp the four knots build, the hue-preserving rebuild behind it, the transfer pair, and exposureMove. A mask calls the same functions the frame calls. The rebuild carries the chroma instead of re-scaling it. Lightness takes the curve and the colour rides the difference — the channel differences move by ONE shared scale k, pulled back only where the cube has no room left. The doc's ratio (R_new = R_old * Luma_new / Luma_old) was the old reading and it is exact only while nothing clips: a channel past 1.0 stops being scaled with its neighbours and the hue goes with it. Measured on a flat patch frame, a skin tone at 24.0° came back at 48.0° at HIGHLIGHT +100, a warm white at 37° at 57.4°, and under L = 0.5 the same ratio multiplied a near-black pixel's cast by x30 — colour noise amplified, which is why the 0.55..1.35 clamp was there. The scale is the chroma's own now: over 135 knob combinations on seven colours and five greys, the ramp moves the luma and the hue does not move at all (Δ < 1e-9°). EXPOSURE gets the same treatment, which is what the second half of the request was: the linear domain decides where the luma is going, and the pixel is rebuilt onto it through the same lightMove. The old pass multiplied the three channels in linear light, so +1 EV clipped them by three different amounts: measured on the scratchpad probe, 29.2° of hue drift on a skin tone at +1 EV and 33.3° at +2, against 0.00° here. The stop is applied as a ratio on the pixel's own encoded luma rather than pointed straight at the encoded linear target, which is what makes the knob exactly the identity at 0 EV — the transfer does not commute with the luma weights, so pointing at it brightened a colour by a couple of code values even at zero. A grey is the knob it always was: 128 through +1 EV is 176, the same number the linear per-channel multiply put there, so nothing a user has dialled in moves. Verified: `npx tsc --noEmit` clean, `npm run build` clean. highlight-knee-check now runs EXPOSURE_SKSL for real — compiled with CanvasKit and four pixels pushed through it, agreeing with the twin to a code value on a grey at +1 EV (176), a skin tone at +1 EV and +2 EV, and a shadow at -2 EV; it also pins the hue, the cube, the identity at 0 EV and the black pixel that has no light to move. mask-wb-check compiles the mask pass and pushes the same stops through it: 128 through +1 EV is 176, through -1 EV is 92, +2 EV lands the channel on the ceiling at 255 and holds the hue within 3°. auto-tone-check, preview-match-check, white-level-check, raw-develop-check, half-check and roll-walk-check all pass. Live on the built bundle in a 1440x950 browser: the LIGHT panel's EXPOSURE +1 EV takes the mid grey of a flat patch frame from 0.502 to 0.690 (a stop on light gives 0.686) and moves no patch's hue at -1 EV (Δ 0.00°), and a linear gradient mask's EXPOSURE +1 EV and HIGHLIGHT +100 move the pixels inside the mask (luma 185.9 -> 211.1 and 185.9 -> 197.5) while the corner outside it does not move at all (220.2 -> 220.2), with no console error. Co-authored-by: PenguinHarness <noreply@penguin.local>
325 lines
17 KiB
TypeScript
325 lines
17 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;
|
|
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 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, 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));
|
|
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;' : ''}
|
|
${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;
|
|
}
|
|
`;
|
|
}
|