#989: align pasted effect keyframes with the playhead

This commit is contained in:
Moein Saharkhiz committed 2026-10-12 10:26:35 -04:00
1 parent 9efe75330c
commit 55ef2d05db
6 files changed
+379 -13

No files matched your search

+4
View File
@@ -64,3 +64,7 @@ sync_github_name = true
[people.webdevtodayjason]
display_name = "Jason Brashear"
[people.moecr7]
real_name = "Moein Saharkhiz"
display_name = "onlymoein"
+113 -7
View File
@@ -1,7 +1,8 @@
//! Effect menu and Effect Controls gestures.
use effectcraft_project::build::Ids;
use effectcraft_project::{GroupKind, LayerId, PropGroup, Uid};
use effectcraft_project::{GroupKind, Layer, LayerId, PropGroup, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_layers, layer_mut, layers_p, str_p};
@@ -300,12 +301,108 @@ fn effects_p(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(LayerId, Uid)>> {
Ok(sel)
}
/// Edit ▸ Copy with effects selected: puts the effect instances on the effect clipboard.
fn effect_time_error(cmd: &str) -> EngineError {
bad(cmd, "an effect keyframe converts outside the supported time range")
}
/// Validate before Layer's saturating conversions, so a bad stretch or time never silently
/// moves a copied key to another frame. Normal-speed layers keep their exact integer ticks.
fn effect_time(layer: &Layer, time: Tick, to_comp: bool, cmd: &str) -> Result<Tick> {
if !layer.stretch.is_finite() || layer.stretch == 0.0 {
return Err(bad(cmd, "the layer has an invalid time stretch"));
}
let in_range = |t: i64| (Tick::MIN.0..=Tick::MAX.0).contains(&t);
if !in_range(time.0) {
return Err(effect_time_error(cmd));
}
let offset = if to_comp { time.0 } else { time.0.checked_sub(layer.start_time.0).ok_or_else(|| effect_time_error(cmd))? };
let scaled = if (layer.stretch - 100.0).abs() < 1e-9 {
offset
} else {
let scaled = if to_comp { offset as f64 * layer.stretch / 100.0 } else { offset as f64 * 100.0 / layer.stretch };
if !scaled.is_finite() || scaled < Tick::MIN.0 as f64 || scaled > Tick::MAX.0 as f64 {
return Err(effect_time_error(cmd));
}
scaled as i64
};
if !in_range(scaled) {
return Err(effect_time_error(cmd));
}
let time = if to_comp { layer.start_time.0.checked_add(scaled).ok_or_else(|| effect_time_error(cmd))? } else { scaled };
if !in_range(time) {
return Err(effect_time_error(cmd));
}
Ok(Tick(time))
}
/// Change the key time coordinate system throughout an effect, including nested controls.
/// Ease speeds are per second in that coordinate system; reversed time swaps the key sides.
fn retime_effect(group: &mut PropGroup, scale: f64, cmd: &str, mut time: impl FnMut(Tick) -> Result<Tick>) -> Result<()> {
let mut result = Ok(());
group.walk_mut(&mut |pr| {
if result.is_err() {
return;
}
for key in &mut pr.keys {
let mapped = time(key.time).and_then(|t| {
if !scale.is_finite() || scale == 0.0 {
return Err(effect_time_error(cmd));
}
// Spatial paths and non-numeric values ease along positive progress;
// numeric components instead store a signed value derivative.
let speed_scale = if pr.spatial || key.value.dims() == 0 { scale.abs() } else { scale };
for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) {
ease.speed /= speed_scale;
if !ease.speed.is_finite() {
return Err(bad(cmd, "an effect keyframe ease converts outside the supported range"));
}
}
if scale < 0.0 {
std::mem::swap(&mut key.in_interp, &mut key.out_interp);
std::mem::swap(&mut key.in_ease, &mut key.out_ease);
std::mem::swap(&mut key.spatial_in, &mut key.spatial_out);
}
Ok(t)
});
match mapped {
Ok(t) => key.time = t,
Err(e) => {
result = Err(e);
return;
}
}
}
pr.keys.sort_by_key(|key| key.time);
if pr.keys.windows(2).any(|pair| pair[0].time == pair[1].time) {
result = Err(bad(cmd, "the layer's time stretch maps effect keyframes to the same tick"));
}
});
result
}
/// Edit ▸ Copy with effects selected: times on the clipboard are comp-time offsets from the
/// earliest copied key, shared by all copied effects and their parameters (#989).
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "effect.copy";
let fx = effects_p(s, p, "effect.copy")?;
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let groups: Vec<PropGroup> =
fx.iter().filter_map(|(l, u)| comp.layer(*l).and_then(|l| l.effects()).and_then(|e| e.groups().find(|g| g.uid == *u)).cloned()).collect();
let mut groups = Vec::new();
let mut earliest: Option<Tick> = None;
for (lid, uid) in fx {
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let mut group = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == uid)).cloned().ok_or_else(|| bad(CMD, "the effect no longer exists"))?;
retime_effect(&mut group, layer.stretch / 100.0, CMD, |t| {
let t = effect_time(layer, t, true, CMD)?;
earliest = Some(earliest.map_or(t, |first| first.min(t)));
Ok(t)
})?;
groups.push(group);
}
if let Some(first) = earliest {
for group in &mut groups {
retime_effect(group, 1.0, CMD, |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error(CMD))?)))?;
}
}
s.state.clipboard.clear();
s.state.key_clipboard.clear();
s.state.contents_clipboard.clear();
@@ -326,8 +423,10 @@ pub(crate) fn unique_name(fx: &PropGroup, base: &str) -> String {
}
/// Edit ▸ Paste with effects on the clipboard: adds copies to every target layer, after the
/// layer's selected effect when it has one, else at the end of its stack.
/// layer's selected effect when it has one, else at the end of its stack. The earliest copied
/// key lands at the destination comp's CTI; all other keys keep their comp-time offsets.
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "effect.paste";
let clip = s.state.effect_clipboard.clone();
if clip.is_empty() {
return Err(bad("effect.paste", "no effects on the clipboard"));
@@ -337,6 +436,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("effect.paste", "select a layer to paste effects onto"));
}
let sel = selected_effects(s);
let cti = s.time_of(cid);
let label = if clip.len() == 1 { format!("Paste {}", clip[0].name) } else { "Paste Effects".to_string() };
let uids = s.edit(&label, None, |proj, st| {
let mut out = vec![];
@@ -344,6 +444,13 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let mut new_sel = vec![];
for lid in &ids {
let l = layer_mut(proj, cid, *lid)?;
let mut groups = clip.clone();
for group in &mut groups {
retime_effect(group, 100.0 / l.stretch, CMD, |offset| {
let t = Tick(cti.0.checked_add(offset.0).ok_or_else(|| effect_time_error(CMD))?);
effect_time(l, t, false, CMD)
})?;
}
let Some(fx) = l.props.sub_mut("effects") else { continue };
let at = sel
.iter()
@@ -352,8 +459,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
.max()
.map(|i| i + 1)
.unwrap_or(fx.children.len());
for (k, g) in clip.iter().enumerate() {
let mut g = g.clone();
for (k, mut g) in groups.into_iter().enumerate() {
g.reassign_uids(&mut next);
g.name = unique_name(fx, &g.name);
out.push(g.uid);
+2 -2
View File
@@ -220,8 +220,8 @@ pub struct EditorState {
/// Paste sets the same properties of the selected layers.
#[serde(skip)]
pub value_clipboard: Vec<(String, effectcraft_keyframe::Value)>,
/// Copied effect instances (Edit ▸ Copy with effects selected); Paste adds them to the
/// selected layers.
/// Copied effect instances, with comp-time key offsets from the earliest copied key.
/// Paste adds them to the selected layers at the destination comp's CTI.
#[serde(skip)]
pub effect_clipboard: Vec<effectcraft_project::PropGroup>,
/// Copied shape items (Edit ▸ Copy with groups, paths, paints or path operations selected in
+248 -2
View File
@@ -1,8 +1,9 @@
//! Effect Controls operations: copy/paste, duplicate, reorder, remove (Delete), reset, and the
//! Edit menu routing to them when effects are selected, all with undo.
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Layer, LayerId};
use effectcraft_keyframe::{Ease, Interp, Value as KV};
use effectcraft_project::{Layer, LayerId, PropGroup};
use effectcraft_time::Tick;
use serde_json::json;
use crate::Session;
@@ -32,6 +33,24 @@ fn select_effect(s: &mut Session, lid: u64, uid: u64) {
s.state.selected_props = vec![(LayerId(lid), uid)];
}
fn effect_group(s: &Session, lid: u64, uid: u64) -> PropGroup {
layer(s, lid).effects().unwrap().groups().find(|g| g.uid == uid).unwrap().clone()
}
fn animate_effect(s: &mut Session, lid: u64, uid: u64, path: &str, keys: &[(f64, f64)]) {
let prop = effect_group(s, lid, uid).prop(path).unwrap().uid;
for &(time, value) in keys {
s.execute("prop.addKey", json!({"layer": lid, "prop": prop, "time": time, "timeBase": "comp", "value": value})).unwrap();
}
}
fn set_layer_clock(s: &mut Session, lid: u64, start: f64, stretch: f64) {
let cid = s.active_comp_id().unwrap();
let l = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(lid)).unwrap();
l.start_time = Tick::from_seconds_f64(start);
l.stretch = stretch;
}
#[test]
fn copy_paste_effects_between_layers_with_undo() {
let (mut s, a, b) = comp_with_two_solids();
@@ -62,6 +81,233 @@ fn copy_paste_effects_between_layers_with_undo() {
assert_eq!(fx_names(&s, a), vec!["Gaussian Blur"]);
}
/// #989: copying an animated effect aligns its first key to the destination CTI.
#[test]
fn pasted_effect_keys_start_at_the_playhead() {
let (mut s, a, b) = comp_with_two_solids();
let blur = apply(&mut s, a, "Gaussian Blur");
let uid = layer(&s, a).effects().unwrap().groups().next().unwrap().get("blurriness").unwrap().uid;
for (time, value) in [(1.0, 10.0), (3.0, 30.0)] {
s.execute("prop.addKey", json!({"layer": a, "prop": uid, "time": time, "value": value})).unwrap();
}
let source = layer(&s, a);
select_effect(&mut s, a, blur);
s.execute("edit.copy", json!({})).unwrap();
s.execute("time.set", json!({"time": 5.0})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
let undo = s.history.undo.len();
s.execute("edit.paste", json!({})).unwrap();
let pasted = layer(&s, b);
let keys = &pasted.effects().unwrap().groups().next().unwrap().get("blurriness").unwrap().keys;
assert_eq!(keys.iter().map(|k| pasted.comp_time(k.time).seconds()).collect::<Vec<_>>(), vec![5.0, 7.0]);
assert_eq!(keys.iter().map(|k| k.value.as_f64()).collect::<Vec<_>>(), vec![10.0, 30.0]);
assert_eq!(layer(&s, a), source, "copy and paste do not edit the source");
assert_eq!(s.history.undo.len(), undo + 1, "the whole paste is one undo step");
s.execute("edit.undo", json!({})).unwrap();
assert!(fx_names(&s, b).is_empty());
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(layer(&s, b), pasted);
}
#[test]
fn pasted_effects_share_one_anchor_including_nested_parameters() {
let (mut s, a, b) = comp_with_two_solids();
let blur = apply(&mut s, a, "Gaussian Blur");
let fractal = apply(&mut s, a, "Fractal");
animate_effect(&mut s, a, blur, "blurriness", &[(1.0, 10.0), (3.0, 30.0)]);
animate_effect(&mut s, a, fractal, "mandelbrot/mandelbrotX", &[(2.0, 1.25), (4.0, 2.5)]);
animate_effect(&mut s, a, fractal, "mandelbrot/mandelbrotY", &[(3.0, 0.5), (5.0, 2.0)]);
let static_value = effect_group(&s, a, fractal).get("setChoice").unwrap().value.clone();
s.state.selected_props = vec![(LayerId(a), blur), (LayerId(a), fractal)];
s.execute("edit.copy", json!({})).unwrap();
s.execute("time.set", json!({"time": 5.0})).unwrap();
let reply = s.execute("effect.paste", json!({"layers": [b]})).unwrap();
for (index, path, times) in
[(0, "blurriness", vec![5.0, 7.0]), (1, "mandelbrot/mandelbrotX", vec![6.0, 8.0]), (1, "mandelbrot/mandelbrotY", vec![7.0, 9.0])]
{
let group = effect_group(&s, b, reply["effects"][index].as_u64().unwrap());
assert_eq!(group.prop(path).unwrap().keys.iter().map(|k| k.time.seconds()).collect::<Vec<_>>(), times, "{path}");
}
let group = effect_group(&s, b, reply["effects"][1].as_u64().unwrap());
assert_eq!(group.get("setChoice").unwrap().value, static_value, "unanimated controls stay unchanged");
}
#[test]
fn pasted_effects_preserve_comp_timing_and_eased_values_on_stretched_layers() {
for (source_start, source_stretch) in [(2.0, 100.0), (1.0, 200.0), (8.0, -200.0)] {
for (target_start, target_stretch) in [(0.0, 100.0), (3.0, 200.0), (8.0, -100.0)] {
let (mut s, a, b) = comp_with_two_solids();
set_layer_clock(&mut s, a, source_start, source_stretch);
set_layer_clock(&mut s, b, target_start, target_stretch);
let blur = apply(&mut s, a, "Gaussian Blur");
animate_effect(&mut s, a, blur, "blurriness", &[(3.0, 10.0), (5.0, 30.0)]);
let uid = effect_group(&s, a, blur).get("blurriness").unwrap().uid;
let cid = s.active_comp_id().unwrap();
let pr = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(a)).unwrap().props.find_mut(uid).unwrap();
for key in &mut pr.keys {
key.in_interp = Interp::Bezier;
key.out_interp = Interp::Bezier;
key.in_ease = vec![Ease { speed: 7.0, influence: 0.25 }];
key.out_ease = vec![Ease { speed: 3.0, influence: 0.6 }];
key.label = 4;
}
let source = layer(&s, a);
let source_pr = source.props.find(uid).unwrap();
s.execute("effect.copy", json!({"layer": a, "effect": blur})).unwrap();
s.execute("time.set", json!({"time": 5.0})).unwrap();
let pasted = s.execute("effect.paste", json!({"layers": [b]})).unwrap()["effects"][0].as_u64().unwrap();
let target = layer(&s, b);
let target_group = target.effects().unwrap().groups().find(|g| g.uid == pasted).unwrap();
let target_pr = target_group.get("blurriness").unwrap();
let mut times: Vec<Tick> = target_pr.keys.iter().map(|k| target.comp_time(k.time)).collect();
times.sort();
assert_eq!(times, vec![Tick::from_seconds_f64(5.0), Tick::from_seconds_f64(7.0)]);
for offset in [0.0, 0.25, 0.75, 1.5, 2.0] {
let expected = source_pr.value_at(source.layer_time(Tick::from_seconds_f64(3.0 + offset))).as_f64();
let actual = target_pr.value_at(target.layer_time(Tick::from_seconds_f64(5.0 + offset))).as_f64();
assert!((expected - actual).abs() < 1e-8, "source {source_stretch} %, target {target_stretch} %, t={offset}: {expected} != {actual}");
}
assert!(target_pr.keys.iter().all(|k| k.label == 4));
assert_eq!(layer(&s, a), source);
}
}
}
#[test]
fn pasted_spatial_effect_keys_preserve_the_eased_path_on_reversed_layers() {
for (source_stretch, target_stretch) in [(200.0, 100.0), (-200.0, 100.0), (100.0, -200.0)] {
let (mut s, a, b) = comp_with_two_solids();
set_layer_clock(&mut s, a, if source_stretch < 0.0 { 8.0 } else { 1.0 }, source_stretch);
set_layer_clock(&mut s, b, if target_stretch < 0.0 { 8.0 } else { 0.0 }, target_stretch);
let ramp = apply(&mut s, a, "Gradient Ramp");
let uid = effect_group(&s, a, ramp).get("start").unwrap().uid;
for (time, value) in [(3.0, [50.0, 25.0]), (5.0, [180.0, 75.0])] {
s.execute("prop.addKey", json!({"layer": a, "prop": uid, "time": time, "timeBase": "comp", "value": value})).unwrap();
}
let cid = s.active_comp_id().unwrap();
let pr = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(a)).unwrap().props.find_mut(uid).unwrap();
pr.spatial = true;
for key in &mut pr.keys {
key.in_interp = Interp::Bezier;
key.out_interp = Interp::Bezier;
key.in_ease = vec![Ease { speed: 15.0, influence: 0.25 }];
key.out_ease = vec![Ease { speed: 30.0, influence: 0.6 }];
key.spatial_auto = false;
key.spatial_in = [-10.0, -20.0, 0.0];
key.spatial_out = [20.0, 5.0, 0.0];
}
let source = layer(&s, a);
let source_pr = source.props.find(uid).unwrap();
s.execute("effect.copy", json!({"layer": a, "effect": ramp})).unwrap();
s.execute("time.set", json!({"time": 5.0})).unwrap();
let pasted = s.execute("effect.paste", json!({"layers": [b]})).unwrap()["effects"][0].as_u64().unwrap();
let target = layer(&s, b);
let group = target.effects().unwrap().groups().find(|g| g.uid == pasted).unwrap();
let target_pr = group.get("start").unwrap();
for offset in [0.0, 0.25, 0.75, 1.5, 2.0] {
let expected = source_pr.value_at(source.layer_time(Tick::from_seconds_f64(3.0 + offset))).components();
let actual = target_pr.value_at(target.layer_time(Tick::from_seconds_f64(5.0 + offset))).components();
assert_eq!(expected.len(), actual.len());
assert!(expected.iter().zip(&actual).all(|(a, b)| (a - b).abs() < 0.05), "path at {offset}: {expected:?} != {actual:?}");
}
}
}
#[test]
fn effect_clipboard_is_a_snapshot_and_repeated_paste_uses_the_target_comps_cti() {
let (mut s, a, _) = comp_with_two_solids();
let original_comp = s.active_comp_id().unwrap();
let blur = apply(&mut s, a, "Gaussian Blur");
animate_effect(&mut s, a, blur, "blurriness", &[(1.0, 10.0), (3.0, 30.0)]);
s.execute("effect.copy", json!({"layer": a, "effect": blur})).unwrap();
let clipboard = s.state.effect_clipboard.clone();
// Changing the source after Copy must not change what the clipboard will paste.
animate_effect(&mut s, a, blur, "blurriness", &[(4.0, 99.0)]);
s.execute("time.set", json!({"time": 9.0})).unwrap();
let destination = s.execute("comp.new", json!({"name": "Destination", "frameRate": 24, "duration": 10})).unwrap()["comp"].as_u64().unwrap();
let b = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
s.execute("time.set", json!({"time": 4.0})).unwrap();
s.open_comp(original_comp);
for (cti, expected) in [(4.0, vec![4.0, 6.0]), (2.0, vec![2.0, 4.0])] {
s.state.times.insert(effectcraft_project::ItemId(destination), Tick::from_seconds_f64(cti));
let uid = s.execute("effect.paste", json!({"comp": destination, "layers": [b]})).unwrap()["effects"][0].as_u64().unwrap();
let l = s.project.comp(effectcraft_project::ItemId(destination)).unwrap().layer(LayerId(b)).unwrap();
let pr = l.effects().unwrap().groups().find(|g| g.uid == uid).unwrap().get("blurriness").unwrap();
assert_eq!(pr.keys.iter().map(|k| l.comp_time(k.time).seconds()).collect::<Vec<_>>(), expected);
assert_eq!(pr.keys.iter().map(|k| k.value.as_f64()).collect::<Vec<_>>(), vec![10.0, 30.0]);
assert_eq!(s.state.effect_clipboard, clipboard);
}
}
#[test]
fn invalid_effect_paste_timing_leaves_every_target_and_history_unchanged() {
for stretch in [0.0, f64::NAN, f64::INFINITY, 1e-300, 1e300] {
let (mut s, a, b) = comp_with_two_solids();
let blur = apply(&mut s, a, "Gaussian Blur");
animate_effect(&mut s, a, blur, "blurriness", &[(1.0, 10.0), (3.0, 30.0)]);
s.execute("effect.copy", json!({"layer": a, "effect": blur})).unwrap();
set_layer_clock(&mut s, b, 0.0, stretch);
s.execute("time.set", json!({"time": 5.0})).unwrap();
let project = s.project.clone();
let clipboard = s.state.effect_clipboard.clone();
let undo = s.history.undo.len();
// A is valid and comes first; failure on B must roll back A's new effect too.
assert!(s.execute("effect.paste", json!({"layers": [a, b]})).is_err(), "{stretch}");
assert!(std::sync::Arc::ptr_eq(&s.project, &project));
assert_eq!(s.state.effect_clipboard, clipboard);
assert_eq!(s.history.undo.len(), undo);
}
}
#[test]
fn invalid_effect_copy_keeps_the_previous_clipboard() {
let (mut s, a, _) = comp_with_two_solids();
let blur = apply(&mut s, a, "Gaussian Blur");
animate_effect(&mut s, a, blur, "blurriness", &[(1.0, 10.0), (3.0, 30.0)]);
s.execute("effect.copy", json!({"layer": a, "effect": blur})).unwrap();
let clipboard = s.state.effect_clipboard.clone();
set_layer_clock(&mut s, a, 0.0, 0.0);
assert!(s.execute("effect.copy", json!({"layer": a, "effect": blur})).is_err());
assert_eq!(s.state.effect_clipboard, clipboard);
}
#[test]
fn effect_paste_keeps_exact_ntsc_frame_offsets() {
let (mut s, a, b) = comp_with_two_solids();
let cid = s.active_comp_id().unwrap();
let frame = effectcraft_time::FrameRate::new(30000, 1001).frame_duration();
let blur = apply(&mut s, a, "Gaussian Blur");
let uid = effect_group(&s, a, blur).get("blurriness").unwrap().uid;
let pr = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(a)).unwrap().props.find_mut(uid).unwrap();
pr.keys = vec![
effectcraft_keyframe::Keyframe::new(frame.mul_ratio(11, 1), KV::Scalar(10.0)),
effectcraft_keyframe::Keyframe::new(frame.mul_ratio(43, 1), KV::Scalar(30.0)),
];
s.execute("effect.copy", json!({"layer": a, "effect": blur})).unwrap();
let cti = frame.mul_ratio(71, 1);
s.state.times.insert(cid, cti);
let pasted = s.execute("effect.paste", json!({"layers": [b]})).unwrap()["effects"][0].as_u64().unwrap();
let group = effect_group(&s, b, pasted);
assert_eq!(group.get("blurriness").unwrap().keys.iter().map(|k| k.time).collect::<Vec<_>>(), vec![cti, cti + frame.mul_ratio(32, 1)]);
}
#[test]
fn overflowing_effect_paste_times_return_an_error_without_an_edit() {
let (mut s, a, b) = comp_with_two_solids();
let blur = apply(&mut s, a, "Gaussian Blur");
animate_effect(&mut s, a, blur, "blurriness", &[(1.0, 10.0), (3.0, 30.0)]);
s.execute("effect.copy", json!({"layer": a, "effect": blur})).unwrap();
let cid = s.active_comp_id().unwrap();
for cti in [Tick::MAX, Tick(i64::MAX)] {
s.state.times.insert(cid, cti);
let project = s.project.clone();
let undo = s.history.undo.len();
assert!(s.execute("effect.paste", json!({"layers": [b]})).is_err());
assert!(std::sync::Arc::ptr_eq(&s.project, &project));
assert_eq!(s.history.undo.len(), undo);
}
}
#[test]
fn duplicate_reorder_and_delete_selected_effect() {
let (mut s, a, _) = comp_with_two_solids();
+3
View File
@@ -250,6 +250,9 @@ build real projects through these interfaces; they are worked examples of everyt
`keys.transform {…, merge, fromStart: true}` (values are the whole transform since the drag
started). Keys of locked layers can't be selected. Copy / paste: `keys.copy`, then `keys.paste`
at the current time (keys copied from several layers go to as many selected layers in order).
Whole effects: `effect.copy`, then `effect.paste {layers}` places the earliest copied effect
key at the destination comp's current time, preserving the offsets between all copied
effects and parameters, including on offset or time-stretched layers.
`time.nextKey` / `time.previousKey` / `keys.selectAll` take `visible: [{layer, prop}]` to act
only on those properties (the Timeline passes its revealed ones, as J / K and Ctrl+Alt+A do).
* **Easing with Keyframe Velocity**: select keys by path, `keys.select {"keys":[{"layer":"Ring","path":"contents/trim/end","time":0}]}`,
+9 -2
View File
@@ -1,6 +1,6 @@
# Where EffectCraft falls short
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (re-measured against After Effects 2026 26.5; ranked gap list with estimates; numbers reconciled with target-app-parity.md) · **Target:** Adobe After Effects 2026 (26.5.0.89)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (regression evidence for animated effect paste timing, #989; estimates unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89)
An honest assessment of how far EffectCraft is from being a real replacement for After Effects,
and the work that closes the gap. This document is meant for contributors and agents choosing
@@ -65,7 +65,7 @@ are grouped by area; the issue tracker has the rest.
| 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: 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) |
| 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; #989 effect paste timing has engine regression coverage (G2 below) | 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 Working Gamma 2.4 (in, also for float footage: #750); #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) |
| 11 | **Text rendering and layout** | #453 CJK text in comps, #316 paragraph alignment inverted, #483 squeezed at 4:3, #417 soft at Fit, #583 font search, #550 toolbar font options | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) |
| 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); Z position (#489); camera aperture (#260); reflections (#261); extruded surface materials beyond Front / Bevel / Side / Back Color (#315); 3D issue reopened (#307) | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) |
@@ -106,6 +106,13 @@ The most important missing piece: it turns every other estimate here into a meas
### G2. Real-user reliability on every platform
- Regression coverage for [#989](https://github.com/storytold/effectcraft/issues/989):
`tests_effects` checks that whole-effect paste aligns the earliest copied key to the CTI,
keeps offsets across effects and nested parameters, preserves eased values on offset,
stretched and reversed layers, uses the destination comp's CTI and keeps exact NTSC frame
offsets. It also checks clipboard snapshots, repeated paste, undo/redo and atomic errors
for invalid or overflowing timing. These are engine regressions; no live After Effects
comparison is claimed.
- Fix user issues as they come in, each with a regression test, and answer the reporter (#41–#47
and #63–#68 are handled, as are the Windows report that the font menus missed installed fonts
and the reports of 6 October, #85, #88, #89, #93, #103, #106 and #117, and of 7 October, #198,