filmcraft/crates/hevc/tests/common/mod.rs

325 lines
16 KiB
Rust

//! Test fixture generation (ffmpeg + libx265 / VideoToolbox as external oracle and encoder) and
//! comparison helpers.
#![allow(dead_code)]
use filmcraft_hevc::{Decoder, Picture};
use std::path::{Path, PathBuf};
use std::process::Command;
/// One encoder configuration of the fixture matrix.
pub struct Fixture {
pub name: &'static str,
/// lavfi source (without size / rate).
pub source: &'static str,
pub width: u32,
pub height: u32,
pub frames: u32,
/// Extra video filter appended after the source (e.g. noise, fade).
pub filter: &'static str,
/// 10 (yuv420p10le) or 8.
pub bit_depth: u32,
/// Encoder arguments (libx265 unless `-c:v` is given).
pub args: &'static [&'static str],
}
const NOISE: &str = "noise=alls=12:allf=t+u";
macro_rules! fx {
($name:expr, $src:expr, $w:expr, $h:expr, $n:expr, $filter:expr, $bd:expr, [$($a:expr),* $(,)?]) => {
Fixture { name: $name, source: $src, width: $w, height: $h, frames: $n, filter: $filter, bit_depth: $bd, args: &[$($a),*] }
};
}
pub const FIXTURES: &[Fixture] = &[
// intra only
fx!("intra_main", "testsrc2", 352, 288, 3, NOISE, 8, ["-x265-params", "keyint=1:log-level=error"]),
fx!("intra_main10", "testsrc2", 352, 288, 3, NOISE, 10, ["-x265-params", "keyint=1:log-level=error"]),
fx!("intra_nosao_nodbk", "testsrc2", 352, 288, 2, NOISE, 8, ["-x265-params", "keyint=1:no-sao=1:no-deblock=1:log-level=error"]),
// presets (WPP is on by default in x265)
fx!("ultrafast", "testsrc2", 352, 288, 20, NOISE, 8, ["-preset", "ultrafast", "-x265-params", "log-level=error"]),
fx!("veryfast", "testsrc2", 352, 288, 20, NOISE, 8, ["-preset", "veryfast", "-x265-params", "log-level=error"]),
fx!("medium", "testsrc2", 352, 288, 20, NOISE, 8, ["-preset", "medium", "-x265-params", "log-level=error"]),
fx!("slow", "mandelbrot", 352, 288, 16, NOISE, 8, ["-preset", "slow", "-x265-params", "log-level=error"]),
fx!("medium_main10", "testsrc2", 352, 288, 20, NOISE, 10, ["-preset", "medium", "-x265-params", "log-level=error"]),
fx!("slow_main10", "mandelbrot", 352, 288, 12, NOISE, 10, ["-preset", "slow", "-x265-params", "log-level=error"]),
// coding tools
fx!("p_only", "testsrc2", 352, 288, 20, NOISE, 8, ["-x265-params", "bframes=0:log-level=error"]),
fx!("bframes", "testsrc2", 352, 288, 30, NOISE, 8, ["-x265-params", "bframes=8:b-pyramid=1:ref=4:log-level=error"]),
fx!("no_sao", "testsrc2", 352, 288, 16, NOISE, 8, ["-x265-params", "no-sao=1:log-level=error"]),
fx!("no_deblock", "testsrc2", 352, 288, 16, NOISE, 8, ["-x265-params", "no-deblock=1:log-level=error"]),
fx!("deblock_m3_3", "testsrc2", 352, 288, 12, NOISE, 8, ["-x265-params", "deblock=-3,3:log-level=error"]),
fx!("no_wpp", "testsrc2", 352, 288, 12, NOISE, 8, ["-x265-params", "no-wpp=1:log-level=error"]),
fx!("rect_amp", "mandelbrot", 352, 288, 16, NOISE, 8, ["-x265-params", "rect=1:amp=1:log-level=error"]),
fx!("tskip", "testsrc2", 352, 288, 12, NOISE, 8, ["-x265-params", "tskip=1:log-level=error"]),
fx!("weightp", "testsrc2", 352, 288, 30, "fade=in:0:25", 8, ["-x265-params", "weightp=1:weightb=1:bframes=3:log-level=error"]),
fx!("weightp_main10", "testsrc2", 352, 288, 20, "fade=in:0:18", 10, ["-x265-params", "weightp=1:weightb=1:log-level=error"]),
fx!("crf0", "mandelbrot", 176, 144, 6, NOISE, 8, ["-crf", "0", "-x265-params", "log-level=error"]),
fx!("crf51", "testsrc2", 352, 288, 12, NOISE, 8, ["-crf", "51", "-x265-params", "log-level=error"]),
fx!("qp1_main10", "mandelbrot", 176, 144, 4, NOISE, 10, ["-x265-params", "qp=1:log-level=error"]),
fx!("lossless", "testsrc2", 176, 144, 6, NOISE, 8, ["-x265-params", "lossless=1:log-level=error"]),
fx!("cu_lossless", "testsrc2", 176, 144, 6, NOISE, 8, ["-x265-params", "cu-lossless=1:log-level=error"]),
fx!("slices4", "testsrc2", 352, 288, 12, NOISE, 8, ["-x265-params", "slices=4:log-level=error"]),
fx!("scaling_list", "testsrc2", 352, 288, 12, NOISE, 8, ["-x265-params", "scaling-list=default:log-level=error"]),
fx!("ctu16", "testsrc2", 352, 288, 12, NOISE, 8, ["-x265-params", "ctu=16:log-level=error"]),
fx!("ctu32_tu4", "testsrc2", 352, 288, 12, NOISE, 8, ["-x265-params", "ctu=32:tu-intra-depth=4:tu-inter-depth=4:max-tu-size=16:log-level=error"]),
fx!("min_cu16", "testsrc2", 352, 288, 12, NOISE, 8, ["-x265-params", "min-cu-size=16:rect=1:amp=1:log-level=error"]),
fx!("no_signhide", "testsrc2", 352, 288, 12, NOISE, 8, ["-x265-params", "no-signhide=1:log-level=error"]),
fx!("constrained_intra", "testsrc2", 352, 288, 12, NOISE, 8, ["-x265-params", "constrained-intra=1:log-level=error"]),
fx!("no_tmvp", "testsrc2", 352, 288, 12, NOISE, 8, ["-x265-params", "no-temporal-mvp=1:log-level=error"]),
fx!("no_strong_intra", "testsrc2", 352, 288, 6, NOISE, 8, ["-x265-params", "no-strong-intra-smoothing=1:keyint=1:log-level=error"]),
fx!("max_merge1", "testsrc2", 352, 288, 12, NOISE, 8, ["-x265-params", "max-merge=1:log-level=error"]),
fx!("open_gop", "testsrc2", 352, 288, 40, NOISE, 8, ["-x265-params", "keyint=12:min-keyint=12:open-gop=1:bframes=3:log-level=error"]),
fx!("keyint5_idr", "testsrc2", 352, 288, 20, NOISE, 8, ["-x265-params", "keyint=5:no-open-gop=1:log-level=error"]),
fx!("odd_1918x1078", "testsrc2", 1918, 1078, 3, NOISE, 8, ["-preset", "veryfast", "-x265-params", "log-level=error"]),
fx!("hd_1080p", "testsrc2", 1920, 1080, 3, "", 8, ["-preset", "fast", "-x265-params", "log-level=error"]),
fx!("uhd_4k_main10", "testsrc2", 3840, 2160, 2, "", 10, ["-preset", "ultrafast", "-x265-params", "log-level=error"]),
// performance reference streams (tests/perf.rs)
fx!("bench_1080p", "testsrc2", 1920, 1080, 60, "noise=alls=3:allf=t", 8, ["-preset", "medium", "-crf", "24", "-x265-params", "log-level=error"]),
fx!("bench_4k", "testsrc2", 3840, 2160, 20, "noise=alls=3:allf=t", 10, ["-preset", "fast", "-crf", "26", "-x265-params", "log-level=error"]),
// Apple VideoToolbox (hardware encoder, macOS only)
fx!("vt_main", "testsrc2", 640, 360, 20, NOISE, 8, ["-c:v", "hevc_videotoolbox", "-b:v", "2M"]),
fx!("vt_main10", "mandelbrot", 640, 360, 20, NOISE, 10, ["-c:v", "hevc_videotoolbox", "-profile:v", "main10", "-b:v", "2M"]),
];
pub fn fixture(name: &str) -> &'static Fixture {
FIXTURES.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("unknown fixture {name}"))
}
pub fn ffmpeg() -> Option<PathBuf> {
for p in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"] {
if Path::new(p).exists() {
return Some(PathBuf::from(p));
}
}
None
}
pub fn fixtures_dir() -> PathBuf {
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/hevc");
std::fs::create_dir_all(&d).expect("create fixtures dir");
d
}
fn run(cmd: &mut Command) -> bool {
match cmd.output() {
Ok(o) if o.status.success() => true,
Ok(o) => {
eprintln!("command failed: {:?}\n{}", cmd, String::from_utf8_lossy(&o.stderr));
false
}
Err(e) => {
eprintln!("failed to run {:?}: {e}", cmd);
false
}
}
}
pub fn pix_fmt(bd: u32) -> &'static str {
if bd > 8 { "yuv420p10le" } else { "yuv420p" }
}
/// Generate (if needed) the fixture stream and its ffmpeg reference decode.
/// Returns None (with a message) when ffmpeg or the encoder is unavailable.
pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> {
let Some(ff) = ffmpeg() else {
eprintln!("SKIP {}: ffmpeg not found", f.name);
return None;
};
let dir = fixtures_dir();
let hevc = dir.join(format!("{}.hevc", f.name));
let yuv = dir.join(format!("{}.yuv", f.name));
if !hevc.exists() {
let tmp = dir.join(format!("{}.tmp.{}.hevc", f.name, unique()));
let mut vf = format!("{}=size={}x{}:rate=25,format={}", f.source, f.width, f.height, pix_fmt(f.bit_depth));
if !f.filter.is_empty() {
vf.push(',');
vf.push_str(f.filter);
}
let mut c = Command::new(&ff);
c.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", &vf]);
c.args(["-frames:v", &f.frames.to_string()]);
if !f.args.contains(&"-c:v") {
c.args(["-c:v", "libx265"]);
}
c.args(["-pix_fmt", pix_fmt(f.bit_depth)]);
c.args(f.args);
c.args(["-f", "hevc"]).arg(&tmp);
if !run(&mut c) {
// Platform encoders (VideoToolbox) are optional; libx265 fixtures must generate.
assert!(f.args.contains(&"-c:v"), "fixture generation failed for {}", f.name);
let _ = std::fs::remove_file(&tmp);
eprintln!("SKIP {}: encoder unavailable", f.name);
return None;
}
std::fs::rename(&tmp, &hevc).unwrap();
}
if !yuv.exists() {
let tmp = dir.join(format!("{}.tmp.{}.yuv", f.name, unique()));
let mut c = Command::new(&ff);
c.args(["-hide_banner", "-loglevel", "error", "-y", "-i"]).arg(&hevc);
c.args(["-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", pix_fmt(f.bit_depth)]).arg(&tmp);
assert!(run(&mut c), "reference decode failed for {}", f.name);
std::fs::rename(&tmp, &yuv).unwrap();
}
Some((hevc, yuv))
}
fn is_vcl(t: u8) -> bool {
t < 32
}
/// Split an Annex-B stream into access units: a new AU starts at an AUD / VPS / SPS / PPS / prefix SEI
/// NAL after a VCL NAL, or at a VCL NAL with first_slice_segment_in_pic_flag = 1.
pub fn split_access_units(data: &[u8]) -> Vec<&[u8]> {
let mut starts = Vec::new();
let mut i = 0;
while i + 3 <= data.len() {
if data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
let s = if i > 0 && data[i - 1] == 0 { i - 1 } else { i };
starts.push((s, i + 3));
i += 3;
} else {
i += 1;
}
}
let mut aus = Vec::new();
let mut au_start = 0usize;
let mut seen_vcl = false;
for &(sc, payload) in &starts {
if payload + 2 >= data.len() {
continue;
}
let t = (data[payload] >> 1) & 0x3f;
let first_slice = is_vcl(t) && data[payload + 2] & 0x80 != 0;
let boundary = seen_vcl && (matches!(t, 32..=35 | 39) || first_slice);
if boundary {
aus.push(&data[au_start..sc]);
au_start = sc;
seen_vcl = false;
}
if is_vcl(t) {
seen_vcl = true;
}
}
if au_start < data.len() {
aus.push(&data[au_start..]);
}
aus
}
/// Failed decode: (access unit index, error, pictures output before the error).
pub type DecodeFailure = (usize, filmcraft_hevc::Error, Vec<Picture>);
/// Decode with `threads` worker threads (0 = decoder default), pts = access unit index.
pub fn decode_file_threads(path: &Path, threads: usize) -> Result<Vec<Picture>, DecodeFailure> {
let data = std::fs::read(path).unwrap();
let mut dec = if threads == 0 { Decoder::new() } else { Decoder::with_threads(threads) };
let mut out = Vec::new();
for (i, au) in split_access_units(&data).into_iter().enumerate() {
match dec.decode(au, i as i64) {
Ok(p) => out.extend(p),
Err(e) => return Err((i, e, out)),
}
}
out.extend(dec.flush());
if let Some(e) = dec.take_error() {
return Err((usize::MAX, e, out));
}
Ok(out)
}
/// Compare decoded pictures with a raw yuv420p / yuv420p10le reference; returns a diagnostic on the
/// first mismatch (frame, plane, sample position, CTB (64x64) and 8x8 block).
pub fn compare(pics: &[Picture], reference: &[u8], w: usize, h: usize, bd: u32) -> Result<(), String> {
let bps = if bd > 8 { 2 } else { 1 };
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let fsize = (w * h + 2 * cw * ch) * bps;
let nref = reference.len() / fsize;
for (i, p) in pics.iter().enumerate() {
if i >= nref {
return Err(format!("decoder produced {} frames, reference has {}", pics.len(), nref));
}
if p.width as usize != w || p.height as usize != h {
return Err(format!("frame {i}: size {}x{} != {}x{}", p.width, p.height, w, h));
}
let f = &reference[i * fsize..(i + 1) * fsize];
let sample = |buf: &[u8], k: usize| if bps == 2 { u16::from_le_bytes([buf[2 * k], buf[2 * k + 1]]) } else { buf[k] as u16 };
let (ys, cs) = (w * h * bps, cw * ch * bps);
let planes = [("Y", &p.y, &f[..ys], w, h, 1), ("U", &p.u, &f[ys..ys + cs], cw, ch, 2), ("V", &p.v, &f[ys + cs..], cw, ch, 2)];
for (name, got, exp, pw, ph, sub) in planes {
let mut first = None;
let mut count = 0usize;
let mut maxd = 0i32;
for y in 0..ph {
for x in 0..pw {
let (a, b) = (got.get(y * pw + x), sample(exp, y * pw + x));
if a != b {
count += 1;
maxd = maxd.max((a as i32 - b as i32).abs());
if first.is_none() {
first = Some((x, y, a, b));
}
}
}
}
if let Some((x, y, a, b)) = first {
let (lx, ly) = (x * sub, y * sub);
return Err(format!(
"frame {i} (poc {}, pts {}) plane {name}: first mismatch at ({x},{y}) [luma ({lx},{ly}), CTB64 ({},{}), 8x8 ({},{})]: got {a} want {b}; {count} samples differ, max diff {maxd}",
p.poc,
p.pts,
lx / 64,
ly / 64,
lx / 8,
ly / 8
));
}
}
}
if pics.len() != nref {
return Err(format!("decoder produced {} frames, reference has {}", pics.len(), nref));
}
Ok(())
}
/// Full check of one fixture: generate, decode (single- and multi-threaded), compare. Ok(false) when skipped.
pub fn check_fixture(name: &str) -> Result<bool, String> {
let f = fixture(name);
let Some((hevc, yuv)) = ensure(f) else { return Ok(false) };
let reference = std::fs::read(&yuv).unwrap();
for threads in [1, 0] {
let pics = match decode_file_threads(&hevc, threads) {
Ok(p) => p,
Err((au, e, partial)) => {
let cmp = compare(&partial, &reference, f.width as usize, f.height as usize, f.bit_depth).err().unwrap_or_default();
return Err(format!("{name} (threads {threads}): decode error at access unit {au}: {e} (after {} pictures) {cmp}", partial.len()));
}
};
compare(&pics, &reference, f.width as usize, f.height as usize, f.bit_depth).map_err(|e| format!("{name} (threads {threads}): {e}"))?;
check_pts(&pics).map_err(|e| format!("{name} (threads {threads}): {e}"))?;
}
Ok(true)
}
/// Output pts values must be distinct and follow POC order between IRAP pictures.
pub fn check_pts(pics: &[Picture]) -> Result<(), String> {
let mut seen = std::collections::HashSet::new();
for p in pics {
if !seen.insert(p.pts) {
return Err(format!("duplicate pts {} in output", p.pts));
}
}
for w in pics.windows(2) {
if !w[1].key && w[1].poc <= w[0].poc {
return Err(format!("output POC order violated: {} then {}", w[0].poc, w[1].poc));
}
}
Ok(())
}
/// Unique temp-file suffix: process id plus a per-process counter (tests run on parallel threads).
fn unique() -> String {
static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
format!("{}-{}", std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
}