diff --git a/docs_v1.0/API_WORKSPACE/modules/08_media.md b/docs_v1.0/API_WORKSPACE/modules/08_media.md index cf81696..e6927cc 100644 --- a/docs_v1.0/API_WORKSPACE/modules/08_media.md +++ b/docs_v1.0/API_WORKSPACE/modules/08_media.md @@ -194,6 +194,8 @@ Uses a built-in 5×7 bitmap font renderer to draw labels directly on video frame Extract a single frame from a video as JPEG image. Uses FFmpeg `select` filter. +When `frame` is omitted, the system automatically selects the best representative frame using the TKG bridge (see algorithm below). + **Auth**: Required **Scope**: file-level @@ -201,7 +203,7 @@ Extract a single frame from a video as JPEG image. Uses FFmpeg `select` filter. | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| -| `frame` | integer | Yes | — | Zero-based frame number to extract | +| `frame` | integer | No | auto-detect | Zero-based frame number to extract. Omit for auto-detect. | | `x` | integer | No | — | Crop start X (left edge). Requires `y`, `w`, `h`. | | `y` | integer | No | — | Crop start Y (top edge). Requires `x`, `w`, `h`. | | `w` | integer | No | — | Crop width in pixels. Requires `x`, `y`, `h`. | @@ -209,9 +211,26 @@ Extract a single frame from a video as JPEG image. Uses FFmpeg `select` filter. All four crop params (`x`, `y`, `w`, `h`) must be provided together or omitted. -#### Example +#### Auto-detect Algorithm + +When `frame` is not provided, the endpoint finds the best frame using this fallback chain: + +1. **Main characters**: find the two identities with the most face detections (TMDb source) +2. **Mutual gaze**: if their face traces have a TKG `CO_OCCURS_WITH` edge with `mutual_gaze=true`, take `first_frame` +3. **Co-occurrence**: fallback to the first frame where both identities appear together +4. **Single identity**: if only one main identity exists, take its highest-quality face frame +5. **Any identity**: fallback to the best-quality face frame across all identities +6. **Error**: if no face exists, returns `404` + +The selected frame is constrained to the **first half of the video** (`total_frames / 2`). + +#### Examples ```bash +# Auto-detect best representative frame +curl -s "$API/api/v1/file/$FILE_UUID/thumbnail" \ + -H "X-API-Key: $KEY" -o representative.jpg + # Extract frame 1000 (full frame) curl -s "$API/api/v1/file/bd80fec92b0b6963d177a2c55bf713e2/thumbnail?frame=1000" \ -H "Authorization: Bearer $JWT" -o frame_1000.jpg @@ -224,10 +243,104 @@ curl -s "$API/api/v1/file/bd80fec92b0b6963d177a2c55bf713e2/thumbnail?frame=1000& #### Response - **200**: `image/jpeg` binary data -- **404**: File not found +- **404**: File not found / No faces in file (auto-detect) - **500**: FFmpeg error (e.g., frame number exceeds video duration) -### `GET /api/v1/file/:file_uuid/clip` +#### Technical Details + +| Detail | Value | +|--------|-------| +| **Backend** | FFmpeg (`ffmpeg-full`) | +| **Filter** | `select=eq(n\,FRAME)` to select frame, optional `crop=W:H:X:Y` | +| **Output** | Single JPEG via pipe (`image2pipe`, `mjpeg` codec) | +| **Cache** | `Cache-Control: public, max-age=86400` (24h) | +| **Frame number** | Zero-based (`frame=0` = first frame of video) | + +--- + +### `GET /api/v1/file/:file_uuid/representative-frame` + +Return JSON metadata about the best representative frame for the video. Uses the same auto-detect algorithm as `GET /thumbnail` (without crop support). + +**Auth**: Required +**Scope**: file-level + +#### Example + +```bash +curl -s "$API/api/v1/file/$FILE_UUID/representative-frame" \ + -H "X-API-Key: $KEY" | jq '.' +``` + +#### Response (200) + +```json +{ + "success": true, + "file_uuid": "aeed71342a899fe4b4c57b7d41bcb692", + "frame_number": 38165, + "timestamp_secs": 1526.6, + "face_quality": 37292.97, + "main_identities": [ + { + "identity_uuid": "c3545906-c82d-4b66-aa1d-150bc02decce", + "name": "Audrey Hepburn", + "face_count": 16456 + }, + { + "identity_uuid": "2b0ddefe-e2a9-4533-9308-b375594604d5", + "name": "Cary Grant", + "face_count": 10643 + } + ], + "traces": [ + { + "trace_id": 919, + "identity_uuid": "2b0ddefe-e2a9-4533-9308-b375594604d5", + "name": "Cary Grant", + "x": 764, + "y": 237, + "width": 199, + "height": 199, + "confidence": 0.8426 + }, + { + "trace_id": 920, + "identity_uuid": "c3545906-c82d-4b66-aa1d-150bc02decce", + "name": "Audrey Hepburn", + "x": 1143, + "y": 312, + "width": 215, + "height": 215, + "confidence": 0.8068 + } + ] +} +``` + +#### Response Fields + +| Field | Type | Description | +|-------|------|-------------| +| `frame_number` | integer | Selected representative frame number (primary coordinate) | +| `timestamp_secs` | float | Time in seconds (derived from `frame_number / fps`) | +| `face_quality` | float | Quality score `area × confidence` of the best face at this frame | +| `main_identities` | array | Top 2 most frequent TMDb identities in the file | +| `main_identities[].name` | string | Identity display name | +| `main_identities[].face_count` | integer | Total face detections count | +| `traces` | array | All face traces present at the selected frame | +| `traces[].trace_id` | integer | Face trace ID | +| `traces[].identity_uuid` | string or null | Matched identity UUID | +| `traces[].name` | string or null | Identity name | +| `traces[].x, y, width, height` | integer | Bounding box coordinates | +| `traces[].confidence` | float | Detection confidence (0.0–1.0) | + +#### Error Responses + +| HTTP | When | +|------|------| +| `404` | File not found / No faces in file | +| `500` | Database error | Extract a video clip (time range) as MPEG-TS stream. Uses FFmpeg `-ss` fast seek. diff --git a/docs_v1.0/doc_wasm/index.html b/docs_v1.0/doc_wasm/index.html index 5ea42c3..94fbe16 100644 --- a/docs_v1.0/doc_wasm/index.html +++ b/docs_v1.0/doc_wasm/index.html @@ -11,7 +11,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; } #app { display: flex; min-height: 100vh; } html, body { height: 100%; } -.sidebar { width: 260px; min-height: 100vh; background: #fff; border-right: 1px solid #ddd; padding: 20px; display: flex; flex-direction: column; } +.sidebar { width: 260px; height: 100vh; position: sticky; top: 0; overflow-y: auto; background: #fff; border-right: 1px solid #ddd; padding: 20px; display: flex; flex-direction: column; } .sidebar h1 { font-size: 18px; margin-bottom: 16px; } .sidebar a { display: block; padding: 6px 0; color: #0066cc; text-decoration: none; font-size: 14px; cursor: pointer; } .sidebar a:hover { color: #003d80; } diff --git a/docs_v1.0/doc_wasm/modules/08_media.md b/docs_v1.0/doc_wasm/modules/08_media.md index cf81696..e6927cc 100644 --- a/docs_v1.0/doc_wasm/modules/08_media.md +++ b/docs_v1.0/doc_wasm/modules/08_media.md @@ -194,6 +194,8 @@ Uses a built-in 5×7 bitmap font renderer to draw labels directly on video frame Extract a single frame from a video as JPEG image. Uses FFmpeg `select` filter. +When `frame` is omitted, the system automatically selects the best representative frame using the TKG bridge (see algorithm below). + **Auth**: Required **Scope**: file-level @@ -201,7 +203,7 @@ Extract a single frame from a video as JPEG image. Uses FFmpeg `select` filter. | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| -| `frame` | integer | Yes | — | Zero-based frame number to extract | +| `frame` | integer | No | auto-detect | Zero-based frame number to extract. Omit for auto-detect. | | `x` | integer | No | — | Crop start X (left edge). Requires `y`, `w`, `h`. | | `y` | integer | No | — | Crop start Y (top edge). Requires `x`, `w`, `h`. | | `w` | integer | No | — | Crop width in pixels. Requires `x`, `y`, `h`. | @@ -209,9 +211,26 @@ Extract a single frame from a video as JPEG image. Uses FFmpeg `select` filter. All four crop params (`x`, `y`, `w`, `h`) must be provided together or omitted. -#### Example +#### Auto-detect Algorithm + +When `frame` is not provided, the endpoint finds the best frame using this fallback chain: + +1. **Main characters**: find the two identities with the most face detections (TMDb source) +2. **Mutual gaze**: if their face traces have a TKG `CO_OCCURS_WITH` edge with `mutual_gaze=true`, take `first_frame` +3. **Co-occurrence**: fallback to the first frame where both identities appear together +4. **Single identity**: if only one main identity exists, take its highest-quality face frame +5. **Any identity**: fallback to the best-quality face frame across all identities +6. **Error**: if no face exists, returns `404` + +The selected frame is constrained to the **first half of the video** (`total_frames / 2`). + +#### Examples ```bash +# Auto-detect best representative frame +curl -s "$API/api/v1/file/$FILE_UUID/thumbnail" \ + -H "X-API-Key: $KEY" -o representative.jpg + # Extract frame 1000 (full frame) curl -s "$API/api/v1/file/bd80fec92b0b6963d177a2c55bf713e2/thumbnail?frame=1000" \ -H "Authorization: Bearer $JWT" -o frame_1000.jpg @@ -224,10 +243,104 @@ curl -s "$API/api/v1/file/bd80fec92b0b6963d177a2c55bf713e2/thumbnail?frame=1000& #### Response - **200**: `image/jpeg` binary data -- **404**: File not found +- **404**: File not found / No faces in file (auto-detect) - **500**: FFmpeg error (e.g., frame number exceeds video duration) -### `GET /api/v1/file/:file_uuid/clip` +#### Technical Details + +| Detail | Value | +|--------|-------| +| **Backend** | FFmpeg (`ffmpeg-full`) | +| **Filter** | `select=eq(n\,FRAME)` to select frame, optional `crop=W:H:X:Y` | +| **Output** | Single JPEG via pipe (`image2pipe`, `mjpeg` codec) | +| **Cache** | `Cache-Control: public, max-age=86400` (24h) | +| **Frame number** | Zero-based (`frame=0` = first frame of video) | + +--- + +### `GET /api/v1/file/:file_uuid/representative-frame` + +Return JSON metadata about the best representative frame for the video. Uses the same auto-detect algorithm as `GET /thumbnail` (without crop support). + +**Auth**: Required +**Scope**: file-level + +#### Example + +```bash +curl -s "$API/api/v1/file/$FILE_UUID/representative-frame" \ + -H "X-API-Key: $KEY" | jq '.' +``` + +#### Response (200) + +```json +{ + "success": true, + "file_uuid": "aeed71342a899fe4b4c57b7d41bcb692", + "frame_number": 38165, + "timestamp_secs": 1526.6, + "face_quality": 37292.97, + "main_identities": [ + { + "identity_uuid": "c3545906-c82d-4b66-aa1d-150bc02decce", + "name": "Audrey Hepburn", + "face_count": 16456 + }, + { + "identity_uuid": "2b0ddefe-e2a9-4533-9308-b375594604d5", + "name": "Cary Grant", + "face_count": 10643 + } + ], + "traces": [ + { + "trace_id": 919, + "identity_uuid": "2b0ddefe-e2a9-4533-9308-b375594604d5", + "name": "Cary Grant", + "x": 764, + "y": 237, + "width": 199, + "height": 199, + "confidence": 0.8426 + }, + { + "trace_id": 920, + "identity_uuid": "c3545906-c82d-4b66-aa1d-150bc02decce", + "name": "Audrey Hepburn", + "x": 1143, + "y": 312, + "width": 215, + "height": 215, + "confidence": 0.8068 + } + ] +} +``` + +#### Response Fields + +| Field | Type | Description | +|-------|------|-------------| +| `frame_number` | integer | Selected representative frame number (primary coordinate) | +| `timestamp_secs` | float | Time in seconds (derived from `frame_number / fps`) | +| `face_quality` | float | Quality score `area × confidence` of the best face at this frame | +| `main_identities` | array | Top 2 most frequent TMDb identities in the file | +| `main_identities[].name` | string | Identity display name | +| `main_identities[].face_count` | integer | Total face detections count | +| `traces` | array | All face traces present at the selected frame | +| `traces[].trace_id` | integer | Face trace ID | +| `traces[].identity_uuid` | string or null | Matched identity UUID | +| `traces[].name` | string or null | Identity name | +| `traces[].x, y, width, height` | integer | Bounding box coordinates | +| `traces[].confidence` | float | Detection confidence (0.0–1.0) | + +#### Error Responses + +| HTTP | When | +|------|------| +| `404` | File not found / No faces in file | +| `500` | Database error | Extract a video clip (time range) as MPEG-TS stream. Uses FFmpeg `-ss` fast seek. diff --git a/src/api/docs.rs b/src/api/docs.rs index 9a51a8b..1f52c6b 100644 --- a/src/api/docs.rs +++ b/src/api/docs.rs @@ -9,7 +9,7 @@ async fn doc_redirect() -> axum::response::Redirect { async fn wasm_doc_handler() -> Result { let path = - std::path::Path::new("/Users/accusys/momentry_core_0.1/docs_v1.0/doc_wasm/index.html"); + std::path::Path::new("/Users/accusys/momentry_core/docs_v1.0/doc_wasm/index.html"); match tokio::fs::read_to_string(path).await { Ok(html) => Ok(([("content-type", "text/html; charset=utf-8")], html)), Err(_) => Err((StatusCode::NOT_FOUND, "Doc not found")), @@ -22,7 +22,7 @@ async fn wasm_doc_file_handler( if file.contains("..") || file.contains("//") { return Err((StatusCode::NOT_FOUND, "Invalid path")); } - let base = std::path::Path::new("/Users/accusys/momentry_core_0.1/docs_v1.0/doc_wasm"); + let base = std::path::Path::new("/Users/accusys/momentry_core/docs_v1.0/doc_wasm"); let path = base.join(&file); if !path.exists() || !path.starts_with(base) { return Err((StatusCode::NOT_FOUND, "File not found")); diff --git a/src/api/media_api.rs b/src/api/media_api.rs index c3b290d..2150e2f 100644 --- a/src/api/media_api.rs +++ b/src/api/media_api.rs @@ -690,7 +690,7 @@ async fn stream_video( #[derive(Debug, serde::Deserialize)] struct ThumbQuery { - frame: i64, + frame: Option, x: Option, y: Option, w: Option, @@ -703,6 +703,20 @@ async fn face_thumbnail( Query(q): Query, ) -> Result { let videos_table = schema::table_name("videos"); + + let frame = match q.frame { + Some(f) => f, + None => { + let result = crate::core::processor::tkg::query_auto_representative_frame( + state.db.pool(), + &file_uuid, + ) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + result.frame_number + } + }; + let row: Option<(String,)> = sqlx::query_as(&format!( "SELECT file_path FROM {} WHERE file_uuid = $1", videos_table @@ -713,7 +727,7 @@ async fn face_thumbnail( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let (file_path,) = row.ok_or(StatusCode::NOT_FOUND)?; - let select = format!("select=eq(n\\,{})", q.frame); + let select = format!("select=eq(n\\,{})", frame); let vf = if let (Some(x), Some(y), Some(w), Some(h)) = (q.x, q.y, q.w, q.h) { format!("{},crop={}:{}:{}:{}", select, w, h, x, y) } else { diff --git a/src/api/trace_agent_api.rs b/src/api/trace_agent_api.rs index c1d4dd1..d6e946e 100644 --- a/src/api/trace_agent_api.rs +++ b/src/api/trace_agent_api.rs @@ -33,6 +33,10 @@ pub fn trace_agent_routes() -> Router { "/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)] @@ -783,3 +787,59 @@ async fn rebuild_tkg( }), } } + +// ── 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) +} diff --git a/src/core/processor/mod.rs b/src/core/processor/mod.rs index ada48ef..51ebb07 100644 --- a/src/core/processor/mod.rs +++ b/src/core/processor/mod.rs @@ -36,6 +36,9 @@ pub use scene_classification::{ SceneSegment, }; pub use story::{process_story, StoryChildChunk, StoryParentChunk, StoryResult, StoryStats}; -pub use tkg::{build_tkg, TkgResult}; +pub use tkg::{ + build_tkg, query_auto_representative_frame, FrameTraceInfo, MainIdentityInfo, + RepresentativeFrameResult, TkgResult, +}; pub use visual_chunk::{process_visual_chunk, process_visual_chunk_advanced, VisualChunkResult}; pub use yolo::{process_yolo, YoloFrame, YoloObject, YoloResult}; diff --git a/src/core/processor/tkg.rs b/src/core/processor/tkg.rs index 9cd7d3d..52a6147 100644 --- a/src/core/processor/tkg.rs +++ b/src/core/processor/tkg.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::collections::HashMap; use std::path::Path; @@ -835,6 +835,206 @@ async fn build_face_face_edges(pool: &PgPool, file_uuid: &str, pose_data: &[Face Ok(edge_count) } +// ── TKG Bridge: Representative Frame ────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct FrameTraceInfo { + pub trace_id: i32, + pub identity_uuid: Option, + pub name: Option, + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, + pub confidence: f64, +} + +#[derive(Debug, Serialize)] +pub struct MainIdentityInfo { + pub identity_uuid: String, + pub name: String, + pub face_count: i64, +} + +#[derive(Debug, Serialize)] +pub struct RepresentativeFrameResult { + pub frame_number: i64, + pub face_quality: f64, + pub main_identities: Vec, + pub traces: Vec, +} + +pub async fn query_auto_representative_frame( + pool: &PgPool, + file_uuid: &str, +) -> Result { + let id_table = t("identities"); + let fd_table = t("face_detections"); + let nodes_table = t("tkg_nodes"); + let edges_table = t("tkg_edges"); + let video_table = t("videos"); + + let half_frame: i64 = match sqlx::query_scalar::<_, i64>(&format!( + "SELECT COALESCE(total_frames / 2, 0) FROM {} WHERE file_uuid = $1", + video_table + )) + .bind(file_uuid) + .fetch_optional(pool) + .await? + { + Some(f) if f > 0 => f, + _ => i64::MAX, + }; + + let mains = sqlx::query_as::<_, (i32, String, String, i64)>(&format!( + "SELECT i.id, i.uuid::text, i.name, COUNT(fd.id)::bigint \ + FROM {} fd \ + JOIN {} i ON i.id = fd.identity_id \ + WHERE fd.file_uuid = $1 AND fd.identity_id IS NOT NULL \ + AND i.source = 'tmdb' \ + GROUP BY i.id, i.uuid, i.name \ + ORDER BY COUNT(fd.id) DESC LIMIT 2", + fd_table, id_table + )) + .bind(file_uuid) + .fetch_all(pool) + .await + .context("Failed to detect main identities")?; + + let main_ids: Vec<(i32, String, String, i64)> = mains; + let main_idents: Vec = main_ids.iter().map(|(_, u, n, c)| + MainIdentityInfo { identity_uuid: u.clone(), name: n.clone(), face_count: *c } + ).collect(); + + let frame_number: Option = if main_ids.len() >= 2 { + let id_a = main_ids[0].0; + let id_b = main_ids[1].0; + + let trace_a: Option<(i32,)> = sqlx::query_as(&format!( + "SELECT trace_id FROM {} WHERE file_uuid = $1 AND identity_id = $2 \ + AND trace_id IS NOT NULL GROUP BY trace_id ORDER BY COUNT(*) DESC LIMIT 1", + fd_table + )) + .bind(file_uuid).bind(id_a) + .fetch_optional(pool).await?; + + let trace_b: Option<(i32,)> = sqlx::query_as(&format!( + "SELECT trace_id FROM {} WHERE file_uuid = $1 AND identity_id = $2 \ + AND trace_id IS NOT NULL GROUP BY trace_id ORDER BY COUNT(*) DESC LIMIT 1", + fd_table + )) + .bind(file_uuid).bind(id_b) + .fetch_optional(pool).await?; + + match (trace_a, trace_b) { + (Some((ta,)), Some((tb,))) => { + let tkg_frame: Option<(i64,)> = sqlx::query_as(&format!( + "SELECT (e.properties->>'first_frame')::bigint \ + FROM {} e \ + JOIN {} a ON a.id = e.source_node_id \ + JOIN {} b ON b.id = e.target_node_id \ + WHERE e.file_uuid = $1 \ + AND a.external_id = concat('trace_', $2) \ + AND b.external_id = concat('trace_', $3) \ + AND e.properties->>'mutual_gaze' = 'true' \ + LIMIT 1", + edges_table, nodes_table, nodes_table + )) + .bind(file_uuid).bind(ta).bind(tb) + .fetch_optional(pool).await?; + + if let Some((f,)) = tkg_frame { + if f <= half_frame { Some(f) } else { None } + } else { + sqlx::query_scalar::<_, i64>(&format!( + "SELECT MIN(fd_a.frame_number)::bigint \ + FROM {} fd_a \ + JOIN {} fd_b ON fd_a.frame_number = fd_b.frame_number \ + WHERE fd_a.file_uuid = $1 AND fd_a.identity_id = $2 \ + AND fd_b.identity_id = $3 AND fd_a.frame_number <= $4", + fd_table, fd_table + )) + .bind(file_uuid).bind(id_a).bind(id_b).bind(half_frame) + .fetch_optional(pool).await? + } + } + _ => None, + } + } else { + None + }; + + let frame_number: Option = match frame_number { + Some(f) => Some(f), + None => { + if let Some((first_id,)) = main_ids.first().map(|(id, _, _, _)| (*id,)) { + sqlx::query_scalar::<_, i64>(&format!( + "SELECT frame_number::bigint FROM {} \ + WHERE file_uuid = $1 AND identity_id = $2 \ + AND frame_number <= $3 \ + ORDER BY (width::float8 * height::float8) * confidence::float8 DESC \ + LIMIT 1", + fd_table + )) + .bind(file_uuid).bind(first_id).bind(half_frame) + .fetch_optional(pool).await? + } else { + None + } + } + }; + + let frame_number: Option = match frame_number { + Some(f) => Some(f), + None => { + sqlx::query_scalar::<_, i64>(&format!( + "SELECT frame_number::bigint FROM {} \ + WHERE file_uuid = $1 AND identity_id IS NOT NULL \ + AND frame_number <= $2 \ + ORDER BY (width::float8 * height::float8) * confidence::float8 DESC \ + LIMIT 1", + fd_table + )) + .bind(file_uuid).bind(half_frame) + .fetch_optional(pool).await? + } + }; + + let frame_number = frame_number.ok_or_else(|| anyhow::anyhow!("No faces found in this file"))?; + + let face_quality: f64 = sqlx::query_scalar::<_, f64>(&format!( + "SELECT COALESCE(MAX((width::float8 * height::float8) * confidence::float8), 0) \ + FROM {} WHERE file_uuid = $1 AND frame_number = $2", + fd_table + )) + .bind(file_uuid).bind(frame_number) + .fetch_one(pool).await?; + + let traces: Vec = sqlx::query_as::<_, (i32, Option, Option, i32, i32, i32, i32, f64)>(&format!( + "SELECT fd.trace_id, i.uuid::text, i.name, fd.x, fd.y, fd.width, fd.height, fd.confidence::float8 \ + FROM {} fd \ + LEFT JOIN {} i ON i.id = fd.identity_id \ + WHERE fd.file_uuid = $1 AND fd.frame_number = $2 AND fd.trace_id IS NOT NULL \ + ORDER BY fd.trace_id", + fd_table, id_table + )) + .bind(file_uuid).bind(frame_number) + .fetch_all(pool) + .await? + .into_iter() + .map(|(trace_id, identity_uuid, name, x, y, width, height, confidence)| { + FrameTraceInfo { trace_id, identity_uuid, name, x, y, width, height, confidence } + }) + .collect(); + + Ok(RepresentativeFrameResult { + frame_number, + face_quality, + main_identities: main_idents, + traces, + }) +} + // ── Tests ───────────────────────────────────────────────────────── #[cfg(test)]