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(vbps)) = (plan.target_bytes, plan.spec.video.bitrate_bps) {
196 if size > target && plan.spec.two_pass {
197 let corrected = (vbps as f64 * (target as f64 / size as f64) * 0.97) as u64;
198 if corrected >= budget::ABSOLUTE_MIN_VIDEO_BPS {
199 let mut retry = plan.clone();
200 retry.spec.video.bitrate_bps = Some(corrected);
201 let args = build_pass_args(&retry, PassKind::Second, &passlog, encoder, zscale);
202 tools.run_pass(&args, total, &mut |f| on_progress(PassKind::Second, f))?;
203 size = fs::metadata(&plan.output)?.len();
204 }
205 }
206 }
207
208 cleanup_passlog(&passlog);
209 Ok(Outcome {
210 output: plan.output.clone(),
211 final_bytes: size,
212 vmaf: None,
213 already_compact: false,
214 })
215 }
216
217 pub fn estimate(&self, plan: &EncodePlan) -> Result<Option<u64>, EngineError> {
224 if plan.spec.passthrough {
225 return Ok(fs::metadata(&plan.input).ok().map(|m| m.len()));
226 }
227 if ceiling_plan(plan).is_some() {
228 let tools = self.tools()?;
230 let encoder = resolve_encoder(&tools, plan)?;
231 let zscale = wants_zscale(&tools, plan);
232 let fit = ceiling_fit(&tools, plan, encoder, zscale).map(|(_, bytes)| bytes);
233 return Ok(fit.or(plan.expected_bytes));
234 }
235 if let Some(bytes) = plan.expected_bytes {
236 return Ok(Some(bytes));
237 }
238 if plan.spec.audio_only || plan.spec.video.crf.is_none() {
239 return Ok(None);
240 }
241 let tools = self.tools()?;
242 let encoder = resolve_encoder(&tools, plan)?;
243 let zscale = wants_zscale(&tools, plan);
244 Ok(predict_crf_bytes(&tools, plan, encoder, zscale))
245 }
246
247 fn keep_original(
250 &self,
251 tools: &deepshrink_ffmpeg::Tools,
252 plan: &EncodePlan,
253 on_progress: &mut dyn FnMut(PassKind, f64),
254 ) -> Result<Outcome, EngineError> {
255 let _ = tools; let output = match plan.input.extension() {
257 Some(ext) => plan.output.with_extension(ext),
258 None => plan.output.clone(),
259 };
260 fs::copy(&plan.input, &output)?;
263 on_progress(PassKind::Single, 1.0);
264 Ok(Outcome {
265 final_bytes: fs::metadata(&output)?.len(),
266 output,
267 vmaf: None,
268 already_compact: true,
269 })
270 }
271
272 fn run_passthrough(
281 &self,
282 tools: &deepshrink_ffmpeg::Tools,
283 plan: &EncodePlan,
284 on_progress: &mut dyn FnMut(PassKind, f64),
285 ) -> Result<Outcome, EngineError> {
286 let args = build_pass_args(plan, PassKind::Single, "", "copy", false);
288 let remuxed = tools.run_pass(&args, plan.source_duration_sec, &mut |f| {
289 on_progress(PassKind::Single, f)
290 });
291 if remuxed.is_err() {
292 fs::copy(&plan.input, &plan.output)?;
293 on_progress(PassKind::Single, 1.0);
294 }
295 let size = fs::metadata(&plan.output)?.len();
296 Ok(Outcome {
297 output: plan.output.clone(),
298 final_bytes: size,
299 vmaf: None,
300 already_compact: true,
301 })
302 }
303
304 fn run_crf_search(
311 &self,
312 tools: &deepshrink_ffmpeg::Tools,
313 plan: &EncodePlan,
314 encoder: &str,
315 zscale: bool,
316 target_vmaf: f64,
317 on_progress: &mut dyn FnMut(PassKind, f64),
318 ) -> Result<Outcome, EngineError> {
319 let (ref_w, ref_h) = match (plan.source_width, plan.source_height) {
320 (Some(w), Some(h)) => (w, h),
321 _ => return self.run_plain(tools, plan, encoder, zscale, on_progress),
322 };
323 let ref_fps = plan.source_fps.unwrap_or(0.0);
324 let total = plan.source_duration_sec;
325 let (lo, hi) = plan.spec.video.codec.crf_search_bounds();
326 let n_threads = thread_count();
327
328 let mut err: Option<EngineError> = None;
329 let mut last_crf: Option<u8> = None;
330
331 let (chosen_crf, chosen_vmaf) = budget::search_crf(target_vmaf, lo, hi, |crf| {
332 if err.is_some() {
333 return f64::NEG_INFINITY;
334 }
335 match encode_at_crf(tools, plan, encoder, zscale, crf, total, on_progress).and_then(
336 |()| {
337 last_crf = Some(crf);
338 deepshrink_ffmpeg::measure_vmaf(
339 &tools.ffmpeg,
340 &plan.output,
341 &plan.input,
342 ref_w,
343 ref_h,
344 ref_fps,
345 n_threads,
346 )
347 .map_err(EngineError::from)
348 },
349 ) {
350 Ok(v) => v,
351 Err(e) => {
352 err = Some(e);
353 f64::NEG_INFINITY
354 }
355 }
356 });
357 if let Some(e) = err {
358 return Err(e);
359 }
360
361 if last_crf != Some(chosen_crf) {
363 encode_at_crf(tools, plan, encoder, zscale, chosen_crf, total, on_progress)?;
364 }
365 let size = fs::metadata(&plan.output)?.len();
366 Ok(Outcome {
367 output: plan.output.clone(),
368 final_bytes: size,
369 vmaf: Some(chosen_vmaf),
370 already_compact: false,
371 })
372 }
373
374 fn measure_output(
377 &self,
378 tools: &deepshrink_ffmpeg::Tools,
379 plan: &EncodePlan,
380 output: &Path,
381 ) -> Option<f64> {
382 let (w, h) = (plan.source_width?, plan.source_height?);
383 let fps = plan.source_fps.unwrap_or(0.0);
384 deepshrink_ffmpeg::measure_vmaf(
385 &tools.ffmpeg,
386 output,
387 &plan.input,
388 w,
389 h,
390 fps,
391 thread_count(),
392 )
393 .ok()
394 }
395
396 fn plan_audio(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
398 let duration = info.duration_sec;
399 if !duration.is_finite() || duration <= 0.0 {
400 return Err(EngineError::Unsupported(format!(
401 "could not determine duration of {}",
402 info.path.display()
403 )));
404 }
405 let codec = opts.audio_codec;
406 let target = target_bytes(&opts.goal, info.size_bytes);
407
408 if let Some(tb) = target {
410 if info.size_bytes > 0 && info.size_bytes <= tb {
411 let src_ext = info
412 .path
413 .extension()
414 .and_then(|e| e.to_str())
415 .unwrap_or("audio");
416 let output = opts
417 .output
418 .clone()
419 .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
420 return Ok(passthrough_plan(
421 info,
422 output,
423 tb,
424 false,
425 opts.keep_metadata,
426 ));
427 }
428 }
429
430 let mono = opts.mono || info.audio_channels == Some(1);
432
433 let (bitrate_bps, expected_bytes) = match target {
434 Some(tb) => {
435 let raw = budget::audio_bitrate_bps(tb, duration).ok_or(EngineError::Infeasible)?;
436 if raw < budget::ABSOLUTE_MIN_AUDIO_BPS {
437 return Err(EngineError::Infeasible);
438 }
439 let bps = budget::snap_audio_bitrate(raw);
440 let predicted = (bps as f64 * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD))
441 .round() as u64;
442 (bps, Some(predicted))
443 }
444 None => {
445 let bps = quality_audio_bps(opts.quality, codec, mono);
447 if !opts.allow_larger && already_compact_audio(bps, info) {
451 let src_ext = info
452 .path
453 .extension()
454 .and_then(|e| e.to_str())
455 .unwrap_or("audio");
456 let output = opts
457 .output
458 .clone()
459 .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
460 let mut plan =
461 passthrough_plan(info, output, info.size_bytes, false, opts.keep_metadata);
462 plan.summary =
463 "stream copy (already compact — a re-encode would not be smaller)".into();
464 return Ok(plan);
465 }
466 let predicted = (bps as f64 * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD))
468 .round() as u64;
469 (bps, Some(predicted))
470 }
471 };
472
473 let audio = AudioSpec {
474 codec,
475 bitrate_bps,
476 mono,
477 sample_rate: opts.sample_rate,
478 vbr: opts.vbr,
479 };
480 let output = opts
481 .output
482 .clone()
483 .unwrap_or_else(|| output_with_ext(&info.path, codec.extension()));
484 let summary = build_audio_summary(&audio, info.audio_channels);
485
486 Ok(EncodePlan {
487 input: info.path.clone(),
488 output,
489 summary,
490 expected_bytes,
491 target_bytes: target,
492 target_vmaf: None,
493 source_duration_sec: duration,
494 source_width: info.width,
495 source_height: info.height,
496 source_fps: info.fps,
497 spec: EncodeSpec {
498 video: placeholder_video_spec(),
499 audio: Some(audio),
500 faststart: false,
501 two_pass: false,
502 passthrough: false,
503 audio_only: true,
504 dpi: None,
505 keep_metadata: opts.keep_metadata,
506 tags: capture_tags(info, opts.keep_metadata),
507 },
508 guard_larger: target.is_none() && !opts.allow_larger,
510 ceiling_crf: None,
511 })
512 }
513}
514
515impl Engine for MediaEngine {
516 fn supports(&self, input: &Path) -> bool {
517 matches!(detect_kind(input), MediaKind::Video | MediaKind::Audio)
518 }
519
520 fn probe(&self, input: &Path) -> Result<MediaInfo, EngineError> {
521 let tools = deepshrink_ffmpeg::locate()?;
522 let p = deepshrink_ffmpeg::probe(&tools.ffprobe, input)?;
523
524 let video = p.video_stream();
525 let audio = p.audio_stream();
526 let size_bytes = p
528 .size_bytes()
529 .or_else(|| fs::metadata(input).ok().map(|m| m.len()))
530 .unwrap_or(0);
531
532 Ok(MediaInfo {
533 path: input.to_path_buf(),
534 kind: detect_kind(input),
535 duration_sec: p.duration_sec().unwrap_or(0.0),
536 size_bytes,
537 width: video.and_then(|v| v.width),
538 height: video.and_then(|v| v.height),
539 fps: p.fps(),
540 video_codec: video.and_then(|v| v.codec_name.clone()),
541 audio_codec: audio.and_then(|a| a.codec_name.clone()),
542 audio_channels: audio.and_then(|a| a.channels),
543 audio_bitrate_bps: p.audio_bitrate_bps(),
544 capture: capture_meta(&p),
545 hdr: video
546 .and_then(|v| v.color_transfer.as_deref())
547 .and_then(Hdr::from_transfer),
548 })
549 }
550
551 fn plan(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
552 match info.kind {
553 MediaKind::Audio => return self.plan_audio(info, opts),
554 MediaKind::Unsupported => {
555 return Err(EngineError::Unsupported(format!(
556 "{} is not a supported media file",
557 info.path.display()
558 )))
559 }
560 MediaKind::Video => {}
561 }
562 let duration = info.duration_sec;
563 if !duration.is_finite() || duration <= 0.0 {
564 return Err(EngineError::Unsupported(format!(
565 "could not determine duration of {}",
566 info.path.display()
567 )));
568 }
569 let src_height = info.height.unwrap_or(0);
570
571 let target = target_bytes(&opts.goal, info.size_bytes);
572 let output = opts
573 .output
574 .clone()
575 .unwrap_or_else(|| output_with_ext(&info.path, video_container(info, target, opts)));
576
577 if let Some(tb) = target {
583 if info.size_bytes > 0 && info.size_bytes <= tb {
584 let src_ext = info
585 .path
586 .extension()
587 .and_then(|e| e.to_str())
588 .unwrap_or("mp4");
589 let output = opts
590 .output
591 .clone()
592 .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
593 return Ok(passthrough_plan(info, output, tb, true, opts.keep_metadata));
594 }
595 }
596
597 let audio = decide_audio(
598 opts,
599 info.has_audio(),
600 info.audio_bitrate_bps,
601 target,
602 duration,
603 )?;
604 let audio_bps = audio.as_ref().map(|a| a.bitrate_bps).unwrap_or(0);
605
606 let (video, expected_bytes) = if let Some(tb) = target {
607 let vbps = budget::video_bitrate_bps(tb, duration, audio_bps)
608 .filter(|&b| b >= budget::ABSOLUTE_MIN_VIDEO_BPS)
609 .ok_or(EngineError::Infeasible)?;
610 let height = pick_height(opts.resolution, src_height, vbps);
611 let predicted = ((vbps + audio_bps) as f64 * duration / 8.0
612 * (1.0 + budget::CONTAINER_OVERHEAD))
613 .round() as u64;
614 (
615 VideoSpec {
616 codec: opts.video_codec,
617 bitrate_bps: Some(vbps),
618 crf: None,
619 height,
620 fps: pick_fps(opts.fps, info.fps),
621 preset: opts.quality,
622 to_sdr: info.hdr,
624 },
625 Some(predicted),
626 )
627 } else {
628 let crf = opts.quality.default_crf(opts.video_codec);
631 let height = match opts.resolution {
632 ResolutionOpt::Height(h) => clamp_height(h, src_height),
633 ResolutionOpt::Auto => None,
634 };
635 (
636 VideoSpec {
637 codec: opts.video_codec,
638 bitrate_bps: None,
639 crf: Some(crf),
640 height,
641 fps: pick_fps(opts.fps, info.fps),
642 preset: opts.quality,
643 to_sdr: None,
645 },
646 None,
647 )
648 };
649
650 let two_pass = video.bitrate_bps.is_some() && opts.two_pass.unwrap_or(true);
654 let summary = build_summary(&video, audio.as_ref(), two_pass);
655
656 Ok(EncodePlan {
657 input: info.path.clone(),
658 output,
659 summary,
660 expected_bytes,
661 target_bytes: target,
662 target_vmaf: opts.target_vmaf,
663 source_duration_sec: duration,
664 source_width: info.width,
665 source_height: info.height,
666 source_fps: info.fps,
667 spec: EncodeSpec {
668 video,
669 audio,
670 faststart: true,
671 two_pass,
672 passthrough: false,
673 audio_only: false,
674 dpi: None,
675 keep_metadata: opts.keep_metadata,
676 tags: capture_tags(info, opts.keep_metadata),
677 },
678 guard_larger: target.is_none() && !opts.allow_larger,
680 ceiling_crf: target.map(|_| opts.quality.default_crf(opts.video_codec)),
681 })
682 }
683
684 fn run(&self, plan: &EncodePlan) -> Result<Outcome, EngineError> {
685 self.run_with_progress(plan, &mut |_, _| {})
686 }
687}
688
689fn placeholder_video_spec() -> VideoSpec {
691 VideoSpec {
692 codec: crate::options::VideoCodec::H264,
693 bitrate_bps: None,
694 crf: None,
695 height: None,
696 fps: None,
697 preset: crate::options::QualityPreset::Balanced,
698 to_sdr: None,
699 }
700}
701
702fn passthrough_plan(
705 info: &MediaInfo,
706 output: PathBuf,
707 target: u64,
708 faststart: bool,
709 keep_metadata: bool,
710) -> EncodePlan {
711 EncodePlan {
712 input: info.path.clone(),
713 output,
714 summary: "stream copy (already within target)".to_string(),
715 expected_bytes: Some(info.size_bytes),
716 target_bytes: Some(target),
717 target_vmaf: None,
718 source_duration_sec: info.duration_sec,
719 source_width: info.width,
720 source_height: info.height,
721 source_fps: info.fps,
722 spec: EncodeSpec {
723 video: placeholder_video_spec(),
724 audio: None,
725 faststart,
726 two_pass: false,
727 passthrough: true,
728 audio_only: false,
729 dpi: None,
730 keep_metadata,
731 tags: capture_tags(info, keep_metadata),
732 },
733 guard_larger: false,
734 ceiling_crf: None,
735 }
736}
737
738fn quality_audio_bps(quality: QualityPreset, codec: AudioCodec, mono: bool) -> u64 {
741 let stereo = match (codec, quality) {
742 (AudioCodec::Opus, QualityPreset::Fast) => 64_000,
743 (AudioCodec::Opus, QualityPreset::Balanced) => 96_000,
744 (AudioCodec::Opus, QualityPreset::Max) => 128_000,
745 (AudioCodec::Mp3, QualityPreset::Fast) => 128_000,
746 (AudioCodec::Mp3, QualityPreset::Balanced) => 160_000,
747 (AudioCodec::Mp3, QualityPreset::Max) => 256_000,
748 (AudioCodec::Aac, QualityPreset::Fast) => 96_000,
749 (AudioCodec::Aac, QualityPreset::Balanced) => 128_000,
750 (AudioCodec::Aac, QualityPreset::Max) => 192_000,
751 };
752 if mono {
753 stereo / 2
754 } else {
755 stereo
756 }
757}
758
759fn already_compact_audio(bps: u64, info: &MediaInfo) -> bool {
763 if info.duration_sec <= 0.0 || info.size_bytes == 0 {
764 return false;
765 }
766 let source_bps = info.size_bytes as f64 * 8.0 / info.duration_sec;
767 bps as f64 >= source_bps * 0.9
768}
769
770fn copy_mtime(input: &Path, output: &Path) {
772 let Ok(mtime) = fs::metadata(input).and_then(|m| m.modified()) else {
773 return;
774 };
775 if let Ok(f) = fs::File::options().write(true).open(output) {
776 let _ = f.set_modified(mtime);
777 }
778}
779
780fn video_container(info: &MediaInfo, target: Option<u64>, opts: &ShrinkOpts) -> &'static str {
786 let mov_source = info
787 .path
788 .extension()
789 .and_then(|e| e.to_str())
790 .is_some_and(|e| e.eq_ignore_ascii_case("mov"));
791 if mov_source && target.is_none() && opts.video_codec != VideoCodec::Av1 {
792 "mov"
793 } else {
794 "mp4"
795 }
796}
797
798fn capture_meta(p: &deepshrink_ffmpeg::Ffprobe) -> CaptureMeta {
800 let tag = |keys: &[&str]| {
801 keys.iter()
802 .find_map(|k| p.format_tag(k))
803 .map(str::to_string)
804 };
805 let created_local = tag(&["com.apple.quicktime.creationdate"]);
806 let created_utc = created_local
807 .as_deref()
808 .and_then(to_utc)
809 .or_else(|| tag(&["creation_time"]));
810 CaptureMeta {
811 created_utc,
812 created_local,
813 location: tag(&["com.apple.quicktime.location.ISO6709", "location"]),
814 make: tag(&["com.apple.quicktime.make", "make"]),
815 model: tag(&["com.apple.quicktime.model", "model"]),
816 }
817}
818
819fn capture_tags(info: &MediaInfo, keep: bool) -> Vec<(String, String)> {
822 if !keep {
823 return Vec::new();
824 }
825 let c = &info.capture;
826 [
827 ("creation_time", c.created_utc.as_ref()),
828 ("date", c.created_local.as_ref()),
829 ("location", c.location.as_ref()),
830 ("make", c.make.as_ref()),
831 ("model", c.model.as_ref()),
832 ]
833 .into_iter()
834 .filter_map(|(k, v)| v.map(|v| (k.to_string(), v.clone())))
835 .collect()
836}
837
838fn to_utc(s: &str) -> Option<String> {
841 let s = s.trim();
842 let num = |a: usize, b: usize| s.get(a..b)?.parse::<i64>().ok();
843 let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
844 let (h, mi, se) = (num(11, 13)?, num(14, 16)?, num(17, 19)?);
845 if s.get(4..5)? != "-" || s.get(10..11).map(|c| c == "T" || c == " ") != Some(true) {
846 return None;
847 }
848 let rest = s
850 .get(19..)?
851 .trim_start_matches(|c: char| c == '.' || c.is_ascii_digit());
852 let offset_min = match rest {
853 "" | "Z" | "z" => 0,
854 r if r.starts_with('+') || r.starts_with('-') => {
855 let digits: String = r[1..].chars().filter(char::is_ascii_digit).collect();
856 let (oh, om) = (
857 digits.get(0..2)?.parse::<i64>().ok()?,
858 digits.get(2..4).unwrap_or("00").parse::<i64>().ok()?,
859 );
860 let m = oh * 60 + om;
861 if r.starts_with('-') {
862 -m
863 } else {
864 m
865 }
866 }
867 _ => return None,
868 };
869 let secs = days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + se - offset_min * 60;
870 let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
871 let (y, mo, d) = civil_from_days(days);
872 Some(format!(
873 "{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}Z",
874 rem / 3600,
875 rem % 3600 / 60,
876 rem % 60
877 ))
878}
879
880fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
882 let y = if m <= 2 { y - 1 } else { y };
883 let era = y.div_euclid(400);
884 let yoe = y - era * 400;
885 let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
886 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
887 era * 146_097 + doe - 719_468
888}
889
890fn civil_from_days(z: i64) -> (i64, i64, i64) {
892 let z = z + 719_468;
893 let era = z.div_euclid(146_097);
894 let doe = z - era * 146_097;
895 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
896 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
897 let mp = (5 * doy + 2) / 153;
898 let d = doy - (153 * mp + 2) / 5 + 1;
899 let m = if mp < 10 { mp + 3 } else { mp - 9 };
900 (yoe + era * 400 + i64::from(m <= 2), m, d)
901}
902
903fn metadata_args(plan: &EncodePlan) -> (Vec<OsString>, Option<&'static str>) {
910 if !plan.spec.keep_metadata {
911 return (vec!["-map_metadata".into(), "-1".into()], None);
912 }
913 let mut a: Vec<OsString> = vec!["-map_metadata".into(), "0".into()];
914 for (k, v) in &plan.spec.tags {
915 a.push("-metadata".into());
916 a.push(format!("{k}={v}").into());
917 }
918 (a, None)
919}
920
921fn movflags(faststart: bool, meta: Option<&'static str>) -> Option<String> {
923 let mut v = String::new();
924 if faststart {
925 v.push_str("+faststart");
926 }
927 if let Some(m) = meta {
928 v.push_str(m);
929 }
930 (!v.is_empty()).then_some(v)
931}
932
933const CEILING_MARGIN: f64 = 0.9;
936
937fn ceiling_plan(plan: &EncodePlan) -> Option<EncodePlan> {
940 let crf = plan.ceiling_crf?;
941 if plan.target_bytes.is_none()
942 || plan.spec.passthrough
943 || plan.spec.audio_only
944 || plan.spec.video.bitrate_bps.is_none()
945 {
946 return None;
947 }
948 let mut c = plan.clone();
949 c.spec.video.bitrate_bps = None;
950 c.spec.video.crf = Some(crf);
951 c.spec.two_pass = false;
952 c.summary = build_summary(&c.spec.video, c.spec.audio.as_ref(), false);
953 Some(c)
954}
955
956fn ceiling_fit(
960 tools: &deepshrink_ffmpeg::Tools,
961 plan: &EncodePlan,
962 encoder: &str,
963 zscale: bool,
964) -> Option<(EncodePlan, u64)> {
965 let target = plan.target_bytes?;
966 let ceiling = ceiling_plan(plan)?;
967 let predicted = predict_crf_bytes(tools, &ceiling, encoder, zscale)?;
968 ((predicted as f64) < target as f64 * CEILING_MARGIN).then_some((ceiling, predicted))
969}
970
971fn wants_zscale(tools: &deepshrink_ffmpeg::Tools, plan: &EncodePlan) -> bool {
973 plan.spec.video.to_sdr == Some(Hdr::Pq)
974 && deepshrink_ffmpeg::has_filter(&tools.ffmpeg, "zscale")
975}
976
977const SAMPLE_SECS: f64 = 3.0;
979const SAMPLE_BIAS: f64 = 0.92;
982
983fn predict_crf_bytes(
987 tools: &deepshrink_ffmpeg::Tools,
988 plan: &EncodePlan,
989 encoder: &str,
990 zscale: bool,
991) -> Option<u64> {
992 let duration = plan.source_duration_sec;
993 if !duration.is_finite() || duration <= 0.0 {
994 return None;
995 }
996 let (windows, bias): (Vec<(f64, f64)>, f64) = if duration >= SAMPLE_SECS * 4.0 {
999 (
1000 [0.2, 0.5, 0.8]
1001 .iter()
1002 .map(|at| ((duration * at - SAMPLE_SECS / 2.0).max(0.0), SAMPLE_SECS))
1003 .collect(),
1004 SAMPLE_BIAS,
1005 )
1006 } else {
1007 (vec![(0.0, duration)], 1.0)
1008 };
1009 let mut sample = plan.clone();
1010 sample.spec.audio = None;
1011 sample.spec.faststart = false;
1012 let mut video_bytes = 0u64;
1013 let mut sampled = 0.0;
1014 for (i, &(start, len)) in windows.iter().enumerate() {
1015 sample.output =
1016 std::env::temp_dir().join(format!("deepshrink-sample-{}-{i}.mp4", std::process::id()));
1017 let mut args = build_pass_args(&sample, PassKind::Single, "", encoder, zscale);
1018 let at_input = args.iter().position(|a| a == "-i")?;
1019 args.splice(
1020 at_input..at_input,
1021 [
1022 OsString::from("-ss"),
1023 OsString::from(format!("{start:.2}")),
1024 OsString::from("-t"),
1025 OsString::from(format!("{len:.2}")),
1026 ],
1027 );
1028 let ran = tools.run_pass(&args, len, &mut |_| {});
1029 let bytes = fs::metadata(&sample.output).map(|m| m.len()).ok();
1030 let _ = fs::remove_file(&sample.output);
1031 video_bytes += bytes.filter(|_| ran.is_ok())?;
1032 sampled += len;
1033 }
1034 let video_bps = video_bytes as f64 * 8.0 / sampled * bias;
1035 let audio_bps = plan.spec.audio.as_ref().map(|a| a.bitrate_bps).unwrap_or(0) as f64;
1036 Some(((video_bps + audio_bps) * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD)) as u64)
1037}
1038
1039fn build_audio_summary(audio: &AudioSpec, src_channels: Option<u32>) -> String {
1042 let mut parts = vec![
1043 audio.codec.label().to_string(),
1044 format!("{} kbps", audio.bitrate_bps / 1000),
1045 ];
1046 if audio.mono {
1047 let note = if src_channels == Some(1) {
1049 "mono"
1050 } else {
1051 "mono (downmix)"
1052 };
1053 parts.push(note.to_string());
1054 }
1055 if let Some(sr) = audio.sample_rate {
1056 parts.push(format!("{} Hz", sr));
1057 }
1058 parts.join(" · ")
1059}
1060
1061fn target_bytes(goal: &SizeGoal, original: u64) -> Option<u64> {
1063 match goal {
1064 SizeGoal::Target(b) => Some(*b),
1065 SizeGoal::Reduce(f) => Some(budget::reduce_target_bytes(original, *f)),
1066 SizeGoal::Preset(p) => p.limit_bytes,
1067 SizeGoal::Quality => None,
1068 }
1069}
1070
1071fn decide_audio(
1073 opts: &ShrinkOpts,
1074 has_audio: bool,
1075 source_bps: Option<u64>,
1076 target: Option<u64>,
1077 duration: f64,
1078) -> Result<Option<AudioSpec>, EngineError> {
1079 if !has_audio {
1080 return Ok(None);
1081 }
1082 let mono = opts.mono;
1085 match opts.audio {
1086 AudioChoice::Drop => Ok(None),
1087 AudioChoice::Bitrate(b) => Ok(Some(AudioSpec {
1088 mono,
1089 ..AudioSpec::cbr(AudioCodec::Aac, b)
1090 })),
1091 AudioChoice::Keep => {
1092 let bps = match target {
1093 Some(tb) => budget::fit_audio_bps(tb, duration, AUDIO_LADDER)
1094 .ok_or(EngineError::Infeasible)?,
1095 None => budget::DEFAULT_AUDIO_BPS,
1096 };
1097 let bps = match source_bps {
1101 Some(src) => bps.min(src.max(MIN_TRACK_BPS)),
1102 None => bps,
1103 };
1104 Ok(Some(AudioSpec {
1105 mono,
1106 ..AudioSpec::cbr(AudioCodec::Aac, bps)
1107 }))
1108 }
1109 }
1110}
1111
1112const MIN_TRACK_BPS: u64 = 32_000;
1115
1116fn pick_height(res: ResolutionOpt, src_height: u32, vbps: u64) -> Option<u32> {
1118 match res {
1119 ResolutionOpt::Height(h) => clamp_height(h, src_height),
1120 ResolutionOpt::Auto => {
1121 let chosen = budget::choose_height(src_height, vbps);
1122 if src_height > 0 && chosen < src_height {
1123 Some(chosen)
1124 } else {
1125 None
1126 }
1127 }
1128 }
1129}
1130
1131fn clamp_height(requested: u32, src_height: u32) -> Option<u32> {
1134 if src_height == 0 {
1135 return Some(requested);
1136 }
1137 let h = requested.min(src_height);
1138 if h == src_height {
1139 None
1140 } else {
1141 Some(h)
1142 }
1143}
1144
1145fn pick_fps(fps: FpsOpt, src_fps: Option<f64>) -> Option<u32> {
1147 match fps {
1148 FpsOpt::Auto => None,
1149 FpsOpt::Cap(f) => match src_fps {
1150 Some(src) if (f as f64) >= src => None,
1151 _ => Some(f),
1152 },
1153 }
1154}
1155
1156fn output_with_ext(input: &Path, ext: &str) -> PathBuf {
1158 let stem = input
1159 .file_stem()
1160 .map(|s| s.to_string_lossy().into_owned())
1161 .unwrap_or_else(|| "output".to_string());
1162 let mut out = input.parent().map(Path::to_path_buf).unwrap_or_default();
1163 out.push(format!("{stem}.shrink.{ext}"));
1164 out
1165}
1166
1167fn build_summary(video: &VideoSpec, audio: Option<&AudioSpec>, two_pass: bool) -> String {
1168 let mut parts = vec![video.codec.label().to_string()];
1169 match (video.bitrate_bps, video.crf) {
1170 (Some(bps), _) => parts.push(format!("up to {} kbps video", bps / 1000)),
1171 (_, Some(crf)) => parts.push(format!("CRF {crf}")),
1172 _ => {}
1173 }
1174 if let Some(a) = audio {
1175 parts.push(format!("{} kbps audio", a.bitrate_bps / 1000));
1176 } else {
1177 parts.push("no audio".to_string());
1178 }
1179 if let Some(h) = video.height {
1180 parts.push(format!("{h}p"));
1181 }
1182 if let Some(f) = video.fps {
1183 parts.push(format!("{f} fps"));
1184 }
1185 if video.to_sdr.is_some() {
1186 parts.push("HDR → SDR".to_string());
1187 }
1188 parts.push(if two_pass { "two-pass" } else { "CRF" }.to_string());
1189 parts.join(" · ")
1190}
1191
1192fn video_filters(video: &VideoSpec, zscale: bool) -> Option<String> {
1201 const COLORSPACE: &str = "colorspace=all=bt709:iall=bt2020:itrc=bt2020-10:format=yuv420p";
1202 let mut chain = Vec::new();
1203 if let Some(h) = video.height {
1204 chain.push(format!("scale=-2:{h}"));
1205 }
1206 match video.to_sdr {
1207 Some(Hdr::Pq) if zscale => chain.push(
1208 "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,\
1209 tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
1210 .to_string(),
1211 ),
1212 Some(_) => chain.push(COLORSPACE.to_string()),
1213 None => {}
1214 }
1215 (!chain.is_empty()).then(|| chain.join(","))
1216}
1217
1218fn passlog_base(plan: &EncodePlan) -> String {
1220 let stem = plan
1221 .input
1222 .file_stem()
1223 .map(|s| s.to_string_lossy().into_owned())
1224 .unwrap_or_else(|| "ds".to_string());
1225 let dir = std::env::temp_dir();
1226 dir.join(format!("deepshrink-{}-{}", std::process::id(), stem))
1227 .to_string_lossy()
1228 .into_owned()
1229}
1230
1231fn cleanup_passlog(base: &str) {
1233 for suffix in ["-0.log", "-0.log.mbtree"] {
1234 let _ = fs::remove_file(format!("{base}{suffix}"));
1235 }
1236}
1237
1238fn encode_at_crf(
1240 tools: &deepshrink_ffmpeg::Tools,
1241 plan: &EncodePlan,
1242 encoder: &str,
1243 zscale: bool,
1244 crf: u8,
1245 total: f64,
1246 on_progress: &mut dyn FnMut(PassKind, f64),
1247) -> Result<(), EngineError> {
1248 let mut trial = plan.clone();
1249 trial.spec.video.crf = Some(crf);
1250 trial.spec.video.bitrate_bps = None;
1251 trial.spec.two_pass = false;
1252 let args = build_pass_args(&trial, PassKind::Single, "", encoder, zscale);
1253 tools.run_pass(&args, total, &mut |f| on_progress(PassKind::Single, f))?;
1254 Ok(())
1255}
1256
1257fn thread_count() -> usize {
1259 std::thread::available_parallelism()
1260 .map(|n| n.get())
1261 .unwrap_or(1)
1262}
1263
1264fn null_sink() -> &'static str {
1266 if cfg!(windows) {
1267 "NUL"
1268 } else {
1269 "/dev/null"
1270 }
1271}
1272
1273fn resolve_encoder(
1281 tools: &deepshrink_ffmpeg::Tools,
1282 plan: &EncodePlan,
1283) -> Result<&'static str, EngineError> {
1284 let codec = plan.spec.video.codec;
1285 let primary = codec.encoder();
1286 let Some(fallback) = codec.fallback_encoder() else {
1287 return Ok(primary);
1288 };
1289 if plan.spec.passthrough || plan.spec.audio_only {
1291 return Ok(primary);
1292 }
1293 if deepshrink_ffmpeg::has_encoder(&tools.ffmpeg, primary) {
1294 return Ok(primary);
1295 }
1296 if deepshrink_ffmpeg::has_encoder(&tools.ffmpeg, fallback) {
1297 return Ok(fallback);
1298 }
1299 Err(EngineError::Unsupported(format!(
1300 "this ffmpeg build has no {} encoder (looked for {primary} and {fallback})",
1301 codec.label()
1302 )))
1303}
1304
1305fn build_pass_args(
1310 plan: &EncodePlan,
1311 pass: PassKind,
1312 passlog: &str,
1313 encoder: &str,
1314 zscale: bool,
1315) -> Vec<OsString> {
1316 let s = &plan.spec;
1317 let mut a: Vec<OsString> = Vec::new();
1318 macro_rules! push {
1321 ($arg:expr) => {
1322 a.push(OsString::from($arg))
1323 };
1324 }
1325
1326 push!("-hide_banner");
1327 push!("-y");
1328 push!("-loglevel");
1329 push!("error");
1330 push!("-progress");
1331 push!("pipe:1");
1332 push!("-nostats");
1333 push!("-i");
1334 a.push(plan.input.clone().into_os_string());
1335
1336 let (meta, meta_flag) = metadata_args(plan);
1337
1338 if s.passthrough {
1340 push!("-c");
1341 push!("copy");
1342 a.extend(meta.iter().cloned());
1343 if let Some(flags) = movflags(s.faststart, meta_flag) {
1344 push!("-movflags");
1345 push!(flags);
1346 }
1347 a.push(plan.output.clone().into_os_string());
1348 return a;
1349 }
1350
1351 if s.audio_only {
1353 push!("-vn");
1354 if let Some(au) = &s.audio {
1355 push!("-c:a");
1356 push!(au.codec.encoder());
1357 if au.mono {
1358 push!("-ac");
1359 push!("1");
1360 }
1361 if let Some(sr) = au.sample_rate {
1362 push!("-ar");
1363 push!(sr.to_string());
1364 }
1365 push!("-b:a");
1366 push!(au.bitrate_bps.to_string());
1367 if matches!(au.codec, AudioCodec::Opus) {
1370 push!("-vbr");
1371 push!(if au.vbr { "on" } else { "constrained" });
1372 }
1373 }
1374 a.extend(meta.iter().cloned());
1375 if let Some(flags) = movflags(false, meta_flag) {
1376 push!("-movflags");
1377 push!(flags);
1378 }
1379 a.push(plan.output.clone().into_os_string());
1380 return a;
1381 }
1382
1383 push!("-c:v");
1385 push!(encoder);
1386 if let Some(vf) = video_filters(&s.video, zscale) {
1387 push!("-vf");
1388 push!(vf);
1389 }
1390 if let Some(f) = s.video.fps {
1391 push!("-r");
1392 push!(f.to_string());
1393 }
1394 let (speed_flag, speed_value) = s.video.preset.speed_flags(encoder);
1397 push!(speed_flag);
1398 push!(speed_value);
1399 if let Some(tag) = s.video.codec.mp4_tag() {
1400 push!("-tag:v");
1401 push!(tag);
1402 }
1403 if s.video.to_sdr.is_some() {
1406 for (flag, value) in [
1407 ("-pix_fmt", "yuv420p"),
1408 ("-color_primaries", "bt709"),
1409 ("-color_trc", "bt709"),
1410 ("-colorspace", "bt709"),
1411 ] {
1412 push!(flag);
1413 push!(value);
1414 }
1415 }
1416
1417 match (s.video.bitrate_bps, s.video.crf) {
1419 (Some(bps), _) => {
1420 push!("-b:v");
1421 push!(bps.to_string());
1422 if s.two_pass {
1423 push!("-pass");
1424 push!(match pass {
1425 PassKind::First => "1",
1426 _ => "2",
1427 });
1428 push!("-passlogfile");
1429 push!(passlog);
1430 }
1431 }
1432 (_, Some(crf)) => {
1433 push!("-crf");
1434 push!(crf.to_string());
1435 }
1436 _ => {}
1437 }
1438
1439 match pass {
1441 PassKind::First => {
1442 push!("-an");
1444 push!("-f");
1445 push!("null");
1446 push!(null_sink());
1447 }
1448 PassKind::Second | PassKind::Single => {
1449 match &s.audio {
1450 Some(au) => {
1451 push!("-c:a");
1452 push!(au.codec.encoder());
1453 if au.mono {
1457 push!("-ac");
1458 push!("1");
1459 }
1460 push!("-b:a");
1461 push!(au.bitrate_bps.to_string());
1462 }
1463 None => push!("-an"),
1464 }
1465 a.extend(meta.iter().cloned());
1466 if let Some(flags) = movflags(s.faststart, meta_flag) {
1467 push!("-movflags");
1468 push!(flags);
1469 }
1470 a.push(plan.output.clone().into_os_string());
1471 }
1472 }
1473 a
1474}
1475
1476#[cfg(test)]
1477mod tests {
1478 use super::*;
1479 use crate::options::{AudioCodec, QualityPreset, VideoCodec};
1480 use crate::size::preset;
1481
1482 fn enc(plan: &EncodePlan) -> &'static str {
1485 plan.spec.video.codec.encoder()
1486 }
1487
1488 fn video_info(duration: f64, size: u64, w: u32, h: u32, audio: bool) -> MediaInfo {
1489 MediaInfo {
1490 path: PathBuf::from("/tmp/clip.mp4"),
1491 kind: MediaKind::Video,
1492 duration_sec: duration,
1493 size_bytes: size,
1494 width: Some(w),
1495 height: Some(h),
1496 fps: Some(30.0),
1497 video_codec: Some("h264".into()),
1498 audio_codec: if audio { Some("aac".into()) } else { None },
1499 audio_channels: if audio { Some(2) } else { None },
1500 audio_bitrate_bps: None,
1501 capture: CaptureMeta::default(),
1502 hdr: None,
1503 }
1504 }
1505
1506 fn opts_target(bytes: u64) -> ShrinkOpts {
1507 ShrinkOpts {
1508 goal: SizeGoal::Target(bytes),
1509 ..Default::default()
1510 }
1511 }
1512
1513 #[test]
1514 fn supports_video_and_audio() {
1515 let e = MediaEngine::new();
1516 assert!(e.supports(&PathBuf::from("clip.mp4")));
1517 assert!(e.supports(&PathBuf::from("lecture.wav")));
1518 assert!(!e.supports(&PathBuf::from("photo.jpg")));
1519 }
1520
1521 #[test]
1522 fn plan_target_builds_two_pass_with_budget() {
1523 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1524 let plan = MediaEngine::new()
1525 .plan(&info, &opts_target(8_000_000))
1526 .unwrap();
1527
1528 assert!(plan.spec.two_pass);
1529 assert_eq!(plan.target_bytes, Some(8_000_000));
1530 assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
1531 let vbps = plan.spec.video.bitrate_bps.unwrap();
1532 assert!(vbps >= budget::ABSOLUTE_MIN_VIDEO_BPS);
1533 assert!(plan.spec.video.height.is_some());
1535 assert!(plan.spec.audio.is_some());
1536 assert!(plan.expected_bytes.unwrap() <= 8_000_000 + 8_000_000 / 20);
1538 }
1539
1540 #[test]
1541 fn plan_video_mono_downmixes_the_audio_track() {
1542 let info = video_info(60.0, 100_000_000, 1280, 720, true);
1543 let opts = ShrinkOpts {
1544 mono: true,
1545 ..opts_target(8_000_000)
1546 };
1547 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1548 let audio = plan.spec.audio.as_ref().expect("kept audio track");
1549 assert!(audio.mono, "opts.mono downmixes the video's audio track");
1550 let stereo = MediaEngine::new()
1552 .plan(&info, &opts_target(8_000_000))
1553 .unwrap();
1554 assert!(!stereo.spec.audio.as_ref().unwrap().mono);
1555 }
1556
1557 #[test]
1558 fn video_mono_reaches_ffmpeg_as_ac_1() {
1559 let info = video_info(60.0, 100_000_000, 1280, 720, true);
1562 let plan = MediaEngine::new()
1563 .plan(
1564 &info,
1565 &ShrinkOpts {
1566 mono: true,
1567 ..opts_target(8_000_000)
1568 },
1569 )
1570 .unwrap();
1571 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan), false);
1572 let joined: Vec<String> = args
1573 .iter()
1574 .map(|a| a.to_string_lossy().into_owned())
1575 .collect();
1576 let ac = joined.iter().position(|a| a == "-ac").expect("-ac emitted");
1577 assert_eq!(joined[ac + 1], "1");
1578
1579 let stereo = MediaEngine::new()
1581 .plan(&info, &opts_target(8_000_000))
1582 .unwrap();
1583 let stereo_args: Vec<String> = build_pass_args(
1584 &stereo,
1585 PassKind::Second,
1586 "/tmp/passlog",
1587 enc(&stereo),
1588 false,
1589 )
1590 .iter()
1591 .map(|a| a.to_string_lossy().into_owned())
1592 .collect();
1593 assert!(!stereo_args.iter().any(|a| a == "-ac"));
1594 }
1595
1596 #[test]
1597 fn plan_preset_discord_sets_target() {
1598 let info = video_info(30.0, 50_000_000, 1280, 720, true);
1599 let opts = ShrinkOpts {
1600 goal: SizeGoal::Preset(preset("discord").unwrap()),
1601 ..Default::default()
1602 };
1603 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1604 assert_eq!(plan.target_bytes, Some(8_000_000));
1605 }
1606
1607 #[test]
1608 fn plan_reduce_targets_complement_of_original() {
1609 let info = video_info(60.0, 100_000_000, 1920, 1080, true);
1610 let opts = ShrinkOpts {
1611 goal: SizeGoal::Reduce(0.70),
1612 ..Default::default()
1613 };
1614 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1615 assert_eq!(plan.target_bytes, Some(30_000_000));
1616 }
1617
1618 #[test]
1619 fn plan_passthrough_when_source_already_fits() {
1620 let info = video_info(10.0, 200_000, 1280, 720, true);
1622 let plan = MediaEngine::new()
1623 .plan(&info, &opts_target(1_000_000))
1624 .unwrap();
1625 assert!(plan.spec.passthrough);
1626 assert!(!plan.spec.two_pass);
1627 assert_eq!(plan.expected_bytes, Some(200_000));
1628 let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog", enc(&plan), false);
1629 let joined: Vec<String> = args
1630 .iter()
1631 .map(|a| a.to_string_lossy().into_owned())
1632 .collect();
1633 assert!(joined.contains(&"copy".to_string()));
1634 assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
1637 }
1638
1639 #[test]
1640 fn plan_passthrough_keeps_the_source_container() {
1641 let info = MediaInfo {
1644 path: PathBuf::from("/tmp/voice.3gp"),
1645 ..video_info(10.0, 200_000, 320, 240, true)
1646 };
1647 let plan = MediaEngine::new()
1648 .plan(&info, &opts_target(1_000_000))
1649 .unwrap();
1650 assert!(plan.spec.passthrough);
1651 assert_eq!(plan.output, PathBuf::from("/tmp/voice.shrink.3gp"));
1652 }
1653
1654 #[test]
1655 fn plan_infeasible_when_target_too_small() {
1656 let info = video_info(600.0, 500_000_000, 1920, 1080, true);
1657 let err = MediaEngine::new().plan(&info, &opts_target(50_000));
1658 assert!(matches!(err, Err(EngineError::Infeasible)));
1659 }
1660
1661 #[test]
1662 fn plan_quality_mode_uses_crf_single_pass() {
1663 let info = video_info(60.0, 100_000_000, 1920, 1080, true);
1664 let opts = ShrinkOpts {
1665 goal: SizeGoal::Quality,
1666 quality: QualityPreset::Balanced,
1667 ..Default::default()
1668 };
1669 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1670 assert!(!plan.spec.two_pass);
1671 assert_eq!(plan.spec.video.crf, Some(23));
1672 assert!(plan.spec.video.bitrate_bps.is_none());
1673 assert!(plan.expected_bytes.is_none());
1674 }
1675
1676 #[test]
1677 fn plan_drops_audio_when_requested() {
1678 let info = video_info(30.0, 50_000_000, 1280, 720, true);
1679 let opts = ShrinkOpts {
1680 audio: AudioChoice::Drop,
1681 ..opts_target(8_000_000)
1682 };
1683 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1684 assert!(plan.spec.audio.is_none());
1685 }
1686
1687 fn audio_info(duration: f64, size: u64, channels: u32) -> MediaInfo {
1688 MediaInfo {
1689 path: PathBuf::from("/tmp/lecture.wav"),
1690 kind: MediaKind::Audio,
1691 duration_sec: duration,
1692 size_bytes: size,
1693 width: None,
1694 height: None,
1695 fps: None,
1696 video_codec: None,
1697 audio_codec: Some("pcm_s16le".into()),
1698 audio_channels: Some(channels),
1699 audio_bitrate_bps: None,
1700 capture: CaptureMeta::default(),
1701 hdr: None,
1702 }
1703 }
1704
1705 #[test]
1706 fn quality_audio_bitrate_follows_tier_codec_and_channels() {
1707 use QualityPreset::*;
1708 assert_eq!(quality_audio_bps(Balanced, AudioCodec::Aac, false), 128_000);
1709 assert_eq!(quality_audio_bps(Balanced, AudioCodec::Aac, true), 64_000);
1710 assert_eq!(quality_audio_bps(Fast, AudioCodec::Opus, true), 32_000);
1711 assert_eq!(quality_audio_bps(Max, AudioCodec::Mp3, false), 256_000);
1712 for c in [AudioCodec::Aac, AudioCodec::Opus, AudioCodec::Mp3] {
1714 let t: Vec<_> = [Fast, Balanced, Max]
1715 .map(|q| quality_audio_bps(q, c, false))
1716 .into();
1717 assert!(t[0] < t[1] && t[1] < t[2], "{c:?}: {t:?}");
1718 }
1719 }
1720
1721 #[test]
1722 fn a_compact_audiobook_is_kept_in_quality_mode() {
1723 let mut info = audio_info(3600.0, 64_000 / 8 * 3600, 1);
1725 info.path = PathBuf::from("/tmp/book.mp3");
1726 let plan = MediaEngine::new()
1727 .plan(&info, &ShrinkOpts::default())
1728 .unwrap();
1729 assert!(plan.spec.passthrough, "{}", plan.summary);
1730 assert!(plan.output.to_string_lossy().ends_with(".mp3"));
1731 assert!(plan.summary.contains("already compact"));
1732
1733 let smaller = ShrinkOpts {
1735 audio_codec: AudioCodec::Opus,
1736 quality: QualityPreset::Fast,
1737 ..ShrinkOpts::default()
1738 };
1739 let plan = MediaEngine::new().plan(&info, &smaller).unwrap();
1740 assert!(!plan.spec.passthrough);
1741 assert_eq!(plan.spec.audio.as_ref().unwrap().bitrate_bps, 32_000);
1742 assert!(
1743 plan.guard_larger,
1744 "quality mode keeps the post-encode check"
1745 );
1746
1747 let allow = ShrinkOpts {
1749 allow_larger: true,
1750 ..ShrinkOpts::default()
1751 };
1752 let plan = MediaEngine::new().plan(&info, &allow).unwrap();
1753 assert!(!plan.spec.passthrough && !plan.guard_larger);
1754 }
1755
1756 #[test]
1757 fn the_guard_is_for_quality_mode_only() {
1758 let info = video_info(60.0, 50_000_000, 1920, 1080, true);
1759 let quality = MediaEngine::new()
1760 .plan(&info, &ShrinkOpts::default())
1761 .unwrap();
1762 assert!(quality.guard_larger);
1763 let target = MediaEngine::new()
1764 .plan(&info, &opts_target(10_000_000))
1765 .unwrap();
1766 assert!(!target.guard_larger, "a size target is its own guarantee");
1767 }
1768
1769 #[test]
1770 fn plan_audio_single_pass_with_fitted_bitrate() {
1771 let info = audio_info(3480.0, 600_000_000, 2);
1773 let plan = MediaEngine::new()
1774 .plan(&info, &opts_target(10_000_000))
1775 .unwrap();
1776 assert!(plan.spec.audio_only);
1777 assert!(!plan.spec.two_pass);
1778 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.m4a"));
1779 let au = plan.spec.audio.as_ref().unwrap();
1780 assert!(budget::AUDIO_STEPS.contains(&au.bitrate_bps));
1782 assert!(plan.expected_bytes.unwrap() <= 10_000_000 + 10_000_000 / 20);
1783 }
1784
1785 #[test]
1786 fn plan_audio_mono_source_marked_speech() {
1787 let info = audio_info(600.0, 100_000_000, 1);
1788 let plan = MediaEngine::new()
1789 .plan(&info, &opts_target(5_000_000))
1790 .unwrap();
1791 assert!(plan.spec.audio.as_ref().unwrap().mono);
1792 }
1793
1794 #[test]
1795 fn plan_audio_opus_extension_and_vbr_args() {
1796 let info = audio_info(600.0, 100_000_000, 2);
1797 let opts = ShrinkOpts {
1798 audio_codec: AudioCodec::Opus,
1799 mono: true,
1800 ..opts_target(3_000_000)
1801 };
1802 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1803 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.opus"));
1804 let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog", enc(&plan), false);
1805 let j: Vec<String> = args
1806 .iter()
1807 .map(|a| a.to_string_lossy().into_owned())
1808 .collect();
1809 assert!(j.contains(&"-vn".to_string()));
1810 assert!(j.contains(&"libopus".to_string()));
1811 assert!(j.contains(&"-ac".to_string())); assert!(j.contains(&"-vbr".to_string()));
1813 }
1814
1815 #[test]
1816 fn plan_audio_infeasible_when_target_tiny() {
1817 let info = audio_info(3600.0, 500_000_000, 2);
1818 assert!(matches!(
1819 MediaEngine::new().plan(&info, &opts_target(1_000)),
1820 Err(EngineError::Infeasible)
1821 ));
1822 }
1823
1824 #[test]
1825 fn plan_audio_passthrough_when_source_fits() {
1826 let info = audio_info(600.0, 2_000_000, 2);
1827 let plan = MediaEngine::new()
1828 .plan(&info, &opts_target(10_000_000))
1829 .unwrap();
1830 assert!(plan.spec.passthrough);
1831 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.wav"));
1833 }
1834
1835 #[test]
1836 fn pass1_args_have_no_audio_and_null_sink() {
1837 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1838 let plan = MediaEngine::new()
1839 .plan(&info, &opts_target(8_000_000))
1840 .unwrap();
1841 let args = build_pass_args(&plan, PassKind::First, "/tmp/passlog", enc(&plan), false);
1842 let joined: Vec<String> = args
1843 .iter()
1844 .map(|a| a.to_string_lossy().into_owned())
1845 .collect();
1846 assert!(joined.contains(&"-an".to_string()));
1847 assert!(joined.contains(&"null".to_string()));
1848 assert!(joined.iter().any(|a| a == "1")); assert!(!joined.iter().any(|a| a.contains("shrink.mp4")));
1850 }
1851
1852 #[test]
1853 fn pass2_args_write_output_with_audio() {
1854 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1855 let plan = MediaEngine::new()
1856 .plan(&info, &opts_target(8_000_000))
1857 .unwrap();
1858 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan), false);
1859 let joined: Vec<String> = args
1860 .iter()
1861 .map(|a| a.to_string_lossy().into_owned())
1862 .collect();
1863 assert!(joined.iter().any(|a| a.contains("clip.shrink.mp4")));
1864 assert!(joined.contains(&"-c:a".to_string()));
1865 assert!(joined.iter().any(|a| a.contains("+faststart")));
1866 assert!(joined.iter().any(|a| a == "2")); }
1868
1869 fn joined(plan: &EncodePlan, pass: PassKind) -> Vec<String> {
1870 joined_with(plan, pass, false)
1871 }
1872
1873 fn joined_with(plan: &EncodePlan, pass: PassKind, zscale: bool) -> Vec<String> {
1874 build_pass_args(plan, pass, "/tmp/passlog", enc(plan), zscale)
1875 .iter()
1876 .map(|a| a.to_string_lossy().into_owned())
1877 .collect()
1878 }
1879
1880 #[test]
1881 fn hdr_transfer_is_recognised() {
1882 assert_eq!(Hdr::from_transfer("arib-std-b67"), Some(Hdr::Hlg));
1883 assert_eq!(Hdr::from_transfer("smpte2084"), Some(Hdr::Pq));
1884 assert_eq!(Hdr::from_transfer("bt709"), None);
1885 }
1886
1887 #[test]
1888 fn hdr_is_tone_mapped_to_sdr_for_size_targets_only() {
1889 let mut info = iphone_info();
1890 info.hdr = Some(Hdr::Hlg);
1891 let engine = MediaEngine::new();
1892
1893 let target = engine.plan(&info, &opts_target(10_000_000)).unwrap();
1895 assert_eq!(target.spec.video.to_sdr, Some(Hdr::Hlg));
1896 assert!(target.summary.contains("HDR → SDR"));
1897 let vf_of =
1898 |args: &[String]| args[args.iter().position(|a| a == "-vf").unwrap() + 1].clone();
1899 let hlg = joined_with(&target, PassKind::Second, true);
1901 let vf = vf_of(&hlg);
1902 assert!(
1903 vf.contains("colorspace=all=bt709") && !vf.contains("zscale"),
1904 "{vf}"
1905 );
1906 assert!(
1908 vf.find("scale=-2:").unwrap() < vf.find("colorspace").unwrap(),
1909 "{vf}"
1910 );
1911 for pair in [
1912 ["-pix_fmt", "yuv420p"],
1913 ["-color_trc", "bt709"],
1914 ["-colorspace", "bt709"],
1915 ] {
1916 assert!(hlg.windows(2).any(|w| w == pair), "{pair:?}");
1917 }
1918 let mut pq = target.clone();
1920 pq.spec.video.to_sdr = Some(Hdr::Pq);
1921 let vf = vf_of(&joined_with(&pq, PassKind::Second, true));
1922 assert!(
1923 vf.contains("tonemap=hable") && vf.contains("npl=100"),
1924 "{vf}"
1925 );
1926 let vf = vf_of(&joined_with(&pq, PassKind::Second, false));
1927 assert!(
1928 vf.contains("colorspace=all=bt709") && !vf.contains("zscale"),
1929 "{vf}"
1930 );
1931
1932 let quality = engine.plan(&info, &ShrinkOpts::default()).unwrap();
1934 assert_eq!(quality.spec.video.to_sdr, None);
1935 let args = joined(&quality, PassKind::Single);
1936 assert!(!args
1937 .iter()
1938 .any(|a| a == "-pix_fmt" || a.contains("colorspace")));
1939
1940 let sdr = engine
1942 .plan(&iphone_info(), &opts_target(10_000_000))
1943 .unwrap();
1944 assert_eq!(sdr.spec.video.to_sdr, None);
1945 assert!(!joined(&sdr, PassKind::Second)
1946 .iter()
1947 .any(|a| a == "-pix_fmt"));
1948 }
1949
1950 #[test]
1951 fn a_size_target_is_a_ceiling_at_the_quality_crf() {
1952 let info = video_info(60.0, 200_000_000, 1920, 1080, true);
1953 let engine = MediaEngine::new();
1954 let opts = opts_target(50_000_000);
1955 let plan = engine.plan(&info, &opts).unwrap();
1956 let crf = opts.quality.default_crf(opts.video_codec);
1957 assert_eq!(plan.ceiling_crf, Some(crf));
1958
1959 let ceiling = ceiling_plan(&plan).unwrap();
1960 assert_eq!(ceiling.spec.video.crf, Some(crf));
1961 assert_eq!(ceiling.spec.video.bitrate_bps, None);
1962 assert!(!ceiling.spec.two_pass);
1963 assert_eq!(ceiling.spec.video.height, plan.spec.video.height);
1965 assert_eq!(ceiling.spec.audio, plan.spec.audio);
1966 assert_eq!(ceiling.output, plan.output);
1967 assert_eq!(ceiling.target_bytes, plan.target_bytes);
1968
1969 let quality = engine.plan(&info, &ShrinkOpts::default()).unwrap();
1971 assert!(quality.ceiling_crf.is_none() && ceiling_plan(&quality).is_none());
1972 let fits = engine.plan(&info, &opts_target(300_000_000)).unwrap();
1973 assert!(fits.spec.passthrough && ceiling_plan(&fits).is_none());
1974 }
1975
1976 fn iphone_info() -> MediaInfo {
1977 let mut info = video_info(60.0, 50_000_000, 2160, 3840, true);
1978 info.path = PathBuf::from("/tmp/IMG_3325.MOV");
1979 info.capture = CaptureMeta {
1980 created_utc: to_utc("2026-09-26T20:01:54+0300"),
1981 created_local: Some("2026-09-26T20:01:54+0300".into()),
1982 location: Some("+50.4160+030.2796+155.635/".into()),
1983 make: Some("Apple".into()),
1984 model: Some("iPhone 12 Pro Max".into()),
1985 };
1986 info
1987 }
1988
1989 #[test]
1990 fn metadata_is_kept_by_default_and_strippable() {
1991 let info = iphone_info();
1992 let plan = MediaEngine::new()
1993 .plan(&info, &ShrinkOpts::default())
1994 .unwrap();
1995 let a = joined(&plan, PassKind::Single);
1996 let at = a.iter().position(|x| x == "-map_metadata").unwrap();
1997 assert_eq!(a[at + 1], "0");
1998 for tag in [
2001 "creation_time=2026-09-26T17:01:54Z",
2002 "location=+50.4160+030.2796+155.635/",
2003 "make=Apple",
2004 "model=iPhone 12 Pro Max",
2005 "date=2026-09-26T20:01:54+0300",
2006 ] {
2007 assert!(a.contains(&tag.to_string()), "{tag} in {a:?}");
2008 }
2009 assert!(a.contains(&"+faststart".to_string()));
2010
2011 let strip = ShrinkOpts {
2012 keep_metadata: false,
2013 ..ShrinkOpts::default()
2014 };
2015 let plan = MediaEngine::new().plan(&info, &strip).unwrap();
2016 let a = joined(&plan, PassKind::Single);
2017 let at = a.iter().position(|x| x == "-map_metadata").unwrap();
2018 assert_eq!(a[at + 1], "-1");
2019 assert!(!a.iter().any(|x| x.starts_with("location=")));
2020 assert!(a.contains(&"+faststart".to_string()));
2021 }
2022
2023 #[test]
2024 fn apple_local_time_converts_to_utc() {
2025 let utc = |s: &str| to_utc(s);
2026 assert_eq!(
2027 utc("2026-09-26T20:01:54+0300").as_deref(),
2028 Some("2026-09-26T17:01:54Z")
2029 );
2030 assert_eq!(
2031 utc("2026-09-26T20:01:54+03:00").as_deref(),
2032 Some("2026-09-26T17:01:54Z")
2033 );
2034 assert_eq!(
2035 utc("2026-01-01T01:30:00+0300").as_deref(),
2036 Some("2025-12-31T22:30:00Z")
2037 );
2038 assert_eq!(
2039 utc("2026-03-01T23:00:00-0500").as_deref(),
2040 Some("2026-03-02T04:00:00Z")
2041 );
2042 assert_eq!(
2043 utc("2024-02-29T12:00:00.123Z").as_deref(),
2044 Some("2024-02-29T12:00:00Z")
2045 );
2046 assert_eq!(utc("yesterday"), None);
2047 }
2048
2049 #[test]
2050 fn an_iphone_mov_stays_mov_in_quality_mode_only() {
2051 let info = iphone_info();
2052 let out = |opts: &ShrinkOpts| {
2053 let plan = MediaEngine::new().plan(&info, opts).unwrap();
2054 plan.output.to_string_lossy().into_owned()
2055 };
2056 assert!(out(&ShrinkOpts::default()).ends_with(".shrink.mov"));
2057 assert!(out(&opts_target(8_000_000)).ends_with(".shrink.mp4"));
2059 let av1 = ShrinkOpts {
2061 video_codec: VideoCodec::Av1,
2062 ..ShrinkOpts::default()
2063 };
2064 assert!(out(&av1).ends_with(".shrink.mp4"));
2065 let mp4 = video_info(60.0, 50_000_000, 1920, 1080, true);
2067 let p = MediaEngine::new()
2068 .plan(&mp4, &ShrinkOpts::default())
2069 .unwrap();
2070 assert!(p.output.to_string_lossy().ends_with(".shrink.mp4"));
2071 }
2072
2073 #[test]
2074 fn a_video_audio_track_is_never_upsampled() {
2075 let mut info = video_info(60.0, 50_000_000, 1920, 1080, true);
2076 info.audio_bitrate_bps = Some(64_000);
2077 let bps_of = |info: &MediaInfo, opts: &ShrinkOpts| {
2078 let plan = MediaEngine::new().plan(info, opts).unwrap();
2079 plan.spec.audio.unwrap().bitrate_bps
2080 };
2081 assert_eq!(bps_of(&info, &ShrinkOpts::default()), 64_000);
2083 assert!(bps_of(&info, &opts_target(20_000_000)) <= 64_000);
2085 let explicit = ShrinkOpts {
2087 audio: AudioChoice::Bitrate(128_000),
2088 ..ShrinkOpts::default()
2089 };
2090 assert_eq!(bps_of(&info, &explicit), 128_000);
2091 info.audio_bitrate_bps = None;
2093 assert_eq!(
2094 bps_of(&info, &ShrinkOpts::default()),
2095 budget::DEFAULT_AUDIO_BPS
2096 );
2097 }
2098
2099 #[test]
2100 fn h265_adds_hvc1_tag() {
2101 let info = video_info(60.0, 100_000_000, 1280, 720, false);
2102 let opts = ShrinkOpts {
2103 video_codec: VideoCodec::H265,
2104 ..opts_target(8_000_000)
2105 };
2106 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
2107 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan), false);
2108 let joined: Vec<String> = args
2109 .iter()
2110 .map(|a| a.to_string_lossy().into_owned())
2111 .collect();
2112 assert!(joined.contains(&"hvc1".to_string()));
2113 assert!(joined.contains(&"libx265".to_string()));
2114 }
2115}