demux: add demux:// per-track elementary-stream sink

New write-only pes::Stream sink that taps the per-track PesFrame stream
(the seam right before MKV muxing) and writes each track to its own
elementary-stream file, plus chapters and per-audio-track delay metadata.
Purely additive — the MKV mux path is untouched.

- mux/demux_sink.rs: DemuxSink + EsWriter dispatch. Pass-through for
  codecs whose Frame.data is already standalone ES (MPEG-2, VC-1, AC3/
  E-AC3, DTS/DTS-HD, TrueHD, LPCM). Non-trivial writers:
  - AnnexBWriter: reframes hvcC/avcC 4-byte-length-prefixed NALs to
    Annex-B and prepends VPS/SPS/PPS parsed out of the codec_private
    configuration record (HEVC .hevc / H.264 .h264).
  - PgsSupWriter: rebuilds the HDMV 'PG' segment framing the parser
    strips, with 90kHz PTS/DTS (.sup).
  - VobSubWriter: writes raw SPUs to .sub and synthesizes the .idx
    sidecar (palette + per-SPU timestamp/filepos).
  - Delay-in-filename (mkvmerge-readable 'DELAY <n>ms') + chapter XML/OGM
    export. TimelineRebase ports the MKV muxer's seamless-branch epoch
    logic so per-track ES timestamps stay continuous across clip joins.
- mux/resolve.rs: StreamUrl::Demux variant + scheme/path_str/parse_url/
  input(write-only)/output arms.
- mux/mod.rs: module + public type re-exports.

16 unit tests: Annex-B reframing, hvcC/avcC param extraction, delay
sign/rounding + mkvmerge-regex match, PGS .sup framing, VobSub .idx
synthesis, chapter XML/OGM, timeline rebase, and end-to-end file-keying
by track + track selection.
This commit is contained in:
Matthew Jackson
2026-06-25 16:23:37 -07:00
parent 52d2e85e3c
commit 730af6b1d9
3 changed files with 1251 additions and 1 deletions
File diff suppressed because it is too large Load Diff
+3
View File
@@ -41,6 +41,7 @@ pub mod resolve;
// dead-code lint rather than delete still-relevant scaffolding. // dead-code lint rather than delete still-relevant scaffolding.
#[allow(dead_code)] #[allow(dead_code)]
pub(crate) mod codec; pub(crate) mod codec;
pub(crate) mod demux_sink;
#[allow(dead_code)] #[allow(dead_code)]
pub(crate) mod demux_thread; pub(crate) mod demux_thread;
@@ -90,6 +91,7 @@ pub(crate) mod stdio;
pub(crate) mod ts; pub(crate) mod ts;
pub(crate) mod tsmux; pub(crate) mod tsmux;
pub use demux_sink::{ChaptersFmt, DelayMode, DemuxOptions, DemuxSink, Naming};
pub use disc::DiscStream; pub use disc::DiscStream;
pub use m2ts::M2tsStream; pub use m2ts::M2tsStream;
pub use mkvstream::MkvStream; pub use mkvstream::MkvStream;
@@ -135,6 +137,7 @@ mod tests {
assert_eq!(parse_url("stdio://").scheme(), "stdio"); assert_eq!(parse_url("stdio://").scheme(), "stdio");
assert_eq!(parse_url("iso://f").scheme(), "iso"); assert_eq!(parse_url("iso://f").scheme(), "iso");
assert_eq!(parse_url("null://").scheme(), "null"); assert_eq!(parse_url("null://").scheme(), "null");
assert_eq!(parse_url("demux://out/").scheme(), "demux");
assert_eq!(parse_url("bogus://x").scheme(), "unknown"); assert_eq!(parse_url("bogus://x").scheme(), "unknown");
} }
+33 -1
View File
@@ -55,6 +55,11 @@ pub enum StreamUrl {
Dir { path: PathBuf }, Dir { path: PathBuf },
/// Null sink (write-only, discards data). /// Null sink (write-only, discards data).
Null, Null,
/// Per-track elementary-stream output directory (`demux://`). A write-only
/// sink that fans each track of a title out to its own ES file (plus
/// chapters + delay metadata). Like `dir://` it targets a directory; the
/// CLI constructs the `DemuxSink` with full options before the mux loop.
Demux { dir: PathBuf },
/// Unrecognized URL. /// Unrecognized URL.
Unknown { raw: String }, Unknown { raw: String },
} }
@@ -71,6 +76,7 @@ impl StreamUrl {
StreamUrl::Iso { .. } => "iso", StreamUrl::Iso { .. } => "iso",
StreamUrl::Dir { .. } => "dir", StreamUrl::Dir { .. } => "dir",
StreamUrl::Null => "null", StreamUrl::Null => "null",
StreamUrl::Demux { .. } => "demux",
StreamUrl::Unknown { .. } => "unknown", StreamUrl::Unknown { .. } => "unknown",
} }
} }
@@ -83,7 +89,8 @@ impl StreamUrl {
StreamUrl::M2ts { path } StreamUrl::M2ts { path }
| StreamUrl::Mkv { path } | StreamUrl::Mkv { path }
| StreamUrl::Iso { path } | StreamUrl::Iso { path }
| StreamUrl::Dir { path } => path.to_str().unwrap_or(""), | StreamUrl::Dir { path }
| StreamUrl::Demux { dir: path } => path.to_str().unwrap_or(""),
StreamUrl::Network { addr } => addr, StreamUrl::Network { addr } => addr,
StreamUrl::Stdio | StreamUrl::Null => "", StreamUrl::Stdio | StreamUrl::Null => "",
StreamUrl::Unknown { raw } => raw, StreamUrl::Unknown { raw } => raw,
@@ -151,6 +158,11 @@ pub fn parse_url(url: &str) -> StreamUrl {
path: PathBuf::from(rest), path: PathBuf::from(rest),
}; };
} }
if let Some(rest) = url.strip_prefix("demux://") {
return StreamUrl::Demux {
dir: PathBuf::from(rest),
};
}
StreamUrl::Unknown { StreamUrl::Unknown {
raw: url.to_string(), raw: url.to_string(),
} }
@@ -389,6 +401,8 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
// PES source. Mirror `null://` → write-only. // PES source. Mirror `null://` → write-only.
StreamUrl::Dir { .. } => Err(crate::error::Error::StreamWriteOnly.into()), StreamUrl::Dir { .. } => Err(crate::error::Error::StreamWriteOnly.into()),
StreamUrl::Null => Err(crate::error::Error::StreamWriteOnly.into()), StreamUrl::Null => Err(crate::error::Error::StreamWriteOnly.into()),
// `demux://` is an output-only sink (per-track ES files); never a source.
StreamUrl::Demux { .. } => Err(crate::error::Error::StreamWriteOnly.into()),
StreamUrl::Unknown { ref raw } => { StreamUrl::Unknown { ref raw } => {
Err(crate::error::Error::StreamUrlInvalid { url: raw.clone() }.into()) Err(crate::error::Error::StreamUrlInvalid { url: raw.clone() }.into())
} }
@@ -446,6 +460,18 @@ pub fn output(
// exactly the category the crate already rejects for `iso://`. The CLI // exactly the category the crate already rejects for `iso://`. The CLI
// routes a `dir://` dest to `Disc::extract_tree` before reaching here. // routes a `dir://` dest to `Disc::extract_tree` before reaching here.
StreamUrl::Dir { .. } => Err(crate::error::Error::StreamReadOnly.into()), StreamUrl::Dir { .. } => Err(crate::error::Error::StreamReadOnly.into()),
// `demux://` with default options. The CLI constructs `DemuxSink`
// directly (with parsed flags) before reaching here, mirroring how a
// `dir://` dest is special-cased; this arm covers the bare
// `output()` call with the default option set.
StreamUrl::Demux { ref dir } => {
validate_file_path(dir, "demux")?;
Ok(Box::new(super::demux_sink::DemuxSink::create(
dir,
title,
&super::demux_sink::DemuxOptions::default(),
)?))
}
StreamUrl::Unknown { ref raw } => { StreamUrl::Unknown { ref raw } => {
Err(crate::error::Error::StreamUrlInvalid { url: raw.clone() }.into()) Err(crate::error::Error::StreamUrlInvalid { url: raw.clone() }.into())
} }
@@ -849,6 +875,12 @@ mod tests {
} }
assert_eq!(parse_url("dir://x").scheme(), "dir"); assert_eq!(parse_url("dir://x").scheme(), "dir");
assert_eq!(parse_url("dir://x/y").path_str(), "x/y"); assert_eq!(parse_url("dir://x/y").path_str(), "x/y");
assert_eq!(parse_url("demux://out/movie/").path_str(), "out/movie/");
assert_eq!(parse_url("demux://x").scheme(), "demux");
assert!(
!parse_url("demux://x").is_disc_source(),
"demux:// is a sink, never a disc source"
);
assert!( assert!(
!parse_url("dir://x").is_disc_source(), !parse_url("dir://x").is_disc_source(),
"dir:// is a sink, never a disc source" "dir:// is a sink, never a disc source"