Skip to main content

codec/
codec_strings.rs

1//! HLS / DASH `CODECS` attribute string generation.
2//!
3//! Generates the precise codec-string bytes that go into the
4//! `#EXT-X-STREAM-INF:CODECS="..."` line of a HLS master playlist.
5//! These strings are what hls.js (and Safari's native HLS, and DASH
6//! players) use to decide whether the browser can play a given variant
7//! BEFORE downloading any media bytes. A wrong string causes the
8//! variant to be silently skipped, so they have to be parsed from the
9//! actual bitstream — never composed from a config file.
10//!
11//! Sources of truth:
12//! - AV1: AV1 Codec ISO Media File Format Binding v1.2.0 §A.3,
13//!   "Codecs Parameter String"
14//! - AAC-LC in MP4: ISO/IEC 14496-3 + RFC 6381 §3.3
15//! - AVC: RFC 6381 §3.3 (`avc1.PPCCLL` hex from SPS)
16//! - HEVC: ISO/IEC 14496-15 §A.5
17//!
18//! We currently emit AV1 and AAC strings. AVC / HEVC formatters are
19//! sketched as future work for when those codecs ride through the
20//! pipeline as outputs.
21
22use crate::pixel_format::Av1SequenceHeader;
23
24/// AV1 codec string — `av01.P.LLT.DD.M.CCC.TTT.MMM.F`.
25///
26/// Per the AV1 ISOBMFF binding §A.3:
27///   - `P` = `seq_profile` (decimal, 1 char). Profile 0 (Main) is by
28///     far the most common; 1 (High) and 2 (Professional) are rare.
29///   - `LL` = `seq_level_idx_0` formatted as 2-digit decimal (00..31).
30///   - `T` = `seq_tier_0` mapped to 'M' (Main, 0) or 'H' (High, 1).
31///     Tier is only signaled in the bitstream for levels >= 4.0
32///     (level_idx > 7); the parser implicitly sets it to 0 below
33///     that.
34///   - `DD` = bit depth as 2-digit decimal (08, 10, or 12).
35///   - `M` = `monochrome` flag (0 or 1).
36///   - `CCC.TTT.MMM` = `color_primaries`, `transfer_characteristics`,
37///     `matrix_coefficients` formatted as 3-digit zero-padded
38///     decimals. H.273 codes 1/1/1 = BT.709, 9/16/9 = BT.2020 PQ
39///     (HDR10), 9/18/9 = BT.2020 HLG, etc.
40///   - `F` = `color_range` flag (0 = limited / studio, 1 = full).
41///
42/// Per spec, the optional tail (`.M.CCC.TTT.MMM.F`) MAY be omitted
43/// when ALL of these are at their defaults (M=0, CCC=001, TTT=001,
44/// MMM=001, F=0 — i.e. SDR BT.709 limited). We emit the SHORT form
45/// when at defaults and the LONG form otherwise.
46///
47/// The original posture was "always emit long for explicit
48/// identification", but that broke playback in the browser MSE path:
49/// some hls.js / Chrome / Edge versions reject the long form via
50/// `MediaSource.isTypeSupported('video/mp4; codecs="av01.0.05M.08.0.001.001.001.0"')`
51/// even though the underlying av1C bitstream is byte-identical to
52/// what the same browser plays via direct rendition load (which
53/// internally generates the short form by inferring codec from
54/// init.mp4 — bypassing the long-form attribute path). Switching the
55/// master playlist to short-form when at defaults makes the same
56/// segments decode consistently across native HLS, hls.js, and
57/// Safari.
58///
59/// The HDR / wide-gamut / monochrome / non-default-range case still
60/// emits the full 9-component form — those values are NOT defaults
61/// and short form would mean "BT.709 limited 8-bit" which is wrong.
62pub fn av1_codec_string(h: &Av1SequenceHeader) -> String {
63    let tier_char = if h.seq_tier_0 == 0 { 'M' } else { 'H' };
64    let at_defaults = !h.monochrome
65        && h.color_primaries == 1
66        && h.transfer_characteristics == 1
67        && h.matrix_coefficients == 1
68        && !h.color_range;
69    if at_defaults {
70        format!(
71            "av01.{}.{:02}{}.{:02}",
72            h.seq_profile, h.seq_level_idx_0, tier_char, h.bit_depth,
73        )
74    } else {
75        format!(
76            "av01.{}.{:02}{}.{:02}.{}.{:03}.{:03}.{:03}.{}",
77            h.seq_profile,
78            h.seq_level_idx_0,
79            tier_char,
80            h.bit_depth,
81            u8::from(h.monochrome),
82            h.color_primaries,
83            h.transfer_characteristics,
84            h.matrix_coefficients,
85            u8::from(h.color_range),
86        )
87    }
88}
89
90/// AAC-LC in MP4 codec string. Always `mp4a.40.2`:
91///   - `mp4a` = ISO/IEC 14496 sample entry fourcc
92///   - `40`   = ObjectTypeIndication for MPEG-4 Audio (decimal 64,
93///              hex 0x40)
94///   - `2`    = Audio Object Type 2 (AAC-LC) per ISO/IEC 14496-3
95///              Table 1.16
96///
97/// HE-AAC v1 = `mp4a.40.5`, HE-AAC v2 = `mp4a.40.29`. We don't emit
98/// those today — the audio rendition is always AAC-LC stereo at 48
99/// kHz per the CMAF ladder defaults — but if the worker ever
100/// passes-through HE-AAC source, this needs to inspect the AOT
101/// signaled in the AudioSpecificConfig and switch. Until then,
102/// callers using the constant string are correct.
103pub const AAC_LC_CODEC_STRING: &str = "mp4a.40.2";
104
105/// Convenience: pack an HLS `CODECS=` attribute value for a variant
106/// that carries one video and one audio track. Order is
107/// `<video>,<audio>` per RFC 8216 §4.3.4.2 and HLS-Authoring spec.
108pub fn hls_codecs_attribute(video: &str, audio: &str) -> String {
109    format!("{video},{audio}")
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    fn synth_seq_header(
117        seq_profile: u8,
118        seq_level_idx_0: u8,
119        seq_tier_0: u8,
120        bit_depth: u8,
121        monochrome: bool,
122        color_primaries: u8,
123        transfer_characteristics: u8,
124        matrix_coefficients: u8,
125        color_range: bool,
126    ) -> Av1SequenceHeader {
127        Av1SequenceHeader {
128            seq_profile,
129            still_picture: false,
130            reduced_still_picture_header: false,
131            max_frame_width_minus1: 0,
132            max_frame_height_minus1: 0,
133            seq_level_idx_0,
134            seq_tier_0,
135            bit_depth,
136            monochrome,
137            color_primaries,
138            transfer_characteristics,
139            matrix_coefficients,
140            color_range,
141            chroma_subsampling_x: true,
142            chroma_subsampling_y: true,
143            film_grain_params_present: false,
144            enable_filter_intra: false,
145            enable_intra_edge_filter: false,
146            enable_interintra_compound: false,
147            enable_masked_compound: false,
148            enable_warped_motion: false,
149            enable_dual_filter: false,
150            enable_order_hint: false,
151            enable_jnt_comp: false,
152            enable_ref_frame_mvs: false,
153            enable_superres: false,
154            enable_cdef: false,
155            enable_restoration: false,
156            order_hint_bits: 0,
157            seq_force_screen_content_tools: 0,
158            seq_force_integer_mv: 0,
159            frame_width_bits_minus_1: 0,
160            frame_height_bits_minus_1: 0,
161            use_128x128_superblock: false,
162            separate_uv_delta_q: false,
163        }
164    }
165
166    #[test]
167    fn av1_string_short_form_at_bt709_defaults() {
168        // Profile 0, level_idx 8 (level 4.0), Main tier, 8-bit, SDR BT.709 limited.
169        // The "boring 1080p" baseline string — at all defaults so the
170        // short form is correct. Long form here was rejected by Chrome
171        // / hls.js MediaSource.isTypeSupported in 2026-05-02 testing
172        // (manifest_url playback dropped video while audio worked).
173        let h = synth_seq_header(0, 8, 0, 8, false, 1, 1, 1, false);
174        assert_eq!(av1_codec_string(&h), "av01.0.08M.08");
175    }
176
177    #[test]
178    fn av1_string_high_tier_renders_h_character() {
179        // Level 6.0 (idx 16), High tier — tier_char swaps M -> H.
180        // Bit depth + color codes deviate from defaults so long form is correct.
181        let h = synth_seq_header(0, 16, 1, 10, false, 9, 16, 9, false);
182        assert_eq!(av1_codec_string(&h), "av01.0.16H.10.0.009.016.009.0");
183    }
184
185    #[test]
186    fn av1_string_hdr10_bt2020_pq_full_range() {
187        // BT.2020 + PQ + BT.2020 NCL + full range = HDR10 limited PQ.
188        // CCC=009, TTT=016, MMM=009, F=1. Long form REQUIRED — short
189        // form at defaults would mis-signal as BT.709 SDR.
190        let h = synth_seq_header(0, 12, 0, 10, false, 9, 16, 9, true);
191        assert_eq!(av1_codec_string(&h), "av01.0.12M.10.0.009.016.009.1");
192    }
193
194    #[test]
195    fn av1_string_monochrome_uses_long_form() {
196        // Monochrome is non-default — long form required so the player
197        // doesn't allocate a chroma buffer that won't get filled.
198        let h = synth_seq_header(0, 8, 0, 8, true, 1, 1, 1, false);
199        assert_eq!(av1_codec_string(&h), "av01.0.08M.08.1.001.001.001.0");
200    }
201
202    #[test]
203    fn av1_string_full_range_at_8bit_bt709_uses_long_form() {
204        // Full range != 0 so even with BT.709 / 8-bit the SDR-defaults
205        // check fails — long form required so the player applies
206        // full-range scaling.
207        let h = synth_seq_header(0, 8, 0, 8, false, 1, 1, 1, true);
208        assert_eq!(av1_codec_string(&h), "av01.0.08M.08.0.001.001.001.1");
209    }
210
211    #[test]
212    fn av1_string_two_digit_level_padding() {
213        // level_idx 0 must format as "00", not "0".
214        let h = synth_seq_header(0, 0, 0, 8, false, 1, 1, 1, false);
215        let s = av1_codec_string(&h);
216        assert!(s.starts_with("av01.0.00M."), "got: {s}");
217    }
218
219    #[test]
220    fn av1_string_two_digit_bit_depth_padding() {
221        // 8-bit at defaults → short form; 10-bit + 12-bit deviate from
222        // bit-depth=8 (which is the implicit default carried by short
223        // form) but they're still valid as short form so long as
224        // color codes are at default.
225        let h_8 = synth_seq_header(0, 8, 0, 8, false, 1, 1, 1, false);
226        let h_10 = synth_seq_header(0, 8, 0, 10, false, 1, 1, 1, false);
227        let h_12 = synth_seq_header(2, 8, 0, 12, false, 1, 1, 1, false);
228        assert_eq!(av1_codec_string(&h_8), "av01.0.08M.08");
229        assert_eq!(av1_codec_string(&h_10), "av01.0.08M.10");
230        assert_eq!(av1_codec_string(&h_12), "av01.2.08M.12");
231    }
232
233    #[test]
234    fn aac_lc_constant_is_canonical() {
235        assert_eq!(AAC_LC_CODEC_STRING, "mp4a.40.2");
236    }
237
238    #[test]
239    fn hls_codecs_attribute_concatenates_video_then_audio() {
240        let s = hls_codecs_attribute("av01.0.08M.08.0.001.001.001.0", AAC_LC_CODEC_STRING);
241        assert_eq!(s, "av01.0.08M.08.0.001.001.001.0,mp4a.40.2");
242    }
243}