Skip to main content

tairitsu_packager/visual_diff/
mod.rs

1use anyhow::{Context, Result};
2use serde::Serialize;
3use std::{
4    fs,
5    path::{Path, PathBuf},
6};
7
8use image::{GenericImageView, ImageBuffer, Rgba};
9
10pub struct DiffConfig {
11    pub tolerance: f32,
12    pub output_dir: PathBuf,
13    pub baseline_dir: PathBuf,
14    pub generate_html: bool,
15    pub fail_on_diff: bool,
16}
17
18impl Default for DiffConfig {
19    fn default() -> Self {
20        Self {
21            tolerance: 0.01,
22            output_dir: PathBuf::from("target/visual-diff"),
23            baseline_dir: PathBuf::from("tests/visual/baseline"),
24            generate_html: true,
25            fail_on_diff: true,
26        }
27    }
28}
29
30#[derive(Debug, Clone, Serialize)]
31pub struct DiffResult {
32    pub name: String,
33    pub passed: bool,
34    pub pixel_diff_ratio: f32,
35    pub total_pixels: u64,
36    pub diff_pixels: u64,
37    pub baseline_path: Option<String>,
38    pub actual_path: String,
39    pub diff_image_path: Option<String>,
40    pub width: u32,
41    pub height: u32,
42}
43
44#[derive(Debug, Clone, Serialize)]
45pub struct DiffReport {
46    pub timestamp: String,
47    pub tolerance: f32,
48    pub total: usize,
49    pub passed: usize,
50    pub failed: usize,
51    pub results: Vec<DiffResult>,
52}
53
54#[allow(dead_code)]
55fn rgba_distance(a: &Rgba<u8>, b: &Rgba<u8>) -> f32 {
56    let dr = a[0].abs_diff(b[0]) as f32;
57    let dg = a[1].abs_diff(b[1]) as f32;
58    let db = a[2].abs_diff(b[2]) as f32;
59    let da = a[3].abs_diff(b[3]) as f32;
60    (dr * dr + dg * dg + db * db + da * da).sqrt() / 510.0
61}
62
63pub fn compare_images(baseline: &Path, actual: &Path, config: &DiffConfig) -> Result<DiffResult> {
64    let name = actual
65        .file_stem()
66        .and_then(|s| s.to_str())
67        .unwrap_or("unknown")
68        .to_string();
69
70    let baseline_img = image::open(baseline)
71        .with_context(|| format!("Failed to open baseline: {}", baseline.display()))?;
72    let actual_img = image::open(actual)
73        .with_context(|| format!("Failed to open actual: {}", actual.display()))?;
74
75    let (w, h) = baseline_img.dimensions();
76    let (aw, ah) = actual_img.dimensions();
77
78    if w != aw || h != ah {
79        return Ok(DiffResult {
80            name,
81            passed: false,
82            pixel_diff_ratio: 1.0,
83            total_pixels: (w * h) as u64,
84            diff_pixels: (w * h) as u64,
85            baseline_path: Some(baseline.display().to_string()),
86            actual_path: actual.display().to_string(),
87            diff_image_path: None,
88            width: w,
89            height: h,
90        });
91    }
92
93    let baseline_rgba = baseline_img.to_rgba8();
94    let actual_rgba = actual_img.to_rgba8();
95
96    let total_pixels = (w * h) as u64;
97    let mut diff_count: u64 = 0;
98
99    let mut diff_img = ImageBuffer::new(w, h);
100
101    for y in 0..h {
102        for x in 0..w {
103            let bp = baseline_rgba.get_pixel(x, y);
104            let ap = actual_rgba.get_pixel(x, y);
105
106            if bp != ap {
107                diff_count += 1;
108                let alpha_f = 128u16;
109                let inv_alpha = 256u16 - alpha_f;
110                let blended = Rgba([
111                    ((ap[0] as u16 * inv_alpha + 255u16 * alpha_f) >> 8) as u8,
112                    ((ap[1] as u16 * inv_alpha) >> 8) as u8,
113                    ((ap[2] as u16 * inv_alpha) >> 8) as u8,
114                    255u8,
115                ]);
116                diff_img.put_pixel(x, y, blended);
117            } else {
118                diff_img.put_pixel(x, y, *ap);
119            }
120        }
121    }
122
123    let ratio = if total_pixels > 0 {
124        diff_count as f32 / total_pixels as f32
125    } else {
126        0.0
127    };
128
129    let passed = ratio <= config.tolerance;
130
131    let diff_image_path = if !passed || config.generate_html {
132        let diff_filename = format!("{}_diff.png", name);
133        let diff_path = config.output_dir.join(&diff_filename);
134        fs::create_dir_all(&config.output_dir)?;
135        diff_img
136            .save(&diff_path)
137            .with_context(|| format!("Failed to save diff image: {}", diff_path.display()))?;
138        Some(diff_path.display().to_string())
139    } else {
140        None
141    };
142
143    Ok(DiffResult {
144        name,
145        passed,
146        pixel_diff_ratio: ratio,
147        total_pixels,
148        diff_pixels: diff_count,
149        baseline_path: Some(baseline.display().to_string()),
150        actual_path: actual.display().to_string(),
151        diff_image_path,
152        width: w,
153        height: h,
154    })
155}
156
157pub fn decode_screenshot(base64_data: &str, output: &Path) -> Result<()> {
158    let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, base64_data)
159        .context("Failed to decode base64 screenshot data")?;
160    fs::create_dir_all(
161        output
162            .parent()
163            .context("Output path has no parent directory")?,
164    )?;
165    fs::write(output, bytes)?;
166    Ok(())
167}
168
169pub fn run_visual_diff(actual_images: &[PathBuf], config: &DiffConfig) -> Result<DiffReport> {
170    fs::create_dir_all(&config.output_dir)?;
171    fs::create_dir_all(&config.baseline_dir)?;
172
173    let mut results = Vec::new();
174
175    for actual_path in actual_images {
176        let name = actual_path
177            .file_name()
178            .and_then(|n| n.to_str())
179            .unwrap_or("unknown")
180            .to_string();
181
182        let baseline_path = config
183            .baseline_dir
184            .join(actual_path.file_name().context("Missing filename")?);
185
186        if !baseline_path.exists() {
187            results.push(DiffResult {
188                name: name.clone(),
189                passed: false,
190                pixel_diff_ratio: 1.0,
191                total_pixels: 0,
192                diff_pixels: 0,
193                baseline_path: None,
194                actual_path: actual_path.display().to_string(),
195                diff_image_path: None,
196                width: 0,
197                height: 0,
198            });
199            eprintln!("  MISSING BASELINE: {} (copy to create baseline)", name);
200            continue;
201        }
202
203        match compare_images(&baseline_path, actual_path, config) {
204            Ok(result) => {
205                let status = if result.passed { "PASS" } else { "FAIL" };
206                eprintln!(
207                    "  {}: {} ({:.4}% diff, {}/{})",
208                    result.name,
209                    status,
210                    result.pixel_diff_ratio * 100.0,
211                    result.diff_pixels,
212                    result.total_pixels
213                );
214                results.push(result);
215            }
216            Err(e) => {
217                eprintln!("  ERROR: {} - {}", name, e);
218                results.push(DiffResult {
219                    name,
220                    passed: false,
221                    pixel_diff_ratio: 1.0,
222                    total_pixels: 0,
223                    diff_pixels: 0,
224                    baseline_path: Some(baseline_path.display().to_string()),
225                    actual_path: actual_path.display().to_string(),
226                    diff_image_path: None,
227                    width: 0,
228                    height: 0,
229                });
230            }
231        }
232    }
233
234    let passed = results.iter().filter(|r| r.passed).count();
235    let failed = results.len() - passed;
236
237    let report = DiffReport {
238        timestamp: chrono::Utc::now().to_rfc3339(),
239        tolerance: config.tolerance,
240        total: results.len(),
241        passed,
242        failed,
243        results: results.clone(),
244    };
245
246    if config.generate_html {
247        generate_html_report(&report, config)?;
248    }
249
250    let json_path = config.output_dir.join("report.json");
251    fs::write(&json_path, serde_json::to_string_pretty(&report)?)
252        .with_context(|| format!("Failed to write report: {}", json_path.display()))?;
253
254    Ok(report)
255}
256
257fn generate_html_report(report: &DiffReport, config: &DiffConfig) -> Result<()> {
258    let html = build_html_report(report, config);
259    let path = config.output_dir.join("index.html");
260    fs::write(&path, html)?;
261    Ok(())
262}
263
264fn build_html_report(report: &DiffReport, _config: &DiffConfig) -> String {
265    let _status_class = if report.failed == 0 { "pass" } else { "fail" };
266    let rows: Vec<String> = report.results.iter().map(|r| {
267        let cls = if r.passed { "pass" } else { "fail" };
268        let diff_img = match &r.diff_image_path {
269            Some(p) => {
270                let p_rel = Path::new(p).file_name()
271                    .and_then(|n| n.to_str()).unwrap_or("");
272                format!(
273                    r#"<div class="slider-wrap"><div class="slider"><img src="{}" class="diff-img"/></div></div>"#,
274                    p_rel
275                )
276            }
277            None => "<span class=\"no-diff\">No diff image</span>".into(),
278        };
279        format!(
280            r#"<tr class="{}">
281      <td>{}</td>
282      <td>{}</td>
283      <td>{:.4}%</td>
284      <td>{}</td>
285      <td>{}</td>
286      <td class="diff-cell">{}</td>
287    </tr>"#,
288            cls,
289            html_escape(&r.name),
290            if r.passed { "&#10004; PASS" } else { "&#10008; FAIL" },
291            r.pixel_diff_ratio * 100.0,
292            r.diff_pixels,
293            r.total_pixels,
294            diff_img
295        )
296    }).collect();
297
298    format!(
299        r#"<!DOCTYPE html>
300<html lang="en">
301<head>
302<meta charset="UTF-8"/>
303<title>Visual Regression Report</title>
304<style>
305  body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; background: #f8f9fa; color: #333; }}
306  h1 {{ margin-bottom: 4px; }}
307  .meta {{ color: #666; margin-bottom: 16px; }}
308  table {{ border-collapse: collapse; width: 100%; background: white; box-shadow: 0 1px 3px rgba(0,0,0,.12); }}
309  th {{ background: #555; color: white; padding: 10px 14px; text-align: left; }}
310  td {{ padding: 8px 14px; border-bottom: 1px solid #eee; vertical-align: middle; }}
311  tr:hover {{ background: #fafafa; }}
312  tr.pass td:first-child {{ border-left: 3px solid #28a745; }}
313  tr.fail td:first-child {{ border-left: 3px solid #dc3545; }}
314  .pass {{ color: #28a745; }} .fail {{ color: #dc3545; }}
315  .summary {{ display: inline-flex; gap: 24px; margin: 16px 0; font-size: 15px; }}
316  .summary strong {{ font-size: 18px; }}
317  .slider-wrap {{ max-width: 400px; overflow-x: auto; }}
318  .slider img {{ max-width: 360px; height: auto; border: 1px solid #ddd; }}
319  .diff-cell {{ min-width: 380px; }}
320  .no-diff {{ color: #999; font-style: italic; }}
321  .badge {{ display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 13px; font-weight: 600; }}
322  .badge-pass {{ background: #d4edda; color: #155724; }}
323  .badge-fail {{ background: #f8d7da; color: #721c24; }}
324</style>
325</head>
326<body>
327<h1>Visual Regression Report</h1>
328<p class="meta">{}</p>
329<div class="summary">
330  <span>Total: <strong>{}</strong></span>
331  <span class="pass">Passed: <strong>{}</strong></span>
332  <span class="fail">Failed: <strong>{}</strong></span>
333  <span>Tolerance: <strong>{:.1}%</strong></span>
334</div>
335<table>
336  <thead><tr><th>Name</th><th>Status</th><th>Diff Ratio</th><th>Different Pixels</th><th>Total Pixels</th><th>Diff Image</th></tr></thead>
337  <tbody>{}</tbody>
338</table>
339</body>
340</html>"#,
341        report.timestamp,
342        report.total,
343        report.passed,
344        report.failed,
345        report.tolerance * 100.0,
346        rows.join("\n")
347    )
348}
349
350fn html_escape(s: &str) -> String {
351    s.replace('&', "&amp;")
352        .replace('<', "&lt;")
353        .replace('>', "&gt;")
354        .replace('"', "&quot;")
355}
356
357pub fn update_baseline(actual_images: &[PathBuf], baseline_dir: &Path) -> Result<usize> {
358    fs::create_dir_all(baseline_dir)?;
359    let mut count = 0;
360    for path in actual_images {
361        let filename = path.file_name().context("Missing filename")?;
362        let dest = baseline_dir.join(filename);
363        fs::copy(path, &dest)?;
364        count += 1;
365    }
366    Ok(count)
367}