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::Frame;
19use super::backend::{self, Backend, Codec};
20use crate::{Error, Size};
21
22/// Which decoder implementation to use. `#[non_exhaustive]` so new selection
23/// strategies can be added without breaking external `match`es.
24#[derive(Clone, Debug, Default, PartialEq, Eq)]
25#[non_exhaustive]
26pub enum Kind {
27	/// Prefer a platform hardware decoder, fall back to software.
28	#[default]
29	Auto,
30	/// Hardware only; error if none is available.
31	Hardware,
32	/// Software (openh264) only.
33	Software,
34	/// A specific backend by name, e.g. `"videotoolbox"`, `"nvdec"`,
35	/// `"openh264"`.
36	Named(String),
37}
38
39/// Decoder configuration.
40///
41/// `#[non_exhaustive]`: build via [`Config::new`] (or `default()`) and set the
42/// optional fields, so future knobs don't break callers.
43#[derive(Clone, Debug, Default)]
44#[non_exhaustive]
45pub struct Config {
46	/// Which backend to use.
47	pub kind: Kind,
48	/// Upper bound on buffering before a stalled group is skipped. `None` uses
49	/// the moq-mux default (skip aggressively); set it to your playout buffer for
50	/// a softer skip. Forwarded to the container consumer's `with_latency`.
51	pub latency_max: Option<Duration>,
52	/// Ask the decoder to emit frames at this size (both dimensions even) instead
53	/// of the stream's native one. Best effort: a hardware decoder with a
54	/// built-in scaler (NVDEC) honors it for free, other backends ignore it.
55	/// Check each [`Frame`](super::Surface)'s dimensions and scale the remainder
56	/// yourself.
57	pub resize: Option<Size>,
58}
59
60impl Config {
61	/// A default config: automatic backend selection, default latency.
62	pub fn new() -> Self {
63		Self::default()
64	}
65}
66
67/// How to turn a container payload into a backend access unit.
68enum Conversion {
69	/// The payload is already in the backend's input framing: Annex-B for avc3 /
70	/// hev1, OBU temporal units for AV1.
71	Passthrough,
72	/// avc1 / hvc1: length-prefixed NALs with the parameter sets out-of-band (in
73	/// the avcC / hvcC description). Replace the length prefixes with start codes
74	/// and prepend `keyframe_prefix` (the parameter sets) ahead of every keyframe.
75	LengthPrefixed { length_size: usize, keyframe_prefix: Bytes },
76}
77
78/// Decodes container payloads (the codec bitstream) into raw [`Frame`]s.
79///
80/// The bring-your-own-payload layer under [`Consumer`](super::Consumer): use it
81/// when the frames don't come from a plain track subscription, e.g. a transcoder
82/// serving individually fetched groups. Feed it the payload of each container
83/// frame in decode order; it handles avc1/hvc1 -> Annex-B conversion, passes
84/// AV1 OBU temporal units through, and gates output until the first keyframe.
85pub struct Decoder {
86	backend: Box<dyn Backend>,
87	conversion: Conversion,
88	got_keyframe: bool,
89}
90
91impl Decoder {
92	/// Build a decoder for the catalog's video config. Errors if the codec is
93	/// not supported by the native backends.
94	pub fn new(catalog: &VideoConfig, config: &Config) -> Result<Self, Error> {
95		let (codec, conversion) = match &catalog.codec {
96			VideoCodec::H264(h264) => {
97				let conversion = if h264.inline {
98					Conversion::Passthrough
99				} else {
100					let avcc = catalog.description.as_ref().ok_or_else(|| {
101						Error::Codec(anyhow::anyhow!("avc1 H.264 track is missing its avcC description"))
102					})?;
103					let params = h264::Avcc::parse(avcc).map_err(moq_mux::Error::from)?;
104					let keyframe_prefix = annexb::build_prefix(params.sps.iter().chain(params.pps.iter()));
105					Conversion::LengthPrefixed {
106						length_size: params.length_size,
107						keyframe_prefix,
108					}
109				};
110				(Codec::H264, conversion)
111			}
112			VideoCodec::H265(h265) => {
113				let conversion = if h265.in_band {
114					Conversion::Passthrough
115				} else {
116					let hvcc = catalog.description.as_ref().ok_or_else(|| {
117						Error::Codec(anyhow::anyhow!("hvc1 H.265 track is missing its hvcC description"))
118					})?;
119					let params = h265::Hvcc::parse(hvcc).map_err(moq_mux::Error::from)?;
120					let keyframe_prefix =
121						annexb::build_prefix(params.vps.iter().chain(params.sps.iter()).chain(params.pps.iter()));
122					Conversion::LengthPrefixed {
123						length_size: params.length_size,
124						keyframe_prefix,
125					}
126				};
127				(Codec::H265, conversion)
128			}
129			VideoCodec::AV1(av1) if is_supported_av1(av1) => (Codec::Av1, Conversion::Passthrough),
130			other => return Err(Error::UnsupportedCodec(other.to_string())),
131		};
132
133		let backend = backend::open(codec, config)?;
134		tracing::debug!(decoder = backend.name(), "opened video decoder");
135		Ok(Self {
136			backend,
137			conversion,
138			got_keyframe: false,
139		})
140	}
141
142	/// The decoder backend name in use, e.g. `"videotoolbox"`.
143	pub fn name(&self) -> &str {
144		self.backend.name()
145	}
146
147	/// Decode one container frame, returning zero or more raw frames. `timestamp` is
148	/// this frame's presentation time; it rides through the decoder and comes back on
149	/// each output frame, so a reordering decoder (B-frames) stamps every picture
150	/// with its own presentation time rather than this access unit's. With no
151	/// reordering the two coincide.
152	pub fn decode(&mut self, payload: &Bytes, timestamp: Timestamp, keyframe: bool) -> Result<Vec<Frame>, Error> {
153		// Wait for the first keyframe: a decoder started mid-GOP can't decode
154		// delta frames, and the parameter sets ride along with the keyframe.
155		if !self.got_keyframe {
156			if !keyframe {
157				return Ok(Vec::new());
158			}
159			self.got_keyframe = true;
160		}
161
162		let access_unit = match &self.conversion {
163			// Cheap refcount bump; the backend splits codec units off this buffer.
164			Conversion::Passthrough => payload.clone(),
165			Conversion::LengthPrefixed {
166				length_size,
167				keyframe_prefix,
168			} => {
169				let prefix = keyframe.then(|| keyframe_prefix.as_ref());
170				annexb::from_length_prefixed(payload, *length_size, prefix).map_err(moq_mux::Error::from)?
171			}
172		};
173
174		Ok(self
175			.backend
176			.decode(access_unit, timestamp, keyframe)?
177			.into_iter()
178			.map(|decoded| Frame {
179				timestamp: decoded.timestamp,
180				size: Size::new(decoded.frame.width(), decoded.frame.height()),
181				surface: decoded.frame,
182			})
183			.collect())
184	}
185}
186
187fn is_supported_av1(av1: &AV1) -> bool {
188	av1.bitdepth == 8 && !av1.mono_chrome && av1.chroma_subsampling_x && av1.chroma_subsampling_y
189}
190
191#[cfg(test)]
192mod tests {
193	use moq_net::Timestamp;
194
195	use super::backend::{self, Codec};
196	use crate::encode::{Config as EncodeConfig, Encoder, Kind as EncodeKind};
197	use crate::frame::I420;
198
199	/// A mid-gray RGBA frame: encodable without a camera.
200	fn gray_rgba(width: u32, height: u32) -> Vec<u8> {
201		vec![0x80u8; width as usize * height as usize * 4]
202	}
203
204	/// Assert a decoded picture is the expected size and looks like the gray frame
205	/// we encoded. Mid-gray RGBA (0x80) is a flat picture: BT.601 limited-range
206	/// luma near 125 and neutral chroma near 128. Averaging each plane catches
207	/// plane swaps, stride bugs, and a misread Y/UV split that a size check misses.
208	fn assert_gray(i420: &I420, width: u32, height: u32) {
209		assert_eq!(i420.width, width);
210		assert_eq!(i420.height, height);
211		let luma = (width * height) as usize;
212		// Tightly-packed I420: luma + two quarter-size chroma planes.
213		assert_eq!(i420.data.len(), luma * 3 / 2);
214
215		let avg = |plane: &[u8]| plane.iter().map(|&b| b as u32).sum::<u32>() / plane.len() as u32;
216		let y = avg(&i420.data[..luma]);
217		let u = avg(&i420.data[luma..luma + luma / 4]);
218		let v = avg(&i420.data[luma + luma / 4..]);
219		assert!((110..=140).contains(&y), "luma {y} off for a gray frame");
220		assert!((118..=138).contains(&u), "u {u} off for a gray frame");
221		assert!((118..=138).contains(&v), "v {v} off for a gray frame");
222	}
223
224	/// Encode 10 gray frames with `encoder`, decode them through `decoder`, and
225	/// assert each decoded picture round-trips. Keyframe gating is exercised (the
226	/// first packet is a keyframe with inline parameter sets).
227	fn round_trip(mut encoder: Encoder, mut decoder: Box<dyn backend::Backend>, expect_name: &str) {
228		assert_eq!(decoder.name(), expect_name);
229
230		let frame = gray_rgba(320, 240);
231		let mut decoded = Vec::new();
232		for i in 0..10u64 {
233			let keyframe = i == 0;
234			// Distinct, spread-apart timestamps so a round-tripped value is unambiguous.
235			let timestamp = Timestamp::from_micros(i * 33_333).unwrap();
236			for packet in encoder
237				.encode_rgba(&frame, crate::Size::new(320, 240), keyframe)
238				.unwrap()
239			{
240				decoded.extend(decoder.decode(packet, timestamp, keyframe).unwrap());
241			}
242		}
243
244		assert!(!decoded.is_empty(), "decoder produced no frames");
245		for out in &decoded {
246			assert_gray(&out.frame.to_i420().unwrap(), 320, 240);
247		}
248
249		// The timestamp rides through the codec and comes back on each picture. These
250		// backends don't reorder, so it returns in feed order: strictly increasing and
251		// drawn from the values we fed.
252		let micros: Vec<u128> = decoded.iter().map(|d| d.timestamp.as_micros()).collect();
253		assert!(
254			micros.windows(2).all(|w| w[0] < w[1]),
255			"decoded timestamps not strictly increasing: {micros:?}"
256		);
257		assert!(
258			micros.iter().all(|&t| t % 33_333 == 0 && t < 333_330),
259			"decoded timestamp outside the fed set: {micros:?}"
260		);
261	}
262
263	/// A decoder config selecting one backend by kind.
264	fn decode_config(kind: super::Kind) -> super::Config {
265		super::Config {
266			kind,
267			..super::Config::new()
268		}
269	}
270
271	/// An openh264 (software H.264) encoder for the gray test stream.
272	fn h264_software_encoder() -> Encoder {
273		Encoder::new(&EncodeConfig {
274			kind: EncodeKind::Software,
275			..EncodeConfig::new(320, 240, 30)
276		})
277		.expect("openh264 encoder")
278	}
279
280	#[test]
281	fn openh264_round_trip() {
282		let decoder = backend::open(Codec::H264, &decode_config(super::Kind::Software)).expect("openh264 decoder");
283		round_trip(h264_software_encoder(), decoder, "openh264");
284	}
285
286	#[test]
287	fn av1_is_supported_by_hardware_only() {
288		let catalog = hang::catalog::VideoConfig::new(hang::catalog::AV1::default());
289		let config = decode_config(super::Kind::Software);
290		let Err(err) = super::Decoder::new(&catalog, &config) else {
291			panic!("software AV1 decode unexpectedly opened");
292		};
293		assert!(matches!(err, crate::Error::NoDecoder(_)));
294	}
295
296	#[test]
297	fn av1_rejects_unsupported_catalog_shape() {
298		let av1 = hang::catalog::AV1 {
299			bitdepth: 10,
300			..hang::catalog::AV1::default()
301		};
302		let catalog = hang::catalog::VideoConfig::new(av1);
303		let config = decode_config(super::Kind::Auto);
304		let Err(err) = super::Decoder::new(&catalog, &config) else {
305			panic!("10-bit AV1 decode unexpectedly opened");
306		};
307		assert!(matches!(err, crate::Error::UnsupportedCodec(_)));
308	}
309
310	#[cfg(target_os = "macos")]
311	#[test]
312	fn videotoolbox_round_trip() {
313		let decoder = backend::open(Codec::H264, &decode_config(super::Kind::Named("videotoolbox".into())))
314			.expect("videotoolbox decoder");
315		round_trip(h264_software_encoder(), decoder, "videotoolbox");
316	}
317
318	/// Encode `count` gray frames and decode them, returning the decoded pictures.
319	/// The shared setup for the residency and re-encode tests below.
320	#[cfg(target_os = "macos")]
321	fn decode_gray(count: u64) -> Vec<backend::Decoded> {
322		let mut encoder = h264_software_encoder();
323		let mut decoder = backend::open(Codec::H264, &decode_config(super::Kind::Named("videotoolbox".into())))
324			.expect("videotoolbox decoder");
325
326		let rgba = gray_rgba(320, 240);
327		let mut decoded = Vec::new();
328		for i in 0..count {
329			let keyframe = i == 0;
330			let timestamp = Timestamp::from_micros(i * 33_333).unwrap();
331			for packet in encoder
332				.encode_rgba(&rgba, crate::Size::new(320, 240), keyframe)
333				.unwrap()
334			{
335				decoded.extend(decoder.decode(packet, 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.frame, crate::frame::Surface::PixelBuffer(_)),
353				"VideoToolbox decode downloaded to the CPU instead of keeping its surface"
354			);
355		}
356	}
357
358	/// A decoded surface feeds the hardware encoder as-is, which is the zero-copy
359	/// transcode path (`moq-transcode` re-encodes what it decodes). On macOS that
360	/// only became reachable once decode stopped downloading to I420.
361	#[cfg(target_os = "macos")]
362	#[test]
363	fn videotoolbox_surface_reencodes_in_place() {
364		let decoded = decode_gray(3);
365
366		let encoder = Encoder::new(&EncodeConfig {
367			kind: EncodeKind::Named("videotoolbox".into()),
368			..EncodeConfig::new(320, 240, 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 decoded.iter().enumerate() {
377			packets += encoder.encode(&out.frame, i == 0).unwrap().len();
378		}
379		packets += encoder.finish().unwrap().len();
380
381		assert!(packets > 0, "re-encoding decoded surfaces produced no packets");
382	}
383
384	/// H.265 has no software path, so the HEVC round-trip rides VideoToolbox on
385	/// both ends: hardware HEVC encode emitting hev1 (inline VPS/SPS/PPS) and
386	/// hardware HEVC decode. Skips cleanly on a Mac without HEVC hardware (older
387	/// Intel models predating the HEVC encoder).
388	#[cfg(target_os = "macos")]
389	#[test]
390	fn videotoolbox_hevc_round_trip() {
391		let encoder = Encoder::new(&EncodeConfig {
392			kind: EncodeKind::Named("videotoolbox".into()),
393			codec: crate::encode::Codec::H265,
394			..EncodeConfig::new(320, 240, 30)
395		});
396		let Ok(encoder) = encoder else {
397			eprintln!("skipping: no VideoToolbox H.265 hardware encoder available");
398			return;
399		};
400		let decoder = backend::open(Codec::H265, &decode_config(super::Kind::Named("videotoolbox".into())))
401			.expect("videotoolbox H.265 decoder");
402		round_trip(encoder, decoder, "videotoolbox");
403	}
404
405	#[cfg(target_os = "windows")]
406	#[test]
407	fn mediafoundation_round_trip() {
408		// Requires a hardware decoder MFT (GPU). Skip on machines without one
409		// rather than fail: CI runners are often headless.
410		let Ok(decoder) = backend::open(
411			Codec::H264,
412			&decode_config(super::Kind::Named("mediafoundation".into())),
413		) else {
414			eprintln!("skipping: no Media Foundation H.264 hardware decoder available");
415			return;
416		};
417		round_trip(h264_software_encoder(), decoder, "mediafoundation");
418	}
419
420	/// H.265 has no software encoder or decoder, so the HEVC round-trip rides the
421	/// Media Foundation hardware path on both ends: NVENC/QSV/AMF encode through an
422	/// HEVC encoder MFT, DXVA decode through an HEVC decoder MFT. Skips cleanly when
423	/// either is absent (no GPU, or no HEVC Video Extensions installed).
424	#[cfg(target_os = "windows")]
425	#[test]
426	fn mediafoundation_hevc_round_trip() {
427		let encoder = Encoder::new(&EncodeConfig {
428			kind: EncodeKind::Named("mediafoundation".into()),
429			codec: crate::encode::Codec::H265,
430			..EncodeConfig::new(320, 240, 30)
431		});
432		let Ok(encoder) = encoder else {
433			eprintln!("skipping: no Media Foundation H.265 hardware encoder available");
434			return;
435		};
436		let Ok(decoder) = backend::open(
437			Codec::H265,
438			&decode_config(super::Kind::Named("mediafoundation".into())),
439		) else {
440			eprintln!("skipping: no Media Foundation H.265 hardware decoder available");
441			return;
442		};
443		round_trip(encoder, decoder, "mediafoundation");
444	}
445}