Files
effectcraft/crates/engine/src/tests_markers.rs
T
bflatasticandClaude Opus 5.5 f013bdd8d7 M13.33: nested comp markers on the precomp layer bar
- commands::markers::nested_markers: a precomp layer's comp markers at their times in the
  containing comp (start and stretch in closed form; with time remapping or a stretch over
  protected regions, the first frame whose source time reaches them), within In–Out.
- markers.nested {layer}: the same list for agents.
- Timeline: drawn on the precomp bar as outlined read-only markers (positions cached per
  revision), hover names them, double-click opens the nested comp at the marker; automation id
  timeline.layer.<id>.nestedMarker.<i>.
- Tests: engine (start, stretch, trim, time remap, non-precomp error), UI (draw, label, open).
  Docs: parity, control protocol.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:12:06 +07:00

184 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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));
}
/// A precomp layer shows its comp's markers on its bar, mapped through its timing.
#[test]
fn nested_comp_markers_follow_the_precomp_layers_timing() {
let mut s = crate::Session::default();
let pre = s.execute("comp.new", json!({"name": "Pre", "width": 32, "height": 32, "frameRate": 10, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
for (t, c) in [(1.0, "beat"), (3.0, "drop")] {
s.execute("markers.set", json!({"new": true, "time": t, "comment": c})).unwrap();
}
s.execute("comp.new", json!({"name": "Main", "width": 32, "height": 32, "frameRate": 10, "duration": 20})).unwrap();
let l = s.execute("layer.addItem", json!({"item": pre})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layers": [l], "start": 2.0})).unwrap();
let times = |s: &mut crate::Session| -> Vec<(f64, String)> {
let v = s.execute_checked("markers.nested", json!({"layer": l})).unwrap();
v.as_array().unwrap().iter().map(|m| (m["time"].as_f64().unwrap(), m["comment"].as_str().unwrap().to_string())).collect()
};
assert_eq!(times(&mut s), vec![(3.0, "beat".to_string()), (5.0, "drop".to_string())]);
// Stretched to 200 %: twice as far from the start.
s.execute("layer.timeStretch", json!({"layers": [l], "percent": 200})).unwrap();
assert_eq!(times(&mut s), vec![(4.0, "beat".to_string()), (8.0, "drop".to_string())]);
s.execute("layer.timeStretch", json!({"layers": [l], "percent": 100})).unwrap();
// Trimmed: markers outside the In–Out range are not on the bar.
s.execute("layer.timing", json!({"layers": [l], "out": 4.0})).unwrap();
assert_eq!(times(&mut s), vec![(3.0, "beat".to_string())]);
s.execute("layer.timing", json!({"layers": [l], "out": 6.0})).unwrap();
// Time remapping at half speed: nested 1 s is reached 2 s into the layer.
s.execute("layer.enableTimeRemap", json!({"layers": [l]})).unwrap();
s.execute("prop.addKey", json!({"layer": l, "path": "timeRemap", "time": 4.0, "value": 2.0})).unwrap();
let t = times(&mut s);
assert_eq!(t.first(), Some(&(4.0, "beat".to_string())), "{t:?}");
// Not a precomp layer.
let solid = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute("markers.nested", json!({"layer": solid})).is_err());
}