mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 10:03:51 +00:00
* 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>
231 lines
8.4 KiB
Rust
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)
|
|
}
|