From 9610d1b111282eee41eaaf8bc26897184c717b61 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 16 Oct 2026 15:46:24 +0700 Subject: [PATCH] fix(heal): eliminate brush cursor mouse lag and stutter in desktop STUDIO by bypassing React reconciliation on pointermove --- docker/frontend/src/engine/inpaint.ts | 8 +- docker/frontend/src/styles/app.css | 6 +- docker/frontend/src/ui/ImageStage.tsx | 122 +++++++++++++++----------- 3 files changed, 84 insertions(+), 52 deletions(-) diff --git a/docker/frontend/src/engine/inpaint.ts b/docker/frontend/src/engine/inpaint.ts index 066c367..3fbb07d 100644 --- a/docker/frontend/src/engine/inpaint.ts +++ b/docker/frontend/src/engine/inpaint.ts @@ -75,15 +75,21 @@ async function hasDevice(): Promise { } } +let isWebGPUActive = false; + // WebGPU on the main thread: asynchronous and GPU-bound. async function openSession(ort: typeof Ort): Promise { if (await hasDevice()) { try { const gpu = await ort.InferenceSession.create(MODEL_URL, { executionProviders: ['webgpu'] }); - if (await warmsUp(ort, gpu)) return gpu; + if (await warmsUp(ort, gpu)) { + isWebGPUActive = true; + return gpu; + } await gpu.release(); } catch {} } + isWebGPUActive = false; return ort.InferenceSession.create(MODEL_URL, { executionProviders: ['wasm'], enableCpuMemArena: false, diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 8e6992e..ef3cc03 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -779,7 +779,11 @@ button.hint:disabled { cursor: default; } transform: translate(-50%, -50%); pointer-events: none; } -.brush-cursor { border: 1px solid rgba(255, 255, 255, 0.9); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55); } +.brush-cursor { + border: 1px solid rgba(255, 255, 255, 0.9); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55); + will-change: left, top; +} /* The stroke in progress is one band of the brush's own width — a path with round ends and round joins — so a drag reads as a brush being drawn across the photo rather than as the beads a row of overlapping circles looks like. diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 1256a24..bb5b8ad 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -869,7 +869,8 @@ export function ImageStage({ const [pickPos, setPickPos] = useState<{ fx: number; fy: number } | null>(null); // Where the brush's circle is drawing itself, in the same fraction: the ring // follows the pointer at every zoom instead of sliding by the zoom factor. - const [brushPos, setBrushPos] = useState<{ fx: number; fy: number } | null>(null); + // Brush cursor DOM ref for 60-144Hz zero-lag tracking without triggering React component re-renders + const brushCursorRef = useRef(null); // The colour that was read is rarely a clear patch of photo, so the mixer's // card can be dragged off the point it hangs on. The offset is a fraction of // the photo — which is what the layer is — so a zoom keeps the card where it @@ -1059,25 +1060,36 @@ export function ImageStage({ // user can take hold of. const spotUnder = useCallback( (p: { fx: number; fy: number }) => { - if (!box || brushTool !== 'heal' || !onBrushEdit) return null; + if (!box || brushTool !== 'heal' || !onBrushEdit || brushSpots.length === 0) 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]; - // 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 sx = s.x * box.width; + const sy = s.y * box.height; + const prev = i > 0 && s.stroke !== undefined && brushSpots[i - 1].stroke === s.stroke ? brushSpots[i - 1] : null; + + // Fast AABB rejection: eliminate hypot & vector projections for distant spots + if (Math.abs(px - sx) > reach || Math.abs(py - sy) > reach) { + if (!prev) continue; + const psx = prev.x * box.width; + const psy = prev.y * box.height; + const minX = Math.min(sx, psx) - reach; + const maxX = Math.max(sx, psx) + reach; + const minY = Math.min(sy, psy) - reach; + const maxY = Math.max(sy, psy) + reach; + if (px < minX || px > maxX || py < minY || py > maxY) continue; + } + const gap = prev ? Math.min( - Math.hypot(px - s.x * box.width, py - s.y * box.height), + Math.hypot(px - sx, py - sy), gapToRun(px, py, prev, s, box.width, box.height) ) - : Math.hypot(px - s.x * box.width, py - s.y * box.height); + : Math.hypot(px - sx, py - sy); if (gap > reach || gap >= bestGap) continue; best = i; bestGap = gap; @@ -1155,8 +1167,7 @@ export function ImageStage({ const bandOn = (b: StrokeRun) => { if (selSpot !== null && b.spots.includes(selSpot)) return true; if (handSpot !== null && b.spots.includes(handSpot)) return true; - const under = brushPos ? spotUnder(brushPos) : null; - return under !== null && b.spots.includes(under); + return false; }; const brushDown = (e: React.PointerEvent) => { @@ -1205,7 +1216,19 @@ export function ImageStage({ return; } const p = brushPoint(e.clientX, e.clientY); - setBrushPos(p ? { fx: p.fx, fy: p.fy } : null); + if (brushCursorRef.current && box) { + if (p) { + const d = 2 * brushR * box.width; + const el = brushCursorRef.current; + if (el.style.display !== 'block') el.style.display = 'block'; + el.style.width = `${d}px`; + el.style.height = `${d}px`; + el.style.left = `${p.fx * 100}%`; + el.style.top = `${p.fy * 100}%`; + } else { + brushCursorRef.current.style.display = 'none'; + } + } const grab = grabRef.current; if (grab !== null && onBrushEdit && p) { const dx = p.fx - grab.anchor.fx; @@ -1223,10 +1246,11 @@ export function ImageStage({ return; } const stroke = strokeRef.current; - // Which repair is the one in hand, for the ring it wears: the spot under - // the pointer. Only while nothing is being painted or dragged, so a stroke - // does not flicker the rings it runs over. - if (!stroke) setHandSpot(p ? (spotUnder(p) ?? null) : null); + // Only update handSpot if it changed (deduplicated to prevent unnecessary re-renders) + if (!stroke) { + const nextHand = p ? (spotUnder(p) ?? null) : null; + setHandSpot((prev) => (prev === nextHand ? prev : nextHand)); + } if (!stroke || !p) return; const last = stroke.pts[stroke.pts.length - 1]; // The spacing is a fraction of the brush, measured in the photo's own @@ -1312,6 +1336,15 @@ export function ImageStage({ setHandSpot(null); }, [brushTool]); + // Sync brush cursor size when brushR or box dimensions change + useEffect(() => { + if (brushCursorRef.current && box) { + const d = 2 * brushR * box.width; + brushCursorRef.current.style.width = `${d}px`; + brushCursorRef.current.style.height = `${d}px`; + } + }, [brushR, box]); + // The img's box at scale 1 — the natural size fitted into the wrap, never // enlarged. Computed rather than measured so the transform cannot feed back // into it. @@ -2174,7 +2207,7 @@ export function ImageStage({ }} onPointerMove={brushMove} onPointerLeave={() => { - setBrushPos(null); + if (brushCursorRef.current) brushCursorRef.current.style.display = 'none'; setHandSpot(null); }} onPointerUp={brushUp} @@ -2182,6 +2215,7 @@ export function ImageStage({ strokeRef.current = null; panRef.current = null; setBrushTrail([]); + if (brushCursorRef.current) brushCursorRef.current.style.display = 'none'; setHandSpot(null); }} > @@ -2240,34 +2274,28 @@ export function ImageStage({ ) : null} {brushSpots.map((s, i) => { - // r is a fraction of the photo's WIDTH, so the circle is round - // whatever the picture's shape is — the same measurement the - // shader makes against the render's own pixels. + const isJoined = Boolean(bands?.dst.joined[i]); + const isSel = i === selSpot; + const isHand = i === handSpot; + // A spot joined into a stroke contour that is not selected or hovered + // is completely hidden by CSS. Skip generating DOM nodes to keep cursor 100% lag-free. + if (isJoined && !isSel && !isHand) return null; + const d = 2 * s.r * box.width; - const at = (x: number, y: number) => ({ - left: `${x * 100}%`, - top: `${y * 100}%`, - width: d, - height: d, - }); - // A spot the band stands for keeps its place and its box on the - // photo — it is still the spot the shader works from — and gives - // up only its own edge, which is the whole of what a row of - // circles was showing. - // - // A HEAL spot that is neither in hand nor chosen is idle: it - // keeps a faint print of the repair it made, and gives up the - // ring, which comes back under the pointer — that is where the - // spot is taken hold of again, so nothing about it is lost. - const idle = brushTool === 'heal' && i !== selSpot && i !== handSpot; + const idle = brushTool === 'heal' && !isSel && !isHand; return ( ); @@ -2288,18 +2316,12 @@ export function ImageStage({ × ) : null} - {brushPos ? ( - - ) : null} + ) : null} {/* FX's gradient masks — LINEAR and RADIAL GRADIENT. The layer takes