mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 03:57:44 -06:00
M14.4: scripting with an After Effects-style JavaScript object model
New crate effectcraft-script (L4): boa runtime with app/Project/CompItem/ Layer/PropertyGroup/Property/TextDocument/MarkerValue/RenderQueue/File object model over engine commands (undoable, undo groups), AE match-name table, file/network security gate. Entry points: script.run command, File > Scripts > Run Script File (.jsx/.js), Window > Script Console, effectcraft-cli script, MCP run_script. Engine: project.delete/duplicate, prop.renameGroup/setGroupEnabled/removeGroup/duplicateGroup/moveGroup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e911cc5c93
commit
78cd313d3c
@@ -358,9 +358,30 @@ fn collect_files(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------------------------------------------------------------- scripts
|
||||
|
||||
/// Run a command script: a JSON array or JSON lines of `{"command": id, "params": {…}}` (also
|
||||
/// accepts the control channel's `{"method": "engine.execute", "params": {"id", "params"}}`).
|
||||
/// File ▸ Scripts ▸ Run Script File…: `.jsx` / `.js` files run as JavaScript against the After
|
||||
/// Effects-style object model ([`Session::script`]); anything else is a command script: a JSON
|
||||
/// array or JSON lines of `{"command": id, "params": {…}}` (also accepts the control channel's
|
||||
/// `{"method": "engine.execute", "params": {"id", "params"}}`).
|
||||
fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(path) = str_p(p, "path")
|
||||
&& p.get("steps").is_none()
|
||||
&& is_js_script(path)
|
||||
{
|
||||
if path.to_ascii_lowercase().ends_with(".jsxbin") {
|
||||
return Err(bad("file.runScript", "binary .jsxbin scripts are not supported: run the .jsx source"));
|
||||
}
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
let code = String::from_utf8_lossy(&bytes).to_string();
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let out = run_js(s, &code, path, false)?;
|
||||
if let Some(e) = out.get("error").filter(|e| !e.is_null()) {
|
||||
let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default();
|
||||
let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error");
|
||||
s.events.push(crate::Event::Toast { message: format!("{name}{line}: {msg}"), error: true });
|
||||
return Err(EngineError::Other(format!("{name}{line}: {msg}")));
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
let steps: Vec<Value> = match (p.get("steps"), str_p(p, "path")) {
|
||||
(Some(Value::Array(a)), _) => a.clone(),
|
||||
(_, Some(path)) => {
|
||||
@@ -388,7 +409,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
_ => return Err(bad("file.runScript", format!("step {}: needs `command`", i + 1))),
|
||||
};
|
||||
if id == "file.runScript" {
|
||||
if id == "file.runScript" || id == "script.run" {
|
||||
return Err(bad("file.runScript", "scripts can't run scripts"));
|
||||
}
|
||||
let r = s.execute(&id, params).map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?;
|
||||
@@ -397,6 +418,28 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"steps": results.len(), "results": results}))
|
||||
}
|
||||
|
||||
/// `.jsx` / `.js` (and `.jsxbin`, rejected) are JavaScript; `.json` / `.jsonl` / other files are
|
||||
/// command scripts.
|
||||
fn is_js_script(path: &str) -> bool {
|
||||
let l = path.to_ascii_lowercase();
|
||||
l.ends_with(".jsx") || l.ends_with(".js") || l.ends_with(".jsxbin")
|
||||
}
|
||||
|
||||
/// Run JavaScript through the session's scripting engine.
|
||||
fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result<Value> {
|
||||
let runner = s.script.ok_or_else(|| EngineError::Other("JavaScript scripting is not available in this build".into()))?;
|
||||
Ok(runner(s, &crate::ScriptRequest { code, name, console }))
|
||||
}
|
||||
|
||||
/// `script.run`: run JavaScript (the After Effects scripting object model: `app.project`, comps,
|
||||
/// layers, properties…) and report `{ok, result, output, error}`; script errors are reported in
|
||||
/// `error` (with line and column), not as a command failure.
|
||||
fn script_run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let code = str_p(p, "code").ok_or_else(|| bad("script.run", "missing `code` (JavaScript)"))?.to_string();
|
||||
let name = str_p(p, "name").unwrap_or("script").to_string();
|
||||
run_js(s, &code, &name, super::b_p(p, "console").unwrap_or(false))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- footage
|
||||
|
||||
fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -638,10 +681,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Run Script File...",
|
||||
["File", "Scripts"],
|
||||
None,
|
||||
"{path (.jsonl/.json command script) | steps: [{command, params}]}",
|
||||
"{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | steps: [{command, params}]}",
|
||||
always,
|
||||
run_script
|
||||
),
|
||||
cmd!(
|
||||
"script.run",
|
||||
"Run Script",
|
||||
[],
|
||||
None,
|
||||
"{code (JavaScript: app, app.project, CompItem, Layer, Property… like After Effects scripting), name?, console? (Script Console context)} → {ok, result, output, error: {message, line, column} | null}",
|
||||
always,
|
||||
script_run
|
||||
),
|
||||
cmd!(
|
||||
"file.interpretFootage",
|
||||
"Main...",
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod paint;
|
||||
mod paths;
|
||||
mod project_items;
|
||||
mod prop;
|
||||
mod prop_groups;
|
||||
pub mod puppet;
|
||||
mod query;
|
||||
mod render_queue;
|
||||
@@ -105,6 +106,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(text_edit::specs());
|
||||
v.extend(layer_time::specs());
|
||||
v.extend(prop::specs());
|
||||
v.extend(prop_groups::specs());
|
||||
v.extend(project_items::specs());
|
||||
v.extend(anim::specs());
|
||||
v.extend(anim_tools::specs());
|
||||
|
||||
@@ -140,6 +140,92 @@ fn set_comment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Delete project items (folders with their contents). Layers that use a deleted item and Render
|
||||
/// Queue items of deleted comps go too, like After Effects' Edit ▸ Clear in the Project panel.
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "project.delete";
|
||||
let ids = items_p(s, p, c)?;
|
||||
let mut doomed: Vec<ItemId> = vec![];
|
||||
for id in s.project.items.keys() {
|
||||
if ids.iter().any(|d| is_within(&s.project, *id, *d)) {
|
||||
doomed.push(*id);
|
||||
}
|
||||
}
|
||||
let n = doomed.len();
|
||||
s.edit("Delete Items", None, |proj, st| {
|
||||
for id in &doomed {
|
||||
proj.items.remove(id);
|
||||
}
|
||||
let comp_ids: Vec<ItemId> = proj.comps().map(|(id, _)| *id).collect();
|
||||
for cid in comp_ids {
|
||||
let uses = proj.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.item().is_some_and(|i| doomed.contains(&i))));
|
||||
if !uses {
|
||||
continue;
|
||||
}
|
||||
if let Some(comp) = proj.comp_mut(cid) {
|
||||
let gone: Vec<effectcraft_project::LayerId> =
|
||||
comp.layers.iter().filter(|l| l.source.item().is_some_and(|i| doomed.contains(&i))).map(|l| l.id).collect();
|
||||
comp.layers.retain(|l| !gone.contains(&l.id));
|
||||
for l in &mut comp.layers {
|
||||
if l.parent.is_some_and(|x| gone.contains(&x)) {
|
||||
l.parent = None;
|
||||
}
|
||||
if l.track_matte.is_some_and(|m| gone.contains(&m.layer)) {
|
||||
l.track_matte = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
proj.render_queue.retain(|r| !doomed.contains(&r.comp));
|
||||
st.project_selection.retain(|i| !doomed.contains(i));
|
||||
Ok(())
|
||||
})?;
|
||||
s.sanitize_state();
|
||||
Ok(json!(n))
|
||||
}
|
||||
|
||||
/// Duplicate project items (Edit ▸ Duplicate in the Project panel): comps get fresh layer ids and
|
||||
/// a numbered name; the copies are selected.
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "project.duplicate";
|
||||
let ids = items_p(s, p, c)?;
|
||||
let mut created = vec![];
|
||||
s.edit("Duplicate", None, |proj, st| {
|
||||
for id in &ids {
|
||||
let it = proj.item(*id).ok_or(EngineError::Other("item vanished".into()))?.clone();
|
||||
if it.is_folder() {
|
||||
return Err(bad(c, "folders can't be duplicated"));
|
||||
}
|
||||
let base = it.name.trim_end_matches(|ch: char| ch.is_ascii_digit()).trim_end().to_string();
|
||||
let name = (2..).map(|i| format!("{base} {i}")).find(|n| proj.find_by_name(n).is_none()).unwrap_or_default();
|
||||
let mut kind = it.kind.clone();
|
||||
if let effectcraft_project::ItemKind::Comp(comp) = &mut kind {
|
||||
let comp = std::sync::Arc::make_mut(comp);
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for l in &mut comp.layers {
|
||||
let new = effectcraft_project::LayerId(proj.alloc());
|
||||
map.insert(l.id, new);
|
||||
l.id = new;
|
||||
let mut next = proj.next_id;
|
||||
l.props.reassign_uids(&mut next);
|
||||
proj.next_id = next + 1;
|
||||
}
|
||||
for l in &mut comp.layers {
|
||||
l.parent = l.parent.and_then(|p| map.get(&p).copied());
|
||||
if let Some(m) = &mut l.track_matte {
|
||||
m.layer = map.get(&m.layer).copied().unwrap_or(m.layer);
|
||||
}
|
||||
}
|
||||
}
|
||||
let nid = proj.add_item(&name, it.label, it.parent, kind);
|
||||
created.push(nid);
|
||||
}
|
||||
st.project_selection = created.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"items": created.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("project.select", "Select Project Items", [], None, "{items: [id|name], add?}", always, select),
|
||||
@@ -147,5 +233,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("project.move", "Move to Folder", [], None, "{items?: [id|name] (default: selected), folder: id|name|null (root)}", always, move_items),
|
||||
cmd!("project.setLabel", "Item Label", [], None, "{items?, label: name|index}", always, set_label),
|
||||
cmd!("project.setComment", "Item Comment", [], None, "{items?, comment}", always, set_comment),
|
||||
cmd!(
|
||||
"project.delete",
|
||||
"Delete Project Items",
|
||||
[],
|
||||
None,
|
||||
"{items?: [id|name] (default: selected)} (folders with their contents; layers using the items go too)",
|
||||
always,
|
||||
delete
|
||||
),
|
||||
cmd!("project.duplicate", "Duplicate Project Items", [], None, "{items?: [id|name] (default: selected)} → {items}", always, duplicate),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Generic edits of property-tree groups by uid: rename, enable, remove, duplicate and reorder an
|
||||
//! effect, mask, shape item or text animator (what scripting's `PropertyGroup.name`, `enabled`,
|
||||
//! `remove()`, `duplicate()` and `moveTo()` do, and what agents need without a UI selection).
|
||||
|
||||
use effectcraft_project::{GroupKind, ItemId, LayerId, Node, PropGroup, Uid};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
/// Groups that can be renamed, removed, duplicated and reordered: instances in indexed lists.
|
||||
fn is_instance(g: &PropGroup) -> bool {
|
||||
matches!(g.kind, GroupKind::Indexed | GroupKind::Effect { .. } | GroupKind::Mask { .. } | GroupKind::Tracker { .. })
|
||||
}
|
||||
|
||||
/// `{layer, prop: uid}` → (comp, layer, group uid), checking that the group is an instance.
|
||||
fn group_ref(s: &Session, p: &Value, cmd: &str, need_instance: bool) -> Result<(ItemId, LayerId, Uid)> {
|
||||
let (cid, lid) = layer_p(s, p, cmd)?;
|
||||
let uid = p.get("prop").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `prop` (group uid)"))?;
|
||||
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
let g = l.props.find_group(uid).ok_or_else(|| bad(cmd, format!("no property group @{uid}")))?;
|
||||
if need_instance && !is_instance(g) {
|
||||
return Err(bad(cmd, format!("`{}` is a fixed group (only effects, masks, shape items and animators)", g.name)));
|
||||
}
|
||||
Ok((cid, lid, uid))
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.renameGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?.to_string();
|
||||
if name.trim().is_empty() {
|
||||
return Err(bad(c, "the name can't be empty"));
|
||||
}
|
||||
s.edit("Rename", None, |proj, _| {
|
||||
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
g.name = name.clone();
|
||||
Ok(json!(name))
|
||||
})
|
||||
}
|
||||
|
||||
fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.setGroupEnabled";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
let v = b_p(p, "value");
|
||||
s.edit("Toggle", None, |proj, _| {
|
||||
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
g.enabled = v.unwrap_or(!g.enabled);
|
||||
Ok(json!(g.enabled))
|
||||
})
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.removeGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
s.edit("Delete", None, |proj, st| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
parent.children.retain(|n| n.uid() != uid);
|
||||
st.selected_props.retain(|(_, u)| *u != uid);
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.duplicateGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
s.edit("Duplicate", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
let i = parent.children.iter().position(|n| n.uid() == uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
let Node::Group(mut g) = parent.children[i].clone() else { return Err(bad(c, "not a group")) };
|
||||
g.reassign_uids(&mut next);
|
||||
let new = g.uid;
|
||||
parent.children.insert(i + 1, Node::Group(g));
|
||||
proj.next_id = next + 1;
|
||||
Ok(json!({"prop": new}))
|
||||
})
|
||||
}
|
||||
|
||||
fn move_to(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.moveGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
let to = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing `index` (1-based)"))? as usize;
|
||||
s.edit("Reorder", None, |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
let i = parent.children.iter().position(|n| n.uid() == uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
if to < 1 || to > parent.children.len() {
|
||||
return Err(bad(c, format!("index {to} is out of range 1..{}", parent.children.len())));
|
||||
}
|
||||
let n = parent.children.remove(i);
|
||||
parent.children.insert(to - 1, n);
|
||||
Ok(json!(to))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("prop.renameGroup", "Rename Property Group", [], None, "{layer?, prop: uid, name}", has_layers, rename),
|
||||
cmd!("prop.setGroupEnabled", "Enable Property Group", [], None, "{layer?, prop: uid, value?}", has_layers, set_enabled),
|
||||
cmd!("prop.removeGroup", "Delete Property Group", [], None, "{layer?, prop: uid}", has_layers, remove),
|
||||
cmd!("prop.duplicateGroup", "Duplicate Property Group", [], None, "{layer?, prop: uid} → {prop: new uid}", has_layers, duplicate),
|
||||
cmd!("prop.moveGroup", "Reorder Property Group", [], None, "{layer?, prop: uid, index (1-based among its siblings)}", has_layers, move_to),
|
||||
]
|
||||
}
|
||||
@@ -114,7 +114,8 @@ fn add_animator(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Animator {}", anims.children.len() + 1));
|
||||
let mut g = build::text_animator(&mut ids, &name, vec![]);
|
||||
let added: usize = kinds.iter().map(|k| add_props(&mut ids, &mut g, k, three)).sum();
|
||||
if added == 0 {
|
||||
// `properties: []` adds an empty animator (scripting's addProperty("ADBE Text Animator")).
|
||||
if added == 0 && !kinds.is_empty() {
|
||||
return Err(bad(C, "unknown animator property"));
|
||||
}
|
||||
let uid = g.uid;
|
||||
|
||||
@@ -285,8 +285,25 @@ pub struct Session {
|
||||
pub autosave: autosave::AutoSaveState,
|
||||
/// The viewer snapshot (Take Snapshot / Show Snapshot).
|
||||
pub snapshot: Option<viewer::Snapshot>,
|
||||
/// The JavaScript scripting engine (set by the host that links `effectcraft-script`):
|
||||
/// `script.run`, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`) and the Script Console.
|
||||
pub script: Option<ScriptRunner>,
|
||||
}
|
||||
|
||||
/// A script to run (see [`Session::script`]).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ScriptRequest<'a> {
|
||||
pub code: &'a str,
|
||||
/// File name shown in errors and `$.fileName` (`console` for the Script Console).
|
||||
pub name: &'a str,
|
||||
/// Run in the Script Console's persistent context (variables survive between runs).
|
||||
pub console: bool,
|
||||
}
|
||||
|
||||
/// Runs a script against the session and reports `{ok, result, output, error: {message, line,
|
||||
/// column, file} | null}`.
|
||||
pub type ScriptRunner = fn(&mut Session, &ScriptRequest) -> Value;
|
||||
|
||||
impl Default for Session {
|
||||
fn default() -> Self {
|
||||
Session {
|
||||
@@ -319,6 +336,7 @@ impl Default for Session {
|
||||
shortcut_table: std::sync::OnceLock::new(),
|
||||
autosave: autosave::AutoSaveState::default(),
|
||||
snapshot: None,
|
||||
script: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -942,6 +942,7 @@ Window
|
||||
Preview | window.panel {"panel":"preview"} | Cmd+3
|
||||
Progress | window.unavailablePanel {"panel":"progress"}
|
||||
Properties | window.panel {"panel":"properties"}
|
||||
Script Console | window.panel {"panel":"scriptConsole"}
|
||||
Smoother | window.panel {"panel":"smoother"}
|
||||
Tools | window.panel {"panel":"tools"} | Cmd+1
|
||||
Tracker | window.panel {"panel":"tracker"}
|
||||
|
||||
@@ -39,3 +39,60 @@ fn move_into_folder_rename_label_comment_with_undo() {
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.item(comp).unwrap().comment, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_and_duplicate_items() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newNull", json!({})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
|
||||
s.execute("renderQueue.add", json!({"comp": inner.0})).unwrap();
|
||||
// Duplicate: a new comp with fresh layer ids.
|
||||
let r = s.execute("project.duplicate", json!({"items": [inner.0]})).unwrap();
|
||||
let dup = ItemId(r["items"][0].as_u64().unwrap());
|
||||
assert_eq!(s.project.item(dup).unwrap().name, "Inner 2");
|
||||
let a = s.project.comp(inner).unwrap().layers[0].id;
|
||||
let b = s.project.comp(dup).unwrap().layers[0].id;
|
||||
assert_ne!(a, b);
|
||||
// Delete: layers that use the item and its render items go too; undoable.
|
||||
s.execute("project.delete", json!({"items": ["Inner"]})).unwrap();
|
||||
assert!(s.project.item(inner).is_none());
|
||||
assert!(s.project.comp(outer).unwrap().layers.is_empty());
|
||||
assert!(s.project.render_queue.is_empty());
|
||||
s.undo();
|
||||
assert!(s.project.item(inner).is_some());
|
||||
assert_eq!(s.project.comp(outer).unwrap().layers.len(), 1);
|
||||
// Folders take their contents.
|
||||
let f = ItemId(s.execute("project.newFolder", json!({"name": "F"})).unwrap()["item"].as_u64().unwrap());
|
||||
s.execute("project.move", json!({"items": [dup.0], "folder": f.0})).unwrap();
|
||||
s.execute("project.delete", json!({"items": [f.0]})).unwrap();
|
||||
assert!(s.project.item(dup).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_group_edits() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
let a = s.execute("effect.apply", json!({"layer": l, "effect": "Gaussian Blur"})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
let b = s.execute("effect.apply", json!({"layer": l, "effect": "Invert"})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
let fx = |s: &Session| -> Vec<(u64, String, bool)> {
|
||||
s.active_comp().unwrap().layers[0].props.sub("effects").unwrap().groups().map(|g| (g.uid, g.name.clone(), g.enabled)).collect()
|
||||
};
|
||||
s.execute("prop.renameGroup", json!({"layer": l, "prop": a, "name": "Soft"})).unwrap();
|
||||
s.execute("prop.setGroupEnabled", json!({"layer": l, "prop": a, "value": false})).unwrap();
|
||||
s.execute("prop.moveGroup", json!({"layer": l, "prop": b, "index": 1})).unwrap();
|
||||
let d = s.execute("prop.duplicateGroup", json!({"layer": l, "prop": a})).unwrap()["prop"].as_u64().unwrap();
|
||||
let v = fx(&s);
|
||||
assert_eq!(v.iter().map(|x| x.0).collect::<Vec<_>>(), [b, a, d]);
|
||||
assert_eq!(v[1].1, "Soft");
|
||||
assert!(!v[1].2);
|
||||
s.execute("prop.removeGroup", json!({"layer": l, "prop": a})).unwrap();
|
||||
assert_eq!(fx(&s).len(), 2);
|
||||
// Fixed groups can't be removed.
|
||||
let tr = s.active_comp().unwrap().layers[0].props.sub("transform").unwrap().uid;
|
||||
assert!(s.execute("prop.removeGroup", json!({"layer": l, "prop": tr})).is_err());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user