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}