Skip to main content

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}