mux/mkv: spec-conformance fixes (field order, CICP, VobSub idx, SeekHead, AC-3)
- FieldOrder now derives from the bitstream's measured top_field_first (Some(true)→TFF, Some(false)→BFF) instead of hardcoding TFF for all interlaced content; falls back to TFF when unmeasured. Adds VideoStream::top_field_first; DVD/BD scan sets None with a precise TODO(spec) for parser→title plumbing. - CICP (matrix/transfer/primaries/range) now prefers measured CICP from the bitstream (VideoStream::measured_cicp) over the coarse ColorSpace enum, so the container stops assuming a colour space the stream may contradict. Enum remains the fallback. - VobSub S_VOBSUB CodecPrivate now emits a `size: WxH` line ahead of the palette per the .idx format so players place/scale subs correctly. - SeekHead: when zero cues are written, the CUES Seek entry is Voided instead of leaving a dangling pointer to the Cues offset (now Tags/EOF). - AC-3 Channels back-patch offset is captured from the writer instead of the hardcoded chan_elem_pos+2 (decoupled from the VINT width choice). - Hoisted inline CICP codes and the dvcC fourcc to named constants citing ITU-T H.273 / RFC 9559; fixed the stale FieldOrder comment. - DefaultDuration vs pulldown: precise TODO(spec) left (needs the same parser→title channel as top_field_first). Tests: BFF-from-measured-flag, measured-CICP-overrides-enum, VobSub size: line present/omitted, zero-cue SeekHead Void. precommit (1.86) green.
This commit is contained in:
@@ -148,6 +148,17 @@ impl Disc {
|
|||||||
// the CLI/UI render the localized descriptor. `label`
|
// the CLI/UI render the localized descriptor. `label`
|
||||||
// stays empty for disc video streams.
|
// stays empty for disc video streams.
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
// TODO(spec): for 1080i HEVC/H.264/VC-1 titles, surface
|
||||||
|
// the measured field order (H.264/HEVC pic_struct, VC-1
|
||||||
|
// pulldown) from the codec parser instead of the TFF
|
||||||
|
// fallback; needs the parser→title channel (see dvd.rs).
|
||||||
|
top_field_first: None,
|
||||||
|
// TODO(spec): prefer the HEVC/H.264 VUI colour_description
|
||||||
|
// (measured CICP) over this MPLS playlist-nibble guess
|
||||||
|
// once the parser surfaces it through the output title.
|
||||||
|
// `None` keeps the enum fallback. (HDR MaxCLL/Mastering
|
||||||
|
// metadata is a separate task and intentionally not here.)
|
||||||
|
measured_cicp: None,
|
||||||
})),
|
})),
|
||||||
2 | 5 => {
|
2 | 5 => {
|
||||||
// Guard: if coding_type is a subtitle codec (PGS 0x90/0x91),
|
// Guard: if coding_type is a subtitle codec (PGS 0x90/0x91),
|
||||||
|
|||||||
+31
-2
@@ -49,7 +49,31 @@ impl Disc {
|
|||||||
}),
|
}),
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
// TODO(spec): populate from the MPEG-2 picture coding extension
|
||||||
|
// once the codec parser surfaces it. `Mpeg2Parser` already reads
|
||||||
|
// `top_field_first` (mux/codec/mpeg2.rs `picture_nb_fields`) but
|
||||||
|
// tracks are built from the IFO scan BEFORE any frame is parsed;
|
||||||
|
// wiring it requires a CodecParser accessor surfaced through
|
||||||
|
// PipelinedPesStream/DiscStream into the output title (mirroring
|
||||||
|
// the existing `codec_private` handshake). Until then `None`
|
||||||
|
// means the muxer falls back to TFF (correct for ~all DVDs).
|
||||||
|
top_field_first: None,
|
||||||
|
// TODO(spec): DVD MPEG-2 carries no VUI; the colour signalling is
|
||||||
|
// the sequence_display_extension colour_description when present.
|
||||||
|
// Surface it from `Mpeg2Parser` (same handshake as above) and set
|
||||||
|
// this so a disc that states e.g. BT.601-625 colour overrides the
|
||||||
|
// PAL/NTSC guess in `color_space`. `None` uses the enum fallback.
|
||||||
|
measured_cicp: None,
|
||||||
});
|
});
|
||||||
|
// TODO(spec): DefaultDuration is derived from the declared IFO
|
||||||
|
// frame_rate (25 / 29.97). A soft-telecined 23.976-in-29.97 DVD then
|
||||||
|
// reports 29.97 fps instead of the true 23.976 film rate. The pulldown
|
||||||
|
// cadence is detectable from the parser's per-picture `nb_fields`
|
||||||
|
// (repeat_first_field) — when most frames are 3-field-then-2-field
|
||||||
|
// 2:3 pulldown the film rate is frame_rate × 4/5. Emitting the film
|
||||||
|
// DefaultDuration needs the parser to report the measured cadence
|
||||||
|
// through the same parser→title channel as `top_field_first` above;
|
||||||
|
// left as a follow-up to avoid a speculative rate change here.
|
||||||
|
|
||||||
// Map DvdAudioAttr to Stream::Audio. The PID is derived from the
|
// Map DvdAudioAttr to Stream::Audio. The PID is derived from the
|
||||||
// stream's REAL on-wire private_stream_1 sub-stream id (assigned
|
// stream's REAL on-wire private_stream_1 sub-stream id (assigned
|
||||||
@@ -132,11 +156,16 @@ impl Disc {
|
|||||||
|
|
||||||
let size_bytes: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
|
let size_bytes: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
|
||||||
|
|
||||||
// Build pre-formatted palette codec_data for VobSub subtitle streams
|
// Build pre-formatted VobSub `.idx` codec_data (size: + palette:
|
||||||
|
// lines) for VobSub subtitle streams. The `size:` line carries
|
||||||
|
// the coded video frame the subpicture was authored against
|
||||||
|
// (720x480 NTSC / 720x576 PAL) so players place and scale the
|
||||||
|
// bitmap correctly.
|
||||||
|
let (vid_w, vid_h) = ts.video.resolution.pixels();
|
||||||
let codec_data = dvd_title
|
let codec_data = dvd_title
|
||||||
.palette
|
.palette
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|pal| crate::mux::codec::dvdsub::format_palette(pal));
|
.map(|pal| crate::mux::codec::dvdsub::format_palette(pal, vid_w, vid_h));
|
||||||
|
|
||||||
// Map DvdSubtitleAttr to Stream::Subtitle
|
// Map DvdSubtitleAttr to Stream::Subtitle
|
||||||
let subtitle_streams: Vec<Stream> = ts
|
let subtitle_streams: Vec<Stream> = ts
|
||||||
|
|||||||
@@ -196,6 +196,35 @@ pub struct VideoStream {
|
|||||||
pub secondary: bool,
|
pub secondary: bool,
|
||||||
/// Extra label (e.g. "Dolby Vision EL")
|
/// Extra label (e.g. "Dolby Vision EL")
|
||||||
pub label: String,
|
pub label: String,
|
||||||
|
/// Field-display order MEASURED from the elementary stream's interlace
|
||||||
|
/// signalling (MPEG-2 picture coding extension `top_field_first`, H.264/HEVC
|
||||||
|
/// `pic_struct`), when available: `Some(true)` = top-field-first,
|
||||||
|
/// `Some(false)` = bottom-field-first. `None` = not measured — the muxer
|
||||||
|
/// falls back to TFF for interlaced content (the dominant DVD/HD case). The
|
||||||
|
/// container FieldOrder must agree with the bitstream, so a measured BFF
|
||||||
|
/// stream must NOT be stamped TFF. Ignored for progressive video.
|
||||||
|
pub top_field_first: Option<bool>,
|
||||||
|
/// CICP colour signalling (matrix, transfer, primaries, full_range) MEASURED
|
||||||
|
/// from the bitstream — HEVC/H.264 VUI `colour_description` or MPEG-2
|
||||||
|
/// `sequence_display_extension`. `Some(...)` takes precedence over the
|
||||||
|
/// coarse `color_space` enum (a playlist nibble / PAL-NTSC guess); `None`
|
||||||
|
/// means the bitstream did not state it, so the enum-derived triplet is used.
|
||||||
|
/// Codes are ITU-T H.273 (CICP); `range` is 1 = limited/TV, 2 = full.
|
||||||
|
pub measured_cicp: Option<MeasuredCicp>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Measured CICP colour signalling read directly from a video elementary stream
|
||||||
|
/// (ITU-T H.273). Preferred over the coarse [`ColorSpace`] enum when present.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct MeasuredCicp {
|
||||||
|
/// MatrixCoefficients (ITU-T H.273 Table 4).
|
||||||
|
pub matrix: u8,
|
||||||
|
/// TransferCharacteristics (ITU-T H.273 Table 3).
|
||||||
|
pub transfer: u8,
|
||||||
|
/// ColourPrimaries (ITU-T H.273 Table 2).
|
||||||
|
pub primaries: u8,
|
||||||
|
/// Range: 1 = limited (studio/TV), 2 = full. Matroska Colour/Range values.
|
||||||
|
pub range: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An audio stream.
|
/// An audio stream.
|
||||||
@@ -3776,6 +3805,8 @@ mod tests {
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
})],
|
})],
|
||||||
chapters: Vec::new(),
|
chapters: Vec::new(),
|
||||||
extents: Vec::new(),
|
extents: Vec::new(),
|
||||||
|
|||||||
@@ -1308,6 +1308,8 @@ mod apply_tests {
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1760,6 +1762,8 @@ mod apply_tests {
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
});
|
});
|
||||||
let mut titles = vec![title_with(vec![interlaced])];
|
let mut titles = vec![title_with(vec![interlaced])];
|
||||||
fill_defaults(&mut titles);
|
fill_defaults(&mut titles);
|
||||||
@@ -1782,6 +1786,8 @@ mod apply_tests {
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
});
|
});
|
||||||
let mut titles = vec![title_with(vec![progressive])];
|
let mut titles = vec![title_with(vec![progressive])];
|
||||||
fill_defaults(&mut titles);
|
fill_defaults(&mut titles);
|
||||||
|
|||||||
+51
-11
@@ -182,20 +182,37 @@ fn clamp_u8(v: f64) -> u8 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format a 16-color YCbCr palette as a VobSub .idx palette header.
|
/// Format a 16-color YCbCr palette as a VobSub `.idx` header for S_VOBSUB
|
||||||
|
/// CodecPrivate.
|
||||||
///
|
///
|
||||||
/// Each entry is `[padding, Y, Cb, Cr]`. Output is a UTF-8 text block:
|
/// Each entry is `[padding, Y, Cb, Cr]`. Output is a UTF-8 text block carrying
|
||||||
/// `palette: rrggbb, rrggbb, ...\n`
|
/// the two `.idx` header lines mkvmerge / libvobsub expect:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// size: <width>x<height>
|
||||||
|
/// palette: rrggbb, rrggbb, ...
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The `size:` line is the VobSub original-frame resolution (the video frame the
|
||||||
|
/// subpicture coordinates were authored against). Players read it to place and
|
||||||
|
/// scale the bitmap; without it, some renderers assume a default frame and
|
||||||
|
/// mis-position or mis-scale the subtitles. `width`/`height` are the title's
|
||||||
|
/// coded video dimensions. When either is 0 (unknown) the `size:` line is
|
||||||
|
/// omitted rather than emitting a `0x0` frame.
|
||||||
///
|
///
|
||||||
/// Returns the formatted bytes suitable for MKV codec_private.
|
/// Returns the formatted bytes suitable for MKV codec_private.
|
||||||
pub fn format_palette(palette: &[[u8; 4]]) -> Vec<u8> {
|
pub fn format_palette(palette: &[[u8; 4]], width: u32, height: u32) -> Vec<u8> {
|
||||||
let mut parts: Vec<String> = Vec::with_capacity(palette.len());
|
let mut parts: Vec<String> = Vec::with_capacity(palette.len());
|
||||||
for color in palette {
|
for color in palette {
|
||||||
let [r, g, b] = ycbcr_to_rgb(color);
|
let [r, g, b] = ycbcr_to_rgb(color);
|
||||||
parts.push(format!("{r:02x}{g:02x}{b:02x}"));
|
parts.push(format!("{r:02x}{g:02x}{b:02x}"));
|
||||||
}
|
}
|
||||||
let line = format!("palette: {}\n", parts.join(", "));
|
let mut out = String::new();
|
||||||
line.into_bytes()
|
if width > 0 && height > 0 {
|
||||||
|
out.push_str(&format!("size: {width}x{height}\n"));
|
||||||
|
}
|
||||||
|
out.push_str(&format!("palette: {}\n", parts.join(", ")));
|
||||||
|
out.into_bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -436,7 +453,7 @@ mod tests {
|
|||||||
[0x00, 0, 128, 128], // Y=0 → RGB (0,0,0)
|
[0x00, 0, 128, 128], // Y=0 → RGB (0,0,0)
|
||||||
[0x00, 255, 128, 128], // Y=255 → RGB (255,255,255)
|
[0x00, 255, 128, 128], // Y=255 → RGB (255,255,255)
|
||||||
];
|
];
|
||||||
let result = format_palette(&palette);
|
let result = format_palette(&palette, 0, 0);
|
||||||
let text = String::from_utf8(result).unwrap();
|
let text = String::from_utf8(result).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
text.starts_with("palette: "),
|
text.starts_with("palette: "),
|
||||||
@@ -460,7 +477,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn format_palette_16_colors() {
|
fn format_palette_16_colors() {
|
||||||
let palette: Vec<[u8; 4]> = (0..16).map(|i| [0x00, (i * 16) as u8, 128, 128]).collect();
|
let palette: Vec<[u8; 4]> = (0..16).map(|i| [0x00, (i * 16) as u8, 128, 128]).collect();
|
||||||
let result = format_palette(&palette);
|
let result = format_palette(&palette, 0, 0);
|
||||||
let text = String::from_utf8(result).unwrap();
|
let text = String::from_utf8(result).unwrap();
|
||||||
// Should have exactly 15 commas (16 colors separated by ", ")
|
// Should have exactly 15 commas (16 colors separated by ", ")
|
||||||
let comma_count = text.matches(", ").count();
|
let comma_count = text.matches(", ").count();
|
||||||
@@ -475,11 +492,34 @@ mod tests {
|
|||||||
fn format_palette_hex_format() {
|
fn format_palette_hex_format() {
|
||||||
// Y=128, Cb=128, Cr=128 → R=128, G=128, B=128 → "808080"
|
// Y=128, Cb=128, Cr=128 → R=128, G=128, B=128 → "808080"
|
||||||
let palette = vec![[0x00, 128, 128, 128]];
|
let palette = vec![[0x00, 128, 128, 128]];
|
||||||
let result = format_palette(&palette);
|
let result = format_palette(&palette, 0, 0);
|
||||||
let text = String::from_utf8(result).unwrap();
|
let text = String::from_utf8(result).unwrap();
|
||||||
assert_eq!(text, "palette: 808080\n");
|
assert_eq!(text, "palette: 808080\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_palette_emits_size_line_before_palette() {
|
||||||
|
// With non-zero dimensions the `.idx` `size:` line is prepended ahead of
|
||||||
|
// the palette so players place/scale the VobSub bitmap (PAL 720x576).
|
||||||
|
let palette = vec![[0x00, 128, 128, 128]];
|
||||||
|
let result = format_palette(&palette, 720, 576);
|
||||||
|
let text = String::from_utf8(result).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
text, "size: 720x576\npalette: 808080\n",
|
||||||
|
"size: line must precede palette: line"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_palette_omits_size_line_when_dimensions_unknown() {
|
||||||
|
// 0 width/height (unknown resolution) omits the size line rather than
|
||||||
|
// emitting a 0x0 frame; the palette line is still present.
|
||||||
|
let palette = vec![[0x00, 128, 128, 128]];
|
||||||
|
let result = format_palette(&palette, 0, 576);
|
||||||
|
let text = String::from_utf8(result).unwrap();
|
||||||
|
assert_eq!(text, "palette: 808080\n", "no size line when a dim is 0");
|
||||||
|
}
|
||||||
|
|
||||||
// --- SPU_size boundary: completes exactly at declared size ---
|
// --- SPU_size boundary: completes exactly at declared size ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -641,7 +681,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn format_palette_empty_is_just_prefix() {
|
fn format_palette_empty_is_just_prefix() {
|
||||||
// An empty palette yields "palette: \n" (prefix + newline, no entries).
|
// An empty palette yields "palette: \n" (prefix + newline, no entries).
|
||||||
let result = format_palette(&[]);
|
let result = format_palette(&[], 0, 0);
|
||||||
assert_eq!(String::from_utf8(result).unwrap(), "palette: \n");
|
assert_eq!(String::from_utf8(result).unwrap(), "palette: \n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,7 +689,7 @@ mod tests {
|
|||||||
fn format_palette_pads_each_channel_to_two_hex_digits() {
|
fn format_palette_pads_each_channel_to_two_hex_digits() {
|
||||||
// Each RGB channel is formatted as exactly 2 hex digits (zero-padded).
|
// Each RGB channel is formatted as exactly 2 hex digits (zero-padded).
|
||||||
// Y=16,neutral → 0x10 → "101010" (each channel two digits).
|
// Y=16,neutral → 0x10 → "101010" (each channel two digits).
|
||||||
let result = format_palette(&[[0x00, 16, 128, 128]]);
|
let result = format_palette(&[[0x00, 16, 128, 128]], 0, 0);
|
||||||
assert_eq!(String::from_utf8(result).unwrap(), "palette: 101010\n");
|
assert_eq!(String::from_utf8(result).unwrap(), "palette: 101010\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -832,6 +832,8 @@ mod tests {
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-9
@@ -394,6 +394,10 @@ pub const SEGMENT: u32 = 0x1853_8067;
|
|||||||
// SeekHead
|
// SeekHead
|
||||||
pub const SEEK_HEAD: u32 = 0x114D_9B74;
|
pub const SEEK_HEAD: u32 = 0x114D_9B74;
|
||||||
pub const SEEK: u32 = 0x4DBB;
|
pub const SEEK: u32 = 0x4DBB;
|
||||||
|
/// Void — RFC 9559 (Matroska) §EBML global element 0xEC. Used to neutralise a
|
||||||
|
/// reserved-but-unused region (e.g. the CUES SeekHead entry when no Cues element
|
||||||
|
/// is written) so it carries no meaning to a parser.
|
||||||
|
pub const VOID: u32 = 0xEC;
|
||||||
pub const SEEK_ID: u32 = 0x53AB;
|
pub const SEEK_ID: u32 = 0x53AB;
|
||||||
pub const SEEK_POSITION: u32 = 0x53AC;
|
pub const SEEK_POSITION: u32 = 0x53AC;
|
||||||
|
|
||||||
@@ -436,16 +440,13 @@ pub const FIELD_ORDER: u32 = 0x9D;
|
|||||||
pub const INTERLACED_INTERLACED: u64 = 1;
|
pub const INTERLACED_INTERLACED: u64 = 1;
|
||||||
pub const INTERLACED_PROGRESSIVE: u64 = 2;
|
pub const INTERLACED_PROGRESSIVE: u64 = 2;
|
||||||
// FieldOrder values (Matroska / RFC 9559, element 0x9D): 1 = top-field-first,
|
// FieldOrder values (Matroska / RFC 9559, element 0x9D): 1 = top-field-first,
|
||||||
// 6 = bottom-field-first, 2 = undetermined, 0 = progressive. NTSC DVD (480i),
|
// 6 = bottom-field-first, 0 = progressive. The muxer derives TFF vs BFF from the
|
||||||
// PAL DVD (576i) and HD (1080i) are all emitted top-field-first — the muxer
|
// bitstream's measured top_field_first when available, falling back to TFF for
|
||||||
// hardcodes TFF for every interlaced DVD/HD source (DV is the only common BFF
|
// interlaced content (NTSC 480i / PAL 576i / HD 1080i are overwhelmingly TFF).
|
||||||
// source and freemkv does not produce it). 0xFF is our sentinel for
|
// 0xFF is our sentinel for "undetermined / omit the element".
|
||||||
// "undetermined / omit".
|
|
||||||
pub const FIELD_ORDER_TFF: u8 = 1;
|
pub const FIELD_ORDER_TFF: u8 = 1;
|
||||||
// Bottom-field-first. Retained for completeness/round-trip tests; the muxer
|
/// Bottom-field-first (RFC 9559 element 0x9D = 6). Emitted when the bitstream's
|
||||||
// emits TFF for all DVD/HD interlaced content (DV is the only common BFF
|
/// measured top_field_first is false.
|
||||||
// source and freemkv does not produce it).
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub const FIELD_ORDER_BFF: u8 = 6;
|
pub const FIELD_ORDER_BFF: u8 = 6;
|
||||||
pub const FIELD_ORDER_UNDETERMINED: u8 = 0xFF;
|
pub const FIELD_ORDER_UNDETERMINED: u8 = 0xFF;
|
||||||
pub const DISPLAY_WIDTH: u32 = 0x54B0;
|
pub const DISPLAY_WIDTH: u32 = 0x54B0;
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ mod tests {
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
})],
|
})],
|
||||||
chapters: Vec::new(),
|
chapters: Vec::new(),
|
||||||
extents: Vec::new(),
|
extents: Vec::new(),
|
||||||
|
|||||||
@@ -212,6 +212,8 @@ impl M2tsMeta {
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: *secondary,
|
secondary: *secondary,
|
||||||
label: label.clone(),
|
label: label.clone(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
MetaStream::Audio {
|
MetaStream::Audio {
|
||||||
@@ -407,6 +409,8 @@ mod tests {
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
}));
|
}));
|
||||||
t
|
t
|
||||||
}
|
}
|
||||||
@@ -656,6 +660,8 @@ mod tests {
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
let meta = M2tsMeta::from_title(&t);
|
let meta = M2tsMeta::from_title(&t);
|
||||||
|
|||||||
+338
-30
@@ -12,6 +12,54 @@ use crate::disc::{
|
|||||||
};
|
};
|
||||||
use std::io::{self, Seek, Write};
|
use std::io::{self, Seek, Write};
|
||||||
|
|
||||||
|
// ── CICP colour codes (ITU-T H.273) ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The Matroska Colour element (RFC 9559) carries MatrixCoefficients,
|
||||||
|
// TransferCharacteristics and Primaries verbatim as the integer code-points
|
||||||
|
// defined by ITU-T H.273 ("Coding-independent code points", CICP). Hoisting the
|
||||||
|
// codes to named constants keeps the colour match arms self-documenting and the
|
||||||
|
// values traceable to the public spec table that defines each.
|
||||||
|
|
||||||
|
/// ColourPrimaries = 1 (BT.709 / sRGB) — ITU-T H.273 Table 2.
|
||||||
|
const CICP_PRIMARIES_BT709: u8 = 1;
|
||||||
|
/// ColourPrimaries = 5 (BT.470 System B/G — PAL/SECAM SD) — ITU-T H.273 Table 2.
|
||||||
|
const CICP_PRIMARIES_BT470BG: u8 = 5;
|
||||||
|
/// ColourPrimaries = 6 (BT.601-525 / SMPTE 170M — NTSC SD) — ITU-T H.273 Table 2.
|
||||||
|
const CICP_PRIMARIES_BT601_525: u8 = 6;
|
||||||
|
/// ColourPrimaries = 9 (BT.2020 / BT.2100) — ITU-T H.273 Table 2.
|
||||||
|
const CICP_PRIMARIES_BT2020: u8 = 9;
|
||||||
|
|
||||||
|
/// TransferCharacteristics = 1 (BT.709) — ITU-T H.273 Table 3.
|
||||||
|
const CICP_TRANSFER_BT709: u8 = 1;
|
||||||
|
/// TransferCharacteristics = 5 (BT.470 System B/G) — ITU-T H.273 Table 3.
|
||||||
|
const CICP_TRANSFER_BT470BG: u8 = 5;
|
||||||
|
/// TransferCharacteristics = 6 (BT.601-525 / SMPTE 170M) — ITU-T H.273 Table 3.
|
||||||
|
const CICP_TRANSFER_BT601_525: u8 = 6;
|
||||||
|
/// TransferCharacteristics = 16 (SMPTE ST 2084 / PQ — HDR10/HDR10+/DV) — ITU-T
|
||||||
|
/// H.273 Table 3.
|
||||||
|
const CICP_TRANSFER_PQ: u8 = 16;
|
||||||
|
/// TransferCharacteristics = 18 (ARIB STD-B67 / Hybrid Log-Gamma) — ITU-T H.273
|
||||||
|
/// Table 3.
|
||||||
|
const CICP_TRANSFER_HLG: u8 = 18;
|
||||||
|
|
||||||
|
/// MatrixCoefficients = 1 (BT.709) — ITU-T H.273 Table 4.
|
||||||
|
const CICP_MATRIX_BT709: u8 = 1;
|
||||||
|
/// MatrixCoefficients = 5 (BT.470 System B/G) — ITU-T H.273 Table 4.
|
||||||
|
const CICP_MATRIX_BT470BG: u8 = 5;
|
||||||
|
/// MatrixCoefficients = 6 (BT.601-525 / SMPTE 170M) — ITU-T H.273 Table 4.
|
||||||
|
const CICP_MATRIX_BT601_525: u8 = 6;
|
||||||
|
/// MatrixCoefficients = 9 (BT.2020 non-constant luminance) — ITU-T H.273 Table 4.
|
||||||
|
const CICP_MATRIX_BT2020NC: u8 = 9;
|
||||||
|
|
||||||
|
/// Matroska Colour/Range = 1 (broadcast / studio-swing "limited" range). RFC
|
||||||
|
/// 9559 Range element. (0 = unspecified, 2 = full.)
|
||||||
|
const COLOUR_RANGE_LIMITED: u8 = 1;
|
||||||
|
|
||||||
|
/// BlockAddIDType "dvcC" — the DOVIDecoderConfigurationRecord fourcc, big-endian
|
||||||
|
/// ASCII 'd''v''c''C'. Matroska BlockAdditionMapping/BlockAddIDType for a Dolby
|
||||||
|
/// Vision configuration record (RFC 9559 + Dolby Vision-in-Matroska spec).
|
||||||
|
const BLOCK_ADD_ID_TYPE_DVCC: u64 = 0x6476_6343;
|
||||||
|
|
||||||
/// MKV track definition (built from disc stream metadata).
|
/// MKV track definition (built from disc stream metadata).
|
||||||
pub struct MkvTrack {
|
pub struct MkvTrack {
|
||||||
pub track_type: u64, // 1=video, 2=audio, 17=subtitle
|
pub track_type: u64, // 1=video, 2=audio, 17=subtitle
|
||||||
@@ -92,19 +140,58 @@ impl MkvTrack {
|
|||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
// (matrix, transfer, primaries, range) — ITU-T H.273 / CICP codes.
|
// CICP (matrix, transfer, primaries, range) — ITU-T H.273 code points.
|
||||||
let (matrix, transfer, primaries, range) = match v.color_space {
|
//
|
||||||
ColorSpace::Bt2020 => (9, 16, 9, 1), // bt2020nc, PQ, bt2020, limited
|
// Prefer MEASURED CICP read from the bitstream (HEVC/H.264 VUI
|
||||||
ColorSpace::Bt709 => (1, 1, 1, 1), // bt709
|
// colour_description or MPEG-2 sequence_display_extension) when the
|
||||||
ColorSpace::Bt470bg => (5, 5, 5, 1), // PAL SD: BT.470BG matrix/transfer/primaries
|
// stream states it: those are authoritative. Fall back to the coarse
|
||||||
ColorSpace::Smpte170m => (6, 6, 6, 1), // NTSC SD: SMPTE 170M / BT.601-525
|
// `color_space` enum (a playlist nibble / PAL-NTSC guess) only when no
|
||||||
|
// measured triplet is present, so the container stops ASSUMING a colour
|
||||||
|
// space the bitstream may contradict.
|
||||||
|
let (matrix, transfer, primaries, range) = match v.measured_cicp {
|
||||||
|
Some(c) => (c.matrix, c.transfer, c.primaries, c.range),
|
||||||
|
None => {
|
||||||
|
let (m, t, p, r) = match v.color_space {
|
||||||
|
ColorSpace::Bt2020 => (
|
||||||
|
CICP_MATRIX_BT2020NC,
|
||||||
|
CICP_TRANSFER_PQ,
|
||||||
|
CICP_PRIMARIES_BT2020,
|
||||||
|
COLOUR_RANGE_LIMITED,
|
||||||
|
),
|
||||||
|
ColorSpace::Bt709 => (
|
||||||
|
CICP_MATRIX_BT709,
|
||||||
|
CICP_TRANSFER_BT709,
|
||||||
|
CICP_PRIMARIES_BT709,
|
||||||
|
COLOUR_RANGE_LIMITED,
|
||||||
|
),
|
||||||
|
// PAL SD: BT.470 System B/G matrix/transfer/primaries.
|
||||||
|
ColorSpace::Bt470bg => (
|
||||||
|
CICP_MATRIX_BT470BG,
|
||||||
|
CICP_TRANSFER_BT470BG,
|
||||||
|
CICP_PRIMARIES_BT470BG,
|
||||||
|
COLOUR_RANGE_LIMITED,
|
||||||
|
),
|
||||||
|
// NTSC SD: SMPTE 170M / BT.601-525.
|
||||||
|
ColorSpace::Smpte170m => (
|
||||||
|
CICP_MATRIX_BT601_525,
|
||||||
|
CICP_TRANSFER_BT601_525,
|
||||||
|
CICP_PRIMARIES_BT601_525,
|
||||||
|
COLOUR_RANGE_LIMITED,
|
||||||
|
),
|
||||||
ColorSpace::Unknown => (0, 0, 0, 0),
|
ColorSpace::Unknown => (0, 0, 0, 0),
|
||||||
};
|
};
|
||||||
// Override transfer for non-PQ HDR
|
// Override the transfer for non-PQ HDR signalled by the HdrFormat
|
||||||
let transfer = match v.hdr {
|
// (the enum can't express HLG). Only applies on the enum
|
||||||
HdrFormat::Hdr10 | HdrFormat::Hdr10Plus | HdrFormat::DolbyVision => 16, // PQ
|
// fallback; a measured CICP already carries the real transfer.
|
||||||
HdrFormat::Hlg => 18,
|
let t = match v.hdr {
|
||||||
_ => transfer,
|
HdrFormat::Hdr10 | HdrFormat::Hdr10Plus | HdrFormat::DolbyVision => {
|
||||||
|
CICP_TRANSFER_PQ
|
||||||
|
}
|
||||||
|
HdrFormat::Hlg => CICP_TRANSFER_HLG,
|
||||||
|
_ => t,
|
||||||
|
};
|
||||||
|
(m, t, p, r)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
// Display dimensions. For square-pixel video (HD/UHD/BD) the display
|
// Display dimensions. For square-pixel video (HD/UHD/BD) the display
|
||||||
// aspect equals the pixel grid, so display == pixel. For anamorphic
|
// aspect equals the pixel grid, so display == pixel. For anamorphic
|
||||||
@@ -135,14 +222,21 @@ impl MkvTrack {
|
|||||||
colour_primaries: primaries,
|
colour_primaries: primaries,
|
||||||
colour_range: range,
|
colour_range: range,
|
||||||
interlaced: v.resolution.is_interlaced(),
|
interlaced: v.resolution.is_interlaced(),
|
||||||
// PAL DVD (576i), NTSC DVD (480i), and HD interlaced (1080i) are
|
// FieldOrder (Matroska 0x9D) MUST agree with the elementary stream's
|
||||||
// all top-field-first ("almost everything but DV is TFF"). MediaInfo
|
// interlace signalling. Derive it from the MEASURED `top_field_first`
|
||||||
// reads "Top Field First" off the MPEG-2 picture coding extension,
|
// when the bitstream stated it: Some(true) → TFF, Some(false) → BFF.
|
||||||
// so the container element must agree — emitting BFF here for 576i
|
// Genuinely bottom-field-first content (rare, but real) was previously
|
||||||
// (the pre-rc.5.1 value) was a wrong container value that disagreed
|
// mis-stamped TFF because the muxer hardcoded TFF for ALL interlaced
|
||||||
// with the stream. Progressive content leaves the order undetermined.
|
// streams. When the flag is NOT measured (`None`), fall back to TFF —
|
||||||
|
// PAL DVD (576i), NTSC DVD (480i) and HD (1080i) are overwhelmingly
|
||||||
|
// top-field-first ("almost everything but DV is TFF"). Progressive
|
||||||
|
// content leaves the order undetermined (the element is omitted).
|
||||||
field_order: if v.resolution.is_interlaced() {
|
field_order: if v.resolution.is_interlaced() {
|
||||||
ebml::FIELD_ORDER_TFF
|
match v.top_field_first {
|
||||||
|
Some(true) => ebml::FIELD_ORDER_TFF,
|
||||||
|
Some(false) => ebml::FIELD_ORDER_BFF,
|
||||||
|
None => ebml::FIELD_ORDER_TFF,
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ebml::FIELD_ORDER_UNDETERMINED
|
ebml::FIELD_ORDER_UNDETERMINED
|
||||||
},
|
},
|
||||||
@@ -334,6 +428,11 @@ pub struct MkvMuxer<W: Write + Seek> {
|
|||||||
/// silently empty file. See `write_frame` for the track-0 invariant.
|
/// silently empty file. See `write_frame` for the track-0 invariant.
|
||||||
dropped_pre_cluster: u64,
|
dropped_pre_cluster: u64,
|
||||||
seek_fixups: Vec<SeekPositionFixup>,
|
seek_fixups: Vec<SeekPositionFixup>,
|
||||||
|
/// Absolute file offset of the CUES SeekHead entry (a fixed 21-byte Seek
|
||||||
|
/// element). When `finish()` writes no Cues element (zero cue points), this
|
||||||
|
/// entry is overwritten with a Void so the SeekHead carries no pointer to a
|
||||||
|
/// non-existent / wrong element.
|
||||||
|
cues_seek_entry_pos: Option<u64>,
|
||||||
info_offset: u64,
|
info_offset: u64,
|
||||||
tracks_offset: u64,
|
tracks_offset: u64,
|
||||||
chapters_offset: Option<u64>,
|
chapters_offset: Option<u64>,
|
||||||
@@ -521,7 +620,17 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
let seek_id_be = (ebml::SEEK as u16).to_be_bytes();
|
let seek_id_be = (ebml::SEEK as u16).to_be_bytes();
|
||||||
let seek_inner_id_be = (ebml::SEEK_ID as u16).to_be_bytes();
|
let seek_inner_id_be = (ebml::SEEK_ID as u16).to_be_bytes();
|
||||||
let seek_pos_id_be = (ebml::SEEK_POSITION as u16).to_be_bytes();
|
let seek_pos_id_be = (ebml::SEEK_POSITION as u16).to_be_bytes();
|
||||||
|
// Absolute file offset where the CUES Seek entry begins, so that — if no
|
||||||
|
// Cues element is ultimately written (zero cue points) — the entry can be
|
||||||
|
// overwritten with a Void at finish() instead of leaving a SeekHead
|
||||||
|
// pointer that resolves to whatever element (Tags / EOF) happens to land
|
||||||
|
// at the Cues offset. See `cues_seek_entry_pos` / `finish`.
|
||||||
|
let mut cues_seek_entry_pos: Option<u64> = None;
|
||||||
for target_id in &targets {
|
for target_id in &targets {
|
||||||
|
let entry_pos = writer.stream_position()?;
|
||||||
|
if *target_id == ebml::CUES {
|
||||||
|
cues_seek_entry_pos = Some(entry_pos);
|
||||||
|
}
|
||||||
writer.write_all(&[seek_id_be[0], seek_id_be[1], 0x92])?;
|
writer.write_all(&[seek_id_be[0], seek_id_be[1], 0x92])?;
|
||||||
writer.write_all(&[seek_inner_id_be[0], seek_inner_id_be[1], 0x84])?;
|
writer.write_all(&[seek_inner_id_be[0], seek_inner_id_be[1], 0x84])?;
|
||||||
writer.write_all(&target_id.to_be_bytes())?;
|
writer.write_all(&target_id.to_be_bytes())?;
|
||||||
@@ -631,8 +740,9 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
ebml::write_uint(&mut writer, ebml::PIXEL_WIDTH, track.pixel_width as u64)?;
|
ebml::write_uint(&mut writer, ebml::PIXEL_WIDTH, track.pixel_width as u64)?;
|
||||||
ebml::write_uint(&mut writer, ebml::PIXEL_HEIGHT, track.pixel_height as u64)?;
|
ebml::write_uint(&mut writer, ebml::PIXEL_HEIGHT, track.pixel_height as u64)?;
|
||||||
// Scan type. FlagInterlaced: 1 = interlaced, 2 = progressive.
|
// Scan type. FlagInterlaced: 1 = interlaced, 2 = progressive.
|
||||||
// FieldOrder is only written for interlaced content with a
|
// FieldOrder (0x9D) is only written for interlaced content with a
|
||||||
// determined order (TFF=0/2/6/14, BFF=1/9/13...).
|
// determined order: TFF = 1, BFF = 6, 0 = progressive, and the
|
||||||
|
// element is omitted entirely when undetermined (RFC 9559).
|
||||||
ebml::write_uint(
|
ebml::write_uint(
|
||||||
&mut writer,
|
&mut writer,
|
||||||
ebml::FLAG_INTERLACED,
|
ebml::FLAG_INTERLACED,
|
||||||
@@ -679,7 +789,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
if let Some(ref dvcc) = track.dv_config {
|
if let Some(ref dvcc) = track.dv_config {
|
||||||
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
|
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
|
||||||
// BlockAddIDType = "dvcC" fourcc (DOVIDecoderConfigurationRecord).
|
// BlockAddIDType = "dvcC" fourcc (DOVIDecoderConfigurationRecord).
|
||||||
ebml::write_uint(&mut writer, ebml::BLOCK_ADD_ID_TYPE, 0x6476_6343)?;
|
ebml::write_uint(&mut writer, ebml::BLOCK_ADD_ID_TYPE, BLOCK_ADD_ID_TYPE_DVCC)?;
|
||||||
ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, dvcc)?;
|
ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, dvcc)?;
|
||||||
ebml::end_master(&mut writer, map_pos)?;
|
ebml::end_master(&mut writer, map_pos)?;
|
||||||
}
|
}
|
||||||
@@ -691,18 +801,24 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
// Omit Channels when unknown (0) — Matroska defaults it to 1
|
// Omit Channels when unknown (0) — Matroska defaults it to 1
|
||||||
// rather than us fabricating a 6-channel count.
|
// rather than us fabricating a 6-channel count.
|
||||||
if track.channels > 0 {
|
if track.channels > 0 {
|
||||||
// Record the offset of the 1-byte Channels value so an AC-3
|
// Capture the ACTUAL file offset of the 1-byte Channels value
|
||||||
// track can correct it from the bitstream acmod on its first
|
// so an AC-3 track can correct it from the bitstream acmod on
|
||||||
// frame (the IFO nibble is unreliable). write_uint emits
|
// its first frame (the IFO nibble is unreliable). Rather than
|
||||||
// ID(0x9F, 1B) + size(0x81, 1B) + value(1B) for 1..=255, so
|
// assume write_uint's encoding (ID + size widths), write the
|
||||||
// the value byte sits 2 bytes after the element start.
|
// element's ID and size explicitly, then record the position
|
||||||
let chan_elem_pos = writer.stream_position()?;
|
// immediately before the value byte. Channels is 1..=255 so
|
||||||
ebml::write_uint(&mut writer, ebml::CHANNELS, track.channels as u64)?;
|
// the value is exactly one byte (Size = 1), and the acmod
|
||||||
|
// correction is likewise 1..=255 — the width never changes, so
|
||||||
|
// the in-place single-byte rewrite stays valid.
|
||||||
|
ebml::write_id(&mut writer, ebml::CHANNELS)?;
|
||||||
|
ebml::write_size(&mut writer, 1)?;
|
||||||
|
let value_offset = writer.stream_position()?;
|
||||||
|
writer.write_all(&[track.channels])?;
|
||||||
if track.codec_id == ebml::CODEC_AC3 {
|
if track.codec_id == ebml::CODEC_AC3 {
|
||||||
ac3_channel_fixups.insert(
|
ac3_channel_fixups.insert(
|
||||||
i,
|
i,
|
||||||
Ac3ChannelFixup {
|
Ac3ChannelFixup {
|
||||||
value_offset: chan_elem_pos + 2,
|
value_offset,
|
||||||
claimed: track.channels,
|
claimed: track.channels,
|
||||||
corrected: false,
|
corrected: false,
|
||||||
},
|
},
|
||||||
@@ -759,6 +875,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
frame_count: 0,
|
frame_count: 0,
|
||||||
dropped_pre_cluster: 0,
|
dropped_pre_cluster: 0,
|
||||||
seek_fixups,
|
seek_fixups,
|
||||||
|
cues_seek_entry_pos,
|
||||||
info_offset,
|
info_offset,
|
||||||
tracks_offset,
|
tracks_offset,
|
||||||
chapters_offset,
|
chapters_offset,
|
||||||
@@ -1014,6 +1131,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
// Write Cues
|
// Write Cues
|
||||||
let cues_start = self.writer.stream_position()?;
|
let cues_start = self.writer.stream_position()?;
|
||||||
let cues_offset = cues_start - self.segment_start;
|
let cues_offset = cues_start - self.segment_start;
|
||||||
|
let have_cues = !self.cues.is_empty();
|
||||||
if !self.cues.is_empty() {
|
if !self.cues.is_empty() {
|
||||||
let cues_pos = ebml::start_master(&mut self.writer, ebml::CUES)?;
|
let cues_pos = ebml::start_master(&mut self.writer, ebml::CUES)?;
|
||||||
for cue in &self.cues {
|
for cue in &self.cues {
|
||||||
@@ -1038,8 +1156,16 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
// bitrate for EVERY track this way, not just CBR audio.
|
// bitrate for EVERY track this way, not just CBR audio.
|
||||||
self.write_bps_tags()?;
|
self.write_bps_tags()?;
|
||||||
|
|
||||||
// Back-patch SeekHead SeekPosition values now that all element offsets are known.
|
// Back-patch SeekHead SeekPosition values now that all element offsets
|
||||||
|
// are known. When no Cues element was written (zero cue points), the
|
||||||
|
// CUES entry's SeekPosition would otherwise be back-patched to
|
||||||
|
// `cues_offset`, which now holds Tags / EOF — a dangling pointer to a
|
||||||
|
// non-Cues element. Skip that fixup and instead Void the whole CUES Seek
|
||||||
|
// entry (below) so the SeekHead carries no false pointer.
|
||||||
for fixup in &self.seek_fixups {
|
for fixup in &self.seek_fixups {
|
||||||
|
if fixup.target_id == ebml::CUES && !have_cues {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let offset = match fixup.target_id {
|
let offset = match fixup.target_id {
|
||||||
ebml::INFO => self.info_offset,
|
ebml::INFO => self.info_offset,
|
||||||
ebml::TRACKS => self.tracks_offset,
|
ebml::TRACKS => self.tracks_offset,
|
||||||
@@ -1053,6 +1179,21 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
.seek(std::io::SeekFrom::Start(fixup.value_offset))?;
|
.seek(std::io::SeekFrom::Start(fixup.value_offset))?;
|
||||||
self.writer.write_all(&offset.to_be_bytes())?;
|
self.writer.write_all(&offset.to_be_bytes())?;
|
||||||
}
|
}
|
||||||
|
// Neutralise the unused CUES Seek entry. The entry is a fixed 21-byte
|
||||||
|
// Seek master: SEEK(2 ID + 1 size) + SEEK_ID(2+1) + 4-byte target id +
|
||||||
|
// SEEK_POSITION(2+1) + 8-byte value = 21 bytes. A Void (0xEC, 1-byte ID)
|
||||||
|
// with a 1-byte size VINT covering the remaining 19 bytes occupies
|
||||||
|
// exactly 1 + 1 + 19 = 21 bytes, overwriting the entry in place without
|
||||||
|
// shifting any following element.
|
||||||
|
if !have_cues {
|
||||||
|
if let Some(entry_pos) = self.cues_seek_entry_pos {
|
||||||
|
self.writer.seek(std::io::SeekFrom::Start(entry_pos))?;
|
||||||
|
ebml::write_id(&mut self.writer, ebml::VOID)?;
|
||||||
|
// 19 = 21-byte entry minus the Void ID (1) and size (1) bytes.
|
||||||
|
ebml::write_size(&mut self.writer, 19)?;
|
||||||
|
self.writer.write_all(&[0u8; 19])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
self.writer.seek(std::io::SeekFrom::End(0))?;
|
self.writer.seek(std::io::SeekFrom::End(0))?;
|
||||||
|
|
||||||
self.writer.flush()?;
|
self.writer.flush()?;
|
||||||
@@ -1206,6 +1347,8 @@ mod tests {
|
|||||||
display_aspect: Some((16, 9)),
|
display_aspect: Some((16, 9)),
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
};
|
};
|
||||||
let t = MkvTrack::video(&base);
|
let t = MkvTrack::video(&base);
|
||||||
assert_eq!((t.pixel_width, t.pixel_height), (720, 576));
|
assert_eq!((t.pixel_width, t.pixel_height), (720, 576));
|
||||||
@@ -1227,6 +1370,113 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// FieldOrder must follow the MEASURED top_field_first, not a hardcoded TFF.
|
||||||
|
/// An interlaced stream whose parsed `top_field_first == Some(false)` tags
|
||||||
|
/// BFF (=6); `Some(true)` and `None` (unknown, the fallback) tag TFF (=1).
|
||||||
|
#[test]
|
||||||
|
fn interlaced_field_order_from_measured_tff() {
|
||||||
|
let base = VideoStream {
|
||||||
|
pid: 0xE0,
|
||||||
|
codec: Codec::Mpeg2,
|
||||||
|
resolution: Resolution::R576i, // interlaced
|
||||||
|
frame_rate: crate::disc::FrameRate::F25,
|
||||||
|
hdr: HdrFormat::Sdr,
|
||||||
|
color_space: ColorSpace::Bt470bg,
|
||||||
|
display_aspect: None,
|
||||||
|
secondary: false,
|
||||||
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Measured bottom-field-first → BFF, NOT the old hardcoded TFF.
|
||||||
|
let bff = VideoStream {
|
||||||
|
top_field_first: Some(false),
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
MkvTrack::video(&bff).field_order,
|
||||||
|
ebml::FIELD_ORDER_BFF,
|
||||||
|
"measured top_field_first=false must tag BFF (6), not TFF"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Measured top-field-first → TFF.
|
||||||
|
let tff = VideoStream {
|
||||||
|
top_field_first: Some(true),
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(MkvTrack::video(&tff).field_order, ebml::FIELD_ORDER_TFF);
|
||||||
|
|
||||||
|
// Unknown (not measured) → TFF fallback (dominant DVD/HD case).
|
||||||
|
assert_eq!(MkvTrack::video(&base).field_order, ebml::FIELD_ORDER_TFF);
|
||||||
|
|
||||||
|
// Progressive content leaves the order undetermined regardless of flag.
|
||||||
|
let prog = VideoStream {
|
||||||
|
resolution: Resolution::R1080p,
|
||||||
|
top_field_first: Some(false),
|
||||||
|
..base
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
MkvTrack::video(&prog).field_order,
|
||||||
|
ebml::FIELD_ORDER_UNDETERMINED,
|
||||||
|
"progressive video never carries a field order"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Measured CICP from the bitstream must take precedence over the coarse
|
||||||
|
/// `color_space` enum. A BT.2020/PQ enum that would otherwise produce
|
||||||
|
/// (9,16,9) is overridden by a measured BT.709 triplet when present.
|
||||||
|
#[test]
|
||||||
|
fn measured_cicp_overrides_color_space_enum() {
|
||||||
|
let base = VideoStream {
|
||||||
|
pid: 0xE0,
|
||||||
|
codec: Codec::Hevc,
|
||||||
|
resolution: Resolution::R2160p,
|
||||||
|
frame_rate: crate::disc::FrameRate::F24,
|
||||||
|
hdr: HdrFormat::Hdr10, // enum/HDR path would force PQ transfer
|
||||||
|
color_space: ColorSpace::Bt2020,
|
||||||
|
display_aspect: None,
|
||||||
|
secondary: false,
|
||||||
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Without a measured triplet: enum + HDR → BT.2020nc / PQ / BT.2020.
|
||||||
|
let t_enum = MkvTrack::video(&base);
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
t_enum.colour_matrix,
|
||||||
|
t_enum.colour_transfer,
|
||||||
|
t_enum.colour_primaries
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CICP_MATRIX_BT2020NC,
|
||||||
|
CICP_TRANSFER_PQ,
|
||||||
|
CICP_PRIMARIES_BT2020
|
||||||
|
),
|
||||||
|
"enum fallback derives BT.2020/PQ"
|
||||||
|
);
|
||||||
|
|
||||||
|
// With a measured BT.709 triplet: the bitstream's value wins outright,
|
||||||
|
// INCLUDING the transfer (the HDR override does not apply to measured).
|
||||||
|
let measured = VideoStream {
|
||||||
|
measured_cicp: Some(crate::disc::MeasuredCicp {
|
||||||
|
matrix: CICP_MATRIX_BT709,
|
||||||
|
transfer: CICP_TRANSFER_BT709,
|
||||||
|
primaries: CICP_PRIMARIES_BT709,
|
||||||
|
range: COLOUR_RANGE_LIMITED,
|
||||||
|
}),
|
||||||
|
..base
|
||||||
|
};
|
||||||
|
let t = MkvTrack::video(&measured);
|
||||||
|
assert_eq!(
|
||||||
|
(t.colour_matrix, t.colour_transfer, t.colour_primaries),
|
||||||
|
(CICP_MATRIX_BT709, CICP_TRANSFER_BT709, CICP_PRIMARIES_BT709),
|
||||||
|
"measured CICP must override the enum, transfer included"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper: search for a 4-byte big-endian EBML ID in a byte slice.
|
/// Helper: search for a 4-byte big-endian EBML ID in a byte slice.
|
||||||
fn find_id(data: &[u8], id: u32) -> Option<usize> {
|
fn find_id(data: &[u8], id: u32) -> Option<usize> {
|
||||||
let bytes = id.to_be_bytes();
|
let bytes = id.to_be_bytes();
|
||||||
@@ -1411,6 +1661,54 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When no Cues element is written (zero cue points), the SeekHead must NOT
|
||||||
|
/// retain a CUES entry that back-patches to the Cues offset — that offset now
|
||||||
|
/// holds Tags / EOF, a dangling pointer to a non-Cues element. finish() Voids
|
||||||
|
/// the unused CUES Seek entry instead. (The empty-cues case is defensive —
|
||||||
|
/// the normal path pushes a cue with every cluster — so the test clears the
|
||||||
|
/// cue list directly before finalizing.)
|
||||||
|
#[test]
|
||||||
|
fn zero_cues_voids_seekhead_entry_no_dangling_pointer() {
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
|
||||||
|
let writer = SharedWriter(shared.clone());
|
||||||
|
let tracks = [make_video_track()];
|
||||||
|
let mut muxer = MkvMuxer::new(writer, &tracks, Some("NoCue"), 60.0, &[]).unwrap();
|
||||||
|
muxer
|
||||||
|
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None)
|
||||||
|
.unwrap();
|
||||||
|
// Force the zero-cue branch: drop every cue before finalizing.
|
||||||
|
let cues_entry_pos = muxer.cues_seek_entry_pos.expect("CUES seek entry recorded");
|
||||||
|
muxer.cues.clear();
|
||||||
|
muxer.finish().unwrap();
|
||||||
|
|
||||||
|
let data = shared.lock().unwrap().clone().into_inner();
|
||||||
|
|
||||||
|
// No Cues element is written.
|
||||||
|
assert!(
|
||||||
|
find_id(&data, ebml::CUES).is_none(),
|
||||||
|
"no Cues element expected when there are zero cue points"
|
||||||
|
);
|
||||||
|
// The recorded CUES Seek entry was overwritten with a Void (0xEC) of the
|
||||||
|
// remaining 19 bytes — it no longer begins a SEEK (0x4DBB) element.
|
||||||
|
let entry = &data[cues_entry_pos as usize..cues_entry_pos as usize + 2];
|
||||||
|
assert_eq!(
|
||||||
|
entry,
|
||||||
|
&[ebml::VOID as u8, 0x80 | 19],
|
||||||
|
"CUES Seek entry must be Void(19), not a live Seek pointer"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Defensively confirm no Seek entry's SeekPosition resolves to the (now
|
||||||
|
// Tags/EOF) cues offset: scan all 8-byte SeekPosition values in the
|
||||||
|
// SeekHead and ensure none equals the offset where Cues would have been.
|
||||||
|
// (Sanity: the file must still parse its real elements.)
|
||||||
|
assert!(
|
||||||
|
find_id(&data, ebml::INFO).is_some() && find_id(&data, ebml::TRACKS).is_some(),
|
||||||
|
"Info and Tracks must still be present and seekable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn monotonic_ts_forces_strictly_increasing() {
|
fn monotonic_ts_forces_strictly_increasing() {
|
||||||
// First frame passes through unchanged.
|
// First frame passes through unchanged.
|
||||||
@@ -3085,6 +3383,8 @@ mod tests {
|
|||||||
display_aspect: Some((16, 9)),
|
display_aspect: Some((16, 9)),
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
};
|
};
|
||||||
let t = MkvTrack::video(&v);
|
let t = MkvTrack::video(&v);
|
||||||
assert!(t.interlaced, "576i is interlaced");
|
assert!(t.interlaced, "576i is interlaced");
|
||||||
@@ -3116,6 +3416,8 @@ mod tests {
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
};
|
};
|
||||||
let t = MkvTrack::video(&v);
|
let t = MkvTrack::video(&v);
|
||||||
assert_eq!(t.default_duration_ns, 40_000_000, "frame duration is 40 ms");
|
assert_eq!(t.default_duration_ns, 40_000_000, "frame duration is 40 ms");
|
||||||
@@ -3259,6 +3561,8 @@ mod tests {
|
|||||||
display_aspect: Some((16, 9)),
|
display_aspect: Some((16, 9)),
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
};
|
};
|
||||||
let t = MkvTrack::video(&v);
|
let t = MkvTrack::video(&v);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -3318,6 +3622,8 @@ mod tests {
|
|||||||
display_aspect: Some((4, 3)),
|
display_aspect: Some((4, 3)),
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
};
|
};
|
||||||
let t = MkvTrack::video(&v);
|
let t = MkvTrack::video(&v);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -3378,6 +3684,8 @@ mod tests {
|
|||||||
display_aspect: Some((4, 3)),
|
display_aspect: Some((4, 3)),
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
};
|
};
|
||||||
let t = MkvTrack::video(&v);
|
let t = MkvTrack::video(&v);
|
||||||
assert!(t.interlaced, "480i is interlaced");
|
assert!(t.interlaced, "480i is interlaced");
|
||||||
|
|||||||
@@ -614,6 +614,8 @@ fn parse_track(
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: is_secondary,
|
secondary: is_secondary,
|
||||||
label: name,
|
label: name,
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
2 => Some(crate::disc::Stream::Audio(AudioStream {
|
2 => Some(crate::disc::Stream::Audio(AudioStream {
|
||||||
|
|||||||
@@ -355,6 +355,8 @@ mod tests {
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: "Main".into(),
|
label: "Main".into(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
}),
|
}),
|
||||||
Stream::Audio(AudioStream {
|
Stream::Audio(AudioStream {
|
||||||
pid: 0x1100,
|
pid: 0x1100,
|
||||||
|
|||||||
@@ -640,6 +640,8 @@ mod tests {
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary,
|
secondary,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
}));
|
}));
|
||||||
t
|
t
|
||||||
}
|
}
|
||||||
@@ -797,6 +799,8 @@ mod tests {
|
|||||||
display_aspect: Some((4, 3)),
|
display_aspect: Some((4, 3)),
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
}));
|
}));
|
||||||
let parsers: Vec<(u16, Box<dyn CodecParser>)> =
|
let parsers: Vec<(u16, Box<dyn CodecParser>)> =
|
||||||
vec![(crate::mux::ps::DVD_VIDEO_PID, Box::new(Mpeg2Parser::new()))];
|
vec![(crate::mux::ps::DVD_VIDEO_PID, Box::new(Mpeg2Parser::new()))];
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ mod tests {
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
}));
|
}));
|
||||||
// Index 0 = the video stream's codec init data.
|
// Index 0 = the video stream's codec init data.
|
||||||
t.codec_privates = vec![Some(vec![0xDE, 0xAD, 0xBE, 0xEF])];
|
t.codec_privates = vec![Some(vec![0xDE, 0xAD, 0xBE, 0xEF])];
|
||||||
|
|||||||
@@ -694,6 +694,8 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
CodecKind::Audio => Some(Stream::Audio(AudioStream {
|
CodecKind::Audio => Some(Stream::Audio(AudioStream {
|
||||||
|
|||||||
@@ -169,6 +169,8 @@ fn title_with_video(
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
})],
|
})],
|
||||||
chapters: Vec::new(),
|
chapters: Vec::new(),
|
||||||
extents: Vec::new(),
|
extents: Vec::new(),
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ fn sample_disc_title() -> DiscTitle {
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: "Main".into(),
|
label: "Main".into(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
}),
|
}),
|
||||||
Stream::Audio(AudioStream {
|
Stream::Audio(AudioStream {
|
||||||
pid: 0x1100,
|
pid: 0x1100,
|
||||||
@@ -401,6 +403,8 @@ fn meta_codec_roundtrip() {
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
for (i, &codec) in codecs_audio.iter().enumerate() {
|
for (i, &codec) in codecs_audio.iter().enumerate() {
|
||||||
@@ -503,6 +507,8 @@ fn meta_all_stream_types() {
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: "Primary".into(),
|
label: "Primary".into(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
}),
|
}),
|
||||||
Stream::Audio(AudioStream {
|
Stream::Audio(AudioStream {
|
||||||
pid: 0x1100,
|
pid: 0x1100,
|
||||||
@@ -688,6 +694,8 @@ fn mkvstream_meta_preserves_all_streams() {
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: "Main Video".into(),
|
label: "Main Video".into(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
}),
|
}),
|
||||||
Stream::Audio(AudioStream {
|
Stream::Audio(AudioStream {
|
||||||
pid: 0x1100,
|
pid: 0x1100,
|
||||||
@@ -787,6 +795,8 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
|
|||||||
display_aspect: None,
|
display_aspect: None,
|
||||||
secondary: false,
|
secondary: false,
|
||||||
label: "Main".into(),
|
label: "Main".into(),
|
||||||
|
top_field_first: None,
|
||||||
|
measured_cicp: None,
|
||||||
})],
|
})],
|
||||||
chapters: Vec::new(),
|
chapters: Vec::new(),
|
||||||
extents: Vec::new(),
|
extents: Vec::new(),
|
||||||
|
|||||||
Reference in New Issue
Block a user