web: let FX carry Lightroom's two gradient masks, and grade inside them

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.
This commit is contained in:
2026-09-24 10:42:58 +07:00
parent 01af863fd8
commit b568fa3fdc
7 changed files with 845 additions and 17 deletions
+40
View File
@@ -120,6 +120,41 @@ export interface MosaicSpot {
r: number;
}
// FX tab > LINEAR / RADIAL GRADIENT. One local adjustment: a shape the user
// dragged on the photo, the three knobs it carries, and the alpha the render
// reads them at — Lightroom's gradient masks, with the maths of the GLSL spec
// they were asked for (scratchpad gradient_mask.md) ported to SkSL.
//
// Everything positional is a fraction of the render, the same measurement HEAL
// and MOSAIC store and for the same reason: the preview and the export render
// the same photo at two sizes, and only a fraction lands a shape on both. The
// semi-axes are fractions of the WIDTH, so an ellipse is the same ellipse on a
// portrait photo as on a landscape one, exactly as HEAL's circle is.
//
// LINEAR: (x, y) and (ex, ey) are the two ends of the drag, and the alpha runs
// from 0 at the start to 1 at the end — the drag IS the feather, which is what
// makes the same gesture a wide fade or a hard edge. RADIAL: (x, y) is the
// centre, (rx, ry) the semi-axes, `angle` the ellipse's rotation in radians and
// `feather` the fraction of the axis the alpha fades over, inward from the rim.
export interface GradientMask {
kind: 'linear' | 'radial';
x: number;
y: number;
ex: number;
ey: number;
rx: number;
ry: number;
angle: number;
feather: number;
// The three local knobs. Exposure is in EV, the -5..+5 the spec's blend shader
// is written for (its own `pow(2.0, e)`); contrast and saturation are the
// spec's -1..+1 as the app's own -10..+10 knobs, so 0 is every one of them and
// nothing moves until the user moves it.
exposure: number;
contrast: number;
saturation: number;
}
export interface ColorAdjustments {
exposure: number; // -10 to +10 (mapped to matrix multiplier or offset)
contrast: number; // -10 to +10
@@ -161,6 +196,11 @@ export interface ColorAdjustments {
// FX tab > MOSAIC. The spots the hiding brush covered, in the order they were
// laid down. Absent = nothing was hidden, and the pass is not built at all.
mosaic?: MosaicSpot[];
// FX tab > LINEAR / RADIAL GRADIENT. The masks the user drew, in the order
// they were laid down: each is applied to what the one before it left, the
// way a stack of local adjustments reads in Lightroom. Absent = no local
// adjustments at all, and no pass is built.
masks?: GradientMask[];
exposureCompensation: number; // -3 to +3 EV. Camera: AE bias (hardware). Library: 2^EV matrix gain.
}
@@ -0,0 +1,169 @@
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;
}
`;
}
+159 -2
View File
@@ -30,6 +30,7 @@ import {
type CropRect,
type FrameId,
type GPSInfo,
type GradientMask,
type HealSpot,
type HslBand,
type HslBandId,
@@ -41,6 +42,7 @@ import { grainPerInch, grainStockFor } from '../shared/utils/grainShader';
import { curveIsActive } from '../shared/utils/toneCurve';
import { HEAL_DEFAULT_R } from '../shared/utils/heal';
import { MOSAIC_DEFAULT_R } from '../shared/utils/mosaic';
import { MASK_DEFAULT_FEATHER, MASK_EXPOSURE_MAX } from '../shared/utils/gradientMask';
import type { MsgKey } from './i18n/vi';
// Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there.
@@ -300,6 +302,15 @@ export function Workspace() {
const [brushTool, setBrushTool] = useState<'heal' | 'mosaic' | null>(null);
const [healR, setHealR] = useState(HEAL_DEFAULT_R);
const [mosaicR, setMosaicR] = useState(MOSAIC_DEFAULT_R);
// FX's gradient masks: which shape the next drag on the photo lays down (null
// for neither), the masks the recipe holds, and which of them is chosen — the
// one whose shape is on the photo and whose knobs are in the panel beside it.
const [maskTool, setMaskTool] = useState<'linear' | 'radial' | null>(null);
const [maskSel, setMaskSel] = useState<number | null>(null);
const masks = recipe.adjustments.masks ?? [];
// The one mask the column beside the chips speaks about: its shape is on the
// photo and its knobs are in the panel. Null while none is chosen.
const selMask = maskSel !== null ? masks[maskSel] ?? null : null;
const healSpots = recipe.adjustments.heal ?? [];
const mosaicSpots = recipe.adjustments.mosaic ?? [];
const [sample, setSample] = useState<{ r: number; g: number; b: number } | null>(null);
@@ -978,6 +989,68 @@ export function Workspace() {
[brushTool, remember, setAdjustment]
);
// FX's two gradient masks are a list the user draws rather than a knob, but
// they are written the same way the brush's spots are: a shape dragged onto
// the photo is one undo step, and the knobs on the chosen shape are one more
// however far they travel — the first move of a drag takes the snapshot and
// the rest ride on it. An empty list drops the field, the way CLEAR does.
const editMasks = useCallback(
(list: GradientMask[], undo: boolean) => {
if (undo) remember();
setAdjustment({ masks: list.length ? list : undefined });
setMaskSel(list.length ? Math.min(maskSel ?? 0, list.length - 1) : null);
},
[maskSel, remember, setAdjustment]
);
const addMask = useCallback(
(mask: GradientMask) => {
remember();
const list = lookRef.current?.recipe.adjustments.masks ?? [];
setAdjustment({ masks: [...list, mask] });
// The shape just drawn is the one being edited: its handles and its knobs
// are how the drag that made it is finished.
setMaskSel(list.length);
},
[remember, setAdjustment]
);
const deleteMask = useCallback(() => {
if (maskSel === null) return;
remember();
const list = masks.filter((_, i) => i !== maskSel);
setAdjustment({ masks: list.length ? list : undefined });
setMaskSel(null);
}, [masks, maskSel, remember, setAdjustment]);
const clearMasks = useCallback(() => {
remember();
setAdjustment({ masks: undefined });
setMaskSel(null);
}, [remember, setAdjustment]);
// One knob of the chosen mask, through the same one-edit-per-gesture door as
// every other slider. EXP is the spec's own EV (-5..+5); CON and SAT are the
// app's -10..+10, which the shader turns into the spec's -1..+1.
const setMaskKnob = useCallback(
(patch: Partial<GradientMask>) => {
if (maskSel === null) return;
const list = masks.slice();
const cur = list[maskSel];
if (!cur) return;
list[maskSel] = { ...cur, ...patch };
setAdjustmentOnce({ masks: list });
},
[masks, maskSel, setAdjustmentOnce]
);
// A mask that leaves the list — RESET, or an older file loaded over it — takes
// the selection with it: the knobs must never speak about a shape the photo no
// longer holds.
useEffect(() => {
if (maskSel !== null && maskSel >= masks.length) setMaskSel(null);
}, [maskSel, masks.length]);
// The knobs of a freshly drawn or newly chosen mask are a new gesture: the
// next drag takes its own snapshot instead of riding on the drawing one.
useEffect(() => {
sliderEditRef.current = false;
}, [maskSel]);
// FRAME's STRAIGHTEN rides the same one-edit-per-gesture rule as a knob, so
// dragging the ruler is one undo step instead of one per degree.
const setStraightenOnce = useCallback(
@@ -2153,6 +2226,9 @@ export function Workspace() {
|| markStyle.color !== DEFAULT_MARK_STYLE.color || markStyle.size !== DEFAULT_MARK_STYLE.size
|| markStyle.x !== DEFAULT_MARK_STYLE.x || markStyle.y !== DEFAULT_MARK_STYLE.y
|| markStyle.font !== DEFAULT_MARK_STYLE.font
// A gradient mask is a change like any other: while one is on the photo,
// RESET is the way back to the frame as it was imported.
|| masks.length > 0
|| useGeotag
|| (Object.keys(DEFAULT_GPS_STYLE) as (keyof GpsStyle)[]).some((k) => gpsStyle[k] !== DEFAULT_GPS_STYLE[k]);
}, [recipe, simId, frameId, crop, rotation, straighten, markOn, markText, markStyle, useGeotag, gpsStyle]);
@@ -2347,6 +2423,7 @@ export function Workspace() {
onClick: () => {
// The tools that take the pointer on the photo never share it.
setPicking(false);
setMaskTool(null);
setBrushTool((v) => (v === 'heal' ? null : 'heal'));
},
},
@@ -2361,12 +2438,31 @@ export function Workspace() {
amberValue: mosaicSpots.length > 0,
onClick: () => {
setPicking(false);
setMaskTool(null);
setBrushTool((v) => (v === 'mosaic' ? null : 'mosaic'));
},
},
...(mosaicSpots.length
? [{ key: 'mosaic-clear', label: 'CLEAR', onClick: clearMosaicSpots }]
: []),
// LINEAR and RADIAL GRADIENT are the tab's other two tools that take
// the pointer: a chip arms one and the next drag on the photo lays the
// shape down, inside which the column beside it grades the pixels. The
// chip carries the shape's own name rather than its count, so CLEAR
// below it can, and the pair sits with HEAL and MOSAIC because all
// four change the photo where it is rather than the look on top of it.
...(['linear', 'radial'] as const).map((kind): ChipDef => ({
key: kind,
label: kind.toUpperCase(),
active: maskTool === kind,
amberValue: masks.some((m) => m.kind === kind),
onClick: () => {
setPicking(false);
setBrushTool(null);
setMaskTool((v) => (v === kind ? null : kind));
},
})),
...(masks.length ? [{ key: 'masks-clear', label: 'CLEAR', onClick: clearMasks }] : []),
{ key: 'mono', label: 'MONOCHROME', active: monoOn, onClick: toggleMono },
// GRAIN is a strip of its own — amount, size, and the count they add up
// to — so it is one chip here and its two knobs live inside it; the
@@ -2396,7 +2492,7 @@ export function Workspace() {
onClick: () => toggleParam(key),
});
return [
{ key: 'hsl-pick', label: 'PICK', active: picking, onClick: () => { setBrushTool(null); setPicking((v) => !v); } },
{ key: 'hsl-pick', label: 'PICK', active: picking, onClick: () => { setBrushTool(null); setMaskTool(null); setPicking((v) => !v); } },
...HSL_BANDS.map((b): ChipDef => {
const band = recipe.adjustments.hslBands?.[b.id];
const moved = !!band && (band[0] !== 0 || band[1] !== 0 || band[2] !== 0);
@@ -2671,7 +2767,62 @@ export function Workspace() {
</div>
</div>
{/* column 2 — FRAME's WATERMARK chip opens the two collapses, one per
{/* column 2 — FX's GRADIENT MASK, while one is chosen: the shape's own
name, the way to take it off the photo, and the three knobs it
grades with. A radial mask adds the feather it fades over, since
that is the number its rim is made of. */}
{selMask ? (
<div className="col col-sub strip" data-col="mask">
<ChipRow
chips={[
{ key: 'mask-kind', label: selMask.kind.toUpperCase(), active: true, onClick: () => {} },
{ key: 'mask-delete', label: 'DELETE', onClick: deleteMask },
]}
/>
<div className="hsl-card-knobs">
<MiniSlider
label="EXP"
value={selMask.exposure}
min={-MASK_EXPOSURE_MAX}
max={MASK_EXPOSURE_MAX}
step={0.1}
prefix="mask-knob"
format={(v) => `${v > 0 ? '+' : ''}${v.toFixed(1)}`}
onChange={(v) => setMaskKnob({ exposure: v })}
onReset={() => setMaskKnob({ exposure: 0 })}
/>
<MiniSlider
label="CON"
value={selMask.contrast}
prefix="mask-knob"
onChange={(v) => setMaskKnob({ contrast: Math.round(v) })}
onReset={() => setMaskKnob({ contrast: 0 })}
/>
<MiniSlider
label="SAT"
value={selMask.saturation}
prefix="mask-knob"
onChange={(v) => setMaskKnob({ saturation: Math.round(v) })}
onReset={() => setMaskKnob({ saturation: 0 })}
/>
{selMask.kind === 'radial' ? (
<MiniSlider
label="FEA"
value={Math.round(selMask.feather * 100)}
min={0}
max={100}
step={5}
prefix="mask-knob"
format={(v) => `${v}%`}
onChange={(v) => setMaskKnob({ feather: v / 100 })}
onReset={() => setMaskKnob({ feather: MASK_DEFAULT_FEATHER })}
/>
) : null}
</div>
</div>
) : null}
{/* column 2b — FRAME's WATERMARK chip opens the two collapses, one per
watermark type: the header chip, then that mark's own controls */}
{wmOpen ? (
<div className="col col-sub strip wm-extra" data-col="wm">
@@ -2865,6 +3016,12 @@ export function Workspace() {
onBrushR={setBrushR}
onBrushSpots={addBrushSpots}
onBrushEdit={editBrushSpots}
maskTool={maskTool}
masks={masks}
maskSel={maskSel}
onMaskSel={setMaskSel}
onMaskCreate={addMask}
onMaskEdit={editMasks}
pickPanel={pickPanel}
pickPanelAt={pickedAt}
// FRAME's custom mark owns a box on the photo while its panel is
+48 -2
View File
@@ -36,6 +36,7 @@ import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from '../../shared/uti
import { CURVE_SKSL, CURVE_LUT_SIZE, curveIsActive, curveLut } from '../../shared/utils/toneCurve';
import { healSkSL, healUniforms, readHeal } from '../../shared/utils/heal';
import { mosaicSkSL, mosaicUniforms, readMosaic } from '../../shared/utils/mosaic';
import { gradientMaskSkSL, maskUniforms, readMasks } from '../../shared/utils/gradientMask';
import {
GRAIN_SKSL,
HALATION_SKSL,
@@ -180,6 +181,19 @@ function mosaicEffectFor(count: number): any {
return effect;
}
// FX's gradient masks are the third shader of that kind — the shapes and the
// knobs are the recipe's, so it is built for the count it is handed and cached
// by count the same way (shared/utils/gradientMask.ts).
const maskEffects = new Map<number, any>();
function maskEffectFor(count: number): any {
let effect = maskEffects.get(count);
if (effect === undefined) {
effect = Skia.RuntimeEffect.Make(gradientMaskSkSL(count)) ?? null;
maskEffects.set(count, effect);
}
return effect;
}
// CLARITY_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on
// a 1:1 export canvas, so the radius matches what the preview tuned.
function convolvePaint(srcImage: any, amount: number): any {
@@ -652,7 +666,39 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
}
}
// 6d. HEAL — the FX tab's dust brush (see heal.ts). It is the last thing
// 6d. The gradient masks — FX's LINEAR / RADIAL GRADIENT (see
// gradientMask.ts). The three knobs each shape carries are applied through
// that shape's own alpha, masks in the order they were drawn, so the second
// reads what the first left. It sits here, after the frame-wide grade and
// before HEAL, because a local adjustment is part of the look and not a
// repair: the pixels a repair borrows are meant to carry it already.
const masks = readMasks(adjustments.masks);
if (masks.length) {
const effect = maskEffectFor(masks.length);
flush(surface);
const snap = own(surface.makeImageSnapshot());
const child = snap
? own(
snap.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
)
)
: null;
const shader =
effect && child
? own(effect.makeShaderWithChildren(maskUniforms(masks, width, height), [child]))
: null;
if (shader) {
// Same shape as the two passes below: the shader reads the frame it is
// about to overwrite, so the surface is snapshotted, cleared and redrawn.
canvas.clear(Skia.Color('transparent'));
const maskPaint = own(Skia.Paint());
maskPaint.setShader(shader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), maskPaint);
}
}
// 6e. HEAL — the FX tab's dust brush (see heal.ts). It is the last thing
// that touches the photo's own pixels, and deliberately so: the borrowed
// patch is copied out of the pixels the pipeline has already built, so a
// repair matches the grade, the blur and the grain around it by
@@ -688,7 +734,7 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
}
}
// 6e. MOSAIC — the FX tab's hiding brush (see mosaic.ts). It comes after
// 6f. MOSAIC — the FX tab's hiding brush (see mosaic.ts). It comes after
// HEAL because hiding is the last thing the photo's own pixels are asked
// for: a repair under a hidden patch is work nobody will ever see, and the
// snapshot this pass reads already carries the repairs. It is a hard-edged
+43
View File
@@ -529,6 +529,49 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
pointer-events: auto;
}
.brush-del:hover { background: var(--accent); }
/* FX's gradient masks — LINEAR and RADIAL GRADIENT, one layer. It takes the
pointer while a mask tool is armed, so a drag draws the next shape instead of
panning the photo; the shapes already on the photo answer only through their
own pin and handles, which is what lets a press mean "take hold of this one"
rather than "draw another". */
.mask-layer {
position: absolute;
border-radius: var(--radius-sm);
cursor: crosshair;
touch-action: none;
}
/* The shape's own outline, over the photo and never in front of the pointer: the
pin and the handles are the only things on this layer to press. */
.mask-shape {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.45));
}
.mask-line { stroke: rgba(255, 255, 255, 0.85); stroke-width: 1; }
/* An ellipse's rim, and inside it the ellipse its feather fades to — so the
hand the shape has on the photo is visible before the knobs are moved. */
.mask-ring { fill: none; stroke: rgba(255, 255, 255, 0.85); stroke-width: 1; }
.mask-ring.feather { stroke: rgba(255, 255, 255, 0.5); stroke-dasharray: 4 4; }
/* The pin that moves the whole shape, and the handles that resize it. Both are
the pointer's own, and both keep a screen size of their own whatever the zoom
(the layer's transform is taken back out of them). */
.mask-pin,
.mask-handle {
position: absolute;
width: 13px;
height: 13px;
border: 1px solid rgba(0, 0, 0, 0.55);
background: rgba(255, 255, 255, 0.9);
pointer-events: auto;
cursor: move;
}
.mask-pin { border-radius: 50%; }
.mask-handle { border-radius: 2px; }
.mask-handle.rot { border-radius: 50%; cursor: grab; }
/* The mixer's readout: the sampled swatch, its RGB and HSL, and the band the
ruler is pointed at. */
.hsl-readout {
+23 -10
View File
@@ -129,16 +129,29 @@ export function SliderRow({ slider }: { slider: SliderDef }) {
}
// The same ruler without its own column: one knob of the mixer's inline panel,
// where HUE/SAT/LUM sit side by side under the colour they are moving. Wheel
// steps it, double-click puts it back to 0.
// where HUE/SAT/LUM sit side by side under the colour they are moving — and, in
// the same shape, the knobs a gradient mask carries. Wheel steps it, double-click
// puts it back to 0. A mask's knobs are not the mixer's -10..+10 integers (an
// exposure is a stop, a feather is a percentage), so the range and the readout
// come in with them; the defaults are the mixer's own, unchanged.
export function MiniSlider({
label,
value,
min = -10,
max = 10,
step = 1,
prefix = 'hsl-knob',
format,
onChange,
onReset,
}: {
label: string;
value: number;
min?: number;
max?: number;
step?: number;
prefix?: string;
format?: (value: number) => string;
onChange: (value: number) => void;
onReset: () => void;
}) {
@@ -154,25 +167,25 @@ export function MiniSlider({
e.preventDefault();
e.stopPropagation();
const s = live.current;
const next = Math.max(-10, Math.min(10, s.value + (e.deltaY < 0 ? 1 : -1)));
const next = Math.max(min, Math.min(max, s.value + (e.deltaY < 0 ? step : -step)));
if (next !== s.value) s.onChange(next);
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
}, [min, max, step]);
return (
<div className="mini-slider" ref={ref} data-key={`hsl-knob-${label}`}>
<div className="mini-slider" ref={ref} data-key={`${prefix}-${label}`}>
<div className="mini-head">
<span>{label}</span>
<b className={value !== 0 ? 'amber' : ''} data-key={`hsl-knob-${label}-value`}>
{value > 0 ? `+${value}` : String(value)}
<b className={value !== 0 ? 'amber' : ''} data-key={`${prefix}-${label}-value`}>
{format ? format(value) : value > 0 ? `+${value}` : String(value)}
</b>
</div>
<input
type="range"
min={-10}
max={10}
step={1}
min={min}
max={max}
step={step}
value={value}
aria-label={label}
onChange={(e) => onChange(Number(e.target.value))}
+363 -3
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { MIN_CROP_FRAC, type CropRect, type HealSpot, type MosaicSpot } from '../../shared/types';
import { MIN_CROP_FRAC, type CropRect, type GradientMask, type HealSpot, type MosaicSpot } from '../../shared/types';
import { bitmapReader, samplePixel } from '../engine/imageOps';
import { findHealSource } from '../../shared/utils/heal';
import { MASK_DEFAULT_FEATHER, MASK_MIN } from '../../shared/utils/gradientMask';
import { BRUSH_SPACING, wheelBrushR } from '../../shared/utils/brush';
import { measureMark } from '../engine/exportEngine';
import { Histogram } from './Histogram';
@@ -117,6 +118,85 @@ const strokeRuns = (spots: { x: number; y: number; r: number }[], box: { width:
return { paths, joined };
};
// FX's gradient masks, the part of them that is geometry: which handle a press
// took hold of, and where that handle's move leaves the shape.
type MaskPart = 'move' | 'p0' | 'p1' | 'rx' | 'ry' | 'rot';
// The centre a mask is pinned and moved by: an ellipse's own centre, and the
// middle of a linear ramp — where Lightroom puts its pin too, since the shape is
// dragged as one thing from there.
const maskPin = (m: GradientMask) => (m.kind === 'radial' ? { x: m.x, y: m.y } : { x: (m.x + m.ex) / 2, y: (m.y + m.ey) / 2 });
// The shape a drag lays down. Both ends are already fractions of the render (the
// brush's own point), so the mask costs no conversion: a linear one IS the drag,
// and a radial one is the centre the press landed on plus the radius the drag
// reached, its axis lying along the direction the hand went — the ellipse a
// radial mask starts life as is the circle that drag describes. Too short a drag
// is not a shape at all: nothing is laid down, the way a press with the brush is
// not a stroke.
const maskFromDrag = (
kind: 'linear' | 'radial',
a: { fx: number; fy: number },
b: { fx: number; fy: number },
box: { width: number; height: number }
): GradientMask | null => {
const dx = (b.fx - a.fx) * box.width;
const dy = (b.fy - a.fy) * box.height;
const r = Math.hypot(dx, dy) / box.width;
if (r < MASK_MIN) return null;
const common = { exposure: 0, contrast: 0, saturation: 0 };
if (kind === 'linear') {
return { kind, x: a.fx, y: a.fy, ex: b.fx, ey: b.fy, rx: 0, ry: 0, angle: 0, feather: 0, ...common };
}
return {
kind,
x: a.fx,
y: a.fy,
ex: a.fx,
ey: a.fy,
rx: r,
ry: r,
angle: Math.atan2(dy, dx),
feather: MASK_DEFAULT_FEATHER,
...common,
};
};
// One handle's move, as the shape it leaves. The two ends of a linear ramp are
// themselves; a radial mask resizes along the ellipse's OWN axes — the pointer
// is projected onto the axis the handle rides, so a hand that wanders across it
// does not shrink it — and its rotation handle sits on the +ry axis, so the
// angle is simply the direction from the centre to the pointer, one quarter turn
// back. 'move' takes the whole of it, the pin's own drag.
const maskDragged = (
m: GradientMask,
part: MaskPart,
p: { fx: number; fy: number },
box: { width: number; height: number }
): GradientMask => {
const dx = (p.fx - m.x) * box.width;
const dy = (p.fy - m.y) * box.height;
switch (part) {
case 'p0':
return { ...m, x: p.fx, y: p.fy };
case 'p1':
return { ...m, ex: p.fx, ey: p.fy };
case 'move': {
// The whole shape by the same delta: the ends of a ramp travel with its
// middle, and an ellipse travels with its centre.
const ddx = p.fx - m.x;
const ddy = p.fy - m.y;
return { ...m, x: p.fx, y: p.fy, ex: m.ex + ddx, ey: m.ey + ddy };
}
case 'rot':
return { ...m, angle: Math.atan2(dy, dx) - Math.PI / 2 };
case 'rx':
return { ...m, rx: Math.max(MASK_MIN, Math.abs(dx * Math.cos(m.angle) + dy * Math.sin(m.angle)) / box.width) };
default:
return { ...m, ry: Math.max(MASK_MIN, Math.abs(-dx * Math.sin(m.angle) + dy * Math.cos(m.angle)) / box.width) };
}
};
// The centre column: drop target, preview canvas, and the PEEK comparison.
// Dragging a photo anywhere on the page works, not just on the frame — the
// whole stage is the drop zone.
@@ -160,6 +240,12 @@ export function ImageStage({
onBrushR,
onBrushSpots,
onBrushEdit,
maskTool = null,
masks = [],
maskSel = null,
onMaskSel,
onMaskCreate,
onMaskEdit,
dims = null,
}: {
previewUrl: string | null;
@@ -227,6 +313,22 @@ export function ImageStage({
// — which is what makes a drag one undo step and a press that only selects
// one cost no step at all.
onBrushEdit?: (spots: (HealSpot | MosaicSpot)[], undo: boolean) => void;
// FX's gradient masks — LINEAR and RADIAL GRADIENT. The tool is armed by a
// chip; one drag lays one shape down, and from then on the shape is a thing on
// the photo: a pin to move it by and, for the one that is chosen, the handles
// that resize it (the two ends of a ramp; an ellipse's two axes and its
// rotation). The knobs each mask carries are not here — they are the panel
// beside the photo, and they reach the render the same way every other slider
// does.
maskTool?: 'linear' | 'radial' | null;
masks?: GradientMask[];
maskSel?: number | null;
onMaskSel?: (i: number | null) => void;
onMaskCreate?: (mask: GradientMask) => void;
// A shape being moved or resized: the list the photo now holds, and whether
// this is the change the gesture started with — the same one-undo-per-drag
// rule the brush's own edit follows.
onMaskEdit?: (masks: GradientMask[], undo: boolean) => void;
// The photo's own pixel size, as the stage stands: the file's shape turned by
// the quarter turn and cut by a committed crop, so the number in the row under
// the photo is the resolution an export at the photo's own size renders.
@@ -343,7 +445,7 @@ export function ImageStage({
}, []);
useEffect(() => {
if (!cropEditing && !compare && !picking && !pickPanelAt && !wmCount && !showStraighten && !showCurve && !brushTool) {
if (!cropEditing && !compare && !picking && !pickPanelAt && !wmCount && !showStraighten && !showCurve && !brushTool && !maskTool) {
setBox(null);
return;
}
@@ -364,7 +466,7 @@ export function ImageStage({
ro?.disconnect();
window.removeEventListener('resize', measure);
};
}, [cropEditing, compare, picking, pickPanelAt, wmCount, showStraighten, showCurve, brushTool, measure, previewUrl]);
}, [cropEditing, compare, picking, pickPanelAt, wmCount, showStraighten, showCurve, brushTool, maskTool, measure, previewUrl]);
const grabPanel = (e: React.PointerEvent<HTMLDivElement>) => {
e.stopPropagation(); // the photo must not pan under the card
@@ -815,6 +917,164 @@ export function ImageStage({
return () => el.removeEventListener('wheel', onWheel);
}, [onBrushR, brushR, brushTool, box, zoomAt]);
// --- FX's gradient masks ---------------------------------------------------
// A mask is drawn rather than painted: one drag is one shape, and the drag's
// two ends are what is stored (the ramp of a linear mask, the centre and the
// radius of a radial one). The draft is that drag while it is happening, so
// the shape can be seen before it is let go; the flags below are the gesture's
// own undo boundary, the same one the brush's grab keeps.
const [draft, setDraft] = useState<{ a: BrushPoint; b: BrushPoint } | null>(null);
const maskGrab = useRef<{ i: number; part: MaskPart } | null>(null);
const maskUndo = useRef(false);
const maskDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!onMaskCreate || !maskTool) return;
const p = brushPoint(e.clientX, e.clientY);
if (!p) return;
e.currentTarget.setPointerCapture(e.pointerId);
setDraft({ a: p, b: p });
};
const maskMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!draft) return;
const p = brushPoint(e.clientX, e.clientY);
// Off the photo the end stays where it was, the brush's own rule: a shape is
// a place on the picture, and a pointer past the frame has left it.
if (p) setDraft({ a: draft.a, b: p });
};
const maskUp = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
const d = draft;
setDraft(null);
if (!d || !onMaskCreate || !maskTool || !box) return;
const mask = maskFromDrag(maskTool, d.a, d.b, box);
if (mask) onMaskCreate(mask);
};
// A pin or a handle: the press is on a shape already on the photo, so it takes
// hold of that shape rather than drawing a new one. The handler stops the event
// where it is, because the layer underneath would read the same press as the
// start of a drag.
const grabMask = (i: number, part: MaskPart, e: React.PointerEvent<HTMLElement>) => {
if (!onMaskEdit) return;
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
maskGrab.current = { i, part };
maskUndo.current = false;
onMaskSel?.(i);
};
const moveMask = (e: React.PointerEvent<HTMLElement>) => {
const g = maskGrab.current;
const was = g ? masks[g.i] : null;
if (!g || !was || !onMaskEdit || !box) return;
const p = brushPoint(e.clientX, e.clientY);
if (!p) return;
const next = masks.slice();
next[g.i] = maskDragged(was, g.part, p, box);
onMaskEdit(next, !maskUndo.current);
maskUndo.current = true;
};
const endMask = (e: React.PointerEvent<HTMLElement>) => {
// A press that never travelled is a choice, not an edit: nothing was
// written, so the caller is not asked for a step.
maskGrab.current = null;
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
};
// Where a mask's own geometry falls on the photo, as the fractions the layer
// places in %: a point along `u` is rx from the centre at the rim and one along
// `v` is ry, which is the ellipse's own frame — the same frame the shader
// rotates a pixel into. Both semi-axes are fractions of the WIDTH (the
// renderer's own measurement, so an ellipse stays the same ellipse on any
// photo shape), which is why a y offset is turned back into a fraction of the
// height here. The rotation handle rides just outside the +ry axis, a fixed
// number of SCREEN pixels clear of the rim, so it stays reachable at any zoom.
const maskGeom = (m: GradientMask) => {
const u = { x: Math.cos(m.angle), y: Math.sin(m.angle) };
const v = { x: -Math.sin(m.angle), y: Math.cos(m.angle) };
const w = box?.width ?? 0;
const k = box && box.height > 0 ? box.width / box.height : 1;
const clear = 24 / Math.max(1, view.s) / Math.max(1, w);
const at = (dx: number, dy: number) => ({ x: m.x + dx, y: m.y + dy * k });
return {
at,
axis: (sx: number, sy: number) =>
at(sx * m.rx * u.x + sy * m.ry * v.x, sx * m.rx * u.y + sy * m.ry * v.y),
rot: at(v.x * (m.ry + clear), v.y * (m.ry + clear)),
};
};
const selMask = maskSel !== null && maskSel >= 0 && maskSel < masks.length ? masks[maskSel] : null;
const selGeom = selMask && box ? maskGeom(selMask) : null;
// A handle keeps a screen size of its own whatever the zoom, the way the × the
// brush lays on a chosen spot does: a shape aimed at a zoom is a shape whose
// handles have to stay reachable at one.
const maskScale = { transform: `translate(-50%, -50%) scale(${1 / view.s})` };
// The outline of a mask's own maths, drawn where the render will read it — a
// linear ramp is the drag's two ends and the middle its smoothstep turns at,
// and an ellipse is its rim plus the inner ellipse the alpha fades to. The
// ellipse's semi-axes are fractions of the WIDTH, like HEAL's radius, so both
// of them are that times the box's width in the layer's own pixels.
const maskShape = (m: GradientMask | null) =>
m && box ? (
<svg
className="mask-shape"
data-key="mask-shape"
data-kind={m.kind}
viewBox={`0 0 ${box.width} ${box.height}`}
aria-hidden="true"
>
{m.kind === 'linear'
? (() => {
// The three lines are drawn across the drag, so the ramp reads as
// the direction it falls in rather than as one more line on the
// photo: a tick at each end and one where the hand let go.
const p0 = { x: m.x * box.width, y: m.y * box.height };
const p1 = { x: m.ex * box.width, y: m.ey * box.height };
const len = Math.hypot(p1.x - p0.x, p1.y - p0.y) || 1;
const n = { x: -(p1.y - p0.y) / len, y: (p1.x - p0.x) / len };
const L = Math.hypot(box.width, box.height) / 2;
const ticks = [p0, { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 }, p1];
return (
<g>
{ticks.map((t, i) => (
<line
key={i}
className="mask-line"
x1={t.x - n.x * L}
y1={t.y - n.y * L}
x2={t.x + n.x * L}
y2={t.y + n.y * L}
/>
))}
</g>
);
})()
: (() => {
const cx = m.x * box.width;
const cy = m.y * box.height;
const rx = m.rx * box.width;
const ry = m.ry * box.width;
return (
<g transform={`rotate(${(m.angle * 180) / Math.PI} ${cx} ${cy})`}>
<ellipse className="mask-ring" cx={cx} cy={cy} rx={rx} ry={ry} />
<ellipse
className="mask-ring feather"
cx={cx}
cy={cy}
rx={rx * (1 - m.feather)}
ry={ry * (1 - m.feather)}
/>
</g>
);
})()}
</svg>
) : null;
// A new photo, or the frame coming up or going down (APPLY, CANCEL, UNDO),
// puts the view back on the fit.
useEffect(() => {
@@ -1347,6 +1607,106 @@ export function ImageStage({
) : null}
</div>
) : null}
{/* FX's gradient masks — LINEAR and RADIAL GRADIENT. The layer takes
the pointer while a mask tool is armed: a drag anywhere on the photo
draws the next shape, and its two ends are the shape's own numbers,
so what is being drawn is visible while it is drawn. Every mask on
the photo keeps a pin — the handle it is moved by, and how one of
them is chosen at all — and the chosen one adds the outline of what
the render will do (the two ends of a linear ramp and the ramp
between them; an ellipse and the inner ellipse its feather fades
to) plus the handles that resize it. */}
{maskTool && !cropEditing && box ? (
<div
className="mask-layer"
data-key="mask-layer"
data-tool={maskTool}
style={{
left: box.left,
top: box.top,
width: box.width,
height: box.height,
transform:
view.s === 1 ? undefined : `translate(${view.x}px, ${view.y}px) scale(${view.s})`,
}}
onPointerDown={(e) => {
e.stopPropagation();
maskDown(e);
}}
onPointerMove={maskMove}
onPointerUp={maskUp}
onPointerCancel={() => setDraft(null)}
>
{maskShape((() => {
// The shape under the hand right now: the same construction the
// gesture will store, drawn before it is let go.
if (!draft) return null;
return maskFromDrag(maskTool, draft.a, draft.b, box);
})())}
{masks.map((m, i) => (
<span key={i}>
{i === maskSel ? maskShape(m) : null}
{(() => {
const pin = maskPin(m);
return (
<span
className="mask-pin"
data-key="mask-pin"
data-i={i}
data-kind={m.kind}
style={{
left: `${pin.x * 100}%`,
top: `${pin.y * 100}%`,
...maskScale,
}}
onPointerDown={(e) => grabMask(i, 'move', e)}
onPointerMove={moveMask}
onPointerUp={endMask}
onPointerCancel={endMask}
/>
);
})()}
</span>
))}
{selMask && selGeom
? selMask.kind === 'linear'
? ([
{ part: 'p0' as const, at: { x: selMask.x, y: selMask.y }, k: 'l0' },
{ part: 'p1' as const, at: { x: selMask.ex, y: selMask.ey }, k: 'l1' },
].map(({ part, at, k }) => (
<span
key={k}
className="mask-handle"
data-key={`mask-handle-${part}`}
style={{ left: `${at.x * 100}%`, top: `${at.y * 100}%`, ...maskScale }}
onPointerDown={(e) => grabMask(maskSel as number, part, e)}
onPointerMove={moveMask}
onPointerUp={endMask}
onPointerCancel={endMask}
/>
)))
: ([
{ part: 'rx' as const, at: selGeom.axis(1, 0), k: 'x1' },
{ part: 'rx' as const, at: selGeom.axis(-1, 0), k: 'x2' },
{ part: 'ry' as const, at: selGeom.axis(0, 1), k: 'y1' },
{ part: 'ry' as const, at: selGeom.axis(0, -1), k: 'y2' },
{ part: 'rot' as const, at: selGeom.rot, k: 'rot' },
].map(({ part, at, k }) => (
<span
key={k}
className={`mask-handle${part === 'rot' ? ' rot' : ''}`}
data-key={`mask-handle-${part}-${k}`}
style={{ left: `${at.x * 100}%`, top: `${at.y * 100}%`, ...maskScale }}
onPointerDown={(e) => grabMask(maskSel as number, part, e)}
onPointerMove={moveMask}
onPointerUp={endMask}
onPointerCancel={endMask}
/>
)))
: null}
</div>
) : null}
{/* The mixer's panel for the colour the eyedropper just read: it hangs
on the photo at the point it was read from, and its swatch follows
the three knobs under it. */}