Skip to main content

oximedia_transcode/
transcode_preset.rs

1//! High-level `TranscodePreset` enum and `TranscodeEstimator` for real-world workflows.
2//!
3//! `TranscodePreset` encodes industry-standard platform requirements as
4//! ready-to-use `TranscodeConfig` values.  `TranscodeEstimator` provides
5//! lightweight analytical estimates of output size, encoding speed and
6//! perceptual quality (VMAF approximation) without actually running an
7//! encoder.
8
9use crate::{QualityMode, TranscodeConfig};
10use serde::{Deserialize, Serialize};
11
12// ─────────────────────────────────────────────────────────────────────────────
13// TranscodePreset
14// ─────────────────────────────────────────────────────────────────────────────
15
16/// Common real-world transcoding presets for streaming platforms, archive, and
17/// delivery workflows.
18///
19/// Each variant maps to a concrete `TranscodeConfig` via [`TranscodePreset::into_config`].
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub enum TranscodePreset {
22    // ── Streaming platforms ───────────────────────────────────────────────
23    /// YouTube 1080p — AV1 4 Mbps, Opus 192 kbps.
24    YouTubeHd,
25    /// YouTube 4K UHD — AV1 15 Mbps, Opus 192 kbps.
26    YouTubeUhd,
27    /// Netflix 1080p — AV1 6 Mbps, Opus 256 kbps.
28    NetflixHd,
29    /// Twitch live 1080p60 — VP9 6 Mbps, Opus 160 kbps.
30    TwitchStreamHd,
31    // ── Archive ───────────────────────────────────────────────────────────
32    /// Lossless archive — FFV1 Level 3 video, FLAC level 8 audio.
33    LosslessArchive,
34    /// ProRes-like high-bitrate CBR output using VP9 (edit-friendly proxy).
35    ProresLt,
36    // ── Delivery ─────────────────────────────────────────────────────────
37    /// Broadcast HD — AV1 CBR 50 Mbps, PCM 48 kHz.
38    BroadcastHd,
39    /// Web delivery — VP9 2 Mbps 720p, Opus 128 kbps.
40    WebDelivery,
41    /// Podcast audio — Opus 64 kbps mono CBR (no video).
42    PodcastAudio,
43}
44
45impl TranscodePreset {
46    /// Converts this preset into a ready-to-use [`TranscodeConfig`].
47    ///
48    /// The returned config has all codec, bitrate, resolution and frame-rate
49    /// fields pre-populated.  `input` and `output` paths are left as `None` so
50    /// callers can attach them.
51    #[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                // Lossless — no bitrate targets
114                video_bitrate: None,
115                audio_bitrate: None,
116                // Preserve original resolution and frame rate
117                width: None,
118                height: None,
119                frame_rate: None,
120                quality_mode: Some(QualityMode::VeryHigh),
121                preserve_metadata: true,
122                hw_accel: false, // FFV1 is software-only
123                ..TranscodeConfig::default()
124            },
125
126            Self::ProresLt => TranscodeConfig {
127                video_codec: Some("vp9".to_string()),
128                audio_codec: Some("opus".to_string()),
129                // High bitrate CBR approximating ProRes LT at 1080p (~45 Mbps)
130                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), // PCM 48 kHz 16-bit stereo
146                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    /// Returns a short human-readable description of this preset.
187    #[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    /// Returns all available presets in logical order.
213    #[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// ─────────────────────────────────────────────────────────────────────────────
230// TranscodeEstimator
231// ─────────────────────────────────────────────────────────────────────────────
232
233/// Analytical estimator for transcoding resource and quality metrics.
234///
235/// All methods are pure functions — they perform no I/O and no actual encoding.
236/// Results are approximations useful for pre-flight planning (UI display, job
237/// scheduling, storage budgeting) rather than precise measurements.
238#[derive(Debug, Clone)]
239pub struct TranscodeEstimator;
240
241impl TranscodeEstimator {
242    /// Estimates the output file size in bytes.
243    ///
244    /// Uses the combined bitrate of video and audio streams to compute the
245    /// expected byte count for a given duration.
246    ///
247    /// # Arguments
248    ///
249    /// * `duration_secs` — Content duration in seconds.
250    /// * `video_bitrate_kbps` — Video bitrate in kilobits per second (or `None`
251    ///   for audio-only output).
252    /// * `audio_bitrate_kbps` — Audio bitrate in kilobits per second (or `None`
253    ///   for video-only output).
254    #[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        // total_kbps * 1000 bits/s / 8 bits/byte * duration_secs
266        let bytes_per_second = total_kbps * 1000 / 8;
267        (bytes_per_second as f64 * duration_secs) as u64
268    }
269
270    /// Estimates the encoding speed factor relative to real-time.
271    ///
272    /// A factor of `1.0` means encoding takes as long as the source duration.
273    /// A factor of `0.5` means encoding takes half the source duration (2× faster
274    /// than real-time), while `4.0` means encoding takes four times longer than
275    /// the source (0.25× real-time).
276    ///
277    /// # Arguments
278    ///
279    /// * `codec` — Codec name (e.g. `"av1"`, `"vp9"`, `"h264"`, `"flac"`, `"opus"`).
280    /// * `preset` — Encoder preset string (e.g. `"slow"`, `"fast"`, `"ultrafast"`).
281    /// * `resolution_pixels` — Total pixel count (width × height; use `0` for
282    ///   audio-only streams).
283    #[must_use]
284    pub fn estimate_speed_factor(codec: &str, preset: &str, resolution_pixels: u64) -> f32 {
285        // Base speed factor per codec family (at 1080p reference)
286        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        // Preset multiplier: slower presets cost more CPU time
298        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        // Resolution scaling: 1080p (≈2 MP) is the reference
311        let reference_pixels: f64 = 1920.0 * 1080.0;
312        let resolution_scale: f32 = if resolution_pixels == 0 {
313            // Audio-only — resolution does not matter
314            0.0
315        } else {
316            let scale = (resolution_pixels as f64 / reference_pixels).sqrt();
317            scale as f32
318        };
319
320        // For audio codecs the base factor already incorporates the stream
321        // complexity independently of resolution
322        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); // minimum scaling
331            (base_factor * preset_multiplier * res_factor).max(0.01)
332        }
333    }
334
335    /// Estimates perceptual video quality as an approximate VMAF score (0–100).
336    ///
337    /// The estimate is derived from the bits-per-pixel metric and a
338    /// logarithmic saturation curve that reflects empirically observed
339    /// VMAF behaviour.  It is not a substitute for actual VMAF measurement.
340    ///
341    /// # Arguments
342    ///
343    /// * `bitrate_kbps` — Video bitrate in kilobits per second.
344    /// * `resolution_pixels` — Total pixel count (width × height).
345    ///
346    /// # Returns
347    ///
348    /// An estimated VMAF score in the range `[0.0, 100.0]`.
349    #[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        // Bits per pixel per second — a resolution-normalised quality metric.
356        // Higher bpp → higher quality.
357        let bpp = (bitrate_kbps as f64 * 1000.0) / resolution_pixels as f64;
358
359        // Empirically-derived logarithmic VMAF curve.
360        // VMAF ≈ 100 * (1 − exp(−k * bpp))  with shape parameter k=6.
361        // At bpp=0.1 → ~45 VMAF, bpp=0.3 → ~84, bpp=0.7 → ~99
362        let k = 6.0_f64;
363        let vmaf = 100.0 * (1.0 - (-k * bpp).exp());
364
365        // Clamp to valid range
366        vmaf.clamp(0.0, 100.0) as f32
367    }
368}
369
370// ─────────────────────────────────────────────────────────────────────────────
371// Tests
372// ─────────────────────────────────────────────────────────────────────────────
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    // ── TranscodePreset tests ─────────────────────────────────────────────
379
380    #[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        // Lossless — no bitrate targets
429        assert!(config.video_bitrate.is_none());
430        assert!(config.audio_bitrate.is_none());
431        // FFV1 is software-only
432        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        // High bitrate for edit-friendly proxy
441        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        // No video
468        assert!(config.video_codec.is_none());
469        assert!(config.video_bitrate.is_none());
470        // Loudness normalisation enabled for podcasts
471        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    // ── TranscodeEstimator tests ──────────────────────────────────────────
500
501    #[test]
502    fn test_estimator_size_bytes_basic() {
503        // (5000 + 192) kbps * 1000 / 8 bytes/s * 60 s
504        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        // 128 kbps * 1000 / 8 * 120 = 1_920_000
528        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        // 4000 kbps * 1000 / 8 * 10 = 5_000_000
535        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        // h264 fast should be reasonably fast
552        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        // Audio encoding is much faster than real-time
560        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}