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:
109
src-tauri/Cargo.lock
generated
109
src-tauri/Cargo.lock
generated
@@ -3509,6 +3509,15 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kamadak-exif"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837"
|
||||
dependencies = [
|
||||
"mutate_once",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.2.0"
|
||||
@@ -3704,6 +3713,15 @@ dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
|
||||
dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.13.1"
|
||||
@@ -3747,6 +3765,7 @@ dependencies = [
|
||||
"http 1.4.2",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"lz4_flex 0.11.6",
|
||||
"md5 0.8.0",
|
||||
"nix 0.29.0",
|
||||
"once_cell",
|
||||
@@ -3768,18 +3787,21 @@ dependencies = [
|
||||
"smb2",
|
||||
"ssh-key",
|
||||
"ssh2",
|
||||
"sysinfo",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tokio-util",
|
||||
"toml 0.8.2",
|
||||
"tower-http 0.5.2",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
"url",
|
||||
"uuid",
|
||||
"x25519-dalek",
|
||||
"xattr",
|
||||
"xmltree",
|
||||
"zip",
|
||||
"zstd 0.13.3",
|
||||
@@ -3933,14 +3955,17 @@ dependencies = [
|
||||
name = "momentry-studio"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"argon2 0.5.3",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"futures",
|
||||
"image",
|
||||
"kamadak-exif",
|
||||
"lru 0.12.5",
|
||||
"markbase-core",
|
||||
"rand 0.8.6",
|
||||
"reqwest 0.11.27",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
@@ -3992,6 +4017,12 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mutate_once"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
@@ -4063,6 +4094,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -6509,7 +6549,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"hmac 0.13.0",
|
||||
"log",
|
||||
"lz4_flex",
|
||||
"lz4_flex 0.13.1",
|
||||
"md-5 0.11.0",
|
||||
"md4",
|
||||
"num_enum",
|
||||
@@ -7027,6 +7067,20 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"memchr",
|
||||
"ntapi",
|
||||
"rayon",
|
||||
"windows 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.5.1"
|
||||
@@ -8530,6 +8584,16 @@ dependencies = [
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
|
||||
dependencies = [
|
||||
"windows-core 0.57.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.58.0"
|
||||
@@ -8583,6 +8647,18 @@ dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
|
||||
dependencies = [
|
||||
"windows-implement 0.57.0",
|
||||
"windows-interface 0.57.0",
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.58.0"
|
||||
@@ -8644,6 +8720,17 @@ dependencies = [
|
||||
"windows-threading 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.58.0"
|
||||
@@ -8666,6 +8753,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.58.0"
|
||||
@@ -8720,6 +8818,15 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
|
||||
@@ -30,11 +30,14 @@ base64 = "0.22"
|
||||
lru = "0.12"
|
||||
futures = "0.3"
|
||||
image = { version = "0.24", default-features = false, features = ["jpeg"] }
|
||||
kamadak-exif = "0.6"
|
||||
axum = "0.7"
|
||||
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
argon2 = "0.5"
|
||||
rand = "0.8"
|
||||
# MarkBase Core for Admin/Client GUI
|
||||
markbase-core = { path = "../../markbase/markbase-core" }
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Standalone proxy binary for testing/development
|
||||
// Re-exports the proxy module from main crate
|
||||
#[path = "../proxy.rs"]
|
||||
mod proxy;
|
||||
|
||||
fn main() {
|
||||
// We can't import from the main crate as a library easily,
|
||||
// so instead we'll just call through the Tauri binary mechanism.
|
||||
// For now, the full Tauri binary must be used to run the proxy.
|
||||
eprintln!("Use `cargo tauri dev` to start the full application with proxy.");
|
||||
eprintln!("For standalone proxy testing, the proxy runs as part of the Tauri binary.");
|
||||
std::process::exit(1);
|
||||
#[path = "../db.rs"]
|
||||
mod db;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let _ = db::init_db();
|
||||
proxy::start_proxy_server().await;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use argon2::{self, Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version};
|
||||
use argon2::password_hash::{SaltString, rand_core::OsRng};
|
||||
|
||||
const APP_TABLES: &str = "
|
||||
CREATE TABLE IF NOT EXISTS search_history (
|
||||
@@ -25,6 +27,7 @@ CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT,
|
||||
display_name TEXT,
|
||||
role TEXT DEFAULT 'user',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
@@ -49,19 +52,27 @@ pub fn init_db() -> Result<(), String> {
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL;").map_err(|e| format!("WAL mode error: {}", e))?;
|
||||
conn.execute_batch(APP_TABLES).map_err(|e| format!("Failed to create tables: {}", e))?;
|
||||
|
||||
// Add password_hash column if not exists (migration)
|
||||
conn.execute("ALTER TABLE app_users ADD COLUMN password_hash TEXT", []).ok();
|
||||
|
||||
let default_id = "demo";
|
||||
let default_user = "demo";
|
||||
let count: i64 = conn
|
||||
let default_password = "demo123";
|
||||
|
||||
// Check if demo user exists and has password
|
||||
let password_hash: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM app_users WHERE id = ?1",
|
||||
"SELECT password_hash FROM app_users WHERE id = ?1",
|
||||
[default_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
if count == 0 {
|
||||
.unwrap_or(None);
|
||||
|
||||
if password_hash.is_none() || password_hash.as_ref().map_or(true, |h| h.is_empty()) {
|
||||
let password_hash = hash_password(default_password);
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO app_users (id, username, display_name, role) VALUES (?1, ?2, ?3, 'admin')",
|
||||
rusqlite::params![default_id, default_user, "Demo User"],
|
||||
"INSERT OR REPLACE INTO app_users (id, username, password_hash, display_name, role) VALUES (?1, ?2, ?3, ?4, 'admin')",
|
||||
rusqlite::params![default_id, default_user, password_hash, "Demo User"],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert default user: {}", e))?;
|
||||
}
|
||||
@@ -98,6 +109,39 @@ pub struct BookmarkItem {
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LoginResult {
|
||||
pub success: bool,
|
||||
pub user: Option<User>,
|
||||
pub token: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
fn hash_password(password: &str) -> String {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default());
|
||||
argon2.hash_password(password.as_bytes(), &salt).unwrap().to_string()
|
||||
}
|
||||
|
||||
fn verify_password(hash: &str, password: &str) -> bool {
|
||||
let parsed_hash = PasswordHash::new(hash);
|
||||
match parsed_hash {
|
||||
Ok(h) => {
|
||||
let argon2 = Argon2::default();
|
||||
argon2.verify_password(password.as_bytes(), &h).is_ok()
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub fn get_search_history(limit: Option<u32>) -> Result<Vec<HistoryItem>, String> {
|
||||
let limit = limit.unwrap_or(30).min(30);
|
||||
@@ -235,4 +279,58 @@ pub fn delete_bookmark(id: i64) -> Result<(), String> {
|
||||
conn.execute("DELETE FROM bookmarks WHERE id = ?1", [id])
|
||||
.map_err(|e| format!("Delete error: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn verify_user(username: &str, password: &str) -> Result<Option<User>, String> {
|
||||
let conn = get_conn()?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, username, password_hash, display_name, role FROM app_users WHERE username = ?1")
|
||||
.map_err(|e| format!("Prepare error: {}", e))?;
|
||||
|
||||
let result = stmt
|
||||
.query_row([username], |row| {
|
||||
let id: String = row.get(0)?;
|
||||
let username: String = row.get(1)?;
|
||||
let password_hash: Option<String> = row.get(2)?;
|
||||
let display_name: Option<String> = row.get(3)?;
|
||||
let role: String = row.get(4)?;
|
||||
Ok((id, username, password_hash, display_name, role))
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok((id, uname, password_hash, display_name, role)) => {
|
||||
if let Some(hash) = password_hash {
|
||||
if verify_password(&hash, password) {
|
||||
Ok(Some(User { id, username: uname, display_name, role }))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_user_by_username(username: &str) -> Result<Option<User>, String> {
|
||||
let conn = get_conn()?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, username, display_name, role FROM app_users WHERE username = ?1")
|
||||
.map_err(|e| format!("Prepare error: {}", e))?;
|
||||
|
||||
let result = stmt
|
||||
.query_row([username], |row| {
|
||||
Ok(User {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
display_name: row.get(2)?,
|
||||
role: row.get(3)?,
|
||||
})
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(user) => Ok(Some(user)),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -9,9 +9,12 @@ use tower_http::cors::{Any, CorsLayer};
|
||||
use crate::db;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use image::GenericImageView;
|
||||
use std::sync::Mutex;
|
||||
use image::DynamicImage;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::num::NonZeroUsize;
|
||||
use lru::LruCache;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
const CORE_API: &str = "http://localhost:3002";
|
||||
const API_KEY: &str = "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69";
|
||||
@@ -25,6 +28,16 @@ const PROFILE_DIRS: [&str; 4] = [
|
||||
|
||||
static FACE_THUMB_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
|
||||
static PROFILE_PROXY_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
|
||||
static RAW_DIM_PROXY_CACHE: Mutex<Option<HashMap<String, (u32, u32)>>> = Mutex::new(None);
|
||||
static THUMB_SEM_MAP_PROXY: Mutex<Option<HashMap<String, Arc<Semaphore>>>> = Mutex::new(None);
|
||||
|
||||
fn acquire_thumb_semaphore(uuid: &str) -> Arc<Semaphore> {
|
||||
let mut map = THUMB_SEM_MAP_PROXY.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_face_thumb_cache() -> std::sync::MutexGuard<'static, Option<LruCache<String, String>>> {
|
||||
let mut guard = FACE_THUMB_CACHE.lock().unwrap();
|
||||
@@ -67,7 +80,15 @@ pub async fn start_proxy_server() {
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", axum::routing::get(health_handler))
|
||||
.route("/api/v1/auth/login", axum::routing::post(login_handler))
|
||||
.route("/api/v1/face-thumbnail", axum::routing::get(get_face_thumbnail_handler))
|
||||
.route("/api/v1/media/frame", axum::routing::get(get_frame_handler))
|
||||
.route("/api/v1/identity-matches", axum::routing::get(get_identity_matches_handler))
|
||||
.route("/api/v1/cluster-results", axum::routing::get(get_cluster_results_handler))
|
||||
.route("/api/v1/processor-json", axum::routing::get(get_processor_json_handler))
|
||||
.route("/api/v1/file/thumbnail", axum::routing::get(get_file_thumbnail_by_path_handler))
|
||||
.route("/api/v1/file/:file_uuid/pose", axum::routing::get(get_pose_handler))
|
||||
.route("/api/v1/file/:file_uuid/appearance", axum::routing::get(get_appearance_handler))
|
||||
.fallback(fallback_handler)
|
||||
.layer(cors)
|
||||
.with_state(state);
|
||||
@@ -91,6 +112,81 @@ async fn health_handler() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct LoginRequest {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct LoginResponse {
|
||||
success: bool,
|
||||
user: Option<UserInfo>,
|
||||
token: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct UserInfo {
|
||||
id: String,
|
||||
username: String,
|
||||
display_name: Option<String>,
|
||||
role: String,
|
||||
}
|
||||
|
||||
async fn login_handler(body: axum::body::Bytes) -> Response {
|
||||
let req: Result<LoginRequest, _> = serde_json::from_slice(&body);
|
||||
|
||||
match req {
|
||||
Ok(login_req) => {
|
||||
match db::verify_user(&login_req.username, &login_req.password) {
|
||||
Ok(Some(user)) => {
|
||||
let token = uuid::Uuid::new_v4().to_string();
|
||||
let response = LoginResponse {
|
||||
success: true,
|
||||
user: Some(UserInfo {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
}),
|
||||
token: Some(token),
|
||||
error: None,
|
||||
};
|
||||
(StatusCode::OK, axum::Json(response)).into_response()
|
||||
}
|
||||
Ok(None) => {
|
||||
let response = LoginResponse {
|
||||
success: false,
|
||||
user: None,
|
||||
token: None,
|
||||
error: Some("Invalid username or password".to_string()),
|
||||
};
|
||||
(StatusCode::UNAUTHORIZED, axum::Json(response)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let response = LoginResponse {
|
||||
success: false,
|
||||
user: None,
|
||||
token: None,
|
||||
error: Some(e),
|
||||
};
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, axum::Json(response)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let response = LoginResponse {
|
||||
success: false,
|
||||
user: None,
|
||||
token: None,
|
||||
error: Some(format!("Invalid request: {}", e)),
|
||||
};
|
||||
(StatusCode::BAD_REQUEST, axum::Json(response)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fallback_handler(State(state): State<ProxyState>, req: axum::extract::Request) -> Response {
|
||||
let path = req.uri().path().to_string();
|
||||
let method = req.method().clone();
|
||||
@@ -483,6 +579,35 @@ async fn get_identity_profile_handler_inner(uuid: String) -> Response {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read EXIF orientation and physically rotate pixels, stripping orientation tag.
|
||||
/// Fetch raw video dimensions from Core API with in-memory cache.
|
||||
async fn fetch_raw_dimensions(uuid: &str) -> Option<(u32, u32)> {
|
||||
{
|
||||
let cache = RAW_DIM_PROXY_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 = reqwest::get(&url).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_PROXY_CACHE.lock().unwrap();
|
||||
let cache = cache.get_or_insert_with(HashMap::new);
|
||||
cache.insert(uuid.to_string(), (w, h));
|
||||
}
|
||||
Some((w, h))
|
||||
}
|
||||
|
||||
|
||||
|
||||
async fn get_face_thumbnail_handler(State(state): State<ProxyState>, axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
||||
let start = std::time::Instant::now();
|
||||
let uuid = match params.get("uuid") {
|
||||
@@ -511,6 +636,17 @@ async fn get_face_thumbnail_handler(State(state): State<ProxyState>, axum::extra
|
||||
}
|
||||
}
|
||||
|
||||
let _permit = acquire_thumb_semaphore(&uuid).acquire_owned().await.unwrap();
|
||||
// Double-check cache after acquiring semaphore
|
||||
{
|
||||
let mut cache = get_face_thumb_cache();
|
||||
if let Some(c) = cache.as_mut() {
|
||||
if let Some(val) = c.get(&cache_key) {
|
||||
eprintln!("[proxy] <-- FACE-THUMB {} (cached after wait) {}ms", uuid, start.elapsed().as_millis());
|
||||
return ([("content-type", "text/plain")], val.clone()).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
let url = format!("{}/api/v1/file/{}/thumbnail?api_key={}&frame={}", CORE_API, uuid, API_KEY, frame);
|
||||
|
||||
let bytes = match state.client.get(&url).send().await {
|
||||
@@ -555,4 +691,328 @@ async fn get_face_thumbnail_handler(State(state): State<ProxyState>, axum::extra
|
||||
|
||||
eprintln!("[proxy] <-- FACE-THUMB {} {} {}ms", uuid, result.len(), start.elapsed().as_millis());
|
||||
([("content-type", "text/plain")], result).into_response()
|
||||
}
|
||||
|
||||
async fn get_identity_matches_handler(axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
||||
let file_hash = match params.get("file_hash") {
|
||||
Some(v) => v.clone(),
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing file_hash").into_response(),
|
||||
};
|
||||
let output_bases = [
|
||||
"/Users/accusys/momentry/output",
|
||||
"/Users/accusys/momentry/output_dev",
|
||||
"/Volumes/external/momentry/output",
|
||||
"/Volumes/external/momentry/output_dev",
|
||||
];
|
||||
let mut match_path = None;
|
||||
for base in &output_bases {
|
||||
let path = format!("{}/{}/{}.identity_match_round1.json", base, file_hash, file_hash);
|
||||
if std::path::Path::new(&path).exists() {
|
||||
match_path = Some(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
match match_path {
|
||||
Some(path) => match std::fs::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Parse failed: {}", e)).into_response();
|
||||
}
|
||||
};
|
||||
axum::Json(parsed).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e)).into_response(),
|
||||
},
|
||||
None => (StatusCode::NOT_FOUND, "Identity match file not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_cluster_results_handler(axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
||||
let file_hash = match params.get("file_hash") {
|
||||
Some(v) => v.clone(),
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing file_hash").into_response(),
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/file/{}/face-groups", CORE_API, file_hash);
|
||||
let resp = match client
|
||||
.get(&url)
|
||||
.header("X-API-Key", API_KEY)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Face groups request failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
let body = match resp.text().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Body read failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let axum_status = axum::http::StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
if !axum_status.is_success() {
|
||||
return (axum_status, body).into_response();
|
||||
}
|
||||
|
||||
let result: serde_json::Value = match serde_json::from_str(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("JSON parse failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let clusters = result.get("face_groups").cloned().unwrap_or(serde_json::json!([]));
|
||||
axum::Json(serde_json::json!({
|
||||
"success": true,
|
||||
"clusters": clusters
|
||||
})).into_response()
|
||||
}
|
||||
|
||||
async fn get_processor_json_handler(
|
||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let file_hash = match params.get("file_hash") {
|
||||
Some(v) => v.clone(),
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing file_hash").into_response(),
|
||||
};
|
||||
let processor = match params.get("processor") {
|
||||
Some(v) => v.clone(),
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing processor").into_response(),
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let json_url = format!("{}/api/v1/file/{}/json/{}", CORE_API, file_hash, processor);
|
||||
let json_resp = match client
|
||||
.post(&json_url)
|
||||
.header("X-API-Key", API_KEY)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("JSON request failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let status = json_resp.status();
|
||||
let body = match json_resp.text().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Body read failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let axum_status = axum::http::StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
if axum_status.is_success() {
|
||||
match serde_json::from_str::<serde_json::Value>(&body) {
|
||||
Ok(v) => axum::Json(v).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("JSON parse failed: {}", e)).into_response(),
|
||||
}
|
||||
} else {
|
||||
(axum_status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_file_thumbnail_by_path_handler(axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
||||
let path = match params.get("path") {
|
||||
Some(v) => v.clone(),
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing path").into_response(),
|
||||
};
|
||||
let frame: u32 = params.get("frame").and_then(|v| v.parse().ok()).unwrap_or(30);
|
||||
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
return (StatusCode::NOT_FOUND, "File not found").into_response();
|
||||
}
|
||||
|
||||
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||
let is_image = matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "gif" | "webp");
|
||||
|
||||
if is_image {
|
||||
// For images, read directly
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => {
|
||||
let mime = match ext.as_str() {
|
||||
"png" => "image/png",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
_ => "image/jpeg",
|
||||
};
|
||||
([( "content-type", mime )], axum::body::Body::from(bytes)).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e)).into_response(),
|
||||
}
|
||||
} else {
|
||||
// For videos, extract frame using ffmpeg
|
||||
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;
|
||||
|
||||
match output {
|
||||
Ok(out) if out.status.success() => {
|
||||
let jpeg = out.stdout;
|
||||
([("content-type", "image/jpeg")], axum::body::Body::from(jpeg)).into_response()
|
||||
}
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, "Thumbnail extraction failed").into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_frame_handler(State(state): State<ProxyState>, axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
||||
let file_uuid = match params.get("file_uuid") {
|
||||
Some(v) => v.clone(),
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing file_uuid").into_response(),
|
||||
};
|
||||
let frame: u32 = params.get("frame").and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
|
||||
// Get file path from Core API
|
||||
let url = format!("{}/api/v1/file/{}?api_key={}", CORE_API, file_uuid, API_KEY);
|
||||
let file_info = match state.client.get(&url).send().await {
|
||||
Ok(resp) => match resp.json::<serde_json::Value>().await {
|
||||
Ok(json) => json,
|
||||
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Parse failed: {}", e)).into_response(),
|
||||
},
|
||||
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Request failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let file_path = match file_info["file_path"].as_str() {
|
||||
Some(p) => p.to_string(),
|
||||
None => return (StatusCode::NOT_FOUND, "File path not found").into_response(),
|
||||
};
|
||||
|
||||
if !std::path::Path::new(&file_path).exists() {
|
||||
return (StatusCode::NOT_FOUND, "File not found").into_response();
|
||||
}
|
||||
|
||||
let ext = file_path.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||
let is_image = matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "gif" | "webp");
|
||||
|
||||
if is_image {
|
||||
match std::fs::read(&file_path) {
|
||||
Ok(bytes) => {
|
||||
let mime = match ext.as_str() {
|
||||
"png" => "image/png",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
_ => "image/jpeg",
|
||||
};
|
||||
([("content-type", mime)], axum::body::Body::from(bytes)).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e)).into_response(),
|
||||
}
|
||||
} else {
|
||||
let fps = file_info["fps"].as_f64().unwrap_or(24.0);
|
||||
let time_sec = frame as f64 / fps;
|
||||
let output = tokio::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-ss", &time_sec.to_string(),
|
||||
"-i", &file_path,
|
||||
"-vframes", "1",
|
||||
"-q:v", "2",
|
||||
"-f", "mjpeg",
|
||||
"-"
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match output {
|
||||
Ok(out) if out.status.success() => {
|
||||
let jpeg = out.stdout;
|
||||
([("content-type", "image/jpeg")], axum::body::Body::from(jpeg)).into_response()
|
||||
}
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, "Frame extraction failed").into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_pose_handler(
|
||||
axum::extract::Path(file_uuid): axum::extract::Path<String>,
|
||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let frame = match params.get("frame").and_then(|v| v.parse::<u32>().ok()) {
|
||||
Some(f) => f,
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing frame").into_response(),
|
||||
};
|
||||
|
||||
let mut url = format!("{}/api/v1/file/{}/pose?frame={}", CORE_API, file_uuid, frame);
|
||||
if let Some(bbox_x) = params.get("bbox_x") {
|
||||
url.push_str(&format!("&bbox_x={}", bbox_x));
|
||||
}
|
||||
if let Some(bbox_y) = params.get("bbox_y") {
|
||||
url.push_str(&format!("&bbox_y={}", bbox_y));
|
||||
}
|
||||
if let Some(bbox_w) = params.get("bbox_w") {
|
||||
url.push_str(&format!("&bbox_w={}", bbox_w));
|
||||
}
|
||||
if let Some(bbox_h) = params.get("bbox_h") {
|
||||
url.push_str(&format!("&bbox_h={}", bbox_h));
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = match client
|
||||
.get(&url)
|
||||
.header("X-API-Key", API_KEY)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Request failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let body = match resp.text().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Body read failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
(status, body).into_response()
|
||||
}
|
||||
|
||||
async fn get_appearance_handler(
|
||||
axum::extract::Path(file_uuid): axum::extract::Path<String>,
|
||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let frame = match params.get("frame").and_then(|v| v.parse::<u32>().ok()) {
|
||||
Some(f) => f,
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing frame").into_response(),
|
||||
};
|
||||
|
||||
let mut url = format!("{}/api/v1/file/{}/appearance?frame={}", CORE_API, file_uuid, frame);
|
||||
if let Some(bbox_x) = params.get("bbox_x") {
|
||||
url.push_str(&format!("&bbox_x={}", bbox_x));
|
||||
}
|
||||
if let Some(bbox_y) = params.get("bbox_y") {
|
||||
url.push_str(&format!("&bbox_y={}", bbox_y));
|
||||
}
|
||||
if let Some(bbox_w) = params.get("bbox_w") {
|
||||
url.push_str(&format!("&bbox_w={}", bbox_w));
|
||||
}
|
||||
if let Some(bbox_h) = params.get("bbox_h") {
|
||||
url.push_str(&format!("&bbox_h={}", bbox_h));
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = match client
|
||||
.get(&url)
|
||||
.header("X-API-Key", API_KEY)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Request failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let body = match resp.text().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Body read failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
(status, body).into_response()
|
||||
}
|
||||
Reference in New Issue
Block a user