mux: json:// emits the complete title model (no dropped fields)

The json:// sink previously emitted only codec + language + pid per
stream. Fill it out to the full DiscTitle: video carries resolution
(+ pixel dims, interlaced), frame rate (+ fraction), HDR, colour space,
display aspect, and measured CICP; audio carries channels (+ count),
sample rate (+ Hz), and editorial purpose; subtitles carry the
qualifier. Add the clip list and chapter names. This is everything the
scan resolved, so json:// is a lossless machine-readable view of a
title rather than a summary.
This commit is contained in:
Matthew Jackson
2026-07-18 18:14:11 -07:00
parent a8563e9fa3
commit 6a4ac97a33
+134 -12
View File
@@ -93,41 +93,123 @@ impl Stream for ChaptersSink {
// ── json:// ────────────────────────────────────────────────────────────────── // ── 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 { fn stream_json(s: &DiscStream) -> serde_json::Value {
use super::demux_sink::codec_label; use super::demux_sink::codec_label;
use serde_json::json; use serde_json::json;
match s { match s {
DiscStream::Video(v) => json!({ DiscStream::Video(v) => {
let (w, h) = v.resolution.pixels();
let (fps_num, fps_den) = v.frame_rate.as_fraction();
let mut o = json!({
"kind": "video", "kind": "video",
"codec": codec_label(v.codec), "codec": codec_label(v.codec),
"pid": v.pid, "pid": v.pid,
}), "resolution": v.resolution.to_string(),
DiscStream::Audio(a) => json!({ "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", "kind": "audio",
"codec": codec_label(a.codec), "codec": codec_label(a.codec),
"language": a.language,
"pid": a.pid, "pid": a.pid,
}), "language": a.language,
"channels": a.channels.to_string(),
"channel_count": a.channels.count(),
"sample_rate": a.sample_rate.to_string(),
"sample_rate_hz": a.sample_rate.hz(),
"secondary": a.secondary,
"purpose": purpose_id(a.purpose),
});
if !a.label.is_empty() {
o["label"] = json!(a.label);
}
o
}
DiscStream::Subtitle(t) => json!({ DiscStream::Subtitle(t) => json!({
"kind": "subtitle", "kind": "subtitle",
"codec": codec_label(t.codec), "codec": codec_label(t.codec),
"pid": t.pid,
"language": t.language, "language": t.language,
"forced": t.forced, "forced": t.forced,
"pid": t.pid, "qualifier": qualifier_id(t.qualifier),
}), }),
} }
} }
/// The `json://` document for one title: identity, duration/size, its streams, /// The `json://` document for one title: identity, duration/size, its clips,
/// and its chapter points. A stable, machine-readable view of one title. /// 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 { pub(crate) fn title_json(title: &DiscTitle) -> serde_json::Value {
use serde_json::json; use serde_json::json;
let streams: Vec<_> = title.streams.iter().map(stream_json).collect(); 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 let chapters: Vec<_> = title
.chapters .chapters
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, c)| json!({ "n": i + 1, "start_secs": c.time_secs })) .map(|(i, c)| json!({ "n": i + 1, "start_secs": c.time_secs, "name": c.name }))
.collect(); .collect();
json!({ json!({
"playlist": title.playlist, "playlist": title.playlist,
@@ -135,6 +217,7 @@ pub(crate) fn title_json(title: &DiscTitle) -> serde_json::Value {
"duration_secs": title.duration_secs, "duration_secs": title.duration_secs,
"size_bytes": title.size_bytes, "size_bytes": title.size_bytes,
"format": format!("{:?}", title.content_format), "format": format!("{:?}", title.content_format),
"clips": clips,
"streams": streams, "streams": streams,
"chapters": chapters, "chapters": chapters,
}) })
@@ -226,10 +309,49 @@ mod tests {
})]; })];
let v = title_json(&t); let v = title_json(&t);
assert_eq!(v["playlist"], "MAIN"); assert_eq!(v["playlist"], "MAIN");
assert_eq!(v["streams"][0]["kind"], "audio"); let a = &v["streams"][0];
assert_eq!(v["streams"][0]["codec"], "TrueHD"); assert_eq!(a["kind"], "audio");
assert_eq!(v["streams"][0]["language"], "eng"); 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]["n"], 2);
assert_eq!(v["chapters"][1]["start_secs"], 62.5); assert_eq!(v["chapters"][1]["start_secs"], 62.5);
assert_eq!(v["chapters"][1]["name"], "2");
}
#[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");
} }
} }