Merge pull request #1039 from moecr7/codex/fix-989-effect-paste-timing

# Conflicts:
#	contributors/people.toml
#	docs/gaps.md
This commit is contained in:
Brandon Thomas committed 2026-10-13 04:35:19 -04:00
commit 66c08de572
5 files changed
+370 -11

No files matched your search

+4
View File
@@ -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"
+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};
@@ -319,12 +320,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();
@@ -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<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"));
@@ -356,6 +455,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![];
@@ -363,6 +463,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()
@@ -371,8 +478,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
@@ -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<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
@@ -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}]}`,