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
51 if let Some(target_vmaf) = plan.target_vmaf {
54 if plan.spec.video.crf.is_some() && !plan.spec.audio_only && !plan.spec.passthrough {
55 return self.run_crf_search(&tools, plan, target_vmaf, on_progress);
56 }
57 }
58
59 let mut outcome = self.run_plain(&tools, plan, on_progress)?;
60
61 if plan.target_vmaf.is_some() && !plan.spec.audio_only && !plan.spec.passthrough {
64 outcome.vmaf = self.measure_output(&tools, plan, &plan.output);
65 }
66 Ok(outcome)
67 }
68
69 fn run_plain(
72 &self,
73 tools: &deepshrink_ffmpeg::Tools,
74 plan: &EncodePlan,
75 on_progress: &mut dyn FnMut(PassKind, f64),
76 ) -> Result<Outcome, EngineError> {
77 let passlog = passlog_base(plan);
78 let total = plan.source_duration_sec;
79
80 if plan.spec.two_pass {
81 let args1 = build_pass_args(plan, PassKind::First, &passlog);
82 deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args1, total, &mut |f| {
83 on_progress(PassKind::First, f)
84 })?;
85 let args2 = build_pass_args(plan, PassKind::Second, &passlog);
86 deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args2, total, &mut |f| {
87 on_progress(PassKind::Second, f)
88 })?;
89 } else {
90 let args = build_pass_args(plan, PassKind::Single, &passlog);
91 deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, total, &mut |f| {
92 on_progress(PassKind::Single, f)
93 })?;
94 }
95
96 let mut size = fs::metadata(&plan.output)?.len();
97
98 if let (Some(target), Some(vbps)) = (plan.target_bytes, plan.spec.video.bitrate_bps) {
101 if size > target && plan.spec.two_pass {
102 let corrected = (vbps as f64 * (target as f64 / size as f64) * 0.97) as u64;
103 if corrected >= budget::ABSOLUTE_MIN_VIDEO_BPS {
104 let mut retry = plan.clone();
105 retry.spec.video.bitrate_bps = Some(corrected);
106 let args = build_pass_args(&retry, PassKind::Second, &passlog);
107 deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, total, &mut |f| {
108 on_progress(PassKind::Second, f)
109 })?;
110 size = fs::metadata(&plan.output)?.len();
111 }
112 }
113 }
114
115 cleanup_passlog(&passlog);
116 Ok(Outcome {
117 output: plan.output.clone(),
118 final_bytes: size,
119 vmaf: None,
120 })
121 }
122
123 fn run_crf_search(
130 &self,
131 tools: &deepshrink_ffmpeg::Tools,
132 plan: &EncodePlan,
133 target_vmaf: f64,
134 on_progress: &mut dyn FnMut(PassKind, f64),
135 ) -> Result<Outcome, EngineError> {
136 let (ref_w, ref_h) = match (plan.source_width, plan.source_height) {
137 (Some(w), Some(h)) => (w, h),
138 _ => return self.run_plain(tools, plan, on_progress),
139 };
140 let ref_fps = plan.source_fps.unwrap_or(0.0);
141 let total = plan.source_duration_sec;
142 let (lo, hi) = plan.spec.video.codec.crf_search_bounds();
143 let n_threads = thread_count();
144
145 let mut err: Option<EngineError> = None;
146 let mut last_crf: Option<u8> = None;
147
148 let (chosen_crf, chosen_vmaf) = budget::search_crf(target_vmaf, lo, hi, |crf| {
149 if err.is_some() {
150 return f64::NEG_INFINITY;
151 }
152 match encode_at_crf(tools, plan, crf, total, on_progress).and_then(|()| {
153 last_crf = Some(crf);
154 deepshrink_ffmpeg::measure_vmaf(
155 &tools.ffmpeg,
156 &plan.output,
157 &plan.input,
158 ref_w,
159 ref_h,
160 ref_fps,
161 n_threads,
162 )
163 .map_err(EngineError::from)
164 }) {
165 Ok(v) => v,
166 Err(e) => {
167 err = Some(e);
168 f64::NEG_INFINITY
169 }
170 }
171 });
172 if let Some(e) = err {
173 return Err(e);
174 }
175
176 if last_crf != Some(chosen_crf) {
178 encode_at_crf(tools, plan, chosen_crf, total, on_progress)?;
179 }
180 let size = fs::metadata(&plan.output)?.len();
181 Ok(Outcome {
182 output: plan.output.clone(),
183 final_bytes: size,
184 vmaf: Some(chosen_vmaf),
185 })
186 }
187
188 fn measure_output(
191 &self,
192 tools: &deepshrink_ffmpeg::Tools,
193 plan: &EncodePlan,
194 output: &Path,
195 ) -> Option<f64> {
196 let (w, h) = (plan.source_width?, plan.source_height?);
197 let fps = plan.source_fps.unwrap_or(0.0);
198 deepshrink_ffmpeg::measure_vmaf(
199 &tools.ffmpeg,
200 output,
201 &plan.input,
202 w,
203 h,
204 fps,
205 thread_count(),
206 )
207 .ok()
208 }
209
210 fn plan_audio(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
212 let duration = info.duration_sec;
213 if !duration.is_finite() || duration <= 0.0 {
214 return Err(EngineError::Unsupported(format!(
215 "could not determine duration of {}",
216 info.path.display()
217 )));
218 }
219 let codec = opts.audio_codec;
220 let target = target_bytes(&opts.goal, info.size_bytes);
221
222 if let Some(tb) = target {
224 if info.size_bytes > 0 && info.size_bytes <= tb {
225 let src_ext = info
226 .path
227 .extension()
228 .and_then(|e| e.to_str())
229 .unwrap_or("audio");
230 let output = opts
231 .output
232 .clone()
233 .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
234 return Ok(passthrough_plan(info, output, tb, false));
235 }
236 }
237
238 let mono = opts.mono || info.audio_channels == Some(1);
240
241 let (bitrate_bps, expected_bytes) = match target {
242 Some(tb) => {
243 let raw = budget::audio_bitrate_bps(tb, duration).ok_or(EngineError::Infeasible)?;
244 if raw < budget::ABSOLUTE_MIN_AUDIO_BPS {
245 return Err(EngineError::Infeasible);
246 }
247 let bps = budget::snap_audio_bitrate(raw);
248 let predicted = (bps as f64 * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD))
249 .round() as u64;
250 (bps, Some(predicted))
251 }
252 None => {
253 let bps = if mono { 96_000 } else { 160_000 };
255 (bps, None)
256 }
257 };
258
259 let audio = AudioSpec {
260 codec,
261 bitrate_bps,
262 mono,
263 sample_rate: opts.sample_rate,
264 vbr: opts.vbr,
265 };
266 let output = opts
267 .output
268 .clone()
269 .unwrap_or_else(|| output_with_ext(&info.path, codec.extension()));
270 let summary = build_audio_summary(&audio, info.audio_channels);
271
272 Ok(EncodePlan {
273 input: info.path.clone(),
274 output,
275 summary,
276 expected_bytes,
277 target_bytes: target,
278 target_vmaf: None,
279 source_duration_sec: duration,
280 source_width: info.width,
281 source_height: info.height,
282 source_fps: info.fps,
283 spec: EncodeSpec {
284 video: placeholder_video_spec(),
285 audio: Some(audio),
286 faststart: false,
287 two_pass: false,
288 passthrough: false,
289 audio_only: true,
290 },
291 })
292 }
293}
294
295impl Engine for MediaEngine {
296 fn supports(&self, input: &Path) -> bool {
297 matches!(detect_kind(input), MediaKind::Video | MediaKind::Audio)
298 }
299
300 fn probe(&self, input: &Path) -> Result<MediaInfo, EngineError> {
301 let tools = deepshrink_ffmpeg::locate()?;
302 let p = deepshrink_ffmpeg::probe(&tools.ffprobe, input)?;
303
304 let video = p.video_stream();
305 let audio = p.audio_stream();
306 let size_bytes = p
308 .size_bytes()
309 .or_else(|| fs::metadata(input).ok().map(|m| m.len()))
310 .unwrap_or(0);
311
312 Ok(MediaInfo {
313 path: input.to_path_buf(),
314 kind: detect_kind(input),
315 duration_sec: p.duration_sec().unwrap_or(0.0),
316 size_bytes,
317 width: video.and_then(|v| v.width),
318 height: video.and_then(|v| v.height),
319 fps: p.fps(),
320 video_codec: video.and_then(|v| v.codec_name.clone()),
321 audio_codec: audio.and_then(|a| a.codec_name.clone()),
322 audio_channels: audio.and_then(|a| a.channels),
323 })
324 }
325
326 fn plan(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
327 match info.kind {
328 MediaKind::Audio => return self.plan_audio(info, opts),
329 MediaKind::Unsupported => {
330 return Err(EngineError::Unsupported(format!(
331 "{} is not a supported media file",
332 info.path.display()
333 )))
334 }
335 MediaKind::Video => {}
336 }
337 let duration = info.duration_sec;
338 if !duration.is_finite() || duration <= 0.0 {
339 return Err(EngineError::Unsupported(format!(
340 "could not determine duration of {}",
341 info.path.display()
342 )));
343 }
344 let src_height = info.height.unwrap_or(0);
345
346 let target = target_bytes(&opts.goal, info.size_bytes);
347 let output = opts
348 .output
349 .clone()
350 .unwrap_or_else(|| output_with_ext(&info.path, "mp4"));
351
352 if let Some(tb) = target {
355 if info.size_bytes > 0 && info.size_bytes <= tb {
356 return Ok(passthrough_plan(info, output, tb, true));
357 }
358 }
359
360 let audio = decide_audio(opts, info.has_audio(), target, duration)?;
361 let audio_bps = audio.as_ref().map(|a| a.bitrate_bps).unwrap_or(0);
362
363 let (video, expected_bytes) = if let Some(tb) = target {
364 let vbps = budget::video_bitrate_bps(tb, duration, audio_bps)
365 .filter(|&b| b >= budget::ABSOLUTE_MIN_VIDEO_BPS)
366 .ok_or(EngineError::Infeasible)?;
367 let height = pick_height(opts.resolution, src_height, vbps);
368 let predicted = ((vbps + audio_bps) as f64 * duration / 8.0
369 * (1.0 + budget::CONTAINER_OVERHEAD))
370 .round() as u64;
371 (
372 VideoSpec {
373 codec: opts.video_codec,
374 bitrate_bps: Some(vbps),
375 crf: None,
376 height,
377 fps: pick_fps(opts.fps, info.fps),
378 preset: opts.quality,
379 },
380 Some(predicted),
381 )
382 } else {
383 let crf = opts.quality.default_crf(opts.video_codec);
386 let height = match opts.resolution {
387 ResolutionOpt::Height(h) => clamp_height(h, src_height),
388 ResolutionOpt::Auto => None,
389 };
390 (
391 VideoSpec {
392 codec: opts.video_codec,
393 bitrate_bps: None,
394 crf: Some(crf),
395 height,
396 fps: pick_fps(opts.fps, info.fps),
397 preset: opts.quality,
398 },
399 None,
400 )
401 };
402
403 let two_pass = video.bitrate_bps.is_some();
404 let summary = build_summary(&video, audio.as_ref(), two_pass);
405
406 Ok(EncodePlan {
407 input: info.path.clone(),
408 output,
409 summary,
410 expected_bytes,
411 target_bytes: target,
412 target_vmaf: opts.target_vmaf,
413 source_duration_sec: duration,
414 source_width: info.width,
415 source_height: info.height,
416 source_fps: info.fps,
417 spec: EncodeSpec {
418 video,
419 audio,
420 faststart: true,
421 two_pass,
422 passthrough: false,
423 audio_only: false,
424 },
425 })
426 }
427
428 fn run(&self, plan: &EncodePlan) -> Result<Outcome, EngineError> {
429 self.run_with_progress(plan, &mut |_, _| {})
430 }
431}
432
433fn placeholder_video_spec() -> VideoSpec {
435 VideoSpec {
436 codec: crate::options::VideoCodec::H264,
437 bitrate_bps: None,
438 crf: None,
439 height: None,
440 fps: None,
441 preset: crate::options::QualityPreset::Balanced,
442 }
443}
444
445fn passthrough_plan(info: &MediaInfo, output: PathBuf, target: u64, faststart: bool) -> EncodePlan {
448 EncodePlan {
449 input: info.path.clone(),
450 output,
451 summary: "stream copy (already within target)".to_string(),
452 expected_bytes: Some(info.size_bytes),
453 target_bytes: Some(target),
454 target_vmaf: None,
455 source_duration_sec: info.duration_sec,
456 source_width: info.width,
457 source_height: info.height,
458 source_fps: info.fps,
459 spec: EncodeSpec {
460 video: placeholder_video_spec(),
461 audio: None,
462 faststart,
463 two_pass: false,
464 passthrough: true,
465 audio_only: false,
466 },
467 }
468}
469
470fn build_audio_summary(audio: &AudioSpec, src_channels: Option<u32>) -> String {
473 let mut parts = vec![
474 audio.codec.label().to_string(),
475 format!("{} kbps", audio.bitrate_bps / 1000),
476 ];
477 if audio.mono {
478 let note = if src_channels == Some(1) {
480 "mono"
481 } else {
482 "mono (downmix)"
483 };
484 parts.push(note.to_string());
485 }
486 if let Some(sr) = audio.sample_rate {
487 parts.push(format!("{} Hz", sr));
488 }
489 parts.join(" · ")
490}
491
492fn target_bytes(goal: &SizeGoal, original: u64) -> Option<u64> {
494 match goal {
495 SizeGoal::Target(b) => Some(*b),
496 SizeGoal::Reduce(f) => Some(budget::reduce_target_bytes(original, *f)),
497 SizeGoal::Preset(p) => p.limit_bytes,
498 SizeGoal::Quality => None,
499 }
500}
501
502fn decide_audio(
504 opts: &ShrinkOpts,
505 has_audio: bool,
506 target: Option<u64>,
507 duration: f64,
508) -> Result<Option<AudioSpec>, EngineError> {
509 if !has_audio {
510 return Ok(None);
511 }
512 match opts.audio {
513 AudioChoice::Drop => Ok(None),
514 AudioChoice::Bitrate(b) => Ok(Some(AudioSpec::cbr(AudioCodec::Aac, b))),
515 AudioChoice::Keep => {
516 let bps = match target {
517 Some(tb) => budget::fit_audio_bps(tb, duration, AUDIO_LADDER)
518 .ok_or(EngineError::Infeasible)?,
519 None => budget::DEFAULT_AUDIO_BPS,
520 };
521 Ok(Some(AudioSpec::cbr(AudioCodec::Aac, bps)))
522 }
523 }
524}
525
526fn pick_height(res: ResolutionOpt, src_height: u32, vbps: u64) -> Option<u32> {
528 match res {
529 ResolutionOpt::Height(h) => clamp_height(h, src_height),
530 ResolutionOpt::Auto => {
531 let chosen = budget::choose_height(src_height, vbps);
532 if src_height > 0 && chosen < src_height {
533 Some(chosen)
534 } else {
535 None
536 }
537 }
538 }
539}
540
541fn clamp_height(requested: u32, src_height: u32) -> Option<u32> {
544 if src_height == 0 {
545 return Some(requested);
546 }
547 let h = requested.min(src_height);
548 if h == src_height {
549 None
550 } else {
551 Some(h)
552 }
553}
554
555fn pick_fps(fps: FpsOpt, src_fps: Option<f64>) -> Option<u32> {
557 match fps {
558 FpsOpt::Auto => None,
559 FpsOpt::Cap(f) => match src_fps {
560 Some(src) if (f as f64) >= src => None,
561 _ => Some(f),
562 },
563 }
564}
565
566fn output_with_ext(input: &Path, ext: &str) -> PathBuf {
568 let stem = input
569 .file_stem()
570 .map(|s| s.to_string_lossy().into_owned())
571 .unwrap_or_else(|| "output".to_string());
572 let mut out = input.parent().map(Path::to_path_buf).unwrap_or_default();
573 out.push(format!("{stem}.shrink.{ext}"));
574 out
575}
576
577fn build_summary(video: &VideoSpec, audio: Option<&AudioSpec>, two_pass: bool) -> String {
578 let mut parts = vec![video.codec.label().to_string()];
579 match (video.bitrate_bps, video.crf) {
580 (Some(bps), _) => parts.push(format!("{} kbps video", bps / 1000)),
581 (_, Some(crf)) => parts.push(format!("CRF {crf}")),
582 _ => {}
583 }
584 if let Some(a) = audio {
585 parts.push(format!("{} kbps audio", a.bitrate_bps / 1000));
586 } else {
587 parts.push("no audio".to_string());
588 }
589 if let Some(h) = video.height {
590 parts.push(format!("{h}p"));
591 }
592 if let Some(f) = video.fps {
593 parts.push(format!("{f} fps"));
594 }
595 parts.push(if two_pass { "two-pass" } else { "CRF" }.to_string());
596 parts.join(" · ")
597}
598
599fn passlog_base(plan: &EncodePlan) -> String {
601 let stem = plan
602 .input
603 .file_stem()
604 .map(|s| s.to_string_lossy().into_owned())
605 .unwrap_or_else(|| "ds".to_string());
606 let dir = std::env::temp_dir();
607 dir.join(format!("deepshrink-{}-{}", std::process::id(), stem))
608 .to_string_lossy()
609 .into_owned()
610}
611
612fn cleanup_passlog(base: &str) {
614 for suffix in ["-0.log", "-0.log.mbtree"] {
615 let _ = fs::remove_file(format!("{base}{suffix}"));
616 }
617}
618
619fn encode_at_crf(
621 tools: &deepshrink_ffmpeg::Tools,
622 plan: &EncodePlan,
623 crf: u8,
624 total: f64,
625 on_progress: &mut dyn FnMut(PassKind, f64),
626) -> Result<(), EngineError> {
627 let mut trial = plan.clone();
628 trial.spec.video.crf = Some(crf);
629 trial.spec.video.bitrate_bps = None;
630 trial.spec.two_pass = false;
631 let args = build_pass_args(&trial, PassKind::Single, "");
632 deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, total, &mut |f| {
633 on_progress(PassKind::Single, f)
634 })?;
635 Ok(())
636}
637
638fn thread_count() -> usize {
640 std::thread::available_parallelism()
641 .map(|n| n.get())
642 .unwrap_or(1)
643}
644
645fn null_sink() -> &'static str {
647 if cfg!(windows) {
648 "NUL"
649 } else {
650 "/dev/null"
651 }
652}
653
654fn build_pass_args(plan: &EncodePlan, pass: PassKind, passlog: &str) -> Vec<OsString> {
657 let s = &plan.spec;
658 let mut a: Vec<OsString> = Vec::new();
659 macro_rules! push {
662 ($arg:expr) => {
663 a.push(OsString::from($arg))
664 };
665 }
666
667 push!("-hide_banner");
668 push!("-y");
669 push!("-loglevel");
670 push!("error");
671 push!("-progress");
672 push!("pipe:1");
673 push!("-nostats");
674 push!("-i");
675 a.push(plan.input.clone().into_os_string());
676
677 if s.passthrough {
679 push!("-c");
680 push!("copy");
681 if s.faststart {
682 push!("-movflags");
683 push!("+faststart");
684 }
685 a.push(plan.output.clone().into_os_string());
686 return a;
687 }
688
689 if s.audio_only {
691 push!("-vn");
692 if let Some(au) = &s.audio {
693 push!("-c:a");
694 push!(au.codec.encoder());
695 if au.mono {
696 push!("-ac");
697 push!("1");
698 }
699 if let Some(sr) = au.sample_rate {
700 push!("-ar");
701 push!(sr.to_string());
702 }
703 push!("-b:a");
704 push!(au.bitrate_bps.to_string());
705 if matches!(au.codec, AudioCodec::Opus) {
708 push!("-vbr");
709 push!(if au.vbr { "on" } else { "constrained" });
710 }
711 }
712 a.push(plan.output.clone().into_os_string());
713 return a;
714 }
715
716 push!("-c:v");
718 push!(s.video.codec.encoder());
719 if let Some(h) = s.video.height {
720 push!("-vf");
721 push!(format!("scale=-2:{h}"));
722 }
723 if let Some(f) = s.video.fps {
724 push!("-r");
725 push!(f.to_string());
726 }
727 push!("-preset");
728 push!(s.video.preset.encoder_preset());
729 if let Some(tag) = s.video.codec.mp4_tag() {
730 push!("-tag:v");
731 push!(tag);
732 }
733
734 match (s.video.bitrate_bps, s.video.crf) {
736 (Some(bps), _) => {
737 push!("-b:v");
738 push!(bps.to_string());
739 if s.two_pass {
740 push!("-pass");
741 push!(match pass {
742 PassKind::First => "1",
743 _ => "2",
744 });
745 push!("-passlogfile");
746 push!(passlog);
747 }
748 }
749 (_, Some(crf)) => {
750 push!("-crf");
751 push!(crf.to_string());
752 }
753 _ => {}
754 }
755
756 match pass {
758 PassKind::First => {
759 push!("-an");
761 push!("-f");
762 push!("null");
763 push!(null_sink());
764 }
765 PassKind::Second | PassKind::Single => {
766 match &s.audio {
767 Some(au) => {
768 push!("-c:a");
769 push!(au.codec.encoder());
770 push!("-b:a");
771 push!(au.bitrate_bps.to_string());
772 }
773 None => push!("-an"),
774 }
775 if s.faststart {
776 push!("-movflags");
777 push!("+faststart");
778 }
779 a.push(plan.output.clone().into_os_string());
780 }
781 }
782 a
783}
784
785#[cfg(test)]
786mod tests {
787 use super::*;
788 use crate::options::{AudioCodec, QualityPreset, VideoCodec};
789 use crate::size::preset;
790
791 fn video_info(duration: f64, size: u64, w: u32, h: u32, audio: bool) -> MediaInfo {
792 MediaInfo {
793 path: PathBuf::from("/tmp/clip.mp4"),
794 kind: MediaKind::Video,
795 duration_sec: duration,
796 size_bytes: size,
797 width: Some(w),
798 height: Some(h),
799 fps: Some(30.0),
800 video_codec: Some("h264".into()),
801 audio_codec: if audio { Some("aac".into()) } else { None },
802 audio_channels: if audio { Some(2) } else { None },
803 }
804 }
805
806 fn opts_target(bytes: u64) -> ShrinkOpts {
807 ShrinkOpts {
808 goal: SizeGoal::Target(bytes),
809 ..Default::default()
810 }
811 }
812
813 #[test]
814 fn supports_video_and_audio() {
815 let e = MediaEngine::new();
816 assert!(e.supports(&PathBuf::from("clip.mp4")));
817 assert!(e.supports(&PathBuf::from("lecture.wav")));
818 assert!(!e.supports(&PathBuf::from("photo.jpg")));
819 }
820
821 #[test]
822 fn plan_target_builds_two_pass_with_budget() {
823 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
824 let plan = MediaEngine::new()
825 .plan(&info, &opts_target(8_000_000))
826 .unwrap();
827
828 assert!(plan.spec.two_pass);
829 assert_eq!(plan.target_bytes, Some(8_000_000));
830 assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
831 let vbps = plan.spec.video.bitrate_bps.unwrap();
832 assert!(vbps >= budget::ABSOLUTE_MIN_VIDEO_BPS);
833 assert!(plan.spec.video.height.is_some());
835 assert!(plan.spec.audio.is_some());
836 assert!(plan.expected_bytes.unwrap() <= 8_000_000 + 8_000_000 / 20);
838 }
839
840 #[test]
841 fn plan_preset_discord_sets_target() {
842 let info = video_info(30.0, 50_000_000, 1280, 720, true);
843 let opts = ShrinkOpts {
844 goal: SizeGoal::Preset(preset("discord").unwrap()),
845 ..Default::default()
846 };
847 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
848 assert_eq!(plan.target_bytes, Some(8_000_000));
849 }
850
851 #[test]
852 fn plan_reduce_targets_complement_of_original() {
853 let info = video_info(60.0, 100_000_000, 1920, 1080, true);
854 let opts = ShrinkOpts {
855 goal: SizeGoal::Reduce(0.70),
856 ..Default::default()
857 };
858 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
859 assert_eq!(plan.target_bytes, Some(30_000_000));
860 }
861
862 #[test]
863 fn plan_passthrough_when_source_already_fits() {
864 let info = video_info(10.0, 200_000, 1280, 720, true);
866 let plan = MediaEngine::new()
867 .plan(&info, &opts_target(1_000_000))
868 .unwrap();
869 assert!(plan.spec.passthrough);
870 assert!(!plan.spec.two_pass);
871 assert_eq!(plan.expected_bytes, Some(200_000));
872 let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog");
873 let joined: Vec<String> = args
874 .iter()
875 .map(|a| a.to_string_lossy().into_owned())
876 .collect();
877 assert!(joined.contains(&"copy".to_string()));
878 }
879
880 #[test]
881 fn plan_infeasible_when_target_too_small() {
882 let info = video_info(600.0, 500_000_000, 1920, 1080, true);
883 let err = MediaEngine::new().plan(&info, &opts_target(50_000));
884 assert!(matches!(err, Err(EngineError::Infeasible)));
885 }
886
887 #[test]
888 fn plan_quality_mode_uses_crf_single_pass() {
889 let info = video_info(60.0, 100_000_000, 1920, 1080, true);
890 let opts = ShrinkOpts {
891 goal: SizeGoal::Quality,
892 quality: QualityPreset::Balanced,
893 ..Default::default()
894 };
895 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
896 assert!(!plan.spec.two_pass);
897 assert_eq!(plan.spec.video.crf, Some(23));
898 assert!(plan.spec.video.bitrate_bps.is_none());
899 assert!(plan.expected_bytes.is_none());
900 }
901
902 #[test]
903 fn plan_drops_audio_when_requested() {
904 let info = video_info(30.0, 50_000_000, 1280, 720, true);
905 let opts = ShrinkOpts {
906 audio: AudioChoice::Drop,
907 ..opts_target(8_000_000)
908 };
909 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
910 assert!(plan.spec.audio.is_none());
911 }
912
913 fn audio_info(duration: f64, size: u64, channels: u32) -> MediaInfo {
914 MediaInfo {
915 path: PathBuf::from("/tmp/lecture.wav"),
916 kind: MediaKind::Audio,
917 duration_sec: duration,
918 size_bytes: size,
919 width: None,
920 height: None,
921 fps: None,
922 video_codec: None,
923 audio_codec: Some("pcm_s16le".into()),
924 audio_channels: Some(channels),
925 }
926 }
927
928 #[test]
929 fn plan_audio_single_pass_with_fitted_bitrate() {
930 let info = audio_info(3480.0, 600_000_000, 2);
932 let plan = MediaEngine::new()
933 .plan(&info, &opts_target(10_000_000))
934 .unwrap();
935 assert!(plan.spec.audio_only);
936 assert!(!plan.spec.two_pass);
937 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.m4a"));
938 let au = plan.spec.audio.as_ref().unwrap();
939 assert!(budget::AUDIO_STEPS.contains(&au.bitrate_bps));
941 assert!(plan.expected_bytes.unwrap() <= 10_000_000 + 10_000_000 / 20);
942 }
943
944 #[test]
945 fn plan_audio_mono_source_marked_speech() {
946 let info = audio_info(600.0, 100_000_000, 1);
947 let plan = MediaEngine::new()
948 .plan(&info, &opts_target(5_000_000))
949 .unwrap();
950 assert!(plan.spec.audio.as_ref().unwrap().mono);
951 }
952
953 #[test]
954 fn plan_audio_opus_extension_and_vbr_args() {
955 let info = audio_info(600.0, 100_000_000, 2);
956 let opts = ShrinkOpts {
957 audio_codec: AudioCodec::Opus,
958 mono: true,
959 ..opts_target(3_000_000)
960 };
961 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
962 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.opus"));
963 let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog");
964 let j: Vec<String> = args
965 .iter()
966 .map(|a| a.to_string_lossy().into_owned())
967 .collect();
968 assert!(j.contains(&"-vn".to_string()));
969 assert!(j.contains(&"libopus".to_string()));
970 assert!(j.contains(&"-ac".to_string())); assert!(j.contains(&"-vbr".to_string()));
972 }
973
974 #[test]
975 fn plan_audio_infeasible_when_target_tiny() {
976 let info = audio_info(3600.0, 500_000_000, 2);
977 assert!(matches!(
978 MediaEngine::new().plan(&info, &opts_target(1_000)),
979 Err(EngineError::Infeasible)
980 ));
981 }
982
983 #[test]
984 fn plan_audio_passthrough_when_source_fits() {
985 let info = audio_info(600.0, 2_000_000, 2);
986 let plan = MediaEngine::new()
987 .plan(&info, &opts_target(10_000_000))
988 .unwrap();
989 assert!(plan.spec.passthrough);
990 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.wav"));
992 }
993
994 #[test]
995 fn pass1_args_have_no_audio_and_null_sink() {
996 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
997 let plan = MediaEngine::new()
998 .plan(&info, &opts_target(8_000_000))
999 .unwrap();
1000 let args = build_pass_args(&plan, PassKind::First, "/tmp/passlog");
1001 let joined: Vec<String> = args
1002 .iter()
1003 .map(|a| a.to_string_lossy().into_owned())
1004 .collect();
1005 assert!(joined.contains(&"-an".to_string()));
1006 assert!(joined.contains(&"null".to_string()));
1007 assert!(joined.iter().any(|a| a == "1")); assert!(!joined.iter().any(|a| a.contains("shrink.mp4")));
1009 }
1010
1011 #[test]
1012 fn pass2_args_write_output_with_audio() {
1013 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1014 let plan = MediaEngine::new()
1015 .plan(&info, &opts_target(8_000_000))
1016 .unwrap();
1017 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog");
1018 let joined: Vec<String> = args
1019 .iter()
1020 .map(|a| a.to_string_lossy().into_owned())
1021 .collect();
1022 assert!(joined.iter().any(|a| a.contains("clip.shrink.mp4")));
1023 assert!(joined.contains(&"-c:a".to_string()));
1024 assert!(joined.contains(&"+faststart".to_string()));
1025 assert!(joined.iter().any(|a| a == "2")); }
1027
1028 #[test]
1029 fn h265_adds_hvc1_tag() {
1030 let info = video_info(60.0, 100_000_000, 1280, 720, false);
1031 let opts = ShrinkOpts {
1032 video_codec: VideoCodec::H265,
1033 ..opts_target(8_000_000)
1034 };
1035 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1036 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog");
1037 let joined: Vec<String> = args
1038 .iter()
1039 .map(|a| a.to_string_lossy().into_owned())
1040 .collect();
1041 assert!(joined.contains(&"hvc1".to_string()));
1042 assert!(joined.contains(&"libx265".to_string()));
1043 }
1044}