diff --git a/src/mux/meta_sink.rs b/src/mux/meta_sink.rs index cfaed2e..745e3d7 100644 --- a/src/mux/meta_sink.rs +++ b/src/mux/meta_sink.rs @@ -93,41 +93,123 @@ impl Stream for ChaptersSink { // ── 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) => 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::Video(v) => { + let (w, h) = v.resolution.pixels(); + 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(), + "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!({ "kind": "subtitle", "codec": codec_label(t.codec), + "pid": t.pid, "language": t.language, "forced": t.forced, - "pid": t.pid, + "qualifier": qualifier_id(t.qualifier), }), } } -/// The `json://` document for one title: identity, duration/size, its streams, -/// and its chapter points. A stable, machine-readable view of one title. +/// 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 })) + .map(|(i, c)| json!({ "n": i + 1, "start_secs": c.time_secs, "name": c.name })) .collect(); json!({ "playlist": title.playlist, @@ -135,6 +217,7 @@ pub(crate) fn title_json(title: &DiscTitle) -> serde_json::Value { "duration_secs": title.duration_secs, "size_bytes": title.size_bytes, "format": format!("{:?}", title.content_format), + "clips": clips, "streams": streams, "chapters": chapters, }) @@ -226,10 +309,49 @@ mod tests { })]; 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"); + 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"); + } + + #[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"); } }