mux: chapters:// and json:// metadata sinks
Two write-only file sinks that ignore the PES stream and emit the title metadata at construction (fvi-style, wired through output()): - chapters:// — chapter markers in the format the extension picks: .xml (Matroska, default), .txt/.ogm (OGM simple), .vtt (WebVTT). Reuses the existing chapters_xml/chapters_ogm writers; adds a WebVTT writer. - json:// — one title's model (playlist, duration, size, format, streams, chapters) as pretty JSON via serde_json. New mux/meta_sink.rs; StreamUrl gains Chapters/Json; codec_label + chapters_xml/ogm promoted to pub(crate) for reuse. Tests: chapters_format_selected_by_extension, title_json_carries_streams_and_chapters.
This commit is contained in:
@@ -150,7 +150,7 @@ fn extension_for(codec: Codec) -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Short codec label for friendly filenames.
|
/// Short codec label for friendly filenames.
|
||||||
fn codec_label(codec: Codec) -> &'static str {
|
pub(crate) fn codec_label(codec: Codec) -> &'static str {
|
||||||
match codec {
|
match codec {
|
||||||
Codec::Hevc => "HEVC",
|
Codec::Hevc => "HEVC",
|
||||||
Codec::H264 => "AVC",
|
Codec::H264 => "AVC",
|
||||||
@@ -548,7 +548,7 @@ fn fmt_chapter_time_ns(time_secs: f64) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize chapters as mkvmerge chapter XML.
|
/// 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();
|
let mut s = String::new();
|
||||||
s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||||
s.push_str("<!DOCTYPE Chapters SYSTEM \"matroskachapters.dtd\">\n");
|
s.push_str("<!DOCTYPE Chapters SYSTEM \"matroskachapters.dtd\">\n");
|
||||||
@@ -578,7 +578,7 @@ fn chapters_xml(chapters: &[Chapter]) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize chapters as OGM/simple chapter text.
|
/// 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();
|
let mut s = String::new();
|
||||||
for (i, c) in chapters.iter().enumerate() {
|
for (i, c) in chapters.iter().enumerate() {
|
||||||
let n = i + 1;
|
let n = i + 1;
|
||||||
|
|||||||
@@ -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<Self> {
|
||||||
|
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<Option<PesFrame>> {
|
||||||
|
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<Self> {
|
||||||
|
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<Option<PesFrame>> {
|
||||||
|
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<Chapter> {
|
||||||
|
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("<Chapters>"), "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("<Chapters>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ pub(crate) mod m2ts;
|
|||||||
/// to round-trip codec_privates that don't fit inside the underlying format).
|
/// to round-trip codec_privates that don't fit inside the underlying format).
|
||||||
/// Exposed for integration tests that exercise the wire format directly.
|
/// Exposed for integration tests that exercise the wire format directly.
|
||||||
pub mod meta;
|
pub mod meta;
|
||||||
|
pub(crate) mod meta_sink;
|
||||||
|
|
||||||
// ── Sequential-sink muxers ──────────────────────────────────────────────────
|
// ── Sequential-sink muxers ──────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
|
|||||||
+41
-4
@@ -74,6 +74,15 @@ pub enum StreamUrl {
|
|||||||
/// 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`).
|
||||||
Fvi { path: PathBuf },
|
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.
|
/// Unrecognized URL.
|
||||||
Unknown { raw: String },
|
Unknown { raw: String },
|
||||||
}
|
}
|
||||||
@@ -94,6 +103,8 @@ impl StreamUrl {
|
|||||||
StreamUrl::Audio { .. } => "audio",
|
StreamUrl::Audio { .. } => "audio",
|
||||||
StreamUrl::Sub { .. } => "sub",
|
StreamUrl::Sub { .. } => "sub",
|
||||||
StreamUrl::Fvi { .. } => "fvi",
|
StreamUrl::Fvi { .. } => "fvi",
|
||||||
|
StreamUrl::Chapters { .. } => "chapters",
|
||||||
|
StreamUrl::Json { .. } => "json",
|
||||||
StreamUrl::Unknown { .. } => "unknown",
|
StreamUrl::Unknown { .. } => "unknown",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,7 +121,9 @@ impl StreamUrl {
|
|||||||
| StreamUrl::Demux { dir: path }
|
| StreamUrl::Demux { dir: path }
|
||||||
| StreamUrl::Audio { dir: path }
|
| StreamUrl::Audio { dir: path }
|
||||||
| StreamUrl::Sub { 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::Network { addr } => addr,
|
||||||
StreamUrl::Stdio | StreamUrl::Null => "",
|
StreamUrl::Stdio | StreamUrl::Null => "",
|
||||||
StreamUrl::Unknown { raw } => raw,
|
StreamUrl::Unknown { raw } => raw,
|
||||||
@@ -193,6 +206,16 @@ pub fn parse_url(url: &str) -> StreamUrl {
|
|||||||
dir: PathBuf::from(rest),
|
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://") {
|
if let Some(rest) = url.strip_prefix("fvi://") {
|
||||||
return StreamUrl::Fvi {
|
return StreamUrl::Fvi {
|
||||||
path: PathBuf::from(rest),
|
path: PathBuf::from(rest),
|
||||||
@@ -469,9 +492,11 @@ 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 { .. } | StreamUrl::Audio { .. } | StreamUrl::Sub { .. } => {
|
StreamUrl::Demux { .. }
|
||||||
Err(crate::error::Error::StreamWriteOnly.into())
|
| 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.
|
// `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 } => {
|
||||||
@@ -584,6 +609,18 @@ pub fn output(
|
|||||||
0,
|
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 } => {
|
StreamUrl::Unknown { ref raw } => {
|
||||||
Err(crate::error::Error::StreamUrlInvalid { url: raw.clone() }.into())
|
Err(crate::error::Error::StreamUrlInvalid { url: raw.clone() }.into())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user