diff --git a/src/mux/demux_sink.rs b/src/mux/demux_sink.rs index d106d70..315800b 100644 --- a/src/mux/demux_sink.rs +++ b/src/mux/demux_sink.rs @@ -88,6 +88,10 @@ pub struct DemuxOptions { pub export_chapters: bool, /// Selected track indices. `None` = all tracks. pub selection: Option>, + /// 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, } 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, privates: Vec>>) -> 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 diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 09e7e43..975da6b 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -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 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