mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 14:08:38 -06:00
307 lines
15 KiB
Rust
307 lines
15 KiB
Rust
//! Headless checks for the 3D Camera Tracker UI (egui_kittest, UI logic only): the viewer's track
|
||
//! points and selection, the Effect Controls buttons and the Tracker panel's Track Camera button.
|
||
//! The solve is a synthetic one written into the effect (the analysis itself is tested in the
|
||
//! engine and track crates).
|
||
|
||
use crate::gpu_test::RenderSerial;
|
||
use effectcraft_engine::Session;
|
||
use effectcraft_engine::effects::camera_tracker as ct;
|
||
use effectcraft_engine::track::camtrack::{CameraSolve, ShotType, SolveMethod, SolvedFrame, SolvedPoint};
|
||
use effectcraft_ui_egui::EffectcraftApp;
|
||
use effectcraft_ui_egui::dock::PanelKind;
|
||
use egui_kittest::Harness;
|
||
use serde_json::json;
|
||
|
||
const W: f64 = 640.0;
|
||
const H: f64 = 360.0;
|
||
|
||
fn app() -> (EffectcraftApp, u64, u64) {
|
||
let mut s = Session::default();
|
||
s.execute("comp.new", json!({"name": "Shot", "width": W as u32, "height": H as u32, "duration": 2, "frameRate": 30})).unwrap();
|
||
let plate =
|
||
s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": W as u32, "height": H as u32})).unwrap()["layer"].as_u64().unwrap();
|
||
let r = s.execute("effect.apply", json!({"effect": ct::ID, "layers": [plate]})).unwrap();
|
||
let uid = r["effects"][0].as_u64().unwrap();
|
||
// A synthetic solve: a static camera (focal 600) looking at a 6 × 4 grid of points on z = 1.
|
||
let frames = 60;
|
||
let solve = CameraSolve {
|
||
version: 1,
|
||
size: [W, H],
|
||
start: 0.0,
|
||
frame_duration: 1.0 / 30.0,
|
||
shot: ShotType::FixedAngle,
|
||
method_used: SolveMethod::Typical,
|
||
average_error: 0.21,
|
||
frames: vec![SolvedFrame { rot: [0.0; 3], center: [0.0; 3], focal: 600.0, solved: true }; frames],
|
||
points: (0..24)
|
||
.map(|i| SolvedPoint {
|
||
id: i,
|
||
pos: [((i % 6) as f64 - 2.5) * 0.15, ((i / 6) as f64 - 1.5) * 0.12, 1.0 + 0.01 * (i % 3) as f64],
|
||
error: 0.2,
|
||
first: 0,
|
||
last: frames as u32 - 1,
|
||
})
|
||
.collect(),
|
||
ground: None,
|
||
distortion: None,
|
||
};
|
||
s.execute("prop.set", json!({"layer": plate, "path": "effects/#1/solve", "value": solve.to_json()})).unwrap();
|
||
s.camera_pending.clear();
|
||
s.state.selected_props = vec![(effectcraft_engine::project::LayerId(plate), uid)];
|
||
s.execute("layer.select", json!({"layers": [plate]})).unwrap();
|
||
s.state.selected_props = vec![(effectcraft_engine::project::LayerId(plate), uid)];
|
||
(EffectcraftApp::new(s), plate, uid)
|
||
}
|
||
|
||
fn ids(h: &Harness<'_, EffectcraftApp>) -> Vec<String> {
|
||
h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).map(|e| e.id.clone()).collect()
|
||
}
|
||
|
||
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect {
|
||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
|
||
egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))
|
||
}
|
||
|
||
fn click(h: &mut Harness<'_, EffectcraftApp>, at: egui::Pos2, modifiers: egui::Modifiers) {
|
||
h.input_mut().events.push(egui::Event::ModifiersChanged(modifiers));
|
||
h.input_mut().events.push(egui::Event::PointerMoved(at));
|
||
h.input_mut().events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers });
|
||
h.input_mut().events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers });
|
||
h.run_steps(3);
|
||
h.input_mut().events.push(egui::Event::ModifiersChanged(Default::default()));
|
||
h.step();
|
||
}
|
||
|
||
#[test]
|
||
fn deleting_viewer_track_points_preserves_the_effect_and_layer() {
|
||
let (a, plate, uid) = app();
|
||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| a);
|
||
h.state_mut().show_panel(PanelKind::Composition);
|
||
h.run_steps(4);
|
||
let point = rect(&h, "viewer.cameraTracker.point.7").center();
|
||
click(&mut h, point, Default::default());
|
||
h.state_mut().ui.focused = PanelKind::Composition;
|
||
h.input_mut().events.push(egui::Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
|
||
h.run_steps(3);
|
||
let layer = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(plate)).unwrap();
|
||
let effect = layer.props.find_group(uid).expect("tracker effect survived");
|
||
let params = effectcraft_engine::camera_track::static_params(effect);
|
||
assert!(ct::deleted(¶ms).contains(&7));
|
||
}
|
||
|
||
#[test]
|
||
fn viewer_points_selection_and_effect_controls() {
|
||
let (a, plate, uid) = app();
|
||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| a);
|
||
h.state_mut().show_panel(PanelKind::Composition);
|
||
h.run_steps(4);
|
||
let all = ids(&h);
|
||
assert!(all.iter().any(|i| i == "viewer.cameraTracker"), "track point overlay registered");
|
||
let mut pts: Vec<&String> = all.iter().filter(|i| i.starts_with("viewer.cameraTracker.point.")).collect();
|
||
pts.sort();
|
||
pts.dedup();
|
||
let n = pts.len();
|
||
assert_eq!(n, 24, "every visible point is drawn");
|
||
// Click a point: it is selected; Shift-click another adds it.
|
||
let r = rect(&h, "viewer.cameraTracker.point.7");
|
||
click(&mut h, r.center(), Default::default());
|
||
assert_eq!(h.state().session.state.camera_points, vec![7]);
|
||
let r = rect(&h, "viewer.cameraTracker.point.9");
|
||
click(&mut h, r.center(), egui::Modifiers::SHIFT);
|
||
assert_eq!(h.state().session.state.camera_points, vec![7, 9]);
|
||
// Marquee over the first row selects its six points.
|
||
let (a0, a5) = (rect(&h, "viewer.cameraTracker.point.0"), rect(&h, "viewer.cameraTracker.point.5"));
|
||
let from = a0.left_top() - egui::vec2(6.0, 6.0);
|
||
let to = a5.right_bottom() + egui::vec2(6.0, 6.0);
|
||
h.input_mut().events.push(egui::Event::PointerMoved(from));
|
||
h.input_mut().events.push(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||
h.step();
|
||
for i in 1..=8 {
|
||
h.input_mut().events.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
|
||
h.step();
|
||
}
|
||
h.input_mut().events.push(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||
h.run_steps(3);
|
||
let mut sel = h.state().session.state.camera_points.clone();
|
||
sel.sort();
|
||
assert_eq!(sel, vec![0, 1, 2, 3, 4, 5]);
|
||
// The right-click menu's Create Solid and Camera (via its command).
|
||
let ctx = h.ctx.clone();
|
||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "camera.createFromSolve", json!({"kind": "solid"})).unwrap();
|
||
let comp = h.state().session.active_comp().unwrap();
|
||
assert!(comp.layers.iter().any(|l| l.name == "3D Tracker Camera" && l.is_camera()));
|
||
assert!(comp.layers.iter().any(|l| l.name.starts_with("Track Solid")));
|
||
|
||
// Effect Controls: Analyze and Create Camera.
|
||
h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap();
|
||
h.state_mut().show_panel(PanelKind::EffectControls);
|
||
h.run_steps(4);
|
||
let all = ids(&h);
|
||
for want in [
|
||
format!("effectControls.cameraTracker.{uid}.analyze"),
|
||
format!("effectControls.cameraTracker.{uid}.createCamera"),
|
||
format!("effectControls.cameraTracker.{uid}.exportBlender"),
|
||
] {
|
||
assert!(all.contains(&want), "missing {want}");
|
||
}
|
||
// Tracker panel: Track Camera is available for the selected layer.
|
||
h.state_mut().show_panel(PanelKind::Tracker);
|
||
h.run_steps(3);
|
||
assert!(ids(&h).iter().any(|i| i == "tracker.trackCamera"));
|
||
assert!(h.state().session.is_enabled("track.camera"));
|
||
}
|
||
|
||
#[test]
|
||
fn blender_export_button_opens_python_dialog_and_writes_scene() {
|
||
use effectcraft_engine::project::{Footage, FootageKind, ItemKind, LayerId, LayerSource};
|
||
let (mut a, plate, uid) = app();
|
||
let cid = a.session.active_comp_id().unwrap();
|
||
a.session
|
||
.edit("video fixture", None, |p, _| {
|
||
let item = p.add_item(
|
||
"Video",
|
||
Default::default(),
|
||
None,
|
||
ItemKind::Footage(Footage {
|
||
path: "/tmp/effectcraft-ui-synthetic.mp4".into(),
|
||
kind: FootageKind::Video,
|
||
width: W as u32,
|
||
height: H as u32,
|
||
has_video: true,
|
||
duration: effectcraft_engine::time::Tick::from_seconds_f64(2.0),
|
||
..Default::default()
|
||
}),
|
||
);
|
||
p.comp_mut(cid).unwrap().layer_mut(LayerId(plate)).unwrap().source = LayerSource::Footage { item };
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
a.session.camera_pending.clear();
|
||
let output = std::env::temp_dir().join(format!("effectcraft-ui-blender-{}.py", std::process::id()));
|
||
let selected_path = output.clone();
|
||
a.hooks.pick_save_file = Some(Box::new(move |name, ext| {
|
||
assert!(name.ends_with(".py"));
|
||
assert_eq!(ext, "py");
|
||
Some(selected_path.to_string_lossy().into_owned())
|
||
}));
|
||
let shot = std::env::var("EFFECTCRAFT_BLENDER_UI_SNAPSHOT").ok();
|
||
let builder = Harness::builder().with_size(egui::vec2(1600.0, 1000.0));
|
||
let builder = if shot.is_some() { builder.wgpu() } else { builder };
|
||
let _device = shot.is_some().then(crate::gpu_test::lock);
|
||
let mut h = builder.build_eframe(|_| a);
|
||
h.state_mut().show_panel(PanelKind::EffectControls);
|
||
h.run_steps(5);
|
||
let button = rect(&h, &format!("effectControls.cameraTracker.{uid}.exportBlender"));
|
||
if let Some(path) = shot {
|
||
h.render().expect("render export button").save(path).unwrap();
|
||
}
|
||
click(&mut h, button.center(), Default::default());
|
||
assert!(output.exists(), "UI export failed: {}", h.state().ui.status);
|
||
let script = std::fs::read_to_string(output).expect("clicking the export button saves a Python scene");
|
||
assert!(script.contains("def import_scene():"));
|
||
assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 1, "export does not create a camera in the source project");
|
||
}
|
||
|
||
/// Drag with `button` from `from` to `to` in steps, then release.
|
||
fn drag_with(h: &mut Harness<'_, EffectcraftApp>, button: egui::PointerButton, from: egui::Pos2, to: egui::Pos2) {
|
||
h.input_mut().events.push(egui::Event::PointerMoved(from));
|
||
h.input_mut().events.push(egui::Event::PointerButton { pos: from, button, pressed: true, modifiers: Default::default() });
|
||
h.step();
|
||
for i in 1..=8 {
|
||
h.input_mut().events.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
|
||
h.step();
|
||
}
|
||
h.input_mut().events.push(egui::Event::PointerButton { pos: to, button, pressed: false, modifiers: Default::default() });
|
||
h.run_steps(3);
|
||
}
|
||
|
||
/// #505: with the track points shown, the wheel still zooms the viewer, and a middle-button drag
|
||
/// and a Spacebar (Hand tool) drag over the points still pan it.
|
||
#[test]
|
||
fn viewer_zooms_and_pans_over_the_track_points() {
|
||
let (a, ..) = app();
|
||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| a);
|
||
h.state_mut().show_panel(PanelKind::Composition);
|
||
h.run_steps(4);
|
||
// Wheel over a point: zooms.
|
||
let at = rect(&h, "viewer.cameraTracker.point.7").center();
|
||
let zoom = h.state().ui.viewer.zoom;
|
||
h.input_mut().events.push(egui::Event::PointerMoved(at));
|
||
h.input_mut().events.push(egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Line,
|
||
delta: egui::vec2(0.0, 3.0),
|
||
modifiers: Default::default(),
|
||
phase: egui::TouchPhase::Move,
|
||
source: egui::MouseWheelSource::Wheel,
|
||
});
|
||
h.run_steps(20);
|
||
assert_ne!(h.state().ui.viewer.zoom, zoom, "the wheel zooms over the track points");
|
||
// Middle-button drag from a point: pans, and selects nothing.
|
||
let at = rect(&h, "viewer.cameraTracker.point.7").center();
|
||
let before = h.state().ui.viewer.pan;
|
||
drag_with(&mut h, egui::PointerButton::Middle, at, at + egui::vec2(120.0, 60.0));
|
||
let after = h.state().ui.viewer.pan;
|
||
assert!((after[0] - before[0] - 120.0).abs() < 1.0 && (after[1] - before[1] - 60.0).abs() < 1.0, "{before:?} → {after:?}");
|
||
assert!(h.state().session.state.camera_points.is_empty(), "a pan selects no points");
|
||
// Spacebar held: a primary drag from a point pans instead of drawing a marquee.
|
||
let at = rect(&h, "viewer.cameraTracker.point.7").center();
|
||
let space = |h: &mut Harness<'_, EffectcraftApp>, pressed: bool| {
|
||
h.input_mut().events.push(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Default::default() });
|
||
h.step();
|
||
};
|
||
space(&mut h, true);
|
||
drag_with(&mut h, egui::PointerButton::Primary, at, at + egui::vec2(80.0, 40.0));
|
||
space(&mut h, false);
|
||
let end = h.state().ui.viewer.pan;
|
||
assert!((end[0] - after[0] - 80.0).abs() < 1.0 && (end[1] - after[1] - 40.0).abs() < 1.0, "{after:?} → {end:?}");
|
||
assert!(h.state().session.state.camera_points.is_empty(), "a Spacebar pan selects no points");
|
||
}
|
||
|
||
/// Step the UI until background frame renders have landed.
|
||
fn settle(h: &mut Harness<'_, EffectcraftApp>) {
|
||
for _ in 0..600 {
|
||
h.step();
|
||
if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||
}
|
||
for _ in 0..4 {
|
||
h.step();
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "analyses the demo's 3D Showcase and renders with wgpu; run with --ignored (set EC_SNAPSHOT_DIR to keep PNGs)"]
|
||
fn snapshot_showcase_track() {
|
||
let _device = crate::gpu_test::lock();
|
||
let dir = std::env::var("EC_SNAPSHOT_DIR").ok();
|
||
let mut s = Session::default();
|
||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||
s.execute("comp.new", json!({"name": "Camera Track", "width": 1920, "height": 1080, "duration": 4, "frameRate": 29.97})).unwrap();
|
||
s.execute("layer.addItem", json!({"item": "3D Showcase"})).unwrap();
|
||
s.execute("layer.select", json!({"layers": ["#1"]})).unwrap();
|
||
s.execute("track.camera", json!({"layer": "#1", "wait": true})).unwrap();
|
||
s.execute("time.set", json!({"time": 2.0})).unwrap();
|
||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_pixels_per_point(1.0).wgpu().build_eframe(|_| EffectcraftApp::new(s));
|
||
h.state_mut().show_panel(PanelKind::Composition);
|
||
settle(&mut h);
|
||
let pts: Vec<String> = ids(&h).into_iter().filter(|i| i.starts_with("viewer.cameraTracker.point.")).collect();
|
||
assert!(pts.len() > 10, "{}", pts.len());
|
||
// Hover between points to show a target, then select three and show the selection's target.
|
||
let r = rect(&h, &pts[0]);
|
||
h.input_mut().events.push(egui::Event::PointerMoved(r.center() + egui::vec2(12.0, 12.0)));
|
||
h.run_steps(2);
|
||
if let Some(d) = &dir {
|
||
h.render_serialized().expect("render").save(format!("{d}/camera_tracker_hover.png")).unwrap();
|
||
}
|
||
let sel: Vec<u64> = pts.iter().take(5).filter_map(|i| i.rsplit('.').next()?.parse().ok()).collect();
|
||
h.state_mut().session.execute("camera.selectPoints", json!({"points": sel})).unwrap();
|
||
h.state_mut().session.execute("camera.createFromSolve", json!({"kind": "solid"})).unwrap();
|
||
h.state_mut().session.execute("layer.select", json!({"layers": ["#3"]})).unwrap();
|
||
settle(&mut h);
|
||
if let Some(d) = &dir {
|
||
h.render_serialized().expect("render").save(format!("{d}/camera_tracker_solid.png")).unwrap();
|
||
}
|
||
}
|