Files
gui-video-clipper/src-tauri/src/services/clip_exporter.rs

273 lines
7.4 KiB
Rust
Raw Normal View History

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<String> {
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::<Vec<_>>()
.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");
}
}