#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>
This commit is contained in:
parent
9a7d3e7699
commit
81bec748ec
|
|
@ -65,6 +65,8 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
|
|||
`crates/engine/src/templates.rs`.
|
||||
- Text animator presets (Typewriter, Fade Up Characters, Bounce In Words, Tracking In, Scramble, Blur In, Jitter,
|
||||
Drop In Lines): `crates/engine/presets/text_animators.json` (sidecar `.attribution`).
|
||||
- Transition animation presets (23: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades):
|
||||
`crates/engine/presets/transitions.json` (sidecar `.attribution`).
|
||||
- Sample scripts (File ▸ Scripts) and ScriptUI panel: `crates/engine/scripts/Rename Layers.jsx`,
|
||||
`crates/engine/scripts/Sort Layers by In Point.jsx`, `crates/engine/scripts/Create Null at Selected Layers.jsx`,
|
||||
`crates/engine/scripts/Render Queue Batch.jsx`, `crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,255 @@
|
|||
{
|
||||
"format": "effectcraft.animationPresets/1",
|
||||
"notes": "Original EffectCraft transition presets (MIT OR Apache-2.0), written from scratch on EffectCraft's own Transition effects. Key times are seconds from the current time (layer time); effect paths are the effect's parameter ids; layer paths are property paths such as transform/opacity.",
|
||||
"presets": [
|
||||
{
|
||||
"id": "fadeIn",
|
||||
"name": "Fade In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "The layer fades up from transparent over half a second.",
|
||||
"layer": { "keys": { "transform/opacity": [[0, 0], [0.5, 100]] }, "ease": true }
|
||||
},
|
||||
{
|
||||
"id": "fadeOut",
|
||||
"name": "Fade Out",
|
||||
"category": "Transitions",
|
||||
"direction": "out",
|
||||
"description": "The layer fades down to transparent over half a second.",
|
||||
"layer": { "keys": { "transform/opacity": [[0, 100], [0.5, 0]] }, "ease": true }
|
||||
},
|
||||
{
|
||||
"id": "wipeInFromLeft",
|
||||
"name": "Wipe In from Left",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "A soft edge sweeps across from the left and reveals the layer.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.linearwipe", "set": { "angle": 270, "feather": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wipeInFromRight",
|
||||
"name": "Wipe In from Right",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "A soft edge sweeps across from the right and reveals the layer.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.linearwipe", "set": { "angle": 90, "feather": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wipeInFromTop",
|
||||
"name": "Wipe In from Top",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "The layer is revealed from the top edge downwards.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.linearwipe", "set": { "angle": 0, "feather": 30 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wipeInFromBottom",
|
||||
"name": "Wipe In from Bottom",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "The layer is revealed from the bottom edge upwards.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.linearwipe", "set": { "angle": 180, "feather": 30 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "diagonalSweepIn",
|
||||
"name": "Diagonal Sweep In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "A wide, very soft edge sweeps in from the bottom-left corner.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.linearwipe", "set": { "angle": 45, "feather": 160 }, "keys": { "completion": [[0, 100], [1.2, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wipeOutToRight",
|
||||
"name": "Wipe Out to Right",
|
||||
"category": "Transitions",
|
||||
"direction": "out",
|
||||
"description": "A soft edge travels left to right and takes the layer away.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.linearwipe", "set": { "angle": 90, "feather": 40 }, "keys": { "completion": [[0, 0], [1, 100]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "clockReveal",
|
||||
"name": "Clock Reveal",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "A clock hand sweeps once around the centre and reveals the layer.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.radialwipe", "set": { "wipe": 1, "feather": 6 }, "keys": { "completion": [[0, 100], [1.2, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fanOpen",
|
||||
"name": "Fan Open",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "The layer opens like a fan, from the top down both sides.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.radialwipe", "set": { "wipe": 2, "startAngle": 180, "feather": 10 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "clockWipeOut",
|
||||
"name": "Clock Wipe Out",
|
||||
"category": "Transitions",
|
||||
"direction": "out",
|
||||
"description": "A clock hand sweeps once around the centre and removes the layer.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.radialwipe", "set": { "feather": 6 }, "keys": { "completion": [[0, 0], [1.2, 100]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "blindsOpen",
|
||||
"name": "Blinds Open",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "Vertical slats widen until the layer is whole.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.venetian", "set": { "direction": 0, "width": 60, "feather": 4 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "slatsDown",
|
||||
"name": "Slats Down",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "Horizontal slats thicken until the layer is whole.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.venetian", "set": { "direction": 90, "width": 36, "feather": 3 }, "keys": { "completion": [[0, 100], [0.8, 0]] } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "blindsClose",
|
||||
"name": "Blinds Close",
|
||||
"category": "Transitions",
|
||||
"direction": "out",
|
||||
"description": "Vertical slats narrow until the layer is gone.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.venetian", "set": { "direction": 0, "width": 60, "feather": 4 }, "keys": { "completion": [[0, 0], [0.9, 100]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pixelDissolveIn",
|
||||
"name": "Pixel Dissolve In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "Small hard-edged blocks pop in at random until the layer is whole.",
|
||||
"effects": [
|
||||
{
|
||||
"effect": "ec.transition.blockdissolve",
|
||||
"set": { "blockWidth": 6, "blockHeight": 6, "softEdges": false },
|
||||
"keys": { "completion": [[0, 100], [1.2, 0]] }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mosaicFadeIn",
|
||||
"name": "Mosaic Fade In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "Large soft tiles fade in at random.",
|
||||
"effects": [
|
||||
{
|
||||
"effect": "ec.transition.blockdissolve",
|
||||
"set": { "blockWidth": 48, "blockHeight": 48, "feather": 16 },
|
||||
"keys": { "completion": [[0, 100], [1.2, 0]] },
|
||||
"ease": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pixelDissolveOut",
|
||||
"name": "Pixel Dissolve Out",
|
||||
"category": "Transitions",
|
||||
"direction": "out",
|
||||
"description": "Small hard-edged blocks drop out at random until the layer is gone.",
|
||||
"effects": [
|
||||
{
|
||||
"effect": "ec.transition.blockdissolve",
|
||||
"set": { "blockWidth": 6, "blockHeight": 6, "softEdges": false },
|
||||
"keys": { "completion": [[0, 0], [1.2, 100]] }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cardFlipIn",
|
||||
"name": "Card Flip In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "A grid of cards turns over, left to right, to show the layer.",
|
||||
"effects": [
|
||||
{
|
||||
"effect": "ec.transition.cardwipe",
|
||||
"set": { "rows": 4, "columns": 8, "flipAxis": 1, "flipOrder": 1, "transitionWidth": 70 },
|
||||
"keys": { "completion": [[0, 100], [1.4, 0]] },
|
||||
"ease": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cardFlipOut",
|
||||
"name": "Card Flip Out",
|
||||
"category": "Transitions",
|
||||
"direction": "out",
|
||||
"description": "A grid of cards turns over, left to right, and the layer is gone.",
|
||||
"effects": [
|
||||
{
|
||||
"effect": "ec.transition.cardwipe",
|
||||
"set": { "rows": 4, "columns": 8, "flipAxis": 1, "transitionWidth": 70 },
|
||||
"keys": { "completion": [[0, 0], [1.4, 100]] },
|
||||
"ease": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gridDoorsIn",
|
||||
"name": "Grid Doors In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "Tiles open like small doors from the centre outwards.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.ccgridwipe", "set": { "shape": 0, "tiles": 12, "reverse": true }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "lensPopIn",
|
||||
"name": "Lens Pop In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "The layer grows out of a circle that stretches the picture outwards.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.ccradialscalewipe", "set": { "reverse": true }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "lineSweepIn",
|
||||
"name": "Line Sweep In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "A stepped, slanted edge sweeps in from the lower left.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.cclinesweep", "set": { "direction": 90, "thickness": 30, "slant": 30 }, "keys": { "completion": [[0, 100], [1, 0]] } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "jawsCloseIn",
|
||||
"name": "Jaws Close In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "Toothed edges close in from the top and bottom until the layer is whole.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.ccjaws", "set": { "shape": 2, "height": 30, "width": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
asset: transitions.json
|
||||
title: EffectCraft Transitions animation presets (Fade In/Out, Wipe In from Left/Right/Top/Bottom, Diagonal Sweep In, Wipe Out to Right, Clock Reveal, Fan Open, Clock Wipe Out, Blinds Open/Close, Slats Down, Pixel Dissolve In/Out, Mosaic Fade In, Card Flip In/Out, Grid Doors In, Lens Pop In, Line Sweep In, Jaws Close In)
|
||||
author: EffectCraft contributors
|
||||
source: original work
|
||||
license: MIT OR Apache-2.0
|
||||
added: 2026-10-11 by vangel76 (Claude Code agent, #757)
|
||||
notes: Effect choices, values and key timings written from scratch for EffectCraft on its own Transition effects; not derived from any Adobe preset (.ffx) or its values.
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
//! EffectCraft's own animation preset library (Effects & Presets ▸ * Animation Presets): the
|
||||
//! Transitions category, original presets built on our Transition effects and layer opacity.
|
||||
//!
|
||||
//! Each preset adds effects (with values and keyframes) and/or keys layer properties, starting
|
||||
//! at the current time, on every selected layer. The library is data
|
||||
//! (`presets/transitions.json`), written from scratch; nothing comes from After Effects' `.ffx`
|
||||
//! presets.
|
||||
|
||||
use effectcraft_keyframe::Keyframe;
|
||||
use effectcraft_project::build::Ids;
|
||||
use effectcraft_project::{Node, Property};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::text_anim::json_value;
|
||||
use super::{CommandSpec, bad, has_layers, layer_mut, layers_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
|
||||
/// The original transition presets (`presets/transitions.json`).
|
||||
pub const TRANSITIONS_JSON: &str = include_str!("../../presets/transitions.json");
|
||||
|
||||
/// Keys per animated property, so a hostile inline preset can't allocate without bound.
|
||||
const MAX_KEYS: usize = 256;
|
||||
|
||||
fn presets() -> Vec<Value> {
|
||||
serde_json::from_str::<Value>(TRANSITIONS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn text<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
|
||||
v.get(key).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
/// (id, name, category) of every built-in animation preset, in library order.
|
||||
pub fn preset_names() -> Vec<(String, String, String)> {
|
||||
presets()
|
||||
.iter()
|
||||
.filter_map(|p| Some((text(p, "id")?.to_string(), text(p, "name")?.to_string(), text(p, "category").unwrap_or("Transitions").to_string())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_preset(want: &str) -> Option<Value> {
|
||||
presets().into_iter().find(|x| text(x, "id") == Some(want) || text(x, "name").is_some_and(|n| n.eq_ignore_ascii_case(want)))
|
||||
}
|
||||
|
||||
/// Set `pr` to `v` (when given) and replace its keys with `keys` (`[[seconds, value], …]`,
|
||||
/// seconds from `at`). Returns false when nothing in the request fits the property.
|
||||
fn set_prop(pr: &mut Property, v: Option<&Value>, keys: Option<&Value>, at: Tick, ease: bool) -> bool {
|
||||
let mut ok = true;
|
||||
if let Some(v) = v {
|
||||
match json_value(v, &pr.value) {
|
||||
Some(val) => pr.value = val,
|
||||
None => ok = false,
|
||||
}
|
||||
}
|
||||
let Some(list) = keys else { return ok };
|
||||
let Some(list) = list.as_array() else { return false };
|
||||
let mut ks: Vec<Keyframe> = Vec::new();
|
||||
for k in list.iter().take(MAX_KEYS) {
|
||||
let t = k.get(0).and_then(Value::as_f64).filter(|t| t.is_finite() && t.abs() <= 86_400.0);
|
||||
let (Some(t), Some(val)) = (t, k.get(1).and_then(|x| json_value(x, &pr.value))) else { return false };
|
||||
let mut key = Keyframe::new(at + Tick::from_seconds_f64(t), val);
|
||||
if pr.hold_only {
|
||||
key = key.hold();
|
||||
} else if ease {
|
||||
key = key.eased();
|
||||
}
|
||||
ks.push(key);
|
||||
}
|
||||
ks.sort_by_key(|k| k.time);
|
||||
ks.dedup_by_key(|k| k.time);
|
||||
let Some(first) = ks.first() else { return false };
|
||||
pr.value = first.value.clone();
|
||||
pr.keys = ks;
|
||||
ok
|
||||
}
|
||||
|
||||
/// Apply `set` / `keys` maps to the properties under `root`; returns the paths that don't exist
|
||||
/// or don't take the given values.
|
||||
fn apply_maps(root: &mut effectcraft_project::PropGroup, spec: &Value, at: Tick) -> Vec<String> {
|
||||
let ease = spec.get("ease").and_then(Value::as_bool).unwrap_or(false);
|
||||
let empty = Map::new();
|
||||
let set = spec.get("set").and_then(Value::as_object).unwrap_or(&empty);
|
||||
let keys = spec.get("keys").and_then(Value::as_object).unwrap_or(&empty);
|
||||
let mut skipped = Vec::new();
|
||||
for (path, v) in set {
|
||||
if !root.prop_mut(path).is_some_and(|pr| set_prop(pr, Some(v), None, at, false)) {
|
||||
skipped.push(path.clone());
|
||||
}
|
||||
}
|
||||
for (path, list) in keys {
|
||||
if !root.prop_mut(path).is_some_and(|pr| !pr.static_only && set_prop(pr, None, Some(list), at, ease)) {
|
||||
skipped.push(path.clone());
|
||||
}
|
||||
}
|
||||
skipped
|
||||
}
|
||||
|
||||
/// `anim.applyBuiltinPreset`: add a built-in preset's effects and keyframes to the layers.
|
||||
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "anim.applyBuiltinPreset";
|
||||
let want = str_p(p, "preset").ok_or_else(|| bad(C, "`preset` id or name required"))?;
|
||||
let preset = find_preset(want).ok_or_else(|| bad(C, format!("no animation preset `{want}`")))?;
|
||||
let name = text(&preset, "name").unwrap_or("Preset").to_string();
|
||||
let effects: Vec<(&'static effectcraft_effects::EffectSpec, Value)> = preset
|
||||
.get("effects")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|e| {
|
||||
let id = text(e, "effect").unwrap_or_default();
|
||||
effectcraft_effects::lookup(id).map(|spec| (spec, e.clone())).ok_or_else(|| bad(C, format!("preset `{name}` uses unknown effect `{id}`")))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let layer_spec = preset.get("layer").cloned();
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select a layer"));
|
||||
}
|
||||
let sizes: Vec<_> = {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
ids.iter().filter_map(|id| comp.layer(*id)).map(|l| (l.id, effectcraft_render::effect_bounds(&s.project, comp, l).0)).collect()
|
||||
};
|
||||
let now = s.time();
|
||||
let (added, skipped) = s.edit(&format!("Apply Animation Preset {name}"), None, |proj, _| {
|
||||
let mut added: Vec<u64> = Vec::new();
|
||||
let mut skipped: Vec<String> = Vec::new();
|
||||
for (lid, size) in &sizes {
|
||||
let mut next = proj.next_id;
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let at = l.layer_time(now);
|
||||
if !effects.is_empty() {
|
||||
let fx = l.props.sub_mut("effects").ok_or_else(|| bad(C, "this layer type has no effects"))?;
|
||||
for (spec, ej) in &effects {
|
||||
let same = fx.groups().filter(|g| g.match_id == spec.id).count();
|
||||
let inst = if same == 0 { spec.name.to_string() } else { format!("{} {}", spec.name, same.saturating_add(1)) };
|
||||
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), &inst, *size);
|
||||
skipped.extend(apply_maps(&mut g, ej, at).into_iter().map(|path| format!("{}/{path}", spec.id)));
|
||||
added.push(g.uid);
|
||||
fx.children.push(Node::Group(g));
|
||||
}
|
||||
}
|
||||
if let Some(ls) = &layer_spec {
|
||||
skipped.extend(apply_maps(&mut l.props, ls, at));
|
||||
}
|
||||
proj.next_id = next;
|
||||
}
|
||||
Ok((added, skipped))
|
||||
})?;
|
||||
Ok(json!({"preset": text(&preset, "id"), "effects": added, "skipped": skipped}))
|
||||
}
|
||||
|
||||
/// `anim.builtinPresets`: the library, optionally one category.
|
||||
fn list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cat = str_p(p, "category");
|
||||
Ok(Value::Array(
|
||||
presets()
|
||||
.iter()
|
||||
.filter(|x| cat.is_none_or(|c| text(x, "category").is_some_and(|k| k.eq_ignore_ascii_case(c))))
|
||||
.map(|x| {
|
||||
json!({
|
||||
"id": x.get("id"),
|
||||
"name": x.get("name"),
|
||||
"category": x.get("category"),
|
||||
"direction": x.get("direction"),
|
||||
"description": x.get("description"),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("anim.applyBuiltinPreset", "Apply Built-in Animation Preset", [], None, "{preset: id|name (see anim.builtinPresets), layers?}", has_layers, apply),
|
||||
query!("anim.builtinPresets", "Built-in Animation Presets", "{category?}", list),
|
||||
]
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
mod align;
|
||||
mod align_data;
|
||||
mod anim;
|
||||
mod anim_presets;
|
||||
pub mod anim_tools;
|
||||
mod animation;
|
||||
pub(crate) mod app_more;
|
||||
|
|
@ -162,6 +163,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(layer_menu::specs());
|
||||
v.extend(create::specs());
|
||||
v.extend(animation::specs());
|
||||
v.extend(anim_presets::specs());
|
||||
v.extend(view::specs());
|
||||
v.extend(viewer_cmds::specs());
|
||||
v.extend(shape_stroke::specs());
|
||||
|
|
@ -221,6 +223,11 @@ pub fn text_preset_list() -> Vec<(String, String)> {
|
|||
text_anim::preset_names()
|
||||
}
|
||||
|
||||
/// (id, name, category) of the built-in animation presets (Transitions…).
|
||||
pub fn builtin_preset_list() -> Vec<(String, String, String)> {
|
||||
anim_presets::preset_names()
|
||||
}
|
||||
|
||||
pub fn find(id: &str) -> Option<&'static CommandSpec> {
|
||||
// Every command execution looks its spec up: index the ~2,000 ids once.
|
||||
static INDEX: OnceLock<std::collections::HashMap<&'static str, usize>> = OnceLock::new();
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ fn enable_per_char(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
/// JSON → property value shaped like `like`.
|
||||
fn json_value(v: &Value, like: &KV) -> Option<KV> {
|
||||
pub(super) fn json_value(v: &Value, like: &KV) -> Option<KV> {
|
||||
Some(match v {
|
||||
Value::Bool(b) => match like {
|
||||
KV::Bool(_) => KV::Bool(*b),
|
||||
|
|
|
|||
|
|
@ -1046,6 +1046,8 @@ mod tests;
|
|||
#[cfg(test)]
|
||||
mod tests_3d;
|
||||
#[cfg(test)]
|
||||
mod tests_anim_presets;
|
||||
#[cfg(test)]
|
||||
mod tests_anim_tools;
|
||||
#[cfg(test)]
|
||||
mod tests_camera_track;
|
||||
|
|
@ -1124,6 +1126,11 @@ pub fn text_presets() -> Vec<(String, String)> {
|
|||
commands::text_preset_list()
|
||||
}
|
||||
|
||||
/// Built-in animation presets (Effects & Presets ▸ * Animation Presets): (id, name, category).
|
||||
pub fn animation_presets() -> Vec<(String, String, String)> {
|
||||
commands::builtin_preset_list()
|
||||
}
|
||||
|
||||
pub fn text_families() -> Vec<String> {
|
||||
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
//! 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());
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
//! Effects & Presets: search (matches highlighted), the contents menu (colour-depth / GPU
|
||||
//! filters, animation presets on/off, categories or alphabetical, refresh), Favorites (star an
|
||||
//! effect), Recently Used, "* Animation Presets" (EffectCraft's own text animation presets and
|
||||
//! the user's `.ecpreset` files) and the effect categories. Double-click applies to the
|
||||
//! effect), Recently Used, "* Animation Presets" (EffectCraft's own text animation and transition
|
||||
//! presets and the user's `.ecpreset` files) and the effect categories. Double-click applies to the
|
||||
//! selected layers; dragging an effect onto a layer (timeline, viewer, Effect Controls) applies
|
||||
//! it there.
|
||||
|
||||
|
|
@ -108,6 +108,8 @@ fn highlighted(p: &egui::Painter, pos: egui::Pos2, name: &str, query: &str, t: &
|
|||
enum Apply {
|
||||
Effect(String),
|
||||
TextPreset(String),
|
||||
/// A built-in animation preset (Transitions…) by id.
|
||||
Builtin(String),
|
||||
PresetFile(String),
|
||||
}
|
||||
|
||||
|
|
@ -340,18 +342,35 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
|
||||
// * Animation Presets: our text presets and the user's .ecpreset files.
|
||||
// * Animation Presets: our own presets (Text, Transitions…) and the user's .ecpreset files.
|
||||
if view.presets {
|
||||
let text: Vec<(String, String)> =
|
||||
effectcraft_engine::text_presets().into_iter().filter(|(_, n)| query.is_empty() || n.to_lowercase().contains(&query)).collect();
|
||||
let builtin: Vec<(String, String, String)> =
|
||||
effectcraft_engine::animation_presets().into_iter().filter(|(_, n, _)| query.is_empty() || n.to_lowercase().contains(&query)).collect();
|
||||
let mut categories: Vec<&str> = Vec::new();
|
||||
for (_, _, c) in &builtin {
|
||||
if !categories.contains(&c.as_str()) {
|
||||
categories.push(c);
|
||||
}
|
||||
}
|
||||
let files: Vec<PresetFile> =
|
||||
user_presets(&ctx, force_scan).into_iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect();
|
||||
if (!text.is_empty() || !files.is_empty()) && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) {
|
||||
let any = !text.is_empty() || !builtin.is_empty() || !files.is_empty();
|
||||
if any && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) {
|
||||
if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) {
|
||||
for (id, name) in text {
|
||||
preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, Apply::TextPreset(id.clone()), &mut apply);
|
||||
}
|
||||
}
|
||||
for cat in &categories {
|
||||
if !folder_row(app, ui, &lp, list, &mut y, &format!("presets/{cat}"), cat, 14.0, searching, None) {
|
||||
continue;
|
||||
}
|
||||
for (id, name, _) in builtin.iter().filter(|(_, _, c)| c == cat) {
|
||||
preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, Apply::Builtin(id.clone()), &mut apply);
|
||||
}
|
||||
}
|
||||
if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) {
|
||||
for f in files {
|
||||
preset_row(
|
||||
|
|
@ -418,6 +437,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let (cmd, params) = match &apply {
|
||||
Apply::Effect(id) => ("effect.apply", json!({"effect": id})),
|
||||
Apply::TextPreset(id) => ("layer.applyTextPreset", json!({"preset": id})),
|
||||
Apply::Builtin(id) => ("anim.applyBuiltinPreset", json!({"preset": id})),
|
||||
Apply::PresetFile(path) => ("anim.applyPreset", json!({"path": path})),
|
||||
};
|
||||
match app.session.execute(cmd, params) {
|
||||
|
|
|
|||
|
|
@ -362,3 +362,31 @@ fn essential_graphics_scale_keeps_constrain_proportions() {
|
|||
let unlinked = scale(&h);
|
||||
assert!(unlinked[0] > linked[0] && unlinked[1] == linked[1], "unlinked, X moves alone: {unlinked:?}");
|
||||
}
|
||||
|
||||
/// #757: Effects & Presets lists the built-in Transitions presets under * Animation Presets;
|
||||
/// double-clicking one applies it to the selected layer (effect plus keyframes).
|
||||
#[test]
|
||||
fn transition_presets_apply_from_effects_and_presets() {
|
||||
let mut h = harness();
|
||||
let layer = h.state().session.active_comp().unwrap().layers[0].id.0;
|
||||
h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap();
|
||||
open(&mut h, "effectsPresets");
|
||||
h.state_mut().ui.effects_search = "clock reveal".into();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().auto.find("effects.category.presets/Transitions").is_some(), "the Transitions folder is listed");
|
||||
let e = h.state().auto.find("effects.preset.builtin.clockReveal").expect("Clock Reveal is listed").clone();
|
||||
let p: Pos2 = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
// Both clicks in one frame: the harness' frame time is longer than a double-click's.
|
||||
for _ in 0..2 {
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
|
||||
}
|
||||
}
|
||||
h.run_steps(3);
|
||||
let comp = h.state().session.active_comp().unwrap();
|
||||
let fx = comp.layers[0].effects().unwrap().groups().next().expect("an effect was added").clone();
|
||||
assert_eq!(fx.name, "Radial Wipe");
|
||||
assert_eq!(fx.prop("completion").unwrap().keys.len(), 2);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ After Effects', and does the preset library exist. The per-effect catalogue is t
|
|||
| On the GPU | **280** (92%) | Measured: registry flags; GPU vs CPU agreement tested per effect |
|
||||
| 32-bit float, no clamping | 306 | Measured: registry flags |
|
||||
| **Rendered output matches After Effects** | **unmeasured** | No effect is compared with an After Effects render yet. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) |
|
||||
| Animation presets | **8** original text-animator presets against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`; the After Effects `Presets` folder |
|
||||
| Animation presets | **31** original presets (8 text-animator, 23 Transitions) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`; the After Effects `Presets` folder |
|
||||
| 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels and the rebuilt Effect Controls (26.5) missing | Release notes |
|
||||
|
||||
**Breadth ≈ 85%** (presets weigh in; the effects themselves are ≈ 100%). **Ready ≈ 40%**: until a
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ are grouped by area; the issue tracker has the rest.
|
|||
| 4 | **No CI gate on pull requests** | `.github/workflows` has release, packaging-lint, FreeBSD and Windows ARM64 jobs only; `cargo xtask ci` runs locally | Regressions reach main unseen; 49 community PRs landed in one batch (#598) | 4–8 h | G3 · this file |
|
||||
| 5 | **Playback and scrubbing feel** | #595 stutter on cached frames, #304, #329 slow scrubbing, #554 / #504 / #335 audio late or crackling, #586 puppet lag | Preview is where motion designers spend their day | 25–50 h | G5 · [hardware-parity.md](hardware-parity.md) |
|
||||
| 6 | **Linux and Windows interaction bugs** | #491 multi-layer edits, #488 rename arrows, #493, #492, #467 shortcuts, #268 drag and drop, #320 workspaces not persisting, #302 docking, #613 Direct3D 12 compositor, #596 GPU not detected | Many reports come from Linux; some Windows GPUs fail to start the compositor | 25–45 h | G2 · [ui-parity.md](ui-parity.md) |
|
||||
| 7 | **Animation presets: 8 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 8 original text-animator presets and read only our `.ecpreset` | Presets are how many designers start; tutorials assume them | 20–40 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) |
|
||||
| 7 | **Animation presets: 31 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 31 original presets (8 text-animator, 23 Transitions: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades; #757) and read only our `.ecpreset`. Missing: Behaviors, Backgrounds, Shapes, Synthetics, Image and more Text | Presets are how many designers start; tutorials assume them | 15–35 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) |
|
||||
| 8 | **Mask and shape bugs** | #510, #513, #527 (first Intersect/Darken mask), #509 Roto Brush size, #303 Mesh Warp, #553 pen tools on motion paths | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) |
|
||||
| 9 | **Effect behaviour bugs** | #580, #526 Fractal reset, #514 Apply Color LUT, #463 Curl Noise, #369 Motion Tile output size, #473 Cryptomatte selection, #474 multi-layer EXR decoded twice, #534–#536 community fixes pending | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) |
|
||||
| 10 | **Colour pipeline differences** | #494 32-bpc blend modes clamp and apply opacity differently; #495 no Working Gamma 2.4; #340 ProRes 4444 tagged bt709 but sRGB-encoded; ACES 2.0 / OCIO 2.5 configs (26.5) missing; #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue