Skip to main content

screen_app/preview/
diagnostics.rs

1//! M-CAM.3 / AUT-257 — runtime diagnostics for the camera pipeline.
2//!
3//! Lives in `tauri::State` so the worker thread can mutate cheap
4//! atomic counters per frame without ever holding a mutex on the
5//! hot path, and the Leptos overlay can poll a snapshot via the
6//! `preview_diagnostics` IPC command at a much lower rate (~2 Hz).
7//!
8//! ```admonish important title="Why atomics, not a single Mutex<Stats>"
9//! `WindowEvent::Moved` for the bubble was already locking a mutex
10//! per drag event and that's fine — drag is at most 60 Hz. But the
11//! camera worker pushes 30 frames per second, and `preview_status`
12//! polling from Leptos lands ~2 Hz. A `Mutex<Stats>` would serialise
13//! both producer + consumer on the same lock. Atomic `u64` / `u32`
14//! reads + writes are wait-free; the consumer just snapshots whatever
15//! was last written without blocking the worker.
16//! ```
17//!
18//! The first-received frame is also dumped to PNG (one-shot, behind
19//! a `PathBuf` mutex set only once) so the user has visual proof
20//! that real pixels reached Rust — see [`maybe_dump_first_frame`].
21
22use std::path::PathBuf;
23use std::sync::Mutex;
24use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
25
26use serde::{Deserialize, Serialize};
27use tauri::Manager;
28
29/// Tauri-managed diagnostic counters for the camera pipeline.
30///
31/// All counters reset on `start_preview` so the user gets a
32/// fresh-from-zero ticker each session — easier to eyeball "the
33/// number is going up" without comparing against a baseline.
34#[derive(Default)]
35pub struct PreviewDiagnostics {
36    /// Number of frames the worker has pulled from gst since the
37    /// current session started. Resets on each `start_preview`.
38    pub frames_received: AtomicU64,
39    /// Source frame width in pixels (as reported by gst's caps
40    /// negotiation). `0` until first frame.
41    pub source_width: AtomicU32,
42    /// Source frame height in pixels. `0` until first frame.
43    pub source_height: AtomicU32,
44    /// Source framerate × 100 (encoded as integer for atomic
45    /// storage; 30 fps → `3000`, 29.97 → `2997`). `0` until known.
46    pub source_fps_hundredths: AtomicU32,
47    /// Path of the first-frame PNG dump, if one has been written
48    /// this session. `None` until first frame dumps successfully.
49    pub first_frame_dump_path: Mutex<Option<PathBuf>>,
50}
51
52impl PreviewDiagnostics {
53    /// Reset all counters. Called on every `start_preview` so the
54    /// user sees a fresh ticker per session.
55    pub fn reset(&self) {
56        self.frames_received.store(0, Ordering::Relaxed);
57        self.source_width.store(0, Ordering::Relaxed);
58        self.source_height.store(0, Ordering::Relaxed);
59        self.source_fps_hundredths.store(0, Ordering::Relaxed);
60        *self
61            .first_frame_dump_path
62            .lock()
63            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
64    }
65
66    /// Record that one frame just arrived. Worker thread calls this
67    /// on every successful `next_frame()`.
68    pub fn record_frame(&self) {
69        self.frames_received.fetch_add(1, Ordering::Relaxed);
70    }
71
72    /// Record the source dims + fps once they're known (after the
73    /// gst caps negotiation completes — practically, on opening the
74    /// stream). Cheap to call repeatedly with the same values.
75    pub fn record_source(&self, width: u32, height: u32, fps: f64) {
76        self.source_width.store(width, Ordering::Relaxed);
77        self.source_height.store(height, Ordering::Relaxed);
78        // Clamp the float into the u32 range as hundredths. 60 fps
79        // → 6000 fits; even 600 fps → 60_000 fits comfortably.
80        let hundredths = (fps * 100.0).clamp(0.0, f64::from(u32::MAX));
81        #[allow(
82            clippy::cast_possible_truncation,
83            clippy::cast_sign_loss,
84            reason = "value clamped to [0, u32::MAX] above"
85        )]
86        let hundredths_u32 = hundredths as u32;
87        self.source_fps_hundredths
88            .store(hundredths_u32, Ordering::Relaxed);
89    }
90
91    /// Set the first-frame dump path (one-shot). Returns `true` on
92    /// the first call (caller should encode + write the PNG),
93    /// `false` on subsequent calls (already dumped this session).
94    pub fn try_claim_dump_slot(&self, path: PathBuf) -> bool {
95        let mut guard = self
96            .first_frame_dump_path
97            .lock()
98            .unwrap_or_else(std::sync::PoisonError::into_inner);
99        if guard.is_some() {
100            return false;
101        }
102        *guard = Some(path);
103        true
104    }
105
106    /// Read a serialisable snapshot. The IPC command returns this;
107    /// Leptos polls every 500ms.
108    #[must_use]
109    pub fn snapshot(&self) -> DiagnosticsSnapshot {
110        let path = self
111            .first_frame_dump_path
112            .lock()
113            .unwrap_or_else(std::sync::PoisonError::into_inner)
114            .as_ref()
115            .map(|p| p.to_string_lossy().into_owned());
116        DiagnosticsSnapshot {
117            frames_received: self.frames_received.load(Ordering::Relaxed),
118            source_width: self.source_width.load(Ordering::Relaxed),
119            source_height: self.source_height.load(Ordering::Relaxed),
120            source_fps_hundredths: self.source_fps_hundredths.load(Ordering::Relaxed),
121            first_frame_dump_path: path,
122        }
123    }
124}
125
126/// Wire-format snapshot of [`PreviewDiagnostics`]. Returned by the
127/// `preview_diagnostics` IPC command.
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
129pub struct DiagnosticsSnapshot {
130    /// Total frames received since `start_preview`.
131    pub frames_received: u64,
132    /// Source frame width in pixels. `0` if no frames yet.
133    pub source_width: u32,
134    /// Source frame height in pixels. `0` if no frames yet.
135    pub source_height: u32,
136    /// Source framerate × 100. `0` if unknown. Leptos divides by 100
137    /// for display.
138    pub source_fps_hundredths: u32,
139    /// Absolute path of the first-frame PNG dump, or `None` if no
140    /// dump succeeded this session.
141    pub first_frame_dump_path: Option<String>,
142}
143
144/// One-shot writer: on the very first frame of each session, dump
145/// the BGRA bytes to a PNG file under the app cache dir so the user
146/// can confirm visually that real pixels reached Rust. Returns the
147/// absolute path on success; logs + returns `None` on encode/write
148/// failure (we don't want a diagnostic to crash the worker).
149///
150/// Subsequent calls within the same session no-op (the dump slot is
151/// claimed once via [`PreviewDiagnostics::try_claim_dump_slot`]).
152pub fn maybe_dump_first_frame(
153    app: &tauri::AppHandle,
154    diagnostics: &PreviewDiagnostics,
155    bgra: &[u8],
156    width: u32,
157    height: u32,
158) {
159    let Ok(cache_dir) = app.path().app_cache_dir() else {
160        tracing::warn!("app_cache_dir unavailable; first-frame dump skipped");
161        return;
162    };
163    let path = cache_dir.join("first-frame.png");
164    if !diagnostics.try_claim_dump_slot(path.clone()) {
165        // Already dumped this session — fast path.
166        return;
167    }
168    match write_bgra_as_png(&path, bgra, width, height) {
169        Ok(()) => {
170            tracing::info!(?path, "first-frame PNG dumped");
171        }
172        Err(err) => {
173            tracing::warn!(?err, ?path, "first-frame PNG dump failed");
174            // Clear the slot so a subsequent successful frame can retry.
175            *diagnostics
176                .first_frame_dump_path
177                .lock()
178                .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
179        }
180    }
181}
182
183/// Encode + write BGRA bytes as a PNG. Splits the BGRA→RGBA byte
184/// swap from the encode call so the swap itself is unit-testable.
185fn write_bgra_as_png(
186    path: &std::path::Path,
187    bgra: &[u8],
188    width: u32,
189    height: u32,
190) -> Result<(), String> {
191    let rgba = bgra_to_rgba(bgra);
192    if let Some(parent) = path.parent() {
193        std::fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
194    }
195    let buf = image::RgbaImage::from_raw(width, height, rgba)
196        .ok_or_else(|| "RgbaImage::from_raw rejected the buffer (size mismatch)".to_owned())?;
197    buf.save_with_format(path, image::ImageFormat::Png)
198        .map_err(|e| format!("save: {e}"))
199}
200
201/// In-place byte swap on each pixel: BGRA → RGBA. Allocates a new
202/// `Vec<u8>` rather than mutating in place so the worker's frame
203/// buffer is preserved (the follow-up wisp commit will upload the
204/// same BGRA bytes to `VideoTexture` and benefits from byte order
205/// matching the wisp expectation).
206#[must_use]
207fn bgra_to_rgba(bgra: &[u8]) -> Vec<u8> {
208    let mut rgba = Vec::with_capacity(bgra.len());
209    for chunk in bgra.chunks_exact(4) {
210        // chunk = [B, G, R, A] → push [R, G, B, A]
211        rgba.push(chunk[2]);
212        rgba.push(chunk[1]);
213        rgba.push(chunk[0]);
214        rgba.push(chunk[3]);
215    }
216    rgba
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn diagnostics_reset_clears_counters() {
225        let d = PreviewDiagnostics::default();
226        d.record_frame();
227        d.record_frame();
228        d.record_source(640, 480, 30.0);
229        assert_eq!(d.frames_received.load(Ordering::Relaxed), 2);
230        assert_eq!(d.source_width.load(Ordering::Relaxed), 640);
231        d.reset();
232        assert_eq!(d.frames_received.load(Ordering::Relaxed), 0);
233        assert_eq!(d.source_width.load(Ordering::Relaxed), 0);
234        assert_eq!(d.source_height.load(Ordering::Relaxed), 0);
235        assert_eq!(d.source_fps_hundredths.load(Ordering::Relaxed), 0);
236    }
237
238    #[test]
239    fn diagnostics_records_fps_as_hundredths() {
240        let d = PreviewDiagnostics::default();
241        d.record_source(640, 480, 30.0);
242        assert_eq!(d.source_fps_hundredths.load(Ordering::Relaxed), 3000);
243        d.record_source(640, 480, 29.97);
244        assert_eq!(d.source_fps_hundredths.load(Ordering::Relaxed), 2997);
245    }
246
247    #[test]
248    fn try_claim_dump_slot_is_one_shot() {
249        let d = PreviewDiagnostics::default();
250        assert!(d.try_claim_dump_slot(PathBuf::from("/tmp/a.png")));
251        assert!(!d.try_claim_dump_slot(PathBuf::from("/tmp/b.png")));
252        // Reset re-opens the slot for the next session.
253        d.reset();
254        assert!(d.try_claim_dump_slot(PathBuf::from("/tmp/c.png")));
255    }
256
257    #[test]
258    fn snapshot_round_trips_through_serde() {
259        let snapshot = DiagnosticsSnapshot {
260            frames_received: 42,
261            source_width: 640,
262            source_height: 480,
263            source_fps_hundredths: 2997,
264            first_frame_dump_path: Some("/tmp/first-frame.png".to_owned()),
265        };
266        let json = serde_json::to_string(&snapshot).unwrap();
267        let back: DiagnosticsSnapshot = serde_json::from_str(&json).unwrap();
268        assert_eq!(back, snapshot);
269    }
270
271    #[test]
272    fn bgra_to_rgba_swaps_red_and_blue_channels() {
273        // Single pixel: B=0x11, G=0x22, R=0x33, A=0x44.
274        let bgra = vec![0x11, 0x22, 0x33, 0x44];
275        let rgba = bgra_to_rgba(&bgra);
276        // Expect R=0x33, G=0x22, B=0x11, A=0x44.
277        assert_eq!(rgba, vec![0x33, 0x22, 0x11, 0x44]);
278    }
279
280    #[test]
281    fn bgra_to_rgba_handles_multi_pixel_buffers() {
282        // 2 pixels: (B0,G0,R0,A0), (B1,G1,R1,A1).
283        let bgra = vec![1, 2, 3, 4, 5, 6, 7, 8];
284        let rgba = bgra_to_rgba(&bgra);
285        // (R0=3,G0=2,B0=1,A0=4), (R1=7,G1=6,B1=5,A1=8).
286        assert_eq!(rgba, vec![3, 2, 1, 4, 7, 6, 5, 8]);
287    }
288
289    #[test]
290    fn bgra_to_rgba_truncates_incomplete_trailing_pixel() {
291        // chunks_exact drops a non-4-byte trailing remainder. Verify
292        // the resulting RGBA length is the largest multiple of 4 ≤
293        // input length.
294        let bgra = vec![1, 2, 3, 4, 5, 6, 7]; // 7 bytes (one full pixel + 3 extra)
295        let rgba = bgra_to_rgba(&bgra);
296        assert_eq!(rgba.len(), 4);
297    }
298}