1use std::ffi::OsString;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use super::{
14 AudioSpec, EncodePlan, EncodeSpec, Engine, EngineError, MediaInfo, Outcome, ShrinkOpts,
15 SizeGoal, VideoSpec,
16};
17use crate::budget;
18use crate::detect::{detect_kind, MediaKind};
19use crate::options::{AudioChoice, AudioCodec, FpsOpt, ResolutionOpt};
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 tools = deepshrink_ffmpeg::locate()?;
50 let encoder = resolve_encoder(&tools, plan)?;
51
52 if let Some(target_vmaf) = plan.target_vmaf {
55 if plan.spec.video.crf.is_some() && !plan.spec.audio_only && !plan.spec.passthrough {
56 return self.run_crf_search(&tools, plan, encoder, target_vmaf, on_progress);
57 }
58 }
59
60 let mut outcome = self.run_plain(&tools, plan, encoder, on_progress)?;
61
62 if plan.target_vmaf.is_some() && !plan.spec.audio_only && !plan.spec.passthrough {
65 outcome.vmaf = self.measure_output(&tools, plan, &plan.output);
66 }
67 Ok(outcome)
68 }
69
70 fn run_plain(
73 &self,
74 tools: &deepshrink_ffmpeg::Tools,
75 plan: &EncodePlan,
76 encoder: &str,
77 on_progress: &mut dyn FnMut(PassKind, f64),
78 ) -> Result<Outcome, EngineError> {
79 let passlog = passlog_base(plan);
80 let total = plan.source_duration_sec;
81
82 if plan.spec.passthrough {
83 return self.run_passthrough(tools, plan, on_progress);
84 }
85
86 if plan.spec.two_pass {
87 let args1 = build_pass_args(plan, PassKind::First, &passlog, encoder);
88 deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args1, total, &mut |f| {
89 on_progress(PassKind::First, f)
90 })?;
91 let args2 = build_pass_args(plan, PassKind::Second, &passlog, encoder);
92 deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args2, total, &mut |f| {
93 on_progress(PassKind::Second, f)
94 })?;
95 } else {
96 let args = build_pass_args(plan, PassKind::Single, &passlog, encoder);
97 deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, total, &mut |f| {
98 on_progress(PassKind::Single, f)
99 })?;
100 }
101
102 let mut size = fs::metadata(&plan.output)?.len();
103
104 if let (Some(target), Some(vbps)) = (plan.target_bytes, plan.spec.video.bitrate_bps) {
107 if size > target && plan.spec.two_pass {
108 let corrected = (vbps as f64 * (target as f64 / size as f64) * 0.97) as u64;
109 if corrected >= budget::ABSOLUTE_MIN_VIDEO_BPS {
110 let mut retry = plan.clone();
111 retry.spec.video.bitrate_bps = Some(corrected);
112 let args = build_pass_args(&retry, PassKind::Second, &passlog, encoder);
113 deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, total, &mut |f| {
114 on_progress(PassKind::Second, f)
115 })?;
116 size = fs::metadata(&plan.output)?.len();
117 }
118 }
119 }
120
121 cleanup_passlog(&passlog);
122 Ok(Outcome {
123 output: plan.output.clone(),
124 final_bytes: size,
125 vmaf: None,
126 })
127 }
128
129 fn run_passthrough(
138 &self,
139 tools: &deepshrink_ffmpeg::Tools,
140 plan: &EncodePlan,
141 on_progress: &mut dyn FnMut(PassKind, f64),
142 ) -> Result<Outcome, EngineError> {
143 let args = build_pass_args(plan, PassKind::Single, "", "copy");
145 let remuxed =
146 deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, plan.source_duration_sec, &mut |f| {
147 on_progress(PassKind::Single, f)
148 });
149 if remuxed.is_err() {
150 fs::copy(&plan.input, &plan.output)?;
151 on_progress(PassKind::Single, 1.0);
152 }
153 let size = fs::metadata(&plan.output)?.len();
154 Ok(Outcome {
155 output: plan.output.clone(),
156 final_bytes: size,
157 vmaf: None,
158 })
159 }
160
161 fn run_crf_search(
168 &self,
169 tools: &deepshrink_ffmpeg::Tools,
170 plan: &EncodePlan,
171 encoder: &str,
172 target_vmaf: f64,
173 on_progress: &mut dyn FnMut(PassKind, f64),
174 ) -> Result<Outcome, EngineError> {
175 let (ref_w, ref_h) = match (plan.source_width, plan.source_height) {
176 (Some(w), Some(h)) => (w, h),
177 _ => return self.run_plain(tools, plan, encoder, on_progress),
178 };
179 let ref_fps = plan.source_fps.unwrap_or(0.0);
180 let total = plan.source_duration_sec;
181 let (lo, hi) = plan.spec.video.codec.crf_search_bounds();
182 let n_threads = thread_count();
183
184 let mut err: Option<EngineError> = None;
185 let mut last_crf: Option<u8> = None;
186
187 let (chosen_crf, chosen_vmaf) = budget::search_crf(target_vmaf, lo, hi, |crf| {
188 if err.is_some() {
189 return f64::NEG_INFINITY;
190 }
191 match encode_at_crf(tools, plan, encoder, crf, total, on_progress).and_then(|()| {
192 last_crf = Some(crf);
193 deepshrink_ffmpeg::measure_vmaf(
194 &tools.ffmpeg,
195 &plan.output,
196 &plan.input,
197 ref_w,
198 ref_h,
199 ref_fps,
200 n_threads,
201 )
202 .map_err(EngineError::from)
203 }) {
204 Ok(v) => v,
205 Err(e) => {
206 err = Some(e);
207 f64::NEG_INFINITY
208 }
209 }
210 });
211 if let Some(e) = err {
212 return Err(e);
213 }
214
215 if last_crf != Some(chosen_crf) {
217 encode_at_crf(tools, plan, encoder, chosen_crf, total, on_progress)?;
218 }
219 let size = fs::metadata(&plan.output)?.len();
220 Ok(Outcome {
221 output: plan.output.clone(),
222 final_bytes: size,
223 vmaf: Some(chosen_vmaf),
224 })
225 }
226
227 fn measure_output(
230 &self,
231 tools: &deepshrink_ffmpeg::Tools,
232 plan: &EncodePlan,
233 output: &Path,
234 ) -> Option<f64> {
235 let (w, h) = (plan.source_width?, plan.source_height?);
236 let fps = plan.source_fps.unwrap_or(0.0);
237 deepshrink_ffmpeg::measure_vmaf(
238 &tools.ffmpeg,
239 output,
240 &plan.input,
241 w,
242 h,
243 fps,
244 thread_count(),
245 )
246 .ok()
247 }
248
249 fn plan_audio(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
251 let duration = info.duration_sec;
252 if !duration.is_finite() || duration <= 0.0 {
253 return Err(EngineError::Unsupported(format!(
254 "could not determine duration of {}",
255 info.path.display()
256 )));
257 }
258 let codec = opts.audio_codec;
259 let target = target_bytes(&opts.goal, info.size_bytes);
260
261 if let Some(tb) = target {
263 if info.size_bytes > 0 && info.size_bytes <= tb {
264 let src_ext = info
265 .path
266 .extension()
267 .and_then(|e| e.to_str())
268 .unwrap_or("audio");
269 let output = opts
270 .output
271 .clone()
272 .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
273 return Ok(passthrough_plan(info, output, tb, false));
274 }
275 }
276
277 let mono = opts.mono || info.audio_channels == Some(1);
279
280 let (bitrate_bps, expected_bytes) = match target {
281 Some(tb) => {
282 let raw = budget::audio_bitrate_bps(tb, duration).ok_or(EngineError::Infeasible)?;
283 if raw < budget::ABSOLUTE_MIN_AUDIO_BPS {
284 return Err(EngineError::Infeasible);
285 }
286 let bps = budget::snap_audio_bitrate(raw);
287 let predicted = (bps as f64 * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD))
288 .round() as u64;
289 (bps, Some(predicted))
290 }
291 None => {
292 let bps = if mono { 96_000 } else { 160_000 };
294 (bps, None)
295 }
296 };
297
298 let audio = AudioSpec {
299 codec,
300 bitrate_bps,
301 mono,
302 sample_rate: opts.sample_rate,
303 vbr: opts.vbr,
304 };
305 let output = opts
306 .output
307 .clone()
308 .unwrap_or_else(|| output_with_ext(&info.path, codec.extension()));
309 let summary = build_audio_summary(&audio, info.audio_channels);
310
311 Ok(EncodePlan {
312 input: info.path.clone(),
313 output,
314 summary,
315 expected_bytes,
316 target_bytes: target,
317 target_vmaf: None,
318 source_duration_sec: duration,
319 source_width: info.width,
320 source_height: info.height,
321 source_fps: info.fps,
322 spec: EncodeSpec {
323 video: placeholder_video_spec(),
324 audio: Some(audio),
325 faststart: false,
326 two_pass: false,
327 passthrough: false,
328 audio_only: true,
329 dpi: None,
330 },
331 })
332 }
333}
334
335impl Engine for MediaEngine {
336 fn supports(&self, input: &Path) -> bool {
337 matches!(detect_kind(input), MediaKind::Video | MediaKind::Audio)
338 }
339
340 fn probe(&self, input: &Path) -> Result<MediaInfo, EngineError> {
341 let tools = deepshrink_ffmpeg::locate()?;
342 let p = deepshrink_ffmpeg::probe(&tools.ffprobe, input)?;
343
344 let video = p.video_stream();
345 let audio = p.audio_stream();
346 let size_bytes = p
348 .size_bytes()
349 .or_else(|| fs::metadata(input).ok().map(|m| m.len()))
350 .unwrap_or(0);
351
352 Ok(MediaInfo {
353 path: input.to_path_buf(),
354 kind: detect_kind(input),
355 duration_sec: p.duration_sec().unwrap_or(0.0),
356 size_bytes,
357 width: video.and_then(|v| v.width),
358 height: video.and_then(|v| v.height),
359 fps: p.fps(),
360 video_codec: video.and_then(|v| v.codec_name.clone()),
361 audio_codec: audio.and_then(|a| a.codec_name.clone()),
362 audio_channels: audio.and_then(|a| a.channels),
363 })
364 }
365
366 fn plan(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
367 match info.kind {
368 MediaKind::Audio => return self.plan_audio(info, opts),
369 MediaKind::Unsupported => {
370 return Err(EngineError::Unsupported(format!(
371 "{} is not a supported media file",
372 info.path.display()
373 )))
374 }
375 MediaKind::Video => {}
376 }
377 let duration = info.duration_sec;
378 if !duration.is_finite() || duration <= 0.0 {
379 return Err(EngineError::Unsupported(format!(
380 "could not determine duration of {}",
381 info.path.display()
382 )));
383 }
384 let src_height = info.height.unwrap_or(0);
385
386 let target = target_bytes(&opts.goal, info.size_bytes);
387 let output = opts
388 .output
389 .clone()
390 .unwrap_or_else(|| output_with_ext(&info.path, "mp4"));
391
392 if let Some(tb) = target {
398 if info.size_bytes > 0 && info.size_bytes <= tb {
399 let src_ext = info
400 .path
401 .extension()
402 .and_then(|e| e.to_str())
403 .unwrap_or("mp4");
404 let output = opts
405 .output
406 .clone()
407 .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
408 return Ok(passthrough_plan(info, output, tb, true));
409 }
410 }
411
412 let audio = decide_audio(opts, info.has_audio(), target, duration)?;
413 let audio_bps = audio.as_ref().map(|a| a.bitrate_bps).unwrap_or(0);
414
415 let (video, expected_bytes) = if let Some(tb) = target {
416 let vbps = budget::video_bitrate_bps(tb, duration, audio_bps)
417 .filter(|&b| b >= budget::ABSOLUTE_MIN_VIDEO_BPS)
418 .ok_or(EngineError::Infeasible)?;
419 let height = pick_height(opts.resolution, src_height, vbps);
420 let predicted = ((vbps + audio_bps) as f64 * duration / 8.0
421 * (1.0 + budget::CONTAINER_OVERHEAD))
422 .round() as u64;
423 (
424 VideoSpec {
425 codec: opts.video_codec,
426 bitrate_bps: Some(vbps),
427 crf: None,
428 height,
429 fps: pick_fps(opts.fps, info.fps),
430 preset: opts.quality,
431 },
432 Some(predicted),
433 )
434 } else {
435 let crf = opts.quality.default_crf(opts.video_codec);
438 let height = match opts.resolution {
439 ResolutionOpt::Height(h) => clamp_height(h, src_height),
440 ResolutionOpt::Auto => None,
441 };
442 (
443 VideoSpec {
444 codec: opts.video_codec,
445 bitrate_bps: None,
446 crf: Some(crf),
447 height,
448 fps: pick_fps(opts.fps, info.fps),
449 preset: opts.quality,
450 },
451 None,
452 )
453 };
454
455 let two_pass = video.bitrate_bps.is_some() && opts.two_pass.unwrap_or(true);
459 let summary = build_summary(&video, audio.as_ref(), two_pass);
460
461 Ok(EncodePlan {
462 input: info.path.clone(),
463 output,
464 summary,
465 expected_bytes,
466 target_bytes: target,
467 target_vmaf: opts.target_vmaf,
468 source_duration_sec: duration,
469 source_width: info.width,
470 source_height: info.height,
471 source_fps: info.fps,
472 spec: EncodeSpec {
473 video,
474 audio,
475 faststart: true,
476 two_pass,
477 passthrough: false,
478 audio_only: false,
479 dpi: None,
480 },
481 })
482 }
483
484 fn run(&self, plan: &EncodePlan) -> Result<Outcome, EngineError> {
485 self.run_with_progress(plan, &mut |_, _| {})
486 }
487}
488
489fn placeholder_video_spec() -> VideoSpec {
491 VideoSpec {
492 codec: crate::options::VideoCodec::H264,
493 bitrate_bps: None,
494 crf: None,
495 height: None,
496 fps: None,
497 preset: crate::options::QualityPreset::Balanced,
498 }
499}
500
501fn passthrough_plan(info: &MediaInfo, output: PathBuf, target: u64, faststart: bool) -> EncodePlan {
504 EncodePlan {
505 input: info.path.clone(),
506 output,
507 summary: "stream copy (already within target)".to_string(),
508 expected_bytes: Some(info.size_bytes),
509 target_bytes: Some(target),
510 target_vmaf: None,
511 source_duration_sec: info.duration_sec,
512 source_width: info.width,
513 source_height: info.height,
514 source_fps: info.fps,
515 spec: EncodeSpec {
516 video: placeholder_video_spec(),
517 audio: None,
518 faststart,
519 two_pass: false,
520 passthrough: true,
521 audio_only: false,
522 dpi: None,
523 },
524 }
525}
526
527fn build_audio_summary(audio: &AudioSpec, src_channels: Option<u32>) -> String {
530 let mut parts = vec![
531 audio.codec.label().to_string(),
532 format!("{} kbps", audio.bitrate_bps / 1000),
533 ];
534 if audio.mono {
535 let note = if src_channels == Some(1) {
537 "mono"
538 } else {
539 "mono (downmix)"
540 };
541 parts.push(note.to_string());
542 }
543 if let Some(sr) = audio.sample_rate {
544 parts.push(format!("{} Hz", sr));
545 }
546 parts.join(" · ")
547}
548
549fn target_bytes(goal: &SizeGoal, original: u64) -> Option<u64> {
551 match goal {
552 SizeGoal::Target(b) => Some(*b),
553 SizeGoal::Reduce(f) => Some(budget::reduce_target_bytes(original, *f)),
554 SizeGoal::Preset(p) => p.limit_bytes,
555 SizeGoal::Quality => None,
556 }
557}
558
559fn decide_audio(
561 opts: &ShrinkOpts,
562 has_audio: bool,
563 target: Option<u64>,
564 duration: f64,
565) -> Result<Option<AudioSpec>, EngineError> {
566 if !has_audio {
567 return Ok(None);
568 }
569 let mono = opts.mono;
572 match opts.audio {
573 AudioChoice::Drop => Ok(None),
574 AudioChoice::Bitrate(b) => Ok(Some(AudioSpec {
575 mono,
576 ..AudioSpec::cbr(AudioCodec::Aac, b)
577 })),
578 AudioChoice::Keep => {
579 let bps = match target {
580 Some(tb) => budget::fit_audio_bps(tb, duration, AUDIO_LADDER)
581 .ok_or(EngineError::Infeasible)?,
582 None => budget::DEFAULT_AUDIO_BPS,
583 };
584 Ok(Some(AudioSpec {
585 mono,
586 ..AudioSpec::cbr(AudioCodec::Aac, bps)
587 }))
588 }
589 }
590}
591
592fn pick_height(res: ResolutionOpt, src_height: u32, vbps: u64) -> Option<u32> {
594 match res {
595 ResolutionOpt::Height(h) => clamp_height(h, src_height),
596 ResolutionOpt::Auto => {
597 let chosen = budget::choose_height(src_height, vbps);
598 if src_height > 0 && chosen < src_height {
599 Some(chosen)
600 } else {
601 None
602 }
603 }
604 }
605}
606
607fn clamp_height(requested: u32, src_height: u32) -> Option<u32> {
610 if src_height == 0 {
611 return Some(requested);
612 }
613 let h = requested.min(src_height);
614 if h == src_height {
615 None
616 } else {
617 Some(h)
618 }
619}
620
621fn pick_fps(fps: FpsOpt, src_fps: Option<f64>) -> Option<u32> {
623 match fps {
624 FpsOpt::Auto => None,
625 FpsOpt::Cap(f) => match src_fps {
626 Some(src) if (f as f64) >= src => None,
627 _ => Some(f),
628 },
629 }
630}
631
632fn output_with_ext(input: &Path, ext: &str) -> PathBuf {
634 let stem = input
635 .file_stem()
636 .map(|s| s.to_string_lossy().into_owned())
637 .unwrap_or_else(|| "output".to_string());
638 let mut out = input.parent().map(Path::to_path_buf).unwrap_or_default();
639 out.push(format!("{stem}.shrink.{ext}"));
640 out
641}
642
643fn build_summary(video: &VideoSpec, audio: Option<&AudioSpec>, two_pass: bool) -> String {
644 let mut parts = vec![video.codec.label().to_string()];
645 match (video.bitrate_bps, video.crf) {
646 (Some(bps), _) => parts.push(format!("{} kbps video", bps / 1000)),
647 (_, Some(crf)) => parts.push(format!("CRF {crf}")),
648 _ => {}
649 }
650 if let Some(a) = audio {
651 parts.push(format!("{} kbps audio", a.bitrate_bps / 1000));
652 } else {
653 parts.push("no audio".to_string());
654 }
655 if let Some(h) = video.height {
656 parts.push(format!("{h}p"));
657 }
658 if let Some(f) = video.fps {
659 parts.push(format!("{f} fps"));
660 }
661 parts.push(if two_pass { "two-pass" } else { "CRF" }.to_string());
662 parts.join(" · ")
663}
664
665fn passlog_base(plan: &EncodePlan) -> String {
667 let stem = plan
668 .input
669 .file_stem()
670 .map(|s| s.to_string_lossy().into_owned())
671 .unwrap_or_else(|| "ds".to_string());
672 let dir = std::env::temp_dir();
673 dir.join(format!("deepshrink-{}-{}", std::process::id(), stem))
674 .to_string_lossy()
675 .into_owned()
676}
677
678fn cleanup_passlog(base: &str) {
680 for suffix in ["-0.log", "-0.log.mbtree"] {
681 let _ = fs::remove_file(format!("{base}{suffix}"));
682 }
683}
684
685fn encode_at_crf(
687 tools: &deepshrink_ffmpeg::Tools,
688 plan: &EncodePlan,
689 encoder: &str,
690 crf: u8,
691 total: f64,
692 on_progress: &mut dyn FnMut(PassKind, f64),
693) -> Result<(), EngineError> {
694 let mut trial = plan.clone();
695 trial.spec.video.crf = Some(crf);
696 trial.spec.video.bitrate_bps = None;
697 trial.spec.two_pass = false;
698 let args = build_pass_args(&trial, PassKind::Single, "", encoder);
699 deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, total, &mut |f| {
700 on_progress(PassKind::Single, f)
701 })?;
702 Ok(())
703}
704
705fn thread_count() -> usize {
707 std::thread::available_parallelism()
708 .map(|n| n.get())
709 .unwrap_or(1)
710}
711
712fn null_sink() -> &'static str {
714 if cfg!(windows) {
715 "NUL"
716 } else {
717 "/dev/null"
718 }
719}
720
721fn resolve_encoder(
729 tools: &deepshrink_ffmpeg::Tools,
730 plan: &EncodePlan,
731) -> Result<&'static str, EngineError> {
732 let codec = plan.spec.video.codec;
733 let primary = codec.encoder();
734 let Some(fallback) = codec.fallback_encoder() else {
735 return Ok(primary);
736 };
737 if plan.spec.passthrough || plan.spec.audio_only {
739 return Ok(primary);
740 }
741 if deepshrink_ffmpeg::has_encoder(&tools.ffmpeg, primary) {
742 return Ok(primary);
743 }
744 if deepshrink_ffmpeg::has_encoder(&tools.ffmpeg, fallback) {
745 return Ok(fallback);
746 }
747 Err(EngineError::Unsupported(format!(
748 "this ffmpeg build has no {} encoder (looked for {primary} and {fallback})",
749 codec.label()
750 )))
751}
752
753fn build_pass_args(
758 plan: &EncodePlan,
759 pass: PassKind,
760 passlog: &str,
761 encoder: &str,
762) -> Vec<OsString> {
763 let s = &plan.spec;
764 let mut a: Vec<OsString> = Vec::new();
765 macro_rules! push {
768 ($arg:expr) => {
769 a.push(OsString::from($arg))
770 };
771 }
772
773 push!("-hide_banner");
774 push!("-y");
775 push!("-loglevel");
776 push!("error");
777 push!("-progress");
778 push!("pipe:1");
779 push!("-nostats");
780 push!("-i");
781 a.push(plan.input.clone().into_os_string());
782
783 if s.passthrough {
785 push!("-c");
786 push!("copy");
787 if s.faststart {
788 push!("-movflags");
789 push!("+faststart");
790 }
791 a.push(plan.output.clone().into_os_string());
792 return a;
793 }
794
795 if s.audio_only {
797 push!("-vn");
798 if let Some(au) = &s.audio {
799 push!("-c:a");
800 push!(au.codec.encoder());
801 if au.mono {
802 push!("-ac");
803 push!("1");
804 }
805 if let Some(sr) = au.sample_rate {
806 push!("-ar");
807 push!(sr.to_string());
808 }
809 push!("-b:a");
810 push!(au.bitrate_bps.to_string());
811 if matches!(au.codec, AudioCodec::Opus) {
814 push!("-vbr");
815 push!(if au.vbr { "on" } else { "constrained" });
816 }
817 }
818 a.push(plan.output.clone().into_os_string());
819 return a;
820 }
821
822 push!("-c:v");
824 push!(encoder);
825 if let Some(h) = s.video.height {
826 push!("-vf");
827 push!(format!("scale=-2:{h}"));
828 }
829 if let Some(f) = s.video.fps {
830 push!("-r");
831 push!(f.to_string());
832 }
833 let (speed_flag, speed_value) = s.video.preset.speed_flags(encoder);
836 push!(speed_flag);
837 push!(speed_value);
838 if let Some(tag) = s.video.codec.mp4_tag() {
839 push!("-tag:v");
840 push!(tag);
841 }
842
843 match (s.video.bitrate_bps, s.video.crf) {
845 (Some(bps), _) => {
846 push!("-b:v");
847 push!(bps.to_string());
848 if s.two_pass {
849 push!("-pass");
850 push!(match pass {
851 PassKind::First => "1",
852 _ => "2",
853 });
854 push!("-passlogfile");
855 push!(passlog);
856 }
857 }
858 (_, Some(crf)) => {
859 push!("-crf");
860 push!(crf.to_string());
861 }
862 _ => {}
863 }
864
865 match pass {
867 PassKind::First => {
868 push!("-an");
870 push!("-f");
871 push!("null");
872 push!(null_sink());
873 }
874 PassKind::Second | PassKind::Single => {
875 match &s.audio {
876 Some(au) => {
877 push!("-c:a");
878 push!(au.codec.encoder());
879 if au.mono {
883 push!("-ac");
884 push!("1");
885 }
886 push!("-b:a");
887 push!(au.bitrate_bps.to_string());
888 }
889 None => push!("-an"),
890 }
891 if s.faststart {
892 push!("-movflags");
893 push!("+faststart");
894 }
895 a.push(plan.output.clone().into_os_string());
896 }
897 }
898 a
899}
900
901#[cfg(test)]
902mod tests {
903 use super::*;
904 use crate::options::{AudioCodec, QualityPreset, VideoCodec};
905 use crate::size::preset;
906
907 fn enc(plan: &EncodePlan) -> &'static str {
910 plan.spec.video.codec.encoder()
911 }
912
913 fn video_info(duration: f64, size: u64, w: u32, h: u32, audio: bool) -> MediaInfo {
914 MediaInfo {
915 path: PathBuf::from("/tmp/clip.mp4"),
916 kind: MediaKind::Video,
917 duration_sec: duration,
918 size_bytes: size,
919 width: Some(w),
920 height: Some(h),
921 fps: Some(30.0),
922 video_codec: Some("h264".into()),
923 audio_codec: if audio { Some("aac".into()) } else { None },
924 audio_channels: if audio { Some(2) } else { None },
925 }
926 }
927
928 fn opts_target(bytes: u64) -> ShrinkOpts {
929 ShrinkOpts {
930 goal: SizeGoal::Target(bytes),
931 ..Default::default()
932 }
933 }
934
935 #[test]
936 fn supports_video_and_audio() {
937 let e = MediaEngine::new();
938 assert!(e.supports(&PathBuf::from("clip.mp4")));
939 assert!(e.supports(&PathBuf::from("lecture.wav")));
940 assert!(!e.supports(&PathBuf::from("photo.jpg")));
941 }
942
943 #[test]
944 fn plan_target_builds_two_pass_with_budget() {
945 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
946 let plan = MediaEngine::new()
947 .plan(&info, &opts_target(8_000_000))
948 .unwrap();
949
950 assert!(plan.spec.two_pass);
951 assert_eq!(plan.target_bytes, Some(8_000_000));
952 assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
953 let vbps = plan.spec.video.bitrate_bps.unwrap();
954 assert!(vbps >= budget::ABSOLUTE_MIN_VIDEO_BPS);
955 assert!(plan.spec.video.height.is_some());
957 assert!(plan.spec.audio.is_some());
958 assert!(plan.expected_bytes.unwrap() <= 8_000_000 + 8_000_000 / 20);
960 }
961
962 #[test]
963 fn plan_video_mono_downmixes_the_audio_track() {
964 let info = video_info(60.0, 100_000_000, 1280, 720, true);
965 let opts = ShrinkOpts {
966 mono: true,
967 ..opts_target(8_000_000)
968 };
969 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
970 let audio = plan.spec.audio.as_ref().expect("kept audio track");
971 assert!(audio.mono, "opts.mono downmixes the video's audio track");
972 let stereo = MediaEngine::new()
974 .plan(&info, &opts_target(8_000_000))
975 .unwrap();
976 assert!(!stereo.spec.audio.as_ref().unwrap().mono);
977 }
978
979 #[test]
980 fn video_mono_reaches_ffmpeg_as_ac_1() {
981 let info = video_info(60.0, 100_000_000, 1280, 720, true);
984 let plan = MediaEngine::new()
985 .plan(
986 &info,
987 &ShrinkOpts {
988 mono: true,
989 ..opts_target(8_000_000)
990 },
991 )
992 .unwrap();
993 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan));
994 let joined: Vec<String> = args
995 .iter()
996 .map(|a| a.to_string_lossy().into_owned())
997 .collect();
998 let ac = joined.iter().position(|a| a == "-ac").expect("-ac emitted");
999 assert_eq!(joined[ac + 1], "1");
1000
1001 let stereo = MediaEngine::new()
1003 .plan(&info, &opts_target(8_000_000))
1004 .unwrap();
1005 let stereo_args: Vec<String> =
1006 build_pass_args(&stereo, PassKind::Second, "/tmp/passlog", enc(&stereo))
1007 .iter()
1008 .map(|a| a.to_string_lossy().into_owned())
1009 .collect();
1010 assert!(!stereo_args.iter().any(|a| a == "-ac"));
1011 }
1012
1013 #[test]
1014 fn plan_preset_discord_sets_target() {
1015 let info = video_info(30.0, 50_000_000, 1280, 720, true);
1016 let opts = ShrinkOpts {
1017 goal: SizeGoal::Preset(preset("discord").unwrap()),
1018 ..Default::default()
1019 };
1020 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1021 assert_eq!(plan.target_bytes, Some(8_000_000));
1022 }
1023
1024 #[test]
1025 fn plan_reduce_targets_complement_of_original() {
1026 let info = video_info(60.0, 100_000_000, 1920, 1080, true);
1027 let opts = ShrinkOpts {
1028 goal: SizeGoal::Reduce(0.70),
1029 ..Default::default()
1030 };
1031 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1032 assert_eq!(plan.target_bytes, Some(30_000_000));
1033 }
1034
1035 #[test]
1036 fn plan_passthrough_when_source_already_fits() {
1037 let info = video_info(10.0, 200_000, 1280, 720, true);
1039 let plan = MediaEngine::new()
1040 .plan(&info, &opts_target(1_000_000))
1041 .unwrap();
1042 assert!(plan.spec.passthrough);
1043 assert!(!plan.spec.two_pass);
1044 assert_eq!(plan.expected_bytes, Some(200_000));
1045 let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog", enc(&plan));
1046 let joined: Vec<String> = args
1047 .iter()
1048 .map(|a| a.to_string_lossy().into_owned())
1049 .collect();
1050 assert!(joined.contains(&"copy".to_string()));
1051 assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
1054 }
1055
1056 #[test]
1057 fn plan_passthrough_keeps_the_source_container() {
1058 let info = MediaInfo {
1061 path: PathBuf::from("/tmp/voice.3gp"),
1062 ..video_info(10.0, 200_000, 320, 240, true)
1063 };
1064 let plan = MediaEngine::new()
1065 .plan(&info, &opts_target(1_000_000))
1066 .unwrap();
1067 assert!(plan.spec.passthrough);
1068 assert_eq!(plan.output, PathBuf::from("/tmp/voice.shrink.3gp"));
1069 }
1070
1071 #[test]
1072 fn plan_infeasible_when_target_too_small() {
1073 let info = video_info(600.0, 500_000_000, 1920, 1080, true);
1074 let err = MediaEngine::new().plan(&info, &opts_target(50_000));
1075 assert!(matches!(err, Err(EngineError::Infeasible)));
1076 }
1077
1078 #[test]
1079 fn plan_quality_mode_uses_crf_single_pass() {
1080 let info = video_info(60.0, 100_000_000, 1920, 1080, true);
1081 let opts = ShrinkOpts {
1082 goal: SizeGoal::Quality,
1083 quality: QualityPreset::Balanced,
1084 ..Default::default()
1085 };
1086 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1087 assert!(!plan.spec.two_pass);
1088 assert_eq!(plan.spec.video.crf, Some(23));
1089 assert!(plan.spec.video.bitrate_bps.is_none());
1090 assert!(plan.expected_bytes.is_none());
1091 }
1092
1093 #[test]
1094 fn plan_drops_audio_when_requested() {
1095 let info = video_info(30.0, 50_000_000, 1280, 720, true);
1096 let opts = ShrinkOpts {
1097 audio: AudioChoice::Drop,
1098 ..opts_target(8_000_000)
1099 };
1100 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1101 assert!(plan.spec.audio.is_none());
1102 }
1103
1104 fn audio_info(duration: f64, size: u64, channels: u32) -> MediaInfo {
1105 MediaInfo {
1106 path: PathBuf::from("/tmp/lecture.wav"),
1107 kind: MediaKind::Audio,
1108 duration_sec: duration,
1109 size_bytes: size,
1110 width: None,
1111 height: None,
1112 fps: None,
1113 video_codec: None,
1114 audio_codec: Some("pcm_s16le".into()),
1115 audio_channels: Some(channels),
1116 }
1117 }
1118
1119 #[test]
1120 fn plan_audio_single_pass_with_fitted_bitrate() {
1121 let info = audio_info(3480.0, 600_000_000, 2);
1123 let plan = MediaEngine::new()
1124 .plan(&info, &opts_target(10_000_000))
1125 .unwrap();
1126 assert!(plan.spec.audio_only);
1127 assert!(!plan.spec.two_pass);
1128 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.m4a"));
1129 let au = plan.spec.audio.as_ref().unwrap();
1130 assert!(budget::AUDIO_STEPS.contains(&au.bitrate_bps));
1132 assert!(plan.expected_bytes.unwrap() <= 10_000_000 + 10_000_000 / 20);
1133 }
1134
1135 #[test]
1136 fn plan_audio_mono_source_marked_speech() {
1137 let info = audio_info(600.0, 100_000_000, 1);
1138 let plan = MediaEngine::new()
1139 .plan(&info, &opts_target(5_000_000))
1140 .unwrap();
1141 assert!(plan.spec.audio.as_ref().unwrap().mono);
1142 }
1143
1144 #[test]
1145 fn plan_audio_opus_extension_and_vbr_args() {
1146 let info = audio_info(600.0, 100_000_000, 2);
1147 let opts = ShrinkOpts {
1148 audio_codec: AudioCodec::Opus,
1149 mono: true,
1150 ..opts_target(3_000_000)
1151 };
1152 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1153 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.opus"));
1154 let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog", enc(&plan));
1155 let j: Vec<String> = args
1156 .iter()
1157 .map(|a| a.to_string_lossy().into_owned())
1158 .collect();
1159 assert!(j.contains(&"-vn".to_string()));
1160 assert!(j.contains(&"libopus".to_string()));
1161 assert!(j.contains(&"-ac".to_string())); assert!(j.contains(&"-vbr".to_string()));
1163 }
1164
1165 #[test]
1166 fn plan_audio_infeasible_when_target_tiny() {
1167 let info = audio_info(3600.0, 500_000_000, 2);
1168 assert!(matches!(
1169 MediaEngine::new().plan(&info, &opts_target(1_000)),
1170 Err(EngineError::Infeasible)
1171 ));
1172 }
1173
1174 #[test]
1175 fn plan_audio_passthrough_when_source_fits() {
1176 let info = audio_info(600.0, 2_000_000, 2);
1177 let plan = MediaEngine::new()
1178 .plan(&info, &opts_target(10_000_000))
1179 .unwrap();
1180 assert!(plan.spec.passthrough);
1181 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.wav"));
1183 }
1184
1185 #[test]
1186 fn pass1_args_have_no_audio_and_null_sink() {
1187 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1188 let plan = MediaEngine::new()
1189 .plan(&info, &opts_target(8_000_000))
1190 .unwrap();
1191 let args = build_pass_args(&plan, PassKind::First, "/tmp/passlog", enc(&plan));
1192 let joined: Vec<String> = args
1193 .iter()
1194 .map(|a| a.to_string_lossy().into_owned())
1195 .collect();
1196 assert!(joined.contains(&"-an".to_string()));
1197 assert!(joined.contains(&"null".to_string()));
1198 assert!(joined.iter().any(|a| a == "1")); assert!(!joined.iter().any(|a| a.contains("shrink.mp4")));
1200 }
1201
1202 #[test]
1203 fn pass2_args_write_output_with_audio() {
1204 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1205 let plan = MediaEngine::new()
1206 .plan(&info, &opts_target(8_000_000))
1207 .unwrap();
1208 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan));
1209 let joined: Vec<String> = args
1210 .iter()
1211 .map(|a| a.to_string_lossy().into_owned())
1212 .collect();
1213 assert!(joined.iter().any(|a| a.contains("clip.shrink.mp4")));
1214 assert!(joined.contains(&"-c:a".to_string()));
1215 assert!(joined.contains(&"+faststart".to_string()));
1216 assert!(joined.iter().any(|a| a == "2")); }
1218
1219 #[test]
1220 fn h265_adds_hvc1_tag() {
1221 let info = video_info(60.0, 100_000_000, 1280, 720, false);
1222 let opts = ShrinkOpts {
1223 video_codec: VideoCodec::H265,
1224 ..opts_target(8_000_000)
1225 };
1226 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1227 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan));
1228 let joined: Vec<String> = args
1229 .iter()
1230 .map(|a| a.to_string_lossy().into_owned())
1231 .collect();
1232 assert!(joined.contains(&"hvc1".to_string()));
1233 assert!(joined.contains(&"libx265".to_string()));
1234 }
1235}