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 { 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, sort_by: Option, page: Option, page_size: Option, limit: Option, min_confidence: Option, max_confidence: Option, } #[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, } #[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, } async fn list_traces_sorted( State(state): State, Path(file_uuid): Path, Json(req): Json, ) -> Result, (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)> = 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 = 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, page_size: Option, limit: Option, offset: Option, interpolate: Option, } #[derive(Debug, Serialize)] struct TraceFaceItem { id: i32, start_frame: i32, end_frame: i32, start_time: f64, end_time: f64, x: Option, y: Option, width: Option, height: Option, confidence: f64, interpolated: bool, } #[derive(Debug, Serialize)] struct TraceFacesResponse { success: bool, file_uuid: String, trace_id: i32, fps: f64, total: i64, faces: Vec, } fn lerp_i32(a: Option, b: Option, t: f64) -> Option { 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, Path((file_uuid, trace_id)): Path<(String, i32)>, Query(q): Query, ) -> Result, (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, Option, Option, Option, 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 = 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( pool: &sqlx::PgPool, file_uuid: &str, trace_id: i32, err_fn: F, ) -> Result 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 = 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::() { 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, Path((file_uuid, trace_id)): Path<(String, i32)>, ) -> Result, (StatusCode, Json)> { 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, Path((file_uuid, trace_id)): Path<(String, i32)>, ) -> Result)> { 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, representative_face_b: Option, } #[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, Path((file_uuid, identity_uuid_a, identity_uuid_b)): Path<(String, String, String)>, ) -> Result, (StatusCode, Json)> { 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, error: Option, } async fn rebuild_tkg( State(state): State, Path(file_uuid): Path, ) -> Json { 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, traces: Vec, } async fn get_representative_frame( State(state): State, Path(file_uuid): Path, ) -> Result, (StatusCode, Json)> { 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) }