Skip to main content

deepshrink_core/engine/
media.rs

1//! Media engine v0.1: video + audio via ffmpeg (external process).
2//!
3//! - `probe` shells out to ffprobe and maps the result into [`MediaInfo`].
4//! - `plan` is pure bitrate budgeting → an [`EncodePlan`] (tested without ffmpeg).
5//!   `plan` dispatches on media kind: two-pass video vs single-pass audio.
6//! - `run` executes the plan: encode, size verification and (for video) a single
7//!   correction retry on overshoot.
8
9use std::ffi::OsString;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use super::{
14    AudioSpec, EncodePlan, EncodeSpec, Engine, EngineError, MediaInfo, Outcome, ShrinkOpts,
15    SizeGoal, VideoSpec,
16};
17use crate::budget;
18use crate::detect::{detect_kind, MediaKind};
19use crate::options::{AudioChoice, AudioCodec, FpsOpt, ResolutionOpt};
20
21/// Audio bitrate ladder (bits/s, descending) tried when keeping a track under
22/// a tight size budget.
23const AUDIO_LADDER: &[u64] = &[128_000, 96_000, 64_000, 48_000];
24
25/// Which pass of the encode a progress update belongs to.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum PassKind {
28    Single,
29    First,
30    Second,
31}
32
33/// The ffmpeg engine for video and audio.
34#[derive(Debug, Default, Clone, Copy)]
35pub struct MediaEngine;
36
37impl MediaEngine {
38    pub fn new() -> Self {
39        Self
40    }
41
42    /// Like [`Engine::run`] but reports progress: `on_progress(pass, fraction)`
43    /// is called with `fraction` in 0.0..=1.0 as each pass proceeds.
44    pub fn run_with_progress(
45        &self,
46        plan: &EncodePlan,
47        on_progress: &mut dyn FnMut(PassKind, f64),
48    ) -> Result<Outcome, EngineError> {
49        let tools = deepshrink_ffmpeg::locate()?;
50
51        // VMAF-targeted quality search: applies to CRF-mode video only. Size /
52        // audio / passthrough encodes keep their existing single path.
53        if let Some(target_vmaf) = plan.target_vmaf {
54            if plan.spec.video.crf.is_some() && !plan.spec.audio_only && !plan.spec.passthrough {
55                return self.run_crf_search(&tools, plan, target_vmaf, on_progress);
56            }
57        }
58
59        let mut outcome = self.run_plain(&tools, plan, on_progress)?;
60
61        // Size-targeted video with `--vmaf`: encode to budget, then report the
62        // VMAF actually achieved (best effort — a failed measurement is silent).
63        if plan.target_vmaf.is_some() && !plan.spec.audio_only && !plan.spec.passthrough {
64            outcome.vmaf = self.measure_output(&tools, plan, &plan.output);
65        }
66        Ok(outcome)
67    }
68
69    /// The plain encode: two-pass (with one correction retry) or single-pass,
70    /// no VMAF handling. Returns an [`Outcome`] with `vmaf = None`.
71    fn run_plain(
72        &self,
73        tools: &deepshrink_ffmpeg::Tools,
74        plan: &EncodePlan,
75        on_progress: &mut dyn FnMut(PassKind, f64),
76    ) -> Result<Outcome, EngineError> {
77        let passlog = passlog_base(plan);
78        let total = plan.source_duration_sec;
79
80        if plan.spec.two_pass {
81            let args1 = build_pass_args(plan, PassKind::First, &passlog);
82            deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args1, total, &mut |f| {
83                on_progress(PassKind::First, f)
84            })?;
85            let args2 = build_pass_args(plan, PassKind::Second, &passlog);
86            deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args2, total, &mut |f| {
87                on_progress(PassKind::Second, f)
88            })?;
89        } else {
90            let args = build_pass_args(plan, PassKind::Single, &passlog);
91            deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, total, &mut |f| {
92                on_progress(PassKind::Single, f)
93            })?;
94        }
95
96        let mut size = fs::metadata(&plan.output)?.len();
97
98        // Single correction retry: if two-pass overshot the target (VBV slack),
99        // scale the video bitrate down proportionally and re-run pass 2.
100        if let (Some(target), Some(vbps)) = (plan.target_bytes, plan.spec.video.bitrate_bps) {
101            if size > target && plan.spec.two_pass {
102                let corrected = (vbps as f64 * (target as f64 / size as f64) * 0.97) as u64;
103                if corrected >= budget::ABSOLUTE_MIN_VIDEO_BPS {
104                    let mut retry = plan.clone();
105                    retry.spec.video.bitrate_bps = Some(corrected);
106                    let args = build_pass_args(&retry, PassKind::Second, &passlog);
107                    deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, total, &mut |f| {
108                        on_progress(PassKind::Second, f)
109                    })?;
110                    size = fs::metadata(&plan.output)?.len();
111                }
112            }
113        }
114
115        cleanup_passlog(&passlog);
116        Ok(Outcome {
117            output: plan.output.clone(),
118            final_bytes: size,
119            vmaf: None,
120        })
121    }
122
123    /// Search CRF for the smallest output that still meets `target_vmaf`.
124    ///
125    /// Each trial is a single-pass CRF encode into `plan.output` followed by a
126    /// VMAF measurement against the source. Drives [`budget::search_crf`], so
127    /// the search algorithm itself is unit-tested separately. Falls back to a
128    /// plain encode if the source resolution is unknown (nothing to measure).
129    fn run_crf_search(
130        &self,
131        tools: &deepshrink_ffmpeg::Tools,
132        plan: &EncodePlan,
133        target_vmaf: f64,
134        on_progress: &mut dyn FnMut(PassKind, f64),
135    ) -> Result<Outcome, EngineError> {
136        let (ref_w, ref_h) = match (plan.source_width, plan.source_height) {
137            (Some(w), Some(h)) => (w, h),
138            _ => return self.run_plain(tools, plan, on_progress),
139        };
140        let ref_fps = plan.source_fps.unwrap_or(0.0);
141        let total = plan.source_duration_sec;
142        let (lo, hi) = plan.spec.video.codec.crf_search_bounds();
143        let n_threads = thread_count();
144
145        let mut err: Option<EngineError> = None;
146        let mut last_crf: Option<u8> = None;
147
148        let (chosen_crf, chosen_vmaf) = budget::search_crf(target_vmaf, lo, hi, |crf| {
149            if err.is_some() {
150                return f64::NEG_INFINITY;
151            }
152            match encode_at_crf(tools, plan, crf, total, on_progress).and_then(|()| {
153                last_crf = Some(crf);
154                deepshrink_ffmpeg::measure_vmaf(
155                    &tools.ffmpeg,
156                    &plan.output,
157                    &plan.input,
158                    ref_w,
159                    ref_h,
160                    ref_fps,
161                    n_threads,
162                )
163                .map_err(EngineError::from)
164            }) {
165                Ok(v) => v,
166                Err(e) => {
167                    err = Some(e);
168                    f64::NEG_INFINITY
169                }
170            }
171        });
172        if let Some(e) = err {
173            return Err(e);
174        }
175
176        // Leave the chosen CRF on disk (the search may have ended elsewhere).
177        if last_crf != Some(chosen_crf) {
178            encode_at_crf(tools, plan, chosen_crf, total, on_progress)?;
179        }
180        let size = fs::metadata(&plan.output)?.len();
181        Ok(Outcome {
182            output: plan.output.clone(),
183            final_bytes: size,
184            vmaf: Some(chosen_vmaf),
185        })
186    }
187
188    /// Measure the VMAF of an encoded `output` against the plan's source.
189    /// Returns `None` on any failure or when the source dimensions are unknown.
190    fn measure_output(
191        &self,
192        tools: &deepshrink_ffmpeg::Tools,
193        plan: &EncodePlan,
194        output: &Path,
195    ) -> Option<f64> {
196        let (w, h) = (plan.source_width?, plan.source_height?);
197        let fps = plan.source_fps.unwrap_or(0.0);
198        deepshrink_ffmpeg::measure_vmaf(
199            &tools.ffmpeg,
200            output,
201            &plan.input,
202            w,
203            h,
204            fps,
205            thread_count(),
206        )
207        .ok()
208    }
209
210    /// Plan a pure-audio encode (single pass, codec + fitted bitrate).
211    fn plan_audio(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
212        let duration = info.duration_sec;
213        if !duration.is_finite() || duration <= 0.0 {
214            return Err(EngineError::Unsupported(format!(
215                "could not determine duration of {}",
216                info.path.display()
217            )));
218        }
219        let codec = opts.audio_codec;
220        let target = target_bytes(&opts.goal, info.size_bytes);
221
222        // "Never make it bigger": stream-copy remux when the source already fits.
223        if let Some(tb) = target {
224            if info.size_bytes > 0 && info.size_bytes <= tb {
225                let src_ext = info
226                    .path
227                    .extension()
228                    .and_then(|e| e.to_str())
229                    .unwrap_or("audio");
230                let output = opts
231                    .output
232                    .clone()
233                    .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
234                return Ok(passthrough_plan(info, output, tb, false));
235            }
236        }
237
238        // Mono for speech: explicit flag, or a single-channel source.
239        let mono = opts.mono || info.audio_channels == Some(1);
240
241        let (bitrate_bps, expected_bytes) = match target {
242            Some(tb) => {
243                let raw = budget::audio_bitrate_bps(tb, duration).ok_or(EngineError::Infeasible)?;
244                if raw < budget::ABSOLUTE_MIN_AUDIO_BPS {
245                    return Err(EngineError::Infeasible);
246                }
247                let bps = budget::snap_audio_bitrate(raw);
248                let predicted = (bps as f64 * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD))
249                    .round() as u64;
250                (bps, Some(predicted))
251            }
252            None => {
253                // Quality mode: a transparent-ish default, lower for speech.
254                let bps = if mono { 96_000 } else { 160_000 };
255                (bps, None)
256            }
257        };
258
259        let audio = AudioSpec {
260            codec,
261            bitrate_bps,
262            mono,
263            sample_rate: opts.sample_rate,
264            vbr: opts.vbr,
265        };
266        let output = opts
267            .output
268            .clone()
269            .unwrap_or_else(|| output_with_ext(&info.path, codec.extension()));
270        let summary = build_audio_summary(&audio, info.audio_channels);
271
272        Ok(EncodePlan {
273            input: info.path.clone(),
274            output,
275            summary,
276            expected_bytes,
277            target_bytes: target,
278            target_vmaf: None,
279            source_duration_sec: duration,
280            source_width: info.width,
281            source_height: info.height,
282            source_fps: info.fps,
283            spec: EncodeSpec {
284                video: placeholder_video_spec(),
285                audio: Some(audio),
286                faststart: false,
287                two_pass: false,
288                passthrough: false,
289                audio_only: true,
290            },
291        })
292    }
293}
294
295impl Engine for MediaEngine {
296    fn supports(&self, input: &Path) -> bool {
297        matches!(detect_kind(input), MediaKind::Video | MediaKind::Audio)
298    }
299
300    fn probe(&self, input: &Path) -> Result<MediaInfo, EngineError> {
301        let tools = deepshrink_ffmpeg::locate()?;
302        let p = deepshrink_ffmpeg::probe(&tools.ffprobe, input)?;
303
304        let video = p.video_stream();
305        let audio = p.audio_stream();
306        // Prefer ffprobe's reported size; fall back to the filesystem.
307        let size_bytes = p
308            .size_bytes()
309            .or_else(|| fs::metadata(input).ok().map(|m| m.len()))
310            .unwrap_or(0);
311
312        Ok(MediaInfo {
313            path: input.to_path_buf(),
314            kind: detect_kind(input),
315            duration_sec: p.duration_sec().unwrap_or(0.0),
316            size_bytes,
317            width: video.and_then(|v| v.width),
318            height: video.and_then(|v| v.height),
319            fps: p.fps(),
320            video_codec: video.and_then(|v| v.codec_name.clone()),
321            audio_codec: audio.and_then(|a| a.codec_name.clone()),
322            audio_channels: audio.and_then(|a| a.channels),
323        })
324    }
325
326    fn plan(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
327        match info.kind {
328            MediaKind::Audio => return self.plan_audio(info, opts),
329            MediaKind::Unsupported => {
330                return Err(EngineError::Unsupported(format!(
331                    "{} is not a supported media file",
332                    info.path.display()
333                )))
334            }
335            MediaKind::Video => {}
336        }
337        let duration = info.duration_sec;
338        if !duration.is_finite() || duration <= 0.0 {
339            return Err(EngineError::Unsupported(format!(
340                "could not determine duration of {}",
341                info.path.display()
342            )));
343        }
344        let src_height = info.height.unwrap_or(0);
345
346        let target = target_bytes(&opts.goal, info.size_bytes);
347        let output = opts
348            .output
349            .clone()
350            .unwrap_or_else(|| output_with_ext(&info.path, "mp4"));
351
352        // "Never make it bigger": if the source already fits the target, just
353        // remux (stream copy) instead of re-encoding it up to the target.
354        if let Some(tb) = target {
355            if info.size_bytes > 0 && info.size_bytes <= tb {
356                return Ok(passthrough_plan(info, output, tb, true));
357            }
358        }
359
360        let audio = decide_audio(opts, info.has_audio(), target, duration)?;
361        let audio_bps = audio.as_ref().map(|a| a.bitrate_bps).unwrap_or(0);
362
363        let (video, expected_bytes) = if let Some(tb) = target {
364            let vbps = budget::video_bitrate_bps(tb, duration, audio_bps)
365                .filter(|&b| b >= budget::ABSOLUTE_MIN_VIDEO_BPS)
366                .ok_or(EngineError::Infeasible)?;
367            let height = pick_height(opts.resolution, src_height, vbps);
368            let predicted = ((vbps + audio_bps) as f64 * duration / 8.0
369                * (1.0 + budget::CONTAINER_OVERHEAD))
370                .round() as u64;
371            (
372                VideoSpec {
373                    codec: opts.video_codec,
374                    bitrate_bps: Some(vbps),
375                    crf: None,
376                    height,
377                    fps: pick_fps(opts.fps, info.fps),
378                    preset: opts.quality,
379                },
380                Some(predicted),
381            )
382        } else {
383            // Quality mode: CRF, no hard size guarantee. The CRF default is
384            // codec-aware; a `--vmaf` target refines it via a search in `run`.
385            let crf = opts.quality.default_crf(opts.video_codec);
386            let height = match opts.resolution {
387                ResolutionOpt::Height(h) => clamp_height(h, src_height),
388                ResolutionOpt::Auto => None,
389            };
390            (
391                VideoSpec {
392                    codec: opts.video_codec,
393                    bitrate_bps: None,
394                    crf: Some(crf),
395                    height,
396                    fps: pick_fps(opts.fps, info.fps),
397                    preset: opts.quality,
398                },
399                None,
400            )
401        };
402
403        let two_pass = video.bitrate_bps.is_some();
404        let summary = build_summary(&video, audio.as_ref(), two_pass);
405
406        Ok(EncodePlan {
407            input: info.path.clone(),
408            output,
409            summary,
410            expected_bytes,
411            target_bytes: target,
412            target_vmaf: opts.target_vmaf,
413            source_duration_sec: duration,
414            source_width: info.width,
415            source_height: info.height,
416            source_fps: info.fps,
417            spec: EncodeSpec {
418                video,
419                audio,
420                faststart: true,
421                two_pass,
422                passthrough: false,
423                audio_only: false,
424            },
425        })
426    }
427
428    fn run(&self, plan: &EncodePlan) -> Result<Outcome, EngineError> {
429        self.run_with_progress(plan, &mut |_, _| {})
430    }
431}
432
433/// A placeholder video spec — ignored while `passthrough`/`audio_only` is set.
434fn placeholder_video_spec() -> VideoSpec {
435    VideoSpec {
436        codec: crate::options::VideoCodec::H264,
437        bitrate_bps: None,
438        crf: None,
439        height: None,
440        fps: None,
441        preset: crate::options::QualityPreset::Balanced,
442    }
443}
444
445/// A stream-copy remux plan for when the source already fits the target.
446/// `faststart` is only meaningful for MP4/MOV; pass `false` for pure audio.
447fn passthrough_plan(info: &MediaInfo, output: PathBuf, target: u64, faststart: bool) -> EncodePlan {
448    EncodePlan {
449        input: info.path.clone(),
450        output,
451        summary: "stream copy (already within target)".to_string(),
452        expected_bytes: Some(info.size_bytes),
453        target_bytes: Some(target),
454        target_vmaf: None,
455        source_duration_sec: info.duration_sec,
456        source_width: info.width,
457        source_height: info.height,
458        source_fps: info.fps,
459        spec: EncodeSpec {
460            video: placeholder_video_spec(),
461            audio: None,
462            faststart,
463            two_pass: false,
464            passthrough: true,
465            audio_only: false,
466        },
467    }
468}
469
470/// Human-readable summary for a pure-audio plan, e.g.
471/// "Opus · 22 kbps · mono (speech)".
472fn build_audio_summary(audio: &AudioSpec, src_channels: Option<u32>) -> String {
473    let mut parts = vec![
474        audio.codec.label().to_string(),
475        format!("{} kbps", audio.bitrate_bps / 1000),
476    ];
477    if audio.mono {
478        // A single-channel source (or --mono) reads as speech.
479        let note = if src_channels == Some(1) {
480            "mono"
481        } else {
482            "mono (downmix)"
483        };
484        parts.push(note.to_string());
485    }
486    if let Some(sr) = audio.sample_rate {
487        parts.push(format!("{} Hz", sr));
488    }
489    parts.join(" · ")
490}
491
492/// Resolve the absolute target size (bytes) for a goal, if it imposes one.
493fn target_bytes(goal: &SizeGoal, original: u64) -> Option<u64> {
494    match goal {
495        SizeGoal::Target(b) => Some(*b),
496        SizeGoal::Reduce(f) => Some(budget::reduce_target_bytes(original, *f)),
497        SizeGoal::Preset(p) => p.limit_bytes,
498        SizeGoal::Quality => None,
499    }
500}
501
502/// Decide the audio track for a video encode.
503fn decide_audio(
504    opts: &ShrinkOpts,
505    has_audio: bool,
506    target: Option<u64>,
507    duration: f64,
508) -> Result<Option<AudioSpec>, EngineError> {
509    if !has_audio {
510        return Ok(None);
511    }
512    match opts.audio {
513        AudioChoice::Drop => Ok(None),
514        AudioChoice::Bitrate(b) => Ok(Some(AudioSpec::cbr(AudioCodec::Aac, b))),
515        AudioChoice::Keep => {
516            let bps = match target {
517                Some(tb) => budget::fit_audio_bps(tb, duration, AUDIO_LADDER)
518                    .ok_or(EngineError::Infeasible)?,
519                None => budget::DEFAULT_AUDIO_BPS,
520            };
521            Ok(Some(AudioSpec::cbr(AudioCodec::Aac, bps)))
522        }
523    }
524}
525
526/// Choose the encode height in auto/explicit mode.
527fn pick_height(res: ResolutionOpt, src_height: u32, vbps: u64) -> Option<u32> {
528    match res {
529        ResolutionOpt::Height(h) => clamp_height(h, src_height),
530        ResolutionOpt::Auto => {
531            let chosen = budget::choose_height(src_height, vbps);
532            if src_height > 0 && chosen < src_height {
533                Some(chosen)
534            } else {
535                None
536            }
537        }
538    }
539}
540
541/// Clamp an explicit height to the source (never upscale); `None` if it equals
542/// the source (no scaling needed).
543fn clamp_height(requested: u32, src_height: u32) -> Option<u32> {
544    if src_height == 0 {
545        return Some(requested);
546    }
547    let h = requested.min(src_height);
548    if h == src_height {
549        None
550    } else {
551        Some(h)
552    }
553}
554
555/// Choose an fps cap; `None` if uncapped or the cap is ≥ the source rate.
556fn pick_fps(fps: FpsOpt, src_fps: Option<f64>) -> Option<u32> {
557    match fps {
558        FpsOpt::Auto => None,
559        FpsOpt::Cap(f) => match src_fps {
560            Some(src) if (f as f64) >= src => None,
561            _ => Some(f),
562        },
563    }
564}
565
566/// Default output path: `<stem>.shrink.<ext>` next to the input.
567fn output_with_ext(input: &Path, ext: &str) -> PathBuf {
568    let stem = input
569        .file_stem()
570        .map(|s| s.to_string_lossy().into_owned())
571        .unwrap_or_else(|| "output".to_string());
572    let mut out = input.parent().map(Path::to_path_buf).unwrap_or_default();
573    out.push(format!("{stem}.shrink.{ext}"));
574    out
575}
576
577fn build_summary(video: &VideoSpec, audio: Option<&AudioSpec>, two_pass: bool) -> String {
578    let mut parts = vec![video.codec.label().to_string()];
579    match (video.bitrate_bps, video.crf) {
580        (Some(bps), _) => parts.push(format!("{} kbps video", bps / 1000)),
581        (_, Some(crf)) => parts.push(format!("CRF {crf}")),
582        _ => {}
583    }
584    if let Some(a) = audio {
585        parts.push(format!("{} kbps audio", a.bitrate_bps / 1000));
586    } else {
587        parts.push("no audio".to_string());
588    }
589    if let Some(h) = video.height {
590        parts.push(format!("{h}p"));
591    }
592    if let Some(f) = video.fps {
593        parts.push(format!("{f} fps"));
594    }
595    parts.push(if two_pass { "two-pass" } else { "CRF" }.to_string());
596    parts.join(" · ")
597}
598
599/// Base path for ffmpeg's two-pass log, unique per process + input stem.
600fn passlog_base(plan: &EncodePlan) -> String {
601    let stem = plan
602        .input
603        .file_stem()
604        .map(|s| s.to_string_lossy().into_owned())
605        .unwrap_or_else(|| "ds".to_string());
606    let dir = std::env::temp_dir();
607    dir.join(format!("deepshrink-{}-{}", std::process::id(), stem))
608        .to_string_lossy()
609        .into_owned()
610}
611
612/// Remove the files ffmpeg leaves behind for `-passlogfile <base>`.
613fn cleanup_passlog(base: &str) {
614    for suffix in ["-0.log", "-0.log.mbtree"] {
615        let _ = fs::remove_file(format!("{base}{suffix}"));
616    }
617}
618
619/// Encode a single-pass CRF trial into `plan.output` at the given CRF.
620fn encode_at_crf(
621    tools: &deepshrink_ffmpeg::Tools,
622    plan: &EncodePlan,
623    crf: u8,
624    total: f64,
625    on_progress: &mut dyn FnMut(PassKind, f64),
626) -> Result<(), EngineError> {
627    let mut trial = plan.clone();
628    trial.spec.video.crf = Some(crf);
629    trial.spec.video.bitrate_bps = None;
630    trial.spec.two_pass = false;
631    let args = build_pass_args(&trial, PassKind::Single, "");
632    deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, total, &mut |f| {
633        on_progress(PassKind::Single, f)
634    })?;
635    Ok(())
636}
637
638/// Threads to hand libvmaf (bounded by available parallelism).
639fn thread_count() -> usize {
640    std::thread::available_parallelism()
641        .map(|n| n.get())
642        .unwrap_or(1)
643}
644
645/// Platform null sink for the discard output of pass 1.
646fn null_sink() -> &'static str {
647    if cfg!(windows) {
648        "NUL"
649    } else {
650        "/dev/null"
651    }
652}
653
654/// Build the ffmpeg argv for one pass. Video-processing options (codec, filters,
655/// bitrate) are shared across passes; audio/output differ per pass.
656fn build_pass_args(plan: &EncodePlan, pass: PassKind, passlog: &str) -> Vec<OsString> {
657    let s = &plan.spec;
658    let mut a: Vec<OsString> = Vec::new();
659    // Local helper — a macro (not a closure) so it doesn't hold a borrow of `a`
660    // across the direct `a.push(..)` calls used for OsString paths.
661    macro_rules! push {
662        ($arg:expr) => {
663            a.push(OsString::from($arg))
664        };
665    }
666
667    push!("-hide_banner");
668    push!("-y");
669    push!("-loglevel");
670    push!("error");
671    push!("-progress");
672    push!("pipe:1");
673    push!("-nostats");
674    push!("-i");
675    a.push(plan.input.clone().into_os_string());
676
677    // Passthrough: stream copy, no re-encode. Output only (single pass).
678    if s.passthrough {
679        push!("-c");
680        push!("copy");
681        if s.faststart {
682            push!("-movflags");
683            push!("+faststart");
684        }
685        a.push(plan.output.clone().into_os_string());
686        return a;
687    }
688
689    // Pure audio: drop video, encode the audio track only (single pass).
690    if s.audio_only {
691        push!("-vn");
692        if let Some(au) = &s.audio {
693            push!("-c:a");
694            push!(au.codec.encoder());
695            if au.mono {
696                push!("-ac");
697                push!("1");
698            }
699            if let Some(sr) = au.sample_rate {
700                push!("-ar");
701                push!(sr.to_string());
702            }
703            push!("-b:a");
704            push!(au.bitrate_bps.to_string());
705            // Opus supports VBR; use constrained VBR by default for a tighter
706            // fit to the target, or full VBR when requested.
707            if matches!(au.codec, AudioCodec::Opus) {
708                push!("-vbr");
709                push!(if au.vbr { "on" } else { "constrained" });
710            }
711        }
712        a.push(plan.output.clone().into_os_string());
713        return a;
714    }
715
716    // Video codec + filters.
717    push!("-c:v");
718    push!(s.video.codec.encoder());
719    if let Some(h) = s.video.height {
720        push!("-vf");
721        push!(format!("scale=-2:{h}"));
722    }
723    if let Some(f) = s.video.fps {
724        push!("-r");
725        push!(f.to_string());
726    }
727    push!("-preset");
728    push!(s.video.preset.encoder_preset());
729    if let Some(tag) = s.video.codec.mp4_tag() {
730        push!("-tag:v");
731        push!(tag);
732    }
733
734    // Rate control.
735    match (s.video.bitrate_bps, s.video.crf) {
736        (Some(bps), _) => {
737            push!("-b:v");
738            push!(bps.to_string());
739            if s.two_pass {
740                push!("-pass");
741                push!(match pass {
742                    PassKind::First => "1",
743                    _ => "2",
744                });
745                push!("-passlogfile");
746                push!(passlog);
747            }
748        }
749        (_, Some(crf)) => {
750            push!("-crf");
751            push!(crf.to_string());
752        }
753        _ => {}
754    }
755
756    // Audio + output.
757    match pass {
758        PassKind::First => {
759            // Analysis pass: no audio, discard the muxed output.
760            push!("-an");
761            push!("-f");
762            push!("null");
763            push!(null_sink());
764        }
765        PassKind::Second | PassKind::Single => {
766            match &s.audio {
767                Some(au) => {
768                    push!("-c:a");
769                    push!(au.codec.encoder());
770                    push!("-b:a");
771                    push!(au.bitrate_bps.to_string());
772                }
773                None => push!("-an"),
774            }
775            if s.faststart {
776                push!("-movflags");
777                push!("+faststart");
778            }
779            a.push(plan.output.clone().into_os_string());
780        }
781    }
782    a
783}
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788    use crate::options::{AudioCodec, QualityPreset, VideoCodec};
789    use crate::size::preset;
790
791    fn video_info(duration: f64, size: u64, w: u32, h: u32, audio: bool) -> MediaInfo {
792        MediaInfo {
793            path: PathBuf::from("/tmp/clip.mp4"),
794            kind: MediaKind::Video,
795            duration_sec: duration,
796            size_bytes: size,
797            width: Some(w),
798            height: Some(h),
799            fps: Some(30.0),
800            video_codec: Some("h264".into()),
801            audio_codec: if audio { Some("aac".into()) } else { None },
802            audio_channels: if audio { Some(2) } else { None },
803        }
804    }
805
806    fn opts_target(bytes: u64) -> ShrinkOpts {
807        ShrinkOpts {
808            goal: SizeGoal::Target(bytes),
809            ..Default::default()
810        }
811    }
812
813    #[test]
814    fn supports_video_and_audio() {
815        let e = MediaEngine::new();
816        assert!(e.supports(&PathBuf::from("clip.mp4")));
817        assert!(e.supports(&PathBuf::from("lecture.wav")));
818        assert!(!e.supports(&PathBuf::from("photo.jpg")));
819    }
820
821    #[test]
822    fn plan_target_builds_two_pass_with_budget() {
823        let info = video_info(120.0, 300_000_000, 1920, 1080, true);
824        let plan = MediaEngine::new()
825            .plan(&info, &opts_target(8_000_000))
826            .unwrap();
827
828        assert!(plan.spec.two_pass);
829        assert_eq!(plan.target_bytes, Some(8_000_000));
830        assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
831        let vbps = plan.spec.video.bitrate_bps.unwrap();
832        assert!(vbps >= budget::ABSOLUTE_MIN_VIDEO_BPS);
833        // 8 MB over 120 s is a low budget → downscale from 1080p.
834        assert!(plan.spec.video.height.is_some());
835        assert!(plan.spec.audio.is_some());
836        // Predicted size should not exceed the target.
837        assert!(plan.expected_bytes.unwrap() <= 8_000_000 + 8_000_000 / 20);
838    }
839
840    #[test]
841    fn plan_preset_discord_sets_target() {
842        let info = video_info(30.0, 50_000_000, 1280, 720, true);
843        let opts = ShrinkOpts {
844            goal: SizeGoal::Preset(preset("discord").unwrap()),
845            ..Default::default()
846        };
847        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
848        assert_eq!(plan.target_bytes, Some(8_000_000));
849    }
850
851    #[test]
852    fn plan_reduce_targets_complement_of_original() {
853        let info = video_info(60.0, 100_000_000, 1920, 1080, true);
854        let opts = ShrinkOpts {
855            goal: SizeGoal::Reduce(0.70),
856            ..Default::default()
857        };
858        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
859        assert_eq!(plan.target_bytes, Some(30_000_000));
860    }
861
862    #[test]
863    fn plan_passthrough_when_source_already_fits() {
864        // Source is 200 KB, target 1 MB → never inflate; stream-copy remux.
865        let info = video_info(10.0, 200_000, 1280, 720, true);
866        let plan = MediaEngine::new()
867            .plan(&info, &opts_target(1_000_000))
868            .unwrap();
869        assert!(plan.spec.passthrough);
870        assert!(!plan.spec.two_pass);
871        assert_eq!(plan.expected_bytes, Some(200_000));
872        let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog");
873        let joined: Vec<String> = args
874            .iter()
875            .map(|a| a.to_string_lossy().into_owned())
876            .collect();
877        assert!(joined.contains(&"copy".to_string()));
878    }
879
880    #[test]
881    fn plan_infeasible_when_target_too_small() {
882        let info = video_info(600.0, 500_000_000, 1920, 1080, true);
883        let err = MediaEngine::new().plan(&info, &opts_target(50_000));
884        assert!(matches!(err, Err(EngineError::Infeasible)));
885    }
886
887    #[test]
888    fn plan_quality_mode_uses_crf_single_pass() {
889        let info = video_info(60.0, 100_000_000, 1920, 1080, true);
890        let opts = ShrinkOpts {
891            goal: SizeGoal::Quality,
892            quality: QualityPreset::Balanced,
893            ..Default::default()
894        };
895        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
896        assert!(!plan.spec.two_pass);
897        assert_eq!(plan.spec.video.crf, Some(23));
898        assert!(plan.spec.video.bitrate_bps.is_none());
899        assert!(plan.expected_bytes.is_none());
900    }
901
902    #[test]
903    fn plan_drops_audio_when_requested() {
904        let info = video_info(30.0, 50_000_000, 1280, 720, true);
905        let opts = ShrinkOpts {
906            audio: AudioChoice::Drop,
907            ..opts_target(8_000_000)
908        };
909        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
910        assert!(plan.spec.audio.is_none());
911    }
912
913    fn audio_info(duration: f64, size: u64, channels: u32) -> MediaInfo {
914        MediaInfo {
915            path: PathBuf::from("/tmp/lecture.wav"),
916            kind: MediaKind::Audio,
917            duration_sec: duration,
918            size_bytes: size,
919            width: None,
920            height: None,
921            fps: None,
922            video_codec: None,
923            audio_codec: Some("pcm_s16le".into()),
924            audio_channels: Some(channels),
925        }
926    }
927
928    #[test]
929    fn plan_audio_single_pass_with_fitted_bitrate() {
930        // 58 min stereo lecture, target 10 MB.
931        let info = audio_info(3480.0, 600_000_000, 2);
932        let plan = MediaEngine::new()
933            .plan(&info, &opts_target(10_000_000))
934            .unwrap();
935        assert!(plan.spec.audio_only);
936        assert!(!plan.spec.two_pass);
937        assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.m4a"));
938        let au = plan.spec.audio.as_ref().unwrap();
939        // Snapped down to a standard step, never above the raw budget.
940        assert!(budget::AUDIO_STEPS.contains(&au.bitrate_bps));
941        assert!(plan.expected_bytes.unwrap() <= 10_000_000 + 10_000_000 / 20);
942    }
943
944    #[test]
945    fn plan_audio_mono_source_marked_speech() {
946        let info = audio_info(600.0, 100_000_000, 1);
947        let plan = MediaEngine::new()
948            .plan(&info, &opts_target(5_000_000))
949            .unwrap();
950        assert!(plan.spec.audio.as_ref().unwrap().mono);
951    }
952
953    #[test]
954    fn plan_audio_opus_extension_and_vbr_args() {
955        let info = audio_info(600.0, 100_000_000, 2);
956        let opts = ShrinkOpts {
957            audio_codec: AudioCodec::Opus,
958            mono: true,
959            ..opts_target(3_000_000)
960        };
961        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
962        assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.opus"));
963        let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog");
964        let j: Vec<String> = args
965            .iter()
966            .map(|a| a.to_string_lossy().into_owned())
967            .collect();
968        assert!(j.contains(&"-vn".to_string()));
969        assert!(j.contains(&"libopus".to_string()));
970        assert!(j.contains(&"-ac".to_string())); // mono downmix
971        assert!(j.contains(&"-vbr".to_string()));
972    }
973
974    #[test]
975    fn plan_audio_infeasible_when_target_tiny() {
976        let info = audio_info(3600.0, 500_000_000, 2);
977        assert!(matches!(
978            MediaEngine::new().plan(&info, &opts_target(1_000)),
979            Err(EngineError::Infeasible)
980        ));
981    }
982
983    #[test]
984    fn plan_audio_passthrough_when_source_fits() {
985        let info = audio_info(600.0, 2_000_000, 2);
986        let plan = MediaEngine::new()
987            .plan(&info, &opts_target(10_000_000))
988            .unwrap();
989        assert!(plan.spec.passthrough);
990        // Passthrough keeps the source container/extension.
991        assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.wav"));
992    }
993
994    #[test]
995    fn pass1_args_have_no_audio_and_null_sink() {
996        let info = video_info(120.0, 300_000_000, 1920, 1080, true);
997        let plan = MediaEngine::new()
998            .plan(&info, &opts_target(8_000_000))
999            .unwrap();
1000        let args = build_pass_args(&plan, PassKind::First, "/tmp/passlog");
1001        let joined: Vec<String> = args
1002            .iter()
1003            .map(|a| a.to_string_lossy().into_owned())
1004            .collect();
1005        assert!(joined.contains(&"-an".to_string()));
1006        assert!(joined.contains(&"null".to_string()));
1007        assert!(joined.iter().any(|a| a == "1")); // -pass 1
1008        assert!(!joined.iter().any(|a| a.contains("shrink.mp4")));
1009    }
1010
1011    #[test]
1012    fn pass2_args_write_output_with_audio() {
1013        let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1014        let plan = MediaEngine::new()
1015            .plan(&info, &opts_target(8_000_000))
1016            .unwrap();
1017        let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog");
1018        let joined: Vec<String> = args
1019            .iter()
1020            .map(|a| a.to_string_lossy().into_owned())
1021            .collect();
1022        assert!(joined.iter().any(|a| a.contains("clip.shrink.mp4")));
1023        assert!(joined.contains(&"-c:a".to_string()));
1024        assert!(joined.contains(&"+faststart".to_string()));
1025        assert!(joined.iter().any(|a| a == "2")); // -pass 2
1026    }
1027
1028    #[test]
1029    fn h265_adds_hvc1_tag() {
1030        let info = video_info(60.0, 100_000_000, 1280, 720, false);
1031        let opts = ShrinkOpts {
1032            video_codec: VideoCodec::H265,
1033            ..opts_target(8_000_000)
1034        };
1035        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1036        let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog");
1037        let joined: Vec<String> = args
1038            .iter()
1039            .map(|a| a.to_string_lossy().into_owned())
1040            .collect();
1041        assert!(joined.contains(&"hvc1".to_string()));
1042        assert!(joined.contains(&"libx265".to_string()));
1043    }
1044}