styx_libcamera/
lib.rs

1#![doc = include_str!("../README.md")]
2use styx_capture::prelude::*;
3
4#[cfg(feature = "probe")]
5use libcamera::{
6    camera::Camera,
7    camera_manager::CameraManager,
8    color_space::{ColorSpace as LcColorSpace, Primaries as LcPrimaries, Range as LcRange},
9    control,
10    control_value::{ControlType, ControlValue as LcValue},
11    controls::ControlId,
12    properties::PropertyId,
13    stream::StreamRole,
14};
15#[cfg(feature = "probe")]
16use smallvec::smallvec;
17#[cfg(feature = "probe")]
18use std::cell::UnsafeCell;
19#[cfg(feature = "probe")]
20use std::sync::{Mutex, OnceLock};
21#[cfg(feature = "probe")]
22use std::time::{Duration, Instant};
23#[cfg(feature = "probe")]
24use styx_core::controls::{Access, ControlKind, ControlValue};
25
26/// Libcamera device information with a descriptor built from advertised formats.
27#[derive(Clone)]
28pub struct LibcameraDeviceInfo {
29    pub id: String,
30    pub properties: Vec<(String, String)>,
31    pub descriptor: CaptureDescriptor,
32}
33
34/// Probe available libcamera devices and return descriptors.
35#[cfg(feature = "probe")]
36pub fn probe_devices() -> Vec<LibcameraDeviceInfo> {
37    if let Some(cached) = read_probe_cache() {
38        return cached;
39    }
40
41    let mut devices = Vec::new();
42
43    let manager = match manager() {
44        Ok(mgr) => mgr,
45        Err(err) => {
46            if debug_enabled() {
47                eprintln!("libcamera manager init failed: {err}");
48            }
49            write_probe_cache(&devices);
50            return devices;
51        }
52    };
53
54    {
55        let cameras = manager.cameras();
56        if debug_enabled() {
57            let ids: Vec<String> = cameras.iter().map(|c| c.id().to_string()).collect();
58            eprintln!("libcamera probe: discovered {} camera(s): {:?}", ids.len(), ids);
59        }
60
61        for camera in cameras.iter() {
62            match build_info(&camera) {
63                Ok(info) => devices.push(info),
64                Err(err) => {
65                    if debug_enabled() {
66                        eprintln!(
67                            "libcamera probe: failed to build descriptor for {}: {err}",
68                            camera.id()
69                        );
70                    }
71                }
72            }
73        }
74    }
75    if stop_when_idle_enabled() {
76        let _ = try_stop_if_idle();
77    }
78    write_probe_cache(&devices);
79    devices
80}
81
82#[cfg(feature = "probe")]
83static MANAGER: OnceLock<SharedManager> = OnceLock::new();
84#[cfg(feature = "probe")]
85static INIT_GUARD: Mutex<()> = Mutex::new(());
86
87#[cfg(feature = "probe")]
88static PROBE_CACHE: OnceLock<Mutex<ProbeCache>> = OnceLock::new();
89
90#[cfg(feature = "probe")]
91#[derive(Default)]
92struct ProbeCache {
93    last_probe_at: Option<Instant>,
94    cached_devices: Vec<LibcameraDeviceInfo>,
95}
96
97#[cfg(feature = "probe")]
98fn probe_cache_ttl() -> Duration {
99    const DEFAULT_MS: u64 = 1_000;
100    let ms = std::env::var("STYX_LIBCAMERA_PROBE_CACHE_MS")
101        .ok()
102        .and_then(|v| v.parse::<u64>().ok())
103        .unwrap_or(DEFAULT_MS);
104    Duration::from_millis(ms.max(0))
105}
106
107#[cfg(feature = "probe")]
108fn debug_enabled() -> bool {
109    std::env::var_os("STYX_LIBCAMERA_DEBUG").is_some()
110}
111
112#[cfg(feature = "probe")]
113fn stop_when_idle_enabled() -> bool {
114    matches!(
115        std::env::var("STYX_LIBCAMERA_STOP_WHEN_IDLE")
116            .ok()
117            .as_deref()
118            .map(str::to_ascii_lowercase)
119            .as_deref(),
120        Some("1") | Some("true") | Some("yes") | Some("on")
121    )
122}
123
124#[cfg(feature = "probe")]
125fn read_probe_cache() -> Option<Vec<LibcameraDeviceInfo>> {
126    let cache = PROBE_CACHE.get_or_init(|| Mutex::new(ProbeCache::default()));
127    let ttl = probe_cache_ttl();
128    let guard = cache.lock().ok()?;
129    let Some(last) = guard.last_probe_at else {
130        return None;
131    };
132    if last.elapsed() <= ttl {
133        return Some(guard.cached_devices.clone());
134    }
135    None
136}
137
138#[cfg(feature = "probe")]
139fn write_probe_cache(devices: &[LibcameraDeviceInfo]) {
140    let cache = PROBE_CACHE.get_or_init(|| Mutex::new(ProbeCache::default()));
141    if let Ok(mut guard) = cache.lock() {
142        guard.last_probe_at = Some(Instant::now());
143        guard.cached_devices = devices.to_vec();
144    }
145}
146
147#[cfg(feature = "probe")]
148struct SharedManager {
149    manager: UnsafeCell<CameraManager>,
150    lock: Mutex<()>,
151}
152
153#[cfg(feature = "probe")]
154unsafe impl Send for SharedManager {}
155#[cfg(feature = "probe")]
156unsafe impl Sync for SharedManager {}
157
158#[cfg(feature = "probe")]
159pub fn manager() -> Result<&'static CameraManager, String> {
160    if let Some(mgr) = MANAGER.get() {
161        return Ok(unsafe { &*mgr.manager.get() });
162    }
163
164    // Serialize creation to avoid multiple CameraManager instances.
165    let _guard = INIT_GUARD.lock().map_err(|e| e.to_string())?;
166    if let Some(mgr) = MANAGER.get() {
167        return Ok(unsafe { &*mgr.manager.get() });
168    }
169
170    let mgr = CameraManager::new().map_err(|e| e.to_string())?;
171    MANAGER
172        .set(SharedManager {
173            manager: UnsafeCell::new(mgr),
174            lock: Mutex::new(()),
175        })
176        .map_err(|_| "failed to set libcamera manager".to_string())?;
177    MANAGER
178        .get()
179        .map(|m| unsafe { &*m.manager.get() })
180        .ok_or_else(|| "failed to init libcamera manager".to_string())
181}
182
183/// Run a closure with exclusive mutable access to the shared `CameraManager`.
184///
185/// This is required to call lifecycle methods like `stop()`/`try_stop()` while still allowing
186/// other code to hold a `'static` reference for enumeration/capture.
187#[cfg(feature = "probe")]
188pub fn with_manager_mut<R>(f: impl FnOnce(&mut CameraManager) -> R) -> Result<R, String> {
189    let shared = MANAGER.get().or_else(|| {
190        let _ = manager();
191        MANAGER.get()
192    }).ok_or_else(|| "failed to init libcamera manager".to_string())?;
193    let _guard = shared.lock.lock().map_err(|e| e.to_string())?;
194    let mgr = unsafe { &mut *shared.manager.get() };
195    Ok(f(mgr))
196}
197
198/// Best-effort attempt to stop libcamera when no camera handles are alive.
199///
200/// This releases large PiSP/IPA allocations (seen as `/memfd:pisp_*`) so idle memory stays low.
201#[cfg(feature = "probe")]
202pub fn try_stop_if_idle() -> Result<(), String> {
203    // NOTE: On some PiSP/libcamera builds, calling `try_stop()` while any downstream resources are
204    // still unwinding (requests/framebuffers/backings) can crash libcamera with errors like:
205    //   "Removing media device /dev/media* while still in use"
206    // Prefer safety/stability; opt-in to stopping via env for memory-sensitive scenarios.
207    let enabled = std::env::var("STYX_LIBCAMERA_STOP_IF_IDLE")
208        .ok()
209        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
210        .unwrap_or(false);
211    if !enabled {
212        return Ok(());
213    }
214
215    with_manager_mut(|mgr| {
216        let _ = mgr.try_stop();
217    })
218}
219
220#[cfg(feature = "probe")]
221fn build_info(camera: &Camera) -> Result<LibcameraDeviceInfo, Box<dyn std::error::Error>> {
222    let mut modes = Vec::new();
223    let mut seen = std::collections::HashSet::<(FourCc, u32, u32)>::new();
224    let is_pisp = is_rpi_pisp_sensor_i2c(camera.id());
225
226    // Some pipelines (notably on Raspberry Pi / PiSP) advertise different pixel formats depending
227    // on the requested stream role. Probe multiple roles so we surface everything libcamera
228    // advertises instead of an implicit allow-list.
229    for role in [
230        StreamRole::ViewFinder,
231        StreamRole::VideoRecording,
232        StreamRole::StillCapture,
233        StreamRole::Raw,
234    ] {
235        if let Some(cfg) = camera.generate_configuration(&[role])
236            && let Some(view_cfg) = cfg.get(0)
237        {
238            let color = map_color_space(view_cfg.get_color_space());
239            let formats = view_cfg.formats();
240            for pf in formats.pixel_formats().into_iter() {
241                let fourcc = map_pixel_format_to_fourcc(pf);
242                if is_pisp && pisp_disallowed_fourcc(fourcc) {
243                    continue;
244                }
245                for size in formats.sizes(pf) {
246                    let Some(res) = Resolution::new(size.width, size.height) else {
247                        continue;
248                    };
249                    if !seen.insert((fourcc, size.width, size.height)) {
250                        continue;
251                    }
252                    let format = MediaFormat::new(fourcc, res, color);
253                    modes.push(Mode {
254                        id: ModeId {
255                            format: format.clone(),
256                            interval: None,
257                        },
258                        format,
259                        intervals: smallvec![],
260                        interval_stepwise: None,
261                    });
262                }
263            }
264        }
265    }
266    let controls = map_controls(camera.controls());
267    let mut properties = map_properties(camera.properties());
268    properties.push(("id".into(), camera.id().to_string()));
269    let descriptor = CaptureDescriptor { modes, controls };
270    Ok(LibcameraDeviceInfo {
271        id: camera.id().to_string(),
272        properties,
273        descriptor,
274    })
275}
276
277#[cfg(feature = "probe")]
278fn is_rpi_pisp_sensor_i2c(id: &str) -> bool {
279    id.starts_with("/base/") && id.contains("/i2c@")
280}
281
282#[cfg(feature = "probe")]
283fn pisp_disallowed_fourcc(code: FourCc) -> bool {
284    // PiSP asserts on several formats during configuration validation.
285    matches!(
286        &code.to_u32().to_le_bytes(),
287        b"YV12" | b"XB24" | b"XR24" | b"YU16" | b"YV16" | b"YU24" | b"YV24" | b"YVYU" | b"VYUY"
288    )
289}
290
291#[cfg(feature = "probe")]
292fn map_pixel_format_to_fourcc(pf: libcamera::pixel_format::PixelFormat) -> FourCc {
293    let base = FourCc::from(pf.fourcc());
294    match base.to_u32().to_le_bytes() {
295        // Normalize libcamera's RGB/BGR FourCCs into Styx's "friendly" aliases.
296        // This keeps the rest of the stack consistent (encoders/decoders default to `RG24`).
297        bytes if bytes == *b"RGB3" => return FourCc::new(*b"RG24"),
298        bytes if bytes == *b"BGR3" => return FourCc::new(*b"BG24"),
299        bytes if bytes == *b"RGB0" => return FourCc::new(*b"XR24"),
300        bytes if bytes == *b"BGR0" => return FourCc::new(*b"XB24"),
301        _ => {}
302    }
303    let Some(info) = pf.info() else {
304        return base;
305    };
306    if !info.packed || info.colour_encoding != libcamera::pixel_format::ColourEncoding::Raw {
307        return base;
308    }
309
310    const RG10: [u8; 4] = *b"RG10";
311    const BG10: [u8; 4] = *b"BG10";
312    const GB10: [u8; 4] = *b"GB10";
313    const BA10: [u8; 4] = *b"BA10";
314    const RG12: [u8; 4] = *b"RG12";
315    const BG12: [u8; 4] = *b"BG12";
316    const GB12: [u8; 4] = *b"GB12";
317    const BA12: [u8; 4] = *b"BA12";
318
319    match (base.to_u32().to_le_bytes(), info.bits_per_pixel) {
320        // RAW10 MIPI packed.
321        (RG10, 10) => FourCc::new(*b"pRAA"),
322        (BG10, 10) => FourCc::new(*b"pBAA"),
323        (GB10, 10) => FourCc::new(*b"pGAA"),
324        (BA10, 10) => FourCc::new(*b"pgAA"),
325
326        // RAW12 MIPI packed.
327        (RG12, 12) => FourCc::new(*b"pRCC"),
328        (BG12, 12) => FourCc::new(*b"pBCC"),
329        (GB12, 12) => FourCc::new(*b"pGCC"),
330        (BA12, 12) => FourCc::new(*b"pgCC"),
331
332        _ => base,
333    }
334}
335
336#[cfg(feature = "probe")]
337fn map_controls(map: &control::ControlInfoMap) -> Vec<ControlMeta> {
338    fn kind_from_type(control_type: ControlType) -> ControlKind {
339        match control_type {
340            ControlType::Bool => ControlKind::Bool,
341            ControlType::Byte | ControlType::Uint16 | ControlType::Uint32 => ControlKind::Uint,
342            ControlType::Int32 | ControlType::Int64 => ControlKind::Int,
343            ControlType::Float => ControlKind::Float,
344            ControlType::None
345            | ControlType::String
346            | ControlType::Rectangle
347            | ControlType::Size
348            | ControlType::Point => ControlKind::Unknown,
349        }
350    }
351
352    fn as_nonneg_i64(v: &ControlValue) -> Option<i64> {
353        match v {
354            ControlValue::Uint(n) => Some(*n as i64),
355            ControlValue::Int(n) if *n >= 0 => Some(*n as i64),
356            _ => None,
357        }
358    }
359
360    let mut out = Vec::new();
361    for (id, info) in map.into_iter() {
362        // Prefer dynamic lookup so we include draft/vendor controls (e.g. NoiseReductionMode)
363        // that aren't covered by the generated `TryFrom` tables.
364        let name = ControlId::from_id(id)
365            .map(|cid| cid.name().to_string())
366            .or_else(|| ControlId::try_from(id).ok().map(|cid| cid.name().to_string()))
367            .unwrap_or_else(|| format!("ctrl_{id}"));
368        let min = convert_value(&info.min());
369        let max = convert_value(&info.max());
370        let default = convert_value(&info.def());
371        let control_type = ControlType::from(&info.def());
372        let mut kind = kind_from_type(control_type);
373
374        // If libcamera provides a bounded list of accepted values, treat it as a menu.
375        // Note: We only surface a menu when the allowed values are a contiguous 0..N range.
376        // This preserves the existing "menu value == index" semantics used by the rest of Styx.
377        let mut menu: Option<Vec<String>> = None;
378        let values = info.values();
379        if !values.is_empty() {
380            let mut allowed = values
381                .iter()
382                .map(convert_value)
383                .filter_map(|v| as_nonneg_i64(&v))
384                .collect::<Vec<_>>();
385            allowed.sort_unstable();
386            allowed.dedup();
387            let contiguous = allowed.first().is_some_and(|first| *first == 0)
388                && allowed.iter().enumerate().all(|(idx, v)| *v == idx as i64);
389
390            if contiguous {
391                let enumerators = ControlId::from_id(id)
392                    .map(|cid| cid.enumerators_map())
393                    .unwrap_or_default();
394                menu = Some(
395                    allowed
396                        .iter()
397                        .map(|v| enumerators.get(&(*v as i32)).cloned().unwrap_or_default())
398                        .collect(),
399                );
400                kind = match kind {
401                    ControlKind::Int => ControlKind::IntMenu,
402                    ControlKind::Uint => ControlKind::Menu,
403                    other => other,
404                };
405            }
406        }
407
408        // libcamera-rs currently doesn't expose some draft controls via `ControlId::from_id`,
409        // so patch up well-known PiSP controls by numeric ID.
410        let (name, menu) = match (id, name.as_str(), menu.as_ref()) {
411            // libcamera::controls::draft::NoiseReductionMode
412            (10002, "ctrl_10002", Some(existing)) if existing.iter().all(|s| s.is_empty()) => (
413                "NoiseReductionMode".to_string(),
414                Some(vec![
415                    "NoiseReductionModeOff".into(),
416                    "NoiseReductionModeFast".into(),
417                    "NoiseReductionModeHighQuality".into(),
418                    "NoiseReductionModeMinimal".into(),
419                    "NoiseReductionModeZSL".into(),
420                ]),
421            ),
422            (10002, "ctrl_10002", None) => (
423                "NoiseReductionMode".to_string(),
424                Some(vec![
425                    "NoiseReductionModeOff".into(),
426                    "NoiseReductionModeFast".into(),
427                    "NoiseReductionModeHighQuality".into(),
428                    "NoiseReductionModeMinimal".into(),
429                    "NoiseReductionModeZSL".into(),
430                ]),
431            ),
432            _ => (name, menu),
433        };
434
435        // Skip unsupported libcamera control types entirely rather than exposing "Unknown".
436        if matches!(kind, ControlKind::Unknown) {
437            continue;
438        }
439        out.push(ControlMeta {
440            id: ControlId(id),
441            name,
442            kind,
443            access: Access::ReadWrite,
444            min,
445            max,
446            default,
447            step: None,
448            menu,
449        });
450    }
451    out
452}
453
454#[cfg(feature = "probe")]
455fn map_properties(props: &control::PropertyList) -> Vec<(String, String)> {
456    let mut out = Vec::new();
457    for (id, val) in props.into_iter() {
458        let name = PropertyId::try_from(id)
459            .map(|pid| pid.name().to_string())
460            .unwrap_or_else(|_| format!("prop_{id}"));
461        out.push((name, format_property_value(&val)));
462    }
463    out
464}
465
466#[cfg(feature = "probe")]
467fn format_property_value(val: &LcValue) -> String {
468    match val {
469        LcValue::None => String::new(),
470        LcValue::Bool(v) => v.first().map(|n| n.to_string()).unwrap_or_default(),
471        LcValue::Byte(v) => v.first().map(|n| n.to_string()).unwrap_or_default(),
472        LcValue::Uint16(v) => v.first().map(|n| n.to_string()).unwrap_or_default(),
473        LcValue::Uint32(v) => v.first().map(|n| n.to_string()).unwrap_or_default(),
474        LcValue::Int32(v) => v.first().map(|n| n.to_string()).unwrap_or_default(),
475        LcValue::Int64(v) => v.first().map(|n| n.to_string()).unwrap_or_default(),
476        LcValue::Float(v) => v.first().map(|n| n.to_string()).unwrap_or_default(),
477        LcValue::String(v) => v.first().cloned().unwrap_or_default(),
478        other => format!("{other:?}"),
479    }
480}
481
482#[cfg(feature = "probe")]
483fn map_color_space(cs: Option<LcColorSpace>) -> ColorSpace {
484    let Some(cs) = cs else {
485        return ColorSpace::Unknown;
486    };
487    let primaries = cs.primaries;
488    let transfer = cs.transfer_function;
489    let range = cs.range;
490    let full = matches!(range, LcRange::Full);
491    match (primaries, transfer) {
492        (LcPrimaries::Rec2020, _) => {
493            if full {
494                ColorSpace::Srgb
495            } else {
496                ColorSpace::Bt2020
497            }
498        }
499        (LcPrimaries::Rec709 | LcPrimaries::Smpte170m, _)
500        | (_, libcamera::color_space::TransferFunction::Srgb) => {
501            if full {
502                ColorSpace::Srgb
503            } else {
504                ColorSpace::Bt709
505            }
506        }
507        _ => {
508            if full {
509                ColorSpace::Srgb
510            } else {
511                ColorSpace::Unknown
512            }
513        }
514    }
515}
516
517#[cfg(feature = "probe")]
518fn convert_value(val: &LcValue) -> ControlValue {
519    match val {
520        LcValue::None => ControlValue::None,
521        LcValue::Bool(v) => v
522            .first()
523            .copied()
524            .map(ControlValue::Bool)
525            .unwrap_or(ControlValue::None),
526        LcValue::Byte(v) => v
527            .first()
528            .copied()
529            .map(|b| ControlValue::Uint(b as u32))
530            .unwrap_or(ControlValue::None),
531        LcValue::Uint16(v) => v
532            .first()
533            .copied()
534            .map(|b| ControlValue::Uint(b as u32))
535            .unwrap_or(ControlValue::None),
536        LcValue::Uint32(v) => v
537            .first()
538            .copied()
539            .map(ControlValue::Uint)
540            .unwrap_or(ControlValue::None),
541        LcValue::Int32(v) => v
542            .first()
543            .copied()
544            .map(ControlValue::Int)
545            .unwrap_or(ControlValue::None),
546        LcValue::Int64(v) => v
547            .first()
548            .copied()
549            .map(|i| ControlValue::Int(i.clamp(i32::MIN as i64, i32::MAX as i64) as i32))
550            .unwrap_or(ControlValue::None),
551        LcValue::Float(v) => v
552            .first()
553            .copied()
554            .map(ControlValue::Float)
555            .unwrap_or(ControlValue::None),
556        _ => ControlValue::None,
557    }
558}
559
560/// Placeholder libcamera capture source.
561pub struct LibcameraCapture {
562    descriptor: CaptureDescriptor,
563}
564
565impl LibcameraCapture {
566    /// Create a new libcamera capture source with the provided descriptor.
567    pub fn new(descriptor: CaptureDescriptor) -> Self {
568        Self { descriptor }
569    }
570}
571
572impl CaptureSource for LibcameraCapture {
573    fn descriptor(&self) -> &CaptureDescriptor {
574        &self.descriptor
575    }
576
577    fn next_frame(&self) -> Option<FrameLease> {
578        // Stub: real implementation would poll libcamera streams.
579        None
580    }
581}
582
583pub mod prelude {
584    #[cfg(feature = "probe")]
585    pub use crate::probe_devices;
586    pub use crate::{LibcameraCapture, LibcameraDeviceInfo};
587    pub use styx_capture::prelude::*;
588}