Skip to main content

ai_gui_auto_video_editor/
batch_processor.rs

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
30/// RAII guard for cleaning up temporary video files on drop.
31/// Tracks intermediate files and removes them when the guard goes out of scope.
32struct 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
72// Trait for getting video duration
73pub trait DurationGetter: Send + Sync {
74    fn get_duration(&self, path: &Path) -> Result<f32>;
75}
76
77// Concrete implementation using ffprobe
78pub 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
100/// Duration getter that caches results keyed by file metadata.
101/// Uses modification time + file size to detect changes.
102pub 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
150/// Concatenate intro/outro videos using ffmpeg concat demuxer
151/// Uses a list file instead of filter_complex to avoid filter injection risks
152fn 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    // Collect video paths in order
167    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    // Build concat demuxer list file
177    // Escape single quotes in paths: replace ' with '\''
178    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    // Guard ensures temp files are cleaned up even on early return
277    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    // Merge scene changes with silences if scene detection is enabled
298    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    // Fetch transcript if needed for filler-word removal or audio ducking
320    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(&current_file, &stabilized)?;
483        if current_file != output_file {
484            guard.untrack(&current_file);
485            let _ = fs::remove_file(&current_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(&current_file, &corrected)?;
496        if current_file != output_file {
497            guard.untrack(&current_file);
498            let _ = fs::remove_file(&current_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(&current_file, &reframed, config.video.target_resolution)?;
509        if current_file != output_file {
510            guard.untrack(&current_file);
511            let _ = fs::remove_file(&current_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(&current_file, &blurred)?;
522        if current_file != output_file {
523            guard.untrack(&current_file);
524            let _ = fs::remove_file(&current_file);
525        }
526        guard.track(blurred.clone());
527        current_file = blurred;
528    }
529
530    // Apply target resolution scaling if configured and not already reframed
531    // Scaling must happen BEFORE watermarking to avoid stretching the watermark
532    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(&current_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(&current_file);
552            let _ = fs::remove_file(&current_file);
553        }
554        guard.track(scaled.clone());
555        current_file = scaled;
556    }
557
558    // Apply watermark if configured (must be LAST video processing step)
559    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            &current_file,
571            watermark_path,
572            &watermarked,
573            position,
574            scale,
575        )?;
576
577        if current_file != output_file {
578            guard.untrack(&current_file);
579            let _ = fs::remove_file(&current_file);
580        }
581        guard.track(watermarked.clone());
582        current_file = watermarked;
583    }
584
585    // Move final temp file to output if needed
586    if current_file != output_file {
587        fs::rename(&current_file, &output_file)?;
588    }
589    guard.untrack(&output_file); // Don't delete the final output
590
591    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
605/// Transcribe the input file if transcription is needed for processing.
606/// Returns `Some(transcript)` on success, `None` on failure or if not needed.
607fn 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
620/// Merge silence segments with scene-change boundaries.
621/// Scene changes are treated as additional cut points - they split existing
622/// silence segments or create new boundaries for trimming.
623fn 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
723/// Export additional files (SRT, chapters, FCPXML, EDL, clips) based on config
724fn 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    // Use cached transcript if available, otherwise transcribe the output file
734    // NOTE: Cached transcript timestamps come from the ORIGINAL input file.
735    // When video is trimmed (silences removed), output timestamps will differ
736    // from input timestamps by small amounts (typically <1s per cut, equal to
737    // silence padding). For automator use cases this drift is acceptable.
738    // If frame-accurate exports are needed, disable filler_words so exports
739    // always transcribe the output file directly.
740    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    // Generate thumbnail
846    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    // Multi-format output
860    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    // Generate quick preview
891    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
907/// Generate styled ASS subtitle file from transcript
908fn 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        // Escape text for ASS format
933        // In ASS, \N is a forced newline. Literal backslashes must be escaped as \\.
934        // Order matters: escape backslashes first, then newlines.
935        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
958/// Burn subtitle file into video using FFmpeg
959fn 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
985/// Extract highlight clips based on audio energy peaks in transcript
986fn 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    // Analyze audio energy per transcript segment using ffprobe
996    let mut segment_energy: Vec<(f32, f32, f32)> = Vec::new(); // (start, end, energy)
997
998    for seg in transcript {
999        let text = seg.text.trim();
1000        if text.is_empty() || text == "[No speech detected]" {
1001            continue;
1002        }
1003
1004        // Estimate energy from segment duration and word count (proxy for speech energy)
1005        // Longer segments with more words = more content = higher energy
1006        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); // words per second
1009
1010        segment_energy.push((seg.start, seg.end, energy));
1011    }
1012
1013    if segment_energy.is_empty() {
1014        return Ok(vec![]);
1015    }
1016
1017    // Find peaks: sort by energy and take top N segments
1018    segment_energy.sort_by(|a, b| b.2.total_cmp(&a.2));
1019
1020    // Get video duration to clamp clips properly
1021    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        // Expand segment to reasonable clip duration
1032        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    // Extract clips using FFmpeg
1042    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        // Fast keyframe-seeking with stream copy. Cuts may shift to the nearest keyframe
1048        // (acceptable for highlight clips). For frame-accurate cuts re-encode instead.
1049        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    // Load or initialize progress tracking
1105    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        // Apply per-file preset based on filename
1151        let file_preset = crate::preset_rules::preset_for_file(
1152            input_file,
1153            &preset_rules,
1154            crate::config::Preset::Youtube, // Default when no rule matches
1155        );
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            // Merge with base config to preserve paths, exports, etc.
1160            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/// Process a directory of videos in parallel using multiple worker threads.
1212/// Each worker gets its own analyzer/editor instances since they are stateless.
1213#[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    // Load progress and filter out completed files
1245    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    // Split files into chunks for each worker
1282    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                    // Create fresh instances per worker (they're stateless)
1306                    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            // Simulate successful trimming by creating an empty output file
1417            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    // Mock DurationGetter for testing purposes
1450    struct MockDurationGetter;
1451    impl DurationGetter for MockDurationGetter {
1452        fn get_duration(&self, _path: &Path) -> Result<f32> {
1453            Ok(60.0) // Return a dummy duration
1454        }
1455    }
1456
1457    #[test]
1458    fn test_batch_processing_integration() -> Result<()> {
1459        let input_dir = tempdir()?;
1460        let output_dir = tempdir()?;
1461
1462        // Create dummy video files
1463        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"))?; // Should be ignored
1466
1467        // Use mock implementations
1468        let mock_analyzer = MockFfmpegAnalyzer;
1469        let mock_editor = MockFfmpegEditor;
1470        let mock_duration_getter = MockDurationGetter;
1471
1472        // Use config with audio enhancement disabled (mock doesn't create files)
1473        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        // Check if output files were created
1489        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        // find_video_files returns Ok([]) for nonexistent dirs (WalkDir yields error, filtered to empty)
1541        // So this should succeed with no files processed
1542        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        // Should complete even with failures (logs errors but doesn't panic)
1681        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        // Note: with default config (enhance=true), output goes to .trimmed.mp4 intermediate
1720        // which then gets renamed to final output
1721        let output_files: Vec<_> = fs::read_dir(output_dir.path())?
1722            .filter_map(|e| e.ok())
1723            .map(|e| e.path())
1724            .collect();
1725        // With enhance=true, intermediate files are created but may be cleaned up
1726        // The final output should exist after rename
1727        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        // Output dir exists but is empty
1745        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        // With all features disabled, trim is still called which creates the output
1794        assert!(result.is_ok());
1795        // The output file should be created (trim_video creates it)
1796        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        // First run
1816        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        // Second run should skip already completed files
1828        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        // Both files should exist (one from each run)
1840        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    // Tests for merge_silences_and_scenes
1849
1850    #[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        // Two silences that overlap - without scenes they remain separate
1892        // (deduplication only happens when scenes cause overlaps)
1893        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        // Adjacent silences without scenes remain separate
1911        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        // Scene segment close to silence boundary extends it
1929        let silences = vec![Segment {
1930            start: 1.0,
1931            end: 3.0,
1932        }];
1933        // Scene at 0.7s creates a segment from 0.7 to 1.7 (assuming 1s default)
1934        // The scene start (0.7) is within 0.5s of silence start (1.0), so silence start extends to 0.7
1935        let scenes = vec![0.7];
1936        let merged = merge_silences_and_scenes(&silences, &scenes, 10.0);
1937        assert_eq!(merged.len(), 1);
1938        // Scene at 0.7 creates segment [0.7, 1.7], which extends silence start from 1.0 to 0.7
1939        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        // Silences and scenes with no overlap should not affect each other
1946        let silences = vec![Segment {
1947            start: 1.0,
1948            end: 2.0,
1949        }];
1950        // Scene at 5.0 is far from silence
1951        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        // Multiple silences and scenes with complex interactions
1966        // Scene boundaries close to silence edges cause extension
1967        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        // Scene at 0.8 is close to first silence start (diff=0.2 < 0.5)
1978        // Scene at 3.2 is close to first silence end (diff=0.2 < 0.5)
1979        // Scene at 5.8 is close to second silence start (diff=0.2 < 0.5)
1980        // Scene at 8.2 is close to second silence end (diff=0.2 < 0.5)
1981        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        // First silence extended by scene boundaries
1985        assert_eq!(
1986            merged[0],
1987            Segment {
1988                start: 0.8,
1989                end: 3.2
1990            }
1991        );
1992        // Second silence extended by scene boundaries
1993        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        // Should contain ANSI color codes
2006        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        // Should contain the values
2011        assert!(s.contains("7"), "missing successful count");
2012        assert!(s.contains("2"), "missing failed count");
2013        assert!(s.contains("1"), "missing skipped count");
2014        // Should contain percentages
2015        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        // Edge case: total = 0 should not panic
2022        let s = format_batch_summary(0, 0, 0, 0);
2023        assert!(s.contains("BATCH SUMMARY"));
2024        // Percentages should be 0 when total is 0
2025        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        // Should right-align large numbers
2032        assert!(s.contains("99999"));
2033        assert!(s.contains("100000"));
2034        // Percentage calculations should be correct
2035        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    // ── Pure logic tests (no FFmpeg needed) ─────────────────────────────────
2046
2047    #[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        // Gap between segments
2061        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    // ── Batch progress edge cases ──────────────────────────────────────────
2093    #[test]
2094    fn test_format_batch_summary_empty_stats() {
2095        let s = format_batch_summary(0, 0, 0, 0);
2096        // Should not panic and should show 0% rates
2097        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        // 0% success, 100% failure
2104        assert!(s.contains("0%") || s.contains("100%"));
2105    }
2106
2107    #[test]
2108    fn test_merge_silences_scenes_empty_both() {
2109        // Both silences and scenes empty
2110        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        // Should return empty (scenes is empty so return silences as-is)
2114        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]; // Single scene at 30s
2121        let result = merge_silences_and_scenes(&silences, &scenes, 60.0);
2122        // When silences is empty, the function returns empty vec (early return)
2123        // This is expected behavior - silences drive the output
2124        assert!(result.is_empty() || result.len() == 2);
2125    }
2126
2127    // ── BatchProcessor edge cases ──────────────────────────────────────────
2128    #[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        // Result depends on overlap handling
2143        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            }, // Start
2153            crate::analyzer::Segment {
2154                start: 55.0,
2155                end: 60.0,
2156            }, // End
2157        ];
2158        let scenes: Vec<f32> = vec![];
2159        let result = merge_silences_and_scenes(&silences, &scenes, 60.0);
2160        // With no scenes, result equals silences
2161        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        // Segments don't overlap
2179        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        // Verify format_batch_summary returns valid strings
2187        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        // All should be non-empty strings
2192        assert!(!s1.is_empty());
2193        assert!(!s2.is_empty());
2194        assert!(!s3.is_empty());
2195    }
2196
2197    // ── BatchProcessor edge cases ──────────────────────────────────────────
2198    #[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        // Gap between silences
2211        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        // Total removed is 10 seconds
2229        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        // Sequential silences form continuous region
2249        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        // HashMap doesn't guarantee order, but we can check count
2270        assert_eq!(progress.completed.len(), 3);
2271    }
2272
2273    // ── BatchProgress final edge cases ──────────────────────────────────
2274    #[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        // Half completed
2284        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        // State before clearing
2295        assert_eq!(progress.completed.len() + progress.failed.len(), 2);
2296        // Reset would require new instance
2297        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        // Zero total is valid
2306        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        // Check timing values are captured
2317        let total_time: u128 = progress.completed.values().map(|v| *v as u128).sum();
2318        assert_eq!(total_time, 300);
2319    }
2320}