filmcraft/crates/ui-egui/tests/mask_roundness_ui.rs

159 lines
6.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The corner-roundness handle of rectangle / polygon masks on the Program monitor, driven
//! headless through the control channel: dragging it rounds the corners in one undo step; masks
//! without sharp corners show no handle.
//!
//! With `FILMCRAFT_UI_SHOTS=<dir>` the test also renders the UI with wgpu and writes PNGs there.
use std::sync::mpsc::{Sender, channel};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::control::ControlRequest;
use serde_json::{Value, json};
struct Driver {
harness: Harness<'static, FilmcraftApp>,
tx: Sender<ControlRequest>,
shots: Option<std::path::PathBuf>,
}
impl Driver {
fn demo() -> Self {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).expect("demo project");
let (tx, rx) = channel();
let app = FilmcraftApp::new(session).with_control(rx);
let shots = std::env::var_os("FILMCRAFT_UI_SHOTS").map(std::path::PathBuf::from);
let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000);
if shots.is_some() {
b = b.wgpu().with_pixels_per_point(1.0);
}
let harness = b.build_eframe(move |_cc| app);
let mut d = Driver { harness, tx, shots };
d.frames(4);
d
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let ctx = self.harness.ctx.clone();
let mut raw = std::mem::take(self.harness.input_mut());
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
*self.harness.input_mut() = raw;
self.harness.step();
}
}
fn call(&mut self, method: &str, params: Value) -> Value {
let (req, reply) = ControlRequest::new(method, params.clone());
self.tx.send(req).unwrap();
for _ in 0..600 {
self.frames(1);
if let Ok(v) = reply.try_recv() {
return v;
}
}
panic!("no reply to {method} {params}");
}
fn ok(&mut self, method: &str, params: Value) -> Value {
let r = self.call(method, params.clone());
assert_eq!(r["ok"], true, "{method} {params}: {r}");
r["result"].clone()
}
fn exec(&mut self, command: &str, params: Value) -> Value {
self.ok("engine.execute", json!({"command": command, "params": params}))
}
fn rect(&mut self, id: &str) -> [f64; 4] {
let v = self.ok("ui.elements", json!({"prefix": id}));
let e = v.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("no element {id}: {v}")).clone();
let r: Vec<f64> = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
[r[0], r[1], r[2], r[3]]
}
fn center(&mut self, id: &str) -> (f64, f64) {
let r = self.rect(id);
(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)
}
fn drag(&mut self, id: &str, dx: f64, dy: f64) {
let (x, y) = self.center(id);
self.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + dx, "y": y + dy}, "steps": 6}));
self.frames(2);
}
fn has(&mut self, id: &str) -> bool {
let v = self.ok("ui.elements", json!({"prefix": id}));
v.as_array().is_some_and(|a| a.iter().any(|e| e["id"] == id))
}
fn masks(&mut self) -> Vec<Value> {
self.exec("masks.list", json!({}))["masks"].as_array().unwrap().clone()
}
fn shot(&mut self, name: &str) {
let Some(dir) = self.shots.clone() else { return };
// let the background frame workers finish the (blurred) frame
for _ in 0..40 {
self.frames(1);
std::thread::sleep(std::time::Duration::from_millis(25));
}
let img = self.harness.render().expect("wgpu render");
std::fs::create_dir_all(&dir).unwrap();
img.save(dir.join(format!("{name}.png"))).unwrap();
}
}
fn vertex_count(m: &Value) -> usize {
m["path"]["vertices"].as_array().map_or(0, Vec::len)
}
#[test]
fn roundness_handle_rounds_mask_corners_in_one_undo_step() {
let mut d = Driver::demo();
d.ok("ui.set", json!({"workspace": "Effects"}));
let seq = d.exec("sequence.inspect", json!({}));
let clip = &seq["video"][0]["items"][0];
let (id, start, dur) = (clip["clip"].as_u64().unwrap(), clip["start"].as_i64().unwrap(), clip["duration"].as_i64().unwrap());
d.exec("playhead.set", json!({"time": start + dur / 2}));
d.exec("timeline.select", json!({"clips": [id]}));
// an ellipse has no sharp corner: no roundness handle
d.exec("masks.add", json!({"clip": id, "effect": "opacity", "shape": "ellipse"}));
d.exec("masks.select", json!({"clip": id, "effect": "opacity", "mask": 0}));
d.frames(3);
assert!(d.has("program.mask.vertex.0"), "the mask overlay is on the monitor");
assert!(!d.has("program.mask.roundness"), "no roundness handle on an ellipse");
// a rectangle has one, just inside its top-left corner
d.exec("masks.add", json!({"clip": id, "effect": "opacity", "shape": "rectangle", "center": [960, 540], "size": [960, 540]}));
d.exec("masks.select", json!({"clip": id, "effect": "opacity", "mask": 1}));
d.frames(3);
assert!(d.has("program.mask.roundness"));
let corner = d.center("program.mask.vertex.0");
let handle = d.center("program.mask.roundness");
assert!(handle.0 > corner.0 && handle.1 > corner.1, "handle {handle:?} inside the top-left corner {corner:?}");
assert_eq!(vertex_count(&d.masks()[1]), 4);
d.shot("mask-roundness-handle");
// a click without a drag changes nothing
let undo0 = d.exec("history.list", json!({}))["undo"].as_array().unwrap().len();
d.ok("ui.click", json!({"id": "program.mask.roundness"}));
d.frames(2);
assert_eq!(vertex_count(&d.masks()[1]), 4);
assert_eq!(d.exec("history.list", json!({}))["undo"].as_array().unwrap().len(), undo0);
// dragging inward rounds all four corners: eight vertices, one undo step
d.drag("program.mask.roundness", 30.0, 30.0);
let m = d.masks();
assert_eq!(vertex_count(&m[1]), 8, "{}", m[1]["path"]);
let undo1 = d.exec("history.list", json!({}))["undo"].as_array().unwrap().len();
assert_eq!(undo1, undo0 + 1, "one undo step per drag");
// the first fillet starts below the old top-left corner (960 − 480, 540 − 270)
let p = &m[1]["path"]["vertices"][0]["p"];
let (x, y) = (p[0].as_f64().unwrap(), p[1].as_f64().unwrap());
assert!((x - 480.0).abs() < 1e-6 && y > 270.0 + 10.0, "fillet start ({x}, {y})");
d.shot("mask-rounded-corners");
// the rounded corners are no longer sharp: the handle is gone
d.frames(2);
assert!(!d.has("program.mask.roundness"));
d.exec("edit.undo", json!({}));
d.frames(2);
assert_eq!(vertex_count(&d.masks()[1]), 4);
assert!(d.has("program.mask.roundness"));
}