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