cranpose_services/camera.rs
1//! Live camera capture: an in-app viewfinder frame source.
2//!
3//! Unlike the one-shot [`image_picker`](crate::image_picker) (which presents the
4//! system camera UI), this exposes a running capture session whose latest frame
5//! the app polls to render its own viewfinder and run per-frame detection —
6//! matching the Android/desktop live-preview experience.
7//!
8//! The platform backend installs an implementation via [`set_platform_camera`]
9//! (iOS `AVCaptureSession`, desktop `nokhwa`, …). No default: [`camera`] returns
10//! `None` where live capture is unsupported, so the app can fall back to the
11//! image picker.
12
13use crate::registry::ServiceRegistry;
14use std::sync::Arc;
15
16/// A single captured frame as tightly-packed RGBA8 (`width * height * 4` bytes,
17/// row-major, no padding).
18#[derive(Clone)]
19pub struct CameraFrame {
20 pub width: u32,
21 pub height: u32,
22 pub rgba: Vec<u8>,
23}
24
25/// A full-resolution still photograph as an encoded image.
26///
27/// Unlike [`CameraFrame`] (a viewfinder-resolution stream frame), a still is
28/// captured through the platform's dedicated photo pipeline at full sensor
29/// resolution — on iOS that is `AVCapturePhotoOutput`, roughly 12 MP versus the
30/// 720p-class viewfinder. The bytes are an encoded JPEG whose EXIF orientation
31/// tag reflects the device rotation; decode with an orientation-aware decoder.
32#[derive(Clone)]
33pub struct CameraStill {
34 pub jpeg: Vec<u8>,
35}
36
37/// One capture device the app may pick, as reported by [`Camera::lenses`].
38///
39/// `id` is the platform's own handle for the device (an `AVCaptureDevice`
40/// uniqueID on iOS, a camera2 id on Android); pass it back to
41/// [`Camera::use_lens`]. `name` is for a button label: "Ultra wide", "Wide",
42/// "Tele".
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct CameraLens {
45 pub id: String,
46 pub name: String,
47}
48
49/// What the light does when a still is captured.
50///
51/// `Auto` leaves the choice to the device's exposure metering. A backend with
52/// no flash reports `false` from [`Camera::set_flash`] and the app hides the
53/// control.
54#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
55pub enum FlashMode {
56 #[default]
57 Off,
58 Auto,
59 On,
60}
61
62#[derive(Debug, thiserror::Error)]
63pub enum CameraError {
64 /// No live camera backend on this platform.
65 #[error("live camera capture is not supported here")]
66 Unsupported,
67 /// The user denied camera access.
68 #[error("camera permission denied")]
69 PermissionDenied,
70 /// Any other failure (no device, configuration error, …).
71 #[error("{0}")]
72 Failed(String),
73}
74
75/// A running (or startable) live camera. Implementations are `Send + Sync` so a
76/// background preview pump can start/stop and poll frames off the UI thread.
77pub trait Camera: Send + Sync {
78 /// Start the capture session, returning a human-readable device name. Safe
79 /// to call again while already running (idempotent).
80 fn start(&self) -> Result<String, CameraError>;
81 /// The most recent frame, or `None` if none has arrived yet.
82 fn latest_frame(&self) -> Option<CameraFrame>;
83 /// Capture a full-resolution still through the platform photo pipeline.
84 ///
85 /// Blocks up to a few seconds while the device exposes and encodes.
86 /// Returns `None` where the backend has no dedicated photo path (callers
87 /// should fall back to [`latest_frame`](Self::latest_frame)) or when the
88 /// capture fails.
89 fn capture_still(&self) -> Option<CameraStill> {
90 None
91 }
92 /// Toggle the capture device's torch (flashlight) while the session runs.
93 /// Scanner-style apps light dim scenes instead of trying to analyze
94 /// photon-starved frames. Returns `false` where the device has no torch
95 /// or the backend has none wired; the torch dies with the session.
96 fn set_torch(&self, _on: bool) -> bool {
97 false
98 }
99 /// The capture devices the app may pick between, back cameras first and in
100 /// field-of-view order (widest first). An empty list means the app shows no
101 /// lens control: either the platform has one camera or the backend does not
102 /// list them.
103 fn lenses(&self) -> Vec<CameraLens> {
104 Vec::new()
105 }
106
107 /// The id of the device the session uses, or `None` when nothing is open
108 /// and the backend has no stored choice.
109 fn lens(&self) -> Option<String> {
110 None
111 }
112
113 /// Open `id` instead of the current device, keeping the session running.
114 /// Returns `false` when the id is unknown or the backend cannot switch.
115 fn use_lens(&self, _id: &str) -> bool {
116 false
117 }
118
119 /// Whether the current device has a flash for stills.
120 fn has_flash(&self) -> bool {
121 false
122 }
123
124 /// What the flash does on the next [`capture_still`](Self::capture_still).
125 /// Returns `false` where the device has no flash or the backend has none
126 /// wired; the mode dies with the session.
127 fn set_flash(&self, _mode: FlashMode) -> bool {
128 false
129 }
130
131 /// Stop the session and release the device.
132 fn stop(&self);
133}
134
135pub type CameraRef = Arc<dyn Camera>;
136
137static PLATFORM_CAMERA: ServiceRegistry<dyn Camera> = ServiceRegistry::new();
138
139/// Installs the platform live camera, replacing any previous one.
140pub fn set_platform_camera(camera: CameraRef) {
141 PLATFORM_CAMERA.set(camera);
142}
143
144/// Removes any registered platform camera (tests/teardown).
145pub fn clear_platform_camera() {
146 PLATFORM_CAMERA.clear();
147}
148
149/// The registered live camera, or `None` where live capture is unsupported.
150pub fn camera() -> Option<CameraRef> {
151 PLATFORM_CAMERA.get()
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn registration_round_trips() {
160 let _guard = crate::registry::test_service_guard();
161 clear_platform_camera();
162 assert!(camera().is_none());
163 struct Fake;
164 impl Camera for Fake {
165 fn start(&self) -> Result<String, CameraError> {
166 Ok("fake".into())
167 }
168 fn latest_frame(&self) -> Option<CameraFrame> {
169 Some(CameraFrame {
170 width: 1,
171 height: 1,
172 rgba: vec![0, 0, 0, 255],
173 })
174 }
175 fn stop(&self) {}
176 }
177 set_platform_camera(Arc::new(Fake));
178 let cam = camera().expect("registered");
179 assert_eq!(cam.start().unwrap(), "fake");
180 assert_eq!(cam.latest_frame().unwrap().width, 1);
181 clear_platform_camera();
182 }
183
184 #[test]
185 fn a_backend_that_lists_no_lens_and_no_flash_says_so() {
186 let _guard = crate::registry::test_service_guard();
187 clear_platform_camera();
188 struct Bare;
189 impl Camera for Bare {
190 fn start(&self) -> Result<String, CameraError> {
191 Ok("bare".into())
192 }
193 fn latest_frame(&self) -> Option<CameraFrame> {
194 None
195 }
196 fn stop(&self) {}
197 }
198 set_platform_camera(Arc::new(Bare));
199 let cam = camera().expect("registered");
200 assert!(cam.lenses().is_empty());
201 assert_eq!(cam.lens(), None);
202 assert!(!cam.use_lens("0"));
203 assert!(!cam.has_flash());
204 assert!(!cam.set_flash(FlashMode::On));
205 clear_platform_camera();
206 }
207
208 #[test]
209 fn a_backend_that_lists_two_lenses_hands_them_over_in_order() {
210 let _guard = crate::registry::test_service_guard();
211 clear_platform_camera();
212 struct Two;
213 impl Camera for Two {
214 fn start(&self) -> Result<String, CameraError> {
215 Ok("two".into())
216 }
217 fn latest_frame(&self) -> Option<CameraFrame> {
218 None
219 }
220 fn lenses(&self) -> Vec<CameraLens> {
221 vec![
222 CameraLens {
223 id: "u".into(),
224 name: "Ultra wide".into(),
225 },
226 CameraLens {
227 id: "w".into(),
228 name: "Wide".into(),
229 },
230 ]
231 }
232 fn lens(&self) -> Option<String> {
233 Some("w".into())
234 }
235 fn use_lens(&self, id: &str) -> bool {
236 id == "u" || id == "w"
237 }
238 fn stop(&self) {}
239 }
240 set_platform_camera(Arc::new(Two));
241 let cam = camera().expect("registered");
242 let lenses = cam.lenses();
243 assert_eq!(lenses.len(), 2);
244 assert_eq!(lenses[0].name, "Ultra wide");
245 assert_eq!(cam.lens().as_deref(), Some("w"));
246 assert!(cam.use_lens("u"));
247 assert!(!cam.use_lens("tele"));
248 clear_platform_camera();
249 }
250}