Files
filmcraft/crates/platform/tests/media_foundation.rs
Davi AndreiandClaude Sonnet 5.5 79cb1d0f8d Windows: VP9 + AV1 hardware decode through Media Foundation / DXVA (#30)
Extends the Windows backend (#141) to VP9 (profiles 0 and 2) and AV1 (main, 8/10-bit), 4:2:0.

- codecs::hw: FrameStreamInfo (vpcC / av1C): profile, bit depth, chroma, picture size and colour as
  the software decoders report them (vp9_color / av1_color are now shared with them), random-access
  samples, in-band format changes; StreamInfo lets HybridDecoder handle NAL and frame-coded streams.
- platform: media_foundation::stream (what each codec needs: DXVA profile, MFT request, input
  format), MfDecoder generalised; VP9 frames / AV1 temporal units go in as they are; the MFT's
  declared picture size (display aperture) is checked against the bitstream's.
- Declined up front: VP9 profiles 1/3, AV1 profiles 1/2, 4:2:2/4:4:4, mono, 12-bit, no codec
  extension, no DXVA profile. A key frame of another size or a different sequence header falls back
  to the software decoder.
- Tests: bit-exact parity (360p / 1080p / 2160p, seeks, resets, flush, hidden frames), forced
  failure, damaged samples and av1C, format change fallback, WebM / Matroska through the media stack.
- Bench: AV1 fixtures with libaom when ffmpeg lacks SVT-AV1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMorBHwct72udS2LiC3ZHp
2026-10-07 12:39:45 -03:00

654 lines
25 KiB
Rust

//! Media Foundation / Direct3D 11 decoding against our software decoders (Windows): 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 = "windows")]
mod common;
use std::time::Duration;
use common::*;
use filmcraft_codecs::VideoDecoder;
use filmcraft_codecs::hw::NalStreamInfo;
use filmcraft_platform::HybridDecoder;
use filmcraft_platform::media_foundation::MfDecoder;
/// 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::media_foundation_factory(&s.entry) {
Some(Ok(d)) => {
assert!(d.name().starts_with("Media Foundation"), "{}", d.name());
Some(d)
}
Some(Err(e)) => panic!("factory error: {e}"),
None => {
eprintln!("SKIPPED: no Media Foundation hardware decoder for {}", s.entry.codec.name());
None
}
}
}
fn software(s: &Stream) -> Box<dyn VideoDecoder> {
filmcraft_codecs::software_video_decoder(&s.entry).unwrap()
}
/// Every picture of `s` from the hardware decoder equals the software decoder's: whole stream,
/// after `reset` + reseek to each later sync sample, after a mid-stream `flush`, and a full pass
/// after all the resets.
fn parity(name: &str, s: &Stream) {
let Some(mut hw) = hardware(s) else { return };
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() >= 40, "{name}: {} pictures", a.len());
assert_same(name, &a, &b);
assert!(hw.name().starts_with("Media Foundation"), "{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));
drained_decoder_restarts_only_at_idr(name, s);
// 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}");
}
}
/// After a `flush` (which drains the MFT) the run can only go on at an IDR picture; from the middle
/// of a GOP the decoder reports an error (the GOP cache seeks after every flush, and the hybrid
/// decoder would replay the run in software). After an IDR the pictures are the software decoder's.
fn drained_decoder_restarts_only_at_idr(name: &str, s: &Stream) {
use filmcraft_codecs::hw::{NalCodec, StreamInfo};
let info = filmcraft_platform::media_foundation::stream_info(&s.entry).unwrap();
let is_idr = |i: usize| match &info {
StreamInfo::Nal(n) => n.nal_types(&s.samples[i].0).iter().any(|&t| if n.codec == NalCodec::H264 { t == 5 } else { matches!(t, 19 | 20) }),
StreamInfo::Frame(f) => f.is_random_access(&s.samples[i].0),
};
let Ok(mut mf) = MfDecoder::new(info.clone()) else { return };
let mid = s.samples.len() / 2;
let Some(idr) = (mid..s.samples.len()).find(|&i| s.sync[i] && is_idr(i)) else { return };
let mut sw = software(s);
let before: Vec<_> = s.samples[..mid].iter().flat_map(|(d, p)| mf.decode(d, *p).unwrap()).collect();
let drained = mf.flush();
assert!(before.len() + drained.len() >= mid.saturating_sub(1), "{name}: flush returns the held pictures");
if mid > 0 && !s.sync[mid] {
assert!(mf.decode(&s.samples[mid].0, s.samples[mid].1).is_err(), "{name}: continuing mid-GOP after a flush is an error");
}
// flush, then an IDR: same pictures as software for the run that follows
mf.reset();
let _ = decode_all(&mut mf, &s.samples[..idr.min(mid + 2)]);
let a = decode_all(&mut mf, &s.samples[idr..]);
let b = decode_all(sw.as_mut(), &s.samples[idr..]);
assert_same(&format!("{name} flush then IDR {idr}"), &a, &b);
}
#[test]
fn bit_exact_with_the_software_decoders() {
let ff = filmcraft_testkit::require_ffmpeg!();
for (name, s) in small_streams(&ff) {
parity(&name, &s);
}
}
/// The small parity fixtures: H.264 High, HEVC Main (open GOP) and Main 10, VP9 profiles 0 / 2 and
/// AV1 8- / 10-bit (640x360, B-frames or hidden reference frames, two GOPs or more).
fn small_streams(ff: &std::path::Path) -> Vec<(String, Stream)> {
let mut out = Vec::new();
for (name, _) in FIXTURES {
if let Some(path) = named(ff, name) {
out.push((name.to_string(), read_stream(&path)));
}
}
for codec in ["vp9", "vp9p2", "av1", "av1p10"] {
if let Some(path) = frame_fixture(ff, codec, "640x360") {
out.push((format!("{codec} 640x360"), read_stream(&path)));
}
}
out
}
/// 1920x1080 and 3840x2160 with B-frames and two GOPs: H.264 High, HEVC Main (open GOP) and
/// HEVC Main 10.
fn large_fixture(ff: &std::path::Path, codec: &str, size: &str) -> Option<std::path::PathBuf> {
let (w, h) = size.split_once('x')?;
let src = format!("testsrc2=s={size}:r=24:d=2,noise=alls=12:allf=t");
let (name, args): (String, Vec<&str>) = match codec {
"h264" => (
format!("h264_{h}.mp4"),
vec![
"-f",
"lavfi",
"-i",
&src,
"-c:v",
"libx264",
"-profile:v",
"high",
"-preset",
"veryfast",
"-x264-params",
"bframes=3:b-pyramid=normal:keyint=24:min-keyint=24:scenecut=0",
"-pix_fmt",
"yuv420p",
],
),
"hevc" => (
format!("hevc_main_{h}.mp4"),
vec![
"-f",
"lavfi",
"-i",
&src,
"-c:v",
"libx265",
"-preset",
"ultrafast",
"-tag:v",
"hvc1",
"-x265-params",
"log-level=error:bframes=4:keyint=24:min-keyint=24:scenecut=0:open-gop=1",
"-pix_fmt",
"yuv420p",
],
),
_ => (
format!("hevc_main10_{h}.mp4"),
vec![
"-f",
"lavfi",
"-i",
&src,
"-c:v",
"libx265",
"-preset",
"ultrafast",
"-profile:v",
"main10",
"-tag:v",
"hvc1",
"-x265-params",
"log-level=error:bframes=4:keyint=24:min-keyint=24:scenecut=0",
"-pix_fmt",
"yuv420p10le",
],
),
};
let _ = w;
fixture(ff, &name, &args)
}
fn large(codec: &str, size: &str) {
let ff = filmcraft_testkit::require_ffmpeg!();
let Some(path) = large_fixture(&ff, codec, size) else { return };
parity(&format!("{codec} {size}"), &read_stream(&path));
}
#[test]
fn h264_1080p_and_2160p_are_bit_exact() {
large("h264", "1920x1080");
large("h264", "3840x2160");
}
#[test]
fn hevc_main_1080p_and_2160p_are_bit_exact() {
large("hevc", "1920x1080");
large("hevc", "3840x2160");
}
#[test]
fn hevc_main10_1080p_and_2160p_are_bit_exact() {
large("hevc10", "1920x1080");
large("hevc10", "3840x2160");
}
#[test]
fn mid_stream_failure_continues_in_software() {
let ff = filmcraft_testkit::require_ffmpeg!();
for (name, s) in small_streams(&ff) {
let name = name.as_str();
let info = filmcraft_platform::media_foundation::stream_info(&s.entry).unwrap();
let Ok(_) = MfDecoder::new(info.clone()) else {
eprintln!("SKIPPED: no Media Foundation hardware decoder for {name}");
continue;
};
let reference = decode_all(software(&s).as_mut(), &s.samples);
assert!(!reference.is_empty(), "{name}");
// 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 = MfDecoder::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, s) in small_streams(&ff) {
let name = name.as_str();
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::media_foundation_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(),
filmcraft_isobmff::CodecConfig::Av1(c) => {
damaged_av1c(name, &s, c);
continue;
}
_ => 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) = MfDecoder::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 Media Foundation 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");
}
}
/// Creating and dropping decoders again and again (every clip on a timeline makes one) neither
/// fails nor leaks Direct3D / Media Foundation objects into the next one.
#[test]
fn repeated_initialisation() {
let ff = filmcraft_testkit::require_ffmpeg!();
let Some(path) = named(&ff, "h264_high.mp4") else { return };
let s = read_stream(&path);
let reference = decode_all(software(&s).as_mut(), &s.samples[..13]);
for round in 0..40 {
let Some(mut hw) = hardware(&s) else { return };
if round % 8 == 0 {
assert_same(&format!("round {round}"), &decode_all(hw.as_mut(), &s.samples[..13]), &reference);
}
}
}
/// Several decoders at once on different threads (the frame workers) share the device and give the
/// software decoder's pictures.
#[test]
fn concurrent_decoders_share_the_device() {
let ff = filmcraft_testkit::require_ffmpeg!();
let jobs: Vec<_> = ["h264_high.mp4", "hevc_main.mp4", "hevc_main10.mp4", "h264_high.mp4", "hevc_main.mp4"]
.into_iter()
.filter_map(|n| named(&ff, n))
.map(|path| {
std::thread::spawn(move || {
let s = read_stream(&path);
let Some(mut hw) = hardware(&s) else { return };
let n = s.samples.len().min(40);
let a = decode_all(hw.as_mut(), &s.samples[..n]);
hw.reset();
let b = decode_all(hw.as_mut(), &s.samples[..n]);
let c = decode_all(software(&s).as_mut(), &s.samples[..n]);
assert_same("concurrent", &a, &c);
assert_same("concurrent, after a reset", &b, &c);
})
})
.collect();
for j in jobs {
j.join().unwrap();
}
}
/// A decoder dropped, used and moved between threads (decoders migrate between frame workers).
#[test]
fn decoder_moves_between_threads() {
let ff = filmcraft_testkit::require_ffmpeg!();
let Some(path) = named(&ff, "hevc_main.mp4") else { return };
let s = read_stream(&path);
let Some(mut hw) = hardware(&s) else { return };
let reference = decode_all(software(&s).as_mut(), &s.samples[..30]);
let samples = s.samples.clone();
let (hw, out) = std::thread::spawn(move || {
let out = decode_all(hw.as_mut(), &samples[..30]);
(hw, out)
})
.join()
.unwrap();
assert_same("first thread", &out, &reference);
let mut hw = hw;
hw.reset();
let samples = s.samples.clone();
let out = std::thread::spawn(move || decode_all(hw.as_mut(), &samples[..30])).join().unwrap();
assert_same("second thread", &out, &reference);
}
// ---- VP9 and AV1 (frame-coded streams: vpcC / av1C) ----
/// A VP9 / AV1 fixture with hidden reference frames (VP9 alt-ref superframes, AV1 alt-ref and
/// show_existing_frame), two GOPs: `codec` is `vp9`, `vp9p2` (10-bit), `av1` or `av1p10`.
fn frame_fixture(ff: &std::path::Path, codec: &str, size: &str) -> Option<std::path::PathBuf> {
let (_, h) = size.split_once('x')?;
let src = format!("testsrc2=s={size}:r=24:d=2,noise=alls=12:allf=t");
let (name, args): (String, Vec<&str>) = match codec {
"vp9" => (
format!("vp9_p0_{h}.mp4"),
vec![
"-f",
"lavfi",
"-i",
&src,
"-c:v",
"libvpx-vp9",
"-profile:v",
"0",
"-b:v",
"0",
"-crf",
"32",
"-deadline",
"good",
"-cpu-used",
"8",
"-g",
"24",
"-auto-alt-ref",
"1",
"-lag-in-frames",
"8",
"-row-mt",
"1",
"-pix_fmt",
"yuv420p",
],
),
"vp9p2" => (
format!("vp9_p2_{h}.mp4"),
vec![
"-f",
"lavfi",
"-i",
&src,
"-c:v",
"libvpx-vp9",
"-profile:v",
"2",
"-b:v",
"0",
"-crf",
"32",
"-deadline",
"good",
"-cpu-used",
"8",
"-g",
"24",
"-auto-alt-ref",
"1",
"-lag-in-frames",
"8",
"-row-mt",
"1",
"-pix_fmt",
"yuv420p10le",
],
),
"av1" => (
format!("av1_p0_{h}.mp4"),
vec![
"-f",
"lavfi",
"-i",
&src,
"-c:v",
"libaom-av1",
"-crf",
"34",
"-b:v",
"0",
"-cpu-used",
"8",
"-row-mt",
"1",
"-g",
"24",
"-lag-in-frames",
"8",
"-pix_fmt",
"yuv420p",
],
),
_ => (
format!("av1_p0_10bit_{h}.mp4"),
vec![
"-f",
"lavfi",
"-i",
&src,
"-c:v",
"libaom-av1",
"-crf",
"34",
"-b:v",
"0",
"-cpu-used",
"8",
"-row-mt",
"1",
"-g",
"24",
"-lag-in-frames",
"8",
"-pix_fmt",
"yuv420p10le",
],
),
};
fixture(ff, &name, &args)
}
fn frame_parity(codec: &str, sizes: &[&str]) {
let ff = filmcraft_testkit::require_ffmpeg!();
for size in sizes {
let Some(path) = frame_fixture(&ff, codec, size) else { continue };
parity(&format!("{codec} {size}"), &read_stream(&path));
}
}
#[test]
fn vp9_profile_0_is_bit_exact() {
frame_parity("vp9", &["640x360", "1920x1080", "3840x2160"]);
}
#[test]
fn vp9_profile_2_10bit_is_bit_exact() {
frame_parity("vp9p2", &["640x360", "1920x1080", "3840x2160"]);
}
#[test]
fn av1_8bit_is_bit_exact() {
frame_parity("av1", &["640x360", "1920x1080", "3840x2160"]);
}
#[test]
fn av1_10bit_is_bit_exact() {
frame_parity("av1p10", &["640x360", "1920x1080", "3840x2160"]);
}
/// Damaged `av1C` sequence headers (bit flips, truncation): the backend declines or the decoder
/// errors / falls back, never crashes or hangs.
fn damaged_av1c(name: &str, s: &Stream, c: &filmcraft_isobmff::Av1Config) {
let mut seed = 0x9E37_79B9u64;
for round in 0..24 {
let mut cfg = c.clone();
if round % 3 == 2 {
cfg.config_obus.truncate(cfg.config_obus.len() * (round + 1) / 30);
} else {
for _ in 0..1 + round % 4 {
let at = (xorshift(&mut seed) as usize) % cfg.config_obus.len().max(1);
if let Some(b) = cfg.config_obus.get_mut(at) {
*b ^= 1 << (xorshift(&mut seed) % 8);
}
}
}
let mut entry = s.entry.clone();
entry.codec = filmcraft_isobmff::CodecConfig::Av1(cfg);
let samples: Vec<_> = s.samples.iter().take(12).cloned().collect();
bounded(&format!("{name} av1C round {round}"), Duration::from_secs(60), move || {
let Some(info) = filmcraft_platform::media_foundation::stream_info(&entry) else { return };
let Ok(mut d) = MfDecoder::new(info) else { return };
for (smp, pts) in &samples {
if d.decode(smp, *pts).is_err() {
break;
}
}
let _ = d.flush();
});
}
}
/// A key frame of another size (VP9) / a sequence header unlike the `av1C` one (AV1) appearing in
/// the middle of a stream is something the hardware session was not set up for: the hybrid decoder
/// continues with the software decoder, whose output is the reference.
#[test]
fn in_band_format_changes_fall_back_to_software() {
let ff = filmcraft_testkit::require_ffmpeg!();
for codec in ["vp9", "av1"] {
let (Some(big), Some(small)) = (frame_fixture(&ff, codec, "1920x1080"), frame_fixture(&ff, codec, "640x360")) else { continue };
let (a, b) = (read_stream(&big), read_stream(&small));
let Some(mut hw) = hardware(&a) else { continue };
let mut sw = software(&a);
// (the second stream's pictures come later in time, as in a real stream)
let samples: Vec<_> = a.samples[..8].iter().cloned().chain(b.samples[..8].iter().map(|(d, p)| (d.clone(), p + 100_000))).collect();
let before = filmcraft_codecs::hw::hw_stats().fallbacks;
let got = decode_all(hw.as_mut(), &samples);
let want = decode_all(sw.as_mut(), &samples);
assert!(filmcraft_codecs::hw::hw_stats().fallbacks > before, "{codec}: the change was counted as a fallback");
assert!(!hw.name().starts_with("Media Foundation"), "{codec}: continued in {}", hw.name());
assert_same(&format!("{codec} 1080p then 360p"), &got, &want);
assert!(got.iter().any(|f| f.frame.width == 640) && got.iter().any(|f| f.frame.width == 1920), "{codec}: both sizes came out");
}
}