Files
effectcraft/crates/engine/src/commands/edit.rs
T
bflatasticandClaude Opus 5.5 31fc517baa Edit: Delete removes selected animators, masks and shape groups (#151)
Selecting a property group also selects its layer, and edit.clear only
looked for selected keys, mask vertices, puppet pins and effects before
deleting the selected layers, so Delete with a text animator, mask or
shape group selected deleted the whole layer.

edit.clear now removes the selected removable groups (text animators,
masks, shape items, trackers) of the active comp in one undo step before
falling back to layers. It shares the removal with prop.removeGroup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:32:40 +07:00

737 lines
31 KiB
Rust

//! Edit menu.
use effectcraft_color::Label;
use effectcraft_project::{Comp, Expression, ItemKind, Layer, LayerId, LayerSource, TrackMatte};
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.text_edit.is_some() {
// Text editing pastes the system clipboard's text (passed as `text`) or copied text.
return Ok(());
}
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 s.state.text_edit.is_some() {
return s.execute("text.setSelection", json!({"select": "all"}));
}
// A puppet pin selected: every pin of that kind.
if let Some(r) = super::puppet::select_all_of_kind(s) {
return Ok(r);
}
// Locked layers (and shy layers while they are hidden) can't be selected.
if let Some(c) = s.active_comp() {
s.state.selected_layers = c.layers.iter().filter(|l| super::selectable(c, l)).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)
}
/// Copied layers keep their parent and track matte: the copy of that layer when it was copied
/// too, else the layer itself when it is in `comp`, else none. `map`: (original, copy).
fn relink_copies(comp: &mut Comp, map: &[(LayerId, LayerId)]) {
let present: Vec<LayerId> = comp.layers.iter().map(|l| l.id).collect();
let link = |id: LayerId| map.iter().find(|(o, _)| *o == id).map(|(_, c)| *c).or(present.contains(&id).then_some(id));
for l in comp.layers.iter_mut().filter(|l| map.iter().any(|(_, c)| *c == l.id)) {
l.parent = l.parent.and_then(link);
l.track_matte = l.track_matte.and_then(|m| link(m.layer).map(|layer| TrackMatte { layer, ..m }));
}
}
/// 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![];
let mut map = 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);
map.push((*id, l.id));
comp.layers.insert(i, l);
}
relink_copies(comp, &map);
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> {
if s.state.text_edit.is_some() && p.get("layers").is_none() {
return s.execute("text.delete", json!({}));
}
// 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!({}));
}
// Puppet pins selected → delete the pins (not their layer).
if p.get("layers").is_none() && !super::puppet::selected_pins(s).is_empty() {
return s.execute("puppet.removePin", json!({}));
}
// Effects selected → remove them.
if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() {
return s.execute("effect.remove", json!({}));
}
// Masks, shape items, text animators or trackers selected → remove them.
if p.get("layers").is_none()
&& let Some(n) = super::prop_groups::remove_selected(s)?
{
return Ok(n);
}
let (cid, ids) = layers_p(s, p)?;
let ids = super::unlocked(s, cid, ids, "edit.clear")?;
// Children of deleted layers are unparented where they are (like After Effects).
let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?;
let orphans: Vec<LayerId> = comp.layers.iter().filter(|l| !ids.contains(&l.id) && l.parent.is_some_and(|p| ids.contains(&p))).map(|l| l.id).collect();
let fixes = super::layer::parent_fixes(s, cid, &orphans, None);
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 let Some(f) = fixes.iter().find(|f| f.layer == l.id) {
f.apply(l);
}
}
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> {
if s.state.text_edit.is_some() && p.get("layers").is_none() {
return super::text_edit::copy(s);
}
// 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_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.text_edit.is_some() && p.get("layers").is_none() {
return super::text_edit::cut(s);
}
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 s.state.text_edit.is_some() {
return super::text_edit::paste(s, p);
}
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![];
let mut map = vec![];
for (k, mut l) in clip.into_iter().enumerate() {
let original = l.id;
reid(&mut l, &mut next);
l.name = comp.unique_layer_name(&l.name);
created.push(l.id);
map.push((original, l.id));
comp.layers.insert(at + k, l);
}
relink_copies(comp, &map);
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();
// Settings ▸ General ▸ Create Split Layers Above Original Layer.
let above = s.prefs.general.create_split_layers_above;
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(if above { i } else { i + 1 }, 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> {
// Keyframe colour labels: selected keyframes take the label (unless layers are named).
let explicit_layers = p.get("layers").is_some() || p.get("layer").is_some();
if p.get("keys").is_some() || (!s.state.selected_keys.is_empty() && !explicit_layers && str_p(p, "target") != Some("layers")) {
return super::keys_more::label_keys(s, p);
}
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)
}
/// Edit ▸ Paste Reversed Keyframes: the copied keys mirrored in time over their whole span (the
/// last key first, incoming and outgoing eases swapped), pasted like Edit ▸ Paste
/// (`keys.paste`: at the current time, into the selected or the same properties).
fn paste_reversed(s: &mut Session, p: &Value) -> Result<Value> {
let original = s.state.key_clipboard.clone();
let span = original.iter().flat_map(|c| c.keys.iter().map(|k| k.time)).max().unwrap_or(Tick::ZERO);
s.state.key_clipboard = original
.iter()
.map(|c| crate::KeyClip {
keys: c
.keys
.iter()
.rev()
.map(|k| {
let mut k = k.clone();
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
})
.collect(),
..c.clone()
})
.collect();
let r = s.execute("keys.paste", p.clone());
s.state.key_clipboard = original;
r
}
/// 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();
if matches!(what.as_str(), "all" | "snapshot") {
s.snapshot = None;
s.state.viewer.show_snapshot = false;
}
let memory = matches!(what.as_str(), "all" | "memoryAndDisk" | "memory" | "image");
let disk = matches!(what.as_str(), "all" | "memoryAndDisk" | "disk");
if memory {
s.layer_cache.clear();
s.footage.purge();
}
let mut disk_entries = 0;
if disk && let Some(dc) = &s.disk_cache {
disk_entries = dc.stats().entries;
dc.clear();
} else if disk && let Some(h) = &s.storage {
// The browser's disk cache (Origin Private File System).
disk_entries = h.clear("diskCache").ok().and_then(|r| r["entries"].as_u64()).unwrap_or(0) as usize;
}
if matches!(what.as_str(), "all" | "image" | "memory" | "memoryAndDisk") {
effectcraft_effects::roto::purge();
}
// The viewers' RAM preview goes with the memory purges (and the 3D one, whose renders it
// holds); purging the disk cache or the snapshot leaves it.
if memory || what == "3d" {
s.events.push(crate::Event::PurgeCaches);
}
s.toast(format!("Purged {what} cache"));
Ok(json!({"purged": what, "diskEntries": disk_entries}))
}
/// `cache.diskStats`: the disk cache's folder, limit and contents.
pub(crate) fn disk_stats(s: &mut Session, _: &Value) -> Result<Value> {
let Some(dc) = &s.disk_cache else {
// The browser's disk cache (Origin Private File System), when the web app has one.
let web = s.storage.as_ref().map(|h| h.info()["diskCache"].clone()).filter(|d| d.is_object());
return Ok(web.unwrap_or_else(|| json!({"enabled": false})));
};
let st = dc.stats();
Ok(json!({
"enabled": true,
"folder": dc.folder().to_string_lossy(),
"entries": st.entries,
"frames": dc.keys(effectcraft_render::disk_cache::Kind::Frame).len(),
"layers": dc.keys(effectcraft_render::disk_cache::Kind::Layer).len(),
"bytes": st.bytes,
"maxBytes": st.max_bytes,
"hits": st.hits,
"misses": st.misses,
"writes": st.writes,
"evictions": st.evictions,
}))
}
/// 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.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, "{layers?, prop?|path?, time?}", 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?, keys?, target?: 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),
crate::query!("cache.diskStats", "Disk Cache Statistics", "{}", disk_stats),
cmd!("edit.editOriginal", "Edit Original...", ["Edit"], Some("Cmd+E"), "{}", has_selected_footage, edit_original),
crate::query!(
"edit.history.list",
"History",
"{} → {states: [{index, id, label, parent, depth, current, line, future}], current, branches}",
history_list
),
cmd!(
"edit.history.goto",
"Go to History State",
[],
None,
"{index? (from edit.history.list) | id? | steps? (negative = back along the line)}",
always_ok,
history_goto
),
]
}
// ---------------------------------------------------------------- History panel
/// `edit.history.list`: every state of the (branching) undo history.
fn history_list(s: &mut Session, _: &Value) -> Result<Value> {
let nodes = s.history_tree();
let current = nodes.iter().position(|n| n.current);
Ok(json!({"states": nodes, "current": current, "branches": s.history.branches.len()}))
}
/// `edit.history.goto`: jump to a state by `index` (from `edit.history.list`), `id`, or
/// `steps` along the working line (negative = back, like Undo).
fn history_goto(s: &mut Session, p: &Value) -> Result<Value> {
let c = "edit.history.goto";
if let Some(n) = p.get("steps").and_then(Value::as_i64) {
for _ in 0..n.unsigned_abs() {
let moved = if n < 0 { s.undo() } else { s.redo() };
if !moved {
break;
}
}
} else {
let nodes = s.history_tree();
let id = match (p.get("id").and_then(Value::as_str), p.get("index").and_then(Value::as_u64)) {
(Some(id), _) => id.to_string(),
(None, Some(i)) => nodes.get(i as usize).map(|n| n.id.clone()).ok_or_else(|| super::bad(c, format!("no state {i} (0..{})", nodes.len())))?,
_ => return Err(super::bad(c, "give `index`, `id` or `steps`")),
};
if !s.goto_history(&id) {
return Err(super::bad(c, format!("no history state `{id}`")));
}
}
let nodes = s.history_tree();
let cur = nodes.iter().find(|n| n.current).cloned();
Ok(json!({"current": cur.as_ref().map(|n| n.index), "label": cur.map(|n| n.label)}))
}
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(())
}