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