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;
12
13use super::plan::*;
14use super::{
15    CaptureMeta, EncodePlan, Engine, EngineError, Hdr, MediaInfo, Outcome, ShrinkOpts, VideoSpec,
16};
17use crate::budget;
18use crate::detect::{detect_kind, MediaKind};
19use crate::options::AudioCodec;
20
21/// Which pass of the encode a progress update belongs to.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum PassKind {
24    Single,
25    First,
26    Second,
27}
28
29/// The ffmpeg engine for video and audio.
30#[derive(Debug, Default, Clone)]
31pub struct MediaEngine {
32    /// Stops this engine's runs (see [`MediaEngine::with_cancel`]).
33    cancel: Option<deepshrink_ffmpeg::CancelToken>,
34}
35
36impl MediaEngine {
37    pub fn new() -> Self {
38        Self::default()
39    }
40
41    /// An engine whose `run` / `estimate` stop when `cancel` is set: the running
42    /// ffmpeg is killed, the partial output removed, and the call returns an
43    /// error for which [`EngineError::is_cancelled`] is true.
44    pub fn with_cancel(cancel: deepshrink_ffmpeg::CancelToken) -> Self {
45        Self {
46            cancel: Some(cancel),
47        }
48    }
49
50    /// The located ffmpeg / ffprobe, carrying this engine's cancel token.
51    fn tools(&self) -> Result<deepshrink_ffmpeg::Tools, EngineError> {
52        let tools = deepshrink_ffmpeg::locate()?;
53        Ok(match &self.cancel {
54            Some(c) => tools.with_cancel(c.clone()),
55            None => tools,
56        })
57    }
58
59    /// Like [`Engine::run`] but reports progress: `on_progress(pass, fraction)`
60    /// is called with `fraction` in 0.0..=1.0 as each pass proceeds.
61    pub fn run_with_progress(
62        &self,
63        plan: &EncodePlan,
64        on_progress: &mut dyn FnMut(PassKind, f64),
65    ) -> Result<Outcome, EngineError> {
66        let outcome = match self.run_inner(plan, on_progress) {
67            Ok(o) => o,
68            Err(e) => {
69                // A stopped encode leaves nothing behind: no half-written file,
70                // no two-pass log.
71                if e.is_cancelled() && plan.output != plan.input {
72                    let _ = fs::remove_file(&plan.output);
73                    cleanup_passlog(&passlog_base(plan));
74                }
75                return Err(e);
76            }
77        };
78        // Keep the source's modification time too, so the result sorts next to
79        // the original (Finder, Photos imports) instead of "today".
80        if plan.spec.keep_metadata {
81            copy_mtime(&plan.input, &outcome.output);
82        }
83        Ok(outcome)
84    }
85
86    fn run_inner(
87        &self,
88        plan: &EncodePlan,
89        on_progress: &mut dyn FnMut(PassKind, f64),
90    ) -> Result<Outcome, EngineError> {
91        let tools = self.tools()?;
92        let encoder = resolve_encoder(&tools, plan)?;
93        let zscale = wants_zscale(&tools, plan);
94
95        // VMAF-targeted quality search: applies to CRF-mode video only. Size /
96        // audio / passthrough encodes keep their existing single path.
97        if let Some(target_vmaf) = plan.target_vmaf {
98            if plan.spec.video.crf.is_some() && !plan.spec.audio_only && !plan.spec.passthrough {
99                return self.run_crf_search(
100                    &tools,
101                    plan,
102                    encoder,
103                    zscale,
104                    target_vmaf,
105                    on_progress,
106                );
107            }
108        }
109
110        // "Never make it bigger" in quality mode (sizes are guaranteed by the
111        // target path already): predict a CRF video from samples and skip the
112        // encode when it won't save at least `MIN_SAVING`; after any guarded
113        // encode, keep the original if the result doesn't after all.
114        let source = if plan.guard_larger && !plan.spec.passthrough {
115            fs::metadata(&plan.input).map(|m| m.len()).unwrap_or(0)
116        } else {
117            0
118        };
119        if source > 0
120            && plan.target_vmaf.is_none()
121            && !plan.spec.audio_only
122            && plan.spec.video.crf.is_some()
123        {
124            if let Some(predicted) = predict_crf_bytes(&tools, plan, encoder, zscale) {
125                if super::not_worth_it(predicted, source) {
126                    return self.keep_original(&tools, plan, on_progress);
127                }
128            }
129        }
130
131        // A size target is a ceiling, not a quota: when the quality preset's
132        // CRF comfortably fits, encode at it instead of filling the budget.
133        let ceiling = match ceiling_fit(&tools, plan, encoder, zscale) {
134            Some((ceiling, _)) => {
135                let o = self.run_plain(&tools, &ceiling, encoder, zscale, on_progress)?;
136                // Predictions are ±5%; a miss falls back to the budgeted encode.
137                plan.target_bytes
138                    .is_some_and(|t| o.final_bytes <= t)
139                    .then_some(o)
140            }
141            None => None,
142        };
143        let mut outcome = match ceiling {
144            Some(o) => o,
145            None => self.run_plain(&tools, plan, encoder, zscale, on_progress)?,
146        };
147        if source > 0 && super::not_worth_it(outcome.final_bytes, source) {
148            let _ = fs::remove_file(&outcome.output);
149            return self.keep_original(&tools, plan, on_progress);
150        }
151
152        // Size-targeted video with `--vmaf`: encode to budget, then report the
153        // VMAF actually achieved (best effort — a failed measurement is silent).
154        if plan.target_vmaf.is_some() && !plan.spec.audio_only && !plan.spec.passthrough {
155            outcome.vmaf = self.measure_output(&tools, plan, &plan.output);
156        }
157        Ok(outcome)
158    }
159
160    /// The plain encode: two-pass (with one correction retry) or single-pass,
161    /// no VMAF handling. Returns an [`Outcome`] with `vmaf = None`.
162    fn run_plain(
163        &self,
164        tools: &deepshrink_ffmpeg::Tools,
165        plan: &EncodePlan,
166        encoder: &str,
167        zscale: bool,
168        on_progress: &mut dyn FnMut(PassKind, f64),
169    ) -> Result<Outcome, EngineError> {
170        let passlog = passlog_base(plan);
171        let total = plan.source_duration_sec;
172
173        if plan.spec.passthrough {
174            return self.run_passthrough(tools, plan, on_progress);
175        }
176
177        if plan.spec.two_pass {
178            let args1 = build_pass_args(plan, PassKind::First, &passlog, encoder, zscale);
179            tools.run_pass(&args1, total, &mut |f| on_progress(PassKind::First, f))?;
180            let args2 = build_pass_args(plan, PassKind::Second, &passlog, encoder, zscale);
181            tools.run_pass(&args2, total, &mut |f| on_progress(PassKind::Second, f))?;
182        } else {
183            let args = build_pass_args(plan, PassKind::Single, &passlog, encoder, zscale);
184            tools.run_pass(&args, total, &mut |f| on_progress(PassKind::Single, f))?;
185        }
186
187        let mut size = fs::metadata(&plan.output)?.len();
188
189        // Correction retry: if the encode overshot the target (VBV slack),
190        // scale the video bitrate down proportionally and re-run the final
191        // pass. Two-pass needs one; Apple's one-pass encoder is looser, so it
192        // gets up to three.
193        if let (Some(target), Some(mut vbps)) = (plan.target_bytes, plan.spec.video.bitrate_bps) {
194            let (tries, pass) = if plan.spec.two_pass {
195                (1, PassKind::Second)
196            } else if plan.spec.video.hardware {
197                (3, PassKind::Single)
198            } else {
199                (0, PassKind::Single)
200            };
201            for _ in 0..tries {
202                if size <= target {
203                    break;
204                }
205                let Some(corrected) = corrected_bitrate(vbps, target, size) else {
206                    break;
207                };
208                vbps = corrected;
209                let mut retry = plan.clone();
210                retry.spec.video.bitrate_bps = Some(corrected);
211                let args = build_pass_args(&retry, pass, &passlog, encoder, zscale);
212                tools.run_pass(&args, total, &mut |f| on_progress(pass, f))?;
213                size = fs::metadata(&plan.output)?.len();
214            }
215        }
216
217        cleanup_passlog(&passlog);
218        Ok(Outcome {
219            output: plan.output.clone(),
220            final_bytes: size,
221            vmaf: None,
222            already_compact: false,
223        })
224    }
225
226    /// The expected output size of `plan`, without running it — the honest
227    /// preview for a UI. Size targets and audio come straight from the plan
228    /// (pure); a quality-mode (CRF) video is predicted from short sample
229    /// encodes, like the "never bigger" guard does (~2–3 s for any length).
230    /// Compare the result with the source: at or above it, a guarded run keeps
231    /// the original (`Outcome::already_compact`). `None` if it can't be told.
232    pub fn estimate(&self, plan: &EncodePlan) -> Result<Option<u64>, EngineError> {
233        if plan.spec.passthrough {
234            return Ok(fs::metadata(&plan.input).ok().map(|m| m.len()));
235        }
236        if ceiling_plan(plan).is_some() {
237            // A size target: the budget, or less when the quality CRF fits.
238            let tools = self.tools()?;
239            let encoder = resolve_encoder(&tools, plan)?;
240            let zscale = wants_zscale(&tools, plan);
241            let fit = ceiling_fit(&tools, plan, encoder, zscale).map(|(_, bytes)| bytes);
242            return Ok(fit.or(plan.expected_bytes));
243        }
244        if let Some(bytes) = plan.expected_bytes {
245            return Ok(Some(bytes));
246        }
247        if plan.spec.audio_only || plan.spec.video.crf.is_none() {
248            return Ok(None);
249        }
250        let tools = self.tools()?;
251        let encoder = resolve_encoder(&tools, plan)?;
252        let zscale = wants_zscale(&tools, plan);
253        Ok(predict_crf_bytes(&tools, plan, encoder, zscale))
254    }
255
256    /// The guard fired: deliver the source as-is (a byte copy, in its own
257    /// container/extension) instead of a re-encode that would not be smaller.
258    fn keep_original(
259        &self,
260        tools: &deepshrink_ffmpeg::Tools,
261        plan: &EncodePlan,
262        on_progress: &mut dyn FnMut(PassKind, f64),
263    ) -> Result<Outcome, EngineError> {
264        let _ = tools; // no ffmpeg needed: the source is delivered byte-for-byte
265        let output = match plan.input.extension() {
266            Some(ext) => plan.output.with_extension(ext),
267            None => plan.output.clone(),
268        };
269        // A plain copy, not a remux: "kept as-is" must mean identical bytes (a
270        // +faststart remux came out a few KB larger than the source).
271        fs::copy(&plan.input, &output)?;
272        on_progress(PassKind::Single, 1.0);
273        Ok(Outcome {
274            final_bytes: fs::metadata(&output)?.len(),
275            output,
276            vmaf: None,
277            already_compact: true,
278        })
279    }
280
281    /// Passthrough: the source already fits, so its streams are copied as-is.
282    ///
283    /// A stream copy is normally the cheapest and safest path, but it is not
284    /// infallible — some codecs simply cannot be muxed by the container's muxer
285    /// (ffmpeg needs a parser it may not have). Since nothing is being
286    /// re-encoded here, a failed remux falls back to copying the file verbatim:
287    /// the promise of this branch is "you get your file, unchanged and within
288    /// target", and that must hold for every input.
289    fn run_passthrough(
290        &self,
291        tools: &deepshrink_ffmpeg::Tools,
292        plan: &EncodePlan,
293        on_progress: &mut dyn FnMut(PassKind, f64),
294    ) -> Result<Outcome, EngineError> {
295        // Stream copy — the video encoder is never reached.
296        let args = build_pass_args(plan, PassKind::Single, "", "copy", false);
297        let remuxed = tools.run_pass(&args, plan.source_duration_sec, &mut |f| {
298            on_progress(PassKind::Single, f)
299        });
300        if remuxed.is_err() {
301            fs::copy(&plan.input, &plan.output)?;
302            on_progress(PassKind::Single, 1.0);
303        }
304        let size = fs::metadata(&plan.output)?.len();
305        Ok(Outcome {
306            output: plan.output.clone(),
307            final_bytes: size,
308            vmaf: None,
309            already_compact: true,
310        })
311    }
312
313    /// Search CRF for the smallest output that still meets `target_vmaf`.
314    ///
315    /// Each trial is a single-pass CRF encode into `plan.output` followed by a
316    /// VMAF measurement against the source. Drives [`budget::search_crf`], so
317    /// the search algorithm itself is unit-tested separately. Falls back to a
318    /// plain encode if the source resolution is unknown (nothing to measure).
319    fn run_crf_search(
320        &self,
321        tools: &deepshrink_ffmpeg::Tools,
322        plan: &EncodePlan,
323        encoder: &str,
324        zscale: bool,
325        target_vmaf: f64,
326        on_progress: &mut dyn FnMut(PassKind, f64),
327    ) -> Result<Outcome, EngineError> {
328        let (ref_w, ref_h) = match (plan.source_width, plan.source_height) {
329            (Some(w), Some(h)) => (w, h),
330            _ => return self.run_plain(tools, plan, encoder, zscale, on_progress),
331        };
332        let ref_fps = plan.source_fps.unwrap_or(0.0);
333        let total = plan.source_duration_sec;
334        let (lo, hi) = plan.spec.video.codec.crf_search_bounds();
335        let n_threads = thread_count();
336
337        let mut err: Option<EngineError> = None;
338        let mut last_crf: Option<u8> = None;
339
340        let (chosen_crf, chosen_vmaf) = budget::search_crf(target_vmaf, lo, hi, |crf| {
341            if err.is_some() {
342                return f64::NEG_INFINITY;
343            }
344            match encode_at_crf(tools, plan, encoder, zscale, crf, total, on_progress).and_then(
345                |()| {
346                    last_crf = Some(crf);
347                    deepshrink_ffmpeg::measure_vmaf(
348                        &tools.ffmpeg,
349                        &plan.output,
350                        &plan.input,
351                        ref_w,
352                        ref_h,
353                        ref_fps,
354                        n_threads,
355                    )
356                    .map_err(EngineError::from)
357                },
358            ) {
359                Ok(v) => v,
360                Err(e) => {
361                    err = Some(e);
362                    f64::NEG_INFINITY
363                }
364            }
365        });
366        if let Some(e) = err {
367            return Err(e);
368        }
369
370        // Leave the chosen CRF on disk (the search may have ended elsewhere).
371        if last_crf != Some(chosen_crf) {
372            encode_at_crf(tools, plan, encoder, zscale, chosen_crf, total, on_progress)?;
373        }
374        let size = fs::metadata(&plan.output)?.len();
375        Ok(Outcome {
376            output: plan.output.clone(),
377            final_bytes: size,
378            vmaf: Some(chosen_vmaf),
379            already_compact: false,
380        })
381    }
382
383    /// Measure the VMAF of an encoded `output` against the plan's source.
384    /// Returns `None` on any failure or when the source dimensions are unknown.
385    fn measure_output(
386        &self,
387        tools: &deepshrink_ffmpeg::Tools,
388        plan: &EncodePlan,
389        output: &Path,
390    ) -> Option<f64> {
391        let (w, h) = (plan.source_width?, plan.source_height?);
392        let fps = plan.source_fps.unwrap_or(0.0);
393        deepshrink_ffmpeg::measure_vmaf(
394            &tools.ffmpeg,
395            output,
396            &plan.input,
397            w,
398            h,
399            fps,
400            thread_count(),
401        )
402        .ok()
403    }
404}
405
406impl Engine for MediaEngine {
407    fn supports(&self, input: &Path) -> bool {
408        matches!(detect_kind(input), MediaKind::Video | MediaKind::Audio)
409    }
410
411    fn probe(&self, input: &Path) -> Result<MediaInfo, EngineError> {
412        let tools = deepshrink_ffmpeg::locate()?;
413        let p = deepshrink_ffmpeg::probe(&tools.ffprobe, input)?;
414
415        let video = p.video_stream();
416        let audio = p.audio_stream();
417        // Prefer ffprobe's reported size; fall back to the filesystem.
418        let size_bytes = p
419            .size_bytes()
420            .or_else(|| fs::metadata(input).ok().map(|m| m.len()))
421            .unwrap_or(0);
422
423        Ok(MediaInfo {
424            path: input.to_path_buf(),
425            kind: detect_kind(input),
426            duration_sec: p.duration_sec().unwrap_or(0.0),
427            size_bytes,
428            // As shown: a phone's portrait clip is stored landscape + rotation.
429            width: video.and_then(|v| v.display_size().0),
430            height: video.and_then(|v| v.display_size().1),
431            fps: p.fps(),
432            video_codec: video.and_then(|v| v.codec_name.clone()),
433            audio_codec: audio.and_then(|a| a.codec_name.clone()),
434            audio_channels: audio.and_then(|a| a.channels),
435            audio_bitrate_bps: p.audio_bitrate_bps(),
436            capture: capture_meta(&p),
437            hdr: video
438                .and_then(|v| v.color_transfer.as_deref())
439                .and_then(Hdr::from_transfer),
440        })
441    }
442
443    fn plan(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
444        // ffmpeg is asked about Apple's encoder only when it's requested.
445        let hw = opts.hardware && opts.target_vmaf.is_none() && hardware_encoding_available();
446        super::plan::plan(info, opts, hw)
447    }
448
449    fn run(&self, plan: &EncodePlan) -> Result<Outcome, EngineError> {
450        self.run_with_progress(plan, &mut |_, _| {})
451    }
452}
453
454/// Whether this Mac can encode with Apple's hardware (VideoToolbox) with a
455/// constant-quality target: Apple Silicon and an ffmpeg with the encoders.
456/// Asked once per process (it spawns `ffmpeg -encoders`).
457pub fn hardware_encoding_available() -> bool {
458    static AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
459    *AVAILABLE.get_or_init(|| {
460        // VideoToolbox's constant quality (`-q:v`) is Apple Silicon only.
461        cfg!(all(target_os = "macos", target_arch = "aarch64"))
462            && deepshrink_ffmpeg::locate().is_ok_and(|t| {
463                deepshrink_ffmpeg::has_encoder(&t.ffmpeg, "h264_videotoolbox")
464                    && deepshrink_ffmpeg::has_encoder(&t.ffmpeg, "hevc_videotoolbox")
465            })
466    })
467}
468
469/// Best-effort: give `output` the modification time of `input`.
470fn copy_mtime(input: &Path, output: &Path) {
471    let Ok(mtime) = fs::metadata(input).and_then(|m| m.modified()) else {
472        return;
473    };
474    if let Ok(f) = fs::File::options().write(true).open(output) {
475        let _ = f.set_modified(mtime);
476    }
477}
478
479/// Read the capture metadata from the probe's container tags.
480fn capture_meta(p: &deepshrink_ffmpeg::Ffprobe) -> CaptureMeta {
481    let tag = |keys: &[&str]| {
482        keys.iter()
483            .find_map(|k| p.format_tag(k))
484            .map(str::to_string)
485    };
486    let created_local = tag(&["com.apple.quicktime.creationdate"]);
487    let created_utc = created_local
488        .as_deref()
489        .and_then(to_utc)
490        .or_else(|| tag(&["creation_time"]));
491    CaptureMeta {
492        created_utc,
493        created_local,
494        location: tag(&["com.apple.quicktime.location.ISO6709", "location"]),
495        make: tag(&["com.apple.quicktime.make", "make"]),
496        model: tag(&["com.apple.quicktime.model", "model"]),
497    }
498}
499
500/// Metadata flags for the output. Keep = map the source's global metadata,
501/// then re-state the capture tags explicitly (`-metadata k=v`): ffmpeg's own
502/// copy of an iPhone's `com.apple.quicktime.*` keys (`use_metadata_tags`) is
503/// not readable by Apple's frameworks, whereas `location` / `make` / `model` /
504/// `date` land in QuickTime user data (©xyz, ©mak, …) that Photos and Finder
505/// read, and `creation_time` sets the movie header. Strip = drop it all.
506fn metadata_args(plan: &EncodePlan) -> (Vec<OsString>, Option<&'static str>) {
507    if !plan.spec.keep_metadata {
508        return (vec!["-map_metadata".into(), "-1".into()], None);
509    }
510    let mut a: Vec<OsString> = vec!["-map_metadata".into(), "0".into()];
511    for (k, v) in &plan.spec.tags {
512        a.push("-metadata".into());
513        a.push(format!("{k}={v}").into());
514    }
515    (a, None)
516}
517
518/// `-movflags` value combining faststart and metadata tags (None = no flag).
519fn movflags(faststart: bool, meta: Option<&'static str>) -> Option<String> {
520    let mut v = String::new();
521    if faststart {
522        v.push_str("+faststart");
523    }
524    if let Some(m) = meta {
525        v.push_str(m);
526    }
527    (!v.is_empty()).then_some(v)
528}
529
530/// [`ceiling_plan`] and its predicted size, if sample encodes say it lands
531/// comfortably under the target (the same ~2–3 s of samples as the
532/// quality-mode preview).
533fn ceiling_fit(
534    tools: &deepshrink_ffmpeg::Tools,
535    plan: &EncodePlan,
536    encoder: &str,
537    zscale: bool,
538) -> Option<(EncodePlan, u64)> {
539    let target = plan.target_bytes?;
540    let ceiling = ceiling_plan(plan)?;
541    let predicted = predict_crf_bytes(tools, &ceiling, encoder, zscale)?;
542    ((predicted as f64) < target as f64 * CEILING_MARGIN).then_some((ceiling, predicted))
543}
544
545/// Whether to tone-map with `zscale` — asked of ffmpeg only for a PQ source.
546fn wants_zscale(tools: &deepshrink_ffmpeg::Tools, plan: &EncodePlan) -> bool {
547    plan.spec.video.to_sdr == Some(Hdr::Pq)
548        && deepshrink_ffmpeg::has_filter(&tools.ffmpeg, "zscale")
549}
550
551/// Sample windows for [`predict_crf_bytes`]: three 3-second clips at 20/50/80%.
552const SAMPLE_SECS: f64 = 3.0;
553/// The shortest window for heavy video (4K, 60 fps): measured on a 60 s 4K60
554/// iPhone clip, 1.5 s windows predicted as well as 3 s (+3.3 % vs +3.8 %) in
555/// half the time; 1 s drifted to +7 %.
556const MIN_SAMPLE_SECS: f64 = 1.5;
557
558/// Sample window length: 3 s up to 1080p30, shorter as the pixel rate grows
559/// (4K60 → 1.5 s), so a preview of heavy video doesn't take a minute.
560fn sample_secs(plan: &EncodePlan) -> f64 {
561    const REFERENCE: f64 = 1920.0 * 1080.0 * 30.0;
562    let (w, h) = match (plan.source_width, plan.source_height) {
563        (Some(w), Some(h)) if w > 0 && h > 0 => (w as f64, h as f64),
564        _ => return SAMPLE_SECS,
565    };
566    let fps = plan
567        .source_fps
568        .filter(|f| f.is_finite() && *f > 0.0)
569        .unwrap_or(30.0);
570    (SAMPLE_SECS * REFERENCE / (w * h * fps)).clamp(MIN_SAMPLE_SECS, SAMPLE_SECS)
571}
572/// Each sample starts on a keyframe, so samples over-predict by ~8–10% (a
573/// 30 s phone clip: 20.4 MB predicted vs 18.7 MB real) — scale that back.
574const SAMPLE_BIAS: f64 = 0.92;
575
576/// Predict a CRF video encode's final size from short sample encodes (same
577/// encoder, CRF, preset, scaling, fps; audio at the planned bitrate): three
578/// 3 s windows, or the whole clip when it's under 12 s. `None` if a sample fails.
579fn predict_crf_bytes(
580    tools: &deepshrink_ffmpeg::Tools,
581    plan: &EncodePlan,
582    encoder: &str,
583    zscale: bool,
584) -> Option<u64> {
585    let duration = plan.source_duration_sec;
586    if !duration.is_finite() || duration <= 0.0 {
587        return None;
588    }
589    // Long clips: three 3 s windows. Short ones (< 12 s): the whole clip once —
590    // exact, and still cheap — so no keyframe bias to correct either.
591    let win = sample_secs(plan);
592    let (windows, bias): (Vec<(f64, f64)>, f64) = if duration >= win * 4.0 {
593        (
594            [0.2, 0.5, 0.8]
595                .iter()
596                .map(|at| ((duration * at - win / 2.0).max(0.0), win))
597                .collect(),
598            SAMPLE_BIAS,
599        )
600    } else {
601        (vec![(0.0, duration)], 1.0)
602    };
603    let mut sample = plan.clone();
604    sample.spec.audio = None;
605    sample.spec.faststart = false;
606    let mut video_bytes = 0u64;
607    let mut sampled = 0.0;
608    for (i, &(start, len)) in windows.iter().enumerate() {
609        sample.output =
610            std::env::temp_dir().join(format!("deepshrink-sample-{}-{i}.mp4", std::process::id()));
611        let mut args = build_pass_args(&sample, PassKind::Single, "", encoder, zscale);
612        let at_input = args.iter().position(|a| a == "-i")?;
613        args.splice(
614            at_input..at_input,
615            [
616                OsString::from("-ss"),
617                OsString::from(format!("{start:.2}")),
618                OsString::from("-t"),
619                OsString::from(format!("{len:.2}")),
620            ],
621        );
622        let ran = tools.run_pass(&args, len, &mut |_| {});
623        let bytes = fs::metadata(&sample.output).map(|m| m.len()).ok();
624        let _ = fs::remove_file(&sample.output);
625        video_bytes += bytes.filter(|_| ran.is_ok())?;
626        sampled += len;
627    }
628    let video_bps = video_bytes as f64 * 8.0 / sampled * bias;
629    let audio_bps = plan.spec.audio.as_ref().map(|a| a.bitrate_bps).unwrap_or(0) as f64;
630    Some(((video_bps + audio_bps) * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD)) as u64)
631}
632
633/// The `-vf` chain: downscale first (fewer pixels to convert), then HDR → SDR.
634///
635/// HLG (phones) was designed to stay watchable as SDR: `colorspace` re-maps
636/// BT.2020 → BT.709 reading the HLG curve as the BT.2020 gamma — side by side
637/// with an iPhone clip it's the closest match to what macOS itself shows, and
638/// it works in every ffmpeg build. PQ (HDR10) needs a real tone-map, which
639/// takes `zscale` (libzimg — in the app's bundled ffmpeg, not in every build);
640/// without it PQ falls back to `colorspace` too: flatter, but 8-bit SDR that plays.
641fn video_filters(video: &VideoSpec, zscale: bool) -> Option<String> {
642    const COLORSPACE: &str = "colorspace=all=bt709:iall=bt2020:itrc=bt2020-10:format=yuv420p";
643    let mut chain = Vec::new();
644    // The cap is the short side: the width of a portrait video.
645    if let Some(h) = video.height {
646        chain.push(if video.portrait {
647            format!("scale={h}:-2")
648        } else {
649            format!("scale=-2:{h}")
650        });
651    }
652    match video.to_sdr {
653        Some(Hdr::Pq) if zscale => chain.push(
654            "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,\
655             tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
656                .to_string(),
657        ),
658        Some(_) => chain.push(COLORSPACE.to_string()),
659        None => {}
660    }
661    (!chain.is_empty()).then(|| chain.join(","))
662}
663
664/// Base path for ffmpeg's two-pass log, unique per process + input stem.
665fn passlog_base(plan: &EncodePlan) -> String {
666    let stem = plan
667        .input
668        .file_stem()
669        .map(|s| s.to_string_lossy().into_owned())
670        .unwrap_or_else(|| "ds".to_string());
671    let dir = std::env::temp_dir();
672    dir.join(format!("deepshrink-{}-{}", std::process::id(), stem))
673        .to_string_lossy()
674        .into_owned()
675}
676
677/// Remove the files ffmpeg leaves behind for `-passlogfile <base>`.
678fn cleanup_passlog(base: &str) {
679    for suffix in ["-0.log", "-0.log.mbtree"] {
680        let _ = fs::remove_file(format!("{base}{suffix}"));
681    }
682}
683
684/// Encode a single-pass CRF trial into `plan.output` at the given CRF.
685fn encode_at_crf(
686    tools: &deepshrink_ffmpeg::Tools,
687    plan: &EncodePlan,
688    encoder: &str,
689    zscale: bool,
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, zscale);
699    tools.run_pass(&args, total, &mut |f| on_progress(PassKind::Single, f))?;
700    Ok(())
701}
702
703/// Threads to hand libvmaf (bounded by available parallelism).
704fn thread_count() -> usize {
705    std::thread::available_parallelism()
706        .map(|n| n.get())
707        .unwrap_or(1)
708}
709
710/// Platform null sink for the discard output of pass 1.
711fn null_sink() -> &'static str {
712    if cfg!(windows) {
713        "NUL"
714    } else {
715        "/dev/null"
716    }
717}
718
719/// Pick the ffmpeg encoder to drive this plan with.
720///
721/// x264/x265 are in every build worth supporting, so they're taken on faith —
722/// asking ffmpeg costs a process spawn per run. AV1 is the exception: builds
723/// disagree on which (if any) AV1 encoder they carry, so it's probed, with
724/// libaom as the fallback and a plain-English error when neither is present
725/// (better than handing the user ffmpeg's "Unknown encoder" dump).
726fn resolve_encoder(
727    tools: &deepshrink_ffmpeg::Tools,
728    plan: &EncodePlan,
729) -> Result<&'static str, EngineError> {
730    let codec = plan.spec.video.codec;
731    // Apple's hardware encoder (availability was checked when planning).
732    if plan.spec.video.hardware && !plan.spec.passthrough && !plan.spec.audio_only {
733        if let Some(hw) = codec.hardware_encoder() {
734            return Ok(hw);
735        }
736    }
737    let primary = codec.encoder();
738    let Some(fallback) = codec.fallback_encoder() else {
739        return Ok(primary);
740    };
741    // Passthrough/audio-only encodes never touch the video encoder.
742    if plan.spec.passthrough || plan.spec.audio_only {
743        return Ok(primary);
744    }
745    if deepshrink_ffmpeg::has_encoder(&tools.ffmpeg, primary) {
746        return Ok(primary);
747    }
748    if deepshrink_ffmpeg::has_encoder(&tools.ffmpeg, fallback) {
749        return Ok(fallback);
750    }
751    Err(EngineError::Unsupported(format!(
752        "this ffmpeg build has no {} encoder (looked for {primary} and {fallback})",
753        codec.label()
754    )))
755}
756
757/// Build the ffmpeg argv for one pass. Video-processing options (codec, filters,
758/// bitrate) are shared across passes; audio/output differ per pass. `encoder` is
759/// the resolved `-c:v` name (see [`resolve_encoder`]) — it can differ from the
760/// codec's default for AV1.
761fn build_pass_args(
762    plan: &EncodePlan,
763    pass: PassKind,
764    passlog: &str,
765    encoder: &str,
766    zscale: bool,
767) -> Vec<OsString> {
768    let s = &plan.spec;
769    let mut a: Vec<OsString> = Vec::new();
770    // Local helper — a macro (not a closure) so it doesn't hold a borrow of `a`
771    // across the direct `a.push(..)` calls used for OsString paths.
772    macro_rules! push {
773        ($arg:expr) => {
774            a.push(OsString::from($arg))
775        };
776    }
777
778    push!("-hide_banner");
779    push!("-y");
780    push!("-loglevel");
781    push!("error");
782    push!("-progress");
783    push!("pipe:1");
784    push!("-nostats");
785    push!("-i");
786    a.push(plan.input.clone().into_os_string());
787
788    let (meta, meta_flag) = metadata_args(plan);
789
790    // Passthrough: stream copy, no re-encode. Output only (single pass).
791    if s.passthrough {
792        push!("-c");
793        push!("copy");
794        a.extend(meta.iter().cloned());
795        if let Some(flags) = movflags(s.faststart, meta_flag) {
796            push!("-movflags");
797            push!(flags);
798        }
799        a.push(plan.output.clone().into_os_string());
800        return a;
801    }
802
803    // Pure audio: drop video, encode the audio track only (single pass).
804    if s.audio_only {
805        push!("-vn");
806        if let Some(au) = &s.audio {
807            push!("-c:a");
808            push!(au.codec.encoder());
809            if au.mono {
810                push!("-ac");
811                push!("1");
812            }
813            if let Some(sr) = au.sample_rate {
814                push!("-ar");
815                push!(sr.to_string());
816            }
817            push!("-b:a");
818            push!(au.bitrate_bps.to_string());
819            // Opus supports VBR; use constrained VBR by default for a tighter
820            // fit to the target, or full VBR when requested.
821            if matches!(au.codec, AudioCodec::Opus) {
822                push!("-vbr");
823                push!(if au.vbr { "on" } else { "constrained" });
824            }
825        }
826        a.extend(meta.iter().cloned());
827        if let Some(flags) = movflags(false, meta_flag) {
828            push!("-movflags");
829            push!(flags);
830        }
831        a.push(plan.output.clone().into_os_string());
832        return a;
833    }
834
835    // Video codec + filters.
836    push!("-c:v");
837    push!(encoder);
838    if let Some(vf) = video_filters(&s.video, zscale) {
839        push!("-vf");
840        push!(vf);
841    }
842    if let Some(f) = s.video.fps {
843        push!("-r");
844        push!(f.to_string());
845    }
846    // The speed knob is per-encoder: `-preset medium` is meaningless (and fatal)
847    // to SVT-AV1, which wants a number.
848    // VideoToolbox has no speed preset — it's fast by construction.
849    let videotoolbox = encoder.ends_with("_videotoolbox");
850    if !videotoolbox {
851        let (speed_flag, speed_value) = s.video.preset.speed_flags(encoder);
852        push!(speed_flag);
853        push!(speed_value);
854    }
855    if let Some(tag) = s.video.codec.mp4_tag() {
856        push!("-tag:v");
857        push!(tag);
858    }
859    // Tone-mapped to SDR: 8-bit, and labelled BT.709 so players don't treat
860    // it as HDR (the source's BT.2020/HLG tags would otherwise carry over).
861    if s.video.to_sdr.is_some() {
862        for (flag, value) in [
863            ("-pix_fmt", "yuv420p"),
864            ("-color_primaries", "bt709"),
865            ("-color_trc", "bt709"),
866            ("-colorspace", "bt709"),
867        ] {
868            push!(flag);
869            push!(value);
870        }
871    }
872
873    // Rate control.
874    match (s.video.bitrate_bps, s.video.crf) {
875        (Some(bps), _) => {
876            push!("-b:v");
877            push!(bps.to_string());
878            if s.two_pass {
879                push!("-pass");
880                push!(match pass {
881                    PassKind::First => "1",
882                    _ => "2",
883                });
884                push!("-passlogfile");
885                push!(passlog);
886            }
887        }
888        (_, Some(crf)) => {
889            // Apple's encoder takes a constant quality (1–100), not a CRF.
890            push!(if videotoolbox { "-q:v" } else { "-crf" });
891            push!(crf.to_string());
892        }
893        _ => {}
894    }
895
896    // Audio + output.
897    match pass {
898        PassKind::First => {
899            // Analysis pass: no audio, discard the muxed output.
900            push!("-an");
901            push!("-f");
902            push!("null");
903            push!(null_sink());
904        }
905        PassKind::Second | PassKind::Single => {
906            match &s.audio {
907                Some(au) => {
908                    push!("-c:a");
909                    push!(au.codec.encoder());
910                    // A mono downmix has to reach ffmpeg here too — the audio
911                    // track of a video is muxed in this pass, not the audio-only
912                    // branch above.
913                    if au.mono {
914                        push!("-ac");
915                        push!("1");
916                    }
917                    push!("-b:a");
918                    push!(au.bitrate_bps.to_string());
919                }
920                None => push!("-an"),
921            }
922            a.extend(meta.iter().cloned());
923            if let Some(flags) = movflags(s.faststart, meta_flag) {
924                push!("-movflags");
925                push!(flags);
926            }
927            a.push(plan.output.clone().into_os_string());
928        }
929    }
930    a
931}
932
933#[cfg(test)]
934mod tests {
935    use super::*;
936    use crate::engine::SizeGoal;
937    use crate::options::{AudioChoice, AudioCodec, QualityPreset, ResolutionOpt, VideoCodec};
938    use crate::size::preset;
939    use std::path::PathBuf;
940
941    /// The encoder `run` would resolve for a plan without probing ffmpeg (every
942    /// codec these tests use has its primary encoder everywhere).
943    fn enc(plan: &EncodePlan) -> &'static str {
944        plan.spec.video.codec.encoder()
945    }
946
947    fn video_info(duration: f64, size: u64, w: u32, h: u32, audio: bool) -> MediaInfo {
948        MediaInfo {
949            path: PathBuf::from("/tmp/clip.mp4"),
950            kind: MediaKind::Video,
951            duration_sec: duration,
952            size_bytes: size,
953            width: Some(w),
954            height: Some(h),
955            fps: Some(30.0),
956            video_codec: Some("h264".into()),
957            audio_codec: if audio { Some("aac".into()) } else { None },
958            audio_channels: if audio { Some(2) } else { None },
959            audio_bitrate_bps: None,
960            capture: CaptureMeta::default(),
961            hdr: None,
962        }
963    }
964
965    fn opts_target(bytes: u64) -> ShrinkOpts {
966        ShrinkOpts {
967            goal: SizeGoal::Target(bytes),
968            ..Default::default()
969        }
970    }
971
972    #[test]
973    fn supports_video_and_audio() {
974        let e = MediaEngine::new();
975        assert!(e.supports(&PathBuf::from("clip.mp4")));
976        assert!(e.supports(&PathBuf::from("lecture.wav")));
977        assert!(!e.supports(&PathBuf::from("photo.jpg")));
978    }
979
980    #[test]
981    fn plan_target_builds_two_pass_with_budget() {
982        let info = video_info(120.0, 300_000_000, 1920, 1080, true);
983        let plan = MediaEngine::new()
984            .plan(&info, &opts_target(8_000_000))
985            .unwrap();
986
987        assert!(plan.spec.two_pass);
988        assert_eq!(plan.target_bytes, Some(8_000_000));
989        assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
990        let vbps = plan.spec.video.bitrate_bps.unwrap();
991        assert!(vbps >= budget::ABSOLUTE_MIN_VIDEO_BPS);
992        // 8 MB over 120 s is a low budget → downscale from 1080p.
993        assert!(plan.spec.video.height.is_some());
994        assert!(plan.spec.audio.is_some());
995        // Predicted size should not exceed the target.
996        assert!(plan.expected_bytes.unwrap() <= 8_000_000 + 8_000_000 / 20);
997    }
998
999    #[test]
1000    fn plan_video_mono_downmixes_the_audio_track() {
1001        let info = video_info(60.0, 100_000_000, 1280, 720, true);
1002        let opts = ShrinkOpts {
1003            mono: true,
1004            ..opts_target(8_000_000)
1005        };
1006        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1007        let audio = plan.spec.audio.as_ref().expect("kept audio track");
1008        assert!(audio.mono, "opts.mono downmixes the video's audio track");
1009        // A stereo request stays stereo.
1010        let stereo = MediaEngine::new()
1011            .plan(&info, &opts_target(8_000_000))
1012            .unwrap();
1013        assert!(!stereo.spec.audio.as_ref().unwrap().mono);
1014    }
1015
1016    #[test]
1017    fn video_mono_reaches_ffmpeg_as_ac_1() {
1018        // The plan carrying `mono` is only half the job — the muxing pass of a
1019        // video encode has to actually emit `-ac 1`, or the output stays stereo.
1020        let info = video_info(60.0, 100_000_000, 1280, 720, true);
1021        let plan = MediaEngine::new()
1022            .plan(
1023                &info,
1024                &ShrinkOpts {
1025                    mono: true,
1026                    ..opts_target(8_000_000)
1027                },
1028            )
1029            .unwrap();
1030        let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan), false);
1031        let joined: Vec<String> = args
1032            .iter()
1033            .map(|a| a.to_string_lossy().into_owned())
1034            .collect();
1035        let ac = joined.iter().position(|a| a == "-ac").expect("-ac emitted");
1036        assert_eq!(joined[ac + 1], "1");
1037
1038        // Stereo request → no downmix flag at all.
1039        let stereo = MediaEngine::new()
1040            .plan(&info, &opts_target(8_000_000))
1041            .unwrap();
1042        let stereo_args: Vec<String> = build_pass_args(
1043            &stereo,
1044            PassKind::Second,
1045            "/tmp/passlog",
1046            enc(&stereo),
1047            false,
1048        )
1049        .iter()
1050        .map(|a| a.to_string_lossy().into_owned())
1051        .collect();
1052        assert!(!stereo_args.iter().any(|a| a == "-ac"));
1053    }
1054
1055    #[test]
1056    fn plan_preset_discord_sets_target() {
1057        let info = video_info(30.0, 50_000_000, 1280, 720, true);
1058        let opts = ShrinkOpts {
1059            goal: SizeGoal::Preset(preset("discord").unwrap()),
1060            ..Default::default()
1061        };
1062        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1063        assert_eq!(plan.target_bytes, Some(8_000_000));
1064    }
1065
1066    #[test]
1067    fn plan_reduce_targets_complement_of_original() {
1068        let info = video_info(60.0, 100_000_000, 1920, 1080, true);
1069        let opts = ShrinkOpts {
1070            goal: SizeGoal::Reduce(0.70),
1071            ..Default::default()
1072        };
1073        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1074        assert_eq!(plan.target_bytes, Some(30_000_000));
1075    }
1076
1077    #[test]
1078    fn plan_passthrough_when_source_already_fits() {
1079        // Source is 200 KB, target 1 MB → never inflate; stream-copy remux.
1080        let info = video_info(10.0, 200_000, 1280, 720, true);
1081        let plan = MediaEngine::new()
1082            .plan(&info, &opts_target(1_000_000))
1083            .unwrap();
1084        assert!(plan.spec.passthrough);
1085        assert!(!plan.spec.two_pass);
1086        assert_eq!(plan.expected_bytes, Some(200_000));
1087        let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog", enc(&plan), false);
1088        let joined: Vec<String> = args
1089            .iter()
1090            .map(|a| a.to_string_lossy().into_owned())
1091            .collect();
1092        assert!(joined.contains(&"copy".to_string()));
1093        // Same container as the source: a stream copy must land somewhere its
1094        // codecs are muxable.
1095        assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
1096    }
1097
1098    #[test]
1099    fn plan_passthrough_keeps_the_source_container() {
1100        // A .3gp may carry codecs (AMR-NB) that no .mp4 muxer accepts — copying
1101        // its streams into an .mp4 would fail on a file we aren't re-encoding.
1102        let info = MediaInfo {
1103            path: PathBuf::from("/tmp/voice.3gp"),
1104            ..video_info(10.0, 200_000, 320, 240, true)
1105        };
1106        let plan = MediaEngine::new()
1107            .plan(&info, &opts_target(1_000_000))
1108            .unwrap();
1109        assert!(plan.spec.passthrough);
1110        assert_eq!(plan.output, PathBuf::from("/tmp/voice.shrink.3gp"));
1111    }
1112
1113    #[test]
1114    fn plan_infeasible_when_target_too_small() {
1115        let info = video_info(600.0, 500_000_000, 1920, 1080, true);
1116        let err = MediaEngine::new().plan(&info, &opts_target(50_000));
1117        assert!(matches!(err, Err(EngineError::Infeasible)));
1118    }
1119
1120    #[test]
1121    fn plan_quality_mode_uses_crf_single_pass() {
1122        let info = video_info(60.0, 100_000_000, 1920, 1080, true);
1123        let opts = ShrinkOpts {
1124            goal: SizeGoal::Quality,
1125            quality: QualityPreset::Balanced,
1126            ..Default::default()
1127        };
1128        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1129        assert!(!plan.spec.two_pass);
1130        assert_eq!(plan.spec.video.crf, Some(23));
1131        assert!(plan.spec.video.bitrate_bps.is_none());
1132        assert!(plan.expected_bytes.is_none());
1133    }
1134
1135    #[test]
1136    fn plan_drops_audio_when_requested() {
1137        let info = video_info(30.0, 50_000_000, 1280, 720, true);
1138        let opts = ShrinkOpts {
1139            audio: AudioChoice::Drop,
1140            ..opts_target(8_000_000)
1141        };
1142        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1143        assert!(plan.spec.audio.is_none());
1144    }
1145
1146    fn audio_info(duration: f64, size: u64, channels: u32) -> MediaInfo {
1147        MediaInfo {
1148            path: PathBuf::from("/tmp/lecture.wav"),
1149            kind: MediaKind::Audio,
1150            duration_sec: duration,
1151            size_bytes: size,
1152            width: None,
1153            height: None,
1154            fps: None,
1155            video_codec: None,
1156            audio_codec: Some("pcm_s16le".into()),
1157            audio_channels: Some(channels),
1158            audio_bitrate_bps: None,
1159            capture: CaptureMeta::default(),
1160            hdr: None,
1161        }
1162    }
1163
1164    #[test]
1165    fn quality_audio_bitrate_follows_tier_codec_and_channels() {
1166        use QualityPreset::*;
1167        assert_eq!(quality_audio_bps(Balanced, AudioCodec::Aac, false), 128_000);
1168        assert_eq!(quality_audio_bps(Balanced, AudioCodec::Aac, true), 64_000);
1169        assert_eq!(quality_audio_bps(Fast, AudioCodec::Opus, true), 32_000);
1170        assert_eq!(quality_audio_bps(Max, AudioCodec::Mp3, false), 256_000);
1171        // Every tier is strictly smaller → larger, per codec.
1172        for c in [AudioCodec::Aac, AudioCodec::Opus, AudioCodec::Mp3] {
1173            let t: Vec<_> = [Fast, Balanced, Max]
1174                .map(|q| quality_audio_bps(q, c, false))
1175                .into();
1176            assert!(t[0] < t[1] && t[1] < t[2], "{c:?}: {t:?}");
1177        }
1178    }
1179
1180    #[test]
1181    fn a_compact_audiobook_is_kept_in_quality_mode() {
1182        // 1 h mono at 64 kbps (the review case): balanced AAC mono is 64 kbps too.
1183        let mut info = audio_info(3600.0, 64_000 / 8 * 3600, 1);
1184        info.path = PathBuf::from("/tmp/book.mp3");
1185        let plan = MediaEngine::new()
1186            .plan(&info, &ShrinkOpts::default())
1187            .unwrap();
1188        assert!(plan.spec.passthrough, "{}", plan.summary);
1189        assert!(plan.output.to_string_lossy().ends_with(".mp3"));
1190        assert!(plan.summary.contains("already compact"));
1191
1192        // A genuinely smaller recipe still encodes (Opus fast mono = 32 kbps).
1193        let smaller = ShrinkOpts {
1194            audio_codec: AudioCodec::Opus,
1195            quality: QualityPreset::Fast,
1196            ..ShrinkOpts::default()
1197        };
1198        let plan = MediaEngine::new().plan(&info, &smaller).unwrap();
1199        assert!(!plan.spec.passthrough);
1200        assert_eq!(plan.spec.audio.as_ref().unwrap().bitrate_bps, 32_000);
1201        assert!(
1202            plan.guard_larger,
1203            "quality mode keeps the post-encode check"
1204        );
1205
1206        // Opting out re-encodes at the tier bitrate.
1207        let allow = ShrinkOpts {
1208            allow_larger: true,
1209            ..ShrinkOpts::default()
1210        };
1211        let plan = MediaEngine::new().plan(&info, &allow).unwrap();
1212        assert!(!plan.spec.passthrough && !plan.guard_larger);
1213    }
1214
1215    #[test]
1216    fn the_guard_is_for_quality_mode_only() {
1217        let info = video_info(60.0, 50_000_000, 1920, 1080, true);
1218        let quality = MediaEngine::new()
1219            .plan(&info, &ShrinkOpts::default())
1220            .unwrap();
1221        assert!(quality.guard_larger);
1222        let target = MediaEngine::new()
1223            .plan(&info, &opts_target(10_000_000))
1224            .unwrap();
1225        assert!(!target.guard_larger, "a size target is its own guarantee");
1226    }
1227
1228    #[test]
1229    fn plan_audio_single_pass_with_fitted_bitrate() {
1230        // 58 min stereo lecture, target 10 MB.
1231        let info = audio_info(3480.0, 600_000_000, 2);
1232        let plan = MediaEngine::new()
1233            .plan(&info, &opts_target(10_000_000))
1234            .unwrap();
1235        assert!(plan.spec.audio_only);
1236        assert!(!plan.spec.two_pass);
1237        assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.m4a"));
1238        let au = plan.spec.audio.as_ref().unwrap();
1239        // Snapped down to a standard step, never above the raw budget.
1240        assert!(budget::AUDIO_STEPS.contains(&au.bitrate_bps));
1241        assert!(plan.expected_bytes.unwrap() <= 10_000_000 + 10_000_000 / 20);
1242    }
1243
1244    #[test]
1245    fn plan_audio_mono_source_marked_speech() {
1246        let info = audio_info(600.0, 100_000_000, 1);
1247        let plan = MediaEngine::new()
1248            .plan(&info, &opts_target(5_000_000))
1249            .unwrap();
1250        assert!(plan.spec.audio.as_ref().unwrap().mono);
1251    }
1252
1253    #[test]
1254    fn plan_audio_opus_extension_and_vbr_args() {
1255        let info = audio_info(600.0, 100_000_000, 2);
1256        let opts = ShrinkOpts {
1257            audio_codec: AudioCodec::Opus,
1258            mono: true,
1259            ..opts_target(3_000_000)
1260        };
1261        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1262        assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.opus"));
1263        let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog", enc(&plan), false);
1264        let j: Vec<String> = args
1265            .iter()
1266            .map(|a| a.to_string_lossy().into_owned())
1267            .collect();
1268        assert!(j.contains(&"-vn".to_string()));
1269        assert!(j.contains(&"libopus".to_string()));
1270        assert!(j.contains(&"-ac".to_string())); // mono downmix
1271        assert!(j.contains(&"-vbr".to_string()));
1272    }
1273
1274    #[test]
1275    fn plan_audio_infeasible_when_target_tiny() {
1276        let info = audio_info(3600.0, 500_000_000, 2);
1277        assert!(matches!(
1278            MediaEngine::new().plan(&info, &opts_target(1_000)),
1279            Err(EngineError::Infeasible)
1280        ));
1281    }
1282
1283    #[test]
1284    fn plan_audio_passthrough_when_source_fits() {
1285        let info = audio_info(600.0, 2_000_000, 2);
1286        let plan = MediaEngine::new()
1287            .plan(&info, &opts_target(10_000_000))
1288            .unwrap();
1289        assert!(plan.spec.passthrough);
1290        // Passthrough keeps the source container/extension.
1291        assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.wav"));
1292    }
1293
1294    #[test]
1295    fn pass1_args_have_no_audio_and_null_sink() {
1296        let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1297        let plan = MediaEngine::new()
1298            .plan(&info, &opts_target(8_000_000))
1299            .unwrap();
1300        let args = build_pass_args(&plan, PassKind::First, "/tmp/passlog", enc(&plan), false);
1301        let joined: Vec<String> = args
1302            .iter()
1303            .map(|a| a.to_string_lossy().into_owned())
1304            .collect();
1305        assert!(joined.contains(&"-an".to_string()));
1306        assert!(joined.contains(&"null".to_string()));
1307        assert!(joined.iter().any(|a| a == "1")); // -pass 1
1308        assert!(!joined.iter().any(|a| a.contains("shrink.mp4")));
1309    }
1310
1311    #[test]
1312    fn pass2_args_write_output_with_audio() {
1313        let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1314        let plan = MediaEngine::new()
1315            .plan(&info, &opts_target(8_000_000))
1316            .unwrap();
1317        let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan), false);
1318        let joined: Vec<String> = args
1319            .iter()
1320            .map(|a| a.to_string_lossy().into_owned())
1321            .collect();
1322        assert!(joined.iter().any(|a| a.contains("clip.shrink.mp4")));
1323        assert!(joined.contains(&"-c:a".to_string()));
1324        assert!(joined.iter().any(|a| a.contains("+faststart")));
1325        assert!(joined.iter().any(|a| a == "2")); // -pass 2
1326    }
1327
1328    fn joined(plan: &EncodePlan, pass: PassKind) -> Vec<String> {
1329        joined_with(plan, pass, false)
1330    }
1331
1332    fn joined_with(plan: &EncodePlan, pass: PassKind, zscale: bool) -> Vec<String> {
1333        build_pass_args(plan, pass, "/tmp/passlog", enc(plan), zscale)
1334            .iter()
1335            .map(|a| a.to_string_lossy().into_owned())
1336            .collect()
1337    }
1338
1339    #[test]
1340    fn hdr_transfer_is_recognised() {
1341        assert_eq!(Hdr::from_transfer("arib-std-b67"), Some(Hdr::Hlg));
1342        assert_eq!(Hdr::from_transfer("smpte2084"), Some(Hdr::Pq));
1343        assert_eq!(Hdr::from_transfer("bt709"), None);
1344    }
1345
1346    #[test]
1347    fn hdr_is_tone_mapped_to_sdr_for_size_targets_only() {
1348        let mut info = iphone_info();
1349        info.hdr = Some(Hdr::Hlg);
1350        let engine = MediaEngine::new();
1351
1352        // Discord: must play everywhere → 8-bit SDR BT.709.
1353        let target = engine.plan(&info, &opts_target(10_000_000)).unwrap();
1354        assert_eq!(target.spec.video.to_sdr, Some(Hdr::Hlg));
1355        assert!(target.summary.contains("HDR → SDR"));
1356        let vf_of =
1357            |args: &[String]| args[args.iter().position(|a| a == "-vf").unwrap() + 1].clone();
1358        // HLG: the colorspace re-map (closest to what macOS shows), any build.
1359        let hlg = joined_with(&target, PassKind::Second, true);
1360        let vf = vf_of(&hlg);
1361        assert!(
1362            vf.contains("colorspace=all=bt709") && !vf.contains("zscale"),
1363            "{vf}"
1364        );
1365        // Downscale first (a portrait clip: by its width), then convert the
1366        // fewer pixels.
1367        assert!(
1368            vf.find("scale=").unwrap() < vf.find("colorspace").unwrap(),
1369            "{vf}"
1370        );
1371        for pair in [
1372            ["-pix_fmt", "yuv420p"],
1373            ["-color_trc", "bt709"],
1374            ["-colorspace", "bt709"],
1375        ] {
1376            assert!(hlg.windows(2).any(|w| w == pair), "{pair:?}");
1377        }
1378        // PQ: a real tone-map with zscale, the colorspace re-map without it.
1379        let mut pq = target.clone();
1380        pq.spec.video.to_sdr = Some(Hdr::Pq);
1381        let vf = vf_of(&joined_with(&pq, PassKind::Second, true));
1382        assert!(
1383            vf.contains("tonemap=hable") && vf.contains("npl=100"),
1384            "{vf}"
1385        );
1386        let vf = vf_of(&joined_with(&pq, PassKind::Second, false));
1387        assert!(
1388            vf.contains("colorspace=all=bt709") && !vf.contains("zscale"),
1389            "{vf}"
1390        );
1391
1392        // Quality mode keeps HDR and 10-bit as shot.
1393        let quality = engine.plan(&info, &ShrinkOpts::default()).unwrap();
1394        assert_eq!(quality.spec.video.to_sdr, None);
1395        let args = joined(&quality, PassKind::Single);
1396        assert!(!args
1397            .iter()
1398            .any(|a| a == "-pix_fmt" || a.contains("colorspace")));
1399
1400        // An SDR source is left alone even with a target.
1401        let sdr = engine
1402            .plan(&iphone_info(), &opts_target(10_000_000))
1403            .unwrap();
1404        assert_eq!(sdr.spec.video.to_sdr, None);
1405        assert!(!joined(&sdr, PassKind::Second)
1406            .iter()
1407            .any(|a| a == "-pix_fmt"));
1408    }
1409
1410    #[test]
1411    fn a_size_target_is_a_ceiling_at_the_quality_crf() {
1412        let info = video_info(60.0, 200_000_000, 1920, 1080, true);
1413        let engine = MediaEngine::new();
1414        let opts = opts_target(50_000_000);
1415        let plan = engine.plan(&info, &opts).unwrap();
1416        let crf = opts.quality.default_crf(opts.video_codec);
1417        assert_eq!(plan.ceiling_crf, Some(crf));
1418
1419        let ceiling = ceiling_plan(&plan).unwrap();
1420        assert_eq!(ceiling.spec.video.crf, Some(crf));
1421        assert_eq!(ceiling.spec.video.bitrate_bps, None);
1422        assert!(!ceiling.spec.two_pass);
1423        // Same everything else: resolution, audio, output, the target itself.
1424        assert_eq!(ceiling.spec.video.height, plan.spec.video.height);
1425        assert_eq!(ceiling.spec.audio, plan.spec.audio);
1426        assert_eq!(ceiling.output, plan.output);
1427        assert_eq!(ceiling.target_bytes, plan.target_bytes);
1428
1429        // Quality mode and passthrough have no ceiling to try.
1430        let quality = engine.plan(&info, &ShrinkOpts::default()).unwrap();
1431        assert!(quality.ceiling_crf.is_none() && ceiling_plan(&quality).is_none());
1432        let fits = engine.plan(&info, &opts_target(300_000_000)).unwrap();
1433        assert!(fits.spec.passthrough && ceiling_plan(&fits).is_none());
1434    }
1435
1436    #[test]
1437    fn heavy_video_samples_shorter_windows() {
1438        let engine = MediaEngine::new();
1439        let mut info = video_info(120.0, 500_000_000, 1920, 1080, true);
1440        info.fps = Some(30.0);
1441        let plan = engine.plan(&info, &ShrinkOpts::default()).unwrap();
1442        assert_eq!(sample_secs(&plan), 3.0);
1443        info = video_info(120.0, 500_000_000, 3840, 2160, true);
1444        info.fps = Some(60.0);
1445        let plan = engine.plan(&info, &ShrinkOpts::default()).unwrap();
1446        assert_eq!(sample_secs(&plan), MIN_SAMPLE_SECS);
1447    }
1448
1449    #[test]
1450    fn apple_hardware_uses_quality_one_pass_and_no_preset() {
1451        let engine = MediaEngine::new();
1452        let info = video_info(60.0, 200_000_000, 1920, 1080, true);
1453        let mut plan = engine.plan(&info, &ShrinkOpts::default()).unwrap();
1454        // As a plan would be on an Apple Silicon Mac with `hardware: true`.
1455        plan.spec.video.hardware = true;
1456        plan.spec.video.crf = QualityPreset::Balanced.default_hw_quality(VideoCodec::H264);
1457        let args: Vec<String> =
1458            build_pass_args(&plan, PassKind::Single, "", "h264_videotoolbox", false)
1459                .iter()
1460                .map(|a| a.to_string_lossy().into_owned())
1461                .collect();
1462        assert!(args.windows(2).any(|w| w == ["-c:v", "h264_videotoolbox"]));
1463        assert!(args.windows(2).any(|w| w == ["-q:v", "66"]), "{args:?}");
1464        assert!(
1465            !args.iter().any(|a| a == "-crf" || a == "-preset"),
1466            "{args:?}"
1467        );
1468        let mut hw = plan.clone();
1469        hw.spec.passthrough = false;
1470        assert_eq!(
1471            resolve_encoder(
1472                &deepshrink_ffmpeg::Tools {
1473                    ffmpeg: "ffmpeg".into(),
1474                    ffprobe: "ffprobe".into(),
1475                    cancel: Default::default(),
1476                },
1477                &hw
1478            )
1479            .unwrap(),
1480            "h264_videotoolbox"
1481        );
1482        // AV1 has no Apple encoder: the quality map says so.
1483        assert_eq!(
1484            QualityPreset::Balanced.default_hw_quality(VideoCodec::Av1),
1485            None
1486        );
1487    }
1488
1489    #[test]
1490    fn a_portrait_video_is_capped_on_its_short_side() {
1491        let engine = MediaEngine::new();
1492        // As shown (probe applies the rotation): 2160 × 3840, portrait.
1493        let info = video_info(60.0, 200_000_000, 2160, 3840, true);
1494        let opts = ShrinkOpts {
1495            resolution: ResolutionOpt::Height(1080),
1496            ..ShrinkOpts::default()
1497        };
1498        let plan = engine.plan(&info, &opts).unwrap();
1499        assert_eq!(plan.spec.video.height, Some(1080));
1500        assert!(plan.spec.video.portrait);
1501        let args = joined(&plan, PassKind::Single);
1502        let vf = &args[args.iter().position(|a| a == "-vf").unwrap() + 1];
1503        // 1080 × 1920, not 608 × 1080.
1504        assert!(vf.starts_with("scale=1080:-2"), "{vf}");
1505
1506        // Landscape is unchanged: height is the short side.
1507        let info = video_info(60.0, 200_000_000, 3840, 2160, true);
1508        let plan = engine.plan(&info, &opts).unwrap();
1509        assert!(!plan.spec.video.portrait);
1510        let args = joined(&plan, PassKind::Single);
1511        assert!(args.iter().any(|a| a.starts_with("scale=-2:1080")));
1512    }
1513
1514    fn iphone_info() -> MediaInfo {
1515        let mut info = video_info(60.0, 50_000_000, 2160, 3840, true);
1516        info.path = PathBuf::from("/tmp/IMG_3325.MOV");
1517        info.capture = CaptureMeta {
1518            created_utc: to_utc("2026-09-26T20:01:54+0300"),
1519            created_local: Some("2026-09-26T20:01:54+0300".into()),
1520            location: Some("+50.4160+030.2796+155.635/".into()),
1521            make: Some("Apple".into()),
1522            model: Some("iPhone 12 Pro Max".into()),
1523        };
1524        info
1525    }
1526
1527    #[test]
1528    fn metadata_is_kept_by_default_and_strippable() {
1529        let info = iphone_info();
1530        let plan = MediaEngine::new()
1531            .plan(&info, &ShrinkOpts::default())
1532            .unwrap();
1533        let a = joined(&plan, PassKind::Single);
1534        let at = a.iter().position(|x| x == "-map_metadata").unwrap();
1535        assert_eq!(a[at + 1], "0");
1536        // The capture tags are re-stated explicitly — the shooting date (not
1537        // the file's export time) in UTC, and location / make / model.
1538        for tag in [
1539            "creation_time=2026-09-26T17:01:54Z",
1540            "location=+50.4160+030.2796+155.635/",
1541            "make=Apple",
1542            "model=iPhone 12 Pro Max",
1543            "date=2026-09-26T20:01:54+0300",
1544        ] {
1545            assert!(a.contains(&tag.to_string()), "{tag} in {a:?}");
1546        }
1547        assert!(a.contains(&"+faststart".to_string()));
1548
1549        let strip = ShrinkOpts {
1550            keep_metadata: false,
1551            ..ShrinkOpts::default()
1552        };
1553        let plan = MediaEngine::new().plan(&info, &strip).unwrap();
1554        let a = joined(&plan, PassKind::Single);
1555        let at = a.iter().position(|x| x == "-map_metadata").unwrap();
1556        assert_eq!(a[at + 1], "-1");
1557        assert!(!a.iter().any(|x| x.starts_with("location=")));
1558        assert!(a.contains(&"+faststart".to_string()));
1559    }
1560
1561    #[test]
1562    fn apple_local_time_converts_to_utc() {
1563        let utc = |s: &str| to_utc(s);
1564        assert_eq!(
1565            utc("2026-09-26T20:01:54+0300").as_deref(),
1566            Some("2026-09-26T17:01:54Z")
1567        );
1568        assert_eq!(
1569            utc("2026-09-26T20:01:54+03:00").as_deref(),
1570            Some("2026-09-26T17:01:54Z")
1571        );
1572        assert_eq!(
1573            utc("2026-01-01T01:30:00+0300").as_deref(),
1574            Some("2025-12-31T22:30:00Z")
1575        );
1576        assert_eq!(
1577            utc("2026-03-01T23:00:00-0500").as_deref(),
1578            Some("2026-03-02T04:00:00Z")
1579        );
1580        assert_eq!(
1581            utc("2024-02-29T12:00:00.123Z").as_deref(),
1582            Some("2024-02-29T12:00:00Z")
1583        );
1584        assert_eq!(utc("yesterday"), None);
1585    }
1586
1587    #[test]
1588    fn an_iphone_mov_stays_mov_in_quality_mode_only() {
1589        let info = iphone_info();
1590        let out = |opts: &ShrinkOpts| {
1591            let plan = MediaEngine::new().plan(&info, opts).unwrap();
1592            plan.output.to_string_lossy().into_owned()
1593        };
1594        assert!(out(&ShrinkOpts::default()).ends_with(".shrink.mov"));
1595        // Platform presets / size targets are for sharing → MP4.
1596        assert!(out(&opts_target(8_000_000)).ends_with(".shrink.mp4"));
1597        // AV1 has no QuickTime mapping → MP4.
1598        let av1 = ShrinkOpts {
1599            video_codec: VideoCodec::Av1,
1600            ..ShrinkOpts::default()
1601        };
1602        assert!(out(&av1).ends_with(".shrink.mp4"));
1603        // Non-MOV sources are unaffected.
1604        let mp4 = video_info(60.0, 50_000_000, 1920, 1080, true);
1605        let p = MediaEngine::new()
1606            .plan(&mp4, &ShrinkOpts::default())
1607            .unwrap();
1608        assert!(p.output.to_string_lossy().ends_with(".shrink.mp4"));
1609    }
1610
1611    #[test]
1612    fn a_video_audio_track_is_never_upsampled() {
1613        let mut info = video_info(60.0, 50_000_000, 1920, 1080, true);
1614        info.audio_bitrate_bps = Some(64_000);
1615        let bps_of = |info: &MediaInfo, opts: &ShrinkOpts| {
1616            let plan = MediaEngine::new().plan(info, opts).unwrap();
1617            plan.spec.audio.unwrap().bitrate_bps
1618        };
1619        // Quality mode default is 128 kbps — capped at the source's 64 kbps.
1620        assert_eq!(bps_of(&info, &ShrinkOpts::default()), 64_000);
1621        // A size target too: never above the source (the rest goes to video).
1622        assert!(bps_of(&info, &opts_target(20_000_000)) <= 64_000);
1623        // An explicit `--audio 128k` is still honoured as asked.
1624        let explicit = ShrinkOpts {
1625            audio: AudioChoice::Bitrate(128_000),
1626            ..ShrinkOpts::default()
1627        };
1628        assert_eq!(bps_of(&info, &explicit), 128_000);
1629        // Unknown source rate → the default.
1630        info.audio_bitrate_bps = None;
1631        assert_eq!(
1632            bps_of(&info, &ShrinkOpts::default()),
1633            budget::DEFAULT_AUDIO_BPS
1634        );
1635    }
1636
1637    #[test]
1638    fn h265_adds_hvc1_tag() {
1639        let info = video_info(60.0, 100_000_000, 1280, 720, false);
1640        let opts = ShrinkOpts {
1641            video_codec: VideoCodec::H265,
1642            ..opts_target(8_000_000)
1643        };
1644        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1645        let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan), false);
1646        let joined: Vec<String> = args
1647            .iter()
1648            .map(|a| a.to_string_lossy().into_owned())
1649            .collect();
1650        assert!(joined.contains(&"hvc1".to_string()));
1651        assert!(joined.contains(&"libx265".to_string()));
1652    }
1653}