1use crate::{QualityMode, TranscodeConfig};
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub enum TranscodePreset {
22 YouTubeHd,
25 YouTubeUhd,
27 NetflixHd,
29 TwitchStreamHd,
31 LosslessArchive,
34 ProresLt,
36 BroadcastHd,
39 WebDelivery,
41 PodcastAudio,
43}
44
45impl TranscodePreset {
46 #[must_use]
52 pub fn into_config(self) -> TranscodeConfig {
53 match self {
54 Self::YouTubeHd => TranscodeConfig {
55 video_codec: Some("av1".to_string()),
56 audio_codec: Some("opus".to_string()),
57 video_bitrate: Some(4_000_000),
58 audio_bitrate: Some(192_000),
59 width: Some(1920),
60 height: Some(1080),
61 frame_rate: Some((30, 1)),
62 quality_mode: Some(QualityMode::High),
63 hw_accel: true,
64 preserve_metadata: true,
65 ..TranscodeConfig::default()
66 },
67
68 Self::YouTubeUhd => TranscodeConfig {
69 video_codec: Some("av1".to_string()),
70 audio_codec: Some("opus".to_string()),
71 video_bitrate: Some(15_000_000),
72 audio_bitrate: Some(192_000),
73 width: Some(3840),
74 height: Some(2160),
75 frame_rate: Some((30, 1)),
76 quality_mode: Some(QualityMode::VeryHigh),
77 hw_accel: true,
78 preserve_metadata: true,
79 ..TranscodeConfig::default()
80 },
81
82 Self::NetflixHd => TranscodeConfig {
83 video_codec: Some("av1".to_string()),
84 audio_codec: Some("opus".to_string()),
85 video_bitrate: Some(6_000_000),
86 audio_bitrate: Some(256_000),
87 width: Some(1920),
88 height: Some(1080),
89 frame_rate: Some((24, 1)),
90 quality_mode: Some(QualityMode::VeryHigh),
91 hw_accel: true,
92 preserve_metadata: true,
93 ..TranscodeConfig::default()
94 },
95
96 Self::TwitchStreamHd => TranscodeConfig {
97 video_codec: Some("vp9".to_string()),
98 audio_codec: Some("opus".to_string()),
99 video_bitrate: Some(6_000_000),
100 audio_bitrate: Some(160_000),
101 width: Some(1920),
102 height: Some(1080),
103 frame_rate: Some((60, 1)),
104 quality_mode: Some(QualityMode::High),
105 hw_accel: true,
106 preserve_metadata: false,
107 ..TranscodeConfig::default()
108 },
109
110 Self::LosslessArchive => TranscodeConfig {
111 video_codec: Some("ffv1".to_string()),
112 audio_codec: Some("flac".to_string()),
113 video_bitrate: None,
115 audio_bitrate: None,
116 width: None,
118 height: None,
119 frame_rate: None,
120 quality_mode: Some(QualityMode::VeryHigh),
121 preserve_metadata: true,
122 hw_accel: false, ..TranscodeConfig::default()
124 },
125
126 Self::ProresLt => TranscodeConfig {
127 video_codec: Some("vp9".to_string()),
128 audio_codec: Some("opus".to_string()),
129 video_bitrate: Some(45_000_000),
131 audio_bitrate: Some(192_000),
132 width: Some(1920),
133 height: Some(1080),
134 frame_rate: Some((30, 1)),
135 quality_mode: Some(QualityMode::VeryHigh),
136 hw_accel: true,
137 preserve_metadata: true,
138 ..TranscodeConfig::default()
139 },
140
141 Self::BroadcastHd => TranscodeConfig {
142 video_codec: Some("av1".to_string()),
143 audio_codec: Some("pcm".to_string()),
144 video_bitrate: Some(50_000_000),
145 audio_bitrate: Some(1_536_000), width: Some(1920),
147 height: Some(1080),
148 frame_rate: Some((30, 1)),
149 quality_mode: Some(QualityMode::VeryHigh),
150 hw_accel: true,
151 preserve_metadata: true,
152 ..TranscodeConfig::default()
153 },
154
155 Self::WebDelivery => TranscodeConfig {
156 video_codec: Some("vp9".to_string()),
157 audio_codec: Some("opus".to_string()),
158 video_bitrate: Some(2_000_000),
159 audio_bitrate: Some(128_000),
160 width: Some(1280),
161 height: Some(720),
162 frame_rate: Some((30, 1)),
163 quality_mode: Some(QualityMode::Medium),
164 hw_accel: true,
165 preserve_metadata: false,
166 ..TranscodeConfig::default()
167 },
168
169 Self::PodcastAudio => TranscodeConfig {
170 video_codec: None,
171 audio_codec: Some("opus".to_string()),
172 video_bitrate: None,
173 audio_bitrate: Some(64_000),
174 width: None,
175 height: None,
176 frame_rate: None,
177 quality_mode: Some(QualityMode::Medium),
178 normalize_audio: true,
179 hw_accel: false,
180 preserve_metadata: true,
181 ..TranscodeConfig::default()
182 },
183 }
184 }
185
186 #[must_use]
188 pub fn description(&self) -> &'static str {
189 match self {
190 Self::YouTubeHd => "YouTube 1080p HD — AV1 4 Mbps video, Opus 192 kbps audio, 30 fps",
191 Self::YouTubeUhd => "YouTube 4K UHD — AV1 15 Mbps video, Opus 192 kbps audio, 30 fps",
192 Self::NetflixHd => "Netflix 1080p — AV1 6 Mbps video, Opus 256 kbps audio, 24 fps",
193 Self::TwitchStreamHd => {
194 "Twitch live 1080p60 — VP9 6 Mbps video, Opus 160 kbps audio, 60 fps"
195 }
196 Self::LosslessArchive => {
197 "Lossless archive — FFV1 Level 3 lossless video, FLAC level 8 lossless audio"
198 }
199 Self::ProresLt => {
200 "ProRes LT-equivalent — VP9 CBR 45 Mbps, Opus 192 kbps, edit-friendly proxy"
201 }
202 Self::BroadcastHd => {
203 "Broadcast HD — AV1 CBR 50 Mbps video, PCM 48 kHz uncompressed audio"
204 }
205 Self::WebDelivery => "Web delivery — VP9 2 Mbps 720p video, Opus 128 kbps audio",
206 Self::PodcastAudio => {
207 "Podcast audio-only — Opus 64 kbps mono CBR with loudness normalisation"
208 }
209 }
210 }
211
212 #[must_use]
214 pub fn all() -> Vec<TranscodePreset> {
215 vec![
216 Self::YouTubeHd,
217 Self::YouTubeUhd,
218 Self::NetflixHd,
219 Self::TwitchStreamHd,
220 Self::LosslessArchive,
221 Self::ProresLt,
222 Self::BroadcastHd,
223 Self::WebDelivery,
224 Self::PodcastAudio,
225 ]
226 }
227}
228
229#[derive(Debug, Clone)]
239pub struct TranscodeEstimator;
240
241impl TranscodeEstimator {
242 #[must_use]
255 pub fn estimate_size_bytes(
256 duration_secs: f64,
257 video_bitrate_kbps: Option<u32>,
258 audio_bitrate_kbps: Option<u32>,
259 ) -> u64 {
260 if duration_secs <= 0.0 {
261 return 0;
262 }
263 let total_kbps =
264 u64::from(video_bitrate_kbps.unwrap_or(0)) + u64::from(audio_bitrate_kbps.unwrap_or(0));
265 let bytes_per_second = total_kbps * 1000 / 8;
267 (bytes_per_second as f64 * duration_secs) as u64
268 }
269
270 #[must_use]
284 pub fn estimate_speed_factor(codec: &str, preset: &str, resolution_pixels: u64) -> f32 {
285 let base_factor: f32 = match codec {
287 "av1" | "libaom-av1" | "svt-av1" => 5.0,
288 "vp9" | "libvpx-vp9" => 2.5,
289 "vp8" | "libvpx" => 1.2,
290 "h264" | "libx264" | "h265" | "hevc" | "libx265" => 0.8,
291 "flac" | "pcm" | "pcm_s16le" | "pcm_s24le" | "pcm_s32le" => 0.05,
292 "opus" | "libopus" => 0.1,
293 "ffv1" => 1.0,
294 _ => 1.5,
295 };
296
297 let preset_multiplier: f32 = match preset {
299 "ultrafast" | "superfast" => 0.2,
300 "veryfast" => 0.4,
301 "faster" | "fast" => 0.6,
302 "medium" => 1.0,
303 "slow" => 1.8,
304 "slower" => 2.5,
305 "veryslow" => 4.0,
306 "placebo" => 8.0,
307 _ => 1.0,
308 };
309
310 let reference_pixels: f64 = 1920.0 * 1080.0;
312 let resolution_scale: f32 = if resolution_pixels == 0 {
313 0.0
315 } else {
316 let scale = (resolution_pixels as f64 / reference_pixels).sqrt();
317 scale as f32
318 };
319
320 let is_audio_only = matches!(
323 codec,
324 "flac" | "opus" | "libopus" | "pcm" | "pcm_s16le" | "pcm_s24le" | "pcm_s32le"
325 );
326
327 if is_audio_only {
328 (base_factor * preset_multiplier).max(0.01)
329 } else {
330 let res_factor = resolution_scale.max(0.25); (base_factor * preset_multiplier * res_factor).max(0.01)
332 }
333 }
334
335 #[must_use]
350 pub fn estimate_vmaf(bitrate_kbps: u32, resolution_pixels: u64) -> f32 {
351 if resolution_pixels == 0 || bitrate_kbps == 0 {
352 return 0.0;
353 }
354
355 let bpp = (bitrate_kbps as f64 * 1000.0) / resolution_pixels as f64;
358
359 let k = 6.0_f64;
363 let vmaf = 100.0 * (1.0 - (-k * bpp).exp());
364
365 vmaf.clamp(0.0, 100.0) as f32
367 }
368}
369
370#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
381 fn test_transcode_preset_all_returns_all_variants() {
382 let all = TranscodePreset::all();
383 assert_eq!(all.len(), 9, "Expected 9 preset variants");
384 }
385
386 #[test]
387 fn test_youtube_hd_config() {
388 let config = TranscodePreset::YouTubeHd.into_config();
389 assert_eq!(config.video_codec, Some("av1".to_string()));
390 assert_eq!(config.audio_codec, Some("opus".to_string()));
391 assert_eq!(config.video_bitrate, Some(4_000_000));
392 assert_eq!(config.audio_bitrate, Some(192_000));
393 assert_eq!(config.width, Some(1920));
394 assert_eq!(config.height, Some(1080));
395 assert_eq!(config.frame_rate, Some((30, 1)));
396 }
397
398 #[test]
399 fn test_youtube_uhd_config() {
400 let config = TranscodePreset::YouTubeUhd.into_config();
401 assert_eq!(config.video_codec, Some("av1".to_string()));
402 assert_eq!(config.width, Some(3840));
403 assert_eq!(config.height, Some(2160));
404 assert_eq!(config.video_bitrate, Some(15_000_000));
405 }
406
407 #[test]
408 fn test_netflix_hd_config() {
409 let config = TranscodePreset::NetflixHd.into_config();
410 assert_eq!(config.video_codec, Some("av1".to_string()));
411 assert_eq!(config.audio_bitrate, Some(256_000));
412 assert_eq!(config.frame_rate, Some((24, 1)));
413 }
414
415 #[test]
416 fn test_twitch_stream_hd_config() {
417 let config = TranscodePreset::TwitchStreamHd.into_config();
418 assert_eq!(config.video_codec, Some("vp9".to_string()));
419 assert_eq!(config.frame_rate, Some((60, 1)));
420 assert_eq!(config.audio_bitrate, Some(160_000));
421 }
422
423 #[test]
424 fn test_lossless_archive_config() {
425 let config = TranscodePreset::LosslessArchive.into_config();
426 assert_eq!(config.video_codec, Some("ffv1".to_string()));
427 assert_eq!(config.audio_codec, Some("flac".to_string()));
428 assert!(config.video_bitrate.is_none());
430 assert!(config.audio_bitrate.is_none());
431 assert!(!config.hw_accel);
433 assert!(config.preserve_metadata);
434 }
435
436 #[test]
437 fn test_prores_lt_config() {
438 let config = TranscodePreset::ProresLt.into_config();
439 assert_eq!(config.video_codec, Some("vp9".to_string()));
440 assert!(config.video_bitrate.unwrap_or(0) > 10_000_000);
442 }
443
444 #[test]
445 fn test_broadcast_hd_config() {
446 let config = TranscodePreset::BroadcastHd.into_config();
447 assert_eq!(config.video_codec, Some("av1".to_string()));
448 assert_eq!(config.audio_codec, Some("pcm".to_string()));
449 assert_eq!(config.video_bitrate, Some(50_000_000));
450 }
451
452 #[test]
453 fn test_web_delivery_config() {
454 let config = TranscodePreset::WebDelivery.into_config();
455 assert_eq!(config.video_codec, Some("vp9".to_string()));
456 assert_eq!(config.width, Some(1280));
457 assert_eq!(config.height, Some(720));
458 assert_eq!(config.video_bitrate, Some(2_000_000));
459 assert_eq!(config.audio_bitrate, Some(128_000));
460 }
461
462 #[test]
463 fn test_podcast_audio_config() {
464 let config = TranscodePreset::PodcastAudio.into_config();
465 assert_eq!(config.audio_codec, Some("opus".to_string()));
466 assert_eq!(config.audio_bitrate, Some(64_000));
467 assert!(config.video_codec.is_none());
469 assert!(config.video_bitrate.is_none());
470 assert!(config.normalize_audio);
472 }
473
474 #[test]
475 fn test_description_not_empty() {
476 for preset in TranscodePreset::all() {
477 let desc = preset.description();
478 assert!(
479 !desc.is_empty(),
480 "Description for {preset:?} should not be empty"
481 );
482 }
483 }
484
485 #[test]
486 fn test_all_presets_unique_descriptions() {
487 let descs: Vec<&'static str> = TranscodePreset::all()
488 .iter()
489 .map(|p| p.description())
490 .collect();
491 let unique: std::collections::HashSet<&&str> = descs.iter().collect();
492 assert_eq!(
493 unique.len(),
494 descs.len(),
495 "All preset descriptions should be unique"
496 );
497 }
498
499 #[test]
502 fn test_estimator_size_bytes_basic() {
503 let size = TranscodeEstimator::estimate_size_bytes(60.0, Some(5_000), Some(192));
505 assert!(size > 0, "Size should be positive");
506 assert!(
507 size < 200_000_000,
508 "Sanity check: should be < 200 MB for 60s"
509 );
510 }
511
512 #[test]
513 fn test_estimator_size_bytes_zero_duration() {
514 let size = TranscodeEstimator::estimate_size_bytes(0.0, Some(5_000), Some(192));
515 assert_eq!(size, 0);
516 }
517
518 #[test]
519 fn test_estimator_size_bytes_negative_duration() {
520 let size = TranscodeEstimator::estimate_size_bytes(-10.0, Some(5_000), Some(192));
521 assert_eq!(size, 0);
522 }
523
524 #[test]
525 fn test_estimator_size_bytes_audio_only() {
526 let size = TranscodeEstimator::estimate_size_bytes(120.0, None, Some(128));
527 assert_eq!(size, 1_920_000);
529 }
530
531 #[test]
532 fn test_estimator_size_bytes_video_only() {
533 let size = TranscodeEstimator::estimate_size_bytes(10.0, Some(4_000), None);
534 assert_eq!(size, 5_000_000);
536 }
537
538 #[test]
539 fn test_estimator_speed_factor_av1_slow() {
540 let factor = TranscodeEstimator::estimate_speed_factor("av1", "slow", 1920 * 1080);
541 assert!(
542 factor > 1.0,
543 "AV1 slow at 1080p should be slower than real-time"
544 );
545 }
546
547 #[test]
548 fn test_estimator_speed_factor_h264_fast() {
549 let factor = TranscodeEstimator::estimate_speed_factor("h264", "fast", 1280 * 720);
550 assert!(factor > 0.0, "Speed factor should be positive");
551 assert!(factor < 4.0, "h264 fast should not be extremely slow");
553 }
554
555 #[test]
556 fn test_estimator_speed_factor_audio_codec() {
557 let factor = TranscodeEstimator::estimate_speed_factor("opus", "medium", 0);
558 assert!(factor > 0.0);
559 assert!(
561 factor < 1.0,
562 "Opus encoding should be faster than real-time"
563 );
564 }
565
566 #[test]
567 fn test_estimator_speed_factor_4k_slower() {
568 let factor_1080p = TranscodeEstimator::estimate_speed_factor("av1", "medium", 1920 * 1080);
569 let factor_4k = TranscodeEstimator::estimate_speed_factor("av1", "medium", 3840 * 2160);
570 assert!(
571 factor_4k > factor_1080p,
572 "4K should take longer to encode than 1080p"
573 );
574 }
575
576 #[test]
577 fn test_estimator_vmaf_high_bitrate() {
578 let vmaf = TranscodeEstimator::estimate_vmaf(10_000, 1920 * 1080);
579 assert!(vmaf > 0.0, "VMAF should be positive");
580 assert!(vmaf <= 100.0, "VMAF should not exceed 100");
581 }
582
583 #[test]
584 fn test_estimator_vmaf_low_bitrate() {
585 let vmaf_low = TranscodeEstimator::estimate_vmaf(500, 1920 * 1080);
586 let vmaf_high = TranscodeEstimator::estimate_vmaf(8_000, 1920 * 1080);
587 assert!(
588 vmaf_low < vmaf_high,
589 "Higher bitrate should produce higher VMAF"
590 );
591 }
592
593 #[test]
594 fn test_estimator_vmaf_clamped_at_100() {
595 let vmaf = TranscodeEstimator::estimate_vmaf(100_000, 320 * 240);
596 assert!(vmaf <= 100.0, "VMAF must be clamped to 100.0");
597 }
598
599 #[test]
600 fn test_estimator_vmaf_zero_resolution() {
601 let vmaf = TranscodeEstimator::estimate_vmaf(5_000, 0);
602 assert_eq!(vmaf, 0.0, "Zero resolution should return VMAF 0");
603 }
604
605 #[test]
606 fn test_estimator_vmaf_zero_bitrate() {
607 let vmaf = TranscodeEstimator::estimate_vmaf(0, 1920 * 1080);
608 assert_eq!(vmaf, 0.0, "Zero bitrate should return VMAF 0");
609 }
610}