use crate::models::{Clip, CutMode}; use std::path::Path; use std::process::Command; pub fn build_ffmpeg_args( clip: &Clip, source: &str, output: &str, cut_mode: &CutMode, ) -> Vec { let mut args = vec![ "-y".to_string(), "-ss".to_string(), format!("{}", clip.start_time), "-to".to_string(), format!("{}", clip.end_time), "-i".to_string(), source.to_string(), ]; match cut_mode { CutMode::Lossless => { args.extend(["-c".to_string(), "copy".to_string()]); } CutMode::Precise { format: _ } => { let ext = Path::new(output) .extension() .map(|e| e.to_string_lossy().to_ascii_lowercase()) .unwrap_or_default(); let (video_codec, audio_codec) = match ext.as_str() { "webm" => ("libvpx-vp9", "libopus"), "mp4" | "mkv" => ("libx264", "aac"), _ => ("libx264", "aac"), }; args.extend([ "-c:v".to_string(), video_codec.to_string(), "-c:a".to_string(), audio_codec.to_string(), ]); } } args.push(output.to_string()); args } pub fn expand_tilde_path(path: &str) -> String { if path == "~" { return dirs::home_dir() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|| path.to_string()); } if let Some(rest) = path.strip_prefix("~/") { if let Some(home) = dirs::home_dir() { return home.join(rest).to_string_lossy().to_string(); } } path.to_string() } pub fn generate_output_path( output_dir: &str, video_title: &str, clip_label: &str, extension: &str, ) -> String { let sanitized_title = sanitize_filename(video_title); let sanitized_label = sanitize_filename(clip_label); let base = format!( "{}/{} - {}.{}", output_dir, sanitized_title, sanitized_label, extension ); if !Path::new(&base).exists() { return base; } for i in 2..100 { let candidate = format!( "{}/{} - {} ({}).{}", output_dir, sanitized_title, sanitized_label, i, extension ); if !Path::new(&candidate).exists() { return candidate; } } base } fn sanitize_filename(name: &str) -> String { name.chars() .map(|c| { if c == '/' || c == '\\' || c == ':' || c == '"' || c == '|' || c == '?' || c == '*' || c == '<' || c == '>' { '_' } else { c } }) .collect() } pub fn get_extension(source: &str, cut_mode: &CutMode) -> String { match cut_mode { CutMode::Lossless => Path::new(source) .extension() .map(|e| e.to_string_lossy().to_string()) .unwrap_or_else(|| "mp4".to_string()), CutMode::Precise { format } => format.clone(), } } pub fn export_single_clip( clip: &Clip, source: &str, output: &str, cut_mode: &CutMode, ) -> Result<(), String> { let args = build_ffmpeg_args(clip, source, output, cut_mode); let output_result = Command::new("ffmpeg") .args(&args) .output() .map_err(|e| format!("Failed to run ffmpeg: {e}"))?; if !output_result.status.success() { let stderr = String::from_utf8_lossy(&output_result.stderr); return Err(format!("ffmpeg export failed: {stderr}")); } Ok(()) } pub fn export_merged( clips: &[Clip], source: &str, output: &str, cut_mode: &CutMode, temp_dir: &str, ) -> Result<(), String> { let mut temp_files = Vec::new(); let ext = get_extension(source, cut_mode); for (i, clip) in clips.iter().enumerate() { let temp_path = format!("{}/merge_part_{}.{}", temp_dir, i, ext); export_single_clip(clip, source, &temp_path, cut_mode)?; temp_files.push(temp_path); } let concat_path = format!("{}/concat_list.txt", temp_dir); let concat_content: String = temp_files .iter() .map(|p| format!("file '{}'", p)) .collect::>() .join("\n"); std::fs::write(&concat_path, concat_content) .map_err(|e| format!("Failed to write concat file: {e}"))?; let result = Command::new("ffmpeg") .args([ "-y", "-f", "concat", "-safe", "0", "-i", &concat_path, "-c", "copy", output, ]) .output() .map_err(|e| format!("Failed to run ffmpeg concat: {e}"))?; if !result.status.success() { let stderr = String::from_utf8_lossy(&result.stderr); return Err(format!("ffmpeg merge failed: {stderr}")); } for f in &temp_files { let _ = std::fs::remove_file(f); } let _ = std::fs::remove_file(&concat_path); Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_build_ffmpeg_args_lossless() { let clip = Clip { id: "1".to_string(), start_time: 10.5, end_time: 20.0, label: "Clip 1".to_string(), color: "#000".to_string(), }; let args = build_ffmpeg_args(&clip, "input.mp4", "output.mp4", &CutMode::Lossless); assert!(args.contains(&"-c".to_string())); assert!(args.contains(&"copy".to_string())); assert!(args.contains(&"10.5".to_string())); assert!(args.contains(&"20".to_string())); } #[test] fn test_build_ffmpeg_args_precise() { let clip = Clip { id: "1".to_string(), start_time: 5.0, end_time: 15.0, label: "Clip 1".to_string(), color: "#000".to_string(), }; let mode = CutMode::Precise { format: "mp4".to_string(), }; let args = build_ffmpeg_args(&clip, "input.mp4", "output.mp4", &mode); assert!(args.contains(&"-c:v".to_string())); assert!(args.contains(&"libx264".to_string())); assert!(args.contains(&"aac".to_string())); let webm_args = build_ffmpeg_args(&clip, "input.mp4", "output.webm", &mode); assert!(webm_args.contains(&"libvpx-vp9".to_string())); assert!(webm_args.contains(&"libopus".to_string())); } #[test] fn test_expand_tilde_path() { let expanded = expand_tilde_path("~/Videos"); assert!(!expanded.starts_with("~/")); assert!(expanded.ends_with("Videos") || expanded.contains("Videos")); } #[test] fn test_generate_output_path_no_collision() { let path = generate_output_path("/tmp", "My Video", "Clip 1", "mp4"); assert!(path.contains("My Video")); assert!(path.contains("Clip 1")); assert!(path.ends_with(".mp4")); } #[test] fn test_sanitize_filename() { assert_eq!(sanitize_filename("a/b:c"), "a_b_c"); assert_eq!(sanitize_filename("normal"), "normal"); } #[test] fn test_get_extension_lossless() { assert_eq!(get_extension("video.webm", &CutMode::Lossless), "webm"); } #[test] fn test_get_extension_precise() { let mode = CutMode::Precise { format: "mkv".to_string(), }; assert_eq!(get_extension("video.webm", &mode), "mkv"); } }