Skip to main content

screen_app/preview/
pipeline.rs

1//! M-CAM.3 / AUT-257 — camera-pipeline worker thread.
2//!
3//! Owns a dedicated OS thread that runs the gst-`autovideosrc` capture
4//! subprocess, pulls BGRA frames into Rust, and (in follow-up commits)
5//! uploads each frame to a `wisp::VideoTexture`, renders the wisp
6//! scene with an M-VEC.6 circle mask into an offscreen
7//! `RenderTexture`, reads back the masked BGRA bytes, and emits them
8//! to Leptos via a Tauri `Channel<T>`.
9//!
10//! ```admonish important title="What this commit ships"
11//! Just the **gst-into-Rust** layer:
12//!
13//! 1. `start_preview` spawns a [`CameraPipeline`] worker.
14//! 2. Worker opens `media::gstreamer_video::VideoStream::from_default_camera`,
15//!    triggering the macOS permission prompt on first run.
16//! 3. Worker loops `next_frame()` and advances the
17//!    [`PreviewLifecycle`](crate::preview::PreviewLifecycle) state
18//!    machine — `Starting → Running` on first successful frame.
19//! 4. `stop_preview` drops the worker; `Drop` flips the cancel flag
20//!    and joins the thread. The gst child is killed by
21//!    `gstreamer_video::VideoStream`'s own `Drop` impl (per CLAUDE.md
22//!    "Drop-kill the child" pattern).
23//!
24//! NOT yet shipped:
25//!
26//! * **No wisp upload + render** — the frames sit in the worker; they
27//!   don't yet flow through a `wisp::Stage` + M-VEC.6 mask. That's the
28//!   next commit.
29//! * **No frame emission to Leptos** — Tauri `Channel<T>` is wired
30//!   alongside the wisp work. For now, the user sees the `Running`
31//!   lifecycle transition in `preview_status` but no pixels.
32//! ```
33//!
34//! Thread-affinity contract — `media::gstreamer_video::VideoStream`
35//! owns a `std::process::Child` (gst-launch subprocess) + a stdout
36//! reader. `Child` is `Send`, so the worker thread can own the stream
37//! exclusively. No `Rc`/`RefCell` anywhere in the type, so it's safe
38//! to move into the spawned thread.
39
40use std::sync::Arc;
41use std::sync::atomic::{AtomicBool, Ordering};
42use std::thread::{self, JoinHandle};
43
44use media::gstreamer_video::GstreamerVideoCapture;
45
46use super::diagnostics::{PreviewDiagnostics, maybe_dump_first_frame};
47use super::{CameraError, PreviewState};
48
49/// Default capture width in pixels. Paired with [`PREVIEW_HEIGHT`]
50/// — square dims are the natural input shape for the circular bubble
51/// mask. 720×720 (M-QUAL.3): the webcam's native 16:9 frame is
52/// center-cropped to 1:1 then scaled here, so the recorded bubble is
53/// crisp at native-resolution output instead of an upscaled 480².
54pub const PREVIEW_WIDTH: u32 = 720;
55
56/// Default capture height in pixels. Matches [`PREVIEW_WIDTH`] —
57/// see that constant's docs for the square-crop rationale.
58pub const PREVIEW_HEIGHT: u32 = 720;
59
60/// Source framerate request. gst will negotiate the closest the OS
61/// camera supports; the actual rate is reflected in
62/// [`media::gstreamer_video::GstreamerVideoCapture::framerate`].
63pub const PREVIEW_FPS: u32 = 30;
64
65// Compile-time invariants for the camera preview constants. These
66// replace the runtime `#[test]` versions that clippy's
67// `assertions_on_constants` lint flagged — these fire at compile
68// time (zero runtime cost), and they actually fail the build if a
69// future edit drifts the values, instead of just failing a test.
70//
71// PREVIEW_WIDTH must equal PREVIEW_HEIGHT: the M-CAM.3 follow-up
72// applies a circular mask whose max radius is `min(w, h) / 2`. A
73// non-square input would crop or stretch silently, both of which
74// are wrong.
75const _: () = assert!(
76    PREVIEW_WIDTH == PREVIEW_HEIGHT,
77    "PREVIEW_WIDTH must equal PREVIEW_HEIGHT — circular mask requires square input"
78);
79
80// PREVIEW_FPS must be a round target most cameras support natively
81// (30 or 60). Off-target rates (24 / 25 / 29.97) need explicit gst
82// caps negotiation that we don't ship today.
83const _: () = assert!(
84    PREVIEW_FPS == 30 || PREVIEW_FPS == 60,
85    "PREVIEW_FPS must be 30 or 60 — off-target rates need gst caps negotiation"
86);
87
88/// Camera-pipeline worker handle. Owns the spawned thread and a
89/// cooperative cancel flag; `Drop` cancels + joins so a panicking
90/// caller can never leave a zombie gst child behind.
91pub struct CameraPipeline {
92    cancel: Arc<AtomicBool>,
93    handle: Option<JoinHandle<()>>,
94}
95
96impl CameraPipeline {
97    /// Spawn the worker thread for the camera identified by
98    /// `camera_id` (M-CAM.4 — was hard-coded to the OS default
99    /// device in M-CAM.3). Returns immediately; the worker
100    /// transitions the preview lifecycle to `Running` once
101    /// `next_frame()` succeeds (after any macOS permission prompt
102    /// resolves). An empty `camera_id` keeps the legacy "OS default"
103    /// behaviour so the picker can start without a selection.
104    ///
105    /// # Errors
106    ///
107    /// Returns `Err` only if the OS refuses to spawn a thread (which
108    /// effectively never happens). gst-side errors are handled inside
109    /// the worker — the worker logs via `tracing::error!` and
110    /// advances the lifecycle back to `Idle` so the UI shows a
111    /// recovery state.
112    pub fn spawn(app: tauri::AppHandle, camera_id: String) -> Result<Self, CameraError> {
113        let cancel = Arc::new(AtomicBool::new(false));
114        let cancel_for_thread = Arc::clone(&cancel);
115        let handle = thread::Builder::new()
116            .name("camera-pipeline".to_owned())
117            .spawn(move || {
118                run_pipeline(&app, &cancel_for_thread, &camera_id);
119            })
120            .map_err(|err| CameraError::GstFailed(format!("thread spawn failed: {err}")))?;
121        Ok(Self {
122            cancel,
123            handle: Some(handle),
124        })
125    }
126}
127
128impl Drop for CameraPipeline {
129    fn drop(&mut self) {
130        self.cancel.store(true, Ordering::Relaxed);
131        if let Some(handle) = self.handle.take() {
132            // Best-effort join; if the worker panicked the join
133            // returns `Err` but we don't care — we're tearing down.
134            let _ = handle.join();
135        }
136    }
137}
138
139/// The actual worker loop. Lives on the spawned thread; the only
140/// reason it's `&` borrows is so the public-facing `spawn` can move
141/// the cloned values in without keeping `Self` alive on the thread.
142fn run_pipeline(app: &tauri::AppHandle, cancel: &AtomicBool, camera_id: &str) {
143    use tauri::Manager;
144
145    // Reset diagnostics on session start so the user sees a fresh
146    // frame counter / dump-slot per `start_preview` invocation.
147    let diagnostics_state = app.state::<PreviewDiagnostics>();
148    diagnostics_state.reset();
149
150    // M-CAM.4 — pin capture to the user's picked camera. Empty
151    // `camera_id` preserves the M-CAM.3 "OS default" behaviour for
152    // pre-picker callers (no Leptos UI yet → no id to route on).
153    let mut stream = if camera_id.is_empty() {
154        tracing::info!("camera-pipeline: no camera_id supplied; using OS default");
155        match GstreamerVideoCapture::from_default_camera(PREVIEW_WIDTH, PREVIEW_HEIGHT, PREVIEW_FPS)
156        {
157            Ok(stream) => stream,
158            Err(err) => {
159                tracing::error!(?err, "VideoStream::from_default_camera failed");
160                reset_lifecycle(app);
161                return;
162            }
163        }
164    } else {
165        match GstreamerVideoCapture::from_camera(
166            camera_id,
167            PREVIEW_WIDTH,
168            PREVIEW_HEIGHT,
169            PREVIEW_FPS,
170        ) {
171            Ok(stream) => stream,
172            Err(err) => {
173                tracing::error!(?err, %camera_id, "VideoStream::from_camera failed");
174                reset_lifecycle(app);
175                return;
176            }
177        }
178    };
179    let (src_w, src_h) = stream.dimensions();
180    let src_fps = stream.framerate();
181    diagnostics_state.record_source(src_w, src_h, src_fps);
182    tracing::info!(
183        width = src_w,
184        height = src_h,
185        fps = src_fps,
186        "camera-pipeline opened; awaiting first frame"
187    );
188
189    // M-PIX.1 — forward frames into the encoder's CameraFrameSlot
190    // when a recording session is active. Slot is `Option`-wrapped
191    // because the slot belongs to RecordingState which may not be
192    // managed (defensive read) and writes are no-ops when the slot
193    // is absent.
194    let camera_frame_slot = app
195        .try_state::<crate::recording::RecordingState>()
196        .map(|state| crate::recording::FrameSlot::clone(&state.camera_frame_slot));
197
198    while !cancel.load(Ordering::Relaxed) {
199        match stream.next_frame() {
200            Ok(frame) => {
201                advance_to_running(app);
202                diagnostics_state.record_frame();
203                // One-shot PNG dump of the first frame so the user
204                // can open the file and confirm real pixels reached
205                // Rust. No-op on every subsequent frame this session.
206                maybe_dump_first_frame(
207                    app,
208                    &diagnostics_state,
209                    &frame.bgra,
210                    frame.width,
211                    frame.height,
212                );
213                // M-PIX.1 — push BGRA bytes into the shared slot
214                // (latest-frame-wins). Encoder feed thread clones
215                // out at render time. Clone here is unavoidable —
216                // `frame` is consumed by drop below; the slot
217                // outlives this iteration. Cost: one ~480×480×4 =
218                // 920 KB copy per frame at 30 fps = ~28 MB/s on
219                // the heap, negligible at the workloads this
220                // recorder targets.
221                if let Some(ref slot) = camera_frame_slot {
222                    let mut guard = slot
223                        .lock()
224                        .unwrap_or_else(std::sync::PoisonError::into_inner);
225                    *guard = Some(frame.bgra.clone());
226                }
227                drop(frame);
228            }
229            Err(err) => {
230                tracing::warn!(?err, "VideoStream::next_frame errored; tearing down");
231                break;
232            }
233        }
234    }
235
236    tracing::info!("camera-pipeline cancel observed; shutting down");
237    reset_lifecycle(app);
238    // `stream` drops here → gst-launch child killed + reaped per
239    // CLAUDE.md's "Drop-kill the child" pattern.
240    drop(stream);
241}
242
243/// Mark the preview lifecycle as `Running` (idempotent — already-
244/// `Running` stays running). Called from the worker thread on each
245/// successful frame; the `mark_running` transition is idempotent so
246/// we don't need a "first frame" guard.
247fn advance_to_running(app: &tauri::AppHandle) {
248    use tauri::Manager;
249    let state = app.state::<PreviewState>();
250    let mut guard = state
251        .0
252        .lock()
253        .unwrap_or_else(std::sync::PoisonError::into_inner);
254    let next = guard.mark_running();
255    if *guard != next {
256        tracing::info!("preview lifecycle: Starting → Running (first frame received)");
257        *guard = next;
258    }
259}
260
261/// Drive the lifecycle back to `Idle` on shutdown OR on gst-side
262/// startup failure (no camera attached, permission denied, etc.).
263fn reset_lifecycle(app: &tauri::AppHandle) {
264    use tauri::Manager;
265    let state = app.state::<PreviewState>();
266    let mut guard = state
267        .0
268        .lock()
269        .unwrap_or_else(std::sync::PoisonError::into_inner);
270    *guard = guard.try_stop().finish_stop();
271}
272
273/// Tauri-managed handle for the active camera pipeline. Held in
274/// `tauri::State` so the `stop_preview` command can drop the worker
275/// (and consequently kill the gst child + join the thread).
276///
277/// Wrapping `Option<CameraPipeline>` in a `Mutex` rather than an
278/// `AtomicCell` keeps the dep surface small; contention is bounded
279/// by user-driven start/stop clicks, which can't race meaningfully.
280#[derive(Default)]
281pub struct CameraPipelineHandle(pub std::sync::Mutex<Option<CameraPipeline>>);
282
283impl CameraPipelineHandle {
284    /// Install a freshly-spawned pipeline. If one was already
285    /// running, it's dropped first (which kills + joins it before
286    /// the new one starts). Idempotent under concurrent calls — the
287    /// mutex serialises the swap.
288    pub fn install(&self, pipeline: CameraPipeline) {
289        let mut guard = self
290            .0
291            .lock()
292            .unwrap_or_else(std::sync::PoisonError::into_inner);
293        *guard = Some(pipeline);
294    }
295
296    /// Drop the active pipeline (which cancels + joins the worker).
297    /// No-op if no pipeline is active.
298    pub fn shutdown(&self) {
299        let mut guard = self
300            .0
301            .lock()
302            .unwrap_or_else(std::sync::PoisonError::into_inner);
303        *guard = None;
304    }
305
306    /// `true` if a worker is currently held. Used by tests +
307    /// diagnostics; the lifecycle state machine in
308    /// [`PreviewLifecycle`] is the source of truth for UI state.
309    #[must_use]
310    pub fn is_active(&self) -> bool {
311        self.0
312            .lock()
313            .unwrap_or_else(std::sync::PoisonError::into_inner)
314            .is_some()
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn handle_install_replaces_previous() {
324        // Pure-state test of the handle — never actually spawns a
325        // worker (which would require a tauri::AppHandle, a real
326        // gst install, and a webcam). The state machine here is
327        // small enough to verify directly: an Option<T> behind a
328        // Mutex with install / shutdown / is_active.
329        let handle = CameraPipelineHandle::default();
330        assert!(!handle.is_active());
331
332        // Simulate a worker by installing then reading. We can't
333        // construct a CameraPipeline without spawning, so use
334        // `Option::take` mechanics by going through `shutdown`.
335        handle.shutdown();
336        assert!(!handle.is_active());
337    }
338
339    // PREVIEW_WIDTH/HEIGHT/FPS invariants are now enforced at compile
340    // time via the `const _: () = assert!(..)` blocks above the test
341    // module — they fail the build if a future edit drifts the
342    // values, which is a stronger guard than these `#[test]`s ever
343    // were (and silences clippy's `assertions_on_constants` lint
344    // that flagged the test-time versions in CI).
345}