Files
effectcraft/crates/engine/src/tests_anim_presets.rs
T
vangel76andClaude Opus 5.5 81bec748ec #757: Transitions animation presets in Effects & Presets
Problem: EffectCraft shipped only 8 original text-animator presets against
After Effects' 621; there was no Transitions category (ranked gap #7).

Fix: an original preset library, crates/engine/presets/transitions.json
(23 presets on our own Transition effects and layer opacity: fades,
directional and diagonal wipes, clock and fan reveals, blinds and slats,
pixel/mosaic dissolves, card flips, CC grid, radial scale, line sweep and
jaws wipes; In and Out). New commands anim.applyBuiltinPreset (adds the
effects with values and keyframes from the current time, one undo step,
reports any path that doesn't fit) and anim.builtinPresets. Effects &
Presets lists them under * Animation Presets > Transitions; double-click
applies. Sidecar and ATTRIBUTION.md entry; gaps/effects-parity updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:10 +02:00

75 lines
3.9 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.
//! Built-in animation presets (Effects & Presets ▸ * Animation Presets ▸ Transitions).
use effectcraft_time::Tick;
use serde_json::json;
use crate::Session;
use crate::render::RenderOpts;
/// A 160×90 comp with one white full-frame solid; returns (session, solid).
fn session() -> (Session, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "T", "width": 160, "height": 90, "frameRate": 30, "duration": 5})).unwrap();
let solid = s.execute("layer.newSolid", json!({"name": "S", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
(s, solid)
}
/// Share of the comp's pixels that are mostly opaque at `secs`.
fn coverage(s: &Session, secs: f64) -> f64 {
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, Tick::from_seconds_f64(secs), RenderOpts { scale: 1.0, ..Default::default() });
img.data.iter().filter(|p| p[3] > 0.5).count() as f64 / img.data.len().max(1) as f64
}
#[test]
fn every_transition_preset_applies_and_transitions() {
let (mut s, solid) = session();
let list = s.execute("anim.builtinPresets", json!({"category": "Transitions"})).unwrap();
let list = list.as_array().unwrap().clone();
assert!(list.len() >= 20, "{}", list.len());
assert_eq!(crate::animation_presets().len(), list.len());
let start = 0.5;
for p in &list {
let id = p["id"].as_str().unwrap();
let incoming = match p["direction"].as_str() {
Some("in") => true,
Some("out") => false,
d => panic!("{id}: direction {d:?}"),
};
s.execute("time.set", json!({"time": start})).unwrap();
let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": id})).unwrap();
assert_eq!(r["skipped"], json!([]), "{id}: every path in the preset exists and takes its values");
let (a, b) = (coverage(&s, start), coverage(&s, start + 2.0));
let (hidden, shown) = if incoming { (a, b) } else { (b, a) };
assert!(hidden < 0.15, "{id}: hidden end covers {hidden:.3}");
assert!(shown > 0.97, "{id}: shown end covers {shown:.3}");
// Keys start at the current time: before it the layer holds the first key's state.
assert!((coverage(&s, 0.0) - a).abs() < 0.02, "{id}");
s.execute("edit.undo", json!({})).unwrap();
let l = &s.active_comp().unwrap().layers[0];
assert_eq!(l.effects().map_or(0, |fx| fx.groups().count()), 0, "{id}: undo removes the effects");
assert!(l.props.prop("transform/opacity").unwrap().keys.is_empty(), "{id}: undo removes the keys");
}
}
#[test]
fn transition_preset_by_name_and_bad_input() {
let (mut s, solid) = session();
// By name; a second application numbers the effect like Effect ▸ apply does.
s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": "Wipe In from Left"})).unwrap();
let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": "wipeInFromLeft"})).unwrap();
assert_eq!(r["effects"].as_array().unwrap().len(), 1);
let names: Vec<String> = s.active_comp().unwrap().layers[0].effects().unwrap().groups().map(|g| g.name.clone()).collect();
assert_eq!(names, ["Linear Wipe", "Linear Wipe 2"]);
let fx = s.active_comp().unwrap().layers[0].effects().unwrap().groups().next().unwrap().clone();
let completion = fx.prop("completion").unwrap();
assert_eq!(completion.keys.len(), 2);
assert_eq!(completion.keys[0].time, Tick::ZERO);
// Unknown presets and missing params are errors, not panics.
assert!(s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": "nope"})).is_err());
assert!(s.execute("anim.applyBuiltinPreset", json!({"layers": [solid]})).is_err());
// A layer without effects (a camera) refuses effect presets.
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute("anim.applyBuiltinPreset", json!({"layers": [cam], "preset": "clockReveal"})).is_err());
}