#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
reason = "pixel arithmetic is bounded by the negotiated frame size"
)]
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use v4l::buffer::Type;
use v4l::io::mmap::Stream as MmapStream;
use v4l::io::traits::{CaptureStream, Stream as StreamTrait};
use v4l::video::Capture;
use v4l::{Device, Format, FourCC};
use zune_core::bytestream::ZCursor;
use zune_core::colorspace::ColorSpace;
use zune_core::options::DecoderOptions;
use zune_jpeg::JpegDecoder;
pub use crate::capture_types::{CaptureError, Frame};
use crate::{CameraAuthorization, linux};
const PREVIEW_WIDTH: u32 = 1280;
const PREVIEW_HEIGHT: u32 = 720;
const OVERSIZED_REQUEST: u32 = 16384;
const BUFFER_COUNT: u32 = 4;
const STREAM_TIMEOUT: Duration = Duration::from_secs(3);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Encoding {
Mjpeg,
Yuyv,
}
impl Encoding {
const PREFERRED: [(Self, &'static [u8; 4]); 2] =
[(Self::Mjpeg, b"MJPG"), (Self::Yuyv, b"YUYV")];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Quality {
Preview,
Native,
}
struct Session {
device: Device,
encoding: Encoding,
width: u32,
height: u32,
}
fn open_session(unique_id: &str, quality: Quality) -> Result<Session, CaptureError> {
let path = linux::node_for_unique_id(unique_id).ok_or(CaptureError::NotFound)?;
let device = Device::with_path(&path).map_err(|error| {
if error.kind() == std::io::ErrorKind::PermissionDenied {
CaptureError::AccessDenied
} else {
CaptureError::Setup(format!("{}: {error}", path.display()))
}
})?;
let available = device
.enum_formats()
.map_err(|error| CaptureError::Setup(error.to_string()))?;
let (encoding, fourcc) = Encoding::PREFERRED
.into_iter()
.find(|(_, fourcc)| {
available
.iter()
.any(|format| format.fourcc == FourCC::new(fourcc))
})
.ok_or_else(|| {
CaptureError::Setup(format!(
"camera offers no MJPEG or YUYV format (has: {})",
available
.iter()
.map(|format| format.fourcc.to_string())
.collect::<Vec<_>>()
.join(", ")
))
})?;
let (width, height) = match quality {
Quality::Preview => (PREVIEW_WIDTH, PREVIEW_HEIGHT),
Quality::Native => largest_size(&device, FourCC::new(fourcc)),
};
let requested = Format::new(width, height, FourCC::new(fourcc));
let actual = with_busy_retry(|| device.set_format(&requested))
.map_err(|error| CaptureError::Setup(error.to_string()))?;
if actual.fourcc != FourCC::new(fourcc) {
return Err(CaptureError::Setup(format!(
"driver substituted {} for the requested {}",
actual.fourcc,
FourCC::new(fourcc)
)));
}
Ok(Session {
device,
encoding,
width: actual.width,
height: actual.height,
})
}
fn largest_size(device: &Device, fourcc: FourCC) -> (u32, u32) {
device
.enum_framesizes(fourcc)
.into_iter()
.flatten()
.flat_map(|size| size.size.to_discrete())
.map(|discrete| (discrete.width, discrete.height))
.max_by_key(|&(width, height)| u64::from(width) * u64::from(height))
.unwrap_or((OVERSIZED_REQUEST, OVERSIZED_REQUEST))
}
const BUSY: i32 = 16;
const REOPEN_GRACE: Duration = Duration::from_millis(1500);
const REOPEN_POLL: Duration = Duration::from_millis(25);
fn build_stream(session: &Session, timeout: Duration) -> Result<MmapStream<'static>, CaptureError> {
let mut stream = with_busy_retry(|| {
MmapStream::with_buffers(&session.device, Type::VideoCapture, BUFFER_COUNT)
})
.map_err(|error| CaptureError::Setup(error.to_string()))?;
stream.set_timeout(timeout);
Ok(stream)
}
fn with_busy_retry<T>(mut step: impl FnMut() -> std::io::Result<T>) -> std::io::Result<T> {
let deadline = Instant::now() + REOPEN_GRACE;
loop {
match step() {
Err(error) if error.raw_os_error() == Some(BUSY) && Instant::now() < deadline => {
std::thread::sleep(REOPEN_POLL);
}
outcome => return outcome,
}
}
}
pub fn capture_frame(unique_id: &str, timeout: Duration) -> Result<Frame, CaptureError> {
let session = open_session(unique_id, Quality::Native)?;
let mut stream = build_stream(&session, timeout)?;
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
let Ok((buffer, meta)) = stream.next() else {
break;
};
if let Some(frame) = decode(&buffer[..used(buffer, meta.bytesused)], &session) {
return Ok(frame);
}
}
Err(CaptureError::Timeout)
}
fn used(buffer: &[u8], bytesused: u32) -> usize {
(bytesused as usize).min(buffer.len())
}
struct Shared {
latest: Mutex<Option<Arc<Frame>>>,
generation: AtomicU64,
}
pub struct CameraStream {
shared: Arc<Shared>,
stop: Arc<AtomicBool>,
}
impl CameraStream {
#[must_use]
pub fn latest_frame(&self) -> Option<Arc<Frame>> {
self.shared.latest.lock().ok().and_then(|slot| slot.clone())
}
#[must_use]
pub fn take_frame(&self) -> Option<Arc<Frame>> {
self.shared
.latest
.lock()
.ok()
.and_then(|mut slot| slot.take())
}
#[must_use]
pub fn frame_generation(&self) -> u64 {
self.shared.generation.load(Ordering::Relaxed)
}
}
impl Drop for CameraStream {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
}
}
pub fn start_stream(unique_id: &str) -> Result<CameraStream, CaptureError> {
let session = open_session(unique_id, Quality::Preview)?;
let stream = build_stream(&session, STREAM_TIMEOUT)?;
let shared = Arc::new(Shared {
latest: Mutex::new(None),
generation: AtomicU64::new(0),
});
let stop = Arc::new(AtomicBool::new(false));
std::thread::Builder::new()
.name("openlogi-camera".into())
.spawn({
let shared = Arc::clone(&shared);
let stop = Arc::clone(&stop);
move || run_stream(stream, &session, &shared, &stop)
})
.map_err(|error| CaptureError::Setup(error.to_string()))?;
Ok(CameraStream { shared, stop })
}
fn run_stream(
mut stream: MmapStream<'static>,
session: &Session,
shared: &Shared,
stop: &AtomicBool,
) {
while !stop.load(Ordering::Relaxed) {
let (buffer, meta) = match stream.next() {
Ok(frame) => frame,
Err(error) => {
tracing::warn!(%error, "camera stream ended");
break;
}
};
let Some(frame) = decode(&buffer[..used(buffer, meta.bytesused)], session) else {
continue;
};
if let Ok(mut slot) = shared.latest.lock() {
*slot = Some(Arc::new(frame));
}
shared.generation.fetch_add(1, Ordering::Relaxed);
}
let _ = stream.stop();
}
fn decode(buffer: &[u8], session: &Session) -> Option<Frame> {
match session.encoding {
Encoding::Mjpeg => decode_mjpeg(buffer),
Encoding::Yuyv => decode_yuyv(buffer, session.width, session.height),
}
}
fn decode_mjpeg(buffer: &[u8]) -> Option<Frame> {
let options = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::BGRA);
let mut decoder = JpegDecoder::new_with_options(ZCursor::new(buffer), options);
let bgra = decoder.decode().ok()?;
let info = decoder.info()?;
let (width, height) = (u32::from(info.width), u32::from(info.height));
if bgra.len() < (width as usize) * (height as usize) * 4 {
return None;
}
Some(Frame {
width,
height,
bgra,
})
}
fn decode_yuyv(buffer: &[u8], width: u32, height: u32) -> Option<Frame> {
let pixels = (width as usize).checked_mul(height as usize)?;
if buffer.len() < pixels * 2 {
return None;
}
let mut bgra = vec![0u8; pixels * 4];
for (pair, out) in buffer[..pixels * 2]
.chunks_exact(4)
.zip(bgra.chunks_exact_mut(8))
{
let (y0, u, y1, v) = (
i32::from(pair[0]),
i32::from(pair[1]) - 128,
i32::from(pair[2]),
i32::from(pair[3]) - 128,
);
write_bgra(&mut out[..4], y0, u, v);
write_bgra(&mut out[4..], y1, u, v);
}
Some(Frame {
width,
height,
bgra,
})
}
fn write_bgra(out: &mut [u8], y: i32, u: i32, v: i32) {
let y = y * 256;
out[0] = clamp_u8(y + 452 * u);
out[1] = clamp_u8(y - 88 * u - 183 * v);
out[2] = clamp_u8(y + 359 * v);
out[3] = 0xFF;
}
fn clamp_u8(scaled: i32) -> u8 {
(scaled / 256).clamp(0, 255) as u8
}
#[must_use]
pub fn camera_access_granted() -> bool {
camera_authorization() == CameraAuthorization::Granted
}
#[must_use]
pub fn camera_authorization() -> CameraAuthorization {
let nodes = linux::nodes();
if nodes.is_empty() {
return CameraAuthorization::Granted;
}
if nodes
.iter()
.any(|node| Device::with_path(&node.path).is_ok())
{
CameraAuthorization::Granted
} else {
CameraAuthorization::Denied
}
}
pub fn request_camera_access() {}
#[cfg(test)]
#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
mod tests {
use super::*;
#[test]
fn yuyv_rejects_a_short_buffer() {
assert!(decode_yuyv(&[0; 3], 2, 1).is_none());
}
#[test]
fn yuyv_decodes_grey_to_grey() {
let frame = decode_yuyv(&[128, 128, 128, 128], 2, 1).expect("2x1 frame");
assert_eq!(frame.width, 2);
assert_eq!(frame.height, 1);
assert_eq!(frame.bgra, vec![128, 128, 128, 255, 128, 128, 128, 255]);
}
#[test]
fn yuyv_saturates_out_of_gamut_chroma() {
let frame = decode_yuyv(&[255, 255, 255, 255], 2, 1).expect("2x1 frame");
assert_eq!(&frame.bgra[..4], &[255, 120, 255, 255]);
}
#[test]
fn mjpeg_rejects_a_non_jpeg_buffer() {
assert!(decode_mjpeg(&[0xFF; 64]).is_none());
}
#[test]
fn used_clamps_a_driver_overreporting_bytesused() {
assert_eq!(used(&[0; 10], 99), 10);
assert_eq!(used(&[0; 10], 4), 4);
}
}