Skip to main content

app_ui/
camera_preview.rs

1//! `<CameraPreview />` — Leptos canvas painted by raw BGRA frames
2//! pulled from the `CameraFrameSlot` (M-PIX.8 of M-RECORD-EXPORT-REAL-PIXELS).
3//!
4//! Owns the `RecorderPreviewState` enum that drives the four UX
5//! states (`Initialising` / `AwaitingPermission` / `PermissionDenied`
6//! / `Live`), and a `<canvas>` element painted at 15 fps by an
7//! `setInterval` worker that polls the
8//! `latest_camera_frame_bgra` Tauri command (raw `ArrayBuffer`
9//! over `tauri::ipc::Response` — zero JSON overhead).
10//!
11//! ```admonish info title="BGRA → RGBA swap"
12//! `CanvasRenderingContext2D::putImageData` expects RGBA in linear
13//! byte order. The capture pipeline emits BGRA (the macOS /
14//! GStreamer / SCK default for screen + camera). We swap the R/B
15//! channels in-place in JS before constructing the `ImageData`.
16//! ```
17//!
18//! ```admonish info title="Mask is CSS now"
19//! M-PIX.8 paints the raw rectangular BGRA frame into the canvas
20//! and uses `border-radius: 50%` to render it as a circle. The
21//! wisp-baked mask (described in earlier comments) lives in the
22//! ENCODER side: `RecordingScene` masks the cam sprite into a
23//! circle in the .mp4 output. The UI preview uses CSS so we
24//! don't need a separate wisp pipeline running in the webview
25//! just for the preview.
26//! ```
27
28use leptos::prelude::*;
29#[cfg(target_arch = "wasm32")]
30use leptos::task::spawn_local;
31#[cfg(target_arch = "wasm32")]
32use wasm_bindgen::JsCast;
33#[cfg(target_arch = "wasm32")]
34use wasm_bindgen::prelude::*;
35
36/// Four-state UX machine for the camera preview surface
37/// (M-CAM.3 / AUT-257).
38///
39/// Transitions: `Initialising` is the initial render before
40/// `start_preview` IPC resolves. `AwaitingPermission` is the
41/// macOS-prompt-pending state. `PermissionDenied` is the post-prompt
42/// reject. `Live` is the running pipeline.
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub enum RecorderPreviewState {
45    /// `start_preview` IPC has not yet resolved.
46    #[default]
47    Initialising,
48    /// macOS permission prompt is up; the user hasn't clicked yet.
49    AwaitingPermission,
50    /// User has denied camera access.
51    PermissionDenied,
52    /// Pipeline is running + painting frames.
53    Live,
54}
55
56impl RecorderPreviewState {
57    /// Human-readable copy for the loading / error states. `Live`
58    /// returns an empty string because the canvas paints over it.
59    #[must_use]
60    pub fn copy(self) -> &'static str {
61        match self {
62            Self::Initialising => "Starting camera…",
63            Self::AwaitingPermission => "Waiting for camera permission…",
64            Self::PermissionDenied => {
65                "Camera access denied. Grant access in System Settings → Privacy & Security, then re-open this surface."
66            }
67            Self::Live => "",
68        }
69    }
70
71    /// Kebab-case slug for CSS class hooks + data-attribute styling.
72    #[must_use]
73    pub fn slug(self) -> &'static str {
74        match self {
75            Self::Initialising => "initialising",
76            Self::AwaitingPermission => "awaiting-permission",
77            Self::PermissionDenied => "permission-denied",
78            Self::Live => "live",
79        }
80    }
81}
82
83/// Stable DOM id for the `<canvas>` element. M-CAM.3's Rust-side
84/// pipeline finds the canvas by `document.getElementById(...)` and
85/// `putImageData`s frames into its 2D context.
86pub const CANVAS_DOM_ID: &str = "camera-preview-canvas";
87
88/// Frame width the camera preview canvas paints at — must match the
89/// capture size (`screen_app::preview::pipeline::PREVIEW_WIDTH`, 720
90/// since M-QUAL.3) so the polled BGRA bytes fit the canvas
91/// pixel-for-pixel (`putImageData` does not scale). The two crates
92/// can't share a constant (native vs wasm), so keep them in lockstep.
93pub const PREVIEW_CANVAS_WIDTH: u32 = 720;
94/// Frame height (square: matches the preview pipeline).
95pub const PREVIEW_CANVAS_HEIGHT: u32 = 720;
96/// Bytes per frame (BGRA × width × height).
97pub const PREVIEW_CANVAS_BYTES: usize =
98    (PREVIEW_CANVAS_WIDTH as usize) * (PREVIEW_CANVAS_HEIGHT as usize) * 4;
99/// 15 fps preview poll interval in milliseconds. Sufficient for a
100/// "see your face" UX; IPC traffic ~31 MB/s at 720×720.
101pub const PREVIEW_POLL_MS: i32 = 66;
102
103/// `<CameraPreview />` — the recorder surface's webcam preview.
104///
105/// The component renders a 720×720 `<canvas>` plus an overlaid copy
106/// element that displays the current `RecorderPreviewState`. M-PIX.8
107/// installs a 15 fps poll that pulls the latest BGRA frame from
108/// `CameraFrameSlot` via the `latest_camera_frame_bgra` Tauri
109/// command + paints it into the canvas (with a BGRA→RGBA swap).
110#[component]
111pub fn CameraPreview() -> impl IntoView {
112    let state = RwSignal::new(RecorderPreviewState::default());
113    install_camera_frame_poll(state);
114    view! {
115        <section
116            class="camera-preview-surface"
117            data-state=move || state.get().slug()
118        >
119            <canvas
120                id=CANVAS_DOM_ID
121                class="camera-preview"
122                width=PREVIEW_CANVAS_WIDTH
123                height=PREVIEW_CANVAS_HEIGHT
124                aria-label="Live webcam preview (circular)"
125            />
126            <Show
127                when=move || !matches!(state.get(), RecorderPreviewState::Live)
128                fallback=|| view! { <></> }
129            >
130                <div class="camera-preview-overlay">
131                    {move || state.get().copy()}
132                </div>
133            </Show>
134        </section>
135    }
136}
137
138/// Set up the 15 fps poll → fetch BGRA → swap R/B → `putImageData`
139/// loop. Flips `state` to `Live` on first painted frame.
140///
141/// `setInterval` lives for the lifetime of the page (the closure is
142/// `forget()`-ed so the JS runtime keeps it). Matches the
143/// `install_*_listener` pattern used by the file-drop +
144/// recording-status hooks.
145#[cfg(target_arch = "wasm32")]
146fn install_camera_frame_poll(state: RwSignal<RecorderPreviewState>) {
147    let closure = Closure::wrap(Box::new(move || {
148        spawn_local(async move {
149            paint_one_frame(state).await;
150        });
151    }) as Box<dyn FnMut()>);
152    if let Some(window) = web_sys::window() {
153        let _ = window.set_interval_with_callback_and_timeout_and_arguments_0(
154            closure.as_ref().unchecked_ref(),
155            PREVIEW_POLL_MS,
156        );
157    }
158    closure.forget();
159}
160
161#[cfg(not(target_arch = "wasm32"))]
162fn install_camera_frame_poll(_state: RwSignal<RecorderPreviewState>) {}
163
164/// One paint tick: call the IPC, get an `ArrayBuffer`, copy into a
165/// `Uint8ClampedArray`, swap R↔B per pixel, build `ImageData`, hand
166/// to the canvas 2D context. Best-effort: every failure path silently
167/// no-ops + a `console.trace` so the UI doesn't crash if the canvas
168/// vanished between renders.
169#[cfg(target_arch = "wasm32")]
170async fn paint_one_frame(state: RwSignal<RecorderPreviewState>) {
171    use js_sys::{Reflect, Uint8ClampedArray};
172    use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData};
173
174    let Some(window) = web_sys::window() else {
175        return;
176    };
177    let Ok(invoke_fn) = Reflect::get(&window, &JsValue::from_str("__screenLatestCameraFrameBgra"))
178    else {
179        return;
180    };
181    if !invoke_fn.is_function() {
182        return;
183    }
184    let invoke: js_sys::Function = invoke_fn.unchecked_into();
185    let Ok(promise) = invoke.call0(&JsValue::NULL) else {
186        return;
187    };
188    let promise: js_sys::Promise = match promise.dyn_into() {
189        Ok(p) => p,
190        Err(_) => return,
191    };
192    let result = wasm_bindgen_futures::JsFuture::from(promise).await;
193    let Ok(buf) = result else {
194        return;
195    };
196
197    // Empty / no-frame: skip.
198    let bytes = if let Ok(array_buffer) = buf.clone().dyn_into::<js_sys::ArrayBuffer>() {
199        Uint8ClampedArray::new(&array_buffer)
200    } else if let Ok(typed_array) = buf.dyn_into::<Uint8ClampedArray>() {
201        typed_array
202    } else {
203        return;
204    };
205    let len = bytes.length() as usize;
206    if len < PREVIEW_CANVAS_BYTES {
207        return;
208    }
209
210    // Pull bytes into a Vec for in-place R↔B swap. Cheaper than
211    // hopping in-and-out of JS for each byte.
212    let mut rgba = vec![0u8; PREVIEW_CANVAS_BYTES];
213    bytes.copy_to(&mut rgba[..PREVIEW_CANVAS_BYTES]);
214    for px in rgba.chunks_exact_mut(4) {
215        // BGRA → RGBA: swap byte 0 (B) with byte 2 (R).
216        px.swap(0, 2);
217    }
218
219    // Find the canvas + 2D context. Defensive — the canvas DOM node
220    // is mounted by the component but a route change could have
221    // unmounted it.
222    let document = window.document();
223    let Some(document) = document else {
224        return;
225    };
226    let Some(canvas_el) = document.get_element_by_id(CANVAS_DOM_ID) else {
227        return;
228    };
229    let Ok(canvas) = canvas_el.dyn_into::<HtmlCanvasElement>() else {
230        return;
231    };
232    let Ok(Some(ctx)) = canvas.get_context("2d") else {
233        return;
234    };
235    let Ok(ctx) = ctx.dyn_into::<CanvasRenderingContext2d>() else {
236        return;
237    };
238
239    let Ok(image_data) = ImageData::new_with_u8_clamped_array_and_sh(
240        wasm_bindgen::Clamped(&rgba[..]),
241        PREVIEW_CANVAS_WIDTH,
242        PREVIEW_CANVAS_HEIGHT,
243    ) else {
244        return;
245    };
246    let _ = ctx.put_image_data(&image_data, 0.0, 0.0);
247
248    // First successful paint flips the state machine to Live so
249    // the overlay copy ("Starting camera…") clears.
250    if !matches!(state.get_untracked(), RecorderPreviewState::Live) {
251        state.set(RecorderPreviewState::Live);
252    }
253}
254
255#[cfg(not(target_arch = "wasm32"))]
256#[allow(
257    dead_code,
258    clippy::unused_async,
259    reason = "native stub for symmetry with the wasm32 async impl; cargo check on native target sees no caller and no .await."
260)]
261async fn paint_one_frame(_state: RwSignal<RecorderPreviewState>) {}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn default_is_initialising() {
269        assert_eq!(
270            RecorderPreviewState::default(),
271            RecorderPreviewState::Initialising
272        );
273    }
274
275    #[test]
276    fn each_state_has_unique_slug() {
277        let states = [
278            RecorderPreviewState::Initialising,
279            RecorderPreviewState::AwaitingPermission,
280            RecorderPreviewState::PermissionDenied,
281            RecorderPreviewState::Live,
282        ];
283        let mut slugs: Vec<_> = states.iter().map(|s| s.slug()).collect();
284        slugs.sort_unstable();
285        slugs.dedup();
286        assert_eq!(slugs.len(), states.len());
287    }
288
289    #[test]
290    fn live_has_empty_copy() {
291        assert!(RecorderPreviewState::Live.copy().is_empty());
292    }
293
294    #[test]
295    fn non_live_states_have_non_empty_copy() {
296        for s in [
297            RecorderPreviewState::Initialising,
298            RecorderPreviewState::AwaitingPermission,
299            RecorderPreviewState::PermissionDenied,
300        ] {
301            assert!(!s.copy().is_empty(), "state {s:?} had empty copy");
302        }
303    }
304}