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

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