Skip to main content

media/
camera.rs

1//! Camera device enumeration (M-CAM.1 / AUT-255) — CLI-pipe pattern.
2//!
3//! Spawns `gst-device-monitor-1.0 Video/Source` and parses its
4//! human-readable text output into a [`Vec<CameraDevice>`]. Preserves
5//! the project's CLI-pipe-over-`gstreamer-rs` convention (CLAUDE.md:
6//! "Upgrading to `gstreamer-rs` Rust bindings is a later chunk").
7//!
8//! ```admonish note title="Option 1 from the ticket decision"
9//! The ticket spec offered three enumeration backends: gst CLI
10//! subprocess (chosen), platform-native (`AVCaptureDevice` /
11//! `IMFActivate` / `udev`), or the `gstreamer-rs` `DeviceMonitor`.
12//! Option 1 minimises new surface area — no new Rust deps, no
13//! per-OS code paths. The cost is parsing a loosely-specified text
14//! output, mitigated by the fixture-driven parser tests below.
15//! ```
16
17use std::process::{Command, Stdio};
18
19use serde::{Deserialize, Serialize};
20
21/// One attached camera device with a stable ID, a human-readable
22/// label, and a flag indicating whether the OS treats it as the
23/// default.
24///
25/// `id` is derived from the device name via FNV-1a hashing so the
26/// same camera produces the same ID across reboots even when the
27/// OS's underlying device-id string is non-stable (macOS
28/// AVFoundation has a history of doing this).
29#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
30pub struct CameraDevice {
31    /// Stable identifier — used by M-CAM.4's "last-used camera"
32    /// persistence and by M-CAM.2's `start_preview(camera_id)` IPC.
33    pub id: String,
34    /// Human-readable label (e.g. `"FaceTime HD Camera"`).
35    pub label: String,
36    /// `true` for the first device the OS lists. There's no canonical
37    /// "default camera" concept in the gst output, so we fall back to
38    /// "first in the list" — which generally matches the macOS /
39    /// Windows default-device selection.
40    pub is_default: bool,
41    /// gst-launch source-element tokens that pin capture to *this*
42    /// physical device — extracted from `gst-device-monitor-1.0`'s
43    /// per-device hint line (e.g. `"avfvideosrc device-index=0"`).
44    /// `None` when the parser couldn't find a hint line; callers
45    /// should fall back to `autovideosrc` in that case. Used by
46    /// [`super::gstreamer_video::GstreamerVideoCapture::from_camera`]
47    /// to actually route to the picked camera (M-CAM.4).
48    #[serde(default)]
49    pub gst_source: Option<String>,
50}
51
52/// Enumerate every camera the OS exposes via `gst-device-monitor-1.0`.
53///
54/// Returns an empty `Vec` (not an error) if the host has no cameras
55/// or the binary isn't on `PATH` — matches the M-CAM.0 probe
56/// convention. Integration tests should runtime-skip when the
57/// returned slice is empty.
58#[must_use]
59pub fn list_cameras() -> Vec<CameraDevice> {
60    let path_env = std::env::var("PATH").unwrap_or_else(|_| "<unset>".to_owned());
61    let output = Command::new("gst-device-monitor-1.0")
62        .args(["Video/Source"])
63        .stdout(Stdio::piped())
64        // Capture stderr so a permission-denied / no-camera /
65        // missing-binary failure isn't silent. Logged via `tracing`
66        // below if non-empty — the M-CAM.0/1 lift uncovered that
67        // GUI-launched binaries on macOS sometimes have a sanitised
68        // PATH and we couldn't tell from a silent empty Vec.
69        .stderr(Stdio::piped())
70        .output();
71    match output {
72        Ok(out) if out.status.success() => {
73            let text = String::from_utf8_lossy(&out.stdout);
74            let devices = parse_device_monitor_output(&text);
75            let stderr = String::from_utf8_lossy(&out.stderr);
76            if devices.is_empty() {
77                tracing::warn!(
78                    stdout_bytes = out.stdout.len(),
79                    stderr_bytes = out.stderr.len(),
80                    %path_env,
81                    "list_cameras: gst-device-monitor exited 0 but parser found 0 cameras"
82                );
83                if !text.is_empty() {
84                    tracing::warn!(stdout = %text, "raw gst-device-monitor stdout");
85                }
86                if !stderr.is_empty() {
87                    tracing::warn!(stderr = %stderr, "raw gst-device-monitor stderr");
88                }
89            } else {
90                tracing::info!(
91                    count = devices.len(),
92                    labels = ?devices.iter().map(|d| &d.label).collect::<Vec<_>>(),
93                    "list_cameras: gst-device-monitor returned cameras"
94                );
95            }
96            devices
97        }
98        Ok(out) => {
99            // Non-zero exit — surface what gst said and which PATH
100            // we used so the failure mode is debuggable.
101            tracing::warn!(
102                status = ?out.status,
103                stderr = %String::from_utf8_lossy(&out.stderr),
104                %path_env,
105                "list_cameras: gst-device-monitor exited non-zero"
106            );
107            Vec::new()
108        }
109        Err(err) => {
110            // Spawn itself failed — almost always "binary not on
111            // PATH". Log PATH so the user can see what was searched.
112            tracing::warn!(
113                ?err,
114                %path_env,
115                "list_cameras: failed to spawn gst-device-monitor-1.0 \
116                 (probably missing from PATH for the launched binary)"
117            );
118            Vec::new()
119        }
120    }
121}
122
123/// Pure-Rust parser for `gst-device-monitor-1.0 Video/Source` text
124/// output. Split out from [`list_cameras`] so the parser is testable
125/// against captured fixtures without needing gst installed.
126///
127/// Captures (per device block): the `name : ...` line as `label`, and
128/// the `gst-launch-1.0 <src-element> [<props>] ! ...` example line as
129/// [`CameraDevice::gst_source`] (verbatim source-element tokens).
130#[must_use]
131pub fn parse_device_monitor_output(text: &str) -> Vec<CameraDevice> {
132    let mut devices = Vec::new();
133    let mut current_name: Option<String> = None;
134    let mut current_source: Option<String> = None;
135    for raw_line in text.lines() {
136        let line = raw_line.trim();
137        if line.starts_with("Device found:") {
138            // New device block — emit the previous one if pending.
139            if let Some(label) = current_name.take() {
140                devices.push(make_device(
141                    label,
142                    current_source.take(),
143                    devices.is_empty(),
144                ));
145            }
146            continue;
147        }
148        // The `name :` line carries the human-readable label. gst
149        // formats this with variable whitespace + colon padding.
150        if let Some(rest) = line.strip_prefix("name") {
151            let value = rest.trim_start_matches([' ', '\t', ':']);
152            if !value.is_empty() && current_name.is_none() {
153                current_name = Some(value.to_string());
154            }
155            continue;
156        }
157        // Per-device hint line: `gst-launch-1.0 <src> [<props>] ! ...`.
158        // Extract everything between `gst-launch-1.0 ` and ` ! `; if
159        // there's no ` ! ` (one-element pipelines hint), take the rest
160        // of the line. M-CAM.4 uses this verbatim as the routing
161        // source so `device-index=N` actually pins capture.
162        if let Some(rest) = line.strip_prefix("gst-launch-1.0 ")
163            && current_source.is_none()
164        {
165            let source = rest.split(" ! ").next().unwrap_or(rest).trim();
166            if !source.is_empty() {
167                current_source = Some(source.to_string());
168            }
169        }
170    }
171    if let Some(label) = current_name.take() {
172        devices.push(make_device(
173            label,
174            current_source.take(),
175            devices.is_empty(),
176        ));
177    }
178    devices
179}
180
181fn make_device(label: String, gst_source: Option<String>, is_first: bool) -> CameraDevice {
182    let id = stable_id_for(&label);
183    CameraDevice {
184        id,
185        label,
186        is_default: is_first,
187        gst_source,
188    }
189}
190
191/// Locate the [`CameraDevice`] whose stable id matches `id` by
192/// re-probing the OS via [`list_cameras`]. Used by
193/// [`super::gstreamer_video::GstreamerVideoCapture::from_camera`] to
194/// resolve the picker's camera id back to its OS-native source
195/// element on every recording start (M-CAM.4). Returns `None` when
196/// the camera has been unplugged since enumeration.
197#[must_use]
198pub fn find_by_id(id: &str) -> Option<CameraDevice> {
199    list_cameras().into_iter().find(|d| d.id == id)
200}
201
202/// Derive a stable ID for a camera from its human-readable label
203/// using FNV-1a. Deterministic, dependency-free.
204#[must_use]
205pub fn stable_id_for(label: &str) -> String {
206    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
207    for byte in label.bytes() {
208        hash ^= u64::from(byte);
209        hash = hash.wrapping_mul(0x100_0000_01b3);
210    }
211    format!("cam-{hash:016x}")
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    /// Real-world `gst-device-monitor-1.0 Video/Source` output
219    /// captured from a macOS dev box with one built-in camera.
220    const MACOS_SINGLE_CAM: &str = "Probing devices...
221
222Device found:
223
224\tname  : FaceTime HD Camera
225\tclass : Video/Source
226\tcaps  : video/x-raw, format=(string)NV12, width=(int)1280, height=(int)720, framerate=(fraction)30/1
227\tproperties:
228\t\tdevice.api = avfvideosrc
229\tgst-launch-1.0 avfvideosrc device-index=0 ! ...
230";
231
232    /// Synthetic output for the two-camera case used to verify the
233    /// `is_default` flag goes to the first listed device only.
234    const TWO_CAMS: &str = "Device found:
235
236\tname  : FaceTime HD Camera
237\tclass : Video/Source
238
239Device found:
240
241\tname  : External USB Cam
242\tclass : Video/Source
243";
244
245    #[test]
246    fn parser_extracts_single_camera() {
247        let cams = parse_device_monitor_output(MACOS_SINGLE_CAM);
248        assert_eq!(cams.len(), 1);
249        assert_eq!(cams[0].label, "FaceTime HD Camera");
250        assert!(cams[0].is_default);
251    }
252
253    #[test]
254    fn parser_extracts_multiple_cameras_with_default_first() {
255        let cams = parse_device_monitor_output(TWO_CAMS);
256        assert_eq!(cams.len(), 2);
257        assert_eq!(cams[0].label, "FaceTime HD Camera");
258        assert!(cams[0].is_default);
259        assert_eq!(cams[1].label, "External USB Cam");
260        assert!(!cams[1].is_default);
261    }
262
263    #[test]
264    fn parser_extracts_macos_gst_source_with_device_index() {
265        // M-CAM.4 — the `gst-launch-1.0 avfvideosrc device-index=0 ! ...`
266        // hint line is what routes capture to *this* camera.
267        let cams = parse_device_monitor_output(MACOS_SINGLE_CAM);
268        assert_eq!(
269            cams[0].gst_source.as_deref(),
270            Some("avfvideosrc device-index=0")
271        );
272    }
273
274    #[test]
275    fn parser_extracts_per_device_gst_source_for_each_block() {
276        // Synthetic two-cam output where each device has a distinct
277        // gst-launch hint. Confirms the per-device state machine
278        // emits a fresh `gst_source` per `Device found:` block (vs.
279        // accidentally reusing the first device's source for both).
280        let two_cams_with_hints = "Device found:
281
282\tname  : FaceTime HD Camera
283\tclass : Video/Source
284\tgst-launch-1.0 avfvideosrc device-index=0 ! ...
285
286Device found:
287
288\tname  : External USB Cam
289\tclass : Video/Source
290\tgst-launch-1.0 avfvideosrc device-index=1 ! ...
291";
292        let cams = parse_device_monitor_output(two_cams_with_hints);
293        assert_eq!(cams.len(), 2);
294        assert_eq!(
295            cams[0].gst_source.as_deref(),
296            Some("avfvideosrc device-index=0")
297        );
298        assert_eq!(
299            cams[1].gst_source.as_deref(),
300            Some("avfvideosrc device-index=1")
301        );
302    }
303
304    #[test]
305    fn parser_emits_none_gst_source_when_hint_absent() {
306        // The legacy TWO_CAMS fixture has no `gst-launch-1.0` line —
307        // the parser should report `None` so `from_camera` falls back
308        // to `autovideosrc` rather than building a malformed pipeline.
309        let cams = parse_device_monitor_output(TWO_CAMS);
310        assert!(cams.iter().all(|d| d.gst_source.is_none()));
311    }
312
313    #[test]
314    fn parser_returns_empty_for_empty_input() {
315        assert!(parse_device_monitor_output("").is_empty());
316        assert!(parse_device_monitor_output("Probing devices...").is_empty());
317    }
318
319    #[test]
320    fn stable_id_is_deterministic_per_label() {
321        assert_eq!(
322            stable_id_for("FaceTime HD Camera"),
323            stable_id_for("FaceTime HD Camera")
324        );
325        // Different labels → different IDs.
326        assert_ne!(
327            stable_id_for("FaceTime HD Camera"),
328            stable_id_for("External USB Cam")
329        );
330    }
331
332    #[test]
333    fn stable_id_prefix_is_cam() {
334        assert!(stable_id_for("any").starts_with("cam-"));
335    }
336
337    #[test]
338    fn camera_device_serde_round_trip() {
339        let cam = CameraDevice {
340            id: "cam-feedface".into(),
341            label: "Test Cam".into(),
342            is_default: true,
343            gst_source: Some("avfvideosrc device-index=2".into()),
344        };
345        let json = serde_json::to_string(&cam).unwrap();
346        let parsed: CameraDevice = serde_json::from_str(&json).unwrap();
347        assert_eq!(parsed, cam);
348    }
349
350    #[test]
351    fn camera_device_serde_back_compat_when_gst_source_absent() {
352        // Pre-M-CAM.4 persisted records (or external JSON fixtures)
353        // won't carry `gst_source`. The `#[serde(default)]` on the
354        // field must let those deserialize cleanly — otherwise we'd
355        // silently break any external consumer.
356        let legacy_json = r#"{"id":"cam-feedface","label":"Test Cam","is_default":true}"#;
357        let parsed: CameraDevice = serde_json::from_str(legacy_json).unwrap();
358        assert_eq!(parsed.id, "cam-feedface");
359        assert!(parsed.gst_source.is_none());
360    }
361}