diff --git a/docker/frontend/shared/utils/heal.ts b/docker/frontend/shared/utils/heal.ts index 7beb79f..e2a0c57 100644 --- a/docker/frontend/shared/utils/heal.ts +++ b/docker/frontend/shared/utils/heal.ts @@ -10,20 +10,32 @@ import type { HealSpot } from '../types'; // the export rendering the same photo at two sizes, and keeps the circle round // whatever the photo's shape. // +// There is no ceiling on the list. The shader is built to carry exactly the +// spots the recipe holds (healSkSL), so a new repair can never push an old one +// out: the dust you healed first is the dust that stays healed. +// // The source is SEARCHED for rather than asked for. Lightroom picks the patch // and lets you drag the second circle afterwards; the search below is the same // idea without the second circle, and it is a pure function of a sampler so a // synthetic picture can hold it to account. -export const HEAL_MAX = 16; +// // Feather, as a fraction of the radius: inside it the patch is copied, outward -// it fades to nothing, so the circle leaves no rim of its own. -export const HEAL_FEATHER = 0.55; -// Brush sizes, S/M/L, as fractions of the photo's width — the labels the chips -// wear sit beside them so the two cannot drift apart. The default is the -// sensor-dust end: a speck is a few thousandths of the frame. -export const HEAL_SIZES = [0.012, 0.03, 0.07]; -export const HEAL_SIZE_LABELS = ['S', 'M', 'L']; -export const HEAL_DEFAULT_R = HEAL_SIZES[0]; +// it fades to nothing, so the circle leaves no rim of its own. It is the outer +// 15% and no more, because that band is the only place the dust being repaired +// is mixed back into the patch — a wide fade keeps the speck's own edge alive +// as a faint ring inside the circle, which is a blur of the dust rather than a +// repair. sub-pixel at the default brush, still a soft edge at a big one. +export const HEAL_FEATHER = 0.85; +// The brush's radius, as a fraction of the photo's width. The wheel walks +// between the bounds multiplicatively, so a trackpad's small deltas and a +// mouse's 100px notch are the same gesture at two speeds; the default is the +// sensor-dust end, where a speck is a few thousandths of the frame. +export const HEAL_DEFAULT_R = 0.012; +export const HEAL_MIN_R = 0.003; +export const HEAL_MAX_R = 0.25; +// How far apart a drag lays its spots, in radii: any closer and the run is the +// same repair written twice, which is work for nothing. +export const HEAL_SPACING = 0.6; // How far the search looks, in radii, and how many directions it looks in. const SEARCH_DISTANCES = [2.6, 4.2, 6.5]; const SEARCH_DIRS = 8; @@ -48,13 +60,21 @@ const num = (v: unknown, fallback: number) => { }; const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); -// The stored spots, made readable: numbers, inside the frame, at most HEAL_MAX -// of them. Everything below reads a recipe through this, so a hand-written or -// older file cannot produce a spot the brush and the renderer disagree about. +// The brush radius after a wheel notch. Multiplicative, so the brush grows by +// the same proportion wherever it starts, and bounded — past HEAL_MAX_R a spot +// would borrow a patch from off the photo, and below HEAL_MIN_R it would be +// finer than the pixels it is drawn on at preview scale. +export function wheelHealR(r: number, deltaY: number): number { + const next = r * Math.exp(-deltaY * 0.0015); + return next < HEAL_MIN_R ? HEAL_MIN_R : next > HEAL_MAX_R ? HEAL_MAX_R : next; +} + +// The stored spots, made readable: numbers, inside the frame. Everything below +// reads a recipe through this, so a hand-written or older file cannot produce a +// spot the brush and the renderer disagree about. export function readHeal(heal: HealSpot[] | undefined): HealSpot[] { if (!Array.isArray(heal)) return []; return heal - .slice(0, HEAL_MAX) .map((s) => ({ x: clamp01(num(s?.x, 0)), y: clamp01(num(s?.y, 0)), @@ -65,24 +85,29 @@ export function readHeal(heal: HealSpot[] | undefined): HealSpot[] { .filter((s) => s.r > 0); } -// The uniform block HEAL_SKSL reads: the circles, the patches, then the frame -// the fractions are of. Declaration order, arrays expanded — that is how the -// runtime effect wants its uniforms, and one buffer is one upload per render. +// The uniform block the shader for `n` spots reads: the circles, the patches, +// then the frame the fractions are of. Declaration order, arrays expanded — +// that is how the runtime effect wants its uniforms, and one buffer is one +// upload per render. Its length is a function of the list, not a fixed +// capacity, because the shader carries exactly the spots the recipe holds. export function healUniforms(spots: HealSpot[], width: number, height: number): Float32Array { - const u = new Float32Array((HEAL_MAX * 2 + 1) * 4); const list = readHeal(spots); - for (let i = 0; i < list.length; i++) { + const n = list.length; + const u = new Float32Array((n * 2 + 1) * 4); + for (let i = 0; i < n; i++) { const s = list[i]; u.set([s.x, s.y, s.r, 0], i * 4); - u.set([s.sx, s.sy, 0, 0], (HEAL_MAX + i) * 4); + u.set([s.sx, s.sy, 0, 0], (n + i) * 4); } - u.set([width, height, HEAL_FEATHER, 0], HEAL_MAX * 2 * 4); + u.set([width, height, HEAL_FEATHER, 0], n * 2 * 4); return u; } // One unrolled block per spot. SkSL indexes a uniform array by constant only -// (see TONE_SKSL's mixer), so the spots are written out rather than looped — -// and the buffer is the same HEAL_MAX long whatever the recipe holds. +// (see TONE_SKSL's mixer), so the spots are written out rather than looped, and +// the shader is built for the count it is handed rather than for a capacity — +// that is what lets the list be uncapped. A count costs one RuntimeEffect to +// compile, so the renderer caches them by count (exportEngine's healEffectFor). const spotBlock = (i: number) => ` { float4 s = spots[${i}]; @@ -102,17 +127,19 @@ const spotBlock = (i: number) => ` // snapshot of the surface) and writes the borrowed patches back over them, so a // repair is one draw: no blur, no smoothing, and the grain and the frame land // on top of it afterwards exactly as they land on the rest of the photo. -export const HEAL_SKSL = ` +export function healSkSL(count: number): string { + return ` uniform shader img; -uniform float4 spots[${HEAL_MAX}]; -uniform float4 srcs[${HEAL_MAX}]; +uniform float4 spots[${count}]; +uniform float4 srcs[${count}]; uniform float4 size; half4 main(float2 pos) { - half4 c = img.eval(pos);${Array.from({ length: HEAL_MAX }, (_, i) => spotBlock(i)).join('')} + half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => spotBlock(i)).join('')} return c; } `; +} // The patch to borrow for a spot at (x, y) of radius r, from a sampler that // answers fractions of the same photo. The candidates are a ring of offsets in diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 7270702..e03d12c 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -38,7 +38,7 @@ import { import { HSL_BANDS, isMonochromeBase, kelvinToRGB, nearestHslBand, rgbToHsl } from '../shared/utils/colorUtils'; import { grainPerInch, grainStockFor } from '../shared/utils/grainShader'; import { curveIsActive } from '../shared/utils/toneCurve'; -import { HEAL_MAX, HEAL_DEFAULT_R, HEAL_SIZE_LABELS, HEAL_SIZES } from '../shared/utils/heal'; +import { HEAL_DEFAULT_R } from '../shared/utils/heal'; import type { MsgKey } from './i18n/vi'; // Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there. @@ -901,15 +901,17 @@ export function Workspace() { ); // FX's HEAL brush writes the spots the same way a knob writes its value: one - // click is one repair and one undo step, so UNDO takes the dust back out one - // spot at a time, and CLEAR — one step — hands the whole photo back. The list - // is capped at the number the shader carries; past that the oldest spot is - // the one that goes, so the brush keeps working instead of refusing. - const addHealSpot = useCallback( - (spot: HealSpot) => { + // gesture is one undo step whatever it laid down, so UNDO takes the dust back + // out a stroke at a time, and CLEAR — one step — hands the whole photo back. + // Nothing is capped: the shader is built to carry exactly the spots the + // recipe holds (shared/utils/heal.ts), so the repair made first is still + // there after the hundredth. + const addHealSpots = useCallback( + (spots: HealSpot[]) => { + if (!spots.length) return; remember(); const list = lookRef.current?.recipe.adjustments.heal ?? []; - setAdjustment({ heal: [...list, spot].slice(-HEAL_MAX) }); + setAdjustment({ heal: [...list, ...spots] }); }, [remember, setAdjustment] ); @@ -2274,13 +2276,15 @@ export function Workspace() { ]; case 'fx': { // FX's HEAL is the tab's one tool that is not a look: the chip arms the - // dust brush, S/M/L choose how much of the photo a click covers, and - // CLEAR hands the whole photo back. It sits above the look chips - // because it repairs the file rather than grading it. + // dust brush, the wheel sizes it — the chip's own readout is that size, + // since the brush has no size buttons to point at — and CLEAR hands the + // whole photo back. It sits above the look chips because it repairs the + // file rather than grading it. return [ { key: 'heal', label: 'HEAL', + value: healing ? `${(healR * 100).toFixed(1)}%` : undefined, active: healing, amberValue: healSpots.length > 0, onClick: () => { @@ -2289,14 +2293,6 @@ export function Workspace() { setHealing((v) => !v); }, }, - ...(healing - ? HEAL_SIZES.map((r, i): ChipDef => ({ - key: `heal-size-${i}`, - label: HEAL_SIZE_LABELS[i], - active: healR === r, - onClick: () => setHealR(r), - })) - : []), ...(healSpots.length ? [{ key: 'heal-clear', label: 'CLEAR', onClick: clearHealSpots }] : []), @@ -2795,7 +2791,8 @@ export function Workspace() { healing={healing} healSpots={healSpots} healR={healR} - onHealSpot={addHealSpot} + onHealR={setHealR} + onHealSpots={addHealSpots} 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 3942d87..3145db5 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -34,7 +34,7 @@ import { } from '../../shared/utils/toneShader'; import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from '../../shared/utils/cinemaShader'; import { CURVE_SKSL, CURVE_LUT_SIZE, curveIsActive, curveLut } from '../../shared/utils/toneCurve'; -import { HEAL_SKSL, healUniforms, readHeal } from '../../shared/utils/heal'; +import { healSkSL, healUniforms, readHeal } from '../../shared/utils/heal'; import { GRAIN_SKSL, HALATION_SKSL, @@ -141,7 +141,6 @@ let cinemaEffect: any = null; let glowEffect: any = null; let halationEffect: any = null; let curveEffect: any = null; -let healEffect: any = null; function effects() { if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(CLARITY_SKSL); @@ -150,8 +149,22 @@ function effects() { if (!glowEffect) glowEffect = Skia.RuntimeEffect.Make(GLOW_SKSL); if (!halationEffect) halationEffect = Skia.RuntimeEffect.Make(HALATION_SKSL); if (!curveEffect) curveEffect = Skia.RuntimeEffect.Make(CURVE_SKSL); - if (!healEffect) healEffect = Skia.RuntimeEffect.Make(HEAL_SKSL); - return { sharpenEffect, toneEffect, cinemaEffect, glowEffect, halationEffect, curveEffect, healEffect }; + return { sharpenEffect, toneEffect, cinemaEffect, glowEffect, halationEffect, curveEffect }; +} + +// FX's HEAL is the one effect whose shader depends on the recipe rather than on +// constants: it carries exactly the spots the photo has (shared/utils/heal.ts), +// which is what lets the list be uncapped. Every distinct count is therefore a +// shader of its own, kept here so a stroke's worth of spots is paid for once +// and every later render at that count is free. +const healEffects = new Map(); +function healEffectFor(count: number): any { + let effect = healEffects.get(count); + if (effect === undefined) { + effect = Skia.RuntimeEffect.Make(healSkSL(count)) ?? null; + healEffects.set(count, effect); + } + return effect; } // CLARITY_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on @@ -635,7 +648,10 @@ export async function renderPhoto(input: RenderInput): Promise void; + onHealR?: (r: number) => void; + onHealSpots?: (spots: HealSpot[]) => 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. @@ -347,34 +351,105 @@ export function ImageStage({ [previewUrl, onPickColor] ); - // FX's HEAL brush. One click lays one spot down: the circle is where the - // click landed and the patch is whatever the search finds around it. The - // repair is drawn the moment the recipe carries the spot — the same live road - // every other knob takes, so the picture answers while the dust is still - // under the cursor. - const healAt = useCallback( - async (e: React.MouseEvent) => { - const img = imgRef.current; - if (!img || !previewUrl || !onHealSpot) return; - const rect = img.getBoundingClientRect(); - if (!(rect.width > 0 && rect.height > 0)) return; - const fx = (e.clientX - rect.left) / rect.width; - const fy = (e.clientY - rect.top) / rect.height; - if (!(fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1)) return; - // The same decode the eyedropper keeps, under the same rule: one bitmap - // per URL, and the search reads it a few hundred times rather than once. - if (!pickCache.current || pickCache.current.url !== previewUrl) { - const blob = await (await fetch(previewUrl)).blob(); - pickCache.current?.bmp.close(); - pickCache.current = { url: previewUrl, bmp: await createImageBitmap(blob) }; - } - const src = findHealSource(bitmapReader(pickCache.current.bmp), fx, fy, healR); - // No candidate patch means no honest repair: the spot then borrows its own - // pixels, which is a way of doing nothing rather than a blind guess. - onHealSpot({ x: fx, y: fy, r: healR, sx: src ? src.sx : fx, sy: src ? src.sy : fy }); - }, - [previewUrl, onHealSpot, healR] - ); + // FX's HEAL brush. The pointer paints: a click is a stroke of one spot, and a + // drag lays a run of them, one every HEAL_SPACING radii of travel, so a + // scratch of dust is one gesture rather than twenty clicks. The run is turned + // into spots when the pointer comes up and reported in one go — the repair + // lands with the gesture rather than under it, and that is what keeps one + // stroke to one undo step however long it was — while the trail being painted + // is drawn from the pointer track as it moves. + type HealPoint = { fx: number; fy: number; px: number; py: number }; + const strokeRef = useRef<{ pts: HealPoint[]; w: number } | null>(null); + const [healTrail, setHealTrail] = useState([]); + + // The same decode the eyedropper keeps, under the same rule: one bitmap per + // URL, and the search reads it a few hundred times rather than once. + const healReader = useCallback(async () => { + if (!previewUrl) return null; + if (!pickCache.current || pickCache.current.url !== previewUrl) { + const blob = await (await fetch(previewUrl)).blob(); + pickCache.current?.bmp.close(); + pickCache.current = { url: previewUrl, bmp: await createImageBitmap(blob) }; + } + return bitmapReader(pickCache.current.bmp); + }, [previewUrl]); + + // A pointer position as both a fraction of the render (what a spot stores) + // and a pixel offset in the photo's box (what the spacing is measured in). + // Off the photo there is no point: a stroke may run out over the frame and + // come back, and the part outside lays nothing down. + const healPoint = useCallback((clientX: number, clientY: number): HealPoint | null => { + const img = imgRef.current; + if (!img) return null; + const rect = img.getBoundingClientRect(); + if (!(rect.width > 0 && rect.height > 0)) return null; + const fx = (clientX - rect.left) / rect.width; + const fy = (clientY - rect.top) / rect.height; + if (!(fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1)) return null; + return { fx, fy, px: clientX - rect.left, py: clientY - rect.top }; + }, []); + + const healDown = (e: React.PointerEvent) => { + if (!onHealSpots) return; + const p = healPoint(e.clientX, e.clientY); + if (!p) return; + // Capture, so a stroke that runs off the photo still ends where the + // pointer does instead of leaving a spot hanging at the edge. + e.currentTarget.setPointerCapture(e.pointerId); + strokeRef.current = { pts: [p], w: imgRef.current?.getBoundingClientRect().width ?? 0 }; + setHealTrail([p]); + }; + + const healMove = (e: React.PointerEvent) => { + const r = e.currentTarget.getBoundingClientRect(); + setHealPos({ x: e.clientX - r.left, y: e.clientY - r.top }); + const stroke = strokeRef.current; + if (!stroke) return; + const p = healPoint(e.clientX, e.clientY); + if (!p) return; + const last = stroke.pts[stroke.pts.length - 1]; + // The spacing is a fraction of the brush, measured in the photo's own + // pixels: any closer and the run is the same repair written twice. + if (Math.hypot(p.px - last.px, p.py - last.py) < Math.max(1, healR * stroke.w * HEAL_SPACING)) return; + stroke.pts.push(p); + setHealTrail(stroke.pts.slice()); + }; + + const healUp = useCallback(async () => { + const stroke = strokeRef.current; + strokeRef.current = null; + setHealTrail([]); + if (!stroke || !onHealSpots) return; + const reader = await healReader(); + if (!reader) return; + // No candidate patch means no honest repair: that spot then borrows its own + // pixels, which is a way of doing nothing rather than a blind guess. + onHealSpots( + stroke.pts.map((p) => { + const src = findHealSource(reader, p.fx, p.fy, healR); + return { x: p.fx, y: p.fy, r: healR, sx: src ? src.sx : p.fx, sy: src ? src.sy : p.fy }; + }) + ); + }, [healR, healReader, onHealSpots]); + + // The wheel is the brush's size. It is bound natively rather than through + // React's own onWheel because that one is passive: the wheel would scroll the + // page under the brush instead of growing it. The layer is a child of the + // stage that also listens for the wheel (its own zoom), so the event is + // stopped here as well: while the brush is up the wheel sizes the brush and + // nothing else. + const healLayerRef = useRef(null); + useEffect(() => { + const el = healLayerRef.current; + if (!el || !onHealR) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + onHealR(wheelHealR(healR, e.deltaY)); + }; + el.addEventListener('wheel', onWheel, { passive: false }); + return () => el.removeEventListener('wheel', onWheel); + }, [onHealR, healR, healing]); // The img's box at scale 1 — the natural size fitted into the wrap, never // enlarged. Computed rather than measured so the transform cannot feed back @@ -822,14 +897,16 @@ export function ImageStage({ ) : null} {/* FX's HEAL brush. The layer swallows the pointer the way the eyedropper's does, so a click repairs instead of panning the - photo; the circle rides the cursor at the size a spot will be, and - every spot already laid down keeps its own circle on the picture — - the drawn circle and the borrowed patch, the pair the renderer - works with. */} + photo and a drag paints instead; the circle rides the cursor at + the size a spot will be, the wheel changes that size, and every + spot already laid down keeps its own circle on the picture — the + drawn circle and the borrowed patch, the pair the renderer works + with. */} {healing && !cropEditing && box ? (
e.stopPropagation()} - onPointerMove={(e) => { - const r = e.currentTarget.getBoundingClientRect(); - setHealPos({ x: e.clientX - r.left, y: e.clientY - r.top }); + onPointerDown={(e) => { + e.stopPropagation(); + healDown(e); }} + onPointerMove={healMove} onPointerLeave={() => setHealPos(null)} - onClick={healAt} + onPointerUp={healUp} + onPointerCancel={() => { + strokeRef.current = null; + setHealTrail([]); + }} > + {/* The stroke being painted: the run the pointer has drawn so + far, at the brush's size, before it becomes repairs. */} + {healTrail.map((t, i) => ( + + ))} {healSpots.map((s, i) => { // r is a fraction of the photo's WIDTH, so the circle is round // whatever the picture's shape is — the same measurement the