From e0ce0357650d8e8804c18bd2da8657da2ce7346c Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:47:16 -0700 Subject: [PATCH] Preserve interlaced scan type in label and MKV output Interlacing is detected upstream (PAL DVD -> R576i) but was dropped in two places: the video label hardcoded a 'p' suffix, and the muxer never wrote any scan-type flag, so MediaInfo inferred progressive and reported 576p for a 576i source. - Add Resolution::is_interlaced() for the R*i variants. - generate_video_label now branches i/p for the heights that can be interlaced (1080, 576, 480) instead of always emitting 'p'. - MkvTrack carries interlaced + field_order; the video serializer emits FlagInterlaced (0x9A; 1=interlaced, 2=progressive) and, for interlaced content, FieldOrder (0x9D) - bottom-field-first for PAL 576i, top-field-first otherwise. Adds the EBML constants. --- src/disc/mod.rs | 8 +++++ src/labels/mod.rs | 64 ++++++++++++++++++++++++++++++++--- src/mux/ebml.rs | 12 +++++++ src/mux/mkv.rs | 86 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 5 deletions(-) diff --git a/src/disc/mod.rs b/src/disc/mod.rs index c8acf2f..32ee006 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -655,6 +655,14 @@ impl Resolution { matches!(self, Resolution::R2160p | Resolution::R4320p) } + /// True if this is an interlaced resolution (the `R*i` variants). + pub fn is_interlaced(&self) -> bool { + matches!( + self, + Resolution::R480i | Resolution::R576i | Resolution::R1080i + ) + } + /// True if this is an HD (720p+) resolution. pub fn is_hd(&self) -> bool { !matches!( diff --git a/src/labels/mod.rs b/src/labels/mod.rs index cb3478f..766672b 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -296,7 +296,13 @@ pub fn fill_defaults(titles: &mut [crate::disc::DiscTitle]) { } else { v.resolution.pixels() }; - v.label = generate_video_label(&v.codec, px, &v.hdr, v.secondary); + v.label = generate_video_label( + &v.codec, + px, + v.resolution.is_interlaced(), + &v.hdr, + v.secondary, + ); } Stream::Subtitle(s) if s.forced => { // Ensure forced subs are labeled even if BD-J didn't set a name @@ -311,6 +317,7 @@ pub fn fill_defaults(titles: &mut [crate::disc::DiscTitle]) { fn generate_video_label( codec: &crate::disc::Codec, pixels: (u32, u32), + interlaced: bool, hdr: &crate::disc::HdrFormat, secondary: bool, ) -> String { @@ -331,20 +338,22 @@ fn generate_video_label( // Codec parts.push(codec.name().to_string()); - // Resolution + // Resolution. Scan type (i/p) is honored for heights that can be + // interlaced on disc (1080 and SD 576/480); 720/4K/8K are always + // progressive. let (w, h) = pixels; let res = if w >= 7680 { "8K" } else if w >= 3840 { "4K" } else if w >= 1920 { - "1080p" + if interlaced { "1080i" } else { "1080p" } } else if w >= 1280 { "720p" } else if h >= 576 { - "576p" + if interlaced { "576i" } else { "576p" } } else if h >= 480 { - "480p" + if interlaced { "480i" } else { "480p" } } else { "" }; @@ -1736,6 +1745,51 @@ mod apply_tests { } } + /// Spec: an interlaced resolution (`R*i`) must surface the "i" scan type + /// in the generated label, not a hardcoded "p". PAL DVD is 576i. + /// Mutation: hardcode "p" → 576i video mislabeled as 576p. + #[test] + fn fill_defaults_video_label_honors_interlaced_scan_type() { + let interlaced = Stream::Video(VideoStream { + pid: 0x1011, + codec: Codec::Mpeg2, + resolution: Resolution::R576i, + frame_rate: FrameRate::F25, + hdr: HdrFormat::Sdr, + color_space: ColorSpace::Bt470bg, + display_aspect: None, + secondary: false, + label: String::new(), + }); + let mut titles = vec![title_with(vec![interlaced])]; + fill_defaults(&mut titles); + if let Stream::Video(v) = &titles[0].streams[0] { + assert!(v.label.contains("576i"), "expected 576i, got {}", v.label); + assert!( + !v.label.contains("576p"), + "must not say 576p, got {}", + v.label + ); + } + + let progressive = Stream::Video(VideoStream { + pid: 0x1011, + codec: Codec::Mpeg2, + resolution: Resolution::R576p, + frame_rate: FrameRate::F25, + hdr: HdrFormat::Sdr, + color_space: ColorSpace::Bt470bg, + display_aspect: None, + secondary: false, + label: String::new(), + }); + let mut titles = vec![title_with(vec![progressive])]; + fill_defaults(&mut titles); + if let Stream::Video(v) = &titles[0].streams[0] { + assert!(v.label.contains("576p"), "expected 576p, got {}", v.label); + } + } + // ── codec_hint_consistent hardening ─────────────────────────────────────── /// Spec: "Dolby Digital" (AC-3) hint is consistent ONLY with AC-3 streams; diff --git a/src/mux/ebml.rs b/src/mux/ebml.rs index 0ec8bd8..51ca701 100644 --- a/src/mux/ebml.rs +++ b/src/mux/ebml.rs @@ -424,6 +424,18 @@ pub const DEFAULT_DURATION: u32 = 0x23_E383; pub const VIDEO: u32 = 0xE0; pub const PIXEL_WIDTH: u32 = 0xB0; pub const PIXEL_HEIGHT: u32 = 0xBA; +// Scan type (children of Video). +pub const FLAG_INTERLACED: u32 = 0x9A; +pub const FIELD_ORDER: u32 = 0x9D; +// FlagInterlaced values: 1 = interlaced, 2 = progressive (0 = undetermined). +pub const INTERLACED_INTERLACED: u64 = 1; +pub const INTERLACED_PROGRESSIVE: u64 = 2; +// FieldOrder values (Matroska): 0/2 = top-field-first, 1/9 = bottom-field-first. +// NTSC DVD (480i) and HD (1080i) are top-field-first; PAL DVD (576i) is +// bottom-field-first. 0xFF is our sentinel for "undetermined / omit". +pub const FIELD_ORDER_TFF: u8 = 2; +pub const FIELD_ORDER_BFF: u8 = 9; +pub const FIELD_ORDER_UNDETERMINED: u8 = 0xFF; pub const DISPLAY_WIDTH: u32 = 0x54B0; pub const DISPLAY_HEIGHT: u32 = 0x54BA; pub const COLOUR: u32 = 0x55B0; diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index 7ba2df2..f283020 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -31,6 +31,11 @@ pub struct MkvTrack { pub colour_transfer: u8, // TransferCharacteristics (16=smpte2084/PQ) pub colour_primaries: u8, // Primaries (9=bt2020) pub colour_range: u8, // Range (1=tv/limited) + // Scan type. `interlaced` drives FlagInterlaced (0x9A): true → 1 + // (interlaced), false → 2 (progressive). `field_order` (0x9D) is only + // meaningful when interlaced; `FIELD_ORDER_UNDETERMINED` omits it. + pub interlaced: bool, + pub field_order: u8, // Audio-specific pub sample_rate: f64, pub channels: u8, @@ -125,6 +130,18 @@ impl MkvTrack { colour_transfer: transfer, colour_primaries: primaries, colour_range: range, + interlaced: v.resolution.is_interlaced(), + // PAL DVD (576i) is bottom-field-first; NTSC DVD (480i) is + // top-field-first. HD interlaced (1080i) is top-field-first. + // Progressive content leaves the field order undetermined. + field_order: if v.resolution.is_interlaced() { + match v.resolution { + Resolution::R576i => ebml::FIELD_ORDER_BFF, + _ => ebml::FIELD_ORDER_TFF, + } + } else { + ebml::FIELD_ORDER_UNDETERMINED + }, sample_rate: 0.0, channels: 0, bit_depth: 0, @@ -195,6 +212,8 @@ impl MkvTrack { colour_transfer: 0, colour_primaries: 0, colour_range: 0, + interlaced: false, + field_order: ebml::FIELD_ORDER_UNDETERMINED, sample_rate: sr, channels: ch, bit_depth: 0, @@ -228,6 +247,8 @@ impl MkvTrack { colour_transfer: 0, colour_primaries: 0, colour_range: 0, + interlaced: false, + field_order: ebml::FIELD_ORDER_UNDETERMINED, sample_rate: 0.0, channels: 0, bit_depth: 0, @@ -666,6 +687,21 @@ impl MkvMuxer { let vid_pos = ebml::start_master(&mut writer, ebml::VIDEO)?; 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)?; + // Scan type. FlagInterlaced: 1 = interlaced, 2 = progressive. + // FieldOrder is only written for interlaced content with a + // determined order (TFF=0/2/6/14, BFF=1/9/13...). + ebml::write_uint( + &mut writer, + ebml::FLAG_INTERLACED, + if track.interlaced { + ebml::INTERLACED_INTERLACED + } else { + ebml::INTERLACED_PROGRESSIVE + }, + )?; + if track.interlaced && track.field_order != ebml::FIELD_ORDER_UNDETERMINED { + ebml::write_uint(&mut writer, ebml::FIELD_ORDER, track.field_order as u64)?; + } if track.display_width > 0 && track.display_height > 0 { ebml::write_uint(&mut writer, ebml::DISPLAY_WIDTH, track.display_width as u64)?; ebml::write_uint( @@ -1161,6 +1197,8 @@ mod tests { colour_transfer: 0, colour_primaries: 0, colour_range: 0, + interlaced: false, + field_order: ebml::FIELD_ORDER_UNDETERMINED, sample_rate: 0.0, channels: 0, bit_depth: 0, @@ -1186,6 +1224,8 @@ mod tests { colour_transfer: 0, colour_primaries: 0, colour_range: 0, + interlaced: false, + field_order: ebml::FIELD_ORDER_UNDETERMINED, sample_rate: 48000.0, channels: 6, bit_depth: 0, @@ -2944,6 +2984,52 @@ mod tests { ); } + #[test] + fn video_emits_flag_interlaced_and_field_order() { + // An interlaced (576i PAL) track must emit FlagInterlaced=1 and + // FieldOrder=9 (bottom-field-first). A progressive track must emit + // FlagInterlaced=2 and NO FieldOrder. + let mut interlaced = make_video_track(); + interlaced.interlaced = true; + interlaced.field_order = ebml::FIELD_ORDER_BFF; + let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[interlaced], None, 0.0, &[]).unwrap(); + let data = muxer.writer.into_inner(); + let fi = find_id(&data, ebml::FLAG_INTERLACED).expect("FlagInterlaced present"); + // [id=0x9A][size=0x81][value] + assert_eq!( + data[fi + 2], + ebml::INTERLACED_INTERLACED as u8, + "FlagInterlaced must be 1 (interlaced)" + ); + let fo = find_id(&data, ebml::FIELD_ORDER).expect("FieldOrder present"); + assert_eq!( + data[fo + 2], + ebml::FIELD_ORDER_BFF, + "FieldOrder must be 9 (bottom-field-first) for PAL DVD" + ); + + // Progressive track: FlagInterlaced=2, no FieldOrder. + let muxer = MkvMuxer::new( + Cursor::new(Vec::new()), + &[make_video_track()], + None, + 0.0, + &[], + ) + .unwrap(); + let data = muxer.writer.into_inner(); + let fi = find_id(&data, ebml::FLAG_INTERLACED).expect("FlagInterlaced present"); + assert_eq!( + data[fi + 2], + ebml::INTERLACED_PROGRESSIVE as u8, + "FlagInterlaced must be 2 (progressive)" + ); + assert!( + find_id(&data, ebml::FIELD_ORDER).is_none(), + "no FieldOrder for progressive content" + ); + } + #[test] fn dolby_vision_track_emits_block_addition_mapping() { // A DV track (dv_config set) must emit BlockAdditionMapping (0x41E4)