styx/
lib.rs

1#![doc = include_str!("../README.md")]
2
3#[cfg(any(feature = "v4l2", feature = "libcamera"))]
4use std::collections::HashSet;
5#[cfg(feature = "v4l2")]
6use std::panic::{AssertUnwindSafe, catch_unwind};
7#[cfg(feature = "file-backend")]
8use std::path::PathBuf;
9
10pub use styx_capture as capture;
11pub use styx_codec as codec;
12pub use styx_core as core;
13#[cfg(feature = "libcamera")]
14pub use styx_libcamera as libcamera;
15#[cfg(feature = "v4l2")]
16pub use styx_v4l2 as v4l2;
17#[cfg(feature = "preview-window")]
18pub mod preview;
19
20pub use thiserror;
21
22pub mod capture_api;
23mod metrics;
24pub mod session;
25
26/// Unified device descriptor for probed backends.
27///
28/// # Example
29/// ```rust,ignore
30/// use styx::prelude::*;
31///
32/// for dev in probe_all() {
33///     println!("{} backends: {}", dev.identity.display, dev.backends.len());
34/// }
35/// ```
36#[derive(Debug, Clone)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
39pub struct ProbedDevice {
40    pub identity: DeviceIdentity,
41    pub backends: Vec<ProbedBackend>,
42}
43
44/// Backend-specific entry for a probed device.
45///
46/// # Example
47/// ```rust,ignore
48/// use styx::prelude::*;
49///
50/// let dev = probe_all().into_iter().next().expect("device");
51/// for backend in dev.backends {
52///     println!("{:?}: {} modes", backend.kind, backend.descriptor.modes.len());
53/// }
54/// ```
55#[derive(Debug, Clone)]
56#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
57#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
58pub struct ProbedBackend {
59    pub kind: BackendKind,
60    pub handle: BackendHandle,
61    pub descriptor: styx_capture::CaptureDescriptor,
62    pub properties: Vec<(String, String)>,
63}
64
65/// Known backend kinds.
66///
67/// The `Virtual`/`Netcam`/`File` kinds map to synthetic backends created via
68/// helpers in `styx::capture_api`.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
71#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
72pub enum BackendKind {
73    V4l2,
74    Libcamera,
75    Virtual,
76    Netcam,
77    File,
78}
79
80/// Backend-specific handle used for configuration/streaming.
81///
82/// # Example
83/// ```rust,ignore
84/// use styx::prelude::*;
85///
86/// let dev = probe_all().into_iter().next().expect("device");
87/// let handle = &dev.backends[0].handle;
88/// println!("backend kind: {:?}", handle.kind());
89/// ```
90#[derive(Debug, Clone)]
91#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
92pub enum BackendHandle {
93    #[cfg(feature = "v4l2")]
94    V4l2 {
95        path: String,
96    },
97    #[cfg(feature = "libcamera")]
98    Libcamera {
99        id: String,
100    },
101    Virtual,
102    #[cfg(feature = "netcam")]
103    Netcam {
104        url: String,
105        width: u32,
106        height: u32,
107        fps: u32,
108    },
109    #[cfg(feature = "file-backend")]
110    File {
111        #[cfg_attr(feature = "schema", schema(value_type = Vec<String>))]
112        paths: Vec<PathBuf>,
113        fps: u32,
114        loop_forever: bool,
115    },
116}
117
118impl BackendHandle {
119    /// Return the backend kind for this handle.
120    pub fn kind(&self) -> BackendKind {
121        match self {
122            #[cfg(feature = "v4l2")]
123            BackendHandle::V4l2 { .. } => BackendKind::V4l2,
124            #[cfg(feature = "libcamera")]
125            BackendHandle::Libcamera { .. } => BackendKind::Libcamera,
126            BackendHandle::Virtual => BackendKind::Virtual,
127            #[cfg(feature = "netcam")]
128            BackendHandle::Netcam { .. } => BackendKind::Netcam,
129            #[cfg(feature = "file-backend")]
130            BackendHandle::File { .. } => BackendKind::File,
131        }
132    }
133}
134
135#[cfg(feature = "serde")]
136impl serde::Serialize for BackendHandle {
137    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
138    where
139        S: serde::Serializer,
140    {
141        if serializer.is_human_readable() {
142            #[derive(serde::Serialize)]
143            #[serde(tag = "type", rename_all = "snake_case")]
144            enum HumanHandle<'a> {
145                #[cfg(feature = "v4l2")]
146                V4l2 {
147                    path: &'a str,
148                },
149                #[cfg(feature = "libcamera")]
150                Libcamera {
151                    id: &'a str,
152                },
153                Virtual,
154                #[cfg(feature = "netcam")]
155                Netcam {
156                    url: &'a str,
157                    width: u32,
158                    height: u32,
159                    fps: u32,
160                },
161                #[cfg(feature = "file-backend")]
162                File {
163                    paths: Vec<String>,
164                    fps: u32,
165                    loop_forever: bool,
166                },
167            }
168
169            let human = match self {
170                #[cfg(feature = "v4l2")]
171                BackendHandle::V4l2 { path } => HumanHandle::V4l2 { path },
172                #[cfg(feature = "libcamera")]
173                BackendHandle::Libcamera { id } => HumanHandle::Libcamera { id },
174                BackendHandle::Virtual => HumanHandle::Virtual,
175                #[cfg(feature = "netcam")]
176                BackendHandle::Netcam {
177                    url,
178                    width,
179                    height,
180                    fps,
181                } => HumanHandle::Netcam {
182                    url,
183                    width: *width,
184                    height: *height,
185                    fps: *fps,
186                },
187                #[cfg(feature = "file-backend")]
188                BackendHandle::File {
189                    paths,
190                    fps,
191                    loop_forever,
192                } => HumanHandle::File {
193                    paths: paths
194                        .iter()
195                        .map(|p| p.to_string_lossy().to_string())
196                        .collect(),
197                    fps: *fps,
198                    loop_forever: *loop_forever,
199                },
200            };
201            human.serialize(serializer)
202        } else {
203            #[derive(serde::Serialize)]
204            enum BinaryHandle<'a> {
205                #[cfg(feature = "v4l2")]
206                V4l2(&'a str),
207                #[cfg(feature = "libcamera")]
208                Libcamera(&'a str),
209                Virtual,
210                #[cfg(feature = "netcam")]
211                Netcam {
212                    url: &'a str,
213                    width: u32,
214                    height: u32,
215                    fps: u32,
216                },
217                #[cfg(feature = "file-backend")]
218                File {
219                    paths: Vec<String>,
220                    fps: u32,
221                    loop_forever: bool,
222                },
223            }
224            let bin = match self {
225                #[cfg(feature = "v4l2")]
226                BackendHandle::V4l2 { path } => BinaryHandle::V4l2(path),
227                #[cfg(feature = "libcamera")]
228                BackendHandle::Libcamera { id } => BinaryHandle::Libcamera(id),
229                BackendHandle::Virtual => BinaryHandle::Virtual,
230                #[cfg(feature = "netcam")]
231                BackendHandle::Netcam {
232                    url,
233                    width,
234                    height,
235                    fps,
236                } => BinaryHandle::Netcam {
237                    url,
238                    width: *width,
239                    height: *height,
240                    fps: *fps,
241                },
242                #[cfg(feature = "file-backend")]
243                BackendHandle::File {
244                    paths,
245                    fps,
246                    loop_forever,
247                } => BinaryHandle::File {
248                    paths: paths
249                        .iter()
250                        .map(|p| p.to_string_lossy().to_string())
251                        .collect(),
252                    fps: *fps,
253                    loop_forever: *loop_forever,
254                },
255            };
256            bin.serialize(serializer)
257        }
258    }
259}
260
261#[cfg(feature = "serde")]
262impl<'de> serde::Deserialize<'de> for BackendHandle {
263    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
264    where
265        D: serde::Deserializer<'de>,
266    {
267        if deserializer.is_human_readable() {
268            #[derive(serde::Deserialize)]
269            #[serde(tag = "type", rename_all = "snake_case")]
270            enum HumanHandle {
271                #[cfg(feature = "v4l2")]
272                V4l2 {
273                    path: String,
274                },
275                #[cfg(feature = "libcamera")]
276                Libcamera {
277                    id: String,
278                },
279                Virtual,
280                #[cfg(feature = "netcam")]
281                Netcam {
282                    url: String,
283                    width: u32,
284                    height: u32,
285                    fps: u32,
286                },
287                #[cfg(feature = "file-backend")]
288                File {
289                    paths: Vec<String>,
290                    fps: u32,
291                    loop_forever: bool,
292                },
293            }
294            let human = HumanHandle::deserialize(deserializer)?;
295            let handle = match human {
296                #[cfg(feature = "v4l2")]
297                HumanHandle::V4l2 { path } => BackendHandle::V4l2 { path },
298                #[cfg(feature = "libcamera")]
299                HumanHandle::Libcamera { id } => BackendHandle::Libcamera { id },
300                HumanHandle::Virtual => BackendHandle::Virtual,
301                #[cfg(feature = "netcam")]
302                HumanHandle::Netcam {
303                    url,
304                    width,
305                    height,
306                    fps,
307                } => BackendHandle::Netcam {
308                    url,
309                    width,
310                    height,
311                    fps,
312                },
313                #[cfg(feature = "file-backend")]
314                HumanHandle::File {
315                    paths,
316                    fps,
317                    loop_forever,
318                } => BackendHandle::File {
319                    paths: paths.into_iter().map(PathBuf::from).collect(),
320                    fps,
321                    loop_forever,
322                },
323            };
324            Ok(handle)
325        } else {
326            #[derive(serde::Deserialize)]
327            enum BinaryHandle {
328                #[cfg(feature = "v4l2")]
329                V4l2(String),
330                #[cfg(feature = "libcamera")]
331                Libcamera(String),
332                Virtual,
333                #[cfg(feature = "netcam")]
334                Netcam {
335                    url: String,
336                    width: u32,
337                    height: u32,
338                    fps: u32,
339                },
340                #[cfg(feature = "file-backend")]
341                File {
342                    paths: Vec<String>,
343                    fps: u32,
344                    loop_forever: bool,
345                },
346            }
347            let bin = BinaryHandle::deserialize(deserializer)?;
348            let handle = match bin {
349                #[cfg(feature = "v4l2")]
350                BinaryHandle::V4l2(path) => BackendHandle::V4l2 { path },
351                #[cfg(feature = "libcamera")]
352                BinaryHandle::Libcamera(id) => BackendHandle::Libcamera { id },
353                BinaryHandle::Virtual => BackendHandle::Virtual,
354                #[cfg(feature = "netcam")]
355                BinaryHandle::Netcam {
356                    url,
357                    width,
358                    height,
359                    fps,
360                } => BackendHandle::Netcam {
361                    url,
362                    width,
363                    height,
364                    fps,
365                },
366                #[cfg(feature = "file-backend")]
367                BinaryHandle::File {
368                    paths,
369                    fps,
370                    loop_forever,
371                } => BackendHandle::File {
372                    paths: paths.into_iter().map(PathBuf::from).collect(),
373                    fps,
374                    loop_forever,
375                },
376            };
377            Ok(handle)
378        }
379    }
380}
381
382/// Physical device identity derived from fingerprints/props.
383///
384/// `display` is a human-friendly string, while `keys` contains fingerprints
385/// that help merge identical devices across backends.
386#[derive(Debug, Clone)]
387#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
388#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
389pub struct DeviceIdentity {
390    /// Display-friendly identifier.
391    pub display: String,
392    /// Fingerprint keys used for matching.
393    pub keys: Vec<String>,
394}
395
396/// Probe result that includes any backend errors encountered.
397///
398/// # Example
399/// ```rust,ignore
400/// use styx::prelude::*;
401///
402/// let res = probe_all_with_errors();
403/// for err in &res.errors {
404///     eprintln!("probe error: {err}");
405/// }
406/// ```
407#[derive(Debug, Clone)]
408#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
409pub struct ProbeResult {
410    pub devices: Vec<ProbedDevice>,
411    pub errors: Vec<String>,
412}
413
414/// Probe all enabled backends and return a merged list.
415///
416/// # Example
417/// ```rust,ignore
418/// use styx::prelude::*;
419///
420/// let devices = probe_all();
421/// if devices.is_empty() {
422///     eprintln!("no devices found");
423/// }
424/// ```
425pub fn probe_all() -> Vec<ProbedDevice> {
426    probe_all_with_errors().devices
427}
428
429/// Probe all enabled backends and include any probe errors.
430///
431/// Prefer this when you want observability into backend failures.
432pub fn probe_all_with_errors() -> ProbeResult {
433    #[allow(unused_mut)]
434    let mut devices: Vec<ProbedDevice> = Vec::new();
435    #[allow(unused_mut)]
436    let mut errors: Vec<String> = Vec::new();
437
438    #[cfg(feature = "v4l2")]
439    {
440        let (v4l2_devices, v4l2_errors) =
441            match catch_unwind(AssertUnwindSafe(|| styx_v4l2::probe_devices())) {
442                Ok(res) => res,
443                Err(_) => (Vec::new(), vec!["v4l2 probe panicked".to_string()]),
444            };
445        errors.extend(v4l2_errors);
446        for dev in v4l2_devices {
447            let backend = ProbedBackend {
448                kind: BackendKind::V4l2,
449                handle: BackendHandle::V4l2 {
450                    path: dev.path.clone(),
451                },
452                descriptor: dev.descriptor,
453                properties: dev.properties,
454            };
455            merge_backend(&mut devices, dev.path.clone(), backend);
456        }
457    }
458    #[cfg(feature = "libcamera")]
459    {
460        for dev in styx_libcamera::probe_devices() {
461            let backend = ProbedBackend {
462                kind: BackendKind::Libcamera,
463                handle: BackendHandle::Libcamera { id: dev.id.clone() },
464                descriptor: dev.descriptor,
465                properties: dev.properties,
466            };
467            merge_backend(&mut devices, dev.id.clone(), backend);
468        }
469    }
470    ProbeResult { devices, errors }
471}
472
473#[cfg(any(feature = "v4l2", feature = "libcamera"))]
474fn merge_backend(devices: &mut Vec<ProbedDevice>, id: String, backend: ProbedBackend) {
475    let new_keys: HashSet<String> = derive_keys(&id, &backend.properties).into_iter().collect();
476    let new_keys_vec: Vec<String> = new_keys.iter().cloned().collect();
477    if let Some(existing) = devices
478        .iter_mut()
479        .find(|d| d.identity.keys.iter().any(|k| new_keys.contains(k)))
480    {
481        existing.backends.push(backend);
482        for k in new_keys {
483            if existing.identity.keys.iter().any(|ek| ek == &k) {
484                continue;
485            }
486            existing.identity.keys.push(k);
487        }
488    } else {
489        devices.push(ProbedDevice {
490            identity: DeviceIdentity {
491                display: pick_id(&id, &backend.properties),
492                keys: new_keys_vec,
493            },
494            backends: vec![backend],
495        });
496    }
497}
498
499#[cfg(any(feature = "v4l2", feature = "libcamera"))]
500fn derive_keys(id: &str, props: &[(String, String)]) -> Vec<String> {
501    let mut keys = Vec::new();
502    if !id.starts_with("/dev/video") {
503        keys.push(id.to_string());
504    }
505    for (k, v) in props {
506        let v_trimmed = v.trim();
507        let v_lower = v_trimmed.to_ascii_lowercase();
508        if v_lower == "rp1-cfe" {
509            continue;
510        }
511        // Only include salient properties for matching.
512        if k.eq_ignore_ascii_case("bus")
513            || k.eq_ignore_ascii_case("card")
514            || k.eq_ignore_ascii_case("driver")
515            || k.eq_ignore_ascii_case("model")
516        {
517            keys.push(v_trimmed.to_string());
518        }
519        if let Some(vidpid) = extract_vid_pid(v_trimmed) {
520            keys.push(vidpid);
521        }
522    }
523    if let Some(vidpid) = extract_vid_pid(id) {
524        keys.push(vidpid);
525    }
526    keys
527}
528
529#[cfg(any(feature = "v4l2", feature = "libcamera"))]
530fn pick_id(id: &str, props: &[(String, String)]) -> String {
531    if let Some(model) = props
532        .iter()
533        .find(|(k, _)| k.eq_ignore_ascii_case("model"))
534        .map(|(_, v)| v.trim())
535        && !model.is_empty()
536        && !model.eq_ignore_ascii_case("rp1-cfe")
537    {
538        return model.to_string();
539    }
540    if let Some(vidpid) = props.iter().find_map(|(_, v)| extract_vid_pid(v)) {
541        return vidpid;
542    }
543    if let Some(bus) = props
544        .iter()
545        .find(|(k, _)| k.eq_ignore_ascii_case("bus"))
546        .map(|(_, v)| v.clone())
547    {
548        return bus;
549    }
550    if let Some(card) = props
551        .iter()
552        .find(|(k, _)| k.eq_ignore_ascii_case("card"))
553        .map(|(_, v)| v.clone())
554    {
555        return card;
556    }
557    id.to_string()
558}
559
560#[cfg(any(feature = "v4l2", feature = "libcamera"))]
561fn extract_vid_pid(s: &str) -> Option<String> {
562    let bytes = s.as_bytes();
563    for i in 0..bytes.len().saturating_sub(8) {
564        let slice = &bytes[i..i + 9];
565        if slice[4] != b':' {
566            continue;
567        }
568        if slice[..4].iter().all(|b| b.is_ascii_hexdigit())
569            && slice[5..].iter().all(|b| b.is_ascii_hexdigit())
570        {
571            return Some(String::from_utf8_lossy(slice).to_string());
572        }
573    }
574    None
575}
576
577pub mod prelude {
578    #[cfg(feature = "file-backend")]
579    pub use crate::capture_api::make_file_device;
580    #[cfg(feature = "netcam")]
581    pub use crate::capture_api::make_netcam_device;
582    pub use crate::capture_api::{
583        CaptureError, CaptureHandle, CaptureRequest, CaptureTunables, StyxConfig,
584        set_capture_tunables, start_capture,
585    };
586    pub use crate::metrics::{PipelineMetrics, StageMetrics};
587    #[cfg(feature = "preview-window")]
588    pub use crate::preview::PreviewWindow;
589    pub use crate::probe_all;
590    pub use crate::session::{MediaPipeline, MediaPipelineBuilder};
591    pub use crate::{BackendHandle, BackendKind, ProbedBackend, ProbedDevice};
592    pub use styx_capture::prelude::*;
593    pub use styx_codec::prelude::*;
594    #[allow(unused_imports)]
595    pub use styx_core::prelude::*;
596    #[cfg(feature = "libcamera")]
597    pub use styx_libcamera::prelude::{
598        LibcameraCapture, LibcameraDeviceInfo, probe_devices as probe_libcamera,
599    };
600    #[cfg(feature = "v4l2")]
601    pub use styx_v4l2::prelude::{V4l2DeviceInfo, probe_devices as probe_v4l2};
602}