use anyhow::{anyhow, Result}; use ffmpeg_sidecar::download::{auto_download_with_progress, FfmpegDownloadProgressEvent}; use ffmpeg_sidecar::ffprobe::ffprobe_path; use ffmpeg_sidecar::paths::ffmpeg_path; use serde::Deserialize; use std::path::{Path, PathBuf}; use std::process::Command; // On Windows, GUI apps spawn subprocesses with a visible console window by default. // CREATE_NO_WINDOW suppresses that for every ffmpeg/ffprobe invocation. #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; #[cfg(target_os = "windows")] const CREATE_NO_WINDOW: u32 = 0x08000000; #[derive(Debug, Clone)] pub struct MediaTools { ffmpeg_path: PathBuf, ffprobe_path: PathBuf, } #[derive(Debug, Clone)] pub struct VideoMetadata { pub duration_ms: Option, pub width: Option, pub height: Option, pub video_codec: Option, pub audio_codec: Option, } impl MediaTools { pub fn resolve() -> Self { Self { ffmpeg_path: ffmpeg_path(), ffprobe_path: ffprobe_path(), } } pub fn ensure_installed() -> Result<()> { // Skip download entirely if both binaries are already present. if ffmpeg_path().exists() && ffprobe_path().exists() { return Ok(()); } auto_download_with_progress(|event| match event { FfmpegDownloadProgressEvent::Starting => { println!("Downloading bundled FFmpeg..."); } FfmpegDownloadProgressEvent::Downloading { total_bytes, downloaded_bytes, } => { println!( "Downloading bundled FFmpeg: {}/{} bytes", downloaded_bytes, total_bytes ); } FfmpegDownloadProgressEvent::UnpackingArchive => { println!("Unpacking bundled FFmpeg..."); } FfmpegDownloadProgressEvent::Done => { println!("Bundled FFmpeg ready."); } }) } pub fn ffmpeg_command(&self) -> Command { let mut cmd = Command::new(&self.ffmpeg_path); #[cfg(target_os = "windows")] cmd.creation_flags(CREATE_NO_WINDOW); cmd } pub fn ffprobe_command(&self) -> Command { let mut cmd = Command::new(&self.ffprobe_path); #[cfg(target_os = "windows")] cmd.creation_flags(CREATE_NO_WINDOW); cmd } } pub fn probe_video_metadata(tools: &MediaTools, video_path: &Path) -> Result { let output = tools .ffprobe_command() .args([ "-v", "error", "-print_format", "json", "-show_format", "-show_streams", &video_path.to_string_lossy(), ]) .output()?; if !output.status.success() { return Err(anyhow!( "ffprobe failed for {}: {}", video_path.display(), String::from_utf8_lossy(&output.stderr) )); } let probe: FfprobeOutput = serde_json::from_slice(&output.stdout)?; let video_stream = probe .streams .iter() .find(|stream| stream.codec_type.as_deref() == Some("video")); let audio_stream = probe .streams .iter() .find(|stream| stream.codec_type.as_deref() == Some("audio")); let duration_ms = probe .format .and_then(|format| format.duration) .and_then(|duration| parse_duration_ms(&duration)) .or_else(|| { video_stream .and_then(|stream| stream.duration.as_deref()) .and_then(parse_duration_ms) }); Ok(VideoMetadata { duration_ms, width: video_stream.and_then(|stream| stream.width), height: video_stream.and_then(|stream| stream.height), video_codec: video_stream.and_then(|stream| stream.codec_name.clone()), audio_codec: audio_stream.and_then(|stream| stream.codec_name.clone()), }) } fn parse_duration_ms(value: &str) -> Option { let seconds = value.parse::().ok()?; Some((seconds * 1000.0).round() as i64) } #[derive(Deserialize)] struct FfprobeOutput { format: Option, #[serde(default)] streams: Vec, } #[derive(Deserialize)] struct FfprobeFormat { duration: Option, } #[derive(Deserialize)] struct FfprobeStream { codec_type: Option, codec_name: Option, duration: Option, width: Option, height: Option, }