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