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, QualityPreset, 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        let encoder = resolve_encoder(&tools, plan)?;
51
52        // VMAF-targeted quality search: applies to CRF-mode video only. Size /
53        // audio / passthrough encodes keep their existing single path.
54        if let Some(target_vmaf) = plan.target_vmaf {
55            if plan.spec.video.crf.is_some() && !plan.spec.audio_only && !plan.spec.passthrough {
56                return self.run_crf_search(&tools, plan, encoder, target_vmaf, on_progress);
57            }
58        }
59
60        // "Never make it bigger" in quality mode (sizes are guaranteed by the
61        // target path already): predict a CRF video from samples and skip the
62        // encode when it clearly won't shrink; after any guarded encode, keep
63        // the original if the result isn't smaller after all.
64        let source = if plan.guard_larger && !plan.spec.passthrough {
65            fs::metadata(&plan.input).map(|m| m.len()).unwrap_or(0)
66        } else {
67            0
68        };
69        if source > 0
70            && plan.target_vmaf.is_none()
71            && !plan.spec.audio_only
72            && plan.spec.video.crf.is_some()
73        {
74            if let Some(predicted) = predict_crf_bytes(&tools, plan, encoder) {
75                if predicted >= source {
76                    return self.keep_original(&tools, plan, on_progress);
77                }
78            }
79        }
80
81        let mut outcome = self.run_plain(&tools, plan, encoder, on_progress)?;
82        if source > 0 && outcome.final_bytes >= source {
83            let _ = fs::remove_file(&outcome.output);
84            return self.keep_original(&tools, plan, on_progress);
85        }
86
87        // Size-targeted video with `--vmaf`: encode to budget, then report the
88        // VMAF actually achieved (best effort — a failed measurement is silent).
89        if plan.target_vmaf.is_some() && !plan.spec.audio_only && !plan.spec.passthrough {
90            outcome.vmaf = self.measure_output(&tools, plan, &plan.output);
91        }
92        Ok(outcome)
93    }
94
95    /// The plain encode: two-pass (with one correction retry) or single-pass,
96    /// no VMAF handling. Returns an [`Outcome`] with `vmaf = None`.
97    fn run_plain(
98        &self,
99        tools: &deepshrink_ffmpeg::Tools,
100        plan: &EncodePlan,
101        encoder: &str,
102        on_progress: &mut dyn FnMut(PassKind, f64),
103    ) -> Result<Outcome, EngineError> {
104        let passlog = passlog_base(plan);
105        let total = plan.source_duration_sec;
106
107        if plan.spec.passthrough {
108            return self.run_passthrough(tools, plan, on_progress);
109        }
110
111        if plan.spec.two_pass {
112            let args1 = build_pass_args(plan, PassKind::First, &passlog, encoder);
113            deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args1, total, &mut |f| {
114                on_progress(PassKind::First, f)
115            })?;
116            let args2 = build_pass_args(plan, PassKind::Second, &passlog, encoder);
117            deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args2, total, &mut |f| {
118                on_progress(PassKind::Second, f)
119            })?;
120        } else {
121            let args = build_pass_args(plan, PassKind::Single, &passlog, encoder);
122            deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, total, &mut |f| {
123                on_progress(PassKind::Single, f)
124            })?;
125        }
126
127        let mut size = fs::metadata(&plan.output)?.len();
128
129        // Single correction retry: if two-pass overshot the target (VBV slack),
130        // scale the video bitrate down proportionally and re-run pass 2.
131        if let (Some(target), Some(vbps)) = (plan.target_bytes, plan.spec.video.bitrate_bps) {
132            if size > target && plan.spec.two_pass {
133                let corrected = (vbps as f64 * (target as f64 / size as f64) * 0.97) as u64;
134                if corrected >= budget::ABSOLUTE_MIN_VIDEO_BPS {
135                    let mut retry = plan.clone();
136                    retry.spec.video.bitrate_bps = Some(corrected);
137                    let args = build_pass_args(&retry, PassKind::Second, &passlog, encoder);
138                    deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, total, &mut |f| {
139                        on_progress(PassKind::Second, f)
140                    })?;
141                    size = fs::metadata(&plan.output)?.len();
142                }
143            }
144        }
145
146        cleanup_passlog(&passlog);
147        Ok(Outcome {
148            output: plan.output.clone(),
149            final_bytes: size,
150            vmaf: None,
151            already_compact: false,
152        })
153    }
154
155    /// The guard fired: deliver the source as-is (a byte copy, in its own
156    /// container/extension) instead of a re-encode that would not be smaller.
157    fn keep_original(
158        &self,
159        tools: &deepshrink_ffmpeg::Tools,
160        plan: &EncodePlan,
161        on_progress: &mut dyn FnMut(PassKind, f64),
162    ) -> Result<Outcome, EngineError> {
163        let _ = tools; // no ffmpeg needed: the source is delivered byte-for-byte
164        let output = match plan.input.extension() {
165            Some(ext) => plan.output.with_extension(ext),
166            None => plan.output.clone(),
167        };
168        // A plain copy, not a remux: "kept as-is" must mean identical bytes (a
169        // +faststart remux came out a few KB larger than the source).
170        fs::copy(&plan.input, &output)?;
171        on_progress(PassKind::Single, 1.0);
172        Ok(Outcome {
173            final_bytes: fs::metadata(&output)?.len(),
174            output,
175            vmaf: None,
176            already_compact: true,
177        })
178    }
179
180    /// Passthrough: the source already fits, so its streams are copied as-is.
181    ///
182    /// A stream copy is normally the cheapest and safest path, but it is not
183    /// infallible — some codecs simply cannot be muxed by the container's muxer
184    /// (ffmpeg needs a parser it may not have). Since nothing is being
185    /// re-encoded here, a failed remux falls back to copying the file verbatim:
186    /// the promise of this branch is "you get your file, unchanged and within
187    /// target", and that must hold for every input.
188    fn run_passthrough(
189        &self,
190        tools: &deepshrink_ffmpeg::Tools,
191        plan: &EncodePlan,
192        on_progress: &mut dyn FnMut(PassKind, f64),
193    ) -> Result<Outcome, EngineError> {
194        // Stream copy — the video encoder is never reached.
195        let args = build_pass_args(plan, PassKind::Single, "", "copy");
196        let remuxed =
197            deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, plan.source_duration_sec, &mut |f| {
198                on_progress(PassKind::Single, f)
199            });
200        if remuxed.is_err() {
201            fs::copy(&plan.input, &plan.output)?;
202            on_progress(PassKind::Single, 1.0);
203        }
204        let size = fs::metadata(&plan.output)?.len();
205        Ok(Outcome {
206            output: plan.output.clone(),
207            final_bytes: size,
208            vmaf: None,
209            already_compact: true,
210        })
211    }
212
213    /// Search CRF for the smallest output that still meets `target_vmaf`.
214    ///
215    /// Each trial is a single-pass CRF encode into `plan.output` followed by a
216    /// VMAF measurement against the source. Drives [`budget::search_crf`], so
217    /// the search algorithm itself is unit-tested separately. Falls back to a
218    /// plain encode if the source resolution is unknown (nothing to measure).
219    fn run_crf_search(
220        &self,
221        tools: &deepshrink_ffmpeg::Tools,
222        plan: &EncodePlan,
223        encoder: &str,
224        target_vmaf: f64,
225        on_progress: &mut dyn FnMut(PassKind, f64),
226    ) -> Result<Outcome, EngineError> {
227        let (ref_w, ref_h) = match (plan.source_width, plan.source_height) {
228            (Some(w), Some(h)) => (w, h),
229            _ => return self.run_plain(tools, plan, encoder, on_progress),
230        };
231        let ref_fps = plan.source_fps.unwrap_or(0.0);
232        let total = plan.source_duration_sec;
233        let (lo, hi) = plan.spec.video.codec.crf_search_bounds();
234        let n_threads = thread_count();
235
236        let mut err: Option<EngineError> = None;
237        let mut last_crf: Option<u8> = None;
238
239        let (chosen_crf, chosen_vmaf) = budget::search_crf(target_vmaf, lo, hi, |crf| {
240            if err.is_some() {
241                return f64::NEG_INFINITY;
242            }
243            match encode_at_crf(tools, plan, encoder, crf, total, on_progress).and_then(|()| {
244                last_crf = Some(crf);
245                deepshrink_ffmpeg::measure_vmaf(
246                    &tools.ffmpeg,
247                    &plan.output,
248                    &plan.input,
249                    ref_w,
250                    ref_h,
251                    ref_fps,
252                    n_threads,
253                )
254                .map_err(EngineError::from)
255            }) {
256                Ok(v) => v,
257                Err(e) => {
258                    err = Some(e);
259                    f64::NEG_INFINITY
260                }
261            }
262        });
263        if let Some(e) = err {
264            return Err(e);
265        }
266
267        // Leave the chosen CRF on disk (the search may have ended elsewhere).
268        if last_crf != Some(chosen_crf) {
269            encode_at_crf(tools, plan, encoder, chosen_crf, total, on_progress)?;
270        }
271        let size = fs::metadata(&plan.output)?.len();
272        Ok(Outcome {
273            output: plan.output.clone(),
274            final_bytes: size,
275            vmaf: Some(chosen_vmaf),
276            already_compact: false,
277        })
278    }
279
280    /// Measure the VMAF of an encoded `output` against the plan's source.
281    /// Returns `None` on any failure or when the source dimensions are unknown.
282    fn measure_output(
283        &self,
284        tools: &deepshrink_ffmpeg::Tools,
285        plan: &EncodePlan,
286        output: &Path,
287    ) -> Option<f64> {
288        let (w, h) = (plan.source_width?, plan.source_height?);
289        let fps = plan.source_fps.unwrap_or(0.0);
290        deepshrink_ffmpeg::measure_vmaf(
291            &tools.ffmpeg,
292            output,
293            &plan.input,
294            w,
295            h,
296            fps,
297            thread_count(),
298        )
299        .ok()
300    }
301
302    /// Plan a pure-audio encode (single pass, codec + fitted bitrate).
303    fn plan_audio(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
304        let duration = info.duration_sec;
305        if !duration.is_finite() || duration <= 0.0 {
306            return Err(EngineError::Unsupported(format!(
307                "could not determine duration of {}",
308                info.path.display()
309            )));
310        }
311        let codec = opts.audio_codec;
312        let target = target_bytes(&opts.goal, info.size_bytes);
313
314        // "Never make it bigger": stream-copy remux when the source already fits.
315        if let Some(tb) = target {
316            if info.size_bytes > 0 && info.size_bytes <= tb {
317                let src_ext = info
318                    .path
319                    .extension()
320                    .and_then(|e| e.to_str())
321                    .unwrap_or("audio");
322                let output = opts
323                    .output
324                    .clone()
325                    .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
326                return Ok(passthrough_plan(info, output, tb, false));
327            }
328        }
329
330        // Mono for speech: explicit flag, or a single-channel source.
331        let mono = opts.mono || info.audio_channels == Some(1);
332
333        let (bitrate_bps, expected_bytes) = match target {
334            Some(tb) => {
335                let raw = budget::audio_bitrate_bps(tb, duration).ok_or(EngineError::Infeasible)?;
336                if raw < budget::ABSOLUTE_MIN_AUDIO_BPS {
337                    return Err(EngineError::Infeasible);
338                }
339                let bps = budget::snap_audio_bitrate(raw);
340                let predicted = (bps as f64 * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD))
341                    .round() as u64;
342                (bps, Some(predicted))
343            }
344            None => {
345                // Quality mode: per tier, codec and channel count.
346                let bps = quality_audio_bps(opts.quality, codec, mono);
347                // Never re-encode lossy audio at (nearly) its own bitrate or
348                // above: that is only generation loss, often a bigger file (a
349                // 64 kbps MP3 audiobook → 160 kbps AAC doubled it). Keep it.
350                if !opts.allow_larger && already_compact_audio(bps, info) {
351                    let src_ext = info
352                        .path
353                        .extension()
354                        .and_then(|e| e.to_str())
355                        .unwrap_or("audio");
356                    let output = opts
357                        .output
358                        .clone()
359                        .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
360                    let mut plan = passthrough_plan(info, output, info.size_bytes, false);
361                    plan.summary =
362                        "stream copy (already compact — a re-encode would not be smaller)".into();
363                    return Ok(plan);
364                }
365                (bps, None)
366            }
367        };
368
369        let audio = AudioSpec {
370            codec,
371            bitrate_bps,
372            mono,
373            sample_rate: opts.sample_rate,
374            vbr: opts.vbr,
375        };
376        let output = opts
377            .output
378            .clone()
379            .unwrap_or_else(|| output_with_ext(&info.path, codec.extension()));
380        let summary = build_audio_summary(&audio, info.audio_channels);
381
382        Ok(EncodePlan {
383            input: info.path.clone(),
384            output,
385            summary,
386            expected_bytes,
387            target_bytes: target,
388            target_vmaf: None,
389            source_duration_sec: duration,
390            source_width: info.width,
391            source_height: info.height,
392            source_fps: info.fps,
393            spec: EncodeSpec {
394                video: placeholder_video_spec(),
395                audio: Some(audio),
396                faststart: false,
397                two_pass: false,
398                passthrough: false,
399                audio_only: true,
400                dpi: None,
401            },
402            // A size target is its own guarantee; quality mode gets the guard.
403            guard_larger: target.is_none() && !opts.allow_larger,
404        })
405    }
406}
407
408impl Engine for MediaEngine {
409    fn supports(&self, input: &Path) -> bool {
410        matches!(detect_kind(input), MediaKind::Video | MediaKind::Audio)
411    }
412
413    fn probe(&self, input: &Path) -> Result<MediaInfo, EngineError> {
414        let tools = deepshrink_ffmpeg::locate()?;
415        let p = deepshrink_ffmpeg::probe(&tools.ffprobe, input)?;
416
417        let video = p.video_stream();
418        let audio = p.audio_stream();
419        // Prefer ffprobe's reported size; fall back to the filesystem.
420        let size_bytes = p
421            .size_bytes()
422            .or_else(|| fs::metadata(input).ok().map(|m| m.len()))
423            .unwrap_or(0);
424
425        Ok(MediaInfo {
426            path: input.to_path_buf(),
427            kind: detect_kind(input),
428            duration_sec: p.duration_sec().unwrap_or(0.0),
429            size_bytes,
430            width: video.and_then(|v| v.width),
431            height: video.and_then(|v| v.height),
432            fps: p.fps(),
433            video_codec: video.and_then(|v| v.codec_name.clone()),
434            audio_codec: audio.and_then(|a| a.codec_name.clone()),
435            audio_channels: audio.and_then(|a| a.channels),
436        })
437    }
438
439    fn plan(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
440        match info.kind {
441            MediaKind::Audio => return self.plan_audio(info, opts),
442            MediaKind::Unsupported => {
443                return Err(EngineError::Unsupported(format!(
444                    "{} is not a supported media file",
445                    info.path.display()
446                )))
447            }
448            MediaKind::Video => {}
449        }
450        let duration = info.duration_sec;
451        if !duration.is_finite() || duration <= 0.0 {
452            return Err(EngineError::Unsupported(format!(
453                "could not determine duration of {}",
454                info.path.display()
455            )));
456        }
457        let src_height = info.height.unwrap_or(0);
458
459        let target = target_bytes(&opts.goal, info.size_bytes);
460        let output = opts
461            .output
462            .clone()
463            .unwrap_or_else(|| output_with_ext(&info.path, "mp4"));
464
465        // "Never make it bigger": if the source already fits the target, just
466        // remux (stream copy) instead of re-encoding it up to the target. The
467        // copy stays in the *source* container — an .mp4 cannot hold every codec
468        // a source may carry (an AMR-NB track from a .3gp, say), and a stream
469        // copy must not be the thing that breaks a file we aren't even re-encoding.
470        if let Some(tb) = target {
471            if info.size_bytes > 0 && info.size_bytes <= tb {
472                let src_ext = info
473                    .path
474                    .extension()
475                    .and_then(|e| e.to_str())
476                    .unwrap_or("mp4");
477                let output = opts
478                    .output
479                    .clone()
480                    .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
481                return Ok(passthrough_plan(info, output, tb, true));
482            }
483        }
484
485        let audio = decide_audio(opts, info.has_audio(), target, duration)?;
486        let audio_bps = audio.as_ref().map(|a| a.bitrate_bps).unwrap_or(0);
487
488        let (video, expected_bytes) = if let Some(tb) = target {
489            let vbps = budget::video_bitrate_bps(tb, duration, audio_bps)
490                .filter(|&b| b >= budget::ABSOLUTE_MIN_VIDEO_BPS)
491                .ok_or(EngineError::Infeasible)?;
492            let height = pick_height(opts.resolution, src_height, vbps);
493            let predicted = ((vbps + audio_bps) as f64 * duration / 8.0
494                * (1.0 + budget::CONTAINER_OVERHEAD))
495                .round() as u64;
496            (
497                VideoSpec {
498                    codec: opts.video_codec,
499                    bitrate_bps: Some(vbps),
500                    crf: None,
501                    height,
502                    fps: pick_fps(opts.fps, info.fps),
503                    preset: opts.quality,
504                },
505                Some(predicted),
506            )
507        } else {
508            // Quality mode: CRF, no hard size guarantee. The CRF default is
509            // codec-aware; a `--vmaf` target refines it via a search in `run`.
510            let crf = opts.quality.default_crf(opts.video_codec);
511            let height = match opts.resolution {
512                ResolutionOpt::Height(h) => clamp_height(h, src_height),
513                ResolutionOpt::Auto => None,
514            };
515            (
516                VideoSpec {
517                    codec: opts.video_codec,
518                    bitrate_bps: None,
519                    crf: Some(crf),
520                    height,
521                    fps: pick_fps(opts.fps, info.fps),
522                    preset: opts.quality,
523                },
524                None,
525            )
526        };
527
528        // Two-pass is how a bitrate budget is actually hit; the caller can force
529        // it off (faster, looser) but can't force it on in CRF mode, where there
530        // is no budget for a first pass to measure.
531        let two_pass = video.bitrate_bps.is_some() && opts.two_pass.unwrap_or(true);
532        let summary = build_summary(&video, audio.as_ref(), two_pass);
533
534        Ok(EncodePlan {
535            input: info.path.clone(),
536            output,
537            summary,
538            expected_bytes,
539            target_bytes: target,
540            target_vmaf: opts.target_vmaf,
541            source_duration_sec: duration,
542            source_width: info.width,
543            source_height: info.height,
544            source_fps: info.fps,
545            spec: EncodeSpec {
546                video,
547                audio,
548                faststart: true,
549                two_pass,
550                passthrough: false,
551                audio_only: false,
552                dpi: None,
553            },
554            // A size target is its own guarantee; quality mode gets the guard.
555            guard_larger: target.is_none() && !opts.allow_larger,
556        })
557    }
558
559    fn run(&self, plan: &EncodePlan) -> Result<Outcome, EngineError> {
560        self.run_with_progress(plan, &mut |_, _| {})
561    }
562}
563
564/// A placeholder video spec — ignored while `passthrough`/`audio_only` is set.
565fn placeholder_video_spec() -> VideoSpec {
566    VideoSpec {
567        codec: crate::options::VideoCodec::H264,
568        bitrate_bps: None,
569        crf: None,
570        height: None,
571        fps: None,
572        preset: crate::options::QualityPreset::Balanced,
573    }
574}
575
576/// A stream-copy remux plan for when the source already fits the target.
577/// `faststart` is only meaningful for MP4/MOV; pass `false` for pure audio.
578fn passthrough_plan(info: &MediaInfo, output: PathBuf, target: u64, faststart: bool) -> EncodePlan {
579    EncodePlan {
580        input: info.path.clone(),
581        output,
582        summary: "stream copy (already within target)".to_string(),
583        expected_bytes: Some(info.size_bytes),
584        target_bytes: Some(target),
585        target_vmaf: None,
586        source_duration_sec: info.duration_sec,
587        source_width: info.width,
588        source_height: info.height,
589        source_fps: info.fps,
590        spec: EncodeSpec {
591            video: placeholder_video_spec(),
592            audio: None,
593            faststart,
594            two_pass: false,
595            passthrough: true,
596            audio_only: false,
597            dpi: None,
598        },
599        guard_larger: false,
600    }
601}
602
603/// Quality-mode audio bitrate by tier, codec and channel count (mono = half).
604/// Opus needs the least for the same quality, MP3 the most.
605fn quality_audio_bps(quality: QualityPreset, codec: AudioCodec, mono: bool) -> u64 {
606    let stereo = match (codec, quality) {
607        (AudioCodec::Opus, QualityPreset::Fast) => 64_000,
608        (AudioCodec::Opus, QualityPreset::Balanced) => 96_000,
609        (AudioCodec::Opus, QualityPreset::Max) => 128_000,
610        (AudioCodec::Mp3, QualityPreset::Fast) => 128_000,
611        (AudioCodec::Mp3, QualityPreset::Balanced) => 160_000,
612        (AudioCodec::Mp3, QualityPreset::Max) => 256_000,
613        (AudioCodec::Aac, QualityPreset::Fast) => 96_000,
614        (AudioCodec::Aac, QualityPreset::Balanced) => 128_000,
615        (AudioCodec::Aac, QualityPreset::Max) => 192_000,
616    };
617    if mono {
618        stereo / 2
619    } else {
620        stereo
621    }
622}
623
624/// A pure-audio source whose own bitrate is at or under ~110% of what we'd
625/// encode at: a re-encode can't meaningfully shrink it. The source rate comes
626/// from size / duration (embedded cover art only raises it — the safe side).
627fn already_compact_audio(bps: u64, info: &MediaInfo) -> bool {
628    if info.duration_sec <= 0.0 || info.size_bytes == 0 {
629        return false;
630    }
631    let source_bps = info.size_bytes as f64 * 8.0 / info.duration_sec;
632    bps as f64 >= source_bps * 0.9
633}
634
635/// Sample windows for [`predict_crf_bytes`]: three 3-second clips at 20/50/80%.
636const SAMPLE_SECS: f64 = 3.0;
637/// Each sample starts on a keyframe, so samples over-predict by ~8–10% (a
638/// 30 s phone clip: 20.4 MB predicted vs 18.7 MB real) — scale that back.
639const SAMPLE_BIAS: f64 = 0.92;
640
641/// Predict a CRF video encode's final size from three short sample encodes
642/// (same encoder, CRF, preset, scaling, fps; audio at the planned bitrate).
643/// `None` for short clips (a full encode is cheap; the post-encode check
644/// covers them) or when a sample fails.
645fn predict_crf_bytes(
646    tools: &deepshrink_ffmpeg::Tools,
647    plan: &EncodePlan,
648    encoder: &str,
649) -> Option<u64> {
650    let duration = plan.source_duration_sec;
651    if !duration.is_finite() || duration < SAMPLE_SECS * 4.0 {
652        return None;
653    }
654    let mut sample = plan.clone();
655    sample.spec.audio = None;
656    sample.spec.faststart = false;
657    let mut video_bytes = 0u64;
658    let mut sampled = 0.0;
659    for (i, at) in [0.2, 0.5, 0.8].iter().enumerate() {
660        let start = (duration * at - SAMPLE_SECS / 2.0).max(0.0);
661        sample.output =
662            std::env::temp_dir().join(format!("deepshrink-sample-{}-{i}.mp4", std::process::id()));
663        let mut args = build_pass_args(&sample, PassKind::Single, "", encoder);
664        let at_input = args.iter().position(|a| a == "-i")?;
665        args.splice(
666            at_input..at_input,
667            [
668                OsString::from("-ss"),
669                OsString::from(format!("{start:.2}")),
670                OsString::from("-t"),
671                OsString::from(format!("{SAMPLE_SECS}")),
672            ],
673        );
674        let ran = deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, SAMPLE_SECS, &mut |_| {});
675        let bytes = fs::metadata(&sample.output).map(|m| m.len()).ok();
676        let _ = fs::remove_file(&sample.output);
677        video_bytes += bytes.filter(|_| ran.is_ok())?;
678        sampled += SAMPLE_SECS;
679    }
680    let video_bps = video_bytes as f64 * 8.0 / sampled * SAMPLE_BIAS;
681    let audio_bps = plan.spec.audio.as_ref().map(|a| a.bitrate_bps).unwrap_or(0) as f64;
682    Some(((video_bps + audio_bps) * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD)) as u64)
683}
684
685/// Human-readable summary for a pure-audio plan, e.g.
686/// "Opus · 22 kbps · mono (speech)".
687fn build_audio_summary(audio: &AudioSpec, src_channels: Option<u32>) -> String {
688    let mut parts = vec![
689        audio.codec.label().to_string(),
690        format!("{} kbps", audio.bitrate_bps / 1000),
691    ];
692    if audio.mono {
693        // A single-channel source (or --mono) reads as speech.
694        let note = if src_channels == Some(1) {
695            "mono"
696        } else {
697            "mono (downmix)"
698        };
699        parts.push(note.to_string());
700    }
701    if let Some(sr) = audio.sample_rate {
702        parts.push(format!("{} Hz", sr));
703    }
704    parts.join(" · ")
705}
706
707/// Resolve the absolute target size (bytes) for a goal, if it imposes one.
708fn target_bytes(goal: &SizeGoal, original: u64) -> Option<u64> {
709    match goal {
710        SizeGoal::Target(b) => Some(*b),
711        SizeGoal::Reduce(f) => Some(budget::reduce_target_bytes(original, *f)),
712        SizeGoal::Preset(p) => p.limit_bytes,
713        SizeGoal::Quality => None,
714    }
715}
716
717/// Decide the audio track for a video encode.
718fn decide_audio(
719    opts: &ShrinkOpts,
720    has_audio: bool,
721    target: Option<u64>,
722    duration: f64,
723) -> Result<Option<AudioSpec>, EngineError> {
724    if !has_audio {
725        return Ok(None);
726    }
727    // A `--mono` request downmixes the kept audio track (speech clips / smaller
728    // files). A single-channel source stays mono regardless.
729    let mono = opts.mono;
730    match opts.audio {
731        AudioChoice::Drop => Ok(None),
732        AudioChoice::Bitrate(b) => Ok(Some(AudioSpec {
733            mono,
734            ..AudioSpec::cbr(AudioCodec::Aac, b)
735        })),
736        AudioChoice::Keep => {
737            let bps = match target {
738                Some(tb) => budget::fit_audio_bps(tb, duration, AUDIO_LADDER)
739                    .ok_or(EngineError::Infeasible)?,
740                None => budget::DEFAULT_AUDIO_BPS,
741            };
742            Ok(Some(AudioSpec {
743                mono,
744                ..AudioSpec::cbr(AudioCodec::Aac, bps)
745            }))
746        }
747    }
748}
749
750/// Choose the encode height in auto/explicit mode.
751fn pick_height(res: ResolutionOpt, src_height: u32, vbps: u64) -> Option<u32> {
752    match res {
753        ResolutionOpt::Height(h) => clamp_height(h, src_height),
754        ResolutionOpt::Auto => {
755            let chosen = budget::choose_height(src_height, vbps);
756            if src_height > 0 && chosen < src_height {
757                Some(chosen)
758            } else {
759                None
760            }
761        }
762    }
763}
764
765/// Clamp an explicit height to the source (never upscale); `None` if it equals
766/// the source (no scaling needed).
767fn clamp_height(requested: u32, src_height: u32) -> Option<u32> {
768    if src_height == 0 {
769        return Some(requested);
770    }
771    let h = requested.min(src_height);
772    if h == src_height {
773        None
774    } else {
775        Some(h)
776    }
777}
778
779/// Choose an fps cap; `None` if uncapped or the cap is ≥ the source rate.
780fn pick_fps(fps: FpsOpt, src_fps: Option<f64>) -> Option<u32> {
781    match fps {
782        FpsOpt::Auto => None,
783        FpsOpt::Cap(f) => match src_fps {
784            Some(src) if (f as f64) >= src => None,
785            _ => Some(f),
786        },
787    }
788}
789
790/// Default output path: `<stem>.shrink.<ext>` next to the input.
791fn output_with_ext(input: &Path, ext: &str) -> PathBuf {
792    let stem = input
793        .file_stem()
794        .map(|s| s.to_string_lossy().into_owned())
795        .unwrap_or_else(|| "output".to_string());
796    let mut out = input.parent().map(Path::to_path_buf).unwrap_or_default();
797    out.push(format!("{stem}.shrink.{ext}"));
798    out
799}
800
801fn build_summary(video: &VideoSpec, audio: Option<&AudioSpec>, two_pass: bool) -> String {
802    let mut parts = vec![video.codec.label().to_string()];
803    match (video.bitrate_bps, video.crf) {
804        (Some(bps), _) => parts.push(format!("{} kbps video", bps / 1000)),
805        (_, Some(crf)) => parts.push(format!("CRF {crf}")),
806        _ => {}
807    }
808    if let Some(a) = audio {
809        parts.push(format!("{} kbps audio", a.bitrate_bps / 1000));
810    } else {
811        parts.push("no audio".to_string());
812    }
813    if let Some(h) = video.height {
814        parts.push(format!("{h}p"));
815    }
816    if let Some(f) = video.fps {
817        parts.push(format!("{f} fps"));
818    }
819    parts.push(if two_pass { "two-pass" } else { "CRF" }.to_string());
820    parts.join(" · ")
821}
822
823/// Base path for ffmpeg's two-pass log, unique per process + input stem.
824fn passlog_base(plan: &EncodePlan) -> String {
825    let stem = plan
826        .input
827        .file_stem()
828        .map(|s| s.to_string_lossy().into_owned())
829        .unwrap_or_else(|| "ds".to_string());
830    let dir = std::env::temp_dir();
831    dir.join(format!("deepshrink-{}-{}", std::process::id(), stem))
832        .to_string_lossy()
833        .into_owned()
834}
835
836/// Remove the files ffmpeg leaves behind for `-passlogfile <base>`.
837fn cleanup_passlog(base: &str) {
838    for suffix in ["-0.log", "-0.log.mbtree"] {
839        let _ = fs::remove_file(format!("{base}{suffix}"));
840    }
841}
842
843/// Encode a single-pass CRF trial into `plan.output` at the given CRF.
844fn encode_at_crf(
845    tools: &deepshrink_ffmpeg::Tools,
846    plan: &EncodePlan,
847    encoder: &str,
848    crf: u8,
849    total: f64,
850    on_progress: &mut dyn FnMut(PassKind, f64),
851) -> Result<(), EngineError> {
852    let mut trial = plan.clone();
853    trial.spec.video.crf = Some(crf);
854    trial.spec.video.bitrate_bps = None;
855    trial.spec.two_pass = false;
856    let args = build_pass_args(&trial, PassKind::Single, "", encoder);
857    deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, total, &mut |f| {
858        on_progress(PassKind::Single, f)
859    })?;
860    Ok(())
861}
862
863/// Threads to hand libvmaf (bounded by available parallelism).
864fn thread_count() -> usize {
865    std::thread::available_parallelism()
866        .map(|n| n.get())
867        .unwrap_or(1)
868}
869
870/// Platform null sink for the discard output of pass 1.
871fn null_sink() -> &'static str {
872    if cfg!(windows) {
873        "NUL"
874    } else {
875        "/dev/null"
876    }
877}
878
879/// Pick the ffmpeg encoder to drive this plan with.
880///
881/// x264/x265 are in every build worth supporting, so they're taken on faith —
882/// asking ffmpeg costs a process spawn per run. AV1 is the exception: builds
883/// disagree on which (if any) AV1 encoder they carry, so it's probed, with
884/// libaom as the fallback and a plain-English error when neither is present
885/// (better than handing the user ffmpeg's "Unknown encoder" dump).
886fn resolve_encoder(
887    tools: &deepshrink_ffmpeg::Tools,
888    plan: &EncodePlan,
889) -> Result<&'static str, EngineError> {
890    let codec = plan.spec.video.codec;
891    let primary = codec.encoder();
892    let Some(fallback) = codec.fallback_encoder() else {
893        return Ok(primary);
894    };
895    // Passthrough/audio-only encodes never touch the video encoder.
896    if plan.spec.passthrough || plan.spec.audio_only {
897        return Ok(primary);
898    }
899    if deepshrink_ffmpeg::has_encoder(&tools.ffmpeg, primary) {
900        return Ok(primary);
901    }
902    if deepshrink_ffmpeg::has_encoder(&tools.ffmpeg, fallback) {
903        return Ok(fallback);
904    }
905    Err(EngineError::Unsupported(format!(
906        "this ffmpeg build has no {} encoder (looked for {primary} and {fallback})",
907        codec.label()
908    )))
909}
910
911/// Build the ffmpeg argv for one pass. Video-processing options (codec, filters,
912/// bitrate) are shared across passes; audio/output differ per pass. `encoder` is
913/// the resolved `-c:v` name (see [`resolve_encoder`]) — it can differ from the
914/// codec's default for AV1.
915fn build_pass_args(
916    plan: &EncodePlan,
917    pass: PassKind,
918    passlog: &str,
919    encoder: &str,
920) -> Vec<OsString> {
921    let s = &plan.spec;
922    let mut a: Vec<OsString> = Vec::new();
923    // Local helper — a macro (not a closure) so it doesn't hold a borrow of `a`
924    // across the direct `a.push(..)` calls used for OsString paths.
925    macro_rules! push {
926        ($arg:expr) => {
927            a.push(OsString::from($arg))
928        };
929    }
930
931    push!("-hide_banner");
932    push!("-y");
933    push!("-loglevel");
934    push!("error");
935    push!("-progress");
936    push!("pipe:1");
937    push!("-nostats");
938    push!("-i");
939    a.push(plan.input.clone().into_os_string());
940
941    // Passthrough: stream copy, no re-encode. Output only (single pass).
942    if s.passthrough {
943        push!("-c");
944        push!("copy");
945        if s.faststart {
946            push!("-movflags");
947            push!("+faststart");
948        }
949        a.push(plan.output.clone().into_os_string());
950        return a;
951    }
952
953    // Pure audio: drop video, encode the audio track only (single pass).
954    if s.audio_only {
955        push!("-vn");
956        if let Some(au) = &s.audio {
957            push!("-c:a");
958            push!(au.codec.encoder());
959            if au.mono {
960                push!("-ac");
961                push!("1");
962            }
963            if let Some(sr) = au.sample_rate {
964                push!("-ar");
965                push!(sr.to_string());
966            }
967            push!("-b:a");
968            push!(au.bitrate_bps.to_string());
969            // Opus supports VBR; use constrained VBR by default for a tighter
970            // fit to the target, or full VBR when requested.
971            if matches!(au.codec, AudioCodec::Opus) {
972                push!("-vbr");
973                push!(if au.vbr { "on" } else { "constrained" });
974            }
975        }
976        a.push(plan.output.clone().into_os_string());
977        return a;
978    }
979
980    // Video codec + filters.
981    push!("-c:v");
982    push!(encoder);
983    if let Some(h) = s.video.height {
984        push!("-vf");
985        push!(format!("scale=-2:{h}"));
986    }
987    if let Some(f) = s.video.fps {
988        push!("-r");
989        push!(f.to_string());
990    }
991    // The speed knob is per-encoder: `-preset medium` is meaningless (and fatal)
992    // to SVT-AV1, which wants a number.
993    let (speed_flag, speed_value) = s.video.preset.speed_flags(encoder);
994    push!(speed_flag);
995    push!(speed_value);
996    if let Some(tag) = s.video.codec.mp4_tag() {
997        push!("-tag:v");
998        push!(tag);
999    }
1000
1001    // Rate control.
1002    match (s.video.bitrate_bps, s.video.crf) {
1003        (Some(bps), _) => {
1004            push!("-b:v");
1005            push!(bps.to_string());
1006            if s.two_pass {
1007                push!("-pass");
1008                push!(match pass {
1009                    PassKind::First => "1",
1010                    _ => "2",
1011                });
1012                push!("-passlogfile");
1013                push!(passlog);
1014            }
1015        }
1016        (_, Some(crf)) => {
1017            push!("-crf");
1018            push!(crf.to_string());
1019        }
1020        _ => {}
1021    }
1022
1023    // Audio + output.
1024    match pass {
1025        PassKind::First => {
1026            // Analysis pass: no audio, discard the muxed output.
1027            push!("-an");
1028            push!("-f");
1029            push!("null");
1030            push!(null_sink());
1031        }
1032        PassKind::Second | PassKind::Single => {
1033            match &s.audio {
1034                Some(au) => {
1035                    push!("-c:a");
1036                    push!(au.codec.encoder());
1037                    // A mono downmix has to reach ffmpeg here too — the audio
1038                    // track of a video is muxed in this pass, not the audio-only
1039                    // branch above.
1040                    if au.mono {
1041                        push!("-ac");
1042                        push!("1");
1043                    }
1044                    push!("-b:a");
1045                    push!(au.bitrate_bps.to_string());
1046                }
1047                None => push!("-an"),
1048            }
1049            if s.faststart {
1050                push!("-movflags");
1051                push!("+faststart");
1052            }
1053            a.push(plan.output.clone().into_os_string());
1054        }
1055    }
1056    a
1057}
1058
1059#[cfg(test)]
1060mod tests {
1061    use super::*;
1062    use crate::options::{AudioCodec, QualityPreset, VideoCodec};
1063    use crate::size::preset;
1064
1065    /// The encoder `run` would resolve for a plan without probing ffmpeg (every
1066    /// codec these tests use has its primary encoder everywhere).
1067    fn enc(plan: &EncodePlan) -> &'static str {
1068        plan.spec.video.codec.encoder()
1069    }
1070
1071    fn video_info(duration: f64, size: u64, w: u32, h: u32, audio: bool) -> MediaInfo {
1072        MediaInfo {
1073            path: PathBuf::from("/tmp/clip.mp4"),
1074            kind: MediaKind::Video,
1075            duration_sec: duration,
1076            size_bytes: size,
1077            width: Some(w),
1078            height: Some(h),
1079            fps: Some(30.0),
1080            video_codec: Some("h264".into()),
1081            audio_codec: if audio { Some("aac".into()) } else { None },
1082            audio_channels: if audio { Some(2) } else { None },
1083        }
1084    }
1085
1086    fn opts_target(bytes: u64) -> ShrinkOpts {
1087        ShrinkOpts {
1088            goal: SizeGoal::Target(bytes),
1089            ..Default::default()
1090        }
1091    }
1092
1093    #[test]
1094    fn supports_video_and_audio() {
1095        let e = MediaEngine::new();
1096        assert!(e.supports(&PathBuf::from("clip.mp4")));
1097        assert!(e.supports(&PathBuf::from("lecture.wav")));
1098        assert!(!e.supports(&PathBuf::from("photo.jpg")));
1099    }
1100
1101    #[test]
1102    fn plan_target_builds_two_pass_with_budget() {
1103        let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1104        let plan = MediaEngine::new()
1105            .plan(&info, &opts_target(8_000_000))
1106            .unwrap();
1107
1108        assert!(plan.spec.two_pass);
1109        assert_eq!(plan.target_bytes, Some(8_000_000));
1110        assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
1111        let vbps = plan.spec.video.bitrate_bps.unwrap();
1112        assert!(vbps >= budget::ABSOLUTE_MIN_VIDEO_BPS);
1113        // 8 MB over 120 s is a low budget → downscale from 1080p.
1114        assert!(plan.spec.video.height.is_some());
1115        assert!(plan.spec.audio.is_some());
1116        // Predicted size should not exceed the target.
1117        assert!(plan.expected_bytes.unwrap() <= 8_000_000 + 8_000_000 / 20);
1118    }
1119
1120    #[test]
1121    fn plan_video_mono_downmixes_the_audio_track() {
1122        let info = video_info(60.0, 100_000_000, 1280, 720, true);
1123        let opts = ShrinkOpts {
1124            mono: true,
1125            ..opts_target(8_000_000)
1126        };
1127        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1128        let audio = plan.spec.audio.as_ref().expect("kept audio track");
1129        assert!(audio.mono, "opts.mono downmixes the video's audio track");
1130        // A stereo request stays stereo.
1131        let stereo = MediaEngine::new()
1132            .plan(&info, &opts_target(8_000_000))
1133            .unwrap();
1134        assert!(!stereo.spec.audio.as_ref().unwrap().mono);
1135    }
1136
1137    #[test]
1138    fn video_mono_reaches_ffmpeg_as_ac_1() {
1139        // The plan carrying `mono` is only half the job — the muxing pass of a
1140        // video encode has to actually emit `-ac 1`, or the output stays stereo.
1141        let info = video_info(60.0, 100_000_000, 1280, 720, true);
1142        let plan = MediaEngine::new()
1143            .plan(
1144                &info,
1145                &ShrinkOpts {
1146                    mono: true,
1147                    ..opts_target(8_000_000)
1148                },
1149            )
1150            .unwrap();
1151        let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan));
1152        let joined: Vec<String> = args
1153            .iter()
1154            .map(|a| a.to_string_lossy().into_owned())
1155            .collect();
1156        let ac = joined.iter().position(|a| a == "-ac").expect("-ac emitted");
1157        assert_eq!(joined[ac + 1], "1");
1158
1159        // Stereo request → no downmix flag at all.
1160        let stereo = MediaEngine::new()
1161            .plan(&info, &opts_target(8_000_000))
1162            .unwrap();
1163        let stereo_args: Vec<String> =
1164            build_pass_args(&stereo, PassKind::Second, "/tmp/passlog", enc(&stereo))
1165                .iter()
1166                .map(|a| a.to_string_lossy().into_owned())
1167                .collect();
1168        assert!(!stereo_args.iter().any(|a| a == "-ac"));
1169    }
1170
1171    #[test]
1172    fn plan_preset_discord_sets_target() {
1173        let info = video_info(30.0, 50_000_000, 1280, 720, true);
1174        let opts = ShrinkOpts {
1175            goal: SizeGoal::Preset(preset("discord").unwrap()),
1176            ..Default::default()
1177        };
1178        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1179        assert_eq!(plan.target_bytes, Some(8_000_000));
1180    }
1181
1182    #[test]
1183    fn plan_reduce_targets_complement_of_original() {
1184        let info = video_info(60.0, 100_000_000, 1920, 1080, true);
1185        let opts = ShrinkOpts {
1186            goal: SizeGoal::Reduce(0.70),
1187            ..Default::default()
1188        };
1189        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1190        assert_eq!(plan.target_bytes, Some(30_000_000));
1191    }
1192
1193    #[test]
1194    fn plan_passthrough_when_source_already_fits() {
1195        // Source is 200 KB, target 1 MB → never inflate; stream-copy remux.
1196        let info = video_info(10.0, 200_000, 1280, 720, true);
1197        let plan = MediaEngine::new()
1198            .plan(&info, &opts_target(1_000_000))
1199            .unwrap();
1200        assert!(plan.spec.passthrough);
1201        assert!(!plan.spec.two_pass);
1202        assert_eq!(plan.expected_bytes, Some(200_000));
1203        let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog", enc(&plan));
1204        let joined: Vec<String> = args
1205            .iter()
1206            .map(|a| a.to_string_lossy().into_owned())
1207            .collect();
1208        assert!(joined.contains(&"copy".to_string()));
1209        // Same container as the source: a stream copy must land somewhere its
1210        // codecs are muxable.
1211        assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
1212    }
1213
1214    #[test]
1215    fn plan_passthrough_keeps_the_source_container() {
1216        // A .3gp may carry codecs (AMR-NB) that no .mp4 muxer accepts — copying
1217        // its streams into an .mp4 would fail on a file we aren't re-encoding.
1218        let info = MediaInfo {
1219            path: PathBuf::from("/tmp/voice.3gp"),
1220            ..video_info(10.0, 200_000, 320, 240, true)
1221        };
1222        let plan = MediaEngine::new()
1223            .plan(&info, &opts_target(1_000_000))
1224            .unwrap();
1225        assert!(plan.spec.passthrough);
1226        assert_eq!(plan.output, PathBuf::from("/tmp/voice.shrink.3gp"));
1227    }
1228
1229    #[test]
1230    fn plan_infeasible_when_target_too_small() {
1231        let info = video_info(600.0, 500_000_000, 1920, 1080, true);
1232        let err = MediaEngine::new().plan(&info, &opts_target(50_000));
1233        assert!(matches!(err, Err(EngineError::Infeasible)));
1234    }
1235
1236    #[test]
1237    fn plan_quality_mode_uses_crf_single_pass() {
1238        let info = video_info(60.0, 100_000_000, 1920, 1080, true);
1239        let opts = ShrinkOpts {
1240            goal: SizeGoal::Quality,
1241            quality: QualityPreset::Balanced,
1242            ..Default::default()
1243        };
1244        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1245        assert!(!plan.spec.two_pass);
1246        assert_eq!(plan.spec.video.crf, Some(23));
1247        assert!(plan.spec.video.bitrate_bps.is_none());
1248        assert!(plan.expected_bytes.is_none());
1249    }
1250
1251    #[test]
1252    fn plan_drops_audio_when_requested() {
1253        let info = video_info(30.0, 50_000_000, 1280, 720, true);
1254        let opts = ShrinkOpts {
1255            audio: AudioChoice::Drop,
1256            ..opts_target(8_000_000)
1257        };
1258        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1259        assert!(plan.spec.audio.is_none());
1260    }
1261
1262    fn audio_info(duration: f64, size: u64, channels: u32) -> MediaInfo {
1263        MediaInfo {
1264            path: PathBuf::from("/tmp/lecture.wav"),
1265            kind: MediaKind::Audio,
1266            duration_sec: duration,
1267            size_bytes: size,
1268            width: None,
1269            height: None,
1270            fps: None,
1271            video_codec: None,
1272            audio_codec: Some("pcm_s16le".into()),
1273            audio_channels: Some(channels),
1274        }
1275    }
1276
1277    #[test]
1278    fn quality_audio_bitrate_follows_tier_codec_and_channels() {
1279        use QualityPreset::*;
1280        assert_eq!(quality_audio_bps(Balanced, AudioCodec::Aac, false), 128_000);
1281        assert_eq!(quality_audio_bps(Balanced, AudioCodec::Aac, true), 64_000);
1282        assert_eq!(quality_audio_bps(Fast, AudioCodec::Opus, true), 32_000);
1283        assert_eq!(quality_audio_bps(Max, AudioCodec::Mp3, false), 256_000);
1284        // Every tier is strictly smaller → larger, per codec.
1285        for c in [AudioCodec::Aac, AudioCodec::Opus, AudioCodec::Mp3] {
1286            let t: Vec<_> = [Fast, Balanced, Max]
1287                .map(|q| quality_audio_bps(q, c, false))
1288                .into();
1289            assert!(t[0] < t[1] && t[1] < t[2], "{c:?}: {t:?}");
1290        }
1291    }
1292
1293    #[test]
1294    fn a_compact_audiobook_is_kept_in_quality_mode() {
1295        // 1 h mono at 64 kbps (the review case): balanced AAC mono is 64 kbps too.
1296        let mut info = audio_info(3600.0, 64_000 / 8 * 3600, 1);
1297        info.path = PathBuf::from("/tmp/book.mp3");
1298        let plan = MediaEngine::new()
1299            .plan(&info, &ShrinkOpts::default())
1300            .unwrap();
1301        assert!(plan.spec.passthrough, "{}", plan.summary);
1302        assert!(plan.output.to_string_lossy().ends_with(".mp3"));
1303        assert!(plan.summary.contains("already compact"));
1304
1305        // A genuinely smaller recipe still encodes (Opus fast mono = 32 kbps).
1306        let smaller = ShrinkOpts {
1307            audio_codec: AudioCodec::Opus,
1308            quality: QualityPreset::Fast,
1309            ..ShrinkOpts::default()
1310        };
1311        let plan = MediaEngine::new().plan(&info, &smaller).unwrap();
1312        assert!(!plan.spec.passthrough);
1313        assert_eq!(plan.spec.audio.as_ref().unwrap().bitrate_bps, 32_000);
1314        assert!(
1315            plan.guard_larger,
1316            "quality mode keeps the post-encode check"
1317        );
1318
1319        // Opting out re-encodes at the tier bitrate.
1320        let allow = ShrinkOpts {
1321            allow_larger: true,
1322            ..ShrinkOpts::default()
1323        };
1324        let plan = MediaEngine::new().plan(&info, &allow).unwrap();
1325        assert!(!plan.spec.passthrough && !plan.guard_larger);
1326    }
1327
1328    #[test]
1329    fn the_guard_is_for_quality_mode_only() {
1330        let info = video_info(60.0, 50_000_000, 1920, 1080, true);
1331        let quality = MediaEngine::new()
1332            .plan(&info, &ShrinkOpts::default())
1333            .unwrap();
1334        assert!(quality.guard_larger);
1335        let target = MediaEngine::new()
1336            .plan(&info, &opts_target(10_000_000))
1337            .unwrap();
1338        assert!(!target.guard_larger, "a size target is its own guarantee");
1339    }
1340
1341    #[test]
1342    fn plan_audio_single_pass_with_fitted_bitrate() {
1343        // 58 min stereo lecture, target 10 MB.
1344        let info = audio_info(3480.0, 600_000_000, 2);
1345        let plan = MediaEngine::new()
1346            .plan(&info, &opts_target(10_000_000))
1347            .unwrap();
1348        assert!(plan.spec.audio_only);
1349        assert!(!plan.spec.two_pass);
1350        assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.m4a"));
1351        let au = plan.spec.audio.as_ref().unwrap();
1352        // Snapped down to a standard step, never above the raw budget.
1353        assert!(budget::AUDIO_STEPS.contains(&au.bitrate_bps));
1354        assert!(plan.expected_bytes.unwrap() <= 10_000_000 + 10_000_000 / 20);
1355    }
1356
1357    #[test]
1358    fn plan_audio_mono_source_marked_speech() {
1359        let info = audio_info(600.0, 100_000_000, 1);
1360        let plan = MediaEngine::new()
1361            .plan(&info, &opts_target(5_000_000))
1362            .unwrap();
1363        assert!(plan.spec.audio.as_ref().unwrap().mono);
1364    }
1365
1366    #[test]
1367    fn plan_audio_opus_extension_and_vbr_args() {
1368        let info = audio_info(600.0, 100_000_000, 2);
1369        let opts = ShrinkOpts {
1370            audio_codec: AudioCodec::Opus,
1371            mono: true,
1372            ..opts_target(3_000_000)
1373        };
1374        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1375        assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.opus"));
1376        let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog", enc(&plan));
1377        let j: Vec<String> = args
1378            .iter()
1379            .map(|a| a.to_string_lossy().into_owned())
1380            .collect();
1381        assert!(j.contains(&"-vn".to_string()));
1382        assert!(j.contains(&"libopus".to_string()));
1383        assert!(j.contains(&"-ac".to_string())); // mono downmix
1384        assert!(j.contains(&"-vbr".to_string()));
1385    }
1386
1387    #[test]
1388    fn plan_audio_infeasible_when_target_tiny() {
1389        let info = audio_info(3600.0, 500_000_000, 2);
1390        assert!(matches!(
1391            MediaEngine::new().plan(&info, &opts_target(1_000)),
1392            Err(EngineError::Infeasible)
1393        ));
1394    }
1395
1396    #[test]
1397    fn plan_audio_passthrough_when_source_fits() {
1398        let info = audio_info(600.0, 2_000_000, 2);
1399        let plan = MediaEngine::new()
1400            .plan(&info, &opts_target(10_000_000))
1401            .unwrap();
1402        assert!(plan.spec.passthrough);
1403        // Passthrough keeps the source container/extension.
1404        assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.wav"));
1405    }
1406
1407    #[test]
1408    fn pass1_args_have_no_audio_and_null_sink() {
1409        let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1410        let plan = MediaEngine::new()
1411            .plan(&info, &opts_target(8_000_000))
1412            .unwrap();
1413        let args = build_pass_args(&plan, PassKind::First, "/tmp/passlog", enc(&plan));
1414        let joined: Vec<String> = args
1415            .iter()
1416            .map(|a| a.to_string_lossy().into_owned())
1417            .collect();
1418        assert!(joined.contains(&"-an".to_string()));
1419        assert!(joined.contains(&"null".to_string()));
1420        assert!(joined.iter().any(|a| a == "1")); // -pass 1
1421        assert!(!joined.iter().any(|a| a.contains("shrink.mp4")));
1422    }
1423
1424    #[test]
1425    fn pass2_args_write_output_with_audio() {
1426        let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1427        let plan = MediaEngine::new()
1428            .plan(&info, &opts_target(8_000_000))
1429            .unwrap();
1430        let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan));
1431        let joined: Vec<String> = args
1432            .iter()
1433            .map(|a| a.to_string_lossy().into_owned())
1434            .collect();
1435        assert!(joined.iter().any(|a| a.contains("clip.shrink.mp4")));
1436        assert!(joined.contains(&"-c:a".to_string()));
1437        assert!(joined.contains(&"+faststart".to_string()));
1438        assert!(joined.iter().any(|a| a == "2")); // -pass 2
1439    }
1440
1441    #[test]
1442    fn h265_adds_hvc1_tag() {
1443        let info = video_info(60.0, 100_000_000, 1280, 720, false);
1444        let opts = ShrinkOpts {
1445            video_codec: VideoCodec::H265,
1446            ..opts_target(8_000_000)
1447        };
1448        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1449        let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan));
1450        let joined: Vec<String> = args
1451            .iter()
1452            .map(|a| a.to_string_lossy().into_owned())
1453            .collect();
1454        assert!(joined.contains(&"hvc1".to_string()));
1455        assert!(joined.contains(&"libx265".to_string()));
1456    }
1457}