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

@@ -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,
}