mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-26 02:59:51 -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>
233 lines
10 KiB
Rust
233 lines
10 KiB
Rust
//! VideoToolbox decoding against our software decoders (macOS): every picture of H.264 High,
|
|
//! HEVC Main and HEVC Main 10 fixtures with B-frames must be identical (bit-exact planes, colour,
|
|
//! pixel aspect, pts and presentation order), also after `reset` + reseek and a mid-stream
|
|
//! `flush`; a forced mid-stream failure must continue in software with the software decoder's
|
|
//! exact output; damaged samples and parameter sets must give errors or fall back, never crash or
|
|
//! hang. Skips without ffmpeg (fixture generator) or without a hardware decoder.
|
|
#![cfg(target_os = "macos")]
|
|
|
|
mod common;
|
|
|
|
use std::time::Duration;
|
|
|
|
use common::*;
|
|
use filmcraft_codecs::VideoDecoder;
|
|
use filmcraft_codecs::hw::NalStreamInfo;
|
|
use filmcraft_platform::HybridDecoder;
|
|
use filmcraft_platform::videotoolbox::VtDecoder;
|
|
|
|
/// The hardware decoder for a stream, or `None` (skip) when this machine has none for it.
|
|
fn hardware(s: &Stream) -> Option<Box<dyn VideoDecoder>> {
|
|
match filmcraft_platform::videotoolbox_factory(&s.entry) {
|
|
Some(Ok(d)) => {
|
|
assert!(d.name().starts_with("VideoToolbox"), "{}", d.name());
|
|
Some(d)
|
|
}
|
|
Some(Err(e)) => panic!("factory error: {e}"),
|
|
None => {
|
|
eprintln!("SKIPPED: no VideoToolbox hardware decoder for {}", s.entry.codec.name());
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn software(s: &Stream) -> Box<dyn VideoDecoder> {
|
|
filmcraft_codecs::software_video_decoder(&s.entry).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn bit_exact_with_the_software_decoders() {
|
|
let ff = filmcraft_testkit::require_ffmpeg!();
|
|
for (name, _) in FIXTURES {
|
|
let Some(path) = named(&ff, name) else { continue };
|
|
let s = read_stream(&path);
|
|
let Some(mut hw) = hardware(&s) else { continue };
|
|
let mut sw = software(&s);
|
|
let a = decode_all(hw.as_mut(), &s.samples);
|
|
let b = decode_all(sw.as_mut(), &s.samples);
|
|
assert!(a.len() >= 60, "{name}: {} pictures", a.len());
|
|
assert_same(name, &a, &b);
|
|
assert!(hw.name().starts_with("VideoToolbox"), "{name}: stayed in hardware ({})", hw.name());
|
|
|
|
// reset + reseek to each later sync sample, decode a stretch, flush mid-stream
|
|
let syncs: Vec<usize> = (1..s.samples.len()).filter(|&i| s.sync[i]).collect();
|
|
assert!(!syncs.is_empty(), "{name}: more than one GOP");
|
|
for &k in syncs.iter().rev() {
|
|
let end = (k + 17).min(s.samples.len());
|
|
for d in [&mut hw, &mut sw] {
|
|
d.reset();
|
|
}
|
|
let a = decode_all(hw.as_mut(), &s.samples[k..end]);
|
|
let b = decode_all(sw.as_mut(), &s.samples[k..end]);
|
|
assert!(!a.is_empty(), "{name}: pictures after seeking to {k}");
|
|
assert_same(&format!("{name} from sample {k}"), &a, &b);
|
|
}
|
|
// back to the start after seeking around: the whole stream again
|
|
hw.reset();
|
|
sw.reset();
|
|
assert_same(&format!("{name} after resets"), &decode_all(hw.as_mut(), &s.samples), &decode_all(sw.as_mut(), &s.samples));
|
|
// random-access / disposable answers match the software decoder's
|
|
for (smp, _) in &s.samples {
|
|
assert_eq!(hw.is_random_access(smp), sw.is_random_access(smp), "{name}");
|
|
assert_eq!(hw.is_disposable(smp), sw.is_disposable(smp), "{name}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mid_stream_failure_continues_in_software() {
|
|
let ff = filmcraft_testkit::require_ffmpeg!();
|
|
for (name, _) in FIXTURES {
|
|
let Some(path) = named(&ff, name) else { continue };
|
|
let s = read_stream(&path);
|
|
let info = NalStreamInfo::from_entry(&s.entry).unwrap().unwrap();
|
|
let Ok(_) = VtDecoder::new(info.clone()) else {
|
|
eprintln!("SKIPPED: no VideoToolbox hardware decoder for {name}");
|
|
continue;
|
|
};
|
|
let reference = decode_all(software(&s).as_mut(), &s.samples);
|
|
// fail at the first sample, inside the first GOP, at a sync sample and just after one
|
|
let k = (1..s.samples.len()).find(|&i| s.sync[i]).unwrap();
|
|
for fail_at in [0, 5, k, k + 1, k + 9] {
|
|
let mut vt = VtDecoder::new(info.clone()).unwrap();
|
|
vt.fail_after(fail_at as u64);
|
|
let mut d = HybridDecoder::new(Box::new(vt), s.entry.clone(), info.clone());
|
|
let before = filmcraft_codecs::hw::hw_stats().fallbacks;
|
|
let out = decode_all(&mut d, &s.samples);
|
|
assert!(!d.is_hardware(), "{name}: switched to software");
|
|
assert!(filmcraft_codecs::hw::hw_stats().fallbacks > before, "{name}: fallback counted");
|
|
assert_same(&format!("{name} failing at sample {fail_at}"), &out, &reference);
|
|
// the instance stays in software across seeks
|
|
d.reset();
|
|
assert_same(&format!("{name} after the fallback and a reset"), &decode_all(&mut d, &s.samples), &reference);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run `f` on a thread; fail if it panics or takes longer than `limit`.
|
|
fn bounded(what: &str, limit: Duration, f: impl FnOnce() + Send + 'static) {
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
std::thread::spawn(move || {
|
|
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
|
|
let _ = tx.send(r.is_ok());
|
|
});
|
|
match rx.recv_timeout(limit) {
|
|
Ok(true) => {}
|
|
Ok(false) => panic!("{what}: panicked"),
|
|
Err(_) => panic!("{what}: hung"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn damaged_samples_and_parameter_sets_never_crash_or_hang() {
|
|
let ff = filmcraft_testkit::require_ffmpeg!();
|
|
for (name, _) in FIXTURES {
|
|
let Some(path) = named(&ff, name) else { continue };
|
|
let s = read_stream(&path);
|
|
if hardware(&s).is_none() {
|
|
continue;
|
|
}
|
|
// damaged samples (bit flips, truncation, corrupt length prefixes) through the hybrid
|
|
for seed in 1..=6u64 {
|
|
let samples = s.samples.clone();
|
|
let entry = s.entry.clone();
|
|
bounded(&format!("{name} seed {seed}"), Duration::from_secs(60), move || {
|
|
let mut seed = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
|
|
let mut d = filmcraft_platform::videotoolbox_factory(&entry).unwrap().unwrap();
|
|
for (i, (smp, pts)) in samples.iter().enumerate().take(40) {
|
|
let mut m = smp.clone();
|
|
match xorshift(&mut seed) % 4 {
|
|
0 => {
|
|
for _ in 0..1 + xorshift(&mut seed) % 8 {
|
|
let at = (xorshift(&mut seed) as usize) % m.len().max(1);
|
|
if let Some(b) = m.get_mut(at) {
|
|
*b ^= 1 << (xorshift(&mut seed) % 8);
|
|
}
|
|
}
|
|
}
|
|
1 => m.truncate((xorshift(&mut seed) as usize) % m.len().max(1)),
|
|
2 if m.len() > 4 => m[..4].copy_from_slice(&(xorshift(&mut seed) as u32).to_be_bytes()),
|
|
_ => {}
|
|
}
|
|
let _ = d.decode(&m, *pts);
|
|
if i % 13 == 12 {
|
|
d.reset();
|
|
}
|
|
}
|
|
let _ = d.flush();
|
|
});
|
|
}
|
|
// damaged parameter sets: the factory declines or the decoder errors / falls back
|
|
let rec = match &s.entry.codec {
|
|
filmcraft_isobmff::CodecConfig::Avc(a) => a.to_bytes(),
|
|
filmcraft_isobmff::CodecConfig::Hevc(c) => c.to_bytes(),
|
|
_ => continue,
|
|
};
|
|
let hevc = matches!(s.entry.codec, filmcraft_isobmff::CodecConfig::Hevc(_));
|
|
let mut seed = 0x1234_5678u64;
|
|
for round in 0..24 {
|
|
let mut r = rec.clone();
|
|
if round % 3 == 2 {
|
|
r.truncate(r.len() * (round + 1) / 30);
|
|
} else {
|
|
for _ in 0..1 + round % 4 {
|
|
let at = 6 + (xorshift(&mut seed) as usize) % r.len().saturating_sub(6).max(1);
|
|
if let Some(b) = r.get_mut(at) {
|
|
*b ^= 1 << (xorshift(&mut seed) % 8);
|
|
}
|
|
}
|
|
}
|
|
let samples: Vec<_> = s.samples.iter().take(12).cloned().collect();
|
|
bounded(&format!("{name} record round {round}"), Duration::from_secs(60), move || {
|
|
let Some(info) = (if hevc { NalStreamInfo::from_hvcc(&r) } else { NalStreamInfo::from_avcc(&r) }).ok() else { return };
|
|
let Ok(mut vt) = VtDecoder::new(info) else { return };
|
|
for (smp, pts) in &samples {
|
|
if vt.decode(smp, *pts).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
let _ = vt.flush();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 4:2:2 (which our HEVC decoder does not decode): when VideoToolbox takes it, its pictures must
|
|
/// match ffmpeg's decode exactly; when it does not, the factory declines.
|
|
#[test]
|
|
fn hevc_422_10bit_matches_ffmpeg_when_the_hardware_takes_it() {
|
|
let ff = filmcraft_testkit::require_ffmpeg!();
|
|
let args = [
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"testsrc2=s=640x360:r=24:d=1,noise=alls=12:allf=t",
|
|
"-c:v",
|
|
"libx265",
|
|
"-preset",
|
|
"fast",
|
|
"-tag:v",
|
|
"hvc1",
|
|
"-x265-params",
|
|
"log-level=error:bframes=3:keyint=12:min-keyint=12:scenecut=0",
|
|
"-pix_fmt",
|
|
"yuv422p10le",
|
|
];
|
|
let Some(path) = fixture(&ff, "hevc_422_10.mp4", &args) else { return };
|
|
let s = read_stream(&path);
|
|
let Some(mut hw) = hardware(&s) else { return };
|
|
let out = decode_all(hw.as_mut(), &s.samples);
|
|
let o =
|
|
std::process::Command::new(&ff).args(["-v", "error", "-i", path.to_str().unwrap(), "-f", "rawvideo", "-pix_fmt", "yuv422p10le", "-"]).output().unwrap();
|
|
let (w, h) = (640usize, 360usize);
|
|
let fsize = (w * h + 2 * (w / 2) * h) * 2;
|
|
assert_eq!(out.len(), o.stdout.len() / fsize, "frame count");
|
|
for (i, f) in out.iter().enumerate() {
|
|
let raw: Vec<u16> = o.stdout[i * fsize..(i + 1) * fsize].as_chunks::<2>().0.iter().map(|b| u16::from_le_bytes([b[0], b[1]])).collect();
|
|
let filmcraft_frame::PixelData::Yuv16 { planes, chroma, bits, .. } = &f.frame.data else { panic!("16-bit planes") };
|
|
assert_eq!((*chroma, *bits), (filmcraft_frame::Chroma::C422, 10));
|
|
let (y, c) = (w * h, (w / 2) * h);
|
|
assert!(planes[0][..] == raw[..y] && planes[1][..] == raw[y..y + c] && planes[2][..] == raw[y + c..], "frame {i} differs from ffmpeg");
|
|
}
|
|
}
|