Files
effectcraft/crates/ui-egui/tests/ui_fx_editors.rs
vangel76andClaude Opus 5.5 c733277263 #303: Mesh Warp's grid can be dragged in the Composition viewer
Problem: Mesh Warp keeps its Distortion Mesh in a hidden text parameter,
and the viewer only draws handles for point parameters (Bezier Warp's),
so with Mesh Warp selected there was no grid and nothing to drag: the
effect could not warp anything from the UI.

Fix: with a Mesh Warp effect (or one of its parameters) selected and
layer controls shown, the viewer draws the (rows+1)x(columns+1) grid
over the layer in effect space, with a handle on every vertex
(automation id viewer.meshWarp.<fx>.<row>.<col>). Dragging a vertex
writes its offset to the `mesh` parameter through prop.set, one undo
step per drag; a mesh that does not match Rows/Columns starts from the
undistorted grid, as it renders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:43:38 +02:00

303 lines
14 KiB
Rust
Raw Permalink 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.
//! Headless checks (egui_kittest) of the visual editors for hidden / text effect parameters
//! (M13.10): Lumetri's RGB and hue/saturation curves, Colorama's output cycle wheel, Glow's
//! colour map and Reshape's correspondence points (Effect Controls buttons and viewer handles).
//! Each edit goes through `prop.set` and undoes in one step.
use effectcraft_engine::Session;
use effectcraft_engine::keyframe::Value as KV;
use effectcraft_engine::project::{LayerId, Node, PropGroup};
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use egui::{Event, Pos2, pos2};
use egui_kittest::Harness;
use serde_json::json;
fn harness(s: Session) -> Harness<'static, EffectcraftApp> {
let mut app = EffectcraftApp::new(s);
app.show_panel(PanelKind::EffectControls);
app.toggle_maximize(PanelKind::EffectControls);
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1400.0)).build_eframe(|_| app);
h.run_steps(4);
h
}
/// A comp with one 320×180 solid carrying `effect`; returns the session, layer and effect uid.
fn with_effect(effect: &str) -> (Session, u64, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Fx", "width": 320, "height": 180, "frameRate": 24, "duration": 4})).unwrap();
let l = s.execute("layer.newSolid", json!({"name": "S", "color": "#6080a0"})).unwrap()["layer"].as_u64().unwrap();
let fx = s.execute("effect.apply", json!({"layers": [l], "effect": effect})).unwrap()["effects"][0].as_u64().unwrap();
s.state.selected_layers = vec![LayerId(l)];
(s, l, fx)
}
fn group(h: &Harness<'_, EffectcraftApp>, layer: u64, fx: u64) -> PropGroup {
h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find_group(fx).unwrap().clone()
}
/// A parameter's value by its spec-id path inside the effect (`curves/rgbCurves/master`).
fn value(h: &Harness<'_, EffectcraftApp>, layer: u64, fx: u64, path: &str) -> KV {
let g = group(h, layer, fx);
let mut cur = &g;
let parts: Vec<&str> = path.split('/').collect();
for p in &parts[..parts.len() - 1] {
cur = cur.sub(p).unwrap_or_else(|| panic!("no group {p}"));
}
cur.get(parts[parts.len() - 1]).unwrap().value.clone()
}
fn s(v: KV) -> String {
match v {
KV::Str(s) => s,
other => panic!("{other:?}"),
}
}
/// Close every parameter group of the effect except those named (and their parents).
fn only_groups(h: &mut Harness<'_, EffectcraftApp>, layer: u64, fx: u64, keep: &[&str]) {
fn walk(g: &PropGroup, keep: &[&str], out: &mut Vec<u64>) {
for c in &g.children {
if let Node::Group(sg) = c {
if !keep.contains(&sg.match_id.as_str()) {
out.push(sg.uid);
}
walk(sg, keep, out);
}
}
}
let g = group(h, layer, fx);
let mut closed = vec![];
walk(&g, keep, &mut closed);
h.state_mut().ui.fx_closed.extend(closed);
h.run_steps(3);
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect {
let e = h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).find(|e| e.id == id).cloned().unwrap_or_else(|| {
let near: Vec<String> = h
.state()
.auto
.elements
.iter()
.map(|e| e.id.clone())
.filter(|i| i.starts_with("effectControls.effect") || i.starts_with("viewer.reshape"))
.collect();
panic!("no element {id}; have {near:?}")
});
egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))
}
fn at(r: egui::Rect, fx: f32, fy: f32) -> Pos2 {
pos2(r.min.x + r.width() * fx, r.min.y + r.height() * fy)
}
fn click_n(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, n: u32) {
h.event(Event::PointerMoved(p));
h.step();
// (all in one frame: the harness' frame time is longer than a double-click's)
for _ in 0..n {
h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
}
h.step();
h.run_steps(2);
}
fn click_id(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let p = rect(h, id).center();
click_n(h, p, 1);
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
h.event(Event::PointerMoved(from));
h.step();
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
for k in 1..=6 {
let t = k as f32 / 6.0;
h.event(Event::PointerMoved(from + (to - from) * t));
h.step();
}
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
}
fn undo(h: &mut Harness<'_, EffectcraftApp>) {
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
}
#[test]
fn lumetri_rgb_and_hue_saturation_curve_editors() {
let (s0, l, fx) = with_effect("Lumetri Color");
let mut h = harness(s0);
only_groups(&mut h, l, fx, &["curves", "rgbCurves", "hueSaturationCurves"]);
let pre = format!("effectControls.effect.{fx}.lumetri.rgbCurves");
for k in 0..4 {
rect(&h, &format!("{pre}.channel.{k}"));
}
rect(&h, &format!("{pre}.point.0"));
// Red channel: a click in the graph adds a point to the red curve only.
click_id(&mut h, &format!("{pre}.channel.1"));
let gr = rect(&h, &format!("{pre}.graph"));
click_n(&mut h, at(gr, 0.5, 0.25), 1);
let red = s(value(&h, l, fx, "curves/rgbCurves/red"));
assert_eq!(red.split_whitespace().count(), 3, "{red}");
assert_eq!(s(value(&h, l, fx, "curves/rgbCurves/master")), "0,0 1,1");
rect(&h, &format!("{pre}.point.2"));
undo(&mut h);
assert_eq!(s(value(&h, l, fx, "curves/rgbCurves/red")), "0,0 1,1");
// Hue vs Hue: pick the tab, add a point, drag it: one undo step for the drag.
let hs = format!("effectControls.effect.{fx}.lumetri.hueSatCurves");
click_id(&mut h, &format!("{hs}.curve.1"));
let gr = rect(&h, &format!("{hs}.graph"));
click_n(&mut h, at(gr, 0.3, 0.5), 1);
let v = s(value(&h, l, fx, "curves/hueSaturationCurves/hueVsHue"));
assert_eq!(v.split_whitespace().count(), 1, "{v}");
assert_eq!(s(value(&h, l, fx, "curves/hueSaturationCurves/hueVsSat")), "");
let p0 = rect(&h, &format!("{hs}.point.0")).center();
let undo_len = h.state().session.history.undo.len();
drag(&mut h, p0, p0 + egui::vec2(0.0, -gr.height() * 0.25));
let v = s(value(&h, l, fx, "curves/hueSaturationCurves/hueVsHue"));
let y: f32 = v.split(',').nth(1).unwrap().parse().unwrap();
assert!(y > 0.7, "{v}");
assert_eq!(h.state().session.history.undo.len(), undo_len + 1, "one undo step per drag");
// Reset empties the curve.
click_id(&mut h, &format!("{hs}.reset"));
assert_eq!(s(value(&h, l, fx, "curves/hueSaturationCurves/hueVsHue")), "");
}
#[test]
fn colorama_output_cycle_wheel() {
let (s0, l, fx) = with_effect("Colorama");
let mut h = harness(s0);
only_groups(&mut h, l, fx, &["outputCycle"]);
let pre = format!("effectControls.effect.{fx}.colorama");
// The Hue Cycle preset has six stops.
for i in 0..6 {
rect(&h, &format!("{pre}.stop.{i}"));
}
// Clicking the ring adds a stop: the palette becomes Custom with seven stops.
let w = rect(&h, &format!("{pre}.wheel"));
let mid = w.width() / 2.0 - 20.0 - 9.0;
let ring = pos2(w.center().x + mid * std::f32::consts::FRAC_1_SQRT_2, w.center().y + mid * std::f32::consts::FRAC_1_SQRT_2);
click_n(&mut h, ring, 1);
assert_eq!(value(&h, l, fx, "outputCycle/usePresetPalette"), KV::Enum(8));
let pal = s(value(&h, l, fx, "outputCycle/palette"));
assert_eq!(pal.split_whitespace().count(), 7, "{pal}");
rect(&h, &format!("{pre}.stop.6"));
// Dragging a stop moves its position.
let st = rect(&h, &format!("{pre}.stop.0")).center();
drag(&mut h, st, pos2(w.max.x - 6.0, w.center().y));
let pal2 = s(value(&h, l, fx, "outputCycle/palette"));
assert_ne!(pal, pal2);
assert!(pal2.split_whitespace().any(|t| t.starts_with("0.25") || t.starts_with("0.24") || t.starts_with("0.26")), "{pal2}");
rect(&h, &format!("{pre}.color"));
}
#[test]
fn glow_arbitrary_map_editor() {
let (mut s0, l, fx) = with_effect("Glow");
let colors = s0.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find_group(fx).unwrap().get("colors").unwrap().uid;
s0.execute("prop.set", json!({"layer": l, "prop": colors, "value": 1})).unwrap();
let mut h = harness(s0);
let pre = format!("effectControls.effect.{fx}.glow");
rect(&h, &format!("{pre}.colorMap"));
assert!(!h.state().auto.elements.iter().any(|e| e.id == format!("{pre}.graph")), "A & B: just the strip");
h.state_mut().session.execute("prop.set", json!({"layer": l, "prop": colors, "value": 2})).unwrap();
h.run_steps(3);
click_id(&mut h, &format!("{pre}.channel.2"));
let gr = rect(&h, &format!("{pre}.graph"));
click_n(&mut h, at(gr, 0.5, 0.9), 1);
let m = s(value(&h, l, fx, "arbitraryMap"));
let parts: Vec<&str> = m.split('|').map(str::trim).collect();
assert_eq!(parts.len(), 3, "{m}");
assert_eq!(parts[0], "0,0 1,1");
assert_eq!(parts[2].split_whitespace().count(), 3, "{m}");
}
#[test]
fn reshape_correspondence_points_on_the_viewer() {
let (mut s0, l, fx) = with_effect("Reshape");
let sq = |x0: f64, y0: f64, d: f64| json!([[x0, y0], [x0 + d, y0], [x0 + d, y0 + d], [x0, y0 + d]]);
s0.execute("mask.new", json!({"layer": l, "vertices": sq(60.0, 40.0, 60.0), "closed": true})).unwrap();
s0.execute("mask.new", json!({"layer": l, "vertices": sq(180.0, 60.0, 80.0), "closed": true})).unwrap();
let g = s0.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find_group(fx).unwrap().clone();
s0.execute("prop.set", json!({"layer": l, "prop": g.get("sourceMask").unwrap().uid, "value": 1})).unwrap();
s0.execute("prop.set", json!({"layer": l, "prop": g.get("destinationMask").unwrap().uid, "value": 2})).unwrap();
let mut h = harness(s0);
let pre = format!("effectControls.effect.{fx}.reshape");
click_id(&mut h, &format!("{pre}.add"));
assert_eq!(s(value(&h, l, fx, "correspondencePoints")), "0,0");
click_id(&mut h, &format!("{pre}.add"));
assert_eq!(s(value(&h, l, fx, "correspondencePoints")).split_whitespace().count(), 2);
click_id(&mut h, &format!("{pre}.remove"));
assert_eq!(s(value(&h, l, fx, "correspondencePoints")), "0,0");
// With the effect selected, the viewer shows the handles: drag the destination one along
// its outline.
h.state_mut().toggle_maximize(PanelKind::EffectControls);
h.state_mut().session.state.selected_props = vec![(LayerId(l), fx)];
for _ in 0..6 {
h.step();
}
let d0 = rect(&h, &format!("viewer.reshape.{fx}.dest.0"));
rect(&h, &format!("viewer.reshape.{fx}.source.0"));
let undo_len = h.state().session.history.undo.len();
drag(&mut h, d0.center(), d0.center() + egui::vec2(60.0, 0.0));
let v = s(value(&h, l, fx, "correspondencePoints"));
let d: f64 = v.split(',').nth(1).unwrap().parse().unwrap();
assert!(d > 0.02 && d < 0.3, "moved along the top edge: {v}");
assert_eq!(h.state().session.history.undo.len(), undo_len + 1);
}
/// #303: with Mesh Warp selected, the viewer shows its grid; dragging a vertex warps the mesh
/// (one undo step), and the other vertices stay put.
#[test]
fn mesh_warp_vertices_drag_on_the_viewer() {
let (mut s0, l, fx) = with_effect("Mesh Warp");
let g = s0.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find_group(fx).unwrap().clone();
s0.execute("prop.set", json!({"layer": l, "prop": g.get("rows").unwrap().uid, "value": 2})).unwrap();
s0.execute("prop.set", json!({"layer": l, "prop": g.get("columns").unwrap().uid, "value": 2})).unwrap();
let mut h = harness(s0);
h.state_mut().toggle_maximize(PanelKind::EffectControls);
h.state_mut().session.state.selected_props = vec![(LayerId(l), fx)];
for _ in 0..6 {
h.step();
}
let v = |r: usize, c: usize| format!("viewer.meshWarp.{fx}.{r}.{c}");
let (tl, mid, br) = (rect(&h, &v(0, 0)).center(), rect(&h, &v(1, 1)).center(), rect(&h, &v(2, 2)).center());
assert!(((tl.x + br.x) / 2.0 - mid.x).abs() < 1.0 && ((tl.y + br.y) / 2.0 - mid.y).abs() < 1.0, "{tl:?} {mid:?} {br:?}");
assert!(br.x - tl.x > 100.0, "the grid spans the layer: {tl:?} {br:?}");
assert_eq!(s(value(&h, l, fx, "mesh")), "");
let undo_len = h.state().session.history.undo.len();
drag(&mut h, mid, mid + egui::vec2(40.0, 0.0));
let mesh = effectcraft_engine::effects::parse_mesh(&s(value(&h, l, fx, "mesh")));
assert_eq!(mesh.len(), 9, "{mesh:?}");
assert!(mesh[4].0 > 10.0 && mesh[4].1.abs() < 1.0, "the centre vertex moved right: {mesh:?}");
assert!(mesh.iter().enumerate().all(|(i, o)| i == 4 || *o == (0.0, 0.0)), "{mesh:?}");
assert_eq!(h.state().session.history.undo.len(), undo_len + 1);
let moved = rect(&h, &v(1, 1)).center();
assert!((moved.x - mid.x - 40.0).abs() < 2.0, "the handle follows the vertex: {mid:?} → {moved:?}");
}
/// UI fixes of M13.10: the Composition / Timeline tabs' close button, swatch and lock; the
/// Preview panel scrolls in the default workspace (where it shows just the transport).
#[test]
fn composition_tabs_and_scrolling_preview() {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(4);
rect(&h, "panel.tab.Timeline.close");
rect(&h, "preview.scroll");
click_id(&mut h, "panel.tab.Composition.lock");
assert!(h.state().ui.locked_tabs.contains("Composition"));
click_id(&mut h, "panel.tab.Composition.lock");
assert!(h.state().ui.locked_tabs.is_empty());
// The Timeline's × closes its comp's tab (the panel stays, as in After Effects).
let shown = h.state().session.state.active_comp.unwrap();
click_id(&mut h, "panel.tab.Timeline.close");
assert!(!h.state().session.state.open_comps.contains(&shown));
assert!(h.state().ui.dock.contains(PanelKind::Timeline));
}