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

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