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}