diff --git a/src/mux/demux_sink.rs b/src/mux/demux_sink.rs index 315800b..3a8446a 100644 --- a/src/mux/demux_sink.rs +++ b/src/mux/demux_sink.rs @@ -150,7 +150,7 @@ fn extension_for(codec: Codec) -> &'static str { } /// Short codec label for friendly filenames. -fn codec_label(codec: Codec) -> &'static str { +pub(crate) fn codec_label(codec: Codec) -> &'static str { match codec { Codec::Hevc => "HEVC", Codec::H264 => "AVC", @@ -548,7 +548,7 @@ fn fmt_chapter_time_ns(time_secs: f64) -> String { } /// Serialize chapters as mkvmerge chapter XML. -fn chapters_xml(chapters: &[Chapter]) -> String { +pub(crate) fn chapters_xml(chapters: &[Chapter]) -> String { let mut s = String::new(); s.push_str("\n"); s.push_str("\n"); @@ -578,7 +578,7 @@ fn chapters_xml(chapters: &[Chapter]) -> String { } /// Serialize chapters as OGM/simple chapter text. -fn chapters_ogm(chapters: &[Chapter]) -> String { +pub(crate) fn chapters_ogm(chapters: &[Chapter]) -> String { let mut s = String::new(); for (i, c) in chapters.iter().enumerate() { let n = i + 1; diff --git a/src/mux/meta_sink.rs b/src/mux/meta_sink.rs new file mode 100644 index 0000000..cfaed2e --- /dev/null +++ b/src/mux/meta_sink.rs @@ -0,0 +1,235 @@ +//! `chapters://` and `json://` metadata sinks. +//! +//! Both ignore the PES stream entirely: everything they emit is already known +//! from the [`DiscTitle`] at construction, so each writes its whole file at +//! `create()` and treats every `write()` frame as a no-op. They are wired +//! through [`super::resolve::output`] like the other write-only sinks; the +//! ISO/disc scan that builds the title is all they need. + +use crate::disc::{Chapter, DiscTitle, Stream as DiscStream}; +use crate::pes::{PesFrame, Stream}; +use std::fs::File; +use std::io::{self, Write}; +use std::path::Path; + +// ── chapters:// ────────────────────────────────────────────────────────────── + +/// `HH:MM:SS.mmm` for a WebVTT cue timestamp. +fn vtt_time(secs: f64) -> String { + let total_ms = (secs.max(0.0) * 1000.0).round() as u64; + let ms = total_ms % 1000; + let total_s = total_ms / 1000; + format!( + "{:02}:{:02}:{:02}.{:03}", + total_s / 3600, + (total_s / 60) % 60, + total_s % 60, + ms + ) +} + +/// WebVTT chapter cues (`.vtt`). Each chapter spans until the next one starts +/// (the last runs to its own start — length is unknown without the title tail). +fn chapters_vtt(chapters: &[Chapter]) -> String { + let mut s = String::from("WEBVTT\n\n"); + for (i, c) in chapters.iter().enumerate() { + let start = c.time_secs.max(0.0); + let end = chapters + .get(i + 1) + .map(|n| n.time_secs.max(0.0)) + .unwrap_or(start); + s.push_str(&format!( + "{}\n{} --> {}\nChapter {}\n\n", + i + 1, + vtt_time(start), + vtt_time(end), + c.name + )); + } + s +} + +/// Chapter content in the format the output extension selects: `.txt`/`.ogm` +/// (OGM simple), `.vtt` (WebVTT), else Matroska XML (`.xml` / default). +pub(crate) fn chapters_content(chapters: &[Chapter], ext: Option<&str>) -> String { + match ext.map(|e| e.to_ascii_lowercase()).as_deref() { + Some("txt") | Some("ogm") => super::demux_sink::chapters_ogm(chapters), + Some("vtt") => chapters_vtt(chapters), + _ => super::demux_sink::chapters_xml(chapters), + } +} + +/// `chapters://` sink: writes the title's chapter markers at construction; the +/// PES stream is ignored. +pub struct ChaptersSink { + title: DiscTitle, +} + +impl ChaptersSink { + pub fn create(path: &Path, title: &DiscTitle) -> io::Result { + let ext = path.extension().and_then(|e| e.to_str()); + let content = chapters_content(&title.chapters, ext); + File::create(path)?.write_all(content.as_bytes())?; + Ok(Self { + title: title.clone(), + }) + } +} + +impl Stream for ChaptersSink { + fn read(&mut self) -> io::Result> { + Err(crate::error::Error::StreamWriteOnly.into()) + } + fn write(&mut self, _frame: &PesFrame) -> io::Result<()> { + Ok(()) // whole file written at create() + } + fn finish(&mut self) -> io::Result<()> { + Ok(()) + } + fn info(&self) -> &DiscTitle { + &self.title + } +} + +// ── json:// ────────────────────────────────────────────────────────────────── + +fn stream_json(s: &DiscStream) -> serde_json::Value { + use super::demux_sink::codec_label; + use serde_json::json; + match s { + DiscStream::Video(v) => json!({ + "kind": "video", + "codec": codec_label(v.codec), + "pid": v.pid, + }), + DiscStream::Audio(a) => json!({ + "kind": "audio", + "codec": codec_label(a.codec), + "language": a.language, + "pid": a.pid, + }), + DiscStream::Subtitle(t) => json!({ + "kind": "subtitle", + "codec": codec_label(t.codec), + "language": t.language, + "forced": t.forced, + "pid": t.pid, + }), + } +} + +/// The `json://` document for one title: identity, duration/size, its streams, +/// and its chapter points. A stable, machine-readable view of one title. +pub(crate) fn title_json(title: &DiscTitle) -> serde_json::Value { + use serde_json::json; + let streams: Vec<_> = title.streams.iter().map(stream_json).collect(); + let chapters: Vec<_> = title + .chapters + .iter() + .enumerate() + .map(|(i, c)| json!({ "n": i + 1, "start_secs": c.time_secs })) + .collect(); + json!({ + "playlist": title.playlist, + "playlist_id": title.playlist_id, + "duration_secs": title.duration_secs, + "size_bytes": title.size_bytes, + "format": format!("{:?}", title.content_format), + "streams": streams, + "chapters": chapters, + }) +} + +/// `json://` sink: writes the title's structured metadata at construction; the +/// PES stream is ignored. +pub struct JsonSink { + title: DiscTitle, +} + +impl JsonSink { + pub fn create(path: &Path, title: &DiscTitle) -> io::Result { + let doc = + serde_json::to_string_pretty(&title_json(title)).unwrap_or_else(|_| "{}".to_string()); + let mut f = File::create(path)?; + f.write_all(doc.as_bytes())?; + f.write_all(b"\n")?; + Ok(Self { + title: title.clone(), + }) + } +} + +impl Stream for JsonSink { + fn read(&mut self) -> io::Result> { + Err(crate::error::Error::StreamWriteOnly.into()) + } + fn write(&mut self, _frame: &PesFrame) -> io::Result<()> { + Ok(()) + } + fn finish(&mut self) -> io::Result<()> { + Ok(()) + } + fn info(&self) -> &DiscTitle { + &self.title + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::disc::Chapter; + + fn chaps() -> Vec { + vec![ + Chapter { + time_secs: 0.0, + name: "1".into(), + }, + Chapter { + time_secs: 62.5, + name: "2".into(), + }, + ] + } + + #[test] + fn chapters_format_selected_by_extension() { + let xml = chapters_content(&chaps(), Some("xml")); + assert!(xml.contains(""), "xml chosen for .xml"); + let ogm = chapters_content(&chaps(), Some("txt")); + assert!(ogm.contains("CHAPTER01="), "ogm chosen for .txt"); + let vtt = chapters_content(&chaps(), Some("vtt")); + assert!( + vtt.starts_with("WEBVTT") && vtt.contains("00:01:02.500"), + "vtt chosen for .vtt, with cue timing" + ); + // Unknown / missing extension defaults to XML. + assert!(chapters_content(&chaps(), None).contains("")); + } + + #[test] + fn title_json_carries_streams_and_chapters() { + use crate::disc::{AudioChannels, AudioStream, Codec, DiscTitle}; + use crate::disc::{LabelPurpose, SampleRate, Stream as DiscStream}; + let mut t = DiscTitle::empty(); + t.playlist = "MAIN".into(); + t.chapters = chaps(); + t.streams = vec![DiscStream::Audio(AudioStream { + pid: 0x1100, + codec: Codec::TrueHd, + channels: AudioChannels::Stereo, + language: "eng".into(), + sample_rate: SampleRate::S48, + secondary: false, + purpose: LabelPurpose::Normal, + label: String::new(), + })]; + let v = title_json(&t); + assert_eq!(v["playlist"], "MAIN"); + assert_eq!(v["streams"][0]["kind"], "audio"); + assert_eq!(v["streams"][0]["codec"], "TrueHD"); + assert_eq!(v["streams"][0]["language"], "eng"); + assert_eq!(v["chapters"][1]["n"], 2); + assert_eq!(v["chapters"][1]["start_secs"], 62.5); + } +} diff --git a/src/mux/mod.rs b/src/mux/mod.rs index 55d831c..4350681 100644 --- a/src/mux/mod.rs +++ b/src/mux/mod.rs @@ -62,6 +62,7 @@ pub(crate) mod m2ts; /// to round-trip codec_privates that don't fit inside the underlying format). /// Exposed for integration tests that exercise the wire format directly. pub mod meta; +pub(crate) mod meta_sink; // ── Sequential-sink muxers ────────────────────────────────────────────────── // diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 975da6b..dcd7f9d 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -74,6 +74,15 @@ pub enum StreamUrl { /// 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`). Fvi { path: PathBuf }, + /// Chapter-marker export (`chapters://`). A write-only sink that ignores the + /// PES stream and writes the title's chapter points to a single file, format + /// chosen by the output extension: `.xml` (Matroska, default), `.txt` (OGM), + /// `.vtt` (WebVTT). + Chapters { path: PathBuf }, + /// Structured title/stream/chapter metadata (`json://`). A write-only sink + /// that ignores the PES stream and writes the selected title's model as one + /// JSON document — machine-readable `info` for one title. + Json { path: PathBuf }, /// Unrecognized URL. Unknown { raw: String }, } @@ -94,6 +103,8 @@ impl StreamUrl { StreamUrl::Audio { .. } => "audio", StreamUrl::Sub { .. } => "sub", StreamUrl::Fvi { .. } => "fvi", + StreamUrl::Chapters { .. } => "chapters", + StreamUrl::Json { .. } => "json", StreamUrl::Unknown { .. } => "unknown", } } @@ -110,7 +121,9 @@ impl StreamUrl { | StreamUrl::Demux { dir: path } | StreamUrl::Audio { dir: path } | StreamUrl::Sub { dir: path } - | StreamUrl::Fvi { path } => path.to_str().unwrap_or(""), + | StreamUrl::Fvi { path } + | StreamUrl::Chapters { path } + | StreamUrl::Json { path } => path.to_str().unwrap_or(""), StreamUrl::Network { addr } => addr, StreamUrl::Stdio | StreamUrl::Null => "", StreamUrl::Unknown { raw } => raw, @@ -193,6 +206,16 @@ pub fn parse_url(url: &str) -> StreamUrl { dir: PathBuf::from(rest), }; } + if let Some(rest) = url.strip_prefix("chapters://") { + return StreamUrl::Chapters { + path: PathBuf::from(rest), + }; + } + if let Some(rest) = url.strip_prefix("json://") { + return StreamUrl::Json { + path: PathBuf::from(rest), + }; + } if let Some(rest) = url.strip_prefix("fvi://") { return StreamUrl::Fvi { path: PathBuf::from(rest), @@ -469,9 +492,11 @@ 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 { .. } | StreamUrl::Audio { .. } | StreamUrl::Sub { .. } => { - Err(crate::error::Error::StreamWriteOnly.into()) - } + StreamUrl::Demux { .. } + | StreamUrl::Audio { .. } + | StreamUrl::Sub { .. } + | StreamUrl::Chapters { .. } + | StreamUrl::Json { .. } => 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 } => { @@ -584,6 +609,18 @@ pub fn output( 0, )?)) } + // `chapters://` and `json://` write the title metadata at construction and + // ignore the PES stream (see `meta_sink`). + StreamUrl::Chapters { ref path } => { + validate_file_path(path, "chapters")?; + Ok(Box::new(super::meta_sink::ChaptersSink::create( + path, title, + )?)) + } + StreamUrl::Json { ref path } => { + validate_file_path(path, "json")?; + Ok(Box::new(super::meta_sink::JsonSink::create(path, title)?)) + } StreamUrl::Unknown { ref raw } => { Err(crate::error::Error::StreamUrlInvalid { url: raw.clone() }.into()) }