1use 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
21const AUDIO_LADDER: &[u64] = &[128_000, 96_000, 64_000, 48_000];
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum PassKind {
28 Single,
29 First,
30 Second,
31}
32
33#[derive(Debug, Default, Clone)]
35pub struct MediaEngine {
36 cancel: Option<deepshrink_ffmpeg::CancelToken>,
38}
39
40impl MediaEngine {
41 pub fn new() -> Self {
42 Self::default()
43 }
44
45 pub fn with_cancel(cancel: deepshrink_ffmpeg::CancelToken) -> Self {
49 Self {
50 cancel: Some(cancel),
51 }
52 }
53
54 fn tools(&self) -> Result<deepshrink_ffmpeg::Tools, EngineError> {
56 let tools = deepshrink_ffmpeg::locate()?;
57 Ok(match &self.cancel {
58 Some(c) => tools.with_cancel(c.clone()),
59 None => tools,
60 })
61 }
62
63 pub fn run_with_progress(
66 &self,
67 plan: &EncodePlan,
68 on_progress: &mut dyn FnMut(PassKind, f64),
69 ) -> Result<Outcome, EngineError> {
70 let outcome = match self.run_inner(plan, on_progress) {
71 Ok(o) => o,
72 Err(e) => {
73 if e.is_cancelled() && plan.output != plan.input {
76 let _ = fs::remove_file(&plan.output);
77 cleanup_passlog(&passlog_base(plan));
78 }
79 return Err(e);
80 }
81 };
82 if plan.spec.keep_metadata {
85 copy_mtime(&plan.input, &outcome.output);
86 }
87 Ok(outcome)
88 }
89
90 fn run_inner(
91 &self,
92 plan: &EncodePlan,
93 on_progress: &mut dyn FnMut(PassKind, f64),
94 ) -> Result<Outcome, EngineError> {
95 let tools = self.tools()?;
96 let encoder = resolve_encoder(&tools, plan)?;
97 let zscale = wants_zscale(&tools, plan);
98
99 if let Some(target_vmaf) = plan.target_vmaf {
102 if plan.spec.video.crf.is_some() && !plan.spec.audio_only && !plan.spec.passthrough {
103 return self.run_crf_search(
104 &tools,
105 plan,
106 encoder,
107 zscale,
108 target_vmaf,
109 on_progress,
110 );
111 }
112 }
113
114 let source = if plan.guard_larger && !plan.spec.passthrough {
119 fs::metadata(&plan.input).map(|m| m.len()).unwrap_or(0)
120 } else {
121 0
122 };
123 if source > 0
124 && plan.target_vmaf.is_none()
125 && !plan.spec.audio_only
126 && plan.spec.video.crf.is_some()
127 {
128 if let Some(predicted) = predict_crf_bytes(&tools, plan, encoder, zscale) {
129 if super::not_worth_it(predicted, source) {
130 return self.keep_original(&tools, plan, on_progress);
131 }
132 }
133 }
134
135 let ceiling = match ceiling_fit(&tools, plan, encoder, zscale) {
138 Some((ceiling, _)) => {
139 let o = self.run_plain(&tools, &ceiling, encoder, zscale, on_progress)?;
140 plan.target_bytes
142 .is_some_and(|t| o.final_bytes <= t)
143 .then_some(o)
144 }
145 None => None,
146 };
147 let mut outcome = match ceiling {
148 Some(o) => o,
149 None => self.run_plain(&tools, plan, encoder, zscale, on_progress)?,
150 };
151 if source > 0 && super::not_worth_it(outcome.final_bytes, source) {
152 let _ = fs::remove_file(&outcome.output);
153 return self.keep_original(&tools, plan, on_progress);
154 }
155
156 if plan.target_vmaf.is_some() && !plan.spec.audio_only && !plan.spec.passthrough {
159 outcome.vmaf = self.measure_output(&tools, plan, &plan.output);
160 }
161 Ok(outcome)
162 }
163
164 fn run_plain(
167 &self,
168 tools: &deepshrink_ffmpeg::Tools,
169 plan: &EncodePlan,
170 encoder: &str,
171 zscale: bool,
172 on_progress: &mut dyn FnMut(PassKind, f64),
173 ) -> Result<Outcome, EngineError> {
174 let passlog = passlog_base(plan);
175 let total = plan.source_duration_sec;
176
177 if plan.spec.passthrough {
178 return self.run_passthrough(tools, plan, on_progress);
179 }
180
181 if plan.spec.two_pass {
182 let args1 = build_pass_args(plan, PassKind::First, &passlog, encoder, zscale);
183 tools.run_pass(&args1, total, &mut |f| on_progress(PassKind::First, f))?;
184 let args2 = build_pass_args(plan, PassKind::Second, &passlog, encoder, zscale);
185 tools.run_pass(&args2, total, &mut |f| on_progress(PassKind::Second, f))?;
186 } else {
187 let args = build_pass_args(plan, PassKind::Single, &passlog, encoder, zscale);
188 tools.run_pass(&args, total, &mut |f| on_progress(PassKind::Single, f))?;
189 }
190
191 let mut size = fs::metadata(&plan.output)?.len();
192
193 if let (Some(target), Some(mut vbps)) = (plan.target_bytes, plan.spec.video.bitrate_bps) {
198 let (tries, pass) = if plan.spec.two_pass {
199 (1, PassKind::Second)
200 } else if plan.spec.video.hardware {
201 (3, PassKind::Single)
202 } else {
203 (0, PassKind::Single)
204 };
205 for _ in 0..tries {
206 if size <= target {
207 break;
208 }
209 let corrected = (vbps as f64 * (target as f64 / size as f64) * 0.97) as u64;
210 if corrected < budget::ABSOLUTE_MIN_VIDEO_BPS {
211 break;
212 }
213 vbps = corrected;
214 let mut retry = plan.clone();
215 retry.spec.video.bitrate_bps = Some(corrected);
216 let args = build_pass_args(&retry, pass, &passlog, encoder, zscale);
217 tools.run_pass(&args, total, &mut |f| on_progress(pass, f))?;
218 size = fs::metadata(&plan.output)?.len();
219 }
220 }
221
222 cleanup_passlog(&passlog);
223 Ok(Outcome {
224 output: plan.output.clone(),
225 final_bytes: size,
226 vmaf: None,
227 already_compact: false,
228 })
229 }
230
231 pub fn estimate(&self, plan: &EncodePlan) -> Result<Option<u64>, EngineError> {
238 if plan.spec.passthrough {
239 return Ok(fs::metadata(&plan.input).ok().map(|m| m.len()));
240 }
241 if ceiling_plan(plan).is_some() {
242 let tools = self.tools()?;
244 let encoder = resolve_encoder(&tools, plan)?;
245 let zscale = wants_zscale(&tools, plan);
246 let fit = ceiling_fit(&tools, plan, encoder, zscale).map(|(_, bytes)| bytes);
247 return Ok(fit.or(plan.expected_bytes));
248 }
249 if let Some(bytes) = plan.expected_bytes {
250 return Ok(Some(bytes));
251 }
252 if plan.spec.audio_only || plan.spec.video.crf.is_none() {
253 return Ok(None);
254 }
255 let tools = self.tools()?;
256 let encoder = resolve_encoder(&tools, plan)?;
257 let zscale = wants_zscale(&tools, plan);
258 Ok(predict_crf_bytes(&tools, plan, encoder, zscale))
259 }
260
261 fn keep_original(
264 &self,
265 tools: &deepshrink_ffmpeg::Tools,
266 plan: &EncodePlan,
267 on_progress: &mut dyn FnMut(PassKind, f64),
268 ) -> Result<Outcome, EngineError> {
269 let _ = tools; let output = match plan.input.extension() {
271 Some(ext) => plan.output.with_extension(ext),
272 None => plan.output.clone(),
273 };
274 fs::copy(&plan.input, &output)?;
277 on_progress(PassKind::Single, 1.0);
278 Ok(Outcome {
279 final_bytes: fs::metadata(&output)?.len(),
280 output,
281 vmaf: None,
282 already_compact: true,
283 })
284 }
285
286 fn run_passthrough(
295 &self,
296 tools: &deepshrink_ffmpeg::Tools,
297 plan: &EncodePlan,
298 on_progress: &mut dyn FnMut(PassKind, f64),
299 ) -> Result<Outcome, EngineError> {
300 let args = build_pass_args(plan, PassKind::Single, "", "copy", false);
302 let remuxed = tools.run_pass(&args, plan.source_duration_sec, &mut |f| {
303 on_progress(PassKind::Single, f)
304 });
305 if remuxed.is_err() {
306 fs::copy(&plan.input, &plan.output)?;
307 on_progress(PassKind::Single, 1.0);
308 }
309 let size = fs::metadata(&plan.output)?.len();
310 Ok(Outcome {
311 output: plan.output.clone(),
312 final_bytes: size,
313 vmaf: None,
314 already_compact: true,
315 })
316 }
317
318 fn run_crf_search(
325 &self,
326 tools: &deepshrink_ffmpeg::Tools,
327 plan: &EncodePlan,
328 encoder: &str,
329 zscale: bool,
330 target_vmaf: f64,
331 on_progress: &mut dyn FnMut(PassKind, f64),
332 ) -> Result<Outcome, EngineError> {
333 let (ref_w, ref_h) = match (plan.source_width, plan.source_height) {
334 (Some(w), Some(h)) => (w, h),
335 _ => return self.run_plain(tools, plan, encoder, zscale, on_progress),
336 };
337 let ref_fps = plan.source_fps.unwrap_or(0.0);
338 let total = plan.source_duration_sec;
339 let (lo, hi) = plan.spec.video.codec.crf_search_bounds();
340 let n_threads = thread_count();
341
342 let mut err: Option<EngineError> = None;
343 let mut last_crf: Option<u8> = None;
344
345 let (chosen_crf, chosen_vmaf) = budget::search_crf(target_vmaf, lo, hi, |crf| {
346 if err.is_some() {
347 return f64::NEG_INFINITY;
348 }
349 match encode_at_crf(tools, plan, encoder, zscale, crf, total, on_progress).and_then(
350 |()| {
351 last_crf = Some(crf);
352 deepshrink_ffmpeg::measure_vmaf(
353 &tools.ffmpeg,
354 &plan.output,
355 &plan.input,
356 ref_w,
357 ref_h,
358 ref_fps,
359 n_threads,
360 )
361 .map_err(EngineError::from)
362 },
363 ) {
364 Ok(v) => v,
365 Err(e) => {
366 err = Some(e);
367 f64::NEG_INFINITY
368 }
369 }
370 });
371 if let Some(e) = err {
372 return Err(e);
373 }
374
375 if last_crf != Some(chosen_crf) {
377 encode_at_crf(tools, plan, encoder, zscale, chosen_crf, total, on_progress)?;
378 }
379 let size = fs::metadata(&plan.output)?.len();
380 Ok(Outcome {
381 output: plan.output.clone(),
382 final_bytes: size,
383 vmaf: Some(chosen_vmaf),
384 already_compact: false,
385 })
386 }
387
388 fn measure_output(
391 &self,
392 tools: &deepshrink_ffmpeg::Tools,
393 plan: &EncodePlan,
394 output: &Path,
395 ) -> Option<f64> {
396 let (w, h) = (plan.source_width?, plan.source_height?);
397 let fps = plan.source_fps.unwrap_or(0.0);
398 deepshrink_ffmpeg::measure_vmaf(
399 &tools.ffmpeg,
400 output,
401 &plan.input,
402 w,
403 h,
404 fps,
405 thread_count(),
406 )
407 .ok()
408 }
409
410 fn plan_audio(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
412 let duration = info.duration_sec;
413 if !duration.is_finite() || duration <= 0.0 {
414 return Err(EngineError::Unsupported(format!(
415 "could not determine duration of {}",
416 info.path.display()
417 )));
418 }
419 let codec = opts.audio_codec;
420 let target = target_bytes(&opts.goal, info.size_bytes);
421
422 if let Some(tb) = target {
424 if info.size_bytes > 0 && info.size_bytes <= tb {
425 let src_ext = info
426 .path
427 .extension()
428 .and_then(|e| e.to_str())
429 .unwrap_or("audio");
430 let output = opts
431 .output
432 .clone()
433 .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
434 return Ok(passthrough_plan(
435 info,
436 output,
437 tb,
438 false,
439 opts.keep_metadata,
440 ));
441 }
442 }
443
444 let mono = opts.mono || info.audio_channels == Some(1);
446
447 let (bitrate_bps, expected_bytes) = match target {
448 Some(tb) => {
449 let raw = budget::audio_bitrate_bps(tb, duration).ok_or(EngineError::Infeasible)?;
450 if raw < budget::ABSOLUTE_MIN_AUDIO_BPS {
451 return Err(EngineError::Infeasible);
452 }
453 let bps = budget::snap_audio_bitrate(raw);
454 let predicted = (bps as f64 * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD))
455 .round() as u64;
456 (bps, Some(predicted))
457 }
458 None => {
459 let bps = quality_audio_bps(opts.quality, codec, mono);
461 if !opts.allow_larger && already_compact_audio(bps, info) {
465 let src_ext = info
466 .path
467 .extension()
468 .and_then(|e| e.to_str())
469 .unwrap_or("audio");
470 let output = opts
471 .output
472 .clone()
473 .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
474 let mut plan =
475 passthrough_plan(info, output, info.size_bytes, false, opts.keep_metadata);
476 plan.summary =
477 "stream copy (already compact — a re-encode would not be smaller)".into();
478 return Ok(plan);
479 }
480 let predicted = (bps as f64 * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD))
482 .round() as u64;
483 (bps, Some(predicted))
484 }
485 };
486
487 let audio = AudioSpec {
488 codec,
489 bitrate_bps,
490 mono,
491 sample_rate: opts.sample_rate,
492 vbr: opts.vbr,
493 };
494 let output = opts
495 .output
496 .clone()
497 .unwrap_or_else(|| output_with_ext(&info.path, codec.extension()));
498 let summary = build_audio_summary(&audio, info.audio_channels);
499
500 Ok(EncodePlan {
501 input: info.path.clone(),
502 output,
503 summary,
504 expected_bytes,
505 target_bytes: target,
506 target_vmaf: None,
507 source_duration_sec: duration,
508 source_width: info.width,
509 source_height: info.height,
510 source_fps: info.fps,
511 spec: EncodeSpec {
512 video: placeholder_video_spec(),
513 audio: Some(audio),
514 faststart: false,
515 two_pass: false,
516 passthrough: false,
517 audio_only: true,
518 dpi: None,
519 keep_metadata: opts.keep_metadata,
520 tags: capture_tags(info, opts.keep_metadata),
521 },
522 guard_larger: target.is_none() && !opts.allow_larger,
524 ceiling_crf: None,
525 })
526 }
527}
528
529impl Engine for MediaEngine {
530 fn supports(&self, input: &Path) -> bool {
531 matches!(detect_kind(input), MediaKind::Video | MediaKind::Audio)
532 }
533
534 fn probe(&self, input: &Path) -> Result<MediaInfo, EngineError> {
535 let tools = deepshrink_ffmpeg::locate()?;
536 let p = deepshrink_ffmpeg::probe(&tools.ffprobe, input)?;
537
538 let video = p.video_stream();
539 let audio = p.audio_stream();
540 let size_bytes = p
542 .size_bytes()
543 .or_else(|| fs::metadata(input).ok().map(|m| m.len()))
544 .unwrap_or(0);
545
546 Ok(MediaInfo {
547 path: input.to_path_buf(),
548 kind: detect_kind(input),
549 duration_sec: p.duration_sec().unwrap_or(0.0),
550 size_bytes,
551 width: video.and_then(|v| v.display_size().0),
553 height: video.and_then(|v| v.display_size().1),
554 fps: p.fps(),
555 video_codec: video.and_then(|v| v.codec_name.clone()),
556 audio_codec: audio.and_then(|a| a.codec_name.clone()),
557 audio_channels: audio.and_then(|a| a.channels),
558 audio_bitrate_bps: p.audio_bitrate_bps(),
559 capture: capture_meta(&p),
560 hdr: video
561 .and_then(|v| v.color_transfer.as_deref())
562 .and_then(Hdr::from_transfer),
563 })
564 }
565
566 fn plan(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
567 match info.kind {
568 MediaKind::Audio => return self.plan_audio(info, opts),
569 MediaKind::Unsupported => {
570 return Err(EngineError::Unsupported(format!(
571 "{} is not a supported media file",
572 info.path.display()
573 )))
574 }
575 MediaKind::Video => {}
576 }
577 let duration = info.duration_sec;
578 if !duration.is_finite() || duration <= 0.0 {
579 return Err(EngineError::Unsupported(format!(
580 "could not determine duration of {}",
581 info.path.display()
582 )));
583 }
584 let (w, h) = (info.width.unwrap_or(0), info.height.unwrap_or(0));
586 let portrait = h > w;
587 let src_height = w.min(h);
588
589 let target = target_bytes(&opts.goal, info.size_bytes);
590 let output = opts
591 .output
592 .clone()
593 .unwrap_or_else(|| output_with_ext(&info.path, video_container(info, target, opts)));
594
595 if let Some(tb) = target {
601 if info.size_bytes > 0 && info.size_bytes <= tb {
602 let src_ext = info
603 .path
604 .extension()
605 .and_then(|e| e.to_str())
606 .unwrap_or("mp4");
607 let output = opts
608 .output
609 .clone()
610 .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
611 return Ok(passthrough_plan(info, output, tb, true, opts.keep_metadata));
612 }
613 }
614
615 let audio = decide_audio(
616 opts,
617 info.has_audio(),
618 info.audio_bitrate_bps,
619 target,
620 duration,
621 )?;
622 let audio_bps = audio.as_ref().map(|a| a.bitrate_bps).unwrap_or(0);
623
624 let hw_quality = opts
627 .quality
628 .default_hw_quality(opts.video_codec)
629 .filter(|_| {
630 opts.hardware && opts.target_vmaf.is_none() && hardware_encoding_available()
631 });
632 let hardware = hw_quality.is_some();
633 let quality_value =
635 hw_quality.unwrap_or_else(|| opts.quality.default_crf(opts.video_codec));
636
637 let (video, expected_bytes) = if let Some(tb) = target {
638 let vbps = budget::video_bitrate_bps(tb, duration, audio_bps)
639 .filter(|&b| b >= budget::ABSOLUTE_MIN_VIDEO_BPS)
640 .ok_or(EngineError::Infeasible)?;
641 let height = pick_height(opts.resolution, src_height, vbps);
642 let predicted = ((vbps + audio_bps) as f64 * duration / 8.0
643 * (1.0 + budget::CONTAINER_OVERHEAD))
644 .round() as u64;
645 (
646 VideoSpec {
647 codec: opts.video_codec,
648 bitrate_bps: Some(vbps),
649 crf: None,
650 height,
651 fps: pick_fps(opts.fps, info.fps),
652 preset: opts.quality,
653 to_sdr: info.hdr,
655 hardware,
656 portrait,
657 },
658 Some(predicted),
659 )
660 } else {
661 let crf = quality_value;
664 let height = match opts.resolution {
665 ResolutionOpt::Height(h) => clamp_height(h, src_height),
666 ResolutionOpt::Auto => None,
667 };
668 (
669 VideoSpec {
670 codec: opts.video_codec,
671 bitrate_bps: None,
672 crf: Some(crf),
673 height,
674 fps: pick_fps(opts.fps, info.fps),
675 preset: opts.quality,
676 to_sdr: info
679 .hdr
680 .filter(|_| hardware && opts.video_codec == VideoCodec::H264),
681 hardware,
682 portrait,
683 },
684 None,
685 )
686 };
687
688 let two_pass = video.bitrate_bps.is_some() && opts.two_pass.unwrap_or(true) && !hardware;
694 let summary = build_summary(&video, audio.as_ref(), two_pass);
695
696 Ok(EncodePlan {
697 input: info.path.clone(),
698 output,
699 summary,
700 expected_bytes,
701 target_bytes: target,
702 target_vmaf: opts.target_vmaf,
703 source_duration_sec: duration,
704 source_width: info.width,
705 source_height: info.height,
706 source_fps: info.fps,
707 spec: EncodeSpec {
708 video,
709 audio,
710 faststart: true,
711 two_pass,
712 passthrough: false,
713 audio_only: false,
714 dpi: None,
715 keep_metadata: opts.keep_metadata,
716 tags: capture_tags(info, opts.keep_metadata),
717 },
718 guard_larger: target.is_none() && !opts.allow_larger,
720 ceiling_crf: target.map(|_| quality_value),
721 })
722 }
723
724 fn run(&self, plan: &EncodePlan) -> Result<Outcome, EngineError> {
725 self.run_with_progress(plan, &mut |_, _| {})
726 }
727}
728
729fn placeholder_video_spec() -> VideoSpec {
731 VideoSpec {
732 codec: crate::options::VideoCodec::H264,
733 bitrate_bps: None,
734 crf: None,
735 height: None,
736 fps: None,
737 preset: crate::options::QualityPreset::Balanced,
738 to_sdr: None,
739 hardware: false,
740 portrait: false,
741 }
742}
743
744pub fn hardware_encoding_available() -> bool {
748 static AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
749 *AVAILABLE.get_or_init(|| {
750 cfg!(all(target_os = "macos", target_arch = "aarch64"))
752 && deepshrink_ffmpeg::locate().is_ok_and(|t| {
753 deepshrink_ffmpeg::has_encoder(&t.ffmpeg, "h264_videotoolbox")
754 && deepshrink_ffmpeg::has_encoder(&t.ffmpeg, "hevc_videotoolbox")
755 })
756 })
757}
758
759fn passthrough_plan(
762 info: &MediaInfo,
763 output: PathBuf,
764 target: u64,
765 faststart: bool,
766 keep_metadata: bool,
767) -> EncodePlan {
768 EncodePlan {
769 input: info.path.clone(),
770 output,
771 summary: "stream copy (already within target)".to_string(),
772 expected_bytes: Some(info.size_bytes),
773 target_bytes: Some(target),
774 target_vmaf: None,
775 source_duration_sec: info.duration_sec,
776 source_width: info.width,
777 source_height: info.height,
778 source_fps: info.fps,
779 spec: EncodeSpec {
780 video: placeholder_video_spec(),
781 audio: None,
782 faststart,
783 two_pass: false,
784 passthrough: true,
785 audio_only: false,
786 dpi: None,
787 keep_metadata,
788 tags: capture_tags(info, keep_metadata),
789 },
790 guard_larger: false,
791 ceiling_crf: None,
792 }
793}
794
795fn quality_audio_bps(quality: QualityPreset, codec: AudioCodec, mono: bool) -> u64 {
798 let stereo = match (codec, quality) {
799 (AudioCodec::Opus, QualityPreset::Fast) => 64_000,
800 (AudioCodec::Opus, QualityPreset::Balanced) => 96_000,
801 (AudioCodec::Opus, QualityPreset::Max) => 128_000,
802 (AudioCodec::Mp3, QualityPreset::Fast) => 128_000,
803 (AudioCodec::Mp3, QualityPreset::Balanced) => 160_000,
804 (AudioCodec::Mp3, QualityPreset::Max) => 256_000,
805 (AudioCodec::Aac, QualityPreset::Fast) => 96_000,
806 (AudioCodec::Aac, QualityPreset::Balanced) => 128_000,
807 (AudioCodec::Aac, QualityPreset::Max) => 192_000,
808 };
809 if mono {
810 stereo / 2
811 } else {
812 stereo
813 }
814}
815
816fn already_compact_audio(bps: u64, info: &MediaInfo) -> bool {
820 if info.duration_sec <= 0.0 || info.size_bytes == 0 {
821 return false;
822 }
823 let source_bps = info.size_bytes as f64 * 8.0 / info.duration_sec;
824 bps as f64 >= source_bps * 0.9
825}
826
827fn copy_mtime(input: &Path, output: &Path) {
829 let Ok(mtime) = fs::metadata(input).and_then(|m| m.modified()) else {
830 return;
831 };
832 if let Ok(f) = fs::File::options().write(true).open(output) {
833 let _ = f.set_modified(mtime);
834 }
835}
836
837fn video_container(info: &MediaInfo, target: Option<u64>, opts: &ShrinkOpts) -> &'static str {
843 let mov_source = info
844 .path
845 .extension()
846 .and_then(|e| e.to_str())
847 .is_some_and(|e| e.eq_ignore_ascii_case("mov"));
848 if mov_source && target.is_none() && opts.video_codec != VideoCodec::Av1 {
849 "mov"
850 } else {
851 "mp4"
852 }
853}
854
855fn capture_meta(p: &deepshrink_ffmpeg::Ffprobe) -> CaptureMeta {
857 let tag = |keys: &[&str]| {
858 keys.iter()
859 .find_map(|k| p.format_tag(k))
860 .map(str::to_string)
861 };
862 let created_local = tag(&["com.apple.quicktime.creationdate"]);
863 let created_utc = created_local
864 .as_deref()
865 .and_then(to_utc)
866 .or_else(|| tag(&["creation_time"]));
867 CaptureMeta {
868 created_utc,
869 created_local,
870 location: tag(&["com.apple.quicktime.location.ISO6709", "location"]),
871 make: tag(&["com.apple.quicktime.make", "make"]),
872 model: tag(&["com.apple.quicktime.model", "model"]),
873 }
874}
875
876fn capture_tags(info: &MediaInfo, keep: bool) -> Vec<(String, String)> {
879 if !keep {
880 return Vec::new();
881 }
882 let c = &info.capture;
883 [
884 ("creation_time", c.created_utc.as_ref()),
885 ("date", c.created_local.as_ref()),
886 ("location", c.location.as_ref()),
887 ("make", c.make.as_ref()),
888 ("model", c.model.as_ref()),
889 ]
890 .into_iter()
891 .filter_map(|(k, v)| v.map(|v| (k.to_string(), v.clone())))
892 .collect()
893}
894
895fn to_utc(s: &str) -> Option<String> {
898 let s = s.trim();
899 let num = |a: usize, b: usize| s.get(a..b)?.parse::<i64>().ok();
900 let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
901 let (h, mi, se) = (num(11, 13)?, num(14, 16)?, num(17, 19)?);
902 if s.get(4..5)? != "-" || s.get(10..11).map(|c| c == "T" || c == " ") != Some(true) {
903 return None;
904 }
905 let rest = s
907 .get(19..)?
908 .trim_start_matches(|c: char| c == '.' || c.is_ascii_digit());
909 let offset_min = match rest {
910 "" | "Z" | "z" => 0,
911 r if r.starts_with('+') || r.starts_with('-') => {
912 let digits: String = r[1..].chars().filter(char::is_ascii_digit).collect();
913 let (oh, om) = (
914 digits.get(0..2)?.parse::<i64>().ok()?,
915 digits.get(2..4).unwrap_or("00").parse::<i64>().ok()?,
916 );
917 let m = oh * 60 + om;
918 if r.starts_with('-') {
919 -m
920 } else {
921 m
922 }
923 }
924 _ => return None,
925 };
926 let secs = days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + se - offset_min * 60;
927 let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
928 let (y, mo, d) = civil_from_days(days);
929 Some(format!(
930 "{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}Z",
931 rem / 3600,
932 rem % 3600 / 60,
933 rem % 60
934 ))
935}
936
937fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
939 let y = if m <= 2 { y - 1 } else { y };
940 let era = y.div_euclid(400);
941 let yoe = y - era * 400;
942 let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
943 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
944 era * 146_097 + doe - 719_468
945}
946
947fn civil_from_days(z: i64) -> (i64, i64, i64) {
949 let z = z + 719_468;
950 let era = z.div_euclid(146_097);
951 let doe = z - era * 146_097;
952 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
953 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
954 let mp = (5 * doy + 2) / 153;
955 let d = doy - (153 * mp + 2) / 5 + 1;
956 let m = if mp < 10 { mp + 3 } else { mp - 9 };
957 (yoe + era * 400 + i64::from(m <= 2), m, d)
958}
959
960fn metadata_args(plan: &EncodePlan) -> (Vec<OsString>, Option<&'static str>) {
967 if !plan.spec.keep_metadata {
968 return (vec!["-map_metadata".into(), "-1".into()], None);
969 }
970 let mut a: Vec<OsString> = vec!["-map_metadata".into(), "0".into()];
971 for (k, v) in &plan.spec.tags {
972 a.push("-metadata".into());
973 a.push(format!("{k}={v}").into());
974 }
975 (a, None)
976}
977
978fn movflags(faststart: bool, meta: Option<&'static str>) -> Option<String> {
980 let mut v = String::new();
981 if faststart {
982 v.push_str("+faststart");
983 }
984 if let Some(m) = meta {
985 v.push_str(m);
986 }
987 (!v.is_empty()).then_some(v)
988}
989
990const CEILING_MARGIN: f64 = 0.9;
993
994fn ceiling_plan(plan: &EncodePlan) -> Option<EncodePlan> {
997 let crf = plan.ceiling_crf?;
998 if plan.target_bytes.is_none()
999 || plan.spec.passthrough
1000 || plan.spec.audio_only
1001 || plan.spec.video.bitrate_bps.is_none()
1002 {
1003 return None;
1004 }
1005 let mut c = plan.clone();
1006 c.spec.video.bitrate_bps = None;
1007 c.spec.video.crf = Some(crf);
1008 c.spec.two_pass = false;
1009 c.summary = build_summary(&c.spec.video, c.spec.audio.as_ref(), false);
1010 Some(c)
1011}
1012
1013fn ceiling_fit(
1017 tools: &deepshrink_ffmpeg::Tools,
1018 plan: &EncodePlan,
1019 encoder: &str,
1020 zscale: bool,
1021) -> Option<(EncodePlan, u64)> {
1022 let target = plan.target_bytes?;
1023 let ceiling = ceiling_plan(plan)?;
1024 let predicted = predict_crf_bytes(tools, &ceiling, encoder, zscale)?;
1025 ((predicted as f64) < target as f64 * CEILING_MARGIN).then_some((ceiling, predicted))
1026}
1027
1028fn wants_zscale(tools: &deepshrink_ffmpeg::Tools, plan: &EncodePlan) -> bool {
1030 plan.spec.video.to_sdr == Some(Hdr::Pq)
1031 && deepshrink_ffmpeg::has_filter(&tools.ffmpeg, "zscale")
1032}
1033
1034const SAMPLE_SECS: f64 = 3.0;
1036const MIN_SAMPLE_SECS: f64 = 1.5;
1040
1041fn sample_secs(plan: &EncodePlan) -> f64 {
1044 const REFERENCE: f64 = 1920.0 * 1080.0 * 30.0;
1045 let (w, h) = match (plan.source_width, plan.source_height) {
1046 (Some(w), Some(h)) if w > 0 && h > 0 => (w as f64, h as f64),
1047 _ => return SAMPLE_SECS,
1048 };
1049 let fps = plan
1050 .source_fps
1051 .filter(|f| f.is_finite() && *f > 0.0)
1052 .unwrap_or(30.0);
1053 (SAMPLE_SECS * REFERENCE / (w * h * fps)).clamp(MIN_SAMPLE_SECS, SAMPLE_SECS)
1054}
1055const SAMPLE_BIAS: f64 = 0.92;
1058
1059fn predict_crf_bytes(
1063 tools: &deepshrink_ffmpeg::Tools,
1064 plan: &EncodePlan,
1065 encoder: &str,
1066 zscale: bool,
1067) -> Option<u64> {
1068 let duration = plan.source_duration_sec;
1069 if !duration.is_finite() || duration <= 0.0 {
1070 return None;
1071 }
1072 let win = sample_secs(plan);
1075 let (windows, bias): (Vec<(f64, f64)>, f64) = if duration >= win * 4.0 {
1076 (
1077 [0.2, 0.5, 0.8]
1078 .iter()
1079 .map(|at| ((duration * at - win / 2.0).max(0.0), win))
1080 .collect(),
1081 SAMPLE_BIAS,
1082 )
1083 } else {
1084 (vec![(0.0, duration)], 1.0)
1085 };
1086 let mut sample = plan.clone();
1087 sample.spec.audio = None;
1088 sample.spec.faststart = false;
1089 let mut video_bytes = 0u64;
1090 let mut sampled = 0.0;
1091 for (i, &(start, len)) in windows.iter().enumerate() {
1092 sample.output =
1093 std::env::temp_dir().join(format!("deepshrink-sample-{}-{i}.mp4", std::process::id()));
1094 let mut args = build_pass_args(&sample, PassKind::Single, "", encoder, zscale);
1095 let at_input = args.iter().position(|a| a == "-i")?;
1096 args.splice(
1097 at_input..at_input,
1098 [
1099 OsString::from("-ss"),
1100 OsString::from(format!("{start:.2}")),
1101 OsString::from("-t"),
1102 OsString::from(format!("{len:.2}")),
1103 ],
1104 );
1105 let ran = tools.run_pass(&args, len, &mut |_| {});
1106 let bytes = fs::metadata(&sample.output).map(|m| m.len()).ok();
1107 let _ = fs::remove_file(&sample.output);
1108 video_bytes += bytes.filter(|_| ran.is_ok())?;
1109 sampled += len;
1110 }
1111 let video_bps = video_bytes as f64 * 8.0 / sampled * bias;
1112 let audio_bps = plan.spec.audio.as_ref().map(|a| a.bitrate_bps).unwrap_or(0) as f64;
1113 Some(((video_bps + audio_bps) * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD)) as u64)
1114}
1115
1116fn build_audio_summary(audio: &AudioSpec, src_channels: Option<u32>) -> String {
1119 let mut parts = vec![
1120 audio.codec.label().to_string(),
1121 format!("{} kbps", audio.bitrate_bps / 1000),
1122 ];
1123 if audio.mono {
1124 let note = if src_channels == Some(1) {
1126 "mono"
1127 } else {
1128 "mono (downmix)"
1129 };
1130 parts.push(note.to_string());
1131 }
1132 if let Some(sr) = audio.sample_rate {
1133 parts.push(format!("{} Hz", sr));
1134 }
1135 parts.join(" · ")
1136}
1137
1138fn target_bytes(goal: &SizeGoal, original: u64) -> Option<u64> {
1140 match goal {
1141 SizeGoal::Target(b) => Some(*b),
1142 SizeGoal::Reduce(f) => Some(budget::reduce_target_bytes(original, *f)),
1143 SizeGoal::Preset(p) => p.limit_bytes,
1144 SizeGoal::Quality => None,
1145 }
1146}
1147
1148fn decide_audio(
1150 opts: &ShrinkOpts,
1151 has_audio: bool,
1152 source_bps: Option<u64>,
1153 target: Option<u64>,
1154 duration: f64,
1155) -> Result<Option<AudioSpec>, EngineError> {
1156 if !has_audio {
1157 return Ok(None);
1158 }
1159 let mono = opts.mono;
1162 match opts.audio {
1163 AudioChoice::Drop => Ok(None),
1164 AudioChoice::Bitrate(b) => Ok(Some(AudioSpec {
1165 mono,
1166 ..AudioSpec::cbr(AudioCodec::Aac, b)
1167 })),
1168 AudioChoice::Keep => {
1169 let bps = match target {
1170 Some(tb) => budget::fit_audio_bps(tb, duration, AUDIO_LADDER)
1171 .ok_or(EngineError::Infeasible)?,
1172 None => budget::DEFAULT_AUDIO_BPS,
1173 };
1174 let bps = match source_bps {
1178 Some(src) => bps.min(src.max(MIN_TRACK_BPS)),
1179 None => bps,
1180 };
1181 Ok(Some(AudioSpec {
1182 mono,
1183 ..AudioSpec::cbr(AudioCodec::Aac, bps)
1184 }))
1185 }
1186 }
1187}
1188
1189const MIN_TRACK_BPS: u64 = 32_000;
1192
1193fn pick_height(res: ResolutionOpt, src_height: u32, vbps: u64) -> Option<u32> {
1195 match res {
1196 ResolutionOpt::Height(h) => clamp_height(h, src_height),
1197 ResolutionOpt::Auto => {
1198 let chosen = budget::choose_height(src_height, vbps);
1199 if src_height > 0 && chosen < src_height {
1200 Some(chosen)
1201 } else {
1202 None
1203 }
1204 }
1205 }
1206}
1207
1208fn clamp_height(requested: u32, src_height: u32) -> Option<u32> {
1211 if src_height == 0 {
1212 return Some(requested);
1213 }
1214 let h = requested.min(src_height);
1215 if h == src_height {
1216 None
1217 } else {
1218 Some(h)
1219 }
1220}
1221
1222fn pick_fps(fps: FpsOpt, src_fps: Option<f64>) -> Option<u32> {
1224 match fps {
1225 FpsOpt::Auto => None,
1226 FpsOpt::Cap(f) => match src_fps {
1227 Some(src) if (f as f64) >= src => None,
1228 _ => Some(f),
1229 },
1230 }
1231}
1232
1233fn output_with_ext(input: &Path, ext: &str) -> PathBuf {
1235 let stem = input
1236 .file_stem()
1237 .map(|s| s.to_string_lossy().into_owned())
1238 .unwrap_or_else(|| "output".to_string());
1239 let mut out = input.parent().map(Path::to_path_buf).unwrap_or_default();
1240 out.push(format!("{stem}.shrink.{ext}"));
1241 out
1242}
1243
1244fn build_summary(video: &VideoSpec, audio: Option<&AudioSpec>, two_pass: bool) -> String {
1245 let mut parts = vec![if video.hardware {
1246 format!("{} (Apple hardware)", video.codec.label())
1247 } else {
1248 video.codec.label().to_string()
1249 }];
1250 match (video.bitrate_bps, video.crf) {
1251 (Some(bps), _) => parts.push(format!("up to {} kbps video", bps / 1000)),
1252 (_, Some(q)) if video.hardware => parts.push(format!("quality {q}")),
1253 (_, Some(crf)) => parts.push(format!("CRF {crf}")),
1254 _ => {}
1255 }
1256 if let Some(a) = audio {
1257 parts.push(format!("{} kbps audio", a.bitrate_bps / 1000));
1258 } else {
1259 parts.push("no audio".to_string());
1260 }
1261 if let Some(h) = video.height {
1262 parts.push(format!("{h}p"));
1263 }
1264 if let Some(f) = video.fps {
1265 parts.push(format!("{f} fps"));
1266 }
1267 if video.to_sdr.is_some() {
1268 parts.push("HDR → SDR".to_string());
1269 }
1270 parts.push(
1271 if two_pass {
1272 "two-pass"
1273 } else if video.hardware {
1274 "one pass"
1275 } else {
1276 "CRF"
1277 }
1278 .to_string(),
1279 );
1280 parts.join(" · ")
1281}
1282
1283fn video_filters(video: &VideoSpec, zscale: bool) -> Option<String> {
1292 const COLORSPACE: &str = "colorspace=all=bt709:iall=bt2020:itrc=bt2020-10:format=yuv420p";
1293 let mut chain = Vec::new();
1294 if let Some(h) = video.height {
1296 chain.push(if video.portrait {
1297 format!("scale={h}:-2")
1298 } else {
1299 format!("scale=-2:{h}")
1300 });
1301 }
1302 match video.to_sdr {
1303 Some(Hdr::Pq) if zscale => chain.push(
1304 "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,\
1305 tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
1306 .to_string(),
1307 ),
1308 Some(_) => chain.push(COLORSPACE.to_string()),
1309 None => {}
1310 }
1311 (!chain.is_empty()).then(|| chain.join(","))
1312}
1313
1314fn passlog_base(plan: &EncodePlan) -> String {
1316 let stem = plan
1317 .input
1318 .file_stem()
1319 .map(|s| s.to_string_lossy().into_owned())
1320 .unwrap_or_else(|| "ds".to_string());
1321 let dir = std::env::temp_dir();
1322 dir.join(format!("deepshrink-{}-{}", std::process::id(), stem))
1323 .to_string_lossy()
1324 .into_owned()
1325}
1326
1327fn cleanup_passlog(base: &str) {
1329 for suffix in ["-0.log", "-0.log.mbtree"] {
1330 let _ = fs::remove_file(format!("{base}{suffix}"));
1331 }
1332}
1333
1334fn encode_at_crf(
1336 tools: &deepshrink_ffmpeg::Tools,
1337 plan: &EncodePlan,
1338 encoder: &str,
1339 zscale: bool,
1340 crf: u8,
1341 total: f64,
1342 on_progress: &mut dyn FnMut(PassKind, f64),
1343) -> Result<(), EngineError> {
1344 let mut trial = plan.clone();
1345 trial.spec.video.crf = Some(crf);
1346 trial.spec.video.bitrate_bps = None;
1347 trial.spec.two_pass = false;
1348 let args = build_pass_args(&trial, PassKind::Single, "", encoder, zscale);
1349 tools.run_pass(&args, total, &mut |f| on_progress(PassKind::Single, f))?;
1350 Ok(())
1351}
1352
1353fn thread_count() -> usize {
1355 std::thread::available_parallelism()
1356 .map(|n| n.get())
1357 .unwrap_or(1)
1358}
1359
1360fn null_sink() -> &'static str {
1362 if cfg!(windows) {
1363 "NUL"
1364 } else {
1365 "/dev/null"
1366 }
1367}
1368
1369fn resolve_encoder(
1377 tools: &deepshrink_ffmpeg::Tools,
1378 plan: &EncodePlan,
1379) -> Result<&'static str, EngineError> {
1380 let codec = plan.spec.video.codec;
1381 if plan.spec.video.hardware && !plan.spec.passthrough && !plan.spec.audio_only {
1383 if let Some(hw) = codec.hardware_encoder() {
1384 return Ok(hw);
1385 }
1386 }
1387 let primary = codec.encoder();
1388 let Some(fallback) = codec.fallback_encoder() else {
1389 return Ok(primary);
1390 };
1391 if plan.spec.passthrough || plan.spec.audio_only {
1393 return Ok(primary);
1394 }
1395 if deepshrink_ffmpeg::has_encoder(&tools.ffmpeg, primary) {
1396 return Ok(primary);
1397 }
1398 if deepshrink_ffmpeg::has_encoder(&tools.ffmpeg, fallback) {
1399 return Ok(fallback);
1400 }
1401 Err(EngineError::Unsupported(format!(
1402 "this ffmpeg build has no {} encoder (looked for {primary} and {fallback})",
1403 codec.label()
1404 )))
1405}
1406
1407fn build_pass_args(
1412 plan: &EncodePlan,
1413 pass: PassKind,
1414 passlog: &str,
1415 encoder: &str,
1416 zscale: bool,
1417) -> Vec<OsString> {
1418 let s = &plan.spec;
1419 let mut a: Vec<OsString> = Vec::new();
1420 macro_rules! push {
1423 ($arg:expr) => {
1424 a.push(OsString::from($arg))
1425 };
1426 }
1427
1428 push!("-hide_banner");
1429 push!("-y");
1430 push!("-loglevel");
1431 push!("error");
1432 push!("-progress");
1433 push!("pipe:1");
1434 push!("-nostats");
1435 push!("-i");
1436 a.push(plan.input.clone().into_os_string());
1437
1438 let (meta, meta_flag) = metadata_args(plan);
1439
1440 if s.passthrough {
1442 push!("-c");
1443 push!("copy");
1444 a.extend(meta.iter().cloned());
1445 if let Some(flags) = movflags(s.faststart, meta_flag) {
1446 push!("-movflags");
1447 push!(flags);
1448 }
1449 a.push(plan.output.clone().into_os_string());
1450 return a;
1451 }
1452
1453 if s.audio_only {
1455 push!("-vn");
1456 if let Some(au) = &s.audio {
1457 push!("-c:a");
1458 push!(au.codec.encoder());
1459 if au.mono {
1460 push!("-ac");
1461 push!("1");
1462 }
1463 if let Some(sr) = au.sample_rate {
1464 push!("-ar");
1465 push!(sr.to_string());
1466 }
1467 push!("-b:a");
1468 push!(au.bitrate_bps.to_string());
1469 if matches!(au.codec, AudioCodec::Opus) {
1472 push!("-vbr");
1473 push!(if au.vbr { "on" } else { "constrained" });
1474 }
1475 }
1476 a.extend(meta.iter().cloned());
1477 if let Some(flags) = movflags(false, meta_flag) {
1478 push!("-movflags");
1479 push!(flags);
1480 }
1481 a.push(plan.output.clone().into_os_string());
1482 return a;
1483 }
1484
1485 push!("-c:v");
1487 push!(encoder);
1488 if let Some(vf) = video_filters(&s.video, zscale) {
1489 push!("-vf");
1490 push!(vf);
1491 }
1492 if let Some(f) = s.video.fps {
1493 push!("-r");
1494 push!(f.to_string());
1495 }
1496 let videotoolbox = encoder.ends_with("_videotoolbox");
1500 if !videotoolbox {
1501 let (speed_flag, speed_value) = s.video.preset.speed_flags(encoder);
1502 push!(speed_flag);
1503 push!(speed_value);
1504 }
1505 if let Some(tag) = s.video.codec.mp4_tag() {
1506 push!("-tag:v");
1507 push!(tag);
1508 }
1509 if s.video.to_sdr.is_some() {
1512 for (flag, value) in [
1513 ("-pix_fmt", "yuv420p"),
1514 ("-color_primaries", "bt709"),
1515 ("-color_trc", "bt709"),
1516 ("-colorspace", "bt709"),
1517 ] {
1518 push!(flag);
1519 push!(value);
1520 }
1521 }
1522
1523 match (s.video.bitrate_bps, s.video.crf) {
1525 (Some(bps), _) => {
1526 push!("-b:v");
1527 push!(bps.to_string());
1528 if s.two_pass {
1529 push!("-pass");
1530 push!(match pass {
1531 PassKind::First => "1",
1532 _ => "2",
1533 });
1534 push!("-passlogfile");
1535 push!(passlog);
1536 }
1537 }
1538 (_, Some(crf)) => {
1539 push!(if videotoolbox { "-q:v" } else { "-crf" });
1541 push!(crf.to_string());
1542 }
1543 _ => {}
1544 }
1545
1546 match pass {
1548 PassKind::First => {
1549 push!("-an");
1551 push!("-f");
1552 push!("null");
1553 push!(null_sink());
1554 }
1555 PassKind::Second | PassKind::Single => {
1556 match &s.audio {
1557 Some(au) => {
1558 push!("-c:a");
1559 push!(au.codec.encoder());
1560 if au.mono {
1564 push!("-ac");
1565 push!("1");
1566 }
1567 push!("-b:a");
1568 push!(au.bitrate_bps.to_string());
1569 }
1570 None => push!("-an"),
1571 }
1572 a.extend(meta.iter().cloned());
1573 if let Some(flags) = movflags(s.faststart, meta_flag) {
1574 push!("-movflags");
1575 push!(flags);
1576 }
1577 a.push(plan.output.clone().into_os_string());
1578 }
1579 }
1580 a
1581}
1582
1583#[cfg(test)]
1584mod tests {
1585 use super::*;
1586 use crate::options::{AudioCodec, QualityPreset, VideoCodec};
1587 use crate::size::preset;
1588
1589 fn enc(plan: &EncodePlan) -> &'static str {
1592 plan.spec.video.codec.encoder()
1593 }
1594
1595 fn video_info(duration: f64, size: u64, w: u32, h: u32, audio: bool) -> MediaInfo {
1596 MediaInfo {
1597 path: PathBuf::from("/tmp/clip.mp4"),
1598 kind: MediaKind::Video,
1599 duration_sec: duration,
1600 size_bytes: size,
1601 width: Some(w),
1602 height: Some(h),
1603 fps: Some(30.0),
1604 video_codec: Some("h264".into()),
1605 audio_codec: if audio { Some("aac".into()) } else { None },
1606 audio_channels: if audio { Some(2) } else { None },
1607 audio_bitrate_bps: None,
1608 capture: CaptureMeta::default(),
1609 hdr: None,
1610 }
1611 }
1612
1613 fn opts_target(bytes: u64) -> ShrinkOpts {
1614 ShrinkOpts {
1615 goal: SizeGoal::Target(bytes),
1616 ..Default::default()
1617 }
1618 }
1619
1620 #[test]
1621 fn supports_video_and_audio() {
1622 let e = MediaEngine::new();
1623 assert!(e.supports(&PathBuf::from("clip.mp4")));
1624 assert!(e.supports(&PathBuf::from("lecture.wav")));
1625 assert!(!e.supports(&PathBuf::from("photo.jpg")));
1626 }
1627
1628 #[test]
1629 fn plan_target_builds_two_pass_with_budget() {
1630 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1631 let plan = MediaEngine::new()
1632 .plan(&info, &opts_target(8_000_000))
1633 .unwrap();
1634
1635 assert!(plan.spec.two_pass);
1636 assert_eq!(plan.target_bytes, Some(8_000_000));
1637 assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
1638 let vbps = plan.spec.video.bitrate_bps.unwrap();
1639 assert!(vbps >= budget::ABSOLUTE_MIN_VIDEO_BPS);
1640 assert!(plan.spec.video.height.is_some());
1642 assert!(plan.spec.audio.is_some());
1643 assert!(plan.expected_bytes.unwrap() <= 8_000_000 + 8_000_000 / 20);
1645 }
1646
1647 #[test]
1648 fn plan_video_mono_downmixes_the_audio_track() {
1649 let info = video_info(60.0, 100_000_000, 1280, 720, true);
1650 let opts = ShrinkOpts {
1651 mono: true,
1652 ..opts_target(8_000_000)
1653 };
1654 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1655 let audio = plan.spec.audio.as_ref().expect("kept audio track");
1656 assert!(audio.mono, "opts.mono downmixes the video's audio track");
1657 let stereo = MediaEngine::new()
1659 .plan(&info, &opts_target(8_000_000))
1660 .unwrap();
1661 assert!(!stereo.spec.audio.as_ref().unwrap().mono);
1662 }
1663
1664 #[test]
1665 fn video_mono_reaches_ffmpeg_as_ac_1() {
1666 let info = video_info(60.0, 100_000_000, 1280, 720, true);
1669 let plan = MediaEngine::new()
1670 .plan(
1671 &info,
1672 &ShrinkOpts {
1673 mono: true,
1674 ..opts_target(8_000_000)
1675 },
1676 )
1677 .unwrap();
1678 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan), false);
1679 let joined: Vec<String> = args
1680 .iter()
1681 .map(|a| a.to_string_lossy().into_owned())
1682 .collect();
1683 let ac = joined.iter().position(|a| a == "-ac").expect("-ac emitted");
1684 assert_eq!(joined[ac + 1], "1");
1685
1686 let stereo = MediaEngine::new()
1688 .plan(&info, &opts_target(8_000_000))
1689 .unwrap();
1690 let stereo_args: Vec<String> = build_pass_args(
1691 &stereo,
1692 PassKind::Second,
1693 "/tmp/passlog",
1694 enc(&stereo),
1695 false,
1696 )
1697 .iter()
1698 .map(|a| a.to_string_lossy().into_owned())
1699 .collect();
1700 assert!(!stereo_args.iter().any(|a| a == "-ac"));
1701 }
1702
1703 #[test]
1704 fn plan_preset_discord_sets_target() {
1705 let info = video_info(30.0, 50_000_000, 1280, 720, true);
1706 let opts = ShrinkOpts {
1707 goal: SizeGoal::Preset(preset("discord").unwrap()),
1708 ..Default::default()
1709 };
1710 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1711 assert_eq!(plan.target_bytes, Some(8_000_000));
1712 }
1713
1714 #[test]
1715 fn plan_reduce_targets_complement_of_original() {
1716 let info = video_info(60.0, 100_000_000, 1920, 1080, true);
1717 let opts = ShrinkOpts {
1718 goal: SizeGoal::Reduce(0.70),
1719 ..Default::default()
1720 };
1721 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1722 assert_eq!(plan.target_bytes, Some(30_000_000));
1723 }
1724
1725 #[test]
1726 fn plan_passthrough_when_source_already_fits() {
1727 let info = video_info(10.0, 200_000, 1280, 720, true);
1729 let plan = MediaEngine::new()
1730 .plan(&info, &opts_target(1_000_000))
1731 .unwrap();
1732 assert!(plan.spec.passthrough);
1733 assert!(!plan.spec.two_pass);
1734 assert_eq!(plan.expected_bytes, Some(200_000));
1735 let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog", enc(&plan), false);
1736 let joined: Vec<String> = args
1737 .iter()
1738 .map(|a| a.to_string_lossy().into_owned())
1739 .collect();
1740 assert!(joined.contains(&"copy".to_string()));
1741 assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
1744 }
1745
1746 #[test]
1747 fn plan_passthrough_keeps_the_source_container() {
1748 let info = MediaInfo {
1751 path: PathBuf::from("/tmp/voice.3gp"),
1752 ..video_info(10.0, 200_000, 320, 240, true)
1753 };
1754 let plan = MediaEngine::new()
1755 .plan(&info, &opts_target(1_000_000))
1756 .unwrap();
1757 assert!(plan.spec.passthrough);
1758 assert_eq!(plan.output, PathBuf::from("/tmp/voice.shrink.3gp"));
1759 }
1760
1761 #[test]
1762 fn plan_infeasible_when_target_too_small() {
1763 let info = video_info(600.0, 500_000_000, 1920, 1080, true);
1764 let err = MediaEngine::new().plan(&info, &opts_target(50_000));
1765 assert!(matches!(err, Err(EngineError::Infeasible)));
1766 }
1767
1768 #[test]
1769 fn plan_quality_mode_uses_crf_single_pass() {
1770 let info = video_info(60.0, 100_000_000, 1920, 1080, true);
1771 let opts = ShrinkOpts {
1772 goal: SizeGoal::Quality,
1773 quality: QualityPreset::Balanced,
1774 ..Default::default()
1775 };
1776 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1777 assert!(!plan.spec.two_pass);
1778 assert_eq!(plan.spec.video.crf, Some(23));
1779 assert!(plan.spec.video.bitrate_bps.is_none());
1780 assert!(plan.expected_bytes.is_none());
1781 }
1782
1783 #[test]
1784 fn plan_drops_audio_when_requested() {
1785 let info = video_info(30.0, 50_000_000, 1280, 720, true);
1786 let opts = ShrinkOpts {
1787 audio: AudioChoice::Drop,
1788 ..opts_target(8_000_000)
1789 };
1790 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1791 assert!(plan.spec.audio.is_none());
1792 }
1793
1794 fn audio_info(duration: f64, size: u64, channels: u32) -> MediaInfo {
1795 MediaInfo {
1796 path: PathBuf::from("/tmp/lecture.wav"),
1797 kind: MediaKind::Audio,
1798 duration_sec: duration,
1799 size_bytes: size,
1800 width: None,
1801 height: None,
1802 fps: None,
1803 video_codec: None,
1804 audio_codec: Some("pcm_s16le".into()),
1805 audio_channels: Some(channels),
1806 audio_bitrate_bps: None,
1807 capture: CaptureMeta::default(),
1808 hdr: None,
1809 }
1810 }
1811
1812 #[test]
1813 fn quality_audio_bitrate_follows_tier_codec_and_channels() {
1814 use QualityPreset::*;
1815 assert_eq!(quality_audio_bps(Balanced, AudioCodec::Aac, false), 128_000);
1816 assert_eq!(quality_audio_bps(Balanced, AudioCodec::Aac, true), 64_000);
1817 assert_eq!(quality_audio_bps(Fast, AudioCodec::Opus, true), 32_000);
1818 assert_eq!(quality_audio_bps(Max, AudioCodec::Mp3, false), 256_000);
1819 for c in [AudioCodec::Aac, AudioCodec::Opus, AudioCodec::Mp3] {
1821 let t: Vec<_> = [Fast, Balanced, Max]
1822 .map(|q| quality_audio_bps(q, c, false))
1823 .into();
1824 assert!(t[0] < t[1] && t[1] < t[2], "{c:?}: {t:?}");
1825 }
1826 }
1827
1828 #[test]
1829 fn a_compact_audiobook_is_kept_in_quality_mode() {
1830 let mut info = audio_info(3600.0, 64_000 / 8 * 3600, 1);
1832 info.path = PathBuf::from("/tmp/book.mp3");
1833 let plan = MediaEngine::new()
1834 .plan(&info, &ShrinkOpts::default())
1835 .unwrap();
1836 assert!(plan.spec.passthrough, "{}", plan.summary);
1837 assert!(plan.output.to_string_lossy().ends_with(".mp3"));
1838 assert!(plan.summary.contains("already compact"));
1839
1840 let smaller = ShrinkOpts {
1842 audio_codec: AudioCodec::Opus,
1843 quality: QualityPreset::Fast,
1844 ..ShrinkOpts::default()
1845 };
1846 let plan = MediaEngine::new().plan(&info, &smaller).unwrap();
1847 assert!(!plan.spec.passthrough);
1848 assert_eq!(plan.spec.audio.as_ref().unwrap().bitrate_bps, 32_000);
1849 assert!(
1850 plan.guard_larger,
1851 "quality mode keeps the post-encode check"
1852 );
1853
1854 let allow = ShrinkOpts {
1856 allow_larger: true,
1857 ..ShrinkOpts::default()
1858 };
1859 let plan = MediaEngine::new().plan(&info, &allow).unwrap();
1860 assert!(!plan.spec.passthrough && !plan.guard_larger);
1861 }
1862
1863 #[test]
1864 fn the_guard_is_for_quality_mode_only() {
1865 let info = video_info(60.0, 50_000_000, 1920, 1080, true);
1866 let quality = MediaEngine::new()
1867 .plan(&info, &ShrinkOpts::default())
1868 .unwrap();
1869 assert!(quality.guard_larger);
1870 let target = MediaEngine::new()
1871 .plan(&info, &opts_target(10_000_000))
1872 .unwrap();
1873 assert!(!target.guard_larger, "a size target is its own guarantee");
1874 }
1875
1876 #[test]
1877 fn plan_audio_single_pass_with_fitted_bitrate() {
1878 let info = audio_info(3480.0, 600_000_000, 2);
1880 let plan = MediaEngine::new()
1881 .plan(&info, &opts_target(10_000_000))
1882 .unwrap();
1883 assert!(plan.spec.audio_only);
1884 assert!(!plan.spec.two_pass);
1885 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.m4a"));
1886 let au = plan.spec.audio.as_ref().unwrap();
1887 assert!(budget::AUDIO_STEPS.contains(&au.bitrate_bps));
1889 assert!(plan.expected_bytes.unwrap() <= 10_000_000 + 10_000_000 / 20);
1890 }
1891
1892 #[test]
1893 fn plan_audio_mono_source_marked_speech() {
1894 let info = audio_info(600.0, 100_000_000, 1);
1895 let plan = MediaEngine::new()
1896 .plan(&info, &opts_target(5_000_000))
1897 .unwrap();
1898 assert!(plan.spec.audio.as_ref().unwrap().mono);
1899 }
1900
1901 #[test]
1902 fn plan_audio_opus_extension_and_vbr_args() {
1903 let info = audio_info(600.0, 100_000_000, 2);
1904 let opts = ShrinkOpts {
1905 audio_codec: AudioCodec::Opus,
1906 mono: true,
1907 ..opts_target(3_000_000)
1908 };
1909 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1910 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.opus"));
1911 let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog", enc(&plan), false);
1912 let j: Vec<String> = args
1913 .iter()
1914 .map(|a| a.to_string_lossy().into_owned())
1915 .collect();
1916 assert!(j.contains(&"-vn".to_string()));
1917 assert!(j.contains(&"libopus".to_string()));
1918 assert!(j.contains(&"-ac".to_string())); assert!(j.contains(&"-vbr".to_string()));
1920 }
1921
1922 #[test]
1923 fn plan_audio_infeasible_when_target_tiny() {
1924 let info = audio_info(3600.0, 500_000_000, 2);
1925 assert!(matches!(
1926 MediaEngine::new().plan(&info, &opts_target(1_000)),
1927 Err(EngineError::Infeasible)
1928 ));
1929 }
1930
1931 #[test]
1932 fn plan_audio_passthrough_when_source_fits() {
1933 let info = audio_info(600.0, 2_000_000, 2);
1934 let plan = MediaEngine::new()
1935 .plan(&info, &opts_target(10_000_000))
1936 .unwrap();
1937 assert!(plan.spec.passthrough);
1938 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.wav"));
1940 }
1941
1942 #[test]
1943 fn pass1_args_have_no_audio_and_null_sink() {
1944 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1945 let plan = MediaEngine::new()
1946 .plan(&info, &opts_target(8_000_000))
1947 .unwrap();
1948 let args = build_pass_args(&plan, PassKind::First, "/tmp/passlog", enc(&plan), false);
1949 let joined: Vec<String> = args
1950 .iter()
1951 .map(|a| a.to_string_lossy().into_owned())
1952 .collect();
1953 assert!(joined.contains(&"-an".to_string()));
1954 assert!(joined.contains(&"null".to_string()));
1955 assert!(joined.iter().any(|a| a == "1")); assert!(!joined.iter().any(|a| a.contains("shrink.mp4")));
1957 }
1958
1959 #[test]
1960 fn pass2_args_write_output_with_audio() {
1961 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1962 let plan = MediaEngine::new()
1963 .plan(&info, &opts_target(8_000_000))
1964 .unwrap();
1965 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan), false);
1966 let joined: Vec<String> = args
1967 .iter()
1968 .map(|a| a.to_string_lossy().into_owned())
1969 .collect();
1970 assert!(joined.iter().any(|a| a.contains("clip.shrink.mp4")));
1971 assert!(joined.contains(&"-c:a".to_string()));
1972 assert!(joined.iter().any(|a| a.contains("+faststart")));
1973 assert!(joined.iter().any(|a| a == "2")); }
1975
1976 fn joined(plan: &EncodePlan, pass: PassKind) -> Vec<String> {
1977 joined_with(plan, pass, false)
1978 }
1979
1980 fn joined_with(plan: &EncodePlan, pass: PassKind, zscale: bool) -> Vec<String> {
1981 build_pass_args(plan, pass, "/tmp/passlog", enc(plan), zscale)
1982 .iter()
1983 .map(|a| a.to_string_lossy().into_owned())
1984 .collect()
1985 }
1986
1987 #[test]
1988 fn hdr_transfer_is_recognised() {
1989 assert_eq!(Hdr::from_transfer("arib-std-b67"), Some(Hdr::Hlg));
1990 assert_eq!(Hdr::from_transfer("smpte2084"), Some(Hdr::Pq));
1991 assert_eq!(Hdr::from_transfer("bt709"), None);
1992 }
1993
1994 #[test]
1995 fn hdr_is_tone_mapped_to_sdr_for_size_targets_only() {
1996 let mut info = iphone_info();
1997 info.hdr = Some(Hdr::Hlg);
1998 let engine = MediaEngine::new();
1999
2000 let target = engine.plan(&info, &opts_target(10_000_000)).unwrap();
2002 assert_eq!(target.spec.video.to_sdr, Some(Hdr::Hlg));
2003 assert!(target.summary.contains("HDR → SDR"));
2004 let vf_of =
2005 |args: &[String]| args[args.iter().position(|a| a == "-vf").unwrap() + 1].clone();
2006 let hlg = joined_with(&target, PassKind::Second, true);
2008 let vf = vf_of(&hlg);
2009 assert!(
2010 vf.contains("colorspace=all=bt709") && !vf.contains("zscale"),
2011 "{vf}"
2012 );
2013 assert!(
2016 vf.find("scale=").unwrap() < vf.find("colorspace").unwrap(),
2017 "{vf}"
2018 );
2019 for pair in [
2020 ["-pix_fmt", "yuv420p"],
2021 ["-color_trc", "bt709"],
2022 ["-colorspace", "bt709"],
2023 ] {
2024 assert!(hlg.windows(2).any(|w| w == pair), "{pair:?}");
2025 }
2026 let mut pq = target.clone();
2028 pq.spec.video.to_sdr = Some(Hdr::Pq);
2029 let vf = vf_of(&joined_with(&pq, PassKind::Second, true));
2030 assert!(
2031 vf.contains("tonemap=hable") && vf.contains("npl=100"),
2032 "{vf}"
2033 );
2034 let vf = vf_of(&joined_with(&pq, PassKind::Second, false));
2035 assert!(
2036 vf.contains("colorspace=all=bt709") && !vf.contains("zscale"),
2037 "{vf}"
2038 );
2039
2040 let quality = engine.plan(&info, &ShrinkOpts::default()).unwrap();
2042 assert_eq!(quality.spec.video.to_sdr, None);
2043 let args = joined(&quality, PassKind::Single);
2044 assert!(!args
2045 .iter()
2046 .any(|a| a == "-pix_fmt" || a.contains("colorspace")));
2047
2048 let sdr = engine
2050 .plan(&iphone_info(), &opts_target(10_000_000))
2051 .unwrap();
2052 assert_eq!(sdr.spec.video.to_sdr, None);
2053 assert!(!joined(&sdr, PassKind::Second)
2054 .iter()
2055 .any(|a| a == "-pix_fmt"));
2056 }
2057
2058 #[test]
2059 fn a_size_target_is_a_ceiling_at_the_quality_crf() {
2060 let info = video_info(60.0, 200_000_000, 1920, 1080, true);
2061 let engine = MediaEngine::new();
2062 let opts = opts_target(50_000_000);
2063 let plan = engine.plan(&info, &opts).unwrap();
2064 let crf = opts.quality.default_crf(opts.video_codec);
2065 assert_eq!(plan.ceiling_crf, Some(crf));
2066
2067 let ceiling = ceiling_plan(&plan).unwrap();
2068 assert_eq!(ceiling.spec.video.crf, Some(crf));
2069 assert_eq!(ceiling.spec.video.bitrate_bps, None);
2070 assert!(!ceiling.spec.two_pass);
2071 assert_eq!(ceiling.spec.video.height, plan.spec.video.height);
2073 assert_eq!(ceiling.spec.audio, plan.spec.audio);
2074 assert_eq!(ceiling.output, plan.output);
2075 assert_eq!(ceiling.target_bytes, plan.target_bytes);
2076
2077 let quality = engine.plan(&info, &ShrinkOpts::default()).unwrap();
2079 assert!(quality.ceiling_crf.is_none() && ceiling_plan(&quality).is_none());
2080 let fits = engine.plan(&info, &opts_target(300_000_000)).unwrap();
2081 assert!(fits.spec.passthrough && ceiling_plan(&fits).is_none());
2082 }
2083
2084 #[test]
2085 fn heavy_video_samples_shorter_windows() {
2086 let engine = MediaEngine::new();
2087 let mut info = video_info(120.0, 500_000_000, 1920, 1080, true);
2088 info.fps = Some(30.0);
2089 let plan = engine.plan(&info, &ShrinkOpts::default()).unwrap();
2090 assert_eq!(sample_secs(&plan), 3.0);
2091 info = video_info(120.0, 500_000_000, 3840, 2160, true);
2092 info.fps = Some(60.0);
2093 let plan = engine.plan(&info, &ShrinkOpts::default()).unwrap();
2094 assert_eq!(sample_secs(&plan), MIN_SAMPLE_SECS);
2095 }
2096
2097 #[test]
2098 fn apple_hardware_uses_quality_one_pass_and_no_preset() {
2099 let engine = MediaEngine::new();
2100 let info = video_info(60.0, 200_000_000, 1920, 1080, true);
2101 let mut plan = engine.plan(&info, &ShrinkOpts::default()).unwrap();
2102 plan.spec.video.hardware = true;
2104 plan.spec.video.crf = QualityPreset::Balanced.default_hw_quality(VideoCodec::H264);
2105 let args: Vec<String> =
2106 build_pass_args(&plan, PassKind::Single, "", "h264_videotoolbox", false)
2107 .iter()
2108 .map(|a| a.to_string_lossy().into_owned())
2109 .collect();
2110 assert!(args.windows(2).any(|w| w == ["-c:v", "h264_videotoolbox"]));
2111 assert!(args.windows(2).any(|w| w == ["-q:v", "66"]), "{args:?}");
2112 assert!(
2113 !args.iter().any(|a| a == "-crf" || a == "-preset"),
2114 "{args:?}"
2115 );
2116 let mut hw = plan.clone();
2117 hw.spec.passthrough = false;
2118 assert_eq!(
2119 resolve_encoder(
2120 &deepshrink_ffmpeg::Tools {
2121 ffmpeg: "ffmpeg".into(),
2122 ffprobe: "ffprobe".into(),
2123 cancel: Default::default(),
2124 },
2125 &hw
2126 )
2127 .unwrap(),
2128 "h264_videotoolbox"
2129 );
2130 assert_eq!(
2132 QualityPreset::Balanced.default_hw_quality(VideoCodec::Av1),
2133 None
2134 );
2135 }
2136
2137 #[test]
2138 fn a_portrait_video_is_capped_on_its_short_side() {
2139 let engine = MediaEngine::new();
2140 let info = video_info(60.0, 200_000_000, 2160, 3840, true);
2142 let opts = ShrinkOpts {
2143 resolution: ResolutionOpt::Height(1080),
2144 ..ShrinkOpts::default()
2145 };
2146 let plan = engine.plan(&info, &opts).unwrap();
2147 assert_eq!(plan.spec.video.height, Some(1080));
2148 assert!(plan.spec.video.portrait);
2149 let args = joined(&plan, PassKind::Single);
2150 let vf = &args[args.iter().position(|a| a == "-vf").unwrap() + 1];
2151 assert!(vf.starts_with("scale=1080:-2"), "{vf}");
2153
2154 let info = video_info(60.0, 200_000_000, 3840, 2160, true);
2156 let plan = engine.plan(&info, &opts).unwrap();
2157 assert!(!plan.spec.video.portrait);
2158 let args = joined(&plan, PassKind::Single);
2159 assert!(args.iter().any(|a| a.starts_with("scale=-2:1080")));
2160 }
2161
2162 fn iphone_info() -> MediaInfo {
2163 let mut info = video_info(60.0, 50_000_000, 2160, 3840, true);
2164 info.path = PathBuf::from("/tmp/IMG_3325.MOV");
2165 info.capture = CaptureMeta {
2166 created_utc: to_utc("2026-09-26T20:01:54+0300"),
2167 created_local: Some("2026-09-26T20:01:54+0300".into()),
2168 location: Some("+50.4160+030.2796+155.635/".into()),
2169 make: Some("Apple".into()),
2170 model: Some("iPhone 12 Pro Max".into()),
2171 };
2172 info
2173 }
2174
2175 #[test]
2176 fn metadata_is_kept_by_default_and_strippable() {
2177 let info = iphone_info();
2178 let plan = MediaEngine::new()
2179 .plan(&info, &ShrinkOpts::default())
2180 .unwrap();
2181 let a = joined(&plan, PassKind::Single);
2182 let at = a.iter().position(|x| x == "-map_metadata").unwrap();
2183 assert_eq!(a[at + 1], "0");
2184 for tag in [
2187 "creation_time=2026-09-26T17:01:54Z",
2188 "location=+50.4160+030.2796+155.635/",
2189 "make=Apple",
2190 "model=iPhone 12 Pro Max",
2191 "date=2026-09-26T20:01:54+0300",
2192 ] {
2193 assert!(a.contains(&tag.to_string()), "{tag} in {a:?}");
2194 }
2195 assert!(a.contains(&"+faststart".to_string()));
2196
2197 let strip = ShrinkOpts {
2198 keep_metadata: false,
2199 ..ShrinkOpts::default()
2200 };
2201 let plan = MediaEngine::new().plan(&info, &strip).unwrap();
2202 let a = joined(&plan, PassKind::Single);
2203 let at = a.iter().position(|x| x == "-map_metadata").unwrap();
2204 assert_eq!(a[at + 1], "-1");
2205 assert!(!a.iter().any(|x| x.starts_with("location=")));
2206 assert!(a.contains(&"+faststart".to_string()));
2207 }
2208
2209 #[test]
2210 fn apple_local_time_converts_to_utc() {
2211 let utc = |s: &str| to_utc(s);
2212 assert_eq!(
2213 utc("2026-09-26T20:01:54+0300").as_deref(),
2214 Some("2026-09-26T17:01:54Z")
2215 );
2216 assert_eq!(
2217 utc("2026-09-26T20:01:54+03:00").as_deref(),
2218 Some("2026-09-26T17:01:54Z")
2219 );
2220 assert_eq!(
2221 utc("2026-01-01T01:30:00+0300").as_deref(),
2222 Some("2025-12-31T22:30:00Z")
2223 );
2224 assert_eq!(
2225 utc("2026-03-01T23:00:00-0500").as_deref(),
2226 Some("2026-03-02T04:00:00Z")
2227 );
2228 assert_eq!(
2229 utc("2024-02-29T12:00:00.123Z").as_deref(),
2230 Some("2024-02-29T12:00:00Z")
2231 );
2232 assert_eq!(utc("yesterday"), None);
2233 }
2234
2235 #[test]
2236 fn an_iphone_mov_stays_mov_in_quality_mode_only() {
2237 let info = iphone_info();
2238 let out = |opts: &ShrinkOpts| {
2239 let plan = MediaEngine::new().plan(&info, opts).unwrap();
2240 plan.output.to_string_lossy().into_owned()
2241 };
2242 assert!(out(&ShrinkOpts::default()).ends_with(".shrink.mov"));
2243 assert!(out(&opts_target(8_000_000)).ends_with(".shrink.mp4"));
2245 let av1 = ShrinkOpts {
2247 video_codec: VideoCodec::Av1,
2248 ..ShrinkOpts::default()
2249 };
2250 assert!(out(&av1).ends_with(".shrink.mp4"));
2251 let mp4 = video_info(60.0, 50_000_000, 1920, 1080, true);
2253 let p = MediaEngine::new()
2254 .plan(&mp4, &ShrinkOpts::default())
2255 .unwrap();
2256 assert!(p.output.to_string_lossy().ends_with(".shrink.mp4"));
2257 }
2258
2259 #[test]
2260 fn a_video_audio_track_is_never_upsampled() {
2261 let mut info = video_info(60.0, 50_000_000, 1920, 1080, true);
2262 info.audio_bitrate_bps = Some(64_000);
2263 let bps_of = |info: &MediaInfo, opts: &ShrinkOpts| {
2264 let plan = MediaEngine::new().plan(info, opts).unwrap();
2265 plan.spec.audio.unwrap().bitrate_bps
2266 };
2267 assert_eq!(bps_of(&info, &ShrinkOpts::default()), 64_000);
2269 assert!(bps_of(&info, &opts_target(20_000_000)) <= 64_000);
2271 let explicit = ShrinkOpts {
2273 audio: AudioChoice::Bitrate(128_000),
2274 ..ShrinkOpts::default()
2275 };
2276 assert_eq!(bps_of(&info, &explicit), 128_000);
2277 info.audio_bitrate_bps = None;
2279 assert_eq!(
2280 bps_of(&info, &ShrinkOpts::default()),
2281 budget::DEFAULT_AUDIO_BPS
2282 );
2283 }
2284
2285 #[test]
2286 fn h265_adds_hvc1_tag() {
2287 let info = video_info(60.0, 100_000_000, 1280, 720, false);
2288 let opts = ShrinkOpts {
2289 video_codec: VideoCodec::H265,
2290 ..opts_target(8_000_000)
2291 };
2292 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
2293 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan), false);
2294 let joined: Vec<String> = args
2295 .iter()
2296 .map(|a| a.to_string_lossy().into_owned())
2297 .collect();
2298 assert!(joined.contains(&"hvc1".to_string()));
2299 assert!(joined.contains(&"libx265".to_string()));
2300 }
2301}