diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 6750a327..85827f74 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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` diff --git a/crates/engine/presets/transitions.json b/crates/engine/presets/transitions.json new file mode 100644 index 00000000..386e0274 --- /dev/null +++ b/crates/engine/presets/transitions.json @@ -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 } + ] + } + ] +} diff --git a/crates/engine/presets/transitions.json.attribution b/crates/engine/presets/transitions.json.attribution new file mode 100644 index 00000000..d09c75ed --- /dev/null +++ b/crates/engine/presets/transitions.json.attribution @@ -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. diff --git a/crates/engine/src/commands/anim_presets.rs b/crates/engine/src/commands/anim_presets.rs new file mode 100644 index 00000000..e14425cb --- /dev/null +++ b/crates/engine/src/commands/anim_presets.rs @@ -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 { + serde_json::from_str::(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 { + 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 = 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 { + 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 { + 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::>()?; + 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 = Vec::new(); + let mut skipped: Vec = 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 { + 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 { + 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), + ] +} diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 98fe0cb4..70e9d345 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -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> = OnceLock::new(); diff --git a/crates/engine/src/commands/text_anim.rs b/crates/engine/src/commands/text_anim.rs index 1496af84..9268a77d 100644 --- a/crates/engine/src/commands/text_anim.rs +++ b/crates/engine/src/commands/text_anim.rs @@ -246,7 +246,7 @@ fn enable_per_char(s: &mut Session, p: &Value) -> Result { } /// JSON → property value shaped like `like`. -fn json_value(v: &Value, like: &KV) -> Option { +pub(super) fn json_value(v: &Value, like: &KV) -> Option { Some(match v { Value::Bool(b) => match like { KV::Bool(_) => KV::Bool(*b), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a9e9b606..6de6afb6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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 { effectcraft_text::families().into_iter().map(|(f, _)| f).collect() } diff --git a/crates/engine/src/tests_anim_presets.rs b/crates/engine/src/tests_anim_presets.rs new file mode 100644 index 00000000..4dd2576f --- /dev/null +++ b/crates/engine/src/tests_anim_presets.rs @@ -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 = 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()); +} diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index e60354b1..da6812be 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -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 = 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) { diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index a71bebfe..e3df3896 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -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); +} diff --git a/docs/effects-parity.md b/docs/effects-parity.md index b57b84b5..5f26587c 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -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 diff --git a/docs/gaps.md b/docs/gaps.md index acb22a63..3b8dab1e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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) |