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