- Remove unused imports (n8n_search, universal_search, Client, Arc, etc.) - Update API endpoints for identity, face recognition, search - Fix postgres_db.rs search_videos parent_uuid column - Add snapshot API and identity agent API - Clean up backup files (.bak, .bak2)
840 lines
23 KiB
Rust
840 lines
23 KiB
Rust
use axum::{
|
|
body::Body,
|
|
extract::{Path, Query, State},
|
|
http::{header, StatusCode},
|
|
response::{IntoResponse, Json},
|
|
routing::{get, post},
|
|
Router,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
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 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 success: bool,
|
|
pub message: String,
|
|
pub identity_uuid: Option<String>,
|
|
pub identity_name: String,
|
|
pub total_vectors: Option<i32>,
|
|
pub angle_coverage: Option<Vec<String>>,
|
|
pub quality_avg: Option<f64>,
|
|
}
|
|
|
|
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/faces/candidates", get(list_face_candidates))
|
|
.route(
|
|
"/api/v1/identities/:identity_id/faces",
|
|
get(get_identity_faces),
|
|
)
|
|
.route("/api/v1/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(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Json(req): Json<RegisterFromFaceRequest>,
|
|
) -> Result<Json<RegisterFromFaceResponse>, (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());
|
|
|
|
let scripts_dir = std::env::var("MOMENTRY_SCRIPTS_DIR").unwrap_or_else(|_| {
|
|
let mut path = std::env::current_dir().unwrap_or_default();
|
|
path.push("scripts");
|
|
path.to_string_lossy().to_string()
|
|
});
|
|
|
|
let script_path = format!("{}/select_face_reference_vectors_v2.py", scripts_dir);
|
|
|
|
tracing::info!(
|
|
"Registering identity '{}' from face.json: {}",
|
|
req.identity_name,
|
|
req.face_json_path
|
|
);
|
|
|
|
let output = Command::new(&python_path)
|
|
.arg(&script_path)
|
|
.arg("--face-json")
|
|
.arg(&req.face_json_path)
|
|
.arg("--identity-name")
|
|
.arg(&req.identity_name)
|
|
.arg("--register")
|
|
.arg("--schema")
|
|
.arg(&schema)
|
|
.output()
|
|
.map_err(|e| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to execute script: {}", e),
|
|
)
|
|
})?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Script failed: {}", stderr),
|
|
));
|
|
}
|
|
|
|
let db = PostgresDb::init().await.map_err(|e| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("DB error: {}", e),
|
|
)
|
|
})?;
|
|
|
|
let query = r#"
|
|
SELECT uuid, reference_data->'total_references' as total,
|
|
reference_data->'angles_covered' as angles,
|
|
reference_data->'quality_avg' as quality
|
|
FROM identities
|
|
WHERE name = $1
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
"#;
|
|
|
|
let row: Option<(String, Option<i32>, Option<Vec<String>>, Option<f64>)> =
|
|
sqlx::query_as(query)
|
|
.bind(&req.identity_name)
|
|
.fetch_optional(db.pool())
|
|
.await
|
|
.map_err(|e| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Query error: {}", e),
|
|
)
|
|
})?;
|
|
|
|
match row {
|
|
Some((uuid, total, angles, quality)) => Ok(Json(RegisterFromFaceResponse {
|
|
success: true,
|
|
message: format!(
|
|
"Successfully registered identity '{}' with {} reference vectors",
|
|
req.identity_name,
|
|
total.unwrap_or(0)
|
|
),
|
|
identity_uuid: Some(uuid),
|
|
identity_name: req.identity_name,
|
|
total_vectors: total,
|
|
angle_coverage: angles,
|
|
quality_avg: quality,
|
|
})),
|
|
None => Ok(Json(RegisterFromFaceResponse {
|
|
success: true,
|
|
message: format!(
|
|
"Identity '{}' registered, but details not found",
|
|
req.identity_name
|
|
),
|
|
identity_uuid: None,
|
|
identity_name: req.identity_name,
|
|
total_vectors: None,
|
|
angle_coverage: None,
|
|
quality_avg: None,
|
|
})),
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
Query(query): Query<ListIdentitiesQuery>,
|
|
) -> Result<Json<IdentityListResponse>, (StatusCode, String)> {
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("DB error: {}", 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 count_sql = "SELECT COUNT(*) FROM identities";
|
|
let total: i64 = match sqlx::query_scalar(count_sql).fetch_one(db.pool()).await {
|
|
Ok(count) => count,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Count error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let sql = "SELECT id, name, metadata FROM identities ORDER BY id DESC LIMIT $1 OFFSET $2";
|
|
|
|
let rows: Vec<(i32, String, Option<serde_json::Value>)> = match sqlx::query_as(sql)
|
|
.bind(page_size as i64)
|
|
.bind(offset)
|
|
.fetch_all(db.pool())
|
|
.await
|
|
{
|
|
Ok(rows) => rows,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Query error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let identities: Vec<IdentityResponse> = rows
|
|
.into_iter()
|
|
.map(|r| IdentityResponse {
|
|
id: r.0,
|
|
name: r.1,
|
|
metadata: r.2,
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(IdentityListResponse {
|
|
identities,
|
|
count: total,
|
|
page,
|
|
page_size,
|
|
}))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ListIdentitiesQuery {
|
|
pub page: Option<usize>,
|
|
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>,
|
|
pub min_confidence: Option<f64>,
|
|
pub page: Option<usize>,
|
|
pub page_size: Option<usize>,
|
|
pub limit: Option<usize>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct FaceCandidate {
|
|
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>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct FaceCandidatesResponse {
|
|
pub candidates: Vec<FaceCandidate>,
|
|
pub total: i64,
|
|
pub page: usize,
|
|
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 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>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct IdentityFacesResponse {
|
|
pub identity_id: i32,
|
|
pub faces: Vec<IdentityFace>,
|
|
pub total: i64,
|
|
}
|
|
|
|
async fn list_face_candidates(
|
|
Query(query): Query<FaceCandidatesQuery>,
|
|
) -> Result<Json<FaceCandidatesResponse>, (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 = std::cmp::min(query.page_size.unwrap_or(15), 100);
|
|
let offset = (page - 1) * page_size;
|
|
let min_confidence = query.min_confidence.unwrap_or(0.5);
|
|
|
|
let table = crate::core::db::schema::table_name("face_detections");
|
|
|
|
let total: i64 = if let Some(file_uuid) = &query.file_uuid {
|
|
let count_sql = format!(
|
|
"SELECT COUNT(*) FROM {} WHERE identity_id IS NULL AND confidence >= $1 AND file_uuid = $2",
|
|
table
|
|
);
|
|
match sqlx::query_scalar(&count_sql)
|
|
.bind(min_confidence)
|
|
.bind(file_uuid)
|
|
.fetch_one(db.pool())
|
|
.await
|
|
{
|
|
Ok(count) => count,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Count error: {}", e),
|
|
))
|
|
}
|
|
}
|
|
} else {
|
|
let count_sql = format!(
|
|
"SELECT COUNT(*) FROM {} WHERE identity_id IS NULL AND confidence >= $1",
|
|
table
|
|
);
|
|
match sqlx::query_scalar(&count_sql)
|
|
.bind(min_confidence)
|
|
.fetch_one(db.pool())
|
|
.await
|
|
{
|
|
Ok(count) => count,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Count error: {}", e),
|
|
))
|
|
}
|
|
}
|
|
};
|
|
|
|
let rows = if let Some(file_uuid) = &query.file_uuid {
|
|
let sql = format!(
|
|
"SELECT id, face_id, file_uuid, frame_number, confidence, bbox, attributes
|
|
FROM {}
|
|
WHERE identity_id IS NULL AND confidence >= $1 AND file_uuid = $2
|
|
ORDER BY confidence DESC
|
|
LIMIT $3 OFFSET $4",
|
|
table
|
|
);
|
|
match sqlx::query_as::<
|
|
_,
|
|
(
|
|
i32,
|
|
Option<String>,
|
|
String,
|
|
i64,
|
|
f64,
|
|
Option<serde_json::Value>,
|
|
Option<serde_json::Value>,
|
|
),
|
|
>(&sql)
|
|
.bind(min_confidence)
|
|
.bind(file_uuid)
|
|
.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),
|
|
))
|
|
}
|
|
}
|
|
} else {
|
|
let sql = format!(
|
|
"SELECT id, face_id, file_uuid, frame_number, confidence, bbox, attributes
|
|
FROM {}
|
|
WHERE identity_id IS NULL AND confidence >= $1
|
|
ORDER BY confidence DESC
|
|
LIMIT $2 OFFSET $3",
|
|
table
|
|
);
|
|
match sqlx::query_as::<
|
|
_,
|
|
(
|
|
i32,
|
|
Option<String>,
|
|
String,
|
|
i64,
|
|
f64,
|
|
Option<serde_json::Value>,
|
|
Option<serde_json::Value>,
|
|
),
|
|
>(&sql)
|
|
.bind(min_confidence)
|
|
.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 candidates: Vec<FaceCandidate> = rows
|
|
.into_iter()
|
|
.map(|r| FaceCandidate {
|
|
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(FaceCandidatesResponse {
|
|
candidates,
|
|
total,
|
|
page,
|
|
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(face_id): Path<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",
|
|
table_fd, table_v
|
|
);
|
|
|
|
let row: Option<(i64, Option<serde_json::Value>, String, f64)> = match sqlx::query_as(&sql)
|
|
.bind(face_id)
|
|
.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",
|
|
×tamp.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,
|
|
}
|