feat: ASRX hybrid pipeline, identity history, worker fixes, checkpoint system
This commit is contained in:
@@ -1,10 +1,4 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::post,
|
||||
Router,
|
||||
};
|
||||
use axum::{extract::State, http::StatusCode, response::Json, routing::post, Router};
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -13,7 +7,10 @@ use std::time::Instant;
|
||||
|
||||
use crate::api::types::AppState;
|
||||
use crate::core::db::schema;
|
||||
use crate::core::llm::function_calling::{self, ChatMessage, LlmResponse, ToolCall, ToolDef};
|
||||
use crate::core::llm::function_calling::{
|
||||
self, call_llm_vision, ChatMessage, LlmResponse, ToolCall, ToolDef,
|
||||
};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
|
||||
// ── Conversation Manager ─────────────────────────────────────────
|
||||
|
||||
@@ -43,11 +40,14 @@ fn get_or_create_conv(conv_id: Option<&str>) -> (String, Vec<ChatMessage>) {
|
||||
}
|
||||
}
|
||||
let id = uuid::Uuid::new_v4().to_string().replace('-', "")[..16].to_string();
|
||||
map.insert(id.clone(), Conversation {
|
||||
messages: Vec::new(),
|
||||
created_at: Instant::now(),
|
||||
last_active: Instant::now(),
|
||||
});
|
||||
map.insert(
|
||||
id.clone(),
|
||||
Conversation {
|
||||
messages: Vec::new(),
|
||||
created_at: Instant::now(),
|
||||
last_active: Instant::now(),
|
||||
},
|
||||
);
|
||||
(id, Vec::new())
|
||||
}
|
||||
|
||||
@@ -85,8 +85,13 @@ const SYSTEM_PROMPT: &str = r#"你是 Momentry 影片分析助手。回答用戶
|
||||
## 工具使用規則
|
||||
1. 先確認用戶在問哪部影片 — 使用 find_file 或 list_files
|
||||
2. 人物問題優先使用 tkg_query
|
||||
3. 語意/內容問題使用 smart_search 或 universal_search
|
||||
4. 可以同時呼叫多個工具
|
||||
3. 人物台詞/發言問題使用 identities_search(輸入人名→回傳台詞片段)
|
||||
4. 人物對話互動(誰跟誰說話)使用 tkg_query 的 speaker_interaction
|
||||
5. 人物台詞內容使用 tkg_query 的 speaker_dialogue
|
||||
6. 用文字反查人物使用 identity_text(輸入關鍵字→找出誰說/提到這段話)
|
||||
7. 語意/內容問題使用 smart_search 或 universal_search
|
||||
8. 畫面分析使用 analyze_frame — 可以分析影片中的任何畫面內容(場景、人物表情、動作、物件等)
|
||||
9. 可以同時呼叫多個工具
|
||||
|
||||
## 引導規則
|
||||
- 如果用戶沒說片名 → 用 find_file 搜尋,如果名稱不明確就反問
|
||||
@@ -120,16 +125,16 @@ fn make_tools(pool: &sqlx::PgPool) -> Vec<ToolDef> {
|
||||
),
|
||||
function_calling::make_tool(
|
||||
"tkg_query",
|
||||
"查詢影片的人物互動、配對、同框資料。query_type 包括:top_identities(人物排名)、first_cooccurrence(第一次同框)、identity_details(人物詳細)、mutual_gaze(互看)、interaction_network(互動網絡)、identity_traces(出場片段)、file_info(影片資訊)。",
|
||||
"查詢影片的人物互動、配對、同框、台詞資料。query_type 包括:top_identities(人物排名)、first_cooccurrence(第一次同框)、identity_details(人物詳細)、mutual_gaze(互看)、interaction_network(互動網絡)、identity_traces(出場片段)、file_info(影片資訊)、speaker_dialogue(人物台詞)、speaker_interaction(兩人對話互動)。",
|
||||
serde_json::json!({
|
||||
"file_uuid": {"type": "string", "description": "影片 UUID"},
|
||||
"query_type": {
|
||||
"type": "string",
|
||||
"enum": ["top_identities", "first_cooccurrence", "identity_details", "mutual_gaze", "interaction_network", "identity_traces", "file_info"],
|
||||
"enum": ["top_identities", "first_cooccurrence", "identity_details", "mutual_gaze", "interaction_network", "identity_traces", "file_info", "speaker_dialogue", "speaker_interaction"],
|
||||
"description": "查詢類型"
|
||||
},
|
||||
"identity_name": {"type": "string", "description": "人物名稱(配合 identity_details / identity_traces)"},
|
||||
"identity_b": {"type": "string", "description": "第二人物名稱(配合 first_cooccurrence / mutual_gaze)"},
|
||||
"identity_name": {"type": "string", "description": "人物名稱(配合 identity_details / identity_traces / speaker_dialogue / speaker_interaction)"},
|
||||
"identity_b": {"type": "string", "description": "第二人物名稱(配合 first_cooccurrence / mutual_gaze / speaker_interaction)"},
|
||||
"limit": {"type": "integer", "default": 5}
|
||||
}),
|
||||
vec!["file_uuid", "query_type"],
|
||||
@@ -144,6 +149,26 @@ fn make_tools(pool: &sqlx::PgPool) -> Vec<ToolDef> {
|
||||
}),
|
||||
vec!["query"],
|
||||
),
|
||||
function_calling::make_tool(
|
||||
"identity_text",
|
||||
"搜尋文字關鍵字,找出有提及該內容的影片人物。適合回答「誰說了OOO」、「誰跟OOO有關」。不是查詢人物的台詞,而是用文字反查人物。",
|
||||
serde_json::json!({
|
||||
"q": {"type": "string", "description": "搜尋關鍵字(台詞片段、主題等)"},
|
||||
"file_uuid": {"type": "string", "description": "限制搜尋範圍(可選)"},
|
||||
"limit": {"type": "integer", "default": 10}
|
||||
}),
|
||||
vec!["q"],
|
||||
),
|
||||
function_calling::make_tool(
|
||||
"identities_search",
|
||||
"查詢特定人物的台詞/發言內容。輸入人物名稱,回傳該人物在影片中說過的話。適合回答「某某人說了什麼」、「某某人的台詞」。",
|
||||
serde_json::json!({
|
||||
"q": {"type": "string", "description": "人物名稱關鍵字(姓名、角色名、別名)"},
|
||||
"file_uuid": {"type": "string", "description": "限制搜尋範圍(可選)"},
|
||||
"limit": {"type": "integer", "default": 10}
|
||||
}),
|
||||
vec!["q"],
|
||||
),
|
||||
function_calling::make_tool(
|
||||
"get_identity_detail",
|
||||
"查詢單一身份的詳細資料(名字、角色、TMDb 資訊)。",
|
||||
@@ -168,6 +193,16 @@ fn make_tools(pool: &sqlx::PgPool) -> Vec<ToolDef> {
|
||||
}),
|
||||
vec!["file_uuid"],
|
||||
),
|
||||
function_calling::make_tool(
|
||||
"analyze_frame",
|
||||
"分析影片中指定畫面的視覺內容(場景、人物表情、動作、物件等)。若不指定 frame_number,會使用代表性畫面。問題會傳給視覺 LLM 分析。",
|
||||
serde_json::json!({
|
||||
"file_uuid": {"type": "string", "description": "影片 UUID"},
|
||||
"question": {"type": "string", "description": "關於畫面的問題,例如「這個場景發生什麼事?」"},
|
||||
"frame_number": {"type": "integer", "description": "指定的 frame 編號(可選)"}
|
||||
}),
|
||||
vec!["file_uuid"],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -193,9 +228,10 @@ async fn exec_find_file(pool: &sqlx::PgPool, args: &serde_json::Value) -> Result
|
||||
if rows.is_empty() {
|
||||
return Ok(serde_json::json!({"found": false, "message": "No files match the query. Try different keywords."}).to_string());
|
||||
}
|
||||
let files: Vec<serde_json::Value> = rows.into_iter().map(|(u, n, hd)| {
|
||||
serde_json::json!({"file_uuid": u, "file_name": n, "has_data": hd})
|
||||
}).collect();
|
||||
let files: Vec<serde_json::Value> = rows
|
||||
.into_iter()
|
||||
.map(|(u, n, hd)| serde_json::json!({"file_uuid": u, "file_name": n, "has_data": hd}))
|
||||
.collect();
|
||||
Ok(serde_json::json!({"found": true, "files": files}).to_string())
|
||||
}
|
||||
|
||||
@@ -214,15 +250,19 @@ async fn exec_list_files(pool: &sqlx::PgPool, args: &serde_json::Value) -> Resul
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let files: Vec<serde_json::Value> = rows.into_iter().map(|(u, n, hd)| {
|
||||
serde_json::json!({"file_uuid": u, "file_name": n, "has_data": hd})
|
||||
}).collect();
|
||||
let files: Vec<serde_json::Value> = rows
|
||||
.into_iter()
|
||||
.map(|(u, n, hd)| serde_json::json!({"file_uuid": u, "file_name": n, "has_data": hd}))
|
||||
.collect();
|
||||
Ok(serde_json::json!({"files": files}).to_string())
|
||||
}
|
||||
|
||||
async fn exec_tkg_query(pool: &sqlx::PgPool, args: &serde_json::Value) -> Result<String, String> {
|
||||
let file_uuid = args.get("file_uuid").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let query_type = args.get("query_type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let query_type = args
|
||||
.get("query_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let identity_name = args.get("identity_name").and_then(|v| v.as_str());
|
||||
let identity_b = args.get("identity_b").and_then(|v| v.as_str());
|
||||
let limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(5);
|
||||
@@ -242,9 +282,11 @@ async fn exec_tkg_query(pool: &sqlx::PgPool, args: &serde_json::Value) -> Result
|
||||
GROUP BY i.uuid, i.name ORDER BY face_count DESC LIMIT $2",
|
||||
fd_table, id_table
|
||||
))
|
||||
.bind(file_uuid).bind(limit)
|
||||
.bind(file_uuid)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await.map_err(|e| e.to_string())?;
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({"identities": rows}).to_string())
|
||||
}
|
||||
"first_cooccurrence" => {
|
||||
@@ -325,8 +367,9 @@ async fn exec_tkg_query(pool: &sqlx::PgPool, args: &serde_json::Value) -> Result
|
||||
}
|
||||
"identity_traces" => {
|
||||
let name = identity_name.unwrap_or("");
|
||||
let rows: Vec<(i32, i64, i32, i32)> = sqlx::query_as(&format!(
|
||||
"SELECT fd.trace_id, COUNT(*)::bigint, MIN(fd.frame_number)::int, MAX(fd.frame_number)::int \
|
||||
// MIN/MAX frame_number should be bigint (i64), not int
|
||||
let rows: Vec<(i32, i64, i64, i64)> = sqlx::query_as(&format!(
|
||||
"SELECT fd.trace_id, COUNT(*)::bigint, MIN(fd.frame_number)::bigint, MAX(fd.frame_number)::bigint \
|
||||
FROM {} fd JOIN {} i ON i.id = fd.identity_id \
|
||||
WHERE fd.file_uuid = $1 AND i.name ILIKE $2 \
|
||||
GROUP BY fd.trace_id ORDER BY COUNT(*) DESC LIMIT $3",
|
||||
@@ -344,14 +387,133 @@ async fn exec_tkg_query(pool: &sqlx::PgPool, args: &serde_json::Value) -> Result
|
||||
))
|
||||
.bind(file_uuid)
|
||||
.fetch_optional(pool)
|
||||
.await.map_err(|e| e.to_string())?;
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({"file_info": row.map(|(n, d, w, h, f)| serde_json::json!({"file_name": n, "duration_sec": d, "width": w, "height": h, "fps": f}))}).to_string())
|
||||
}
|
||||
_ => Ok(serde_json::json!({"error": format!("Unknown query_type: {}", query_type)}).to_string()),
|
||||
"speaker_dialogue" => {
|
||||
let name = identity_name.unwrap_or("");
|
||||
let rows: Vec<(String, Option<String>)> = sqlx::query_as(&format!(
|
||||
"SELECT DISTINCT sn.external_id, sn.properties->>'full_text' AS full_text \
|
||||
FROM {} i \
|
||||
JOIN {} fd ON fd.identity_id = i.id AND ($2::text IS NULL OR fd.file_uuid = $2) \
|
||||
JOIN {} fn ON fn.file_uuid = fd.file_uuid \
|
||||
AND fn.node_type = 'face_trace' \
|
||||
AND fn.external_id = CONCAT('trace_', fd.trace_id) \
|
||||
JOIN {} e ON e.source_node_id = fn.id \
|
||||
AND e.edge_type = 'SPEAKS_AS' \
|
||||
AND ($2::text IS NULL OR e.file_uuid = $2) \
|
||||
JOIN {} sn ON sn.id = e.target_node_id \
|
||||
WHERE i.name ILIKE $1 \
|
||||
LIMIT $3",
|
||||
id_table, fd_table, nodes, edges, nodes
|
||||
))
|
||||
.bind(name)
|
||||
.bind(file_uuid)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(
|
||||
serde_json::json!({"speakers": rows.iter().map(|(sid, text)| {
|
||||
serde_json::json!({"speaker_id": sid, "dialogue": text})
|
||||
}).collect::<Vec<_>>()})
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
"speaker_interaction" => {
|
||||
let name_a = identity_name.unwrap_or("");
|
||||
let name_b = identity_b.unwrap_or("");
|
||||
if name_a.is_empty() || name_b.is_empty() {
|
||||
return Ok(
|
||||
serde_json::json!({"error": "identity_name and identity_b are required"})
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// Get both speakers' segments from TKG
|
||||
let rows: Vec<(String, String, serde_json::Value)> = sqlx::query_as(&format!(
|
||||
"SELECT sn.external_id, sn.properties->>'full_text' AS full_text, sn.properties->'segments' AS segments \
|
||||
FROM {} i \
|
||||
JOIN {} fd ON fd.identity_id = i.id AND ($3::text IS NULL OR fd.file_uuid = $3) \
|
||||
JOIN {} fn ON fn.file_uuid = fd.file_uuid \
|
||||
AND fn.node_type = 'face_trace' \
|
||||
AND fn.external_id = CONCAT('trace_', fd.trace_id) \
|
||||
JOIN {} e ON e.source_node_id = fn.id \
|
||||
AND e.edge_type = 'SPEAKS_AS' \
|
||||
AND ($3::text IS NULL OR e.file_uuid = $3) \
|
||||
JOIN {} sn ON sn.id = e.target_node_id \
|
||||
WHERE (i.name ILIKE $1 OR i.name ILIKE $2) \
|
||||
ORDER BY sn.external_id",
|
||||
id_table, fd_table, nodes, edges, nodes
|
||||
))
|
||||
.bind(name_a)
|
||||
.bind(name_b)
|
||||
.bind(file_uuid)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut interactions = Vec::new();
|
||||
for i in 0..rows.len() {
|
||||
for j in i + 1..rows.len() {
|
||||
let (sid_a, text_a, segs_a_val) = &rows[i];
|
||||
let (sid_b, text_b, segs_b_val) = &rows[j];
|
||||
let segs_a = segs_a_val.as_array();
|
||||
let segs_b = segs_b_val.as_array();
|
||||
if let (Some(a_list), Some(b_list)) = (segs_a, segs_b) {
|
||||
for sa in a_list {
|
||||
let sa_start = sa.get("start").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let sa_end = sa.get("end").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let sa_text = sa.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if sa_text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for sb in b_list {
|
||||
let sb_start =
|
||||
sb.get("start").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let sb_end = sb.get("end").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let sb_text = sb.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if sb_text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Check temporal overlap
|
||||
let overlap_start = sa_start.max(sb_start);
|
||||
let overlap_end = sa_end.min(sb_end);
|
||||
if overlap_start < overlap_end {
|
||||
interactions.push(serde_json::json!({
|
||||
"speaker_a": sid_a,
|
||||
"speaker_b": sid_b,
|
||||
"time_range_s": [overlap_start, overlap_end],
|
||||
"dialogue_a": sa_text,
|
||||
"dialogue_b": sb_text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
interactions.sort_by(|a, b| {
|
||||
let a_start = a["time_range_s"][0].as_f64().unwrap_or(0.0);
|
||||
let b_start = b["time_range_s"][0].as_f64().unwrap_or(0.0);
|
||||
a_start.partial_cmp(&b_start).unwrap()
|
||||
});
|
||||
interactions.truncate(limit as usize);
|
||||
|
||||
Ok(serde_json::json!({"interactions": interactions, "speaker_a_text": rows.first().map(|r| r.1.clone()), "speaker_b_text": rows.get(1).map(|r| r.1.clone())}).to_string())
|
||||
}
|
||||
_ => Ok(
|
||||
serde_json::json!({"error": format!("Unknown query_type: {}", query_type)}).to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn exec_smart_search(_pool: &sqlx::PgPool, args: &serde_json::Value) -> Result<String, String> {
|
||||
async fn exec_smart_search(
|
||||
_pool: &sqlx::PgPool,
|
||||
args: &serde_json::Value,
|
||||
) -> Result<String, String> {
|
||||
let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let file_uuid = args.get("file_uuid").and_then(|v| v.as_str());
|
||||
let limit = args.get("limit").and_then(|v| v.as_i64()).unwrap_or(5);
|
||||
@@ -359,7 +521,8 @@ async fn exec_smart_search(_pool: &sqlx::PgPool, args: &serde_json::Value) -> Re
|
||||
let chunk_table = schema::table_name("chunk");
|
||||
let mut sql = format!(
|
||||
"SELECT chunk_id, text_content, start_frame, end_frame, chunk_type \
|
||||
FROM {} WHERE text_content ILIKE $1", chunk_table
|
||||
FROM {} WHERE text_content ILIKE $1",
|
||||
chunk_table
|
||||
);
|
||||
if file_uuid.is_some() {
|
||||
sql.push_str(" AND file_uuid = $2");
|
||||
@@ -369,21 +532,147 @@ async fn exec_smart_search(_pool: &sqlx::PgPool, args: &serde_json::Value) -> Re
|
||||
if let Some(fuid) = file_uuid {
|
||||
let like = format!("%{}%", query);
|
||||
let rows: Vec<(String, Option<String>, i64, i64, String)> = sqlx::query_as(&sql)
|
||||
.bind(&like).bind(fuid)
|
||||
.bind(&like)
|
||||
.bind(fuid)
|
||||
.fetch_all(_pool)
|
||||
.await.map_err(|e| e.to_string())?;
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({"results": rows}).to_string())
|
||||
} else {
|
||||
let like = format!("%{}%", query);
|
||||
let rows: Vec<(String, Option<String>, i64, i64, String)> = sqlx::query_as(&sql)
|
||||
.bind(&like)
|
||||
.fetch_all(_pool)
|
||||
.await.map_err(|e| e.to_string())?;
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({"results": rows}).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
async fn exec_get_identity_detail(pool: &sqlx::PgPool, args: &serde_json::Value) -> Result<String, String> {
|
||||
async fn exec_identity_text(
|
||||
pool: &sqlx::PgPool,
|
||||
args: &serde_json::Value,
|
||||
) -> Result<String, String> {
|
||||
let q = args.get("q").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let file_uuid = args.get("file_uuid").and_then(|v| v.as_str());
|
||||
let limit = args
|
||||
.get("limit")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(10)
|
||||
.min(50);
|
||||
|
||||
let chunk_table = schema::table_name("chunk");
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
let id_table = schema::table_name("identities");
|
||||
let like_q = format!("%{}%", q.replace('%', "%%"));
|
||||
|
||||
let sql = format!(
|
||||
"SELECT c.chunk_id, c.start_time, c.end_time, c.text_content, \
|
||||
i.name AS identity_name, fd.trace_id, i.source AS identity_source \
|
||||
FROM {} c \
|
||||
JOIN {} fd ON fd.file_uuid = c.file_uuid \
|
||||
AND fd.frame_number BETWEEN c.start_frame AND c.end_frame \
|
||||
AND fd.identity_id IS NOT NULL \
|
||||
JOIN {} i ON i.id = fd.identity_id \
|
||||
WHERE ($1::text IS NULL OR c.file_uuid = $1) \
|
||||
AND (LOWER(c.text_content) LIKE LOWER($2) OR LOWER(c.content::text) LIKE LOWER($2)) \
|
||||
ORDER BY c.start_time \
|
||||
LIMIT $3",
|
||||
chunk_table, fd_table, id_table
|
||||
);
|
||||
|
||||
let rows: Vec<(
|
||||
String,
|
||||
f64,
|
||||
f64,
|
||||
Option<String>,
|
||||
String,
|
||||
Option<i32>,
|
||||
String,
|
||||
)> = sqlx::query_as(&sql)
|
||||
.bind(file_uuid)
|
||||
.bind(&like_q)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(
|
||||
serde_json::json!({"results": rows.iter().map(|(chunk_id, st, et, txt, name, tid, src)| {
|
||||
serde_json::json!({
|
||||
"chunk_id": chunk_id,
|
||||
"start_time": st,
|
||||
"end_time": et,
|
||||
"text": txt,
|
||||
"identity_name": name,
|
||||
"trace_id": tid,
|
||||
"source": src
|
||||
})
|
||||
} ).collect::<Vec<_>>()})
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn exec_identities_search(
|
||||
pool: &sqlx::PgPool,
|
||||
args: &serde_json::Value,
|
||||
) -> Result<String, String> {
|
||||
let q = args.get("q").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let file_uuid = args.get("file_uuid").and_then(|v| v.as_str());
|
||||
let limit = args
|
||||
.get("limit")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(10)
|
||||
.min(50);
|
||||
|
||||
let id_table = schema::table_name("identities");
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
let chunk_table = schema::table_name("chunk");
|
||||
let like_q = format!("%{}%", q.replace('%', "%%"));
|
||||
|
||||
let sql = format!(
|
||||
"SELECT DISTINCT ON (i.name, c.chunk_id) \
|
||||
i.name, c.chunk_id, c.start_time, c.end_time, c.text_content, fd.trace_id \
|
||||
FROM {} i \
|
||||
JOIN {} fd ON fd.identity_id = i.id \
|
||||
JOIN {} c ON c.file_uuid = fd.file_uuid \
|
||||
AND c.start_time <= fd.frame_number / COALESCE(c.fps, 25.0) \
|
||||
AND c.end_time >= fd.frame_number / COALESCE(c.fps, 25.0) \
|
||||
WHERE (i.name ILIKE $1 \
|
||||
OR EXISTS (SELECT 1 FROM jsonb_array_elements(i.metadata->'aliases') AS a WHERE a->>'name' ILIKE $1)) \
|
||||
AND ($2::text IS NULL OR fd.file_uuid = $2) \
|
||||
ORDER BY i.name, c.chunk_id, c.start_time \
|
||||
LIMIT $3",
|
||||
id_table, fd_table, chunk_table
|
||||
);
|
||||
|
||||
let rows: Vec<(String, String, f64, f64, Option<String>, Option<i32>)> = sqlx::query_as(&sql)
|
||||
.bind(&like_q)
|
||||
.bind(file_uuid)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(
|
||||
serde_json::json!({"results": rows.iter().map(|(name, chunk_id, st, et, txt, tid)| {
|
||||
serde_json::json!({
|
||||
"identity_name": name,
|
||||
"chunk_id": chunk_id,
|
||||
"start_time": st,
|
||||
"end_time": et,
|
||||
"text": txt,
|
||||
"trace_id": tid,
|
||||
})
|
||||
}).collect::<Vec<_>>()})
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn exec_get_identity_detail(
|
||||
pool: &sqlx::PgPool,
|
||||
args: &serde_json::Value,
|
||||
) -> Result<String, String> {
|
||||
let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let id_table = schema::table_name("identities");
|
||||
let row: Option<(String, String, Option<String>, Option<i32>, Option<String>)> = sqlx::query_as(&format!(
|
||||
@@ -396,7 +685,10 @@ async fn exec_get_identity_detail(pool: &sqlx::PgPool, args: &serde_json::Value)
|
||||
Ok(serde_json::json!({"identity": row.map(|(u, n, s, t, c)| serde_json::json!({"uuid": u, "name": n, "source": s, "tmdb_id": t, "character": c}))}).to_string())
|
||||
}
|
||||
|
||||
async fn exec_get_file_info(pool: &sqlx::PgPool, args: &serde_json::Value) -> Result<String, String> {
|
||||
async fn exec_get_file_info(
|
||||
pool: &sqlx::PgPool,
|
||||
args: &serde_json::Value,
|
||||
) -> Result<String, String> {
|
||||
let file_uuid = args.get("file_uuid").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let videos = schema::table_name("videos");
|
||||
let row: Option<(String, f64, i32, i32, f64)> = sqlx::query_as(&format!(
|
||||
@@ -405,11 +697,15 @@ async fn exec_get_file_info(pool: &sqlx::PgPool, args: &serde_json::Value) -> Re
|
||||
))
|
||||
.bind(file_uuid)
|
||||
.fetch_optional(pool)
|
||||
.await.map_err(|e| e.to_string())?;
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({"file_info": row.map(|(n, d, w, h, f)| serde_json::json!({"file_name": n, "duration_sec": d, "width": w, "height": h, "fps": f}))}).to_string())
|
||||
}
|
||||
|
||||
async fn exec_get_representative_frame(pool: &sqlx::PgPool, args: &serde_json::Value) -> Result<String, String> {
|
||||
async fn exec_get_representative_frame(
|
||||
pool: &sqlx::PgPool,
|
||||
args: &serde_json::Value,
|
||||
) -> Result<String, String> {
|
||||
let file_uuid = args.get("file_uuid").and_then(|v| v.as_str()).unwrap_or("");
|
||||
match crate::core::processor::tkg::query_auto_representative_frame(pool, file_uuid).await {
|
||||
Ok(r) => Ok(serde_json::json!({
|
||||
@@ -417,24 +713,131 @@ async fn exec_get_representative_frame(pool: &sqlx::PgPool, args: &serde_json::V
|
||||
"face_quality": r.face_quality,
|
||||
"main_identities": r.main_identities,
|
||||
"traces": r.traces,
|
||||
}).to_string()),
|
||||
})
|
||||
.to_string()),
|
||||
Err(e) => Ok(serde_json::json!({"error": e.to_string()}).to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn exec_analyze_frame(
|
||||
pool: &sqlx::PgPool,
|
||||
args: &serde_json::Value,
|
||||
) -> Result<String, String> {
|
||||
let file_uuid = args.get("file_uuid").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let question = args
|
||||
.get("question")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("請描述這個畫面中的內容");
|
||||
|
||||
if file_uuid.is_empty() {
|
||||
return Ok(serde_json::json!({"error": "file_uuid is required"}).to_string());
|
||||
}
|
||||
|
||||
let videos = schema::table_name("videos");
|
||||
let (video_path, fps): (String, f64) = sqlx::query_as(&format!(
|
||||
"SELECT file_path, COALESCE(fps, 25.0) FROM {} WHERE file_uuid = $1",
|
||||
videos
|
||||
))
|
||||
.bind(file_uuid)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "Video not found".to_string())?;
|
||||
|
||||
let frame_number = match args.get("frame_number").and_then(|v| v.as_i64()) {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
match crate::core::processor::tkg::query_auto_representative_frame(pool, file_uuid)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r.frame_number,
|
||||
Err(_) => {
|
||||
let duration: f64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COALESCE(duration, 0) FROM {} WHERE file_uuid = $1",
|
||||
videos
|
||||
))
|
||||
.bind(file_uuid)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.unwrap_or(0.0);
|
||||
if duration > 0.0 {
|
||||
((duration / 2.0) * fps) as i64
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let timestamp_secs = frame_number as f64 / fps;
|
||||
|
||||
let ffmpeg_path = std::env::var("MOMENTRY_FFMPEG").unwrap_or_else(|_| {
|
||||
let full = "/opt/homebrew/opt/ffmpeg-full/bin/ffmpeg";
|
||||
if std::path::Path::new(full).exists() {
|
||||
full.to_string()
|
||||
} else {
|
||||
"ffmpeg".to_string()
|
||||
}
|
||||
});
|
||||
|
||||
let output = tokio::process::Command::new(&ffmpeg_path)
|
||||
.args([
|
||||
"-ss",
|
||||
&format!("{:.3}", timestamp_secs),
|
||||
"-i",
|
||||
&video_path,
|
||||
"-vframes",
|
||||
"1",
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-vcodec",
|
||||
"mjpeg",
|
||||
"-",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("ffmpeg execution error: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Ok(serde_json::json!({"error": format!("ffmpeg failed: {}", stderr)}).to_string());
|
||||
}
|
||||
|
||||
let base64_img = BASE64.encode(&output.stdout);
|
||||
|
||||
let system_prompt =
|
||||
"你是一個專業的影片畫面分析助手。請根據提供的畫面以及用戶的問題,詳細描述畫面中的內容,包括場景、人物、動作、表情、物件等。請用繁體中文回答。";
|
||||
let vision_result = call_llm_vision(system_prompt, question, vec![base64_img], 1024, 120)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"frame_number": frame_number,
|
||||
"timestamp_secs": timestamp_secs,
|
||||
"analysis": vision_result,
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
|
||||
// ── Tool Router ───────────────────────────────────────────────────
|
||||
|
||||
async fn execute_tool(pool: &sqlx::PgPool, tool_call: &ToolCall) -> (String, String, String) {
|
||||
let name = tool_call.function.name.clone();
|
||||
let args: serde_json::Value = serde_json::from_str(&tool_call.function.arguments).unwrap_or_default();
|
||||
let args: serde_json::Value =
|
||||
serde_json::from_str(&tool_call.function.arguments).unwrap_or_default();
|
||||
let result = match name.as_str() {
|
||||
"find_file" => exec_find_file(pool, &args).await,
|
||||
"list_files" => exec_list_files(pool, &args).await,
|
||||
"tkg_query" => exec_tkg_query(pool, &args).await,
|
||||
"smart_search" => exec_smart_search(pool, &args).await,
|
||||
"identity_text" => exec_identity_text(pool, &args).await,
|
||||
"identities_search" => exec_identities_search(pool, &args).await,
|
||||
"get_identity_detail" => exec_get_identity_detail(pool, &args).await,
|
||||
"get_file_info" => exec_get_file_info(pool, &args).await,
|
||||
"get_representative_frame" => exec_get_representative_frame(pool, &args).await,
|
||||
"analyze_frame" => exec_analyze_frame(pool, &args).await,
|
||||
_ => Err(format!("Unknown tool: {}", name)),
|
||||
};
|
||||
let content = match result {
|
||||
@@ -476,7 +879,11 @@ async fn run_tool_loop(
|
||||
for call in &calls {
|
||||
let (tool_call_id, name, content) = execute_tool(pool, call).await;
|
||||
sources.push(serde_json::json!({"tool": name, "result": content}));
|
||||
messages.push(function_calling::make_tool_result(&tool_call_id, &name, &content));
|
||||
messages.push(function_calling::make_tool_result(
|
||||
&tool_call_id,
|
||||
&name,
|
||||
&content,
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -484,7 +891,10 @@ async fn run_tool_loop(
|
||||
}
|
||||
}
|
||||
}
|
||||
("已達到最大查詢次數,請縮小問題範圍後重新詢問。".to_string(), sources)
|
||||
(
|
||||
"已達到最大查詢次數,請縮小問題範圍後重新詢問。".to_string(),
|
||||
sources,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Handler ───────────────────────────────────────────────────────
|
||||
@@ -495,13 +905,8 @@ async fn agent_search(
|
||||
) -> Result<Json<AgentSearchResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let (conv_id, history) = get_or_create_conv(req.conversation_id.as_deref());
|
||||
|
||||
let (answer, sources) = run_tool_loop(
|
||||
state.db.pool(),
|
||||
SYSTEM_PROMPT,
|
||||
&req.query,
|
||||
history,
|
||||
)
|
||||
.await;
|
||||
let (answer, sources) =
|
||||
run_tool_loop(state.db.pool(), SYSTEM_PROMPT, &req.query, history).await;
|
||||
|
||||
// Save updated messages for conversation continuation
|
||||
let new_msgs = function_calling::build_conversation(SYSTEM_PROMPT, &req.query, vec![]);
|
||||
@@ -509,7 +914,11 @@ async fn agent_search(
|
||||
|
||||
let needs_input = answer.contains('?') || answer.contains('?');
|
||||
let suggestions = if needs_input {
|
||||
Some(vec!["演員名".to_string(), "電影片名".to_string(), "年份".to_string()])
|
||||
Some(vec![
|
||||
"演員名".to_string(),
|
||||
"電影片名".to_string(),
|
||||
"年份".to_string(),
|
||||
])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -526,6 +935,5 @@ async fn agent_search(
|
||||
// ── Routes ─────────────────────────────────────────────────────────
|
||||
|
||||
pub fn agent_search_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/agents/search", post(agent_search))
|
||||
Router::new().route("/api/v1/agents/search", post(agent_search))
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ async fn doc_redirect() -> axum::response::Redirect {
|
||||
|
||||
async fn wasm_doc_handler() -> Result<impl axum::response::IntoResponse, (StatusCode, &'static str)>
|
||||
{
|
||||
let path =
|
||||
std::path::Path::new("/Users/accusys/momentry_core/docs_v1.0/doc_wasm/index.html");
|
||||
let path = std::path::Path::new("/Users/accusys/momentry_core/docs_v1.0/doc_wasm/index.html");
|
||||
match tokio::fs::read_to_string(path).await {
|
||||
Ok(html) => Ok(([("content-type", "text/html; charset=utf-8")], html)),
|
||||
Err(_) => Err((StatusCode::NOT_FOUND, "Doc not found")),
|
||||
|
||||
198
src/api/files.rs
198
src/api/files.rs
@@ -12,7 +12,7 @@ use std::collections::HashMap;
|
||||
use super::types::AppState;
|
||||
use crate::core::config;
|
||||
use crate::core::db::schema;
|
||||
use crate::core::db::{Database, PostgresDb};
|
||||
use crate::core::db::{Database, PostgresDb, QdrantDb, RedisClient};
|
||||
use crate::core::storage::content_hash;
|
||||
use crate::FileManager;
|
||||
|
||||
@@ -767,17 +767,7 @@ async fn register_file(
|
||||
if let Some(ref vp) = video_path {
|
||||
if let Ok(job) = auto_state.db.create_monitor_job(&auto_uuid, Some(vp)).await {
|
||||
tracing::info!("[AUTO-PIPELINE] Job {} created for {}", job.id, auto_uuid);
|
||||
let all_procs: Vec<&str> = vec![
|
||||
"asr",
|
||||
"cut",
|
||||
"yolo",
|
||||
"ocr",
|
||||
"face",
|
||||
"pose",
|
||||
"asrx",
|
||||
"visual_chunk",
|
||||
"5w1h",
|
||||
];
|
||||
let all_procs: Vec<&str> = vec!["cut", "yolo", "ocr", "face", "pose", "asrx"];
|
||||
let total = sqlx::query_scalar::<_, i64>(&format!(
|
||||
"SELECT COALESCE(total_frames, 0) FROM {} WHERE file_uuid = $1",
|
||||
schema::table_name("videos")
|
||||
@@ -986,6 +976,10 @@ struct UnregisterResponse {
|
||||
deleted_face_detections: u64,
|
||||
deleted_processor_results: u64,
|
||||
deleted_chunks: u64,
|
||||
deleted_tkg_nodes: u64,
|
||||
deleted_qdrant_vectors: Option<u64>,
|
||||
deleted_redis_keys: Option<u64>,
|
||||
deleted_output_files: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -994,18 +988,30 @@ struct UnregisterRequest {
|
||||
file_path: Option<String>,
|
||||
}
|
||||
|
||||
fn delete_output_files(uuid: &str) {
|
||||
let output_dir = config::OUTPUT_DIR.to_string();
|
||||
if let Ok(entries) = std::fs::read_dir(&output_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if name.starts_with(uuid) {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
fn delete_output_files(uuid: &str) -> u64 {
|
||||
let mut deleted_count = 0u64;
|
||||
let output_dirs = [
|
||||
config::OUTPUT_DIR.to_string(),
|
||||
"/Users/accusys/momentry/output_dev".to_string(),
|
||||
"/Users/accusys/momentry/output".to_string(),
|
||||
];
|
||||
|
||||
for output_dir in &output_dirs {
|
||||
if let Ok(entries) = std::fs::read_dir(output_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if name.starts_with(uuid) && name.ends_with(".json") {
|
||||
if std::fs::remove_file(&path).is_ok() {
|
||||
deleted_count += 1;
|
||||
tracing::info!("[UNREGISTER] Deleted output file: {}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
deleted_count
|
||||
}
|
||||
|
||||
async fn unregister(
|
||||
@@ -1024,65 +1030,54 @@ async fn unregister(
|
||||
let processor_table = schema::table_name("processor_results");
|
||||
let chunks_table = schema::table_name("chunk");
|
||||
let parent_chunks_table = schema::table_name("parent_chunks");
|
||||
|
||||
let deleted_faces: i64 =
|
||||
sqlx::query(&format!("DELETE FROM {} WHERE file_uuid = $1", face_table))
|
||||
.bind(&uuid)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("[unregister] Failed to delete faces: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
.rows_affected() as i64;
|
||||
|
||||
let deleted_processors: i64 = sqlx::query(&format!(
|
||||
"DELETE FROM {} WHERE file_uuid = $1",
|
||||
processor_table
|
||||
))
|
||||
.bind(&uuid)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("[unregister] Failed to delete processors: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
.rows_affected() as i64;
|
||||
|
||||
let deleted_parent_chunks: i64 = sqlx::query(&format!(
|
||||
"DELETE FROM {} WHERE uuid = $1",
|
||||
parent_chunks_table
|
||||
))
|
||||
.bind(&uuid)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("[unregister] Failed to delete parent chunks: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
.rows_affected() as i64;
|
||||
|
||||
let deleted_chunks: i64 = sqlx::query(&format!("DELETE FROM {} WHERE file_uuid = $1", chunks_table))
|
||||
.bind(&uuid)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("[unregister] Failed to delete chunks: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
.rows_affected() as i64;
|
||||
|
||||
// Delete pre_chunks
|
||||
let pre_chunks_table = schema::table_name("pre_chunks");
|
||||
let deleted_pre_chunks: i64 = sqlx::query(&format!(
|
||||
"DELETE FROM {} WHERE file_uuid = $1",
|
||||
pre_chunks_table
|
||||
let tkg_nodes_table = schema::table_name("tkg_nodes");
|
||||
let cuts_table = schema::table_name("cuts");
|
||||
let strangers_table = schema::table_name("strangers");
|
||||
let chunk_vectors_table = schema::table_name("chunk_vectors");
|
||||
let monitor_jobs_table = schema::table_name("monitor_jobs");
|
||||
let frames_table = schema::table_name("frames");
|
||||
|
||||
let mut tx = state.db.pool().begin().await.map_err(|e| {
|
||||
tracing::error!("[unregister] Failed to start transaction: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
macro_rules! delete_safe {
|
||||
($table:expr, $where:expr, $bind:expr, $label:expr) => {{
|
||||
sqlx::query(&format!("DELETE FROM {} WHERE {}", $table, $where))
|
||||
.bind($bind)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("[unregister] Failed to delete {}: {}", $label, e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
.rows_affected() as i64
|
||||
}};
|
||||
}
|
||||
|
||||
let deleted_faces = delete_safe!(face_table, "file_uuid = $1", &uuid, "faces");
|
||||
let deleted_processors = delete_safe!(processor_table, "file_uuid = $1", &uuid, "processors");
|
||||
let deleted_parent_chunks =
|
||||
delete_safe!(parent_chunks_table, "uuid = $1", &uuid, "parent chunks");
|
||||
let deleted_chunks = delete_safe!(chunks_table, "file_uuid = $1", &uuid, "chunks");
|
||||
let deleted_pre_chunks = delete_safe!(pre_chunks_table, "file_uuid = $1", &uuid, "pre_chunks");
|
||||
let deleted_tkg_nodes = delete_safe!(tkg_nodes_table, "file_uuid = $1", &uuid, "TKG nodes");
|
||||
let deleted_cuts = delete_safe!(cuts_table, "file_uuid = $1", &uuid, "cuts");
|
||||
let deleted_strangers = delete_safe!(strangers_table, "file_uuid = $1", &uuid, "strangers");
|
||||
let deleted_chunk_vectors =
|
||||
delete_safe!(chunk_vectors_table, "uuid = $1", &uuid, "chunk vectors");
|
||||
let deleted_monitor_jobs = delete_safe!(monitor_jobs_table, "uuid = $1", &uuid, "monitor jobs");
|
||||
let deleted_frames: i64 = sqlx::query(&format!(
|
||||
"DELETE FROM {} WHERE file_id = (SELECT id FROM {} WHERE file_uuid = $1)",
|
||||
frames_table, videos_table
|
||||
))
|
||||
.bind(&uuid)
|
||||
.execute(state.db.pool())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("[unregister] Failed to delete pre_chunks: {}", e);
|
||||
tracing::error!("[unregister] Failed to delete frames: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
.rows_affected() as i64;
|
||||
@@ -1092,14 +1087,59 @@ async fn unregister(
|
||||
videos_table
|
||||
))
|
||||
.bind(&uuid)
|
||||
.execute(state.db.pool())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("[unregister] Failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
delete_output_files(&uuid);
|
||||
tx.commit().await.map_err(|e| {
|
||||
tracing::error!("[unregister] Failed to commit transaction: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"[UNREGISTER] Deleted: {} faces, {} processors, {} parent_chunks, {} chunks, {} pre_chunks, {} tkg_nodes, {} cuts, {} strangers, {} chunk_vectors, {} monitor_jobs, {} frames",
|
||||
deleted_faces, deleted_processors, deleted_parent_chunks, deleted_chunks,
|
||||
deleted_pre_chunks, deleted_tkg_nodes, deleted_cuts, deleted_strangers,
|
||||
deleted_chunk_vectors, deleted_monitor_jobs, deleted_frames
|
||||
);
|
||||
|
||||
let deleted_output_files = delete_output_files(&uuid);
|
||||
|
||||
let deleted_qdrant_vectors = {
|
||||
let qdrant = QdrantDb::new();
|
||||
match qdrant.delete_by_uuid(&uuid).await {
|
||||
Ok(_) => {
|
||||
tracing::info!("[UNREGISTER] Deleted Qdrant vectors for {}", uuid);
|
||||
Some(1)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[UNREGISTER] Failed to delete Qdrant vectors: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let deleted_redis_keys = {
|
||||
match RedisClient::new() {
|
||||
Ok(redis) => match redis.delete_worker_job(&uuid).await {
|
||||
Ok(_) => {
|
||||
tracing::info!("[UNREGISTER] Deleted Redis keys for {}", uuid);
|
||||
Some(1)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[UNREGISTER] Failed to delete Redis keys: {}", e);
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("[UNREGISTER] Failed to create Redis client: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(UnregisterResponse {
|
||||
success: true,
|
||||
@@ -1107,7 +1147,11 @@ async fn unregister(
|
||||
file_uuid: uuid,
|
||||
deleted_face_detections: deleted_faces as u64,
|
||||
deleted_processor_results: deleted_processors as u64,
|
||||
deleted_chunks: (deleted_chunks + deleted_parent_chunks) as u64,
|
||||
deleted_chunks: (deleted_chunks + deleted_parent_chunks + deleted_pre_chunks) as u64,
|
||||
deleted_tkg_nodes: deleted_tkg_nodes as u64,
|
||||
deleted_qdrant_vectors,
|
||||
deleted_redis_keys,
|
||||
deleted_output_files,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -471,7 +471,7 @@ async fn store_parent_summary(
|
||||
"sentence_count": sentences.len(),
|
||||
});
|
||||
sqlx::query(&format!(
|
||||
r#"UPDATE {} SET summary_text = $1, metadata = metadata || $2::jsonb
|
||||
r#"UPDATE {} SET summary_text = $1, metadata = jsonb_deep_merge(COALESCE(metadata, '{{}}'::jsonb), $2::jsonb)
|
||||
WHERE chunk_id = $3 AND file_uuid = $4"#,
|
||||
table
|
||||
))
|
||||
@@ -743,7 +743,7 @@ pub async fn run_5w1h_agent(db: &PostgresDb, file_uuid: &str) -> anyhow::Result<
|
||||
|
||||
// Auto-vectorize sentences with EmbeddingGemma (768D)
|
||||
tracing::info!("[5W1H] Starting vectorize for sentence chunks...");
|
||||
let embedder = Embedder::new("embeddinggemma-300M-Q8_0.gguf".to_string());
|
||||
let embedder = Embedder::new("embeddinggemma-300m".to_string());
|
||||
let qdrant = QdrantDb::new();
|
||||
qdrant.init_collection(768).await?;
|
||||
|
||||
|
||||
@@ -388,10 +388,18 @@ async fn health_detailed(State(state): State<AppState>) -> Json<DetailedHealthRe
|
||||
let directory_exists = identities_root.is_dir();
|
||||
let files_count = crate::core::identity::storage::count_identity_files();
|
||||
let index_ok = crate::core::identity::storage::read_index().is_ok();
|
||||
let db_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM identities")
|
||||
let id_cnt: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM identities")
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let st_cnt: i64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COUNT(*) FROM {} WHERE file_uuid IS NOT NULL",
|
||||
crate::core::db::schema::table_name("strangers")
|
||||
))
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let db_count = id_cnt + st_cnt;
|
||||
IdentityHealth {
|
||||
directory_exists,
|
||||
files_count,
|
||||
|
||||
@@ -220,8 +220,8 @@ async fn list_identities(
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let auto_identities: i64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COUNT(*) FROM {} WHERE source = 'auto'",
|
||||
identities_table
|
||||
"SELECT COUNT(*) FROM {} WHERE file_uuid IS NOT NULL",
|
||||
crate::core::db::schema::table_name("strangers")
|
||||
))
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
@@ -258,7 +258,7 @@ pub struct FaceCandidate {
|
||||
pub id: i32,
|
||||
pub face_id: Option<String>,
|
||||
pub file_uuid: String,
|
||||
pub frame_number: i32,
|
||||
pub frame_number: i64,
|
||||
pub confidence: f32,
|
||||
pub bbox: Option<serde_json::Value>,
|
||||
pub attributes: Option<serde_json::Value>,
|
||||
@@ -352,7 +352,7 @@ async fn list_face_candidates(
|
||||
|
||||
let rows = if let Some(file_uuid) = &query.file_uuid {
|
||||
let sql = format!(
|
||||
"SELECT id, face_id, file_uuid, frame_number::int, confidence::float4,
|
||||
"SELECT id, face_id, file_uuid, frame_number::bigint, confidence::float4,
|
||||
jsonb_build_object('x', x, 'y', y, 'width', width, 'height', height) as bbox,
|
||||
NULL::jsonb as attributes
|
||||
FROM {}
|
||||
@@ -367,7 +367,7 @@ async fn list_face_candidates(
|
||||
i32,
|
||||
Option<String>,
|
||||
String,
|
||||
i32,
|
||||
i64,
|
||||
f32,
|
||||
Option<serde_json::Value>,
|
||||
Option<serde_json::Value>,
|
||||
@@ -390,7 +390,7 @@ async fn list_face_candidates(
|
||||
}
|
||||
} else {
|
||||
let sql = format!(
|
||||
"SELECT id, face_id, file_uuid, frame_number::int, confidence::float4,
|
||||
"SELECT id, face_id, file_uuid, frame_number::bigint, confidence::float4,
|
||||
jsonb_build_object('x', x, 'y', y, 'width', width, 'height', height) as bbox,
|
||||
NULL::jsonb as attributes
|
||||
FROM {}
|
||||
@@ -405,7 +405,7 @@ async fn list_face_candidates(
|
||||
i32,
|
||||
Option<String>,
|
||||
String,
|
||||
i32,
|
||||
i64,
|
||||
f32,
|
||||
Option<serde_json::Value>,
|
||||
Option<serde_json::Value>,
|
||||
|
||||
@@ -640,8 +640,9 @@ async fn match_faces_iterative(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::
|
||||
);
|
||||
|
||||
// Step 2: 載入所有 face_detections(含 frame_number),按 trace_id 分組
|
||||
// frame_number is BIGINT (i64) in database
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
let fd_rows = sqlx::query_as::<_, (i32, i32, Vec<f32>)>(&format!(
|
||||
let fd_rows = sqlx::query_as::<_, (i32, i64, Vec<f32>)>(&format!(
|
||||
"SELECT trace_id, frame_number, embedding FROM {} \
|
||||
WHERE file_uuid=$1 AND trace_id IS NOT NULL AND embedding IS NOT NULL \
|
||||
ORDER BY trace_id, frame_number",
|
||||
@@ -658,7 +659,7 @@ async fn match_faces_iterative(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::
|
||||
|
||||
// 分組:trace_id → (frame_number, embedding)
|
||||
use std::collections::HashMap;
|
||||
let mut trace_faces_raw: HashMap<i32, Vec<(i32, Vec<f32>)>> = HashMap::new();
|
||||
let mut trace_faces_raw: HashMap<i32, Vec<(i64, Vec<f32>)>> = HashMap::new();
|
||||
for (tid, frame, emb) in &fd_rows {
|
||||
trace_faces_raw
|
||||
.entry(*tid)
|
||||
@@ -723,6 +724,7 @@ async fn match_faces_iterative(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::
|
||||
|
||||
// Step 5: 寫入 DB — Round 1 結果先存
|
||||
let identities_table = schema::table_name("identities");
|
||||
let strangers_table = schema::table_name("strangers");
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
let mut updated = 0usize;
|
||||
for (tid, name) in &matched {
|
||||
@@ -805,13 +807,28 @@ async fn match_faces_iterative(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: 未匹配的 trace 設 stranger_id = trace_id
|
||||
// trace_id 在同一個 file 內是 sequential integer,直接複用為 stranger_id
|
||||
// Step 6: 未匹配的 trace 設 stranger_id = strangers.id (FK)
|
||||
// First: ensure strangers records exist
|
||||
let _ = sqlx::query(&format!(
|
||||
"INSERT INTO {} (file_uuid, trace_id) \
|
||||
SELECT $1, fd.trace_id FROM {} fd \
|
||||
WHERE fd.file_uuid = $1 AND fd.trace_id IS NOT NULL \
|
||||
AND fd.identity_id IS NULL \
|
||||
ON CONFLICT (file_uuid, trace_id) DO NOTHING",
|
||||
strangers_table, fd_table
|
||||
))
|
||||
.bind(file_uuid)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Then: update face_detections.stranger_id = strangers.id
|
||||
let stranger_update = sqlx::query(&format!(
|
||||
"UPDATE {} SET stranger_id = trace_id \
|
||||
WHERE file_uuid = $1 AND trace_id IS NOT NULL AND identity_id IS NULL \
|
||||
AND (stranger_id IS NULL OR stranger_id != trace_id)",
|
||||
fd_table
|
||||
"UPDATE {} fd SET stranger_id = s.id \
|
||||
FROM {} s \
|
||||
WHERE s.file_uuid = fd.file_uuid AND s.trace_id = fd.trace_id \
|
||||
AND fd.file_uuid = $1 AND fd.identity_id IS NULL \
|
||||
AND fd.trace_id IS NOT NULL AND fd.stranger_id IS NULL",
|
||||
fd_table, strangers_table
|
||||
))
|
||||
.bind(file_uuid)
|
||||
.execute(pool)
|
||||
@@ -971,16 +988,30 @@ pub async fn bind_speakers(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::Resu
|
||||
|
||||
let ib_table = schema::table_name("identity_bindings");
|
||||
let _ = sqlx::query(
|
||||
&format!("INSERT INTO {} (identity_id, identity_type, identity_value, confidence, metadata) \
|
||||
VALUES ($1, 'speaker', $2, $3, $4::jsonb) \
|
||||
ON CONFLICT (identity_id, identity_type, identity_value) DO UPDATE SET confidence = EXCLUDED.confidence, metadata = EXCLUDED.metadata", ib_table)
|
||||
&format!("INSERT INTO {} (identity_id, identity_type, identity_value, file_uuid, confidence, metadata) \
|
||||
VALUES ($1, 'speaker', $2, $3, $4, $5::jsonb) \
|
||||
ON CONFLICT (identity_id, identity_type, identity_value, file_uuid) \
|
||||
DO UPDATE SET confidence = EXCLUDED.confidence, metadata = EXCLUDED.metadata", ib_table)
|
||||
)
|
||||
.bind(identity_id)
|
||||
.bind(&best_speaker)
|
||||
.bind(file_uuid)
|
||||
.bind(overlap_ratio)
|
||||
.bind(&metadata)
|
||||
.execute(pool).await;
|
||||
|
||||
// Also update speaker_detections with the identity_id
|
||||
let sd_table = schema::table_name("speaker_detections");
|
||||
let _ = sqlx::query(
|
||||
&format!("UPDATE {} SET identity_id = $1, confidence = $2 \
|
||||
WHERE file_uuid = $3 AND speaker_id = $4 AND identity_id IS NULL", sd_table)
|
||||
)
|
||||
.bind(identity_id)
|
||||
.bind(overlap_ratio)
|
||||
.bind(file_uuid)
|
||||
.bind(&best_speaker)
|
||||
.execute(pool).await;
|
||||
|
||||
bindings += 1;
|
||||
}
|
||||
}
|
||||
@@ -1028,31 +1059,31 @@ pub async fn run_identity_agent(db: &PostgresDb, file_uuid: &str) -> anyhow::Res
|
||||
let speakers = extract_speakers_from_asrx_data(&asrx_data);
|
||||
let identities = analyze_person_speaker_overlap(&persons, &speakers);
|
||||
|
||||
for (idx, id_result) in identities.iter().enumerate() {
|
||||
let identity_name = format!("stranger_{}", idx);
|
||||
let _ = identities.len();
|
||||
if !identities.is_empty() {
|
||||
let metadata = serde_json::json!({
|
||||
"source": "identity_agent",
|
||||
"trace_ids": id_result.person_ids,
|
||||
"speaker_ids": id_result.speaker_ids,
|
||||
"confidence": id_result.confidence,
|
||||
"speaker_ids": identities[0].speaker_ids,
|
||||
"confidence": identities[0].confidence,
|
||||
"evidence": {
|
||||
"speaker_overlap": id_result.evidence.speaker_overlap,
|
||||
"frame_ratio": id_result.evidence.frame_ratio,
|
||||
"speaker_overlap": identities[0].evidence.speaker_overlap,
|
||||
"frame_ratio": identities[0].evidence.frame_ratio,
|
||||
},
|
||||
"reasoning": id_result.reasoning,
|
||||
"reasoning": identities[0].reasoning,
|
||||
});
|
||||
let _ = sqlx::query(
|
||||
&format!("INSERT INTO {} (name, identity_type, source, metadata, status) VALUES ($1, 'people', 'auto', $2::jsonb, 'pending') ON CONFLICT DO NOTHING", schema::table_name("identities"))
|
||||
)
|
||||
.bind(&identity_name)
|
||||
let _ = sqlx::query(&format!(
|
||||
"INSERT INTO {} (file_uuid, trace_id, metadata) \
|
||||
VALUES ($1, NULL, $2::jsonb) ON CONFLICT DO NOTHING",
|
||||
schema::table_name("strangers")
|
||||
))
|
||||
.bind(file_uuid)
|
||||
.bind(&metadata)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
let _created = identities.len();
|
||||
tracing::info!(
|
||||
"[IdentityAgent] Created {} auto identities from face_clustered for {}",
|
||||
_created,
|
||||
"[IdentityAgent] Analyzed {} face clusters from face_clustered for {}",
|
||||
identities.len(),
|
||||
file_uuid
|
||||
);
|
||||
} else {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,10 @@ pub fn bbox_routes() -> Router<crate::api::types::AppState> {
|
||||
"/api/v1/file/:file_uuid/trace/:trace_id/video",
|
||||
get(trace_video),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/file/:file_uuid/stranger/:stranger_id/video",
|
||||
get(stranger_video),
|
||||
)
|
||||
.route("/api/v1/file/:file_uuid/video", get(stream_video))
|
||||
.route("/api/v1/file/:file_uuid/thumbnail", get(face_thumbnail))
|
||||
.route("/api/v1/file/:file_uuid/clip", get(video_clip))
|
||||
@@ -210,8 +214,9 @@ async fn bbox_overlay_video(
|
||||
let start_sec = start_f as f64 / fps;
|
||||
|
||||
// Get face bboxes
|
||||
// frame_number is BIGINT (i64) in database
|
||||
let face_table = schema::table_name("face_detections");
|
||||
let rows: Vec<(i32, i32, i32, i32, i32, Option<i32>, Option<String>)> = sqlx::query_as(
|
||||
let rows: Vec<(i64, i32, i32, i32, i32, Option<i32>, Option<String>)> = sqlx::query_as(
|
||||
&format!("SELECT frame_number, x, y, width, height, trace_id, face_id FROM {} WHERE file_uuid = $1 AND frame_number BETWEEN $2 AND $3 ORDER BY frame_number", face_table)
|
||||
)
|
||||
.bind(face_fuid).bind(start_f).bind(end_f)
|
||||
@@ -222,7 +227,7 @@ async fn bbox_overlay_video(
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for (frame, x, y, w, h, trace_id, _) in &rows {
|
||||
let text = format!("t{}", trace_id.unwrap_or(0));
|
||||
let offset = frame - start_f;
|
||||
let offset = (*frame as i32) - start_f;
|
||||
parts.push(format!(
|
||||
"drawbox=x={}:y={}:w={}:h={}:color=red@0.8:thickness=4:enable='eq(n,{})'",
|
||||
x, y, w, h, offset
|
||||
@@ -300,6 +305,15 @@ async fn trace_video(
|
||||
State(state): State<crate::api::types::AppState>,
|
||||
Path((file_uuid, trace_id)): Path<(String, i32)>,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
trace_video_inner(&state, &file_uuid, trace_id, ¶ms).await
|
||||
}
|
||||
|
||||
async fn trace_video_inner(
|
||||
state: &crate::api::types::AppState,
|
||||
file_uuid: &str,
|
||||
trace_id: i32,
|
||||
params: &std::collections::HashMap<String, String>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
use axum::http::header;
|
||||
|
||||
@@ -317,8 +331,9 @@ async fn trace_video(
|
||||
let (video_path, fps, _width, _height) = row.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
// Query face detections to find frame range for target trace
|
||||
// frame_number is BIGINT (i64) in database
|
||||
let face_table = schema::table_name("face_detections");
|
||||
let rows: Vec<(i32, i32, i32, i32, i32)> = sqlx::query_as(&format!(
|
||||
let rows: Vec<(i64, i32, i32, i32, i32)> = sqlx::query_as(&format!(
|
||||
"SELECT frame_number, x, y, width, height FROM {} WHERE file_uuid = $1 AND trace_id = $2 ORDER BY frame_number",
|
||||
face_table
|
||||
))
|
||||
@@ -371,11 +386,12 @@ async fn trace_video(
|
||||
|
||||
// === DEBUG MODE: text overlay, list all traces in frame range ===
|
||||
let start_fn = (start_sec * fps) as i32;
|
||||
let end_fn = ((start_sec + duration) * fps) as i32;
|
||||
let end_fn = ((start_sec + duration) * fps) as i64;
|
||||
|
||||
// Query all traces with identity names and bbox positions in the visible frame range
|
||||
// frame_number is BIGINT (i64) in database
|
||||
let identities_table = schema::table_name("identities");
|
||||
let all_rows: Vec<(i32, i32, i32, i32, i32, i32, Option<String>)> = sqlx::query_as(&format!(
|
||||
let all_rows: Vec<(i32, i64, i32, i32, i32, i32, Option<String>)> = sqlx::query_as(&format!(
|
||||
"SELECT fd.trace_id, fd.frame_number, fd.x, fd.y, fd.width, fd.height, i.name \
|
||||
FROM {} fd \
|
||||
LEFT JOIN {} i ON fd.identity_id = i.id \
|
||||
@@ -391,9 +407,10 @@ async fn trace_video(
|
||||
.unwrap_or_default();
|
||||
|
||||
// Group frames by trace_id, compute start_frame per trace; collect bbox per frame
|
||||
let mut trace_frames: HashMap<i32, Vec<i32>> = HashMap::new();
|
||||
// frame_number is i64 (BIGINT), so HashMaps need i64 for frame values
|
||||
let mut trace_frames: HashMap<i32, Vec<i64>> = HashMap::new();
|
||||
let mut trace_identity: HashMap<i32, String> = HashMap::new();
|
||||
let mut bbox_per_frame: HashMap<(i32, i32), (i32, i32, i32, i32)> = HashMap::new(); // (tid, fn) -> (x, y, w, h)
|
||||
let mut bbox_per_frame: HashMap<(i32, i64), (i32, i32, i32, i32)> = HashMap::new(); // (tid, fn) -> (x, y, w, h)
|
||||
for (tid, fn_, x, y, w, h, name_opt) in &all_rows {
|
||||
trace_frames.entry(*tid).or_default().push(*fn_);
|
||||
bbox_per_frame.insert((*tid, *fn_), (*x, *y, *w, *h));
|
||||
@@ -417,7 +434,7 @@ async fn trace_video(
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
|
||||
// Sort traces for consistent ordering
|
||||
let mut sorted_traces: Vec<(i32, &Vec<i32>)> =
|
||||
let mut sorted_traces: Vec<(i32, &Vec<i64>)> =
|
||||
trace_frames.iter().map(|(k, v)| (*k, v)).collect();
|
||||
sorted_traces.sort_by_key(|(tid, _)| *tid);
|
||||
|
||||
@@ -695,6 +712,7 @@ struct ThumbQuery {
|
||||
y: Option<i32>,
|
||||
w: Option<i32>,
|
||||
h: Option<i32>,
|
||||
trace_id: Option<i32>,
|
||||
}
|
||||
|
||||
async fn face_thumbnail(
|
||||
@@ -717,15 +735,70 @@ async fn face_thumbnail(
|
||||
}
|
||||
};
|
||||
|
||||
let row: Option<(String,)> = sqlx::query_as(&format!(
|
||||
"SELECT file_path FROM {} WHERE file_uuid = $1",
|
||||
// Step 1: Check for pre-stored face crop if trace_id is provided
|
||||
if let Some(trace_id) = q.trace_id {
|
||||
let output_dir = crate::core::config::OUTPUT_DIR.as_str();
|
||||
let cached_path = std::path::PathBuf::from(output_dir)
|
||||
.join(".faces")
|
||||
.join(&file_uuid)
|
||||
.join(trace_id.to_string())
|
||||
.join(format!("{}.jpg", frame));
|
||||
|
||||
if cached_path.exists() {
|
||||
tracing::debug!("[thumbnail] Using cached face crop: {}", cached_path.display());
|
||||
let bytes = tokio::fs::read(&cached_path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!("[thumbnail] Failed to read cached file: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// Validate cached JPEG
|
||||
crate::core::thumbnail::validator::validate_jpeg(&bytes).map_err(|e| {
|
||||
tracing::warn!("[thumbnail] Cached JPEG validation failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "image/jpeg")
|
||||
.header(header::CACHE_CONTROL, "public, max-age=86400")
|
||||
.body(Body::from(bytes))
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
// Cached file not found, fallback to ffmpeg
|
||||
tracing::debug!("[thumbnail] Cached file not found, falling back to ffmpeg");
|
||||
}
|
||||
|
||||
// Step 2: Fallback to ffmpeg on-demand extraction
|
||||
let row: Option<(String, Option<i64>, Option<i32>, Option<i32>)> = sqlx::query_as(&format!(
|
||||
"SELECT file_path, total_frames, width, height FROM {} WHERE file_uuid = $1",
|
||||
videos_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let (file_path,) = row.ok_or(StatusCode::NOT_FOUND)?;
|
||||
let (file_path, total_frames, video_width, video_height) = row.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
if let Some(total) = total_frames {
|
||||
if total > 0 {
|
||||
crate::core::thumbnail::validator::validate_frame(frame, total).map_err(|e| {
|
||||
tracing::warn!("[thumbnail] Frame validation failed: {}", e);
|
||||
StatusCode::BAD_REQUEST
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(x), Some(y), Some(w), Some(h)) = (q.x, q.y, q.w, q.h) {
|
||||
if let (Some(vw), Some(vh)) = (video_width, video_height) {
|
||||
crate::core::thumbnail::validator::validate_crop(x, y, w, h, vw, vh).map_err(|e| {
|
||||
tracing::warn!("[thumbnail] Crop validation failed: {}", e);
|
||||
StatusCode::BAD_REQUEST
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
let select = format!("select=eq(n\\,{})", frame);
|
||||
let vf = if let (Some(x), Some(y), Some(w), Some(h)) = (q.x, q.y, q.w, q.h) {
|
||||
@@ -755,6 +828,11 @@ async fn face_thumbnail(
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
crate::core::thumbnail::validator::validate_jpeg(&output.stdout).map_err(|e| {
|
||||
tracing::warn!("[thumbnail] JPEG validation failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "image/jpeg")
|
||||
@@ -849,3 +927,127 @@ async fn video_clip(
|
||||
.body(Body::from(output.stdout))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
async fn stranger_video(
|
||||
State(state): State<crate::api::types::AppState>,
|
||||
Path((file_uuid, stranger_id)): Path<(String, i32)>,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
stranger_video_inner(&state, &file_uuid, stranger_id, ¶ms).await
|
||||
}
|
||||
|
||||
async fn stranger_video_inner(
|
||||
state: &crate::api::types::AppState,
|
||||
file_uuid: &str,
|
||||
stranger_id: i32,
|
||||
params: &std::collections::HashMap<String, String>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
use axum::http::header;
|
||||
use uuid::Uuid;
|
||||
|
||||
tracing::info!("[stranger_video] Starting for file={}, stranger={}", file_uuid, stranger_id);
|
||||
|
||||
let (mode, audio) = parse_video_params(¶ms);
|
||||
|
||||
let videos_table = schema::table_name("videos");
|
||||
tracing::debug!("[stranger_video] videos_table: {}", videos_table);
|
||||
|
||||
let row: Option<(String, f64, i32, i32)> = sqlx::query_as(&format!(
|
||||
"SELECT file_path, COALESCE(fps, 24.0), COALESCE(width, 0), COALESCE(height, 0) FROM {} WHERE file_uuid = $1",
|
||||
videos_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("[stranger_video] Video query error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let (video_path, fps, _width, _height) = row.ok_or_else(|| {
|
||||
tracing::error!("[stranger_video] Video not found for uuid={}", file_uuid);
|
||||
StatusCode::NOT_FOUND
|
||||
})?;
|
||||
|
||||
tracing::info!("[stranger_video] Found video: path={}, fps={}", video_path, fps);
|
||||
|
||||
// Query face detections by stranger_id directly
|
||||
let face_table = schema::table_name("face_detections");
|
||||
tracing::debug!("[stranger_video] face_table: {}", face_table);
|
||||
|
||||
// frame_number is BIGINT (i64) in database
|
||||
let rows: Vec<(i64, i32, i32, i32, i32)> = sqlx::query_as(&format!(
|
||||
"SELECT frame_number, x, y, width, height FROM {} WHERE file_uuid = $1 AND stranger_id = $2 ORDER BY frame_number",
|
||||
face_table
|
||||
))
|
||||
.bind(&file_uuid).bind(stranger_id)
|
||||
.fetch_all(state.db.pool()).await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("[stranger_video] Face query error: {}", e);
|
||||
vec![]
|
||||
});
|
||||
|
||||
tracing::info!("[stranger_video] Found {} faces", rows.len());
|
||||
|
||||
if rows.is_empty() {
|
||||
tracing::error!("[stranger_video] No faces found for stranger_id={}", stranger_id);
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
let first_frame = rows[0].0;
|
||||
let last_frame = rows[rows.len() - 1].0;
|
||||
let start_sec = first_frame as f64 / fps;
|
||||
let padding = params
|
||||
.get("padding")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(2.0);
|
||||
let duration = (last_frame - first_frame) as f64 / fps + padding * 2.0;
|
||||
let seek = (start_sec - padding).max(0.0);
|
||||
|
||||
tracing::info!("[stranger_video] Frame range: {} - {}, time: {:.2}s - {:.2}s",
|
||||
first_frame, last_frame, seek, seek + duration);
|
||||
|
||||
// Only support normal mode for stranger video
|
||||
let tmp = std::env::temp_dir().join(format!("stranger_{}.mp4", Uuid::new_v4()));
|
||||
let tmp_str = tmp.to_str().unwrap_or("").to_string();
|
||||
let sk = seek.to_string();
|
||||
let du = duration.to_string();
|
||||
let mut cmd_args = vec!["-ss", &sk, "-i", &video_path, "-t", &du, "-c", "copy"];
|
||||
if audio == "off" {
|
||||
cmd_args.push("-an");
|
||||
}
|
||||
cmd_args.extend_from_slice(&["-y", &tmp_str]);
|
||||
|
||||
tracing::debug!("[stranger_video] ffmpeg args: {:?}", cmd_args);
|
||||
|
||||
let result = ffmpeg_cmd()
|
||||
.args(&cmd_args)
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
tracing::error!("[stranger_video] ffmpeg spawn error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
if !result.status.success() {
|
||||
tracing::error!("[stranger_video] ffmpeg failed: {}", String::from_utf8_lossy(&result.stderr));
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
tracing::info!("[stranger_video] ffmpeg success, output size: {} bytes", result.stdout.len());
|
||||
|
||||
let data = tokio::fs::read(&tmp)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("[stranger_video] Read output error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
|
||||
tracing::info!("[stranger_video] Returning video, size: {} bytes", data.len());
|
||||
|
||||
Ok(Response::builder()
|
||||
.header(header::CONTENT_TYPE, "video/mp4")
|
||||
.header(header::CONTENT_LENGTH, data.len())
|
||||
.body(Body::from(data))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ pub mod auth;
|
||||
pub mod docs;
|
||||
pub mod files;
|
||||
pub mod five_w1h_agent_api;
|
||||
pub mod processing;
|
||||
pub mod health;
|
||||
pub mod identities;
|
||||
pub mod identity_agent_api;
|
||||
@@ -12,6 +11,7 @@ pub mod identity_api;
|
||||
pub mod identity_binding;
|
||||
pub mod media_api;
|
||||
pub mod middleware;
|
||||
pub mod processing;
|
||||
pub mod scan;
|
||||
pub mod search;
|
||||
pub mod server;
|
||||
@@ -19,7 +19,5 @@ pub mod tmdb_api;
|
||||
pub mod trace_agent_api;
|
||||
pub mod types;
|
||||
pub mod universal_search;
|
||||
pub mod visual_chunk_search;
|
||||
pub mod visual_search;
|
||||
|
||||
pub use server::start_server;
|
||||
|
||||
@@ -233,50 +233,54 @@ async fn trigger_processing(
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let processors_to_run: Vec<&str> = if let Some(procs) = &req.processors {
|
||||
// 檢查 job 是否存在,不存在則 INSERT(state machine entry)
|
||||
let existing_id: Option<i32> = sqlx::query_scalar(&format!(
|
||||
"SELECT id FROM {monitor_jobs_table} WHERE uuid = $1"
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if existing_id.is_none() {
|
||||
state
|
||||
.db
|
||||
.create_monitor_job(&file_uuid, Some(&file_path))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
"[TRIGGER] Failed to create monitor job for {}: {}",
|
||||
file_uuid,
|
||||
e
|
||||
);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
}
|
||||
|
||||
// UPDATE processors + reset 狀態讓 worker 可 pickup
|
||||
let procs_db: Vec<String> = procs.iter().map(|s| s.to_string()).collect();
|
||||
sqlx::query(&format!(
|
||||
"UPDATE {monitor_jobs_table} SET processors = $1::text[], status = 'pending' WHERE uuid = $2"
|
||||
))
|
||||
.bind(&procs_db)
|
||||
.bind(&file_uuid)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("[TRIGGER] Failed to update monitor job for {}: {}", file_uuid, e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
procs.iter().map(|s| s.as_str()).collect()
|
||||
let processors_to_run: Vec<String> = if let Some(procs) = &req.processors {
|
||||
procs.iter().map(|s| s.to_string()).collect()
|
||||
} else {
|
||||
vec![]
|
||||
crate::core::db::ProcessorType::all()
|
||||
.iter()
|
||||
.map(|p| p.as_str().to_string())
|
||||
.collect()
|
||||
};
|
||||
|
||||
// 確保 monitor_job 存在
|
||||
let existing_id: Option<i32> = sqlx::query_scalar(&format!(
|
||||
"SELECT id FROM {monitor_jobs_table} WHERE uuid = $1"
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if existing_id.is_none() {
|
||||
state
|
||||
.db
|
||||
.create_monitor_job(&file_uuid, Some(&file_path))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
"[TRIGGER] Failed to create monitor job for {}: {}",
|
||||
file_uuid,
|
||||
e
|
||||
);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
}
|
||||
|
||||
// UPDATE processors + reset 狀態讓 worker 可 pickup
|
||||
sqlx::query(&format!(
|
||||
"UPDATE {monitor_jobs_table} SET processors = $1::text[], status = 'pending' WHERE uuid = $2"
|
||||
))
|
||||
.bind(&processors_to_run)
|
||||
.bind(&file_uuid)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("[TRIGGER] Failed to update monitor job for {}: {}", file_uuid, e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let processors_to_run_refs: Vec<&str> = processors_to_run.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let notification = serde_json::json!({
|
||||
"action": "process",
|
||||
"file_uuid": file_uuid,
|
||||
@@ -285,7 +289,7 @@ async fn trigger_processing(
|
||||
"file_type": file_type,
|
||||
"content_hash": content_hash,
|
||||
"output_dir": output_dir,
|
||||
"processors": processors_to_run,
|
||||
"processors": processors_to_run_refs,
|
||||
});
|
||||
|
||||
let notification_key = format!("{}notifications", REDIS_KEY_PREFIX.as_str());
|
||||
|
||||
@@ -414,8 +414,6 @@ async fn get_ingestion_status(
|
||||
"SELECT COUNT(*) FROM {} WHERE file_uuid = '{file_uuid}'",
|
||||
schema::table_name("tkg_edges")
|
||||
));
|
||||
let scene_5w1h = count_sql!(&format!("SELECT COUNT(*) FROM {chunk} WHERE file_uuid = '{file_uuid}' AND chunk_type = 'cut' AND summary_text IS NOT NULL AND summary_text != ''"));
|
||||
|
||||
let related_identities: Vec<IdentityRef> =
|
||||
match sqlx::query_as::<_, (String, String)>(&format!(
|
||||
"SELECT DISTINCT i.uuid::text, i.name FROM {identities} i \
|
||||
@@ -491,11 +489,6 @@ async fn get_ingestion_status(
|
||||
Some(format!("{identity_count} identities matched"))
|
||||
),
|
||||
step!("scene_metadata", scene_meta_ok, None),
|
||||
step!(
|
||||
"5w1h",
|
||||
scene_5w1h > 0,
|
||||
Some(format!("{scene_5w1h} scenes with 5W1H"))
|
||||
),
|
||||
];
|
||||
|
||||
Ok(Json(IngestionStatusResponse {
|
||||
|
||||
@@ -5,7 +5,7 @@ use tokio::time::timeout;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
use crate::core::cache::{MongoCache, RedisCache};
|
||||
use crate::core::db::{Database, PostgresDb};
|
||||
use crate::core::db::{Database, PostgresDb, QdrantDb};
|
||||
use crate::Embedder;
|
||||
|
||||
use super::agent_api;
|
||||
@@ -14,7 +14,6 @@ use super::auth;
|
||||
use super::docs;
|
||||
use super::files;
|
||||
use super::five_w1h_agent_api;
|
||||
use super::processing;
|
||||
use super::health;
|
||||
use super::identities;
|
||||
use super::identity_agent_api;
|
||||
@@ -22,18 +21,18 @@ use super::identity_api;
|
||||
use super::identity_binding;
|
||||
use super::media_api;
|
||||
use super::middleware::unified_auth;
|
||||
use super::processing;
|
||||
use super::scan;
|
||||
use super::search::search_routes;
|
||||
use super::tmdb_api;
|
||||
use super::trace_agent_api;
|
||||
use super::types::AppState;
|
||||
use super::universal_search::universal_search_routes;
|
||||
use super::visual_search;
|
||||
|
||||
pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> {
|
||||
health::init_server_state(host, port);
|
||||
|
||||
let embedder = std::sync::Arc::new(Embedder::new("nomic-embed-text-v2-moe:latest".to_string()));
|
||||
let embedder = std::sync::Arc::new(Embedder::new("embeddinggemma-300m".to_string()));
|
||||
|
||||
// ── ⚠️ WARNING: DO NOT move MongoCache::init() back to critical path ──
|
||||
//
|
||||
@@ -57,6 +56,9 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> {
|
||||
let redis_cache = RedisCache::new()?;
|
||||
let db = PostgresDb::init().await?;
|
||||
|
||||
// Run migrations (create identity_history table if not exists)
|
||||
PostgresDb::run_migrations(db.pool()).await?;
|
||||
|
||||
let schema_health = health::check_schema_migrations(db.pool()).await;
|
||||
if schema_health.ok {
|
||||
tracing::info!(
|
||||
@@ -89,8 +91,10 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> {
|
||||
let db = std::sync::Arc::new(db);
|
||||
let api_state = super::middleware::ApiState { db: db.clone() };
|
||||
|
||||
let qdrant = std::sync::Arc::new(QdrantDb::new());
|
||||
let state = AppState {
|
||||
db,
|
||||
qdrant,
|
||||
embedder,
|
||||
embedder_model: "nomic-embed-text-v2-moe:latest".to_string(),
|
||||
mongo_cache,
|
||||
@@ -129,7 +133,6 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> {
|
||||
.merge(auth::auth_routes())
|
||||
.merge(health::health_routes())
|
||||
.merge(docs::doc_routes())
|
||||
.merge(visual_search::visual_search_routes())
|
||||
.merge(protected_routes)
|
||||
.layer(cors)
|
||||
.with_state(state);
|
||||
|
||||
@@ -25,14 +25,19 @@ pub fn trace_agent_routes() -> Router<crate::api::types::AppState> {
|
||||
"/api/v1/file/:file_uuid/trace/:trace_id/thumbnail",
|
||||
get(get_trace_thumbnail),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/file/:file_uuid/stranger/:stranger_id/representative-face",
|
||||
get(get_stranger_representative_face),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/file/:file_uuid/stranger/:stranger_id/thumbnail",
|
||||
get(get_stranger_thumbnail),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/file/:file_uuid/identities/:identity_uuid_a/co-occur-with/:identity_uuid_b",
|
||||
get(get_cooccurrence),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/file/:file_uuid/tkg/rebuild",
|
||||
post(rebuild_tkg),
|
||||
)
|
||||
.route("/api/v1/file/:file_uuid/tkg/rebuild", post(rebuild_tkg))
|
||||
.route(
|
||||
"/api/v1/file/:file_uuid/representative-frame",
|
||||
get(get_representative_frame),
|
||||
@@ -54,8 +59,8 @@ struct TracesRequest {
|
||||
struct TraceInfo {
|
||||
trace_id: i32,
|
||||
face_count: i64,
|
||||
start_frame: i32,
|
||||
end_frame: i32,
|
||||
start_frame: i64,
|
||||
end_frame: i64,
|
||||
start_time: f64,
|
||||
end_time: f64,
|
||||
duration_sec: f64,
|
||||
@@ -110,8 +115,8 @@ async fn list_traces_sorted(
|
||||
"SELECT tt.*, fd.id AS sample_face_id FROM (
|
||||
SELECT trace_id::int AS trace_id,
|
||||
COUNT(*) AS face_count,
|
||||
MIN(frame_number)::int AS start_frame,
|
||||
MAX(frame_number)::int AS end_frame,
|
||||
MIN(frame_number)::bigint AS start_frame,
|
||||
MAX(frame_number)::bigint AS end_frame,
|
||||
(MAX(frame_number) - MIN(frame_number))::float8 AS duration_sec,
|
||||
AVG(confidence)::float8 AS avg_confidence
|
||||
FROM {}
|
||||
@@ -132,7 +137,7 @@ async fn list_traces_sorted(
|
||||
crate::core::db::schema::table_name("face_detections"),
|
||||
);
|
||||
|
||||
let rows: Vec<(i32, i64, i32, i32, f64, f64, Option<i32>)> = sqlx::query_as(&query)
|
||||
let rows: Vec<(i32, i64, i64, i64, f64, f64, Option<i32>)> = sqlx::query_as(&query)
|
||||
.bind(&file_uuid)
|
||||
.bind(min_faces)
|
||||
.bind(effective_limit)
|
||||
@@ -193,8 +198,8 @@ struct TraceFacesQuery {
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TraceFaceItem {
|
||||
id: i32,
|
||||
start_frame: i32,
|
||||
end_frame: i32,
|
||||
start_frame: i64,
|
||||
end_frame: i64,
|
||||
start_time: f64,
|
||||
end_time: f64,
|
||||
x: Option<i32>,
|
||||
@@ -260,14 +265,14 @@ async fn list_trace_faces(
|
||||
|
||||
let rows: Vec<(
|
||||
i32,
|
||||
i32,
|
||||
i64,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
Option<i32>,
|
||||
f32,
|
||||
)> = sqlx::query_as(&format!(
|
||||
"SELECT id, frame_number::int, x, y, width, height, confidence::float4 \
|
||||
"SELECT id, frame_number, x, y, width, height, confidence::float4 \
|
||||
FROM {} WHERE file_uuid = $1 AND trace_id = $2 \
|
||||
ORDER BY frame_number ASC LIMIT $3 OFFSET $4",
|
||||
crate::core::db::schema::table_name("face_detections")
|
||||
@@ -405,7 +410,8 @@ where
|
||||
let video_table = schema::table_name("videos");
|
||||
|
||||
let fps: f64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COALESCE(fps, 25.0) FROM {} WHERE file_uuid = $1", video_table
|
||||
"SELECT COALESCE(fps, 25.0) FROM {} WHERE file_uuid = $1",
|
||||
video_table
|
||||
))
|
||||
.bind(file_uuid)
|
||||
.fetch_optional(pool)
|
||||
@@ -414,7 +420,8 @@ where
|
||||
.unwrap_or(25.0);
|
||||
|
||||
let face_count: (i64,) = sqlx::query_as(&format!(
|
||||
"SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND trace_id = $2", fd_table
|
||||
"SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND trace_id = $2",
|
||||
fd_table
|
||||
))
|
||||
.bind(file_uuid)
|
||||
.bind(trace_id)
|
||||
@@ -422,7 +429,15 @@ where
|
||||
.await
|
||||
.map_err(|e| err_fn(anyhow::anyhow!("{}", e)))?;
|
||||
|
||||
struct Candidate { frame: i64, x: i32, y: i32, w: i32, h: i32, conf: f64, score: f64 }
|
||||
struct Candidate {
|
||||
frame: i64,
|
||||
x: i32,
|
||||
y: i32,
|
||||
w: i32,
|
||||
h: i32,
|
||||
conf: f64,
|
||||
score: f64,
|
||||
}
|
||||
|
||||
let rows = sqlx::query_as::<_, (i64, i32, i32, i32, i32, f64)>(&format!(
|
||||
"SELECT frame_number::bigint, x, y, width, height, confidence::float8 \
|
||||
@@ -431,7 +446,8 @@ where
|
||||
ORDER BY (width::float8 * height::float8) * confidence::float8 DESC LIMIT 10",
|
||||
fd_table
|
||||
))
|
||||
.bind(file_uuid).bind(trace_id)
|
||||
.bind(file_uuid)
|
||||
.bind(trace_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| err_fn(anyhow::anyhow!("{}", e)))?;
|
||||
@@ -440,15 +456,25 @@ where
|
||||
return Err(err_fn(anyhow::anyhow!("No suitable face found")));
|
||||
}
|
||||
|
||||
let candidates: Vec<Candidate> = rows.into_iter()
|
||||
let candidates: Vec<Candidate> = rows
|
||||
.into_iter()
|
||||
.map(|(frame, x, y, w, h, conf)| {
|
||||
let score = (w as f64 * h as f64) * conf;
|
||||
Candidate { frame, x, y, w, h, conf, score }
|
||||
Candidate {
|
||||
frame,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
conf,
|
||||
score,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let video_path: String = sqlx::query_scalar(&format!(
|
||||
"SELECT file_path FROM {} WHERE file_uuid = $1", video_table
|
||||
"SELECT file_path FROM {} WHERE file_uuid = $1",
|
||||
video_table
|
||||
))
|
||||
.bind(file_uuid)
|
||||
.fetch_optional(pool)
|
||||
@@ -463,16 +489,31 @@ where
|
||||
for (i, c) in candidates.iter().enumerate() {
|
||||
let seek = c.frame as f64 / fps;
|
||||
if let Ok(output) = tokio::process::Command::new("ffmpeg")
|
||||
.args(["-ss", &format!("{:.2}", seek), "-i", &video_path,
|
||||
"-vframes", "1", "-vf", &format!("crop={}:{}:{}:{},blurdetect", c.w, c.h, c.x, c.y),
|
||||
"-f", "null", "-"])
|
||||
.output().await
|
||||
.args([
|
||||
"-ss",
|
||||
&format!("{:.2}", seek),
|
||||
"-i",
|
||||
&video_path,
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
&format!("crop={}:{}:{}:{},blurdetect", c.w, c.h, c.x, c.y),
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
for line in stderr.lines() {
|
||||
if let Some(blur_str) = line.split("blur mean: ").nth(1) {
|
||||
if let Ok(blur) = blur_str.trim().parse::<f64>() {
|
||||
if blur < best_blur { best_blur = blur; best = c.frame; best_idx = i; }
|
||||
if blur < best_blur {
|
||||
best_blur = blur;
|
||||
best = c.frame;
|
||||
best_idx = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -481,9 +522,17 @@ where
|
||||
|
||||
let chosen = &candidates[best_idx];
|
||||
Ok(RepFaceSelection {
|
||||
frame: chosen.frame, x: chosen.x, y: chosen.y, w: chosen.w, h: chosen.h,
|
||||
conf: chosen.conf, blur: best_blur, score: chosen.score,
|
||||
video_path, fps, face_count: face_count.0,
|
||||
frame: chosen.frame,
|
||||
x: chosen.x,
|
||||
y: chosen.y,
|
||||
w: chosen.w,
|
||||
h: chosen.h,
|
||||
conf: chosen.conf,
|
||||
blur: best_blur,
|
||||
score: chosen.score,
|
||||
video_path,
|
||||
fps,
|
||||
face_count: face_count.0,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -491,19 +540,36 @@ async fn get_representative_face(
|
||||
State(state): State<crate::api::types::AppState>,
|
||||
Path((file_uuid, trace_id)): Path<(String, i32)>,
|
||||
) -> Result<Json<RepFaceResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let sel = select_rep_face(state.db.pool(), &file_uuid, trace_id, |e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
||||
}).await?;
|
||||
get_representative_face_inner(&state, &file_uuid, trace_id).await
|
||||
}
|
||||
|
||||
async fn get_representative_face_inner(
|
||||
state: &crate::api::types::AppState,
|
||||
file_uuid: &str,
|
||||
trace_id: i32,
|
||||
) -> Result<Json<RepFaceResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let sel = select_rep_face(state.db.pool(), file_uuid, trace_id, |e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(Json(RepFaceResponse {
|
||||
success: true,
|
||||
file_uuid,
|
||||
file_uuid: file_uuid.to_string(),
|
||||
trace_id,
|
||||
face_count: sel.face_count,
|
||||
representative: RepFaceResult {
|
||||
frame_number: sel.frame,
|
||||
timestamp_secs: sel.frame as f64 / sel.fps,
|
||||
bbox: RepFaceBbox { x: sel.x, y: sel.y, width: sel.w, height: sel.h },
|
||||
bbox: RepFaceBbox {
|
||||
x: sel.x,
|
||||
y: sel.y,
|
||||
width: sel.w,
|
||||
height: sel.h,
|
||||
},
|
||||
confidence: sel.conf,
|
||||
quality_score: sel.score,
|
||||
blur_score: sel.blur,
|
||||
@@ -515,34 +581,118 @@ async fn get_trace_thumbnail(
|
||||
State(state): State<crate::api::types::AppState>,
|
||||
Path((file_uuid, trace_id)): Path<(String, i32)>,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
get_trace_thumbnail_inner(&state, &file_uuid, trace_id).await
|
||||
}
|
||||
|
||||
async fn get_trace_thumbnail_inner(
|
||||
state: &crate::api::types::AppState,
|
||||
file_uuid: &str,
|
||||
trace_id: i32,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
// Step 1: Check for pre-stored face crops in .faces/{file_uuid}/{trace_id}/
|
||||
// For trace_id=0 (untracked/stranger), check unbound directory instead
|
||||
let output_dir = crate::core::config::OUTPUT_DIR.as_str();
|
||||
let trace_id_str = trace_id.to_string();
|
||||
let trace_dir_name = if trace_id == 0 { "unbound" } else { &trace_id_str };
|
||||
let trace_dir = std::path::PathBuf::from(output_dir)
|
||||
.join(".faces")
|
||||
.join(&file_uuid)
|
||||
.join(trace_dir_name);
|
||||
|
||||
if trace_dir.exists() {
|
||||
// Find any cached face crop in this trace directory
|
||||
if let Ok(mut entries) = std::fs::read_dir(&trace_dir) {
|
||||
while let Some(Ok(entry)) = entries.next() {
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(false, |e| e == "jpg") {
|
||||
tracing::info!("[trace_thumbnail] Using cached face crop: {}", path.display());
|
||||
let bytes = tokio::fs::read(&path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Validate cached JPEG
|
||||
crate::core::thumbnail::validator::validate_jpeg(&bytes).map_err(|e| {
|
||||
tracing::warn!("[trace_thumbnail] Cached JPEG validation failed: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "Invalid cached JPEG"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "image/jpeg")
|
||||
.header(header::CACHE_CONTROL, "public, max-age=86400")
|
||||
.body(Body::from(bytes))
|
||||
.unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Fallback to ffmpeg on-demand extraction
|
||||
let sel = select_rep_face(state.db.pool(), &file_uuid, trace_id, |e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
||||
}).await?;
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
|
||||
let seek = sel.frame as f64 / sel.fps;
|
||||
let tmp = std::env::temp_dir().join(format!("trace_{}_{}.jpg", file_uuid, trace_id));
|
||||
|
||||
tracing::debug!("[trace_thumbnail] Fallback to ffmpeg for trace {} frame {}", trace_id, sel.frame);
|
||||
|
||||
let status = tokio::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-ss", &format!("{:.2}", seek),
|
||||
"-i", &sel.video_path,
|
||||
"-vframes", "1",
|
||||
"-vf", &format!("crop={}:{}:{}:{},scale=320:320", sel.w, sel.h, sel.x, sel.y),
|
||||
"-q:v", "2",
|
||||
"-y", &tmp.to_string_lossy().to_string(),
|
||||
"-ss",
|
||||
&format!("{:.2}", seek),
|
||||
"-i",
|
||||
&sel.video_path,
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
&format!("crop={}:{}:{}:{},scale=320:320", sel.w, sel.h, sel.x, sel.y),
|
||||
"-q:v",
|
||||
"2",
|
||||
"-y",
|
||||
&tmp.to_string_lossy().to_string(),
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !status.status.success() {
|
||||
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "FFmpeg failed"}))));
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "FFmpeg failed"})),
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = tokio::fs::read(&tmp).await.map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
|
||||
crate::core::thumbnail::validator::validate_jpeg(&bytes).map_err(|e| {
|
||||
tracing::warn!("[trace_thumbnail] JPEG validation failed: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "Invalid JPEG output"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let _ = tokio::fs::remove_file(&tmp).await;
|
||||
@@ -605,10 +755,16 @@ async fn get_cooccurrence(
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Identity A not found"})))
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Identity A not found"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let id_b = sqlx::query_as::<_, (i32, String)>(&format!(
|
||||
@@ -619,31 +775,38 @@ async fn get_cooccurrence(
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Identity B not found"})))
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Identity B not found"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Stage 2: Find first frame where both identity_ids appear
|
||||
let cooccur: Option<(i64,)> = sqlx::query_as(
|
||||
&format!(
|
||||
"SELECT MIN(fd.frame_number)::bigint FROM {} fd \
|
||||
let cooccur: Option<(i64,)> = sqlx::query_as(&format!(
|
||||
"SELECT MIN(fd.frame_number)::bigint FROM {} fd \
|
||||
WHERE fd.file_uuid = $1 AND fd.identity_id = $2 \
|
||||
AND fd.frame_number IN ( \
|
||||
SELECT frame_number FROM {} \
|
||||
WHERE file_uuid = $1 AND identity_id = $3 \
|
||||
)",
|
||||
fd_table, fd_table
|
||||
)
|
||||
)
|
||||
fd_table, fd_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.bind(id_a.0)
|
||||
.bind(id_b.0)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let (first_frame,) = cooccur.ok_or_else(|| {
|
||||
@@ -653,13 +816,17 @@ async fn get_cooccurrence(
|
||||
// Get fps for timestamp
|
||||
let video_table = schema::table_name("videos");
|
||||
let fps: f64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COALESCE(fps, 25.0) FROM {} WHERE file_uuid = $1", video_table
|
||||
"SELECT COALESCE(fps, 25.0) FROM {} WHERE file_uuid = $1",
|
||||
video_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?
|
||||
.unwrap_or(25.0);
|
||||
|
||||
@@ -685,40 +852,67 @@ async fn get_cooccurrence(
|
||||
// Stage 4: Get representative faces for both traces (reusing select_rep_face)
|
||||
let rep_a = if let Some((tid,)) = trace_a {
|
||||
select_rep_face(state.db.pool(), &file_uuid, tid, |e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
||||
}).await.ok().map(|sel| CoOccurRepFace {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.map(|sel| CoOccurRepFace {
|
||||
frame_number: sel.frame,
|
||||
bbox: RepFaceBbox { x: sel.x, y: sel.y, width: sel.w, height: sel.h },
|
||||
bbox: RepFaceBbox {
|
||||
x: sel.x,
|
||||
y: sel.y,
|
||||
width: sel.w,
|
||||
height: sel.h,
|
||||
},
|
||||
confidence: sel.conf,
|
||||
thumbnail_url: format!("/api/v1/file/{}/trace/{}/thumbnail", file_uuid, tid),
|
||||
})
|
||||
} else { None };
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let rep_b = if let Some((tid,)) = trace_b {
|
||||
select_rep_face(state.db.pool(), &file_uuid, tid, |e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
||||
}).await.ok().map(|sel| CoOccurRepFace {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.map(|sel| CoOccurRepFace {
|
||||
frame_number: sel.frame,
|
||||
bbox: RepFaceBbox { x: sel.x, y: sel.y, width: sel.w, height: sel.h },
|
||||
bbox: RepFaceBbox {
|
||||
x: sel.x,
|
||||
y: sel.y,
|
||||
width: sel.w,
|
||||
height: sel.h,
|
||||
},
|
||||
confidence: sel.conf,
|
||||
thumbnail_url: format!("/api/v1/file/{}/trace/{}/thumbnail", file_uuid, tid),
|
||||
})
|
||||
} else { None };
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Total co-occurrence frames (from TKG if available, otherwise from face_detections)
|
||||
let total_cooccurrence_frames: i64 = sqlx::query_scalar(
|
||||
&format!(
|
||||
"SELECT COUNT(DISTINCT fd.frame_number)::bigint FROM {} fd \
|
||||
let total_cooccurrence_frames: i64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COUNT(DISTINCT fd.frame_number)::bigint FROM {} fd \
|
||||
WHERE fd.file_uuid = $1 AND fd.identity_id = $2 \
|
||||
AND fd.frame_number IN ( \
|
||||
SELECT frame_number FROM {} \
|
||||
WHERE file_uuid = $1 AND identity_id = $3 \
|
||||
)",
|
||||
fd_table, fd_table
|
||||
)
|
||||
)
|
||||
.bind(&file_uuid).bind(id_a.0).bind(id_b.0)
|
||||
.fetch_one(state.db.pool()).await
|
||||
fd_table, fd_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.bind(id_a.0)
|
||||
.bind(id_b.0)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(Json(CoOccurResponse {
|
||||
@@ -758,12 +952,7 @@ async fn rebuild_tkg(
|
||||
State(state): State<crate::api::types::AppState>,
|
||||
Path(file_uuid): Path<String>,
|
||||
) -> Json<TkgRebuildResponse> {
|
||||
let result = crate::core::processor::tkg::build_tkg(
|
||||
&state.db,
|
||||
&file_uuid,
|
||||
&OUTPUT_DIR,
|
||||
)
|
||||
.await;
|
||||
let result = crate::core::processor::tkg::build_tkg(&state.db, &file_uuid, &OUTPUT_DIR).await;
|
||||
|
||||
match result {
|
||||
Ok(r) => Json(TkgRebuildResponse {
|
||||
@@ -807,14 +996,14 @@ async fn get_representative_frame(
|
||||
State(state): State<crate::api::types::AppState>,
|
||||
Path(file_uuid): Path<String>,
|
||||
) -> Result<Json<RepFrameResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let result = tkg::query_auto_representative_frame(
|
||||
state.db.pool(),
|
||||
&file_uuid,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": e.to_string()})))
|
||||
})?;
|
||||
let result = tkg::query_auto_representative_frame(state.db.pool(), &file_uuid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let fps = query_fps(state.db.pool(), &file_uuid).await;
|
||||
|
||||
@@ -843,3 +1032,59 @@ async fn query_fps(pool: &sqlx::PgPool, file_uuid: &str) -> f64 {
|
||||
.flatten()
|
||||
.unwrap_or(25.0)
|
||||
}
|
||||
|
||||
async fn get_stranger_representative_face(
|
||||
State(state): State<crate::api::types::AppState>,
|
||||
Path((file_uuid, stranger_id)): Path<(String, i32)>,
|
||||
) -> Result<Json<RepFaceResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let faces_table = crate::core::db::schema::table_name("face_detections");
|
||||
|
||||
let trace_id: i32 = sqlx::query_scalar(&format!(
|
||||
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND stranger_id = $2 LIMIT 1",
|
||||
faces_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.bind(stranger_id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?
|
||||
.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Stranger not found"})),
|
||||
))?;
|
||||
|
||||
get_representative_face_inner(&state, &file_uuid, trace_id).await
|
||||
}
|
||||
|
||||
async fn get_stranger_thumbnail(
|
||||
State(state): State<crate::api::types::AppState>,
|
||||
Path((file_uuid, stranger_id)): Path<(String, i32)>,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
let faces_table = crate::core::db::schema::table_name("face_detections");
|
||||
|
||||
let trace_id: i32 = sqlx::query_scalar(&format!(
|
||||
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND stranger_id = $2 LIMIT 1",
|
||||
faces_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.bind(stranger_id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?
|
||||
.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Stranger not found"})),
|
||||
))?;
|
||||
|
||||
get_trace_thumbnail_inner(&state, &file_uuid, trace_id).await
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: std::sync::Arc<crate::core::db::PostgresDb>,
|
||||
pub qdrant: std::sync::Arc<crate::core::db::QdrantDb>,
|
||||
pub embedder: std::sync::Arc<crate::Embedder>,
|
||||
pub embedder_model: String,
|
||||
pub mongo_cache: crate::core::cache::MongoCache,
|
||||
|
||||
@@ -60,13 +60,12 @@ pub struct UniversalSearchResponse {
|
||||
pub enum SearchResult {
|
||||
#[serde(rename = "chunk")]
|
||||
Chunk {
|
||||
file_uuid: String,
|
||||
chunk_id: String,
|
||||
chunk_type: String,
|
||||
// Primary: frame-accurate position
|
||||
start_frame: i64,
|
||||
end_frame: i64,
|
||||
fps: f64,
|
||||
// Reference: time derived from frames (subject to FPS variation)
|
||||
start_time: f64,
|
||||
end_time: f64,
|
||||
score: f64,
|
||||
@@ -76,9 +75,8 @@ pub enum SearchResult {
|
||||
},
|
||||
#[serde(rename = "frame")]
|
||||
Frame {
|
||||
// Primary: exact frame number
|
||||
file_uuid: String,
|
||||
frame_number: i64,
|
||||
// Reference: time derived from frame (subject to FPS variation)
|
||||
timestamp: f64,
|
||||
score: f64,
|
||||
objects: Option<Vec<serde_json::Value>>,
|
||||
@@ -88,6 +86,7 @@ pub enum SearchResult {
|
||||
},
|
||||
#[serde(rename = "person")]
|
||||
Person {
|
||||
file_uuid: Option<String>,
|
||||
identity_id: i32,
|
||||
identity_uuid: String,
|
||||
name: Option<String>,
|
||||
@@ -328,17 +327,15 @@ async fn search_chunks(
|
||||
db: &PostgresDb,
|
||||
req: &UniversalSearchRequest,
|
||||
) -> Result<Vec<SearchResult>, anyhow::Error> {
|
||||
// uuid is required for chunk search - chunk_id is only unique within a video
|
||||
let uuid = match &req.file_uuid {
|
||||
Some(u) => u.replace('\'', "''"),
|
||||
None => return Err(anyhow::anyhow!("file_uuid is required for chunk search")),
|
||||
};
|
||||
|
||||
let chunk_table = schema::table_name("chunk");
|
||||
let mut sql = format!(
|
||||
"SELECT chunk_id, chunk_type, start_time, end_time, (start_time * fps)::bigint as start_frame, (end_time * fps)::bigint as end_frame, fps, text_content, content FROM {} WHERE file_uuid = '{}'",
|
||||
chunk_table, uuid
|
||||
"SELECT file_uuid, chunk_id, chunk_type, start_time, end_time, (start_time * fps)::bigint as start_frame, (end_time * fps)::bigint as end_frame, fps, text_content, content FROM {} WHERE 1=1",
|
||||
chunk_table
|
||||
);
|
||||
|
||||
if let Some(uuid) = &req.file_uuid {
|
||||
sql.push_str(&format!(" AND file_uuid = '{}'", uuid.replace('\'', "''")));
|
||||
}
|
||||
if let Some(tr) = &req.time_range {
|
||||
sql.push_str(&format!(
|
||||
" AND start_time >= {} AND end_time <= {}",
|
||||
@@ -422,6 +419,7 @@ async fn search_chunks(
|
||||
sql.push_str(&format!(" LIMIT {}", req.page_size.unwrap_or(20)));
|
||||
|
||||
let rows: Vec<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
f64,
|
||||
@@ -437,6 +435,7 @@ async fn search_chunks(
|
||||
.into_iter()
|
||||
.map(
|
||||
|(
|
||||
file_uuid,
|
||||
chunk_id,
|
||||
chunk_type,
|
||||
start_time,
|
||||
@@ -457,7 +456,6 @@ async fn search_chunks(
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
});
|
||||
// Simple scoring: if query matches, score 0.8
|
||||
let score = if !req.query.is_empty()
|
||||
&& text.as_ref().map_or(false, |t| {
|
||||
t.to_lowercase().contains(&req.query.to_lowercase())
|
||||
@@ -468,6 +466,7 @@ async fn search_chunks(
|
||||
};
|
||||
|
||||
SearchResult::Chunk {
|
||||
file_uuid,
|
||||
chunk_id,
|
||||
chunk_type,
|
||||
start_time,
|
||||
@@ -549,7 +548,7 @@ async fn search_frames_internal(
|
||||
|
||||
let results: Vec<SearchResult> = rows
|
||||
.into_iter()
|
||||
.map(|(frame_number, timestamp, yolo, ocr, face, _uuid)| {
|
||||
.map(|(frame_number, timestamp, yolo, ocr, face, file_uuid)| {
|
||||
let objects = yolo.as_ref().and_then(|v| {
|
||||
v.get("objects")
|
||||
.map(|o| o.as_array().cloned().unwrap_or_default())
|
||||
@@ -571,6 +570,7 @@ async fn search_frames_internal(
|
||||
});
|
||||
|
||||
SearchResult::Frame {
|
||||
file_uuid,
|
||||
frame_number,
|
||||
timestamp,
|
||||
score: 0.7,
|
||||
@@ -589,37 +589,54 @@ async fn search_persons_internal(
|
||||
db: &PostgresDb,
|
||||
req: &UniversalSearchRequest,
|
||||
) -> Result<Vec<SearchResult>, anyhow::Error> {
|
||||
let uuid = match &req.file_uuid {
|
||||
Some(u) => u.replace('\'', "''"),
|
||||
None => return Err(anyhow::anyhow!("file_uuid is required for person search")),
|
||||
};
|
||||
|
||||
let id_table = schema::table_name("identities");
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
let mut sql = format!(
|
||||
"SELECT i.id, i.uuid::text, i.name, COUNT(fd.id) AS appearance_count, \
|
||||
MIN(fd.timestamp_secs) AS first_time, MAX(fd.timestamp_secs) AS last_time \
|
||||
FROM {} i JOIN {} fd ON fd.identity_id = i.id \
|
||||
WHERE fd.file_uuid = '{}'",
|
||||
id_table, fd_table, uuid
|
||||
MIN(fd.timestamp_secs) AS first_time, MAX(fd.timestamp_secs) AS last_time, \
|
||||
fd.file_uuid \
|
||||
FROM {} i JOIN {} fd ON fd.identity_id = i.id WHERE 1=1",
|
||||
id_table, fd_table
|
||||
);
|
||||
|
||||
if let Some(uuid) = &req.file_uuid {
|
||||
sql.push_str(&format!(
|
||||
" AND fd.file_uuid = '{}'",
|
||||
uuid.replace('\'', "''")
|
||||
));
|
||||
}
|
||||
|
||||
if !req.query.is_empty() {
|
||||
let q = req.query.replace('\'', "''");
|
||||
sql.push_str(&format!(" AND i.name ILIKE '%{}%'", q));
|
||||
}
|
||||
|
||||
sql.push_str(" GROUP BY i.id, i.uuid, i.name");
|
||||
sql.push_str(" GROUP BY i.id, i.uuid, i.name, fd.file_uuid");
|
||||
sql.push_str(" ORDER BY appearance_count DESC");
|
||||
sql.push_str(&format!(" LIMIT {}", req.page_size.unwrap_or(20)));
|
||||
|
||||
let rows: Vec<(i32, String, Option<String>, i64, Option<f64>, Option<f64>)> =
|
||||
sqlx::query_as(&sql).fetch_all(db.pool()).await?;
|
||||
let rows: Vec<(
|
||||
i32,
|
||||
String,
|
||||
Option<String>,
|
||||
i64,
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
String,
|
||||
)> = sqlx::query_as(&sql).fetch_all(db.pool()).await?;
|
||||
|
||||
let results: Vec<SearchResult> = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(identity_id, identity_uuid, name, appearance_count, first_time, last_time)| {
|
||||
|(
|
||||
identity_id,
|
||||
identity_uuid,
|
||||
name,
|
||||
appearance_count,
|
||||
first_time,
|
||||
last_time,
|
||||
file_uuid,
|
||||
)| {
|
||||
let score = if !req.query.is_empty()
|
||||
&& name.as_ref().map_or(false, |n| {
|
||||
n.to_lowercase().contains(&req.query.to_lowercase())
|
||||
@@ -630,6 +647,7 @@ async fn search_persons_internal(
|
||||
};
|
||||
|
||||
SearchResult::Person {
|
||||
file_uuid: Some(file_uuid),
|
||||
identity_id,
|
||||
identity_uuid,
|
||||
name,
|
||||
|
||||
@@ -1,513 +0,0 @@
|
||||
//! Visual chunk search functionality.
|
||||
//!
|
||||
//! This module provides search capabilities for visual chunks based on:
|
||||
//! - Object classes (e.g., "person", "car", "envelope")
|
||||
//! - Confidence thresholds
|
||||
//! - Object counts
|
||||
//! - Spatial density
|
||||
//! - Object relationships
|
||||
|
||||
use crate::core::chunk::types::{Chunk, ChunkRule, ChunkType};
|
||||
use crate::core::db::{schema, PostgresDb};
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Criteria for searching visual chunks
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct VisualChunkSearchCriteria {
|
||||
/// Minimum average confidence across frames
|
||||
pub min_avg_confidence: Option<f32>,
|
||||
/// Minimum number of frames with objects
|
||||
pub min_frames_with_objects: Option<u32>,
|
||||
/// Minimum number of unique object classes
|
||||
pub min_unique_classes: Option<u32>,
|
||||
/// Specific object classes to include (empty means all)
|
||||
#[serde(default)]
|
||||
pub required_classes: Vec<String>,
|
||||
/// Object class counts to filter by
|
||||
#[serde(default)]
|
||||
pub class_counts: HashMap<String, (u32, u32)>,
|
||||
/// Time range (optional)
|
||||
pub time_range: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
impl Default for VisualChunkSearchCriteria {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_avg_confidence: None,
|
||||
min_frames_with_objects: None,
|
||||
min_unique_classes: None,
|
||||
required_classes: Vec::new(),
|
||||
class_counts: HashMap::new(),
|
||||
time_range: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Search visual chunks based on criteria
|
||||
pub async fn search_visual_chunks(
|
||||
db: &PostgresDb,
|
||||
uuid: &str,
|
||||
criteria: &VisualChunkSearchCriteria,
|
||||
) -> Result<Vec<Chunk>> {
|
||||
// First, get all visual chunks for this video
|
||||
let all_chunks = get_visual_chunks_by_uuid(db, uuid).await?;
|
||||
|
||||
// Apply filters
|
||||
let filtered_chunks: Vec<Chunk> = all_chunks
|
||||
.into_iter()
|
||||
.filter(|chunk| {
|
||||
// Check min avg confidence
|
||||
if let Some(min_avg_confidence) = criteria.min_avg_confidence {
|
||||
if let Some(content) = &chunk.content.as_object() {
|
||||
if let Some(metadata) = content.get("metadata") {
|
||||
if let Some(avg_confidence) = metadata.get("avg_confidence") {
|
||||
if let Some(conf) = avg_confidence.as_f64() {
|
||||
if conf < min_avg_confidence as f64 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check min frames with objects
|
||||
if let Some(min_frames) = criteria.min_frames_with_objects {
|
||||
if let Some(stats) = &chunk.visual_stats {
|
||||
if let Some(frames_with_objects) = stats.get("frames_with_objects") {
|
||||
if let Some(count) = frames_with_objects.as_u64() {
|
||||
if count < min_frames as u64 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check min unique classes
|
||||
if let Some(min_unique_classes) = criteria.min_unique_classes {
|
||||
if let Some(content) = &chunk.content.as_object() {
|
||||
if let Some(metadata) = content.get("metadata") {
|
||||
if let Some(unique_classes) = metadata.get("unique_classes") {
|
||||
if let Some(classes) = unique_classes.as_array() {
|
||||
if (classes.len() as u32) < min_unique_classes {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check required classes
|
||||
if !criteria.required_classes.is_empty() {
|
||||
if let Some(content) = &chunk.content.as_object() {
|
||||
if let Some(keyframe_objects) = content.get("keyframe_objects") {
|
||||
if let Some(objects) = keyframe_objects.as_array() {
|
||||
let mut found_all = true;
|
||||
for required_class in &criteria.required_classes {
|
||||
let mut found = false;
|
||||
for obj in objects {
|
||||
if let Some(class_name) = obj.get("class_name") {
|
||||
if let Some(class_str) = class_name.as_str() {
|
||||
if class_str == required_class {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
found_all = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found_all {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check class counts
|
||||
if !criteria.class_counts.is_empty() {
|
||||
if let Some(content) = &chunk.content.as_object() {
|
||||
if let Some(metadata) = content.get("metadata") {
|
||||
if let Some(object_counts) = metadata.get("object_counts") {
|
||||
for (class, (min, max)) in &criteria.class_counts {
|
||||
if let Some(count_value) = object_counts.get(class) {
|
||||
if let Some(count) = count_value.as_u64() {
|
||||
if *min > 0 && count < *min as u64 {
|
||||
return false;
|
||||
}
|
||||
if *max < u32::MAX && count > *max as u64 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else if *min > 0 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else if criteria.class_counts.values().any(|(min, _)| *min > 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check time range
|
||||
if let Some((start_time, end_time)) = criteria.time_range {
|
||||
// Calculate chunk time from frames
|
||||
let chunk_start_time = chunk.start_frame as f64 / chunk.fps;
|
||||
let chunk_end_time = chunk.end_frame as f64 / chunk.fps;
|
||||
|
||||
if chunk_start_time < start_time || chunk_end_time > end_time {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(filtered_chunks)
|
||||
}
|
||||
|
||||
/// Get all visual chunks for a video UUID
|
||||
async fn get_visual_chunks_by_uuid(db: &PostgresDb, uuid: &str) -> Result<Vec<Chunk>> {
|
||||
let chunk_table = schema::table_name("chunk");
|
||||
let sql = format!(
|
||||
"SELECT file_id, file_uuid, chunk_id, chunk_type, fps, start_frame, end_frame, text_content, content, metadata, vector_id, visual_stats FROM {} WHERE file_uuid = '{}' AND chunk_type = 'visual' ORDER BY start_frame ASC",
|
||||
chunk_table, uuid.replace('\'', "''")
|
||||
);
|
||||
|
||||
let rows: Vec<(
|
||||
i32, // file_id
|
||||
String, // uuid
|
||||
String, // chunk_id
|
||||
String, // chunk_type
|
||||
f64, // fps
|
||||
i64, // start_frame
|
||||
i64, // end_frame
|
||||
Option<String>, // text_content
|
||||
Value, // content
|
||||
Option<Value>, // metadata
|
||||
Option<String>, // vector_id
|
||||
Option<Value>, // visual_stats
|
||||
)> = sqlx::query_as(&sql).fetch_all(db.pool()).await?;
|
||||
|
||||
let mut chunks = Vec::new();
|
||||
for row in rows {
|
||||
let chunk_type = match row.3.as_str() {
|
||||
"visual" => ChunkType::Visual,
|
||||
"sentence" => ChunkType::Sentence,
|
||||
"time_based" => ChunkType::TimeBased,
|
||||
"cut" => ChunkType::Cut,
|
||||
"trace" => ChunkType::Trace,
|
||||
"story" => ChunkType::Story,
|
||||
_ => ChunkType::TimeBased,
|
||||
};
|
||||
|
||||
// Calculate frame_count
|
||||
let frame_count = (row.6 - row.5) as i32;
|
||||
|
||||
chunks.push(Chunk {
|
||||
file_id: row.0,
|
||||
uuid: row.1,
|
||||
chunk_id: row.2,
|
||||
chunk_type,
|
||||
rule: ChunkRule::Rule2, // Visual chunks use Rule2
|
||||
fps: row.4,
|
||||
start_frame: row.5,
|
||||
end_frame: row.6,
|
||||
text_content: row.7,
|
||||
content: row.8,
|
||||
metadata: row.9,
|
||||
vector_id: row.10,
|
||||
frame_count,
|
||||
pre_chunk_ids: Vec::new(),
|
||||
parent_chunk_id: None,
|
||||
child_chunk_ids: Vec::new(),
|
||||
visual_stats: row.11,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(chunks)
|
||||
}
|
||||
|
||||
/// Search visual chunks by object class
|
||||
pub async fn search_visual_chunks_by_class(
|
||||
db: &PostgresDb,
|
||||
uuid: &str,
|
||||
object_class: &str,
|
||||
min_count: Option<u32>,
|
||||
max_count: Option<u32>,
|
||||
) -> Result<Vec<Chunk>> {
|
||||
let all_chunks = get_visual_chunks_by_uuid(db, uuid).await?;
|
||||
|
||||
let filtered_chunks: Vec<Chunk> = all_chunks
|
||||
.into_iter()
|
||||
.filter(|chunk| {
|
||||
// Check if chunk contains the object class
|
||||
let mut contains_class = false;
|
||||
if let Some(content) = &chunk.content.as_object() {
|
||||
if let Some(keyframe_objects) = content.get("keyframe_objects") {
|
||||
if let Some(objects) = keyframe_objects.as_array() {
|
||||
for obj in objects {
|
||||
if let Some(class_name) = obj.get("class_name") {
|
||||
if let Some(class_str) = class_name.as_str() {
|
||||
if class_str == object_class {
|
||||
contains_class = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !contains_class {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check count in visual_stats
|
||||
if let Some(stats) = &chunk.visual_stats {
|
||||
if let Some(count) = stats.get(object_class) {
|
||||
if let Some(c) = count.as_u64() {
|
||||
if let Some(min) = min_count {
|
||||
if c < min as u64 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(max) = max_count {
|
||||
if c > max as u64 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(filtered_chunks)
|
||||
}
|
||||
|
||||
/// Search visual chunks by spatial density
|
||||
pub async fn search_visual_chunks_by_density(
|
||||
db: &PostgresDb,
|
||||
uuid: &str,
|
||||
min_density: f32,
|
||||
max_density: Option<f32>,
|
||||
) -> Result<Vec<Chunk>> {
|
||||
let all_chunks = get_visual_chunks_by_uuid(db, uuid).await?;
|
||||
|
||||
let filtered_chunks: Vec<Chunk> = all_chunks
|
||||
.into_iter()
|
||||
.filter(|chunk| {
|
||||
if let Some(content) = &chunk.content.as_object() {
|
||||
if let Some(metadata) = content.get("metadata") {
|
||||
if let Some(density_value) = metadata.get("spatial_density") {
|
||||
if let Some(density) = density_value.as_f64() {
|
||||
if density < min_density as f64 {
|
||||
return false;
|
||||
}
|
||||
if let Some(max_dens) = max_density {
|
||||
if density > max_dens as f64 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(filtered_chunks)
|
||||
}
|
||||
|
||||
/// Find chunks containing specific object combinations
|
||||
pub async fn search_visual_chunks_by_combination(
|
||||
db: &PostgresDb,
|
||||
uuid: &str,
|
||||
combination: &[(&str, u32)],
|
||||
) -> Result<Vec<Chunk>> {
|
||||
let all_chunks = get_visual_chunks_by_uuid(db, uuid).await?;
|
||||
|
||||
let filtered_chunks: Vec<Chunk> = all_chunks
|
||||
.into_iter()
|
||||
.filter(|chunk| {
|
||||
// Check if all required combinations are present
|
||||
for (object_class, min_count) in combination {
|
||||
let mut found = false;
|
||||
if let Some(stats) = &chunk.visual_stats {
|
||||
if let Some(object_counts) = stats.get("object_counts") {
|
||||
if let Some(count_value) = object_counts.get(*object_class) {
|
||||
if let Some(count) = count_value.as_u64() {
|
||||
if count >= *min_count as u64 {
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(filtered_chunks)
|
||||
}
|
||||
|
||||
/// Get visual chunk statistics
|
||||
pub async fn get_visual_chunk_statistics(
|
||||
db: &PostgresDb,
|
||||
uuid: &str,
|
||||
) -> Result<HashMap<String, Value>> {
|
||||
let chunk_table = schema::table_name("chunk");
|
||||
let sql = format!(
|
||||
"SELECT
|
||||
COUNT(*) as total_chunks,
|
||||
AVG((content->'metadata'->>'avg_confidence')::float) as avg_confidence,
|
||||
MIN((content->'metadata'->>'avg_confidence')::float) as min_confidence,
|
||||
MAX((content->'metadata'->>'avg_confidence')::float) as max_confidence,
|
||||
SUM((content->'metadata'->>'object_count')::int) as total_objects,
|
||||
AVG((content->'metadata'->>'spatial_density')::float) as avg_density
|
||||
FROM {}
|
||||
WHERE file_uuid = '{}'
|
||||
AND chunk_type = 'visual'",
|
||||
chunk_table,
|
||||
uuid.replace('\'', "''")
|
||||
);
|
||||
|
||||
let row: (
|
||||
i64,
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
Option<f64>,
|
||||
Option<i64>,
|
||||
Option<f64>,
|
||||
) = sqlx::query_as(&sql).fetch_one(db.pool()).await?;
|
||||
|
||||
let mut stats = HashMap::new();
|
||||
stats.insert("total_chunks".to_string(), Value::from(row.0));
|
||||
stats.insert(
|
||||
"avg_confidence".to_string(),
|
||||
Value::from(row.1.unwrap_or(0.0)),
|
||||
);
|
||||
stats.insert(
|
||||
"min_confidence".to_string(),
|
||||
Value::from(row.2.unwrap_or(0.0)),
|
||||
);
|
||||
stats.insert(
|
||||
"max_confidence".to_string(),
|
||||
Value::from(row.3.unwrap_or(0.0)),
|
||||
);
|
||||
stats.insert("total_objects".to_string(), Value::from(row.4.unwrap_or(0)));
|
||||
stats.insert("avg_density".to_string(), Value::from(row.5.unwrap_or(0.0)));
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_visual_chunk_search_criteria_default() {
|
||||
let criteria = VisualChunkSearchCriteria::default();
|
||||
|
||||
assert_eq!(criteria.min_avg_confidence, None);
|
||||
assert_eq!(criteria.min_frames_with_objects, None);
|
||||
assert_eq!(criteria.min_unique_classes, None);
|
||||
assert!(criteria.required_classes.is_empty());
|
||||
assert!(criteria.class_counts.is_empty());
|
||||
assert_eq!(criteria.time_range, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visual_chunk_search_criteria_with_values() {
|
||||
let mut criteria = VisualChunkSearchCriteria::default();
|
||||
criteria.min_avg_confidence = Some(0.8);
|
||||
criteria.min_frames_with_objects = Some(10);
|
||||
criteria.min_unique_classes = Some(3);
|
||||
criteria.required_classes = vec!["person".to_string(), "car".to_string()];
|
||||
criteria.time_range = Some((0.0, 60.0));
|
||||
|
||||
assert_eq!(criteria.min_avg_confidence, Some(0.8));
|
||||
assert_eq!(criteria.min_frames_with_objects, Some(10));
|
||||
assert_eq!(criteria.min_unique_classes, Some(3));
|
||||
assert_eq!(criteria.required_classes.len(), 2);
|
||||
assert_eq!(criteria.time_range, Some((0.0, 60.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visual_chunk_search_criteria_serialization() {
|
||||
let criteria = VisualChunkSearchCriteria {
|
||||
min_avg_confidence: Some(0.85),
|
||||
min_frames_with_objects: Some(5),
|
||||
min_unique_classes: Some(2),
|
||||
required_classes: vec!["person".to_string()],
|
||||
class_counts: HashMap::new(),
|
||||
time_range: Some((10.0, 30.0)),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&criteria).unwrap();
|
||||
assert!(json.contains("min_avg_confidence"));
|
||||
assert!(json.contains("required_classes"));
|
||||
|
||||
let deserialized: VisualChunkSearchCriteria = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.min_avg_confidence, Some(0.85));
|
||||
assert_eq!(deserialized.required_classes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visual_chunk_search_criteria_with_class_counts() {
|
||||
let mut criteria = VisualChunkSearchCriteria::default();
|
||||
criteria.class_counts.insert("person".to_string(), (5, 20));
|
||||
criteria.class_counts.insert("car".to_string(), (1, 10));
|
||||
|
||||
assert_eq!(criteria.class_counts.len(), 2);
|
||||
assert_eq!(criteria.class_counts.get("person"), Some(&(5, 20)));
|
||||
assert_eq!(criteria.class_counts.get("car"), Some(&(1, 10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunk_type_conversion() {
|
||||
// Test chunk type string to enum conversion logic
|
||||
let test_cases = vec![
|
||||
("visual", ChunkType::Visual),
|
||||
("sentence", ChunkType::Sentence),
|
||||
("time_based", ChunkType::TimeBased),
|
||||
("cut", ChunkType::Cut),
|
||||
("trace", ChunkType::Trace),
|
||||
("story", ChunkType::Story),
|
||||
("unknown", ChunkType::TimeBased), // Default fallback
|
||||
];
|
||||
|
||||
for (input, expected) in test_cases {
|
||||
let chunk_type = match input {
|
||||
"visual" => ChunkType::Visual,
|
||||
"sentence" => ChunkType::Sentence,
|
||||
"time_based" => ChunkType::TimeBased,
|
||||
"cut" => ChunkType::Cut,
|
||||
"trace" => ChunkType::Trace,
|
||||
"story" => ChunkType::Story,
|
||||
_ => ChunkType::TimeBased,
|
||||
};
|
||||
assert_eq!(chunk_type, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
use axum::{extract::State, http::StatusCode, response::Json, routing::post, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::types::AppState;
|
||||
use super::visual_chunk_search;
|
||||
use crate::core::cache::keys;
|
||||
use crate::core::chunk::types::Chunk;
|
||||
use crate::core::db::{Database, PostgresDb};
|
||||
|
||||
fn generate_visual_search_hash(
|
||||
uuid: &str,
|
||||
criteria: &visual_chunk_search::VisualChunkSearchCriteria,
|
||||
) -> String {
|
||||
let data = serde_json::json!({
|
||||
"uuid": uuid,
|
||||
"criteria": criteria,
|
||||
});
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data.to_string().as_bytes());
|
||||
format!("{:x}", hasher.finalize())[..16].to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct VisualChunkSearchRequest {
|
||||
file_uuid: String,
|
||||
criteria: visual_chunk_search::VisualChunkSearchCriteria,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct VisualChunkSearchResponse {
|
||||
chunks: Vec<Chunk>,
|
||||
total: usize,
|
||||
}
|
||||
|
||||
async fn search_visual_chunks(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<VisualChunkSearchRequest>,
|
||||
) -> Result<Json<VisualChunkSearchResponse>, StatusCode> {
|
||||
let criteria_hash = generate_visual_search_hash(&req.file_uuid, &req.criteria);
|
||||
let cache_key = keys::visual_search(&req.file_uuid, &criteria_hash);
|
||||
let ttl = state.mongo_cache.ttl_visual_search();
|
||||
|
||||
let chunks = state
|
||||
.mongo_cache
|
||||
.get_or_fetch(&cache_key, ttl, keys::CATEGORY_VISUAL_SEARCH, || async {
|
||||
let db = PostgresDb::init()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?;
|
||||
|
||||
visual_chunk_search::search_visual_chunks(&db, &req.file_uuid, &req.criteria)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Visual search failed: {}", e))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Visual chunk search failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(VisualChunkSearchResponse {
|
||||
total: chunks.len(),
|
||||
chunks,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct VisualChunkSearchByClassRequest {
|
||||
uuid: String,
|
||||
object_class: String,
|
||||
min_count: Option<u32>,
|
||||
max_count: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct VisualChunkSearchByDensityRequest {
|
||||
uuid: String,
|
||||
min_density: f32,
|
||||
max_density: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct VisualChunkStatsRequest {
|
||||
uuid: String,
|
||||
}
|
||||
|
||||
async fn search_visual_chunks_by_class(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<VisualChunkSearchByClassRequest>,
|
||||
) -> Result<Json<VisualChunkSearchResponse>, StatusCode> {
|
||||
let db = PostgresDb::init()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let chunks = visual_chunk_search::search_visual_chunks_by_class(
|
||||
&db,
|
||||
&req.uuid,
|
||||
&req.object_class,
|
||||
req.min_count,
|
||||
req.max_count,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Visual chunk search by class failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(VisualChunkSearchResponse {
|
||||
total: chunks.len(),
|
||||
chunks,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn search_visual_chunks_by_density(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<VisualChunkSearchByDensityRequest>,
|
||||
) -> Result<Json<VisualChunkSearchResponse>, StatusCode> {
|
||||
let db = PostgresDb::init()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let chunks = visual_chunk_search::search_visual_chunks_by_density(
|
||||
&db,
|
||||
&req.uuid,
|
||||
req.min_density,
|
||||
req.max_density,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Visual chunk search by density failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(VisualChunkSearchResponse {
|
||||
total: chunks.len(),
|
||||
chunks,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct VisualChunkStatsResponse {
|
||||
uuid: String,
|
||||
stats: std::collections::HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
async fn get_visual_chunk_stats(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<VisualChunkStatsRequest>,
|
||||
) -> Result<Json<VisualChunkStatsResponse>, StatusCode> {
|
||||
let db = PostgresDb::init()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let stats = visual_chunk_search::get_visual_chunk_statistics(&db, &req.uuid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Get visual chunk stats failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(VisualChunkStatsResponse {
|
||||
uuid: req.uuid,
|
||||
stats,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct VisualChunkSearchByCombinationRequest {
|
||||
uuid: String,
|
||||
combination: Vec<(String, u32)>,
|
||||
}
|
||||
|
||||
async fn search_visual_chunks_by_combination(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<VisualChunkSearchByCombinationRequest>,
|
||||
) -> Result<Json<VisualChunkSearchResponse>, StatusCode> {
|
||||
let db = PostgresDb::init()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let combination: Vec<(&str, u32)> = req
|
||||
.combination
|
||||
.iter()
|
||||
.map(|(c, n)| (c.as_str(), *n))
|
||||
.collect();
|
||||
|
||||
let chunks =
|
||||
visual_chunk_search::search_visual_chunks_by_combination(&db, &req.uuid, &combination)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Visual chunk search by combination failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
Ok(Json(VisualChunkSearchResponse {
|
||||
total: chunks.len(),
|
||||
chunks,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn visual_search_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/search/visual", post(search_visual_chunks))
|
||||
.route(
|
||||
"/api/v1/search/visual/class",
|
||||
post(search_visual_chunks_by_class),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/search/visual/density",
|
||||
post(search_visual_chunks_by_density),
|
||||
)
|
||||
.route("/api/v1/search/visual/stats", post(get_visual_chunk_stats))
|
||||
.route(
|
||||
"/api/v1/search/visual/combination",
|
||||
post(search_visual_chunks_by_combination),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user