diff --git a/docs_v1.0/M4_workspace/2026-07-19_pose_appearance_poc.md b/docs_v1.0/M4_workspace/2026-07-19_pose_appearance_poc.md new file mode 100644 index 0000000..647e437 --- /dev/null +++ b/docs_v1.0/M4_workspace/2026-07-19_pose_appearance_poc.md @@ -0,0 +1,300 @@ +# Pose & Appearance API POC Plan + +**Date**: 2026-07-19 +**Author**: Core API Team +**Status**: Ready for Implementation +**Target**: Studio Team + +--- + +## 1. Overview + +This document outlines the POC implementation for pose skeleton and appearance color display in Momentry Studio's Face Detail Modal. + +--- + +## 2. Endpoints + +### 2.1 Get Pose + +``` +GET /api/v1/file/:file_uuid/pose?frame=:frame_no +``` + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `file_uuid` | string | Yes | File UUID (path parameter) | +| `frame` | integer | Yes | Frame number (query parameter) | + +**Response (200):** + +```json +{ + "frame": 303, + "keypoints": [ + {"name": "nose", "x": 315.9, "y": 364.2, "confidence": 0.616}, + {"name": "left_eye", "x": 312.4, "y": 362.8, "confidence": 0.616}, + {"name": "right_eye", "x": 316.2, "y": 361.4, "confidence": 0.616}, + {"name": "left_ear", "x": 343.1, "y": 354.7, "confidence": 0.856}, + {"name": "right_ear", "x": 0, "y": 0, "confidence": 0}, + {"name": "left_shoulder", "x": 0, "y": 0, "confidence": 0}, + {"name": "right_shoulder", "x": 296.0, "y": 410.8, "confidence": 0.863}, + {"name": "left_elbow", "x": 0, "y": 0, "confidence": 0}, + {"name": "right_elbow", "x": 0, "y": 0, "confidence": 0}, + {"name": "left_wrist", "x": 0, "y": 0, "confidence": 0}, + {"name": "right_wrist", "x": 0, "y": 0, "confidence": 0}, + {"name": "left_hip", "x": 0, "y": 0, "confidence": 0}, + {"name": "right_hip", "x": 0, "y": 0, "confidence": 0}, + {"name": "left_knee", "x": 0, "y": 0, "confidence": 0}, + {"name": "right_knee", "x": 0, "y": 0, "confidence": 0}, + {"name": "left_ankle", "x": 0, "y": 0, "confidence": 0}, + {"name": "right_ankle", "x": 0, "y": 0, "confidence": 0} + ], + "pose_class": "unknown", + "confidence": null +} +``` + +**Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `frame` | integer | Frame number | +| `keypoints` | array | 17 COCO keypoints (deduplicated) | +| `keypoints[].name` | string | Keypoint name | +| `keypoints[].x` | float | X coordinate in pixels | +| `keypoints[].y` | float | Y coordinate in pixels | +| `keypoints[].confidence` | float | Detection confidence 0-1 (0 means not detected) | +| `pose_class` | string | Always `"unknown"` in POC | +| `confidence` | null | Always `null` in POC | + +**Error Responses:** + +| HTTP | When | +|------|------| +| 404 | Frame not found or pose.json not found | +| 401 | Missing or invalid API key | + +--- + +### 2.2 Get Appearance + +``` +GET /api/v1/file/:file_uuid/appearance?frame=:frame_no +``` + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `file_uuid` | string | Yes | File UUID (path parameter) | +| `frame` | integer | Yes | Frame number (query parameter) | + +**Response (200):** + +```json +{ + "frame": 0, + "dominant_colors": [], + "hsv_histogram": [ + [0.59, 0, 0, ...], + [0.97, 0.02, 0.01, ...], + [0.004, 0.02, 0.03, ...] + ] +} +``` + +**Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `frame` | integer | Frame number | +| `dominant_colors` | array | Empty array in POC (`[]`) | +| `hsv_histogram` | array | 3x30 HSV histogram bins | + +**Error Responses:** + +| HTTP | When | +|------|------| +| 404 | Frame not found or appearance.json not found | +| 401 | Missing or invalid API key | + +--- + +## 3. Implementation Notes + +### 3.1 Data Source + +| Processor | File | Location | +|-----------|------|----------| +| Pose | `{file_uuid}.pose.json` | `{OUTPUT_DIR}/{file_uuid}/` | +| Appearance | `{file_uuid}.appearance.json` | `{OUTPUT_DIR}/{file_uuid}/` | + +### 3.2 Keypoint Deduplication + +The pose processor may output multiple keypoints with the same name (e.g., 8 "nose" entries). Core API will: + +1. Group keypoints by `name` +2. Select the one with highest `confidence` +3. Return exactly 17 unique keypoints + +If a keypoint is not detected, it will have: +```json +{"name": "left_shoulder", "x": 0, "y": 0, "confidence": 0} +``` + +### 3.3 COCO-17 Keypoint Names + +``` +nose, left_eye, right_eye, left_ear, right_ear, +left_shoulder, right_shoulder, left_elbow, right_elbow, +left_wrist, right_wrist, left_hip, right_hip, +left_knee, right_knee, left_ankle, right_ankle +``` + +### 3.4 Frame Alignment + +- Pose/appearance `frame` numbers should align with face `best_face_frame` +- If frame not found, API returns 404 +- Frontend should use the face's `best_face_frame` or `start_frame` to query pose/appearance + +--- + +## 4. Frontend Integration (Studio Team) + +### 4.1 API Builder + +**File**: `src/api/index.ts` + +```typescript +case 'get_pose': { + const { fileUuid, frame } = a + return { + url: `/api/v1/file/${fileUuid}/pose?frame=${frame}`, + method: 'GET' + } +} + +case 'get_appearance': { + const { fileUuid, frame } = a + return { + url: `/api/v1/file/${fileUuid}/appearance?frame=${frame}`, + method: 'GET' + } +} +``` + +### 4.2 Store Functions + +**File**: `src/store.ts` + +```typescript +export async function getPose(fileUuid: string, frame: number): Promise { + const response = await callApi('get_pose', { fileUuid, frame }) + return response +} + +export async function getAppearance(fileUuid: string, frame: number): Promise { + const response = await callApi('get_appearance', { fileUuid, frame }) + return response +} +``` + +### 4.3 TypeScript Types + +**File**: `src/api/types.ts` + +Already defined in Studio codebase: +- `PoseKeypoint` +- `PoseClass` +- `PoseResponse` +- `DominantColor` +- `AppearanceResponse` + +### 4.4 Usage Example + +```typescript +// In Face Detail Modal +const face = selectedFace +const frame = face.best_face_frame || face.start_frame + +// Fetch pose +const pose = await getPose(fileUuid, frame) + +// Fetch appearance +const appearance = await getAppearance(fileUuid, frame) + +// Render skeleton on canvas +renderPoseSkeleton(canvas, pose.keypoints) + +// Display colors (when implemented) +displayDominantColors(appearance.dominant_colors) +``` + +--- + +## 5. POC Limitations + +| Feature | POC Status | Future | +|---------|------------|--------| +| Pose classification | `"unknown"` | ML model for standing/sitting/etc. | +| Dominant colors | Empty array `[]` | HSV extraction algorithm | +| Multiple persons | First person only | Match by trace_id | +| Confidence | `null` | Aggregated confidence score | + +--- + +## 6. Testing + +### 6.1 Test Commands + +```bash +# Set variables +API="http://localhost:3002" +KEY="muser_demo_key_32chars_abcdef1234567890" +FILE_UUID="477b24d3030c56b64092c8fea96676df" + +# Test pose endpoint +curl -s "$API/api/v1/file/$FILE_UUID/pose?frame=303" \ + -H "X-API-Key: $KEY" | jq '.keypoints | length' + +# Expected: 17 + +# Test appearance endpoint +curl -s "$API/api/v1/file/$FILE_UUID/appearance?frame=303" \ + -H "X-API-Key: $KEY" | jq '.hsv_histogram | length' + +# Expected: 3 (H, S, V channels) +``` + +### 6.2 Expected Response Time + +- Target: < 100ms per request +- Cacheable: Yes (pose/appearance data doesn't change) + +--- + +## 7. Timeline + +| Phase | Team | Task | Status | +|-------|------|------|--------| +| 1 | Core API | Implement endpoints | ✅ Complete | +| 2 | Core API | Test & verify | ✅ Complete | +| 3 | Core API | Notify Studio | ✅ Complete | +| 4 | Studio | Frontend integration | Pending | +| 5 | Studio | UI testing | Pending | + +--- + +## 8. Contact + +**Core API Team**: Will notify Studio when Phase 2 is complete via `M4_workspace/` document. + +**Studio Team**: Ready to integrate once Phase 3 notification is received. + +--- + +*Document Version: 1.0* +*Last Updated: 2026-07-19* \ No newline at end of file diff --git a/docs_v1.0/doc_wasm/modules/18_profile.md b/docs_v1.0/doc_wasm/modules/18_profile.md index b07a8ab..bde2421 100644 --- a/docs_v1.0/doc_wasm/modules/18_profile.md +++ b/docs_v1.0/doc_wasm/modules/18_profile.md @@ -1,5 +1,5 @@ - + ## Profile Management @@ -158,6 +158,77 @@ curl -s -X PUT "$API/api/v1/trace-profile/group" \ --- +### `GET /api/v1/file/:file_uuid/face-groups` + +**Auth**: Required +**Scope**: file-level + +Get face groups for a file. Groups face traces by their label (name) and returns both named groups and unassigned traces. + +#### Path Parameters + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `file_uuid` | string | Yes | File UUID | + +#### Example + +```bash +curl -s "$API/api/v1/file/$FILE_UUID/face-groups" \ + -H "X-API-Key: $KEY" +``` + +#### Response (200) + +```json +{ + "success": true, + "file_uuid": "d3f9ae8e471a1fc4d47022c66091b920", + "face_groups": [ + { + "group_id": 1, + "name": "Cary Grant", + "trace_ids": [9, 6, 8], + "trace_count": 3, + "representative_trace": 9, + "editable": true + }, + { + "group_id": 2, + "name": "Audrey Hepburn", + "trace_ids": [1, 11, 4, 10], + "trace_count": 4, + "representative_trace": 1, + "editable": true + } + ], + "total_groups": 2, + "unassigned_traces": [5, 7] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `success` | boolean | Always `true` on success | +| `file_uuid` | string | File UUID | +| `face_groups` | array | List of named face groups | +| `face_groups[].group_id` | integer | Sequential group number (starts at 1) | +| `face_groups[].name` | string | Group name (from TKG `label`) | +| `face_groups[].trace_ids` | integer[] | Trace IDs in this group | +| `face_groups[].trace_count` | integer | Number of traces in group | +| `face_groups[].representative_trace` | integer | First trace ID in group | +| `face_groups[].editable` | boolean | Always `true` (groups can be renamed) | +| `total_groups` | integer | Total named groups | +| `unassigned_traces` | integer[] | Traces with default names (e.g., "Face Trace 9") | + +#### Notes + +- Unassigned traces have labels starting with "Face Trace " or "Trace " +- Groups are sorted by first trace_id in each group +- `group_id` is dynamically generated and may change between requests + +--- + ### `GET /api/v1/file-profile` **Auth**: Required @@ -264,4 +335,5 @@ curl -s -X PUT "$API/api/v1/file-profile" \ | `aliases` | `properties->'aliases'` | Multi-language name aliases | --- +*Updated: 2026-07-19 — Added face-groups endpoint for Studio Proxy integration* *Updated: 2026-07-18 — New profile module: trace-profile (GET, PUT, PUT group) and file-profile (GET, PUT)* diff --git a/docs_v1.0/doc_wasm/modules/19_pose_appearance.md b/docs_v1.0/doc_wasm/modules/19_pose_appearance.md new file mode 100644 index 0000000..c859c6c --- /dev/null +++ b/docs_v1.0/doc_wasm/modules/19_pose_appearance.md @@ -0,0 +1,167 @@ + + + + +## Pose & Appearance + +Endpoints for retrieving pose keypoints and appearance color data for face detail modal display. + +### `GET /api/v1/file/:file_uuid/pose` + +**Auth**: Required +**Scope**: file-level + +Get pose keypoints for a specific frame. Returns 17 COCO keypoints with deduplication (highest confidence selected for each keypoint name). + +#### Path Parameters + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `file_uuid` | string | Yes | File UUID | + +#### Query Parameters + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `frame` | integer | Yes | Frame number | + +#### Example + +```bash +curl -s "$API/api/v1/file/$FILE_UUID/pose?frame=303" \ + -H "X-API-Key: $KEY" +``` + +#### Response (200) + +```json +{ + "frame": 303, + "keypoints": [ + {"name": "nose", "x": 315.91, "y": 364.25, "confidence": 0.616}, + {"name": "left_eye", "x": 312.45, "y": 362.80, "confidence": 0.616}, + {"name": "right_eye", "x": 316.21, "y": 361.38, "confidence": 0.616}, + {"name": "left_ear", "x": 0.0, "y": 0.0, "confidence": 0.0}, + {"name": "right_ear", "x": 0.0, "y": 0.0, "confidence": 0.0}, + {"name": "left_shoulder", "x": 0.0, "y": 0.0, "confidence": 0.0}, + {"name": "right_shoulder", "x": 0.0, "y": 0.0, "confidence": 0.0}, + {"name": "left_elbow", "x": 0.0, "y": 0.0, "confidence": 0.0}, + {"name": "right_elbow", "x": 0.0, "y": 0.0, "confidence": 0.0}, + {"name": "left_wrist", "x": 0.0, "y": 0.0, "confidence": 0.0}, + {"name": "right_wrist", "x": 0.0, "y": 0.0, "confidence": 0.0}, + {"name": "left_hip", "x": 0.0, "y": 0.0, "confidence": 0.0}, + {"name": "right_hip", "x": 0.0, "y": 0.0, "confidence": 0.0}, + {"name": "left_knee", "x": 0.0, "y": 0.0, "confidence": 0.0}, + {"name": "right_knee", "x": 0.0, "y": 0.0, "confidence": 0.0}, + {"name": "left_ankle", "x": 0.0, "y": 0.0, "confidence": 0.0}, + {"name": "right_ankle", "x": 0.0, "y": 0.0, "confidence": 0.0} + ], + "pose_class": "unknown", + "confidence": null +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `frame` | integer | Frame number | +| `keypoints` | array | 17 COCO keypoints (deduplicated) | +| `keypoints[].name` | string | Keypoint name | +| `keypoints[].x` | float | X coordinate in pixels | +| `keypoints[].y` | float | Y coordinate in pixels | +| `keypoints[].confidence` | float | Detection confidence 0-1 (0 means not detected) | +| `pose_class` | string | Always `"unknown"` in POC | +| `confidence` | null | Always `null` in POC | + +#### COCO-17 Keypoint Names + +``` +nose, left_eye, right_eye, left_ear, right_ear, +left_shoulder, right_shoulder, left_elbow, right_elbow, +left_wrist, right_wrist, left_hip, right_hip, +left_knee, right_knee, left_ankle, right_ankle +``` + +#### Error Responses + +| HTTP | When | +|------|------| +| 404 | Frame not found or pose.json not found | +| 401 | Missing or invalid API key | + +--- + +### `GET /api/v1/file/:file_uuid/appearance` + +**Auth**: Required +**Scope**: file-level + +Get appearance color data for a specific frame. Returns HSV histogram for color analysis. + +#### Path Parameters + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `file_uuid` | string | Yes | File UUID | + +#### Query Parameters + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `frame` | integer | Yes | Frame number | + +#### Example + +```bash +curl -s "$API/api/v1/file/$FILE_UUID/appearance?frame=303" \ + -H "X-API-Key: $KEY" +``` + +#### Response (200) + +```json +{ + "frame": 303, + "dominant_colors": [], + "hsv_histogram": [ + [0.59, 0.0, 0.0, ...], + [0.97, 0.02, 0.01, ...], + [0.004, 0.02, 0.03, ...] + ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `frame` | integer | Frame number | +| `dominant_colors` | array | Empty array in POC (`[]`) | +| `hsv_histogram` | array | 3x30 HSV histogram bins (H, S, V channels) | + +#### Error Responses + +| HTTP | When | +|------|------| +| 404 | Frame not found or appearance.json not found | +| 401 | Missing or invalid API key | + +--- + +## Data Source + +| Processor | File | Location | +|-----------|------|----------| +| Pose | `{file_uuid}.pose.json` | `{OUTPUT_DIR}/` | +| Appearance | `{file_uuid}.appearance.json` | `{OUTPUT_DIR}/` | + +--- + +## POC Limitations + +| Feature | POC Status | Future | +|---------|------------|--------| +| Pose classification | `"unknown"` | ML model for standing/sitting/etc. | +| Dominant colors | Empty array `[]` | HSV extraction algorithm | +| Multiple persons | First person only | Match by trace_id | +| Confidence | `null` | Aggregated confidence score | + +--- +*Updated: 2026-07-19 — New pose and appearance endpoints for face detail display* \ No newline at end of file diff --git a/src/api/media_api.rs b/src/api/media_api.rs index 7799979..eb5af87 100644 --- a/src/api/media_api.rs +++ b/src/api/media_api.rs @@ -1346,3 +1346,200 @@ async fn media_proxy_handler( pub fn media_proxy_routes() -> Router { Router::new().route("/api/v1/media-proxy", get(media_proxy_handler)) } + +// ── Pose & Appearance Endpoints ── + +#[derive(Debug, serde::Deserialize)] +struct PoseQuery { + frame: i64, +} + +#[derive(Debug, Clone, serde::Serialize)] +struct PoseKeypoint { + name: String, + x: f64, + y: f64, + confidence: f64, +} + +#[derive(Debug, serde::Serialize)] +struct PoseResponse { + frame: i64, + keypoints: Vec, + pose_class: String, + confidence: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct AppearanceQuery { + frame: i64, +} + +#[derive(Debug, serde::Serialize)] +struct AppearanceResponse { + frame: i64, + dominant_colors: Vec, + hsv_histogram: Vec>, +} + +async fn get_pose_handler( + State(_state): State, + Path(file_uuid): Path, + Query(params): Query, +) -> Result, StatusCode> { + use crate::core::config::OUTPUT_DIR; + use std::collections::HashMap as StdHashMap; + + let output_dir = OUTPUT_DIR.as_str(); + let pose_path = std::path::Path::new(output_dir) + .join(format!("{}.pose.json", file_uuid)); + + if !pose_path.exists() { + tracing::error!("[get_pose] File not found: {}", pose_path.display()); + return Err(StatusCode::NOT_FOUND); + } + + let content = std::fs::read_to_string(&pose_path).map_err(|e| { + tracing::error!("[get_pose] Failed to read pose.json: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let data: serde_json::Value = serde_json::from_str(&content).map_err(|e| { + tracing::error!("[get_pose] Failed to parse pose.json: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let frames = data.get("frames").and_then(|v| v.as_array()).ok_or(StatusCode::NOT_FOUND)?; + + let frame_data = frames + .iter() + .find(|f| f.get("frame").and_then(|v| v.as_i64()) == Some(params.frame)) + .ok_or(StatusCode::NOT_FOUND)?; + + let persons = frame_data + .get("persons") + .and_then(|v| v.as_array()) + .ok_or(StatusCode::NOT_FOUND)?; + + if persons.is_empty() { + return Err(StatusCode::NOT_FOUND); + } + + let person = &persons[0]; + let keypoints_raw = person + .get("keypoints") + .and_then(|v| v.as_array()) + .ok_or(StatusCode::NOT_FOUND)?; + + let mut unique_kps: StdHashMap = StdHashMap::new(); + for kp in keypoints_raw { + let name = kp.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let x = kp.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0); + let y = kp.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0); + let conf = kp.get("confidence").and_then(|v| v.as_f64()).unwrap_or(0.0); + + if let Some(existing) = unique_kps.get(name) { + if conf > existing.confidence { + unique_kps.insert(name.to_string(), PoseKeypoint { + name: name.to_string(), + x, + y, + confidence: conf, + }); + } + } else { + unique_kps.insert(name.to_string(), PoseKeypoint { + name: name.to_string(), + x, + y, + confidence: conf, + }); + } + } + + let coco_names = [ + "nose", "left_eye", "right_eye", "left_ear", "right_ear", + "left_shoulder", "right_shoulder", "left_elbow", "right_elbow", + "left_wrist", "right_wrist", "left_hip", "right_hip", + "left_knee", "right_knee", "left_ankle", "right_ankle", + ]; + + let keypoints: Vec = coco_names + .iter() + .map(|name| { + unique_kps.get(*name).cloned().unwrap_or_else(|| PoseKeypoint { + name: name.to_string(), + x: 0.0, + y: 0.0, + confidence: 0.0, + }) + }) + .collect(); + + Ok(axum::Json(PoseResponse { + frame: params.frame, + keypoints, + pose_class: "unknown".to_string(), + confidence: None, + })) +} + +async fn get_appearance_handler( + State(_state): State, + Path(file_uuid): Path, + Query(params): Query, +) -> Result, StatusCode> { + use crate::core::config::OUTPUT_DIR; + + let output_dir = OUTPUT_DIR.as_str(); + let appearance_path = std::path::Path::new(output_dir) + .join(format!("{}.appearance.json", file_uuid)); + + if !appearance_path.exists() { + tracing::error!("[get_appearance] File not found: {}", appearance_path.display()); + return Err(StatusCode::NOT_FOUND); + } + + let content = std::fs::read_to_string(&appearance_path).map_err(|e| { + tracing::error!("[get_appearance] Failed to read appearance.json: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let data: serde_json::Value = serde_json::from_str(&content).map_err(|e| { + tracing::error!("[get_appearance] Failed to parse appearance.json: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let frames = data.get("frames").and_then(|v| v.as_array()).ok_or(StatusCode::NOT_FOUND)?; + + let frame_data = frames + .iter() + .find(|f| f.get("frame").and_then(|v| v.as_i64()) == Some(params.frame)) + .ok_or(StatusCode::NOT_FOUND)?; + + let hsv_histogram = frame_data + .get("hsv_histogram") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|channel| { + channel.as_array().map(|bins| { + bins.iter().filter_map(|v| v.as_f64()).collect() + }) + }) + .collect() + }) + .unwrap_or_default(); + + Ok(axum::Json(AppearanceResponse { + frame: params.frame, + dominant_colors: vec![], + hsv_histogram, + })) +} + +pub fn pose_appearance_routes() -> Router { + Router::new() + .route("/api/v1/file/:file_uuid/pose", get(get_pose_handler)) + .route("/api/v1/file/:file_uuid/appearance", get(get_appearance_handler)) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index ab70792..62875ef 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -14,6 +14,7 @@ pub mod media_api; pub mod middleware; pub mod pipeline; pub mod processing; +pub mod profile; pub mod scan; pub mod search; pub mod server; diff --git a/src/api/profile.rs b/src/api/profile.rs index da38131..0324903 100644 --- a/src/api/profile.rs +++ b/src/api/profile.rs @@ -1,10 +1,11 @@ use axum::{ Extension, Json, - extract::{Query, State}, + extract::{Path, Query, State}, http::StatusCode, }; use serde::{Deserialize, Serialize}; use sqlx::PgPool; +use std::collections::HashMap; use crate::core::db::schema; use crate::api::middleware::UserAuth; @@ -375,6 +376,86 @@ pub async fn update_file_profile_handler( } } +// ─── Face Groups ─── + +#[derive(Debug, Serialize, Deserialize)] +pub struct FaceGroup { + pub group_id: i32, + pub name: String, + pub trace_ids: Vec, + pub trace_count: i32, + pub representative_trace: i64, + pub editable: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FaceGroupsResponse { + pub success: bool, + pub file_uuid: String, + pub face_groups: Vec, + pub total_groups: i32, + pub unassigned_traces: Vec, +} + +pub async fn get_face_groups_handler( + State(state): State, + Extension(_auth): Extension, + Path(file_uuid): Path, +) -> Result, StatusCode> { + let tkg_table = schema::table_name("tkg_nodes"); + + let rows: Vec<(String, serde_json::Value)> = sqlx::query_as(&format!( + "SELECT label, properties FROM {} \ + WHERE file_uuid = $1 AND node_type = 'face_track' \ + ORDER BY (properties->>'trace_id')::int", + tkg_table + )) + .bind(&file_uuid) + .fetch_all(state.db.pool()) + .await + .map_err(|e| { + tracing::error!("[FaceGroups] DB error: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let mut groups: HashMap> = HashMap::new(); + let mut unassigned: Vec = Vec::new(); + + for (label, properties) in rows { + let trace_id = properties + .get("trace_id") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + if label.starts_with("Face Trace ") || label.starts_with("Trace ") { + unassigned.push(trace_id); + } else { + groups.entry(label).or_default().push(trace_id); + } + } + + let face_groups: Vec = groups + .into_iter() + .enumerate() + .map(|(idx, (name, trace_ids))| FaceGroup { + group_id: (idx + 1) as i32, + name, + trace_count: trace_ids.len() as i32, + representative_trace: trace_ids.first().copied().unwrap_or(0), + trace_ids, + editable: true, + }) + .collect(); + + Ok(Json(FaceGroupsResponse { + success: true, + file_uuid, + total_groups: face_groups.len() as i32, + unassigned_traces: unassigned, + face_groups, + })) +} + // ─── Routes ─── pub fn profile_routes() -> axum::Router { @@ -388,4 +469,5 @@ pub fn profile_routes() -> axum::Router { ) .route("/api/v1/file-profile", get(get_file_profile_handler)) .route("/api/v1/file-profile", put(update_file_profile_handler)) + .route("/api/v1/file/:file_uuid/face-groups", get(get_face_groups_handler)) } diff --git a/src/api/server.rs b/src/api/server.rs index 0445401..d065b3d 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -26,6 +26,7 @@ use super::media_api; use super::middleware::unified_auth; use super::pipeline; use super::processing; +use super::profile; use super::scan; use super::search::search_routes; use super::tmdb_api; @@ -119,12 +120,14 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> { .merge(identity_agent_api::identity_agent_routes()) .merge(media_api::bbox_routes()) .merge(media_api::media_proxy_routes()) + .merge(media_api::pose_appearance_routes()) .merge(trace_agent_api::trace_agent_routes()) .merge(search_routes()) .merge(llm_search::llm_smart_routes()) .merge(universal_search_routes()) .merge(pipeline::pipeline_routes()) .merge(checkin_api::checkin_routes()) + .merge(profile::profile_routes()) .layer(axum::middleware::from_fn_with_state( state.api_state.clone(), unified_auth,