Files
craft-libs/crates/media/tests/oracle_ogg.rs
JohnandClaude Opus 5.5 9c5cc064e9 craft-media: Ogg oracle tests against ffprobe (#579)
* craft-media: Ogg oracle tests against ffprobe

oracle_ogg.rs compares the Ogg demuxer with ffprobe on ffmpeg-made fixtures,
like oracle_matroska.rs: every packet byte for byte (Adler-32 from
`-show_data_hash`), codec, rate, channels and Theora picture size, and
OpusTiming's starts, durations, pre-skip and total against ffmpeg's pts,
durations and Skip Samples side data. Fixtures: Opus (stereo, mono 16 kHz with
2.5 ms frames, 60 ms frames with an odd length, 5.1, one 10 ms page, a page per
packet), Vorbis, FLAC in Ogg, Theora + Vorbis, grouped streams and a chained
file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* craft-media: Ogg oracle compares Opus starts on ffmpeg's clock

OpusTiming's starts now equal ffprobe's pts as they are, not relative to the
first packet, so a shift of every start fails the test; a stream that starts
at granule 0 also starts at -pre_skip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 07:11:24 -05:00

327 lines
14 KiB
Rust

//! Oracle tests: compare the Ogg demuxer's streams, packet bytes and Opus timing with ffprobe on
//! ffmpeg-generated fixtures (skipped when ffmpeg/ffprobe are not installed).
mod common;
use craft_media::ogg::{self, Codec, OggFile, OpusTiming};
use serde_json::Value;
use std::path::{Path, PathBuf};
use std::process::Command;
fn fixture_dir() -> PathBuf {
common::fixture_subdir("ogg")
}
/// A tone with a slow beat and a little noise, so encoders spend bits unevenly across packets.
fn music(rate: u32, layout: &str, seconds: &str) -> Vec<String> {
let expr = format!("0.3*sin(2*PI*220*t)+0.2*sin(2*PI*331*t)*sin(2*PI*0.5*t)+0.05*random(0):s={rate}:c={layout}:d={seconds}");
["-f", "lavfi", "-i", &format!("aevalsrc={expr}")].map(String::from).to_vec()
}
/// Every fixture `fixture` knows (for `generate_fixtures`).
const ALL_FIXTURES: &[&str] = &[
"opus_stereo.opus",
"opus_mono16k_2_5ms.opus",
"opus_60ms_odd_length.opus",
"opus_51.opus",
"opus_10ms.opus",
"opus_small_pages.opus",
"vorbis_stereo.ogg",
"flac.oga",
"theora_vorbis.ogv",
"grouped.ogg",
"chained_opus.opus",
];
/// Fixture name → ffmpeg arguments (after `-y -v error`, before the output path). `None` for a
/// fixture made from others.
fn spec(name: &str) -> Option<Vec<String>> {
let cat = |parts: &[Vec<String>]| parts.concat();
let s = |a: &[&str]| a.iter().map(|x| x.to_string()).collect::<Vec<_>>();
Some(match name {
"opus_stereo.opus" => cat(&[music(48000, "stereo", "3"), s(&["-c:a", "libopus", "-b:a", "128k"])]),
"opus_mono16k_2_5ms.opus" => cat(&[music(16000, "mono", "1"), s(&["-c:a", "libopus", "-frame_duration", "2.5"])]),
"opus_60ms_odd_length.opus" => cat(&[music(48000, "stereo", "3.217"), s(&["-c:a", "libopus", "-frame_duration", "60"])]),
"opus_51.opus" => cat(&[music(48000, "5.1", "2"), s(&["-c:a", "libopus", "-mapping_family", "1"])]),
// one page, shorter than the pre-skip
"opus_10ms.opus" => cat(&[music(48000, "stereo", "0.01"), s(&["-c:a", "libopus"])]),
"opus_small_pages.opus" => cat(&[music(48000, "stereo", "2"), s(&["-c:a", "libopus", "-page_duration", "20000"])]),
"vorbis_stereo.ogg" => cat(&[music(44100, "stereo", "3"), s(&["-c:a", "libvorbis", "-q:a", "5"])]),
"flac.oga" => cat(&[music(44100, "stereo", "3"), s(&["-c:a", "flac", "-sample_fmt", "s16"])]),
"theora_vorbis.ogv" => cat(&[
s(&["-f", "lavfi", "-i", "testsrc2=s=320x240:r=25:d=2"]),
music(48000, "stereo", "2"),
s(&["-c:v", "libtheora", "-q:v", "6", "-c:a", "libvorbis"]),
]),
// grouped (multiplexed) streams: two Opus and a Vorbis
"grouped.ogg" => cat(&[
music(48000, "stereo", "2"),
music(48000, "mono", "2"),
music(44100, "stereo", "2"),
s(&["-map", "0", "-map", "1", "-map", "2", "-c:a:0", "libopus", "-c:a:1", "libopus", "-c:a:2", "libvorbis"]),
]),
"chained_link_a.opus" => cat(&[music(48000, "stereo", "2"), s(&["-c:a", "libopus", "-fflags", "+bitexact", "-serial_offset", "100"])]),
"chained_link_b.opus" => cat(&[music(48000, "stereo", "1.5"), s(&["-c:a", "libopus", "-fflags", "+bitexact", "-serial_offset", "200"])]),
"chained_opus.opus" => return None,
_ => panic!("unknown fixture {name}"),
})
}
/// Generate (once) and return the path of a fixture; `None` after printing `SKIPPED` if generation
/// failed (a failure with `CRAFT_MEDIA_REQUIRE_ORACLES=1`).
fn fixture(ffmpeg: &Path, name: &str) -> Option<PathBuf> {
let dir = fixture_dir();
let out = dir.join(name);
if std::fs::metadata(&out).is_ok_and(|m| m.len() > 0) {
return Some(out);
}
let tmp = common::temp_path(&out);
let Some(args) = spec(name) else {
// a chained file: one complete Ogg file after another, each with its own serial
let mut bytes = std::fs::read(fixture(ffmpeg, "chained_link_a.opus")?).unwrap();
bytes.extend(std::fs::read(fixture(ffmpeg, "chained_link_b.opus")?).unwrap());
std::fs::write(&tmp, bytes).unwrap();
std::fs::rename(&tmp, &out).unwrap();
return Some(out);
};
let mut c = Command::new(ffmpeg);
c.args(["-y", "-v", "error", "-nostdin"]).args(&args).args(["-f", "ogg"]).arg(&tmp);
// the rename is atomic: concurrent generators race harmlessly
common::run_generator(&mut c, &tmp, &out, common::oracles_required())
}
/// ffprobe's streams and packets, with an Adler-32 of every packet's bytes.
fn ffprobe_json(ffprobe: &Path, path: &Path) -> Value {
let out = Command::new(ffprobe)
.args(["-v", "error", "-show_streams", "-show_packets", "-show_data_hash", "adler32", "-of", "json"])
.arg(path)
.output()
.expect("run ffprobe");
assert!(out.status.success(), "ffprobe failed on {}: {}", path.display(), String::from_utf8_lossy(&out.stderr));
serde_json::from_slice(&out.stdout).expect("ffprobe json")
}
fn adler32(data: &[u8]) -> String {
let (mut a, mut b) = (1u32, 0u32);
for &x in data {
a = (a + u32::from(x)) % 65_521;
b = (b + a) % 65_521;
}
format!("adler32:{:08x}", (b << 16) | a)
}
/// One of ffprobe's packets: size, data hash, pts, duration, and the Opus trims ffmpeg sends as
/// "Skip Samples" side data (samples to skip at the start, padding to discard at the end).
#[derive(Debug)]
struct FfPacket {
size: usize,
hash: String,
pts: Option<i64>,
duration: Option<i64>,
skip: i64,
discard: i64,
}
fn ff_packets(json: &Value, stream: usize) -> Vec<FfPacket> {
let packets = json["packets"].as_array().unwrap();
packets
.iter()
.filter(|p| common::int(&p["stream_index"]) == Some(stream as i64))
.map(|p| {
let side = p["side_data_list"].as_array().and_then(|l| l.iter().find(|d| d["side_data_type"] == "Skip Samples"));
let trim = |key: &str| side.and_then(|d| common::int(&d[key])).unwrap_or(0);
FfPacket {
size: common::int(&p["size"]).unwrap() as usize,
hash: p["data_hash"].as_str().unwrap().to_string(),
pts: common::int(&p["pts"]),
duration: common::int(&p["duration"]),
skip: trim("skip_samples"),
discard: trim("discard_padding"),
}
})
.collect()
}
/// The fixture, our reading of it, and ffprobe's; `None` after printing `SKIPPED`.
fn run(name: &str) -> Option<(Vec<u8>, OggFile, Value)> {
let (ffmpeg, ffprobe) = (common::ffmpeg()?, common::ffprobe()?);
let path = fixture(&ffmpeg, name)?;
let bytes = std::fs::read(&path).unwrap();
let file = ogg::open(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
let json = ffprobe_json(&ffprobe, &path);
Some((bytes, file, json))
}
/// Our stream `s` against ffprobe's stream `ff`: codec, rate, channels, picture size, and every
/// packet byte for byte (ffprobe leaves the header packets out too). `ours` are the packets to
/// compare, so a chained file can give the links ffmpeg joins into one stream.
fn assert_stream_matches(name: &str, file: &OggFile, bytes: &[u8], ours: &[(usize, usize)], json: &Value, ff: usize) {
let stream = &json["streams"][ff];
let s = &file.streams[ours[0].0];
let context = format!("{name}: stream {ff} ({})", s.codec.name());
assert_eq!(stream["codec_name"].as_str(), Some(s.codec.name().to_lowercase().as_str()), "{context}: codec");
let params = s.codec_params();
if let Some(rate) = params.sample_rate {
assert_eq!(common::int(&stream["sample_rate"]), Some(i64::from(rate)), "{context}: sample rate");
}
if let Some(channels) = params.channels {
assert_eq!(common::int(&stream["channels"]), Some(i64::from(channels)), "{context}: channels");
}
if let (Some(w), Some(h)) = (params.width, params.height) {
assert_eq!((common::int(&stream["width"]), common::int(&stream["height"])), (Some(i64::from(w)), Some(i64::from(h))), "{context}: size");
}
let theirs = ff_packets(json, ff);
assert_eq!(ours.len(), theirs.len(), "{context}: packet count");
for (k, (&(si, pi), p)) in ours.iter().zip(&theirs).enumerate() {
let data = file.read_packet(bytes, si, pi).unwrap();
assert_eq!((data.len(), adler32(&data)), (p.size, p.hash.clone()), "{context}: packet {k}");
}
eprintln!("{context}: {} packets byte-identical", ours.len());
}
/// Every packet of stream `s`, as (stream, packet) indices.
fn all(file: &OggFile, s: usize) -> Vec<(usize, usize)> {
(0..file.streams[s].packets.len()).map(|i| (s, i)).collect()
}
/// `OpusTiming` against ffmpeg: the starts are its pts (both on the same clock: a stream that starts
/// at granule 0 starts at `-pre_skip`), the durations its packet durations except the last, which
/// ffmpeg trims by the discard padding, the pre-skip its first packet's skip, and the total its
/// playable length.
fn assert_opus_timing_matches(name: &str, file: &OggFile, s: usize, json: &Value, ff: usize) {
let stream = &file.streams[s];
let pre_skip = stream.headers.first().and_then(|h| h.get(10..12)).map_or(0, |b| u32::from(u16::from_le_bytes([b[0], b[1]])));
let timing = OpusTiming::of(stream, pre_skip);
let theirs = ff_packets(json, ff);
let context = format!("{name}: Opus stream {ff}");
assert_eq!(timing.starts.len(), theirs.len(), "{context}: packet count");
let last = theirs.len() - 1;
for (k, p) in theirs.iter().enumerate() {
assert_eq!(p.pts, Some(timing.starts[k]), "{context}: start of packet {k}");
let duration = p.duration.unwrap() + if k == last { p.discard } else { 0 };
assert_eq!(duration, i64::from(timing.durations[k]), "{context}: duration of packet {k}");
}
assert_eq!(i64::from(timing.pre_skip), theirs[0].skip, "{context}: pre-skip");
if stream.packets.first().and_then(|p| p.granule).is_some_and(|g| g >= 0) {
assert_eq!(timing.starts[0], -i64::from(timing.pre_skip), "{context}: first start");
}
let playable: i64 = theirs.iter().map(|p| p.duration.unwrap()).sum::<i64>() - theirs[0].skip;
assert_eq!(timing.total, playable, "{context}: total");
eprintln!("{context}: timing of {} packets matches, {} samples", theirs.len(), timing.total);
}
/// A file of one Opus stream: its packets and its timing.
fn opus(name: &str) -> Option<OggFile> {
let (bytes, file, json) = run(name)?;
assert_eq!(file.streams.len(), 1, "{name}: {:?}", file.warnings);
assert!(file.warnings.is_empty(), "{name}: {:?}", file.warnings);
assert_eq!(file.streams[0].codec, Codec::Opus);
assert_stream_matches(name, &file, &bytes, &all(&file, 0), &json, 0);
assert_opus_timing_matches(name, &file, 0, &json, 0);
Some(file)
}
#[test]
fn opus_stereo() {
let Some(file) = opus("opus_stereo.opus") else { return };
assert!(file.streams[0].saw_eos);
}
#[test]
fn opus_mono_16_khz_with_2_5_ms_frames() {
opus("opus_mono16k_2_5ms.opus");
}
#[test]
fn opus_60_ms_frames_with_an_odd_length() {
opus("opus_60ms_odd_length.opus");
}
#[test]
fn opus_5_1_mapping_family_1() {
let Some(file) = opus("opus_51.opus") else { return };
assert_eq!(file.streams[0].codec_params().channels, Some(6));
}
#[test]
fn opus_shorter_than_the_pre_skip() {
opus("opus_10ms.opus");
}
#[test]
fn opus_with_a_page_per_packet() {
let Some(file) = opus("opus_small_pages.opus") else { return };
assert!(file.streams[0].pages as usize >= file.streams[0].packets.len());
}
#[test]
fn vorbis_stereo() {
let Some((bytes, file, json)) = run("vorbis_stereo.ogg") else { return };
assert!(file.warnings.is_empty(), "{:?}", file.warnings);
assert_eq!(file.streams[0].headers.len(), 3);
assert_stream_matches("vorbis_stereo.ogg", &file, &bytes, &all(&file, 0), &json, 0);
}
#[test]
fn flac_in_ogg() {
let Some((bytes, file, json)) = run("flac.oga") else { return };
assert!(file.warnings.is_empty(), "{:?}", file.warnings);
assert_eq!(file.streams[0].codec, Codec::Flac);
assert!(file.streams[0].codec_params().extradata.starts_with(b"fLaC"));
assert_stream_matches("flac.oga", &file, &bytes, &all(&file, 0), &json, 0);
}
#[test]
fn theora_and_vorbis() {
let Some((bytes, file, json)) = run("theora_vorbis.ogv") else { return };
assert!(file.warnings.is_empty(), "{:?}", file.warnings);
assert_eq!(file.streams.len(), 2);
for s in 0..2 {
assert_stream_matches("theora_vorbis.ogv", &file, &bytes, &all(&file, s), &json, s);
}
let video = file.stream_of(Codec::Theora).unwrap();
let params = file.streams[video].codec_params();
assert_eq!((params.width, params.height), (Some(320), Some(240)));
}
/// Multiplexed streams: each is read on its own, in ffmpeg's order.
#[test]
fn grouped_streams() {
let Some((bytes, file, json)) = run("grouped.ogg") else { return };
assert!(file.warnings.is_empty(), "{:?}", file.warnings);
let codecs: Vec<Codec> = file.streams.iter().map(|s| s.codec).collect();
assert_eq!(codecs, [Codec::Opus, Codec::Opus, Codec::Vorbis]);
for s in 0..3 {
assert_stream_matches("grouped.ogg", &file, &bytes, &all(&file, s), &json, s);
}
for s in 0..2 {
assert_opus_timing_matches("grouped.ogg", &file, s, &json, s);
}
}
/// A chained file: two links, each its own stream with its own headers. ffmpeg joins the links of
/// one codec into one stream.
#[test]
fn chained_opus() {
let Some((bytes, file, json)) = run("chained_opus.opus") else { return };
assert!(file.warnings.is_empty(), "{:?}", file.warnings);
assert_eq!(file.streams.len(), 2);
assert_ne!(file.streams[0].serial, file.streams[1].serial);
assert!(file.streams.iter().all(|s| s.codec == Codec::Opus && s.headers.len() == 2 && s.saw_eos));
assert_eq!(json["streams"].as_array().map(Vec::len), Some(1), "ffmpeg's one stream");
let mut both = all(&file, 0);
both.extend(all(&file, 1));
assert_stream_matches("chained_opus.opus", &file, &bytes, &both, &json, 0);
}
/// Pre-generate every fixture (`cargo test -p craft-media --all-features -- --ignored generate_fixtures`).
#[test]
#[ignore]
fn generate_fixtures() {
let Some(ffmpeg) = common::ffmpeg() else { return };
for name in ALL_FIXTURES {
let out = [fixture_dir().join(name)];
common::generate_and_report(&format!("ogg/{name}"), &out, || fixture(&ffmpeg, name));
}
}