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}