Chapters, DVD subtitle palette, MKV track flags, progress total_bytes

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<u64>
- DiscStream, IsoStream: from disc_title.size_bytes
- M2tsStream, MkvStream: from file metadata on open
- NetworkStream, StdioStream, NullStream: None

316 tests total, all passing.
This commit is contained in:
MattJackson
2026-04-11 17:43:47 +00:00
parent 9cb0c369e4
commit c91d6e371f
21 changed files with 690 additions and 12 deletions
+156 -4
View File
@@ -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<Vec<u8>>,
}
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<Vec<u8>>) -> Self {
Self { codec_data }
}
}
@@ -37,10 +45,54 @@ impl CodecParser for DvdSubParser {
}
fn codec_private(&self) -> Option<Vec<u8>> {
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<u8> {
let mut parts: Vec<String> = 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");
}
}
+11 -1
View File
@@ -79,6 +79,16 @@ impl CodecParser for PassthroughParser {
/// Create the appropriate parser for a codec.
pub fn parser_for_codec(codec: Codec) -> Box<dyn CodecParser> {
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<Vec<u8>>,
) -> Box<dyn CodecParser> {
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<dyn CodecParser> {
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)),
}
}
+3
View File
@@ -90,6 +90,9 @@ impl IOStream for DiscStream {
fn finish(&mut self) -> io::Result<()> {
Ok(())
}
fn total_bytes(&self) -> Option<u64> {
Some(self.disc_title.size_bytes)
}
}
impl Read for DiscStream {
+10
View File
@@ -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;
+8
View File
@@ -205,6 +205,14 @@ impl IOStream for IsoStream {
}
Ok(())
}
fn total_bytes(&self) -> Option<u64> {
// 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 {
+15
View File
@@ -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<u64>,
}
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<Self> {
// 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<u64> {
self.content_size
}
}
impl Write for M2tsStream {
+2
View File
@@ -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,
}
+151 -3
View File
@@ -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<W: Write + Seek> {
const CLUSTER_DURATION_MS: i64 = 5000;
impl<W: Write + Seek> MkvMuxer<W> {
/// 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> {
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<Self> {
// EBML Header
let ebml_pos = ebml::start_master(&mut writer, ebml::EBML)?;
@@ -228,6 +239,25 @@ impl<W: Write + Seek> MkvMuxer<W> {
}
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"
);
}
}
+14 -2
View File
@@ -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<u64>,
}
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<Self> {
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<u64> {
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<Option<crate
codec,
language: lang,
forced,
codec_data: None,
})),
_ => None,
})
+5
View File
@@ -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<u64> {
None
}
}
// Combined traits for internal trait objects.
+1
View File
@@ -179,6 +179,7 @@ mod tests {
label: "English".into(),
}),
],
chapters: Vec::new(),
extents: Vec::new(),
content_format: ContentFormat::BdTs,
}
+6
View File
@@ -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);
}
}
+8
View File
@@ -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);
}
}
+1
View File
@@ -451,6 +451,7 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
codec: Codec::Pgs,
language: "und".into(),
forced: false,
codec_data: None,
})),
_ => None,
};