1use serde::{Deserialize, Serialize};
11use tokio::process::Command;
12
13use crate::ffprobe_path;
14
15#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
21pub struct MasteringDisplay {
22 pub green_x: u32,
24 pub green_y: u32,
26 pub blue_x: u32,
28 pub blue_y: u32,
30 pub red_x: u32,
32 pub red_y: u32,
34 pub white_x: u32,
36 pub white_y: u32,
38 pub max_luminance: u32,
40 pub min_luminance: u32,
42}
43
44impl MasteringDisplay {
45 pub fn to_x265_string(&self) -> String {
49 format!(
50 "G({},{})B({},{})R({},{})WP({},{})L({},{})",
51 self.green_x,
52 self.green_y,
53 self.blue_x,
54 self.blue_y,
55 self.red_x,
56 self.red_y,
57 self.white_x,
58 self.white_y,
59 self.max_luminance,
60 self.min_luminance,
61 )
62 }
63
64 pub fn to_svtav1_string(&self) -> String {
69 let c = |u: u32| trim_float(u as f64 / CHROMA_UNIT);
70 let l = |u: u32| trim_float(u as f64 / LUMA_UNIT);
71 format!(
72 "G({},{})B({},{})R({},{})WP({},{})L({},{})",
73 c(self.green_x),
74 c(self.green_y),
75 c(self.blue_x),
76 c(self.blue_y),
77 c(self.red_x),
78 c(self.red_y),
79 c(self.white_x),
80 c(self.white_y),
81 l(self.max_luminance),
82 l(self.min_luminance),
83 )
84 }
85}
86
87fn trim_float(v: f64) -> String {
90 let s = format!("{v:.6}");
91 s.trim_end_matches('0').trim_end_matches('.').to_string()
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
96pub struct Hdr10Metadata {
97 pub mastering_display: Option<MasteringDisplay>,
99 pub max_cll: Option<u32>,
101 pub max_fall: Option<u32>,
103}
104
105impl Hdr10Metadata {
106 pub fn is_empty(&self) -> bool {
108 self.mastering_display.is_none() && self.max_cll.is_none()
109 }
110}
111
112pub async fn probe_hdr10_metadata(path: &str) -> anyhow::Result<Option<Hdr10Metadata>> {
118 let output = Command::new(ffprobe_path())
119 .args([
120 "-v",
121 "error",
122 "-select_streams",
123 "v:0",
124 "-read_intervals",
125 "%+#1",
126 "-show_frames",
127 "-print_format",
128 "json",
129 path,
130 ])
131 .stderr(std::process::Stdio::piped())
132 .output()
133 .await?;
134
135 if !output.status.success() {
136 let stderr = String::from_utf8_lossy(&output.stderr);
137 anyhow::bail!("ffprobe (hdr10) failed for {path}: {stderr}");
138 }
139
140 let parsed: FramesJson = serde_json::from_slice(&output.stdout)
141 .map_err(|e| anyhow::anyhow!("failed to parse ffprobe hdr10 output: {e}"))?;
142
143 Ok(parse_hdr10(&parsed))
144}
145
146const MASTERING_DISPLAY: &str = "Mastering display metadata";
147const CONTENT_LIGHT: &str = "Content light level metadata";
148
149fn parse_hdr10(frames: &FramesJson) -> Option<Hdr10Metadata> {
150 let side_data = &frames.frames.first()?.side_data_list;
151 let mut md = Hdr10Metadata::default();
152
153 for sd in side_data {
154 match sd.side_data_type.as_str() {
155 MASTERING_DISPLAY => md.mastering_display = parse_mastering_display(sd),
156 CONTENT_LIGHT => {
157 md.max_cll = value_to_u32(&sd.max_content);
158 md.max_fall = value_to_u32(&sd.max_average);
159 }
160 _ => {}
161 }
162 }
163
164 if md.is_empty() { None } else { Some(md) }
165}
166
167const CHROMA_UNIT: f64 = 50000.0;
171const LUMA_UNIT: f64 = 10000.0;
172
173fn parse_mastering_display(sd: &SideData) -> Option<MasteringDisplay> {
174 Some(MasteringDisplay {
175 green_x: scaled(&sd.green_x, CHROMA_UNIT)?,
176 green_y: scaled(&sd.green_y, CHROMA_UNIT)?,
177 blue_x: scaled(&sd.blue_x, CHROMA_UNIT)?,
178 blue_y: scaled(&sd.blue_y, CHROMA_UNIT)?,
179 red_x: scaled(&sd.red_x, CHROMA_UNIT)?,
180 red_y: scaled(&sd.red_y, CHROMA_UNIT)?,
181 white_x: scaled(&sd.white_point_x, CHROMA_UNIT)?,
182 white_y: scaled(&sd.white_point_y, CHROMA_UNIT)?,
183 max_luminance: scaled(&sd.max_luminance, LUMA_UNIT)?,
184 min_luminance: scaled(&sd.min_luminance, LUMA_UNIT)?,
185 })
186}
187
188fn rational(s: &str) -> Option<f64> {
190 let s = s.trim();
191 if s.is_empty() {
192 return None;
193 }
194 if let Some((num, den)) = s.split_once('/') {
195 let num: f64 = num.trim().parse().ok()?;
196 let den: f64 = den.trim().parse().ok()?;
197 if den == 0.0 {
198 return None;
199 }
200 Some(num / den)
201 } else {
202 s.parse().ok()
203 }
204}
205
206fn scaled(s: &str, unit: f64) -> Option<u32> {
208 rational(s).map(|v| (v * unit).round().max(0.0) as u32)
209}
210
211fn value_to_u32(v: &Option<serde_json::Value>) -> Option<u32> {
213 match v {
214 Some(serde_json::Value::Number(n)) => n.as_u64().map(|x| x as u32),
215 Some(serde_json::Value::String(s)) => s.trim().parse().ok(),
216 _ => None,
217 }
218}
219
220#[derive(Deserialize)]
221struct FramesJson {
222 #[serde(default)]
223 frames: Vec<FrameJson>,
224}
225
226#[derive(Deserialize)]
227struct FrameJson {
228 #[serde(default)]
229 side_data_list: Vec<SideData>,
230}
231
232#[derive(Deserialize)]
233struct SideData {
234 #[serde(default)]
235 side_data_type: String,
236 #[serde(default)]
238 red_x: String,
239 #[serde(default)]
240 red_y: String,
241 #[serde(default)]
242 green_x: String,
243 #[serde(default)]
244 green_y: String,
245 #[serde(default)]
246 blue_x: String,
247 #[serde(default)]
248 blue_y: String,
249 #[serde(default)]
250 white_point_x: String,
251 #[serde(default)]
252 white_point_y: String,
253 #[serde(default)]
254 min_luminance: String,
255 #[serde(default)]
256 max_luminance: String,
257 #[serde(default)]
259 max_content: Option<serde_json::Value>,
260 #[serde(default)]
261 max_average: Option<serde_json::Value>,
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 const SAMPLE: &str = r#"{
271 "frames": [{
272 "side_data_list": [
273 {
274 "side_data_type": "Mastering display metadata",
275 "red_x": "34000/50000", "red_y": "16000/50000",
276 "green_x": "13250/50000", "green_y": "34500/50000",
277 "blue_x": "7500/50000", "blue_y": "3000/50000",
278 "white_point_x": "15635/50000", "white_point_y": "16450/50000",
279 "min_luminance": "50/10000", "max_luminance": "10000000/10000"
280 },
281 {
282 "side_data_type": "Content light level metadata",
283 "max_content": 1000, "max_average": 400
284 }
285 ]
286 }]
287 }"#;
288
289 #[test]
290 fn test_parse_full_hdr10() {
291 let frames: FramesJson = serde_json::from_str(SAMPLE).unwrap();
292 let md = parse_hdr10(&frames).expect("metadata present");
293 let display = md.mastering_display.expect("mastering display present");
294 assert_eq!(display.green_x, 13250);
295 assert_eq!(display.green_y, 34500);
296 assert_eq!(display.red_x, 34000);
297 assert_eq!(display.white_x, 15635);
298 assert_eq!(display.max_luminance, 10_000_000);
299 assert_eq!(display.min_luminance, 50);
300 assert_eq!(md.max_cll, Some(1000));
301 assert_eq!(md.max_fall, Some(400));
302 }
303
304 #[test]
305 fn test_to_x265_format() {
306 let frames: FramesJson = serde_json::from_str(SAMPLE).unwrap();
307 let md = parse_hdr10(&frames).unwrap().mastering_display.unwrap();
308 assert_eq!(
309 md.to_x265_string(),
310 "G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,50)"
311 );
312 }
313
314 #[test]
315 fn test_to_svtav1_format() {
316 let frames: FramesJson = serde_json::from_str(SAMPLE).unwrap();
317 let md = parse_hdr10(&frames).unwrap().mastering_display.unwrap();
318 assert_eq!(
320 md.to_svtav1_string(),
321 "G(0.265,0.69)B(0.15,0.06)R(0.68,0.32)WP(0.3127,0.329)L(1000,0.005)"
322 );
323 }
324
325 #[test]
326 fn test_trim_float() {
327 assert_eq!(trim_float(0.265), "0.265");
328 assert_eq!(trim_float(1000.0), "1000");
329 assert_eq!(trim_float(0.005), "0.005");
330 assert_eq!(trim_float(0.0), "0");
331 }
332
333 #[test]
334 fn test_sdr_returns_none() {
335 let frames: FramesJson =
336 serde_json::from_str(r#"{"frames":[{"side_data_list":[]}]}"#).unwrap();
337 assert!(parse_hdr10(&frames).is_none());
338 }
339
340 #[test]
341 fn test_content_light_only() {
342 let json = r#"{"frames":[{"side_data_list":[
343 {"side_data_type":"Content light level metadata","max_content":600,"max_average":120}
344 ]}]}"#;
345 let frames: FramesJson = serde_json::from_str(json).unwrap();
346 let md = parse_hdr10(&frames).expect("cll present");
347 assert!(md.mastering_display.is_none());
348 assert_eq!(md.max_cll, Some(600));
349 assert_eq!(md.max_fall, Some(120));
350 }
351
352 #[test]
353 fn test_content_light_string_values() {
354 let json = r#"{"frames":[{"side_data_list":[
355 {"side_data_type":"Content light level metadata","max_content":"600","max_average":"120"}
356 ]}]}"#;
357 let frames: FramesJson = serde_json::from_str(json).unwrap();
358 let md = parse_hdr10(&frames).unwrap();
359 assert_eq!(md.max_cll, Some(600));
360 assert_eq!(md.max_fall, Some(120));
361 }
362
363 #[test]
364 fn test_no_frames_returns_none() {
365 let frames: FramesJson = serde_json::from_str(r#"{"frames":[]}"#).unwrap();
366 assert!(parse_hdr10(&frames).is_none());
367 }
368
369 #[test]
370 fn test_rational_parsing() {
371 assert_eq!(rational("1/2"), Some(0.5));
372 assert_eq!(rational("100"), Some(100.0));
373 assert_eq!(rational("5/0"), None);
374 assert_eq!(rational(""), None);
375 }
376
377 #[test]
378 fn test_is_empty() {
379 assert!(Hdr10Metadata::default().is_empty());
380 let md = Hdr10Metadata { max_cll: Some(1000), ..Default::default() };
381 assert!(!md.is_empty());
382 }
383}