Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
bflatastic
2026-10-30 14:04:46 +07:00
109 changed files with 4958 additions and 519 deletions
+1 -1
View File
@@ -164,7 +164,7 @@ pub use enabled::always;
pub(crate) use enabled::{has_comp, has_keys, has_layers, has_project_selection, not_yet, not_yet_run};
pub use params::match_path_of;
pub(crate) use params::{
b_p, bad, comp_id, f_p, frontend, layer_mut, layer_p, layers_p, merge_p, popup_options, resolve_effect_path, resolve_layer, selectable,
b_p, bad, comp_id, f_p, frontend, layer_mut, layer_p, layers_or, layers_p, merge_p, popup_options, resolve_effect_path, resolve_layer, selectable,
selected_leaf_props, str_p, time_p, unlocked, visible_p,
};
pub use schema::{accepted_params, check_params, params_schema};
+35 -6
View File
@@ -56,6 +56,10 @@ fn compatible(a: &Property, b: &effectcraft_keyframe::Value) -> bool {
a.value.kind_name() == b.kind_name() && a.value.components().len() == b.components().len()
}
pub(crate) fn locked_selection(comp: &effectcraft_project::Comp, selected: &[KeyRef]) -> Vec<KeyRef> {
selected.iter().filter(|k| comp.layer(k.layer).is_some_and(|l| l.switches.locked)).copied().collect()
}
/// Paste copied keyframes at the current time into the target layers' properties.
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let clip = s.state.key_clipboard.clone();
@@ -90,6 +94,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let mut plan: Vec<(LayerId, Uid, usize)> = vec![];
for (ti, lid) in targets.iter().enumerate() {
let Some(l) = comp.layer(*lid) else { continue };
if l.switches.locked {
continue;
}
for (ci, c) in clip.iter().enumerate() {
if paired && sources.get(ti) != Some(&c.layer) {
continue;
@@ -111,11 +118,12 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
}
}
if plan.is_empty() {
return Err(bad("keys.paste", "no compatible property to paste into (select a property of the same type)"));
return Err(bad("keys.paste", "no compatible unlocked property to paste into (unlock the target or select a property of the same type)"));
}
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut sel = vec![];
let mut sel = locked_selection(comp, &st.selected_keys);
let retained = sel.len();
for (lid, uid, ci) in &plan {
let Some(l) = comp.layer_mut(*lid) else { continue };
let times: Vec<Tick> = clip[*ci].keys.iter().map(|k| l.layer_time(t + k.time)).collect();
@@ -137,8 +145,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
}
retime_roving(&mut pr.keys, pr.spatial);
}
let pasted = sel.len().saturating_sub(retained);
st.selected_keys = sel;
Ok(json!(st.selected_keys.len()))
Ok(json!(pasted))
})
}
@@ -176,6 +185,9 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = time_p(s, p, Some(comp));
let target = comp.layer(lid).ok_or(EngineError::NoComp)?;
if target.switches.locked {
return Err(bad("keys.paste", "the target layer is locked"));
}
let mut order: Vec<usize> = (0..path.vertices.len()).collect();
if path.closed && !order.is_empty() {
order.push(0);
@@ -203,6 +215,9 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L
})
.collect();
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
let comp = proj.comp(cid).ok_or(EngineError::NoComp)?;
let mut selected = locked_selection(comp, &st.selected_keys);
let retained = selected.len();
let l = super::layer_mut(proj, cid, lid)?;
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.paste", "no property"))?;
let mut sel = vec![];
@@ -217,8 +232,10 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L
// Re-timing keeps the keys' order, so the pasted keys are found again by index.
let pasted: Vec<usize> = pr.keys.iter().enumerate().filter(|(_, k)| sel.contains(&k.time)).map(|(i, _)| i).collect();
retime_roving(&mut pr.keys, pr.spatial);
st.selected_keys = pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect();
Ok(json!(st.selected_keys.len()))
selected.extend(pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }));
let count = selected.len().saturating_sub(retained);
st.selected_keys = selected;
Ok(json!(count))
})
}
@@ -297,6 +314,9 @@ fn set_key(s: &mut Session, p: &Value) -> Result<Value> {
let base = KeyTimeBase::from_params(p, "keys.set")?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
if layer.switches.locked {
return Err(bad("keys.set", "the layer is locked"));
}
let t = key_time(layer, comp.frame_rate, p, "time", base, "keys.set", false)?.ok_or_else(|| bad("keys.set", "missing `time` (layer time by default)"))?;
let new_t = key_time(layer, comp.frame_rate, p, "newTime", base, "keys.set", true)?;
let value = p.get("value").cloned();
@@ -350,6 +370,9 @@ fn set_ease(s: &mut Session, p: &Value) -> Result<Value> {
let influence = f_p(p, "influence").map(|v| (v / 100.0).clamp(0.001, 1.0));
s.edit("Keyframe Velocity", merge_p(p), |proj, _| {
let l = super::layer_mut(proj, cid, lid)?;
if l.switches.locked {
return Err(bad("keys.setEase", "the layer is locked"));
}
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setEase", "no property"))?;
let i = nearest_key(&pr.keys, t).ok_or_else(|| bad("keys.setEase", "no keyframes"))?;
let spatial = pr.spatial;
@@ -496,7 +519,13 @@ fn time_reverse(s: &mut Session, _: &Value) -> Result<Value> {
}
let mut new_sel = vec![];
for ((lid, uid), times) in groups {
let Some(pr) = comp.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) else { continue };
let Some(layer) = comp.layer_mut(lid) else { continue };
// A locked layer's keys stay as they are, and stay selected.
if layer.switches.locked {
new_sel.extend(times.iter().map(|time| KeyRef { layer: lid, prop: uid, time: *time }));
continue;
}
let Some(pr) = layer.props.find_mut(uid) else { continue };
let (mut picked, rest): (Vec<Keyframe>, Vec<Keyframe>) = pr.keys.drain(..).partition(|k| times.contains(&k.time));
effectcraft_keyframe::time_reverse(&mut picked);
new_sel.extend(picked.iter().map(|k| KeyRef { layer: lid, prop: uid, time: k.time }));
+3 -2
View File
@@ -213,7 +213,8 @@ fn sample_curve(
fn grouped_selection(s: &Session) -> std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> {
let mut g: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
for k in &s.state.selected_keys {
let Some(comp) = s.active_comp() else { return g };
for k in super::prop::editable_keys(comp, &s.state.selected_keys) {
g.entry((k.layer, k.prop)).or_default().push(k.time);
}
for v in g.values_mut() {
@@ -273,7 +274,7 @@ fn run_on_selection(
s.edit(label, None, |proj, st| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let fr = comp.frame_rate;
let mut sel = vec![];
let mut sel = super::anim::locked_selection(comp, &st.selected_keys);
for ((lid, uid), times) in groups {
let Some(l) = comp.layer_mut(lid) else { continue };
let lc = l.clone();
+2 -1
View File
@@ -271,7 +271,8 @@ fn add_keyframe(s: &mut Session, _: &Value) -> Result<Value> {
/// Selected keys grouped by (layer, property).
fn keys_by_prop(s: &Session) -> BTreeMap<(LayerId, Uid), Vec<Tick>> {
let mut m: BTreeMap<(LayerId, Uid), Vec<Tick>> = BTreeMap::new();
for k in &s.state.selected_keys {
let Some(comp) = s.active_comp() else { return m };
for k in super::prop::editable_keys(comp, &s.state.selected_keys) {
m.entry((k.layer, k.prop)).or_default().push(k.time);
}
m
+43 -11
View File
@@ -6,7 +6,7 @@ use effectcraft_project::{Comp, Expression, ItemId, ItemKind, Layer, LayerId, La
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, has_comp, has_layers, layers_p, match_path_of, selected_leaf_props, str_p};
use super::{CommandSpec, has_comp, has_layers, layers_or, layers_p, match_path_of, selected_leaf_props, str_p};
use crate::{EngineError, KeyRef, LinkClip, Result, Session, cmd};
fn can_undo(s: &Session) -> std::result::Result<(), String> {
@@ -104,9 +104,16 @@ pub(crate) fn reid(layer: &mut Layer, next: &mut u64) {
*next += 1;
}
/// `edit.duplicate`: copies the targets in place. Without `layers` the targets are, in order:
/// during a text edit the edited layer (the edit is committed first, as `text.endEdit` does),
/// else the selected effects, shape items or text animators, else the selected layers. Layer
/// copies are selected and their ids returned; the other paths return their delegated result.
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
// Effects selected → duplicate them (Edit ▸ Duplicate in Effect Controls).
if p.get("layers").is_none() {
let editing = s.state.text_edit.is_some();
// Effects selected → duplicate them (Edit ▸ Duplicate in Effect Controls). An open text edit
// comes first, as it does for Clear: opening the editor keeps the property selection, but
// while typing, Duplicate means the edited layer, not an effect or item selected before.
if p.get("layers").is_none() && !editing {
let fx = super::effect::selected_effects(s);
if !fx.is_empty() {
let mut out = vec![];
@@ -126,7 +133,17 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
return super::prop_groups::duplicate_contents(s, &items);
}
}
let (cid, ids) = layers_p(s, p)?;
// A deselected layer never keeps an open text edit (#2226): the copies take the selection, so
// commit an open edit first, exactly as `text.endEdit` and Deselect All do — one call on the
// command path so every route (menu, shortcut, palette, MCP, script) ends the edit and leaves
// the same final state. Unnamed, the target is the edited layer even when an engine route
// selected another one meanwhile.
let edited = s.state.text_edit.as_ref().map(|e| e.layer);
super::text_edit::end_edit(s, &Value::Null)?;
let (cid, ids) = match edited {
Some(layer) => layers_or(s, p, &[layer])?,
None => layers_p(s, p)?,
};
let new = s.edit("Duplicate", None, |proj, st| {
let mut next = proj.next_id;
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
@@ -143,14 +160,16 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
}
relink_copies(comp, &map);
proj.next_id = next;
st.selected_layers = created.clone();
// The property, keyframe and mask-vertex selection the edit kept belongs to the originals;
// left in place, the next Duplicate or Clear would act on it, not on the copies. So during
// an edit the copies take the selection the way a click on them would.
if editing {
super::layer::select_only(st, created.clone());
} else {
st.selected_layers = created.clone();
}
Ok(created)
})?;
// Duplicating selects the copies, so an open text edit on a now-deselected layer ends
// (#2226): typing must not keep editing an unselected layer.
if s.state.text_edit.as_ref().is_some_and(|e| !s.state.selected_layers.contains(&e.layer)) {
super::text_edit::end_edit(s, &Value::Null)?;
}
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
}
@@ -968,7 +987,15 @@ pub fn specs() -> Vec<CommandSpec> {
paste_reversed
),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?} (effects or shape items when they are selected)", has_layers, duplicate),
cmd!(
"edit.duplicate",
"Duplicate",
["Edit"],
Some("Cmd+D"),
"{layers?} (without layers: during a text edit the edited layer, else the selected effects, shape items or text animators, else the selected layers)",
layers_or_text_edit,
duplicate
),
cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split),
cmd!("edit.liftWorkArea", "Lift Work Area", ["Edit"], None, "{layers?}", has_comp, lift),
cmd!("edit.extractWorkArea", "Extract Work Area", ["Edit"], None, "{layers?}", has_comp, extract),
@@ -1038,6 +1065,11 @@ fn layers_or_keys(s: &Session) -> std::result::Result<(), String> {
if !s.state.selected_keys.is_empty() || !s.state.selected_vertices.is_empty() { super::has_comp(s) } else { has_layers(s) }
}
/// Duplicate's unnamed target: the edited layer during a text edit, else the selection.
fn layers_or_text_edit(s: &Session) -> std::result::Result<(), String> {
if s.state.text_edit.is_some() { super::has_comp(s) } else { has_layers(s) }
}
fn always_ok(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
+7
View File
@@ -72,6 +72,10 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
let mut new_sel = vec![];
for ((lid, uid), times) in groups {
let Some(layer) = comp.layer_mut(lid) else { continue };
if layer.switches.locked {
new_sel.extend(times.into_iter().map(|time| KeyRef { layer: lid, prop: uid, time }));
continue;
}
let (start, stretch) = (layer.start_time, layer.stretch);
let to_comp = |lt: Tick| if (stretch - 100.0).abs() < 1e-9 { start + lt } else { start + Tick((lt.0 as f64 * stretch / 100.0) as i64) };
let to_layer = |ct: Tick| {
@@ -122,6 +126,9 @@ fn spatial_tangents(s: &mut Session, p: &Value) -> Result<Value> {
let brk = b_p(p, "break").unwrap_or(false);
s.edit("Edit Spatial Tangents", merge_p(p), |proj, _| {
let l = super::layer_mut(proj, cid, lid)?;
if l.switches.locked {
return Err(bad("keys.setSpatialTangents", "the layer is locked"));
}
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setSpatialTangents", "no property"))?;
if !pr.spatial {
return Err(bad("keys.setSpatialTangents", "not a spatial property"));
+2
View File
@@ -37,6 +37,8 @@ pub(crate) fn label_keys(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("keys.label", "select keyframes first"));
}
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let keys = super::prop::editable_keys(comp, &keys);
let n = s.edit("Keyframe Label", None, |proj, _| {
let mut n = 0;
for k in &keys {
+10 -1
View File
@@ -28,6 +28,7 @@ pub(crate) use create::{color_p, index_p, insert_layer, insert_layer_at, place,
pub(crate) use parent::parent_fixes;
use parent::set_parent;
use replace::{has_replace_source, replace_source};
pub(crate) use select::select_only;
use select::{select, select_next, select_prev, select_step};
use switches::{blend, rename, set_comment, set_switch, track_matte};
use timing::{arrange, move_frames, precompose, slip, timing};
@@ -147,7 +148,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("layer.moveLater", "Move Layers 1 Frame Later", [], Some("Alt+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 1)),
cmd!("layer.moveEarlier10", "Move Layers 10 Frames Earlier", [], Some("Alt+Shift+PageUp"), "{layers?}", has_layers, |s, p| move_frames(s, p, -10)),
cmd!("layer.moveLater10", "Move Layers 10 Frames Later", [], Some("Alt+Shift+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 10)),
cmd!("layer.slip", "Slip Edit", [], None, "{layers?, delta? (seconds) | frames?, merge?}", has_layers, slip),
cmd!(
"layer.slip",
"Slip Edit",
[],
None,
"{layers?, delta? (seconds) | frames?, merge?} → {requested, slipped: [{layer, delta, clamped}]}",
has_layers,
slip
),
cmd!("layer.slipBack", "Slip Edit 1 Frame Earlier", [], None, "{layers?}", has_layers, |s, p| {
let mut p = p.clone();
p["frames"] = json!(-1);
+11 -5
View File
@@ -25,15 +25,21 @@ pub(super) fn select(s: &mut Session, p: &Value) -> Result<Value> {
}
}
} else {
s.state.selected_layers = ids;
s.state.selected_props.clear();
s.state.selected_keys.clear();
let keep = s.state.selected_layers.clone();
s.state.selected_vertices.retain(|v| keep.contains(&v.layer));
select_only(&mut s.state, ids);
}
Ok(json!(s.state.selected_layers.iter().map(|l| l.0).collect::<Vec<_>>()))
}
/// Make `ids` the whole layer selection, as a plain click on them does: no property or keyframe
/// stays selected, and mask vertices stay selected only on layers that are still selected.
pub(crate) fn select_only(st: &mut crate::EditorState, ids: Vec<LayerId>) {
st.selected_layers = ids;
st.selected_props.clear();
st.selected_keys.clear();
let keep = st.selected_layers.clone();
st.selected_vertices.retain(|v| keep.contains(&v.layer));
}
pub(super) fn select_step(s: &mut Session, p: &Value, dir: i64) -> Result<Value> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
if comp.layers.is_empty() {
+7 -3
View File
@@ -84,8 +84,9 @@ pub(super) fn move_frames(s: &mut Session, p: &Value, frames: i64) -> Result<Val
/// Slip edit: move a layer's source (footage or precomp) under its fixed in and out points, like
/// dragging the source-duration bar in After Effects. `delta` in seconds or `frames`; positive
/// moves the source later. Clamped so the source still covers the in–out span. Keyframes and
/// layer markers are in layer time, so they travel with the source.
/// moves the source later. Clamped so the source still covers the in–out span: the result gives
/// the `requested` shift and, per layer, the `delta` applied and whether it was `clamped` (#2371).
/// Keyframes and layer markers are in layer time, so they travel with the source.
pub(super) fn slip(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
@@ -130,7 +131,10 @@ pub(super) fn slip(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
Ok(json!({"slipped": shifts.iter().map(|(id, d)| json!({"layer": id.0, "delta": d.seconds()})).collect::<Vec<_>>()}))
Ok(json!({
"requested": delta.seconds(),
"slipped": shifts.iter().map(|(id, d)| json!({"layer": id.0, "delta": d.seconds(), "clamped": *d != delta})).collect::<Vec<_>>(),
}))
}
pub(super) fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
+7 -5
View File
@@ -438,20 +438,22 @@ fn interpolate(s: &mut Session, p: &Value) -> Result<Value> {
// The layer: given, else the layer of selected Mask Path keys, else the selection.
let cid = super::comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let selected = super::prop::editable_keys(comp, &s.state.selected_keys);
let lid = match p.get("layer") {
Some(v) => super::resolve_layer(comp, v).ok_or_else(|| bad(CMD, format!("no layer {v}")))?,
None => match s.state.selected_keys.iter().find(|k| comp.layer(k.layer).is_some_and(|l| l.masks().is_some_and(|m| m.find(k.prop).is_some()))) {
None => match selected.iter().find(|k| comp.layer(k.layer).is_some_and(|l| l.masks().is_some_and(|m| m.find(k.prop).is_some()))) {
Some(k) => k.layer,
None => layer_p(s, p, CMD)?.1,
},
};
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
if !super::prop::key_layer_editable(comp, lid) {
return Err(bad(CMD, "the layer is locked"));
}
let masks = layer.masks().ok_or_else(|| bad(CMD, "the layer has no masks"))?;
let uid = match p.get("mask") {
Some(_) => mask_uid(s, layer, p),
None => s
.state
.selected_keys
None => selected
.iter()
.filter(|k| k.layer == lid)
.find_map(|k| masks.groups().find(|g| g.get("path").is_some_and(|pr| pr.uid == k.prop)).map(|g| g.uid))
@@ -466,7 +468,7 @@ fn interpolate(s: &mut Session, p: &Value) -> Result<Value> {
let ts: Vec<Tick> = a.iter().filter_map(Value::as_f64).map(|t| layer.layer_time(comp.frame_rate.snap_nearest(Tick::from_seconds_f64(t)))).collect();
ts.into_iter().filter_map(|t| path_prop.keys.iter().find(|k| (k.time.0 - t.0).abs() <= comp.frame_duration().0 / 2).map(|k| k.time)).collect()
}
None => s.state.selected_keys.iter().filter(|k| k.layer == lid && k.prop == path_prop.uid).map(|k| k.time).collect(),
None => selected.iter().filter(|k| k.layer == lid && k.prop == path_prop.uid).map(|k| k.time).collect(),
};
times.sort();
times.dedup();
+6 -1
View File
@@ -148,6 +148,11 @@ pub(crate) fn layer_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Laye
/// `layers` param (array) or the selection.
pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec<LayerId>)> {
layers_or(s, p, &s.state.selected_layers)
}
/// `layers` param (array), else `layer`, else those of `fallback` still in the comp.
pub(crate) fn layers_or(s: &Session, p: &Value, fallback: &[LayerId]) -> Result<(ItemId, Vec<LayerId>)> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
// Explicit references that match no layer are an error (agents otherwise get a silent no-op).
@@ -159,7 +164,7 @@ pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec<LayerId>)>
if let Some(v) = p.get("layer").filter(|v| !v.is_null()) {
return Ok((cid, vec![resolve(v)?]));
}
Ok((cid, s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect()))
Ok((cid, fallback.iter().copied().filter(|l| comp.layer(*l).is_some()).collect()))
}
/// Whether a layer can be picked by the user (Select All, Ctrl+Up/Down, a Timeline click): not
+66 -10
View File
@@ -247,7 +247,8 @@ fn property_reply(p: &Value, layer: &Layer, pr: &Property, time: Tick, value: Va
/// property (by match path, `transform/position`, `effects/<effect>#n/<param>`) of every other
/// selected, unlocked layer of the comp whose value is of the same kind, as (layer, uid). Empty
/// when `selected` is not set or the edited layer isn't selected. After Effects applies a value
/// edit, the stopwatch and the keyframe button to all selected layers (#355, #491).
/// edit, the stopwatch and the keyframe button to all selected layers (#355, #491). A parameter
/// of a selected Puppet pin also reaches the same parameter of the other selected pins (#1751).
fn selected_twins(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(LayerId, Uid)>> {
if b_p(p, "selected") != Some(true) {
return Ok(vec![]);
@@ -260,14 +261,31 @@ fn selected_twins(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(LayerId, Uid
return Ok(vec![]);
}
let kind = (pr.value.kind_name(), pr.value.components().len());
Ok(s.state
let same_kind = |q: &&Property| (q.value.kind_name(), q.value.components().len()) == kind;
let mut twins: Vec<(LayerId, Uid)> = s
.state
.selected_layers
.iter()
.filter(|o| **o != lid)
.filter_map(|o| comp.layer(*o))
.filter(|l| !l.switches.locked)
.filter_map(|l| l.props.prop(&path).filter(|q| (q.value.kind_name(), q.value.components().len()) == kind).map(|q| (l.id, q.uid)))
.collect())
.filter_map(|l| l.props.prop(&path).filter(same_kind).map(|q| (l.id, q.uid)))
.collect();
// A parameter of a selected Puppet pin: the same parameter of every other selected pin, on
// this layer too, so several pins move or key together (#1751).
let pin = super::puppet::layer_pins(layer).find(|g| g.props().any(|q| q.uid == uid)).map(|g| g.uid);
if let Some(pin) = pin
&& s.state.selected_props.contains(&(lid, pin))
{
for (ol, ou) in &s.state.selected_props {
let Some(other) = comp.layer(*ol).filter(|l| !l.switches.locked) else { continue };
let Some(q) = super::puppet::layer_pins(other).find(|g| g.uid == *ou).and_then(|g| g.get(&pr.match_id)).filter(same_kind) else { continue };
if (*ol, q.uid) != (lid, uid) && !twins.contains(&(*ol, q.uid)) {
twins.push((*ol, q.uid));
}
}
}
Ok(twins)
}
/// `p` aimed at property `uid` of layer `lid` instead.
@@ -286,8 +304,13 @@ fn twin_params(p: &Value, lid: LayerId, uid: Uid) -> Value {
/// The value of property `uid` of `lid` at the CTI as numbers (Position with Separate
/// Dimensions: its X / Y / Z Position).
fn current_components(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Option<Vec<f64>> {
components_at(s, cid, lid, uid, None)
}
/// [`current_components`] at layer time `at` (the CTI when `None`).
fn components_at(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, at: Option<Tick>) -> Option<Vec<f64>> {
let l = s.project.comp(cid)?.layer(lid)?;
let lt = l.layer_time(s.time_of(cid));
let lt = at.unwrap_or_else(|| l.layer_time(s.time_of(cid)));
if let Some(tr) = l.transform()
&& tr.get("position").is_some_and(|q| q.uid == uid)
&& tr.get("positionX").is_some()
@@ -319,7 +342,9 @@ pub(crate) fn proportional_share(index: usize, count: usize) -> f64 {
/// `prop.set`; with `selected`, also on the other selected layers: the same value, or with
/// `offset` (a scrub) the same change from each layer's own value. A typed value with `dims`
/// (the components edited, e.g. `[2]` for Z) sets only those components on the other layers,
/// which keep the rest of their own value (#1434). With `offset` and
/// which keep the rest of their own value (#1434); `follow` components move on each layer by the
/// change its first typed component takes (linked Audio Levels keep each layer's balance, #2485).
/// With `offset` and
/// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change
/// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`,
/// one that a continuing scrub keeps folding into).
@@ -344,10 +369,19 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
(None, Some(a), Some(_)) if !scalar => Some(a.iter().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect()),
_ => None,
};
let follow: Vec<usize> =
p.get("follow").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect();
// Each layer's own value where `set` writes it (a `time` other than the CTI keys there).
let typed = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid| {
if let (Some(dims), Some(n)) = (&dims, &new)
&& let Some(mut c) = current_components(s, cid, olid, ouid)
&& let Some(mut c) = components_at(s, cid, olid, ouid, snapped_key_time(s, cid, olid, q, "prop.set").ok().flatten())
{
let change = dims.first().and_then(|&d| Some(n.get(d)? - c.get(d)?)).unwrap_or(0.0);
for &d in &follow {
if let Some(slot) = c.get_mut(d) {
*slot += change;
}
}
for &d in dims {
if let (Some(slot), Some(v)) = (c.get_mut(d), n.get(d)) {
*slot = *v;
@@ -914,6 +948,15 @@ fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
/// References that can be edited, including explicit key lists supplied by commands.
pub(crate) fn editable_keys(comp: &effectcraft_project::Comp, selected: &[KeyRef]) -> Vec<KeyRef> {
selected.iter().filter(|key| key_layer_editable(comp, key.layer)).copied().collect()
}
pub(crate) fn key_layer_editable(comp: &effectcraft_project::Comp, layer: LayerId) -> bool {
comp.layer(layer).is_some_and(|layer| !layer.switches.locked)
}
/// Apply `f` to every selected key; keeps the selection pointing at moved keys.
pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: impl Fn(&mut Vec<Keyframe>, usize, &mut Tick) -> bool) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
@@ -926,7 +969,16 @@ pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: im
groups.entry((k.layer, k.prop)).or_default().push(k.time);
}
for ((lid, uid), times) in groups {
let Some(pr) = comp.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) else { continue };
let Some(layer) = comp.layer_mut(lid) else { continue };
// A locked layer's keys stay as they are, and stay selected.
if layer.switches.locked {
for time in times {
let r = KeyRef { layer: lid, prop: uid, time };
remapped.insert(r, r);
}
continue;
}
let Some(pr) = layer.props.find_mut(uid) else { continue };
// Process each stored key once, in an order that avoids collisions when moving.
let mut idx: Vec<(usize, Tick)> = times.iter().filter_map(|t| key_at(&pr.keys, *t).map(|i| (i, *t))).collect();
idx.sort_by_key(|x| x.0);
@@ -1079,6 +1131,10 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
let mut removed = 0usize;
let mut changed = std::collections::BTreeSet::new();
for k in &sel {
// A layer locked after its keys were selected keeps them (the selection stays too).
if comp.layer(k.layer).is_some_and(|l| l.switches.locked) {
continue;
}
if let Some(pr) = comp.layer_mut(k.layer).and_then(|l| l.props.find_mut(k.prop))
&& let Some(i) = key_at(&pr.keys, k.time)
{
@@ -1092,7 +1148,7 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
}
}
}
st.selected_keys.clear();
st.selected_keys.retain(|k| comp.layer(k.layer).is_some_and(|l| l.switches.locked));
for (layer, prop) in changed {
sync_polystar_property(proj, st, cid, layer, prop, time)?;
}
@@ -1366,7 +1422,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Property Value",
[],
None,
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value)}",
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value), follow? (with dims: components that move on each other layer by the change its first typed component takes)}",
has_layers,
set_selected
),
+81 -28
View File
@@ -5,14 +5,16 @@
//! The native `.prproj` format has no public specification and is not read or written.
use std::collections::HashMap;
use std::sync::Arc;
use effectcraft_interchange::{ExportOptions, ImportOptions, PrecompMode, PrerenderMode, Prerendered, TimelineFormat};
use effectcraft_project::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, ProResProfile, RenderQueueItem, TimeSpan};
use effectcraft_project::{ItemId, LayerId, LayerSource, Project};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, str_p};
use crate::{EngineError, Result, Session, cmd};
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, str_p};
use crate::jobs::TaskCtl;
use crate::{EngineError, Exporter, Result, Session, cmd};
fn format_p(p: &Value, cmd: &str) -> Result<Option<TimelineFormat>> {
match str_p(p, "format") {
@@ -95,18 +97,34 @@ fn isolate(project: &Project, cid: ItemId, lid: LayerId) -> Option<Project> {
Some(q)
}
/// Render one layer to ProRes 4444 with alpha over its in–out span.
fn prerender(s: &Session, cid: ItemId, lid: LayerId, path: &str) -> std::result::Result<Prerendered, String> {
let exporter = s.exporter.clone().ok_or("export is not available in this build")?;
/// What a pre-render needs from the session, so it can run on a background thread.
struct PrerenderCx {
exporter: Option<Arc<dyn Exporter>>,
footage: Arc<dyn effectcraft_render::FootageSource>,
expr: Option<Arc<dyn effectcraft_render::ExprHost>>,
accel: Option<Arc<dyn effectcraft_render::Accelerator>>,
nested_switches: bool,
}
/// Render one layer to ProRes 4444 with alpha over its in–out span; `progress` as the exporter's.
fn prerender(
cx: &PrerenderCx,
project: &Project,
cid: ItemId,
lid: LayerId,
path: &str,
progress: &mut dyn FnMut(u64, u64) -> bool,
) -> std::result::Result<Prerendered, String> {
let exporter = cx.exporter.as_ref().ok_or("export is not available in this build")?;
if !exporter.formats().contains(&OutputFormat::ProRes) {
return Err("ProRes export is not available in this build".into());
}
let comp = s.project.comp(cid).ok_or("no composition")?;
let comp = project.comp(cid).ok_or("no composition")?;
let l = comp.layer(lid).ok_or("no layer")?;
let fd = comp.frame_duration();
let start = comp.frame_rate.snap(l.in_point.max(effectcraft_time::Tick::ZERO));
let end = l.out_point.min(comp.duration).max(start + fd);
let q = isolate(&s.project, cid, lid).ok_or("no composition")?;
let q = isolate(project, cid, lid).ok_or("no composition")?;
let mut item = RenderQueueItem::new(0, cid);
item.settings.time_span = TimeSpan::Custom { start, end };
item.output = OutputModule::for_format(OutputFormat::ProRes);
@@ -116,16 +134,16 @@ fn prerender(s: &Session, cid: ItemId, lid: LayerId, path: &str) -> std::result:
item.output.output = path.to_string();
let job = crate::ExportJob {
project: &q,
footage: s.footage.as_ref(),
expr: s.expr.as_deref(),
accel: s.accel.as_deref(),
footage: cx.footage.as_ref(),
expr: cx.expr.as_deref(),
accel: cx.accel.as_deref(),
item: &item,
path,
storage: None,
label: format!("Pre-render {}", l.name),
nested_switches: s.prefs.general.switches_affect_nested_comps,
nested_switches: cx.nested_switches,
};
let r = exporter.export(&job, &mut |_, _| true)?;
let r = exporter.export(&job, progress)?;
Ok(Prerendered { path: r.path, width: comp.width, height: comp.height, frame_rate: comp.frame_rate, duration: end - start })
}
@@ -157,33 +175,67 @@ fn export_timeline(s: &mut Session, p: &Value) -> Result<Value> {
let stem = fp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Timeline".into());
let relative_to = Some(dir.to_string_lossy().to_string()).filter(|d| !d.is_empty());
let opts = ExportOptions { format, prerender: prerender_mode, precomps, relative_to };
// Pre-render what the timeline can't represent.
let project = s.project.clone();
let services = s.services.clone();
let cx = PrerenderCx {
exporter: s.exporter.clone(),
footage: s.footage.clone(),
expr: s.expr.clone(),
accel: s.accel.clone(),
nested_switches: s.prefs.general.switches_affect_nested_comps,
};
// The pre-renders can take minutes: with `background` (the export dialog) they run as a job
// in Window ▸ Progress instead of with the window waiting (#2269).
let wait = !b_p(p, "background").unwrap_or(false);
s.spawn_task("exportTimeline", format!("Export {}", format.label()), wait, move |ctl| {
let (bytes, mut result) = export_timeline_work(&cx, &project, cid, &opts, &dir, &stem, ctl)?;
services.write_file(&path, &bytes).map_err(|e| format!("cannot write {path}: {e}"))?;
result["path"] = json!(path);
result["toast"] = json!(match format {
TimelineFormat::Fcp7Xml => format!("Exported {path} (Final Cut Pro XML; import it in Premiere Pro with File ▸ Import)"),
_ => format!("Exported {path} ({})", format.label()),
});
Ok(Box::new(move |_: &mut Session| Ok(result)))
})
}
/// Pre-render what the timeline can't represent, then build the timeline: its bytes and the
/// command's result (without `path`).
fn export_timeline_work(
cx: &PrerenderCx,
project: &Project,
cid: ItemId,
opts: &ExportOptions,
dir: &std::path::Path,
stem: &str,
ctl: &TaskCtl,
) -> std::result::Result<(Vec<u8>, Value), String> {
let mut pre: HashMap<(ItemId, LayerId), Prerendered> = HashMap::new();
let mut rendered = vec![];
let mut errors = vec![];
for (c, l) in effectcraft_interchange::plan_prerender(&s.project, cid, &opts) {
let comp_name = s.project.item(c).map(|i| i.name.clone()).unwrap_or_default();
let layer_name = s.project.comp(c).and_then(|cc| cc.layer(l)).map(|x| x.name.clone()).unwrap_or_default();
let plan = effectcraft_interchange::plan_prerender(project, cid, opts);
let n = plan.len() as u64;
for (i, (c, l)) in (0u64..).zip(plan) {
let comp_name = project.item(c).map(|it| it.name.clone()).unwrap_or_default();
let layer_name = project.comp(c).and_then(|cc| cc.layer(l)).map(|x| x.name.clone()).unwrap_or_default();
ctl.message(format!("Pre-render {layer_name}"));
let file = dir.join(format!("{stem}_{}_{}_{}.mov", safe(&comp_name), safe(&layer_name), l.0)).to_string_lossy().to_string();
match prerender(s, c, l, &file) {
// Progress over all the layers, each one's frames a share of it.
let mut progress = |done: u64, total: u64| ctl.progress(i * 1000 + done.saturating_mul(1000) / total.max(1), n * 1000);
match prerender(cx, project, c, l, &file, &mut progress) {
Ok(r) => {
rendered.push(r.path.clone());
pre.insert((c, l), r);
}
Err(_) if ctl.cancelled() => return Err(crate::render_queue::CANCELLED.into()),
Err(e) => errors.push(format!("{comp_name} ▸ {layer_name}: {e}")),
}
}
let out = effectcraft_interchange::export(&s.project, cid, &opts, &pre).map_err(|e| EngineError::Other(e.to_string()))?;
s.services.write_file(&path, &out.bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
let out = effectcraft_interchange::export(project, cid, opts, &pre).map_err(|e| e.to_string())?;
let mut warnings = out.warnings.clone();
warnings.extend(errors.iter().map(|e| format!("pre-render failed: {e}")));
s.toast(match format {
TimelineFormat::Fcp7Xml => format!("Exported {path} (Final Cut Pro XML; import it in Premiere Pro with File ▸ Import)"),
_ => format!("Exported {path} ({})", format.label()),
});
Ok(json!({
"path": path,
"format": format.id(),
let result = json!({
"format": opts.format.id(),
"bytes": out.bytes.len(),
"sequences": out.sequences,
"videoTracks": out.video_tracks,
@@ -191,7 +243,8 @@ fn export_timeline(s: &mut Session, p: &Value) -> Result<Value> {
"clips": out.clips,
"prerendered": rendered,
"warnings": warnings,
}))
});
Ok((out.bytes, result))
}
/// The formats, for agents and the export dialog.
@@ -220,7 +273,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Adobe Premiere Pro Project...",
["File", "Export"],
None,
"{comp?, path (Final Cut Pro XML .xml for Premiere Pro or .fcpxml .otio .edl .aaf .omf), format?: xml|fcpxml|otio|edl|aaf|omf, prerender?: none|unsupported|all, precomps?: nest|prerender}",
"{comp?, path (Final Cut Pro XML .xml for Premiere Pro or .fcpxml .otio .edl .aaf .omf), format?: xml|fcpxml|otio|edl|aaf|omf, prerender?: none|unsupported|all, precomps?: nest|prerender, background?: bool (pre-render and write as a job, jobs.list / jobs.wait; returns {job})}",
has_comp,
export_timeline
),
+1 -1
View File
@@ -36,7 +36,7 @@ pub use sections::{
use sections::{Extra, default_labels, lenient_comp_presets};
/// Current settings layout version.
pub const PREFS_VERSION: u32 = 3;
pub const PREFS_VERSION: u32 = 4;
/// Settings file name in the config store.
pub const PREFS_FILE: &str = "prefs.json";
@@ -9,6 +9,8 @@ use super::{Appearance, General, Map, PREFS_VERSION, Prefs, Startup, Value, json
/// `autoSaveMinutes`, `autoSaveVersions`, `labelNames` (array) and `theme` at the top level.
/// - version 2 defaulted the disk cache to 100 GB, more than most laptops have free; the stored
/// old default becomes the new one (a size the user chose is kept).
/// - version 3 and earlier could still hold `createSplitLayersAbove: false`, the default before
/// #2083 (every save kept it); it becomes After Effects' default, above (#2545).
pub fn migrate(mut v: Value) -> Value {
let Some(obj) = v.as_object_mut() else { return json!({}) };
let version = obj.get("version").and_then(Value::as_u64).unwrap_or(0);
@@ -42,6 +44,12 @@ pub fn migrate(mut v: Value) -> Value {
{
d.insert("diskCacheMaxGb".into(), json!(20));
}
if version < 4
&& let Some(g) = obj.get_mut("general").and_then(Value::as_object_mut)
&& g.get("createSplitLayersAbove") == Some(&json!(false))
{
g.insert("createSplitLayersAbove".into(), json!(true));
}
// Settings saved before appearance modes had one theme: keep showing it, as a fixed Dark or
// Light mode, instead of switching established users to Auto.
if let Some(a) = obj.get_mut("appearance").and_then(Value::as_object_mut)
+74
View File
@@ -9,6 +9,80 @@ fn select(s: &mut crate::Session, layer: u64, path: &str) {
s.execute("prop.select", json!({"layer": layer, "path": path})).unwrap();
}
fn selection_command_skips_locked_keys(command: &str, path: &str, values: [serde_json::Value; 3], mut params: serde_json::Value) {
let (mut s, locked) = setup();
let unlocked = s.execute("layer.newSolid", json!({"name": "Editable", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
for layer in [locked, unlocked] {
animate(&mut s, layer, path, &[(0.0, values[0].clone()), (0.5, values[1].clone()), (1.0, values[2].clone())]);
}
let before = prop(&s, locked, path).keys;
let selected: Vec<_> = [locked, unlocked]
.into_iter()
.flat_map(|layer| {
let pr = prop(&s, layer, path);
pr.keys.into_iter().map(move |key| crate::KeyRef { layer: effectcraft_project::LayerId(layer), prop: pr.uid, time: key.time })
})
.collect();
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
s.state.selected_keys = selected.clone();
if params.get("keys").is_some() {
params["keys"] = serde_json::to_value(&selected).unwrap();
}
s.execute_checked(command, params.clone()).unwrap();
assert_eq!(prop(&s, locked, path).keys, before, "{command} changed a locked layer");
let after = prop(&s, unlocked, path).keys;
assert_ne!(after, before, "{command} must still edit unlocked keys");
let after_selection = s.state.selected_keys.clone();
assert!(selected.iter().filter(|k| k.layer.0 == locked).all(|key| after_selection.contains(key)));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(prop(&s, unlocked, path).keys, before);
assert!(selected.iter().filter(|key| key.layer.0 == locked).all(|key| s.state.selected_keys.contains(key)));
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(prop(&s, unlocked, path).keys, after);
assert_eq!(s.state.selected_keys, after_selection);
// All-locked input must preserve the selection and document as well.
s.state.selected_keys.retain(|k| k.layer.0 == locked);
if params.get("keys").is_some() {
params["keys"] = serde_json::to_value(&s.state.selected_keys).unwrap();
}
let locked_selection = s.state.selected_keys.clone();
let project = serde_json::to_value(&*s.project).unwrap();
let _ = s.execute_checked(command, params);
assert_eq!(serde_json::to_value(&*s.project).unwrap(), project);
assert_eq!(s.state.selected_keys, locked_selection);
}
#[test]
fn wiggler_preserves_locked_keys_in_mixed_selection() {
selection_command_skips_locked_keys(
"keys.wiggle",
"transform/opacity",
[json!(10), json!(90), json!(20)],
json!({"apply": "temporal", "frequency": 8, "magnitude": 10}),
);
}
#[test]
fn smoother_preserves_locked_keys_in_mixed_selection() {
selection_command_skips_locked_keys("keys.smooth", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"tolerance": 1}));
}
#[test]
fn exponential_scale_preserves_locked_keys_in_mixed_selection() {
selection_command_skips_locked_keys(
"keys.exponentialScale",
"transform/scale",
[json!([100, 100, 100]), json!([150, 150, 150]), json!([400, 400, 400])],
json!({}),
);
}
#[test]
fn edit_label_preserves_locked_keys_in_mixed_selection() {
selection_command_skips_locked_keys("edit.label", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"label": "Red"}));
selection_command_skips_locked_keys("edit.label", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"label": "Red", "keys": true}));
}
#[test]
fn wiggle_adds_keys_at_frequency_within_magnitude() {
let (mut s, l) = setup();
+204
View File
@@ -43,3 +43,207 @@ fn delete_keys_reports_zero_when_no_selected_key_exists() {
assert_eq!(removed, json!(0));
assert_eq!(keys_of(&s, layer, prop), keys);
}
/// A layer locked after its keys were selected keeps them: Delete and Edit Value leave them alone.
#[test]
fn a_locked_layers_keys_cannot_be_deleted_or_edited() {
let (mut s, layer, prop) = setup();
let keys = keys_of(&s, layer, prop);
s.state.selected_keys = keys.clone();
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
let removed = s.execute_checked("keys.delete", json!({})).unwrap();
assert_eq!(removed, json!(0));
assert_eq!(keys_of(&s, layer, prop), keys);
assert_eq!(s.state.selected_keys, keys, "the selection stays");
let e = s.execute("keys.set", json!({"layer": layer, "prop": prop, "time": 0, "value": 90})).unwrap_err().to_string();
assert!(e.contains("locked"), "{e}");
let value = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys[0].value.as_f64();
assert_eq!(value, 0.0);
// Unlocked again, both work.
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
s.execute_checked("keys.set", json!({"layer": layer, "prop": prop, "time": 0, "value": 90})).unwrap();
assert_eq!(s.execute_checked("keys.delete", json!({})).unwrap(), json!(3));
}
/// Easy Ease, Toggle Hold and Time-Reverse leave a locked layer's keys as they are and keep them
/// selected; unlocked, they change them.
#[test]
fn selection_based_key_edits_skip_a_locked_layer() {
let (mut s, layer, prop) = setup();
let keys = keys_of(&s, layer, prop);
let stored = |s: &Session| s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.clone();
let before = stored(&s);
s.state.selected_keys = keys.clone();
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
for command in ["keys.easyEase", "keys.toggleHold", "keys.timeReverse"] {
s.execute_checked(command, json!({})).unwrap();
assert_eq!(stored(&s), before, "{command} changed a locked layer's keys");
assert_eq!(s.state.selected_keys, keys, "{command} dropped the selection of a locked layer's keys");
}
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
s.execute_checked("keys.easyEase", json!({})).unwrap();
assert_ne!(stored(&s), before, "unlocked, Easy Ease changes the keys");
}
#[test]
fn spatial_handles_cannot_edit_a_locked_layer() {
let (mut s, layer, _) = setup();
for (time, value) in [(0, [10, 20, 0]), (1, [80, 60, 0])] {
s.execute("prop.addKey", json!({"layer": layer, "path": "transform/position", "time": time, "value": value})).unwrap();
}
let prop = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/position").unwrap().uid;
s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": prop, "time": 0}]})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
let before = s.project.to_json();
let selected = s.state.selected_keys.clone();
let undo_len = s.history.undo.len();
let params = json!({"layer": layer, "prop": prop, "time": 0, "out": [15, 25, 0]});
let error = s.execute("keys.setSpatialTangents", params.clone()).unwrap_err().to_string();
assert!(error.contains("locked"), "{error}");
assert_eq!(s.project.to_json(), before);
assert_eq!(s.state.selected_keys, selected);
assert_eq!(s.history.undo.len(), undo_len);
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
let unlocked = s.project.to_json();
s.execute("keys.setSpatialTangents", params).unwrap();
let edited = s.project.to_json();
assert_ne!(edited, unlocked);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.to_json(), unlocked);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.to_json(), edited);
}
#[test]
fn pasting_keys_or_motion_paths_cannot_edit_locked_targets() {
for path_clip in [false, true] {
for explicit in [false, true] {
let (mut s, layer, opacity) = setup();
let target_path = if path_clip { "transform/position" } else { "transform/opacity" };
let source = if path_clip {
s.execute("mask.new", json!({"layer": layer, "vertices": [[0, 0], [20, 10], [40, 0]], "closed": false})).unwrap();
s.execute("prop.toggleAnimation", json!({"layer": layer, "path": "masks/#1/path"})).unwrap();
s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("masks/#1/path").unwrap().uid
} else {
opacity
};
s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": source, "time": 0}]})).unwrap();
s.execute("keys.copy", json!({})).unwrap();
s.execute("prop.select", json!({"layer": layer, "path": target_path})).unwrap();
s.execute("time.set", json!({"time": 3})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
let params = if explicit { json!({"layer": layer, "path": target_path}) } else { json!({}) };
let before = s.project.to_json();
let selected = s.state.selected_keys.clone();
let undo_len = s.history.undo.len();
let result = s.execute("keys.paste", params.clone());
assert!(result.is_err(), "path={path_clip}, explicit={explicit}: {result:?}");
assert_eq!(s.project.to_json(), before);
assert_eq!(s.state.selected_keys, selected);
assert_eq!(s.history.undo.len(), undo_len);
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
let unlocked = s.project.to_json();
s.execute("keys.paste", params).unwrap();
let pasted = s.project.to_json();
assert_ne!(pasted, unlocked);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.to_json(), unlocked);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.to_json(), pasted);
}
}
}
#[test]
fn key_paste_skips_locked_targets_without_changing_layer_pairing() {
let (mut s, locked, locked_prop) = setup();
let other = s.execute("layer.newSolid", json!({"name": "Other", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
for (time, value) in [(0, 100), (1, 80), (2, 60)] {
s.execute("prop.addKey", json!({"layer": other, "path": "transform/opacity", "time": time, "value": value})).unwrap();
}
let other_prop = s.active_comp().unwrap().layer(LayerId(other)).unwrap().props.prop("transform/opacity").unwrap().uid;
let locked_keys = keys_of(&s, locked, locked_prop);
s.state.selected_keys = locked_keys.iter().chain(keys_of(&s, other, other_prop).iter()).copied().collect();
s.execute("keys.copy", json!({})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
s.execute("time.set", json!({"time": 3})).unwrap();
let before = s.project.to_json();
assert_eq!(s.execute("keys.paste", json!({"layers": [locked, other]})).unwrap(), json!(3), "only newly pasted keys are counted");
assert_eq!(keys_of(&s, locked, locked_prop), locked_keys);
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)));
assert_eq!(s.state.selected_keys.len(), 6);
let keys = &s.active_comp().unwrap().layer(LayerId(other)).unwrap().props.find(other_prop).unwrap().keys;
assert_eq!(keys.iter().filter(|k| k.time.seconds() >= 3.0).map(|k| k.value.as_f64()).collect::<Vec<_>>(), [100.0, 80.0, 60.0]);
let pasted = s.project.to_json();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.to_json(), before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.to_json(), pasted);
}
#[test]
fn motion_path_paste_preserves_locked_source_selection() {
for explicit in [false, true] {
let (mut s, source, _) = setup();
s.execute("mask.new", json!({"layer": source, "vertices": [[0, 0], [20, 10], [40, 0]], "closed": false})).unwrap();
s.execute("prop.toggleAnimation", json!({"layer": source, "path": "masks/#1/path"})).unwrap();
let uid = s.active_comp().unwrap().layer(LayerId(source)).unwrap().props.prop("masks/#1/path").unwrap().uid;
s.execute("keys.select", json!({"keys": [{"layer": source, "prop": uid, "time": 0}]})).unwrap();
s.execute("keys.copy", json!({})).unwrap();
let locked_keys = s.state.selected_keys.clone();
let target = s.execute("layer.newSolid", json!({"name": "Target", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.select", json!({"layer": target, "path": "transform/position"})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [source], "switch": "locked", "value": true})).unwrap();
s.state.selected_keys = locked_keys.clone();
let params = if explicit { json!({"layer": target, "path": "transform/position"}) } else { json!({}) };
let before = s.project.to_json();
assert_eq!(s.execute("keys.paste", params).unwrap(), json!(3));
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)));
assert_eq!(s.state.selected_keys.len(), 4);
assert_eq!(s.state.selected_keys.iter().filter(|k| k.layer == LayerId(target)).count(), 3);
let pasted = s.project.to_json();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.to_json(), before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.to_json(), pasted);
}
}
#[test]
fn key_transform_preserves_locked_keys_in_a_mixed_selection() {
let (mut s, locked, locked_prop) = setup();
let unlocked = s.execute("layer.newSolid", json!({"name": "Unlocked", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
for t in 0..3 {
s.execute("prop.addKey", json!({"layer": unlocked, "path": "transform/opacity", "time": t, "value": t * 25})).unwrap();
}
let unlocked_prop = s.active_comp().unwrap().layer(LayerId(unlocked)).unwrap().props.prop("transform/opacity").unwrap().uid;
let locked_keys = keys_of(&s, locked, locked_prop);
s.state.selected_keys = locked_keys.iter().chain(keys_of(&s, unlocked, unlocked_prop).iter()).copied().collect();
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
let stored = |s: &Session, layer, prop| s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.clone();
let before = stored(&s, locked, locked_prop);
let undo = s.history.undo.len();
for offset in [0.5, 1.0] {
s.execute("keys.transform", json!({"timeOffset": offset, "valueOffset": offset * 10.0, "merge": "mixed-transform", "fromStart": true})).unwrap();
assert_eq!(stored(&s, locked, locked_prop), before);
assert_eq!(s.state.selected_keys.len(), 6);
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)), "locked selections stay at their original times");
assert!(keys_of(&s, unlocked, unlocked_prop).iter().all(|k| s.state.selected_keys.contains(k)), "unlocked selections follow the keys");
let moved = stored(&s, unlocked, unlocked_prop);
for (i, key) in moved.iter().enumerate() {
assert_eq!(key.time.seconds(), i as f64 + offset);
assert_eq!(key.value.as_f64(), i as f64 * 25.0 + offset * 10.0);
}
}
assert_eq!(s.history.undo.len(), undo + 1, "one merged drag");
let after = stored(&s, unlocked, unlocked_prop);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(stored(&s, locked, locked_prop), before);
assert_eq!(stored(&s, unlocked, unlocked_prop), before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(stored(&s, locked, locked_prop), before);
assert_eq!(stored(&s, unlocked, unlocked_prop), after);
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": false})).unwrap();
s.execute("keys.transform", json!({"timeOffset": 0.5, "valueOffset": 10})).unwrap();
assert_ne!(stored(&s, locked, locked_prop), before, "unlocking makes the keys editable again");
}
+4 -1
View File
@@ -58,7 +58,8 @@ fn slip_moves_the_source_under_fixed_in_and_out() {
let before = get(&s);
assert_eq!((before.start_time, before.in_point.seconds(), before.out_point.seconds()), (Tick::ZERO, 1.0, 3.0));
// Slip the source 0.5 s later: in/out stay, the source (start) moves.
s.execute("layer.slip", json!({"layers": [id], "delta": 0.5})).unwrap();
let r = s.execute("layer.slip", json!({"layers": [id], "delta": 0.5})).unwrap();
assert_eq!(r["slipped"][0]["clamped"], json!(false));
let l = get(&s);
assert_eq!((l.start_time.seconds(), l.in_point, l.out_point), (0.5, before.in_point, before.out_point));
// The source time at comp 2 s is now 1.5 s.
@@ -67,6 +68,8 @@ fn slip_moves_the_source_under_fixed_in_and_out() {
let r = s.execute("layer.slip", json!({"layers": [id], "delta": 5.0})).unwrap();
assert_eq!(get(&s).start_time.seconds(), 1.0);
assert_eq!(r["slipped"][0]["delta"], json!(0.5));
// The response says the 5 s asked for was cut short (#2371).
assert_eq!((r["requested"].clone(), r["slipped"][0]["clamped"].clone()), (json!(5.0), json!(true)));
// Slip by a frame.
s.execute("layer.slipBack", json!({"layers": [id]})).unwrap();
assert!((get(&s).start_time.seconds() - (1.0 - 1.0 / 30.0)).abs() < 1e-9);
+45
View File
@@ -184,6 +184,51 @@ fn mask_interpolation_creates_matching_keys() {
assert!(s.execute_checked("mask.interpolate", json!({"matchingMethod": "magic", "times": [0, 0.8], "layer": clip.0})).is_err());
}
#[test]
fn mask_interpolation_skips_locked_selection_and_rejects_explicit_locked_targets() {
let (mut s, locked, _) = setup(patch_frames(rotating_pose));
let unlocked = effectcraft_project::LayerId(s.execute("layer.newSolid", json!({"width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap());
let a = ShapePath::rect([20.0, 20.0], 10.0, 10.0);
let b = ShapePath::rect([40.0, 30.0], 20.0, 20.0);
let cid = s.active_comp_id().unwrap();
let mut selected = vec![];
let mut masks = vec![];
for layer in [locked, unlocked] {
let uid = new_mask(&mut s, layer, &a);
masks.push(uid);
s.edit("keys", None, |project, _| {
let pr = project.comp_mut(cid).unwrap().layer_mut(layer).unwrap().props.find_group_mut(uid).unwrap().get_mut("path").unwrap();
pr.keys = vec![Keyframe::new(frame_time(0), KV::Path(a.clone())), Keyframe::new(frame_time(20), KV::Path(b.clone()))];
selected.extend(pr.keys.iter().map(|key| crate::KeyRef { layer, prop: pr.uid, time: key.time }));
Ok(())
})
.unwrap();
}
s.execute("layer.setSwitch", json!({"layers": [locked.0], "switch": "locked", "value": true})).unwrap();
s.state.selected_keys = selected.clone();
let before = mask_path(&s, locked, masks[0]).keys;
let options = serde_json::to_value(&s.state.mask_interp).unwrap();
let project = serde_json::to_value(&*s.project).unwrap();
let error = s.execute_checked("mask.interpolate", json!({"layer": locked.0, "mask": masks[0], "times": [0.0, 0.8], "quality": 75})).unwrap_err();
assert!(error.to_string().contains("locked"), "{error}");
assert_eq!(serde_json::to_value(&*s.project).unwrap(), project);
assert_eq!(serde_json::to_value(&s.state.mask_interp).unwrap(), options);
assert_eq!(s.state.selected_keys, selected);
s.execute_checked("mask.interpolate", json!({})).unwrap();
assert_eq!(mask_path(&s, locked, masks[0]).keys, before);
assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 21);
assert_eq!(s.state.selected_keys, selected);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 2);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 21);
s.state.selected_keys.retain(|key| key.layer == locked);
let selection = s.state.selected_keys.clone();
assert!(s.execute_checked("mask.interpolate", json!({})).is_err());
assert_eq!(mask_path(&s, locked, masks[0]).keys, before);
assert_eq!(s.state.selected_keys, selection);
}
// ---------------------------------------------------------------- Warp Stabilizer
/// Camera offset on frame `f`: a slow pan plus jitter.
+39
View File
@@ -308,6 +308,45 @@ fn puppet_select_all_takes_the_pin_kind_and_several_pins_record_together() {
assert!(s.execute("puppet.recordPin", json!({"layer": id, "pin": a, "pins": [st], "samples": samples})).is_err());
}
/// Editing a parameter of one of several selected pins edits the same parameter of the others,
/// in one undo step; unselected pins keep theirs (#1751).
#[test]
fn editing_one_selected_pin_edits_the_other_selected_pins() {
let (mut s, id) = setup();
let pin = |s: &mut Session, x: i64| s.execute("puppet.addPin", json!({"layer": id, "position": [x, 30]})).unwrap()["pin"].as_u64().unwrap();
let (a, b, c) = (pin(&mut s, 10), pin(&mut s, 50), pin(&mut s, 90));
let position = |s: &Session, pin: u64| layer(s, id).props.find_group(pin).unwrap().get("position").unwrap().clone();
let (pa, pb) = (position(&s, a).uid, position(&s, b).uid);
let at = |s: &Session, pin: u64| position(s, pin).value_at(Tick::ZERO).components();
s.execute("prop.select", json!({"layer": id, "prop": a, "selectKeys": false})).unwrap();
s.execute("prop.select", json!({"layer": id, "prop": b, "selectKeys": false, "add": true})).unwrap();
// A typed value lands on both selected pins, and one Undo takes it back.
let steps = s.history.undo.len();
s.execute("prop.set", json!({"layer": id, "prop": pa, "value": [20, 40], "selected": true})).unwrap();
assert_eq!((at(&s, a), at(&s, b), at(&s, c)), (vec![20.0, 40.0], vec![20.0, 40.0], vec![90.0, 30.0]));
assert_eq!(s.history.undo.len(), steps + 1, "one undo step");
s.undo();
assert_eq!((at(&s, a), at(&s, b)), (vec![10.0, 30.0], vec![50.0, 30.0]));
// A scrub moves each selected pin by the same change from its own value.
s.execute("prop.set", json!({"layer": id, "prop": pb, "value": [55, 25], "selected": true, "offset": true})).unwrap();
assert_eq!((at(&s, a), at(&s, b), at(&s, c)), (vec![15.0, 25.0], vec![55.0, 25.0], vec![90.0, 30.0]));
// The stopwatch follows on both (a new pin starts keyed, so the toggle turns it off).
let animated = |s: &Session| [a, b, c].map(|p| position(s, p).is_animated());
assert_eq!(animated(&s), [true; 3]);
s.execute("prop.toggleAnimation", json!({"layer": id, "prop": pa, "selected": true})).unwrap();
assert_eq!(animated(&s), [false, false, true]);
// Without `selected`, or with only one pin selected, the edit stays on its own pin.
s.execute("prop.set", json!({"layer": id, "prop": pa, "value": [0, 0]})).unwrap();
assert_eq!(at(&s, b), vec![55.0, 25.0]);
s.execute("prop.select", json!({"layer": id, "prop": a, "selectKeys": false})).unwrap();
s.execute("prop.set", json!({"layer": id, "prop": pa, "value": [5, 5], "selected": true})).unwrap();
assert_eq!(at(&s, b), vec![55.0, 25.0]);
}
fn expr_of(s: &Session, layer: u64, uid: u64) -> String {
let l = layer_of(s, layer);
l.props.find(uid).and_then(|p| p.expr.as_ref()).map(|e| e.text.clone()).unwrap_or_default()
+9
View File
@@ -79,6 +79,15 @@ fn prefs_migrate_old_disk_cache_default() {
assert_eq!(Prefs::from_json(r#"{"version": 3, "disk": {"diskCacheMaxGb": 100}}"#).disk.disk_cache_max_gb, 100);
}
#[test]
fn prefs_migrate_old_split_layers_default() {
// Settings saved before #2083 kept "below"; they move to After Effects' "above" once (#2545).
let above = |doc: &str| Prefs::from_json(doc).general.create_split_layers_above;
assert!(above(r#"{"version": 3, "general": {"createSplitLayersAbove": false}}"#));
assert!(above(r#"{"version": 2, "general": {"createSplitLayersAbove": false}}"#));
assert!(!above(r#"{"version": 4, "general": {"createSplitLayersAbove": false}}"#), "a choice made since stays");
}
#[test]
fn prefs_set_validates_keys_types_and_ranges() {
let mut p = Prefs::default();
+182 -3
View File
@@ -7,6 +7,7 @@ use serde_json::json;
use crate::Session;
use crate::commands::text_edit::{MAX_IME_TEXT_BYTES, ime_stamp_revision, ime_surrounding, layer_doc};
use crate::tests::three_d::layer_count;
fn session(text: &str) -> (Session, u64) {
let mut s = Session::default();
@@ -1231,10 +1232,188 @@ fn deselect_all_keeps_an_empty_type_tool_layer() {
s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == effectcraft_project::LayerId(new)),
"the Type tool opens the edit on its new, empty layer"
);
let count = |s: &Session| s.active_comp().map_or(0, |c| c.layers.len());
let before = count(&s);
let before = layer_count(&s);
s.execute("edit.deselectAll", json!({})).unwrap();
assert!(s.state.text_edit.is_none());
assert!(s.state.selected_layers.is_empty());
assert_eq!(count(&s), before, "an empty Type-tool layer is kept, as in After Effects");
assert_eq!(layer_count(&s), before, "an empty Type-tool layer is kept, as in After Effects");
}
#[test]
fn duplicate_ends_an_edit_on_already_committed_text() {
// The engine route (MCP, control channel, script) with text that was committed before the
// edit opened and nothing typed inside it: the edit still ends and the copy is selected.
let (mut s, t) = editing("Hello", 5);
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert!(s.state.text_edit.is_none(), "the open edit ends on a direct edit.duplicate");
assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "and the copy is selected");
assert_eq!(doc(&s, t).text, "Hello", "the committed text is kept on the original");
assert_eq!(doc(&s, copy).text, "Hello", "and carried by the copy");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_count(&s), 1, "undo removes the copy");
assert!(s.state.text_edit.is_none(), "undo does not reopen the edit");
}
#[test]
fn duplicate_during_an_edit_copies_the_layer_not_a_selected_property_group() {
use effectcraft_project::LayerId;
// Opening the editor by command, as a Type-tool click does, keeps the property selection, so
// an effect, shape item or text animator can still be selected while the user types.
for what in ["effect", "shape item", "text animator"] {
let (mut s, t) = session("Hello");
let (owner, path) = match what {
"effect" => {
s.execute("effect.apply", json!({"layers": [t], "effect": "Gaussian Blur"})).unwrap();
(t, "effects")
}
"shape item" => (s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(), "contents"),
_ => {
s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["opacity"]})).unwrap();
(t, "text/animators")
}
};
let items =
|s: &Session| s.active_comp().unwrap().layer(LayerId(owner)).unwrap().props.group(path).unwrap().groups().map(|g| g.uid).collect::<Vec<_>>();
let item = items(&s)[0];
let n = items(&s).len();
let before = layer_count(&s);
s.execute("prop.select", json!({"layer": owner, "prop": item})).unwrap();
begin_edit_at(&mut s, t, 5);
let r = s.execute("edit.duplicate", json!({})).unwrap();
assert!(s.state.text_edit.is_none(), "{what}: Duplicate ends the open edit");
assert_eq!(items(&s).len(), n, "{what}: the selected {what} is not duplicated");
assert_eq!(layer_count(&s), before + 1, "{what}: the edited layer is");
assert_eq!(s.state.selected_layers, vec![LayerId(r[0].as_u64().unwrap())], "{what}: and its copy is selected");
// The selection the edit passed over goes with it: the next Duplicate copies the selected
// copy rather than reaching back to the original's {what}.
s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(items(&s).len(), n, "{what}: a second Duplicate leaves the original's {what} alone");
assert_eq!(layer_count(&s), before + 2, "{what}: and copies the copy");
// With no edit open, the selected item keeps its precedence over the layers.
s.execute("prop.select", json!({"layer": owner, "prop": item})).unwrap();
s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(items(&s).len(), n + 1, "{what}: outside an edit Duplicate copies the selected {what}");
assert_eq!(layer_count(&s), before + 2, "{what}: and no layer");
}
}
#[test]
fn duplicate_during_an_edit_leaves_no_key_or_vertex_selected_on_the_original() {
use effectcraft_project::LayerId;
// Opening the editor keeps the keyframe and mask-vertex selection too. Once the copy takes the
// selection, Clear deletes the copy, not a key or a vertex of the original.
for what in ["keyframe", "mask vertex"] {
let (mut s, t) = session("Hello");
let mask = match what {
"keyframe" => {
for time in [0.0, 1.0] {
s.execute("prop.addKey", json!({"layer": t, "path": "transform/opacity", "time": time, "value": 50})).unwrap();
}
s.execute("keys.select", json!({"keys": [{"layer": t, "path": "transform/opacity", "time": 0.0}]})).unwrap();
None
}
// The Pen leaves the new mask's last vertex selected.
_ => Some(s.execute("mask.new", json!({"layer": t, "vertices": [[0, 0], [40, 0], [40, 40]], "closed": true})).unwrap()["mask"].as_u64().unwrap()),
};
let original = |s: &Session| {
let l = s.active_comp().unwrap().layer(LayerId(t)).unwrap();
match mask {
None => l.props.prop("transform/opacity").unwrap().keys.len(),
Some(m) => l.props.find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().vertices.len(),
}
};
let (n, before) = (original(&s), layer_count(&s));
begin_edit_at(&mut s, t, 5);
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "{what}: the copy is selected");
s.execute("edit.clear", json!({})).unwrap();
assert_eq!(layer_count(&s), before, "{what}: Clear deletes the selected copy");
assert_eq!(original(&s), n, "{what}: and leaves the original's {what} alone");
}
}
#[test]
fn duplicate_during_an_edit_copies_the_edited_layer_after_another_is_selected() {
use effectcraft_project::LayerId;
// An engine route (MCP, control channel, script) can select another layer while the edit
// stays open. The implicit Duplicate still means the edited layer, as Clear does; a named
// target keeps its meaning.
let (mut s, t) = session("Hello");
let other = s.execute("layer.newText", json!({"text": "Other"})).unwrap()["layer"].as_u64().unwrap();
begin_edit_at(&mut s, t, 5);
s.execute("layer.select", json!({"layer": other})).unwrap();
assert!(s.state.text_edit.as_ref().is_some_and(|e| e.layer == LayerId(t)), "selecting another layer leaves the edit open");
let before = layer_count(&s);
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert!(s.state.text_edit.is_none(), "Duplicate ends the open edit");
assert_eq!(layer_count(&s), before + 1, "one layer is copied");
assert_eq!(doc(&s, copy).text, "Hello", "the edited layer is copied, not the newly selected one");
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "and its copy is selected");
// Null parameters name no layer, as everywhere else layers are read.
for unnamed in [json!({"layers": null}), json!({"layer": null})] {
begin_edit_at(&mut s, t, 5);
s.execute("layer.select", json!({"layer": other})).unwrap();
let copy = s.execute("edit.duplicate", unnamed.clone()).unwrap()[0].as_u64().unwrap();
assert_eq!(doc(&s, copy).text, "Hello", "{unnamed}: the edited layer is copied");
}
begin_edit_at(&mut s, t, 5);
let named = s.execute("edit.duplicate", json!({"layer": other})).unwrap()[0].as_u64().unwrap();
assert_eq!(doc(&s, named).text, "Other", "a named layer is copied as named");
}
#[test]
fn duplicate_during_an_edit_with_nothing_selected_copies_the_edited_layer() {
use effectcraft_project::LayerId;
// An engine route can also clear the selection while the edit stays open. Duplicate stays
// available, since its unnamed target is the edited layer, not the selection.
let (mut s, _) = editing("Hello", 5);
s.execute("layer.select", json!({"layers": []})).unwrap();
assert!(s.state.selected_layers.is_empty() && s.state.text_edit.is_some(), "the edit is open with nothing selected");
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert!(s.state.text_edit.is_none(), "Duplicate ends the open edit");
assert_eq!(doc(&s, copy).text, "Hello", "and copies the edited layer");
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "which is selected");
// With no edit open, nothing selected still means nothing to duplicate.
s.execute("layer.select", json!({"layers": []})).unwrap();
let before = layer_count(&s);
assert!(s.execute("edit.duplicate", json!({})).is_err(), "outside an edit Duplicate needs a selection");
assert_eq!(layer_count(&s), before, "and copies nothing");
}
#[test]
fn duplicate_ends_the_open_edit_and_selects_the_copy() {
let (mut s, t) = editing("Hello", 5);
s.execute("text.insert", json!({"text": "!"})).unwrap();
assert_eq!(doc(&s, t).text, "Hello!");
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
// A deselected layer never keeps an open text edit: Duplicate commits the edit through the
// same route as text.endEdit before the selection moves, so every route (menu, shortcut,
// palette, MCP, script) leaves one final state.
assert!(s.state.text_edit.is_none(), "Duplicate ends the open text edit, as Deselect All does");
assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "and selects the copy");
assert_eq!(doc(&s, t).text, "Hello!", "the original keeps the typed text");
assert_eq!(doc(&s, copy).text, "Hello!", "the copy carries it too");
// The edit ending is no undoable step of its own (as with text.endEdit): undo takes the
// duplicate, it does not reopen the edit or undo the typing.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_count(&s), 1, "undo removed the copy");
assert_eq!(doc(&s, t).text, "Hello!", "the typed text stays committed");
assert!(s.state.text_edit.is_none(), "undo does not reopen the edit");
// With no open edit Duplicate is untouched by all of this: same behavior, no extra steps.
s.execute("edit.duplicate", json!({"layers": [t]})).unwrap();
assert_eq!(layer_count(&s), 2, "a plain duplicate still duplicates");
assert!(s.state.text_edit.is_none());
}
#[test]
fn duplicate_of_an_empty_type_tool_layer_ends_the_edit_and_copies_it() {
use effectcraft_project::LayerId;
let (mut s, _keep) = session("Hello");
let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert!(s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == LayerId(new)), "the Type tool opens the edit on its new, empty layer");
let before = layer_count(&s);
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert!(s.state.text_edit.is_none(), "the edit ends the way text.endEdit ends it");
assert_eq!(layer_count(&s), before + 1, "the empty layer is kept, as text.endEdit keeps it, and copied");
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "and the copy is selected");
}
+2 -1
View File
@@ -26,7 +26,8 @@ fn auto_orient_command() {
assert!(!effectcraft_render::three_d::camera::is_two_node(&s.active_comp().unwrap().layers[0]));
}
fn layer_count(s: &Session) -> usize {
/// Layers in the active comp.
pub(crate) fn layer_count(s: &Session) -> usize {
s.active_comp().unwrap().layers.len()
}
+73
View File
@@ -298,6 +298,25 @@ fn interpolation_dialog_temporal_spatial_and_roving() {
assert_ne!(prop(&s, l, "transform/position").keys[0].out_interp, Interp::Hold);
}
/// Editing another key's value or time keeps the roving key at its share of the path (#1284: the
/// roving key went back to where it was after edits to other keys).
#[test]
fn roving_key_follows_value_and_time_edits_to_other_keys() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0])), (0.3, json!([300, 0])), (2.0, json!([400, 0]))]);
let uid = prop(&s, l, "transform/position").uid;
s.execute("keys.select", json!({"keys": [{"layer": l, "prop": uid, "time": 0.3}]})).unwrap();
s.execute("keys.interpolation", json!({"roving": true})).unwrap();
let roving_at = |s: &Session| prop(s, l, "transform/position").keys[1].time.seconds();
assert!((roving_at(&s) - 1.5).abs() < 0.01, "start: {}", roving_at(&s));
// The last key's value moves to 800 px: the path is 300 + 500 px, so the roving key is at 300 / 800.
s.execute("keys.set", json!({"layer": l, "path": "transform/position", "time": 2.0, "value": [800, 0]})).unwrap();
assert!((roving_at(&s) - 0.75).abs() < 0.02, "value edit: {}", roving_at(&s));
// The last key moves to 3 s: the same 300 / 800 share of 3 s.
s.execute("keys.set", json!({"layer": l, "path": "transform/position", "time": 2.0, "newTime": 3.0})).unwrap();
assert!((roving_at(&s) - 1.125).abs() < 0.02, "time edit: {}", roving_at(&s));
}
#[test]
fn graph_editor_key_and_handle_edits() {
let (mut s, l) = setup();
@@ -325,6 +344,34 @@ fn graph_editor_key_and_handle_edits() {
assert_eq!(prop(&s, l, "transform/opacity").keys[0].out_interp, Interp::Linear);
}
#[test]
fn graph_editor_ease_edits_reject_a_locked_layer() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (2.0, json!(0))]);
select_prop_keys(&mut s, l, "transform/opacity");
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "locked", "value": true})).unwrap();
let before = prop(&s, l, "transform/opacity");
let selected = s.state.selected_keys.clone();
let undo = s.history.undo.len();
for side in ["in", "out"] {
let error =
s.execute("keys.setEase", json!({"layer": l, "path": "transform/opacity", "time": 1.0, "side": side, "speed": 200, "influence": 60})).unwrap_err();
assert!(error.to_string().contains("locked"), "{error}");
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys);
assert_eq!(s.state.selected_keys, selected);
assert_eq!(s.history.undo.len(), undo);
}
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "locked", "value": false})).unwrap();
s.execute("keys.setEase", json!({"layer": l, "path": "transform/opacity", "time": 1.0, "side": "out", "speed": 200, "influence": 60})).unwrap();
let after = prop(&s, l, "transform/opacity");
assert_eq!(after.keys[1].out_interp, Interp::Bezier);
assert_eq!(after.keys[1].out_ease[0].speed, 200.0);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/opacity").keys, after.keys);
}
#[test]
fn convert_expression_to_keyframes_on_a_reversed_layer_keeps_values() {
let (mut s, l) = setup();
@@ -1483,3 +1530,29 @@ fn a_typed_component_applies_only_that_component_to_the_selected_layers() {
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [1.0, 2.0, 3.0], "selected": true})).unwrap();
assert_eq!(pos(&s, c), vec![1.0, 2.0, 3.0]);
}
/// #2485: linked Audio Levels typed with several layers selected: each takes the typed Left, and
/// its Right moves by its own change, so every layer keeps its own stereo balance.
#[test]
fn a_typed_component_moves_its_follow_components_by_each_layers_own_change() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Sound", "width": 64, "height": 36, "duration": 4})).unwrap();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap();
let a = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": a, "path": "audio/levels", "value": [-6.0, -12.0]})).unwrap();
s.execute("prop.set", json!({"layer": b, "path": "audio/levels", "value": [-20.0, -30.0]})).unwrap();
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
let uid = prop(&s, a, "audio/levels").uid;
let undo = s.history.undo.len();
s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [-3.0, -9.0], "selected": true, "dims": [0], "follow": [1]})).unwrap();
assert_eq!(prop(&s, a, "audio/levels").value.components(), vec![-3.0, -9.0]);
assert_eq!(prop(&s, b, "audio/levels").value.components(), vec![-3.0, -13.0], "B rises 17 dB on both channels");
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
// Keyed at a time other than the CTI: B's change is measured on its key there.
animate(&mut s, b, "audio/levels", &[(0.0, json!([-20.0, -30.0])), (1.0, json!([-10.0, -40.0]))]);
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [-4.0, -10.0], "time": 1.0, "selected": true, "dims": [0], "follow": [1]})).unwrap();
let keys: Vec<Vec<f64>> = prop(&s, b, "audio/levels").keys.iter().map(|k| k.value.components()).collect();
assert_eq!(keys, vec![vec![-20.0, -30.0], vec![-4.0, -34.0]], "B's 1 s key rises 6 dB on both channels");
}
@@ -52,6 +52,14 @@ fn export_and_reimport_timeline_commands() {
let r = s.execute_checked("file.exportTimeline", json!({"comp": main, "path": tmp("NoExt"), "format": "otio", "prerender": "none"})).unwrap();
assert!(r["path"].as_str().unwrap().ends_with("NoExt.otio"));
assert!(s.execute_checked("file.exportTimeline", json!({"path": tmp("x.xml"), "prerender": "sometimes"})).is_err());
// In the background (the export dialog, #2269): a job, then the same file and result.
let path = tmp("Background.xml");
let r = s.execute_checked("file.exportTimeline", json!({"comp": main, "path": path, "background": true})).unwrap();
assert!(r["job"].is_string(), "{r}");
s.wait_jobs();
let done = s.job_log.last().unwrap();
assert_eq!((done.status.as_str(), done.result["path"].as_str()), ("done", Some(path.as_str())));
assert!(std::fs::read_to_string(&path).unwrap().contains("<xmeml"));
assert!(s.execute_checked("file.importTimeline", json!({"path": "/nope/a.prproj"})).is_err());
let formats = s.execute("file.timelineFormats", json!({})).unwrap();
assert_eq!(formats.as_array().unwrap().len(), 6);