mux/meta: preserve color_space round-trip in m2ts metadata
MetaStream::Video dropped color_space on from_title/to_title, hardcoding BT.709 on the way back. HDR titles (BT.2020) lost their color metadata. Add a color_space field, populate it in from_title, and use it in to_title. For pre-0.30.7 metadata that has no color_space, derive it from the preserved hdr field (all HDR formats are BT.2020, SDR is BT.709). Adds ColorSpace::id() + FromStr for serialization round-trip.
This commit is contained in:
@@ -779,6 +779,22 @@ impl ColorSpace {
|
||||
ColorSpace::Unknown => "",
|
||||
}
|
||||
}
|
||||
|
||||
const ALL_CS: &[(&'static str, ColorSpace)] = &[
|
||||
("bt709", ColorSpace::Bt709),
|
||||
("bt2020", ColorSpace::Bt2020),
|
||||
("unknown", ColorSpace::Unknown),
|
||||
];
|
||||
|
||||
/// Compact identifier for serialization (round-trips via `FromStr`).
|
||||
pub fn id(&self) -> &'static str {
|
||||
for (id, v) in Self::ALL_CS {
|
||||
if v == self {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ColorSpace {
|
||||
@@ -787,6 +803,24 @@ impl std::fmt::Display for ColorSpace {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ColorSpace {
|
||||
type Err = ();
|
||||
fn from_str(s: &str) -> std::result::Result<Self, ()> {
|
||||
for (id, v) in ColorSpace::ALL_CS {
|
||||
if *id == s {
|
||||
return Ok(*v);
|
||||
}
|
||||
}
|
||||
// Also accept display names (e.g. "BT.2020").
|
||||
for (_id, v) in ColorSpace::ALL_CS {
|
||||
if ColorSpace::name(v) == s {
|
||||
return Ok(*v);
|
||||
}
|
||||
}
|
||||
Ok(ColorSpace::Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── FromStr impls — single source of truth via ALL_* arrays ───────────────
|
||||
//
|
||||
// Each enum defines a const array of (str, variant) pairs. Display, FromStr,
|
||||
|
||||
+149
-15
@@ -3,10 +3,22 @@
|
||||
//! Format: [8B magic] [4B json_len] [JSON] [padding to 192B boundary] [BD-TS data...]
|
||||
//! Other tools skip the header during TS sync recovery (scan for 0x47).
|
||||
|
||||
use crate::disc::{AudioStream, ColorSpace, DiscTitle, Stream, SubtitleStream, VideoStream};
|
||||
use crate::disc::{AudioStream, ColorSpace, DiscTitle, HdrFormat, Stream, SubtitleStream, VideoStream};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{self, Read, Write};
|
||||
|
||||
/// Derive the color space from the HDR format, for metadata written before the
|
||||
/// color space was persisted (pre-0.30.7). All HDR formats use BT.2020 wide
|
||||
/// gamut; SDR uses BT.709.
|
||||
fn color_space_from_hdr(hdr: HdrFormat) -> ColorSpace {
|
||||
match hdr {
|
||||
HdrFormat::Hdr10 | HdrFormat::Hdr10Plus | HdrFormat::Hlg | HdrFormat::DolbyVision => {
|
||||
ColorSpace::Bt2020
|
||||
}
|
||||
HdrFormat::Sdr => ColorSpace::Bt709,
|
||||
}
|
||||
}
|
||||
|
||||
/// Magic bytes: "FMKV" + version 1 + 2 reserved bytes.
|
||||
const MAGIC: [u8; 8] = [b'F', b'M', b'K', b'V', 0x00, 0x01, 0x00, 0x00];
|
||||
|
||||
@@ -42,6 +54,11 @@ pub enum MetaStream {
|
||||
frame_rate: String,
|
||||
#[serde(default)]
|
||||
hdr: String,
|
||||
/// Color space id (e.g. "bt709", "bt2020"). Empty/absent in pre-0.30.7
|
||||
/// metadata — `to_title` then derives it from `hdr` so HDR color
|
||||
/// primaries/transfer/matrix still round-trip.
|
||||
#[serde(default)]
|
||||
color_space: String,
|
||||
#[serde(default)]
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
@@ -91,6 +108,7 @@ impl M2tsMeta {
|
||||
resolution: v.resolution.to_string(),
|
||||
frame_rate: v.frame_rate.to_string(),
|
||||
hdr: v.hdr.id().into(),
|
||||
color_space: v.color_space.id().into(),
|
||||
label: v.label.clone(),
|
||||
secondary: v.secondary,
|
||||
codec_private: title
|
||||
@@ -137,23 +155,38 @@ impl M2tsMeta {
|
||||
resolution,
|
||||
frame_rate,
|
||||
hdr,
|
||||
color_space,
|
||||
label,
|
||||
secondary,
|
||||
codec_private: _,
|
||||
} => Stream::Video(VideoStream {
|
||||
pid: *pid,
|
||||
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
|
||||
resolution: resolution
|
||||
.parse()
|
||||
.unwrap_or(crate::disc::Resolution::Unknown),
|
||||
frame_rate: frame_rate
|
||||
.parse()
|
||||
.unwrap_or(crate::disc::FrameRate::Unknown),
|
||||
hdr: hdr.parse().unwrap_or(crate::disc::HdrFormat::Sdr),
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: *secondary,
|
||||
label: label.clone(),
|
||||
}),
|
||||
} => {
|
||||
let hdr_fmt = hdr.parse().unwrap_or(crate::disc::HdrFormat::Sdr);
|
||||
// Prefer the explicitly stored color space. Pre-0.30.7
|
||||
// metadata has none, so derive it from the HDR format:
|
||||
// every HDR variant (HDR10/HDR10+/HLG/Dolby Vision) is
|
||||
// BT.2020; SDR is BT.709.
|
||||
let cs = if color_space.is_empty() {
|
||||
color_space_from_hdr(hdr_fmt)
|
||||
} else {
|
||||
color_space
|
||||
.parse::<ColorSpace>()
|
||||
.unwrap_or(ColorSpace::Unknown)
|
||||
};
|
||||
Stream::Video(VideoStream {
|
||||
pid: *pid,
|
||||
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
|
||||
resolution: resolution
|
||||
.parse()
|
||||
.unwrap_or(crate::disc::Resolution::Unknown),
|
||||
frame_rate: frame_rate
|
||||
.parse()
|
||||
.unwrap_or(crate::disc::FrameRate::Unknown),
|
||||
hdr: hdr_fmt,
|
||||
color_space: cs,
|
||||
secondary: *secondary,
|
||||
label: label.clone(),
|
||||
})
|
||||
}
|
||||
MetaStream::Audio {
|
||||
pid,
|
||||
codec,
|
||||
@@ -289,3 +322,104 @@ pub fn read_header(r: &mut impl Read) -> io::Result<Option<M2tsMeta>> {
|
||||
|
||||
// Serialization uses Codec::id() / HdrFormat::id() and Display impls.
|
||||
// Deserialization uses FromStr impls (.parse()) on each enum.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::disc::{Codec, FrameRate, Resolution};
|
||||
|
||||
fn video_title(hdr: HdrFormat, cs: ColorSpace) -> DiscTitle {
|
||||
let mut t = DiscTitle::empty();
|
||||
t.streams.push(Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::Hevc,
|
||||
resolution: Resolution::R2160p,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr,
|
||||
color_space: cs,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
}));
|
||||
t
|
||||
}
|
||||
|
||||
fn round_trip_color_space(title: &DiscTitle) -> ColorSpace {
|
||||
let meta = M2tsMeta::from_title(title);
|
||||
let back = meta.to_title();
|
||||
match &back.streams[0] {
|
||||
Stream::Video(v) => v.color_space,
|
||||
_ => panic!("expected video stream"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_space_round_trips_bt2020() {
|
||||
// The regression: HDR10 / BT.2020 must survive from_title → to_title,
|
||||
// not collapse to the hardcoded BT.709.
|
||||
let cs = round_trip_color_space(&video_title(HdrFormat::Hdr10, ColorSpace::Bt2020));
|
||||
assert_eq!(cs, ColorSpace::Bt2020, "BT.2020 must round-trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_space_round_trips_bt709() {
|
||||
let cs = round_trip_color_space(&video_title(HdrFormat::Sdr, ColorSpace::Bt709));
|
||||
assert_eq!(cs, ColorSpace::Bt709);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_space_serialized_in_json() {
|
||||
let meta = M2tsMeta::from_title(&video_title(HdrFormat::Hdr10, ColorSpace::Bt2020));
|
||||
let json = serde_json::to_string(&meta).unwrap();
|
||||
assert!(
|
||||
json.contains("bt2020"),
|
||||
"color_space must be serialized: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_metadata_without_color_space_derives_from_hdr() {
|
||||
// Pre-0.30.7 JSON has no color_space field. to_title must derive the
|
||||
// color space from the HDR format so HDR color metadata is preserved.
|
||||
let json = r#"{
|
||||
"v": 1,
|
||||
"title": "x",
|
||||
"duration": 0.0,
|
||||
"streams": [
|
||||
{"type":"video","pid":4113,"codec":"hevc","resolution":"2160p",
|
||||
"frame_rate":"23.976","hdr":"hdr10","label":"","secondary":false}
|
||||
]
|
||||
}"#;
|
||||
let meta: M2tsMeta = serde_json::from_str(json).unwrap();
|
||||
let back = meta.to_title();
|
||||
match &back.streams[0] {
|
||||
Stream::Video(v) => {
|
||||
assert_eq!(v.hdr, HdrFormat::Hdr10);
|
||||
assert_eq!(
|
||||
v.color_space,
|
||||
ColorSpace::Bt2020,
|
||||
"HDR10 must derive BT.2020 when color_space absent"
|
||||
);
|
||||
}
|
||||
_ => panic!("expected video stream"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_sdr_without_color_space_derives_bt709() {
|
||||
let json = r#"{
|
||||
"v": 1,
|
||||
"title": "x",
|
||||
"duration": 0.0,
|
||||
"streams": [
|
||||
{"type":"video","pid":4113,"codec":"h264","resolution":"1080p",
|
||||
"frame_rate":"24","hdr":"sdr","label":"","secondary":false}
|
||||
]
|
||||
}"#;
|
||||
let meta: M2tsMeta = serde_json::from_str(json).unwrap();
|
||||
let back = meta.to_title();
|
||||
match &back.streams[0] {
|
||||
Stream::Video(v) => assert_eq!(v.color_space, ColorSpace::Bt709),
|
||||
_ => panic!("expected video stream"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user