`resolve_vts_key` called the `Option`-returning `css::crack_key`, which collapses "no scrambled sector was seen" with "scrambled sectors were seen and no key came out", and then fell back to the disc-wide key. A multi-VTS CSS DVD whose second title set resists the Stevenson scan was descrambled under the FIRST set's key: corrupt PES behind an intact header, written out with `complete = true` at exit 0. `CrackOutcome` exists precisely to keep those two apart, and its doc says callers must surface the second as a hard error. Every sibling path in the crate already does — `Disc::decrypt_keys_for_title` and the mux path both map `ScrambledUncracked` to `CssKeyMissing`. This was the one path that did not. The ordering comment 15 lines above describes this exact outcome as the bug it exists to prevent; ordering makes the crack far more likely to succeed, but it cannot make a failed crack safe. Also, three things nothing could catch: - The demux output filename took the stream's language raw while the base beside it was sanitised. A language code is three raw STN bytes through `from_utf8_lossy`, and `00 00 00` is the ordinary "undefined" encoding on real discs — a NUL in a path fails `File::create` with InvalidInput, taking the whole export down before one track file opened. `sanitize` now maps control characters too; it did not. - `css::crack_key_scan`'s short-read handling was dead code under test: every source in the module returned the full request, so reverting `advance` to the requested count, or dropping the `.max(1)`, left the suite green. The `.max(1)` is load-bearing — without it a source returning `Ok(0)` never moves the cursor and never increments the budget, so the scan spins forever. That mutation now HANGS the test rather than failing it, which is the honest demonstration. - `MAX_SUBDIRS`'s const-assert carried `#[cfg(not(test))]` inside a `#[cfg(test)] mod tests`, so it compiled in no configuration and could never fire — the dead gate the test above it was written to replace. Moved to module scope, and verified it now rejects a wrong constant at compile time.
1986 lines
79 KiB
Rust
1986 lines
79 KiB
Rust
//! `demux://` sink — write each track of a title as a separate elementary
|
||
//! stream file (per-codec ES, PGS `.sup`, VobSub `.idx`/`.sub`, LPCM raw PCM),
|
||
//! plus a chapters file and per-audio-track delay metadata.
|
||
//!
|
||
//! This is a write-only [`crate::pes::Stream`]. Instead of muxing the per-track
|
||
//! `PesFrame` stream into a single container (as `MkvStream` does), it routes
|
||
//! each frame's payload to the file for `frame.track`, post-processing where the
|
||
//! internal codec `Frame` form differs from the standalone on-disk ES form:
|
||
//!
|
||
//! - **HEVC / H.264**: the codec parsers emit hvcC/avcC-style 4-byte
|
||
//! length-prefixed NALs (start codes stripped) and carry the parameter sets
|
||
//! out-of-band in `codec_private`. A standalone `.hevc`/`.h264` needs Annex-B
|
||
//! framing with the parameter sets prepended — see [`AnnexBWriter`].
|
||
//! - **PGS**: the parser collapses a display/clear PCS pair into one
|
||
//! duration-bearing `Frame` with the raw segment payload but no `PG` magic /
|
||
//! timestamp header. A `.sup` needs the HDMV segment framing rebuilt — see
|
||
//! [`PgsSupWriter`].
|
||
//! - **VobSub**: the `.sub` is the raw SPU stream; the `.idx` sidecar is
|
||
//! synthesized from per-SPU PTS + byte offsets — see [`VobSubWriter`].
|
||
//!
|
||
//! Every other codec writes `frame.data` verbatim ([`PassthroughWriter`]).
|
||
//!
|
||
//! The sink does NOT touch the MKV mux path; it is purely additive.
|
||
|
||
use crate::disc::{Chapter, Codec, DiscTitle, Stream as DiscStream};
|
||
use crate::mux::hevc::{
|
||
append_length_prefixed_as_annex_b_sized, avcc_to_annex_b, hvcc_to_annex_b, nal_length_size,
|
||
};
|
||
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};
|
||
|
||
/// Filename-naming strategy for the per-track files.
|
||
// `allow(dead_code)`: the sink honours all variants, but only the `#[default]` is
|
||
// constructed today (`output()` builds `DemuxOptions::default()`). The alternates
|
||
// are a staged option surface awaiting the CLI `--naming` flag (not yet wired).
|
||
#[allow(dead_code)]
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum Naming {
|
||
/// `<base> <track> <lang> <codec> [DELAY <n>ms].<ext>` — human-readable.
|
||
#[default]
|
||
Friendly,
|
||
/// `<base> <pid>.<ext>` — names by MPEG PID.
|
||
Pid,
|
||
/// `track<NN>.<ext>` — bare track index.
|
||
Track,
|
||
}
|
||
|
||
/// How (and whether) to record audio sync delay.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum DelayMode {
|
||
/// Embed `DELAY <n>ms` in each audio filename (the filename-delay
|
||
/// convention downstream muxers parse).
|
||
#[default]
|
||
Filename,
|
||
/// Write a `<base> delays.txt` sidecar instead.
|
||
Sidecar,
|
||
/// Record no delay information.
|
||
None,
|
||
}
|
||
|
||
/// Chapter export format.
|
||
// `allow(dead_code)`: only the `#[default]` XML variant is constructed today (via
|
||
// `DemuxOptions::default()`); OGM/Both await the CLI `--chapters` flag.
|
||
#[allow(dead_code)]
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum ChaptersFmt {
|
||
/// Matroska chapter XML.
|
||
#[default]
|
||
Xml,
|
||
/// OGM/simple `CHAPTERnn=`/`CHAPTERnnNAME=` text.
|
||
Ogm,
|
||
/// Both files.
|
||
Both,
|
||
}
|
||
|
||
/// Options controlling `demux://` output, assembled from CLI flags.
|
||
#[derive(Debug, Clone)]
|
||
pub struct DemuxOptions {
|
||
/// Filename stem (e.g. the playlist or disc label).
|
||
pub base: String,
|
||
/// Naming strategy.
|
||
pub naming: Naming,
|
||
/// Delay-metadata mode.
|
||
pub delay_mode: DelayMode,
|
||
/// Chapter export format.
|
||
pub chapters_fmt: ChaptersFmt,
|
||
/// Also export chapters (the `chapters` selection keyword / default on).
|
||
pub export_chapters: bool,
|
||
/// Selected track indices. `None` = all tracks.
|
||
pub selection: Option<Vec<usize>>,
|
||
/// Restrict output to one track class. `None` = every class (plain
|
||
/// `demux://`). `Some(Audio)` is the `audio://` sink; `Some(Subtitle)` is
|
||
/// `sub://`. Filtered tracks are skipped entirely (no file written).
|
||
pub kind_filter: Option<TrackKind>,
|
||
}
|
||
|
||
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,
|
||
kind_filter: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Track class, used for delay attribution and naming — and, via
|
||
/// [`DemuxOptions::kind_filter`], to restrict a demux to one class (the
|
||
/// `audio://` / `sub://` sinks are a `demux://` filtered to Audio / Subtitle).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum TrackKind {
|
||
Video,
|
||
Audio,
|
||
Subtitle,
|
||
}
|
||
|
||
// ── Codec → on-disk extension ────────────────────────────────────────────────
|
||
|
||
/// File extension (without the dot) for a codec's standalone elementary stream.
|
||
/// Chosen to match the conventional elementary-stream extensions downstream
|
||
/// muxers and codec tools expect.
|
||
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",
|
||
}
|
||
}
|
||
|
||
/// Short codec label for friendly filenames.
|
||
pub(crate) 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",
|
||
}
|
||
}
|
||
|
||
// ── Per-codec ES writers ─────────────────────────────────────────────────────
|
||
|
||
/// Per-codec elementary-stream writer.
|
||
///
|
||
/// Most codecs are pass-through; HEVC/H.264 re-frame to Annex-B, PGS re-frames
|
||
/// to `.sup`, VobSub records `.idx` entries and emits the sidecar at finish.
|
||
trait EsWriter: Send {
|
||
/// Write one frame's payload to `w`. Returns the number of bytes written to
|
||
/// the main file.
|
||
///
|
||
/// Nothing in production reads this count: the sole caller discards it with
|
||
/// `?`, and `VobSubWriter` tracks `.idx` fileposes from its own `pos` field.
|
||
/// This previously claimed the VobSub writer depended on the return value,
|
||
/// which would have a maintainer believe a wrong count shows up as broken
|
||
/// `.idx` output. It does not.
|
||
fn write_frame(&mut self, w: &mut dyn Write, f: &PesFrame, pts_ns: i64) -> io::Result<usize>;
|
||
|
||
/// Finalize. Default: no-op. The VobSub writer serializes its `.idx` here.
|
||
fn finish(&mut self, _w: &mut dyn Write) -> io::Result<()> {
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// Verbatim pass-through: `frame.data` is already a standalone ES.
|
||
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())
|
||
}
|
||
}
|
||
|
||
/// HEVC/H.264 writer: reframes length-prefixed NALs (the hvcC/avcC form the
|
||
/// parsers emit) into Annex-B, prepending the parameter sets once.
|
||
struct AnnexBWriter {
|
||
/// Annex-B-framed VPS/SPS/PPS (or SPS/PPS), parsed from the hvcC/avcC.
|
||
params: Vec<u8>,
|
||
wrote_params: bool,
|
||
/// Octets per NAL length prefix, from the configuration record's
|
||
/// `lengthSizeMinusOne` (ISO/IEC 14496-15). NOT assumed to be 4: a legal
|
||
/// avcC/hvcC may declare 1 or 2, and reading those as u32-BE parses no NALs
|
||
/// at all, so the raw prefixed bytes would be emitted as if already Annex B.
|
||
length_size: usize,
|
||
/// Reused length-prefixed -> Annex-B conversion buffer. `write_frame` used to
|
||
/// allocate and free a whole-frame Vec per video frame; extracting the video ES
|
||
/// of a UHD title is ~200,000 frames of 150-400 KB, every one over the
|
||
/// allocator's mmap threshold, so that was ~200,000 mmap/munmap pairs plus
|
||
/// millions of first-touch page faults of pure overhead. Kept on the writer and
|
||
/// cleared per frame instead, matching what tsmux.rs already does.
|
||
scratch: Vec<u8>,
|
||
}
|
||
|
||
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,
|
||
length_size: nal_length_size(codec, codec_private),
|
||
scratch: Vec::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
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 {
|
||
// Prepend the parameter sets at the very start of the stream so a
|
||
// raw decoder (which has no hvcC/avcC) sees them in-band.
|
||
if !self.params.is_empty() {
|
||
w.write_all(&self.params)?;
|
||
n += self.params.len();
|
||
}
|
||
self.wrote_params = true;
|
||
}
|
||
// Reframe via the canonical length-prefixed→Annex-B converter (single
|
||
// source of truth across all muxers — see `crate::mux::hevc`). It skips
|
||
// zero-length NALs and drops a truncated trailing NAL without panicking,
|
||
// rather than `break`ing on the first zero-length NAL.
|
||
// Reuse the writer's buffer rather than allocating per frame; clear()
|
||
// keeps the capacity, so steady state costs no allocation at all. The
|
||
// prefix width still comes from the record, never a hardcoded 4.
|
||
self.scratch.clear();
|
||
self.scratch.reserve(f.data.len() + (f.data.len() / 32) + 4);
|
||
let mut scratch = std::mem::take(&mut self.scratch);
|
||
append_length_prefixed_as_annex_b_sized(&mut scratch, &f.data, self.length_size);
|
||
w.write_all(&scratch)?;
|
||
n += scratch.len();
|
||
self.scratch = scratch;
|
||
Ok(n)
|
||
}
|
||
}
|
||
|
||
/// Extract the parameter-set NALs from an hvcC (HEVC) or avcC (H.264)
|
||
/// configuration record and return them as a single Annex-B blob
|
||
/// (`00 00 00 01 | NAL …`). Returns an empty Vec if the record can't be parsed.
|
||
/// Delegates to the canonical hvcC/avcC → Annex-B converters in
|
||
/// [`crate::mux::hevc`] — the single source of truth across all muxers.
|
||
fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec<u8> {
|
||
let converted = match codec {
|
||
Codec::Hevc => hvcc_to_annex_b(record),
|
||
Codec::H264 => avcc_to_annex_b(record),
|
||
_ => return Vec::new(),
|
||
};
|
||
converted.unwrap_or_else(|| {
|
||
// A malformed hvcC/avcC record yields no parameter sets. Returning empty
|
||
// means keyframes ship WITHOUT in-band SPS/PPS — playable from the first
|
||
// keyframe but broken for seek-to-arbitrary-point and hardware decoders.
|
||
// Surface it rather than silently degrading the output.
|
||
tracing::warn!(
|
||
target: "mux",
|
||
?codec,
|
||
"codec-private (hvcC/avcC) parse failed; keyframes will lack in-band SPS/PPS"
|
||
);
|
||
Vec::new()
|
||
})
|
||
}
|
||
|
||
/// PGS `.sup` writer: rebuilds the HDMV segment framing the parser stripped.
|
||
///
|
||
/// The parser hands us the concatenated PGS segments of a display set in
|
||
/// `frame.data` (segment_type + segment_size + payload, repeated), with no `PG`
|
||
/// magic and no PTS/DTS. A `.sup` prefixes each segment with a 13-byte header:
|
||
/// `0x50 0x47` ("PG") | PTS u32 BE | DTS u32 BE | (segment_type|size already
|
||
/// present in the payload).
|
||
/// When the parser folded a trailing clear (`duration_ns` set), we re-emit it as
|
||
/// an empty composition at `pts + duration` so players time the subtitle out.
|
||
struct PgsSupWriter;
|
||
|
||
// ── PGS / HDMV segment framing constants ─────────────────────────────────────
|
||
// HDMV Presentation Graphics Stream, as published in the Blu-ray Disc
|
||
// Read-Only Format (BD-ROM) Part 3 graphics-stream specification (and the
|
||
// public US 2009/0185789 A1 application that documents the segment layout).
|
||
|
||
/// `.sup` per-segment magic: ASCII "PG" (0x50 0x47) starting each segment's
|
||
/// 13-byte header (magic | PTS u32 BE | DTS u32 BE) in a PGStream `.sup` file.
|
||
const SUP_MAGIC: [u8; 2] = [0x50, 0x47];
|
||
/// Size in bytes of the `.sup` per-segment header (magic 2 + PTS 4 + DTS 4).
|
||
const SUP_HEADER_LEN: usize = SUP_MAGIC.len() + 4 + 4;
|
||
/// PGS segment type: Presentation Composition Segment (PCS).
|
||
const SEG_PCS: u8 = 0x16;
|
||
/// PGS segment type: END of display set.
|
||
const SEG_END: u8 = 0x80;
|
||
/// PCS `composition_state` value: Normal (an update to the current epoch).
|
||
const PCS_COMPOSITION_STATE_NORMAL: u8 = 0x00;
|
||
/// PGS segment header on the wire (inside `frame.data`): type(1) + size(2 BE).
|
||
const PGS_SEG_HEADER_LEN: usize = 3;
|
||
/// Byte offset of `width`/`height` within a PCS segment (after type+size).
|
||
const PCS_WIDTH_OFFSET: usize = PGS_SEG_HEADER_LEN; // 3
|
||
|
||
/// 90 kHz ticks from nanoseconds (saturating into u32 for the `.sup` header).
|
||
fn ns_to_90k(pts_ns: i64) -> u32 {
|
||
if pts_ns <= 0 {
|
||
return 0;
|
||
}
|
||
// 90_000 / 1e9 = 9 / 100_000
|
||
let ticks = (pts_ns as i128 * 9) / 100_000;
|
||
ticks.clamp(0, u32::MAX as i128) as u32
|
||
}
|
||
|
||
impl PgsSupWriter {
|
||
/// Walk the concatenated segments in `data`, emitting each with a `PG`
|
||
/// header carrying `pts90k`/`dts90k`. Returns bytes written.
|
||
fn emit_segments(
|
||
data: &[u8],
|
||
pts90k: u32,
|
||
dts90k: u32,
|
||
w: &mut dyn Write,
|
||
) -> io::Result<usize> {
|
||
let mut pos = 0;
|
||
let mut written = 0;
|
||
// Each PGS segment in the payload is: type(1) + size(2 BE) + size bytes.
|
||
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)
|
||
}
|
||
|
||
/// Build a synthetic "clear" display set: an empty PCS (0 composition
|
||
/// objects) followed by an END segment. The parser folds the original
|
||
/// clear/end PCS pair's wipe time into the display frame's `duration_ns`
|
||
/// and drops the clear bytes, so a faithful `.sup` re-emits one here at
|
||
/// `display_pts + duration`. Without it every subtitle lingers to EOF.
|
||
///
|
||
/// `width`/`height` are carried from the display set's PCS so the clear PCS
|
||
/// advertises the same video geometry; they don't affect the wipe but keep
|
||
/// the segment well-formed.
|
||
///
|
||
/// Returned bytes are concatenated `type(1)+size(2 BE)+payload` segments,
|
||
/// the same shape [`emit_segments`] consumes.
|
||
fn synthetic_clear_display_set(width: u16, height: u16) -> Vec<u8> {
|
||
// Empty PCS payload (HDMV PGS, BD-ROM Part 3): width(2) height(2)
|
||
// frame_rate(1) composition_number(2) composition_state(1)
|
||
// palette_update_flag(1) palette_id(1) number_of_composition_objects(1).
|
||
const PCS_FRAME_RATE: u8 = 0x10; // reserved high nibble | rate code
|
||
const PCS_NO_OBJECTS: u8 = 0x00; // number_of_composition_objects = 0
|
||
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, // composition_number
|
||
PCS_COMPOSITION_STATE_NORMAL,
|
||
0x00, // palette_update_flag
|
||
0x00, // palette_id
|
||
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);
|
||
// END segment: type SEG_END, zero-length payload.
|
||
out.push(SEG_END);
|
||
out.extend_from_slice(&0u16.to_be_bytes());
|
||
out
|
||
}
|
||
|
||
/// Read the (width, height) the display set's first PCS advertises, if the
|
||
/// frame starts with a PCS carrying them; else `(0, 0)`.
|
||
fn pcs_dimensions(data: &[u8]) -> (u16, u16) {
|
||
// segment: type(1) size(2) payload; PCS payload begins width(2) height(2).
|
||
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)?;
|
||
// The parser folds the display/clear PCS pair's wipe time into
|
||
// `duration_ns` and drops the clear bytes. Re-emit a synthetic clear
|
||
// display set at `pts + duration` so the subtitle is timed out instead
|
||
// of lingering to EOF.
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// VobSub writer: appends raw SPUs to the `.sub` and records `(pts, filepos)`
|
||
/// for the `.idx` sidecar emitted at finish.
|
||
struct VobSubWriter {
|
||
idx_path: PathBuf,
|
||
/// Pre-formatted `.idx` palette header line bytes, if available.
|
||
palette_line: Option<String>,
|
||
/// Two-letter language id for the `.idx` `id:` line (empty = omit).
|
||
lang2: String,
|
||
entries: Vec<(i64, u64)>,
|
||
pos: u64,
|
||
}
|
||
|
||
impl VobSubWriter {
|
||
fn new(idx_path: PathBuf, codec_private: Option<&[u8]>, lang: &str) -> Self {
|
||
// codec_private for DvdSub is the pre-formatted VobSub `.idx` palette
|
||
// header (UTF-8). Carry it through verbatim if present.
|
||
let palette_line = codec_private
|
||
.and_then(|b| std::str::from_utf8(b).ok())
|
||
.map(|s| s.trim_end().to_string());
|
||
// VobSub `id:` lines use a 2-letter code; stream languages are ISO
|
||
// 639-2 (3-letter). Take the leading two chars — the convention
|
||
// downstream muxers read to assign a track language.
|
||
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");
|
||
// The conventional `id: <lang2>, index: 0` line downstream muxers read
|
||
// to assign the subtitle track's language. Omit the language token when
|
||
// unknown but still emit the index so the entry list is well-formed.
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// VobSub `.idx` timestamp: `HH:MM:SS:mmm` (note colon before ms, per spec).
|
||
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}")
|
||
}
|
||
|
||
/// Pick the per-codec writer for a track.
|
||
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),
|
||
}
|
||
}
|
||
|
||
// ── Delay + chapter helpers ──────────────────────────────────────────────────
|
||
|
||
/// Delay in ms = round((audio_first_pts − ref_video_first_pts) / 1e6).
|
||
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;
|
||
// Round to nearest ms (ties away from zero).
|
||
if diff >= 0 {
|
||
(diff + 500_000) / 1_000_000
|
||
} else {
|
||
(diff - 500_000) / 1_000_000
|
||
}
|
||
}
|
||
|
||
/// `DELAY <signed-int>ms` — matches the conventional case-insensitive
|
||
/// `delay\s+(-?\d+)` filename-delay convention downstream muxers parse.
|
||
fn delay_token(ms: i64) -> String {
|
||
format!("DELAY {ms}ms")
|
||
}
|
||
|
||
/// Format a chapter time (seconds) as `HH:MM:SS.nnnnnnnnn` for chapter XML.
|
||
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}")
|
||
}
|
||
|
||
/// Serialize chapters as Matroska chapter XML.
|
||
pub(crate) 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
|
||
}
|
||
|
||
/// Serialize chapters as OGM/simple chapter text.
|
||
pub(crate) fn chapters_ogm(chapters: &[Chapter]) -> String {
|
||
let mut s = String::new();
|
||
for (i, c) in chapters.iter().enumerate() {
|
||
let n = i + 1;
|
||
// OGM uses HH:MM:SS.mmm (millisecond precision).
|
||
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('>', ">")
|
||
}
|
||
|
||
/// Replace path-hostile characters in a filename component.
|
||
///
|
||
/// Control characters included, NUL above all. Every string this touches is
|
||
/// disc bytes, and a language code is three raw STN bytes run through
|
||
/// `from_utf8_lossy` with no validation — `00 00 00` is the ordinary
|
||
/// "undefined" encoding on real Blu-rays. A NUL in a path aborts `File::create`
|
||
/// with `InvalidInput`, which took the whole demux export down before a single
|
||
/// track file was opened.
|
||
fn sanitize(s: &str) -> String {
|
||
s.chars()
|
||
.map(|c| match c {
|
||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||
c if c.is_control() => '_',
|
||
_ => c,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
// ── The sink ─────────────────────────────────────────────────────────────────
|
||
|
||
/// One open output track.
|
||
struct TrackOut {
|
||
/// Current on-disk path (audio is renamed to embed the delay at finish).
|
||
path: PathBuf,
|
||
w: BufWriter<File>,
|
||
kind: TrackKind,
|
||
writer: Box<dyn EsWriter>,
|
||
first_pts_ns: Option<i64>,
|
||
}
|
||
|
||
/// `demux://` sink: one file per selected track + chapters + delay metadata.
|
||
pub struct DemuxSink {
|
||
dir: PathBuf,
|
||
title: DiscTitle,
|
||
opts: DemuxOptions,
|
||
/// Index = track id; `None` for unselected tracks.
|
||
tracks: Vec<Option<TrackOut>>,
|
||
ref_video_track: Option<usize>,
|
||
/// Every VIDEO track index, recorded before the kind filter.
|
||
///
|
||
/// The filtered `tracks` slots are `None` for a class this export drops, so
|
||
/// they cannot answer "is this video" for a frame that still flows through
|
||
/// `write()`.
|
||
video_tracks: std::collections::HashSet<usize>,
|
||
/// First PTS observed on `ref_video_track`, recorded in `write()` REGARDLESS
|
||
/// of whether that track has a `TrackOut`. The DELAY reference cannot live in
|
||
/// `TrackOut::first_pts_ns`: `audio://` / `sub://` filter the video track's
|
||
/// output away in `create()`, so no `TrackOut` exists to record it, and the
|
||
/// old `unwrap_or(0)` fallback then measured every delay against a reference
|
||
/// of zero and baked a plausible-looking wrong `DELAY` into the filename.
|
||
/// `None` = no reference seen → no delay is emitted at all (see
|
||
/// `apply_delays`).
|
||
ref_first_pts_ns: Option<i64>,
|
||
timeline: TimelineContinuity,
|
||
finished: bool,
|
||
/// Frames the timeline PLACED (not necessarily persisted).
|
||
///
|
||
/// A frame for a track this export filters out still flows through
|
||
/// `write()` and still counts here, so this is "the timeline placed
|
||
/// something" rather than "a file received bytes". That is the right
|
||
/// denominator for the drop gate below — it asks whether placement worked
|
||
/// at all — but it is deliberately NOT a per-track written count, so a
|
||
/// single lost track among many is not visible to it.
|
||
frames_mapped: u64,
|
||
}
|
||
|
||
impl DemuxSink {
|
||
/// Create the sink: make `dir`, and open one file per selected track.
|
||
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;
|
||
let mut video_tracks: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
||
|
||
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())
|
||
}
|
||
};
|
||
// Record the primary-video reference BEFORE the kind filter: the video
|
||
// track drives multi-clip PTS-continuity rebasing and the audio DELAY
|
||
// tag even for `audio://` / `sub://` outputs, where its frames flow
|
||
// through write() but are not persisted to disk.
|
||
if kind == TrackKind::Video && ref_video_track.is_none() {
|
||
ref_video_track = Some(idx);
|
||
}
|
||
// Also BEFORE the kind filter, and for the same reason: the seam
|
||
// crossing rule differs for a track that carries B-frame reorder,
|
||
// and a video track still flows through write() on an `audio://`
|
||
// or `sub://` export even though nothing persists it. Reading the
|
||
// kind back out of the filtered slot would call it non-video and
|
||
// hand it the permissive rule.
|
||
if kind == TrackKind::Video {
|
||
video_tracks.insert(idx);
|
||
}
|
||
// Kind filter: `audio://` / `sub://` keep only their class.
|
||
if opts.kind_filter.is_some_and(|k| k != kind) {
|
||
tracks.push(None);
|
||
continue;
|
||
}
|
||
|
||
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)?;
|
||
|
||
// VobSub carries a sidecar `.idx`.
|
||
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; // sidecar path is owned by the VobSub writer
|
||
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,
|
||
video_tracks,
|
||
ref_first_pts_ns: None,
|
||
timeline: TimelineContinuity::with_clips(&title.clips, title.content_format),
|
||
finished: false,
|
||
frames_mapped: 0,
|
||
})
|
||
}
|
||
|
||
/// Filename stem (without extension) for a track.
|
||
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}")];
|
||
// The language is disc bytes too, and got none of the
|
||
// treatment `opts.base` two lines up already had.
|
||
let lang = sanitize(lang);
|
||
if !lang.is_empty() {
|
||
parts.push(lang);
|
||
}
|
||
parts.push(codec_label(codec).to_string());
|
||
parts.join(" ")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Apply audio delays (rename files) or write the `delays.txt` sidecar.
|
||
fn apply_delays(&mut self) -> io::Result<()> {
|
||
if self.opts.delay_mode == DelayMode::None {
|
||
return Ok(());
|
||
}
|
||
// No video reference (audio-only title, or the reference track never
|
||
// produced a frame) → there is nothing to measure a delay against.
|
||
// OMIT the delay entirely rather than fall back to a reference of zero:
|
||
// a filename claiming `DELAY 600ms` when the real offset is unknown is a
|
||
// silently wrong number that downstream muxers will act on, while a
|
||
// missing tag is simply "no delay information", which is the truth.
|
||
let Some(ref_pts) = self.ref_first_pts_ns else {
|
||
tracing::warn!(
|
||
target: "mux",
|
||
"demux sink: no reference video PTS observed; omitting audio DELAY metadata"
|
||
);
|
||
return Ok(());
|
||
};
|
||
|
||
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 => {
|
||
// Insert the delay token before the extension.
|
||
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(())
|
||
}
|
||
|
||
/// Write the chapter file(s).
|
||
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>> {
|
||
// Write-only sink, per the Stream trait contract.
|
||
Err(crate::error::Error::StreamWriteOnly.into())
|
||
}
|
||
|
||
fn write(&mut self, frame: &PesFrame) -> io::Result<()> {
|
||
// The PRIMARY VIDEO track (`ref_video_track`, the first DiscStream::Video)
|
||
// drives epoch decisions; every other track is a passive rider on the same
|
||
// global offset — see `TimelineContinuity`. Drive epochs off the same
|
||
// dynamically-resolved video reference used for the audio-delay
|
||
// computation, NOT the literal stream index 0 — an M2TS/PMT title can list
|
||
// an audio ES before the video ES, in which case track 0 is audio and a
|
||
// non-video epoch driver would ratchet the frontier on sparse/lagging PTS.
|
||
let drives = Some(frame.track) == self.ref_video_track;
|
||
// Keyed on the track KIND, not on driving epochs: a second video track
|
||
// (a Dolby Vision enhancement layer) does not drive epochs but does
|
||
// carry B-frame reorder, and the seam-crossing rule differs on exactly
|
||
// that property.
|
||
let is_video = self.video_tracks.contains(&frame.track);
|
||
// See `MkvMuxer::write_frame`: `None` is material outside the
|
||
// playlist's clip marks and is dropped rather than emitted.
|
||
let Some(pts) = self.timeline.map(
|
||
frame.pts,
|
||
drives,
|
||
frame.track,
|
||
is_video,
|
||
frame.source.map(|s| s.byte),
|
||
) else {
|
||
return Ok(());
|
||
};
|
||
self.frames_mapped = self.frames_mapped.saturating_add(1);
|
||
if drives {
|
||
// Delay reference: recorded here, not in the track's `TrackOut`, so
|
||
// it survives the `audio://` / `sub://` kind filter dropping the
|
||
// video output.
|
||
self.ref_first_pts_ns.get_or_insert(pts);
|
||
}
|
||
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;
|
||
// Same reporting as the MKV muxer's finish. Frames the playlist's clip
|
||
// marks exclude are dropped on purpose, but the count must not be
|
||
// write-only in one sink and reported in the other — an unexpected
|
||
// volume here is how a demux ends up quietly short.
|
||
let seam_dropped = self.timeline.dropped_total();
|
||
// Frames ARRIVED and every one was dropped. That is a fully-dropped
|
||
// title, which this sink used to finish cleanly: a directory of
|
||
// zero-byte track files beside a populated chapters document, at exit
|
||
// 0. Keyed on frames having been offered, because a sink that is never
|
||
// given any — a chapters-only export, or a track class the title does
|
||
// not carry — legitimately writes none.
|
||
if self.frames_mapped == 0 && seam_dropped > 0 {
|
||
return Err(crate::error::Error::SinkWroteNothing.into());
|
||
}
|
||
if seam_dropped > self.frames_mapped {
|
||
return Err(crate::error::Error::SeamPlanDroppedMost {
|
||
dropped: seam_dropped,
|
||
written: self.frames_mapped,
|
||
}
|
||
.into());
|
||
}
|
||
if seam_dropped > 0 {
|
||
tracing::info!(
|
||
target: "mux",
|
||
dropped = seam_dropped,
|
||
"frames outside the playlist's clip marks were dropped at clip joins"
|
||
);
|
||
}
|
||
// Flush each track's codec writer, then the buffered file.
|
||
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,
|
||
};
|
||
|
||
// ── The language component is disc bytes ──────────────────────────────
|
||
//
|
||
// `opts.base` was sanitised and the language beside it was not, though a
|
||
// language code is three raw STN bytes run through `from_utf8_lossy` with
|
||
// no validation. `00 00 00` is the ordinary "undefined" encoding on real
|
||
// Blu-rays, and a NUL in a path fails `File::create` with `InvalidInput` —
|
||
// which aborted the whole export before a single track file was opened.
|
||
|
||
/// The stem stays one usable filename component whatever the disc says.
|
||
#[test]
|
||
fn a_hostile_language_code_cannot_break_the_output_filename() {
|
||
let opts = DemuxOptions {
|
||
base: "Movie".to_string(),
|
||
..Default::default()
|
||
};
|
||
assert!(matches!(opts.naming, Naming::Friendly), "default naming");
|
||
|
||
for lang in ["\u{0}\u{0}\u{0}", "a/b", "..", "a\nb", "e:s"] {
|
||
let stem = DemuxSink::stem_for(&opts, 1, 0x1100, lang, Codec::Ac3);
|
||
assert!(
|
||
!stem.chars().any(|c| c.is_control()),
|
||
"control character survived into the filename for {lang:?}: {stem:?}"
|
||
);
|
||
assert!(
|
||
!stem.contains('/') && !stem.contains('\\') && !stem.contains(':'),
|
||
"a path separator survived for {lang:?}: {stem:?}"
|
||
);
|
||
assert!(
|
||
std::path::Path::new(&stem).components().count() == 1,
|
||
"the stem must stay ONE component for {lang:?}: {stem:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A legitimate language is still carried through untouched — the
|
||
/// sanitiser must not be so lossy that it stops naming the track.
|
||
#[test]
|
||
fn an_ordinary_language_code_survives_sanitising() {
|
||
let opts = DemuxOptions {
|
||
base: "Movie".to_string(),
|
||
..Default::default()
|
||
};
|
||
let stem = DemuxSink::stem_for(&opts, 1, 0x1100, "eng", Codec::Ac3);
|
||
assert!(stem.contains("eng"), "got {stem:?}");
|
||
}
|
||
|
||
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 subtitle_stream(codec: Codec, lang: &str) -> DiscStream {
|
||
DiscStream::Subtitle(crate::disc::SubtitleStream {
|
||
pid: 0x1200,
|
||
codec,
|
||
language: lang.to_string(),
|
||
forced: false,
|
||
qualifier: crate::disc::LabelQualifier::None,
|
||
codec_data: None,
|
||
})
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
/// `audio://` and `sub://` are `demux://` with a kind filter: only tracks of
|
||
/// the selected class get a file; every other track is skipped entirely.
|
||
#[test]
|
||
fn kind_filter_keeps_only_the_selected_class() {
|
||
let title = title_with(
|
||
vec![
|
||
video_stream(Codec::H264),
|
||
audio_stream(Codec::Ac3, "eng"),
|
||
subtitle_stream(Codec::Pgs, "eng"),
|
||
],
|
||
vec![None, None, None],
|
||
);
|
||
let sub_opts = DemuxOptions {
|
||
kind_filter: Some(TrackKind::Subtitle),
|
||
export_chapters: false,
|
||
..Default::default()
|
||
};
|
||
let sub = DemuxSink::create(&tempdir(), &title, &sub_opts).unwrap();
|
||
assert!(
|
||
sub.tracks[0].is_none() && sub.tracks[1].is_none() && sub.tracks[2].is_some(),
|
||
"sub:// keeps only the subtitle track"
|
||
);
|
||
let audio_opts = DemuxOptions {
|
||
kind_filter: Some(TrackKind::Audio),
|
||
export_chapters: false,
|
||
..Default::default()
|
||
};
|
||
let audio = DemuxSink::create(&tempdir(), &title, &audio_opts).unwrap();
|
||
assert!(
|
||
audio.tracks[0].is_none() && audio.tracks[1].is_some() && audio.tracks[2].is_none(),
|
||
"audio:// keeps only the audio track"
|
||
);
|
||
}
|
||
|
||
/// `audio://` filters the video track's file out, but the video track is
|
||
/// still the DELAY reference. The delay must be measured against the video's
|
||
/// actual first PTS, not against an assumed zero — a filename that says
|
||
/// `DELAY 600ms` when the true audio offset is 100ms is worse than no tag.
|
||
#[test]
|
||
fn audio_only_sink_delays_against_filtered_video_reference() {
|
||
let dir = tempdir();
|
||
let title = title_with(
|
||
vec![video_stream(Codec::Mpeg2), audio_stream(Codec::Ac3, "eng")],
|
||
vec![None, None],
|
||
);
|
||
let opts = DemuxOptions {
|
||
base: "Ao".to_string(),
|
||
kind_filter: Some(TrackKind::Audio),
|
||
export_chapters: false,
|
||
..Default::default()
|
||
};
|
||
let mut sink = DemuxSink::create(&dir, &title, &opts).unwrap();
|
||
// Video starts at 500ms, audio at 600ms → true delay is +100ms.
|
||
sink.write(&PesFrame {
|
||
coding: None,
|
||
source: None,
|
||
track: 0,
|
||
pts: 500_000_000,
|
||
keyframe: true,
|
||
data: vec![0x00, 0x00, 0x01, 0xB3],
|
||
duration_ns: None,
|
||
})
|
||
.unwrap();
|
||
sink.write(&PesFrame {
|
||
coding: None,
|
||
source: None,
|
||
track: 1,
|
||
pts: 600_000_000,
|
||
keyframe: true,
|
||
data: vec![0x0B, 0x77],
|
||
duration_ns: None,
|
||
})
|
||
.unwrap();
|
||
sink.finish().unwrap();
|
||
|
||
let names: Vec<String> = std::fs::read_dir(&dir)
|
||
.unwrap()
|
||
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
||
.collect();
|
||
assert!(
|
||
names.iter().any(|n| n == "Ao t01 eng AC3 DELAY 100ms.ac3"),
|
||
"audio delay must be relative to the filtered video reference \
|
||
(500ms), got {names:?}"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// With no video reference at all (audio-only title), there is nothing to
|
||
/// measure the delay against. Omit the DELAY tag rather than emit one
|
||
/// computed against a fabricated zero reference.
|
||
#[test]
|
||
fn no_video_reference_omits_delay_tag() {
|
||
let dir = tempdir();
|
||
let title = title_with(vec![audio_stream(Codec::Ac3, "eng")], vec![None]);
|
||
let opts = DemuxOptions {
|
||
base: "NoRef".to_string(),
|
||
export_chapters: false,
|
||
..Default::default()
|
||
};
|
||
let mut sink = DemuxSink::create(&dir, &title, &opts).unwrap();
|
||
sink.write(&PesFrame {
|
||
coding: None,
|
||
source: None,
|
||
track: 0,
|
||
pts: 600_000_000,
|
||
keyframe: true,
|
||
data: vec![0x0B, 0x77],
|
||
duration_ns: None,
|
||
})
|
||
.unwrap();
|
||
sink.finish().unwrap();
|
||
|
||
let names: Vec<String> = std::fs::read_dir(&dir)
|
||
.unwrap()
|
||
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
||
.collect();
|
||
assert!(
|
||
names.iter().all(|n| !n.to_lowercase().contains("delay")),
|
||
"no video reference → no DELAY tag, got {names:?}"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
// ── Annex-B reframing ────────────────────────────────────────────────────
|
||
//
|
||
// The length-prefixed → Annex-B conversion and the hvcC/avcC param-set
|
||
// extraction are exercised canonically in `crate::mux::hevc`; the sink
|
||
// delegates to those helpers. Here we only assert the sink-level wiring:
|
||
// param-set prepend and (crucially) that a zero-length NAL mid-frame no
|
||
// longer truncates the rest of the access unit.
|
||
|
||
#[test]
|
||
fn zero_length_nal_midframe_does_not_truncate_access_unit() {
|
||
// The OLD local reframer `break`d on a zero-length NAL, dropping every
|
||
// NAL after it. The canonical `append_length_prefixed_as_annex_b` skips
|
||
// just the empty NAL and keeps going. Frame: NAL(2) | NAL(0) | NAL(3).
|
||
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, // NAL #1 (len 2)
|
||
0, 0, 0, 0, // zero-length NAL — must be skipped, not fatal
|
||
0, 0, 0, 3, 0x01, 0x02, 0x03, // NAL #3 (len 3) — must survive
|
||
],
|
||
duration_ns: None,
|
||
};
|
||
w.write_frame(&mut out, &f, 0).unwrap();
|
||
// Both real NALs present; the empty NAL emitted nothing.
|
||
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"
|
||
);
|
||
}
|
||
|
||
/// Regression (silent corruption): the sink reframed every frame as if the
|
||
/// NAL length prefixes were 4 octets wide. ISO/IEC 14496-15 §5.3.3.1.2 lets
|
||
/// an avcC declare `lengthSizeMinusOne = 1` (2-octet prefixes); reading those
|
||
/// as one u32-BE gives an absurd length, so nothing parsed and the raw
|
||
/// prefixed bytes were written to the `demux://` ES verbatim — no start
|
||
/// codes, undecodable video, and no error anywhere.
|
||
#[test]
|
||
fn annexb_writer_honours_the_records_declared_nal_length_size() {
|
||
// avcC with byte 4 = 0xFD → lengthSizeMinusOne 1 → 2-octet prefixes.
|
||
// numSPS = 1 (0xE1), SPS len 2 = [0x67 0x42], numPPS = 1, PPS len 1.
|
||
let rec = [
|
||
1, 0x42, 0x00, 0x1F, 0xFD, 0xE1, 0, 2, 0x67, 0x42, 1, 0, 1, 0x68,
|
||
];
|
||
assert_eq!(nal_length_size(Codec::H264, Some(&rec)), 2);
|
||
let mut w = AnnexBWriter::new(Codec::H264, Some(&rec));
|
||
let mut out = Vec::new();
|
||
let f = PesFrame {
|
||
coding: None,
|
||
source: None,
|
||
track: 0,
|
||
pts: 0,
|
||
keyframe: true,
|
||
// Two NALs with 2-octet length prefixes.
|
||
data: vec![0, 2, 0xAA, 0xBB, 0, 3, 0x01, 0x02, 0x03],
|
||
duration_ns: None,
|
||
};
|
||
w.write_frame(&mut out, &f, 0).unwrap();
|
||
assert_eq!(
|
||
out,
|
||
vec![
|
||
0, 0, 0, 1, 0x67, 0x42, // SPS
|
||
0, 0, 0, 1, 0x68, // PPS
|
||
0, 0, 0, 1, 0xAA, 0xBB, // frame NAL #1
|
||
0, 0, 0, 1, 0x01, 0x02, 0x03, // frame NAL #2
|
||
],
|
||
"2-octet-prefixed NALs must reach the ES as Annex B"
|
||
);
|
||
}
|
||
|
||
#[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();
|
||
// params (SPS+PPS as annexb) then the frame NAL.
|
||
assert_eq!(
|
||
out,
|
||
vec![
|
||
0, 0, 0, 1, 0x67, 0x42, // SPS
|
||
0, 0, 0, 1, 0x68, // PPS
|
||
0, 0, 0, 1, 0xAA, 0xBB // frame NAL
|
||
]
|
||
);
|
||
// Second frame: NO param re-prepend.
|
||
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]);
|
||
}
|
||
|
||
// ── Delay ────────────────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn delay_ms_sign_and_rounding() {
|
||
// Audio later than video → positive (must be delayed).
|
||
assert_eq!(delay_ms(1_000_000_000, 0), 1000);
|
||
// Audio earlier than video → negative (must be advanced).
|
||
assert_eq!(delay_ms(0, 248_000_000), -248);
|
||
// Rounding to nearest ms.
|
||
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() {
|
||
// Convention: case-insensitive /delay\s+(-?\d+)/.
|
||
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));
|
||
}
|
||
|
||
/// Minimal stand-in for the `delay\s+(-?\d+)` convention (case-insensitive).
|
||
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()
|
||
}
|
||
|
||
// ── PGS .sup framing ─────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn pgs_sup_frames_each_segment_with_pg_header() {
|
||
// One segment: type=0x16, size=2, payload=[0xDE,0xAD].
|
||
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()); // PTS
|
||
assert_eq!(&out[6..10], &0x10u32.to_be_bytes()); // DTS
|
||
assert_eq!(&out[SUP_HEADER_LEN..], &payload); // segment body verbatim
|
||
assert_eq!(written, SUP_HEADER_LEN + 2);
|
||
}
|
||
|
||
#[test]
|
||
fn pgs_frame_with_duration_emits_clear_segment() {
|
||
// A display set with a real PCS (type 0x16) carrying 1920x1080, and a
|
||
// duration → the writer must append a synthetic clear display set
|
||
// (empty PCS + END) timestamped at pts + duration.
|
||
let mut pcs = vec![SEG_PCS, 0x00, 0x0B];
|
||
pcs.extend_from_slice(&[0x07, 0x80, 0x04, 0x38]); // 1920x1080
|
||
pcs.extend_from_slice(&[0x10, 0x00, 0x00, 0x80, 0x00, 0x00, 0x01]); // 1 object
|
||
let f = PesFrame {
|
||
coding: None,
|
||
source: None,
|
||
track: 0,
|
||
pts: 1_000_000_000, // 1s
|
||
keyframe: true,
|
||
data: pcs,
|
||
duration_ns: Some(2_000_000_000), // 2s display → clear at 3s
|
||
};
|
||
let mut out = Vec::new();
|
||
let mut w = PgsSupWriter;
|
||
w.write_frame(&mut out, &f, f.pts).unwrap();
|
||
|
||
// Parse out every PG-framed segment: PG(2) PTS(4) DTS(4) type(1) size(2).
|
||
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;
|
||
}
|
||
// Display PCS at 1s (90k), then a clear PCS + END at 3s.
|
||
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() {
|
||
// No duration → no synthetic clear (the subtitle's wipe time is unknown).
|
||
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();
|
||
// Exactly one PG-framed segment (the display), no clear appended.
|
||
// Output = `.sup` header (10) + the on-wire segment (type+size 3 + 2
|
||
// payload = 5) → 15 bytes, with no trailing clear.
|
||
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);
|
||
// 1 second = 90000 ticks.
|
||
assert_eq!(ns_to_90k(1_000_000_000), 90_000);
|
||
assert_eq!(ns_to_90k(-5), 0);
|
||
}
|
||
|
||
// ── VobSub .idx ──────────────────────────────────────────────────────────
|
||
|
||
#[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"));
|
||
// The conventional `id:` line downstream muxers read to assign the language.
|
||
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"));
|
||
// Second SPU at 1s, filepos = 10.
|
||
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");
|
||
}
|
||
|
||
// ── Chapters ─────────────────────────────────────────────────────────────
|
||
|
||
#[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"));
|
||
}
|
||
|
||
/// Chapter names originate on the disc, which is untrusted input, and the
|
||
/// sink drops them straight into an XML document (`<ChapterString>`). Escaping
|
||
/// is what keeps a hostile or merely odd name from terminating the element and
|
||
/// injecting markup — the document is character data, so XML 1.0 §2.4 requires
|
||
/// `&` and `<`, and escaping `>` as well keeps a `]]>` sequence safe too.
|
||
///
|
||
/// The order matters as much as the set: `&` must be replaced FIRST, otherwise
|
||
/// the ampersands introduced by the `<`/`>` replacements get escaped a second
|
||
/// time and `<` renders as the literal text `<` instead of a `<`.
|
||
#[test]
|
||
fn chapter_names_are_xml_escaped_so_a_disc_cannot_inject_markup() {
|
||
let chaps = vec![Chapter {
|
||
time_secs: 0.0,
|
||
name: "</ChapterString><Injected/> Tom & Jerry <3 >:(".to_string(),
|
||
}];
|
||
let xml = chapters_xml(&chaps);
|
||
|
||
assert!(
|
||
xml.contains(
|
||
"<ChapterString></ChapterString><Injected/> \
|
||
Tom & Jerry <3 >:(</ChapterString>"
|
||
),
|
||
"every metacharacter escaped, and `&` escaped first so nothing is \
|
||
double-escaped; got:\n{xml}"
|
||
);
|
||
// The injected element must not survive as markup anywhere in the file.
|
||
assert!(
|
||
!xml.contains("<Injected/>"),
|
||
"a chapter name must not be able to open a new element"
|
||
);
|
||
// Exactly one ChapterString element pair — the name did not close it early.
|
||
assert_eq!(xml.matches("<ChapterString>").count(), 1);
|
||
assert_eq!(xml.matches("</ChapterString>").count(), 1);
|
||
|
||
// A name with no metacharacters passes through byte-identical: escaping
|
||
// must not rewrite ordinary text.
|
||
let plain = chapters_xml(&[Chapter {
|
||
time_secs: 0.0,
|
||
name: "Opening Credits".to_string(),
|
||
}]);
|
||
assert!(plain.contains("<ChapterString>Opening Credits</ChapterString>"));
|
||
}
|
||
|
||
// ── Timeline continuity ──────────────────────────────────────────────────
|
||
//
|
||
// The corrector itself is tested verbatim in `crate::mux::timeline`. Here we
|
||
// only confirm the sink drives it with the right `drives_epoch`: track 0 is
|
||
// the epoch driver, every other track is a passive rider on the same offset.
|
||
|
||
#[test]
|
||
fn timeline_track0_drives_epoch_others_ride() {
|
||
let mut tl = TimelineContinuity::new();
|
||
// Clip 1: video 0..10s (track 0 drives the epoch).
|
||
assert_eq!(tl.adjust(0, true, 0), 0);
|
||
assert_eq!(tl.adjust(0, false, 1), 0); // audio rides the same offset
|
||
assert_eq!(tl.adjust(10_000_000_000, true, 0), 10_000_000_000);
|
||
// Clip 2 seam: video PTS jumps back to ~0 (> 3s back) → new epoch.
|
||
let out = tl.adjust(0, true, 0);
|
||
assert!(out >= 10_000_000_000, "epoch must advance past prev high");
|
||
// Audio in clip 2 (non-epoch) gets the SAME offset (A/V sync preserved).
|
||
let a = tl.adjust(0, false, 1);
|
||
assert_eq!(a, out);
|
||
}
|
||
|
||
/// Regression for the hardcoded `frame.track == 0` epoch driver: on an
|
||
/// M2TS/PMT title the PMT can list an AUDIO ES before the VIDEO ES, so the
|
||
/// video lands at stream index 1. The sink already resolves the video
|
||
/// reference dynamically (`ref_video_track`); the epoch driver must use that
|
||
/// SAME reference, not the literal 0. Here track 0 is audio and track 1 is
|
||
/// video. A video-only clip boundary (track 1) must open a new epoch (bump
|
||
/// `offset_ns`). With the bug (only `frame.track == 0` drives epochs) the
|
||
/// video back-jump would be treated as a passive rider and `offset_ns` would
|
||
/// stay 0 — corrupting every track's rebased timeline.
|
||
#[test]
|
||
fn epoch_driver_follows_ref_video_not_track_zero() {
|
||
let dir = tempdir();
|
||
// Audio FIRST (index 0), video SECOND (index 1).
|
||
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, // VIDEO is track 1 here
|
||
pts,
|
||
keyframe: true,
|
||
data: vec![0x00, 0x00, 0x00, 0x01, data],
|
||
duration_ns: None,
|
||
};
|
||
// Clip 1 video: 0s then 10s — advances the frontier.
|
||
sink.write(&vid(0, 0xAA)).unwrap();
|
||
sink.write(&vid(10_000_000_000, 0xBB)).unwrap();
|
||
// Clip 2 seam: video PTS jumps back to ~0 (> 3s back) → NEW epoch. The
|
||
// video track (index 1) must drive this, bumping the offset.
|
||
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);
|
||
}
|
||
|
||
// ── End-to-end sink ──────────────────────────────────────────────────────
|
||
|
||
#[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();
|
||
|
||
// Video frame (track 0) and audio frame (track 1).
|
||
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, // audio 100ms late
|
||
keyframe: true,
|
||
data: vec![0x0B, 0x77, 0x01, 0x02],
|
||
duration_ns: None,
|
||
})
|
||
.unwrap();
|
||
sink.finish().unwrap();
|
||
|
||
// Video file written verbatim (passthrough).
|
||
let v = std::fs::read(dir.join("Test t00 MPEG2.m2v")).unwrap();
|
||
assert_eq!(v, vec![0x00, 0x00, 0x01, 0xB3, 0xDE]);
|
||
// Audio file renamed with the delay token (100ms → DELAY 100ms).
|
||
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]), // video only
|
||
..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(); // dropped — track 1 not selected
|
||
sink.finish().unwrap();
|
||
assert!(dir.join("Sel t00 MPEG2.m2v").exists());
|
||
// No audio file created.
|
||
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);
|
||
}
|
||
|
||
/// A demux export whose seam plan drops every frame must FAIL, not write a
|
||
/// directory of empty track files and report success.
|
||
///
|
||
/// Audit finding: this sink builds its timeline with
|
||
/// `TimelineContinuity::with_clips` but no test ever gave it a title with
|
||
/// usable PlayItem marks, so `frames_mapped` and `dropped_total()` were
|
||
/// always zero and BOTH gates in `finish` were unreachable. Deleting either
|
||
/// left the whole suite green while a `demux://` export of a
|
||
/// seamless-branching title wrote zero-byte track files beside a populated
|
||
/// chapters document, at exit 0.
|
||
#[test]
|
||
fn a_demux_export_the_seam_plan_emptied_fails() {
|
||
let dir = tempdir();
|
||
let mut title = title_with(vec![video_stream(Codec::H264)], vec![None]);
|
||
// Two clips (SeamPlan needs >= 2 with strictly increasing IN marks)
|
||
// covering 100s..200s and 200s..300s in 45 kHz ticks.
|
||
title.clips = vec![
|
||
crate::disc::Clip {
|
||
feed_span: None,
|
||
clip_id: "00000".into(),
|
||
in_time: 100 * 45_000,
|
||
out_time: 200 * 45_000,
|
||
duration_secs: 100.0,
|
||
source_packets: 0,
|
||
},
|
||
crate::disc::Clip {
|
||
feed_span: None,
|
||
clip_id: "00001".into(),
|
||
in_time: 200 * 45_000,
|
||
out_time: 300 * 45_000,
|
||
duration_secs: 100.0,
|
||
source_packets: 0,
|
||
},
|
||
];
|
||
let mut sink = DemuxSink::create(&dir, &title, &DemuxOptions::default()).unwrap();
|
||
|
||
// Frames far before the first IN mark: the plan places none of them.
|
||
for (i, pts) in [0i64, 1_000_000_000].iter().enumerate() {
|
||
let f = PesFrame {
|
||
coding: None,
|
||
source: None,
|
||
track: 0,
|
||
pts: *pts,
|
||
keyframe: true,
|
||
data: vec![0x00, 0x00, 0x00, 0x01, 0x09, 0x10, i as u8],
|
||
duration_ns: None,
|
||
};
|
||
let _ = Stream::write(&mut sink, &f);
|
||
}
|
||
|
||
let err = Stream::finish(&mut sink).expect_err("an emptied export must fail");
|
||
assert_eq!(
|
||
crate::error::error_code(&err),
|
||
Some(crate::error::E_SINK_WROTE_NOTHING),
|
||
"a fully-dropped demux export must report SinkWroteNothing, not success"
|
||
);
|
||
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);
|
||
}
|
||
|
||
/// Tiny unique temp dir helper (avoids a dev-dependency on `tempfile`).
|
||
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
|
||
}
|
||
|
||
/// `demux://` exports the title's chapters as side files at `finish()`.
|
||
/// A `write_chapters` that returned `Ok(())` without writing produces a
|
||
/// demux run that reports complete success while the chapter files simply
|
||
/// do not exist — the caller has no way to tell an intentionally
|
||
/// chapterless title from a lost export.
|
||
///
|
||
/// Both formats are requested at once and BOTH files are checked, with
|
||
/// distinct chapter names and a non-zero timestamp, so a writer that emitted
|
||
/// one format, an empty file, or the wrong chapter list cannot pass.
|
||
#[test]
|
||
fn finish_exports_both_chapter_formats_with_real_content() {
|
||
let dir = tempdir();
|
||
let mut title = title_with(vec![video_stream(Codec::Mpeg2)], vec![None]);
|
||
title.chapters = vec![
|
||
crate::disc::Chapter {
|
||
time_secs: 0.0,
|
||
name: "Opening".into(),
|
||
},
|
||
crate::disc::Chapter {
|
||
time_secs: 62.5,
|
||
name: "Second".into(),
|
||
},
|
||
];
|
||
let opts = DemuxOptions {
|
||
base: "ChapTitle".into(),
|
||
export_chapters: true,
|
||
chapters_fmt: ChaptersFmt::Both,
|
||
..Default::default()
|
||
};
|
||
let mut sink = DemuxSink::create(&dir, &title, &opts).unwrap();
|
||
sink.finish().unwrap();
|
||
|
||
let xml = std::fs::read_to_string(dir.join("ChapTitle chapters.xml"))
|
||
.expect("chapters.xml must exist after finish");
|
||
let ogm = std::fs::read_to_string(dir.join("ChapTitle chapters.txt"))
|
||
.expect("chapters.txt must exist after finish");
|
||
|
||
// Content, not merely existence: both chapters, both names, and the
|
||
// 62.5 s timestamp formatted per its format.
|
||
assert!(
|
||
xml.contains("Opening") && xml.contains("Second"),
|
||
"xml: {xml}"
|
||
);
|
||
assert!(
|
||
xml.contains("00:01:02.500"),
|
||
"xml must carry the real chapter time: {xml}"
|
||
);
|
||
assert!(
|
||
ogm.contains("CHAPTER01=") && ogm.contains("CHAPTER02="),
|
||
"ogm: {ogm}"
|
||
);
|
||
assert!(
|
||
ogm.contains("Opening") && ogm.contains("Second"),
|
||
"ogm names: {ogm}"
|
||
);
|
||
assert!(
|
||
ogm.contains("00:01:02.500"),
|
||
"ogm must carry the real chapter time: {ogm}"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// The other side of the gate: with the export switched off, no chapter
|
||
/// file is written at all. Without this the test above would also pass for
|
||
/// a `write_chapters` that ignored `opts.export_chapters`.
|
||
#[test]
|
||
fn chapters_are_not_exported_when_the_option_is_off() {
|
||
let dir = tempdir();
|
||
let mut title = title_with(vec![video_stream(Codec::Mpeg2)], vec![None]);
|
||
title.chapters = vec![crate::disc::Chapter {
|
||
time_secs: 0.0,
|
||
name: "Opening".into(),
|
||
}];
|
||
let opts = DemuxOptions {
|
||
base: "ChapTitle".into(),
|
||
export_chapters: false,
|
||
chapters_fmt: ChaptersFmt::Both,
|
||
..Default::default()
|
||
};
|
||
let mut sink = DemuxSink::create(&dir, &title, &opts).unwrap();
|
||
sink.finish().unwrap();
|
||
assert!(!dir.join("ChapTitle chapters.xml").exists());
|
||
assert!(!dir.join("ChapTitle chapters.txt").exists());
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Every filename component `demux://` writes is built from DISC-CONTROLLED
|
||
/// text — the volume label / playlist name (`opts.base`) and the track label.
|
||
/// `sanitize` is the only thing standing between that text and the
|
||
/// filesystem: a surviving `/` (or `\` on Windows) makes the sink write
|
||
/// OUTSIDE the output directory the caller chose, and `:` / `?` / `*` / `"` /
|
||
/// `<` / `>` / `|` make the create fail outright on Windows and on SMB/exFAT
|
||
/// shares, which are the normal targets for a rip.
|
||
#[test]
|
||
fn sanitize_neutralises_every_path_hostile_character() {
|
||
for c in ['/', '\\', ':', '*', '?', '"', '<', '>', '|'] {
|
||
let got = sanitize(&format!("a{c}b"));
|
||
assert_eq!(got, "a_b", "{c:?} must be replaced, got {got:?}");
|
||
}
|
||
// A traversal attempt in a disc label cannot escape the output directory:
|
||
// no separator survives, so the whole thing stays ONE component.
|
||
let escaped = sanitize("../../etc/passwd");
|
||
assert_eq!(escaped, ".._.._etc_passwd");
|
||
assert!(
|
||
!std::path::Path::new(&escaped)
|
||
.components()
|
||
.any(|c| matches!(c, std::path::Component::ParentDir)),
|
||
"the sanitized name must not decompose into a parent-directory hop"
|
||
);
|
||
// Ordinary characters — including spaces, dots, unicode and other
|
||
// punctuation — are preserved, so the replacement is targeted, not a
|
||
// blanket scrub that would mangle real titles.
|
||
assert_eq!(
|
||
sanitize("Amélie (2001) - Chapter 1.5 [Director's Cut]"),
|
||
"Amélie (2001) - Chapter 1.5 [Director's Cut]"
|
||
);
|
||
}
|
||
|
||
/// End-to-end witness that `sanitize` is actually applied on the write path:
|
||
/// a disc label containing a separator must produce ONE file inside the
|
||
/// chosen directory, never a write into a sibling/parent path.
|
||
#[test]
|
||
fn a_disc_label_with_a_separator_cannot_write_outside_the_output_directory() {
|
||
let dir = tempdir();
|
||
let title = title_with(vec![video_stream(Codec::Mpeg2)], vec![None]);
|
||
let opts = DemuxOptions {
|
||
base: "../evil/Title".into(),
|
||
export_chapters: false,
|
||
..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, 0xAA],
|
||
duration_ns: None,
|
||
})
|
||
.unwrap();
|
||
sink.finish().unwrap();
|
||
|
||
let names: Vec<String> = std::fs::read_dir(&dir)
|
||
.unwrap()
|
||
.filter_map(|e| e.ok())
|
||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||
.collect();
|
||
assert_eq!(names.len(), 1, "exactly one output file; got {names:?}");
|
||
assert!(
|
||
names[0].starts_with(".._evil_Title"),
|
||
"the separators must be neutralised in the real filename; got {names:?}"
|
||
);
|
||
assert!(
|
||
!dir.parent().unwrap().join("evil").exists(),
|
||
"nothing may be created outside the output directory"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
}
|