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

422 lines
19 KiB
Rust

//! View menu and monitor view options, driven headless through the control channel: playback /
//! paused resolution, High Quality Playback, display modes (channels, comparison, multi-camera,
//! source waveform), magnification and panning, rulers and guides (drag out of the ruler, move,
//! lock, clear, Add Guide…, templates), Snap in Program Monitor, and the Graphics menu commands.
//!
//! With `FILMCRAFT_UI_SHOTS=<dir>` the test also renders the UI with wgpu and writes `view-*.png`.
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 menu(&mut self, id: &str) -> Value {
let r = self.ok("ui.menu.invoke", json!({"id": id}));
self.frames(2);
r
}
fn program(&mut self) -> Value {
self.ok("ui.inspect", json!({}))["ui"]["program"].clone()
}
fn rect(&mut self, id: &str) -> [f64; 4] {
self.try_rect(id).unwrap_or_else(|| panic!("no element {id}"))
}
fn try_rect(&mut self, id: &str) -> Option<[f64; 4]> {
let v = self.ok("ui.elements", json!({"prefix": id}));
let e = v.as_array().unwrap().iter().find(|e| e["id"] == id)?.clone();
let r: Vec<f64> = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
Some([r[0], r[1], r[2], r[3]])
}
fn click(&mut self, id: &str) {
self.ok("ui.click", json!({"id": id}));
self.frames(2);
}
fn shot(&mut self, name: &str) {
let Some(dir) = self.shots.clone() else { return };
// give the frame workers time to deliver full-resolution frames
for _ in 0..8 {
self.frames(3);
std::thread::sleep(std::time::Duration::from_millis(150));
}
let img = self.harness.render().expect("wgpu render");
std::fs::create_dir_all(&dir).unwrap();
img.save(dir.join(format!("view-{name}.png"))).unwrap();
}
}
fn first_media_item(v: &Value) -> Option<u64> {
match v {
Value::Object(m) => {
if let Some(id) = m.get("item").and_then(Value::as_u64)
&& m.get("type").and_then(Value::as_str).is_some_and(|t| t == "Movie")
{
return Some(id);
}
m.values().find_map(first_media_item)
}
Value::Array(a) => a.iter().find_map(first_media_item),
_ => None,
}
}
#[test]
fn view_menu_resolutions_and_display_modes() {
let mut d = Driver::demo();
let menu = d.ok("ui.menu.list", json!({}));
let item = |id: &str| menu.as_array().unwrap().iter().find(|m| m["id"] == id).cloned().unwrap_or_else(|| panic!("no menu item {id}"));
assert_eq!(item("view.playbackRes.sixteenth")["path"], json!(["View", "Playback Resolution"]));
assert_eq!(item("view.pausedRes.full")["checked"], true, "Paused Resolution defaults to Full");
assert_eq!(item("view.display.composite")["checked"], true);
assert_eq!(item("view.magnification.1600")["label"], "1600%");
assert_eq!(item("view.clearGuides")["enabled"], false, "no guides yet");
assert_eq!(item("graphics.alignFrame.left")["path"], json!(["Graphics and Titles", "Align to Video Frame"]));
d.menu("view.playbackRes.sixteenth");
d.menu("view.pausedRes.quarter");
d.menu("view.highQualityPlayback");
let p = d.program();
assert_eq!((p["res"].as_str(), p["paused_res"].as_str(), p["high_quality"].as_bool()), (Some("Sixteenth"), Some("Quarter"), Some(true)));
// channels
d.menu("view.display.red");
assert_eq!(d.program()["display"], "Red");
d.shot("display-red");
d.menu("view.display.alpha");
assert_eq!(d.program()["display"], "Alpha");
// comparison view: reference frame at the playhead, stepped by the reference buttons
d.exec("playhead.set", json!({"seconds": 2}));
d.menu("view.display.comparison");
let p = d.program();
assert_eq!(p["display"], "Comparison");
let r0 = p["compare_ref"].as_i64().unwrap();
d.frames(3);
assert!(d.try_rect("program.compare.reference").is_some());
d.click("program.compare.next");
let r1 = d.program()["compare_ref"].as_i64().unwrap();
assert!(r1 > r0, "reference stepped forward: {r0} → {r1}");
d.exec("playhead.set", json!({"seconds": 4}));
d.shot("comparison");
// Multi-Camera links to the existing multi-camera view; Composite turns it off
d.menu("view.display.multicam");
assert_eq!(d.program()["multicam"], true);
d.menu("view.display.composite");
let p = d.program();
assert_eq!((p["multicam"].as_bool(), p["display"].as_str()), (Some(false), Some("Composite")));
// wrench menu items carry automation ids
d.click("program.settings");
assert!(d.try_rect("program.settings.highQualityPlayback").is_some(), "wrench menu lists High Quality Playback");
d.click("program.settings.highQualityPlayback");
assert_eq!(d.program()["high_quality"], false);
// source monitor: Audio Waveform display mode
let project = d.exec("project.inspect", json!({}));
let item = first_media_item(&project).expect("media item");
d.exec("source.open", json!({"item": item}));
d.menu("view.display.audioWaveform");
let s = d.ok("ui.inspect", json!({}))["ui"]["source"]["display"].clone();
assert_eq!(s, "AudioWaveform");
d.frames(3);
assert!(d.try_rect("source.waveform").is_some(), "waveform drawn in the source monitor");
d.shot("source-waveform");
d.menu("view.display.videoAndWaveform");
d.frames(2);
assert!(d.try_rect("source.waveform").is_some() && d.try_rect("source.picture").is_some());
// ui.set reaches the monitor state directly
d.ok("ui.set", json!({"source": {"display": "Composite", "show_rulers": true}}));
let s = d.ok("ui.inspect", json!({}))["ui"]["source"].clone();
assert_eq!((s["display"].as_str(), s["show_rulers"].as_bool()), (Some("Composite"), Some(true)));
}
fn num(v: &Value) -> f64 {
v.as_f64().unwrap_or_else(|| panic!("not a number: {v}"))
}
/// #807: the wheel over a monitor zooms about the pointer, a middle-button drag pans, Fit resets,
/// and each monitor keeps its own view.
#[test]
fn wheel_zooms_about_the_pointer_and_the_middle_button_pans() {
let mut d = Driver::demo();
// at 100% the picture is larger than the monitor, so the element is the monitor's area
d.menu("view.magnification.100");
let area = d.rect("program.picture");
let centre = (area[0] + area[2] / 2.0, area[1] + area[3] / 2.0);
let at = (area[0] + area[2] * 0.25, area[1] + area[3] * 0.4);
// 50 points up: 1.5 times; the point under the pointer stays under it
d.ok("ui.scroll", json!({"x": at.0, "y": at.1, "dx": 0.0, "dy": 50.0}));
d.frames(2);
let p = d.program();
assert!((num(&p["zoom"]) - 1.5).abs() < 1e-4, "{p}");
let want = ((centre.0 - at.0) * 0.5, (centre.1 - at.1) * 0.5);
assert!((num(&p["pan"][0]) - want.0).abs() < 1.0 && (num(&p["pan"][1]) - want.1).abs() < 1.0, "pan {} for {want:?}", p["pan"]);
// the middle button drags the picture, whatever the tool
let (zoom, pan) = (num(&p["zoom"]), [num(&p["pan"][0]), num(&p["pan"][1])]);
let send = |d: &mut Driver, e: egui::Event| {
d.harness.input_mut().events.push(e);
d.frames(1);
};
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() };
let from = egui::pos2(centre.0 as f32, centre.1 as f32);
send(&mut d, egui::Event::PointerMoved(from));
send(&mut d, button(from, true));
for k in 1..=4 {
send(&mut d, egui::Event::PointerMoved(from + egui::vec2(-10.0 * k as f32, 5.0 * k as f32)));
}
send(&mut d, button(from + egui::vec2(-40.0, 20.0), false));
let p = d.program();
assert_eq!(num(&p["zoom"]), zoom, "a pan does not zoom");
assert!((num(&p["pan"][0]) - (pan[0] - 40.0)).abs() < 1.0 && (num(&p["pan"][1]) - (pan[1] + 20.0)).abs() < 1.0, "pan {} after {pan:?}", p["pan"]);
// zooming out stops at the menu's smallest level; Fit resets zoom and pan
for _ in 0..12 {
d.ok("ui.scroll", json!({"x": centre.0, "y": centre.1, "dx": 0.0, "dy": -50.0}));
}
d.frames(2);
assert!((num(&d.program()["zoom"]) - 0.1).abs() < 1e-6, "stops at 10%");
d.menu("view.magnification.fit");
let p = d.program();
assert!(p["zoom"].is_null() && p["pan"] == json!([0.0, 0.0]), "{p}");
// from Fit, the wheel zooms in too; the Source monitor keeps its own view
d.ok("ui.scroll", json!({"x": centre.0, "y": centre.1, "dx": 0.0, "dy": 20.0}));
d.frames(2);
assert!(d.program()["zoom"].as_f64().is_some(), "zoomed in from Fit");
let source = d.ok("ui.inspect", json!({}))["ui"]["source"].clone();
assert!(source["zoom"].is_null() && source["pan"] == json!([0.0, 0.0]), "{source}");
}
#[test]
fn magnification_pans_and_guides_from_rulers() {
let mut d = Driver::demo();
// zoom dropdown → 200%
d.click("program.zoom");
d.click("program.zoom.200");
assert_eq!(d.program()["zoom"], 2.0);
let pic = d.rect("program.picture");
// pan with the Hand tool
d.ok("ui.set", json!({"tool": "Hand"}));
let (cx, cy) = (pic[0] + pic[2] / 2.0, pic[1] + pic[3] / 2.0);
d.ok("ui.drag", json!({"from": {"x": cx, "y": cy}, "to": {"x": cx + 80.0, "y": cy + 30.0}, "steps": 6}));
d.frames(2);
let pan = d.program()["pan"].clone();
assert!(pan[0].as_f64().unwrap() > 20.0, "panned right: {pan}");
d.shot("magnified");
d.menu("view.magnification.fit");
let p = d.program();
assert!(p["zoom"].is_null() && p["pan"] == json!([0.0, 0.0]));
d.ok("ui.set", json!({"tool": "Selection"}));
// rulers, then drag a guide out of the left ruler (vertical guide)
d.menu("view.showRulers");
d.frames(2);
let left = d.rect("program.ruler.left");
let pic = d.rect("program.picture");
let x = pic[0] + pic[2] * 0.25;
d.ok("ui.drag", json!({"from": {"x": left[0] + left[2] / 2.0, "y": pic[1] + pic[3] / 2.0}, "to": {"x": x, "y": pic[1] + pic[3] / 2.0}, "steps": 6}));
d.frames(2);
let g = d.program()["guides"].clone();
assert_eq!(g.as_array().unwrap().len(), 1, "{g}");
assert_eq!(g[0]["vertical"], true);
let pos = g[0]["position"].as_f64().unwrap();
assert!((pos - 480.0).abs() < 8.0, "a quarter across a 1920 frame: {pos}");
// and a horizontal one from the top ruler
let top = d.rect("program.ruler.top");
d.ok(
"ui.drag",
json!({"from": {"x": pic[0] + pic[2] / 2.0, "y": top[1] + top[3] / 2.0}, "to": {"x": pic[0] + pic[2] / 2.0, "y": pic[1] + pic[3] * 0.5}, "steps": 6}),
);
d.frames(2);
assert_eq!(d.program()["guides"].as_array().unwrap().len(), 2);
// move the vertical guide
let gr = d.rect("program.guide.0");
let (gx, gy) = (gr[0] + gr[2] / 2.0, gr[1] + gr[3] * 0.2);
d.ok("ui.drag", json!({"from": {"x": gx, "y": gy}, "to": {"x": gx + 40.0, "y": gy}, "steps": 6}));
d.frames(2);
let moved = d.program()["guides"][0]["position"].as_f64().unwrap();
assert!(moved > pos + 50.0, "guide moved: {pos} → {moved}");
d.shot("rulers-guides");
// lock: dragging no longer moves it
d.menu("view.lockGuides");
let gr = d.rect("program.guide.0");
let (gx, gy) = (gr[0] + gr[2] / 2.0, gr[1] + gr[3] * 0.2);
d.ok("ui.drag", json!({"from": {"x": gx, "y": gy}, "to": {"x": gx + 40.0, "y": gy}, "steps": 6}));
d.frames(2);
assert_eq!(d.program()["guides"][0]["position"].as_f64().unwrap(), moved);
d.menu("view.lockGuides");
// templates live in the preferences
d.ok("ui.menu.invoke", json!({"id": "view.guideTemplates.save", "params": {"name": "Two lines"}}));
let prefs = d.exec("prefs.get", json!({"key": "guides.templates"}));
assert_eq!(prefs[0]["name"], "Two lines");
assert_eq!(prefs[0]["guides"].as_array().unwrap().len(), 2);
d.menu("view.clearGuides");
assert!(d.program()["guides"].as_array().unwrap().is_empty());
d.menu("view.guideTemplates.manage");
d.click("guides.manage.row.0");
d.click("guides.manage.apply");
assert_eq!(d.program()["guides"].as_array().unwrap().len(), 2, "template applied");
assert!(d.ok("ui.inspect", json!({}))["ui"]["guide_dialog"].is_null(), "dialog closed");
// Add Guide… dialog
d.menu("view.addGuide");
d.click("guides.add.horizontal");
d.click("guides.add.ok");
let g = d.program()["guides"].clone();
assert_eq!(g.as_array().unwrap().len(), 3);
assert_eq!(g[2]["vertical"], false);
// dragging a guide out of the picture removes it
let gr = d.rect("program.guide.0");
let left = d.rect("program.ruler.left");
d.ok("ui.drag", json!({"from": {"x": gr[0] + gr[2] / 2.0, "y": gr[1] + gr[3] * 0.2}, "to": {"x": left[0] + 4.0, "y": gr[1] + gr[3] * 0.2}, "steps": 6}));
d.frames(2);
assert_eq!(d.program()["guides"].as_array().unwrap().len(), 2, "guide dropped on the ruler is removed");
}
#[test]
fn snap_in_program_monitor_and_graphics_menu() {
let mut d = Driver::demo();
d.exec("playhead.set", json!({"seconds": 2}));
let r = d.exec("graphics.newRectangle", json!({"position": [880, 300], "size": [200, 100]}));
let clip = r["clip"].as_u64().unwrap();
d.frames(4);
let drag_by = |d: &mut Driver, frame_dx: f64, extra: f64| {
let pic = d.rect("program.picture");
let k = pic[2] / 1920.0;
let lr = d.rect(&format!("program.layer.{clip}.0"));
// off the centre: there the anchor point is picked, and dragging it moves it alone
let (x, y) = (lr[0] + lr[2] / 4.0, lr[1] + lr[3] / 2.0);
d.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + frame_dx * k + extra, "y": y}, "steps": 8}));
d.frames(2);
};
let pos_x = |d: &mut Driver| d.exec("graphics.list", json!({"clip": clip}))["layers"][0]["position"][0].as_f64().unwrap();
// centre 880 → near 960 (+3 screen px): snaps onto the frame centre
drag_by(&mut d, 80.0, 3.0);
assert!((pos_x(&mut d) - 960.0).abs() < 0.01, "snapped to the centre: {}", pos_x(&mut d));
// a guide at x = 1300: the right edge (centre + 100) snaps to it
d.ok("ui.menu.invoke", json!({"id": "view.addGuide", "params": {"orientation": "vertical", "position": 1300}}));
drag_by(&mut d, 240.0, -4.0);
assert!((pos_x(&mut d) - 1200.0).abs() < 0.01, "right edge on the guide: {}", pos_x(&mut d));
d.shot("snap");
// snapping off: the same drag lands off the guide
d.menu("view.snapInProgramMonitor");
assert_eq!(d.program()["snap"], false);
drag_by(&mut d, -240.0, 4.0);
assert!((pos_x(&mut d) - 960.0).abs() > 1.0, "free move: {}", pos_x(&mut d));
// Graphics menu: New Layer ▸ Ellipse, Align to Video Frame, Arrange
d.exec("graphics.newEllipse", json!({"clip": clip, "position": [400, 400], "size": [100, 100]}));
d.menu("graphics.alignFrame.left");
let l = d.exec("graphics.list", json!({"clip": clip}));
assert!(l["layers"][1]["quad"][0][0].as_f64().unwrap().abs() < 0.01, "ellipse on the left edge: {l}");
d.menu("graphics.sendToBack");
let l = d.exec("graphics.list", json!({"clip": clip}));
assert_eq!(l["layers"][0]["kind"], "Ellipse");
let menu = d.ok("ui.menu.list", json!({}));
let shortcut = menu.as_array().unwrap().iter().find(|m| m["id"] == "graphics.bringToFront").unwrap()["shortcut"].clone();
assert_eq!(shortcut, "Cmd+Shift+]");
d.shot("graphics-menu");
}
#[test]
fn elements_wait_for_the_timeline_zoom_to_settle() {
let mut d = Driver::demo();
let project = d.exec("project.inspect", json!({}));
let item = first_media_item(&project).expect("a movie in the demo project");
// opening a sequence fits the timeline with an animated zoom: rects read right away must
// already be the final ones
d.exec("file.newSequence", json!({"name": "Zoom", "fromItem": item}));
let clip = d.exec("sequence.inspect", json!({}))["video"][0]["items"][0]["clip"].as_u64().expect("clip");
let id = format!("timeline.clip.{clip}");
let first = d.rect(&id);
d.frames(120);
let settled = d.rect(&id);
for k in 0..4 {
assert!((first[k] - settled[k]).abs() < 0.5, "rect read while zooming {first:?}, settled {settled:?}");
}
}
/// Frames until `ui.elements` answers (it retries while the timeline is still zooming).
fn elements_latency(d: &mut Driver) -> usize {
let (req, reply) = ControlRequest::new("ui.elements", json!({"prefix": "timeline."}));
d.tx.send(req).unwrap();
for n in 1..=600 {
d.frames(1);
if let Ok(v) = reply.try_recv() {
assert_eq!(v["ok"], true, "{v}");
return n;
}
}
panic!("ui.elements never answered");
}
#[test]
fn elements_answer_promptly_once_the_timeline_is_at_rest() {
let mut d = Driver::demo();
d.frames(120);
// a timeline at rest must not keep `ui.elements` waiting (the animation snaps to its target)
assert!(elements_latency(&mut d) <= 3);
for pps in [40.0, 400.0, 7.5] {
d.ok("ui.set", json!({"timeline": {"pps": pps}}));
d.ok("ui.set", json!({"timeline": {"scroll": 1.5}}));
d.frames(120);
let n = elements_latency(&mut d);
assert!(n <= 3, "ui.elements took {n} frames at rest after zooming to {pps} px/s");
}
}