performance improvement
This commit is contained in:
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { api } from "../ipc";
|
||||
import type { CocoShape, ImportedCoco, VideoInfo } from "../types";
|
||||
import type { CocoShape, ImportedCoco, Settings, VideoInfo } from "../types";
|
||||
import { FrameCanvas } from "../components/extract/FrameCanvas";
|
||||
import { drawOverlay } from "../components/extract/overlay";
|
||||
|
||||
@@ -11,6 +11,7 @@ const FRAME_BITMAP_CACHE_LIMIT = 24;
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
settings: Settings | null;
|
||||
}
|
||||
|
||||
interface BulkProgress {
|
||||
@@ -19,7 +20,14 @@ interface BulkProgress {
|
||||
phase: "extract" | "undo";
|
||||
}
|
||||
|
||||
export function ExtractMode({ username }: Props) {
|
||||
export function ExtractMode({ username, settings }: Props) {
|
||||
// frame_skip ≥ 1 means: during playback advance by N frames per tick, and
|
||||
// bulk-extract takes every Nth frame across [start, end]. Kept in a ref so
|
||||
// the long-lived playback loop sees changes without re-running its effect.
|
||||
const frameSkipRef = useRef<number>(Math.max(1, settings?.frame_skip ?? 1));
|
||||
useEffect(() => {
|
||||
frameSkipRef.current = Math.max(1, settings?.frame_skip ?? 1);
|
||||
}, [settings?.frame_skip]);
|
||||
const [video, setVideo] = useState<VideoInfo | null>(null);
|
||||
const [frameIdx, setFrameIdx] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
@@ -53,9 +61,6 @@ export function ExtractMode({ username }: Props) {
|
||||
const frameMapRef = useRef<Map<number, CocoShape[]>>(new Map());
|
||||
const overlayVisibleRef = useRef(overlayVisible);
|
||||
const annotatedFrames = useRef<Set<number>>(new Set());
|
||||
// Playback clock anchor — set when playback starts, rebased on seeks so a
|
||||
// slider drag during play doesn't get overwritten by the tick loop.
|
||||
const playAnchorRef = useRef<{ time: number; idx: number } | null>(null);
|
||||
|
||||
useEffect(() => { frameIdxRef.current = frameIdx; }, [frameIdx]);
|
||||
useEffect(() => {
|
||||
@@ -69,6 +74,12 @@ export function ExtractMode({ username }: Props) {
|
||||
const renderedIdx = useRef(-1);
|
||||
const decodingInFlight = useRef(false);
|
||||
const bitmapCacheRef = useRef<Map<number, ImageBitmap>>(new Map());
|
||||
// Last decode/render error message. Set in `pump` on failure, cleared on
|
||||
// any successful render, and observed by the playback loop so a mid-play
|
||||
// failure stops playback with a visible banner instead of leaving the UI
|
||||
// stuck in "playing" while the canvas freezes.
|
||||
const decodeErrorRef = useRef<string | null>(null);
|
||||
const [playbackError, setPlaybackError] = useState<string | null>(null);
|
||||
|
||||
const clearBitmapCache = useCallback(() => {
|
||||
for (const bitmap of bitmapCacheRef.current.values()) {
|
||||
@@ -105,31 +116,82 @@ export function ExtractMode({ username }: Props) {
|
||||
const drawBitmap = useCallback(
|
||||
(bitmap: ImageBitmap, atFrameIdx: number) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
if (!canvas || !video) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Backing store = displayed CSS rect × DPR, capped at video native
|
||||
// resolution (anything above is upsampling for no benefit). Halves
|
||||
// drawImage cost on 4K-into-1080p, which is the common slow-system
|
||||
// case. Coords below are in image-pixel space — the transform handles
|
||||
// the mapping to backing pixels, so overlay.ts is unchanged.
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const targetW = Math.max(
|
||||
1,
|
||||
Math.min(video.width, Math.round(rect.width * dpr))
|
||||
);
|
||||
const targetH = Math.max(
|
||||
1,
|
||||
Math.min(video.height, Math.round(rect.height * dpr))
|
||||
);
|
||||
if (canvas.width !== targetW) canvas.width = targetW;
|
||||
if (canvas.height !== targetH) canvas.height = targetH;
|
||||
|
||||
ctx.setTransform(
|
||||
targetW / video.width,
|
||||
0,
|
||||
0,
|
||||
targetH / video.height,
|
||||
0,
|
||||
0
|
||||
);
|
||||
ctx.clearRect(0, 0, video.width, video.height);
|
||||
ctx.drawImage(bitmap, 0, 0, video.width, video.height);
|
||||
|
||||
if (overlayVisibleRef.current) {
|
||||
const shapes = frameMapRef.current.get(atFrameIdx);
|
||||
if (shapes && shapes.length > 0) {
|
||||
drawOverlay(ctx, shapes, canvas.width, canvas.height);
|
||||
drawOverlay(ctx, shapes, video.width, video.height);
|
||||
}
|
||||
}
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
},
|
||||
[]
|
||||
[video]
|
||||
);
|
||||
|
||||
// Returns true if we drew, false if we were preempted by a newer request,
|
||||
// and throws if bitmap creation failed — pump uses this to decide whether
|
||||
// to mark the frame rendered or surface the error to the playback loop.
|
||||
const renderFrame = useCallback(
|
||||
async (bytes: ArrayBuffer, atFrameIdx: number) => {
|
||||
async (bytes: ArrayBuffer, atFrameIdx: number): Promise<boolean> => {
|
||||
// Diagnostic timing log: set `window.SAM_FRAME_DEBUG = true` in devtools
|
||||
// on a slow machine to see per-frame bitmap and draw timings — confirms
|
||||
// where the bottleneck lives (decode vs createImageBitmap vs drawImage).
|
||||
const debug = (window as unknown as { SAM_FRAME_DEBUG?: boolean })
|
||||
.SAM_FRAME_DEBUG === true;
|
||||
const t0 = debug ? performance.now() : 0;
|
||||
const blob = new Blob([bytes], { type: "image/jpeg" });
|
||||
let bitmap: ImageBitmap;
|
||||
try { bitmap = await createImageBitmap(blob); }
|
||||
catch (e) { console.error("createImageBitmap failed", e); return; }
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`createImageBitmap failed: ${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
}
|
||||
const t1 = debug ? performance.now() : 0;
|
||||
rememberBitmap(atFrameIdx, bitmap);
|
||||
if (requestedIdx.current !== atFrameIdx) return;
|
||||
if (requestedIdx.current !== atFrameIdx) return false;
|
||||
drawBitmap(bitmap, atFrameIdx);
|
||||
if (debug) {
|
||||
const t2 = performance.now();
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(
|
||||
`[SAM] f${atFrameIdx} bytes=${bytes.byteLength} bitmap=${(t1 - t0).toFixed(0)}ms draw=${(t2 - t1).toFixed(0)}ms`
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[drawBitmap, rememberBitmap]
|
||||
);
|
||||
@@ -149,11 +211,17 @@ export function ExtractMode({ username }: Props) {
|
||||
try {
|
||||
const bytes = await api.decodeFrame(want);
|
||||
if (requestedIdx.current !== want) continue;
|
||||
await renderFrame(bytes, want);
|
||||
renderedIdx.current = want;
|
||||
const drew = await renderFrame(bytes, want);
|
||||
if (drew) {
|
||||
renderedIdx.current = want;
|
||||
decodeErrorRef.current = null;
|
||||
}
|
||||
// !drew means renderFrame was preempted by a newer request; loop
|
||||
// continues and the new target gets a fresh decode.
|
||||
} catch (e) {
|
||||
if (requestedIdx.current !== want) continue;
|
||||
console.error("decode_frame failed", e);
|
||||
console.error("decode/render failed", e);
|
||||
decodeErrorRef.current = e instanceof Error ? e.message : String(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -181,47 +249,114 @@ export function ExtractMode({ username }: Props) {
|
||||
|
||||
useEffect(() => clearBitmapCache, [clearBitmapCache]);
|
||||
|
||||
// Redraw on canvas resize: backing-store is sized from CSS rect × DPR, so
|
||||
// a window resize/maximize would otherwise leave the old backing dimensions
|
||||
// until the next decode landed. Cheap — just re-runs drawBitmap on the
|
||||
// already-cached frame.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !video) return;
|
||||
let scheduled = false;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
scheduled = false;
|
||||
const idx = frameIdxRef.current;
|
||||
const cached = getCachedBitmap(idx);
|
||||
if (cached) drawBitmap(cached, idx);
|
||||
else { renderedIdx.current = -1; pump(); }
|
||||
});
|
||||
});
|
||||
ro.observe(canvas);
|
||||
return () => ro.disconnect();
|
||||
}, [video, drawBitmap, getCachedBitmap, pump]);
|
||||
|
||||
// --- playback loop ---------------------------------------------------
|
||||
// Render-paced: advance ONLY after the previous frame actually drew. On a
|
||||
// fast box this hits target fps; on a slow box it plays smoothly at the
|
||||
// achievable decode/render rate instead of dropping 9-of-10 frames at the
|
||||
// canvas while the wall-clock UI counter races ahead.
|
||||
useEffect(() => {
|
||||
if (!playing || !video) return;
|
||||
playAnchorRef.current = {
|
||||
time: performance.now(),
|
||||
idx: frameIdxRef.current,
|
||||
};
|
||||
let cancelled = false;
|
||||
let nextDueAt = performance.now();
|
||||
|
||||
let raf = 0;
|
||||
const tick = () => {
|
||||
const anchor = playAnchorRef.current;
|
||||
if (!anchor) return;
|
||||
const elapsed = (performance.now() - anchor.time) / 1000;
|
||||
const want = Math.min(
|
||||
video.total_frames - 1,
|
||||
anchor.idx + Math.floor(elapsed * video.fps * speed)
|
||||
);
|
||||
// Only trigger the review-mode auto-pause once we've actually advanced
|
||||
// past the resume point. Otherwise pressing play while parked on an
|
||||
// annotated frame would re-pause on the very first tick, making the
|
||||
// play button look broken — the user would have to step a frame
|
||||
// forward manually before play could "stick".
|
||||
if (
|
||||
reviewMode &&
|
||||
want > anchor.idx &&
|
||||
annotatedFrames.current.has(want)
|
||||
) {
|
||||
setFrameIdx(want);
|
||||
setPlaying(false);
|
||||
return;
|
||||
const waitForFrame = (target: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const check = () => {
|
||||
if (
|
||||
cancelled ||
|
||||
renderedIdx.current === target ||
|
||||
decodeErrorRef.current !== null
|
||||
) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(check);
|
||||
};
|
||||
check();
|
||||
});
|
||||
|
||||
const loop = async () => {
|
||||
while (!cancelled && playing) {
|
||||
const skip = Math.max(1, frameSkipRef.current | 0);
|
||||
// Real-time pacing regardless of skip: skip controls density,
|
||||
// `speed` controls speed. Without the skip factor here, skip=3 would
|
||||
// play at 3× wall-clock — which is what the speed slider is for.
|
||||
const interval = (skip * 1000) / (video.fps * speed);
|
||||
const current = frameIdxRef.current;
|
||||
let target = Math.min(video.total_frames - 1, current + skip);
|
||||
if (target <= current) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
// Review-mode scan: with skip > 1 a naive +skip jump can leap past
|
||||
// annotations between current+1 and current+skip. Walk that window
|
||||
// and clamp `target` down to the first annotated frame.
|
||||
if (reviewMode) {
|
||||
for (let i = current + 1; i <= target; i++) {
|
||||
if (annotatedFrames.current.has(i)) {
|
||||
target = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
requestedIdx.current = target;
|
||||
pump();
|
||||
await waitForFrame(target);
|
||||
if (cancelled) return;
|
||||
// Decode failed mid-play: stop and surface so the UI doesn't claim to
|
||||
// be playing while the canvas is frozen on the last good frame.
|
||||
if (decodeErrorRef.current) {
|
||||
setPlaybackError(decodeErrorRef.current);
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
setFrameIdx(target);
|
||||
// Pause AFTER rendering so the user actually sees the annotated frame.
|
||||
if (reviewMode && annotatedFrames.current.has(target)) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const due = nextDueAt + interval;
|
||||
if (due > now) {
|
||||
await new Promise((r) => setTimeout(r, due - now));
|
||||
nextDueAt = due;
|
||||
} else {
|
||||
// Behind real-time — don't accumulate debt and then "catch up" by
|
||||
// skipping frames; just reset the clock to now.
|
||||
nextDueAt = now;
|
||||
}
|
||||
}
|
||||
setFrameIdx(want);
|
||||
if (want >= video.total_frames - 1) { setPlaying(false); return; }
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
loop();
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
playAnchorRef.current = null;
|
||||
cancelled = true;
|
||||
};
|
||||
}, [playing, video, speed, reviewMode]);
|
||||
}, [playing, video, speed, reviewMode, pump]);
|
||||
|
||||
// --- navigation helpers ----------------------------------------------
|
||||
const seekToFrame = useCallback((idx: number) => {
|
||||
@@ -237,11 +372,6 @@ export function ExtractMode({ username }: Props) {
|
||||
pump();
|
||||
}
|
||||
setFrameIdx((prev) => (prev === clamped ? prev : clamped));
|
||||
// If a playback tick is active, rebase its anchor so the loop continues
|
||||
// from the new position instead of snapping back.
|
||||
if (playAnchorRef.current) {
|
||||
playAnchorRef.current = { time: performance.now(), idx: clamped };
|
||||
}
|
||||
}, [drawBitmap, getCachedBitmap, pump, video]);
|
||||
|
||||
const stepFrames = useCallback((delta: number) => {
|
||||
@@ -252,7 +382,15 @@ export function ExtractMode({ username }: Props) {
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (!video) return;
|
||||
setPlaying((p) => !p);
|
||||
setPlaying((p) => {
|
||||
if (!p) {
|
||||
// Fresh play attempt — clear any stale decode error so the loop can
|
||||
// re-arm. If it fails again, the banner will reappear.
|
||||
decodeErrorRef.current = null;
|
||||
setPlaybackError(null);
|
||||
}
|
||||
return !p;
|
||||
});
|
||||
}, [video]);
|
||||
|
||||
const bumpSpeed = useCallback((dir: 1 | -1) => {
|
||||
@@ -326,22 +464,31 @@ export function ExtractMode({ username }: Props) {
|
||||
};
|
||||
}, [bulkStart, bulkEnd]);
|
||||
|
||||
const bulkCount = useMemo(() => {
|
||||
if (!bulkRange) return 0;
|
||||
const skip = Math.max(1, settings?.frame_skip ?? 1);
|
||||
return Math.floor((bulkRange.end - bulkRange.start) / skip) + 1;
|
||||
}, [bulkRange, settings?.frame_skip]);
|
||||
|
||||
async function runBulkExtract() {
|
||||
if (!outputFolder || !bulkRange) return;
|
||||
setPlaying(false);
|
||||
setBulkError(null);
|
||||
setBulkProgress({ done: 0, total: bulkRange.end - bulkRange.start + 1, phase: "extract" });
|
||||
const skip = Math.max(1, settings?.frame_skip ?? 1);
|
||||
const total = Math.floor((bulkRange.end - bulkRange.start) / skip) + 1;
|
||||
setBulkProgress({ done: 0, total, phase: "extract" });
|
||||
const unlisten = await listen<{ done: number; total: number }>(
|
||||
"bulk-extract-progress",
|
||||
(e) => setBulkProgress({ ...e.payload, phase: "extract" })
|
||||
);
|
||||
// Only include annotations for frames we'll actually emit; backend will
|
||||
// step start..end by `skip` and shapesByFrame is keyed by absolute idx.
|
||||
const shapesByFrame: Record<string, CocoShape[]> = {};
|
||||
if (coco) {
|
||||
for (const [key, shapes] of Object.entries(coco.frames)) {
|
||||
const idx = Number(key);
|
||||
if (idx >= bulkRange.start && idx <= bulkRange.end) {
|
||||
shapesByFrame[key] = shapes;
|
||||
}
|
||||
for (let idx = bulkRange.start; idx <= bulkRange.end; idx += skip) {
|
||||
const key = String(idx);
|
||||
const shapes = coco.frames[key];
|
||||
if (shapes) shapesByFrame[key] = shapes;
|
||||
}
|
||||
}
|
||||
try {
|
||||
@@ -350,7 +497,8 @@ export function ExtractMode({ username }: Props) {
|
||||
bulkRange.end,
|
||||
outputFolder,
|
||||
shapesByFrame,
|
||||
username
|
||||
username,
|
||||
skip
|
||||
);
|
||||
const existing = await api.listExtractedFrames(outputFolder);
|
||||
setExtractedFrames(new Set(existing));
|
||||
@@ -401,6 +549,16 @@ export function ExtractMode({ username }: Props) {
|
||||
const tag = (e.target as HTMLElement | null)?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||
const step = e.ctrlKey || e.metaKey ? 10 : 1;
|
||||
// Adaptive throttle: drop OS key-repeats when the previous step is
|
||||
// still decoding. On fast systems this is a no-op; on slow systems it
|
||||
// prevents a held arrow from queueing 30 seeks/sec the pump can't keep
|
||||
// up with, which is what causes the "huge gap between keypress and
|
||||
// frame" feel.
|
||||
const isHeldRepeat =
|
||||
e.repeat &&
|
||||
(e.key === "ArrowRight" || e.key === "ArrowLeft") &&
|
||||
requestedIdx.current !== renderedIdx.current;
|
||||
if (isHeldRepeat) { e.preventDefault(); return; }
|
||||
switch (e.key) {
|
||||
case " ":
|
||||
e.preventDefault(); togglePlay(); break;
|
||||
@@ -463,6 +621,8 @@ export function ExtractMode({ username }: Props) {
|
||||
setPlaying(false);
|
||||
setSpeed(1);
|
||||
renderedIdx.current = -1;
|
||||
decodeErrorRef.current = null;
|
||||
setPlaybackError(null);
|
||||
// Reset per-video extraction context, then auto-adopt the per-video
|
||||
// default folder. If the user already extracted frames into it in a
|
||||
// prior session, listExtractedFrames rehydrates them immediately.
|
||||
@@ -590,7 +750,7 @@ export function ExtractMode({ username }: Props) {
|
||||
onClick={runBulkExtract}
|
||||
disabled={!outputFolder || !bulkRange || !!bulkProgress}
|
||||
>
|
||||
Bulk Extract{bulkRange && ` (${bulkRange.end - bulkRange.start + 1})`}
|
||||
Bulk Extract{bulkRange && ` (${bulkCount})`}
|
||||
</button>
|
||||
<button
|
||||
onClick={runBulkUndo}
|
||||
@@ -683,6 +843,22 @@ export function ExtractMode({ username }: Props) {
|
||||
</button>
|
||||
<span className="dim shortcuts">space · ← → · ctrl+← → · ↑ ↓ · H · E · U · [ ]</span>
|
||||
</div>
|
||||
|
||||
{playbackError && (
|
||||
<p className="error" role="alert">
|
||||
Playback stopped: {playbackError}
|
||||
{" "}
|
||||
<button
|
||||
className="link"
|
||||
onClick={() => {
|
||||
decodeErrorRef.current = null;
|
||||
setPlaybackError(null);
|
||||
}}
|
||||
>
|
||||
dismiss
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user