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//! Emits AV1, H.264 (`avc1`/`avc3`), H.265 (`hvc1`/`hev1`), and AAC strings.
19
20use crate::pixel_format::{Av1SequenceHeader, H264SpsInfo, HevcSpsInfo};
21
22/// AV1 codec string — `av01.P.LLT.DD.M.CCC.TTT.MMM.F`.
23///
24/// Per the AV1 ISOBMFF binding §A.3:
25///   - `P` = `seq_profile` (decimal, 1 char). Profile 0 (Main) is by
26///     far the most common; 1 (High) and 2 (Professional) are rare.
27///   - `LL` = `seq_level_idx_0` formatted as 2-digit decimal (00..31).
28///   - `T` = `seq_tier_0` mapped to 'M' (Main, 0) or 'H' (High, 1).
29///     Tier is only signaled in the bitstream for levels >= 4.0
30///     (level_idx > 7); the parser implicitly sets it to 0 below
31///     that.
32///   - `DD` = bit depth as 2-digit decimal (08, 10, or 12).
33///   - `M` = `monochrome` flag (0 or 1).
34///   - `CCC.TTT.MMM` = `color_primaries`, `transfer_characteristics`,
35///     `matrix_coefficients` formatted as 3-digit zero-padded
36///     decimals. H.273 codes 1/1/1 = BT.709, 9/16/9 = BT.2020 PQ
37///     (HDR10), 9/18/9 = BT.2020 HLG, etc.
38///   - `F` = `color_range` flag (0 = limited / studio, 1 = full).
39///
40/// Per spec, the optional tail (`.M.CCC.TTT.MMM.F`) MAY be omitted
41/// when ALL of these are at their defaults (M=0, CCC=001, TTT=001,
42/// MMM=001, F=0 — i.e. SDR BT.709 limited). We emit the SHORT form
43/// when at defaults and the LONG form otherwise.
44///
45/// The original posture was "always emit long for explicit
46/// identification", but that broke playback in the browser MSE path:
47/// some hls.js / Chrome / Edge versions reject the long form via
48/// `MediaSource.isTypeSupported('video/mp4; codecs="av01.0.05M.08.0.001.001.001.0"')`
49/// even though the underlying av1C bitstream is byte-identical to
50/// what the same browser plays via direct rendition load (which
51/// internally generates the short form by inferring codec from
52/// init.mp4 — bypassing the long-form attribute path). Switching the
53/// master playlist to short-form when at defaults makes the same
54/// segments decode consistently across native HLS, hls.js, and
55/// Safari.
56///
57/// The HDR / wide-gamut / monochrome / non-default-range case still
58/// emits the full 9-component form — those values are NOT defaults
59/// and short form would mean "BT.709 limited 8-bit" which is wrong.
60pub fn av1_codec_string(h: &Av1SequenceHeader) -> String {
61    let tier_char = if h.seq_tier_0 == 0 { 'M' } else { 'H' };
62    let at_defaults = !h.monochrome
63        && h.color_primaries == 1
64        && h.transfer_characteristics == 1
65        && h.matrix_coefficients == 1
66        && !h.color_range;
67    if at_defaults {
68        format!(
69            "av01.{}.{:02}{}.{:02}",
70            h.seq_profile, h.seq_level_idx_0, tier_char, h.bit_depth,
71        )
72    } else {
73        format!(
74            "av01.{}.{:02}{}.{:02}.{}.{:03}.{:03}.{:03}.{}",
75            h.seq_profile,
76            h.seq_level_idx_0,
77            tier_char,
78            h.bit_depth,
79            u8::from(h.monochrome),
80            h.color_primaries,
81            h.transfer_characteristics,
82            h.matrix_coefficients,
83            u8::from(h.color_range),
84        )
85    }
86}
87
88/// H.264 codec string `<fourcc>.PPCCLL` per RFC 6381 §3.3 — hex bytes from the
89/// SPS: `PP` = `profile_idc`, `CC` = the packed `constraint_set` flags byte,
90/// `LL` = `level_idc`. `fourcc` is the sample-entry type: `avc1` (parameter sets
91/// out-of-band in `avcC`) or `avc3` (in-band). Example: High@L4.0 → `avc1.640028`.
92pub fn avc_codec_string(fourcc: &str, sps: &H264SpsInfo) -> String {
93    format!(
94        "{}.{:02X}{:02X}{:02X}",
95        fourcc, sps.profile_idc, sps.constraint_set_flags, sps.level_idc
96    )
97}
98
99/// H.265 codec string `<fourcc>.{space}{profile}.{compat}.{tier}{level}{.cons}*`
100/// per ISO/IEC 14496-15 §E.3, parsed from the SPS profile_tier_level:
101///   - `{space}` = `general_profile_space` as a letter (0 → omitted, 1→'A', …),
102///     then `general_profile_idc` in decimal.
103///   - `{compat}` = `general_profile_compatibility_flags` with its 32-bit order
104///     **reversed**, in hex with leading zeros omitted (Main → `6`).
105///   - `{tier}{level}` = 'L'/'H' from `general_tier_flag` + `general_level_idc`
106///     in decimal (L4.0 → `L120`, L3.1 → `L93`).
107///   - `{.cons}*` = the six `general_constraint_indicator_flags` bytes, each a
108///     `.XX` hex segment, trailing zero bytes omitted.
109/// `fourcc` is `hvc1` (out-of-band) or `hev1` (in-band). Example: Main@L3.1
110/// progressive → `hvc1.1.6.L93.B0`.
111pub fn hevc_codec_string(fourcc: &str, sps: &HevcSpsInfo) -> String {
112    let space = match sps.general_profile_space {
113        0 => String::new(),
114        n => ((b'A' + n - 1) as char).to_string(),
115    };
116    let compat = sps.profile_compatibility_flags.reverse_bits();
117    let tier = if sps.tier_flag { 'H' } else { 'L' };
118    let bytes = [
119        (sps.general_constraint_flags >> 40) as u8,
120        (sps.general_constraint_flags >> 32) as u8,
121        (sps.general_constraint_flags >> 24) as u8,
122        (sps.general_constraint_flags >> 16) as u8,
123        (sps.general_constraint_flags >> 8) as u8,
124        sps.general_constraint_flags as u8,
125    ];
126    let mut cons = String::new();
127    if let Some(end) = bytes.iter().rposition(|&b| b != 0) {
128        for b in &bytes[..=end] {
129            cons.push_str(&format!(".{b:02X}"));
130        }
131    }
132    format!(
133        "{}.{}{}.{:X}.{}{}{}",
134        fourcc, space, sps.profile_idc, compat, tier, sps.level_idc, cons
135    )
136}
137
138/// AAC-LC in MP4 codec string. Always `mp4a.40.2`:
139///   - `mp4a` = ISO/IEC 14496 sample entry fourcc
140///   - `40`   = ObjectTypeIndication for MPEG-4 Audio (decimal 64,
141///              hex 0x40)
142///   - `2`    = Audio Object Type 2 (AAC-LC) per ISO/IEC 14496-3
143///              Table 1.16
144///
145/// HE-AAC v1 = `mp4a.40.5`, HE-AAC v2 = `mp4a.40.29`. We don't emit
146/// those today — the audio rendition is always AAC-LC stereo at 48
147/// kHz per the CMAF ladder defaults — but if the worker ever
148/// passes-through HE-AAC source, this needs to inspect the AOT
149/// signaled in the AudioSpecificConfig and switch. Until then,
150/// callers using the constant string are correct.
151pub const AAC_LC_CODEC_STRING: &str = "mp4a.40.2";
152
153/// Convenience: pack an HLS `CODECS=` attribute value for a variant
154/// that carries one video and one audio track. Order is
155/// `<video>,<audio>` per RFC 8216 §4.3.4.2 and HLS-Authoring spec.
156pub fn hls_codecs_attribute(video: &str, audio: &str) -> String {
157    format!("{video},{audio}")
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn avc_codec_string_high_l40() {
166        let sps = H264SpsInfo {
167            profile_idc: 100, // High = 0x64
168            constraint_set_flags: 0x00,
169            level_idc: 40, // L4.0 = 0x28
170            ..Default::default()
171        };
172        assert_eq!(avc_codec_string("avc1", &sps), "avc1.640028");
173        assert_eq!(avc_codec_string("avc3", &sps), "avc3.640028");
174    }
175
176    #[test]
177    fn avc_codec_string_baseline_constrained() {
178        let sps = H264SpsInfo {
179            profile_idc: 66, // Baseline = 0x42
180            constraint_set_flags: 0xC0, // constraint_set0+1
181            level_idc: 30, // 0x1E
182            ..Default::default()
183        };
184        assert_eq!(avc_codec_string("avc1", &sps), "avc1.42C01E");
185    }
186
187    #[test]
188    fn hevc_codec_string_main_l31() {
189        // Well-known Main@L3.1 progressive string: hvc1.1.6.L93.B0
190        let sps = HevcSpsInfo {
191            general_profile_space: 0,
192            profile_idc: 1,
193            profile_compatibility_flags: 0x6000_0000, // flags[1]+[2] → reversed = 0x6
194            tier_flag: false,
195            level_idc: 93,
196            general_constraint_flags: 0xB000_0000_0000, // first byte 0xB0, rest zero
197            ..Default::default()
198        };
199        assert_eq!(hevc_codec_string("hvc1", &sps), "hvc1.1.6.L93.B0");
200        assert_eq!(hevc_codec_string("hev1", &sps), "hev1.1.6.L93.B0");
201    }
202
203    #[test]
204    fn hevc_codec_string_main10_high_tier_no_constraints() {
205        let sps = HevcSpsInfo {
206            general_profile_space: 0,
207            profile_idc: 2, // Main 10
208            profile_compatibility_flags: 0x2000_0000, // flags[2] → reversed = 0x4
209            tier_flag: true, // High tier
210            level_idc: 120, // L4.0
211            general_constraint_flags: 0, // all zero → no trailing .XX
212            ..Default::default()
213        };
214        assert_eq!(hevc_codec_string("hvc1", &sps), "hvc1.2.4.H120");
215    }
216
217    fn synth_seq_header(
218        seq_profile: u8,
219        seq_level_idx_0: u8,
220        seq_tier_0: u8,
221        bit_depth: u8,
222        monochrome: bool,
223        color_primaries: u8,
224        transfer_characteristics: u8,
225        matrix_coefficients: u8,
226        color_range: bool,
227    ) -> Av1SequenceHeader {
228        Av1SequenceHeader {
229            seq_profile,
230            still_picture: false,
231            reduced_still_picture_header: false,
232            max_frame_width_minus1: 0,
233            max_frame_height_minus1: 0,
234            seq_level_idx_0,
235            seq_tier_0,
236            bit_depth,
237            monochrome,
238            color_primaries,
239            transfer_characteristics,
240            matrix_coefficients,
241            color_range,
242            chroma_subsampling_x: true,
243            chroma_subsampling_y: true,
244            film_grain_params_present: false,
245            enable_filter_intra: false,
246            enable_intra_edge_filter: false,
247            enable_interintra_compound: false,
248            enable_masked_compound: false,
249            enable_warped_motion: false,
250            enable_dual_filter: false,
251            enable_order_hint: false,
252            enable_jnt_comp: false,
253            enable_ref_frame_mvs: false,
254            enable_superres: false,
255            enable_cdef: false,
256            enable_restoration: false,
257            order_hint_bits: 0,
258            seq_force_screen_content_tools: 0,
259            seq_force_integer_mv: 0,
260            frame_width_bits_minus_1: 0,
261            frame_height_bits_minus_1: 0,
262            use_128x128_superblock: false,
263            separate_uv_delta_q: false,
264        }
265    }
266
267    #[test]
268    fn av1_string_short_form_at_bt709_defaults() {
269        // Profile 0, level_idx 8 (level 4.0), Main tier, 8-bit, SDR BT.709 limited.
270        // The "boring 1080p" baseline string — at all defaults so the
271        // short form is correct. Long form here was rejected by Chrome
272        // / hls.js MediaSource.isTypeSupported in 2026-05-02 testing
273        // (manifest_url playback dropped video while audio worked).
274        let h = synth_seq_header(0, 8, 0, 8, false, 1, 1, 1, false);
275        assert_eq!(av1_codec_string(&h), "av01.0.08M.08");
276    }
277
278    #[test]
279    fn av1_string_high_tier_renders_h_character() {
280        // Level 6.0 (idx 16), High tier — tier_char swaps M -> H.
281        // Bit depth + color codes deviate from defaults so long form is correct.
282        let h = synth_seq_header(0, 16, 1, 10, false, 9, 16, 9, false);
283        assert_eq!(av1_codec_string(&h), "av01.0.16H.10.0.009.016.009.0");
284    }
285
286    #[test]
287    fn av1_string_hdr10_bt2020_pq_full_range() {
288        // BT.2020 + PQ + BT.2020 NCL + full range = HDR10 limited PQ.
289        // CCC=009, TTT=016, MMM=009, F=1. Long form REQUIRED — short
290        // form at defaults would mis-signal as BT.709 SDR.
291        let h = synth_seq_header(0, 12, 0, 10, false, 9, 16, 9, true);
292        assert_eq!(av1_codec_string(&h), "av01.0.12M.10.0.009.016.009.1");
293    }
294
295    #[test]
296    fn av1_string_monochrome_uses_long_form() {
297        // Monochrome is non-default — long form required so the player
298        // doesn't allocate a chroma buffer that won't get filled.
299        let h = synth_seq_header(0, 8, 0, 8, true, 1, 1, 1, false);
300        assert_eq!(av1_codec_string(&h), "av01.0.08M.08.1.001.001.001.0");
301    }
302
303    #[test]
304    fn av1_string_full_range_at_8bit_bt709_uses_long_form() {
305        // Full range != 0 so even with BT.709 / 8-bit the SDR-defaults
306        // check fails — long form required so the player applies
307        // full-range scaling.
308        let h = synth_seq_header(0, 8, 0, 8, false, 1, 1, 1, true);
309        assert_eq!(av1_codec_string(&h), "av01.0.08M.08.0.001.001.001.1");
310    }
311
312    #[test]
313    fn av1_string_two_digit_level_padding() {
314        // level_idx 0 must format as "00", not "0".
315        let h = synth_seq_header(0, 0, 0, 8, false, 1, 1, 1, false);
316        let s = av1_codec_string(&h);
317        assert!(s.starts_with("av01.0.00M."), "got: {s}");
318    }
319
320    #[test]
321    fn av1_string_two_digit_bit_depth_padding() {
322        // 8-bit at defaults → short form; 10-bit + 12-bit deviate from
323        // bit-depth=8 (which is the implicit default carried by short
324        // form) but they're still valid as short form so long as
325        // color codes are at default.
326        let h_8 = synth_seq_header(0, 8, 0, 8, false, 1, 1, 1, false);
327        let h_10 = synth_seq_header(0, 8, 0, 10, false, 1, 1, 1, false);
328        let h_12 = synth_seq_header(2, 8, 0, 12, false, 1, 1, 1, false);
329        assert_eq!(av1_codec_string(&h_8), "av01.0.08M.08");
330        assert_eq!(av1_codec_string(&h_10), "av01.0.08M.10");
331        assert_eq!(av1_codec_string(&h_12), "av01.2.08M.12");
332    }
333
334    #[test]
335    fn aac_lc_constant_is_canonical() {
336        assert_eq!(AAC_LC_CODEC_STRING, "mp4a.40.2");
337    }
338
339    #[test]
340    fn hls_codecs_attribute_concatenates_video_then_audio() {
341        let s = hls_codecs_attribute("av01.0.08M.08.0.001.001.001.0", AAC_LC_CODEC_STRING);
342        assert_eq!(s, "av01.0.08M.08.0.001.001.001.0,mp4a.40.2");
343    }
344}