Skip to main content

moq_video/
frame.rs

1//! [`Frame`]: one raw picture, and [`Surface`]: its pixels and where they live.
2//!
3//! Representations chosen so the common path stays zero-copy:
4//! - `Surface::PixelBuffer` is a macOS `CVPixelBuffer` (IOSurface-backed NV12).
5//!   Capture and the VideoToolbox decoder both produce it, and the VideoToolbox
6//!   encoder consumes it directly, no copy and no color conversion.
7//! - `Surface::Texture` is a Windows Direct3D11 NV12 texture, produced by Media
8//!   Foundation capture and decode one GPU blit removed from their own pools
9//!   (which they recycle, so a frame has to be lifted out of them), and consumed
10//!   by the hardware encoder MFT on the same device with no copy at all, so a
11//!   camera or a decoder reaches an encoder without touching the CPU. Drawing one
12//!   still goes through `into_i420`, since the render module imports a
13//!   `PixelBuffer` but has no Direct3D11 path yet.
14// `render` is deliberately not a doc link: the module sits behind a non-default
15// feature, so linking it fails the `-D warnings` rustdoc build of a plain build.
16//! - `Surface::I420` is CPU-resident planar I420, for the CPU encode path and
17//!   platforms without a zero-copy capture.
18//!
19//! A backend that consumes a GPU surface takes the frame as-is; a CPU encoder
20//! asks for I420 via [`Surface::into_i420`], which downloads the GPU frame only when
21//! needed.
22
23use std::borrow::Cow;
24
25use bytes::Bytes;
26use moq_net::Timestamp;
27
28use yuv::{YuvChromaSubsampling, YuvConversionMode, YuvPlanarImageMut, rgba_to_yuv420};
29
30use crate::{Color, Error, Size};
31
32/// One raw (uncompressed) video frame: the pixels plus when they are shown.
33///
34/// The currency of the crate's raw side: [`capture`](crate::capture) and
35/// [`decode`](crate::decode) produce these, and
36/// [`encode::Encoder::encode`](crate::encode::Encoder::encode) consumes them,
37/// handing back the compressed [`encode::Encoded`](crate::encode::Encoded).
38pub struct Frame {
39	/// Presentation timestamp. It rides through the encoder with the picture, so a
40	/// backend that buffers or reorders still stamps each packet with the time of
41	/// the frame it actually encoded.
42	pub timestamp: Timestamp,
43	/// The pixels, and where they currently live.
44	pub surface: Surface,
45}
46
47impl Frame {
48	/// A frame shown at `timestamp`.
49	pub fn new(surface: Surface, timestamp: Timestamp) -> Self {
50		Self { timestamp, surface }
51	}
52
53	/// The frame resolution, from the surface itself.
54	pub fn size(&self) -> Size {
55		Size::new(self.surface.width(), self.surface.height())
56	}
57
58	/// A copy of this frame scaled to `size` (both dimensions even and non-zero),
59	/// preserving the timestamp. CUDA and macOS `CVPixelBuffer` frames scale on
60	/// the GPU and stay there, so resize ->
61	/// [`encode`](crate::encode::Encoder::encode) never touches the CPU. Other
62	/// frames scale on the CPU. When one output size is enough, prefer decoding
63	/// straight to it ([`decode::Config::resize`](crate::decode::Config)), which
64	/// is free on decoders with a hardware scaler; this method is for fanning one
65	/// decoded stream out to several sizes.
66	pub fn resize(&self, size: Size) -> Result<Frame, Error> {
67		Ok(Frame {
68			timestamp: self.timestamp,
69			surface: self.surface.resize(size)?,
70		})
71	}
72}
73
74/// Where a frame's pixels currently live.
75///
76/// Decoders and capture sources hand these out; encoders and renderers consume
77/// them. Match to take a zero-copy fast path for the representation you can use,
78/// and fall back to [`into_i420`](Self::into_i420) for everything else, which is
79/// always available:
80///
81/// ```ignore
82/// match surface {
83///     #[cfg(target_os = "macos")]
84///     Surface::PixelBuffer(buffer) => draw_metal(buffer),
85///     other => upload(other.into_i420()?),
86/// }
87/// ```
88///
89/// Variants are platform-gated, and the enum is `#[non_exhaustive]` so new
90/// representations stay additive: write that `other` arm and your code keeps
91/// building everywhere.
92#[non_exhaustive]
93pub enum Surface {
94	/// Zero-copy GPU surface (macOS `CVPixelBuffer`), from capture or a
95	/// VideoToolbox decode.
96	#[cfg(target_os = "macos")]
97	PixelBuffer(macos::PixelBuffer),
98	/// Zero-copy GPU texture (Windows Direct3D11 NV12).
99	#[cfg(target_os = "windows")]
100	Texture(d3d11::Texture),
101	/// Zero-copy GPU buffer (Linux CUDA NV12). Produced only by the NVDEC
102	/// decoder, consumed in place by the NVENC encoder.
103	#[cfg(all(target_os = "linux", feature = "nvdec"))]
104	Cuda(cuda::Frame),
105	/// CPU-resident planar I420.
106	I420(I420),
107}
108
109impl Surface {
110	/// The frame width in pixels.
111	pub fn width(&self) -> u32 {
112		match self {
113			#[cfg(target_os = "macos")]
114			Surface::PixelBuffer(s) => s.width,
115			#[cfg(target_os = "windows")]
116			Surface::Texture(t) => t.width,
117			#[cfg(all(target_os = "linux", feature = "nvdec"))]
118			Surface::Cuda(c) => c.width,
119			Surface::I420(i) => i.width,
120		}
121	}
122
123	/// The frame height in pixels.
124	pub fn height(&self) -> u32 {
125		match self {
126			#[cfg(target_os = "macos")]
127			Surface::PixelBuffer(s) => s.height,
128			#[cfg(target_os = "windows")]
129			Surface::Texture(t) => t.height,
130			#[cfg(all(target_os = "linux", feature = "nvdec"))]
131			Surface::Cuda(c) => c.height,
132			Surface::I420(i) => i.height,
133		}
134	}
135
136	/// Convert tightly-packed RGBA (`width * height * 4` bytes, no row padding) to
137	/// a CPU I420 surface in [`Color::infer`]'s color space for `size`, limited
138	/// range. The result reports it via [`I420::color`], and an encoder writes it
139	/// into the bitstream, so the pixels and their label cannot disagree.
140	///
141	/// The bring-your-own-pixels entry point: wrap the result in a [`Frame`] to
142	/// encode it. A capture source or decoder hands you a surface directly, often a
143	/// GPU one, so don't route those through here.
144	pub fn rgba(rgba: &[u8], size: Size) -> Result<Self, Error> {
145		size.validate("RGBA frame")?;
146		let expected = size.pixels() as usize * 4;
147		if rgba.len() != expected {
148			return Err(Error::Codec(anyhow::anyhow!(
149				"RGBA buffer is {} bytes, expected {expected} for {size}",
150				rgba.len()
151			)));
152		}
153		Ok(Surface::I420(I420::from_rgba(
154			rgba,
155			size.width * 4,
156			size.width,
157			size.height,
158		)?))
159	}
160
161	/// A copy scaled to `size`, staying on the GPU for CUDA and macOS pixel-buffer
162	/// surfaces. The pixel half of [`Frame::resize`], which is what you usually
163	/// want since it carries the timestamp across too.
164	pub fn resize(&self, size: Size) -> Result<Surface, Error> {
165		size.validate("resize to")?;
166		let Size { width, height } = size;
167
168		Ok(match self {
169			Surface::I420(i420) => Surface::I420(i420.resize(width, height)?),
170			#[cfg(target_os = "macos")]
171			Surface::PixelBuffer(pixels) => match pixels.resize(width, height) {
172				Ok(scaled) => Surface::PixelBuffer(scaled),
173				// A transfer session or pool can fail on older hardware. Keep the
174				// stream alive with the universal CPU path.
175				Err(err) => {
176					static WARN_ONCE: std::sync::Once = std::sync::Once::new();
177					WARN_ONCE.call_once(|| tracing::warn!(%err, "GPU resize failed; falling back to the CPU"));
178					Surface::I420(pixels.download_i420()?.resize(width, height)?)
179				}
180			},
181			#[cfg(all(target_os = "linux", feature = "nvdec"))]
182			Surface::Cuda(cuda) => match cuda.resize(width, height) {
183				Ok(scaled) => Surface::Cuda(scaled),
184				// E.g. the driver rejected the vendored PTX: degrade to a CPU
185				// resize (download once) instead of killing the stream.
186				Err(err) => {
187					static WARN_ONCE: std::sync::Once = std::sync::Once::new();
188					WARN_ONCE.call_once(|| tracing::warn!(%err, "GPU resize failed; falling back to the CPU"));
189					Surface::I420(cuda.download_i420()?.resize(width, height)?)
190				}
191			},
192			// D3D11 textures have no GPU scaler wired up yet, so a ladder fanning one
193			// decoded frame out to several sizes downloads it once per rung.
194			#[allow(unreachable_patterns)]
195			other => Surface::I420(other.to_i420()?.into_owned().resize(width, height)?),
196		})
197	}
198
199	/// The pixels as tightly-packed I420 (YUV 4:2:0): Y (`width * height` bytes),
200	/// then U, then V (`width/2 * height/2` each), no row padding.
201	///
202	/// Bytes only, so the color space does not come along. Take it from
203	/// [`I420::color`] first if you need to interpret these samples, since this
204	/// consumes the surface.
205	///
206	/// Always available, whichever variant you hold, so it is the universal arm of
207	/// a `match`. Free for `Surface::I420`; downloads any GPU surface.
208	pub fn into_i420(self) -> Result<Bytes, Error> {
209		match self {
210			Surface::I420(i420) => Ok(Bytes::from(i420.data)),
211			#[allow(unreachable_patterns)]
212			other => Ok(Bytes::from(other.to_i420()?.into_owned().data)),
213		}
214	}
215
216	/// The pixels as a CoreVideo pixel buffer, the mirror of
217	/// [`into_i420`](Self::into_i420) pointing the other way.
218	///
219	/// Free for `Surface::PixelBuffer` (a retain, staying on the GPU);
220	/// a CPU frame is uploaded into a fresh buffer, so this always yields something
221	/// drawable rather than making you write the upload. Wrap it in a
222	/// `CVMetalTextureCache` to render it.
223	///
224	/// Check `CVPixelBufferGetPixelFormatType` before sampling: a hardware decode
225	/// gives NV12 (bi-planar), an uploaded CPU frame planar I420.
226	///
227	/// A decoded buffer comes from the decoder's pool, so holding many frames holds
228	/// pool slots and eventually stalls decoding. Draw and drop.
229	#[cfg(target_os = "macos")]
230	pub fn into_pixel_buffer(
231		self,
232	) -> Result<objc2_core_foundation::CFRetained<objc2_core_video::CVPixelBuffer>, Error> {
233		match self {
234			Surface::PixelBuffer(pixels) => Ok(pixels.buffer),
235			Surface::I420(i420) => macos::upload_i420(&i420),
236		}
237	}
238
239	/// The color space these samples are in, when it is known rather than
240	/// guessed. `None` for a GPU surface whose format names none, and for pixels
241	/// that merely passed through without anything naming their space.
242	///
243	/// Worth reading before encoding pixels you resized: [`resize`](Self::resize)
244	/// carries the space across, so a frame scaled past 576 lines no longer
245	/// matches what an encoder sized for the result would infer. Pass this to
246	/// [`encode::Config::color`](crate::encode::Config::color) to keep the label
247	/// honest.
248	pub fn color(&self) -> Option<Color> {
249		match self {
250			#[cfg(target_os = "macos")]
251			Surface::PixelBuffer(s) => s.color(),
252			#[cfg(target_os = "windows")]
253			Surface::Texture(_) => None,
254			#[cfg(all(target_os = "linux", feature = "nvdec"))]
255			Surface::Cuda(_) => None,
256			Surface::I420(i) => i.color(),
257		}
258	}
259
260	/// A CPU I420 view, downloading a GPU frame only if necessary.
261	pub(crate) fn to_i420(&self) -> Result<Cow<'_, I420>, Error> {
262		match self {
263			#[cfg(target_os = "macos")]
264			Surface::PixelBuffer(s) => Ok(Cow::Owned(s.download_i420()?)),
265			#[cfg(target_os = "windows")]
266			Surface::Texture(t) => Ok(Cow::Owned(t.download_i420()?)),
267			#[cfg(all(target_os = "linux", feature = "nvdec"))]
268			Surface::Cuda(c) => Ok(Cow::Owned(c.download_i420()?)),
269			Surface::I420(i) => Ok(Cow::Borrowed(i)),
270		}
271	}
272}
273
274/// A raw video frame in planar I420 (YUV 4:2:0), tightly packed (no padding),
275/// at the encoder resolution. Width and height are even (chroma is 2x2).
276#[derive(Clone)]
277pub struct I420 {
278	pub(crate) width: u32,
279	pub(crate) height: u32,
280	/// Y plane (`width * height`) then U then V (`width/2 * height/2` each).
281	pub(crate) data: Vec<u8>,
282	/// The color space these samples are in, when it is known rather than
283	/// guessed. Set by the conversions that pick a matrix themselves; `None`
284	/// where the pixels only passed through (a decode, a camera) and the
285	/// bitstream's answer did not come with them.
286	pub(crate) color: Option<Color>,
287}
288
289impl I420 {
290	/// Wrap tightly-packed I420 planes: Y (`width * height`), then U, then V
291	/// (`width/2 * height/2` each), no row padding.
292	///
293	/// Both dimensions must be even and non-zero (4:2:0 chroma is 2x2), and `data`
294	/// must be exactly [`I420::len`] bytes. Checked here so a short buffer can't
295	/// reach a plane split and panic downstream.
296	pub fn new(width: u32, height: u32, data: Vec<u8>) -> Result<Self, Error> {
297		crate::Size::new(width, height).validate("I420")?;
298		let expected = Self::len(width, height);
299		if data.len() != expected {
300			return Err(Error::Codec(anyhow::anyhow!(
301				"I420 {width}x{height} needs {expected} bytes, got {}",
302				data.len()
303			)));
304		}
305		Ok(Self {
306			width,
307			height,
308			data,
309			color: None,
310		})
311	}
312
313	/// The frame width in pixels.
314	pub fn width(&self) -> u32 {
315		self.width
316	}
317
318	/// The frame height in pixels.
319	pub fn height(&self) -> u32 {
320		self.height
321	}
322
323	/// The packed planes, Y then U then V.
324	pub fn data(&self) -> &[u8] {
325		&self.data
326	}
327
328	/// The color space these samples are in, or `None` when the crate does not
329	/// know: the pixels came out of a decoder or a camera, and the bitstream's
330	/// color description did not travel with them.
331	///
332	/// Anything converting these samples to RGB needs an answer either way, so
333	/// treat `None` as "fall back to [`Color::infer`]" rather than "does not
334	/// matter". Use [`with_color`](Self::with_color) if you know better.
335	pub fn color(&self) -> Option<Color> {
336		self.color
337	}
338
339	/// Declare the color space of these samples, for a caller who knows it (the
340	/// stream's VUI, a camera's documented output) where the crate cannot.
341	pub fn with_color(mut self, color: Color) -> Self {
342		self.color = Some(color);
343		self
344	}
345
346	/// Tightly-packed I420 byte length for the given even dimensions.
347	pub fn len(width: u32, height: u32) -> usize {
348		let luma = width as usize * height as usize;
349		luma + luma / 2
350	}
351
352	/// Convert RGBA (`stride` bytes per row, >= `width * 4`) to I420 in
353	/// [`Color::infer`]'s color space for this size, limited range. Used by
354	/// [`Surface::rgba`] (tightly packed) and the screen-capture paths, whose
355	/// surfaces carry a driver-chosen row pitch.
356	pub(crate) fn from_rgba(rgba: &[u8], stride: u32, width: u32, height: u32) -> Result<Self, Error> {
357		let color = Color::infer(Size::new(width, height));
358		let (range, matrix) = color.yuv();
359		let mut planar = YuvPlanarImageMut::alloc(width, height, YuvChromaSubsampling::Yuv420);
360		rgba_to_yuv420(&mut planar, rgba, stride, range, matrix, YuvConversionMode::Balanced)
361			.map_err(|e| Error::Codec(anyhow::anyhow!("rgba_to_yuv420 failed for {width}x{height}: {e}")))?;
362		Ok(Self::pack(&planar, width, height, Some(color)))
363	}
364
365	/// Convert BGRA to I420 in [`Color::infer`]'s color space for this size.
366	/// `stride` is the source row pitch in bytes (>= `width * 4`), so a padded
367	/// surface maps directly. Used by the screen-capture paths: Windows Desktop
368	/// Duplication (BGRA staging texture) and Linux PipeWire (BGRx/BGRA
369	/// shared-memory buffers).
370	#[cfg(any(target_os = "windows", all(target_os = "linux", feature = "pipewire")))]
371	pub(crate) fn from_bgra(bgra: &[u8], stride: u32, width: u32, height: u32) -> Result<Self, Error> {
372		use yuv::bgra_to_yuv420;
373
374		let color = Color::infer(Size::new(width, height));
375		let (range, matrix) = color.yuv();
376		let mut planar = YuvPlanarImageMut::alloc(width, height, YuvChromaSubsampling::Yuv420);
377		bgra_to_yuv420(&mut planar, bgra, stride, range, matrix, YuvConversionMode::Balanced)
378			.map_err(|e| Error::Codec(anyhow::anyhow!("bgra_to_yuv420 failed for {width}x{height}: {e}")))?;
379		Ok(Self::pack(&planar, width, height, Some(color)))
380	}
381
382	/// Pack strided Y/U/V planes (4:2:0, full-size luma, half-size chroma) into a
383	/// tightly-packed I420 buffer. `y_stride` / `uv_stride` are the source row
384	/// strides, which a decoder may pad wider than the visible width. Used by the
385	/// software H.264 decode backend, whose `DecodedYUV` exposes strided planes.
386	/// Width and height must be even (4:2:0 chroma).
387	pub(crate) fn from_planes(
388		y: &[u8],
389		u: &[u8],
390		v: &[u8],
391		y_stride: usize,
392		uv_stride: usize,
393		width: u32,
394		height: u32,
395	) -> Self {
396		let (w, h) = (width as usize, height as usize);
397		let (cw, ch) = (w / 2, h / 2);
398
399		let mut data = vec![0u8; Self::len(width, height)];
400		let (luma, chroma) = data.split_at_mut(w * h);
401		let (u_dst, v_dst) = chroma.split_at_mut(cw * ch);
402
403		for row in 0..h {
404			luma[row * w..row * w + w].copy_from_slice(&y[row * y_stride..row * y_stride + w]);
405		}
406		for row in 0..ch {
407			u_dst[row * cw..row * cw + cw].copy_from_slice(&u[row * uv_stride..row * uv_stride + cw]);
408			v_dst[row * cw..row * cw + cw].copy_from_slice(&v[row * uv_stride..row * uv_stride + cw]);
409		}
410
411		Self {
412			width,
413			height,
414			data,
415			color: None,
416		}
417	}
418
419	/// Convert tightly-packed RGB (`width * height * 3` bytes) to I420 in
420	/// [`Color::infer`]'s color space for this size. Used for MJPEG capture
421	/// (Linux V4L2), which decodes to RGB.
422	#[cfg(target_os = "linux")]
423	pub(crate) fn from_rgb(rgb: &[u8], width: u32, height: u32) -> Result<Self, Error> {
424		use yuv::rgb_to_yuv420;
425
426		let color = Color::infer(Size::new(width, height));
427		let (range, matrix) = color.yuv();
428		let mut planar = YuvPlanarImageMut::alloc(width, height, YuvChromaSubsampling::Yuv420);
429		rgb_to_yuv420(&mut planar, rgb, width * 3, range, matrix, YuvConversionMode::Balanced)
430			.map_err(|e| Error::Codec(anyhow::anyhow!("rgb_to_yuv420 failed for {width}x{height}: {e}")))?;
431		Ok(Self::pack(&planar, width, height, Some(color)))
432	}
433
434	/// Convert packed YUYV (YUV 4:2:2, `stride` bytes per row) to I420. A chroma
435	/// resample (4:2:2 -> 4:2:0), no color-space conversion. Used for the raw
436	/// V4L2 capture path (Linux).
437	#[cfg(target_os = "linux")]
438	pub(crate) fn from_yuyv(yuyv: &[u8], stride: u32, width: u32, height: u32) -> Result<Self, Error> {
439		use yuv::{YuvPackedImage, yuyv422_to_yuv420};
440
441		let mut planar = YuvPlanarImageMut::alloc(width, height, YuvChromaSubsampling::Yuv420);
442		let packed = YuvPackedImage {
443			yuy: yuyv,
444			yuy_stride: stride,
445			width,
446			height,
447		};
448		yuyv422_to_yuv420(&mut planar, &packed)
449			.map_err(|e| Error::Codec(anyhow::anyhow!("yuyv422_to_yuv420 failed for {width}x{height}: {e}")))?;
450		// A chroma resample, not a color conversion: these samples are in
451		// whatever space the camera produced, which nothing here names.
452		Ok(Self::pack(&planar, width, height, None))
453	}
454
455	/// Split tightly-packed NV12 (Y plane `width * height`, then interleaved UV
456	/// `width/2 * height/2` pairs) into planar I420. A chroma deinterleave, no
457	/// color-space conversion. Used for the Windows Media Foundation capture path,
458	/// whose source reader hands us NV12.
459	#[cfg(target_os = "windows")]
460	pub(crate) fn from_nv12(nv12: &[u8], width: u32, height: u32) -> Result<Self, Error> {
461		let (w, h) = (width as usize, height as usize);
462		let luma = w * h;
463		let chroma = luma / 4;
464		let need = luma + 2 * chroma;
465		if nv12.len() < need {
466			return Err(Error::Codec(anyhow::anyhow!(
467				"NV12 buffer too small: {} < {need} for {width}x{height}",
468				nv12.len()
469			)));
470		}
471
472		let mut data = vec![0u8; Self::len(width, height)];
473		data[..luma].copy_from_slice(&nv12[..luma]);
474		let (u_dst, v_dst) = data[luma..].split_at_mut(chroma);
475		deinterleave_uv(&nv12[luma..need], u_dst, v_dst);
476		Ok(Self {
477			width,
478			height,
479			data,
480			color: None,
481		})
482	}
483
484	/// Resize to `width` x `height` (both even) with a per-plane SIMD bilinear
485	/// convolution: Y at full size, U/V at quarter size. The CPU half of
486	/// [`Frame::resize`].
487	pub(crate) fn resize(&self, width: u32, height: u32) -> Result<Self, Error> {
488		use std::cell::RefCell;
489
490		use fast_image_resize::images::{Image, ImageRef};
491		use fast_image_resize::{FilterType, PixelType, ResizeAlg, ResizeOptions, Resizer};
492
493		// The resizer caches its convolution state; recreating it per frame on a
494		// live path would throw that away, so keep one per thread (decode/encode
495		// loops are single-threaded).
496		thread_local! {
497			static RESIZER: RefCell<Resizer> = RefCell::new(Resizer::new());
498		}
499
500		// Bilinear convolution: proper filter support at any downscale factor,
501		// the cheapest option that doesn't alias.
502		let options = ResizeOptions::new().resize_alg(ResizeAlg::Convolution(FilterType::Bilinear));
503
504		let plane = |resizer: &mut Resizer,
505		             src: &[u8],
506		             sw: u32,
507		             sh: u32,
508		             dst: &mut [u8],
509		             dw: u32,
510		             dh: u32|
511		 -> Result<(), Error> {
512			let src = ImageRef::new(sw, sh, src, PixelType::U8)
513				.map_err(|e| Error::Codec(anyhow::anyhow!("resize source: {e}")))?;
514			let mut dst = Image::from_slice_u8(dw, dh, dst, PixelType::U8)
515				.map_err(|e| Error::Codec(anyhow::anyhow!("resize destination: {e}")))?;
516			resizer
517				.resize(&src, &mut dst, &options)
518				.map_err(|e| Error::Codec(anyhow::anyhow!("resize: {e}")))
519		};
520
521		let luma = width as usize * height as usize;
522		let mut data = vec![0u8; Self::len(width, height)];
523		let (y_dst, chroma) = data.split_at_mut(luma);
524		let (u_dst, v_dst) = chroma.split_at_mut(luma / 4);
525
526		RESIZER.with_borrow_mut(|resizer| {
527			plane(resizer, self.y(), self.width, self.height, y_dst, width, height)?;
528			let (sw2, sh2) = (self.width / 2, self.height / 2);
529			let (dw2, dh2) = (width / 2, height / 2);
530			plane(resizer, self.u(), sw2, sh2, u_dst, dw2, dh2)?;
531			plane(resizer, self.v(), sw2, sh2, v_dst, dw2, dh2)
532		})?;
533
534		// Resampling moves samples around, it does not reinterpret them.
535		Ok(Self {
536			width,
537			height,
538			data,
539			color: self.color,
540		})
541	}
542
543	/// Flatten the three planes of a freshly-converted image into one tightly
544	/// packed I420 buffer (Y, then U, then V).
545	/// `color` is what the caller's conversion produced: the RGB conversions pick
546	/// a matrix, so they know it outright, while a caller that only resamples
547	/// chroma passes `None` and leaves the samples' space open.
548	fn pack(planar: &YuvPlanarImageMut<u8>, width: u32, height: u32, color: Option<Color>) -> Self {
549		let mut data = Vec::with_capacity(Self::len(width, height));
550		data.extend_from_slice(planar.y_plane.borrow());
551		data.extend_from_slice(planar.u_plane.borrow());
552		data.extend_from_slice(planar.v_plane.borrow());
553		Self {
554			width,
555			height,
556			data,
557			color,
558		}
559	}
560
561	fn luma_len(&self) -> usize {
562		self.width as usize * self.height as usize
563	}
564
565	fn chroma_len(&self) -> usize {
566		self.luma_len() / 4
567	}
568
569	/// The Y (luma) plane, `width * height` bytes.
570	pub fn y(&self) -> &[u8] {
571		&self.data[..self.luma_len()]
572	}
573
574	/// The U (chroma) plane, `width/2 * height/2` bytes.
575	pub fn u(&self) -> &[u8] {
576		let start = self.luma_len();
577		&self.data[start..start + self.chroma_len()]
578	}
579
580	/// The V (chroma) plane, `width/2 * height/2` bytes.
581	pub fn v(&self) -> &[u8] {
582		let start = self.luma_len() + self.chroma_len();
583		&self.data[start..start + self.chroma_len()]
584	}
585}
586
587/// Interleave separate U and V planes into a packed NV12 chroma plane
588/// (`u[i], v[i]` -> `uv[2i], uv[2i+1]`). `uv` must be twice the length of `u`.
589#[cfg(any(target_os = "windows", all(target_os = "linux", feature = "nvenc")))]
590pub(crate) fn interleave_uv(u: &[u8], v: &[u8], uv: &mut [u8]) {
591	for (pair, (u, v)) in uv.chunks_exact_mut(2).zip(u.iter().zip(v)) {
592		pair[0] = *u;
593		pair[1] = *v;
594	}
595}
596
597/// Split a packed NV12 chroma plane into separate U and V planes, the inverse of
598/// [`interleave_uv`].
599#[cfg(target_os = "windows")]
600pub(crate) fn deinterleave_uv(uv: &[u8], u: &mut [u8], v: &mut [u8]) {
601	for (pair, (u, v)) in uv.chunks_exact(2).zip(u.iter_mut().zip(v)) {
602		*u = pair[0];
603		*v = pair[1];
604	}
605}
606
607#[cfg(target_os = "macos")]
608pub mod macos {
609	//! macOS CoreVideo surfaces: the [`PixelBuffer`] behind
610	//! `Surface::PixelBuffer`, GPU resize, and download/upload between it and CPU
611	//! I420.
612
613	use std::collections::{HashMap, VecDeque};
614	use std::ffi::c_void;
615	use std::ptr;
616	use std::ptr::NonNull;
617	use std::sync::{Arc, LazyLock, Mutex};
618
619	use objc2_core_foundation::{CFDictionary, CFNumber, CFNumberType, CFRetained, CFString};
620	use objc2_core_video::{
621		CVPixelBuffer, CVPixelBufferCreate, CVPixelBufferGetBaseAddressOfPlane, CVPixelBufferGetBytesPerRowOfPlane,
622		CVPixelBufferGetPixelFormatType, CVPixelBufferLockBaseAddress, CVPixelBufferLockFlags, CVPixelBufferPool,
623		CVPixelBufferUnlockBaseAddress, kCVImageBufferYCbCrMatrix_ITU_R_601_4, kCVImageBufferYCbCrMatrix_ITU_R_709_2,
624		kCVImageBufferYCbCrMatrixKey, kCVPixelBufferHeightKey, kCVPixelBufferIOSurfacePropertiesKey,
625		kCVPixelBufferPixelFormatTypeKey, kCVPixelBufferWidthKey, kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
626		kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, kCVPixelFormatType_420YpCbCr8Planar,
627	};
628	use objc2_video_toolbox::VTPixelTransferSession;
629
630	use super::I420;
631	use crate::{Color, Error};
632
633	/// Read-only lock flag (`kCVPixelBufferLock_ReadOnly`).
634	const LOCK_READ_ONLY: CVPixelBufferLockFlags = CVPixelBufferLockFlags(1);
635
636	/// Enough reusable scalers for a large rendition ladder without retaining
637	/// every resolution a long-lived process has ever seen.
638	const SCALER_CACHE_CAPACITY: usize = 16;
639
640	/// Transfer sessions and destination pools are reusable, but VideoToolbox does
641	/// not promise concurrent access to a session. Each cached output size gets
642	/// its own serialized scaler so independent ladder rungs do not contend.
643	type ScalerCache = Mutex<Cache<Scaler>>;
644	static SCALERS: LazyLock<ScalerCache> = LazyLock::new(|| Mutex::new(Cache::new(SCALER_CACHE_CAPACITY)));
645
646	/// A bounded least-recently-used cache that never evicts a value in use.
647	struct Cache<T> {
648		values: HashMap<(u32, u32), Arc<Mutex<T>>>,
649		order: VecDeque<(u32, u32)>,
650		capacity: usize,
651	}
652
653	impl<T> Cache<T> {
654		fn new(capacity: usize) -> Self {
655			Self {
656				values: HashMap::new(),
657				order: VecDeque::new(),
658				capacity,
659			}
660		}
661
662		fn get_or_insert_with<E>(
663			&mut self,
664			key: (u32, u32),
665			create: impl FnOnce() -> Result<T, E>,
666		) -> Result<Arc<Mutex<T>>, E> {
667			if let Some(value) = self.values.get(&key).cloned() {
668				self.touch(key);
669				return Ok(value);
670			}
671
672			let value = Arc::new(Mutex::new(create()?));
673			self.values.insert(key, Arc::clone(&value));
674			self.touch(key);
675			self.prune();
676			Ok(value)
677		}
678
679		fn touch(&mut self, key: (u32, u32)) {
680			self.order.retain(|entry| *entry != key);
681			self.order.push_back(key);
682		}
683
684		fn prune(&mut self) {
685			let mut remaining = self.order.len();
686			while self.values.len() > self.capacity && remaining > 0 {
687				let key = self.order.pop_front().expect("remaining entries");
688				let idle = self.values.get(&key).is_some_and(|value| Arc::strong_count(value) == 1);
689				if idle {
690					self.values.remove(&key);
691				} else {
692					self.order.push_back(key);
693				}
694				remaining -= 1;
695			}
696		}
697	}
698
699	/// A captured GPU surface. Cloning is a cheap retain (no pixel copy), which
700	/// is what keeps the capture -> encode path zero-copy.
701	pub struct PixelBuffer {
702		pub(crate) buffer: CFRetained<CVPixelBuffer>,
703		pub(crate) width: u32,
704		pub(crate) height: u32,
705	}
706
707	// SAFETY: CVPixelBuffer is a reference-counted CoreFoundation wrapper around
708	// an IOSurface. Retain/release are thread-safe, every &self access is a
709	// plain field read or a read-only CVPixelBufferLockBaseAddress, and no code
710	// path write-locks a shared surface, so the handle can move between threads
711	// (capture delegate -> encode loop, decode callback -> consumer) and be
712	// shared by reference. objc2 leaves CoreVideo types !Send/!Sync out of
713	// conservatism. Sync is load-bearing: the VideoToolbox decoder hands these
714	// out as decoded frames, and moq-transcode shares them as Arc<Frame>
715	// across its rung fanout.
716	unsafe impl Send for PixelBuffer {}
717	unsafe impl Sync for PixelBuffer {}
718
719	impl PixelBuffer {
720		/// The underlying CoreVideo buffer, to hand to Metal or another CoreVideo
721		/// consumer. Borrowing keeps it on the GPU.
722		pub fn buffer(&self) -> &CVPixelBuffer {
723			&self.buffer
724		}
725
726		/// The buffer width in pixels.
727		pub fn width(&self) -> u32 {
728			self.width
729		}
730
731		/// The buffer height in pixels.
732		pub fn height(&self) -> u32 {
733			self.height
734		}
735
736		pub(crate) fn new(buffer: CFRetained<CVPixelBuffer>, width: u32, height: u32) -> Self {
737			Self { buffer, width, height }
738		}
739
740		/// Scale into an NV12 buffer owned by the destination-size pool.
741		pub(crate) fn resize(&self, width: u32, height: u32) -> Result<Self, Error> {
742			let scaler = {
743				let mut scalers = SCALERS
744					.lock()
745					.map_err(|_| Error::Codec(anyhow::anyhow!("pixel-transfer scaler cache lock poisoned")))?;
746				scalers.get_or_insert_with((width, height), || Scaler::new(width, height))?
747			};
748
749			let result = scaler
750				.lock()
751				.map_err(|_| Error::Codec(anyhow::anyhow!("pixel-transfer scaler lock poisoned")))?
752				.resize(self);
753			drop(scaler);
754			if let Ok(mut scalers) = SCALERS.lock() {
755				scalers.prune();
756			}
757			result
758		}
759
760		/// The color space this buffer's matrix attachment names, falling back to
761		/// [`Color::infer`] when it carries none.
762		///
763		/// VideoToolbox copies the matrix out of the stream's VUI onto every decoded
764		/// buffer, so this is the source's own answer wherever the source gave one.
765		/// The range is not in this attachment; the caller pairs it with the one the
766		/// pixel format names.
767		fn matrix(&self) -> Color {
768			let inferred = Color::infer(crate::Size::new(self.width, self.height));
769			// SAFETY: a null attachment mode is documented as "don't report it".
770			let Some(value) = (unsafe { self.buffer.attachment(kCVImageBufferYCbCrMatrixKey, ptr::null_mut()) }) else {
771				return inferred;
772			};
773			let Some(name) = value.downcast_ref::<CFString>() else {
774				return inferred;
775			};
776
777			// Compare against the constants rather than the string literals: these
778			// are CFString identities Apple owns, not values we should spell out.
779			if name == unsafe { kCVImageBufferYCbCrMatrix_ITU_R_709_2 } {
780				Color::Bt709Limited
781			} else if name == unsafe { kCVImageBufferYCbCrMatrix_ITU_R_601_4 } {
782				Color::Bt601Limited
783			} else {
784				// BT.2020 and the P3 matrices land here. We have no variant for them,
785				// so the size guess is the least wrong answer available.
786				inferred
787			}
788		}
789
790		/// The color space these samples are in: the matrix from the buffer's
791		/// attachment paired with the range its pixel format names. `None` for a
792		/// format that names neither.
793		pub(crate) fn color(&self) -> Option<Color> {
794			let format = CVPixelBufferGetPixelFormatType(&self.buffer);
795			let limited = if format == kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange {
796				true
797			} else if format == kCVPixelFormatType_420YpCbCr8BiPlanarFullRange {
798				false
799			} else {
800				return None;
801			};
802			Some(self.matrix().with_range(limited))
803		}
804
805		/// Download an NV12 surface to packed I420 (the CPU encode path).
806		///
807		/// A deinterleave, not a color conversion, so the samples keep whatever
808		/// space they arrived in. The pixel format names the range and the buffer's
809		/// matrix attachment names the matrix, so a decoded frame reports the space
810		/// its own bitstream declared rather than one guessed from its size.
811		pub(crate) fn download_i420(&self) -> Result<I420, Error> {
812			let format = CVPixelBufferGetPixelFormatType(&self.buffer);
813			if format != kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
814				&& format != kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
815			{
816				return Err(Error::Codec(anyhow::anyhow!(
817					"cannot download pixel format {format:#x}; expected NV12"
818				)));
819			}
820
821			let color = self.color();
822
823			let (w, h) = (self.width as usize, self.height as usize);
824			let (cw, ch) = (w / 2, h / 2);
825
826			let status = unsafe { CVPixelBufferLockBaseAddress(&self.buffer, LOCK_READ_ONLY) };
827			if status != 0 {
828				return Err(Error::Codec(anyhow::anyhow!(
829					"CVPixelBufferLockBaseAddress failed: {status}"
830				)));
831			}
832			let _guard = UnlockGuard(&self.buffer);
833
834			let mut data = vec![0u8; I420::len(self.width, self.height)];
835			let (luma, chroma) = data.split_at_mut(w * h);
836			let (u_plane, v_plane) = chroma.split_at_mut(cw * ch);
837
838			// Plane 0: Y, copied row by row honoring stride.
839			let y_base = CVPixelBufferGetBaseAddressOfPlane(&self.buffer, 0) as *const u8;
840			let y_stride = CVPixelBufferGetBytesPerRowOfPlane(&self.buffer, 0);
841			for row in 0..h {
842				unsafe {
843					ptr::copy_nonoverlapping(y_base.add(row * y_stride), luma[row * w..].as_mut_ptr(), w);
844				}
845			}
846
847			// Plane 1: interleaved UV -> split into U and V.
848			let uv_base = CVPixelBufferGetBaseAddressOfPlane(&self.buffer, 1) as *const u8;
849			let uv_stride = CVPixelBufferGetBytesPerRowOfPlane(&self.buffer, 1);
850			for row in 0..ch {
851				let src = unsafe { uv_base.add(row * uv_stride) };
852				for col in 0..cw {
853					unsafe {
854						u_plane[row * cw + col] = *src.add(col * 2);
855						v_plane[row * cw + col] = *src.add(col * 2 + 1);
856					}
857				}
858			}
859
860			Ok(I420 {
861				width: self.width,
862				height: self.height,
863				data,
864				color,
865			})
866		}
867	}
868
869	/// One VideoToolbox transfer session and destination pool for an output size.
870	struct Scaler {
871		session: CFRetained<VTPixelTransferSession>,
872		pool: CFRetained<CVPixelBufferPool>,
873		width: u32,
874		height: u32,
875	}
876
877	// SAFETY: the cache only exposes a Scaler behind its per-size Mutex, so the
878	// transfer session and pool are used and released serially even when resize
879	// calls arrive on different executor threads.
880	unsafe impl Send for Scaler {}
881
882	impl Scaler {
883		fn new(width: u32, height: u32) -> Result<Self, Error> {
884			let mut session_ptr: *mut VTPixelTransferSession = std::ptr::null_mut();
885			let status = unsafe {
886				VTPixelTransferSession::create(None, NonNull::new(&mut session_ptr).expect("stack pointer is non-null"))
887			};
888			let session = NonNull::new(session_ptr)
889				.filter(|_| status == 0)
890				.map(|ptr| unsafe { CFRetained::from_raw(ptr) })
891				.ok_or_else(|| Error::Codec(anyhow::anyhow!("VTPixelTransferSessionCreate failed: {status}")))?;
892
893			let attributes = pool_attributes(width, height)?;
894			let mut pool_ptr: *mut CVPixelBufferPool = std::ptr::null_mut();
895			let status = unsafe {
896				CVPixelBufferPool::create(
897					None,
898					None,
899					Some(&attributes),
900					NonNull::new(&mut pool_ptr).expect("stack pointer is non-null"),
901				)
902			};
903			let pool = NonNull::new(pool_ptr)
904				.filter(|_| status == 0)
905				.map(|ptr| unsafe { CFRetained::from_raw(ptr) })
906				.ok_or_else(|| Error::Codec(anyhow::anyhow!("CVPixelBufferPoolCreate failed: {status}")))?;
907
908			Ok(Self {
909				session,
910				pool,
911				width,
912				height,
913			})
914		}
915
916		fn resize(&mut self, source: &PixelBuffer) -> Result<PixelBuffer, Error> {
917			let mut output_ptr: *mut CVPixelBuffer = std::ptr::null_mut();
918			let status = unsafe {
919				CVPixelBufferPool::create_pixel_buffer(
920					None,
921					&self.pool,
922					NonNull::new(&mut output_ptr).expect("stack pointer is non-null"),
923				)
924			};
925			let output = NonNull::new(output_ptr)
926				.filter(|_| status == 0)
927				.map(|ptr| unsafe { CFRetained::from_raw(ptr) })
928				.ok_or_else(|| Error::Codec(anyhow::anyhow!("CVPixelBufferPoolCreatePixelBuffer failed: {status}")))?;
929
930			let status = unsafe { self.session.transfer_image(&source.buffer, &output) };
931			if status != 0 {
932				return Err(Error::Codec(anyhow::anyhow!(
933					"VTPixelTransferSessionTransferImage failed: {status}"
934				)));
935			}
936
937			Ok(PixelBuffer::new(output, self.width, self.height))
938		}
939	}
940
941	/// Build a reusable NV12 IOSurface pool for one output size.
942	fn pool_attributes(width: u32, height: u32) -> Result<CFRetained<CFDictionary>, Error> {
943		let width =
944			i32::try_from(width).map_err(|_| Error::Codec(anyhow::anyhow!("pixel-buffer width is too large")))?;
945		let height =
946			i32::try_from(height).map_err(|_| Error::Codec(anyhow::anyhow!("pixel-buffer height is too large")))?;
947		let format = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange as i32;
948
949		let width = cf_number(width)?;
950		let height = cf_number(height)?;
951		let format = cf_number(format)?;
952		let iosurface = unsafe {
953			CFDictionary::new(
954				None,
955				std::ptr::null_mut(),
956				std::ptr::null_mut(),
957				0,
958				&objc2_core_foundation::kCFTypeDictionaryKeyCallBacks,
959				&objc2_core_foundation::kCFTypeDictionaryValueCallBacks,
960			)
961		}
962		.ok_or_else(|| Error::Codec(anyhow::anyhow!("failed to build IOSurface attributes dictionary")))?;
963
964		let mut keys = [
965			(unsafe { kCVPixelBufferPixelFormatTypeKey } as *const CFString).cast::<c_void>(),
966			(unsafe { kCVPixelBufferWidthKey } as *const CFString).cast::<c_void>(),
967			(unsafe { kCVPixelBufferHeightKey } as *const CFString).cast::<c_void>(),
968			(unsafe { kCVPixelBufferIOSurfacePropertiesKey } as *const CFString).cast::<c_void>(),
969		];
970		let mut values = [
971			(format.as_ref() as *const CFNumber).cast::<c_void>(),
972			(width.as_ref() as *const CFNumber).cast::<c_void>(),
973			(height.as_ref() as *const CFNumber).cast::<c_void>(),
974			(iosurface.as_ref() as *const CFDictionary).cast::<c_void>(),
975		];
976		unsafe {
977			CFDictionary::new(
978				None,
979				keys.as_mut_ptr(),
980				values.as_mut_ptr(),
981				4,
982				&objc2_core_foundation::kCFTypeDictionaryKeyCallBacks,
983				&objc2_core_foundation::kCFTypeDictionaryValueCallBacks,
984			)
985		}
986		.ok_or_else(|| {
987			Error::Codec(anyhow::anyhow!(
988				"failed to build pixel-buffer pool attributes dictionary"
989			))
990		})
991	}
992
993	fn cf_number(value: i32) -> Result<CFRetained<CFNumber>, Error> {
994		unsafe { CFNumber::new(None, CFNumberType::SInt32Type, (&value as *const i32).cast::<c_void>()) }
995			.ok_or_else(|| Error::Codec(anyhow::anyhow!("failed to build CFNumber")))
996	}
997
998	struct UnlockGuard<'a>(&'a CVPixelBuffer);
999
1000	impl Drop for UnlockGuard<'_> {
1001		fn drop(&mut self) {
1002			unsafe { CVPixelBufferUnlockBaseAddress(self.0, LOCK_READ_ONLY) };
1003		}
1004	}
1005
1006	/// Allocate a planar I420 `CVPixelBuffer` and copy the frame into it: the
1007	/// upload half of [`Surface::into_i420`], for when the pixels are on the
1008	/// CPU but a CoreVideo consumer (the VideoToolbox encoder, a renderer) needs a
1009	/// buffer. Note the format is planar I420, not the NV12 a hardware decode
1010	/// hands back, so callers query `CVPixelBufferGetPixelFormatType`.
1011	pub(crate) fn upload_i420(frame: &I420) -> Result<CFRetained<CVPixelBuffer>, Error> {
1012		let (w, h) = (frame.width as usize, frame.height as usize);
1013		let (cw, ch) = (w / 2, h / 2);
1014
1015		let mut ptr: *mut CVPixelBuffer = std::ptr::null_mut();
1016		let status = unsafe {
1017			CVPixelBufferCreate(
1018				None,
1019				w,
1020				h,
1021				kCVPixelFormatType_420YpCbCr8Planar,
1022				None,
1023				NonNull::new(&mut ptr).unwrap(),
1024			)
1025		};
1026		let buffer = NonNull::new(ptr)
1027			.filter(|_| status == 0)
1028			.map(|p| unsafe { CFRetained::from_raw(p) })
1029			.ok_or_else(|| Error::Codec(anyhow::anyhow!("CVPixelBufferCreate failed: {status}")))?;
1030
1031		let flags = CVPixelBufferLockFlags(0);
1032		let status = unsafe { CVPixelBufferLockBaseAddress(&buffer, flags) };
1033		if status != 0 {
1034			return Err(Error::Codec(anyhow::anyhow!(
1035				"CVPixelBufferLockBaseAddress failed: {status}"
1036			)));
1037		}
1038
1039		copy_plane(&buffer, 0, frame.y(), w, h);
1040		copy_plane(&buffer, 1, frame.u(), cw, ch);
1041		copy_plane(&buffer, 2, frame.v(), cw, ch);
1042
1043		unsafe { CVPixelBufferUnlockBaseAddress(&buffer, flags) };
1044		Ok(buffer)
1045	}
1046
1047	/// Copy a tightly-packed source plane into a pixel-buffer plane, honoring its
1048	/// (possibly padded) row stride.
1049	fn copy_plane(buffer: &CVPixelBuffer, plane: usize, src: &[u8], row_bytes: usize, rows: usize) {
1050		let base = CVPixelBufferGetBaseAddressOfPlane(buffer, plane) as *mut u8;
1051		let stride = CVPixelBufferGetBytesPerRowOfPlane(buffer, plane);
1052		for y in 0..rows {
1053			unsafe {
1054				let dst = base.add(y * stride);
1055				std::ptr::copy_nonoverlapping(src[y * row_bytes..].as_ptr(), dst, row_bytes);
1056			}
1057		}
1058	}
1059
1060	#[cfg(test)]
1061	mod cache_tests {
1062		use super::Cache;
1063
1064		#[test]
1065		fn evicts_the_least_recently_used_idle_value() {
1066			let mut cache = Cache::new(2);
1067
1068			let first = cache.get_or_insert_with((1, 1), || Ok::<_, ()>(())).unwrap();
1069			drop(first);
1070			let second = cache.get_or_insert_with((2, 2), || Ok::<_, ()>(())).unwrap();
1071			drop(second);
1072
1073			let first = cache
1074				.get_or_insert_with((1, 1), || Err::<(), _>("cached value was recreated"))
1075				.unwrap();
1076			drop(first);
1077			let third = cache.get_or_insert_with((3, 3), || Ok::<_, ()>(())).unwrap();
1078			drop(third);
1079
1080			assert!(cache.values.contains_key(&(1, 1)));
1081			assert!(!cache.values.contains_key(&(2, 2)));
1082			assert!(cache.values.contains_key(&(3, 3)));
1083			assert_eq!(cache.values.len(), 2);
1084		}
1085
1086		#[test]
1087		fn defers_eviction_until_an_active_value_is_released() {
1088			let mut cache = Cache::new(1);
1089			let first = cache.get_or_insert_with((1, 1), || Ok::<_, ()>(())).unwrap();
1090			let second = cache.get_or_insert_with((2, 2), || Ok::<_, ()>(())).unwrap();
1091			assert_eq!(cache.values.len(), 2);
1092
1093			drop(first);
1094			cache.prune();
1095			assert!(!cache.values.contains_key(&(1, 1)));
1096			assert!(cache.values.contains_key(&(2, 2)));
1097			assert_eq!(cache.values.len(), 1);
1098			drop(second);
1099		}
1100	}
1101}
1102
1103#[cfg(all(target_os = "linux", feature = "nvdec"))]
1104pub mod cuda {
1105	//! Linux CUDA device memory: the NV12 [`Frame`] behind `Surface::Cuda`, which
1106	//! NVDEC produces and NVENC consumes in place.
1107
1108	use std::sync::{Arc, OnceLock};
1109
1110	use cudarc::driver::{CudaContext, CudaFunction, LaunchConfig, PushKernelArg, result};
1111
1112	use super::I420;
1113	use crate::Error;
1114
1115	/// The NV12 box-filter resize kernels, vendored as PTX (see nv12_resize.cu)
1116	/// and JIT-compiled by the driver, so building needs no CUDA toolkit.
1117	const RESIZE_PTX: &str = include_str!("frame/nv12_resize.ptx");
1118
1119	/// The loaded resize kernels, one per process (everything runs in the
1120	/// device's primary context, so one module serves every frame).
1121	struct Kernels {
1122		luma: CudaFunction,
1123		chroma: CudaFunction,
1124	}
1125
1126	fn kernels(ctx: &Arc<CudaContext>) -> Result<&'static Kernels, Error> {
1127		static KERNELS: OnceLock<Result<Kernels, String>> = OnceLock::new();
1128		KERNELS
1129			.get_or_init(|| {
1130				let module = ctx
1131					.load_module(cudarc::nvrtc::Ptx::from_src(RESIZE_PTX))
1132					.map_err(|e| format!("load nv12_resize PTX: {e:?}"))?;
1133				Ok(Kernels {
1134					luma: module
1135						.load_function("resize_luma")
1136						.map_err(|e| format!("load resize_luma: {e:?}"))?,
1137					chroma: module
1138						.load_function("resize_chroma")
1139						.map_err(|e| format!("load resize_chroma: {e:?}"))?,
1140				})
1141			})
1142			.as_ref()
1143			.map_err(|e| Error::Codec(anyhow::anyhow!("CUDA resize unavailable: {e}")))
1144	}
1145
1146	/// An owned device allocation. Plain `cuMemAlloc` on purpose: NVENC's
1147	/// resource registration rejects stream-ordered pool memory
1148	/// (`cuMemAllocAsync`), which is what cudarc's `CudaSlice` uses on any GPU
1149	/// with memory-pool support.
1150	struct Buffer {
1151		ctx: Arc<CudaContext>,
1152		ptr: cudarc::driver::sys::CUdeviceptr,
1153		len: usize,
1154	}
1155
1156	impl Drop for Buffer {
1157		fn drop(&mut self) {
1158			// Drop may run on any thread; freeing needs the context current.
1159			if self.ctx.bind_to_thread().is_ok() {
1160				// SAFETY: the pointer came from `malloc_sync` and is freed once.
1161				let _ = unsafe { result::free_sync(self.ptr) };
1162			}
1163		}
1164	}
1165
1166	/// A GPU NV12 frame in CUDA device memory: NVDEC's output and NVENC's
1167	/// zero-copy input. One buffer holds both planes at a shared row `pitch`:
1168	/// `height` luma rows, then `height / 2` interleaved-UV rows. Cloning bumps
1169	/// refcounts (no pixel copy), which keeps decode -> encode on the GPU.
1170	///
1171	/// Both codecs use the device's primary CUDA context (`CudaContext::new`
1172	/// retains it), so a frame decoded by NVDEC is directly addressable by NVENC.
1173	#[derive(Clone)]
1174	pub struct Frame {
1175		buf: Arc<Buffer>,
1176		pub(crate) width: u32,
1177		pub(crate) height: u32,
1178		/// Row pitch in bytes of both planes (>= `width`).
1179		pub(crate) pitch: u32,
1180	}
1181
1182	impl Frame {
1183		/// Allocate an NV12 buffer for `width` x `height` (both even) at row
1184		/// pitch `pitch`. Uninitialized: the caller copies the full extent in.
1185		pub(crate) fn alloc(ctx: &Arc<CudaContext>, width: u32, height: u32, pitch: u32) -> Result<Self, Error> {
1186			debug_assert!(pitch >= width && width.is_multiple_of(2) && height.is_multiple_of(2));
1187			let len = pitch as usize * height as usize * 3 / 2;
1188			ctx.bind_to_thread()
1189				.map_err(|e| Error::Codec(anyhow::anyhow!("CUDA bind: {e:?}")))?;
1190			// SAFETY: a plain device allocation; ownership lands in `Buffer`,
1191			// whose Drop frees it exactly once.
1192			let ptr = unsafe { result::malloc_sync(len) }
1193				.map_err(|e| Error::Codec(anyhow::anyhow!("CUDA alloc of {len} bytes: {e:?}")))?;
1194			Ok(Self {
1195				buf: Arc::new(Buffer {
1196					ctx: ctx.clone(),
1197					ptr,
1198					len,
1199				}),
1200				width,
1201				height,
1202				pitch,
1203			})
1204		}
1205
1206		/// The raw device pointer, for FFI (the NVDEC copy destination, the
1207		/// NVENC resource registration). Valid while `self` is alive.
1208		pub(crate) fn device_ptr(&self) -> u64 {
1209			self.buf.ptr
1210		}
1211
1212		/// Download and de-pitch to packed I420 (the CPU fallback: a software
1213		/// encoder, or a caller that wants bytes).
1214		pub(crate) fn download_i420(&self) -> Result<I420, Error> {
1215			self.buf
1216				.ctx
1217				.bind_to_thread()
1218				.map_err(|e| Error::Codec(anyhow::anyhow!("CUDA bind: {e:?}")))?;
1219			let mut host = vec![0u8; self.buf.len];
1220			// SAFETY: the buffer is `len` bytes of device memory and stays alive
1221			// for the synchronous copy.
1222			unsafe { result::memcpy_dtoh_sync(&mut host, self.buf.ptr) }
1223				.map_err(|e| Error::Codec(anyhow::anyhow!("CUDA download: {e:?}")))?;
1224
1225			let (w, h) = (self.width as usize, self.height as usize);
1226			let (cw, ch) = (w / 2, h / 2);
1227			let pitch = self.pitch as usize;
1228
1229			let mut data = vec![0u8; I420::len(self.width, self.height)];
1230			let (luma, chroma) = data.split_at_mut(w * h);
1231			let (u_dst, v_dst) = chroma.split_at_mut(cw * ch);
1232
1233			for row in 0..h {
1234				luma[row * w..row * w + w].copy_from_slice(&host[row * pitch..row * pitch + w]);
1235			}
1236			let uv_base = pitch * h;
1237			for row in 0..ch {
1238				let src = &host[uv_base + row * pitch..uv_base + row * pitch + w];
1239				for col in 0..cw {
1240					u_dst[row * cw + col] = src[col * 2];
1241					v_dst[row * cw + col] = src[col * 2 + 1];
1242				}
1243			}
1244
1245			Ok(I420 {
1246				width: self.width,
1247				height: self.height,
1248				data,
1249				// A deinterleave, not a color conversion, and nothing here names
1250				// the space these samples are in. Left unknown to be inferred.
1251				color: None,
1252			})
1253		}
1254
1255		/// Resize to `width` x `height` (both even) with the box-filter kernel,
1256		/// staying in device memory. The GPU half of
1257		/// [`Frame::resize`].
1258		pub(crate) fn resize(&self, width: u32, height: u32) -> Result<Self, Error> {
1259			let ctx = &self.buf.ctx;
1260			let kernels = kernels(ctx)?;
1261
1262			// Destination row pitch aligned to 256 bytes: comfortable coalescing
1263			// and a multiple of 4 as NVENC registration requires.
1264			let pitch = width.next_multiple_of(256);
1265			let dst = Self::alloc(ctx, width, height, pitch)?;
1266
1267			let stream = ctx.default_stream();
1268			let block = (16u32, 16, 1);
1269			let grid = |w: u32, h: u32| (w.div_ceil(16), h.div_ceil(16), 1);
1270			let launch_err = |plane: &str, e| Error::Codec(anyhow::anyhow!("CUDA resize {plane}: {e:?}"));
1271
1272			// Luma plane: one thread per destination pixel.
1273			//
1274			// SAFETY: both buffers are live NV12 allocations of pitch * height *
1275			// 3 / 2 bytes, and the kernels bound every access by the dimensions
1276			// passed alongside the pointers.
1277			unsafe {
1278				stream
1279					.launch_builder(&kernels.luma)
1280					.arg(&self.buf.ptr)
1281					.arg(&self.pitch)
1282					.arg(&self.width)
1283					.arg(&self.height)
1284					.arg(&dst.buf.ptr)
1285					.arg(&pitch)
1286					.arg(&width)
1287					.arg(&height)
1288					.launch(LaunchConfig {
1289						grid_dim: grid(width, height),
1290						block_dim: block,
1291						shared_mem_bytes: 0,
1292					})
1293			}
1294			.map_err(|e| launch_err("luma", e))?;
1295
1296			// Chroma plane: one thread per destination UV pair, offset past the
1297			// luma rows in both buffers.
1298			let src_uv = self.buf.ptr + u64::from(self.pitch) * u64::from(self.height);
1299			let dst_uv = dst.buf.ptr + u64::from(pitch) * u64::from(height);
1300			let (src_pw, src_ph) = (self.width / 2, self.height / 2);
1301			let (dst_pw, dst_ph) = (width / 2, height / 2);
1302			// SAFETY: as above; the UV offsets stay inside the same allocations.
1303			unsafe {
1304				stream
1305					.launch_builder(&kernels.chroma)
1306					.arg(&src_uv)
1307					.arg(&self.pitch)
1308					.arg(&src_pw)
1309					.arg(&src_ph)
1310					.arg(&dst_uv)
1311					.arg(&pitch)
1312					.arg(&dst_pw)
1313					.arg(&dst_ph)
1314					.launch(LaunchConfig {
1315						grid_dim: grid(dst_pw, dst_ph),
1316						block_dim: block,
1317						shared_mem_bytes: 0,
1318					})
1319			}
1320			.map_err(|e| launch_err("chroma", e))?;
1321
1322			// The frame may head straight to NVENC (which does not order against
1323			// our stream), so wait for the kernels rather than queueing.
1324			stream
1325				.synchronize()
1326				.map_err(|e| Error::Codec(anyhow::anyhow!("CUDA resize sync: {e:?}")))?;
1327			Ok(dst)
1328		}
1329	}
1330}
1331
1332#[cfg(target_os = "windows")]
1333pub mod d3d11 {
1334	//! Windows Direct3D11 surfaces: the NV12 [`Texture`] behind
1335	//! `Surface::Texture`, shared by Media Foundation capture, decode, and encode.
1336
1337	use std::ffi::c_void;
1338	use std::ptr;
1339
1340	use windows::Win32::Foundation::HMODULE;
1341	use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE;
1342	use windows::Win32::Graphics::Direct3D10::ID3D10Multithread;
1343	use windows::Win32::Graphics::Direct3D11::{
1344		D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_BIND_VIDEO_ENCODER, D3D11_BOX,
1345		D3D11_CPU_ACCESS_READ, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_VIDEO_SUPPORT,
1346		D3D11_FORMAT_SUPPORT, D3D11_FORMAT_SUPPORT_RENDER_TARGET, D3D11_FORMAT_SUPPORT_SHADER_SAMPLE,
1347		D3D11_FORMAT_SUPPORT_VIDEO_ENCODER, D3D11_MAP_READ, D3D11_MAPPED_SUBRESOURCE, D3D11_SDK_VERSION,
1348		D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, D3D11_USAGE_STAGING, D3D11CreateDevice, ID3D11Device,
1349		ID3D11DeviceContext, ID3D11Texture2D,
1350	};
1351	use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT, DXGI_SAMPLE_DESC};
1352	use windows::Win32::Media::MediaFoundation::{IMFDXGIBuffer, IMFSample};
1353	use windows::core::Interface;
1354
1355	use super::I420;
1356	use crate::Error;
1357
1358	fn err(ctx: &str, e: windows::core::Error) -> Error {
1359		Error::Codec(anyhow::anyhow!("{ctx}: {e}"))
1360	}
1361
1362	/// Create a hardware Direct3D11 device, multithread-protected (Media
1363	/// Foundation's internal threads or DXGI duplication and our capture thread
1364	/// both touch it). The shared low-level constructor behind the Media
1365	/// Foundation device manager and the Desktop Duplication capture path.
1366	pub(crate) fn create_device() -> Result<ID3D11Device, Error> {
1367		let mut device: Option<ID3D11Device> = None;
1368		unsafe {
1369			D3D11CreateDevice(
1370				None,
1371				D3D_DRIVER_TYPE_HARDWARE,
1372				HMODULE::default(),
1373				D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_VIDEO_SUPPORT,
1374				None,
1375				D3D11_SDK_VERSION,
1376				Some(&mut device),
1377				None,
1378				None,
1379			)
1380			.map_err(|e| err("D3D11CreateDevice", e))?;
1381		}
1382		let device = device.ok_or_else(|| Error::Codec(anyhow::anyhow!("D3D11CreateDevice returned null")))?;
1383
1384		let multithread = device
1385			.cast::<ID3D10Multithread>()
1386			.map_err(|e| err("query ID3D10Multithread", e))?;
1387		unsafe {
1388			let _ = multithread.SetMultithreadProtected(true);
1389		}
1390		Ok(device)
1391	}
1392
1393	/// A GPU texture (NV12) on the Direct3D11 device of whichever Media Foundation
1394	/// object produced it: the capture source reader, or the DXVA decoder. Holds
1395	/// that device so the download fallback and the hardware encoder run on the
1396	/// device that owns the texture. Cloning the COM handles is a cheap `AddRef`,
1397	/// which is what keeps capture -> encode and decode -> encode zero-copy.
1398	pub struct Texture {
1399		pub(crate) device: ID3D11Device,
1400		pub(crate) texture: ID3D11Texture2D,
1401		pub(crate) width: u32,
1402		pub(crate) height: u32,
1403	}
1404
1405	impl Texture {
1406		/// Blit the `width` x `height` picture out of a Media Foundation sample into
1407		/// a texture we own, staying on `device` and on the GPU.
1408		///
1409		/// The exit from a Media Foundation pool, which a frame cannot simply be
1410		/// handed out of. Both producers here allocate their output from a pool and
1411		/// recycle a slot the moment its sample is released, so a texture handle
1412		/// alone is not ownership: the next picture is written over a frame a
1413		/// consumer is still holding. Keeping the sample instead is worse, because a
1414		/// decoder's pool is short (8 slices on the hardware this was written
1415		/// against) and it has no error to report when it runs dry: the MFT blocks
1416		/// inside `ProcessInput` waiting for a picture buffer a consumer is holding.
1417		/// A decoder's slices are bound `D3D11_BIND_DECODER` and nothing else, on
1418		/// top of that, so no shader can sample one and no encoder can read it.
1419		///
1420		/// One GPU-to-GPU copy buys a frame that outlives its producer, holds
1421		/// nothing back, and can be bound. It also crops the coded size (a decoder
1422		/// allocates in whole macroblocks) to the display size, so the result is
1423		/// exactly the picture. `width` and `height` are that display size, which
1424		/// the texture itself does not know.
1425		///
1426		/// Errors if the sample is system-memory backed, which is the caller's cue
1427		/// to take its CPU path.
1428		pub(crate) fn copy_from_sample(
1429			device: &ID3D11Device,
1430			sample: &IMFSample,
1431			width: u32,
1432			height: u32,
1433		) -> Result<Self, Error> {
1434			let (source, subresource) = resolve(sample)?;
1435
1436			// One plain slice in the producer's own format.
1437			let mut desc = D3D11_TEXTURE2D_DESC::default();
1438			unsafe { source.GetDesc(&mut desc) };
1439			let texture = alloc(device, width, height, desc.Format)?;
1440
1441			// Every edge has to be even for 4:2:0 chroma; the decoder's frame size is
1442			// validated even before it reaches here.
1443			let region = D3D11_BOX {
1444				left: 0,
1445				top: 0,
1446				front: 0,
1447				right: width,
1448				bottom: height,
1449				back: 1,
1450			};
1451			let context = unsafe { device.GetImmediateContext() }.map_err(|e| err("GetImmediateContext", e))?;
1452			unsafe {
1453				context.CopySubresourceRegion(&texture, 0, 0, 0, 0, &source, subresource, Some(&region));
1454			}
1455
1456			Ok(Self {
1457				device: device.clone(),
1458				texture,
1459				width,
1460				height,
1461			})
1462		}
1463
1464		/// The Direct3D11 texture holding the pixels. Borrowing keeps them on the
1465		/// GPU.
1466		///
1467		/// NV12, one slice, exactly [`width`](Self::width) x
1468		/// [`height`](Self::height), and bound for everything the driver supports
1469		/// for the format: sampling in a shader, drawing into, and the hardware
1470		/// encoder. This crate allocated it, so none of that is the producer's
1471		/// choice leaking through.
1472		pub fn texture(&self) -> &ID3D11Texture2D {
1473			&self.texture
1474		}
1475
1476		/// The Direct3D11 device the texture belongs to. Anything reading the
1477		/// texture has to run on this device.
1478		pub fn device(&self) -> &ID3D11Device {
1479			&self.device
1480		}
1481
1482		/// The frame width in pixels.
1483		pub fn width(&self) -> u32 {
1484			self.width
1485		}
1486
1487		/// The frame height in pixels.
1488		pub fn height(&self) -> u32 {
1489			self.height
1490		}
1491
1492		/// Copy the NV12 texture to a CPU-readable staging texture and
1493		/// deinterleave it into packed I420 (the CPU encode path, when the encoder
1494		/// can't consume the GPU texture directly).
1495		pub(crate) fn download_i420(&self) -> Result<I420, Error> {
1496			let context = unsafe { self.device.GetImmediateContext() }.map_err(|e| err("GetImmediateContext", e))?;
1497
1498			// A CPU-readable copy of the source texture's single slice.
1499			let mut desc = D3D11_TEXTURE2D_DESC::default();
1500			unsafe { self.texture.GetDesc(&mut desc) };
1501			desc.ArraySize = 1;
1502			desc.MipLevels = 1;
1503			desc.Usage = D3D11_USAGE_STAGING;
1504			desc.BindFlags = 0;
1505			desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ.0 as u32;
1506			desc.MiscFlags = 0;
1507
1508			let mut staging: Option<ID3D11Texture2D> = None;
1509			unsafe {
1510				self.device
1511					.CreateTexture2D(&desc, None, Some(&mut staging))
1512					.map_err(|e| err("CreateTexture2D (staging)", e))?;
1513			}
1514			let staging = staging.ok_or_else(|| Error::Codec(anyhow::anyhow!("CreateTexture2D returned null")))?;
1515
1516			unsafe {
1517				context.CopySubresourceRegion(&staging, 0, 0, 0, 0, &self.texture, 0, None);
1518			}
1519
1520			let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
1521			unsafe {
1522				context
1523					.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped))
1524					.map_err(|e| err("Map (staging)", e))?;
1525			}
1526			let _guard = UnmapGuard {
1527				context: &context,
1528				resource: &staging,
1529			};
1530
1531			let (w, h) = (self.width as usize, self.height as usize);
1532			let (cw, ch) = (w / 2, h / 2);
1533			let pitch = mapped.RowPitch as usize;
1534			let base = mapped.pData as *const u8;
1535			// The UV plane begins after the *texture's* Y plane, which spans the
1536			// allocated height, not the display height. A DXVA decode pool allocates
1537			// textures at the coded size (e.g. 1088 rows for a 1080p display), so
1538			// keying the offset off `self.height` would read chroma from inside the
1539			// still-luma padding rows and produce garbage color.
1540			let tex_height = desc.Height as usize;
1541
1542			let mut data = vec![0u8; I420::len(self.width, self.height)];
1543			let (luma, chroma) = data.split_at_mut(w * h);
1544			let (u_plane, v_plane) = chroma.split_at_mut(cw * ch);
1545
1546			// Y plane: h rows of `pitch` bytes, only the first w used.
1547			for row in 0..h {
1548				unsafe {
1549					ptr::copy_nonoverlapping(base.add(row * pitch), luma[row * w..].as_mut_ptr(), w);
1550				}
1551			}
1552			// Interleaved UV plane sits right after the full Y plane, h/2 rows.
1553			let uv_base = unsafe { base.add(pitch * tex_height) };
1554			for row in 0..ch {
1555				let src = unsafe { uv_base.add(row * pitch) };
1556				for col in 0..cw {
1557					unsafe {
1558						u_plane[row * cw + col] = *src.add(col * 2);
1559						v_plane[row * cw + col] = *src.add(col * 2 + 1);
1560					}
1561				}
1562			}
1563
1564			Ok(I420 {
1565				width: self.width,
1566				height: self.height,
1567				data,
1568				// A deinterleave, not a color conversion, and nothing here names
1569				// the space these samples are in. Left unknown to be inferred.
1570				color: None,
1571			})
1572		}
1573	}
1574
1575	/// A plain single-slice texture on `device`, bound for whatever the driver
1576	/// supports. Where every frame this module hands out is allocated.
1577	fn alloc(device: &ID3D11Device, width: u32, height: u32, format: DXGI_FORMAT) -> Result<ID3D11Texture2D, Error> {
1578		let desc = D3D11_TEXTURE2D_DESC {
1579			Width: width,
1580			Height: height,
1581			MipLevels: 1,
1582			ArraySize: 1,
1583			Format: format,
1584			SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 },
1585			Usage: D3D11_USAGE_DEFAULT,
1586			BindFlags: bind_flags(device, format),
1587			CPUAccessFlags: 0,
1588			MiscFlags: 0,
1589		};
1590
1591		let mut texture: Option<ID3D11Texture2D> = None;
1592		unsafe {
1593			device
1594				.CreateTexture2D(&desc, None, Some(&mut texture))
1595				.map_err(|e| err("CreateTexture2D", e))?;
1596		}
1597		texture.ok_or_else(|| Error::Codec(anyhow::anyhow!("CreateTexture2D returned null")))
1598	}
1599
1600	/// The Direct3D11 texture behind a Media Foundation sample, and which slice of
1601	/// it this sample is. Errors if the sample is system-memory backed.
1602	fn resolve(sample: &IMFSample) -> Result<(ID3D11Texture2D, u32), Error> {
1603		let buffer = unsafe { sample.GetBufferByIndex(0) }.map_err(|e| err("get sample buffer", e))?;
1604		let dxgi = buffer
1605			.cast::<IMFDXGIBuffer>()
1606			.map_err(|e| err("sample buffer is not a DXGI surface", e))?;
1607
1608		// GetResource returns a fresh ref (`AddRef`) we take ownership of.
1609		let mut raw: *mut c_void = ptr::null_mut();
1610		unsafe {
1611			dxgi.GetResource(&ID3D11Texture2D::IID, &mut raw)
1612				.map_err(|e| err("get DXGI resource", e))?;
1613		}
1614		let texture = unsafe { ID3D11Texture2D::from_raw(raw) };
1615		let subresource = unsafe { dxgi.GetSubresourceIndex() }.map_err(|e| err("get subresource index", e))?;
1616		Ok((texture, subresource))
1617	}
1618
1619	/// What a texture of `format` can be bound as on this device: everything a
1620	/// consumer might want (sampling it in a shader, drawing into it, feeding it to
1621	/// the hardware encoder) that the driver actually supports for the format.
1622	///
1623	/// Asked rather than assumed, because NV12 is exactly the format a driver is
1624	/// allowed to be picky about, and `CreateTexture2D` fails outright on a flag it
1625	/// does not support. Whatever comes back, the texture is still copyable and
1626	/// downloadable, so a bare-bones driver costs a consumer a copy rather than the
1627	/// frame.
1628	fn bind_flags(device: &ID3D11Device, format: DXGI_FORMAT) -> u32 {
1629		let support = unsafe { device.CheckFormatSupport(format) }.unwrap_or_default();
1630		let supports = |flag: D3D11_FORMAT_SUPPORT| support & flag.0 as u32 != 0;
1631
1632		let mut flags = 0;
1633		if supports(D3D11_FORMAT_SUPPORT_SHADER_SAMPLE) {
1634			flags |= D3D11_BIND_SHADER_RESOURCE.0 as u32;
1635		}
1636		if supports(D3D11_FORMAT_SUPPORT_RENDER_TARGET) {
1637			flags |= D3D11_BIND_RENDER_TARGET.0 as u32;
1638		}
1639		if supports(D3D11_FORMAT_SUPPORT_VIDEO_ENCODER) {
1640			flags |= D3D11_BIND_VIDEO_ENCODER.0 as u32;
1641		}
1642		flags
1643	}
1644
1645	struct UnmapGuard<'a> {
1646		context: &'a ID3D11DeviceContext,
1647		resource: &'a ID3D11Texture2D,
1648	}
1649
1650	impl Drop for UnmapGuard<'_> {
1651		fn drop(&mut self) {
1652			unsafe { self.context.Unmap(self.resource, 0) };
1653		}
1654	}
1655}
1656
1657#[cfg(test)]
1658mod tests {
1659	/// A conversion that picks a matrix says so; one that only moves samples
1660	/// around must not.
1661	///
1662	/// The distinction decides whether a renderer trusts the frame or guesses
1663	/// from the resolution, and guessing wrong tints saturated colors (see the
1664	/// render module's HD test). Labeling everything with the RGB matrix would be
1665	/// worse than labeling nothing: a 720p camera's BT.709 samples would be
1666	/// pinned to BT.601 rather than inferring BT.709 correctly.
1667	#[test]
1668	fn only_a_real_color_conversion_labels_its_output() {
1669		use super::I420;
1670		use crate::{Color, Size};
1671
1672		let size = Size::new(64, 64);
1673		let rgba = vec![0u8; size.pixels() as usize * 4];
1674		let converted = I420::from_rgba(&rgba, size.width * 4, size.width, size.height).expect("rgba to i420");
1675		assert_eq!(
1676			converted.color(),
1677			Some(Color::Bt601Limited),
1678			"an RGB conversion knows the matrix it used"
1679		);
1680
1681		// Resampling moves samples around; it does not reinterpret them.
1682		let resized = converted.resize(32, 32).expect("resize");
1683		assert_eq!(resized.color(), Some(Color::Bt601Limited), "resize preserves the space");
1684
1685		// A passthrough leaves it open for the consumer to infer.
1686		let raw = I420::new(64, 64, vec![0; I420::len(64, 64)]).expect("i420");
1687		assert_eq!(raw.color(), None);
1688		assert_eq!(raw.with_color(Color::Bt709Full).color(), Some(Color::Bt709Full));
1689	}
1690
1691	/// V4L2 hands back YUYV already in the camera's color space, so the 4:2:2 ->
1692	/// 4:2:0 chroma resample must not claim it is BT.601: a 720p camera is
1693	/// usually BT.709, and mislabeling pins it to the wrong matrix instead of
1694	/// letting the resolution heuristic get it right.
1695	#[cfg(target_os = "linux")]
1696	#[test]
1697	fn yuyv_capture_keeps_its_color_space_open() {
1698		let (width, height) = (1280, 720);
1699		// YUYV packs two pixels into four bytes.
1700		let yuyv = vec![0u8; width as usize * height as usize * 2];
1701		let frame = super::I420::from_yuyv(&yuyv, width * 2, width, height).expect("yuyv to i420");
1702		assert_eq!(frame.color(), None, "a chroma resample names no color space");
1703	}
1704
1705	/// A short buffer is rejected at construction rather than panicking later: the
1706	/// plane splits in `y`/`u`/`v` and the CoreVideo upload both index blindly, so
1707	/// a public `I420` has to be impossible to build malformed.
1708	#[test]
1709	fn i420_new_rejects_a_short_buffer() {
1710		use super::I420;
1711
1712		assert!(I420::new(64, 32, vec![0; I420::len(64, 32)]).is_ok());
1713		assert!(I420::new(64, 32, vec![0; I420::len(64, 32) - 1]).is_err());
1714		assert!(I420::new(64, 32, Vec::new()).is_err());
1715		// Odd and zero dimensions have no valid 4:2:0 chroma.
1716		assert!(I420::new(63, 32, vec![0; I420::len(63, 32)]).is_err());
1717		assert!(I420::new(0, 32, Vec::new()).is_err());
1718	}
1719
1720	use super::{Frame, I420, Surface};
1721	use crate::Size;
1722
1723	/// The counterpart for the RGBA entry point: a buffer that isn't exactly one
1724	/// frame of the declared size is a caller mistake, not slack to truncate.
1725	#[test]
1726	fn surface_rgba_rejects_a_mismatched_buffer() {
1727		let ok = vec![0x80u8; 64 * 32 * 4];
1728		assert!(Surface::rgba(&ok, Size::new(64, 32)).is_ok());
1729		assert!(Surface::rgba(&ok[..ok.len() - 4], Size::new(64, 32)).is_err());
1730		assert!(Surface::rgba(&ok, Size::new(32, 32)).is_err());
1731		assert!(Surface::rgba(&ok, Size::new(0, 32)).is_err());
1732	}
1733
1734	/// The conversion picks its matrix by resolution, matching what a player
1735	/// assumes for an untagged stream, and reports the one it used.
1736	///
1737	/// The regression: every RGB conversion hardcoded BT.601. A 1080p screen
1738	/// capture was converted with BT.601, encoded untagged, and decoded with the
1739	/// BT.709 inverse, which turns pure red into roughly (255, 24, 0). Grays are
1740	/// unaffected, which is why it survived casual inspection.
1741	#[test]
1742	fn rgb_conversion_follows_the_size_heuristic() {
1743		use yuv::{YuvPlanarImage, yuv420_to_rgba};
1744
1745		use crate::Color;
1746
1747		let red = |size: Size| {
1748			let rgba = [255u8, 0, 0, 255].repeat(size.pixels() as usize);
1749			I420::from_rgba(&rgba, size.width * 4, size.width, size.height).unwrap()
1750		};
1751
1752		// Decode with the matrix a player picks for an untagged stream of this
1753		// size, and sample the middle of the frame.
1754		let decode = |i420: &I420| {
1755			let (w, h) = (i420.width, i420.height);
1756			let (range, matrix) = Color::infer(Size::new(w, h)).yuv();
1757			let planar = YuvPlanarImage {
1758				y_plane: i420.y(),
1759				y_stride: w,
1760				u_plane: i420.u(),
1761				u_stride: w / 2,
1762				v_plane: i420.v(),
1763				v_stride: w / 2,
1764				width: w,
1765				height: h,
1766			};
1767			let mut rgba = vec![0u8; (w * h * 4) as usize];
1768			yuv420_to_rgba(&planar, &mut rgba, w * 4, range, matrix).unwrap();
1769			let px = ((h / 2 * w + w / 2) * 4) as usize;
1770			[rgba[px], rgba[px + 1], rgba[px + 2]]
1771		};
1772
1773		for (size, expected) in [
1774			(Size::new(720, 480), Color::Bt601Limited),
1775			(Size::new(720, 576), Color::Bt601Limited),
1776			(Size::new(1280, 720), Color::Bt709Limited),
1777			(Size::new(1920, 1080), Color::Bt709Limited),
1778		] {
1779			let i420 = red(size);
1780			assert_eq!(i420.color(), Some(expected), "{size} reported color");
1781
1782			// Red survives the round trip at every size. Before the fix the 720p and
1783			// 1080p cases came back around (255, 24, 0).
1784			let rgb = decode(&i420);
1785			assert!(
1786				rgb[1] <= 2 && rgb[2] <= 2,
1787				"{size} red came back as {rgb:?}, so the matrix and the label disagree"
1788			);
1789		}
1790	}
1791
1792	/// The frame's size comes from the surface rather than a field alongside it,
1793	/// so the two cannot drift apart, and a resize carries the timing across.
1794	#[test]
1795	fn frame_size_follows_the_surface() {
1796		let rgba = vec![0x80u8; 64 * 32 * 4];
1797		let surface = Surface::rgba(&rgba, Size::new(64, 32)).unwrap();
1798
1799		let frame = Frame::new(surface, moq_net::Timestamp::from_micros(1234).unwrap());
1800		assert_eq!(frame.size(), Size::new(64, 32));
1801
1802		let scaled = frame.resize(Size::new(32, 16)).unwrap();
1803		assert_eq!(scaled.size(), Size::new(32, 16));
1804		assert_eq!(scaled.timestamp, frame.timestamp);
1805	}
1806
1807	/// `into_pixel_buffer` is total: a CPU frame uploads rather than failing, so a
1808	/// renderer never has to write the upload itself. Software-decoded frames take
1809	/// this path.
1810	#[cfg(target_os = "macos")]
1811	#[test]
1812	fn into_pixel_buffer_uploads_a_cpu_frame() {
1813		use objc2_core_video::{CVPixelBufferGetHeight, CVPixelBufferGetWidth};
1814
1815		let i420 = I420::new(64, 32, vec![0x80; I420::len(64, 32)]).unwrap();
1816		let frame = Frame::new(Surface::I420(i420), moq_net::Timestamp::from_micros(0).unwrap());
1817
1818		let buffer = frame.surface.into_pixel_buffer().expect("upload a CPU frame");
1819		assert_eq!(CVPixelBufferGetWidth(&buffer), 64);
1820		assert_eq!(CVPixelBufferGetHeight(&buffer), 32);
1821	}
1822
1823	/// A gradient I420 frame with structure in every plane, so resize bugs
1824	/// (plane swaps, stride mistakes) shift the averages measurably.
1825	fn gradient_i420(width: u32, height: u32) -> I420 {
1826		let (w, h) = (width as usize, height as usize);
1827		let (cw, ch) = (w / 2, h / 2);
1828		let mut data = vec![0u8; I420::len(width, height)];
1829		let (y, chroma) = data.split_at_mut(w * h);
1830		let (u, v) = chroma.split_at_mut(cw * ch);
1831		for row in 0..h {
1832			for col in 0..w {
1833				y[row * w + col] = ((col * 255) / w) as u8;
1834			}
1835		}
1836		for row in 0..ch {
1837			for col in 0..cw {
1838				u[row * cw + col] = ((row * 255) / ch) as u8;
1839				v[row * cw + col] = (((row + col) * 255) / (ch + cw)) as u8;
1840			}
1841		}
1842		I420 {
1843			width,
1844			height,
1845			data,
1846			color: None,
1847		}
1848	}
1849
1850	/// Mean absolute error between two equal-length planes.
1851	fn mae(a: &[u8], b: &[u8]) -> u64 {
1852		assert_eq!(a.len(), b.len());
1853		a.iter().zip(b).map(|(x, y)| x.abs_diff(*y) as u64).sum::<u64>() / a.len() as u64
1854	}
1855
1856	/// The CPU resize follows the source gradients at any downscale factor: a
1857	/// horizontal luma ramp stays a ramp, and the chroma ramps follow too.
1858	#[test]
1859	fn i420_resize_follows_gradients() {
1860		let src = gradient_i420(320, 240);
1861		let dst = src.resize(128, 96).unwrap();
1862		assert_eq!((dst.width, dst.height), (128, 96));
1863
1864		// Reference: the same gradients sampled at the destination geometry.
1865		let expected = gradient_i420(128, 96);
1866		assert!(mae(dst.y(), expected.y()) < 4, "luma ramp drifted");
1867		assert!(mae(dst.u(), expected.u()) < 4, "u ramp drifted");
1868		assert!(mae(dst.v(), expected.v()) < 4, "v ramp drifted");
1869	}
1870
1871	/// VideoToolbox and the CPU convolution agree on a smooth NV12 gradient.
1872	/// The result remains a pixel buffer, pinning the residency regression.
1873	#[cfg(target_os = "macos")]
1874	#[test]
1875	fn pixel_buffer_resize_matches_cpu() {
1876		let src_i420 = gradient_i420(320, 240);
1877		let src = Surface::PixelBuffer(nv12_surface(&src_i420));
1878		let scaled = src.resize(Size::new(160, 120)).unwrap();
1879		let Surface::PixelBuffer(scaled) = scaled else {
1880			panic!("VideoToolbox resize downloaded to the CPU");
1881		};
1882
1883		let gpu = scaled.download_i420().unwrap();
1884		let cpu = src_i420.resize(160, 120).unwrap();
1885
1886		assert_eq!((gpu.width, gpu.height), (160, 120));
1887		assert!(mae(gpu.y(), cpu.y()) < 4, "GPU and CPU luma disagree");
1888		assert!(mae(gpu.u(), cpu.u()) < 4, "GPU and CPU u disagree");
1889		assert!(mae(gpu.v(), cpu.v()) < 4, "GPU and CPU v disagree");
1890	}
1891
1892	/// Upload a packed I420 test picture as NV12, including CoreVideo row
1893	/// padding, so the transfer test starts from the decoder's surface format.
1894	#[cfg(target_os = "macos")]
1895	fn nv12_surface(frame: &I420) -> super::macos::PixelBuffer {
1896		use std::ptr::{self, NonNull};
1897
1898		use objc2_core_foundation::CFRetained;
1899		use objc2_core_video::{
1900			CVPixelBuffer, CVPixelBufferCreate, CVPixelBufferGetBaseAddressOfPlane, CVPixelBufferGetBytesPerRowOfPlane,
1901			CVPixelBufferLockBaseAddress, CVPixelBufferLockFlags, CVPixelBufferUnlockBaseAddress,
1902			kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
1903		};
1904
1905		let mut raw: *mut CVPixelBuffer = ptr::null_mut();
1906		let status = unsafe {
1907			CVPixelBufferCreate(
1908				None,
1909				frame.width as usize,
1910				frame.height as usize,
1911				kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
1912				None,
1913				NonNull::new(&mut raw).expect("stack pointer is non-null"),
1914			)
1915		};
1916		assert_eq!(status, 0, "CVPixelBufferCreate failed");
1917		let buffer = unsafe { CFRetained::from_raw(NonNull::new(raw).expect("CoreVideo returned a buffer")) };
1918
1919		let flags = CVPixelBufferLockFlags(0);
1920		assert_eq!(unsafe { CVPixelBufferLockBaseAddress(&buffer, flags) }, 0);
1921		let width = frame.width as usize;
1922		let height = frame.height as usize;
1923		let y_base = CVPixelBufferGetBaseAddressOfPlane(&buffer, 0) as *mut u8;
1924		let y_stride = CVPixelBufferGetBytesPerRowOfPlane(&buffer, 0);
1925		for row in 0..height {
1926			unsafe {
1927				ptr::copy_nonoverlapping(frame.y()[row * width..].as_ptr(), y_base.add(row * y_stride), width);
1928			}
1929		}
1930
1931		let (chroma_width, chroma_height) = (width / 2, height / 2);
1932		let uv_base = CVPixelBufferGetBaseAddressOfPlane(&buffer, 1) as *mut u8;
1933		let uv_stride = CVPixelBufferGetBytesPerRowOfPlane(&buffer, 1);
1934		for row in 0..chroma_height {
1935			let output = unsafe { uv_base.add(row * uv_stride) };
1936			for col in 0..chroma_width {
1937				unsafe {
1938					*output.add(col * 2) = frame.u()[row * chroma_width + col];
1939					*output.add(col * 2 + 1) = frame.v()[row * chroma_width + col];
1940				}
1941			}
1942		}
1943		unsafe { CVPixelBufferUnlockBaseAddress(&buffer, flags) };
1944
1945		super::macos::PixelBuffer::new(buffer, frame.width, frame.height)
1946	}
1947
1948	/// GPU (box filter) and CPU (bilinear convolution) resizes agree on a
1949	/// smooth gradient. Runs on real hardware; skips without the NVIDIA driver.
1950	#[cfg(all(target_os = "linux", feature = "nvdec"))]
1951	#[test]
1952	fn cuda_resize_matches_cpu() {
1953		use std::sync::Arc;
1954
1955		use cudarc::driver::{CudaContext, result};
1956
1957		use super::cuda;
1958
1959		// Same probe as the codec backends: no driver, no test.
1960		if unsafe { libloading::Library::new("libcuda.so.1") }.is_err() {
1961			return;
1962		}
1963		let Ok(ctx): Result<Arc<CudaContext>, _> = CudaContext::new(0) else {
1964			return;
1965		};
1966
1967		let (w, h) = (322u32, 242u32); // odd-ish sizes: exercise pitch != width
1968		let src_i420 = gradient_i420(w, h);
1969
1970		// Upload as pitched NV12: Y rows, then interleaved UV rows.
1971		let pitch = 512u32;
1972		let frame = cuda::Frame::alloc(&ctx, w, h, pitch).unwrap();
1973		let mut host = vec![0u8; pitch as usize * h as usize * 3 / 2];
1974		for row in 0..h as usize {
1975			let dst = row * pitch as usize;
1976			host[dst..dst + w as usize].copy_from_slice(&src_i420.y()[row * w as usize..(row + 1) * w as usize]);
1977		}
1978		let (cw, ch) = (w as usize / 2, h as usize / 2);
1979		for row in 0..ch {
1980			let dst = (h as usize + row) * pitch as usize;
1981			for col in 0..cw {
1982				host[dst + 2 * col] = src_i420.u()[row * cw + col];
1983				host[dst + 2 * col + 1] = src_i420.v()[row * cw + col];
1984			}
1985		}
1986		// SAFETY: the frame's buffer is exactly host.len() bytes.
1987		unsafe { result::memcpy_htod_sync(frame.device_ptr(), &host) }.unwrap();
1988
1989		let scaled = frame.resize(160, 120).unwrap();
1990		let gpu = scaled.download_i420().unwrap();
1991		let cpu = src_i420.resize(160, 120).unwrap();
1992
1993		assert_eq!((gpu.width, gpu.height), (160, 120));
1994		assert!(mae(gpu.y(), cpu.y()) < 4, "GPU and CPU luma disagree");
1995		assert!(mae(gpu.u(), cpu.u()) < 4, "GPU and CPU u disagree");
1996		assert!(mae(gpu.v(), cpu.v()) < 4, "GPU and CPU v disagree");
1997	}
1998}