diff --git a/mp4parse/src/lib.rs b/mp4parse/src/lib.rs index 58bdcc36..7d4a292a 100644 --- a/mp4parse/src/lib.rs +++ b/mp4parse/src/lib.rs @@ -185,6 +185,7 @@ pub enum Status { BoxBadWideSize, CheckParserStateErr, ColrBadQuantity, + ColrBadQuantityBMFF, ColrBadSize, ColrBadType, ColrReservedNonzero, @@ -465,6 +466,10 @@ impl From for &str { ColourInformationBox (colr) for a given value of colour_type \ per HEIF (ISO/IEC DIS 23008-12) § 6.5.5.1" } + Status::ColrBadQuantityBMFF => { + "Each sample entry shall have at most one ColourInformationBox (colr) \ + per ISOBMFF (ISO 14496-12:2020) § 12.1.5" + } Status::ColrBadSize => { "Unexpected size for colr box" } @@ -1175,6 +1180,9 @@ pub struct VideoSampleEntry { pub codec_specific: VideoCodecSpecific, pub protection_info: TryVec, pub pixel_aspect_ratio: Option, + /// Only `ColourInformation::Nclx` is currently surfaced through the C API; + /// `ColourInformation::Icc` is stored but not exposed to C consumers. + pub colour_info: Option, } /// Represent a Video Partition Codec Configuration 'vpcC' box (aka vp9). The meaning of each @@ -3600,7 +3608,13 @@ fn read_ipco( let property = match b.head.name { BoxType::AuxiliaryTypeProperty => ItemProperty::AuxiliaryType(read_auxc(&mut b)?), BoxType::AV1CodecConfigurationBox => ItemProperty::AV1Config(read_av1c(&mut b)?), - BoxType::ColourInformationBox => ItemProperty::Colour(read_colr(&mut b, strictness)?), + BoxType::ColourInformationBox => match read_colr(&mut b, strictness)? { + ParsedColourInformation::Supported(colr) => ItemProperty::Colour(colr), + ParsedColourInformation::Unsupported(colour_type) => { + error!("read_colr colour_type: {colour_type:?}"); + return Status::ColrBadType.into(); + } + }, BoxType::ImageMirror => ItemProperty::Mirroring(read_imir(&mut b)?), BoxType::ImageRotation => ItemProperty::Rotation(read_irot(&mut b)?), BoxType::ImageSpatialExtentsProperty => { @@ -3722,10 +3736,10 @@ fn read_pixi(src: &mut BMFFBox) -> Result { #[repr(C)] #[derive(Debug)] pub struct NclxColourInformation { - colour_primaries: u8, - transfer_characteristics: u8, - matrix_coefficients: u8, - full_range_flag: bool, + pub colour_primaries: u8, + pub transfer_characteristics: u8, + pub matrix_coefficients: u8, + pub full_range_flag: bool, } /// The raw bytes of the ICC profile @@ -3758,12 +3772,17 @@ impl ColourInformation { } } +enum ParsedColourInformation { + Supported(ColourInformation), + Unsupported(FourCC), +} + /// Parse colour information /// See ISOBMFF (ISO 14496-12:2020) § 12.1.5 fn read_colr( src: &mut BMFFBox, strictness: ParseStrictness, -) -> Result { +) -> Result { let colour_type = be_u32(src)?.to_be_bytes(); match &colour_type { @@ -3790,22 +3809,26 @@ fn read_colr( )?; } - Ok(ColourInformation::Nclx(NclxColourInformation { - colour_primaries, - transfer_characteristics, - matrix_coefficients, - full_range_flag, - })) + Ok(ParsedColourInformation::Supported(ColourInformation::Nclx( + NclxColourInformation { + colour_primaries, + transfer_characteristics, + matrix_coefficients, + full_range_flag, + }, + ))) } - b"rICC" | b"prof" => Ok(ColourInformation::Icc( + b"rICC" | b"prof" => Ok(ParsedColourInformation::Supported(ColourInformation::Icc( IccColourInformation { bytes: src.read_into_try_vec()?, }, FourCC::from(colour_type), - )), + ))), _ => { - error!("read_colr colour_type: {colour_type:?}"); - Status::ColrBadType.into() + let four_cc = FourCC::from(colour_type); + warn!("read_colr: unsupported colour_type {four_cc:?}, skipping"); + skip_box_remain(src)?; + Ok(ParsedColourInformation::Unsupported(four_cc)) } } } @@ -5533,7 +5556,10 @@ fn read_hdlr(src: &mut BMFFBox, strictness: ParseStrictness) -> Resu } /// Parse an video description inside an stsd box. -fn read_video_sample_entry(src: &mut BMFFBox) -> Result { +fn read_video_sample_entry( + src: &mut BMFFBox, + strictness: ParseStrictness, +) -> Result { let name = src.get_header().name; let codec_type = match name { BoxType::AVCSampleEntry | BoxType::AVC3SampleEntry => CodecType::H264, @@ -5567,6 +5593,7 @@ fn read_video_sample_entry(src: &mut BMFFBox) -> Result // Skip clap/pasp/etc. for now. let mut codec_specific = None; let mut pixel_aspect_ratio = None; + let mut colour_info = None; let mut protection_info = TryVec::new(); let mut iter = src.box_iter(); while let Some(mut b) = iter.next_box()? { @@ -5683,6 +5710,22 @@ fn read_video_sample_entry(src: &mut BMFFBox) -> Result } debug!("Parsed pasp box: {pasp:?}, PAR {pixel_aspect_ratio:?}"); } + BoxType::ColourInformationBox => { + if colour_info.is_some() { + warn!("Multiple colr boxes in video sample entry, keeping first"); + fail_with_status_if( + strictness != ParseStrictness::Permissive, + Status::ColrBadQuantityBMFF, + )?; + skip_box_content(&mut b)?; + } else { + if let ParsedColourInformation::Supported(colr) = read_colr(&mut b, strictness)? + { + debug!("Parsed colr box: {colr:?}"); + colour_info = Some(colr); + } + } + } _ => { debug!("Unsupported video codec, box {:?} found", b.head.name); skip_box_content(&mut b)?; @@ -5701,6 +5744,7 @@ fn read_video_sample_entry(src: &mut BMFFBox) -> Result codec_specific, protection_info, pixel_aspect_ratio, + colour_info, }) }), ) @@ -5908,9 +5952,9 @@ fn read_stsd( while descriptions.len() < description_count { if let Some(mut b) = iter.next_box()? { let description = match track.track_type { - TrackType::Video => read_video_sample_entry(&mut b), - TrackType::Picture => read_video_sample_entry(&mut b), - TrackType::AuxiliaryVideo => read_video_sample_entry(&mut b), + TrackType::Video => read_video_sample_entry(&mut b, strictness), + TrackType::Picture => read_video_sample_entry(&mut b, strictness), + TrackType::AuxiliaryVideo => read_video_sample_entry(&mut b, strictness), TrackType::Audio => read_audio_sample_entry(&mut b, strictness), TrackType::Metadata => Err(Error::Unsupported("metadata track")), TrackType::Unknown => Err(Error::Unsupported("unknown track type")), diff --git a/mp4parse/src/tests.rs b/mp4parse/src/tests.rs index d7fd74c9..52edf661 100644 --- a/mp4parse/src/tests.rs +++ b/mp4parse/src/tests.rs @@ -1145,7 +1145,8 @@ fn read_stsd_mp4v() { let mut iter = super::BoxIter::new(&mut stream); let mut stream = iter.next_box().unwrap().unwrap(); - let sample_entry = super::read_video_sample_entry(&mut stream).unwrap(); + let sample_entry = + super::read_video_sample_entry(&mut stream, super::ParseStrictness::Normal).unwrap(); match sample_entry { super::SampleEntry::Video(v) => { @@ -1245,7 +1246,7 @@ fn unknown_video_sample_entry() { }); let mut iter = super::BoxIter::new(&mut stream); let mut stream = iter.next_box().unwrap().unwrap(); - match super::read_video_sample_entry(&mut stream) { + match super::read_video_sample_entry(&mut stream, super::ParseStrictness::Normal) { Ok(super::SampleEntry::Unknown) => (), _ => panic!("expected a different error result"), } diff --git a/mp4parse_capi/src/lib.rs b/mp4parse_capi/src/lib.rs index dbdfafaf..1b51d0cf 100644 --- a/mp4parse_capi/src/lib.rs +++ b/mp4parse_capi/src/lib.rs @@ -264,6 +264,18 @@ pub struct Mp4parseTrackVideoSampleInfo { pub image_height: u16, pub extra_data: Mp4parseByteData, pub protected_data: Mp4parseSinfInfo, + /// True when a `colr` box with `colour_type = 'nclx'` was present. When false, + /// the CICP fields below are all zero and must not be interpreted. + pub has_colour_info: bool, + /// CICP colour primaries (ISO 23091-2 § 8.1). Valid only when `has_colour_info`. + pub colour_primaries: u8, + /// CICP transfer characteristics (ISO 23091-2 § 8.2). Valid only when `has_colour_info`. + pub transfer_characteristics: u8, + /// CICP matrix coefficients (ISO 23091-2 § 8.3). Valid only when `has_colour_info`. + /// Note: value 0 is a valid CICP value (Identity/GBR), not an absence indicator. + pub matrix_coefficients: u8, + /// Full range flag from the colr nclx box. Valid only when `has_colour_info`. + pub full_range_flag: bool, } #[repr(C)] @@ -1120,6 +1132,14 @@ fn mp4parse_get_track_video_info_safe( }; } } + if let Some(mp4parse::ColourInformation::Nclx(ref nclx)) = video.colour_info { + sample_info.has_colour_info = true; + sample_info.colour_primaries = nclx.colour_primaries; + sample_info.transfer_characteristics = nclx.transfer_characteristics; + sample_info.matrix_coefficients = nclx.matrix_coefficients; + sample_info.full_range_flag = nclx.full_range_flag; + } + video_sample_infos.push(sample_info)?; } diff --git a/mp4parse_capi/tests/test_colour_info.rs b/mp4parse_capi/tests/test_colour_info.rs new file mode 100644 index 00000000..3d0312d1 --- /dev/null +++ b/mp4parse_capi/tests/test_colour_info.rs @@ -0,0 +1,158 @@ +use mp4parse_capi::*; +use std::io::Read; + +extern "C" fn buf_read(buf: *mut u8, size: usize, userdata: *mut std::os::raw::c_void) -> isize { + let input: &mut std::fs::File = unsafe { &mut *(userdata as *mut _) }; + let buf = unsafe { std::slice::from_raw_parts_mut(buf, size) }; + match input.read(buf) { + Ok(n) => n as isize, + Err(_) => -1, + } +} + +unsafe fn open_parser(path: &str) -> *mut Mp4parseParser { + let mut file = std::fs::File::open(path).expect("file not found"); + let io = Mp4parseIo { + read: Some(buf_read), + userdata: &mut file as *mut _ as *mut std::os::raw::c_void, + }; + let mut parser = std::ptr::null_mut(); + let rv = mp4parse_new(&io, &mut parser); + assert_eq!(rv, Mp4parseStatus::Ok); + assert!(!parser.is_null()); + parser +} + +/// HDR10 (PQ): colour_primaries=9, transfer=16, matrix=9, full_range=false +#[test] +fn video_colr_nclx_hdr10() { + unsafe { + let parser = open_parser("tests/video_colr_nclx_hdr10.mp4"); + + let mut video = Mp4parseTrackVideoInfo::default(); + let rv = mp4parse_get_track_video_info(parser, 0, &mut video); + assert_eq!(rv, Mp4parseStatus::Ok); + + assert_eq!(video.sample_info_count, 1); + let sample = &*video.sample_info; + assert!(sample.has_colour_info); + assert_eq!(sample.colour_primaries, 9); + assert_eq!(sample.transfer_characteristics, 16); + assert_eq!(sample.matrix_coefficients, 9); + assert!(!sample.full_range_flag); + + mp4parse_free(parser); + } +} + +/// HDR10 full-range: colour_primaries=9, transfer=16, matrix=9, full_range=true +#[test] +fn video_colr_nclx_hdr10_full_range() { + unsafe { + let parser = open_parser("tests/video_colr_nclx_hdr10_full_range.mp4"); + + let mut video = Mp4parseTrackVideoInfo::default(); + let rv = mp4parse_get_track_video_info(parser, 0, &mut video); + assert_eq!(rv, Mp4parseStatus::Ok); + + assert_eq!(video.sample_info_count, 1); + let sample = &*video.sample_info; + assert!(sample.has_colour_info); + assert_eq!(sample.colour_primaries, 9); + assert_eq!(sample.transfer_characteristics, 16); + assert_eq!(sample.matrix_coefficients, 9); + assert!(sample.full_range_flag); + + mp4parse_free(parser); + } +} + +/// HLG: colour_primaries=9, transfer=18, matrix=9, full_range=false +#[test] +fn video_colr_nclx_hlg() { + unsafe { + let parser = open_parser("tests/video_colr_nclx_hlg.mp4"); + + let mut video = Mp4parseTrackVideoInfo::default(); + let rv = mp4parse_get_track_video_info(parser, 0, &mut video); + assert_eq!(rv, Mp4parseStatus::Ok); + + assert_eq!(video.sample_info_count, 1); + let sample = &*video.sample_info; + assert!(sample.has_colour_info); + assert_eq!(sample.colour_primaries, 9); + assert_eq!(sample.transfer_characteristics, 18); + assert_eq!(sample.matrix_coefficients, 9); + assert!(!sample.full_range_flag); + + mp4parse_free(parser); + } +} + +/// HLG full-range: colour_primaries=9, transfer=18, matrix=9, full_range=true +#[test] +fn video_colr_nclx_hlg_full_range() { + unsafe { + let parser = open_parser("tests/video_colr_nclx_hlg_full_range.mp4"); + + let mut video = Mp4parseTrackVideoInfo::default(); + let rv = mp4parse_get_track_video_info(parser, 0, &mut video); + assert_eq!(rv, Mp4parseStatus::Ok); + + assert_eq!(video.sample_info_count, 1); + let sample = &*video.sample_info; + assert!(sample.has_colour_info); + assert_eq!(sample.colour_primaries, 9); + assert_eq!(sample.transfer_characteristics, 18); + assert_eq!(sample.matrix_coefficients, 9); + assert!(sample.full_range_flag); + + mp4parse_free(parser); + } +} + +/// RGB (Identity matrix, MC=0): has_colour_info=true but matrix_coefficients=0. +/// Validates that has_colour_info correctly distinguishes "colr box present with MC=0" +/// from "no colr box" (where matrix_coefficients is also 0). +#[test] +fn video_colr_nclx_rgb_identity_matrix() { + unsafe { + let parser = open_parser("tests/video_colr_nclx_rgb.mp4"); + + let mut video = Mp4parseTrackVideoInfo::default(); + let rv = mp4parse_get_track_video_info(parser, 0, &mut video); + assert_eq!(rv, Mp4parseStatus::Ok); + + assert_eq!(video.sample_info_count, 1); + let sample = &*video.sample_info; + assert!(sample.has_colour_info); + assert_eq!(sample.colour_primaries, 1); + assert_eq!(sample.transfer_characteristics, 1); + assert_eq!(sample.matrix_coefficients, 0); // Identity/GBR — valid CICP value, not "absent" + assert!(!sample.full_range_flag); + + mp4parse_free(parser); + } +} + +/// No colr box: has_colour_info=false, CICP fields are zero +#[test] +fn video_no_colr_box() { + unsafe { + let parser = open_parser("tests/white.mp4"); + + let mut video = Mp4parseTrackVideoInfo::default(); + let rv = mp4parse_get_track_video_info(parser, 0, &mut video); + assert_eq!(rv, Mp4parseStatus::Ok); + + assert_eq!(video.sample_info_count, 1); + let sample = &*video.sample_info; + assert!(!sample.has_colour_info); + assert_eq!(sample.colour_primaries, 0); + assert_eq!(sample.transfer_characteristics, 0); + assert_eq!(sample.matrix_coefficients, 0); + assert!(!sample.full_range_flag); + + mp4parse_free(parser); + } +} diff --git a/mp4parse_capi/tests/video_colr_nclx_hdr10.mp4 b/mp4parse_capi/tests/video_colr_nclx_hdr10.mp4 new file mode 100644 index 00000000..70cb5a3a Binary files /dev/null and b/mp4parse_capi/tests/video_colr_nclx_hdr10.mp4 differ diff --git a/mp4parse_capi/tests/video_colr_nclx_hdr10_full_range.mp4 b/mp4parse_capi/tests/video_colr_nclx_hdr10_full_range.mp4 new file mode 100644 index 00000000..9964a512 Binary files /dev/null and b/mp4parse_capi/tests/video_colr_nclx_hdr10_full_range.mp4 differ diff --git a/mp4parse_capi/tests/video_colr_nclx_hlg.mp4 b/mp4parse_capi/tests/video_colr_nclx_hlg.mp4 new file mode 100644 index 00000000..9bed3fc6 Binary files /dev/null and b/mp4parse_capi/tests/video_colr_nclx_hlg.mp4 differ diff --git a/mp4parse_capi/tests/video_colr_nclx_hlg_full_range.mp4 b/mp4parse_capi/tests/video_colr_nclx_hlg_full_range.mp4 new file mode 100644 index 00000000..52696a05 Binary files /dev/null and b/mp4parse_capi/tests/video_colr_nclx_hlg_full_range.mp4 differ diff --git a/mp4parse_capi/tests/video_colr_nclx_rgb.mp4 b/mp4parse_capi/tests/video_colr_nclx_rgb.mp4 new file mode 100644 index 00000000..00ea4124 Binary files /dev/null and b/mp4parse_capi/tests/video_colr_nclx_rgb.mp4 differ