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 fs::copy(&plan.input, &output)?;
272 on_progress(PassKind::Single, 1.0);
273 Ok(Outcome {
274 final_bytes: fs::metadata(&output)?.len(),
275 output,
276 vmaf: None,
277 already_compact: true,
278 })
279 }
280
281 fn run_passthrough(
290 &self,
291 tools: &deepshrink_ffmpeg::Tools,
292 plan: &EncodePlan,
293 on_progress: &mut dyn FnMut(PassKind, f64),
294 ) -> Result<Outcome, EngineError> {
295 let args = build_pass_args(plan, PassKind::Single, "", "copy", false);
297 let remuxed = tools.run_pass(&args, plan.source_duration_sec, &mut |f| {
298 on_progress(PassKind::Single, f)
299 });
300 if remuxed.is_err() {
301 fs::copy(&plan.input, &plan.output)?;
302 on_progress(PassKind::Single, 1.0);
303 }
304 let size = fs::metadata(&plan.output)?.len();
305 Ok(Outcome {
306 output: plan.output.clone(),
307 final_bytes: size,
308 vmaf: None,
309 already_compact: true,
310 })
311 }
312
313 fn run_crf_search(
320 &self,
321 tools: &deepshrink_ffmpeg::Tools,
322 plan: &EncodePlan,
323 encoder: &str,
324 zscale: bool,
325 target_vmaf: f64,
326 on_progress: &mut dyn FnMut(PassKind, f64),
327 ) -> Result<Outcome, EngineError> {
328 let (ref_w, ref_h) = match (plan.source_width, plan.source_height) {
329 (Some(w), Some(h)) => (w, h),
330 _ => return self.run_plain(tools, plan, encoder, zscale, on_progress),
331 };
332 let ref_fps = plan.source_fps.unwrap_or(0.0);
333 let total = plan.source_duration_sec;
334 let (lo, hi) = plan.spec.video.codec.crf_search_bounds();
335 let n_threads = thread_count();
336
337 let mut err: Option<EngineError> = None;
338 let mut last_crf: Option<u8> = None;
339
340 let (chosen_crf, chosen_vmaf) = budget::search_crf(target_vmaf, lo, hi, |crf| {
341 if err.is_some() {
342 return f64::NEG_INFINITY;
343 }
344 match encode_at_crf(tools, plan, encoder, zscale, crf, total, on_progress).and_then(
345 |()| {
346 last_crf = Some(crf);
347 deepshrink_ffmpeg::measure_vmaf(
348 &tools.ffmpeg,
349 &plan.output,
350 &plan.input,
351 ref_w,
352 ref_h,
353 ref_fps,
354 n_threads,
355 )
356 .map_err(EngineError::from)
357 },
358 ) {
359 Ok(v) => v,
360 Err(e) => {
361 err = Some(e);
362 f64::NEG_INFINITY
363 }
364 }
365 });
366 if let Some(e) = err {
367 return Err(e);
368 }
369
370 if last_crf != Some(chosen_crf) {
372 encode_at_crf(tools, plan, encoder, zscale, chosen_crf, total, on_progress)?;
373 }
374 let size = fs::metadata(&plan.output)?.len();
375 Ok(Outcome {
376 output: plan.output.clone(),
377 final_bytes: size,
378 vmaf: Some(chosen_vmaf),
379 already_compact: false,
380 })
381 }
382
383 fn measure_output(
386 &self,
387 tools: &deepshrink_ffmpeg::Tools,
388 plan: &EncodePlan,
389 output: &Path,
390 ) -> Option<f64> {
391 let (w, h) = (plan.source_width?, plan.source_height?);
392 let fps = plan.source_fps.unwrap_or(0.0);
393 deepshrink_ffmpeg::measure_vmaf(
394 &tools.ffmpeg,
395 output,
396 &plan.input,
397 w,
398 h,
399 fps,
400 thread_count(),
401 )
402 .ok()
403 }
404}
405
406impl Engine for MediaEngine {
407 fn supports(&self, input: &Path) -> bool {
408 matches!(detect_kind(input), MediaKind::Video | MediaKind::Audio)
409 }
410
411 fn probe(&self, input: &Path) -> Result<MediaInfo, EngineError> {
412 let tools = deepshrink_ffmpeg::locate()?;
413 let p = deepshrink_ffmpeg::probe(&tools.ffprobe, input)?;
414
415 let video = p.video_stream();
416 let audio = p.audio_stream();
417 let size_bytes = p
419 .size_bytes()
420 .or_else(|| fs::metadata(input).ok().map(|m| m.len()))
421 .unwrap_or(0);
422
423 Ok(MediaInfo {
424 path: input.to_path_buf(),
425 kind: detect_kind(input),
426 duration_sec: p.duration_sec().unwrap_or(0.0),
427 size_bytes,
428 width: video.and_then(|v| v.display_size().0),
430 height: video.and_then(|v| v.display_size().1),
431 fps: p.fps(),
432 video_codec: video.and_then(|v| v.codec_name.clone()),
433 audio_codec: audio.and_then(|a| a.codec_name.clone()),
434 audio_channels: audio.and_then(|a| a.channels),
435 audio_bitrate_bps: p.audio_bitrate_bps(),
436 capture: capture_meta(&p),
437 hdr: video
438 .and_then(|v| v.color_transfer.as_deref())
439 .and_then(Hdr::from_transfer),
440 })
441 }
442
443 fn plan(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
444 let hw = opts.hardware && opts.target_vmaf.is_none() && hardware_encoding_available();
446 super::plan::plan(info, opts, hw)
447 }
448
449 fn run(&self, plan: &EncodePlan) -> Result<Outcome, EngineError> {
450 self.run_with_progress(plan, &mut |_, _| {})
451 }
452}
453
454pub fn hardware_encoding_available() -> bool {
458 static AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
459 *AVAILABLE.get_or_init(|| {
460 cfg!(all(target_os = "macos", target_arch = "aarch64"))
462 && deepshrink_ffmpeg::locate().is_ok_and(|t| {
463 deepshrink_ffmpeg::has_encoder(&t.ffmpeg, "h264_videotoolbox")
464 && deepshrink_ffmpeg::has_encoder(&t.ffmpeg, "hevc_videotoolbox")
465 })
466 })
467}
468
469fn copy_mtime(input: &Path, output: &Path) {
471 let Ok(mtime) = fs::metadata(input).and_then(|m| m.modified()) else {
472 return;
473 };
474 if let Ok(f) = fs::File::options().write(true).open(output) {
475 let _ = f.set_modified(mtime);
476 }
477}
478
479fn capture_meta(p: &deepshrink_ffmpeg::Ffprobe) -> CaptureMeta {
481 let tag = |keys: &[&str]| {
482 keys.iter()
483 .find_map(|k| p.format_tag(k))
484 .map(str::to_string)
485 };
486 let created_local = tag(&["com.apple.quicktime.creationdate"]);
487 let created_utc = created_local
488 .as_deref()
489 .and_then(to_utc)
490 .or_else(|| tag(&["creation_time"]));
491 CaptureMeta {
492 created_utc,
493 created_local,
494 location: tag(&["com.apple.quicktime.location.ISO6709", "location"]),
495 make: tag(&["com.apple.quicktime.make", "make"]),
496 model: tag(&["com.apple.quicktime.model", "model"]),
497 }
498}
499
500fn metadata_args(plan: &EncodePlan) -> (Vec<OsString>, Option<&'static str>) {
507 if !plan.spec.keep_metadata {
508 return (vec!["-map_metadata".into(), "-1".into()], None);
509 }
510 let mut a: Vec<OsString> = vec!["-map_metadata".into(), "0".into()];
511 for (k, v) in &plan.spec.tags {
512 a.push("-metadata".into());
513 a.push(format!("{k}={v}").into());
514 }
515 (a, None)
516}
517
518fn movflags(faststart: bool, meta: Option<&'static str>) -> Option<String> {
520 let mut v = String::new();
521 if faststart {
522 v.push_str("+faststart");
523 }
524 if let Some(m) = meta {
525 v.push_str(m);
526 }
527 (!v.is_empty()).then_some(v)
528}
529
530fn ceiling_fit(
534 tools: &deepshrink_ffmpeg::Tools,
535 plan: &EncodePlan,
536 encoder: &str,
537 zscale: bool,
538) -> Option<(EncodePlan, u64)> {
539 let target = plan.target_bytes?;
540 let ceiling = ceiling_plan(plan)?;
541 let predicted = predict_crf_bytes(tools, &ceiling, encoder, zscale)?;
542 ((predicted as f64) < target as f64 * CEILING_MARGIN).then_some((ceiling, predicted))
543}
544
545fn wants_zscale(tools: &deepshrink_ffmpeg::Tools, plan: &EncodePlan) -> bool {
547 plan.spec.video.to_sdr == Some(Hdr::Pq)
548 && deepshrink_ffmpeg::has_filter(&tools.ffmpeg, "zscale")
549}
550
551const SAMPLE_SECS: f64 = 3.0;
553const MIN_SAMPLE_SECS: f64 = 1.5;
557
558fn sample_secs(plan: &EncodePlan) -> f64 {
561 const REFERENCE: f64 = 1920.0 * 1080.0 * 30.0;
562 let (w, h) = match (plan.source_width, plan.source_height) {
563 (Some(w), Some(h)) if w > 0 && h > 0 => (w as f64, h as f64),
564 _ => return SAMPLE_SECS,
565 };
566 let fps = plan
567 .source_fps
568 .filter(|f| f.is_finite() && *f > 0.0)
569 .unwrap_or(30.0);
570 (SAMPLE_SECS * REFERENCE / (w * h * fps)).clamp(MIN_SAMPLE_SECS, SAMPLE_SECS)
571}
572const SAMPLE_BIAS: f64 = 0.92;
575
576fn predict_crf_bytes(
580 tools: &deepshrink_ffmpeg::Tools,
581 plan: &EncodePlan,
582 encoder: &str,
583 zscale: bool,
584) -> Option<u64> {
585 let duration = plan.source_duration_sec;
586 if !duration.is_finite() || duration <= 0.0 {
587 return None;
588 }
589 let win = sample_secs(plan);
592 let (windows, bias): (Vec<(f64, f64)>, f64) = if duration >= win * 4.0 {
593 (
594 [0.2, 0.5, 0.8]
595 .iter()
596 .map(|at| ((duration * at - win / 2.0).max(0.0), win))
597 .collect(),
598 SAMPLE_BIAS,
599 )
600 } else {
601 (vec![(0.0, duration)], 1.0)
602 };
603 let mut sample = plan.clone();
604 sample.spec.audio = None;
605 sample.spec.faststart = false;
606 let mut video_bytes = 0u64;
607 let mut sampled = 0.0;
608 for (i, &(start, len)) in windows.iter().enumerate() {
609 sample.output =
610 std::env::temp_dir().join(format!("deepshrink-sample-{}-{i}.mp4", std::process::id()));
611 let mut args = build_pass_args(&sample, PassKind::Single, "", encoder, zscale);
612 let at_input = args.iter().position(|a| a == "-i")?;
613 args.splice(
614 at_input..at_input,
615 [
616 OsString::from("-ss"),
617 OsString::from(format!("{start:.2}")),
618 OsString::from("-t"),
619 OsString::from(format!("{len:.2}")),
620 ],
621 );
622 let ran = tools.run_pass(&args, len, &mut |_| {});
623 let bytes = fs::metadata(&sample.output).map(|m| m.len()).ok();
624 let _ = fs::remove_file(&sample.output);
625 video_bytes += bytes.filter(|_| ran.is_ok())?;
626 sampled += len;
627 }
628 let video_bps = video_bytes as f64 * 8.0 / sampled * bias;
629 let audio_bps = plan.spec.audio.as_ref().map(|a| a.bitrate_bps).unwrap_or(0) as f64;
630 Some(((video_bps + audio_bps) * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD)) as u64)
631}
632
633fn video_filters(video: &VideoSpec, zscale: bool) -> Option<String> {
642 const COLORSPACE: &str = "colorspace=all=bt709:iall=bt2020:itrc=bt2020-10:format=yuv420p";
643 let mut chain = Vec::new();
644 if let Some(h) = video.height {
646 chain.push(if video.portrait {
647 format!("scale={h}:-2")
648 } else {
649 format!("scale=-2:{h}")
650 });
651 }
652 match video.to_sdr {
653 Some(Hdr::Pq) if zscale => chain.push(
654 "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,\
655 tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
656 .to_string(),
657 ),
658 Some(_) => chain.push(COLORSPACE.to_string()),
659 None => {}
660 }
661 (!chain.is_empty()).then(|| chain.join(","))
662}
663
664fn passlog_base(plan: &EncodePlan) -> String {
666 let stem = plan
667 .input
668 .file_stem()
669 .map(|s| s.to_string_lossy().into_owned())
670 .unwrap_or_else(|| "ds".to_string());
671 let dir = std::env::temp_dir();
672 dir.join(format!("deepshrink-{}-{}", std::process::id(), stem))
673 .to_string_lossy()
674 .into_owned()
675}
676
677fn cleanup_passlog(base: &str) {
679 for suffix in ["-0.log", "-0.log.mbtree"] {
680 let _ = fs::remove_file(format!("{base}{suffix}"));
681 }
682}
683
684fn encode_at_crf(
686 tools: &deepshrink_ffmpeg::Tools,
687 plan: &EncodePlan,
688 encoder: &str,
689 zscale: bool,
690 crf: u8,
691 total: f64,
692 on_progress: &mut dyn FnMut(PassKind, f64),
693) -> Result<(), EngineError> {
694 let mut trial = plan.clone();
695 trial.spec.video.crf = Some(crf);
696 trial.spec.video.bitrate_bps = None;
697 trial.spec.two_pass = false;
698 let args = build_pass_args(&trial, PassKind::Single, "", encoder, zscale);
699 tools.run_pass(&args, total, &mut |f| on_progress(PassKind::Single, f))?;
700 Ok(())
701}
702
703fn thread_count() -> usize {
705 std::thread::available_parallelism()
706 .map(|n| n.get())
707 .unwrap_or(1)
708}
709
710fn null_sink() -> &'static str {
712 if cfg!(windows) {
713 "NUL"
714 } else {
715 "/dev/null"
716 }
717}
718
719fn resolve_encoder(
727 tools: &deepshrink_ffmpeg::Tools,
728 plan: &EncodePlan,
729) -> Result<&'static str, EngineError> {
730 let codec = plan.spec.video.codec;
731 if plan.spec.video.hardware && !plan.spec.passthrough && !plan.spec.audio_only {
733 if let Some(hw) = codec.hardware_encoder() {
734 return Ok(hw);
735 }
736 }
737 let primary = codec.encoder();
738 let Some(fallback) = codec.fallback_encoder() else {
739 return Ok(primary);
740 };
741 if plan.spec.passthrough || plan.spec.audio_only {
743 return Ok(primary);
744 }
745 if deepshrink_ffmpeg::has_encoder(&tools.ffmpeg, primary) {
746 return Ok(primary);
747 }
748 if deepshrink_ffmpeg::has_encoder(&tools.ffmpeg, fallback) {
749 return Ok(fallback);
750 }
751 Err(EngineError::Unsupported(format!(
752 "this ffmpeg build has no {} encoder (looked for {primary} and {fallback})",
753 codec.label()
754 )))
755}
756
757fn build_pass_args(
762 plan: &EncodePlan,
763 pass: PassKind,
764 passlog: &str,
765 encoder: &str,
766 zscale: bool,
767) -> Vec<OsString> {
768 let s = &plan.spec;
769 let mut a: Vec<OsString> = Vec::new();
770 macro_rules! push {
773 ($arg:expr) => {
774 a.push(OsString::from($arg))
775 };
776 }
777
778 push!("-hide_banner");
779 push!("-y");
780 push!("-loglevel");
781 push!("error");
782 push!("-progress");
783 push!("pipe:1");
784 push!("-nostats");
785 push!("-i");
786 a.push(plan.input.clone().into_os_string());
787
788 let (meta, meta_flag) = metadata_args(plan);
789
790 if s.passthrough {
792 push!("-c");
793 push!("copy");
794 a.extend(meta.iter().cloned());
795 if let Some(flags) = movflags(s.faststart, meta_flag) {
796 push!("-movflags");
797 push!(flags);
798 }
799 a.push(plan.output.clone().into_os_string());
800 return a;
801 }
802
803 if s.audio_only {
805 push!("-vn");
806 if let Some(au) = &s.audio {
807 push!("-c:a");
808 push!(au.codec.encoder());
809 if au.mono {
810 push!("-ac");
811 push!("1");
812 }
813 if let Some(sr) = au.sample_rate {
814 push!("-ar");
815 push!(sr.to_string());
816 }
817 push!("-b:a");
818 push!(au.bitrate_bps.to_string());
819 if matches!(au.codec, AudioCodec::Opus) {
822 push!("-vbr");
823 push!(if au.vbr { "on" } else { "constrained" });
824 }
825 }
826 a.extend(meta.iter().cloned());
827 if let Some(flags) = movflags(false, meta_flag) {
828 push!("-movflags");
829 push!(flags);
830 }
831 a.push(plan.output.clone().into_os_string());
832 return a;
833 }
834
835 push!("-c:v");
837 push!(encoder);
838 if let Some(vf) = video_filters(&s.video, zscale) {
839 push!("-vf");
840 push!(vf);
841 }
842 if let Some(f) = s.video.fps {
843 push!("-r");
844 push!(f.to_string());
845 }
846 let videotoolbox = encoder.ends_with("_videotoolbox");
850 if !videotoolbox {
851 let (speed_flag, speed_value) = s.video.preset.speed_flags(encoder);
852 push!(speed_flag);
853 push!(speed_value);
854 }
855 if let Some(tag) = s.video.codec.mp4_tag() {
856 push!("-tag:v");
857 push!(tag);
858 }
859 if s.video.to_sdr.is_some() {
862 for (flag, value) in [
863 ("-pix_fmt", "yuv420p"),
864 ("-color_primaries", "bt709"),
865 ("-color_trc", "bt709"),
866 ("-colorspace", "bt709"),
867 ] {
868 push!(flag);
869 push!(value);
870 }
871 }
872
873 match (s.video.bitrate_bps, s.video.crf) {
875 (Some(bps), _) => {
876 push!("-b:v");
877 push!(bps.to_string());
878 if s.two_pass {
879 push!("-pass");
880 push!(match pass {
881 PassKind::First => "1",
882 _ => "2",
883 });
884 push!("-passlogfile");
885 push!(passlog);
886 }
887 }
888 (_, Some(crf)) => {
889 push!(if videotoolbox { "-q:v" } else { "-crf" });
891 push!(crf.to_string());
892 }
893 _ => {}
894 }
895
896 match pass {
898 PassKind::First => {
899 push!("-an");
901 push!("-f");
902 push!("null");
903 push!(null_sink());
904 }
905 PassKind::Second | PassKind::Single => {
906 match &s.audio {
907 Some(au) => {
908 push!("-c:a");
909 push!(au.codec.encoder());
910 if au.mono {
914 push!("-ac");
915 push!("1");
916 }
917 push!("-b:a");
918 push!(au.bitrate_bps.to_string());
919 }
920 None => push!("-an"),
921 }
922 a.extend(meta.iter().cloned());
923 if let Some(flags) = movflags(s.faststart, meta_flag) {
924 push!("-movflags");
925 push!(flags);
926 }
927 a.push(plan.output.clone().into_os_string());
928 }
929 }
930 a
931}
932
933#[cfg(test)]
934mod tests {
935 use super::*;
936 use crate::engine::SizeGoal;
937 use crate::options::{AudioChoice, AudioCodec, QualityPreset, ResolutionOpt, VideoCodec};
938 use crate::size::preset;
939 use std::path::PathBuf;
940
941 fn enc(plan: &EncodePlan) -> &'static str {
944 plan.spec.video.codec.encoder()
945 }
946
947 fn video_info(duration: f64, size: u64, w: u32, h: u32, audio: bool) -> MediaInfo {
948 MediaInfo {
949 path: PathBuf::from("/tmp/clip.mp4"),
950 kind: MediaKind::Video,
951 duration_sec: duration,
952 size_bytes: size,
953 width: Some(w),
954 height: Some(h),
955 fps: Some(30.0),
956 video_codec: Some("h264".into()),
957 audio_codec: if audio { Some("aac".into()) } else { None },
958 audio_channels: if audio { Some(2) } else { None },
959 audio_bitrate_bps: None,
960 capture: CaptureMeta::default(),
961 hdr: None,
962 }
963 }
964
965 fn opts_target(bytes: u64) -> ShrinkOpts {
966 ShrinkOpts {
967 goal: SizeGoal::Target(bytes),
968 ..Default::default()
969 }
970 }
971
972 #[test]
973 fn supports_video_and_audio() {
974 let e = MediaEngine::new();
975 assert!(e.supports(&PathBuf::from("clip.mp4")));
976 assert!(e.supports(&PathBuf::from("lecture.wav")));
977 assert!(!e.supports(&PathBuf::from("photo.jpg")));
978 }
979
980 #[test]
981 fn plan_target_builds_two_pass_with_budget() {
982 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
983 let plan = MediaEngine::new()
984 .plan(&info, &opts_target(8_000_000))
985 .unwrap();
986
987 assert!(plan.spec.two_pass);
988 assert_eq!(plan.target_bytes, Some(8_000_000));
989 assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
990 let vbps = plan.spec.video.bitrate_bps.unwrap();
991 assert!(vbps >= budget::ABSOLUTE_MIN_VIDEO_BPS);
992 assert!(plan.spec.video.height.is_some());
994 assert!(plan.spec.audio.is_some());
995 assert!(plan.expected_bytes.unwrap() <= 8_000_000 + 8_000_000 / 20);
997 }
998
999 #[test]
1000 fn plan_video_mono_downmixes_the_audio_track() {
1001 let info = video_info(60.0, 100_000_000, 1280, 720, true);
1002 let opts = ShrinkOpts {
1003 mono: true,
1004 ..opts_target(8_000_000)
1005 };
1006 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1007 let audio = plan.spec.audio.as_ref().expect("kept audio track");
1008 assert!(audio.mono, "opts.mono downmixes the video's audio track");
1009 let stereo = MediaEngine::new()
1011 .plan(&info, &opts_target(8_000_000))
1012 .unwrap();
1013 assert!(!stereo.spec.audio.as_ref().unwrap().mono);
1014 }
1015
1016 #[test]
1017 fn video_mono_reaches_ffmpeg_as_ac_1() {
1018 let info = video_info(60.0, 100_000_000, 1280, 720, true);
1021 let plan = MediaEngine::new()
1022 .plan(
1023 &info,
1024 &ShrinkOpts {
1025 mono: true,
1026 ..opts_target(8_000_000)
1027 },
1028 )
1029 .unwrap();
1030 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan), false);
1031 let joined: Vec<String> = args
1032 .iter()
1033 .map(|a| a.to_string_lossy().into_owned())
1034 .collect();
1035 let ac = joined.iter().position(|a| a == "-ac").expect("-ac emitted");
1036 assert_eq!(joined[ac + 1], "1");
1037
1038 let stereo = MediaEngine::new()
1040 .plan(&info, &opts_target(8_000_000))
1041 .unwrap();
1042 let stereo_args: Vec<String> = build_pass_args(
1043 &stereo,
1044 PassKind::Second,
1045 "/tmp/passlog",
1046 enc(&stereo),
1047 false,
1048 )
1049 .iter()
1050 .map(|a| a.to_string_lossy().into_owned())
1051 .collect();
1052 assert!(!stereo_args.iter().any(|a| a == "-ac"));
1053 }
1054
1055 #[test]
1056 fn plan_preset_discord_sets_target() {
1057 let info = video_info(30.0, 50_000_000, 1280, 720, true);
1058 let opts = ShrinkOpts {
1059 goal: SizeGoal::Preset(preset("discord").unwrap()),
1060 ..Default::default()
1061 };
1062 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1063 assert_eq!(plan.target_bytes, Some(8_000_000));
1064 }
1065
1066 #[test]
1067 fn plan_reduce_targets_complement_of_original() {
1068 let info = video_info(60.0, 100_000_000, 1920, 1080, true);
1069 let opts = ShrinkOpts {
1070 goal: SizeGoal::Reduce(0.70),
1071 ..Default::default()
1072 };
1073 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1074 assert_eq!(plan.target_bytes, Some(30_000_000));
1075 }
1076
1077 #[test]
1078 fn plan_passthrough_when_source_already_fits() {
1079 let info = video_info(10.0, 200_000, 1280, 720, true);
1081 let plan = MediaEngine::new()
1082 .plan(&info, &opts_target(1_000_000))
1083 .unwrap();
1084 assert!(plan.spec.passthrough);
1085 assert!(!plan.spec.two_pass);
1086 assert_eq!(plan.expected_bytes, Some(200_000));
1087 let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog", enc(&plan), false);
1088 let joined: Vec<String> = args
1089 .iter()
1090 .map(|a| a.to_string_lossy().into_owned())
1091 .collect();
1092 assert!(joined.contains(&"copy".to_string()));
1093 assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
1096 }
1097
1098 #[test]
1099 fn plan_passthrough_keeps_the_source_container() {
1100 let info = MediaInfo {
1103 path: PathBuf::from("/tmp/voice.3gp"),
1104 ..video_info(10.0, 200_000, 320, 240, true)
1105 };
1106 let plan = MediaEngine::new()
1107 .plan(&info, &opts_target(1_000_000))
1108 .unwrap();
1109 assert!(plan.spec.passthrough);
1110 assert_eq!(plan.output, PathBuf::from("/tmp/voice.shrink.3gp"));
1111 }
1112
1113 #[test]
1114 fn plan_infeasible_when_target_too_small() {
1115 let info = video_info(600.0, 500_000_000, 1920, 1080, true);
1116 let err = MediaEngine::new().plan(&info, &opts_target(50_000));
1117 assert!(matches!(err, Err(EngineError::Infeasible)));
1118 }
1119
1120 #[test]
1121 fn plan_quality_mode_uses_crf_single_pass() {
1122 let info = video_info(60.0, 100_000_000, 1920, 1080, true);
1123 let opts = ShrinkOpts {
1124 goal: SizeGoal::Quality,
1125 quality: QualityPreset::Balanced,
1126 ..Default::default()
1127 };
1128 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1129 assert!(!plan.spec.two_pass);
1130 assert_eq!(plan.spec.video.crf, Some(23));
1131 assert!(plan.spec.video.bitrate_bps.is_none());
1132 assert!(plan.expected_bytes.is_none());
1133 }
1134
1135 #[test]
1136 fn plan_drops_audio_when_requested() {
1137 let info = video_info(30.0, 50_000_000, 1280, 720, true);
1138 let opts = ShrinkOpts {
1139 audio: AudioChoice::Drop,
1140 ..opts_target(8_000_000)
1141 };
1142 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1143 assert!(plan.spec.audio.is_none());
1144 }
1145
1146 fn audio_info(duration: f64, size: u64, channels: u32) -> MediaInfo {
1147 MediaInfo {
1148 path: PathBuf::from("/tmp/lecture.wav"),
1149 kind: MediaKind::Audio,
1150 duration_sec: duration,
1151 size_bytes: size,
1152 width: None,
1153 height: None,
1154 fps: None,
1155 video_codec: None,
1156 audio_codec: Some("pcm_s16le".into()),
1157 audio_channels: Some(channels),
1158 audio_bitrate_bps: None,
1159 capture: CaptureMeta::default(),
1160 hdr: None,
1161 }
1162 }
1163
1164 #[test]
1165 fn quality_audio_bitrate_follows_tier_codec_and_channels() {
1166 use QualityPreset::*;
1167 assert_eq!(quality_audio_bps(Balanced, AudioCodec::Aac, false), 128_000);
1168 assert_eq!(quality_audio_bps(Balanced, AudioCodec::Aac, true), 64_000);
1169 assert_eq!(quality_audio_bps(Fast, AudioCodec::Opus, true), 32_000);
1170 assert_eq!(quality_audio_bps(Max, AudioCodec::Mp3, false), 256_000);
1171 for c in [AudioCodec::Aac, AudioCodec::Opus, AudioCodec::Mp3] {
1173 let t: Vec<_> = [Fast, Balanced, Max]
1174 .map(|q| quality_audio_bps(q, c, false))
1175 .into();
1176 assert!(t[0] < t[1] && t[1] < t[2], "{c:?}: {t:?}");
1177 }
1178 }
1179
1180 #[test]
1181 fn a_compact_audiobook_is_kept_in_quality_mode() {
1182 let mut info = audio_info(3600.0, 64_000 / 8 * 3600, 1);
1184 info.path = PathBuf::from("/tmp/book.mp3");
1185 let plan = MediaEngine::new()
1186 .plan(&info, &ShrinkOpts::default())
1187 .unwrap();
1188 assert!(plan.spec.passthrough, "{}", plan.summary);
1189 assert!(plan.output.to_string_lossy().ends_with(".mp3"));
1190 assert!(plan.summary.contains("already compact"));
1191
1192 let smaller = ShrinkOpts {
1194 audio_codec: AudioCodec::Opus,
1195 quality: QualityPreset::Fast,
1196 ..ShrinkOpts::default()
1197 };
1198 let plan = MediaEngine::new().plan(&info, &smaller).unwrap();
1199 assert!(!plan.spec.passthrough);
1200 assert_eq!(plan.spec.audio.as_ref().unwrap().bitrate_bps, 32_000);
1201 assert!(
1202 plan.guard_larger,
1203 "quality mode keeps the post-encode check"
1204 );
1205
1206 let allow = ShrinkOpts {
1208 allow_larger: true,
1209 ..ShrinkOpts::default()
1210 };
1211 let plan = MediaEngine::new().plan(&info, &allow).unwrap();
1212 assert!(!plan.spec.passthrough && !plan.guard_larger);
1213 }
1214
1215 #[test]
1216 fn the_guard_is_for_quality_mode_only() {
1217 let info = video_info(60.0, 50_000_000, 1920, 1080, true);
1218 let quality = MediaEngine::new()
1219 .plan(&info, &ShrinkOpts::default())
1220 .unwrap();
1221 assert!(quality.guard_larger);
1222 let target = MediaEngine::new()
1223 .plan(&info, &opts_target(10_000_000))
1224 .unwrap();
1225 assert!(!target.guard_larger, "a size target is its own guarantee");
1226 }
1227
1228 #[test]
1229 fn plan_audio_single_pass_with_fitted_bitrate() {
1230 let info = audio_info(3480.0, 600_000_000, 2);
1232 let plan = MediaEngine::new()
1233 .plan(&info, &opts_target(10_000_000))
1234 .unwrap();
1235 assert!(plan.spec.audio_only);
1236 assert!(!plan.spec.two_pass);
1237 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.m4a"));
1238 let au = plan.spec.audio.as_ref().unwrap();
1239 assert!(budget::AUDIO_STEPS.contains(&au.bitrate_bps));
1241 assert!(plan.expected_bytes.unwrap() <= 10_000_000 + 10_000_000 / 20);
1242 }
1243
1244 #[test]
1245 fn plan_audio_mono_source_marked_speech() {
1246 let info = audio_info(600.0, 100_000_000, 1);
1247 let plan = MediaEngine::new()
1248 .plan(&info, &opts_target(5_000_000))
1249 .unwrap();
1250 assert!(plan.spec.audio.as_ref().unwrap().mono);
1251 }
1252
1253 #[test]
1254 fn plan_audio_opus_extension_and_vbr_args() {
1255 let info = audio_info(600.0, 100_000_000, 2);
1256 let opts = ShrinkOpts {
1257 audio_codec: AudioCodec::Opus,
1258 mono: true,
1259 ..opts_target(3_000_000)
1260 };
1261 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1262 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.opus"));
1263 let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog", enc(&plan), false);
1264 let j: Vec<String> = args
1265 .iter()
1266 .map(|a| a.to_string_lossy().into_owned())
1267 .collect();
1268 assert!(j.contains(&"-vn".to_string()));
1269 assert!(j.contains(&"libopus".to_string()));
1270 assert!(j.contains(&"-ac".to_string())); assert!(j.contains(&"-vbr".to_string()));
1272 }
1273
1274 #[test]
1275 fn plan_audio_infeasible_when_target_tiny() {
1276 let info = audio_info(3600.0, 500_000_000, 2);
1277 assert!(matches!(
1278 MediaEngine::new().plan(&info, &opts_target(1_000)),
1279 Err(EngineError::Infeasible)
1280 ));
1281 }
1282
1283 #[test]
1284 fn plan_audio_passthrough_when_source_fits() {
1285 let info = audio_info(600.0, 2_000_000, 2);
1286 let plan = MediaEngine::new()
1287 .plan(&info, &opts_target(10_000_000))
1288 .unwrap();
1289 assert!(plan.spec.passthrough);
1290 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.wav"));
1292 }
1293
1294 #[test]
1295 fn pass1_args_have_no_audio_and_null_sink() {
1296 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1297 let plan = MediaEngine::new()
1298 .plan(&info, &opts_target(8_000_000))
1299 .unwrap();
1300 let args = build_pass_args(&plan, PassKind::First, "/tmp/passlog", enc(&plan), false);
1301 let joined: Vec<String> = args
1302 .iter()
1303 .map(|a| a.to_string_lossy().into_owned())
1304 .collect();
1305 assert!(joined.contains(&"-an".to_string()));
1306 assert!(joined.contains(&"null".to_string()));
1307 assert!(joined.iter().any(|a| a == "1")); assert!(!joined.iter().any(|a| a.contains("shrink.mp4")));
1309 }
1310
1311 #[test]
1312 fn pass2_args_write_output_with_audio() {
1313 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1314 let plan = MediaEngine::new()
1315 .plan(&info, &opts_target(8_000_000))
1316 .unwrap();
1317 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan), false);
1318 let joined: Vec<String> = args
1319 .iter()
1320 .map(|a| a.to_string_lossy().into_owned())
1321 .collect();
1322 assert!(joined.iter().any(|a| a.contains("clip.shrink.mp4")));
1323 assert!(joined.contains(&"-c:a".to_string()));
1324 assert!(joined.iter().any(|a| a.contains("+faststart")));
1325 assert!(joined.iter().any(|a| a == "2")); }
1327
1328 fn joined(plan: &EncodePlan, pass: PassKind) -> Vec<String> {
1329 joined_with(plan, pass, false)
1330 }
1331
1332 fn joined_with(plan: &EncodePlan, pass: PassKind, zscale: bool) -> Vec<String> {
1333 build_pass_args(plan, pass, "/tmp/passlog", enc(plan), zscale)
1334 .iter()
1335 .map(|a| a.to_string_lossy().into_owned())
1336 .collect()
1337 }
1338
1339 #[test]
1340 fn hdr_transfer_is_recognised() {
1341 assert_eq!(Hdr::from_transfer("arib-std-b67"), Some(Hdr::Hlg));
1342 assert_eq!(Hdr::from_transfer("smpte2084"), Some(Hdr::Pq));
1343 assert_eq!(Hdr::from_transfer("bt709"), None);
1344 }
1345
1346 #[test]
1347 fn hdr_is_tone_mapped_to_sdr_for_size_targets_only() {
1348 let mut info = iphone_info();
1349 info.hdr = Some(Hdr::Hlg);
1350 let engine = MediaEngine::new();
1351
1352 let target = engine.plan(&info, &opts_target(10_000_000)).unwrap();
1354 assert_eq!(target.spec.video.to_sdr, Some(Hdr::Hlg));
1355 assert!(target.summary.contains("HDR → SDR"));
1356 let vf_of =
1357 |args: &[String]| args[args.iter().position(|a| a == "-vf").unwrap() + 1].clone();
1358 let hlg = joined_with(&target, PassKind::Second, true);
1360 let vf = vf_of(&hlg);
1361 assert!(
1362 vf.contains("colorspace=all=bt709") && !vf.contains("zscale"),
1363 "{vf}"
1364 );
1365 assert!(
1368 vf.find("scale=").unwrap() < vf.find("colorspace").unwrap(),
1369 "{vf}"
1370 );
1371 for pair in [
1372 ["-pix_fmt", "yuv420p"],
1373 ["-color_trc", "bt709"],
1374 ["-colorspace", "bt709"],
1375 ] {
1376 assert!(hlg.windows(2).any(|w| w == pair), "{pair:?}");
1377 }
1378 let mut pq = target.clone();
1380 pq.spec.video.to_sdr = Some(Hdr::Pq);
1381 let vf = vf_of(&joined_with(&pq, PassKind::Second, true));
1382 assert!(
1383 vf.contains("tonemap=hable") && vf.contains("npl=100"),
1384 "{vf}"
1385 );
1386 let vf = vf_of(&joined_with(&pq, PassKind::Second, false));
1387 assert!(
1388 vf.contains("colorspace=all=bt709") && !vf.contains("zscale"),
1389 "{vf}"
1390 );
1391
1392 let quality = engine.plan(&info, &ShrinkOpts::default()).unwrap();
1394 assert_eq!(quality.spec.video.to_sdr, None);
1395 let args = joined(&quality, PassKind::Single);
1396 assert!(!args
1397 .iter()
1398 .any(|a| a == "-pix_fmt" || a.contains("colorspace")));
1399
1400 let sdr = engine
1402 .plan(&iphone_info(), &opts_target(10_000_000))
1403 .unwrap();
1404 assert_eq!(sdr.spec.video.to_sdr, None);
1405 assert!(!joined(&sdr, PassKind::Second)
1406 .iter()
1407 .any(|a| a == "-pix_fmt"));
1408 }
1409
1410 #[test]
1411 fn a_size_target_is_a_ceiling_at_the_quality_crf() {
1412 let info = video_info(60.0, 200_000_000, 1920, 1080, true);
1413 let engine = MediaEngine::new();
1414 let opts = opts_target(50_000_000);
1415 let plan = engine.plan(&info, &opts).unwrap();
1416 let crf = opts.quality.default_crf(opts.video_codec);
1417 assert_eq!(plan.ceiling_crf, Some(crf));
1418
1419 let ceiling = ceiling_plan(&plan).unwrap();
1420 assert_eq!(ceiling.spec.video.crf, Some(crf));
1421 assert_eq!(ceiling.spec.video.bitrate_bps, None);
1422 assert!(!ceiling.spec.two_pass);
1423 assert_eq!(ceiling.spec.video.height, plan.spec.video.height);
1425 assert_eq!(ceiling.spec.audio, plan.spec.audio);
1426 assert_eq!(ceiling.output, plan.output);
1427 assert_eq!(ceiling.target_bytes, plan.target_bytes);
1428
1429 let quality = engine.plan(&info, &ShrinkOpts::default()).unwrap();
1431 assert!(quality.ceiling_crf.is_none() && ceiling_plan(&quality).is_none());
1432 let fits = engine.plan(&info, &opts_target(300_000_000)).unwrap();
1433 assert!(fits.spec.passthrough && ceiling_plan(&fits).is_none());
1434 }
1435
1436 #[test]
1437 fn heavy_video_samples_shorter_windows() {
1438 let engine = MediaEngine::new();
1439 let mut info = video_info(120.0, 500_000_000, 1920, 1080, true);
1440 info.fps = Some(30.0);
1441 let plan = engine.plan(&info, &ShrinkOpts::default()).unwrap();
1442 assert_eq!(sample_secs(&plan), 3.0);
1443 info = video_info(120.0, 500_000_000, 3840, 2160, true);
1444 info.fps = Some(60.0);
1445 let plan = engine.plan(&info, &ShrinkOpts::default()).unwrap();
1446 assert_eq!(sample_secs(&plan), MIN_SAMPLE_SECS);
1447 }
1448
1449 #[test]
1450 fn apple_hardware_uses_quality_one_pass_and_no_preset() {
1451 let engine = MediaEngine::new();
1452 let info = video_info(60.0, 200_000_000, 1920, 1080, true);
1453 let mut plan = engine.plan(&info, &ShrinkOpts::default()).unwrap();
1454 plan.spec.video.hardware = true;
1456 plan.spec.video.crf = QualityPreset::Balanced.default_hw_quality(VideoCodec::H264);
1457 let args: Vec<String> =
1458 build_pass_args(&plan, PassKind::Single, "", "h264_videotoolbox", false)
1459 .iter()
1460 .map(|a| a.to_string_lossy().into_owned())
1461 .collect();
1462 assert!(args.windows(2).any(|w| w == ["-c:v", "h264_videotoolbox"]));
1463 assert!(args.windows(2).any(|w| w == ["-q:v", "66"]), "{args:?}");
1464 assert!(
1465 !args.iter().any(|a| a == "-crf" || a == "-preset"),
1466 "{args:?}"
1467 );
1468 let mut hw = plan.clone();
1469 hw.spec.passthrough = false;
1470 assert_eq!(
1471 resolve_encoder(
1472 &deepshrink_ffmpeg::Tools {
1473 ffmpeg: "ffmpeg".into(),
1474 ffprobe: "ffprobe".into(),
1475 cancel: Default::default(),
1476 },
1477 &hw
1478 )
1479 .unwrap(),
1480 "h264_videotoolbox"
1481 );
1482 assert_eq!(
1484 QualityPreset::Balanced.default_hw_quality(VideoCodec::Av1),
1485 None
1486 );
1487 }
1488
1489 #[test]
1490 fn a_portrait_video_is_capped_on_its_short_side() {
1491 let engine = MediaEngine::new();
1492 let info = video_info(60.0, 200_000_000, 2160, 3840, true);
1494 let opts = ShrinkOpts {
1495 resolution: ResolutionOpt::Height(1080),
1496 ..ShrinkOpts::default()
1497 };
1498 let plan = engine.plan(&info, &opts).unwrap();
1499 assert_eq!(plan.spec.video.height, Some(1080));
1500 assert!(plan.spec.video.portrait);
1501 let args = joined(&plan, PassKind::Single);
1502 let vf = &args[args.iter().position(|a| a == "-vf").unwrap() + 1];
1503 assert!(vf.starts_with("scale=1080:-2"), "{vf}");
1505
1506 let info = video_info(60.0, 200_000_000, 3840, 2160, true);
1508 let plan = engine.plan(&info, &opts).unwrap();
1509 assert!(!plan.spec.video.portrait);
1510 let args = joined(&plan, PassKind::Single);
1511 assert!(args.iter().any(|a| a.starts_with("scale=-2:1080")));
1512 }
1513
1514 fn iphone_info() -> MediaInfo {
1515 let mut info = video_info(60.0, 50_000_000, 2160, 3840, true);
1516 info.path = PathBuf::from("/tmp/IMG_3325.MOV");
1517 info.capture = CaptureMeta {
1518 created_utc: to_utc("2026-09-26T20:01:54+0300"),
1519 created_local: Some("2026-09-26T20:01:54+0300".into()),
1520 location: Some("+50.4160+030.2796+155.635/".into()),
1521 make: Some("Apple".into()),
1522 model: Some("iPhone 12 Pro Max".into()),
1523 };
1524 info
1525 }
1526
1527 #[test]
1528 fn metadata_is_kept_by_default_and_strippable() {
1529 let info = iphone_info();
1530 let plan = MediaEngine::new()
1531 .plan(&info, &ShrinkOpts::default())
1532 .unwrap();
1533 let a = joined(&plan, PassKind::Single);
1534 let at = a.iter().position(|x| x == "-map_metadata").unwrap();
1535 assert_eq!(a[at + 1], "0");
1536 for tag in [
1539 "creation_time=2026-09-26T17:01:54Z",
1540 "location=+50.4160+030.2796+155.635/",
1541 "make=Apple",
1542 "model=iPhone 12 Pro Max",
1543 "date=2026-09-26T20:01:54+0300",
1544 ] {
1545 assert!(a.contains(&tag.to_string()), "{tag} in {a:?}");
1546 }
1547 assert!(a.contains(&"+faststart".to_string()));
1548
1549 let strip = ShrinkOpts {
1550 keep_metadata: false,
1551 ..ShrinkOpts::default()
1552 };
1553 let plan = MediaEngine::new().plan(&info, &strip).unwrap();
1554 let a = joined(&plan, PassKind::Single);
1555 let at = a.iter().position(|x| x == "-map_metadata").unwrap();
1556 assert_eq!(a[at + 1], "-1");
1557 assert!(!a.iter().any(|x| x.starts_with("location=")));
1558 assert!(a.contains(&"+faststart".to_string()));
1559 }
1560
1561 #[test]
1562 fn apple_local_time_converts_to_utc() {
1563 let utc = |s: &str| to_utc(s);
1564 assert_eq!(
1565 utc("2026-09-26T20:01:54+0300").as_deref(),
1566 Some("2026-09-26T17:01:54Z")
1567 );
1568 assert_eq!(
1569 utc("2026-09-26T20:01:54+03:00").as_deref(),
1570 Some("2026-09-26T17:01:54Z")
1571 );
1572 assert_eq!(
1573 utc("2026-01-01T01:30:00+0300").as_deref(),
1574 Some("2025-12-31T22:30:00Z")
1575 );
1576 assert_eq!(
1577 utc("2026-03-01T23:00:00-0500").as_deref(),
1578 Some("2026-03-02T04:00:00Z")
1579 );
1580 assert_eq!(
1581 utc("2024-02-29T12:00:00.123Z").as_deref(),
1582 Some("2024-02-29T12:00:00Z")
1583 );
1584 assert_eq!(utc("yesterday"), None);
1585 }
1586
1587 #[test]
1588 fn an_iphone_mov_stays_mov_in_quality_mode_only() {
1589 let info = iphone_info();
1590 let out = |opts: &ShrinkOpts| {
1591 let plan = MediaEngine::new().plan(&info, opts).unwrap();
1592 plan.output.to_string_lossy().into_owned()
1593 };
1594 assert!(out(&ShrinkOpts::default()).ends_with(".shrink.mov"));
1595 assert!(out(&opts_target(8_000_000)).ends_with(".shrink.mp4"));
1597 let av1 = ShrinkOpts {
1599 video_codec: VideoCodec::Av1,
1600 ..ShrinkOpts::default()
1601 };
1602 assert!(out(&av1).ends_with(".shrink.mp4"));
1603 let mp4 = video_info(60.0, 50_000_000, 1920, 1080, true);
1605 let p = MediaEngine::new()
1606 .plan(&mp4, &ShrinkOpts::default())
1607 .unwrap();
1608 assert!(p.output.to_string_lossy().ends_with(".shrink.mp4"));
1609 }
1610
1611 #[test]
1612 fn a_video_audio_track_is_never_upsampled() {
1613 let mut info = video_info(60.0, 50_000_000, 1920, 1080, true);
1614 info.audio_bitrate_bps = Some(64_000);
1615 let bps_of = |info: &MediaInfo, opts: &ShrinkOpts| {
1616 let plan = MediaEngine::new().plan(info, opts).unwrap();
1617 plan.spec.audio.unwrap().bitrate_bps
1618 };
1619 assert_eq!(bps_of(&info, &ShrinkOpts::default()), 64_000);
1621 assert!(bps_of(&info, &opts_target(20_000_000)) <= 64_000);
1623 let explicit = ShrinkOpts {
1625 audio: AudioChoice::Bitrate(128_000),
1626 ..ShrinkOpts::default()
1627 };
1628 assert_eq!(bps_of(&info, &explicit), 128_000);
1629 info.audio_bitrate_bps = None;
1631 assert_eq!(
1632 bps_of(&info, &ShrinkOpts::default()),
1633 budget::DEFAULT_AUDIO_BPS
1634 );
1635 }
1636
1637 #[test]
1638 fn h265_adds_hvc1_tag() {
1639 let info = video_info(60.0, 100_000_000, 1280, 720, false);
1640 let opts = ShrinkOpts {
1641 video_codec: VideoCodec::H265,
1642 ..opts_target(8_000_000)
1643 };
1644 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1645 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan), false);
1646 let joined: Vec<String> = args
1647 .iter()
1648 .map(|a| a.to_string_lossy().into_owned())
1649 .collect();
1650 assert!(joined.contains(&"hvc1".to_string()));
1651 assert!(joined.contains(&"libx265".to_string()));
1652 }
1653}