use serde::{Deserialize, Serialize};
use tokio::process::Command;
use crate::ffprobe_path;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MasteringDisplay {
pub green_x: u32,
pub green_y: u32,
pub blue_x: u32,
pub blue_y: u32,
pub red_x: u32,
pub red_y: u32,
pub white_x: u32,
pub white_y: u32,
pub max_luminance: u32,
pub min_luminance: u32,
}
impl MasteringDisplay {
pub fn to_x265_string(&self) -> String {
format!(
"G({},{})B({},{})R({},{})WP({},{})L({},{})",
self.green_x,
self.green_y,
self.blue_x,
self.blue_y,
self.red_x,
self.red_y,
self.white_x,
self.white_y,
self.max_luminance,
self.min_luminance,
)
}
pub fn to_svtav1_string(&self) -> String {
let c = |u: u32| trim_float(u as f64 / CHROMA_UNIT);
let l = |u: u32| trim_float(u as f64 / LUMA_UNIT);
format!(
"G({},{})B({},{})R({},{})WP({},{})L({},{})",
c(self.green_x),
c(self.green_y),
c(self.blue_x),
c(self.blue_y),
c(self.red_x),
c(self.red_y),
c(self.white_x),
c(self.white_y),
l(self.max_luminance),
l(self.min_luminance),
)
}
}
fn trim_float(v: f64) -> String {
let s = format!("{v:.6}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Hdr10Metadata {
pub mastering_display: Option<MasteringDisplay>,
pub max_cll: Option<u32>,
pub max_fall: Option<u32>,
}
impl Hdr10Metadata {
pub fn is_empty(&self) -> bool {
self.mastering_display.is_none() && self.max_cll.is_none()
}
}
pub async fn probe_hdr10_metadata(path: &str) -> anyhow::Result<Option<Hdr10Metadata>> {
let output = Command::new(ffprobe_path())
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-read_intervals",
"%+#1",
"-show_frames",
"-print_format",
"json",
path,
])
.stderr(std::process::Stdio::piped())
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("ffprobe (hdr10) failed for {path}: {stderr}");
}
let parsed: FramesJson = serde_json::from_slice(&output.stdout)
.map_err(|e| anyhow::anyhow!("failed to parse ffprobe hdr10 output: {e}"))?;
Ok(parse_hdr10(&parsed))
}
const MASTERING_DISPLAY: &str = "Mastering display metadata";
const CONTENT_LIGHT: &str = "Content light level metadata";
fn parse_hdr10(frames: &FramesJson) -> Option<Hdr10Metadata> {
let side_data = &frames.frames.first()?.side_data_list;
let mut md = Hdr10Metadata::default();
for sd in side_data {
match sd.side_data_type.as_str() {
MASTERING_DISPLAY => md.mastering_display = parse_mastering_display(sd),
CONTENT_LIGHT => {
md.max_cll = value_to_u32(&sd.max_content);
md.max_fall = value_to_u32(&sd.max_average);
}
_ => {}
}
}
if md.is_empty() { None } else { Some(md) }
}
const CHROMA_UNIT: f64 = 50000.0;
const LUMA_UNIT: f64 = 10000.0;
fn parse_mastering_display(sd: &SideData) -> Option<MasteringDisplay> {
Some(MasteringDisplay {
green_x: scaled(&sd.green_x, CHROMA_UNIT)?,
green_y: scaled(&sd.green_y, CHROMA_UNIT)?,
blue_x: scaled(&sd.blue_x, CHROMA_UNIT)?,
blue_y: scaled(&sd.blue_y, CHROMA_UNIT)?,
red_x: scaled(&sd.red_x, CHROMA_UNIT)?,
red_y: scaled(&sd.red_y, CHROMA_UNIT)?,
white_x: scaled(&sd.white_point_x, CHROMA_UNIT)?,
white_y: scaled(&sd.white_point_y, CHROMA_UNIT)?,
max_luminance: scaled(&sd.max_luminance, LUMA_UNIT)?,
min_luminance: scaled(&sd.min_luminance, LUMA_UNIT)?,
})
}
fn rational(s: &str) -> Option<f64> {
let s = s.trim();
if s.is_empty() {
return None;
}
if let Some((num, den)) = s.split_once('/') {
let num: f64 = num.trim().parse().ok()?;
let den: f64 = den.trim().parse().ok()?;
if den == 0.0 {
return None;
}
Some(num / den)
} else {
s.parse().ok()
}
}
fn scaled(s: &str, unit: f64) -> Option<u32> {
rational(s).map(|v| (v * unit).round().max(0.0) as u32)
}
fn value_to_u32(v: &Option<serde_json::Value>) -> Option<u32> {
match v {
Some(serde_json::Value::Number(n)) => n.as_u64().map(|x| x as u32),
Some(serde_json::Value::String(s)) => s.trim().parse().ok(),
_ => None,
}
}
#[derive(Deserialize)]
struct FramesJson {
#[serde(default)]
frames: Vec<FrameJson>,
}
#[derive(Deserialize)]
struct FrameJson {
#[serde(default)]
side_data_list: Vec<SideData>,
}
#[derive(Deserialize)]
struct SideData {
#[serde(default)]
side_data_type: String,
#[serde(default)]
red_x: String,
#[serde(default)]
red_y: String,
#[serde(default)]
green_x: String,
#[serde(default)]
green_y: String,
#[serde(default)]
blue_x: String,
#[serde(default)]
blue_y: String,
#[serde(default)]
white_point_x: String,
#[serde(default)]
white_point_y: String,
#[serde(default)]
min_luminance: String,
#[serde(default)]
max_luminance: String,
#[serde(default)]
max_content: Option<serde_json::Value>,
#[serde(default)]
max_average: Option<serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"{
"frames": [{
"side_data_list": [
{
"side_data_type": "Mastering display metadata",
"red_x": "34000/50000", "red_y": "16000/50000",
"green_x": "13250/50000", "green_y": "34500/50000",
"blue_x": "7500/50000", "blue_y": "3000/50000",
"white_point_x": "15635/50000", "white_point_y": "16450/50000",
"min_luminance": "50/10000", "max_luminance": "10000000/10000"
},
{
"side_data_type": "Content light level metadata",
"max_content": 1000, "max_average": 400
}
]
}]
}"#;
#[test]
fn test_parse_full_hdr10() {
let frames: FramesJson = serde_json::from_str(SAMPLE).unwrap();
let md = parse_hdr10(&frames).expect("metadata present");
let display = md.mastering_display.expect("mastering display present");
assert_eq!(display.green_x, 13250);
assert_eq!(display.green_y, 34500);
assert_eq!(display.red_x, 34000);
assert_eq!(display.white_x, 15635);
assert_eq!(display.max_luminance, 10_000_000);
assert_eq!(display.min_luminance, 50);
assert_eq!(md.max_cll, Some(1000));
assert_eq!(md.max_fall, Some(400));
}
#[test]
fn test_to_x265_format() {
let frames: FramesJson = serde_json::from_str(SAMPLE).unwrap();
let md = parse_hdr10(&frames).unwrap().mastering_display.unwrap();
assert_eq!(
md.to_x265_string(),
"G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,50)"
);
}
#[test]
fn test_to_svtav1_format() {
let frames: FramesJson = serde_json::from_str(SAMPLE).unwrap();
let md = parse_hdr10(&frames).unwrap().mastering_display.unwrap();
assert_eq!(
md.to_svtav1_string(),
"G(0.265,0.69)B(0.15,0.06)R(0.68,0.32)WP(0.3127,0.329)L(1000,0.005)"
);
}
#[test]
fn test_trim_float() {
assert_eq!(trim_float(0.265), "0.265");
assert_eq!(trim_float(1000.0), "1000");
assert_eq!(trim_float(0.005), "0.005");
assert_eq!(trim_float(0.0), "0");
}
#[test]
fn test_sdr_returns_none() {
let frames: FramesJson =
serde_json::from_str(r#"{"frames":[{"side_data_list":[]}]}"#).unwrap();
assert!(parse_hdr10(&frames).is_none());
}
#[test]
fn test_content_light_only() {
let json = r#"{"frames":[{"side_data_list":[
{"side_data_type":"Content light level metadata","max_content":600,"max_average":120}
]}]}"#;
let frames: FramesJson = serde_json::from_str(json).unwrap();
let md = parse_hdr10(&frames).expect("cll present");
assert!(md.mastering_display.is_none());
assert_eq!(md.max_cll, Some(600));
assert_eq!(md.max_fall, Some(120));
}
#[test]
fn test_content_light_string_values() {
let json = r#"{"frames":[{"side_data_list":[
{"side_data_type":"Content light level metadata","max_content":"600","max_average":"120"}
]}]}"#;
let frames: FramesJson = serde_json::from_str(json).unwrap();
let md = parse_hdr10(&frames).unwrap();
assert_eq!(md.max_cll, Some(600));
assert_eq!(md.max_fall, Some(120));
}
#[test]
fn test_no_frames_returns_none() {
let frames: FramesJson = serde_json::from_str(r#"{"frames":[]}"#).unwrap();
assert!(parse_hdr10(&frames).is_none());
}
#[test]
fn test_rational_parsing() {
assert_eq!(rational("1/2"), Some(0.5));
assert_eq!(rational("100"), Some(100.0));
assert_eq!(rational("5/0"), None);
assert_eq!(rational(""), None);
}
#[test]
fn test_is_empty() {
assert!(Hdr10Metadata::default().is_empty());
let md = Hdr10Metadata { max_cll: Some(1000), ..Default::default() };
assert!(!md.is_empty());
}
}