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, pub export_chapters: bool,
/// Selected track indices. `None` = all tracks. /// Selected track indices. `None` = all tracks.
pub selection: Option<Vec<usize>>, 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 { impl Default for DemuxOptions {
@@ -99,13 +103,16 @@ impl Default for DemuxOptions {
chapters_fmt: ChaptersFmt::default(), chapters_fmt: ChaptersFmt::default(),
export_chapters: true, export_chapters: true,
selection: None, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TrackKind { pub enum TrackKind {
Video, Video,
Audio, Audio,
Subtitle, Subtitle,
@@ -658,6 +665,11 @@ impl DemuxSink {
(TrackKind::Subtitle, s.codec, s.pid, s.language.clone()) (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() { if kind == TrackKind::Video && ref_video_track.is_none() {
ref_video_track = Some(idx); 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 { fn title_with(streams: Vec<DiscStream>, privates: Vec<Option<Vec<u8>>>) -> DiscTitle {
let mut t = DiscTitle::empty(); let mut t = DiscTitle::empty();
t.streams = streams; t.streams = streams;
@@ -882,6 +905,40 @@ mod tests {
t 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 ──────────────────────────────────────────────────── // ── Annex-B reframing ────────────────────────────────────────────────────
// //
// The length-prefixed → Annex-B conversion and the hvcC/avcC param-set // 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 /// chapters + delay metadata). Like `dir://` it targets a directory; the
/// CLI constructs the `DemuxSink` with full options before the mux loop. /// CLI constructs the `DemuxSink` with full options before the mux loop.
Demux { dir: PathBuf }, 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 /// 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 /// 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`). /// video track to a `.fvi` file (normative spec `docs/FVI_FORMAT.md`).
@@ -83,6 +91,8 @@ impl StreamUrl {
StreamUrl::Dir { .. } => "dir", StreamUrl::Dir { .. } => "dir",
StreamUrl::Null => "null", StreamUrl::Null => "null",
StreamUrl::Demux { .. } => "demux", StreamUrl::Demux { .. } => "demux",
StreamUrl::Audio { .. } => "audio",
StreamUrl::Sub { .. } => "sub",
StreamUrl::Fvi { .. } => "fvi", StreamUrl::Fvi { .. } => "fvi",
StreamUrl::Unknown { .. } => "unknown", StreamUrl::Unknown { .. } => "unknown",
} }
@@ -98,6 +108,8 @@ impl StreamUrl {
| StreamUrl::Iso { path } | StreamUrl::Iso { path }
| StreamUrl::Dir { path } | StreamUrl::Dir { path }
| StreamUrl::Demux { dir: path } | StreamUrl::Demux { dir: path }
| StreamUrl::Audio { dir: path }
| StreamUrl::Sub { dir: path }
| StreamUrl::Fvi { path } => path.to_str().unwrap_or(""), | StreamUrl::Fvi { path } => path.to_str().unwrap_or(""),
StreamUrl::Network { addr } => addr, StreamUrl::Network { addr } => addr,
StreamUrl::Stdio | StreamUrl::Null => "", StreamUrl::Stdio | StreamUrl::Null => "",
@@ -171,6 +183,16 @@ pub fn parse_url(url: &str) -> StreamUrl {
dir: PathBuf::from(rest), 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://") { if let Some(rest) = url.strip_prefix("fvi://") {
return StreamUrl::Fvi { return StreamUrl::Fvi {
path: PathBuf::from(rest), 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::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. // `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. // `fvi://` is an output-only sink (per-picture video index); never a source.
StreamUrl::Fvi { .. } => Err(crate::error::Error::StreamWriteOnly.into()), StreamUrl::Fvi { .. } => Err(crate::error::Error::StreamWriteOnly.into()),
StreamUrl::Unknown { ref raw } => { StreamUrl::Unknown { ref raw } => {
@@ -526,6 +550,27 @@ pub fn output(
dir, title, &opts, 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`). // `fvi://` writes the per-picture video index (`docs/FVI_FORMAT.md`).
// The bare `output()` arm records the resolver path as the provenance // The bare `output()` arm records the resolver path as the provenance
// `source.path` and defaults the title index to 0 (the resolver carries // `source.path` and defaults the title index to 0 (the resolver carries