2026-09-21 11:20:32 -04:00
|
|
|
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: _ } => {
|
|
|
|
|
args.extend([
|
|
|
|
|
"-c:v".to_string(),
|
|
|
|
|
"libx264".to_string(),
|
|
|
|
|
"-c:a".to_string(),
|
|
|
|
|
"aac".to_string(),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
args.push(output.to_string());
|
|
|
|
|
args
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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");
|
|
|
|
|
}
|
|
|
|
|
}
|