use super::{FFramesRendererError, FFramesRendererResult, FrameRenderer, Previewer, RgbaFrame};
use crate::Video;
use serde::Serialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct SnapshotOptions {
pub directory: PathBuf,
pub channel_threshold: u8,
pub max_diff_ratio: f64,
pub update: bool,
}
impl Default for SnapshotOptions {
fn default() -> Self {
Self {
directory: PathBuf::from("_frame_snapshots"),
channel_threshold: 16,
max_diff_ratio: 0.001,
update: std::env::var("FFRAMES_UPDATE_SNAPSHOTS")
.is_ok_and(|v| v != "0" && !v.is_empty()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SnapshotStatus {
Matched,
Created,
Updated,
Failed,
}
#[derive(Debug, Clone, Serialize)]
pub struct SnapshotResult {
pub spec: String,
pub frame: usize,
pub status: SnapshotStatus,
pub snapshot: PathBuf,
pub diff_ratio: f64,
pub actual: Option<PathBuf>,
pub diff: Option<PathBuf>,
}
pub fn snapshot_name(spec: &str) -> String {
let name: String = spec
.chars()
.map(|c| match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' => c.to_ascii_lowercase(),
'%' => 'p',
_ => '_',
})
.collect();
name.trim_matches('_').to_owned()
}
pub fn diff_frames(
expected: &RgbaFrame,
actual: &RgbaFrame,
channel_threshold: u8,
) -> (f64, Option<RgbaFrame>) {
if expected.width != actual.width || expected.height != actual.height {
return (1., None);
}
let mut differing = 0usize;
let mut diff = Vec::with_capacity(expected.pixels.len());
for (e, a) in expected
.pixels
.as_chunks::<4>()
.0
.iter()
.zip(actual.pixels.as_chunks::<4>().0)
{
let differs = e
.iter()
.zip(a)
.any(|(e, a)| e.abs_diff(*a) > channel_threshold);
if differs {
differing += 1;
diff.extend_from_slice(&[255, 0, 0, 255]);
} else {
let gray = ((e[0] as u32 + e[1] as u32 + e[2] as u32) / 3 / 4) as u8;
diff.extend_from_slice(&[gray, gray, gray, 255]);
}
}
let total = (expected.width * expected.height).max(1) as f64;
(
differing as f64 / total,
Some(RgbaFrame {
width: expected.width,
height: expected.height,
pixels: diff,
}),
)
}
fn load_png(path: &Path) -> FFramesRendererResult<RgbaFrame> {
let image = image::open(path)
.map_err(|err| FFramesRendererError::ImageError((path.display().to_string(), err)))?
.into_rgba8();
Ok(RgbaFrame {
width: image.width(),
height: image.height(),
pixels: image.into_raw(),
})
}
pub fn check_frame<'a, 'media: 'a, TVideo: Video>(
previewer: &mut Previewer<'a, 'media, TVideo>,
renderer: &mut dyn FrameRenderer,
spec: &str,
options: &SnapshotOptions,
) -> FFramesRendererResult<SnapshotResult> {
let frame = previewer
.timeline()
.resolve_frame(spec)
.map_err(|err| FFramesRendererError::Custom(err.to_string()))?;
let actual = previewer.render(frame, renderer)?;
std::fs::create_dir_all(&options.directory)?;
let name = snapshot_name(spec);
let snapshot = options.directory.join(format!("{name}.png"));
let actual_path = options.directory.join(format!("{name}.actual.png"));
let diff_path = options.directory.join(format!("{name}.diff.png"));
let _ = std::fs::remove_file(&actual_path);
let _ = std::fs::remove_file(&diff_path);
let mut result = SnapshotResult {
spec: spec.to_owned(),
frame,
status: SnapshotStatus::Matched,
snapshot: snapshot.clone(),
diff_ratio: 0.,
actual: None,
diff: None,
};
if !snapshot.exists() {
actual.save_png(&snapshot)?;
result.status = SnapshotStatus::Created;
return Ok(result);
}
let expected = load_png(&snapshot)?;
let (ratio, diff) = diff_frames(&expected, &actual, options.channel_threshold);
result.diff_ratio = ratio;
if ratio <= options.max_diff_ratio {
return Ok(result);
}
if options.update {
actual.save_png(&snapshot)?;
result.status = SnapshotStatus::Updated;
return Ok(result);
}
actual.save_png(&actual_path)?;
result.actual = Some(actual_path);
if let Some(diff) = diff {
diff.save_png(&diff_path)?;
result.diff = Some(diff_path);
}
result.status = SnapshotStatus::Failed;
Ok(result)
}
pub fn assert_frames<'a, 'media: 'a, TVideo: Video>(
previewer: &mut Previewer<'a, 'media, TVideo>,
renderer: &mut dyn FrameRenderer,
specs: &[&str],
options: &SnapshotOptions,
) -> Vec<SnapshotResult> {
let results: Vec<SnapshotResult> = specs
.iter()
.map(|spec| {
check_frame(previewer, renderer, spec, options)
.unwrap_or_else(|err| panic!("snapshot {spec}: {err}"))
})
.collect();
let failed: Vec<String> = results
.iter()
.filter(|r| r.status == SnapshotStatus::Failed)
.map(|r| {
format!(
" {} (frame {}): {:.3}% of pixels differ, see {} and {}",
r.spec,
r.frame,
r.diff_ratio * 100.,
r.actual.as_deref().unwrap_or(Path::new("-")).display(),
r.diff.as_deref().unwrap_or(Path::new("-")).display(),
)
})
.collect();
if !failed.is_empty() {
panic!(
"{} frame snapshot(s) differ (FFRAMES_UPDATE_SNAPSHOTS=1 accepts them):\n{}",
failed.len(),
failed.join("\n")
);
}
results
}
#[cfg(test)]
mod tests {
use super::*;
fn solid(color: [u8; 4]) -> RgbaFrame {
RgbaFrame {
width: 4,
height: 4,
pixels: color.repeat(16),
}
}
#[test]
fn diff_ignores_small_differences() {
let (ratio, _) = diff_frames(
&solid([100, 100, 100, 255]),
&solid([110, 100, 100, 255]),
16,
);
assert_eq!(ratio, 0.);
let (ratio, diff) = diff_frames(
&solid([100, 100, 100, 255]),
&solid([200, 100, 100, 255]),
16,
);
assert_eq!(ratio, 1.);
assert_eq!(&diff.unwrap().pixels[..4], &[255, 0, 0, 255]);
}
#[test]
fn names_are_file_safe() {
assert_eq!(snapshot_name("Intro@1.5s"), "intro_1.5s");
assert_eq!(snapshot_name("50%"), "50p");
assert_eq!(snapshot_name("#3"), "3");
}
}