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

454 lines
15 KiB
Rust

//! Test fixture generation (ffmpeg/libx264 as an external oracle) and comparison helpers.
#![allow(dead_code)]
use filmcraft_h264::{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,
/// Encoder arguments.
pub args: &'static [&'static str],
}
const NOISE: &str = "noise=alls=12:allf=t+u";
pub const FIXTURES: &[Fixture] = &[
Fixture {
name: "intra_cavlc",
source: "testsrc2",
width: 176,
height: 144,
frames: 3,
filter: "",
args: &["-profile:v", "baseline", "-x264-params", "keyint=1:no-deblock=1"],
},
Fixture {
name: "intra_cavlc_noise",
source: "mandelbrot",
width: 352,
height: 288,
frames: 3,
filter: NOISE,
args: &["-profile:v", "baseline", "-x264-params", "keyint=1:no-deblock=1"],
},
Fixture {
name: "intra_cavlc_8x8",
source: "testsrc2",
width: 352,
height: 288,
frames: 3,
filter: NOISE,
args: &["-profile:v", "high", "-x264-params", "keyint=1:no-deblock=1:cabac=0"],
},
Fixture {
name: "intra_cavlc_cqm",
source: "testsrc2",
width: 352,
height: 288,
frames: 3,
filter: NOISE,
args: &["-profile:v", "high", "-x264-params", "keyint=1:no-deblock=1:cabac=0:cqm=jvt"],
},
Fixture {
name: "p_cavlc_nodeblock",
source: "testsrc2",
width: 352,
height: 288,
frames: 20,
filter: NOISE,
args: &["-profile:v", "baseline", "-x264-params", "no-deblock=1:ref=3"],
},
Fixture {
name: "baseline_qcif",
source: "testsrc2",
width: 176,
height: 144,
frames: 30,
filter: "",
args: &["-profile:v", "baseline", "-x264-params", "keyint=15"],
},
Fixture { name: "baseline_cif_noise", source: "mandelbrot", width: 352, height: 288, frames: 25, filter: NOISE, args: &["-profile:v", "baseline"] },
Fixture {
name: "cavlc_b",
source: "testsrc2",
width: 352,
height: 288,
frames: 30,
filter: NOISE,
args: &["-profile:v", "high", "-x264-params", "cabac=0:bframes=3"],
},
Fixture {
name: "cavlc_b_temporal",
source: "testsrc2",
width: 352,
height: 288,
frames: 30,
filter: NOISE,
args: &["-profile:v", "main", "-x264-params", "cabac=0:bframes=3:direct=temporal:weightb=1"],
},
Fixture {
name: "cavlc_weightp",
source: "testsrc2",
width: 352,
height: 288,
frames: 30,
filter: "fade=in:0:25",
args: &["-profile:v", "main", "-x264-params", "cabac=0:weightp=2:bframes=2:weightb=1"],
},
Fixture { name: "main_cabac_b", source: "testsrc2", width: 352, height: 288, frames: 30, filter: NOISE, args: &["-profile:v", "main"] },
Fixture { name: "high_720p", source: "smptehdbars", width: 1280, height: 720, frames: 10, filter: NOISE, args: &["-profile:v", "high"] },
Fixture { name: "high_1080p", source: "testsrc2", width: 1920, height: 1080, frames: 6, filter: "", args: &["-profile:v", "high"] },
Fixture { name: "crop_1918x1078", source: "testsrc2", width: 1918, height: 1078, frames: 5, filter: NOISE, args: &["-profile:v", "high"] },
Fixture {
name: "bpyramid",
source: "testsrc2",
width: 352,
height: 288,
frames: 30,
filter: NOISE,
args: &["-profile:v", "high", "-x264-params", "bframes=3:b-pyramid=normal"],
},
Fixture {
name: "weightp2",
source: "testsrc2",
width: 352,
height: 288,
frames: 30,
filter: "fade=in:0:25",
args: &["-profile:v", "high", "-x264-params", "weightp=2"],
},
Fixture {
name: "weightb",
source: "testsrc2",
width: 352,
height: 288,
frames: 30,
filter: "fade=in:0:25",
args: &["-profile:v", "high", "-x264-params", "weightb=1:bframes=3"],
},
Fixture {
name: "direct_temporal",
source: "testsrc2",
width: 352,
height: 288,
frames: 30,
filter: NOISE,
args: &["-profile:v", "high", "-x264-params", "direct=temporal:bframes=3"],
},
Fixture {
name: "direct_spatial",
source: "mandelbrot",
width: 352,
height: 288,
frames: 30,
filter: NOISE,
args: &["-profile:v", "high", "-x264-params", "direct=spatial:bframes=3"],
},
Fixture {
name: "ref4",
source: "testsrc2",
width: 352,
height: 288,
frames: 30,
filter: NOISE,
args: &["-profile:v", "high", "-x264-params", "ref=4:bframes=2"],
},
Fixture {
name: "no_deblock",
source: "testsrc2",
width: 352,
height: 288,
frames: 20,
filter: NOISE,
args: &["-profile:v", "high", "-x264-params", "no-deblock=1"],
},
Fixture {
name: "deblock_m2_2",
source: "testsrc2",
width: 352,
height: 288,
frames: 20,
filter: NOISE,
args: &["-profile:v", "high", "-x264-params", "deblock=-2,2"],
},
Fixture {
name: "cqm_jvt",
source: "testsrc2",
width: 352,
height: 288,
frames: 20,
filter: NOISE,
args: &["-profile:v", "high", "-x264-params", "cqm=jvt"],
},
Fixture {
name: "slices4",
source: "testsrc2",
width: 352,
height: 288,
frames: 20,
filter: NOISE,
args: &["-profile:v", "high", "-x264-params", "slices=4"],
},
Fixture {
name: "slices4_cavlc",
source: "testsrc2",
width: 352,
height: 288,
frames: 20,
filter: NOISE,
args: &["-profile:v", "baseline", "-x264-params", "slices=4"],
},
Fixture {
name: "keyint10",
source: "testsrc2",
width: 352,
height: 288,
frames: 30,
filter: NOISE,
args: &["-profile:v", "high", "-x264-params", "keyint=10"],
},
Fixture {
name: "open_gop",
source: "testsrc2",
width: 352,
height: 288,
frames: 40,
filter: NOISE,
args: &["-profile:v", "high", "-x264-params", "open-gop=1:keyint=12:bframes=3"],
},
Fixture {
name: "constrained_intra",
source: "testsrc2",
width: 352,
height: 288,
frames: 20,
filter: NOISE,
args: &["-profile:v", "high", "-x264-params", "constrained-intra=1"],
},
Fixture {
name: "no8x8dct",
source: "testsrc2",
width: 352,
height: 288,
frames: 20,
filter: NOISE,
args: &["-profile:v", "high", "-x264-params", "8x8dct=0"],
},
Fixture { name: "qp50", source: "testsrc2", width: 352, height: 288, frames: 20, filter: NOISE, args: &["-profile:v", "high", "-qp", "50"] },
Fixture { name: "qp1", source: "mandelbrot", width: 352, height: 288, frames: 10, filter: NOISE, args: &["-profile:v", "high", "-qp", "1"] },
Fixture {
name: "qp1_cavlc",
source: "mandelbrot",
width: 176,
height: 144,
frames: 10,
filter: NOISE,
args: &["-profile:v", "main", "-qp", "1", "-x264-params", "cabac=0"],
},
];
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/h264");
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
}
}
}
/// Generate (if needed) the fixture stream and its ffmpeg reference decode.
/// Returns None (with a message) when ffmpeg 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 h264 = dir.join(format!("{}.h264", f.name));
let yuv = dir.join(format!("{}.yuv", f.name));
if !h264.exists() {
let tmp = dir.join(format!("{}.tmp.{}.h264", f.name, std::process::id()));
let mut vf = format!("{}=size={}x{}:rate=25,format=yuv420p", f.source, f.width, f.height);
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(), "-c:v", "libx264"]);
c.args(f.args);
c.args(["-f", "h264"]).arg(&tmp);
assert!(run(&mut c), "fixture generation failed for {}", f.name);
std::fs::rename(&tmp, &h264).unwrap();
}
if !yuv.exists() {
let tmp = dir.join(format!("{}.tmp.{}.yuv", f.name, std::process::id()));
let mut c = Command::new(&ff);
c.args(["-hide_banner", "-loglevel", "error", "-y", "-i"]).arg(&h264);
c.args(["-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "yuv420p"]).arg(&tmp);
assert!(run(&mut c), "reference decode failed for {}", f.name);
std::fs::rename(&tmp, &yuv).unwrap();
}
Some((h264, yuv))
}
/// Split an Annex-B stream into access units (new AU at AUD/SPS/PPS/SEI after a slice, or at a slice with
/// first_mb_in_slice == 0).
pub fn split_access_units(data: &[u8]) -> Vec<&[u8]> {
// find start code positions
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_slice = false;
for &(sc, payload) in &starts {
if payload >= data.len() {
continue;
}
let t = data[payload] & 0x1f;
let is_slice = t == 1 || t == 5;
let first_mb_zero = is_slice && payload + 1 < data.len() && data[payload + 1] & 0x80 != 0;
let boundary = seen_slice && (matches!(t, 6..=9) || first_mb_zero);
if boundary {
aus.push(&data[au_start..sc]);
au_start = sc;
seen_slice = false;
}
if is_slice {
seen_slice = true;
}
}
if au_start < data.len() {
aus.push(&data[au_start..]);
}
aus
}
/// Decode a whole Annex-B file access unit by access unit (pts = AU index).
/// Failed decode: (access unit index, error, pictures output before the error).
pub type DecodeFailure = (usize, filmcraft_h264::Error, Vec<Picture>);
pub fn decode_file(path: &Path) -> Result<Vec<Picture>, DecodeFailure> {
let data = std::fs::read(path).unwrap();
let mut dec = Decoder::new();
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());
Ok(out)
}
/// Compare decoded pictures with a raw yuv420p reference; returns a diagnostic on the first mismatch.
pub fn compare(pics: &[Picture], reference: &[u8], w: usize, h: usize) -> Result<(), String> {
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let fsize = w * h + 2 * cw * ch;
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 planes =
[("Y", &p.y[..], &f[..w * h], w, h, 16), ("U", &p.u[..], &f[w * h..w * h + cw * ch], cw, ch, 8), ("V", &p.v[..], &f[w * h + cw * ch..], cw, ch, 8)];
for (name, got, exp, pw, ph, mbs) in planes {
if got != exp {
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[y * pw + x], 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));
}
}
}
}
let (x, y, a, b) = first.unwrap();
let mb_w = pw.div_ceil(mbs);
return Err(format!(
"frame {i} (poc {}, pts {}) plane {name}: first mismatch at ({x},{y}) MB {} (mb_x {}, mb_y {}): got {a} want {b}; {count} samples differ, max diff {maxd}",
p.poc,
p.pts,
(y / mbs) * mb_w + x / mbs,
x / mbs,
y / mbs
));
}
}
}
if pics.len() != nref {
return Err(format!("decoder produced {} frames, reference has {}", pics.len(), nref));
}
Ok(())
}
/// Full check of one fixture: generate, decode, compare. Returns Ok(false) when skipped.
pub fn check_fixture(name: &str) -> Result<bool, String> {
let f = fixture(name);
let Some((h264, yuv)) = ensure(f) else { return Ok(false) };
let reference = std::fs::read(&yuv).unwrap();
let pics = match decode_file(&h264) {
Ok(p) => p,
Err((au, e, partial)) => {
// still report comparison of what was decoded
let cmp = compare(&partial, &reference, f.width as usize, f.height as usize).err().unwrap_or_default();
return Err(format!("{name}: decode error at access unit {au}: {e} (after {} pictures) {cmp}", partial.len()));
}
};
compare(&pics, &reference, f.width as usize, f.height as usize).map_err(|e| format!("{name}: {e}"))?;
Ok(true)
}