mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-25 18:48:09 -06:00
- New crates/platform (filmcraft-platform, L5): OS media FFI only. The one crate allowed `unsafe` (own [lints] with unsafe_code = "deny", #[allow(unsafe_code)] only on the videotoolbox module, SAFETY comment on every block, safe Result-returning public API). AGENTS.md §0.3 names the exception; docs/adr/0001-platform-ffi.md records why and the rules. - VideoToolbox decoder for avcC / hvcC, 8/10-bit 4:2:0 and 4:2:2: session from the sample entry's parameter sets with hardware required, asynchronous decode (2 in flight), biplanar CVPixelBuffer copied to planar Yuv8/Yuv16, reorder buffer of the stream's own depth, RASL skipped after a starting CRA. Output callback under catch_unwind. - HybridDecoder: transparent mid-stream fallback to the software decoder (replays samples since the last restart point, drops already-returned pictures). New codecs::software_video_decoder (built-in factories only) and codecs::hw (switch, counters, NalStreamInfo shared with software decoders; vui_color / sar_par helpers now used by both). - Settings ▸ Playback ▸ Hardware decoding: Auto / Off (engine applies it to the codecs switch); desktop app, CLI/MCP and bench call register(). - perf.stats decode.framesDecoded and decode.hardware counters. - Tests: bit-exact parity with the software decoders (H.264 High, HEVC Main open GOP, Main 10; resets, reseeks, flushes), forced-failure fallback, mutation fuzz of samples and parameter sets, cross-platform fallback with a stand-in decoder, setting Off, HEVC 4:2:2 vs ffmpeg. - Bench --hw auto|off; results in docs/performance.md (4K CPU/frame ~86-119 ms -> ~4 ms). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
5.8 KiB
Rust
175 lines
5.8 KiB
Rust
//! Shared helpers for the platform tests: ffmpeg-made fixtures (external generator only, never
|
|
//! linked), MP4 sample reading, and decoding a run of samples to compare decoders.
|
|
#![allow(dead_code)]
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
|
|
use filmcraft_codecs::{DecodedFrame, VideoDecoder};
|
|
use filmcraft_frame::PixelData;
|
|
use filmcraft_isobmff::SampleEntry;
|
|
|
|
/// The parity fixtures: (file, ffmpeg arguments). B-frames everywhere (reordering), a 360-line
|
|
/// picture (coded 368: cropping), two GOPs or more (seeks), and an open-GOP HEVC stream (CRA with
|
|
/// RASL pictures).
|
|
pub const FIXTURES: &[(&str, &[&str])] = &[
|
|
(
|
|
"h264_high.mp4",
|
|
&[
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"testsrc2=s=640x360:r=24:d=3,noise=alls=12:allf=t",
|
|
"-c:v",
|
|
"libx264",
|
|
"-profile:v",
|
|
"high",
|
|
"-preset",
|
|
"fast",
|
|
"-x264-params",
|
|
"bframes=3:b-pyramid=normal:keyint=24:min-keyint=24:scenecut=0",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
],
|
|
),
|
|
(
|
|
"hevc_main.mp4",
|
|
&[
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"testsrc2=s=640x360:r=24:d=3,noise=alls=12:allf=t",
|
|
"-c:v",
|
|
"libx265",
|
|
"-preset",
|
|
"fast",
|
|
"-tag:v",
|
|
"hvc1",
|
|
"-x265-params",
|
|
"log-level=error:bframes=4:keyint=24:min-keyint=24:scenecut=0:open-gop=1",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
],
|
|
),
|
|
(
|
|
"hevc_main10.mp4",
|
|
&[
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"testsrc2=s=640x360:r=24:d=3,noise=alls=12:allf=t",
|
|
"-c:v",
|
|
"libx265",
|
|
"-preset",
|
|
"fast",
|
|
"-profile:v",
|
|
"main10",
|
|
"-tag:v",
|
|
"hvc1",
|
|
"-x265-params",
|
|
"log-level=error:bframes=4:keyint=24:min-keyint=24:scenecut=0",
|
|
"-pix_fmt",
|
|
"yuv420p10le",
|
|
],
|
|
),
|
|
];
|
|
|
|
pub fn dir() -> PathBuf {
|
|
filmcraft_testkit::fixtures_dir("platform")
|
|
}
|
|
|
|
/// Generate `name` with `ffmpeg -y -v error <args> <out>` (cached).
|
|
pub fn fixture(ff: &Path, name: &str, args: &[&str]) -> Option<PathBuf> {
|
|
let out = dir().join(name);
|
|
filmcraft_testkit::fixtures::generate(&out, |tmp| {
|
|
let st = Command::new(ff).args(["-y", "-v", "error"]).args(args).arg(tmp).stdin(Stdio::null()).status();
|
|
match st {
|
|
Ok(s) if s.success() => true,
|
|
other => {
|
|
eprintln!("fixture {name}: ffmpeg failed: {other:?}");
|
|
false
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/// The fixture by name.
|
|
pub fn named(ff: &Path, name: &str) -> Option<PathBuf> {
|
|
let (_, args) = FIXTURES.iter().find(|f| f.0 == name)?;
|
|
fixture(ff, name, args)
|
|
}
|
|
|
|
/// A video track's sample entry, samples (decode order) with pts, and sync flags.
|
|
pub struct Stream {
|
|
pub entry: SampleEntry,
|
|
pub samples: Vec<(Vec<u8>, i64)>,
|
|
pub sync: Vec<bool>,
|
|
}
|
|
|
|
pub fn read_stream(path: &Path) -> Stream {
|
|
let bytes = std::fs::read(path).unwrap();
|
|
let file = filmcraft_isobmff::open(bytes.clone()).unwrap();
|
|
let t = file.track_of_kind(filmcraft_isobmff::TrackKind::Video).unwrap();
|
|
let track = &file.tracks[t];
|
|
let samples = (0..track.samples.len()).map(|i| (file.read_sample(&bytes, t, i).unwrap(), track.samples[i].pts)).collect();
|
|
Stream { entry: track.entries[0].clone(), samples, sync: track.samples.iter().map(|s| s.is_sync).collect() }
|
|
}
|
|
|
|
/// Feed `samples` then flush.
|
|
pub fn decode_all(d: &mut dyn VideoDecoder, samples: &[(Vec<u8>, i64)]) -> Vec<DecodedFrame> {
|
|
let mut out = Vec::new();
|
|
for (s, p) in samples {
|
|
out.extend(d.decode(s, *p).unwrap_or_else(|e| panic!("{}: {e}", d.name())));
|
|
}
|
|
out.extend(d.flush());
|
|
out
|
|
}
|
|
|
|
/// Assert two decoders' outputs are the same pictures (pts order, size, format, colour, aspect
|
|
/// and every sample).
|
|
pub fn assert_same(what: &str, a: &[DecodedFrame], b: &[DecodedFrame]) {
|
|
let pa: Vec<i64> = a.iter().map(|f| f.pts).collect();
|
|
let pb: Vec<i64> = b.iter().map(|f| f.pts).collect();
|
|
assert_eq!(pa, pb, "{what}: pts sequence");
|
|
for (x, y) in a.iter().zip(b) {
|
|
let (fx, fy) = (&x.frame, &y.frame);
|
|
assert_eq!((fx.width, fx.height, fx.par, fx.color), (fy.width, fy.height, fy.par, fy.color), "{what}: pts {} geometry / colour", x.pts);
|
|
match (&fx.data, &fy.data) {
|
|
(PixelData::Yuv8 { planes: p, chroma: c, .. }, PixelData::Yuv8 { planes: q, chroma: d, .. }) => {
|
|
assert_eq!(c, d, "{what}: chroma");
|
|
for i in 0..3 {
|
|
assert!(p[i] == q[i], "{what}: pts {} plane {i} differs (max diff {})", x.pts, max_diff8(&p[i], &q[i]));
|
|
}
|
|
}
|
|
(PixelData::Yuv16 { planes: p, chroma: c, bits: bx, .. }, PixelData::Yuv16 { planes: q, chroma: d, bits: by, .. }) => {
|
|
assert_eq!((c, bx), (d, by), "{what}: chroma / bits");
|
|
for i in 0..3 {
|
|
assert!(p[i] == q[i], "{what}: pts {} plane {i} differs (max diff {})", x.pts, max_diff16(&p[i], &q[i]));
|
|
}
|
|
}
|
|
_ => panic!("{what}: pts {}: pixel formats differ", x.pts),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn max_diff8(a: &[u8], b: &[u8]) -> String {
|
|
if a.len() != b.len() {
|
|
return format!("sizes {} vs {}", a.len(), b.len());
|
|
}
|
|
a.iter().zip(b).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0).to_string()
|
|
}
|
|
|
|
fn max_diff16(a: &[u16], b: &[u16]) -> String {
|
|
if a.len() != b.len() {
|
|
return format!("sizes {} vs {}", a.len(), b.len());
|
|
}
|
|
a.iter().zip(b).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0).to_string()
|
|
}
|
|
|
|
pub fn xorshift(seed: &mut u64) -> u64 {
|
|
*seed ^= *seed << 13;
|
|
*seed ^= *seed >> 7;
|
|
*seed ^= *seed << 17;
|
|
*seed
|
|
}
|