use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; use tracing::info; /// A single extracted frame with metadata #[derive(Debug, Clone)] pub struct CachedFrame { pub path: PathBuf, pub frame_number: u64, pub timestamp_secs: f64, } /// Manages shared frame extraction for concurrent processors pub struct FrameManager { pub dir: PathBuf, pub frames: Vec, pub fps: f64, pub total_frames: u64, pub duration_secs: f64, } impl FrameManager { /// Extract frames from video at `sample_interval` into a temp directory. pub async fn extract( video_path: &str, sample_interval: u32, fps: f64, total_frames: u64, ) -> Result { let dir = std::env::temp_dir().join(format!("frames_{}", uuid_from_path(video_path))); let _ = std::fs::create_dir_all(&dir); let pattern = dir.join("frame_%05d.jpg").to_string_lossy().to_string(); let video_path = video_path.to_owned(); info!( "[FrameCache] Extracting frames (interval={}) to {:?}", sample_interval, dir ); let output = tokio::process::Command::new("ffmpeg") .args([ "-y", "-v", "quiet", "-i", &video_path, "-vf", &format!("select=not(mod(n\\,{})),scale=320:-2", sample_interval), "-vsync", "vfr", "-q:v", "15", &pattern, ]) .output() .await .context("Frame extraction via ffmpeg failed")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); anyhow::bail!("ffmpeg frame extraction failed: {}", stderr); } // Read extracted frames let mut frames: Vec = Vec::new(); let mut entries: Vec<_> = std::fs::read_dir(&dir)? .filter_map(|e| e.ok()) .filter(|e| e.path().extension().map_or(false, |ext| ext == "jpg")) .collect(); entries.sort_by_key(|e| e.file_name()); for entry in &entries { let fname = entry.file_name(); let fname_str = fname.to_string_lossy(); if let Some(num_str) = fname_str .strip_prefix("frame_") .and_then(|s| s.strip_suffix(".jpg")) { if let Ok(frame_num) = num_str.parse::() { let timestamp = frame_num as f64 / fps; frames.push(CachedFrame { path: entry.path(), frame_number: frame_num, timestamp_secs: timestamp, }); } } } let duration_secs = if fps > 0.0 { total_frames as f64 / fps } else { 0.0 }; info!( "[FrameCache] Extracted {} frames to {:?}", frames.len(), dir ); Ok(FrameManager { dir, frames, fps, total_frames, duration_secs, }) } /// Clean up the extracted frame files pub fn cleanup(&self) { let _ = std::fs::remove_dir_all(&self.dir); info!("[FrameCache] Cleaned up {:?}", self.dir); } /// Get a frame by index pub fn get_frame(&self, index: usize) -> Option<&CachedFrame> { self.frames.get(index) } /// Number of extracted frames pub fn len(&self) -> usize { self.frames.len() } pub fn is_empty(&self) -> bool { self.frames.is_empty() } } fn uuid_from_path(path: &str) -> String { use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); path.hash(&mut hasher); format!("{:x}", hasher.finish()) }