Skip to main content

moq_video/decode/
mod.rs

1//! Subscribe to an H.264, H.265, or AV1 track and decode it to raw frames.
2//!
3//! The decode counterpart to [`encode`](crate::encode), and the mirror of
4//! `moq_audio::decode::Consumer`. [`Consumer`] subscribes to a moq-mux video
5//! track and hands back decoded [`Frame`]s; a native backend does the work
6//! (VideoToolbox on macOS, Media Foundation / DXVA on Windows, NVDEC on Linux,
7//! openh264 everywhere as the software fallback for H.264).
8//!
9//! H.264 and H.265 are supported, symmetric with what [`encode`](crate::encode)
10//! produces. AV1 is decode-only on NVDEC. H.265 and AV1 are hardware-only (no
11//! software fallback). Any other codec yields
12//! [`Error::UnsupportedCodec`](crate::Error).
13
14use moq_net::Timestamp;
15
16use crate::{Error, Size, Surface};
17
18// Crate-visible so the NVENC encode backend's round-trip test can decode its
19// output with the software decoder (an in-crate, ffmpeg-free encode->decode
20// check that catches input-pitch corruption).
21pub(crate) mod backend;
22mod consumer;
23mod decoder;
24
25pub use consumer::Consumer;
26pub use decoder::{Config, Decoder, Kind};
27
28/// A decoded raw video frame: CPU I420, or a GPU frame when a hardware decoder
29/// produced one (NVDEC on Linux).
30///
31/// A GPU frame stays on the GPU until something needs bytes: feeding it to
32/// [`encode::Encoder::encode`](crate::encode::Encoder::encode) keeps it there
33/// (the zero-copy transcode path), while [`Surface::into_i420`]
34/// downloads it.
35pub struct Frame {
36	/// Presentation timestamp, carried through from the container. It rides out of
37	/// the decoder with each picture, so a reordered frame (B-frames) keeps its own
38	/// time rather than the input access unit's.
39	pub timestamp: Timestamp,
40	/// The decoded resolution, which is [`Config::resize`] when the backend
41	/// honored it and the stream's native size otherwise.
42	pub size: Size,
43	/// Where the pixels live. Match on it for a zero-copy path, or call
44	/// [`Surface::into_i420`] for the universal one.
45	pub surface: Surface,
46}
47
48impl Frame {
49	/// A copy of this frame scaled to `size` (both dimensions even and non-zero),
50	/// preserving the timestamp. A CUDA frame scales on the GPU (a box filter,
51	/// correct at any downscale factor) and stays there, so resize ->
52	/// [`encode`](crate::encode::Encoder::encode) never touches the CPU. Every
53	/// other frame scales on the CPU, which for a `CVPixelBuffer` means a
54	/// download first. When one output size is enough, prefer decoding
55	/// straight to it ([`Config::resize`]), which is free on decoders with a
56	/// hardware scaler; this method is for fanning one decoded stream out to
57	/// several sizes.
58	pub fn resize(&self, size: Size) -> Result<Frame, Error> {
59		size.validate("resize to")?;
60		let Size { width, height } = size;
61
62		let surface = match &self.surface {
63			Surface::I420(i420) => Surface::I420(i420.resize(width, height)?),
64			#[cfg(all(target_os = "linux", feature = "nvdec"))]
65			Surface::Cuda(cuda) => match cuda.resize(width, height) {
66				Ok(scaled) => Surface::Cuda(scaled),
67				// E.g. the driver rejected the vendored PTX: degrade to a CPU
68				// resize (download once) instead of killing the stream.
69				Err(err) => {
70					static WARN_ONCE: std::sync::Once = std::sync::Once::new();
71					WARN_ONCE.call_once(|| tracing::warn!(%err, "GPU resize failed; falling back to the CPU"));
72					Surface::I420(cuda.download_i420()?.resize(width, height)?)
73				}
74			},
75			// CVPixelBuffer (the VideoToolbox decoder's output) and D3D11 textures
76			// have no GPU scaler wired up yet: download, then scale on the CPU.
77			#[allow(unreachable_patterns)]
78			other => Surface::I420(other.to_i420()?.into_owned().resize(width, height)?),
79		};
80
81		Ok(Frame {
82			timestamp: self.timestamp,
83			size,
84			surface,
85		})
86	}
87}
88
89#[cfg(test)]
90mod tests {
91	/// Callers (libmoq, moq-transcode) hold these across `.await`s in spawned
92	/// tasks and share frames via `Arc` (the transcode fanout), so both must
93	/// stay `Send` and `Frame` also `Sync` even when a platform's frame wraps
94	/// a GPU handle. Compile-time check; fails per-platform if a variant
95	/// regresses.
96	#[test]
97	fn frame_and_consumer_are_thread_safe() {
98		fn assert_send<T: Send>() {}
99		fn assert_sync<T: Sync>() {}
100		assert_send::<super::Frame>();
101		assert_sync::<super::Frame>();
102		assert_send::<super::Consumer>();
103	}
104
105	/// `into_pixel_buffer` is total: a CPU frame uploads rather than failing, so a
106	/// renderer never has to write the upload itself. Software-decoded frames take
107	/// this path.
108	#[cfg(target_os = "macos")]
109	#[test]
110	fn into_pixel_buffer_uploads_a_cpu_frame() {
111		use objc2_core_video::{CVPixelBufferGetHeight, CVPixelBufferGetWidth};
112
113		let frame = super::Frame {
114			timestamp: moq_net::Timestamp::from_micros(0).unwrap(),
115			size: crate::Size::new(64, 32),
116			surface: crate::Surface::I420(crate::I420 {
117				width: 64,
118				height: 32,
119				data: vec![0x80; crate::I420::len(64, 32)],
120			}),
121		};
122
123		let buffer = frame.surface.into_pixel_buffer().expect("upload a CPU frame");
124		assert_eq!(CVPixelBufferGetWidth(&buffer), 64);
125		assert_eq!(CVPixelBufferGetHeight(&buffer), 32);
126	}
127}