139 lines
3.9 KiB
Rust
139 lines
3.9 KiB
Rust
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<CachedFrame>,
|
|
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<Self> {
|
|
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<CachedFrame> = 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::<u64>() {
|
|
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())
|
|
}
|