Skip to main content

oximedia_transcode/
codec_profile.rs

1//! Codec profile definitions and selector utilities.
2//!
3//! Provides `CodecLevel`, `CodecProfile`, and `CodecProfileSelector`
4//! for choosing the best encoding profile for a given resolution.
5
6#![allow(dead_code)]
7
8/// H.264/HEVC codec conformance level.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
10pub enum CodecLevel {
11    /// Level 3.0 – up to 720p30.
12    Level30,
13    /// Level 3.1 – up to 720p60 / 1080p30.
14    Level31,
15    /// Level 4.0 – up to 1080p30 high-quality.
16    Level40,
17    /// Level 4.1 – up to 1080p60.
18    Level41,
19    /// Level 5.0 – up to 2160p30.
20    Level50,
21    /// Level 5.1 – up to 2160p60.
22    Level51,
23    /// Level 5.2 – 2160p120 / 8K30.
24    Level52,
25}
26
27impl CodecLevel {
28    /// Returns the level as a floating-point number (e.g. 4.1).
29    #[allow(clippy::cast_precision_loss)]
30    #[must_use]
31    pub fn as_f32(self) -> f32 {
32        match self {
33            CodecLevel::Level30 => 3.0,
34            CodecLevel::Level31 => 3.1,
35            CodecLevel::Level40 => 4.0,
36            CodecLevel::Level41 => 4.1,
37            CodecLevel::Level50 => 5.0,
38            CodecLevel::Level51 => 5.1,
39            CodecLevel::Level52 => 5.2,
40        }
41    }
42
43    /// Returns the maximum pixel count this level supports per frame.
44    #[must_use]
45    pub fn max_pixels(self) -> u64 {
46        match self {
47            CodecLevel::Level30 => 1_280 * 720,
48            CodecLevel::Level31 => 1_280 * 720,
49            CodecLevel::Level40 => 1_920 * 1_080,
50            CodecLevel::Level41 => 1_920 * 1_080,
51            CodecLevel::Level50 => 3_840 * 2_160,
52            CodecLevel::Level51 => 3_840 * 2_160,
53            CodecLevel::Level52 => 7_680 * 4_320,
54        }
55    }
56}
57
58/// An encoding profile combining codec name, level, and capability flags.
59#[derive(Debug, Clone)]
60pub struct CodecProfile {
61    /// Codec identifier (e.g. "h264", "hevc", "av1").
62    pub codec: String,
63    /// Conformance level.
64    pub level: CodecLevel,
65    /// Maximum bitrate in Megabits per second.
66    max_bitrate_mbps_val: f64,
67    /// Whether hardware encoders are typically available for this profile.
68    hw_encodable: bool,
69    /// Whether this profile supports 10-bit colour.
70    supports_10bit: bool,
71}
72
73impl CodecProfile {
74    /// Creates a new codec profile.
75    pub fn new(
76        codec: impl Into<String>,
77        level: CodecLevel,
78        max_bitrate_mbps: f64,
79        hw_encodable: bool,
80    ) -> Self {
81        Self {
82            codec: codec.into(),
83            level,
84            max_bitrate_mbps_val: max_bitrate_mbps,
85            hw_encodable,
86            supports_10bit: false,
87        }
88    }
89
90    /// Enables 10-bit colour support for this profile.
91    #[must_use]
92    pub fn with_10bit(mut self) -> Self {
93        self.supports_10bit = true;
94        self
95    }
96
97    /// Returns the maximum bitrate in Megabits per second.
98    #[must_use]
99    pub fn max_bitrate_mbps(&self) -> f64 {
100        self.max_bitrate_mbps_val
101    }
102
103    /// Returns `true` if this profile is typically hardware-encodable.
104    #[must_use]
105    pub fn is_hardware_encodable(&self) -> bool {
106        self.hw_encodable
107    }
108
109    /// Returns `true` if this profile supports 10-bit depth.
110    #[must_use]
111    pub fn supports_10bit(&self) -> bool {
112        self.supports_10bit
113    }
114
115    /// Returns the maximum pixel count for this profile's level.
116    #[must_use]
117    pub fn max_pixels(&self) -> u64 {
118        self.level.max_pixels()
119    }
120
121    /// Returns `true` if this profile can encode the given resolution.
122    #[must_use]
123    pub fn supports_resolution(&self, width: u32, height: u32) -> bool {
124        let pixels = u64::from(width) * u64::from(height);
125        pixels <= self.level.max_pixels()
126    }
127}
128
129/// Selects an appropriate codec profile based on resolution requirements.
130#[derive(Debug, Default)]
131pub struct CodecProfileSelector {
132    profiles: Vec<CodecProfile>,
133}
134
135impl CodecProfileSelector {
136    /// Creates an empty selector.
137    #[must_use]
138    pub fn new() -> Self {
139        Self::default()
140    }
141
142    /// Creates a selector pre-loaded with common H.264 profiles.
143    #[must_use]
144    pub fn with_h264_defaults() -> Self {
145        let mut s = Self::new();
146        s.add(CodecProfile::new("h264", CodecLevel::Level31, 10.0, true));
147        s.add(CodecProfile::new("h264", CodecLevel::Level41, 50.0, true));
148        s.add(CodecProfile::new("h264", CodecLevel::Level51, 240.0, true));
149        s
150    }
151
152    /// Creates a selector pre-loaded with common HEVC profiles.
153    #[must_use]
154    pub fn with_hevc_defaults() -> Self {
155        let mut s = Self::new();
156        s.add(CodecProfile::new("hevc", CodecLevel::Level41, 40.0, true).with_10bit());
157        s.add(CodecProfile::new("hevc", CodecLevel::Level51, 160.0, true).with_10bit());
158        s.add(CodecProfile::new("hevc", CodecLevel::Level52, 640.0, false).with_10bit());
159        s
160    }
161
162    /// Adds a profile to the selector.
163    pub fn add(&mut self, profile: CodecProfile) {
164        self.profiles.push(profile);
165    }
166
167    /// Selects the lowest-level (most compatible) profile that supports the
168    /// given resolution. Returns `None` if no profile is sufficient.
169    #[must_use]
170    pub fn select_for_resolution(&self, width: u32, height: u32) -> Option<&CodecProfile> {
171        self.profiles
172            .iter()
173            .filter(|p| p.supports_resolution(width, height))
174            .min_by(|a, b| a.level.cmp(&b.level))
175    }
176
177    /// Returns all profiles that support the given resolution.
178    #[must_use]
179    pub fn all_for_resolution(&self, width: u32, height: u32) -> Vec<&CodecProfile> {
180        self.profiles
181            .iter()
182            .filter(|p| p.supports_resolution(width, height))
183            .collect()
184    }
185
186    /// Returns the number of profiles registered.
187    #[must_use]
188    pub fn profile_count(&self) -> usize {
189        self.profiles.len()
190    }
191}
192
193/// Per-codec tune presets for content-specific optimization.
194///
195/// Each preset encodes recommended encoder parameters for a specific
196/// content type (film, animation, grain, screen, etc.) for a specific codec.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct CodecTunePreset {
199    /// Codec this preset applies to (e.g. "av1", "vp9").
200    pub codec: String,
201    /// Content tune name (e.g. "film", "animation", "grain").
202    pub tune_name: String,
203    /// Recommended encoder options as key-value pairs.
204    pub options: Vec<(String, String)>,
205    /// Description of what this tune optimizes for.
206    pub description: String,
207}
208
209impl CodecTunePreset {
210    /// AV1 film tune preset.
211    ///
212    /// Optimized for live-action content with natural motion, subtle colour
213    /// gradients, and moderate detail. Uses film grain synthesis to maintain
214    /// perceived quality at lower bitrates.
215    #[must_use]
216    pub fn av1_film() -> Self {
217        Self {
218            codec: "av1".to_string(),
219            tune_name: "film".to_string(),
220            options: vec![
221                ("enable-film-grain".to_string(), "1".to_string()),
222                ("film-grain-denoise".to_string(), "1".to_string()),
223                ("film-grain-table".to_string(), "auto".to_string()),
224                ("aq-mode".to_string(), "2".to_string()),
225                ("deltaq-mode".to_string(), "3".to_string()),
226                ("enable-qm".to_string(), "1".to_string()),
227                ("qm-min".to_string(), "5".to_string()),
228                ("tune".to_string(), "ssim".to_string()),
229                ("arnr-maxframes".to_string(), "7".to_string()),
230                ("arnr-strength".to_string(), "4".to_string()),
231            ],
232            description: "Film: grain synthesis, perceptual quality, natural motion".to_string(),
233        }
234    }
235
236    /// AV1 animation tune preset.
237    ///
238    /// Optimized for animated content with flat colour areas, sharp edges,
239    /// and less texture. Favours PSNR-based quality and aggressive deblocking.
240    #[must_use]
241    pub fn av1_animation() -> Self {
242        Self {
243            codec: "av1".to_string(),
244            tune_name: "animation".to_string(),
245            options: vec![
246                ("enable-film-grain".to_string(), "0".to_string()),
247                ("aq-mode".to_string(), "0".to_string()),
248                ("deltaq-mode".to_string(), "0".to_string()),
249                ("enable-qm".to_string(), "1".to_string()),
250                ("qm-min".to_string(), "0".to_string()),
251                ("qm-max".to_string(), "8".to_string()),
252                ("tune".to_string(), "psnr".to_string()),
253                ("enable-keyframe-filtering".to_string(), "0".to_string()),
254                ("arnr-maxframes".to_string(), "15".to_string()),
255                ("arnr-strength".to_string(), "6".to_string()),
256                ("enable-smooth-interintra".to_string(), "1".to_string()),
257            ],
258            description: "Animation: flat areas, sharp edges, PSNR-optimized".to_string(),
259        }
260    }
261
262    /// AV1 grain preservation tune preset.
263    ///
264    /// Preserves film grain and high-frequency texture detail. Uses grain
265    /// synthesis with higher fidelity parameters and disables aggressive
266    /// denoising.
267    #[must_use]
268    pub fn av1_grain() -> Self {
269        Self {
270            codec: "av1".to_string(),
271            tune_name: "grain".to_string(),
272            options: vec![
273                ("enable-film-grain".to_string(), "1".to_string()),
274                ("film-grain-denoise".to_string(), "0".to_string()),
275                ("aq-mode".to_string(), "1".to_string()),
276                ("deltaq-mode".to_string(), "3".to_string()),
277                ("enable-qm".to_string(), "1".to_string()),
278                ("qm-min".to_string(), "0".to_string()),
279                ("tune".to_string(), "ssim".to_string()),
280                ("arnr-maxframes".to_string(), "4".to_string()),
281                ("arnr-strength".to_string(), "2".to_string()),
282                ("noise-sensitivity".to_string(), "0".to_string()),
283            ],
284            description: "Grain: preserve film grain and high-frequency detail".to_string(),
285        }
286    }
287
288    /// VP9 film tune preset.
289    ///
290    /// Optimized for live-action content using VP9-specific parameters.
291    #[must_use]
292    pub fn vp9_film() -> Self {
293        Self {
294            codec: "vp9".to_string(),
295            tune_name: "film".to_string(),
296            options: vec![
297                ("aq-mode".to_string(), "2".to_string()),
298                ("lag-in-frames".to_string(), "25".to_string()),
299                ("auto-alt-ref".to_string(), "6".to_string()),
300                ("arnr-maxframes".to_string(), "7".to_string()),
301                ("arnr-strength".to_string(), "4".to_string()),
302                ("arnr-type".to_string(), "3".to_string()),
303                ("tune".to_string(), "ssim".to_string()),
304                ("row-mt".to_string(), "1".to_string()),
305            ],
306            description: "Film: temporal filtering, perceptual quality for live-action".to_string(),
307        }
308    }
309
310    /// VP9 animation tune preset.
311    ///
312    /// Optimized for animated content with VP9-specific parameters.
313    #[must_use]
314    pub fn vp9_animation() -> Self {
315        Self {
316            codec: "vp9".to_string(),
317            tune_name: "animation".to_string(),
318            options: vec![
319                ("aq-mode".to_string(), "0".to_string()),
320                ("lag-in-frames".to_string(), "25".to_string()),
321                ("auto-alt-ref".to_string(), "6".to_string()),
322                ("arnr-maxframes".to_string(), "15".to_string()),
323                ("arnr-strength".to_string(), "6".to_string()),
324                ("arnr-type".to_string(), "3".to_string()),
325                ("tune".to_string(), "psnr".to_string()),
326                ("row-mt".to_string(), "1".to_string()),
327            ],
328            description: "Animation: flat areas, sharp edges, PSNR-optimized for VP9".to_string(),
329        }
330    }
331
332    /// VP9 grain preservation tune preset.
333    #[must_use]
334    pub fn vp9_grain() -> Self {
335        Self {
336            codec: "vp9".to_string(),
337            tune_name: "grain".to_string(),
338            options: vec![
339                ("aq-mode".to_string(), "0".to_string()),
340                ("lag-in-frames".to_string(), "25".to_string()),
341                ("auto-alt-ref".to_string(), "2".to_string()),
342                ("arnr-maxframes".to_string(), "4".to_string()),
343                ("arnr-strength".to_string(), "1".to_string()),
344                ("arnr-type".to_string(), "3".to_string()),
345                ("tune".to_string(), "ssim".to_string()),
346                ("noise-sensitivity".to_string(), "0".to_string()),
347                ("row-mt".to_string(), "1".to_string()),
348            ],
349            description: "Grain: minimal temporal filtering to preserve texture".to_string(),
350        }
351    }
352
353    /// Returns all available tune presets for a given codec.
354    #[must_use]
355    pub fn presets_for_codec(codec: &str) -> Vec<Self> {
356        match codec.to_lowercase().as_str() {
357            "av1" | "libaom-av1" | "svt-av1" | "rav1e" => {
358                vec![Self::av1_film(), Self::av1_animation(), Self::av1_grain()]
359            }
360            "vp9" | "libvpx-vp9" => {
361                vec![Self::vp9_film(), Self::vp9_animation(), Self::vp9_grain()]
362            }
363            _ => Vec::new(),
364        }
365    }
366
367    /// Looks up a specific tune preset by codec and tune name.
368    #[must_use]
369    pub fn find(codec: &str, tune_name: &str) -> Option<Self> {
370        Self::presets_for_codec(codec)
371            .into_iter()
372            .find(|p| p.tune_name == tune_name)
373    }
374
375    /// Returns the number of encoder options in this preset.
376    #[must_use]
377    pub fn option_count(&self) -> usize {
378        self.options.len()
379    }
380
381    /// Merges this preset's options into a `CodecConfig`'s options list.
382    ///
383    /// Existing options with matching keys are overwritten.
384    pub fn apply_to_options(&self, options: &mut Vec<(String, String)>) {
385        for (key, value) in &self.options {
386            // Remove any existing option with the same key
387            options.retain(|(k, _)| k != key);
388            options.push((key.clone(), value.clone()));
389        }
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn test_codec_level_ordering() {
399        assert!(CodecLevel::Level30 < CodecLevel::Level31);
400        assert!(CodecLevel::Level41 < CodecLevel::Level50);
401        assert!(CodecLevel::Level51 < CodecLevel::Level52);
402    }
403
404    #[test]
405    fn test_codec_level_as_f32() {
406        let v = CodecLevel::Level41.as_f32();
407        assert!((v - 4.1_f32).abs() < 0.001);
408    }
409
410    #[test]
411    fn test_codec_level_max_pixels_1080p() {
412        assert_eq!(CodecLevel::Level41.max_pixels(), 1_920 * 1_080);
413    }
414
415    #[test]
416    fn test_codec_level_max_pixels_4k() {
417        assert_eq!(CodecLevel::Level51.max_pixels(), 3_840 * 2_160);
418    }
419
420    #[test]
421    fn test_profile_max_bitrate() {
422        let p = CodecProfile::new("h264", CodecLevel::Level41, 50.0, true);
423        assert!((p.max_bitrate_mbps() - 50.0).abs() < f64::EPSILON);
424    }
425
426    #[test]
427    fn test_profile_hw_encodable() {
428        let p = CodecProfile::new("h264", CodecLevel::Level41, 50.0, true);
429        assert!(p.is_hardware_encodable());
430        let p2 = CodecProfile::new("av1", CodecLevel::Level51, 100.0, false);
431        assert!(!p2.is_hardware_encodable());
432    }
433
434    #[test]
435    fn test_profile_10bit_default_false() {
436        let p = CodecProfile::new("h264", CodecLevel::Level41, 50.0, true);
437        assert!(!p.supports_10bit());
438    }
439
440    #[test]
441    fn test_profile_with_10bit() {
442        let p = CodecProfile::new("hevc", CodecLevel::Level51, 160.0, true).with_10bit();
443        assert!(p.supports_10bit());
444    }
445
446    #[test]
447    fn test_profile_supports_resolution_1080p() {
448        let p = CodecProfile::new("h264", CodecLevel::Level41, 50.0, true);
449        assert!(p.supports_resolution(1920, 1080));
450        assert!(!p.supports_resolution(3840, 2160));
451    }
452
453    #[test]
454    fn test_selector_h264_defaults_count() {
455        let sel = CodecProfileSelector::with_h264_defaults();
456        assert_eq!(sel.profile_count(), 3);
457    }
458
459    #[test]
460    fn test_selector_select_for_1080p_returns_lowest_level() {
461        let sel = CodecProfileSelector::with_h264_defaults();
462        let p = sel
463            .select_for_resolution(1920, 1080)
464            .expect("should succeed in test");
465        // Level31 max_pixels is 1280*720, so Level41 should be selected
466        assert_eq!(p.level, CodecLevel::Level41);
467    }
468
469    #[test]
470    fn test_selector_select_for_720p() {
471        let sel = CodecProfileSelector::with_h264_defaults();
472        let p = sel
473            .select_for_resolution(1280, 720)
474            .expect("should succeed in test");
475        assert_eq!(p.level, CodecLevel::Level31);
476    }
477
478    #[test]
479    fn test_selector_select_for_8k_returns_none_h264() {
480        let sel = CodecProfileSelector::with_h264_defaults();
481        // 8K exceeds all H.264 profiles in the default set
482        assert!(sel.select_for_resolution(7680, 4320).is_none());
483    }
484
485    #[test]
486    fn test_selector_hevc_supports_4k() {
487        let sel = CodecProfileSelector::with_hevc_defaults();
488        let p = sel
489            .select_for_resolution(3840, 2160)
490            .expect("should succeed in test");
491        assert_eq!(p.codec, "hevc");
492        assert!(p.supports_10bit());
493    }
494
495    // ── CodecTunePreset tests ───────────────────────────────────────────
496
497    #[test]
498    fn test_av1_film_preset() {
499        let p = CodecTunePreset::av1_film();
500        assert_eq!(p.codec, "av1");
501        assert_eq!(p.tune_name, "film");
502        assert!(p.option_count() > 0);
503        assert!(p
504            .options
505            .iter()
506            .any(|(k, v)| k == "enable-film-grain" && v == "1"));
507        assert!(p.options.iter().any(|(k, v)| k == "tune" && v == "ssim"));
508    }
509
510    #[test]
511    fn test_av1_animation_preset() {
512        let p = CodecTunePreset::av1_animation();
513        assert_eq!(p.codec, "av1");
514        assert_eq!(p.tune_name, "animation");
515        assert!(p
516            .options
517            .iter()
518            .any(|(k, v)| k == "enable-film-grain" && v == "0"));
519        assert!(p.options.iter().any(|(k, v)| k == "tune" && v == "psnr"));
520    }
521
522    #[test]
523    fn test_av1_grain_preset() {
524        let p = CodecTunePreset::av1_grain();
525        assert_eq!(p.codec, "av1");
526        assert_eq!(p.tune_name, "grain");
527        // Film grain enabled but denoise disabled
528        assert!(p
529            .options
530            .iter()
531            .any(|(k, v)| k == "enable-film-grain" && v == "1"));
532        assert!(p
533            .options
534            .iter()
535            .any(|(k, v)| k == "film-grain-denoise" && v == "0"));
536    }
537
538    #[test]
539    fn test_vp9_film_preset() {
540        let p = CodecTunePreset::vp9_film();
541        assert_eq!(p.codec, "vp9");
542        assert_eq!(p.tune_name, "film");
543        assert!(p
544            .options
545            .iter()
546            .any(|(k, v)| k == "lag-in-frames" && v == "25"));
547        assert!(p.options.iter().any(|(k, v)| k == "tune" && v == "ssim"));
548    }
549
550    #[test]
551    fn test_vp9_animation_preset() {
552        let p = CodecTunePreset::vp9_animation();
553        assert_eq!(p.codec, "vp9");
554        assert_eq!(p.tune_name, "animation");
555        assert!(p.options.iter().any(|(k, v)| k == "tune" && v == "psnr"));
556    }
557
558    #[test]
559    fn test_vp9_grain_preset() {
560        let p = CodecTunePreset::vp9_grain();
561        assert_eq!(p.codec, "vp9");
562        assert_eq!(p.tune_name, "grain");
563        // Minimal temporal filtering
564        assert!(p
565            .options
566            .iter()
567            .any(|(k, v)| k == "arnr-strength" && v == "1"));
568    }
569
570    #[test]
571    fn test_presets_for_av1() {
572        let presets = CodecTunePreset::presets_for_codec("av1");
573        assert_eq!(presets.len(), 3);
574        let names: Vec<&str> = presets.iter().map(|p| p.tune_name.as_str()).collect();
575        assert!(names.contains(&"film"));
576        assert!(names.contains(&"animation"));
577        assert!(names.contains(&"grain"));
578    }
579
580    #[test]
581    fn test_presets_for_vp9() {
582        let presets = CodecTunePreset::presets_for_codec("vp9");
583        assert_eq!(presets.len(), 3);
584    }
585
586    #[test]
587    fn test_presets_for_av1_alias() {
588        let presets = CodecTunePreset::presets_for_codec("libaom-av1");
589        assert_eq!(presets.len(), 3);
590    }
591
592    #[test]
593    fn test_presets_for_vp9_alias() {
594        let presets = CodecTunePreset::presets_for_codec("libvpx-vp9");
595        assert_eq!(presets.len(), 3);
596    }
597
598    #[test]
599    fn test_presets_for_unknown_codec() {
600        let presets = CodecTunePreset::presets_for_codec("h264");
601        assert!(presets.is_empty());
602    }
603
604    #[test]
605    fn test_find_av1_film() {
606        let p = CodecTunePreset::find("av1", "film");
607        assert!(p.is_some());
608        let p = p.expect("should find av1 film preset");
609        assert_eq!(p.tune_name, "film");
610    }
611
612    #[test]
613    fn test_find_vp9_animation() {
614        let p = CodecTunePreset::find("vp9", "animation");
615        assert!(p.is_some());
616    }
617
618    #[test]
619    fn test_find_nonexistent() {
620        assert!(CodecTunePreset::find("av1", "nonexistent").is_none());
621        assert!(CodecTunePreset::find("h264", "film").is_none());
622    }
623
624    #[test]
625    fn test_apply_to_options() {
626        let preset = CodecTunePreset::av1_film();
627        let mut options = vec![
628            ("tune".to_string(), "psnr".to_string()),
629            ("custom".to_string(), "value".to_string()),
630        ];
631        preset.apply_to_options(&mut options);
632        // "tune" should be overwritten to "ssim"
633        let tune_val = options
634            .iter()
635            .find(|(k, _)| k == "tune")
636            .map(|(_, v)| v.as_str());
637        assert_eq!(tune_val, Some("ssim"));
638        // "custom" should still be present
639        assert!(options.iter().any(|(k, _)| k == "custom"));
640        // Film grain options should be added
641        assert!(options
642            .iter()
643            .any(|(k, v)| k == "enable-film-grain" && v == "1"));
644    }
645
646    #[test]
647    fn test_apply_to_empty_options() {
648        let preset = CodecTunePreset::vp9_grain();
649        let mut options = Vec::new();
650        preset.apply_to_options(&mut options);
651        assert_eq!(options.len(), preset.option_count());
652    }
653
654    #[test]
655    fn test_description_not_empty() {
656        let presets = [
657            CodecTunePreset::av1_film(),
658            CodecTunePreset::av1_animation(),
659            CodecTunePreset::av1_grain(),
660            CodecTunePreset::vp9_film(),
661            CodecTunePreset::vp9_animation(),
662            CodecTunePreset::vp9_grain(),
663        ];
664        for p in &presets {
665            assert!(
666                !p.description.is_empty(),
667                "Description for {}:{} should not be empty",
668                p.codec,
669                p.tune_name
670            );
671        }
672    }
673}