From fcdeab82e60c4905fb9c202caca0ba3e1ba543e3 Mon Sep 17 00:00:00 2001 From: Accusys Date: Tue, 21 Jul 2026 04:08:16 +0800 Subject: [PATCH] feat: save key_frame.jpg and key_face.jpg for each face trace during TKG build - Extract full frame at best confidence frame using ffmpeg - Crop face region using average bbox coordinates - Save to .faces/{file_uuid}/{trace_id}/key_frame.jpg and key_face.jpg - TKG node properties store full file paths for UI display - Supports future extension for cut representative frames --- src/core/processor/tkg.rs | 65 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/src/core/processor/tkg.rs b/src/core/processor/tkg.rs index 7ab68dc..233c1ee 100644 --- a/src/core/processor/tkg.rs +++ b/src/core/processor/tkg.rs @@ -1218,6 +1218,69 @@ async fn build_face_track_nodes_from_qdrant( "status": "pending", }); + // Save key frame and key face JPGs + let output_dir = crate::core::config::OUTPUT_DIR.as_str(); + let face_dir = std::path::PathBuf::from(output_dir) + .join(".faces").join(file_uuid).join(&external_id); + let _ = std::fs::create_dir_all(&face_dir); + + let key_frame_path = face_dir.join("key_frame.jpg"); + let key_face_path = face_dir.join("key_face.jpg"); + + // Get video path from database + let video_path: Option = sqlx::query_scalar(&format!( + "SELECT file_path FROM {} WHERE file_uuid = $1", + crate::core::db::schema::table_name("videos") + )) + .bind(file_uuid) + .fetch_optional(pool) + .await + .ok() + .flatten(); + + // Extract key frame using ffmpeg + if !key_frame_path.exists() { + if let Some(ref vp) = video_path { + let timestamp = agg.best_frame as f64 / 30.0; + let _ = std::process::Command::new("ffmpeg") + .args(["-y", "-v", "quiet", "-ss", ×tamp.to_string(), "-i", vp]) + .args(["-frames:v", "1", "-q:v", "2"]) + .arg(key_frame_path.to_str().unwrap_or("")) + .output(); + } + } + + // Crop key face from key frame using bbox + if !key_face_path.exists() && key_frame_path.exists() { + let bbox = &props["avg_bbox"]; + let x = bbox["x"].as_i64().unwrap_or(0) as u32; + let y = bbox["y"].as_i64().unwrap_or(0) as u32; + let w = bbox["width"].as_i64().unwrap_or(100) as u32; + let h = bbox["height"].as_i64().unwrap_or(100) as u32; + let _ = std::process::Command::new("ffmpeg") + .args(["-y", "-v", "quiet", "-i", key_frame_path.to_str().unwrap_or("")]) + .args(["-vf", &format!("crop={}:{}:{}:{}", w, h, x, y)]) + .args(["-q:v", "2"]) + .arg(key_face_path.to_str().unwrap_or("")) + .output(); + } + + // Update props with actual file paths + let final_props = serde_json::json!({ + "trace_id": tid, + "frame_count": agg.frame_count, + "start_frame": agg.start_f, + "end_frame": agg.end_f, + "key_frame": if key_frame_path.exists() { key_frame_path.to_string_lossy().to_string() } else { serde_json::Value::Null.to_string() }, + "key_face": if key_face_path.exists() { key_face_path.to_string_lossy().to_string() } else { serde_json::Value::Null.to_string() }, + "avg_bbox": props["avg_bbox"].clone(), + "avg_yaw": props["avg_yaw"].clone(), + "avg_pitch": props["avg_pitch"].clone(), + "avg_roll": props["avg_roll"].clone(), + "pose_count": pose_count, + "status": "pending", + }); + sqlx::query(&format!( r#" INSERT INTO {} (node_type, external_id, file_uuid, label, properties) @@ -1232,7 +1295,7 @@ async fn build_face_track_nodes_from_qdrant( .bind(&external_id) .bind(file_uuid) .bind(&label) - .bind(serde_json::to_string(&props)?) + .bind(serde_json::to_string(&final_props)?) .execute(pool) .await?;