first version
This commit is contained in:
648
sam-tool-tauri/src/modes/ExtractMode.tsx
Normal file
648
sam-tool-tauri/src/modes/ExtractMode.tsx
Normal file
@@ -0,0 +1,648 @@
|
||||
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 { FrameCanvas } from "../components/extract/FrameCanvas";
|
||||
import { drawOverlay } from "../components/extract/overlay";
|
||||
|
||||
const SPEEDS = [0.25, 0.5, 1, 1.5, 2, 3, 4];
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface BulkProgress {
|
||||
done: number;
|
||||
total: number;
|
||||
phase: "extract" | "undo";
|
||||
}
|
||||
|
||||
export function ExtractMode({ username }: Props) {
|
||||
const [video, setVideo] = useState<VideoInfo | null>(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<string | null>(null);
|
||||
|
||||
const [coco, setCoco] = useState<ImportedCoco | null>(null);
|
||||
|
||||
// Extraction state
|
||||
const [outputFolder, setOutputFolder] = useState<string | null>(null);
|
||||
const [extractedFrames, setExtractedFrames] = useState<Set<number>>(new Set());
|
||||
const [bulkStart, setBulkStart] = useState<number | null>(null);
|
||||
const [bulkEnd, setBulkEnd] = useState<number | null>(null);
|
||||
const [bulkProgress, setBulkProgress] = useState<BulkProgress | null>(null);
|
||||
const [bulkError, setBulkError] = useState<string | null>(null);
|
||||
const [infoCollapsed, setInfoCollapsed] = useState(false);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const frameMap = useMemo(() => {
|
||||
const m = new Map<number, CocoShape[]>();
|
||||
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<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(() => {
|
||||
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 renderFrame = useCallback(
|
||||
async (bytes: ArrayBuffer, atFrameIdx: number) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const blob = new Blob([bytes], { type: "image/jpeg" });
|
||||
let bitmap: ImageBitmap;
|
||||
try { bitmap = await createImageBitmap(blob); }
|
||||
catch (e) { console.error("createImageBitmap failed", e); return; }
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) { bitmap.close(); return; }
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
bitmap.close();
|
||||
|
||||
if (overlayVisibleRef.current) {
|
||||
const shapes = frameMapRef.current.get(atFrameIdx);
|
||||
if (shapes && shapes.length > 0) {
|
||||
drawOverlay(ctx, shapes, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const pump = useCallback(async () => {
|
||||
if (decodingInFlight.current) return;
|
||||
decodingInFlight.current = true;
|
||||
try {
|
||||
while (requestedIdx.current !== renderedIdx.current) {
|
||||
const want = requestedIdx.current;
|
||||
try {
|
||||
const bytes = await api.decodeFrame(want);
|
||||
if (requestedIdx.current !== want) continue;
|
||||
await renderFrame(bytes, want);
|
||||
renderedIdx.current = want;
|
||||
} catch (e) {
|
||||
if (requestedIdx.current !== want) continue;
|
||||
console.error("decode_frame failed", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
decodingInFlight.current = false;
|
||||
}
|
||||
}, [renderFrame]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!video) return;
|
||||
requestedIdx.current = frameIdx;
|
||||
pump();
|
||||
}, [frameIdx, pump, video]);
|
||||
|
||||
useEffect(() => {
|
||||
renderedIdx.current = -1;
|
||||
pump();
|
||||
}, [overlayVisible, frameMap, pump]);
|
||||
|
||||
// --- playback loop ---------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (!playing || !video) return;
|
||||
playAnchorRef.current = {
|
||||
time: performance.now(),
|
||||
idx: frameIdxRef.current,
|
||||
};
|
||||
|
||||
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)
|
||||
);
|
||||
if (reviewMode && annotatedFrames.current.has(want)) {
|
||||
setFrameIdx(want);
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
setFrameIdx(want);
|
||||
if (want >= video.total_frames - 1) { setPlaying(false); return; }
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
playAnchorRef.current = null;
|
||||
};
|
||||
}, [playing, video, speed, reviewMode]);
|
||||
|
||||
// --- navigation helpers ----------------------------------------------
|
||||
const seekToFrame = useCallback((idx: number) => {
|
||||
if (!video) return;
|
||||
const clamped = Math.max(0, Math.min(video.total_frames - 1, idx));
|
||||
setFrameIdx(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 };
|
||||
}
|
||||
}, [video]);
|
||||
|
||||
const stepFrames = useCallback((delta: number) => {
|
||||
if (!video) return;
|
||||
setPlaying(false);
|
||||
setFrameIdx((i) => Math.max(0, Math.min(video.total_frames - 1, i + delta)));
|
||||
}, [video]);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (!video) return;
|
||||
setPlaying((p) => !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;
|
||||
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;
|
||||
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]);
|
||||
|
||||
async function runBulkExtract() {
|
||||
if (!outputFolder || !bulkRange) return;
|
||||
setBulkError(null);
|
||||
setBulkProgress({ done: 0, total: bulkRange.end - bulkRange.start + 1, phase: "extract" });
|
||||
const unlisten = await listen<{ done: number; total: number }>(
|
||||
"bulk-extract-progress",
|
||||
(e) => setBulkProgress({ ...e.payload, phase: "extract" })
|
||||
);
|
||||
try {
|
||||
await api.bulkExtract(
|
||||
bulkRange.start,
|
||||
bulkRange.end,
|
||||
outputFolder,
|
||||
coco?.frames ?? {},
|
||||
username
|
||||
);
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
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(); extractCurrentFrame(); break;
|
||||
case "u": case "U":
|
||||
e.preventDefault(); undoCurrentFrame(); break;
|
||||
case "[":
|
||||
e.preventDefault(); setRangeStart(); break;
|
||||
case "]":
|
||||
e.preventDefault(); setRangeEnd(); break;
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [togglePlay, stepFrames, bumpSpeed, extractCurrentFrame, undoCurrentFrame, setRangeStart, setRangeEnd]);
|
||||
|
||||
// --- 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);
|
||||
setVideo(info);
|
||||
setFrameIdx(0);
|
||||
setPlaying(false);
|
||||
setSpeed(1);
|
||||
renderedIdx.current = -1;
|
||||
// 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 (
|
||||
<div className="upload-pane">
|
||||
<button className="primary" onClick={chooseVideo}>Upload Video</button>
|
||||
{loadError && <p className="error">{loadError}</p>}
|
||||
<p className="dim">
|
||||
Supported: mp4, mov, avi, mkv, webm, flv, wmv. Decoded via <code>ffmpeg</code>.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="extract-mode">
|
||||
{!infoCollapsed && (
|
||||
<>
|
||||
<div className="video-info">
|
||||
<span className="path" title={video.path}>{video.path.split("/").pop()}</span>
|
||||
<span className="dim">
|
||||
{video.width}×{video.height} · {video.fps.toFixed(2)} fps · {video.total_frames} frames · {video.duration_s.toFixed(1)}s
|
||||
</span>
|
||||
<button className="link" onClick={chooseVideo}>change…</button>
|
||||
</div>
|
||||
|
||||
<div className="coco-info">
|
||||
{coco ? (
|
||||
<>
|
||||
<span>
|
||||
<span className="dim">{coco.stats.source_format === "sam" ? "SAM" : "COCO"}:</span>{" "}
|
||||
<strong>{coco.stats.total_annotations}</strong> annotations ·{" "}
|
||||
<strong>{coco.stats.frame_count}</strong> frames ·{" "}
|
||||
<strong>{coco.stats.categories}</strong> categories
|
||||
{coco.stats.skipped_rle > 0 && <span className="dim"> · {coco.stats.skipped_rle} RLE skipped</span>}
|
||||
{coco.stats.skipped_no_image > 0 && <span className="dim"> · {coco.stats.skipped_no_image} unmappable</span>}
|
||||
</span>
|
||||
<button className="link" onClick={chooseCoco}>replace…</button>
|
||||
<button className="link" onClick={clearCoco}>clear</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="dim">no annotations loaded</span>
|
||||
<button className="link" onClick={chooseCoco}>import annotations…</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={`extract-info${infoCollapsed ? " collapsed" : ""}`}>
|
||||
{outputFolder ? (
|
||||
<>
|
||||
<span>Output: <strong title={outputFolder}>{outputFolder.split("/").pop()}</strong></span>
|
||||
<span className="dim"> · {extractedFrames.size} extracted</span>
|
||||
<button className="link" onClick={pickOutputFolder}>change…</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="link" onClick={pickOutputFolder}>choose output folder…</button>
|
||||
)}
|
||||
<div className="extract-actions">
|
||||
<button
|
||||
className={isCurrentExtracted ? "extracted" : "primary-ghost"}
|
||||
onClick={extractCurrentFrame}
|
||||
disabled={!outputFolder}
|
||||
title="E"
|
||||
>
|
||||
{isCurrentExtracted ? "✓ re-extract (E)" : "Extract (E)"}
|
||||
</button>
|
||||
<button
|
||||
onClick={undoCurrentFrame}
|
||||
disabled={!outputFolder || !isCurrentExtracted}
|
||||
title="U"
|
||||
>
|
||||
Undo (U)
|
||||
</button>
|
||||
<div className="range-picker">
|
||||
<button onClick={setRangeStart} title="[">
|
||||
Start [ {bulkStart ?? "–"}
|
||||
</button>
|
||||
<button onClick={setRangeEnd} title="]">
|
||||
End ] {bulkEnd ?? "–"}
|
||||
</button>
|
||||
{(bulkStart != null || bulkEnd != null) && (
|
||||
<button className="link" onClick={clearRange}>clear</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={runBulkExtract}
|
||||
disabled={!outputFolder || !bulkRange || !!bulkProgress}
|
||||
>
|
||||
Bulk Extract{bulkRange && ` (${bulkRange.end - bulkRange.start + 1})`}
|
||||
</button>
|
||||
<button
|
||||
onClick={runBulkUndo}
|
||||
disabled={!outputFolder || !bulkRange || !!bulkProgress}
|
||||
>
|
||||
Bulk Undo
|
||||
</button>
|
||||
<button
|
||||
className="collapse-toggle"
|
||||
onClick={() => setInfoCollapsed((v) => !v)}
|
||||
title={infoCollapsed ? "show info" : "hide info"}
|
||||
>
|
||||
{infoCollapsed ? "v" : "^"}
|
||||
</button>
|
||||
</div>
|
||||
{bulkProgress && (
|
||||
<div className="progress">
|
||||
<div className="progress-bar" style={{ width: `${pct}%` }} />
|
||||
<span>
|
||||
{bulkProgress.phase === "extract" ? "extracting" : "undoing"} ·{" "}
|
||||
{bulkProgress.done} / {bulkProgress.total}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{bulkError && <p className="error">{bulkError}</p>}
|
||||
</div>
|
||||
|
||||
<FrameCanvas ref={canvasRef} video={video} extracted={isCurrentExtracted} />
|
||||
|
||||
<div className="timeline">
|
||||
<div className="timeline-track-wrap">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={video.total_frames - 1}
|
||||
value={frameIdx}
|
||||
onChange={(e) => seekToFrame(Number(e.target.value))}
|
||||
style={
|
||||
{
|
||||
"--val": `${(frameIdx / Math.max(1, video.total_frames - 1)) * 100}%`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
{extractedFrames.size > 0 && (
|
||||
<ExtractedTicks
|
||||
extracted={extractedFrames}
|
||||
totalFrames={video.total_frames}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="timeline-readout">
|
||||
frame <strong>{frameIdx}</strong> / {video.total_frames - 1}
|
||||
<span className="dim"> · {formatTime(t)}</span>
|
||||
{currentShapes && currentShapes.length > 0 && (
|
||||
<span className="badge">
|
||||
{currentShapes.length} annotation{currentShapes.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
{isCurrentExtracted && <span className="badge extracted">✓ extracted</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="controls">
|
||||
<button className={playing ? "active" : ""} onClick={togglePlay}>
|
||||
{playing ? "❚❚ pause" : "▶ play"}
|
||||
</button>
|
||||
<button onClick={() => stepFrames(-1)} title="←">◀ 1</button>
|
||||
<button onClick={() => stepFrames(1)} title="→">1 ▶</button>
|
||||
<button onClick={() => stepFrames(-10)} title="Ctrl+←">◀◀ 10</button>
|
||||
<button onClick={() => stepFrames(10)} title="Ctrl+→">10 ▶▶</button>
|
||||
<div className="speed">
|
||||
<button onClick={() => bumpSpeed(-1)} title="↓">–</button>
|
||||
<span className="speed-readout">{speed}×</span>
|
||||
<button onClick={() => bumpSpeed(1)} title="↑">+</button>
|
||||
</div>
|
||||
<label className={`review-toggle ${reviewMode ? "on" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reviewMode}
|
||||
onChange={(e) => setReviewMode(e.target.checked)}
|
||||
/>
|
||||
Review mode
|
||||
</label>
|
||||
<button
|
||||
className={`overlay-toggle ${overlayVisible ? "on" : ""}`}
|
||||
onClick={() => setOverlayVisible((v) => !v)}
|
||||
title="H"
|
||||
>
|
||||
{overlayVisible ? "👁 overlay on" : "◌ overlay off"}
|
||||
</button>
|
||||
<span className="dim shortcuts">space · ← → · ctrl+← → · ↑ ↓ · H · E · U · [ ]</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<number>;
|
||||
totalFrames: number;
|
||||
}) {
|
||||
const ref = useRef<HTMLCanvasElement>(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 <canvas ref={ref} className="timeline-ticks" aria-hidden="true" />;
|
||||
}
|
||||
Reference in New Issue
Block a user