use std::path::Path;
use fenestra_core::{TextSize, Theme, col, div, image_rgba8, row, text};
use image::RgbaImage;
use crate::{render_element, with_headless};
const CHANNEL_TOLERANCE: u8 = 3;
const MAX_DIFFERING_FRACTION: f64 = 0.002;
pub const UPDATE_ENV: &str = "FENESTRA_UPDATE_SNAPSHOTS";
pub const BUDGET_ENV: &str = "FENESTRA_SNAPSHOT_BUDGET";
fn differing_budget() -> f64 {
std::env::var(BUDGET_ENV)
.ok()
.and_then(|v| v.parse::<f64>().ok())
.filter(|b| b.is_finite() && (0.0..=1.0).contains(b))
.unwrap_or(MAX_DIFFERING_FRACTION)
}
pub fn assert_png_snapshot(dir: impl AsRef<Path>, name: &str, actual: &RgbaImage) {
let dir = dir.as_ref();
let golden_path = dir.join(format!("{name}.png"));
let update = std::env::var(UPDATE_ENV).is_ok_and(|v| v == "1");
if update {
std::fs::create_dir_all(dir).expect("create snapshot dir");
actual.save(&golden_path).expect("write golden");
return;
}
let artifacts = [
dir.join(format!("{name}.actual.png")),
dir.join(format!("{name}.diff.png")),
dir.join(format!("{name}.side.png")),
];
let golden = match image::open(&golden_path) {
Ok(img) => img.into_rgba8(),
Err(_) => panic!(
"missing golden {}; run with {UPDATE_ENV}=1 to create it",
golden_path.display()
),
};
if golden.dimensions() != actual.dimensions() {
let actual_path = dir.join(format!("{name}.actual.png"));
actual.save(&actual_path).ok();
panic!(
"golden {} is {:?} but actual is {:?} (actual written to {})",
golden_path.display(),
golden.dimensions(),
actual.dimensions(),
actual_path.display()
);
}
let total = u64::from(golden.width()) * u64::from(golden.height());
let mut differing: u64 = 0;
let mut max_delta: u8 = 0;
let mut worst: (u32, u32) = (0, 0);
for (x, y, a) in actual.enumerate_pixels() {
let g = golden.get_pixel(x, y);
let mut pixel_exceeds = false;
for c in 0..4 {
let delta = g.0[c].abs_diff(a.0[c]);
if delta > max_delta {
max_delta = delta;
worst = (x, y);
}
if delta > CHANNEL_TOLERANCE {
pixel_exceeds = true;
}
}
if pixel_exceeds {
differing += 1;
}
}
#[expect(clippy::cast_precision_loss, reason = "image pixel counts are small")]
let fraction = differing as f64 / total as f64;
let budget = differing_budget();
if fraction > budget {
actual.save(&artifacts[0]).ok();
let diff = diff_image(&golden, actual);
diff.save(&artifacts[1]).ok();
side_by_side(&golden, actual, &diff)
.save(&artifacts[2])
.ok();
panic!(
"snapshot {name}: {differing}/{total} pixels ({:.3}%) exceed channel tolerance \
{CHANNEL_TOLERANCE}, over budget {:.3}% (max delta {max_delta} at {worst:?})\n\
artifacts: {name}.actual.png, {name}.diff.png (offending pixels in red), \
{name}.side.png — in {}\n\
run with {UPDATE_ENV}=1 to update",
fraction * 100.0,
budget * 100.0,
dir.display()
);
}
for stale in &artifacts {
let _ = std::fs::remove_file(stale);
}
}
pub const MIN_STRIP_SCALE: f32 = 0.05;
pub const MAX_STRIP_SCALE: f32 = 1.0;
const STRIP_GAP: f32 = 8.0;
const STRIP_CAPTION_H: f32 = 18.0;
const THUMB_CAPTION_GAP: f32 = 2.0;
#[derive(Debug)]
pub enum FilmstripError {
NoFrames,
TooLarge {
width: u32,
height: u32,
max_dim: u32,
},
}
impl std::fmt::Display for FilmstripError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoFrames => write!(f, "no frames captured"),
Self::TooLarge {
width,
height,
max_dim,
} => write!(
f,
"strip would be {width}x{height}px, over the renderer's {max_dim}px limit; \
lower `scale` or capture fewer frames"
),
}
}
}
impl std::error::Error for FilmstripError {}
#[must_use]
pub fn clamp_strip_scale(scale: f32) -> f32 {
if scale.is_finite() {
scale.clamp(MIN_STRIP_SCALE, MAX_STRIP_SCALE)
} else {
MAX_STRIP_SCALE
}
}
pub fn filmstrip_image(
frames: &[RgbaImage],
interval_ms: u64,
scale: f32,
) -> Result<RgbaImage, FilmstripError> {
if frames.is_empty() {
return Err(FilmstripError::NoFrames);
}
let scale = clamp_strip_scale(scale);
#[expect(
clippy::cast_precision_loss,
reason = "frame pixel dimensions are far below f32's exact-integer range"
)]
let cells: Vec<(u32, u32, f32, f32)> = frames
.iter()
.map(|f| {
let (w, h) = f.dimensions();
(w, h, w as f32 * scale, h as f32 * scale)
})
.collect();
let strip_w = STRIP_GAP
+ cells
.iter()
.map(|&(_, _, tw, _)| tw + STRIP_GAP)
.sum::<f32>();
let strip_h = STRIP_GAP * 2.0
+ cells.iter().map(|&(_, _, _, th)| th).fold(0.0f32, f32::max)
+ THUMB_CAPTION_GAP
+ STRIP_CAPTION_H;
let max_dim = with_headless(|h| h.max_dimension()).unwrap_or(8192);
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "strip_w/strip_h are sums of small positive cell sizes"
)]
let (strip_w, strip_h) = (strip_w.ceil() as u32, strip_h.ceil() as u32);
if strip_w > max_dim || strip_h > max_dim {
return Err(FilmstripError::TooLarge {
width: strip_w,
height: strip_h,
max_dim,
});
}
let mut strip = row::<()>().p(STRIP_GAP).gap(STRIP_GAP).items_start();
for (i, (frame, &(w, h, tw, th))) in frames.iter().zip(&cells).enumerate() {
let at_ms = (i as u64).saturating_mul(interval_ms);
let thumb = div::<()>()
.child(image_rgba8(w, h, frame.as_raw().clone()).w(tw).h(th))
.themed(|t, s| s.border(1.0, t.border_subtle));
let cell = col::<()>()
.gap(THUMB_CAPTION_GAP)
.items_center()
.child(thumb)
.child(
text(format!("f{i:03} +{at_ms}ms"))
.size(TextSize::Xs)
.mono()
.themed(|t, s| s.color(t.text_muted)),
);
strip = strip.child(cell);
}
Ok(render_element(strip, &Theme::light(), (strip_w, strip_h)))
}
pub fn assert_filmstrip_snapshot(
dir: impl AsRef<Path>,
name: &str,
frames: &[RgbaImage],
interval_ms: u64,
) {
assert_filmstrip_snapshot_scaled(dir, name, frames, interval_ms, MAX_STRIP_SCALE);
}
pub fn assert_filmstrip_snapshot_scaled(
dir: impl AsRef<Path>,
name: &str,
frames: &[RgbaImage],
interval_ms: u64,
scale: f32,
) {
let composed = filmstrip_image(frames, interval_ms, scale)
.unwrap_or_else(|e| panic!("assert_filmstrip_snapshot {name}: {e}"));
assert_png_snapshot(dir, name, &composed);
}
fn diff_image(golden: &RgbaImage, actual: &RgbaImage) -> RgbaImage {
let mut out = RgbaImage::new(golden.width(), golden.height());
for (x, y, a) in actual.enumerate_pixels() {
let g = golden.get_pixel(x, y);
let exceeds = (0..4).any(|c| g.0[c].abs_diff(a.0[c]) > CHANNEL_TOLERANCE);
let px = if exceeds {
image::Rgba([255, 0, 0, 255])
} else {
image::Rgba([g.0[0] / 3, g.0[1] / 3, g.0[2] / 3, 255])
};
out.put_pixel(x, y, px);
}
out
}
fn side_by_side(golden: &RgbaImage, actual: &RgbaImage, diff: &RgbaImage) -> RgbaImage {
const GAP: u32 = 4;
let (w, h) = golden.dimensions();
let mut out = RgbaImage::from_pixel(w * 3 + GAP * 2, h, image::Rgba([128, 128, 128, 255]));
for (i, img) in [golden, actual, diff].into_iter().enumerate() {
#[expect(clippy::cast_possible_truncation, reason = "three panes")]
let x0 = (w + GAP) * i as u32;
for (x, y, px) in img.enumerate_pixels() {
out.put_pixel(x0 + x, y, *px);
}
}
out
}