Files
craft-libs/crates/video/tests/mpeg4_encode/main.rs
T
Aleksander Helgaker 77a3ddbf26 craft-video: MPEG-4 Part 2 (Xvid/DivX) Simple profile encoder
* craft-video: MPEG-4 Part 2 Simple profile encoder

Adds a clean-room MPEG-4 Visual (ISO/IEC 14496-2) encoder behind the new
`mpeg4` / `mpeg4-encode` features: the stream type Xvid and DivX write,
which craft-media already identifies (CodecId::Mpeg4Video) in MP4, Matroska,
AVI and MXF but which no crate could produce.

- I- and P-VOPs, 4:2:0 8-bit, H.263 quantisation, half-sample motion with a
  predictor-seeded diamond search, skipped and intra macroblocks in P-VOPs,
  intra refresh within the 132 codings of §7.4.5.
- Constant quantiser or 1-pass average bitrate.
- Headers in band (raw .m4v, AVI) or as extradata / CodecParams (MP4, Matroska).

Tests use ffmpeg/ffprobe as external decoders: every coefficient table entry
and escape mode 1/2 event decodes like the fixed-length escape, and encoded
streams decode without diagnostics, close to the encoder's reconstruction.

---------

Co-authored-by: alehel <git@ornumhelgaker.no>
2026-11-12 09:11:48 -05:00

325 lines
13 KiB
Rust

//! The MPEG-4 Part 2 encoder against ffmpeg / ffprobe as external decoder oracles (skipped when
//! absent), plus API and robustness checks that need no oracle.
use std::path::{Path, PathBuf};
use std::process::Command;
use craft_video::mpeg4::{ColorConfig, Encoder, EncoderConfig, Error, Packet, RateControl, YuvFrame};
struct Yuv {
w: usize,
y: Vec<u8>,
u: Vec<u8>,
v: Vec<u8>,
}
impl Yuv {
fn frame(&self) -> YuvFrame<'_> {
YuvFrame { y: &self.y, u: &self.u, v: &self.v, y_stride: self.w, uv_stride: self.w / 2 }
}
}
fn hash(mut x: u32) -> u32 {
x ^= x >> 16;
x = x.wrapping_mul(0x7feb_352d);
x ^= x >> 15;
x = x.wrapping_mul(0x846c_a68b);
x ^= x >> 16;
x
}
/// Picture `t` of a moving test sequence: a panning texture (sub-sample motion), a drifting
/// gradient, hard-edged blocks moving a whole number of samples a frame, and a little noise.
fn synth(w: usize, h: usize, t: usize) -> Yuv {
let tf = t as f32;
let mut y = vec![0u8; w * h];
for yy in 0..h {
for xx in 0..w {
let (px, py) = (xx as f32 + tf * 1.5, yy as f32 + tf * 0.5);
let tex = ((px / 9.0).sin() + (py / 7.0).cos() + ((px + py) / 23.0).sin()) * 35.0;
let grad = 40.0 * ((xx as f32 / w as f32) * 4.0 + tf * 0.05).sin();
let bx = (xx + 3 * t) / 24;
let by = yy / 24;
let block = if (bx + by).is_multiple_of(3) { 50.0 } else { -20.0 };
let noise = (hash((xx as u32) ^ (yy as u32) << 12 ^ (t as u32) << 24) % 9) as f32 - 4.0;
y[yy * w + xx] = (128.0 + tex + grad + block + noise).clamp(0.0, 255.0) as u8;
}
}
let (cw, ch) = (w / 2, h / 2);
let mut u = vec![0u8; cw * ch];
let mut v = vec![0u8; cw * ch];
for yy in 0..ch {
for xx in 0..cw {
let fx = xx as f32 / cw as f32;
let fy = yy as f32 / ch as f32;
u[yy * cw + xx] = (128.0 + 60.0 * (fx * 3.0 + tf * 0.03).sin()) as u8;
v[yy * cw + xx] = (128.0 + 50.0 * (fy * 5.0 - tf * 0.04).cos() * fx) as u8;
}
}
Yuv { w, y, u, v }
}
fn ffmpeg() -> Option<PathBuf> {
craft_media_testkit::ffmpeg_or_skip("mpeg4 encoder oracle")
}
fn out_dir(name: &str) -> PathBuf {
craft_media_testkit::fixtures_dir(&format!("mpeg4enc/{name}"))
}
fn write_stream(path: &Path, packets: &[Packet]) {
let bytes: Vec<u8> = packets.iter().flat_map(|p| p.data.iter().copied()).collect();
std::fs::write(path, bytes).expect("write stream");
}
/// ffmpeg's decode of a raw MPEG-4 Part 2 stream: (diagnostics, yuv420p frames).
fn ffmpeg_decode(ff: &Path, path: &Path) -> (String, Vec<u8>) {
let out = Command::new(ff)
.args(["-hide_banner", "-nostdin", "-v", "warning", "-f", "m4v", "-i"])
.arg(path)
.args(["-f", "rawvideo", "-pix_fmt", "yuv420p", "-"])
.output()
.expect("run ffmpeg");
assert!(out.status.success(), "ffmpeg failed: {}", String::from_utf8_lossy(&out.stderr));
(String::from_utf8_lossy(&out.stderr).into_owned(), out.stdout)
}
fn psnr(a: &[u8], b: &[u8]) -> f64 {
assert_eq!(a.len(), b.len());
let se: f64 = a.iter().zip(b).map(|(&x, &y)| (f64::from(x) - f64::from(y)).powi(2)).sum();
if se == 0.0 {
return 99.0;
}
10.0 * (255.0f64 * 255.0 * a.len() as f64 / se).log10()
}
fn planes(y: &Yuv) -> Vec<u8> {
[&y.y[..], &y.u[..], &y.v[..]].concat()
}
/// An encoded test sequence: the packets, and for each frame the encoder's reconstruction and the
/// source (planes Y, U, V back to back).
struct Encoded {
enc: Encoder,
packets: Vec<Packet>,
recon: Vec<Vec<u8>>,
src: Vec<Vec<u8>>,
}
/// Encode `n` frames, keeping the encoder's reconstruction of each.
fn encode(cfg: EncoderConfig, n: usize) -> Encoded {
let (w, h) = (cfg.width as usize, cfg.height as usize);
let mut enc = Encoder::new(cfg).expect("encoder");
let (mut packets, mut recon, mut src) = (Vec::new(), Vec::new(), Vec::new());
for t in 0..n {
let pic = synth(w, h, t);
packets.push(enc.encode(&pic.frame()).expect("encode"));
let r = enc.reconstruction().expect("reconstruction");
recon.push([r.y, r.u, r.v].concat());
src.push(planes(&pic));
}
Encoded { enc, packets, recon, src }
}
/// ffmpeg decodes the stream without a diagnostic, frame for frame, to the encoder's own
/// reconstruction (within IDCT rounding) and close to the source.
fn check_against_ffmpeg(name: &str, cfg: EncoderConfig, n: usize, min_psnr: f64) {
let Some(ff) = ffmpeg() else { return };
let (w, h) = (cfg.width as usize, cfg.height as usize);
let Encoded { packets, recon, src, .. } = encode(cfg, n);
let path = out_dir(name).join("stream.m4v");
write_stream(&path, &packets);
let (diag, dec) = ffmpeg_decode(&ff, &path);
assert!(diag.trim().is_empty(), "{name}: ffmpeg reported: {diag}");
let size = w * h * 3 / 2;
assert_eq!(dec.len(), size * n, "{name}: decoded frame count");
for (i, frame) in dec.chunks(size).enumerate() {
let drift = psnr(&recon[i], frame);
assert!(drift >= 48.0, "{name}: frame {i} differs from the encoder's reconstruction ({drift:.1} dB)");
let q = psnr(&src[i], frame);
assert!(q >= min_psnr, "{name}: frame {i} at {q:.1} dB against the source (want {min_psnr})");
}
}
#[test]
fn ffmpeg_decodes_constant_quantiser_streams() {
for (w, h, q, min) in [(176, 144, 2, 38.0), (320, 240, 5, 31.0), (200, 120, 12, 26.0), (64, 48, 31, 20.0)] {
let mut cfg = EncoderConfig::new(w, h, 25, 1);
cfg.rate = RateControl::Quant(q);
cfg.keyint = 12;
check_against_ffmpeg(&format!("cq_{w}x{h}_q{q}"), cfg, 30, min);
}
}
/// Sizes that are not a multiple of 16 leave partial macroblocks at the right and bottom edges.
#[test]
fn ffmpeg_decodes_partial_macroblocks() {
for (w, h) in [(18, 34), (98, 66), (2, 2)] {
let mut cfg = EncoderConfig::new(w, h, 30000, 1001);
cfg.rate = RateControl::Quant(3);
check_against_ffmpeg(&format!("edge_{w}x{h}"), cfg, 12, 30.0);
}
}
/// More than 128 P-VOPs: every macroblock is refreshed with an intra coding before IDCT
/// mismatch can build up, and the time code crosses several seconds.
#[test]
fn long_gop_stays_in_step_with_the_decoder() {
let mut cfg = EncoderConfig::new(96, 64, 10, 1);
cfg.keyint = 1000;
check_against_ffmpeg("long_gop", cfg, 150, 30.0);
}
#[test]
fn bitrate_mode_lands_near_the_target() {
let kbps = 400;
let mut cfg = EncoderConfig::new(320, 240, 25, 1);
cfg.rate = RateControl::Bitrate { kbps };
cfg.keyint = 50;
let n = 100;
let Encoded { packets, .. } = encode(cfg.clone(), n);
let bits: usize = packets.iter().map(|p| p.data.len() * 8).sum();
let got = bits as f64 / (n as f64 / 25.0) / 1000.0;
assert!((got - f64::from(kbps)).abs() < 0.15 * f64::from(kbps), "{got:.0} kbps for a {kbps} kbps target");
check_against_ffmpeg("abr", cfg, 40, 25.0);
}
#[test]
fn ffprobe_reads_the_configuration() {
let Some(ff) = craft_media_testkit::ffprobe() else { return };
let mut cfg = EncoderConfig::new(352, 288, 30000, 1001);
cfg.sar = (12, 11);
cfg.color = Some(ColorConfig { primaries: 6, transfer: 6, matrix: 6, full_range: false });
let Encoded { packets, .. } = encode(cfg, 3);
let path = out_dir("probe").join("stream.m4v");
write_stream(&path, &packets);
let out = Command::new(ff)
.args([
"-v",
"error",
"-show_entries",
"stream=codec_name,profile,width,height,sample_aspect_ratio,color_primaries,color_space",
"-of",
"default=nw=1",
])
.arg(&path)
.output()
.expect("run ffprobe");
let text = String::from_utf8_lossy(&out.stdout);
for want in ["codec_name=mpeg4", "width=352", "height=288", "sample_aspect_ratio=12:11", "color_primaries=smpte170m", "color_space=smpte170m"] {
assert!(text.lines().any(|l| l == want), "ffprobe: missing {want} in\n{text}");
}
assert!(text.contains("Simple"), "ffprobe profile:\n{text}");
}
/// With the headers out of band, a decoder given the extradata (as MP4 or Matroska would)
/// decodes the VOPs.
#[test]
fn out_of_band_headers_are_the_extradata() {
let Some(ff) = ffmpeg() else { return };
let mut cfg = EncoderConfig::new(160, 96, 25, 1);
cfg.headers_in_band = false;
let Encoded { enc, packets, .. } = encode(cfg, 5);
assert!(!packets[0].data.starts_with(&[0, 0, 1, 0xB0]));
let mut bytes = enc.extradata().to_vec();
bytes.extend(packets.iter().flat_map(|p| p.data.iter().copied()));
let path = out_dir("oob").join("stream.m4v");
std::fs::write(&path, bytes).expect("write");
let (diag, dec) = ffmpeg_decode(&ff, &path);
assert!(diag.trim().is_empty(), "{diag}");
assert_eq!(dec.len(), 160 * 96 * 3 / 2 * 5);
let p = enc.codec_params();
assert_eq!(p.extradata, enc.extradata());
assert_eq!(p.tag, Some(*b"mp4v"));
}
#[test]
fn keyframes_follow_keyint_and_requests() {
let mut cfg = EncoderConfig::new(32, 32, 25, 1);
cfg.keyint = 4;
let mut enc = Encoder::new(cfg).expect("encoder");
let pic = synth(32, 32, 0);
let mut keys = Vec::new();
for i in 0..10 {
if i == 6 {
enc.request_keyframe();
}
keys.push(enc.encode(&pic.frame()).expect("encode").keyframe);
}
assert_eq!(keys, [true, false, false, false, true, false, true, false, false, false]);
}
#[test]
fn bad_configurations_and_frames_are_errors() {
let new = |w, h| Encoder::new(EncoderConfig::new(w, h, 25, 1)).err();
assert_eq!(new(0, 16), Some(Error::InvalidDimensions(0, 16)));
assert_eq!(new(17, 16), Some(Error::InvalidDimensions(17, 16)));
assert_eq!(new(8192, 16), Some(Error::InvalidDimensions(8192, 16)));
assert!(matches!(Encoder::new(EncoderConfig::new(16, 16, 0, 1)), Err(Error::InvalidFrameRate(0, 1))));
assert!(matches!(Encoder::new(EncoderConfig::new(16, 16, 70000, 1)), Err(Error::InvalidFrameRate(..))));
assert!(Encoder::new(EncoderConfig::new(16, 16, 140000, 2)).is_err());
let mut cfg = EncoderConfig::new(16, 16, 25, 1);
cfg.rate = RateControl::Quant(0);
assert!(matches!(Encoder::new(cfg.clone()), Err(Error::InvalidConfig(_))));
cfg.rate = RateControl::Quant(32);
assert!(matches!(Encoder::new(cfg.clone()), Err(Error::InvalidConfig(_))));
cfg.rate = RateControl::Quant(4);
cfg.keyint = 0;
assert!(matches!(Encoder::new(cfg), Err(Error::InvalidConfig(_))));
let mut enc = Encoder::new(EncoderConfig::new(32, 16, 25, 1)).expect("encoder");
let y = vec![0u8; 32 * 16];
let c = vec![0u8; 16 * 8];
assert_eq!(enc.encode(&YuvFrame { y: &y[1..], u: &c, v: &c, y_stride: 32, uv_stride: 16 }).err(), Some(Error::BadFrame));
assert_eq!(enc.encode(&YuvFrame { y: &y, u: &c, v: &c, y_stride: 31, uv_stride: 16 }).err(), Some(Error::BadFrame));
assert_eq!(enc.encode(&YuvFrame { y: &y, u: &c[..100], v: &c, y_stride: 32, uv_stride: 16 }).err(), Some(Error::BadFrame));
assert_eq!(enc.encode(&YuvFrame { y: &y, u: &c, v: &c, y_stride: usize::MAX, uv_stride: 16 }).err(), Some(Error::BadFrame));
// A failed frame leaves the encoder usable.
assert!(enc.encode(&YuvFrame { y: &y, u: &c, v: &c, y_stride: 32, uv_stride: 16 }).is_ok());
}
/// Extreme pictures (flat black and white, full-range noise, hard alternation between them) at
/// the finest quantiser hit the largest levels and DC differences; ffmpeg still decodes them.
#[test]
fn extreme_content_at_the_finest_quantiser() {
let (w, h) = (48, 32);
let mut cfg = EncoderConfig::new(w as u32, h as u32, 25, 1);
cfg.rate = RateControl::Quant(1);
let mut enc = Encoder::new(cfg).expect("encoder");
let mut packets = Vec::new();
let mut recon = Vec::new();
let mut seed = 1u32;
for t in 0..8 {
let y: Vec<u8> = (0..w * h)
.map(|i| match t % 4 {
0 => 0,
1 => 255,
2 => {
seed = hash(seed.wrapping_add(i as u32));
seed as u8
}
_ => {
if (i % w + i / w) % 2 == 0 {
0
} else {
255
}
}
})
.collect();
let c = vec![if t % 2 == 0 { 0 } else { 255 }; w * h / 4];
packets.push(enc.encode(&YuvFrame { y: &y, u: &c, v: &c, y_stride: w, uv_stride: w / 2 }).expect("encode"));
let r = enc.reconstruction().expect("reconstruction");
recon.push([r.y, r.u, r.v].concat());
}
let Some(ff) = ffmpeg() else { return };
let path = out_dir("extreme").join("stream.m4v");
write_stream(&path, &packets);
let (diag, dec) = ffmpeg_decode(&ff, &path);
assert!(diag.trim().is_empty(), "{diag}");
for (i, frame) in dec.chunks(w * h * 3 / 2).enumerate() {
let d = psnr(&recon[i], frame);
assert!(d >= 45.0, "frame {i}: {d:.1} dB from the reconstruction");
}
}