Skip to content

Commit 9e456d8

Browse files
alastor0325kinetiknz
authored andcommitted
Parse colr box CICP data for video tracks
Add parsing of the ISO BMFF `colr` box (ISO 14496-12:2020 §12.1.5) in `read_video_sample_entry`, storing the result in a new `colour_info` field on `VideoSampleEntry`. Expose the nclx CICP values through the C API via four new fields on `Mp4parseTrackVideoSampleInfo`: `has_colour_info`, `colour_primaries`, `transfer_characteristics`, `matrix_coefficients`, and `full_range_flag`. This enables Firefox's MP4 demuxer to set `mVideoInfo.mTransferFunction` for fMP4 content (Netflix, Prime Video, DRM), fixing HDR10/HLG video appearing near-black on Windows HDR displays. - `read_video_sample_entry` now takes `strictness` (consistent with `read_audio_sample_entry`) and passes it to `read_colr`. - Duplicate `colr` boxes in a sample entry violate ISO 14496-12:2020 §12.1.5; we warn and keep the first rather than failing playback. - `has_colour_info: bool` is the canonical "present" flag; avoids misinterpreting MC=0 (Identity/GBR) as "absent". - `NclxColourInformation` fields made public for Rust callers. - Seven integration tests added. Test MP4 files were generated with ffmpeg using `-movflags +write_colr` and `-x264-params colorprim/transfer/colormatrix`: HDR10 (cp=9,tc=16,mc=9), HLG (tc=18), HDR10 full-range, HLG full-range, RGB/Identity (mc=0, exercises has_colour_info), and no-colr variants.
1 parent c6f33df commit 9e456d8

9 files changed

Lines changed: 208 additions & 10 deletions

mp4parse/src/lib.rs

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1175,6 +1175,7 @@ pub struct VideoSampleEntry {
11751175
pub codec_specific: VideoCodecSpecific,
11761176
pub protection_info: TryVec<ProtectionSchemeInfoBox>,
11771177
pub pixel_aspect_ratio: Option<f32>,
1178+
pub colour_info: Option<ColourInformation>,
11781179
}
11791180

11801181
/// Represent a Video Partition Codec Configuration 'vpcC' box (aka vp9). The meaning of each
@@ -3722,10 +3723,10 @@ fn read_pixi<T: Read>(src: &mut BMFFBox<T>) -> Result<PixelInformation> {
37223723
#[repr(C)]
37233724
#[derive(Debug)]
37243725
pub struct NclxColourInformation {
3725-
colour_primaries: u8,
3726-
transfer_characteristics: u8,
3727-
matrix_coefficients: u8,
3728-
full_range_flag: bool,
3726+
pub colour_primaries: u8,
3727+
pub transfer_characteristics: u8,
3728+
pub matrix_coefficients: u8,
3729+
pub full_range_flag: bool,
37293730
}
37303731

37313732
/// The raw bytes of the ICC profile
@@ -5533,7 +5534,10 @@ fn read_hdlr<T: Read>(src: &mut BMFFBox<T>, strictness: ParseStrictness) -> Resu
55335534
}
55345535

55355536
/// Parse an video description inside an stsd box.
5536-
fn read_video_sample_entry<T: Read>(src: &mut BMFFBox<T>) -> Result<SampleEntry> {
5537+
fn read_video_sample_entry<T: Read>(
5538+
src: &mut BMFFBox<T>,
5539+
strictness: ParseStrictness,
5540+
) -> Result<SampleEntry> {
55375541
let name = src.get_header().name;
55385542
let codec_type = match name {
55395543
BoxType::AVCSampleEntry | BoxType::AVC3SampleEntry => CodecType::H264,
@@ -5567,6 +5571,7 @@ fn read_video_sample_entry<T: Read>(src: &mut BMFFBox<T>) -> Result<SampleEntry>
55675571
// Skip clap/pasp/etc. for now.
55685572
let mut codec_specific = None;
55695573
let mut pixel_aspect_ratio = None;
5574+
let mut colour_info = None;
55705575
let mut protection_info = TryVec::new();
55715576
let mut iter = src.box_iter();
55725577
while let Some(mut b) = iter.next_box()? {
@@ -5683,6 +5688,19 @@ fn read_video_sample_entry<T: Read>(src: &mut BMFFBox<T>) -> Result<SampleEntry>
56835688
}
56845689
debug!("Parsed pasp box: {pasp:?}, PAR {pixel_aspect_ratio:?}");
56855690
}
5691+
BoxType::ColourInformationBox => {
5692+
if colour_info.is_some() {
5693+
// ISO 14496-12:2020 §12.1.5 requires at most one ColourInformationBox
5694+
// per sample entry. Duplicates are a spec violation, but we tolerate
5695+
// them to avoid breaking playback of malformed content.
5696+
warn!("Multiple colr boxes in video sample entry, keeping first");
5697+
skip_box_content(&mut b)?;
5698+
} else {
5699+
let colr = read_colr(&mut b, strictness)?;
5700+
debug!("Parsed colr box: {colr:?}");
5701+
colour_info = Some(colr);
5702+
}
5703+
}
56865704
_ => {
56875705
debug!("Unsupported video codec, box {:?} found", b.head.name);
56885706
skip_box_content(&mut b)?;
@@ -5701,6 +5719,7 @@ fn read_video_sample_entry<T: Read>(src: &mut BMFFBox<T>) -> Result<SampleEntry>
57015719
codec_specific,
57025720
protection_info,
57035721
pixel_aspect_ratio,
5722+
colour_info,
57045723
})
57055724
}),
57065725
)
@@ -5908,9 +5927,9 @@ fn read_stsd<T: Read>(
59085927
while descriptions.len() < description_count {
59095928
if let Some(mut b) = iter.next_box()? {
59105929
let description = match track.track_type {
5911-
TrackType::Video => read_video_sample_entry(&mut b),
5912-
TrackType::Picture => read_video_sample_entry(&mut b),
5913-
TrackType::AuxiliaryVideo => read_video_sample_entry(&mut b),
5930+
TrackType::Video => read_video_sample_entry(&mut b, strictness),
5931+
TrackType::Picture => read_video_sample_entry(&mut b, strictness),
5932+
TrackType::AuxiliaryVideo => read_video_sample_entry(&mut b, strictness),
59145933
TrackType::Audio => read_audio_sample_entry(&mut b, strictness),
59155934
TrackType::Metadata => Err(Error::Unsupported("metadata track")),
59165935
TrackType::Unknown => Err(Error::Unsupported("unknown track type")),

mp4parse/src/tests.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,7 +1145,8 @@ fn read_stsd_mp4v() {
11451145
let mut iter = super::BoxIter::new(&mut stream);
11461146
let mut stream = iter.next_box().unwrap().unwrap();
11471147

1148-
let sample_entry = super::read_video_sample_entry(&mut stream).unwrap();
1148+
let sample_entry =
1149+
super::read_video_sample_entry(&mut stream, super::ParseStrictness::Normal).unwrap();
11491150

11501151
match sample_entry {
11511152
super::SampleEntry::Video(v) => {
@@ -1245,7 +1246,7 @@ fn unknown_video_sample_entry() {
12451246
});
12461247
let mut iter = super::BoxIter::new(&mut stream);
12471248
let mut stream = iter.next_box().unwrap().unwrap();
1248-
match super::read_video_sample_entry(&mut stream) {
1249+
match super::read_video_sample_entry(&mut stream, super::ParseStrictness::Normal) {
12491250
Ok(super::SampleEntry::Unknown) => (),
12501251
_ => panic!("expected a different error result"),
12511252
}

mp4parse_capi/src/lib.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,18 @@ pub struct Mp4parseTrackVideoSampleInfo {
264264
pub image_height: u16,
265265
pub extra_data: Mp4parseByteData,
266266
pub protected_data: Mp4parseSinfInfo,
267+
/// True when a `colr` box with `colour_type = 'nclx'` was present. When false,
268+
/// the CICP fields below are all zero and must not be interpreted.
269+
pub has_colour_info: bool,
270+
/// CICP colour primaries (ISO 23091-2 § 8.1). Valid only when `has_colour_info`.
271+
pub colour_primaries: u8,
272+
/// CICP transfer characteristics (ISO 23091-2 § 8.2). Valid only when `has_colour_info`.
273+
pub transfer_characteristics: u8,
274+
/// CICP matrix coefficients (ISO 23091-2 § 8.3). Valid only when `has_colour_info`.
275+
/// Note: value 0 is a valid CICP value (Identity/GBR), not an absence indicator.
276+
pub matrix_coefficients: u8,
277+
/// Full range flag from the colr nclx box. Valid only when `has_colour_info`.
278+
pub full_range_flag: bool,
267279
}
268280

269281
#[repr(C)]
@@ -1120,6 +1132,14 @@ fn mp4parse_get_track_video_info_safe(
11201132
};
11211133
}
11221134
}
1135+
if let Some(mp4parse::ColourInformation::Nclx(ref nclx)) = video.colour_info {
1136+
sample_info.has_colour_info = true;
1137+
sample_info.colour_primaries = nclx.colour_primaries;
1138+
sample_info.transfer_characteristics = nclx.transfer_characteristics;
1139+
sample_info.matrix_coefficients = nclx.matrix_coefficients;
1140+
sample_info.full_range_flag = nclx.full_range_flag;
1141+
}
1142+
11231143
video_sample_infos.push(sample_info)?;
11241144
}
11251145

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
use mp4parse_capi::*;
2+
use std::io::Read;
3+
4+
extern "C" fn buf_read(buf: *mut u8, size: usize, userdata: *mut std::os::raw::c_void) -> isize {
5+
let input: &mut std::fs::File = unsafe { &mut *(userdata as *mut _) };
6+
let buf = unsafe { std::slice::from_raw_parts_mut(buf, size) };
7+
match input.read(buf) {
8+
Ok(n) => n as isize,
9+
Err(_) => -1,
10+
}
11+
}
12+
13+
unsafe fn open_parser(path: &str) -> *mut Mp4parseParser {
14+
let mut file = std::fs::File::open(path).expect("file not found");
15+
let io = Mp4parseIo {
16+
read: Some(buf_read),
17+
userdata: &mut file as *mut _ as *mut std::os::raw::c_void,
18+
};
19+
let mut parser = std::ptr::null_mut();
20+
let rv = mp4parse_new(&io, &mut parser);
21+
assert_eq!(rv, Mp4parseStatus::Ok);
22+
assert!(!parser.is_null());
23+
parser
24+
}
25+
26+
/// HDR10 (PQ): colour_primaries=9, transfer=16, matrix=9, full_range=false
27+
#[test]
28+
fn video_colr_nclx_hdr10() {
29+
unsafe {
30+
let parser = open_parser("tests/video_colr_nclx_hdr10.mp4");
31+
32+
let mut video = Mp4parseTrackVideoInfo::default();
33+
let rv = mp4parse_get_track_video_info(parser, 0, &mut video);
34+
assert_eq!(rv, Mp4parseStatus::Ok);
35+
36+
assert_eq!(video.sample_info_count, 1);
37+
let sample = &*video.sample_info;
38+
assert!(sample.has_colour_info);
39+
assert_eq!(sample.colour_primaries, 9);
40+
assert_eq!(sample.transfer_characteristics, 16);
41+
assert_eq!(sample.matrix_coefficients, 9);
42+
assert!(!sample.full_range_flag);
43+
44+
mp4parse_free(parser);
45+
}
46+
}
47+
48+
/// HDR10 full-range: colour_primaries=9, transfer=16, matrix=9, full_range=true
49+
#[test]
50+
fn video_colr_nclx_hdr10_full_range() {
51+
unsafe {
52+
let parser = open_parser("tests/video_colr_nclx_hdr10_full_range.mp4");
53+
54+
let mut video = Mp4parseTrackVideoInfo::default();
55+
let rv = mp4parse_get_track_video_info(parser, 0, &mut video);
56+
assert_eq!(rv, Mp4parseStatus::Ok);
57+
58+
assert_eq!(video.sample_info_count, 1);
59+
let sample = &*video.sample_info;
60+
assert!(sample.has_colour_info);
61+
assert_eq!(sample.colour_primaries, 9);
62+
assert_eq!(sample.transfer_characteristics, 16);
63+
assert_eq!(sample.matrix_coefficients, 9);
64+
assert!(sample.full_range_flag);
65+
66+
mp4parse_free(parser);
67+
}
68+
}
69+
70+
/// HLG: colour_primaries=9, transfer=18, matrix=9, full_range=false
71+
#[test]
72+
fn video_colr_nclx_hlg() {
73+
unsafe {
74+
let parser = open_parser("tests/video_colr_nclx_hlg.mp4");
75+
76+
let mut video = Mp4parseTrackVideoInfo::default();
77+
let rv = mp4parse_get_track_video_info(parser, 0, &mut video);
78+
assert_eq!(rv, Mp4parseStatus::Ok);
79+
80+
assert_eq!(video.sample_info_count, 1);
81+
let sample = &*video.sample_info;
82+
assert!(sample.has_colour_info);
83+
assert_eq!(sample.colour_primaries, 9);
84+
assert_eq!(sample.transfer_characteristics, 18);
85+
assert_eq!(sample.matrix_coefficients, 9);
86+
assert!(!sample.full_range_flag);
87+
88+
mp4parse_free(parser);
89+
}
90+
}
91+
92+
/// HLG full-range: colour_primaries=9, transfer=18, matrix=9, full_range=true
93+
#[test]
94+
fn video_colr_nclx_hlg_full_range() {
95+
unsafe {
96+
let parser = open_parser("tests/video_colr_nclx_hlg_full_range.mp4");
97+
98+
let mut video = Mp4parseTrackVideoInfo::default();
99+
let rv = mp4parse_get_track_video_info(parser, 0, &mut video);
100+
assert_eq!(rv, Mp4parseStatus::Ok);
101+
102+
assert_eq!(video.sample_info_count, 1);
103+
let sample = &*video.sample_info;
104+
assert!(sample.has_colour_info);
105+
assert_eq!(sample.colour_primaries, 9);
106+
assert_eq!(sample.transfer_characteristics, 18);
107+
assert_eq!(sample.matrix_coefficients, 9);
108+
assert!(sample.full_range_flag);
109+
110+
mp4parse_free(parser);
111+
}
112+
}
113+
114+
/// RGB (Identity matrix, MC=0): has_colour_info=true but matrix_coefficients=0.
115+
/// Validates that has_colour_info correctly distinguishes "colr box present with MC=0"
116+
/// from "no colr box" (where matrix_coefficients is also 0).
117+
#[test]
118+
fn video_colr_nclx_rgb_identity_matrix() {
119+
unsafe {
120+
let parser = open_parser("tests/video_colr_nclx_rgb.mp4");
121+
122+
let mut video = Mp4parseTrackVideoInfo::default();
123+
let rv = mp4parse_get_track_video_info(parser, 0, &mut video);
124+
assert_eq!(rv, Mp4parseStatus::Ok);
125+
126+
assert_eq!(video.sample_info_count, 1);
127+
let sample = &*video.sample_info;
128+
assert!(sample.has_colour_info);
129+
assert_eq!(sample.colour_primaries, 1);
130+
assert_eq!(sample.transfer_characteristics, 1);
131+
assert_eq!(sample.matrix_coefficients, 0); // Identity/GBR — valid CICP value, not "absent"
132+
assert!(!sample.full_range_flag);
133+
134+
mp4parse_free(parser);
135+
}
136+
}
137+
138+
/// No colr box: has_colour_info=false, CICP fields are zero
139+
#[test]
140+
fn video_no_colr_box() {
141+
unsafe {
142+
let parser = open_parser("tests/white.mp4");
143+
144+
let mut video = Mp4parseTrackVideoInfo::default();
145+
let rv = mp4parse_get_track_video_info(parser, 0, &mut video);
146+
assert_eq!(rv, Mp4parseStatus::Ok);
147+
148+
assert_eq!(video.sample_info_count, 1);
149+
let sample = &*video.sample_info;
150+
assert!(!sample.has_colour_info);
151+
assert_eq!(sample.colour_primaries, 0);
152+
assert_eq!(sample.transfer_characteristics, 0);
153+
assert_eq!(sample.matrix_coefficients, 0);
154+
assert!(!sample.full_range_flag);
155+
156+
mp4parse_free(parser);
157+
}
158+
}
1.63 KB
Binary file not shown.
1.63 KB
Binary file not shown.
1.63 KB
Binary file not shown.
1.63 KB
Binary file not shown.
1.63 KB
Binary file not shown.

0 commit comments

Comments
 (0)