Skip to main content

deepshrink_core/engine/
plan.rs

1//! The pure half of the media engine: planning an encode (video bitrate under
2//! a size target, quality mode, resolution / fps caps, the audio decision,
3//! output container, metadata tags) and the small calculations the run loop
4//! uses (the size-target ceiling, overshoot corrections). No ffmpeg — this is
5//! what builds without the `ffmpeg` feature (e.g. for iOS).
6
7use std::path::{Path, PathBuf};
8
9use super::{
10    AudioSpec, EncodePlan, EncodeSpec, EngineError, MediaInfo, ShrinkOpts, SizeGoal, VideoSpec,
11};
12use crate::budget;
13use crate::detect::MediaKind;
14use crate::options::{AudioChoice, AudioCodec, FpsOpt, QualityPreset, ResolutionOpt, VideoCodec};
15
16/// Plan an encode — the pure half of [`super::Engine::plan`], with no ffmpeg:
17/// `hw_available` says whether Apple's hardware encoder may be used (the
18/// media engine asks ffmpeg; another platform asks its own encoder).
19pub fn plan(
20    info: &MediaInfo,
21    opts: &ShrinkOpts,
22    hw_available: bool,
23) -> Result<EncodePlan, EngineError> {
24    match info.kind {
25        MediaKind::Audio => return plan_audio(info, opts),
26        MediaKind::Unsupported => {
27            return Err(EngineError::Unsupported(format!(
28                "{} is not a supported media file",
29                info.path.display()
30            )))
31        }
32        MediaKind::Video => {}
33    }
34    let duration = info.duration_sec;
35    if !duration.is_finite() || duration <= 0.0 {
36        return Err(EngineError::Unsupported(format!(
37            "could not determine duration of {}",
38            info.path.display()
39        )));
40    }
41    // Resolution caps apply to the short side (portrait video included).
42    let (w, h) = (info.width.unwrap_or(0), info.height.unwrap_or(0));
43    let portrait = h > w;
44    let src_height = w.min(h);
45
46    let target = target_bytes(&opts.goal, info.size_bytes);
47    let output = opts
48        .output
49        .clone()
50        .unwrap_or_else(|| output_with_ext(&info.path, video_container(info, target, opts)));
51
52    // "Never make it bigger": if the source already fits the target, just
53    // remux (stream copy) instead of re-encoding it up to the target. The
54    // copy stays in the *source* container — an .mp4 cannot hold every codec
55    // a source may carry (an AMR-NB track from a .3gp, say), and a stream
56    // copy must not be the thing that breaks a file we aren't even re-encoding.
57    if let Some(tb) = target {
58        if info.size_bytes > 0 && info.size_bytes <= tb {
59            let src_ext = info
60                .path
61                .extension()
62                .and_then(|e| e.to_str())
63                .unwrap_or("mp4");
64            let output = opts
65                .output
66                .clone()
67                .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
68            return Ok(passthrough_plan(info, output, tb, true, opts.keep_metadata));
69        }
70    }
71
72    let audio = decide_audio(
73        opts,
74        info.has_audio(),
75        info.audio_bitrate_bps,
76        target,
77        duration,
78    )?;
79    let audio_bps = audio.as_ref().map(|a| a.bitrate_bps).unwrap_or(0);
80
81    // Apple's hardware encoder, when asked for and present. Not for a VMAF
82    // search (its CRF bounds are the software encoder's).
83    let hw_quality = opts
84        .quality
85        .default_hw_quality(opts.video_codec)
86        .filter(|_| opts.hardware && opts.target_vmaf.is_none() && hw_available);
87    let hardware = hw_quality.is_some();
88    // The quality value for this encoder: CRF, or VideoToolbox's `-q:v`.
89    let quality_value = hw_quality.unwrap_or_else(|| opts.quality.default_crf(opts.video_codec));
90
91    let (video, expected_bytes) = if let Some(tb) = target {
92        let vbps = budget::video_bitrate_bps(tb, duration, audio_bps)
93            .filter(|&b| b >= budget::ABSOLUTE_MIN_VIDEO_BPS)
94            .ok_or(EngineError::Infeasible)?;
95        let height = pick_height(opts.resolution, src_height, vbps);
96        let predicted = ((vbps + audio_bps) as f64 * duration / 8.0
97            * (1.0 + budget::CONTAINER_OVERHEAD))
98            .round() as u64;
99        (
100            VideoSpec {
101                codec: opts.video_codec,
102                bitrate_bps: Some(vbps),
103                crf: None,
104                height,
105                fps: pick_fps(opts.fps, info.fps),
106                preset: opts.quality,
107                // A size target is for sending: make it play everywhere.
108                to_sdr: info.hdr,
109                hardware,
110                portrait,
111            },
112            Some(predicted),
113        )
114    } else {
115        // Quality mode: CRF, no hard size guarantee. The CRF default is
116        // codec-aware; a `--vmaf` target refines it via a search in `run`.
117        let crf = quality_value;
118        let height = match opts.resolution {
119            ResolutionOpt::Height(h) => clamp_height(h, src_height),
120            ResolutionOpt::Auto => None,
121        };
122        (
123            VideoSpec {
124                codec: opts.video_codec,
125                bitrate_bps: None,
126                crf: Some(crf),
127                height,
128                fps: pick_fps(opts.fps, info.fps),
129                preset: opts.quality,
130                // Quality mode keeps HDR (and 10-bit) as shot — except
131                // Apple's H.264, which is 8-bit only: SDR it is.
132                to_sdr: info
133                    .hdr
134                    .filter(|_| hardware && opts.video_codec == VideoCodec::H264),
135                hardware,
136                portrait,
137            },
138            None,
139        )
140    };
141
142    // Two-pass is how a bitrate budget is actually hit; the caller can force
143    // it off (faster, looser) but can't force it on in CRF mode, where there
144    // is no budget for a first pass to measure.
145    // Apple's encoder has no two-pass: it hits a budget in one (with the
146    // overshoot retry in `run`).
147    let two_pass = video.bitrate_bps.is_some() && opts.two_pass.unwrap_or(true) && !hardware;
148    let summary = build_summary(&video, audio.as_ref(), two_pass);
149
150    Ok(EncodePlan {
151        input: info.path.clone(),
152        output,
153        summary,
154        expected_bytes,
155        target_bytes: target,
156        target_vmaf: opts.target_vmaf,
157        source_duration_sec: duration,
158        source_width: info.width,
159        source_height: info.height,
160        source_fps: info.fps,
161        spec: EncodeSpec {
162            video,
163            audio,
164            faststart: true,
165            two_pass,
166            passthrough: false,
167            audio_only: false,
168            dpi: None,
169            keep_metadata: opts.keep_metadata,
170            tags: capture_tags(info, opts.keep_metadata),
171        },
172        // A size target is its own guarantee; quality mode gets the guard.
173        guard_larger: target.is_none() && !opts.allow_larger,
174        ceiling_crf: target.map(|_| quality_value),
175    })
176}
177
178/// Plan a pure-audio encode (single pass, codec + fitted bitrate).
179pub(crate) fn plan_audio(info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
180    let duration = info.duration_sec;
181    if !duration.is_finite() || duration <= 0.0 {
182        return Err(EngineError::Unsupported(format!(
183            "could not determine duration of {}",
184            info.path.display()
185        )));
186    }
187    let codec = opts.audio_codec;
188    let target = target_bytes(&opts.goal, info.size_bytes);
189
190    // "Never make it bigger": stream-copy remux when the source already fits.
191    if let Some(tb) = target {
192        if info.size_bytes > 0 && info.size_bytes <= tb {
193            let src_ext = info
194                .path
195                .extension()
196                .and_then(|e| e.to_str())
197                .unwrap_or("audio");
198            let output = opts
199                .output
200                .clone()
201                .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
202            return Ok(passthrough_plan(
203                info,
204                output,
205                tb,
206                false,
207                opts.keep_metadata,
208            ));
209        }
210    }
211
212    // Mono for speech: explicit flag, or a single-channel source.
213    let mono = opts.mono || info.audio_channels == Some(1);
214
215    let (bitrate_bps, expected_bytes) = match target {
216        Some(tb) => {
217            let raw = budget::audio_bitrate_bps(tb, duration).ok_or(EngineError::Infeasible)?;
218            if raw < budget::ABSOLUTE_MIN_AUDIO_BPS {
219                return Err(EngineError::Infeasible);
220            }
221            let bps = budget::snap_audio_bitrate(raw);
222            let predicted =
223                (bps as f64 * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD)).round() as u64;
224            (bps, Some(predicted))
225        }
226        None => {
227            // Quality mode: per tier, codec and channel count.
228            let bps = quality_audio_bps(opts.quality, codec, mono);
229            // Never re-encode lossy audio at (nearly) its own bitrate or
230            // above: that is only generation loss, often a bigger file (a
231            // 64 kbps MP3 audiobook → 160 kbps AAC doubled it). Keep it.
232            if !opts.allow_larger && already_compact_audio(bps, info) {
233                let src_ext = info
234                    .path
235                    .extension()
236                    .and_then(|e| e.to_str())
237                    .unwrap_or("audio");
238                let output = opts
239                    .output
240                    .clone()
241                    .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
242                let mut plan =
243                    passthrough_plan(info, output, info.size_bytes, false, opts.keep_metadata);
244                plan.summary =
245                    "stream copy (already compact — a re-encode would not be smaller)".into();
246                return Ok(plan);
247            }
248            // Constant-bitrate estimate (VBR lands close enough for a preview).
249            let predicted =
250                (bps as f64 * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD)).round() as u64;
251            (bps, Some(predicted))
252        }
253    };
254
255    let audio = AudioSpec {
256        codec,
257        bitrate_bps,
258        mono,
259        sample_rate: opts.sample_rate,
260        vbr: opts.vbr,
261    };
262    let output = opts
263        .output
264        .clone()
265        .unwrap_or_else(|| output_with_ext(&info.path, codec.extension()));
266    let summary = build_audio_summary(&audio, info.audio_channels);
267
268    Ok(EncodePlan {
269        input: info.path.clone(),
270        output,
271        summary,
272        expected_bytes,
273        target_bytes: target,
274        target_vmaf: None,
275        source_duration_sec: duration,
276        source_width: info.width,
277        source_height: info.height,
278        source_fps: info.fps,
279        spec: EncodeSpec {
280            video: placeholder_video_spec(),
281            audio: Some(audio),
282            faststart: false,
283            two_pass: false,
284            passthrough: false,
285            audio_only: true,
286            dpi: None,
287            keep_metadata: opts.keep_metadata,
288            tags: capture_tags(info, opts.keep_metadata),
289        },
290        // A size target is its own guarantee; quality mode gets the guard.
291        guard_larger: target.is_none() && !opts.allow_larger,
292        ceiling_crf: None,
293    })
294}
295
296/// Audio bitrate ladder (bits/s, descending) tried when keeping a track under
297/// a tight size budget.
298pub(crate) const AUDIO_LADDER: &[u64] = &[128_000, 96_000, 64_000, 48_000];
299
300/// A placeholder video spec — ignored while `passthrough`/`audio_only` is set.
301pub(crate) fn placeholder_video_spec() -> VideoSpec {
302    VideoSpec {
303        codec: crate::options::VideoCodec::H264,
304        bitrate_bps: None,
305        crf: None,
306        height: None,
307        fps: None,
308        preset: crate::options::QualityPreset::Balanced,
309        to_sdr: None,
310        hardware: false,
311        portrait: false,
312    }
313}
314
315/// A stream-copy remux plan for when the source already fits the target.
316/// `faststart` is only meaningful for MP4/MOV; pass `false` for pure audio.
317pub(crate) fn passthrough_plan(
318    info: &MediaInfo,
319    output: PathBuf,
320    target: u64,
321    faststart: bool,
322    keep_metadata: bool,
323) -> EncodePlan {
324    EncodePlan {
325        input: info.path.clone(),
326        output,
327        summary: "stream copy (already within target)".to_string(),
328        expected_bytes: Some(info.size_bytes),
329        target_bytes: Some(target),
330        target_vmaf: None,
331        source_duration_sec: info.duration_sec,
332        source_width: info.width,
333        source_height: info.height,
334        source_fps: info.fps,
335        spec: EncodeSpec {
336            video: placeholder_video_spec(),
337            audio: None,
338            faststart,
339            two_pass: false,
340            passthrough: true,
341            audio_only: false,
342            dpi: None,
343            keep_metadata,
344            tags: capture_tags(info, keep_metadata),
345        },
346        guard_larger: false,
347        ceiling_crf: None,
348    }
349}
350
351/// Quality-mode audio bitrate by tier, codec and channel count (mono = half).
352/// Opus needs the least for the same quality, MP3 the most.
353pub(crate) fn quality_audio_bps(quality: QualityPreset, codec: AudioCodec, mono: bool) -> u64 {
354    let stereo = match (codec, quality) {
355        (AudioCodec::Opus, QualityPreset::Fast) => 64_000,
356        (AudioCodec::Opus, QualityPreset::Balanced) => 96_000,
357        (AudioCodec::Opus, QualityPreset::Max) => 128_000,
358        (AudioCodec::Mp3, QualityPreset::Fast) => 128_000,
359        (AudioCodec::Mp3, QualityPreset::Balanced) => 160_000,
360        (AudioCodec::Mp3, QualityPreset::Max) => 256_000,
361        (AudioCodec::Aac, QualityPreset::Fast) => 96_000,
362        (AudioCodec::Aac, QualityPreset::Balanced) => 128_000,
363        (AudioCodec::Aac, QualityPreset::Max) => 192_000,
364    };
365    if mono {
366        stereo / 2
367    } else {
368        stereo
369    }
370}
371
372/// A pure-audio source whose own bitrate is at or under ~110% of what we'd
373/// encode at: a re-encode can't meaningfully shrink it. The source rate comes
374/// from size / duration (embedded cover art only raises it — the safe side).
375pub(crate) fn already_compact_audio(bps: u64, info: &MediaInfo) -> bool {
376    if info.duration_sec <= 0.0 || info.size_bytes == 0 {
377        return false;
378    }
379    let source_bps = info.size_bytes as f64 * 8.0 / info.duration_sec;
380    bps as f64 >= source_bps * 0.9
381}
382
383/// Output container for a video. A QuickTime source (an iPhone `.MOV`) stays
384/// QuickTime in quality mode: only a MOV carries its location / camera tags in
385/// a form Apple's apps read (the MP4 muxer drops them). Size targets and
386/// platform presets get MP4 — the most compatible for sharing. AV1 is always
387/// MP4 (QuickTime has no AV1 mapping).
388pub(crate) fn video_container(
389    info: &MediaInfo,
390    target: Option<u64>,
391    opts: &ShrinkOpts,
392) -> &'static str {
393    let mov_source = info
394        .path
395        .extension()
396        .and_then(|e| e.to_str())
397        .is_some_and(|e| e.eq_ignore_ascii_case("mov"));
398    if mov_source && target.is_none() && opts.video_codec != VideoCodec::Av1 {
399        "mov"
400    } else {
401        "mp4"
402    }
403}
404
405/// The explicit output tags for `info`'s capture metadata (empty when
406/// metadata is stripped).
407pub(crate) fn capture_tags(info: &MediaInfo, keep: bool) -> Vec<(String, String)> {
408    if !keep {
409        return Vec::new();
410    }
411    let c = &info.capture;
412    [
413        ("creation_time", c.created_utc.as_ref()),
414        ("date", c.created_local.as_ref()),
415        ("location", c.location.as_ref()),
416        ("make", c.make.as_ref()),
417        ("model", c.model.as_ref()),
418    ]
419    .into_iter()
420    .filter_map(|(k, v)| v.map(|v| (k.to_string(), v.clone())))
421    .collect()
422}
423
424/// `2026-09-26T20:01:54+0300` (also `+03:00`, `Z`, fractional seconds) →
425/// `2026-09-26T17:01:54Z`. `None` if it doesn't parse.
426pub fn to_utc(s: &str) -> Option<String> {
427    let s = s.trim();
428    let num = |a: usize, b: usize| s.get(a..b)?.parse::<i64>().ok();
429    let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
430    let (h, mi, se) = (num(11, 13)?, num(14, 16)?, num(17, 19)?);
431    if s.get(4..5)? != "-" || s.get(10..11).map(|c| c == "T" || c == " ") != Some(true) {
432        return None;
433    }
434    // Offset: skip any fraction, then Z / ±HH[:]MM.
435    let rest = s
436        .get(19..)?
437        .trim_start_matches(|c: char| c == '.' || c.is_ascii_digit());
438    let offset_min = match rest {
439        "" | "Z" | "z" => 0,
440        r if r.starts_with('+') || r.starts_with('-') => {
441            let digits: String = r[1..].chars().filter(char::is_ascii_digit).collect();
442            let (oh, om) = (
443                digits.get(0..2)?.parse::<i64>().ok()?,
444                digits.get(2..4).unwrap_or("00").parse::<i64>().ok()?,
445            );
446            let m = oh * 60 + om;
447            if r.starts_with('-') {
448                -m
449            } else {
450                m
451            }
452        }
453        _ => return None,
454    };
455    let secs = days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + se - offset_min * 60;
456    let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
457    let (y, mo, d) = civil_from_days(days);
458    Some(format!(
459        "{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}Z",
460        rem / 3600,
461        rem % 3600 / 60,
462        rem % 60
463    ))
464}
465
466/// Days since 1970-01-01 for a proleptic Gregorian date (H. Hinnant).
467fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
468    let y = if m <= 2 { y - 1 } else { y };
469    let era = y.div_euclid(400);
470    let yoe = y - era * 400;
471    let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
472    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
473    era * 146_097 + doe - 719_468
474}
475
476/// Inverse of [`days_from_civil`].
477fn civil_from_days(z: i64) -> (i64, i64, i64) {
478    let z = z + 719_468;
479    let era = z.div_euclid(146_097);
480    let doe = z - era * 146_097;
481    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
482    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
483    let mp = (5 * doy + 2) / 153;
484    let d = doy - (153 * mp + 2) / 5 + 1;
485    let m = if mp < 10 { mp + 3 } else { mp - 9 };
486    (yoe + era * 400 + i64::from(m <= 2), m, d)
487}
488
489/// How far under the target a predicted CRF encode must land to be used
490/// instead of the budget (predictions are within ~5%).
491pub const CEILING_MARGIN: f64 = 0.9;
492
493/// A size-target plan re-cast as a single-pass CRF encode at the quality
494/// preset's CRF ([`EncodePlan::ceiling_crf`]). `None` for anything else.
495pub fn ceiling_plan(plan: &EncodePlan) -> Option<EncodePlan> {
496    let crf = plan.ceiling_crf?;
497    if plan.target_bytes.is_none()
498        || plan.spec.passthrough
499        || plan.spec.audio_only
500        || plan.spec.video.bitrate_bps.is_none()
501    {
502        return None;
503    }
504    let mut c = plan.clone();
505    c.spec.video.bitrate_bps = None;
506    c.spec.video.crf = Some(crf);
507    c.spec.two_pass = false;
508    c.summary = build_summary(&c.spec.video, c.spec.audio.as_ref(), false);
509    Some(c)
510}
511
512/// Human-readable summary for a pure-audio plan, e.g.
513/// "Opus · 22 kbps · mono (speech)".
514pub(crate) fn build_audio_summary(audio: &AudioSpec, src_channels: Option<u32>) -> String {
515    let mut parts = vec![
516        audio.codec.label().to_string(),
517        format!("{} kbps", audio.bitrate_bps / 1000),
518    ];
519    if audio.mono {
520        // A single-channel source (or --mono) reads as speech.
521        let note = if src_channels == Some(1) {
522            "mono"
523        } else {
524            "mono (downmix)"
525        };
526        parts.push(note.to_string());
527    }
528    if let Some(sr) = audio.sample_rate {
529        parts.push(format!("{} Hz", sr));
530    }
531    parts.join(" · ")
532}
533
534/// Resolve the absolute target size (bytes) for a goal, if it imposes one.
535pub(crate) fn target_bytes(goal: &SizeGoal, original: u64) -> Option<u64> {
536    match goal {
537        SizeGoal::Target(b) => Some(*b),
538        SizeGoal::Reduce(f) => Some(budget::reduce_target_bytes(original, *f)),
539        SizeGoal::Preset(p) => p.limit_bytes,
540        SizeGoal::Quality => None,
541    }
542}
543
544/// Decide the audio track for a video encode.
545pub(crate) fn decide_audio(
546    opts: &ShrinkOpts,
547    has_audio: bool,
548    source_bps: Option<u64>,
549    target: Option<u64>,
550    duration: f64,
551) -> Result<Option<AudioSpec>, EngineError> {
552    if !has_audio {
553        return Ok(None);
554    }
555    // A `--mono` request downmixes the kept audio track (speech clips / smaller
556    // files). A single-channel source stays mono regardless.
557    let mono = opts.mono;
558    match opts.audio {
559        AudioChoice::Drop => Ok(None),
560        AudioChoice::Bitrate(b) => Ok(Some(AudioSpec {
561            mono,
562            ..AudioSpec::cbr(AudioCodec::Aac, b)
563        })),
564        AudioChoice::Keep => {
565            let bps = match target {
566                Some(tb) => budget::fit_audio_bps(tb, duration, AUDIO_LADDER)
567                    .ok_or(EngineError::Infeasible)?,
568                None => budget::DEFAULT_AUDIO_BPS,
569            };
570            // Never re-encode the track above its own bitrate: that only adds
571            // bytes (a 64 kbps phone recording doesn't need 128 kbps AAC). Any
572            // budget saved here goes to the video.
573            let bps = match source_bps {
574                Some(src) => bps.min(src.max(MIN_TRACK_BPS)),
575                None => bps,
576            };
577            Ok(Some(AudioSpec {
578                mono,
579                ..AudioSpec::cbr(AudioCodec::Aac, bps)
580            }))
581        }
582    }
583}
584
585/// Floor for a capped audio track (a mis-reported tiny source rate must not
586/// starve the audio).
587pub(crate) const MIN_TRACK_BPS: u64 = 32_000;
588
589/// Choose the encode height in auto/explicit mode.
590pub(crate) fn pick_height(res: ResolutionOpt, src_height: u32, vbps: u64) -> Option<u32> {
591    match res {
592        ResolutionOpt::Height(h) => clamp_height(h, src_height),
593        ResolutionOpt::Auto => {
594            let chosen = budget::choose_height(src_height, vbps);
595            if src_height > 0 && chosen < src_height {
596                Some(chosen)
597            } else {
598                None
599            }
600        }
601    }
602}
603
604/// Clamp an explicit height to the source (never upscale); `None` if it equals
605/// the source (no scaling needed).
606pub(crate) fn clamp_height(requested: u32, src_height: u32) -> Option<u32> {
607    if src_height == 0 {
608        return Some(requested);
609    }
610    let h = requested.min(src_height);
611    if h == src_height {
612        None
613    } else {
614        Some(h)
615    }
616}
617
618/// Choose an fps cap; `None` if uncapped or the cap is ≥ the source rate.
619pub(crate) fn pick_fps(fps: FpsOpt, src_fps: Option<f64>) -> Option<u32> {
620    match fps {
621        FpsOpt::Auto => None,
622        FpsOpt::Cap(f) => match src_fps {
623            Some(src) if (f as f64) >= src => None,
624            _ => Some(f),
625        },
626    }
627}
628
629/// Default output path: `<stem>.shrink.<ext>` next to the input.
630pub(crate) fn output_with_ext(input: &Path, ext: &str) -> PathBuf {
631    let stem = input
632        .file_stem()
633        .map(|s| s.to_string_lossy().into_owned())
634        .unwrap_or_else(|| "output".to_string());
635    let mut out = input.parent().map(Path::to_path_buf).unwrap_or_default();
636    out.push(format!("{stem}.shrink.{ext}"));
637    out
638}
639
640pub(crate) fn build_summary(
641    video: &VideoSpec,
642    audio: Option<&AudioSpec>,
643    two_pass: bool,
644) -> String {
645    let mut parts = vec![if video.hardware {
646        format!("{} (Apple hardware)", video.codec.label())
647    } else {
648        video.codec.label().to_string()
649    }];
650    match (video.bitrate_bps, video.crf) {
651        (Some(bps), _) => parts.push(format!("up to {} kbps video", bps / 1000)),
652        (_, Some(q)) if video.hardware => parts.push(format!("quality {q}")),
653        (_, Some(crf)) => parts.push(format!("CRF {crf}")),
654        _ => {}
655    }
656    if let Some(a) = audio {
657        parts.push(format!("{} kbps audio", a.bitrate_bps / 1000));
658    } else {
659        parts.push("no audio".to_string());
660    }
661    if let Some(h) = video.height {
662        parts.push(format!("{h}p"));
663    }
664    if let Some(f) = video.fps {
665        parts.push(format!("{f} fps"));
666    }
667    if video.to_sdr.is_some() {
668        parts.push("HDR → SDR".to_string());
669    }
670    parts.push(
671        if two_pass {
672            "two-pass"
673        } else if video.hardware {
674            "one pass"
675        } else {
676            "CRF"
677        }
678        .to_string(),
679    );
680    parts.join(" · ")
681}
682
683/// The video bitrate for a re-run after an encode of `size` bytes overshot
684/// `target`: scaled down in proportion, with 3 % headroom. `None` below the
685/// encoder's floor (no point re-running).
686pub fn corrected_bitrate(vbps: u64, target: u64, size: u64) -> Option<u64> {
687    let corrected = (vbps as f64 * (target as f64 / size as f64) * 0.97) as u64;
688    (corrected >= budget::ABSOLUTE_MIN_VIDEO_BPS).then_some(corrected)
689}
690
691/// Sample window length for predicting a CRF encode (see [`sample_windows`]).
692pub const SAMPLE_SECS: f64 = 3.0;
693/// The shortest window for heavy video (4K, 60 fps): measured on a 60 s 4K60
694/// iPhone clip, 1.5 s windows predicted as well as 3 s (+3.3 % vs +3.8 %) in
695/// half the time; 1 s drifted to +7 %.
696pub const MIN_SAMPLE_SECS: f64 = 1.5;
697
698/// Sample window length: 3 s up to 1080p30, shorter as the pixel rate grows
699/// (4K60 → 1.5 s), so a preview of heavy video doesn't take a minute.
700pub fn sample_secs(plan: &EncodePlan) -> f64 {
701    const REFERENCE: f64 = 1920.0 * 1080.0 * 30.0;
702    let (w, h) = match (plan.source_width, plan.source_height) {
703        (Some(w), Some(h)) if w > 0 && h > 0 => (w as f64, h as f64),
704        _ => return SAMPLE_SECS,
705    };
706    let fps = plan
707        .source_fps
708        .filter(|f| f.is_finite() && *f > 0.0)
709        .unwrap_or(30.0);
710    (SAMPLE_SECS * REFERENCE / (w * h * fps)).clamp(MIN_SAMPLE_SECS, SAMPLE_SECS)
711}
712/// Each sample starts on a keyframe, so samples over-predict by ~8–10% (a
713/// 30 s phone clip: 20.4 MB predicted vs 18.7 MB real) — scale that back.
714pub const SAMPLE_BIAS: f64 = 0.92;
715/// The same for Apple's hardware encoder, which over-predicts far less:
716/// measured on 6 clips × H.264/HEVC (4K60 HDR, 4K30, 720p; the iOS app's
717/// VideoToolbox path, identical on Mac and iPhone), unbiased samples ran 0.88–
718/// 1.12 of the real size, mean 1.03; 0.97 puts 11 of 12 within ±10 % (0.92: 8).
719pub const SAMPLE_BIAS_HW: f64 = 0.97;
720
721/// The windows `(start, length)` in seconds to sample-encode for predicting a
722/// quality-mode (CRF) encode's size, and the bias to apply to their bit rate.
723/// Long clips: three windows at 20/50/80 %; short ones (under four windows):
724/// the whole clip once — exact, so no keyframe bias to correct. The bias is
725/// the encoder's ([`SAMPLE_BIAS`], or [`SAMPLE_BIAS_HW`] for Apple's).
726pub fn sample_windows(plan: &EncodePlan) -> (Vec<(f64, f64)>, f64) {
727    let duration = plan.source_duration_sec;
728    let win = sample_secs(plan);
729    if duration >= win * 4.0 {
730        (
731            [0.2, 0.5, 0.8]
732                .iter()
733                .map(|at| ((duration * at - win / 2.0).max(0.0), win))
734                .collect(),
735            if plan.spec.video.hardware {
736                SAMPLE_BIAS_HW
737            } else {
738                SAMPLE_BIAS
739            },
740        )
741    } else {
742        (vec![(0.0, duration)], 1.0)
743    }
744}
745
746/// The predicted final size of `plan` from its sample encodes: `video_bytes`
747/// of video-only output over `sampled_secs`, scaled by `bias`, plus the planned
748/// audio and container overhead.
749pub fn predicted_bytes(plan: &EncodePlan, video_bytes: u64, sampled_secs: f64, bias: f64) -> u64 {
750    let duration = plan.source_duration_sec;
751    if sampled_secs <= 0.0 {
752        return 0;
753    }
754    let video_bps = video_bytes as f64 * 8.0 / sampled_secs * bias;
755    let audio_bps = plan.spec.audio.as_ref().map(|a| a.bitrate_bps).unwrap_or(0) as f64;
756    ((video_bps + audio_bps) * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD)) as u64
757}
758
759#[cfg(test)]
760mod tests {
761    //! The planning logic on its own — no ffmpeg (runs with
762    //! `--no-default-features`, as on iOS).
763    use super::*;
764    use crate::engine::CaptureMeta;
765
766    fn video(w: u32, h: u32, secs: f64, size: u64) -> MediaInfo {
767        MediaInfo {
768            path: PathBuf::from("/tmp/clip.mp4"),
769            kind: MediaKind::Video,
770            duration_sec: secs,
771            size_bytes: size,
772            width: Some(w),
773            height: Some(h),
774            fps: Some(30.0),
775            video_codec: Some("h264".into()),
776            audio_codec: Some("aac".into()),
777            audio_channels: Some(2),
778            audio_bitrate_bps: Some(128_000),
779            capture: CaptureMeta::default(),
780            hdr: None,
781        }
782    }
783
784    #[test]
785    fn a_size_target_plans_a_bitrate_under_budget() {
786        let opts = ShrinkOpts {
787            goal: SizeGoal::Target(10_000_000),
788            ..ShrinkOpts::default()
789        };
790        let plan = plan(&video(1920, 1080, 60.0, 200_000_000), &opts, false).unwrap();
791        let vbps = plan.spec.video.bitrate_bps.unwrap();
792        assert_eq!(
793            vbps,
794            budget::video_bitrate_bps(10_000_000, 60.0, 128_000).unwrap()
795        );
796        assert!(plan.spec.two_pass);
797        assert!(plan.expected_bytes.unwrap() <= 10_000_000);
798    }
799
800    #[test]
801    fn apple_hardware_quality_is_used_only_when_available() {
802        let opts = ShrinkOpts {
803            hardware: true,
804            ..ShrinkOpts::default()
805        };
806        let info = video(1920, 1080, 60.0, 200_000_000);
807        let hw = plan(&info, &opts, true).unwrap();
808        assert!(hw.spec.video.hardware);
809        assert_eq!(
810            hw.spec.video.crf,
811            QualityPreset::Balanced.default_hw_quality(VideoCodec::H264)
812        );
813        let sw = plan(&info, &opts, false).unwrap();
814        assert!(!sw.spec.video.hardware);
815        assert_eq!(
816            sw.spec.video.crf,
817            Some(QualityPreset::Balanced.default_crf(VideoCodec::H264))
818        );
819    }
820
821    #[test]
822    fn a_portrait_cap_is_on_the_short_side() {
823        let opts = ShrinkOpts {
824            resolution: ResolutionOpt::Height(1080),
825            ..ShrinkOpts::default()
826        };
827        let p = plan(&video(2160, 3840, 30.0, 100_000_000), &opts, false).unwrap();
828        assert_eq!(p.spec.video.height, Some(1080));
829        assert!(p.spec.video.portrait);
830    }
831
832    #[test]
833    fn an_overshoot_is_corrected_in_proportion_down_to_a_floor() {
834        // 12 MB for a 10 MB target at 4 Mbit/s → ~3.23 Mbit/s.
835        assert_eq!(
836            corrected_bitrate(4_000_000, 10_000_000, 12_000_000),
837            Some(3_233_333)
838        );
839        assert_eq!(corrected_bitrate(10_000, 1_000, 1_000_000), None);
840        // The ceiling: the same plan, at the quality CRF instead of the budget.
841        let opts = ShrinkOpts {
842            goal: SizeGoal::Target(50_000_000),
843            ..ShrinkOpts::default()
844        };
845        let p = plan(&video(1920, 1080, 60.0, 200_000_000), &opts, false).unwrap();
846        let c = ceiling_plan(&p).unwrap();
847        assert_eq!(c.spec.video.bitrate_bps, None);
848        assert!(c.spec.video.crf.is_some() && !c.spec.two_pass);
849    }
850
851    #[test]
852    fn long_clips_sample_three_windows_short_ones_the_whole_clip() {
853        let opts = ShrinkOpts::default();
854        let long = plan(&video(1920, 1080, 60.0, 200_000_000), &opts, false).unwrap();
855        let (w, bias) = sample_windows(&long);
856        assert_eq!(w.len(), 3);
857        assert_eq!(w[1], (28.5, 3.0));
858        assert_eq!(bias, SAMPLE_BIAS);
859        // 1 MB of video over 9 s, 128 kbps audio, 60 s, 1 % overhead.
860        let bytes = predicted_bytes(&long, 1_000_000, 9.0, 1.0);
861        assert_eq!(
862            bytes,
863            ((8_000_000.0 / 9.0 + 128_000.0) * 60.0 / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD))
864                as u64
865        );
866        let short = plan(&video(1920, 1080, 8.0, 20_000_000), &opts, false).unwrap();
867        assert_eq!(sample_windows(&short), (vec![(0.0, 8.0)], 1.0));
868        // Apple's encoder over-predicts less.
869        let hw_opts = ShrinkOpts {
870            hardware: true,
871            ..ShrinkOpts::default()
872        };
873        let hw = plan(&video(1920, 1080, 60.0, 200_000_000), &hw_opts, true).unwrap();
874        assert_eq!(sample_windows(&hw).1, SAMPLE_BIAS_HW);
875    }
876
877    #[test]
878    fn local_capture_time_converts_to_utc() {
879        assert_eq!(
880            to_utc("2026-09-26T20:01:54+0300").as_deref(),
881            Some("2026-09-26T17:01:54Z")
882        );
883    }
884}