1use anyhow::{Context, Result};
2use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::sync::atomic::{AtomicUsize, Ordering};
7use tracing::{debug, info, warn};
8
9use crate::analyzer::ProcessedSegment;
10use crate::analyzer::VideoAnalyzer;
11use crate::config::Config;
12use crate::editor::VideoEditor;
13use crate::editor::calculate_keep_segments;
14use crate::editor::calculate_keep_segments_from_transcript;
15use crate::exporter;
16use crate::progress::BatchProgress;
17use crate::stt_analyzer::{CandleSttAnalyzer, TranscriptSegment, VideoSttAnalyzer};
18use crate::utils::{TempFile, find_video_files};
19
20fn atomic_replace(src: &Path, dst: &Path) -> Result<()> {
21 #[cfg(target_os = "windows")]
22 {
23 if dst.exists() {
24 std::fs::remove_file(dst).context("failed to remove existing destination")?;
25 }
26 }
27 std::fs::rename(src, dst).context("atomic replace failed")
28}
29
30struct TempFileGuard {
33 temps: Vec<PathBuf>,
34 output: PathBuf,
35}
36
37impl TempFileGuard {
38 fn new(output: PathBuf) -> Self {
39 Self {
40 temps: Vec::new(),
41 output,
42 }
43 }
44
45 fn track(&mut self, path: PathBuf) {
46 if path != self.output && !self.temps.contains(&path) {
47 self.temps.push(path);
48 }
49 }
50
51 fn untrack(&mut self, path: &Path) {
52 self.temps.retain(|p| p != path);
53 }
54}
55
56impl Drop for TempFileGuard {
57 fn drop(&mut self) {
58 for path in &self.temps {
59 if let Err(e) = fs::remove_file(path) {
60 debug!(path = ?path, error = %e, "Failed to remove temp file");
61 }
62 }
63 }
64}
65
66#[derive(Debug, Clone)]
67pub struct ProcessingProgress {
68 pub fraction: f32,
69 pub stage: String,
70}
71
72pub trait DurationGetter: Send + Sync {
74 fn get_duration(&self, path: &Path) -> Result<f32>;
75}
76
77pub struct FfmpegDurationGetter;
79
80impl DurationGetter for FfmpegDurationGetter {
81 fn get_duration(&self, path: &Path) -> Result<f32> {
82 let output = std::process::Command::new("ffprobe")
83 .args([
84 "-v",
85 "error",
86 "-show_entries",
87 "format=duration",
88 "-of",
89 "default=noprint_wrappers=1:nokey=1",
90 path.to_str().context("invalid path")?,
91 ])
92 .output()
93 .context("failed to execute ffprobe")?;
94
95 let val_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
96 val_str.parse::<f32>().context("failed to parse duration")
97 }
98}
99
100pub struct CachingDurationGetter {
103 inner: FfmpegDurationGetter,
104 cache: std::sync::Mutex<std::collections::HashMap<PathBuf, (std::time::SystemTime, u64, f32)>>,
105}
106
107impl CachingDurationGetter {
108 pub fn new() -> Self {
109 Self {
110 inner: FfmpegDurationGetter,
111 cache: std::sync::Mutex::new(std::collections::HashMap::new()),
112 }
113 }
114}
115
116impl DurationGetter for CachingDurationGetter {
117 fn get_duration(&self, path: &Path) -> Result<f32> {
118 let metadata = std::fs::metadata(path)
119 .with_context(|| format!("failed to read metadata for {:?}", path))?;
120 let mtime = metadata
121 .modified()
122 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
123 let file_size = metadata.len();
124
125 {
126 let cache = self.cache.lock().unwrap_or_else(|p| p.into_inner());
127 if let Some((cached_mtime, cached_size, cached_dur)) = cache.get(path)
128 && *cached_mtime == mtime
129 && *cached_size == file_size
130 {
131 return Ok(*cached_dur);
132 }
133 }
134
135 let duration = self.inner.get_duration(path)?;
136
137 let mut cache = self.cache.lock().unwrap_or_else(|p| p.into_inner());
138 cache.insert(path.to_path_buf(), (mtime, file_size, duration));
139
140 Ok(duration)
141 }
142}
143
144impl Default for CachingDurationGetter {
145 fn default() -> Self {
146 Self::new()
147 }
148}
149
150fn concatenate_videos(
153 intro: Option<&Path>,
154 main: &Path,
155 outro: Option<&Path>,
156 output: &Path,
157) -> Result<()> {
158 let has_intro = intro.is_some();
159 let has_outro = outro.is_some();
160
161 if !has_intro && !has_outro {
162 fs::copy(main, output)?;
163 return Ok(());
164 }
165
166 let mut video_paths: Vec<&Path> = Vec::new();
168 if let Some(p) = intro {
169 video_paths.push(p);
170 }
171 video_paths.push(main);
172 if let Some(p) = outro {
173 video_paths.push(p);
174 }
175
176 let list_content: String = video_paths
179 .iter()
180 .map(|p| {
181 let path_str = p.to_string_lossy();
182 let escaped = path_str.replace("'", "'\\''");
183 format!("file '{}'\n", escaped)
184 })
185 .collect();
186 let list_file = TempFile::new("agave-concat-list", "txt")?;
187 std::fs::write(list_file.path(), list_content)?;
188
189 let status = std::process::Command::new("ffmpeg")
190 .args(["-f", "concat", "-safe", "0", "-i"])
191 .arg(list_file.path())
192 .args(["-c", "copy", "-y"])
193 .arg(output)
194 .status()
195 .context("failed to execute ffmpeg for concat")?;
196
197 if !status.success() {
198 anyhow::bail!("ffmpeg concat failed with status: {}", status);
199 }
200
201 Ok(())
202}
203
204pub fn process_single_file<A, E, D>(
205 input_file: PathBuf,
206 output_file: PathBuf,
207 config: &Config,
208 analyzer: &A,
209 editor: &E,
210 duration_getter: &D,
211) -> Result<()>
212where
213 A: VideoAnalyzer,
214 E: VideoEditor,
215 D: DurationGetter,
216{
217 process_single_file_with_intro_outro(
218 input_file,
219 output_file,
220 config,
221 analyzer,
222 editor,
223 duration_getter,
224 None,
225 None,
226 )
227}
228
229#[allow(clippy::too_many_arguments)]
230pub fn process_single_file_with_intro_outro<A, E, D>(
231 input_file: PathBuf,
232 output_file: PathBuf,
233 config: &Config,
234 analyzer: &A,
235 editor: &E,
236 duration_getter: &D,
237 intro: Option<PathBuf>,
238 outro: Option<PathBuf>,
239) -> Result<()>
240where
241 A: VideoAnalyzer,
242 E: VideoEditor,
243 D: DurationGetter,
244{
245 process_single_file_with_intro_outro_progress(
246 input_file,
247 output_file,
248 config,
249 analyzer,
250 editor,
251 duration_getter,
252 intro,
253 outro,
254 |_| {},
255 )
256}
257
258#[allow(clippy::too_many_arguments)]
259pub fn process_single_file_with_intro_outro_progress<A, E, D, F>(
260 input_file: PathBuf,
261 output_file: PathBuf,
262 config: &Config,
263 analyzer: &A,
264 editor: &E,
265 duration_getter: &D,
266 intro: Option<PathBuf>,
267 outro: Option<PathBuf>,
268 mut progress: F,
269) -> Result<()>
270where
271 A: VideoAnalyzer,
272 E: VideoEditor,
273 D: DurationGetter,
274 F: FnMut(ProcessingProgress),
275{
276 let mut guard = TempFileGuard::new(output_file.clone());
278
279 report_progress(&mut progress, 0.02, "Analyzing silence");
280 info!(file = ?input_file, "Analyzing video");
281 debug!(mode = ?config.silence.mode, "Silence mode");
282
283 let silences = analyzer
284 .detect_silence(
285 &input_file,
286 config.silence.threshold_db,
287 config.silence.min_duration,
288 )
289 .context("Failed to detect silence")?;
290
291 info!(count = silences.len(), "Detected silent segments");
292
293 report_progress(&mut progress, 0.08, "Planning edits");
294 let video_duration = duration_getter.get_duration(&input_file)?;
295 debug!(duration = video_duration, "Video duration");
296
297 let silences = if config.silence.scene_detect {
299 report_progress(&mut progress, 0.09, "Detecting scene changes");
300 match crate::scene_detection::detect_scene_changes(
301 &input_file,
302 config.silence.scene_threshold,
303 ) {
304 Ok(scenes) => {
305 info!(count = scenes.len(), "Detected scene changes");
306 merge_silences_and_scenes(&silences, &scenes, video_duration)
307 }
308 Err(e) => {
309 warn!(error = %e, "Scene detection failed, using silence only");
310 silences
311 }
312 }
313 } else {
314 silences
315 };
316
317 report_progress(&mut progress, 0.1, "Planning edits");
318
319 let transcript = if config.filler_words.enabled || config.audio.music_file.is_some() {
321 report_progress(&mut progress, 0.1, "Transcribing audio");
322 maybe_transcribe(&input_file, config)
323 } else {
324 None
325 };
326
327 let processed_segments = if config.filler_words.enabled {
328 match &transcript {
329 Some(t) => {
330 let filler_words: Vec<&str> = config
331 .filler_words
332 .words
333 .iter()
334 .map(|s| s.as_str())
335 .collect();
336 calculate_keep_segments_from_transcript(
337 t,
338 video_duration,
339 &filler_words,
340 config.filler_words.padding,
341 )
342 }
343 None => calculate_keep_segments(
344 &silences,
345 video_duration,
346 config.silence.padding,
347 config.silence.mode,
348 config.silence.speedup_factor,
349 config.silence.min_silence_for_speedup,
350 ),
351 }
352 } else {
353 calculate_keep_segments(
354 &silences,
355 video_duration,
356 config.silence.padding,
357 config.silence.mode,
358 config.silence.speedup_factor,
359 config.silence.min_silence_for_speedup,
360 )
361 };
362 debug!(count = processed_segments.len(), "Segments to process");
363
364 let trimmed_file = if config.audio.enhance
365 || config.audio.music_file.is_some()
366 || intro.is_some()
367 || outro.is_some()
368 {
369 let path = output_file.with_extension("trimmed.mp4");
370 guard.track(path.clone());
371 path
372 } else {
373 output_file.clone()
374 };
375
376 report_progress(&mut progress, 0.15, "Trimming video");
377 editor
378 .trim_video_with_progress(
379 &input_file,
380 &trimmed_file,
381 &processed_segments,
382 &mut |value| {
383 let percent = 0.15 + (value * 0.6);
384 report_progress(
385 &mut progress,
386 percent,
387 format!("Trimming video ({:.0}%)", value * 100.0),
388 );
389 },
390 )
391 .context("Failed to trim video")?;
392 debug!(file = ?trimmed_file, "Trimmed video saved");
393
394 let audio_file = if config.audio.noise_reduction {
395 let denoised = output_file.with_extension("denoised.mp4");
396 report_progress(&mut progress, 0.74, "Reducing noise");
397 info!("Reducing audio noise");
398 editor.reduce_noise(&trimmed_file, &denoised)?;
399 if trimmed_file != output_file {
400 guard.untrack(&trimmed_file);
401 let _ = fs::remove_file(&trimmed_file);
402 }
403 guard.track(denoised.clone());
404 denoised
405 } else {
406 trimmed_file
407 };
408
409 let enhanced_file = if config.audio.enhance {
410 let enhanced = output_file.with_extension("enhanced.mp4");
411 report_progress(&mut progress, 0.78, "Enhancing audio");
412 info!("Enhancing audio");
413 editor
414 .enhance_audio(&audio_file, &enhanced, config.audio.target_lufs)
415 .context("Failed to enhance audio")?;
416
417 if audio_file != output_file {
418 guard.untrack(&audio_file);
419 let _ = fs::remove_file(&audio_file);
420 }
421 guard.track(enhanced.clone());
422 enhanced
423 } else {
424 audio_file
425 };
426
427 let with_music_file = if let Some(ref music_path) = config.audio.music_file {
428 let with_music = output_file.with_extension("music.mp4");
429 report_progress(&mut progress, 0.84, "Mixing background music");
430 info!(music = ?music_path, "Mixing background music");
431
432 editor
433 .mix_with_music(
434 &enhanced_file,
435 music_path,
436 &with_music,
437 transcript.as_deref().unwrap_or(&[]),
438 config.audio.duck_volume,
439 )
440 .context("Failed to mix music")?;
441
442 if enhanced_file != output_file {
443 guard.untrack(&enhanced_file);
444 let _ = fs::remove_file(&enhanced_file);
445 }
446 guard.track(with_music.clone());
447 with_music
448 } else {
449 enhanced_file
450 };
451
452 let concat_file = if intro.is_some() || outro.is_some() {
453 report_progress(&mut progress, 0.88, "Adding intro/outro");
454 info!("Adding intro/outro");
455 concatenate_videos(
456 intro.as_deref(),
457 &with_music_file,
458 outro.as_deref(),
459 &output_file,
460 )?;
461
462 if with_music_file != output_file {
463 guard.untrack(&with_music_file);
464 let _ = fs::remove_file(&with_music_file);
465 }
466 guard.track(output_file.clone());
467 output_file.clone()
468 } else {
469 if with_music_file != output_file {
470 guard.untrack(&with_music_file);
471 fs::rename(&with_music_file, &output_file)?;
472 }
473 output_file.clone()
474 };
475
476 let mut current_file = concat_file;
477
478 if config.video.stabilize {
479 let stabilized = output_file.with_extension("stabilized.mp4");
480 report_progress(&mut progress, 0.9, "Stabilizing video");
481 info!("Stabilizing video");
482 editor.stabilize(¤t_file, &stabilized)?;
483 if current_file != output_file {
484 guard.untrack(¤t_file);
485 let _ = fs::remove_file(¤t_file);
486 }
487 guard.track(stabilized.clone());
488 current_file = stabilized;
489 }
490
491 if config.video.color_correct {
492 let corrected = output_file.with_extension("corrected.mp4");
493 report_progress(&mut progress, 0.93, "Color correcting");
494 info!("Color correcting");
495 editor.color_correct(¤t_file, &corrected)?;
496 if current_file != output_file {
497 guard.untrack(¤t_file);
498 let _ = fs::remove_file(¤t_file);
499 }
500 guard.track(corrected.clone());
501 current_file = corrected;
502 }
503
504 if config.video.reframe {
505 let reframed = output_file.with_extension("reframed.mp4");
506 report_progress(&mut progress, 0.95, "Auto-reframing");
507 info!("Auto-reframing to vertical (9:16)");
508 editor.reframe(¤t_file, &reframed, config.video.target_resolution)?;
509 if current_file != output_file {
510 guard.untrack(¤t_file);
511 let _ = fs::remove_file(¤t_file);
512 }
513 guard.track(reframed.clone());
514 current_file = reframed;
515 }
516
517 if config.video.blur_background {
518 let blurred = output_file.with_extension("blurred.mp4");
519 report_progress(&mut progress, 0.97, "Blurring background");
520 info!("Blurring background");
521 editor.blur_background(¤t_file, &blurred)?;
522 if current_file != output_file {
523 guard.untrack(¤t_file);
524 let _ = fs::remove_file(¤t_file);
525 }
526 guard.track(blurred.clone());
527 current_file = blurred;
528 }
529
530 if !config.video.reframe
533 && config.video.target_resolution != crate::config::VideoResolution::default()
534 {
535 let (target_w, target_h) = config.video.target_resolution.dimensions();
536 let scaled = output_file.with_extension("scaled.mp4");
537 report_progress(&mut progress, 0.96, "Scaling to target resolution");
538 info!(resolution = ?config.video.target_resolution, "Scaling to target resolution");
539 let status = std::process::Command::new("ffmpeg")
540 .arg("-i")
541 .arg(¤t_file)
542 .args(["-vf", &format!("scale={}:{}", target_w, target_h)])
543 .args(["-c:a", "copy", "-y"])
544 .arg(&scaled)
545 .status()
546 .context("failed to scale video")?;
547 if !status.success() {
548 anyhow::bail!("ffmpeg scale failed with status: {}", status);
549 }
550 if current_file != output_file {
551 guard.untrack(¤t_file);
552 let _ = fs::remove_file(¤t_file);
553 }
554 guard.track(scaled.clone());
555 current_file = scaled;
556 }
557
558 if let Some(ref watermark_path) = config.video.watermark {
560 let watermarked = output_file.with_extension("watermarked.mp4");
561 report_progress(&mut progress, 0.98, "Adding watermark");
562 info!(watermark = ?watermark_path, "Adding watermark");
563
564 let position =
565 crate::watermark::WatermarkPosition::parse_name(&config.video.watermark_position)
566 .unwrap_or(crate::watermark::WatermarkPosition::BottomRight);
567 let scale = config.video.watermark_scale;
568
569 crate::watermark::add_watermark(
570 ¤t_file,
571 watermark_path,
572 &watermarked,
573 position,
574 scale,
575 )?;
576
577 if current_file != output_file {
578 guard.untrack(¤t_file);
579 let _ = fs::remove_file(¤t_file);
580 }
581 guard.track(watermarked.clone());
582 current_file = watermarked;
583 }
584
585 if current_file != output_file {
587 fs::rename(¤t_file, &output_file)?;
588 }
589 guard.untrack(&output_file); report_progress(&mut progress, 0.99, "Writing exports");
592 export_additional_files(
593 &input_file,
594 &output_file,
595 &processed_segments,
596 config,
597 transcript.as_deref(),
598 )?;
599
600 report_progress(&mut progress, 1.0, "Done");
601 info!(file = ?output_file, "Successfully saved video");
602 Ok(())
603}
604
605fn maybe_transcribe(input_file: &Path, _config: &Config) -> Option<Vec<TranscriptSegment>> {
608 match CandleSttAnalyzer.transcribe(input_file) {
609 Ok(t) => {
610 info!(segments = t.len(), "Transcription complete");
611 Some(t)
612 }
613 Err(e) => {
614 warn!(error = %e, "Transcription failed");
615 None
616 }
617 }
618}
619
620fn merge_silences_and_scenes(
624 silences: &[crate::analyzer::Segment],
625 scenes: &[f32],
626 duration: f32,
627) -> Vec<crate::analyzer::Segment> {
628 if scenes.is_empty() {
629 return silences.to_vec();
630 }
631
632 let scene_segments = crate::scene_detection::scenes_to_segments(scenes, duration);
633
634 let mut merged: Vec<crate::analyzer::Segment> = silences
635 .iter()
636 .map(|silence| {
637 let mut start = silence.start;
638 let mut end = silence.end;
639
640 for scene in &scene_segments {
641 if (scene.start - start).abs() < 0.5 {
642 start = scene.start.min(start);
643 }
644 if (scene.end - end).abs() < 0.5 {
645 end = scene.end.max(end);
646 }
647 }
648
649 crate::analyzer::Segment { start, end }
650 })
651 .collect();
652
653 merged.sort_by(|a, b| a.start.total_cmp(&b.start));
654
655 let mut deduplicated: Vec<crate::analyzer::Segment> = Vec::new();
656 for seg in merged {
657 if let Some(last) = deduplicated.last_mut()
658 && seg.start <= last.end
659 {
660 last.end = last.end.max(seg.end);
661 continue;
662 }
663 deduplicated.push(seg);
664 }
665
666 deduplicated
667}
668
669fn report_progress<F>(progress: &mut F, fraction: f32, stage: impl Into<String>)
670where
671 F: FnMut(ProcessingProgress),
672{
673 progress(ProcessingProgress {
674 fraction: fraction.clamp(0.0, 1.0),
675 stage: stage.into(),
676 });
677}
678
679fn format_batch_summary(total: usize, successful: usize, failed: usize, skipped: usize) -> String {
680 let width = total.to_string().len().max(3);
681 let s_pct = successful
682 .checked_mul(100)
683 .and_then(|v| v.checked_div(total))
684 .unwrap_or(0);
685 let f_pct = failed
686 .checked_mul(100)
687 .and_then(|v| v.checked_div(total))
688 .unwrap_or(0);
689
690 let green = "\x1b[32m";
691 let red = "\x1b[31m";
692 let yellow = "\x1b[33m";
693 let reset = "\x1b[0m";
694
695 format!(
696 "\n=== BATCH SUMMARY ===\n\
697 Total files: {:>width$}\n\
698 {green} Successful:{reset} {:>width$} ({s_pct}%)\n\
699 {red} Failed:{reset} {:>width$} ({f_pct}%)\n\
700 {yellow} Skipped (done):{reset} {:>width$}\n\
701 =====================\n",
702 total,
703 successful,
704 failed,
705 skipped,
706 width = width,
707 green = green,
708 red = red,
709 yellow = yellow,
710 reset = reset,
711 s_pct = s_pct,
712 f_pct = f_pct,
713 )
714}
715
716fn print_batch_summary(total: usize, successful: usize, failed: usize, skipped: usize) {
717 print!(
718 "{}",
719 format_batch_summary(total, successful, failed, skipped)
720 );
721}
722
723fn export_additional_files(
725 input_file: &Path,
726 output_file: &Path,
727 segments: &[ProcessedSegment],
728 config: &Config,
729 cached_transcript: Option<&[TranscriptSegment]>,
730) -> Result<()> {
731 let base_path = output_file.with_extension("");
732
733 let transcript: Option<Vec<TranscriptSegment>> = if config.export.subtitles
741 || config.export.chapters
742 || config.export.captions
743 || config.export.clips
744 {
745 if let Some(t) = cached_transcript {
746 info!(segments = t.len(), "Using cached transcript for exports");
747 Some(t.to_vec())
748 } else {
749 match CandleSttAnalyzer.transcribe(output_file) {
750 Ok(t) => {
751 info!(segments = t.len(), "Transcription complete");
752 Some(t)
753 }
754 Err(e) => {
755 warn!(error = %e, "Transcription failed");
756 None
757 }
758 }
759 }
760 } else {
761 None
762 };
763
764 if config.export.subtitles {
765 let srt_path = base_path.with_extension("srt");
766 debug!(path = %srt_path.display(), "Exporting SRT subtitles");
767 if let Some(ref t) = transcript {
768 exporter::export_srt(t, &srt_path)?;
769 } else {
770 fs::write(&srt_path, "# Transcription failed\n")?;
771 }
772 }
773
774 if config.export.chapters {
775 let chapters_path = {
776 let mut p = base_path.as_os_str().to_os_string();
777 p.push(".chapters.txt");
778 PathBuf::from(p)
779 };
780 debug!(path = %chapters_path.display(), "Exporting YouTube chapters");
781 if let Some(ref t) = transcript {
782 exporter::export_youtube_chapters(t, &chapters_path)?;
783 } else {
784 fs::write(&chapters_path, "00:00 Intro\n")?;
785 }
786 }
787
788 if config.export.captions {
789 let ass_path = base_path.with_extension("ass");
790 debug!(path = %ass_path.display(), "Generating styled captions");
791 if let Some(ref t) = transcript {
792 if let Err(e) = generate_styled_captions(t, &ass_path) {
793 warn!(error = %e, "Failed to generate styled captions");
794 } else {
795 info!("Burning captions into video");
796 let captioned_path = output_file.with_extension("captioned.mp4");
797 burn_subtitles_into_video(output_file, &ass_path, &captioned_path)?;
798 atomic_replace(&captioned_path, output_file)?;
799 }
800 }
801 }
802
803 if config.export.clips
804 && let Some(ref t) = transcript
805 {
806 let clips_output_dir = base_path.parent().unwrap_or_else(|| Path::new("."));
807 let clip_pattern = format!(
808 "{}_clip",
809 base_path.file_stem().unwrap_or_default().to_string_lossy()
810 );
811 match extract_highlight_clips(
812 output_file,
813 t,
814 config.export.clip_count,
815 config.export.clip_min_duration,
816 config.export.clip_max_duration,
817 clips_output_dir,
818 &clip_pattern,
819 ) {
820 Ok(clip_paths) => {
821 info!(count = clip_paths.len(), "Extracted highlight clips");
822 }
823 Err(e) => {
824 warn!(error = %e, "Failed to extract highlight clips");
825 }
826 }
827 }
828
829 if config.export.fcpxml {
830 let fcpxml_path = base_path.with_extension("fcpxml");
831 debug!(path = %fcpxml_path.display(), "Exporting FCPXML");
832 exporter::export_fcpxml(segments, input_file, &fcpxml_path)?;
833 }
834
835 if config.export.edl {
836 let edl_path = base_path.with_extension("edl");
837 debug!(path = %edl_path.display(), "Exporting EDL");
838 let fps = crate::ml::FrameExtractor::get_video_fps(output_file).unwrap_or_else(|_| {
839 warn!("Failed to detect FPS for EDL export, defaulting to 25.0");
840 25.0
841 });
842 exporter::export_edl(segments, input_file, &edl_path, fps)?;
843 }
844
845 if config.export.thumbnail {
847 let thumb_path = base_path.with_extension("jpg");
848 debug!(path = %thumb_path.display(), "Generating thumbnail");
849 if let Err(e) = crate::thumbnail::generate_thumbnail(
850 output_file,
851 &thumb_path,
852 config.export.thumbnail_width,
853 config.export.thumbnail_height,
854 ) {
855 warn!(error = %e, "Failed to generate thumbnail");
856 }
857 }
858
859 if config.export.multi_format && !config.export.extra_resolutions.is_empty() {
861 debug!("Generating multi-format outputs");
862 for resolution in &config.export.extra_resolutions {
863 let (w, h) = resolution.dimensions();
864 let ext = output_file
865 .extension()
866 .and_then(|e| e.to_str())
867 .unwrap_or("mp4");
868 let multi_path = {
869 let mut p = base_path.as_os_str().to_os_string();
870 p.push(format!("_{}p.{}", h, ext));
871 PathBuf::from(p)
872 };
873 debug!(path = %multi_path.display(), resolution = ?resolution, "Generating alternate resolution");
874
875 let status = std::process::Command::new("ffmpeg")
876 .arg("-i")
877 .arg(output_file)
878 .args(["-vf", &format!("scale={}:{}", w, h)])
879 .args(["-c:a", "copy", "-y"])
880 .arg(&multi_path)
881 .status()
882 .context("failed to execute ffmpeg for multi-format")?;
883
884 if !status.success() {
885 warn!(path = %multi_path.display(), "Multi-format ffmpeg failed");
886 }
887 }
888 }
889
890 if config.export.preview {
892 let preview_path = crate::preview::preview_path(output_file);
893 debug!(path = %preview_path.display(), "Generating preview");
894 if let Err(e) = crate::preview::generate_preview(
895 output_file,
896 &preview_path,
897 config.export.preview_duration,
898 480,
899 ) {
900 warn!(error = %e, "Failed to generate preview");
901 }
902 }
903
904 Ok(())
905}
906
907fn generate_styled_captions(transcript: &[TranscriptSegment], output_path: &Path) -> Result<()> {
909 let mut ass = String::new();
910 ass.push_str("[Script Info]\n");
911 ass.push_str("Title: Generated Captions\n");
912 ass.push_str("ScriptType: v4.00+\n");
913 ass.push_str("Collisions: Normal\n");
914 ass.push_str("PlayDepth: 0\n\n");
915
916 ass.push_str("[V4+ Styles]\n");
917 ass.push_str("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n");
918 ass.push_str("Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,2,2,2,10,10,30,1\n\n");
919
920 ass.push_str("[Events]\n");
921 ass.push_str(
922 "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n",
923 );
924
925 for seg in transcript {
926 let text = seg.text.trim();
927 if text.is_empty() || text == "[No speech detected]" {
928 continue;
929 }
930 let start = format_ass_time(seg.start);
931 let end = format_ass_time(seg.end);
932 let escaped = text
936 .replace('\\', "\\\\")
937 .replace('\n', "\\N")
938 .replace('\r', "");
939 ass.push_str(&format!(
940 "Dialogue: 0,{},{},Default,,0,0,0,,{}\n",
941 start, end, escaped
942 ));
943 }
944
945 fs::write(output_path, ass)?;
946 Ok(())
947}
948
949fn format_ass_time(seconds: f32) -> String {
950 let seconds = seconds.max(0.0);
951 let hours = (seconds / 3600.0) as u32;
952 let minutes = ((seconds % 3600.0) / 60.0) as u32;
953 let secs = (seconds % 60.0) as u32;
954 let centisecs = ((seconds % 1.0) * 100.0) as u32;
955 format!("{}:{:02}:{:02}.{:02}", hours, minutes, secs, centisecs)
956}
957
958fn burn_subtitles_into_video(
960 video_path: &Path,
961 subtitle_path: &Path,
962 output_path: &Path,
963) -> Result<()> {
964 let escaped_subtitle_path = crate::utils::escape_ffmpeg_filter_path(subtitle_path);
965 let status = std::process::Command::new("ffmpeg")
966 .arg("-i")
967 .arg(video_path)
968 .arg("-vf")
969 .arg(format!("subtitles='{}'", escaped_subtitle_path))
970 .args(["-c:a", "copy", "-y"])
971 .arg(output_path)
972 .status()
973 .context("failed to burn subtitles")?;
974
975 if !status.success() {
976 anyhow::bail!("ffmpeg subtitle burn failed with status: {}", status);
977 }
978
979 if !output_path.exists() {
980 anyhow::bail!("ffmpeg subtitle burn did not produce output file");
981 }
982 Ok(())
983}
984
985fn extract_highlight_clips(
987 video_path: &Path,
988 transcript: &[TranscriptSegment],
989 clip_count: u32,
990 min_duration: f32,
991 max_duration: f32,
992 output_dir: &Path,
993 clip_pattern: &str,
994) -> Result<Vec<PathBuf>> {
995 let mut segment_energy: Vec<(f32, f32, f32)> = Vec::new(); for seg in transcript {
999 let text = seg.text.trim();
1000 if text.is_empty() || text == "[No speech detected]" {
1001 continue;
1002 }
1003
1004 let duration = seg.end - seg.start;
1007 let word_count = text.split_whitespace().count() as f32;
1008 let energy = word_count / duration.max(1.0); segment_energy.push((seg.start, seg.end, energy));
1011 }
1012
1013 if segment_energy.is_empty() {
1014 return Ok(vec![]);
1015 }
1016
1017 segment_energy.sort_by(|a, b| b.2.total_cmp(&a.2));
1019
1020 let video_duration = crate::ml::FrameExtractor::get_video_duration(video_path)
1022 .unwrap_or_else(|_| {
1023 transcript.last().map(|s| s.end).unwrap_or_else(|| {
1024 warn!(video = %video_path.display(), "Could not determine video duration for clip extraction; using 60.0 as fallback");
1025 60.0
1026 })
1027 });
1028
1029 let mut clip_times: Vec<(f32, f32)> = Vec::new();
1030 for &(start, end, _) in segment_energy.iter().take(clip_count as usize) {
1031 let clip_start = (start - 2.0).max(0.0);
1033 let clip_end = (end + 2.0).min(video_duration);
1034 let clip_duration = clip_end - clip_start;
1035
1036 if clip_duration >= min_duration && clip_duration <= max_duration {
1037 clip_times.push((clip_start, clip_end));
1038 }
1039 }
1040
1041 let mut clip_paths = Vec::new();
1043 for (i, (clip_start, clip_end)) in clip_times.iter().enumerate() {
1044 let clip_path = output_dir.join(format!("{}_{}.mp4", clip_pattern, i + 1));
1045
1046 let duration = clip_end - clip_start;
1047 let status = std::process::Command::new("ffmpeg")
1050 .args([
1051 "-ss",
1052 &format!("{}", clip_start),
1053 "-i",
1054 video_path.to_str().context("invalid path")?,
1055 "-t",
1056 &format!("{}", duration),
1057 "-c",
1058 "copy",
1059 "-avoid_negative_ts",
1060 "make_zero",
1061 "-y",
1062 clip_path.to_str().context("invalid output path")?,
1063 ])
1064 .status()
1065 .context("failed to extract clip")?;
1066
1067 if status.success() && clip_path.exists() {
1068 clip_paths.push(clip_path);
1069 }
1070 }
1071
1072 Ok(clip_paths)
1073}
1074
1075pub fn process_batch_dir<A, E, D>(
1076 input_dir: PathBuf,
1077 output_dir: PathBuf,
1078 config: &Config,
1079 analyzer: &A,
1080 editor: &E,
1081 duration_getter: &D,
1082 no_progress: bool,
1083) -> Result<()>
1084where
1085 A: VideoAnalyzer,
1086 E: VideoEditor,
1087 D: DurationGetter,
1088{
1089 info!(dir = ?input_dir, "Processing directory");
1090 debug!(output = ?output_dir, mode = ?config.silence.mode, "Batch config");
1091
1092 fs::create_dir_all(&output_dir).context(format!(
1093 "Failed to create output directory {:?}",
1094 output_dir
1095 ))?;
1096
1097 let video_files = find_video_files(&input_dir)?;
1098
1099 if video_files.is_empty() {
1100 warn!(dir = ?input_dir, "No supported video files found");
1101 return Ok(());
1102 }
1103
1104 let progress_path = BatchProgress::default_path(&input_dir);
1106 let mut progress = BatchProgress::from_file(&progress_path).unwrap_or_default();
1107 progress.total = video_files.len();
1108
1109 let total_files = video_files.len();
1110 let mut successful_files = 0;
1111 let mut failed_files = 0;
1112 let mut skipped_files = 0;
1113
1114 let pb = if no_progress {
1115 None
1116 } else {
1117 let bar = ProgressBar::new(total_files as u64);
1118 let template = "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} [{eta_precise}] {msg}";
1119 bar.set_style(
1120 ProgressStyle::default_bar()
1121 .template(template)
1122 .unwrap_or_else(|_| ProgressStyle::default_bar())
1123 .progress_chars("#>-"),
1124 );
1125 bar.enable_steady_tick(std::time::Duration::from_millis(250));
1126 Some(bar)
1127 };
1128
1129 let preset_rules = crate::preset_rules::default_preset_rules();
1130
1131 for input_file in &video_files {
1132 if progress.is_completed(input_file) {
1133 info!(file = ?input_file, "Skipping already processed file");
1134 skipped_files += 1;
1135 if let Some(ref b) = pb {
1136 b.inc(1);
1137 }
1138 continue;
1139 }
1140
1141 let file_name = input_file
1142 .file_name()
1143 .context(format!("Could not get file name for {:?}", input_file))?;
1144 let output_file = output_dir.join(file_name);
1145
1146 if let Some(ref b) = pb {
1147 b.set_message(format!("{}", input_file.display()));
1148 }
1149
1150 let file_preset = crate::preset_rules::preset_for_file(
1152 input_file,
1153 &preset_rules,
1154 crate::config::Preset::Youtube, );
1156 let file_config = if file_preset != crate::config::Preset::Youtube {
1157 info!(preset = ?file_preset, file = ?file_name, "Applying filename-based preset");
1158 let mut c = file_preset.to_config();
1159 c = c.merge(config.clone());
1161 c
1162 } else {
1163 config.clone()
1164 };
1165
1166 match process_single_file(
1167 input_file.clone(),
1168 output_file.clone(),
1169 &file_config,
1170 analyzer,
1171 editor,
1172 duration_getter,
1173 ) {
1174 Ok(_) => {
1175 info!(file = ?input_file, "Successfully processed");
1176 successful_files += 1;
1177 progress.mark_completed(input_file);
1178 }
1179 Err(e) => {
1180 warn!(file = ?input_file, error = %e, "Failed to process");
1181 failed_files += 1;
1182 progress.mark_failed(input_file);
1183 }
1184 }
1185
1186 if let Err(e) = progress.to_file(&progress_path) {
1187 warn!("Failed to save progress file: {}", e);
1188 }
1189 if let Some(ref b) = pb {
1190 b.inc(1);
1191 }
1192 }
1193
1194 if let Some(b) = pb {
1195 b.finish_with_message("Done");
1196 }
1197
1198 print_batch_summary(total_files, successful_files, failed_files, skipped_files);
1199
1200 info!(
1201 total = total_files,
1202 successful = successful_files,
1203 failed = failed_files,
1204 skipped = skipped_files,
1205 "Batch processing complete"
1206 );
1207
1208 Ok(())
1209}
1210
1211#[allow(clippy::too_many_arguments)]
1214pub fn process_batch_dir_parallel<A, E, D>(
1215 input_dir: PathBuf,
1216 output_dir: PathBuf,
1217 config: &Config,
1218 worker_count: usize,
1219 _analyzer: &A,
1220 _editor: &E,
1221 _duration_getter: &D,
1222 no_progress: bool,
1223) -> Result<()>
1224where
1225 A: VideoAnalyzer + Send + Sync,
1226 E: VideoEditor + Send + Sync,
1227 D: DurationGetter + Send + Sync,
1228{
1229 let worker_count = worker_count.max(1);
1230 info!(dir = ?input_dir, workers = worker_count, "Processing directory in parallel");
1231
1232 fs::create_dir_all(&output_dir).context(format!(
1233 "Failed to create output directory {:?}",
1234 output_dir
1235 ))?;
1236
1237 let mut video_files = find_video_files(&input_dir)?;
1238
1239 if video_files.is_empty() {
1240 warn!(dir = ?input_dir, "No supported video files found");
1241 return Ok(());
1242 }
1243
1244 let progress_path = BatchProgress::default_path(&input_dir);
1246 let mut progress = BatchProgress::from_file(&progress_path).unwrap_or_default();
1247 video_files.retain(|f| !progress.is_completed(f));
1248
1249 if video_files.is_empty() {
1250 info!("All files already processed");
1251 return Ok(());
1252 }
1253
1254 progress.total = video_files.len();
1255
1256 let total_files = video_files.len();
1257 let successful_files = Arc::new(AtomicUsize::new(0));
1258 let failed_files = Arc::new(AtomicUsize::new(0));
1259 let config = Arc::new(config.clone());
1260 let output_dir = Arc::new(output_dir);
1261 let progress = Arc::new(std::sync::Mutex::new(progress));
1262 let progress_path = Arc::new(progress_path);
1263
1264 let pb = if no_progress {
1265 None
1266 } else {
1267 let mp = MultiProgress::new();
1268 let bar = mp.add(ProgressBar::new(total_files as u64));
1269 let template = "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} [{eta_precise}] {msg}";
1270 bar.set_style(
1271 ProgressStyle::default_bar()
1272 .template(template)
1273 .unwrap_or_else(|_| ProgressStyle::default_bar())
1274 .progress_chars("#>-"),
1275 );
1276 bar.set_message("Parallel processing...");
1277 bar.enable_steady_tick(std::time::Duration::from_millis(250));
1278 Some(Arc::new(bar))
1279 };
1280
1281 let chunks: Vec<Vec<PathBuf>> = video_files
1283 .chunks(total_files.div_ceil(worker_count))
1284 .map(|c| c.to_vec())
1285 .collect();
1286
1287 std::thread::scope(|s| {
1288 for chunk in chunks {
1289 let config = Arc::clone(&config);
1290 let output_dir = Arc::clone(&output_dir);
1291 let successful = Arc::clone(&successful_files);
1292 let failed = Arc::clone(&failed_files);
1293 let progress = Arc::clone(&progress);
1294 let progress_path = Arc::clone(&progress_path);
1295 let pb = pb.clone();
1296
1297 s.spawn(move || {
1298 for input_file in chunk {
1299 let file_name = match input_file.file_name() {
1300 Some(name) => name.to_os_string(),
1301 None => continue,
1302 };
1303 let output_file = output_dir.join(&file_name);
1304
1305 let analyzer = crate::analyzer::FfmpegAnalyzer;
1307 let editor = crate::editor::FfmpegEditor::new(config.video.hw_accel);
1308 let duration_getter = FfmpegDurationGetter;
1309
1310 let result = process_single_file(
1311 input_file.clone(),
1312 output_file,
1313 &config,
1314 &analyzer,
1315 &editor,
1316 &duration_getter,
1317 );
1318
1319 match result {
1320 Ok(_) => {
1321 info!(file = ?input_file, "Successfully processed");
1322 successful.fetch_add(1, Ordering::SeqCst);
1323 let mut p = progress.lock().unwrap_or_else(|p| p.into_inner());
1324 p.mark_completed(&input_file);
1325 if let Err(e) = p.to_file(&progress_path) {
1326 warn!(error = %e, "Failed to save progress file");
1327 }
1328 }
1329 Err(e) => {
1330 warn!(file = ?input_file, error = %e, "Failed to process");
1331 failed.fetch_add(1, Ordering::SeqCst);
1332 let mut p = progress.lock().unwrap_or_else(|p| p.into_inner());
1333 p.mark_failed(&input_file);
1334 if let Err(e) = p.to_file(&progress_path) {
1335 warn!(error = %e, "Failed to save progress file");
1336 }
1337 }
1338 }
1339 if let Some(ref b) = pb {
1340 b.inc(1);
1341 }
1342 }
1343 });
1344 }
1345 });
1346
1347 if let Some(b) = pb {
1348 b.finish_with_message("Done");
1349 }
1350
1351 let successful = successful_files.load(Ordering::SeqCst);
1352 let failed = failed_files.load(Ordering::SeqCst);
1353 let skipped = 0usize;
1354
1355 print_batch_summary(total_files, successful, failed, skipped);
1356
1357 info!(
1358 total = total_files,
1359 successful, failed, "Parallel batch processing complete"
1360 );
1361
1362 Ok(())
1363}
1364
1365#[cfg(test)]
1366mod tests {
1367 use super::*;
1368 use crate::analyzer::Segment;
1369 use tempfile::tempdir;
1370
1371 #[test]
1372 fn test_format_ass_time() {
1373 assert_eq!(format_ass_time(0.0), "0:00:00.00");
1374 assert_eq!(format_ass_time(5.0), "0:00:05.00");
1375 assert_eq!(format_ass_time(65.5), "0:01:05.50");
1376 assert_eq!(format_ass_time(3661.25), "1:01:01.25");
1377 assert_eq!(format_ass_time(359999.99), "100:00:00.00");
1378 assert_eq!(format_ass_time(0.001), "0:00:00.00");
1379 assert_eq!(format_ass_time(-5.0), "0:00:00.00");
1380 assert_eq!(format_ass_time(-0.001), "0:00:00.00");
1381 }
1382
1383 struct MockFfmpegAnalyzer;
1384 impl crate::analyzer::VideoAnalyzer for MockFfmpegAnalyzer {
1385 fn detect_silence(
1386 &self,
1387 _path: &Path,
1388 _threshold_db: f32,
1389 _duration_s: f32,
1390 ) -> Result<Vec<crate::analyzer::Segment>> {
1391 Ok(vec![])
1392 }
1393 }
1394
1395 struct MockFfmpegEditor;
1396 impl VideoEditor for MockFfmpegEditor {
1397 fn reframe(
1398 &self,
1399 _input: &Path,
1400 _output: &Path,
1401 _target_resolution: crate::config::VideoResolution,
1402 ) -> Result<()> {
1403 Ok(())
1404 }
1405
1406 fn blur_background(&self, _input: &Path, _output: &Path) -> Result<()> {
1407 Ok(())
1408 }
1409
1410 fn trim_video(
1411 &self,
1412 _input: &Path,
1413 output: &Path,
1414 _segments: &[crate::analyzer::ProcessedSegment],
1415 ) -> Result<()> {
1416 fs::File::create(output)?;
1418 Ok(())
1419 }
1420
1421 fn mix_with_music(
1422 &self,
1423 _input: &Path,
1424 _music: &Path,
1425 _output: &Path,
1426 _transcript: &[crate::stt_analyzer::TranscriptSegment],
1427 _duck_volume: f32,
1428 ) -> Result<()> {
1429 Ok(())
1430 }
1431
1432 fn enhance_audio(&self, _input: &Path, _output: &Path, _target_lufs: f32) -> Result<()> {
1433 Ok(())
1434 }
1435
1436 fn reduce_noise(&self, _input: &Path, _output: &Path) -> Result<()> {
1437 Ok(())
1438 }
1439
1440 fn stabilize(&self, _input: &Path, _output: &Path) -> Result<()> {
1441 Ok(())
1442 }
1443
1444 fn color_correct(&self, _input: &Path, _output: &Path) -> Result<()> {
1445 Ok(())
1446 }
1447 }
1448
1449 struct MockDurationGetter;
1451 impl DurationGetter for MockDurationGetter {
1452 fn get_duration(&self, _path: &Path) -> Result<f32> {
1453 Ok(60.0) }
1455 }
1456
1457 #[test]
1458 fn test_batch_processing_integration() -> Result<()> {
1459 let input_dir = tempdir()?;
1460 let output_dir = tempdir()?;
1461
1462 fs::File::create(input_dir.path().join("video1.mp4"))?;
1464 fs::File::create(input_dir.path().join("video2.mov"))?;
1465 fs::File::create(input_dir.path().join("document.txt"))?; let mock_analyzer = MockFfmpegAnalyzer;
1469 let mock_editor = MockFfmpegEditor;
1470 let mock_duration_getter = MockDurationGetter;
1471
1472 let mut config = Config::default();
1474 config.audio.enhance = false;
1475
1476 let result = process_batch_dir(
1477 input_dir.path().to_path_buf(),
1478 output_dir.path().to_path_buf(),
1479 &config,
1480 &mock_analyzer,
1481 &mock_editor,
1482 &mock_duration_getter,
1483 true,
1484 );
1485
1486 assert!(result.is_ok());
1487
1488 let output_files: Vec<_> = fs::read_dir(output_dir.path())?
1490 .filter_map(|e| e.ok())
1491 .map(|e| e.path())
1492 .collect();
1493
1494 assert_eq!(output_files.len(), 2);
1495 assert!(output_files.iter().any(|p| p.ends_with("video1.mp4")));
1496 assert!(output_files.iter().any(|p| p.ends_with("video2.mov")));
1497
1498 Ok(())
1499 }
1500
1501 #[test]
1502 fn test_batch_processing_empty_dir() -> Result<()> {
1503 let input_dir = tempdir()?;
1504 let output_dir = tempdir()?;
1505
1506 let mock_analyzer = MockFfmpegAnalyzer;
1507 let mock_editor = MockFfmpegEditor;
1508 let mock_duration_getter = MockDurationGetter;
1509
1510 let config = Config::default();
1511
1512 let result = process_batch_dir(
1513 input_dir.path().to_path_buf(),
1514 output_dir.path().to_path_buf(),
1515 &config,
1516 &mock_analyzer,
1517 &mock_editor,
1518 &mock_duration_getter,
1519 true,
1520 );
1521
1522 assert!(result.is_ok());
1523 let output_files: Vec<_> = fs::read_dir(output_dir.path())?
1524 .filter_map(|e| e.ok())
1525 .collect();
1526 assert_eq!(output_files.len(), 0);
1527 Ok(())
1528 }
1529
1530 #[test]
1531 fn test_batch_processing_nonexistent_input_dir() -> Result<()> {
1532 let output_dir = tempdir()?;
1533
1534 let mock_analyzer = MockFfmpegAnalyzer;
1535 let mock_editor = MockFfmpegEditor;
1536 let mock_duration_getter = MockDurationGetter;
1537
1538 let config = Config::default();
1539
1540 let result = process_batch_dir(
1543 PathBuf::from("/nonexistent/path/12345"),
1544 output_dir.path().to_path_buf(),
1545 &config,
1546 &mock_analyzer,
1547 &mock_editor,
1548 &mock_duration_getter,
1549 true,
1550 );
1551
1552 assert!(result.is_ok());
1553 Ok(())
1554 }
1555
1556 #[test]
1557 fn test_find_video_files_empty_dir() {
1558 let dir = tempdir().unwrap();
1559 let files = find_video_files(dir.path()).unwrap();
1560 assert!(files.is_empty());
1561 }
1562
1563 #[test]
1564 fn test_find_video_files_ignores_non_video() {
1565 let dir = tempdir().unwrap();
1566 fs::File::create(dir.path().join("video.mp4")).unwrap();
1567 fs::File::create(dir.path().join("document.txt")).unwrap();
1568 fs::File::create(dir.path().join("image.jpg")).unwrap();
1569
1570 let files = find_video_files(dir.path()).unwrap();
1571 assert_eq!(files.len(), 1);
1572 assert!(files[0].to_string_lossy().contains("video.mp4"));
1573 }
1574
1575 #[test]
1576 fn test_find_video_files_nested_dirs() {
1577 let dir = tempdir().unwrap();
1578 fs::File::create(dir.path().join("video1.mp4")).unwrap();
1579 fs::create_dir(dir.path().join("subdir")).unwrap();
1580 fs::File::create(dir.path().join("subdir/video2.mov")).unwrap();
1581
1582 let files = find_video_files(dir.path()).unwrap();
1583 assert_eq!(files.len(), 2);
1584 }
1585
1586 #[test]
1587 fn test_find_video_files_case_insensitive() {
1588 let dir = tempdir().unwrap();
1589 fs::File::create(dir.path().join("video1.MP4")).unwrap();
1590 fs::File::create(dir.path().join("video2.mOv")).unwrap();
1591 fs::File::create(dir.path().join("video3.MKV")).unwrap();
1592
1593 let files = find_video_files(dir.path()).unwrap();
1594 assert_eq!(files.len(), 3);
1595 }
1596
1597 struct MockFfmpegAnalyzerFails;
1598 impl crate::analyzer::VideoAnalyzer for MockFfmpegAnalyzerFails {
1599 fn detect_silence(
1600 &self,
1601 _path: &Path,
1602 _threshold_db: f32,
1603 _duration_s: f32,
1604 ) -> Result<Vec<crate::analyzer::Segment>> {
1605 Err(anyhow::anyhow!("Simulated silence detection failure"))
1606 }
1607 }
1608
1609 #[allow(dead_code)]
1610 struct MockFfmpegEditorFails;
1611 impl VideoEditor for MockFfmpegEditorFails {
1612 fn reframe(
1613 &self,
1614 _input: &Path,
1615 _output: &Path,
1616 _target_resolution: crate::config::VideoResolution,
1617 ) -> Result<()> {
1618 Ok(())
1619 }
1620 fn blur_background(&self, _input: &Path, _output: &Path) -> Result<()> {
1621 Ok(())
1622 }
1623 fn trim_video(
1624 &self,
1625 _input: &Path,
1626 output: &Path,
1627 _segments: &[crate::analyzer::ProcessedSegment],
1628 ) -> Result<()> {
1629 fs::File::create(output)?;
1630 Err(anyhow::anyhow!("Simulated trim failure"))
1631 }
1632 fn mix_with_music(
1633 &self,
1634 _input: &Path,
1635 _music: &Path,
1636 _output: &Path,
1637 _transcript: &[crate::stt_analyzer::TranscriptSegment],
1638 _duck_volume: f32,
1639 ) -> Result<()> {
1640 Ok(())
1641 }
1642 fn enhance_audio(&self, _input: &Path, _output: &Path, _target_lufs: f32) -> Result<()> {
1643 Ok(())
1644 }
1645 fn reduce_noise(&self, _input: &Path, _output: &Path) -> Result<()> {
1646 Ok(())
1647 }
1648 fn stabilize(&self, _input: &Path, _output: &Path) -> Result<()> {
1649 Ok(())
1650 }
1651 fn color_correct(&self, _input: &Path, _output: &Path) -> Result<()> {
1652 Ok(())
1653 }
1654 }
1655
1656 #[test]
1657 fn test_batch_processing_with_mock_failure() -> Result<()> {
1658 let input_dir = tempdir()?;
1659 let output_dir = tempdir()?;
1660
1661 fs::File::create(input_dir.path().join("video1.mp4")).unwrap();
1662
1663 let mock_analyzer = MockFfmpegAnalyzerFails;
1664 let mock_editor = MockFfmpegEditor;
1665 let mock_duration_getter = MockDurationGetter;
1666
1667 let mut config = Config::default();
1668 config.audio.enhance = false;
1669
1670 let result = process_batch_dir(
1671 input_dir.path().to_path_buf(),
1672 output_dir.path().to_path_buf(),
1673 &config,
1674 &mock_analyzer,
1675 &mock_editor,
1676 &mock_duration_getter,
1677 true,
1678 );
1679
1680 assert!(result.is_ok());
1682 Ok(())
1683 }
1684
1685 #[test]
1686 fn test_batch_processing_multiple_video_types() -> Result<()> {
1687 let input_dir = tempdir()?;
1688 let output_dir = tempdir()?;
1689
1690 let video_types = [
1691 "video1.mp4",
1692 "video2.mov",
1693 "video3.avi",
1694 "video4.mkv",
1695 "video5.webm",
1696 ];
1697 for name in &video_types {
1698 fs::File::create(input_dir.path().join(name))?;
1699 }
1700 fs::File::create(input_dir.path().join("document.txt"))?;
1701
1702 let mock_analyzer = MockFfmpegAnalyzer;
1703 let mock_editor = MockFfmpegEditor;
1704 let mock_duration_getter = MockDurationGetter;
1705
1706 let config = Config::default();
1707
1708 let result = process_batch_dir(
1709 input_dir.path().to_path_buf(),
1710 output_dir.path().to_path_buf(),
1711 &config,
1712 &mock_analyzer,
1713 &mock_editor,
1714 &mock_duration_getter,
1715 true,
1716 );
1717
1718 assert!(result.is_ok());
1719 let output_files: Vec<_> = fs::read_dir(output_dir.path())?
1722 .filter_map(|e| e.ok())
1723 .map(|e| e.path())
1724 .collect();
1725 assert!(!output_files.is_empty() || output_dir.path().exists());
1728 Ok(())
1729 }
1730
1731 #[test]
1732 fn test_batch_processing_creates_output_dir() -> Result<()> {
1733 let input_dir = tempdir()?;
1734 let output_dir = tempdir()?;
1735
1736 fs::File::create(input_dir.path().join("video.mp4"))?;
1737
1738 let mock_analyzer = MockFfmpegAnalyzer;
1739 let mock_editor = MockFfmpegEditor;
1740 let mock_duration_getter = MockDurationGetter;
1741
1742 let config = Config::default();
1743
1744 assert!(output_dir.path().exists());
1746
1747 let result = process_batch_dir(
1748 input_dir.path().to_path_buf(),
1749 output_dir.path().join("nested"),
1750 &config,
1751 &mock_analyzer,
1752 &mock_editor,
1753 &mock_duration_getter,
1754 true,
1755 );
1756
1757 assert!(result.is_ok());
1758 assert!(output_dir.path().join("nested").exists());
1759 Ok(())
1760 }
1761
1762 #[test]
1763 fn test_batch_processing_with_disabled_features() -> Result<()> {
1764 let input_dir = tempdir()?;
1765 let output_dir = tempdir()?;
1766
1767 fs::File::create(input_dir.path().join("video.mp4"))?;
1768
1769 let mock_analyzer = MockFfmpegAnalyzer;
1770 let mock_editor = MockFfmpegEditor;
1771 let mock_duration_getter = MockDurationGetter;
1772
1773 let mut config = Config::default();
1774 config.audio.enhance = false;
1775 config.audio.noise_reduction = false;
1776 config.video.stabilize = false;
1777 config.video.color_correct = false;
1778 config.video.reframe = false;
1779 config.video.blur_background = false;
1780 config.export.subtitles = false;
1781 config.export.chapters = false;
1782
1783 let result = process_batch_dir(
1784 input_dir.path().to_path_buf(),
1785 output_dir.path().to_path_buf(),
1786 &config,
1787 &mock_analyzer,
1788 &mock_editor,
1789 &mock_duration_getter,
1790 true,
1791 );
1792
1793 assert!(result.is_ok());
1795 assert!(output_dir.path().join("video.mp4").exists() || output_dir.path().exists());
1797 Ok(())
1798 }
1799
1800 #[test]
1801 fn test_batch_processing_progress_persists_across_runs() -> Result<()> {
1802 let input_dir = tempdir()?;
1803 let output_dir = tempdir()?;
1804
1805 fs::File::create(input_dir.path().join("video1.mp4"))?;
1806 fs::File::create(input_dir.path().join("video2.mp4"))?;
1807
1808 let mock_analyzer = MockFfmpegAnalyzer;
1809 let mock_editor = MockFfmpegEditor;
1810 let mock_duration_getter = MockDurationGetter;
1811
1812 let mut config = Config::default();
1813 config.audio.enhance = false;
1814
1815 let result1 = process_batch_dir(
1817 input_dir.path().to_path_buf(),
1818 output_dir.path().to_path_buf(),
1819 &config,
1820 &mock_analyzer,
1821 &mock_editor,
1822 &mock_duration_getter,
1823 true,
1824 );
1825 assert!(result1.is_ok());
1826
1827 let result2 = process_batch_dir(
1829 input_dir.path().to_path_buf(),
1830 output_dir.path().to_path_buf(),
1831 &config,
1832 &mock_analyzer,
1833 &mock_editor,
1834 &mock_duration_getter,
1835 true,
1836 );
1837 assert!(result2.is_ok());
1838
1839 let output_files: Vec<_> = fs::read_dir(output_dir.path())?
1841 .filter_map(|e| e.ok())
1842 .map(|e| e.path())
1843 .collect();
1844 assert_eq!(output_files.len(), 2);
1845 Ok(())
1846 }
1847
1848 #[test]
1851 fn test_merge_silences_and_scenes_empty_scenes() {
1852 let silences = vec![
1853 Segment {
1854 start: 1.0,
1855 end: 3.0,
1856 },
1857 Segment {
1858 start: 5.0,
1859 end: 7.0,
1860 },
1861 ];
1862 let scenes: Vec<f32> = vec![];
1863 let merged = merge_silences_and_scenes(&silences, &scenes, 10.0);
1864 assert_eq!(merged.len(), 2);
1865 assert_eq!(
1866 merged[0],
1867 Segment {
1868 start: 1.0,
1869 end: 3.0
1870 }
1871 );
1872 assert_eq!(
1873 merged[1],
1874 Segment {
1875 start: 5.0,
1876 end: 7.0
1877 }
1878 );
1879 }
1880
1881 #[test]
1882 fn test_merge_silences_and_scenes_empty_silences() {
1883 let silences: Vec<Segment> = vec![];
1884 let scenes = vec![2.0, 5.0];
1885 let merged = merge_silences_and_scenes(&silences, &scenes, 10.0);
1886 assert_eq!(merged.len(), 0);
1887 }
1888
1889 #[test]
1890 fn test_merge_silences_and_scenes_overlapping_silences() {
1891 let silences = vec![
1894 Segment {
1895 start: 1.0,
1896 end: 3.0,
1897 },
1898 Segment {
1899 start: 2.5,
1900 end: 5.0,
1901 },
1902 ];
1903 let scenes: Vec<f32> = vec![];
1904 let merged = merge_silences_and_scenes(&silences, &scenes, 10.0);
1905 assert_eq!(merged.len(), 2);
1906 }
1907
1908 #[test]
1909 fn test_merge_silences_and_scenes_adjacent_silences() {
1910 let silences = vec![
1912 Segment {
1913 start: 1.0,
1914 end: 3.0,
1915 },
1916 Segment {
1917 start: 3.0,
1918 end: 5.0,
1919 },
1920 ];
1921 let scenes: Vec<f32> = vec![];
1922 let merged = merge_silences_and_scenes(&silences, &scenes, 10.0);
1923 assert_eq!(merged.len(), 2);
1924 }
1925
1926 #[test]
1927 fn test_merge_silences_and_scenes_scene_extends_boundary() {
1928 let silences = vec![Segment {
1930 start: 1.0,
1931 end: 3.0,
1932 }];
1933 let scenes = vec![0.7];
1936 let merged = merge_silences_and_scenes(&silences, &scenes, 10.0);
1937 assert_eq!(merged.len(), 1);
1938 assert_eq!(merged[0].start, 0.7);
1940 assert_eq!(merged[0].end, 3.0);
1941 }
1942
1943 #[test]
1944 fn test_merge_silences_and_scenes_no_overlap() {
1945 let silences = vec![Segment {
1947 start: 1.0,
1948 end: 2.0,
1949 }];
1950 let scenes = vec![5.0];
1952 let merged = merge_silences_and_scenes(&silences, &scenes, 10.0);
1953 assert_eq!(merged.len(), 1);
1954 assert_eq!(
1955 merged[0],
1956 Segment {
1957 start: 1.0,
1958 end: 2.0
1959 }
1960 );
1961 }
1962
1963 #[test]
1964 fn test_merge_silences_and_scenes_complex_overlap() {
1965 let silences = vec![
1968 Segment {
1969 start: 1.0,
1970 end: 3.0,
1971 },
1972 Segment {
1973 start: 6.0,
1974 end: 8.0,
1975 },
1976 ];
1977 let scenes = vec![0.8, 3.2, 5.8, 8.2];
1982 let merged = merge_silences_and_scenes(&silences, &scenes, 10.0);
1983 assert_eq!(merged.len(), 2);
1984 assert_eq!(
1986 merged[0],
1987 Segment {
1988 start: 0.8,
1989 end: 3.2
1990 }
1991 );
1992 assert_eq!(
1994 merged[1],
1995 Segment {
1996 start: 5.8,
1997 end: 8.2
1998 }
1999 );
2000 }
2001
2002 #[test]
2003 fn test_format_batch_summary_basic() {
2004 let s = format_batch_summary(10, 7, 2, 1);
2005 assert!(s.contains("\x1b[32m"), "missing green color code");
2007 assert!(s.contains("\x1b[31m"), "missing red color code");
2008 assert!(s.contains("\x1b[33m"), "missing yellow color code");
2009 assert!(s.contains("\x1b[0m"), "missing reset color code");
2010 assert!(s.contains("7"), "missing successful count");
2012 assert!(s.contains("2"), "missing failed count");
2013 assert!(s.contains("1"), "missing skipped count");
2014 assert!(s.contains("70%"), "missing success percentage");
2016 assert!(s.contains("20%"), "missing failure percentage");
2017 }
2018
2019 #[test]
2020 fn test_format_batch_summary_zero_division() {
2021 let s = format_batch_summary(0, 0, 0, 0);
2023 assert!(s.contains("BATCH SUMMARY"));
2024 assert!(s.contains("0%"));
2026 }
2027
2028 #[test]
2029 fn test_format_batch_summary_large_numbers() {
2030 let s = format_batch_summary(100000, 99999, 1, 0);
2031 assert!(s.contains("99999"));
2033 assert!(s.contains("100000"));
2034 assert!(s.contains("99%") || s.contains("100%"));
2036 }
2037
2038 #[test]
2039 fn test_format_batch_summary_all_success() {
2040 let s = format_batch_summary(5, 5, 0, 0);
2041 assert!(s.contains("100%"), "expected 100% success rate");
2042 assert!(s.contains("0%"), "expected 0% failure rate");
2043 }
2044
2045 #[test]
2048 fn test_segment_gap_detection() {
2049 use crate::analyzer::Segment;
2050
2051 let seg1 = Segment {
2052 start: 0.0,
2053 end: 10.0,
2054 };
2055 let seg2 = Segment {
2056 start: 15.0,
2057 end: 25.0,
2058 };
2059
2060 let gap = seg2.start - seg1.end;
2062 assert_eq!(gap, 5.0, "There should be a 5s gap between segments");
2063 }
2064
2065 #[test]
2066 fn test_segment_total_duration() {
2067 use crate::analyzer::ProcessedSegment;
2068
2069 let segments = vec![
2070 ProcessedSegment {
2071 start: 0.0,
2072 end: 10.0,
2073 speed: 1.0,
2074 },
2075 ProcessedSegment {
2076 start: 10.0,
2077 end: 25.0,
2078 speed: 1.0,
2079 },
2080 ProcessedSegment {
2081 start: 25.0,
2082 end: 40.0,
2083 speed: 1.0,
2084 },
2085 ];
2086
2087 let total_duration: f32 = segments.iter().map(|s| s.end - s.start).sum();
2088
2089 assert!((total_duration - 40.0).abs() < 1e-6);
2090 }
2091
2092 #[test]
2094 fn test_format_batch_summary_empty_stats() {
2095 let s = format_batch_summary(0, 0, 0, 0);
2096 assert!(s.contains("0%"));
2098 }
2099
2100 #[test]
2101 fn test_format_batch_summary_all_failed() {
2102 let s = format_batch_summary(5, 0, 0, 5);
2103 assert!(s.contains("0%") || s.contains("100%"));
2105 }
2106
2107 #[test]
2108 fn test_merge_silences_scenes_empty_both() {
2109 let silences: Vec<crate::analyzer::Segment> = vec![];
2111 let scenes: Vec<f32> = vec![];
2112 let result = merge_silences_and_scenes(&silences, &scenes, 60.0);
2113 assert!(result.is_empty());
2115 }
2116
2117 #[test]
2118 fn test_merge_silences_scenes_single_scene() {
2119 let silences: Vec<crate::analyzer::Segment> = vec![];
2120 let scenes = vec![30.0]; let result = merge_silences_and_scenes(&silences, &scenes, 60.0);
2122 assert!(result.is_empty() || result.len() == 2);
2125 }
2126
2127 #[test]
2129 fn test_merge_silences_multiple_markers() {
2130 let silences = vec![
2131 crate::analyzer::Segment {
2132 start: 10.0,
2133 end: 20.0,
2134 },
2135 crate::analyzer::Segment {
2136 start: 30.0,
2137 end: 40.0,
2138 },
2139 ];
2140 let scenes: Vec<f32> = vec![15.0, 35.0];
2141 let result = merge_silences_and_scenes(&silences, &scenes, 60.0);
2142 assert!(result.len() >= 0);
2144 }
2145
2146 #[test]
2147 fn test_merge_silences_at_start_and_end() {
2148 let silences = vec![
2149 crate::analyzer::Segment {
2150 start: 0.0,
2151 end: 5.0,
2152 }, crate::analyzer::Segment {
2154 start: 55.0,
2155 end: 60.0,
2156 }, ];
2158 let scenes: Vec<f32> = vec![];
2159 let result = merge_silences_and_scenes(&silences, &scenes, 60.0);
2160 assert_eq!(result.len(), silences.len());
2162 }
2163
2164 #[test]
2165 fn test_merge_silences_does_not_overlap() {
2166 let silences = vec![
2167 crate::analyzer::Segment {
2168 start: 10.0,
2169 end: 20.0,
2170 },
2171 crate::analyzer::Segment {
2172 start: 30.0,
2173 end: 40.0,
2174 },
2175 ];
2176 let scenes: Vec<f32> = vec![];
2177 let result = merge_silences_and_scenes(&silences, &scenes, 60.0);
2178 for seg in &result {
2180 assert!(seg.end - seg.start > 0.0);
2181 }
2182 }
2183
2184 #[test]
2185 fn test_batch_summary_format_types() {
2186 let s1 = format_batch_summary(0, 0, 0, 0);
2188 let s2 = format_batch_summary(10, 5, 3, 2);
2189 let s3 = format_batch_summary(100, 50, 30, 20);
2190
2191 assert!(!s1.is_empty());
2193 assert!(!s2.is_empty());
2194 assert!(!s3.is_empty());
2195 }
2196
2197 #[test]
2199 fn test_merge_silences_gap_calculation() {
2200 let silences = vec![
2201 crate::analyzer::Segment {
2202 start: 0.0,
2203 end: 5.0,
2204 },
2205 crate::analyzer::Segment {
2206 start: 10.0,
2207 end: 15.0,
2208 },
2209 ];
2210 let gap = silences[1].start - silences[0].end;
2212 assert!((gap - 5.0).abs() < 1e-6);
2213 }
2214
2215 #[test]
2216 fn test_merge_silences_total_removed() {
2217 let silences = vec![
2218 crate::analyzer::Segment {
2219 start: 5.0,
2220 end: 10.0,
2221 },
2222 crate::analyzer::Segment {
2223 start: 20.0,
2224 end: 25.0,
2225 },
2226 ];
2227 let total_removed: f32 = silences.iter().map(|s| s.end - s.start).sum();
2228 assert!((total_removed - 10.0).abs() < 1e-6);
2230 }
2231
2232 #[test]
2233 fn test_merge_silences_sequential() {
2234 let silences = vec![
2235 crate::analyzer::Segment {
2236 start: 0.0,
2237 end: 5.0,
2238 },
2239 crate::analyzer::Segment {
2240 start: 5.0,
2241 end: 10.0,
2242 },
2243 crate::analyzer::Segment {
2244 start: 10.0,
2245 end: 15.0,
2246 },
2247 ];
2248 let total: f32 = silences.iter().map(|s| s.end - s.start).sum();
2250 assert!((total - 15.0).abs() < 1e-6);
2251 }
2252
2253 #[test]
2254 fn test_batch_progress_insertion() {
2255 let mut progress = BatchProgress::default();
2256 progress.total = 5;
2257 progress.completed.insert(PathBuf::from("/a.mp4"), 100);
2258 progress.completed.insert(PathBuf::from("/b.mp4"), 200);
2259 assert_eq!(progress.completed.len(), 2);
2260 }
2261
2262 #[test]
2263 fn test_batch_progress_retains_insertion_order() {
2264 let mut progress = BatchProgress::default();
2265 progress.total = 3;
2266 progress.completed.insert(PathBuf::from("/first.mp4"), 100);
2267 progress.completed.insert(PathBuf::from("/second.mp4"), 200);
2268 progress.completed.insert(PathBuf::from("/third.mp4"), 300);
2269 assert_eq!(progress.completed.len(), 3);
2271 }
2272
2273 #[test]
2275 fn test_batch_progress_completion_rate() {
2276 let mut progress = BatchProgress::default();
2277 progress.total = 10;
2278 for i in 0..5 {
2279 progress
2280 .completed
2281 .insert(PathBuf::from(format!("/{}.mp4", i)), 100);
2282 }
2283 assert_eq!(progress.completed.len(), 5);
2285 assert_eq!(progress.completed.len(), progress.total / 2);
2286 }
2287
2288 #[test]
2289 fn test_batch_progress_clear_state() {
2290 let mut progress = BatchProgress::default();
2291 progress.total = 5;
2292 progress.completed.insert(PathBuf::from("/a.mp4"), 100);
2293 progress.failed.insert(PathBuf::from("/b.mp4"));
2294 assert_eq!(progress.completed.len() + progress.failed.len(), 2);
2296 let new_progress = BatchProgress::default();
2298 assert!(new_progress.completed.is_empty());
2299 }
2300
2301 #[test]
2302 fn test_batch_progress_zero_total() {
2303 let mut progress = BatchProgress::default();
2304 progress.total = 0;
2305 assert_eq!(progress.total, 0);
2307 assert!(progress.completed.is_empty());
2308 }
2309
2310 #[test]
2311 fn test_batch_progress_completed_timing_values() {
2312 let mut progress = BatchProgress::default();
2313 progress.total = 2;
2314 progress.completed.insert(PathBuf::from("/a.mp4"), 100);
2315 progress.completed.insert(PathBuf::from("/b.mp4"), 200);
2316 let total_time: u128 = progress.completed.values().map(|v| *v as u128).sum();
2318 assert_eq!(total_time, 300);
2319 }
2320}