Merge branch 'main' of http://192.168.110.200:3000/admin/momentry_core
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ async fn doc_redirect() -> axum::response::Redirect {
|
||||
async fn wasm_doc_handler() -> Result<impl axum::response::IntoResponse, (StatusCode, &'static str)>
|
||||
{
|
||||
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"));
|
||||
|
||||
@@ -690,7 +690,7 @@ async fn stream_video(
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct ThumbQuery {
|
||||
frame: i64,
|
||||
frame: Option<i64>,
|
||||
x: Option<i32>,
|
||||
y: Option<i32>,
|
||||
w: Option<i32>,
|
||||
@@ -703,6 +703,20 @@ async fn face_thumbnail(
|
||||
Query(q): Query<ThumbQuery>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
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 {
|
||||
|
||||
@@ -33,6 +33,10 @@ pub fn trace_agent_routes() -> Router<crate::api::types::AppState> {
|
||||
"/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<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)
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<String>,
|
||||
pub name: Option<String>,
|
||||
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<MainIdentityInfo>,
|
||||
pub traces: Vec<FrameTraceInfo>,
|
||||
}
|
||||
|
||||
pub async fn query_auto_representative_frame(
|
||||
pool: &PgPool,
|
||||
file_uuid: &str,
|
||||
) -> Result<RepresentativeFrameResult> {
|
||||
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<MainIdentityInfo> = main_ids.iter().map(|(_, u, n, c)|
|
||||
MainIdentityInfo { identity_uuid: u.clone(), name: n.clone(), face_count: *c }
|
||||
).collect();
|
||||
|
||||
let frame_number: Option<i64> = 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<i64> = 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<i64> = 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<FrameTraceInfo> = sqlx::query_as::<_, (i32, Option<String>, Option<String>, 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)]
|
||||
|
||||
Reference in New Issue
Block a user