first version

This commit is contained in:
2026-04-25 16:23:09 +05:30
commit 7d3c36050b
73 changed files with 19033 additions and 0 deletions

14
sam-tool-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
node_modules
dist
dist-ssr
*.local
.vite
# Editor
.vscode/*
!.vscode/extensions.json
.idea
# System
.DS_Store

12
sam-tool-tauri/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SAM Tool</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2149
sam-tool-tauri/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
{
"name": "sam-tool-tauri",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2.1.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.3"
}
}

2
sam-tool-tauri/src-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
target
gen/schemas

5365
sam-tool-tauri/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
[package]
name = "sam-tool-tauri"
version = "0.1.0"
description = "SAM Tool - video frame extraction and annotation"
edition = "2021"
rust-version = "1.77"
[lib]
name = "sam_tool_tauri_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
uuid = { version = "1", features = ["v4"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] }
base64 = "0.22"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
[profile.dev]
incremental = true
[profile.release]
codegen-units = 1
lto = true
opt-level = "s"
panic = "abort"
strip = true

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"fs:default"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,169 @@
use crate::annotation::{AnnotationRecord, ImageAnnotations};
use serde::Serialize;
use std::path::{Path, PathBuf};
#[derive(Serialize, Clone)]
pub struct ImageEntry {
pub filename: String,
pub has_annotations: bool,
pub annotation_count: u32,
pub last_updated: Option<String>,
}
fn updated_at_of(a: &AnnotationRecord) -> &str {
match a {
AnnotationRecord::Bbox { updated_at, .. } => updated_at,
AnnotationRecord::Polygon { updated_at, .. } => updated_at,
}
}
fn is_image(name: &str) -> bool {
let lower = name.to_lowercase();
lower.ends_with(".jpg") || lower.ends_with(".jpeg") || lower.ends_with(".png")
}
fn sibling_json(image_path: &Path) -> PathBuf {
image_path.with_extension("json")
}
pub fn list_images(folder: &Path) -> Result<Vec<ImageEntry>, String> {
if !folder.exists() {
return Err(format!("folder not found: {}", folder.display()));
}
if !folder.is_dir() {
return Err(format!("not a directory: {}", folder.display()));
}
let mut out = Vec::new();
let entries = std::fs::read_dir(folder)
.map_err(|e| format!("read_dir {}: {e}", folder.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("dirent: {e}"))?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !is_image(name) {
continue;
}
let json_path = sibling_json(&path);
let (annotation_count, last_updated) = if json_path.exists() {
match std::fs::read_to_string(&json_path)
.ok()
.and_then(|t| serde_json::from_str::<ImageAnnotations>(&t).ok())
{
Some(ia) => {
let latest = ia
.annotations
.iter()
.map(|a| updated_at_of(a).to_string())
.max();
(ia.annotations.len() as u32, latest)
}
None => (0, None),
}
} else {
(0, None)
};
out.push(ImageEntry {
filename: name.to_string(),
has_annotations: annotation_count > 0,
annotation_count,
last_updated,
});
}
out.sort_by(|a, b| a.filename.cmp(&b.filename));
Ok(out)
}
pub fn read_image_bytes(path: &Path) -> Result<Vec<u8>, String> {
if !path.exists() {
return Err(format!("file not found: {}", path.display()));
}
std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))
}
pub fn load_image_annotations(
image_path: &Path,
) -> Result<Option<ImageAnnotations>, String> {
let json_path = sibling_json(image_path);
if !json_path.exists() {
return Ok(None);
}
let text = std::fs::read_to_string(&json_path)
.map_err(|e| format!("read {}: {e}", json_path.display()))?;
let ia: ImageAnnotations =
serde_json::from_str(&text).map_err(|e| format!("parse sibling JSON: {e}"))?;
Ok(Some(ia))
}
pub fn load_classes(folder: &Path) -> Result<Vec<String>, String> {
let path = folder.join("classes.txt");
if !path.exists() {
return Ok(Vec::new());
}
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("read {}: {e}", path.display()))?;
Ok(text
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect())
}
/// Delete a batch of image files along with their sibling annotation JSONs.
/// Returns the count of images actually removed. Missing images are skipped;
/// any hard I/O error aborts with what succeeded so far reflected in the log.
pub fn delete_images(paths: &[String]) -> Result<u32, String> {
let mut removed: u32 = 0;
for p in paths {
let path = Path::new(p);
if !path.is_file() {
continue;
}
// Only allow image files — guard against a stray path that would
// wipe a JSON or classes.txt by name collision.
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if !is_image(name) {
return Err(format!("refusing to delete non-image: {}", path.display()));
}
std::fs::remove_file(path)
.map_err(|e| format!("remove {}: {e}", path.display()))?;
let json_path = sibling_json(path);
if json_path.exists() {
let _ = std::fs::remove_file(&json_path);
}
removed += 1;
}
Ok(removed)
}
pub fn append_class(folder: &Path, name: &str) -> Result<Vec<String>, String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("class name cannot be empty".into());
}
if trimmed.len() > 96 {
return Err("class name too long".into());
}
std::fs::create_dir_all(folder)
.map_err(|e| format!("mkdir {}: {e}", folder.display()))?;
let mut classes = load_classes(folder)?;
if classes.iter().any(|n| n == trimmed) {
return Err(format!("class '{trimmed}' already exists"));
}
classes.push(trimmed.to_string());
let mut text = String::new();
for c in &classes {
text.push_str(c);
text.push('\n');
}
let path = folder.join("classes.txt");
std::fs::write(&path, text).map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(classes)
}

View File

@@ -0,0 +1,43 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone)]
pub struct ImageAnnotations {
pub version: u32,
pub image: String,
pub width: u32,
pub height: u32,
/// Absolute path to the source video the frame was extracted from.
/// Optional so older JSONs without this field still round-trip cleanly.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_video: Option<String>,
pub annotations: Vec<AnnotationRecord>,
}
/// Internally-tagged enum. Both variants share id/class/author/timestamps and
/// differ only in the shape data (bbox | points). Using a single enum with
/// `#[serde(tag = "type")]` gives clean round-trip serde (flatten + tagged
/// enums together don't deserialize reliably).
#[derive(Serialize, Deserialize, Clone)]
#[serde(tag = "type")]
pub enum AnnotationRecord {
#[serde(rename = "bbox")]
Bbox {
id: String,
class_id: i64,
class_name: String,
bbox: [f64; 4],
author: String,
created_at: String,
updated_at: String,
},
#[serde(rename = "polygon")]
Polygon {
id: String,
class_id: i64,
class_name: String,
points: Vec<[f64; 2]>,
author: String,
created_at: String,
updated_at: String,
},
}

View File

@@ -0,0 +1,359 @@
use serde::{Deserialize, Serialize};
use std::collections::{BTreeSet, HashMap};
use std::path::Path;
#[derive(Deserialize)]
struct CocoFile {
images: Vec<CocoImage>,
categories: Vec<CocoCategory>,
annotations: Vec<CocoAnnotation>,
}
#[derive(Deserialize)]
struct CocoImage {
id: i64,
#[serde(default)]
file_name: String,
}
#[derive(Deserialize)]
struct CocoCategory {
id: i64,
name: String,
}
#[derive(Deserialize)]
struct CocoAnnotation {
image_id: i64,
category_id: i64,
#[serde(default)]
bbox: Option<Vec<f64>>,
#[serde(default)]
segmentation: Option<serde_json::Value>,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(tag = "type")]
pub enum Shape {
#[serde(rename = "bbox")]
Bbox {
class_id: i64,
class_name: String,
bbox: [f64; 4],
},
#[serde(rename = "polygon")]
Polygon {
class_id: i64,
class_name: String,
points: Vec<[f64; 2]>,
},
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Category {
pub id: i64,
pub name: String,
}
#[derive(Serialize, Clone, Default)]
pub struct ImportStats {
pub total_annotations: usize,
pub frame_count: usize,
pub categories: usize,
pub skipped_rle: usize,
pub skipped_no_image: usize,
/// Filled in by the import — "coco" or "sam".
pub source_format: String,
}
#[derive(Serialize, Clone)]
pub struct ImportedCoco {
pub categories: Vec<Category>,
/// Map of frame_idx (as string, per JSON) -> shapes on that frame.
pub frames: HashMap<String, Vec<Shape>>,
pub stats: ImportStats,
}
enum Format {
LabelStudioCoco,
SamAnno,
Unknown,
}
fn detect_format(v: &serde_json::Value) -> Format {
let Some(obj) = v.as_object() else {
return Format::Unknown;
};
if obj.contains_key("images") && obj.contains_key("annotations") {
return Format::LabelStudioCoco;
}
if !obj.is_empty()
&& obj
.keys()
.all(|k| !k.is_empty() && k.chars().all(|c| c.is_ascii_digit()))
&& obj.values().all(|v| v.is_object())
{
return Format::SamAnno;
}
Format::Unknown
}
pub fn import(path: &Path) -> Result<ImportedCoco, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("read {}: {e}", path.display()))?;
let root: serde_json::Value =
serde_json::from_str(&text).map_err(|e| format!("parse JSON: {e}"))?;
match detect_format(&root) {
Format::LabelStudioCoco => {
let coco: CocoFile = serde_json::from_value(root)
.map_err(|e| format!("parse COCO: {e}"))?;
Ok(build_from_coco(coco))
}
Format::SamAnno => build_from_sam_anno(&root),
Format::Unknown => Err(
"unrecognized annotation format — expected Label Studio COCO or SAM anno JSON"
.into(),
),
}
}
// ---- Label Studio COCO -----------------------------------------------------
fn build_from_coco(coco: CocoFile) -> ImportedCoco {
let mut image_frame: HashMap<i64, u64> = HashMap::with_capacity(coco.images.len());
for img in &coco.images {
let frame_idx =
trailing_int(&img.file_name).unwrap_or_else(|| img.id.max(0) as u64);
image_frame.insert(img.id, frame_idx);
}
let cat_name: HashMap<i64, String> = coco
.categories
.iter()
.map(|c| (c.id, c.name.clone()))
.collect();
let mut frames: HashMap<String, Vec<Shape>> = HashMap::new();
let mut stats = ImportStats {
categories: coco.categories.len(),
source_format: "coco".into(),
..Default::default()
};
for ann in &coco.annotations {
stats.total_annotations += 1;
let Some(&frame_idx) = image_frame.get(&ann.image_id) else {
stats.skipped_no_image += 1;
continue;
};
let class_name = cat_name
.get(&ann.category_id)
.cloned()
.unwrap_or_else(|| format!("class_{}", ann.category_id));
let key = frame_idx.to_string();
let bucket = frames.entry(key).or_default();
if let Some(seg) = &ann.segmentation {
match seg {
serde_json::Value::Array(rings) => {
for ring in rings {
if let Some(flat) = ring.as_array() {
let points = parse_flat_polygon(flat);
if points.len() >= 3 {
bucket.push(Shape::Polygon {
class_id: ann.category_id,
class_name: class_name.clone(),
points,
});
}
}
}
}
serde_json::Value::Object(_) => {
stats.skipped_rle += 1;
}
_ => {}
}
}
if let Some(bbox) = &ann.bbox {
if bbox.len() == 4 {
bucket.push(Shape::Bbox {
class_id: ann.category_id,
class_name,
bbox: [bbox[0], bbox[1], bbox[2], bbox[3]],
});
}
}
}
stats.frame_count = frames.len();
let categories = coco
.categories
.into_iter()
.map(|c| Category {
id: c.id,
name: c.name,
})
.collect();
ImportedCoco {
categories,
frames,
stats,
}
}
// ---- Custom SAM annotation format ------------------------------------------
//
// { "<frame_idx>": { "<class_name>": [[id, [tl_x,tl_y], [br_x,br_y],
// [[[x,y], ...], ...rings]]] } }
// Bbox is redundant with the polygon's tight bounding box, so we emit only
// polygons. Class IDs are assigned 0-indexed by sorted class name.
fn build_from_sam_anno(root: &serde_json::Value) -> Result<ImportedCoco, String> {
let obj = root
.as_object()
.ok_or_else(|| "SAM anno root is not an object".to_string())?;
let mut class_names = BTreeSet::<String>::new();
let mut frames: HashMap<String, Vec<Shape>> = HashMap::new();
let mut stats = ImportStats {
source_format: "sam".into(),
..Default::default()
};
for (frame_key, frame_val) in obj {
let Some(cls_obj) = frame_val.as_object() else {
continue;
};
let mut shapes: Vec<Shape> = Vec::new();
for (class_name, items) in cls_obj {
class_names.insert(class_name.clone());
let Some(items) = items.as_array() else {
continue;
};
for item in items {
let Some(arr) = item.as_array() else {
continue;
};
if arr.len() < 4 {
continue;
}
stats.total_annotations += 1;
// arr[3] is a list of polygon rings.
let Some(rings) = arr[3].as_array() else {
continue;
};
for ring in rings {
let Some(pts) = ring.as_array() else {
continue;
};
let mut points = Vec::with_capacity(pts.len());
for p in pts {
let Some(pa) = p.as_array() else {
continue;
};
let (Some(x), Some(y)) = (
pa.first().and_then(|v| v.as_f64()),
pa.get(1).and_then(|v| v.as_f64()),
) else {
continue;
};
points.push([x, y]);
}
if points.len() >= 3 {
shapes.push(Shape::Polygon {
class_id: 0,
class_name: class_name.clone(),
points,
});
}
}
}
}
if !shapes.is_empty() {
frames.insert(frame_key.clone(), shapes);
}
}
// Assign class_ids from sorted class names.
let categories: Vec<Category> = class_names
.iter()
.enumerate()
.map(|(i, name)| Category {
id: i as i64,
name: name.clone(),
})
.collect();
let name_to_id: HashMap<String, i64> = categories
.iter()
.map(|c| (c.name.clone(), c.id))
.collect();
for shapes in frames.values_mut() {
for shape in shapes.iter_mut() {
let (cid, cname) = match shape {
Shape::Bbox {
class_id,
class_name,
..
}
| Shape::Polygon {
class_id,
class_name,
..
} => (class_id, class_name),
};
if let Some(&id) = name_to_id.get(cname) {
*cid = id;
}
}
}
stats.categories = categories.len();
stats.frame_count = frames.len();
Ok(ImportedCoco {
categories,
frames,
stats,
})
}
// ---- helpers ---------------------------------------------------------------
fn trailing_int(file_name: &str) -> Option<u64> {
let stem = Path::new(file_name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(file_name);
let digits: String = stem
.chars()
.rev()
.take_while(|c| c.is_ascii_digit())
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
if digits.is_empty() {
None
} else {
digits.parse().ok()
}
}
fn parse_flat_polygon(flat: &[serde_json::Value]) -> Vec<[f64; 2]> {
let mut out = Vec::with_capacity(flat.len() / 2);
let mut i = 0;
while i + 1 < flat.len() {
let x = flat[i].as_f64();
let y = flat[i + 1].as_f64();
if let (Some(x), Some(y)) = (x, y) {
out.push([x, y]);
}
i += 2;
}
out
}

View File

@@ -0,0 +1,351 @@
use crate::video::VideoInfo;
use std::collections::{HashMap, VecDeque};
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{Child, ChildStdout, Command, Stdio};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
pub const DEFAULT_CACHE_CAPACITY: usize = 500;
pub const MIN_CACHE_CAPACITY: usize = 200;
pub const MAX_CACHE_CAPACITY: usize = 2000;
const READ_BUF_BYTES: usize = 256 * 1024;
/// If the requested frame is this far ahead of the worker, kill + reseek.
const SEEK_AHEAD_THRESHOLD: u64 = 60;
struct FrameCache {
map: HashMap<u64, Vec<u8>>,
order: VecDeque<u64>,
capacity: usize,
}
impl FrameCache {
fn new(capacity: usize) -> Self {
Self {
map: HashMap::new(),
order: VecDeque::new(),
capacity,
}
}
fn contains(&self, idx: u64) -> bool {
self.map.contains_key(&idx)
}
fn get(&mut self, idx: u64) -> Option<Vec<u8>> {
let bytes = self.map.get(&idx)?.clone();
if let Some(pos) = self.order.iter().position(|&i| i == idx) {
self.order.remove(pos);
}
self.order.push_back(idx);
Some(bytes)
}
fn insert(&mut self, idx: u64, bytes: Vec<u8>) {
if self.map.contains_key(&idx) {
if let Some(pos) = self.order.iter().position(|&i| i == idx) {
self.order.remove(pos);
}
} else if self.map.len() >= self.capacity {
if let Some(oldest) = self.order.pop_front() {
self.map.remove(&oldest);
}
}
self.map.insert(idx, bytes);
self.order.push_back(idx);
}
fn set_capacity(&mut self, new_cap: usize) {
self.capacity = new_cap.clamp(MIN_CACHE_CAPACITY, MAX_CACHE_CAPACITY);
while self.map.len() > self.capacity {
if let Some(oldest) = self.order.pop_front() {
self.map.remove(&oldest);
} else {
break;
}
}
}
}
struct SharedState {
/// Owned by shared state (not the worker closure) so stop_worker can
/// kill ffmpeg directly and unblock a pipe read that's waiting for the
/// first post-seek frame.
child: Option<Child>,
cache: FrameCache,
/// Frame the worker's pipe will emit next.
next_idx: u64,
/// Most recent frame the frontend requested — used to bound lookahead.
reader_idx: u64,
/// Worker reads this many frames past reader_idx before parking.
lookahead: u64,
eof: bool,
error: Option<String>,
shutdown: bool,
}
struct Shared {
state: Mutex<SharedState>,
cond: Condvar,
}
pub struct DecoderSession {
path: PathBuf,
info: VideoInfo,
shared: Arc<Shared>,
worker: Option<JoinHandle<()>>,
}
impl DecoderSession {
pub fn open(path: PathBuf, info: VideoInfo) -> Self {
Self {
path,
info,
shared: Arc::new(Shared {
state: Mutex::new(SharedState {
child: None,
cache: FrameCache::new(DEFAULT_CACHE_CAPACITY),
next_idx: 0,
reader_idx: 0,
lookahead: (DEFAULT_CACHE_CAPACITY as u64) / 2,
eof: false,
error: None,
shutdown: false,
}),
cond: Condvar::new(),
}),
worker: None,
}
}
pub fn info(&self) -> &VideoInfo {
&self.info
}
pub fn set_cache_capacity(&self, capacity: usize) {
let clamped = capacity.clamp(MIN_CACHE_CAPACITY, MAX_CACHE_CAPACITY);
let mut st = self.shared.state.lock().unwrap();
st.cache.set_capacity(clamped);
st.lookahead = (clamped as u64) / 2;
}
pub fn decode_at(&mut self, idx: u64) -> Result<Vec<u8>, String> {
if idx >= self.info.total_frames {
return Err(format!(
"frame {} out of range (total {})",
idx, self.info.total_frames
));
}
// Update reader cursor, check cache, note whether a seek is needed.
let need_seek = {
let mut st = self.shared.state.lock().unwrap();
st.reader_idx = idx;
if let Some(bytes) = st.cache.get(idx) {
self.shared.cond.notify_all();
return Ok(bytes);
}
self.worker.is_none()
|| idx < st.next_idx
|| idx > st.next_idx.saturating_add(SEEK_AHEAD_THRESHOLD)
};
if need_seek {
self.seek_and_start_worker(idx)?;
} else {
self.shared.cond.notify_all();
}
// Wait for the worker to produce idx.
let mut st = self.shared.state.lock().unwrap();
loop {
if st.cache.contains(idx) {
return st
.cache
.get(idx)
.ok_or_else(|| "frame vanished from cache".to_string());
}
if let Some(e) = &st.error {
return Err(e.clone());
}
if st.eof {
return Err(format!("frame {} not produced (EOF)", idx));
}
if st.shutdown {
return Err("decoder shut down".into());
}
st = self.shared.cond.wait(st).unwrap();
}
}
fn seek_and_start_worker(&mut self, idx: u64) -> Result<(), String> {
self.stop_worker();
let (child, stdout) = spawn_ffmpeg(&self.path, &self.info, idx)?;
{
let mut st = self.shared.state.lock().unwrap();
st.child = Some(child);
st.next_idx = idx;
st.reader_idx = idx;
st.eof = false;
st.error = None;
st.shutdown = false;
}
self.shared.cond.notify_all();
let shared = self.shared.clone();
let handle = thread::spawn(move || {
worker_loop(stdout, shared);
});
self.worker = Some(handle);
Ok(())
}
fn stop_worker(&mut self) {
// Take ownership of the child out of shared state and kill it FIRST so
// a pipe read in the worker unblocks with EOF. Then join the worker.
let child_opt = {
let mut st = self.shared.state.lock().unwrap();
st.shutdown = true;
st.child.take()
};
if let Some(mut child) = child_opt {
let _ = child.kill();
let _ = child.wait();
}
self.shared.cond.notify_all();
if let Some(handle) = self.worker.take() {
let _ = handle.join();
}
let mut st = self.shared.state.lock().unwrap();
st.shutdown = false;
st.eof = false;
st.error = None;
}
}
impl Drop for DecoderSession {
fn drop(&mut self) {
self.stop_worker();
}
}
fn spawn_ffmpeg(
path: &PathBuf,
info: &VideoInfo,
idx: u64,
) -> Result<(Child, BufReader<ChildStdout>), String> {
let path_str = path
.to_str()
.ok_or_else(|| "non-utf8 path".to_string())?;
let t = idx as f64 / info.fps;
let mut cmd = Command::new("ffmpeg");
cmd.args(["-v", "error", "-threads", "0"]);
if t > 1.0 {
cmd.args(["-ss", &format!("{:.3}", t - 1.0)]);
cmd.args(["-i", path_str]);
cmd.args(["-ss", "1.0"]);
} else {
cmd.args(["-i", path_str]);
cmd.args(["-ss", &format!("{:.3}", t)]);
}
cmd.args([
"-an",
"-q:v",
"3",
"-f",
"image2pipe",
"-vcodec",
"mjpeg",
"pipe:1",
]);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| format!("ffmpeg spawn: {e}"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| "ffmpeg stdout unavailable".to_string())?;
Ok((child, BufReader::with_capacity(READ_BUF_BYTES, stdout)))
}
fn worker_loop(mut stdout: BufReader<ChildStdout>, shared: Arc<Shared>) {
loop {
// Backpressure: wait if we're already far enough ahead.
{
let st = shared.state.lock().unwrap();
if st.shutdown {
return;
}
let ahead = st.next_idx.saturating_sub(st.reader_idx);
if ahead >= st.lookahead {
let (st2, _) = shared
.cond
.wait_timeout(st, Duration::from_millis(100))
.unwrap();
if st2.shutdown {
return;
}
continue;
}
}
match read_jpeg_bytes(&mut stdout) {
Ok(bytes) => {
let mut st = shared.state.lock().unwrap();
if st.shutdown {
return;
}
let idx = st.next_idx;
st.cache.insert(idx, bytes);
st.next_idx = idx + 1;
shared.cond.notify_all();
}
Err(e) => {
let mut st = shared.state.lock().unwrap();
if e.contains("pipe closed") || e.contains("EOF") {
st.eof = true;
} else {
st.error = Some(e);
}
shared.cond.notify_all();
return;
}
}
}
}
fn read_jpeg_bytes(reader: &mut BufReader<ChildStdout>) -> Result<Vec<u8>, String> {
let mut out = Vec::with_capacity(64 * 1024);
let mut last = 0u8;
let mut in_jpeg = false;
loop {
let buf = reader.fill_buf().map_err(|e| format!("pipe read: {e}"))?;
if buf.is_empty() {
return Err("ffmpeg pipe closed before frame completed".into());
}
let mut consumed = 0usize;
for &b in buf {
consumed += 1;
if !in_jpeg {
if last == 0xFF && b == 0xD8 {
in_jpeg = true;
out.push(0xFF);
out.push(0xD8);
}
last = b;
} else {
out.push(b);
if last == 0xFF && b == 0xD9 {
reader.consume(consumed);
return Ok(out);
}
last = b;
}
}
reader.consume(consumed);
}
}

View File

@@ -0,0 +1,188 @@
use crate::annotation::{AnnotationRecord, ImageAnnotations};
use crate::coco::{Category, Shape};
use crate::decoder::DecoderSession;
use chrono::Utc;
use std::path::Path;
use uuid::Uuid;
pub fn image_filename(idx: u64) -> String {
format!("f_{:04}.jpg", idx)
}
pub fn json_filename(idx: u64) -> String {
format!("f_{:04}.json", idx)
}
/// Parse a frame index from either the new `f_0042.jpg` pattern or the legacy
/// `frame_000042.jpg` pattern. Returns None for unrelated files.
fn parse_frame_idx(name: &str) -> Option<u64> {
let stem = name.strip_suffix(".jpg")?;
if let Some(rest) = stem.strip_prefix("f_") {
return rest.parse().ok();
}
if let Some(rest) = stem.strip_prefix("frame_") {
return rest.parse().ok();
}
None
}
pub fn extract_one(
decoder: &mut DecoderSession,
frame_idx: u64,
out_dir: &Path,
shapes: &[Shape],
author: &str,
) -> Result<(), String> {
std::fs::create_dir_all(out_dir)
.map_err(|e| format!("mkdir {}: {e}", out_dir.display()))?;
// Snapshot info so we don't hold an immutable borrow across the mutable
// decode_at call below.
let info = decoder.info().clone();
write_folder_source(out_dir, &info.path);
let jpeg = decoder.decode_at(frame_idx)?;
let img_path = out_dir.join(image_filename(frame_idx));
std::fs::write(&img_path, &jpeg)
.map_err(|e| format!("write {}: {e}", img_path.display()))?;
let json_path = out_dir.join(json_filename(frame_idx));
if shapes.is_empty() {
// Clean up any stale sibling JSON from a prior extract with annotations.
let _ = std::fs::remove_file(&json_path);
return Ok(());
}
let now = Utc::now().to_rfc3339();
let annotations = shapes
.iter()
.map(|s| shape_to_record(s, author, &now))
.collect();
let ia = ImageAnnotations {
version: 1,
image: image_filename(frame_idx),
width: info.width,
height: info.height,
source_video: Some(info.path),
annotations,
};
let text = serde_json::to_string_pretty(&ia)
.map_err(|e| format!("serialize json: {e}"))?;
std::fs::write(&json_path, text)
.map_err(|e| format!("write {}: {e}", json_path.display()))?;
Ok(())
}
pub const FOLDER_SOURCE_FILENAME: &str = "source.json";
#[derive(serde::Serialize, serde::Deserialize)]
pub struct FolderSource {
pub source_video: String,
pub created_at: String,
}
/// Write (once, if missing) a folder-level metadata file recording which
/// video this output folder was extracted from. Silent on failure — this is
/// a best-effort breadcrumb, not a critical write.
pub fn write_folder_source(out_dir: &Path, video_path: &str) {
let meta_path = out_dir.join(FOLDER_SOURCE_FILENAME);
if meta_path.exists() {
return;
}
let meta = FolderSource {
source_video: video_path.to_string(),
created_at: Utc::now().to_rfc3339(),
};
if let Ok(text) = serde_json::to_string_pretty(&meta) {
let _ = std::fs::write(&meta_path, text);
}
}
pub fn read_folder_source(out_dir: &Path) -> Option<FolderSource> {
let meta_path = out_dir.join(FOLDER_SOURCE_FILENAME);
let text = std::fs::read_to_string(&meta_path).ok()?;
serde_json::from_str(&text).ok()
}
pub fn undo_one(frame_idx: u64, out_dir: &Path) -> Result<(), String> {
// Try both naming schemes so legacy extracts are also cleanable.
for pair in [
(image_filename(frame_idx), json_filename(frame_idx)),
(format!("frame_{:06}.jpg", frame_idx), format!("frame_{:06}.json", frame_idx)),
] {
let img = out_dir.join(&pair.0);
let json = out_dir.join(&pair.1);
if img.exists() {
std::fs::remove_file(&img).map_err(|e| format!("remove {}: {e}", img.display()))?;
}
if json.exists() {
std::fs::remove_file(&json).map_err(|e| format!("remove {}: {e}", json.display()))?;
}
}
Ok(())
}
pub fn list_extracted(out_dir: &Path) -> Result<Vec<u64>, String> {
if !out_dir.exists() {
return Ok(vec![]);
}
let mut out = Vec::new();
let entries = std::fs::read_dir(out_dir)
.map_err(|e| format!("read_dir {}: {e}", out_dir.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("dirent: {e}"))?;
if let Some(name) = entry.file_name().to_str() {
if let Some(n) = parse_frame_idx(name) {
out.push(n);
}
}
}
out.sort();
Ok(out)
}
pub fn write_classes_txt(out_dir: &Path, categories: &[Category]) -> Result<(), String> {
std::fs::create_dir_all(out_dir)
.map_err(|e| format!("mkdir {}: {e}", out_dir.display()))?;
let mut sorted = categories.to_vec();
sorted.sort_by_key(|c| c.id);
let mut text = String::new();
for c in &sorted {
text.push_str(&c.name);
text.push('\n');
}
let path = out_dir.join("classes.txt");
std::fs::write(&path, text).map_err(|e| format!("write {}: {e}", path.display()))
}
fn shape_to_record(shape: &Shape, author: &str, ts: &str) -> AnnotationRecord {
match shape {
Shape::Bbox {
class_id,
class_name,
bbox,
} => AnnotationRecord::Bbox {
id: Uuid::new_v4().to_string(),
class_id: *class_id,
class_name: class_name.clone(),
bbox: *bbox,
author: author.to_string(),
created_at: ts.to_string(),
updated_at: ts.to_string(),
},
Shape::Polygon {
class_id,
class_name,
points,
} => AnnotationRecord::Polygon {
id: Uuid::new_v4().to_string(),
class_id: *class_id,
class_name: class_name.clone(),
points: points.clone(),
author: author.to_string(),
created_at: ts.to_string(),
updated_at: ts.to_string(),
},
}
}

View File

@@ -0,0 +1,307 @@
mod annotate;
mod annotation;
mod coco;
mod decoder;
mod extract;
mod sam;
mod settings;
mod state;
mod user;
mod video;
use decoder::DecoderSession;
use serde::Serialize;
use state::AppState;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tauri::{ipc::Response, AppHandle, Emitter, State};
use video::VideoInfo;
#[tauri::command]
fn ping() -> String {
"pong".into()
}
#[tauri::command]
fn open_video(state: State<'_, AppState>, path: String) -> Result<VideoInfo, String> {
let p = PathBuf::from(&path);
if !p.exists() {
return Err(format!("file not found: {}", p.display()));
}
let info = video::probe(&p)?;
let mut guard = state.decoder.lock().map_err(|e| format!("lock: {e}"))?;
*guard = Some(DecoderSession::open(p, info.clone()));
Ok(info)
}
#[tauri::command]
fn close_video(state: State<'_, AppState>) -> Result<(), String> {
let mut guard = state.decoder.lock().map_err(|e| format!("lock: {e}"))?;
*guard = None;
Ok(())
}
#[tauri::command]
fn current_video(state: State<'_, AppState>) -> Result<Option<VideoInfo>, String> {
let guard = state.decoder.lock().map_err(|e| format!("lock: {e}"))?;
Ok(guard.as_ref().map(|d| d.info().clone()))
}
#[tauri::command]
fn decode_frame(state: State<'_, AppState>, frame_idx: u64) -> Result<Response, String> {
let bytes = {
let mut guard = state.decoder.lock().map_err(|e| format!("lock: {e}"))?;
let decoder = guard.as_mut().ok_or("no video loaded")?;
decoder.decode_at(frame_idx)?
};
Ok(Response::new(bytes))
}
#[tauri::command]
fn import_coco(path: String) -> Result<coco::ImportedCoco, String> {
let p = Path::new(&path);
if !p.exists() {
return Err(format!("file not found: {}", p.display()));
}
coco::import(p)
}
#[tauri::command]
fn set_cache_capacity(state: State<'_, AppState>, capacity: usize) -> Result<usize, String> {
let clamped = capacity.clamp(decoder::MIN_CACHE_CAPACITY, decoder::MAX_CACHE_CAPACITY);
let guard = state.decoder.lock().map_err(|e| format!("lock: {e}"))?;
if let Some(d) = guard.as_ref() {
d.set_cache_capacity(clamped);
}
Ok(clamped)
}
#[tauri::command]
fn extract_frame(
state: State<'_, AppState>,
frame_idx: u64,
out_dir: String,
shapes: Vec<coco::Shape>,
author: String,
) -> Result<(), String> {
let mut guard = state.decoder.lock().map_err(|e| format!("lock: {e}"))?;
let decoder = guard.as_mut().ok_or("no video loaded")?;
extract::extract_one(decoder, frame_idx, Path::new(&out_dir), &shapes, &author)
}
#[tauri::command]
fn undo_extract(frame_idx: u64, out_dir: String) -> Result<(), String> {
extract::undo_one(frame_idx, Path::new(&out_dir))
}
#[derive(Serialize, Clone)]
struct BulkProgress {
done: u64,
total: u64,
}
#[tauri::command]
fn bulk_extract(
state: State<'_, AppState>,
app: AppHandle,
start: u64,
end: u64,
out_dir: String,
shapes_by_frame: HashMap<String, Vec<coco::Shape>>,
author: String,
) -> Result<u64, String> {
if end < start {
return Err("end must be >= start".into());
}
let mut guard = state.decoder.lock().map_err(|e| format!("lock: {e}"))?;
let decoder = guard.as_mut().ok_or("no video loaded")?;
let dir = PathBuf::from(&out_dir);
let total = end - start + 1;
let empty: Vec<coco::Shape> = Vec::new();
let mut done: u64 = 0;
for idx in start..=end {
let shapes = shapes_by_frame
.get(&idx.to_string())
.unwrap_or(&empty);
extract::extract_one(decoder, idx, &dir, shapes, &author)?;
done += 1;
if done == 1 || done % 5 == 0 || done == total {
let _ = app.emit("bulk-extract-progress", BulkProgress { done, total });
}
}
Ok(done)
}
#[tauri::command]
fn bulk_undo(
app: AppHandle,
start: u64,
end: u64,
out_dir: String,
) -> Result<u64, String> {
if end < start {
return Err("end must be >= start".into());
}
let dir = PathBuf::from(&out_dir);
let total = end - start + 1;
let mut done: u64 = 0;
for idx in start..=end {
extract::undo_one(idx, &dir)?;
done += 1;
if done == 1 || done % 20 == 0 || done == total {
let _ = app.emit("bulk-undo-progress", BulkProgress { done, total });
}
}
Ok(done)
}
#[tauri::command]
fn list_extracted_frames(out_dir: String) -> Result<Vec<u64>, String> {
extract::list_extracted(Path::new(&out_dir))
}
#[tauri::command]
fn write_classes_txt(out_dir: String, categories: Vec<coco::Category>) -> Result<(), String> {
extract::write_classes_txt(Path::new(&out_dir), &categories)
}
#[tauri::command]
fn list_folder_images(folder: String) -> Result<Vec<annotate::ImageEntry>, String> {
annotate::list_images(Path::new(&folder))
}
#[tauri::command]
fn read_image_bytes(path: String) -> Result<Response, String> {
let bytes = annotate::read_image_bytes(Path::new(&path))?;
Ok(Response::new(bytes))
}
#[tauri::command]
fn load_image_annotations(
image_path: String,
) -> Result<Option<annotation::ImageAnnotations>, String> {
annotate::load_image_annotations(Path::new(&image_path))
}
#[tauri::command]
fn load_classes(folder: String) -> Result<Vec<String>, String> {
annotate::load_classes(Path::new(&folder))
}
#[tauri::command]
fn append_class(folder: String, name: String) -> Result<Vec<String>, String> {
annotate::append_class(Path::new(&folder), &name)
}
#[tauri::command]
fn delete_images(paths: Vec<String>) -> Result<u32, String> {
annotate::delete_images(&paths)
}
#[tauri::command]
async fn sam_health_check(url: String) -> Result<bool, String> {
sam::health_check(&url).await
}
#[tauri::command]
async fn sam_list_models(url: String) -> Result<sam::ModelList, String> {
sam::list_models(&url).await
}
#[tauri::command]
async fn sam_segment_bbox(
url: String,
image_path: String,
bbox: [f64; 4],
image_width: u32,
image_height: u32,
model: Option<String>,
) -> Result<Vec<[f64; 2]>, String> {
sam::segment_bbox(
&url,
Path::new(&image_path),
bbox,
image_width,
image_height,
model,
)
.await
}
#[tauri::command]
fn save_image_annotations(
image_path: String,
payload: annotation::ImageAnnotations,
) -> Result<(), String> {
let p = Path::new(&image_path);
let json_path = p.with_extension("json");
if payload.annotations.is_empty() {
if json_path.exists() {
std::fs::remove_file(&json_path)
.map_err(|e| format!("remove {}: {e}", json_path.display()))?;
}
return Ok(());
}
// Backfill source_video from folder-level source.json when the payload
// doesn't carry it — handles annotations authored via AnnotateMode that
// never went through extract_one.
let mut payload = payload;
if payload.source_video.is_none() {
if let Some(parent) = p.parent() {
if let Some(meta) = extract::read_folder_source(parent) {
payload.source_video = Some(meta.source_video);
}
}
}
let text = serde_json::to_string_pretty(&payload)
.map_err(|e| format!("serialize: {e}"))?;
std::fs::write(&json_path, text)
.map_err(|e| format!("write {}: {e}", json_path.display()))
}
#[tauri::command]
fn read_folder_source(folder: String) -> Option<String> {
extract::read_folder_source(Path::new(&folder)).map(|s| s.source_video)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.manage(AppState::new())
.invoke_handler(tauri::generate_handler![
ping,
user::current_user,
user::list_known_users,
user::set_current_user,
user::clear_current_user,
open_video,
close_video,
current_video,
decode_frame,
set_cache_capacity,
import_coco,
extract_frame,
undo_extract,
bulk_extract,
bulk_undo,
list_extracted_frames,
write_classes_txt,
list_folder_images,
read_image_bytes,
load_image_annotations,
load_classes,
save_image_annotations,
append_class,
delete_images,
read_folder_source,
settings::get_settings,
settings::save_settings,
sam_health_check,
sam_list_models,
sam_segment_bbox,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -0,0 +1,6 @@
// Prevents a console window on Windows release builds (harmless on Linux).
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
sam_tool_tauri_lib::run()
}

View File

@@ -0,0 +1,222 @@
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::Path;
use std::time::Duration;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ModelInfo {
pub id: String,
pub name: String,
pub family: String,
pub available: bool,
#[serde(default)]
pub checkpoint: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ModelList {
#[serde(default)]
pub default: Option<String>,
pub models: Vec<ModelInfo>,
}
/// Fetch the list of models the backend advertises. The Tauri Settings
/// modal uses this to populate a dropdown so users can pick which variant
/// to hit.
pub async fn list_models(url: &str) -> Result<ModelList, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(6))
.build()
.map_err(|e| format!("http client: {e}"))?;
let endpoint = format!("{}/models", url.trim_end_matches('/'));
let res = client
.get(&endpoint)
.send()
.await
.map_err(|e| format!("connect to {endpoint}: {e}"))?;
if !res.status().is_success() {
return Err(format!("GET /models → {}", res.status()));
}
res.json::<ModelList>()
.await
.map_err(|e| format!("parse /models: {e}"))
}
/// Minimal XML that makes a Label-Studio-ML SAM backend happy. The names
/// (`bbox`, `polygon`, `tag`) are referenced from the request / response by
/// their `from_name` / `to_name` fields — if your backend expects different
/// names we can wire them up.
const LABEL_CONFIG: &str = r##"<View>
<Image name="image" value="$image" zoom="true"/>
<BrushLabels name="tag" toName="image">
<Label value="Object" background="#ff0000"/>
</BrushLabels>
<RectangleLabels name="bbox" toName="image" smart="true">
<Label value="Object" background="#ff0000"/>
</RectangleLabels>
<PolygonLabels name="polygon" toName="image" smart="true">
<Label value="Object" background="#ff0000"/>
</PolygonLabels>
</View>"##;
/// Ping the SAM backend's root.
pub async fn health_check(url: &str) -> Result<bool, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(4))
.build()
.map_err(|e| format!("http client: {e}"))?;
let res = client
.get(url)
.send()
.await
.map_err(|e| format!("connect: {e}"))?;
Ok(res.status().is_success() || res.status().is_client_error())
}
/// POST the image + bbox prompt to a Label-Studio-ML SAM backend, return the
/// polygon in **image-pixel** coords.
///
/// Wire protocol (LS-ML):
/// ```json
/// {
/// "tasks": [{ "id": 1, "data": { "image": "data:image/jpeg;base64,..." } }],
/// "label_config": "<View>...</View>",
/// "params": {
/// "context": {
/// "result": [{
/// "original_width": W, "original_height": H, "image_rotation": 0,
/// "value": { "x": %, "y": %, "width": %, "height": %,
/// "rotation": 0, "rectanglelabels": ["Object"] },
/// "id": "box_0",
/// "from_name": "bbox", "to_name": "image",
/// "type": "rectanglelabels"
/// }]
/// }
/// }
/// }
/// ```
pub async fn segment_bbox(
url: &str,
image_path: &Path,
bbox: [f64; 4],
image_width: u32,
image_height: u32,
model: Option<String>,
) -> Result<Vec<[f64; 2]>, String> {
let image_bytes = std::fs::read(image_path)
.map_err(|e| format!("read image {}: {e}", image_path.display()))?;
let image_b64 = base64::engine::general_purpose::STANDARD.encode(&image_bytes);
let data_url = format!("data:image/jpeg;base64,{}", image_b64);
let [x, y, w, h] = bbox;
let width_f = image_width.max(1) as f64;
let height_f = image_height.max(1) as f64;
let x_pct = (x / width_f) * 100.0;
let y_pct = (y / height_f) * 100.0;
let w_pct = (w / width_f) * 100.0;
let h_pct = (h / height_f) * 100.0;
let body = json!({
"tasks": [{
"id": 1,
"data": { "image": data_url }
}],
"label_config": LABEL_CONFIG,
"params": {
"context": {
"result": [{
"original_width": image_width,
"original_height": image_height,
"image_rotation": 0,
"value": {
"x": x_pct,
"y": y_pct,
"width": w_pct,
"height": h_pct,
"rotation": 0,
"rectanglelabels": ["Object"]
},
"id": "box_0",
"from_name": "bbox",
"to_name": "image",
"type": "rectanglelabels"
}]
}
}
});
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.map_err(|e| format!("http client: {e}"))?;
let endpoint = format!("{}/predict", url.trim_end_matches('/'));
let mut req = client.post(&endpoint);
if let Some(ref m) = model {
if !m.is_empty() {
req = req.query(&[("model", m.as_str())]);
}
}
let res = req
.json(&body)
.send()
.await
.map_err(|e| format!("request to {endpoint}: {e}"))?;
if !res.status().is_success() {
let status = res.status();
let body_text = res.text().await.unwrap_or_default();
return Err(format!("SAM {}{}", status, body_text));
}
let raw: Value = res
.json()
.await
.map_err(|e| format!("parse SAM response: {e}"))?;
extract_polygon(&raw, width_f, height_f)
.ok_or_else(|| format!("unrecognized SAM response shape: {}", raw))
}
/// Walk the JSON tree looking for a polygon — LS-ML usually returns
/// `{ results: [{ result: [{ value: { points: [[x%, y%], ...] } }] }] }`,
/// but we also accept plain `{ polygon: [[x, y], ...] }` in pixels.
fn extract_polygon(v: &Value, w: f64, h: f64) -> Option<Vec<[f64; 2]>> {
// Plain shape: pixel polygon at the top level.
if let Some(poly) = v.get("polygon").and_then(|p| p.as_array()) {
let out = parse_points(poly, 1.0, 1.0);
if out.len() >= 3 {
return Some(out);
}
}
// LS-ML shape.
let results = v.get("results").and_then(|r| r.as_array())?;
let first = results.first()?;
let inner = first.get("result").and_then(|r| r.as_array())?;
for entry in inner {
let value = entry.get("value")?;
if let Some(points) = value.get("points").and_then(|p| p.as_array()) {
// LS polygons are in percentages of the original image size.
let out = parse_points(points, w / 100.0, h / 100.0);
if out.len() >= 3 {
return Some(out);
}
}
}
None
}
fn parse_points(arr: &[Value], sx: f64, sy: f64) -> Vec<[f64; 2]> {
let mut out = Vec::with_capacity(arr.len());
for pt in arr {
if let Some(p) = pt.as_array() {
if p.len() >= 2 {
if let (Some(x), Some(y)) = (p[0].as_f64(), p[1].as_f64()) {
out.push([x * sx, y * sy]);
}
}
}
}
out
}

View File

@@ -0,0 +1,71 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
#[derive(Serialize, Deserialize, Clone)]
pub struct Settings {
#[serde(default = "default_sam_url")]
pub sam_url: String,
#[serde(default)]
pub sam_enabled: bool,
/// Model id chosen from the backend's /models list (e.g.
/// "sam2.1_hiera_small"). `None` lets the backend pick its default.
#[serde(default)]
pub sam_model: Option<String>,
#[serde(default = "default_cache_capacity")]
pub cache_capacity: usize,
}
fn default_sam_url() -> String {
"http://localhost:9090".into()
}
fn default_cache_capacity() -> usize {
500
}
impl Default for Settings {
fn default() -> Self {
Self {
sam_url: default_sam_url(),
sam_enabled: false,
sam_model: None,
cache_capacity: default_cache_capacity(),
}
}
}
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_local_data_dir()
.map_err(|e| format!("app data dir: {e}"))?;
fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {e}", dir.display()))?;
Ok(dir.join("settings.json"))
}
pub fn load(app: &AppHandle) -> Result<Settings, String> {
let path = settings_path(app)?;
if !path.exists() {
return Ok(Settings::default());
}
let text = fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
serde_json::from_str(&text).map_err(|e| format!("parse settings: {e}"))
}
pub fn save(app: &AppHandle, s: &Settings) -> Result<(), String> {
let path = settings_path(app)?;
let text = serde_json::to_string_pretty(s).map_err(|e| format!("serialize: {e}"))?;
fs::write(&path, text).map_err(|e| format!("write {}: {e}", path.display()))
}
#[tauri::command]
pub fn get_settings(app: AppHandle) -> Result<Settings, String> {
load(&app)
}
#[tauri::command]
pub fn save_settings(app: AppHandle, settings: Settings) -> Result<(), String> {
save(&app, &settings)
}

View File

@@ -0,0 +1,14 @@
use crate::decoder::DecoderSession;
use std::sync::Mutex;
pub struct AppState {
pub decoder: Mutex<Option<DecoderSession>>,
}
impl AppState {
pub fn new() -> Self {
Self {
decoder: Mutex::new(None),
}
}
}

View File

@@ -0,0 +1,76 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
#[derive(Default, Serialize, Deserialize)]
struct UserStore {
current_user: Option<String>,
#[serde(default)]
known_users: Vec<String>,
}
fn store_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_local_data_dir()
.map_err(|e| format!("could not resolve app data dir: {e}"))?;
fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {e}", dir.display()))?;
Ok(dir.join("users.json"))
}
fn load(app: &AppHandle) -> Result<UserStore, String> {
let path = store_path(app)?;
if !path.exists() {
return Ok(UserStore::default());
}
let text = fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
serde_json::from_str(&text).map_err(|e| format!("parse users.json: {e}"))
}
fn save(app: &AppHandle, store: &UserStore) -> Result<(), String> {
let path = store_path(app)?;
let text = serde_json::to_string_pretty(store).map_err(|e| format!("serialize: {e}"))?;
fs::write(&path, text).map_err(|e| format!("write {}: {e}", path.display()))
}
fn normalize(name: &str) -> Result<String, String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("username cannot be empty".into());
}
if trimmed.len() > 64 {
return Err("username too long (max 64 chars)".into());
}
Ok(trimmed.to_string())
}
#[tauri::command]
pub fn current_user(app: AppHandle) -> Result<Option<String>, String> {
Ok(load(&app)?.current_user)
}
#[tauri::command]
pub fn list_known_users(app: AppHandle) -> Result<Vec<String>, String> {
Ok(load(&app)?.known_users)
}
#[tauri::command]
pub fn set_current_user(app: AppHandle, name: String) -> Result<String, String> {
let name = normalize(&name)?;
let mut store = load(&app)?;
if !store.known_users.iter().any(|u| u == &name) {
store.known_users.push(name.clone());
store.known_users.sort();
}
store.current_user = Some(name.clone());
save(&app, &store)?;
Ok(name)
}
#[tauri::command]
pub fn clear_current_user(app: AppHandle) -> Result<(), String> {
let mut store = load(&app)?;
store.current_user = None;
save(&app, &store)
}

View File

@@ -0,0 +1,106 @@
use serde::Serialize;
use std::path::Path;
use std::process::Command;
#[derive(Serialize, Clone, Debug)]
pub struct VideoInfo {
pub path: String,
pub width: u32,
pub height: u32,
pub fps: f64,
pub total_frames: u64,
pub duration_s: f64,
/// Suggested output folder for this video, derived from the video path so
/// the same folder is produced on each re-open. Frontend uses this to
/// auto-restore the extracted-frame set without the user re-picking.
pub default_output_folder: String,
}
pub fn default_output_folder(path: &Path) -> String {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("video");
let parent = path.parent().unwrap_or_else(|| Path::new("."));
parent
.join(format!("{stem}_extracted"))
.display()
.to_string()
}
fn parse_rational(s: &str) -> Option<f64> {
let (n, d) = s.split_once('/')?;
let n: f64 = n.parse().ok()?;
let d: f64 = d.parse().ok()?;
if d == 0.0 {
None
} else {
Some(n / d)
}
}
pub fn probe(path: &Path) -> Result<VideoInfo, String> {
let path_str = path.to_str().ok_or_else(|| "invalid path".to_string())?;
let out = Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,r_frame_rate,avg_frame_rate,nb_frames,duration",
"-show_entries",
"format=duration",
"-of",
"json",
path_str,
])
.output()
.map_err(|e| format!("ffprobe spawn failed: {e}"))?;
if !out.status.success() {
return Err(format!(
"ffprobe exited {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
));
}
let json: serde_json::Value =
serde_json::from_slice(&out.stdout).map_err(|e| format!("ffprobe json: {e}"))?;
let stream = &json["streams"][0];
if stream.is_null() {
return Err("no video stream found".into());
}
let width = stream["width"]
.as_u64()
.ok_or("stream.width missing")? as u32;
let height = stream["height"]
.as_u64()
.ok_or("stream.height missing")? as u32;
let fps = stream["r_frame_rate"]
.as_str()
.and_then(parse_rational)
.or_else(|| stream["avg_frame_rate"].as_str().and_then(parse_rational))
.ok_or("cannot parse fps")?;
let duration_s = stream["duration"]
.as_str()
.and_then(|s| s.parse::<f64>().ok())
.or_else(|| {
json["format"]["duration"]
.as_str()
.and_then(|s| s.parse::<f64>().ok())
})
.ok_or("cannot parse duration")?;
let total_frames = stream["nb_frames"]
.as_str()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or_else(|| (fps * duration_s).round() as u64);
Ok(VideoInfo {
path: path.display().to_string(),
width,
height,
fps,
total_frames,
duration_s,
default_output_folder: default_output_folder(path),
})
}

View File

@@ -0,0 +1,33 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "SAM Tool",
"version": "0.1.0",
"identifier": "com.seekright.samtool",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "SAM Tool",
"width": 1280,
"height": 860,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": false,
"targets": "all",
"icon": [
"icons/icon.png"
]
}
}

104
sam-tool-tauri/src/App.tsx Normal file
View 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>
);
}

View 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

View 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

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
});

View 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})`;
}

View 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;
}
}

View 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>
);
}

View 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

File diff suppressed because it is too large Load Diff

94
sam-tool-tauri/src/ipc.ts Normal file
View 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,
}),
};

View 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>
);

File diff suppressed because it is too large Load Diff

View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const host = process.env.TAURI_DEV_HOST;
export default defineConfig(async () => ({
plugins: [react()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? { protocol: "ws", host, port: 1421 }
: undefined,
watch: { ignored: ["**/src-tauri/**"] },
},
}));