tairitsu_packager/visual_diff/
mod.rs1use 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 { "✔ PASS" } else { "✘ 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('&', "&")
352 .replace('<', "<")
353 .replace('>', ">")
354 .replace('"', """)
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}