openlogi_camera/capture_linux.rs
1//! V4L2 camera capture on Linux: a one-shot snapshot and a live frame stream.
2//!
3//! Buffers are mmap'd from the kernel and decoded to **BGRA**, gpui's native
4//! texture order, so the preview uploads them without a channel swap.
5//!
6//! Format choice prefers **MJPEG**: at 720p a YUYV stream is ~27 MB/s over USB
7//! and starves other bandwidth on the same controller, while MJPEG is a tenth
8//! of that, and `zune-jpeg` decodes it straight to BGRA in one pass. YUYV is
9//! the fallback for the few cameras that don't offer MJPEG.
10//!
11//! Resolution follows the session's [`Quality`]: the live preview streams 720p,
12//! while a snapshot takes the camera's largest mode. Note this only sizes
13//! *OpenLogi's own* stream — resolution is negotiated per handle, so it is not
14//! a device setting other applications observe, unlike the UVC controls in
15//! `uvc_linux`.
16//!
17//! Unlike macOS, Linux has no per-app camera consent model — access is decided
18//! by filesystem permission on `/dev/video*` (the `video` group). So
19//! [`camera_authorization`] reports `Granted`/`Denied` by probing whether the
20//! node actually opens, and never `Undetermined`: there is nothing to prompt.
21
22use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
23use std::sync::{Arc, Mutex};
24use std::time::{Duration, Instant};
25
26use v4l::buffer::Type;
27use v4l::io::mmap::Stream as MmapStream;
28use v4l::io::traits::{CaptureStream, Stream as StreamTrait};
29use v4l::video::Capture;
30use v4l::{Device, Format, FourCC};
31use zune_core::bytestream::ZCursor;
32use zune_core::colorspace::ColorSpace;
33use zune_core::options::DecoderOptions;
34use zune_jpeg::JpegDecoder;
35
36pub use crate::capture_types::{CaptureError, Frame};
37use crate::{CameraAuthorization, linux};
38
39/// Preview target. The driver picks the nearest size it supports, so this is a
40/// request rather than a guarantee — the negotiated format is read back.
41const PREVIEW_WIDTH: u32 = 1280;
42const PREVIEW_HEIGHT: u32 = 720;
43
44/// Size requested for a native-resolution session when the driver reports only
45/// stepwise or continuous frame sizes, with no discrete list to pick a maximum
46/// from. `VIDIOC_S_FMT` clamps a request to what the device supports, so asking
47/// for more than any current sensor offers resolves to its largest mode.
48const OVERSIZED_REQUEST: u32 = 16384;
49
50/// Mapped buffers to keep in flight. Four is the usual V4L2 default: enough to
51/// absorb a scheduling hiccup without adding a frame of latency.
52const BUFFER_COUNT: u32 = 4;
53
54/// How long a live stream waits for one frame before giving up on the camera.
55///
56/// Sized for **stream start-up**, not the steady state: a UVC camera negotiates
57/// bandwidth and spins up its sensor on the first `STREAMON`, which measured
58/// ~730 ms on an MX Brio, against ~32 ms per frame once running. A timeout is
59/// unrecoverable (see [`run_stream`]), so this must clear the slowest start-up
60/// comfortably rather than sit close to it.
61const STREAM_TIMEOUT: Duration = Duration::from_secs(3);
62
63/// The pixel layouts this backend decodes.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65enum Encoding {
66 /// Motion-JPEG: one baseline JPEG per frame.
67 Mjpeg,
68 /// Packed YUV 4:2:2, two pixels per four bytes (`Y0 Cb Y1 Cr`).
69 Yuyv,
70}
71
72impl Encoding {
73 /// FourCCs in preference order — MJPEG first, for the bandwidth reason in
74 /// the module docs.
75 const PREFERRED: [(Self, &'static [u8; 4]); 2] =
76 [(Self::Mjpeg, b"MJPG"), (Self::Yuyv, b"YUYV")];
77}
78
79/// What a capture session optimises for.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81enum Quality {
82 /// 720p. Sharp in the preview box at a fraction of a 4K frame's decode,
83 /// copy and texture upload — and a live stream pays that cost 30 times a
84 /// second.
85 Preview,
86 /// The camera's largest mode. A snapshot is taken once and kept, so it is
87 /// worth the sensor's full detail; this mirrors the macOS backend, where
88 /// only the preview session carries a 720p preset.
89 Native,
90}
91
92/// A negotiated capture session: the open device plus what its frames contain.
93struct Session {
94 device: Device,
95 encoding: Encoding,
96 width: u32,
97 height: u32,
98}
99
100/// Open `unique_id` and negotiate a decodable format on it.
101fn open_session(unique_id: &str, quality: Quality) -> Result<Session, CaptureError> {
102 let path = linux::node_for_unique_id(unique_id).ok_or(CaptureError::NotFound)?;
103 let device = Device::with_path(&path).map_err(|error| {
104 if error.kind() == std::io::ErrorKind::PermissionDenied {
105 CaptureError::AccessDenied
106 } else {
107 CaptureError::Setup(format!("{}: {error}", path.display()))
108 }
109 })?;
110
111 let available = device
112 .enum_formats()
113 .map_err(|error| CaptureError::Setup(error.to_string()))?;
114
115 let (encoding, fourcc) = Encoding::PREFERRED
116 .into_iter()
117 .find(|(_, fourcc)| {
118 available
119 .iter()
120 .any(|format| format.fourcc == FourCC::new(fourcc))
121 })
122 .ok_or_else(|| {
123 CaptureError::Setup(format!(
124 "camera offers no MJPEG or YUYV format (has: {})",
125 available
126 .iter()
127 .map(|format| format.fourcc.to_string())
128 .collect::<Vec<_>>()
129 .join(", ")
130 ))
131 })?;
132
133 let (width, height) = match quality {
134 Quality::Preview => (PREVIEW_WIDTH, PREVIEW_HEIGHT),
135 Quality::Native => largest_size(&device, FourCC::new(fourcc)),
136 };
137 let requested = Format::new(width, height, FourCC::new(fourcc));
138 let actual = with_busy_retry(|| device.set_format(&requested))
139 .map_err(|error| CaptureError::Setup(error.to_string()))?;
140
141 // The driver may substitute a format it prefers; decoding the wrong layout
142 // would render as noise, so fail loudly instead.
143 if actual.fourcc != FourCC::new(fourcc) {
144 return Err(CaptureError::Setup(format!(
145 "driver substituted {} for the requested {}",
146 actual.fourcc,
147 FourCC::new(fourcc)
148 )));
149 }
150
151 Ok(Session {
152 device,
153 encoding,
154 width: actual.width,
155 height: actual.height,
156 })
157}
158
159/// The largest frame size the camera offers for `fourcc`, by pixel count.
160///
161/// Only discrete sizes can be compared directly; a driver advertising a
162/// stepwise or continuous range gets [`OVERSIZED_REQUEST`] instead and clamps
163/// it down itself.
164fn largest_size(device: &Device, fourcc: FourCC) -> (u32, u32) {
165 device
166 .enum_framesizes(fourcc)
167 .into_iter()
168 .flatten()
169 .flat_map(|size| size.size.to_discrete())
170 .map(|discrete| (discrete.width, discrete.height))
171 .max_by_key(|&(width, height)| u64::from(width) * u64::from(height))
172 .unwrap_or((OVERSIZED_REQUEST, OVERSIZED_REQUEST))
173}
174
175/// `EBUSY` — the device is still streaming, here always on another handle.
176const BUSY: i32 = 16;
177
178/// How long to keep retrying `REQBUFS` while a previous stream finishes tearing
179/// down. Covers the ~600 ms `STREAMOFF` that [`CameraStream::drop`] leaves
180/// running, with headroom for a slower camera.
181const REOPEN_GRACE: Duration = Duration::from_millis(1500);
182
183/// Gap between `REQBUFS` attempts while waiting out a teardown.
184const REOPEN_POLL: Duration = Duration::from_millis(25);
185
186/// Map buffers for a capture stream and arm its per-frame timeout.
187///
188/// The stream is deliberately **not** started here: `MmapStream::next` enqueues
189/// every buffer and issues `STREAMON` itself on first use. Calling `start()`
190/// first would mark the stream active with an empty queue, so `next` would take
191/// its steady-state path and only ever cycle one buffer.
192///
193/// `REQBUFS` is retried while the device reports `EBUSY`, which happens when a
194/// just-dropped stream is still in `STREAMOFF` — reselecting the same camera
195/// within ~600 ms otherwise fails outright. Waiting here (rarely, and only on a
196/// re-open) is the trade for never blocking the UI thread in `drop`.
197fn build_stream(session: &Session, timeout: Duration) -> Result<MmapStream<'static>, CaptureError> {
198 let mut stream = with_busy_retry(|| {
199 MmapStream::with_buffers(&session.device, Type::VideoCapture, BUFFER_COUNT)
200 })
201 .map_err(|error| CaptureError::Setup(error.to_string()))?;
202 stream.set_timeout(timeout);
203 Ok(stream)
204}
205
206/// Run a V4L2 setup ioctl, retrying while the driver reports the device busy.
207///
208/// Both `VIDIOC_S_FMT` and `VIDIOC_REQBUFS` return `EBUSY` while *any* handle
209/// is still streaming, so a re-open racing a previous stream's `STREAMOFF` hits
210/// this on whichever call comes first.
211fn with_busy_retry<T>(mut step: impl FnMut() -> std::io::Result<T>) -> std::io::Result<T> {
212 let deadline = Instant::now() + REOPEN_GRACE;
213 loop {
214 match step() {
215 Err(error) if error.raw_os_error() == Some(BUSY) && Instant::now() < deadline => {
216 std::thread::sleep(REOPEN_POLL);
217 }
218 outcome => return outcome,
219 }
220 }
221}
222
223/// Capture a single frame from the camera with `unique_id`, at the camera's
224/// native resolution.
225///
226/// A partially-filled MJPEG buffer (a dropped USB packet) fails to decode, so
227/// this keeps reading until one decodes or `timeout` elapses. The caller's whole
228/// budget is given to the dequeue, since the first frame carries the stream
229/// start-up cost described on `STREAM_TIMEOUT` — and more of it at full
230/// resolution, where the sensor has more to read out per frame.
231///
232/// # Errors
233/// [`CaptureError::NotFound`] when no camera matches, [`CaptureError::AccessDenied`]
234/// without permission on the node, [`CaptureError::Timeout`] when no frame
235/// decodes in time.
236pub fn capture_frame(unique_id: &str, timeout: Duration) -> Result<Frame, CaptureError> {
237 let session = open_session(unique_id, Quality::Native)?;
238 let mut stream = build_stream(&session, timeout)?;
239 let deadline = Instant::now() + timeout;
240
241 while Instant::now() < deadline {
242 // A dequeue error leaves the stream unusable (see `run_stream`), so
243 // there is nothing to retry — only a torn frame is worth another pass.
244 let Ok((buffer, meta)) = stream.next() else {
245 break;
246 };
247 if let Some(frame) = decode(&buffer[..used(buffer, meta.bytesused)], &session) {
248 return Ok(frame);
249 }
250 }
251
252 Err(CaptureError::Timeout)
253}
254
255/// The filled prefix of a mapped buffer. `bytesused` is what the driver wrote;
256/// the mapping itself is the larger negotiated buffer size, and the tail is
257/// stale data from an earlier frame.
258fn used(buffer: &[u8], bytesused: u32) -> usize {
259 (bytesused as usize).min(buffer.len())
260}
261
262/// Frame slot shared between the capture thread and the UI's polling.
263struct Shared {
264 latest: Mutex<Option<Arc<Frame>>>,
265 generation: AtomicU64,
266}
267
268/// A running capture stream. Dropping it stops the thread and releases the
269/// camera, which is what turns the hardware LED back off.
270pub struct CameraStream {
271 shared: Arc<Shared>,
272 stop: Arc<AtomicBool>,
273}
274
275impl CameraStream {
276 /// The most recently delivered frame, or `None` before the first arrives.
277 /// Returns a shared [`Arc`] so polling at preview rate never copies the
278 /// pixel buffer.
279 #[must_use]
280 pub fn latest_frame(&self) -> Option<Arc<Frame>> {
281 self.shared.latest.lock().ok().and_then(|slot| slot.clone())
282 }
283
284 /// Take the most recent frame out of the slot (the next delivered frame
285 /// refills it). A sole consumer that unwraps the [`Arc`] gets the pixel
286 /// buffer without copying it.
287 #[must_use]
288 pub fn take_frame(&self) -> Option<Arc<Frame>> {
289 self.shared
290 .latest
291 .lock()
292 .ok()
293 .and_then(|mut slot| slot.take())
294 }
295
296 /// A counter that increments on every delivered frame, so the preview can
297 /// skip rebuilding its texture when no new frame has arrived.
298 #[must_use]
299 pub fn frame_generation(&self) -> u64 {
300 self.shared.generation.load(Ordering::Relaxed)
301 }
302}
303
304impl Drop for CameraStream {
305 fn drop(&mut self) {
306 // Signal and return: the worker tears the stream down on its own.
307 //
308 // Deliberately *not* a join. `VIDIOC_STREAMOFF` blocks for ~600 ms on a
309 // UVC camera while the kernel gives back the USB isochronous bandwidth
310 // reservation, and the GUI drops the preview from `set_target` on the
311 // UI thread — joining would freeze the window for that long on every
312 // switch away from the Camera tab. The cost of not waiting is that the
313 // device stays busy briefly, which [`build_stream`] absorbs.
314 self.stop.store(true, Ordering::Relaxed);
315 }
316}
317
318/// Start a live capture stream on the camera with `unique_id`.
319///
320/// # Errors
321/// Same as [`capture_frame`], minus `Timeout` (frames are polled, not awaited).
322pub fn start_stream(unique_id: &str) -> Result<CameraStream, CaptureError> {
323 let session = open_session(unique_id, Quality::Preview)?;
324 let stream = build_stream(&session, STREAM_TIMEOUT)?;
325
326 let shared = Arc::new(Shared {
327 latest: Mutex::new(None),
328 generation: AtomicU64::new(0),
329 });
330 let stop = Arc::new(AtomicBool::new(false));
331
332 std::thread::Builder::new()
333 .name("openlogi-camera".into())
334 .spawn({
335 let shared = Arc::clone(&shared);
336 let stop = Arc::clone(&stop);
337 move || run_stream(stream, &session, &shared, &stop)
338 })
339 .map_err(|error| CaptureError::Setup(error.to_string()))?;
340
341 Ok(CameraStream { shared, stop })
342}
343
344/// Pump frames into `shared` until `stop` is set or the camera stops delivering.
345///
346/// A dequeue error ends the loop rather than retrying. `MmapStream::next` only
347/// re-queues the buffer it last dequeued, so after a timeout the buffer it
348/// points at is still queued and every later call fails `VIDIOC_QBUF` with
349/// `EINVAL` — retrying would spin the CPU forever without ever recovering. The
350/// preview freezes on the last good frame, which the stalled frame generation
351/// makes visible to the caller.
352fn run_stream(
353 mut stream: MmapStream<'static>,
354 session: &Session,
355 shared: &Shared,
356 stop: &AtomicBool,
357) {
358 while !stop.load(Ordering::Relaxed) {
359 let (buffer, meta) = match stream.next() {
360 Ok(frame) => frame,
361 Err(error) => {
362 tracing::warn!(%error, "camera stream ended");
363 break;
364 }
365 };
366 let Some(frame) = decode(&buffer[..used(buffer, meta.bytesused)], session) else {
367 continue;
368 };
369 if let Ok(mut slot) = shared.latest.lock() {
370 *slot = Some(Arc::new(frame));
371 }
372 shared.generation.fetch_add(1, Ordering::Relaxed);
373 }
374 let _ = stream.stop();
375}
376
377/// Decode one raw buffer into a BGRA frame, or `None` when the buffer is
378/// truncated or malformed (a dropped USB packet mid-frame).
379fn decode(buffer: &[u8], session: &Session) -> Option<Frame> {
380 match session.encoding {
381 Encoding::Mjpeg => decode_mjpeg(buffer),
382 Encoding::Yuyv => decode_yuyv(buffer, session.width, session.height),
383 }
384}
385
386/// Decode a Motion-JPEG frame straight to BGRA.
387///
388/// Dimensions come from the JPEG header rather than the negotiated format:
389/// they agree in practice, but trusting the header keeps the buffer length and
390/// the reported size consistent even if a driver lies.
391fn decode_mjpeg(buffer: &[u8]) -> Option<Frame> {
392 let options = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::BGRA);
393 let mut decoder = JpegDecoder::new_with_options(ZCursor::new(buffer), options);
394 let bgra = decoder.decode().ok()?;
395 let info = decoder.info()?;
396 let (width, height) = (u32::from(info.width), u32::from(info.height));
397
398 // A frame whose payload doesn't match its header is a torn capture.
399 if bgra.len() < (width as usize) * (height as usize) * 4 {
400 return None;
401 }
402
403 Some(Frame {
404 width,
405 height,
406 bgra,
407 })
408}
409
410/// Convert packed YUYV 4:2:2 to BGRA using BT.601, the colour space UVC
411/// cameras encode in.
412///
413/// Coefficients are scaled by 256 so the whole conversion is integer work; at
414/// 720p30 this runs per pixel on the capture thread.
415fn decode_yuyv(buffer: &[u8], width: u32, height: u32) -> Option<Frame> {
416 let pixels = (width as usize).checked_mul(height as usize)?;
417 if buffer.len() < pixels * 2 {
418 return None;
419 }
420
421 let mut bgra = vec![0u8; pixels * 4];
422 for (pair, out) in buffer[..pixels * 2]
423 .as_chunks::<4>()
424 .0
425 .iter()
426 .zip(bgra.as_chunks_mut::<8>().0)
427 {
428 let (y0, u, y1, v) = (
429 i32::from(pair[0]),
430 i32::from(pair[1]) - 128,
431 i32::from(pair[2]),
432 i32::from(pair[3]) - 128,
433 );
434 write_bgra(&mut out[..4], y0, u, v);
435 write_bgra(&mut out[4..], y1, u, v);
436 }
437
438 Some(Frame {
439 width,
440 height,
441 bgra,
442 })
443}
444
445/// Write one BT.601 YUV sample as a BGRA pixel.
446fn write_bgra(out: &mut [u8], y: i32, u: i32, v: i32) {
447 let y = y * 256;
448 out[0] = clamp_u8(y + 452 * u);
449 out[1] = clamp_u8(y - 88 * u - 183 * v);
450 out[2] = clamp_u8(y + 359 * v);
451 out[3] = 0xFF;
452}
453
454/// Saturate a fixed-point channel (scaled by 256) into a byte.
455#[expect(
456 clippy::cast_sign_loss,
457 reason = "the channel is clamped to 0..=255 before the narrowing"
458)]
459fn clamp_u8(scaled: i32) -> u8 {
460 (scaled / 256).clamp(0, 255) as u8
461}
462
463/// Whether this process can open the camera nodes it can see.
464#[must_use]
465pub fn camera_access_granted() -> bool {
466 camera_authorization() == CameraAuthorization::Granted
467}
468
469/// Report camera access by probing a node.
470///
471/// Linux has no consent prompt: a node either opens or it doesn't, decided by
472/// its group permissions. `Undetermined` is therefore never returned — with no
473/// camera present at all there is nothing to authorize, which reads as
474/// `Granted` (nothing is being withheld).
475#[must_use]
476pub fn camera_authorization() -> CameraAuthorization {
477 let nodes = linux::nodes();
478 if nodes.is_empty() {
479 return CameraAuthorization::Granted;
480 }
481 if nodes
482 .iter()
483 .any(|node| Device::with_path(&node.path).is_ok())
484 {
485 CameraAuthorization::Granted
486 } else {
487 CameraAuthorization::Denied
488 }
489}
490
491/// No-op: Linux has no consent prompt — access is device-node permissions.
492pub fn request_camera_access() {}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 #[test]
499 fn yuyv_rejects_a_short_buffer() {
500 // One byte short of a single 2x1 macropixel.
501 assert!(decode_yuyv(&[0; 3], 2, 1).is_none());
502 }
503
504 #[test]
505 fn yuyv_decodes_grey_to_grey() {
506 // Y=128 with neutral chroma is mid-grey in every channel.
507 let frame = decode_yuyv(&[128, 128, 128, 128], 2, 1).expect("2x1 frame");
508 assert_eq!(frame.width, 2);
509 assert_eq!(frame.height, 1);
510 assert_eq!(frame.bgra, vec![128, 128, 128, 255, 128, 128, 128, 255]);
511 }
512
513 #[test]
514 fn yuyv_saturates_out_of_gamut_chroma() {
515 // Peak luma with peak chroma drives blue to 479 and red to 433 before
516 // clamping. They must saturate at 255, not wrap (479 as a truncated
517 // byte would be 223 — a vivid colour turning muddy). Green lands at
518 // 120 legitimately, inside the range, so it pins the coefficients too.
519 let frame = decode_yuyv(&[255, 255, 255, 255], 2, 1).expect("2x1 frame");
520 assert_eq!(&frame.bgra[..4], &[255, 120, 255, 255]);
521 }
522
523 #[test]
524 fn mjpeg_rejects_a_non_jpeg_buffer() {
525 assert!(decode_mjpeg(&[0xFF; 64]).is_none());
526 }
527
528 #[test]
529 fn used_clamps_a_driver_overreporting_bytesused() {
530 // A driver claiming more than the mapping holds must not panic the
531 // slice below.
532 assert_eq!(used(&[0; 10], 99), 10);
533 assert_eq!(used(&[0; 10], 4), 4);
534 }
535}