feat: media API (video/bbox/thumbnail), UUID unification, dot matrix text, portal fixes, API dictionary V1.3
This commit is contained in:
@@ -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(¶ms.uuid, ¶ms.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 B,A 被刪除
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user