From 6552e37017117ae5d9577ad59c2b4b7051643df8 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 24 Jul 2026 13:35:08 -0700 Subject: [PATCH 1/4] Extract bit depth from MP4s --- src/bit_reader.rs | 29 +++++++++++ src/mp4_builder.rs | 38 ++++++++++++-- src/video.rs | 122 ++++++++++++++++++++++++++++++++++++++++++--- src/video_error.rs | 4 ++ tests/metadata.rs | 1 + 5 files changed, 183 insertions(+), 11 deletions(-) diff --git a/src/bit_reader.rs b/src/bit_reader.rs index 74f674a3..766ceacb 100644 --- a/src/bit_reader.rs +++ b/src/bit_reader.rs @@ -24,6 +24,20 @@ impl<'a> BitReader<'a> { pub(crate) fn new(bytes: &'a [u8]) -> Self { Self { bytes, i: 0 } } + + pub(crate) fn ue(&mut self) -> Option { + let mut zeros = 0; + + while self.bit()? == 0 { + zeros += 1; + + if zeros > 31 { + return None; + } + } + + Some((1 << zeros) - 1 + self.bits(zeros)?) + } } #[cfg(test)] @@ -39,4 +53,19 @@ mod tests { assert_eq!(reader.bits(6), Some(0b01_1011)); assert_eq!(reader.bits(7), None); } + + #[test] + fn ue() { + #[track_caller] + fn case(data: &[u8], expected: Option) { + assert_eq!(BitReader::new(data).ue(), expected); + } + + case(&[0b1000_0000], Some(0)); + case(&[0b0100_0000], Some(1)); + case(&[0b0110_0000], Some(2)); + case(&[0b0001_0010], Some(8)); + case(&[0b0000_0000], None); + case(&[], None); + } } diff --git a/src/mp4_builder.rs b/src/mp4_builder.rs index 9f204142..732c816c 100644 --- a/src/mp4_builder.rs +++ b/src/mp4_builder.rs @@ -1,8 +1,10 @@ pub struct Mp4Builder { + avcc_profile: u8, duration: u32, frame_count: u32, sample_size: u32, sample_sizes: Vec, + sps: Vec, timescale: u32, tracks: Vec>, } @@ -45,6 +47,12 @@ impl Mp4Builder { self.track(*b"soun", &[entry]) } + #[must_use] + pub fn avcc_profile(mut self, avcc_profile: u8) -> Self { + self.avcc_profile = avcc_profile; + self + } + pub fn build(self) -> Vec { let mut ftyp = Vec::new(); ftyp.extend_from_slice(b"isom"); @@ -76,10 +84,12 @@ impl Mp4Builder { pub fn new() -> Self { Self { + avcc_profile: 0, duration: 0, frame_count: 0, sample_size: 1, sample_sizes: Vec::new(), + sps: Vec::new(), timescale: 1000, tracks: Vec::new(), } @@ -98,6 +108,12 @@ impl Mp4Builder { self } + #[must_use] + pub fn sps(mut self, sps: &[u8]) -> Self { + self.sps = sps.into(); + self + } + #[must_use] pub fn timescale(mut self, timescale: u32) -> Self { self.timescale = timescale; @@ -190,7 +206,13 @@ impl Mp4Builder { self } - pub fn video_entry(entry: [u8; 4], config: [u8; 4], width: u16, height: u16) -> Vec { + pub fn video_entry( + entry: [u8; 4], + config: [u8; 4], + config_payload: &[u8], + width: u16, + height: u16, + ) -> Vec { let mut payload = Vec::new(); payload.extend_from_slice(&[0; 6]); payload.extend_from_slice(&[0, 1]); @@ -198,14 +220,24 @@ impl Mp4Builder { payload.extend_from_slice(&width.to_be_bytes()); payload.extend_from_slice(&height.to_be_bytes()); payload.extend_from_slice(&[0; 50]); - payload.extend_from_slice(&Self::atom(config, &[1, 0, 0, 0, 0xff, 0xe0, 0])); + payload.extend_from_slice(&Self::atom(config, config_payload)); Self::atom(entry, &payload) } #[must_use] pub fn video_track(self, width: u16, height: u16) -> Self { - let entry = Self::video_entry(*b"avc1", *b"avcC", width, height); + let avcc = if self.sps.is_empty() { + vec![1, self.avcc_profile, 0, 0, 0xff, 0xe0, 0] + } else { + let mut avcc = vec![1, self.sps[1], self.sps[2], self.sps[3], 0xff, 0xe1]; + avcc.extend_from_slice(&u16::try_from(self.sps.len()).unwrap().to_be_bytes()); + avcc.extend_from_slice(&self.sps); + avcc.push(0); + avcc + }; + + let entry = Self::video_entry(*b"avc1", *b"avcC", &avcc, width, height); self.track(*b"vide", &[entry]) } } diff --git a/src/video.rs b/src/video.rs index 0cb80442..6ce808ef 100644 --- a/src/video.rs +++ b/src/video.rs @@ -46,6 +46,13 @@ impl Video { formats } + fn high_profile(profile_idc: u64) -> bool { + matches!( + profile_idc, + 44 | 83 | 86 | 100 | 110 | 118 | 122 | 128 | 134 | 135 | 138 | 139 | 244 + ) + } + fn info(&self, root: &Utf8Path) -> Result { let path = root.join(self.as_path()); @@ -163,10 +170,21 @@ impl Video { ); }; + let bit_depth = if let Some(sps) = avc1.avcc.sequence_parameter_sets.first() { + Some(Self::sps_bit_depth(&sps.bytes).context(video_error::SpsInvalid)?) + } else { + ensure!( + !Self::high_profile(avc1.avcc.avc_profile_indication.into()), + video_error::SpsMissing, + ); + + Some(8) + }; + video_track = Some(Track { codec: Codec::H264, info: TrackInfo::Video { - bit_depth: None, + bit_depth, dimensions: Dimensions { height: avc1.height.into(), width: avc1.width.into(), @@ -376,6 +394,43 @@ impl Video { self.ty.resource_type() } + fn sps_bit_depth(sps: &[u8]) -> Option { + let mut rbsp = Vec::new(); + + // skip NAL unit header and remove emulation prevention bytes + for &byte in sps.get(1..)? { + if byte == 3 && rbsp.ends_with(&[0, 0]) { + continue; + } + + rbsp.push(byte); + } + + let mut reader = BitReader::new(&rbsp); + + // profile_idc + let profile_idc = reader.bits(8)?; + + // constraint flags and level_idc + reader.bits(16)?; + + // seq_parameter_set_id + reader.ue()?; + + if !Self::high_profile(profile_idc) { + return Some(8); + } + + // chroma_format_idc + if reader.ue()? == 3 { + // separate_colour_plane_flag + reader.bit()?; + } + + // bit_depth_luma_minus8 + Some(8 + reader.ue()?) + } + fn vp9_bit_depth(data: &[u8]) -> Option { let mut reader = BitReader::new(data); @@ -663,7 +718,7 @@ mod tests { Track { codec: Codec::H264, info: TrackInfo::Video { - bit_depth: None, + bit_depth: Some(8), dimensions: Dimensions { height: 1, width: 2, @@ -688,7 +743,7 @@ mod tests { tracks: vec![Track { codec: Codec::H264, info: TrackInfo::Video { - bit_depth: None, + bit_depth: Some(8), dimensions: Dimensions { height: 1, width: 2, @@ -725,7 +780,7 @@ mod tests { .tracks[0] .info, TrackInfo::Video { - bit_depth: None, + bit_depth: Some(8), dimensions: Dimensions { height: 1, width: 2, @@ -754,7 +809,7 @@ mod tests { Track { codec: Codec::H264, info: TrackInfo::Video { - bit_depth: None, + bit_depth: Some(8), dimensions: Dimensions { height: 1, width: 2, @@ -765,6 +820,35 @@ mod tests { }, ); + assert_eq!( + case( + Mp4Builder::new() + .sps(&[0x67, 100, 0, 31, 0xA6]) + .video_track(2, 1), + ) + .unwrap() + .tracks[0] + .info, + TrackInfo::Video { + bit_depth: Some(10), + dimensions: Dimensions { + height: 1, + width: 2, + }, + frames: 0, + }, + ); + + error( + Mp4Builder::new().sps(&[0x67, 100, 0, 31]).video_track(2, 1), + "invalid SPS", + ); + + error( + Mp4Builder::new().avcc_profile(100).video_track(2, 1), + "missing SPS", + ); + error( Mp4Builder::new().timescale(0).video_track(2, 1), "zero timescale", @@ -796,7 +880,13 @@ mod tests { Mp4Builder::new() .track( *b"vide", - &[Mp4Builder::video_entry(*b"s263", *b"d263", 2, 1)], + &[Mp4Builder::video_entry( + *b"s263", + *b"d263", + &[1, 0, 0, 0, 0xff, 0xe0, 0], + 2, + 1, + )], ) .audio_track(0x40), "track 0 has unsupported video codec `s263`", @@ -843,7 +933,7 @@ mod tests { Track { codec: Codec::H264, info: TrackInfo::Video { - bit_depth: None, + bit_depth: Some(8), dimensions: Dimensions { height: 1, width: 2, @@ -875,7 +965,7 @@ mod tests { Track { codec: Codec::H264, info: TrackInfo::Video { - bit_depth: None, + bit_depth: Some(8), dimensions: Dimensions { height: 1, width: 2, @@ -930,6 +1020,22 @@ mod tests { ); } + #[test] + fn sps_bit_depth() { + #[track_caller] + fn case(sps: &[u8], expected: Option) { + assert_eq!(Video::sps_bit_depth(sps), expected); + } + + case(&[0x67, 66, 0, 30, 0x80], Some(8)); + case(&[0x67, 100, 0, 31, 0xA6], Some(10)); + case(&[0x67, 100, 0, 31, 0x91], Some(8)); + case(&[0x67, 100, 0, 0, 0x03, 0xA6], Some(10)); + case(&[0x67, 100, 0, 31], None); + case(&[0x67], None); + case(&[], None); + } + #[test] fn vp9_bit_depth() { #[track_caller] diff --git a/src/video_error.rs b/src/video_error.rs index 36758503..41e84439 100644 --- a/src/video_error.rs +++ b/src/video_error.rs @@ -21,6 +21,10 @@ pub enum VideoError { DurationMissing, #[snafu(display("duration overflow"))] DurationOverflow, + #[snafu(display("invalid SPS"))] + SpsInvalid, + #[snafu(display("missing SPS"))] + SpsMissing, #[snafu(display("zero timescale"))] TimescaleZero, #[snafu(display("unsupported timestamp scale {timestamp_scale}"))] diff --git a/tests/metadata.rs b/tests/metadata.rs index 5c43c3ce..066b9fdf 100644 --- a/tests/metadata.rs +++ b/tests/metadata.rs @@ -171,6 +171,7 @@ media: "codec": "h264", "info": { "type": "video", + "bit_depth": 8, "dimensions": { "height": 1, "width": 2 From 6c9ac20839ae45779f786aabefc2e84a909dba66 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 24 Jul 2026 13:46:21 -0700 Subject: [PATCH 2/4] Amend --- src/video.rs | 112 +++++++++++++++++++++++++-------------------------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/src/video.rs b/src/video.rs index 6ce808ef..8deef576 100644 --- a/src/video.rs +++ b/src/video.rs @@ -46,7 +46,44 @@ impl Video { formats } - fn high_profile(profile_idc: u64) -> bool { + fn h264_bit_depth(sps: &[u8]) -> Option { + let mut rbsp = Vec::new(); + + // skip NAL unit header and remove emulation prevention bytes + for &byte in sps.get(1..)? { + if byte == 3 && rbsp.ends_with(&[0, 0]) { + continue; + } + + rbsp.push(byte); + } + + let mut reader = BitReader::new(&rbsp); + + // profile_idc + let profile_idc = reader.bits(8)?; + + // constraint flags and level_idc + reader.bits(16)?; + + // seq_parameter_set_id + reader.ue()?; + + if !Self::h264_high_profile(profile_idc) { + return Some(8); + } + + // chroma_format_idc + if reader.ue()? == 3 { + // separate_colour_plane_flag + reader.bit()?; + } + + // bit_depth_luma_minus8 + Some(8 + reader.ue()?) + } + + fn h264_high_profile(profile_idc: u64) -> bool { matches!( profile_idc, 44 | 83 | 86 | 100 | 110 | 118 | 122 | 128 | 134 | 135 | 138 | 139 | 244 @@ -171,10 +208,10 @@ impl Video { }; let bit_depth = if let Some(sps) = avc1.avcc.sequence_parameter_sets.first() { - Some(Self::sps_bit_depth(&sps.bytes).context(video_error::SpsInvalid)?) + Some(Self::h264_bit_depth(&sps.bytes).context(video_error::SpsInvalid)?) } else { ensure!( - !Self::high_profile(avc1.avcc.avc_profile_indication.into()), + !Self::h264_high_profile(avc1.avcc.avc_profile_indication.into()), video_error::SpsMissing, ); @@ -394,43 +431,6 @@ impl Video { self.ty.resource_type() } - fn sps_bit_depth(sps: &[u8]) -> Option { - let mut rbsp = Vec::new(); - - // skip NAL unit header and remove emulation prevention bytes - for &byte in sps.get(1..)? { - if byte == 3 && rbsp.ends_with(&[0, 0]) { - continue; - } - - rbsp.push(byte); - } - - let mut reader = BitReader::new(&rbsp); - - // profile_idc - let profile_idc = reader.bits(8)?; - - // constraint flags and level_idc - reader.bits(16)?; - - // seq_parameter_set_id - reader.ue()?; - - if !Self::high_profile(profile_idc) { - return Some(8); - } - - // chroma_format_idc - if reader.ue()? == 3 { - // separate_colour_plane_flag - reader.bit()?; - } - - // bit_depth_luma_minus8 - Some(8 + reader.ue()?) - } - fn vp9_bit_depth(data: &[u8]) -> Option { let mut reader = BitReader::new(data); @@ -696,6 +696,22 @@ mod tests { case("foo/bar.mp4", ComponentError::Separator { character: '/' }); } + #[test] + fn h264_bit_depth() { + #[track_caller] + fn case(sps: &[u8], expected: Option) { + assert_eq!(Video::h264_bit_depth(sps), expected); + } + + case(&[0x67, 66, 0, 30, 0x80], Some(8)); + case(&[0x67, 100, 0, 31, 0xA6], Some(10)); + case(&[0x67, 100, 0, 31, 0x91], Some(8)); + case(&[0x67, 100, 0, 0, 0x03, 0xA6], Some(10)); + case(&[0x67, 100, 0, 31], None); + case(&[0x67], None); + case(&[], None); + } + #[test] fn mp4_info() { #[track_caller] @@ -1020,22 +1036,6 @@ mod tests { ); } - #[test] - fn sps_bit_depth() { - #[track_caller] - fn case(sps: &[u8], expected: Option) { - assert_eq!(Video::sps_bit_depth(sps), expected); - } - - case(&[0x67, 66, 0, 30, 0x80], Some(8)); - case(&[0x67, 100, 0, 31, 0xA6], Some(10)); - case(&[0x67, 100, 0, 31, 0x91], Some(8)); - case(&[0x67, 100, 0, 0, 0x03, 0xA6], Some(10)); - case(&[0x67, 100, 0, 31], None); - case(&[0x67], None); - case(&[], None); - } - #[test] fn vp9_bit_depth() { #[track_caller] From 37679350865f27c783b9fcd1bd2e239deb5ecca0 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 24 Jul 2026 13:49:28 -0700 Subject: [PATCH 3/4] Adapt --- src/video.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/video.rs b/src/video.rs index 8deef576..aadcb133 100644 --- a/src/video.rs +++ b/src/video.rs @@ -63,8 +63,11 @@ impl Video { // profile_idc let profile_idc = reader.bits(8)?; - // constraint flags and level_idc - reader.bits(16)?; + // constraint flags + reader.bits(8)?; + + // level_idc + reader.bits(8)?; // seq_parameter_set_id reader.ue()?; From 03ebfcd1b748255160038f748812ee09665df400 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 24 Jul 2026 13:51:17 -0700 Subject: [PATCH 4/4] Adjust --- src/video.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/video.rs b/src/video.rs index aadcb133..6397f14b 100644 --- a/src/video.rs +++ b/src/video.rs @@ -49,8 +49,9 @@ impl Video { fn h264_bit_depth(sps: &[u8]) -> Option { let mut rbsp = Vec::new(); - // skip NAL unit header and remove emulation prevention bytes + // skip NAL unit header for &byte in sps.get(1..)? { + // remove emulation prevention bytes if byte == 3 && rbsp.ends_with(&[0, 0]) { continue; }