diff --git a/contributors/people.toml b/contributors/people.toml index c5ec7060..705feb25 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -74,3 +74,7 @@ sync_github_name = true [people.dotCooCoo] real_name = "Robert Lee" sync_github_name = true + +[people.moecr7] +real_name = "Moein Saharkhiz" +display_name = "onlymoein" diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index 30237ede..ce773e54 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -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}; @@ -319,12 +320,108 @@ fn effects_p(s: &Session, p: &Value, cmd: &str) -> Result> { 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 { + 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) -> 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 { + 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 = - 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 = 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(); @@ -345,8 +442,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 { + 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")); @@ -356,6 +455,7 @@ fn paste(s: &mut Session, p: &Value) -> Result { 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![]; @@ -363,6 +463,13 @@ fn paste(s: &mut Session, p: &Value) -> Result { 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() @@ -371,8 +478,7 @@ fn paste(s: &mut Session, p: &Value) -> Result { .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); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 44dd926f..80956687 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -243,8 +243,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, /// Copied shape items (Edit ▸ Copy with groups, paths, paints or path operations selected in diff --git a/crates/engine/src/tests_effects.rs b/crates/engine/src/tests_effects.rs index 83ea9c04..6f87b4ae 100644 --- a/crates/engine/src/tests_effects.rs +++ b/crates/engine/src/tests_effects.rs @@ -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![5.0, 7.0]); + assert_eq!(keys.iter().map(|k| k.value.as_f64()).collect::>(), 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::>(), 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 = 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::>(), expected); + assert_eq!(pr.keys.iter().map(|k| k.value.as_f64()).collect::>(), 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![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(); diff --git a/docs/agents.md b/docs/agents.md index 2eba652a..ccd7c57e 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -257,6 +257,9 @@ build real projects through these interfaces; they are worked examples of everyt the ones between by equal steps, onto subframes; `merge` + `fromStart: true` make `delta` the whole drag. 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}]}`,