Skip to main content

moq_video/capture/
mod.rs

1//! Surface capture. [`Config`] is shared; the implementation is per-platform and
2//! per-source:
3//! - macOS camera -> AVFoundation, screen -> ScreenCaptureKit, both yielding
4//!   zero-copy `CVPixelBuffer` surfaces straight to VideoToolbox.
5//! - Linux camera -> native V4L2 (YUYV / MJPEG -> CPU I420), X11 display and
6//!   window -> X11, Wayland display -> xdg-desktop-portal + PipeWire
7//!   (`pipewire` feature).
8//! - Windows camera -> native Media Foundation (`IMFSourceReader`), screen ->
9//!   DXGI Desktop Duplication, window -> GDI (BGRA -> CPU I420).
10//!
11//! [`encode::publish_capture`](crate::encode::publish_capture) consumes [`Config`].
12
13use std::sync::Arc;
14
15use crate::Error;
16use crate::frame::Surface;
17
18const MAX_FRAMERATE: u32 = 1_000_000;
19
20mod channel;
21use channel::FrameChannel;
22
23/// Type-erased keep-alive for a capture backend, dropped to release the device.
24///
25/// `Send` off macOS (the backend is a pump-thread guard: an `Arc` stop flag plus
26/// a `JoinHandle`), which keeps [`publish_capture`](crate::encode::publish_capture)
27/// `Send` so a server can `tokio::spawn` it. On macOS the backend is the objc
28/// `AVCaptureSession` (plus its delegate), which is `!Send`, so that platform's
29/// capture future is `!Send` too.
30#[cfg(not(target_os = "macos"))]
31type Keepalive = Box<dyn std::any::Any + Send>;
32#[cfg(target_os = "macos")]
33type Keepalive = Box<dyn std::any::Any>;
34
35#[cfg(target_os = "macos")]
36mod avfoundation;
37#[cfg(target_os = "macos")]
38mod screencapture;
39#[cfg(target_os = "macos")]
40mod surface;
41
42// Native V4L2 camera capture on Linux.
43#[cfg(target_os = "linux")]
44mod v4l2;
45// Native X11 display and window capture, including the portal fallback.
46#[cfg(target_os = "linux")]
47mod x11;
48
49// Portal + PipeWire screen capture on Linux.
50#[cfg(all(target_os = "linux", feature = "pipewire"))]
51mod pipewire;
52
53// Native Media Foundation camera capture on Windows.
54#[cfg(target_os = "windows")]
55mod mediafoundation;
56
57// DXGI Desktop Duplication screen capture on Windows.
58#[cfg(target_os = "windows")]
59mod desktopduplication;
60// Native GDI window enumeration and capture on Windows.
61#[cfg(target_os = "windows")]
62mod window;
63
64// Blocking-device -> async-channel bridge used by V4L2 / Media Foundation.
65#[cfg(any(target_os = "linux", target_os = "windows"))]
66mod pump;
67
68/// What to capture. Each variant carries the identifier that selects it, so a
69/// window can't be captured without saying which one, and a camera id can't
70/// reach the display backend.
71///
72/// The identifiers come from [`cameras`], [`displays`], [`windows`], and
73/// [`apps`]; each listed item's `source()` builds the matching variant.
74#[derive(Clone, Debug, PartialEq, Eq)]
75#[non_exhaustive]
76pub enum Source {
77	/// A camera / webcam. `None` opens the default camera.
78	///
79	/// The identifiers from [`cameras`] are an AVFoundation `uniqueID` on macOS,
80	/// a `/dev/videoN` path on Linux, and a Media Foundation symbolic link on
81	/// Windows. Bare numeric indices remain accepted on Linux and Windows.
82	Camera(Option<String>),
83
84	/// A whole display. `None` opens the main display.
85	///
86	/// The id is the opaque value [`displays`] reports. On Wayland the
87	/// xdg-desktop-portal picker owns selection and no stable id is available.
88	Display(Option<String>),
89
90	/// A single window, by the id [`windows`] reports. Supported on macOS,
91	/// Windows, and X11.
92	Window(String),
93
94	/// Every window belonging to one application, by the id [`apps`] reports
95	/// (a bundle identifier). Windows that open later are included. macOS only.
96	App(String),
97}
98
99/// The default camera, matching the historical `Config::default()`.
100impl Default for Source {
101	fn default() -> Self {
102		Self::Camera(None)
103	}
104}
105
106impl Source {
107	/// A short human-readable name for the source, used in logs and as the
108	/// captured device label.
109	///
110	/// macOS-only: one ScreenCaptureKit backend serves display, window, and app,
111	/// so it names the source from the config. The other backends label a stream
112	/// with the device they resolved (`/dev/video0`, a Media Foundation friendly
113	/// name), which the config doesn't know.
114	#[cfg(target_os = "macos")]
115	pub(crate) fn label(&self) -> String {
116		match self {
117			Self::Camera(None) => "camera".to_string(),
118			Self::Camera(Some(id)) => format!("camera:{id}"),
119			Self::Display(None) => "display".to_string(),
120			Self::Display(Some(id)) => format!("display:{id}"),
121			Self::Window(id) => format!("window:{id}"),
122			Self::App(id) => format!("app:{id}"),
123		}
124	}
125}
126
127/// A camera reported by [`cameras`].
128#[derive(Clone, Debug)]
129pub struct Camera {
130	/// Opaque identifier: pass to [`Source::Camera`].
131	pub id: String,
132	/// Human-readable name, e.g. "FaceTime HD Camera".
133	pub name: String,
134}
135
136impl Camera {
137	/// The [`Source`] that captures this camera.
138	pub fn source(&self) -> Source {
139		Source::Camera(Some(self.id.clone()))
140	}
141}
142
143/// A display reported by [`displays`].
144#[derive(Clone, Debug)]
145pub struct Display {
146	/// Opaque identifier: pass to [`Source::Display`].
147	pub id: String,
148	/// Human-readable name, e.g. "Display 1".
149	pub name: String,
150	/// Width in the platform's desktop coordinate space: points on macOS and
151	/// desktop pixels on Windows and X11.
152	pub width: u32,
153	/// Height in the platform's desktop coordinate space: points on macOS and
154	/// desktop pixels on Windows and X11.
155	pub height: u32,
156}
157
158impl Display {
159	/// The [`Source`] that captures this display.
160	pub fn source(&self) -> Source {
161		Source::Display(Some(self.id.clone()))
162	}
163}
164
165/// A window reported by [`windows`].
166#[derive(Clone, Debug)]
167pub struct Window {
168	/// Opaque identifier: pass to [`Source::Window`].
169	pub id: String,
170	/// The window title, empty if it has none.
171	pub title: String,
172	/// The name of the application owning the window.
173	pub app: String,
174	/// Width in platform window coordinates: points on macOS and pixels on
175	/// Windows and X11.
176	pub width: u32,
177	/// Height in platform window coordinates: points on macOS and pixels on
178	/// Windows and X11.
179	pub height: u32,
180}
181
182impl Window {
183	/// The [`Source`] that captures this window.
184	pub fn source(&self) -> Source {
185		Source::Window(self.id.clone())
186	}
187}
188
189/// An application reported by [`apps`].
190#[derive(Clone, Debug)]
191pub struct App {
192	/// Bundle identifier: pass to [`Source::App`].
193	pub id: String,
194	/// Human-readable name, e.g. "Safari".
195	pub name: String,
196}
197
198impl App {
199	/// The [`Source`] that captures every window of this application.
200	pub fn source(&self) -> Source {
201		Source::App(self.id.clone())
202	}
203}
204
205/// Capture configuration. All fields are hints; the backend picks the closest
206/// supported mode.
207///
208/// `#[non_exhaustive]`: construct via [`Config::default`] and set fields, so
209/// new options can be added without breaking callers.
210#[derive(Clone, Debug)]
211#[non_exhaustive]
212pub struct Config {
213	/// What to capture.
214	pub source: Source,
215	/// Preferred output width in pixels.
216	pub width: Option<u32>,
217	/// Preferred output height in pixels.
218	pub height: Option<u32>,
219	/// Preferred frame rate in frames per second, from 1 through 1,000,000.
220	pub framerate: Option<u32>,
221	/// Draw the mouse cursor into captured frames. Screen/window/app sources
222	/// only; ignored by cameras. Defaults to `true`.
223	pub cursor: bool,
224}
225
226impl Default for Config {
227	fn default() -> Self {
228		Self {
229			source: Source::default(),
230			width: None,
231			height: None,
232			framerate: None,
233			cursor: true,
234		}
235	}
236}
237
238/// A live, async frame source opened via [`open`].
239///
240/// Every backend delivers frames through the same latest-frame channel, so the
241/// consumer just `read().await`s regardless of platform. Dropping the stream
242/// releases the device (stops the macOS `AVCaptureSession`, joins the V4L2 /
243/// Media Foundation pump thread). That is the whole point: because `read` is a
244/// real await, cancelling the capture future drops this and the camera turns off
245/// promptly, with no blocking task left pinned to the runtime.
246pub struct Stream {
247	chan: Arc<FrameChannel>,
248	width: u32,
249	height: u32,
250	framerate: Option<u32>,
251	color: Option<crate::Color>,
252	label: String,
253	/// First frame captured during [`open`] (some backends learn their geometry
254	/// only from a frame); returned by the first [`read`](Self::read).
255	pending: Option<Surface>,
256	/// Keeps the backend alive and releases it on drop. Type-erased because it
257	/// differs per platform (objc session + delegate, or pump-thread guard).
258	_backend: Keepalive,
259}
260
261impl Stream {
262	/// Build a stream from a backend's channel, geometry, and keep-alive guard.
263	fn new(
264		chan: Arc<FrameChannel>,
265		width: u32,
266		height: u32,
267		framerate: Option<u32>,
268		label: String,
269		pending: Option<Surface>,
270		backend: Keepalive,
271	) -> Self {
272		let color = pending.as_ref().and_then(Surface::color);
273		Self {
274			chan,
275			width,
276			height,
277			framerate,
278			color,
279			label,
280			pending,
281			_backend: backend,
282		}
283	}
284
285	/// Await the next frame, or `None` once the source ends.
286	///
287	/// `None` is recoverable: the source stopped for a benign reason (it
288	/// resized, the compositor renegotiated the format, the device went idle)
289	/// and reopening is expected to work. An `Err` is terminal for this
290	/// selection: the source is gone or was refused.
291	///
292	/// Dropping this future cancels only the pending read. Dropping the stream
293	/// releases the capture source.
294	pub async fn read(&mut self) -> Result<Option<Surface>, Error> {
295		if let Some(frame) = self.pending.take() {
296			return Ok(Some(frame));
297		}
298		self.chan.recv().await
299	}
300
301	/// Width of the captured frames in pixels.
302	pub fn width(&self) -> u32 {
303		self.width
304	}
305
306	/// Height of the captured frames in pixels.
307	pub fn height(&self) -> u32 {
308		self.height
309	}
310
311	/// The negotiated frame rate, or `None` if the source doesn't report one.
312	pub fn framerate(&self) -> Option<u32> {
313		self.framerate
314	}
315
316	/// The first frame's declared color space, when its capture backend knows it.
317	pub fn color(&self) -> Option<crate::Color> {
318		self.color
319	}
320
321	/// Human-readable label for the selected capture source.
322	pub fn label(&self) -> &str {
323		&self.label
324	}
325}
326
327/// Open the capture source described by `config`.
328pub async fn open(config: &Config) -> Result<Stream, Error> {
329	validate(config)?;
330	match &config.source {
331		Source::Camera(device) => {
332			let _ = device;
333			#[cfg(target_os = "macos")]
334			{
335				avfoundation::open(config, device.as_deref()).await
336			}
337			#[cfg(target_os = "linux")]
338			{
339				v4l2::open(config, device.as_deref()).await
340			}
341			#[cfg(target_os = "windows")]
342			{
343				mediafoundation::open(config, device.as_deref()).await
344			}
345			#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
346			{
347				Err(Error::Unsupported("camera capture".to_string()))
348			}
349		}
350		Source::Display(device) => {
351			let _ = device;
352			#[cfg(target_os = "macos")]
353			{
354				screencapture::open_display(config, device.as_deref()).await
355			}
356			#[cfg(target_os = "windows")]
357			{
358				desktopduplication::open(config, device.as_deref()).await
359			}
360			#[cfg(all(target_os = "linux", feature = "pipewire"))]
361			{
362				if x11::selected(device.as_deref()) {
363					x11::open_display(config, device.as_deref()).await
364				} else {
365					pipewire::open(config, device.as_deref()).await
366				}
367			}
368			#[cfg(all(target_os = "linux", not(feature = "pipewire")))]
369			{
370				x11::open_display(config, device.as_deref()).await
371			}
372			#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
373			{
374				Err(Error::Unsupported("screen capture".to_string()))
375			}
376		}
377		Source::Window(id) => {
378			let _ = id;
379			#[cfg(target_os = "macos")]
380			{
381				screencapture::open_window(config, id).await
382			}
383			#[cfg(target_os = "linux")]
384			{
385				x11::open_window(config, id).await
386			}
387			#[cfg(target_os = "windows")]
388			{
389				window::open(config, id).await
390			}
391			#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
392			{
393				Err(Error::Unsupported("window capture".to_string()))
394			}
395		}
396		Source::App(id) => {
397			let _ = id;
398			#[cfg(target_os = "macos")]
399			{
400				screencapture::open_app(config, id).await
401			}
402			#[cfg(not(target_os = "macos"))]
403			{
404				Err(Error::Unsupported("application capture".to_string()))
405			}
406		}
407	}
408}
409
410fn validate(config: &Config) -> Result<(), Error> {
411	match config.framerate {
412		Some(value) if value == 0 || value > MAX_FRAMERATE => Err(Error::InvalidFramerate(value)),
413		_ => Ok(()),
414	}
415}
416
417/// List the available cameras and the identifiers [`Source::Camera`] accepts.
418pub async fn cameras() -> Result<Vec<Camera>, Error> {
419	#[cfg(target_os = "macos")]
420	{
421		avfoundation::cameras()
422	}
423	#[cfg(target_os = "linux")]
424	{
425		blocking(v4l2::cameras).await
426	}
427	#[cfg(target_os = "windows")]
428	{
429		blocking(mediafoundation::cameras).await
430	}
431	#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
432	{
433		Err(Error::Unsupported("listing cameras".to_string()))
434	}
435}
436
437/// List the available displays and the identifiers [`Source::Display`] accepts.
438///
439/// On Wayland the xdg-desktop-portal picker owns display selection, so there is
440/// no portal list or stable identifier to expose. When XWayland is available,
441/// its native displays are listed with stable X11 ids.
442pub async fn displays() -> Result<Vec<Display>, Error> {
443	#[cfg(target_os = "macos")]
444	{
445		screencapture::displays().await
446	}
447	#[cfg(target_os = "windows")]
448	{
449		blocking(desktopduplication::displays).await
450	}
451	#[cfg(target_os = "linux")]
452	{
453		blocking(x11::displays).await
454	}
455	#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
456	{
457		Err(Error::Unsupported("listing displays".to_string()))
458	}
459}
460
461/// List the on-screen windows.
462pub async fn windows() -> Result<Vec<Window>, Error> {
463	#[cfg(target_os = "macos")]
464	{
465		screencapture::windows().await
466	}
467	#[cfg(target_os = "linux")]
468	{
469		blocking(x11::windows).await
470	}
471	#[cfg(target_os = "windows")]
472	{
473		blocking(window::windows).await
474	}
475	#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
476	{
477		Err(Error::Unsupported("listing windows".to_string()))
478	}
479}
480
481/// List the applications with at least one on-screen window. macOS only.
482pub async fn apps() -> Result<Vec<App>, Error> {
483	#[cfg(target_os = "macos")]
484	{
485		screencapture::apps().await
486	}
487	#[cfg(not(target_os = "macos"))]
488	{
489		Err(Error::Unsupported("listing applications".to_string()))
490	}
491}
492
493/// Run synchronous platform enumeration off the async runtime's worker threads.
494#[cfg(any(target_os = "linux", target_os = "windows"))]
495async fn blocking<T, F>(f: F) -> Result<T, Error>
496where
497	F: FnOnce() -> Result<T, Error> + Send + 'static,
498	T: Send + 'static,
499{
500	tokio::task::spawn_blocking(f)
501		.await
502		.map_err(|err| Error::Codec(anyhow::anyhow!("capture enumeration thread failed: {err}")))?
503}
504
505#[cfg(test)]
506mod tests {
507	use super::*;
508
509	#[test]
510	fn validates_framerate_range() {
511		let mut config = Config::default();
512		assert!(validate(&config).is_ok());
513
514		config.framerate = Some(1);
515		assert!(validate(&config).is_ok());
516		config.framerate = Some(MAX_FRAMERATE);
517		assert!(validate(&config).is_ok());
518
519		config.framerate = Some(0);
520		assert!(matches!(validate(&config), Err(Error::InvalidFramerate(0))));
521		config.framerate = Some(MAX_FRAMERATE + 1);
522		assert!(matches!(validate(&config), Err(Error::InvalidFramerate(value)) if value == MAX_FRAMERATE + 1));
523	}
524}