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:
@@ -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<Chapter> = 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::<u16>().unwrap_or(0);
|
||||
|
||||
@@ -163,6 +181,7 @@ impl Disc {
|
||||
size_bytes: total_size,
|
||||
clips,
|
||||
streams,
|
||||
chapters,
|
||||
extents,
|
||||
content_format: ContentFormat::BdTs,
|
||||
})
|
||||
|
||||
@@ -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<Stream> = 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,
|
||||
});
|
||||
|
||||
@@ -110,6 +110,8 @@ pub struct DiscTitle {
|
||||
pub clips: Vec<Clip>,
|
||||
/// All streams (video, audio, subtitle, etc.)
|
||||
pub streams: Vec<Stream>,
|
||||
/// Chapter points
|
||||
pub chapters: Vec<Chapter>,
|
||||
/// Sector extents for ripping (clip LBA ranges)
|
||||
pub extents: Vec<Extent>,
|
||||
/// 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<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
+69
@@ -30,6 +30,8 @@ pub struct DvdTitleSet {
|
||||
pub video: DvdVideoAttr,
|
||||
/// Audio stream attributes (up to 8)
|
||||
pub audio_streams: Vec<DvdAudioAttr>,
|
||||
/// Subtitle stream attributes (up to 32)
|
||||
pub subtitle_streams: Vec<DvdSubtitleAttr>,
|
||||
/// Titles within this set
|
||||
pub titles: Vec<DvdTitle>,
|
||||
}
|
||||
@@ -43,6 +45,8 @@ pub struct DvdTitle {
|
||||
pub duration_secs: f64,
|
||||
/// Cell sector ranges
|
||||
pub cells: Vec<DvdCell>,
|
||||
/// Subtitle palette from PGC: 16 entries of [padding, Y, Cb, Cr].
|
||||
pub palette: Option<Vec<[u8; 4]>>,
|
||||
}
|
||||
|
||||
/// 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<DvdAudioAttr> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse one subtitle stream attribute block (6 bytes at `offset`).
|
||||
fn parse_subtitle_attr(data: &[u8], offset: usize) -> Result<DvdSubtitleAttr> {
|
||||
// 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<DvdTitle>
|
||||
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);
|
||||
|
||||
+160
-2
@@ -17,6 +17,19 @@ pub struct Playlist {
|
||||
pub play_items: Vec<PlayItem>,
|
||||
/// Streams from the first play item's STN table
|
||||
pub streams: Vec<StreamEntry>,
|
||||
/// Playlist marks (chapter points, etc.)
|
||||
pub marks: Vec<PlaylistMark>,
|
||||
}
|
||||
|
||||
/// 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<Playlist> {
|
||||
|
||||
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<Playlist> {
|
||||
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<u8>], // raw stream entry + attributes bytes for each stream
|
||||
) -> Vec<u8> {
|
||||
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<u8>],
|
||||
marks: &[TestMark],
|
||||
) -> Vec<u8> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+156
-4
@@ -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
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -179,6 +179,7 @@ mod tests {
|
||||
label: "English".into(),
|
||||
}),
|
||||
],
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user