Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
73feb96
feat(metrics): dispatch collection by platform (android, ios-sim, uns…
BlueShork Jun 28, 2026
a97dcf7
fix(metrics): restart capture on device switch; harden ios-sim loop
BlueShork Jun 28, 2026
e156661
feat(bank): module + device_key + image crate
BlueShork Jun 28, 2026
d3aca23
feat(bank): extract takeScreenshot names from flow yaml
BlueShork Jun 28, 2026
b1aea90
feat(bank): YIQ pixelmatch diff core with bbox + diff png
BlueShork Jun 28, 2026
0a2a7db
feat(bank): compare_flow orchestration (seed/match/changed/missing/di…
BlueShork Jun 28, 2026
877d404
feat(bank): compare_screenshots + resolve_comparison tauri commands
BlueShork Jun 28, 2026
de93e78
feat(runner): set CWD to flow directory so takeScreenshot lands next …
BlueShork Jun 28, 2026
ca21380
feat(settings): visual regression thresholds store
BlueShork Jun 28, 2026
8a12837
feat(ipc): bindings + types for screenshot comparison
BlueShork Jun 28, 2026
342ab0d
feat(settings): visual regression thresholds section
BlueShork Jun 28, 2026
fcea8fb
feat(review): trigger comparison on successful single-flow run, queue…
BlueShork Jun 28, 2026
d7cb7f4
test(settings): include visual-regression section in expected order
BlueShork Jun 28, 2026
f3d9999
feat(review): screenshot comparison review modal (keep/replace)
BlueShork Jun 28, 2026
f1a46ae
polish(review): refined regression review UX + English copy, inflight…
BlueShork Jun 28, 2026
5a03fa5
fix(bank): gitignore+prune .runs, surface resolve failures, summarize…
BlueShork Jun 28, 2026
6666e6d
feat(settings): master toggle to enable/disable visual regression
BlueShork Jun 29, 2026
b7f78ee
style: prettier format bank appendLog lines in App.tsx
BlueShork Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ hex = "0.4"
# YAML for Maestro flow generation
serde_yaml = "0.9"

# PNG image codec (screenshot regression bank)
image = { version = "0.25", default-features = false, features = ["png"] }

# gRPC client for talking directly to the on-device Maestro driver
# (bypasses the slow `maestro hierarchy` CLI invocation path when the
# driver is kept alive by a background `maestro studio` process).
Expand Down
269 changes: 269 additions & 0 deletions src-tauri/src/bank/compare.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
// Copyright (c) 2026 Ethan Morisset
// SPDX-License-Identifier: BUSL-1.1

use std::fs;
use std::path::Path;

use base64::Engine;

use crate::bank::device_key;
use crate::bank::diff::diff_images;
use crate::bank::flow::screenshot_names;

#[derive(serde::Serialize, Clone)]
#[serde(rename_all = "snake_case")]
pub enum Status {
Seeded,
Match,
Changed,
Missing,
DimensionMismatch,
}

#[derive(serde::Serialize, Clone)]
pub struct Comparison {
pub name: String,
pub status: Status,
pub changed_ratio: f32,
pub bbox: Option<[u32; 4]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bank_b64: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_b64: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub diff_b64: Option<String>,
}

pub struct CompareInput<'a> {
pub workspace: &'a Path,
pub flow_path: &'a Path,
pub model: &'a str,
pub width: u32,
pub height: u32,
pub tolerance: f64,
pub threshold: f64,
}

fn b64(bytes: &[u8]) -> String {
format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
)
}

fn dims(png: &[u8]) -> Option<(u32, u32)> {
image::load_from_memory(png)
.ok()
.map(|i| (i.width(), i.height()))
}

pub fn compare_flow(input: CompareInput) -> std::io::Result<(String, Vec<Comparison>)> {
let key = device_key(input.model, input.width, input.height);
let bank_dir = input.workspace.join("maestro").join("bank").join(&key);
fs::create_dir_all(&bank_dir)?;

let flow_dir = input.flow_path.parent().unwrap_or(Path::new("."));
let yaml = fs::read_to_string(input.flow_path).unwrap_or_default();
let names = screenshot_names(&yaml);

let mut comps = Vec::new();
for name in names {
let produced = flow_dir.join(format!("{name}.png"));
let reference = bank_dir.join(format!("{name}.png"));

if !produced.exists() {
comps.push(Comparison {
name,
status: Status::Missing,
changed_ratio: 0.0,
bbox: None,
bank_b64: None,
new_b64: None,
diff_b64: None,
});
continue;
}
let new_bytes = fs::read(&produced)?;

if !reference.exists() {
fs::copy(&produced, &reference)?;
comps.push(Comparison {
name,
status: Status::Seeded,
changed_ratio: 0.0,
bbox: None,
bank_b64: None,
new_b64: None,
diff_b64: None,
});
continue;
}
let bank_bytes = fs::read(&reference)?;

if dims(&bank_bytes) != dims(&new_bytes) {
comps.push(Comparison {
name,
status: Status::DimensionMismatch,
changed_ratio: 0.0,
bbox: None,
bank_b64: Some(b64(&bank_bytes)),
new_b64: Some(b64(&new_bytes)),
diff_b64: None,
});
continue;
}

match diff_images(&bank_bytes, &new_bytes, input.tolerance) {
Ok(out) if out.changed_ratio as f64 > input.threshold => comps.push(Comparison {
name,
status: Status::Changed,
changed_ratio: out.changed_ratio,
bbox: out.bbox,
bank_b64: Some(b64(&bank_bytes)),
new_b64: Some(b64(&new_bytes)),
diff_b64: Some(b64(&out.diff_png)),
}),
Ok(out) => comps.push(Comparison {
name,
status: Status::Match,
changed_ratio: out.changed_ratio,
bbox: None,
bank_b64: None,
new_b64: None,
diff_b64: None,
}),
Err(_) => comps.push(Comparison {
name,
status: Status::Missing,
changed_ratio: 0.0,
bbox: None,
bank_b64: None,
new_b64: None,
diff_b64: None,
}),
}
}
Ok((key, comps))
}

#[cfg(test)]
mod tests {
use super::*;
use image::{ImageEncoder, RgbaImage};
use std::fs;

fn write_png(path: &Path, img: &RgbaImage) {
let mut buf = Vec::new();
image::codecs::png::PngEncoder::new(&mut buf)
.write_image(
img.as_raw(),
img.width(),
img.height(),
image::ExtendedColorType::Rgba8,
)
.unwrap();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, buf).unwrap();
}

fn temp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("mdbank_{tag}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}

#[test]
fn seeds_when_bank_empty_then_matches_next_run() {
let ws = temp_dir("seed");
let flow_dir = ws.join("flows");
let flow_path = flow_dir.join("f.yaml");
fs::create_dir_all(&flow_dir).unwrap();
fs::write(&flow_path, "- takeScreenshot: home\n").unwrap();
write_png(
&flow_dir.join("home.png"),
&RgbaImage::from_pixel(2, 2, image::Rgba([1, 2, 3, 255])),
);

let input = CompareInput {
workspace: &ws,
flow_path: &flow_path,
model: "Dev",
width: 2,
height: 2,
tolerance: 0.1,
threshold: 0.001,
};
let (key, comps) = compare_flow(input).unwrap();
assert_eq!(comps.len(), 1);
assert!(matches!(comps[0].status, Status::Seeded));
// la référence existe maintenant
assert!(ws.join("maestro/bank").join(&key).join("home.png").exists());

// 2e run identique → Match
let input2 = CompareInput {
workspace: &ws,
flow_path: &flow_path,
model: "Dev",
width: 2,
height: 2,
tolerance: 0.1,
threshold: 0.001,
};
let (_, comps2) = compare_flow(input2).unwrap();
assert!(matches!(comps2[0].status, Status::Match));
}

#[test]
fn flags_changed_pixels() {
let ws = temp_dir("changed");
let flow_dir = ws.join("flows");
let flow_path = flow_dir.join("f.yaml");
fs::create_dir_all(&flow_dir).unwrap();
fs::write(&flow_path, "- takeScreenshot: home\n").unwrap();
let key = device_key("Dev", 4, 4);
// référence noire
write_png(
&ws.join("maestro/bank").join(&key).join("home.png"),
&RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 0, 255])),
);
// produit avec un coin blanc
let mut produced = RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 0, 255]));
produced.put_pixel(0, 0, image::Rgba([255, 255, 255, 255]));
write_png(&flow_dir.join("home.png"), &produced);

let (_, comps) = compare_flow(CompareInput {
workspace: &ws,
flow_path: &flow_path,
model: "Dev",
width: 4,
height: 4,
tolerance: 0.1,
threshold: 0.001,
})
.unwrap();
assert!(matches!(comps[0].status, Status::Changed));
assert!(comps[0].diff_b64.is_some());
assert_eq!(comps[0].bbox, Some([0, 0, 1, 1]));
}

#[test]
fn missing_when_no_produced_file() {
let ws = temp_dir("missing");
let flow_dir = ws.join("flows");
let flow_path = flow_dir.join("f.yaml");
fs::create_dir_all(&flow_dir).unwrap();
fs::write(&flow_path, "- takeScreenshot: home\n").unwrap();
let (_, comps) = compare_flow(CompareInput {
workspace: &ws,
flow_path: &flow_path,
model: "Dev",
width: 2,
height: 2,
tolerance: 0.1,
threshold: 0.001,
})
.unwrap();
assert!(matches!(comps[0].status, Status::Missing));
}
}
119 changes: 119 additions & 0 deletions src-tauri/src/bank/diff.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 Ethan Morisset
// SPDX-License-Identifier: BUSL-1.1

use image::ImageEncoder;

pub struct DiffOutcome {
pub changed_ratio: f32,
pub bbox: Option<[u32; 4]>,
pub diff_png: Vec<u8>,
}

pub fn diff_images(
bank_png: &[u8],
new_png: &[u8],
tolerance: f64,
) -> Result<DiffOutcome, image::ImageError> {
let bank = image::load_from_memory(bank_png)?.to_rgba8();
let mut new = image::load_from_memory(new_png)?.to_rgba8();
let (w, h) = (new.width(), new.height());

// Seuil pixelmatch : delta max possible (noir↔blanc) = 35215.
let max_delta = 35215.0 * tolerance * tolerance;

let (mut min_x, mut min_y, mut max_x, mut max_y) = (u32::MAX, u32::MAX, 0u32, 0u32);
let mut changed = 0u64;

for y in 0..h {
for x in 0..w {
let a = bank.get_pixel(x, y).0;
let b = new.get_pixel(x, y).0;
if color_delta(a, b) > max_delta {
changed += 1;
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
new.put_pixel(x, y, image::Rgba([255, 0, 0, 255]));
}
}
}

let total = (w as u64) * (h as u64);
let changed_ratio = if total == 0 {
0.0
} else {
changed as f32 / total as f32
};
let bbox = if changed == 0 {
None
} else {
Some([min_x, min_y, max_x - min_x + 1, max_y - min_y + 1])
};

let mut diff_png = Vec::new();
image::codecs::png::PngEncoder::new(&mut diff_png).write_image(
new.as_raw(),
w,
h,
image::ExtendedColorType::Rgba8,
)?;

Ok(DiffOutcome {
changed_ratio,
bbox,
diff_png,
})
}

fn color_delta(a: [u8; 4], b: [u8; 4]) -> f64 {
let (ay, ai, aq) = yiq(a);
let (by, bi, bq) = yiq(b);
let (dy, di, dq) = (ay - by, ai - bi, aq - bq);
0.5053 * dy * dy + 0.299 * di * di + 0.1957 * dq * dq
}

fn yiq(p: [u8; 4]) -> (f64, f64, f64) {
let (r, g, b) = (p[0] as f64, p[1] as f64, p[2] as f64);
let y = r * 0.29889531 + g * 0.58662247 + b * 0.11448223;
let i = r * 0.59597799 - g * 0.27417610 - b * 0.32180189;
let q = r * 0.21147017 - g * 0.52261711 + b * 0.31114694;
(y, i, q)
}

#[cfg(test)]
mod tests {
use super::*;
use image::RgbaImage;

fn png_bytes(img: &RgbaImage) -> Vec<u8> {
let mut buf = Vec::new();
image::codecs::png::PngEncoder::new(&mut buf)
.write_image(
img.as_raw(),
img.width(),
img.height(),
image::ExtendedColorType::Rgba8,
)
.unwrap();
buf
}

#[test]
fn identical_images_have_zero_ratio() {
let img = RgbaImage::from_pixel(4, 4, image::Rgba([10, 20, 30, 255]));
let out = diff_images(&png_bytes(&img), &png_bytes(&img), 0.1).unwrap();
assert_eq!(out.changed_ratio, 0.0);
assert!(out.bbox.is_none());
}

#[test]
fn one_changed_pixel_is_detected_with_bbox() {
let bank = RgbaImage::from_pixel(4, 4, image::Rgba([0, 0, 0, 255]));
let mut new = bank.clone();
new.put_pixel(2, 1, image::Rgba([255, 255, 255, 255])); // blanc vs noir
let out = diff_images(&png_bytes(&bank), &png_bytes(&new), 0.1).unwrap();
assert!(out.changed_ratio > 0.0);
assert_eq!(out.bbox, Some([2, 1, 1, 1]));
}
}
Loading
Loading