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