mux: audio:// and sub:// sinks (demux filtered by track class)

Two extraction sinks built on the existing demux machinery: audio:// keeps
only audio tracks (native containers .thd/.dts/.ac3/.eac3/.pcm...), sub://
only subtitle tracks (PGS .sup, VobSub .idx+.sub, text .srt) — one file per
track, no video, no chapters sidecar. DemuxOptions gains a kind_filter; the
DemuxSink writers (already complete) are reused verbatim. StreamUrl gains
Audio/Sub variants; output() builds the filtered DemuxSink.

Test: kind_filter_keeps_only_the_selected_class.
This commit is contained in:
Matthew Jackson
2026-07-18 17:24:28 -07:00
parent 43cdc1351d
commit 9f33306a0a
2 changed files with 105 additions and 3 deletions
+59 -2
View File
@@ -88,6 +88,10 @@ pub struct DemuxOptions {
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 {
@@ -99,13 +103,16 @@ impl Default for DemuxOptions {
chapters_fmt: ChaptersFmt::default(),
export_chapters: true,
selection: None,
kind_filter: None,
}
}
}
/// Track class, used for delay attribution and naming.
/// 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)]
enum TrackKind {
pub enum TrackKind {
Video,
Audio,
Subtitle,
@@ -658,6 +665,11 @@ impl DemuxSink {
(TrackKind::Subtitle, s.codec, s.pid, s.language.clone())
}
};
// Kind filter: `audio://` / `sub://` keep only their class.
if opts.kind_filter.is_some_and(|k| k != kind) {
tracks.push(None);
continue;
}
if kind == TrackKind::Video && ref_video_track.is_none() {
ref_video_track = Some(idx);
}
@@ -874,6 +886,17 @@ mod tests {
})
}
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;
@@ -882,6 +905,40 @@ mod tests {
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"
);
}
// ── Annex-B reframing ────────────────────────────────────────────────────
//
// The length-prefixed → Annex-B conversion and the hvcC/avcC param-set
+46 -1
View File
@@ -62,6 +62,14 @@ pub enum StreamUrl {
/// chapters + delay metadata). Like `dir://` it targets a directory; the
/// CLI constructs the `DemuxSink` with full options before the mux loop.
Demux { dir: PathBuf },
/// Audio-only per-track output directory (`audio://`) — a `demux://`
/// restricted to audio tracks (native containers: `.thd`, `.dts`, `.ac3`,
/// `.eac3`, `.pcm`, …). One file per audio track; no video/subtitles.
Audio { dir: PathBuf },
/// Subtitle-only per-track output directory (`sub://`) — a `demux://`
/// restricted to subtitle tracks (PGS `.sup`, VobSub `.idx`+`.sub`, text
/// `.srt`). One file per subtitle track.
Sub { dir: PathBuf },
/// freemkv native per-picture video index (`fvi://`). A write-only PES sink
/// that emits one JSON-Lines record per coded picture of the title's primary
/// video track to a `.fvi` file (normative spec `docs/FVI_FORMAT.md`).
@@ -83,6 +91,8 @@ impl StreamUrl {
StreamUrl::Dir { .. } => "dir",
StreamUrl::Null => "null",
StreamUrl::Demux { .. } => "demux",
StreamUrl::Audio { .. } => "audio",
StreamUrl::Sub { .. } => "sub",
StreamUrl::Fvi { .. } => "fvi",
StreamUrl::Unknown { .. } => "unknown",
}
@@ -98,6 +108,8 @@ impl StreamUrl {
| StreamUrl::Iso { path }
| StreamUrl::Dir { path }
| StreamUrl::Demux { dir: path }
| StreamUrl::Audio { dir: path }
| StreamUrl::Sub { dir: path }
| StreamUrl::Fvi { path } => path.to_str().unwrap_or(""),
StreamUrl::Network { addr } => addr,
StreamUrl::Stdio | StreamUrl::Null => "",
@@ -171,6 +183,16 @@ pub fn parse_url(url: &str) -> StreamUrl {
dir: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("audio://") {
return StreamUrl::Audio {
dir: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("sub://") {
return StreamUrl::Sub {
dir: PathBuf::from(rest),
};
}
if let Some(rest) = url.strip_prefix("fvi://") {
return StreamUrl::Fvi {
path: PathBuf::from(rest),
@@ -447,7 +469,9 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
StreamUrl::Dir { .. } => 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::Demux { .. } | StreamUrl::Audio { .. } | StreamUrl::Sub { .. } => {
Err(crate::error::Error::StreamWriteOnly.into())
}
// `fvi://` is an output-only sink (per-picture video index); never a source.
StreamUrl::Fvi { .. } => Err(crate::error::Error::StreamWriteOnly.into()),
StreamUrl::Unknown { ref raw } => {
@@ -526,6 +550,27 @@ pub fn output(
dir, title, &opts,
)?))
}
// `audio://` and `sub://` are `demux://` restricted to one track class —
// audio in native containers, or subtitles as `.sup`/`.idx+.sub`/`.srt`.
// No chapters sidecar (that's a `demux://` / `chapters://` concern).
StreamUrl::Audio { ref dir } | StreamUrl::Sub { ref dir } => {
let (scheme, kind) = match parsed {
StreamUrl::Audio { .. } => ("audio", super::demux_sink::TrackKind::Audio),
_ => ("sub", super::demux_sink::TrackKind::Subtitle),
};
validate_file_path(dir, scheme)?;
let mut opts = super::demux_sink::DemuxOptions {
kind_filter: Some(kind),
export_chapters: false,
..Default::default()
};
if !title.playlist.is_empty() {
opts.base = title.playlist.clone();
}
Ok(Box::new(super::demux_sink::DemuxSink::create(
dir, title, &opts,
)?))
}
// `fvi://` writes the per-picture video index (`docs/FVI_FORMAT.md`).
// The bare `output()` arm records the resolver path as the provenance
// `source.path` and defaults the title index to 0 (the resolver carries