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 mid-gray 320x240 stream, at 30fps.
191	fn gray_frame(index: u64) -> Frame {
192		let rgba = vec![0x80u8; 320 * 240 * 4];
193		let surface = Surface::rgba(&rgba, crate::Size::new(320, 240)).unwrap();
194		Frame::new(surface, Timestamp::from_micros(index * 33_333).unwrap())
195	}
196
197	/// Assert a decoded picture is the expected size and looks like the gray frame
198	/// we encoded. Mid-gray RGBA (0x80) is a flat picture: BT.601 limited-range
199	/// luma near 125 and neutral chroma near 128. Averaging each plane catches
200	/// plane swaps, stride bugs, and a misread Y/UV split that a size check misses.
201	fn assert_gray(i420: &I420, width: u32, height: u32) {
202		assert_eq!(i420.width, width);
203		assert_eq!(i420.height, height);
204		let luma = (width * height) as usize;
205		// Tightly-packed I420: luma + two quarter-size chroma planes.
206		assert_eq!(i420.data.len(), luma * 3 / 2);
207
208		let avg = |plane: &[u8]| plane.iter().map(|&b| b as u32).sum::<u32>() / plane.len() as u32;
209		let y = avg(&i420.data[..luma]);
210		let u = avg(&i420.data[luma..luma + luma / 4]);
211		let v = avg(&i420.data[luma + luma / 4..]);
212		assert!((110..=140).contains(&y), "luma {y} off for a gray frame");
213		assert!((118..=138).contains(&u), "u {u} off for a gray frame");
214		assert!((118..=138).contains(&v), "v {v} off for a gray frame");
215	}
216
217	/// Encode 10 gray frames with `encoder`, decode them through `decoder`, and
218	/// assert each decoded picture round-trips. Keyframe gating is exercised (the
219	/// first packet is a keyframe with inline parameter sets).
220	fn round_trip(mut encoder: Encoder, mut decoder: Box<dyn backend::Backend>, expect_name: &str) {
221		assert_eq!(decoder.name(), expect_name);
222
223		let mut decoded = Vec::new();
224		for i in 0..10u64 {
225			let keyframe = i == 0;
226			if keyframe {
227				encoder.keyframe();
228			}
229			// Distinct, spread-apart timestamps so a round-tripped value is unambiguous.
230			for encoded in encoder.encode(&gray_frame(i)).unwrap() {
231				decoded.extend(decoder.decode(encoded.payload, encoded.timestamp, keyframe).unwrap());
232			}
233		}
234
235		assert!(!decoded.is_empty(), "decoder produced no frames");
236		for out in &decoded {
237			assert_gray(&out.surface.to_i420().unwrap(), 320, 240);
238		}
239
240		// The timestamp rides through the codec and comes back on each picture. These
241		// backends don't reorder, so it returns in feed order: strictly increasing and
242		// drawn from the values we fed.
243		let micros: Vec<u128> = decoded.iter().map(|d| d.timestamp.as_micros()).collect();
244		assert!(
245			micros.windows(2).all(|w| w[0] < w[1]),
246			"decoded timestamps not strictly increasing: {micros:?}"
247		);
248		assert!(
249			micros.iter().all(|&t| t % 33_333 == 0 && t < 333_330),
250			"decoded timestamp outside the fed set: {micros:?}"
251		);
252	}
253
254	/// A decoder config selecting one backend by kind.
255	fn decode_config(kind: super::Kind) -> super::Config {
256		super::Config {
257			kind,
258			..super::Config::new()
259		}
260	}
261
262	/// An openh264 (software H.264) encoder for the gray test stream.
263	fn h264_software_encoder() -> Encoder {
264		Encoder::new(&EncodeConfig {
265			kind: EncodeKind::Software,
266			..EncodeConfig::new(320, 240, 30)
267		})
268		.expect("openh264 encoder")
269	}
270
271	#[test]
272	fn openh264_round_trip() {
273		let decoder = backend::open(Codec::H264, &decode_config(super::Kind::Software)).expect("openh264 decoder");
274		round_trip(h264_software_encoder(), decoder, "openh264");
275	}
276
277	#[test]
278	fn av1_is_supported_by_hardware_only() {
279		let catalog = hang::catalog::VideoConfig::new(hang::catalog::AV1::default());
280		let config = decode_config(super::Kind::Software);
281		let Err(err) = super::Decoder::new(&catalog, &config) else {
282			panic!("software AV1 decode unexpectedly opened");
283		};
284		assert!(matches!(err, crate::Error::NoDecoder(_)));
285	}
286
287	#[test]
288	fn av1_rejects_unsupported_catalog_shape() {
289		let av1 = hang::catalog::AV1 {
290			bitdepth: 10,
291			..hang::catalog::AV1::default()
292		};
293		let catalog = hang::catalog::VideoConfig::new(av1);
294		let config = decode_config(super::Kind::Auto);
295		let Err(err) = super::Decoder::new(&catalog, &config) else {
296			panic!("10-bit AV1 decode unexpectedly opened");
297		};
298		assert!(matches!(err, crate::Error::UnsupportedCodec(_)));
299	}
300
301	#[cfg(target_os = "macos")]
302	#[test]
303	fn videotoolbox_round_trip() {
304		let decoder = backend::open(Codec::H264, &decode_config(super::Kind::Named("videotoolbox".into())))
305			.expect("videotoolbox decoder");
306		round_trip(h264_software_encoder(), decoder, "videotoolbox");
307	}
308
309	/// Encode `count` gray frames and decode them, returning the decoded pictures.
310	/// The shared setup for the residency and re-encode tests below.
311	#[cfg(target_os = "macos")]
312	fn decode_gray(count: u64) -> Vec<Frame> {
313		let mut encoder = h264_software_encoder();
314		let mut decoder = backend::open(Codec::H264, &decode_config(super::Kind::Named("videotoolbox".into())))
315			.expect("videotoolbox decoder");
316
317		let mut decoded = Vec::new();
318		for i in 0..count {
319			let keyframe = i == 0;
320			if keyframe {
321				encoder.keyframe();
322			}
323			for encoded in encoder.encode(&gray_frame(i)).unwrap() {
324				decoded.extend(decoder.decode(encoded.payload, encoded.timestamp, keyframe).unwrap());
325			}
326		}
327
328		assert!(!decoded.is_empty(), "decoder produced no frames");
329		decoded
330	}
331
332	/// VideoToolbox hands back its `CVPixelBuffer` rather than packing to I420 in
333	/// the output callback, which is what leaves a render or re-encode path free of
334	/// a CPU round trip. `round_trip` above only checks the pixels, so it passes
335	/// either way: this is the test that pins the frame's residency.
336	#[cfg(target_os = "macos")]
337	#[test]
338	fn videotoolbox_decode_stays_gpu_resident() {
339		for out in &decode_gray(3) {
340			assert!(
341				matches!(out.surface, Surface::PixelBuffer(_)),
342				"VideoToolbox decode downloaded to the CPU instead of keeping its surface"
343			);
344		}
345	}
346
347	/// The multi-rung transcode path stays on hardware through decode, resize, and
348	/// encode. The residency assertion catches a CPU fallback even when the pixels
349	/// and dimensions still look right.
350	#[cfg(target_os = "macos")]
351	#[test]
352	fn videotoolbox_resized_surface_reencodes_in_place() {
353		let decoded = decode_gray(3);
354		let resized: Vec<_> = decoded
355			.iter()
356			.map(|frame| frame.resize(crate::Size::new(160, 120)).unwrap())
357			.collect();
358		for frame in &resized {
359			assert_eq!(frame.size(), crate::Size::new(160, 120));
360			assert!(
361				matches!(frame.surface, Surface::PixelBuffer(_)),
362				"VideoToolbox resize downloaded to the CPU"
363			);
364		}
365
366		let encoder = Encoder::new(&EncodeConfig {
367			kind: EncodeKind::Named("videotoolbox".into()),
368			..EncodeConfig::new(160, 120, 30)
369		});
370		let Ok(mut encoder) = encoder else {
371			eprintln!("skipping: no VideoToolbox H.264 hardware encoder available");
372			return;
373		};
374
375		let mut packets = 0;
376		for (i, out) in resized.iter().enumerate() {
377			if i == 0 {
378				encoder.keyframe();
379			}
380			packets += encoder.encode(out).unwrap().len();
381		}
382		packets += encoder.finish().unwrap().len();
383
384		assert!(packets > 0, "re-encoding decoded surfaces produced no packets");
385	}
386
387	/// H.265 has no software path, so the HEVC round-trip rides VideoToolbox on
388	/// both ends: hardware HEVC encode emitting hev1 (inline VPS/SPS/PPS) and
389	/// hardware HEVC decode. Skips cleanly on a Mac without HEVC hardware (older
390	/// Intel models predating the HEVC encoder).
391	#[cfg(target_os = "macos")]
392	#[test]
393	fn videotoolbox_hevc_round_trip() {
394		let encoder = Encoder::new(&EncodeConfig {
395			kind: EncodeKind::Named("videotoolbox".into()),
396			codec: crate::encode::Codec::H265,
397			..EncodeConfig::new(320, 240, 30)
398		});
399		let Ok(encoder) = encoder else {
400			eprintln!("skipping: no VideoToolbox H.265 hardware encoder available");
401			return;
402		};
403		let decoder = backend::open(Codec::H265, &decode_config(super::Kind::Named("videotoolbox".into())))
404			.expect("videotoolbox H.265 decoder");
405		round_trip(encoder, decoder, "videotoolbox");
406	}
407
408	#[cfg(target_os = "windows")]
409	#[test]
410	fn mediafoundation_round_trip() {
411		// Requires a hardware decoder MFT (GPU). Skip on machines without one
412		// rather than fail: CI runners are often headless.
413		let Ok(decoder) = backend::open(
414			Codec::H264,
415			&decode_config(super::Kind::Named("mediafoundation".into())),
416		) else {
417			eprintln!("skipping: no Media Foundation H.264 hardware decoder available");
418			return;
419		};
420		round_trip(h264_software_encoder(), decoder, "mediafoundation");
421	}
422
423	/// H.265 has no software encoder or decoder, so the HEVC round-trip rides the
424	/// Media Foundation hardware path on both ends: NVENC/QSV/AMF encode through an
425	/// HEVC encoder MFT, DXVA decode through an HEVC decoder MFT. Skips cleanly when
426	/// either is absent (no GPU, or no HEVC Video Extensions installed).
427	#[cfg(target_os = "windows")]
428	#[test]
429	fn mediafoundation_hevc_round_trip() {
430		let encoder = Encoder::new(&EncodeConfig {
431			kind: EncodeKind::Named("mediafoundation".into()),
432			codec: crate::encode::Codec::H265,
433			..EncodeConfig::new(320, 240, 30)
434		});
435		let Ok(encoder) = encoder else {
436			eprintln!("skipping: no Media Foundation H.265 hardware encoder available");
437			return;
438		};
439		let Ok(decoder) = backend::open(
440			Codec::H265,
441			&decode_config(super::Kind::Named("mediafoundation".into())),
442		) else {
443			eprintln!("skipping: no Media Foundation H.265 hardware decoder available");
444			return;
445		};
446		round_trip(encoder, decoder, "mediafoundation");
447	}
448}