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