Files
momentry_core/src/api/trace_agent_api.rs

846 lines
26 KiB
Rust

use axum::{
body::Body,
extract::{Path, Query, State},
http::{header, StatusCode},
response::{IntoResponse, Json, Response},
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use crate::core::db::PostgresDb;
pub fn trace_agent_routes() -> Router<crate::api::types::AppState> {
Router::new()
.route("/api/v1/file/:file_uuid/traces", post(list_traces_sorted))
.route(
"/api/v1/file/:file_uuid/trace/:trace_id/faces",
get(list_trace_faces),
)
.route(
"/api/v1/file/:file_uuid/trace/:trace_id/representative-face",
get(get_representative_face),
)
.route(
"/api/v1/file/:file_uuid/trace/:trace_id/thumbnail",
get(get_trace_thumbnail),
)
.route(
"/api/v1/file/:file_uuid/identities/:identity_uuid_a/co-occur-with/:identity_uuid_b",
get(get_cooccurrence),
)
.route(
"/api/v1/file/:file_uuid/tkg/rebuild",
post(rebuild_tkg),
)
.route(
"/api/v1/file/:file_uuid/representative-frame",
get(get_representative_frame),
)
}
#[derive(Debug, Deserialize)]
struct TracesRequest {
min_faces: Option<i64>,
sort_by: Option<String>,
page: Option<i64>,
page_size: Option<i64>,
limit: Option<i64>,
min_confidence: Option<f64>,
max_confidence: Option<f64>,
}
#[derive(Debug, Serialize)]
struct TraceInfo {
trace_id: i32,
face_count: i64,
start_frame: i32,
end_frame: i32,
start_time: f64,
end_time: f64,
duration_sec: f64,
avg_confidence: f64,
sample_face_id: Option<String>,
}
#[derive(Debug, Serialize)]
struct TracesResponse {
success: bool,
file_uuid: String,
fps: f64,
total_traces: i64,
total_faces: i64,
page: i64,
page_size: i64,
traces: Vec<TraceInfo>,
}
async fn list_traces_sorted(
State(state): State<crate::api::types::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 page = req.page.unwrap_or(1).max(1);
let page_size = req.page_size.unwrap_or(50).max(1).min(500);
let hard_limit = req.limit.unwrap_or(500);
let effective_limit = hard_limit.min(page_size);
let db_offset = (page - 1) * page_size;
let min_confidence = req.min_confidence.unwrap_or(0.0);
let max_confidence = req.max_confidence.unwrap_or(1.0);
let order_clause = match sort {
"face_count" => "face_count DESC",
"duration" => "duration_sec DESC",
_ => "start_frame ASC",
};
let fps: f64 = sqlx::query_scalar(&format!(
"SELECT COALESCE(fps, 24.0) FROM {} WHERE file_uuid = $1",
crate::core::db::schema::table_name("videos")
))
.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!(
"SELECT tt.*, fd.id AS sample_face_id FROM (
SELECT trace_id::int AS trace_id,
COUNT(*) AS face_count,
MIN(frame_number)::int AS start_frame,
MAX(frame_number)::int AS end_frame,
(MAX(frame_number) - MIN(frame_number))::float8 AS duration_sec,
AVG(confidence)::float8 AS avg_confidence
FROM {}
WHERE file_uuid = $1 AND trace_id IS NOT NULL
AND confidence >= $5 AND confidence <= $6
GROUP BY trace_id
HAVING COUNT(*) >= $2
ORDER BY {}
LIMIT $3 OFFSET $4
) tt
LEFT JOIN LATERAL (
SELECT id FROM {}
WHERE trace_id = tt.trace_id AND file_uuid = $1
ORDER BY confidence DESC LIMIT 1
) fd ON true",
crate::core::db::schema::table_name("face_detections"),
order_clause,
crate::core::db::schema::table_name("face_detections"),
);
let rows: Vec<(i32, i64, i32, i32, f64, f64, Option<i32>)> = sqlx::query_as(&query)
.bind(&file_uuid)
.bind(min_faces)
.bind(effective_limit)
.bind(db_offset)
.bind(min_confidence)
.bind(max_confidence)
.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, sf, ef, dur, conf, fid)| TraceInfo {
trace_id: tid,
face_count: fc,
start_frame: sf,
end_frame: ef,
start_time: sf as f64 / fps,
end_time: ef as f64 / fps,
duration_sec: dur / fps,
avg_confidence: conf,
sample_face_id: fid.map(|v| v.to_string()),
})
.collect();
let (total_traces, total_faces): (i64, i64) = sqlx::query_as(
&format!("SELECT COUNT(DISTINCT trace_id), COUNT(*) FROM {} WHERE file_uuid = $1 AND trace_id IS NOT NULL",
crate::core::db::schema::table_name("face_detections"))
)
.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,
fps,
total_traces,
total_faces,
page,
page_size,
traces,
}))
}
// ── Individual face detections for a trace ──
#[derive(Debug, Deserialize)]
struct TraceFacesQuery {
page: Option<i64>,
page_size: Option<i64>,
limit: Option<i64>,
offset: Option<i64>,
interpolate: Option<bool>,
}
#[derive(Debug, Serialize)]
struct TraceFaceItem {
id: i32,
start_frame: i32,
end_frame: i32,
start_time: f64,
end_time: f64,
x: Option<i32>,
y: Option<i32>,
width: Option<i32>,
height: Option<i32>,
confidence: f64,
interpolated: bool,
}
#[derive(Debug, Serialize)]
struct TraceFacesResponse {
success: bool,
file_uuid: String,
trace_id: i32,
fps: f64,
total: i64,
faces: Vec<TraceFaceItem>,
}
fn lerp_i32(a: Option<i32>, b: Option<i32>, t: f64) -> Option<i32> {
match (a, b) {
(Some(av), Some(bv)) => Some((av as f64 + (bv - av) as f64 * t).round() as i32),
_ => None,
}
}
async fn list_trace_faces(
State(state): State<crate::api::types::AppState>,
Path((file_uuid, trace_id)): Path<(String, i32)>,
Query(q): Query<TraceFacesQuery>,
) -> Result<Json<TraceFacesResponse>, (StatusCode, String)> {
let limit = q.limit.unwrap_or(200).min(1000);
// Support both page/page_size and offset; page/page_size takes precedence
let offset = if q.page.is_some() || q.page_size.is_some() {
let p = q.page.unwrap_or(1).max(1);
let ps = q.page_size.unwrap_or(200).max(1).min(1000);
(p - 1) * ps
} else {
q.offset.unwrap_or(0)
};
let interpolate = q.interpolate.unwrap_or(false);
let fps: f64 = sqlx::query_scalar(&format!(
"SELECT COALESCE(fps, 24.0) FROM {} WHERE file_uuid = $1",
crate::core::db::schema::table_name("videos")
))
.bind(&file_uuid)
.fetch_optional(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.unwrap_or(24.0);
let total_detected: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND trace_id = $2",
crate::core::db::schema::table_name("face_detections")
))
.bind(&file_uuid)
.bind(trace_id)
.fetch_one(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let rows: Vec<(
i32,
i32,
Option<i32>,
Option<i32>,
Option<i32>,
Option<i32>,
f32,
)> = sqlx::query_as(&format!(
"SELECT id, frame_number::int, x, y, width, height, confidence::float4 \
FROM {} WHERE file_uuid = $1 AND trace_id = $2 \
ORDER BY frame_number ASC LIMIT $3 OFFSET $4",
crate::core::db::schema::table_name("face_detections")
))
.bind(&file_uuid)
.bind(trace_id)
.bind(limit)
.bind(offset)
.fetch_all(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut faces: Vec<TraceFaceItem> = Vec::new();
for (i, (id, frame, x, y, w, h, conf)) in rows.iter().enumerate() {
let cur = (x, y, w, h);
// Add interpolated frames between previous and current detection
if interpolate && i > 0 {
let prev = &rows[i - 1];
let prev_frame = prev.1;
let gap = frame - prev_frame;
if gap > 1 {
for mid in 1..gap {
let t = mid as f64 / gap as f64;
let mid_x = lerp_i32(prev.2, *x, t);
let mid_y = lerp_i32(prev.3, *y, t);
let mid_w = lerp_i32(prev.4, *w, t);
let mid_h = lerp_i32(prev.5, *h, t);
let mid_frame = prev_frame + mid;
let mt = (mid_frame as f64 / fps * 10.0).round() / 10.0;
faces.push(TraceFaceItem {
id: 0,
start_frame: mid_frame,
end_frame: mid_frame,
start_time: mt,
end_time: mt,
x: mid_x,
y: mid_y,
width: mid_w,
height: mid_h,
confidence: 0.0,
interpolated: true,
});
}
}
}
// Add the real detection
let frame_val = *frame;
let ft = (frame_val as f64 / fps * 10.0).round() / 10.0;
faces.push(TraceFaceItem {
id: *id,
start_frame: frame_val,
end_frame: frame_val,
start_time: ft,
end_time: ft,
x: *x,
y: *y,
width: *w,
height: *h,
confidence: *conf as f64,
interpolated: false,
});
}
let total = if interpolate && faces.len() as i64 > total_detected {
faces.len() as i64
} else {
total_detected
};
Ok(Json(TraceFacesResponse {
success: true,
file_uuid,
trace_id,
fps,
total,
faces,
}))
}
#[derive(Debug, Serialize)]
struct RepFaceBbox {
x: i32,
y: i32,
width: i32,
height: i32,
}
#[derive(Debug, Serialize)]
struct RepFaceResult {
frame_number: i64,
timestamp_secs: f64,
bbox: RepFaceBbox,
confidence: f64,
quality_score: f64,
blur_score: f64,
}
#[derive(Debug, Serialize)]
struct RepFaceResponse {
success: bool,
file_uuid: String,
trace_id: i32,
face_count: i64,
representative: RepFaceResult,
}
struct RepFaceSelection {
frame: i64,
x: i32,
y: i32,
w: i32,
h: i32,
conf: f64,
blur: f64,
score: f64,
video_path: String,
fps: f64,
face_count: i64,
}
async fn select_rep_face<F, T>(
pool: &sqlx::PgPool,
file_uuid: &str,
trace_id: i32,
err_fn: F,
) -> Result<RepFaceSelection, T>
where
F: Fn(anyhow::Error) -> T,
{
use crate::core::db::schema;
let fd_table = schema::table_name("face_detections");
let video_table = schema::table_name("videos");
let fps: f64 = sqlx::query_scalar(&format!(
"SELECT COALESCE(fps, 25.0) FROM {} WHERE file_uuid = $1", video_table
))
.bind(file_uuid)
.fetch_optional(pool)
.await
.map_err(|e| err_fn(anyhow::anyhow!("{}", e)))?
.unwrap_or(25.0);
let face_count: (i64,) = sqlx::query_as(&format!(
"SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND trace_id = $2", fd_table
))
.bind(file_uuid)
.bind(trace_id)
.fetch_one(pool)
.await
.map_err(|e| err_fn(anyhow::anyhow!("{}", e)))?;
struct Candidate { frame: i64, x: i32, y: i32, w: i32, h: i32, conf: f64, score: f64 }
let rows = sqlx::query_as::<_, (i64, i32, i32, i32, i32, f64)>(&format!(
"SELECT frame_number::bigint, x, y, width, height, confidence::float8 \
FROM {} WHERE file_uuid = $1 AND trace_id = $2 AND confidence > 0.7 \
AND ((metadata->>'qc_ok')::boolean IS NULL OR (metadata->>'qc_ok')::boolean = true) \
ORDER BY (width::float8 * height::float8) * confidence::float8 DESC LIMIT 10",
fd_table
))
.bind(file_uuid).bind(trace_id)
.fetch_all(pool)
.await
.map_err(|e| err_fn(anyhow::anyhow!("{}", e)))?;
if rows.is_empty() {
return Err(err_fn(anyhow::anyhow!("No suitable face found")));
}
let candidates: Vec<Candidate> = rows.into_iter()
.map(|(frame, x, y, w, h, conf)| {
let score = (w as f64 * h as f64) * conf;
Candidate { frame, x, y, w, h, conf, score }
})
.collect();
let video_path: String = sqlx::query_scalar(&format!(
"SELECT file_path FROM {} WHERE file_uuid = $1", video_table
))
.bind(file_uuid)
.fetch_optional(pool)
.await
.map_err(|e| err_fn(anyhow::anyhow!("{}", e)))?
.ok_or_else(|| err_fn(anyhow::anyhow!("Video not found")))?;
let mut best = candidates[0].frame;
let mut best_blur = f64::MAX;
let mut best_idx = 0usize;
for (i, c) in candidates.iter().enumerate() {
let seek = c.frame as f64 / fps;
if let Ok(output) = tokio::process::Command::new("ffmpeg")
.args(["-ss", &format!("{:.2}", seek), "-i", &video_path,
"-vframes", "1", "-vf", &format!("crop={}:{}:{}:{},blurdetect", c.w, c.h, c.x, c.y),
"-f", "null", "-"])
.output().await
{
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr.lines() {
if let Some(blur_str) = line.split("blur mean: ").nth(1) {
if let Ok(blur) = blur_str.trim().parse::<f64>() {
if blur < best_blur { best_blur = blur; best = c.frame; best_idx = i; }
}
}
}
}
}
let chosen = &candidates[best_idx];
Ok(RepFaceSelection {
frame: chosen.frame, x: chosen.x, y: chosen.y, w: chosen.w, h: chosen.h,
conf: chosen.conf, blur: best_blur, score: chosen.score,
video_path, fps, face_count: face_count.0,
})
}
async fn get_representative_face(
State(state): State<crate::api::types::AppState>,
Path((file_uuid, trace_id)): Path<(String, i32)>,
) -> Result<Json<RepFaceResponse>, (StatusCode, Json<serde_json::Value>)> {
let sel = select_rep_face(state.db.pool(), &file_uuid, trace_id, |e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
}).await?;
Ok(Json(RepFaceResponse {
success: true,
file_uuid,
trace_id,
face_count: sel.face_count,
representative: RepFaceResult {
frame_number: sel.frame,
timestamp_secs: sel.frame as f64 / sel.fps,
bbox: RepFaceBbox { x: sel.x, y: sel.y, width: sel.w, height: sel.h },
confidence: sel.conf,
quality_score: sel.score,
blur_score: sel.blur,
},
}))
}
async fn get_trace_thumbnail(
State(state): State<crate::api::types::AppState>,
Path((file_uuid, trace_id)): Path<(String, i32)>,
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
let sel = select_rep_face(state.db.pool(), &file_uuid, trace_id, |e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
}).await?;
let seek = sel.frame as f64 / sel.fps;
let tmp = std::env::temp_dir().join(format!("trace_{}_{}.jpg", file_uuid, trace_id));
let status = tokio::process::Command::new("ffmpeg")
.args([
"-ss", &format!("{:.2}", seek),
"-i", &sel.video_path,
"-vframes", "1",
"-vf", &format!("crop={}:{}:{}:{},scale=320:320", sel.w, sel.h, sel.x, sel.y),
"-q:v", "2",
"-y", &tmp.to_string_lossy().to_string(),
])
.output()
.await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
})?;
if !status.status.success() {
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "FFmpeg failed"}))));
}
let bytes = tokio::fs::read(&tmp).await.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
})?;
let _ = tokio::fs::remove_file(&tmp).await;
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/jpeg")
.header(header::CACHE_CONTROL, "public, max-age=86400")
.body(Body::from(bytes))
.unwrap())
}
#[derive(Debug, Serialize)]
struct CoOccurIdentity {
identity_uuid: String,
name: String,
trace_id: i32,
}
#[derive(Debug, Serialize)]
struct CoOccurRepFace {
frame_number: i64,
bbox: RepFaceBbox,
confidence: f64,
thumbnail_url: String,
}
#[derive(Debug, Serialize)]
struct CoOccurrence {
frame_number: i64,
timestamp_secs: f64,
total_cooccurrence_frames: i64,
representative_face_a: Option<CoOccurRepFace>,
representative_face_b: Option<CoOccurRepFace>,
}
#[derive(Debug, Serialize)]
struct CoOccurResponse {
success: bool,
file_uuid: String,
identity_a: CoOccurIdentity,
identity_b: CoOccurIdentity,
first_cooccurrence: CoOccurrence,
}
async fn get_cooccurrence(
State(state): State<crate::api::types::AppState>,
Path((file_uuid, identity_uuid_a, identity_uuid_b)): Path<(String, String, String)>,
) -> Result<Json<CoOccurResponse>, (StatusCode, Json<serde_json::Value>)> {
use crate::core::db::schema;
let id_table = schema::table_name("identities");
let fd_table = schema::table_name("face_detections");
// Stage 1: Get identity names and IDs
let id_a = sqlx::query_as::<_, (i32, String)>(&format!(
"SELECT id, name FROM {} WHERE uuid::text = $1 OR REPLACE(uuid::text, '-', '') = $1",
id_table
))
.bind(&identity_uuid_a)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
})?
.ok_or_else(|| {
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Identity A not found"})))
})?;
let id_b = sqlx::query_as::<_, (i32, String)>(&format!(
"SELECT id, name FROM {} WHERE uuid::text = $1 OR REPLACE(uuid::text, '-', '') = $1",
id_table
))
.bind(&identity_uuid_b)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
})?
.ok_or_else(|| {
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Identity B not found"})))
})?;
// Stage 2: Find first frame where both identity_ids appear
let cooccur: Option<(i64,)> = sqlx::query_as(
&format!(
"SELECT MIN(fd.frame_number)::bigint FROM {} fd \
WHERE fd.file_uuid = $1 AND fd.identity_id = $2 \
AND fd.frame_number IN ( \
SELECT frame_number FROM {} \
WHERE file_uuid = $1 AND identity_id = $3 \
)",
fd_table, fd_table
)
)
.bind(&file_uuid)
.bind(id_a.0)
.bind(id_b.0)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
})?;
let (first_frame,) = cooccur.ok_or_else(|| {
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "These two identities never appear together in this file"})))
})?;
// Get fps for timestamp
let video_table = schema::table_name("videos");
let fps: f64 = sqlx::query_scalar(&format!(
"SELECT COALESCE(fps, 25.0) FROM {} WHERE file_uuid = $1", video_table
))
.bind(&file_uuid)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
})?
.unwrap_or(25.0);
// Stage 3: Get trace_ids for both at this frame
let trace_a: Option<(i32,)> = sqlx::query_as(
&format!("SELECT trace_id FROM {} WHERE file_uuid = $1 AND frame_number = $2 AND identity_id = $3 AND trace_id IS NOT NULL LIMIT 1", fd_table)
)
.bind(&file_uuid).bind(first_frame).bind(id_a.0)
.fetch_optional(state.db.pool()).await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
})?;
let trace_b: Option<(i32,)> = sqlx::query_as(
&format!("SELECT trace_id FROM {} WHERE file_uuid = $1 AND frame_number = $2 AND identity_id = $3 AND trace_id IS NOT NULL LIMIT 1", fd_table)
)
.bind(&file_uuid).bind(first_frame).bind(id_b.0)
.fetch_optional(state.db.pool()).await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
})?;
// Stage 4: Get representative faces for both traces (reusing select_rep_face)
let rep_a = if let Some((tid,)) = trace_a {
select_rep_face(state.db.pool(), &file_uuid, tid, |e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
}).await.ok().map(|sel| CoOccurRepFace {
frame_number: sel.frame,
bbox: RepFaceBbox { x: sel.x, y: sel.y, width: sel.w, height: sel.h },
confidence: sel.conf,
thumbnail_url: format!("/api/v1/file/{}/trace/{}/thumbnail", file_uuid, tid),
})
} else { None };
let rep_b = if let Some((tid,)) = trace_b {
select_rep_face(state.db.pool(), &file_uuid, tid, |e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
}).await.ok().map(|sel| CoOccurRepFace {
frame_number: sel.frame,
bbox: RepFaceBbox { x: sel.x, y: sel.y, width: sel.w, height: sel.h },
confidence: sel.conf,
thumbnail_url: format!("/api/v1/file/{}/trace/{}/thumbnail", file_uuid, tid),
})
} else { None };
// Total co-occurrence frames (from TKG if available, otherwise from face_detections)
let total_cooccurrence_frames: i64 = sqlx::query_scalar(
&format!(
"SELECT COUNT(DISTINCT fd.frame_number)::bigint FROM {} fd \
WHERE fd.file_uuid = $1 AND fd.identity_id = $2 \
AND fd.frame_number IN ( \
SELECT frame_number FROM {} \
WHERE file_uuid = $1 AND identity_id = $3 \
)",
fd_table, fd_table
)
)
.bind(&file_uuid).bind(id_a.0).bind(id_b.0)
.fetch_one(state.db.pool()).await
.unwrap_or(0);
Ok(Json(CoOccurResponse {
success: true,
file_uuid,
identity_a: CoOccurIdentity {
identity_uuid: identity_uuid_a,
name: id_a.1,
trace_id: trace_a.map(|t| t.0).unwrap_or(0),
},
identity_b: CoOccurIdentity {
identity_uuid: identity_uuid_b,
name: id_b.1,
trace_id: trace_b.map(|t| t.0).unwrap_or(0),
},
first_cooccurrence: CoOccurrence {
frame_number: first_frame,
timestamp_secs: first_frame as f64 / fps,
total_cooccurrence_frames,
representative_face_a: rep_a,
representative_face_b: rep_b,
},
}))
}
use crate::core::config::OUTPUT_DIR;
#[derive(Serialize)]
struct TkgRebuildResponse {
success: bool,
file_uuid: String,
result: Option<serde_json::Value>,
error: Option<String>,
}
async fn rebuild_tkg(
State(state): State<crate::api::types::AppState>,
Path(file_uuid): Path<String>,
) -> Json<TkgRebuildResponse> {
let result = crate::core::processor::tkg::build_tkg(
&state.db,
&file_uuid,
&OUTPUT_DIR,
)
.await;
match result {
Ok(r) => Json(TkgRebuildResponse {
success: true,
file_uuid,
result: Some(serde_json::json!({
"face_trace_nodes": r.face_trace_nodes,
"object_nodes": r.object_nodes,
"speaker_nodes": r.speaker_nodes,
"co_occurrence_edges": r.co_occurrence_edges,
"speaker_face_edges": r.speaker_face_edges,
"face_face_edges": r.face_face_edges,
})),
error: None,
}),
Err(e) => Json(TkgRebuildResponse {
success: false,
file_uuid,
result: None,
error: Some(e.to_string()),
}),
}
}
// ── Representative Frame (JSON) ───────────────────────────────────
use crate::core::processor::tkg;
#[derive(Serialize)]
struct RepFrameResponse {
success: bool,
file_uuid: String,
frame_number: i64,
timestamp_secs: f64,
face_quality: f64,
main_identities: Vec<tkg::MainIdentityInfo>,
traces: Vec<tkg::FrameTraceInfo>,
}
async fn get_representative_frame(
State(state): State<crate::api::types::AppState>,
Path(file_uuid): Path<String>,
) -> Result<Json<RepFrameResponse>, (StatusCode, Json<serde_json::Value>)> {
let result = tkg::query_auto_representative_frame(
state.db.pool(),
&file_uuid,
)
.await
.map_err(|e| {
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": e.to_string()})))
})?;
let fps = query_fps(state.db.pool(), &file_uuid).await;
Ok(Json(RepFrameResponse {
success: true,
file_uuid,
frame_number: result.frame_number,
timestamp_secs: result.frame_number as f64 / fps,
face_quality: result.face_quality,
main_identities: result.main_identities,
traces: result.traces,
}))
}
async fn query_fps(pool: &sqlx::PgPool, file_uuid: &str) -> f64 {
use crate::core::db::schema;
let video_table = schema::table_name("videos");
sqlx::query_scalar(&format!(
"SELECT COALESCE(fps, 25.0) FROM {} WHERE file_uuid = $1",
video_table
))
.bind(file_uuid)
.fetch_optional(pool)
.await
.ok()
.flatten()
.unwrap_or(25.0)
}