//! `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); // Each cue runs until the next chapter. WebVTT drops a cue whose end is not // strictly after its start, so the last chapter (and any degenerate // equal-timestamp pair) gets a 1 s minimum duration rather than being lost. let end = chapters .get(i + 1) .map(|n| n.time_secs.max(0.0)) .filter(|&e| e > start) .unwrap_or(start + 1.0); // No localized prose in the library (see Chapter::name): emit the bare // name, or a plain ordinal when unnamed — the app prepends any "Chapter " // prefix in the user's language. Matches chapters_xml / chapters_ogm. let name = if c.name.is_empty() { (i + 1).to_string() } else { c.name.clone() }; s.push_str(&format!( "{}\n{} --> {}\n{}\n\n", i + 1, vtt_time(start), vtt_time(end), 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:// ────────────────────────────────────────────────────────────────── /// Serialization id for an audio stream's editorial purpose. fn purpose_id(p: crate::labels::LabelPurpose) -> &'static str { use crate::labels::LabelPurpose::*; match p { Normal => "normal", Commentary => "commentary", Descriptive => "descriptive", Score => "score", Ime => "ime", } } /// Serialization id for a subtitle stream's qualifier. fn qualifier_id(q: crate::labels::LabelQualifier) -> &'static str { use crate::labels::LabelQualifier::*; match q { None => "none", Sdh => "sdh", DescriptiveService => "descriptive_service", Forced => "forced", } } /// One stream as JSON — every field the scan resolved, nothing dropped. This is /// the complete per-stream model (`disc::Stream`), not a summary: consumers get /// resolution/HDR/aspect for video, channels/sample-rate/purpose for audio, and /// the qualifier for subtitles, all in machine-readable form. fn stream_json(s: &DiscStream) -> serde_json::Value { use super::demux_sink::codec_label; use serde_json::json; match s { DiscStream::Video(v) => { // Absent dimensions serialise as 0 here; the JSON consumer reads // this as informational metadata, not as a mux input. let (w, h) = v.resolution.pixels().unwrap_or((0, 0)); let (fps_num, fps_den) = v.frame_rate.as_fraction(); let mut o = json!({ "kind": "video", "codec": codec_label(v.codec), "pid": v.pid, "resolution": v.resolution.to_string(), "width": w, "height": h, "interlaced": v.resolution.is_interlaced(), "frame_rate": v.frame_rate.to_string(), "frame_rate_num": fps_num, "frame_rate_den": fps_den, "hdr": v.hdr.id(), "color_space": v.color_space.id(), "secondary": v.secondary, "mvc_dependent": v.is_mvc_dependent(), }); if let Some((num, den)) = v.display_aspect { o["display_aspect"] = json!(format!("{num}:{den}")); } if let Some(c) = v.measured_cicp { o["measured_cicp"] = json!({ "matrix": c.matrix, "transfer": c.transfer, "primaries": c.primaries, "range": c.range, }); } if !v.label.is_empty() { o["label"] = json!(v.label); } o } DiscStream::Audio(a) => { let mut o = json!({ "kind": "audio", "codec": codec_label(a.codec), "pid": a.pid, "language": a.language, "channels": a.channels.to_string(), "sample_rate": a.sample_rate.to_string(), "secondary": a.secondary, "purpose": purpose_id(a.purpose), }); // `AudioChannels::count()` and `SampleRate::hz()` FABRICATE a concrete // value for the `Unknown` variant (6 channels / 48000 Hz), so calling // them unconditionally reported a confident 5.1 / 48 kHz for audio // whose format is genuinely unknown — contradicting the neighbouring // `channels` / `sample_rate` strings, which honestly say "unknown". // Omit the numeric key entirely instead: the same guard `mkv.rs` // applies before writing Channels / SamplingFrequency (there it emits // 0 so the EBML serializer drops the element). Kept as a guard here // rather than fixed in `count()`/`hz()` because those return // non-optional scalars that other callers rely on. if !matches!(a.channels, crate::disc::AudioChannels::Unknown) { o["channel_count"] = json!(a.channels.count()); } if !matches!(a.sample_rate, crate::disc::SampleRate::Unknown) { o["sample_rate_hz"] = json!(a.sample_rate.hz()); } if !a.label.is_empty() { o["label"] = json!(a.label); } o } DiscStream::Subtitle(t) => json!({ "kind": "subtitle", "codec": codec_label(t.codec), "pid": t.pid, "language": t.language, "forced": t.forced, "qualifier": qualifier_id(t.qualifier), }), } } /// The `json://` document for one title: identity, duration/size, its clips, /// its complete stream models, and its chapter points. A stable, machine- /// readable view of one title — the same information the scan resolved, no loss. 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 clips: Vec<_> = title .clips .iter() .map(|c| { json!({ "clip_id": c.clip_id, "duration_secs": c.duration_secs, "source_packets": c.source_packets, }) }) .collect(); let chapters: Vec<_> = title .chapters .iter() .enumerate() .map(|(i, c)| json!({ "n": i + 1, "start_secs": c.time_secs, "name": c.name })) .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), "clips": clips, "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 { // Serializing our own `Value` is infallible in practice (serde_json maps // any non-finite float to `null` at Value construction, so `title_json` // never holds an unencodable value); still, propagate rather than silently // writing "{}" if that ever changes — an empty metadata file must not // masquerade as a successful json:// export. // // `NoMetadata` (E9008), matching `mux::meta`'s serialize guard: this is a // metadata-encoding failure with no MKV involved. It was `MkvInvalid`, // which `error::is_skippable_title_stub` reports as a skippable empty // nav/menu stub — a json:// export that failed to encode is not that. let doc = serde_json::to_string_pretty(&title_json(title)) .map_err(|_| crate::error::Error::NoMetadata)?; 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"); let a = &v["streams"][0]; assert_eq!(a["kind"], "audio"); assert_eq!(a["codec"], "TrueHD"); assert_eq!(a["language"], "eng"); // Completeness: audio carries channels + sample rate + purpose, not just codec. assert_eq!(a["channels"], "stereo"); assert_eq!(a["channel_count"], 2); assert_eq!(a["sample_rate"], "48kHz"); assert_eq!(a["sample_rate_hz"], 48000.0); assert_eq!(a["purpose"], "normal"); assert_eq!(v["chapters"][1]["n"], 2); assert_eq!(v["chapters"][1]["start_secs"], 62.5); assert_eq!(v["chapters"][1]["name"], "2"); } /// An audio stream whose channel layout / sample rate are genuinely unknown /// must not be reported with a fabricated 5.1 / 48 kHz. `AudioChannels::count()` /// maps `Unknown` to 6 and `SampleRate::hz()` maps `Unknown` to 48000, so the /// numeric fields must be omitted rather than computed — otherwise the JSON /// contradicts its own `channels` / `sample_rate` strings ("unknown"). #[test] fn unknown_audio_layout_omits_fabricated_numeric_fields() { use crate::disc::{AudioChannels, AudioStream, Codec, DiscTitle}; use crate::disc::{LabelPurpose, SampleRate, Stream as DiscStream}; let mut t = DiscTitle::empty(); t.streams = vec![DiscStream::Audio(AudioStream { pid: 0x1100, codec: Codec::DtsHdMa, channels: AudioChannels::Unknown, language: "eng".into(), sample_rate: SampleRate::Unknown, secondary: false, purpose: LabelPurpose::Normal, label: String::new(), })]; let v = title_json(&t); let a = &v["streams"][0]; // The honest string fields. assert_eq!(a["channels"], "unknown"); assert_eq!(a["sample_rate"], "unknown"); // The numeric fields must not assert a value the scan never resolved. assert!( a["channel_count"].is_null(), "unknown channel layout must not report a channel_count, got {}", a["channel_count"] ); assert!( a["sample_rate_hz"].is_null(), "unknown sample rate must not report a sample_rate_hz, got {}", a["sample_rate_hz"] ); } #[test] fn video_json_carries_resolution_and_hdr() { use crate::disc::Codec; use crate::disc::{ ColorSpace, DiscTitle, FrameRate, HdrFormat, Resolution, Stream as DiscStream, VideoStream, }; let mut t = DiscTitle::empty(); t.streams = vec![DiscStream::Video(VideoStream { pid: 0x1011, codec: Codec::Hevc, resolution: Resolution::R2160p, frame_rate: FrameRate::F23_976, hdr: HdrFormat::Hdr10, color_space: ColorSpace::Bt2020, display_aspect: None, secondary: false, label: String::new(), measured_cicp: None, })]; let vid = &title_json(&t)["streams"][0]; assert_eq!(vid["kind"], "video"); assert_eq!(vid["resolution"], "2160p"); assert_eq!(vid["width"], 3840); assert_eq!(vid["height"], 2160); assert_eq!(vid["frame_rate"], "23.976"); assert_eq!(vid["frame_rate_num"], 24000); assert_eq!(vid["hdr"], "hdr10"); assert_eq!(vid["color_space"], "bt2020"); } fn temp_path(name: &str) -> std::path::PathBuf { use std::sync::atomic::{AtomicU64, Ordering}; static N: AtomicU64 = AtomicU64::new(0); let n = N.fetch_add(1, Ordering::Relaxed); std::env::temp_dir().join(format!("fmkv_meta_sink_{}_{n}_{name}", std::process::id())) } fn sink_title() -> crate::disc::DiscTitle { let mut t = crate::disc::DiscTitle::empty(); t.playlist = "MAIN".into(); t.chapters = chaps(); t } /// `chapters://` and `json://` are WRITE-ONLY sinks: the whole file is /// emitted at `create()` and there is nothing to demux back. `read()` /// returning `Ok(None)` instead of the write-only error makes a caller that /// pointed a mux INPUT at one of these URLs see a clean empty stream — the /// exact shape of the shipped "empty title, exit code 0" defect. It must /// refuse with the numeric code `E_STREAM_WRITE_ONLY`. #[test] fn metadata_sinks_refuse_to_be_read_from() { let code = format!("E{}", crate::error::Error::StreamWriteOnly.code()); let cpath = temp_path("chapters.xml"); let mut c = ChaptersSink::create(&cpath, &sink_title()).unwrap(); let err = c .read() .expect_err("chapters:// is write-only; read must not report a clean EOF"); assert_eq!(err.kind(), io::ErrorKind::Unsupported); assert!( err.to_string().contains(&code), "expected {code}, got {err}" ); let _ = std::fs::remove_file(&cpath); let jpath = temp_path("meta.json"); let mut j = JsonSink::create(&jpath, &sink_title()).unwrap(); let err = j .read() .expect_err("json:// is write-only; read must not report a clean EOF"); assert_eq!(err.kind(), io::ErrorKind::Unsupported); assert!( err.to_string().contains(&code), "expected {code}, got {err}" ); let _ = std::fs::remove_file(&jpath); } }