feat: frame-based positioning and mark system foundation

Core Changes:
- Fix SearchView to use start_frame/end_frame directly (no time*fps conversion)
- Add hard_delete support to delete_trace API
- VideoPlayer: Main timeline + Mark system foundation
- Proxy: Add local routes for auth, media, identity-matches, cluster-results
- Add .gitignore to exclude build artifacts and dependencies

Design Documents:
- Multi-track Mark system design (.opencode/plans/)
- Video editing positioning standards research

Files Modified:
- src/views/SearchView.vue: Frame positioning, ensureMinDuration (240 frames)
- src/views/PeopleView.vue: batchDeleteGroups with hard_delete
- src/api/index.ts: delete_trace with hard_delete body
- src/components/VideoPlayer.vue: Timeline + Mark UI
- src-tauri/src/proxy.rs: New local routes
- AGENTS.md: Update documentation
This commit is contained in:
2026-07-24 20:19:47 +08:00
parent 299f58e883
commit 5951aca086
38 changed files with 12107 additions and 1666 deletions

View File

@@ -7,13 +7,28 @@ mod db;
use serde::Serialize;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use std::sync::Mutex;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::num::NonZeroUsize;
use lru::LruCache;
use image::GenericImageView;
use image::DynamicImage;
use tokio::sync::Semaphore;
static THUMB_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
static PROFILE_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
static RAW_DIM_CACHE: Mutex<Option<HashMap<String, (u32, u32)>>> = Mutex::new(None);
/// Per-file semaphore: only one thumbnail extraction per file at a time,
/// preventing ffmpeg storms when many traces hit the same file concurrently.
static THUMB_SEM_MAP: Mutex<Option<HashMap<String, Arc<Semaphore>>>> = Mutex::new(None);
fn acquire_thumb_semaphore(uuid: &str) -> Arc<Semaphore> {
let mut map = THUMB_SEM_MAP.lock().unwrap();
let map = map.get_or_insert_with(HashMap::new);
map.entry(uuid.to_string())
.or_insert_with(|| Arc::new(Semaphore::new(1)))
.clone()
}
fn get_client() -> reqwest::Client {
reqwest::Client::builder()
@@ -59,6 +74,8 @@ struct PersonInfo {
status: String,
metadata: serde_json::Value,
file_uuids: Vec<String>,
source: String,
tmdb_id: Option<i64>,
}
#[derive(Serialize)]
@@ -91,6 +108,8 @@ struct FileDetail {
file_name: String,
fps: f64,
duration: f64,
width: u32,
height: u32,
}
#[derive(Serialize)]
@@ -355,7 +374,7 @@ async fn unregister_file(file_uuid: String, delete_output_files: Option<bool>) -
async fn get_people(_page: usize, _per_page: usize) -> Result<Vec<PersonInfo>, String> {
eprintln!("[get_people] called");
let mut people = Vec::new();
for page in 1u32..=2 {
for page in 1u32..=10 {
let url = format!("{}/api/v1/identities?api_key={}&page={}&per_page=100", CORE_API, API_KEY, page);
let resp = match get_client().get(&url).send().await {
Ok(r) => r,
@@ -398,6 +417,8 @@ async fn get_people(_page: usize, _per_page: usize) -> Result<Vec<PersonInfo>, S
status: i["status"].as_str().unwrap_or_else(|| i["metadata"]["status"].as_str().unwrap_or("pending")).to_string(),
metadata: i["metadata"].clone(),
file_uuids: i["file_uuids"].as_array().map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default(),
source: i["source"].as_str().unwrap_or("").to_string(),
tmdb_id: i["tmdb_id"].as_i64(),
});
}
}
@@ -499,9 +520,12 @@ async fn get_traces(uuid: String, per_page: usize, page: Option<u32>) -> Result<
}
#[tauri::command(rename_all = "camelCase")]
async fn get_unassigned_traces(page: usize, per_page: usize) -> Result<serde_json::Value, String> {
async fn get_unassigned_traces(page: usize, per_page: usize, file_uuid: Option<String>) -> Result<serde_json::Value, String> {
let page_size = per_page.min(100);
let url = format!("{}/api/v1/traces/unassigned?api_key={}&page={}&page_size={}", CORE_API, API_KEY, page, page_size);
let mut url = format!("{}/api/v1/traces/unassigned?api_key={}&page={}&page_size={}", CORE_API, API_KEY, page, page_size);
if let Some(ref uuid) = file_uuid {
url.push_str(&format!("&file_uuid={}", uuid));
}
let resp = get_client().get(&url).send().await
.map_err(|e| format!("Unassigned traces request failed: {}", e))?;
let json: serde_json::Value = resp.json().await
@@ -521,9 +545,42 @@ async fn get_file_info(uuid: String) -> Result<FileDetail, String> {
file_name: json["file_name"].as_str().unwrap_or("").to_string(),
fps: json["fps"].as_f64().unwrap_or(24.0),
duration: json["duration"].as_f64().unwrap_or(0.0),
width: json["width"].as_u64().unwrap_or(0) as u32,
height: json["height"].as_u64().unwrap_or(0) as u32,
})
}
/// Read EXIF orientation and physically rotate pixels, stripping orientation tag.
/// This ensures all browsers see the same pixel dimensions regardless of EXIF handling.
fn apply_orientation(bytes: &[u8]) -> Result<Vec<u8>, String> {
use std::io::{Cursor, BufReader};
// Read EXIF orientation tag
let orientation = {
let cursor = Cursor::new(bytes);
let mut reader = BufReader::new(cursor);
match exif::Reader::new().read_from_container(&mut reader) {
Ok(exif) => exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)
.and_then(|f| f.value.get_uint(0))
.unwrap_or(1),
Err(_) => 1,
}
};
let img = image::load_from_memory(bytes).map_err(|e| format!("Image decode: {}", e))?;
let img = match orientation {
2 => img.fliph(),
3 => img.rotate180(),
4 => img.fliph().rotate180(),
5 => img.rotate90().flipv(),
6 => img.rotate270(),
7 => img.rotate90().fliph(),
8 => img.rotate90(),
_ => img,
};
let mut buf = Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Jpeg).map_err(|e| format!("JPEG encode: {}", e))?;
Ok(buf.into_inner())
}
#[tauri::command(rename_all = "camelCase")]
async fn get_thumbnail(uuid: String, frame: u32) -> Result<String, String> {
let key = format!("{}:{}", uuid, frame);
@@ -535,23 +592,8 @@ async fn get_thumbnail(uuid: String, frame: u32) -> Result<String, String> {
}
}
}
let url = format!("{}/api/v1/file/{}/thumbnail?api_key={}&frame={}", CORE_API, uuid, API_KEY, frame);
let bytes = get_client().get(&url).send().await
.map_err(|e| format!("Thumbnail request failed: {}", e))?
.bytes().await
.map_err(|e| format!("Thumbnail read failed: {}", e))?;
let result = format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes));
{
let mut cache = THUMB_CACHE.lock().unwrap();
let cache = cache.get_or_insert_with(|| LruCache::new(NonZeroUsize::new(500).unwrap()));
cache.put(key, result.clone());
}
Ok(result)
}
#[tauri::command(rename_all = "camelCase")]
async fn get_face_thumbnail(uuid: String, frame: u32, bbox_x: Option<f64>, bbox_y: Option<f64>, bbox_w: Option<f64>, bbox_h: Option<f64>) -> Result<String, String> {
let key = format!("face:{}:{}:{:?}:{:?}:{:?}:{:?}", uuid, frame, bbox_x, bbox_y, bbox_w, bbox_h);
let _permit = acquire_thumb_semaphore(&uuid).acquire_owned().await.unwrap();
// Double-check cache after acquiring semaphore
{
let mut cache = THUMB_CACHE.lock().unwrap();
if let Some(cache) = cache.as_mut() {
@@ -565,15 +607,138 @@ async fn get_face_thumbnail(uuid: String, frame: u32, bbox_x: Option<f64>, bbox_
.map_err(|e| format!("Thumbnail request failed: {}", e))?
.bytes().await
.map_err(|e| format!("Thumbnail read failed: {}", e))?;
let corrected = apply_orientation(&bytes)?;
let result = format!("data:image/jpeg;base64,{}", STANDARD.encode(&corrected));
{
let mut cache = THUMB_CACHE.lock().unwrap();
let cache = cache.get_or_insert_with(|| LruCache::new(NonZeroUsize::new(500).unwrap()));
cache.put(key, result.clone());
}
Ok(result)
}
#[tauri::command(rename_all = "camelCase")]
async fn get_file_thumbnail_by_path(path: String, frame: u32) -> Result<String, String> {
if !std::path::Path::new(&path).exists() {
return Err("File not found".into());
}
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
let is_image = matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "gif" | "webp");
if is_image {
let bytes = std::fs::read(&path).map_err(|e| format!("Read failed: {}", e))?;
let mime = match ext.as_str() {
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
_ => "image/jpeg",
};
Ok(format!("data:{};base64,{}", mime, STANDARD.encode(&bytes)))
} else {
let time_sec = frame as f64 / 24.0;
let output = tokio::process::Command::new("ffmpeg")
.args([
"-ss", &time_sec.to_string(),
"-i", &path,
"-vframes", "1",
"-q:v", "2",
"-f", "mjpeg",
"-"
])
.output()
.await
.map_err(|e| format!("ffmpeg failed: {}", e))?;
if output.status.success() {
Ok(format!("data:image/jpeg;base64,{}", STANDARD.encode(&output.stdout)))
} else {
Err("Thumbnail extraction failed".into())
}
}
}
/// Fetch raw video dimensions from Core API with in-memory cache.
/// Returns `None` if the request fails or dimensions are 0.
async fn fetch_raw_dimensions(uuid: &str) -> Option<(u32, u32)> {
{
let cache = RAW_DIM_CACHE.lock().unwrap();
if let Some(cache) = cache.as_ref() {
if let Some(dims) = cache.get(uuid) {
return Some(*dims);
}
}
}
let url = format!("{}/api/v1/file/{}?api_key={}", CORE_API, uuid, API_KEY);
let result = get_client().get(&url).send().await.ok()?
.json::<serde_json::Value>().await.ok()?;
let w = result["width"].as_u64().unwrap_or(0) as u32;
let h = result["height"].as_u64().unwrap_or(0) as u32;
if w == 0 || h == 0 {
return None;
}
{
let mut cache = RAW_DIM_CACHE.lock().unwrap();
let cache = cache.get_or_insert_with(HashMap::new);
cache.insert(uuid.to_string(), (w, h));
}
Some((w, h))
}
#[tauri::command(rename_all = "camelCase")]
async fn get_face_thumbnail(uuid: String, frame: u32, bbox_x: Option<f64>, bbox_y: Option<f64>, bbox_w: Option<f64>, bbox_h: Option<f64>) -> Result<String, String> {
let key = format!("face:{}:{}:{:?}:{:?}:{:?}:{:?}", uuid, frame, bbox_x, bbox_y, bbox_w, bbox_h);
{
let mut cache = THUMB_CACHE.lock().unwrap();
if let Some(cache) = cache.as_mut() {
if let Some(cached) = cache.get(&key) {
return Ok(cached.clone());
}
}
}
let _permit = acquire_thumb_semaphore(&uuid).acquire_owned().await.unwrap();
// Double-check cache after acquiring semaphore
{
let mut cache = THUMB_CACHE.lock().unwrap();
if let Some(cache) = cache.as_mut() {
if let Some(cached) = cache.get(&key) {
return Ok(cached.clone());
}
}
}
let url = format!("{}/api/v1/file/{}/thumbnail?api_key={}&frame={}", CORE_API, uuid, API_KEY, frame);
let bytes = get_client().get(&url).send().await
.map_err(|e| format!("Thumbnail request failed: {}", e))?
.bytes().await
.map_err(|e| format!("Thumbnail read failed: {}", e))?;
let result = if let (Some(bx), Some(by), Some(bw), Some(bh)) = (bbox_x, bbox_y, bbox_w, bbox_h) {
let img = image::load_from_memory(&bytes).map_err(|e| format!("Image decode failed: {}", e))?;
let (w, h) = img.dimensions();
// Determine if CCW rotation is needed by comparing raw video dimensions
// against thumbnail dimensions. If raw != thumb, Core API rotated the frame
// and bbox is in raw space. Fall back to aspect ratio if raw dims unavailable.
let raw_dims = fetch_raw_dimensions(&uuid).await;
let needs_ccw = match raw_dims {
Some((rw, rh)) => (rw, rh) != (w, h),
None => w < h && (w as f64) / (h as f64) < 0.62,
};
let (bx, by, bw, bh) = if !(bx <= 1.0 && by <= 1.0 && bw <= 1.0 && bh <= 1.0) && needs_ccw {
let _rw = h as f64;
let rh = w as f64;
(rh - by - bh, bx, bh, bw)
} else {
(bx, by, bw, bh)
};
let (px, py, pw, ph) = if bx <= 1.0 && by <= 1.0 && bw <= 1.0 && bh <= 1.0 {
((bx * w as f64) as u32, (by * h as f64) as u32, (bw * w as f64) as u32, (bh * h as f64) as u32)
} else {
(bx as u32, by as u32, bw as u32, bh as u32)
};
eprintln!("[get_face_thumbnail] {} frame={} img={}x{} bbox={},{},{},{} -> pixel={},{},{},{} crop={},{},{},{}",
uuid, frame, w, h, bx, by, bw, bh, px, py, pw, ph,
px.min(w.saturating_sub(1)), py.min(h.saturating_sub(1)),
pw.min(w.saturating_sub(px.min(w.saturating_sub(1)))).max(1),
ph.min(h.saturating_sub(py.min(h.saturating_sub(1)))).max(1));
let cx = px.min(w.saturating_sub(1));
let cy = py.min(h.saturating_sub(1));
let cw = pw.min(w.saturating_sub(cx)).max(1);
@@ -1124,8 +1289,122 @@ async fn merge_history(source_uuid: Option<String>, target_uuid: Option<String>,
}
// === Service Management ===
#[tauri::command(rename_all = "camelCase")]
async fn stop_service(name: String) -> Result<String, String> {
let port = match name.as_str() {
"Core API" | "core" => 3002,
"MarkBase" | "markbase" => 11438,
"Proxy" | "proxy" => 8888,
"MarkBaseEngine" | "mbe" => 8080,
"SFTP" => 2024,
"SMB" => 4445,
_ => return Err(format!("Unknown service: {}", name)),
};
let output = std::process::Command::new("sh")
.arg("-c")
.arg(format!("lsof -ti :{} | xargs kill -9 2>/dev/null; echo 'stopped'", port))
.output()
.map_err(|e| format!("Failed to stop service: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
Ok(stdout.trim().to_string())
}
#[tauri::command(rename_all = "camelCase")]
async fn start_service(name: String) -> Result<String, String> {
let cmd = match name.as_str() {
"MarkBase" | "markbase" => {
"cd /Users/accusys/markbase && nohup cargo run --bin markbase-core -- web-start --port 11438 > /tmp/markbase_web.log 2>&1 &"
}
"Core API" | "core" => {
"cd /Users/accusys/momentry_core && nohup bash run-server-3002.sh > /tmp/momentry_start.log 2>&1 &"
}
"Proxy" | "proxy" => {
"cd /Users/accusys/momentry_studio/src-tauri && nohup ./target/debug/momentry-proxy > /tmp/momentry_proxy.log 2>&1 &"
}
"MarkBaseEngine" | "mbe" => {
"cd /Users/accusys/MarkBaseEngine && nohup .build/debug/MarkBaseServer > /tmp/markbase_engine.log 2>&1 &"
}
"SFTP" => {
"cd /Users/accusys/markbase && nohup cargo run --bin markbase-core -- ssh-start --port 2024 > /tmp/markbase_ssh.log 2>&1 &"
}
"SMB" => {
"cd /Users/accusys/markbase && nohup cargo run --bin markbase-core --features smb-server -- smb-start --port 4445 --share-name markbase --root /Users/accusys/momentry/var/sftpgo/data --user demo:demo123 > /tmp/markbase_smb.log 2>&1 &"
}
_ => return Err(format!("Unknown service: {}", name)),
};
let output = std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.output()
.map_err(|e| format!("Failed to start service: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Start failed: {}", stderr));
}
let port = match name.as_str() {
"MarkBase" | "markbase" => 11438,
"Core API" | "core" => 3002,
"Proxy" | "proxy" => 8888,
"MarkBaseEngine" | "mbe" => 8080,
"SFTP" => 2024,
"SMB" => 4445,
_ => 0,
};
if port > 0 {
// MarkBaseEngine takes 5-10s to load the model; skip port check
if port == 8080 {
return Ok("started".to_string());
}
// Wait a moment then check if port is up
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let check = std::process::Command::new("sh")
.arg("-c")
.arg(format!("lsof -ti :{} 2>/dev/null", port))
.output()
.map_err(|e| format!("Check failed: {}", e))?;
if check.stdout.is_empty() {
return Err(format!("Service {} failed to start on port {}", name, port));
}
}
Ok("started".to_string())
}
// === Admin Commands ===
#[tauri::command(rename_all = "camelCase")]
async fn login_user(username: String, password: String) -> Result<db::LoginResult, String> {
match db::verify_user(&username, &password) {
Ok(Some(user)) => {
let token = uuid::Uuid::new_v4().to_string();
Ok(db::LoginResult {
success: true,
user: Some(user),
token: Some(token),
error: None,
})
}
Ok(None) => Ok(db::LoginResult {
success: false,
user: None,
token: None,
error: Some("Invalid username or password".to_string()),
}),
Err(e) => Ok(db::LoginResult {
success: false,
user: None,
token: None,
error: Some(e),
}),
}
}
#[tauri::command(rename_all = "camelCase")]
async fn get_current_user(username: String) -> Result<Option<db::User>, String> {
db::get_user_by_username(&username)
}
#[tauri::command(rename_all = "camelCase")]
async fn list_admin_users() -> Result<serde_json::Value, String> {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/Users/accusys"));
@@ -1161,9 +1440,9 @@ async fn get_system_stats() -> Result<serde_json::Value, String> {
#[tauri::command(rename_all = "camelCase")]
async fn list_shares() -> Result<serde_json::Value, String> {
let shares = vec![
serde_json::json!({"name": "SMB", "port": 4445, "status": "running"}),
serde_json::json!({"name": "SFTP", "port": 2024, "status": "running"}),
serde_json::json!({"name": "WebDAV", "port": 11438, "status": "running"}),
serde_json::json!({"name": "MarkBase Web", "port": 11438, "status": "running", "protocol": "HTTP/WebDAV", "url": "http://localhost:11438"}),
serde_json::json!({"name": "SFTP", "port": 2024, "status": "running", "protocol": "SSH/SFTP"}),
serde_json::json!({"name": "SMB", "port": 4445, "status": "running", "protocol": "SMB"}),
];
Ok(serde_json::Value::Array(shares))
}
@@ -1256,6 +1535,7 @@ fn main() {
get_file_info,
get_thumbnail,
get_face_thumbnail,
get_file_thumbnail_by_path,
get_identity_profile,
update_identity_name,
update_identity_status,
@@ -1288,12 +1568,16 @@ fn main() {
db::get_bookmarks,
db::save_bookmark,
db::delete_bookmark,
login_user,
get_current_user,
list_admin_users,
get_system_stats,
list_shares,
list_client_files,
mkdir_client,
rm_client
rm_client,
stop_service,
start_service
])
.run(tauri::generate_context!())
.expect("error while running tauri application");