1use image::{Rgba, RgbaImage};
27
28use crate::backend::BlobPair;
29
30const CHECK_SIZE: u32 = 8;
32const CHECK_LIGHT: [u8; 3] = [0xFF, 0xFF, 0xFF];
33const CHECK_DARK: [u8; 3] = [0xCC, 0xCC, 0xCC];
34const TWO_UP_SEP: u32 = 4;
36const SEP_COLOR: Rgba<u8> = Rgba([0x80, 0x80, 0x80, 0xFF]);
37const SWIPE_DIVIDER: Rgba<u8> = Rgba([0xFF, 0xDC, 0x00, 0xFF]);
39
40const IMAGE_EXTENSIONS: &[&str] = &[
43 "png", "jpg", "jpeg", "gif", "bmp", "webp", "tiff", "tif", "ico", "qoi", "tga", "pnm", "ppm",
44 "pgm", "pbm",
45];
46
47pub fn is_image_path(path: &str) -> bool {
49 path.rsplit('.')
50 .next()
51 .filter(|_| path.contains('.'))
52 .map(|ext| ext.to_ascii_lowercase())
53 .is_some_and(|ext| IMAGE_EXTENSIONS.contains(&ext.as_str()))
54}
55
56#[derive(Clone, Copy, PartialEq, Eq, Debug)]
58pub enum CompareMode {
59 TwoUp,
60 Swipe,
61 Onion,
62 Difference,
63 Left,
64 Right,
65}
66
67impl CompareMode {
68 pub const ALL: [CompareMode; 6] = [
71 CompareMode::TwoUp,
72 CompareMode::Swipe,
73 CompareMode::Onion,
74 CompareMode::Difference,
75 CompareMode::Left,
76 CompareMode::Right,
77 ];
78
79 pub fn label(self) -> &'static str {
81 match self {
82 CompareMode::TwoUp => "2-Up",
83 CompareMode::Swipe => "Swipe",
84 CompareMode::Onion => "Onion",
85 CompareMode::Difference => "Diff",
86 CompareMode::Left => "Left",
87 CompareMode::Right => "Right",
88 }
89 }
90
91 pub fn next(self) -> Self {
94 match self {
95 CompareMode::TwoUp => CompareMode::Swipe,
96 CompareMode::Swipe => CompareMode::Onion,
97 CompareMode::Onion => CompareMode::Difference,
98 CompareMode::Difference => CompareMode::TwoUp,
99 CompareMode::Left | CompareMode::Right => CompareMode::TwoUp,
100 }
101 }
102
103 pub fn uses_slider(self) -> bool {
105 matches!(self, CompareMode::Swipe | CompareMode::Onion)
106 }
107
108 pub fn is_single(self) -> bool {
110 matches!(self, CompareMode::Left | CompareMode::Right)
111 }
112}
113
114pub struct Canvas {
117 pub w: u32,
118 pub h: u32,
119 pub argb: Vec<u32>,
121}
122
123impl Canvas {
124 fn empty() -> Self {
125 Canvas {
126 w: 0,
127 h: 0,
128 argb: Vec::new(),
129 }
130 }
131}
132
133const SLIDER_STEPS: u32 = 1000;
137
138struct RenderCache {
144 mode: CompareMode,
145 slider_key: u32,
148 box_w: u32,
149 box_h: u32,
150 canvas: Canvas,
151}
152
153struct FitCache {
156 box_w: u32,
157 box_h: u32,
158 norm_old: RgbaImage,
161 norm_new: RgbaImage,
162 diff: RgbaImage,
164}
165
166pub struct ImageComparison {
168 old: Option<RgbaImage>,
169 new: Option<RgbaImage>,
170 meta: String,
171 cache: Option<FitCache>,
172 render_cache: Option<RenderCache>,
173}
174
175impl ImageComparison {
176 pub fn from_blobs(blobs: &BlobPair) -> Option<Self> {
179 let old = blobs.old.as_deref().and_then(decode);
180 let new = blobs.new.as_deref().and_then(decode);
181 if old.is_none() && new.is_none() {
182 return None;
183 }
184 let meta = meta_line(blobs, old.as_ref(), new.as_ref());
185 Some(ImageComparison {
186 old,
187 new,
188 meta,
189 cache: None,
190 render_cache: None,
191 })
192 }
193
194 pub fn meta(&self) -> &str {
196 &self.meta
197 }
198
199 pub fn render(&mut self, mode: CompareMode, slider: f32, box_w: u32, box_h: u32) -> &Canvas {
204 let slider_key = if mode.uses_slider() {
205 (slider.clamp(0.0, 1.0) * SLIDER_STEPS as f32).round() as u32
206 } else {
207 0
208 };
209 let hit = self.render_cache.as_ref().is_some_and(|c| {
210 c.mode == mode && c.slider_key == slider_key && c.box_w == box_w && c.box_h == box_h
211 });
212 if !hit {
213 let canvas = self.compose(mode, slider, box_w, box_h);
214 self.render_cache = Some(RenderCache {
215 mode,
216 slider_key,
217 box_w,
218 box_h,
219 canvas,
220 });
221 }
222 &self
223 .render_cache
224 .as_ref()
225 .expect("cache just populated")
226 .canvas
227 }
228
229 fn compose(&mut self, mode: CompareMode, slider: f32, box_w: u32, box_h: u32) -> Canvas {
231 if box_w == 0 || box_h == 0 {
232 return Canvas::empty();
233 }
234 let composed = match mode {
235 CompareMode::TwoUp => self.two_up(box_w, box_h),
236 CompareMode::Left => single(self.old.as_ref(), box_w, box_h),
237 CompareMode::Right => single(self.new.as_ref(), box_w, box_h),
238 CompareMode::Swipe | CompareMode::Onion | CompareMode::Difference => {
239 self.ensure_cache(box_w, box_h);
240 let c = self.cache.as_ref().expect("cache just built");
241 match mode {
242 CompareMode::Swipe => compose_swipe(&c.norm_old, &c.norm_new, slider),
243 CompareMode::Onion => compose_onion(&c.norm_old, &c.norm_new, slider),
244 CompareMode::Difference => c.diff.clone(),
245 _ => unreachable!(),
246 }
247 }
248 };
249 flatten_to_canvas(&composed)
250 }
251
252 fn two_up(&self, box_w: u32, box_h: u32) -> RgbaImage {
254 let half = box_w.saturating_sub(TWO_UP_SEP) / 2;
255 let left = single(self.old.as_ref(), half.max(1), box_h);
256 let right = single(self.new.as_ref(), half.max(1), box_h);
257 let h = left.height().max(right.height());
258 let w = left.width() + TWO_UP_SEP + right.width();
259 let mut canvas = RgbaImage::new(w, h);
260 for y in 0..h {
262 for x in left.width()..(left.width() + TWO_UP_SEP) {
263 canvas.put_pixel(x, y, SEP_COLOR);
264 }
265 }
266 let lw = left.width();
267 image::imageops::overlay(&mut canvas, &left, 0, 0);
268 image::imageops::overlay(&mut canvas, &right, (lw + TWO_UP_SEP) as i64, 0);
269 canvas
270 }
271
272 fn ensure_cache(&mut self, box_w: u32, box_h: u32) {
274 if self
275 .cache
276 .as_ref()
277 .is_some_and(|c| c.box_w == box_w && c.box_h == box_h)
278 {
279 return;
280 }
281 let (na, nb) = normalize_pair(self.old.as_ref(), self.new.as_ref());
282 let (norm_old, norm_new) = scale_pair(&na, &nb, box_w, box_h);
283 let diff = scale_fit(
284 &build_diff_heatmap(self.old.as_ref(), self.new.as_ref()),
285 box_w,
286 box_h,
287 );
288 self.cache = Some(FitCache {
289 box_w,
290 box_h,
291 norm_old,
292 norm_new,
293 diff,
294 });
295 }
296}
297
298fn decode(bytes: &[u8]) -> Option<RgbaImage> {
300 image::load_from_memory(bytes)
301 .ok()
302 .map(|img| img.to_rgba8())
303}
304
305fn scale_fit(img: &RgbaImage, max_w: u32, max_h: u32) -> RgbaImage {
308 let (w, h) = (img.width(), img.height());
309 if w == 0 || h == 0 || (w <= max_w && h <= max_h) {
310 return img.clone();
311 }
312 let scale = (max_w as f64 / w as f64).min(max_h as f64 / h as f64);
313 let nw = ((w as f64 * scale) as u32).max(1);
314 let nh = ((h as f64 * scale) as u32).max(1);
315 image::imageops::resize(img, nw, nh, image::imageops::FilterType::Triangle)
316}
317
318fn single(img: Option<&RgbaImage>, box_w: u32, box_h: u32) -> RgbaImage {
321 match img {
322 Some(img) => scale_fit(img, box_w, box_h),
323 None => RgbaImage::new(box_w.max(1), box_h.max(1)),
324 }
325}
326
327fn normalize_pair(a: Option<&RgbaImage>, b: Option<&RgbaImage>) -> (RgbaImage, RgbaImage) {
330 let w = a
331 .map_or(0, |i| i.width())
332 .max(b.map_or(0, |i| i.width()))
333 .max(1);
334 let h = a
335 .map_or(0, |i| i.height())
336 .max(b.map_or(0, |i| i.height()))
337 .max(1);
338 let mut out_a = RgbaImage::new(w, h);
339 let mut out_b = RgbaImage::new(w, h);
340 if let Some(a) = a {
341 image::imageops::overlay(&mut out_a, a, 0, 0);
342 }
343 if let Some(b) = b {
344 image::imageops::overlay(&mut out_b, b, 0, 0);
345 }
346 (out_a, out_b)
347}
348
349fn scale_pair(a: &RgbaImage, b: &RgbaImage, max_w: u32, max_h: u32) -> (RgbaImage, RgbaImage) {
351 let (w, h) = (a.width(), a.height());
352 if w == 0 || h == 0 || (w <= max_w && h <= max_h) {
353 return (a.clone(), b.clone());
354 }
355 let scale = (max_w as f64 / w as f64).min(max_h as f64 / h as f64);
356 let nw = ((w as f64 * scale) as u32).max(1);
357 let nh = ((h as f64 * scale) as u32).max(1);
358 let f = image::imageops::FilterType::Triangle;
359 (
360 image::imageops::resize(a, nw, nh, f),
361 image::imageops::resize(b, nw, nh, f),
362 )
363}
364
365fn compose_swipe(a: &RgbaImage, b: &RgbaImage, t: f32) -> RgbaImage {
368 let (w, h) = a.dimensions();
369 let split = ((w as f32) * t.clamp(0.0, 1.0)) as u32;
370 let mut out = RgbaImage::new(w, h);
371 for y in 0..h {
372 for x in 0..w {
373 let px = if x < split { a } else { b }.get_pixel(x, y);
374 out.put_pixel(x, y, *px);
375 }
376 if split < w {
377 out.put_pixel(split, y, SWIPE_DIVIDER);
378 }
379 }
380 out
381}
382
383fn compose_onion(a: &RgbaImage, b: &RgbaImage, t: f32) -> RgbaImage {
385 let t = (t.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
386 let inv = 255 - t;
387 let (w, h) = a.dimensions();
388 let ra = a.as_raw();
389 let rb = b.as_raw();
390 let mut out = vec![0u8; ra.len()];
391 for i in 0..ra.len() {
392 out[i] = ((ra[i] as u32 * inv + rb[i] as u32 * t + 127) / 255) as u8;
393 }
394 RgbaImage::from_raw(w, h, out).expect("onion buffer matches dimensions")
395}
396
397fn build_diff_heatmap(a: Option<&RgbaImage>, b: Option<&RgbaImage>) -> RgbaImage {
401 let aw = a.map_or(0, |i| i.width());
402 let ah = a.map_or(0, |i| i.height());
403 let bw = b.map_or(0, |i| i.width());
404 let bh = b.map_or(0, |i| i.height());
405 let w = aw.max(bw).max(1);
406 let h = ah.max(bh).max(1);
407 let mut out = RgbaImage::from_pixel(w, h, Rgba([0xFF, 0x00, 0xFF, 0xFF]));
408
409 let (Some(a), Some(b)) = (a, b) else {
411 return out;
412 };
413 let common_w = aw.min(bw);
414 let common_h = ah.min(bh);
415 for y in 0..common_h {
416 for x in 0..common_w {
417 let pa = a.get_pixel(x, y).0;
418 let pb = b.get_pixel(x, y).0;
419 let dr = (pa[0] as i16 - pb[0] as i16).unsigned_abs() as u32;
420 let dg = (pa[1] as i16 - pb[1] as i16).unsigned_abs() as u32;
421 let db = (pa[2] as i16 - pb[2] as i16).unsigned_abs() as u32;
422 let diff = (77 * dr + 151 * dg + 28 * db) as f32 / (255.0 * 256.0);
423 out.put_pixel(x, y, heatmap_color(diff));
424 }
425 }
426 out
427}
428
429fn heatmap_color(t: f32) -> Rgba<u8> {
431 let t = t.clamp(0.0, 1.0);
432 let (r, g, b) = if t < 0.25 {
433 (0.0, 0.0, t / 0.25)
434 } else if t < 0.5 {
435 let s = (t - 0.25) / 0.25;
436 (0.0, s, 1.0 - s)
437 } else if t < 0.75 {
438 let s = (t - 0.5) / 0.25;
439 (s, 1.0, 0.0)
440 } else {
441 let s = (t - 0.75) / 0.25;
442 (1.0, 1.0 - s, 0.0)
443 };
444 Rgba([(r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255])
445}
446
447fn flatten_to_canvas(img: &RgbaImage) -> Canvas {
450 let (w, h) = img.dimensions();
451 let mut argb = vec![0u32; (w * h) as usize];
452 for y in 0..h {
453 for x in 0..w {
454 let px = img.get_pixel(x, y).0;
455 let a = px[3] as u32;
456 let bg = checker(x, y);
457 let blend = |s: u8, d: u8| (s as u32 * a + d as u32 * (255 - a)) / 255;
458 let r = blend(px[0], bg[0]);
459 let g = blend(px[1], bg[1]);
460 let b = blend(px[2], bg[2]);
461 argb[(y * w + x) as usize] = 0xFF00_0000 | (r << 16) | (g << 8) | b;
462 }
463 }
464 Canvas { w, h, argb }
465}
466
467fn checker(x: u32, y: u32) -> [u8; 3] {
469 if ((x / CHECK_SIZE) + (y / CHECK_SIZE)).is_multiple_of(2) {
470 CHECK_LIGHT
471 } else {
472 CHECK_DARK
473 }
474}
475
476fn meta_line(blobs: &BlobPair, old: Option<&RgbaImage>, new: Option<&RgbaImage>) -> String {
479 let fmt = pair_str(
480 blobs.old.as_deref().and_then(format_name),
481 blobs.new.as_deref().and_then(format_name),
482 );
483 let dims = pair_str(
484 old.map(|i| format!("{}x{}", i.width(), i.height())),
485 new.map(|i| format!("{}x{}", i.width(), i.height())),
486 );
487 let size = pair_str(
488 blobs.old.as_ref().map(|b| human_size(b.len())),
489 blobs.new.as_ref().map(|b| human_size(b.len())),
490 );
491 [fmt, dims, size]
492 .into_iter()
493 .flatten()
494 .collect::<Vec<_>>()
495 .join(" ")
496}
497
498fn pair_str(a: Option<String>, b: Option<String>) -> Option<String> {
501 match (a, b) {
502 (Some(a), Some(b)) if a == b => Some(a),
503 (Some(a), Some(b)) => Some(format!("{a}→{b}")),
504 (Some(a), None) | (None, Some(a)) => Some(a),
505 (None, None) => None,
506 }
507}
508
509fn format_name(bytes: &[u8]) -> Option<String> {
511 use image::ImageFormat as F;
512 let name = match image::guess_format(bytes).ok()? {
513 F::Png => "PNG",
514 F::Jpeg => "JPEG",
515 F::Gif => "GIF",
516 F::WebP => "WebP",
517 F::Bmp => "BMP",
518 F::Tiff => "TIFF",
519 F::Ico => "ICO",
520 F::Pnm => "PNM",
521 F::Tga => "TGA",
522 F::Qoi => "QOI",
523 _ => return None,
524 };
525 Some(name.to_string())
526}
527
528fn human_size(bytes: usize) -> String {
530 const KIB: f64 = 1024.0;
531 const MIB: f64 = KIB * 1024.0;
532 let b = bytes as f64;
533 if b >= MIB {
534 format!("{:.1}MiB", b / MIB)
535 } else if b >= KIB {
536 format!("{:.1}KiB", b / KIB)
537 } else {
538 format!("{bytes}B")
539 }
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545
546 fn png(w: u32, h: u32, color: [u8; 4]) -> Vec<u8> {
548 let img = RgbaImage::from_pixel(w, h, Rgba(color));
549 let mut bytes = Vec::new();
550 image::DynamicImage::ImageRgba8(img)
551 .write_to(
552 &mut std::io::Cursor::new(&mut bytes),
553 image::ImageFormat::Png,
554 )
555 .unwrap();
556 bytes
557 }
558
559 #[test]
560 fn recognizes_image_extensions() {
561 assert!(is_image_path("a/b/logo.png"));
562 assert!(is_image_path("ICON.PNG"));
563 assert!(is_image_path("photo.jpeg"));
564 assert!(!is_image_path("src/main.rs"));
565 assert!(!is_image_path("drawing.svg")); assert!(!is_image_path("Makefile"));
567 }
568
569 #[test]
570 fn from_blobs_none_when_undecodable() {
571 let blobs = BlobPair {
572 old: Some(b"not an image".to_vec()),
573 new: Some(b"still not".to_vec()),
574 };
575 assert!(ImageComparison::from_blobs(&blobs).is_none());
576 }
577
578 #[test]
579 fn renders_every_mode_to_opaque_canvas() {
580 let blobs = BlobPair {
581 old: Some(png(8, 8, [255, 0, 0, 255])),
582 new: Some(png(8, 8, [0, 0, 255, 255])),
583 };
584 let mut cmp = ImageComparison::from_blobs(&blobs).expect("decodes");
585 for mode in CompareMode::ALL {
586 let canvas = cmp.render(mode, 0.5, 64, 64);
587 assert!(canvas.w > 0 && canvas.h > 0, "{mode:?} produced no canvas");
588 assert_eq!(canvas.argb.len(), (canvas.w * canvas.h) as usize);
589 assert!(
591 canvas.argb.iter().all(|p| p >> 24 == 0xFF),
592 "{mode:?} left transparent pixels"
593 );
594 }
595 }
596
597 #[test]
598 fn difference_of_identical_images_is_black() {
599 let bytes = png(8, 8, [10, 200, 30, 255]);
600 let blobs = BlobPair {
601 old: Some(bytes.clone()),
602 new: Some(bytes),
603 };
604 let mut cmp = ImageComparison::from_blobs(&blobs).expect("decodes");
605 let canvas = cmp.render(CompareMode::Difference, 0.0, 32, 32);
606 assert!(canvas.argb.iter().all(|&p| p == 0xFF00_0000));
608 }
609
610 #[test]
611 fn render_reuses_the_cached_canvas_until_the_key_changes() {
612 let blobs = BlobPair {
613 old: Some(png(8, 8, [255, 0, 0, 255])),
614 new: Some(png(8, 8, [0, 0, 255, 255])),
615 };
616 let mut cmp = ImageComparison::from_blobs(&blobs).expect("decodes");
617 let ptr = |c: &mut ImageComparison, m, s, w, h| c.render(m, s, w, h).argb.as_ptr();
621
622 let a = ptr(&mut cmp, CompareMode::TwoUp, 0.5, 64, 64);
623 assert_eq!(
624 a,
625 ptr(&mut cmp, CompareMode::TwoUp, 0.5, 64, 64),
626 "same key reuses the cached canvas"
627 );
628 assert_eq!(
629 a,
630 ptr(&mut cmp, CompareMode::TwoUp, 0.9, 64, 64),
631 "the slider is irrelevant in 2-up, so the cache stays warm"
632 );
633 let b = ptr(&mut cmp, CompareMode::TwoUp, 0.5, 80, 64);
634 assert_ne!(a, b, "a different box size recomposes");
635 let c = ptr(&mut cmp, CompareMode::Swipe, 0.5, 80, 64);
636 assert_ne!(b, c, "a different mode recomposes");
637 assert_eq!(
638 c,
639 ptr(&mut cmp, CompareMode::Swipe, 0.5, 80, 64),
640 "a slider mode caches at a fixed slider position"
641 );
642 assert_ne!(
643 c,
644 ptr(&mut cmp, CompareMode::Swipe, 0.95, 80, 64),
645 "moving the slider in a slider mode recomposes"
646 );
647 }
648
649 #[test]
650 fn added_image_has_one_side_and_collapsed_meta() {
651 let blobs = BlobPair {
652 old: None,
653 new: Some(png(16, 16, [0, 0, 0, 255])),
654 };
655 let mut cmp = ImageComparison::from_blobs(&blobs).expect("decodes");
656 assert!(cmp.meta().contains("16x16"));
657 let left = cmp.render(CompareMode::Left, 0.0, 32, 32);
659 assert!(left.w > 0 && left.h > 0);
660 }
661}