1use std::path::{Path, PathBuf};
8
9use super::{
10 AudioSpec, EncodePlan, EncodeSpec, EngineError, MediaInfo, ShrinkOpts, SizeGoal, VideoSpec,
11};
12use crate::budget;
13use crate::detect::MediaKind;
14use crate::options::{AudioChoice, AudioCodec, FpsOpt, QualityPreset, ResolutionOpt, VideoCodec};
15
16pub fn plan(
20 info: &MediaInfo,
21 opts: &ShrinkOpts,
22 hw_available: bool,
23) -> Result<EncodePlan, EngineError> {
24 match info.kind {
25 MediaKind::Audio => return plan_audio(info, opts),
26 MediaKind::Unsupported => {
27 return Err(EngineError::Unsupported(format!(
28 "{} is not a supported media file",
29 info.path.display()
30 )))
31 }
32 MediaKind::Video => {}
33 }
34 let duration = info.duration_sec;
35 if !duration.is_finite() || duration <= 0.0 {
36 return Err(EngineError::Unsupported(format!(
37 "could not determine duration of {}",
38 info.path.display()
39 )));
40 }
41 let (w, h) = (info.width.unwrap_or(0), info.height.unwrap_or(0));
43 let portrait = h > w;
44 let src_height = w.min(h);
45
46 let target = target_bytes(&opts.goal, info.size_bytes);
47 let output = opts
48 .output
49 .clone()
50 .unwrap_or_else(|| output_with_ext(&info.path, video_container(info, target, opts)));
51
52 if let Some(tb) = target {
58 if info.size_bytes > 0 && info.size_bytes <= tb {
59 let src_ext = info
60 .path
61 .extension()
62 .and_then(|e| e.to_str())
63 .unwrap_or("mp4");
64 let output = opts
65 .output
66 .clone()
67 .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
68 return Ok(passthrough_plan(info, output, tb, true, opts.keep_metadata));
69 }
70 }
71
72 let audio = decide_audio(
73 opts,
74 info.has_audio(),
75 info.audio_bitrate_bps,
76 target,
77 duration,
78 )?;
79 let audio_bps = audio.as_ref().map(|a| a.bitrate_bps).unwrap_or(0);
80
81 let hw_quality = opts
84 .quality
85 .default_hw_quality(opts.video_codec)
86 .filter(|_| opts.hardware && opts.target_vmaf.is_none() && hw_available);
87 let hardware = hw_quality.is_some();
88 let quality_value = hw_quality.unwrap_or_else(|| opts.quality.default_crf(opts.video_codec));
90
91 let (video, expected_bytes) = if let Some(tb) = target {
92 let vbps = budget::video_bitrate_bps(tb, duration, audio_bps)
93 .filter(|&b| b >= budget::ABSOLUTE_MIN_VIDEO_BPS)
94 .ok_or(EngineError::Infeasible)?;
95 let height = pick_height(opts.resolution, src_height, vbps);
96 let predicted = ((vbps + audio_bps) as f64 * duration / 8.0
97 * (1.0 + budget::CONTAINER_OVERHEAD))
98 .round() as u64;
99 (
100 VideoSpec {
101 codec: opts.video_codec,
102 bitrate_bps: Some(vbps),
103 crf: None,
104 height,
105 fps: pick_fps(opts.fps, info.fps),
106 preset: opts.quality,
107 to_sdr: info.hdr,
109 hardware,
110 portrait,
111 },
112 Some(predicted),
113 )
114 } else {
115 let crf = quality_value;
118 let height = match opts.resolution {
119 ResolutionOpt::Height(h) => clamp_height(h, src_height),
120 ResolutionOpt::Auto => None,
121 };
122 (
123 VideoSpec {
124 codec: opts.video_codec,
125 bitrate_bps: None,
126 crf: Some(crf),
127 height,
128 fps: pick_fps(opts.fps, info.fps),
129 preset: opts.quality,
130 to_sdr: info
133 .hdr
134 .filter(|_| hardware && opts.video_codec == VideoCodec::H264),
135 hardware,
136 portrait,
137 },
138 None,
139 )
140 };
141
142 let two_pass = video.bitrate_bps.is_some() && opts.two_pass.unwrap_or(true) && !hardware;
148 let summary = build_summary(&video, audio.as_ref(), two_pass);
149
150 Ok(EncodePlan {
151 input: info.path.clone(),
152 output,
153 summary,
154 expected_bytes,
155 target_bytes: target,
156 target_vmaf: opts.target_vmaf,
157 source_duration_sec: duration,
158 source_width: info.width,
159 source_height: info.height,
160 source_fps: info.fps,
161 spec: EncodeSpec {
162 video,
163 audio,
164 faststart: true,
165 two_pass,
166 passthrough: false,
167 audio_only: false,
168 dpi: None,
169 keep_metadata: opts.keep_metadata,
170 tags: capture_tags(info, opts.keep_metadata),
171 },
172 guard_larger: target.is_none() && !opts.allow_larger,
174 ceiling_crf: target.map(|_| quality_value),
175 })
176}
177
178pub(crate) fn plan_audio(info: &MediaInfo, opts: &ShrinkOpts) -> Result<EncodePlan, EngineError> {
180 let duration = info.duration_sec;
181 if !duration.is_finite() || duration <= 0.0 {
182 return Err(EngineError::Unsupported(format!(
183 "could not determine duration of {}",
184 info.path.display()
185 )));
186 }
187 let codec = opts.audio_codec;
188 let target = target_bytes(&opts.goal, info.size_bytes);
189
190 if let Some(tb) = target {
192 if info.size_bytes > 0 && info.size_bytes <= tb {
193 let src_ext = info
194 .path
195 .extension()
196 .and_then(|e| e.to_str())
197 .unwrap_or("audio");
198 let output = opts
199 .output
200 .clone()
201 .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
202 return Ok(passthrough_plan(
203 info,
204 output,
205 tb,
206 false,
207 opts.keep_metadata,
208 ));
209 }
210 }
211
212 let mono = opts.mono || info.audio_channels == Some(1);
214
215 let (bitrate_bps, expected_bytes) = match target {
216 Some(tb) => {
217 let raw = budget::audio_bitrate_bps(tb, duration).ok_or(EngineError::Infeasible)?;
218 if raw < budget::ABSOLUTE_MIN_AUDIO_BPS {
219 return Err(EngineError::Infeasible);
220 }
221 let bps = budget::snap_audio_bitrate(raw);
222 let predicted =
223 (bps as f64 * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD)).round() as u64;
224 (bps, Some(predicted))
225 }
226 None => {
227 let bps = quality_audio_bps(opts.quality, codec, mono);
229 if !opts.allow_larger && already_compact_audio(bps, info) {
233 let src_ext = info
234 .path
235 .extension()
236 .and_then(|e| e.to_str())
237 .unwrap_or("audio");
238 let output = opts
239 .output
240 .clone()
241 .unwrap_or_else(|| output_with_ext(&info.path, src_ext));
242 let mut plan =
243 passthrough_plan(info, output, info.size_bytes, false, opts.keep_metadata);
244 plan.summary =
245 "stream copy (already compact — a re-encode would not be smaller)".into();
246 return Ok(plan);
247 }
248 let predicted =
250 (bps as f64 * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD)).round() as u64;
251 (bps, Some(predicted))
252 }
253 };
254
255 let audio = AudioSpec {
256 codec,
257 bitrate_bps,
258 mono,
259 sample_rate: opts.sample_rate,
260 vbr: opts.vbr,
261 };
262 let output = opts
263 .output
264 .clone()
265 .unwrap_or_else(|| output_with_ext(&info.path, codec.extension()));
266 let summary = build_audio_summary(&audio, info.audio_channels);
267
268 Ok(EncodePlan {
269 input: info.path.clone(),
270 output,
271 summary,
272 expected_bytes,
273 target_bytes: target,
274 target_vmaf: None,
275 source_duration_sec: duration,
276 source_width: info.width,
277 source_height: info.height,
278 source_fps: info.fps,
279 spec: EncodeSpec {
280 video: placeholder_video_spec(),
281 audio: Some(audio),
282 faststart: false,
283 two_pass: false,
284 passthrough: false,
285 audio_only: true,
286 dpi: None,
287 keep_metadata: opts.keep_metadata,
288 tags: capture_tags(info, opts.keep_metadata),
289 },
290 guard_larger: target.is_none() && !opts.allow_larger,
292 ceiling_crf: None,
293 })
294}
295
296pub(crate) const AUDIO_LADDER: &[u64] = &[128_000, 96_000, 64_000, 48_000];
299
300pub(crate) fn placeholder_video_spec() -> VideoSpec {
302 VideoSpec {
303 codec: crate::options::VideoCodec::H264,
304 bitrate_bps: None,
305 crf: None,
306 height: None,
307 fps: None,
308 preset: crate::options::QualityPreset::Balanced,
309 to_sdr: None,
310 hardware: false,
311 portrait: false,
312 }
313}
314
315pub(crate) fn passthrough_plan(
318 info: &MediaInfo,
319 output: PathBuf,
320 target: u64,
321 faststart: bool,
322 keep_metadata: bool,
323) -> EncodePlan {
324 EncodePlan {
325 input: info.path.clone(),
326 output,
327 summary: "stream copy (already within target)".to_string(),
328 expected_bytes: Some(info.size_bytes),
329 target_bytes: Some(target),
330 target_vmaf: None,
331 source_duration_sec: info.duration_sec,
332 source_width: info.width,
333 source_height: info.height,
334 source_fps: info.fps,
335 spec: EncodeSpec {
336 video: placeholder_video_spec(),
337 audio: None,
338 faststart,
339 two_pass: false,
340 passthrough: true,
341 audio_only: false,
342 dpi: None,
343 keep_metadata,
344 tags: capture_tags(info, keep_metadata),
345 },
346 guard_larger: false,
347 ceiling_crf: None,
348 }
349}
350
351pub(crate) fn quality_audio_bps(quality: QualityPreset, codec: AudioCodec, mono: bool) -> u64 {
354 let stereo = match (codec, quality) {
355 (AudioCodec::Opus, QualityPreset::Fast) => 64_000,
356 (AudioCodec::Opus, QualityPreset::Balanced) => 96_000,
357 (AudioCodec::Opus, QualityPreset::Max) => 128_000,
358 (AudioCodec::Mp3, QualityPreset::Fast) => 128_000,
359 (AudioCodec::Mp3, QualityPreset::Balanced) => 160_000,
360 (AudioCodec::Mp3, QualityPreset::Max) => 256_000,
361 (AudioCodec::Aac, QualityPreset::Fast) => 96_000,
362 (AudioCodec::Aac, QualityPreset::Balanced) => 128_000,
363 (AudioCodec::Aac, QualityPreset::Max) => 192_000,
364 };
365 if mono {
366 stereo / 2
367 } else {
368 stereo
369 }
370}
371
372pub(crate) fn already_compact_audio(bps: u64, info: &MediaInfo) -> bool {
376 if info.duration_sec <= 0.0 || info.size_bytes == 0 {
377 return false;
378 }
379 let source_bps = info.size_bytes as f64 * 8.0 / info.duration_sec;
380 bps as f64 >= source_bps * 0.9
381}
382
383pub(crate) fn video_container(
389 info: &MediaInfo,
390 target: Option<u64>,
391 opts: &ShrinkOpts,
392) -> &'static str {
393 let mov_source = info
394 .path
395 .extension()
396 .and_then(|e| e.to_str())
397 .is_some_and(|e| e.eq_ignore_ascii_case("mov"));
398 if mov_source && target.is_none() && opts.video_codec != VideoCodec::Av1 {
399 "mov"
400 } else {
401 "mp4"
402 }
403}
404
405pub(crate) fn capture_tags(info: &MediaInfo, keep: bool) -> Vec<(String, String)> {
408 if !keep {
409 return Vec::new();
410 }
411 let c = &info.capture;
412 [
413 ("creation_time", c.created_utc.as_ref()),
414 ("date", c.created_local.as_ref()),
415 ("location", c.location.as_ref()),
416 ("make", c.make.as_ref()),
417 ("model", c.model.as_ref()),
418 ]
419 .into_iter()
420 .filter_map(|(k, v)| v.map(|v| (k.to_string(), v.clone())))
421 .collect()
422}
423
424pub fn to_utc(s: &str) -> Option<String> {
427 let s = s.trim();
428 let num = |a: usize, b: usize| s.get(a..b)?.parse::<i64>().ok();
429 let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
430 let (h, mi, se) = (num(11, 13)?, num(14, 16)?, num(17, 19)?);
431 if s.get(4..5)? != "-" || s.get(10..11).map(|c| c == "T" || c == " ") != Some(true) {
432 return None;
433 }
434 let rest = s
436 .get(19..)?
437 .trim_start_matches(|c: char| c == '.' || c.is_ascii_digit());
438 let offset_min = match rest {
439 "" | "Z" | "z" => 0,
440 r if r.starts_with('+') || r.starts_with('-') => {
441 let digits: String = r[1..].chars().filter(char::is_ascii_digit).collect();
442 let (oh, om) = (
443 digits.get(0..2)?.parse::<i64>().ok()?,
444 digits.get(2..4).unwrap_or("00").parse::<i64>().ok()?,
445 );
446 let m = oh * 60 + om;
447 if r.starts_with('-') {
448 -m
449 } else {
450 m
451 }
452 }
453 _ => return None,
454 };
455 let secs = days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + se - offset_min * 60;
456 let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
457 let (y, mo, d) = civil_from_days(days);
458 Some(format!(
459 "{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}Z",
460 rem / 3600,
461 rem % 3600 / 60,
462 rem % 60
463 ))
464}
465
466fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
468 let y = if m <= 2 { y - 1 } else { y };
469 let era = y.div_euclid(400);
470 let yoe = y - era * 400;
471 let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
472 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
473 era * 146_097 + doe - 719_468
474}
475
476fn civil_from_days(z: i64) -> (i64, i64, i64) {
478 let z = z + 719_468;
479 let era = z.div_euclid(146_097);
480 let doe = z - era * 146_097;
481 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
482 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
483 let mp = (5 * doy + 2) / 153;
484 let d = doy - (153 * mp + 2) / 5 + 1;
485 let m = if mp < 10 { mp + 3 } else { mp - 9 };
486 (yoe + era * 400 + i64::from(m <= 2), m, d)
487}
488
489pub const CEILING_MARGIN: f64 = 0.9;
492
493pub fn ceiling_plan(plan: &EncodePlan) -> Option<EncodePlan> {
496 let crf = plan.ceiling_crf?;
497 if plan.target_bytes.is_none()
498 || plan.spec.passthrough
499 || plan.spec.audio_only
500 || plan.spec.video.bitrate_bps.is_none()
501 {
502 return None;
503 }
504 let mut c = plan.clone();
505 c.spec.video.bitrate_bps = None;
506 c.spec.video.crf = Some(crf);
507 c.spec.two_pass = false;
508 c.summary = build_summary(&c.spec.video, c.spec.audio.as_ref(), false);
509 Some(c)
510}
511
512pub(crate) fn build_audio_summary(audio: &AudioSpec, src_channels: Option<u32>) -> String {
515 let mut parts = vec![
516 audio.codec.label().to_string(),
517 format!("{} kbps", audio.bitrate_bps / 1000),
518 ];
519 if audio.mono {
520 let note = if src_channels == Some(1) {
522 "mono"
523 } else {
524 "mono (downmix)"
525 };
526 parts.push(note.to_string());
527 }
528 if let Some(sr) = audio.sample_rate {
529 parts.push(format!("{} Hz", sr));
530 }
531 parts.join(" · ")
532}
533
534pub(crate) fn target_bytes(goal: &SizeGoal, original: u64) -> Option<u64> {
536 match goal {
537 SizeGoal::Target(b) => Some(*b),
538 SizeGoal::Reduce(f) => Some(budget::reduce_target_bytes(original, *f)),
539 SizeGoal::Preset(p) => p.limit_bytes,
540 SizeGoal::Quality => None,
541 }
542}
543
544pub(crate) fn decide_audio(
546 opts: &ShrinkOpts,
547 has_audio: bool,
548 source_bps: Option<u64>,
549 target: Option<u64>,
550 duration: f64,
551) -> Result<Option<AudioSpec>, EngineError> {
552 if !has_audio {
553 return Ok(None);
554 }
555 let mono = opts.mono;
558 match opts.audio {
559 AudioChoice::Drop => Ok(None),
560 AudioChoice::Bitrate(b) => Ok(Some(AudioSpec {
561 mono,
562 ..AudioSpec::cbr(AudioCodec::Aac, b)
563 })),
564 AudioChoice::Keep => {
565 let bps = match target {
566 Some(tb) => budget::fit_audio_bps(tb, duration, AUDIO_LADDER)
567 .ok_or(EngineError::Infeasible)?,
568 None => budget::DEFAULT_AUDIO_BPS,
569 };
570 let bps = match source_bps {
574 Some(src) => bps.min(src.max(MIN_TRACK_BPS)),
575 None => bps,
576 };
577 Ok(Some(AudioSpec {
578 mono,
579 ..AudioSpec::cbr(AudioCodec::Aac, bps)
580 }))
581 }
582 }
583}
584
585pub(crate) const MIN_TRACK_BPS: u64 = 32_000;
588
589pub(crate) fn pick_height(res: ResolutionOpt, src_height: u32, vbps: u64) -> Option<u32> {
591 match res {
592 ResolutionOpt::Height(h) => clamp_height(h, src_height),
593 ResolutionOpt::Auto => {
594 let chosen = budget::choose_height(src_height, vbps);
595 if src_height > 0 && chosen < src_height {
596 Some(chosen)
597 } else {
598 None
599 }
600 }
601 }
602}
603
604pub(crate) fn clamp_height(requested: u32, src_height: u32) -> Option<u32> {
607 if src_height == 0 {
608 return Some(requested);
609 }
610 let h = requested.min(src_height);
611 if h == src_height {
612 None
613 } else {
614 Some(h)
615 }
616}
617
618pub(crate) fn pick_fps(fps: FpsOpt, src_fps: Option<f64>) -> Option<u32> {
620 match fps {
621 FpsOpt::Auto => None,
622 FpsOpt::Cap(f) => match src_fps {
623 Some(src) if (f as f64) >= src => None,
624 _ => Some(f),
625 },
626 }
627}
628
629pub(crate) fn output_with_ext(input: &Path, ext: &str) -> PathBuf {
631 let stem = input
632 .file_stem()
633 .map(|s| s.to_string_lossy().into_owned())
634 .unwrap_or_else(|| "output".to_string());
635 let mut out = input.parent().map(Path::to_path_buf).unwrap_or_default();
636 out.push(format!("{stem}.shrink.{ext}"));
637 out
638}
639
640pub(crate) fn build_summary(
641 video: &VideoSpec,
642 audio: Option<&AudioSpec>,
643 two_pass: bool,
644) -> String {
645 let mut parts = vec![if video.hardware {
646 format!("{} (Apple hardware)", video.codec.label())
647 } else {
648 video.codec.label().to_string()
649 }];
650 match (video.bitrate_bps, video.crf) {
651 (Some(bps), _) => parts.push(format!("up to {} kbps video", bps / 1000)),
652 (_, Some(q)) if video.hardware => parts.push(format!("quality {q}")),
653 (_, Some(crf)) => parts.push(format!("CRF {crf}")),
654 _ => {}
655 }
656 if let Some(a) = audio {
657 parts.push(format!("{} kbps audio", a.bitrate_bps / 1000));
658 } else {
659 parts.push("no audio".to_string());
660 }
661 if let Some(h) = video.height {
662 parts.push(format!("{h}p"));
663 }
664 if let Some(f) = video.fps {
665 parts.push(format!("{f} fps"));
666 }
667 if video.to_sdr.is_some() {
668 parts.push("HDR → SDR".to_string());
669 }
670 parts.push(
671 if two_pass {
672 "two-pass"
673 } else if video.hardware {
674 "one pass"
675 } else {
676 "CRF"
677 }
678 .to_string(),
679 );
680 parts.join(" · ")
681}
682
683pub fn corrected_bitrate(vbps: u64, target: u64, size: u64) -> Option<u64> {
687 let corrected = (vbps as f64 * (target as f64 / size as f64) * 0.97) as u64;
688 (corrected >= budget::ABSOLUTE_MIN_VIDEO_BPS).then_some(corrected)
689}
690
691pub const SAMPLE_SECS: f64 = 3.0;
693pub const MIN_SAMPLE_SECS: f64 = 1.5;
697
698pub fn sample_secs(plan: &EncodePlan) -> f64 {
701 const REFERENCE: f64 = 1920.0 * 1080.0 * 30.0;
702 let (w, h) = match (plan.source_width, plan.source_height) {
703 (Some(w), Some(h)) if w > 0 && h > 0 => (w as f64, h as f64),
704 _ => return SAMPLE_SECS,
705 };
706 let fps = plan
707 .source_fps
708 .filter(|f| f.is_finite() && *f > 0.0)
709 .unwrap_or(30.0);
710 (SAMPLE_SECS * REFERENCE / (w * h * fps)).clamp(MIN_SAMPLE_SECS, SAMPLE_SECS)
711}
712pub const SAMPLE_BIAS: f64 = 0.92;
715pub const SAMPLE_BIAS_HW: f64 = 0.97;
720
721pub fn sample_windows(plan: &EncodePlan) -> (Vec<(f64, f64)>, f64) {
727 let duration = plan.source_duration_sec;
728 let win = sample_secs(plan);
729 if duration >= win * 4.0 {
730 (
731 [0.2, 0.5, 0.8]
732 .iter()
733 .map(|at| ((duration * at - win / 2.0).max(0.0), win))
734 .collect(),
735 if plan.spec.video.hardware {
736 SAMPLE_BIAS_HW
737 } else {
738 SAMPLE_BIAS
739 },
740 )
741 } else {
742 (vec![(0.0, duration)], 1.0)
743 }
744}
745
746pub fn predicted_bytes(plan: &EncodePlan, video_bytes: u64, sampled_secs: f64, bias: f64) -> u64 {
750 let duration = plan.source_duration_sec;
751 if sampled_secs <= 0.0 {
752 return 0;
753 }
754 let video_bps = video_bytes as f64 * 8.0 / sampled_secs * bias;
755 let audio_bps = plan.spec.audio.as_ref().map(|a| a.bitrate_bps).unwrap_or(0) as f64;
756 ((video_bps + audio_bps) * duration / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD)) as u64
757}
758
759#[cfg(test)]
760mod tests {
761 use super::*;
764 use crate::engine::CaptureMeta;
765
766 fn video(w: u32, h: u32, secs: f64, size: u64) -> MediaInfo {
767 MediaInfo {
768 path: PathBuf::from("/tmp/clip.mp4"),
769 kind: MediaKind::Video,
770 duration_sec: secs,
771 size_bytes: size,
772 width: Some(w),
773 height: Some(h),
774 fps: Some(30.0),
775 video_codec: Some("h264".into()),
776 audio_codec: Some("aac".into()),
777 audio_channels: Some(2),
778 audio_bitrate_bps: Some(128_000),
779 capture: CaptureMeta::default(),
780 hdr: None,
781 }
782 }
783
784 #[test]
785 fn a_size_target_plans_a_bitrate_under_budget() {
786 let opts = ShrinkOpts {
787 goal: SizeGoal::Target(10_000_000),
788 ..ShrinkOpts::default()
789 };
790 let plan = plan(&video(1920, 1080, 60.0, 200_000_000), &opts, false).unwrap();
791 let vbps = plan.spec.video.bitrate_bps.unwrap();
792 assert_eq!(
793 vbps,
794 budget::video_bitrate_bps(10_000_000, 60.0, 128_000).unwrap()
795 );
796 assert!(plan.spec.two_pass);
797 assert!(plan.expected_bytes.unwrap() <= 10_000_000);
798 }
799
800 #[test]
801 fn apple_hardware_quality_is_used_only_when_available() {
802 let opts = ShrinkOpts {
803 hardware: true,
804 ..ShrinkOpts::default()
805 };
806 let info = video(1920, 1080, 60.0, 200_000_000);
807 let hw = plan(&info, &opts, true).unwrap();
808 assert!(hw.spec.video.hardware);
809 assert_eq!(
810 hw.spec.video.crf,
811 QualityPreset::Balanced.default_hw_quality(VideoCodec::H264)
812 );
813 let sw = plan(&info, &opts, false).unwrap();
814 assert!(!sw.spec.video.hardware);
815 assert_eq!(
816 sw.spec.video.crf,
817 Some(QualityPreset::Balanced.default_crf(VideoCodec::H264))
818 );
819 }
820
821 #[test]
822 fn a_portrait_cap_is_on_the_short_side() {
823 let opts = ShrinkOpts {
824 resolution: ResolutionOpt::Height(1080),
825 ..ShrinkOpts::default()
826 };
827 let p = plan(&video(2160, 3840, 30.0, 100_000_000), &opts, false).unwrap();
828 assert_eq!(p.spec.video.height, Some(1080));
829 assert!(p.spec.video.portrait);
830 }
831
832 #[test]
833 fn an_overshoot_is_corrected_in_proportion_down_to_a_floor() {
834 assert_eq!(
836 corrected_bitrate(4_000_000, 10_000_000, 12_000_000),
837 Some(3_233_333)
838 );
839 assert_eq!(corrected_bitrate(10_000, 1_000, 1_000_000), None);
840 let opts = ShrinkOpts {
842 goal: SizeGoal::Target(50_000_000),
843 ..ShrinkOpts::default()
844 };
845 let p = plan(&video(1920, 1080, 60.0, 200_000_000), &opts, false).unwrap();
846 let c = ceiling_plan(&p).unwrap();
847 assert_eq!(c.spec.video.bitrate_bps, None);
848 assert!(c.spec.video.crf.is_some() && !c.spec.two_pass);
849 }
850
851 #[test]
852 fn long_clips_sample_three_windows_short_ones_the_whole_clip() {
853 let opts = ShrinkOpts::default();
854 let long = plan(&video(1920, 1080, 60.0, 200_000_000), &opts, false).unwrap();
855 let (w, bias) = sample_windows(&long);
856 assert_eq!(w.len(), 3);
857 assert_eq!(w[1], (28.5, 3.0));
858 assert_eq!(bias, SAMPLE_BIAS);
859 let bytes = predicted_bytes(&long, 1_000_000, 9.0, 1.0);
861 assert_eq!(
862 bytes,
863 ((8_000_000.0 / 9.0 + 128_000.0) * 60.0 / 8.0 * (1.0 + budget::CONTAINER_OVERHEAD))
864 as u64
865 );
866 let short = plan(&video(1920, 1080, 8.0, 20_000_000), &opts, false).unwrap();
867 assert_eq!(sample_windows(&short), (vec![(0.0, 8.0)], 1.0));
868 let hw_opts = ShrinkOpts {
870 hardware: true,
871 ..ShrinkOpts::default()
872 };
873 let hw = plan(&video(1920, 1080, 60.0, 200_000_000), &hw_opts, true).unwrap();
874 assert_eq!(sample_windows(&hw).1, SAMPLE_BIAS_HW);
875 }
876
877 #[test]
878 fn local_capture_time_converts_to_utc() {
879 assert_eq!(
880 to_utc("2026-09-26T20:01:54+0300").as_deref(),
881 Some("2026-09-26T17:01:54Z")
882 );
883 }
884}