Skip to main content

yscv_video/
camera.rs

1use crate::VideoError;
2
3#[cfg(feature = "native-camera")]
4use std::fmt;
5#[cfg(feature = "native-camera")]
6use std::time::Instant;
7
8#[cfg(feature = "native-camera")]
9use nokhwa::Camera;
10#[cfg(feature = "native-camera")]
11use nokhwa::pixel_format::RgbFormat;
12#[cfg(feature = "native-camera")]
13use nokhwa::utils::{ApiBackend, CameraIndex, RequestedFormat, RequestedFormatType, Resolution};
14
15#[cfg(feature = "native-camera")]
16use super::convert::{micros_to_u64, rgb8_bytes_to_frame};
17use super::frame::{Frame, Rgb8Frame};
18use super::source::FrameSource;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct CameraConfig {
22    pub device_index: u32,
23    pub width: u32,
24    pub height: u32,
25    pub fps: u32,
26}
27
28impl Default for CameraConfig {
29    fn default() -> Self {
30        Self {
31            device_index: 0,
32            width: 640,
33            height: 480,
34            fps: 30,
35        }
36    }
37}
38
39impl CameraConfig {
40    pub fn validate(&self) -> Result<(), VideoError> {
41        if self.width == 0 || self.height == 0 {
42            return Err(VideoError::InvalidCameraResolution {
43                width: self.width,
44                height: self.height,
45            });
46        }
47        if self.fps == 0 {
48            return Err(VideoError::InvalidCameraFps { fps: self.fps });
49        }
50        Ok(())
51    }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct CameraDeviceInfo {
56    pub index: u32,
57    pub label: String,
58}
59
60#[cfg(feature = "native-camera")]
61pub fn list_camera_devices() -> Result<Vec<CameraDeviceInfo>, VideoError> {
62    let backend = preferred_camera_backend();
63    let cameras = match nokhwa::query(backend) {
64        Ok(cameras) => cameras,
65        Err(primary_err) if backend != ApiBackend::Auto => {
66            nokhwa::query(ApiBackend::Auto).map_err(|fallback_err| {
67                VideoError::Source(format!(
68                    "failed to enumerate cameras with preferred backend {backend:?}: {primary_err}; \
69                     auto fallback failed: {fallback_err}"
70                ))
71            })?
72        }
73        Err(err) => {
74            return Err(VideoError::Source(format!(
75                "failed to enumerate cameras with backend {backend:?}: {err}"
76            )));
77        }
78    };
79    let mut devices = Vec::with_capacity(cameras.len());
80    for (idx, info) in cameras.into_iter().enumerate() {
81        let fallback_index = u32::try_from(idx).unwrap_or(u32::MAX);
82        let index = match info.index() {
83            CameraIndex::Index(value) => *value,
84            CameraIndex::String(value) => value.parse::<u32>().unwrap_or(fallback_index),
85        };
86        let human_name = info.human_name().trim().to_string();
87        let description = info.description().trim().to_string();
88        let label = if description.is_empty() {
89            human_name
90        } else {
91            format!("{human_name} ({description})")
92        };
93        devices.push(CameraDeviceInfo { index, label });
94    }
95    devices.sort_by(|left, right| {
96        left.index
97            .cmp(&right.index)
98            .then_with(|| left.label.cmp(&right.label))
99    });
100    devices.dedup_by(|left, right| left.index == right.index && left.label == right.label);
101    Ok(devices)
102}
103
104#[cfg(not(feature = "native-camera"))]
105pub fn list_camera_devices() -> Result<Vec<CameraDeviceInfo>, VideoError> {
106    Err(VideoError::CameraBackendDisabled)
107}
108
109pub fn resolve_camera_device(query: &str) -> Result<CameraDeviceInfo, VideoError> {
110    let devices = list_camera_devices()?;
111    select_camera_device(&devices, query)
112}
113
114pub fn resolve_camera_device_index(query: &str) -> Result<u32, VideoError> {
115    Ok(resolve_camera_device(query)?.index)
116}
117
118pub fn query_camera_devices(query: &str) -> Result<Vec<CameraDeviceInfo>, VideoError> {
119    let devices = list_camera_devices()?;
120    filter_camera_devices(&devices, query)
121}
122
123pub fn filter_camera_devices(
124    devices: &[CameraDeviceInfo],
125    query: &str,
126) -> Result<Vec<CameraDeviceInfo>, VideoError> {
127    let normalized_query = query.trim();
128    if normalized_query.is_empty() {
129        return Err(VideoError::InvalidCameraDeviceQuery {
130            query: query.to_string(),
131        });
132    }
133
134    let index_query = normalized_query.parse::<u32>().ok();
135    let query_lc = normalized_query.to_lowercase();
136    let mut matches = devices
137        .iter()
138        .filter(|device| {
139            if let Some(index_query) = index_query
140                && device.index == index_query
141            {
142                return true;
143            }
144            device.label.to_lowercase().contains(&query_lc)
145        })
146        .cloned()
147        .collect::<Vec<_>>();
148
149    matches.sort_by(|left, right| {
150        left.index
151            .cmp(&right.index)
152            .then_with(|| left.label.cmp(&right.label))
153    });
154    matches.dedup_by(|left, right| left.index == right.index && left.label == right.label);
155    Ok(matches)
156}
157
158pub(crate) fn select_camera_device(
159    devices: &[CameraDeviceInfo],
160    query: &str,
161) -> Result<CameraDeviceInfo, VideoError> {
162    let normalized_query = query.trim();
163    if normalized_query.is_empty() {
164        return Err(VideoError::InvalidCameraDeviceQuery {
165            query: query.to_string(),
166        });
167    }
168
169    if let Ok(index_query) = normalized_query.parse::<u32>() {
170        let by_index = devices
171            .iter()
172            .filter(|device| device.index == index_query)
173            .cloned()
174            .collect::<Vec<_>>();
175        if let Some(device) = unique_match(&by_index) {
176            return Ok(device);
177        }
178        if by_index.len() > 1 {
179            return Err(VideoError::CameraDeviceAmbiguous {
180                query: normalized_query.to_string(),
181                matches: format_device_matches(&by_index),
182            });
183        }
184    }
185
186    let query_lc = normalized_query.to_lowercase();
187
188    let mut exact = Vec::new();
189    let mut partial = Vec::new();
190    for device in devices {
191        let label_lc = device.label.to_lowercase();
192        if label_lc == query_lc {
193            exact.push(device.clone());
194            continue;
195        }
196        if label_lc.contains(&query_lc) {
197            partial.push(device.clone());
198        }
199    }
200
201    if let Some(device) = unique_match(&exact) {
202        return Ok(device);
203    }
204    if exact.len() > 1 {
205        return Err(VideoError::CameraDeviceAmbiguous {
206            query: normalized_query.to_string(),
207            matches: format_device_matches(&exact),
208        });
209    }
210    if let Some(device) = unique_match(&partial) {
211        return Ok(device);
212    }
213    if partial.len() > 1 {
214        return Err(VideoError::CameraDeviceAmbiguous {
215            query: normalized_query.to_string(),
216            matches: format_device_matches(&partial),
217        });
218    }
219    Err(VideoError::CameraDeviceNotFound {
220        query: normalized_query.to_string(),
221    })
222}
223
224fn unique_match(matches: &[CameraDeviceInfo]) -> Option<CameraDeviceInfo> {
225    if matches.len() == 1 {
226        Some(matches[0].clone())
227    } else {
228        None
229    }
230}
231
232fn format_device_matches(matches: &[CameraDeviceInfo]) -> Vec<String> {
233    let mut values = matches
234        .iter()
235        .map(|device| format!("{}: {}", device.index, device.label))
236        .collect::<Vec<_>>();
237    values.sort();
238    values
239}
240
241#[cfg(feature = "native-camera")]
242pub struct CameraFrameSource {
243    camera: Camera,
244    next_index: u64,
245    started_at: Instant,
246}
247
248#[cfg(feature = "native-camera")]
249impl fmt::Debug for CameraFrameSource {
250    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251        f.debug_struct("CameraFrameSource")
252            .field("next_index", &self.next_index)
253            .finish_non_exhaustive()
254    }
255}
256
257#[cfg(not(feature = "native-camera"))]
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub struct CameraFrameSource;
260
261#[cfg(feature = "native-camera")]
262impl CameraFrameSource {
263    pub fn open(config: CameraConfig) -> Result<Self, VideoError> {
264        config.validate()?;
265        let backend = preferred_camera_backend();
266        let mut camera =
267            open_camera_with_backend(config.device_index, backend).or_else(|primary| {
268                if backend == ApiBackend::Auto {
269                    return Err(primary);
270                }
271                open_camera_with_backend(config.device_index, ApiBackend::Auto).map_err(|fallback| {
272                VideoError::Source(format!(
273                    "failed to create camera source with preferred backend {backend:?}: {primary}; \
274                     auto fallback failed: {fallback}"
275                ))
276            })
277            })?;
278        camera
279            .set_resolution(Resolution::new(config.width, config.height))
280            .map_err(|err| VideoError::Source(format!("failed to set camera resolution: {err}")))?;
281        camera
282            .set_frame_rate(config.fps)
283            .map_err(|err| VideoError::Source(format!("failed to set camera frame rate: {err}")))?;
284        camera
285            .open_stream()
286            .map_err(|err| VideoError::Source(format!("failed to open camera stream: {err}")))?;
287
288        Ok(Self {
289            camera,
290            next_index: 0,
291            started_at: Instant::now(),
292        })
293    }
294
295    pub fn next_rgb8_frame(&mut self) -> Result<Option<Rgb8Frame>, VideoError> {
296        let captured = self
297            .camera
298            .frame()
299            .map_err(|err| VideoError::Source(format!("failed to read camera frame: {err}")))?;
300        let resolution = captured.resolution();
301        let width = usize::try_from(resolution.width()).map_err(|err| {
302            VideoError::Source(format!("failed to convert frame width to usize: {err}"))
303        })?;
304        let mut height = usize::try_from(resolution.height()).map_err(|err| {
305            VideoError::Source(format!("failed to convert frame height to usize: {err}"))
306        })?;
307        let buf = captured.buffer_bytes();
308        // Some backends (notably macOS AVFoundation via nokhwa) report a
309        // resolution that does not match the actual buffer size.  When the
310        // width looks correct but height doesn't, derive the real height from
311        // the buffer length so we don't reject the frame.
312        let expected = width.saturating_mul(height).saturating_mul(3);
313        if buf.len() != expected && width > 0 {
314            let actual_pixels = buf.len() / 3;
315            if actual_pixels > 0 && actual_pixels % width == 0 {
316                height = actual_pixels / width;
317            }
318        }
319        let timestamp_us = micros_to_u64(self.started_at.elapsed().as_micros());
320        let frame = Rgb8Frame::from_bytes(self.next_index, timestamp_us, width, height, buf)?;
321        self.next_index += 1;
322        Ok(Some(frame))
323    }
324}
325
326#[cfg(feature = "native-camera")]
327fn preferred_camera_backend() -> ApiBackend {
328    if cfg!(target_os = "linux") {
329        ApiBackend::Video4Linux
330    } else if cfg!(target_os = "windows") {
331        ApiBackend::MediaFoundation
332    } else if cfg!(target_os = "macos") {
333        ApiBackend::AVFoundation
334    } else {
335        ApiBackend::Auto
336    }
337}
338
339#[cfg(feature = "native-camera")]
340fn open_camera_with_backend(device_index: u32, backend: ApiBackend) -> Result<Camera, VideoError> {
341    let requested =
342        RequestedFormat::new::<RgbFormat>(RequestedFormatType::AbsoluteHighestFrameRate);
343    Camera::with_backend(CameraIndex::Index(device_index), requested, backend).map_err(|err| {
344        VideoError::Source(format!(
345            "failed to create camera source with backend {backend:?}: {err}"
346        ))
347    })
348}
349
350#[cfg(not(feature = "native-camera"))]
351impl CameraFrameSource {
352    pub fn open(config: CameraConfig) -> Result<Self, VideoError> {
353        config.validate()?;
354        Err(VideoError::CameraBackendDisabled)
355    }
356
357    pub fn next_rgb8_frame(&mut self) -> Result<Option<Rgb8Frame>, VideoError> {
358        Err(VideoError::CameraBackendDisabled)
359    }
360}
361
362#[cfg(feature = "native-camera")]
363impl FrameSource for CameraFrameSource {
364    fn next_frame(&mut self) -> Result<Option<Frame>, VideoError> {
365        let Some(raw_frame) = self.next_rgb8_frame()? else {
366            return Ok(None);
367        };
368        let frame = rgb8_bytes_to_frame(
369            raw_frame.index(),
370            raw_frame.timestamp_us(),
371            raw_frame.width(),
372            raw_frame.height(),
373            raw_frame.data(),
374        )?;
375        Ok(Some(frame))
376    }
377}
378
379#[cfg(not(feature = "native-camera"))]
380impl FrameSource for CameraFrameSource {
381    fn next_frame(&mut self) -> Result<Option<Frame>, VideoError> {
382        Err(VideoError::CameraBackendDisabled)
383    }
384}