camshooter 0.1.1

Select a webcam, preview the live stream, and grab PNG snapshots with a keypress.
//! Camera enumeration and the background capture thread.
//!
//! Design constraints that shape this module:
//! - `nokhwa::Camera` is `!Send`, so it is created and owned *entirely* inside the
//!   capture thread. When that thread ends, the `Camera` drops and V4L2 releases
//!   `/dev/videoN` (the camera LED turns off). Nothing camera-related escapes to the UI.
//! - The only thing shared with the UI is the newest decoded frame, via a single
//!   mutex-guarded slot (`FrameSlot`) — a "latest value wins" cell, not a queue.
//! - After storing each frame the thread calls `ctx.request_repaint()`, otherwise egui
//!   sleeps and the preview only refreshes on mouse movement.

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;

use eframe::egui;
use image::RgbImage;
use nokhwa::pixel_format::RgbFormat;
use nokhwa::utils::{ApiBackend, CameraIndex, RequestedFormat, RequestedFormatType};
use nokhwa::{Camera, query};

/// If this many frame reads/decodes fail in a row we assume the device went away
/// (e.g. unplugged mid-stream) and bail out so the UI can fall back to the picker.
const MAX_CONSECUTIVE_ERRORS: u32 = 30;

/// A webcam the user can pick, as shown in the device list.
#[derive(Clone)]
pub struct CameraOption {
    pub index: CameraIndex,
    pub name: String,
}

/// Enumerate available webcams. Returns an empty Vec (not an error) when none are found
/// so the picker can show a friendly "no webcams detected" message.
pub fn list_cameras() -> anyhow::Result<Vec<CameraOption>> {
    let infos = query(ApiBackend::Auto)?;
    Ok(infos
        .into_iter()
        .map(|info| CameraOption {
            name: info.human_name(),
            index: info.index().clone(),
        })
        .collect())
}

/// Shared "newest frame" slot. The capture thread overwrites it; the UI reads it.
pub type FrameSlot = Arc<Mutex<Option<RgbImage>>>;

/// Owns a running capture thread and the channels back to the UI.
///
/// Dropping this without calling [`CaptureHandle::stop`] still signals the thread (via
/// `Drop`), but you should call `stop()` explicitly so the device is released *before*
/// you try to open another camera — V4L2 will reject opening a device still held.
pub struct CaptureHandle {
    join: Option<JoinHandle<()>>,
    stop: Arc<AtomicBool>,
    /// Newest decoded frame, shared with the UI.
    slot: FrameSlot,
    /// Set to `Some(msg)` if the thread died (open failed or device disconnected).
    error: Arc<Mutex<Option<String>>>,
}

impl CaptureHandle {
    /// Signal the capture thread to stop and wait for it to fully exit (joining is what
    /// guarantees the `Camera` has dropped and the device is free). Idempotent.
    pub fn stop(&mut self) {
        self.stop.store(true, Ordering::Release);
        if let Some(handle) = self.join.take() {
            let _ = handle.join();
        }
    }

    /// Take the newest frame, returning `Some` only when a *new* one has arrived since
    /// the last call. `take()` (rather than `clone()`) means the UI uploads to the GPU
    /// only when there's genuinely a new frame — on a heartbeat repaint with no new
    /// frame this returns `None` and the caller reuses its cached `last_frame`. The lock
    /// is held only for the move, so contention with the capture thread is negligible.
    pub fn latest_frame(&self) -> Option<RgbImage> {
        self.slot.lock().unwrap_or_else(|e| e.into_inner()).take()
    }

    /// The thread's terminal error, if it died.
    pub fn take_error(&self) -> Option<String> {
        self.error.lock().unwrap_or_else(|e| e.into_inner()).take()
    }
}

impl Drop for CaptureHandle {
    fn drop(&mut self) {
        self.stop();
    }
}

/// Spawn the capture thread for `index`. Returns immediately; frames begin landing in
/// the returned handle's `slot`. `ctx` is used to wake the UI on each new frame.
pub fn start_capture(index: CameraIndex, ctx: egui::Context) -> CaptureHandle {
    let stop = Arc::new(AtomicBool::new(false));
    let slot: FrameSlot = Arc::new(Mutex::new(None));
    let error = Arc::new(Mutex::new(None));

    let thread_stop = Arc::clone(&stop);
    let thread_slot = Arc::clone(&slot);
    let thread_error = Arc::clone(&error);

    let join = std::thread::Builder::new()
        .name("camshooter-capture".into())
        .spawn(move || capture_loop(index, ctx, thread_stop, thread_slot, thread_error))
        .expect("failed to spawn capture thread");

    CaptureHandle {
        join: Some(join),
        stop,
        slot,
        error,
    }
}

/// The capture thread body. Owns the `Camera` for its whole lifetime.
fn capture_loop(
    index: CameraIndex,
    ctx: egui::Context,
    stop: Arc<AtomicBool>,
    slot: FrameSlot,
    error: Arc<Mutex<Option<String>>>,
) {
    // Request the highest frame rate the device offers, decoding to RGB. Choosing this
    // deliberately avoids the camera defaulting to a slow high-res MJPEG-only mode.
    let requested =
        RequestedFormat::new::<RgbFormat>(RequestedFormatType::AbsoluteHighestFrameRate);

    let mut camera = match Camera::new(index, requested) {
        Ok(c) => c,
        Err(e) => {
            *error.lock().unwrap_or_else(|e| e.into_inner()) =
                Some(format!("Could not open camera: {e}"));
            ctx.request_repaint();
            return;
        }
    };

    if let Err(e) = camera.open_stream() {
        *error.lock().unwrap_or_else(|e| e.into_inner()) =
            Some(format!("Could not start stream: {e}"));
        ctx.request_repaint();
        return;
    }

    let mut consecutive_errors = 0u32;
    while !stop.load(Ordering::Acquire) {
        match camera
            .frame()
            .and_then(|buf| buf.decode_image::<RgbFormat>())
        {
            Ok(rgb) => {
                consecutive_errors = 0;
                *slot.lock().unwrap_or_else(|e| e.into_inner()) = Some(rgb);
                ctx.request_repaint();
            }
            Err(_) => {
                // A single dropped/garbled frame is normal; only give up if they pile up.
                consecutive_errors += 1;
                if consecutive_errors >= MAX_CONSECUTIVE_ERRORS {
                    *error.lock().unwrap_or_else(|e| e.into_inner()) =
                        Some("Lost connection to the camera (was it unplugged?)".into());
                    ctx.request_repaint();
                    break;
                }
            }
        }
    }
    // `camera` drops here -> device released, LED off.
}