1use std::path::Path;
18
19use fenestra_core::{TextSize, Theme, col, div, image_rgba8, row, text};
20use image::RgbaImage;
21
22use crate::{render_element, with_headless};
23
24const CHANNEL_TOLERANCE: u8 = 3;
26const MAX_DIFFERING_FRACTION: f64 = 0.002;
29
30pub const UPDATE_ENV: &str = "FENESTRA_UPDATE_SNAPSHOTS";
32
33pub const BUDGET_ENV: &str = "FENESTRA_SNAPSHOT_BUDGET";
36
37fn differing_budget() -> f64 {
38 std::env::var(BUDGET_ENV)
39 .ok()
40 .and_then(|v| v.parse::<f64>().ok())
41 .filter(|b| b.is_finite() && (0.0..=1.0).contains(b))
42 .unwrap_or(MAX_DIFFERING_FRACTION)
43}
44
45pub fn assert_png_snapshot(dir: impl AsRef<Path>, name: &str, actual: &RgbaImage) {
51 let dir = dir.as_ref();
52 let golden_path = dir.join(format!("{name}.png"));
53 let update = std::env::var(UPDATE_ENV).is_ok_and(|v| v == "1");
54
55 if update {
56 std::fs::create_dir_all(dir).expect("create snapshot dir");
57 actual.save(&golden_path).expect("write golden");
58 return;
59 }
60
61 let artifacts = [
62 dir.join(format!("{name}.actual.png")),
63 dir.join(format!("{name}.diff.png")),
64 dir.join(format!("{name}.side.png")),
65 ];
66
67 let golden = match image::open(&golden_path) {
68 Ok(img) => img.into_rgba8(),
69 Err(_) => panic!(
70 "missing golden {}; run with {UPDATE_ENV}=1 to create it",
71 golden_path.display()
72 ),
73 };
74
75 if golden.dimensions() != actual.dimensions() {
76 let actual_path = dir.join(format!("{name}.actual.png"));
77 actual.save(&actual_path).ok();
78 panic!(
79 "golden {} is {:?} but actual is {:?} (actual written to {})",
80 golden_path.display(),
81 golden.dimensions(),
82 actual.dimensions(),
83 actual_path.display()
84 );
85 }
86
87 let total = u64::from(golden.width()) * u64::from(golden.height());
88 let mut differing: u64 = 0;
89 let mut max_delta: u8 = 0;
90 let mut worst: (u32, u32) = (0, 0);
91 for (x, y, a) in actual.enumerate_pixels() {
92 let g = golden.get_pixel(x, y);
93 let mut pixel_exceeds = false;
94 for c in 0..4 {
95 let delta = g.0[c].abs_diff(a.0[c]);
96 if delta > max_delta {
97 max_delta = delta;
98 worst = (x, y);
99 }
100 if delta > CHANNEL_TOLERANCE {
101 pixel_exceeds = true;
102 }
103 }
104 if pixel_exceeds {
105 differing += 1;
106 }
107 }
108
109 #[expect(clippy::cast_precision_loss, reason = "image pixel counts are small")]
110 let fraction = differing as f64 / total as f64;
111 let budget = differing_budget();
112 if fraction > budget {
113 actual.save(&artifacts[0]).ok();
114 let diff = diff_image(&golden, actual);
115 diff.save(&artifacts[1]).ok();
116 side_by_side(&golden, actual, &diff)
117 .save(&artifacts[2])
118 .ok();
119 panic!(
120 "snapshot {name}: {differing}/{total} pixels ({:.3}%) exceed channel tolerance \
121 {CHANNEL_TOLERANCE}, over budget {:.3}% (max delta {max_delta} at {worst:?})\n\
122 artifacts: {name}.actual.png, {name}.diff.png (offending pixels in red), \
123 {name}.side.png — in {}\n\
124 run with {UPDATE_ENV}=1 to update",
125 fraction * 100.0,
126 budget * 100.0,
127 dir.display()
128 );
129 }
130
131 for stale in &artifacts {
133 let _ = std::fs::remove_file(stale);
134 }
135}
136
137pub const MIN_STRIP_SCALE: f32 = 0.05;
141pub const MAX_STRIP_SCALE: f32 = 1.0;
143
144const STRIP_GAP: f32 = 8.0;
148const STRIP_CAPTION_H: f32 = 18.0;
149const THUMB_CAPTION_GAP: f32 = 2.0;
153
154#[derive(Debug)]
157pub enum FilmstripError {
158 NoFrames,
160 TooLarge {
164 width: u32,
166 height: u32,
168 max_dim: u32,
170 },
171}
172
173impl std::fmt::Display for FilmstripError {
174 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 match self {
176 Self::NoFrames => write!(f, "no frames captured"),
177 Self::TooLarge {
178 width,
179 height,
180 max_dim,
181 } => write!(
182 f,
183 "strip would be {width}x{height}px, over the renderer's {max_dim}px limit; \
184 lower `scale` or capture fewer frames"
185 ),
186 }
187 }
188}
189
190impl std::error::Error for FilmstripError {}
191
192#[must_use]
197pub fn clamp_strip_scale(scale: f32) -> f32 {
198 if scale.is_finite() {
199 scale.clamp(MIN_STRIP_SCALE, MAX_STRIP_SCALE)
200 } else {
201 MAX_STRIP_SCALE
202 }
203}
204
205pub fn filmstrip_image(
224 frames: &[RgbaImage],
225 interval_ms: u64,
226 scale: f32,
227) -> Result<RgbaImage, FilmstripError> {
228 if frames.is_empty() {
229 return Err(FilmstripError::NoFrames);
230 }
231 let scale = clamp_strip_scale(scale);
232
233 #[expect(
234 clippy::cast_precision_loss,
235 reason = "frame pixel dimensions are far below f32's exact-integer range"
236 )]
237 let cells: Vec<(u32, u32, f32, f32)> = frames
238 .iter()
239 .map(|f| {
240 let (w, h) = f.dimensions();
241 (w, h, w as f32 * scale, h as f32 * scale)
242 })
243 .collect();
244
245 let strip_w = STRIP_GAP
246 + cells
247 .iter()
248 .map(|&(_, _, tw, _)| tw + STRIP_GAP)
249 .sum::<f32>();
250 let strip_h = STRIP_GAP * 2.0
251 + cells.iter().map(|&(_, _, _, th)| th).fold(0.0f32, f32::max)
252 + THUMB_CAPTION_GAP
253 + STRIP_CAPTION_H;
254
255 let max_dim = with_headless(|h| h.max_dimension()).unwrap_or(8192);
256 #[expect(
257 clippy::cast_possible_truncation,
258 clippy::cast_sign_loss,
259 reason = "strip_w/strip_h are sums of small positive cell sizes"
260 )]
261 let (strip_w, strip_h) = (strip_w.ceil() as u32, strip_h.ceil() as u32);
262 if strip_w > max_dim || strip_h > max_dim {
263 return Err(FilmstripError::TooLarge {
264 width: strip_w,
265 height: strip_h,
266 max_dim,
267 });
268 }
269
270 let mut strip = row::<()>().p(STRIP_GAP).gap(STRIP_GAP).items_start();
271 for (i, (frame, &(w, h, tw, th))) in frames.iter().zip(&cells).enumerate() {
272 let at_ms = (i as u64).saturating_mul(interval_ms);
273 let thumb = div::<()>()
274 .child(image_rgba8(w, h, frame.as_raw().clone()).w(tw).h(th))
275 .themed(|t, s| s.border(1.0, t.border_subtle));
276 let cell = col::<()>()
277 .gap(THUMB_CAPTION_GAP)
278 .items_center()
279 .child(thumb)
280 .child(
281 text(format!("f{i:03} +{at_ms}ms"))
282 .size(TextSize::Xs)
283 .mono()
284 .themed(|t, s| s.color(t.text_muted)),
285 );
286 strip = strip.child(cell);
287 }
288
289 Ok(render_element(strip, &Theme::light(), (strip_w, strip_h)))
290}
291
292pub fn assert_filmstrip_snapshot(
310 dir: impl AsRef<Path>,
311 name: &str,
312 frames: &[RgbaImage],
313 interval_ms: u64,
314) {
315 assert_filmstrip_snapshot_scaled(dir, name, frames, interval_ms, MAX_STRIP_SCALE);
316}
317
318pub fn assert_filmstrip_snapshot_scaled(
325 dir: impl AsRef<Path>,
326 name: &str,
327 frames: &[RgbaImage],
328 interval_ms: u64,
329 scale: f32,
330) {
331 let composed = filmstrip_image(frames, interval_ms, scale)
332 .unwrap_or_else(|e| panic!("assert_filmstrip_snapshot {name}: {e}"));
333 assert_png_snapshot(dir, name, &composed);
334}
335
336fn diff_image(golden: &RgbaImage, actual: &RgbaImage) -> RgbaImage {
339 let mut out = RgbaImage::new(golden.width(), golden.height());
340 for (x, y, a) in actual.enumerate_pixels() {
341 let g = golden.get_pixel(x, y);
342 let exceeds = (0..4).any(|c| g.0[c].abs_diff(a.0[c]) > CHANNEL_TOLERANCE);
343 let px = if exceeds {
344 image::Rgba([255, 0, 0, 255])
345 } else {
346 image::Rgba([g.0[0] / 3, g.0[1] / 3, g.0[2] / 3, 255])
347 };
348 out.put_pixel(x, y, px);
349 }
350 out
351}
352
353fn side_by_side(golden: &RgbaImage, actual: &RgbaImage, diff: &RgbaImage) -> RgbaImage {
355 const GAP: u32 = 4;
356 let (w, h) = golden.dimensions();
357 let mut out = RgbaImage::from_pixel(w * 3 + GAP * 2, h, image::Rgba([128, 128, 128, 255]));
358 for (i, img) in [golden, actual, diff].into_iter().enumerate() {
359 #[expect(clippy::cast_possible_truncation, reason = "three panes")]
360 let x0 = (w + GAP) * i as u32;
361 for (x, y, px) in img.enumerate_pixels() {
362 out.put_pixel(x0 + x, y, *px);
363 }
364 }
365 out
366}