Files
effectcraft/crates/engine/src/tests/analysis_inputs.rs
T
arnav-hiwarkar 385b9530a1 #3535: Warp Stabilizer and 3D Camera Tracker keys cover a precomp's contents
On a precomp layer the key covered only the nested comp's size, rate and
duration, so an edit inside the precomp kept a stale analysis. The key now
writes the precomp layer's Essential Properties and what its comp draws, field
by field and recursively. Each comp is written once per key and nesting is
capped, so shared, deep or cyclic precomps are keyed in bounded time. Docs say
what clears an analysis.
2026-11-12 15:00:14 +05:30

198 lines
9.2 KiB
Rust

//! Warp Stabilizer and 3D Camera Tracker analyses follow their input: the layer's frames, its
//! masks, the effects above them and a precomp's contents (#3535). Edits that leave the input
//! as it was (effects below, names, labels) keep them.
use effectcraft_effects::camera_tracker::{TRACKS, TRACKS_KEY};
use effectcraft_effects::warp_stab::{ANALYSIS, ANALYSIS_KEY};
use effectcraft_keyframe::Value;
use effectcraft_project::{ItemId, LayerId, Project};
use serde_json::{Value as Json, json};
use crate::Session;
const COMP: ItemId = ItemId(2);
const CLIP: LayerId = LayerId(3);
const WARP: u64 = 26;
const TRACKER: u64 = 54;
/// `tracked-0.22.ecproj`'s clip with a mask and, in order, a Warp Stabilizer, a 3D Camera
/// Tracker, a Gaussian Blur and a Roto Brush.
const ANALYSED: &str = include_str!("fixtures/analysed-0.24.ecproj");
fn open(json: &str) -> Session {
let mut s = Session::default();
s.replace_project(Project::from_json(json).unwrap(), None);
s.open_comp(COMP);
s
}
/// Which keys an analysis saves.
#[derive(Clone, Copy, Debug)]
enum Saved {
/// Today's: the input to the instance.
Input,
/// Before #3535: the layer's frames only (still recognised).
Frames,
}
/// The key an analysis of instance `uid` (no Detailed Analysis) saves.
fn key(p: &Project, uid: u64, saved: Saved) -> String {
let layer = p.comp(COMP).unwrap().layer(CLIP).unwrap();
match saved {
Saved::Input => crate::analysis::frames_key::analysis_key(p, COMP, layer, uid),
Saved::Frames => crate::analysis::frames_key::signature(p, COMP, layer),
}
}
/// Give the Warp Stabilizer and the tracker an analysis of the clip as it is now, saved with
/// `saved` keys, and reopen the project.
fn analyse(s: &mut Session, saved: Saved) {
let mut p = (*s.project).clone();
let set = |p: &mut Project, uid: u64, data: &str, id: &str, key: &str| {
let g = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap().props.find_group_mut(uid).unwrap();
g.prop_mut(data).unwrap().value = Value::Str(format!("saved {data}"));
g.prop_mut(id).unwrap().value = Value::Str(key.into());
};
// The Warp Stabilizer is above the tracker: its analysis is part of the tracker's input.
let k = key(&p, WARP, saved);
set(&mut p, WARP, ANALYSIS, ANALYSIS_KEY, &k);
let k = key(&p, TRACKER, saved);
set(&mut p, TRACKER, TRACKS, TRACKS_KEY, &k);
*s = open(&p.to_json());
run(s, "renderQueue.add", json!({"comp": COMP.0, "format": "png", "output": "frame.png"}));
assert_eq!(analysed(s), [true, true], "{saved:?}");
}
/// Whether the Warp Stabilizer and the tracker still have their analysis.
fn analysed(s: &Session) -> [bool; 2] {
let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap();
let has = |uid: u64, id: &str| matches!(&layer.props.find_group(uid).unwrap().prop(id).unwrap().value, Value::Str(s) if !s.is_empty());
[has(WARP, ANALYSIS), has(TRACKER, TRACKS)]
}
fn run(s: &mut Session, cmd: &str, params: Json) -> Json {
s.execute_checked(cmd, params).unwrap()
}
fn undo(s: &mut Session) {
run(s, "edit.undo", json!({}));
}
/// An edit, its effect on the two analyses, and that undo brings them back.
fn check(s: &mut Session, cmd: &str, params: Json, kept: [bool; 2]) {
assert_eq!(analysed(s), [true, true], "before {cmd} {params}");
run(s, cmd, params.clone());
assert_eq!(analysed(s), kept, "{cmd} {params}");
undo(s);
assert_eq!(analysed(s), [true, true], "undo of {cmd} {params}");
}
#[test]
fn masks_and_effects_above_are_part_of_the_input() {
for saved in [Saved::Input, Saved::Frames] {
masks_and_effects_above(saved);
}
}
fn masks_and_effects_above(saved: Saved) {
let mut s = open(ANALYSED);
analyse(&mut s, saved);
// The layer's mask is under every effect.
check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "masks/#1/expansion", "value": 3}), [false, false]);
// The Warp Stabilizer is above the tracker: its settings change the tracker's input only.
check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "effects/#1/stabilization/smoothness", "value": 20}), [true, false]);
// The Gaussian Blur and the Roto Brush are below both.
check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "effects/#3/blurriness", "value": 4}), [true, true]);
run(&mut s, "layer.rename", json!({"layer": CLIP.0, "name": "Renamed"}));
assert_eq!(analysed(&s), [true, true]);
// The Effects switch turns the Warp Stabilizer (above the tracker) off.
check(&mut s, "layer.setSwitch", json!({"layers": [CLIP.0], "switch": "effects", "value": false}), [true, false]);
// An effect moved above the Warp Stabilizer is part of both inputs, and its settings too.
s.edit("blur first", None, |p, _| {
let fx = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap().props.sub_mut("effects").unwrap();
let blur = fx.children.remove(2);
fx.children.insert(0, blur);
Ok(())
})
.unwrap();
assert_eq!(analysed(&s), [false, false]);
analyse(&mut s, saved);
check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "effects/#1/blurriness", "value": 4}), [false, false]);
// Saved and reopened, they still match, and a mask edit still clears them.
let mut s = open(&s.project.to_json());
run(&mut s, "renderQueue.add", json!({"comp": COMP.0, "format": "png", "output": "frame.png"}));
assert_eq!(analysed(&s), [true, true]);
check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "masks/#1/expansion", "value": 3}), [false, false]);
}
#[test]
fn a_precomps_contents_are_part_of_the_input() {
for saved in [Saved::Input, Saved::Frames] {
precomp_contents(saved);
}
}
fn precomp_contents(saved: Saved) {
let mut s = open(ANALYSED);
// Pre-compose the clip, leaving its masks and effects on the precomp layer.
let r = run(&mut s, "layer.precompose", json!({"layers": [CLIP.0], "mode": "leave"}));
let inner = ItemId(r["comp"].as_u64().unwrap());
let inner_layer = |s: &Session, comp: ItemId| s.project.comp(comp).unwrap().layers[0].id.0;
let clip = inner_layer(&s, inner);
analyse(&mut s, saved);
// Edits inside the precomp change the frames.
check(&mut s, "prop.set", json!({"comp": inner.0, "layer": clip, "path": "transform/opacity", "value": 50}), [false, false]);
check(&mut s, "effect.apply", json!({"comp": inner.0, "layers": [clip], "effect": "ec.blur.gaussian"}), [false, false]);
check(&mut s, "layer.timing", json!({"comp": inner.0, "layers": [clip], "out": 0.25}), [false, false]);
// A name, label or comment inside it doesn't.
run(&mut s, "layer.rename", json!({"comp": inner.0, "layer": clip, "name": "Inner"}));
assert_eq!(analysed(&s), [true, true]);
// A precomp inside the precomp: its contents too.
let r = run(&mut s, "layer.precompose", json!({"comp": inner.0, "layers": [clip], "mode": "leave"}));
let deeper = ItemId(r["comp"].as_u64().unwrap());
assert_eq!(analysed(&s), [false, false], "pre-composing inside the precomp changes its contents");
analyse(&mut s, saved);
let clip = inner_layer(&s, deeper);
check(&mut s, "prop.set", json!({"comp": deeper.0, "layer": clip, "path": "transform/opacity", "value": 50}), [false, false]);
// Saved and reopened, they still match, and an edit inside still clears them.
let mut s = open(&s.project.to_json());
run(&mut s, "renderQueue.add", json!({"comp": COMP.0, "format": "png", "output": "frame.png"}));
assert_eq!(analysed(&s), [true, true]);
check(&mut s, "prop.set", json!({"comp": deeper.0, "layer": clip, "path": "transform/opacity", "value": 50}), [false, false]);
}
/// Precomps used many times, nested deeply or (in a broken project) in a cycle are keyed in
/// bounded time, and an edit deep inside still changes the key.
#[test]
fn shared_deep_and_cyclic_precomps_are_keyed_in_bounded_time() {
use effectcraft_color::Label;
use effectcraft_project::{ItemKind, LayerSource};
let mut s = open(ANALYSED);
let r = run(&mut s, "layer.precompose", json!({"layers": [CLIP.0], "mode": "leave"}));
let inner = ItemId(r["comp"].as_u64().unwrap());
let mut p = (*s.project).clone();
let template = p.comp(inner).unwrap().clone();
// 40 comps, each drawing the next one twice (2^40 paths if each were walked), the last
// drawing the first.
let ids: Vec<ItemId> = (0..40).map(|i| p.add_item(&format!("Nest {i}"), Label::default(), None, ItemKind::Comp(template.clone().into()))).collect();
for (i, id) in ids.iter().enumerate() {
let next = ids[(i + 1) % ids.len()];
let mut layer = template.layers[0].clone();
layer.source = LayerSource::Comp { item: next };
let c = p.comp_mut(*id).unwrap();
c.layers.push(layer.clone());
layer.id = LayerId(layer.id.0 + 1000);
c.layers.push(layer);
}
p.comp_mut(inner).unwrap().layers[0].source = LayerSource::Comp { item: ids[0] };
let key = |p: &Project| crate::analysis::frames_key::analysis_key(p, COMP, p.comp(COMP).unwrap().layer(CLIP).unwrap(), WARP);
let start = std::time::Instant::now();
let before = key(&p);
assert!(start.elapsed() < std::time::Duration::from_secs(2), "{:?}", start.elapsed());
p.comp_mut(ids[10]).unwrap().background = [1.0, 0.0, 0.0];
assert_ne!(key(&p), before);
}