mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 19:18:14 -06:00
- keys.wiggle / keys.smooth / motion.sketch engine commands (seeded noise, curve-fit smoothing within tolerance, pointer-path capture to Position keys), Wiggler, Smoother and Motion Sketch panels, viewer capture overlay. - markers.list/set/delete/convert, Update Markers From Source; Marker dialog (time, duration, comment, chapter, URL + frame target, cue point, protected region, label); timeline marker drag with snapping, Alt-drag duration, Cmd/Ctrl-click delete, context menu, protected regions shaded. - layer.precompose mode leave|move, adjustDuration, open; Pre-compose dialog; Composition Navigator bar and Mini-Flowchart (Tab). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
150 lines
8.1 KiB
Rust
150 lines
8.1 KiB
Rust
//! Composition / layer markers: dialog fields, move, duration, delete, convert, update from
|
|
//! source, all with undo.
|
|
|
|
use serde_json::json;
|
|
|
|
use crate::tests_timeline::setup;
|
|
|
|
#[test]
|
|
fn comp_marker_dialog_fields_and_undo() {
|
|
let (mut s, _) = setup();
|
|
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
|
s.execute("comp.addMarker", json!({"comment": "a"})).unwrap();
|
|
let r = s
|
|
.execute(
|
|
"markers.set",
|
|
json!({"index": 0, "time": 1.51, "duration": 0.5, "comment": "Intro", "chapter": "One", "url": "https://getartcraft.com", "frameTarget": "_blank",
|
|
"cuePoint": {"name": "cue", "navigation": true, "params": [["k", "v"]]}, "protected": true, "label": "Aqua"}),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(r["index"], 0);
|
|
let l = s.execute("markers.list", json!({})).unwrap();
|
|
let m = &l[0];
|
|
// Snapped to whole frames (30 fps).
|
|
assert!((m["time"].as_f64().unwrap() - 1.5).abs() < 1e-9, "{m}");
|
|
assert!((m["duration"].as_f64().unwrap() - 0.5).abs() < 1e-9);
|
|
assert_eq!(m["comment"], "Intro");
|
|
assert_eq!(m["chapter"], "One");
|
|
assert_eq!(m["url"], "https://getartcraft.com");
|
|
assert_eq!(m["frameTarget"], "_blank");
|
|
assert_eq!(m["cuePoint"]["name"], "cue");
|
|
assert_eq!(m["cuePoint"]["navigation"], true);
|
|
assert_eq!(m["cuePoint"]["params"][0][1], "v");
|
|
assert_eq!(m["protected"], true);
|
|
assert_eq!(m["label"], "Aqua");
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
let l = s.execute("markers.list", json!({})).unwrap();
|
|
assert_eq!(l[0]["comment"], "a");
|
|
assert_eq!(l[0]["time"], 1.0);
|
|
s.execute("edit.redo", json!({})).unwrap();
|
|
// Find by time, clear the cue point, delete with undo.
|
|
s.execute("markers.set", json!({"at": 1.5, "cuePoint": null})).unwrap();
|
|
assert!(s.execute("markers.list", json!({})).unwrap()[0]["cuePoint"].is_null());
|
|
s.execute("markers.delete", json!({"at": 1.5})).unwrap();
|
|
assert_eq!(s.execute("markers.list", json!({})).unwrap().as_array().unwrap().len(), 0);
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
assert_eq!(s.execute("markers.list", json!({})).unwrap().as_array().unwrap().len(), 1);
|
|
assert!(s.execute("markers.set", json!({"index": 5, "comment": "x"})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn drag_merges_into_one_undo_step_and_markers_stay_sorted() {
|
|
let (mut s, _) = setup();
|
|
for t in [0.5, 2.0] {
|
|
s.execute("markers.set", json!({"new": true, "time": t})).unwrap();
|
|
}
|
|
let before = s.history.undo.len();
|
|
// A drag: several moves merged under one key; passing the other marker re-sorts.
|
|
let mut idx = 0;
|
|
for t in [1.0, 1.5, 2.5] {
|
|
idx = s.execute("markers.set", json!({"index": idx, "time": t, "merge": "mk-drag"})).unwrap()["index"].as_u64().unwrap();
|
|
}
|
|
assert_eq!(idx, 1);
|
|
assert_eq!(s.history.undo.len(), before + 1);
|
|
let l = s.execute("markers.list", json!({})).unwrap();
|
|
assert_eq!(l[0]["time"], 2.0);
|
|
assert_eq!(l[1]["time"], 2.5);
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
assert_eq!(s.execute("markers.list", json!({})).unwrap()[0]["time"], 0.5);
|
|
}
|
|
|
|
#[test]
|
|
fn layer_markers_convert_and_update_from_source() {
|
|
let (mut s, l) = setup();
|
|
// Layer starts at 1 s: layer markers are stored in layer time but addressed in comp time.
|
|
s.execute("layer.timing", json!({"layers": [l], "start": 1.0})).unwrap();
|
|
s.execute("markers.set", json!({"layer": l, "new": true, "time": 2.0, "comment": "hit"})).unwrap();
|
|
let c = s.active_comp().unwrap();
|
|
let lay = c.layer(effectcraft_project::LayerId(l)).unwrap();
|
|
assert_eq!(lay.markers[0].time.seconds(), 1.0);
|
|
assert_eq!(s.execute("markers.list", json!({"layer": l})).unwrap()[0]["time"], 2.0);
|
|
// Layer → comp marker keeps the comp time.
|
|
s.execute("markers.convert", json!({"layer": l, "index": 0})).unwrap();
|
|
assert_eq!(s.execute("markers.list", json!({})).unwrap()[0]["time"], 2.0);
|
|
assert_eq!(s.execute("markers.list", json!({"layer": l})).unwrap().as_array().unwrap().len(), 0);
|
|
// And back.
|
|
s.execute("markers.convert", json!({"index": 0, "toLayer": l})).unwrap();
|
|
assert_eq!(s.execute("markers.list", json!({"layer": l})).unwrap()[0]["comment"], "hit");
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
assert_eq!(s.execute("markers.list", json!({})).unwrap().as_array().unwrap().len(), 1);
|
|
// Locked markers refuse edits.
|
|
s.execute("layer.markersLock", json!({"layers": [l], "value": true})).unwrap();
|
|
assert!(s.execute("markers.set", json!({"layer": l, "new": true})).is_err());
|
|
s.execute("layer.markersLock", json!({"layers": [l], "value": false})).unwrap();
|
|
|
|
// Update Markers From Source on a precomp layer.
|
|
let r = s.execute("layer.precompose", json!({"layers": [l], "name": "Inner"})).unwrap();
|
|
let inner = r["comp"].as_u64().unwrap();
|
|
let outer_layer = r["layer"].as_u64().unwrap();
|
|
s.execute("markers.set", json!({"comp": inner, "new": true, "time": 0.5, "comment": "src"})).unwrap();
|
|
s.execute("layer.select", json!({"layers": [outer_layer]})).unwrap();
|
|
let r = s.execute("layer.updateMarkersFromSource", json!({})).unwrap();
|
|
assert_eq!(r["added"], 1);
|
|
let lm = s.execute("markers.list", json!({"layer": outer_layer})).unwrap();
|
|
assert!(lm.as_array().unwrap().iter().any(|m| m["comment"] == "src"));
|
|
// Idempotent; undoable.
|
|
assert_eq!(s.execute("layer.updateMarkersFromSource", json!({})).unwrap()["added"], 0);
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
assert!(!s.execute("markers.list", json!({"layer": outer_layer})).unwrap().as_array().unwrap().iter().any(|m| m["comment"] == "src"));
|
|
}
|
|
|
|
#[test]
|
|
fn precompose_leave_and_move_attributes() {
|
|
use effectcraft_project::{ItemId, LayerId, LayerSource};
|
|
let (mut s, l) = setup();
|
|
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [50, 60]})).unwrap();
|
|
let outer = s.active_comp_id().unwrap();
|
|
// Leave all attributes: same layer, keeps its transform; the new comp is the source size and
|
|
// holds a default layer of the original source.
|
|
let r = s.execute("layer.precompose", json!({"layers": [l], "name": "Leave", "mode": "leave"})).unwrap();
|
|
assert_eq!(r["layer"], l);
|
|
let inner = ItemId(r["comp"].as_u64().unwrap());
|
|
let lay = s.project.comp(outer).unwrap().layer(LayerId(l)).unwrap().clone();
|
|
assert_eq!(lay.source, LayerSource::Comp { item: inner });
|
|
assert_eq!(lay.props.prop("transform/position").unwrap().value.as_vec2(), [50.0, 60.0]);
|
|
let ic = s.project.comp(inner).unwrap();
|
|
assert_eq!((ic.width, ic.height), (100, 100));
|
|
assert_eq!(ic.layers.len(), 1);
|
|
assert_eq!(ic.layers[0].props.prop("transform/position").unwrap().value.as_vec2(), [50.0, 50.0]);
|
|
assert!(matches!(ic.layers[0].source, LayerSource::Solid { .. }));
|
|
s.execute("edit.undo", json!({})).unwrap();
|
|
assert!(s.project.comp(inner).is_none());
|
|
assert!(matches!(s.project.comp(outer).unwrap().layer(LayerId(l)).unwrap().source, LayerSource::Solid { .. }));
|
|
// Leave needs one layer with a source item.
|
|
let t = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();
|
|
assert!(s.execute("layer.precompose", json!({"layers": [t], "mode": "leave"})).is_err());
|
|
assert!(s.execute("layer.precompose", json!({"layers": [t, l], "mode": "leave"})).is_err());
|
|
// Move all attributes + adjust duration + open: the layer moves inside, trimmed to its span.
|
|
s.execute("layer.timing", json!({"layers": [l], "in": 1.0, "out": 3.0})).unwrap();
|
|
let r = s.execute("layer.precompose", json!({"layers": [l], "name": "Move", "adjustDuration": true, "open": true})).unwrap();
|
|
let inner = ItemId(r["comp"].as_u64().unwrap());
|
|
let ic = s.project.comp(inner).unwrap();
|
|
assert!((ic.duration.seconds() - 2.0).abs() < 1e-9);
|
|
assert_eq!(ic.layers[0].props.prop("transform/position").unwrap().value.as_vec2(), [50.0, 60.0]);
|
|
assert_eq!(ic.layers[0].in_point.seconds(), 0.0);
|
|
let nl = s.project.comp(outer).unwrap().layer(LayerId(r["layer"].as_u64().unwrap())).unwrap().clone();
|
|
assert_eq!((nl.in_point.seconds(), nl.out_point.seconds()), (1.0, 3.0));
|
|
assert_eq!(s.active_comp_id(), Some(inner));
|
|
}
|