Skip to main content

viser_ffmpeg/
hdr.rs

1//! HDR10 static metadata: mastering-display colour volume (SMPTE ST 2086) and
2//! content light level (MaxCLL / MaxFALL, CTA-861.3).
3//!
4//! ffprobe does not surface these as top-level stream fields; they ride as
5//! per-frame side data (HEVC SEI / AV1 OBU metadata). [`probe_hdr10_metadata`]
6//! reads the first video frame's `side_data_list` and normalises the values
7//! into the integer units x265 expects for its `master-display` / `max-cll`
8//! parameters, so HDR10 signalling survives a re-encode.
9
10use serde::{Deserialize, Serialize};
11use tokio::process::Command;
12
13use crate::ffprobe_path;
14
15/// SMPTE ST 2086 mastering-display colour volume.
16///
17/// Chromaticity coordinates are stored in units of `1/50000` and luminance in
18/// units of `0.0001 cd/m²` — the exact integer encoding x265's `master-display`
19/// parameter consumes.
20#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
21pub struct MasteringDisplay {
22    /// Green primary x, in 1/50000 units.
23    pub green_x: u32,
24    /// Green primary y, in 1/50000 units.
25    pub green_y: u32,
26    /// Blue primary x, in 1/50000 units.
27    pub blue_x: u32,
28    /// Blue primary y, in 1/50000 units.
29    pub blue_y: u32,
30    /// Red primary x, in 1/50000 units.
31    pub red_x: u32,
32    /// Red primary y, in 1/50000 units.
33    pub red_y: u32,
34    /// White point x, in 1/50000 units.
35    pub white_x: u32,
36    /// White point y, in 1/50000 units.
37    pub white_y: u32,
38    /// Maximum display luminance, in 0.0001 cd/m² units.
39    pub max_luminance: u32,
40    /// Minimum display luminance, in 0.0001 cd/m² units.
41    pub min_luminance: u32,
42}
43
44impl MasteringDisplay {
45    /// Formats as an x265 `master-display` value:
46    /// `G(gx,gy)B(bx,by)R(rx,ry)WP(wx,wy)L(max,min)`, with chromaticity in
47    /// 1/50000 units and luminance in 0.0001 cd/m² units (the stored encoding).
48    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    /// Formats as an SVT-AV1 `mastering-display` value. SVT-AV1 shares x265's
65    /// `G()B()R()WP()L()` grammar but expects **real** chromaticity coordinates
66    /// in `[0,1]` and luminance in cd/m² — not x265's scaled integers. Feeding
67    /// it the x265 integers makes SVT-AV1 clip the values to garbage.
68    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
87/// Formats a float with up to 6 decimals, trimming trailing zeros (and a bare
88/// trailing `.`), so `0.265000 → 0.265` and `1000.0 → 1000`.
89fn 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/// HDR10 static metadata extracted from a source's first video frame.
95#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
96pub struct Hdr10Metadata {
97    /// Mastering-display colour volume, when present.
98    pub mastering_display: Option<MasteringDisplay>,
99    /// Maximum content light level (MaxCLL), in cd/m².
100    pub max_cll: Option<u32>,
101    /// Maximum frame-average light level (MaxFALL), in cd/m².
102    pub max_fall: Option<u32>,
103}
104
105impl Hdr10Metadata {
106    /// Returns `true` when no usable metadata was found.
107    pub fn is_empty(&self) -> bool {
108        self.mastering_display.is_none() && self.max_cll.is_none()
109    }
110}
111
112/// Probes the first video frame for HDR10 static metadata.
113///
114/// Returns `Ok(None)` when the source carries no mastering-display or
115/// content-light side data (e.g. SDR or HLG content). Decodes a single frame,
116/// so the cost is negligible.
117pub 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
167/// Chromaticity coordinates are reported as fractions of 1 (e.g. `"13250/50000"`)
168/// and scale to 1/50000 units; luminance (e.g. `"10000000/10000"`) scales to
169/// 0.0001 cd/m² units.
170const 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
188/// Parses a `"num/den"` rational (or plain decimal) into `f64`.
189fn 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
206/// Parses a rational and scales it into the integer unit x265 expects.
207fn scaled(s: &str, unit: f64) -> Option<u32> {
208    rational(s).map(|v| (v * unit).round().max(0.0) as u32)
209}
210
211/// ffprobe emits MaxCLL/MaxFALL as JSON numbers; older builds use strings.
212fn 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    // Mastering-display fields (rational strings).
237    #[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    // Content-light fields (numbers, occasionally strings).
258    #[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    // A representative ffprobe `-show_frames` payload for BT.2020 HDR10 content
269    // mastered on a P3-D65 display at 1000 nits, MaxCLL 1000 / MaxFALL 400.
270    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        // Real chromaticity coordinates (0..1) and luminance in cd/m².
319        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}