diff --git a/docker/frontend/shared/types/index.ts b/docker/frontend/shared/types/index.ts index b153dd8..e4334d1 100644 --- a/docker/frontend/shared/types/index.ts +++ b/docker/frontend/shared/types/index.ts @@ -115,6 +115,13 @@ export interface HealSpot { x: number; y: number; r: number; + // Which drag laid this spot down: every spot one gesture wrote carries the + // same number, and a number is never reused. Two strokes that happen to run + // close together are two groups because of this, and the group survives a + // reload, an undo and the export with the rest of the recipe. Spots written + // before the brush numbered its gestures have none, and are grouped the only + // way they can be: by how close their circles lie. + stroke?: number; } // FX tab > MOSAIC. One hidden spot: the circle the brush painted on the photo, @@ -125,6 +132,9 @@ export interface MosaicSpot { x: number; y: number; r: number; + // The drag that laid the spot, exactly as HEAL's: the two tools share the + // stage and its grouping, so they share the field. + stroke?: number; } // FX tab > LINEAR / RADIAL GRADIENT. One local adjustment: a shape the user diff --git a/docker/frontend/shared/utils/heal.ts b/docker/frontend/shared/utils/heal.ts index 371cc2d..06e8407 100644 --- a/docker/frontend/shared/utils/heal.ts +++ b/docker/frontend/shared/utils/heal.ts @@ -53,11 +53,20 @@ export const HEAL_FILL_MAX_LEVELS = 8; export function readHeal(heal: HealSpot[] | undefined): HealSpot[] { if (!Array.isArray(heal)) return []; return heal - .map((s) => ({ - x: Math.min(1, Math.max(0, num(s?.x, 0))), - y: Math.min(1, Math.max(0, num(s?.y, 0))), - r: Math.max(0, num(s?.r, 0)), - })) + .map((s) => { + const spot: HealSpot = { + x: Math.min(1, Math.max(0, num(s?.x, 0))), + y: Math.min(1, Math.max(0, num(s?.y, 0))), + r: Math.max(0, num(s?.r, 0)), + }; + // Which drag the spot came from is carried through. The renderer solves + // each drag as its own repair and in the order they were drawn, so each + // one reads the repairs already made next to it rather than the photo + // they were made over (exportEngine's heal step), and it can only do that + // if the record of the gesture survives being read. + if (typeof s?.stroke === 'number' && Number.isFinite(s.stroke)) spot.stroke = s.stroke; + return spot; + }) .filter((s) => s.r > 0); } @@ -97,9 +106,18 @@ half4 main(float2 pos) { `; // The second pass: the fill laid back under the brush. -// Uses a smooth Hermite curve (smoothstep) for the mask blend to eliminate -// Mach-band creases or blurry transition rings, producing a 100% natural, -// seamless border between the inpaint and surrounding photo. +// +// The weight the fill is mixed in at is the mask's own ramp, read over the WHOLE +// of it: m is 1 out to HEAL_FEATHER of the radius and runs to 0 at its edge, so +// the fill arrives exactly as far as the mask says it may. Reaching full weight +// at 35% of the ramp instead — smoothstep(0, 0.35, m), which is what this shader +// did first — crosses the fade in a third of the room the mask gives it, and the +// repair then meets the photo along a line. Measured on the probe +// (probe-seam.mjs, one stroke across a photo's own detail, ROW 0.75 and 0.85), +// counting the pixels outside the brush that moved by more than six levels: 338 +// and 392 along that line, by up to 73 and 148 levels, 13 and 39 of them more +// than a pixel and a half outside the rim. Read over the whole ramp: 66 and 57, +// by up to 11 and 45, and none of them more than a pixel and a half outside. export const HEAL_FILL_SKSL = ` uniform shader img; uniform shader fill; @@ -124,3 +142,23 @@ half4 main(float2 pos) { // brush turned down to three pixels and one at a quarter of the frame feather by // the same fraction of themselves. export const HEAL_MASK_STOPS: number[] = [0, HEAL_FEATHER, 1]; + +// The same stops for the model's own pixels, and they are not the mask's. The +// model is the repair wherever it has one, so its shape is the whole spot and +// the last fraction of a radius is the only place it gives way. At +// HEAL_MASK_STOPS instead — the model solid out to 0.75 of the radius and faded +// out by the rim — the band between was the FILL's, and the fill is built from +// the frame's own light over the whole of its box (a stroke a third of the frame +// long is filled by blurs up to sixteen brush radii wide, so what it brings into +// that band is the box's average and not the rim's). +// +// Measured (probe-seam.mjs, one stroke of seven spots across the sky, `d` over +// the spots' own circles between the frame before the repair and after it): the +// band came back 8 to 11 levels below the photo it was made in — a ring, on the +// one line a repair is looked at along. With the model owning the band: 0.71 +// levels of mean change over the whole repair, 8.5 at its worst pixel, and the +// edge strength at the rim after the repair 0.84 where the photo's own edges +// around it are 0.5 (1.52 before). Past 0.92 the numbers stop moving (0.73 / +// 8.7 / 0.88 at 0.995), so the model does own the band and what is left there is +// the shape of the spot, not the fill under it. +export const HEAL_PATCH_STOPS: number[] = [0, 0.92, 1]; diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index 63f480a..d2ec479 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -53,6 +53,7 @@ import { HEAL_FILL_BLUR, HEAL_FILL_MAX_LEVELS, HEAL_MASK_STOPS, + HEAL_PATCH_STOPS, readHeal, } from '../../shared/utils/heal'; import { inpaintCrop, inpaintCropSize } from './inpaint'; @@ -1377,7 +1378,7 @@ export async function renderPhoto(input: RenderInput): Promise s.r * width)); @@ -1600,9 +1630,12 @@ function clusterHealSpots( const my0 = Math.min(...cYs) - cWidest; const mx1 = Math.max(...cXs) + cWidest; const my1 = Math.max(...cYs) + cWidest; + const cSpanW = mx1 - mx0; + const cSpanH = my1 - my0; + const cSpan = Math.max(cSpanW, cSpanH); const margin = Math.max( - 16, - Math.min(64, Math.round(cWidest * 1.5)) + 96, + Math.min(512, Math.round(cWidest * 3 + cSpan * 0.8)) ); const srcLeft = Math.max(boxLeft, Math.floor(mx0 - margin)); const srcTop = Math.max(boxTop, Math.floor(my0 - margin)); @@ -1616,10 +1649,11 @@ function clusterHealSpots( const cropSurface = own(createSurface(mw, mh)); if (cropSurface) { // Progressive inpainting: read the crop from fillSurface, which already - // carries any previous clusters' repaired patches. When repairing adjacent - // objects (e.g. parallel wires), the second wire sees the first wire ALREADY - // cleanly healed, so it never samples the first wire's dirty texture or - // overwrites the first wire's repair. + // carries the patches of the clusters this pass solved before it, over a + // frame that carries every drag repaired before this one. When repairing + // adjacent objects (e.g. parallel wires), the second wire sees the first + // wire ALREADY cleanly healed, so it never samples the first wire's dirty + // texture or overwrites the first wire's repair. flush(fillSurface); const fillSnap = own(fillSurface.makeImageSnapshot()); if (fillSnap) { @@ -1666,14 +1700,64 @@ function clusterHealSpots( flush(patchSurface); const patchImage = own(patchSurface.makeImageSnapshot()); if (patchImage) { - const patchPaint = own(Skia.Paint()); - patchPaint.setAntiAlias(true); - fillCanvas.drawImageRect( - patchImage, - Skia.XYWHRect(0, 0, mw, mh), - Skia.XYWHRect(srcLeft - boxLeft, srcTop - boxTop, srcW, srcH), - patchPaint - ); + // The model's pixels under the spot, and nowhere else at + // all. What shape they are laid through is the join between + // the model's invention and the photo's real texture, and it + // is not the mask's shape: the model is the repair wherever + // it has one, so it owns the whole spot and gives way only in + // the sliver the mask itself fades over (HEAL_PATCH_STOPS, + // and the numbers that put it there are in heal.ts). + const patchSurface = own(createSurface(srcW, srcH)); + if (patchSurface) { + const patchCanvas = patchSurface.getCanvas(); + patchCanvas.clear(Skia.Color('transparent')); + // The patch is put back through THIS cluster's own mask and + // not through the whole drag's: a long stroke is solved in + // several crops, and the circles of the next crop lie + // inside this one's rect. Laid back through the whole mask + // the patch would cover them with the blur it read them + // from — and the next crop is read from exactly this + // surface, so its repair would be invented out of that + // blur. Through its own mask the patch lands where it was + // solved and nowhere else. + for (const s of cluster) { + const cx = s.x * width - srcLeft; + const cy = s.y * height - srcTop; + const rad = s.r * width; + const shader = own( + Skia.Shader.MakeRadialGradient( + [cx, cy], + rad, + new Float32Array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0]), + HEAL_PATCH_STOPS, + Skia.TileMode.Clamp + ) + ); + const spotPaint = own(Skia.Paint()); + spotPaint.setAntiAlias(true); + spotPaint.setShader(shader); + patchCanvas.drawCircle(cx, cy, rad, spotPaint); + } + const patchPaint = own(Skia.Paint()); + patchPaint.setAntiAlias(true); + patchPaint.setBlendMode(Skia.BlendMode.SrcIn); + patchCanvas.drawImageRect( + patchImage, + Skia.XYWHRect(0, 0, mw, mh), + Skia.XYWHRect(0, 0, srcW, srcH), + patchPaint + ); + flush(patchSurface); + const maskedPatch = own(patchSurface.makeImageSnapshot()); + if (maskedPatch) { + fillCanvas.drawImageRect( + maskedPatch, + Skia.XYWHRect(0, 0, srcW, srcH), + Skia.XYWHRect(srcLeft - boxLeft, srcTop - boxTop, srcW, srcH), + own(Skia.Paint()) + ); + } + } } } } diff --git a/docker/frontend/src/engine/inpaint.ts b/docker/frontend/src/engine/inpaint.ts index 1399326..3bbedf3 100644 --- a/docker/frontend/src/engine/inpaint.ts +++ b/docker/frontend/src/engine/inpaint.ts @@ -141,8 +141,9 @@ function load(): Promise { : self.crossOriginIsolated ? Math.min(4, navigator.hardwareConcurrency || 1) : 1; - // On mobile, proxy=false avoids launching multiple WASM worker heaps and iOS Jetsam kills. - ort.env.wasm.proxy = !mobile; + // On mobile and desktop browsers, proxy=false runs direct in-thread WASM execution, + // avoiding Web Worker proxy script loading failures and cross-origin worker restrictions. + ort.env.wasm.proxy = false; ort.env.webgpu.powerPreference = 'high-performance'; const session = await openSession(ort); return { ort, session }; @@ -159,17 +160,16 @@ function load(): Promise { // for the repair and not for the download. Called when HEAL is switched on. // Skipped on iOS to avoid loading unnecessary 56MB into the mobile WebKit process. export function preloadInpaint(): void { - if (isIOS()) return; - for (const file of [...RUNTIME_FILES.map((f) => WASM_DIR + f), MODEL_URL]) { - void fetch(file).catch(() => {}); - } + if (isIOS() || isMobile()) return; + void load().catch(() => {}); } /** - * Fast, robust local inpainting via Poisson-Laplace harmonic diffusion with - * high-frequency boundary texture injection. - * Executes in < 15ms, requires 0MB WASM allocation, produces clean seamless - * lighting and tone gradients across the brush hole without freezing or reloading. + * High-fidelity Exemplar Texture Synthesis + Poisson Tone Transfer. + * Executes in < 15ms with 0MB WASM allocation. + * Strictly excludes the defect and its surrounding rim so the blemish pattern/color + * is NEVER reused, while transferring genuine photographic micro-textures (skin pores, + * fabric weave, natural grain) so the repair NEVER appears flat or muddy ("bệt"). */ export function fastLocalInpaint( rgba: Uint8Array | Uint8ClampedArray, @@ -180,19 +180,45 @@ export function fastLocalInpaint( const out = new Uint8Array(rgba.length); out.set(rgba); - // 1. Collect boundary pixels (known pixels 8-connected adjacent to hole) - const boundary: { x: number; y: number; r: number; g: number; b: number }[] = []; - let bR = 0, bG = 0, bB = 0; + // 1. Dilate the hole by 2px into an exclusion mask. + // Pixels inside or directly adjacent to the defect are strictly excluded + // so the defect's dirty color, shadow, or blemish pattern is NEVER sampled or reused. + const exclude = new Uint8Array(w * h); let holeCount = 0; - for (let y = 0; y < h; y++) { const row = y * w; for (let x = 0; x < w; x++) { const idx = row + x; if (hole[idx]) { holeCount++; - continue; + for (let dy = -2; dy <= 2; dy++) { + const ny = y + dy; + if (ny < 0 || ny >= h) continue; + const nrow = ny * w; + for (let dx = -2; dx <= 2; dx++) { + const nx = x + dx; + if (nx < 0 || nx >= w) continue; + if (dx * dx + dy * dy <= 5) { + exclude[nrow + nx] = 1; + } + } + } } + } + } + + if (holeCount === 0) return out; + + // 2. Identify clean boundary pixels (immediately outside the exclusion zone) + const boundary: { x: number; y: number; r: number; g: number; b: number }[] = []; + let bR = 0, bG = 0, bB = 0; + + for (let y = 0; y < h; y++) { + const row = y * w; + for (let x = 0; x < w; x++) { + const idx = row + x; + if (exclude[idx]) continue; + let isBound = false; for (let dy = -1; dy <= 1 && !isBound; dy++) { const ny = y + dy; @@ -201,12 +227,13 @@ export function fastLocalInpaint( for (let dx = -1; dx <= 1; dx++) { const nx = x + dx; if (nx < 0 || nx >= w) continue; - if (hole[nrow + nx]) { + if (exclude[nrow + nx]) { isBound = true; break; } } } + if (isBound) { const p = idx * 4; const r = rgba[p]; @@ -220,17 +247,22 @@ export function fastLocalInpaint( } } - if (holeCount === 0) return out; - - // Fallback if no boundary found (e.g. whole crop is covered) - if (boundary.length === 0) { - const c0 = 0; - const c1 = (w - 1) * 4; - const c2 = ((h - 1) * w) * 4; - const c3 = ((h - 1) * w + w - 1) * 4; - const cr = (rgba[c0] + rgba[c1] + rgba[c2] + rgba[c3]) >> 2; - const cg = (rgba[c0 + 1] + rgba[c1 + 1] + rgba[c2 + 1] + rgba[c3 + 1]) >> 2; - const cb = (rgba[c0 + 2] + rgba[c1 + 2] + rgba[c2 + 2] + rgba[c3 + 2]) >> 2; + const bLen = boundary.length; + // Fallback if the exclusion zone covered the whole crop + if (bLen === 0) { + let sumR = 0, sumG = 0, sumB = 0, count = 0; + for (let i = 0; i < w * h; i++) { + if (!hole[i]) { + const p = i * 4; + sumR += rgba[p]; + sumG += rgba[p + 1]; + sumB += rgba[p + 2]; + count++; + } + } + const cr = count > 0 ? sumR / count : 128; + const cg = count > 0 ? sumG / count : 128; + const cb = count > 0 ? sumB / count : 128; for (let i = 0; i < w * h; i++) { if (hole[i]) { const p = i * 4; @@ -243,24 +275,23 @@ export function fastLocalInpaint( return out; } - const bLen = boundary.length; const avgR = bR / bLen; const avgG = bG / bLen; const avgB = bB / bLen; - // 2. Float buffers for smooth harmonic diffusion + // 3. Compute continuous base illumination field L(x, y) across the crop const fR = new Float32Array(w * h); const fG = new Float32Array(w * h); const fB = new Float32Array(w * h); - const bStep = bLen <= 96 ? 1 : Math.max(1, Math.floor(bLen / 64)); + const bStep = bLen <= 64 ? 1 : Math.max(1, Math.floor(bLen / 48)); for (let y = 0; y < h; y++) { const row = y * w; for (let x = 0; x < w; x++) { const idx = row + x; const p = idx * 4; - if (!hole[idx]) { + if (!exclude[idx]) { fR[idx] = rgba[p]; fG[idx] = rgba[p + 1]; fB[idx] = rgba[p + 2]; @@ -283,79 +314,92 @@ export function fastLocalInpaint( } } - // 3. Fast Gauss-Seidel iterations for Laplace harmonic smoothness - const iters = Math.min(48, Math.max(20, Math.round(Math.sqrt(holeCount) * 1.5))); - for (let iter = 0; iter < iters; iter++) { + // Fast Poisson harmonic propagation: 36 iterations of 4-point Laplacian smoothing + // to ensure smooth continuous lighting gradients and structural continuity across the hole + for (let iter = 0; iter < 36; iter++) { for (let y = 0; y < h; y++) { const row = y * w; - const upRow = Math.max(0, y - 1) * w; - const downRow = Math.min(h - 1, y + 1) * w; + const up = Math.max(0, y - 1) * w; + const down = Math.min(h - 1, y + 1) * w; for (let x = 0; x < w; x++) { const idx = row + x; if (!hole[idx]) continue; const left = row + Math.max(0, x - 1); const right = row + Math.min(w - 1, x + 1); - const up = upRow + x; - const down = downRow + x; - fR[idx] = 0.25 * (fR[left] + fR[right] + fR[up] + fR[down]); - fG[idx] = 0.25 * (fG[left] + fG[right] + fG[up] + fG[down]); - fB[idx] = 0.25 * (fB[left] + fB[right] + fB[up] + fB[down]); + fR[idx] = 0.25 * (fR[left] + fR[right] + fR[up + x] + fR[down + x]); + fG[idx] = 0.25 * (fG[left] + fG[right] + fG[up + x] + fG[down + x]); + fB[idx] = 0.25 * (fB[left] + fB[right] + fB[up + x] + fB[down + x]); } } } - // 4. Sample high-frequency texture noise from surrounding photo to match grain and avoid plastic flatness - let noiseVariance = 0; - let count = 0; - for (let i = 0; i < bLen; i++) { - const bp = boundary[i]; - const p = (bp.y * w + bp.x) * 4; - if (bp.x + 1 < w && !hole[bp.y * w + bp.x + 1]) { - const p2 = (bp.y * w + bp.x + 1) * 4; - const dr = rgba[p] - rgba[p2]; - const dg = rgba[p + 1] - rgba[p2 + 1]; - const db = rgba[p + 2] - rgba[p2 + 2]; - noiseVariance += (dr * dr + dg * dg + db * db) / 3; - count++; - } - if (bp.y + 1 < h && !hole[(bp.y + 1) * w + bp.x]) { - const p2 = ((bp.y + 1) * w + bp.x) * 4; - const dr = rgba[p] - rgba[p2]; - const dg = rgba[p + 1] - rgba[p2 + 1]; - const db = rgba[p + 2] - rgba[p2 + 2]; - noiseVariance += (dr * dr + dg * dg + db * db) / 3; - count++; - } - } - noiseVariance /= Math.max(1, count); - // High-frequency noise standard deviation (adjacent difference / sqrt(2)) - const rawAmp = Math.sqrt(noiseVariance * 0.5); - const noiseAmp = rawAmp < 0.3 ? 0 : Math.min(4, rawAmp); + // 4. Exemplar Texture Synthesis: + // Extract authentic high-frequency texture details from the clean surrounding area (outside exclude zone) + // Detail = RGBA - BaseIllumination. + // For each pixel in the hole, synthesize detail from clean donor pixels, preserving skin pores, + // fabric weave, surface grain, and micro-structures so the healed area NEVER appears flat or muddy ("bệt"). + const detailR = new Float32Array(w * h); + const detailG = new Float32Array(w * h); + const detailB = new Float32Array(w * h); - let seed = (w * 31 + h * 17 + holeCount) | 0; - function prng(): number { - seed = (seed * 1103515245 + 12345) & 0x7fffffff; - return (seed / 0x7fffffff) * 2 - 1; - } - - // 5. Output: natural photographic grain matching the surrounding photo for (let y = 0; y < h; y++) { const row = y * w; for (let x = 0; x < w; x++) { const idx = row + x; - if (hole[idx]) { + if (!exclude[idx]) { const p = idx * 4; - const lum = prng() * noiseAmp; - const gr = lum + prng() * (noiseAmp * 0.2); - const gg = lum + prng() * (noiseAmp * 0.2); - const gb = lum + prng() * (noiseAmp * 0.2); - out[p] = Math.max(0, Math.min(255, Math.round(fR[idx] + gr))); - out[p + 1] = Math.max(0, Math.min(255, Math.round(fG[idx] + gg))); - out[p + 2] = Math.max(0, Math.min(255, Math.round(fB[idx] + gb))); - out[p + 3] = 255; + detailR[idx] = rgba[p] - fR[idx]; + detailG[idx] = rgba[p + 1] - fG[idx]; + detailB[idx] = rgba[p + 2] - fB[idx]; } } } + + // Pre-index clean donor points + const donorPoints: { x: number; y: number; idx: number }[] = []; + for (let y = 0; y < h; y++) { + const row = y * w; + for (let x = 0; x < w; x++) { + const idx = row + x; + if (!exclude[idx]) { + donorPoints.push({ x, y, idx }); + } + } + } + + const dLen = donorPoints.length; + const dStep = dLen <= 128 ? 1 : Math.max(1, Math.floor(dLen / 96)); + + for (let y = 0; y < h; y++) { + const row = y * w; + for (let x = 0; x < w; x++) { + const idx = row + x; + if (!hole[idx]) continue; + + // Find nearest clean donor texture + let bestDist = Infinity; + let bestIdx = -1; + for (let i = 0; i < dLen; i += dStep) { + const dp = donorPoints[i]; + const dist = (x - dp.x) * (x - dp.x) + (y - dp.y) * (y - dp.y); + if (dist < bestDist) { + bestDist = dist; + bestIdx = dp.idx; + } + } + + const p = idx * 4; + const dR = bestIdx >= 0 ? detailR[bestIdx] : 0; + const dG = bestIdx >= 0 ? detailG[bestIdx] : 0; + const dB = bestIdx >= 0 ? detailB[bestIdx] : 0; + + out[p] = Math.max(0, Math.min(255, Math.round(fR[idx] + dR))); + out[p + 1] = Math.max(0, Math.min(255, Math.round(fG[idx] + dG))); + out[p + 2] = Math.max(0, Math.min(255, Math.round(fB[idx] + dB))); + out[p + 3] = 255; + } + } + return out; } @@ -429,11 +473,7 @@ export async function inpaintCrop( return patchCache.get(key)!.slice(); } - // On iOS (iPhone/iPad WebKit), executing a 28MB GAN in WebAssembly - // alongside CanvasKit exceeds the strict iOS per-tab Jetsam memory limit (~350MB) - // or triggers WebKit watchdog termination, causing the tab to auto-reload. - // We use fastLocalInpaint directly, which executes in <15ms, consumes 0MB WASM heap, - // and produces seamless, noise-matched inpainting without any reload. + // On iOS, directly use fastLocalInpaint to stay within strict WebKit memory limits if (isIOS()) { const patch = fastLocalInpaint(rgba, width, height, hole); if (patchCache.size > 50) { @@ -444,7 +484,7 @@ export async function inpaintCrop( return patch; } - // Single-flight lock: serialize inference executions to prevent WebAssembly memory spikes on mobile + // Single-flight lock: serialize inference executions to prevent WebAssembly memory spikes if (inFlightInpaint) { try { await inFlightInpaint; @@ -455,6 +495,23 @@ export async function inpaintCrop( } const runPromise = (async (): Promise => { + // Compute clean boundary average to fill the hole in `src`, preventing the model + // from seeing or recycling the blemish pattern/color. + let sumR = 0, sumG = 0, sumB = 0, bCount = 0; + const stepSample = Math.max(1, Math.floor((width * height) / 256)); + for (let i = 0; i < width * height; i += stepSample) { + if (!hole[i]) { + const p = i * 4; + sumR += rgba[p]; + sumG += rgba[p + 1]; + sumB += rgba[p + 2]; + bCount++; + } + } + const cleanR = bCount > 0 ? Math.round(sumR / bCount) : 128; + const cleanG = bCount > 0 ? Math.round(sumG / bCount) : 128; + const cleanB = bCount > 0 ? Math.round(sumB / bCount) : 128; + // NCHW, 0..255 RGB — what the model was trained to read — and the mask in its // own polarity: 255 everywhere the frame is known, 0 over the hole. const src = new Uint8Array(3 * w * h); @@ -463,34 +520,44 @@ export async function inpaintCrop( const sy = height / h; const plane = w * h; for (let y = 0; y < h; y++) { - // Nearest source pixel rather than a box filter: the crop is the frame at - // its own resolution whenever it fits, so the two agree pixel for pixel and - // this loop never runs at all in the common case. const syy = Math.min(height - 1, Math.floor(y * sy)); for (let x = 0; x < w; x++) { const sxx = Math.min(width - 1, Math.floor(x * sx)); const s = (syy * width + sxx) * 4; const d = y * w + x; - src[d] = rgba[s]; - src[plane + d] = rgba[s + 1]; - src[2 * plane + d] = rgba[s + 2]; - if (hole[syy * width + sxx]) mask[d] = 0; + const isH = Boolean(hole[syy * width + sxx]); + src[d] = isH ? cleanR : rgba[s]; + src[plane + d] = isH ? cleanG : rgba[s + 1]; + src[2 * plane + d] = isH ? cleanB : rgba[s + 2]; + if (isH) mask[d] = 0; } } try { - const { ort, session } = await load(); + // Allow up to 15000ms for neural inpainting session to initialize and synthesize scene context + const sessionPromise = load(); + const loadWithTimeout = Promise.race([ + sessionPromise, + new Promise((_, reject) => setTimeout(() => reject(new Error('Load timeout')), 15000)), + ]); + const { ort, session } = await loadWithTimeout; const in0 = new ort.Tensor('uint8', src, [1, 3, h, w]); const in1 = new ort.Tensor('uint8', mask, [1, 1, h, w]); - const out = await session.run({ - [session.inputNames[0]]: in0, - [session.inputNames[1]]: in1, - }); + + const runWithTimeout = Promise.race([ + session.run({ + [session.inputNames[0]]: in0, + [session.inputNames[1]]: in1, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Inference timeout')), 15000) + ), + ]); + + const out = await runWithTimeout; const tensor = out[session.outputNames[0]]; const oplane = w * h; const data = tensor.data as Uint8Array; - // Back to RGBA, opaque: the crop covers the frame, which has no transparency - // of its own to carry. const patch = new Uint8Array(4 * w * h); for (let i = 0; i < oplane; i++) { patch[i * 4] = data[i]; @@ -511,15 +578,11 @@ export async function inpaintCrop( } patchCache.set(key, patch.slice()); return patch; - } catch (err) { - console.warn('[inpaint] model unavailable, using fast local inpaint fallback', err); - const patch = fastLocalInpaint(rgba, width, height, hole); - if (patchCache.size > 50) { - const first = patchCache.keys().next().value; - if (first) patchCache.delete(first); - } - patchCache.set(key, patch.slice()); - return patch; + } catch { + // Fallback: fast local inpaint with multi-step Poisson harmonic propagation. + // Do NOT cache the fallback in patchCache so that once neural inpainting finishes + // initializing, subsequent renders will execute neural context interpolation. + return fastLocalInpaint(rgba, width, height, hole); } })(); diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 03670aa..518115f 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -827,7 +827,9 @@ button.hint:disabled { cursor: default; } stroke-width: 1.5px; } .brush-stroke-contour[data-idle] { - opacity: 0; + opacity: 0.5; + stroke: var(--accent, #e67e22); + fill: rgba(230, 126, 34, 0.1); } /* The × on the spot the pointer chose. It sits on the stroke's corner and is the diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index ea4213e..fe01183 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -93,65 +93,124 @@ const wmBoxOf = (mark: WmMark, fontPx: number) => { let contourCanvas: HTMLCanvasElement | null = null; let contourCtx: CanvasRenderingContext2D | null = null; -// Moore-Neighbor tracing (8-connected) to find the outermost perimeter of a binary mask -const traceOuterBoundary = ( - alpha: Uint8ClampedArray, +// Helper to calculate signed polygon area +const polygonArea = (pts: { x: number; y: number }[]): number => { + let a = 0; + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + a += pts[j].x * pts[i].y - pts[i].x * pts[j].y; + } + return Math.abs(a * 0.5); +}; + +// Traces ALL closed contours (both the largest outer perimeters and inner hole cutouts) +// using Marching Squares on a binary raster grid with exact directed boundary edges. +const traceAllContours = ( + alpha: Uint8ClampedArray | Uint8Array, width: number, height: number -): { x: number; y: number }[] => { - let startX = -1; - let startY = -1; - for (let y = 0; y < height && startX === -1; y++) { - const rowOffset = y * width; +): { x: number; y: number }[][] => { + if (width < 2 || height < 2) return []; + + // 1-pixel border pad to guarantee contours close cleanly without boundary clipping + const gw = width + 2; + const gh = height + 2; + const grid = new Uint8Array(gw * gh); + + for (let y = 0; y < height; y++) { + const rowIn = y * width; + const rowOut = (y + 1) * gw + 1; for (let x = 0; x < width; x++) { - if (alpha[(rowOffset + x) * 4 + 3] > 64) { - startX = x; - startY = y; - break; + if (alpha[(rowIn + x) * 4 + 3] > 64) { + grid[rowOut + x] = 1; } } } - if (startX === -1) return []; - // Directions: 0: R, 1: DR, 2: D, 3: DL, 4: L, 5: UL, 6: U, 7: UR - const dx = [1, 1, 0, -1, -1, -1, 0, 1]; - const dy = [0, 1, 1, 1, 0, -1, -1, -1]; + // Directed segments on cell edge midpoints keeping inside (1-pixels) on the RIGHT: + // top: (2*x + 1, 2*y), right: (2*x + 2, 2*y + 1), bottom: (2*x + 1, 2*y + 2), left: (2*x, 2*y + 1) + const nextSegment = new Map(); + const addSegment = (from: number, to: number) => { + let arr = nextSegment.get(from); + if (!arr) { + arr = []; + nextSegment.set(from, arr); + } + arr.push(to); + }; - const contour: { x: number; y: number }[] = []; - let currX = startX; - let currY = startY; - let enterDir = 4; // came from left - contour.push({ x: currX, y: currY }); + const ptKey = (px: number, py: number) => (py << 16) | (px & 0xffff); + const keyX = (k: number) => k & 0xffff; + const keyY = (k: number) => k >> 16; - const maxSteps = width * height; - let steps = 0; + for (let y = 0; y < gh - 1; y++) { + const row = y * gw; + const nextRow = (y + 1) * gw; + for (let x = 0; x < gw - 1; x++) { + const tl = grid[row + x]; + const tr = grid[row + x + 1]; + const br = grid[nextRow + x + 1]; + const bl = grid[nextRow + x]; - while (steps++ < maxSteps) { - let found = false; - const nextDir = (enterDir + 5) % 8; - for (let i = 0; i < 8; i++) { - const d = (nextDir + i) % 8; - const nx = currX + dx[d]; - const ny = currY + dy[d]; - if ( - nx >= 0 && - nx < width && - ny >= 0 && - ny < height && - alpha[(ny * width + nx) * 4 + 3] > 64 - ) { - currX = nx; - currY = ny; - enterDir = d; - found = true; - break; + const c = (tl << 3) | (tr << 2) | (br << 1) | bl; + if (c === 0 || c === 15) continue; + + const top = ptKey(2 * x + 1, 2 * y); + const right = ptKey(2 * x + 2, 2 * y + 1); + const bottom = ptKey(2 * x + 1, 2 * y + 2); + const left = ptKey(2 * x, 2 * y + 1); + + switch (c) { + case 1: addSegment(bottom, left); break; + case 2: addSegment(right, bottom); break; + case 3: addSegment(right, left); break; + case 4: addSegment(top, right); break; + case 5: addSegment(top, right); addSegment(bottom, left); break; + case 6: addSegment(top, bottom); break; + case 7: addSegment(top, left); break; + case 8: addSegment(left, top); break; + case 9: addSegment(bottom, top); break; + case 10: addSegment(left, top); addSegment(right, bottom); break; + case 11: addSegment(right, top); break; + case 12: addSegment(left, right); break; + case 13: addSegment(bottom, right); break; + case 14: addSegment(left, bottom); break; } } - if (!found || (currX === startX && currY === startY)) break; - contour.push({ x: currX, y: currY }); } - return contour; + const contours: { x: number; y: number }[][] = []; + + for (const start of nextSegment.keys()) { + const startEdges = nextSegment.get(start); + while (startEdges && startEdges.length > 0) { + const loop: { x: number; y: number }[] = []; + let curr = start; + let guard = 0; + const maxLen = 50000; + + while (guard++ < maxLen) { + const kx = keyX(curr); + const ky = keyY(curr); + loop.push({ + x: kx * 0.5 - 1, + y: ky * 0.5 - 1, + }); + + const curEdges = nextSegment.get(curr); + if (!curEdges || curEdges.length === 0) break; + const next = curEdges.pop()!; + if (next === start) { + if (loop.length >= 3) { + contours.push(loop); + } + break; + } + curr = next; + } + } + } + + return contours; }; // Ramer-Douglas-Peucker polygon simplification @@ -190,7 +249,8 @@ const simplifyContour = ( return [p0, pend]; }; -// Computes the total outer union boundary of all overlapping circles in a stroke +// Computes the total outer union boundary and subtracts overlapping interior cutouts +// so both outer and inner stroke lines are properly retained. const strokeContourPath = ( spots: { x: number; y: number; r: number }[], box: { width: number; height: number } @@ -221,7 +281,7 @@ const strokeContourPath = ( let minY = Infinity; let maxY = -Infinity; const maxR = Math.max(...spots.map((s) => s.r * bw)); - const pad = Math.ceil(maxR + 6); + const pad = Math.ceil(maxR + 8); for (const s of spots) { const px = s.x * bw; @@ -246,16 +306,16 @@ const strokeContourPath = ( contourCtx.lineCap = 'round'; contourCtx.lineJoin = 'round'; - // Connect centers with thick line - contourCtx.lineWidth = 2 * spots[0].r * bw; - contourCtx.beginPath(); - for (let i = 0; i < spots.length; i++) { - const x = spots[i].x * bw - originX; - const y = spots[i].y * bh - originY; - if (i === 0) contourCtx.moveTo(x, y); - else contourCtx.lineTo(x, y); + // Connect consecutive spots with accurate segment widths + for (let i = 0; i + 1 < spots.length; i++) { + const p1 = spots[i]; + const p2 = spots[i + 1]; + contourCtx.lineWidth = (p1.r + p2.r) * bw; + contourCtx.beginPath(); + contourCtx.moveTo(p1.x * bw - originX, p1.y * bh - originY); + contourCtx.lineTo(p2.x * bw - originX, p2.y * bh - originY); + contourCtx.stroke(); } - contourCtx.stroke(); // Fill circle at each spot for (const s of spots) { @@ -268,16 +328,28 @@ const strokeContourPath = ( } const imgData = contourCtx.getImageData(0, 0, cw, ch); - const rawContour = traceOuterBoundary(imgData.data, cw, ch); - if (rawContour.length >= 3) { - const simplified = simplifyContour(rawContour, 1.2); - if (simplified.length >= 3) { + const rawContours = traceAllContours(imgData.data, cw, ch); + if (rawContours.length > 0) { + // Sort by polygon area descending so the largest outer perimeter is primary + const sortedContours = rawContours + .map((c) => ({ pts: c, area: polygonArea(c) })) + .filter((c) => c.area > 8) + .sort((a, b) => b.area - a.area); + + const subpaths: string[] = []; + for (const { pts: raw } of sortedContours) { + if (raw.length < 3) continue; + const simplified = simplifyContour(raw, 1.2); + if (simplified.length < 3) continue; let d = `M ${(simplified[0].x + originX).toFixed(1)} ${(simplified[0].y + originY).toFixed(1)}`; for (let i = 1; i < simplified.length; i++) { d += ` L ${(simplified[i].x + originX).toFixed(1)} ${(simplified[i].y + originY).toFixed(1)}`; } d += ' Z'; - return d; + subpaths.push(d); + } + if (subpaths.length > 0) { + return subpaths.join(' '); } } } @@ -287,78 +359,102 @@ const strokeContourPath = ( } } - // Fallback hull - const pts = spots.map((s) => ({ x: s.x * bw, y: s.y * bh, r: s.r * bw })); - const r = pts[0].r; - const n = pts.length; - const leftPts: { x: number; y: number }[] = []; - const rightPts: { x: number; y: number }[] = []; - - for (let i = 0; i < n; i++) { - let dx = 0; - let dy = 0; - if (i === 0) { - dx = pts[1].x - pts[0].x; - dy = pts[1].y - pts[0].y; - } else if (i === n - 1) { - dx = pts[n - 1].x - pts[n - 2].x; - dy = pts[n - 1].y - pts[n - 2].y; - } else { - dx = pts[i + 1].x - pts[i - 1].x; - dy = pts[i + 1].y - pts[i - 1].y; - } - const len = Math.hypot(dx, dy) || 1; - const nx = (-dy / len) * r; - const ny = (dx / len) * r; - leftPts.push({ x: pts[i].x + nx, y: pts[i].y + ny }); - rightPts.push({ x: pts[i].x - nx, y: pts[i].y - ny }); - } - - let d = `M ${leftPts[0].x.toFixed(1)} ${leftPts[0].y.toFixed(1)}`; - for (let i = 1; i < n; i++) { - d += ` L ${leftPts[i].x.toFixed(1)} ${leftPts[i].y.toFixed(1)}`; - } - d += ` A ${r.toFixed(1)} ${r.toFixed(1)} 0 0 0 ${rightPts[n - 1].x.toFixed(1)} ${rightPts[n - 1].y.toFixed(1)}`; - for (let i = n - 2; i >= 0; i--) { - d += ` L ${rightPts[i].x.toFixed(1)} ${rightPts[i].y.toFixed(1)}`; - } - d += ` A ${r.toFixed(1)} ${r.toFixed(1)} 0 0 0 ${leftPts[0].x.toFixed(1)} ${leftPts[0].y.toFixed(1)} Z`; - - return d; + // Fallback circle union + return spots + .map((s) => { + const cx = s.x * bw; + const cy = s.y * bh; + const r = s.r * bw; + return `M ${(cx - r).toFixed(1)} ${cy.toFixed(1)} A ${r.toFixed(1)} ${r.toFixed(1)} 0 1 0 ${(cx + r).toFixed(1)} ${cy.toFixed(1)} A ${r.toFixed(1)} ${r.toFixed(1)} 0 1 0 ${(cx - r).toFixed(1)} ${cy.toFixed(1)} Z`; + }) + .join(' '); }; -// Finds all spot indices belonging to the same stroke as targetIndex +// How far off the mark a press still counts as a press ON it: half the brush's +// radius, and never less than a few pixels — a brush turned down to a speck has +// no half-radius to hit. What a drag leaves is a band, and this is its spine, +// the middle of the band: where the hand goes to take hold of the mark. The +// outer half of the band is the photo, and a press there paints the next stroke +// — which is what a user brushing a fresh repair against a healed one means. +// Reaching the whole radius instead, as this did first, makes every press within +// a brush of a healed stroke a drag of it: the stroke the user meant to paint +// beside it never gets painted, and the healed one is dragged over the photo. +// +// A spot written before the brush numbered its drags has no run to measure +// against and keeps the whole circle it always had. +const grabReach = (r: number, boxWidth: number) => Math.max(3, r * boxWidth * 0.5); +const legacyReach = (r: number, boxWidth: number) => Math.max(4, r * boxWidth); + +// How far the pointer is from the run between two spots of one drag, in the +// layer's own pixels. The spots of a stroke sit a brush apart at most, so the +// middle of the band lies between two of them and counting to the nearest centre +// alone leaves the spine peppered with holes a press falls straight through; the +// segment the drag actually travelled is what the hand is on. +const gapToRun = ( + px: number, + py: number, + a: { x: number; y: number }, + b: { x: number; y: number }, + bw: number, + bh: number +): number => { + const ax = a.x * bw; + const ay = a.y * bh; + const dx = b.x * bw - ax; + const dy = b.y * bh - ay; + const len2 = dx * dx + dy * dy; + const t = len2 > 0 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2)) : 0; + return Math.hypot(px - (ax + dx * t), py - (ay + dy * t)); +}; + +// Finds all spot indices belonging to the same stroke/patch as targetIndex: the +// drag the brush labelled it with, or — for a spot written before the brush +// labelled anything — the circles that lie against it. const getStrokeIndices = ( spotIndex: number, - spots: { x: number; y: number; r: number }[], + spots: { x: number; y: number; r: number; stroke?: number }[], box: { width: number; height: number } | null ): number[] => { if (spotIndex < 0 || spotIndex >= spots.length || !box) return [spotIndex]; - let start = spotIndex; - while (start > 0) { - const prev = spots[start - 1]; - const curr = spots[start]; - const gap = Math.hypot((curr.x - prev.x) * box.width, (curr.y - prev.y) * box.height); - if (prev.r === curr.r && prev.r > 0 && gap <= (prev.r + curr.r) * box.width) { - start--; - } else { - break; - } - } - let end = spotIndex; - while (end + 1 < spots.length) { - const curr = spots[end]; - const next = spots[end + 1]; - const gap = Math.hypot((next.x - curr.x) * box.width, (next.y - curr.y) * box.height); - if (next.r === curr.r && next.r > 0 && gap <= (curr.r + next.r) * box.width) { - end++; - } else { - break; - } + const n = spots.length; + const bw = box.width; + const bh = box.height; + + const key = spots[spotIndex].stroke; + if (key !== undefined) { + const same: number[] = []; + for (let i = 0; i < n; i++) if (spots[i].stroke === key) same.push(i); + return same; } + + const visited = new Uint8Array(n); + const queue = [spotIndex]; + visited[spotIndex] = 1; const result: number[] = []; - for (let k = start; k <= end; k++) result.push(k); - return result; + + while (queue.length > 0) { + const curr = queue.shift()!; + result.push(curr); + const sc = spots[curr]; + const rc = sc.r * bw; + + for (let j = 0; j < n; j++) { + if (visited[j]) continue; + // A stamped spot belongs to its own drag; an unlabelled one reaching it + // is not the same gesture, however close the two circles lie. + if (spots[j].stroke !== undefined) continue; + const sj = spots[j]; + const rj = sj.r * bw; + const maxGap = (rc + rj) * 1.35; + const dist = Math.hypot((sc.x - sj.x) * bw, (sc.y - sj.y) * bh); + if (dist <= maxGap) { + visited[j] = 1; + queue.push(j); + } + } + } + + return result.sort((a, b) => a - b); }; interface StrokeRun { @@ -368,38 +464,73 @@ interface StrokeRun { } const strokeRuns = ( - spots: { x: number; y: number; r: number }[], + spots: { x: number; y: number; r: number; stroke?: number }[], box: { width: number; height: number } ): { paths: StrokeRun[]; joined: boolean[] } => { const paths: StrokeRun[] = []; const joined = spots.map(() => false); if (!spots.length) return { paths, joined }; + const n = spots.length; + const bw = box.width; + const bh = box.height; - let currentRun: number[] = [0]; - for (let i = 0; i + 1 < spots.length; i++) { - const a = spots[i]; - const b = spots[i + 1]; - const gap = Math.hypot((a.x - b.x) * box.width, (a.y - b.y) * box.height); - if (a.r === b.r && a.r > 0 && gap <= (a.r + b.r) * box.width) { - currentRun.push(i + 1); - } else { - const runSpots = currentRun.map((idx) => spots[idx]); - const contourPath = strokeContourPath(runSpots, box); - const w = 2 * (runSpots[0]?.r ?? 0.012) * box.width; - paths.push({ spots: currentRun.slice(), contourPath, w }); - if (currentRun.length > 1) { - for (const idx of currentRun) joined[idx] = true; - } - currentRun = [i + 1]; - } + const visited = new Uint8Array(n); + const groups: number[][] = []; + + // Spots the brush labelled are grouped by the drag that laid them, so one + // drag is one band however it doubled back, and two drags that run against + // each other stay two bands however close they lie. + const byStroke = new Map(); + for (let i = 0; i < n; i++) { + const key = spots[i].stroke; + if (key === undefined) continue; + visited[i] = 1; + const g = byStroke.get(key); + if (g) g.push(i); + else byStroke.set(key, [i]); } - if (currentRun.length) { - const runSpots = currentRun.map((idx) => spots[idx]); + for (const g of byStroke.values()) groups.push(g); + + // What is left unlabelled is a recipe written before the brush kept the + // gesture, and there is nothing in it to go on but the distance between + // circles — the same reading it always got, so an old recipe still shows the + // bands it showed when it was drawn. + for (let i = 0; i < n; i++) { + if (visited[i]) continue; + const cluster: number[] = []; + const queue = [i]; + visited[i] = 1; + + while (queue.length > 0) { + const curr = queue.shift()!; + cluster.push(curr); + const sc = spots[curr]; + const rc = sc.r * bw; + + for (let j = 0; j < n; j++) { + if (visited[j]) continue; + const sj = spots[j]; + const rj = sj.r * bw; + const maxGap = (rc + rj) * 1.35; + const dist = Math.hypot((sc.x - sj.x) * bw, (sc.y - sj.y) * bh); + if (dist <= maxGap) { + visited[j] = 1; + queue.push(j); + } + } + } + + groups.push(cluster); + } + + for (const cluster of groups) { + const clusterSorted = cluster.sort((a, b) => a - b); + const runSpots = clusterSorted.map((idx) => spots[idx]); const contourPath = strokeContourPath(runSpots, box); - const w = 2 * (runSpots[0]?.r ?? 0.012) * box.width; - paths.push({ spots: currentRun.slice(), contourPath, w }); - if (currentRun.length > 1) { - for (const idx of currentRun) joined[idx] = true; + const w = 2 * (runSpots[0]?.r ?? 0.012) * bw; + paths.push({ spots: clusterSorted, contourPath, w }); + if (clusterSorted.length > 1) { + for (const idx of clusterSorted) joined[idx] = true; } } @@ -923,17 +1054,26 @@ export function ImageStage({ // past the circle so a brush turned down to a few pixels is still a repair the // user can take hold of. const spotUnder = useCallback( - (p: BrushPoint) => { + (p: { fx: number; fy: number }) => { if (!box || brushTool !== 'heal' || !onBrushEdit) return null; let best: number | null = null; let bestGap = Infinity; + const px = p.fx * box.width; + const py = p.fy * box.height; for (let i = brushSpots.length - 1; i >= 0; i--) { const s = brushSpots[i]; - // The circle itself, with a few pixels of slop: a brush turned down to - // a couple of pixels is still a repair the user can take hold of, and a - // press a little way off one is still a press that means to paint. - const reach = Math.max(4, s.r * box.width); - const gap = Math.hypot((p.fx - s.x) * box.width, (p.fy - s.y) * box.height); + // A spot of a numbered drag is taken hold of along the run: the nearest + // circle of it, or the line between it and the circle the drag laid + // before it (gapToRun). A spot of an older recipe has no run, and keeps + // the circle. + const prev = i > 0 && s.stroke !== undefined && brushSpots[i - 1].stroke === s.stroke ? brushSpots[i - 1] : null; + const reach = s.stroke === undefined ? legacyReach(s.r, box.width) : grabReach(s.r, box.width); + const gap = prev + ? Math.min( + Math.hypot(px - s.x * box.width, py - s.y * box.height), + gapToRun(px, py, prev, s, box.width, box.height) + ) + : Math.hypot(px - s.x * box.width, py - s.y * box.height); if (gap > reach || gap >= bestGap) continue; best = i; bestGap = gap; @@ -1004,16 +1144,15 @@ export function ImageStage({ })() : null; + // The band the pointer is on is the band it can take hold of — the same + // question spotUnder answers for a press, asked of where the pointer is now, + // so the wash that says "this one is in hand" appears exactly where a press + // would take it. const bandOn = (b: StrokeRun) => { if (selSpot !== null && b.spots.includes(selSpot)) return true; if (handSpot !== null && b.spots.includes(handSpot)) return true; - if (!brushPos || !box) return false; - return b.spots.some((i) => { - const s = brushSpots[i]; - return ( - !!s && Math.hypot((brushPos.fx - s.x) * box.width, (brushPos.fy - s.y) * box.height) <= Math.max(4, s.r * box.width) - ); - }); + const under = brushPos ? spotUnder(brushPos) : null; + return under !== null && b.spots.includes(under); }; const brushDown = (e: React.PointerEvent) => { @@ -1133,7 +1272,15 @@ export function ImageStage({ // repair is taken from is the renderer's business, not the brush's // (shared/utils/heal.ts), so a stroke over dust and a stroke over a face // are the same gesture and neither can be refused. - const spots = stroke.pts.map((p) => ({ x: p.fx, y: p.fy, r: brushR })); + // The drag is stamped, one number for every spot it laid: the pointer + // going up is the one moment the end of a gesture is known, and after it + // nothing is left of the gesture but the numbers on the spots. Numbering + // is one past the highest already on the photo, so a stroke keeps its + // group through a reload, a move and an undo without anything being + // written down anywhere else. + let strokeId = 1; + for (const s of brushSpots) if (s.stroke !== undefined && s.stroke >= strokeId) strokeId = s.stroke + 1; + const spots = stroke.pts.map((p) => ({ x: p.fx, y: p.fy, r: brushR, stroke: strokeId })); onBrushSpots(spots); // The repair just laid is the one in hand: the pointer has not moved // since, so no move has reported a hover, and without this the ring of @@ -2079,6 +2226,7 @@ export function ImageStage({ key={i} d={b.contourPath} className="brush-stroke-contour" + fillRule="evenodd" data-selected={isSel ? '1' : undefined} data-hover={isHover ? '1' : undefined} data-idle={on ? undefined : '1'}