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