effectcraft/crates/engine/src/commands/edit.rs

587 lines
24 KiB
Rust

//! Edit menu.
use effectcraft_color::Label;
use effectcraft_project::{Expression, ItemKind, Layer, LayerId, LayerSource};
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 crate::{EngineError, LinkClip, Result, Session, cmd};
fn can_undo(s: &Session) -> std::result::Result<(), String> {
if s.history.undo.is_empty() { Err("nothing to undo".into()) } else { Ok(()) }
}
fn can_redo(s: &Session) -> std::result::Result<(), String> {
if s.history.redo.is_empty() { Err("nothing to redo".into()) } else { Ok(()) }
}
fn has_clip(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
if s.state.clipboard.is_empty() && s.state.key_clipboard.is_empty() && s.state.effect_clipboard.is_empty() && s.state.link_clipboard.is_none() {
Err("the clipboard is empty".into())
} else {
Ok(())
}
}
fn has_key_clip(s: &Session) -> std::result::Result<(), String> {
has_layers(s)?;
if s.state.key_clipboard.is_empty() { Err("no keyframes on the clipboard".into()) } else { Ok(()) }
}
fn has_props_or_layers(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
if s.state.selected_layers.is_empty() && s.state.selected_props.is_empty() { Err("select layers or properties first".into()) } else { Ok(()) }
}
fn has_props_or_layers_or_items(s: &Session) -> std::result::Result<(), String> {
if has_props_or_layers(s).is_ok() || !s.state.project_selection.is_empty() { Ok(()) } else { Err("select layers or project items first".into()) }
}
fn has_selected_footage(s: &Session) -> std::result::Result<(), String> {
if original_path(s).is_some() { Ok(()) } else { Err("select a footage item or layer".into()) }
}
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
let label = s.history.undo.last().map(|u| u.0.clone());
s.undo();
Ok(json!({"undone": label}))
}
fn redo(s: &mut Session, _: &Value) -> Result<Value> {
let label = s.history.redo.last().map(|u| u.0.clone());
s.redo();
Ok(json!({"redone": label}))
}
fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
if let Some(c) = s.active_comp() {
s.state.selected_layers = c.layers.iter().map(|l| l.id).collect();
}
Ok(json!(s.state.selected_layers.len()))
}
fn deselect_all(s: &mut Session, _: &Value) -> Result<Value> {
s.state.selected_layers.clear();
s.state.selected_props.clear();
s.state.selected_keys.clear();
s.state.selected_vertices.clear();
Ok(Value::Null)
}
/// Fresh ids for a copied layer (and its property uids).
pub(crate) fn reid(layer: &mut Layer, next: &mut u64) {
layer.id = LayerId(*next);
*next += 1;
layer.props.reassign_uids(next);
*next += 1;
}
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 fx = super::effect::selected_effects(s);
if !fx.is_empty() {
let mut out = vec![];
for (lid, uid) in fx {
out.push(s.execute("effect.duplicate", json!({"layer": lid.0, "effect": uid}))?);
}
return Ok(json!({"effects": out}));
}
}
let (cid, ids) = 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)?;
let mut created = vec![];
for id in &ids {
let Some(i) = comp.layers.iter().position(|l| l.id == *id) else { continue };
let mut l = comp.layers[i].clone();
reid(&mut l, &mut next);
l.name = comp.unique_layer_name(&l.name);
created.push(l.id);
comp.layers.insert(i, l);
}
proj.next_id = next;
st.selected_layers = created.clone();
Ok(created)
})?;
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
// Keyframes selected → delete keys; mask vertices → delete them; else layers.
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
return s.execute("keys.delete", json!({}));
}
if !s.state.selected_vertices.is_empty() && p.get("layers").is_none() {
return s.execute("mask.deleteVertices", json!({}));
}
// Effects selected → remove them.
if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() {
return s.execute("effect.remove", json!({}));
}
let (cid, ids) = layers_p(s, p)?;
s.edit("Clear", None, |proj, st| {
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
comp.layers.retain(|l| !ids.contains(&l.id));
for l in &mut comp.layers {
if l.parent.is_some_and(|p| ids.contains(&p)) {
l.parent = None;
}
if l.track_matte.is_some_and(|m| ids.contains(&m.layer)) {
l.track_matte = None;
}
}
st.selected_layers.clear();
st.selected_props.clear();
st.selected_keys.clear();
Ok(())
})?;
Ok(json!(ids.len()))
}
fn clear_clipboards(s: &mut Session) {
s.state.clipboard.clear();
s.state.key_clipboard.clear();
s.state.effect_clipboard.clear();
s.state.link_clipboard = None;
s.state.clip_is_keys = false;
}
/// Edit ▸ Copy: selected keyframes (when any) go to the keyframe clipboard, else layers.
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
// Keyframes selected → copy keys (pasted at the CTI).
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
s.state.link_clipboard = None;
s.state.effect_clipboard.clear();
return s.execute("keys.copy", json!({}));
}
// Effects selected (Effect Controls / timeline) → copy the effects.
if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() {
return s.execute("effect.copy", json!({}));
}
let (cid, ids) = layers_p(s, p)?;
s.state.clip_is_keys = false;
let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?;
let layers: Vec<Layer> = comp.layers.iter().filter(|l| ids.contains(&l.id)).cloned().collect();
clear_clipboards(s);
s.state.clipboard = layers;
Ok(json!(s.state.clipboard.len()))
}
/// The expression accessor for a property, e.g. `comp("Main").layer("Solid").transform("Position")`.
pub(crate) fn link_expression(s: &Session, lid: LayerId, uid: effectcraft_project::Uid, relative: bool) -> Option<String> {
let cid = s.active_comp_id()?;
let comp = s.project.comp(cid)?;
let l = comp.layer(lid)?;
let names = l.props.name_path_of(uid)?;
let matches = match_path_of(&l.props, uid)?;
let names: Vec<&str> = names.split('/').collect();
let first = matches.split('/').next()?.to_string();
let q = |x: &str| serde_json::to_string(x).unwrap_or_default();
let base = if relative { format!("thisComp.layer({})", q(&l.name)) } else { format!("comp({}).layer({})", q(&s.project.item(cid)?.name), q(&l.name)) };
let mut out = match first.as_str() {
"transform" => format!("{base}.transform"),
"effects" => format!("{base}.effect({})", q(names.get(1)?)),
"masks" => format!("{base}.mask({})", q(names.get(1)?)),
"text" => format!("{base}.text"),
"contents" => format!("{base}.content({})", q(names.get(1)?)),
_ => return None,
};
let skip = if matches!(first.as_str(), "effects" | "masks" | "contents") { 2 } else { 1 };
for n in names.iter().skip(skip) {
out.push_str(&format!("({})", q(n)));
}
Some(out)
}
fn copy_links(s: &mut Session, p: &Value, relative: bool) -> Result<Value> {
let props = selected_leaf_props(s);
if props.is_empty() {
// Layers: copy them with every animatable transform/effect property linked to the source.
let (cid, ids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let mut layers = vec![];
for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) {
let mut copy = l.clone();
let mut uids = vec![];
for g in ["transform", "effects"] {
if let Some(grp) = l.props.sub(g) {
grp.walk("", &mut |_, pr| {
if !pr.static_only && !pr.hold_only {
uids.push(pr.uid);
}
});
}
}
for uid in uids {
if let (Some(e), Some(pr)) = (link_expression(s, l.id, uid, relative), copy.props.find_mut(uid)) {
pr.expr = Some(Expression { text: e, enabled: true });
}
}
layers.push(copy);
}
let n = layers.len();
clear_clipboards(s);
s.state.clipboard = layers;
return Ok(json!({"layers": n}));
}
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let mut links = vec![];
for (lid, uid) in &props {
let Some(path) = comp.layer(*lid).and_then(|l| match_path_of(&l.props, *uid)) else { continue };
if let Some(e) = link_expression(s, *lid, *uid, relative) {
links.push((path, e));
}
}
let n = links.len();
clear_clipboards(s);
s.state.link_clipboard = Some(LinkClip::Links { relative, links });
Ok(json!({"links": n}))
}
fn copy_expression_only(s: &mut Session, _: &Value) -> Result<Value> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let mut out = vec![];
for (lid, uid) in selected_leaf_props(s) {
let Some(l) = comp.layer(lid) else { continue };
if let (Some(pr), Some(path)) = (l.props.find(uid), match_path_of(&l.props, uid))
&& let Some(e) = &pr.expr
{
out.push((path, e.clone()));
}
}
if out.is_empty() {
return Err(EngineError::Other("the selected properties have no expressions".into()));
}
let n = out.len();
clear_clipboards(s);
s.state.link_clipboard = Some(LinkClip::Expressions(out));
Ok(json!({"expressions": n}))
}
/// Paste the keyframe clipboard at the CTI onto the selected layers (same property paths).
fn paste_keys(s: &mut Session, reversed: bool) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let ids = s.state.selected_layers.clone();
if ids.is_empty() {
return Err(EngineError::Other("select a layer to paste keyframes into".into()));
}
let clip = s.state.key_clipboard.clone();
let t = s.time();
let n = s.edit(if reversed { "Paste Reversed Keyframes" } else { "Paste Keyframes" }, None, |proj, st| {
let mut n = 0;
st.selected_keys.clear();
for lid in &ids {
let l = super::layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
for c in &clip {
let Some(pr) = l.props.prop_mut(&c.path) else { continue };
let span = c.keys.last().map(|k| k.time).unwrap_or(Tick::ZERO);
for k in &c.keys {
let mut k = k.clone();
if std::mem::discriminant(&k.value) != std::mem::discriminant(&pr.value) {
continue;
}
if reversed {
k.time = span - k.time;
std::mem::swap(&mut k.in_interp, &mut k.out_interp);
std::mem::swap(&mut k.in_ease, &mut k.out_ease);
std::mem::swap(&mut k.spatial_in, &mut k.spatial_out);
}
k.time += lt;
st.selected_keys.push(crate::KeyRef { layer: *lid, prop: pr.uid, time: k.time });
effectcraft_keyframe::set_key(&mut pr.keys, k);
n += 1;
}
}
}
Ok(n)
})?;
Ok(json!({"keys": n}))
}
fn paste_links(s: &mut Session, clip: LinkClip) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let ids = s.state.selected_layers.clone();
if ids.is_empty() {
return Err(EngineError::Other("select a layer to paste into".into()));
}
let pairs: Vec<(String, Expression)> = match clip {
LinkClip::Links { links, .. } => links.into_iter().map(|(p, e)| (p, Expression { text: e, enabled: true })).collect(),
LinkClip::Expressions(v) => v,
};
let n = s.edit("Paste", None, |proj, _| {
let mut n = 0;
for lid in &ids {
let l = super::layer_mut(proj, cid, *lid)?;
for (path, e) in &pairs {
if let Some(pr) = l.props.prop_mut(path) {
pr.expr = Some(e.clone());
n += 1;
}
}
}
Ok(n)
})?;
Ok(json!({"expressions": n}))
}
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
s.execute("keys.copy", json!({}))?;
return s.execute("keys.delete", json!({}));
}
copy(s, p)?;
delete(s, p)
}
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(clip) = s.state.link_clipboard.clone() {
return paste_links(s, clip);
}
if s.state.clip_is_keys && !s.state.key_clipboard.is_empty() {
return s.execute("keys.paste", p.clone());
}
if !s.state.effect_clipboard.is_empty() {
return s.execute("effect.paste", p.clone());
}
let cid = super::comp_id(s, p)?;
let clip = s.state.clipboard.clone();
let new = s.edit("Paste", None, |proj, st| {
let mut next = proj.next_id;
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
let at = st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0);
let mut created = vec![];
for (k, mut l) in clip.into_iter().enumerate() {
reid(&mut l, &mut next);
l.name = comp.unique_layer_name(&l.name);
l.parent = None;
l.track_matte = None;
created.push(l.id);
comp.layers.insert(at + k, l);
}
proj.next_id = next;
st.selected_layers = created.clone();
Ok(created)
})?;
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
}
fn split(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let t = s.time();
let new = s.edit("Split Layer", None, |proj, st| {
let mut next = proj.next_id;
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
let mut created = vec![];
for id in &ids {
let Some(i) = comp.layers.iter().position(|l| l.id == *id) else { continue };
if !(t > comp.layers[i].in_point && t < comp.layers[i].out_point) {
continue;
}
let mut b = comp.layers[i].clone();
reid(&mut b, &mut next);
b.in_point = t;
comp.layers[i].out_point = t;
created.push(b.id);
comp.layers.insert(i, b);
}
proj.next_id = next;
st.selected_layers = created.clone();
Ok(created)
})?;
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
}
fn label(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "label").unwrap_or("Red");
let lab = s.prefs.label_from_name(name).ok_or_else(|| super::bad("edit.label", format!("unknown label `{name}`")))?;
let (cid, ids) = layers_p(s, p)?;
let items = s.state.project_selection.clone();
s.edit("Label", None, |proj, _| {
if ids.is_empty() {
for i in &items {
if let Some(it) = proj.item_mut(*i) {
it.label = lab;
}
}
}
if let Some(comp) = proj.comp_mut(cid) {
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
l.label = lab;
}
}
Ok(())
})?;
Ok(Value::Null)
}
fn paste_reversed(s: &mut Session, _: &Value) -> Result<Value> {
paste_keys(s, true)
}
/// Lift (leave a gap) or extract (close the gap) the work area from the selected layers (all
/// unlocked layers when none are selected).
fn work_area_cut(s: &mut Session, p: &Value, extract: bool) -> Result<Value> {
let cid = super::comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let (a, b) = comp.work_area;
let ids: Vec<LayerId> = match layers_p(s, p)?.1 {
v if v.is_empty() => comp.layers.iter().map(|l| l.id).collect(),
v => v,
};
let gap = b - a;
s.edit(if extract { "Extract Work Area" } else { "Lift Work Area" }, None, |proj, st| {
let mut next = proj.next_id;
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let shift = |l: &mut Layer, d: Tick| {
l.start_time += d;
l.in_point += d;
l.out_point += d;
};
let mut i = 0;
while i < c.layers.len() {
if !ids.contains(&c.layers[i].id) || c.layers[i].switches.locked {
i += 1;
continue;
}
let (lin, lout) = (c.layers[i].in_point, c.layers[i].out_point);
if lout <= a {
i += 1;
} else if lin >= b {
if extract {
shift(&mut c.layers[i], Tick::ZERO - gap);
}
i += 1;
} else if lin >= a && lout <= b {
c.layers.remove(i);
} else if lin < a && lout > b {
// Split around the work area: the layer keeps the head, a copy takes the tail.
let mut tail = c.layers[i].clone();
reid(&mut tail, &mut next);
tail.in_point = b;
if extract {
shift(&mut tail, Tick::ZERO - gap);
}
c.layers[i].out_point = a;
c.layers.insert(i, tail);
i += 2;
} else if lin < a {
c.layers[i].out_point = a;
i += 1;
} else {
c.layers[i].in_point = b;
if extract {
shift(&mut c.layers[i], Tick::ZERO - gap);
}
i += 1;
}
}
let alive: Vec<LayerId> = c.layers.iter().map(|l| l.id).collect();
st.selected_layers.retain(|l| alive.contains(l));
proj.next_id = next;
Ok(())
})?;
Ok(Value::Null)
}
fn lift(s: &mut Session, p: &Value) -> Result<Value> {
work_area_cut(s, p, false)
}
fn extract(s: &mut Session, p: &Value) -> Result<Value> {
work_area_cut(s, p, true)
}
fn select_label_group(s: &mut Session, _: &Value) -> Result<Value> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let labels: Vec<Label> = s.state.selected_layers.iter().filter_map(|l| comp.layer(*l)).map(|l| l.label).collect();
if labels.is_empty() {
// Project panel: items with the same labels.
let items: Vec<Label> = s.state.project_selection.iter().filter_map(|i| s.project.item(*i)).map(|i| i.label).collect();
s.state.project_selection = s.project.items.values().filter(|i| items.contains(&i.label)).map(|i| i.id).collect();
return Ok(json!({"items": s.state.project_selection.len()}));
}
s.state.selected_layers = comp.layers.iter().filter(|l| labels.contains(&l.label)).map(|l| l.id).collect();
Ok(json!({"layers": s.state.selected_layers.len()}))
}
fn purge_caches(s: &mut Session, p: &Value) -> Result<Value> {
let what = str_p(p, "what").unwrap_or("all").to_string();
s.events.push(crate::Event::PurgeCaches);
s.toast(format!("Purged {what} cache"));
Ok(json!({"purged": what}))
}
/// Path of the footage behind the selected layer or project item.
fn original_path(s: &Session) -> Option<String> {
let comp = s.active_comp();
let from_layer = s.state.selected_layers.first().and_then(|l| comp?.layer(*l)).and_then(|l| match l.source {
LayerSource::Footage { item } => Some(item),
_ => None,
});
let item = from_layer.or_else(|| s.state.project_selection.first().copied())?;
match &s.project.item(item)?.kind {
ItemKind::Footage(f) if !f.path.is_empty() => Some(f.path.clone()),
_ => None,
}
}
fn edit_original(s: &mut Session, _: &Value) -> Result<Value> {
let path = original_path(s).ok_or_else(|| super::bad("edit.editOriginal", "select a footage item or layer"))?;
let url = format!("file://{path}");
s.events.push(crate::Event::OpenUrl(url.clone()));
Ok(json!({"url": url}))
}
fn purge(s: &mut Session, _: &Value) -> Result<Value> {
s.history.undo.clear();
s.history.redo.clear();
s.toast("Purged undo history");
Ok(Value::Null)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{layers?} (keyframes when keys are selected)", layers_or_keys, cut),
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{layers?} (keyframes when keys are selected)", layers_or_keys, copy),
cmd!(
"edit.copyWithPropertyLinks",
"Copy with Property Links",
["Edit"],
Some("Cmd+Alt+C"),
"{layers?} (selected properties, or layers, as expressions linking to the originals)",
has_props_or_layers,
|s, p| copy_links(s, p, false)
),
cmd!(
"edit.copyWithRelativePropertyLinks",
"Copy with Relative Property Links",
["Edit"],
None,
"{layers?} (like Copy with Property Links, using thisComp)",
has_props_or_layers,
|s, p| copy_links(s, p, true)
),
cmd!("edit.copyExpressionOnly", "Copy Expression Only", ["Edit"], None, "{}", has_props_or_layers, copy_expression_only),
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{} (layers, keyframes at the CTI, or property links / expressions)", has_clip, paste),
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{}", has_key_clip, paste_reversed),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?}", has_layers, 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),
cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_comp, select_all),
cmd!("edit.deselectAll", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always_ok, deselect_all),
cmd!("edit.label", "Label", ["Edit", "Label"], None, "{label: Red|Yellow|Aqua|…, layers?}", always_ok, label),
cmd!("edit.selectLabelGroup", "Select Label Group", ["Edit", "Label"], None, "{}", has_props_or_layers_or_items, select_label_group),
cmd!("edit.purgeUndo", "Undo", ["Edit", "Purge"], None, "{}", always_ok, purge),
cmd!("edit.purge", "Purge", [], None, "{what?: all|memoryAndDisk|memory|disk|3d|image|snapshot}", always_ok, purge_caches),
cmd!("edit.editOriginal", "Edit Original...", ["Edit"], Some("Cmd+E"), "{}", has_selected_footage, edit_original),
]
}
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) }
}
fn always_ok(_: &Session) -> std::result::Result<(), String> {
Ok(())
}