Skip to content

Commit 79e2e01

Browse files
committed
feat: live camera preview on the diagnostics page
Streams webcam frames into a preview panel while the Diagnostics page is open, driven by a Start/Stop command channel so the device is only held open when needed. The preview requests a 640x480 mode (falling back to whatever the camera offers) since decoding a full-resolution frame every tick was taking ~450ms and made the feed crawl; the donation snapshot path is untouched and still captures at the camera's highest resolution.
1 parent 8b720de commit 79e2e01

4 files changed

Lines changed: 330 additions & 127 deletions

File tree

src/camera.rs

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
use log::{error, info};
22
use nokhwa::Camera;
33
use nokhwa::pixel_format::RgbFormat;
4-
use nokhwa::utils::{CameraIndex, RequestedFormat, RequestedFormatType};
4+
use nokhwa::utils::{
5+
CameraFormat, CameraIndex, FrameFormat, RequestedFormat, RequestedFormatType, Resolution,
6+
};
57
use std::path::PathBuf;
8+
use std::sync::mpsc::{Receiver, SyncSender, TryRecvError};
69
use std::thread;
7-
use std::time::{SystemTime, UNIX_EPOCH};
10+
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
811

912
/// Captures a single frame from the default webcam and saves it as a JPEG
1013
/// under `photos_dir`, running on a dedicated thread so it never blocks the UI.
@@ -19,7 +22,7 @@ pub fn capture_donation_photo(photos_dir: &str, username: &str) {
1922
});
2023
}
2124

22-
fn capture_and_save(photos_dir: &str, username: &str) -> Result<(), String> {
25+
fn open_camera() -> Result<Camera, String> {
2326
let index = CameraIndex::Index(0);
2427
let requested =
2528
RequestedFormat::new::<RgbFormat>(RequestedFormatType::AbsoluteHighestFrameRate);
@@ -31,6 +34,31 @@ fn capture_and_save(photos_dir: &str, username: &str) -> Result<(), String> {
3134
.open_stream()
3235
.map_err(|e| format!("failed to open webcam stream: {e}"))?;
3336

37+
Ok(camera)
38+
}
39+
40+
/// Opens the camera for the live diagnostics preview. Decoding a full-resolution
41+
/// frame (e.g. 1920x1080, which many webcams default to) takes ~450ms — far too
42+
/// slow for a live view — so this asks for a modest 640x480 mode first, which
43+
/// virtually every UVC/AVFoundation webcam supports and decodes in under 100ms.
44+
/// Falls back to whatever the camera actually offers if that request is rejected.
45+
fn open_preview_camera() -> Result<Camera, String> {
46+
let small = RequestedFormat::new::<RgbFormat>(RequestedFormatType::Exact(CameraFormat::new(
47+
Resolution::new(640, 480),
48+
FrameFormat::MJPEG,
49+
30,
50+
)));
51+
if let Ok(mut camera) = Camera::new(CameraIndex::Index(0), small)
52+
&& camera.open_stream().is_ok()
53+
{
54+
return Ok(camera);
55+
}
56+
open_camera()
57+
}
58+
59+
fn capture_and_save(photos_dir: &str, username: &str) -> Result<(), String> {
60+
let mut camera = open_camera()?;
61+
3462
// Discard the first couple of frames to let auto-exposure/white-balance settle.
3563
for _ in 0..2 {
3664
let _ = camera.frame();
@@ -69,3 +97,73 @@ fn capture_and_save(photos_dir: &str, username: &str) -> Result<(), String> {
6997
info!("📷 Saved donation photo to {path:?}");
7098
Ok(())
7199
}
100+
101+
/// Commands accepted by the [`spawn_preview`] thread.
102+
pub enum PreviewCommand {
103+
Start,
104+
Stop,
105+
}
106+
107+
/// A single decoded RGB8 frame, ready to hand to `slint::SharedPixelBuffer`.
108+
pub struct PreviewFrame {
109+
pub rgb: Vec<u8>,
110+
pub width: u32,
111+
pub height: u32,
112+
}
113+
114+
const PREVIEW_FRAME_INTERVAL: Duration = Duration::from_millis(100);
115+
116+
/// Spawns a thread that owns the webcam for as long as the diagnostics page's
117+
/// live preview is active. `Start`/`Stop` on `cmd_rx` open/close the device;
118+
/// while open, frames are pushed to `frame_tx` on a best-effort basis (a full
119+
/// channel just means the consumer hasn't caught up, so the frame is dropped).
120+
pub fn spawn_preview(cmd_rx: Receiver<PreviewCommand>, frame_tx: SyncSender<PreviewFrame>) {
121+
thread::spawn(move || {
122+
let mut camera: Option<Camera> = None;
123+
124+
loop {
125+
let cmd = if camera.is_some() {
126+
match cmd_rx.try_recv() {
127+
Ok(cmd) => Some(cmd),
128+
Err(TryRecvError::Empty) => None,
129+
Err(TryRecvError::Disconnected) => break,
130+
}
131+
} else {
132+
match cmd_rx.recv() {
133+
Ok(cmd) => Some(cmd),
134+
Err(_) => break,
135+
}
136+
};
137+
138+
match cmd {
139+
Some(PreviewCommand::Start) if camera.is_none() => match open_preview_camera() {
140+
Ok(cam) => camera = Some(cam),
141+
Err(e) => error!("📷 Failed to start preview: {}", e),
142+
},
143+
Some(PreviewCommand::Stop) => camera = None,
144+
_ => {}
145+
}
146+
147+
let Some(cam) = camera.as_mut() else {
148+
continue;
149+
};
150+
151+
let frame_start = Instant::now();
152+
match cam.frame().and_then(|f| f.decode_image::<RgbFormat>()) {
153+
Ok(image) => {
154+
let _ = frame_tx.try_send(PreviewFrame {
155+
width: image.width(),
156+
height: image.height(),
157+
rgb: image.into_raw(),
158+
});
159+
}
160+
Err(e) => error!("📷 Preview frame capture failed: {}", e),
161+
}
162+
// Capture+decode already ate into the budget; only sleep the remainder
163+
// so the preview holds close to its target cadence instead of drifting.
164+
if let Some(remaining) = PREVIEW_FRAME_INTERVAL.checked_sub(frame_start.elapsed()) {
165+
thread::sleep(remaining);
166+
}
167+
}
168+
});
169+
}

src/main.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,6 +944,51 @@ mod diagnostics_handler {
944944
);
945945
std::mem::forget(timer);
946946

947+
// Live camera preview — only streams while the Diagnostics page is open.
948+
let (preview_cmd_tx, preview_cmd_rx) = std::sync::mpsc::channel::<camera::PreviewCommand>();
949+
let (preview_frame_tx, preview_frame_rx) =
950+
std::sync::mpsc::sync_channel::<camera::PreviewFrame>(1);
951+
camera::spawn_preview(preview_cmd_rx, preview_frame_tx);
952+
953+
let weak_preview = app.as_weak();
954+
let was_on_diagnostics = Rc::new(RefCell::new(false));
955+
let preview_timer = Timer::default();
956+
preview_timer.start(
957+
TimerMode::Repeated,
958+
std::time::Duration::from_millis(100),
959+
move || {
960+
let Some(window) = weak_preview.upgrade() else {
961+
return;
962+
};
963+
let on_page = window.get_on_diagnostics_page();
964+
if on_page != *was_on_diagnostics.borrow() {
965+
*was_on_diagnostics.borrow_mut() = on_page;
966+
if on_page {
967+
let _ = preview_cmd_tx.send(camera::PreviewCommand::Start);
968+
} else {
969+
let _ = preview_cmd_tx.send(camera::PreviewCommand::Stop);
970+
window.set_diag_camera_available(false);
971+
}
972+
}
973+
// Drain to the most recent frame only — older ones are stale.
974+
let mut latest = None;
975+
while let Ok(frame) = preview_frame_rx.try_recv() {
976+
latest = Some(frame);
977+
}
978+
if let Some(frame) = latest {
979+
let pixel_buffer =
980+
slint::SharedPixelBuffer::<slint::Rgb8Pixel>::clone_from_slice(
981+
&frame.rgb,
982+
frame.width,
983+
frame.height,
984+
);
985+
window.set_diag_camera_frame(slint::Image::from_rgb8(pixel_buffer));
986+
window.set_diag_camera_available(true);
987+
}
988+
},
989+
);
990+
std::mem::forget(preview_timer);
991+
947992
let cashcode_tx_reset = cashcode_tx;
948993
app.on_diag_reset_bills(move || {
949994
info!("🔄 Diagnostics: resetting bill acceptor");

ui/main_window.slint

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ export component MainWindow inherits Window {
5050
in-out property <LogEntry> diag-bill-status: { level: 0, text: "Initializing..." };
5151
in-out property <LogEntry> diag-coin-status: { level: 0, text: "Initializing..." };
5252
in-out property <LogEntry> diag-backend-status: { level: 0, text: "Not checked" };
53+
// read by Rust to know when to start/stop the camera preview
54+
out property <bool> on-diagnostics-page: current-page == Page.Diagnostics;
55+
in-out property <image> diag-camera-frame: @image-url("");
56+
in-out property <bool> diag-camera-available: false;
5357
callback diag-reset-bills();
5458
callback diag-reenumerate-coins();
5559
callback diag-play-sound();
@@ -225,11 +229,23 @@ export component MainWindow inherits Window {
225229
bill-status: root.diag-bill-status;
226230
coin-status: root.diag-coin-status;
227231
backend-status: root.diag-backend-status;
228-
back-clicked => { root.current-page = Page.Main; }
229-
reset-bills => { root.diag-reset-bills(); }
230-
reenumerate-coins => { root.diag-reenumerate-coins(); }
231-
play-sound => { root.diag-play-sound(); }
232-
check-backend => { root.diag-check-backend(); }
232+
camera-frame: root.diag-camera-frame;
233+
camera-available: root.diag-camera-available;
234+
back-clicked => {
235+
root.current-page = Page.Main;
236+
}
237+
reset-bills => {
238+
root.diag-reset-bills();
239+
}
240+
reenumerate-coins => {
241+
root.diag-reenumerate-coins();
242+
}
243+
play-sound => {
244+
root.diag-play-sound();
245+
}
246+
check-backend => {
247+
root.diag-check-backend();
248+
}
233249
}
234250

235251
// Confetti overlay — rendered on top of all pages

0 commit comments

Comments
 (0)