From b568fa3fdc67292d9aaa899356a896499dc18c68 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Thu, 24 Sep 2026 10:42:58 +0700 Subject: [PATCH] web: let FX carry Lightroom's two gradient masks, and grade inside them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docker/frontend/shared/types/index.ts | 40 ++ docker/frontend/shared/utils/gradientMask.ts | 169 +++++++++ docker/frontend/src/App.tsx | 161 +++++++- docker/frontend/src/engine/exportEngine.ts | 50 ++- docker/frontend/src/styles/app.css | 43 +++ docker/frontend/src/ui/ChipColumn.tsx | 33 +- docker/frontend/src/ui/ImageStage.tsx | 366 ++++++++++++++++++- 7 files changed, 845 insertions(+), 17 deletions(-) create mode 100644 docker/frontend/shared/utils/gradientMask.ts diff --git a/docker/frontend/shared/types/index.ts b/docker/frontend/shared/types/index.ts index f30f117..091fb48 100644 --- a/docker/frontend/shared/types/index.ts +++ b/docker/frontend/shared/types/index.ts @@ -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. } diff --git a/docker/frontend/shared/utils/gradientMask.ts b/docker/frontend/shared/utils/gradientMask.ts new file mode 100644 index 0000000..4804cc1 --- /dev/null +++ b/docker/frontend/shared/utils/gradientMask.ts @@ -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; +} +`; +} diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index ea692c3..6ca1338 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -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(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) => { + 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() { - {/* 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 ? ( +
+ {} }, + { key: 'mask-delete', label: 'DELETE', onClick: deleteMask }, + ]} + /> +
+ `${v > 0 ? '+' : ''}${v.toFixed(1)}`} + onChange={(v) => setMaskKnob({ exposure: v })} + onReset={() => setMaskKnob({ exposure: 0 })} + /> + setMaskKnob({ contrast: Math.round(v) })} + onReset={() => setMaskKnob({ contrast: 0 })} + /> + setMaskKnob({ saturation: Math.round(v) })} + onReset={() => setMaskKnob({ saturation: 0 })} + /> + {selMask.kind === 'radial' ? ( + `${v}%`} + onChange={(v) => setMaskKnob({ feather: v / 100 })} + onReset={() => setMaskKnob({ feather: MASK_DEFAULT_FEATHER })} + /> + ) : null} +
+
+ ) : 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 ? (
@@ -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 diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index 0eeeebf..ebb8320 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -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(); +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