first version
This commit is contained in:
2
sam-tool-tauri/src-tauri/.gitignore
vendored
Normal file
2
sam-tool-tauri/src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
target
|
||||
gen/schemas
|
||||
5365
sam-tool-tauri/src-tauri/Cargo.lock
generated
Normal file
5365
sam-tool-tauri/src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
sam-tool-tauri/src-tauri/Cargo.toml
Normal file
35
sam-tool-tauri/src-tauri/Cargo.toml
Normal 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
|
||||
3
sam-tool-tauri/src-tauri/build.rs
Normal file
3
sam-tool-tauri/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
11
sam-tool-tauri/src-tauri/capabilities/default.json
Normal file
11
sam-tool-tauri/src-tauri/capabilities/default.json
Normal 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"
|
||||
]
|
||||
}
|
||||
BIN
sam-tool-tauri/src-tauri/icons/128x128.png
Normal file
BIN
sam-tool-tauri/src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
BIN
sam-tool-tauri/src-tauri/icons/128x128@2x.png
Normal file
BIN
sam-tool-tauri/src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
sam-tool-tauri/src-tauri/icons/32x32.png
Normal file
BIN
sam-tool-tauri/src-tauri/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 663 B |
BIN
sam-tool-tauri/src-tauri/icons/icon.png
Normal file
BIN
sam-tool-tauri/src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
169
sam-tool-tauri/src-tauri/src/annotate.rs
Normal file
169
sam-tool-tauri/src-tauri/src/annotate.rs
Normal 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)
|
||||
}
|
||||
43
sam-tool-tauri/src-tauri/src/annotation.rs
Normal file
43
sam-tool-tauri/src-tauri/src/annotation.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
359
sam-tool-tauri/src-tauri/src/coco.rs
Normal file
359
sam-tool-tauri/src-tauri/src/coco.rs
Normal 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
|
||||
}
|
||||
351
sam-tool-tauri/src-tauri/src/decoder.rs
Normal file
351
sam-tool-tauri/src-tauri/src/decoder.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
188
sam-tool-tauri/src-tauri/src/extract.rs
Normal file
188
sam-tool-tauri/src-tauri/src/extract.rs
Normal 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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
307
sam-tool-tauri/src-tauri/src/lib.rs
Normal file
307
sam-tool-tauri/src-tauri/src/lib.rs
Normal 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");
|
||||
}
|
||||
6
sam-tool-tauri/src-tauri/src/main.rs
Normal file
6
sam-tool-tauri/src-tauri/src/main.rs
Normal 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()
|
||||
}
|
||||
222
sam-tool-tauri/src-tauri/src/sam.rs
Normal file
222
sam-tool-tauri/src-tauri/src/sam.rs
Normal 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
|
||||
}
|
||||
71
sam-tool-tauri/src-tauri/src/settings.rs
Normal file
71
sam-tool-tauri/src-tauri/src/settings.rs
Normal 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)
|
||||
}
|
||||
14
sam-tool-tauri/src-tauri/src/state.rs
Normal file
14
sam-tool-tauri/src-tauri/src/state.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
76
sam-tool-tauri/src-tauri/src/user.rs
Normal file
76
sam-tool-tauri/src-tauri/src/user.rs
Normal 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)
|
||||
}
|
||||
106
sam-tool-tauri/src-tauri/src/video.rs
Normal file
106
sam-tool-tauri/src-tauri/src/video.rs
Normal 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),
|
||||
})
|
||||
}
|
||||
33
sam-tool-tauri/src-tauri/tauri.conf.json
Normal file
33
sam-tool-tauri/src-tauri/tauri.conf.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user