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
+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. */}