use std::path::Path;
use std::process::Command;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct FrameStats {
pub black: f64,
pub low: f64,
pub saturation: f64,
}
impl FrameStats {
pub fn looks_log(self) -> bool {
self.black > 0.11 && self.saturation < 0.025
}
}
pub fn frame_stats(path: &Path, at: f64, bit_depth: u8) -> Option<FrameStats> {
let out = Command::new("ffmpeg")
.args(["-hide_banner", "-v", "error", "-nostdin", "-ss"])
.arg(format!("{at:.2}"))
.arg("-i")
.arg(path)
.args([
"-frames:v",
"1",
"-an",
"-vf",
"scale=480:-2,signalstats,metadata=print:file=-",
"-f",
"null",
"-",
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
parse(&String::from_utf8_lossy(&out.stdout), bit_depth)
}
fn parse(text: &str, bit_depth: u8) -> Option<FrameStats> {
let get = |k: &str| -> Option<f64> {
text.lines().find_map(|l| {
l.split_once(&format!("signalstats.{k}="))?
.1
.trim()
.parse()
.ok()
})
};
let full = f64::from((1u32 << bit_depth.clamp(8, 16)) - 1);
Some(FrameStats {
black: get("YMIN")? / full,
low: get("YLOW")? / full,
saturation: get("SATAVG")? / full,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_signalstats() {
let dlog = "frame:0\nlavfi.signalstats.YMIN=155\nlavfi.signalstats.YLOW=251\nlavfi.signalstats.SATAVG=3.72\n";
let s = parse(dlog, 10).unwrap();
assert!(s.looks_log());
let normal = "lavfi.signalstats.YMIN=64\nlavfi.signalstats.YLOW=111\nlavfi.signalstats.SATAVG=59.2\n";
assert!(!parse(normal, 10).unwrap().looks_log());
assert!(parse("", 10).is_none());
}
}