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, QualityPreset, 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 source = if plan.guard_larger && !plan.spec.passthrough {
65 fs::metadata(&plan.input).map(|m| m.len()).unwrap_or(0)
66 } else {
67 0
68 };
69 if source > 0
70 && plan.target_vmaf.is_none()
71 && !plan.spec.audio_only
72 && plan.spec.video.crf.is_some()
73 {
74 if let Some(predicted) = predict_crf_bytes(&tools, plan, encoder) {
75 if predicted >= source {
76 return self.keep_original(&tools, plan, on_progress);
77 }
78 }
79 }
80
81 let mut outcome = self.run_plain(&tools, plan, encoder, on_progress)?;
82 if source > 0 && outcome.final_bytes >= source {
83 let _ = fs::remove_file(&outcome.output);
84 return self.keep_original(&tools, plan, on_progress);
85 }
86
87 if plan.target_vmaf.is_some() && !plan.spec.audio_only && !plan.spec.passthrough {
90 outcome.vmaf = self.measure_output(&tools, plan, &plan.output);
91 }
92 Ok(outcome)
93 }
94
95 fn run_plain(
98 &self,
99 tools: &deepshrink_ffmpeg::Tools,
100 plan: &EncodePlan,
101 encoder: &str,
102 on_progress: &mut dyn FnMut(PassKind, f64),
103 ) -> Result<Outcome, EngineError> {
104 let passlog = passlog_base(plan);
105 let total = plan.source_duration_sec;
106
107 if plan.spec.passthrough {
108 return self.run_passthrough(tools, plan, on_progress);
109 }
110
111 if plan.spec.two_pass {
112 let args1 = build_pass_args(plan, PassKind::First, &passlog, encoder);
113 deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args1, total, &mut |f| {
114 on_progress(PassKind::First, f)
115 })?;
116 let args2 = build_pass_args(plan, PassKind::Second, &passlog, encoder);
117 deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args2, total, &mut |f| {
118 on_progress(PassKind::Second, f)
119 })?;
120 } else {
121 let args = build_pass_args(plan, PassKind::Single, &passlog, encoder);
122 deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, total, &mut |f| {
123 on_progress(PassKind::Single, f)
124 })?;
125 }
126
127 let mut size = fs::metadata(&plan.output)?.len();
128
129 if let (Some(target), Some(vbps)) = (plan.target_bytes, plan.spec.video.bitrate_bps) {
132 if size > target && plan.spec.two_pass {
133 let corrected = (vbps as f64 * (target as f64 / size as f64) * 0.97) as u64;
134 if corrected >= budget::ABSOLUTE_MIN_VIDEO_BPS {
135 let mut retry = plan.clone();
136 retry.spec.video.bitrate_bps = Some(corrected);
137 let args = build_pass_args(&retry, PassKind::Second, &passlog, encoder);
138 deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, total, &mut |f| {
139 on_progress(PassKind::Second, f)
140 })?;
141 size = fs::metadata(&plan.output)?.len();
142 }
143 }
144 }
145
146 cleanup_passlog(&passlog);
147 Ok(Outcome {
148 output: plan.output.clone(),
149 final_bytes: size,
150 vmaf: None,
151 already_compact: false,
152 })
153 }
154
155 fn keep_original(
158 &self,
159 tools: &deepshrink_ffmpeg::Tools,
160 plan: &EncodePlan,
161 on_progress: &mut dyn FnMut(PassKind, f64),
162 ) -> Result<Outcome, EngineError> {
163 let _ = tools; let output = match plan.input.extension() {
165 Some(ext) => plan.output.with_extension(ext),
166 None => plan.output.clone(),
167 };
168 fs::copy(&plan.input, &output)?;
171 on_progress(PassKind::Single, 1.0);
172 Ok(Outcome {
173 final_bytes: fs::metadata(&output)?.len(),
174 output,
175 vmaf: None,
176 already_compact: true,
177 })
178 }
179
180 fn run_passthrough(
189 &self,
190 tools: &deepshrink_ffmpeg::Tools,
191 plan: &EncodePlan,
192 on_progress: &mut dyn FnMut(PassKind, f64),
193 ) -> Result<Outcome, EngineError> {
194 let args = build_pass_args(plan, PassKind::Single, "", "copy");
196 let remuxed =
197 deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, plan.source_duration_sec, &mut |f| {
198 on_progress(PassKind::Single, f)
199 });
200 if remuxed.is_err() {
201 fs::copy(&plan.input, &plan.output)?;
202 on_progress(PassKind::Single, 1.0);
203 }
204 let size = fs::metadata(&plan.output)?.len();
205 Ok(Outcome {
206 output: plan.output.clone(),
207 final_bytes: size,
208 vmaf: None,
209 already_compact: true,
210 })
211 }
212
213 fn run_crf_search(
220 &self,
221 tools: &deepshrink_ffmpeg::Tools,
222 plan: &EncodePlan,
223 encoder: &str,
224 target_vmaf: f64,
225 on_progress: &mut dyn FnMut(PassKind, f64),
226 ) -> Result<Outcome, EngineError> {
227 let (ref_w, ref_h) = match (plan.source_width, plan.source_height) {
228 (Some(w), Some(h)) => (w, h),
229 _ => return self.run_plain(tools, plan, encoder, on_progress),
230 };
231 let ref_fps = plan.source_fps.unwrap_or(0.0);
232 let total = plan.source_duration_sec;
233 let (lo, hi) = plan.spec.video.codec.crf_search_bounds();
234 let n_threads = thread_count();
235
236 let mut err: Option<EngineError> = None;
237 let mut last_crf: Option<u8> = None;
238
239 let (chosen_crf, chosen_vmaf) = budget::search_crf(target_vmaf, lo, hi, |crf| {
240 if err.is_some() {
241 return f64::NEG_INFINITY;
242 }
243 match encode_at_crf(tools, plan, encoder, crf, total, on_progress).and_then(|()| {
244 last_crf = Some(crf);
245 deepshrink_ffmpeg::measure_vmaf(
246 &tools.ffmpeg,
247 &plan.output,
248 &plan.input,
249 ref_w,
250 ref_h,
251 ref_fps,
252 n_threads,
253 )
254 .map_err(EngineError::from)
255 }) {
256 Ok(v) => v,
257 Err(e) => {
258 err = Some(e);
259 f64::NEG_INFINITY
260 }
261 }
262 });
263 if let Some(e) = err {
264 return Err(e);
265 }
266
267 if last_crf != Some(chosen_crf) {
269 encode_at_crf(tools, plan, encoder, chosen_crf, total, on_progress)?;
270 }
271 let size = fs::metadata(&plan.output)?.len();
272 Ok(Outcome {
273 output: plan.output.clone(),
274 final_bytes: size,
275 vmaf: Some(chosen_vmaf),
276 already_compact: false,
277 })
278 }
279
280 fn measure_output(
283 &self,
284 tools: &deepshrink_ffmpeg::Tools,
285 plan: &EncodePlan,
286 output: &Path,
287 ) -> Option<f64> {
288 let (w, h) = (plan.source_width?, plan.source_height?);
289 let fps = plan.source_fps.unwrap_or(0.0);
290 deepshrink_ffmpeg::measure_vmaf(
291 &tools.ffmpeg,
292 output,
293 &plan.input,
294 w,
295 h,
296 fps,
297 thread_count(),
298 )
299 .ok()
300 }
301
302 fn plan_audio(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
304 let duration = info.duration_sec;
305 if !duration.is_finite() || duration <= 0.0 {
306 return Err(EngineError::Unsupported(format!(
307 "could not determine duration of {}",
308 info.path.display()
309 )));
310 }
311 let codec = opts.audio_codec;
312 let target = target_bytes(&opts.goal, info.size_bytes);
313
314 if let Some(tb) = target {
316 if info.size_bytes > 0 && info.size_bytes <= tb {
317 let src_ext = info
318 .path
319 .extension()
320 .and_then(|e| e.to_str())
321 .unwrap_or("audio");
322 let output = opts
323 .output
324 .clone()
325 .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
326 return Ok(passthrough_plan(info, output, tb, false));
327 }
328 }
329
330 let mono = opts.mono || info.audio_channels == Some(1);
332
333 let (bitrate_bps, expected_bytes) = match target {
334 Some(tb) => {
335 let raw = budget::audio_bitrate_bps(tb, duration).ok_or(EngineError::Infeasible)?;
336 if raw < budget::ABSOLUTE_MIN_AUDIO_BPS {
337 return Err(EngineError::Infeasible);
338 }
339 let bps = budget::snap_audio_bitrate(raw);
340 let predicted = (bps as f64 * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD))
341 .round() as u64;
342 (bps, Some(predicted))
343 }
344 None => {
345 let bps = quality_audio_bps(opts.quality, codec, mono);
347 if !opts.allow_larger && already_compact_audio(bps, info) {
351 let src_ext = info
352 .path
353 .extension()
354 .and_then(|e| e.to_str())
355 .unwrap_or("audio");
356 let output = opts
357 .output
358 .clone()
359 .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
360 let mut plan = passthrough_plan(info, output, info.size_bytes, false);
361 plan.summary =
362 "stream copy (already compact — a re-encode would not be smaller)".into();
363 return Ok(plan);
364 }
365 (bps, None)
366 }
367 };
368
369 let audio = AudioSpec {
370 codec,
371 bitrate_bps,
372 mono,
373 sample_rate: opts.sample_rate,
374 vbr: opts.vbr,
375 };
376 let output = opts
377 .output
378 .clone()
379 .unwrap_or_else(|| output_with_ext(&info.path, codec.extension()));
380 let summary = build_audio_summary(&audio, info.audio_channels);
381
382 Ok(EncodePlan {
383 input: info.path.clone(),
384 output,
385 summary,
386 expected_bytes,
387 target_bytes: target,
388 target_vmaf: None,
389 source_duration_sec: duration,
390 source_width: info.width,
391 source_height: info.height,
392 source_fps: info.fps,
393 spec: EncodeSpec {
394 video: placeholder_video_spec(),
395 audio: Some(audio),
396 faststart: false,
397 two_pass: false,
398 passthrough: false,
399 audio_only: true,
400 dpi: None,
401 },
402 guard_larger: target.is_none() && !opts.allow_larger,
404 })
405 }
406}
407
408impl Engine for MediaEngine {
409 fn supports(&self, input: &Path) -> bool {
410 matches!(detect_kind(input), MediaKind::Video | MediaKind::Audio)
411 }
412
413 fn probe(&self, input: &Path) -> Result<MediaInfo, EngineError> {
414 let tools = deepshrink_ffmpeg::locate()?;
415 let p = deepshrink_ffmpeg::probe(&tools.ffprobe, input)?;
416
417 let video = p.video_stream();
418 let audio = p.audio_stream();
419 let size_bytes = p
421 .size_bytes()
422 .or_else(|| fs::metadata(input).ok().map(|m| m.len()))
423 .unwrap_or(0);
424
425 Ok(MediaInfo {
426 path: input.to_path_buf(),
427 kind: detect_kind(input),
428 duration_sec: p.duration_sec().unwrap_or(0.0),
429 size_bytes,
430 width: video.and_then(|v| v.width),
431 height: video.and_then(|v| v.height),
432 fps: p.fps(),
433 video_codec: video.and_then(|v| v.codec_name.clone()),
434 audio_codec: audio.and_then(|a| a.codec_name.clone()),
435 audio_channels: audio.and_then(|a| a.channels),
436 })
437 }
438
439 fn plan(&self, info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
440 match info.kind {
441 MediaKind::Audio => return self.plan_audio(info, opts),
442 MediaKind::Unsupported => {
443 return Err(EngineError::Unsupported(format!(
444 "{} is not a supported media file",
445 info.path.display()
446 )))
447 }
448 MediaKind::Video => {}
449 }
450 let duration = info.duration_sec;
451 if !duration.is_finite() || duration <= 0.0 {
452 return Err(EngineError::Unsupported(format!(
453 "could not determine duration of {}",
454 info.path.display()
455 )));
456 }
457 let src_height = info.height.unwrap_or(0);
458
459 let target = target_bytes(&opts.goal, info.size_bytes);
460 let output = opts
461 .output
462 .clone()
463 .unwrap_or_else(|| output_with_ext(&info.path, "mp4"));
464
465 if let Some(tb) = target {
471 if info.size_bytes > 0 && info.size_bytes <= tb {
472 let src_ext = info
473 .path
474 .extension()
475 .and_then(|e| e.to_str())
476 .unwrap_or("mp4");
477 let output = opts
478 .output
479 .clone()
480 .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
481 return Ok(passthrough_plan(info, output, tb, true));
482 }
483 }
484
485 let audio = decide_audio(opts, info.has_audio(), target, duration)?;
486 let audio_bps = audio.as_ref().map(|a| a.bitrate_bps).unwrap_or(0);
487
488 let (video, expected_bytes) = if let Some(tb) = target {
489 let vbps = budget::video_bitrate_bps(tb, duration, audio_bps)
490 .filter(|&b| b >= budget::ABSOLUTE_MIN_VIDEO_BPS)
491 .ok_or(EngineError::Infeasible)?;
492 let height = pick_height(opts.resolution, src_height, vbps);
493 let predicted = ((vbps + audio_bps) as f64 * duration / 8.0
494 * (1.0 + budget::CONTAINER_OVERHEAD))
495 .round() as u64;
496 (
497 VideoSpec {
498 codec: opts.video_codec,
499 bitrate_bps: Some(vbps),
500 crf: None,
501 height,
502 fps: pick_fps(opts.fps, info.fps),
503 preset: opts.quality,
504 },
505 Some(predicted),
506 )
507 } else {
508 let crf = opts.quality.default_crf(opts.video_codec);
511 let height = match opts.resolution {
512 ResolutionOpt::Height(h) => clamp_height(h, src_height),
513 ResolutionOpt::Auto => None,
514 };
515 (
516 VideoSpec {
517 codec: opts.video_codec,
518 bitrate_bps: None,
519 crf: Some(crf),
520 height,
521 fps: pick_fps(opts.fps, info.fps),
522 preset: opts.quality,
523 },
524 None,
525 )
526 };
527
528 let two_pass = video.bitrate_bps.is_some() && opts.two_pass.unwrap_or(true);
532 let summary = build_summary(&video, audio.as_ref(), two_pass);
533
534 Ok(EncodePlan {
535 input: info.path.clone(),
536 output,
537 summary,
538 expected_bytes,
539 target_bytes: target,
540 target_vmaf: opts.target_vmaf,
541 source_duration_sec: duration,
542 source_width: info.width,
543 source_height: info.height,
544 source_fps: info.fps,
545 spec: EncodeSpec {
546 video,
547 audio,
548 faststart: true,
549 two_pass,
550 passthrough: false,
551 audio_only: false,
552 dpi: None,
553 },
554 guard_larger: target.is_none() && !opts.allow_larger,
556 })
557 }
558
559 fn run(&self, plan: &EncodePlan) -> Result<Outcome, EngineError> {
560 self.run_with_progress(plan, &mut |_, _| {})
561 }
562}
563
564fn placeholder_video_spec() -> VideoSpec {
566 VideoSpec {
567 codec: crate::options::VideoCodec::H264,
568 bitrate_bps: None,
569 crf: None,
570 height: None,
571 fps: None,
572 preset: crate::options::QualityPreset::Balanced,
573 }
574}
575
576fn passthrough_plan(info: &MediaInfo, output: PathBuf, target: u64, faststart: bool) -> EncodePlan {
579 EncodePlan {
580 input: info.path.clone(),
581 output,
582 summary: "stream copy (already within target)".to_string(),
583 expected_bytes: Some(info.size_bytes),
584 target_bytes: Some(target),
585 target_vmaf: None,
586 source_duration_sec: info.duration_sec,
587 source_width: info.width,
588 source_height: info.height,
589 source_fps: info.fps,
590 spec: EncodeSpec {
591 video: placeholder_video_spec(),
592 audio: None,
593 faststart,
594 two_pass: false,
595 passthrough: true,
596 audio_only: false,
597 dpi: None,
598 },
599 guard_larger: false,
600 }
601}
602
603fn quality_audio_bps(quality: QualityPreset, codec: AudioCodec, mono: bool) -> u64 {
606 let stereo = match (codec, quality) {
607 (AudioCodec::Opus, QualityPreset::Fast) => 64_000,
608 (AudioCodec::Opus, QualityPreset::Balanced) => 96_000,
609 (AudioCodec::Opus, QualityPreset::Max) => 128_000,
610 (AudioCodec::Mp3, QualityPreset::Fast) => 128_000,
611 (AudioCodec::Mp3, QualityPreset::Balanced) => 160_000,
612 (AudioCodec::Mp3, QualityPreset::Max) => 256_000,
613 (AudioCodec::Aac, QualityPreset::Fast) => 96_000,
614 (AudioCodec::Aac, QualityPreset::Balanced) => 128_000,
615 (AudioCodec::Aac, QualityPreset::Max) => 192_000,
616 };
617 if mono {
618 stereo / 2
619 } else {
620 stereo
621 }
622}
623
624fn already_compact_audio(bps: u64, info: &MediaInfo) -> bool {
628 if info.duration_sec <= 0.0 || info.size_bytes == 0 {
629 return false;
630 }
631 let source_bps = info.size_bytes as f64 * 8.0 / info.duration_sec;
632 bps as f64 >= source_bps * 0.9
633}
634
635const SAMPLE_SECS: f64 = 3.0;
637const SAMPLE_BIAS: f64 = 0.92;
640
641fn predict_crf_bytes(
646 tools: &deepshrink_ffmpeg::Tools,
647 plan: &EncodePlan,
648 encoder: &str,
649) -> Option<u64> {
650 let duration = plan.source_duration_sec;
651 if !duration.is_finite() || duration < SAMPLE_SECS * 4.0 {
652 return None;
653 }
654 let mut sample = plan.clone();
655 sample.spec.audio = None;
656 sample.spec.faststart = false;
657 let mut video_bytes = 0u64;
658 let mut sampled = 0.0;
659 for (i, at) in [0.2, 0.5, 0.8].iter().enumerate() {
660 let start = (duration * at - SAMPLE_SECS / 2.0).max(0.0);
661 sample.output =
662 std::env::temp_dir().join(format!("deepshrink-sample-{}-{i}.mp4", std::process::id()));
663 let mut args = build_pass_args(&sample, PassKind::Single, "", encoder);
664 let at_input = args.iter().position(|a| a == "-i")?;
665 args.splice(
666 at_input..at_input,
667 [
668 OsString::from("-ss"),
669 OsString::from(format!("{start:.2}")),
670 OsString::from("-t"),
671 OsString::from(format!("{SAMPLE_SECS}")),
672 ],
673 );
674 let ran = deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, SAMPLE_SECS, &mut |_| {});
675 let bytes = fs::metadata(&sample.output).map(|m| m.len()).ok();
676 let _ = fs::remove_file(&sample.output);
677 video_bytes += bytes.filter(|_| ran.is_ok())?;
678 sampled += SAMPLE_SECS;
679 }
680 let video_bps = video_bytes as f64 * 8.0 / sampled * SAMPLE_BIAS;
681 let audio_bps = plan.spec.audio.as_ref().map(|a| a.bitrate_bps).unwrap_or(0) as f64;
682 Some(((video_bps + audio_bps) * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD)) as u64)
683}
684
685fn build_audio_summary(audio: &AudioSpec, src_channels: Option<u32>) -> String {
688 let mut parts = vec![
689 audio.codec.label().to_string(),
690 format!("{} kbps", audio.bitrate_bps / 1000),
691 ];
692 if audio.mono {
693 let note = if src_channels == Some(1) {
695 "mono"
696 } else {
697 "mono (downmix)"
698 };
699 parts.push(note.to_string());
700 }
701 if let Some(sr) = audio.sample_rate {
702 parts.push(format!("{} Hz", sr));
703 }
704 parts.join(" · ")
705}
706
707fn target_bytes(goal: &SizeGoal, original: u64) -> Option<u64> {
709 match goal {
710 SizeGoal::Target(b) => Some(*b),
711 SizeGoal::Reduce(f) => Some(budget::reduce_target_bytes(original, *f)),
712 SizeGoal::Preset(p) => p.limit_bytes,
713 SizeGoal::Quality => None,
714 }
715}
716
717fn decide_audio(
719 opts: &ShrinkOpts,
720 has_audio: bool,
721 target: Option<u64>,
722 duration: f64,
723) -> Result<Option<AudioSpec>, EngineError> {
724 if !has_audio {
725 return Ok(None);
726 }
727 let mono = opts.mono;
730 match opts.audio {
731 AudioChoice::Drop => Ok(None),
732 AudioChoice::Bitrate(b) => Ok(Some(AudioSpec {
733 mono,
734 ..AudioSpec::cbr(AudioCodec::Aac, b)
735 })),
736 AudioChoice::Keep => {
737 let bps = match target {
738 Some(tb) => budget::fit_audio_bps(tb, duration, AUDIO_LADDER)
739 .ok_or(EngineError::Infeasible)?,
740 None => budget::DEFAULT_AUDIO_BPS,
741 };
742 Ok(Some(AudioSpec {
743 mono,
744 ..AudioSpec::cbr(AudioCodec::Aac, bps)
745 }))
746 }
747 }
748}
749
750fn pick_height(res: ResolutionOpt, src_height: u32, vbps: u64) -> Option<u32> {
752 match res {
753 ResolutionOpt::Height(h) => clamp_height(h, src_height),
754 ResolutionOpt::Auto => {
755 let chosen = budget::choose_height(src_height, vbps);
756 if src_height > 0 && chosen < src_height {
757 Some(chosen)
758 } else {
759 None
760 }
761 }
762 }
763}
764
765fn clamp_height(requested: u32, src_height: u32) -> Option<u32> {
768 if src_height == 0 {
769 return Some(requested);
770 }
771 let h = requested.min(src_height);
772 if h == src_height {
773 None
774 } else {
775 Some(h)
776 }
777}
778
779fn pick_fps(fps: FpsOpt, src_fps: Option<f64>) -> Option<u32> {
781 match fps {
782 FpsOpt::Auto => None,
783 FpsOpt::Cap(f) => match src_fps {
784 Some(src) if (f as f64) >= src => None,
785 _ => Some(f),
786 },
787 }
788}
789
790fn output_with_ext(input: &Path, ext: &str) -> PathBuf {
792 let stem = input
793 .file_stem()
794 .map(|s| s.to_string_lossy().into_owned())
795 .unwrap_or_else(|| "output".to_string());
796 let mut out = input.parent().map(Path::to_path_buf).unwrap_or_default();
797 out.push(format!("{stem}.shrink.{ext}"));
798 out
799}
800
801fn build_summary(video: &VideoSpec, audio: Option<&AudioSpec>, two_pass: bool) -> String {
802 let mut parts = vec![video.codec.label().to_string()];
803 match (video.bitrate_bps, video.crf) {
804 (Some(bps), _) => parts.push(format!("{} kbps video", bps / 1000)),
805 (_, Some(crf)) => parts.push(format!("CRF {crf}")),
806 _ => {}
807 }
808 if let Some(a) = audio {
809 parts.push(format!("{} kbps audio", a.bitrate_bps / 1000));
810 } else {
811 parts.push("no audio".to_string());
812 }
813 if let Some(h) = video.height {
814 parts.push(format!("{h}p"));
815 }
816 if let Some(f) = video.fps {
817 parts.push(format!("{f} fps"));
818 }
819 parts.push(if two_pass { "two-pass" } else { "CRF" }.to_string());
820 parts.join(" · ")
821}
822
823fn passlog_base(plan: &EncodePlan) -> String {
825 let stem = plan
826 .input
827 .file_stem()
828 .map(|s| s.to_string_lossy().into_owned())
829 .unwrap_or_else(|| "ds".to_string());
830 let dir = std::env::temp_dir();
831 dir.join(format!("deepshrink-{}-{}", std::process::id(), stem))
832 .to_string_lossy()
833 .into_owned()
834}
835
836fn cleanup_passlog(base: &str) {
838 for suffix in ["-0.log", "-0.log.mbtree"] {
839 let _ = fs::remove_file(format!("{base}{suffix}"));
840 }
841}
842
843fn encode_at_crf(
845 tools: &deepshrink_ffmpeg::Tools,
846 plan: &EncodePlan,
847 encoder: &str,
848 crf: u8,
849 total: f64,
850 on_progress: &mut dyn FnMut(PassKind, f64),
851) -> Result<(), EngineError> {
852 let mut trial = plan.clone();
853 trial.spec.video.crf = Some(crf);
854 trial.spec.video.bitrate_bps = None;
855 trial.spec.two_pass = false;
856 let args = build_pass_args(&trial, PassKind::Single, "", encoder);
857 deepshrink_ffmpeg::run_pass(&tools.ffmpeg, &args, total, &mut |f| {
858 on_progress(PassKind::Single, f)
859 })?;
860 Ok(())
861}
862
863fn thread_count() -> usize {
865 std::thread::available_parallelism()
866 .map(|n| n.get())
867 .unwrap_or(1)
868}
869
870fn null_sink() -> &'static str {
872 if cfg!(windows) {
873 "NUL"
874 } else {
875 "/dev/null"
876 }
877}
878
879fn resolve_encoder(
887 tools: &deepshrink_ffmpeg::Tools,
888 plan: &EncodePlan,
889) -> Result<&'static str, EngineError> {
890 let codec = plan.spec.video.codec;
891 let primary = codec.encoder();
892 let Some(fallback) = codec.fallback_encoder() else {
893 return Ok(primary);
894 };
895 if plan.spec.passthrough || plan.spec.audio_only {
897 return Ok(primary);
898 }
899 if deepshrink_ffmpeg::has_encoder(&tools.ffmpeg, primary) {
900 return Ok(primary);
901 }
902 if deepshrink_ffmpeg::has_encoder(&tools.ffmpeg, fallback) {
903 return Ok(fallback);
904 }
905 Err(EngineError::Unsupported(format!(
906 "this ffmpeg build has no {} encoder (looked for {primary} and {fallback})",
907 codec.label()
908 )))
909}
910
911fn build_pass_args(
916 plan: &EncodePlan,
917 pass: PassKind,
918 passlog: &str,
919 encoder: &str,
920) -> Vec<OsString> {
921 let s = &plan.spec;
922 let mut a: Vec<OsString> = Vec::new();
923 macro_rules! push {
926 ($arg:expr) => {
927 a.push(OsString::from($arg))
928 };
929 }
930
931 push!("-hide_banner");
932 push!("-y");
933 push!("-loglevel");
934 push!("error");
935 push!("-progress");
936 push!("pipe:1");
937 push!("-nostats");
938 push!("-i");
939 a.push(plan.input.clone().into_os_string());
940
941 if s.passthrough {
943 push!("-c");
944 push!("copy");
945 if s.faststart {
946 push!("-movflags");
947 push!("+faststart");
948 }
949 a.push(plan.output.clone().into_os_string());
950 return a;
951 }
952
953 if s.audio_only {
955 push!("-vn");
956 if let Some(au) = &s.audio {
957 push!("-c:a");
958 push!(au.codec.encoder());
959 if au.mono {
960 push!("-ac");
961 push!("1");
962 }
963 if let Some(sr) = au.sample_rate {
964 push!("-ar");
965 push!(sr.to_string());
966 }
967 push!("-b:a");
968 push!(au.bitrate_bps.to_string());
969 if matches!(au.codec, AudioCodec::Opus) {
972 push!("-vbr");
973 push!(if au.vbr { "on" } else { "constrained" });
974 }
975 }
976 a.push(plan.output.clone().into_os_string());
977 return a;
978 }
979
980 push!("-c:v");
982 push!(encoder);
983 if let Some(h) = s.video.height {
984 push!("-vf");
985 push!(format!("scale=-2:{h}"));
986 }
987 if let Some(f) = s.video.fps {
988 push!("-r");
989 push!(f.to_string());
990 }
991 let (speed_flag, speed_value) = s.video.preset.speed_flags(encoder);
994 push!(speed_flag);
995 push!(speed_value);
996 if let Some(tag) = s.video.codec.mp4_tag() {
997 push!("-tag:v");
998 push!(tag);
999 }
1000
1001 match (s.video.bitrate_bps, s.video.crf) {
1003 (Some(bps), _) => {
1004 push!("-b:v");
1005 push!(bps.to_string());
1006 if s.two_pass {
1007 push!("-pass");
1008 push!(match pass {
1009 PassKind::First => "1",
1010 _ => "2",
1011 });
1012 push!("-passlogfile");
1013 push!(passlog);
1014 }
1015 }
1016 (_, Some(crf)) => {
1017 push!("-crf");
1018 push!(crf.to_string());
1019 }
1020 _ => {}
1021 }
1022
1023 match pass {
1025 PassKind::First => {
1026 push!("-an");
1028 push!("-f");
1029 push!("null");
1030 push!(null_sink());
1031 }
1032 PassKind::Second | PassKind::Single => {
1033 match &s.audio {
1034 Some(au) => {
1035 push!("-c:a");
1036 push!(au.codec.encoder());
1037 if au.mono {
1041 push!("-ac");
1042 push!("1");
1043 }
1044 push!("-b:a");
1045 push!(au.bitrate_bps.to_string());
1046 }
1047 None => push!("-an"),
1048 }
1049 if s.faststart {
1050 push!("-movflags");
1051 push!("+faststart");
1052 }
1053 a.push(plan.output.clone().into_os_string());
1054 }
1055 }
1056 a
1057}
1058
1059#[cfg(test)]
1060mod tests {
1061 use super::*;
1062 use crate::options::{AudioCodec, QualityPreset, VideoCodec};
1063 use crate::size::preset;
1064
1065 fn enc(plan: &EncodePlan) -> &'static str {
1068 plan.spec.video.codec.encoder()
1069 }
1070
1071 fn video_info(duration: f64, size: u64, w: u32, h: u32, audio: bool) -> MediaInfo {
1072 MediaInfo {
1073 path: PathBuf::from("/tmp/clip.mp4"),
1074 kind: MediaKind::Video,
1075 duration_sec: duration,
1076 size_bytes: size,
1077 width: Some(w),
1078 height: Some(h),
1079 fps: Some(30.0),
1080 video_codec: Some("h264".into()),
1081 audio_codec: if audio { Some("aac".into()) } else { None },
1082 audio_channels: if audio { Some(2) } else { None },
1083 }
1084 }
1085
1086 fn opts_target(bytes: u64) -> ShrinkOpts {
1087 ShrinkOpts {
1088 goal: SizeGoal::Target(bytes),
1089 ..Default::default()
1090 }
1091 }
1092
1093 #[test]
1094 fn supports_video_and_audio() {
1095 let e = MediaEngine::new();
1096 assert!(e.supports(&PathBuf::from("clip.mp4")));
1097 assert!(e.supports(&PathBuf::from("lecture.wav")));
1098 assert!(!e.supports(&PathBuf::from("photo.jpg")));
1099 }
1100
1101 #[test]
1102 fn plan_target_builds_two_pass_with_budget() {
1103 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1104 let plan = MediaEngine::new()
1105 .plan(&info, &opts_target(8_000_000))
1106 .unwrap();
1107
1108 assert!(plan.spec.two_pass);
1109 assert_eq!(plan.target_bytes, Some(8_000_000));
1110 assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
1111 let vbps = plan.spec.video.bitrate_bps.unwrap();
1112 assert!(vbps >= budget::ABSOLUTE_MIN_VIDEO_BPS);
1113 assert!(plan.spec.video.height.is_some());
1115 assert!(plan.spec.audio.is_some());
1116 assert!(plan.expected_bytes.unwrap() <= 8_000_000 + 8_000_000 / 20);
1118 }
1119
1120 #[test]
1121 fn plan_video_mono_downmixes_the_audio_track() {
1122 let info = video_info(60.0, 100_000_000, 1280, 720, true);
1123 let opts = ShrinkOpts {
1124 mono: true,
1125 ..opts_target(8_000_000)
1126 };
1127 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1128 let audio = plan.spec.audio.as_ref().expect("kept audio track");
1129 assert!(audio.mono, "opts.mono downmixes the video's audio track");
1130 let stereo = MediaEngine::new()
1132 .plan(&info, &opts_target(8_000_000))
1133 .unwrap();
1134 assert!(!stereo.spec.audio.as_ref().unwrap().mono);
1135 }
1136
1137 #[test]
1138 fn video_mono_reaches_ffmpeg_as_ac_1() {
1139 let info = video_info(60.0, 100_000_000, 1280, 720, true);
1142 let plan = MediaEngine::new()
1143 .plan(
1144 &info,
1145 &ShrinkOpts {
1146 mono: true,
1147 ..opts_target(8_000_000)
1148 },
1149 )
1150 .unwrap();
1151 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan));
1152 let joined: Vec<String> = args
1153 .iter()
1154 .map(|a| a.to_string_lossy().into_owned())
1155 .collect();
1156 let ac = joined.iter().position(|a| a == "-ac").expect("-ac emitted");
1157 assert_eq!(joined[ac + 1], "1");
1158
1159 let stereo = MediaEngine::new()
1161 .plan(&info, &opts_target(8_000_000))
1162 .unwrap();
1163 let stereo_args: Vec<String> =
1164 build_pass_args(&stereo, PassKind::Second, "/tmp/passlog", enc(&stereo))
1165 .iter()
1166 .map(|a| a.to_string_lossy().into_owned())
1167 .collect();
1168 assert!(!stereo_args.iter().any(|a| a == "-ac"));
1169 }
1170
1171 #[test]
1172 fn plan_preset_discord_sets_target() {
1173 let info = video_info(30.0, 50_000_000, 1280, 720, true);
1174 let opts = ShrinkOpts {
1175 goal: SizeGoal::Preset(preset("discord").unwrap()),
1176 ..Default::default()
1177 };
1178 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1179 assert_eq!(plan.target_bytes, Some(8_000_000));
1180 }
1181
1182 #[test]
1183 fn plan_reduce_targets_complement_of_original() {
1184 let info = video_info(60.0, 100_000_000, 1920, 1080, true);
1185 let opts = ShrinkOpts {
1186 goal: SizeGoal::Reduce(0.70),
1187 ..Default::default()
1188 };
1189 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1190 assert_eq!(plan.target_bytes, Some(30_000_000));
1191 }
1192
1193 #[test]
1194 fn plan_passthrough_when_source_already_fits() {
1195 let info = video_info(10.0, 200_000, 1280, 720, true);
1197 let plan = MediaEngine::new()
1198 .plan(&info, &opts_target(1_000_000))
1199 .unwrap();
1200 assert!(plan.spec.passthrough);
1201 assert!(!plan.spec.two_pass);
1202 assert_eq!(plan.expected_bytes, Some(200_000));
1203 let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog", enc(&plan));
1204 let joined: Vec<String> = args
1205 .iter()
1206 .map(|a| a.to_string_lossy().into_owned())
1207 .collect();
1208 assert!(joined.contains(&"copy".to_string()));
1209 assert_eq!(plan.output, PathBuf::from("/tmp/clip.shrink.mp4"));
1212 }
1213
1214 #[test]
1215 fn plan_passthrough_keeps_the_source_container() {
1216 let info = MediaInfo {
1219 path: PathBuf::from("/tmp/voice.3gp"),
1220 ..video_info(10.0, 200_000, 320, 240, true)
1221 };
1222 let plan = MediaEngine::new()
1223 .plan(&info, &opts_target(1_000_000))
1224 .unwrap();
1225 assert!(plan.spec.passthrough);
1226 assert_eq!(plan.output, PathBuf::from("/tmp/voice.shrink.3gp"));
1227 }
1228
1229 #[test]
1230 fn plan_infeasible_when_target_too_small() {
1231 let info = video_info(600.0, 500_000_000, 1920, 1080, true);
1232 let err = MediaEngine::new().plan(&info, &opts_target(50_000));
1233 assert!(matches!(err, Err(EngineError::Infeasible)));
1234 }
1235
1236 #[test]
1237 fn plan_quality_mode_uses_crf_single_pass() {
1238 let info = video_info(60.0, 100_000_000, 1920, 1080, true);
1239 let opts = ShrinkOpts {
1240 goal: SizeGoal::Quality,
1241 quality: QualityPreset::Balanced,
1242 ..Default::default()
1243 };
1244 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1245 assert!(!plan.spec.two_pass);
1246 assert_eq!(plan.spec.video.crf, Some(23));
1247 assert!(plan.spec.video.bitrate_bps.is_none());
1248 assert!(plan.expected_bytes.is_none());
1249 }
1250
1251 #[test]
1252 fn plan_drops_audio_when_requested() {
1253 let info = video_info(30.0, 50_000_000, 1280, 720, true);
1254 let opts = ShrinkOpts {
1255 audio: AudioChoice::Drop,
1256 ..opts_target(8_000_000)
1257 };
1258 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1259 assert!(plan.spec.audio.is_none());
1260 }
1261
1262 fn audio_info(duration: f64, size: u64, channels: u32) -> MediaInfo {
1263 MediaInfo {
1264 path: PathBuf::from("/tmp/lecture.wav"),
1265 kind: MediaKind::Audio,
1266 duration_sec: duration,
1267 size_bytes: size,
1268 width: None,
1269 height: None,
1270 fps: None,
1271 video_codec: None,
1272 audio_codec: Some("pcm_s16le".into()),
1273 audio_channels: Some(channels),
1274 }
1275 }
1276
1277 #[test]
1278 fn quality_audio_bitrate_follows_tier_codec_and_channels() {
1279 use QualityPreset::*;
1280 assert_eq!(quality_audio_bps(Balanced, AudioCodec::Aac, false), 128_000);
1281 assert_eq!(quality_audio_bps(Balanced, AudioCodec::Aac, true), 64_000);
1282 assert_eq!(quality_audio_bps(Fast, AudioCodec::Opus, true), 32_000);
1283 assert_eq!(quality_audio_bps(Max, AudioCodec::Mp3, false), 256_000);
1284 for c in [AudioCodec::Aac, AudioCodec::Opus, AudioCodec::Mp3] {
1286 let t: Vec<_> = [Fast, Balanced, Max]
1287 .map(|q| quality_audio_bps(q, c, false))
1288 .into();
1289 assert!(t[0] < t[1] && t[1] < t[2], "{c:?}: {t:?}");
1290 }
1291 }
1292
1293 #[test]
1294 fn a_compact_audiobook_is_kept_in_quality_mode() {
1295 let mut info = audio_info(3600.0, 64_000 / 8 * 3600, 1);
1297 info.path = PathBuf::from("/tmp/book.mp3");
1298 let plan = MediaEngine::new()
1299 .plan(&info, &ShrinkOpts::default())
1300 .unwrap();
1301 assert!(plan.spec.passthrough, "{}", plan.summary);
1302 assert!(plan.output.to_string_lossy().ends_with(".mp3"));
1303 assert!(plan.summary.contains("already compact"));
1304
1305 let smaller = ShrinkOpts {
1307 audio_codec: AudioCodec::Opus,
1308 quality: QualityPreset::Fast,
1309 ..ShrinkOpts::default()
1310 };
1311 let plan = MediaEngine::new().plan(&info, &smaller).unwrap();
1312 assert!(!plan.spec.passthrough);
1313 assert_eq!(plan.spec.audio.as_ref().unwrap().bitrate_bps, 32_000);
1314 assert!(
1315 plan.guard_larger,
1316 "quality mode keeps the post-encode check"
1317 );
1318
1319 let allow = ShrinkOpts {
1321 allow_larger: true,
1322 ..ShrinkOpts::default()
1323 };
1324 let plan = MediaEngine::new().plan(&info, &allow).unwrap();
1325 assert!(!plan.spec.passthrough && !plan.guard_larger);
1326 }
1327
1328 #[test]
1329 fn the_guard_is_for_quality_mode_only() {
1330 let info = video_info(60.0, 50_000_000, 1920, 1080, true);
1331 let quality = MediaEngine::new()
1332 .plan(&info, &ShrinkOpts::default())
1333 .unwrap();
1334 assert!(quality.guard_larger);
1335 let target = MediaEngine::new()
1336 .plan(&info, &opts_target(10_000_000))
1337 .unwrap();
1338 assert!(!target.guard_larger, "a size target is its own guarantee");
1339 }
1340
1341 #[test]
1342 fn plan_audio_single_pass_with_fitted_bitrate() {
1343 let info = audio_info(3480.0, 600_000_000, 2);
1345 let plan = MediaEngine::new()
1346 .plan(&info, &opts_target(10_000_000))
1347 .unwrap();
1348 assert!(plan.spec.audio_only);
1349 assert!(!plan.spec.two_pass);
1350 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.m4a"));
1351 let au = plan.spec.audio.as_ref().unwrap();
1352 assert!(budget::AUDIO_STEPS.contains(&au.bitrate_bps));
1354 assert!(plan.expected_bytes.unwrap() <= 10_000_000 + 10_000_000 / 20);
1355 }
1356
1357 #[test]
1358 fn plan_audio_mono_source_marked_speech() {
1359 let info = audio_info(600.0, 100_000_000, 1);
1360 let plan = MediaEngine::new()
1361 .plan(&info, &opts_target(5_000_000))
1362 .unwrap();
1363 assert!(plan.spec.audio.as_ref().unwrap().mono);
1364 }
1365
1366 #[test]
1367 fn plan_audio_opus_extension_and_vbr_args() {
1368 let info = audio_info(600.0, 100_000_000, 2);
1369 let opts = ShrinkOpts {
1370 audio_codec: AudioCodec::Opus,
1371 mono: true,
1372 ..opts_target(3_000_000)
1373 };
1374 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1375 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.opus"));
1376 let args = build_pass_args(&plan, PassKind::Single, "/tmp/passlog", enc(&plan));
1377 let j: Vec<String> = args
1378 .iter()
1379 .map(|a| a.to_string_lossy().into_owned())
1380 .collect();
1381 assert!(j.contains(&"-vn".to_string()));
1382 assert!(j.contains(&"libopus".to_string()));
1383 assert!(j.contains(&"-ac".to_string())); assert!(j.contains(&"-vbr".to_string()));
1385 }
1386
1387 #[test]
1388 fn plan_audio_infeasible_when_target_tiny() {
1389 let info = audio_info(3600.0, 500_000_000, 2);
1390 assert!(matches!(
1391 MediaEngine::new().plan(&info, &opts_target(1_000)),
1392 Err(EngineError::Infeasible)
1393 ));
1394 }
1395
1396 #[test]
1397 fn plan_audio_passthrough_when_source_fits() {
1398 let info = audio_info(600.0, 2_000_000, 2);
1399 let plan = MediaEngine::new()
1400 .plan(&info, &opts_target(10_000_000))
1401 .unwrap();
1402 assert!(plan.spec.passthrough);
1403 assert_eq!(plan.output, PathBuf::from("/tmp/lecture.shrink.wav"));
1405 }
1406
1407 #[test]
1408 fn pass1_args_have_no_audio_and_null_sink() {
1409 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1410 let plan = MediaEngine::new()
1411 .plan(&info, &opts_target(8_000_000))
1412 .unwrap();
1413 let args = build_pass_args(&plan, PassKind::First, "/tmp/passlog", enc(&plan));
1414 let joined: Vec<String> = args
1415 .iter()
1416 .map(|a| a.to_string_lossy().into_owned())
1417 .collect();
1418 assert!(joined.contains(&"-an".to_string()));
1419 assert!(joined.contains(&"null".to_string()));
1420 assert!(joined.iter().any(|a| a == "1")); assert!(!joined.iter().any(|a| a.contains("shrink.mp4")));
1422 }
1423
1424 #[test]
1425 fn pass2_args_write_output_with_audio() {
1426 let info = video_info(120.0, 300_000_000, 1920, 1080, true);
1427 let plan = MediaEngine::new()
1428 .plan(&info, &opts_target(8_000_000))
1429 .unwrap();
1430 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan));
1431 let joined: Vec<String> = args
1432 .iter()
1433 .map(|a| a.to_string_lossy().into_owned())
1434 .collect();
1435 assert!(joined.iter().any(|a| a.contains("clip.shrink.mp4")));
1436 assert!(joined.contains(&"-c:a".to_string()));
1437 assert!(joined.contains(&"+faststart".to_string()));
1438 assert!(joined.iter().any(|a| a == "2")); }
1440
1441 #[test]
1442 fn h265_adds_hvc1_tag() {
1443 let info = video_info(60.0, 100_000_000, 1280, 720, false);
1444 let opts = ShrinkOpts {
1445 video_codec: VideoCodec::H265,
1446 ..opts_target(8_000_000)
1447 };
1448 let plan = MediaEngine::new().plan(&info, &opts).unwrap();
1449 let args = build_pass_args(&plan, PassKind::Second, "/tmp/passlog", enc(&plan));
1450 let joined: Vec<String> = args
1451 .iter()
1452 .map(|a| a.to_string_lossy().into_owned())
1453 .collect();
1454 assert!(joined.contains(&"hvc1".to_string()));
1455 assert!(joined.contains(&"libx265".to_string()));
1456 }
1457}