Skip to main content

moq_video/decode/
decoder.rs

1//! Video decoder front end.
2//!
3//! Prepares each container frame for a [`Backend`](super::backend::Backend):
4//! converts out-of-band payloads (avc1 / hvc1: length-prefixed NALs with the
5//! parameter sets in the description) to Annex-B and injects those parameter sets
6//! ahead of keyframes, leaving in-band H.264 / H.265 payloads (avc3 / hev1,
7//! already Annex-B inline) and AV1 OBU temporal units untouched. Gates output
8//! until the first keyframe so the backend never sees a delta frame it can't
9//! decode.
10//!
11//! A track that says avc1 and carries no description is read as Annex-B rather
12//! than refused. A browser encoding with WebCodecs' `annexb` output keeps the
13//! avc1 label while putting its parameter sets in band, which is what
14//! `@moq/publish` does today. Length-prefixed payloads without their parameter
15//! sets could not be decoded anyway, so the lenient reading only ever turns an
16//! error into a picture.
17
18use std::time::Duration;
19
20use bytes::Bytes;
21use hang::catalog::{AV1, VideoCodec, VideoConfig};
22use moq_mux::codec::{annexb, h264, h265};
23use moq_net::Timestamp;
24
25use super::backend::{self, Backend, Codec};
26use crate::{Error, Frame, Size};
27
28/// Which decoder implementation to use. `#[non_exhaustive]` so new selection
29/// strategies can be added without breaking external `match`es.
30#[derive(Clone, Debug, Default, PartialEq, Eq)]
31#[non_exhaustive]
32pub enum Kind {
33	/// Prefer a platform hardware decoder, fall back to software.
34	#[default]
35	Auto,
36	/// Hardware only; error if none is available.
37	Hardware,
38	/// Software (openh264) only.
39	Software,
40	/// A specific backend by name, e.g. `"videotoolbox"`, `"mediacodec"`,
41	/// `"nvdec"`, `"vaapi"`, `"v4l2"`, or `"openh264"`.
42	Named(String),
43}
44
45/// Decoder configuration.
46///
47/// `#[non_exhaustive]`: build via [`Config::new`] (or `default()`) and set the
48/// optional fields, so future knobs don't break callers.
49#[derive(Clone, Debug, Default)]
50#[non_exhaustive]
51pub struct Config {
52	/// Which backend to use.
53	pub kind: Kind,
54	/// Upper bound on buffering before a stalled group is skipped. `None` uses
55	/// the moq-mux default (skip aggressively); set it to your playout buffer for
56	/// a softer skip. Forwarded to the container consumer's `with_latency`.
57	pub latency_max: Option<Duration>,
58	/// Ask the decoder to emit frames at this size (both dimensions even) instead
59	/// of the stream's native one. Best effort: a hardware decoder with a
60	/// built-in scaler (NVDEC) honors it for free, other backends ignore it.
61	/// Check each [`Frame`](crate::Frame)'s dimensions and scale the remainder
62	/// yourself.
63	pub resize: Option<Size>,
64	/// Ask the decoder to leave each picture on the GPU, as the surface the
65	/// hardware decoded it into, rather than downloading it to CPU memory.
66	///
67	/// For a consumer that draws the frames, `render::Renderer` imports such a
68	/// surface directly, so the picture never touches system memory. Off by
69	/// default because it is not free to a consumer that does not draw: handing a
70	/// surface out retires it from the decoder's recycling pool, which costs an
71	/// allocation per picture, and a CPU consumer then pays the download it would
72	/// have paid anyway.
73	///
74	/// Best effort, like [`resize`](Self::resize): only the VAAPI backend honors
75	/// it today and the others ignore it, so match on each
76	/// [`Frame`](crate::Frame)'s surface rather than assuming. A frame that does
77	/// come back GPU-resident still answers
78	/// [`Surface::into_i420`](crate::Surface::into_i420), so nothing downstream
79	/// breaks on it.
80	pub gpu_frames: bool,
81}
82
83impl Config {
84	/// A default config: automatic backend selection, default latency.
85	pub fn new() -> Self {
86		Self::default()
87	}
88}
89
90/// How to turn a container payload into a backend access unit.
91enum Conversion {
92	/// The payload is already in the backend's input framing: Annex-B for avc3 /
93	/// hev1, OBU temporal units for AV1.
94	Passthrough,
95	/// avc1 / hvc1: length-prefixed NALs with the parameter sets out-of-band (in
96	/// the avcC / hvcC description). Replace the length prefixes with start codes
97	/// and prepend `keyframe_prefix` (the parameter sets) ahead of every keyframe.
98	LengthPrefixed { length_size: usize, keyframe_prefix: Bytes },
99}
100
101/// Decodes container payloads (the codec bitstream) into raw [`Frame`]s.
102///
103/// The bring-your-own-payload layer under [`Consumer`](super::Consumer): use it
104/// when the frames don't come from a plain track subscription, e.g. a transcoder
105/// serving individually fetched groups. Feed it the payload of each container
106/// frame in decode order; it handles avc1/hvc1 -> Annex-B conversion, passes
107/// AV1 OBU temporal units through, and gates output until the first keyframe.
108pub struct Decoder {
109	backend: Box<dyn Backend>,
110	conversion: Conversion,
111	got_keyframe: bool,
112}
113
114impl Decoder {
115	/// Build a decoder for the catalog's video config. Errors if the codec is
116	/// not supported by the native backends.
117	pub fn new(catalog: &VideoConfig, config: &Config) -> Result<Self, Error> {
118		let (codec, conversion) = match &catalog.codec {
119			VideoCodec::H264(h264) => {
120				let conversion = match (h264.inline, catalog.description.as_ref()) {
121					(true, _) => Conversion::Passthrough,
122					(false, Some(avcc)) => {
123						let params = h264::Avcc::parse(avcc).map_err(moq_mux::Error::from)?;
124						let keyframe_prefix = annexb::build_prefix(params.sps.iter().chain(params.pps.iter()));
125						Conversion::LengthPrefixed {
126							length_size: params.length_size,
127							keyframe_prefix,
128						}
129					}
130					(false, None) => {
131						tracing::warn!("avc1 track has no avcC description; reading it as Annex-B");
132						Conversion::Passthrough
133					}
134				};
135				(Codec::H264, conversion)
136			}
137			VideoCodec::H265(h265) => {
138				let conversion = if h265.in_band {
139					Conversion::Passthrough
140				} else {
141					let hvcc = catalog.description.as_ref().ok_or_else(|| {
142						Error::Codec(anyhow::anyhow!("hvc1 H.265 track is missing its hvcC description"))
143					})?;
144					let params = h265::Hvcc::parse(hvcc).map_err(moq_mux::Error::from)?;
145					let keyframe_prefix =
146						annexb::build_prefix(params.vps.iter().chain(params.sps.iter()).chain(params.pps.iter()));
147					Conversion::LengthPrefixed {
148						length_size: params.length_size,
149						keyframe_prefix,
150					}
151				};
152				(Codec::H265, conversion)
153			}
154			VideoCodec::AV1(av1) if is_supported_av1(av1) => (Codec::Av1, Conversion::Passthrough),
155			other => return Err(Error::UnsupportedCodec(other.to_string())),
156		};
157
158		let backend = backend::open(codec, config)?;
159		tracing::debug!(decoder = backend.name(), "opened video decoder");
160		Ok(Self {
161			backend,
162			conversion,
163			got_keyframe: false,
164		})
165	}
166
167	/// The decoder backend name in use, e.g. `"videotoolbox"`.
168	pub fn name(&self) -> &str {
169		self.backend.name()
170	}
171
172	/// Decode one container frame, returning zero or more raw frames. `timestamp` is
173	/// this frame's presentation time; it rides through the decoder and comes back on
174	/// each output frame, so a reordering decoder (B-frames) stamps every picture
175	/// with its own presentation time rather than this access unit's. With no
176	/// reordering the two coincide.
177	pub fn decode(&mut self, payload: &Bytes, timestamp: Timestamp, keyframe: bool) -> Result<Vec<Frame>, Error> {
178		// Wait for the first keyframe: a decoder started mid-GOP can't decode
179		// delta frames, and the parameter sets ride along with the keyframe.
180		if !self.got_keyframe {
181			if !keyframe {
182				return Ok(Vec::new());
183			}
184			self.got_keyframe = true;
185		}
186
187		let access_unit = match &self.conversion {
188			// Cheap refcount bump; the backend splits codec units off this buffer.
189			Conversion::Passthrough => payload.clone(),
190			Conversion::LengthPrefixed {
191				length_size,
192				keyframe_prefix,
193			} => {
194				let prefix = keyframe.then(|| keyframe_prefix.as_ref());
195				annexb::from_length_prefixed(payload, *length_size, prefix).map_err(moq_mux::Error::from)?
196			}
197		};
198
199		self.backend.decode(access_unit, timestamp, keyframe)
200	}
201
202	/// Return the frames the backend still holds once the stream has ended.
203	///
204	/// Call this after the last access unit and before dropping the decoder. The
205	/// decoder remains reusable and waits for a keyframe before accepting the
206	/// next stream.
207	pub fn flush(&mut self) -> Result<Vec<Frame>, Error> {
208		self.got_keyframe = false;
209		self.backend.flush()
210	}
211}
212
213fn is_supported_av1(av1: &AV1) -> bool {
214	av1.bitdepth == 8 && !av1.mono_chrome && av1.chroma_subsampling_x && av1.chroma_subsampling_y
215}
216
217#[cfg(test)]
218mod tests {
219	use moq_net::Timestamp;
220
221	use super::backend::{self, Codec};
222	use crate::encode::{Config as EncodeConfig, Encoder, Kind as EncodeKind};
223	use crate::frame::I420;
224	use crate::{Frame, Surface};
225
226	/// The `index`th frame of a flat `size` stream at 30fps, every pixel at RGB
227	/// `level`.
228	fn flat_frame(index: u64, level: u8, size: crate::Size) -> Frame {
229		let rgba = vec![level; size.pixels() as usize * 4];
230		let surface = Surface::rgba(&rgba, size).unwrap();
231		Frame::new(surface, Timestamp::from_micros(index * 33_333).unwrap())
232	}
233
234	/// The `index`th frame of a mid-gray 320x240 stream, at 30fps.
235	fn gray_frame(index: u64) -> Frame {
236		flat_frame(index, 0x80, gray_size())
237	}
238
239	/// Assert a decoded picture is the expected size and looks like the gray frame
240	/// we encoded. Mid-gray RGBA (0x80) is a flat picture: BT.601 limited-range
241	/// luma near 125 and neutral chroma near 128. Averaging each plane catches
242	/// plane swaps, stride bugs, and a misread Y/UV split that a size check misses.
243	fn assert_gray(i420: &I420, width: u32, height: u32) {
244		assert_eq!(i420.width, width);
245		assert_eq!(i420.height, height);
246		let luma = (width * height) as usize;
247		// Tightly-packed I420: luma + two quarter-size chroma planes.
248		assert_eq!(i420.data.len(), luma * 3 / 2);
249
250		let avg = |plane: &[u8]| plane.iter().map(|&b| b as u32).sum::<u32>() / plane.len() as u32;
251		let y = avg(&i420.data[..luma]);
252		let u = avg(&i420.data[luma..luma + luma / 4]);
253		let v = avg(&i420.data[luma + luma / 4..]);
254		assert!((110..=140).contains(&y), "luma {y} off for a gray frame");
255		assert!((118..=138).contains(&u), "u {u} off for a gray frame");
256		assert!((118..=138).contains(&v), "v {v} off for a gray frame");
257	}
258
259	/// Encode 10 gray frames with `encoder`, decode them through `decoder`, and
260	/// assert each decoded picture round-trips. Keyframe gating is exercised (the
261	/// first packet is a keyframe with inline parameter sets).
262	fn round_trip(mut encoder: Encoder, mut decoder: Box<dyn backend::Backend>, expect_name: &str) {
263		assert_eq!(decoder.name(), expect_name);
264
265		let mut decoded = Vec::new();
266		for i in 0..10u64 {
267			let keyframe = i == 0;
268			if keyframe {
269				encoder.keyframe();
270			}
271			// Distinct, spread-apart timestamps so a round-tripped value is unambiguous.
272			for encoded in encoder.encode(&gray_frame(i)).unwrap() {
273				decoded.extend(decoder.decode(encoded.payload, encoded.timestamp, keyframe).unwrap());
274			}
275		}
276		decoded.extend(decoder.flush().unwrap());
277
278		assert!(!decoded.is_empty(), "decoder produced no frames");
279		for out in &decoded {
280			assert_gray(&out.surface.to_i420().unwrap(), 320, 240);
281		}
282
283		// The timestamp rides through the codec and comes back on each picture,
284		// including any tail released by the drain. It returns in presentation order:
285		// strictly increasing and drawn from the values we fed.
286		let micros: Vec<u128> = decoded.iter().map(|d| d.timestamp.as_micros()).collect();
287		assert!(
288			micros.windows(2).all(|w| w[0] < w[1]),
289			"decoded timestamps not strictly increasing: {micros:?}"
290		);
291		assert!(
292			micros.iter().all(|&t| t % 33_333 == 0 && t < 333_330),
293			"decoded timestamp outside the fed set: {micros:?}"
294		);
295	}
296
297	/// A decoder config selecting one backend by kind.
298	fn decode_config(kind: super::Kind) -> super::Config {
299		super::Config {
300			kind,
301			..super::Config::new()
302		}
303	}
304
305	/// An openh264 (software H.264) encoder for a `size` test stream at 30fps.
306	fn h264_software_encoder(size: crate::Size) -> Encoder {
307		Encoder::new(&EncodeConfig {
308			kind: EncodeKind::Software,
309			..EncodeConfig::new(size.width, size.height, 30)
310		})
311		.expect("openh264 encoder")
312	}
313
314	/// The size the gray test stream is encoded at.
315	fn gray_size() -> crate::Size {
316		crate::Size::new(320, 240)
317	}
318
319	#[test]
320	fn openh264_round_trip() {
321		let decoder = backend::open(Codec::H264, &decode_config(super::Kind::Software)).expect("openh264 decoder");
322		round_trip(h264_software_encoder(gray_size()), decoder, "openh264");
323	}
324
325	/// A description-less avc1 track from WebCodecs carries Annex-B payloads with
326	/// its parameter sets in band, the only framing that can decode without avcC.
327	#[test]
328	fn avc1_without_avcc_decodes_as_annexb() {
329		// The catalog shape observed from @moq/publish: `"codec": "avc1.640028"`
330		// and no `description`.
331		let h264 = hang::catalog::H264 {
332			inline: false,
333			profile: 0x64,
334			constraints: 0x00,
335			level: 0x28,
336		};
337		let catalog = hang::catalog::VideoConfig::new(h264);
338		assert_eq!(catalog.codec.to_string(), "avc1.640028");
339		assert!(catalog.description.is_none());
340
341		let mut decoder = super::Decoder::new(&catalog, &decode_config(super::Kind::Software))
342			.expect("a description-less avc1 track opens rather than erroring");
343		assert!(
344			matches!(decoder.conversion, super::Conversion::Passthrough),
345			"a description-less avc1 track is read as Annex-B"
346		);
347
348		// openh264 emits Annex-B access units with SPS/PPS inline ahead of each
349		// IDR, which is the bitstream WebCodecs produces in `annexb` format.
350		let mut encoder = h264_software_encoder(gray_size());
351		let mut decoded = Vec::new();
352		for i in 0..5u64 {
353			let keyframe = i == 0;
354			if keyframe {
355				encoder.keyframe();
356			}
357			for encoded in encoder.encode(&gray_frame(i)).unwrap() {
358				assert!(
359					encoded.payload.starts_with(&[0, 0, 0, 1]) || encoded.payload.starts_with(&[0, 0, 1]),
360					"the test feeds Annex-B, not length-prefixed NALs"
361				);
362				decoded.extend(decoder.decode(&encoded.payload, encoded.timestamp, keyframe).unwrap());
363			}
364		}
365
366		assert!(!decoded.is_empty(), "decoder produced no frames");
367		for out in &decoded {
368			assert_gray(&out.surface.to_i420().unwrap(), 320, 240);
369		}
370	}
371
372	#[test]
373	fn av1_is_supported_by_hardware_only() {
374		let catalog = hang::catalog::VideoConfig::new(hang::catalog::AV1::default());
375		let config = decode_config(super::Kind::Software);
376		let Err(err) = super::Decoder::new(&catalog, &config) else {
377			panic!("software AV1 decode unexpectedly opened");
378		};
379		assert!(matches!(err, crate::Error::NoDecoder(_)));
380	}
381
382	#[test]
383	fn av1_rejects_unsupported_catalog_shape() {
384		let av1 = hang::catalog::AV1 {
385			bitdepth: 10,
386			..hang::catalog::AV1::default()
387		};
388		let catalog = hang::catalog::VideoConfig::new(av1);
389		let config = decode_config(super::Kind::Auto);
390		let Err(err) = super::Decoder::new(&catalog, &config) else {
391			panic!("10-bit AV1 decode unexpectedly opened");
392		};
393		assert!(matches!(err, crate::Error::UnsupportedCodec(_)));
394	}
395
396	#[cfg(target_os = "macos")]
397	#[test]
398	fn videotoolbox_round_trip() {
399		let decoder = backend::open(Codec::H264, &decode_config(super::Kind::Named("videotoolbox".into())))
400			.expect("videotoolbox decoder");
401		round_trip(h264_software_encoder(gray_size()), decoder, "videotoolbox");
402	}
403
404	/// Encode `count` gray frames and decode them, returning the decoded pictures.
405	/// The shared setup for the residency and re-encode tests below.
406	#[cfg(target_os = "macos")]
407	fn decode_gray(count: u64) -> Vec<Frame> {
408		let mut encoder = h264_software_encoder(gray_size());
409		let mut decoder = backend::open(Codec::H264, &decode_config(super::Kind::Named("videotoolbox".into())))
410			.expect("videotoolbox decoder");
411
412		let mut decoded = Vec::new();
413		for i in 0..count {
414			let keyframe = i == 0;
415			if keyframe {
416				encoder.keyframe();
417			}
418			for encoded in encoder.encode(&gray_frame(i)).unwrap() {
419				decoded.extend(decoder.decode(encoded.payload, encoded.timestamp, keyframe).unwrap());
420			}
421		}
422
423		assert!(!decoded.is_empty(), "decoder produced no frames");
424		decoded
425	}
426
427	/// VideoToolbox hands back its `CVPixelBuffer` rather than packing to I420 in
428	/// the output callback, which is what leaves a render or re-encode path free of
429	/// a CPU round trip. `round_trip` above only checks the pixels, so it passes
430	/// either way: this is the test that pins the frame's residency.
431	#[cfg(target_os = "macos")]
432	#[test]
433	fn videotoolbox_decode_stays_gpu_resident() {
434		for out in &decode_gray(3) {
435			assert!(
436				matches!(out.surface, Surface::PixelBuffer(_)),
437				"VideoToolbox decode downloaded to the CPU instead of keeping its surface"
438			);
439		}
440	}
441
442	/// The multi-rung transcode path stays on hardware through decode, resize, and
443	/// encode. The residency assertion catches a CPU fallback even when the pixels
444	/// and dimensions still look right.
445	#[cfg(target_os = "macos")]
446	#[test]
447	fn videotoolbox_resized_surface_reencodes_in_place() {
448		let decoded = decode_gray(3);
449		let resized: Vec<_> = decoded
450			.iter()
451			.map(|frame| frame.resize(crate::Size::new(160, 120)).unwrap())
452			.collect();
453		for frame in &resized {
454			assert_eq!(frame.size(), crate::Size::new(160, 120));
455			assert!(
456				matches!(frame.surface, Surface::PixelBuffer(_)),
457				"VideoToolbox resize downloaded to the CPU"
458			);
459		}
460
461		let encoder = Encoder::new(&EncodeConfig {
462			kind: EncodeKind::Named("videotoolbox".into()),
463			..EncodeConfig::new(160, 120, 30)
464		});
465		let Ok(mut encoder) = encoder else {
466			eprintln!("skipping: no VideoToolbox H.264 hardware encoder available");
467			return;
468		};
469
470		let mut packets = 0;
471		for (i, out) in resized.iter().enumerate() {
472			if i == 0 {
473				encoder.keyframe();
474			}
475			packets += encoder.encode(out).unwrap().len();
476		}
477		packets += encoder.finish().unwrap().len();
478
479		assert!(packets > 0, "re-encoding decoded surfaces produced no packets");
480	}
481
482	/// H.265 has no software path, so the HEVC round-trip rides VideoToolbox on
483	/// both ends: hardware HEVC encode emitting hev1 (inline VPS/SPS/PPS) and
484	/// hardware HEVC decode. Skips cleanly on a Mac without HEVC hardware (older
485	/// Intel models predating the HEVC encoder).
486	#[cfg(target_os = "macos")]
487	#[test]
488	fn videotoolbox_hevc_round_trip() {
489		let encoder = Encoder::new(&EncodeConfig {
490			kind: EncodeKind::Named("videotoolbox".into()),
491			codec: crate::encode::Codec::H265,
492			..EncodeConfig::new(320, 240, 30)
493		});
494		let Ok(encoder) = encoder else {
495			eprintln!("skipping: no VideoToolbox H.265 hardware encoder available");
496			return;
497		};
498		let decoder = backend::open(Codec::H265, &decode_config(super::Kind::Named("videotoolbox".into())))
499			.expect("videotoolbox H.265 decoder");
500		round_trip(encoder, decoder, "videotoolbox");
501	}
502
503	#[cfg(target_os = "windows")]
504	#[test]
505	fn mediafoundation_round_trip() {
506		// Requires a hardware decoder MFT (GPU). Skip on machines without one
507		// rather than fail: CI runners are often headless.
508		let Ok(decoder) = backend::open(
509			Codec::H264,
510			&decode_config(super::Kind::Named("mediafoundation".into())),
511		) else {
512			eprintln!("skipping: no Media Foundation H.264 hardware decoder available");
513			return;
514		};
515		round_trip(h264_software_encoder(gray_size()), decoder, "mediafoundation");
516	}
517
518	/// A distinct RGB level per frame index, so a caller holding several decoded
519	/// pictures at once can tell them apart. Spaced far enough apart that lossy
520	/// coding can't blur two of them together, which caps how long a stream this
521	/// builds.
522	#[cfg(target_os = "windows")]
523	fn level(index: u64) -> u8 {
524		u8::try_from(0x20 + index * 0x10).expect("test stream is short enough to keep its levels distinct")
525	}
526
527	/// The limited-range BT.601 luma a flat [`level`] frame decodes to.
528	#[cfg(target_os = "windows")]
529	fn expected_luma(level: u8) -> u32 {
530		16 + (219 * level as u32) / 255
531	}
532
533	/// Decode `count` frames of a `size` [`level`] stream through the Media
534	/// Foundation hardware decoder, holding every picture rather than consuming it
535	/// as it arrives. `None` when this machine has no hardware decoder.
536	#[cfg(target_os = "windows")]
537	fn decode_levels(count: u64, size: crate::Size) -> Option<(Vec<Frame>, Box<dyn backend::Backend>)> {
538		let mut encoder = h264_software_encoder(size);
539		let decoder = backend::open(
540			Codec::H264,
541			&decode_config(super::Kind::Named("mediafoundation".into())),
542		);
543		let Ok(mut decoder) = decoder else {
544			eprintln!("skipping: no Media Foundation H.264 hardware decoder available");
545			return None;
546		};
547
548		let mut decoded = Vec::new();
549		for i in 0..count {
550			let keyframe = i == 0;
551			if keyframe {
552				encoder.keyframe();
553			}
554			for encoded in encoder.encode(&flat_frame(i, level(i), size)).unwrap() {
555				decoded.extend(decoder.decode(encoded.payload, encoded.timestamp, keyframe).unwrap());
556			}
557		}
558
559		assert!(!decoded.is_empty(), "decoder produced no frames");
560		// The decoder goes back to the caller rather than being dropped here: its
561		// `ComGuard` tears Media Foundation down for the whole thread, and a test
562		// that keeps working with the frames afterwards would be doing so in a
563		// process no application resembles.
564		Some((decoded, decoder))
565	}
566
567	/// Every plane of a decoded flat frame: its average luma, and its average U and
568	/// V, which stay neutral because the source is gray. Chroma is the half that
569	/// catches a bad plane split, since the UV plane sits after the *texture's* luma
570	/// rows rather than the frame's.
571	#[cfg(target_os = "windows")]
572	fn plane_averages(frame: &Frame) -> (u32, u32, u32) {
573		let i420 = frame.surface.to_i420().unwrap();
574		let average = |plane: &[u8]| plane.iter().map(|&b| b as u32).sum::<u32>() / plane.len() as u32;
575		(average(i420.y()), average(i420.u()), average(i420.v()))
576	}
577
578	/// A decoded frame comes back as a GPU texture rather than downloaded pixels,
579	/// which is what leaves a render or re-encode path free of a CPU round trip.
580	/// `round_trip` above only checks the pixels, so it passes either way: this is
581	/// the test that pins the frame's residency.
582	#[cfg(target_os = "windows")]
583	#[test]
584	fn mediafoundation_decode_stays_gpu_resident() {
585		let Some((decoded, _decoder)) = decode_levels(3, gray_size()) else {
586			return;
587		};
588		for out in &decoded {
589			assert!(
590				matches!(out.surface, Surface::Texture(_)),
591				"Media Foundation decode downloaded to the CPU instead of keeping its picture on the GPU"
592			);
593		}
594	}
595
596	/// Held frames keep their own pixels. The decoder decodes into a short array of
597	/// picture buffers and recycles a slice as soon as its sample is released, so
598	/// handing that slice out as the frame would let later pictures overwrite
599	/// frames a consumer is still holding: the decoder's texture has to be copied
600	/// into one of ours on the way out.
601	///
602	/// A distinct level per frame is what makes that visible; a fixed test picture
603	/// looks identical either way.
604	#[cfg(target_os = "windows")]
605	#[test]
606	fn mediafoundation_held_frames_keep_their_pixels() {
607		// More frames than the decoder's pool has slices (8 on the hardware this
608		// was written against, one per picture), so it has to recycle the slices
609		// the earliest frames came out of.
610		let Some((decoded, _decoder)) = decode_levels(12, gray_size()) else {
611			return;
612		};
613
614		for (i, out) in decoded.iter().enumerate() {
615			let (luma, _, _) = plane_averages(out);
616			let want = expected_luma(level(i as u64));
617			// Half the gap between adjacent levels, so a frame showing a neighbour's
618			// picture fails rather than squeaking through.
619			assert!(
620				luma.abs_diff(want) <= 6,
621				"frame {i} decoded to luma {luma}, expected about {want}: the decoder recycled its picture buffer"
622			);
623		}
624	}
625
626	/// A height that isn't a whole number of macroblocks is coded padded (180 rows
627	/// become 192), and the frame has to be the picture rather than the padding.
628	///
629	/// Chroma is the assertion that bites: the interleaved UV plane starts after
630	/// the *texture's* luma rows, so reading a padded texture as if it were the
631	/// frame lands in the last luma rows and colors the picture with them.
632	#[cfg(target_os = "windows")]
633	#[test]
634	fn mediafoundation_decode_crops_coded_padding() {
635		let size = crate::Size::new(320, 180);
636		let Some((decoded, _decoder)) = decode_levels(3, size) else {
637			return;
638		};
639
640		for (i, out) in decoded.iter().enumerate() {
641			assert_eq!(out.size(), size, "frame {i} came back at the coded size");
642			let (luma, u, v) = plane_averages(out);
643			assert!(
644				luma.abs_diff(expected_luma(level(i as u64))) <= 6,
645				"frame {i} luma {luma} is not its own picture"
646			);
647			// Gray in, so both chroma planes stay neutral.
648			assert!(
649				u.abs_diff(128) <= 4 && v.abs_diff(128) <= 4,
650				"frame {i} chroma ({u}, {v}) is not neutral: the plane split read into the padding"
651			);
652		}
653	}
654
655	/// The transcode path stays on hardware from decode through re-encode: the
656	/// hardware encoder MFT takes the decoded texture on the same Direct3D11
657	/// device, no download and no upload. The residency assertion catches a CPU
658	/// fallback even when the pixels and dimensions still look right.
659	///
660	/// Decoding what comes back is the other half, and the one that pins the blit:
661	/// the encoder reads the texture on its own timeline, so a copy that never
662	/// landed still produces packets, just of the wrong picture.
663	#[cfg(target_os = "windows")]
664	#[test]
665	fn mediafoundation_decoded_texture_reencodes_in_place() {
666		let size = gray_size();
667		let Some((decoded, _decoder)) = decode_levels(3, size) else {
668			return;
669		};
670		for out in &decoded {
671			assert!(
672				matches!(out.surface, Surface::Texture(_)),
673				"Media Foundation decode downloaded to the CPU"
674			);
675		}
676
677		let encoder = Encoder::new(&EncodeConfig {
678			kind: EncodeKind::Named("mediafoundation".into()),
679			..EncodeConfig::new(size.width, size.height, 30)
680		});
681		let Ok(mut encoder) = encoder else {
682			eprintln!("skipping: no Media Foundation H.264 hardware encoder available");
683			return;
684		};
685
686		let mut reencoded = Vec::new();
687		for (i, out) in decoded.iter().enumerate() {
688			if i == 0 {
689				encoder.keyframe();
690			}
691			reencoded.extend(encoder.encode(out).unwrap());
692		}
693		reencoded.extend(encoder.finish().unwrap());
694		assert!(
695			!reencoded.is_empty(),
696			"re-encoding decoded textures produced no packets"
697		);
698
699		// Back to pixels through the software decoder, so this leans on nothing the
700		// hardware path just did.
701		let mut decoder = backend::open(Codec::H264, &decode_config(super::Kind::Software)).expect("openh264 decoder");
702		let mut out = Vec::new();
703		for (i, encoded) in reencoded.iter().enumerate() {
704			out.extend(
705				decoder
706					.decode(encoded.payload.clone(), encoded.timestamp, i == 0)
707					.unwrap(),
708			);
709		}
710
711		// Every frame, not merely some: a hardware encoder holding its tail back is
712		// what this file's flush exists to stop, and a per-frame check alone cannot
713		// see a stream that came back one short.
714		assert_eq!(out.len(), decoded.len(), "the re-encoded stream lost frames");
715		for (i, frame) in out.iter().enumerate() {
716			assert_eq!(frame.size(), size, "re-encoded frame {i} changed size");
717			let (luma, _, _) = plane_averages(frame);
718			let want = expected_luma(level(i as u64));
719			assert!(
720				luma.abs_diff(want) <= 6,
721				"re-encoded frame {i} came back as luma {luma}, expected about {want}"
722			);
723		}
724	}
725
726	/// The multi-rung transcode path stays on hardware through decode, resize, and
727	/// encode: the Direct3D11 video processor scales the decoded texture on its own
728	/// device and the encoder MFT reads the result in place. The residency
729	/// assertion catches a CPU fallback even when the pixels and dimensions still
730	/// look right, which is what a ladder pays for once per rung.
731	#[cfg(target_os = "windows")]
732	#[test]
733	#[ignore = "explicit live-DXVA GPU probe; VideoProcessorBlt can hang on affected drivers"]
734	fn mediafoundation_resized_texture_reencodes_in_place() {
735		let target = crate::Size::new(160, 120);
736		let resize = crate::resize::Config {
737			acceleration: crate::resize::Acceleration::Gpu,
738			..Default::default()
739		};
740		let Some((decoded, _decoder)) = decode_levels(3, gray_size()) else {
741			return;
742		};
743		let Some(device) = decoded.iter().find_map(|frame| match &frame.surface {
744			Surface::Texture(texture) => Some(texture.device()),
745			_ => None,
746		}) else {
747			panic!("Media Foundation decode did not return a Direct3D11 texture");
748		};
749		if !crate::frame::d3d11::supports_nv12_render_target(device) {
750			eprintln!("skipping: driver cannot render to NV12");
751			return;
752		}
753		let resized: Vec<_> = decoded
754			.iter()
755			.map(|frame| frame.resize_with(target, &resize).unwrap())
756			.collect();
757		for frame in &resized {
758			assert_eq!(frame.size(), target);
759			assert!(
760				matches!(frame.surface, Surface::Texture(_)),
761				"Direct3D11 resize downloaded to the CPU"
762			);
763		}
764
765		let encoder = Encoder::new(&EncodeConfig {
766			kind: EncodeKind::Named("mediafoundation".into()),
767			..EncodeConfig::new(target.width, target.height, 30)
768		});
769		let Ok(mut encoder) = encoder else {
770			eprintln!("skipping: no Media Foundation H.264 hardware encoder available");
771			return;
772		};
773
774		let mut packets = 0;
775		for (i, out) in resized.iter().enumerate() {
776			if i == 0 {
777				encoder.keyframe();
778			}
779			packets += encoder.encode(out).unwrap().len();
780		}
781		packets += encoder.finish().unwrap().len();
782
783		assert!(packets > 0, "re-encoding resized textures produced no packets");
784	}
785
786	/// H.265 has no software encoder or decoder, so the HEVC round-trip rides the
787	/// Media Foundation hardware path on both ends: NVENC/QSV/AMF encode through an
788	/// HEVC encoder MFT, DXVA decode through an HEVC decoder MFT. Skips cleanly when
789	/// either is absent (no GPU, or no HEVC Video Extensions installed).
790	#[cfg(target_os = "windows")]
791	#[test]
792	fn mediafoundation_hevc_round_trip() {
793		let encoder = Encoder::new(&EncodeConfig {
794			kind: EncodeKind::Named("mediafoundation".into()),
795			codec: crate::encode::Codec::H265,
796			..EncodeConfig::new(320, 240, 30)
797		});
798		let Ok(encoder) = encoder else {
799			eprintln!("skipping: no Media Foundation H.265 hardware encoder available");
800			return;
801		};
802		let Ok(decoder) = backend::open(
803			Codec::H265,
804			&decode_config(super::Kind::Named("mediafoundation".into())),
805		) else {
806			eprintln!("skipping: no Media Foundation H.265 hardware decoder available");
807			return;
808		};
809		round_trip(encoder, decoder, "mediafoundation");
810	}
811}