use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use crate::config::TargetConfig;
pub const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(30);
const POLL_INTERVAL: Duration = Duration::from_millis(50);
const KILL_REAP_GRACE: Duration = Duration::from_millis(500);
fn wait_with_timeout(
mut child: Child,
timeout: Duration,
descriptor: &str,
) -> Result<std::process::Output> {
let started = Instant::now();
loop {
match child.try_wait().context("Failed to poll ffprobe child")? {
Some(_status) => {
return child
.wait_with_output()
.context("Failed to collect ffprobe output");
}
None => {
if started.elapsed() >= timeout {
let _ = child.kill();
let reap_deadline = Instant::now() + KILL_REAP_GRACE;
while Instant::now() < reap_deadline {
if matches!(child.try_wait(), Ok(Some(_))) {
break;
}
std::thread::sleep(POLL_INTERVAL);
}
bail!(
"ffprobe timed out after {} reading {}; \
the source filesystem may be unresponsive",
format_timeout(timeout),
descriptor
);
}
std::thread::sleep(POLL_INTERVAL);
}
}
}
}
fn format_timeout(d: Duration) -> String {
if d.as_secs() >= 1 && d.subsec_millis() == 0 {
format!("{}s", d.as_secs())
} else if d.as_secs() == 0 {
format!("{}ms", d.as_millis())
} else {
format!("{:.3}s", d.as_secs_f64())
}
}
#[derive(Debug, Clone, Default)]
#[allow(dead_code)]
pub struct MediaInfo {
pub codec: String,
pub width: u32,
pub height: u32,
pub bitrate_kbps: u32,
pub duration_secs: f64,
pub pix_fmt: String,
pub has_audio: bool,
pub has_subtitles: bool,
pub color_primaries: Option<String>,
pub color_transfer: Option<String>,
pub color_space: Option<String>,
pub color_range: Option<String>,
pub master_display: Option<String>,
pub max_cll: Option<String>,
}
#[derive(Deserialize)]
struct FfprobeOutput {
streams: Vec<FfprobeStream>,
#[serde(default)]
format: Option<FfprobeFormat>,
}
#[derive(Deserialize)]
struct FfprobeStream {
codec_name: Option<String>,
codec_type: Option<String>,
width: Option<u32>,
height: Option<u32>,
pix_fmt: Option<String>,
color_primaries: Option<String>,
color_transfer: Option<String>,
color_space: Option<String>,
color_range: Option<String>,
#[serde(default)]
side_data_list: Option<Vec<FfprobeSideData>>,
#[serde(default)]
tags: Option<FfprobeTags>,
}
#[derive(Deserialize)]
struct FfprobeTags {
#[serde(rename = "BPS")]
bps: Option<String>,
}
#[derive(Deserialize)]
struct FfprobeFormat {
bit_rate: Option<String>,
duration: Option<String>,
}
#[derive(Deserialize, Default)]
struct FfprobeSideData {
side_data_type: Option<String>,
red_x: Option<String>,
red_y: Option<String>,
green_x: Option<String>,
green_y: Option<String>,
blue_x: Option<String>,
blue_y: Option<String>,
white_point_x: Option<String>,
white_point_y: Option<String>,
min_luminance: Option<String>,
max_luminance: Option<String>,
max_content: Option<u32>,
max_average: Option<u32>,
}
#[derive(Deserialize)]
struct FfprobeFramesOutput {
#[serde(default)]
frames: Vec<FfprobeFrame>,
}
#[derive(Deserialize)]
struct FfprobeFrame {
#[serde(default)]
side_data_list: Option<Vec<FfprobeSideData>>,
}
pub fn probe_file(path: &Path) -> Result<MediaInfo> {
probe_file_with_timeout(path, DEFAULT_PROBE_TIMEOUT)
}
pub fn probe_file_with_timeout(path: &Path, timeout: Duration) -> Result<MediaInfo> {
let child = Command::new("ffprobe")
.args([
"-v",
"error",
"-print_format",
"json",
"-show_streams",
"-show_format",
])
.arg(path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("Failed to spawn ffprobe")?;
let descriptor = path.display().to_string();
let output = wait_with_timeout(child, timeout, &descriptor)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let err_msg = stderr
.lines()
.rfind(|l| !l.trim().is_empty())
.unwrap_or("unknown error");
bail!("ffprobe failed for {:?}: {}", path, err_msg);
}
let mut info = parse_ffprobe_json(&output.stdout)?;
if needs_frame_side_data_probe(&info) {
if let Ok(frame_out) = probe_first_frame_side_data(path) {
apply_frame_side_data(&mut info, &frame_out);
}
}
Ok(info)
}
fn needs_frame_side_data_probe(info: &MediaInfo) -> bool {
if info.master_display.is_some() && info.max_cll.is_some() {
return false;
}
let trc = info.color_transfer.as_deref().unwrap_or("");
let prim = info.color_primaries.as_deref().unwrap_or("");
let looks_hdr =
matches!(trc, "smpte2084" | "arib-std-b67") || prim.eq_ignore_ascii_case("bt2020");
let no_color_info = info.color_transfer.is_none() && info.color_primaries.is_none();
looks_hdr || no_color_info
}
#[allow(dead_code)]
pub fn probe_iso_file(iso_path: &Path, inner_path: &str) -> Result<MediaInfo> {
probe_iso_file_with_timeout(iso_path, inner_path, DEFAULT_PROBE_TIMEOUT)
}
pub fn probe_iso_file_with_timeout(
iso_path: &Path,
inner_path: &str,
timeout: Duration,
) -> Result<MediaInfo> {
let mut child = Command::new("ffprobe")
.args([
"-v",
"error",
"-print_format",
"json",
"-show_streams",
"-show_format",
"-i",
"pipe:0",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("Failed to spawn ffprobe")?;
let mut stdin = child.stdin.take().unwrap();
let iso = iso_path.to_path_buf();
let inner = inner_path.to_string();
let writer_handle = std::thread::spawn(move || {
let _ = crate::iso::cat_file(&iso, &inner, &mut stdin);
});
let descriptor = format!("{}:{}", iso_path.display(), inner_path);
let output = wait_with_timeout(child, timeout, &descriptor)?;
let _ = writer_handle.join();
if !output.status.success() {
bail!(
"ffprobe failed for {}:{}: {}",
iso_path.display(),
inner_path,
String::from_utf8_lossy(&output.stderr)
);
}
parse_ffprobe_json(&output.stdout)
}
#[cfg(test)]
fn run_with_timeout(
program: &str,
args: &[&str],
timeout: Duration,
descriptor: &str,
) -> Result<std::process::Output> {
let child = Command::new(program)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("Failed to spawn {}", program))?;
wait_with_timeout(child, timeout, descriptor)
}
fn probe_first_frame_side_data(path: &Path) -> Result<FfprobeFramesOutput> {
let output = Command::new("ffprobe")
.args([
"-v",
"error",
"-print_format",
"json",
"-select_streams",
"v:0",
"-show_frames",
"-read_intervals",
"%+#1",
])
.arg(path)
.output()
.context("Failed to run ffprobe (frames)")?;
if !output.status.success() {
bail!(
"ffprobe (frames) failed for {:?}: {}",
path,
String::from_utf8_lossy(&output.stderr)
);
}
parse_frames_json(&output.stdout)
}
fn parse_frames_json(json: &[u8]) -> Result<FfprobeFramesOutput> {
serde_json::from_slice(json).context("Failed to parse ffprobe frames JSON")
}
fn apply_frame_side_data(info: &mut MediaInfo, frames: &FfprobeFramesOutput) {
let Some(frame) = frames.frames.first() else {
return;
};
let Some(side_data) = frame.side_data_list.as_ref() else {
return;
};
for sd in side_data {
match sd.side_data_type.as_deref() {
Some("Mastering display metadata") if info.master_display.is_none() => {
if let Some(s) = format_master_display(sd) {
info.master_display = Some(s);
}
}
Some("Content light level metadata") if info.max_cll.is_none() => {
if let Some(s) = format_max_cll(sd) {
info.max_cll = Some(s);
}
}
_ => {}
}
}
}
fn parse_rational(s: &str) -> Option<(i64, i64)> {
let (num, den) = s.split_once('/')?;
let num: i64 = num.trim().parse().ok()?;
let den: i64 = den.trim().parse().ok()?;
if den == 0 {
return None;
}
Some((num, den))
}
fn rational_to_50000(s: &str) -> Option<i64> {
let (num, den) = parse_rational(s)?;
Some(num.saturating_mul(50000) / den)
}
fn rational_to_10000(s: &str) -> Option<i64> {
let (num, den) = parse_rational(s)?;
Some(num.saturating_mul(10000) / den)
}
fn format_master_display(sd: &FfprobeSideData) -> Option<String> {
let gx = rational_to_50000(sd.green_x.as_deref()?)?;
let gy = rational_to_50000(sd.green_y.as_deref()?)?;
let bx = rational_to_50000(sd.blue_x.as_deref()?)?;
let by = rational_to_50000(sd.blue_y.as_deref()?)?;
let rx = rational_to_50000(sd.red_x.as_deref()?)?;
let ry = rational_to_50000(sd.red_y.as_deref()?)?;
let wpx = rational_to_50000(sd.white_point_x.as_deref()?)?;
let wpy = rational_to_50000(sd.white_point_y.as_deref()?)?;
let lmax = rational_to_10000(sd.max_luminance.as_deref()?)?;
let lmin = rational_to_10000(sd.min_luminance.as_deref()?)?;
Some(format!(
"G({},{})B({},{})R({},{})WP({},{})L({},{})",
gx, gy, bx, by, rx, ry, wpx, wpy, lmax, lmin
))
}
fn format_max_cll(sd: &FfprobeSideData) -> Option<String> {
let cll = sd.max_content?;
let fall = sd.max_average?;
Some(format!("{},{}", cll, fall))
}
fn parse_ffprobe_json(json: &[u8]) -> Result<MediaInfo> {
let probe: FfprobeOutput =
serde_json::from_slice(json).context("Failed to parse ffprobe JSON output")?;
let video_stream = probe
.streams
.iter()
.find(|s| s.codec_type.as_deref() == Some("video"))
.context("No video stream found")?;
let codec = video_stream
.codec_name
.clone()
.unwrap_or_else(|| "unknown".to_string());
let width = video_stream.width.unwrap_or(0);
let height = video_stream.height.unwrap_or(0);
let pix_fmt = video_stream
.pix_fmt
.clone()
.unwrap_or_else(|| "unknown".to_string());
let bitrate_kbps = video_stream
.tags
.as_ref()
.and_then(|t| t.bps.as_ref())
.and_then(|b| b.parse::<u64>().ok())
.map(|b| (b / 1000) as u32)
.or_else(|| {
probe
.format
.as_ref()
.and_then(|f| f.bit_rate.as_ref())
.and_then(|b| b.parse::<u64>().ok())
.map(|b| (b / 1000) as u32)
})
.unwrap_or(0);
let duration_secs = probe
.format
.as_ref()
.and_then(|f| f.duration.as_ref())
.and_then(|d| d.parse::<f64>().ok())
.unwrap_or(0.0);
let has_audio = probe
.streams
.iter()
.any(|s| s.codec_type.as_deref() == Some("audio"));
let has_subtitles = probe
.streams
.iter()
.any(|s| s.codec_type.as_deref() == Some("subtitle"));
let color_primaries = clean_color_tag(video_stream.color_primaries.as_deref());
let color_transfer = clean_color_tag(video_stream.color_transfer.as_deref());
let color_space = clean_color_tag(video_stream.color_space.as_deref());
let color_range = clean_color_tag(video_stream.color_range.as_deref());
let mut master_display: Option<String> = None;
let mut max_cll: Option<String> = None;
if let Some(side_data) = video_stream.side_data_list.as_ref() {
for sd in side_data {
match sd.side_data_type.as_deref() {
Some("Mastering display metadata") if master_display.is_none() => {
master_display = format_master_display(sd);
}
Some("Content light level metadata") if max_cll.is_none() => {
max_cll = format_max_cll(sd);
}
_ => {}
}
}
}
Ok(MediaInfo {
codec,
width,
height,
bitrate_kbps,
duration_secs,
pix_fmt,
has_audio,
has_subtitles,
color_primaries,
color_transfer,
color_space,
color_range,
master_display,
max_cll,
})
}
fn clean_color_tag(s: Option<&str>) -> Option<String> {
let s = s?.trim();
if s.is_empty() || s.eq_ignore_ascii_case("unknown") || s.eq_ignore_ascii_case("unspecified") {
return None;
}
Some(s.to_string())
}
pub fn meets_target(info: &MediaInfo, target: &TargetConfig) -> bool {
let is_hevc = matches!(info.codec.as_str(), "hevc" | "h265");
if !is_hevc {
return false;
}
if info.width > target.max_width || info.height > target.max_height {
return false;
}
if target.max_bitrate_kbps > 0 && info.bitrate_kbps > target.max_bitrate_kbps {
return false;
}
true
}
pub fn is_10bit(pix_fmt: &str) -> bool {
pix_fmt.contains("10le")
|| pix_fmt.contains("10be")
|| pix_fmt.contains("12le")
|| pix_fmt.contains("12be")
|| pix_fmt == "p010"
|| pix_fmt == "p010le"
|| pix_fmt == "p010be"
}
#[cfg(test)]
mod tests {
use super::*;
fn make_target() -> TargetConfig {
TargetConfig {
codec: "hevc".to_string(),
quality: 28,
preset: "slow".to_string(),
max_width: 3840,
max_height: 2160,
max_bitrate_kbps: 0,
container: "mkv".to_string(),
audio_codec: "copy".to_string(),
subtitle_codec: "copy".to_string(),
}
}
fn make_info(codec: &str, width: u32, height: u32, bitrate_kbps: u32) -> MediaInfo {
MediaInfo {
codec: codec.to_string(),
width,
height,
bitrate_kbps,
duration_secs: 420.0,
pix_fmt: "yuv420p".to_string(),
has_audio: true,
has_subtitles: false,
..MediaInfo::default()
}
}
#[test]
fn test_meets_target_hevc_within_bounds() {
assert!(meets_target(
&make_info("hevc", 1280, 720, 800),
&make_target()
));
}
#[test]
fn test_fails_target_not_hevc() {
assert!(!meets_target(
&make_info("h264", 1280, 720, 800),
&make_target()
));
}
#[test]
fn test_fails_target_too_large() {
assert!(!meets_target(
&make_info("hevc", 7680, 4320, 800),
&make_target()
));
}
#[test]
fn test_fails_target_bitrate_too_high() {
let mut target = make_target();
target.max_bitrate_kbps = 500;
assert!(!meets_target(&make_info("hevc", 1280, 720, 800), &target));
}
#[test]
fn test_meets_target_bitrate_within_limit() {
let mut target = make_target();
target.max_bitrate_kbps = 1000;
assert!(meets_target(&make_info("hevc", 1280, 720, 800), &target));
}
#[test]
fn test_is_10bit() {
assert!(is_10bit("yuv420p10le"));
assert!(is_10bit("p010le"));
assert!(is_10bit("yuv444p10be"));
assert!(!is_10bit("yuv420p"));
assert!(!is_10bit("nv12"));
}
#[test]
fn test_timeout_fires_on_slow_command() {
let started = Instant::now();
let result = run_with_timeout(
"sleep",
&["60"],
Duration::from_secs(1),
"/fake/path/that/hangs",
);
let elapsed = started.elapsed();
assert!(result.is_err(), "expected timeout error, got {:?}", result);
let err = result.unwrap_err().to_string();
assert!(
err.contains("timed out after 1s"),
"error should mention timeout duration: {}",
err
);
assert!(
err.contains("/fake/path/that/hangs"),
"error should include the descriptor path: {}",
err
);
assert!(
err.contains("filesystem may be unresponsive"),
"error should hint at the likely cause: {}",
err
);
assert!(
elapsed < Duration::from_secs(5),
"watchdog took {:?}, expected < 5s",
elapsed
);
}
#[test]
fn test_timeout_does_not_fire_on_fast_command() {
let result = run_with_timeout("echo", &["{}"], Duration::from_secs(5), "fast-command");
let output = result.expect("fast command should succeed");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("{}"),
"echo output missing payload: {}",
stdout
);
}
#[test]
fn test_timeout_kills_child_on_expiry() {
let started = Instant::now();
let result = run_with_timeout("sleep", &["30"], Duration::from_millis(200), "kill-test");
let elapsed = started.elapsed();
assert!(result.is_err(), "expected timeout error, got: {:?}", result);
assert!(
elapsed < Duration::from_secs(2),
"kill path took too long: {:?}",
elapsed
);
}
#[test]
fn test_timeout_message_renders_subsecond_durations() {
let result = run_with_timeout("sleep", &["10"], Duration::from_millis(150), "subsec-test");
let err = result.expect_err("expected timeout").to_string();
assert!(
err.contains("150ms"),
"expected '150ms' in timeout error, got: {}",
err
);
}
#[test]
fn test_clean_color_tag_drops_unknown() {
assert_eq!(clean_color_tag(Some("bt2020")), Some("bt2020".to_string()));
assert_eq!(clean_color_tag(Some("unknown")), None);
assert_eq!(clean_color_tag(Some("UNKNOWN")), None);
assert_eq!(clean_color_tag(Some("unspecified")), None);
assert_eq!(clean_color_tag(Some("")), None);
assert_eq!(clean_color_tag(Some(" ")), None);
assert_eq!(clean_color_tag(None), None);
}
#[test]
fn test_parse_color_tags_from_stream() {
let json = br#"{
"streams": [{
"codec_name": "hevc",
"codec_type": "video",
"width": 3840,
"height": 2160,
"pix_fmt": "yuv420p10le",
"color_primaries": "bt2020",
"color_transfer": "smpte2084",
"color_space": "bt2020nc",
"color_range": "tv"
}]
}"#;
let info = parse_ffprobe_json(json).unwrap();
assert_eq!(info.color_primaries.as_deref(), Some("bt2020"));
assert_eq!(info.color_transfer.as_deref(), Some("smpte2084"));
assert_eq!(info.color_space.as_deref(), Some("bt2020nc"));
assert_eq!(info.color_range.as_deref(), Some("tv"));
}
#[test]
fn test_parse_unknown_color_tags_become_none() {
let json = br#"{
"streams": [{
"codec_name": "h264",
"codec_type": "video",
"width": 1920,
"height": 1080,
"pix_fmt": "yuv420p",
"color_primaries": "unknown",
"color_transfer": "unknown",
"color_space": "unknown",
"color_range": "unknown"
}]
}"#;
let info = parse_ffprobe_json(json).unwrap();
assert_eq!(info.color_primaries, None);
assert_eq!(info.color_transfer, None);
assert_eq!(info.color_space, None);
assert_eq!(info.color_range, None);
}
#[test]
fn test_master_display_format_from_stream_side_data() {
let json = br#"{
"streams": [{
"codec_name": "hevc",
"codec_type": "video",
"width": 3840,
"height": 2160,
"pix_fmt": "yuv420p10le",
"color_primaries": "bt2020",
"color_transfer": "smpte2084",
"color_space": "bt2020nc",
"side_data_list": [{
"side_data_type": "Mastering display metadata",
"red_x": "35400/50000",
"red_y": "14600/50000",
"green_x": "8500/50000",
"green_y": "39850/50000",
"blue_x": "6550/50000",
"blue_y": "2300/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
}]
}]
}"#;
let info = parse_ffprobe_json(json).unwrap();
assert_eq!(
info.master_display.as_deref(),
Some("G(8500,39850)B(6550,2300)R(35400,14600)WP(15635,16450)L(10000000,50)")
);
assert_eq!(info.max_cll.as_deref(), Some("1000,400"));
}
#[test]
fn test_master_display_format_from_frame_side_data() {
let frames_json = br#"{
"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": "1/10000",
"max_luminance": "40000000/10000"
}, {
"side_data_type": "Content light level metadata",
"max_content": 4000,
"max_average": 1000
}]
}]
}"#;
let frames = parse_frames_json(frames_json).unwrap();
let mut info = MediaInfo::default();
apply_frame_side_data(&mut info, &frames);
assert_eq!(
info.master_display.as_deref(),
Some("G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(40000000,1)")
);
assert_eq!(info.max_cll.as_deref(), Some("4000,1000"));
}
#[test]
fn test_master_display_missing_field_returns_none() {
let frames_json = br#"{
"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",
"white_point_x": "15635/50000",
"white_point_y": "16450/50000",
"min_luminance": "1/10000",
"max_luminance": "40000000/10000"
}]
}]
}"#;
let frames = parse_frames_json(frames_json).unwrap();
let mut info = MediaInfo::default();
apply_frame_side_data(&mut info, &frames);
assert_eq!(info.master_display, None);
}
#[test]
fn test_max_cll_missing_field_returns_none() {
let frames_json = br#"{
"frames": [{
"side_data_list": [{
"side_data_type": "Content light level metadata",
"max_content": 1000
}]
}]
}"#;
let frames = parse_frames_json(frames_json).unwrap();
let mut info = MediaInfo::default();
apply_frame_side_data(&mut info, &frames);
assert_eq!(info.max_cll, None);
}
#[test]
fn test_apply_frame_side_data_does_not_overwrite_stream_header() {
let frames_json = br#"{
"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": "1/10000",
"max_luminance": "40000000/10000"
}]
}]
}"#;
let frames = parse_frames_json(frames_json).unwrap();
let mut info = MediaInfo::default();
info.master_display = Some("STREAM-HEADER-VALUE".to_string());
apply_frame_side_data(&mut info, &frames);
assert_eq!(info.master_display.as_deref(), Some("STREAM-HEADER-VALUE"));
}
#[test]
fn test_needs_frame_side_data_probe_skips_complete_sdr() {
let mut info = MediaInfo::default();
info.color_transfer = Some("bt709".to_string());
info.color_primaries = Some("bt709".to_string());
info.master_display = Some("x".to_string());
info.max_cll = Some("y".to_string());
assert!(!needs_frame_side_data_probe(&info));
}
#[test]
fn test_needs_frame_side_data_probe_skips_sdr_missing_hdr_fields() {
let mut info = MediaInfo::default();
info.color_transfer = Some("bt709".to_string());
info.color_primaries = Some("bt709".to_string());
assert!(!needs_frame_side_data_probe(&info));
}
#[test]
fn test_needs_frame_side_data_probe_runs_on_pq_hdr_missing_fields() {
let mut info = MediaInfo::default();
info.color_transfer = Some("smpte2084".to_string());
info.color_primaries = Some("bt2020".to_string());
assert!(needs_frame_side_data_probe(&info));
}
#[test]
fn test_needs_frame_side_data_probe_runs_when_color_tags_unknown() {
let info = MediaInfo::default();
assert!(needs_frame_side_data_probe(&info));
}
#[test]
fn test_needs_frame_side_data_probe_skips_pq_when_already_populated() {
let mut info = MediaInfo::default();
info.color_transfer = Some("smpte2084".to_string());
info.master_display = Some("x".to_string());
info.max_cll = Some("y".to_string());
assert!(!needs_frame_side_data_probe(&info));
}
#[test]
fn test_rational_helpers() {
assert_eq!(rational_to_50000("13250/50000"), Some(13250));
assert_eq!(rational_to_50000("1/2"), Some(25000));
assert_eq!(rational_to_50000("garbage"), None);
assert_eq!(rational_to_50000("1/0"), None);
assert_eq!(rational_to_10000("40000000/10000"), Some(40000000));
assert_eq!(rational_to_10000("1/10000"), Some(1));
}
}