first version
This commit is contained in:
104
sam-tool-tauri/src/App.tsx
Normal file
104
sam-tool-tauri/src/App.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Mode, Settings } from "./types";
|
||||
import { api } from "./ipc";
|
||||
import { Login } from "./components/auth/Login";
|
||||
import { TopNav } from "./components/shell/TopNav";
|
||||
import { ExtractMode } from "./modes/ExtractMode";
|
||||
import { AnnotateMode } from "./modes/AnnotateMode";
|
||||
import { ErrorBoundary } from "./components/shell/ErrorBoundary";
|
||||
import { SettingsModal } from "./components/shell/SettingsModal";
|
||||
|
||||
export default function App() {
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [mode, setMode] = useState<Mode>("extract");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [fullview, setFullview] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.currentUser().then((u) => {
|
||||
setUsername(u);
|
||||
setChecking(false);
|
||||
});
|
||||
api.getSettings().then(setSettings).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onError(e: ErrorEvent) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[uncaught error]", e.message, e.error);
|
||||
}
|
||||
function onRejection(e: PromiseRejectionEvent) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[unhandled rejection]", e.reason);
|
||||
}
|
||||
window.addEventListener("error", onError);
|
||||
window.addEventListener("unhandledrejection", onRejection);
|
||||
return () => {
|
||||
window.removeEventListener("error", onError);
|
||||
window.removeEventListener("unhandledrejection", onRejection);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "F11") {
|
||||
e.preventDefault();
|
||||
setFullview((v) => !v);
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
if (checking) {
|
||||
return <div className="boot">…</div>;
|
||||
}
|
||||
|
||||
if (!username) {
|
||||
return <Login onLogin={setUsername} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`app${fullview ? " fullview" : ""}`}>
|
||||
{!fullview && (
|
||||
<TopNav
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
username={username}
|
||||
onLogout={() => setUsername(null)}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onToggleFullview={() => setFullview(true)}
|
||||
/>
|
||||
)}
|
||||
<main>
|
||||
{mode === "extract" && (
|
||||
<ErrorBoundary label="Extract mode">
|
||||
<ExtractMode username={username} />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
{mode === "annotate" && (
|
||||
<ErrorBoundary label="Annotate mode">
|
||||
<AnnotateMode username={username} settings={settings} />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</main>
|
||||
{fullview && (
|
||||
<button
|
||||
className="exit-fullview"
|
||||
onClick={() => setFullview(false)}
|
||||
title="Exit fullview (F11)"
|
||||
aria-label="Exit fullview"
|
||||
>
|
||||
⛶
|
||||
</button>
|
||||
)}
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onSaved={setSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
sam-tool-tauri/src/assets/seek-logo.svg
Normal file
20
sam-tool-tauri/src/assets/seek-logo.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none" aria-label="SeekRight">
|
||||
<defs>
|
||||
<linearGradient id="srBg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FFD657"/>
|
||||
<stop offset="55%" stop-color="#F5C230"/>
|
||||
<stop offset="100%" stop-color="#E5A800"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="srGloss" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.35"/>
|
||||
<stop offset="45%" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="6" y="6" width="116" height="116" rx="28" fill="url(#srBg)"/>
|
||||
<rect x="6" y="6" width="116" height="62" rx="28" fill="url(#srGloss)"/>
|
||||
<rect x="6.5" y="6.5" width="115" height="115" rx="27.5" fill="none"
|
||||
stroke="#C08A00" stroke-opacity="0.35" stroke-width="1"/>
|
||||
<text x="22" y="92"
|
||||
font-family="Inter, system-ui, -apple-system, 'Segoe UI', sans-serif"
|
||||
font-weight="900" font-size="64" fill="#1F3488" letter-spacing="-3">SR</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
12
sam-tool-tauri/src/assets/seek-mark.svg
Normal file
12
sam-tool-tauri/src/assets/seek-mark.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" aria-label="Seek">
|
||||
<defs>
|
||||
<linearGradient id="mark" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#F5C838"/>
|
||||
<stop offset="100%" stop-color="#ECBA20"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M 6 6 L 46 6 Q 54 6 56 14 L 60 54 Q 61 60 54 60 L 6 60 Q 2 60 2 54 L 2 12 Q 2 6 6 6 Z"
|
||||
fill="url(#mark)"/>
|
||||
<text x="10" y="48" font-family="Inter, system-ui, -apple-system, 'Segoe UI', sans-serif"
|
||||
font-weight="900" font-size="44" fill="#1F3488" letter-spacing="-2">S</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 597 B |
98
sam-tool-tauri/src/components/annotate/ClassList.tsx
Normal file
98
sam-tool-tauri/src/components/annotate/ClassList.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useState } from "react";
|
||||
import { colorForClass } from "../extract/overlay";
|
||||
|
||||
interface Props {
|
||||
classes: string[];
|
||||
activeClassId: number | null;
|
||||
onSelect: (id: number) => void;
|
||||
onAdd?: (name: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function ClassList({ classes, activeClassId, onSelect, onAdd }: Props) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit() {
|
||||
if (!onAdd) return;
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onAdd(trimmed);
|
||||
setName("");
|
||||
setAdding(false);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="class-list-wrap">
|
||||
{classes.length === 0 ? (
|
||||
<div className="class-list empty">
|
||||
no <code>classes.txt</code> in folder
|
||||
</div>
|
||||
) : (
|
||||
<ul className="class-list">
|
||||
{classes.map((name, id) => (
|
||||
<li
|
||||
key={`${id}-${name}`}
|
||||
className={id === activeClassId ? "active" : ""}
|
||||
onClick={() => onSelect(id)}
|
||||
title={`Activate "${name}"`}
|
||||
>
|
||||
<span
|
||||
className="swatch"
|
||||
style={{ background: colorForClass(id) }}
|
||||
/>
|
||||
<span className="name">{name}</span>
|
||||
<span className="id">{id}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{onAdd && (
|
||||
<div className="class-add">
|
||||
{adding ? (
|
||||
<div className="class-add-row">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={name}
|
||||
maxLength={96}
|
||||
placeholder="class name"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); submit(); }
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setAdding(false); setName(""); setError(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button disabled={busy || !name.trim()} onClick={submit}>add</button>
|
||||
<button
|
||||
className="link"
|
||||
disabled={busy}
|
||||
onClick={() => { setAdding(false); setName(""); setError(null); }}
|
||||
>
|
||||
cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="link add-class-trigger" onClick={() => setAdding(true)}>
|
||||
+ add class
|
||||
</button>
|
||||
)}
|
||||
{error && <div className="class-add-error">{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
count: number;
|
||||
sampleNames: string[];
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-step delete confirmation. Step 1 asks plainly, step 2 warns that the
|
||||
* action is irreversible. The user has to click twice, so accidental Enter
|
||||
* presses can't nuke a folder.
|
||||
*/
|
||||
export function DeleteConfirmModal({
|
||||
count,
|
||||
sampleNames,
|
||||
busy,
|
||||
error,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, [onCancel]);
|
||||
|
||||
const preview = sampleNames.slice(0, 6).join(", ");
|
||||
const more = sampleNames.length > 6 ? ` +${sampleNames.length - 6} more` : "";
|
||||
|
||||
return (
|
||||
<div className="label-picker-backdrop" onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget && !busy) onCancel();
|
||||
}}>
|
||||
<div className="delete-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="delete-modal-header">
|
||||
{step === 1 ? "Delete images?" : "This cannot be undone"}
|
||||
</div>
|
||||
<div className="delete-modal-body">
|
||||
{step === 1 ? (
|
||||
<>
|
||||
<p>
|
||||
You are about to delete <strong>{count}</strong>{" "}
|
||||
image{count === 1 ? "" : "s"}
|
||||
{" "}and any sibling <code>.json</code> annotations.
|
||||
</p>
|
||||
{preview && (
|
||||
<p className="dim delete-preview">{preview}{more}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
<strong>{count}</strong> image{count === 1 ? "" : "s"} will be
|
||||
permanently removed from disk. There is no undo.
|
||||
</p>
|
||||
<p className="dim">
|
||||
Click <strong>Delete permanently</strong> to proceed, or{" "}
|
||||
<strong>Cancel</strong> to back out.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{error && <p className="error">{error}</p>}
|
||||
</div>
|
||||
<div className="delete-modal-actions">
|
||||
<button onClick={onCancel} disabled={busy}>Cancel</button>
|
||||
{step === 1 ? (
|
||||
<button
|
||||
className="danger-ghost"
|
||||
onClick={() => setStep(2)}
|
||||
disabled={busy}
|
||||
autoFocus
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="danger"
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? "Deleting…" : "Delete permanently"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
sam-tool-tauri/src/components/annotate/ImageList.tsx
Normal file
68
sam-tool-tauri/src/components/annotate/ImageList.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ImageEntry } from "../../types";
|
||||
|
||||
interface Props {
|
||||
images: ImageEntry[];
|
||||
selectedIdx: number | null;
|
||||
selectedSet: Set<number>;
|
||||
onSelect: (idx: number, e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function timeAgo(iso: string | null | undefined): string {
|
||||
if (!iso) return "";
|
||||
const t = new Date(iso).getTime();
|
||||
if (!Number.isFinite(t)) return "";
|
||||
const diff = Math.max(0, Date.now() - t);
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 5) return "now";
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 30) return `${d}d`;
|
||||
const mo = Math.floor(d / 30);
|
||||
return `${mo}mo`;
|
||||
}
|
||||
|
||||
export function ImageList({ images, selectedIdx, selectedSet, onSelect }: Props) {
|
||||
if (images.length === 0) {
|
||||
return <div className="image-list empty">no images in folder</div>;
|
||||
}
|
||||
return (
|
||||
<ul className="image-list">
|
||||
{images.map((img, i) => {
|
||||
const isPrimary = i === selectedIdx;
|
||||
const isInSet = selectedSet.has(i);
|
||||
const cls = [
|
||||
isPrimary ? "selected" : "",
|
||||
isInSet && !isPrimary ? "multi-selected" : "",
|
||||
img.has_annotations ? "annotated" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<li
|
||||
key={img.filename}
|
||||
className={cls}
|
||||
onClick={(e) => onSelect(i, e)}
|
||||
title={img.filename}
|
||||
>
|
||||
<span className="marker">{img.has_annotations ? "✓" : "·"}</span>
|
||||
<span className="name">{img.filename}</span>
|
||||
{img.annotation_count > 0 && (
|
||||
<span className="count" title={`${img.annotation_count} annotation${img.annotation_count === 1 ? "" : "s"}`}>
|
||||
{img.annotation_count}
|
||||
</span>
|
||||
)}
|
||||
{img.last_updated && (
|
||||
<span className="updated dim" title={img.last_updated}>
|
||||
{timeAgo(img.last_updated)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
277
sam-tool-tauri/src/components/annotate/LabelPicker.tsx
Normal file
277
sam-tool-tauri/src/components/annotate/LabelPicker.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { colorForClass } from "../extract/overlay";
|
||||
|
||||
interface Props {
|
||||
classes: string[];
|
||||
/** Pre-selected class id (last-picked) to highlight when the picker opens. */
|
||||
defaultClassId: number | null;
|
||||
/** Tag shown at the top of the panel — "new bbox", "new polygon", "reclass". */
|
||||
mode: "new-bbox" | "new-polygon" | "reclass";
|
||||
onConfirm: (classId: number) => void;
|
||||
onCancel: () => void;
|
||||
/** When present, shows "+ add '<query>'" below the list when the query has
|
||||
* no matches. Must resolve with the id of the newly created class. */
|
||||
onAdd?: (name: string) => Promise<number>;
|
||||
}
|
||||
|
||||
function initials(name: string): string {
|
||||
return name
|
||||
.split(/[_\s-]+/)
|
||||
.filter(Boolean)
|
||||
.map((p) => p[0]!.toUpperCase())
|
||||
.join("");
|
||||
}
|
||||
|
||||
function scoreMatch(query: string, name: string): number {
|
||||
if (!query) return 1;
|
||||
const qu = query.toUpperCase();
|
||||
const ql = query.toLowerCase();
|
||||
const init = initials(name);
|
||||
const lo = name.toLowerCase();
|
||||
if (lo === ql) return 105;
|
||||
if (init === qu) return 100;
|
||||
if (init.startsWith(qu)) return 90;
|
||||
if (init.includes(qu)) return 75;
|
||||
if (lo.startsWith(ql)) return 70;
|
||||
if (lo.includes(ql)) return 50;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function LabelPicker({
|
||||
classes,
|
||||
defaultClassId,
|
||||
mode,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onAdd,
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [busyAdding, setBusyAdding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [highlightIdx, setHighlightIdx] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const out = classes
|
||||
.map((name, id) => ({ name, id, score: scoreMatch(query, name) }))
|
||||
.filter((x) => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score || a.id - b.id);
|
||||
return out;
|
||||
}, [classes, query]);
|
||||
|
||||
// Reset highlight when list shape changes; prefer the default class if
|
||||
// present in the current filtered set.
|
||||
useEffect(() => {
|
||||
if (query.trim() === "") {
|
||||
const pos =
|
||||
defaultClassId != null
|
||||
? filtered.findIndex((f) => f.id === defaultClassId)
|
||||
: 0;
|
||||
setHighlightIdx(pos >= 0 ? pos : 0);
|
||||
} else {
|
||||
setHighlightIdx(0);
|
||||
}
|
||||
}, [query, filtered, defaultClassId]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.querySelector<HTMLElement>("li.highlight");
|
||||
el?.scrollIntoView({ block: "nearest" });
|
||||
}, [highlightIdx]);
|
||||
|
||||
// Force focus on mount — `autoFocus` alone has been unreliable here because
|
||||
// the picker mounts on a mouseup event whose focus target (document.body)
|
||||
// sometimes wins the race. Keeping focus on the input is what makes the
|
||||
// "start typing immediately" UX work.
|
||||
useEffect(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.select();
|
||||
}, []);
|
||||
|
||||
// Capture all keydowns at the window level while the picker is open. This
|
||||
// routes key events to the picker even if focus escapes (e.g. the user
|
||||
// clicked the backdrop). Esc closes, printable keys feed the input,
|
||||
// navigation/confirm keys run through `onKey`.
|
||||
useEffect(() => {
|
||||
function onWin(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
// Prevent the parent (AnnotateMode) window handler from hijacking A/D,
|
||||
// space, etc. while the picker is open.
|
||||
if (document.activeElement === el) {
|
||||
// Still guard keys the parent would act on — A/D navigation, etc.
|
||||
if (
|
||||
e.key.length === 1 &&
|
||||
!e.ctrlKey && !e.metaKey && !e.altKey
|
||||
) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Input not focused: pull focus back. For printable chars, also append
|
||||
// to the query so the first keystroke isn't lost.
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setQuery((q) => q + e.key);
|
||||
el.focus();
|
||||
} else if (
|
||||
e.key === "Enter" ||
|
||||
e.key === "ArrowUp" ||
|
||||
e.key === "ArrowDown" ||
|
||||
e.key === "Tab" ||
|
||||
e.key === "Backspace"
|
||||
) {
|
||||
e.stopPropagation();
|
||||
el.focus();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onWin, true);
|
||||
return () => window.removeEventListener("keydown", onWin, true);
|
||||
}, [onCancel]);
|
||||
|
||||
async function confirmCurrent() {
|
||||
if (filtered.length > 0) {
|
||||
onConfirm(filtered[highlightIdx]?.id ?? filtered[0].id);
|
||||
return;
|
||||
}
|
||||
// No matches — offer to add a new class from the query.
|
||||
if (onAdd && query.trim()) {
|
||||
await addFromQuery();
|
||||
}
|
||||
}
|
||||
|
||||
async function addFromQuery() {
|
||||
if (!onAdd) return;
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
// If query exactly matches an existing class, select it instead.
|
||||
const exact = classes.findIndex(
|
||||
(c) => c.toLowerCase() === q.toLowerCase()
|
||||
);
|
||||
if (exact >= 0) {
|
||||
onConfirm(exact);
|
||||
return;
|
||||
}
|
||||
setBusyAdding(true);
|
||||
setError(null);
|
||||
try {
|
||||
const newId = await onAdd(q);
|
||||
onConfirm(newId);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setBusyAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onKey(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
confirmCurrent();
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setHighlightIdx((i) => Math.min(filtered.length - 1, i + 1));
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setHighlightIdx((i) => Math.max(0, i - 1));
|
||||
break;
|
||||
case "Tab":
|
||||
// Tab moves highlight forward (like autocomplete)
|
||||
e.preventDefault();
|
||||
setHighlightIdx((i) => (filtered.length > 0 ? (i + 1) % filtered.length : 0));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const title =
|
||||
mode === "new-bbox"
|
||||
? "label bbox"
|
||||
: mode === "new-polygon"
|
||||
? "label polygon"
|
||||
: "change class";
|
||||
|
||||
return (
|
||||
<div className="label-picker-backdrop" onMouseDown={(e) => {
|
||||
// Click on backdrop (not modal) cancels.
|
||||
if (e.target === e.currentTarget) onCancel();
|
||||
}}>
|
||||
<div className="label-picker" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="label-picker-header">
|
||||
<span className="mode-tag">{title}</span>
|
||||
<span className="dim">{classes.length} classes</span>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="type letters — e.g. SL for Street_Light or 'st' for substring"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
/>
|
||||
<ul ref={listRef} className="label-picker-list">
|
||||
{filtered.map((x, i) => (
|
||||
<li
|
||||
key={`${x.id}-${x.name}`}
|
||||
className={i === highlightIdx ? "highlight" : ""}
|
||||
onMouseEnter={() => setHighlightIdx(i)}
|
||||
onClick={() => onConfirm(x.id)}
|
||||
>
|
||||
<span className="swatch" style={{ background: colorForClass(x.id) }} />
|
||||
<span className="name">{x.name}</span>
|
||||
<span className="init">{initials(x.name)}</span>
|
||||
<span className="id">{x.id}</span>
|
||||
</li>
|
||||
))}
|
||||
{filtered.length === 0 && !query.trim() && (
|
||||
<li className="empty">
|
||||
<span className="dim">type to filter, or add new below</span>
|
||||
</li>
|
||||
)}
|
||||
{filtered.length === 0 && query.trim() && !onAdd && (
|
||||
<li className="empty"><span className="dim">no matches</span></li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{onAdd && (
|
||||
<div className="label-picker-add-row">
|
||||
<button
|
||||
className="add-inline"
|
||||
onClick={addFromQuery}
|
||||
disabled={busyAdding || !query.trim()}
|
||||
title={
|
||||
query.trim()
|
||||
? `Create "${query.trim()}" as a new class`
|
||||
: "Type a name first, then click to add"
|
||||
}
|
||||
>
|
||||
{query.trim()
|
||||
? `+ add "${query.trim()}" as new class`
|
||||
: "+ add new class (type name above)"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="picker-error">{error}</p>}
|
||||
<div className="label-picker-hint">
|
||||
<kbd>↑↓</kbd> nav · <kbd>Enter</kbd> pick · <kbd>Tab</kbd> next match ·{" "}
|
||||
<kbd>Esc</kbd> cancel
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
sam-tool-tauri/src/components/auth/Login.tsx
Normal file
106
sam-tool-tauri/src/components/auth/Login.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../ipc";
|
||||
import seekLogo from "../../assets/seek-logo.svg";
|
||||
|
||||
interface Props {
|
||||
onLogin: (username: string) => void;
|
||||
}
|
||||
|
||||
export function Login({ onLogin }: Props) {
|
||||
const [known, setKnown] = useState<string[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [mode, setMode] = useState<"pick" | "new">("new");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.listKnownUsers().then((users) => {
|
||||
setKnown(users);
|
||||
if (users.length > 0) setMode("pick");
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function submit(username: string) {
|
||||
const trimmed = username.trim();
|
||||
if (!trimmed) {
|
||||
setError("Please enter a name");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const confirmed = await api.setCurrentUser(trimmed);
|
||||
onLogin(confirmed);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<img className="login-logo" src={seekLogo} alt="Seek" />
|
||||
<p className="subtitle">Who's annotating?</p>
|
||||
|
||||
{mode === "pick" && known.length > 0 && (
|
||||
<div className="known-users">
|
||||
{known.map((u) => (
|
||||
<button
|
||||
key={u}
|
||||
className="known-user"
|
||||
onClick={() => submit(u)}
|
||||
disabled={busy}
|
||||
>
|
||||
{u}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="link"
|
||||
onClick={() => {
|
||||
setMode("new");
|
||||
setName("");
|
||||
}}
|
||||
>
|
||||
+ new user
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "new" && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit(name);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="your name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={64}
|
||||
/>
|
||||
<div className="actions">
|
||||
{known.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="link"
|
||||
onClick={() => setMode("pick")}
|
||||
>
|
||||
← back
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" disabled={busy || !name.trim()}>
|
||||
continue
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
sam-tool-tauri/src/components/extract/FrameCanvas.tsx
Normal file
26
sam-tool-tauri/src/components/extract/FrameCanvas.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { forwardRef } from "react";
|
||||
import type { VideoInfo } from "../../types";
|
||||
|
||||
interface Props {
|
||||
video: VideoInfo;
|
||||
extracted?: boolean;
|
||||
}
|
||||
|
||||
export const FrameCanvas = forwardRef<HTMLCanvasElement, Props>(function FrameCanvas(
|
||||
{ video, extracted },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div className="canvas-stage">
|
||||
<div
|
||||
className="canvas-frame"
|
||||
style={{ aspectRatio: `${video.width} / ${video.height}` }}
|
||||
>
|
||||
<canvas ref={ref} width={video.width} height={video.height} />
|
||||
{extracted && (
|
||||
<div className="frame-extracted-banner">EXTRACTED</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
149
sam-tool-tauri/src/components/extract/overlay.ts
Normal file
149
sam-tool-tauri/src/components/extract/overlay.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { CocoShape } from "../../types";
|
||||
|
||||
const CLASS_COLORS = [
|
||||
"#ff6b6b",
|
||||
"#4ecdc4",
|
||||
"#ffd166",
|
||||
"#a78bfa",
|
||||
"#67e8f9",
|
||||
"#fbbf24",
|
||||
"#86efac",
|
||||
"#f9a8d4",
|
||||
"#a3e635",
|
||||
"#fca5a5",
|
||||
];
|
||||
|
||||
export function colorForClass(classId: number): string {
|
||||
const idx =
|
||||
((classId % CLASS_COLORS.length) + CLASS_COLORS.length) % CLASS_COLORS.length;
|
||||
return CLASS_COLORS[idx];
|
||||
}
|
||||
|
||||
export function drawOverlay(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
shapes: CocoShape[],
|
||||
canvasW: number,
|
||||
canvasH: number
|
||||
) {
|
||||
const baseLineWidth = Math.max(2, Math.round(Math.min(canvasW, canvasH) / 400));
|
||||
const fontSize = Math.max(12, Math.round(Math.min(canvasW, canvasH) / 60));
|
||||
ctx.font = `${fontSize}px sans-serif`;
|
||||
ctx.textBaseline = "top";
|
||||
|
||||
for (const shape of shapes) {
|
||||
const color = colorForClass(shape.class_id);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = baseLineWidth;
|
||||
|
||||
if (shape.type === "bbox") {
|
||||
const [x, y, w, h] = shape.bbox;
|
||||
// Soft class-tinted fill for quick visual ID.
|
||||
ctx.fillStyle = hexWithAlpha(color, 0.1);
|
||||
ctx.fillRect(x, y, w, h);
|
||||
// Dark outer stroke for contrast against any background.
|
||||
ctx.strokeStyle = "rgba(0, 0, 0, 0.55)";
|
||||
ctx.lineWidth = baseLineWidth + Math.max(1, Math.round(baseLineWidth * 0.6));
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
// Bright inner stroke in class color.
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = baseLineWidth;
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
drawLabel(ctx, shape.class_name, x, y, h, color, fontSize, canvasW, canvasH);
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
shape.points.forEach(([x, y], i) => {
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = hexWithAlpha(color, 0.18);
|
||||
ctx.fill();
|
||||
|
||||
if (shape.points.length > 0) {
|
||||
// Anchor label at the bounding box of the polygon so placement is
|
||||
// stable regardless of which vertex happens to be first.
|
||||
const [bx, by, , bh] = boundingBox(shape.points);
|
||||
drawLabel(
|
||||
ctx,
|
||||
shape.class_name,
|
||||
bx,
|
||||
by,
|
||||
bh,
|
||||
color,
|
||||
fontSize,
|
||||
canvasW,
|
||||
canvasH
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a label near a shape without letting it fall off the canvas.
|
||||
*
|
||||
* Vertical: prefer *above* the shape. If that clips the top, flip to *below*.
|
||||
* If below also clips, clamp inside the canvas.
|
||||
*
|
||||
* Horizontal: left-align with the shape. If the label would exceed the right
|
||||
* edge, shift left so it ends at the edge. Finally clamp to the left edge.
|
||||
*/
|
||||
function drawLabel(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
shapeX: number,
|
||||
shapeY: number,
|
||||
shapeH: number,
|
||||
color: string,
|
||||
fontSize: number,
|
||||
canvasW: number,
|
||||
canvasH: number
|
||||
) {
|
||||
const pad = Math.round(fontSize * 0.3);
|
||||
const metrics = ctx.measureText(text);
|
||||
const w = Math.min(metrics.width + pad * 2, canvasW);
|
||||
const h = fontSize + pad * 2;
|
||||
|
||||
// Vertical
|
||||
let ly: number;
|
||||
if (shapeY - h >= 0) {
|
||||
ly = shapeY - h;
|
||||
} else if (shapeY + shapeH + h <= canvasH) {
|
||||
ly = shapeY + shapeH;
|
||||
} else {
|
||||
ly = Math.max(0, Math.min(canvasH - h, shapeY));
|
||||
}
|
||||
|
||||
// Horizontal
|
||||
let lx = shapeX;
|
||||
if (lx + w > canvasW) lx = canvasW - w;
|
||||
if (lx < 0) lx = 0;
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(lx, ly, w, h);
|
||||
ctx.fillStyle = "#0e0e14";
|
||||
ctx.fillText(text, lx + pad, ly + pad);
|
||||
}
|
||||
|
||||
function boundingBox(points: [number, number][]): [number, number, number, number] {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const [x, y] of points) {
|
||||
if (x < minX) minX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
return [minX, minY, maxX - minX, maxY - minY];
|
||||
}
|
||||
|
||||
function hexWithAlpha(hex: string, alpha: number): string {
|
||||
const c = hex.replace("#", "");
|
||||
const r = parseInt(c.slice(0, 2), 16);
|
||||
const g = parseInt(c.slice(2, 4), 16);
|
||||
const b = parseInt(c.slice(4, 6), 16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
42
sam-tool-tauri/src/components/shell/ErrorBoundary.tsx
Normal file
42
sam-tool-tauri/src/components/shell/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
// Surface in DevTools console for diagnosis.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${this.props.label}] error:`, error, info);
|
||||
}
|
||||
|
||||
reset = () => this.setState({ error: null });
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div className="error-boundary">
|
||||
<h2>{this.props.label} crashed</h2>
|
||||
<pre>{this.state.error.message}</pre>
|
||||
<pre className="dim">{this.state.error.stack}</pre>
|
||||
<button className="primary" onClick={this.reset}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
274
sam-tool-tauri/src/components/shell/SettingsModal.tsx
Normal file
274
sam-tool-tauri/src/components/shell/SettingsModal.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../ipc";
|
||||
import type { SamModelInfo, Settings } from "../../types";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved?: (s: Settings) => void;
|
||||
}
|
||||
|
||||
type TestState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "testing" }
|
||||
| { kind: "ok" }
|
||||
| { kind: "fail"; message: string };
|
||||
|
||||
type ModelsState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "ok"; models: SamModelInfo[]; defaultId: string | null }
|
||||
| { kind: "fail"; message: string };
|
||||
|
||||
const URL_PRESETS: { label: string; url: string }[] = [
|
||||
{ label: "localhost", url: "http://localhost:9090" },
|
||||
{ label: "127.0.0.1", url: "http://127.0.0.1:9090" },
|
||||
];
|
||||
|
||||
export function SettingsModal({ open, onClose, onSaved }: Props) {
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [test, setTest] = useState<TestState>({ kind: "idle" });
|
||||
const [models, setModels] = useState<ModelsState>({ kind: "idle" });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError(null);
|
||||
setTest({ kind: "idle" });
|
||||
setModels({ kind: "idle" });
|
||||
api
|
||||
.getSettings()
|
||||
.then((s) => {
|
||||
setSettings(s);
|
||||
// Kick off a model list fetch in the background so the dropdown
|
||||
// populates without the user having to click "test".
|
||||
void loadModels(s.sam_url);
|
||||
})
|
||||
.catch((e) => setError(String(e)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
async function loadModels(url: string) {
|
||||
if (!url) return;
|
||||
setModels({ kind: "loading" });
|
||||
try {
|
||||
const list = await api.samListModels(url);
|
||||
setModels({ kind: "ok", models: list.models, defaultId: list.default });
|
||||
} catch (e) {
|
||||
setModels({ kind: "fail", message: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
if (!open || !settings) return open ? (
|
||||
<div className="modal-backdrop">
|
||||
<div className="modal settings-modal">
|
||||
<div className="dim">loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
async function save() {
|
||||
if (!settings) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.saveSettings(settings);
|
||||
onSaved?.(settings);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
setTest({ kind: "testing" });
|
||||
try {
|
||||
const ok = await api.samHealthCheck(settings!.sam_url);
|
||||
setTest(ok ? { kind: "ok" } : { kind: "fail", message: "server replied but not 2xx" });
|
||||
if (ok) await loadModels(settings!.sam_url);
|
||||
} catch (e) {
|
||||
setTest({ kind: "fail", message: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
function setUrl(url: string) {
|
||||
setSettings({ ...settings!, sam_url: url });
|
||||
setTest({ kind: "idle" });
|
||||
setModels({ kind: "idle" });
|
||||
}
|
||||
|
||||
const currentModelExistsInList =
|
||||
models.kind === "ok" &&
|
||||
settings.sam_model != null &&
|
||||
models.models.some((m) => m.id === settings.sam_model);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="modal settings-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Settings</h2>
|
||||
<button className="modal-close" onClick={onClose} aria-label="close">×</button>
|
||||
</div>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-row">
|
||||
<label>
|
||||
<div className="settings-label">SAM backend URL</div>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.sam_url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="http://localhost:9090"
|
||||
/>
|
||||
</label>
|
||||
<div className="url-presets">
|
||||
{URL_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.url}
|
||||
type="button"
|
||||
className="link preset"
|
||||
onClick={() => setUrl(p.url)}
|
||||
title={`use ${p.url}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-row test-row">
|
||||
<button
|
||||
className="ghost"
|
||||
disabled={test.kind === "testing"}
|
||||
onClick={testConnection}
|
||||
>
|
||||
{test.kind === "testing" ? "testing…" : "test connection"}
|
||||
</button>
|
||||
{test.kind === "ok" && <span className="chip ok">✓ reachable</span>}
|
||||
{test.kind === "fail" && (
|
||||
<span className="chip fail" title={test.message}>
|
||||
✗ {test.message.slice(0, 60)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-row">
|
||||
<label>
|
||||
<div className="settings-label">
|
||||
Model
|
||||
{models.kind === "loading" && <span className="dim"> · loading…</span>}
|
||||
{models.kind === "fail" && (
|
||||
<span className="dim" title={models.message}> · couldn't load list</span>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value={settings.sam_model ?? ""}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
sam_model: e.target.value || null,
|
||||
})
|
||||
}
|
||||
disabled={models.kind !== "ok"}
|
||||
>
|
||||
<option value="">
|
||||
{models.kind === "ok" && models.defaultId
|
||||
? `backend default (${models.defaultId})`
|
||||
: "backend default"}
|
||||
</option>
|
||||
{models.kind === "ok" &&
|
||||
models.models.map((m) => (
|
||||
<option
|
||||
key={m.id}
|
||||
value={m.id}
|
||||
disabled={!m.available}
|
||||
>
|
||||
{m.name}
|
||||
{!m.available ? " · checkpoint missing" : ""}
|
||||
</option>
|
||||
))}
|
||||
{!currentModelExistsInList && settings.sam_model && (
|
||||
<option value={settings.sam_model}>
|
||||
{settings.sam_model} (not in list)
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
{models.kind === "fail" && (
|
||||
<button
|
||||
className="link"
|
||||
onClick={() => loadModels(settings.sam_url)}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-row">
|
||||
<label className="switch-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.sam_enabled}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, sam_enabled: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<strong>SAM mode</strong>
|
||||
<span className="dim"> — auto-segment bboxes via the backend</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-row">
|
||||
<label>
|
||||
<div className="settings-label">
|
||||
Frame cache capacity
|
||||
<span className="dim"> · {settings.cache_capacity} frames</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={200}
|
||||
max={2000}
|
||||
step={50}
|
||||
value={settings.cache_capacity}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
cache_capacity: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="range-ticks">
|
||||
<span>200</span>
|
||||
<span>1000</span>
|
||||
<span>2000</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && <div className="picker-error">{error}</div>}
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="ghost" onClick={onClose} disabled={saving}>
|
||||
cancel
|
||||
</button>
|
||||
<button className="primary" onClick={save} disabled={saving}>
|
||||
{saving ? "saving…" : "save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
sam-tool-tauri/src/components/shell/TopNav.tsx
Normal file
80
sam-tool-tauri/src/components/shell/TopNav.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useState } from "react";
|
||||
import type { Mode } from "../../types";
|
||||
import { api } from "../../ipc";
|
||||
import seekLogo from "../../assets/seek-logo.svg";
|
||||
|
||||
interface Props {
|
||||
mode: Mode;
|
||||
onModeChange: (m: Mode) => void;
|
||||
username: string;
|
||||
onLogout: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onToggleFullview: () => void;
|
||||
}
|
||||
|
||||
export function TopNav({
|
||||
mode,
|
||||
onModeChange,
|
||||
username,
|
||||
onLogout,
|
||||
onOpenSettings,
|
||||
onToggleFullview,
|
||||
}: Props) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
async function logout() {
|
||||
await api.clearCurrentUser();
|
||||
setMenuOpen(false);
|
||||
onLogout();
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="topnav">
|
||||
<div className="brand-mark">
|
||||
<img className="brand-icon" src={seekLogo} alt="SR" />
|
||||
<span className="brand-text">SAM Tool</span>
|
||||
</div>
|
||||
<nav>
|
||||
<button
|
||||
className={mode === "extract" ? "active" : ""}
|
||||
onClick={() => onModeChange("extract")}
|
||||
>
|
||||
Extract
|
||||
</button>
|
||||
<button
|
||||
className={mode === "annotate" ? "active" : ""}
|
||||
onClick={() => onModeChange("annotate")}
|
||||
>
|
||||
Annotate
|
||||
</button>
|
||||
</nav>
|
||||
<div className="spacer" />
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={onToggleFullview}
|
||||
title="Full view (F11)"
|
||||
aria-label="Full view"
|
||||
>
|
||||
⛶
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={onOpenSettings}
|
||||
title="Settings"
|
||||
aria-label="Settings"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
<div className="user-chip">
|
||||
<button className="chip" onClick={() => setMenuOpen((v) => !v)}>
|
||||
{username} ▾
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="menu" onMouseLeave={() => setMenuOpen(false)}>
|
||||
<button onClick={logout}>Switch user</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
2063
sam-tool-tauri/src/index.css
Normal file
2063
sam-tool-tauri/src/index.css
Normal file
File diff suppressed because it is too large
Load Diff
94
sam-tool-tauri/src/ipc.ts
Normal file
94
sam-tool-tauri/src/ipc.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
CocoCategory,
|
||||
CocoShape,
|
||||
ImageAnnotations,
|
||||
ImageEntry,
|
||||
ImportedCoco,
|
||||
SamModelList,
|
||||
Settings,
|
||||
VideoInfo,
|
||||
} from "./types";
|
||||
|
||||
export const api = {
|
||||
ping: () => invoke<string>("ping"),
|
||||
|
||||
currentUser: () => invoke<string | null>("current_user"),
|
||||
listKnownUsers: () => invoke<string[]>("list_known_users"),
|
||||
setCurrentUser: (name: string) => invoke<string>("set_current_user", { name }),
|
||||
clearCurrentUser: () => invoke<void>("clear_current_user"),
|
||||
|
||||
openVideo: (path: string) => invoke<VideoInfo>("open_video", { path }),
|
||||
closeVideo: () => invoke<void>("close_video"),
|
||||
currentVideo: () => invoke<VideoInfo | null>("current_video"),
|
||||
decodeFrame: (frameIdx: number) =>
|
||||
invoke<ArrayBuffer>("decode_frame", { frameIdx }),
|
||||
setCacheCapacity: (capacity: number) =>
|
||||
invoke<number>("set_cache_capacity", { capacity }),
|
||||
|
||||
importCoco: (path: string) => invoke<ImportedCoco>("import_coco", { path }),
|
||||
|
||||
extractFrame: (frameIdx: number, outDir: string, shapes: CocoShape[], author: string) =>
|
||||
invoke<void>("extract_frame", { frameIdx, outDir, shapes, author }),
|
||||
undoExtract: (frameIdx: number, outDir: string) =>
|
||||
invoke<void>("undo_extract", { frameIdx, outDir }),
|
||||
bulkExtract: (
|
||||
start: number,
|
||||
end: number,
|
||||
outDir: string,
|
||||
shapesByFrame: Record<string, CocoShape[]>,
|
||||
author: string
|
||||
) =>
|
||||
invoke<number>("bulk_extract", {
|
||||
start,
|
||||
end,
|
||||
outDir,
|
||||
shapesByFrame,
|
||||
author,
|
||||
}),
|
||||
bulkUndo: (start: number, end: number, outDir: string) =>
|
||||
invoke<number>("bulk_undo", { start, end, outDir }),
|
||||
listExtractedFrames: (outDir: string) =>
|
||||
invoke<number[]>("list_extracted_frames", { outDir }),
|
||||
writeClassesTxt: (outDir: string, categories: CocoCategory[]) =>
|
||||
invoke<void>("write_classes_txt", { outDir, categories }),
|
||||
|
||||
listFolderImages: (folder: string) =>
|
||||
invoke<ImageEntry[]>("list_folder_images", { folder }),
|
||||
readImageBytes: (path: string) =>
|
||||
invoke<ArrayBuffer>("read_image_bytes", { path }),
|
||||
loadImageAnnotations: (imagePath: string) =>
|
||||
invoke<ImageAnnotations | null>("load_image_annotations", { imagePath }),
|
||||
loadClasses: (folder: string) => invoke<string[]>("load_classes", { folder }),
|
||||
saveImageAnnotations: (imagePath: string, payload: ImageAnnotations) =>
|
||||
invoke<void>("save_image_annotations", { imagePath, payload }),
|
||||
appendClass: (folder: string, name: string) =>
|
||||
invoke<string[]>("append_class", { folder, name }),
|
||||
deleteImages: (paths: string[]) => invoke<number>("delete_images", { paths }),
|
||||
readFolderSource: (folder: string) =>
|
||||
invoke<string | null>("read_folder_source", { folder }),
|
||||
|
||||
getSettings: () => invoke<Settings>("get_settings"),
|
||||
saveSettings: (settings: Settings) =>
|
||||
invoke<void>("save_settings", { settings }),
|
||||
samHealthCheck: (url: string) =>
|
||||
invoke<boolean>("sam_health_check", { url }),
|
||||
samListModels: (url: string) =>
|
||||
invoke<SamModelList>("sam_list_models", { url }),
|
||||
samSegmentBbox: (
|
||||
url: string,
|
||||
imagePath: string,
|
||||
bbox: [number, number, number, number],
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
model?: string | null
|
||||
) =>
|
||||
invoke<[number, number][]>("sam_segment_bbox", {
|
||||
url,
|
||||
imagePath,
|
||||
bbox,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
model: model ?? null,
|
||||
}),
|
||||
};
|
||||
10
sam-tool-tauri/src/main.tsx
Normal file
10
sam-tool-tauri/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
1825
sam-tool-tauri/src/modes/AnnotateMode.tsx
Normal file
1825
sam-tool-tauri/src/modes/AnnotateMode.tsx
Normal file
File diff suppressed because it is too large
Load Diff
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" />;
|
||||
}
|
||||
109
sam-tool-tauri/src/types.ts
Normal file
109
sam-tool-tauri/src/types.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
export type Mode = "extract" | "annotate";
|
||||
|
||||
export interface VideoInfo {
|
||||
path: string;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
total_frames: number;
|
||||
duration_s: number;
|
||||
default_output_folder: string;
|
||||
}
|
||||
|
||||
export interface CocoCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CocoBboxShape {
|
||||
type: "bbox";
|
||||
class_id: number;
|
||||
class_name: string;
|
||||
bbox: [number, number, number, number];
|
||||
}
|
||||
|
||||
export interface CocoPolygonShape {
|
||||
type: "polygon";
|
||||
class_id: number;
|
||||
class_name: string;
|
||||
points: [number, number][];
|
||||
}
|
||||
|
||||
export type CocoShape = CocoBboxShape | CocoPolygonShape;
|
||||
|
||||
export interface CocoImportStats {
|
||||
total_annotations: number;
|
||||
frame_count: number;
|
||||
categories: number;
|
||||
skipped_rle: number;
|
||||
skipped_no_image: number;
|
||||
source_format: "coco" | "sam" | string;
|
||||
}
|
||||
|
||||
export interface ImportedCoco {
|
||||
categories: CocoCategory[];
|
||||
frames: Record<string, CocoShape[]>;
|
||||
stats: CocoImportStats;
|
||||
}
|
||||
|
||||
export type AnnotationType = "bbox" | "polygon";
|
||||
|
||||
export interface BboxAnnotation {
|
||||
id: string;
|
||||
class_id: number;
|
||||
class_name: string;
|
||||
type: "bbox";
|
||||
bbox: [number, number, number, number];
|
||||
author: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PolygonAnnotation {
|
||||
id: string;
|
||||
class_id: number;
|
||||
class_name: string;
|
||||
type: "polygon";
|
||||
points: [number, number][];
|
||||
author: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type Annotation = BboxAnnotation | PolygonAnnotation;
|
||||
|
||||
export interface ImageAnnotations {
|
||||
version: 1;
|
||||
image: string;
|
||||
width: number;
|
||||
height: number;
|
||||
source_video?: string | null;
|
||||
annotations: Annotation[];
|
||||
}
|
||||
|
||||
export interface ImageEntry {
|
||||
filename: string;
|
||||
has_annotations: boolean;
|
||||
annotation_count: number;
|
||||
last_updated: string | null;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
sam_url: string;
|
||||
sam_enabled: boolean;
|
||||
sam_model?: string | null;
|
||||
cache_capacity: number;
|
||||
}
|
||||
|
||||
export interface SamModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
family: string;
|
||||
available: boolean;
|
||||
checkpoint: string;
|
||||
}
|
||||
|
||||
export interface SamModelList {
|
||||
default: string | null;
|
||||
models: SamModelInfo[];
|
||||
}
|
||||
1
sam-tool-tauri/src/vite-env.d.ts
vendored
Normal file
1
sam-tool-tauri/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user