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, Settings, VideoInfo } from "../types"; import { FrameCanvas } from "../components/extract/FrameCanvas"; import { drawOverlay } from "../components/extract/overlay"; const SPEEDS = [0.25, 0.5, 1, 1.5, 2, 3, 4]; const FRAME_BITMAP_CACHE_LIMIT = 24; interface Props { username: string; settings: Settings | null; } interface BulkProgress { done: number; total: number; phase: "extract" | "undo"; } 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(Math.max(1, settings?.frame_skip ?? 1)); useEffect(() => { frameSkipRef.current = Math.max(1, settings?.frame_skip ?? 1); }, [settings?.frame_skip]); // Preview-quality cap (in px height; 0 = original). Read per-draw via ref // so a settings change triggers an immediate redraw of the current frame // without rebuilding the playback effect. const previewMaxHRef = useRef(settings?.preview_max_height ?? 0); useEffect(() => { previewMaxHRef.current = settings?.preview_max_height ?? 0; }, [settings?.preview_max_height]); const [video, setVideo] = useState(null); const [frameIdx, setFrameIdx] = useState(0); const [playing, setPlaying] = useState(false); const [speed, setSpeed] = useState(1); const [reviewMode, setReviewMode] = useState(false); const [overlayVisible, setOverlayVisible] = useState(true); const [loadError, setLoadError] = useState(null); const [coco, setCoco] = useState(null); // Extraction state const [outputFolder, setOutputFolder] = useState(null); const [extractedFrames, setExtractedFrames] = useState>(new Set()); const [bulkStart, setBulkStart] = useState(null); const [bulkEnd, setBulkEnd] = useState(null); const [bulkProgress, setBulkProgress] = useState(null); const [bulkError, setBulkError] = useState(null); const [infoCollapsed, setInfoCollapsed] = useState(false); const canvasRef = useRef(null); const frameMap = useMemo(() => { const m = new Map(); if (!coco) return m; for (const [k, v] of Object.entries(coco.frames)) m.set(Number(k), v); return m; }, [coco]); // Refs mirroring state for effects that shouldn't depend on fast-changing values. const frameIdxRef = useRef(0); const frameMapRef = useRef>(new Map()); const overlayVisibleRef = useRef(overlayVisible); const annotatedFrames = useRef>(new Set()); useEffect(() => { frameIdxRef.current = frameIdx; }, [frameIdx]); useEffect(() => { frameMapRef.current = frameMap; annotatedFrames.current = new Set(frameMap.keys()); }, [frameMap]); useEffect(() => { overlayVisibleRef.current = overlayVisible; }, [overlayVisible]); // --- decode pump (latest-wins coalescing) ---------------------------- const requestedIdx = useRef(0); const renderedIdx = useRef(-1); const decodingInFlight = useRef(false); const bitmapCacheRef = useRef>(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(null); const [playbackError, setPlaybackError] = useState(null); // Cached canvas CSS rect. drawBitmap used to call getBoundingClientRect() // every frame, which forces a synchronous layout flush each time. We now // refresh it from the ResizeObserver below and fall back to a one-shot // query if it isn't populated yet. const canvasRectRef = useRef<{ width: number; height: number } | null>(null); const clearBitmapCache = useCallback(() => { for (const bitmap of bitmapCacheRef.current.values()) { bitmap.close(); } bitmapCacheRef.current.clear(); }, []); const getCachedBitmap = useCallback((idx: number) => { const cache = bitmapCacheRef.current; const bitmap = cache.get(idx); if (!bitmap) return null; cache.delete(idx); cache.set(idx, bitmap); return bitmap; }, []); const rememberBitmap = useCallback((idx: number, bitmap: ImageBitmap) => { const cache = bitmapCacheRef.current; const existing = cache.get(idx); if (existing && existing !== bitmap) { existing.close(); } cache.delete(idx); cache.set(idx, bitmap); while (cache.size > FRAME_BITMAP_CACHE_LIMIT) { const oldest = cache.keys().next().value; if (oldest == null) break; cache.get(oldest)?.close(); cache.delete(oldest); } }, []); const drawBitmap = useCallback( (bitmap: ImageBitmap, atFrameIdx: number) => { const canvas = canvasRef.current; if (!canvas || !video) return; const ctx = canvas.getContext("2d"); if (!ctx) return; // Backing store = displayed CSS rect × DPR, capped at video native and // optionally at the user's Preview-quality preset. Halves drawImage // cost on 4K-into-1080p (the common slow-system case). Coords below // are in image-pixel space — the transform maps to backing pixels so // overlay.ts is unchanged. The rect is cached by the ResizeObserver // effect; we only fall back to a live query on the first frame. let rect = canvasRectRef.current; if (!rect) { const dom = canvas.getBoundingClientRect(); rect = { width: dom.width, height: dom.height }; canvasRectRef.current = rect; } const dpr = window.devicePixelRatio || 1; let maxW = video.width; let maxH = video.height; const previewMaxH = previewMaxHRef.current; if (previewMaxH > 0 && previewMaxH < video.height) { maxH = previewMaxH; maxW = Math.round(previewMaxH * (video.width / video.height)); } const targetW = Math.max( 1, Math.min(maxW, Math.round(rect.width * dpr)) ); const targetH = Math.max( 1, Math.min(maxH, 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, 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): Promise => { // 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) { 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 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] ); const pump = useCallback(async () => { if (decodingInFlight.current) return; decodingInFlight.current = true; try { while (requestedIdx.current !== renderedIdx.current) { const want = requestedIdx.current; const cached = getCachedBitmap(want); if (cached) { drawBitmap(cached, want); renderedIdx.current = want; continue; } try { const bytes = await api.decodeFrame(want); if (requestedIdx.current !== want) continue; 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/render failed", e); decodeErrorRef.current = e instanceof Error ? e.message : String(e); break; } } } finally { decodingInFlight.current = false; } }, [drawBitmap, getCachedBitmap, renderFrame]); useEffect(() => { if (!video) return; requestedIdx.current = frameIdx; pump(); }, [frameIdx, pump, video]); useEffect(() => { const cached = getCachedBitmap(frameIdxRef.current); if (cached) { drawBitmap(cached, frameIdxRef.current); renderedIdx.current = frameIdxRef.current; } else { renderedIdx.current = -1; pump(); } }, [overlayVisible, frameMap, drawBitmap, getCachedBitmap, pump]); useEffect(() => clearBitmapCache, [clearBitmapCache]); // Live re-render when the preview-quality cap changes so the user sees the // resolution swap immediately on Settings save instead of having to seek. useEffect(() => { const canvas = canvasRef.current; if (!canvas || !video) return; const idx = frameIdxRef.current; const cached = getCachedBitmap(idx); if (cached) drawBitmap(cached, idx); // If not cached, the next pump invocation picks up the new cap naturally. }, [settings?.preview_max_height, video, drawBitmap, getCachedBitmap]); // 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; // Seed the rect cache once on mount so the first drawBitmap doesn't // have to force a layout flush itself. { const dom = canvas.getBoundingClientRect(); canvasRectRef.current = { width: dom.width, height: dom.height }; } const ro = new ResizeObserver((entries) => { // Update the cached rect from the observer entry — it carries the // CSS-box size without forcing a sync layout. const last = entries[entries.length - 1]; const box = last?.contentBoxSize?.[0]; if (box) { canvasRectRef.current = { width: box.inlineSize, height: box.blockSize, }; } else if (last?.contentRect) { canvasRectRef.current = { width: last.contentRect.width, height: last.contentRect.height, }; } 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; let cancelled = false; let nextDueAt = performance.now(); const waitForFrame = (target: number) => new Promise((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); 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; // Fast path: await pump directly. When pump owns the queue it // resolves the instant target is drawn (no 16ms rAF latency tax). // If another caller (ResizeObserver / overlay-change effect) was // already running pump, our call short-circuits — fall back to // rAF polling in that case so we still wait correctly. await pump(); if ( !cancelled && renderedIdx.current !== target && decodeErrorRef.current === null ) { 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; } const advanced = Math.max(1, target - current); // Real-time pacing regardless of skip: skip controls density, // `speed` controls speed. Without the advanced-frame factor here, // skip=3 would play at 3x wall-clock, which is what the speed slider // is for. Compute after review-mode clamping so annotated stops do not // wait for the full skip window. const interval = (advanced * 1000) / (video.fps * speed); // Keep the fast playback loop's cursor in sync immediately. Waiting // for React's frameIdx effect can make a slow decode path replay the // same target once and add an extra frame interval per rendered frame. frameIdxRef.current = target; 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; } } }; loop(); return () => { cancelled = true; }; }, [playing, video, speed, reviewMode, pump]); // --- navigation helpers ---------------------------------------------- const seekToFrame = useCallback((idx: number) => { if (!video) return; const clamped = Math.max(0, Math.min(video.total_frames - 1, idx)); frameIdxRef.current = clamped; requestedIdx.current = clamped; const cached = getCachedBitmap(clamped); if (cached) { drawBitmap(cached, clamped); renderedIdx.current = clamped; } else { pump(); } setFrameIdx((prev) => (prev === clamped ? prev : clamped)); }, [drawBitmap, getCachedBitmap, pump, video]); const stepFrames = useCallback((delta: number) => { if (!video) return; setPlaying(false); seekToFrame(frameIdxRef.current + delta); }, [seekToFrame, video]); const togglePlay = useCallback(() => { if (!video) return; 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) => { setSpeed((s) => { const i = SPEEDS.indexOf(s); const next = Math.max(0, Math.min(SPEEDS.length - 1, (i < 0 ? 2 : i) + dir)); return SPEEDS[next]; }); }, []); // --- extraction helpers ---------------------------------------------- const applyOutputFolder = useCallback(async (path: string) => { setOutputFolder(path); try { const existing = await api.listExtractedFrames(path); setExtractedFrames(new Set(existing)); } catch (e) { // Folder may not exist yet — that's fine, we'll create it on extract. setExtractedFrames(new Set()); console.warn("listExtractedFrames failed (non-fatal):", e); } if (coco && coco.categories.length > 0) { try { await api.writeClassesTxt(path, coco.categories); } catch (e) { console.error(e); } } }, [coco]); async function pickOutputFolder() { const selected = await openDialog({ directory: true, multiple: false }); if (!selected || typeof selected !== "string") return; await applyOutputFolder(selected); } const extractCurrentFrame = useCallback(async () => { if (!outputFolder || !video) return; setPlaying(false); setBulkError(null); try { const shapes = frameMap.get(frameIdx) ?? []; await api.extractFrame(frameIdx, outputFolder, shapes, username); setExtractedFrames((prev) => new Set(prev).add(frameIdx)); } catch (e) { setBulkError(String(e)); } }, [outputFolder, video, frameIdx, frameMap, username]); const undoCurrentFrame = useCallback(async () => { if (!outputFolder) return; setPlaying(false); setBulkError(null); try { await api.undoExtract(frameIdx, outputFolder); setExtractedFrames((prev) => { const n = new Set(prev); n.delete(frameIdx); return n; }); } catch (e) { setBulkError(String(e)); } }, [outputFolder, frameIdx]); const setRangeStart = useCallback(() => setBulkStart(frameIdx), [frameIdx]); const setRangeEnd = useCallback(() => setBulkEnd(frameIdx), [frameIdx]); const clearRange = useCallback(() => { setBulkStart(null); setBulkEnd(null); }, []); const bulkRange = useMemo(() => { if (bulkStart == null || bulkEnd == null) return null; return { start: Math.min(bulkStart, bulkEnd), end: Math.max(bulkStart, bulkEnd), }; }, [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); 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 = {}; if (coco) { 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 { await api.bulkExtract( bulkRange.start, bulkRange.end, outputFolder, shapesByFrame, username, skip ); const existing = await api.listExtractedFrames(outputFolder); setExtractedFrames(new Set(existing)); } catch (e) { setBulkError(String(e)); } finally { unlisten(); setBulkProgress(null); } } async function runBulkUndo() { if (!outputFolder || !bulkRange) return; setPlaying(false); setBulkError(null); setBulkProgress({ done: 0, total: bulkRange.end - bulkRange.start + 1, phase: "undo" }); const unlisten = await listen<{ done: number; total: number }>( "bulk-undo-progress", (e) => setBulkProgress({ ...e.payload, phase: "undo" }) ); try { await api.bulkUndo(bulkRange.start, bulkRange.end, outputFolder); const existing = await api.listExtractedFrames(outputFolder); setExtractedFrames(new Set(existing)); } catch (e) { setBulkError(String(e)); } finally { unlisten(); setBulkProgress(null); } } const extractCurrentFrameRef = useRef(extractCurrentFrame); const undoCurrentFrameRef = useRef(undoCurrentFrame); const setRangeStartRef = useRef(setRangeStart); const setRangeEndRef = useRef(setRangeEnd); useEffect(() => { extractCurrentFrameRef.current = extractCurrentFrame; undoCurrentFrameRef.current = undoCurrentFrame; setRangeStartRef.current = setRangeStart; setRangeEndRef.current = setRangeEnd; }, [extractCurrentFrame, undoCurrentFrame, setRangeStart, setRangeEnd]); // --- keyboard -------------------------------------------------------- useEffect(() => { function onKey(e: KeyboardEvent) { 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; case "ArrowRight": e.preventDefault(); stepFrames(step); break; case "ArrowLeft": e.preventDefault(); stepFrames(-step); break; case "ArrowUp": e.preventDefault(); bumpSpeed(1); break; case "ArrowDown": e.preventDefault(); bumpSpeed(-1); break; case "h": case "H": e.preventDefault(); setOverlayVisible((v) => !v); break; case "e": case "E": e.preventDefault(); extractCurrentFrameRef.current(); break; case "u": case "U": e.preventDefault(); undoCurrentFrameRef.current(); break; case "[": e.preventDefault(); setRangeStartRef.current(); break; case "]": e.preventDefault(); setRangeEndRef.current(); break; } } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [togglePlay, stepFrames, bumpSpeed]); // --- video/coco open ------------------------------------------------- useEffect(() => { api.currentVideo().then((v) => { if (!v) return; setVideo(v); // Re-adopt the default folder on mount too, so switching modes and // coming back doesn't drop the extracted-frames overlay. applyOutputFolder(v.default_output_folder); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); async function chooseVideo() { setLoadError(null); const selected = await openDialog({ multiple: false, filters: [ { name: "Video", extensions: [ "mp4","MP4","mov","MOV","avi","AVI","mkv","MKV","webm","WEBM","flv","FLV","wmv","WMV", ], }, { name: "All files", extensions: ["*"] }, ], }); if (!selected || typeof selected !== "string") return; try { const info = await api.openVideo(selected); clearBitmapCache(); setVideo(info); setFrameIdx(0); 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. setBulkStart(null); setBulkEnd(null); await applyOutputFolder(info.default_output_folder); } catch (e) { setLoadError(String(e)); } } async function chooseCoco() { const selected = await openDialog({ multiple: false, filters: [{ name: "Annotations JSON", extensions: ["json"] }, { name: "All files", extensions: ["*"] }], }); if (!selected || typeof selected !== "string") return; try { const imported = await api.importCoco(selected); setCoco(imported); // If a folder is already picked, write (or refresh) classes.txt. if (outputFolder && imported.categories.length > 0) { try { await api.writeClassesTxt(outputFolder, imported.categories); } catch (e) { console.error(e); } } } catch (e) { setLoadError(String(e)); } } function clearCoco() { setCoco(null); } // --- render ---------------------------------------------------------- if (!video) { return (
{loadError &&

{loadError}

}

Supported: mp4, mov, avi, mkv, webm, flv, wmv. Decoded via ffmpeg.

); } const t = frameIdx / video.fps; const currentShapes = frameMap.get(frameIdx); const isCurrentExtracted = extractedFrames.has(frameIdx); const pct = bulkProgress ? Math.round((bulkProgress.done / bulkProgress.total) * 100) : 0; return (
{!infoCollapsed && ( <>
{video.path.split("/").pop()} {video.width}×{video.height} · {video.fps.toFixed(2)} fps · {video.total_frames} frames · {video.duration_s.toFixed(1)}s
{coco ? ( <> {coco.stats.source_format === "sam" ? "SAM" : "COCO"}:{" "} {coco.stats.total_annotations} annotations ·{" "} {coco.stats.frame_count} frames ·{" "} {coco.stats.categories} categories {coco.stats.skipped_rle > 0 && · {coco.stats.skipped_rle} RLE skipped} {coco.stats.skipped_no_image > 0 && · {coco.stats.skipped_no_image} unmappable} ) : ( <> no annotations loaded )}
)}
{outputFolder ? ( <> Output: {outputFolder.split("/").pop()} · {extractedFrames.size} extracted ) : ( )}
{(bulkStart != null || bulkEnd != null) && ( )}
{bulkProgress && (
{bulkProgress.phase === "extract" ? "extracting" : "undoing"} ·{" "} {bulkProgress.done} / {bulkProgress.total}
)} {bulkError &&

{bulkError}

}
seekToFrame(Number(e.target.value))} style={ { "--val": `${(frameIdx / Math.max(1, video.total_frames - 1)) * 100}%`, } as React.CSSProperties } /> {extractedFrames.size > 0 && ( )}
frame {frameIdx} / {video.total_frames - 1} · {formatTime(t)} {currentShapes && currentShapes.length > 0 && ( {currentShapes.length} annotation{currentShapes.length === 1 ? "" : "s"} )} {isCurrentExtracted && ✓ extracted}
{speed}×
space · ← → · ctrl+← → · ↑ ↓ · H · E · U · [ ]
{playbackError && (

Playback stopped: {playbackError} {" "}

)}
); } function formatTime(s: number) { const m = Math.floor(s / 60); const r = s - m * 60; return `${m}:${r.toFixed(2).padStart(5, "0")}`; } /** * Canvas overlay that draws one green tick per pixel-column of the timeline * track where at least one extracted frame falls. Canvas avoids the DOM * blow-up of 1-div-per-frame on long videos, and collapses dense neighboring * frames into a single visible stripe at narrow timeline widths. */ function ExtractedTicks({ extracted, totalFrames, }: { extracted: Set; totalFrames: number; }) { const ref = useRef(null); useEffect(() => { const canvas = ref.current; if (!canvas) return; let raf = 0; const draw = () => { raf = 0; const rect = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; const w = Math.max(1, Math.floor(rect.width * dpr)); const h = Math.max(1, Math.floor(rect.height * dpr)); if (canvas.width !== w) canvas.width = w; if (canvas.height !== h) canvas.height = h; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.clearRect(0, 0, w, h); if (extracted.size === 0 || totalFrames <= 0) return; ctx.fillStyle = "rgba(92, 207, 117, 0.85)"; const max = Math.max(1, totalFrames - 1); for (const f of extracted) { const x = Math.round((f / max) * (w - 1)); ctx.fillRect(x, 0, Math.max(1, Math.floor(dpr)), h); } }; const schedule = () => { if (raf) return; raf = requestAnimationFrame(draw); }; schedule(); const ro = new ResizeObserver(schedule); ro.observe(canvas); return () => { if (raf) cancelAnimationFrame(raf); ro.disconnect(); }; }, [extracted, totalFrames]); return