feat: media API (video/bbox/thumbnail), UUID unification, dot matrix text, portal fixes, API dictionary V1.3

This commit is contained in:
Warren
2026-05-06 13:34:49 +08:00
parent e75c4d6f07
commit 74b6182eba
197 changed files with 17511 additions and 8759 deletions

View File

@@ -1,944 +0,0 @@
use axum::{
extract::{Multipart, Path, Query, State},
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::core::db::{schema, Database, PostgresDb};
use crate::core::processor::face_recognition::{
process_face_recognition, register_face, FaceRecognitionResult, FaceRegistrationResult,
};
#[derive(Debug, Deserialize)]
pub struct FaceRecognitionRequest {
pub file_uuid: String,
pub enable_recognition: Option<bool>,
pub enable_tracking: Option<bool>,
pub enable_clustering: Option<bool>,
pub database_path: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct FaceRecognitionResponse {
pub success: bool,
pub message: String,
pub result: Option<FaceRecognitionResult>,
pub processing_id: String,
}
#[derive(Debug, Deserialize)]
pub struct FaceRegistrationRequest {
pub file_uuid: String,
pub name: String,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Serialize)]
pub struct FaceRegistrationApiResponse {
pub success: bool,
pub message: String,
pub result: Option<FaceRegistrationResult>,
}
#[derive(Debug, Deserialize)]
pub struct FaceSearchRequest {
pub file_uuid: String,
pub embedding: Vec<f32>,
pub similarity_threshold: Option<f64>,
pub limit: Option<i32>,
}
#[derive(Debug, Serialize)]
pub struct FaceSearchResponse {
pub success: bool,
pub message: String,
pub results: Vec<FaceSearchResult>,
}
#[derive(Debug, Serialize)]
pub struct FaceSearchResult {
pub face_id: String,
pub name: Option<String>,
pub similarity: f64,
pub attributes: Option<serde_json::Value>,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub struct FaceListQuery {
pub file_uuid: String,
pub page: Option<usize>,
pub page_size: Option<usize>,
pub active_only: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct FaceListResponse {
pub success: bool,
pub message: String,
pub faces: Vec<FaceListItem>,
pub count: i64,
pub page: usize,
pub page_size: usize,
}
#[derive(Debug, Serialize)]
pub struct FaceListItem {
pub face_id: String,
pub name: Option<String>,
pub created_at: String,
pub updated_at: String,
pub is_active: bool,
pub metadata: Option<serde_json::Value>,
}
pub fn face_recognition_routes() -> Router<crate::api::server::AppState> {
Router::new()
.route("/api/v1/face/recognize", post(recognize_faces))
.route("/api/v1/face/register", post(register_face_api))
.route("/api/v1/face/search", post(search_faces))
.route("/api/v1/face/list", get(list_faces))
.route(
"/api/v1/files/:file_uuid/faces/:face_id",
get(get_face_details),
)
.route(
"/api/v1/files/:file_uuid/faces/:face_id",
axum::routing::delete(delete_face),
)
.route(
"/api/v1/face/results/:file_uuid",
get(get_recognition_results),
)
}
async fn recognize_faces(
State(_state): State<crate::api::server::AppState>,
Json(request): Json<FaceRecognitionRequest>,
) -> Result<Json<FaceRecognitionResponse>, (StatusCode, String)> {
let processing_id = Uuid::new_v4().to_string();
tracing::info!(
"[FACE_RECOGNITION] Starting recognition for video: {}, processing_id: {}",
request.file_uuid,
processing_id
);
// Get video path from database
let db = match PostgresDb::init().await {
Ok(db) => db,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to connect to database: {}", e),
))
}
};
let video_record = match db.get_video_by_uuid(&request.file_uuid).await {
Ok(Some(record)) => record,
Ok(None) => {
return Err((
StatusCode::NOT_FOUND,
format!("Video not found: {}", request.file_uuid),
))
}
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to fetch video: {}", e),
))
}
};
let video_path = video_record.file_path;
let output_path = format!(
"{}/face_recognition_{}.json",
crate::core::config::OUTPUT_DIR.as_str(),
processing_id
);
// Process face recognition
let result = match process_face_recognition(
&video_path,
&output_path,
Some(&processing_id),
request.enable_recognition.unwrap_or(true),
request.enable_tracking.unwrap_or(true),
request.enable_clustering.unwrap_or(true),
)
.await
{
Ok(result) => result,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Face recognition failed: {}", e),
))
}
};
// Store results in database
if let Err(e) = store_recognition_results(&db, &request.file_uuid, &result).await {
tracing::warn!("Failed to store recognition results: {}", e);
}
Ok(Json(FaceRecognitionResponse {
success: true,
message: format!("Face recognition completed for {}", request.file_uuid),
result: Some(result),
processing_id,
}))
}
async fn register_face_api(
State(_state): State<crate::api::server::AppState>,
mut multipart: Multipart,
) -> Result<Json<FaceRegistrationApiResponse>, (StatusCode, String)> {
let mut image_path: Option<String> = None;
let mut name: Option<String> = None;
let mut metadata: Option<serde_json::Value> = None;
// Parse multipart form data
while let Some(field) = multipart.next_field().await.map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Failed to parse form data: {}", e),
)
})? {
let field_name = field.name().unwrap_or("").to_string();
match field_name.as_str() {
"image" => {
// Save uploaded image
let file_name = format!("face_registration_{}.jpg", Uuid::new_v4());
let file_path = format!("/tmp/{}", file_name);
let data = field.bytes().await.map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Failed to read image data: {}", e),
)
})?;
tokio::fs::write(&file_path, &data).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to save image: {}", e),
)
})?;
image_path = Some(file_path);
}
"name" => {
let value = field.text().await.map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Failed to read name: {}", e),
)
})?;
name = Some(value);
}
"metadata" => {
let value = field.text().await.map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Failed to read metadata: {}", e),
)
})?;
metadata = Some(serde_json::from_str(&value).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Invalid JSON metadata: {}", e),
)
})?);
}
_ => {}
}
}
// Validate required fields
let image_path =
image_path.ok_or((StatusCode::BAD_REQUEST, "Image is required".to_string()))?;
let name = name.ok_or((StatusCode::BAD_REQUEST, "Name is required".to_string()))?;
// Register face
let result = match register_face(&image_path, &name, metadata.clone()).await {
Ok(result) => result,
Err(e) => {
// Clean up temporary file
let _ = tokio::fs::remove_file(&image_path).await;
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Face registration failed: {}", e),
));
}
};
// Clean up temporary file
let _ = tokio::fs::remove_file(&image_path).await;
// Store in PostgreSQL face_identities table
if result.success && !result.embedding.is_empty() {
let db = match PostgresDb::init().await {
Ok(db) => db,
Err(e) => {
tracing::warn!("[FACE_REGISTRATION] Failed to connect to DB: {}", e);
// Return success even if DB write fails (embedding is still in JSON output)
return Ok(Json(FaceRegistrationApiResponse {
success: result.success,
message: format!("{} (Warning: DB write failed: {})", result.message, e),
result: Some(result),
}));
}
};
// Convert embedding to PostgreSQL vector format
let embedding_str = format!(
"[{}]",
result
.embedding
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(",")
);
// Insert into face_identities
let face_identities_table = schema::table_name("face_identities");
let attrs_json =
serde_json::to_string(&result.attributes).unwrap_or_else(|_| "{}".to_string());
// Use public.vector type to work across schemas
let vector_type = if schema::SCHEMA_PREFIX.as_str().is_empty() {
"vector".to_string()
} else {
"public.vector".to_string()
};
let insert_query = format!(
r#"
INSERT INTO {} (face_id, name, embedding, attributes, metadata, is_active)
VALUES ($1, $2, $3::{}, $4::jsonb, $5, TRUE)
ON CONFLICT (face_id) DO UPDATE SET
name = EXCLUDED.name,
embedding = EXCLUDED.embedding,
attributes = EXCLUDED.attributes,
metadata = COALESCE(EXCLUDED.metadata, {}.metadata),
updated_at = CURRENT_TIMESTAMP,
is_active = TRUE
"#,
face_identities_table, vector_type, face_identities_table
);
match sqlx::query(&insert_query)
.bind(&result.face_id)
.bind(&name)
.bind(&embedding_str)
.bind(&attrs_json)
.bind(serde_json::to_string(&metadata.unwrap_or(serde_json::json!({}))).unwrap())
.execute(db.pool())
.await
{
Ok(_) => {
tracing::info!(
"[FACE_REGISTRATION] Stored face '{}' (face_id={}) in DB",
name,
result.face_id
);
}
Err(e) => {
tracing::warn!("[FACE_REGISTRATION] Failed to store face in DB: {}", e);
}
}
}
Ok(Json(FaceRegistrationApiResponse {
success: result.success,
message: result.message.clone(),
result: Some(result),
}))
}
async fn search_faces(
State(_state): State<crate::api::server::AppState>,
Json(request): Json<FaceSearchRequest>,
) -> Result<Json<FaceSearchResponse>, (StatusCode, String)> {
let db = match PostgresDb::init().await {
Ok(db) => db,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to connect to database: {}", e),
))
}
};
// Convert embedding to PostgreSQL vector format
let embedding_str = format!(
"[{}]",
request
.embedding
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(",")
);
let similarity_threshold = request.similarity_threshold.unwrap_or(0.6);
let limit = request.limit.unwrap_or(10);
// Search for similar faces
let face_identities_table = schema::table_name("face_identities");
let vector_type = if schema::SCHEMA_PREFIX.as_str().is_empty() {
"vector".to_string()
} else {
"public.vector".to_string()
};
let query = format!(
r#"
SELECT
face_id,
name,
1 - (embedding <=> $1::{}) as similarity,
attributes,
metadata
FROM {}
WHERE is_active = TRUE
AND embedding IS NOT NULL
AND 1 - (embedding <=> $1::{}) >= $2
ORDER BY embedding <=> $1::{}
LIMIT $3
"#,
vector_type, face_identities_table, vector_type, vector_type
);
let results: Vec<FaceSearchResult> = match sqlx::query_as::<
_,
(
String,
Option<String>,
f64,
Option<serde_json::Value>,
Option<serde_json::Value>,
),
>(query.as_str())
.bind(&embedding_str)
.bind(similarity_threshold)
.bind(limit)
.fetch_all(db.pool())
.await
{
Ok(rows) => rows
.into_iter()
.map(
|(face_id, name, similarity, attributes, metadata)| FaceSearchResult {
face_id,
name,
similarity,
attributes,
metadata,
},
)
.collect(),
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to search faces: {}", e),
))
}
};
Ok(Json(FaceSearchResponse {
success: true,
message: format!("Found {} similar faces", results.len()),
results,
}))
}
async fn list_faces(
State(_state): State<crate::api::server::AppState>,
Query(query): Query<FaceListQuery>,
) -> Result<Json<FaceListResponse>, (StatusCode, String)> {
let db = match PostgresDb::init().await {
Ok(db) => db,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to connect to database: {}", e),
))
}
};
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20);
let offset = ((page - 1) as i64) * (page_size as i64);
let active_only = query.active_only.unwrap_or(true);
// Build query
let mut where_clause = "WHERE 1=1".to_string();
if active_only {
where_clause.push_str(" AND is_active = TRUE");
}
let face_identities_table = schema::table_name("face_identities");
let count_query = format!(
"SELECT COUNT(*) FROM {} {}",
face_identities_table, where_clause
);
let list_query = format!(
"SELECT face_id, name, created_at, updated_at, is_active, metadata FROM {} {} ORDER BY created_at DESC LIMIT $1 OFFSET $2",
face_identities_table, where_clause
);
// Get total count
let total: i64 = match sqlx::query_scalar(&count_query).fetch_one(db.pool()).await {
Ok(count) => count,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to count faces: {}", e),
))
}
};
// Get face list
let faces: Vec<FaceListItem> = match sqlx::query_as::<
_,
(
String,
Option<String>,
chrono::DateTime<chrono::Utc>,
chrono::DateTime<chrono::Utc>,
bool,
Option<serde_json::Value>,
),
>(&list_query)
.bind(page_size as i32)
.bind(offset)
.fetch_all(db.pool())
.await
{
Ok(rows) => rows
.into_iter()
.map(
|(face_id, name, created_at, updated_at, is_active, metadata)| FaceListItem {
face_id,
name,
created_at: created_at.to_rfc3339(),
updated_at: updated_at.to_rfc3339(),
is_active,
metadata,
},
)
.collect(),
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to list faces: {}", e),
))
}
};
Ok(Json(FaceListResponse {
success: true,
message: format!("Found {} faces", total),
faces,
count: total,
page,
page_size,
}))
}
async fn get_face_details(
State(_state): State<crate::api::server::AppState>,
Path((file_uuid, face_id)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let db = match PostgresDb::init().await {
Ok(db) => db,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to connect to database: {}", e),
))
}
};
let face_identities_table = schema::table_name("face_identities");
let query = format!(
r#"
SELECT
face_id,
name,
embedding,
attributes,
metadata,
created_at,
updated_at,
is_active
FROM {}
WHERE face_id = $1 AND file_uuid = $2
"#,
face_identities_table
);
let face: Option<(
String,
Option<String>,
Option<String>,
Option<serde_json::Value>,
Option<serde_json::Value>,
chrono::DateTime<chrono::Utc>,
chrono::DateTime<chrono::Utc>,
bool,
)> = match sqlx::query_as(&query)
.bind(&face_id)
.bind(&file_uuid)
.fetch_optional(db.pool())
.await
{
Ok(face) => face,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to fetch face details: {}", e),
))
}
};
match face {
Some((
face_id,
name,
embedding,
attributes,
metadata,
created_at,
updated_at,
is_active,
)) => {
let response = serde_json::json!({
"success": true,
"face_id": face_id,
"name": name,
"has_embedding": embedding.is_some(),
"attributes": attributes,
"metadata": metadata,
"created_at": created_at.to_rfc3339(),
"updated_at": updated_at.to_rfc3339(),
"is_active": is_active
});
Ok(Json(response))
}
None => Err((
StatusCode::NOT_FOUND,
format!("Face not found: {}", face_id),
)),
}
}
async fn delete_face(
State(_state): State<crate::api::server::AppState>,
Path((file_uuid, face_id)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let db = match PostgresDb::init().await {
Ok(db) => db,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to connect to database: {}", e),
))
}
};
// Soft delete by marking as inactive
let face_identities_table = schema::table_name("face_identities");
let query = format!(
r#"
UPDATE {}
SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE face_id = $1 AND file_uuid = $2 AND is_active = TRUE
RETURNING face_id, name
"#,
face_identities_table
);
let deleted: Option<(String, Option<String>)> = match sqlx::query_as(&query)
.bind(&face_id)
.bind(&file_uuid)
.fetch_optional(db.pool())
.await
{
Ok(result) => result,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to delete face: {}", e),
))
}
};
match deleted {
Some((deleted_id, name)) => {
let response = serde_json::json!({
"success": true,
"message": format!("Face '{}' deleted successfully", name.clone().unwrap_or_else(|| deleted_id.clone())),
"face_id": deleted_id.clone()
});
Ok(Json(response))
}
None => Err((
StatusCode::NOT_FOUND,
format!("Face not found or already deleted: {}", face_id),
)),
}
}
async fn get_recognition_results(
State(_state): State<crate::api::server::AppState>,
Path(file_uuid): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let db = match PostgresDb::init().await {
Ok(db) => db,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to connect to database: {}", e),
))
}
};
let query = r#"
SELECT
file_uuid,
frame_count,
fps,
total_faces,
recognized_faces,
clusters_count,
result_data,
processing_time_secs,
created_at
FROM face_recognition_results
WHERE file_uuid = $1
ORDER BY created_at DESC
LIMIT 1
"#;
let result: Option<(
String,
i64,
f64,
i32,
i32,
i32,
serde_json::Value,
Option<f64>,
chrono::DateTime<chrono::Utc>,
)> = match sqlx::query_as(query)
.bind(&file_uuid)
.fetch_optional(db.pool())
.await
{
Ok(result) => result,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to fetch recognition results: {}", e),
))
}
};
match result {
Some((
file_uuid,
frame_count,
fps,
total_faces,
recognized_faces,
clusters_count,
result_data,
processing_time_secs,
created_at,
)) => {
let response = serde_json::json!({
"success": true,
"file_uuid": file_uuid,
"frame_count": frame_count,
"fps": fps,
"total_faces": total_faces,
"recognized_faces": recognized_faces,
"clusters_count": clusters_count,
"result_data": result_data,
"processing_time_secs": processing_time_secs,
"created_at": created_at.to_rfc3339()
});
Ok(Json(response))
}
None => Err((
StatusCode::NOT_FOUND,
format!("No recognition results found for video: {}", file_uuid),
)),
}
}
async fn store_recognition_results(
db: &PostgresDb,
file_uuid: &str,
result: &FaceRecognitionResult,
) -> Result<(), anyhow::Error> {
let total_faces = result.frames.iter().map(|f| f.faces.len()).sum::<usize>();
let recognized_faces = result
.frames
.iter()
.flat_map(|f| &f.faces)
.filter(|face| face.identity.is_some())
.count();
let query = r#"
INSERT INTO face_recognition_results (
file_uuid,
frame_count,
fps,
total_faces,
recognized_faces,
clusters_count,
result_data
) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (file_uuid) DO UPDATE SET
frame_count = EXCLUDED.frame_count,
fps = EXCLUDED.fps,
total_faces = EXCLUDED.total_faces,
recognized_faces = EXCLUDED.recognized_faces,
clusters_count = EXCLUDED.clusters_count,
result_data = EXCLUDED.result_data,
updated_at = CURRENT_TIMESTAMP
"#;
sqlx::query(query)
.bind(file_uuid)
.bind(result.frame_count as i64)
.bind(result.fps)
.bind(total_faces as i32)
.bind(recognized_faces as i32)
.bind(result.face_clusters.len() as i32)
.bind(serde_json::to_value(result)?)
.execute(db.pool())
.await?;
// Store individual face detections
for frame in &result.frames {
for face in &frame.faces {
if let Some(embedding) = &face.embedding {
let embedding_str = format!(
"[{}]",
embedding
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(",")
);
let insert_query = r#"
INSERT INTO face_detections (
file_uuid,
frame_number,
timestamp_secs,
face_id,
x,
y,
width,
height,
confidence,
embedding,
attributes,
identity_confidence,
cluster_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::vector, $11, $12, $13)
ON CONFLICT (file_uuid, frame_number, x, y, width, height) DO UPDATE SET
face_id = EXCLUDED.face_id,
confidence = EXCLUDED.confidence,
embedding = EXCLUDED.embedding,
attributes = EXCLUDED.attributes,
identity_confidence = EXCLUDED.identity_confidence,
cluster_id = EXCLUDED.cluster_id
"#;
let identity_confidence = face.identity.as_ref().map(|id| id.confidence as f64);
let cluster_id = result
.face_clusters
.iter()
.find(|c| {
c.face_ids
.contains(&face.face_id.clone().unwrap_or_default())
})
.map(|c| c.cluster_id.clone());
sqlx::query(insert_query)
.bind(file_uuid)
.bind(frame.frame as i64)
.bind(frame.timestamp)
.bind(face.face_id.as_deref())
.bind(face.x)
.bind(face.y)
.bind(face.width)
.bind(face.height)
.bind(face.confidence as f64)
.bind(&embedding_str)
.bind(serde_json::to_value(&face.attributes)?)
.bind(identity_confidence)
.bind(cluster_id)
.execute(db.pool())
.await?;
}
}
}
// Store face clusters
for cluster in &result.face_clusters {
let centroid = &cluster.centroid;
let centroid_str = format!(
"[{}]",
centroid
.iter()
.map(|v: &f32| v.to_string())
.collect::<Vec<_>>()
.join(",")
);
let cluster_query = r#"
INSERT INTO face_clusters (
cluster_id,
file_uuid,
centroid,
size,
representative_face_id,
metadata
) VALUES ($1, $2, $3::vector, $4, $5, $6)
ON CONFLICT (cluster_id) DO UPDATE SET
centroid = EXCLUDED.centroid,
size = EXCLUDED.size,
representative_face_id = EXCLUDED.representative_face_id,
metadata = EXCLUDED.metadata
"#;
sqlx::query(cluster_query)
.bind(&cluster.cluster_id)
.bind(file_uuid)
.bind(&centroid_str)
.bind(cluster.size as i32)
.bind(cluster.representative_face_id.as_deref())
.bind(&cluster.metadata)
.execute(db.pool())
.await?;
}
Ok(())
}

View File

@@ -12,31 +12,14 @@ use std::process::Command;
use crate::core::db::{Database, PostgresDb};
#[derive(Debug, Deserialize)]
pub struct RegisterFromPersonRequest {
pub file_uuid: String,
pub person_id: String,
pub identity_name: String,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub struct RegisterFromFaceRequest {
pub struct CreateIdentityRequest {
pub face_json_path: String,
pub identity_name: String,
pub schema: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct RegisterFromPersonResponse {
pub success: bool,
pub message: String,
pub identity_id: i32,
pub identity_name: String,
pub person_id: String,
}
#[derive(Debug, Serialize)]
pub struct RegisterFromFaceResponse {
pub struct CreateIdentityResponse {
pub success: bool,
pub message: String,
pub identity_uuid: Option<String>,
@@ -48,26 +31,17 @@ pub struct RegisterFromFaceResponse {
pub fn identity_routes() -> Router<crate::api::server::AppState> {
Router::new()
.route("/api/v1/identities/from-person", post(register_from_person))
.route("/api/v1/identities/from-face", post(register_from_face))
.route("/api/v1/identities", get(list_identities))
.route("/api/v1/identity", post(create_identity))
.route("/api/v1/faces/candidates", get(list_face_candidates))
.route(
"/api/v1/identities/:identity_id/faces",
get(get_identity_faces),
)
.route(
"/api/v1/files/:file_uuid/faces/:face_id/thumbnail",
get(get_face_thumbnail),
)
}
/// Register a Global Identity from face.json with multi-angle reference vectors.
/// Calls select_face_reference_vectors_v2.py for automatic reference selection.
async fn register_from_face(
async fn create_identity(
State(_state): State<crate::api::server::AppState>,
Json(req): Json<RegisterFromFaceRequest>,
) -> Result<Json<RegisterFromFaceResponse>, (StatusCode, String)> {
Json(req): Json<CreateIdentityRequest>,
) -> Result<Json<CreateIdentityResponse>, (StatusCode, String)> {
let schema = req.schema.unwrap_or("dev".to_string());
let python_path =
std::env::var("MOMENTRY_PYTHON_PATH").unwrap_or("/opt/homebrew/bin/python3.11".to_string());
@@ -141,7 +115,7 @@ async fn register_from_face(
})?;
match row {
Some((uuid, total, angles, quality)) => Ok(Json(RegisterFromFaceResponse {
Some((uuid, total, angles, quality)) => Ok(Json(CreateIdentityResponse {
success: true,
message: format!(
"Successfully registered identity '{}' with {} reference vectors",
@@ -154,7 +128,7 @@ async fn register_from_face(
angle_coverage: angles,
quality_avg: quality,
})),
None => Ok(Json(RegisterFromFaceResponse {
None => Ok(Json(CreateIdentityResponse {
success: true,
message: format!(
"Identity '{}' registered, but details not found",
@@ -169,175 +143,6 @@ async fn register_from_face(
}
}
/// Register a Global Identity from a specific Person in a video.
/// This creates/updates the Identity record, links the Person to the Identity,
/// and updates the Person's name to match the Identity.
async fn register_from_person(
State(_state): State<crate::api::server::AppState>,
Json(req): Json<RegisterFromPersonRequest>,
) -> Result<Json<RegisterFromPersonResponse>, (StatusCode, String)> {
let db = match PostgresDb::init().await {
Ok(db) => db,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("DB error: {}", e),
))
}
};
let mut tx = match db.pool().begin().await {
Ok(tx) => tx,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Tx error: {}", e),
))
}
};
// 1. Check if Person exists
let person_query =
"SELECT id, name FROM person_identities WHERE person_id = $1 AND file_uuid = $2";
let person: Option<(i32, Option<String>)> = match sqlx::query_as(person_query)
.bind(&req.person_id)
.bind(&req.file_uuid)
.fetch_optional(&mut *tx)
.await
{
Ok(p) => p,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Query error: {}", e),
))
}
};
let (person_db_id, _old_name) = match person {
Some(p) => p,
None => {
return Err((
StatusCode::NOT_FOUND,
format!(
"Person '{}' not found in video '{}'",
req.person_id, req.file_uuid
),
))
}
};
// 2. Check if Identity exists
let identity_query = "SELECT id FROM identities WHERE name = $1";
let identity_id: Option<i32> = match sqlx::query_scalar(identity_query)
.bind(&req.identity_name)
.fetch_optional(&mut *tx)
.await
{
Ok(id) => id,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Query error: {}", e),
))
}
};
let final_identity_id = if let Some(id) = identity_id {
id
} else {
// Create new Identity
let meta_json = req.metadata.clone().unwrap_or(serde_json::json!({}));
let new_id: i32 = match sqlx::query_scalar(
r#"
INSERT INTO identities (name, embedding, metadata)
VALUES ($1, NULLIF($2, '')::public.vector, $3)
RETURNING id
"#,
)
.bind(&req.identity_name)
.bind("".to_string()) // No embedding for now via this API
.bind(&meta_json)
.fetch_one(&mut *tx)
.await
{
Ok(id) => id,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Insert identity error: {}", e),
))
}
};
new_id
};
// 3. Create Binding
// Columns: id, identity_id, identity_type, identity_value, confidence, metadata, created_at
let binding_query = r#"
INSERT INTO identity_bindings (identity_id, identity_type, identity_value, confidence, metadata)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT DO NOTHING
"#;
match sqlx::query(binding_query)
.bind(final_identity_id)
.bind("person_id") // identity_type
.bind(&req.person_id) // identity_value
.bind(1.0) // confidence
.bind(serde_json::to_string(&serde_json::json!({"auto_updated": true})).unwrap())
.execute(&mut *tx)
.await
{
Ok(_) => {}
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Binding error: {}", e),
))
}
};
// 4. Update Person Name
let update_person = "UPDATE person_identities SET name = $1 WHERE id = $2";
match sqlx::query(update_person)
.bind(&req.identity_name)
.bind(person_db_id)
.execute(&mut *tx)
.await
{
Ok(_) => {}
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Update person error: {}", e),
))
}
};
match tx.commit().await {
Ok(_) => {}
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Commit error: {}", e),
))
}
};
Ok(Json(RegisterFromPersonResponse {
success: true,
message: format!(
"Successfully registered identity '{}' and linked to person '{}'",
req.identity_name, req.person_id
),
identity_id: final_identity_id,
identity_name: req.identity_name,
person_id: req.person_id,
}))
}
/// List all global identities
async fn list_identities(
State(_state): State<crate::api::server::AppState>,
@@ -409,21 +214,6 @@ pub struct ListIdentitiesQuery {
pub page_size: Option<usize>,
}
#[derive(Debug, Serialize)]
pub struct IdentityResponse {
pub id: i32,
pub name: String,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Serialize)]
pub struct IdentityListResponse {
pub identities: Vec<IdentityResponse>,
pub count: i64,
pub page: usize,
pub page_size: usize,
}
#[derive(Debug, Deserialize)]
pub struct FaceCandidatesQuery {
pub file_uuid: Option<String>,
@@ -438,8 +228,8 @@ pub struct FaceCandidate {
pub id: i32,
pub face_id: Option<String>,
pub file_uuid: String,
pub frame_number: i64,
pub confidence: f64,
pub frame_number: i32,
pub confidence: f32,
pub bbox: Option<serde_json::Value>,
pub attributes: Option<serde_json::Value>,
}
@@ -452,29 +242,19 @@ pub struct FaceCandidatesResponse {
pub page_size: usize,
}
#[derive(Debug, Deserialize)]
pub struct IdentityFacesQuery {
pub page: Option<usize>,
pub page_size: Option<usize>,
pub limit: Option<usize>,
}
#[derive(Debug, Serialize)]
pub struct IdentityFace {
pub struct IdentityResponse {
pub id: i32,
pub face_id: Option<String>,
pub file_uuid: String,
pub frame_number: i64,
pub confidence: f64,
pub bbox: Option<serde_json::Value>,
pub attributes: Option<serde_json::Value>,
pub name: String,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Serialize)]
pub struct IdentityFacesResponse {
pub identity_id: i32,
pub faces: Vec<IdentityFace>,
pub total: i64,
pub struct IdentityListResponse {
pub identities: Vec<IdentityResponse>,
pub count: i64,
pub page: usize,
pub page_size: usize,
}
async fn list_face_candidates(
@@ -538,7 +318,9 @@ 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, confidence, bbox, attributes
"SELECT id, face_id, file_uuid, frame_number, confidence,
jsonb_build_object('x', x, 'y', y, 'width', width, 'height', height) as bbox,
NULL::jsonb as attributes
FROM {}
WHERE identity_id IS NULL AND confidence >= $1 AND file_uuid = $2
ORDER BY confidence DESC
@@ -551,8 +333,8 @@ async fn list_face_candidates(
i32,
Option<String>,
String,
i64,
f64,
i32,
f32,
Option<serde_json::Value>,
Option<serde_json::Value>,
),
@@ -574,7 +356,9 @@ async fn list_face_candidates(
}
} else {
let sql = format!(
"SELECT id, face_id, file_uuid, frame_number, confidence, bbox, attributes
"SELECT id, face_id, file_uuid, frame_number, confidence,
jsonb_build_object('x', x, 'y', y, 'width', width, 'height', height) as bbox,
NULL::jsonb as attributes
FROM {}
WHERE identity_id IS NULL AND confidence >= $1
ORDER BY confidence DESC
@@ -587,8 +371,8 @@ async fn list_face_candidates(
i32,
Option<String>,
String,
i64,
f64,
i32,
f32,
Option<serde_json::Value>,
Option<serde_json::Value>,
),
@@ -629,215 +413,3 @@ async fn list_face_candidates(
page_size,
}))
}
async fn get_identity_faces(
axum::extract::Path(identity_id): axum::extract::Path<i32>,
Query(query): Query<IdentityFacesQuery>,
) -> Result<Json<IdentityFacesResponse>, (StatusCode, String)> {
let db = match PostgresDb::init().await {
Ok(db) => db,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to connect to database: {}", e),
))
}
};
let page_size = std::cmp::min(query.page_size.unwrap_or(100), 1000);
let offset = (query.page.unwrap_or(1) - 1) * page_size;
let table = crate::core::db::schema::table_name("face_detections");
let count_sql = format!("SELECT COUNT(*) FROM {} WHERE identity_id = $1", table);
let total: i64 = match sqlx::query_scalar(&count_sql)
.bind(identity_id)
.fetch_one(db.pool())
.await
{
Ok(count) => count,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Count error: {}", e),
))
}
};
let sql = format!(
"SELECT id, face_id, file_uuid, frame_number, confidence, bbox, attributes
FROM {}
WHERE identity_id = $1
ORDER BY confidence DESC
LIMIT $2 OFFSET $3",
table
);
let rows = match sqlx::query_as::<
_,
(
i32,
Option<String>,
String,
i64,
f64,
Option<serde_json::Value>,
Option<serde_json::Value>,
),
>(&sql)
.bind(identity_id)
.bind(page_size as i64)
.bind(offset as i64)
.fetch_all(db.pool())
.await
{
Ok(rows) => rows,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Query error: {}", e),
))
}
};
let faces: Vec<IdentityFace> = rows
.into_iter()
.map(|r| IdentityFace {
id: r.0,
face_id: r.1,
file_uuid: r.2,
frame_number: r.3,
confidence: r.4,
bbox: r.5,
attributes: r.6,
})
.collect();
Ok(Json(IdentityFacesResponse {
identity_id,
faces,
total,
}))
}
async fn get_face_thumbnail(
Path((file_uuid, face_id)): Path<(String, i32)>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let db = match PostgresDb::init().await {
Ok(db) => db,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to connect to database: {}", e),
))
}
};
let table_fd = crate::core::db::schema::table_name("face_detections");
let table_v = crate::core::db::schema::table_name("videos");
let sql = format!(
"SELECT fd.frame_number, fd.bbox, v.file_path, v.fps
FROM {} fd
JOIN {} v ON fd.file_uuid = v.uuid
WHERE fd.id = $1 AND fd.file_uuid = $2",
table_fd, table_v
);
let row: Option<(i64, Option<serde_json::Value>, String, f64)> = match sqlx::query_as(&sql)
.bind(face_id)
.bind(&file_uuid)
.fetch_optional(db.pool())
.await
{
Ok(row) => row,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Query error: {}", e),
))
}
};
let (frame_number, bbox_json, file_path, fps) = match row {
Some(r) => r,
None => return Err((StatusCode::NOT_FOUND, format!("Face {} not found", face_id))),
};
let bbox: Bbox = match bbox_json {
Some(json) => serde_json::from_value(json).unwrap_or(Bbox {
x: 0,
y: 0,
width: 100,
height: 100,
}),
None => Bbox {
x: 0,
y: 0,
width: 100,
height: 100,
},
};
let timestamp = frame_number as f64 / fps;
let crop_filter = format!("crop={}:{}:{}:{}", bbox.width, bbox.height, bbox.x, bbox.y);
let output = match Command::new("ffmpeg")
.args(&[
"-ss",
&timestamp.to_string(),
"-i",
&file_path,
"-vf",
&crop_filter,
"-frames:v",
"1",
"-f",
"image2pipe",
"-vcodec",
"mjpeg",
"-",
])
.output()
{
Ok(o) => o,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("ffmpeg error: {}", e),
))
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("ffmpeg failed: {}", stderr),
));
}
let response = axum::response::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/jpeg")
.header(header::CACHE_CONTROL, "public, max-age=3600")
.body(Body::from(output.stdout))
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Response error: {}", e),
)
})?;
Ok(response)
}
#[derive(Debug, Deserialize)]
struct Bbox {
x: i32,
y: i32,
width: i32,
height: i32,
}

View File

@@ -531,17 +531,17 @@ async fn suggest_merge(
i2.uuid as target_uuid,
i1.name as source_name,
i2.name as target_name,
COUNT(DISTINCT fi1.file_uuid) as shared_files
COUNT(DISTINCT fd1.file_uuid) as shared_files
FROM identities i1
JOIN identities i2 ON i1.id < i2.id
LEFT JOIN file_identities fi1 ON fi1.identity_id = i1.id
LEFT JOIN file_identities fi2 ON fi2.identity_id = i2.id AND fi1.file_uuid = fi2.file_uuid
LEFT JOIN face_detections fd1 ON fd1.identity_id = i1.id
LEFT JOIN face_detections fd2 ON fd2.identity_id = i2.id AND fd1.file_uuid = fd2.file_uuid
WHERE i1.identity_type = 'people'
AND i2.identity_type = 'people'
AND i1.id != i2.id
{}
GROUP BY i1.uuid, i2.uuid, i1.name, i2.name
HAVING COUNT(DISTINCT fi1.file_uuid) > 0
HAVING COUNT(DISTINCT fd1.file_uuid) > 0
ORDER BY shared_files DESC
LIMIT 50
"#,

View File

@@ -13,13 +13,25 @@ use crate::core::db::ResourceRecord;
pub fn identity_routes() -> Router<crate::api::server::AppState> {
Router::new()
.route("/api/v1/files", get(list_files))
.route("/api/v1/files/:uuid", get(get_file_detail))
.route("/api/v1/files/:uuid/identities", get(get_file_identities))
.route("/api/v1/identities/:uuid", get(get_identity_detail))
.route("/api/v1/identities/:uuid/files", get(get_identity_files))
.route("/api/v1/identities/:uuid/chunks", get(get_identity_chunks))
.route("/api/v1/resources/register", post(register_resource))
.route("/api/v1/resources/heartbeat", post(heartbeat_resource))
.route("/api/v1/file/:file_uuid", get(get_file_detail))
.route(
"/api/v1/file/:file_uuid/identities",
get(get_file_identities),
)
.route(
"/api/v1/identity/:identity_uuid",
get(get_identity_detail).delete(delete_identity),
)
.route(
"/api/v1/identity/:identity_uuid/files",
get(get_identity_files),
)
.route(
"/api/v1/identity/:identity_uuid/chunks",
get(get_identity_chunks),
)
.route("/api/v1/resource/register", post(register_resource))
.route("/api/v1/resource/heartbeat", post(heartbeat_resource))
.route("/api/v1/resources", get(list_resources))
}
@@ -124,11 +136,11 @@ pub struct FileDetailResponse {
async fn get_file_detail(
State(state): State<crate::api::server::AppState>,
Path(uuid): Path<String>,
Path(file_uuid): Path<String>,
) -> Result<Json<FileDetailResponse>, (StatusCode, String)> {
let file = state
.db
.get_file_by_uuid(&uuid)
.get_file_by_uuid(&file_uuid)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -141,7 +153,10 @@ async fn get_file_detail(
metadata: f.probe_json,
created_at: f.created_at,
})),
None => Err((StatusCode::NOT_FOUND, format!("File not found: {}", uuid))),
None => Err((
StatusCode::NOT_FOUND,
format!("File not found: {}", file_uuid),
)),
}
}
@@ -169,7 +184,7 @@ pub struct FileIdentityItem {
async fn get_file_identities(
State(state): State<crate::api::server::AppState>,
Path(uuid): Path<String>,
Path(file_uuid): Path<String>,
Query(params): Query<FilesQuery>,
) -> Result<Json<FileIdentitiesResponse>, (StatusCode, String)> {
let page = params.page.unwrap_or(1);
@@ -178,7 +193,7 @@ async fn get_file_identities(
let records = state
.db
.get_file_identities(&uuid, page_size as i32, offset)
.get_file_identities(&file_uuid, page_size as i32, offset)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -198,7 +213,7 @@ async fn get_file_identities(
Ok(Json(FileIdentitiesResponse {
success: true,
file_uuid: uuid,
file_uuid: file_uuid,
total: data.len() as i64,
page,
page_size,
@@ -222,10 +237,15 @@ pub struct IdentityDetailResponse {
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
fn strip_uuid(u: &uuid::Uuid) -> String {
u.to_string().replace('-', "")
}
async fn get_identity_detail(
State(state): State<crate::api::server::AppState>,
Path(uuid_str): Path<String>,
Path(identity_uuid): Path<String>,
) -> Result<Json<IdentityDetailResponse>, (StatusCode, String)> {
let uuid_str = identity_uuid;
let uuid = Uuid::parse_str(&uuid_str)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?;
@@ -267,6 +287,45 @@ pub struct IdentityFilesResponse {
pub data: Vec<IdentityFileItem>,
}
async fn delete_identity(
State(state): State<crate::api::server::AppState>,
Path(identity_uuid): Path<String>,
) -> Result<StatusCode, StatusCode> {
let table = crate::core::db::schema::table_name("face_detections");
let id_table = crate::core::db::schema::table_name("identities");
// Get identity_id from identity_uuid
let row: Option<(i32,)> = sqlx::query_as(&format!(
"SELECT id FROM {} WHERE replace(uuid::text, '-', '') = $1",
id_table
))
.bind(&identity_uuid)
.fetch_optional(state.db.pool())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let (identity_id,) = row.ok_or(StatusCode::NOT_FOUND)?;
// Unbind all faces
sqlx::query(&format!(
"UPDATE {} SET identity_id = NULL, identity_confidence = NULL WHERE identity_id = $1",
table
))
.bind(identity_id)
.execute(state.db.pool())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Delete identity
sqlx::query(&format!("DELETE FROM {} WHERE id = $1", id_table))
.bind(identity_id)
.execute(state.db.pool())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Serialize)]
pub struct IdentityFileItem {
pub file_uuid: String,
@@ -282,9 +341,10 @@ pub struct IdentityFileItem {
async fn get_identity_files(
State(state): State<crate::api::server::AppState>,
Path(uuid_str): Path<String>,
Path(identity_uuid): Path<String>,
Query(params): Query<FilesQuery>,
) -> Result<Json<IdentityFilesResponse>, (StatusCode, String)> {
let uuid_str = identity_uuid;
let uuid = Uuid::parse_str(&uuid_str)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?;
@@ -421,9 +481,10 @@ pub struct IdentityChunkItem {
async fn get_identity_chunks(
State(state): State<crate::api::server::AppState>,
Path(uuid_str): Path<String>,
Path(identity_uuid): Path<String>,
Query(params): Query<FilesQuery>,
) -> Result<Json<IdentityChunksResponse>, (StatusCode, String)> {
let uuid_str = identity_uuid;
let uuid = Uuid::parse_str(&uuid_str)
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?;

View File

@@ -8,7 +8,9 @@ use axum::{
use serde::{Deserialize, Serialize};
use crate::core::db::{Database, PostgresDb};
use crate::core::person_identity::{BindIdentityRequest, Identity, UnbindIdentityRequest};
use crate::core::person_identity::{
BindIdentityRequest, Identity, MergeIdentitiesRequest, UnbindIdentityRequest,
};
#[derive(Debug, Clone, Serialize)]
pub struct ApiResponse<T: Serialize> {
@@ -56,323 +58,223 @@ pub async fn list_identities(
}))
}
/// 綁定身份 (Face/Speaker -> Identity)
/// V4.0 直接綁定face_detections.identity_id = identities.id
pub async fn bind_identity(
Path(identity_uuid): Path<String>,
Json(req): Json<BindIdentityRequest>,
) -> Result<Json<ApiResponse<()>>, (StatusCode, Json<serde_json::Value>)> {
let db = get_db().await?;
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
let table = crate::core::db::schema::table_name("face_detections");
let id_table = crate::core::db::schema::table_name("identities");
let identity = if let Some(id_id) = req.identity_id {
db.get_identity_by_id(id_id).await.ok().flatten()
} else if let Some(name) = &req.name {
db.get_or_create_identity(name).await.ok()
} else {
None
};
let db = sqlx::PgPool::connect(&crate::core::config::DATABASE_URL)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
let identity = match identity {
Some(t) => t,
None => {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "Identity not found or name required" })),
));
}
};
let source = req.source.unwrap_or("manual".to_string());
db.bind_identity(
identity.id as i64,
&req.binding_type,
&req.binding_value,
&source,
1.0,
)
// Get identity_id from identity_uuid
let identity_row: Option<(i32, String)> = sqlx::query_as(&format!(
"SELECT id, name FROM {} WHERE uuid = $1::uuid",
id_table
))
.bind(&identity_uuid)
.fetch_optional(&db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
Ok(Json(ApiResponse {
success: true,
message: format!(
"Bound {} '{}' to Identity '{}'",
req.binding_type, req.binding_value, identity.name
),
data: None,
}))
}
/// 解綁身份
pub async fn unbind_identity(
Json(req): Json<UnbindIdentityRequest>,
) -> Result<Json<ApiResponse<()>>, (StatusCode, Json<serde_json::Value>)> {
let db = get_db().await?;
db.unbind_identity(&req.binding_type, &req.binding_value)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
Ok(Json(ApiResponse {
success: true,
message: format!("Unbound {} '{}'", req.binding_type, req.binding_value),
data: None,
}))
}
/// 查詢機器 ID 對應的 Identity (人物)
pub async fn get_identity_info(
Path((binding_type, binding_value)): Path<(String, String)>,
) -> Result<Json<ApiResponse<Identity>>, (StatusCode, Json<serde_json::Value>)> {
let db = get_db().await?;
let identity = db
.get_identity_by_binding(&binding_type, &binding_value)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
let identity = identity.ok_or_else(|| {
let (identity_id, name) = identity_row.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Identity not found" })),
Json(serde_json::json!({"error": format!("Identity not found: {}", identity_uuid)})),
)
})?;
// Direct UPDATE face_detections.identity_id
let result = sqlx::query(&format!(
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND face_id = $3",
table
))
.bind(identity_id)
.bind(&req.file_uuid)
.bind(&req.face_id)
.execute(&db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
Ok(Json(ApiResponse {
success: true,
message: "Identity info retrieved".to_string(),
data: Some(identity),
}))
}
/// 列出未綁定的信號 (待標註列表)
pub async fn list_unbound_signals(
Query(params): Query<ListSignalsParams>,
) -> Result<Json<ApiResponse<Vec<String>>>, (StatusCode, Json<serde_json::Value>)> {
let db = get_db().await?;
let signals = db
.list_unbound_signals(&params.uuid, &params.binding_type)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
Ok(Json(ApiResponse {
success: true,
message: format!(
"Found {} unbound {} signals",
signals.len(),
params.binding_type
"Bound face {} of {} to {}",
req.face_id, req.file_uuid, name
),
data: Some(signals),
data: Some(serde_json::json!({"rows_affected": result.rows_affected()})),
}))
}
/// 獲取特定信號 (Face ID 或 Speaker ID) 出現的所有 Chunk (時間軸)
pub async fn get_signal_timeline(
Path((uuid, binding_type, binding_value)): Path<(String, String, String)>,
) -> Result<Json<ApiResponse<Vec<serde_json::Value>>>, (StatusCode, Json<serde_json::Value>)> {
let db = get_db().await?;
let chunks = db
.get_chunks_by_signal(&uuid, &binding_type, &binding_value)
/// V4.0 直接解綁SET face_detections.identity_id = NULL
pub async fn unbind_identity(
Json(req): Json<UnbindIdentityRequest>,
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
let table = crate::core::db::schema::table_name("face_detections");
let db = sqlx::PgPool::connect(&crate::core::config::DATABASE_URL)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
let result = sqlx::query(&format!(
"UPDATE {} SET identity_id = NULL WHERE file_uuid = $1 AND face_id = $2",
table
))
.bind(&req.file_uuid)
.bind(&req.face_id)
.execute(&db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
Ok(Json(ApiResponse {
success: true,
message: format!(
"Found {} chunks for {} '{}'",
chunks.len(),
binding_type,
binding_value
),
data: Some(chunks),
message: format!("Unbound face {} from {}", req.face_id, req.file_uuid),
data: Some(serde_json::json!({"rows_affected": result.rows_affected()})),
}))
}
#[derive(Debug, Deserialize)]
pub struct AVSuggestRequest {
pub file_uuid: String,
pub overlap_threshold: Option<f64>, // default 0.6
}
/// V4.0 合併:將 identity A 合併入 identity BA 被刪除
pub async fn merge_identities(
Path(from_uuid): Path<String>,
Json(req): Json<MergeIdentitiesRequest>,
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
let face_table = crate::core::db::schema::table_name("face_detections");
let id_table = crate::core::db::schema::table_name("identities");
#[derive(Debug, Serialize)]
pub struct AVSuggestion {
pub face_id: String,
pub speaker_id: String,
pub overlap_score: f64,
pub face_talent_id: Option<i32>,
pub speaker_talent_id: Option<i32>,
}
/// Suggests Face-Speaker bindings based on temporal overlap
pub async fn suggest_audio_visual_bindings(
Json(req): Json<AVSuggestRequest>,
) -> Result<Json<ApiResponse<Vec<AVSuggestion>>>, (StatusCode, Json<serde_json::Value>)> {
let db = get_db().await?;
let threshold = req.overlap_threshold.unwrap_or(0.6);
// 1. Get Face signals and their time ranges
let face_signals = db
.list_unbound_signals(&req.file_uuid, "face")
let db = sqlx::PgPool::connect(&crate::core::config::DATABASE_URL)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("Face signals: {}", e) })),
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
let speaker_signals = db
.list_unbound_signals(&req.file_uuid, "speaker")
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("Speaker signals: {}", e) })),
)
})?;
// Get IDs for both identities
let from_row: Option<(i32, String)> = sqlx::query_as(&format!(
"SELECT id, name FROM {} WHERE uuid = $1::uuid",
id_table
))
.bind(&from_uuid)
.fetch_optional(&db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
let (from_id, from_name) = from_row.ok_or((
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Source identity not found"})),
))?;
let mut suggestions = Vec::new();
let into_row: Option<(i32, String)> = sqlx::query_as(&format!(
"SELECT id, name FROM {} WHERE uuid = $1::uuid",
id_table
))
.bind(&req.into_uuid)
.fetch_optional(&db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
let (into_id, into_name) = into_row.ok_or((
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Target identity not found"})),
))?;
for face_id in &face_signals {
for speaker_id in &speaker_signals {
// Calculate overlap
// In a real implementation, we would query the exact timestamps from DB.
// For now, we'll use a placeholder or simple heuristic if timestamps are available in signal timeline.
// Let's assume we fetch timelines.
// Transfer all face bindings from source → target
let updated = sqlx::query(&format!(
"UPDATE {} SET identity_id = $1 WHERE identity_id = $2",
face_table
))
.bind(into_id)
.bind(from_id)
.execute(&db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
// Placeholder: Calculate overlap by fetching timelines
let face_timeline = db
.get_chunks_by_signal(&req.file_uuid, "face", face_id)
.await
.unwrap_or_default();
let speaker_timeline = db
.get_chunks_by_signal(&req.file_uuid, "speaker", speaker_id)
.await
.unwrap_or_default();
let keep = req.keep_history.unwrap_or(true);
// Simplified overlap calculation based on chunk count/ids (assuming chunk_id contains time info or we have ranges)
// Since chunk IDs are generic, we rely on content JSON having 'start_time'
let overlap = calculate_overlap(&face_timeline, &speaker_timeline);
if overlap >= threshold {
// Check if they are already bound to the same identity
let face_identity = db
.get_identity_by_binding("face", face_id)
.await
.ok()
.flatten();
let speaker_identity = db
.get_identity_by_binding("speaker", speaker_id)
.await
.ok()
.flatten();
// If both bound to different identities, don't suggest (conflict)
if let (Some(fi), Some(si)) = (&face_identity, &speaker_identity) {
if fi.id != si.id {
continue;
}
}
suggestions.push(AVSuggestion {
face_id: face_id.clone(),
speaker_id: speaker_id.clone(),
overlap_score: overlap,
face_talent_id: face_identity.as_ref().map(|i| i.id as i32),
speaker_talent_id: speaker_identity.as_ref().map(|i| i.id as i32),
});
}
}
}
suggestions.sort_by(|a, b| b.overlap_score.partial_cmp(&a.overlap_score).unwrap());
Ok(Json(ApiResponse {
success: true,
message: format!("Found {} AV suggestions", suggestions.len()),
data: Some(suggestions),
}))
}
fn calculate_overlap(
face_chunks: &[serde_json::Value],
speaker_chunks: &[serde_json::Value],
) -> f64 {
// Simplified: Extract start/end times and calculate intersection over union
// Assuming chunks have start_frame or start_time in content JSON
// If content is raw string, we might need to parse it.
// In our schema, content is JSONB.
let mut face_ranges: Vec<(f64, f64)> = Vec::new();
for c in face_chunks {
if let Some(content) = c.get("content") {
if let (Some(start), Some(end)) = (
content.get("start_time").and_then(|v| v.as_f64()),
content.get("end_time").and_then(|v| v.as_f64()),
) {
face_ranges.push((start, end));
}
}
}
let mut speaker_ranges: Vec<(f64, f64)> = Vec::new();
for c in speaker_chunks {
if let Some(content) = c.get("content") {
if let (Some(start), Some(end)) = (
content.get("start_time").and_then(|v| v.as_f64()),
content.get("end_time").and_then(|v| v.as_f64()),
) {
speaker_ranges.push((start, end));
}
}
}
let mut overlap_duration = 0.0;
for (fs, fe) in &face_ranges {
for (ss, se) in &speaker_ranges {
let start = fs.max(*ss);
let end = fe.min(*se);
if start < end {
overlap_duration += end - start;
}
}
}
// Return normalized overlap (0.0 to 1.0+), simple version: overlap / min_duration
let min_duration = face_ranges
.iter()
.map(|(_, e)| e)
.sum::<f64>()
.min(speaker_ranges.iter().map(|(_, e)| e).sum::<f64>());
if min_duration > 0.0 {
(overlap_duration / min_duration).min(1.0)
if keep {
// Mark as merged, keep record
sqlx::query(&format!(
"UPDATE {} SET status = 'merged', metadata = COALESCE(metadata, '{{}}'::jsonb) || jsonb_build_object('merged_into', $1) WHERE id = $2",
id_table
))
.bind(&req.into_uuid).bind(from_id)
.execute(&db).await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))))?;
} else {
0.0
// Delete source identity
sqlx::query(&format!("DELETE FROM {} WHERE id = $1", id_table))
.bind(from_id)
.execute(&db)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?;
}
Ok(Json(ApiResponse {
success: true,
message: format!(
"Merged '{}' into '{}' ({} faces transferred, {})",
from_name,
into_name,
updated.rows_affected(),
if keep {
"history kept"
} else {
"source deleted"
}
),
data: Some(serde_json::json!({"faces_transferred": updated.rows_affected()})),
}))
}
// ============================================================================
// Router Setup
// ============================================================================
// Router Setup
// ============================================================================
@@ -384,29 +286,15 @@ pub struct ListIdentitiesParams {
pub offset: Option<i32>,
}
#[derive(Debug, Deserialize)]
pub struct ListSignalsParams {
pub uuid: String,
pub binding_type: String, // "face" or "speaker"
}
pub fn identity_binding_routes() -> Router<crate::api::server::AppState> {
Router::new()
.route("/api/v1/identities/bind", post(bind_identity))
.route("/api/v1/identities/unbind", post(unbind_identity))
.route("/api/v1/identity/:identity_uuid/bind", post(bind_identity))
.route(
"/api/v1/identity/:binding_type/:binding_value",
get(get_identity_info),
)
// 信號發現 (Discovery)
.route("/api/v1/signals/unbound", get(list_unbound_signals))
// 信號時間軸 (Timeline)
.route(
"/api/v1/signals/:uuid/:binding_type/:binding_value/timeline",
get(get_signal_timeline),
"/api/v1/identity/:identity_uuid/unbind",
post(unbind_identity),
)
.route(
"/api/v1/identities/suggest-av",
post(suggest_audio_visual_bindings),
"/api/v1/identity/:from_uuid/mergeinto",
post(merge_identities),
)
}

402
src/api/media_api.rs Normal file
View File

@@ -0,0 +1,402 @@
use axum::{
body::Body,
extract::{Path, Query, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
routing::get,
Router,
};
use crate::core::db::{schema, PostgresDb};
pub fn bbox_routes() -> Router<crate::api::server::AppState> {
Router::new()
.route(
"/api/v1/file/:file_uuid/video/bbox",
get(bbox_overlay_video),
)
.route("/api/v1/file/:file_uuid/video", get(stream_video))
.route("/api/v1/file/:file_uuid/thumbnail", get(face_thumbnail))
}
/// 5×7 bitmap font — each character 5 wide × 7 tall
/// Encoding: col 0=0x10, col 1=0x08, col 2=0x04, col 3=0x02, col 4=0x01
fn bitmap_char(c: char) -> [u8; 7] {
match c.to_ascii_lowercase() {
'0' => [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E],
'1' => [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E],
'2' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F],
'3' => [0x0E, 0x11, 0x01, 0x06, 0x01, 0x11, 0x0E],
'4' => [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02],
'5' => [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E],
'6' => [0x0E, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x0E],
'7' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x10],
'8' => [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E],
'9' => [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x11, 0x0E],
'a' => [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
'b' => [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E],
'c' => [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E],
'd' => [0x1C, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1C],
'e' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F],
'f' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10],
't' => [0x04, 0x04, 0x1F, 0x04, 0x04, 0x04, 0x06],
'_' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F],
' ' => [0x00; 7],
_ => [0x00; 7],
}
}
/// Width of one character in pixels (5 cols × 3px/dot = 15px)
const CHAR_W: i32 = 5 * 3;
/// Spacing between characters (px)
const CHAR_GAP: i32 = 4;
/// Total advance per character
const CHAR_ADVANCE: i32 = CHAR_W + CHAR_GAP;
fn render_text(
parts: &mut Vec<String>,
text: &str,
origin_x: i32,
origin_y: i32,
enable: Option<i32>,
) -> i32 {
let mut px = origin_x;
for ch in text.chars() {
let bm = bitmap_char(ch);
for (row, bits) in bm.iter().enumerate() {
for col in 0..5 {
if bits & (1 << (4 - col)) != 0 {
let x = px + col as i32 * 3;
let y = origin_y + row as i32 * 3;
if let Some(offset) = enable {
parts.push(format!(
"drawbox=x={}:y={}:w=3:h=3:color=white@1.0:t=fill:enable='eq(n,{})'",
x, y, offset
));
} else {
parts.push(format!(
"drawbox=x={}:y={}:w=3:h=3:color=white@1.0:t=fill",
x, y
));
}
}
}
}
px += CHAR_ADVANCE;
}
px
}
#[derive(Debug, serde::Deserialize)]
struct BboxParams {
start: Option<i32>,
end: Option<i32>,
face_uuid: Option<String>,
duration: Option<f64>,
}
async fn bbox_overlay_video(
State(state): State<crate::api::server::AppState>,
Path(file_uuid): Path<String>,
Query(p): Query<BboxParams>,
) -> Result<impl IntoResponse, StatusCode> {
let videos_table = schema::table_name("videos");
let row: Option<(String,)> = sqlx::query_as(&format!(
"SELECT file_path FROM {} WHERE file_uuid = $1",
videos_table
))
.bind(&file_uuid)
.fetch_optional(state.db.pool())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let (video_path,) = row.ok_or(StatusCode::NOT_FOUND)?;
let start_f = p.start.unwrap_or(0);
let end_f = p.end.unwrap_or(i32::MAX);
let face_fuid = p.face_uuid.as_deref().unwrap_or(&file_uuid);
let duration = p.duration.unwrap_or(10.0);
// Get FPS
let fps: f64 = sqlx::query_scalar(&format!(
"SELECT COALESCE(fps, 24.0) FROM {} WHERE file_uuid = $1",
videos_table
))
.bind(&file_uuid)
.fetch_optional(state.db.pool())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.unwrap_or(24.0);
let start_sec = start_f as f64 / fps;
// Get face bboxes
let face_table = schema::table_name("face_detections");
let rows: Vec<(i32, 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)
.fetch_all(state.db.pool()).await
.unwrap_or_else(|e| { tracing::error!("bbox query error: {}", e); vec![] });
// Build filters
let mut parts: Vec<String> = Vec::new();
let mut is_first = true;
for (frame, x, y, w, h, trace_id, _) in &rows {
let text = format!("t{}", trace_id.unwrap_or(0));
if is_first {
is_first = false;
// Persistent bbox: thin pale red border
parts.push(format!(
"drawbox=x={}:y={}:w={}:h={}:color=red@0.3:thickness=4",
x, y, w, h
));
// Always-on text: top-left of bbox with padding
let tx = *x + 6;
let ty = *y + 6;
render_text(&mut parts, &text, tx, ty, None);
} else {
let offset = frame - start_f;
// Per-frame bbox: thick bright red
parts.push(format!(
"drawbox=x={}:y={}:w={}:h={}:color=red@0.8:thickness=8:enable='eq(n,{})'",
x, y, w, h, offset
));
// Per-frame text
let tx = *x + 6;
let ty = *y + 6;
render_text(&mut parts, &text, tx, ty, Some(offset));
}
}
let vf = if parts.is_empty() {
"null".to_string()
} else {
parts.join(",")
};
let tmp = std::env::temp_dir().join(format!("bbox_{}.mp4", uuid::Uuid::new_v4()));
let tmp_str = tmp.to_str().unwrap_or("").to_string();
let status = std::process::Command::new("ffmpeg")
.args([
"-ss",
&start_sec.to_string(),
"-i",
&video_path,
"-t",
&duration.to_string(),
"-vf",
&vf,
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-crf",
"28",
"-an",
"-movflags",
"+faststart",
"-y",
&tmp_str,
])
.status()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !status.success() {
let _ = std::fs::remove_file(&tmp);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
let data = tokio::fs::read(&tmp)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let _ = std::fs::remove_file(&tmp);
Ok(Response::builder()
.header(header::CONTENT_TYPE, "video/mp4")
.header(header::CONTENT_LENGTH, data.len())
.body(Body::from(data))
.unwrap())
}
fn parse_range(range: &str, file_size: u64) -> (u64, u64) {
let r = range.trim_start_matches("bytes=");
let parts: Vec<&str> = r.split('-').collect();
let start = parts[0].parse::<u64>().unwrap_or(0);
let end = if parts.len() > 1 && !parts[1].is_empty() {
parts[1].parse::<u64>().unwrap_or(file_size - 1)
} else {
file_size - 1
};
(start.min(file_size - 1), end.min(file_size - 1))
}
async fn stream_video(
State(state): State<crate::api::server::AppState>,
Path(file_uuid): Path<String>,
Query(params): Query<std::collections::HashMap<String, String>>,
request: axum::http::Request<Body>,
) -> Result<impl IntoResponse, StatusCode> {
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let videos_table = schema::table_name("videos");
let row: Option<(String,)> = sqlx::query_as(&format!(
"SELECT file_path 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 src = std::path::PathBuf::from(&file_path);
if !src.exists() {
return Err(StatusCode::NOT_FOUND);
}
// Chunk extraction with start/end params
if let (Some(s), Some(e)) = (params.get("start"), params.get("end")) {
let start: f64 = s.parse().unwrap_or(0.0);
let end: f64 = e.parse().unwrap_or(0.0);
let dur = end - start;
if dur <= 0.0 {
return Err(StatusCode::BAD_REQUEST);
}
let tmp = std::env::temp_dir().join(format!("chunk_{}.mp4", uuid::Uuid::new_v4()));
let tmp_str = tmp.to_str().unwrap_or("").to_string();
let status = std::process::Command::new("ffmpeg")
.args([
"-ss",
&start.to_string(),
"-i",
&file_path,
"-t",
&dur.to_string(),
"-c",
"copy",
"-movflags",
"+faststart",
"-y",
&tmp_str,
])
.status()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !status.success() {
let _ = std::fs::remove_file(&tmp);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
let data = tokio::fs::read(&tmp)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let _ = std::fs::remove_file(&tmp);
return Ok(Response::builder()
.header(header::CONTENT_TYPE, "video/mp4")
.header(header::CONTENT_LENGTH, data.len())
.body(Body::from(data))
.unwrap());
}
let file_size = src.metadata().map(|m| m.len()).unwrap_or(0);
let content_type = "video/mp4";
let range_hdr = request
.headers()
.get(header::RANGE)
.and_then(|v| v.to_str().ok());
if let Some(range_str) = range_hdr {
let (start, end) = parse_range(range_str, file_size);
let length = end - start + 1;
let mut file = tokio::fs::File::open(&src)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
file.seek(std::io::SeekFrom::Start(start))
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let take = file.take(length);
let stream = tokio_util::io::ReaderStream::new(take);
let body = Body::from_stream(stream);
Ok(Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, content_type)
.header(
header::CONTENT_RANGE,
format!("bytes {}-{}/{}", start, end, file_size),
)
.header(header::CONTENT_LENGTH, length)
.body(body)
.unwrap())
} else {
let file = tokio::fs::File::open(&src)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let stream = tokio_util::io::ReaderStream::new(file);
let body = Body::from_stream(stream);
Ok(Response::builder()
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_LENGTH, file_size)
.header(header::ACCEPT_RANGES, "bytes")
.body(body)
.unwrap())
}
}
#[derive(Debug, serde::Deserialize)]
struct ThumbQuery {
frame: i64,
x: Option<i32>,
y: Option<i32>,
w: Option<i32>,
h: Option<i32>,
}
async fn face_thumbnail(
State(state): State<crate::api::server::AppState>,
Path(file_uuid): Path<String>,
Query(q): Query<ThumbQuery>,
) -> Result<impl IntoResponse, StatusCode> {
let videos_table = schema::table_name("videos");
let row: Option<(String,)> = sqlx::query_as(&format!(
"SELECT file_path 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 select = format!("select=eq(n\\,{})", q.frame);
let vf = if let (Some(x), Some(y), Some(w), Some(h)) = (q.x, q.y, q.w, q.h) {
format!("{},crop={}:{}:{}:{}", select, w, h, x, y)
} else {
select
};
let output = std::process::Command::new("ffmpeg")
.args([
"-i",
&file_path,
"-vf",
&vf,
"-frames:v",
"1",
"-f",
"image2pipe",
"-vcodec",
"mjpeg",
"-",
])
.output()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !output.status.success() {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/jpeg")
.header(header::CACHE_CONTROL, "public, max-age=86400")
.body(Body::from(output.stdout))
.unwrap())
}

View File

@@ -1,18 +1,15 @@
pub mod agent_api;
pub mod face_recognition;
pub mod five_w1h_agent_api;
pub mod identities;
pub mod identity_agent_api;
pub mod identity_api;
pub mod identity_binding;
pub mod media_api;
pub mod middleware;
pub mod n8n_search;
pub mod person_identity;
pub mod search;
pub mod server;
pub mod snapshot_api;
pub mod trace_agent_api;
pub mod universal_search;
pub mod visual_chunk_search;
pub mod who;
pub use server::start_server;

View File

@@ -1,260 +0,0 @@
use crate::core::db::{Bm25Result, PostgresDb};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug, Deserialize)]
pub struct SmartSearchRequest {
pub query: String,
pub uuid: Option<String>,
pub limit: Option<usize>,
}
#[derive(Debug, Serialize)]
pub struct SmartSearchResponse {
pub query: String,
pub parsed_dimensions: serde_json::Value,
pub hits: Vec<serde_json::Value>,
pub total: usize,
}
#[derive(Debug, Deserialize, Serialize)]
struct LlmDimensionResponse {
pub who: Option<String>,
pub what: Option<String>,
pub when: Option<String>,
pub r#where: Option<String>,
pub why: Option<String>,
#[serde(default)]
pub keywords: Vec<String>,
}
/// POST /api/v1/n8n/search/smart
pub async fn n8n_search_smart(
db: &PostgresDb,
req: SmartSearchRequest,
) -> Result<SmartSearchResponse, Box<dyn std::error::Error + Send + Sync>> {
let limit = req.limit.unwrap_or(10);
let file_uuid = req.uuid.clone();
// 1. Call LLM to extract 5W1H (Fallback to keywords if LLM fails)
let dimensions = match parse_query_with_llm(&req.query).await {
Some(dims) => dims,
None => LlmDimensionResponse {
who: None,
what: None,
when: None,
r#where: None,
why: None,
keywords: extract_keywords(&req.query),
},
};
// Prepare search terms based on dimensions
let keywords = dimensions.keywords.join(" ");
let semantic_query = [
dimensions.who.clone(),
dimensions.what.clone(),
dimensions.r#where.clone(),
dimensions.why.clone(),
dimensions.when.clone(),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" ");
// 2. Multi-dimensional Search
let mut hits: Vec<serde_json::Value> = Vec::new();
let mut seen_chunk_ids: HashSet<String> = HashSet::new();
// Helper function
fn add_hit(
hits: &mut Vec<serde_json::Value>,
seen_chunk_ids: &mut HashSet<String>,
sr: Bm25Result,
boost: f32,
) {
if seen_chunk_ids.insert(sr.chunk_id.clone()) {
let score = sr.bm25_score * boost;
let val = serde_json::json!({
"id": sr.chunk_id,
"vid": sr.uuid,
"start": sr.start_time,
"end": sr.end_time,
"text": sr.text,
"score": score,
"chunk_type": sr.chunk_type
});
hits.push(val);
}
}
// A. Keyword Search (BM25)
if !keywords.is_empty() {
if let Ok(results) = db.search_bm25(&keywords, file_uuid.as_deref(), limit).await {
for sr in results {
add_hit(&mut hits, &mut seen_chunk_ids, sr, 1.0);
}
}
}
// B. Who Search (Person Matching)
if let Some(who_query) = &dimensions.who {
// 1. Search Person
if let Ok(persons) = db.search_person_candidates(who_query, &file_uuid, 5).await {
if !persons.is_empty() {
let person_id = persons[0]
.get("candidate_id")
.and_then(|v| v.as_str())
.map(String::from);
if let Some(pid) = person_id {
// Heuristic: Search BM25 for the person's ID or Name
let person_name = persons[0]
.get("display_name")
.and_then(|v| v.as_str())
.unwrap_or(who_query);
// Re-run BM25 with person name to find specific chunks and boost them
if let Ok(results) = db
.search_bm25(person_name, file_uuid.as_deref(), limit)
.await
{
for sr in results {
let id = sr.chunk_id.clone();
if seen_chunk_ids.insert(id) {
let score = sr.bm25_score * 1.5;
let val = serde_json::json!({
"id": sr.chunk_id,
"vid": sr.uuid,
"start": sr.start_time,
"end": sr.end_time,
"text": sr.text,
"score": score,
"matched_person": pid
});
hits.push(val);
}
}
}
}
}
}
}
// Sort by score
hits.sort_by(|a, b| {
let score_a = a.get("score").and_then(|v| v.as_f64()).unwrap_or(0.0);
let score_b = b.get("score").and_then(|v| v.as_f64()).unwrap_or(0.0);
score_b.partial_cmp(&score_a).unwrap()
});
// Limit
hits.truncate(limit);
let total = hits.len();
Ok(SmartSearchResponse {
query: req.query,
parsed_dimensions: serde_json::json!(dimensions),
hits,
total,
})
}
fn extract_keywords(query: &str) -> Vec<String> {
// Simple keyword extraction: remove common stop words and punctuation
let stop_words = [
"who", "what", "where", "when", "why", "how", "is", "the", "a", "an", "and", "or", "of",
"in", "to", "for", "with", "by", "on", "at", "from", "up", "about", "into", "over",
"after",
];
query
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { ' ' })
.collect::<String>()
.split_whitespace()
.filter(|w| !stop_words.contains(w))
.map(String::from)
.collect()
}
async fn parse_query_with_llm(query: &str) -> Option<LlmDimensionResponse> {
let client = reqwest::Client::new();
// Test connectivity first
if let Ok(resp) = client.get("http://127.0.0.1:8081/health").send().await {
tracing::info!("LLM Health Check: {}", resp.status());
} else {
tracing::error!("LLM Server is unreachable at 127.0.0.1:8081");
}
// We use the OpenAI-compatible endpoint provided by llama.cpp server (default port 8081)
let prompt = format!(
r#"Analyze the user query and extract the following dimensions into a JSON object.
If a dimension is not present, use null.
Dimensions: "who" (person/subject), "what" (action/event), "where" (location), "when" (time), "why" (reason/intent), "keywords" (array of specific keywords).
User Query: "{}"
Output ONLY the JSON object.
"#,
query
);
let payload = serde_json::json!({
"model": "gemma4",
"messages": [
{
"role": "user",
"content": prompt
}
],
"temperature": 0.1,
"stream": false
});
if let Ok(response) = client
.post("http://127.0.0.1:8081/v1/chat/completions")
.json(&payload)
.timeout(std::time::Duration::from_secs(60))
.send()
.await
{
tracing::info!("LLM Response Status: {}", response.status());
if let Ok(json_resp) = response.json::<serde_json::Value>().await {
tracing::info!("LLM Response Body: {}", json_resp);
if let Some(choices) = json_resp.get("choices").and_then(|v| v.as_array()) {
if let Some(choice) = choices.get(0) {
if let Some(content) = choice
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_str())
{
if let Some(start) = content.find("{") {
if let Some(end) = content.rfind("}") {
let json_str = &content[start..=end];
if let Ok(dims) =
serde_json::from_str::<LlmDimensionResponse>(json_str)
{
tracing::info!("Parsed LLM Dimensions: {:?}", dims);
return Some(dims);
} else {
tracing::warn!("Failed to parse LLM JSON: {}", json_str);
}
}
}
}
}
}
} else {
tracing::warn!("Failed to parse LLM response JSON");
}
} else {
tracing::warn!("LLM request failed or timed out");
}
None
}

File diff suppressed because it is too large Load Diff

View File

@@ -188,5 +188,5 @@ async fn get_ollama_embedding(
// --- Router Setup ---
pub fn search_routes() -> Router<crate::api::server::AppState> {
Router::new().route("/smart", post(smart_search))
Router::new().route("/api/v1/search/smart", post(smart_search))
}

View File

@@ -20,7 +20,6 @@ use crate::core::text::tokenizer::tokenize_chinese_text;
use crate::{Embedder, FileManager};
use super::agent_api;
use super::face_recognition;
use super::five_w1h_agent_api;
use super::identities;
use super::identity_api;
@@ -2491,26 +2490,15 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> {
.route("/api/v1/files/register", post(register_file))
.route("/api/v1/unregister", post(unregister))
.route("/api/v1/files/scan", get(scan_files))
.route("/api/v1/files/:file_uuid/probe", get(probe_by_uuid))
.route("/api/v1/files/:file_uuid/process", post(trigger_processing))
.route("/api/v1/assets/:uuid/status", get(get_asset_status))
.route("/api/v1/jobs/:job_id", get(get_job_status))
.route("/api/v1/rules/:rule/status", get(get_rule_status))
.route("/api/v1/search/hybrid", post(hybrid_search))
.route("/api/v1/search", post(search))
.route("/api/v1/search/bm25", post(search_bm25))
.route("/api/v1/lookup", get(lookup))
.route("/api/v1/videos/:uuid", delete(delete_video))
.route("/api/v1/videos/:uuid/details", get(video_details))
.route("/api/v1/videos/:uuid/pre_chunks", get(list_pre_chunks))
.route("/api/v1/file/:file_uuid/probe", get(probe_by_uuid))
.route("/api/v1/file/:file_uuid/process", post(trigger_processing))
.route("/api/v1/file/:file_uuid/chunks", get(list_pre_chunks))
.route("/api/v1/progress/:uuid", get(get_progress))
.route("/api/v1/jobs", get(list_jobs))
.route("/api/v1/config/cache", post(cache_toggle))
.merge(face_recognition::face_recognition_routes())
// .merge(person_identity::person_identity_routes()) // V4.0: DISABLED (person_identities table removed)
.merge(identity_binding::identity_binding_routes())
.merge(identities::identity_routes())
.merge(super::snapshot_api::snapshot_routes())
.layer(axum::middleware::from_fn_with_state(
state.api_state.clone(),
api_key_validation,
@@ -2548,6 +2536,7 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> {
.merge(agent_api::agent_routes()) // Phase 6 Routes
.merge(super::identity_agent_api::identity_agent_routes()) // Phase 5 Routes
.merge(five_w1h_agent_api::five_w1h_agent_routes()) // Phase 3 Routes (5W1H Agent)
.merge(super::media_api::bbox_routes()) // Media: video/bbox/thumbnail
.merge(search_routes()) // Smart search drill-down
.merge(universal_search_routes()) // Universal / frames / persons search
.merge(protected_routes)

View File

@@ -1,335 +0,0 @@
use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use crate::core::processor::snapshot_agent::SnapshotAgent;
use crate::core::storage::snapshot_manager::SnapshotManager;
pub fn snapshot_routes() -> Router<crate::api::server::AppState> {
Router::new()
.route(
"/api/v1/files/:uuid/snapshots",
get(get_file_snapshots).post(generate_file_snapshots),
)
.route(
"/api/v1/files/:uuid/snapshots/status",
get(get_file_snapshot_status),
)
.route(
"/api/v1/files/:uuid/snapshots/migrate",
post(migrate_file_snapshots),
)
.route(
"/api/v1/files/:uuid/snapshots/teardown",
post(teardown_file_snapshots),
)
.route(
"/api/v1/identities/:uuid/snapshots",
get(get_identity_snapshots).post(generate_identity_snapshots),
)
}
// --- File Snapshot Endpoints ---
#[derive(Debug, Serialize)]
pub struct FileSnapshotsResponse {
pub success: bool,
pub file_uuid: String,
pub tier: String,
pub hits: u64,
pub types: Vec<String>,
}
async fn get_file_snapshots(
State(state): State<crate::api::server::AppState>,
Path(uuid): Path<String>,
) -> Result<Json<FileSnapshotsResponse>, (StatusCode, String)> {
let output_dir = crate::core::config::OUTPUT_DIR.as_str();
let manager = SnapshotManager::new(output_dir);
let hits = state
.redis_cache
.get_snapshot_hits(&uuid)
.await
.unwrap_or(0);
let tier = SnapshotManager::compute_tier(hits);
let types = manager.list_snapshot_types(&uuid);
state.redis_cache.update_last_access(&uuid).await.ok();
Ok(Json(FileSnapshotsResponse {
success: true,
file_uuid: uuid,
tier: tier.to_string(),
hits,
types,
}))
}
#[derive(Debug, Serialize)]
pub struct SnapshotStatusResponse {
pub success: bool,
pub file_uuid: String,
pub status: String,
pub progress: Option<f32>,
pub tier: String,
}
async fn get_file_snapshot_status(
State(state): State<crate::api::server::AppState>,
Path(uuid): Path<String>,
) -> Result<Json<SnapshotStatusResponse>, (StatusCode, String)> {
let status_json = state
.redis_cache
.get_snapshot_status(&uuid)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let status: String = status_json
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("cold")
.to_string();
let progress: Option<f32> = status_json
.get("progress")
.and_then(|v| v.as_f64())
.map(|f| f as f32);
let hits = state
.redis_cache
.get_snapshot_hits(&uuid)
.await
.unwrap_or(0);
let tier = SnapshotManager::compute_tier(hits);
Ok(Json(SnapshotStatusResponse {
success: true,
file_uuid: uuid,
status,
progress,
tier: tier.to_string(),
}))
}
#[derive(Debug, Deserialize)]
pub struct GenerateSnapshotRequest {
#[serde(rename = "type")]
pub snapshot_type: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct GenerateSnapshotResponse {
pub success: bool,
pub message: String,
pub file_uuid: String,
}
async fn generate_file_snapshots(
State(state): State<crate::api::server::AppState>,
Path(uuid): Path<String>,
Json(req): Json<GenerateSnapshotRequest>,
) -> Result<Json<GenerateSnapshotResponse>, (StatusCode, String)> {
let output_dir = crate::core::config::OUTPUT_DIR.as_str();
let manager = SnapshotManager::new(output_dir);
let agent = SnapshotAgent::default();
manager
.ensure_file_dirs(&uuid)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
state
.redis_cache
.set_snapshot_status(&uuid, "generating", Some(0.0))
.await
.map_err(|e: anyhow::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let snapshot_type = req.snapshot_type.as_deref().unwrap_or("faces");
tracing::info!(
"Starting snapshot generation for file_uuid={}, type={}",
uuid,
snapshot_type
);
match agent.generate_file_snapshots(&uuid, snapshot_type).await {
Ok(_) => {
state
.redis_cache
.set_snapshot_status(&uuid, "ready", Some(1.0))
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
tracing::info!("Snapshot generation completed for file_uuid={}", uuid);
Ok(Json(GenerateSnapshotResponse {
success: true,
message: format!("Snapshot generation completed for type: {}", snapshot_type),
file_uuid: uuid,
}))
}
Err(e) => {
state
.redis_cache
.set_snapshot_status(&uuid, "failed", None)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
tracing::error!("Snapshot generation failed for file_uuid={}: {}", uuid, e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Snapshot generation failed: {}", e),
))
}
}
}
#[derive(Debug, Deserialize)]
pub struct MigrateSnapshotRequest {
pub parent_uuid: String,
}
#[derive(Debug, Serialize)]
pub struct MigrateSnapshotResponse {
pub success: bool,
pub message: String,
pub file_uuid: String,
pub migrated_types: Vec<String>,
}
async fn migrate_file_snapshots(
State(state): State<crate::api::server::AppState>,
Path(uuid): Path<String>,
Json(req): Json<MigrateSnapshotRequest>,
) -> Result<Json<MigrateSnapshotResponse>, (StatusCode, String)> {
let agent = SnapshotAgent::default();
tracing::info!(
"Starting snapshot migration from parent_uuid={} to file_uuid={}",
req.parent_uuid,
uuid
);
match agent.migrate_snapshots(&uuid, &req.parent_uuid).await {
Ok(migrated) => {
state
.redis_cache
.set_migrate_hint(&uuid, &req.parent_uuid, migrated.len() as u64)
.await
.map_err(|e: anyhow::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(MigrateSnapshotResponse {
success: true,
message: format!("Migrated {} snapshot types", migrated.len()),
file_uuid: uuid,
migrated_types: migrated,
}))
}
Err(e) => {
tracing::error!("Snapshot migration failed: {}", e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Migration failed: {}", e),
))
}
}
}
// --- Identity Snapshot Endpoints ---
#[derive(Debug, Serialize)]
pub struct IdentitySnapshotsResponse {
pub success: bool,
pub identity_uuid: String,
pub has_reference: bool,
pub face_count: usize,
}
async fn get_identity_snapshots(
State(_state): State<crate::api::server::AppState>,
Path(uuid): Path<String>,
) -> Result<Json<IdentitySnapshotsResponse>, (StatusCode, String)> {
let output_dir = crate::core::config::OUTPUT_DIR.as_str();
let manager = SnapshotManager::new(output_dir);
let identity_dir = manager.identity_snapshot_dir(&uuid);
let has_reference = identity_dir.join("reference.jpg").exists();
let face_count = if identity_dir.join("faces").exists() {
std::fs::read_dir(identity_dir.join("faces"))
.map(|entries| entries.flatten().count())
.unwrap_or(0)
} else {
0
};
Ok(Json(IdentitySnapshotsResponse {
success: true,
identity_uuid: uuid,
has_reference,
face_count,
}))
}
#[derive(Debug, Serialize)]
pub struct GenerateIdentitySnapshotResponse {
pub success: bool,
pub message: String,
pub identity_uuid: String,
}
async fn generate_identity_snapshots(
State(_state): State<crate::api::server::AppState>,
Path(uuid): Path<String>,
) -> Result<Json<GenerateIdentitySnapshotResponse>, (StatusCode, String)> {
let output_dir = crate::core::config::OUTPUT_DIR.as_str();
let manager = SnapshotManager::new(output_dir);
manager
.ensure_identity_dirs(&uuid)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
tracing::info!("Snapshot generation requested for identity_uuid={}", uuid);
Ok(Json(GenerateIdentitySnapshotResponse {
success: true,
message: "Identity snapshot directories created".to_string(),
identity_uuid: uuid,
}))
}
#[derive(Debug, Serialize)]
pub struct TeardownSnapshotResponse {
pub success: bool,
pub message: String,
pub file_uuid: String,
}
async fn teardown_file_snapshots(
Path(uuid): Path<String>,
) -> Result<Json<TeardownSnapshotResponse>, (StatusCode, String)> {
let agent = SnapshotAgent::default();
tracing::info!("Manual teardown requested for file_uuid={}", uuid);
match agent.auto_tear_down(&uuid).await {
Ok(_) => Ok(Json(TeardownSnapshotResponse {
success: true,
message: "Snapshot teardown completed".to_string(),
file_uuid: uuid,
})),
Err(e) => {
tracing::error!("Snapshot teardown failed for file_uuid={}: {}", uuid, e);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Teardown failed: {}", e),
))
}
}
}

140
src/api/trace_agent_api.rs Normal file
View File

@@ -0,0 +1,140 @@
use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
routing::post,
Router,
};
use serde::{Deserialize, Serialize};
use crate::core::db::PostgresDb;
pub fn trace_agent_routes() -> Router<crate::api::server::AppState> {
Router::new().route(
"/api/v1/file/:file_uuid/face_trace/sortby",
post(list_traces_sorted),
)
}
#[derive(Debug, Deserialize)]
struct TracesRequest {
min_faces: Option<i64>,
sort_by: Option<String>,
limit: Option<i64>,
}
#[derive(Debug, Serialize)]
struct TraceInfo {
trace_id: i32,
face_count: i64,
first_frame: i32,
last_frame: i32,
first_sec: f64,
last_sec: f64,
duration_sec: f64,
avg_confidence: f64,
sample_face_id: Option<String>,
}
#[derive(Debug, Serialize)]
struct TracesResponse {
success: bool,
file_uuid: String,
total_traces: i64,
total_faces: i64,
traces: Vec<TraceInfo>,
}
async fn list_traces_sorted(
State(state): State<crate::api::server::AppState>,
Path(file_uuid): Path<String>,
Json(req): Json<TracesRequest>,
) -> Result<Json<TracesResponse>, (StatusCode, String)> {
let min_faces = req.min_faces.unwrap_or(1);
let sort = req.sort_by.as_deref().unwrap_or("first_appearance");
let limit = req.limit.unwrap_or(500);
let order_clause = match sort {
"face_count" => "face_count DESC",
"duration" => "duration_sec DESC",
_ => "first_frame ASC",
};
// Get actual video FPS
let fps: f64 =
sqlx::query_scalar("SELECT COALESCE(fps, 24.0) FROM dev.videos WHERE file_uuid = $1")
.bind(&file_uuid)
.fetch_optional(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.unwrap_or(24.0);
let query = format!(
r#"SELECT tt.trace_id, tt.face_count, tt.first_frame, tt.last_frame,
ROUND(tt.first_frame::numeric / {}, 1)::float8 AS first_sec,
ROUND(tt.last_frame::numeric / {}, 1)::float8 AS last_sec,
ROUND((tt.last_frame - tt.first_frame)::numeric / {}, 1)::float8 AS duration_sec,
ROUND(tt.avg_confidence::numeric, 4)::float8 AS avg_confidence,
fd.id::text AS sample_face_id
FROM (
SELECT trace_id,
COUNT(*) AS face_count,
MIN(frame_number) AS first_frame,
MAX(frame_number) AS last_frame,
AVG(confidence) AS avg_confidence
FROM dev.face_detections
WHERE file_uuid = $1 AND trace_id IS NOT NULL
GROUP BY trace_id
HAVING COUNT(*) >= $2
ORDER BY {}
LIMIT $3
) tt
LEFT JOIN LATERAL (
SELECT id FROM dev.face_detections
WHERE trace_id = tt.trace_id AND file_uuid = $1
ORDER BY confidence DESC LIMIT 1
) fd ON true
"#,
fps, fps, fps, order_clause
);
let rows: Vec<(i32, i64, i32, i32, f64, f64, f64, f64, Option<String>)> =
sqlx::query_as(&query)
.bind(&file_uuid)
.bind(min_faces)
.bind(limit)
.fetch_all(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let traces: Vec<TraceInfo> = rows
.into_iter()
.map(|(tid, fc, ff, lf, fs, ls, dur, conf, fid)| TraceInfo {
trace_id: tid,
face_count: fc,
first_frame: ff,
last_frame: lf,
first_sec: fs,
last_sec: ls,
duration_sec: dur,
avg_confidence: conf,
sample_face_id: fid,
})
.collect();
let (total_traces, total_faces): (i64, i64) = sqlx::query_as(
"SELECT COUNT(DISTINCT trace_id), COUNT(*) FROM dev.face_detections WHERE file_uuid = $1 AND trace_id IS NOT NULL"
)
.bind(&file_uuid)
.fetch_one(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(TracesResponse {
success: true,
file_uuid,
total_traces,
total_faces,
traces,
}))
}

View File

@@ -93,7 +93,6 @@ pub fn universal_search_routes() -> Router<crate::api::server::AppState> {
Router::new()
.route("/api/v1/search/universal", post(universal_search))
.route("/api/v1/search/frames", post(search_frames))
.route("/api/v1/search/persons", get(search_persons))
}
/// Unified search across all data types

View File

@@ -1,145 +0,0 @@
//! Who API - 身份識別與 ID 映射接口 (Video-Scoped)
use axum::{
extract::State,
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
// --- Request / Response Structures ---
#[derive(Debug, Deserialize)]
pub struct WhoQuery {
pub face_id: Option<String>,
pub speaker_id: Option<String>,
pub uuid: Option<String>,
pub chunk_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct WhoCandidatesRequest {
pub query: String,
pub file_uuid: Option<String>,
pub limit: Option<i32>,
}
#[derive(Debug, Deserialize)]
pub struct DefinePersonRequest {
pub uuid: String,
pub identity_id: Option<i32>,
pub name: String,
pub face_ids: Option<Vec<String>>,
pub speaker_ids: Option<Vec<String>>,
}
#[derive(Debug, Serialize)]
pub struct WhoIdentity {
pub identity_id: i32,
pub uuid: String,
pub name: String,
pub tags: Option<Vec<String>>,
pub face_ids: Vec<String>,
pub speaker_ids: Vec<String>,
}
// --- API Handlers ---
/// GET /api/v1/who
pub async fn get_who_identity(
State(state): State<crate::api::server::AppState>,
axum::extract::Query(query): axum::extract::Query<WhoQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let db = &state.db;
// Priority 1: Query by Chunk (UUID + Chunk ID)
if let (Some(uuid), Some(chunk_id)) = (&query.uuid, &query.chunk_id) {
let info = db
.get_who_info_by_chunk(uuid, chunk_id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
return Ok(Json(info));
}
// Priority 2: List all for a specific UUID
if let Some(uuid) = &query.uuid {
// TODO: Implement list_all_persons(uuid)
return Ok(Json(serde_json::json!({ "message": "List all pending" })));
}
Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "Missing uuid" })),
))
}
/// POST /api/v1/who/candidates
/// Search person_identities table for n8n workflow
pub async fn get_who_candidates(
State(state): State<crate::api::server::AppState>,
Json(req): Json<WhoCandidatesRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let db = &state.db;
let limit = req.limit.unwrap_or(20);
let query_str = format!("%{}%", req.query);
let results = db
.search_person_candidates(&query_str, &req.file_uuid, limit)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
// Format for n8n
let response = serde_json::json!({
"query": req.query,
"items": results,
"total": results.len()
});
Ok(Json(response))
}
/// POST /api/v1/who
pub async fn define_person(
State(state): State<crate::api::server::AppState>,
Json(req): Json<DefinePersonRequest>,
) -> Result<Json<WhoIdentity>, (StatusCode, Json<serde_json::Value>)> {
let db = &state.db;
let identity = db
.create_or_update_person(
&req.uuid,
req.identity_id,
req.name.clone(),
req.face_ids.unwrap_or_default(),
req.speaker_ids.unwrap_or_default(),
)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
Ok(Json(identity))
}
// --- Router Setup ---
pub fn who_routes() -> Router<crate::api::server::AppState> {
Router::new()
.route("/api/v1/who", get(get_who_identity).post(define_person))
.route("/api/v1/who/candidates", post(get_who_candidates))
}