b568fa3fdc
FX had two tools that change the photo where it is — HEAL repairs a speck, MOSAIC hides a patch — and every knob that graded the frame graded all of it. The scratchpad's gradient_mask.md asks for the two local adjustments the phone's own editor has and Lightroom made familiar: a linear gradient and a radial one. This is that spec, written for the renderer this app actually has. A mask is a SHAPE rather than a value, so it is dragged rather than turned: the LINEAR chip arms a ramp and the next drag on the photo is its two ends — zero at the press, one at the release, the spec's own convention, which is what makes the same gesture a wide fade or a hard edge — and RADIAL arms an ellipse whose centre is the press, whose semi-axes are the drag's own distance and whose axis lies along the direction the hand went, so the circle a drag describes is the circle the mask starts life as. Both shapes keep a pin (the whole shape travels by it) and, while chosen, the handles that move the ends or the axes and the one that turns the ellipse; what is drawn is the shape the render will read, so the ramp and the rim are visible before a knob is moved. Inside the shape, three knobs grade in the spec's own order and its own maths: exposure as `pow(2.0, e)` in stops (its -5..+5), contrast about the middle, saturation as a mix away from the pixel's own REC-709 luma — the mixer's -10..+10 read as the spec's -1..+1 — and a radial mask adds the feather it fades over, which is the fraction of its own axis the alpha holds full before it dies at the rim. Several masks run in the order they were drawn, each reading what the one before it left, which is what a stack of local adjustments is. The maths is GLSL in the md and the renderer is Skia (canvaskit-wasm, SkSL runtime effects), so it is ported stage for stage: one pass, after the frame-wide grade and the vignette and before HEAL, because a local adjustment is part of the look and not a repair — the pixels a repair borrows are then meant to carry the mask's light already. Preview and export both come through renderPhoto, so the file carries the masks the stage is showing by construction, and the shape and the knobs ride in the recipe's own JSON, which is what makes them survive a save. The chips sit with HEAL and MOSAIC because all four take the pointer on the photo, and they are exclusive with every other armed tool, the eyedropper included — while a mask tool is armed the layer takes the photo, so a drag means "draw the next shape" and a press on a pin means "take hold of this one", which is why the shapes already laid are answered through their pin and handles alone. A knob drag on a mask is one undo step, a shape drag is one more, a press that only chose a mask records nothing at all, and RESET is the way back with the whole frame as it was imported. ponytail: the spec's own "Gợi ý nâng cấp" rung — Highlights and Shadows isolated with pow(luma, 3) and pow(1-luma, 3) weight masks — is not here, and neither is Lightroom's per-mask invert and colour/tone range. The three knobs are what "gradient mask" means until a photo shows a sky that has to be rescued apart from the grass under it; the md itself calls it an upgrade, not the feature. Verified: tsc clean; mask-probe 35/0 on the dev server and again on 8090 (the two chips, both shapes drawn and moved and turned, the ramp read off the pixels — 61 -> 244 at the release and 61 at the press — the feather read off the rings, DELETE/UNDO/REDO/CLEAR, and one gesture one undo step); brush-edit 33/0, heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/award-column/otp-code/ tone-curve all ALL PASS, backend 180/0.
170 lines
8.1 KiB
TypeScript
170 lines
8.1 KiB
TypeScript
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;
|
|
}
|
|
`;
|
|
}
|