fix(heal): eliminate brush cursor mouse lag and stutter in desktop STUDIO by bypassing React reconciliation on pointermove
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user