Files
craft-libs/crates/media/tests/common/mod.rs
Aleksander HelgakerandClaude Opus 5.5 006b50d2ae craft-media: shared codec identifiers, one ByteSource, the testkit (1/10) (#439)
* craft-media: shared codec identifiers, one ByteSource, the testkit

The types a codec crate needs to describe a file and configure a
decoder, ahead of craft-audio:

- `Container`, `CodecId`, `PcmFormat` and `CodecParams` (`id.rs`),
  always built, with optional serde.
- `codec_params()` on `isobmff::Track` and `matroska::Track`, both
  returning `CodecParams` in the same form; `extradata` follows
  Matroska's `CodecPrivate`.
- One `ByteSource` trait (`source.rs`), with `ReadSeekSource`;
  `isobmff::ByteSource` and `matroska::ByteSource` are that trait.
- craft-media-testkit (`publish = false`): the ffmpeg oracle helpers
  from craft-media's tests, so the codec crates share one copy.

The existing tests prove the refactor; the new types have their own.

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

* craft-media: APV extradata is the bare record; reseek after a poisoned lock

An MP4 apvC track gave CodecParams.extradata with the 4-byte FullBox header
in front, where Matroska's V_APV CodecPrivate is the bare
APVDecoderConfigurationRecord. ApvConfig::record_bytes serializes the record
alone and extradata uses it.

ReadSeekSource kept its tracked position after recovering a poisoned lock or
a failed seek, so a reader moved by a panic was read from the wrong offset
with no error. Both now force a seek on the next read.

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

* craft-media: MP4 codec params per sample description

Track::codec_params always described the first stsd entry, while each sample
selects its own through description_index. It now describes the first
sample's entry; codec_params_for gives the parameters of any entry, and
has_mixed_descriptions says whether one set covers every sample.

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

* craft-media: test file-backed ByteSources; a File read past i64::MAX is an EOF

A File and a ReadSeekSource over a file now have the buffer's test: the same
bytes at the same offsets, and UnexpectedEof past the end. The test found
that a File read at an offset past i64::MAX failed with the OS's
InvalidInput instead of the UnexpectedEof the trait promises; it now
reports the end of the source like the other implementations.

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

* craft-media: Matroska codec params match MP4 for ALAC, DNx and Opus

Matroska's codec_params gave Unknown for A_ALAC and for DNxHD/DNxHR stored
as V_MS/VFW/FOURCC (AVdn, AVdh), where MP4 gives Alac and Dnxhd; ALAC's
extradata is the CodecPrivate, the ALACSpecificConfig MP4's alac box holds.
Opus now reports 48 kHz from Matroska as from MP4, whatever rate the muxer
declared.

ReadSeekSource clears the poison when it recovers the lock, so one
panicked read costs one recovery instead of a seek on every later read.

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

* craft-media: MP4 reports the MPEG audio layer the first frame names

An MP4 track with MPEG audio reported CodecId::Mp3 whatever the layer, since
mp4a + 0x69/0x6B names none (ffmpeg stores MP2 that way), while Matroska
reports Mp1/Mp2/Mp3 from its CodecID. open() now reads the frame header of
each such entry's first sample and codec_params() reports the layer it
names; without a valid header, QuickTime's .mp2 gives Mp2 and anything else
Mp3 as before. CodecConfig::Mp3 is unchanged.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:18:36 +01:00

231 lines
8.4 KiB
Rust

//! craft-media's oracle-test helpers: the generic ones from `craft-media-testkit`, and the MP4
//! fixtures and ffprobe queries the container tests share.
#![allow(dead_code)]
pub use craft_media_testkit::*;
use serde_json::Value;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Fixtures of the `isobmff` tests.
pub fn fixture_dir() -> PathBuf {
fixture_subdir("isobmff")
}
pub fn out_dir() -> PathBuf {
let d = fixture_dir().join("out");
std::fs::create_dir_all(&d).unwrap();
d
}
const V: &[&str] = &["-f", "lavfi", "-i", "testsrc2=size=160x120:rate=25"];
const A: &[&str] = &["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000"];
/// (fixture name, ffmpeg arguments after the global options, output file name derived from the name).
pub fn fixture_specs() -> Vec<(&'static str, Vec<&'static str>)> {
let cat = |parts: &[&[&'static str]]| parts.concat();
vec![
("h264_bframes.mp4", cat(&[V, A, &["-t", "2", "-c:v", "libx264", "-bf", "2", "-g", "12", "-pix_fmt", "yuv420p", "-c:a", "aac"]])),
("h264.mov", cat(&[V, A, &["-t", "2", "-c:v", "libx264", "-bf", "3", "-g", "25", "-pix_fmt", "yuv420p", "-c:a", "pcm_s16le"]])),
(
"hevc.mp4",
cat(&[V, &["-t", "2", "-c:v", "libx265", "-tag:v", "hvc1", "-x265-params", "log-level=error:bframes=3", "-pix_fmt", "yuv420p"]]),
),
("prores.mov", cat(&[V, A, &["-t", "1", "-c:v", "prores_ks", "-profile:v", "3", "-c:a", "pcm_s24le"]])),
("prores4444.mov", cat(&[V, &["-t", "0.5", "-c:v", "prores_ks", "-profile:v", "4", "-pix_fmt", "yuva444p10le"]])),
("mjpeg.mov", cat(&[V, &["-t", "1", "-c:v", "mjpeg", "-q:v", "5"]])),
(
"dnxhd.mov",
vec![
"-f",
"lavfi",
"-i",
"testsrc2=size=1280x720:rate=25",
"-t",
"0.2",
"-c:v",
"dnxhd",
"-profile:v",
"dnxhr_lb",
"-pix_fmt",
"yuv422p",
],
),
("pcm_f32.mov", cat(&[A, &["-t", "1", "-c:a", "pcm_f32le"]])),
("pcm_s16be.mov", cat(&[A, &["-t", "1", "-c:a", "pcm_s16be"]])),
("aac.mp4", cat(&[A, &["-t", "2", "-c:a", "aac", "-b:a", "96k"]])),
("aac_stereo44.m4a", vec!["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=44100", "-t", "1.5", "-ac", "2", "-c:a", "aac"]),
(
"frag.mp4",
cat(&[
V,
A,
&[
"-t",
"3",
"-c:v",
"libx264",
"-bf",
"2",
"-g",
"25",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-movflags",
"frag_keyframe+empty_moov",
],
]),
),
(
"frag_dash.mp4",
cat(&[
V,
&[
"-t",
"3",
"-c:v",
"libx264",
"-bf",
"2",
"-g",
"25",
"-pix_fmt",
"yuv420p",
"-movflags",
"frag_keyframe+empty_moov+default_base_moof",
],
]),
),
("faststart.mp4", cat(&[V, A, &["-t", "2", "-c:v", "libx264", "-bf", "2", "-pix_fmt", "yuv420p", "-c:a", "aac", "-movflags", "+faststart"]])),
(
"tmcd_2997df.mov",
vec!["-f", "lavfi", "-i", "testsrc2=size=160x120:rate=30000/1001", "-t", "1", "-c:v", "mjpeg", "-timecode", "01:00:00;00"],
),
(
"tmcd_23976.mov",
vec![
"-f",
"lavfi",
"-i",
"testsrc2=size=160x120:rate=24000/1001",
"-t",
"1",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-timecode",
"01:00:00:00",
],
),
(
"h264_2997.mp4",
vec!["-f", "lavfi", "-i", "testsrc2=size=160x120:rate=30000/1001", "-t", "2", "-c:v", "libx264", "-bf", "2", "-pix_fmt", "yuv420p"],
),
("flac.mp4", cat(&[A, &["-t", "1", "-c:a", "flac"]])),
("alac.m4a", cat(&[A, &["-t", "1", "-c:a", "alac"]])),
("opus.mp4", cat(&[A, &["-t", "1", "-c:a", "libopus"]])),
("ac3.mp4", cat(&[A, &["-t", "1", "-c:a", "ac3"]])),
("vp9.mp4", cat(&[V, &["-t", "1", "-c:v", "libvpx-vp9", "-deadline", "realtime", "-cpu-used", "8"]])),
("av1.mp4", cat(&[V, &["-t", "1", "-c:v", "libsvtav1", "-preset", "12"]])),
(
"colr_pasp.mov",
cat(&[
V,
&[
"-t",
"0.5",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-color_primaries",
"bt709",
"-color_trc",
"bt709",
"-colorspace",
"bt709",
"-aspect",
"16:9",
],
]),
),
]
}
fn run_ffmpeg(ff: &Path, args: &[&str], out: &Path) -> Option<PathBuf> {
let tmp = temp_path(out);
let mut cmd = Command::new(ff);
cmd.args(["-v", "error", "-y", "-nostdin"]).args(args).arg(&tmp);
run_generator(&mut cmd, &tmp, out, oracles_required())
}
/// Path to a fixture, generating it with ffmpeg if needed. `None` after printing `SKIPPED` if
/// ffmpeg is missing or the encoder isn't available (a failure with `CRAFT_MEDIA_REQUIRE_ORACLES=1`).
pub fn fixture(name: &str) -> Option<PathBuf> {
let ff = ffmpeg()?;
let path = fixture_dir().join(name);
if path.exists() {
return Some(path);
}
if name == "editlist_cut.mp4" {
let src = fixture("h264_bframes.mp4")?;
let s = src.to_str().expect("fixture path is UTF-8").to_string();
return run_ffmpeg(&ff, &["-ss", "0.5", "-i", &s, "-c", "copy"], &path);
}
let (_, args) = fixture_specs().into_iter().find(|(n, _)| *n == name).unwrap_or_else(|| panic!("unknown fixture {name}"));
run_ffmpeg(&ff, &args, &path)
}
pub fn all_fixture_names() -> Vec<&'static str> {
let mut v: Vec<&str> = fixture_specs().iter().map(|(n, _)| *n).collect();
v.push("editlist_cut.mp4");
v
}
/// ffprobe's JSON for `path`, or `None` after printing `SKIPPED` (a failure with
/// `CRAFT_MEDIA_REQUIRE_ORACLES=1`).
pub fn probe_json(path: &Path, what: &[&str]) -> Option<Value> {
let fp = ffprobe()?;
let o = match Command::new(fp).args(["-v", "error", "-of", "json"]).args(what).arg(path).output() {
Ok(o) if o.status.success() => o,
Ok(o) => return skip(&format!("ffprobe failed on {}: {}", path.display(), String::from_utf8_lossy(&o.stderr).trim())),
Err(e) => return skip(&format!("could not run ffprobe: {e}")),
};
match serde_json::from_slice(&o.stdout) {
Ok(v) => Some(v),
Err(e) => skip(&format!("ffprobe output for {} is not JSON: {e}", path.display())),
}
}
/// ffprobe's packets for `path`, or `None` after a skipped probe (see [`probe_json`]).
pub fn packets(path: &Path) -> Option<Vec<Value>> {
probe_json(path, &["-show_packets"]).map(|v| v["packets"].as_array().cloned().unwrap_or_default())
}
/// ffprobe's streams for `path`, or `None` after a skipped probe (see [`probe_json`]).
pub fn streams(path: &Path) -> Option<Vec<Value>> {
probe_json(path, &["-show_streams"]).map(|v| v["streams"].as_array().cloned().unwrap_or_default())
}
pub fn int(v: &Value) -> Option<i64> {
match v {
Value::Number(n) => n.as_i64(),
Value::String(s) => s.parse().ok(),
_ => None,
}
}
/// Decode a file with ffmpeg to the null muxer; returns stderr (empty on success).
pub fn ffmpeg_decode_errors(path: &Path) -> Option<String> {
let ff = ffmpeg()?;
let o = Command::new(ff).args(["-v", "error", "-nostdin", "-i"]).arg(path).args(["-f", "null", "-"]).output().ok()?;
let mut s = String::from_utf8_lossy(&o.stderr).into_owned();
if !o.status.success() {
s.push_str("\n(ffmpeg exited with failure)");
}
Some(s)
}