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