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:
Brandon Thomas 2026-10-02 22:13:09 -04:00
parent e911cc5c93
commit 78cd313d3c
36 changed files with 4621 additions and 28 deletions

17
Cargo.lock generated
View File

@ -1145,6 +1145,7 @@ name = "effectcraft-automation"
version = "0.1.0"
dependencies = [
"effectcraft-engine",
"effectcraft-script",
"image",
"serde_json",
"thiserror 2.0.21",
@ -1287,6 +1288,7 @@ dependencies = [
"effectcraft-expr",
"effectcraft-media",
"effectcraft-project",
"effectcraft-script",
"serde_json",
]
@ -1392,6 +1394,21 @@ dependencies = [
"web-time",
]
[[package]]
name = "effectcraft-script"
version = "0.1.0"
dependencies = [
"boa_engine",
"effectcraft-engine",
"effectcraft-expr",
"effectcraft-keyframe",
"effectcraft-path",
"effectcraft-project",
"effectcraft-render",
"effectcraft-time",
"serde_json",
]
[[package]]
name = "effectcraft-text"
version = "0.1.0"

View File

@ -32,6 +32,7 @@ effectcraft-engine = { path = "crates/engine" }
effectcraft-host = { path = "crates/host" }
effectcraft-ui-egui = { path = "crates/ui-egui" }
effectcraft-automation = { path = "crates/automation" }
effectcraft-script = { path = "crates/script" }
# foundation
thiserror = "2"

View File

@ -15,6 +15,7 @@
//! effectcraft-cli render F.ecproj --queue render the project's Render Queue
//! effectcraft-cli bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu] render timings
//! (--gpu: CPU vs GPU ms/frame for every comp at Full and Half)
//! effectcraft-cli script FILE.jsx [F.ecproj] | --eval CODE run an After Effects-style script
//! effectcraft-cli mcp [--bridge PORT] MCP server on stdio
//!
//! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty)
@ -32,7 +33,7 @@ use effectcraft_automation::tools::{self, Reply};
use effectcraft_automation::{Backend, McpServer};
use serde_json::{Value, json};
const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|set|render-frame|render|mcp> [args] [--json]
const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|set|render-frame|render|script|mcp> [args] [--json]
info project + engine summary
commands [--filter TEXT] [--enabled] list engine commands
exec <command-id> [--params JSON] run one engine command
@ -47,6 +48,9 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu] per-layer/effect render timings;
--play N renders N consecutive frames with/without the layer cache;
--gpu compares CPU and GPU ms/frame for every comp at Full and Half
script FILE.jsx [F.ecproj] | --eval CODE run JavaScript with the After Effects-style object model
(app.project, comps, layers, properties…); prints writeLn
output and the result; errors exit 1 with file:line:col
mcp [--bridge PORT] MCP server (JSON-RPC over stdio)
options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge PORT --json --gpu
<comp>: id or name, '-' = active comp; <layer>: id, '#n' or name; <value>: JSON or bare string";
@ -68,6 +72,7 @@ const VALUED: &[&str] = &[
"--out",
"--expression",
"--depth",
"--eval",
// render
"--format",
"--start",
@ -243,6 +248,49 @@ fn tool(b: &mut Backend, name: &str, a: Value) -> Result<Value, Failure> {
}
}
/// `script FILE.jsx [project]` / `script --eval CODE`: run JavaScript against a headless session
/// (an empty project unless one is given, or `--demo`) or the running app (`--bridge`).
fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
let (code, name) = match (args.opt("--eval"), args.pos.first()) {
(Some(c), _) => (c.to_string(), "eval".to_string()),
(None, Some(f)) => (std::fs::read_to_string(f).map_err(|e| Failure::Error(format!("cannot read {f}: {e}")))?, f.clone()),
(None, None) => return usage_err("script needs a .jsx/.js file or --eval CODE"),
};
let mut b = backend(args, false)?;
let r = b.exec("script.run", json!({"code": code, "name": name}))?;
let saved = maybe_save(&mut b, args)?;
if json_out {
let mut o = r.clone();
if let Some(p) = &saved {
o["saved"] = json!(p);
}
emit(&o, true);
} else {
if let Some(out) = r["output"].as_str().filter(|o| !o.is_empty()) {
println!("{out}");
}
if !r["result"].is_null() && r["error"].is_null() {
println!("{}", r["result"]);
}
if let Some(p) = saved {
eprintln!("saved {p}");
}
}
if let Some(e) = r.get("error").filter(|e| !e.is_null()) {
let pos = match (e["line"].as_u64(), e["column"].as_u64()) {
(Some(l), Some(c)) => format!(":{l}:{c}"),
(Some(l), None) => format!(":{l}"),
_ => String::new(),
};
let msg = format!("{}{pos}: {}", e["file"].as_str().unwrap_or(&name), e["message"].as_str().unwrap_or("script error"));
if json_out {
std::process::exit(1);
}
return Err(Failure::Error(msg));
}
Ok(())
}
/// Print a result: compact JSON with `--json`, else pretty JSON.
fn emit(v: &Value, json_out: bool) {
if json_out {
@ -315,6 +363,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
}
}
}
"script" => script_cmd(args, json_out)?,
"exec" => {
let Some(id) = args.pos.first().cloned() else { return usage_err("exec needs a command id") };
let params = match (args.opt("--params"), args.pos.get(1).map(String::as_str)) {

View File

@ -131,3 +131,34 @@ fn mcp_over_stdio() {
assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]);
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
}
#[test]
fn script_file_and_eval() {
let proj = tmp("scripted.ecproj");
let p = proj.to_str().unwrap();
let jsx = tmp("build.jsx");
std::fs::write(
&jsx,
"app.beginUndoGroup('Build');\nvar c = app.project.items.addComp('FromJsx', 160, 90, 1, 2, 24);\nc.layers.addSolid([1, 0, 0], 'Red', 160, 90, 1);\napp.endUndoGroup();\nwriteLn('built ' + c.name);\nc.numLayers",
)
.unwrap();
let v = ok_json(&["script", jsx.to_str().unwrap(), "--save-as", p]);
assert_eq!(v["ok"], json!(true), "{v}");
assert_eq!(v["result"], json!(1));
assert_eq!(v["output"], json!("built FromJsx"));
assert_eq!(v["saved"], p);
// Run against the saved project (positional), plain output.
let out = bin().args(["script", "--eval", "app.project.item(1).name + ' has ' + app.project.item(1).numLayers", p]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "\"FromJsx has 1\"");
// Errors: exit 1 with file:line:col.
let bad = tmp("bad.jsx");
std::fs::write(&bad, "var a = 1;\n\nmissingFunction();\n").unwrap();
let out = bin().args(["script", bad.to_str().unwrap()]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("bad.jsx:3:1") && err.contains("missingFunction"), "{err}");
let (code, v) = run_json(&["script", "--eval", "\nnope()"]);
assert_eq!(code, 1);
assert_eq!(v["error"]["line"], json!(2));
}

View File

@ -105,8 +105,8 @@ fn query_flag(name: &str) -> bool {
web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()).is_some_and(|p| p.has(name))
}
/// A session wired for the browser: media decoding from memory, expressions, Render Queue export
/// to downloads.
/// A session wired for the browser: media decoding from memory, expressions, scripting, Render
/// Queue export to downloads.
pub fn session() -> Session {
let pool = Arc::new(effectcraft_media::MediaPool::new());
Session {
@ -115,6 +115,7 @@ pub fn session() -> Session {
importer: Some(Arc::new(files::WebImporter { pool })),
expr: Some(Arc::new(effectcraft_expr::Expressions)),
expr_check: Some(effectcraft_expr::check_syntax),
script: Some(effectcraft_host::script::runner),
exporter: Some(Arc::new(effectcraft_host::FileExporter { sink: Some(files::export_sink()) })),
..Default::default()
}

View File

@ -14,3 +14,6 @@ effectcraft-engine = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
image = { workspace = true }
[dev-dependencies]
effectcraft-script = { workspace = true }

View File

@ -54,6 +54,7 @@ fn initialize_and_list_tools() {
for want in [
"list_commands",
"execute_command",
"run_script",
"get_project",
"get_comp",
"get_layer",
@ -263,3 +264,30 @@ fn bridge_rejects_non_loopback() {
assert!(Backend::bridge("10.0.0.1:9877").is_err());
assert!(Backend::bridge("localhost:9877").is_ok());
}
#[test]
fn run_script_round_trip() {
let mut sess = Session::default();
effectcraft_script::install(&mut sess);
let mut s = McpServer::new(Backend::headless(sess));
let r = call_json(
&mut s,
"run_script",
json!({"code": "var c = app.project.items.addComp('Scripted', 320, 180, 1, 2, 24);\nc.layers.addText('Hi');\nwriteLn('made ' + c.name);\nc.numLayers"}),
);
assert_eq!(r["ok"], json!(true), "{r}");
assert_eq!(r["result"], json!(1));
assert_eq!(r["output"], json!("made Scripted"));
// The edit is visible to the other tools.
let p = call_json(&mut s, "get_project", json!({}));
assert!(p.to_string().contains("Scripted"), "{p}");
// Errors come back with their line.
let r = call_json(&mut s, "run_script", json!({"code": "var a = 1;\nundefinedFn();", "name": "bad.jsx"}));
assert_eq!(r["ok"], json!(false));
assert_eq!(r["error"]["line"], json!(2));
assert_eq!(r["error"]["file"], json!("bad.jsx"));
// Without a scripting engine the command explains itself.
let mut bare = server();
let (c, err) = call(&mut bare, "run_script", json!({"code": "1"}));
assert!(err && c[0]["text"].as_str().unwrap().contains("not available"), "{c:?}");
}

View File

@ -140,6 +140,15 @@ fn execute_command(b: &mut Backend, a: &Value) -> Result<Reply> {
json_reply(b.exec(id, params)?)
}
fn run_script(b: &mut Backend, a: &Value) -> Result<Reply> {
let code = get(a, "code").and_then(Value::as_str).ok_or_else(|| Error::BadArgs("missing `code` (JavaScript)".into()))?;
let mut p = json!({"code": code});
if let Some(n) = get(a, "name") {
p["name"] = n.clone();
}
json_reply(b.exec("script.run", p)?)
}
fn get_project(b: &mut Backend, _: &Value) -> Result<Reply> {
json_reply(b.exec("project.summary", json!({}))?)
}
@ -386,6 +395,21 @@ static TOOLS: &[ToolDef] = &[
},
run: execute_command,
},
ToolDef {
name: "run_script",
description: "Run JavaScript with an After Effects-style scripting object model (app, app.project, items.addComp, comp.layers.addSolid/addText/addShape/addNull/addCamera/addLight, layer.property(\"ADBE Transform Group\").property(\"ADBE Position\").setValueAtTime(t, v), Effects.addProperty(\"ADBE Gaussian Blur 2\"), expressions, markers, renderQueue…). Every edit is an undoable engine command; app.beginUndoGroup/endUndoGroup make one undo step. Returns {ok, result (last expression value), output (writeLn/$.writeln/alert text), error: {message, line, column} | null}. File access is limited to the project folder unless the user allows scripts to write files.",
bridge_only: false,
schema: || {
schema(
json!({
"code": {"type": "string", "description": "JavaScript source, e.g. `var c = app.project.items.addComp(\"Main\", 1920, 1080, 1, 5, 30); c.layers.addText(\"Hi\"); c.numLayers`."},
"name": {"type": "string", "description": "Script name used in error messages (default `script`)."}
}),
&["code"],
)
},
run: run_script,
},
ToolDef {
name: "get_project",
description: "Project overview: file path, dirty flag, bit depth, every item (id, name, type Composition/Footage/Solid/Folder, size, duration, layer count), the active comp id, current time (s), selected layers and the undo stack.",

View File

@ -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...",

View File

@ -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());

View File

@ -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),
]
}

View File

@ -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),
]
}

View File

@ -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;

View File

@ -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,
}
}
}

View File

@ -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"}

View File

@ -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());
}

View File

@ -1,6 +1,6 @@
[package]
name = "effectcraft-host"
description = "Builds a fully wired EffectCraft session: media decoding (FilmCraft codecs), importer and the expression engine"
description = "Builds a fully wired EffectCraft session: media decoding (FilmCraft codecs), importer, the expression engine and scripting"
version.workspace = true
edition.workspace = true
license.workspace = true
@ -14,6 +14,7 @@ effectcraft-engine = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-media = { workspace = true }
effectcraft-expr = { workspace = true }
effectcraft-script = { workspace = true }
effectcraft-export = { workspace = true }
[dev-dependencies]

View File

@ -1,12 +1,14 @@
//! A fully wired [`Session`]: footage decoding through `effectcraft-media` (FilmCraft's codecs),
//! the media importer, the expression engine and Render Queue export through
//! `effectcraft-export` (FilmCraft's encoders). Frontends (desktop, CLI, MCP, web) start here.
//! the media importer, the expression engine, JavaScript scripting (`effectcraft-script`) and
//! Render Queue export through `effectcraft-export` (FilmCraft's encoders). Frontends (desktop,
//! CLI, MCP, web) start here.
use std::sync::Arc;
use effectcraft_engine::project::render_queue::OutputFormat;
use effectcraft_engine::{ExportJob, ExportResult, Exporter, Importer, Session};
use effectcraft_project::Footage;
pub use effectcraft_script as script;
struct MediaImporter;
@ -47,7 +49,7 @@ impl Exporter for FileExporter {
}
}
/// A new session with media, import, expressions and export enabled.
/// A new session with media, import, expressions, scripting and export enabled.
pub fn session() -> Session {
Session {
exporter: Some(Arc::new(FileExporter::default())),
@ -55,6 +57,7 @@ pub fn session() -> Session {
importer: Some(Arc::new(MediaImporter)),
expr: Some(Arc::new(effectcraft_expr::Expressions)),
expr_check: Some(effectcraft_expr::check_syntax),
script: Some(effectcraft_script::runner),
..Default::default()
}
}
@ -188,6 +191,35 @@ mod tests {
assert_eq!(value(&s, a, "transform/rotation").as_f64(), 12.0);
}
/// A script builds a comp, queues it and renders it through the Render Queue.
#[test]
fn script_renders_through_the_render_queue() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/host-script");
let _ = std::fs::remove_dir_all(&dir);
let out = dir.join("scripted.gif");
let mut s = super::session();
let code = format!(
r#"
var comp = app.project.items.addComp("Scripted", 64, 48, 1, 0.2, 10);
var sq = comp.layers.addSolid([1, 0.5, 0], "Square", 16, 16, 1);
sq.transform.position.setValueAtTime(0, [8, 24]);
sq.transform.position.setValueAtTime(0.1, [56, 24]);
var item = app.project.renderQueue.items.add(comp);
item.outputModule(1).applyTemplate("GIF");
item.outputModule(1).file = new File({});
app.project.renderQueue.render();
[item.status == RQItemStatus.DONE, item.outputModule(1).file.fsName, app.project.renderQueue.numItems]
"#,
serde_json::to_string(&out.to_string_lossy()).unwrap()
);
let r = s.execute("script.run", json!({"code": code})).unwrap();
assert_eq!(r["ok"], json!(true), "{r}");
assert_eq!(r["result"][0], json!(true), "{r}");
assert_eq!(r["result"][2], json!(1));
let bytes = std::fs::read(&out).unwrap();
assert!(bytes.starts_with(b"GIF89a"));
}
#[test]
fn expression_syntax_errors_disable_the_expression() {
let mut s = super::session();

27
crates/script/Cargo.toml Normal file
View File

@ -0,0 +1,27 @@
[package]
name = "effectcraft-script"
description = "EffectCraft scripting: JavaScript (boa) with an After Effects-style object model (app, project, CompItem, Layer, Property…) over the engine's undoable commands"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
effectcraft-engine = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-keyframe = { workspace = true }
effectcraft-time = { workspace = true }
effectcraft-render = { workspace = true }
effectcraft-path = { workspace = true }
serde_json = { workspace = true }
boa_engine = { workspace = true }
# On the web, boa's clock and Math.random need the browser (getrandom `wasm_js`, web-time).
[target.'cfg(target_arch = "wasm32")'.dependencies]
boa_engine = { workspace = true, features = ["js"] }
[dev-dependencies]
effectcraft-expr = { workspace = true }

64
crates/script/src/lib.rs Normal file
View File

@ -0,0 +1,64 @@
//! # effectcraft-script (L4)
//!
//! Scripting with an After Effects-style object model, on the boa JavaScript engine (the one the
//! expressions use). Scripts written for After Effects' documented scripting API mostly run
//! unchanged:
//!
//! ```text
//! app.beginUndoGroup("Build");
//! var comp = app.project.items.addComp("Main", 1920, 1080, 1, 10, 30);
//! var solid = comp.layers.addSolid([1, 0, 0], "Red", 1920, 1080, 1);
//! solid.property("ADBE Transform Group").property("ADBE Position").setValueAtTime(0, [0, 540]);
//! solid.Effects.addProperty("ADBE Gaussian Blur 2").property("Blurriness").setValue(20);
//! app.endUndoGroup();
//! ```
//!
//! * **Object model** (`prelude.js`): `app` (project, open/newProject, undo groups,
//! `executeCommand`/`findMenuCommandId`, `scheduleTask`), `Project`, `ItemCollection`,
//! `CompItem`/`FootageItem`/`FolderItem`, `LayerCollection`, `AVLayer`/`TextLayer`/
//! `ShapeLayer`/`CameraLayer`/`LightLayer`, `PropertyGroup`/`Property` (values, keyframes,
//! eases, expressions), `MaskPropertyGroup`, `TextDocument`, `Shape`, `KeyframeEase`,
//! `MarkerValue`, `RenderQueue`/`RenderQueueItem`/`OutputModule`, `ImportOptions`, `File`/
//! `Folder`, `$`, `alert`/`writeLn`, and the enums (`BlendingMode`, `KeyframeInterpolationType`,
//! `TrackMatteType`, `LightType`, `ParagraphJustification`, `PropertyValueType`…).
//! * **Edits are engine commands**: every mutating call runs a command through
//! [`Session::execute`](effectcraft_engine::Session::execute), so it is undoable, journaled
//! and identical to the UI's action; `app.beginUndoGroup`/`endUndoGroup` fold everything in
//! between into one undo step.
//! * **Match names** ([`matchnames`]): After Effects' documented match names for our properties
//! and effects (`ADBE Transform Group`, `ADBE Position`, `ADBE Gaussian Blur 2`…).
//! * **Security**: scripts can import/open/save projects and render, but `File` reads are limited
//! to the project's folder and writes (and the network) are off unless Preferences ▸ Scripting
//! & Expressions ▸ Allow Scripts to Write Files and Access Network is on.
//!
//! Entry points: [`install`] sets [`Session::script`](effectcraft_engine::Session::script), which
//! the `script.run` command, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`), the Script Console
//! panel, `effectcraft-cli script` and the MCP `run_script` tool use.
#![recursion_limit = "256"]
pub mod matchnames;
mod model;
mod runtime;
use effectcraft_engine::{ScriptRequest, Session};
pub use runtime::{Outcome, ScriptError, run};
/// The [`effectcraft_engine::ScriptRunner`] this crate provides.
pub fn runner(s: &mut Session, req: &ScriptRequest) -> serde_json::Value {
run(s, req).to_json()
}
/// Enable scripting on a session.
pub fn install(s: &mut Session) {
s.script = Some(runner);
}
/// Run `code` as a one-off script named `name`.
pub fn run_code(s: &mut Session, code: &str, name: &str) -> Outcome {
run(s, &ScriptRequest { code, name, console: false })
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,443 @@
//! After Effects-compatible match names for our property tree and effects, so common scripts
//! (`layer.property("ADBE Transform Group").property("ADBE Position")`,
//! `layer.Effects.addProperty("ADBE Gaussian Blur 2")`) work unchanged.
//!
//! The names are the stable identifiers After Effects documents for scripting (Scripting Guide
//! ▸ match names). Properties without a documented equivalent keep our own match id
//! (`perChar3d`, `ec.blur.bilateral`), which `property()` and `addProperty()` accept too.
use effectcraft_project::{LayerSource, Node};
/// Match name of a layer (its root property group).
pub fn layer(source: &LayerSource) -> &'static str {
match source {
LayerSource::Text => "ADBE Text Layer",
LayerSource::Shape => "ADBE Vector Layer",
LayerSource::Camera => "ADBE Camera Layer",
LayerSource::Light { .. } => "ADBE Light Layer",
_ => "ADBE AV Layer",
}
}
/// Top-level groups of a layer.
const LAYER_GROUPS: &[(&str, &str)] = &[
("transform", "ADBE Transform Group"),
("masks", "ADBE Mask Parade"),
("effects", "ADBE Effect Parade"),
("text", "ADBE Text Properties"),
("contents", "ADBE Root Vectors Group"),
("materialOptions", "ADBE Material Options Group"),
("cameraOptions", "ADBE Camera Options Group"),
("lightOptions", "ADBE Light Options Group"),
("audio", "ADBE Audio Group"),
("layerStyles", "ADBE Layer Styles"),
("motionTrackers", "ADBE MTrackers"),
("timeRemap", "ADBE Time Remapping"),
];
const TRANSFORM: &[(&str, &str)] = &[
("anchor", "ADBE Anchor Point"),
// Cameras and lights store the Point of Interest where AVLayers keep the Anchor Point.
("poi", "ADBE Anchor Point"),
("position", "ADBE Position"),
("positionX", "ADBE Position_0"),
("positionY", "ADBE Position_1"),
("positionZ", "ADBE Position_2"),
("scale", "ADBE Scale"),
("orientation", "ADBE Orientation"),
("rotationX", "ADBE Rotate X"),
("rotationY", "ADBE Rotate Y"),
("rotation", "ADBE Rotate Z"),
("opacity", "ADBE Opacity"),
];
const MASK: &[(&str, &str)] =
&[("path", "ADBE Mask Shape"), ("feather", "ADBE Mask Feather"), ("opacity", "ADBE Mask Opacity"), ("expansion", "ADBE Mask Offset")];
const TEXT: &[(&str, &str)] = &[
("sourceText", "ADBE Text Document"),
("pathOptions", "ADBE Text Path Options"),
("moreOptions", "ADBE Text More Options"),
("animators", "ADBE Text Animators"),
];
const TEXT_ANIMATOR: &[(&str, &str)] = &[("selectors", "ADBE Text Selectors"), ("properties", "ADBE Text Animator Properties")];
const CAMERA: &[(&str, &str)] = &[
("zoom", "ADBE Camera Zoom"),
("dof", "ADBE Camera Depth of Field"),
("focusDistance", "ADBE Camera Focus Distance"),
("aperture", "ADBE Camera Aperture"),
("blurLevel", "ADBE Camera Blur Level"),
];
const LIGHT: &[(&str, &str)] = &[
("intensity", "ADBE Light Intensity"),
("color", "ADBE Light Color"),
("coneAngle", "ADBE Light Cone Angle"),
("coneFeather", "ADBE Light Cone Feather 2"),
("falloff", "ADBE Light Falloff Type"),
("radius", "ADBE Light Falloff Start"),
("falloffDistance", "ADBE Light Falloff Distance"),
("castsShadows", "ADBE Light Casts Shadows"),
("shadowDarkness", "ADBE Light Shadow Darkness"),
("shadowDiffusion", "ADBE Light Shadow Diffusion"),
];
const MATERIAL: &[(&str, &str)] = &[
("castsShadows", "ADBE Casts Shadows"),
("lightTransmission", "ADBE Light Transmission"),
("acceptsShadows", "ADBE Accepts Shadows"),
("acceptsLights", "ADBE Accepts Lights"),
("ambient", "ADBE Ambient Coefficient"),
("diffuse", "ADBE Diffuse Coefficient"),
("specularIntensity", "ADBE Specular Coefficient"),
("specularShininess", "ADBE Shininess Coefficient"),
("metal", "ADBE Metal Coefficient"),
];
/// Shape items (Contents ▸ Add): our match id → AE match name.
pub const SHAPE_ITEMS: &[(&str, &str)] = &[
("group", "ADBE Vector Group"),
("rect", "ADBE Vector Shape - Rect"),
("ellipse", "ADBE Vector Shape - Ellipse"),
("star", "ADBE Vector Shape - Star"),
("path", "ADBE Vector Shape - Group"),
("fill", "ADBE Vector Graphic - Fill"),
("stroke", "ADBE Vector Graphic - Stroke"),
("gfill", "ADBE Vector Graphic - G-Fill"),
("gstroke", "ADBE Vector Graphic - G-Stroke"),
("merge", "ADBE Vector Filter - Merge"),
("offset", "ADBE Vector Filter - Offset"),
("pucker", "ADBE Vector Filter - PB"),
("repeater", "ADBE Vector Filter - Repeater"),
("round", "ADBE Vector Filter - RC"),
("trim", "ADBE Vector Filter - Trim"),
("twist", "ADBE Vector Filter - Twist"),
("wiggle", "ADBE Vector Filter - Roughen"),
("zigzag", "ADBE Vector Filter - Zigzag"),
];
/// Properties inside shape items: (item match id, property match id, AE match name).
const SHAPE_PROPS: &[(&str, &str, &str)] = &[
("group", "blend", "ADBE Vector Blend Mode"),
("group", "contents", "ADBE Vectors Group"),
("group", "transform", "ADBE Vector Transform Group"),
("transform", "anchor", "ADBE Vector Anchor"),
("transform", "position", "ADBE Vector Position"),
("transform", "scale", "ADBE Vector Scale"),
("transform", "skew", "ADBE Vector Skew"),
("transform", "skewAxis", "ADBE Vector Skew Axis"),
("transform", "rotation", "ADBE Vector Rotation"),
("transform", "opacity", "ADBE Vector Group Opacity"),
("rect", "direction", "ADBE Vector Shape Direction"),
("rect", "size", "ADBE Vector Rect Size"),
("rect", "position", "ADBE Vector Rect Position"),
("rect", "roundness", "ADBE Vector Rect Roundness"),
("ellipse", "direction", "ADBE Vector Shape Direction"),
("ellipse", "size", "ADBE Vector Ellipse Size"),
("ellipse", "position", "ADBE Vector Ellipse Position"),
("star", "direction", "ADBE Vector Shape Direction"),
("star", "type", "ADBE Vector Star Type"),
("star", "points", "ADBE Vector Star Points"),
("star", "position", "ADBE Vector Star Position"),
("star", "rotation", "ADBE Vector Star Rotation"),
("star", "innerRadius", "ADBE Vector Star Inner Radius"),
("star", "outerRadius", "ADBE Vector Star Outer Radius"),
("star", "innerRoundness", "ADBE Vector Star Inner Roundess"),
("star", "outerRoundness", "ADBE Vector Star Outer Roundess"),
("path", "direction", "ADBE Vector Shape Direction"),
("path", "path", "ADBE Vector Shape"),
("fill", "blend", "ADBE Vector Blend Mode"),
("fill", "composite", "ADBE Vector Composite Order"),
("fill", "rule", "ADBE Vector Fill Rule"),
("fill", "color", "ADBE Vector Fill Color"),
("fill", "opacity", "ADBE Vector Fill Opacity"),
("stroke", "blend", "ADBE Vector Blend Mode"),
("stroke", "composite", "ADBE Vector Composite Order"),
("stroke", "color", "ADBE Vector Stroke Color"),
("stroke", "opacity", "ADBE Vector Stroke Opacity"),
("stroke", "width", "ADBE Vector Stroke Width"),
("stroke", "cap", "ADBE Vector Stroke Line Cap"),
("stroke", "join", "ADBE Vector Stroke Line Join"),
("stroke", "miter", "ADBE Vector Stroke Miter Limit"),
("stroke", "dashes", "ADBE Vector Stroke Dashes"),
("trim", "start", "ADBE Vector Trim Start"),
("trim", "end", "ADBE Vector Trim End"),
("trim", "offset", "ADBE Vector Trim Offset"),
("trim", "mode", "ADBE Vector Trim Type"),
("repeater", "copies", "ADBE Vector Repeater Copies"),
("repeater", "offset", "ADBE Vector Repeater Offset"),
("repeater", "composite", "ADBE Vector Repeater Order"),
("repeater", "transform", "ADBE Vector Repeater Transform"),
("round", "radius", "ADBE Vector RoundCorner Radius"),
("pucker", "amount", "ADBE Vector PuckerBloat Amount"),
("twist", "angle", "ADBE Vector Twist Angle"),
("twist", "center", "ADBE Vector Twist Center"),
("merge", "mode", "ADBE Vector Merge Type"),
];
/// Effects: our effect id → AE match name (the documented ones; the rest keep our id).
pub const EFFECTS: &[(&str, &str)] = &[
("ec.blur.gaussian", "ADBE Gaussian Blur 2"),
("ec.blur.fastbox", "ADBE Box Blur2"),
("ec.blur.directional", "ADBE Motion Blur"),
("ec.blur.radial", "ADBE Radial Blur"),
("ec.blur.cameralens", "ADBE Camera Lens Blur"),
("ec.blur.channel", "ADBE Channel Blur"),
("ec.blur.compound", "ADBE Compound Blur"),
("ec.blur.sharpen", "ADBE Sharpen"),
("ec.blur.smart", "ADBE Smart Blur"),
("ec.blur.unsharp", "ADBE Unsharp Mask2"),
("ec.channel.invert", "ADBE Invert"),
("ec.channel.setmatte", "ADBE Set Matte3"),
("ec.channel.setchannels", "ADBE Set Channels"),
("ec.channel.shiftchannels", "ADBE Shift Channels"),
("ec.channel.minimax", "ADBE Minimax"),
("ec.channel.blend", "ADBE Blend"),
("ec.channel.arithmetic", "ADBE Arithmetic"),
("ec.channel.calculations", "ADBE Calculations"),
("ec.channel.solidcomposite", "ADBE Solid Composite"),
("ec.color.brightnesscontrast", "ADBE Brightness & Contrast 2"),
("ec.color.curves", "ADBE CurvesCustom"),
("ec.color.levels", "ADBE Easy Levels2"),
("ec.color.levelsic", "ADBE Pro Levels2"),
("ec.color.huesaturation", "ADBE HUE SATURATION"),
("ec.color.exposure", "ADBE Exposure2"),
("ec.color.tint", "ADBE Tint"),
("ec.color.tritone", "ADBE Tritone"),
("ec.color.blackwhite", "ADBE Black&White"),
("ec.color.channelmixer", "ADBE CHANNEL MIXER"),
("ec.color.colorbalance", "ADBE Color Balance 2"),
("ec.color.colorbalancehls", "ADBE Color Balance (HLS)"),
("ec.color.changecolor", "ADBE Change Color"),
("ec.color.changetocolor", "ADBE Change To Color"),
("ec.color.colorama", "APC Colorama"),
("ec.color.leavecolor", "ADBE Leave Color"),
("ec.color.photofilter", "ADBE Photo Filter"),
("ec.color.vibrance", "ADBE Vibrance"),
("ec.color.lumetri", "ADBE Lumetri"),
("ec.color.shadowhighlight", "ADBE Shadow/Highlight"),
("ec.color.selectivecolor", "ADBE Selective Color"),
("ec.color.gammapedestalgain", "ADBE Gamma/Pedestal/Gain"),
("ec.color.equalize", "ADBE Equalize"),
("ec.color.autocontrast", "ADBE AutoContrast"),
("ec.color.autolevels", "ADBE AutoLevels"),
("ec.color.autocolor", "ADBE AutoColor"),
("ec.control.slider", "ADBE Slider Control"),
("ec.control.angle", "ADBE Angle Control"),
("ec.control.checkbox", "ADBE Checkbox Control"),
("ec.control.color", "ADBE Color Control"),
("ec.control.point", "ADBE Point Control"),
("ec.control.point3d", "ADBE Point3D Control"),
("ec.control.layer", "ADBE Layer Control"),
("ec.control.dropdown", "ADBE Dropdown Control"),
("ec.distort.transform", "ADBE Geometry2"),
("ec.distort.cornerpin", "ADBE Corner Pin"),
("ec.distort.bulge", "ADBE Bulge"),
("ec.distort.mirror", "ADBE Mirror"),
("ec.distort.offset", "ADBE Offset"),
("ec.distort.ripple", "ADBE Ripple"),
("ec.distort.spherize", "ADBE Spherize"),
("ec.distort.twirl", "ADBE Twirl"),
("ec.distort.wavewarp", "ADBE Wave Warp"),
("ec.distort.turbulentdisplace", "ADBE Turbulent Displace"),
("ec.distort.displacementmap", "ADBE Displacement Map"),
("ec.distort.meshwarp", "ADBE MESH WARP"),
("ec.distort.polar", "ADBE Polar Coordinates"),
("ec.distort.opticscompensation", "ADBE Optics Compensation"),
("ec.distort.magnify", "ADBE Magnify"),
("ec.distort.warpstabilizer", "ADBE SubspaceStabilizer"),
("ec.distort.puppet", "ADBE FreePin3"),
("ec.generate.fill", "ADBE Fill"),
("ec.generate.gradientramp", "ADBE Ramp"),
("ec.generate.fourcolor", "ADBE 4ColorGradient"),
("ec.generate.stroke", "ADBE Stroke"),
("ec.generate.grid", "ADBE Grid"),
("ec.generate.checkerboard", "ADBE Checkerboard"),
("ec.generate.circle", "ADBE Circle"),
("ec.generate.ellipse", "ADBE Ellipse"),
("ec.generate.cellpattern", "ADBE Cell Pattern"),
("ec.generate.lensflare", "ADBE Lens Flare"),
("ec.generate.vegas", "ADBE Vegas"),
("ec.generate.writeon", "ADBE Write On"),
("ec.generate.audiospectrum", "ADBE AudSpect"),
("ec.generate.audiowaveform", "ADBE AudWave"),
("ec.generate.beam", "ADBE Laser"),
("ec.generate.radiowaves", "ADBE Radio Waves"),
("ec.generate.scribble", "ADBE Scribble Fill"),
("ec.generate.paintbucket", "ADBE Paint Bucket"),
("ec.key.colorkey", "ADBE Color Key"),
("ec.key.luma", "ADBE Luma Key"),
("ec.key.linearcolor", "ADBE Linear Color Key2"),
("ec.key.colorrange", "ADBE Color Range"),
("ec.key.colordifference", "ADBE Color Difference Key"),
("ec.key.extract", "ADBE Extract"),
("ec.key.spill", "ADBE Spill2"),
("ec.matte.simplechoker", "ADBE Simple Choker"),
("ec.matte.mattechoker", "ADBE Matte Choker"),
("ec.noise.noise", "ADBE Noise"),
("ec.noise.fractal", "ADBE Fractal Noise"),
("ec.noise.median", "ADBE Median"),
("ec.noise.addgrain", "VISINF Grain Implant"),
("ec.noise.dustscratches", "ADBE Dust & Scratches"),
("ec.noise.turbulent", "ADBE Turbulent Noise"),
("ec.perspective.dropshadow", "ADBE Drop Shadow"),
("ec.perspective.radialshadow", "ADBE Radial Shadow"),
("ec.perspective.bevelalpha", "ADBE Bevel Alpha"),
("ec.perspective.beveledges", "ADBE Bevel Edges"),
("ec.perspective.3dglasses", "ADBE 3D Glasses2"),
("ec.stylize.glow", "ADBE Glo2"),
("ec.stylize.mosaic", "ADBE Mosaic"),
("ec.stylize.posterize", "ADBE Posterize"),
("ec.stylize.threshold", "ADBE Threshold2"),
("ec.stylize.findedges", "ADBE Find Edges"),
("ec.stylize.emboss", "ADBE Emboss"),
("ec.stylize.coloremboss", "ADBE Color Emboss"),
("ec.stylize.roughenedges", "ADBE Roughen Edges"),
("ec.stylize.scatter", "ADBE Scatter"),
("ec.stylize.strobe", "ADBE Strobe"),
("ec.stylize.motiontile", "ADBE Tile"),
("ec.stylize.texturize", "ADBE Texturize"),
("ec.stylize.cartoon", "ADBE Cartoonify"),
("ec.stylize.brushstrokes", "ADBE Brush Strokes"),
("ec.text.numbers", "ADBE Numbers2"),
("ec.text.timecode", "ADBE Timecode"),
("ec.time.echo", "ADBE Echo"),
("ec.time.posterizetime", "ADBE Posterize Time"),
("ec.time.timedisplacement", "ADBE Time Displacement"),
("ec.time.timedifference", "ADBE Time Difference"),
("ec.time.pixelmotionblur", "ADBE Pixel Motion Blur"),
("ec.time.timewarp", "ADBE Timewarp"),
("ec.transition.linearwipe", "ADBE Linear Wipe"),
("ec.transition.radialwipe", "ADBE Radial Wipe"),
("ec.transition.venetian", "ADBE Venetian Blinds"),
("ec.transition.blockdissolve", "ADBE Block Dissolve"),
("ec.transition.gradientwipe", "ADBE Gradient Wipe"),
("ec.transition.iriswipe", "ADBE Iris Wipe"),
("ec.utility.applylut", "ADBE Apply Color LUT2"),
("ec.utility.cineon", "ADBE Cineon Converter2"),
("ec.utility.growbounds", "ADBE Grow Bounds"),
];
fn find<'a>(t: &'a [(&str, &'a str)], k: &str) -> Option<&'a str> {
t.iter().find(|(a, _)| *a == k).map(|(_, b)| *b)
}
/// AE match name of an effect id (falls back to the id).
pub fn effect(id: &str) -> String {
find(EFFECTS, id).map(str::to_string).unwrap_or_else(|| id.to_string())
}
/// Our effect id for an AE effect match name.
pub fn effect_from_match(m: &str) -> Option<&'static str> {
EFFECTS.iter().find(|(_, b)| b.eq_ignore_ascii_case(m)).map(|(a, _)| *a)
}
/// Our shape item kind (`layer.addShapeItem`) for an AE match name, our match id or a display
/// name (`Rectangle`, `Fill`, `Trim Paths`).
pub fn shape_kind(name: &str) -> Option<&'static str> {
if let Some((k, _)) = SHAPE_ITEMS.iter().find(|(k, m)| m.eq_ignore_ascii_case(name) || k.eq_ignore_ascii_case(name)) {
return Some(k);
}
let n: String = name.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase();
Some(match n.as_str() {
"rectangle" | "rectanglepath" => "rect",
"ellipsepath" => "ellipse",
"polystar" | "polystarpath" => "star",
"polygon" => "polygon",
"group" | "vectorgroup" => "group",
"fill" => "fill",
"stroke" => "stroke",
"gradientfill" => "gfill",
"gradientstroke" => "gstroke",
"trimpaths" | "trim" => "trim",
"repeater" => "repeater",
"roundcorners" => "round",
"offsetpaths" => "offset",
"puckerbloat" | "puckerandbloat" => "pucker",
"twist" => "twist",
"zigzag" => "zigzag",
"wigglepaths" => "wiggle",
"mergepaths" => "merge",
_ => return None,
})
}
/// Match name of the node at the end of `chain` (layer root's children → … → node). `effect`
/// params are numbered `<effect match name>-0001…` in parameter order, like After Effects.
pub fn node(chain: &[&Node]) -> String {
let Some(last) = chain.last() else { return String::new() };
let id = last.match_id();
let parent = chain.len().checked_sub(2).map(|i| chain[i]);
let grand = chain.len().checked_sub(3).map(|i| chain[i]);
let top = chain.first().map(|n| n.match_id()).unwrap_or("");
let own = || id.to_string();
if chain.len() == 1 {
return find(LAYER_GROUPS, id).map(str::to_string).unwrap_or_else(own);
}
let pid = parent.map(|p| p.match_id()).unwrap_or("");
match top {
"transform" if chain.len() == 2 => find(TRANSFORM, id).map(str::to_string).unwrap_or_else(own),
"masks" if chain.len() == 2 => "ADBE Mask Atom".into(),
"masks" if chain.len() == 3 => find(MASK, id).map(str::to_string).unwrap_or_else(own),
"effects" if chain.len() == 2 => effect(id),
"effects" if chain.len() == 3 => {
let fx = parent.and_then(Node::as_group);
match fx {
Some(g) => {
let i = g.children.iter().position(|c| c.uid() == last.uid()).unwrap_or(0) + 1;
format!("{}-{i:04}", effect(&g.match_id))
}
None => own(),
}
}
"text" if chain.len() == 2 => find(TEXT, id).map(str::to_string).unwrap_or_else(own),
"text" if pid == "animators" => "ADBE Text Animator".into(),
"text" if grand.map(|g| g.match_id()) == Some("animators") => find(TEXT_ANIMATOR, id).map(str::to_string).unwrap_or_else(own),
"cameraOptions" => find(CAMERA, id).map(str::to_string).unwrap_or_else(own),
"lightOptions" => find(LIGHT, id).map(str::to_string).unwrap_or_else(own),
"materialOptions" => find(MATERIAL, id).map(str::to_string).unwrap_or_else(own),
"audio" if id == "levels" => "ADBE Audio Levels".into(),
"contents" => {
// Items sit in a `contents` list; their properties under the item.
if pid == "contents" {
if id == "star" || id == "polygon" {
return "ADBE Vector Shape - Star".into();
}
return find(SHAPE_ITEMS, id).map(str::to_string).unwrap_or_else(own);
}
if let Some((_, _, m)) = SHAPE_PROPS.iter().find(|(item, prop, _)| *item == pid && *prop == id) {
return m.to_string();
}
own()
}
_ => own(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn effect_names_round_trip() {
for (id, m) in EFFECTS {
assert_eq!(effect(id), *m);
assert_eq!(effect_from_match(m), Some(*id));
}
assert_eq!(effect("ec.blur.ccvector"), "ec.blur.ccvector");
let mut ids: Vec<&str> = EFFECTS.iter().map(|(a, _)| *a).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), EFFECTS.len(), "duplicate effect ids");
}
#[test]
fn shape_kinds() {
assert_eq!(shape_kind("ADBE Vector Shape - Rect"), Some("rect"));
assert_eq!(shape_kind("Trim Paths"), Some("trim"));
assert_eq!(shape_kind("ADBE Vector Graphic - Fill"), Some("fill"));
assert_eq!(shape_kind("nope"), None);
}
}

679
crates/script/src/model.rs Normal file
View File

@ -0,0 +1,679 @@
//! Read-only queries behind the object model (`__query(kind, json)`): project, items, layers,
//! property nodes, values and keyframes as JSON shaped for the prelude. Every edit goes through
//! engine commands instead (`__exec`), so it is undoable and journaled.
use effectcraft_engine::Session;
use effectcraft_engine::color::Label;
use effectcraft_keyframe::{Interp, TextDoc, Value as KV};
use effectcraft_project::{Comp, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, PropGroup};
use effectcraft_render::EvalCtx;
use effectcraft_time::Tick;
use serde_json::{Value as J, json};
use crate::matchnames;
type R = Result<J, String>;
fn u(a: &J, k: &str) -> Option<u64> {
a.get(k).and_then(J::as_u64)
}
fn f(a: &J, k: &str) -> Option<f64> {
a.get(k).and_then(J::as_f64)
}
fn comp_of<'a>(s: &'a Session, a: &J) -> Result<(ItemId, &'a Comp), String> {
let id = ItemId(u(a, "comp").ok_or("missing comp")?);
s.project.comp(id).map(|c| (id, c)).ok_or_else(|| "the composition no longer exists".to_string())
}
fn layer_of<'a>(s: &'a Session, a: &J) -> Result<(ItemId, &'a Comp, &'a Layer), String> {
let (cid, c) = comp_of(s, a)?;
let l = c.layer(LayerId(u(a, "layer").ok_or("missing layer")?)).ok_or("the layer no longer exists (it was deleted)")?;
Ok((cid, c, l))
}
pub fn query(s: &mut Session, kind: &str, a: &J) -> R {
match kind {
"project" => Ok(project(s)),
"item" => item(s, a),
"layer" => layer(s, a),
"node" => node(s, a),
"child" => child(s, a),
"value" => value(s, a),
"keys" => keys(s, a),
"textdoc" => textdoc(s, a),
"markers" => markers(s, a),
"sourceRect" => source_rect(s, a),
"resolveAdd" => resolve_add(s, a),
"menuCommandId" => Ok(menu_command_id(a.get("name").and_then(J::as_str).unwrap_or(""))),
"commandAt" => {
let i = u(a, "index").unwrap_or(0) as usize;
Ok(effectcraft_engine::command_specs().get(i.wrapping_sub(1)).map(|c| json!(c.id)).unwrap_or(J::Null))
}
"exprError" => {
let code = a.get("code").and_then(J::as_str).unwrap_or("");
Ok(match s.expr_check {
Some(check) => json!(check(code).err().unwrap_or_default()),
None => json!(""),
})
}
"labels" => Ok(json!(Label::ALL.iter().map(|l| l.name()).collect::<Vec<_>>())),
"effects" => Ok(json!(
effectcraft_engine::effects::registry()
.iter()
.map(|e| json!({"id": e.id, "name": e.name, "matchName": matchnames::effect(e.id), "category": e.category}))
.collect::<Vec<_>>()
)),
_ => Err(format!("unknown query `{kind}`")),
}
}
fn project(s: &Session) -> J {
let p = &s.project;
let items: Vec<u64> = p.items.keys().map(|k| k.0).collect();
let active = s.active_comp_id().map(|c| c.0).or_else(|| (s.state.project_selection.len() == 1).then(|| s.state.project_selection[0].0));
json!({
"items": items,
"active": active,
"selection": s.state.project_selection.iter().map(|i| i.0).collect::<Vec<_>>(),
"path": s.path,
"dirty": s.is_dirty(),
"bitsPerChannel": p.settings.bit_depth.bits(),
"linearBlending": p.settings.blend_linear,
"linearizeWorkingSpace": p.settings.linearize,
"workingSpace": p.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default(),
"timeDisplayFrames": p.settings.time_display == effectcraft_project::TimeDisplayStyle::Frames,
"renderQueue": p.render_queue.len(),
"revision": s.revision,
})
}
fn label_index(l: Label) -> usize {
Label::ALL.iter().position(|x| *x == l).unwrap_or(0)
}
fn item(s: &Session, a: &J) -> R {
let id = ItemId(u(a, "id").ok_or("missing id")?);
let it = s.project.item(id).ok_or("the item no longer exists (it was deleted)")?;
let mut o = json!({
"id": id.0,
"name": it.name,
"typeName": it.type_name(),
"parent": it.parent.map(|p| p.0).unwrap_or(0),
"comment": it.comment,
"label": label_index(it.label),
"selected": s.state.project_selection.contains(&id),
});
let used_in: Vec<u64> = s.project.comps().filter(|(_, c)| c.layers.iter().any(|l| l.source.item() == Some(id))).map(|(cid, _)| cid.0).collect();
o["usedIn"] = json!(used_in);
match &it.kind {
ItemKind::Folder => {
o["kind"] = json!("folder");
o["children"] = json!(s.project.items.values().filter(|c| c.parent == Some(id)).map(|c| c.id.0).collect::<Vec<_>>());
}
ItemKind::Comp(c) => {
let cid = id;
o["kind"] = json!("comp");
let sel: Vec<u64> = s.state.selected_layers.iter().filter(|l| c.layer(**l).is_some()).map(|l| l.0).collect();
let fps = c.frame_rate.as_f64();
if let Some(m) = o.as_object_mut() {
m.extend(
json!({
"width": c.width, "height": c.height, "pixelAspect": c.pixel_aspect,
"duration": c.duration.seconds(), "frameRate": fps, "frameDuration": c.frame_duration().seconds(),
"layers": c.layers.iter().map(|l| l.id.0).collect::<Vec<_>>(),
"selectedLayers": sel,
"selectedProperties": s.state.selected_props.iter().filter(|(l, _)| c.layer(*l).is_some()).map(|(l, p)| json!([l.0, p])).collect::<Vec<_>>(),
"time": s.time_of(cid).seconds(),
"workAreaStart": c.work_area.0.seconds(), "workAreaDuration": (c.work_area.1 - c.work_area.0).seconds(),
"bgColor": c.background, "displayStartTime": c.display_start.seconds(),
"hideShyLayers": c.hide_shy, "motionBlur": c.enable_motion_blur, "frameBlending": c.enable_frame_blending,
"draft3d": c.draft_3d, "shutterAngle": c.shutter_angle, "shutterPhase": c.shutter_phase,
"motionBlurSamplesPerFrame": c.motion_blur_samples,
"renderer": match c.renderer { effectcraft_project::Renderer::Classic3D => "ADBE Classic 3D", effectcraft_project::Renderer::Advanced3D => "ADBE Advanced 3d" },
"open": s.state.open_comps.contains(&cid), "active": s.active_comp_id() == Some(cid),
"hasVideo": true, "hasAudio": c.layers.iter().any(|l| l.switches.audio && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. })),
"posterTime": c.poster_time.seconds(),
})
.as_object()
.cloned()
.unwrap_or_default(),
)
}
}
ItemKind::Footage(fo) => {
o["kind"] = json!("footage");
o["file"] = json!(fo.path);
o["width"] = json!(fo.width);
o["height"] = json!(fo.height);
o["pixelAspect"] = json!(fo.pixel_aspect);
o["duration"] = json!(if fo.kind == effectcraft_project::FootageKind::Still { 0.0 } else { fo.duration.seconds() });
o["frameRate"] = json!(if fo.kind == effectcraft_project::FootageKind::Still { 0.0 } else { fo.frame_rate.as_f64() });
o["hasVideo"] = json!(fo.has_video);
o["hasAudio"] = json!(fo.has_audio);
o["footageMissing"] = json!(fo.missing);
o["isStill"] = json!(fo.kind == effectcraft_project::FootageKind::Still);
o["loop"] = json!(fo.loop_count);
}
ItemKind::Solid(so) => {
o["kind"] = json!("solid");
o["width"] = json!(so.width);
o["height"] = json!(so.height);
o["pixelAspect"] = json!(so.pixel_aspect);
o["color"] = json!(so.color);
o["duration"] = json!(0.0);
o["frameRate"] = json!(0.0);
o["hasVideo"] = json!(true);
o["hasAudio"] = json!(false);
o["isStill"] = json!(true);
}
}
Ok(o)
}
fn layer(s: &Session, a: &J) -> R {
let (cid, c, l) = layer_of(s, a)?;
let kind = match &l.source {
LayerSource::Text => "text",
LayerSource::Shape => "shape",
LayerSource::Camera => "camera",
LayerSource::Light { .. } => "light",
_ => "av",
};
let (w, h) = effectcraft_render::source_size(&s.project, l);
let t = s.time_of(cid);
Ok(json!({
"id": l.id.0,
"comp": cid.0,
"kind": kind,
"name": l.name,
"index": c.index_of(l.id).unwrap_or(0),
"matchName": matchnames::layer(&l.source),
"root": l.props.uid,
"source": l.source.item().map(|i| i.0),
"nullLayer": matches!(l.source, LayerSource::Null),
"lightType": match &l.source { LayerSource::Light { kind } => kind.label(), _ => "" },
"enabled": l.switches.video,
"audioEnabled": l.switches.audio,
"solo": l.switches.solo,
"locked": l.switches.locked,
"shy": l.switches.shy,
"collapse": l.switches.collapse,
"quality": format!("{:?}", l.switches.quality),
"samplingQuality": format!("{:?}", l.switches.sampling),
"effectsActive": l.switches.effects,
"frameBlending": format!("{:?}", l.switches.frame_blend),
"motionBlur": l.switches.motion_blur,
"adjustmentLayer": l.switches.adjustment,
"threeDLayer": l.switches.three_d,
"guideLayer": l.switches.guide,
"preserveTransparency": l.preserve_transparency,
"inPoint": l.in_point.seconds(),
"outPoint": l.out_point.seconds(),
"startTime": l.start_time.seconds(),
"stretch": l.stretch,
"time": l.layer_time(t).seconds(),
"active": l.switches.video && l.is_active_at(t),
"parent": l.parent.map(|p| p.0),
"blendingMode": l.blend_mode.label(),
"trackMatte": l.track_matte.map(|m| json!({"layer": m.layer.0, "kind": format!("{:?}", m.kind)})),
"isTrackMatte": c.layers.iter().any(|x| x.track_matte.is_some_and(|m| m.layer == l.id)),
"label": label_index(l.label),
"comment": l.comment,
"selected": s.state.selected_layers.contains(&l.id),
"autoOrient": format!("{:?}", l.auto_orient),
"width": w, "height": h,
"hasVideo": l.source.is_av(),
"hasAudio": matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. }) && l.props.sub("audio").is_some(),
"timeRemapEnabled": l.props.prop("timeRemap").is_some(),
}))
}
/// Node at the end of a uid chain below the layer root, and the chain itself.
fn chain(l: &Layer, uid: u64) -> Option<Vec<&Node>> {
l.props.node_chain(uid)
}
/// PropertyValueType of a property.
fn value_type(p: &effectcraft_project::Property) -> &'static str {
if matches!(p.ui, ParamUi::Mask) {
return "MASK_INDEX";
}
match &p.value {
KV::Scalar(_) | KV::Bool(_) | KV::Enum(_) => "OneD",
KV::Vec2(_) => {
if p.spatial {
"TwoD_SPATIAL"
} else {
"TwoD"
}
}
KV::Vec3(_) => {
if p.spatial {
"ThreeD_SPATIAL"
} else {
"ThreeD"
}
}
KV::Color(_) => "COLOR",
KV::Path(_) => "SHAPE",
KV::Text(_) => "TEXT_DOCUMENT",
KV::Layer(_) => "LAYER_INDEX",
KV::Gradient(_) | KV::Str(_) => "CUSTOM_VALUE",
}
}
/// Groups whose children are instances (effects, masks, shape items, animators).
fn indexed_parade(g: &PropGroup) -> bool {
matches!(g.match_id.as_str(), "effects" | "masks" | "contents" | "animators" | "selectors" | "layerStyles" | "motionTrackers")
}
fn node_json(c: &Comp, l: &Layer, uid: u64) -> R {
if uid == 0 || uid == l.props.uid {
let n = l.props.children.len();
return Ok(json!({
"uid": l.props.uid, "kind": "group", "name": l.name, "matchName": matchnames::layer(&l.source), "matchId": "layer",
"index": c.index_of(l.id).unwrap_or(0), "depth": 0, "parent": null, "isLayer": true, "numProperties": n,
"children": l.props.children.iter().map(Node::uid).collect::<Vec<_>>(), "propertyType": "NAMED_GROUP",
"enabled": l.switches.video, "canSetEnabled": true,
}));
}
let ch = chain(l, uid).ok_or("the property no longer exists (it was deleted)")?;
let node = *ch.last().ok_or("bad property")?;
let parent_group: &PropGroup = if ch.len() >= 2 { ch[ch.len() - 2].as_group().ok_or("bad tree")? } else { &l.props };
let index = parent_group.children.iter().position(|x| x.uid() == uid).map(|i| i + 1).unwrap_or(0);
let parent_uid = if ch.len() >= 2 { ch[ch.len() - 2].uid() } else { l.props.uid };
let match_name = matchnames::node(&ch);
let instance_parent = indexed_parade(parent_group) && ch.len() >= 2 || (ch.len() == 2 && ch[0].match_id() == "contents");
let mut o = json!({
"uid": uid, "name": node.name(), "matchName": match_name, "matchId": node.match_id(), "index": index,
"depth": ch.len(), "parent": parent_uid, "isLayer": false,
"isEffect": matches!(node.as_group().map(|g| &g.kind), Some(GroupKind::Effect { .. })),
"isMask": matches!(node.as_group().map(|g| &g.kind), Some(GroupKind::Mask { .. })),
});
match node {
Node::Group(g) => {
let instance = matches!(g.kind, GroupKind::Indexed | GroupKind::Effect { .. } | GroupKind::Mask { .. } | GroupKind::Tracker { .. });
o["kind"] = json!("group");
o["numProperties"] = json!(g.children.len());
o["children"] = json!(g.children.iter().map(Node::uid).collect::<Vec<_>>());
o["propertyType"] = json!(if indexed_parade(g) { "INDEXED_GROUP" } else { "NAMED_GROUP" });
o["enabled"] = json!(g.enabled);
o["canSetEnabled"] = json!(instance);
o["instance"] = json!(instance || instance_parent);
o["effectId"] = json!(match &g.kind {
GroupKind::Effect { effect } => Some(effect.clone()),
_ => None,
});
if let GroupKind::Mask { mode, inverted, color, locked, .. } = &g.kind {
o["maskMode"] = json!(mode.label());
o["inverted"] = json!(inverted);
o["color"] = json!([color[0] as f64 / 255.0, color[1] as f64 / 255.0, color[2] as f64 / 255.0]);
o["locked"] = json!(locked);
}
}
Node::Prop(p) => {
o["kind"] = json!("prop");
o["propertyType"] = json!("PROPERTY");
o["valueType"] = json!(value_type(p));
o["type"] = json!(p.value.kind_name());
o["dims"] = json!(p.value.dims());
o["spatial"] = json!(p.spatial);
o["numKeys"] = json!(p.keys.len());
o["expression"] = json!(p.expr.as_ref().map(|e| e.text.clone()).unwrap_or_default());
o["expressionEnabled"] = json!(p.has_expression());
o["canVaryOverTime"] = json!(!p.static_only);
o["canSetExpression"] = json!(!p.static_only && !matches!(p.ui, ParamUi::Hidden));
o["hidden"] = json!(matches!(p.ui, ParamUi::Hidden));
o["enabled"] = json!(true);
o["canSetEnabled"] = json!(false);
o["isModified"] = json!(p.is_animated() || p.has_expression());
if let ParamUi::Slider { min, max, .. } = p.ui {
o["min"] = json!(min);
o["max"] = json!(max);
}
if let ParamUi::Popup { options } = &p.ui {
o["options"] = json!(options);
}
o["ui"] = json!(match &p.ui {
ParamUi::Percent => "%",
ParamUi::Angle => "degrees",
ParamUi::Pixels | ParamUi::Point | ParamUi::Point3 => "pixels",
_ => "",
});
// Position with Separate Dimensions.
o["separated"] = json!(node.match_id() == "position" && parent_group.match_id == "transform" && parent_group.get("positionX").is_some());
o["separationFollower"] = json!(matches!(node.match_id(), "positionX" | "positionY" | "positionZ"));
}
}
Ok(o)
}
fn node(s: &Session, a: &J) -> R {
let (_, c, l) = layer_of(s, a)?;
node_json(c, l, u(a, "uid").unwrap_or(0))
}
/// Normalized name for loose lookups: `Anchor Point`, `anchorPoint`, `anchor_point` → `anchorpoint`.
fn loose(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase()
}
/// AE-style aliases for our match ids (attribute names scripts use).
fn alias(k: &str) -> Option<&'static str> {
Some(match k {
"anchorpoint" => "anchor",
"pointofinterest" => "poi",
"xrotation" => "rotationX",
"yrotation" => "rotationY",
"zrotation" => "rotation",
"xposition" => "positionX",
"yposition" => "positionY",
"zposition" => "positionZ",
"effect" | "effects" => "effects",
"mask" | "masks" => "masks",
"content" | "contents" => "contents",
"maskshape" | "maskpath" => "path",
"maskfeather" => "feather",
"maskopacity" => "opacity",
"maskexpansion" | "maskoffset" => "expansion",
"sourcetext" => "sourceText",
"timeremap" => "timeRemap",
"audiolevels" => "levels",
_ => return None,
})
}
fn child(s: &Session, a: &J) -> R {
let (_, c, l) = layer_of(s, a)?;
let uid = u(a, "uid").unwrap_or(0);
let g: &PropGroup = if uid == 0 || uid == l.props.uid { &l.props } else { l.props.find_group(uid).ok_or("not a property group")? };
let key = a.get("key").cloned().unwrap_or(J::Null);
let found = match &key {
J::Number(n) => n.as_u64().and_then(|i| g.children.get((i as usize).checked_sub(1)?)),
J::String(k) => {
let chain_of = |n: &Node| -> String {
let mut ch = if uid == 0 || uid == l.props.uid { vec![] } else { chain(l, uid).unwrap_or_default() };
ch.push(n);
matchnames::node(&ch)
};
let lk = loose(k);
g.children
.iter()
.find(|n| chain_of(n) == *k)
.or_else(|| g.children.iter().find(|n| n.name() == k))
.or_else(|| g.children.iter().find(|n| n.match_id() == k))
.or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k)))
.or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk))
.or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m)))
}
_ => None,
};
match found {
Some(n) => node_json(c, l, n.uid()),
None => Ok(J::Null),
}
}
/// A value as the object model shows it: popups 1-based, checkboxes 0/1, layer references as
/// layer indices, paths as `{vertices, inTangents, outTangents, closed}`, text as the document.
fn js_value(c: &Comp, v: &KV) -> J {
match v {
KV::Bool(b) => json!(if *b { 1 } else { 0 }),
KV::Enum(i) => json!(*i + 1),
KV::Layer(l) => json!(l.and_then(|id| c.index_of(LayerId(id))).unwrap_or(0)),
KV::Path(p) => json!({"vertices": p.vertices, "inTangents": p.in_tangents, "outTangents": p.out_tangents, "closed": p.closed}),
KV::Text(d) => text_json(d),
v => v.to_json(),
}
}
/// A text document for `TextDocument`: the serialized document plus the first character's style.
fn text_json(d: &TextDoc) -> J {
let mut doc = serde_json::to_value(d).unwrap_or(J::Null);
let st = d.style_at(0);
if let Some(o) = doc.as_object_mut() {
o.insert("first".into(), effectcraft_keyframe::text_doc::char_style_json(&st));
o.insert("para".into(), effectcraft_keyframe::text_doc::para_style_json(&d.para(0)));
}
doc
}
fn value(s: &Session, a: &J) -> R {
let (cid, c, l) = layer_of(s, a)?;
let uid = u(a, "uid").ok_or("missing uid")?;
let p = l.props.find(uid).ok_or("the property no longer exists (it was deleted)")?;
let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
let pre = a.get("pre").and_then(J::as_bool).unwrap_or(false);
let v = if pre || !p.has_expression() {
p.value_at(l.layer_time(t))
} else {
let mut ctx = EvalCtx::new(&s.project, cid, c, t);
ctx.expr = s.expr.as_deref();
ctx.value(l, p)
};
Ok(js_value(c, &v))
}
fn interp(i: Interp) -> &'static str {
match i {
Interp::Linear => "LINEAR",
Interp::Bezier => "BEZIER",
Interp::Hold => "HOLD",
}
}
fn keys(s: &Session, a: &J) -> R {
let (cid, c, l) = layer_of(s, a)?;
let uid = u(a, "uid").ok_or("missing uid")?;
let p = l.props.find(uid).ok_or("the property no longer exists (it was deleted)")?;
let sel: Vec<Tick> = s.state.selected_keys.iter().filter(|k| k.layer == l.id && k.prop == uid).map(|k| k.time).collect();
let _ = cid;
let ease = |e: &[effectcraft_keyframe::Ease], n: usize| -> J {
let n = n.max(1);
// Influence is stored as a fraction; scripts see percent.
let mut v: Vec<J> = e.iter().map(|x| json!({"speed": x.speed, "influence": x.influence * 100.0})).collect();
let d = e.first().cloned().unwrap_or_default();
while v.len() < n {
v.push(json!({"speed": d.speed, "influence": d.influence * 100.0}));
}
J::Array(v)
};
let dims = if p.spatial { 1 } else { p.value.dims().max(1) };
let out: Vec<J> = p
.keys
.iter()
.map(|k| {
json!({
"time": l.comp_time(k.time).seconds(),
"layerTime": k.time.seconds(),
"value": js_value(c, &k.value),
"inInterp": interp(k.in_interp), "outInterp": interp(k.out_interp),
"inEase": ease(&k.in_ease, dims), "outEase": ease(&k.out_ease, dims),
"continuous": k.continuous, "autoBezier": k.auto_bezier, "roving": k.roving,
"spatialIn": k.spatial_in, "spatialOut": k.spatial_out,
"spatialContinuous": k.spatial_continuous, "spatialAutoBezier": k.spatial_auto,
"selected": sel.contains(&k.time),
})
})
.collect();
Ok(J::Array(out))
}
/// Apply `layer.setText` attribute keys to the Source Text document at `time` and return the new
/// document (for `prop.set` / `prop.addKey`).
fn textdoc(s: &Session, a: &J) -> R {
let (cid, _, l) = layer_of(s, a)?;
let uid = u(a, "uid").ok_or("missing uid")?;
let p = l.props.find(uid).ok_or("the property no longer exists")?;
let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
let KV::Text(doc) = p.value_at(l.layer_time(t)) else { return Err("not a Source Text property".into()) };
let mut d = *doc;
if let Some(attrs) = a.get("attrs").and_then(J::as_object) {
if let Some(text) = attrs.get("text").and_then(J::as_str) {
d.set_text(text);
}
for (k, v) in attrs {
if k == "text" || v.is_null() {
continue;
}
match d.set_attr(k, v, None) {
Ok(true) => {}
Ok(false) => return Err(format!("TextDocument: unknown attribute `{k}`")),
Err(e) => return Err(format!("TextDocument.{k}: {e}")),
}
}
}
serde_json::to_value(&d).map_err(|e| e.to_string())
}
fn markers(s: &Session, a: &J) -> R {
let (_, c) = comp_of(s, a)?;
let (list, to_comp): (&[effectcraft_project::Marker], Option<&Layer>) = match u(a, "layer") {
Some(lid) => {
let l = c.layer(LayerId(lid)).ok_or("the layer no longer exists")?;
(&l.markers, Some(l))
}
None => (&c.markers, None),
};
let out: Vec<J> = list
.iter()
.map(|m| {
let t = to_comp.map(|l| l.comp_time(m.time)).unwrap_or(m.time);
json!({
"time": t.seconds(), "duration": m.duration.seconds(), "comment": m.comment, "chapter": m.chapter, "url": m.url,
"frameTarget": m.frame_target, "label": label_index(m.label), "protectedRegion": m.protected,
"cuePointName": m.cue_point.as_ref().map(|c| c.name.clone()).unwrap_or_default(),
"eventCuePoint": m.cue_point.as_ref().is_some_and(|c| !c.navigation),
"params": m.cue_point.as_ref().map(|c| c.params.clone()).unwrap_or_default(),
})
})
.collect();
Ok(J::Array(out))
}
fn source_rect(s: &Session, a: &J) -> R {
let (cid, c, l) = layer_of(s, a)?;
let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
let mut ctx = EvalCtx::new(&s.project, cid, c, t);
ctx.expr = s.expr.as_deref();
let r = match &l.source {
LayerSource::Text => {
let paths: Vec<_> = effectcraft_render::text::glyph_paths(&ctx, l).into_iter().map(|(p, _)| p).collect();
effectcraft_path::bounds(&paths).map(|b| [b.y0, b.x0, b.width(), b.height()]).unwrap_or([0.0; 4])
}
LayerSource::Shape => match l.props.sub("contents") {
Some(contents) => {
let buf = effectcraft_render::shapes::render(&ctx, l, contents, 1.0);
if buf.img.width <= 4 { [0.0; 4] } else { [2.0 - buf.offset[1], 2.0 - buf.offset[0], buf.img.width as f64 - 4.0, buf.img.height as f64 - 4.0] }
}
None => [0.0; 4],
},
_ => {
let (w, h) = effectcraft_render::source_size(&s.project, l);
[0.0, 0.0, w as f64, h as f64]
}
};
Ok(json!({"top": r[0], "left": r[1], "width": r[2], "height": r[3]}))
}
/// What `PropertyGroup.addProperty(name)` adds to the group `uid`.
fn resolve_add(s: &Session, a: &J) -> R {
let (_, _, l) = layer_of(s, a)?;
let uid = u(a, "uid").unwrap_or(0);
let name = a.get("name").and_then(J::as_str).unwrap_or("");
let g: &PropGroup = if uid == 0 || uid == l.props.uid { &l.props } else { l.props.find_group(uid).ok_or("not a property group")? };
let in_contents = g.match_id == "contents";
let in_group = l.props.sub("contents").is_some_and(|c| c.uid == g.uid || c.find_group(g.uid).is_some());
Ok(match g.match_id.as_str() {
"effects" => {
let id = matchnames::effect_from_match(name).map(str::to_string).or_else(|| effectcraft_engine::effects::lookup(name).map(|e| e.id.to_string()));
match id {
Some(id) => json!({"kind": "effect", "effect": id}),
None => json!({"error": format!("Can not add a property with name \"{name}\" to this PropertyGroup (unknown effect)")}),
}
}
"masks" => {
if name == "ADBE Mask Atom" || loose(name) == "mask" {
json!({"kind": "mask"})
} else {
json!({"error": format!("Can not add \"{name}\" to Masks (use \"ADBE Mask Atom\")")})
}
}
"animators" => json!({"kind": "animator"}),
_ if in_contents && in_group => match matchnames::shape_kind(name) {
Some(k) => {
json!({"kind": "shape", "shape": k, "group": if l.props.sub("contents").is_some_and(|c| c.uid == g.uid) { J::Null } else { json!(g.uid) }})
}
None => json!({"error": format!("Can not add \"{name}\" to Contents")}),
},
"properties" if l.props.group("text/animators").is_some_and(|a| a.find_group(g.uid).is_some()) => {
let animator = l.props.group("text/animators").and_then(|a| a.groups().find(|x| x.find_group(g.uid).is_some()).map(|x| x.uid));
match text_animator_property(name) {
Some(k) => json!({"kind": "animatorProperty", "property": k, "animator": animator}),
None => json!({"error": format!("Can not add \"{name}\" to an animator's Properties")}),
}
}
"selectors" if l.props.group("text/animators").is_some_and(|a| a.find_group(g.uid).is_some()) => {
let animator = l.props.group("text/animators").and_then(|a| a.groups().find(|x| x.find_group(g.uid).is_some()).map(|x| x.uid));
let kind = match name {
"ADBE Text Wiggly Selector" => "wiggly",
"ADBE Text Expressible Selector" => "expression",
n if loose(n).contains("wiggly") => "wiggly",
n if loose(n).contains("expression") => "expression",
_ => "range",
};
json!({"kind": "selector", "selector": kind, "animator": animator})
}
_ => json!({"error": format!("Can not add a property to \"{}\"", g.name)}),
})
}
/// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match
/// name or a display name.
fn text_animator_property(name: &str) -> Option<&'static str> {
const AE: &[(&str, &str)] = &[
("ADBE Text Anchor Point 3D", "anchor"),
("ADBE Text Position 3D", "position"),
("ADBE Text Scale 3D", "scale"),
("ADBE Text Skew", "skew"),
("ADBE Text Rotation", "rotation"),
("ADBE Text Opacity", "opacity"),
("ADBE Text Fill Color", "fillColor"),
("ADBE Text Fill Hue", "fillHue"),
("ADBE Text Fill Saturation", "fillSaturation"),
("ADBE Text Fill Brightness", "fillBrightness"),
("ADBE Text Fill Opacity", "fillOpacity"),
("ADBE Text Stroke Color", "strokeColor"),
("ADBE Text Stroke Hue", "strokeHue"),
("ADBE Text Stroke Saturation", "strokeSaturation"),
("ADBE Text Stroke Brightness", "strokeBrightness"),
("ADBE Text Stroke Opacity", "strokeOpacity"),
("ADBE Text Stroke Width", "strokeWidth"),
("ADBE Text Tracking Amount", "tracking"),
("ADBE Text Line Anchor", "lineAnchor"),
("ADBE Text Line Spacing", "lineSpacing"),
("ADBE Text Character Offset", "characterOffset"),
("ADBE Text Character Replace", "characterValue"),
("ADBE Text Blur", "blur"),
];
if let Some((_, k)) = AE.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) {
return Some(k);
}
let n = loose(name);
AE.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking"))
}
/// `app.findMenuCommandId(name)`: 1-based index of the command whose label matches (menu items
/// first), 0 when none does.
fn menu_command_id(name: &str) -> J {
let norm = |s: &str| s.trim().trim_end_matches("...").trim_end_matches('…').trim().to_ascii_lowercase();
let n = norm(name);
let specs = effectcraft_engine::command_specs();
let pick = specs
.iter()
.position(|c| !c.menu.is_empty() && norm(c.label) == n)
.or_else(|| specs.iter().position(|c| norm(c.label) == n))
.or_else(|| specs.iter().position(|c| c.id == name));
json!(pick.map(|i| i + 1).unwrap_or(0))
}

1689
crates/script/src/prelude.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,546 @@
//! The boa runtime: native functions (`__exec`, `__query`, `__print`, `__undo`, `__task`,
//! `__file`), the session hand-off while a script runs, undo groups and error reporting.
use std::cell::RefCell;
use std::mem::ManuallyDrop;
use std::sync::Arc;
use boa_engine::{Context, JsNativeError, JsResult, JsString, JsValue, NativeFunction, Source, js_string};
use effectcraft_engine::project::Project;
use effectcraft_engine::{ScriptRequest, Session};
use serde_json::{Value as J, json};
const PRELUDE: &str = include_str!("prelude.js");
/// Engine commands that create layers: like After Effects, scripts add new layers at the top of
/// the stack (the UI inserts above the selection).
const CREATES_LAYER: &[&str] =
&["layer.newSolid", "layer.newNull", "layer.newText", "layer.newShape", "layer.newAdjustment", "layer.newCamera", "layer.newLight", "layer.addItem"];
/// What a script run produced.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Outcome {
/// The value of the script's last expression statement (JSON when it converts; model objects
/// as strings like `"[object CompItem]"`).
pub result: J,
/// `writeLn` / `$.writeln` / `alert` output, one entry per line.
pub output: Vec<String>,
pub error: Option<ScriptError>,
}
/// An uncaught script error.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ScriptError {
pub message: String,
pub line: Option<u32>,
pub column: Option<u32>,
pub file: String,
}
impl Outcome {
pub fn ok(&self) -> bool {
self.error.is_none()
}
pub fn to_json(&self) -> J {
json!({
"ok": self.ok(),
"result": self.result,
"output": self.output.join("\n"),
"error": self.error.as_ref().map(|e| json!({"message": e.message, "line": e.line, "column": e.column, "file": e.file})),
})
}
}
/// The session and per-run state while a script runs (the natives reach it here; nothing
/// borrowed ever enters the JS heap).
struct Active {
session: Session,
output: Vec<String>,
/// Open undo groups: (name, project snapshot when the outermost group began).
groups: Vec<(String, Arc<Project>)>,
/// `app.scheduleTask` queue: (id, code, repeat).
tasks: Vec<(u32, String, bool)>,
next_task: u32,
name: String,
}
thread_local! {
static ACTIVE: RefCell<Option<Active>> = const { RefCell::new(None) };
/// The Script Console's persistent context (never dropped, like the expression runtime: boa's
/// own thread-local heap may be torn down first at thread exit).
static CONSOLE: RefCell<Option<ManuallyDrop<Context>>> = const { RefCell::new(None) };
}
fn with_active<T>(f: impl FnOnce(&mut Active) -> Result<T, String>) -> Result<T, String> {
ACTIVE.with(|a| match a.try_borrow_mut() {
Ok(mut g) => match g.as_mut() {
Some(act) => f(act),
None => Err("no script is running".into()),
},
Err(_) => Err("scripts can't run scripts".into()),
})
}
fn throw(msg: impl Into<String>) -> boa_engine::JsError {
JsNativeError::error().with_message(msg.into()).into()
}
fn arg_string(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult<String> {
Ok(args.get(i).cloned().unwrap_or_default().to_string(ctx)?.to_std_string_escaped())
}
fn arg_json(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult<J> {
let s = arg_string(args, i, ctx)?;
if s.is_empty() || s == "undefined" {
return Ok(json!({}));
}
serde_json::from_str(&s).map_err(|e| throw(format!("bad parameters: {e}")))
}
fn js_str(s: &str) -> JsValue {
JsValue::from(JsString::from(s))
}
/// `__exec(commandId, paramsJson)` → result JSON. Runs an engine command (undoable) with the
/// target comp made active for its duration.
fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let id = arg_string(args, 0, ctx)?;
let params = arg_json(args, 1, ctx)?;
if id == "script.run" || id == "file.runScript" {
return Err(throw("scripts can't run scripts"));
}
let r = with_active(|a| Ok(exec(&mut a.session, &id, params)));
match r {
Ok(Ok(v)) => Ok(js_str(&v.to_string())),
Ok(Err(e)) | Err(e) => Err(throw(e)),
}
}
/// Run a command for a script: the `comp` it targets is active while it runs (commands act on
/// the active comp), then the user's active comp comes back.
pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result<J, String> {
use effectcraft_engine::project::ItemId;
let target = params.get("comp").and_then(J::as_u64).map(ItemId).filter(|c| s.project.comp(*c).is_some());
let prev = s.state.active_comp;
if let Some(c) = target
&& prev != Some(c)
{
s.state.active_comp = Some(c);
}
if CREATES_LAYER.contains(&id) {
s.state.selected_layers.clear();
}
let r = s.execute(id, params);
if let Some(c) = target
&& prev != Some(c)
&& s.state.active_comp == Some(c)
&& id != "comp.open"
{
match prev {
Some(p) if s.project.comp(p).is_some() => s.state.active_comp = Some(p),
_ => {
if !s.state.open_comps.contains(&c) {
s.state.open_comps.push(c);
}
}
}
}
r.map_err(|e| e.to_string())
}
/// `__query(kind, json)` → JSON.
fn native_query(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let kind = arg_string(args, 0, ctx)?;
let a = arg_json(args, 1, ctx)?;
let r = with_active(|act| match kind.as_str() {
"app" => Ok(json!({
"version": format!("26.0 (EffectCraft {})", env!("CARGO_PKG_VERSION")),
"buildName": env!("CARGO_PKG_VERSION"),
"scriptName": act.name,
"allowFiles": act.session.prefs.scripting.allow_scripts_write_files,
"projectDir": project_dir(&act.session),
})),
_ => crate::model::query(&mut act.session, &kind, &a),
});
match r {
Ok(v) => Ok(js_str(&v.to_string())),
Err(e) => Err(throw(e)),
}
}
/// `__print(text)`: `writeLn`, `$.writeln`, `alert`.
fn native_print(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let text = arg_string(args, 0, ctx)?;
let _ = with_active(|a| {
a.output.extend(text.split('\n').map(str::to_string));
Ok(())
});
Ok(JsValue::undefined())
}
/// `__undo("begin" | "end", name)`: After Effects' `app.beginUndoGroup` / `endUndoGroup`. Every
/// command run inside the outermost group folds into one undo step named after the group.
fn native_undo(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let op = arg_string(args, 0, ctx)?;
let name = arg_string(args, 1, ctx)?;
with_active(|a| {
match op.as_str() {
"begin" => {
let snap = a.session.project.clone();
a.groups.push((if name.is_empty() || name == "undefined" { "Script".into() } else { name }, snap));
}
_ => {
if a.groups.len() == 1 {
let (name, snap) = a.groups.pop().unwrap_or_default();
collapse(&mut a.session, &name, &snap);
} else {
a.groups.pop();
}
}
}
Ok(())
})
.map_err(throw)?;
Ok(JsValue::undefined())
}
/// Fold the undo steps recorded since the project was `snap` into one step called `name`.
fn collapse(s: &mut Session, name: &str, snap: &Arc<Project>) {
let h = &mut s.history;
let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) else {
// Nothing was recorded (or history was reset by New / Open Project).
return;
};
let first = h.undo[start].1.clone();
h.undo.truncate(start);
h.undo.push((name.to_string(), first));
h.merge_key = None;
}
/// `__task("schedule", code, repeat)` → id; `__task("cancel", id)`.
fn native_task(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let op = arg_string(args, 0, ctx)?;
if op == "cancel" {
let id = args.get(1).cloned().unwrap_or_default().to_number(ctx)? as u32;
let _ = with_active(|a| {
a.tasks.retain(|t| t.0 != id);
Ok(())
});
return Ok(JsValue::undefined());
}
let code = arg_string(args, 1, ctx)?;
let repeat = args.get(2).cloned().unwrap_or_default().to_boolean();
let id = with_active(|a| {
a.next_task += 1;
let id = a.next_task;
a.tasks.push((id, code, repeat));
Ok(id)
})
.map_err(throw)?;
Ok(JsValue::from(id))
}
fn project_dir(s: &Session) -> Option<String> {
let p = s.path.as_deref()?;
std::path::Path::new(p).parent().map(|d| d.to_string_lossy().to_string())
}
/// Lexically normalized absolute path (`a/./b/../c` → `a/c`).
fn normalize(p: &str) -> std::path::PathBuf {
use std::path::{Component, Path, PathBuf};
let path = Path::new(p);
let abs = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(path) };
let mut out = PathBuf::new();
for c in abs.components() {
match c {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
c => out.push(c),
}
}
// Resolve symlinks where the file (or its folder) exists, so links can't escape the project.
std::fs::canonicalize(&out)
.or_else(|_| out.parent().map(std::fs::canonicalize).unwrap_or(Ok(out.clone())).map(|d| d.join(out.file_name().unwrap_or_default())))
.unwrap_or(out)
}
const GATE: &str = "Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network";
/// The file-access gate: reads under the project's folder are always allowed; other reads, all
/// writes and listing other folders need the Allow Scripts to Write Files preference.
fn check_access(s: &Session, path: &str, write: bool) -> Result<std::path::PathBuf, String> {
let p = normalize(path);
if s.prefs.scripting.allow_scripts_write_files {
return Ok(p);
}
if write {
return Err(format!("Unable to write {path}: scripts can't write files unless {GATE} is on"));
}
if let Some(dir) = project_dir(s) {
let d = normalize(&dir);
if p.starts_with(&d) {
return Ok(p);
}
}
Err(format!("Unable to access {path}: scripts can only read files in the project's folder unless {GATE} is on"))
}
/// `__file(op, path, data)`: `read` → text, `write`/`append` → bytes, `exists` → bool, `list` →
/// names, `mkdir`, `remove`, `isDir`.
fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let op = arg_string(args, 0, ctx)?;
let path = arg_string(args, 1, ctx)?;
let data = if args.len() > 2 { arg_string(args, 2, ctx)? } else { String::new() };
let r: Result<J, String> = with_active(|a| {
let s = &a.session;
match op.as_str() {
"access" => Ok(json!(check_access(s, &path, false)?.exists())),
"exists" | "isDir" => {
// Unreadable locations report "missing" instead of failing.
let Ok(p) = check_access(s, &path, false) else { return Ok(json!(false)) };
Ok(json!(if op == "exists" { p.exists() } else { p.is_dir() }))
}
"read" => {
let p = check_access(s, &path, false)?;
let bytes = s.services.read_file(&p.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?;
Ok(json!(String::from_utf8_lossy(&bytes)))
}
"write" | "append" => {
let p = check_access(s, &path, true)?;
let ps = p.to_string_lossy().to_string();
let mut bytes = if op == "append" { s.services.read_file(&ps).unwrap_or_default() } else { vec![] };
bytes.extend_from_slice(data.as_bytes());
s.services.write_file(&ps, &bytes).map_err(|e| format!("Unable to write {path}: {e}"))?;
Ok(json!(bytes.len()))
}
"list" => {
let p = check_access(s, &path, false)?;
let mut names: Vec<String> = std::fs::read_dir(&p)
.map_err(|e| format!("Unable to list {path}: {e}"))?
.filter_map(|e| e.ok())
.map(|e| e.path().to_string_lossy().to_string())
.collect();
names.sort();
Ok(json!(names))
}
"mkdir" => {
let p = check_access(s, &path, true)?;
std::fs::create_dir_all(&p).map_err(|e| format!("Unable to create {path}: {e}"))?;
Ok(json!(true))
}
"remove" => {
let p = check_access(s, &path, true)?;
let r = if p.is_dir() { std::fs::remove_dir(&p) } else { std::fs::remove_file(&p) };
Ok(json!(r.is_ok()))
}
"network" => Err(if s.prefs.scripting.allow_scripts_write_files {
"network access from scripts is not supported in EffectCraft".to_string()
} else {
format!("scripts can't access the network unless {GATE} is on")
}),
_ => Err(format!("unknown file operation `{op}`")),
}
});
match r {
Ok(v) => Ok(js_str(&v.to_string())),
Err(e) => Err(throw(e)),
}
}
fn new_context() -> Result<Context, String> {
let mut ctx = Context::default();
let limits = ctx.runtime_limits_mut();
limits.set_loop_iteration_limit(200_000_000);
limits.set_recursion_limit(2000);
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 6] = [
(js_string!("__exec"), 2, native_exec),
(js_string!("__query"), 2, native_query),
(js_string!("__print"), 1, native_print),
(js_string!("__undo"), 2, native_undo),
(js_string!("__task"), 3, native_task),
(js_string!("__file"), 3, native_file),
];
for (name, len, f) in natives {
ctx.register_global_callable(name, len, NativeFunction::from_fn_ptr(f)).map_err(|e| e.to_string())?;
}
ctx.eval(Source::from_bytes(PRELUDE)).map_err(|e| format!("script prelude: {e}"))?;
Ok(ctx)
}
/// Turn a boa error into a message with line and column (`code` is the user's script).
fn script_error(e: &boa_engine::JsError, ctx: &mut Context, file: &str, code: &str) -> ScriptError {
// Thrown values: `throw new Error("x")`, `throw "x"`, native errors.
let display = e.to_string();
let message = match e.as_opaque() {
Some(v) => {
let o = v.as_object();
let msg = o.as_ref().and_then(|o| o.get(js_string!("message"), ctx).ok()).filter(|m| !m.is_undefined());
let name = o.as_ref().and_then(|o| o.get(js_string!("name"), ctx).ok()).filter(|m| !m.is_undefined());
match (name, msg) {
(Some(n), Some(m)) => {
let n = n.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default();
let m = m.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default();
if n == "Error" { m } else { format!("{n}: {m}") }
}
_ => v.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_else(|_| display.clone()),
}
}
None => {
let first = display.lines().next().unwrap_or("").trim();
let first = first.split(" at line ").next().unwrap_or(first).trim();
let first = match first.rfind(" (") {
Some(i) if first.ends_with(')') && first[i..].contains(':') => first[..i].trim(),
_ => first,
};
first.strip_prefix("Error: ").unwrap_or(first).to_string()
}
};
let (line, column) = position(&display, file, code);
ScriptError { message, line, column, file: file.to_string() }
}
/// Line and column of an error: `at line N, col M` (syntax errors), else the innermost backtrace
/// frame in the user's script (`file:line:col`), else (names boa can't place) the first use of
/// the undefined name in `code`.
fn position(msg: &str, file: &str, code: &str) -> (Option<u32>, Option<u32>) {
if let Some(i) = msg.find("at line ") {
let rest = &msg[i + 8..];
let line = rest.split(|c: char| !c.is_ascii_digit()).next().and_then(|n| n.parse().ok());
let col = rest.find("col ").and_then(|j| rest[j + 4..].split(|c: char| !c.is_ascii_digit()).next()).and_then(|n| n.parse().ok());
return (line, col);
}
// `(file:12:5)`: the digits after the file name.
let pick = |l: &str| -> Option<(u32, u32)> {
let i = l.find(&format!("({file}:"))? + file.len() + 2;
let mut it = l[i..].split([':', ')']);
Some((it.next()?.parse().ok()?, it.next()?.parse().ok()?))
};
let frames: Vec<&str> = msg.lines().skip(1).collect();
if let Some((a, b)) = frames.iter().find_map(|l| pick(l)).or_else(|| msg.lines().next().and_then(pick)) {
return (Some(a), Some(b));
}
// `ReferenceError: name is not defined` without a position.
let first = msg.lines().next().unwrap_or("");
if let Some(name) = first.split("ReferenceError: ").nth(1).and_then(|r| r.split(" is not defined").next()) {
for (n, line) in code.lines().enumerate() {
let b = line.as_bytes();
let mut from = 0;
while let Some(i) = line[from..].find(name).map(|i| i + from) {
let before = i.checked_sub(1).map(|j| b[j]);
let after = b.get(i + name.len()).copied();
let ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'$';
if !before.is_some_and(|c| ident(c) || c == b'.') && !after.is_some_and(ident) {
return (Some(n as u32 + 1), Some(i as u32 + 1));
}
from = i + name.len();
}
}
}
(None, None)
}
/// Evaluate `code` in `ctx` and convert the result.
fn eval_in(ctx: &mut Context, code: &str, file: &str) -> Result<J, ScriptError> {
let src = Source::from_bytes(code.as_bytes()).with_path(std::path::Path::new(file));
let script = boa_engine::Script::parse(src, None, ctx).map_err(|e| script_error(&e, ctx, file, code))?;
let v = script.evaluate(ctx).map_err(|e| script_error(&e, ctx, file, code))?;
Ok(result_json(&v, ctx))
}
/// The script's completion value as JSON (`__result` in the prelude does the conversion).
fn result_json(v: &JsValue, ctx: &mut Context) -> J {
if v.is_undefined() {
return J::Null;
}
let global = ctx.global_object();
let Some(f) = global.get(js_string!("__result"), ctx).ok().and_then(|f| f.as_object()) else { return J::Null };
match f.call(&JsValue::undefined(), std::slice::from_ref(v), ctx) {
Ok(s) if s.is_string() => {
let s = s.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default();
serde_json::from_str(&s).unwrap_or(J::String(s))
}
_ => J::Null,
}
}
/// Restores the caller's session when a run ends (on every exit path).
struct Restore<'a> {
target: &'a mut Session,
}
impl Drop for Restore<'_> {
fn drop(&mut self) {
if let Some(a) = ACTIVE.with(|c| c.borrow_mut().take()) {
*self.target = a.session;
}
}
}
/// Run a script against `session`: the object model sees and edits that session through engine
/// commands. Script errors come back in [`Outcome::error`].
pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome {
let running = ACTIVE.with(|a| a.try_borrow().map(|g| g.is_some()).unwrap_or(true));
if running {
return Outcome {
error: Some(ScriptError { message: "scripts can't run scripts".into(), file: req.name.into(), ..Default::default() }),
..Default::default()
};
}
let s = std::mem::take(session);
ACTIVE.with(|a| *a.borrow_mut() = Some(Active { session: s, output: vec![], groups: vec![], tasks: vec![], next_task: 0, name: req.name.to_string() }));
let guard = Restore { target: session };
let file = if req.name.is_empty() { "script" } else { req.name };
let mut result = if req.console {
CONSOLE.with(|c| {
let mut c = c.borrow_mut();
if c.is_none() {
match new_context() {
Ok(ctx) => *c = Some(ManuallyDrop::new(ctx)),
Err(e) => return Err(ScriptError { message: e, file: file.into(), ..Default::default() }),
}
}
let ctx = c.as_mut().map(|m| &mut **m).expect("console context");
eval_in(ctx, req.code, file)
})
} else {
match new_context() {
Ok(mut ctx) => {
let r = eval_in(&mut ctx, req.code, file);
// app.scheduleTask: run the queued one-shot tasks after the script, in order.
let tasks = with_active(|a| Ok(std::mem::take(&mut a.tasks))).unwrap_or_default();
let mut r = r;
for (_, code, repeat) in tasks {
if repeat {
continue;
}
if let Err(e) = eval_in(&mut ctx, &code, "scheduled task")
&& r.is_ok()
{
r = Err(e);
}
}
r
}
Err(e) => Err(ScriptError { message: e, file: file.into(), ..Default::default() }),
}
};
// Close undo groups left open; drop selections that no longer exist.
let output = with_active(|a| {
if let Some((name, snap)) = a.groups.first().cloned() {
collapse(&mut a.session, &name, &snap);
a.groups.clear();
}
a.session.sanitize_state();
Ok(std::mem::take(&mut a.output))
})
.unwrap_or_default();
drop(guard);
let mut out = Outcome { output, ..Default::default() };
match result.as_mut() {
Ok(v) => out.result = std::mem::take(v),
Err(e) => out.error = Some(e.clone()),
}
out
}

392
crates/script/src/tests.rs Normal file
View File

@ -0,0 +1,392 @@
//! End-to-end scripts against a headless session: build with the object model, read back through
//! the engine.
use std::sync::Arc;
use effectcraft_engine::Session;
use effectcraft_engine::keyframe::Value as KV;
use effectcraft_engine::project::{Comp, ItemId, Layer};
use serde_json::{Value, json};
use crate::{Outcome, run_code};
fn session() -> Session {
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), expr_check: Some(effectcraft_expr::check_syntax), ..Default::default() };
crate::install(&mut s);
s
}
fn ok(s: &mut Session, code: &str) -> Outcome {
let o = run_code(s, code, "test.jsx");
assert!(o.error.is_none(), "script failed: {:?}\noutput: {:?}", o.error, o.output);
o
}
fn comp_named<'a>(s: &'a Session, name: &str) -> (ItemId, &'a Comp) {
let it = s.project.items.values().find(|i| i.name == name && i.as_comp().is_some()).unwrap_or_else(|| panic!("no comp {name}"));
(it.id, it.as_comp().unwrap())
}
fn layer<'a>(c: &'a Comp, name: &str) -> &'a Layer {
c.layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("no layer {name}"))
}
#[test]
fn builds_a_comp_end_to_end() {
let mut s = session();
let o = ok(
&mut s,
r#"
app.beginUndoGroup("Build");
var comp = app.project.items.addComp("Main", 640, 360, 1, 4, 25);
var bg = comp.layers.addSolid([0.2, 0.4, 0.6], "BG", 640, 360, 1);
var txt = comp.layers.addText("Hello");
txt.name = "Title";
var pos = txt.property("ADBE Transform Group").property("ADBE Position");
pos.setValueAtTime(0, [100, 180]);
pos.setValueAtTime(2, [540, 180]);
txt.transform.opacity.expression = "50 + 25";
var blur = bg.Effects.addProperty("ADBE Gaussian Blur 2");
blur.property("Blurriness").setValue(12);
var nul = comp.layers.addNull();
txt.setParentWithJump(nul);
bg.blendingMode = BlendingMode.MULTIPLY;
var rq = app.project.renderQueue.items.add(comp);
rq.outputModule(1).file = new File("/tmp/effectcraft-script-test/out.mp4");
app.endUndoGroup();
writeLn("layers: " + comp.numLayers);
[comp.numLayers, pos.numKeys, pos.keyValue(2)[0], txt.transform.opacity.value, comp.layer(1).name]
"#,
);
assert_eq!(o.output, vec!["layers: 3"]);
assert_eq!(o.result, json!([3, 2, 540, 75, "Null 1"]));
let (cid, c) = comp_named(&s, "Main");
assert_eq!((c.width, c.height), (640, 360));
assert_eq!(c.duration.seconds(), 4.0);
// AE adds new layers at the top.
let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["Null 1", "Title", "BG"]);
let title = layer(c, "Title");
let p = title.props.prop("transform/position").unwrap();
assert_eq!(p.keys.len(), 2);
assert_eq!(p.keys[1].value, KV::Vec3([540.0, 180.0, 0.0]));
assert_eq!(title.props.prop("transform/opacity").unwrap().expr.as_ref().unwrap().text, "50 + 25");
assert_eq!(title.parent, Some(layer(c, "Null 1").id));
let bg = layer(c, "BG");
assert_eq!(bg.blend_mode, effectcraft_engine::color::BlendMode::Multiply);
let fx = bg.props.group("effects/#1").unwrap();
assert_eq!(fx.match_id, "ec.blur.gaussian");
assert_eq!(bg.props.prop("effects/#1/blurriness").unwrap().value, KV::Scalar(12.0));
assert_eq!(s.project.render_queue.len(), 1);
assert_eq!(s.project.render_queue[0].comp, cid);
// One undo step for the whole group.
assert_eq!(s.history.undo.len(), 1);
assert_eq!(s.history.undo[0].0, "Build");
assert!(s.undo());
assert!(s.project.items.is_empty());
}
#[test]
fn undo_steps_without_a_group() {
let mut s = session();
ok(&mut s, r#"var c = app.project.items.addComp("A", 100, 100, 1, 1, 24); c.layers.addNull(); c.layers.addNull();"#);
assert_eq!(s.history.undo.len(), 3);
// An unbalanced group is closed when the script ends.
ok(&mut s, r#"app.beginUndoGroup("Two"); var c = app.project.item(1); c.layers.addNull(); c.layers.addNull();"#);
assert_eq!(s.history.undo.len(), 4);
assert_eq!(s.history.undo[3].0, "Two");
// Nested groups fold into the outermost one.
ok(
&mut s,
r#"app.beginUndoGroup("Outer"); app.beginUndoGroup("Inner"); app.project.item(1).layers.addNull(); app.endUndoGroup(); app.project.item(1).layers.addNull(); app.endUndoGroup();"#,
);
assert_eq!(s.history.undo.len(), 5);
assert_eq!(s.history.undo[4].0, "Outer");
s.undo();
assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 4);
}
#[test]
fn errors_report_line_and_column() {
let mut s = session();
let o = run_code(&mut s, "var a = 1;\nvar b = 2;\nfoo.bar();\n", "broken.jsx");
let e = o.error.expect("error");
assert_eq!(e.line, Some(3), "{e:?}");
assert!(e.message.contains("foo"), "{e:?}");
assert_eq!(e.file, "broken.jsx");
let o = run_code(&mut s, "var a = 1;\n\nvar = ;\n", "syntax.jsx");
let e = o.error.expect("syntax error");
assert_eq!(e.line, Some(3), "{e:?}");
let o = run_code(&mut s, "writeLn('before');\nthrow new Error('custom failure');\n", "throw.jsx");
let e = o.error.expect("thrown");
assert_eq!(e.message, "custom failure");
assert_eq!(e.line, Some(2), "{e:?}");
assert_eq!(o.output, vec!["before"]);
// Errors from the object model point at the user's line.
let o = run_code(&mut s, "var c = app.project.items.addComp('C', 10, 10, 1, 1, 24);\n\nc.layer(5);\n", "range.jsx");
let e = o.error.expect("range error");
assert!(e.message.contains("out of range"), "{e:?}");
assert_eq!(e.line, Some(3), "{e:?}");
}
#[test]
fn match_names_and_lookups() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("M", 200, 200, 1, 2, 30);
var l = c.layers.addSolid([1, 1, 1], "S", 200, 200, 1);
var tr = l.property("ADBE Transform Group");
var fx = l.property("ADBE Effect Parade").addProperty("Gaussian Blur");
var m = l.property("ADBE Mask Parade").addProperty("ADBE Mask Atom");
var sh = c.layers.addShape();
var grp = sh.property("ADBE Root Vectors Group").addProperty("ADBE Vector Group");
var rect = grp.property("ADBE Vectors Group").addProperty("ADBE Vector Shape - Rect");
var fill = grp.property("Contents").addProperty("ADBE Vector Graphic - Fill");
fill.property("ADBE Vector Fill Color").setValue([1, 0, 0]);
rect.property("ADBE Vector Rect Size").setValue([50, 60]);
[
l.matchName, tr.matchName, tr.property("ADBE Position").matchName, tr.property("Anchor Point").matchName,
tr.property(1).name, tr.anchorPoint.matchName, l.transform.position.propertyValueType == PropertyValueType.ThreeD_SPATIAL,
fx.matchName, fx.property(1).matchName, fx.property("ADBE Gaussian Blur 2-0001").name, m.matchName,
m.property("ADBE Mask Shape").name, sh.matchName, grp.matchName, rect.matchName, fill.matchName,
tr.propertyType == PropertyType.NAMED_GROUP, l.Effects.propertyType == PropertyType.INDEXED_GROUP,
tr.position.parentProperty.matchName, tr.position.propertyDepth, l.property("Nope") === null,
fx.isEffect, m.isMask, l.Effects.numProperties
]
"#,
);
assert_eq!(
o.result,
json!([
"ADBE AV Layer",
"ADBE Transform Group",
"ADBE Position",
"ADBE Anchor Point",
"Anchor Point",
"ADBE Anchor Point",
true,
"ADBE Gaussian Blur 2",
"ADBE Gaussian Blur 2-0001",
"Blurriness",
"ADBE Mask Atom",
"Mask Path",
"ADBE Vector Layer",
"ADBE Vector Group",
"ADBE Vector Shape - Rect",
"ADBE Vector Graphic - Fill",
true,
true,
"ADBE Transform Group",
2,
true,
true,
true,
1
])
);
let (_, c) = comp_named(&s, "M");
let sh = c.layers.iter().find(|l| l.props.sub("contents").is_some()).unwrap();
assert_eq!(sh.props.prop("contents/#1/contents/rect/size").unwrap().value, KV::Vec2([50.0, 60.0]));
assert_eq!(sh.props.prop("contents/#1/contents/fill/color").unwrap().value, KV::Color([1.0, 0.0, 0.0, 1.0]));
}
#[test]
fn keyframes_eases_and_interpolation() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("K", 100, 100, 1, 5, 25);
var l = c.layers.addNull();
var r = l.transform.rotation;
r.setValuesAtTimes([0, 1, 2], [0, 90, 180]);
r.setInterpolationTypeAtKey(2, KeyframeInterpolationType.HOLD, KeyframeInterpolationType.LINEAR);
r.setTemporalEaseAtKey(3, [new KeyframeEase(0, 75)]);
var idx = r.addKey(1.5);
r.removeKey(1);
var out = [r.numKeys, idx, r.keyTime(1), r.keyInInterpolationType(1) == KeyframeInterpolationType.HOLD,
r.keyOutInterpolationType(1) == KeyframeInterpolationType.LINEAR, r.keyInTemporalEase(3)[0].influence,
r.valueAtTime(1, false), r.isTimeVarying, r.nearestKeyIndex(1.9)];
l.startTime = 1;
out.push(r.keyTime(1));
out
"#,
);
let v = o.result.as_array().unwrap();
assert_eq!(v[0], json!(3));
assert_eq!(v[1], json!(3));
assert_eq!(v[2], json!(1));
assert_eq!(v[3], json!(true));
assert_eq!(v[4], json!(true));
assert!((v[5].as_f64().unwrap() - 75.0).abs() < 1e-6, "{v:?}");
assert_eq!(v[6], json!(90));
assert_eq!(v[7], json!(true));
assert_eq!(v[8], json!(3));
// Moving the layer moves its keys (layer time): key 1 now sits at 2 s comp time.
assert_eq!(v[9], json!(2));
}
#[test]
fn text_documents() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("T", 400, 200, 1, 2, 30);
var t = c.layers.addText("abc");
var st = t.property("ADBE Text Properties").property("ADBE Text Document");
var doc = st.value;
doc.text = "Changed";
doc.fontSize = 40;
doc.fillColor = [1, 0, 0];
doc.justification = ParagraphJustification.CENTER_JUSTIFY;
doc.applyStroke = true;
doc.strokeWidth = 3;
st.setValue(doc);
var d2 = t.text.sourceText.value;
[d2.text, d2.fontSize, d2.fillColor, d2.justification == ParagraphJustification.CENTER_JUSTIFY, d2.strokeWidth, t instanceof TextLayer, t.name]
"#,
);
assert_eq!(o.result, json!(["Changed", 40, [1, 0, 0], true, 3, true, "abc"]));
let (_, c) = comp_named(&s, "T");
let KV::Text(d) = &c.layers[0].props.prop("text/sourceText").unwrap().value else { panic!() };
assert_eq!(d.text, "Changed");
assert_eq!(d.style_at(0).size, 40.0);
}
#[test]
fn layers_items_and_markers() {
let mut s = session();
let o = ok(
&mut s,
r#"
var f = app.project.items.addFolder("Comps");
var c = f.items.addComp("Inner", 100, 100, 1, 3, 30);
var main = app.project.items.addComp("Outer", 200, 200, 1, 6, 30);
var nested = main.layers.add(c);
var a = main.layers.addSolid([1, 0, 0], "A", 50, 50, 1, 2);
var b = main.layers.addSolid([0, 1, 0], "B", 50, 50, 1);
b.moveAfter(nested);
a.enabled = false; a.solo = true; a.locked = true; a.shy = true;
a.threeDLayer = true;
var cam = main.layers.addCamera("Cam", [100, 100]);
var lt = main.layers.addLight("Key", [100, 100]);
lt.lightType = LightType.SPOT;
var d = a.duplicate();
d.name = "A copy";
var mv = new MarkerValue("hello");
mv.duration = 0.5;
main.markerProperty.setValueAtTime(1, mv);
b.marker.setValueAtTime(0.5, new MarkerValue("layer marker"));
b.inPoint = 1; b.outPoint = 3;
var pc = main.layers.precompose([b.index], "Pre", true);
[c.parentFolder.name, nested.source.name, a.outPoint, a.enabled, a.solo, a.locked, a.shy, a.threeDLayer,
cam instanceof CameraLayer, lt.lightType == LightType.SPOT, main.markerProperty.numKeys, main.markerProperty.keyValue(1).comment,
main.markerProperty.keyValue(1).duration, pc.name, pc instanceof CompItem, main.layer("A copy").index, app.project.numItems,
main.layers.byName("Pre") !== null, f.numItems]
"#,
);
assert_eq!(o.result, json!(["Comps", "Inner", 2, false, true, true, true, true, true, true, 1, "hello", 0.5, "Pre", true, 3, 7, true, 1]));
let (_, c) = comp_named(&s, "Outer");
assert_eq!(c.markers.len(), 1);
assert!(
c.layers
.iter()
.any(|l| l.name == "Key"
&& matches!(l.source, effectcraft_engine::project::LayerSource::Light { kind: effectcraft_engine::project::LightKind::Spot }))
);
}
#[test]
fn security_gate_for_files() {
let dir = std::env::temp_dir().join(format!("ec-script-gate-{}", std::process::id()));
let proj = dir.join("proj");
std::fs::create_dir_all(&proj).unwrap();
std::fs::write(proj.join("data.txt"), "inside").unwrap();
std::fs::write(dir.join("secret.txt"), "outside").unwrap();
let mut s = session();
s.path = Some(proj.join("p.ecproj").to_string_lossy().to_string());
let q = |p: &std::path::Path| serde_json::to_string(&p.to_string_lossy()).unwrap();
// Reading inside the project folder works.
let o = ok(&mut s, &format!("var f = new File({}); f.open('r'); var t = f.read(); f.close(); t", q(&proj.join("data.txt"))));
assert_eq!(o.result, json!("inside"));
// Outside it (also via `..`) is refused.
for path in [dir.join("secret.txt"), proj.join("../secret.txt")] {
let o = run_code(&mut s, &format!("var f = new File({}); f.open('r');", q(&path)), "gate.jsx");
let e = o.error.expect("blocked");
assert!(e.message.contains("Allow Scripts to Write Files"), "{e:?}");
}
// Writing is refused anywhere, the network too.
let o = run_code(&mut s, &format!("var f = new File({}); f.open('w'); f.write('x'); f.close();", q(&proj.join("out.txt"))), "gate.jsx");
assert!(o.error.is_some());
assert!(!proj.join("out.txt").exists());
assert!(run_code(&mut s, "new Socket()", "net.jsx").error.is_some());
assert!(run_code(&mut s, "system.callSystem('ls')", "sys.jsx").error.is_some());
// With the preference on, scripts can write and read anywhere.
s.prefs.scripting.allow_scripts_write_files = true;
ok(&mut s, &format!("var f = new File({}); f.open('w'); f.writeln('written'); f.close();", q(&dir.join("w.txt"))));
assert_eq!(std::fs::read_to_string(dir.join("w.txt")).unwrap(), "written\n");
let o = ok(&mut s, &format!("var f = new File({}); f.open('r'); f.readln()", q(&dir.join("secret.txt"))));
assert_eq!(o.result, json!("outside"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn engine_entry_points() {
let mut s = session();
// script.run reports errors in the result, not as a command failure.
let r = s.execute("script.run", json!({"code": "writeLn('hi'); 1 + 2"})).unwrap();
assert_eq!(r["ok"], json!(true));
assert_eq!(r["result"], json!(3));
assert_eq!(r["output"], json!("hi"));
let r = s.execute("script.run", json!({"code": "\nnope()"})).unwrap();
assert_eq!(r["ok"], json!(false));
assert_eq!(r["error"]["line"], json!(2));
// Scripts can't run scripts.
let r = s.execute("script.run", json!({"code": "app.run('script.run', {code: '1'})"})).unwrap();
assert!(r["error"]["message"].as_str().unwrap().contains("scripts can't run scripts"), "{r}");
// File ▸ Scripts ▸ Run Script File… runs .jsx as JavaScript.
let dir = std::env::temp_dir().join(format!("ec-script-file-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("make.jsx");
std::fs::write(&f, "app.project.items.addComp('From File', 320, 240, 1, 2, 24);").unwrap();
s.execute("file.runScript", json!({"path": f.to_string_lossy()})).unwrap();
assert!(s.project.items.values().any(|i| i.name == "From File"));
std::fs::write(&f, "\n\nbroken(;").unwrap();
let e = s.execute("file.runScript", json!({"path": f.to_string_lossy()})).unwrap_err().to_string();
assert!(e.contains("make.jsx (line 3)"), "{e}");
// JSON command scripts still work.
let j = dir.join("steps.json");
std::fs::write(&j, r#"[{"command": "comp.new", "params": {"name": "From Steps"}}]"#).unwrap();
s.execute("file.runScript", json!({"path": j.to_string_lossy()})).unwrap();
assert!(s.project.items.values().any(|i| i.name == "From Steps"));
// Menu commands by name.
let o = ok(&mut s, "app.findMenuCommandId('New Project') > 0 && app.findMenuCommandId('No Such Thing') === 0");
assert_eq!(o.result, json!(true));
let o = ok(&mut s, "app.executeCommand(app.findMenuCommandId('New Project')); app.project.numItems");
assert_eq!(o.result, json!(0));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn console_keeps_variables() {
let mut s = session();
let req = |code: &'static str| effectcraft_engine::ScriptRequest { code, name: "console", console: true };
let r = crate::run(&mut s, &req("var counter = 41;"));
assert!(r.error.is_none(), "{r:?}");
let r = crate::run(&mut s, &req("counter + 1"));
assert_eq!(r.result, json!(42));
let _ = Value::Null;
}
#[test]
fn schedule_task_runs_after_the_script() {
let mut s = session();
let o = ok(
&mut s,
"app.scheduleTask('writeLn(\"later\")', 100, false); var id = app.scheduleTask('writeLn(\"never\")', 10, false); app.cancelTask(id); writeLn('now');",
);
assert_eq!(o.output, vec!["now", "later"]);
}

View File

@ -34,10 +34,11 @@ pub enum PanelKind {
History,
Markers,
MaskInterpolation,
ScriptConsole,
}
impl PanelKind {
pub const ALL: [PanelKind; 24] = [
pub const ALL: [PanelKind; 25] = [
PanelKind::Project,
PanelKind::EffectControls,
PanelKind::Composition,
@ -62,6 +63,7 @@ impl PanelKind {
PanelKind::History,
PanelKind::Markers,
PanelKind::MaskInterpolation,
PanelKind::ScriptConsole,
];
pub fn title(self) -> &'static str {
match self {
@ -89,6 +91,7 @@ impl PanelKind {
PanelKind::History => "History",
PanelKind::Markers => "Markers",
PanelKind::MaskInterpolation => "Mask Interpolation",
PanelKind::ScriptConsole => "Script Console",
}
}
pub fn id(self) -> String {

View File

@ -697,7 +697,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
"file.exportLottie" => ("path", Ask::Save("Animation.json")),
"file.importLottie" => ("path", Ask::Open(&["json", "lottie"])),
"render.saveCurrentPreview" => ("path", Ask::Save("Preview.mp4")),
"file.runScript" => ("path", Ask::Open(&["jsonl", "json", "txt"])),
"file.runScript" => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
"file.replaceFootage" => ("path", Ask::Import),
"file.collectFiles" => ("folder", Ask::Save("Collected Files")),
_ => return None,

View File

@ -21,6 +21,7 @@ pub mod project;
pub mod properties;
pub mod puppet_tool;
pub mod render_queue;
pub mod script_console;
pub mod settings;
pub mod shortcut_editor;
pub mod text_panels;
@ -82,6 +83,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
PanelKind::History => misc::history(app, ui, rect),
PanelKind::Markers => misc::markers(app, ui, rect),
PanelKind::MaskInterpolation => anim_tools::mask_interpolation(app, ui, rect),
PanelKind::ScriptConsole => script_console::show(app, ui, rect),
PanelKind::Wiggler => anim_tools::wiggler(app, ui, rect),
PanelKind::Smoother => anim_tools::smoother(app, ui, rect),
PanelKind::MotionSketch => anim_tools::motion_sketch(app, ui, rect),

View File

@ -529,16 +529,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
dresp.on_hover_text("Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings");
let tr = Rect::from_min_size(pos2(foot.max.x - 30.0, foot.min.y + 3.0), vec2(22.0, 22.0));
if widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text("Delete selected project items").clicked() {
let sel = app.session.state.project_selection.clone();
let _ = app.session.edit("Delete Items", None, |proj, st| {
for id in &sel {
proj.items.remove(id);
}
st.project_selection.clear();
Ok(())
});
app.session.sanitize_state();
if widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text("Delete selected project items").clicked()
&& !app.session.state.project_selection.is_empty()
{
actions.push(("project.delete".into(), json!({})));
}
app.auto.add("project.delete", tr, "Delete");
for (id, params) in actions {

View File

@ -0,0 +1,144 @@
//! Window ▸ Script Console: a JavaScript REPL over the After Effects-style scripting object model
//! (`app.project`, comps, layers, properties…). Each run goes through the `script.run` command
//! in the console's persistent context, so variables survive between runs; edits are undoable.
use egui::{Align2, Rect, Sense, pos2, vec2};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::EffectcraftApp;
use crate::theme::Tokens;
/// One line of console output.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ConsoleLine {
pub text: String,
/// `input`, `output`, `result` or `error`.
pub kind: String,
}
/// Script Console state (serde, so agents can read it).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ScriptConsole {
pub input: String,
pub log: Vec<ConsoleLine>,
/// Previously run inputs (Up/Down recall).
#[serde(default)]
pub history: Vec<String>,
}
impl ScriptConsole {
fn push(&mut self, kind: &str, text: &str) {
for l in text.lines() {
self.log.push(ConsoleLine { text: l.to_string(), kind: kind.to_string() });
}
if self.log.len() > 2000 {
let extra = self.log.len() - 2000;
self.log.drain(..extra);
}
}
/// Record a `script.run` result.
pub fn record(&mut self, code: &str, r: &Result<Value, String>) {
self.push("input", &format!("> {}", code.trim()));
match r {
Ok(v) => {
if let Some(o) = v["output"].as_str().filter(|o| !o.is_empty()) {
self.push("output", o);
}
if let Some(e) = v.get("error").filter(|e| !e.is_null()) {
let line = e["line"].as_u64().map(|l| format!("line {l}: ")).unwrap_or_default();
self.push("error", &format!("{line}{}", e["message"].as_str().unwrap_or("error")));
} else if !v["result"].is_null() {
self.push("result", &v["result"].to_string());
}
}
Err(e) => self.push("error", e),
}
}
}
/// Run the console input.
pub fn run(app: &mut EffectcraftApp, ctx: &egui::Context) {
let code = app.ui.script_console.input.trim().to_string();
if code.is_empty() {
return;
}
let r = crate::menus::invoke(app, ctx, "script.run", json!({"code": code, "name": "Script Console", "console": true}));
let con = &mut app.ui.script_console;
con.record(&code, &r);
if con.history.last() != Some(&code) {
con.history.push(code);
}
con.input.clear();
}
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let input_h = 86.0;
let bar_h = 26.0;
let out_rect = Rect::from_min_max(rect.min, pos2(rect.max.x, rect.max.y - input_h - bar_h));
let p = ui.painter().with_clip_rect(rect);
p.rect_filled(out_rect, 0.0, t.field_bg);
// Output, newest at the bottom.
let line_h = 16.0;
let visible = ((out_rect.height() - 8.0) / line_h).max(1.0) as usize;
let log = &app.ui.script_console.log;
let start = log.len().saturating_sub(visible);
for (i, l) in log[start..].iter().enumerate() {
let color = match l.kind.as_str() {
"error" => t.danger,
"input" => t.text_faint,
"result" => t.accent,
_ => t.text,
};
p.text(pos2(out_rect.min.x + 8.0, out_rect.min.y + 4.0 + i as f32 * line_h), Align2::LEFT_TOP, &l.text, Tokens::mono(11.5), color);
}
if log.is_empty() {
p.text(
out_rect.center(),
Align2::CENTER_CENTER,
"JavaScript with the After Effects scripting object model — try app.project.numItems",
Tokens::ui(12.0),
t.text_faint,
);
}
app.auto.add("scriptConsole.output", out_rect, "Script Console output");
// Input.
let in_rect = Rect::from_min_size(pos2(rect.min.x + 4.0, out_rect.max.y + 4.0), vec2(rect.width() - 8.0, input_h - 8.0));
let mut run_now = false;
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(in_rect));
let te = egui::TextEdit::multiline(&mut app.ui.script_console.input)
.id(egui::Id::new("script-console-input"))
.font(Tokens::mono(12.0))
.desired_width(in_rect.width())
.desired_rows(4)
.hint_text("Script… (⌘/Ctrl+Enter runs)");
let resp = child.add_sized(in_rect.size(), te);
if resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command) {
run_now = true;
}
app.auto.add("scriptConsole.input", in_rect, "Script Console input");
// Buttons.
let bar = Rect::from_min_size(pos2(rect.min.x, rect.max.y - bar_h), vec2(rect.width(), bar_h));
let run_r = Rect::from_min_size(pos2(bar.max.x - 64.0, bar.min.y + 2.0), vec2(56.0, 20.0));
let clear_r = Rect::from_min_size(pos2(bar.max.x - 128.0, bar.min.y + 2.0), vec2(56.0, 20.0));
let run_resp = ui.interact(run_r, egui::Id::new("script-console-run"), Sense::click());
p.rect_filled(run_r, 3.0, if run_resp.hovered() { t.accent_hover } else { t.accent });
p.text(run_r.center(), Align2::CENTER_CENTER, "Run", Tokens::ui(12.0), egui::Color32::WHITE);
let clear_resp = ui.interact(clear_r, egui::Id::new("script-console-clear"), Sense::click());
p.rect_stroke(clear_r, 3.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
p.text(clear_r.center(), Align2::CENTER_CENTER, "Clear", Tokens::ui(12.0), t.text);
app.auto.add("scriptConsole.run", run_r, "Run");
app.auto.add("scriptConsole.clear", clear_r, "Clear");
if run_resp.clicked() {
run_now = true;
}
if clear_resp.clicked() {
app.ui.script_console.log.clear();
}
if run_now {
let ctx = ui.ctx().clone();
run(app, &ctx);
}
}

View File

@ -476,6 +476,9 @@ impl Default for AnimToolsState {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UiState {
pub tool: Tool,
/// Window ▸ Script Console input, output and history.
#[serde(default)]
pub script_console: crate::panels::script_console::ScriptConsole,
/// Tool shown in each toolbar slot.
pub slot_tools: Vec<Tool>,
pub workspace: String,
@ -576,6 +579,7 @@ impl Default for UiState {
fn default() -> Self {
UiState {
tool: Tool::Selection,
script_console: Default::default(),
slot_tools: Tool::SLOTS.iter().map(|s| s[0]).collect(),
workspace: "Default".into(),
dock: crate::dock::workspace("Default"),

View File

@ -0,0 +1,44 @@
//! Window ▸ Script Console: the panel shows, runs JavaScript through `script.run` (persistent
//! context) and logs output, results and errors (egui_kittest, UI logic only).
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use egui_kittest::Harness;
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
let c = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
h.input_mut().events.push(egui::Event::PointerMoved(c));
for pressed in [true, false] {
h.input_mut().events.push(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
}
h.run_steps(2);
}
#[test]
fn script_console_runs_scripts() {
let s = effectcraft_host::session();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::ScriptConsole);
h.run_steps(3);
for id in ["scriptConsole.input", "scriptConsole.output", "scriptConsole.run", "scriptConsole.clear"] {
assert!(h.state().auto.find(id).is_some(), "missing {id}");
}
h.state_mut().ui.script_console.input = "var c = app.project.items.addComp('Console', 320, 180, 1, 2, 24); writeLn('made'); c.name".into();
h.run_steps(1);
click(&mut h, "scriptConsole.run");
assert!(h.state().session.project.items.values().any(|i| i.name == "Console"));
// Variables persist between runs; errors are logged with their line.
h.state_mut().ui.script_console.input = "c.numLayers + 5".into();
click(&mut h, "scriptConsole.run");
h.state_mut().ui.script_console.input = "nope()".into();
click(&mut h, "scriptConsole.run");
let log: Vec<(String, String)> = h.state().ui.script_console.log.iter().map(|l| (l.kind.clone(), l.text.clone())).collect();
assert!(log.contains(&("output".into(), "made".into())), "{log:?}");
assert!(log.contains(&("result".into(), "\"Console\"".into())), "{log:?}");
assert!(log.contains(&("result".into(), "5".into())), "{log:?}");
assert!(log.iter().any(|(k, t)| k == "error" && t.contains("line 1") && t.contains("nope")), "{log:?}");
assert!(h.state_mut().session.undo(), "console edits are undoable");
click(&mut h, "scriptConsole.clear");
assert!(h.state().ui.script_console.log.is_empty());
}

View File

@ -49,6 +49,7 @@ The server speaks JSON-RPC 2.0 over stdio, one message per line, and supports MC
|---|---|
| `list_commands {filter?, enabled_only?}` | Discover command ids, their param docs, and whether each can run now. |
| `execute_command {command, params?}` | Run any command (undoable). |
| `run_script {code, name?}` | Run JavaScript with the After Effects-style scripting object model (`app.project`, `comp.layers.addText(…)`, `layer.property("ADBE Transform Group").property("ADBE Position").setValueAtTime(…)`…). Returns `{ok, result, output, error: {message, line, column}}`; edits are undoable. |
| `get_project` / `get_comp {comp?}` | Project items, comp settings and layers. |
| `get_layer {layer, comp?, time?, flat?}` | A layer's property tree. Every node has a `path`. |
| `get_property {layer, path, comp?, time?}` | Value at a time, keyframes and expression. |
@ -191,6 +192,19 @@ effectcraft-cli exec file.exportLottie '{"comp":"Main","path":"main.json","inclu
effectcraft-cli exec file.importLottie '{"path":"anim.json"}' main.ecproj --save
```
Scripts written against After Effects' documented scripting API run with `script`:
```sh
effectcraft-cli script build.jsx --save-as main.ecproj # empty project unless one is given
effectcraft-cli script --eval 'app.project.item(1).numLayers' main.ecproj --json
effectcraft-cli script tweak.jsx --bridge 9877 # against the live app
```
`writeLn`/`$.writeln`/`alert` output is printed, then the value of the last expression. A script
error exits with status 1 and `file:line:col: message`. Scripts may read files only in the
project's folder and may not write files or use the network unless the user turns on Preferences ▸
Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network.
`file.exportLottie` returns `{path, bytes, warnings}`: the warnings list every feature Lottie
cannot express (most effects, cameras and lights, layer styles, audio, video footage…), so an
agent can check what a player will not show. A `.lottie` path writes a dotLottie archive.

View File

@ -30,13 +30,15 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
| L3 | `gpu` | The GPU compositor and GPU effects on wgpu compute shaders (Metal, Vulkan, Direct3D 12, WebGPU), checked against the CPU renderer |
| L3 | `lottie` | Lottie JSON / dotLottie import and export (layers, precomps, eased and spatial keyframes, shapes, masks, mattes) with a warnings list for what Lottie cannot express |
| L4 | `engine` | `Session`: project, undo history, editor state, the command registry and menus |
| L4 | `host` | A fully wired `Session` (media, expressions, exporter) for the frontends |
| L4 | `script` | Scripting: JavaScript (boa) with an After Effects-style object model (`app.project`, comps, layers, properties, render queue) whose edits run engine commands; AE match names |
| L4 | `host` | A fully wired `Session` (media, expressions, scripting, exporter) for the frontends |
| L5 | `ui-egui` | The desktop interface: docking, panels, viewer, timeline, graph editor, dialogs, control channel |
| L5 | `automation` | The MCP server, headless or bridged to the running app |
| L6 | apps `effectcraft`, `effectcraft-cli`, `effectcraft-web` | Desktop app; command-line tool (render, exec, get/set, MCP); the browser app (wasm32, [web.md](web.md)) |
Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `effects → project, text, path, track`,
`media / expr / export / gpu → render`, `export → media`, `lottie → format`, `host → engine`.
`media / expr / export / gpu → render`, `export → media`, `lottie → format`, `host → engine, script`,
`script → engine`.
## 2. Time
@ -182,6 +184,28 @@ Expressions are JavaScript, run by boa, with After Effects' object model (`thisC
`time`, `value`, `wiggle`, `loopOut`, vector maths on arrays, and so on). A syntax error keeps the
text, disables the expression and shows a warning, like After Effects.
## 5a. Scripting
`effectcraft-script` runs JavaScript (boa, a fresh context per run; the Script Console keeps one)
with an object model written from the behaviour the After Effects Scripting Guide documents:
`app`, `Project`, `ItemCollection`, `CompItem`/`FootageItem`/`FolderItem`, `LayerCollection`,
`AVLayer`/`TextLayer`/`ShapeLayer`/`CameraLayer`/`LightLayer`, `PropertyGroup`/`Property`
(values, keyframes, eases, expressions), `TextDocument`, `Shape`, `KeyframeEase`, `MarkerValue`,
`RenderQueue`/`RenderQueueItem`/`OutputModule`, `File`/`Folder` and the enums. The model lives in
`prelude.js`; it reads the session through `__query` (JSON snapshots of items, layers, property
nodes, values and keys) and edits only through `__exec`, which runs engine commands, so every
scripted edit is undoable and identical to the UI's. While a script runs the session is moved
into a thread-local the natives borrow (nothing borrowed enters the JS heap); the comp a call
targets is made active for that command only. `app.beginUndoGroup`/`endUndoGroup` fold the undo
steps in between into one named step. Match names (`ADBE Transform Group`, `ADBE Position`,
`ADBE Gaussian Blur 2`, `ADBE Vector Shape - Rect`…) come from a table in `matchnames.rs`; effect
parameters are `<effect match name>-0001…`. `File` reads are limited to the project's folder, and
writes and the network are refused, unless Preferences ▸ Scripting & Expressions ▸ Allow Scripts
to Write Files and Access Network is on. Entry points: the `script.run` command
(`Session::script`, set by the host), File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`; `.json`
command scripts still run as steps), Window ▸ Script Console, `effectcraft-cli script` and the
MCP `run_script` tool.
## 6. Commands, the UI seam and automation
Every action is a command: an id, a label, a menu path, a shortcut, a parameter description,

View File

@ -75,7 +75,7 @@ library groundwork for mask tracking and Warp Stabilizer.
What is left is concentrated in large systems: Roto Brush and Refine Edge, the 3D camera tracker,
Advanced 3D (models, PBR, image-based lighting), the Warp Stabilizer and mask-tracking UI on top of
the new tracking library, a JavaScript scripting object model, SVG/PSD import, WebM, a disk cache,
the new tracking library, SVG/PSD import, WebM, a disk cache,
the missing effect categories (3D Channel, Immersive Video, OCIO), native macOS menus, and
wasm threads.
@ -87,7 +87,7 @@ wasm threads.
| Output | 85% | 4.2 | multiple output modules, pre-render, WebM, audio-only |
| Audio | 85% | 0.5 | audio to keyframes |
| Import | 78% | 4.5 | PSD, SVG as shapes, Lottie |
| Automation | 76% | 3.3 | a JavaScript scripting object model |
| Automation | ≈ 90% | 0.8 | scripting covers the documented core object model (AUT-2, M14.4: `app`, project items, comps, layers, properties and keyframes, text documents, markers, render queue, Script Console, `effectcraft-cli script`, MCP `run_script`); still missing: ScriptUI panels/dialogs, `.jsxbin`, sockets |
| Shapes | 68% | 3.9 | taper and wave strokes (pen tool for shape paths and vertex editing landed in M6.5) |
| Compositions | 67% | 7.4 | marker dialog, flowchart, Essential Graphics |
| Animation | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) |

View File

@ -39,6 +39,7 @@ const LAYERS: &[(&str, u8)] = &[
("lottie", 3),
("format", 3),
("engine", 4),
("script", 4),
("host", 4),
("ui-egui", 5),
("automation", 5),
@ -63,6 +64,8 @@ const SAME_LAYER: &[(&str, &str)] = &[
("gpu", "render"),
("lottie", "format"),
("host", "engine"),
("script", "engine"),
("host", "script"),
("cli", "effectcraft"),
];