mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
mux: carry display aspect, cicp, labels, chapters and format in the fmkv header
This commit is contained in:
+229
-6
@@ -53,6 +53,12 @@ pub struct M2tsMeta {
|
||||
/// Readers that predate it ignore the field.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub timings: Vec<MetaTiming>,
|
||||
/// Chapter marks; absent in older headers.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub chapters: Vec<MetaChapter>,
|
||||
/// Frame format of the source ("mpeg_ps"); empty/absent = BD-TS.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub content_format: String,
|
||||
/// Frames on the wire carry the DiscardPadding extension (header v2). Taken
|
||||
/// from the version byte, not the JSON.
|
||||
#[serde(skip)]
|
||||
@@ -69,6 +75,14 @@ pub struct MetaTiming {
|
||||
pub seek_preroll_ns: u64,
|
||||
}
|
||||
|
||||
/// One chapter mark carried in the FMKV header.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MetaChapter {
|
||||
pub time_secs: f64,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// A single stream descriptor in the metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
@@ -95,6 +109,12 @@ pub enum MetaStream {
|
||||
/// Base64-encoded codec initialization data (HEVCDecoderConfigurationRecord, etc.)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
codec_private: Option<String>,
|
||||
/// Display aspect `[w, h]` when it differs from the pixel grid.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
display_aspect: Option<[u32; 2]>,
|
||||
/// Measured CICP `[matrix, transfer, primaries, range]`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
cicp: Option<[u8; 4]>,
|
||||
},
|
||||
#[serde(rename = "audio")]
|
||||
Audio {
|
||||
@@ -115,6 +135,9 @@ pub enum MetaStream {
|
||||
/// emit audio tracks missing their init data versus a direct rip.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
codec_private: Option<String>,
|
||||
/// Stream purpose ("commentary", "descriptive", "score", "ime"); empty = normal.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
purpose: String,
|
||||
},
|
||||
#[serde(rename = "subtitle")]
|
||||
Subtitle {
|
||||
@@ -127,6 +150,9 @@ pub enum MetaStream {
|
||||
/// Base64-encoded codec initialization data (e.g. VobSub idx palette).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
codec_private: Option<String>,
|
||||
/// Qualifier ("sdh", "descriptive_service", "forced"); empty = none.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
qualifier: String,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -159,6 +185,10 @@ impl M2tsMeta {
|
||||
label: v.label.clone(),
|
||||
secondary: v.secondary,
|
||||
codec_private: codec_private_b64(i),
|
||||
display_aspect: v.display_aspect.map(|(w, h)| [w, h]),
|
||||
cicp: v
|
||||
.measured_cicp
|
||||
.map(|c| [c.matrix, c.transfer, c.primaries, c.range]),
|
||||
},
|
||||
Stream::Audio(a) => MetaStream::Audio {
|
||||
pid: a.pid,
|
||||
@@ -169,6 +199,7 @@ impl M2tsMeta {
|
||||
label: a.label.clone(),
|
||||
secondary: a.secondary,
|
||||
codec_private: codec_private_b64(i),
|
||||
purpose: purpose_id(a.purpose).into(),
|
||||
},
|
||||
Stream::Subtitle(s) => MetaStream::Subtitle {
|
||||
pid: s.pid,
|
||||
@@ -176,6 +207,7 @@ impl M2tsMeta {
|
||||
language: s.language.clone(),
|
||||
forced: s.forced,
|
||||
codec_private: codec_private_b64(i),
|
||||
qualifier: qualifier_id(s.qualifier).into(),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
@@ -186,6 +218,20 @@ impl M2tsMeta {
|
||||
duration: title.duration_secs,
|
||||
streams,
|
||||
timings: Vec::new(),
|
||||
// A non-finite time would serialize as null and fail the whole header on read.
|
||||
chapters: title
|
||||
.chapters
|
||||
.iter()
|
||||
.filter(|c| c.time_secs.is_finite())
|
||||
.map(|c| MetaChapter {
|
||||
time_secs: c.time_secs,
|
||||
name: c.name.clone(),
|
||||
})
|
||||
.collect(),
|
||||
content_format: match title.content_format {
|
||||
crate::disc::ContentFormat::BdTs => String::new(),
|
||||
crate::disc::ContentFormat::MpegPs => "mpeg_ps".into(),
|
||||
},
|
||||
frame_padding: false,
|
||||
}
|
||||
}
|
||||
@@ -235,6 +281,8 @@ impl M2tsMeta {
|
||||
label,
|
||||
secondary,
|
||||
codec_private: _,
|
||||
display_aspect,
|
||||
cicp,
|
||||
} => {
|
||||
let hdr_fmt = hdr.parse().unwrap_or(crate::disc::HdrFormat::Sdr);
|
||||
// Prefer the stored color space; pre-0.30.7 metadata has none,
|
||||
@@ -258,10 +306,17 @@ impl M2tsMeta {
|
||||
.unwrap_or(crate::disc::FrameRate::Unknown),
|
||||
hdr: hdr_fmt,
|
||||
color_space: cs,
|
||||
display_aspect: None,
|
||||
display_aspect: display_aspect.map(|[w, h]| (w, h)),
|
||||
secondary: *secondary,
|
||||
label: label.clone(),
|
||||
measured_cicp: None,
|
||||
measured_cicp: cicp.map(|[matrix, transfer, primaries, range]| {
|
||||
crate::disc::MeasuredCicp {
|
||||
matrix,
|
||||
transfer,
|
||||
primaries,
|
||||
range,
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
MetaStream::Audio {
|
||||
@@ -273,6 +328,7 @@ impl M2tsMeta {
|
||||
label,
|
||||
secondary,
|
||||
codec_private: _,
|
||||
purpose,
|
||||
} => Stream::Audio(AudioStream {
|
||||
pid: *pid,
|
||||
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
|
||||
@@ -284,7 +340,7 @@ impl M2tsMeta {
|
||||
.parse()
|
||||
.unwrap_or(crate::disc::SampleRate::Unknown),
|
||||
secondary: *secondary,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
purpose: purpose_from_id(purpose),
|
||||
label: label.clone(),
|
||||
}),
|
||||
MetaStream::Subtitle {
|
||||
@@ -293,12 +349,13 @@ impl M2tsMeta {
|
||||
language,
|
||||
forced,
|
||||
codec_private,
|
||||
qualifier,
|
||||
} => Stream::Subtitle(SubtitleStream {
|
||||
pid: *pid,
|
||||
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
|
||||
language: language.clone(),
|
||||
forced: *forced,
|
||||
qualifier: crate::disc::LabelQualifier::None,
|
||||
qualifier: qualifier_from_id(qualifier),
|
||||
codec_data: decode_codec_private(codec_private),
|
||||
}),
|
||||
})
|
||||
@@ -311,9 +368,19 @@ impl M2tsMeta {
|
||||
size_bytes: 0,
|
||||
clips: Vec::new(),
|
||||
streams,
|
||||
chapters: Vec::new(),
|
||||
chapters: self
|
||||
.chapters
|
||||
.iter()
|
||||
.map(|c| crate::disc::Chapter {
|
||||
time_secs: c.time_secs,
|
||||
name: c.name.clone(),
|
||||
})
|
||||
.collect(),
|
||||
extents: Vec::new(),
|
||||
content_format: crate::disc::ContentFormat::BdTs,
|
||||
content_format: match self.content_format.as_str() {
|
||||
"mpeg_ps" => crate::disc::ContentFormat::MpegPs,
|
||||
_ => crate::disc::ContentFormat::BdTs,
|
||||
},
|
||||
codec_privates: self.codec_privates(),
|
||||
}
|
||||
}
|
||||
@@ -337,6 +404,44 @@ impl M2tsMeta {
|
||||
}
|
||||
}
|
||||
|
||||
// Wire ids for the label enums; unknown ids read back as the neutral value.
|
||||
fn purpose_id(p: crate::disc::LabelPurpose) -> &'static str {
|
||||
use crate::disc::LabelPurpose::*;
|
||||
match p {
|
||||
Normal => "",
|
||||
Commentary => "commentary",
|
||||
Descriptive => "descriptive",
|
||||
Score => "score",
|
||||
Ime => "ime",
|
||||
}
|
||||
}
|
||||
|
||||
fn purpose_from_id(id: &str) -> crate::disc::LabelPurpose {
|
||||
use crate::disc::LabelPurpose::*;
|
||||
[Commentary, Descriptive, Score, Ime]
|
||||
.into_iter()
|
||||
.find(|p| purpose_id(*p) == id)
|
||||
.unwrap_or(Normal)
|
||||
}
|
||||
|
||||
fn qualifier_id(q: crate::disc::LabelQualifier) -> &'static str {
|
||||
use crate::disc::LabelQualifier::*;
|
||||
match q {
|
||||
None => "",
|
||||
Sdh => "sdh",
|
||||
DescriptiveService => "descriptive_service",
|
||||
Forced => "forced",
|
||||
}
|
||||
}
|
||||
|
||||
fn qualifier_from_id(id: &str) -> crate::disc::LabelQualifier {
|
||||
use crate::disc::LabelQualifier::*;
|
||||
[Sdh, DescriptiveService, Forced]
|
||||
.into_iter()
|
||||
.find(|q| qualifier_id(*q) == id)
|
||||
.unwrap_or(None)
|
||||
}
|
||||
|
||||
/// Decode an optional base64 codec_private string into raw bytes. Invalid
|
||||
/// base64 decodes to `None` (treated as absent) rather than erroring — a
|
||||
/// corrupt init blob shouldn't fail the whole metadata parse.
|
||||
@@ -972,4 +1077,122 @@ mod tests {
|
||||
let back = read_header(&mut io::Cursor::new(&buf)).unwrap().unwrap();
|
||||
assert!(back.timings.len() <= 256);
|
||||
}
|
||||
|
||||
// Everything a receiver's muxer uses must survive the FMKV header.
|
||||
fn full_title() -> DiscTitle {
|
||||
let mut t = video_title(HdrFormat::Sdr, ColorSpace::Bt709);
|
||||
if let Stream::Video(v) = &mut t.streams[0] {
|
||||
v.display_aspect = Some((16, 9));
|
||||
v.measured_cicp = Some(crate::disc::MeasuredCicp {
|
||||
matrix: 6,
|
||||
transfer: 6,
|
||||
primaries: 5,
|
||||
range: 1,
|
||||
});
|
||||
}
|
||||
t.streams.push(Stream::Audio(AudioStream {
|
||||
pid: 0x80,
|
||||
codec: Codec::Ac3,
|
||||
channels: crate::disc::AudioChannels::Stereo,
|
||||
language: "eng".into(),
|
||||
sample_rate: crate::disc::SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Commentary,
|
||||
label: String::new(),
|
||||
}));
|
||||
t.streams.push(Stream::Subtitle(SubtitleStream {
|
||||
pid: 0x20,
|
||||
codec: Codec::DvdSub,
|
||||
language: "eng".into(),
|
||||
forced: false,
|
||||
qualifier: crate::disc::LabelQualifier::Sdh,
|
||||
codec_data: None,
|
||||
}));
|
||||
t.chapters = vec![
|
||||
crate::disc::Chapter {
|
||||
time_secs: 0.0,
|
||||
name: "1".into(),
|
||||
},
|
||||
crate::disc::Chapter {
|
||||
time_secs: 312.5,
|
||||
name: "2".into(),
|
||||
},
|
||||
];
|
||||
t.content_format = crate::disc::ContentFormat::MpegPs;
|
||||
t
|
||||
}
|
||||
|
||||
fn over_the_wire(t: &DiscTitle) -> DiscTitle {
|
||||
let mut buf = Vec::new();
|
||||
write_header(&mut buf, &M2tsMeta::from_title(t)).unwrap();
|
||||
read_header(&mut io::Cursor::new(buf))
|
||||
.unwrap()
|
||||
.expect("header")
|
||||
.to_title()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_shape_colour_labels_chapters_and_format_survive_the_header() {
|
||||
let back = over_the_wire(&full_title());
|
||||
let Stream::Video(v) = &back.streams[0] else {
|
||||
panic!("video first")
|
||||
};
|
||||
assert_eq!(v.display_aspect, Some((16, 9)));
|
||||
assert_eq!(
|
||||
v.measured_cicp
|
||||
.map(|c| (c.matrix, c.transfer, c.primaries, c.range)),
|
||||
Some((6, 6, 5, 1))
|
||||
);
|
||||
let Stream::Audio(a) = &back.streams[1] else {
|
||||
panic!("audio second")
|
||||
};
|
||||
assert_eq!(a.purpose, crate::disc::LabelPurpose::Commentary);
|
||||
let Stream::Subtitle(s) = &back.streams[2] else {
|
||||
panic!("subtitle third")
|
||||
};
|
||||
assert_eq!(s.qualifier, crate::disc::LabelQualifier::Sdh);
|
||||
let marks: Vec<_> = back
|
||||
.chapters
|
||||
.iter()
|
||||
.map(|c| (c.time_secs, c.name.as_str()))
|
||||
.collect();
|
||||
assert_eq!(marks, [(0.0, "1"), (312.5, "2")]);
|
||||
assert_eq!(back.content_format, crate::disc::ContentFormat::MpegPs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_without_the_optional_fields_still_reads_with_the_old_defaults() {
|
||||
let old = r#"{"v":1,"title":"t","duration":1.0,"streams":[
|
||||
{"type":"video","pid":4113,"codec":"hevc"},
|
||||
{"type":"audio","pid":128,"codec":"ac3"},
|
||||
{"type":"subtitle","pid":32,"codec":"pgs"}]}"#;
|
||||
let t = serde_json::from_str::<M2tsMeta>(old).unwrap().to_title();
|
||||
let Stream::Video(v) = &t.streams[0] else {
|
||||
panic!("video")
|
||||
};
|
||||
assert_eq!((v.display_aspect, v.measured_cicp), (None, None));
|
||||
assert!(t.chapters.is_empty());
|
||||
assert_eq!(t.content_format, crate::disc::ContentFormat::BdTs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_from_a_newer_writer_are_ignored() {
|
||||
let newer = r#"{"v":1,"title":"t","duration":1.0,"future":[1],"streams":[
|
||||
{"type":"audio","pid":128,"codec":"ac3","future":{"x":1}}]}"#;
|
||||
assert_eq!(
|
||||
serde_json::from_str::<M2tsMeta>(newer)
|
||||
.unwrap()
|
||||
.streams
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_finite_chapter_time_does_not_break_the_header() {
|
||||
let mut t = full_title();
|
||||
t.chapters[1].time_secs = f64::NAN;
|
||||
let back = over_the_wire(&t);
|
||||
assert_eq!(back.chapters.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user