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
+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