diff --git a/docs/face_filter_integration_guide.md b/docs/face_filter_integration_guide.md new file mode 100644 index 0000000..d1d0a32 --- /dev/null +++ b/docs/face_filter_integration_guide.md @@ -0,0 +1,543 @@ +# Face Filter Integration Guide for PeopleView + +**版本**: v1.0 +**日期**: 2026-08-09 +**狀態**: 可整合 + +--- + +## 概述 + +本指南說明如何將 Face Filter 功能整合到 PeopleView 中。 + +--- + +## 已創建的檔案 + +### 1. Composable +**位置**: `src/composables/useFaceFilter.ts` + +**功能**: +- 管理臉部過濾狀態 +- 提供三種過濾模式(instant, backend, hybrid) +- 處理 API 呼叫 +- 儲存使用者偏好到 localStorage + +**主要 exports**: +```typescript +export function useFaceFilter() { + return { + mode, // 'instant' | 'backend' | 'hybrid' + rules, // QuickFilterRules + allFaces, // FaceMetadata[] + filteredFaces, // FaceMetadata[] + filterResult, // FilterResult | null + filterStats, // FilterStats | null + loading, // boolean + error, // string | null + stats, // computed stats + + // Actions + initialize, + updateRules, + setMode, + applyBackendFilter, + getFaceReportData, + refreshStats, + resetToDefault, + applyLocalFilter + }; +} +``` + +--- + +### 2. Components + +#### FaceFilterPanel.vue +**位置**: `src/components/FaceFilterPanel.vue` + +**功能**: +- 過濾面板 UI +- 模式選擇(instant/backend/hybrid) +- 過濾規則控制(confidence, bbox_size, source, backend_status) +- 統計顯示 + +**Props**: +```typescript +{ + fileUuid: string // 必需的檔案 UUID +} +``` + +**使用範例**: +```vue + +``` + +--- + +#### FaceCard.vue +**位置**: `src/components/FaceCard.vue` + +**功能**: +- 臉部卡片顯示(純文字,無縮圖) +- 狀態標示(accept/reject/review) +- 來源徽章(AV/MP) +- 選擇功能 + +**Props**: +```typescript +{ + face: FaceMetadata, + selected?: boolean, + showCheckbox?: boolean +} +``` + +**Events**: +```typescript +{ + click: [face: FaceMetadata], + select: [face: FaceMetadata] +} +``` + +**使用範例**: +```vue + +``` + +--- + +## 整合步驟 + +### Step 1: 導入 Composable + +在 PeopleView.vue 中導入 useFaceFilter: + +```vue + +``` + +--- + +### Step 2: 添加 FaceFilterPanel 組件 + +在 toolbar 區域添加 FaceFilterPanel: + +```vue + + + +``` + +--- + +### Step 3: 初始化過濾 + +當選擇檔案時初始化過濾: + +```typescript +// 在 onFileFilterChange 或 watch(selectedFileUuid) 中 +watch(selectedFileUuid, async (newUuid) => { + if (newUuid) { + await initialize(newUuid); + } +}); +``` + +--- + +### Step 4: 使用過濾後的臉部 + +使用 `filteredFaces` 替代原始的臉部列表: + +```vue + +
+ +
+ + +
+ +
+``` + +--- + +### Step 5: 整合到現有 UI + +#### 選項 A: 獨立面板(推薦) + +FaceFilterPanel 作為獨立面板,放在 toolbar 下方: + +```vue +
+
+ +
+ + + + + +
+ +
+
+``` + +#### 選項 B: 整合到現有面板 + +將過濾控制整合到現有的 Pending Faces 面板: + +```vue +
+
+
Pending Faces
+ + +
+ + + +
+
+ + +
+ +
+
+``` + +--- + +## API 端點 + +所有 API 呼叫都通過 composable 處理,使用 proxy 到 `http://localhost:8888/api/v1/...` + +**實際端點**: +1. `GET /api/v1/file/{uuid}/faces` - 獲取臉部元數據 +2. `POST /api/v1/file/{uuid}/faces/filter` - 套用後端過濾 +3. `GET /api/v1/file/{uuid}/face/{faceId}/filter-report` - 獲取單臉報告 +4. `GET /api/v1/file/{uuid}/filter-stats` - 獲取統計 +5. `GET/POST /api/v1/file/{uuid}/filter-rules` - 規則 CRUD + +--- + +## 資料結構 + +### FaceMetadata + +```typescript +interface FaceMetadata { + face_id: string; + frame: number; + primary_source: 'apple_vision' | 'mediapipe'; + primary_confidence: number; + detection_sources: Array<{ + source_type: string; + confidence: number; + }>; + filter_features: { + face_id: string; + frame: number; + bbox_size: number; // min(width, height) + apple_vision_confidence: number; + mediapipe_confidence?: number; + eye_ratio?: number; + nose_ratio?: number; + pose_score?: number; + mediapipe_detected?: boolean; + mediapipe_iou?: number; + embedding_similarity?: number; + consecutive_frames?: number; + total_frames_detected?: number; + backend_filter_status?: 'accept' | 'reject' | 'review'; + }; +} +``` + +--- + +## 過濾模式 + +### Instant Mode +- **效能**: 5-10ms +- **邏輯**: Client 端即時過濾 +- **適用**: 快速調整規則 + +### Backend Mode +- **效能**: 100-200ms(API 呼叫) +- **邏輯**: 後端複雜過濾(landmark, embedding, temporal) +- **適用**: 高品質過濾 + +### Hybrid Mode +- **效能**: 5-10ms(前端)+ 後端預先計算 +- **邏輯**: 後端標示 + 前端即時過濾(AND) +- **適用**: 最佳使用者體驗 + +--- + +## 使用者偏好 + +偏好儲存在 `localStorage`: + +**Key**: `momentry_face_filter_preferences` + +**結構**: +```json +{ + "mode": "instant", + "rules": { + "min_confidence": 0.5, + "min_bbox_size": 80, + "source": "all", + "backend_status": "accept" + } +} +``` + +--- + +## 效能優化 + +### 分頁 +- `page_size ≤ 50`(防 hang) +- 虛擬滾動(可選) + +### 快取 +- 臉部元數據快取在 composable +- 偏好儲存在 localStorage + +### 懶載入 +- JPG 縮圖懶載入(未來擴展) +- 當前使用純文字卡片 + +--- + +## 錯誤處理 + +Composable 提供 error 狀態: + +```typescript +if (error.value) { + console.error('Face filter error:', error.value); + // 顯示錯誤訊息給使用者 +} +``` + +--- + +## 測試 + +### 單元測試 +```typescript +import { useFaceFilter } from '../composables/useFaceFilter'; + +describe('useFaceFilter', () => { + it('should filter faces by confidence', () => { + const { updateRules, filteredFaces } = useFaceFilter(); + + // Setup test data + // ... + + updateRules({ min_confidence: 0.7 }); + expect(filteredFaces.value.length).toBeLessThan(allFaces.value.length); + }); +}); +``` + +### 整合測試 +1. 選擇檔案 +2. 驗證臉部載入 +3. 調整過濾規則 +4. 驗證過濾結果 +5. 切換模式 +6. 驗證模式切換 + +--- + +## 未來擴展 + +### 1. 縮圖支援 +當 Core 實作 `/face/{id}/thumbnail` 端點後: + +```vue + +``` + +### 2. 規則管理 UI +當需要規則 CRUD 時: + +```vue + +``` + +### 3. 批量操作 +```typescript +const selectedFaces = ref([]); + +const batchAction = async (action: 'accept' | 'reject') => { + await fetch('/api/v1/file/.../faces/batch', { + method: 'POST', + body: JSON.stringify({ + face_ids: selectedFaces.value.map(f => f.face_id), + action + }) + }); +}; +``` + +### 4. 匯出功能 +```typescript +const exportFilteredFaces = () => { + const csv = filteredFaces.value.map(face => + `${face.face_id},${face.frame},${face.primary_confidence},${face.filter_features.bbox_size}` + ).join('\n'); + + // Download CSV +}; +``` + +--- + +## 疑難排解 + +### 問題 1: 臉部未載入 +**檢查**: +- `selectedFileUuid` 是否正確 +- API 端點是否可達 +- 網路請求是否成功 + +**解決**: +```typescript +console.log('File UUID:', selectedFileUuid); +console.log('API Response:', await fetchFaces(selectedFileUuid)); +``` + +### 問題 2: 過濾未生效 +**檢查**: +- `mode` 是否正確 +- `rules` 是否正確 +- `filteredFaces` 是否更新 + +**解決**: +```typescript +console.log('Mode:', mode.value); +console.log('Rules:', rules.value); +console.log('Filtered:', filteredFaces.value.length); +``` + +### 問題 3: 偏好未儲存 +**檢查**: +- `localStorage` 是否可寫 +- Key 是否正確 + +**解決**: +```typescript +console.log('LocalStorage:', localStorage.getItem('momentry_face_filter_preferences')); +``` + +--- + +## 總結 + +**整合步驟**: +1. ✅ 導入 useFaceFilter composable +2. ✅ 添加 FaceFilterPanel 組件 +3. ✅ 初始化過濾(watch selectedFileUuid) +4. ✅ 使用 filteredFaces 顯示 +5. ✅ 處理錯誤和載入狀態 + +**預期效果**: +- 即時過濾(5-10ms) +- 三種模式切換 +- 使用者偏好儲存 +- 純文字卡片(無縮圖) + +**未來擴展**: +- 縮圖支援 +- 規則管理 UI +- 批量操作 +- 匯出功能 + +--- + +**文件版本**: v1.0 +**最後更新**: 2026-08-09 +**相關文件**: +- `/Users/accusys/momentry_core/docs_v1.0/STUDIO_TEAM/face_filter_usage_guide.md` +- `/Users/accusys/momentry_core/docs_v1.0/STUDIO_TEAM/face_filter_hybrid_mode_guide.md` diff --git a/src-tauri/src/bin/gen_password.rs b/src-tauri/src/bin/gen_password.rs deleted file mode 100644 index 323dcd8..0000000 --- a/src-tauri/src/bin/gen_password.rs +++ /dev/null @@ -1,12 +0,0 @@ -use argon2::{Algorithm, Argon2, Params, PasswordHasher, Version}; -use argon2::password_hash::{SaltString, rand_core::OsRng}; - -fn main() { - let password = std::env::args().nth(1).unwrap_or_else(|| "demo123".to_string()); - let salt = SaltString::generate(&mut OsRng); - let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default()); - match argon2.hash_password(password.as_bytes(), &salt) { - Ok(hash) => println!("{}", hash), - Err(e) => eprintln!("Error: {}", e), - } -} diff --git a/src-tauri/src/main.rs.bak3 b/src-tauri/src/main.rs.bak3 deleted file mode 100644 index e31e78c..0000000 --- a/src-tauri/src/main.rs.bak3 +++ /dev/null @@ -1,1177 +0,0 @@ -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -mod proxy; -mod db; - -use serde::Serialize; -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use std::sync::Mutex; -use std::num::NonZeroUsize; -use lru::LruCache; -use image::GenericImageView; - -static THUMB_CACHE: Mutex>> = Mutex::new(None); -static PROFILE_CACHE: Mutex>> = Mutex::new(None); - -fn get_client() -> reqwest::Client { - reqwest::Client::builder() - .connect_timeout(std::time::Duration::from_secs(10)) - .timeout(std::time::Duration::from_secs(60)) - .pool_max_idle_per_host(10) - .build() - .expect("Failed to build HTTP client") -} - -#[derive(Serialize)] -struct SearchResult { - file_uuid: String, - start_time: f64, - end_time: f64, - start_frame: i64, - end_frame: i64, - summary: String, - similarity: f64, - file_name: Option, -} - -#[derive(Serialize)] -struct FileInfo { - file_uuid: String, - file_name: String, - file_path: String, - file_size: i64, - modified_time: String, - #[serde(rename = "isRegistered")] - is_registered: bool, - status: String, - registration_time: Option, - #[serde(rename = "ingested")] - ingested: bool, -} - -#[derive(Serialize)] -struct PersonInfo { - identity_uuid: String, - name: String, - starred: bool, - status: String, - metadata: serde_json::Value, -} - -#[derive(Serialize)] -struct FaceInfo { - id: i64, - file_uuid: String, - frame_number: i64, - timestamp_secs: f64, - face_id: Option, - confidence: f64, - bbox: Option, -} - -#[derive(Serialize)] -struct TraceInfo { - trace_id: i32, - file_uuid: String, - frame_count: i64, - first_frame: i64, - last_frame: i64, - first_sec: f64, - last_sec: f64, - avg_confidence: f64, - face_id: Option, -} - -#[derive(Serialize)] -struct FileDetail { - file_uuid: String, - file_name: String, - fps: f64, - duration: f64, -} - -#[derive(Serialize)] -struct FaceCandidate { - id: i64, - face_id: Option, - file_uuid: String, - frame_number: i64, - confidence: f64, - bbox: Option, -} - -#[derive(Serialize)] -struct FaceBBox { - x: f64, - y: f64, - width: f64, - height: f64, -} - -#[derive(Serialize)] -struct SearchIdentityResult { - identity_id: i64, - name: String, - source: String, - tmdb_id: Option, - file_uuid: Option, - trace_id: Option, - start_frame: Option, - end_frame: Option, - start_time: f64, - end_time: f64, - text_content: Option, -} - -const CORE_API: &str = "http://localhost:3002"; -const API_KEY: &str = "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69"; - -#[tauri::command(rename_all = "camelCase")] -async fn search_llm_smart(query: String, limit: usize) -> Result, String> { - let client = get_client(); - let url = format!("{}/api/v1/search/llm-smart?api_key={}", CORE_API, API_KEY); - - let response = client - .post(&url) - .json(&serde_json::json!({"query": query, "limit": limit})) - .send() - .await - .map_err(|e| format!("Request failed: {}", e))?; - - let json: serde_json::Value = response - .json() - .await - .map_err(|e| format!("Parse failed: {}", e))?; - - let results: Vec = json["results"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|r| { - Some(SearchResult { - file_uuid: r["file_uuid"].as_str()?.to_string(), - start_time: r["start_time"].as_f64()?, - end_time: r["end_time"].as_f64()?, - start_frame: r["start_frame"].as_i64().unwrap_or(0), - end_frame: r["end_frame"].as_i64().unwrap_or(0), - summary: r["summary"].as_str()?.to_string(), - similarity: r["similarity"].as_f64().unwrap_or(0.0), - file_name: r["file_name"].as_str().map(|s| s.to_string()), - }) - }) - .collect(); - - Ok(results) -} - -#[derive(serde::Deserialize)] -struct GetFilesArgs { - #[serde(rename = "pageSize")] - page_size: usize, -} - -#[tauri::command(rename_all = "camelCase")] -async fn search_agents(query: String, conversation_id: Option) -> Result { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(120)) - .build() - .map_err(|e| format!("Client build failed: {}", e))?; - let url = format!("{}/api/v1/agents/search?api_key={}", CORE_API, API_KEY); - let mut body = serde_json::json!({"query": query}); - if let Some(cid) = conversation_id { - body["conversation_id"] = serde_json::Value::String(cid); - } - let response = client - .post(&url) - .json(&body) - .send() - .await - .map_err(|e| format!("Agent search request failed: {}", e))?; - let json: serde_json::Value = response - .json() - .await - .map_err(|e| format!("Agent search parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_files(args: GetFilesArgs) -> Result, String> { - let page_size = args.page_size; - let client = get_client(); - let url = format!("{}/api/v1/files/scan?api_key={}&page_size={}", CORE_API, API_KEY, page_size); - - let response = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = response.json().await.map_err(|e| format!("Parse failed: {}", e))?; - - let files: Vec = json["files"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|f| { - let is_reg = f["is_registered"].as_bool().unwrap_or(false); - let file_uuid = f["file_uuid"].as_str() - .unwrap_or_else(|| f["file_path"].as_str().unwrap_or("")).to_string(); - let status = f["status"].as_str().unwrap_or("").to_string(); - Some(FileInfo { - file_uuid, - file_name: f["file_name"].as_str()?.to_string(), - file_path: f["file_path"].as_str().unwrap_or("").to_string(), - file_size: f["file_size"].as_i64().unwrap_or(0), - modified_time: f["modified_time"].as_str().unwrap_or("").to_string(), - is_registered: is_reg, - registration_time: f["registration_time"].as_str().map(|s| s.to_string()), - ingested: status == "completed", - status, - }) - }) - .collect(); - - Ok(files) -} - -#[derive(Serialize)] -struct RegisterResult { - success: bool, - file_uuid: String, - file_name: String, - message: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn register_file(file_path: String) -> Result { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(120)) - .build() - .map_err(|e| format!("Client build failed: {}", e))?; - let url = format!("{}/api/v1/files/register?api_key={}", CORE_API, API_KEY); - let response = client.post(&url) - .json(&serde_json::json!({"file_path": file_path})) - .send() - .await - .map_err(|e| format!("Register request failed: {}", e))?; - let json: serde_json::Value = response.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(RegisterResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(), - file_name: json["file_name"].as_str().unwrap_or("").to_string(), - message: json["message"].as_str().unwrap_or("").to_string(), - }) -} - -#[derive(Serialize)] -struct ProcessResult { - success: bool, - file_uuid: String, - message: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn process_file(file_uuid: String, processors: Vec) -> Result { - let url = format!("{}/api/v1/file/{}/process?api_key={}", CORE_API, file_uuid, API_KEY); - let response = get_client().post(&url) - .json(&serde_json::json!({"processors": processors})) - .send() - .await - .map_err(|e| format!("Process request failed: {}", e))?; - let json: serde_json::Value = response.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(ProcessResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(), - message: json["message"].as_str().unwrap_or("").to_string(), - }) -} - -#[derive(Serialize)] -struct IngestResult { - success: bool, - file_uuid: String, - message: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn ingest_file(file_uuid: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/file/{}/checkin?api_key={}", CORE_API, file_uuid, API_KEY); - let resp = client.post(&url).send().await - .map_err(|e| format!("Ingest (checkin) request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Ingest (checkin) parse failed: {}", e))?; - Ok(IngestResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid, - message: json["message"].as_str().unwrap_or("Checkin complete").to_string(), - }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn checkout_file(file_uuid: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/file/{}/checkout?api_key={}", CORE_API, file_uuid, API_KEY); - let resp = client.post(&url).send().await - .map_err(|e| format!("Checkout request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Checkout parse failed: {}", e))?; - Ok(IngestResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid, - message: json["message"].as_str().unwrap_or("Checkout complete").to_string(), - }) -} - -#[derive(Serialize)] -struct UnregisterResult { - success: bool, - file_uuid: String, - message: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn unregister_file(file_uuid: String, delete_output_files: Option) -> Result { - let url = format!("{}/api/v1/unregister?api_key={}", CORE_API, API_KEY); - let mut body = serde_json::json!({"file_uuid": file_uuid}); - if let Some(dof) = delete_output_files { - body["delete_output_files"] = serde_json::Value::Bool(dof); - } - let response = get_client().post(&url) - .json(&body) - .send() - .await - .map_err(|e| format!("Unregister request failed: {}", e))?; - let json: serde_json::Value = response.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(UnregisterResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(), - message: json["message"].as_str().unwrap_or("").to_string(), - }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_people(_page: usize, _per_page: usize) -> Result, String> { - eprintln!("[get_people] called"); - let mut people = Vec::new(); - for page in 1u32..=2 { - 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, - Err(e) => { - eprintln!("[get_people] page {} request failed: {} — continuing", page, e); - continue; - } - }; - let json: serde_json::Value = match resp.json().await { - Ok(v) => v, - Err(e) => { - eprintln!("[get_people] page {} parse error: {} — continuing", page, e); - continue; - } - }; - let identities = match json["identities"].as_array() { - Some(arr) => arr, - None => { - eprintln!("[get_people] page {} has no identities array — stopping", page); - break; - } - }; - if identities.is_empty() { - eprintln!("[get_people] page {} empty — stopping", page); - break; - } - for i in identities { - let uuid = match i["identity_uuid"].as_str() { - Some(u) => u.to_string(), - None => continue, - }; - let name = match i["name"].as_str() { - Some(n) => n.to_string(), - None => continue, - }; - people.push(PersonInfo { - identity_uuid: uuid, - name, - starred: i["starred"].as_bool().unwrap_or_else(|| i["metadata"]["starred"].as_bool().unwrap_or(false)), - status: i["status"].as_str().unwrap_or_else(|| i["metadata"]["status"].as_str().unwrap_or("pending")).to_string(), - metadata: i["metadata"].clone(), - }); - } - } - eprintln!("[get_people] returning {} people", people.len()); - Ok(people) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_faces(uuid: String, per_page: usize) -> Result, String> { - let url = format!("{}/api/v1/identity/{}/faces?api_key={}&page_size={}", CORE_API, uuid, API_KEY, per_page); - let t = std::time::Instant::now(); - eprintln!("[get_faces] --> {}", uuid); - - let response = match get_client().get(&url).send().await { - Ok(r) => r, - Err(e) => { - eprintln!("[get_faces] <-- {} ERROR {} {:?}", uuid, e, t.elapsed()); - return Ok(vec![]); - } - }; - let json: serde_json::Value = match response.json().await { - Ok(v) => v, - Err(e) => { - eprintln!("[get_faces] <-- {} PARSE_ERR {} {:?}", uuid, e, t.elapsed()); - return Ok(vec![]); - } - }; - - let faces: Vec = json["data"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|f| { - Some(FaceInfo { - id: f["id"].as_i64().unwrap_or(0), - file_uuid: f["file_uuid"].as_str()?.to_string(), - frame_number: f["frame_number"].as_i64().unwrap_or(0), - timestamp_secs: f["timestamp_secs"].as_f64().unwrap_or(0.0), - face_id: f["face_id"].as_str().map(|s| s.to_string()), - confidence: f["confidence"].as_f64().unwrap_or(0.0), - bbox: f["bbox"].as_object().map(|o| FaceBBox { - x: o["x"].as_f64().unwrap_or(0.0), - y: o["y"].as_f64().unwrap_or(0.0), - width: o["width"].as_f64().unwrap_or(0.0), - height: o["height"].as_f64().unwrap_or(0.0), - }), - }) - }) - .collect(); - - eprintln!("[get_faces] <-- {} {} faces {:?}", uuid, faces.len(), t.elapsed()); - Ok(faces) -} - -#[derive(Serialize)] -struct TracesResponse { - total: i64, - traces: Vec, -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_traces(uuid: String, per_page: usize, page: Option) -> Result { - let page_size = per_page.min(100); - let page_num = page.unwrap_or(1); - let t = std::time::Instant::now(); - eprintln!("[get_traces] --> {} page={} page_size={}", uuid, page_num, page_size); - - let url = format!("{}/api/v1/identity/{}/traces?api_key={}&page_size={}&page={}", CORE_API, uuid, API_KEY, page_size, page_num); - - let resp = get_client().get(&url).send().await - .map_err(|e| format!("Traces request failed: {}", e))?; - - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Traces parse failed: {}", e))?; - - let total = json["total"].as_i64().unwrap_or(0); - - let traces: Vec = json["traces"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|t| { - Some(TraceInfo { - trace_id: t["trace_id"].as_i64().unwrap_or(0) as i32, - file_uuid: t["file_uuid"].as_str()?.to_string(), - frame_count: t["frame_count"].as_i64().unwrap_or(0), - first_frame: t["first_frame"].as_i64().unwrap_or(0), - last_frame: t["last_frame"].as_i64().unwrap_or(0), - first_sec: t["first_sec"].as_f64().unwrap_or(0.0), - last_sec: t["last_sec"].as_f64().unwrap_or(0.0), - avg_confidence: t["avg_confidence"].as_f64().unwrap_or(0.0), - face_id: t["face_id"].as_str().map(String::from), - }) - }) - .collect(); - - eprintln!("[get_traces] <-- {} total={} {} traces {:?}", uuid, total, traces.len(), t.elapsed()); - Ok(TracesResponse { total, traces }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_file_info(uuid: String) -> Result { - let url = format!("{}/api/v1/file/{}?api_key={}", CORE_API, uuid, API_KEY); - let resp = get_client().get(&url).send().await - .map_err(|e| format!("File info request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("File info parse failed: {}", e))?; - Ok(FileDetail { - file_uuid: json["file_uuid"].as_str().unwrap_or("").to_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), - }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_thumbnail(uuid: String, frame: u32) -> Result { - let key = format!("{}:{}", uuid, frame); - { - 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 = 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, bbox_y: Option, bbox_w: Option, bbox_h: Option) -> Result { - 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 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(); - 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) - }; - 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); - let ch = ph.min(h.saturating_sub(cy)).max(1); - let cropped = img.crop_imm(cx, cy, cw, ch); - let mut buf = std::io::Cursor::new(Vec::new()); - cropped.write_to(&mut buf, image::ImageFormat::Jpeg).map_err(|e| format!("Encode failed: {}", e))?; - format!("data:image/jpeg;base64,{}", STANDARD.encode(buf.into_inner())) - } else { - 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) -} - -const PROFILE_DIRS: &[&str] = &[ - "/Users/accusys/momentry/output/identities", - "/Users/accusys/momentry/output_dev/identities", - "/Volumes/external/momentry/output/identities", - "/Volumes/external/momentry/output_dev/identities", -]; - -#[tauri::command(rename_all = "camelCase")] -async fn get_identity_profile(uuid: String) -> Result { - let uuid_display = uuid.clone(); - let no_dash = uuid.replace('-', ""); - { - let mut cache = PROFILE_CACHE.lock().unwrap(); - if let Some(cache) = cache.as_mut() { - if let Some(cached) = cache.get(&uuid_display) { - eprintln!("[get_identity_profile] <-- {} (cached)", uuid_display); - return Ok(cached.clone()); - } - } - } - let t = std::time::Instant::now(); - let result = (|| -> Option { - for dir in PROFILE_DIRS { - for candidate in [&no_dash, &uuid_display] { - let path = format!("{}/{}/profile.jpg", dir, candidate); - if std::path::Path::new(&path).exists() { - let bytes = std::fs::read(&path).ok()?; - return Some(format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes))); - } - } - } - None - })(); - match result { - Some(data) => { - let mut cache = PROFILE_CACHE.lock().unwrap(); - let cache = cache.get_or_insert_with(|| LruCache::new(NonZeroUsize::new(500).unwrap())); - cache.put(uuid_display.clone(), data.clone()); - eprintln!("[get_identity_profile] <-- {} {} {}ms", uuid_display, "FOUND", t.elapsed().as_millis()); - Ok(data) - } - None => { - eprintln!("[get_identity_profile] <-- {} {} {}ms", uuid_display, "404", t.elapsed().as_millis()); - Err("Profile image not found".to_string()) - } - } -} - -#[tauri::command(rename_all = "camelCase")] -async fn update_identity_name(uuid: String, name: String) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - let resp = client.patch(&url) - .json(&serde_json::json!({"name": name})) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Update failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn update_identity_status(uuid: String, status: String) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - // GET current identity to merge metadata (preserve starred, role, notes, etc.) - let get_resp = client.get(&url).send().await - .map_err(|e| format!("GET failed: {}", e))?; - let json: serde_json::Value = get_resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - let mut metadata = json["metadata"].as_object() - .cloned() - .unwrap_or_default(); - metadata.insert("status".to_string(), serde_json::Value::String(status)); - let resp = client.patch(&url) - .json(&serde_json::json!({"metadata": metadata})) - .send().await - .map_err(|e| format!("PATCH failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Update failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn update_identity_starred(uuid: String, starred: bool) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - let get_resp = client.get(&url).send().await - .map_err(|e| format!("GET failed: {}", e))?; - let json: serde_json::Value = get_resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - let mut metadata = json["metadata"].as_object() - .cloned() - .unwrap_or_default(); - metadata.insert("starred".to_string(), serde_json::Value::Bool(starred)); - let resp = client.patch(&url) - .json(&serde_json::json!({"metadata": metadata})) - .send().await - .map_err(|e| format!("PATCH failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Update failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn update_identity(uuid: String, name: Option, metadata_json: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(n) = &name { - body["name"] = serde_json::Value::String(n.clone()); - } - if !metadata_json.is_empty() { - let meta: serde_json::Value = serde_json::from_str(&metadata_json).map_err(|e| format!("Invalid metadata JSON: {}", e))?; - body["metadata"] = meta; - } - let resp = client.patch(&url) - .json(&body) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn upload_profile_image(uuid: String, file_path: String) -> Result<(), String> { - let file_bytes = std::fs::read(&file_path).map_err(|e| format!("Failed to read file: {}", e))?; - let file_name = std::path::Path::new(&file_path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("profile.jpg") - .to_string(); - let part = reqwest::multipart::Part::bytes(file_bytes) - .file_name(file_name) - .mime_str("image/jpeg") - .unwrap_or_else(|_| reqwest::multipart::Part::bytes(std::fs::read(&file_path).unwrap_or_default()).file_name("profile.jpg")); - let form = reqwest::multipart::Form::new() - .part("image", part); - let url = format!("{}/api/v1/identity/{}/profile-image?api_key={}", CORE_API, uuid, API_KEY); - let resp = get_client().post(&url) - .multipart(form) - .send().await - .map_err(|e| format!("Upload failed: {}", e))?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(format!("Upload failed: {} {}", status, body)); - } - let mut cache = PROFILE_CACHE.lock().unwrap(); - if let Some(c) = cache.as_mut() { - c.pop(&uuid); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn match_from_photo(identity_uuid: String, file_uuid: String, image_path: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/agents/identity/match-from-photo?api_key={}", CORE_API, API_KEY); - let mut form = reqwest::multipart::Form::new() - .text("identity_uuid", identity_uuid.replace('-', "")) - .text("file_uuid", file_uuid); - if let Some(path) = image_path { - let file_bytes = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?; - let part = reqwest::multipart::Part::bytes(file_bytes) - .file_name("profile.jpg") - .mime_str("image/jpeg") - .unwrap_or_else(|_| reqwest::multipart::Part::bytes(std::fs::read(&path).unwrap_or_default()).file_name("profile.jpg")); - form = form.part("image", part); - } - let resp = client.post(&url) - .multipart(form) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(format!("Match failed: {} {}", status, body)); - } - let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn set_profile_from_face(uuid: String, file_uuid: String, face_id: Option, id: Option, trace_id: Option, frame_number: Option) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/profile-image/from-face?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({"file_uuid": file_uuid}); - if let Some(fid) = face_id { - body["face_id"] = serde_json::json!(fid); - } else if let Some(rid) = id { - body["id"] = serde_json::json!(rid); - } else if let Some(tid) = trace_id { - body["trace_id"] = serde_json::json!(tid); - if let Some(frame) = frame_number { - body["frame_number"] = serde_json::json!(frame); - } - } else { - return Err("Either face_id, id, or trace_id is required".to_string()); - } - let resp = client.post(&url) - .json(&body) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - let status = resp.status(); - let body_text = resp.text().await.unwrap_or_default(); - return Err(format!("Set profile failed: {} {}", status, body_text)); - } - let mut cache = PROFILE_CACHE.lock().unwrap(); - if let Some(c) = cache.as_mut() { - c.pop(&uuid); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn delete_identity(uuid: String) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - let resp = client.delete(&url) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() && resp.status() != 204 { - return Err(format!("Delete failed: {}", resp.status())); - } - Ok(()) -} - -#[derive(Serialize)] -struct CreateIdentityFromFaceResult { - success: bool, - identity_uuid: String, - name: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn create_identity_from_face(name: String, _face_id: Option, _face_row_id: Option, file_uuid: String) -> Result { - let client = get_client(); - - // Use POST /api/v1/file/:file_uuid/pending-person to create pending identity - let url = format!("{}/api/v1/file/{}/pending-person?api_key={}", CORE_API, file_uuid, API_KEY); - let body = serde_json::json!({ - "name": name - }); - - let resp = client.post(&url) - .json(&body) - .send().await - .map_err(|e| format!("Create pending person request failed: {}", e))?; - - let status = resp.status(); - let resp_json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse response failed: {}", e))?; - - eprintln!("[create_identity_from_face] status={} resp={}", status, resp_json); - - if !status.is_success() { - return Err(format!("Create pending person failed: {} - {}", status, resp_json["error"].as_str().unwrap_or("unknown error"))); - } - - // identity_uuid is inside data object - let data_obj = resp_json.get("data").unwrap_or(&resp_json); - let new_uuid = data_obj.get("identity_uuid") - .and_then(|v| v.as_str()) - .or_else(|| resp_json.get("identity_uuid").and_then(|v| v.as_str())) - .or_else(|| resp_json.get("uuid").and_then(|v| v.as_str())) - .unwrap_or(""); - - if new_uuid.is_empty() { - return Err("Create pending person failed: no uuid returned".to_string()); - } - - Ok(CreateIdentityFromFaceResult { - success: true, - identity_uuid: new_uuid.to_string(), - name, - }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn search_identities(query: String, limit: usize) -> Result, String> { - let url = format!("{}/api/v1/identities/search?api_key={}&q={}", CORE_API, API_KEY, query); - let resp = get_client().get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?; - let results: Vec = json["results"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .take(limit) - .filter_map(|r| { - Some(SearchIdentityResult { - identity_id: r["identity_id"].as_i64().unwrap_or(0), - name: r["name"].as_str()?.to_string(), - source: r["source"].as_str().unwrap_or("").to_string(), - tmdb_id: r["tmdb_id"].as_i64(), - file_uuid: r["file_uuid"].as_str().map(|s| s.to_string()), - trace_id: r["trace_id"].as_i64(), - start_frame: r["start_frame"].as_i64(), - end_frame: r["end_frame"].as_i64(), - start_time: r["start_time"].as_f64().unwrap_or(0.0), - end_time: r["end_time"].as_f64().unwrap_or(0.0), - text_content: r["text_content"].as_str().map(|s| s.to_string()), - }) - }) - .collect(); - Ok(results) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_face_candidates(page: usize, per_page: usize) -> Result, String> { - let client = get_client(); - let url = format!("{}/api/v1/faces/candidates?api_key={}&page={}&page_size={}", CORE_API, API_KEY, page, per_page); - let resp = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?; - let candidates: Vec = json["candidates"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|c| { - Some(FaceCandidate { - id: c["id"].as_i64().unwrap_or(0), - face_id: c["face_id"].as_str().map(|s| s.to_string()), - file_uuid: c["file_uuid"].as_str()?.to_string(), - frame_number: c["frame_number"].as_i64().unwrap_or(0), - confidence: c["confidence"].as_f64().unwrap_or(0.0), - bbox: c["bbox"].as_object().map(|o| FaceBBox { - x: o["x"].as_f64().unwrap_or(0.0), - y: o["y"].as_f64().unwrap_or(0.0), - width: o["width"].as_f64().unwrap_or(0.0), - height: o["height"].as_f64().unwrap_or(0.0), - }), - }) - }) - .collect(); - Ok(candidates) -} - -#[tauri::command(rename_all = "camelCase")] -async fn merge_identities(uuid: String, into_uuid: String) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/mergeinto?api_key={}", CORE_API, uuid, API_KEY); - let resp = client.post(&url) - .json(&serde_json::json!({"into_uuid": into_uuid})) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Merge failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn bind_face(uuid: String, face_id: Option, face_row_id: Option, file_uuid: String, expand_to_trace: Option) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/bind?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({"file_uuid": file_uuid}); - if let Some(fid) = &face_id { - body["face_id"] = serde_json::json!(fid); - } else if let Some(rid) = face_row_id { - body["id"] = serde_json::json!(rid); - } else { - return Err("Either face_id or face_row_id is required".to_string()); - } - if let Some(expand) = expand_to_trace { - body["expand_to_trace"] = serde_json::json!(expand); - } - let resp = client.post(&url) - .json(&body) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Bind failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn unbind_face(uuid: String, face_id: Option, face_row_id: Option, file_uuid: String, _frame_number: Option) -> Result<(), String> { - let client = get_client(); - let mut body = serde_json::json!({"file_uuid": file_uuid}); - if let Some(fid) = face_id { - body["face_id"] = serde_json::json!(fid); - } else if let Some(rid) = face_row_id { - body["id"] = serde_json::json!(rid); - } else { - return Err("Either face_id or face_row_id is required".to_string()); - } - let url = format!("{}/api/v1/identity/{}/unbind?api_key={}", CORE_API, uuid, API_KEY); - eprintln!("[unbind_face] POST {} body={}", url, body); - let resp = client.post(&url) - .json(&body) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - let status = resp.status(); - let resp_text = resp.text().await.unwrap_or_default(); - eprintln!("[unbind_face] status={} body={}", status, resp_text); - if !status.is_success() { - return Err(format!("Unbind failed: {} - {}", status, resp_text)); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_undo(uuid: String, steps: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/undo?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(s) = steps { - body["steps"] = serde_json::Value::Number(s.into()); - } - let resp = client.post(&url).json(&body).send().await - .map_err(|e| format!("Undo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_redo(uuid: String, steps: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/redo?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(s) = steps { - body["steps"] = serde_json::Value::Number(s.into()); - } - let resp = client.post(&url).json(&body).send().await - .map_err(|e| format!("Redo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_history(uuid: String, page: Option, page_size: Option) -> Result { - let client = get_client(); - let mut url = format!("{}/api/v1/identity/{}/history?api_key={}", CORE_API, uuid, API_KEY); - if let Some(p) = page { url = format!("{}&page={}", url, p); } - if let Some(ps) = page_size { url = format!("{}&page_size={}", url, ps); } - let resp = client.get(&url).send().await - .map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_bind_undo(uuid: String, steps: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/bind/undo?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(s) = steps { - body["steps"] = serde_json::Value::Number(s.into()); - } - let resp = client.post(&url).json(&body).send().await - .map_err(|e| format!("Bind undo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_bind_redo(uuid: String, steps: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/bind/redo?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(s) = steps { - body["steps"] = serde_json::Value::Number(s.into()); - } - let resp = client.post(&url).json(&body).send().await - .map_err(|e| format!("Bind redo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_bind_history(uuid: String, page: Option, page_size: Option) -> Result { - let client = get_client(); - let mut url = format!("{}/api/v1/identity/{}/bind/history?api_key={}", CORE_API, uuid, API_KEY); - if let Some(p) = page { url = format!("{}&page={}", url, p); } - if let Some(ps) = page_size { url = format!("{}&page_size={}", url, ps); } - let resp = client.get(&url).send().await - .map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn merge_undo(merge_id: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/merge/{}/undo?api_key={}", CORE_API, merge_id, API_KEY); - let resp = client.post(&url).send().await - .map_err(|e| format!("Merge undo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn merge_redo(merge_id: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/merge/{}/redo?api_key={}", CORE_API, merge_id, API_KEY); - let resp = client.post(&url).send().await - .map_err(|e| format!("Merge redo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn merge_history(source_uuid: Option, target_uuid: Option, page: Option, page_size: Option) -> Result { - let client = get_client(); - let mut url = format!("{}/api/v1/identity/merge/history?api_key={}", CORE_API, API_KEY); - if let Some(s) = source_uuid { url = format!("{}&source_uuid={}", url, s); } - if let Some(t) = target_uuid { url = format!("{}&target_uuid={}", url, t); } - if let Some(p) = page { url = format!("{}&page={}", url, p); } - if let Some(ps) = page_size { url = format!("{}&page_size={}", url, ps); } - let resp = client.get(&url).send().await - .map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -fn main() { - std::thread::spawn(|| { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(proxy::start_proxy_server()); - }); - - tauri::Builder::default() - .plugin(tauri_plugin_shell::init()) - .setup(|app| { - use tauri::Manager; - let _ = app.get_webview_window("main"); - if let Err(e) = db::init_db() { - eprintln!("Failed to init database: {}", e); - } - Ok(()) - }) -.invoke_handler(tauri::generate_handler![ - search_llm_smart, - search_agents, - get_files, - register_file, - process_file, - ingest_file, - checkout_file, - unregister_file, - get_people, - get_faces, - get_traces, - get_file_info, - get_thumbnail, - get_face_thumbnail, - get_identity_profile, - update_identity_name, - update_identity_status, - update_identity_starred, - update_identity, - upload_profile_image, - set_profile_from_face, - match_from_photo, - delete_identity, - create_identity_from_face, - search_identities, - get_face_candidates, - merge_identities, - bind_face, - unbind_face, - identity_undo, - identity_redo, - identity_history, - identity_bind_undo, - identity_bind_redo, - identity_bind_history, - merge_undo, - merge_redo, - merge_history, - db::get_search_history, - db::save_search_history, - db::rename_search_history, - db::pin_search_history, - db::delete_search_history, - db::get_bookmarks, - db::save_bookmark, - db::delete_bookmark - ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} diff --git a/src-tauri/src/main.rs.base b/src-tauri/src/main.rs.base deleted file mode 100644 index e31e78c..0000000 --- a/src-tauri/src/main.rs.base +++ /dev/null @@ -1,1177 +0,0 @@ -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -mod proxy; -mod db; - -use serde::Serialize; -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use std::sync::Mutex; -use std::num::NonZeroUsize; -use lru::LruCache; -use image::GenericImageView; - -static THUMB_CACHE: Mutex>> = Mutex::new(None); -static PROFILE_CACHE: Mutex>> = Mutex::new(None); - -fn get_client() -> reqwest::Client { - reqwest::Client::builder() - .connect_timeout(std::time::Duration::from_secs(10)) - .timeout(std::time::Duration::from_secs(60)) - .pool_max_idle_per_host(10) - .build() - .expect("Failed to build HTTP client") -} - -#[derive(Serialize)] -struct SearchResult { - file_uuid: String, - start_time: f64, - end_time: f64, - start_frame: i64, - end_frame: i64, - summary: String, - similarity: f64, - file_name: Option, -} - -#[derive(Serialize)] -struct FileInfo { - file_uuid: String, - file_name: String, - file_path: String, - file_size: i64, - modified_time: String, - #[serde(rename = "isRegistered")] - is_registered: bool, - status: String, - registration_time: Option, - #[serde(rename = "ingested")] - ingested: bool, -} - -#[derive(Serialize)] -struct PersonInfo { - identity_uuid: String, - name: String, - starred: bool, - status: String, - metadata: serde_json::Value, -} - -#[derive(Serialize)] -struct FaceInfo { - id: i64, - file_uuid: String, - frame_number: i64, - timestamp_secs: f64, - face_id: Option, - confidence: f64, - bbox: Option, -} - -#[derive(Serialize)] -struct TraceInfo { - trace_id: i32, - file_uuid: String, - frame_count: i64, - first_frame: i64, - last_frame: i64, - first_sec: f64, - last_sec: f64, - avg_confidence: f64, - face_id: Option, -} - -#[derive(Serialize)] -struct FileDetail { - file_uuid: String, - file_name: String, - fps: f64, - duration: f64, -} - -#[derive(Serialize)] -struct FaceCandidate { - id: i64, - face_id: Option, - file_uuid: String, - frame_number: i64, - confidence: f64, - bbox: Option, -} - -#[derive(Serialize)] -struct FaceBBox { - x: f64, - y: f64, - width: f64, - height: f64, -} - -#[derive(Serialize)] -struct SearchIdentityResult { - identity_id: i64, - name: String, - source: String, - tmdb_id: Option, - file_uuid: Option, - trace_id: Option, - start_frame: Option, - end_frame: Option, - start_time: f64, - end_time: f64, - text_content: Option, -} - -const CORE_API: &str = "http://localhost:3002"; -const API_KEY: &str = "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69"; - -#[tauri::command(rename_all = "camelCase")] -async fn search_llm_smart(query: String, limit: usize) -> Result, String> { - let client = get_client(); - let url = format!("{}/api/v1/search/llm-smart?api_key={}", CORE_API, API_KEY); - - let response = client - .post(&url) - .json(&serde_json::json!({"query": query, "limit": limit})) - .send() - .await - .map_err(|e| format!("Request failed: {}", e))?; - - let json: serde_json::Value = response - .json() - .await - .map_err(|e| format!("Parse failed: {}", e))?; - - let results: Vec = json["results"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|r| { - Some(SearchResult { - file_uuid: r["file_uuid"].as_str()?.to_string(), - start_time: r["start_time"].as_f64()?, - end_time: r["end_time"].as_f64()?, - start_frame: r["start_frame"].as_i64().unwrap_or(0), - end_frame: r["end_frame"].as_i64().unwrap_or(0), - summary: r["summary"].as_str()?.to_string(), - similarity: r["similarity"].as_f64().unwrap_or(0.0), - file_name: r["file_name"].as_str().map(|s| s.to_string()), - }) - }) - .collect(); - - Ok(results) -} - -#[derive(serde::Deserialize)] -struct GetFilesArgs { - #[serde(rename = "pageSize")] - page_size: usize, -} - -#[tauri::command(rename_all = "camelCase")] -async fn search_agents(query: String, conversation_id: Option) -> Result { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(120)) - .build() - .map_err(|e| format!("Client build failed: {}", e))?; - let url = format!("{}/api/v1/agents/search?api_key={}", CORE_API, API_KEY); - let mut body = serde_json::json!({"query": query}); - if let Some(cid) = conversation_id { - body["conversation_id"] = serde_json::Value::String(cid); - } - let response = client - .post(&url) - .json(&body) - .send() - .await - .map_err(|e| format!("Agent search request failed: {}", e))?; - let json: serde_json::Value = response - .json() - .await - .map_err(|e| format!("Agent search parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_files(args: GetFilesArgs) -> Result, String> { - let page_size = args.page_size; - let client = get_client(); - let url = format!("{}/api/v1/files/scan?api_key={}&page_size={}", CORE_API, API_KEY, page_size); - - let response = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = response.json().await.map_err(|e| format!("Parse failed: {}", e))?; - - let files: Vec = json["files"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|f| { - let is_reg = f["is_registered"].as_bool().unwrap_or(false); - let file_uuid = f["file_uuid"].as_str() - .unwrap_or_else(|| f["file_path"].as_str().unwrap_or("")).to_string(); - let status = f["status"].as_str().unwrap_or("").to_string(); - Some(FileInfo { - file_uuid, - file_name: f["file_name"].as_str()?.to_string(), - file_path: f["file_path"].as_str().unwrap_or("").to_string(), - file_size: f["file_size"].as_i64().unwrap_or(0), - modified_time: f["modified_time"].as_str().unwrap_or("").to_string(), - is_registered: is_reg, - registration_time: f["registration_time"].as_str().map(|s| s.to_string()), - ingested: status == "completed", - status, - }) - }) - .collect(); - - Ok(files) -} - -#[derive(Serialize)] -struct RegisterResult { - success: bool, - file_uuid: String, - file_name: String, - message: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn register_file(file_path: String) -> Result { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(120)) - .build() - .map_err(|e| format!("Client build failed: {}", e))?; - let url = format!("{}/api/v1/files/register?api_key={}", CORE_API, API_KEY); - let response = client.post(&url) - .json(&serde_json::json!({"file_path": file_path})) - .send() - .await - .map_err(|e| format!("Register request failed: {}", e))?; - let json: serde_json::Value = response.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(RegisterResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(), - file_name: json["file_name"].as_str().unwrap_or("").to_string(), - message: json["message"].as_str().unwrap_or("").to_string(), - }) -} - -#[derive(Serialize)] -struct ProcessResult { - success: bool, - file_uuid: String, - message: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn process_file(file_uuid: String, processors: Vec) -> Result { - let url = format!("{}/api/v1/file/{}/process?api_key={}", CORE_API, file_uuid, API_KEY); - let response = get_client().post(&url) - .json(&serde_json::json!({"processors": processors})) - .send() - .await - .map_err(|e| format!("Process request failed: {}", e))?; - let json: serde_json::Value = response.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(ProcessResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(), - message: json["message"].as_str().unwrap_or("").to_string(), - }) -} - -#[derive(Serialize)] -struct IngestResult { - success: bool, - file_uuid: String, - message: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn ingest_file(file_uuid: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/file/{}/checkin?api_key={}", CORE_API, file_uuid, API_KEY); - let resp = client.post(&url).send().await - .map_err(|e| format!("Ingest (checkin) request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Ingest (checkin) parse failed: {}", e))?; - Ok(IngestResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid, - message: json["message"].as_str().unwrap_or("Checkin complete").to_string(), - }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn checkout_file(file_uuid: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/file/{}/checkout?api_key={}", CORE_API, file_uuid, API_KEY); - let resp = client.post(&url).send().await - .map_err(|e| format!("Checkout request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Checkout parse failed: {}", e))?; - Ok(IngestResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid, - message: json["message"].as_str().unwrap_or("Checkout complete").to_string(), - }) -} - -#[derive(Serialize)] -struct UnregisterResult { - success: bool, - file_uuid: String, - message: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn unregister_file(file_uuid: String, delete_output_files: Option) -> Result { - let url = format!("{}/api/v1/unregister?api_key={}", CORE_API, API_KEY); - let mut body = serde_json::json!({"file_uuid": file_uuid}); - if let Some(dof) = delete_output_files { - body["delete_output_files"] = serde_json::Value::Bool(dof); - } - let response = get_client().post(&url) - .json(&body) - .send() - .await - .map_err(|e| format!("Unregister request failed: {}", e))?; - let json: serde_json::Value = response.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(UnregisterResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(), - message: json["message"].as_str().unwrap_or("").to_string(), - }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_people(_page: usize, _per_page: usize) -> Result, String> { - eprintln!("[get_people] called"); - let mut people = Vec::new(); - for page in 1u32..=2 { - 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, - Err(e) => { - eprintln!("[get_people] page {} request failed: {} — continuing", page, e); - continue; - } - }; - let json: serde_json::Value = match resp.json().await { - Ok(v) => v, - Err(e) => { - eprintln!("[get_people] page {} parse error: {} — continuing", page, e); - continue; - } - }; - let identities = match json["identities"].as_array() { - Some(arr) => arr, - None => { - eprintln!("[get_people] page {} has no identities array — stopping", page); - break; - } - }; - if identities.is_empty() { - eprintln!("[get_people] page {} empty — stopping", page); - break; - } - for i in identities { - let uuid = match i["identity_uuid"].as_str() { - Some(u) => u.to_string(), - None => continue, - }; - let name = match i["name"].as_str() { - Some(n) => n.to_string(), - None => continue, - }; - people.push(PersonInfo { - identity_uuid: uuid, - name, - starred: i["starred"].as_bool().unwrap_or_else(|| i["metadata"]["starred"].as_bool().unwrap_or(false)), - status: i["status"].as_str().unwrap_or_else(|| i["metadata"]["status"].as_str().unwrap_or("pending")).to_string(), - metadata: i["metadata"].clone(), - }); - } - } - eprintln!("[get_people] returning {} people", people.len()); - Ok(people) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_faces(uuid: String, per_page: usize) -> Result, String> { - let url = format!("{}/api/v1/identity/{}/faces?api_key={}&page_size={}", CORE_API, uuid, API_KEY, per_page); - let t = std::time::Instant::now(); - eprintln!("[get_faces] --> {}", uuid); - - let response = match get_client().get(&url).send().await { - Ok(r) => r, - Err(e) => { - eprintln!("[get_faces] <-- {} ERROR {} {:?}", uuid, e, t.elapsed()); - return Ok(vec![]); - } - }; - let json: serde_json::Value = match response.json().await { - Ok(v) => v, - Err(e) => { - eprintln!("[get_faces] <-- {} PARSE_ERR {} {:?}", uuid, e, t.elapsed()); - return Ok(vec![]); - } - }; - - let faces: Vec = json["data"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|f| { - Some(FaceInfo { - id: f["id"].as_i64().unwrap_or(0), - file_uuid: f["file_uuid"].as_str()?.to_string(), - frame_number: f["frame_number"].as_i64().unwrap_or(0), - timestamp_secs: f["timestamp_secs"].as_f64().unwrap_or(0.0), - face_id: f["face_id"].as_str().map(|s| s.to_string()), - confidence: f["confidence"].as_f64().unwrap_or(0.0), - bbox: f["bbox"].as_object().map(|o| FaceBBox { - x: o["x"].as_f64().unwrap_or(0.0), - y: o["y"].as_f64().unwrap_or(0.0), - width: o["width"].as_f64().unwrap_or(0.0), - height: o["height"].as_f64().unwrap_or(0.0), - }), - }) - }) - .collect(); - - eprintln!("[get_faces] <-- {} {} faces {:?}", uuid, faces.len(), t.elapsed()); - Ok(faces) -} - -#[derive(Serialize)] -struct TracesResponse { - total: i64, - traces: Vec, -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_traces(uuid: String, per_page: usize, page: Option) -> Result { - let page_size = per_page.min(100); - let page_num = page.unwrap_or(1); - let t = std::time::Instant::now(); - eprintln!("[get_traces] --> {} page={} page_size={}", uuid, page_num, page_size); - - let url = format!("{}/api/v1/identity/{}/traces?api_key={}&page_size={}&page={}", CORE_API, uuid, API_KEY, page_size, page_num); - - let resp = get_client().get(&url).send().await - .map_err(|e| format!("Traces request failed: {}", e))?; - - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Traces parse failed: {}", e))?; - - let total = json["total"].as_i64().unwrap_or(0); - - let traces: Vec = json["traces"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|t| { - Some(TraceInfo { - trace_id: t["trace_id"].as_i64().unwrap_or(0) as i32, - file_uuid: t["file_uuid"].as_str()?.to_string(), - frame_count: t["frame_count"].as_i64().unwrap_or(0), - first_frame: t["first_frame"].as_i64().unwrap_or(0), - last_frame: t["last_frame"].as_i64().unwrap_or(0), - first_sec: t["first_sec"].as_f64().unwrap_or(0.0), - last_sec: t["last_sec"].as_f64().unwrap_or(0.0), - avg_confidence: t["avg_confidence"].as_f64().unwrap_or(0.0), - face_id: t["face_id"].as_str().map(String::from), - }) - }) - .collect(); - - eprintln!("[get_traces] <-- {} total={} {} traces {:?}", uuid, total, traces.len(), t.elapsed()); - Ok(TracesResponse { total, traces }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_file_info(uuid: String) -> Result { - let url = format!("{}/api/v1/file/{}?api_key={}", CORE_API, uuid, API_KEY); - let resp = get_client().get(&url).send().await - .map_err(|e| format!("File info request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("File info parse failed: {}", e))?; - Ok(FileDetail { - file_uuid: json["file_uuid"].as_str().unwrap_or("").to_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), - }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_thumbnail(uuid: String, frame: u32) -> Result { - let key = format!("{}:{}", uuid, frame); - { - 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 = 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, bbox_y: Option, bbox_w: Option, bbox_h: Option) -> Result { - 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 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(); - 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) - }; - 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); - let ch = ph.min(h.saturating_sub(cy)).max(1); - let cropped = img.crop_imm(cx, cy, cw, ch); - let mut buf = std::io::Cursor::new(Vec::new()); - cropped.write_to(&mut buf, image::ImageFormat::Jpeg).map_err(|e| format!("Encode failed: {}", e))?; - format!("data:image/jpeg;base64,{}", STANDARD.encode(buf.into_inner())) - } else { - 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) -} - -const PROFILE_DIRS: &[&str] = &[ - "/Users/accusys/momentry/output/identities", - "/Users/accusys/momentry/output_dev/identities", - "/Volumes/external/momentry/output/identities", - "/Volumes/external/momentry/output_dev/identities", -]; - -#[tauri::command(rename_all = "camelCase")] -async fn get_identity_profile(uuid: String) -> Result { - let uuid_display = uuid.clone(); - let no_dash = uuid.replace('-', ""); - { - let mut cache = PROFILE_CACHE.lock().unwrap(); - if let Some(cache) = cache.as_mut() { - if let Some(cached) = cache.get(&uuid_display) { - eprintln!("[get_identity_profile] <-- {} (cached)", uuid_display); - return Ok(cached.clone()); - } - } - } - let t = std::time::Instant::now(); - let result = (|| -> Option { - for dir in PROFILE_DIRS { - for candidate in [&no_dash, &uuid_display] { - let path = format!("{}/{}/profile.jpg", dir, candidate); - if std::path::Path::new(&path).exists() { - let bytes = std::fs::read(&path).ok()?; - return Some(format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes))); - } - } - } - None - })(); - match result { - Some(data) => { - let mut cache = PROFILE_CACHE.lock().unwrap(); - let cache = cache.get_or_insert_with(|| LruCache::new(NonZeroUsize::new(500).unwrap())); - cache.put(uuid_display.clone(), data.clone()); - eprintln!("[get_identity_profile] <-- {} {} {}ms", uuid_display, "FOUND", t.elapsed().as_millis()); - Ok(data) - } - None => { - eprintln!("[get_identity_profile] <-- {} {} {}ms", uuid_display, "404", t.elapsed().as_millis()); - Err("Profile image not found".to_string()) - } - } -} - -#[tauri::command(rename_all = "camelCase")] -async fn update_identity_name(uuid: String, name: String) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - let resp = client.patch(&url) - .json(&serde_json::json!({"name": name})) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Update failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn update_identity_status(uuid: String, status: String) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - // GET current identity to merge metadata (preserve starred, role, notes, etc.) - let get_resp = client.get(&url).send().await - .map_err(|e| format!("GET failed: {}", e))?; - let json: serde_json::Value = get_resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - let mut metadata = json["metadata"].as_object() - .cloned() - .unwrap_or_default(); - metadata.insert("status".to_string(), serde_json::Value::String(status)); - let resp = client.patch(&url) - .json(&serde_json::json!({"metadata": metadata})) - .send().await - .map_err(|e| format!("PATCH failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Update failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn update_identity_starred(uuid: String, starred: bool) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - let get_resp = client.get(&url).send().await - .map_err(|e| format!("GET failed: {}", e))?; - let json: serde_json::Value = get_resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - let mut metadata = json["metadata"].as_object() - .cloned() - .unwrap_or_default(); - metadata.insert("starred".to_string(), serde_json::Value::Bool(starred)); - let resp = client.patch(&url) - .json(&serde_json::json!({"metadata": metadata})) - .send().await - .map_err(|e| format!("PATCH failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Update failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn update_identity(uuid: String, name: Option, metadata_json: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(n) = &name { - body["name"] = serde_json::Value::String(n.clone()); - } - if !metadata_json.is_empty() { - let meta: serde_json::Value = serde_json::from_str(&metadata_json).map_err(|e| format!("Invalid metadata JSON: {}", e))?; - body["metadata"] = meta; - } - let resp = client.patch(&url) - .json(&body) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn upload_profile_image(uuid: String, file_path: String) -> Result<(), String> { - let file_bytes = std::fs::read(&file_path).map_err(|e| format!("Failed to read file: {}", e))?; - let file_name = std::path::Path::new(&file_path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("profile.jpg") - .to_string(); - let part = reqwest::multipart::Part::bytes(file_bytes) - .file_name(file_name) - .mime_str("image/jpeg") - .unwrap_or_else(|_| reqwest::multipart::Part::bytes(std::fs::read(&file_path).unwrap_or_default()).file_name("profile.jpg")); - let form = reqwest::multipart::Form::new() - .part("image", part); - let url = format!("{}/api/v1/identity/{}/profile-image?api_key={}", CORE_API, uuid, API_KEY); - let resp = get_client().post(&url) - .multipart(form) - .send().await - .map_err(|e| format!("Upload failed: {}", e))?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(format!("Upload failed: {} {}", status, body)); - } - let mut cache = PROFILE_CACHE.lock().unwrap(); - if let Some(c) = cache.as_mut() { - c.pop(&uuid); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn match_from_photo(identity_uuid: String, file_uuid: String, image_path: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/agents/identity/match-from-photo?api_key={}", CORE_API, API_KEY); - let mut form = reqwest::multipart::Form::new() - .text("identity_uuid", identity_uuid.replace('-', "")) - .text("file_uuid", file_uuid); - if let Some(path) = image_path { - let file_bytes = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?; - let part = reqwest::multipart::Part::bytes(file_bytes) - .file_name("profile.jpg") - .mime_str("image/jpeg") - .unwrap_or_else(|_| reqwest::multipart::Part::bytes(std::fs::read(&path).unwrap_or_default()).file_name("profile.jpg")); - form = form.part("image", part); - } - let resp = client.post(&url) - .multipart(form) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(format!("Match failed: {} {}", status, body)); - } - let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn set_profile_from_face(uuid: String, file_uuid: String, face_id: Option, id: Option, trace_id: Option, frame_number: Option) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/profile-image/from-face?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({"file_uuid": file_uuid}); - if let Some(fid) = face_id { - body["face_id"] = serde_json::json!(fid); - } else if let Some(rid) = id { - body["id"] = serde_json::json!(rid); - } else if let Some(tid) = trace_id { - body["trace_id"] = serde_json::json!(tid); - if let Some(frame) = frame_number { - body["frame_number"] = serde_json::json!(frame); - } - } else { - return Err("Either face_id, id, or trace_id is required".to_string()); - } - let resp = client.post(&url) - .json(&body) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - let status = resp.status(); - let body_text = resp.text().await.unwrap_or_default(); - return Err(format!("Set profile failed: {} {}", status, body_text)); - } - let mut cache = PROFILE_CACHE.lock().unwrap(); - if let Some(c) = cache.as_mut() { - c.pop(&uuid); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn delete_identity(uuid: String) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - let resp = client.delete(&url) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() && resp.status() != 204 { - return Err(format!("Delete failed: {}", resp.status())); - } - Ok(()) -} - -#[derive(Serialize)] -struct CreateIdentityFromFaceResult { - success: bool, - identity_uuid: String, - name: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn create_identity_from_face(name: String, _face_id: Option, _face_row_id: Option, file_uuid: String) -> Result { - let client = get_client(); - - // Use POST /api/v1/file/:file_uuid/pending-person to create pending identity - let url = format!("{}/api/v1/file/{}/pending-person?api_key={}", CORE_API, file_uuid, API_KEY); - let body = serde_json::json!({ - "name": name - }); - - let resp = client.post(&url) - .json(&body) - .send().await - .map_err(|e| format!("Create pending person request failed: {}", e))?; - - let status = resp.status(); - let resp_json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse response failed: {}", e))?; - - eprintln!("[create_identity_from_face] status={} resp={}", status, resp_json); - - if !status.is_success() { - return Err(format!("Create pending person failed: {} - {}", status, resp_json["error"].as_str().unwrap_or("unknown error"))); - } - - // identity_uuid is inside data object - let data_obj = resp_json.get("data").unwrap_or(&resp_json); - let new_uuid = data_obj.get("identity_uuid") - .and_then(|v| v.as_str()) - .or_else(|| resp_json.get("identity_uuid").and_then(|v| v.as_str())) - .or_else(|| resp_json.get("uuid").and_then(|v| v.as_str())) - .unwrap_or(""); - - if new_uuid.is_empty() { - return Err("Create pending person failed: no uuid returned".to_string()); - } - - Ok(CreateIdentityFromFaceResult { - success: true, - identity_uuid: new_uuid.to_string(), - name, - }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn search_identities(query: String, limit: usize) -> Result, String> { - let url = format!("{}/api/v1/identities/search?api_key={}&q={}", CORE_API, API_KEY, query); - let resp = get_client().get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?; - let results: Vec = json["results"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .take(limit) - .filter_map(|r| { - Some(SearchIdentityResult { - identity_id: r["identity_id"].as_i64().unwrap_or(0), - name: r["name"].as_str()?.to_string(), - source: r["source"].as_str().unwrap_or("").to_string(), - tmdb_id: r["tmdb_id"].as_i64(), - file_uuid: r["file_uuid"].as_str().map(|s| s.to_string()), - trace_id: r["trace_id"].as_i64(), - start_frame: r["start_frame"].as_i64(), - end_frame: r["end_frame"].as_i64(), - start_time: r["start_time"].as_f64().unwrap_or(0.0), - end_time: r["end_time"].as_f64().unwrap_or(0.0), - text_content: r["text_content"].as_str().map(|s| s.to_string()), - }) - }) - .collect(); - Ok(results) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_face_candidates(page: usize, per_page: usize) -> Result, String> { - let client = get_client(); - let url = format!("{}/api/v1/faces/candidates?api_key={}&page={}&page_size={}", CORE_API, API_KEY, page, per_page); - let resp = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?; - let candidates: Vec = json["candidates"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|c| { - Some(FaceCandidate { - id: c["id"].as_i64().unwrap_or(0), - face_id: c["face_id"].as_str().map(|s| s.to_string()), - file_uuid: c["file_uuid"].as_str()?.to_string(), - frame_number: c["frame_number"].as_i64().unwrap_or(0), - confidence: c["confidence"].as_f64().unwrap_or(0.0), - bbox: c["bbox"].as_object().map(|o| FaceBBox { - x: o["x"].as_f64().unwrap_or(0.0), - y: o["y"].as_f64().unwrap_or(0.0), - width: o["width"].as_f64().unwrap_or(0.0), - height: o["height"].as_f64().unwrap_or(0.0), - }), - }) - }) - .collect(); - Ok(candidates) -} - -#[tauri::command(rename_all = "camelCase")] -async fn merge_identities(uuid: String, into_uuid: String) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/mergeinto?api_key={}", CORE_API, uuid, API_KEY); - let resp = client.post(&url) - .json(&serde_json::json!({"into_uuid": into_uuid})) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Merge failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn bind_face(uuid: String, face_id: Option, face_row_id: Option, file_uuid: String, expand_to_trace: Option) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/bind?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({"file_uuid": file_uuid}); - if let Some(fid) = &face_id { - body["face_id"] = serde_json::json!(fid); - } else if let Some(rid) = face_row_id { - body["id"] = serde_json::json!(rid); - } else { - return Err("Either face_id or face_row_id is required".to_string()); - } - if let Some(expand) = expand_to_trace { - body["expand_to_trace"] = serde_json::json!(expand); - } - let resp = client.post(&url) - .json(&body) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Bind failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn unbind_face(uuid: String, face_id: Option, face_row_id: Option, file_uuid: String, _frame_number: Option) -> Result<(), String> { - let client = get_client(); - let mut body = serde_json::json!({"file_uuid": file_uuid}); - if let Some(fid) = face_id { - body["face_id"] = serde_json::json!(fid); - } else if let Some(rid) = face_row_id { - body["id"] = serde_json::json!(rid); - } else { - return Err("Either face_id or face_row_id is required".to_string()); - } - let url = format!("{}/api/v1/identity/{}/unbind?api_key={}", CORE_API, uuid, API_KEY); - eprintln!("[unbind_face] POST {} body={}", url, body); - let resp = client.post(&url) - .json(&body) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - let status = resp.status(); - let resp_text = resp.text().await.unwrap_or_default(); - eprintln!("[unbind_face] status={} body={}", status, resp_text); - if !status.is_success() { - return Err(format!("Unbind failed: {} - {}", status, resp_text)); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_undo(uuid: String, steps: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/undo?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(s) = steps { - body["steps"] = serde_json::Value::Number(s.into()); - } - let resp = client.post(&url).json(&body).send().await - .map_err(|e| format!("Undo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_redo(uuid: String, steps: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/redo?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(s) = steps { - body["steps"] = serde_json::Value::Number(s.into()); - } - let resp = client.post(&url).json(&body).send().await - .map_err(|e| format!("Redo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_history(uuid: String, page: Option, page_size: Option) -> Result { - let client = get_client(); - let mut url = format!("{}/api/v1/identity/{}/history?api_key={}", CORE_API, uuid, API_KEY); - if let Some(p) = page { url = format!("{}&page={}", url, p); } - if let Some(ps) = page_size { url = format!("{}&page_size={}", url, ps); } - let resp = client.get(&url).send().await - .map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_bind_undo(uuid: String, steps: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/bind/undo?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(s) = steps { - body["steps"] = serde_json::Value::Number(s.into()); - } - let resp = client.post(&url).json(&body).send().await - .map_err(|e| format!("Bind undo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_bind_redo(uuid: String, steps: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/bind/redo?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(s) = steps { - body["steps"] = serde_json::Value::Number(s.into()); - } - let resp = client.post(&url).json(&body).send().await - .map_err(|e| format!("Bind redo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_bind_history(uuid: String, page: Option, page_size: Option) -> Result { - let client = get_client(); - let mut url = format!("{}/api/v1/identity/{}/bind/history?api_key={}", CORE_API, uuid, API_KEY); - if let Some(p) = page { url = format!("{}&page={}", url, p); } - if let Some(ps) = page_size { url = format!("{}&page_size={}", url, ps); } - let resp = client.get(&url).send().await - .map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn merge_undo(merge_id: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/merge/{}/undo?api_key={}", CORE_API, merge_id, API_KEY); - let resp = client.post(&url).send().await - .map_err(|e| format!("Merge undo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn merge_redo(merge_id: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/merge/{}/redo?api_key={}", CORE_API, merge_id, API_KEY); - let resp = client.post(&url).send().await - .map_err(|e| format!("Merge redo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn merge_history(source_uuid: Option, target_uuid: Option, page: Option, page_size: Option) -> Result { - let client = get_client(); - let mut url = format!("{}/api/v1/identity/merge/history?api_key={}", CORE_API, API_KEY); - if let Some(s) = source_uuid { url = format!("{}&source_uuid={}", url, s); } - if let Some(t) = target_uuid { url = format!("{}&target_uuid={}", url, t); } - if let Some(p) = page { url = format!("{}&page={}", url, p); } - if let Some(ps) = page_size { url = format!("{}&page_size={}", url, ps); } - let resp = client.get(&url).send().await - .map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -fn main() { - std::thread::spawn(|| { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(proxy::start_proxy_server()); - }); - - tauri::Builder::default() - .plugin(tauri_plugin_shell::init()) - .setup(|app| { - use tauri::Manager; - let _ = app.get_webview_window("main"); - if let Err(e) = db::init_db() { - eprintln!("Failed to init database: {}", e); - } - Ok(()) - }) -.invoke_handler(tauri::generate_handler![ - search_llm_smart, - search_agents, - get_files, - register_file, - process_file, - ingest_file, - checkout_file, - unregister_file, - get_people, - get_faces, - get_traces, - get_file_info, - get_thumbnail, - get_face_thumbnail, - get_identity_profile, - update_identity_name, - update_identity_status, - update_identity_starred, - update_identity, - upload_profile_image, - set_profile_from_face, - match_from_photo, - delete_identity, - create_identity_from_face, - search_identities, - get_face_candidates, - merge_identities, - bind_face, - unbind_face, - identity_undo, - identity_redo, - identity_history, - identity_bind_undo, - identity_bind_redo, - identity_bind_history, - merge_undo, - merge_redo, - merge_history, - db::get_search_history, - db::save_search_history, - db::rename_search_history, - db::pin_search_history, - db::delete_search_history, - db::get_bookmarks, - db::save_bookmark, - db::delete_bookmark - ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} diff --git a/src-tauri/src/main.rs.clean b/src-tauri/src/main.rs.clean deleted file mode 100644 index e31e78c..0000000 --- a/src-tauri/src/main.rs.clean +++ /dev/null @@ -1,1177 +0,0 @@ -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -mod proxy; -mod db; - -use serde::Serialize; -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use std::sync::Mutex; -use std::num::NonZeroUsize; -use lru::LruCache; -use image::GenericImageView; - -static THUMB_CACHE: Mutex>> = Mutex::new(None); -static PROFILE_CACHE: Mutex>> = Mutex::new(None); - -fn get_client() -> reqwest::Client { - reqwest::Client::builder() - .connect_timeout(std::time::Duration::from_secs(10)) - .timeout(std::time::Duration::from_secs(60)) - .pool_max_idle_per_host(10) - .build() - .expect("Failed to build HTTP client") -} - -#[derive(Serialize)] -struct SearchResult { - file_uuid: String, - start_time: f64, - end_time: f64, - start_frame: i64, - end_frame: i64, - summary: String, - similarity: f64, - file_name: Option, -} - -#[derive(Serialize)] -struct FileInfo { - file_uuid: String, - file_name: String, - file_path: String, - file_size: i64, - modified_time: String, - #[serde(rename = "isRegistered")] - is_registered: bool, - status: String, - registration_time: Option, - #[serde(rename = "ingested")] - ingested: bool, -} - -#[derive(Serialize)] -struct PersonInfo { - identity_uuid: String, - name: String, - starred: bool, - status: String, - metadata: serde_json::Value, -} - -#[derive(Serialize)] -struct FaceInfo { - id: i64, - file_uuid: String, - frame_number: i64, - timestamp_secs: f64, - face_id: Option, - confidence: f64, - bbox: Option, -} - -#[derive(Serialize)] -struct TraceInfo { - trace_id: i32, - file_uuid: String, - frame_count: i64, - first_frame: i64, - last_frame: i64, - first_sec: f64, - last_sec: f64, - avg_confidence: f64, - face_id: Option, -} - -#[derive(Serialize)] -struct FileDetail { - file_uuid: String, - file_name: String, - fps: f64, - duration: f64, -} - -#[derive(Serialize)] -struct FaceCandidate { - id: i64, - face_id: Option, - file_uuid: String, - frame_number: i64, - confidence: f64, - bbox: Option, -} - -#[derive(Serialize)] -struct FaceBBox { - x: f64, - y: f64, - width: f64, - height: f64, -} - -#[derive(Serialize)] -struct SearchIdentityResult { - identity_id: i64, - name: String, - source: String, - tmdb_id: Option, - file_uuid: Option, - trace_id: Option, - start_frame: Option, - end_frame: Option, - start_time: f64, - end_time: f64, - text_content: Option, -} - -const CORE_API: &str = "http://localhost:3002"; -const API_KEY: &str = "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69"; - -#[tauri::command(rename_all = "camelCase")] -async fn search_llm_smart(query: String, limit: usize) -> Result, String> { - let client = get_client(); - let url = format!("{}/api/v1/search/llm-smart?api_key={}", CORE_API, API_KEY); - - let response = client - .post(&url) - .json(&serde_json::json!({"query": query, "limit": limit})) - .send() - .await - .map_err(|e| format!("Request failed: {}", e))?; - - let json: serde_json::Value = response - .json() - .await - .map_err(|e| format!("Parse failed: {}", e))?; - - let results: Vec = json["results"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|r| { - Some(SearchResult { - file_uuid: r["file_uuid"].as_str()?.to_string(), - start_time: r["start_time"].as_f64()?, - end_time: r["end_time"].as_f64()?, - start_frame: r["start_frame"].as_i64().unwrap_or(0), - end_frame: r["end_frame"].as_i64().unwrap_or(0), - summary: r["summary"].as_str()?.to_string(), - similarity: r["similarity"].as_f64().unwrap_or(0.0), - file_name: r["file_name"].as_str().map(|s| s.to_string()), - }) - }) - .collect(); - - Ok(results) -} - -#[derive(serde::Deserialize)] -struct GetFilesArgs { - #[serde(rename = "pageSize")] - page_size: usize, -} - -#[tauri::command(rename_all = "camelCase")] -async fn search_agents(query: String, conversation_id: Option) -> Result { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(120)) - .build() - .map_err(|e| format!("Client build failed: {}", e))?; - let url = format!("{}/api/v1/agents/search?api_key={}", CORE_API, API_KEY); - let mut body = serde_json::json!({"query": query}); - if let Some(cid) = conversation_id { - body["conversation_id"] = serde_json::Value::String(cid); - } - let response = client - .post(&url) - .json(&body) - .send() - .await - .map_err(|e| format!("Agent search request failed: {}", e))?; - let json: serde_json::Value = response - .json() - .await - .map_err(|e| format!("Agent search parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_files(args: GetFilesArgs) -> Result, String> { - let page_size = args.page_size; - let client = get_client(); - let url = format!("{}/api/v1/files/scan?api_key={}&page_size={}", CORE_API, API_KEY, page_size); - - let response = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = response.json().await.map_err(|e| format!("Parse failed: {}", e))?; - - let files: Vec = json["files"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|f| { - let is_reg = f["is_registered"].as_bool().unwrap_or(false); - let file_uuid = f["file_uuid"].as_str() - .unwrap_or_else(|| f["file_path"].as_str().unwrap_or("")).to_string(); - let status = f["status"].as_str().unwrap_or("").to_string(); - Some(FileInfo { - file_uuid, - file_name: f["file_name"].as_str()?.to_string(), - file_path: f["file_path"].as_str().unwrap_or("").to_string(), - file_size: f["file_size"].as_i64().unwrap_or(0), - modified_time: f["modified_time"].as_str().unwrap_or("").to_string(), - is_registered: is_reg, - registration_time: f["registration_time"].as_str().map(|s| s.to_string()), - ingested: status == "completed", - status, - }) - }) - .collect(); - - Ok(files) -} - -#[derive(Serialize)] -struct RegisterResult { - success: bool, - file_uuid: String, - file_name: String, - message: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn register_file(file_path: String) -> Result { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(120)) - .build() - .map_err(|e| format!("Client build failed: {}", e))?; - let url = format!("{}/api/v1/files/register?api_key={}", CORE_API, API_KEY); - let response = client.post(&url) - .json(&serde_json::json!({"file_path": file_path})) - .send() - .await - .map_err(|e| format!("Register request failed: {}", e))?; - let json: serde_json::Value = response.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(RegisterResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(), - file_name: json["file_name"].as_str().unwrap_or("").to_string(), - message: json["message"].as_str().unwrap_or("").to_string(), - }) -} - -#[derive(Serialize)] -struct ProcessResult { - success: bool, - file_uuid: String, - message: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn process_file(file_uuid: String, processors: Vec) -> Result { - let url = format!("{}/api/v1/file/{}/process?api_key={}", CORE_API, file_uuid, API_KEY); - let response = get_client().post(&url) - .json(&serde_json::json!({"processors": processors})) - .send() - .await - .map_err(|e| format!("Process request failed: {}", e))?; - let json: serde_json::Value = response.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(ProcessResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(), - message: json["message"].as_str().unwrap_or("").to_string(), - }) -} - -#[derive(Serialize)] -struct IngestResult { - success: bool, - file_uuid: String, - message: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn ingest_file(file_uuid: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/file/{}/checkin?api_key={}", CORE_API, file_uuid, API_KEY); - let resp = client.post(&url).send().await - .map_err(|e| format!("Ingest (checkin) request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Ingest (checkin) parse failed: {}", e))?; - Ok(IngestResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid, - message: json["message"].as_str().unwrap_or("Checkin complete").to_string(), - }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn checkout_file(file_uuid: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/file/{}/checkout?api_key={}", CORE_API, file_uuid, API_KEY); - let resp = client.post(&url).send().await - .map_err(|e| format!("Checkout request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Checkout parse failed: {}", e))?; - Ok(IngestResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid, - message: json["message"].as_str().unwrap_or("Checkout complete").to_string(), - }) -} - -#[derive(Serialize)] -struct UnregisterResult { - success: bool, - file_uuid: String, - message: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn unregister_file(file_uuid: String, delete_output_files: Option) -> Result { - let url = format!("{}/api/v1/unregister?api_key={}", CORE_API, API_KEY); - let mut body = serde_json::json!({"file_uuid": file_uuid}); - if let Some(dof) = delete_output_files { - body["delete_output_files"] = serde_json::Value::Bool(dof); - } - let response = get_client().post(&url) - .json(&body) - .send() - .await - .map_err(|e| format!("Unregister request failed: {}", e))?; - let json: serde_json::Value = response.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(UnregisterResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(), - message: json["message"].as_str().unwrap_or("").to_string(), - }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_people(_page: usize, _per_page: usize) -> Result, String> { - eprintln!("[get_people] called"); - let mut people = Vec::new(); - for page in 1u32..=2 { - 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, - Err(e) => { - eprintln!("[get_people] page {} request failed: {} — continuing", page, e); - continue; - } - }; - let json: serde_json::Value = match resp.json().await { - Ok(v) => v, - Err(e) => { - eprintln!("[get_people] page {} parse error: {} — continuing", page, e); - continue; - } - }; - let identities = match json["identities"].as_array() { - Some(arr) => arr, - None => { - eprintln!("[get_people] page {} has no identities array — stopping", page); - break; - } - }; - if identities.is_empty() { - eprintln!("[get_people] page {} empty — stopping", page); - break; - } - for i in identities { - let uuid = match i["identity_uuid"].as_str() { - Some(u) => u.to_string(), - None => continue, - }; - let name = match i["name"].as_str() { - Some(n) => n.to_string(), - None => continue, - }; - people.push(PersonInfo { - identity_uuid: uuid, - name, - starred: i["starred"].as_bool().unwrap_or_else(|| i["metadata"]["starred"].as_bool().unwrap_or(false)), - status: i["status"].as_str().unwrap_or_else(|| i["metadata"]["status"].as_str().unwrap_or("pending")).to_string(), - metadata: i["metadata"].clone(), - }); - } - } - eprintln!("[get_people] returning {} people", people.len()); - Ok(people) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_faces(uuid: String, per_page: usize) -> Result, String> { - let url = format!("{}/api/v1/identity/{}/faces?api_key={}&page_size={}", CORE_API, uuid, API_KEY, per_page); - let t = std::time::Instant::now(); - eprintln!("[get_faces] --> {}", uuid); - - let response = match get_client().get(&url).send().await { - Ok(r) => r, - Err(e) => { - eprintln!("[get_faces] <-- {} ERROR {} {:?}", uuid, e, t.elapsed()); - return Ok(vec![]); - } - }; - let json: serde_json::Value = match response.json().await { - Ok(v) => v, - Err(e) => { - eprintln!("[get_faces] <-- {} PARSE_ERR {} {:?}", uuid, e, t.elapsed()); - return Ok(vec![]); - } - }; - - let faces: Vec = json["data"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|f| { - Some(FaceInfo { - id: f["id"].as_i64().unwrap_or(0), - file_uuid: f["file_uuid"].as_str()?.to_string(), - frame_number: f["frame_number"].as_i64().unwrap_or(0), - timestamp_secs: f["timestamp_secs"].as_f64().unwrap_or(0.0), - face_id: f["face_id"].as_str().map(|s| s.to_string()), - confidence: f["confidence"].as_f64().unwrap_or(0.0), - bbox: f["bbox"].as_object().map(|o| FaceBBox { - x: o["x"].as_f64().unwrap_or(0.0), - y: o["y"].as_f64().unwrap_or(0.0), - width: o["width"].as_f64().unwrap_or(0.0), - height: o["height"].as_f64().unwrap_or(0.0), - }), - }) - }) - .collect(); - - eprintln!("[get_faces] <-- {} {} faces {:?}", uuid, faces.len(), t.elapsed()); - Ok(faces) -} - -#[derive(Serialize)] -struct TracesResponse { - total: i64, - traces: Vec, -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_traces(uuid: String, per_page: usize, page: Option) -> Result { - let page_size = per_page.min(100); - let page_num = page.unwrap_or(1); - let t = std::time::Instant::now(); - eprintln!("[get_traces] --> {} page={} page_size={}", uuid, page_num, page_size); - - let url = format!("{}/api/v1/identity/{}/traces?api_key={}&page_size={}&page={}", CORE_API, uuid, API_KEY, page_size, page_num); - - let resp = get_client().get(&url).send().await - .map_err(|e| format!("Traces request failed: {}", e))?; - - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Traces parse failed: {}", e))?; - - let total = json["total"].as_i64().unwrap_or(0); - - let traces: Vec = json["traces"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|t| { - Some(TraceInfo { - trace_id: t["trace_id"].as_i64().unwrap_or(0) as i32, - file_uuid: t["file_uuid"].as_str()?.to_string(), - frame_count: t["frame_count"].as_i64().unwrap_or(0), - first_frame: t["first_frame"].as_i64().unwrap_or(0), - last_frame: t["last_frame"].as_i64().unwrap_or(0), - first_sec: t["first_sec"].as_f64().unwrap_or(0.0), - last_sec: t["last_sec"].as_f64().unwrap_or(0.0), - avg_confidence: t["avg_confidence"].as_f64().unwrap_or(0.0), - face_id: t["face_id"].as_str().map(String::from), - }) - }) - .collect(); - - eprintln!("[get_traces] <-- {} total={} {} traces {:?}", uuid, total, traces.len(), t.elapsed()); - Ok(TracesResponse { total, traces }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_file_info(uuid: String) -> Result { - let url = format!("{}/api/v1/file/{}?api_key={}", CORE_API, uuid, API_KEY); - let resp = get_client().get(&url).send().await - .map_err(|e| format!("File info request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("File info parse failed: {}", e))?; - Ok(FileDetail { - file_uuid: json["file_uuid"].as_str().unwrap_or("").to_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), - }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_thumbnail(uuid: String, frame: u32) -> Result { - let key = format!("{}:{}", uuid, frame); - { - 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 = 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, bbox_y: Option, bbox_w: Option, bbox_h: Option) -> Result { - 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 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(); - 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) - }; - 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); - let ch = ph.min(h.saturating_sub(cy)).max(1); - let cropped = img.crop_imm(cx, cy, cw, ch); - let mut buf = std::io::Cursor::new(Vec::new()); - cropped.write_to(&mut buf, image::ImageFormat::Jpeg).map_err(|e| format!("Encode failed: {}", e))?; - format!("data:image/jpeg;base64,{}", STANDARD.encode(buf.into_inner())) - } else { - 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) -} - -const PROFILE_DIRS: &[&str] = &[ - "/Users/accusys/momentry/output/identities", - "/Users/accusys/momentry/output_dev/identities", - "/Volumes/external/momentry/output/identities", - "/Volumes/external/momentry/output_dev/identities", -]; - -#[tauri::command(rename_all = "camelCase")] -async fn get_identity_profile(uuid: String) -> Result { - let uuid_display = uuid.clone(); - let no_dash = uuid.replace('-', ""); - { - let mut cache = PROFILE_CACHE.lock().unwrap(); - if let Some(cache) = cache.as_mut() { - if let Some(cached) = cache.get(&uuid_display) { - eprintln!("[get_identity_profile] <-- {} (cached)", uuid_display); - return Ok(cached.clone()); - } - } - } - let t = std::time::Instant::now(); - let result = (|| -> Option { - for dir in PROFILE_DIRS { - for candidate in [&no_dash, &uuid_display] { - let path = format!("{}/{}/profile.jpg", dir, candidate); - if std::path::Path::new(&path).exists() { - let bytes = std::fs::read(&path).ok()?; - return Some(format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes))); - } - } - } - None - })(); - match result { - Some(data) => { - let mut cache = PROFILE_CACHE.lock().unwrap(); - let cache = cache.get_or_insert_with(|| LruCache::new(NonZeroUsize::new(500).unwrap())); - cache.put(uuid_display.clone(), data.clone()); - eprintln!("[get_identity_profile] <-- {} {} {}ms", uuid_display, "FOUND", t.elapsed().as_millis()); - Ok(data) - } - None => { - eprintln!("[get_identity_profile] <-- {} {} {}ms", uuid_display, "404", t.elapsed().as_millis()); - Err("Profile image not found".to_string()) - } - } -} - -#[tauri::command(rename_all = "camelCase")] -async fn update_identity_name(uuid: String, name: String) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - let resp = client.patch(&url) - .json(&serde_json::json!({"name": name})) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Update failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn update_identity_status(uuid: String, status: String) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - // GET current identity to merge metadata (preserve starred, role, notes, etc.) - let get_resp = client.get(&url).send().await - .map_err(|e| format!("GET failed: {}", e))?; - let json: serde_json::Value = get_resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - let mut metadata = json["metadata"].as_object() - .cloned() - .unwrap_or_default(); - metadata.insert("status".to_string(), serde_json::Value::String(status)); - let resp = client.patch(&url) - .json(&serde_json::json!({"metadata": metadata})) - .send().await - .map_err(|e| format!("PATCH failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Update failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn update_identity_starred(uuid: String, starred: bool) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - let get_resp = client.get(&url).send().await - .map_err(|e| format!("GET failed: {}", e))?; - let json: serde_json::Value = get_resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - let mut metadata = json["metadata"].as_object() - .cloned() - .unwrap_or_default(); - metadata.insert("starred".to_string(), serde_json::Value::Bool(starred)); - let resp = client.patch(&url) - .json(&serde_json::json!({"metadata": metadata})) - .send().await - .map_err(|e| format!("PATCH failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Update failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn update_identity(uuid: String, name: Option, metadata_json: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(n) = &name { - body["name"] = serde_json::Value::String(n.clone()); - } - if !metadata_json.is_empty() { - let meta: serde_json::Value = serde_json::from_str(&metadata_json).map_err(|e| format!("Invalid metadata JSON: {}", e))?; - body["metadata"] = meta; - } - let resp = client.patch(&url) - .json(&body) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn upload_profile_image(uuid: String, file_path: String) -> Result<(), String> { - let file_bytes = std::fs::read(&file_path).map_err(|e| format!("Failed to read file: {}", e))?; - let file_name = std::path::Path::new(&file_path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("profile.jpg") - .to_string(); - let part = reqwest::multipart::Part::bytes(file_bytes) - .file_name(file_name) - .mime_str("image/jpeg") - .unwrap_or_else(|_| reqwest::multipart::Part::bytes(std::fs::read(&file_path).unwrap_or_default()).file_name("profile.jpg")); - let form = reqwest::multipart::Form::new() - .part("image", part); - let url = format!("{}/api/v1/identity/{}/profile-image?api_key={}", CORE_API, uuid, API_KEY); - let resp = get_client().post(&url) - .multipart(form) - .send().await - .map_err(|e| format!("Upload failed: {}", e))?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(format!("Upload failed: {} {}", status, body)); - } - let mut cache = PROFILE_CACHE.lock().unwrap(); - if let Some(c) = cache.as_mut() { - c.pop(&uuid); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn match_from_photo(identity_uuid: String, file_uuid: String, image_path: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/agents/identity/match-from-photo?api_key={}", CORE_API, API_KEY); - let mut form = reqwest::multipart::Form::new() - .text("identity_uuid", identity_uuid.replace('-', "")) - .text("file_uuid", file_uuid); - if let Some(path) = image_path { - let file_bytes = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?; - let part = reqwest::multipart::Part::bytes(file_bytes) - .file_name("profile.jpg") - .mime_str("image/jpeg") - .unwrap_or_else(|_| reqwest::multipart::Part::bytes(std::fs::read(&path).unwrap_or_default()).file_name("profile.jpg")); - form = form.part("image", part); - } - let resp = client.post(&url) - .multipart(form) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(format!("Match failed: {} {}", status, body)); - } - let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn set_profile_from_face(uuid: String, file_uuid: String, face_id: Option, id: Option, trace_id: Option, frame_number: Option) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/profile-image/from-face?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({"file_uuid": file_uuid}); - if let Some(fid) = face_id { - body["face_id"] = serde_json::json!(fid); - } else if let Some(rid) = id { - body["id"] = serde_json::json!(rid); - } else if let Some(tid) = trace_id { - body["trace_id"] = serde_json::json!(tid); - if let Some(frame) = frame_number { - body["frame_number"] = serde_json::json!(frame); - } - } else { - return Err("Either face_id, id, or trace_id is required".to_string()); - } - let resp = client.post(&url) - .json(&body) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - let status = resp.status(); - let body_text = resp.text().await.unwrap_or_default(); - return Err(format!("Set profile failed: {} {}", status, body_text)); - } - let mut cache = PROFILE_CACHE.lock().unwrap(); - if let Some(c) = cache.as_mut() { - c.pop(&uuid); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn delete_identity(uuid: String) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - let resp = client.delete(&url) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() && resp.status() != 204 { - return Err(format!("Delete failed: {}", resp.status())); - } - Ok(()) -} - -#[derive(Serialize)] -struct CreateIdentityFromFaceResult { - success: bool, - identity_uuid: String, - name: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn create_identity_from_face(name: String, _face_id: Option, _face_row_id: Option, file_uuid: String) -> Result { - let client = get_client(); - - // Use POST /api/v1/file/:file_uuid/pending-person to create pending identity - let url = format!("{}/api/v1/file/{}/pending-person?api_key={}", CORE_API, file_uuid, API_KEY); - let body = serde_json::json!({ - "name": name - }); - - let resp = client.post(&url) - .json(&body) - .send().await - .map_err(|e| format!("Create pending person request failed: {}", e))?; - - let status = resp.status(); - let resp_json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse response failed: {}", e))?; - - eprintln!("[create_identity_from_face] status={} resp={}", status, resp_json); - - if !status.is_success() { - return Err(format!("Create pending person failed: {} - {}", status, resp_json["error"].as_str().unwrap_or("unknown error"))); - } - - // identity_uuid is inside data object - let data_obj = resp_json.get("data").unwrap_or(&resp_json); - let new_uuid = data_obj.get("identity_uuid") - .and_then(|v| v.as_str()) - .or_else(|| resp_json.get("identity_uuid").and_then(|v| v.as_str())) - .or_else(|| resp_json.get("uuid").and_then(|v| v.as_str())) - .unwrap_or(""); - - if new_uuid.is_empty() { - return Err("Create pending person failed: no uuid returned".to_string()); - } - - Ok(CreateIdentityFromFaceResult { - success: true, - identity_uuid: new_uuid.to_string(), - name, - }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn search_identities(query: String, limit: usize) -> Result, String> { - let url = format!("{}/api/v1/identities/search?api_key={}&q={}", CORE_API, API_KEY, query); - let resp = get_client().get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?; - let results: Vec = json["results"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .take(limit) - .filter_map(|r| { - Some(SearchIdentityResult { - identity_id: r["identity_id"].as_i64().unwrap_or(0), - name: r["name"].as_str()?.to_string(), - source: r["source"].as_str().unwrap_or("").to_string(), - tmdb_id: r["tmdb_id"].as_i64(), - file_uuid: r["file_uuid"].as_str().map(|s| s.to_string()), - trace_id: r["trace_id"].as_i64(), - start_frame: r["start_frame"].as_i64(), - end_frame: r["end_frame"].as_i64(), - start_time: r["start_time"].as_f64().unwrap_or(0.0), - end_time: r["end_time"].as_f64().unwrap_or(0.0), - text_content: r["text_content"].as_str().map(|s| s.to_string()), - }) - }) - .collect(); - Ok(results) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_face_candidates(page: usize, per_page: usize) -> Result, String> { - let client = get_client(); - let url = format!("{}/api/v1/faces/candidates?api_key={}&page={}&page_size={}", CORE_API, API_KEY, page, per_page); - let resp = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?; - let candidates: Vec = json["candidates"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|c| { - Some(FaceCandidate { - id: c["id"].as_i64().unwrap_or(0), - face_id: c["face_id"].as_str().map(|s| s.to_string()), - file_uuid: c["file_uuid"].as_str()?.to_string(), - frame_number: c["frame_number"].as_i64().unwrap_or(0), - confidence: c["confidence"].as_f64().unwrap_or(0.0), - bbox: c["bbox"].as_object().map(|o| FaceBBox { - x: o["x"].as_f64().unwrap_or(0.0), - y: o["y"].as_f64().unwrap_or(0.0), - width: o["width"].as_f64().unwrap_or(0.0), - height: o["height"].as_f64().unwrap_or(0.0), - }), - }) - }) - .collect(); - Ok(candidates) -} - -#[tauri::command(rename_all = "camelCase")] -async fn merge_identities(uuid: String, into_uuid: String) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/mergeinto?api_key={}", CORE_API, uuid, API_KEY); - let resp = client.post(&url) - .json(&serde_json::json!({"into_uuid": into_uuid})) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Merge failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn bind_face(uuid: String, face_id: Option, face_row_id: Option, file_uuid: String, expand_to_trace: Option) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/bind?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({"file_uuid": file_uuid}); - if let Some(fid) = &face_id { - body["face_id"] = serde_json::json!(fid); - } else if let Some(rid) = face_row_id { - body["id"] = serde_json::json!(rid); - } else { - return Err("Either face_id or face_row_id is required".to_string()); - } - if let Some(expand) = expand_to_trace { - body["expand_to_trace"] = serde_json::json!(expand); - } - let resp = client.post(&url) - .json(&body) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Bind failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn unbind_face(uuid: String, face_id: Option, face_row_id: Option, file_uuid: String, _frame_number: Option) -> Result<(), String> { - let client = get_client(); - let mut body = serde_json::json!({"file_uuid": file_uuid}); - if let Some(fid) = face_id { - body["face_id"] = serde_json::json!(fid); - } else if let Some(rid) = face_row_id { - body["id"] = serde_json::json!(rid); - } else { - return Err("Either face_id or face_row_id is required".to_string()); - } - let url = format!("{}/api/v1/identity/{}/unbind?api_key={}", CORE_API, uuid, API_KEY); - eprintln!("[unbind_face] POST {} body={}", url, body); - let resp = client.post(&url) - .json(&body) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - let status = resp.status(); - let resp_text = resp.text().await.unwrap_or_default(); - eprintln!("[unbind_face] status={} body={}", status, resp_text); - if !status.is_success() { - return Err(format!("Unbind failed: {} - {}", status, resp_text)); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_undo(uuid: String, steps: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/undo?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(s) = steps { - body["steps"] = serde_json::Value::Number(s.into()); - } - let resp = client.post(&url).json(&body).send().await - .map_err(|e| format!("Undo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_redo(uuid: String, steps: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/redo?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(s) = steps { - body["steps"] = serde_json::Value::Number(s.into()); - } - let resp = client.post(&url).json(&body).send().await - .map_err(|e| format!("Redo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_history(uuid: String, page: Option, page_size: Option) -> Result { - let client = get_client(); - let mut url = format!("{}/api/v1/identity/{}/history?api_key={}", CORE_API, uuid, API_KEY); - if let Some(p) = page { url = format!("{}&page={}", url, p); } - if let Some(ps) = page_size { url = format!("{}&page_size={}", url, ps); } - let resp = client.get(&url).send().await - .map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_bind_undo(uuid: String, steps: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/bind/undo?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(s) = steps { - body["steps"] = serde_json::Value::Number(s.into()); - } - let resp = client.post(&url).json(&body).send().await - .map_err(|e| format!("Bind undo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_bind_redo(uuid: String, steps: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/bind/redo?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(s) = steps { - body["steps"] = serde_json::Value::Number(s.into()); - } - let resp = client.post(&url).json(&body).send().await - .map_err(|e| format!("Bind redo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_bind_history(uuid: String, page: Option, page_size: Option) -> Result { - let client = get_client(); - let mut url = format!("{}/api/v1/identity/{}/bind/history?api_key={}", CORE_API, uuid, API_KEY); - if let Some(p) = page { url = format!("{}&page={}", url, p); } - if let Some(ps) = page_size { url = format!("{}&page_size={}", url, ps); } - let resp = client.get(&url).send().await - .map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn merge_undo(merge_id: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/merge/{}/undo?api_key={}", CORE_API, merge_id, API_KEY); - let resp = client.post(&url).send().await - .map_err(|e| format!("Merge undo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn merge_redo(merge_id: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/merge/{}/redo?api_key={}", CORE_API, merge_id, API_KEY); - let resp = client.post(&url).send().await - .map_err(|e| format!("Merge redo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn merge_history(source_uuid: Option, target_uuid: Option, page: Option, page_size: Option) -> Result { - let client = get_client(); - let mut url = format!("{}/api/v1/identity/merge/history?api_key={}", CORE_API, API_KEY); - if let Some(s) = source_uuid { url = format!("{}&source_uuid={}", url, s); } - if let Some(t) = target_uuid { url = format!("{}&target_uuid={}", url, t); } - if let Some(p) = page { url = format!("{}&page={}", url, p); } - if let Some(ps) = page_size { url = format!("{}&page_size={}", url, ps); } - let resp = client.get(&url).send().await - .map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -fn main() { - std::thread::spawn(|| { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(proxy::start_proxy_server()); - }); - - tauri::Builder::default() - .plugin(tauri_plugin_shell::init()) - .setup(|app| { - use tauri::Manager; - let _ = app.get_webview_window("main"); - if let Err(e) = db::init_db() { - eprintln!("Failed to init database: {}", e); - } - Ok(()) - }) -.invoke_handler(tauri::generate_handler![ - search_llm_smart, - search_agents, - get_files, - register_file, - process_file, - ingest_file, - checkout_file, - unregister_file, - get_people, - get_faces, - get_traces, - get_file_info, - get_thumbnail, - get_face_thumbnail, - get_identity_profile, - update_identity_name, - update_identity_status, - update_identity_starred, - update_identity, - upload_profile_image, - set_profile_from_face, - match_from_photo, - delete_identity, - create_identity_from_face, - search_identities, - get_face_candidates, - merge_identities, - bind_face, - unbind_face, - identity_undo, - identity_redo, - identity_history, - identity_bind_undo, - identity_bind_redo, - identity_bind_history, - merge_undo, - merge_redo, - merge_history, - db::get_search_history, - db::save_search_history, - db::rename_search_history, - db::pin_search_history, - db::delete_search_history, - db::get_bookmarks, - db::save_bookmark, - db::delete_bookmark - ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} diff --git a/src-tauri/src/main.rs.work b/src-tauri/src/main.rs.work deleted file mode 100644 index 80ee73e..0000000 --- a/src-tauri/src/main.rs.work +++ /dev/null @@ -1,1180 +0,0 @@ -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -use dirs; -use markbase_core::vfs::VfsBackend; - -mod proxy; -mod db; - -use serde::Serialize; -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use std::sync::Mutex; -use std::num::NonZeroUsize; -use lru::LruCache; -use image::GenericImageView; - -static THUMB_CACHE: Mutex>> = Mutex::new(None); -static PROFILE_CACHE: Mutex>> = Mutex::new(None); - -fn get_client() -> reqwest::Client { - reqwest::Client::builder() - .connect_timeout(std::time::Duration::from_secs(10)) - .timeout(std::time::Duration::from_secs(60)) - .pool_max_idle_per_host(10) - .build() - .expect("Failed to build HTTP client") -} - -#[derive(Serialize)] -struct SearchResult { - file_uuid: String, - start_time: f64, - end_time: f64, - start_frame: i64, - end_frame: i64, - summary: String, - similarity: f64, - file_name: Option, -} - -#[derive(Serialize)] -struct FileInfo { - file_uuid: String, - file_name: String, - file_path: String, - file_size: i64, - modified_time: String, - #[serde(rename = "isRegistered")] - is_registered: bool, - status: String, - registration_time: Option, - #[serde(rename = "ingested")] - ingested: bool, -} - -#[derive(Serialize)] -struct PersonInfo { - identity_uuid: String, - name: String, - starred: bool, - status: String, - metadata: serde_json::Value, -} - -#[derive(Serialize)] -struct FaceInfo { - id: i64, - file_uuid: String, - frame_number: i64, - timestamp_secs: f64, - face_id: Option, - confidence: f64, - bbox: Option, -} - -#[derive(Serialize)] -struct TraceInfo { - trace_id: i32, - file_uuid: String, - frame_count: i64, - first_frame: i64, - last_frame: i64, - first_sec: f64, - last_sec: f64, - avg_confidence: f64, - face_id: Option, -} - -#[derive(Serialize)] -struct FileDetail { - file_uuid: String, - file_name: String, - fps: f64, - duration: f64, -} - -#[derive(Serialize)] -struct FaceCandidate { - id: i64, - face_id: Option, - file_uuid: String, - frame_number: i64, - confidence: f64, - bbox: Option, -} - -#[derive(Serialize)] -struct FaceBBox { - x: f64, - y: f64, - width: f64, - height: f64, -} - -#[derive(Serialize)] -struct SearchIdentityResult { - identity_id: i64, - name: String, - source: String, - tmdb_id: Option, - file_uuid: Option, - trace_id: Option, - start_frame: Option, - end_frame: Option, - start_time: f64, - end_time: f64, - text_content: Option, -} - -const CORE_API: &str = "http://localhost:3002"; -const API_KEY: &str = "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69"; - -#[tauri::command(rename_all = "camelCase")] -async fn search_llm_smart(query: String, limit: usize) -> Result, String> { - let client = get_client(); - let url = format!("{}/api/v1/search/llm-smart?api_key={}", CORE_API, API_KEY); - - let response = client - .post(&url) - .json(&serde_json::json!({"query": query, "limit": limit})) - .send() - .await - .map_err(|e| format!("Request failed: {}", e))?; - - let json: serde_json::Value = response - .json() - .await - .map_err(|e| format!("Parse failed: {}", e))?; - - let results: Vec = json["results"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|r| { - Some(SearchResult { - file_uuid: r["file_uuid"].as_str()?.to_string(), - start_time: r["start_time"].as_f64()?, - end_time: r["end_time"].as_f64()?, - start_frame: r["start_frame"].as_i64().unwrap_or(0), - end_frame: r["end_frame"].as_i64().unwrap_or(0), - summary: r["summary"].as_str()?.to_string(), - similarity: r["similarity"].as_f64().unwrap_or(0.0), - file_name: r["file_name"].as_str().map(|s| s.to_string()), - }) - }) - .collect(); - - Ok(results) -} - -#[derive(serde::Deserialize)] -struct GetFilesArgs { - #[serde(rename = "pageSize")] - page_size: usize, -} - -#[tauri::command(rename_all = "camelCase")] -async fn search_agents(query: String, conversation_id: Option) -> Result { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(120)) - .build() - .map_err(|e| format!("Client build failed: {}", e))?; - let url = format!("{}/api/v1/agents/search?api_key={}", CORE_API, API_KEY); - let mut body = serde_json::json!({"query": query}); - if let Some(cid) = conversation_id { - body["conversation_id"] = serde_json::Value::String(cid); - } - let response = client - .post(&url) - .json(&body) - .send() - .await - .map_err(|e| format!("Agent search request failed: {}", e))?; - let json: serde_json::Value = response - .json() - .await - .map_err(|e| format!("Agent search parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_files(args: GetFilesArgs) -> Result, String> { - let page_size = args.page_size; - let client = get_client(); - let url = format!("{}/api/v1/files/scan?api_key={}&page_size={}", CORE_API, API_KEY, page_size); - - let response = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = response.json().await.map_err(|e| format!("Parse failed: {}", e))?; - - let files: Vec = json["files"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|f| { - let is_reg = f["is_registered"].as_bool().unwrap_or(false); - let file_uuid = f["file_uuid"].as_str() - .unwrap_or_else(|| f["file_path"].as_str().unwrap_or("")).to_string(); - let status = f["status"].as_str().unwrap_or("").to_string(); - Some(FileInfo { - file_uuid, - file_name: f["file_name"].as_str()?.to_string(), - file_path: f["file_path"].as_str().unwrap_or("").to_string(), - file_size: f["file_size"].as_i64().unwrap_or(0), - modified_time: f["modified_time"].as_str().unwrap_or("").to_string(), - is_registered: is_reg, - registration_time: f["registration_time"].as_str().map(|s| s.to_string()), - ingested: status == "completed", - status, - }) - }) - .collect(); - - Ok(files) -} - -#[derive(Serialize)] -struct RegisterResult { - success: bool, - file_uuid: String, - file_name: String, - message: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn register_file(file_path: String) -> Result { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(120)) - .build() - .map_err(|e| format!("Client build failed: {}", e))?; - let url = format!("{}/api/v1/files/register?api_key={}", CORE_API, API_KEY); - let response = client.post(&url) - .json(&serde_json::json!({"file_path": file_path})) - .send() - .await - .map_err(|e| format!("Register request failed: {}", e))?; - let json: serde_json::Value = response.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(RegisterResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(), - file_name: json["file_name"].as_str().unwrap_or("").to_string(), - message: json["message"].as_str().unwrap_or("").to_string(), - }) -} - -#[derive(Serialize)] -struct ProcessResult { - success: bool, - file_uuid: String, - message: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn process_file(file_uuid: String, processors: Vec) -> Result { - let url = format!("{}/api/v1/file/{}/process?api_key={}", CORE_API, file_uuid, API_KEY); - let response = get_client().post(&url) - .json(&serde_json::json!({"processors": processors})) - .send() - .await - .map_err(|e| format!("Process request failed: {}", e))?; - let json: serde_json::Value = response.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(ProcessResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(), - message: json["message"].as_str().unwrap_or("").to_string(), - }) -} - -#[derive(Serialize)] -struct IngestResult { - success: bool, - file_uuid: String, - message: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn ingest_file(file_uuid: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/file/{}/checkin?api_key={}", CORE_API, file_uuid, API_KEY); - let resp = client.post(&url).send().await - .map_err(|e| format!("Ingest (checkin) request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Ingest (checkin) parse failed: {}", e))?; - Ok(IngestResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid, - message: json["message"].as_str().unwrap_or("Checkin complete").to_string(), - }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn checkout_file(file_uuid: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/file/{}/checkout?api_key={}", CORE_API, file_uuid, API_KEY); - let resp = client.post(&url).send().await - .map_err(|e| format!("Checkout request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Checkout parse failed: {}", e))?; - Ok(IngestResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid, - message: json["message"].as_str().unwrap_or("Checkout complete").to_string(), - }) -} - -#[derive(Serialize)] -struct UnregisterResult { - success: bool, - file_uuid: String, - message: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn unregister_file(file_uuid: String, delete_output_files: Option) -> Result { - let url = format!("{}/api/v1/unregister?api_key={}", CORE_API, API_KEY); - let mut body = serde_json::json!({"file_uuid": file_uuid}); - if let Some(dof) = delete_output_files { - body["delete_output_files"] = serde_json::Value::Bool(dof); - } - let response = get_client().post(&url) - .json(&body) - .send() - .await - .map_err(|e| format!("Unregister request failed: {}", e))?; - let json: serde_json::Value = response.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(UnregisterResult { - success: json["success"].as_bool().unwrap_or(false), - file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(), - message: json["message"].as_str().unwrap_or("").to_string(), - }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_people(_page: usize, _per_page: usize) -> Result, String> { - eprintln!("[get_people] called"); - let mut people = Vec::new(); - for page in 1u32..=2 { - 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, - Err(e) => { - eprintln!("[get_people] page {} request failed: {} — continuing", page, e); - continue; - } - }; - let json: serde_json::Value = match resp.json().await { - Ok(v) => v, - Err(e) => { - eprintln!("[get_people] page {} parse error: {} — continuing", page, e); - continue; - } - }; - let identities = match json["identities"].as_array() { - Some(arr) => arr, - None => { - eprintln!("[get_people] page {} has no identities array — stopping", page); - break; - } - }; - if identities.is_empty() { - eprintln!("[get_people] page {} empty — stopping", page); - break; - } - for i in identities { - let uuid = match i["identity_uuid"].as_str() { - Some(u) => u.to_string(), - None => continue, - }; - let name = match i["name"].as_str() { - Some(n) => n.to_string(), - None => continue, - }; - people.push(PersonInfo { - identity_uuid: uuid, - name, - starred: i["starred"].as_bool().unwrap_or_else(|| i["metadata"]["starred"].as_bool().unwrap_or(false)), - status: i["status"].as_str().unwrap_or_else(|| i["metadata"]["status"].as_str().unwrap_or("pending")).to_string(), - metadata: i["metadata"].clone(), - }); - } - } - eprintln!("[get_people] returning {} people", people.len()); - Ok(people) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_faces(uuid: String, per_page: usize) -> Result, String> { - let url = format!("{}/api/v1/identity/{}/faces?api_key={}&page_size={}", CORE_API, uuid, API_KEY, per_page); - let t = std::time::Instant::now(); - eprintln!("[get_faces] --> {}", uuid); - - let response = match get_client().get(&url).send().await { - Ok(r) => r, - Err(e) => { - eprintln!("[get_faces] <-- {} ERROR {} {:?}", uuid, e, t.elapsed()); - return Ok(vec![]); - } - }; - let json: serde_json::Value = match response.json().await { - Ok(v) => v, - Err(e) => { - eprintln!("[get_faces] <-- {} PARSE_ERR {} {:?}", uuid, e, t.elapsed()); - return Ok(vec![]); - } - }; - - let faces: Vec = json["data"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|f| { - Some(FaceInfo { - id: f["id"].as_i64().unwrap_or(0), - file_uuid: f["file_uuid"].as_str()?.to_string(), - frame_number: f["frame_number"].as_i64().unwrap_or(0), - timestamp_secs: f["timestamp_secs"].as_f64().unwrap_or(0.0), - face_id: f["face_id"].as_str().map(|s| s.to_string()), - confidence: f["confidence"].as_f64().unwrap_or(0.0), - bbox: f["bbox"].as_object().map(|o| FaceBBox { - x: o["x"].as_f64().unwrap_or(0.0), - y: o["y"].as_f64().unwrap_or(0.0), - width: o["width"].as_f64().unwrap_or(0.0), - height: o["height"].as_f64().unwrap_or(0.0), - }), - }) - }) - .collect(); - - eprintln!("[get_faces] <-- {} {} faces {:?}", uuid, faces.len(), t.elapsed()); - Ok(faces) -} - -#[derive(Serialize)] -struct TracesResponse { - total: i64, - traces: Vec, -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_traces(uuid: String, per_page: usize, page: Option) -> Result { - let page_size = per_page.min(100); - let page_num = page.unwrap_or(1); - let t = std::time::Instant::now(); - eprintln!("[get_traces] --> {} page={} page_size={}", uuid, page_num, page_size); - - let url = format!("{}/api/v1/identity/{}/traces?api_key={}&page_size={}&page={}", CORE_API, uuid, API_KEY, page_size, page_num); - - let resp = get_client().get(&url).send().await - .map_err(|e| format!("Traces request failed: {}", e))?; - - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Traces parse failed: {}", e))?; - - let total = json["total"].as_i64().unwrap_or(0); - - let traces: Vec = json["traces"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|t| { - Some(TraceInfo { - trace_id: t["trace_id"].as_i64().unwrap_or(0) as i32, - file_uuid: t["file_uuid"].as_str()?.to_string(), - frame_count: t["frame_count"].as_i64().unwrap_or(0), - first_frame: t["first_frame"].as_i64().unwrap_or(0), - last_frame: t["last_frame"].as_i64().unwrap_or(0), - first_sec: t["first_sec"].as_f64().unwrap_or(0.0), - last_sec: t["last_sec"].as_f64().unwrap_or(0.0), - avg_confidence: t["avg_confidence"].as_f64().unwrap_or(0.0), - face_id: t["face_id"].as_str().map(String::from), - }) - }) - .collect(); - - eprintln!("[get_traces] <-- {} total={} {} traces {:?}", uuid, total, traces.len(), t.elapsed()); - Ok(TracesResponse { total, traces }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_file_info(uuid: String) -> Result { - let url = format!("{}/api/v1/file/{}?api_key={}", CORE_API, uuid, API_KEY); - let resp = get_client().get(&url).send().await - .map_err(|e| format!("File info request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("File info parse failed: {}", e))?; - Ok(FileDetail { - file_uuid: json["file_uuid"].as_str().unwrap_or("").to_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), - }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_thumbnail(uuid: String, frame: u32) -> Result { - let key = format!("{}:{}", uuid, frame); - { - 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 = 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, bbox_y: Option, bbox_w: Option, bbox_h: Option) -> Result { - 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 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(); - 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) - }; - 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); - let ch = ph.min(h.saturating_sub(cy)).max(1); - let cropped = img.crop_imm(cx, cy, cw, ch); - let mut buf = std::io::Cursor::new(Vec::new()); - cropped.write_to(&mut buf, image::ImageFormat::Jpeg).map_err(|e| format!("Encode failed: {}", e))?; - format!("data:image/jpeg;base64,{}", STANDARD.encode(buf.into_inner())) - } else { - 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) -} - -const PROFILE_DIRS: &[&str] = &[ - "/Users/accusys/momentry/output/identities", - "/Users/accusys/momentry/output_dev/identities", - "/Volumes/external/momentry/output/identities", - "/Volumes/external/momentry/output_dev/identities", -]; - -#[tauri::command(rename_all = "camelCase")] -async fn get_identity_profile(uuid: String) -> Result { - let uuid_display = uuid.clone(); - let no_dash = uuid.replace('-', ""); - { - let mut cache = PROFILE_CACHE.lock().unwrap(); - if let Some(cache) = cache.as_mut() { - if let Some(cached) = cache.get(&uuid_display) { - eprintln!("[get_identity_profile] <-- {} (cached)", uuid_display); - return Ok(cached.clone()); - } - } - } - let t = std::time::Instant::now(); - let result = (|| -> Option { - for dir in PROFILE_DIRS { - for candidate in [&no_dash, &uuid_display] { - let path = format!("{}/{}/profile.jpg", dir, candidate); - if std::path::Path::new(&path).exists() { - let bytes = std::fs::read(&path).ok()?; - return Some(format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes))); - } - } - } - None - })(); - match result { - Some(data) => { - let mut cache = PROFILE_CACHE.lock().unwrap(); - let cache = cache.get_or_insert_with(|| LruCache::new(NonZeroUsize::new(500).unwrap())); - cache.put(uuid_display.clone(), data.clone()); - eprintln!("[get_identity_profile] <-- {} {} {}ms", uuid_display, "FOUND", t.elapsed().as_millis()); - Ok(data) - } - None => { - eprintln!("[get_identity_profile] <-- {} {} {}ms", uuid_display, "404", t.elapsed().as_millis()); - Err("Profile image not found".to_string()) - } - } -} - -#[tauri::command(rename_all = "camelCase")] -async fn update_identity_name(uuid: String, name: String) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - let resp = client.patch(&url) - .json(&serde_json::json!({"name": name})) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Update failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn update_identity_status(uuid: String, status: String) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - // GET current identity to merge metadata (preserve starred, role, notes, etc.) - let get_resp = client.get(&url).send().await - .map_err(|e| format!("GET failed: {}", e))?; - let json: serde_json::Value = get_resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - let mut metadata = json["metadata"].as_object() - .cloned() - .unwrap_or_default(); - metadata.insert("status".to_string(), serde_json::Value::String(status)); - let resp = client.patch(&url) - .json(&serde_json::json!({"metadata": metadata})) - .send().await - .map_err(|e| format!("PATCH failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Update failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn update_identity_starred(uuid: String, starred: bool) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - let get_resp = client.get(&url).send().await - .map_err(|e| format!("GET failed: {}", e))?; - let json: serde_json::Value = get_resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - let mut metadata = json["metadata"].as_object() - .cloned() - .unwrap_or_default(); - metadata.insert("starred".to_string(), serde_json::Value::Bool(starred)); - let resp = client.patch(&url) - .json(&serde_json::json!({"metadata": metadata})) - .send().await - .map_err(|e| format!("PATCH failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Update failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn update_identity(uuid: String, name: Option, metadata_json: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(n) = &name { - body["name"] = serde_json::Value::String(n.clone()); - } - if !metadata_json.is_empty() { - let meta: serde_json::Value = serde_json::from_str(&metadata_json).map_err(|e| format!("Invalid metadata JSON: {}", e))?; - body["metadata"] = meta; - } - let resp = client.patch(&url) - .json(&body) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn upload_profile_image(uuid: String, file_path: String) -> Result<(), String> { - let file_bytes = std::fs::read(&file_path).map_err(|e| format!("Failed to read file: {}", e))?; - let file_name = std::path::Path::new(&file_path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("profile.jpg") - .to_string(); - let part = reqwest::multipart::Part::bytes(file_bytes) - .file_name(file_name) - .mime_str("image/jpeg") - .unwrap_or_else(|_| reqwest::multipart::Part::bytes(std::fs::read(&file_path).unwrap_or_default()).file_name("profile.jpg")); - let form = reqwest::multipart::Form::new() - .part("image", part); - let url = format!("{}/api/v1/identity/{}/profile-image?api_key={}", CORE_API, uuid, API_KEY); - let resp = get_client().post(&url) - .multipart(form) - .send().await - .map_err(|e| format!("Upload failed: {}", e))?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(format!("Upload failed: {} {}", status, body)); - } - let mut cache = PROFILE_CACHE.lock().unwrap(); - if let Some(c) = cache.as_mut() { - c.pop(&uuid); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn match_from_photo(identity_uuid: String, file_uuid: String, image_path: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/agents/identity/match-from-photo?api_key={}", CORE_API, API_KEY); - let mut form = reqwest::multipart::Form::new() - .text("identity_uuid", identity_uuid.replace('-', "")) - .text("file_uuid", file_uuid); - if let Some(path) = image_path { - let file_bytes = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?; - let part = reqwest::multipart::Part::bytes(file_bytes) - .file_name("profile.jpg") - .mime_str("image/jpeg") - .unwrap_or_else(|_| reqwest::multipart::Part::bytes(std::fs::read(&path).unwrap_or_default()).file_name("profile.jpg")); - form = form.part("image", part); - } - let resp = client.post(&url) - .multipart(form) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(format!("Match failed: {} {}", status, body)); - } - let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn set_profile_from_face(uuid: String, file_uuid: String, face_id: Option, id: Option, trace_id: Option, frame_number: Option) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/profile-image/from-face?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({"file_uuid": file_uuid}); - if let Some(fid) = face_id { - body["face_id"] = serde_json::json!(fid); - } else if let Some(rid) = id { - body["id"] = serde_json::json!(rid); - } else if let Some(tid) = trace_id { - body["trace_id"] = serde_json::json!(tid); - if let Some(frame) = frame_number { - body["frame_number"] = serde_json::json!(frame); - } - } else { - return Err("Either face_id, id, or trace_id is required".to_string()); - } - let resp = client.post(&url) - .json(&body) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - let status = resp.status(); - let body_text = resp.text().await.unwrap_or_default(); - return Err(format!("Set profile failed: {} {}", status, body_text)); - } - let mut cache = PROFILE_CACHE.lock().unwrap(); - if let Some(c) = cache.as_mut() { - c.pop(&uuid); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn delete_identity(uuid: String) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY); - let resp = client.delete(&url) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() && resp.status() != 204 { - return Err(format!("Delete failed: {}", resp.status())); - } - Ok(()) -} - -#[derive(Serialize)] -struct CreateIdentityFromFaceResult { - success: bool, - identity_uuid: String, - name: String, -} - -#[tauri::command(rename_all = "camelCase")] -async fn create_identity_from_face(name: String, _face_id: Option, _face_row_id: Option, file_uuid: String) -> Result { - let client = get_client(); - - // Use POST /api/v1/file/:file_uuid/pending-person to create pending identity - let url = format!("{}/api/v1/file/{}/pending-person?api_key={}", CORE_API, file_uuid, API_KEY); - let body = serde_json::json!({ - "name": name - }); - - let resp = client.post(&url) - .json(&body) - .send().await - .map_err(|e| format!("Create pending person request failed: {}", e))?; - - let status = resp.status(); - let resp_json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse response failed: {}", e))?; - - eprintln!("[create_identity_from_face] status={} resp={}", status, resp_json); - - if !status.is_success() { - return Err(format!("Create pending person failed: {} - {}", status, resp_json["error"].as_str().unwrap_or("unknown error"))); - } - - // identity_uuid is inside data object - let data_obj = resp_json.get("data").unwrap_or(&resp_json); - let new_uuid = data_obj.get("identity_uuid") - .and_then(|v| v.as_str()) - .or_else(|| resp_json.get("identity_uuid").and_then(|v| v.as_str())) - .or_else(|| resp_json.get("uuid").and_then(|v| v.as_str())) - .unwrap_or(""); - - if new_uuid.is_empty() { - return Err("Create pending person failed: no uuid returned".to_string()); - } - - Ok(CreateIdentityFromFaceResult { - success: true, - identity_uuid: new_uuid.to_string(), - name, - }) -} - -#[tauri::command(rename_all = "camelCase")] -async fn search_identities(query: String, limit: usize) -> Result, String> { - let url = format!("{}/api/v1/identities/search?api_key={}&q={}", CORE_API, API_KEY, query); - let resp = get_client().get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?; - let results: Vec = json["results"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .take(limit) - .filter_map(|r| { - Some(SearchIdentityResult { - identity_id: r["identity_id"].as_i64().unwrap_or(0), - name: r["name"].as_str()?.to_string(), - source: r["source"].as_str().unwrap_or("").to_string(), - tmdb_id: r["tmdb_id"].as_i64(), - file_uuid: r["file_uuid"].as_str().map(|s| s.to_string()), - trace_id: r["trace_id"].as_i64(), - start_frame: r["start_frame"].as_i64(), - end_frame: r["end_frame"].as_i64(), - start_time: r["start_time"].as_f64().unwrap_or(0.0), - end_time: r["end_time"].as_f64().unwrap_or(0.0), - text_content: r["text_content"].as_str().map(|s| s.to_string()), - }) - }) - .collect(); - Ok(results) -} - -#[tauri::command(rename_all = "camelCase")] -async fn get_face_candidates(page: usize, per_page: usize) -> Result, String> { - let client = get_client(); - let url = format!("{}/api/v1/faces/candidates?api_key={}&page={}&page_size={}", CORE_API, API_KEY, page, per_page); - let resp = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?; - let candidates: Vec = json["candidates"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|c| { - Some(FaceCandidate { - id: c["id"].as_i64().unwrap_or(0), - face_id: c["face_id"].as_str().map(|s| s.to_string()), - file_uuid: c["file_uuid"].as_str()?.to_string(), - frame_number: c["frame_number"].as_i64().unwrap_or(0), - confidence: c["confidence"].as_f64().unwrap_or(0.0), - bbox: c["bbox"].as_object().map(|o| FaceBBox { - x: o["x"].as_f64().unwrap_or(0.0), - y: o["y"].as_f64().unwrap_or(0.0), - width: o["width"].as_f64().unwrap_or(0.0), - height: o["height"].as_f64().unwrap_or(0.0), - }), - }) - }) - .collect(); - Ok(candidates) -} - -#[tauri::command(rename_all = "camelCase")] -async fn merge_identities(uuid: String, into_uuid: String) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/mergeinto?api_key={}", CORE_API, uuid, API_KEY); - let resp = client.post(&url) - .json(&serde_json::json!({"into_uuid": into_uuid})) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Merge failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn bind_face(uuid: String, face_id: Option, face_row_id: Option, file_uuid: String, expand_to_trace: Option) -> Result<(), String> { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/bind?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({"file_uuid": file_uuid}); - if let Some(fid) = &face_id { - body["face_id"] = serde_json::json!(fid); - } else if let Some(rid) = face_row_id { - body["id"] = serde_json::json!(rid); - } else { - return Err("Either face_id or face_row_id is required".to_string()); - } - if let Some(expand) = expand_to_trace { - body["expand_to_trace"] = serde_json::json!(expand); - } - let resp = client.post(&url) - .json(&body) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - if !resp.status().is_success() { - return Err(format!("Bind failed: {}", resp.status())); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn unbind_face(uuid: String, face_id: Option, face_row_id: Option, file_uuid: String, _frame_number: Option) -> Result<(), String> { - let client = get_client(); - let mut body = serde_json::json!({"file_uuid": file_uuid}); - if let Some(fid) = face_id { - body["face_id"] = serde_json::json!(fid); - } else if let Some(rid) = face_row_id { - body["id"] = serde_json::json!(rid); - } else { - return Err("Either face_id or face_row_id is required".to_string()); - } - let url = format!("{}/api/v1/identity/{}/unbind?api_key={}", CORE_API, uuid, API_KEY); - eprintln!("[unbind_face] POST {} body={}", url, body); - let resp = client.post(&url) - .json(&body) - .send().await - .map_err(|e| format!("Request failed: {}", e))?; - let status = resp.status(); - let resp_text = resp.text().await.unwrap_or_default(); - eprintln!("[unbind_face] status={} body={}", status, resp_text); - if !status.is_success() { - return Err(format!("Unbind failed: {} - {}", status, resp_text)); - } - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_undo(uuid: String, steps: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/undo?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(s) = steps { - body["steps"] = serde_json::Value::Number(s.into()); - } - let resp = client.post(&url).json(&body).send().await - .map_err(|e| format!("Undo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_redo(uuid: String, steps: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/redo?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(s) = steps { - body["steps"] = serde_json::Value::Number(s.into()); - } - let resp = client.post(&url).json(&body).send().await - .map_err(|e| format!("Redo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_history(uuid: String, page: Option, page_size: Option) -> Result { - let client = get_client(); - let mut url = format!("{}/api/v1/identity/{}/history?api_key={}", CORE_API, uuid, API_KEY); - if let Some(p) = page { url = format!("{}&page={}", url, p); } - if let Some(ps) = page_size { url = format!("{}&page_size={}", url, ps); } - let resp = client.get(&url).send().await - .map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_bind_undo(uuid: String, steps: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/bind/undo?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(s) = steps { - body["steps"] = serde_json::Value::Number(s.into()); - } - let resp = client.post(&url).json(&body).send().await - .map_err(|e| format!("Bind undo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_bind_redo(uuid: String, steps: Option) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/{}/bind/redo?api_key={}", CORE_API, uuid, API_KEY); - let mut body = serde_json::json!({}); - if let Some(s) = steps { - body["steps"] = serde_json::Value::Number(s.into()); - } - let resp = client.post(&url).json(&body).send().await - .map_err(|e| format!("Bind redo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn identity_bind_history(uuid: String, page: Option, page_size: Option) -> Result { - let client = get_client(); - let mut url = format!("{}/api/v1/identity/{}/bind/history?api_key={}", CORE_API, uuid, API_KEY); - if let Some(p) = page { url = format!("{}&page={}", url, p); } - if let Some(ps) = page_size { url = format!("{}&page_size={}", url, ps); } - let resp = client.get(&url).send().await - .map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn merge_undo(merge_id: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/merge/{}/undo?api_key={}", CORE_API, merge_id, API_KEY); - let resp = client.post(&url).send().await - .map_err(|e| format!("Merge undo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn merge_redo(merge_id: String) -> Result { - let client = get_client(); - let url = format!("{}/api/v1/identity/merge/{}/redo?api_key={}", CORE_API, merge_id, API_KEY); - let resp = client.post(&url).send().await - .map_err(|e| format!("Merge redo failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -#[tauri::command(rename_all = "camelCase")] -async fn merge_history(source_uuid: Option, target_uuid: Option, page: Option, page_size: Option) -> Result { - let client = get_client(); - let mut url = format!("{}/api/v1/identity/merge/history?api_key={}", CORE_API, API_KEY); - if let Some(s) = source_uuid { url = format!("{}&source_uuid={}", url, s); } - if let Some(t) = target_uuid { url = format!("{}&target_uuid={}", url, t); } - if let Some(p) = page { url = format!("{}&page={}", url, p); } - if let Some(ps) = page_size { url = format!("{}&page_size={}", url, ps); } - let resp = client.get(&url).send().await - .map_err(|e| format!("Request failed: {}", e))?; - let json: serde_json::Value = resp.json().await - .map_err(|e| format!("Parse failed: {}", e))?; - Ok(json) -} - -fn main() { - std::thread::spawn(|| { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(proxy::start_proxy_server()); - }); - - tauri::Builder::default() - .plugin(tauri_plugin_shell::init()) - .setup(|app| { - use tauri::Manager; - let _ = app.get_webview_window("main"); - if let Err(e) = db::init_db() { - eprintln!("Failed to init database: {}", e); - } - Ok(()) - }) -.invoke_handler(tauri::generate_handler![ - search_llm_smart, - search_agents, - get_files, - register_file, - process_file, - ingest_file, - checkout_file, - unregister_file, - get_people, - get_faces, - get_traces, - get_file_info, - get_thumbnail, - get_face_thumbnail, - get_identity_profile, - update_identity_name, - update_identity_status, - update_identity_starred, - update_identity, - upload_profile_image, - set_profile_from_face, - match_from_photo, - delete_identity, - create_identity_from_face, - search_identities, - get_face_candidates, - merge_identities, - bind_face, - unbind_face, - identity_undo, - identity_redo, - identity_history, - identity_bind_undo, - identity_bind_redo, - identity_bind_history, - merge_undo, - merge_redo, - merge_history, - db::get_search_history, - db::save_search_history, - db::rename_search_history, - db::pin_search_history, - db::delete_search_history, - db::get_bookmarks, - db::save_bookmark, - db::delete_bookmark - ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} diff --git a/src/components/FaceCard.vue b/src/components/FaceCard.vue new file mode 100644 index 0000000..30c496a --- /dev/null +++ b/src/components/FaceCard.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/src/components/FaceFilterPanel.vue b/src/components/FaceFilterPanel.vue new file mode 100644 index 0000000..0eeb235 --- /dev/null +++ b/src/components/FaceFilterPanel.vue @@ -0,0 +1,406 @@ + + + + + diff --git a/src/composables/useFaceFilter.ts b/src/composables/useFaceFilter.ts new file mode 100644 index 0000000..f7ee012 --- /dev/null +++ b/src/composables/useFaceFilter.ts @@ -0,0 +1,389 @@ +import { ref, computed } from 'vue'; + +// Types +export interface FaceMetadata { + face_id: string; + frame: number; + primary_source: 'apple_vision' | 'mediapipe'; + primary_confidence: number; + detection_sources: Array<{ + source_type: string; + confidence: number; + }>; + filter_features: { + face_id: string; + frame: number; + bbox_size: number; + apple_vision_confidence: number; + mediapipe_confidence?: number; + eye_ratio?: number; + nose_ratio?: number; + pose_score?: number; + mediapipe_detected?: boolean; + mediapipe_iou?: number; + embedding_similarity?: number; + consecutive_frames?: number; + total_frames_detected?: number; + backend_filter_status?: 'accept' | 'reject' | 'review'; + }; +} + +export interface QuickFilterRules { + min_confidence?: number; + max_confidence?: number; + min_bbox_size?: number; + max_bbox_size?: number; + source?: 'apple_vision' | 'mediapipe' | 'all'; + backend_status?: 'accept' | 'reject' | 'review' | 'all'; +} + +export interface FilterResult { + total_faces: number; + passed: number; + filtered: number; + passed_faces: Array<{ + face_id: string; + frame: number; + filter_result: { + overall_confidence: number; + recommendation: 'accept' | 'review' | 'reject'; + reasons: string[]; + }; + }>; + filtered_faces: Array<{ + face_id: string; + frame: number; + filter_result: { + overall_confidence: number; + recommendation: 'accept' | 'review' | 'reject'; + reasons: string[]; + }; + }>; + filter_stats: { + landmark?: number; + mediapipe?: number; + embedding?: number; + temporal?: number; + }; +} + +export interface FilterStats { + total_faces: number; + by_category: { + total_faces: number; + by_source: { + apple_vision: number; + mediapipe: number; + }; + by_quality: { + avg_eye_ratio?: number; + avg_nose_ratio?: number; + avg_pose_score?: number; + mediapipe_verified: number; + avg_embedding_similarity?: number; + avg_consecutive_frames?: number; + }; + }; +} + +// Default rules +const defaultRules: QuickFilterRules = { + min_confidence: 0.5, + min_bbox_size: 80, + source: 'all', + backend_status: 'accept' +}; + +const STORAGE_KEY = 'momentry_face_filter_preferences'; + +// API helpers +const apiBase = '/api/v1'; + +async function fetchFaces(fileUuid: string, params?: { page?: number; page_size?: number; source?: string }) { + const queryParams = new URLSearchParams(); + if (params?.page) queryParams.set('page', params.page.toString()); + if (params?.page_size) queryParams.set('page_size', params.page_size.toString()); + if (params?.source) queryParams.set('source', params.source); + + const response = await fetch(`${apiBase}/file/${fileUuid}/faces?${queryParams}`); + if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); + return response.json(); +} + +async function applyFilter(fileUuid: string, rules?: any) { + const response = await fetch(`${apiBase}/file/${fileUuid}/faces/filter`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rules: rules || null }) + }); + if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); + return response.json(); +} + +async function getFilterStats(fileUuid: string) { + const response = await fetch(`${apiBase}/file/${fileUuid}/filter-stats`); + if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); + return response.json(); +} + +async function getFaceReport(fileUuid: string, faceId: string) { + const response = await fetch(`${apiBase}/file/${fileUuid}/face/${faceId}/filter-report`); + if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); + return response.json(); +} + +// Quick filter function (client-side) +function quickFilter(faces: FaceMetadata[], rules: QuickFilterRules): FaceMetadata[] { + return faces.filter(face => { + // 1. Confidence 過濾 + if (rules.min_confidence && face.primary_confidence < rules.min_confidence) { + return false; + } + if (rules.max_confidence && face.primary_confidence > rules.max_confidence) { + return false; + } + + // 2. Bbox size 過濾 + if (rules.min_bbox_size && face.filter_features.bbox_size < rules.min_bbox_size) { + return false; + } + if (rules.max_bbox_size && face.filter_features.bbox_size > rules.max_bbox_size) { + return false; + } + + // 3. Source 過濾 + if (rules.source && rules.source !== 'all' && face.primary_source !== rules.source) { + return false; + } + + // 4. Backend status 過濾 + if (rules.backend_status && rules.backend_status !== 'all' && + face.filter_features.backend_filter_status !== rules.backend_status) { + return false; + } + + return true; + }); +} + +// Hybrid filter function (backend + frontend) +function hybridFilter(faces: FaceMetadata[], rules: QuickFilterRules): FaceMetadata[] { + // 1. 前端過濾 + const frontendFiltered = quickFilter(faces, rules); + + // 2. 後端已標示 backend_filter_status + // 3. 兩條件 AND + return frontendFiltered.filter(face => + face.filter_features.backend_filter_status === 'accept' + ); +} + +// Main composable +export function useFaceFilter() { + const mode = ref<'instant' | 'backend' | 'hybrid'>('instant'); + const rules = ref({ ...defaultRules }); + const allFaces = ref([]); + const filteredFaces = ref([]); + const filterResult = ref(null); + const filterStats = ref(null); + const loading = ref(false); + const error = ref(null); + + // Computed stats + const stats = computed(() => ({ + total: allFaces.value.length, + filtered: filteredFaces.value.length, + removed: allFaces.value.length - filteredFaces.value.length + })); + + // Load preferences from localStorage + function loadPreferences() { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const prefs = JSON.parse(stored); + mode.value = prefs.mode || 'instant'; + rules.value = prefs.rules || { ...defaultRules }; + } + } catch (err) { + console.error('Failed to load preferences:', err); + } + } + + // Save preferences to localStorage + function savePreferences() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + mode: mode.value, + rules: rules.value + })); + } catch (err) { + console.error('Failed to save preferences:', err); + } + } + + // Initialize: load faces and apply filter + async function initialize(fileUuid: string) { + loading.value = true; + error.value = null; + + try { + // 1. 載入臉部元數據 + const data = await fetchFaces(fileUuid, { page: 1, page_size: 1000 }); + allFaces.value = data.data?.faces || data.faces || []; + + // 2. 載入後端過濾結果(混合模式需要) + if (mode.value === 'hybrid' || mode.value === 'backend') { + const result = await applyFilter(fileUuid, null); + filterResult.value = result; + + // 合併後端過濾結果到元數據 + mergeBackendResults(result); + } + + // 3. 載入統計 + filterStats.value = await getFilterStats(fileUuid); + + // 4. 套用過濾 + applyLocalFilter(); + + // 5. 載入偏好 + loadPreferences(); + } catch (err: any) { + error.value = err.message || 'Failed to initialize'; + console.error('Failed to initialize face filter:', err); + } finally { + loading.value = false; + } + } + + // Merge backend filter results into metadata + function mergeBackendResults(result: FilterResult) { + result.passed_faces.forEach(face => { + const metadata = allFaces.value.find(f => f.face_id === face.face_id); + if (metadata) { + metadata.filter_features.backend_filter_status = face.filter_result.recommendation as any; + } + }); + + result.filtered_faces.forEach(face => { + const metadata = allFaces.value.find(f => f.face_id === face.face_id); + if (metadata) { + metadata.filter_features.backend_filter_status = face.filter_result.recommendation as any; + } + }); + } + + // Apply local filter based on mode + function applyLocalFilter() { + switch (mode.value) { + case 'instant': + filteredFaces.value = quickFilter(allFaces.value, rules.value); + break; + case 'backend': + filteredFaces.value = allFaces.value.filter(face => + face.filter_features.backend_filter_status === 'accept' + ); + break; + case 'hybrid': + filteredFaces.value = hybridFilter(allFaces.value, rules.value); + break; + } + } + + // Update rules and re-filter + function updateRules(newRules: Partial) { + rules.value = { ...rules.value, ...newRules }; + applyLocalFilter(); + savePreferences(); + } + + // Change mode and re-filter + function setMode(newMode: 'instant' | 'backend' | 'hybrid') { + mode.value = newMode; + applyLocalFilter(); + savePreferences(); + } + + // Apply backend filter + async function applyBackendFilter(fileUuid: string, customRules?: any) { + loading.value = true; + error.value = null; + + try { + const result = await applyFilter(fileUuid, customRules); + filterResult.value = result; + + // 合併後端過濾結果到元數據 + mergeBackendResults(result); + + // 重新套用過濾 + applyLocalFilter(); + + return result; + } catch (err: any) { + error.value = err.message || 'Failed to apply backend filter'; + console.error('Failed to apply backend filter:', err); + return null; + } finally { + loading.value = false; + } + } + + // Get face report + async function getFaceReportData(fileUuid: string, faceId: string) { + try { + return await getFaceReport(fileUuid, faceId); + } catch (err: any) { + console.error('Failed to get face report:', err); + return null; + } + } + + // Refresh stats + async function refreshStats(fileUuid: string) { + try { + filterStats.value = await getFilterStats(fileUuid); + } catch (err: any) { + console.error('Failed to refresh stats:', err); + } + } + + // Reset to default + function resetToDefault() { + rules.value = { ...defaultRules }; + applyLocalFilter(); + savePreferences(); + } + + return { + // State + mode, + rules, + allFaces, + filteredFaces, + filterResult, + filterStats, + loading, + error, + stats, + + // Actions + initialize, + updateRules, + setMode, + applyBackendFilter, + getFaceReportData, + refreshStats, + resetToDefault, + applyLocalFilter, + + // API helpers (exposed for direct use) + fetchFaces, + applyFilter, + getFilterStats, + getFaceReport, + quickFilter, + hybridFilter + }; +} diff --git a/src/views/AdminView.vue.backup_20260626_162155 b/src/views/AdminView.vue.backup_20260626_162155 deleted file mode 100644 index 7c777bf..0000000 --- a/src/views/AdminView.vue.backup_20260626_162155 +++ /dev/null @@ -1,85 +0,0 @@ - - - - - diff --git a/src/views/ClientView.vue.backup_20260626_162155 b/src/views/ClientView.vue.backup_20260626_162155 deleted file mode 100644 index 3d30380..0000000 --- a/src/views/ClientView.vue.backup_20260626_162155 +++ /dev/null @@ -1,84 +0,0 @@ - - - - -