use crate::disc::{Chapter, Codec, DiscTitle, Stream as DiscStream};
use crate::mux::hevc::{append_length_prefixed_as_annex_b, avcc_to_annex_b, hvcc_to_annex_b};
use crate::mux::timeline::TimelineContinuity;
use crate::pes::{PesFrame, Stream};
use std::fs::File;
use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Naming {
#[default]
Friendly,
Pid,
Track,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DelayMode {
#[default]
Filename,
Sidecar,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ChaptersFmt {
#[default]
Xml,
Ogm,
Both,
}
#[derive(Debug, Clone)]
pub struct DemuxOptions {
pub base: String,
pub naming: Naming,
pub delay_mode: DelayMode,
pub chapters_fmt: ChaptersFmt,
pub export_chapters: bool,
pub selection: Option<Vec<usize>>,
}
impl Default for DemuxOptions {
fn default() -> Self {
Self {
base: "title".to_string(),
naming: Naming::default(),
delay_mode: DelayMode::default(),
chapters_fmt: ChaptersFmt::default(),
export_chapters: true,
selection: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TrackKind {
Video,
Audio,
Subtitle,
}
fn extension_for(codec: Codec) -> &'static str {
match codec {
Codec::Hevc => "hevc",
Codec::H264 => "h264",
Codec::Vc1 => "vc1",
Codec::Mpeg2 => "m2v",
Codec::Mpeg1 => "mpv",
Codec::Av1 => "obu",
Codec::TrueHd => "thd",
Codec::DtsHdMa | Codec::DtsHdHr => "dtshd",
Codec::Dts => "dts",
Codec::Ac3 => "ac3",
Codec::Ac3Plus => "eac3",
Codec::Lpcm => "pcm",
Codec::Aac => "aac",
Codec::Mp2 => "mp2",
Codec::Mp3 => "mp3",
Codec::Flac => "flac",
Codec::Opus => "opus",
Codec::Pgs => "sup",
Codec::DvdSub => "sub",
Codec::Srt => "srt",
Codec::Ssa => "ssa",
Codec::Unknown(_) => "bin",
}
}
fn codec_label(codec: Codec) -> &'static str {
match codec {
Codec::Hevc => "HEVC",
Codec::H264 => "AVC",
Codec::Vc1 => "VC1",
Codec::Mpeg2 => "MPEG2",
Codec::Mpeg1 => "MPEG1",
Codec::Av1 => "AV1",
Codec::TrueHd => "TrueHD",
Codec::DtsHdMa => "DTS-HD-MA",
Codec::DtsHdHr => "DTS-HD-HR",
Codec::Dts => "DTS",
Codec::Ac3 => "AC3",
Codec::Ac3Plus => "EAC3",
Codec::Lpcm => "LPCM",
Codec::Aac => "AAC",
Codec::Mp2 => "MP2",
Codec::Mp3 => "MP3",
Codec::Flac => "FLAC",
Codec::Opus => "Opus",
Codec::Pgs => "PGS",
Codec::DvdSub => "VobSub",
Codec::Srt => "SRT",
Codec::Ssa => "SSA",
Codec::Unknown(_) => "Unknown",
}
}
trait EsWriter: Send {
fn write_frame(&mut self, w: &mut dyn Write, f: &PesFrame, pts_ns: i64) -> io::Result<usize>;
fn finish(&mut self, _w: &mut dyn Write) -> io::Result<()> {
Ok(())
}
}
struct PassthroughWriter;
impl EsWriter for PassthroughWriter {
fn write_frame(&mut self, w: &mut dyn Write, f: &PesFrame, _pts: i64) -> io::Result<usize> {
w.write_all(&f.data)?;
Ok(f.data.len())
}
}
struct AnnexBWriter {
params: Vec<u8>,
wrote_params: bool,
}
impl AnnexBWriter {
fn new(codec: Codec, codec_private: Option<&[u8]>) -> Self {
let params = codec_private
.map(|rec| annexb_param_sets(codec, rec))
.unwrap_or_default();
Self {
params,
wrote_params: false,
}
}
}
impl EsWriter for AnnexBWriter {
fn write_frame(&mut self, w: &mut dyn Write, f: &PesFrame, _pts: i64) -> io::Result<usize> {
let mut n = 0;
if !self.wrote_params {
if !self.params.is_empty() {
w.write_all(&self.params)?;
n += self.params.len();
}
self.wrote_params = true;
}
let mut scratch = Vec::with_capacity(f.data.len() + (f.data.len() / 32) + 4);
append_length_prefixed_as_annex_b(&mut scratch, &f.data);
w.write_all(&scratch)?;
n += scratch.len();
Ok(n)
}
}
fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec<u8> {
match codec {
Codec::Hevc => hvcc_to_annex_b(record).unwrap_or_default(),
Codec::H264 => avcc_to_annex_b(record).unwrap_or_default(),
_ => Vec::new(),
}
}
struct PgsSupWriter;
const SUP_MAGIC: [u8; 2] = [0x50, 0x47];
const SUP_HEADER_LEN: usize = SUP_MAGIC.len() + 4 + 4;
const SEG_PCS: u8 = 0x16;
const SEG_END: u8 = 0x80;
const PCS_COMPOSITION_STATE_NORMAL: u8 = 0x00;
const PGS_SEG_HEADER_LEN: usize = 3;
const PCS_WIDTH_OFFSET: usize = PGS_SEG_HEADER_LEN;
fn ns_to_90k(pts_ns: i64) -> u32 {
if pts_ns <= 0 {
return 0;
}
let ticks = (pts_ns as i128 * 9) / 100_000;
ticks.clamp(0, u32::MAX as i128) as u32
}
impl PgsSupWriter {
fn emit_segments(
data: &[u8],
pts90k: u32,
dts90k: u32,
w: &mut dyn Write,
) -> io::Result<usize> {
let mut pos = 0;
let mut written = 0;
while pos + PGS_SEG_HEADER_LEN <= data.len() {
let size = u16::from_be_bytes([data[pos + 1], data[pos + 2]]) as usize;
let seg_end = pos + PGS_SEG_HEADER_LEN + size;
if seg_end > data.len() {
break;
}
w.write_all(&SUP_MAGIC)?;
w.write_all(&pts90k.to_be_bytes())?;
w.write_all(&dts90k.to_be_bytes())?;
w.write_all(&data[pos..seg_end])?;
written += SUP_HEADER_LEN + size;
pos = seg_end;
}
Ok(written)
}
fn synthetic_clear_display_set(width: u16, height: u16) -> Vec<u8> {
const PCS_FRAME_RATE: u8 = 0x10; const PCS_NO_OBJECTS: u8 = 0x00; let [w_hi, w_lo] = width.to_be_bytes();
let [h_hi, h_lo] = height.to_be_bytes();
let pcs_payload = [
w_hi,
w_lo,
h_hi,
h_lo,
PCS_FRAME_RATE,
0x00,
0x00, PCS_COMPOSITION_STATE_NORMAL,
0x00, 0x00, PCS_NO_OBJECTS,
];
let mut out = Vec::with_capacity(PGS_SEG_HEADER_LEN * 2 + pcs_payload.len());
out.push(SEG_PCS);
out.extend_from_slice(&(pcs_payload.len() as u16).to_be_bytes());
out.extend_from_slice(&pcs_payload);
out.push(SEG_END);
out.extend_from_slice(&0u16.to_be_bytes());
out
}
fn pcs_dimensions(data: &[u8]) -> (u16, u16) {
if data.len() >= PCS_WIDTH_OFFSET + 4 && data[0] == SEG_PCS {
let w = u16::from_be_bytes([data[PCS_WIDTH_OFFSET], data[PCS_WIDTH_OFFSET + 1]]);
let h = u16::from_be_bytes([data[PCS_WIDTH_OFFSET + 2], data[PCS_WIDTH_OFFSET + 3]]);
(w, h)
} else {
(0, 0)
}
}
}
impl EsWriter for PgsSupWriter {
fn write_frame(&mut self, w: &mut dyn Write, f: &PesFrame, pts_ns: i64) -> io::Result<usize> {
let pts90 = ns_to_90k(pts_ns);
let mut written = Self::emit_segments(&f.data, pts90, pts90, w)?;
if let Some(dur) = f.duration_ns {
let clear_pts = ns_to_90k(pts_ns.saturating_add(dur as i64));
let (w_px, h_px) = Self::pcs_dimensions(&f.data);
let clear = Self::synthetic_clear_display_set(w_px, h_px);
written += Self::emit_segments(&clear, clear_pts, clear_pts, w)?;
}
Ok(written)
}
}
struct VobSubWriter {
idx_path: PathBuf,
palette_line: Option<String>,
lang2: String,
entries: Vec<(i64, u64)>,
pos: u64,
}
impl VobSubWriter {
fn new(idx_path: PathBuf, codec_private: Option<&[u8]>, lang: &str) -> Self {
let palette_line = codec_private
.and_then(|b| std::str::from_utf8(b).ok())
.map(|s| s.trim_end().to_string());
let lang2: String = lang.chars().take(2).collect();
Self {
idx_path,
palette_line,
lang2,
entries: Vec::new(),
pos: 0,
}
}
}
impl EsWriter for VobSubWriter {
fn write_frame(&mut self, w: &mut dyn Write, f: &PesFrame, pts_ns: i64) -> io::Result<usize> {
self.entries.push((pts_ns, self.pos));
w.write_all(&f.data)?;
self.pos += f.data.len() as u64;
Ok(f.data.len())
}
fn finish(&mut self, _w: &mut dyn Write) -> io::Result<()> {
let mut idx = String::new();
idx.push_str("# VobSub index file, v7\n");
if let Some(p) = &self.palette_line {
idx.push_str(p);
idx.push('\n');
}
idx.push_str("langidx: 0\n\n");
if self.lang2.is_empty() {
idx.push_str("id: , index: 0\n");
} else {
idx.push_str(&format!("id: {}, index: 0\n", self.lang2));
}
for (pts_ns, filepos) in &self.entries {
idx.push_str(&format!(
"timestamp: {}, filepos: {:09x}\n",
fmt_idx_timestamp(*pts_ns),
filepos
));
}
std::fs::write(&self.idx_path, idx)
}
}
fn fmt_idx_timestamp(pts_ns: i64) -> String {
let total_ms = (pts_ns.max(0) / 1_000_000) as u64;
let ms = total_ms % 1000;
let total_s = total_ms / 1000;
let s = total_s % 60;
let m = (total_s / 60) % 60;
let h = total_s / 3600;
format!("{h:02}:{m:02}:{s:02}:{ms:03}")
}
fn es_writer_for(
codec: Codec,
codec_private: Option<&[u8]>,
idx_path: Option<PathBuf>,
lang: &str,
) -> Box<dyn EsWriter> {
match codec {
Codec::Hevc | Codec::H264 => Box::new(AnnexBWriter::new(codec, codec_private)),
Codec::Pgs => Box::new(PgsSupWriter),
Codec::DvdSub => Box::new(VobSubWriter::new(
idx_path.unwrap_or_else(|| PathBuf::from("subtitle.idx")),
codec_private,
lang,
)),
_ => Box::new(PassthroughWriter),
}
}
fn delay_ms(audio_first_pts_ns: i64, ref_video_first_pts_ns: i64) -> i64 {
let diff = audio_first_pts_ns - ref_video_first_pts_ns;
if diff >= 0 {
(diff + 500_000) / 1_000_000
} else {
(diff - 500_000) / 1_000_000
}
}
fn delay_token(ms: i64) -> String {
format!("DELAY {ms}ms")
}
fn fmt_chapter_time_ns(time_secs: f64) -> String {
let total_ns = (time_secs.max(0.0) * 1e9).round() as u64;
let ns = total_ns % 1_000_000_000;
let total_s = total_ns / 1_000_000_000;
let s = total_s % 60;
let m = (total_s / 60) % 60;
let h = total_s / 3600;
format!("{h:02}:{m:02}:{s:02}.{ns:09}")
}
fn chapters_xml(chapters: &[Chapter]) -> String {
let mut s = String::new();
s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
s.push_str("<!DOCTYPE Chapters SYSTEM \"matroskachapters.dtd\">\n");
s.push_str("<Chapters>\n <EditionEntry>\n");
for (i, c) in chapters.iter().enumerate() {
s.push_str(" <ChapterAtom>\n");
s.push_str(&format!(
" <ChapterTimeStart>{}</ChapterTimeStart>\n",
fmt_chapter_time_ns(c.time_secs)
));
s.push_str(" <ChapterDisplay>\n");
let name = if c.name.is_empty() {
(i + 1).to_string()
} else {
c.name.clone()
};
s.push_str(&format!(
" <ChapterString>{}</ChapterString>\n",
xml_escape(&name)
));
s.push_str(" <ChapterLanguage>und</ChapterLanguage>\n");
s.push_str(" </ChapterDisplay>\n");
s.push_str(" </ChapterAtom>\n");
}
s.push_str(" </EditionEntry>\n</Chapters>\n");
s
}
fn chapters_ogm(chapters: &[Chapter]) -> String {
let mut s = String::new();
for (i, c) in chapters.iter().enumerate() {
let n = i + 1;
let total_ms = (c.time_secs.max(0.0) * 1000.0).round() as u64;
let ms = total_ms % 1000;
let total_s = total_ms / 1000;
let sec = total_s % 60;
let m = (total_s / 60) % 60;
let h = total_s / 3600;
let name = if c.name.is_empty() {
n.to_string()
} else {
c.name.clone()
};
s.push_str(&format!("CHAPTER{n:02}={h:02}:{m:02}:{sec:02}.{ms:03}\n"));
s.push_str(&format!("CHAPTER{n:02}NAME={name}\n"));
}
s
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ => c,
})
.collect()
}
struct TrackOut {
path: PathBuf,
w: BufWriter<File>,
kind: TrackKind,
writer: Box<dyn EsWriter>,
first_pts_ns: Option<i64>,
}
pub struct DemuxSink {
dir: PathBuf,
title: DiscTitle,
opts: DemuxOptions,
tracks: Vec<Option<TrackOut>>,
ref_video_track: Option<usize>,
timeline: TimelineContinuity,
finished: bool,
}
impl DemuxSink {
pub fn create(dir: &Path, title: &DiscTitle, opts: &DemuxOptions) -> io::Result<Self> {
std::fs::create_dir_all(dir)?;
let mut tracks: Vec<Option<TrackOut>> = Vec::with_capacity(title.streams.len());
let mut ref_video_track = None;
for (idx, stream) in title.streams.iter().enumerate() {
let selected = opts
.selection
.as_ref()
.map(|sel| sel.contains(&idx))
.unwrap_or(true);
if !selected {
tracks.push(None);
continue;
}
let (kind, codec, pid, lang) = match stream {
DiscStream::Video(v) => (TrackKind::Video, v.codec, v.pid, String::new()),
DiscStream::Audio(a) => (TrackKind::Audio, a.codec, a.pid, a.language.clone()),
DiscStream::Subtitle(s) => {
(TrackKind::Subtitle, s.codec, s.pid, s.language.clone())
}
};
if kind == TrackKind::Video && ref_video_track.is_none() {
ref_video_track = Some(idx);
}
let ext = extension_for(codec);
let stem = Self::stem_for(opts, idx, pid, &lang, codec);
let path = dir.join(format!("{stem}.{ext}"));
let file = File::create(&path)?;
let sidecar = if codec == Codec::DvdSub {
Some(dir.join(format!("{stem}.idx")))
} else {
None
};
let codec_private = title.codec_privates.get(idx).and_then(|o| o.as_deref());
let writer = es_writer_for(codec, codec_private, sidecar.clone(), &lang);
let _ = sidecar; tracks.push(Some(TrackOut {
path,
w: BufWriter::new(file),
kind,
writer,
first_pts_ns: None,
}));
}
Ok(Self {
dir: dir.to_path_buf(),
title: title.clone(),
opts: opts.clone(),
tracks,
ref_video_track,
timeline: TimelineContinuity::new(),
finished: false,
})
}
fn stem_for(opts: &DemuxOptions, idx: usize, pid: u16, lang: &str, codec: Codec) -> String {
match opts.naming {
Naming::Track => format!("track{idx:02}"),
Naming::Pid => format!("{} {:04x}", sanitize(&opts.base), pid),
Naming::Friendly => {
let mut parts = vec![sanitize(&opts.base), format!("t{idx:02}")];
if !lang.is_empty() {
parts.push(lang.to_string());
}
parts.push(codec_label(codec).to_string());
parts.join(" ")
}
}
}
fn apply_delays(&mut self) -> io::Result<()> {
if self.opts.delay_mode == DelayMode::None {
return Ok(());
}
let ref_pts = self
.ref_video_track
.and_then(|t| self.tracks.get(t).and_then(|o| o.as_ref()))
.and_then(|t| t.first_pts_ns)
.unwrap_or(0);
let mut sidecar_lines = String::new();
for slot in self.tracks.iter_mut() {
let Some(t) = slot.as_mut() else { continue };
if t.kind != TrackKind::Audio {
continue;
}
let Some(first) = t.first_pts_ns else {
continue;
};
let ms = delay_ms(first, ref_pts);
match self.opts.delay_mode {
DelayMode::Filename => {
let ext = t.path.extension().and_then(|e| e.to_str()).unwrap_or("");
let stem = t
.path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("audio");
let new_name = format!("{stem} {}.{ext}", delay_token(ms));
let new_path = self.dir.join(new_name);
std::fs::rename(&t.path, &new_path)?;
t.path = new_path;
}
DelayMode::Sidecar => {
let name = t
.path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("audio");
sidecar_lines.push_str(&format!("{name}\t{ms}\n"));
}
DelayMode::None => {}
}
}
if self.opts.delay_mode == DelayMode::Sidecar && !sidecar_lines.is_empty() {
let p = self
.dir
.join(format!("{} delays.txt", sanitize(&self.opts.base)));
std::fs::write(p, sidecar_lines)?;
}
Ok(())
}
fn write_chapters(&self) -> io::Result<()> {
if !self.opts.export_chapters || self.title.chapters.is_empty() {
return Ok(());
}
let base = sanitize(&self.opts.base);
if matches!(self.opts.chapters_fmt, ChaptersFmt::Xml | ChaptersFmt::Both) {
std::fs::write(
self.dir.join(format!("{base} chapters.xml")),
chapters_xml(&self.title.chapters),
)?;
}
if matches!(self.opts.chapters_fmt, ChaptersFmt::Ogm | ChaptersFmt::Both) {
std::fs::write(
self.dir.join(format!("{base} chapters.txt")),
chapters_ogm(&self.title.chapters),
)?;
}
Ok(())
}
}
impl Stream for DemuxSink {
fn read(&mut self) -> io::Result<Option<PesFrame>> {
Err(crate::error::Error::StreamWriteOnly.into())
}
fn write(&mut self, frame: &PesFrame) -> io::Result<()> {
let drives = Some(frame.track) == self.ref_video_track;
let pts = self.timeline.adjust(frame.pts, drives);
if let Some(Some(t)) = self.tracks.get_mut(frame.track) {
t.first_pts_ns.get_or_insert(pts);
t.writer.write_frame(&mut t.w, frame, pts)?;
}
Ok(())
}
fn finish(&mut self) -> io::Result<()> {
if self.finished {
return Ok(());
}
self.finished = true;
for slot in self.tracks.iter_mut() {
if let Some(t) = slot.as_mut() {
t.writer.finish(&mut t.w)?;
t.w.flush()?;
}
}
self.apply_delays()?;
self.write_chapters()?;
Ok(())
}
fn info(&self) -> &DiscTitle {
&self.title
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::disc::{
AudioChannels, AudioStream, ColorSpace, ContentFormat, FrameRate, HdrFormat, LabelPurpose,
Resolution, SampleRate, VideoStream,
};
fn video_stream(codec: Codec) -> DiscStream {
DiscStream::Video(VideoStream {
pid: 0x1011,
codec,
resolution: Resolution::R1080p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
})
}
fn audio_stream(codec: Codec, lang: &str) -> DiscStream {
DiscStream::Audio(AudioStream {
pid: 0x1100,
codec,
channels: AudioChannels::Stereo,
language: lang.to_string(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: LabelPurpose::Normal,
label: String::new(),
})
}
fn title_with(streams: Vec<DiscStream>, privates: Vec<Option<Vec<u8>>>) -> DiscTitle {
let mut t = DiscTitle::empty();
t.streams = streams;
t.codec_privates = privates;
t.content_format = ContentFormat::BdTs;
t
}
#[test]
fn zero_length_nal_midframe_does_not_truncate_access_unit() {
let mut w = AnnexBWriter::new(Codec::H264, None);
let mut out = Vec::new();
let f = PesFrame {
coding: None,
source: None,
track: 0,
pts: 0,
keyframe: true,
data: vec![
0, 0, 0, 2, 0xAA, 0xBB, 0, 0, 0, 0, 0, 0, 0, 3, 0x01, 0x02, 0x03, ],
duration_ns: None,
};
w.write_frame(&mut out, &f, 0).unwrap();
assert_eq!(
out,
vec![0, 0, 0, 1, 0xAA, 0xBB, 0, 0, 0, 1, 0x01, 0x02, 0x03],
"trailing NAL after a zero-length NAL must NOT be dropped"
);
}
#[test]
fn annexb_writer_prepends_params_once() {
let rec = [
1, 0x42, 0x00, 0x1F, 0xFF, 0xE1, 0, 2, 0x67, 0x42, 1, 0, 1, 0x68,
];
let mut w = AnnexBWriter::new(Codec::H264, Some(&rec));
let mut out = Vec::new();
let f1 = PesFrame {
coding: None,
source: None,
track: 0,
pts: 0,
keyframe: true,
data: vec![0, 0, 0, 2, 0xAA, 0xBB],
duration_ns: None,
};
w.write_frame(&mut out, &f1, 0).unwrap();
assert_eq!(
out,
vec![
0, 0, 0, 1, 0x67, 0x42, 0, 0, 0, 1, 0x68, 0, 0, 0, 1, 0xAA, 0xBB ]
);
let mut out2 = Vec::new();
let f2 = PesFrame {
coding: None,
source: None,
track: 0,
pts: 0,
keyframe: false,
data: vec![0, 0, 0, 1, 0xCC],
duration_ns: None,
};
w.write_frame(&mut out2, &f2, 0).unwrap();
assert_eq!(out2, vec![0, 0, 0, 1, 0xCC]);
}
#[test]
fn delay_ms_sign_and_rounding() {
assert_eq!(delay_ms(1_000_000_000, 0), 1000);
assert_eq!(delay_ms(0, 248_000_000), -248);
assert_eq!(delay_ms(1_600_000, 0), 2);
assert_eq!(delay_ms(1_400_000, 0), 1);
assert_eq!(delay_ms(-1_600_000, 0), -2);
}
#[test]
fn delay_token_matches_mkvmerge_regex() {
let re = regex_lite_delay;
assert_eq!(re("Movie eng AC3 DELAY -248ms.ac3"), Some(-248));
assert_eq!(re(&format!("x {}.dts", delay_token(1000))), Some(1000));
assert_eq!(re(&format!("x {}.thd", delay_token(0))), Some(0));
assert_eq!(re(&format!("x {}.eac3", delay_token(-5))), Some(-5));
}
fn regex_lite_delay(name: &str) -> Option<i64> {
let lower = name.to_lowercase();
let idx = lower.find("delay")?;
let after = &name[idx + 5..];
let after = after.trim_start();
let mut chars = after.chars().peekable();
let mut num = String::new();
if chars.peek() == Some(&'-') {
num.push('-');
chars.next();
}
for c in chars {
if c.is_ascii_digit() {
num.push(c);
} else {
break;
}
}
num.parse().ok()
}
#[test]
fn pgs_sup_frames_each_segment_with_pg_header() {
let payload = [SEG_PCS, 0x00, 0x02, 0xDE, 0xAD];
let mut out = Vec::new();
let written = PgsSupWriter::emit_segments(&payload, 0x10, 0x10, &mut out).unwrap();
assert_eq!(&out[0..2], &SUP_MAGIC);
assert_eq!(&out[2..6], &0x10u32.to_be_bytes()); assert_eq!(&out[6..10], &0x10u32.to_be_bytes()); assert_eq!(&out[SUP_HEADER_LEN..], &payload); assert_eq!(written, SUP_HEADER_LEN + 2);
}
#[test]
fn pgs_frame_with_duration_emits_clear_segment() {
let mut pcs = vec![SEG_PCS, 0x00, 0x0B];
pcs.extend_from_slice(&[0x07, 0x80, 0x04, 0x38]); pcs.extend_from_slice(&[0x10, 0x00, 0x00, 0x80, 0x00, 0x00, 0x01]); let f = PesFrame {
coding: None,
source: None,
track: 0,
pts: 1_000_000_000, keyframe: true,
data: pcs,
duration_ns: Some(2_000_000_000), };
let mut out = Vec::new();
let mut w = PgsSupWriter;
w.write_frame(&mut out, &f, f.pts).unwrap();
let mut segs: Vec<(u8, u32)> = Vec::new();
let mut pos = 0;
while pos + SUP_HEADER_LEN <= out.len() {
assert_eq!(
&out[pos..pos + 2],
&SUP_MAGIC,
"each segment carries PG magic"
);
let pts = u32::from_be_bytes([out[pos + 2], out[pos + 3], out[pos + 4], out[pos + 5]]);
let seg_type = out[pos + SUP_HEADER_LEN];
let size =
u16::from_be_bytes([out[pos + SUP_HEADER_LEN + 1], out[pos + SUP_HEADER_LEN + 2]])
as usize;
segs.push((seg_type, pts));
pos += SUP_HEADER_LEN + PGS_SEG_HEADER_LEN + size;
}
let clear90 = ns_to_90k(3_000_000_000);
assert!(
segs.iter().any(|&(t, p)| t == SEG_PCS && p == clear90),
"a clear PCS must be emitted at pts+duration, got {segs:?}"
);
assert!(
segs.iter().any(|&(t, p)| t == SEG_END && p == clear90),
"an END segment must terminate the clear display set, got {segs:?}"
);
}
#[test]
fn pgs_frame_without_duration_emits_no_clear() {
let f = PesFrame {
coding: None,
source: None,
track: 0,
pts: 0,
keyframe: true,
data: vec![SEG_PCS, 0x00, 0x02, 0xDE, 0xAD],
duration_ns: None,
};
let mut out = Vec::new();
let mut w = PgsSupWriter;
w.write_frame(&mut out, &f, 0).unwrap();
assert_eq!(&out[0..2], &SUP_MAGIC);
assert_eq!(
out.len(),
SUP_HEADER_LEN + PGS_SEG_HEADER_LEN + 2,
"only the display segment, no clear"
);
}
#[test]
fn ns_to_90k_conversion() {
assert_eq!(ns_to_90k(0), 0);
assert_eq!(ns_to_90k(1_000_000_000), 90_000);
assert_eq!(ns_to_90k(-5), 0);
}
#[test]
fn vobsub_idx_synthesis() {
let dir = tempdir();
let idx = dir.join("sub.idx");
let mut w = VobSubWriter::new(idx.clone(), Some(b"palette: 000000, ffffff"), "eng");
let mut sub = Vec::new();
let f1 = PesFrame {
coding: None,
source: None,
track: 0,
pts: 0,
keyframe: true,
data: vec![0xAA; 10],
duration_ns: None,
};
let f2 = PesFrame {
coding: None,
source: None,
track: 0,
pts: 1_000_000_000,
keyframe: true,
data: vec![0xBB; 20],
duration_ns: None,
};
w.write_frame(&mut sub, &f1, 0).unwrap();
w.write_frame(&mut sub, &f2, 1_000_000_000).unwrap();
w.finish(&mut sub).unwrap();
let idx_text = std::fs::read_to_string(&idx).unwrap();
assert!(idx_text.contains("palette: 000000, ffffff"));
assert!(
idx_text.contains("id: en, index: 0"),
"missing id: line, got:\n{idx_text}"
);
assert!(idx_text.contains("timestamp: 00:00:00:000, filepos: 000000000"));
assert!(idx_text.contains("timestamp: 00:00:01:000, filepos: 00000000a"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn idx_timestamp_format() {
assert_eq!(fmt_idx_timestamp(0), "00:00:00:000");
assert_eq!(fmt_idx_timestamp(3_661_500_000_000), "01:01:01:500");
}
#[test]
fn chapter_xml_and_ogm() {
let chaps = vec![
Chapter {
time_secs: 0.0,
name: "1".to_string(),
},
Chapter {
time_secs: 65.5,
name: "2".to_string(),
},
];
let xml = chapters_xml(&chaps);
assert!(xml.contains("<ChapterTimeStart>00:00:00.000000000</ChapterTimeStart>"));
assert!(xml.contains("<ChapterTimeStart>00:01:05.500000000</ChapterTimeStart>"));
let ogm = chapters_ogm(&chaps);
assert!(ogm.contains("CHAPTER01=00:00:00.000"));
assert!(ogm.contains("CHAPTER02=00:01:05.500"));
assert!(ogm.contains("CHAPTER02NAME=2"));
}
#[test]
fn timeline_track0_drives_epoch_others_ride() {
let mut tl = TimelineContinuity::new();
assert_eq!(tl.adjust(0, true), 0);
assert_eq!(tl.adjust(0, false), 0); assert_eq!(tl.adjust(10_000_000_000, true), 10_000_000_000);
let out = tl.adjust(0, true);
assert!(out >= 10_000_000_000, "epoch must advance past prev high");
let a = tl.adjust(0, false);
assert_eq!(a, out);
}
#[test]
fn epoch_driver_follows_ref_video_not_track_zero() {
let dir = tempdir();
let title = title_with(
vec![audio_stream(Codec::Ac3, "eng"), video_stream(Codec::H264)],
vec![None, None],
);
let mut sink = DemuxSink::create(&dir, &title, &DemuxOptions::default()).unwrap();
assert_eq!(
sink.ref_video_track,
Some(1),
"video reference must be the first VIDEO stream (index 1), not 0"
);
let vid = |pts: i64, data: u8| PesFrame {
coding: None,
source: None,
track: 1, pts,
keyframe: true,
data: vec![0x00, 0x00, 0x00, 0x01, data],
duration_ns: None,
};
sink.write(&vid(0, 0xAA)).unwrap();
sink.write(&vid(10_000_000_000, 0xBB)).unwrap();
sink.write(&vid(0, 0xCC)).unwrap();
assert!(
sink.timeline.offset_ns >= 10_000_000_000,
"video (track 1) must drive the epoch: offset_ns should have advanced \
past the previous high, got {}",
sink.timeline.offset_ns
);
sink.finish().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sink_keys_files_by_track_and_writes_all() {
let dir = tempdir();
let title = title_with(
vec![video_stream(Codec::Mpeg2), audio_stream(Codec::Ac3, "eng")],
vec![None, None],
);
let opts = DemuxOptions {
base: "Test".to_string(),
..Default::default()
};
let mut sink = DemuxSink::create(&dir, &title, &opts).unwrap();
sink.write(&PesFrame {
coding: None,
source: None,
track: 0,
pts: 0,
keyframe: true,
data: vec![0x00, 0x00, 0x01, 0xB3, 0xDE],
duration_ns: None,
})
.unwrap();
sink.write(&PesFrame {
coding: None,
source: None,
track: 1,
pts: 100_000_000, keyframe: true,
data: vec![0x0B, 0x77, 0x01, 0x02],
duration_ns: None,
})
.unwrap();
sink.finish().unwrap();
let v = std::fs::read(dir.join("Test t00 MPEG2.m2v")).unwrap();
assert_eq!(v, vec![0x00, 0x00, 0x01, 0xB3, 0xDE]);
let a = std::fs::read(dir.join("Test t01 eng AC3 DELAY 100ms.ac3")).unwrap();
assert_eq!(a, vec![0x0B, 0x77, 0x01, 0x02]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sink_respects_track_selection() {
let dir = tempdir();
let title = title_with(
vec![video_stream(Codec::Mpeg2), audio_stream(Codec::Ac3, "eng")],
vec![None, None],
);
let opts = DemuxOptions {
base: "Sel".to_string(),
selection: Some(vec![0]), ..Default::default()
};
let mut sink = DemuxSink::create(&dir, &title, &opts).unwrap();
sink.write(&PesFrame {
coding: None,
source: None,
track: 1,
pts: 0,
keyframe: true,
data: vec![0xFF],
duration_ns: None,
})
.unwrap(); sink.finish().unwrap();
assert!(dir.join("Sel t00 MPEG2.m2v").exists());
let entries: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|x| x == "ac3").unwrap_or(false))
.collect();
assert!(entries.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sink_read_returns_write_only() {
let dir = tempdir();
let title = title_with(vec![video_stream(Codec::Mpeg2)], vec![None]);
let mut sink = DemuxSink::create(&dir, &title, &DemuxOptions::default()).unwrap();
let err = Stream::read(&mut sink).expect_err("sink read must error");
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
let _ = std::fs::remove_dir_all(&dir);
}
fn tempdir() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
let p = std::env::temp_dir().join(format!("fmkv_demux_test_{}_{}", std::process::id(), n));
std::fs::create_dir_all(&p).unwrap();
p
}
}