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