mux/hevc: real chroma/bit-depth in hvcC + clamp oversized param sets

codec_private() hardcoded 8-bit 4:2:0 in the hvcC fixed header, wrong for
10-bit Main 10 UHD (essentially all UHD). Parse chroma_format_idc and
bit_depth_luma/chroma_minus8 from the SPS RBSP (with emulation-prevention
removal and sub-layer profile_tier_level handling) and emit the real
values; fall back to 8-bit 4:2:0 only if the SPS can't be parsed.

Also guard the 16-bit NAL length fields: a param set larger than 65535
bytes would truncate the length while appending all bytes, mis-framing
the record. Return None instead of emitting a corrupt hvcC.
This commit is contained in:
MattJackson
2026-06-06 21:22:44 -07:00
parent a5962be86c
commit 2eee777b8f
+356 -6
View File
@@ -165,6 +165,14 @@ impl CodecParser for HevcParser {
let sps = self.sps.as_ref()?; let sps = self.sps.as_ref()?;
let pps = self.pps.as_ref()?; let pps = self.pps.as_ref()?;
// hvcC encodes each NAL's length as a 16-bit field. A param set larger
// than 65535 bytes would silently truncate the length while the full
// bytes are appended → mis-framed record. Refuse rather than emit a
// corrupt hvcC (param sets this large are non-conforming anyway).
if vps.len() > 0xFFFF || sps.len() > 0xFFFF || pps.len() > 0xFFFF {
return None;
}
// Simplified: store as arrays in Annex B format // Simplified: store as arrays in Annex B format
// Full HEVCDecoderConfigurationRecord is complex — for now, concatenate // Full HEVCDecoderConfigurationRecord is complex — for now, concatenate
let mut record = Vec::new(); let mut record = Vec::new();
@@ -208,12 +216,21 @@ impl CodecParser for HevcParser {
record.extend_from_slice(&[0xF0, 0x00]); record.extend_from_slice(&[0xF0, 0x00]);
// parallelismType (6 + 2 bits) // parallelismType (6 + 2 bits)
record.push(0xFC); record.push(0xFC);
// chromaFormat (6 + 2 bits) // chromaFormat / bit depths — parse the real values from the SPS RBSP.
record.push(0xFC | 1); // 4:2:0 // A hardcoded 8-bit 4:2:0 is wrong for 10-bit Main 10 UHD (essentially
// bitDepthLumaMinus8 (5 + 3 bits) // all UHD content). Fall back to 8-bit 4:2:0 only if the SPS can't be
record.push(0xF8); // parsed (emulation-prevention is handled; sub-layer PTL is skipped).
// bitDepthChromaMinus8 (5 + 3 bits) let chroma = parse_sps_chroma(sps).unwrap_or(SpsChroma {
record.push(0xF8); chroma_format_idc: 1,
bit_depth_luma_minus8: 0,
bit_depth_chroma_minus8: 0,
});
// chromaFormat (6 reserved bits set + 2-bit chroma_format_idc)
record.push(0xFC | (chroma.chroma_format_idc & 0x03));
// bitDepthLumaMinus8 (5 reserved bits set + 3-bit value)
record.push(0xF8 | (chroma.bit_depth_luma_minus8 & 0x07));
// bitDepthChromaMinus8 (5 reserved bits set + 3-bit value)
record.push(0xF8 | (chroma.bit_depth_chroma_minus8 & 0x07));
// avgFrameRate // avgFrameRate
record.extend_from_slice(&[0, 0]); record.extend_from_slice(&[0, 0]);
// constantFrameRate + numTemporalLayers + temporalIdNested + lengthSizeMinusOne // constantFrameRate + numTemporalLayers + temporalIdNested + lengthSizeMinusOne
@@ -246,6 +263,181 @@ impl CodecParser for HevcParser {
} }
} }
/// chroma_format_idc + bit depths parsed from an HEVC SPS RBSP, for the hvcC
/// fixed header. Without these the record falsely advertised 8-bit 4:2:0, wrong
/// for 10-bit Main 10 UHD (essentially all UHD content).
struct SpsChroma {
/// chroma_format_idc: 0 mono, 1 4:2:0, 2 4:2:2, 3 4:4:4.
chroma_format_idc: u8,
bit_depth_luma_minus8: u8,
bit_depth_chroma_minus8: u8,
}
/// Minimal MSB-first bit reader over a byte slice.
struct BitReader<'a> {
data: &'a [u8],
bit_pos: usize,
}
impl<'a> BitReader<'a> {
fn new(data: &'a [u8]) -> Self {
Self { data, bit_pos: 0 }
}
fn read_bit(&mut self) -> Option<u32> {
let byte = self.bit_pos / 8;
if byte >= self.data.len() {
return None;
}
let shift = 7 - (self.bit_pos % 8);
self.bit_pos += 1;
Some(((self.data[byte] >> shift) & 1) as u32)
}
fn read_bits(&mut self, n: u32) -> Option<u32> {
let mut v = 0u32;
for _ in 0..n {
v = (v << 1) | self.read_bit()?;
}
Some(v)
}
fn skip_bits(&mut self, n: u32) -> Option<()> {
for _ in 0..n {
self.read_bit()?;
}
Some(())
}
/// Exp-Golomb unsigned, ue(v). Bounded leading-zero count to avoid runaway
/// on corrupt input.
fn read_ue(&mut self) -> Option<u32> {
let mut zeros = 0u32;
while self.read_bit()? == 0 {
zeros += 1;
if zeros > 31 {
return None;
}
}
if zeros == 0 {
return Some(0);
}
let rest = self.read_bits(zeros)?;
Some((1u32 << zeros) - 1 + rest)
}
}
/// Strip HEVC/H.264 emulation-prevention bytes (00 00 03 → 00 00) from a NAL
/// RBSP so a bit reader sees the true coded values.
fn strip_emulation_prevention(rbsp: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(rbsp.len());
let mut zeros = 0usize;
for &b in rbsp {
if zeros >= 2 && b == 0x03 {
// Drop the emulation-prevention byte; reset the run.
zeros = 0;
continue;
}
out.push(b);
if b == 0x00 {
zeros += 1;
} else {
zeros = 0;
}
}
out
}
/// Parse chroma_format_idc and bit depths from a stored SPS NAL
/// (`[2-byte NAL header][RBSP...]`). Handles emulation-prevention and
/// sub-layer profile_tier_level. Returns `None` if the bitstream is too short
/// or malformed (caller falls back to the 8-bit 4:2:0 default).
fn parse_sps_chroma(sps: &[u8]) -> Option<SpsChroma> {
if sps.len() < 3 {
return None;
}
// RBSP begins after the 2-byte HEVC NAL header.
let rbsp = strip_emulation_prevention(&sps[2..]);
let mut r = BitReader::new(&rbsp);
// sps_video_parameter_set_id u(4)
r.skip_bits(4)?;
// sps_max_sub_layers_minus1 u(3)
let max_sub_layers_minus1 = r.read_bits(3)?;
// sps_temporal_id_nesting_flag u(1)
r.skip_bits(1)?;
// profile_tier_level( 1, sps_max_sub_layers_minus1 )
parse_profile_tier_level(&mut r, max_sub_layers_minus1)?;
// sps_seq_parameter_set_id ue(v)
r.read_ue()?;
// chroma_format_idc ue(v)
let chroma_format_idc = r.read_ue()? as u8;
if chroma_format_idc == 3 {
// separate_colour_plane_flag u(1)
r.skip_bits(1)?;
}
// pic_width_in_luma_samples ue(v), pic_height_in_luma_samples ue(v)
r.read_ue()?;
r.read_ue()?;
// conformance_window_flag u(1) + 4× ue(v) if set
if r.read_bit()? == 1 {
r.read_ue()?;
r.read_ue()?;
r.read_ue()?;
r.read_ue()?;
}
// bit_depth_luma_minus8 ue(v), bit_depth_chroma_minus8 ue(v)
let bit_depth_luma_minus8 = r.read_ue()? as u8;
let bit_depth_chroma_minus8 = r.read_ue()? as u8;
Some(SpsChroma {
chroma_format_idc,
bit_depth_luma_minus8,
bit_depth_chroma_minus8,
})
}
/// Consume a profile_tier_level(profilePresentFlag=1, maxNumSubLayersMinus1)
/// structure from the bit reader (HEVC 7.3.3).
fn parse_profile_tier_level(r: &mut BitReader, max_sub_layers_minus1: u32) -> Option<()> {
// general: profile_space u(2) + tier u(1) + profile_idc u(5) = 8 bits,
// profile_compatibility_flags u(32), 4× constraint/flags + reserved = 44
// bits, general_inbld/reserved = 1 bit (total constraint area 48 bits),
// general_level_idc u(8). 88 bits = 11 bytes... but the spec packs the
// general PTL as 8 + 32 + 48 + 8 = 96 bits = 12 bytes. Skip 96 bits.
r.skip_bits(96)?;
if max_sub_layers_minus1 > 0 {
// sub_layer_profile_present_flag[i] u(1) + sub_layer_level_present_flag[i]
// u(1), for i in 0..max_sub_layers_minus1.
let mut profile_present = [false; 8];
let mut level_present = [false; 8];
for i in 0..max_sub_layers_minus1 as usize {
profile_present[i] = r.read_bit()? == 1;
level_present[i] = r.read_bit()? == 1;
}
// reserved_zero_2bits for i in max_sub_layers_minus1..8
if max_sub_layers_minus1 < 8 {
for _ in max_sub_layers_minus1..8 {
r.skip_bits(2)?;
}
}
for i in 0..max_sub_layers_minus1 as usize {
if profile_present[i] {
// sub_layer profile block: 8 + 32 + 48 = 88 bits.
r.skip_bits(88)?;
}
if level_present[i] {
// sub_layer_level_idc u(8)
r.skip_bits(8)?;
}
}
}
Some(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -813,4 +1005,162 @@ mod tests {
offset += length; offset += length;
} }
} }
// --- hvcC chroma / bit-depth from SPS ---
/// MSB-first bit writer for building a test SPS RBSP.
struct BitWriter {
bytes: Vec<u8>,
nbits: usize,
}
impl BitWriter {
fn new() -> Self {
Self {
bytes: Vec::new(),
nbits: 0,
}
}
fn put_bit(&mut self, b: u32) {
if self.nbits % 8 == 0 {
self.bytes.push(0);
}
if b & 1 != 0 {
let i = self.nbits / 8;
let shift = 7 - (self.nbits % 8);
self.bytes[i] |= 1 << shift;
}
self.nbits += 1;
}
fn put_bits(&mut self, v: u32, n: u32) {
for i in (0..n).rev() {
self.put_bit((v >> i) & 1);
}
}
fn put_ue(&mut self, v: u32) {
let val = v + 1;
let bits = 32 - val.leading_zeros();
for _ in 0..bits - 1 {
self.put_bit(0);
}
for i in (0..bits).rev() {
self.put_bit((val >> i) & 1);
}
}
}
/// Build a stored SPS NAL ([2-byte header][RBSP]) with the given
/// chroma_format_idc and bit depths, max_sub_layers_minus1 = 0.
fn make_sps_with_chroma(chroma_idc: u32, bd_luma_m8: u32, bd_chroma_m8: u32) -> Vec<u8> {
let mut w = BitWriter::new();
w.put_bits(0, 4); // sps_video_parameter_set_id
w.put_bits(0, 3); // sps_max_sub_layers_minus1 = 0
w.put_bit(1); // sps_temporal_id_nesting_flag
// general profile_tier_level: 96 bits (12 bytes) of zeros is fine here.
for _ in 0..96 {
w.put_bit(0);
}
w.put_ue(0); // sps_seq_parameter_set_id
w.put_ue(chroma_idc); // chroma_format_idc
if chroma_idc == 3 {
w.put_bit(0); // separate_colour_plane_flag
}
w.put_ue(3840); // pic_width_in_luma_samples
w.put_ue(2160); // pic_height_in_luma_samples
w.put_bit(0); // conformance_window_flag = 0
w.put_ue(bd_luma_m8); // bit_depth_luma_minus8
w.put_ue(bd_chroma_m8); // bit_depth_chroma_minus8
let mut sps = hevc_nal_header(33).to_vec();
sps.extend_from_slice(&w.bytes);
sps
}
fn codec_private_from_sps(sps_nal: &[u8]) -> Vec<u8> {
let mut parser = HevcParser::new();
// VPS + the given SPS + PPS, all length-prefixed in one PES.
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(32));
data.extend_from_slice(&[0xAA, 0xBB]);
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(sps_nal);
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(34));
data.extend_from_slice(&[0xDD, 0xEE]);
parser.parse(&make_pes(data, Some(0)));
parser.codec_private().expect("codec_private")
}
#[test]
fn hvcc_emits_10bit_420_from_sps() {
// Main 10 UHD: chroma_format_idc=1 (4:2:0), bit depths = 10 (minus8 = 2).
let sps = make_sps_with_chroma(1, 2, 2);
let cp = codec_private_from_sps(&sps);
// chromaFormat at cp[16], bit depths at cp[17]/cp[18].
assert_eq!(cp[16], 0xFC | 1, "chroma_format_idc = 1 (4:2:0)");
assert_eq!(cp[17], 0xF8 | 2, "bit_depth_luma_minus8 = 2 (10-bit)");
assert_eq!(cp[18], 0xF8 | 2, "bit_depth_chroma_minus8 = 2 (10-bit)");
}
#[test]
fn hvcc_emits_8bit_420_from_sps() {
// 8-bit 4:2:0 must still report correctly (not a regression).
let sps = make_sps_with_chroma(1, 0, 0);
let cp = codec_private_from_sps(&sps);
assert_eq!(cp[16], 0xFC | 1);
assert_eq!(cp[17], 0xF8);
assert_eq!(cp[18], 0xF8);
}
#[test]
fn hvcc_emits_444_12bit_from_sps() {
// 4:4:4 (idc=3) with 12-bit depth (minus8 = 4).
let sps = make_sps_with_chroma(3, 4, 4);
let cp = codec_private_from_sps(&sps);
assert_eq!(cp[16], 0xFC | 3, "chroma_format_idc = 3 (4:4:4)");
assert_eq!(cp[17], 0xF8 | 4, "bit_depth_luma_minus8 = 4 (12-bit)");
assert_eq!(cp[18], 0xF8 | 4);
}
#[test]
fn hvcc_handles_emulation_prevention_in_sps() {
// Insert an emulation-prevention byte (00 00 03) into the SPS RBSP and
// confirm the chroma/bit-depth parse still lands on the right values.
// Build a 10-bit 4:2:0 SPS, then splice 00 00 03 into the RBSP tail
// (after the fields we parse) — the strip must not corrupt earlier bits.
let mut sps = make_sps_with_chroma(1, 2, 2);
// Append a benign 00 00 03 sequence to the RBSP.
sps.extend_from_slice(&[0x00, 0x00, 0x03, 0x00]);
let cp = codec_private_from_sps(&sps);
assert_eq!(cp[16], 0xFC | 1);
assert_eq!(cp[17], 0xF8 | 2);
assert_eq!(cp[18], 0xF8 | 2);
}
#[test]
fn hvcc_oversized_param_set_returns_none() {
// A param set larger than 65535 bytes cannot be length-encoded in hvcC's
// 16-bit field; codec_private must refuse rather than emit a truncated,
// mis-framed record.
let mut parser = HevcParser::new();
let mut data = Vec::new();
// VPS
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(32));
data.extend_from_slice(&[0xAA, 0xBB]);
// Oversized SPS: header + 70000 bytes of payload (avoid 00 00 0x runs by
// using 0x11 filler so it stays one NAL).
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(33));
data.extend_from_slice(&vec![0x11u8; 70_000]);
// PPS
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(34));
data.extend_from_slice(&[0xDD, 0xEE]);
parser.parse(&make_pes(data, Some(0)));
assert!(
parser.codec_private().is_none(),
"oversized param set must not produce a (truncated) hvcC"
);
}
} }