---
format: typebulb/v1
name: Magic Eraser
---

**code.tsx**

```tsx
import React, { useState, useRef, useEffect, useLayoutEffect, useCallback } from "react"
import { createRoot } from "react-dom/client"

interface Pt { x: number; y: number }
interface Box { x: number; y: number; w: number; h: number }
interface Patch { box: Box; before: Uint8ClampedArray }

const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi)
const BRUSH_MIN = 6, BRUSH_MAX = 120   // brush diameter range, in screen px
// Width of the texture ring sampled around a stroke, in image px.
const ringOf = (brush: number) => clamp(Math.round(brush * 0.6), 4, 24)

function bboxOf(pts: Pt[], pad: number, W: number, H: number): Box {
  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity
  for (const p of pts) { x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y); x1 = Math.max(x1, p.x); y1 = Math.max(y1, p.y) }
  const bx = clamp(Math.floor(x0 - pad), 0, W - 1), by = clamp(Math.floor(y0 - pad), 0, H - 1)
  return { x: bx, y: by, w: clamp(Math.ceil(x1 + pad) + 1 - bx, 1, W - bx), h: clamp(Math.ceil(y1 + pad) + 1 - by, 1, H - by) }
}

// The stroke as a box-local coverage mask (0..255), round caps and joins.
function coverage(pts: Pt[], width: number, box: Box): Uint8Array {
  const c = document.createElement("canvas"); c.width = box.w; c.height = box.h
  const x = c.getContext("2d", { willReadFrequently: true })!
  x.translate(-box.x, -box.y)
  x.lineWidth = width; x.lineCap = "round"; x.lineJoin = "round"; x.strokeStyle = "#000"
  x.beginPath(); x.moveTo(pts[0].x, pts[0].y)
  if (pts.length === 1) x.lineTo(pts[0].x + 0.01, pts[0].y)
  for (let i = 1; i < pts.length; i++) x.lineTo(pts[i].x, pts[i].y)
  x.stroke()
  const a = x.getImageData(0, 0, box.w, box.h).data
  const m = new Uint8Array(box.w * box.h)
  for (let i = 0; i < m.length; i++) m[i] = a[i * 4 + 3]
  return m
}

// Points every `step` px along the path, so pieces have even length however fast the pointer moved.
function resample(pts: Pt[], step: number): Pt[] {
  const out: Pt[] = [pts[0]]
  let carry = step
  for (let i = 1; i < pts.length; i++) {
    const a = pts[i - 1], b = pts[i], d = Math.hypot(b.x - a.x, b.y - a.y)
    if (!d) continue
    let t = carry
    while (t <= d) { out.push({ x: a.x + (b.x - a.x) * t / d, y: a.y + (b.y - a.y) * t / d }); t += step }
    carry = t - d
  }
  const last = pts[pts.length - 1], o = out[out.length - 1]
  if (o.x !== last.x || o.y !== last.y) out.push(last)
  return out
}

function copyBox(img: ImageData, b: Box): Uint8ClampedArray {
  const out = new Uint8ClampedArray(b.w * b.h * 4)
  for (let y = 0; y < b.h; y++) out.set(img.data.subarray(((b.y + y) * img.width + b.x) * 4, ((b.y + y) * img.width + b.x + b.w) * 4), y * b.w * 4)
  return out
}
function pasteBox(img: ImageData, b: Box, px: Uint8ClampedArray) {
  for (let y = 0; y < b.h; y++) img.data.set(px.subarray(y * b.w * 4, (y + 1) * b.w * 4), ((b.y + y) * img.width + b.x) * 4)
}

// The offset whose shifted copy of the ring best matches the ring itself (SSD over RGB), coarse grid then
// a 1px refine. A shifted mask may not overlap the mask: the blemish must never be its own source.
function bestOffset(data: Uint8ClampedArray, W: number, H: number, box: Box, mask: Uint8Array, maskIdx: Int32Array, ringIdx: Int32Array): [number, number] | null {
  const { x: bx, y: by, w: bw, h: bh } = box
  const rstride = Math.max(1, Math.ceil(ringIdx.length / 1200))
  const rs = new Int32Array(Math.ceil(ringIdx.length / rstride))
  for (let k = 0, j = 0; k < ringIdx.length; k += rstride) { const i = ringIdx[k]; rs[j++] = ((by + (i / bw | 0)) * W + bx + (i % bw)) * 4 }
  // Overlap is tested on a 4x-downsampled, one-cell-dilated mask: conservative and 16x cheaper.
  const cw = (bw + 3) >> 2, ch = (bh + 3) >> 2
  const cm = new Uint8Array(cw * ch), cd = new Uint8Array(cw * ch)
  let mx0 = bw, my0 = bh, mx1 = 0, my1 = 0
  for (let k = 0; k < maskIdx.length; k++) {
    const i = maskIdx[k], lx = i % bw, ly = (i / bw) | 0
    cm[(ly >> 2) * cw + (lx >> 2)] = 1
    if (lx < mx0) mx0 = lx; if (lx > mx1) mx1 = lx; if (ly < my0) my0 = ly; if (ly > my1) my1 = ly
  }
  const cells: number[] = []
  for (let c = 0; c < cm.length; c++) if (cm[c]) {
    cells.push(c)
    const cx = c % cw, cy = (c / cw) | 0
    for (let y = Math.max(0, cy - 1); y <= Math.min(ch - 1, cy + 1); y++) for (let x = Math.max(0, cx - 1); x <= Math.min(cw - 1, cx + 1); x++) cd[y * cw + x] = 1
  }
  const mw = mx1 - mx0 + 1, mh = my1 - my0 + 1
  const R = Math.min(clamp(2 * Math.max(bw, bh), 24, 320), Math.max(W, H))
  const step = Math.max(1, Math.ceil(R / 32))
  let best = Infinity, bdx = 0, bdy = 0
  const tryOff = (dx: number, dy: number) => {
    if (!dx && !dy) return
    if (bx + dx < 0 || by + dy < 0 || bx + bw + dx > W || by + bh + dy > H) return
    if (Math.abs(dx) < mw && Math.abs(dy) < mh) {
      for (const c of cells) {
        const x = ((c % cw) * 4 + dx) >> 2, y = (((c / cw) | 0) * 4 + dy) >> 2
        if (x >= 0 && y >= 0 && x < cw && y < ch && cd[y * cw + x]) return
      }
    }
    // Mild preference for nearby sources: they share the blemish's lighting.
    const pen = 1 + 0.2 * Math.hypot(dx, dy) / R, lim = best / pen, shift = (dy * W + dx) * 4
    let s = 0
    for (let k = 0; k < rs.length; k++) {
      const o = rs[k], q = o + shift
      const dr = data[o] - data[q], dg = data[o + 1] - data[q + 1], db = data[o + 2] - data[q + 2]
      s += dr * dr + dg * dg + db * db
      if (s > lim) return
    }
    best = s * pen; bdx = dx; bdy = dy
  }
  for (let dy = -R; dy <= R; dy += step) for (let dx = -R; dx <= R; dx += step) tryOff(dx, dy)
  if (best === Infinity) return null
  if (step > 1) { const cx = bdx, cy = bdy; for (let dy = 1 - step; dy < step; dy++) for (let dx = 1 - step; dx < step; dx++) tryOff(cx + dx, cy + dy) }
  return [bdx, bdy]
}

function unknowns(flag: Uint8Array): Int32Array {
  const a: number[] = []
  for (let g = 0; g < flag.length; g++) if (flag[g] === 2) a.push(g)
  return Int32Array.from(a)
}

// SOR sweeps over the unknown cells: omega near 1 smooths, near 2 converges the low frequencies.
function sweeps(flag: Uint8Array, u: Float32Array, gw: number, unk: Int32Array, iters: number, omega: number, tol: number) {
  for (let it = 0; it < iters; it++) {
    let maxD = 0
    for (let j = 0; j < unk.length; j++) {
      const g = unk[j]
      for (let c = 0; c < 3; c++) {
        let s = 0, k = 0
        if (flag[g - 1]) { s += u[(g - 1) * 3 + c]; k++ }
        if (flag[g + 1]) { s += u[(g + 1) * 3 + c]; k++ }
        if (flag[g - gw]) { s += u[(g - gw) * 3 + c]; k++ }
        if (flag[g + gw]) { s += u[(g + gw) * 3 + c]; k++ }
        if (!k) continue
        const d = s / k - u[g * 3 + c]
        u[g * 3 + c] += omega * d
        if (d > maxD) maxD = d; else if (-d > maxD) maxD = -d
      }
    }
    if (maxD < tol) break
  }
}

// Harmonic membrane: unknown cells (flag 2) take the Laplace interpolation of the known ones (flag 1);
// flag 0 is outside the image. Solved coarse to fine: the half-resolution solve supplies the smooth part
// and a few sweeps per level fix the boundary layer, since Gauss-Seidel kills high-frequency error fast.
function solveMembrane(flag: Uint8Array, u: Float32Array, gw: number, gh: number) {
  const unk = unknowns(flag)
  if (!unk.length) return
  if (Math.max(gw, gh) <= 40) {
    const sum = [0, 0, 0]; let n = 0
    for (let g = 0; g < flag.length; g++) if (flag[g] === 1) { n++; for (let c = 0; c < 3; c++) sum[c] += u[g * 3 + c] }
    for (let j = 0; j < unk.length; j++) for (let c = 0; c < 3; c++) u[unk[j] * 3 + c] = n ? sum[c] / n : 0
    const m = Math.max(gw, gh)
    sweeps(flag, u, gw, unk, 600, Math.min(1.95, 2 / (1 + Math.sin(Math.PI / (m + 1)))), 0.01)
    return
  }
  // Coarse grid keeps a one-cell border so no unknown sits on its edge.
  const cw = ((gw + 1) >> 1) + 2, ch = ((gh + 1) >> 1) + 2
  const cf = new Uint8Array(cw * ch), cu = new Float32Array(cw * ch * 3), cnt = new Uint16Array(cw * ch)
  for (let y = 0; y < gh; y++) for (let x = 0; x < gw; x++) {
    const g = y * gw + x, f = flag[g]; if (!f) continue
    const c = ((y >> 1) + 1) * cw + (x >> 1) + 1
    if (f === 1) { cf[c] = 1; cnt[c]++; for (let k = 0; k < 3; k++) cu[c * 3 + k] += u[g * 3 + k] }
    else if (!cf[c]) cf[c] = 2
  }
  for (let c = 0; c < cf.length; c++) if (cf[c] === 1) for (let k = 0; k < 3; k++) cu[c * 3 + k] /= cnt[c]
  solveMembrane(cf, cu, cw, ch)
  for (let j = 0; j < unk.length; j++) {
    const g = unk[j], c = (((g / gw | 0) >> 1) + 1) * cw + ((g % gw) >> 1) + 1
    for (let k = 0; k < 3; k++) u[g * 3 + k] = cu[c * 3 + k]
  }
  sweeps(flag, u, gw, unk, 20, 1.25, 0.01)
}

// Fill one piece: copy the best-matching patch over the mask, then add a harmonic membrane interpolated
// from (destination minus source) on the border so lighting matches at the seam. A near-flat ring (a sky,
// a gradient) or a mask too big to shift anywhere skips the copy: the membrane alone is a smooth fill,
// where a shifted copy would bend the gradient. Returns the ring's texture measure.
function healPiece(work: ImageData, pts: Pt[], brush: number): number {
  const { width: W, height: H, data } = work
  const ring = ringOf(brush)
  const box = bboxOf(pts, brush / 2 + ring + 1, W, H)
  const { x: bx, y: by, w: bw, h: bh } = box
  const mask = coverage(pts, brush, box), dil = coverage(pts, brush + 2 * ring, box)
  const mi: number[] = [], ri: number[] = []
  for (let i = 0; i < bw * bh; i++) { if (mask[i]) mi.push(i); else if (dil[i] > 127) ri.push(i) }
  if (!mi.length) return 0
  const maskIdx = Int32Array.from(mi), ringIdx = Int32Array.from(ri)
  // Texture: mean difference between horizontal neighbours across the ring.
  let tex = 0, tn = 0
  for (let k = 0; k < ringIdx.length; k += 7) {
    const i = ringIdx[k]; if (i % bw === bw - 1) continue
    const o = ((by + (i / bw | 0)) * W + bx + i % bw) * 4
    tex += Math.abs(data[o] - data[o + 4]) + Math.abs(data[o + 1] - data[o + 5]) + Math.abs(data[o + 2] - data[o + 6]); tn++
  }
  tex = tn ? tex / (3 * tn) : 0
  const off = tex > 0.8 && ringIdx.length ? bestOffset(data, W, H, box, mask, maskIdx, ringIdx) : null
  const [dx, dy] = off ?? [0, 0]

  // Padded grid: flag 0 = outside image, 1 = known (value = dst - src), 2 = unknown (mask).
  const gw = bw + 2, gh = bh + 2
  const flag = new Uint8Array(gw * gh), u = new Float32Array(gw * gh * 3)
  for (let gy = 0; gy < gh; gy++) for (let gx = 0; gx < gw; gx++) {
    const x = bx + gx - 1, y = by + gy - 1, g = gy * gw + gx
    if (x < 0 || y < 0 || x >= W || y >= H) continue
    if (gx > 0 && gy > 0 && gx <= bw && gy <= bh && mask[(gy - 1) * bw + gx - 1]) { flag[g] = 2; continue }
    flag[g] = 1
    const o = (y * W + x) * 4, q = (clamp(y + dy, 0, H - 1) * W + clamp(x + dx, 0, W - 1)) * 4
    for (let c = 0; c < 3; c++) u[g * 3 + c] = data[o + c] - (off ? data[q + c] : 0)
  }
  solveMembrane(flag, u, gw, gh)

  const shift = (dy * W + dx) * 4
  for (let k = 0; k < maskIdx.length; k++) {
    const i = maskIdx[k], lx = i % bw, ly = (i / bw) | 0
    const o = ((by + ly) * W + bx + lx) * 4, g = (ly + 1) * gw + lx + 1, a = mask[i] / 255
    for (let c = 0; c < 3; c++) {
      const v = clamp((off ? data[o + shift + c] : 0) + u[g * 3 + c], 0, 255)
      data[o + c] = data[o + c] * (1 - a) + v * a
    }
  }
  return tex
}

// A stroke heals in pieces about two brush-widths long, each with its own source, so a long stroke can
// follow a background that changes along it.
// ---- Translucent overlays ----
// A constant-colour overlay at constant opacity leaves I = c + k*B (c = alpha*W, k = 1 - alpha): the picture is
// still in the pixels, attenuated. Fit (c, k) to the edge pairs inside the stroke, label the overlay's
// pixels, and invert the blend.

interface Overlay { c: number[]; k: number; alpha: number; W: number[]; n: number }
let fitLog = ""   // why the last overlay fit was accepted or rejected, for the heal log
let debugLabels = false   // a stroke message with debug: true paints the overlay label map instead of unblending

function fitOverlay(data: Uint8ClampedArray, IW: number, box: Box, mask: Uint8Array): { ov: Overlay; P: Int32Array; Q: Int32Array; inl: Uint8Array } | null {
  const { x: bx, y: by, w: bw, h: bh } = box
  // Candidate pairs: pixels two apart (past an anti-aliased edge) inside the stroke that differ
  // noticeably, in both orders since either could be the overlay side.
  const pp: number[] = [], qq: number[] = []
  for (let ly = 0; ly < bh; ly++) for (let lx = 0; lx < bw; lx++) {
    const i = ly * bw + lx; if (!mask[i]) continue
    const o = ((by + ly) * IW + bx + lx) * 4
    for (let d = 0; d < 2; d++) {
      const jx = lx + (d ? 0 : 2), jy = ly + (d ? 2 : 0)
      if (jx >= bw || jy >= bh || !mask[jy * bw + jx]) continue
      const q = ((by + jy) * IW + bx + jx) * 4
      if (Math.max(Math.abs(data[o] - data[q]), Math.abs(data[o + 1] - data[q + 1]), Math.abs(data[o + 2] - data[q + 2])) < 20) continue
      pp.push(o, q); qq.push(q, o)
    }
  }
  const stride = Math.max(1, Math.ceil(pp.length / 40000))
  const n = Math.floor(pp.length / stride)
  fitLog = `pairs ${pp.length / 2}`
  if (n < 400) { fitLog += " too few"; return null }
  const P = new Int32Array(n), Q = new Int32Array(n)
  for (let t = 0; t < n; t++) { P[t] = pp[t * stride]; Q[t] = qq[t * stride] }

  const resid = (c: number[], k: number, t: number) => Math.max(Math.abs(data[Q[t]] - c[0] - k * data[P[t]]), Math.abs(data[Q[t] + 1] - c[1] - k * data[P[t] + 1]), Math.abs(data[Q[t] + 2] - c[2] - k * data[P[t] + 2]))
  const valid = (c: number[], k: number) => k >= 0.15 && k <= 0.92 && c.every(v => v > -6 && v < 261 && v / (1 - k) > -10 && v / (1 - k) < 265)
  // RANSAC on two pairs: their difference cancels c and gives k.
  let seed = 12345
  const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff
  const sub = Math.max(1, Math.floor(n / 4000))
  let bc: number[] | null = null, bk = 0, bestCnt = 0
  for (let it = 0; it < 500; it++) {
    const i = Math.floor(rnd() * n), j = Math.floor(rnd() * n)
    let pd = 0, qd = 0
    for (let ch = 0; ch < 3; ch++) { const dp = data[P[i] + ch] - data[P[j] + ch]; pd += dp * dp; qd += dp * (data[Q[i] + ch] - data[Q[j] + ch]) }
    if (pd < 900) continue
    const k = qd / pd, c = [0, 1, 2].map(ch => data[Q[i] + ch] - k * data[P[i] + ch])
    if (!valid(c, k)) continue
    let cnt = 0
    for (let t = 0; t < n; t += sub) if (resid(c, k, t) <= 10) cnt++
    if (cnt > bestCnt) { bestCnt = cnt; bc = c; bk = k }
  }
  if (!bc) { fitLog += " no model"; return null }
  // Refit on the inliers: shared slope, per-channel intercepts. The slope is the symmetric (geometric-mean)
  // regression, since a pair's two pixels differ in texture as well as tint, and plain least squares reads
  // that as noise in the regressor and shrinks k.
  const inl = new Uint8Array(n)
  let cnt = 0
  for (let r = 0; r < 3; r++) {
    const Sp = [0, 0, 0], Sq = [0, 0, 0], Spp = [0, 0, 0], Sqq = [0, 0, 0]
    cnt = 0
    for (let t = 0; t < n; t++) {
      inl[t] = resid(bc, bk, t) <= 10 ? 1 : 0
      if (!inl[t]) continue
      cnt++
      for (let ch = 0; ch < 3; ch++) { const p = data[P[t] + ch], q = data[Q[t] + ch]; Sp[ch] += p; Sq[ch] += q; Spp[ch] += p * p; Sqq[ch] += q * q }
    }
    if (cnt < 3) { fitLog += " lost"; return null }
    let vp = 0, vq = 0
    for (let ch = 0; ch < 3; ch++) { vp += Spp[ch] - Sp[ch] * Sp[ch] / cnt; vq += Sqq[ch] - Sq[ch] * Sq[ch] / cnt }
    if (vp <= 0) { fitLog += " flat"; return null }
    const k = Math.sqrt(vq / vp), c = [0, 1, 2].map(ch => (Sq[ch] - k * Sp[ch]) / cnt)
    if (!valid(c, k)) { fitLog += ` invalid k=${k.toFixed(2)} c=${c.map(v => v.toFixed(0))}`; return null }
    bc = c; bk = k
  }
  fitLog += ` ransac ${bestCnt}/${Math.ceil(n / sub)} inliers ${cnt}/${n} k=${bk.toFixed(2)} c=${bc.map(v => v.toFixed(0))}`
  if (cnt < 300 || cnt < 0.03 * n) { fitLog += " weak"; return null }
  // The inliers must span varied underlying colours: a single two-tone edge fits any (c, k).
  let s1 = 0, s2 = 0
  for (let t = 0; t < n; t++) if (inl[t]) { const l = 0.2126 * data[P[t]] + 0.7152 * data[P[t] + 1] + 0.0722 * data[P[t] + 2]; s1 += l; s2 += l * l }
  const std = Math.sqrt(Math.max(0, s2 / cnt - (s1 / cnt) ** 2))
  fitLog += ` std ${std.toFixed(0)}`
  if (std < 12) { fitLog += " narrow"; return null }
  const alpha = 1 - bk
  return { ov: { c: bc, k: bk, alpha, W: bc.map(v => clamp(v / alpha, 0, 255)), n: cnt }, P, Q, inl }
}

function removeOverlay(work: ImageData, box: Box, mask: Uint8Array): Overlay | null {
  const { data, width: IW } = work
  const fit = fitOverlay(data, IW, box, mask)
  if (!fit) return null
  let ov = fit.ov
  const { P, Q, inl } = fit
  const { x: bx, y: by, w: bw, h: bh } = box
  const N = bw * bh
  const local = (o: number) => { const px = (o >> 2) % IW - bx, py = ((o >> 2) / IW | 0) - by; return py * bw + px }
  const at = (i: number) => ((by + (i / bw | 0)) * IW + bx + i % bw) * 4
  const nb4 = (i: number, d: number) => { const lx = i % bw, ly = (i / bw) | 0; return d === 0 ? (lx > 0 ? i - 1 : -1) : d === 1 ? (lx < bw - 1 ? i + 1 : -1) : d === 2 ? (ly > 0 ? i - bw : -1) : (ly < bh - 1 ? i + bw : -1) }
  const inGamut = (i: number) => { const o = at(i); for (let ch = 0; ch < 3; ch++) { const v = (data[o + ch] - ov.c[ch]) / ov.k; if (v < -8 || v > 263) return false } return true }

  // Each inlier pair votes: far pixel overlay, near pixel clean.
  const vote = new Int16Array(N)
  for (let t = 0; t < P.length; t++) if (inl[t]) { vote[local(Q[t])]++; vote[local(P[t])]-- }
  const seeds = new Uint8Array(N)
  for (let i = 0; i < N; i++) if (vote[i]) seeds[i] = vote[i] > 0 ? 2 : 1

  // Regions: the stroke cut along every edge, overlay or natural (a step of 14+ between 4-neighbours).
  // A region has one label, and the relation between two touching regions is read off their whole shared
  // border at once, so a few missed pixels along an edge cannot open a gap for a flood to leak through.
  const T = 14
  const wall = new Uint8Array(N)
  for (let i = 0; i < N; i++) {
    if (!mask[i]) continue
    const o = at(i)
    for (let d = 0; d < 4 && !wall[i]; d++) {
      const j = nb4(i, d); if (j < 0 || !mask[j]) continue
      const oj = at(j)
      if (Math.abs(data[o] - data[oj]) >= T || Math.abs(data[o + 1] - data[oj + 1]) >= T || Math.abs(data[o + 2] - data[oj + 2]) >= T) wall[i] = 1
    }
  }
  const reg = new Int32Array(N).fill(-1)
  let R = 0
  const st: number[] = []
  for (let s = 0; s < N; s++) {
    if (!mask[s] || wall[s] || reg[s] >= 0) continue
    reg[s] = R; st.push(s)
    while (st.length) { const i = st.pop()!; for (let d = 0; d < 4; d++) { const j = nb4(i, d); if (j >= 0 && mask[j] && !wall[j] && reg[j] < 0) { reg[j] = R; st.push(j) } } }
    R++
  }
  if (!R) return null
  // Per-region evidence, signed toward overlay: the stroke's rim is clean, so is anything that unblends
  // out of gamut, and the pair votes count too.
  const ev = new Float64Array(R)
  for (let i = 0; i < N; i++) {
    const r = reg[i]; if (r < 0) continue
    const lx = i % bw, ly = (i / bw) | 0
    if (lx === 0 || ly === 0 || lx === bw - 1 || ly === bh - 1 || !mask[i - 1] || !mask[i + 1] || !mask[i - bw] || !mask[i + bw]) ev[r] -= 4
    if (!inGamut(i)) ev[r] -= 4
    ev[r] += vote[i]
  }
  // Border statistics from pixel pairs facing each other across a wall (walls are 2 to 4 px thick, since
  // both sides of a step qualify): mean residual of "b is overlay relative to a", of the reverse, and of
  // "no step at all".
  interface Border { a: number; b: number; n: number; ab: number; ba: number; same: number }
  const borders = new Map<number, Border>()
  const bp: number[] = []   // the facing pairs themselves, pure pixels on both sides
  const bv: number[] = []   // per pair: the two sides' colours, each extrapolated to the wall's midpoint
  for (let i = 0; i < N; i++) {
    const ra = reg[i]; if (ra < 0) continue
    const lx = i % bw, ly = (i / bw) | 0
    for (let d = 0; d < 2; d++) {
      let j = -1
      for (let s = 2; s <= 5; s++) {
        const cand = d === 0 ? (lx + s < bw ? i + s : -1) : (ly + s < bh ? i + s * bw : -1)
        if (cand < 0 || !mask[cand]) break
        if (reg[cand] >= 0) { if (reg[cand] !== ra && s > 1 && reg[cand - (d === 0 ? 1 : bw)] < 0) j = cand; break }
      }
      if (j < 0) continue
      const rb = reg[j]
      // Both sides are extrapolated linearly to the wall's midpoint, so a texture slope across the gap
      // cancels instead of shifting the fit (which the unblend then turns into a uniform offset).
      const step = d === 0 ? 1 : bw, half = (j - i) / step / 2
      const i2 = i - step, j2 = j + step
      const hasI2 = (d === 0 ? lx > 0 : ly > 0) && mask[i2] && reg[i2] === ra
      const hasJ2 = (d === 0 ? j % bw < bw - 1 : (j / bw | 0) < bh - 1) && mask[j2] && reg[j2] === rb
      const oi = at(i), oj = at(j), oi2 = at(hasI2 ? i2 : i), oj2 = at(hasJ2 ? j2 : j)
      const pv = [0, 1, 2].map(ch => data[oi + ch] + (data[oi + ch] - data[oi2 + ch]) * half)
      const qv = [0, 1, 2].map(ch => data[oj + ch] + (data[oj + ch] - data[oj2 + ch]) * half)
      bp.push(i, j); bv.push(pv[0], pv[1], pv[2], qv[0], qv[1], qv[2])
      let ij = 0, ji = 0, same = 0
      for (let ch = 0; ch < 3; ch++) {
        ij = Math.max(ij, Math.abs(qv[ch] - ov.c[ch] - ov.k * pv[ch]))
        ji = Math.max(ji, Math.abs(pv[ch] - ov.c[ch] - ov.k * qv[ch]))
        same = Math.max(same, Math.abs(qv[ch] - pv[ch]))
      }
      const lo = Math.min(ra, rb), hi = Math.max(ra, rb), key = lo * R + hi
      let s = borders.get(key)
      if (!s) { s = { a: lo, b: hi, n: 0, ab: 0, ba: 0, same: 0 }; borders.set(key, s) }
      s.n++; s.same += Math.min(same, 60)
      if (ra === lo) { s.ab += Math.min(ij, 60); s.ba += Math.min(ji, 60) } else { s.ab += Math.min(ji, 60); s.ba += Math.min(ij, 60) }
    }
  }
  // Each border is a same/different constraint weighted by length and margin; a natural edge, which
  // fits neither blend direction, means the same label on both sides. Union-find with parity takes the
  // constraints strongest first, and an oriented border also says which side is the overlay.
  const rels: { a: number; b: number; rel: number; w: number }[] = []
  for (const s of borders.values()) {
    const mab = s.ab / s.n, mba = s.ba / s.n, msame = s.same / s.n, best = Math.min(mab, mba, msame)
    if (best === msame || best > 14) { rels.push({ a: s.a, b: s.b, rel: 0, w: s.n * Math.max(1, Math.min(mab, mba) - msame) }); continue }
    const w = s.n * (Math.min(msame, Math.max(mab, mba)) - best)
    rels.push({ a: s.a, b: s.b, rel: 1, w })
    if (mab < mba) { ev[s.b] += w; ev[s.a] -= w } else { ev[s.a] += w; ev[s.b] -= w }
  }
  rels.sort((x, y) => y.w - x.w)
  const parent = new Int32Array(R), parity = new Uint8Array(R), size = new Int32Array(R).fill(1)
  for (let r = 0; r < R; r++) parent[r] = r
  const rootOf = (x: number) => { while (parent[x] !== x) x = parent[x]; return x }
  const parOf = (x: number) => { let p = 0; while (parent[x] !== x) { p ^= parity[x]; x = parent[x] } return p }
  for (const { a, b, rel } of rels) {
    let ra = rootOf(a), rb = rootOf(b)
    if (ra === rb) continue
    const pa = parOf(a), pb = parOf(b)
    if (size[ra] < size[rb]) { const t = ra; ra = rb; rb = t }
    parent[rb] = ra; parity[rb] = pa ^ pb ^ rel; size[ra] += size[rb]
  }
  const compEv = new Float64Array(R)
  for (let r = 0; r < R; r++) compEv[rootOf(r)] += parOf(r) ? -ev[r] : ev[r]
  const label = new Uint8Array(N)
  let overCount = 0
  for (let i = 0; i < N; i++) {
    const r = reg[i]; if (r < 0) continue
    const over = (compEv[rootOf(r)] > 0) !== (parOf(r) === 1)
    label[i] = over ? 2 : 1
    if (over) overCount++
  }
  fitLog += ` regions ${R} borders ${borders.size} overlay px ${overCount}`
  if (!overCount) return null
  // Wall pixels take the label of their nearest labelled neighbour.
  let fill: number[] = []
  for (let i = 0; i < N; i++) if (mask[i] && label[i]) fill.push(i)
  while (fill.length) {
    const next: number[] = []
    for (const i of fill) for (let d = 0; d < 4; d++) { const j = nb4(i, d); if (j >= 0 && mask[j] && !label[j]) { label[j] = label[i]; next.push(j) } }
    fill = next
  }
  // Specks flip: a component under 40 px is noise, whichever label it carries.
  const seen = new Uint8Array(N), stack: number[] = [], comp: number[] = []
  for (let s = 0; s < N; s++) {
    if (!mask[s] || seen[s]) continue
    const l = label[s]; comp.length = 0; stack.push(s); seen[s] = 1
    while (stack.length) {
      const i = stack.pop()!; comp.push(i)
      for (let d = 0; d < 4; d++) { const j = nb4(i, d); if (j >= 0 && mask[j] && !seen[j] && label[j] === l) { seen[j] = 1; stack.push(j) } }
    }
    if (comp.length < 40) for (const i of comp) label[i] = 3 - l
  }
  // The facing pairs whose regions ended up with different labels: far more than the seeds, so refit
  // (c, k) on them with the same symmetric regression, then judge the result on them too.
  const pairs: number[] = [], pc: number[] = [], po: number[] = []   // (i, j) clean/overlay, and their midpoint colours
  for (let t = 0; t < bp.length; t += 2) {
    const i = bp[t], j = bp[t + 1], v = t * 3
    if (label[i] === 1 && label[j] === 2) { pairs.push(i, j); pc.push(bv[v], bv[v + 1], bv[v + 2]); po.push(bv[v + 3], bv[v + 4], bv[v + 5]) }
    else if (label[i] === 2 && label[j] === 1) { pairs.push(j, i); pc.push(bv[v + 3], bv[v + 4], bv[v + 5]); po.push(bv[v], bv[v + 1], bv[v + 2]) }
  }
  for (let r = 0; r < 2 && pairs.length >= 100; r++) {
    const Sp = [0, 0, 0], Sq = [0, 0, 0], Spp = [0, 0, 0], Sqq = [0, 0, 0]; let m = 0
    for (let t = 0; t < pc.length; t += 3) {
      let res = 0
      for (let ch = 0; ch < 3; ch++) res = Math.max(res, Math.abs(po[t + ch] - ov.c[ch] - ov.k * pc[t + ch]))
      if (res > 14) continue
      m++
      for (let ch = 0; ch < 3; ch++) { const p = pc[t + ch], q = po[t + ch]; Sp[ch] += p; Sq[ch] += q; Spp[ch] += p * p; Sqq[ch] += q * q }
    }
    if (m < 50) break
    let vp = 0, vq = 0
    for (let ch = 0; ch < 3; ch++) { vp += Spp[ch] - Sp[ch] * Sp[ch] / m; vq += Sqq[ch] - Sq[ch] * Sq[ch] / m }
    if (vp <= 0) break
    const k = Math.sqrt(vq / vp), c = [0, 1, 2].map(ch => (Sq[ch] - k * Sp[ch]) / m)
    if (k < 0.1 || k > 0.95) break
    ov = { c, k, alpha: 1 - k, W: c.map(v => clamp(v / (1 - k), 0, 255)), n: m }
  }
  fitLog += ` refit k=${ov.k.toFixed(2)} on ${ov.n}`
  if (debugLabels) {
    // Overlay red, clean green, seeds solid.
    for (let i = 0; i < N; i++) {
      if (!mask[i]) continue
      const o = at(i), l = label[i]
      const t = [l === 2 ? 255 : 0, l === 1 ? 255 : 0, 0], a = 0.45
      for (let ch = 0; ch < 3; ch++) data[o + ch] = data[o + ch] * (1 - a) + t[ch] * a
    }
    for (let i = 0; i < N; i++) if (seeds[i]) { const o = at(i); data[o] = seeds[i] === 2 ? 255 : 0; data[o + 1] = seeds[i] === 1 ? 255 : 0; data[o + 2] = 0 }
    fitLog += " debug"
    return ov
  }
  // Invert the blend on overlay pixels, except one that would leave the gamut: it cannot be overlay,
  // whatever the fill labelled it (a clean sliver too thin to be a region, between a stem and a
  // diagonal, went black without this). Then every wall pixel between the two labels (the anti-aliased rim
  // and the pure pixels flanking it) keeps its own value if it already matches the recovered colours of
  // the pure pixels around it, and otherwise takes their median (a mean straddles a fold edge crossing the
  // rim and leaves a tick): a 1-2 px band of interpolation is invisible, while unblending a partially
  // covered pixel with any estimated opacity leaves a dotted outline.
  const { c, k } = ov
  const B = new Float32Array(N * 3)
  for (let i = 0; i < N; i++) {
    if (!mask[i]) continue
    const o = at(i)
    const over = label[i] === 2 && inGamut(i)
    for (let ch = 0; ch < 3; ch++) B[i * 3 + ch] = over ? (data[o + ch] - c[ch]) / k : data[o + ch]
  }
  const out = new Float32Array(B)
  for (let i = 0; i < N; i++) {
    if (!mask[i] || !wall[i]) continue
    const lx = i % bw, ly = (i / bw) | 0
    let has1 = false, has2 = false
    const vals: number[][] = [[], [], []]
    for (let rad = 1; rad <= 2 && !vals[0].length; rad++)
      for (let y = Math.max(0, ly - rad); y <= Math.min(bh - 1, ly + rad); y++) for (let x = Math.max(0, lx - rad); x <= Math.min(bw - 1, lx + rad); x++) {
        const j = y * bw + x; if (!mask[j]) continue
        if (label[j] === 1) has1 = true; else if (label[j] === 2) has2 = true
        if (!wall[j]) for (let ch = 0; ch < 3; ch++) vals[ch].push(B[j * 3 + ch])
      }
    if (!((has1 && has2) || (label[i] === 2 && !inGamut(i))) || !vals[0].length) continue
    const o = at(i)
    const m = vals.map(v => { v.sort((p, q) => p - q); return v[v.length >> 1] })
    let diff = 0
    for (let ch = 0; ch < 3; ch++) diff = Math.max(diff, Math.abs(data[o + ch] - m[ch]))
    for (let ch = 0; ch < 3; ch++) out[i * 3 + ch] = diff <= 10 ? data[o + ch] : m[ch]
  }
  // Accept only if the overlay's edge vanishes: the step between the facing pairs' midpoint colours must
  // collapse once the overlay side is unblended. An opaque mark's anti-aliased rim fits the model too,
  // but its core stays a step, and this catches it.
  let eb = 0, ea = 0, en = 0
  for (let t = 0; t < pc.length; t += 3) {
    let b = 0, a = 0
    for (let ch = 0; ch < 3; ch++) { b = Math.max(b, Math.abs(po[t + ch] - pc[t + ch])); a = Math.max(a, Math.abs((po[t + ch] - c[ch]) / k - pc[t + ch])) }
    eb += b; ea += a; en++
  }
  fitLog += ` edge ${en ? (eb / en).toFixed(1) : "-"}->${en ? (ea / en).toFixed(1) : "-"} over ${en}`
  if (!en || ea > 0.3 * eb || ea / en > 12) { fitLog += " edge stays"; return null }
  fitLog += " ok"
  for (let i = 0; i < N; i++) {
    if (!mask[i]) continue
    const o = at(i)
    for (let ch = 0; ch < 3; ch++) data[o + ch] = clamp(out[i * 3 + ch], 0, 255)
  }
  return ov
}

interface Info { tex: number[]; overlay: Overlay | null }
let lastInfo: Info | null = null   // what the latest heal did, for the self-test's report

// A stroke first tries the translucent-overlay model over its whole area; failing that it heals in pieces
// about two brush-widths long, each with its own source, so a long stroke can follow a changing background.
function healStroke(work: ImageData, pts: Pt[], brush: number): [Patch, Info] {
  const box = bboxOf(pts, brush / 2 + ringOf(brush) + 1, work.width, work.height)
  const before = copyBox(work, box)
  fitLog = ""
  const overlay = removeOverlay(work, box, coverage(pts, brush, box))
  if (overlay) return [{ box, before }, { tex: [], overlay }]
  const step = Math.max(1, brush / 3)
  const rs = resample(pts, step)
  const per = Math.max(2, Math.round(Math.max(2 * brush, 16) / step))
  const tex: number[] = []
  for (let i = 0; ; i += per - 1) {
    tex.push(healPiece(work, rs.slice(i, i + per), brush))
    if (i + per >= rs.length) break
  }
  return [{ box, before }, { tex, overlay: null }]
}

const tint = (w: number[]) => Math.min(...w) > 200 ? "white" : Math.max(...w) < 60 ? "black" : Math.max(...w) - Math.min(...w) < 25 ? "grey" : `rgb(${w.map(Math.round).join(",")})`

// Synthetic scene for the headless self-test: a striped texture over a colour gradient with a dark spot, a
// scratch, and a translucent banner, plus the exact pixels each mark changed, so the heal can be scored
// against the clean ground truth.
function makeTestScene() {
  const W = 420, H = 300
  const c = document.createElement("canvas"); c.width = W; c.height = H
  const x = c.getContext("2d", { willReadFrequently: true })!
  const im = x.createImageData(W, H)
  let seed = 7
  const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff - 0.5 }
  for (let y = 0; y < H; y++) for (let xx = 0; xx < W; xx++) {
    const o = (y * W + xx) * 4, s = Math.sin(y * 0.31) * 26 + Math.sin((xx + 2 * y) * 0.09) * 9
    im.data[o] = 158 + s + (xx / W - 0.5) * 90 + rnd() * 6; im.data[o + 1] = 122 + s * 0.8 + rnd() * 6; im.data[o + 2] = 88 + s * 0.6 + (y / H - 0.5) * 70 + rnd() * 6; im.data[o + 3] = 255
  }
  x.putImageData(im, 0, 0)
  const clean = x.getImageData(0, 0, W, H)
  const changed = (prev: ImageData) => {
    const cur = x.getImageData(0, 0, W, H), idx: number[] = []
    for (let o = 0; o < cur.data.length; o += 4) if (cur.data[o] !== prev.data[o] || cur.data[o + 1] !== prev.data[o + 1] || cur.data[o + 2] !== prev.data[o + 2]) idx.push(o)
    return { idx, snap: cur }
  }
  x.fillStyle = "#3a2418"; x.beginPath(); x.ellipse(210, 150, 12, 9, 0.4, 0, Math.PI * 2); x.fill()
  const spot = changed(clean)
  x.strokeStyle = "#111"; x.lineWidth = 3; x.beginPath(); x.moveTo(60, 60); x.lineTo(160, 120); x.stroke()
  const scratch = changed(spot.snap)
  x.fillStyle = "rgba(255,255,255,0.5)"; x.fillRect(60, 215, 200, 50)
  const wm = changed(scratch.snap)
  return { canvas: c, clean, marks: [spot.idx, scratch.idx, wm.idx] }
}

function App() {
  const [img, setImg] = useState<{ w: number; h: number; name: string } | null>(null)
  const [brush, setBrush] = useState(24)
  const [fixes, setFixes] = useState(0)
  const [busy, setBusy] = useState(false)
  const [stroke, setStroke] = useState<Pt[]>([])
  const [cursor, setCursor] = useState<Pt | null>(null)
  const [peek, setPeek] = useState(false)   // brush circle shown on the image while its size is being changed
  const [hover, setHover] = useState(false)
  const [toast, setToast] = useState("")
  const [zoom, setZoom] = useState(1)
  const [stage, setStage] = useState({ w: 0, h: 320 })

  const canvasRef = useRef<HTMLCanvasElement>(null)
  const stageRef = useRef<HTMLDivElement>(null)
  const fileRef = useRef<HTMLInputElement>(null)
  const workRef = useRef<ImageData | null>(null)
  const origRef = useRef<ImageData | null>(null)
  const undoRef = useRef<Patch[]>([])
  const strokeRef = useRef<Pt[]>([])
  const drawing = useRef(false)
  const imgRef = useRef(img); imgRef.current = img
  const zoomRef = useRef(zoom); zoomRef.current = zoom
  const pendingScroll = useRef<{ fx: number; fy: number; px: number; py: number } | null>(null)

  const flash = (m: string) => { setToast(m); window.setTimeout(() => setToast(""), 1700) }
  const peekTimer = useRef(0)
  const resize = (f: (b: number) => number) => {
    setBrush(b => clamp(f(b), BRUSH_MIN, BRUSH_MAX))
    setPeek(true); window.clearTimeout(peekTimer.current); peekTimer.current = window.setTimeout(() => setPeek(false), 800)
  }

  const fit = img && stage.w ? Math.min(1, stage.w / img.w, stage.h / img.h) : 1
  const scale = fit * zoom
  const dispW = img ? img.w * scale : 0, dispH = img ? img.h * scale : 0
  const brushImg = brush / scale

  // Whatever the canvas shows becomes the image to heal, and the original the compare button shows.
  const adopt = useCallback((name: string) => {
    const cv = canvasRef.current!, w = cv.width, h = cv.height
    const work = cv.getContext("2d", { willReadFrequently: true })!.getImageData(0, 0, w, h)
    workRef.current = work
    origRef.current = new ImageData(new Uint8ClampedArray(work.data), w, h)
    undoRef.current = []
    setFixes(0); setZoom(1); setImg({ w, h, name })
  }, [])
  const loadSource = useCallback((src: CanvasImageSource, w: number, h: number, name: string) => {
    const cv = canvasRef.current!; cv.width = w; cv.height = h
    cv.getContext("2d", { willReadFrequently: true })!.drawImage(src, 0, 0)
    adopt(name)
  }, [adopt])

  const loadUrl = useCallback((url: string, name: string) => new Promise<void>((res, rej) => {
    const im = new Image()
    im.onload = () => { loadSource(im, im.naturalWidth, im.naturalHeight, name); res() }
    im.onerror = () => rej(new Error("image failed to load"))
    im.src = url
  }), [loadSource])

  const loadFile = useCallback((file?: File | null) => {
    if (!file || !file.type.startsWith("image/")) return
    const url = URL.createObjectURL(file)
    loadUrl(url, file.name).finally(() => URL.revokeObjectURL(url))
  }, [loadUrl])

  // The heal runs a beat later so the "Healing…" hint can paint first. A timeout, not requestAnimationFrame,
  // which never fires while the page is hidden.
  const heal = useCallback((pts: Pt[], b: number, debug = false) => new Promise<Patch>((res, rej) => {
    const work = workRef.current!, cv = canvasRef.current!
    setBusy(true)
    debugLabels = debug
    setTimeout(() => {
      const t0 = performance.now()
      try {
        const [patch, info] = healStroke(work, pts, b)
        lastInfo = info
        cv.getContext("2d")!.putImageData(work, 0, 0, patch.box.x, patch.box.y, patch.box.w, patch.box.h)
        undoRef.current.push(patch)
        if (undoRef.current.length > 40) undoRef.current.shift()
        setFixes(undoRef.current.length)
        const ov = info.overlay
        if (ov) flash(`Unblended a translucent overlay: ${Math.round(ov.alpha * 100)}% ${tint(ov.W)}`)
        tb.log(`[heal] ${pts.length} pts, brush ${Math.round(b)}px, box ${patch.box.w}x${patch.box.h}, ${Math.round(performance.now() - t0)}ms (${document.visibilityState}), ` +
          (ov ? `overlay ${Math.round(ov.alpha * 100)}% ${tint(ov.W)} (${ov.n} pairs)` : `tex ${info.tex.map(t => t.toFixed(1)).join("/")}`) + ` | fit: ${fitLog}`)
        res(patch)
      } catch (e: any) {
        tb.log(`[heal error] ${e?.stack || e}`); flash("Heal failed"); rej(e)
      } finally { setBusy(false) }
    }, 20)
  }), [])

  const undo = useCallback(() => {
    const p = undoRef.current.pop(), work = workRef.current, cv = canvasRef.current
    if (!p || !work || !cv) return
    pasteBox(work, p.box, p.before)
    cv.getContext("2d")!.putImageData(work, 0, 0, p.box.x, p.box.y, p.box.w, p.box.h)
    setFixes(undoRef.current.length)
  }, [])

  const showOriginal = (on: boolean) => {
    const cv = canvasRef.current, w = workRef.current, o = origRef.current
    if (cv && w && o) cv.getContext("2d")!.putImageData(on ? o : w, 0, 0)
  }

  useEffect(() => {
    const el = stageRef.current; if (!el) return
    const measure = () => setStage({ w: el.clientWidth, h: Math.max(320, window.innerHeight - 140) })
    const ro = new ResizeObserver(measure); ro.observe(el)
    window.addEventListener("resize", measure); measure()
    return () => { ro.disconnect(); window.removeEventListener("resize", measure) }
  }, [])

  // Ctrl/⌘+wheel (and trackpad pinch) zooms about the pointer; the scroll fix-up lands after layout.
  useEffect(() => {
    const el = stageRef.current; if (!el) return
    const onWheel = (e: WheelEvent) => {
      if (!(e.ctrlKey || e.metaKey) || !imgRef.current) return
      e.preventDefault()
      const cv = canvasRef.current!, r = cv.getBoundingClientRect(), sr = el.getBoundingClientRect()
      const z1 = clamp(zoomRef.current * Math.exp(-e.deltaY * 0.0015), 1, 8)
      if (z1 === zoomRef.current) return
      pendingScroll.current = { fx: (e.clientX - r.left) / r.width, fy: (e.clientY - r.top) / r.height, px: e.clientX - sr.left - el.clientLeft, py: e.clientY - sr.top - el.clientTop }
      setZoom(z1)
    }
    el.addEventListener("wheel", onWheel, { passive: false })
    return () => el.removeEventListener("wheel", onWheel)
  }, [])
  useLayoutEffect(() => {
    const p = pendingScroll.current, el = stageRef.current, cv = canvasRef.current
    if (!p || !el || !cv) return
    pendingScroll.current = null
    const fr = cv.parentElement as HTMLElement
    el.scrollLeft = fr.offsetLeft + p.fx * cv.offsetWidth - p.px
    el.scrollTop = fr.offsetTop + p.fy * cv.offsetHeight - p.py
  }, [zoom])

  useEffect(() => {
    const onPaste = (e: ClipboardEvent) => {
      const items = e.clipboardData?.items; if (!items) return
      for (const it of items) if (it.type.startsWith("image/")) { loadFile(it.getAsFile()); break }
    }
    const onKey = (e: KeyboardEvent) => {
      if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") { e.preventDefault(); undo() }
      else if (e.key === "[") resize(b => b - 4)
      else if (e.key === "]") resize(b => b + 4)
    }
    window.addEventListener("paste", onPaste); window.addEventListener("keydown", onKey)
    return () => { window.removeEventListener("paste", onPaste); window.removeEventListener("keydown", onKey) }
  }, [loadFile, undo])

  // Headless hooks: `selftest` scores a synthetic heal; {load, watermark?, spots?} loads a data URL,
  // optionally stamping a translucent watermark and dark spots [[x,y,r],…] first; {stroke: [[x,y],…], brush}
  // heals in image px.
  useEffect(() => tb.onMessage(async (m: any) => {
    if (m === "selftest") {
      const { canvas, clean, marks } = makeTestScene()
      loadSource(canvas, canvas.width, canvas.height, "selftest.png")
      const work = workRef.current!
      const err = (idx: number[]) => { let s = 0; for (const o of idx) for (let c = 0; c < 3; c++) s += Math.abs(work.data[o + c] - clean.data[o + c]); return +(s / (idx.length * 3)).toFixed(1) }
      const before = marks.map(err)
      const t0 = performance.now()
      const paths: string[] = []
      for (const [pts, b] of [[[{ x: 210, y: 150 }], 44], [[{ x: 60, y: 60 }, { x: 160, y: 120 }], 12], [[{ x: 60, y: 240 }, { x: 260, y: 240 }], 80]] as [Pt[], number][]) {
        await heal(pts, b); paths.push(lastInfo?.overlay ? "overlay" : "fill")
      }
      const after = marks.map(err)
      return { before, after, paths, ms: Math.round(performance.now() - t0), verdict: after.every((a, i) => a < before[i] / 4) && paths[2] === "overlay" ? "pass" : "fail" }
    }
    if (m && typeof m === "object") {
      if (m.load) {
        await loadUrl(m.load, "loaded.png")
        if (m.watermark || m.spots) {
          const cv = canvasRef.current!, ctx = cv.getContext("2d")!
          ctx.save(); ctx.font = `bold ${Math.round(cv.width / 12)}px sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"
          ctx.fillStyle = "rgba(255,255,255,0.55)"; if (m.watermark) ctx.fillText(m.watermark, cv.width / 2, cv.height / 2)
          ctx.fillStyle = "#3a2418"
          for (const [x, y, r] of m.spots ?? []) { ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill() }
          ctx.restore()
          adopt("loaded.png")
        }
        return { w: cv().width, h: cv().height }
      }
      if (m.stroke) { const p = await heal(m.stroke.map(([x, y]: number[]) => ({ x, y })), m.brush ?? 24, !!m.debug); return p.box }
    }
    function cv() { return canvasRef.current! }
  }), [loadSource, adopt, loadUrl, heal])

  const toImg = (e: React.PointerEvent) => {
    const cv = canvasRef.current!, r = cv.getBoundingClientRect()
    return { x: (e.clientX - r.left) * cv.width / r.width, y: (e.clientY - r.top) * cv.height / r.height }
  }
  const onDown = (e: React.PointerEvent) => {
    if (!img || busy) return
    e.currentTarget.setPointerCapture(e.pointerId)
    drawing.current = true
    const p = toImg(e); strokeRef.current = [p]; setStroke([p])
  }
  const onMove = (e: React.PointerEvent) => {
    const p = toImg(e)
    setCursor(e.pointerType === "touch" ? null : p)
    if (!drawing.current) return
    const s = strokeRef.current, l = s[s.length - 1]
    if (Math.hypot(p.x - l.x, p.y - l.y) < 0.5) return
    s.push(p); setStroke([...s])
  }
  const onUp = () => {
    if (!drawing.current) return
    drawing.current = false
    const pts = strokeRef.current; strokeRef.current = []; setStroke([])
    heal(pts, brushImg)
  }

  const download = () => {
    const cv = canvasRef.current; if (!cv || !img) return
    const a = document.createElement("a"); a.download = img.name.replace(/\.[^.]+$/, "") + "-healed.png"; a.href = cv.toDataURL("image/png"); a.click()
  }
  const copy = () => {
    const cv = canvasRef.current; if (!cv) return
    cv.toBlob(async b => {
      if (!b) return
      try { await navigator.clipboard.write([new ClipboardItem({ "image/png": b })]); flash("Copied to clipboard") }
      catch { flash("Copy blocked — use Download") }
    }, "image/png")
  }

  const points = stroke.length === 1 ? `${stroke[0].x},${stroke[0].y} ${stroke[0].x + 0.01},${stroke[0].y}` : stroke.map(p => `${p.x},${p.y}`).join(" ")
  // Where the size preview sits: under the pointer if it is over the image, else the middle of what is in view.
  const ring = cursor ?? (() => {
    const cv = canvasRef.current, st = stageRef.current
    if (!cv || !st || !img) return null
    const r = cv.getBoundingClientRect(), s = st.getBoundingClientRect()
    const cx = (Math.max(r.left, s.left) + Math.min(r.right, s.right)) / 2, cy = (Math.max(r.top, s.top) + Math.min(r.bottom, s.bottom)) / 2
    return { x: (cx - r.left) * cv.width / r.width, y: (cy - r.top) * cv.height / r.height }
  })()
  const showRing = !busy && (cursor || peek) && ring

  return (
    <div className="wrap">
      <div
        ref={stageRef}
        className={"stage" + (hover ? " hover" : "")}
        style={{ maxHeight: stage.h + 2 }}
        onDragOver={e => { e.preventDefault(); setHover(true) }}
        onDragLeave={() => setHover(false)}
        onDrop={e => { e.preventDefault(); setHover(false); loadFile(e.dataTransfer.files?.[0]) }}
      >
        <div className="frame" hidden={!img} style={{ width: dispW, height: dispH }}>
          <canvas
            ref={canvasRef}
            className={"art" + (busy ? " busy" : "")}
            aria-label="image"
            title="Ctrl+scroll to zoom"
            style={{ width: dispW, height: dispH }}
            onPointerDown={onDown}
            onPointerMove={onMove}
            onPointerUp={onUp}
            onPointerCancel={onUp}
            onPointerLeave={() => setCursor(null)}
          />
          {img && (
            <svg className="ov" viewBox={`0 0 ${img.w} ${img.h}`} style={{ width: dispW, height: dispH }} aria-hidden="true">
              {stroke.length > 0 && <polyline points={points} fill="none" stroke="#14b8a6" strokeOpacity={0.5} strokeWidth={brushImg} strokeLinecap="round" strokeLinejoin="round" />}
              {showRing && <>
                <circle cx={ring.x} cy={ring.y} r={brushImg / 2} fill="none" stroke="rgba(0,0,0,0.6)" strokeWidth={3} vectorEffect="non-scaling-stroke" />
                <circle cx={ring.x} cy={ring.y} r={brushImg / 2} fill="none" stroke="#fff" strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
              </>}
            </svg>
          )}
        </div>
        {!img && (
          <label className="drop">
            <input type="file" accept="image/*" hidden onChange={e => loadFile(e.target.files?.[0])} />
            <div className="dropIcon">⌖</div>
            <div className="dropMain">Drop an image, paste with <kbd>Ctrl</kbd>+<kbd>V</kbd>, or click</div>
            <div className="dropSub">Then paint over a blemish or watermark and let go</div>
          </label>
        )}
      </div>

      <footer className="bar">
        <input ref={fileRef} type="file" accept="image/*" hidden onChange={e => { loadFile(e.target.files?.[0]); e.target.value = "" }} />
        <button className="iconbtn" title="Open an image" aria-label="Open an image" onClick={() => fileRef.current?.click()}>
          <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
          </svg>
        </button>
        <label className="brushctl" title="Brush size ([ and ] keys)">
          <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><circle cx="12" cy="12" r="8" /></svg>
          <input type="range" min={BRUSH_MIN} max={BRUSH_MAX} value={brush} onChange={e => { const v = +e.target.value; resize(() => v) }} aria-label="Brush size" disabled={!img} />
        </label>
        <span className="spin" hidden={!busy} aria-label="Healing" />
        <span className="grow" />
        <button className="iconbtn" title="Undo (Ctrl+Z)" aria-label="Undo" disabled={!fixes} onClick={undo}>↶</button>
        <button className="iconbtn" title="Hold to see the original" aria-label="Show original" disabled={!fixes}
          onPointerDown={() => showOriginal(true)} onPointerUp={() => showOriginal(false)} onPointerLeave={() => showOriginal(false)}>
          <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z" /><circle cx="12" cy="12" r="3" />
          </svg>
        </button>
        <button className="iconbtn" title="Copy image" aria-label="Copy image" disabled={!img} onClick={copy}>
          <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <rect x="3" y="4" width="18" height="16" rx="2" /><circle cx="8.5" cy="9.5" r="1.5" /><path d="M21 16l-5-5-6 6" />
          </svg>
        </button>
        <button className="iconbtn accent" title="Download PNG" aria-label="Download PNG" disabled={!img} onClick={download}>⤓</button>
      </footer>

      <div className={"toast" + (toast ? " show" : "")}>{toast}</div>
    </div>
  )
}

createRoot(document.getElementById("root")!).render(<App />)
```
**styles.css**

```css
* { box-sizing: border-box; }

.wrap {
  max-width: 960px;
  margin: 0 auto;
  padding: 20px 16px 16px;
  font: 14px system-ui, -apple-system, sans-serif;
  color: CanvasText;
  display: grid;
  gap: 12px;
}

.bar { display: flex; align-items: center; gap: 8px; }
.grow { flex: 1 1 auto; }
.spin {
  width: 16px; height: 16px; border-radius: 50%;
  border: 2px solid color-mix(in srgb, currentColor 22%, transparent);
  border-top-color: #14b8a6;
  animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

.brushctl { display: inline-flex; align-items: center; gap: 10px; height: 32px; padding: 0 4px; }
.brushctl svg { flex: 0 0 auto; opacity: 0.8; }
.brushctl input { width: 140px; accent-color: #14b8a6; cursor: pointer; margin: 0; }
.brushctl input:disabled { opacity: 0.4; cursor: default; }

.iconbtn {
  flex: 0 0 auto;
  width: 32px; height: 32px;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 16px; line-height: 1;
  border-radius: 8px; cursor: pointer;
  border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
  background: transparent; color: inherit;
  touch-action: none;
}
.iconbtn:hover:not(:disabled) { background: color-mix(in srgb, currentColor 8%, transparent); }
.iconbtn.accent { border-color: #14b8a6; color: #14b8a6; }
.iconbtn:disabled { opacity: 0.4; cursor: default; }

.stage {
  position: relative;
  border: 1px dashed color-mix(in srgb, currentColor 28%, transparent);
  border-radius: 12px;
  background: color-mix(in srgb, currentColor 4%, transparent);
  min-height: 320px;
  display: flex;
  overflow: auto;
}
.stage.hover { border-color: #14b8a6; background: color-mix(in srgb, #14b8a6 12%, transparent); }

/* margin:auto centres a frame that fits and lets one that overflows scroll from its top-left */
.frame { position: relative; margin: auto; flex: 0 0 auto; }
.art { display: block; touch-action: none; cursor: none; }
.art.busy { cursor: progress; }
.ov { position: absolute; inset: 0; pointer-events: none; }

.drop { display: grid; gap: 6px; place-items: center; align-content: center; text-align: center; padding: 48px 24px; cursor: pointer; width: 100%; }
.dropIcon { font-size: 38px; opacity: 0.55; line-height: 1; }
.dropMain { font-size: 15px; font-weight: 500; }
.dropSub { font-size: 13px; opacity: 0.6; }
kbd {
  font: inherit; padding: 1px 6px; border-radius: 5px;
  border: 1px solid color-mix(in srgb, currentColor 35%, transparent);
  background: color-mix(in srgb, currentColor 8%, transparent);
}

.toast {
  position: fixed; left: 50%; bottom: 22px; transform: translate(-50%, 12px);
  background: CanvasText; color: Canvas; padding: 8px 16px; border-radius: 999px; font-size: 13px;
  opacity: 0; pointer-events: none; transition: opacity 0.18s, transform 0.18s;
}
.toast.show { opacity: 0.92; transform: translate(-50%, 0); }
```
**index.html**

```html
<div id="root"></div>
```
**config.json**

```json
{
  "description": "Remove watermarks, spots, and scratches from a photo: paint over the mark and it disappears. Runs in your browser. Nothing uploaded.",
  "dependencies": {
    "react": "^19.2.7",
    "react-dom": "^19.2.7"
  }
}
```