Files
filmcraft/crates/ui-egui/tests/keyframes_ui.rs
irgendwasmitfelixandClaude Opus 5.5 ca7c56238d #2328: Effect Controls draws a line between the fixed and the added effects
The first effect added from the library after a clip's fixed effects (Motion, Opacity, Time
Remapping; Volume, Channel Volume, Panner on audio) gets an accent-coloured line on its top edge.
It is drawn on the row's edge, so no row moves; with only fixed effects there is none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L8DvNHjchh5Gf2q24UsWQn
2026-10-19 09:41:57 +02:00

730 lines
36 KiB
Rust

//! Keyframes in the Properties and Effect Controls panels, driven headless through the control
//! channel: the Properties panel's keyframe navigator (◀ ◆ ▶) adds and removes the keyframe at the
//! playhead and steps between keyframes, as Premiere's does, and the Effect Controls time ruler
//! moves the playhead (click, or drag the playhead's handle).
//!
//! With `FILMCRAFT_UI_SHOTS=<dir>` the tests also render the UI with wgpu and write PNGs there.
use std::sync::mpsc::{Sender, channel};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_project::{ClipId, Interpolation, Param};
use filmcraft_time::Tick;
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 {
/// The demo project with its first video clip selected and both panels showing.
fn demo() -> (Self, Clip) {
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);
let seq = d.exec("sequence.inspect", json!({}));
let c = &seq["video"][0]["items"][0];
let clip = Clip { id: c["clip"].as_u64().unwrap(), start: c["start"].as_i64().unwrap(), duration: c["duration"].as_i64().unwrap() };
d.exec("timeline.select", json!({"clips": [clip.id]}));
for panel in ["EffectControls", "Properties"] {
d.ok("ui.panel.show", json!({"panel": panel}));
}
d.frames(3);
(d, clip)
}
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}))
}
/// The element's rect `[x, y, w, h]`, or `None` when it is not on screen.
fn find(&mut self, id: &str) -> Option<[f64; 4]> {
self.frames(2);
let v = self.ok("ui.elements", json!({"prefix": id}));
let e = v.as_array().unwrap().iter().find(|e| e["id"] == id)?;
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 rect(&mut self, id: &str) -> [f64; 4] {
self.find(id).unwrap_or_else(|| panic!("no element {id}"))
}
fn click(&mut self, id: &str) {
self.rect(id);
self.ok("ui.click", json!({"id": id}));
self.frames(3);
}
fn gesture(&mut self, from: egui::Pos2, to: egui::Pos2, modifiers: egui::Modifiers, shot: Option<&str>) {
self.press_drag(from, to, modifiers);
if let Some(name) = shot {
self.shot(name);
}
self.release(to, modifiers);
}
fn press_drag(&mut self, from: egui::Pos2, to: egui::Pos2, modifiers: egui::Modifiers) {
self.harness.input_mut().events.push(egui::Event::ModifiersChanged(modifiers));
self.harness.input_mut().events.push(egui::Event::PointerMoved(from));
self.frames(1);
self.harness.input_mut().events.push(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers });
self.frames(1);
for i in 1..=8 {
self.harness.input_mut().events.push(egui::Event::ModifiersChanged(modifiers));
self.harness.input_mut().events.push(egui::Event::PointerMoved(from.lerp(to, i as f32 / 8.0)));
self.frames(1);
}
}
fn release(&mut self, to: egui::Pos2, modifiers: egui::Modifiers) {
self.harness.input_mut().events.push(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers });
self.frames(2);
self.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
self.frames(2);
}
fn playhead(&mut self) -> i64 {
self.exec("sequence.inspect", json!({}))["playhead"].as_i64().unwrap()
}
fn seek(&mut self, seconds: f64) -> i64 {
self.exec("playhead.set", json!({"seconds": seconds}));
self.frames(2);
self.playhead()
}
/// The clip's Motion ▸ Scale: its keyframe times (media time) and its value at the playhead.
fn scale(&mut self, clip: &Clip) -> (Vec<i64>, f64) {
let s = &self.harness.state().session;
let it = s.active_sequence().unwrap().find_item(ClipId(clip.id)).unwrap().1;
let p = it.effect("motion").unwrap().param("scale").unwrap();
(p.keyframes.iter().map(|k| k.time.0).collect(), p.f64_at(it.source_time_at(s.playhead())))
}
fn parameter(&self, clip: &Clip, effect: &str, param: &str) -> Param {
self.harness.state().session.active_sequence().unwrap().find_item(ClipId(clip.id)).unwrap().1.effect(effect).unwrap().param(param).unwrap().clone()
}
fn undo_len(&mut self) -> usize {
self.exec("history.list", json!({}))["undo"].as_array().unwrap().len()
}
fn right_click(&mut self, id: &str) {
self.rect(id);
self.ok("ui.click", json!({"id": id, "button": "right"}));
self.frames(3);
}
fn shot(&mut self, name: &str) {
let Some(dir) = self.shots.clone() else { return };
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();
}
}
struct Clip {
id: u64,
start: i64,
duration: i64,
}
const SCALE: &str = "properties.motion.scale";
/// The reported case: two Scale keyframes, the second set to 50 %. The Properties diamond removes
/// only the keyframe at the playhead, so Scale is 100 % again there (both panels read the same
/// parameter); it used to switch animation off and keep 50 %.
#[test]
fn properties_diamond_adds_and_removes_the_keyframe_at_the_playhead() {
let (mut d, clip) = Driver::demo();
// not animated: the diamond alone
d.rect(&format!("{SCALE}.addKeyframe"));
assert!(d.find(&format!("{SCALE}.prevKeyframe")).is_none(), "no arrows before the parameter is animated");
let t0 = d.seek(0.5);
d.click(&format!("{SCALE}.addKeyframe"));
assert_eq!(d.scale(&clip).0.len(), 1, "the diamond turns animation on with a keyframe at the playhead");
// animated: the arrows appear, in Properties and in Effect Controls
for panel in [SCALE, "effectControls.motion.scale"] {
d.rect(&format!("{panel}.prevKeyframe"));
d.rect(&format!("{panel}.nextKeyframe"));
}
let t1 = d.seek(2.5);
d.click(&format!("{SCALE}.addKeyframe"));
assert_eq!(d.scale(&clip).0.len(), 2, "a second keyframe at the playhead");
d.exec("effects.setParam", json!({"clip": clip.id, "effect": "motion", "param": "scale", "value": 50.0}));
assert_eq!(d.scale(&clip).1, 50.0);
d.shot("keyframes-two");
// on a keyframe: the diamond removes that keyframe only
d.click(&format!("{SCALE}.addKeyframe"));
let (keys, value) = d.scale(&clip);
assert_eq!(keys.len(), 1, "only the keyframe at the playhead went: {keys:?}");
assert_eq!(value, 100.0, "Scale falls back to the remaining keyframe's value");
assert_eq!(d.playhead(), t1);
d.shot("keyframes-removed");
d.exec("edit.undo", json!({}));
let (keys, value) = d.scale(&clip);
assert_eq!((keys.len(), value), (2, 50.0), "one undo step brings the keyframe back");
// the arrows step between the keyframes
d.click(&format!("{SCALE}.prevKeyframe"));
assert_eq!(d.playhead(), t0, "◀ goes to the previous keyframe");
assert_eq!(d.scale(&clip).1, 100.0);
d.click(&format!("{SCALE}.prevKeyframe"));
assert_eq!(d.playhead(), t0, "no keyframe before the first: ◀ does nothing");
d.click(&format!("{SCALE}.nextKeyframe"));
assert_eq!(d.playhead(), t1, "▶ goes to the next keyframe");
assert_eq!(d.scale(&clip).1, 50.0);
d.click("effectControls.motion.scale.prevKeyframe");
assert_eq!(d.playhead(), t0, "the Effect Controls arrows do the same");
// removing the last keyframe ends the animation; the arrows go
d.click(&format!("{SCALE}.addKeyframe"));
d.click(&format!("{SCALE}.nextKeyframe"));
d.click(&format!("{SCALE}.addKeyframe"));
let (keys, value) = d.scale(&clip);
assert!(keys.is_empty(), "{keys:?}");
assert_eq!(value, 50.0, "the last keyframe's value stays");
assert!(d.find(&format!("{SCALE}.prevKeyframe")).is_none());
}
/// #284: every Effect Controls property has a reset button; on an animated property it puts the
/// default in as a keyframe at the playhead and keeps the other keyframes.
#[test]
fn effect_controls_reset_button_keeps_keyframes() {
let (mut d, clip) = Driver::demo();
let t0 = d.seek(0.5);
d.click(&format!("{SCALE}.addKeyframe"));
d.exec("effects.setParam", json!({"clip": clip.id, "effect": "motion", "param": "scale", "value": 150.0, "time": t0}));
d.seek(2.5);
d.click("effectControls.motion.scale.reset");
d.shot("effect-controls-reset");
let (keys, value) = d.scale(&clip);
assert_eq!(keys.len(), 2, "the keyframe stays and the default is keyed at the playhead: {keys:?}");
assert_eq!(value, 100.0);
}
/// Crop is not on a clip until it is used: its diamond applies the effect, then adds the keyframe.
#[test]
fn properties_diamond_applies_crop_first() {
let (mut d, clip) = Driver::demo();
d.seek(1.0);
d.click("properties.crop.left.addKeyframe");
let s = &d.harness.state().session;
let it = s.active_sequence().unwrap().find_item(ClipId(clip.id)).unwrap().1;
assert_eq!(it.effect("crop").expect("crop applied").param("left").unwrap().keyframes.len(), 1);
d.rect("properties.crop.left.nextKeyframe");
}
/// The Effect Controls time ruler: a click moves the playhead there, the playhead's handle drags,
/// and there is no playhead while it is off the clip.
#[test]
fn effect_controls_ruler_moves_the_playhead() {
let (mut d, clip) = Driver::demo();
let frame = Tick::from_seconds_f64(1.0 / 23.976).0;
let [x, y, w, h] = d.rect("effectControls.ruler");
let lane = d.rect("effectControls.lane");
assert_eq!((x, w), (lane[0], lane[2]), "the ruler spans the keyframe lane");
let at = |f: f64| clip.start + (f * clip.duration as f64) as i64;
d.ok("ui.click", json!({"x": x + w * 0.25, "y": y + h * 0.3}));
d.frames(2);
assert!((d.playhead() - at(0.25)).abs() <= frame, "a click on the ruler: {} vs {}", d.playhead(), at(0.25));
let head = d.rect("effectControls.playhead");
assert!((head[0] + head[2] / 2.0 - (x + w * 0.25)).abs() <= 2.0, "the handle sits where the playhead is: {head:?}");
d.shot("keyframes-ruler");
// grab the handle and drag it right
let (hx, hy) = (head[0] + head[2] / 2.0, head[1] + head[3] / 2.0);
d.ok("ui.drag", json!({"from": {"x": hx, "y": hy}, "to": {"x": hx + w * 0.5, "y": hy}, "steps": 6}));
d.frames(2);
assert!((d.playhead() - at(0.75)).abs() <= frame, "dragging the handle: {} vs {}", d.playhead(), at(0.75));
// the playhead is drawn only on the clip
d.exec("playhead.set", json!({"time": clip.start + clip.duration * 2}));
assert!(d.find("effectControls.playhead").is_none(), "no handle while the playhead is past the clip");
d.rect("effectControls.ruler");
}
/// #412: Scale and Position both have a keyframe at the same time. A click on Scale's keyframe in
/// the Effect Controls lane selects that keyframe only (every keyframe under the playhead, which
/// the click moves there, used to light up); a keyframe that is gone drops out of the selection.
#[test]
fn clicking_a_lane_keyframe_selects_only_that_keyframe() {
let (mut d, clip) = Driver::demo();
let t0 = d.seek(1.0);
for param in ["scale", "position"] {
d.exec("effects.addKeyframe", json!({"clip": clip.id, "effect": "motion", "param": param}));
}
let time = d.scale(&clip).0[0];
let motion = {
let s = &d.harness.state().session;
let it = s.active_sequence().unwrap().find_item(ClipId(clip.id)).unwrap().1;
it.effects.iter().position(|e| e.effect == "motion").unwrap()
};
let selection = |d: &mut Driver| d.ok("ui.inspect", json!({}))["ui"]["keyframe_selection"].clone();
d.seek(2.0);
assert_eq!(selection(&mut d), json!([]), "nothing selected yet");
d.click(&format!("effectControls.motion.scale.keyframe.{time}"));
assert_eq!(selection(&mut d), json!([{"clip": clip.id, "effect": motion, "param": "scale", "time": time}]), "only Scale's keyframe");
assert_eq!(d.playhead(), t0, "the click still moves the playhead to the keyframe");
d.shot("keyframes-select-one");
d.click(&format!("effectControls.motion.position.keyframe.{time}"));
assert_eq!(selection(&mut d), json!([{"clip": clip.id, "effect": motion, "param": "position", "time": time}]), "a click replaces the selection");
// the diamond removes the keyframe at the playhead, the selected one: the selection empties
d.click("effectControls.motion.position.addKeyframe");
assert_eq!(selection(&mut d), json!([]), "a deleted keyframe is not selected");
}
#[test]
fn select_all_in_effect_controls_selects_its_keyframes_not_the_clips() {
// #1241
let (mut d, clip) = Driver::demo();
d.seek(1.0);
for param in ["scale", "position"] {
d.exec("effects.addKeyframe", json!({"clip": clip.id, "effect": "motion", "param": param}));
}
d.seek(2.0);
d.exec("effects.addKeyframe", json!({"clip": clip.id, "effect": "motion", "param": "scale"}));
let clips = d.ok("ui.inspect", json!({}))["selection"].clone();
d.ok("ui.set", json!({"focused": "EffectControls"}));
d.ok("ui.key", json!({"key": "Cmd+A"}));
d.frames(2);
let inspect = d.ok("ui.inspect", json!({}));
let keys = inspect["ui"]["keyframe_selection"].as_array().cloned().unwrap_or_default();
assert_eq!(keys.len(), 3, "{keys:?}");
assert!(keys.iter().all(|k| k["clip"] == clip.id));
assert_eq!(inspect["selection"], clips, "the Timeline selection is unchanged");
// with the Timeline focused, Select All still selects the clips
d.ok("ui.set", json!({"focused": "Timeline"}));
d.ok("ui.key", json!({"key": "Cmd+A"}));
d.frames(2);
assert!(d.ok("ui.inspect", json!({}))["selection"].as_array().map_or(0, Vec::len) > clips.as_array().map_or(0, Vec::len));
}
/// Effect Controls' divider (#643): dragging it widens the effect list or the keyframe area, each
/// keeping a minimum width, and the width stays. The mouse wheel scrolls the rows over the
/// keyframe area too, not only over the effect list.
#[test]
fn effect_controls_divider_and_scrolling_anywhere() {
let (mut d, clip) = Driver::demo();
let lane = d.rect("effectControls.lane");
let div = d.rect("effectControls.divider");
let (x, y) = (div[0] + div[2] / 2.0, div[1] + div[3] / 2.0);
d.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + 80.0, "y": y}, "steps": 6}));
let narrower = d.rect("effectControls.lane");
assert!((narrower[0] - (lane[0] + 80.0)).abs() <= 1.0, "the keyframe area starts 80 points further right: {narrower:?} vs {lane:?}");
assert!((narrower[0] + narrower[2] - (lane[0] + lane[2])).abs() <= 1.0, "and still ends at the panel's edge");
d.frames(3);
assert_eq!(d.rect("effectControls.lane"), narrower, "the width stays");
// dragged all the way right, the keyframe area keeps its minimum width
let drag_divider = |d: &mut Driver, by: f64| {
let div = d.rect("effectControls.divider");
let (x, y) = (div[0] + div[2] / 2.0, div[1] + div[3] / 2.0);
d.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + by, "y": y}, "steps": 6}));
};
drag_divider(&mut d, 3000.0);
assert!(d.rect("effectControls.lane")[2] >= 59.0, "the keyframe area can't vanish");
// and all the way left, the effect list keeps the width its rows are laid out for
drag_divider(&mut d, -3000.0);
assert!(d.rect("effectControls.effect.motion")[2] >= 259.0, "the effect list stays wide enough for its values");
// enough effects to scroll; the wheel over the keyframe area scrolls the rows
for fx in ["gaussian_blur", "transform", "crop", "tint"] {
d.exec("effects.apply", json!({"clips": [clip.id], "effect": fx}));
}
let before = d.rect("effectControls.motion.scale.stopwatch");
let lane = d.rect("effectControls.lane");
d.ok("ui.scroll", json!({"x": lane[0] + lane[2] / 2.0, "y": lane[1] + lane[3] * 0.7, "dx": 0.0, "dy": -80.0}));
d.frames(4);
let after = d.find("effectControls.motion.scale.stopwatch");
assert!(after.is_none_or(|r| r[1] < before[1] - 20.0), "the rows scrolled up: {before:?} → {after:?}");
}
/// #967: empty lane space clears both the selected refs and the highlight, without an edit.
#[test]
fn empty_keyframe_lane_click_clears_selection() {
let (mut d, clip) = Driver::demo();
d.seek(1.0);
d.exec("effects.addKeyframe", json!({"clip":clip.id,"effect":"motion","param":"scale"}));
let time = d.scale(&clip).0[0];
let id = format!("effectControls.motion.scale.keyframe.{time}");
let r = d.rect(&id);
let lane = d.rect("effectControls.lane");
d.click(&id);
assert_eq!(d.harness.state().ui.keyframe_selection.len(), 1);
let undo = d.harness.state().session.history.undo.len();
d.ok("ui.click", json!({"x":lane[0] + lane[2] * 0.9,"y":r[1] + r[3]/2.0}));
d.frames(3);
assert!(d.harness.state().ui.keyframe_selection.is_empty(), "empty lane clicks must clear the highlight");
assert_eq!(d.harness.state().session.history.undo.len(), undo);
d.shot("keyframes-selection-cleared");
}
/// #967: one rectangle selects times across property rows and leaves outside keys alone.
#[test]
fn marquee_selects_keyframes_across_property_rows() {
let (mut d, clip) = Driver::demo();
let mut keys = Vec::new();
for (param, seconds) in [("scale", 1.0), ("scale", 2.0), ("rotation", 1.5), ("position", 3.0)] {
let timeline = d.seek(seconds);
d.exec("effects.addKeyframe", json!({"clip":clip.id,"effect":"motion","param":param}));
let it = d.harness.state().session.active_sequence().unwrap().find_item(ClipId(clip.id)).unwrap().1;
keys.push((param, it.source_time_at(Tick(timeline)).0));
}
let rects: Vec<_> = keys.iter().take(3).map(|(param, time)| d.rect(&format!("effectControls.motion.{param}.keyframe.{time}"))).collect();
let from = egui::pos2(
rects.iter().map(|r| r[0]).fold(f64::INFINITY, f64::min) as f32 - 14.0,
rects.iter().map(|r| r[1]).fold(f64::INFINITY, f64::min) as f32 - 12.0,
);
let to = egui::pos2(
rects.iter().map(|r| r[0] + r[2]).fold(f64::NEG_INFINITY, f64::max) as f32 + 14.0,
rects.iter().map(|r| r[1] + r[3]).fold(f64::NEG_INFINITY, f64::max) as f32 + 12.0,
);
let undo = d.harness.state().session.history.undo.len();
d.gesture(from, to, egui::Modifiers::default(), Some("keyframes-marquee-drag"));
let selected = &d.harness.state().ui.keyframe_selection;
assert_eq!(selected.len(), 3, "marquee must select Scale and Rotation together");
for (param, time) in keys.iter().take(3) {
assert!(selected.iter().any(|k| k.clip == clip.id && k.param == *param && k.time.0 == *time));
}
assert_eq!(d.harness.state().session.history.undo.len(), undo, "selection is not a project edit");
d.shot("keyframes-marquee-selected");
}
fn lane_keys(d: &mut Driver, clip: &Clip, specs: &[(&str, f64)]) -> Vec<filmcraft_ui_egui::state::KeyframeRef> {
specs
.iter()
.map(|(param, seconds)| {
let time = d.seek(*seconds);
d.exec("effects.addKeyframe", json!({"clip":clip.id,"effect":"motion","param":param}));
let session = &d.harness.state().session;
let it = session.active_sequence().unwrap().find_item(ClipId(clip.id)).unwrap().1;
filmcraft_ui_egui::state::KeyframeRef {
clip: clip.id,
effect: it.effects.iter().position(|e| e.effect == "motion").unwrap(),
param: (*param).into(),
mask: None,
time: it.source_time_at(Tick(time)),
}
})
.collect()
}
fn key_id(d: &Driver, key: &filmcraft_ui_egui::state::KeyframeRef) -> String {
let it = d.harness.state().session.active_sequence().unwrap().find_item(ClipId(key.clip)).unwrap().1;
let mask = key.mask.map_or_else(String::new, |m| format!("mask{m}."));
format!("effectControls.{}.{mask}{}.keyframe.{}", it.effects[key.effect].effect, key.param, key.time.0)
}
fn center(rect: [f64; 4]) -> egui::Pos2 {
egui::pos2((rect[0] + rect[2] / 2.0) as f32, (rect[1] + rect[3] / 2.0) as f32)
}
fn select_keys(d: &mut Driver, keys: &[filmcraft_ui_egui::state::KeyframeRef]) {
for (i, key) in keys.iter().enumerate() {
let id = key_id(d, key);
d.rect(&id);
d.ok("ui.click", json!({"id":id,"modifiers":{"shift":i>0}}));
d.frames(3);
}
assert_eq!(d.harness.state().ui.keyframe_selection, keys);
}
#[test]
fn selected_keyframes_move_together_and_undo_as_one_edit() {
let (mut d, clip) = Driver::demo();
let keys = lane_keys(&mut d, &clip, &[("scale", 1.0), ("scale", 2.0), ("rotation", 1.5)]);
select_keys(&mut d, &keys);
let lane = d.rect("effectControls.lane");
let from = center(d.rect(&key_id(&d, &keys[0])));
let undo = d.harness.state().session.history.undo.len();
let before = serde_json::to_value(&d.harness.state().session.project).unwrap();
d.gesture(from, from + egui::vec2(lane[2] as f32 * 0.15, 0.0), egui::Modifiers::NONE, Some("keyframes-group-preview"));
let selected = &d.harness.state().ui.keyframe_selection;
assert_eq!(selected.len(), 3);
let delta = selected[0].time - keys[0].time;
assert!(delta > Tick::ZERO);
for (old, new) in keys.iter().zip(selected) {
assert_eq!(new.time, old.time + delta);
assert_eq!(new.param, old.param);
}
assert_eq!(d.harness.state().session.history.undo.len(), undo + 1);
let after = serde_json::to_value(&d.harness.state().session.project).unwrap();
d.exec("edit.undo", json!({}));
assert_eq!(serde_json::to_value(&d.harness.state().session.project).unwrap(), before);
d.exec("edit.redo", json!({}));
assert_eq!(serde_json::to_value(&d.harness.state().session.project).unwrap(), after);
}
#[test]
fn shift_snaps_the_group_to_other_rows_at_different_lane_widths() {
for (width, theme) in [(280.0, "dark"), (480.0, "light")] {
let (mut d, clip) = Driver::demo();
d.harness.state_mut().ui.effect_controls_split = width;
d.ok("ui.set", json!({"theme":theme}));
let at = |f: f64| Tick(clip.start).seconds() + Tick(clip.duration).seconds() * f;
let keys = lane_keys(&mut d, &clip, &[("scale", at(0.2)), ("scale", at(0.4)), ("rotation", at(0.65))]);
select_keys(&mut d, &keys[..2]);
d.seek(0.0);
let from = center(d.rect(&key_id(&d, &keys[0])));
let last = center(d.rect(&key_id(&d, &keys[1])));
let target = center(d.rect(&key_id(&d, &keys[2])));
let to = from + egui::vec2(target.x - last.x - 3.0, 0.0);
let shift = egui::Modifiers { shift: true, ..egui::Modifiers::NONE };
d.press_drag(from, to, shift);
let guide = d.rect("effectControls.snapTarget");
assert!(((guide[0] + guide[2] / 2.0) as f32 - target.x).abs() < 1.0);
d.shot(&format!("keyframes-snap-row-{theme}"));
d.release(to, shift);
let selected = &d.harness.state().ui.keyframe_selection;
assert_eq!(selected.len(), 2);
assert_eq!(selected[1].time, keys[2].time, "the rightmost selected key snaps to Rotation's time");
assert_eq!(selected[1].time - selected[0].time, keys[1].time - keys[0].time);
}
}
#[test]
fn shift_snaps_to_playhead_with_a_screen_space_threshold() {
for (width, theme) in [(280.0, "dark"), (480.0, "light")] {
let (mut d, clip) = Driver::demo();
d.harness.state_mut().ui.effect_controls_split = width;
d.ok("ui.set", json!({"theme":theme}));
let at = |f: f64| Tick(clip.start).seconds() + Tick(clip.duration).seconds() * f;
let keys = lane_keys(&mut d, &clip, &[("scale", at(0.2)), ("scale", at(0.4))]);
select_keys(&mut d, &keys);
let target_time = Tick(d.seek(at(0.6)));
let target_media = d.harness.state().session.active_sequence().unwrap().find_item(ClipId(clip.id)).unwrap().1.source_time_at(target_time);
let from = center(d.rect(&key_id(&d, &keys[0])));
let target = center(d.rect("effectControls.playhead"));
let shift = egui::Modifiers { shift: true, ..egui::Modifiers::NONE };
let to = egui::pos2(target.x + 3.0, from.y);
d.press_drag(from, to, shift);
assert!((center(d.rect("effectControls.snapTarget")).x - target.x).abs() < 1.0);
d.shot(&format!("keyframes-snap-playhead-{theme}"));
d.release(to, shift);
assert_eq!(d.harness.state().ui.keyframe_selection[0].time, target_media);
d.exec("edit.undo", json!({}));
select_keys(&mut d, &keys);
let outside = egui::pos2(target.x + 16.0, from.y);
d.press_drag(from, outside, shift);
assert!(d.find("effectControls.snapTarget").is_none(), "a target more than 8 screen points away must not snap");
d.release(outside, shift);
assert_ne!(d.harness.state().ui.keyframe_selection[0].time, target_media);
}
}
#[test]
fn escape_cancels_a_group_preview_without_editing_the_project() {
let (mut d, clip) = Driver::demo();
let keys = lane_keys(&mut d, &clip, &[("scale", 1.0), ("scale", 1.5)]);
select_keys(&mut d, &keys);
let from = center(d.rect(&key_id(&d, &keys[0])));
let lane = d.rect("effectControls.lane");
let to = from + egui::vec2(lane[2] as f32 * 0.2, 0.0);
let project = d.harness.state().session.project.clone();
let undo = d.harness.state().session.history.undo.len();
d.press_drag(from, to, egui::Modifiers::NONE);
assert!(std::sync::Arc::ptr_eq(&project, &d.harness.state().session.project), "drag previews are not project edits");
d.ok("ui.key", json!({"key":"Escape"}));
d.release(to, egui::Modifiers::NONE);
assert!(std::sync::Arc::ptr_eq(&project, &d.harness.state().session.project));
assert_eq!(d.harness.state().session.history.undo.len(), undo);
assert_eq!(d.harness.state().ui.keyframe_selection, keys);
d.gesture(from, to, egui::Modifiers::NONE, None);
assert_eq!(d.harness.state().session.history.undo.len(), undo + 1, "a new gesture works after cancellation");
}
#[test]
fn effect_and_mask_keyframes_move_together_without_crossing_parameters() {
let (mut d, clip) = Driver::demo();
d.click("effectControls.effect.motion");
d.exec("masks.add", json!({"clip":clip.id,"effect":"opacity","shape":"ellipse"}));
let mut keys = Vec::new();
for (mask, seconds) in [(None, 1.0), (Some(0), 1.5)] {
let time = Tick(d.seek(seconds));
let mut p = json!({"clip":clip.id,"effect":"opacity","param":"opacity"});
if let Some(m) = mask {
p["mask"] = json!(m);
}
d.exec("effects.addKeyframe", p);
let it = d.harness.state().session.active_sequence().unwrap().find_item(ClipId(clip.id)).unwrap().1;
keys.push(filmcraft_ui_egui::state::KeyframeRef {
clip: clip.id,
effect: it.effects.iter().position(|e| e.effect == "opacity").unwrap(),
param: "opacity".into(),
mask,
time: it.source_time_at(time),
});
}
select_keys(&mut d, &keys);
let from = center(d.rect(&key_id(&d, &keys[0])));
let lane = d.rect("effectControls.lane");
d.gesture(from, from + egui::vec2(lane[2] as f32 * 0.1, 0.0), egui::Modifiers::NONE, Some("keyframes-mask-group"));
let selected = &d.harness.state().ui.keyframe_selection;
assert_eq!(selected.len(), 2);
let delta = selected[0].time - keys[0].time;
assert!(delta > Tick::ZERO);
assert_eq!(selected[1].time - keys[1].time, delta);
let it = d.harness.state().session.active_sequence().unwrap().find_item(ClipId(clip.id)).unwrap().1;
assert!(it.effects[keys[0].effect].masks[0].feather.keyframes.is_empty());
}
/// #962: graph points need the same interpolation menu as lane diamonds, including either
/// coordinate of Transform Position. Opening a menu never changes the value or creates an undo.
#[test]
fn graph_keyframes_offer_interpolation_and_clear_in_one_undo_step() {
for (effect, param, values, component) in [
("motion", "scale", [json!(40.0), json!(80.0)], ""),
("motion", "position", [json!([400.0, 300.0]), json!([800.0, 500.0])], ".x"),
("transform", "position", [json!([400.0, 300.0]), json!([800.0, 500.0])], ".x"),
("transform", "position", [json!([400.0, 300.0]), json!([800.0, 500.0])], ".y"),
] {
let (mut d, clip) = Driver::demo();
if effect == "transform" {
d.exec("effects.apply", json!({"clips": [clip.id], "effect": effect}));
for intrinsic in ["motion", "opacity", "time_remap"] {
d.click(&format!("effectControls.effect.{intrinsic}.twirl"));
}
}
for (seconds, value) in [0.5, 2.5].into_iter().zip(values) {
d.seek(seconds);
d.exec("effects.addKeyframe", json!({"clip": clip.id, "effect": effect, "param": param}));
d.exec("effects.setParam", json!({"clip": clip.id, "effect": effect, "param": param, "value": value}));
}
d.click(&format!("effectControls.{effect}.{param}.graphs"));
let before = d.parameter(&clip, effect, param);
let undo = d.undo_len();
let target = format!("effectControls.{param}.graph.keyframe.{}{component}", before.keyframes[0].time.0);
for (key, interpolation) in [
("bezier", Interpolation::Bezier),
("autoBezier", Interpolation::AutoBezier),
("continuousBezier", Interpolation::ContinuousBezier),
("hold", Interpolation::Hold),
("easeIn", Interpolation::EaseIn),
("easeOut", Interpolation::EaseOut),
("linear", Interpolation::Linear),
] {
d.right_click(&target);
assert_eq!(d.parameter(&clip, effect, param), before, "opening the graph menu cannot edit {effect}.{param}{component}");
assert_eq!(d.undo_len(), undo);
if key == "easeIn" && effect == "transform" {
d.shot(&format!("graph-menu-{effect}-{param}{component}"));
}
d.click(&format!("effectControls.keyframeMenu.{key}"));
let changed = d.parameter(&clip, effect, param);
assert_eq!(changed.keyframes[0].interp, interpolation);
assert_eq!(changed.keyframes[0].time, before.keyframes[0].time);
assert_eq!(changed.keyframes[0].value, before.keyframes[0].value);
assert_eq!(changed.keyframes[1], before.keyframes[1]);
assert_eq!(d.undo_len(), undo + 1);
assert!(d.find("effectControls.keyframeMenu.clear").is_none(), "choosing interpolation closes the menu");
d.exec("edit.undo", json!({}));
assert_eq!(d.parameter(&clip, effect, param), before);
d.exec("edit.redo", json!({}));
assert_eq!(d.parameter(&clip, effect, param), changed);
d.exec("edit.undo", json!({}));
}
d.right_click(&target);
d.click("effectControls.keyframeMenu.clear");
let remaining = d.parameter(&clip, effect, param);
assert_eq!(remaining.keyframes, before.keyframes[1..]);
assert_eq!(d.undo_len(), undo + 1);
d.exec("edit.undo", json!({}));
assert_eq!(d.parameter(&clip, effect, param), before);
}
}
#[test]
fn mask_keyframe_menus_preserve_the_effect_and_other_masks() {
let (mut d, clip) = Driver::demo();
d.ok("ui.set", json!({"workspace": "Effects"}));
for intrinsic in ["motion", "time_remap"] {
d.click(&format!("effectControls.effect.{intrinsic}.twirl"));
}
for mask in 0..2 {
d.exec("masks.add", json!({"clip": clip.id, "effect": "opacity", "shape": "ellipse"}));
for (seconds, value) in [(0.5, 20.0), (2.5, 60.0)] {
d.seek(seconds);
d.exec("effects.addKeyframe", json!({"clip": clip.id, "effect": "opacity", "mask": mask, "param": "feather"}));
d.exec("effects.setParam", json!({"clip": clip.id, "effect": "opacity", "mask": mask, "param": "feather", "value": value}));
}
}
let effect = |d: &Driver| d.harness.state().session.active_sequence().unwrap().find_item(ClipId(clip.id)).unwrap().1.effect("opacity").unwrap().clone();
let before = effect(&d);
let time = before.masks[0].param("feather").unwrap().keyframes[0].time.0;
let undo = d.undo_len();
for (target, key, interpolation) in [
(format!("effectControls.opacity.mask0.feather.keyframe.{time}"), "easeOut", Interpolation::EaseOut),
(format!("effectControls.feather.graph.keyframe.{time}"), "easeIn", Interpolation::EaseIn),
] {
if key == "easeIn" {
d.click("effectControls.opacity.mask0.feather.graphs");
}
d.right_click(&target);
assert_eq!(effect(&d), before, "opening the menu cannot change any mask");
assert_eq!(d.undo_len(), undo);
d.click(&format!("effectControls.keyframeMenu.{key}"));
let mut expected = before.clone();
expected.masks[0].param_mut("feather").unwrap().keyframes[0].interp = interpolation;
assert_eq!(effect(&d), expected, "only the first mask's first feather keyframe changes");
assert_eq!(d.undo_len(), undo + 1);
d.exec("edit.undo", json!({}));
assert_eq!(effect(&d), before);
}
}
/// #2328: a line separates the fixed effects (Motion, Opacity, Time Remapping) from the effects
/// added from the library; without added effects there is none.
#[test]
fn a_line_separates_added_effects_from_the_fixed_ones() {
let (mut d, clip) = Driver::demo();
assert!(d.find("effectControls.addedEffects.video").is_none(), "only fixed effects: no line");
d.exec("effects.apply", json!({"clips": [clip.id], "effect": "gaussian_blur"}));
d.exec("effects.apply", json!({"clips": [clip.id], "effect": "tint"}));
let line = d.rect("effectControls.addedEffects.video");
let opacity = d.rect("effectControls.effect.opacity");
let blur = d.rect("effectControls.effect.gaussian_blur");
let tint = d.rect("effectControls.effect.tint");
assert!(
opacity[1] < line[1] && (line[1] + line[3] / 2.0 - blur[1]).abs() <= 0.5,
"on the edge between the fixed and the added effects: {opacity:?} {line:?} {blur:?}"
);
assert!(tint[1] > blur[1], "one line, above all the added effects");
}