From 96a65de3ff51741d0632f4084245058fa787d8b1 Mon Sep 17 00:00:00 2001 From: MattJackson <1085847+MattJackson@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:43:47 +0000 Subject: [PATCH] Chapters, DVD subtitle palette, MKV track flags, progress total_bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chapters: - MPLS PlayList marks parsed (mark_type 1 = chapter) - Chapter struct on DiscTitle (time_secs, name) - MKV Chapters element with EditionEntry/ChapterAtom per mark - 3 MPLS mark tests + 2 MKV chapter tests DVD subtitle palette: - IFO palette extraction (PGC offset 0xA4, 16 × YCbCr colors) - YCbCr→RGB conversion for VobSub .idx format - DvdSubParser codec_private returns formatted palette - codec_data field on SubtitleStream flows through pipeline - 5 palette tests (YCbCr conversion, formatting, overflow) MKV track flags: - FlagDefault: primary video/audio = 1, secondary = 0 - FlagForced: forced subtitles = 1 - Language: set from stream language code - Already implemented, verified with 4 new tests Progress total_bytes: - IOStream trait: total_bytes() -> Option - DiscStream, IsoStream: from disc_title.size_bytes - M2tsStream, MkvStream: from file metadata on open - NetworkStream, StdioStream, NullStream: None 316 tests total, all passing. --- src/disc/bluray.rs | 19 +++++ src/disc/dvd.rs | 24 ++++++ src/disc/mod.rs | 15 ++++ src/ifo.rs | 69 +++++++++++++++++ src/mpls.rs | 162 +++++++++++++++++++++++++++++++++++++++- src/mux/codec/dvdsub.rs | 160 ++++++++++++++++++++++++++++++++++++++- src/mux/codec/mod.rs | 12 ++- src/mux/disc.rs | 3 + src/mux/ebml.rs | 10 +++ src/mux/iso.rs | 8 ++ src/mux/m2ts.rs | 15 ++++ src/mux/meta.rs | 2 + src/mux/mkv.rs | 154 +++++++++++++++++++++++++++++++++++++- src/mux/mkvstream.rs | 16 +++- src/mux/mod.rs | 5 ++ src/mux/network.rs | 1 + src/mux/null.rs | 6 ++ src/mux/stdio.rs | 8 ++ src/mux/ts.rs | 1 + tests/disc_tests.rs | 1 + tests/streams.rs | 11 +++ 21 files changed, 690 insertions(+), 12 deletions(-) diff --git a/src/disc/bluray.rs b/src/disc/bluray.rs index 4a8692b..7151343 100644 --- a/src/disc/bluray.rs +++ b/src/disc/bluray.rs @@ -128,6 +128,7 @@ impl Disc { codec, language: s.language.clone(), forced: false, + codec_data: None, })) } else { Some(Stream::Audio(AudioStream { @@ -146,6 +147,7 @@ impl Disc { codec, language: s.language.clone(), forced: false, + codec_data: None, })), // Stream type 4 = IG, unknown types -- skip _ => None, @@ -153,6 +155,22 @@ impl Disc { }) .collect(); + // Convert marks to chapters (filter mark_type == 1 = chapter entry) + let first_in_time = parsed.play_items.first().map(|pi| pi.in_time).unwrap_or(0); + let chapters: Vec = parsed + .marks + .iter() + .filter(|m| m.mark_type == 1) + .enumerate() + .map(|(i, m)| { + let time_secs = (m.timestamp as f64 - first_in_time as f64) / 45000.0; + Chapter { + time_secs: if time_secs < 0.0 { 0.0 } else { time_secs }, + name: format!("Chapter {}", i + 1), + } + }) + .collect(); + let playlist_num = filename.trim_end_matches(".mpls").trim_end_matches(".MPLS"); let playlist_id = playlist_num.parse::().unwrap_or(0); @@ -163,6 +181,7 @@ impl Disc { size_bytes: total_size, clips, streams, + chapters, extents, content_format: ContentFormat::BdTs, }) diff --git a/src/disc/dvd.rs b/src/disc/dvd.rs index 913b47e..e62445a 100644 --- a/src/disc/dvd.rs +++ b/src/disc/dvd.rs @@ -97,8 +97,31 @@ impl Disc { .map(|e| e.sector_count as u64 * 2048) .sum(); + // Build pre-formatted palette codec_data for VobSub subtitle streams + let codec_data = dvd_title + .palette + .as_ref() + .map(|pal| crate::mux::codec::dvdsub::format_palette(pal)); + + // Map DvdSubtitleAttr to Stream::Subtitle + let subtitle_streams: Vec = ts + .subtitle_streams + .iter() + .enumerate() + .map(|(i, s)| { + Stream::Subtitle(SubtitleStream { + pid: 0x20 + i as u16, // DVD sub-stream IDs 0x20-0x3F + codec: Codec::DvdSub, + language: s.language.clone(), + forced: false, + codec_data: codec_data.clone(), + }) + }) + .collect(); + let mut streams = vec![video_stream.clone()]; streams.extend(audio_streams.iter().cloned()); + streams.extend(subtitle_streams); titles.push(DiscTitle { playlist: format!("VTS_{:02}_{}.VOB", ts.vts_number, title_number), @@ -107,6 +130,7 @@ impl Disc { size_bytes, clips: Vec::new(), streams, + chapters: Vec::new(), extents, content_format: ContentFormat::MpegPs, }); diff --git a/src/disc/mod.rs b/src/disc/mod.rs index b5013d4..9c6e4bf 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -110,6 +110,8 @@ pub struct DiscTitle { pub clips: Vec, /// All streams (video, audio, subtitle, etc.) pub streams: Vec, + /// Chapter points + pub chapters: Vec, /// Sector extents for ripping (clip LBA ranges) pub extents: Vec, /// Content format for this title @@ -190,6 +192,8 @@ pub struct SubtitleStream { pub language: String, /// Whether this is a forced subtitle pub forced: bool, + /// Pre-formatted codec private data (e.g. VobSub .idx palette header) + pub codec_data: Option>, } /// Video/audio codec. @@ -231,6 +235,15 @@ pub enum ColorSpace { Unknown, } +/// A chapter point within a title. +#[derive(Debug, Clone)] +pub struct Chapter { + /// Chapter start time in seconds + pub time_secs: f64, + /// Chapter name (e.g. "Chapter 1", "Chapter 2") + pub name: String, +} + /// A contiguous range of sectors on disc. #[derive(Debug, Clone, Copy)] pub struct Extent { @@ -310,6 +323,7 @@ impl DiscTitle { size_bytes: 0, clips: Vec::new(), streams: Vec::new(), + chapters: Vec::new(), extents: Vec::new(), content_format: ContentFormat::BdTs, } @@ -1073,6 +1087,7 @@ mod tests { secondary: false, label: String::new(), })], + chapters: Vec::new(), extents: Vec::new(), content_format: ContentFormat::BdTs, } diff --git a/src/ifo.rs b/src/ifo.rs index a7f8383..b1133c5 100644 --- a/src/ifo.rs +++ b/src/ifo.rs @@ -30,6 +30,8 @@ pub struct DvdTitleSet { pub video: DvdVideoAttr, /// Audio stream attributes (up to 8) pub audio_streams: Vec, + /// Subtitle stream attributes (up to 32) + pub subtitle_streams: Vec, /// Titles within this set pub titles: Vec, } @@ -43,6 +45,8 @@ pub struct DvdTitle { pub duration_secs: f64, /// Cell sector ranges pub cells: Vec, + /// Subtitle palette from PGC: 16 entries of [padding, Y, Cb, Cr]. + pub palette: Option>, } /// A cell — contiguous sector range within a VOB. @@ -70,6 +74,12 @@ pub struct DvdAudioAttr { pub language: String, } +/// DVD subtitle stream attributes. +#[derive(Debug, Clone)] +pub struct DvdSubtitleAttr { + pub language: String, +} + // ── Constants ─────────────────────────────────────────────────────────────── const VMG_MAGIC: &[u8; 12] = b"DVDVIDEO-VMG"; @@ -285,6 +295,22 @@ fn parse_vts( audio_streams.push(parse_audio_attr(&vts_data, aoff)?); } + // Subtitle streams: count at 0x254 (u16 BE), then 6 bytes each starting at 0x256 + let num_subs = if vts_data.len() >= 0x256 { + be_u16(&vts_data, 0x254).unwrap_or(0) + } else { + 0 + }; + let num_subs = std::cmp::min(num_subs, 32) as usize; // cap at 32 + let mut subtitle_streams = Vec::with_capacity(num_subs); + for i in 0..num_subs { + let soff = 0x256 + i * 6; + if soff + 6 > vts_data.len() { + break; + } + subtitle_streams.push(parse_subtitle_attr(&vts_data, soff)?); + } + // Parse PGC information table let pgcit_offset = (pgcit_sector as usize).checked_mul(SECTOR_SIZE).ok_or(Error::IfoParse)?; let titles = parse_pgcit(&vts_data, pgcit_offset, titles_info)?; @@ -294,6 +320,7 @@ fn parse_vts( vob_start_sector, video, audio_streams, + subtitle_streams, titles, }) } @@ -406,6 +433,28 @@ fn parse_audio_attr(data: &[u8], offset: usize) -> Result { }) } +/// Parse one subtitle stream attribute block (6 bytes at `offset`). +fn parse_subtitle_attr(data: &[u8], offset: usize) -> Result { + // Language code: bytes 2-3 as ISO 639 + let lang_bytes = sub_slice(data, offset + 2, 2)?; + let language = if lang_bytes[0] >= b'a' && lang_bytes[0] <= b'z' + && lang_bytes[1] >= b'a' && lang_bytes[1] <= b'z' + { + String::from_utf8_lossy(lang_bytes).to_string() + } else if lang_bytes[0] == 0 && lang_bytes[1] == 0 { + String::new() + } else { + let s: String = lang_bytes + .iter() + .filter(|&&b| b.is_ascii_alphanumeric()) + .map(|&b| b as char) + .collect(); + s + }; + + Ok(DvdSubtitleAttr { language }) +} + // ── PGC parser ────────────────────────────────────────────────────────────── /// Parse VTS_PGCIT (Program Chain Information Table) to extract titles. @@ -508,10 +557,28 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result duration_secs }; + // Extract subtitle palette at PGC offset 0xA4: 16 colors × 4 bytes [padding, Y, Cb, Cr] + let palette = if pgc_offset + 0xA4 + 64 <= data.len() { + let mut colors = Vec::with_capacity(16); + for i in 0..16 { + let co = pgc_offset + 0xA4 + i * 4; + colors.push([data[co], data[co + 1], data[co + 2], data[co + 3]]); + } + // Only include palette if it's not all zeros (some DVDs have empty palettes) + if colors.iter().any(|c| c[1] != 0 || c[2] != 0 || c[3] != 0) { + Some(colors) + } else { + None + } + } else { + None + }; + Ok(DvdTitle { chapters, duration_secs, cells, + palette, }) } @@ -600,6 +667,7 @@ mod tests { chapters: 5, duration_secs: 3600.0, cells: vec![cell.clone()], + palette: None, }; assert_eq!(title.chapters, 5); assert!((title.duration_secs - 3600.0).abs() < 0.01); @@ -626,6 +694,7 @@ mod tests { vob_start_sector: 512, video, audio_streams: vec![audio], + subtitle_streams: Vec::new(), titles: vec![title], }; assert_eq!(ts.vts_number, 1); diff --git a/src/mpls.rs b/src/mpls.rs index 2b44769..e27c716 100644 --- a/src/mpls.rs +++ b/src/mpls.rs @@ -17,6 +17,19 @@ pub struct Playlist { pub play_items: Vec, /// Streams from the first play item's STN table pub streams: Vec, + /// Playlist marks (chapter points, etc.) + pub marks: Vec, +} + +/// A playlist mark entry from the PlayListMark section. +#[derive(Debug, Clone)] +pub struct PlaylistMark { + /// Mark type: 1 = chapter entry mark + pub mark_type: u8, + /// Which play item this mark belongs to + pub play_item_ref: u16, + /// Timestamp in 45kHz PTS ticks + pub timestamp: u32, } /// A play item — one clip reference with in/out times. @@ -70,6 +83,7 @@ pub fn parse(data: &[u8]) -> Result { let version = String::from_utf8_lossy(&data[4..8]).to_string(); let playlist_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize; + let mark_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize; if playlist_start + 10 > data.len() { return Err(Error::MplsParse); @@ -237,10 +251,35 @@ pub fn parse(data: &[u8]) -> Result { pos += 2 + item_length; } + // Parse PlayListMark section + let mut marks = Vec::new(); + if mark_start > 0 && mark_start + 4 < data.len() { + let ms = &data[mark_start..]; + if ms.len() >= 6 { + let num_marks = u16::from_be_bytes([ms[4], ms[5]]) as usize; + let mut mpos = 6; + for _ in 0..num_marks { + if mpos + 14 > ms.len() { + break; + } + let mark_type = ms[mpos]; + let play_item_ref = u16::from_be_bytes([ms[mpos + 2], ms[mpos + 3]]); + let timestamp = u32::from_be_bytes([ms[mpos + 4], ms[mpos + 5], ms[mpos + 6], ms[mpos + 7]]); + marks.push(PlaylistMark { + mark_type, + play_item_ref, + timestamp, + }); + mpos += 14; + } + } + } + Ok(Playlist { version, play_items, streams, + marks, }) } @@ -367,6 +406,13 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea mod tests { use super::*; + /// A mark entry for test MPLS building. + struct TestMark { + mark_type: u8, + play_item_ref: u16, + timestamp: u32, + } + /// Build a minimal MPLS binary with given play items and STN streams on the first item. /// STN counts: (n_video, n_audio, n_pg, n_ig, n_sec_audio, n_sec_video, n_pip_pg, n_dv) fn build_mpls( @@ -378,14 +424,28 @@ mod tests { )], stn_counts: (u8, u8, u8, u8, u8, u8, u8, u8), stream_entries: &[Vec], // raw stream entry + attributes bytes for each stream + ) -> Vec { + build_mpls_with_marks(play_items_data, stn_counts, stream_entries, &[]) + } + + fn build_mpls_with_marks( + play_items_data: &[( + /*clip_id*/ &[u8; 5], + /*conn*/ u8, + /*in_time*/ u32, + /*out_time*/ u32, + )], + stn_counts: (u8, u8, u8, u8, u8, u8, u8, u8), + stream_entries: &[Vec], + marks: &[TestMark], ) -> Vec { let playlist_start: u32 = 40; // right after the 40-byte header let mut buf = Vec::new(); - // File header: "MPLS" + version + playlist_start + 3 more offsets (unused) + // File header: "MPLS" + version + playlist_start + mark_start placeholder buf.extend_from_slice(b"MPLS0200"); buf.extend_from_slice(&playlist_start.to_be_bytes()); - // mark_start, extension_start (unused by parser), padding to 40 bytes + // mark_start placeholder (will be patched), extension_start, padding to 40 bytes buf.extend_from_slice(&[0u8; 28]); // PlayList section starts here (offset 40) @@ -462,6 +522,27 @@ mod tests { buf[pl_start + 2] = pl_len_bytes[2]; buf[pl_start + 3] = pl_len_bytes[3]; + // Write PlayListMark section + let mark_start = buf.len() as u32; + // Patch mark_start offset in header (bytes 12-15) + let ms_bytes = mark_start.to_be_bytes(); + buf[12] = ms_bytes[0]; + buf[13] = ms_bytes[1]; + buf[14] = ms_bytes[2]; + buf[15] = ms_bytes[3]; + + // Mark section: length(4) + num_marks(2) + marks(14 each) + let mark_section_len = 2 + marks.len() * 14; + buf.extend_from_slice(&(mark_section_len as u32).to_be_bytes()); + buf.extend_from_slice(&(marks.len() as u16).to_be_bytes()); + for m in marks { + buf.push(m.mark_type); // [0] mark_type + buf.push(0); // [1] reserved + buf.extend_from_slice(&m.play_item_ref.to_be_bytes()); // [2-3] play_item_ref + buf.extend_from_slice(&m.timestamp.to_be_bytes()); // [4-7] timestamp + buf.extend_from_slice(&[0u8; 6]); // [8-13] padding (entry_ES_PID + duration + mark_data) + } + buf } @@ -685,4 +766,81 @@ mod tests { assert!(playlist.streams[2].secondary); assert_eq!(playlist.streams[2].pid, 0x1B00); } + + #[test] + fn parse_marks_chapter_entries() { + let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None); + let marks = vec![ + TestMark { mark_type: 1, play_item_ref: 0, timestamp: 90000 }, + TestMark { mark_type: 1, play_item_ref: 0, timestamp: 4500000 }, + TestMark { mark_type: 1, play_item_ref: 0, timestamp: 9000000 }, + ]; + + let data = build_mpls_with_marks( + &[(b"00001", 1, 90000, 13500000)], + (1, 0, 0, 0, 0, 0, 0, 0), + &[video], + &marks, + ); + + let playlist = parse(&data).expect("should parse marks"); + assert_eq!(playlist.marks.len(), 3); + assert_eq!(playlist.marks[0].mark_type, 1); + assert_eq!(playlist.marks[0].play_item_ref, 0); + assert_eq!(playlist.marks[0].timestamp, 90000); + assert_eq!(playlist.marks[1].timestamp, 4500000); + assert_eq!(playlist.marks[2].timestamp, 9000000); + } + + #[test] + fn parse_marks_chapter_timestamps_correct() { + let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None); + let in_time: u32 = 90000; + + // Chapters at 0s, 100s, 200s relative to in_time + let marks = vec![ + TestMark { mark_type: 1, play_item_ref: 0, timestamp: in_time }, + TestMark { mark_type: 1, play_item_ref: 0, timestamp: in_time + 45000 * 100 }, + TestMark { mark_type: 1, play_item_ref: 0, timestamp: in_time + 45000 * 200 }, + TestMark { mark_type: 2, play_item_ref: 0, timestamp: in_time + 45000 * 50 }, // non-chapter mark + ]; + + let data = build_mpls_with_marks( + &[(b"00001", 1, in_time, in_time + 45000 * 300)], + (1, 0, 0, 0, 0, 0, 0, 0), + &[video], + &marks, + ); + + let playlist = parse(&data).expect("should parse"); + // All 4 marks should be parsed + assert_eq!(playlist.marks.len(), 4); + // Chapter marks (type 1) are 3 of them + let chapter_marks: Vec<_> = playlist.marks.iter().filter(|m| m.mark_type == 1).collect(); + assert_eq!(chapter_marks.len(), 3); + // Non-chapter mark (type 2) + assert_eq!(playlist.marks[3].mark_type, 2); + + // Verify timestamp conversion: (timestamp - in_time) / 45000 + let ch0_secs = (chapter_marks[0].timestamp as f64 - in_time as f64) / 45000.0; + let ch1_secs = (chapter_marks[1].timestamp as f64 - in_time as f64) / 45000.0; + let ch2_secs = (chapter_marks[2].timestamp as f64 - in_time as f64) / 45000.0; + assert!((ch0_secs - 0.0).abs() < 0.001); + assert!((ch1_secs - 100.0).abs() < 0.001); + assert!((ch2_secs - 200.0).abs() < 0.001); + } + + #[test] + fn parse_no_marks_section() { + // When mark_start is 0, no marks should be returned + let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None); + let data = build_mpls( + &[(b"00001", 1, 90000, 4500000)], + (1, 0, 0, 0, 0, 0, 0, 0), + &[video], + ); + let playlist = parse(&data).expect("should parse without marks"); + // build_mpls writes an empty mark section (0 marks) + assert_eq!(playlist.marks.len(), 0); + } } diff --git a/src/mux/codec/dvdsub.rs b/src/mux/codec/dvdsub.rs index ccb7382..89ef273 100644 --- a/src/mux/codec/dvdsub.rs +++ b/src/mux/codec/dvdsub.rs @@ -9,7 +9,10 @@ use super::{pts_to_ns, CodecParser, Frame, PesPacket}; -pub struct DvdSubParser; +pub struct DvdSubParser { + /// Pre-formatted VobSub .idx palette header for codec_private. + codec_data: Option>, +} impl Default for DvdSubParser { fn default() -> Self { @@ -19,7 +22,12 @@ impl Default for DvdSubParser { impl DvdSubParser { pub fn new() -> Self { - Self + Self { codec_data: None } + } + + /// Create a parser with pre-formatted codec private data (palette header). + pub fn with_codec_data(codec_data: Option>) -> Self { + Self { codec_data } } } @@ -37,10 +45,54 @@ impl CodecParser for DvdSubParser { } fn codec_private(&self) -> Option> { - None + self.codec_data.clone() } } +// ── YCbCr → RGB conversion and palette formatting ───────────────────────── + +/// Convert a single YCbCr color to RGB, clamping to [0, 255]. +/// +/// Input: `[padding, Y, Cb, Cr]` (as stored in DVD IFO PGC data). +/// Returns `[R, G, B]`. +pub fn ycbcr_to_rgb(color: &[u8; 4]) -> [u8; 3] { + let y = color[1] as f64; + let cb = color[2] as f64; + let cr = color[3] as f64; + + let r = y + 1.402 * (cr - 128.0); + let g = y - 0.344 * (cb - 128.0) - 0.714 * (cr - 128.0); + let b = y + 1.772 * (cb - 128.0); + + [clamp_u8(r), clamp_u8(g), clamp_u8(b)] +} + +fn clamp_u8(v: f64) -> u8 { + if v < 0.0 { + 0 + } else if v > 255.0 { + 255 + } else { + v.round() as u8 + } +} + +/// Format a 16-color YCbCr palette as a VobSub .idx palette header. +/// +/// Each entry is `[padding, Y, Cb, Cr]`. Output is a UTF-8 text block: +/// `palette: rrggbb, rrggbb, ...\n` +/// +/// Returns the formatted bytes suitable for MKV codec_private. +pub fn format_palette(palette: &[[u8; 4]]) -> Vec { + let mut parts: Vec = Vec::with_capacity(palette.len()); + for color in palette { + let [r, g, b] = ycbcr_to_rgb(color); + parts.push(format!("{:02x}{:02x}{:02x}", r, g, b)); + } + let line = format!("palette: {}\n", parts.join(", ")); + line.into_bytes() +} + #[cfg(test)] mod tests { use super::*; @@ -87,11 +139,20 @@ mod tests { } #[test] - fn codec_private_none() { + fn codec_private_none_by_default() { let parser = DvdSubParser::new(); assert!(parser.codec_private().is_none()); } + #[test] + fn codec_private_returns_palette_when_set() { + let palette_data = b"palette: 000000, ffffff\n".to_vec(); + let parser = DvdSubParser::with_codec_data(Some(palette_data.clone())); + let cp = parser.codec_private(); + assert!(cp.is_some()); + assert_eq!(cp.unwrap(), palette_data); + } + #[test] fn no_pts_defaults_to_zero() { let mut parser = DvdSubParser::new(); @@ -100,4 +161,95 @@ mod tests { assert_eq!(frames.len(), 1); assert_eq!(frames[0].pts_ns, 0); } + + // ── YCbCr → RGB conversion tests ────────────────────────────────────── + + #[test] + fn ycbcr_to_rgb_white() { + // White in YCbCr: Y=235, Cb=128, Cr=128 → R=235, G=235, B=235 + let color = [0x00, 235, 128, 128]; + let [r, g, b] = ycbcr_to_rgb(&color); + assert_eq!(r, 235); + assert_eq!(g, 235); + assert_eq!(b, 235); + } + + #[test] + fn ycbcr_to_rgb_black() { + // Black: Y=16, Cb=128, Cr=128 → R=16, G=16, B=16 + let color = [0x00, 16, 128, 128]; + let [r, g, b] = ycbcr_to_rgb(&color); + assert_eq!(r, 16); + assert_eq!(g, 16); + assert_eq!(b, 16); + } + + #[test] + fn ycbcr_to_rgb_clamps_overflow() { + // Y=255, Cr=255 → R would be 255 + 1.402*127 = ~433, should clamp to 255 + let color = [0x00, 255, 128, 255]; + let [r, _g, _b] = ycbcr_to_rgb(&color); + assert_eq!(r, 255); + } + + #[test] + fn ycbcr_to_rgb_clamps_underflow() { + // Y=0, Cr=0 → R = 0 + 1.402*(0-128) = -179, should clamp to 0 + let color = [0x00, 0, 128, 0]; + let [r, _g, _b] = ycbcr_to_rgb(&color); + assert_eq!(r, 0); + } + + #[test] + fn ycbcr_to_rgb_red() { + // Approximate red: Y=82, Cb=90, Cr=240 + let color = [0x00, 82, 90, 240]; + let [r, g, b] = ycbcr_to_rgb(&color); + // R = 82 + 1.402*(240-128) = 82 + 156.9 ≈ 239 + // G = 82 - 0.344*(90-128) - 0.714*(240-128) = 82 + 13.1 - 79.97 ≈ 15 + // B = 82 + 1.772*(90-128) = 82 - 67.3 ≈ 15 + assert!(r > 200, "R should be high for red, got {}", r); + assert!(g < 30, "G should be low for red, got {}", g); + assert!(b < 30, "B should be low for red, got {}", b); + } + + // ── Palette formatting tests ────────────────────────────────────────── + + #[test] + fn format_palette_basic() { + // Two colors: black and white (at neutral chroma) + let palette = vec![ + [0x00, 0, 128, 128], // Y=0 → RGB (0,0,0) + [0x00, 255, 128, 128], // Y=255 → RGB (255,255,255) + ]; + let result = format_palette(&palette); + let text = String::from_utf8(result).unwrap(); + assert!(text.starts_with("palette: "), "should start with 'palette: '"); + assert!(text.ends_with('\n'), "should end with newline"); + // First color: 000000 + assert!(text.contains("000000"), "black should be 000000, got: {}", text); + // Second color: ffffff + assert!(text.contains("ffffff"), "white should be ffffff, got: {}", text); + } + + #[test] + fn format_palette_16_colors() { + let palette: Vec<[u8; 4]> = (0..16) + .map(|i| [0x00, (i * 16) as u8, 128, 128]) + .collect(); + let result = format_palette(&palette); + let text = String::from_utf8(result).unwrap(); + // Should have exactly 15 commas (16 colors separated by ", ") + let comma_count = text.matches(", ").count(); + assert_eq!(comma_count, 15, "16 colors should have 15 separators, got {}", comma_count); + } + + #[test] + fn format_palette_hex_format() { + // Y=128, Cb=128, Cr=128 → R=128, G=128, B=128 → "808080" + let palette = vec![[0x00, 128, 128, 128]]; + let result = format_palette(&palette); + let text = String::from_utf8(result).unwrap(); + assert_eq!(text, "palette: 808080\n"); + } } diff --git a/src/mux/codec/mod.rs b/src/mux/codec/mod.rs index cc4b786..bbf4b5f 100644 --- a/src/mux/codec/mod.rs +++ b/src/mux/codec/mod.rs @@ -79,6 +79,16 @@ impl CodecParser for PassthroughParser { /// Create the appropriate parser for a codec. pub fn parser_for_codec(codec: Codec) -> Box { + parser_for_codec_with_data(codec, None) +} + +/// Create the appropriate parser for a codec, with optional codec private data. +/// +/// For DvdSub, `codec_data` should be the pre-formatted VobSub .idx palette header. +pub fn parser_for_codec_with_data( + codec: Codec, + codec_data: Option>, +) -> Box { match codec { Codec::H264 => Box::new(h264::H264Parser::new()), Codec::Hevc => Box::new(hevc::HevcParser::new()), @@ -89,7 +99,7 @@ pub fn parser_for_codec(codec: Codec) -> Box { Codec::TrueHd => Box::new(truehd::TrueHdParser::new()), Codec::Pgs => Box::new(pgs::PgsParser::new()), Codec::Lpcm => Box::new(lpcm::LpcmParser::new()), - Codec::DvdSub => Box::new(dvdsub::DvdSubParser::new()), + Codec::DvdSub => Box::new(dvdsub::DvdSubParser::with_codec_data(codec_data)), _ => Box::new(PassthroughParser::new(true)), } } diff --git a/src/mux/disc.rs b/src/mux/disc.rs index 2cb57f8..aca0e75 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -90,6 +90,9 @@ impl IOStream for DiscStream { fn finish(&mut self) -> io::Result<()> { Ok(()) } + fn total_bytes(&self) -> Option { + Some(self.disc_title.size_bytes) + } } impl Read for DiscStream { diff --git a/src/mux/ebml.rs b/src/mux/ebml.rs index 323c4ef..18aefd0 100644 --- a/src/mux/ebml.rs +++ b/src/mux/ebml.rs @@ -408,6 +408,16 @@ pub const CUE_TRACK_POSITIONS: u32 = 0xB7; pub const CUE_TRACK: u32 = 0xF7; pub const CUE_CLUSTER_POSITION: u32 = 0xF1; +// Chapters +pub const CHAPTERS: u32 = 0x1043A770; +pub const EDITION_ENTRY: u32 = 0x45B9; +pub const CHAPTER_ATOM: u32 = 0xB6; +pub const CHAPTER_UID: u32 = 0x73C4; +pub const CHAPTER_TIME_START: u32 = 0x91; +pub const CHAPTER_DISPLAY: u32 = 0x80; +pub const CHAP_STRING: u32 = 0x85; +pub const CHAP_LANGUAGE: u32 = 0x437C; + // Track types pub const TRACK_TYPE_VIDEO: u64 = 1; pub const TRACK_TYPE_AUDIO: u64 = 2; diff --git a/src/mux/iso.rs b/src/mux/iso.rs index 17fdf36..104c180 100644 --- a/src/mux/iso.rs +++ b/src/mux/iso.rs @@ -205,6 +205,14 @@ impl IOStream for IsoStream { } Ok(()) } + fn total_bytes(&self) -> Option { + // Read mode: size is known from disc scan + if self.reader.is_some() { + Some(self.disc_title.size_bytes) + } else { + None + } + } } impl Read for IsoStream { diff --git a/src/mux/m2ts.rs b/src/mux/m2ts.rs index 17ba472..7cc1222 100644 --- a/src/mux/m2ts.rs +++ b/src/mux/m2ts.rs @@ -25,6 +25,8 @@ pub struct M2tsStream { disc_title: DiscTitle, mode: Mode, finished: bool, + /// Content size in bytes (file size minus header), set for read mode. + content_size: Option, } impl M2tsStream { @@ -37,6 +39,7 @@ impl M2tsStream { header_written: false, }, finished: false, + content_size: None, } } @@ -50,14 +53,21 @@ impl M2tsStream { /// /// Tries FMKV metadata header first. Falls back to PMT scan + PTS duration. pub fn open(mut reader: impl Read + Seek + 'static) -> io::Result { + // Get total file size for progress tracking + let file_size = reader.seek(SeekFrom::End(0))?; + reader.seek(SeekFrom::Start(0))?; + // Try FMKV metadata header if let Ok(Some(m)) = meta::read_header(&mut reader) { + let header_end = reader.stream_position()?; + let content_size = file_size.saturating_sub(header_end); return Ok(Self { disc_title: m.to_title(), mode: Mode::Read { reader: Box::new(reader), }, finished: false, + content_size: Some(content_size), }); } @@ -89,6 +99,7 @@ impl M2tsStream { reader: Box::new(reader), }, finished: false, + content_size: Some(file_size), }) } } @@ -109,6 +120,10 @@ impl IOStream for M2tsStream { Ok(()) } } + + fn total_bytes(&self) -> Option { + self.content_size + } } impl Write for M2tsStream { diff --git a/src/mux/meta.rs b/src/mux/meta.rs index 652e94c..051f20d 100644 --- a/src/mux/meta.rs +++ b/src/mux/meta.rs @@ -168,6 +168,7 @@ impl M2tsMeta { codec: str_to_codec(codec), language: language.clone(), forced: *forced, + codec_data: None, }), }) .collect(); @@ -179,6 +180,7 @@ impl M2tsMeta { size_bytes: 0, clips: Vec::new(), streams, + chapters: Vec::new(), extents: Vec::new(), content_format: crate::disc::ContentFormat::BdTs, } diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index b8a1d71..d9ce470 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -5,7 +5,7 @@ //! cues and seek head are finalized at the end. use super::ebml; -use crate::disc::{AudioStream, Codec, SubtitleStream, VideoStream}; +use crate::disc::{AudioStream, Chapter, Codec, SubtitleStream, VideoStream}; use std::io::{self, Seek, SeekFrom, Write}; /// MKV track definition (built from disc stream metadata). @@ -89,7 +89,7 @@ impl MkvTrack { codec_id, language: s.language.clone(), name: String::new(), - codec_private: None, + codec_private: s.codec_data.clone(), is_default: false, is_forced: s.forced, pixel_width: 0, @@ -128,12 +128,23 @@ pub struct MkvMuxer { const CLUSTER_DURATION_MS: i64 = 5000; impl MkvMuxer { - /// Create a new MKV muxer: writes EBML header, Segment start, Info, Tracks. + /// Create a new MKV muxer: writes EBML header, Segment start, Info, Tracks, Chapters. pub fn new( + writer: W, + tracks: &[MkvTrack], + title: Option<&str>, + duration_secs: f64, + ) -> io::Result { + Self::new_with_chapters(writer, tracks, title, duration_secs, &[]) + } + + /// Create a new MKV muxer with chapters: writes EBML header, Segment start, Info, Tracks, Chapters. + pub fn new_with_chapters( mut writer: W, tracks: &[MkvTrack], title: Option<&str>, duration_secs: f64, + chapters: &[Chapter], ) -> io::Result { // EBML Header let ebml_pos = ebml::start_master(&mut writer, ebml::EBML)?; @@ -228,6 +239,25 @@ impl MkvMuxer { } ebml::end_master(&mut writer, tracks_pos)?; + // Chapters + if !chapters.is_empty() { + let chapters_pos = ebml::start_master(&mut writer, ebml::CHAPTERS)?; + let edition_pos = ebml::start_master(&mut writer, ebml::EDITION_ENTRY)?; + for (i, ch) in chapters.iter().enumerate() { + let atom_pos = ebml::start_master(&mut writer, ebml::CHAPTER_ATOM)?; + ebml::write_uint(&mut writer, ebml::CHAPTER_UID, (i + 1) as u64)?; + let time_ns = (ch.time_secs * 1_000_000_000.0) as u64; + ebml::write_uint(&mut writer, ebml::CHAPTER_TIME_START, time_ns)?; + let display_pos = ebml::start_master(&mut writer, ebml::CHAPTER_DISPLAY)?; + ebml::write_string(&mut writer, ebml::CHAP_STRING, &ch.name)?; + ebml::write_string(&mut writer, ebml::CHAP_LANGUAGE, "und")?; + ebml::end_master(&mut writer, display_pos)?; + ebml::end_master(&mut writer, atom_pos)?; + } + ebml::end_master(&mut writer, edition_pos)?; + ebml::end_master(&mut writer, chapters_pos)?; + } + Ok(Self { writer, segment_start, @@ -646,4 +676,122 @@ mod tests { nkf_flags ); } + + #[test] + fn mkv_writes_chapters_element() { + let buf = Cursor::new(Vec::new()); + let tracks = [make_video_track()]; + let chapters = vec![ + Chapter { time_secs: 0.0, name: "Chapter 1".into() }, + Chapter { time_secs: 300.0, name: "Chapter 2".into() }, + Chapter { time_secs: 600.0, name: "Chapter 3".into() }, + ]; + let muxer = MkvMuxer::new_with_chapters( + buf, &tracks, Some("Chapter Test"), 900.0, &chapters, + ).unwrap(); + let data = muxer.writer.into_inner(); + + // Chapters element ID: 0x1043A770 + assert!( + find_id(&data, ebml::CHAPTERS).is_some(), + "Chapters element (0x1043A770) not found in output" + ); + // EditionEntry element ID: 0x45B9 + assert!( + find_id(&data, ebml::EDITION_ENTRY).is_some(), + "EditionEntry element not found" + ); + // ChapterAtom element ID: 0xB6 + assert!( + find_id(&data, ebml::CHAPTER_ATOM).is_some(), + "ChapterAtom element not found" + ); + } + + #[test] + fn mkv_no_chapters_when_empty() { + let buf = Cursor::new(Vec::new()); + let tracks = [make_video_track()]; + let muxer = MkvMuxer::new(buf, &tracks, Some("No Chapters"), 60.0).unwrap(); + let data = muxer.writer.into_inner(); + assert!( + find_id(&data, ebml::CHAPTERS).is_none(), + "Chapters element should not be present when no chapters given" + ); + } + + #[test] + fn mkv_default_flag_on_first_video_and_audio() { + // First video: is_default=true, first audio: is_default=true, second audio: is_default=false + let video = make_video_track(); // is_default: true + let audio1 = make_audio_track(); // is_default: true + let mut audio2 = make_audio_track(); + audio2.is_default = false; + audio2.language = "fra".into(); + + let buf = Cursor::new(Vec::new()); + let tracks = [video, audio1, audio2]; + let muxer = MkvMuxer::new(buf, &tracks, None, 60.0).unwrap(); + let data = muxer.writer.into_inner(); + + // FlagDefault ID is 0x88. When is_default is true, FlagDefault is NOT written + // (MKV default is 1). When is_default is false, FlagDefault=0 IS written. + // So we should find at least one FlagDefault element (for the non-default track). + let flag_default_id = ebml::FLAG_DEFAULT.to_be_bytes(); + let needle = &[flag_default_id[3]]; // 0x88 is a 1-byte ID + let count = data.windows(1).filter(|w| w[0] == 0x88).count(); + // 0x88 appears as FlagDefault + as TrackType (also 0x83... no, 0x83 != 0x88) + // FlagDefault (0x88) should appear for the non-default track + assert!(count >= 1, "FlagDefault should be written for non-default tracks"); + } + + #[test] + fn mkv_forced_flag_on_forced_subtitle() { + use crate::disc::SubtitleStream; + let video = make_video_track(); + let forced_sub = MkvTrack::subtitle(&SubtitleStream { + pid: 0x1200, + codec: Codec::Pgs, + language: "eng".into(), + forced: true, + codec_data: None, + }); + assert!(forced_sub.is_forced); + + let buf = Cursor::new(Vec::new()); + let tracks = [video, forced_sub]; + let muxer = MkvMuxer::new(buf, &tracks, None, 60.0).unwrap(); + let data = muxer.writer.into_inner(); + + // FlagForced ID: 0x55AA (2-byte ID) + assert!( + find_id(&data, ebml::FLAG_FORCED).is_some(), + "FlagForced element should be present for forced subtitle track" + ); + } + + #[test] + fn mkv_no_forced_flag_on_non_forced_subtitle() { + use crate::disc::SubtitleStream; + let video = make_video_track(); + let sub = MkvTrack::subtitle(&SubtitleStream { + pid: 0x1200, + codec: Codec::Pgs, + language: "eng".into(), + forced: false, + codec_data: None, + }); + assert!(!sub.is_forced); + + let buf = Cursor::new(Vec::new()); + let tracks = [video, sub]; + let muxer = MkvMuxer::new(buf, &tracks, None, 60.0).unwrap(); + let data = muxer.writer.into_inner(); + + // FlagForced should NOT be written for non-forced tracks + assert!( + find_id(&data, ebml::FLAG_FORCED).is_none(), + "FlagForced element should not be present for non-forced subtitle" + ); + } } diff --git a/src/mux/mkvstream.rs b/src/mux/mkvstream.rs index b1d67e8..f577ad7 100644 --- a/src/mux/mkvstream.rs +++ b/src/mux/mkvstream.rs @@ -51,6 +51,8 @@ pub struct MkvStream { mode: Mode, max_buffer: usize, finished: bool, + /// File size in bytes, set for read mode. + file_size: Option, } impl MkvStream { @@ -71,6 +73,7 @@ impl MkvStream { }), max_buffer: DEFAULT_MAX_BUFFER, finished: false, + file_size: None, } } @@ -90,7 +93,7 @@ impl MkvStream { crate::disc::Stream::Subtitle(s) => ( s.pid, MkvTrack::subtitle(s), - codec::parser_for_codec(s.codec), + codec::parser_for_codec_with_data(s.codec, s.codec_data.clone()), ), }; let idx = ws.tracks.len(); @@ -116,6 +119,8 @@ impl MkvStream { /// Open an MKV file for reading. pub fn open(mut reader: impl Read + Seek + 'static) -> io::Result { + let file_size = reader.seek(SeekFrom::End(0))?; + reader.seek(SeekFrom::Start(0))?; let disc_title = parse_mkv_header(&mut reader)?; Ok(Self { disc_title, @@ -128,6 +133,7 @@ impl MkvStream { }), max_buffer: 0, finished: false, + file_size: Some(file_size), }) } } @@ -156,6 +162,10 @@ impl IOStream for MkvStream { } Ok(()) } + + fn total_bytes(&self) -> Option { + self.file_size + } } // ── Write ────────────────────────────────────────────────────── @@ -314,11 +324,12 @@ fn begin_streaming(ws: &mut WriteState, dt: &DiscTitle) -> io::Result<()> { .take() .ok_or_else(|| io::Error::other("writer already consumed"))?; - ws.muxer = Some(MkvMuxer::new( + ws.muxer = Some(MkvMuxer::new_with_chapters( writer, &ws.tracks, Some(&dt.playlist), dt.duration_secs, + &dt.chapters, )?); ws.phase = WritePhase::Streaming; @@ -531,6 +542,7 @@ fn parse_track(r: &mut (impl Read + Seek), size: u64) -> io::Result None, }) diff --git a/src/mux/mod.rs b/src/mux/mod.rs index 7562ba1..9c19503 100644 --- a/src/mux/mod.rs +++ b/src/mux/mod.rs @@ -58,6 +58,11 @@ pub trait IOStream: Read + Write { /// Finalize the stream (flush, write index/cues, close). fn finish(&mut self) -> io::Result<()>; + + /// Total content size in bytes, if known. Used for progress display. + fn total_bytes(&self) -> Option { + None + } } // Combined traits for internal trait objects. diff --git a/src/mux/network.rs b/src/mux/network.rs index 9877b8e..0a5e58d 100644 --- a/src/mux/network.rs +++ b/src/mux/network.rs @@ -179,6 +179,7 @@ mod tests { label: "English".into(), }), ], + chapters: Vec::new(), extents: Vec::new(), content_format: ContentFormat::BdTs, } diff --git a/src/mux/null.rs b/src/mux/null.rs index d0129da..61c83c8 100644 --- a/src/mux/null.rs +++ b/src/mux/null.rs @@ -105,4 +105,10 @@ mod tests { assert_eq!(info.streams.len(), 0); boxed.finish().unwrap(); } + + #[test] + fn null_total_bytes_returns_none() { + let ns = NullStream::new(); + assert_eq!(ns.total_bytes(), None); + } } diff --git a/src/mux/stdio.rs b/src/mux/stdio.rs index 5de3568..0a6ae65 100644 --- a/src/mux/stdio.rs +++ b/src/mux/stdio.rs @@ -103,4 +103,12 @@ mod tests { assert_eq!(err.kind(), io::ErrorKind::Unsupported); assert!(err.to_string().contains("cannot write"), "got: {}", err); } + + #[test] + fn stdio_total_bytes_returns_none() { + let input = StdioStream::input(); + assert_eq!(input.total_bytes(), None); + let output = StdioStream::output(); + assert_eq!(output.total_bytes(), None); + } } diff --git a/src/mux/ts.rs b/src/mux/ts.rs index e1c9daf..35ebe43 100644 --- a/src/mux/ts.rs +++ b/src/mux/ts.rs @@ -451,6 +451,7 @@ pub fn scan_streams(data: &[u8]) -> Option> { codec: Codec::Pgs, language: "und".into(), forced: false, + codec_data: None, })), _ => None, }; diff --git a/tests/disc_tests.rs b/tests/disc_tests.rs index ea8c786..87535c1 100644 --- a/tests/disc_tests.rs +++ b/tests/disc_tests.rs @@ -162,6 +162,7 @@ fn title_with_video(codec: Codec, resolution: &str, content_format: ContentForma secondary: false, label: String::new(), })], + chapters: Vec::new(), extents: Vec::new(), content_format, } diff --git a/tests/streams.rs b/tests/streams.rs index b7d658b..09b5254 100644 --- a/tests/streams.rs +++ b/tests/streams.rs @@ -45,8 +45,10 @@ fn sample_disc_title() -> DiscTitle { codec: Codec::Pgs, language: "eng".into(), forced: false, + codec_data: None, }), ], + chapters: Vec::new(), extents: Vec::new(), content_format: ContentFormat::BdTs, } @@ -436,6 +438,7 @@ fn meta_codec_roundtrip() { codec, language: "eng".into(), forced: false, + codec_data: None, })); } @@ -446,6 +449,7 @@ fn meta_codec_roundtrip() { size_bytes: 0, clips: Vec::new(), streams, + chapters: Vec::new(), extents: Vec::new(), content_format: ContentFormat::BdTs, }; @@ -479,6 +483,7 @@ fn meta_empty_streams() { size_bytes: 0, clips: Vec::new(), streams: Vec::new(), + chapters: Vec::new(), extents: Vec::new(), content_format: ContentFormat::BdTs, }; @@ -498,6 +503,7 @@ fn meta_all_stream_types() { duration_secs: 3600.0, size_bytes: 0, clips: Vec::new(), + chapters: Vec::new(), content_format: ContentFormat::BdTs, streams: vec![ Stream::Video(VideoStream { @@ -524,6 +530,7 @@ fn meta_all_stream_types() { codec: Codec::Pgs, language: "fra".into(), forced: true, + codec_data: None, }), Stream::Audio(AudioStream { pid: 0x1110, @@ -641,6 +648,7 @@ fn mkvstream_roundtrip_bdts() { secondary: false, label: "English".into(), })], + chapters: Vec::new(), extents: Vec::new(), content_format: ContentFormat::BdTs, }; @@ -710,14 +718,17 @@ fn mkvstream_meta_preserves_all_streams() { codec: Codec::Pgs, language: "eng".into(), forced: false, + codec_data: None, }), Stream::Subtitle(SubtitleStream { pid: 0x1201, codec: Codec::Pgs, language: "fra".into(), forced: true, + codec_data: None, }), ], + chapters: Vec::new(), extents: Vec::new(), content_format: ContentFormat::BdTs, };