1use std::path::PathBuf;
23use std::sync::Mutex;
24use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
25
26use serde::{Deserialize, Serialize};
27use tauri::Manager;
28
29#[derive(Default)]
35pub struct PreviewDiagnostics {
36 pub frames_received: AtomicU64,
39 pub source_width: AtomicU32,
42 pub source_height: AtomicU32,
44 pub source_fps_hundredths: AtomicU32,
47 pub first_frame_dump_path: Mutex<Option<PathBuf>>,
50}
51
52impl PreviewDiagnostics {
53 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 pub fn record_frame(&self) {
69 self.frames_received.fetch_add(1, Ordering::Relaxed);
70 }
71
72 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 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 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 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
129pub struct DiagnosticsSnapshot {
130 pub frames_received: u64,
132 pub source_width: u32,
134 pub source_height: u32,
136 pub source_fps_hundredths: u32,
139 pub first_frame_dump_path: Option<String>,
142}
143
144pub 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 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 *diagnostics
176 .first_frame_dump_path
177 .lock()
178 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
179 }
180 }
181}
182
183fn 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#[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 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 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 let bgra = vec![0x11, 0x22, 0x33, 0x44];
275 let rgba = bgra_to_rgba(&bgra);
276 assert_eq!(rgba, vec![0x33, 0x22, 0x11, 0x44]);
278 }
279
280 #[test]
281 fn bgra_to_rgba_handles_multi_pixel_buffers() {
282 let bgra = vec![1, 2, 3, 4, 5, 6, 7, 8];
284 let rgba = bgra_to_rgba(&bgra);
285 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 let bgra = vec![1, 2, 3, 4, 5, 6, 7]; let rgba = bgra_to_rgba(&bgra);
296 assert_eq!(rgba.len(), 4);
297 }
298}