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