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