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, CaptureMeta, EncodePlan, EncodeSpec, Engine, EngineError, Hdr, MediaInfo, Outcome,
15    ShrinkOpts, SizeGoal, VideoSpec,
16};
17use crate::budget;
18use crate::detect::{detect_kind, MediaKind};
19use crate::options::{AudioChoice, AudioCodec, FpsOpt, QualityPreset, ResolutionOpt, VideoCodec};
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)]
35pub struct MediaEngine {
36    /// Stops this engine's runs (see [`MediaEngine::with_cancel`]).
37    cancel: Option<deepshrink_ffmpeg::CancelToken>,
38}
39
40impl MediaEngine {
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// An engine whose `run` / `estimate` stop when `cancel` is set: the running
46    /// ffmpeg is killed, the partial output removed, and the call returns an
47    /// error for which [`EngineError::is_cancelled`] is true.
48    pub fn with_cancel(cancel: deepshrink_ffmpeg::CancelToken) -> Self {
49        Self {
50            cancel: Some(cancel),
51        }
52    }
53
54    /// The located ffmpeg / ffprobe, carrying this engine's cancel token.
55    fn tools(&self) -> Result<deepshrink_ffmpeg::Tools, EngineError> {
56        let tools = deepshrink_ffmpeg::locate()?;
57        Ok(match &self.cancel {
58            Some(c) => tools.with_cancel(c.clone()),
59            None => tools,
60        })
61    }
62
63    /// Like [`Engine::run`] but reports progress: `on_progress(pass, fraction)`
64    /// is called with `fraction` in 0.0..=1.0 as each pass proceeds.
65    pub fn run_with_progress(
66        &self,
67        plan: &EncodePlan,
68        on_progress: &mut dyn FnMut(PassKind, f64),
69    ) -> Result<Outcome, EngineError> {
70        let outcome = match self.run_inner(plan, on_progress) {
71            Ok(o) => o,
72            Err(e) => {
73                // A stopped encode leaves nothing behind: no half-written file,
74                // no two-pass log.
75                if e.is_cancelled() && plan.output != plan.input {
76                    let _ = fs::remove_file(&plan.output);
77                    cleanup_passlog(&passlog_base(plan));
78                }
79                return Err(e);
80            }
81        };
82        // Keep the source's modification time too, so the result sorts next to
83        // the original (Finder, Photos imports) instead of "today".
84        if plan.spec.keep_metadata {
85            copy_mtime(&plan.input, &outcome.output);
86        }
87        Ok(outcome)
88    }
89
90    fn run_inner(
91        &self,
92        plan: &EncodePlan,
93        on_progress: &mut dyn FnMut(PassKind, f64),
94    ) -> Result<Outcome, EngineError> {
95        let tools = self.tools()?;
96        let encoder = resolve_encoder(&tools, plan)?;
97        let zscale = wants_zscale(&tools, plan);
98
99        // VMAF-targeted quality search: applies to CRF-mode video only. Size /
100        // audio / passthrough encodes keep their existing single path.
101        if let Some(target_vmaf) = plan.target_vmaf {
102            if plan.spec.video.crf.is_some() && !plan.spec.audio_only && !plan.spec.passthrough {
103                return self.run_crf_search(
104                    &tools,
105                    plan,
106                    encoder,
107                    zscale,
108                    target_vmaf,
109                    on_progress,
110                );
111            }
112        }
113
114        // "Never make it bigger" in quality mode (sizes are guaranteed by the
115        // target path already): predict a CRF video from samples and skip the
116        // encode when it won't save at least `MIN_SAVING`; after any guarded
117        // encode, keep the original if the result doesn't after all.
118        let source = if plan.guard_larger && !plan.spec.passthrough {
119            fs::metadata(&plan.input).map(|m| m.len()).unwrap_or(0)
120        } else {
121            0
122        };
123        if source > 0
124            && plan.target_vmaf.is_none()
125            && !plan.spec.audio_only
126            && plan.spec.video.crf.is_some()
127        {
128            if let Some(predicted) = predict_crf_bytes(&tools, plan, encoder, zscale) {
129                if super::not_worth_it(predicted, source) {
130                    return self.keep_original(&tools, plan, on_progress);
131                }
132            }
133        }
134
135        // A size target is a ceiling, not a quota: when the quality preset's
136        // CRF comfortably fits, encode at it instead of filling the budget.
137        let ceiling = match ceiling_fit(&tools, plan, encoder, zscale) {
138            Some((ceiling, _)) => {
139                let o = self.run_plain(&tools, &ceiling, encoder, zscale, on_progress)?;
140                // Predictions are ±5%; a miss falls back to the budgeted encode.
141                plan.target_bytes
142                    .is_some_and(|t| o.final_bytes <= t)
143                    .then_some(o)
144            }
145            None => None,
146        };
147        let mut outcome = match ceiling {
148            Some(o) => o,
149            None => self.run_plain(&tools, plan, encoder, zscale, on_progress)?,
150        };
151        if source > 0 && super::not_worth_it(outcome.final_bytes, source) {
152            let _ = fs::remove_file(&outcome.output);
153            return self.keep_original(&tools, plan, on_progress);
154        }
155
156        // Size-targeted video with `--vmaf`: encode to budget, then report the
157        // VMAF actually achieved (best effort — a failed measurement is silent).
158        if plan.target_vmaf.is_some() && !plan.spec.audio_only && !plan.spec.passthrough {
159            outcome.vmaf = self.measure_output(&tools, plan, &plan.output);
160        }
161        Ok(outcome)
162    }
163
164    /// The plain encode: two-pass (with one correction retry) or single-pass,
165    /// no VMAF handling. Returns an [`Outcome`] with `vmaf = None`.
166    fn run_plain(
167        &self,
168        tools: &deepshrink_ffmpeg::Tools,
169        plan: &EncodePlan,
170        encoder: &str,
171        zscale: bool,
172        on_progress: &mut dyn FnMut(PassKind, f64),
173    ) -> Result<Outcome, EngineError> {
174        let passlog = passlog_base(plan);
175        let total = plan.source_duration_sec;
176
177        if plan.spec.passthrough {
178            return self.run_passthrough(tools, plan, on_progress);
179        }
180
181        if plan.spec.two_pass {
182            let args1 = build_pass_args(plan, PassKind::First, &passlog, encoder, zscale);
183            tools.run_pass(&args1, total, &mut |f| on_progress(PassKind::First, f))?;
184            let args2 = build_pass_args(plan, PassKind::Second, &passlog, encoder, zscale);
185            tools.run_pass(&args2, total, &mut |f| on_progress(PassKind::Second, f))?;
186        } else {
187            let args = build_pass_args(plan, PassKind::Single, &passlog, encoder, zscale);
188            tools.run_pass(&args, total, &mut |f| on_progress(PassKind::Single, f))?;
189        }
190
191        let mut size = fs::metadata(&plan.output)?.len();
192
193        // Correction retry: if the encode overshot the target (VBV slack),
194        // scale the video bitrate down proportionally and re-run the final
195        // pass. Two-pass needs one; Apple's one-pass encoder is looser, so it
196        // gets up to three.
197        if let (Some(target), Some(mut vbps)) = (plan.target_bytes, plan.spec.video.bitrate_bps) {
198            let (tries, pass) = if plan.spec.two_pass {
199                (1, PassKind::Second)
200            } else if plan.spec.video.hardware {
201                (3, PassKind::Single)
202            } else {
203                (0, PassKind::Single)
204            };
205            for _ in 0..tries {
206                if size <= target {
207                    break;
208                }
209                let corrected = (vbps as f64 * (target as f64 / size as f64) * 0.97) as u64;
210                if corrected < budget::ABSOLUTE_MIN_VIDEO_BPS {
211                    break;
212                }
213                vbps = corrected;
214                let mut retry = plan.clone();
215                retry.spec.video.bitrate_bps = Some(corrected);
216                let args = build_pass_args(&retry, pass, &passlog, encoder, zscale);
217                tools.run_pass(&args, total, &mut |f| on_progress(pass, f))?;
218                size = fs::metadata(&plan.output)?.len();
219            }
220        }
221
222        cleanup_passlog(&passlog);
223        Ok(Outcome {
224            output: plan.output.clone(),
225            final_bytes: size,
226            vmaf: None,
227            already_compact: false,
228        })
229    }
230
231    /// The expected output size of `plan`, without running it — the honest
232    /// preview for a UI. Size targets and audio come straight from the plan
233    /// (pure); a quality-mode (CRF) video is predicted from short sample
234    /// encodes, like the "never bigger" guard does (~2–3 s for any length).
235    /// Compare the result with the source: at or above it, a guarded run keeps
236    /// the original (`Outcome::already_compact`). `None` if it can't be told.
237    pub fn estimate(&self, plan: &EncodePlan) -> Result<Option<u64>, EngineError> {
238        if plan.spec.passthrough {
239            return Ok(fs::metadata(&plan.input).ok().map(|m| m.len()));
240        }
241        if ceiling_plan(plan).is_some() {
242            // A size target: the budget, or less when the quality CRF fits.
243            let tools = self.tools()?;
244            let encoder = resolve_encoder(&tools, plan)?;
245            let zscale = wants_zscale(&tools, plan);
246            let fit = ceiling_fit(&tools, plan, encoder, zscale).map(|(_, bytes)| bytes);
247            return Ok(fit.or(plan.expected_bytes));
248        }
249        if let Some(bytes) = plan.expected_bytes {
250            return Ok(Some(bytes));
251        }
252        if plan.spec.audio_only || plan.spec.video.crf.is_none() {
253            return Ok(None);
254        }
255        let tools = self.tools()?;
256        let encoder = resolve_encoder(&tools, plan)?;
257        let zscale = wants_zscale(&tools, plan);
258        Ok(predict_crf_bytes(&tools, plan, encoder, zscale))
259    }
260
261    /// The guard fired: deliver the source as-is (a byte copy, in its own
262    /// container/extension) instead of a re-encode that would not be smaller.
263    fn keep_original(
264        &self,
265        tools: &deepshrink_ffmpeg::Tools,
266        plan: &EncodePlan,
267        on_progress: &mut dyn FnMut(PassKind, f64),
268    ) -> Result<Outcome, EngineError> {
269        let _ = tools; // no ffmpeg needed: the source is delivered byte-for-byte
270        let output = match plan.input.extension() {
271            Some(ext) => plan.output.with_extension(ext),
272            None => plan.output.clone(),
273        };
274        // A plain copy, not a remux: "kept as-is" must mean identical bytes (a
275        // +faststart remux came out a few KB larger than the source).
276        fs::copy(&plan.input, &output)?;
277        on_progress(PassKind::Single, 1.0);
278        Ok(Outcome {
279            final_bytes: fs::metadata(&output)?.len(),
280            output,
281            vmaf: None,
282            already_compact: true,
283        })
284    }
285
286    /// Passthrough: the source already fits, so its streams are copied as-is.
287    ///
288    /// A stream copy is normally the cheapest and safest path, but it is not
289    /// infallible — some codecs simply cannot be muxed by the container's muxer
290    /// (ffmpeg needs a parser it may not have). Since nothing is being
291    /// re-encoded here, a failed remux falls back to copying the file verbatim:
292    /// the promise of this branch is "you get your file, unchanged and within
293    /// target", and that must hold for every input.
294    fn run_passthrough(
295        &self,
296        tools: &deepshrink_ffmpeg::Tools,
297        plan: &EncodePlan,
298        on_progress: &mut dyn FnMut(PassKind, f64),
299    ) -> Result<Outcome, EngineError> {
300        // Stream copy — the video encoder is never reached.
301        let args = build_pass_args(plan, PassKind::Single, "", "copy", false);
302        let remuxed = tools.run_pass(&args, plan.source_duration_sec, &mut |f| {
303            on_progress(PassKind::Single, f)
304        });
305        if remuxed.is_err() {
306            fs::copy(&plan.input, &plan.output)?;
307            on_progress(PassKind::Single, 1.0);
308        }
309        let size = fs::metadata(&plan.output)?.len();
310        Ok(Outcome {
311            output: plan.output.clone(),
312            final_bytes: size,
313            vmaf: None,
314            already_compact: true,
315        })
316    }
317
318    /// Search CRF for the smallest output that still meets `target_vmaf`.
319    ///
320    /// Each trial is a single-pass CRF encode into `plan.output` followed by a
321    /// VMAF measurement against the source. Drives [`budget::search_crf`], so
322    /// the search algorithm itself is unit-tested separately. Falls back to a
323    /// plain encode if the source resolution is unknown (nothing to measure).
324    fn run_crf_search(
325        &self,
326        tools: &deepshrink_ffmpeg::Tools,
327        plan: &EncodePlan,
328        encoder: &str,
329        zscale: bool,
330        target_vmaf: f64,
331        on_progress: &mut dyn FnMut(PassKind, f64),
332    ) -> Result<Outcome, EngineError> {
333        let (ref_w, ref_h) = match (plan.source_width, plan.source_height) {
334            (Some(w), Some(h)) => (w, h),
335            _ => return self.run_plain(tools, plan, encoder, zscale, on_progress),
336        };
337        let ref_fps = plan.source_fps.unwrap_or(0.0);
338        let total = plan.source_duration_sec;
339        let (lo, hi) = plan.spec.video.codec.crf_search_bounds();
340        let n_threads = thread_count();
341
342        let mut err: Option<EngineError> = None;
343        let mut last_crf: Option<u8> = None;
344
345        let (chosen_crf, chosen_vmaf) = budget::search_crf(target_vmaf, lo, hi, |crf| {
346            if err.is_some() {
347                return f64::NEG_INFINITY;
348            }
349            match encode_at_crf(tools, plan, encoder, zscale, crf, total, on_progress).and_then(
350                |()| {
351                    last_crf = Some(crf);
352                    deepshrink_ffmpeg::measure_vmaf(
353                        &tools.ffmpeg,
354                        &plan.output,
355                        &plan.input,
356                        ref_w,
357                        ref_h,
358                        ref_fps,
359                        n_threads,
360                    )
361                    .map_err(EngineError::from)
362                },
363            ) {
364                Ok(v) => v,
365                Err(e) => {
366                    err = Some(e);
367                    f64::NEG_INFINITY
368                }
369            }
370        });
371        if let Some(e) = err {
372            return Err(e);
373        }
374
375        // Leave the chosen CRF on disk (the search may have ended elsewhere).
376        if last_crf != Some(chosen_crf) {
377            encode_at_crf(tools, plan, encoder, zscale, chosen_crf, total, on_progress)?;
378        }
379        let size = fs::metadata(&plan.output)?.len();
380        Ok(Outcome {
381            output: plan.output.clone(),
382            final_bytes: size,
383            vmaf: Some(chosen_vmaf),
384            already_compact: false,
385        })
386    }
387
388    /// Measure the VMAF of an encoded `output` against the plan's source.
389    /// Returns `None` on any failure or when the source dimensions are unknown.
390    fn measure_output(
391        &self,
392        tools: &deepshrink_ffmpeg::Tools,
393        plan: &EncodePlan,
394        output: &Path,
395    ) -> Option<f64> {
396        let (w, h) = (plan.source_width?, plan.source_height?);
397        let fps = plan.source_fps.unwrap_or(0.0);
398        deepshrink_ffmpeg::measure_vmaf(
399            &tools.ffmpeg,
400            output,
401            &plan.input,
402            w,
403            h,
404            fps,
405            thread_count(),
406        )
407        .ok()
408    }
409
410    /// Plan a pure-audio encode (single pass, codec + fitted bitrate).
411    fn plan_audio(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
412        let duration = info.duration_sec;
413        if !duration.is_finite() || duration <= 0.0 {
414            return Err(EngineError::Unsupported(format!(
415                "could not determine duration of {}",
416                info.path.display()
417            )));
418        }
419        let codec = opts.audio_codec;
420        let target = target_bytes(&opts.goal, info.size_bytes);
421
422        // "Never make it bigger": stream-copy remux when the source already fits.
423        if let Some(tb) = target {
424            if info.size_bytes > 0 && info.size_bytes <= tb {
425                let src_ext = info
426                    .path
427                    .extension()
428                    .and_then(|e| e.to_str())
429                    .unwrap_or("audio");
430                let output = opts
431                    .output
432                    .clone()
433                    .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
434                return Ok(passthrough_plan(
435                    info,
436                    output,
437                    tb,
438                    false,
439                    opts.keep_metadata,
440                ));
441            }
442        }
443
444        // Mono for speech: explicit flag, or a single-channel source.
445        let mono = opts.mono || info.audio_channels == Some(1);
446
447        let (bitrate_bps, expected_bytes) = match target {
448            Some(tb) => {
449                let raw = budget::audio_bitrate_bps(tb, duration).ok_or(EngineError::Infeasible)?;
450                if raw < budget::ABSOLUTE_MIN_AUDIO_BPS {
451                    return Err(EngineError::Infeasible);
452                }
453                let bps = budget::snap_audio_bitrate(raw);
454                let predicted = (bps as f64 * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD))
455                    .round() as u64;
456                (bps, Some(predicted))
457            }
458            None => {
459                // Quality mode: per tier, codec and channel count.
460                let bps = quality_audio_bps(opts.quality, codec, mono);
461                // Never re-encode lossy audio at (nearly) its own bitrate or
462                // above: that is only generation loss, often a bigger file (a
463                // 64 kbps MP3 audiobook → 160 kbps AAC doubled it). Keep it.
464                if !opts.allow_larger && already_compact_audio(bps, info) {
465                    let src_ext = info
466                        .path
467                        .extension()
468                        .and_then(|e| e.to_str())
469                        .unwrap_or("audio");
470                    let output = opts
471                        .output
472                        .clone()
473                        .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
474                    let mut plan =
475                        passthrough_plan(info, output, info.size_bytes, false, opts.keep_metadata);
476                    plan.summary =
477                        "stream copy (already compact — a re-encode would not be smaller)".into();
478                    return Ok(plan);
479                }
480                // Constant-bitrate estimate (VBR lands close enough for a preview).
481                let predicted = (bps as f64 * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD))
482                    .round() as u64;
483                (bps, Some(predicted))
484            }
485        };
486
487        let audio = AudioSpec {
488            codec,
489            bitrate_bps,
490            mono,
491            sample_rate: opts.sample_rate,
492            vbr: opts.vbr,
493        };
494        let output = opts
495            .output
496            .clone()
497            .unwrap_or_else(|| output_with_ext(&info.path, codec.extension()));
498        let summary = build_audio_summary(&audio, info.audio_channels);
499
500        Ok(EncodePlan {
501            input: info.path.clone(),
502            output,
503            summary,
504            expected_bytes,
505            target_bytes: target,
506            target_vmaf: None,
507            source_duration_sec: duration,
508            source_width: info.width,
509            source_height: info.height,
510            source_fps: info.fps,
511            spec: EncodeSpec {
512                video: placeholder_video_spec(),
513                audio: Some(audio),
514                faststart: false,
515                two_pass: false,
516                passthrough: false,
517                audio_only: true,
518                dpi: None,
519                keep_metadata: opts.keep_metadata,
520                tags: capture_tags(info, opts.keep_metadata),
521            },
522            // A size target is its own guarantee; quality mode gets the guard.
523            guard_larger: target.is_none() && !opts.allow_larger,
524            ceiling_crf: None,
525        })
526    }
527}
528
529impl Engine for MediaEngine {
530    fn supports(&self, input: &Path) -> bool {
531        matches!(detect_kind(input), MediaKind::Video | MediaKind::Audio)
532    }
533
534    fn probe(&self, input: &Path) -> Result<MediaInfo, EngineError> {
535        let tools = deepshrink_ffmpeg::locate()?;
536        let p = deepshrink_ffmpeg::probe(&tools.ffprobe, input)?;
537
538        let video = p.video_stream();
539        let audio = p.audio_stream();
540        // Prefer ffprobe's reported size; fall back to the filesystem.
541        let size_bytes = p
542            .size_bytes()
543            .or_else(|| fs::metadata(input).ok().map(|m| m.len()))
544            .unwrap_or(0);
545
546        Ok(MediaInfo {
547            path: input.to_path_buf(),
548            kind: detect_kind(input),
549            duration_sec: p.duration_sec().unwrap_or(0.0),
550            size_bytes,
551            // As shown: a phone's portrait clip is stored landscape + rotation.
552            width: video.and_then(|v| v.display_size().0),
553            height: video.and_then(|v| v.display_size().1),
554            fps: p.fps(),
555            video_codec: video.and_then(|v| v.codec_name.clone()),
556            audio_codec: audio.and_then(|a| a.codec_name.clone()),
557            audio_channels: audio.and_then(|a| a.channels),
558            audio_bitrate_bps: p.audio_bitrate_bps(),
559            capture: capture_meta(&p),
560            hdr: video
561                .and_then(|v| v.color_transfer.as_deref())
562                .and_then(Hdr::from_transfer),
563        })
564    }
565
566    fn plan(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
567        match info.kind {
568            MediaKind::Audio => return self.plan_audio(info, opts),
569            MediaKind::Unsupported => {
570                return Err(EngineError::Unsupported(format!(
571                    "{} is not a supported media file",
572                    info.path.display()
573                )))
574            }
575            MediaKind::Video => {}
576        }
577        let duration = info.duration_sec;
578        if !duration.is_finite() || duration <= 0.0 {
579            return Err(EngineError::Unsupported(format!(
580                "could not determine duration of {}",
581                info.path.display()
582            )));
583        }
584        // Resolution caps apply to the short side (portrait video included).
585        let (w, h) = (info.width.unwrap_or(0), info.height.unwrap_or(0));
586        let portrait = h > w;
587        let src_height = w.min(h);
588
589        let target = target_bytes(&opts.goal, info.size_bytes);
590        let output = opts
591            .output
592            .clone()
593            .unwrap_or_else(|| output_with_ext(&info.path, video_container(info, target, opts)));
594
595        // "Never make it bigger": if the source already fits the target, just
596        // remux (stream copy) instead of re-encoding it up to the target. The
597        // copy stays in the *source* container — an .mp4 cannot hold every codec
598        // a source may carry (an AMR-NB track from a .3gp, say), and a stream
599        // copy must not be the thing that breaks a file we aren't even re-encoding.
600        if let Some(tb) = target {
601            if info.size_bytes > 0 && info.size_bytes <= tb {
602                let src_ext = info
603                    .path
604                    .extension()
605                    .and_then(|e| e.to_str())
606                    .unwrap_or("mp4");
607                let output = opts
608                    .output
609                    .clone()
610                    .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
611                return Ok(passthrough_plan(info, output, tb, true, opts.keep_metadata));
612            }
613        }
614
615        let audio = decide_audio(
616            opts,
617            info.has_audio(),
618            info.audio_bitrate_bps,
619            target,
620            duration,
621        )?;
622        let audio_bps = audio.as_ref().map(|a| a.bitrate_bps).unwrap_or(0);
623
624        // Apple's hardware encoder, when asked for and present. Not for a VMAF
625        // search (its CRF bounds are the software encoder's).
626        let hw_quality = opts
627            .quality
628            .default_hw_quality(opts.video_codec)
629            .filter(|_| {
630                opts.hardware && opts.target_vmaf.is_none() && hardware_encoding_available()
631            });
632        let hardware = hw_quality.is_some();
633        // The quality value for this encoder: CRF, or VideoToolbox's `-q:v`.
634        let quality_value =
635            hw_quality.unwrap_or_else(|| opts.quality.default_crf(opts.video_codec));
636
637        let (video, expected_bytes) = if let Some(tb) = target {
638            let vbps = budget::video_bitrate_bps(tb, duration, audio_bps)
639                .filter(|&b| b >= budget::ABSOLUTE_MIN_VIDEO_BPS)
640                .ok_or(EngineError::Infeasible)?;
641            let height = pick_height(opts.resolution, src_height, vbps);
642            let predicted = ((vbps + audio_bps) as f64 * duration / 8.0
643                * (1.0 + budget::CONTAINER_OVERHEAD))
644                .round() as u64;
645            (
646                VideoSpec {
647                    codec: opts.video_codec,
648                    bitrate_bps: Some(vbps),
649                    crf: None,
650                    height,
651                    fps: pick_fps(opts.fps, info.fps),
652                    preset: opts.quality,
653                    // A size target is for sending: make it play everywhere.
654                    to_sdr: info.hdr,
655                    hardware,
656                    portrait,
657                },
658                Some(predicted),
659            )
660        } else {
661            // Quality mode: CRF, no hard size guarantee. The CRF default is
662            // codec-aware; a `--vmaf` target refines it via a search in `run`.
663            let crf = quality_value;
664            let height = match opts.resolution {
665                ResolutionOpt::Height(h) => clamp_height(h, src_height),
666                ResolutionOpt::Auto => None,
667            };
668            (
669                VideoSpec {
670                    codec: opts.video_codec,
671                    bitrate_bps: None,
672                    crf: Some(crf),
673                    height,
674                    fps: pick_fps(opts.fps, info.fps),
675                    preset: opts.quality,
676                    // Quality mode keeps HDR (and 10-bit) as shot — except
677                    // Apple's H.264, which is 8-bit only: SDR it is.
678                    to_sdr: info
679                        .hdr
680                        .filter(|_| hardware && opts.video_codec == VideoCodec::H264),
681                    hardware,
682                    portrait,
683                },
684                None,
685            )
686        };
687
688        // Two-pass is how a bitrate budget is actually hit; the caller can force
689        // it off (faster, looser) but can't force it on in CRF mode, where there
690        // is no budget for a first pass to measure.
691        // Apple's encoder has no two-pass: it hits a budget in one (with the
692        // overshoot retry in `run`).
693        let two_pass = video.bitrate_bps.is_some() && opts.two_pass.unwrap_or(true) && !hardware;
694        let summary = build_summary(&video, audio.as_ref(), two_pass);
695
696        Ok(EncodePlan {
697            input: info.path.clone(),
698            output,
699            summary,
700            expected_bytes,
701            target_bytes: target,
702            target_vmaf: opts.target_vmaf,
703            source_duration_sec: duration,
704            source_width: info.width,
705            source_height: info.height,
706            source_fps: info.fps,
707            spec: EncodeSpec {
708                video,
709                audio,
710                faststart: true,
711                two_pass,
712                passthrough: false,
713                audio_only: false,
714                dpi: None,
715                keep_metadata: opts.keep_metadata,
716                tags: capture_tags(info, opts.keep_metadata),
717            },
718            // A size target is its own guarantee; quality mode gets the guard.
719            guard_larger: target.is_none() && !opts.allow_larger,
720            ceiling_crf: target.map(|_| quality_value),
721        })
722    }
723
724    fn run(&self, plan: &EncodePlan) -> Result<Outcome, EngineError> {
725        self.run_with_progress(plan, &mut |_, _| {})
726    }
727}
728
729/// A placeholder video spec — ignored while `passthrough`/`audio_only` is set.
730fn placeholder_video_spec() -> VideoSpec {
731    VideoSpec {
732        codec: crate::options::VideoCodec::H264,
733        bitrate_bps: None,
734        crf: None,
735        height: None,
736        fps: None,
737        preset: crate::options::QualityPreset::Balanced,
738        to_sdr: None,
739        hardware: false,
740        portrait: false,
741    }
742}
743
744/// Whether this Mac can encode with Apple's hardware (VideoToolbox) with a
745/// constant-quality target: Apple Silicon and an ffmpeg with the encoders.
746/// Asked once per process (it spawns `ffmpeg -encoders`).
747pub fn hardware_encoding_available() -> bool {
748    static AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
749    *AVAILABLE.get_or_init(|| {
750        // VideoToolbox's constant quality (`-q:v`) is Apple Silicon only.
751        cfg!(all(target_os = "macos", target_arch = "aarch64"))
752            && deepshrink_ffmpeg::locate().is_ok_and(|t| {
753                deepshrink_ffmpeg::has_encoder(&t.ffmpeg, "h264_videotoolbox")
754                    && deepshrink_ffmpeg::has_encoder(&t.ffmpeg, "hevc_videotoolbox")
755            })
756    })
757}
758
759/// A stream-copy remux plan for when the source already fits the target.
760/// `faststart` is only meaningful for MP4/MOV; pass `false` for pure audio.
761fn passthrough_plan(
762    info: &MediaInfo,
763    output: PathBuf,
764    target: u64,
765    faststart: bool,
766    keep_metadata: bool,
767) -> EncodePlan {
768    EncodePlan {
769        input: info.path.clone(),
770        output,
771        summary: "stream copy (already within target)".to_string(),
772        expected_bytes: Some(info.size_bytes),
773        target_bytes: Some(target),
774        target_vmaf: None,
775        source_duration_sec: info.duration_sec,
776        source_width: info.width,
777        source_height: info.height,
778        source_fps: info.fps,
779        spec: EncodeSpec {
780            video: placeholder_video_spec(),
781            audio: None,
782            faststart,
783            two_pass: false,
784            passthrough: true,
785            audio_only: false,
786            dpi: None,
787            keep_metadata,
788            tags: capture_tags(info, keep_metadata),
789        },
790        guard_larger: false,
791        ceiling_crf: None,
792    }
793}
794
795/// Quality-mode audio bitrate by tier, codec and channel count (mono = half).
796/// Opus needs the least for the same quality, MP3 the most.
797fn quality_audio_bps(quality: QualityPreset, codec: AudioCodec, mono: bool) -> u64 {
798    let stereo = match (codec, quality) {
799        (AudioCodec::Opus, QualityPreset::Fast) => 64_000,
800        (AudioCodec::Opus, QualityPreset::Balanced) => 96_000,
801        (AudioCodec::Opus, QualityPreset::Max) => 128_000,
802        (AudioCodec::Mp3, QualityPreset::Fast) => 128_000,
803        (AudioCodec::Mp3, QualityPreset::Balanced) => 160_000,
804        (AudioCodec::Mp3, QualityPreset::Max) => 256_000,
805        (AudioCodec::Aac, QualityPreset::Fast) => 96_000,
806        (AudioCodec::Aac, QualityPreset::Balanced) => 128_000,
807        (AudioCodec::Aac, QualityPreset::Max) => 192_000,
808    };
809    if mono {
810        stereo / 2
811    } else {
812        stereo
813    }
814}
815
816/// A pure-audio source whose own bitrate is at or under ~110% of what we'd
817/// encode at: a re-encode can't meaningfully shrink it. The source rate comes
818/// from size / duration (embedded cover art only raises it — the safe side).
819fn already_compact_audio(bps: u64, info: &MediaInfo) -> bool {
820    if info.duration_sec <= 0.0 || info.size_bytes == 0 {
821        return false;
822    }
823    let source_bps = info.size_bytes as f64 * 8.0 / info.duration_sec;
824    bps as f64 >= source_bps * 0.9
825}
826
827/// Best-effort: give `output` the modification time of `input`.
828fn copy_mtime(input: &Path, output: &Path) {
829    let Ok(mtime) = fs::metadata(input).and_then(|m| m.modified()) else {
830        return;
831    };
832    if let Ok(f) = fs::File::options().write(true).open(output) {
833        let _ = f.set_modified(mtime);
834    }
835}
836
837/// Output container for a video. A QuickTime source (an iPhone `.MOV`) stays
838/// QuickTime in quality mode: only a MOV carries its location / camera tags in
839/// a form Apple's apps read (the MP4 muxer drops them). Size targets and
840/// platform presets get MP4 — the most compatible for sharing. AV1 is always
841/// MP4 (QuickTime has no AV1 mapping).
842fn video_container(info: &MediaInfo, target: Option<u64>, opts: &ShrinkOpts) -> &'static str {
843    let mov_source = info
844        .path
845        .extension()
846        .and_then(|e| e.to_str())
847        .is_some_and(|e| e.eq_ignore_ascii_case("mov"));
848    if mov_source && target.is_none() && opts.video_codec != VideoCodec::Av1 {
849        "mov"
850    } else {
851        "mp4"
852    }
853}
854
855/// Read the capture metadata from the probe's container tags.
856fn capture_meta(p: &deepshrink_ffmpeg::Ffprobe) -> CaptureMeta {
857    let tag = |keys: &[&str]| {
858        keys.iter()
859            .find_map(|k| p.format_tag(k))
860            .map(str::to_string)
861    };
862    let created_local = tag(&["com.apple.quicktime.creationdate"]);
863    let created_utc = created_local
864        .as_deref()
865        .and_then(to_utc)
866        .or_else(|| tag(&["creation_time"]));
867    CaptureMeta {
868        created_utc,
869        created_local,
870        location: tag(&["com.apple.quicktime.location.ISO6709", "location"]),
871        make: tag(&["com.apple.quicktime.make", "make"]),
872        model: tag(&["com.apple.quicktime.model", "model"]),
873    }
874}
875
876/// The explicit output tags for `info`'s capture metadata (empty when
877/// metadata is stripped).
878fn capture_tags(info: &MediaInfo, keep: bool) -> Vec<(String, String)> {
879    if !keep {
880        return Vec::new();
881    }
882    let c = &info.capture;
883    [
884        ("creation_time", c.created_utc.as_ref()),
885        ("date", c.created_local.as_ref()),
886        ("location", c.location.as_ref()),
887        ("make", c.make.as_ref()),
888        ("model", c.model.as_ref()),
889    ]
890    .into_iter()
891    .filter_map(|(k, v)| v.map(|v| (k.to_string(), v.clone())))
892    .collect()
893}
894
895/// `2026-09-26T20:01:54+0300` (also `+03:00`, `Z`, fractional seconds) →
896/// `2026-09-26T17:01:54Z`. `None` if it doesn't parse.
897fn to_utc(s: &str) -> Option<String> {
898    let s = s.trim();
899    let num = |a: usize, b: usize| s.get(a..b)?.parse::<i64>().ok();
900    let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
901    let (h, mi, se) = (num(11, 13)?, num(14, 16)?, num(17, 19)?);
902    if s.get(4..5)? != "-" || s.get(10..11).map(|c| c == "T" || c == " ") != Some(true) {
903        return None;
904    }
905    // Offset: skip any fraction, then Z / ±HH[:]MM.
906    let rest = s
907        .get(19..)?
908        .trim_start_matches(|c: char| c == '.' || c.is_ascii_digit());
909    let offset_min = match rest {
910        "" | "Z" | "z" => 0,
911        r if r.starts_with('+') || r.starts_with('-') => {
912            let digits: String = r[1..].chars().filter(char::is_ascii_digit).collect();
913            let (oh, om) = (
914                digits.get(0..2)?.parse::<i64>().ok()?,
915                digits.get(2..4).unwrap_or("00").parse::<i64>().ok()?,
916            );
917            let m = oh * 60 + om;
918            if r.starts_with('-') {
919                -m
920            } else {
921                m
922            }
923        }
924        _ => return None,
925    };
926    let secs = days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + se - offset_min * 60;
927    let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
928    let (y, mo, d) = civil_from_days(days);
929    Some(format!(
930        "{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}Z",
931        rem / 3600,
932        rem % 3600 / 60,
933        rem % 60
934    ))
935}
936
937/// Days since 1970-01-01 for a proleptic Gregorian date (H. Hinnant).
938fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
939    let y = if m <= 2 { y - 1 } else { y };
940    let era = y.div_euclid(400);
941    let yoe = y - era * 400;
942    let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
943    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
944    era * 146_097 + doe - 719_468
945}
946
947/// Inverse of [`days_from_civil`].
948fn civil_from_days(z: i64) -> (i64, i64, i64) {
949    let z = z + 719_468;
950    let era = z.div_euclid(146_097);
951    let doe = z - era * 146_097;
952    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
953    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
954    let mp = (5 * doy + 2) / 153;
955    let d = doy - (153 * mp + 2) / 5 + 1;
956    let m = if mp < 10 { mp + 3 } else { mp - 9 };
957    (yoe + era * 400 + i64::from(m <= 2), m, d)
958}
959
960/// Metadata flags for the output. Keep = map the source's global metadata,
961/// then re-state the capture tags explicitly (`-metadata k=v`): ffmpeg's own
962/// copy of an iPhone's `com.apple.quicktime.*` keys (`use_metadata_tags`) is
963/// not readable by Apple's frameworks, whereas `location` / `make` / `model` /
964/// `date` land in QuickTime user data (©xyz, ©mak, …) that Photos and Finder
965/// read, and `creation_time` sets the movie header. Strip = drop it all.
966fn metadata_args(plan: &EncodePlan) -> (Vec<OsString>, Option<&'static str>) {
967    if !plan.spec.keep_metadata {
968        return (vec!["-map_metadata".into(), "-1".into()], None);
969    }
970    let mut a: Vec<OsString> = vec!["-map_metadata".into(), "0".into()];
971    for (k, v) in &plan.spec.tags {
972        a.push("-metadata".into());
973        a.push(format!("{k}={v}").into());
974    }
975    (a, None)
976}
977
978/// `-movflags` value combining faststart and metadata tags (None = no flag).
979fn movflags(faststart: bool, meta: Option<&'static str>) -> Option<String> {
980    let mut v = String::new();
981    if faststart {
982        v.push_str("+faststart");
983    }
984    if let Some(m) = meta {
985        v.push_str(m);
986    }
987    (!v.is_empty()).then_some(v)
988}
989
990/// How far under the target a predicted CRF encode must land to be used
991/// instead of the budget (predictions are within ~5%).
992const CEILING_MARGIN: f64 = 0.9;
993
994/// A size-target plan re-cast as a single-pass CRF encode at the quality
995/// preset's CRF ([`EncodePlan::ceiling_crf`]). `None` for anything else.
996fn ceiling_plan(plan: &EncodePlan) -> Option<EncodePlan> {
997    let crf = plan.ceiling_crf?;
998    if plan.target_bytes.is_none()
999        || plan.spec.passthrough
1000        || plan.spec.audio_only
1001        || plan.spec.video.bitrate_bps.is_none()
1002    {
1003        return None;
1004    }
1005    let mut c = plan.clone();
1006    c.spec.video.bitrate_bps = None;
1007    c.spec.video.crf = Some(crf);
1008    c.spec.two_pass = false;
1009    c.summary = build_summary(&c.spec.video, c.spec.audio.as_ref(), false);
1010    Some(c)
1011}
1012
1013/// [`ceiling_plan`] and its predicted size, if sample encodes say it lands
1014/// comfortably under the target (the same ~2–3 s of samples as the
1015/// quality-mode preview).
1016fn ceiling_fit(
1017    tools: &deepshrink_ffmpeg::Tools,
1018    plan: &EncodePlan,
1019    encoder: &str,
1020    zscale: bool,
1021) -> Option<(EncodePlan, u64)> {
1022    let target = plan.target_bytes?;
1023    let ceiling = ceiling_plan(plan)?;
1024    let predicted = predict_crf_bytes(tools, &ceiling, encoder, zscale)?;
1025    ((predicted as f64) < target as f64 * CEILING_MARGIN).then_some((ceiling, predicted))
1026}
1027
1028/// Whether to tone-map with `zscale` — asked of ffmpeg only for a PQ source.
1029fn wants_zscale(tools: &deepshrink_ffmpeg::Tools, plan: &EncodePlan) -> bool {
1030    plan.spec.video.to_sdr == Some(Hdr::Pq)
1031        && deepshrink_ffmpeg::has_filter(&tools.ffmpeg, "zscale")
1032}
1033
1034/// Sample windows for [`predict_crf_bytes`]: three 3-second clips at 20/50/80%.
1035const SAMPLE_SECS: f64 = 3.0;
1036/// The shortest window for heavy video (4K, 60 fps): measured on a 60 s 4K60
1037/// iPhone clip, 1.5 s windows predicted as well as 3 s (+3.3 % vs +3.8 %) in
1038/// half the time; 1 s drifted to +7 %.
1039const MIN_SAMPLE_SECS: f64 = 1.5;
1040
1041/// Sample window length: 3 s up to 1080p30, shorter as the pixel rate grows
1042/// (4K60 → 1.5 s), so a preview of heavy video doesn't take a minute.
1043fn sample_secs(plan: &EncodePlan) -> f64 {
1044    const REFERENCE: f64 = 1920.0 * 1080.0 * 30.0;
1045    let (w, h) = match (plan.source_width, plan.source_height) {
1046        (Some(w), Some(h)) if w > 0 && h > 0 => (w as f64, h as f64),
1047        _ => return SAMPLE_SECS,
1048    };
1049    let fps = plan
1050        .source_fps
1051        .filter(|f| f.is_finite() && *f > 0.0)
1052        .unwrap_or(30.0);
1053    (SAMPLE_SECS * REFERENCE / (w * h * fps)).clamp(MIN_SAMPLE_SECS, SAMPLE_SECS)
1054}
1055/// Each sample starts on a keyframe, so samples over-predict by ~8–10% (a
1056/// 30 s phone clip: 20.4 MB predicted vs 18.7 MB real) — scale that back.
1057const SAMPLE_BIAS: f64 = 0.92;
1058
1059/// Predict a CRF video encode's final size from short sample encodes (same
1060/// encoder, CRF, preset, scaling, fps; audio at the planned bitrate): three
1061/// 3 s windows, or the whole clip when it's under 12 s. `None` if a sample fails.
1062fn predict_crf_bytes(
1063    tools: &deepshrink_ffmpeg::Tools,
1064    plan: &EncodePlan,
1065    encoder: &str,
1066    zscale: bool,
1067) -> Option<u64> {
1068    let duration = plan.source_duration_sec;
1069    if !duration.is_finite() || duration <= 0.0 {
1070        return None;
1071    }
1072    // Long clips: three 3 s windows. Short ones (< 12 s): the whole clip once —
1073    // exact, and still cheap — so no keyframe bias to correct either.
1074    let win = sample_secs(plan);
1075    let (windows, bias): (Vec<(f64, f64)>, f64) = if duration >= win * 4.0 {
1076        (
1077            [0.2, 0.5, 0.8]
1078                .iter()
1079                .map(|at| ((duration * at - win / 2.0).max(0.0), win))
1080                .collect(),
1081            SAMPLE_BIAS,
1082        )
1083    } else {
1084        (vec![(0.0, duration)], 1.0)
1085    };
1086    let mut sample = plan.clone();
1087    sample.spec.audio = None;
1088    sample.spec.faststart = false;
1089    let mut video_bytes = 0u64;
1090    let mut sampled = 0.0;
1091    for (i, &(start, len)) in windows.iter().enumerate() {
1092        sample.output =
1093            std::env::temp_dir().join(format!("deepshrink-sample-{}-{i}.mp4", std::process::id()));
1094        let mut args = build_pass_args(&sample, PassKind::Single, "", encoder, zscale);
1095        let at_input = args.iter().position(|a| a == "-i")?;
1096        args.splice(
1097            at_input..at_input,
1098            [
1099                OsString::from("-ss"),
1100                OsString::from(format!("{start:.2}")),
1101                OsString::from("-t"),
1102                OsString::from(format!("{len:.2}")),
1103            ],
1104        );
1105        let ran = tools.run_pass(&args, len, &mut |_| {});
1106        let bytes = fs::metadata(&sample.output).map(|m| m.len()).ok();
1107        let _ = fs::remove_file(&sample.output);
1108        video_bytes += bytes.filter(|_| ran.is_ok())?;
1109        sampled += len;
1110    }
1111    let video_bps = video_bytes as f64 * 8.0 / sampled * bias;
1112    let audio_bps = plan.spec.audio.as_ref().map(|a| a.bitrate_bps).unwrap_or(0) as f64;
1113    Some(((video_bps + audio_bps) * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD)) as u64)
1114}
1115
1116/// Human-readable summary for a pure-audio plan, e.g.
1117/// "Opus · 22 kbps · mono (speech)".
1118fn build_audio_summary(audio: &AudioSpec, src_channels: Option<u32>) -> String {
1119    let mut parts = vec![
1120        audio.codec.label().to_string(),
1121        format!("{} kbps", audio.bitrate_bps / 1000),
1122    ];
1123    if audio.mono {
1124        // A single-channel source (or --mono) reads as speech.
1125        let note = if src_channels == Some(1) {
1126            "mono"
1127        } else {
1128            "mono (downmix)"
1129        };
1130        parts.push(note.to_string());
1131    }
1132    if let Some(sr) = audio.sample_rate {
1133        parts.push(format!("{} Hz", sr));
1134    }
1135    parts.join(" · ")
1136}
1137
1138/// Resolve the absolute target size (bytes) for a goal, if it imposes one.
1139fn target_bytes(goal: &SizeGoal, original: u64) -> Option<u64> {
1140    match goal {
1141        SizeGoal::Target(b) => Some(*b),
1142        SizeGoal::Reduce(f) => Some(budget::reduce_target_bytes(original, *f)),
1143        SizeGoal::Preset(p) => p.limit_bytes,
1144        SizeGoal::Quality => None,
1145    }
1146}
1147
1148/// Decide the audio track for a video encode.
1149fn decide_audio(
1150    opts: &ShrinkOpts,
1151    has_audio: bool,
1152    source_bps: Option<u64>,
1153    target: Option<u64>,
1154    duration: f64,
1155) -> Result<Option<AudioSpec>, EngineError> {
1156    if !has_audio {
1157        return Ok(None);
1158    }
1159    // A `--mono` request downmixes the kept audio track (speech clips / smaller
1160    // files). A single-channel source stays mono regardless.
1161    let mono = opts.mono;
1162    match opts.audio {
1163        AudioChoice::Drop => Ok(None),
1164        AudioChoice::Bitrate(b) => Ok(Some(AudioSpec {
1165            mono,
1166            ..AudioSpec::cbr(AudioCodec::Aac, b)
1167        })),
1168        AudioChoice::Keep => {
1169            let bps = match target {
1170                Some(tb) => budget::fit_audio_bps(tb, duration, AUDIO_LADDER)
1171                    .ok_or(EngineError::Infeasible)?,
1172                None => budget::DEFAULT_AUDIO_BPS,
1173            };
1174            // Never re-encode the track above its own bitrate: that only adds
1175            // bytes (a 64 kbps phone recording doesn't need 128 kbps AAC). Any
1176            // budget saved here goes to the video.
1177            let bps = match source_bps {
1178                Some(src) => bps.min(src.max(MIN_TRACK_BPS)),
1179                None => bps,
1180            };
1181            Ok(Some(AudioSpec {
1182                mono,
1183                ..AudioSpec::cbr(AudioCodec::Aac, bps)
1184            }))
1185        }
1186    }
1187}
1188
1189/// Floor for a capped audio track (a mis-reported tiny source rate must not
1190/// starve the audio).
1191const MIN_TRACK_BPS: u64 = 32_000;
1192
1193/// Choose the encode height in auto/explicit mode.
1194fn pick_height(res: ResolutionOpt, src_height: u32, vbps: u64) -> Option<u32> {
1195    match res {
1196        ResolutionOpt::Height(h) => clamp_height(h, src_height),
1197        ResolutionOpt::Auto => {
1198            let chosen = budget::choose_height(src_height, vbps);
1199            if src_height > 0 && chosen < src_height {
1200                Some(chosen)
1201            } else {
1202                None
1203            }
1204        }
1205    }
1206}
1207
1208/// Clamp an explicit height to the source (never upscale); `None` if it equals
1209/// the source (no scaling needed).
1210fn clamp_height(requested: u32, src_height: u32) -> Option<u32> {
1211    if src_height == 0 {
1212        return Some(requested);
1213    }
1214    let h = requested.min(src_height);
1215    if h == src_height {
1216        None
1217    } else {
1218        Some(h)
1219    }
1220}
1221
1222/// Choose an fps cap; `None` if uncapped or the cap is ≥ the source rate.
1223fn pick_fps(fps: FpsOpt, src_fps: Option<f64>) -> Option<u32> {
1224    match fps {
1225        FpsOpt::Auto => None,
1226        FpsOpt::Cap(f) => match src_fps {
1227            Some(src) if (f as f64) >= src => None,
1228            _ => Some(f),
1229        },
1230    }
1231}
1232
1233/// Default output path: `<stem>.shrink.<ext>` next to the input.
1234fn output_with_ext(input: &Path, ext: &str) -> PathBuf {
1235    let stem = input
1236        .file_stem()
1237        .map(|s| s.to_string_lossy().into_owned())
1238        .unwrap_or_else(|| "output".to_string());
1239    let mut out = input.parent().map(Path::to_path_buf).unwrap_or_default();
1240    out.push(format!("{stem}.shrink.{ext}"));
1241    out
1242}
1243
1244fn build_summary(video: &VideoSpec, audio: Option<&AudioSpec>, two_pass: bool) -> String {
1245    let mut parts = vec![if video.hardware {
1246        format!("{} (Apple hardware)", video.codec.label())
1247    } else {
1248        video.codec.label().to_string()
1249    }];
1250    match (video.bitrate_bps, video.crf) {
1251        (Some(bps), _) => parts.push(format!("up to {} kbps video", bps / 1000)),
1252        (_, Some(q)) if video.hardware => parts.push(format!("quality {q}")),
1253        (_, Some(crf)) => parts.push(format!("CRF {crf}")),
1254        _ => {}
1255    }
1256    if let Some(a) = audio {
1257        parts.push(format!("{} kbps audio", a.bitrate_bps / 1000));
1258    } else {
1259        parts.push("no audio".to_string());
1260    }
1261    if let Some(h) = video.height {
1262        parts.push(format!("{h}p"));
1263    }
1264    if let Some(f) = video.fps {
1265        parts.push(format!("{f} fps"));
1266    }
1267    if video.to_sdr.is_some() {
1268        parts.push("HDR → SDR".to_string());
1269    }
1270    parts.push(
1271        if two_pass {
1272            "two-pass"
1273        } else if video.hardware {
1274            "one pass"
1275        } else {
1276            "CRF"
1277        }
1278        .to_string(),
1279    );
1280    parts.join(" · ")
1281}
1282
1283/// The `-vf` chain: downscale first (fewer pixels to convert), then HDR → SDR.
1284///
1285/// HLG (phones) was designed to stay watchable as SDR: `colorspace` re-maps
1286/// BT.2020 → BT.709 reading the HLG curve as the BT.2020 gamma — side by side
1287/// with an iPhone clip it's the closest match to what macOS itself shows, and
1288/// it works in every ffmpeg build. PQ (HDR10) needs a real tone-map, which
1289/// takes `zscale` (libzimg — in the app's bundled ffmpeg, not in every build);
1290/// without it PQ falls back to `colorspace` too: flatter, but 8-bit SDR that plays.
1291fn video_filters(video: &VideoSpec, zscale: bool) -> Option<String> {
1292    const COLORSPACE: &str = "colorspace=all=bt709:iall=bt2020:itrc=bt2020-10:format=yuv420p";
1293    let mut chain = Vec::new();
1294    // The cap is the short side: the width of a portrait video.
1295    if let Some(h) = video.height {
1296        chain.push(if video.portrait {
1297            format!("scale={h}:-2")
1298        } else {
1299            format!("scale=-2:{h}")
1300        });
1301    }
1302    match video.to_sdr {
1303        Some(Hdr::Pq) if zscale => chain.push(
1304            "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,\
1305             tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
1306                .to_string(),
1307        ),
1308        Some(_) => chain.push(COLORSPACE.to_string()),
1309        None => {}
1310    }
1311    (!chain.is_empty()).then(|| chain.join(","))
1312}
1313
1314/// Base path for ffmpeg's two-pass log, unique per process + input stem.
1315fn passlog_base(plan: &EncodePlan) -> String {
1316    let stem = plan
1317        .input
1318        .file_stem()
1319        .map(|s| s.to_string_lossy().into_owned())
1320        .unwrap_or_else(|| "ds".to_string());
1321    let dir = std::env::temp_dir();
1322    dir.join(format!("deepshrink-{}-{}", std::process::id(), stem))
1323        .to_string_lossy()
1324        .into_owned()
1325}
1326
1327/// Remove the files ffmpeg leaves behind for `-passlogfile <base>`.
1328fn cleanup_passlog(base: &str) {
1329    for suffix in ["-0.log", "-0.log.mbtree"] {
1330        let _ = fs::remove_file(format!("{base}{suffix}"));
1331    }
1332}
1333
1334/// Encode a single-pass CRF trial into `plan.output` at the given CRF.
1335fn encode_at_crf(
1336    tools: &deepshrink_ffmpeg::Tools,
1337    plan: &EncodePlan,
1338    encoder: &str,
1339    zscale: bool,
1340    crf: u8,
1341    total: f64,
1342    on_progress: &mut dyn FnMut(PassKind, f64),
1343) -> Result<(), EngineError> {
1344    let mut trial = plan.clone();
1345    trial.spec.video.crf = Some(crf);
1346    trial.spec.video.bitrate_bps = None;
1347    trial.spec.two_pass = false;
1348    let args = build_pass_args(&trial, PassKind::Single, "", encoder, zscale);
1349    tools.run_pass(&args, total, &mut |f| on_progress(PassKind::Single, f))?;
1350    Ok(())
1351}
1352
1353/// Threads to hand libvmaf (bounded by available parallelism).
1354fn thread_count() -> usize {
1355    std::thread::available_parallelism()
1356        .map(|n| n.get())
1357        .unwrap_or(1)
1358}
1359
1360/// Platform null sink for the discard output of pass 1.
1361fn null_sink() -> &'static str {
1362    if cfg!(windows) {
1363        "NUL"
1364    } else {
1365        "/dev/null"
1366    }
1367}
1368
1369/// Pick the ffmpeg encoder to drive this plan with.
1370///
1371/// x264/x265 are in every build worth supporting, so they're taken on faith —
1372/// asking ffmpeg costs a process spawn per run. AV1 is the exception: builds
1373/// disagree on which (if any) AV1 encoder they carry, so it's probed, with
1374/// libaom as the fallback and a plain-English error when neither is present
1375/// (better than handing the user ffmpeg's "Unknown encoder" dump).
1376fn resolve_encoder(
1377    tools: &deepshrink_ffmpeg::Tools,
1378    plan: &EncodePlan,
1379) -> Result<&'static str, EngineError> {
1380    let codec = plan.spec.video.codec;
1381    // Apple's hardware encoder (availability was checked when planning).
1382    if plan.spec.video.hardware && !plan.spec.passthrough && !plan.spec.audio_only {
1383        if let Some(hw) = codec.hardware_encoder() {
1384            return Ok(hw);
1385        }
1386    }
1387    let primary = codec.encoder();
1388    let Some(fallback) = codec.fallback_encoder() else {
1389        return Ok(primary);
1390    };
1391    // Passthrough/audio-only encodes never touch the video encoder.
1392    if plan.spec.passthrough || plan.spec.audio_only {
1393        return Ok(primary);
1394    }
1395    if deepshrink_ffmpeg::has_encoder(&tools.ffmpeg, primary) {
1396        return Ok(primary);
1397    }
1398    if deepshrink_ffmpeg::has_encoder(&tools.ffmpeg, fallback) {
1399        return Ok(fallback);
1400    }
1401    Err(EngineError::Unsupported(format!(
1402        "this ffmpeg build has no {} encoder (looked for {primary} and {fallback})",
1403        codec.label()
1404    )))
1405}
1406
1407/// Build the ffmpeg argv for one pass. Video-processing options (codec, filters,
1408/// bitrate) are shared across passes; audio/output differ per pass. `encoder` is
1409/// the resolved `-c:v` name (see [`resolve_encoder`]) — it can differ from the
1410/// codec's default for AV1.
1411fn build_pass_args(
1412    plan: &EncodePlan,
1413    pass: PassKind,
1414    passlog: &str,
1415    encoder: &str,
1416    zscale: bool,
1417) -> Vec<OsString> {
1418    let s = &plan.spec;
1419    let mut a: Vec<OsString> = Vec::new();
1420    // Local helper — a macro (not a closure) so it doesn't hold a borrow of `a`
1421    // across the direct `a.push(..)` calls used for OsString paths.
1422    macro_rules! push {
1423        ($arg:expr) => {
1424            a.push(OsString::from($arg))
1425        };
1426    }
1427
1428    push!("-hide_banner");
1429    push!("-y");
1430    push!("-loglevel");
1431    push!("error");
1432    push!("-progress");
1433    push!("pipe:1");
1434    push!("-nostats");
1435    push!("-i");
1436    a.push(plan.input.clone().into_os_string());
1437
1438    let (meta, meta_flag) = metadata_args(plan);
1439
1440    // Passthrough: stream copy, no re-encode. Output only (single pass).
1441    if s.passthrough {
1442        push!("-c");
1443        push!("copy");
1444        a.extend(meta.iter().cloned());
1445        if let Some(flags) = movflags(s.faststart, meta_flag) {
1446            push!("-movflags");
1447            push!(flags);
1448        }
1449        a.push(plan.output.clone().into_os_string());
1450        return a;
1451    }
1452
1453    // Pure audio: drop video, encode the audio track only (single pass).
1454    if s.audio_only {
1455        push!("-vn");
1456        if let Some(au) = &s.audio {
1457            push!("-c:a");
1458            push!(au.codec.encoder());
1459            if au.mono {
1460                push!("-ac");
1461                push!("1");
1462            }
1463            if let Some(sr) = au.sample_rate {
1464                push!("-ar");
1465                push!(sr.to_string());
1466            }
1467            push!("-b:a");
1468            push!(au.bitrate_bps.to_string());
1469            // Opus supports VBR; use constrained VBR by default for a tighter
1470            // fit to the target, or full VBR when requested.
1471            if matches!(au.codec, AudioCodec::Opus) {
1472                push!("-vbr");
1473                push!(if au.vbr { "on" } else { "constrained" });
1474            }
1475        }
1476        a.extend(meta.iter().cloned());
1477        if let Some(flags) = movflags(false, meta_flag) {
1478            push!("-movflags");
1479            push!(flags);
1480        }
1481        a.push(plan.output.clone().into_os_string());
1482        return a;
1483    }
1484
1485    // Video codec + filters.
1486    push!("-c:v");
1487    push!(encoder);
1488    if let Some(vf) = video_filters(&s.video, zscale) {
1489        push!("-vf");
1490        push!(vf);
1491    }
1492    if let Some(f) = s.video.fps {
1493        push!("-r");
1494        push!(f.to_string());
1495    }
1496    // The speed knob is per-encoder: `-preset medium` is meaningless (and fatal)
1497    // to SVT-AV1, which wants a number.
1498    // VideoToolbox has no speed preset — it's fast by construction.
1499    let videotoolbox = encoder.ends_with("_videotoolbox");
1500    if !videotoolbox {
1501        let (speed_flag, speed_value) = s.video.preset.speed_flags(encoder);
1502        push!(speed_flag);
1503        push!(speed_value);
1504    }
1505    if let Some(tag) = s.video.codec.mp4_tag() {
1506        push!("-tag:v");
1507        push!(tag);
1508    }
1509    // Tone-mapped to SDR: 8-bit, and labelled BT.709 so players don't treat
1510    // it as HDR (the source's BT.2020/HLG tags would otherwise carry over).
1511    if s.video.to_sdr.is_some() {
1512        for (flag, value) in [
1513            ("-pix_fmt", "yuv420p"),
1514            ("-color_primaries", "bt709"),
1515            ("-color_trc", "bt709"),
1516            ("-colorspace", "bt709"),
1517        ] {
1518            push!(flag);
1519            push!(value);
1520        }
1521    }
1522
1523    // Rate control.
1524    match (s.video.bitrate_bps, s.video.crf) {
1525        (Some(bps), _) => {
1526            push!("-b:v");
1527            push!(bps.to_string());
1528            if s.two_pass {
1529                push!("-pass");
1530                push!(match pass {
1531                    PassKind::First => "1",
1532                    _ => "2",
1533                });
1534                push!("-passlogfile");
1535                push!(passlog);
1536            }
1537        }
1538        (_, Some(crf)) => {
1539            // Apple's encoder takes a constant quality (1–100), not a CRF.
1540            push!(if videotoolbox { "-q:v" } else { "-crf" });
1541            push!(crf.to_string());
1542        }
1543        _ => {}
1544    }
1545
1546    // Audio + output.
1547    match pass {
1548        PassKind::First => {
1549            // Analysis pass: no audio, discard the muxed output.
1550            push!("-an");
1551            push!("-f");
1552            push!("null");
1553            push!(null_sink());
1554        }
1555        PassKind::Second | PassKind::Single => {
1556            match &s.audio {
1557                Some(au) => {
1558                    push!("-c:a");
1559                    push!(au.codec.encoder());
1560                    // A mono downmix has to reach ffmpeg here too — the audio
1561                    // track of a video is muxed in this pass, not the audio-only
1562                    // branch above.
1563                    if au.mono {
1564                        push!("-ac");
1565                        push!("1");
1566                    }
1567                    push!("-b:a");
1568                    push!(au.bitrate_bps.to_string());
1569                }
1570                None => push!("-an"),
1571            }
1572            a.extend(meta.iter().cloned());
1573            if let Some(flags) = movflags(s.faststart, meta_flag) {
1574                push!("-movflags");
1575                push!(flags);
1576            }
1577            a.push(plan.output.clone().into_os_string());
1578        }
1579    }
1580    a
1581}
1582
1583#[cfg(test)]
1584mod tests {
1585    use super::*;
1586    use crate::options::{AudioCodec, QualityPreset, VideoCodec};
1587    use crate::size::preset;
1588
1589    /// The encoder `run` would resolve for a plan without probing ffmpeg (every
1590    /// codec these tests use has its primary encoder everywhere).
1591    fn enc(plan: &EncodePlan) -> &'static str {
1592        plan.spec.video.codec.encoder()
1593    }
1594
1595    fn video_info(duration: f64, size: u64, w: u32, h: u32, audio: bool) -> MediaInfo {
1596        MediaInfo {
1597            path: PathBuf::from("/tmp/clip.mp4"),
1598            kind: MediaKind::Video,
1599            duration_sec: duration,
1600            size_bytes: size,
1601            width: Some(w),
1602            height: Some(h),
1603            fps: Some(30.0),
1604            video_codec: Some("h264".into()),
1605            audio_codec: if audio { Some("aac".into()) } else { None },
1606            audio_channels: if audio { Some(2) } else { None },
1607            audio_bitrate_bps: None,
1608            capture: CaptureMeta::default(),
1609            hdr: None,
1610        }
1611    }
1612
1613    fn opts_target(bytes: u64) -> ShrinkOpts {
1614        ShrinkOpts {
1615            goal: SizeGoal::Target(bytes),
1616            ..Default::default()
1617        }
1618    }
1619
1620    #[test]
1621    fn supports_video_and_audio() {
1622        let e = MediaEngine::new();
1623        assert!(e.supports(&PathBuf::from("clip.mp4")));
1624        assert!(e.supports(&PathBuf::from("lecture.wav")));
1625        assert!(!e.supports(&PathBuf::from("photo.jpg")));
1626    }
1627
1628    #[test]
1629    fn plan_target_builds_two_pass_with_budget() {
1630        let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1631        let plan = MediaEngine::new()
1632            .plan(&info, &opts_target(8_000_000))
1633            .unwrap();
1634
1635        assert!(plan.spec.two_pass);
1636        assert_eq!(plan.target_bytes, Some(8_000_000));
1637        assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
1638        let vbps = plan.spec.video.bitrate_bps.unwrap();
1639        assert!(vbps >= budget::ABSOLUTE_MIN_VIDEO_BPS);
1640        // 8 MB over 120 s is a low budget → downscale from 1080p.
1641        assert!(plan.spec.video.height.is_some());
1642        assert!(plan.spec.audio.is_some());
1643        // Predicted size should not exceed the target.
1644        assert!(plan.expected_bytes.unwrap() <= 8_000_000 + 8_000_000 / 20);
1645    }
1646
1647    #[test]
1648    fn plan_video_mono_downmixes_the_audio_track() {
1649        let info = video_info(60.0, 100_000_000, 1280, 720, true);
1650        let opts = ShrinkOpts {
1651            mono: true,
1652            ..opts_target(8_000_000)
1653        };
1654        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1655        let audio = plan.spec.audio.as_ref().expect("kept audio track");
1656        assert!(audio.mono, "opts.mono downmixes the video's audio track");
1657        // A stereo request stays stereo.
1658        let stereo = MediaEngine::new()
1659            .plan(&info, &opts_target(8_000_000))
1660            .unwrap();
1661        assert!(!stereo.spec.audio.as_ref().unwrap().mono);
1662    }
1663
1664    #[test]
1665    fn video_mono_reaches_ffmpeg_as_ac_1() {
1666        // The plan carrying `mono` is only half the job — the muxing pass of a
1667        // video encode has to actually emit `-ac 1`, or the output stays stereo.
1668        let info = video_info(60.0, 100_000_000, 1280, 720, true);
1669        let plan = MediaEngine::new()
1670            .plan(
1671                &info,
1672                &ShrinkOpts {
1673                    mono: true,
1674                    ..opts_target(8_000_000)
1675                },
1676            )
1677            .unwrap();
1678        let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan), false);
1679        let joined: Vec<String> = args
1680            .iter()
1681            .map(|a| a.to_string_lossy().into_owned())
1682            .collect();
1683        let ac = joined.iter().position(|a| a == "-ac").expect("-ac emitted");
1684        assert_eq!(joined[ac + 1], "1");
1685
1686        // Stereo request → no downmix flag at all.
1687        let stereo = MediaEngine::new()
1688            .plan(&info, &opts_target(8_000_000))
1689            .unwrap();
1690        let stereo_args: Vec<String> = build_pass_args(
1691            &stereo,
1692            PassKind::Second,
1693            "/tmp/passlog",
1694            enc(&stereo),
1695            false,
1696        )
1697        .iter()
1698        .map(|a| a.to_string_lossy().into_owned())
1699        .collect();
1700        assert!(!stereo_args.iter().any(|a| a == "-ac"));
1701    }
1702
1703    #[test]
1704    fn plan_preset_discord_sets_target() {
1705        let info = video_info(30.0, 50_000_000, 1280, 720, true);
1706        let opts = ShrinkOpts {
1707            goal: SizeGoal::Preset(preset("discord").unwrap()),
1708            ..Default::default()
1709        };
1710        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1711        assert_eq!(plan.target_bytes, Some(8_000_000));
1712    }
1713
1714    #[test]
1715    fn plan_reduce_targets_complement_of_original() {
1716        let info = video_info(60.0, 100_000_000, 1920, 1080, true);
1717        let opts = ShrinkOpts {
1718            goal: SizeGoal::Reduce(0.70),
1719            ..Default::default()
1720        };
1721        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1722        assert_eq!(plan.target_bytes, Some(30_000_000));
1723    }
1724
1725    #[test]
1726    fn plan_passthrough_when_source_already_fits() {
1727        // Source is 200 KB, target 1 MB → never inflate; stream-copy remux.
1728        let info = video_info(10.0, 200_000, 1280, 720, true);
1729        let plan = MediaEngine::new()
1730            .plan(&info, &opts_target(1_000_000))
1731            .unwrap();
1732        assert!(plan.spec.passthrough);
1733        assert!(!plan.spec.two_pass);
1734        assert_eq!(plan.expected_bytes, Some(200_000));
1735        let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog", enc(&plan), false);
1736        let joined: Vec<String> = args
1737            .iter()
1738            .map(|a| a.to_string_lossy().into_owned())
1739            .collect();
1740        assert!(joined.contains(&"copy".to_string()));
1741        // Same container as the source: a stream copy must land somewhere its
1742        // codecs are muxable.
1743        assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
1744    }
1745
1746    #[test]
1747    fn plan_passthrough_keeps_the_source_container() {
1748        // A .3gp may carry codecs (AMR-NB) that no .mp4 muxer accepts — copying
1749        // its streams into an .mp4 would fail on a file we aren't re-encoding.
1750        let info = MediaInfo {
1751            path: PathBuf::from("/tmp/voice.3gp"),
1752            ..video_info(10.0, 200_000, 320, 240, true)
1753        };
1754        let plan = MediaEngine::new()
1755            .plan(&info, &opts_target(1_000_000))
1756            .unwrap();
1757        assert!(plan.spec.passthrough);
1758        assert_eq!(plan.output, PathBuf::from("/tmp/voice.shrink.3gp"));
1759    }
1760
1761    #[test]
1762    fn plan_infeasible_when_target_too_small() {
1763        let info = video_info(600.0, 500_000_000, 1920, 1080, true);
1764        let err = MediaEngine::new().plan(&info, &opts_target(50_000));
1765        assert!(matches!(err, Err(EngineError::Infeasible)));
1766    }
1767
1768    #[test]
1769    fn plan_quality_mode_uses_crf_single_pass() {
1770        let info = video_info(60.0, 100_000_000, 1920, 1080, true);
1771        let opts = ShrinkOpts {
1772            goal: SizeGoal::Quality,
1773            quality: QualityPreset::Balanced,
1774            ..Default::default()
1775        };
1776        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1777        assert!(!plan.spec.two_pass);
1778        assert_eq!(plan.spec.video.crf, Some(23));
1779        assert!(plan.spec.video.bitrate_bps.is_none());
1780        assert!(plan.expected_bytes.is_none());
1781    }
1782
1783    #[test]
1784    fn plan_drops_audio_when_requested() {
1785        let info = video_info(30.0, 50_000_000, 1280, 720, true);
1786        let opts = ShrinkOpts {
1787            audio: AudioChoice::Drop,
1788            ..opts_target(8_000_000)
1789        };
1790        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1791        assert!(plan.spec.audio.is_none());
1792    }
1793
1794    fn audio_info(duration: f64, size: u64, channels: u32) -> MediaInfo {
1795        MediaInfo {
1796            path: PathBuf::from("/tmp/lecture.wav"),
1797            kind: MediaKind::Audio,
1798            duration_sec: duration,
1799            size_bytes: size,
1800            width: None,
1801            height: None,
1802            fps: None,
1803            video_codec: None,
1804            audio_codec: Some("pcm_s16le".into()),
1805            audio_channels: Some(channels),
1806            audio_bitrate_bps: None,
1807            capture: CaptureMeta::default(),
1808            hdr: None,
1809        }
1810    }
1811
1812    #[test]
1813    fn quality_audio_bitrate_follows_tier_codec_and_channels() {
1814        use QualityPreset::*;
1815        assert_eq!(quality_audio_bps(Balanced, AudioCodec::Aac, false), 128_000);
1816        assert_eq!(quality_audio_bps(Balanced, AudioCodec::Aac, true), 64_000);
1817        assert_eq!(quality_audio_bps(Fast, AudioCodec::Opus, true), 32_000);
1818        assert_eq!(quality_audio_bps(Max, AudioCodec::Mp3, false), 256_000);
1819        // Every tier is strictly smaller → larger, per codec.
1820        for c in [AudioCodec::Aac, AudioCodec::Opus, AudioCodec::Mp3] {
1821            let t: Vec<_> = [Fast, Balanced, Max]
1822                .map(|q| quality_audio_bps(q, c, false))
1823                .into();
1824            assert!(t[0] < t[1] && t[1] < t[2], "{c:?}: {t:?}");
1825        }
1826    }
1827
1828    #[test]
1829    fn a_compact_audiobook_is_kept_in_quality_mode() {
1830        // 1 h mono at 64 kbps (the review case): balanced AAC mono is 64 kbps too.
1831        let mut info = audio_info(3600.0, 64_000 / 8 * 3600, 1);
1832        info.path = PathBuf::from("/tmp/book.mp3");
1833        let plan = MediaEngine::new()
1834            .plan(&info, &ShrinkOpts::default())
1835            .unwrap();
1836        assert!(plan.spec.passthrough, "{}", plan.summary);
1837        assert!(plan.output.to_string_lossy().ends_with(".mp3"));
1838        assert!(plan.summary.contains("already compact"));
1839
1840        // A genuinely smaller recipe still encodes (Opus fast mono = 32 kbps).
1841        let smaller = ShrinkOpts {
1842            audio_codec: AudioCodec::Opus,
1843            quality: QualityPreset::Fast,
1844            ..ShrinkOpts::default()
1845        };
1846        let plan = MediaEngine::new().plan(&info, &smaller).unwrap();
1847        assert!(!plan.spec.passthrough);
1848        assert_eq!(plan.spec.audio.as_ref().unwrap().bitrate_bps, 32_000);
1849        assert!(
1850            plan.guard_larger,
1851            "quality mode keeps the post-encode check"
1852        );
1853
1854        // Opting out re-encodes at the tier bitrate.
1855        let allow = ShrinkOpts {
1856            allow_larger: true,
1857            ..ShrinkOpts::default()
1858        };
1859        let plan = MediaEngine::new().plan(&info, &allow).unwrap();
1860        assert!(!plan.spec.passthrough && !plan.guard_larger);
1861    }
1862
1863    #[test]
1864    fn the_guard_is_for_quality_mode_only() {
1865        let info = video_info(60.0, 50_000_000, 1920, 1080, true);
1866        let quality = MediaEngine::new()
1867            .plan(&info, &ShrinkOpts::default())
1868            .unwrap();
1869        assert!(quality.guard_larger);
1870        let target = MediaEngine::new()
1871            .plan(&info, &opts_target(10_000_000))
1872            .unwrap();
1873        assert!(!target.guard_larger, "a size target is its own guarantee");
1874    }
1875
1876    #[test]
1877    fn plan_audio_single_pass_with_fitted_bitrate() {
1878        // 58 min stereo lecture, target 10 MB.
1879        let info = audio_info(3480.0, 600_000_000, 2);
1880        let plan = MediaEngine::new()
1881            .plan(&info, &opts_target(10_000_000))
1882            .unwrap();
1883        assert!(plan.spec.audio_only);
1884        assert!(!plan.spec.two_pass);
1885        assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.m4a"));
1886        let au = plan.spec.audio.as_ref().unwrap();
1887        // Snapped down to a standard step, never above the raw budget.
1888        assert!(budget::AUDIO_STEPS.contains(&au.bitrate_bps));
1889        assert!(plan.expected_bytes.unwrap() <= 10_000_000 + 10_000_000 / 20);
1890    }
1891
1892    #[test]
1893    fn plan_audio_mono_source_marked_speech() {
1894        let info = audio_info(600.0, 100_000_000, 1);
1895        let plan = MediaEngine::new()
1896            .plan(&info, &opts_target(5_000_000))
1897            .unwrap();
1898        assert!(plan.spec.audio.as_ref().unwrap().mono);
1899    }
1900
1901    #[test]
1902    fn plan_audio_opus_extension_and_vbr_args() {
1903        let info = audio_info(600.0, 100_000_000, 2);
1904        let opts = ShrinkOpts {
1905            audio_codec: AudioCodec::Opus,
1906            mono: true,
1907            ..opts_target(3_000_000)
1908        };
1909        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1910        assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.opus"));
1911        let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog", enc(&plan), false);
1912        let j: Vec<String> = args
1913            .iter()
1914            .map(|a| a.to_string_lossy().into_owned())
1915            .collect();
1916        assert!(j.contains(&"-vn".to_string()));
1917        assert!(j.contains(&"libopus".to_string()));
1918        assert!(j.contains(&"-ac".to_string())); // mono downmix
1919        assert!(j.contains(&"-vbr".to_string()));
1920    }
1921
1922    #[test]
1923    fn plan_audio_infeasible_when_target_tiny() {
1924        let info = audio_info(3600.0, 500_000_000, 2);
1925        assert!(matches!(
1926            MediaEngine::new().plan(&info, &opts_target(1_000)),
1927            Err(EngineError::Infeasible)
1928        ));
1929    }
1930
1931    #[test]
1932    fn plan_audio_passthrough_when_source_fits() {
1933        let info = audio_info(600.0, 2_000_000, 2);
1934        let plan = MediaEngine::new()
1935            .plan(&info, &opts_target(10_000_000))
1936            .unwrap();
1937        assert!(plan.spec.passthrough);
1938        // Passthrough keeps the source container/extension.
1939        assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.wav"));
1940    }
1941
1942    #[test]
1943    fn pass1_args_have_no_audio_and_null_sink() {
1944        let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1945        let plan = MediaEngine::new()
1946            .plan(&info, &opts_target(8_000_000))
1947            .unwrap();
1948        let args = build_pass_args(&plan, PassKind::First, "/tmp/passlog", enc(&plan), false);
1949        let joined: Vec<String> = args
1950            .iter()
1951            .map(|a| a.to_string_lossy().into_owned())
1952            .collect();
1953        assert!(joined.contains(&"-an".to_string()));
1954        assert!(joined.contains(&"null".to_string()));
1955        assert!(joined.iter().any(|a| a == "1")); // -pass 1
1956        assert!(!joined.iter().any(|a| a.contains("shrink.mp4")));
1957    }
1958
1959    #[test]
1960    fn pass2_args_write_output_with_audio() {
1961        let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1962        let plan = MediaEngine::new()
1963            .plan(&info, &opts_target(8_000_000))
1964            .unwrap();
1965        let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan), false);
1966        let joined: Vec<String> = args
1967            .iter()
1968            .map(|a| a.to_string_lossy().into_owned())
1969            .collect();
1970        assert!(joined.iter().any(|a| a.contains("clip.shrink.mp4")));
1971        assert!(joined.contains(&"-c:a".to_string()));
1972        assert!(joined.iter().any(|a| a.contains("+faststart")));
1973        assert!(joined.iter().any(|a| a == "2")); // -pass 2
1974    }
1975
1976    fn joined(plan: &EncodePlan, pass: PassKind) -> Vec<String> {
1977        joined_with(plan, pass, false)
1978    }
1979
1980    fn joined_with(plan: &EncodePlan, pass: PassKind, zscale: bool) -> Vec<String> {
1981        build_pass_args(plan, pass, "/tmp/passlog", enc(plan), zscale)
1982            .iter()
1983            .map(|a| a.to_string_lossy().into_owned())
1984            .collect()
1985    }
1986
1987    #[test]
1988    fn hdr_transfer_is_recognised() {
1989        assert_eq!(Hdr::from_transfer("arib-std-b67"), Some(Hdr::Hlg));
1990        assert_eq!(Hdr::from_transfer("smpte2084"), Some(Hdr::Pq));
1991        assert_eq!(Hdr::from_transfer("bt709"), None);
1992    }
1993
1994    #[test]
1995    fn hdr_is_tone_mapped_to_sdr_for_size_targets_only() {
1996        let mut info = iphone_info();
1997        info.hdr = Some(Hdr::Hlg);
1998        let engine = MediaEngine::new();
1999
2000        // Discord: must play everywhere → 8-bit SDR BT.709.
2001        let target = engine.plan(&info, &opts_target(10_000_000)).unwrap();
2002        assert_eq!(target.spec.video.to_sdr, Some(Hdr::Hlg));
2003        assert!(target.summary.contains("HDR → SDR"));
2004        let vf_of =
2005            |args: &[String]| args[args.iter().position(|a| a == "-vf").unwrap() + 1].clone();
2006        // HLG: the colorspace re-map (closest to what macOS shows), any build.
2007        let hlg = joined_with(&target, PassKind::Second, true);
2008        let vf = vf_of(&hlg);
2009        assert!(
2010            vf.contains("colorspace=all=bt709") && !vf.contains("zscale"),
2011            "{vf}"
2012        );
2013        // Downscale first (a portrait clip: by its width), then convert the
2014        // fewer pixels.
2015        assert!(
2016            vf.find("scale=").unwrap() < vf.find("colorspace").unwrap(),
2017            "{vf}"
2018        );
2019        for pair in [
2020            ["-pix_fmt", "yuv420p"],
2021            ["-color_trc", "bt709"],
2022            ["-colorspace", "bt709"],
2023        ] {
2024            assert!(hlg.windows(2).any(|w| w == pair), "{pair:?}");
2025        }
2026        // PQ: a real tone-map with zscale, the colorspace re-map without it.
2027        let mut pq = target.clone();
2028        pq.spec.video.to_sdr = Some(Hdr::Pq);
2029        let vf = vf_of(&joined_with(&pq, PassKind::Second, true));
2030        assert!(
2031            vf.contains("tonemap=hable") && vf.contains("npl=100"),
2032            "{vf}"
2033        );
2034        let vf = vf_of(&joined_with(&pq, PassKind::Second, false));
2035        assert!(
2036            vf.contains("colorspace=all=bt709") && !vf.contains("zscale"),
2037            "{vf}"
2038        );
2039
2040        // Quality mode keeps HDR and 10-bit as shot.
2041        let quality = engine.plan(&info, &ShrinkOpts::default()).unwrap();
2042        assert_eq!(quality.spec.video.to_sdr, None);
2043        let args = joined(&quality, PassKind::Single);
2044        assert!(!args
2045            .iter()
2046            .any(|a| a == "-pix_fmt" || a.contains("colorspace")));
2047
2048        // An SDR source is left alone even with a target.
2049        let sdr = engine
2050            .plan(&iphone_info(), &opts_target(10_000_000))
2051            .unwrap();
2052        assert_eq!(sdr.spec.video.to_sdr, None);
2053        assert!(!joined(&sdr, PassKind::Second)
2054            .iter()
2055            .any(|a| a == "-pix_fmt"));
2056    }
2057
2058    #[test]
2059    fn a_size_target_is_a_ceiling_at_the_quality_crf() {
2060        let info = video_info(60.0, 200_000_000, 1920, 1080, true);
2061        let engine = MediaEngine::new();
2062        let opts = opts_target(50_000_000);
2063        let plan = engine.plan(&info, &opts).unwrap();
2064        let crf = opts.quality.default_crf(opts.video_codec);
2065        assert_eq!(plan.ceiling_crf, Some(crf));
2066
2067        let ceiling = ceiling_plan(&plan).unwrap();
2068        assert_eq!(ceiling.spec.video.crf, Some(crf));
2069        assert_eq!(ceiling.spec.video.bitrate_bps, None);
2070        assert!(!ceiling.spec.two_pass);
2071        // Same everything else: resolution, audio, output, the target itself.
2072        assert_eq!(ceiling.spec.video.height, plan.spec.video.height);
2073        assert_eq!(ceiling.spec.audio, plan.spec.audio);
2074        assert_eq!(ceiling.output, plan.output);
2075        assert_eq!(ceiling.target_bytes, plan.target_bytes);
2076
2077        // Quality mode and passthrough have no ceiling to try.
2078        let quality = engine.plan(&info, &ShrinkOpts::default()).unwrap();
2079        assert!(quality.ceiling_crf.is_none() && ceiling_plan(&quality).is_none());
2080        let fits = engine.plan(&info, &opts_target(300_000_000)).unwrap();
2081        assert!(fits.spec.passthrough && ceiling_plan(&fits).is_none());
2082    }
2083
2084    #[test]
2085    fn heavy_video_samples_shorter_windows() {
2086        let engine = MediaEngine::new();
2087        let mut info = video_info(120.0, 500_000_000, 1920, 1080, true);
2088        info.fps = Some(30.0);
2089        let plan = engine.plan(&info, &ShrinkOpts::default()).unwrap();
2090        assert_eq!(sample_secs(&plan), 3.0);
2091        info = video_info(120.0, 500_000_000, 3840, 2160, true);
2092        info.fps = Some(60.0);
2093        let plan = engine.plan(&info, &ShrinkOpts::default()).unwrap();
2094        assert_eq!(sample_secs(&plan), MIN_SAMPLE_SECS);
2095    }
2096
2097    #[test]
2098    fn apple_hardware_uses_quality_one_pass_and_no_preset() {
2099        let engine = MediaEngine::new();
2100        let info = video_info(60.0, 200_000_000, 1920, 1080, true);
2101        let mut plan = engine.plan(&info, &ShrinkOpts::default()).unwrap();
2102        // As a plan would be on an Apple Silicon Mac with `hardware: true`.
2103        plan.spec.video.hardware = true;
2104        plan.spec.video.crf = QualityPreset::Balanced.default_hw_quality(VideoCodec::H264);
2105        let args: Vec<String> =
2106            build_pass_args(&plan, PassKind::Single, "", "h264_videotoolbox", false)
2107                .iter()
2108                .map(|a| a.to_string_lossy().into_owned())
2109                .collect();
2110        assert!(args.windows(2).any(|w| w == ["-c:v", "h264_videotoolbox"]));
2111        assert!(args.windows(2).any(|w| w == ["-q:v", "66"]), "{args:?}");
2112        assert!(
2113            !args.iter().any(|a| a == "-crf" || a == "-preset"),
2114            "{args:?}"
2115        );
2116        let mut hw = plan.clone();
2117        hw.spec.passthrough = false;
2118        assert_eq!(
2119            resolve_encoder(
2120                &deepshrink_ffmpeg::Tools {
2121                    ffmpeg: "ffmpeg".into(),
2122                    ffprobe: "ffprobe".into(),
2123                    cancel: Default::default(),
2124                },
2125                &hw
2126            )
2127            .unwrap(),
2128            "h264_videotoolbox"
2129        );
2130        // AV1 has no Apple encoder: the quality map says so.
2131        assert_eq!(
2132            QualityPreset::Balanced.default_hw_quality(VideoCodec::Av1),
2133            None
2134        );
2135    }
2136
2137    #[test]
2138    fn a_portrait_video_is_capped_on_its_short_side() {
2139        let engine = MediaEngine::new();
2140        // As shown (probe applies the rotation): 2160 × 3840, portrait.
2141        let info = video_info(60.0, 200_000_000, 2160, 3840, true);
2142        let opts = ShrinkOpts {
2143            resolution: ResolutionOpt::Height(1080),
2144            ..ShrinkOpts::default()
2145        };
2146        let plan = engine.plan(&info, &opts).unwrap();
2147        assert_eq!(plan.spec.video.height, Some(1080));
2148        assert!(plan.spec.video.portrait);
2149        let args = joined(&plan, PassKind::Single);
2150        let vf = &args[args.iter().position(|a| a == "-vf").unwrap() + 1];
2151        // 1080 × 1920, not 608 × 1080.
2152        assert!(vf.starts_with("scale=1080:-2"), "{vf}");
2153
2154        // Landscape is unchanged: height is the short side.
2155        let info = video_info(60.0, 200_000_000, 3840, 2160, true);
2156        let plan = engine.plan(&info, &opts).unwrap();
2157        assert!(!plan.spec.video.portrait);
2158        let args = joined(&plan, PassKind::Single);
2159        assert!(args.iter().any(|a| a.starts_with("scale=-2:1080")));
2160    }
2161
2162    fn iphone_info() -> MediaInfo {
2163        let mut info = video_info(60.0, 50_000_000, 2160, 3840, true);
2164        info.path = PathBuf::from("/tmp/IMG_3325.MOV");
2165        info.capture = CaptureMeta {
2166            created_utc: to_utc("2026-09-26T20:01:54+0300"),
2167            created_local: Some("2026-09-26T20:01:54+0300".into()),
2168            location: Some("+50.4160+030.2796+155.635/".into()),
2169            make: Some("Apple".into()),
2170            model: Some("iPhone 12 Pro Max".into()),
2171        };
2172        info
2173    }
2174
2175    #[test]
2176    fn metadata_is_kept_by_default_and_strippable() {
2177        let info = iphone_info();
2178        let plan = MediaEngine::new()
2179            .plan(&info, &ShrinkOpts::default())
2180            .unwrap();
2181        let a = joined(&plan, PassKind::Single);
2182        let at = a.iter().position(|x| x == "-map_metadata").unwrap();
2183        assert_eq!(a[at + 1], "0");
2184        // The capture tags are re-stated explicitly — the shooting date (not
2185        // the file's export time) in UTC, and location / make / model.
2186        for tag in [
2187            "creation_time=2026-09-26T17:01:54Z",
2188            "location=+50.4160+030.2796+155.635/",
2189            "make=Apple",
2190            "model=iPhone 12 Pro Max",
2191            "date=2026-09-26T20:01:54+0300",
2192        ] {
2193            assert!(a.contains(&tag.to_string()), "{tag} in {a:?}");
2194        }
2195        assert!(a.contains(&"+faststart".to_string()));
2196
2197        let strip = ShrinkOpts {
2198            keep_metadata: false,
2199            ..ShrinkOpts::default()
2200        };
2201        let plan = MediaEngine::new().plan(&info, &strip).unwrap();
2202        let a = joined(&plan, PassKind::Single);
2203        let at = a.iter().position(|x| x == "-map_metadata").unwrap();
2204        assert_eq!(a[at + 1], "-1");
2205        assert!(!a.iter().any(|x| x.starts_with("location=")));
2206        assert!(a.contains(&"+faststart".to_string()));
2207    }
2208
2209    #[test]
2210    fn apple_local_time_converts_to_utc() {
2211        let utc = |s: &str| to_utc(s);
2212        assert_eq!(
2213            utc("2026-09-26T20:01:54+0300").as_deref(),
2214            Some("2026-09-26T17:01:54Z")
2215        );
2216        assert_eq!(
2217            utc("2026-09-26T20:01:54+03:00").as_deref(),
2218            Some("2026-09-26T17:01:54Z")
2219        );
2220        assert_eq!(
2221            utc("2026-01-01T01:30:00+0300").as_deref(),
2222            Some("2025-12-31T22:30:00Z")
2223        );
2224        assert_eq!(
2225            utc("2026-03-01T23:00:00-0500").as_deref(),
2226            Some("2026-03-02T04:00:00Z")
2227        );
2228        assert_eq!(
2229            utc("2024-02-29T12:00:00.123Z").as_deref(),
2230            Some("2024-02-29T12:00:00Z")
2231        );
2232        assert_eq!(utc("yesterday"), None);
2233    }
2234
2235    #[test]
2236    fn an_iphone_mov_stays_mov_in_quality_mode_only() {
2237        let info = iphone_info();
2238        let out = |opts: &ShrinkOpts| {
2239            let plan = MediaEngine::new().plan(&info, opts).unwrap();
2240            plan.output.to_string_lossy().into_owned()
2241        };
2242        assert!(out(&ShrinkOpts::default()).ends_with(".shrink.mov"));
2243        // Platform presets / size targets are for sharing → MP4.
2244        assert!(out(&opts_target(8_000_000)).ends_with(".shrink.mp4"));
2245        // AV1 has no QuickTime mapping → MP4.
2246        let av1 = ShrinkOpts {
2247            video_codec: VideoCodec::Av1,
2248            ..ShrinkOpts::default()
2249        };
2250        assert!(out(&av1).ends_with(".shrink.mp4"));
2251        // Non-MOV sources are unaffected.
2252        let mp4 = video_info(60.0, 50_000_000, 1920, 1080, true);
2253        let p = MediaEngine::new()
2254            .plan(&mp4, &ShrinkOpts::default())
2255            .unwrap();
2256        assert!(p.output.to_string_lossy().ends_with(".shrink.mp4"));
2257    }
2258
2259    #[test]
2260    fn a_video_audio_track_is_never_upsampled() {
2261        let mut info = video_info(60.0, 50_000_000, 1920, 1080, true);
2262        info.audio_bitrate_bps = Some(64_000);
2263        let bps_of = |info: &MediaInfo, opts: &ShrinkOpts| {
2264            let plan = MediaEngine::new().plan(info, opts).unwrap();
2265            plan.spec.audio.unwrap().bitrate_bps
2266        };
2267        // Quality mode default is 128 kbps — capped at the source's 64 kbps.
2268        assert_eq!(bps_of(&info, &ShrinkOpts::default()), 64_000);
2269        // A size target too: never above the source (the rest goes to video).
2270        assert!(bps_of(&info, &opts_target(20_000_000)) <= 64_000);
2271        // An explicit `--audio 128k` is still honoured as asked.
2272        let explicit = ShrinkOpts {
2273            audio: AudioChoice::Bitrate(128_000),
2274            ..ShrinkOpts::default()
2275        };
2276        assert_eq!(bps_of(&info, &explicit), 128_000);
2277        // Unknown source rate → the default.
2278        info.audio_bitrate_bps = None;
2279        assert_eq!(
2280            bps_of(&info, &ShrinkOpts::default()),
2281            budget::DEFAULT_AUDIO_BPS
2282        );
2283    }
2284
2285    #[test]
2286    fn h265_adds_hvc1_tag() {
2287        let info = video_info(60.0, 100_000_000, 1280, 720, false);
2288        let opts = ShrinkOpts {
2289            video_codec: VideoCodec::H265,
2290            ..opts_target(8_000_000)
2291        };
2292        let plan = MediaEngine::new().plan(&info, &opts).unwrap();
2293        let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan), false);
2294        let joined: Vec<String> = args
2295            .iter()
2296            .map(|a| a.to_string_lossy().into_owned())
2297            .collect();
2298        assert!(joined.contains(&"hvc1".to_string()));
2299        assert!(joined.contains(&"libx265".to_string()));
2300    }
2301}