fix(heal): eliminate brush cursor mouse lag and stutter in desktop STUDIO by bypassing React reconciliation on pointermove

This commit is contained in:
2026-10-16 15:46:24 +07:00
parent 7e4bfd3da7
commit 9610d1b111
3 changed files with 84 additions and 52 deletions
+7 -1
View File
@@ -75,15 +75,21 @@ async function hasDevice(): Promise<boolean> {
}
}
let isWebGPUActive = false;
// WebGPU on the main thread: asynchronous and GPU-bound.
async function openSession(ort: typeof Ort): Promise<Ort.InferenceSession> {
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,
+5 -1
View File
@@ -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.
+72 -50
View File
@@ -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<HTMLSpanElement | null>(null);
// The colour that was read is rarely a clear patch of photo, so the mixer's
// card can be dragged off the point it hangs on. The offset is a fraction of
// the photo — which is what the layer is — so a zoom keeps the card where it
@@ -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<HTMLDivElement>) => {
@@ -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({
</svg>
) : 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 (
<span className="brush-set" data-key="brush-set" data-i={i} key={i}>
<span
className="brush-spot"
data-key="brush-spot"
data-band={bands?.dst.joined[i] ? '1' : undefined}
data-band={isJoined ? '1' : undefined}
data-idle={idle ? '1' : undefined}
style={at(s.x, s.y)}
style={{
left: `${s.x * 100}%`,
top: `${s.y * 100}%`,
width: d,
height: d,
}}
/>
</span>
);
@@ -2288,18 +2316,12 @@ export function ImageStage({
×
</button>
) : null}
{brushPos ? (
<span
className="brush-cursor"
data-key="brush-cursor"
style={{
left: `${brushPos.fx * 100}%`,
top: `${brushPos.fy * 100}%`,
width: 2 * brushR * box.width,
height: 2 * brushR * box.width,
}}
/>
) : null}
<span
ref={brushCursorRef}
className="brush-cursor"
data-key="brush-cursor"
style={{ display: 'none' }}
/>
</div>
) : null}
{/* FX's gradient masks — LINEAR and RADIAL GRADIENT. The layer takes