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