feat(mux): emit HDR10 static metadata from HEVC SEI
Parse the two HDR10 HEVC SEI messages and emit the corresponding Matroska Colour metadata, only when actually present in the bitstream (SDR / no-SEI tracks omit it; nothing is fabricated). Parse (Rec. ITU-T H.265 Annex D): - Mastering Display Colour Volume SEI, payloadType 137 (D.2.28): display_primaries_x/y[3] (SEI order G,B,R), white_point_x/y (0.00002 units), max/min_display_mastering_luminance (0.0001 cd/m²). - Content Light Level Info SEI, payloadType 144 (D.2.35): MaxCLL / MaxFALL (cd/m² integers). HevcParser::scan_sei walks the sei_rbsp ff-extension payloadType/ payloadSize coding and de-emulates (00 00 03) before reading, reusing the existing strip_emulation_prevention helper. Both SEI are required before any metadata is surfaced; SEI NALs still pass through unchanged. Carry: the measured Hdr10Metadata rides PictureInfo (the same per-coded- picture seam FieldOrder uses), flowing through from_codec_frame onto PesFrame.coding to the deferred-muxer activate path, where apply_coding_to_track stamps it on the video track before the header is written. Set only when both SEI were seen. Emit (RFC 9559 / Matroska): new Colour children in ebml.rs (MasteringMetadata 0x55D0, Primary R/G/B + WhitePoint chromaticity 0x55D1..0x55D8, Luminance max/min 0x55D9/0x55DA, MaxCLL 0x55BC, MaxFALL 0x55BD). write_hdr10 converts chromaticity SEI int × 0.00002 → Matroska float, luminance SEI int × 0.0001 → cd/m² float; MaxCLL/MaxFALL are uints verbatim. SEI primary index 0/1/2 (G/B/R) mapped to the Matroska R/G/B element layout. Emitted only when hdr10 is present. Tests: SEI parse with exact raw values, requires-both-SEI, SDR omission, and emulation-prevention stripping (hevc.rs); muxer emit with exact unit scaling + SDR omission of MasteringMetadata/MaxCLL/MaxFALL (mkv.rs); apply_coding_to_track HDR10 plumbing (mkvstream.rs).
This commit is contained in:
@@ -78,6 +78,39 @@ pub enum CodingDetail {
|
|||||||
CodingTypeOnly,
|
CodingTypeOnly,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// HDR10 static metadata measured from a video bitstream (HEVC SEI). Carried on
|
||||||
|
/// [`PictureInfo`] as the per-stream colour-volume signalling: it only ever
|
||||||
|
/// reaches the muxer when BOTH SEI messages were actually present in the stream,
|
||||||
|
/// so an SDR / no-SEI track leaves it `None` and the muxer omits the elements
|
||||||
|
/// (never fabricated).
|
||||||
|
///
|
||||||
|
/// All values are stored in their RAW SEI integer units (NOT yet scaled to the
|
||||||
|
/// Matroska float domain); the muxer applies the H.265 → Matroska unit
|
||||||
|
/// conversion at emit time so the scaling lives in exactly one place.
|
||||||
|
///
|
||||||
|
/// Spec: Rec. ITU-T H.265 D.2.28 (Mastering Display Colour Volume,
|
||||||
|
/// payloadType 137) and D.2.35 (Content Light Level Info, payloadType 144).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Hdr10Metadata {
|
||||||
|
/// `display_primaries_x[c]` / `display_primaries_y[c]` for c = 0,1,2.
|
||||||
|
/// Per H.265 D.3.28 the SEI order is c=0 → Green, c=1 → Blue, c=2 → Red.
|
||||||
|
/// Stored here in that SAME SEI order; the muxer maps to Matroska's R/G/B
|
||||||
|
/// element layout. Units of 0.00002 (chromaticity).
|
||||||
|
pub display_primaries_x: [u16; 3],
|
||||||
|
pub display_primaries_y: [u16; 3],
|
||||||
|
/// `white_point_x` / `white_point_y` in units of 0.00002 (chromaticity).
|
||||||
|
pub white_point_x: u16,
|
||||||
|
pub white_point_y: u16,
|
||||||
|
/// `max_display_mastering_luminance` in units of 0.0001 cd/m².
|
||||||
|
pub max_display_mastering_luminance: u32,
|
||||||
|
/// `min_display_mastering_luminance` in units of 0.0001 cd/m².
|
||||||
|
pub min_display_mastering_luminance: u32,
|
||||||
|
/// `max_content_light_level` (MaxCLL) in cd/m² — already an integer.
|
||||||
|
pub max_content_light_level: u16,
|
||||||
|
/// `max_pic_average_light_level` (MaxFALL) in cd/m² — already an integer.
|
||||||
|
pub max_pic_average_light_level: u16,
|
||||||
|
}
|
||||||
|
|
||||||
/// Codec-agnostic per-picture coding carrier — the single per-frame record the
|
/// Codec-agnostic per-picture coding carrier — the single per-frame record the
|
||||||
/// muxer reads through the accessors below. Raw codec signals live in
|
/// muxer reads through the accessors below. Raw codec signals live in
|
||||||
/// [`CodingDetail`]; consumers MUST use the accessors, never the inner fields.
|
/// [`CodingDetail`]; consumers MUST use the accessors, never the inner fields.
|
||||||
@@ -89,6 +122,11 @@ pub struct PictureInfo {
|
|||||||
/// Raw per-codec coding detail. Holds the bits the field/pulldown
|
/// Raw per-codec coding detail. Holds the bits the field/pulldown
|
||||||
/// accessors derive from.
|
/// accessors derive from.
|
||||||
detail: CodingDetail,
|
detail: CodingDetail,
|
||||||
|
/// HDR10 static metadata measured from the bitstream (HEVC SEI), or `None`
|
||||||
|
/// when the stream carried no HDR10 SEI (SDR / not signalled). Per-stream,
|
||||||
|
/// but rides the per-picture carrier so it flows the same deferred-muxer
|
||||||
|
/// path the measured field order does. Never fabricated.
|
||||||
|
hdr10: Option<Hdr10Metadata>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PictureInfo {
|
impl PictureInfo {
|
||||||
@@ -98,6 +136,7 @@ impl PictureInfo {
|
|||||||
Self {
|
Self {
|
||||||
coding_type,
|
coding_type,
|
||||||
detail: CodingDetail::Mpeg2(m),
|
detail: CodingDetail::Mpeg2(m),
|
||||||
|
hdr10: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,9 +146,27 @@ impl PictureInfo {
|
|||||||
Self {
|
Self {
|
||||||
coding_type,
|
coding_type,
|
||||||
detail: CodingDetail::CodingTypeOnly,
|
detail: CodingDetail::CodingTypeOnly,
|
||||||
|
hdr10: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach measured HDR10 static metadata (HEVC SEI) to this picture,
|
||||||
|
/// consuming and returning `self` for builder-style use. Only ever called
|
||||||
|
/// with `Some(..)` once both HDR10 SEI messages have been seen, so an SDR
|
||||||
|
/// track never carries fabricated colour-volume data.
|
||||||
|
pub fn with_hdr10(mut self, hdr10: Option<Hdr10Metadata>) -> Self {
|
||||||
|
self.hdr10 = hdr10;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Measured HDR10 static metadata for this picture's stream, or `None` when
|
||||||
|
/// the bitstream signalled no HDR10 SEI. Read at mux time to emit the
|
||||||
|
/// Matroska MasteringMetadata / MaxCLL / MaxFALL — omitted entirely when
|
||||||
|
/// `None`.
|
||||||
|
pub fn hdr10(&self) -> Option<Hdr10Metadata> {
|
||||||
|
self.hdr10
|
||||||
|
}
|
||||||
|
|
||||||
/// Agnostic coding type (I/P/B). The single signal for cue/keyframe marking
|
/// Agnostic coding type (I/P/B). The single signal for cue/keyframe marking
|
||||||
/// and B-frame display ordering.
|
/// and B-frame display ordering.
|
||||||
pub fn coding_type(&self) -> CodingType {
|
pub fn coding_type(&self) -> CodingType {
|
||||||
|
|||||||
+471
-3
@@ -13,6 +13,21 @@ const NAL_VPS: u8 = 32;
|
|||||||
const NAL_SPS: u8 = 33;
|
const NAL_SPS: u8 = 33;
|
||||||
const NAL_PPS: u8 = 34;
|
const NAL_PPS: u8 = 34;
|
||||||
const NAL_AUD: u8 = 35;
|
const NAL_AUD: u8 = 35;
|
||||||
|
// Supplemental Enhancement Information (Rec. ITU-T H.265 Table 7-1): a prefix
|
||||||
|
// SEI (type 39) precedes the coded picture it applies to, a suffix SEI (40)
|
||||||
|
// follows it. HDR10 static metadata (mastering display / content light level)
|
||||||
|
// is carried in PREFIX SEI on UHD streams; both are scanned for the two HDR10
|
||||||
|
// payload types below. SEI NALs still pass through to the frame data unchanged
|
||||||
|
// (the `_ =>` arm); scanning them is observation-only.
|
||||||
|
const NAL_SEI_PREFIX: u8 = 39;
|
||||||
|
const NAL_SEI_SUFFIX: u8 = 40;
|
||||||
|
|
||||||
|
// HEVC SEI payload types (Rec. ITU-T H.265 Annex D.2) carrying HDR10 static
|
||||||
|
// metadata.
|
||||||
|
// - Mastering Display Colour Volume (D.2.28): payloadType 137.
|
||||||
|
// - Content Light Level Information (D.2.35): payloadType 144.
|
||||||
|
const SEI_MASTERING_DISPLAY_COLOUR_VOLUME: u32 = 137;
|
||||||
|
const SEI_CONTENT_LIGHT_LEVEL_INFO: u32 = 144;
|
||||||
// Dolby Vision RPU (Reference Processing Unit) — NAL type 62 (UNSPEC62).
|
// Dolby Vision RPU (Reference Processing Unit) — NAL type 62 (UNSPEC62).
|
||||||
// This is NOT filtered: all NAL types except VPS/SPS/PPS/AUD pass through
|
// This is NOT filtered: all NAL types except VPS/SPS/PPS/AUD pass through
|
||||||
// to frame data, so DV enhancement layer RPU NALs are preserved automatically.
|
// to frame data, so DV enhancement layer RPU NALs are preserved automatically.
|
||||||
@@ -145,6 +160,36 @@ pub struct HevcParser {
|
|||||||
// reset and false-arms the CRA→BLA rewrite (corrupting a legitimate in-clip
|
// reset and false-arms the CRA→BLA rewrite (corrupting a legitimate in-clip
|
||||||
// CRA and dropping valid RASL pictures). Each detected wrap adds 2^33 here.
|
// CRA and dropping valid RASL pictures). Each detected wrap adds 2^33 here.
|
||||||
pts_wrap_offset: i64,
|
pts_wrap_offset: i64,
|
||||||
|
// HDR10 static metadata accumulated from prefix/suffix SEI. The Mastering
|
||||||
|
// Display Colour Volume (payloadType 137) and Content Light Level Info
|
||||||
|
// (payloadType 144) messages arrive in (possibly) separate SEI NALs; each is
|
||||||
|
// captured independently and STICKY (first seen wins — they are per-stream
|
||||||
|
// constants). `hdr10()` combines them into a complete `Hdr10Metadata` only
|
||||||
|
// when BOTH are present. An SDR / no-SEI stream leaves both `None` so no
|
||||||
|
// colour-volume metadata is ever fabricated.
|
||||||
|
sei_mastering: Option<MasteringDisplay>,
|
||||||
|
sei_content_light: Option<ContentLightLevel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mastering Display Colour Volume payload (Rec. ITU-T H.265 D.2.28),
|
||||||
|
/// payloadType 137. Raw SEI integer values — chromaticity in 0.00002 units,
|
||||||
|
/// luminance in 0.0001 cd/m² units. SEI primary order is c=0 G, c=1 B, c=2 R.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
struct MasteringDisplay {
|
||||||
|
display_primaries_x: [u16; 3],
|
||||||
|
display_primaries_y: [u16; 3],
|
||||||
|
white_point_x: u16,
|
||||||
|
white_point_y: u16,
|
||||||
|
max_display_mastering_luminance: u32,
|
||||||
|
min_display_mastering_luminance: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content Light Level Information payload (Rec. ITU-T H.265 D.2.35),
|
||||||
|
/// payloadType 144. Both values are cd/m² integers.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
struct ContentLightLevel {
|
||||||
|
max_content_light_level: u16,
|
||||||
|
max_pic_average_light_level: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
// A backward PES-PTS step larger than this (90 kHz ticks) marks a non-seamless
|
// A backward PES-PTS step larger than this (90 kHz ticks) marks a non-seamless
|
||||||
@@ -190,6 +235,82 @@ impl HevcParser {
|
|||||||
pending_clip_boundary: false,
|
pending_clip_boundary: false,
|
||||||
high_pts: None,
|
high_pts: None,
|
||||||
pts_wrap_offset: 0,
|
pts_wrap_offset: 0,
|
||||||
|
sei_mastering: None,
|
||||||
|
sei_content_light: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combine the accumulated mastering-display and content-light SEI into a
|
||||||
|
/// complete [`Hdr10Metadata`], or `None` until BOTH HDR10 SEI messages have
|
||||||
|
/// been seen. Requiring both means an SDR / partially-signalled stream never
|
||||||
|
/// emits a half-populated (confidently-wrong) HDR10 record.
|
||||||
|
fn hdr10(&self) -> Option<crate::mux::codec::Hdr10Metadata> {
|
||||||
|
let m = self.sei_mastering?;
|
||||||
|
let c = self.sei_content_light?;
|
||||||
|
Some(crate::mux::codec::Hdr10Metadata {
|
||||||
|
display_primaries_x: m.display_primaries_x,
|
||||||
|
display_primaries_y: m.display_primaries_y,
|
||||||
|
white_point_x: m.white_point_x,
|
||||||
|
white_point_y: m.white_point_y,
|
||||||
|
max_display_mastering_luminance: m.max_display_mastering_luminance,
|
||||||
|
min_display_mastering_luminance: m.min_display_mastering_luminance,
|
||||||
|
max_content_light_level: c.max_content_light_level,
|
||||||
|
max_pic_average_light_level: c.max_pic_average_light_level,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan an SEI NAL (`[2-byte NAL header][RBSP]`) for the two HDR10 payload
|
||||||
|
/// types and capture each the FIRST time it appears (per-stream constants).
|
||||||
|
///
|
||||||
|
/// RBSP structure (Rec. ITU-T H.265 D.2 `sei_rbsp` / `sei_message`): a
|
||||||
|
/// sequence of messages, each `payloadType` then `payloadSize` encoded as a
|
||||||
|
/// run of 0xFF bytes plus a final <0xFF byte (the "ff-extension" coding),
|
||||||
|
/// followed by `payloadSize` payload bytes. Emulation-prevention (00 00 03)
|
||||||
|
/// is stripped before reading — unlike a slice header, an SEI payload can be
|
||||||
|
/// deep enough that an emulation byte falls inside the fields we read.
|
||||||
|
/// Unknown payload types are skipped by their size so a later HDR10 message
|
||||||
|
/// in the same NAL is still reached.
|
||||||
|
fn scan_sei(&mut self, nal: &[u8]) {
|
||||||
|
let Some(raw) = nal.get(2..) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let rbsp = strip_emulation_prevention(raw);
|
||||||
|
let mut i = 0usize;
|
||||||
|
loop {
|
||||||
|
// payloadType: sum of 0xFF run + final byte.
|
||||||
|
let Some(payload_type) = read_sei_ff_value(&rbsp, &mut i) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
// payloadSize: same ff-extension coding.
|
||||||
|
let Some(payload_size) = read_sei_ff_value(&rbsp, &mut i) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let payload_size = payload_size as usize;
|
||||||
|
let Some(payload) = rbsp.get(i..i.saturating_add(payload_size)) else {
|
||||||
|
break; // truncated / malformed payload length — stop scanning
|
||||||
|
};
|
||||||
|
match payload_type {
|
||||||
|
SEI_MASTERING_DISPLAY_COLOUR_VOLUME if self.sei_mastering.is_none() => {
|
||||||
|
if let Some(m) = parse_mastering_display(payload) {
|
||||||
|
self.sei_mastering = Some(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SEI_CONTENT_LIGHT_LEVEL_INFO if self.sei_content_light.is_none() => {
|
||||||
|
if let Some(c) = parse_content_light_level(payload) {
|
||||||
|
self.sei_content_light = Some(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i += payload_size;
|
||||||
|
// An RBSP trailing byte (0x80) or padding zeros after the last
|
||||||
|
// message is not another payloadType; stop when nothing meaningful
|
||||||
|
// remains. `read_sei_ff_value` returning None on the next pass
|
||||||
|
// handles end-of-buffer; a lone 0x80 trailing bits byte is consumed
|
||||||
|
// as a (bogus) payloadType of 128 then fails the size read → break.
|
||||||
|
if i >= rbsp.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,8 +604,15 @@ impl CodecParser for HevcParser {
|
|||||||
push_length_prefixed(&mut frame_data, &data[nal_start..end]);
|
push_length_prefixed(&mut frame_data, &data[nal_start..end]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
NAL_SEI_PREFIX | NAL_SEI_SUFFIX => {
|
||||||
|
// Observe HDR10 static metadata (mastering display /
|
||||||
|
// content light level) but pass the SEI through
|
||||||
|
// unchanged — scanning is non-destructive.
|
||||||
|
self.scan_sei(&data[nal_start..end]);
|
||||||
|
push_length_prefixed(&mut frame_data, &data[nal_start..end]);
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// All other NAL types (slices, SEI, DV RPU, etc.) pass through
|
// All other NAL types (slices, DV RPU, etc.) pass through
|
||||||
push_length_prefixed(&mut frame_data, &data[nal_start..end]);
|
push_length_prefixed(&mut frame_data, &data[nal_start..end]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -520,11 +648,19 @@ impl CodecParser for HevcParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HDR10 static metadata is per-stream; once both SEI messages have been
|
||||||
|
// seen it is stamped onto every frame's PictureInfo so it rides the same
|
||||||
|
// deferred-muxer path the measured field order uses (the muxer reads it
|
||||||
|
// from the first coded picture before writing the track header). `None`
|
||||||
|
// until both SEI present → SDR / no-SEI tracks carry nothing.
|
||||||
|
let hdr10 = self.hdr10();
|
||||||
vec![Frame {
|
vec![Frame {
|
||||||
// Coding-type only: HEVC field order (pic_struct, from a pic_timing
|
// Coding-type only: HEVC field order (pic_struct, from a pic_timing
|
||||||
// SEI) is not decoded here, so field_order() stays None — honestly
|
// SEI) is not decoded here, so field_order() stays None — honestly
|
||||||
// absent, never guessed.
|
// absent, never guessed. HDR10 metadata is attached when measured.
|
||||||
coding: coding_type.map(PictureInfo::coding_type_only),
|
coding: coding_type
|
||||||
|
.map(PictureInfo::coding_type_only)
|
||||||
|
.map(|p| p.with_hdr10(hdr10)),
|
||||||
source: pes.source,
|
source: pes.source,
|
||||||
pts_ns,
|
pts_ns,
|
||||||
keyframe,
|
keyframe,
|
||||||
@@ -681,6 +817,62 @@ fn strip_emulation_prevention(rbsp: &[u8]) -> Vec<u8> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read an SEI `payloadType` / `payloadSize` value using the H.265 D.2
|
||||||
|
/// ff-extension coding: consume a run of `0xFF` bytes (each adding 255) plus one
|
||||||
|
/// final byte `< 0xFF`. Advances `*i` past the bytes read. Returns `None` at
|
||||||
|
/// end-of-buffer (the value is incomplete / no further message).
|
||||||
|
fn read_sei_ff_value(rbsp: &[u8], i: &mut usize) -> Option<u32> {
|
||||||
|
let mut value: u32 = 0;
|
||||||
|
loop {
|
||||||
|
let b = *rbsp.get(*i)?;
|
||||||
|
*i += 1;
|
||||||
|
value = value.checked_add(b as u32)?;
|
||||||
|
if b != 0xFF {
|
||||||
|
return Some(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a Mastering Display Colour Volume SEI payload (Rec. ITU-T H.265
|
||||||
|
/// D.2.28 / semantics D.3.28). Layout — 24 bytes total, all big-endian:
|
||||||
|
/// display_primaries_x[c] u(16), display_primaries_y[c] u(16) for c=0,1,2
|
||||||
|
/// (SEI primary order is c=0 Green, c=1 Blue, c=2 Red)
|
||||||
|
/// white_point_x u(16), white_point_y u(16)
|
||||||
|
/// max_display_mastering_luminance u(32)
|
||||||
|
/// min_display_mastering_luminance u(32)
|
||||||
|
/// Returns `None` if the payload is shorter than 24 bytes (malformed → ignored,
|
||||||
|
/// never partially populated).
|
||||||
|
fn parse_mastering_display(p: &[u8]) -> Option<MasteringDisplay> {
|
||||||
|
if p.len() < 24 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let u16_at = |off: usize| u16::from_be_bytes([p[off], p[off + 1]]);
|
||||||
|
let u32_at = |off: usize| u32::from_be_bytes([p[off], p[off + 1], p[off + 2], p[off + 3]]);
|
||||||
|
Some(MasteringDisplay {
|
||||||
|
display_primaries_x: [u16_at(0), u16_at(4), u16_at(8)],
|
||||||
|
display_primaries_y: [u16_at(2), u16_at(6), u16_at(10)],
|
||||||
|
white_point_x: u16_at(12),
|
||||||
|
white_point_y: u16_at(14),
|
||||||
|
max_display_mastering_luminance: u32_at(16),
|
||||||
|
min_display_mastering_luminance: u32_at(20),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a Content Light Level Information SEI payload (Rec. ITU-T H.265
|
||||||
|
/// D.2.35 / semantics D.3.35). Layout — 4 bytes, big-endian:
|
||||||
|
/// max_content_light_level u(16) (MaxCLL, cd/m²)
|
||||||
|
/// max_pic_average_light_level u(16) (MaxFALL, cd/m²)
|
||||||
|
/// Returns `None` if shorter than 4 bytes.
|
||||||
|
fn parse_content_light_level(p: &[u8]) -> Option<ContentLightLevel> {
|
||||||
|
if p.len() < 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(ContentLightLevel {
|
||||||
|
max_content_light_level: u16::from_be_bytes([p[0], p[1]]),
|
||||||
|
max_pic_average_light_level: u16::from_be_bytes([p[2], p[3]]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse chroma_format_idc and bit depths from a stored SPS NAL
|
/// Parse chroma_format_idc and bit depths from a stored SPS NAL
|
||||||
/// (`[2-byte NAL header][RBSP...]`). Handles emulation-prevention and
|
/// (`[2-byte NAL header][RBSP...]`). Handles emulation-prevention and
|
||||||
/// sub-layer profile_tier_level. Returns `None` if the bitstream is too short
|
/// sub-layer profile_tier_level. Returns `None` if the bitstream is too short
|
||||||
@@ -793,6 +985,282 @@ mod tests {
|
|||||||
[(nal_type & 0x3F) << 1, 0x01] // tid=1
|
[(nal_type & 0x3F) << 1, 0x01] // tid=1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encode an SEI message body: payloadType + payloadSize (ff-extension) +
|
||||||
|
/// payload bytes. Values < 255 take a single byte each (the common case).
|
||||||
|
fn sei_message(payload_type: u32, payload: &[u8]) -> Vec<u8> {
|
||||||
|
fn ff_encode(mut v: u32) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while v >= 255 {
|
||||||
|
out.push(0xFF);
|
||||||
|
v -= 255;
|
||||||
|
}
|
||||||
|
out.push(v as u8);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
let mut m = ff_encode(payload_type);
|
||||||
|
m.extend(ff_encode(payload.len() as u32));
|
||||||
|
m.extend_from_slice(payload);
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a 24-byte Mastering Display Colour Volume payload (D.2.28) from raw
|
||||||
|
/// SEI integers. SEI primary order is G(0), B(1), R(2).
|
||||||
|
fn mastering_payload(
|
||||||
|
prim_x: [u16; 3],
|
||||||
|
prim_y: [u16; 3],
|
||||||
|
wp_x: u16,
|
||||||
|
wp_y: u16,
|
||||||
|
max_lum: u32,
|
||||||
|
min_lum: u32,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut p = Vec::new();
|
||||||
|
for c in 0..3 {
|
||||||
|
p.extend_from_slice(&prim_x[c].to_be_bytes());
|
||||||
|
p.extend_from_slice(&prim_y[c].to_be_bytes());
|
||||||
|
}
|
||||||
|
p.extend_from_slice(&wp_x.to_be_bytes());
|
||||||
|
p.extend_from_slice(&wp_y.to_be_bytes());
|
||||||
|
p.extend_from_slice(&max_lum.to_be_bytes());
|
||||||
|
p.extend_from_slice(&min_lum.to_be_bytes());
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a 4-byte Content Light Level Info payload (D.2.35).
|
||||||
|
fn cll_payload(maxcll: u16, maxfall: u16) -> Vec<u8> {
|
||||||
|
let mut p = Vec::new();
|
||||||
|
p.extend_from_slice(&maxcll.to_be_bytes());
|
||||||
|
p.extend_from_slice(&maxfall.to_be_bytes());
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert HEVC emulation-prevention bytes: any `00 00` followed by a byte
|
||||||
|
/// ≤ 0x03 gets a `0x03` inserted (Rec. ITU-T H.265 §7.4.2). A real bitstream
|
||||||
|
/// is always EP-coded; the parser strips it back out.
|
||||||
|
fn emulation_prevent(rbsp: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut zeros = 0;
|
||||||
|
for &b in rbsp {
|
||||||
|
if zeros >= 2 && b <= 0x03 {
|
||||||
|
out.push(0x03);
|
||||||
|
zeros = 0;
|
||||||
|
}
|
||||||
|
out.push(b);
|
||||||
|
if b == 0 {
|
||||||
|
zeros += 1;
|
||||||
|
} else {
|
||||||
|
zeros = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap one or more SEI messages in a prefix-SEI NAL (type 39) preceded by an
|
||||||
|
/// Annex-B start code. The assembled message bytes are emulation-prevented
|
||||||
|
/// (as a conforming encoder would) so they never form a false start code; the
|
||||||
|
/// 0x80 RBSP trailing-bits byte is appended.
|
||||||
|
fn sei_nal(messages: &[Vec<u8>]) -> Vec<u8> {
|
||||||
|
let mut rbsp = Vec::new();
|
||||||
|
for m in messages {
|
||||||
|
rbsp.extend_from_slice(m);
|
||||||
|
}
|
||||||
|
let mut v = vec![0x00, 0x00, 0x01];
|
||||||
|
v.extend_from_slice(&hevc_nal_header(NAL_SEI_PREFIX));
|
||||||
|
v.extend_from_slice(&emulation_prevent(&rbsp));
|
||||||
|
v.push(0x80); // rbsp_trailing_bits
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both HDR10 SEI messages in one access unit → the parser surfaces a fully
|
||||||
|
/// populated Hdr10Metadata with the EXACT raw SEI integers (scaling is the
|
||||||
|
/// muxer's job, asserted separately in mkv.rs). DCI-P3 D65 reference values.
|
||||||
|
#[test]
|
||||||
|
fn hevc_parses_hdr10_sei_with_exact_raw_values() {
|
||||||
|
// BT.2020 primaries (SEI order G, B, R) and D65 white point, as a typical
|
||||||
|
// UHD master would signal. Luminance: 1000 cd/m² max (×10000 = 10_000_000),
|
||||||
|
// 0.0001 cd/m² min (= 1).
|
||||||
|
let prim_x = [8500u16, 6550, 35400]; // G, B, R
|
||||||
|
let prim_y = [39850u16, 2300, 14600];
|
||||||
|
let (wp_x, wp_y) = (15635u16, 16450);
|
||||||
|
let (max_lum, min_lum) = (10_000_000u32, 1u32);
|
||||||
|
let (maxcll, maxfall) = (1000u16, 400u16);
|
||||||
|
|
||||||
|
let pps = {
|
||||||
|
let mut v = vec![0x00, 0x00, 0x01];
|
||||||
|
v.extend_from_slice(&hevc_nal_header(NAL_PPS));
|
||||||
|
v.push(0xC0); // num_extra_slice_header_bits 0
|
||||||
|
v
|
||||||
|
};
|
||||||
|
let idr = {
|
||||||
|
let mut v = vec![0x00, 0x00, 0x01];
|
||||||
|
v.extend_from_slice(&hevc_nal_header(19)); // IDR_W_RADL
|
||||||
|
v.push(0xEC); // first_slice, slice_type I
|
||||||
|
v
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut data = pps;
|
||||||
|
data.extend_from_slice(&sei_nal(&[
|
||||||
|
sei_message(
|
||||||
|
SEI_MASTERING_DISPLAY_COLOUR_VOLUME,
|
||||||
|
&mastering_payload(prim_x, prim_y, wp_x, wp_y, max_lum, min_lum),
|
||||||
|
),
|
||||||
|
sei_message(SEI_CONTENT_LIGHT_LEVEL_INFO, &cll_payload(maxcll, maxfall)),
|
||||||
|
]));
|
||||||
|
data.extend_from_slice(&idr);
|
||||||
|
|
||||||
|
let mut parser = HevcParser::new();
|
||||||
|
let frames = parser.parse(&make_pes(data, Some(0)));
|
||||||
|
let h = frames[0]
|
||||||
|
.coding
|
||||||
|
.expect("HEVC frame carries PictureInfo")
|
||||||
|
.hdr10()
|
||||||
|
.expect("both HDR10 SEI present → metadata surfaced");
|
||||||
|
|
||||||
|
assert_eq!(h.display_primaries_x, prim_x, "primary X raw (G,B,R)");
|
||||||
|
assert_eq!(h.display_primaries_y, prim_y, "primary Y raw (G,B,R)");
|
||||||
|
assert_eq!(h.white_point_x, wp_x);
|
||||||
|
assert_eq!(h.white_point_y, wp_y);
|
||||||
|
assert_eq!(h.max_display_mastering_luminance, max_lum);
|
||||||
|
assert_eq!(h.min_display_mastering_luminance, min_lum);
|
||||||
|
assert_eq!(h.max_content_light_level, maxcll);
|
||||||
|
assert_eq!(h.max_pic_average_light_level, maxfall);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the mastering-display SEI (no content-light SEI) → metadata is NOT
|
||||||
|
/// surfaced. HDR10 requires BOTH; a half-populated record is never emitted.
|
||||||
|
#[test]
|
||||||
|
fn hevc_requires_both_hdr10_sei_messages() {
|
||||||
|
let pps = {
|
||||||
|
let mut v = vec![0x00, 0x00, 0x01];
|
||||||
|
v.extend_from_slice(&hevc_nal_header(NAL_PPS));
|
||||||
|
v.push(0xC0);
|
||||||
|
v
|
||||||
|
};
|
||||||
|
let idr = {
|
||||||
|
let mut v = vec![0x00, 0x00, 0x01];
|
||||||
|
v.extend_from_slice(&hevc_nal_header(19));
|
||||||
|
v.push(0xEC); // IDR: first_slice + no_output + pps_id 0 + slice_type I
|
||||||
|
v
|
||||||
|
};
|
||||||
|
let mut data = pps;
|
||||||
|
data.extend_from_slice(&sei_nal(&[sei_message(
|
||||||
|
SEI_MASTERING_DISPLAY_COLOUR_VOLUME,
|
||||||
|
&mastering_payload([1, 2, 3], [4, 5, 6], 7, 8, 9, 10),
|
||||||
|
)]));
|
||||||
|
data.extend_from_slice(&idr);
|
||||||
|
|
||||||
|
let mut parser = HevcParser::new();
|
||||||
|
let frames = parser.parse(&make_pes(data, Some(0)));
|
||||||
|
assert!(
|
||||||
|
frames[0].coding.unwrap().hdr10().is_none(),
|
||||||
|
"mastering-only stream must NOT surface HDR10 (content-light absent)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An SDR stream with no HDR10 SEI at all leaves hdr10() None — never faked.
|
||||||
|
#[test]
|
||||||
|
fn hevc_sdr_stream_has_no_hdr10() {
|
||||||
|
let pps = {
|
||||||
|
let mut v = vec![0x00, 0x00, 0x01];
|
||||||
|
v.extend_from_slice(&hevc_nal_header(NAL_PPS));
|
||||||
|
v.push(0xC0);
|
||||||
|
v
|
||||||
|
};
|
||||||
|
let idr = {
|
||||||
|
let mut v = vec![0x00, 0x00, 0x01];
|
||||||
|
v.extend_from_slice(&hevc_nal_header(19));
|
||||||
|
v.push(0xEC); // IDR: first_slice + no_output + pps_id 0 + slice_type I
|
||||||
|
v
|
||||||
|
};
|
||||||
|
let mut data = pps;
|
||||||
|
data.extend_from_slice(&idr);
|
||||||
|
let mut parser = HevcParser::new();
|
||||||
|
let frames = parser.parse(&make_pes(data, Some(0)));
|
||||||
|
assert!(
|
||||||
|
frames[0].coding.unwrap().hdr10().is_none(),
|
||||||
|
"SDR / no-SEI stream must surface no HDR10 metadata"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The HDR10 SEI parse must de-emulate (00 00 03) before reading payload
|
||||||
|
/// fields. A payload byte sequence 00 00 03 in the bitstream is an
|
||||||
|
/// emulation-prevention insertion the parser must strip, or every field
|
||||||
|
/// after it shifts by one byte. Construct a mastering payload whose raw bytes
|
||||||
|
/// contain 00 00 (forcing an emulation byte), insert the 03, and assert the
|
||||||
|
/// decoded values still match the un-emulated payload.
|
||||||
|
#[test]
|
||||||
|
fn hevc_hdr10_sei_de_emulates() {
|
||||||
|
// prim_x[0]=0x0000, prim_y[0]=0x0002 → raw payload starts 00 00 00 02.
|
||||||
|
// A conforming HEVC encoder inserts an emulation-prevention 0x03 after the
|
||||||
|
// 00 00 (since the following byte is ≤ 0x03), giving 00 00 03 00 02. The
|
||||||
|
// parser MUST strip that 03 before reading, or every later field shifts.
|
||||||
|
let prim_x = [0u16, 6550, 35400];
|
||||||
|
let prim_y = [2u16, 2300, 14600];
|
||||||
|
let payload = mastering_payload(prim_x, prim_y, 15635, 16450, 10_000_000, 1);
|
||||||
|
|
||||||
|
// Manually emulate: insert 0x03 after each 00 00 followed by a byte ≤ 0x03,
|
||||||
|
// the way a conforming HEVC encoder would in the RBSP.
|
||||||
|
let mut emulated = Vec::new();
|
||||||
|
let mut zeros = 0;
|
||||||
|
for &b in &payload {
|
||||||
|
if zeros >= 2 && b <= 0x03 {
|
||||||
|
emulated.push(0x03);
|
||||||
|
zeros = 0;
|
||||||
|
}
|
||||||
|
emulated.push(b);
|
||||||
|
if b == 0 {
|
||||||
|
zeros += 1;
|
||||||
|
} else {
|
||||||
|
zeros = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
emulated.len() > payload.len(),
|
||||||
|
"test must actually insert an emulation byte"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut nal = vec![0x00, 0x00, 0x01];
|
||||||
|
nal.extend_from_slice(&hevc_nal_header(NAL_SEI_PREFIX));
|
||||||
|
nal.push(137); // payloadType
|
||||||
|
nal.push(24); // payloadSize = ORIGINAL (un-emulated) byte count
|
||||||
|
nal.extend_from_slice(&emulated);
|
||||||
|
nal.push(0x80);
|
||||||
|
|
||||||
|
// Pair with a content-light SEI so hdr10() can combine.
|
||||||
|
let mut clnal = vec![0x00, 0x00, 0x01];
|
||||||
|
clnal.extend_from_slice(&hevc_nal_header(NAL_SEI_PREFIX));
|
||||||
|
clnal.push(144);
|
||||||
|
clnal.push(4);
|
||||||
|
clnal.extend_from_slice(&cll_payload(1000, 400));
|
||||||
|
clnal.push(0x80);
|
||||||
|
|
||||||
|
let pps = {
|
||||||
|
let mut v = vec![0x00, 0x00, 0x01];
|
||||||
|
v.extend_from_slice(&hevc_nal_header(NAL_PPS));
|
||||||
|
v.push(0xC0);
|
||||||
|
v
|
||||||
|
};
|
||||||
|
let idr = {
|
||||||
|
let mut v = vec![0x00, 0x00, 0x01];
|
||||||
|
v.extend_from_slice(&hevc_nal_header(19));
|
||||||
|
v.push(0xEC); // IDR: first_slice + no_output + pps_id 0 + slice_type I
|
||||||
|
v
|
||||||
|
};
|
||||||
|
let mut data = pps;
|
||||||
|
data.extend_from_slice(&nal);
|
||||||
|
data.extend_from_slice(&clnal);
|
||||||
|
data.extend_from_slice(&idr);
|
||||||
|
|
||||||
|
let mut parser = HevcParser::new();
|
||||||
|
let frames = parser.parse(&make_pes(data, Some(0)));
|
||||||
|
let h = frames[0].coding.unwrap().hdr10().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
h.display_primaries_x, prim_x,
|
||||||
|
"de-emulated payload must decode to original primary X (00 00 03 stripped)"
|
||||||
|
);
|
||||||
|
assert_eq!(h.display_primaries_y, prim_y);
|
||||||
|
assert_eq!(h.max_display_mastering_luminance, 10_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hevc_populates_measured_coding_type_and_source() {
|
fn hevc_populates_measured_coding_type_and_source() {
|
||||||
use super::super::coding::CodingType;
|
use super::super::coding::CodingType;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ pub mod truehd;
|
|||||||
/// VC-1 (SMPTE 421M) elementary-stream parser.
|
/// VC-1 (SMPTE 421M) elementary-stream parser.
|
||||||
pub mod vc1;
|
pub mod vc1;
|
||||||
|
|
||||||
pub use coding::{FieldOrder, PictureInfo};
|
pub use coding::{FieldOrder, Hdr10Metadata, PictureInfo};
|
||||||
|
|
||||||
use super::ts::PesPacket;
|
use super::ts::PesPacket;
|
||||||
use crate::disc::Codec;
|
use crate::disc::Codec;
|
||||||
|
|||||||
@@ -456,6 +456,25 @@ pub const TRANSFER_CHARACTERISTICS: u32 = 0x55BA;
|
|||||||
pub const MATRIX_COEFFICIENTS: u32 = 0x55B1;
|
pub const MATRIX_COEFFICIENTS: u32 = 0x55B1;
|
||||||
pub const PRIMARIES: u32 = 0x55BB;
|
pub const PRIMARIES: u32 = 0x55BB;
|
||||||
pub const RANGE: u32 = 0x55B9;
|
pub const RANGE: u32 = 0x55B9;
|
||||||
|
// HDR10 static metadata — children of COLOUR (RFC 9559 / Matroska spec).
|
||||||
|
//
|
||||||
|
// MaxCLL / MaxFALL are direct children of Colour and are UINTs (cd/m²).
|
||||||
|
pub const MAX_CLL: u32 = 0x55BC;
|
||||||
|
pub const MAX_FALL: u32 = 0x55BD;
|
||||||
|
// MasteringMetadata is a master child of Colour; its chromaticity / luminance
|
||||||
|
// children are EBML FLOATs. Chromaticity values are in the 0..1 range; the
|
||||||
|
// luminance values are in cd/m².
|
||||||
|
pub const MASTERING_METADATA: u32 = 0x55D0;
|
||||||
|
pub const PRIMARY_R_CHROMATICITY_X: u32 = 0x55D1;
|
||||||
|
pub const PRIMARY_R_CHROMATICITY_Y: u32 = 0x55D2;
|
||||||
|
pub const PRIMARY_G_CHROMATICITY_X: u32 = 0x55D3;
|
||||||
|
pub const PRIMARY_G_CHROMATICITY_Y: u32 = 0x55D4;
|
||||||
|
pub const PRIMARY_B_CHROMATICITY_X: u32 = 0x55D5;
|
||||||
|
pub const PRIMARY_B_CHROMATICITY_Y: u32 = 0x55D6;
|
||||||
|
pub const WHITE_POINT_CHROMATICITY_X: u32 = 0x55D7;
|
||||||
|
pub const WHITE_POINT_CHROMATICITY_Y: u32 = 0x55D8;
|
||||||
|
pub const LUMINANCE_MAX: u32 = 0x55D9;
|
||||||
|
pub const LUMINANCE_MIN: u32 = 0x55DA;
|
||||||
|
|
||||||
// Dolby Vision — BlockAdditionMapping carries the DOVIDecoderConfigurationRecord
|
// Dolby Vision — BlockAdditionMapping carries the DOVIDecoderConfigurationRecord
|
||||||
// (dvcC) so players / mediainfo recognise the track as Dolby Vision.
|
// (dvcC) so players / mediainfo recognise the track as Dolby Vision.
|
||||||
|
|||||||
+215
-2
@@ -95,6 +95,13 @@ pub struct MkvTrack {
|
|||||||
// Dolby Vision: the dvcC (DOVIDecoderConfigurationRecord) for the DV layer,
|
// Dolby Vision: the dvcC (DOVIDecoderConfigurationRecord) for the DV layer,
|
||||||
// emitted as a BlockAdditionMapping. `None` for non-DV tracks.
|
// emitted as a BlockAdditionMapping. `None` for non-DV tracks.
|
||||||
pub dv_config: Option<Vec<u8>>,
|
pub dv_config: Option<Vec<u8>>,
|
||||||
|
/// HDR10 static metadata measured from the bitstream (HEVC SEI), or `None`
|
||||||
|
/// when the stream carried no HDR10 SEI. Set from the first coded picture's
|
||||||
|
/// `PictureInfo` at muxer activation (the same deferred path FieldOrder
|
||||||
|
/// uses), NOT at construction — the SEI is only known once the elementary
|
||||||
|
/// stream is parsed. When `Some`, the serializer emits MasteringMetadata +
|
||||||
|
/// MaxCLL/MaxFALL inside Colour; when `None` they are omitted entirely.
|
||||||
|
pub hdr10: Option<crate::mux::codec::Hdr10Metadata>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a DOVIDecoderConfigurationRecord (dvcC) — 24 bytes — for the Matroska
|
/// Build a DOVIDecoderConfigurationRecord (dvcC) — 24 bytes — for the Matroska
|
||||||
@@ -112,6 +119,83 @@ pub fn dolby_vision_config(profile: u8, level: u8, bl_compat_id: u8) -> Vec<u8>
|
|||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SEI chromaticity unit (Rec. ITU-T H.265 D.3.28): `display_primaries_*` and
|
||||||
|
/// `white_point_*` are in increments of 0.00002. Matroska chromaticity elements
|
||||||
|
/// are floats in the [0, 1] range, so the conversion is `value * 0.00002`.
|
||||||
|
const HDR10_CHROMATICITY_UNIT: f64 = 0.00002;
|
||||||
|
/// SEI luminance unit (Rec. ITU-T H.265 D.3.28): `max/min_display_mastering_
|
||||||
|
/// luminance` are in increments of 0.0001 cd/m². Matroska Luminance elements are
|
||||||
|
/// floats in cd/m², so the conversion is `value * 0.0001`.
|
||||||
|
const HDR10_LUMINANCE_UNIT: f64 = 0.0001;
|
||||||
|
|
||||||
|
/// Emit the HDR10 static-metadata children of the Matroska `Colour` element:
|
||||||
|
/// `MasteringMetadata` (chromaticity / luminance floats) plus `MaxCLL` /
|
||||||
|
/// `MaxFALL` (uints). Called ONLY when the metadata was measured from the
|
||||||
|
/// bitstream SEI, so it is never written for SDR content.
|
||||||
|
///
|
||||||
|
/// Unit conversions (Rec. ITU-T H.265 D.3.28 → RFC 9559 / Matroska):
|
||||||
|
/// - chromaticity: SEI integer × 0.00002 → Matroska float in [0, 1]
|
||||||
|
/// - luminance: SEI integer × 0.0001 → Matroska float in cd/m²
|
||||||
|
/// - MaxCLL / MaxFALL: already cd/m² integers → written as uints verbatim
|
||||||
|
///
|
||||||
|
/// The SEI primary order is c=0 Green, c=1 Blue, c=2 Red (D.3.28); the
|
||||||
|
/// `Hdr10Metadata` arrays preserve that SEI order, so index 0 → G, 1 → B, 2 → R
|
||||||
|
/// is mapped onto the Matroska R/G/B element layout here.
|
||||||
|
fn write_hdr10<W: Write + Seek>(w: &mut W, h: &crate::mux::codec::Hdr10Metadata) -> io::Result<()> {
|
||||||
|
let chroma = |v: u16| -> f64 { v as f64 * HDR10_CHROMATICITY_UNIT };
|
||||||
|
let lum = |v: u32| -> f64 { v as f64 * HDR10_LUMINANCE_UNIT };
|
||||||
|
|
||||||
|
let mm_pos = ebml::start_master(w, ebml::MASTERING_METADATA)?;
|
||||||
|
// SEI index 2 = Red, 0 = Green, 1 = Blue.
|
||||||
|
ebml::write_float(
|
||||||
|
w,
|
||||||
|
ebml::PRIMARY_R_CHROMATICITY_X,
|
||||||
|
chroma(h.display_primaries_x[2]),
|
||||||
|
)?;
|
||||||
|
ebml::write_float(
|
||||||
|
w,
|
||||||
|
ebml::PRIMARY_R_CHROMATICITY_Y,
|
||||||
|
chroma(h.display_primaries_y[2]),
|
||||||
|
)?;
|
||||||
|
ebml::write_float(
|
||||||
|
w,
|
||||||
|
ebml::PRIMARY_G_CHROMATICITY_X,
|
||||||
|
chroma(h.display_primaries_x[0]),
|
||||||
|
)?;
|
||||||
|
ebml::write_float(
|
||||||
|
w,
|
||||||
|
ebml::PRIMARY_G_CHROMATICITY_Y,
|
||||||
|
chroma(h.display_primaries_y[0]),
|
||||||
|
)?;
|
||||||
|
ebml::write_float(
|
||||||
|
w,
|
||||||
|
ebml::PRIMARY_B_CHROMATICITY_X,
|
||||||
|
chroma(h.display_primaries_x[1]),
|
||||||
|
)?;
|
||||||
|
ebml::write_float(
|
||||||
|
w,
|
||||||
|
ebml::PRIMARY_B_CHROMATICITY_Y,
|
||||||
|
chroma(h.display_primaries_y[1]),
|
||||||
|
)?;
|
||||||
|
ebml::write_float(w, ebml::WHITE_POINT_CHROMATICITY_X, chroma(h.white_point_x))?;
|
||||||
|
ebml::write_float(w, ebml::WHITE_POINT_CHROMATICITY_Y, chroma(h.white_point_y))?;
|
||||||
|
ebml::write_float(
|
||||||
|
w,
|
||||||
|
ebml::LUMINANCE_MAX,
|
||||||
|
lum(h.max_display_mastering_luminance),
|
||||||
|
)?;
|
||||||
|
ebml::write_float(
|
||||||
|
w,
|
||||||
|
ebml::LUMINANCE_MIN,
|
||||||
|
lum(h.min_display_mastering_luminance),
|
||||||
|
)?;
|
||||||
|
ebml::end_master(w, mm_pos)?;
|
||||||
|
|
||||||
|
ebml::write_uint(w, ebml::MAX_CLL, h.max_content_light_level as u64)?;
|
||||||
|
ebml::write_uint(w, ebml::MAX_FALL, h.max_pic_average_light_level as u64)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
impl MkvTrack {
|
impl MkvTrack {
|
||||||
/// Build a video track from a [`VideoStream`]. Language defaults to `"und"`;
|
/// Build a video track from a [`VideoStream`]. Language defaults to `"und"`;
|
||||||
/// colour metadata is derived from the stream's colour space and HDR format
|
/// colour metadata is derived from the stream's colour space and HDR format
|
||||||
@@ -266,6 +350,11 @@ impl MkvTrack {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
|
// HDR10 static metadata is measured from the HEVC SEI at mux time,
|
||||||
|
// not known at construction. The mux stream sets it from the first
|
||||||
|
// coded picture's PictureInfo before the header is written (the same
|
||||||
|
// deferred path FieldOrder uses). `None` here → omitted unless seen.
|
||||||
|
hdr10: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +422,7 @@ impl MkvTrack {
|
|||||||
channels: ch,
|
channels: ch,
|
||||||
bit_depth: 0,
|
bit_depth: 0,
|
||||||
dv_config: None,
|
dv_config: None,
|
||||||
|
hdr10: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +459,7 @@ impl MkvTrack {
|
|||||||
channels: 0,
|
channels: 0,
|
||||||
bit_depth: 0,
|
bit_depth: 0,
|
||||||
dv_config: None,
|
dv_config: None,
|
||||||
|
hdr10: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -757,8 +848,9 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
track.display_height as u64,
|
track.display_height as u64,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
// Colour metadata (HDR)
|
// Colour metadata (HDR). Open the Colour master when the track
|
||||||
if track.colour_matrix > 0 || track.colour_transfer > 0 {
|
// carries CICP signalling OR measured HDR10 static metadata.
|
||||||
|
if track.colour_matrix > 0 || track.colour_transfer > 0 || track.hdr10.is_some() {
|
||||||
let col_pos = ebml::start_master(&mut writer, ebml::COLOUR)?;
|
let col_pos = ebml::start_master(&mut writer, ebml::COLOUR)?;
|
||||||
ebml::write_uint(
|
ebml::write_uint(
|
||||||
&mut writer,
|
&mut writer,
|
||||||
@@ -772,6 +864,11 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
)?;
|
)?;
|
||||||
ebml::write_uint(&mut writer, ebml::PRIMARIES, track.colour_primaries as u64)?;
|
ebml::write_uint(&mut writer, ebml::PRIMARIES, track.colour_primaries as u64)?;
|
||||||
ebml::write_uint(&mut writer, ebml::RANGE, track.colour_range as u64)?;
|
ebml::write_uint(&mut writer, ebml::RANGE, track.colour_range as u64)?;
|
||||||
|
// HDR10 static metadata — emitted ONLY when measured from the
|
||||||
|
// bitstream SEI (never fabricated for SDR).
|
||||||
|
if let Some(h) = track.hdr10 {
|
||||||
|
write_hdr10(&mut writer, &h)?;
|
||||||
|
}
|
||||||
ebml::end_master(&mut writer, col_pos)?;
|
ebml::end_master(&mut writer, col_pos)?;
|
||||||
}
|
}
|
||||||
ebml::end_master(&mut writer, vid_pos)?;
|
ebml::end_master(&mut writer, vid_pos)?;
|
||||||
@@ -1469,6 +1566,7 @@ mod tests {
|
|||||||
channels: 0,
|
channels: 0,
|
||||||
bit_depth: 0,
|
bit_depth: 0,
|
||||||
dv_config: None,
|
dv_config: None,
|
||||||
|
hdr10: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1497,6 +1595,7 @@ mod tests {
|
|||||||
channels: 6,
|
channels: 6,
|
||||||
bit_depth: 0,
|
bit_depth: 0,
|
||||||
dv_config: None,
|
dv_config: None,
|
||||||
|
hdr10: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3273,6 +3372,120 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// HDR10 static metadata, when measured, is emitted inside Colour as
|
||||||
|
/// MasteringMetadata (scaled floats) + MaxCLL/MaxFALL (uints) — and OMITTED
|
||||||
|
/// entirely for an SDR track. Asserts the exact H.265 → Matroska unit
|
||||||
|
/// conversions.
|
||||||
|
#[test]
|
||||||
|
fn video_emits_hdr10_mastering_and_cll_with_correct_scaling() {
|
||||||
|
use crate::mux::codec::Hdr10Metadata;
|
||||||
|
|
||||||
|
// Raw SEI integers (SEI primary order G, B, R):
|
||||||
|
// primaries chromaticity ×0.00002, luminance ×0.0001, CLL/FALL verbatim.
|
||||||
|
let h = Hdr10Metadata {
|
||||||
|
display_primaries_x: [8500, 6550, 35400], // G, B, R
|
||||||
|
display_primaries_y: [39850, 2300, 14600],
|
||||||
|
white_point_x: 15635,
|
||||||
|
white_point_y: 16450,
|
||||||
|
max_display_mastering_luminance: 10_000_000, // 1000 cd/m²
|
||||||
|
min_display_mastering_luminance: 1, // 0.0001 cd/m²
|
||||||
|
max_content_light_level: 1000,
|
||||||
|
max_pic_average_light_level: 400,
|
||||||
|
};
|
||||||
|
let mut v = make_video_track();
|
||||||
|
v.colour_matrix = 9;
|
||||||
|
v.colour_transfer = 16;
|
||||||
|
v.colour_primaries = 9;
|
||||||
|
v.colour_range = 1;
|
||||||
|
v.hdr10 = Some(h);
|
||||||
|
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[]).unwrap();
|
||||||
|
let data = muxer.writer.into_inner();
|
||||||
|
|
||||||
|
// Read the 8-byte BE f64 that follows a float element ID (+ 1-byte size).
|
||||||
|
let read_float = |id: u32| -> f64 {
|
||||||
|
let off = find_id(&data, id).unwrap_or_else(|| panic!("element {id:#x} present"));
|
||||||
|
assert_eq!(
|
||||||
|
data[off + 2],
|
||||||
|
0x88,
|
||||||
|
"float element {id:#x} declares 8 bytes"
|
||||||
|
);
|
||||||
|
let mut b = [0u8; 8];
|
||||||
|
b.copy_from_slice(&data[off + 3..off + 11]);
|
||||||
|
f64::from_be_bytes(b)
|
||||||
|
};
|
||||||
|
let read_uint = |id: u32| -> u64 {
|
||||||
|
let off = find_id(&data, id).unwrap_or_else(|| panic!("element {id:#x} present"));
|
||||||
|
let len = (data[off + 2] & 0x7F) as usize;
|
||||||
|
let mut val = 0u64;
|
||||||
|
for &byte in &data[off + 3..off + 3 + len] {
|
||||||
|
val = (val << 8) | byte as u64;
|
||||||
|
}
|
||||||
|
val
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
find_id(&data, ebml::MASTERING_METADATA).is_some(),
|
||||||
|
"MasteringMetadata present"
|
||||||
|
);
|
||||||
|
// R is SEI index 2, G index 0, B index 1.
|
||||||
|
assert_eq!(
|
||||||
|
read_float(ebml::PRIMARY_R_CHROMATICITY_X),
|
||||||
|
35400.0 * 0.00002
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_float(ebml::PRIMARY_R_CHROMATICITY_Y),
|
||||||
|
14600.0 * 0.00002
|
||||||
|
);
|
||||||
|
assert_eq!(read_float(ebml::PRIMARY_G_CHROMATICITY_X), 8500.0 * 0.00002);
|
||||||
|
assert_eq!(
|
||||||
|
read_float(ebml::PRIMARY_G_CHROMATICITY_Y),
|
||||||
|
39850.0 * 0.00002
|
||||||
|
);
|
||||||
|
assert_eq!(read_float(ebml::PRIMARY_B_CHROMATICITY_X), 6550.0 * 0.00002);
|
||||||
|
assert_eq!(read_float(ebml::PRIMARY_B_CHROMATICITY_Y), 2300.0 * 0.00002);
|
||||||
|
assert_eq!(
|
||||||
|
read_float(ebml::WHITE_POINT_CHROMATICITY_X),
|
||||||
|
15635.0 * 0.00002
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_float(ebml::WHITE_POINT_CHROMATICITY_Y),
|
||||||
|
16450.0 * 0.00002
|
||||||
|
);
|
||||||
|
// Luminance: 10_000_000 × 0.0001 = 1000.0 cd/m²; 1 × 0.0001 = 0.0001.
|
||||||
|
assert_eq!(read_float(ebml::LUMINANCE_MAX), 1000.0);
|
||||||
|
assert_eq!(read_float(ebml::LUMINANCE_MIN), 0.0001);
|
||||||
|
// MaxCLL / MaxFALL are cd/m² uints, verbatim.
|
||||||
|
assert_eq!(read_uint(ebml::MAX_CLL), 1000);
|
||||||
|
assert_eq!(read_uint(ebml::MAX_FALL), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An SDR track (no measured HDR10) must NOT emit MasteringMetadata, MaxCLL,
|
||||||
|
/// or MaxFALL — the metadata is never fabricated.
|
||||||
|
#[test]
|
||||||
|
fn sdr_video_omits_hdr10_metadata() {
|
||||||
|
// A track with CICP signalling but hdr10 = None: Colour is emitted, but
|
||||||
|
// none of the HDR10 children are.
|
||||||
|
let mut v = make_video_track();
|
||||||
|
v.colour_matrix = 1; // bt709
|
||||||
|
v.colour_transfer = 1;
|
||||||
|
assert!(v.hdr10.is_none());
|
||||||
|
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[]).unwrap();
|
||||||
|
let data = muxer.writer.into_inner();
|
||||||
|
assert!(
|
||||||
|
find_id(&data, ebml::COLOUR).is_some(),
|
||||||
|
"Colour still emitted"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
find_id(&data, ebml::MASTERING_METADATA).is_none(),
|
||||||
|
"no MasteringMetadata for SDR"
|
||||||
|
);
|
||||||
|
assert!(find_id(&data, ebml::MAX_CLL).is_none(), "no MaxCLL for SDR");
|
||||||
|
assert!(
|
||||||
|
find_id(&data, ebml::MAX_FALL).is_none(),
|
||||||
|
"no MaxFALL for SDR"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn video_emits_flag_interlaced_and_field_order() {
|
fn video_emits_flag_interlaced_and_field_order() {
|
||||||
// An interlaced track must emit FlagInterlaced=1 and its FieldOrder
|
// An interlaced track must emit FlagInterlaced=1 and its FieldOrder
|
||||||
|
|||||||
@@ -246,6 +246,13 @@ impl MkvStream {
|
|||||||
/// picture, so it should never be missing): LOG it loudly so the source can be
|
/// picture, so it should never be missing): LOG it loudly so the source can be
|
||||||
/// debugged, and leave UNDETERMINED — a muxer never fabricates a source fact.
|
/// debugged, and leave UNDETERMINED — a muxer never fabricates a source fact.
|
||||||
fn apply_coding_to_track(track: &mut MkvTrack, coding: Option<crate::mux::codec::PictureInfo>) {
|
fn apply_coding_to_track(track: &mut MkvTrack, coding: Option<crate::mux::codec::PictureInfo>) {
|
||||||
|
// HDR10 static metadata measured from the bitstream (HEVC SEI). Applied for
|
||||||
|
// ANY track type that carries it (independent of interlace): the first coded
|
||||||
|
// picture's PictureInfo holds it once both HDR10 SEI messages were seen.
|
||||||
|
// `None` (SDR / no-SEI) leaves the track's `hdr10` untouched → omitted.
|
||||||
|
if let Some(h) = coding.and_then(|c| c.hdr10()) {
|
||||||
|
track.hdr10 = Some(h);
|
||||||
|
}
|
||||||
if !track.interlaced {
|
if !track.interlaced {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -954,6 +961,59 @@ mod tests {
|
|||||||
assert_eq!(prog.field_order, ebml::FIELD_ORDER_UNDETERMINED);
|
assert_eq!(prog.field_order, ebml::FIELD_ORDER_UNDETERMINED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `apply_coding_to_track` routes MEASURED HDR10 static metadata from the
|
||||||
|
/// first coded picture onto the track (independent of interlace), and leaves
|
||||||
|
/// it `None` when the picture carried none — never fabricated.
|
||||||
|
#[test]
|
||||||
|
fn apply_coding_to_track_plumbs_measured_hdr10() {
|
||||||
|
use crate::disc::{Codec, ColorSpace, FrameRate, HdrFormat, Resolution, VideoStream};
|
||||||
|
use crate::mux::codec::Hdr10Metadata;
|
||||||
|
use crate::mux::codec::coding::{CodingType, PictureInfo};
|
||||||
|
|
||||||
|
let make = || {
|
||||||
|
MkvTrack::video(&VideoStream {
|
||||||
|
pid: 0xE0,
|
||||||
|
codec: Codec::Hevc,
|
||||||
|
resolution: Resolution::R2160p, // progressive UHD
|
||||||
|
frame_rate: FrameRate::F24,
|
||||||
|
hdr: HdrFormat::Hdr10,
|
||||||
|
color_space: ColorSpace::Bt2020,
|
||||||
|
display_aspect: None,
|
||||||
|
secondary: false,
|
||||||
|
label: String::new(),
|
||||||
|
measured_cicp: None,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let h = Hdr10Metadata {
|
||||||
|
display_primaries_x: [8500, 6550, 35400],
|
||||||
|
display_primaries_y: [39850, 2300, 14600],
|
||||||
|
white_point_x: 15635,
|
||||||
|
white_point_y: 16450,
|
||||||
|
max_display_mastering_luminance: 10_000_000,
|
||||||
|
min_display_mastering_luminance: 1,
|
||||||
|
max_content_light_level: 1000,
|
||||||
|
max_pic_average_light_level: 400,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Picture carries HDR10 → plumbed onto the track.
|
||||||
|
let mut t = make();
|
||||||
|
assert!(t.hdr10.is_none(), "fresh track has no HDR10");
|
||||||
|
let pic = PictureInfo::coding_type_only(CodingType::I).with_hdr10(Some(h));
|
||||||
|
apply_coding_to_track(&mut t, Some(pic));
|
||||||
|
assert_eq!(t.hdr10, Some(h), "measured HDR10 must reach the track");
|
||||||
|
|
||||||
|
// Picture without HDR10 → track stays None (never fabricated).
|
||||||
|
let mut t = make();
|
||||||
|
let pic = PictureInfo::coding_type_only(CodingType::I);
|
||||||
|
apply_coding_to_track(&mut t, Some(pic));
|
||||||
|
assert!(t.hdr10.is_none(), "no measured HDR10 → track stays None");
|
||||||
|
|
||||||
|
// No coding at all → None.
|
||||||
|
let mut t = make();
|
||||||
|
apply_coding_to_track(&mut t, None);
|
||||||
|
assert!(t.hdr10.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
// `From<Error> for io::Error` encodes the numeric code into the
|
// `From<Error> for io::Error` encodes the numeric code into the
|
||||||
// Display string as "E{code}: ...". Check the prefix.
|
// Display string as "E{code}: ...". Check the prefix.
|
||||||
/// Extract the error from a `MkvStream::open` result without requiring
|
/// Extract the error from a `MkvStream::open` result without requiring
|
||||||
|
|||||||
Reference in New Issue
Block a user