Files
filmcraft/crates/platform/tests/it/fallback.rs
aiongg 0a881266c8 CI: xtask ci in the dev profile, one test binary per crate, nextest (#2860)
* Tests: one integration-test binary per crate

Each crate with several tests/*.rs files keeps them as modules of tests/it/,
so cargo links one test executable per crate instead of one per file. Files
that change process-wide state or time a workload stay binaries of their own.

* Profiles: line tables for workspace crates, no debug info for dependencies

* Windows ARM64: nightly and on release pushes instead of every push to main

* xtask ci: clippy and tests in the dev profile

The release profile's thin LTO and single codegen unit made linking the test
binaries take most of the gate. The codec and audio crates stay at opt-level 3
in the dev profile, so their tests run at release speed.

* xtask ci: nextest, doctests, no examples, one wasm check

Tests run with cargo nextest when it is installed (cargo test otherwise) and
build with --lib --bins --tests, so examples aren't linked; doctests run
separately. The wasm check keeps going past the first failing crate. VP8
fixture generation locks across processes, since nextest runs each test in
its own.

* VP9 synthetic fixtures: write aside and rename

A test in another process could read a fixture ffmpeg was still writing.

* Fuzz tests: a third of each sweep by default, all of it with FILMCRAFT_FUZZ_ITERS

Every seed, mutation kind and thread count still runs. Header bytes each get
one overwrite value and two bit flips in rotation, so every value and bit
index still occurs across the region. FILMCRAFT_FUZZ_ITERS=N runs the whole
sweep plus N random rounds per seed, as before.

* Platform fallback tests: one failure point per hybrid path

The failure-anywhere test injected a failure at four points around every
restart point; the hybrid's paths differ only in the first GOP, around the
second and last restart points, at the end and never. The replay test needs
the first two GOPs.

* Export tests: shorter ranges with the same checks

The bitrate test measures 0.5–1.5 s, where each preset reaches its own
target (on 0–3 s the simple demo left two presets at the same rate). The
every-preset test exports three frames per preset.

* Audio tests: effects in parallel, a table-driven FFT for the Opus quality metric

The extreme-settings test keeps both settings and the 40 s tail for every
effect, one thread each. The Opus quality metric's FFT builds its twiddles
once and allocates nothing per transform (same algorithm and summation order).

* CI: dev-profile gate with nextest

xtask ci no longer builds with --release, so the release-profile overrides
go; nextest is installed for the test step.

* CI: apt packages through the cached install-apt-packages action

* Review fixes: fuzz tests with a panic hook in their own binaries, loud fixture lock

The fuzz tests of av1, h264, hevc, mpeg2v, vp8 and vp9 count panics through
a process-wide hook, so a panic in another test of the same process would
fail them; they are binaries of their own again. The VP8 fixture lock fails
the test instead of skipping it silently, and the full-sweep doc comments say
how many truncation points the full run samples.

* Fuzz tests: rotate the default subset per run (FILMCRAFT_FUZZ_SEED replays one)

* platform tests: decode-switch tests in their own binaries, one NVENC lock
2026-10-23 13:34:16 -04:00

218 lines
11 KiB
Rust

//! [`HybridDecoder`] fallback on every platform, with a stand-in "hardware" decoder (our software
//! decoder that starts failing after N samples, as a lost hardware session does): the output must
//! be exactly the software decoder's, whatever sample the failure hits. Also: in-band parameter
//! sets that differ from the sample entry's switch to software; identical ones do not; and a
//! damaged sample that the hardware decoded but the software decoder rejects, inside the replayed
//! run, loses no picture.
use crate::common;
use common::*;
use filmcraft_codecs::hw::NalStreamInfo;
use filmcraft_codecs::{DecodedFrame, Result, VideoDecoder};
use filmcraft_platform::HybridDecoder;
/// Decodes like the software decoder until `fail_after` samples were fed, then fails every call
/// (its pending pictures stay retrievable through `flush`, as with a real session).
struct Failing {
inner: Box<dyn VideoDecoder>,
fail_after: usize,
fed: usize,
}
impl VideoDecoder for Failing {
fn decode(&mut self, sample: &[u8], pts: i64) -> Result<Vec<DecodedFrame>> {
self.fed += 1;
if self.fed > self.fail_after {
return Err(filmcraft_codecs::CodecError::Decode("session invalidated (test)".into()));
}
self.inner.decode(sample, pts)
}
fn flush(&mut self) -> Vec<DecodedFrame> {
self.inner.flush()
}
fn reset(&mut self) {
self.inner.reset();
}
fn name(&self) -> &str {
"stand-in hardware"
}
}
fn hybrid(s: &Stream, fail_after: usize) -> HybridDecoder {
let info = NalStreamInfo::from_entry(&s.entry).unwrap().unwrap();
let inner = filmcraft_codecs::software_video_decoder(&s.entry).unwrap();
HybridDecoder::new(Box::new(Failing { inner, fail_after, fed: 0 }), s.entry.clone(), info)
}
#[test]
fn failures_anywhere_continue_with_the_software_output() {
let ff = filmcraft_testkit::require_ffmpeg!();
for (name, _) in FIXTURES {
let Some(path) = named(&ff, name) else { continue };
let s = read_stream(&path);
let syncs: Vec<usize> = (0..s.samples.len()).filter(|&i| s.sync[i]).collect();
let [_, k, .., last] = syncs[..] else { panic!("{name}: three GOPs or more") };
// The stream up to a few samples into its last GOP: past the last failure point the
// hybrid only passes the software decoder's output on.
let samples = &s.samples[..(last + 6).min(s.samples.len())];
let reference = decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), samples);
// The hybrid's paths differ by what the replay log holds and what the hardware had
// pending: in the first GOP (nothing out yet, then pictures held for reordering), at and
// just after the second restart point (the log starts over, or an HEVC CRA keeps the GOP
// before it), at the last one (a CRA drops the GOP before the previous one), at the last
// sample and never. Failing anywhere else takes one of those paths.
let points = [0, 1, 3, 7, k, k + 1, k + 2, k + 5, last, last + 2, samples.len() - 1, samples.len()];
for fail_after in points {
let mut d = hybrid(&s, fail_after);
let out = decode_all(&mut d, samples);
assert_eq!(d.is_hardware(), fail_after >= samples.len(), "{name}: fell back at {fail_after}");
assert_same(&format!("{name} failing after {fail_after} samples"), &out, &reference);
}
// a failure after a seek (reset) into the second GOP
let tail = &samples[k..];
let expect = decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), tail);
let mut d = hybrid(&s, k + 4);
decode_all(&mut d, &samples[..k]);
d.reset();
assert_same(&format!("{name} failing after a seek"), &decode_all(&mut d, tail), &expect);
}
}
#[test]
fn changed_in_band_parameter_sets_switch_to_software() {
let ff = filmcraft_testkit::require_ffmpeg!();
let Some(path) = named(&ff, "h264_high.mp4") else { return };
let s = read_stream(&path);
let info = NalStreamInfo::from_entry(&s.entry).unwrap().unwrap();
let sps = info.parameter_sets[0].clone();
let with_sps = |sps: &[u8], sample: &[u8]| {
let mut v = (sps.len() as u32).to_be_bytes().to_vec();
v.extend_from_slice(sps);
v.extend_from_slice(sample);
v
};
// the same SPS repeated in band at every sync sample: stays in "hardware"
let same: Vec<_> = s.samples.iter().zip(&s.sync).map(|((x, p), &k)| (if k { with_sps(&sps, x) } else { x.clone() }, *p)).collect();
let mut d = hybrid(&s, usize::MAX);
let out = decode_all(&mut d, &same);
assert!(d.is_hardware());
assert_same("repeated SPS", &out, &decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &same));
// a different SPS (same values, another nal_ref_idc: a trailing zero byte is not a change) at
// the second sync sample: software
let mut changed_sps = sps.clone();
changed_sps[0] ^= 0x40;
let k = (1..s.samples.len()).find(|&i| s.sync[i]).unwrap();
let changed: Vec<_> = s.samples.iter().enumerate().map(|(i, (x, p))| (if i == k { with_sps(&changed_sps, x) } else { x.clone() }, *p)).collect();
let mut d = hybrid(&s, usize::MAX);
let out = decode_all(&mut d, &changed);
assert!(!d.is_hardware(), "changed SPS: software");
assert_same("changed SPS", &out, &decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &changed));
}
/// Like [`Failing`], but tolerant of one damaged sample the way a GPU can be: at `damaged_at` it
/// decodes the intact sample instead of the one it is given (which the software decoder rejects).
struct Tolerant {
inner: Box<dyn VideoDecoder>,
intact: Vec<u8>,
damaged_at: usize,
fail_after: usize,
fed: usize,
}
impl VideoDecoder for Tolerant {
fn decode(&mut self, sample: &[u8], pts: i64) -> Result<Vec<DecodedFrame>> {
let i = self.fed;
self.fed += 1;
if self.fed > self.fail_after {
return Err(filmcraft_codecs::CodecError::Decode("session invalidated (test)".into()));
}
self.inner.decode(if i == self.damaged_at { &self.intact } else { sample }, pts)
}
fn flush(&mut self) -> Vec<DecodedFrame> {
self.inner.flush()
}
fn reset(&mut self) {
self.inner.reset();
}
fn name(&self) -> &str {
"stand-in hardware"
}
}
/// Feed `samples` then flush, carrying on past samples the decoder rejects (as playback does);
/// returns the pictures and the indices of the rejected samples.
fn decode_lenient(d: &mut dyn VideoDecoder, samples: &[(Vec<u8>, i64)]) -> (Vec<DecodedFrame>, Vec<usize>) {
let (mut out, mut rejected) = (Vec::new(), Vec::new());
for (i, (s, p)) in samples.iter().enumerate() {
match d.decode(s, *p) {
Ok(f) => out.extend(f),
Err(_) => rejected.push(i),
}
}
out.extend(d.flush());
(out, rejected)
}
/// The sample's NAL unit with its header kept and its payload overwritten: the slice header no
/// longer parses, so the software decoder rejects the sample.
fn damaged(sample: &[u8], nal_header: usize) -> Vec<u8> {
let mut v = sample.to_vec();
let start = (4 + nal_header).min(v.len());
v[start..].fill(0xff);
v
}
/// A replay (after the hardware fails) that meets a sample the software decoder rejects must go
/// on to the end of the replayed run, like decoding in software does, and must not lose the
/// pictures it decoded before that sample: every picture the software decoder outputs for the
/// stream is output, once and in order, and from the next restart point on the pictures are the
/// software decoder's.
#[test]
fn a_sample_software_rejects_during_the_replay_loses_no_pictures() {
let ff = filmcraft_testkit::require_ffmpeg!();
let mut checked = 0;
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 header = if name.starts_with("hevc") { 2 } else { 1 };
// the second restart point, and a disposable picture before it that is not the first sample
let Some(next) = (1..s.samples.len()).find(|&i| s.sync[i]) else { continue };
let Some(d) = (2..next.saturating_sub(2)).find(|&i| info.is_disposable(&s.samples[i].0)) else { continue };
// the first two GOPs: the replay and the restart after it
let end = (next + 1..s.samples.len()).find(|&i| s.sync[i]).unwrap_or(s.samples.len());
let mut stream = s.samples[..end].to_vec();
stream[d].0 = damaged(&s.samples[d].0, header);
let (reference, rejected) = decode_lenient(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &stream);
assert_eq!(rejected, [d], "{name}: the software decoder rejects the damaged sample (and only it)");
let restart = stream[next..].iter().map(|x| x.1).min().unwrap();
// failing at the damaged sample itself (the software decoder rejects the failed sample
// too), and after the hardware had decoded it
for fail_after in [d, d + 1, d + 2, next - 1] {
let tolerant = Tolerant {
inner: filmcraft_codecs::software_video_decoder(&s.entry).unwrap(),
intact: s.samples[d].0.clone(),
damaged_at: d,
fail_after,
fed: 0,
};
let mut h = HybridDecoder::new(Box::new(tolerant), s.entry.clone(), info.clone());
let (out, errors) = decode_lenient(&mut h, &stream);
let what = format!("{name}: sample {d} damaged, hardware failing after {fail_after}");
assert!(!h.is_hardware(), "{what}: fell back");
// the damaged sample is reported as the software decoder reports it, unless the
// hardware had already decoded it
assert_eq!(errors, if fail_after == d { vec![d] } else { vec![] }, "{what}: rejected samples");
let pts: Vec<i64> = out.iter().map(|f| f.pts).collect();
assert!(pts.windows(2).all(|w| w[0] < w[1]), "{what}: pictures once and in order: {pts:?}");
let lost: Vec<i64> = reference.iter().map(|f| f.pts).filter(|p| !pts.contains(p)).collect();
assert!(lost.is_empty(), "{what}: lost pictures at pts {lost:?}");
let from = |v: &[DecodedFrame]| v.iter().position(|f| f.pts >= restart).unwrap_or(v.len());
assert_same(&format!("{what}, from the next restart point"), &out[from(&out)..], &reference[from(&reference)..]);
}
checked += 1;
}
assert_eq!(checked, FIXTURES.len(), "every fixture has a damaged-sample case");
}