Resolution::pixels() returned (0, 0) for Unknown, and the MP4 sink wrote it verbatim into tkhd (ISO/IEC 14496-12 8.3.2) and VisualSampleEntry (12.1.3). Both fields are MANDATORY there, so unlike Matroska — which omits the optional PixelWidth/PixelHeight elements — MP4 has nothing to leave out. The result was a structurally complete file that passes every container check, declares a 0x0 video track, cannot be rendered, and is written with no error anywhere. WHY IT WAS POSSIBLE, which is the part worth keeping: pixels() previously fabricated 1920x1080 for Unknown. That was wrong but playable, so this sink never needed a guard and the absence of one was invisible. Changing the sentinel to (0, 0) moved the defect instead of removing it — a zero PAIR still reads as a usable value, so the sink stored it and serialised it. The accessor's doc comment then ENUMERATED the callers it believed were safe: "the Matroska sink omits the optional elements, the VobSub writer omits its size: line, and no caller divides by either dimension." Two of those three are true. MP4 was not on the list because MP4 has no guard at all, and a prose list cannot enforce itself. mkv.rs's own comment even states the principle — "the check belongs in the one accessor rather than in each caller that remembered to write it" — and labels/mod.rs still carried its own duplicate Unknown test long after the accessor took that job over. So: pixels() now returns Option. Not because Option is tidier, but because every caller genuinely needs a DIFFERENT answer and the compiler is the only thing that reliably makes them choose one. Matroska and the metadata sinks take unwrap_or((0, 0)) with the reason stated at each site; the VobSub path degrades to a palette-only .idx; MP4 fails with E_MP4_UNKNOWN_RESOLUTION (9055). Six call sites, not the five my first grep showed — I piped it through `head` and acted on a truncated list. The compiler caught the sixth. That is the same mistake as trusting a lens that reported silence.
483 lines
18 KiB
Rust
483 lines
18 KiB
Rust
//! `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<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:// ──────────────────────────────────────────────────────────────────
|
|
|
|
/// 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<Self> {
|
|
// 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<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");
|
|
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);
|
|
}
|
|
}
|