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