mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 20:41:25 +00:00
A solve with unsolved frames drew the same block as a full one, so it looked complete. It now gets one more line in the warning colour, "Partial solve: N of M frames solved", under the status line, and the editor grows by that line only then. The line is an automation element, so agents can read it too.
334 lines
17 KiB
Rust
334 lines
17 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");
|
||
}
|
||
|
||
/// A solve with unsolved (interpolated) frames says so in Effect Controls, with the count, on a
|
||
/// line of its own that pushes the Export button down.
|
||
#[test]
|
||
fn effect_controls_show_how_many_frames_a_partial_solve_solved() {
|
||
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::EffectControls);
|
||
h.run_steps(4);
|
||
let line = format!("effectControls.cameraTracker.{uid}.partialSolve");
|
||
let export = format!("effectControls.cameraTracker.{uid}.exportBlender");
|
||
assert!(!ids(&h).contains(&line), "a full solve has no partial-solve line");
|
||
let before = rect(&h, &export);
|
||
|
||
let g = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(plate)).unwrap().props.find_group(uid).unwrap();
|
||
let mut solve = ct::solve(&effectcraft_engine::camera_track::static_params(g)).unwrap().as_ref().clone();
|
||
for f in solve.frames.iter_mut().skip(24) {
|
||
f.solved = false;
|
||
}
|
||
let s = &mut h.state_mut().session;
|
||
s.execute("prop.set", json!({"layer": plate, "path": "effects/#1/solve", "value": solve.to_json()})).unwrap();
|
||
s.camera_pending.clear();
|
||
h.run_steps(4);
|
||
let e = h.state().auto.find(&line).expect("the partial-solve line is drawn");
|
||
assert_eq!(e.label, "Partial solve: 24 of 60 frames solved");
|
||
assert!((rect(&h, &export).min.y - before.min.y - 18.0).abs() < 0.5, "the Export button moves down one line");
|
||
}
|
||
|
||
/// 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();
|
||
}
|
||
}
|