diff --git a/docker/frontend/shared/types/index.ts b/docker/frontend/shared/types/index.ts index 0f2a465..d33596e 100644 --- a/docker/frontend/shared/types/index.ts +++ b/docker/frontend/shared/types/index.ts @@ -97,6 +97,19 @@ export type ToneCurvePoint = [number, number]; export type ToneCurveChannel = 'rgb' | 'r' | 'g' | 'b'; export type ToneCurve = Partial>; +// FX tab > HEAL. One repaired spot: the circle the brush painted on the photo +// and the patch it borrowed, both as fractions of the rendered photo — x/y/sx/sy +// of its width and height, r of its width. Fractions rather than pixels because +// the same spot has to land on the preview and on the full-size export, and a +// fraction of the width keeps the circle round on any photo shape. +export interface HealSpot { + x: number; + y: number; + r: number; + sx: number; + sy: number; +} + export interface ColorAdjustments { exposure: number; // -10 to +10 (mapped to matrix multiplier or offset) contrast: number; // -10 to +10 @@ -132,6 +145,9 @@ export interface ColorAdjustments { // TONE CURVE, the last word on tone: applied after everything above. Absent or // flat = no move at all. toneCurve?: ToneCurve; + // FX tab > HEAL. The spots the dust brush repaired, in the order they were + // laid down. Absent = nothing was healed, and the pass is not built at all. + heal?: HealSpot[]; exposureCompensation: number; // -3 to +3 EV. Camera: AE bias (hardware). Library: 2^EV matrix gain. } diff --git a/docker/frontend/shared/utils/heal.ts b/docker/frontend/shared/utils/heal.ts new file mode 100644 index 0000000..7beb79f --- /dev/null +++ b/docker/frontend/shared/utils/heal.ts @@ -0,0 +1,162 @@ +import type { HealSpot } from '../types'; + +// FX tab > HEAL — the dust brush, and the patch search behind it. +// +// A spot is a circle on the rendered photo plus the patch it borrows: the +// renderer copies the pixels at (sx, sy) onto (x, y) and feathers the edge, so +// a repair is a draw of the same picture rather than a blur over the dust. All +// five numbers are fractions of the render — x/y/sx/sy of its width and height, +// r of its width — which is what makes one set of spots survive the preview and +// the export rendering the same photo at two sizes, and keeps the circle round +// whatever the photo's shape. +// +// 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]; +// 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; +// The taps that stand for "the patch" when two patches are compared: the centre +// and eight points on a ring at half the radius — the scale the eye reads a +// spot's surroundings at. +const PATCH_TAPS: [number, number][] = [ + [0, 0], + [-0.5, 0], + [0.5, 0], + [0, -0.5], + [0, 0.5], + [-0.35, -0.35], + [0.35, -0.35], + [-0.35, 0.35], + [0.35, 0.35], +]; + +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); + +// 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. +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)), + r: Math.max(0, num(s?.r, 0)), + sx: clamp01(num(s?.sx, s?.x ?? 0)), + sy: clamp01(num(s?.sy, s?.y ?? 0)), + })) + .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. +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 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([width, height, HEAL_FEATHER, 0], HEAL_MAX * 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. +const spotBlock = (i: number) => ` + { + float4 s = spots[${i}]; + if (s.z > 0.0) { + float rad = s.z * size.x; + float d = distance(pos, s.xy * size.xy); + if (d < rad) { + float4 t = srcs[${i}]; + half m = half(1.0 - smoothstep(rad * size.z, rad, d)); + c = mix(c, img.eval(pos + (t.xy - s.xy) * size.xy), m); + } + } + } +`; + +// The pass. It reads the pixels the pipeline has already built (the child is a +// 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 = ` +uniform shader img; +uniform float4 spots[${HEAL_MAX}]; +uniform float4 srcs[${HEAL_MAX}]; +uniform float4 size; + +half4 main(float2 pos) { + half4 c = img.eval(pos);${Array.from({ length: HEAL_MAX }, (_, 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 +// eight directions at three distances — the patch has to be far enough that the +// dust is not in it, near enough that the light is the same — plus each one +// mirrored through the spot, which is the pair Lightroom's own auto-source +// leans on. The winner is the one whose surroundings look most like the spot's. +// Returns null when the frame is too small to hold any candidate: the caller +// then leaves the spot where it is rather than inventing a patch off the photo. +export function findHealSource( + sample: (fx: number, fy: number) => { r: number; g: number; b: number }, + x: number, + y: number, + r: number +): { sx: number; sy: number } | null { + if (!(r > 0)) return null; + const inside = (cx: number, cy: number) => cx - r >= 0 && cx + r <= 1 && cy - r >= 0 && cy + r <= 1; + const score = (cx: number, cy: number) => { + let sum = 0; + for (const [dx, dy] of PATCH_TAPS) { + const a = sample(clamp01(x + dx * r), clamp01(y + dy * r)); + const b = sample(clamp01(cx + dx * r), clamp01(cy + dy * r)); + sum += (Math.abs(a.r - b.r) + Math.abs(a.g - b.g) + Math.abs(a.b - b.b)) / 3; + } + return sum / PATCH_TAPS.length; + }; + let best: { sx: number; sy: number; score: number } | null = null; + for (let d = 0; d < SEARCH_DIRS; d++) { + const a = (d / SEARCH_DIRS) * Math.PI * 2; + for (const dist of SEARCH_DISTANCES) { + const cx = x + Math.cos(a) * dist * r; + const cy = y + Math.sin(a) * dist * r; + for (const [px, py] of [ + [cx, cy], + [2 * x - cx, 2 * y - cy], + ]) { + if (!inside(px, py)) continue; + const s = score(px, py); + // A tie keeps the earlier candidate: the ring is walked from the right, + // so the patch nearest the spot wins — the one most likely to share its + // light. + if (!best || s < best.score) best = { sx: px, sy: py, score: s }; + } + } + } + return best ? { sx: best.sx, sy: best.sy } : null; +} diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 186e38d..7270702 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 HealSpot, type HslBand, type HslBandId, type Recipe, @@ -37,6 +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 type { MsgKey } from './i18n/vi'; // Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there. @@ -288,6 +290,12 @@ export function Workspace() { // armed, and the last colour it read off the photo. const [hslBand, setHslBand] = useState('red'); const [picking, setPicking] = useState(false); + // FX's HEAL brush: whether it is armed, the size a click lays down, and — read + // back off the recipe — the spots already repaired, so the photo, UNDO and the + // saved look all speak about the same list. + const [healing, setHealing] = useState(false); + const [healR, setHealR] = useState(HEAL_DEFAULT_R); + const healSpots = recipe.adjustments.heal ?? []; const [sample, setSample] = useState<{ r: number; g: number; b: number } | null>(null); // Where on the photo that colour was read: the mixer's panel hangs there. const [pickedAt, setPickedAt] = useState<{ fx: number; fy: number } | null>(null); @@ -892,6 +900,26 @@ export function Workspace() { [remember, setAdjustment] ); + // 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) => { + remember(); + const list = lookRef.current?.recipe.adjustments.heal ?? []; + setAdjustment({ heal: [...list, spot].slice(-HEAL_MAX) }); + }, + [remember, setAdjustment] + ); + const clearHealSpots = useCallback(() => { + remember(); + // The field is what "this photo was healed" means, so CLEAR drops it rather + // than leaving an empty list behind. + setAdjustment({ heal: undefined }); + }, [remember, setAdjustment]); + // 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( @@ -2244,8 +2272,34 @@ export function Workspace() { groupChip('cx'), groupChip('cxb'), ]; - case 'fx': + 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. return [ + { + key: 'heal', + label: 'HEAL', + active: healing, + amberValue: healSpots.length > 0, + onClick: () => { + // The two tools that take the pointer on the photo never share it. + setPicking(false); + 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 }] + : []), { 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 @@ -2254,6 +2308,7 @@ export function Workspace() { p.key === 'grain' ? [groupChip('grain')] : p.key === 'grainSize' ? [] : paramChips([p]) ), ]; + } case 'hsl': { // The mixer is PRO: the picker, the eight bands and the three // image-wide knobs all come with the account, so a guest is offered the @@ -2274,7 +2329,7 @@ export function Workspace() { onClick: () => toggleParam(key), }); return [ - { key: 'hsl-pick', label: 'PICK', active: picking, onClick: () => setPicking((v) => !v) }, + { key: 'hsl-pick', label: 'PICK', active: picking, onClick: () => { setHealing(false); 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); @@ -2737,6 +2792,10 @@ export function Workspace() { onClear={() => setClearing(true)} picking={picking} onPickColor={pickColor} + healing={healing} + healSpots={healSpots} + healR={healR} + onHealSpot={addHealSpot} 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 362d7ba..3942d87 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -34,6 +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 { GRAIN_SKSL, HALATION_SKSL, @@ -140,6 +141,7 @@ 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); @@ -148,7 +150,8 @@ 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); - return { sharpenEffect, toneEffect, cinemaEffect, glowEffect, halationEffect, curveEffect }; + if (!healEffect) healEffect = Skia.RuntimeEffect.Make(HEAL_SKSL); + return { sharpenEffect, toneEffect, cinemaEffect, glowEffect, halationEffect, curveEffect, healEffect }; } // CLARITY_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on @@ -623,6 +626,39 @@ export async function renderPhoto(input: RenderInput): Promise { r: number; g: number; b: number } { + const w = bitmap.width; + const h = bitmap.height; + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + let data: Uint8ClampedArray | null = null; + if (ctx && w > 0 && h > 0) { + ctx.drawImage(bitmap, 0, 0); + data = ctx.getImageData(0, 0, w, h).data; + } + return (fx, fy) => { + if (!data) return { r: 0, g: 0, b: 0 }; + const x = Math.min(w - 1, Math.max(0, Math.floor(fx * w))); + const y = Math.min(h - 1, Math.max(0, Math.floor(fy * h))); + const i = (y * w + x) * 4; + return { r: data[i], g: data[i + 1], b: data[i + 2] }; + }; +} + // Uploads are capped by the API (and by nginx in front of it), so a phone's // 12MP JPEG has to shrink before it goes up — the same downscale the preview // uses. The ORIGINAL bytes come back untouched (same reference) when the photo diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index f98af94..ec36f77 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -421,6 +421,28 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } cursor: crosshair; touch-action: none; } +/* FX's HEAL brush. The layer takes the pointer the way the eyedropper's does, + so a click repairs instead of panning; the circle under the cursor is the + size a click lays down, and every spot on the photo keeps the pair the + renderer works with — the hole it fills, and the patch it borrowed. */ +.heal-layer { + position: absolute; + border-radius: var(--radius-sm); + cursor: none; + touch-action: none; +} +.heal-set { position: absolute; inset: 0; pointer-events: none; } +.heal-cursor, +.heal-spot, +.heal-source { + position: absolute; + border-radius: 50%; + transform: translate(-50%, -50%); + pointer-events: none; +} +.heal-cursor { border: 1px solid rgba(255, 255, 255, 0.9); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55); } +.heal-spot { border: 1px solid rgba(255, 255, 255, 0.85); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45); } +.heal-source { border: 1px dashed rgba(255, 255, 255, 0.7); } /* The mixer's readout: the sampled swatch, its RGB and HSL, and the band the ruler is pointed at. */ .hsl-readout { diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 51a1b43..59955c7 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useI18n } from '../i18n/I18nProvider'; -import { MIN_CROP_FRAC, type CropRect } from '../../shared/types'; -import { samplePixel } from '../engine/imageOps'; +import { MIN_CROP_FRAC, type CropRect, type HealSpot } from '../../shared/types'; +import { bitmapReader, samplePixel } from '../engine/imageOps'; +import { findHealSource } from '../../shared/utils/heal'; import { measureMark } from '../engine/exportEngine'; import { Histogram } from './Histogram'; @@ -104,6 +105,10 @@ export function ImageStage({ marks = [], straightenRuler = null, curvePanel = null, + healing = false, + healSpots = [], + healR = 0.02, + onHealSpot, dims = null, }: { previewUrl: string | null; @@ -155,6 +160,14 @@ export function ImageStage({ // rather than in a column beside it. It is anchored, not draggable — the graph // is a fixed square and the photo moves under it. curvePanel?: ReactNode; + // FX's HEAL brush: the circle under the cursor is the size of the spot a + // click lays down, and the patch each spot borrows is searched for at the + // moment of the click (shared/utils/heal.ts) — off the render on the stage, + // which is the picture the repair is being judged against. + healing?: boolean; + healSpots?: HealSpot[]; + healR?: number; + onHealSpot?: (spot: 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. @@ -192,6 +205,9 @@ export function ImageStage({ // Where the eyedropper's own icon is drawing itself inside the photo: the // layer is the photo's box, so plain pixels are enough. const [pickPos, setPickPos] = useState<{ x: number; y: number } | null>(null); + // Where the heal brush's circle is drawing itself, same coordinates as the + // eyedropper's icon: plain pixels inside the photo's own box. + const [healPos, setHealPos] = useState<{ x: number; y: number } | null>(null); // The colour that was read is rarely a clear patch of photo, so the mixer's // card can be dragged off the point it hangs on. The offset is a fraction of // the photo — which is what the layer is — so a zoom keeps the card where it @@ -249,7 +265,7 @@ export function ImageStage({ }, []); useEffect(() => { - if (!cropEditing && !compare && !picking && !pickPanelAt && !wmCount && !showStraighten && !showCurve) { + if (!cropEditing && !compare && !picking && !pickPanelAt && !wmCount && !showStraighten && !showCurve && !healing) { setBox(null); return; } @@ -270,7 +286,7 @@ export function ImageStage({ ro?.disconnect(); window.removeEventListener('resize', measure); }; - }, [cropEditing, compare, picking, pickPanelAt, wmCount, showStraighten, showCurve, measure, previewUrl]); + }, [cropEditing, compare, picking, pickPanelAt, wmCount, showStraighten, showCurve, healing, measure, previewUrl]); const grabPanel = (e: React.PointerEvent) => { e.stopPropagation(); // the photo must not pan under the card @@ -331,6 +347,35 @@ 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] + ); + // 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 // into it. @@ -775,6 +820,64 @@ 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. */} + {healing && !cropEditing && box ? ( +
e.stopPropagation()} + onPointerMove={(e) => { + const r = e.currentTarget.getBoundingClientRect(); + setHealPos({ x: e.clientX - r.left, y: e.clientY - r.top }); + }} + onPointerLeave={() => setHealPos(null)} + onClick={healAt} + > + {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 + // shader makes against the render's own pixels. + const d = 2 * s.r * box.width; + const at = (x: number, y: number) => ({ + left: `${x * 100}%`, + top: `${y * 100}%`, + width: d, + height: d, + }); + return ( + + + + + ); + })} + {healPos ? ( + + ) : null} +
+ ) : null} {/* The mixer's panel for the colour the eyedropper just read: it hangs on the photo at the point it was read from, and its swatch follows the three knobs under it. */}