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}