1use std::ffi::OsString;
10use std::fs;
11use std::path::Path;
12
13use super::plan::*;
14use super::{
15 CaptureMeta, EncodePlan, Engine, EngineError, Hdr, MediaInfo, Outcome, ShrinkOpts, VideoSpec,
16};
17use crate::budget;
18use crate::detect::{detect_kind, MediaKind};
19use crate::options::AudioCodec;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum PassKind {
24 Single,
25 First,
26 Second,
27}
28
29#[derive(Debug, Default, Clone)]
31pub struct MediaEngine {
32 cancel: Option<deepshrink_ffmpeg::CancelToken>,
34}
35
36impl MediaEngine {
37 pub fn new() -> Self {
38 Self::default()
39 }
40
41 pub fn with_cancel(cancel: deepshrink_ffmpeg::CancelToken) -> Self {
45 Self {
46 cancel: Some(cancel),
47 }
48 }
49
50 fn tools(&self) -> Result<deepshrink_ffmpeg::Tools, EngineError> {
52 let tools = deepshrink_ffmpeg::locate()?;
53 Ok(match &self.cancel {
54 Some(c) => tools.with_cancel(c.clone()),
55 None => tools,
56 })
57 }
58
59 pub fn run_with_progress(
62 &self,
63 plan: &EncodePlan,
64 on_progress: &mut dyn FnMut(PassKind, f64),
65 ) -> Result<Outcome, EngineError> {
66 let outcome = match self.run_inner(plan, on_progress) {
67 Ok(o) => o,
68 Err(e) => {
69 if e.is_cancelled() && plan.output != plan.input {
72 let _ = fs::remove_file(&plan.output);
73 cleanup_passlog(&passlog_base(plan));
74 }
75 return Err(e);
76 }
77 };
78 if plan.spec.keep_metadata {
81 copy_mtime(&plan.input, &outcome.output);
82 }
83 Ok(outcome)
84 }
85
86 fn run_inner(
87 &self,
88 plan: &EncodePlan,
89 on_progress: &mut dyn FnMut(PassKind, f64),
90 ) -> Result<Outcome, EngineError> {
91 let tools = self.tools()?;
92 let encoder = resolve_encoder(&tools, plan)?;
93 let zscale = wants_zscale(&tools, plan);
94
95 if let Some(target_vmaf) = plan.target_vmaf {
98 if plan.spec.video.crf.is_some() && !plan.spec.audio_only && !plan.spec.passthrough {
99 return self.run_crf_search(
100 &tools,
101 plan,
102 encoder,
103 zscale,
104 target_vmaf,
105 on_progress,
106 );
107 }
108 }
109
110 let source = if plan.guard_larger && !plan.spec.passthrough {
115 fs::metadata(&plan.input).map(|m| m.len()).unwrap_or(0)
116 } else {
117 0
118 };
119 if source > 0
120 && plan.target_vmaf.is_none()
121 && !plan.spec.audio_only
122 && plan.spec.video.crf.is_some()
123 {
124 if let Some(predicted) = predict_crf_bytes(&tools, plan, encoder, zscale) {
125 if super::not_worth_it(predicted, source) {
126 return self.keep_original(&tools, plan, on_progress);
127 }
128 }
129 }
130
131 let ceiling = match ceiling_fit(&tools, plan, encoder, zscale) {
134 Some((ceiling, _)) => {
135 let o = self.run_plain(&tools, &ceiling, encoder, zscale, on_progress)?;
136 plan.target_bytes
138 .is_some_and(|t| o.final_bytes <= t)
139 .then_some(o)
140 }
141 None => None,
142 };
143 let mut outcome = match ceiling {
144 Some(o) => o,
145 None => self.run_plain(&tools, plan, encoder, zscale, on_progress)?,
146 };
147 if source > 0 && super::not_worth_it(outcome.final_bytes, source) {
148 let _ = fs::remove_file(&outcome.output);
149 return self.keep_original(&tools, plan, on_progress);
150 }
151
152 if plan.target_vmaf.is_some() && !plan.spec.audio_only && !plan.spec.passthrough {
155 outcome.vmaf = self.measure_output(&tools, plan, &plan.output);
156 }
157 Ok(outcome)
158 }
159
160 fn run_plain(
163 &self,
164 tools: &deepshrink_ffmpeg::Tools,
165 plan: &EncodePlan,
166 encoder: &str,
167 zscale: bool,
168 on_progress: &mut dyn FnMut(PassKind, f64),
169 ) -> Result<Outcome, EngineError> {
170 let passlog = passlog_base(plan);
171 let total = plan.source_duration_sec;
172
173 if plan.spec.passthrough {
174 return self.run_passthrough(tools, plan, on_progress);
175 }
176
177 if plan.spec.two_pass {
178 let args1 = build_pass_args(plan, PassKind::First, &passlog, encoder, zscale);
179 tools.run_pass(&args1, total, &mut |f| on_progress(PassKind::First, f))?;
180 let args2 = build_pass_args(plan, PassKind::Second, &passlog, encoder, zscale);
181 tools.run_pass(&args2, total, &mut |f| on_progress(PassKind::Second, f))?;
182 } else {
183 let args = build_pass_args(plan, PassKind::Single, &passlog, encoder, zscale);
184 tools.run_pass(&args, total, &mut |f| on_progress(PassKind::Single, f))?;
185 }
186
187 let mut size = fs::metadata(&plan.output)?.len();
188
189 if let (Some(target), Some(mut vbps)) = (plan.target_bytes, plan.spec.video.bitrate_bps) {
194 let (tries, pass) = if plan.spec.two_pass {
195 (1, PassKind::Second)
196 } else if plan.spec.video.hardware {
197 (3, PassKind::Single)
198 } else {
199 (0, PassKind::Single)
200 };
201 for _ in 0..tries {
202 if size <= target {
203 break;
204 }
205 let Some(corrected) = corrected_bitrate(vbps, target, size) else {
206 break;
207 };
208 vbps = corrected;
209 let mut retry = plan.clone();
210 retry.spec.video.bitrate_bps = Some(corrected);
211 let args = build_pass_args(&retry, pass, &passlog, encoder, zscale);
212 tools.run_pass(&args, total, &mut |f| on_progress(pass, f))?;
213 size = fs::metadata(&plan.output)?.len();
214 }
215 }
216
217 cleanup_passlog(&passlog);
218 Ok(Outcome {
219 output: plan.output.clone(),
220 final_bytes: size,
221 vmaf: None,
222 already_compact: false,
223 })
224 }
225
226 pub fn estimate(&self, plan: &EncodePlan) -> Result<Option<u64>, EngineError> {
233 if plan.spec.passthrough {
234 return Ok(fs::metadata(&plan.input).ok().map(|m| m.len()));
235 }
236 if ceiling_plan(plan).is_some() {
237 let tools = self.tools()?;
239 let encoder = resolve_encoder(&tools, plan)?;
240 let zscale = wants_zscale(&tools, plan);
241 let fit = ceiling_fit(&tools, plan, encoder, zscale).map(|(_, bytes)| bytes);
242 return Ok(fit.or(plan.expected_bytes));
243 }
244 if let Some(bytes) = plan.expected_bytes {
245 return Ok(Some(bytes));
246 }
247 if plan.spec.audio_only || plan.spec.video.crf.is_none() {
248 return Ok(None);
249 }
250 let tools = self.tools()?;
251 let encoder = resolve_encoder(&tools, plan)?;
252 let zscale = wants_zscale(&tools, plan);
253 Ok(predict_crf_bytes(&tools, plan, encoder, zscale))
254 }
255
256 fn keep_original(
259 &self,
260 tools: &deepshrink_ffmpeg::Tools,
261 plan: &EncodePlan,
262 on_progress: &mut dyn FnMut(PassKind, f64),
263 ) -> Result<Outcome, EngineError> {
264 let _ = tools; let output = match plan.input.extension() {
266 Some(ext) => plan.output.with_extension(ext),
267 None => plan.output.clone(),
268 };
269 {
275 let mut src = fs::File::open(&plan.input)?;
276 let mut dst = fs::OpenOptions::new()
277 .write(true)
278 .create_new(true)
279 .open(&output)?;
280 std::io::copy(&mut src, &mut dst)?;
281 }
282 if let Ok(meta) = fs::metadata(&plan.input) {
283 let _ = fs::set_permissions(&output, meta.permissions());
284 }
285 on_progress(PassKind::Single, 1.0);
286 Ok(Outcome {
287 final_bytes: fs::metadata(&output)?.len(),
288 output,
289 vmaf: None,
290 already_compact: true,
291 })
292 }
293
294 fn run_passthrough(
303 &self,
304 tools: &deepshrink_ffmpeg::Tools,
305 plan: &EncodePlan,
306 on_progress: &mut dyn FnMut(PassKind, f64),
307 ) -> Result<Outcome, EngineError> {
308 let args = build_pass_args(plan, PassKind::Single, "", "copy", false);
310 let remuxed = tools.run_pass(&args, plan.source_duration_sec, &mut |f| {
311 on_progress(PassKind::Single, f)
312 });
313 if remuxed.is_err() {
314 fs::copy(&plan.input, &plan.output)?;
315 on_progress(PassKind::Single, 1.0);
316 }
317 let size = fs::metadata(&plan.output)?.len();
318 Ok(Outcome {
319 output: plan.output.clone(),
320 final_bytes: size,
321 vmaf: None,
322 already_compact: true,
323 })
324 }
325
326 fn run_crf_search(
333 &self,
334 tools: &deepshrink_ffmpeg::Tools,
335 plan: &EncodePlan,
336 encoder: &str,
337 zscale: bool,
338 target_vmaf: f64,
339 on_progress: &mut dyn FnMut(PassKind, f64),
340 ) -> Result<Outcome, EngineError> {
341 let (ref_w, ref_h) = match (plan.source_width, plan.source_height) {
342 (Some(w), Some(h)) => (w, h),
343 _ => return self.run_plain(tools, plan, encoder, zscale, on_progress),
344 };
345 let ref_fps = plan.source_fps.unwrap_or(0.0);
346 let total = plan.source_duration_sec;
347 let (lo, hi) = plan.spec.video.codec.crf_search_bounds();
348 let n_threads = thread_count();
349
350 let mut err: Option<EngineError> = None;
351 let mut last_crf: Option<u8> = None;
352
353 let (chosen_crf, chosen_vmaf) = budget::search_crf(target_vmaf, lo, hi, |crf| {
354 if err.is_some() {
355 return f64::NEG_INFINITY;
356 }
357 match encode_at_crf(tools, plan, encoder, zscale, crf, total, on_progress).and_then(
358 |()| {
359 last_crf = Some(crf);
360 deepshrink_ffmpeg::measure_vmaf(
361 &tools.ffmpeg,
362 &plan.output,
363 &plan.input,
364 ref_w,
365 ref_h,
366 ref_fps,
367 n_threads,
368 )
369 .map_err(EngineError::from)
370 },
371 ) {
372 Ok(v) => v,
373 Err(e) => {
374 err = Some(e);
375 f64::NEG_INFINITY
376 }
377 }
378 });
379 if let Some(e) = err {
380 return Err(e);
381 }
382
383 if last_crf != Some(chosen_crf) {
385 encode_at_crf(tools, plan, encoder, zscale, chosen_crf, total, on_progress)?;
386 }
387 let size = fs::metadata(&plan.output)?.len();
388 Ok(Outcome {
389 output: plan.output.clone(),
390 final_bytes: size,
391 vmaf: Some(chosen_vmaf),
392 already_compact: false,
393 })
394 }
395
396 fn measure_output(
399 &self,
400 tools: &deepshrink_ffmpeg::Tools,
401 plan: &EncodePlan,
402 output: &Path,
403 ) -> Option<f64> {
404 let (w, h) = (plan.source_width?, plan.source_height?);
405 let fps = plan.source_fps.unwrap_or(0.0);
406 deepshrink_ffmpeg::measure_vmaf(
407 &tools.ffmpeg,
408 output,
409 &plan.input,
410 w,
411 h,
412 fps,
413 thread_count(),
414 )
415 .ok()
416 }
417}
418
419impl Engine for MediaEngine {
420 fn supports(&self, input: &Path) -> bool {
421 matches!(detect_kind(input), MediaKind::Video | MediaKind::Audio)
422 }
423
424 fn probe(&self, input: &Path) -> Result<MediaInfo, EngineError> {
425 let tools = deepshrink_ffmpeg::locate()?;
426 let p = deepshrink_ffmpeg::probe(&tools.ffprobe, input)?;
427
428 let video = p.video_stream();
429 let audio = p.audio_stream();
430 let size_bytes = p
432 .size_bytes()
433 .or_else(|| fs::metadata(input).ok().map(|m| m.len()))
434 .unwrap_or(0);
435
436 Ok(MediaInfo {
437 path: input.to_path_buf(),
438 kind: detect_kind(input),
439 duration_sec: p.duration_sec().unwrap_or(0.0),
440 size_bytes,
441 width: video.and_then(|v| v.display_size().0),
443 height: video.and_then(|v| v.display_size().1),
444 fps: p.fps(),
445 video_codec: video.and_then(|v| v.codec_name.clone()),
446 audio_codec: audio.and_then(|a| a.codec_name.clone()),
447 audio_channels: audio.and_then(|a| a.channels),
448 audio_bitrate_bps: p.audio_bitrate_bps(),
449 capture: capture_meta(&p),
450 hdr: video
451 .and_then(|v| v.color_transfer.as_deref())
452 .and_then(Hdr::from_transfer),
453 })
454 }
455
456 fn plan(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
457 let hw = opts.hardware && opts.target_vmaf.is_none() && hardware_encoding_available();
459 super::plan::plan(info, opts, hw)
460 }
461
462 fn run(&self, plan: &EncodePlan) -> Result<Outcome, EngineError> {
463 self.run_with_progress(plan, &mut |_, _| {})
464 }
465}
466
467pub fn hardware_encoding_available() -> bool {
471 static AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
472 *AVAILABLE.get_or_init(|| {
473 cfg!(all(target_os = "macos", target_arch = "aarch64"))
475 && deepshrink_ffmpeg::locate().is_ok_and(|t| {
476 deepshrink_ffmpeg::has_encoder(&t.ffmpeg, "h264_videotoolbox")
477 && deepshrink_ffmpeg::has_encoder(&t.ffmpeg, "hevc_videotoolbox")
478 })
479 })
480}
481
482fn copy_mtime(input: &Path, output: &Path) {
484 let Ok(mtime) = fs::metadata(input).and_then(|m| m.modified()) else {
485 return;
486 };
487 if let Ok(f) = fs::File::options().write(true).open(output) {
488 let _ = f.set_modified(mtime);
489 }
490}
491
492fn capture_meta(p: &deepshrink_ffmpeg::Ffprobe) -> CaptureMeta {
494 let tag = |keys: &[&str]| {
495 keys.iter()
496 .find_map(|k| p.format_tag(k))
497 .map(str::to_string)
498 };
499 let created_local = tag(&["com.apple.quicktime.creationdate"]);
500 let created_utc = created_local
501 .as_deref()
502 .and_then(to_utc)
503 .or_else(|| tag(&["creation_time"]));
504 CaptureMeta {
505 created_utc,
506 created_local,
507 location: tag(&["com.apple.quicktime.location.ISO6709", "location"]),
508 make: tag(&["com.apple.quicktime.make", "make"]),
509 model: tag(&["com.apple.quicktime.model", "model"]),
510 }
511}
512
513fn metadata_args(plan: &EncodePlan) -> (Vec<OsString>, Option<&'static str>) {
520 if !plan.spec.keep_metadata {
521 return (vec!["-map_metadata".into(), "-1".into()], None);
522 }
523 let mut a: Vec<OsString> = vec!["-map_metadata".into(), "0".into()];
524 for (k, v) in &plan.spec.tags {
525 a.push("-metadata".into());
526 a.push(format!("{k}={v}").into());
527 }
528 (a, None)
529}
530
531fn movflags(faststart: bool, meta: Option<&'static str>) -> Option<String> {
533 let mut v = String::new();
534 if faststart {
535 v.push_str("+faststart");
536 }
537 if let Some(m) = meta {
538 v.push_str(m);
539 }
540 (!v.is_empty()).then_some(v)
541}
542
543fn ceiling_fit(
547 tools: &deepshrink_ffmpeg::Tools,
548 plan: &EncodePlan,
549 encoder: &str,
550 zscale: bool,
551) -> Option<(EncodePlan, u64)> {
552 let target = plan.target_bytes?;
553 let ceiling = ceiling_plan(plan)?;
554 let predicted = predict_crf_bytes(tools, &ceiling, encoder, zscale)?;
555 ((predicted as f64) < target as f64 * CEILING_MARGIN).then_some((ceiling, predicted))
556}
557
558fn wants_zscale(tools: &deepshrink_ffmpeg::Tools, plan: &EncodePlan) -> bool {
560 plan.spec.video.to_sdr == Some(Hdr::Pq)
561 && deepshrink_ffmpeg::has_filter(&tools.ffmpeg, "zscale")
562}
563
564fn predict_crf_bytes(
568 tools: &deepshrink_ffmpeg::Tools,
569 plan: &EncodePlan,
570 encoder: &str,
571 zscale: bool,
572) -> Option<u64> {
573 let duration = plan.source_duration_sec;
574 if !duration.is_finite() || duration <= 0.0 {
575 return None;
576 }
577 let (windows, bias) = sample_windows(plan);
578 let mut sample = plan.clone();
579 sample.spec.audio = None;
580 sample.spec.faststart = false;
581 let mut video_bytes = 0u64;
582 let mut sampled = 0.0;
583 for (i, &(start, len)) in windows.iter().enumerate() {
584 sample.output =
585 std::env::temp_dir().join(format!("deepshrink-sample-{}-{i}.mp4", std::process::id()));
586 let mut args = build_pass_args(&sample, PassKind::Single, "", encoder, zscale);
587 let at_input = args.iter().position(|a| a == "-i")?;
588 args.splice(
589 at_input..at_input,
590 [
591 OsString::from("-ss"),
592 OsString::from(format!("{start:.2}")),
593 OsString::from("-t"),
594 OsString::from(format!("{len:.2}")),
595 ],
596 );
597 let ran = tools.run_pass(&args, len, &mut |_| {});
598 let bytes = fs::metadata(&sample.output).map(|m| m.len()).ok();
599 let _ = fs::remove_file(&sample.output);
600 video_bytes += bytes.filter(|_| ran.is_ok())?;
601 sampled += len;
602 }
603 Some(predicted_bytes(plan, video_bytes, sampled, bias))
604}
605
606fn video_filters(video: &VideoSpec, zscale: bool) -> Option<String> {
615 const COLORSPACE: &str = "colorspace=all=bt709:iall=bt2020:itrc=bt2020-10:format=yuv420p";
616 let mut chain = Vec::new();
617 if let Some(h) = video.height {
619 chain.push(if video.portrait {
620 format!("scale={h}:-2")
621 } else {
622 format!("scale=-2:{h}")
623 });
624 }
625 match video.to_sdr {
626 Some(Hdr::Pq) if zscale => chain.push(
627 "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,\
628 tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
629 .to_string(),
630 ),
631 Some(_) => chain.push(COLORSPACE.to_string()),
632 None => {}
633 }
634 (!chain.is_empty()).then(|| chain.join(","))
635}
636
637fn passlog_base(plan: &EncodePlan) -> String {
639 let stem = plan
640 .input
641 .file_stem()
642 .map(|s| s.to_string_lossy().into_owned())
643 .unwrap_or_else(|| "ds".to_string());
644 let dir = std::env::temp_dir();
645 dir.join(format!("deepshrink-{}-{}", std::process::id(), stem))
646 .to_string_lossy()
647 .into_owned()
648}
649
650fn cleanup_passlog(base: &str) {
652 for suffix in ["-0.log", "-0.log.mbtree"] {
653 let _ = fs::remove_file(format!("{base}{suffix}"));
654 }
655}
656
657fn encode_at_crf(
659 tools: &deepshrink_ffmpeg::Tools,
660 plan: &EncodePlan,
661 encoder: &str,
662 zscale: bool,
663 crf: u8,
664 total: f64,
665 on_progress: &mut dyn FnMut(PassKind, f64),
666) -> Result<(), EngineError> {
667 let mut trial = plan.clone();
668 trial.spec.video.crf = Some(crf);
669 trial.spec.video.bitrate_bps = None;
670 trial.spec.two_pass = false;
671 let args = build_pass_args(&trial, PassKind::Single, "", encoder, zscale);
672 tools.run_pass(&args, total, &mut |f| on_progress(PassKind::Single, f))?;
673 Ok(())
674}
675
676fn thread_count() -> usize {
678 std::thread::available_parallelism()
679 .map(|n| n.get())
680 .unwrap_or(1)
681}
682
683fn null_sink() -> &'static str {
685 if cfg!(windows) {
686 "NUL"
687 } else {
688 "/dev/null"
689 }
690}
691
692fn resolve_encoder(
700 tools: &deepshrink_ffmpeg::Tools,
701 plan: &EncodePlan,
702) -> Result<&'static str, EngineError> {
703 let codec = plan.spec.video.codec;
704 if plan.spec.video.hardware && !plan.spec.passthrough && !plan.spec.audio_only {
706 if let Some(hw) = codec.hardware_encoder() {
707 return Ok(hw);
708 }
709 }
710 let primary = codec.encoder();
711 let Some(fallback) = codec.fallback_encoder() else {
712 return Ok(primary);
713 };
714 if plan.spec.passthrough || plan.spec.audio_only {
716 return Ok(primary);
717 }
718 if deepshrink_ffmpeg::has_encoder(&tools.ffmpeg, primary) {
719 return Ok(primary);
720 }
721 if deepshrink_ffmpeg::has_encoder(&tools.ffmpeg, fallback) {
722 return Ok(fallback);
723 }
724 Err(EngineError::Unsupported(format!(
725 "this ffmpeg build has no {} encoder (looked for {primary} and {fallback})",
726 codec.label()
727 )))
728}
729
730fn build_pass_args(
735 plan: &EncodePlan,
736 pass: PassKind,
737 passlog: &str,
738 encoder: &str,
739 zscale: bool,
740) -> Vec<OsString> {
741 let s = &plan.spec;
742 let mut a: Vec<OsString> = Vec::new();
743 macro_rules! push {
746 ($arg:expr) => {
747 a.push(OsString::from($arg))
748 };
749 }
750
751 push!("-hide_banner");
752 push!("-y");
753 push!("-loglevel");
754 push!("error");
755 push!("-progress");
756 push!("pipe:1");
757 push!("-nostats");
758 push!("-i");
759 a.push(plan.input.clone().into_os_string());
760
761 let (meta, meta_flag) = metadata_args(plan);
762
763 if s.passthrough {
765 push!("-c");
766 push!("copy");
767 a.extend(meta.iter().cloned());
768 if let Some(flags) = movflags(s.faststart, meta_flag) {
769 push!("-movflags");
770 push!(flags);
771 }
772 a.push(plan.output.clone().into_os_string());
773 return a;
774 }
775
776 if s.audio_only {
778 push!("-vn");
779 if let Some(au) = &s.audio {
780 push!("-c:a");
781 push!(au.codec.encoder());
782 if au.mono {
783 push!("-ac");
784 push!("1");
785 }
786 if let Some(sr) = au.sample_rate {
787 push!("-ar");
788 push!(sr.to_string());
789 }
790 push!("-b:a");
791 push!(au.bitrate_bps.to_string());
792 if matches!(au.codec, AudioCodec::Opus) {
795 push!("-vbr");
796 push!(if au.vbr { "on" } else { "constrained" });
797 }
798 }
799 a.extend(meta.iter().cloned());
800 if let Some(flags) = movflags(false, meta_flag) {
801 push!("-movflags");
802 push!(flags);
803 }
804 a.push(plan.output.clone().into_os_string());
805 return a;
806 }
807
808 push!("-c:v");
810 push!(encoder);
811 if let Some(vf) = video_filters(&s.video, zscale) {
812 push!("-vf");
813 push!(vf);
814 }
815 if let Some(f) = s.video.fps {
816 push!("-r");
817 push!(f.to_string());
818 }
819 let videotoolbox = encoder.ends_with("_videotoolbox");
823 if !videotoolbox {
824 let (speed_flag, speed_value) = s.video.preset.speed_flags(encoder);
825 push!(speed_flag);
826 push!(speed_value);
827 }
828 if let Some(tag) = s.video.codec.mp4_tag() {
829 push!("-tag:v");
830 push!(tag);
831 }
832 if s.video.to_sdr.is_some() {
835 for (flag, value) in [
836 ("-pix_fmt", "yuv420p"),
837 ("-color_primaries", "bt709"),
838 ("-color_trc", "bt709"),
839 ("-colorspace", "bt709"),
840 ] {
841 push!(flag);
842 push!(value);
843 }
844 }
845
846 match (s.video.bitrate_bps, s.video.crf) {
848 (Some(bps), _) => {
849 push!("-b:v");
850 push!(bps.to_string());
851 if s.two_pass {
852 push!("-pass");
853 push!(match pass {
854 PassKind::First => "1",
855 _ => "2",
856 });
857 push!("-passlogfile");
858 push!(passlog);
859 }
860 }
861 (_, Some(crf)) => {
862 push!(if videotoolbox { "-q:v" } else { "-crf" });
864 push!(crf.to_string());
865 }
866 _ => {}
867 }
868
869 match pass {
871 PassKind::First => {
872 push!("-an");
874 push!("-f");
875 push!("null");
876 push!(null_sink());
877 }
878 PassKind::Second | PassKind::Single => {
879 match &s.audio {
880 Some(au) => {
881 push!("-c:a");
882 push!(au.codec.encoder());
883 if au.mono {
887 push!("-ac");
888 push!("1");
889 }
890 push!("-b:a");
891 push!(au.bitrate_bps.to_string());
892 }
893 None => push!("-an"),
894 }
895 a.extend(meta.iter().cloned());
896 if let Some(flags) = movflags(s.faststart, meta_flag) {
897 push!("-movflags");
898 push!(flags);
899 }
900 a.push(plan.output.clone().into_os_string());
901 }
902 }
903 a
904}
905
906#[cfg(test)]
907mod tests {
908 use super::*;
909 use crate::engine::SizeGoal;
910 use crate::options::{AudioChoice, AudioCodec, QualityPreset, ResolutionOpt, VideoCodec};
911 use crate::size::preset;
912 use std::path::PathBuf;
913
914 fn enc(plan: &EncodePlan) -> &'static str {
917 plan.spec.video.codec.encoder()
918 }
919
920 fn video_info(duration: f64, size: u64, w: u32, h: u32, audio: bool) -> MediaInfo {
921 MediaInfo {
922 path: PathBuf::from("/tmp/clip.mp4"),
923 kind: MediaKind::Video,
924 duration_sec: duration,
925 size_bytes: size,
926 width: Some(w),
927 height: Some(h),
928 fps: Some(30.0),
929 video_codec: Some("h264".into()),
930 audio_codec: if audio { Some("aac".into()) } else { None },
931 audio_channels: if audio { Some(2) } else { None },
932 audio_bitrate_bps: None,
933 capture: CaptureMeta::default(),
934 hdr: None,
935 }
936 }
937
938 fn opts_target(bytes: u64) -> ShrinkOpts {
939 ShrinkOpts {
940 goal: SizeGoal::Target(bytes),
941 ..Default::default()
942 }
943 }
944
945 #[test]
946 fn supports_video_and_audio() {
947 let e = MediaEngine::new();
948 assert!(e.supports(&PathBuf::from("clip.mp4")));
949 assert!(e.supports(&PathBuf::from("lecture.wav")));
950 assert!(!e.supports(&PathBuf::from("photo.jpg")));
951 }
952
953 #[test]
954 fn plan_target_builds_two_pass_with_budget() {
955 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
956 let plan = MediaEngine::new()
957 .plan(&info, &opts_target(8_000_000))
958 .unwrap();
959
960 assert!(plan.spec.two_pass);
961 assert_eq!(plan.target_bytes, Some(8_000_000));
962 assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
963 let vbps = plan.spec.video.bitrate_bps.unwrap();
964 assert!(vbps >= budget::ABSOLUTE_MIN_VIDEO_BPS);
965 assert!(plan.spec.video.height.is_some());
967 assert!(plan.spec.audio.is_some());
968 assert!(plan.expected_bytes.unwrap() <= 8_000_000 + 8_000_000 / 20);
970 }
971
972 #[test]
973 fn plan_video_mono_downmixes_the_audio_track() {
974 let info = video_info(60.0, 100_000_000, 1280, 720, true);
975 let opts = ShrinkOpts {
976 mono: true,
977 ..opts_target(8_000_000)
978 };
979 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
980 let audio = plan.spec.audio.as_ref().expect("kept audio track");
981 assert!(audio.mono, "opts.mono downmixes the video's audio track");
982 let stereo = MediaEngine::new()
984 .plan(&info, &opts_target(8_000_000))
985 .unwrap();
986 assert!(!stereo.spec.audio.as_ref().unwrap().mono);
987 }
988
989 #[test]
990 fn video_mono_reaches_ffmpeg_as_ac_1() {
991 let info = video_info(60.0, 100_000_000, 1280, 720, true);
994 let plan = MediaEngine::new()
995 .plan(
996 &info,
997 &ShrinkOpts {
998 mono: true,
999 ..opts_target(8_000_000)
1000 },
1001 )
1002 .unwrap();
1003 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan), false);
1004 let joined: Vec<String> = args
1005 .iter()
1006 .map(|a| a.to_string_lossy().into_owned())
1007 .collect();
1008 let ac = joined.iter().position(|a| a == "-ac").expect("-ac emitted");
1009 assert_eq!(joined[ac + 1], "1");
1010
1011 let stereo = MediaEngine::new()
1013 .plan(&info, &opts_target(8_000_000))
1014 .unwrap();
1015 let stereo_args: Vec<String> = build_pass_args(
1016 &stereo,
1017 PassKind::Second,
1018 "/tmp/passlog",
1019 enc(&stereo),
1020 false,
1021 )
1022 .iter()
1023 .map(|a| a.to_string_lossy().into_owned())
1024 .collect();
1025 assert!(!stereo_args.iter().any(|a| a == "-ac"));
1026 }
1027
1028 #[test]
1029 fn plan_preset_discord_sets_target() {
1030 let info = video_info(30.0, 50_000_000, 1280, 720, true);
1031 let opts = ShrinkOpts {
1032 goal: SizeGoal::Preset(preset("discord").unwrap()),
1033 ..Default::default()
1034 };
1035 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1036 assert_eq!(plan.target_bytes, Some(10_000_000));
1037 }
1038
1039 #[test]
1040 fn plan_reduce_targets_complement_of_original() {
1041 let info = video_info(60.0, 100_000_000, 1920, 1080, true);
1042 let opts = ShrinkOpts {
1043 goal: SizeGoal::Reduce(0.70),
1044 ..Default::default()
1045 };
1046 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1047 assert_eq!(plan.target_bytes, Some(30_000_000));
1048 }
1049
1050 #[test]
1051 fn plan_passthrough_when_source_already_fits() {
1052 let info = video_info(10.0, 200_000, 1280, 720, true);
1054 let plan = MediaEngine::new()
1055 .plan(&info, &opts_target(1_000_000))
1056 .unwrap();
1057 assert!(plan.spec.passthrough);
1058 assert!(!plan.spec.two_pass);
1059 assert_eq!(plan.expected_bytes, Some(200_000));
1060 let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog", enc(&plan), false);
1061 let joined: Vec<String> = args
1062 .iter()
1063 .map(|a| a.to_string_lossy().into_owned())
1064 .collect();
1065 assert!(joined.contains(&"copy".to_string()));
1066 assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
1069 }
1070
1071 #[test]
1072 fn plan_passthrough_keeps_the_source_container() {
1073 let info = MediaInfo {
1076 path: PathBuf::from("/tmp/voice.3gp"),
1077 ..video_info(10.0, 200_000, 320, 240, true)
1078 };
1079 let plan = MediaEngine::new()
1080 .plan(&info, &opts_target(1_000_000))
1081 .unwrap();
1082 assert!(plan.spec.passthrough);
1083 assert_eq!(plan.output, PathBuf::from("/tmp/voice.shrink.3gp"));
1084 }
1085
1086 #[test]
1087 fn plan_infeasible_when_target_too_small() {
1088 let info = video_info(600.0, 500_000_000, 1920, 1080, true);
1089 let err = MediaEngine::new().plan(&info, &opts_target(50_000));
1090 assert!(matches!(err, Err(EngineError::Infeasible)));
1091 }
1092
1093 #[test]
1094 fn plan_quality_mode_uses_crf_single_pass() {
1095 let info = video_info(60.0, 100_000_000, 1920, 1080, true);
1096 let opts = ShrinkOpts {
1097 goal: SizeGoal::Quality,
1098 quality: QualityPreset::Balanced,
1099 ..Default::default()
1100 };
1101 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1102 assert!(!plan.spec.two_pass);
1103 assert_eq!(plan.spec.video.crf, Some(23));
1104 assert!(plan.spec.video.bitrate_bps.is_none());
1105 assert!(plan.expected_bytes.is_none());
1106 }
1107
1108 #[test]
1109 fn plan_drops_audio_when_requested() {
1110 let info = video_info(30.0, 50_000_000, 1280, 720, true);
1111 let opts = ShrinkOpts {
1112 audio: AudioChoice::Drop,
1113 ..opts_target(8_000_000)
1114 };
1115 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1116 assert!(plan.spec.audio.is_none());
1117 }
1118
1119 fn audio_info(duration: f64, size: u64, channels: u32) -> MediaInfo {
1120 MediaInfo {
1121 path: PathBuf::from("/tmp/lecture.wav"),
1122 kind: MediaKind::Audio,
1123 duration_sec: duration,
1124 size_bytes: size,
1125 width: None,
1126 height: None,
1127 fps: None,
1128 video_codec: None,
1129 audio_codec: Some("pcm_s16le".into()),
1130 audio_channels: Some(channels),
1131 audio_bitrate_bps: None,
1132 capture: CaptureMeta::default(),
1133 hdr: None,
1134 }
1135 }
1136
1137 #[test]
1138 fn quality_audio_bitrate_follows_tier_codec_and_channels() {
1139 use QualityPreset::*;
1140 assert_eq!(quality_audio_bps(Balanced, AudioCodec::Aac, false), 128_000);
1141 assert_eq!(quality_audio_bps(Balanced, AudioCodec::Aac, true), 64_000);
1142 assert_eq!(quality_audio_bps(Fast, AudioCodec::Opus, true), 32_000);
1143 assert_eq!(quality_audio_bps(Max, AudioCodec::Mp3, false), 256_000);
1144 for c in [AudioCodec::Aac, AudioCodec::Opus, AudioCodec::Mp3] {
1146 let t: Vec<_> = [Fast, Balanced, Max]
1147 .map(|q| quality_audio_bps(q, c, false))
1148 .into();
1149 assert!(t[0] < t[1] && t[1] < t[2], "{c:?}: {t:?}");
1150 }
1151 }
1152
1153 #[test]
1154 fn a_compact_audiobook_is_kept_in_quality_mode() {
1155 let mut info = audio_info(3600.0, 64_000 / 8 * 3600, 1);
1157 info.path = PathBuf::from("/tmp/book.mp3");
1158 let plan = MediaEngine::new()
1159 .plan(&info, &ShrinkOpts::default())
1160 .unwrap();
1161 assert!(plan.spec.passthrough, "{}", plan.summary);
1162 assert!(plan.output.to_string_lossy().ends_with(".mp3"));
1163 assert!(plan.summary.contains("already compact"));
1164
1165 let smaller = ShrinkOpts {
1167 audio_codec: AudioCodec::Opus,
1168 quality: QualityPreset::Fast,
1169 ..ShrinkOpts::default()
1170 };
1171 let plan = MediaEngine::new().plan(&info, &smaller).unwrap();
1172 assert!(!plan.spec.passthrough);
1173 assert_eq!(plan.spec.audio.as_ref().unwrap().bitrate_bps, 32_000);
1174 assert!(
1175 plan.guard_larger,
1176 "quality mode keeps the post-encode check"
1177 );
1178
1179 let allow = ShrinkOpts {
1181 allow_larger: true,
1182 ..ShrinkOpts::default()
1183 };
1184 let plan = MediaEngine::new().plan(&info, &allow).unwrap();
1185 assert!(!plan.spec.passthrough && !plan.guard_larger);
1186 }
1187
1188 #[test]
1189 fn the_guard_is_for_quality_mode_only() {
1190 let info = video_info(60.0, 50_000_000, 1920, 1080, true);
1191 let quality = MediaEngine::new()
1192 .plan(&info, &ShrinkOpts::default())
1193 .unwrap();
1194 assert!(quality.guard_larger);
1195 let target = MediaEngine::new()
1196 .plan(&info, &opts_target(10_000_000))
1197 .unwrap();
1198 assert!(!target.guard_larger, "a size target is its own guarantee");
1199 }
1200
1201 #[test]
1202 fn plan_audio_single_pass_with_fitted_bitrate() {
1203 let info = audio_info(3480.0, 600_000_000, 2);
1205 let plan = MediaEngine::new()
1206 .plan(&info, &opts_target(10_000_000))
1207 .unwrap();
1208 assert!(plan.spec.audio_only);
1209 assert!(!plan.spec.two_pass);
1210 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.m4a"));
1211 let au = plan.spec.audio.as_ref().unwrap();
1212 assert!(budget::AUDIO_STEPS.contains(&au.bitrate_bps));
1214 assert!(plan.expected_bytes.unwrap() <= 10_000_000 + 10_000_000 / 20);
1215 }
1216
1217 #[test]
1218 fn plan_audio_mono_source_marked_speech() {
1219 let info = audio_info(600.0, 100_000_000, 1);
1220 let plan = MediaEngine::new()
1221 .plan(&info, &opts_target(5_000_000))
1222 .unwrap();
1223 assert!(plan.spec.audio.as_ref().unwrap().mono);
1224 }
1225
1226 #[test]
1227 fn plan_audio_opus_extension_and_vbr_args() {
1228 let info = audio_info(600.0, 100_000_000, 2);
1229 let opts = ShrinkOpts {
1230 audio_codec: AudioCodec::Opus,
1231 mono: true,
1232 ..opts_target(3_000_000)
1233 };
1234 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1235 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.opus"));
1236 let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog", enc(&plan), false);
1237 let j: Vec<String> = args
1238 .iter()
1239 .map(|a| a.to_string_lossy().into_owned())
1240 .collect();
1241 assert!(j.contains(&"-vn".to_string()));
1242 assert!(j.contains(&"libopus".to_string()));
1243 assert!(j.contains(&"-ac".to_string())); assert!(j.contains(&"-vbr".to_string()));
1245 }
1246
1247 #[test]
1248 fn plan_audio_infeasible_when_target_tiny() {
1249 let info = audio_info(3600.0, 500_000_000, 2);
1250 assert!(matches!(
1251 MediaEngine::new().plan(&info, &opts_target(1_000)),
1252 Err(EngineError::Infeasible)
1253 ));
1254 }
1255
1256 #[test]
1257 fn plan_audio_passthrough_when_source_fits() {
1258 let info = audio_info(600.0, 2_000_000, 2);
1259 let plan = MediaEngine::new()
1260 .plan(&info, &opts_target(10_000_000))
1261 .unwrap();
1262 assert!(plan.spec.passthrough);
1263 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.wav"));
1265 }
1266
1267 #[test]
1268 fn pass1_args_have_no_audio_and_null_sink() {
1269 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1270 let plan = MediaEngine::new()
1271 .plan(&info, &opts_target(8_000_000))
1272 .unwrap();
1273 let args = build_pass_args(&plan, PassKind::First, "/tmp/passlog", enc(&plan), false);
1274 let joined: Vec<String> = args
1275 .iter()
1276 .map(|a| a.to_string_lossy().into_owned())
1277 .collect();
1278 assert!(joined.contains(&"-an".to_string()));
1279 assert!(joined.contains(&"null".to_string()));
1280 assert!(joined.iter().any(|a| a == "1")); assert!(!joined.iter().any(|a| a.contains("shrink.mp4")));
1282 }
1283
1284 #[test]
1285 fn pass2_args_write_output_with_audio() {
1286 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1287 let plan = MediaEngine::new()
1288 .plan(&info, &opts_target(8_000_000))
1289 .unwrap();
1290 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan), false);
1291 let joined: Vec<String> = args
1292 .iter()
1293 .map(|a| a.to_string_lossy().into_owned())
1294 .collect();
1295 assert!(joined.iter().any(|a| a.contains("clip.shrink.mp4")));
1296 assert!(joined.contains(&"-c:a".to_string()));
1297 assert!(joined.iter().any(|a| a.contains("+faststart")));
1298 assert!(joined.iter().any(|a| a == "2")); }
1300
1301 fn joined(plan: &EncodePlan, pass: PassKind) -> Vec<String> {
1302 joined_with(plan, pass, false)
1303 }
1304
1305 fn joined_with(plan: &EncodePlan, pass: PassKind, zscale: bool) -> Vec<String> {
1306 build_pass_args(plan, pass, "/tmp/passlog", enc(plan), zscale)
1307 .iter()
1308 .map(|a| a.to_string_lossy().into_owned())
1309 .collect()
1310 }
1311
1312 #[test]
1313 fn hdr_transfer_is_recognised() {
1314 assert_eq!(Hdr::from_transfer("arib-std-b67"), Some(Hdr::Hlg));
1315 assert_eq!(Hdr::from_transfer("smpte2084"), Some(Hdr::Pq));
1316 assert_eq!(Hdr::from_transfer("bt709"), None);
1317 }
1318
1319 #[test]
1320 fn hdr_is_tone_mapped_to_sdr_for_size_targets_only() {
1321 let mut info = iphone_info();
1322 info.hdr = Some(Hdr::Hlg);
1323 let engine = MediaEngine::new();
1324
1325 let target = engine.plan(&info, &opts_target(10_000_000)).unwrap();
1327 assert_eq!(target.spec.video.to_sdr, Some(Hdr::Hlg));
1328 assert!(target.summary.contains("HDR → SDR"));
1329 let vf_of =
1330 |args: &[String]| args[args.iter().position(|a| a == "-vf").unwrap() + 1].clone();
1331 let hlg = joined_with(&target, PassKind::Second, true);
1333 let vf = vf_of(&hlg);
1334 assert!(
1335 vf.contains("colorspace=all=bt709") && !vf.contains("zscale"),
1336 "{vf}"
1337 );
1338 assert!(
1341 vf.find("scale=").unwrap() < vf.find("colorspace").unwrap(),
1342 "{vf}"
1343 );
1344 for pair in [
1345 ["-pix_fmt", "yuv420p"],
1346 ["-color_trc", "bt709"],
1347 ["-colorspace", "bt709"],
1348 ] {
1349 assert!(hlg.windows(2).any(|w| w == pair), "{pair:?}");
1350 }
1351 let mut pq = target.clone();
1353 pq.spec.video.to_sdr = Some(Hdr::Pq);
1354 let vf = vf_of(&joined_with(&pq, PassKind::Second, true));
1355 assert!(
1356 vf.contains("tonemap=hable") && vf.contains("npl=100"),
1357 "{vf}"
1358 );
1359 let vf = vf_of(&joined_with(&pq, PassKind::Second, false));
1360 assert!(
1361 vf.contains("colorspace=all=bt709") && !vf.contains("zscale"),
1362 "{vf}"
1363 );
1364
1365 let quality = engine.plan(&info, &ShrinkOpts::default()).unwrap();
1367 assert_eq!(quality.spec.video.to_sdr, None);
1368 let args = joined(&quality, PassKind::Single);
1369 assert!(!args
1370 .iter()
1371 .any(|a| a == "-pix_fmt" || a.contains("colorspace")));
1372
1373 let sdr = engine
1375 .plan(&iphone_info(), &opts_target(10_000_000))
1376 .unwrap();
1377 assert_eq!(sdr.spec.video.to_sdr, None);
1378 assert!(!joined(&sdr, PassKind::Second)
1379 .iter()
1380 .any(|a| a == "-pix_fmt"));
1381 }
1382
1383 #[test]
1384 fn a_size_target_is_a_ceiling_at_the_quality_crf() {
1385 let info = video_info(60.0, 200_000_000, 1920, 1080, true);
1386 let engine = MediaEngine::new();
1387 let opts = opts_target(50_000_000);
1388 let plan = engine.plan(&info, &opts).unwrap();
1389 let crf = opts.quality.default_crf(opts.video_codec);
1390 assert_eq!(plan.ceiling_crf, Some(crf));
1391
1392 let ceiling = ceiling_plan(&plan).unwrap();
1393 assert_eq!(ceiling.spec.video.crf, Some(crf));
1394 assert_eq!(ceiling.spec.video.bitrate_bps, None);
1395 assert!(!ceiling.spec.two_pass);
1396 assert_eq!(ceiling.spec.video.height, plan.spec.video.height);
1398 assert_eq!(ceiling.spec.audio, plan.spec.audio);
1399 assert_eq!(ceiling.output, plan.output);
1400 assert_eq!(ceiling.target_bytes, plan.target_bytes);
1401
1402 let quality = engine.plan(&info, &ShrinkOpts::default()).unwrap();
1404 assert!(quality.ceiling_crf.is_none() && ceiling_plan(&quality).is_none());
1405 let fits = engine.plan(&info, &opts_target(300_000_000)).unwrap();
1406 assert!(fits.spec.passthrough && ceiling_plan(&fits).is_none());
1407 }
1408
1409 #[test]
1410 fn heavy_video_samples_shorter_windows() {
1411 let engine = MediaEngine::new();
1412 let mut info = video_info(120.0, 500_000_000, 1920, 1080, true);
1413 info.fps = Some(30.0);
1414 let plan = engine.plan(&info, &ShrinkOpts::default()).unwrap();
1415 assert_eq!(sample_secs(&plan), 3.0);
1416 info = video_info(120.0, 500_000_000, 3840, 2160, true);
1417 info.fps = Some(60.0);
1418 let plan = engine.plan(&info, &ShrinkOpts::default()).unwrap();
1419 assert_eq!(sample_secs(&plan), MIN_SAMPLE_SECS);
1420 }
1421
1422 #[test]
1423 fn apple_hardware_uses_quality_one_pass_and_no_preset() {
1424 let engine = MediaEngine::new();
1425 let info = video_info(60.0, 200_000_000, 1920, 1080, true);
1426 let mut plan = engine.plan(&info, &ShrinkOpts::default()).unwrap();
1427 plan.spec.video.hardware = true;
1429 plan.spec.video.crf = QualityPreset::Balanced.default_hw_quality(VideoCodec::H264);
1430 let args: Vec<String> =
1431 build_pass_args(&plan, PassKind::Single, "", "h264_videotoolbox", false)
1432 .iter()
1433 .map(|a| a.to_string_lossy().into_owned())
1434 .collect();
1435 assert!(args.windows(2).any(|w| w == ["-c:v", "h264_videotoolbox"]));
1436 assert!(args.windows(2).any(|w| w == ["-q:v", "66"]), "{args:?}");
1437 assert!(
1438 !args.iter().any(|a| a == "-crf" || a == "-preset"),
1439 "{args:?}"
1440 );
1441 let mut hw = plan.clone();
1442 hw.spec.passthrough = false;
1443 assert_eq!(
1444 resolve_encoder(
1445 &deepshrink_ffmpeg::Tools {
1446 ffmpeg: "ffmpeg".into(),
1447 ffprobe: "ffprobe".into(),
1448 cancel: Default::default(),
1449 },
1450 &hw
1451 )
1452 .unwrap(),
1453 "h264_videotoolbox"
1454 );
1455 assert_eq!(
1457 QualityPreset::Balanced.default_hw_quality(VideoCodec::Av1),
1458 None
1459 );
1460 }
1461
1462 #[test]
1463 fn a_portrait_video_is_capped_on_its_short_side() {
1464 let engine = MediaEngine::new();
1465 let info = video_info(60.0, 200_000_000, 2160, 3840, true);
1467 let opts = ShrinkOpts {
1468 resolution: ResolutionOpt::Height(1080),
1469 ..ShrinkOpts::default()
1470 };
1471 let plan = engine.plan(&info, &opts).unwrap();
1472 assert_eq!(plan.spec.video.height, Some(1080));
1473 assert!(plan.spec.video.portrait);
1474 let args = joined(&plan, PassKind::Single);
1475 let vf = &args[args.iter().position(|a| a == "-vf").unwrap() + 1];
1476 assert!(vf.starts_with("scale=1080:-2"), "{vf}");
1478
1479 let info = video_info(60.0, 200_000_000, 3840, 2160, true);
1481 let plan = engine.plan(&info, &opts).unwrap();
1482 assert!(!plan.spec.video.portrait);
1483 let args = joined(&plan, PassKind::Single);
1484 assert!(args.iter().any(|a| a.starts_with("scale=-2:1080")));
1485 }
1486
1487 fn iphone_info() -> MediaInfo {
1488 let mut info = video_info(60.0, 50_000_000, 2160, 3840, true);
1489 info.path = PathBuf::from("/tmp/IMG_3325.MOV");
1490 info.capture = CaptureMeta {
1491 created_utc: to_utc("2026-09-26T20:01:54+0300"),
1492 created_local: Some("2026-09-26T20:01:54+0300".into()),
1493 location: Some("+50.4160+030.2796+155.635/".into()),
1494 make: Some("Apple".into()),
1495 model: Some("iPhone 12 Pro Max".into()),
1496 };
1497 info
1498 }
1499
1500 #[test]
1501 fn metadata_is_kept_by_default_and_strippable() {
1502 let info = iphone_info();
1503 let plan = MediaEngine::new()
1504 .plan(&info, &ShrinkOpts::default())
1505 .unwrap();
1506 let a = joined(&plan, PassKind::Single);
1507 let at = a.iter().position(|x| x == "-map_metadata").unwrap();
1508 assert_eq!(a[at + 1], "0");
1509 for tag in [
1512 "creation_time=2026-09-26T17:01:54Z",
1513 "location=+50.4160+030.2796+155.635/",
1514 "make=Apple",
1515 "model=iPhone 12 Pro Max",
1516 "date=2026-09-26T20:01:54+0300",
1517 ] {
1518 assert!(a.contains(&tag.to_string()), "{tag} in {a:?}");
1519 }
1520 assert!(a.contains(&"+faststart".to_string()));
1521
1522 let strip = ShrinkOpts {
1523 keep_metadata: false,
1524 ..ShrinkOpts::default()
1525 };
1526 let plan = MediaEngine::new().plan(&info, &strip).unwrap();
1527 let a = joined(&plan, PassKind::Single);
1528 let at = a.iter().position(|x| x == "-map_metadata").unwrap();
1529 assert_eq!(a[at + 1], "-1");
1530 assert!(!a.iter().any(|x| x.starts_with("location=")));
1531 assert!(a.contains(&"+faststart".to_string()));
1532 }
1533
1534 #[test]
1535 fn apple_local_time_converts_to_utc() {
1536 let utc = |s: &str| to_utc(s);
1537 assert_eq!(
1538 utc("2026-09-26T20:01:54+0300").as_deref(),
1539 Some("2026-09-26T17:01:54Z")
1540 );
1541 assert_eq!(
1542 utc("2026-09-26T20:01:54+03:00").as_deref(),
1543 Some("2026-09-26T17:01:54Z")
1544 );
1545 assert_eq!(
1546 utc("2026-01-01T01:30:00+0300").as_deref(),
1547 Some("2025-12-31T22:30:00Z")
1548 );
1549 assert_eq!(
1550 utc("2026-03-01T23:00:00-0500").as_deref(),
1551 Some("2026-03-02T04:00:00Z")
1552 );
1553 assert_eq!(
1554 utc("2024-02-29T12:00:00.123Z").as_deref(),
1555 Some("2024-02-29T12:00:00Z")
1556 );
1557 assert_eq!(utc("yesterday"), None);
1558 }
1559
1560 #[test]
1561 fn an_iphone_mov_stays_mov_in_quality_mode_only() {
1562 let info = iphone_info();
1563 let out = |opts: &ShrinkOpts| {
1564 let plan = MediaEngine::new().plan(&info, opts).unwrap();
1565 plan.output.to_string_lossy().into_owned()
1566 };
1567 assert!(out(&ShrinkOpts::default()).ends_with(".shrink.mov"));
1568 assert!(out(&opts_target(8_000_000)).ends_with(".shrink.mp4"));
1570 let av1 = ShrinkOpts {
1572 video_codec: VideoCodec::Av1,
1573 ..ShrinkOpts::default()
1574 };
1575 assert!(out(&av1).ends_with(".shrink.mp4"));
1576 let mp4 = video_info(60.0, 50_000_000, 1920, 1080, true);
1578 let p = MediaEngine::new()
1579 .plan(&mp4, &ShrinkOpts::default())
1580 .unwrap();
1581 assert!(p.output.to_string_lossy().ends_with(".shrink.mp4"));
1582 }
1583
1584 #[test]
1585 fn a_video_audio_track_is_never_upsampled() {
1586 let mut info = video_info(60.0, 50_000_000, 1920, 1080, true);
1587 info.audio_bitrate_bps = Some(64_000);
1588 let bps_of = |info: &MediaInfo, opts: &ShrinkOpts| {
1589 let plan = MediaEngine::new().plan(info, opts).unwrap();
1590 plan.spec.audio.unwrap().bitrate_bps
1591 };
1592 assert_eq!(bps_of(&info, &ShrinkOpts::default()), 64_000);
1594 assert!(bps_of(&info, &opts_target(20_000_000)) <= 64_000);
1596 let explicit = ShrinkOpts {
1598 audio: AudioChoice::Bitrate(128_000),
1599 ..ShrinkOpts::default()
1600 };
1601 assert_eq!(bps_of(&info, &explicit), 128_000);
1602 info.audio_bitrate_bps = None;
1604 assert_eq!(
1605 bps_of(&info, &ShrinkOpts::default()),
1606 budget::DEFAULT_AUDIO_BPS
1607 );
1608 }
1609
1610 #[test]
1611 fn h265_adds_hvc1_tag() {
1612 let info = video_info(60.0, 100_000_000, 1280, 720, false);
1613 let opts = ShrinkOpts {
1614 video_codec: VideoCodec::H265,
1615 ..opts_target(8_000_000)
1616 };
1617 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1618 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan), false);
1619 let joined: Vec<String> = args
1620 .iter()
1621 .map(|a| a.to_string_lossy().into_owned())
1622 .collect();
1623 assert!(joined.contains(&"hvc1".to_string()));
1624 assert!(joined.contains(&"libx265".to_string()));
1625 }
1626}