mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
The start of MPEG-2 encoding in craft_video::mpeg2 (feature mpeg2-encode): progressive frame pictures, closed I/P GOPs, 4:2:0 Main Profile and 4:2:2 Profile, constant quantiser or single-pass average bitrate with adaptive quantisation, half-sample motion search. The encoder shares the decoder's code tables, IDCT, dequantisation and motion compensation, so the decoder outputs exactly the encoder's reconstruction. B pictures, a VBV guarantee, interlaced coding and DVD or broadcast presets are later steps of #1133. The mpeg2 feature is split into mpeg2-decode and mpeg2-encode; mpeg2 still turns on both.
207 lines
8.3 KiB
Rust
207 lines
8.3 KiB
Rust
//! The MPEG-2 encoder against ffmpeg as an external decoder: ffmpeg must decode our streams
|
|
//! without errors, to within the decoder suite's tolerance of the encoder's own reconstruction
|
|
//! (ffmpeg's integer IDCT differs from ours), and report the profile, level and picture types
|
|
//! we signal. Source pictures come from ffmpeg's `testsrc2`.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
|
|
use craft_video::mpeg2::{ChromaFormat, Encoder, EncoderConfig, RateControl, ReconFrame, YuvFrame};
|
|
|
|
use crate::common::{Stats, fixture_dir, pix_fmt_of};
|
|
use crate::oracle::{MAX_DIFFERING, MIN_PSNR, TOL_MAX};
|
|
|
|
/// Raw source frames from ffmpeg's `testsrc2`.
|
|
fn source(ff: &Path, w: u32, h: u32, frames: usize, pix_fmt: &str) -> Vec<u8> {
|
|
let o = Command::new(ff)
|
|
.args([
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"error",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
&format!("testsrc2=size={w}x{h}:rate=25"),
|
|
"-frames:v",
|
|
&frames.to_string(),
|
|
])
|
|
.args(["-pix_fmt", pix_fmt, "-f", "rawvideo", "-"])
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.expect("run ffmpeg");
|
|
assert!(o.status.success(), "ffmpeg: {}", String::from_utf8_lossy(&o.stderr));
|
|
o.stdout
|
|
}
|
|
|
|
fn sizes(cfg: &EncoderConfig) -> (usize, usize, usize) {
|
|
let (w, h) = (cfg.width as usize, cfg.height as usize);
|
|
let ch = if cfg.chroma == ChromaFormat::Yuv422 { h } else { h / 2 };
|
|
(w * h, w / 2 * ch, w / 2)
|
|
}
|
|
|
|
/// Encode `raw` frames; returns the elementary stream and the reconstruction.
|
|
fn encode(cfg: &EncoderConfig, raw: &[u8]) -> (Vec<u8>, Vec<ReconFrame>) {
|
|
let (ys, cs, cw) = sizes(cfg);
|
|
let fsize = ys + 2 * cs;
|
|
let mut enc = Encoder::new(cfg.clone()).expect("config");
|
|
enc.set_recon_capture(true);
|
|
let mut es = Vec::new();
|
|
for (i, f) in raw.chunks_exact(fsize).enumerate() {
|
|
let frame = YuvFrame { y: &f[..ys], cb: &f[ys..ys + cs], cr: &f[ys + cs..], y_stride: cfg.width as usize, c_stride: cw };
|
|
for p in enc.encode(&frame, i as i64).expect("encode") {
|
|
es.extend_from_slice(&p.data);
|
|
}
|
|
}
|
|
for p in enc.flush() {
|
|
es.extend_from_slice(&p.data);
|
|
}
|
|
es.extend_from_slice(enc.end_of_sequence());
|
|
(es, enc.take_recon())
|
|
}
|
|
|
|
/// ffmpeg's decode of `file`, failing on any decoding error.
|
|
fn ffmpeg_decode(ff: &Path, file: &Path, pix_fmt: &str) -> Vec<u8> {
|
|
let o = Command::new(ff)
|
|
.args(["-hide_banner", "-loglevel", "error", "-xerror", "-err_detect", "explode", "-i"])
|
|
.arg(file)
|
|
.args(["-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", pix_fmt, "-"])
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.expect("run ffmpeg");
|
|
assert!(o.status.success() && o.stderr.is_empty(), "ffmpeg rejects {}: {}", file.display(), String::from_utf8_lossy(&o.stderr));
|
|
o.stdout
|
|
}
|
|
|
|
/// ffprobe's stream description and per-frame picture types.
|
|
fn ffprobe(file: &Path) -> Option<(String, Vec<String>)> {
|
|
let fp = craft_media_testkit::ffprobe()?;
|
|
let stream = Command::new(&fp)
|
|
.args(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name,profile,level,pix_fmt", "-of", "default=nw=1"])
|
|
.arg(file)
|
|
.output()
|
|
.ok()?;
|
|
let frames = Command::new(&fp)
|
|
.args(["-v", "error", "-select_streams", "v:0", "-show_entries", "frame=pict_type", "-of", "csv=p=0"])
|
|
.arg(file)
|
|
.output()
|
|
.ok()?;
|
|
let types = String::from_utf8_lossy(&frames.stdout).lines().map(|l| l.trim_end_matches(',').to_string()).filter(|l| !l.is_empty()).collect();
|
|
// "codec_name=…\nprofile=…" in ffprobe's order → "codec,profile,level,pix_fmt"
|
|
let text = String::from_utf8_lossy(&stream.stdout).to_string();
|
|
let field = |k: &str| text.lines().find_map(|l| l.strip_prefix(&format!("{k}="))).unwrap_or("").to_string();
|
|
Some((["codec_name", "profile", "level", "pix_fmt"].map(field).join(","), types))
|
|
}
|
|
|
|
fn write(name: &str, es: &[u8]) -> PathBuf {
|
|
let path = fixture_dir().join(format!("enc_{name}.m2v"));
|
|
std::fs::write(&path, es).expect("write stream");
|
|
path
|
|
}
|
|
|
|
fn psnr_y(recon: &[ReconFrame], raw: &[u8], ys: usize, fsize: usize) -> f64 {
|
|
let mut s = Stats::default();
|
|
for (r, f) in recon.iter().zip(raw.chunks_exact(fsize)) {
|
|
s.add(&r.y, &f[..ys]);
|
|
}
|
|
s.psnr()
|
|
}
|
|
|
|
struct Case {
|
|
name: &'static str,
|
|
cfg: EncoderConfig,
|
|
frames: usize,
|
|
/// ffprobe's "codec,profile,level,pix_fmt".
|
|
probe: &'static str,
|
|
/// Lowest luma PSNR against the source.
|
|
min_psnr: f64,
|
|
}
|
|
|
|
fn check(ff: &Path, c: &Case) -> (Vec<u8>, f64) {
|
|
let pix_fmt = pix_fmt_of(c.cfg.chroma);
|
|
let raw = source(ff, c.cfg.width, c.cfg.height, c.frames, pix_fmt);
|
|
let (ys, cs, _) = sizes(&c.cfg);
|
|
let fsize = ys + 2 * cs;
|
|
let (es, recon) = encode(&c.cfg, &raw);
|
|
let path = write(c.name, &es);
|
|
let decoded = ffmpeg_decode(ff, &path, pix_fmt);
|
|
assert_eq!(decoded.len(), c.frames * fsize, "{}: ffmpeg's frame count", c.name);
|
|
let mut all = Stats::default();
|
|
for (i, (r, f)) in recon.iter().zip(decoded.chunks_exact(fsize)).enumerate() {
|
|
let mut s = Stats::default();
|
|
s.add(&r.y, &f[..ys]);
|
|
s.add(&r.cb, &f[ys..ys + cs]);
|
|
s.add(&r.cr, &f[ys + cs..]);
|
|
assert!(s.max <= TOL_MAX && s.psnr() >= MIN_PSNR, "{} frame {i}: max {} psnr {:.2} against ffmpeg", c.name, s.max, s.psnr());
|
|
all.merge(&s);
|
|
}
|
|
assert!((all.diff as f64) / (all.n as f64) <= MAX_DIFFERING, "{}: {} of {} samples differ from ffmpeg", c.name, all.diff, all.n);
|
|
let quality = psnr_y(&recon, &raw, ys, fsize);
|
|
assert!(quality >= c.min_psnr, "{}: luma PSNR {quality:.2} dB against the source", c.name);
|
|
if let Some((stream, types)) = ffprobe(&path) {
|
|
assert_eq!(stream, c.probe, "{}: ffprobe", c.name);
|
|
let gop = c.cfg.gop_size as usize;
|
|
let want: Vec<&str> = (0..c.frames).map(|i| if i % gop == 0 { "I" } else { "P" }).collect();
|
|
assert_eq!(types, want, "{}: picture types", c.name);
|
|
}
|
|
let kbps = es.len() as f64 * 8.0 * 25.0 / c.frames as f64 / 1000.0;
|
|
println!(
|
|
"{}: {} frames, {kbps:.0} kbit/s, luma PSNR {quality:.2} dB, max diff to ffmpeg {}, {:.3}% differ",
|
|
c.name,
|
|
c.frames,
|
|
all.max,
|
|
100.0 * all.diff as f64 / all.n as f64
|
|
);
|
|
(es, kbps)
|
|
}
|
|
|
|
#[test]
|
|
fn ffmpeg_decodes_our_streams() {
|
|
let Some(ff) = craft_media_testkit::ffmpeg_or_skip("mpeg2 encoder oracle") else { return };
|
|
let cif = EncoderConfig { gop_size: 12, ..EncoderConfig::new(352, 288, 25, 1) };
|
|
let cases = [
|
|
Case { name: "cif_q4", cfg: cif.clone(), frames: 25, probe: "mpeg2video,Main,10,yuv420p", min_psnr: 36.0 },
|
|
Case {
|
|
name: "cif_intra",
|
|
cfg: EncoderConfig { gop_size: 1, rate: RateControl::Quantiser(8), ..cif.clone() },
|
|
frames: 6,
|
|
probe: "mpeg2video,Main,10,yuv420p",
|
|
min_psnr: 32.0,
|
|
},
|
|
Case {
|
|
name: "cif_linear_coarse",
|
|
cfg: EncoderConfig { nonlinear_quant: false, adaptive_quant: false, rate: RateControl::Quantiser(20), gop_size: 15, ..cif.clone() },
|
|
frames: 20,
|
|
probe: "mpeg2video,Main,10,yuv420p",
|
|
min_psnr: 26.0,
|
|
},
|
|
Case {
|
|
name: "sd_422",
|
|
cfg: EncoderConfig { chroma: ChromaFormat::Yuv422, intra_dc_bits: 10, ..EncoderConfig::new(720, 576, 25, 1) },
|
|
frames: 8,
|
|
// 4:2:2@Main is indication 0x85; ffprobe prints its low four bits as the level
|
|
probe: "mpeg2video,4:2:2,5,yuv422p",
|
|
min_psnr: 36.0,
|
|
},
|
|
];
|
|
for c in &cases {
|
|
check(&ff, c);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn average_bitrate_reaches_its_target() {
|
|
let Some(ff) = craft_media_testkit::ffmpeg_or_skip("mpeg2 encoder bitrate") else { return };
|
|
for kbps in [800u32, 2500] {
|
|
let c = Case {
|
|
name: if kbps == 800 { "cif_abr_800" } else { "cif_abr_2500" },
|
|
cfg: EncoderConfig { rate: RateControl::Abr { kbps }, gop_size: 12, ..EncoderConfig::new(352, 288, 25, 1) },
|
|
frames: 72,
|
|
probe: "mpeg2video,Main,10,yuv420p",
|
|
min_psnr: 26.0,
|
|
};
|
|
let (_, got) = check(&ff, &c);
|
|
let err = got / f64::from(kbps) - 1.0;
|
|
assert!(err.abs() < 0.10, "{kbps} kbit/s target: got {got:.0} kbit/s ({:+.1}%)", 100.0 * err);
|
|
}
|
|
}
|