//! FFmpeg wrapper for video compilation. use crate::config::VideoConfig; use crate::error::{Error, Result}; use std::path::Path; use std::process::Stdio; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; /// Compile images into a timelapse video. /// /// # Arguments /// * `input_dir` - Directory containing JPEG images (sorted alphabetically for order) /// * `output_path` - Path for the output video file /// * `config` - Video configuration /// * `progress_callback` - Called with (current_frame, total_frames) pub async fn compile_timelapse( input_dir: &Path, output_path: &Path, config: &VideoConfig, mut progress_callback: F, ) -> Result<()> where F: FnMut(u32, u32), { // Collect and sort image files for reliable ordering. // Filenames are timestamp-based, so alphabetical sort = chronological order. let mut image_files: Vec<_> = Vec::new(); let mut entries = tokio::fs::read_dir(input_dir).await?; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); if path.extension().map(|e| e == "jpg").unwrap_or(false) { image_files.push(path); } } if image_files.is_empty() { return Err(Error::VideoCompilation( "No images found in input directory".to_string(), )); } // Sort alphabetically (timestamp-based names ensure chronological order) image_files.sort(); let image_count = image_files.len() as u32; tracing::info!("Compiling {} images into video", image_count); // Create concat file for ffmpeg (more reliable than glob pattern). // Format: file '/path/to/image.jpg'\nduration 0.0667\n... let frame_duration = 1.0 / config.framerate as f64; let concat_path = input_dir.join(".concat.txt"); let mut concat_content = String::new(); for (i, path) in image_files.iter().enumerate() { // Use just the filename since concat file is in the same directory as images let filename = path .file_name() .map(|f| f.to_string_lossy()) .unwrap_or_default() .replace('\'', "'\\''"); concat_content.push_str(&format!("file '{}'\n", filename)); // Don't add duration after the last frame if i < image_files.len() - 1 { concat_content.push_str(&format!("duration {:.6}\n", frame_duration)); } } // Write concat file let mut file = tokio::fs::File::create(&concat_path).await?; file.write_all(concat_content.as_bytes()).await?; file.flush().await?; // Build ffmpeg command using concat demuxer let mut cmd = Command::new("ffmpeg"); cmd.arg("-y") // Overwrite output .arg("-f") .arg("concat") .arg("-safe") .arg("0") // Allow absolute paths .arg("-i") .arg(&concat_path) .arg("-c:v") .arg(&config.codec) .arg("-crf") .arg(config.crf.to_string()) .arg("-pix_fmt") .arg("yuv420p") .arg("-progress") .arg("pipe:1") .arg(output_path) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = cmd.spawn().map_err(|e| { Error::FFmpeg(format!( "Failed to spawn ffmpeg (is it installed?): {}", e )) })?; // Capture stdout for progress and stderr for errors let stdout = child .stdout .take() .ok_or_else(|| Error::FFmpeg("Failed to capture ffmpeg stdout".to_string()))?; let stderr = child .stderr .take() .ok_or_else(|| Error::FFmpeg("Failed to capture ffmpeg stderr".to_string()))?; // Read stderr in background to avoid blocking let stderr_handle = tokio::spawn(async move { let mut stderr_reader = BufReader::new(stderr).lines(); let mut stderr_output = String::new(); while let Ok(Some(line)) = stderr_reader.next_line().await { stderr_output.push_str(&line); stderr_output.push('\n'); } stderr_output }); // Parse progress from stdout let mut reader = BufReader::new(stdout).lines(); while let Some(line) = reader.next_line().await? { if line.starts_with("frame=") { if let Some(frame_str) = line.strip_prefix("frame=") { if let Ok(frame) = frame_str.trim().parse::() { // Clamp to image_count - ffmpeg can report higher values internally let clamped_frame = frame.min(image_count); progress_callback(clamped_frame, image_count); } } } } let status = child.wait().await?; let stderr_output = stderr_handle.await.unwrap_or_default(); // Clean up concat file let _ = tokio::fs::remove_file(&concat_path).await; if !status.success() { // Get last few lines of stderr for the error message let error_lines: Vec<&str> = stderr_output.lines().rev().take(10).collect(); let error_detail = error_lines.into_iter().rev().collect::>().join("\n"); return Err(Error::FFmpeg(format!( "ffmpeg exited with status {}\n{}", status, error_detail ))); } progress_callback(image_count, image_count); tracing::info!("Video compilation complete: {:?}", output_path); Ok(()) } /// Check if ffmpeg is available. pub async fn check_ffmpeg() -> Result { let output = Command::new("ffmpeg") .arg("-version") .output() .await .map_err(|e| Error::FFmpeg(format!("ffmpeg not found: {}", e)))?; if !output.status.success() { return Err(Error::FFmpeg("ffmpeg version check failed".to_string())); } let version_output = String::from_utf8_lossy(&output.stdout); let version = version_output .lines() .next() .unwrap_or("unknown") .to_string(); Ok(version) }