use serde::Serialize; use std::path::Path; use std::process::Command; #[derive(Serialize, Clone, Debug)] pub struct VideoInfo { pub path: String, pub width: u32, pub height: u32, pub fps: f64, pub total_frames: u64, pub duration_s: f64, /// Suggested output folder for this video, derived from the video path so /// the same folder is produced on each re-open. Frontend uses this to /// auto-restore the extracted-frame set without the user re-picking. pub default_output_folder: String, } pub fn default_output_folder(path: &Path) -> String { let stem = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("video"); let parent = path.parent().unwrap_or_else(|| Path::new(".")); parent .join(format!("{stem}_extracted")) .display() .to_string() } fn parse_rational(s: &str) -> Option { let (n, d) = s.split_once('/')?; let n: f64 = n.parse().ok()?; let d: f64 = d.parse().ok()?; if d == 0.0 { None } else { Some(n / d) } } pub fn probe(path: &Path) -> Result { let path_str = path.to_str().ok_or_else(|| "invalid path".to_string())?; let out = Command::new("ffprobe") .args([ "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,r_frame_rate,avg_frame_rate,nb_frames,duration", "-show_entries", "format=duration", "-of", "json", path_str, ]) .output() .map_err(|e| format!("ffprobe spawn failed: {e}"))?; if !out.status.success() { return Err(format!( "ffprobe exited {}: {}", out.status, String::from_utf8_lossy(&out.stderr) )); } let json: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("ffprobe json: {e}"))?; let stream = &json["streams"][0]; if stream.is_null() { return Err("no video stream found".into()); } let width = stream["width"] .as_u64() .ok_or("stream.width missing")? as u32; let height = stream["height"] .as_u64() .ok_or("stream.height missing")? as u32; let fps = stream["r_frame_rate"] .as_str() .and_then(parse_rational) .or_else(|| stream["avg_frame_rate"].as_str().and_then(parse_rational)) .ok_or("cannot parse fps")?; let duration_s = stream["duration"] .as_str() .and_then(|s| s.parse::().ok()) .or_else(|| { json["format"]["duration"] .as_str() .and_then(|s| s.parse::().ok()) }) .ok_or("cannot parse duration")?; let total_frames = stream["nb_frames"] .as_str() .and_then(|s| s.parse::().ok()) .unwrap_or_else(|| (fps * duration_s).round() as u64); Ok(VideoInfo { path: path.display().to_string(), width, height, fps, total_frames, duration_s, default_output_folder: default_output_folder(path), }) }