diff --git a/Cargo.lock b/Cargo.lock index 46f34e63..c1e86483 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index a1941cf4..6c5ac99a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 90d7ef66..b8222ddc 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -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 [args] [--json] +const USAGE: &str = "usage: effectcraft-cli [args] [--json] info project + engine summary commands [--filter TEXT] [--enabled] list engine commands exec [--params JSON] run one engine command @@ -47,6 +48,9 @@ const USAGE: &str = "usage: effectcraft-cli : id or name, '-' = active comp; : id, '#n' or name; : 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 { } } +/// `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)) { diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index c8427720..d7c48762 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -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)); +} diff --git a/apps/effectcraft-web/src/lib.rs b/apps/effectcraft-web/src/lib.rs index 7a765bfc..edd90e31 100644 --- a/apps/effectcraft-web/src/lib.rs +++ b/apps/effectcraft-web/src/lib.rs @@ -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() } diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index a3b16408..0b2bcb05 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -14,3 +14,6 @@ effectcraft-engine = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } image = { workspace = true } + +[dev-dependencies] +effectcraft-script = { workspace = true } diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index f175b12f..848d3ee0 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -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:?}"); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 205573b6..739c5524 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -140,6 +140,15 @@ fn execute_command(b: &mut Backend, a: &Value) -> Result { json_reply(b.exec(id, params)?) } +fn run_script(b: &mut Backend, a: &Value) -> Result { + 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 { 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.", diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 87c1e2df..5ff744c3 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -358,9 +358,30 @@ fn collect_files(s: &mut Session, p: &Value) -> Result { // ---------------------------------------------------------------- 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 { + 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 = 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 { } _ => 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 { 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 { + 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 { + 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 { @@ -638,10 +681,19 @@ pub fn specs() -> Vec { "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...", diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 4537752f..2e40eb63 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -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()); diff --git a/crates/engine/src/commands/project_items.rs b/crates/engine/src/commands/project_items.rs index 832f5851..a3949896 100644 --- a/crates/engine/src/commands/project_items.rs +++ b/crates/engine/src/commands/project_items.rs @@ -140,6 +140,92 @@ fn set_comment(s: &mut Session, p: &Value) -> Result { 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 { + let c = "project.delete"; + let ids = items_p(s, p, c)?; + let mut doomed: Vec = 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 = 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 = + 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 { + 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::>()})) +} + pub fn specs() -> Vec { vec![ cmd!("project.select", "Select Project Items", [], None, "{items: [id|name], add?}", always, select), @@ -147,5 +233,15 @@ pub fn specs() -> Vec { 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), ] } diff --git a/crates/engine/src/commands/prop_groups.rs b/crates/engine/src/commands/prop_groups.rs new file mode 100644 index 00000000..7f5f2a2c --- /dev/null +++ b/crates/engine/src/commands/prop_groups.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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), + ] +} diff --git a/crates/engine/src/commands/text_anim.rs b/crates/engine/src/commands/text_anim.rs index 73f34c5e..51367da9 100644 --- a/crates/engine/src/commands/text_anim.rs +++ b/crates/engine/src/commands/text_anim.rs @@ -114,7 +114,8 @@ fn add_animator(s: &mut Session, p: &Value) -> Result { 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; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ba34488a..063b9bdf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -285,8 +285,25 @@ pub struct Session { pub autosave: autosave::AutoSaveState, /// The viewer snapshot (Take Snapshot / Show Snapshot). pub snapshot: Option, + /// 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, } +/// 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, } } } diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 38252a88..065d1762 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -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"} diff --git a/crates/engine/src/tests_project_items.rs b/crates/engine/src/tests_project_items.rs index 4d097aca..4ab9ce2b 100644 --- a/crates/engine/src/tests_project_items.rs +++ b/crates/engine/src/tests_project_items.rs @@ -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::>(), [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()); +} diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index b43271c7..dc7965d9 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -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] diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 16199dfb..5235feca 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -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(); diff --git a/crates/script/Cargo.toml b/crates/script/Cargo.toml new file mode 100644 index 00000000..ef756107 --- /dev/null +++ b/crates/script/Cargo.toml @@ -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 } diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs new file mode 100644 index 00000000..a1e9e5d4 --- /dev/null +++ b/crates/script/src/lib.rs @@ -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; diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs new file mode 100644 index 00000000..623027c1 --- /dev/null +++ b/crates/script/src/matchnames.rs @@ -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::().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 `-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); + } +} diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs new file mode 100644 index 00000000..d1dd4768 --- /dev/null +++ b/crates/script/src/model.rs @@ -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; + +fn u(a: &J, k: &str) -> Option { + a.get(k).and_then(J::as_u64) +} +fn f(a: &J, k: &str) -> Option { + 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::>())), + "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::>() + )), + _ => Err(format!("unknown query `{kind}`")), + } +} + +fn project(s: &Session) -> J { + let p = &s.project; + let items: Vec = 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::>(), + "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 = 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::>()); + } + ItemKind::Comp(c) => { + let cid = id; + o["kind"] = json!("comp"); + let sel: Vec = 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::>(), + "selectedLayers": sel, + "selectedProperties": s.state.selected_props.iter().filter(|(l, _)| c.layer(*l).is_some()).map(|(l, p)| json!([l.0, p])).collect::>(), + "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> { + 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::>(), "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::>()); + 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::().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 = 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 = 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 = 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 = 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)) +} diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js new file mode 100644 index 00000000..4f359926 --- /dev/null +++ b/crates/script/src/prelude.js @@ -0,0 +1,1689 @@ +// EffectCraft scripting: an After Effects-style object model over the engine. +// +// Written from the behaviour the After Effects Scripting Guide documents (object names, +// attributes, methods). Reads go through `__query(kind, json)`; every edit runs an engine command +// through `__exec(id, json)`, so it is undoable and identical to the UI's action. + +var __ecGlobal = this; + +function __call(id, p) { + var r = __exec(id, JSON.stringify(p || {})); + return r === undefined || r === "" ? null : JSON.parse(r); +} +function __get(kind, a) { + return JSON.parse(__query(kind, JSON.stringify(a || {}))); +} +function __err(msg) { + return new Error(msg); +} +function __num(v, what) { + var n = Number(v); + if (typeof v === "boolean" || isNaN(n)) throw __err((what || "value") + " must be a number"); + return n; +} +function __str(v) { + if (v === undefined || v === null) return ""; + if (v instanceof File || v instanceof Folder) return v.fsName; + return String(v); +} + +// ------------------------------------------------------------------ enums + +function __enum(name, keys, base) { + var e = {}; + for (var i = 0; i < keys.length; i++) e[keys[i]] = base + i; + e.__name = name; + e.__keys = keys; + e.__base = base; + return e; +} +function __enumKey(e, v) { + var i = v - e.__base; + return i >= 0 && i < e.__keys.length ? e.__keys[i] : null; +} + +var BlendingMode = __enum("BlendingMode", [ + "NORMAL", "DISSOLVE", "DANCING_DISSOLVE", "DARKEN", "MULTIPLY", "COLOR_BURN", "CLASSIC_COLOR_BURN", "LINEAR_BURN", + "DARKER_COLOR", "ADD", "LIGHTEN", "SCREEN", "COLOR_DODGE", "CLASSIC_COLOR_DODGE", "LINEAR_DODGE", "LIGHTER_COLOR", + "OVERLAY", "SOFT_LIGHT", "HARD_LIGHT", "LINEAR_LIGHT", "VIVID_LIGHT", "PIN_LIGHT", "HARD_MIX", "DIFFERENCE", + "CLASSIC_DIFFERENCE", "EXCLUSION", "SUBTRACT", "DIVIDE", "HUE", "SATURATION", "COLOR", "LUMINOSITY", "STENCIL_ALPHA", + "STENCIL_LUMA", "SILHOUETE_ALPHA", "SILHOUETTE_LUMA", "ALPHA_ADD", "LUMINESCENT_PREMUL"], 5212); +BlendingMode.SILHOUETTE_ALPHA = BlendingMode.SILHOUETE_ALPHA; +var KeyframeInterpolationType = __enum("KeyframeInterpolationType", ["LINEAR", "BEZIER", "HOLD"], 6612); +var TrackMatteType = __enum("TrackMatteType", ["NO_TRACK_MATTE", "ALPHA", "ALPHA_INVERTED", "LUMA", "LUMA_INVERTED"], 5012); +var LightType = __enum("LightType", ["PARALLEL", "SPOT", "POINT", "AMBIENT", "ENVIRONMENT"], 4412); +var ParagraphJustification = __enum("ParagraphJustification", [ + "LEFT_JUSTIFY", "CENTER_JUSTIFY", "RIGHT_JUSTIFY", "FULL_JUSTIFY_LASTLINE_LEFT", "FULL_JUSTIFY_LASTLINE_CENTER", + "FULL_JUSTIFY_LASTLINE_RIGHT", "FULL_JUSTIFY_LASTLINE_FULL", "MULTIPLE_JUSTIFICATIONS"], 7413); +var PropertyValueType = __enum("PropertyValueType", [ + "NO_VALUE", "ThreeD_SPATIAL", "ThreeD", "TwoD_SPATIAL", "TwoD", "OneD", "COLOR", "CUSTOM_VALUE", "MARKER", + "LAYER_INDEX", "MASK_INDEX", "SHAPE", "TEXT_DOCUMENT"], 6412); +var PropertyType = __enum("PropertyType", ["PROPERTY", "INDEXED_GROUP", "NAMED_GROUP"], 6212); +var LayerQuality = __enum("LayerQuality", ["BEST", "DRAFT", "WIREFRAME"], 4612); +var LayerSamplingQuality = __enum("LayerSamplingQuality", ["BILINEAR", "BICUBIC"], 4812); +var FrameBlendingType = __enum("FrameBlendingType", ["NO_FRAME_BLEND", "FRAME_MIX", "PIXEL_MOTION"], 4012); +var AutoOrientType = __enum("AutoOrientType", ["NO_AUTO_ORIENT", "ALONG_PATH", "CAMERA_OR_POINT_OF_INTEREST", "CHARACTERS_TOWARD_CAMERA"], 4212); +var MaskMode = __enum("MaskMode", ["NONE", "ADD", "SUBTRACT", "INTERSECT", "LIGHTEN", "DARKEN", "DIFFERENCE"], 6812); +var RQItemStatus = __enum("RQItemStatus", ["WILL_CONTINUE", "NEEDS_OUTPUT", "UNQUEUED", "QUEUED", "RENDERING", "USER_STOPPED", "ERR_STOPPED", "DONE"], 3012); +var CloseOptions = __enum("CloseOptions", ["DO_NOT_SAVE_CHANGES", "PROMPT_TO_SAVE_CHANGES", "SAVE_CHANGES"], 1212); +var ImportAsType = __enum("ImportAsType", ["COMP_CROPPED_LAYERS", "FOOTAGE", "COMP", "PROJECT"], 3812); +var TimeDisplayType = __enum("TimeDisplayType", ["FRAMES", "TIMECODE"], 2012); +var LogType = __enum("LogType", ["ERRORS_ONLY", "ERRORS_AND_SETTINGS", "ERRORS_AND_PER_FRAME_INFO"], 3212); +var KeyframeLabel = __enum("KeyframeLabel", ["NONE"], 0); + +var __blendNames = {}; +(function () { + var labels = ["Normal", "Dissolve", "Dancing Dissolve", "Darken", "Multiply", "Color Burn", "Classic Color Burn", + "Linear Burn", "Darker Color", "Add", "Lighten", "Screen", "Color Dodge", "Classic Color Dodge", "Linear Dodge", + "Lighter Color", "Overlay", "Soft Light", "Hard Light", "Linear Light", "Vivid Light", "Pin Light", "Hard Mix", + "Difference", "Classic Difference", "Exclusion", "Subtract", "Divide", "Hue", "Saturation", "Color", "Luminosity", + "Stencil Alpha", "Stencil Luma", "Silhouette Alpha", "Silhouette Luma", "Alpha Add", "Luminescent Premul"]; + for (var i = 0; i < labels.length; i++) __blendNames[labels[i]] = BlendingMode.__base + i; +})(); +function __blendLabel(v) { + for (var k in __blendNames) if (__blendNames[k] === v) return k; + throw __err("Bad blendingMode value " + v); +} + +var __interpKey = { 6612: "linear", 6613: "bezier", 6614: "hold" }; +var __matteKind = { 5013: "alpha", 5014: "alphaInverted", 5015: "luma", 5016: "lumaInverted" }; +var __matteEnum = { Alpha: 5013, AlphaInverted: 5014, Luma: 5015, LumaInverted: 5016 }; +var __justKey = { 7413: "left", 7414: "center", 7415: "right", 7416: "justifyLeft", 7417: "justifyCenter", 7418: "justifyRight", 7419: "justifyAll" }; + +// ------------------------------------------------------------------ output, $ and dialogs + +function writeLn(s) { __print(__str(s)); } +function write(s) { __print(__str(s)); } +function alert(s) { __print(__str(s)); } +function confirm(s) { __print(__str(s)); return true; } +function prompt(msg, def) { __print(__str(msg)); return def === undefined ? "" : def; } +function clearOutput() {} + +var $ = { + writeln: function () { __print(Array.prototype.slice.call(arguments).map(__str).join("")); }, + write: function () { __print(Array.prototype.slice.call(arguments).map(__str).join("")); }, + sleep: function () {}, + gc: function () {}, + global: __ecGlobal, + level: 0, + engineName: "boa", + get fileName() { return __get("app").scriptName; }, + get os() { return "EffectCraft"; }, + get version() { return "EffectCraft scripting"; }, + get locale() { return "en_US"; }, + evalFile: function () { throw __err("$.evalFile is not supported: scripts can't run scripts"); }, +}; + +// ------------------------------------------------------------------ File / Folder + +function __join(a, b) { return a.replace(/[\/\\]+$/, "") + "/" + b; } + +function File(path) { + if (!(this instanceof File)) return new File(path); + this.__path = path === undefined ? "" : __str(path); + this.__buf = null; + this.__mode = null; + this.__pos = 0; + this.encoding = "UTF-8"; + this.lineFeed = "Unix"; + this.error = ""; +} +File.prototype = { + constructor: File, + get fsName() { return this.__path; }, + get fullName() { return this.__path; }, + get absoluteURI() { return this.__path; }, + get name() { return this.__path.split(/[\/\\]/).pop(); }, + get displayName() { return this.name; }, + get path() { var p = this.__path.split(/[\/\\]/); p.pop(); return p.join("/"); }, + get parent() { return new Folder(this.path); }, + get exists() { return JSON.parse(__file("exists", this.__path)) === true; }, + get length() { return this.__buf !== null ? this.__buf.length : (this.exists ? JSON.parse(__file("read", this.__path)).length : 0); }, + get eof() { return this.__buf === null || this.__pos >= this.__buf.length; }, + toString: function () { return this.__path; }, + open: function (mode) { + mode = (mode || "r").charAt(0).toLowerCase(); + this.__mode = mode; + this.__pos = 0; + if (mode === "r" || mode === "e") { + // Throws when the location is off limits (the scripting file gate). + if (!JSON.parse(__file("access", this.__path))) { + if (mode === "r") { this.error = "File not found"; return false; } + this.__buf = ""; + return true; + } + this.__buf = JSON.parse(__file("read", this.__path)); + } else if (mode === "a") { + this.__buf = this.exists ? JSON.parse(__file("read", this.__path)) : ""; + this.__pos = this.__buf.length; + } else { + // Check the gate now, like After Effects refusing to open a file for writing. + JSON.parse(__file("write", this.__path, "")); + this.__buf = ""; + } + return true; + }, + read: function (n) { + if (this.__buf === null) this.open("r"); + if (this.__buf === null) return ""; + var s = n === undefined ? this.__buf.substring(this.__pos) : this.__buf.substr(this.__pos, n); + this.__pos += s.length; + return s; + }, + readln: function () { + if (this.__buf === null) this.open("r"); + if (this.__buf === null) return ""; + var i = this.__buf.indexOf("\n", this.__pos); + var end = i < 0 ? this.__buf.length : i; + var s = this.__buf.substring(this.__pos, end).replace(/\r$/, ""); + this.__pos = i < 0 ? this.__buf.length : i + 1; + return s; + }, + write: function () { + if (this.__mode !== "w" && this.__mode !== "a" && this.__mode !== "e") throw __err("File is not open for writing"); + var s = Array.prototype.slice.call(arguments).map(__str).join(""); + this.__buf = this.__buf.substring(0, this.__pos) + s + this.__buf.substring(this.__pos + s.length); + this.__pos += s.length; + return true; + }, + writeln: function () { return this.write(Array.prototype.slice.call(arguments).map(__str).join("") + "\n"); }, + seek: function (p) { this.__pos = p; return true; }, + tell: function () { return this.__pos; }, + close: function () { + if ((this.__mode === "w" || this.__mode === "a" || this.__mode === "e") && this.__buf !== null) { + __file("write", this.__path, this.__buf); + } + this.__buf = null; + this.__mode = null; + return true; + }, + remove: function () { return JSON.parse(__file("remove", this.__path)); }, + copy: function (to) { + var data = JSON.parse(__file("read", this.__path)); + __file("write", __str(to), data); + return true; + }, + rename: function () { throw __err("File.rename is not supported"); }, + execute: function () { throw __err("scripts can't execute files"); }, + openDlg: function () { return null; }, + saveDlg: function () { return null; }, +}; +File.openDialog = function () { return null; }; +File.saveDialog = function () { return null; }; +File.decode = function (s) { return decodeURIComponent(s); }; +File.encode = function (s) { return encodeURIComponent(s); }; +File.fs = "EffectCraft"; + +function Folder(path) { + if (!(this instanceof Folder)) return new Folder(path); + this.__path = path === undefined ? "" : __str(path); +} +Folder.prototype = { + constructor: Folder, + get fsName() { return this.__path; }, + get fullName() { return this.__path; }, + get absoluteURI() { return this.__path; }, + get name() { return this.__path.replace(/[\/\\]+$/, "").split(/[\/\\]/).pop(); }, + get displayName() { return this.name; }, + get path() { var p = this.__path.replace(/[\/\\]+$/, "").split(/[\/\\]/); p.pop(); return p.join("/"); }, + get parent() { return new Folder(this.path); }, + get exists() { return JSON.parse(__file("isDir", this.__path)) === true; }, + toString: function () { return this.__path; }, + create: function () { return JSON.parse(__file("mkdir", this.__path)); }, + remove: function () { return JSON.parse(__file("remove", this.__path)); }, + getFiles: function (mask) { + var names = JSON.parse(__file("list", this.__path)); + var out = []; + for (var i = 0; i < names.length; i++) { + var n = names[i]; + if (typeof mask === "string" && mask !== "*") { + var re = new RegExp("^" + mask.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$", "i"); + if (!re.test(n.split(/[\/\\]/).pop())) continue; + } + var isDir = JSON.parse(__file("isDir", n)); + var f = isDir ? new Folder(n) : new File(n); + if (typeof mask === "function" && !mask(f)) continue; + out.push(f); + } + return out; + }, + selectDlg: function () { return null; }, +}; +Folder.selectDialog = function () { return null; }; +Object.defineProperty(Folder, "current", { get: function () { var d = __get("app").projectDir; return d ? new Folder(d) : null; } }); +Object.defineProperty(Folder, "temp", { get: function () { return null; } }); +Object.defineProperty(Folder, "desktop", { get: function () { return null; } }); +Object.defineProperty(Folder, "myDocuments", { get: function () { return null; } }); + +function Socket() { __file("network", ""); } +var system = { callSystem: function () { throw __err("scripts can't run system commands"); } }; + +// ------------------------------------------------------------------ helpers + +// Unknown attributes of property groups and layers resolve to child properties, like After +// Effects (`layer.transform.position`, `layer.Effects`, `effect.blurriness`). +var __attr = { + get: function (t, k, r) { + if (typeof k !== "string" || k in t || k.charAt(0) === "_" || k === "toJSON" || k === "then") return Reflect.get(t, k, r); + var c = t.property(k); + return c === null ? undefined : c; + }, +}; + +// 1-based collections with `[n]` access. +var __coll = { + get: function (t, k, r) { + if (typeof k === "string" && /^[0-9]+$/.test(k)) return t.__at(parseInt(k, 10)); + return Reflect.get(t, k, r); + }, +}; + +function KeyframeEase(speed, influence) { + if (!(this instanceof KeyframeEase)) return new KeyframeEase(speed, influence); + this.speed = speed === undefined ? 0 : speed; + this.influence = influence === undefined ? 16.666666667 : influence; +} + +function Shape() { + if (!(this instanceof Shape)) return new Shape(); + this.vertices = []; + this.inTangents = []; + this.outTangents = []; + this.closed = true; + this.featherSegLocs = []; + this.featherRelSegLocs = []; + this.featherRadii = []; + this.featherInterps = []; + this.featherTensions = []; + this.featherTypes = []; + this.featherRelCornerAngles = []; +} + +function MarkerValue(comment, chapter, url, frameTarget, cuePointName, params) { + if (!(this instanceof MarkerValue)) return new MarkerValue(comment, chapter, url, frameTarget, cuePointName, params); + this.comment = comment === undefined ? "" : String(comment); + this.chapter = chapter === undefined ? "" : String(chapter); + this.url = url === undefined ? "" : String(url); + this.frameTarget = frameTarget === undefined ? "" : String(frameTarget); + this.cuePointName = cuePointName === undefined ? "" : String(cuePointName); + this.duration = 0; + this.eventCuePoint = false; + this.label = 0; + this.protectedRegion = false; + this.__params = []; + if (params) this.setParameters(params); +} +MarkerValue.prototype.getParameters = function () { + var o = {}; + for (var i = 0; i < this.__params.length; i++) o[this.__params[i][0]] = this.__params[i][1]; + return o; +}; +MarkerValue.prototype.setParameters = function (o) { + this.__params = []; + for (var k in o) this.__params.push([k, String(o[k])]); +}; + +// ------------------------------------------------------------------ TextDocument + +// `layer.setText` keys of each TextDocument attribute. +var __textKeys = { + font: "font", fontStyle: "style", fontSize: "size", fillColor: "fill", strokeColor: "stroke", strokeWidth: "strokeWidth", + applyFill: "applyFill", applyStroke: "applyStroke", strokeOverFill: "strokeOverFill", tracking: "tracking", + baselineShift: "baselineShift", horizontalScale: "hScale", verticalScale: "vScale", fauxBold: "fauxBold", + fauxItalic: "fauxItalic", allCaps: "allCaps", smallCaps: "smallCaps", tsume: "tsume", superscript: "superscript", + subscript: "subscript", ligature: "ligatures", startIndent: "indentLeft", endIndent: "indentRight", + firstLineIndent: "indentFirst", spaceBefore: "spaceBefore", spaceAfter: "spaceAfter", +}; + +function TextDocument(text) { + if (!(this instanceof TextDocument)) return new TextDocument(text); + this.__base = null; + this.__changed = {}; + this.__text = text === undefined ? "" : String(text); + this.__textSet = text !== undefined; +} +TextDocument.__from = function (doc) { + var t = new TextDocument(); + t.__base = doc; + t.__text = doc.text; + t.__textSet = false; + return t; +}; +TextDocument.prototype = { + constructor: TextDocument, + get text() { return this.__text; }, + set text(v) { this.__text = String(v); this.__textSet = true; }, + __style: function (k, d) { + if (k in this.__changed) return this.__changed[k]; + if (this.__base && this.__base.first && k in this.__base.first) return this.__base.first[k]; + if (this.__base && this.__base.para && k in this.__base.para) return this.__base.para[k]; + return d; + }, + __set: function (k, v) { this.__changed[k] = v; }, + get font() { return this.__style("font", "Inter"); }, + set font(v) { this.__set("font", String(v)); }, + get fontFamily() { return this.font; }, + get fontStyle() { return this.__style("style", "Regular"); }, + set fontStyle(v) { this.__set("style", String(v)); }, + get fontSize() { return this.__style("size", 72); }, + set fontSize(v) { this.__set("size", __num(v, "fontSize")); }, + get fillColor() { var c = this.__style("fill", [1, 1, 1, 1]); return [c[0], c[1], c[2]]; }, + set fillColor(v) { this.__set("fill", [v[0], v[1], v[2], v.length > 3 ? v[3] : 1]); }, + get strokeColor() { var c = this.__style("stroke", [0, 0, 0, 1]); return [c[0], c[1], c[2]]; }, + set strokeColor(v) { this.__set("stroke", [v[0], v[1], v[2], v.length > 3 ? v[3] : 1]); }, + get strokeWidth() { return this.__style("strokeWidth", 0); }, + set strokeWidth(v) { this.__set("strokeWidth", __num(v, "strokeWidth")); }, + get applyFill() { return this.__style("applyFill", true); }, + set applyFill(v) { this.__set("applyFill", !!v); }, + get applyStroke() { return this.__style("applyStroke", false); }, + set applyStroke(v) { this.__set("applyStroke", !!v); }, + get strokeOverFill() { return "strokeOverFill" in this.__changed ? this.__changed.strokeOverFill : (this.__base ? this.__base.stroke_over_fill : true); }, + set strokeOverFill(v) { this.__set("strokeOverFill", !!v); }, + get tracking() { return this.__style("tracking", 0); }, + set tracking(v) { this.__set("tracking", __num(v, "tracking")); }, + get leading() { var l = this.__style("leading", "auto"); return l === "auto" ? this.fontSize * 1.2 : l; }, + set leading(v) { this.__set("leading", __num(v, "leading")); }, + get autoLeading() { return this.__style("leading", "auto") === "auto"; }, + set autoLeading(v) { if (v) this.__set("leading", "auto"); }, + get justification() { + var j = this.__style("justify", "left"); + for (var k in __justKey) if (__justKey[k] === j) return Number(k); + return ParagraphJustification.LEFT_JUSTIFY; + }, + set justification(v) { + var k = __justKey[v]; + if (!k) throw __err("Bad justification value " + v); + this.__set("justify", k); + }, + get baselineShift() { return this.__style("baselineShift", 0); }, + set baselineShift(v) { this.__set("baselineShift", __num(v)); }, + get horizontalScale() { return this.__style("hScale", 100) / 100; }, + set horizontalScale(v) { this.__set("hScale", __num(v) * 100); }, + get verticalScale() { return this.__style("vScale", 100) / 100; }, + set verticalScale(v) { this.__set("vScale", __num(v) * 100); }, + get fauxBold() { return this.__style("fauxBold", false); }, + set fauxBold(v) { this.__set("fauxBold", !!v); }, + get fauxItalic() { return this.__style("fauxItalic", false); }, + set fauxItalic(v) { this.__set("fauxItalic", !!v); }, + get allCaps() { return this.__style("allCaps", false); }, + set allCaps(v) { this.__set("allCaps", !!v); }, + get smallCaps() { return this.__style("smallCaps", false); }, + set smallCaps(v) { this.__set("smallCaps", !!v); }, + get superscript() { return this.__style("baseline", "normal") === "superscript"; }, + set superscript(v) { this.__set("superscript", !!v); }, + get subscript() { return this.__style("baseline", "normal") === "subscript"; }, + set subscript(v) { this.__set("subscript", !!v); }, + get tsume() { return this.__style("tsume", 0); }, + set tsume(v) { this.__set("tsume", __num(v)); }, + get boxText() { return !!(this.__base && this.__base.box_size); }, + get pointText() { return !this.boxText; }, + get boxTextSize() { return this.__base && this.__base.box_size ? this.__base.box_size.slice() : undefined; }, + set boxTextSize(v) { + var p = this.boxTextPos || [0, 0]; + this.__set("box", [p[0], p[1], v[0], v[1]]); + }, + get boxTextPos() { return this.__base && this.__base.box_size ? this.__base.box_pos.slice() : undefined; }, + get startIndent() { return this.__style("indentLeft", 0); }, + set startIndent(v) { this.__set("indentLeft", __num(v)); }, + get endIndent() { return this.__style("indentRight", 0); }, + set endIndent(v) { this.__set("indentRight", __num(v)); }, + get firstLineIndent() { return this.__style("indentFirst", 0); }, + set firstLineIndent(v) { this.__set("indentFirst", __num(v)); }, + get spaceBefore() { return this.__style("spaceBefore", 0); }, + set spaceBefore(v) { this.__set("spaceBefore", __num(v)); }, + get spaceAfter() { return this.__style("spaceAfter", 0); }, + set spaceAfter(v) { this.__set("spaceAfter", __num(v)); }, + resetCharStyle: function () { + var d = { font: "Inter", style: "Regular", size: 72, fill: [1, 1, 1, 1], stroke: [0, 0, 0, 1], strokeWidth: 0, applyFill: true, + applyStroke: false, tracking: 0, leading: "auto", baselineShift: 0, hScale: 100, vScale: 100, tsume: 0, fauxBold: false, + fauxItalic: false, allCaps: false, smallCaps: false }; + for (var k in d) this.__changed[k] = d[k]; + }, + resetParagraphStyle: function () { + var d = { justify: "left", indentLeft: 0, indentRight: 0, indentFirst: 0, spaceBefore: 0, spaceAfter: 0 }; + for (var k in d) this.__changed[k] = d[k]; + }, + toString: function () { return this.__text; }, + // `layer.setText` attributes to apply. + __attrs: function () { + var a = {}; + for (var k in this.__changed) a[k] = this.__changed[k]; + if (this.__textSet || !this.__base || this.__text !== this.__base.text) a.text = this.__text; + return a; + }, +}; + +// ------------------------------------------------------------------ properties + +function __propOf(layer, info) { + if (!info) return null; + if (info.isLayer) return layer; + if (info.kind === "group") return info.isMask ? new MaskPropertyGroup(layer, info.uid) : new PropertyGroup(layer, info.uid); + return new Property(layer, info.uid); +} + +function PropertyBase() {} +PropertyBase.prototype = { + __info: function () { return __get("node", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid }); }, + __ref: function (p) { + p = p || {}; + p.comp = this.__layer.__comp; + p.layer = this.__layer.__id; + p.prop = this.__uid; + return p; + }, + get name() { return this.__info().name; }, + set name(v) { + var i = this.__info(); + if (!i.instance) throw __err("Can not set the name of \"" + i.name + "\": it is not an indexed group member"); + __call("prop.renameGroup", this.__ref({ name: String(v) })); + }, + get matchName() { return this.__info().matchName; }, + get propertyIndex() { return this.__info().index; }, + get propertyDepth() { return this.__info().depth; }, + get propertyType() { return PropertyType[this.__info().propertyType]; }, + get parentProperty() { + var i = this.__info(); + if (i.depth <= 1) return this.__layer; + return __propOf(this.__layer, __get("node", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: i.parent })); + }, + get isModified() { return !!this.__info().isModified; }, + get canSetEnabled() { return !!this.__info().canSetEnabled; }, + get enabled() { return this.__info().enabled !== false; }, + set enabled(v) { + if (!this.canSetEnabled) throw __err("Can not set enabled on \"" + this.name + "\""); + __call("prop.setGroupEnabled", this.__ref({ value: !!v })); + }, + get active() { return this.enabled && this.__layer.enabled; }, + get elided() { return false; }, + get isEffect() { return !!this.__info().isEffect; }, + get isMask() { return !!this.__info().isMask; }, + get selected() { + var c = __get("item", { id: this.__layer.__comp }).selectedProperties; + for (var i = 0; i < c.length; i++) if (c[i][0] === this.__layer.__id && c[i][1] === this.__uid) return true; + return false; + }, + set selected(v) { + if (v) __call("prop.select", { comp: this.__layer.__comp, layer: this.__layer.__id, prop: this.__uid, add: true }); + }, + propertyGroup: function (n) { + var p = this; + n = n === undefined ? 1 : n; + for (var i = 0; i < n && p; i++) p = p.parentProperty; + return p || null; + }, + remove: function () { __call("prop.removeGroup", this.__ref()); }, + moveTo: function (i) { __call("prop.moveGroup", this.__ref({ index: __num(i, "index") })); }, + duplicate: function () { + var r = __call("prop.duplicateGroup", this.__ref()); + return __propOf(this.__layer, __get("node", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: r.prop })); + }, + toString: function () { return "[object " + this.__kind + "]"; }, +}; + +function PropertyGroup(layer, uid) { + this.__layer = layer; + this.__uid = uid; + this.__kind = "PropertyGroup"; + return new Proxy(this, __attr); +} +PropertyGroup.prototype = Object.create(PropertyBase.prototype); +PropertyGroup.prototype.constructor = PropertyGroup; +Object.defineProperty(PropertyGroup.prototype, "numProperties", { get: function () { return this.__info().numProperties; } }); +PropertyGroup.prototype.property = function (key) { + if (key === undefined || key === null) throw __err("property(): missing name or index"); + var k = typeof key === "number" ? key : String(key); + return __propOf(this.__layer, __get("child", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid, key: k })); +}; +PropertyGroup.prototype.canAddProperty = function (name) { + var r = __get("resolveAdd", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid, name: String(name) }); + return !r.error; +}; +PropertyGroup.prototype.addProperty = function (name) { + var L = this.__layer; + var r = __get("resolveAdd", { comp: L.__comp, layer: L.__id, uid: this.__uid, name: String(name) }); + if (r.error) throw __err(r.error); + var uid; + if (r.kind === "effect") uid = __call("effect.apply", { comp: L.__comp, layers: [L.__id], effect: r.effect }).effects[0]; + else if (r.kind === "mask") uid = __call("layer.addMask", { comp: L.__comp, layer: L.__id }).mask; + else if (r.kind === "shape") { + var p = { comp: L.__comp, layer: L.__id, kind: r.shape }; + if (r.group !== null && r.group !== undefined) p.group = r.group; + uid = __call("layer.addShapeItem", p).uid; + } else if (r.kind === "animator") { + uid = __call("layer.addTextAnimator", { comp: L.__comp, layer: L.__id, properties: [] }).animator; + } else if (r.kind === "animatorProperty") { + var before = this.numProperties; + __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property }); + return this.property(before + 1); + } else if (r.kind === "selector") { + var n0 = this.numProperties; + __call("layer.addTextSelector", { comp: L.__comp, layer: L.__id, animator: r.animator, kind: r.selector }); + return this.property(n0 + 1); + } else throw __err("Can not add \"" + name + "\" here"); + return __propOf(L, __get("node", { comp: L.__comp, layer: L.__id, uid: uid })); +}; + +function MaskPropertyGroup(layer, uid) { + this.__layer = layer; + this.__uid = uid; + this.__kind = "MaskPropertyGroup"; + return new Proxy(this, __attr); +} +MaskPropertyGroup.prototype = Object.create(PropertyGroup.prototype); +MaskPropertyGroup.prototype.constructor = MaskPropertyGroup; +(function () { + var modes = { "None": 6812, "Add": 6813, "Subtract": 6814, "Intersect": 6815, "Lighten": 6816, "Darken": 6817, "Difference": 6818 }; + function mask(o) { return { comp: o.__layer.__comp, layer: o.__layer.__id, mask: o.__uid }; } + Object.defineProperty(MaskPropertyGroup.prototype, "maskMode", { + get: function () { return modes[this.__info().maskMode]; }, + set: function (v) { + var p = mask(this); + for (var k in modes) if (modes[k] === v) p.mode = k; + if (!p.mode) throw __err("Bad MaskMode value " + v); + __call("layer.mask.mode", p); + }, + }); + Object.defineProperty(MaskPropertyGroup.prototype, "inverted", { + get: function () { return !!this.__info().inverted; }, + set: function (v) { var p = mask(this); p.value = !!v; __call("layer.mask.invert", p); }, + }); + Object.defineProperty(MaskPropertyGroup.prototype, "locked", { + get: function () { return !!this.__info().locked; }, + set: function (v) { var p = mask(this); p.value = !!v; __call("layer.mask.lock", p); }, + }); + Object.defineProperty(MaskPropertyGroup.prototype, "color", { get: function () { return this.__info().color; } }); + MaskPropertyGroup.prototype.remove = function () { __call("layer.mask.remove", mask(this)); }; +})(); + +function Property(layer, uid) { + this.__layer = layer; + this.__uid = uid; + this.__kind = "Property"; + return new Proxy(this, __attr); +} +Property.prototype = Object.create(PropertyBase.prototype); +Property.prototype.constructor = Property; +Property.prototype.property = function () { return null; }; +(function () { + var P = Property.prototype; + function def(name, get, set) { Object.defineProperty(P, name, { get: get, set: set }); } + def("propertyValueType", function () { return PropertyValueType[this.__info().valueType]; }); + def("value", function () { return this.valueAtTime(this.__layer.containingComp.time, false); }); + def("numKeys", function () { return this.__info().numKeys; }); + def("expression", function () { return this.__info().expression; }, function (v) { + if (!this.canSetExpression) throw __err("Can not set an expression on \"" + this.name + "\""); + __call("prop.setExpression", this.__ref({ expression: String(v) })); + }); + def("expressionEnabled", function () { return !!this.__info().expressionEnabled; }, function (v) { + __call("prop.setExpression", this.__ref({ enabled: !!v })); + }); + def("expressionError", function () { + var e = this.expression; + return e ? __get("exprError", { code: e }) : ""; + }); + def("canSetExpression", function () { return !!this.__info().canSetExpression; }); + def("canVaryOverTime", function () { return !!this.__info().canVaryOverTime; }); + def("isTimeVarying", function () { var i = this.__info(); return i.numKeys > 0 || !!i.expressionEnabled; }); + def("isSpatial", function () { return !!this.__info().spatial; }); + def("unitsText", function () { return this.__info().ui; }); + def("hasMin", function () { return this.__info().min !== undefined; }); + def("hasMax", function () { return this.__info().max !== undefined; }); + def("minValue", function () { var i = this.__info(); if (i.min === undefined) throw __err("property has no minimum"); return i.min; }); + def("maxValue", function () { var i = this.__info(); if (i.max === undefined) throw __err("property has no maximum"); return i.max; }); + def("dimensionsSeparated", function () { return !!this.__info().separated; }, function (v) { + __call("prop.separateDimensions", { comp: this.__layer.__comp, layer: this.__layer.__id, value: !!v }); + }); + def("isSeparationFollower", function () { return !!this.__info().separationFollower; }); + def("isSeparationLeader", function () { return this.__info().matchId === "position" && this.__info().depth === 2; }); + def("separationDimension", function () { var m = this.__info().matchId; return m === "positionY" ? 1 : m === "positionZ" ? 2 : 0; }); + def("separationLeader", function () { return this.isSeparationFollower ? this.parentProperty.property("ADBE Position") : null; }); + def("selectedKeys", function () { + var k = this.__keys(), out = []; + for (var i = 0; i < k.length; i++) if (k[i].selected) out.push(i + 1); + return out; + }); + def("isDropdownEffect", function () { return false; }); + + P.getSeparationFollower = function (dim) { + return this.parentProperty.property(["ADBE Position_0", "ADBE Position_1", "ADBE Position_2"][dim]); + }; + P.__keys = function () { return __get("keys", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid }); }; + P.__key = function (i) { + var k = this.__keys(); + i = __num(i, "keyIndex"); + if (i < 1 || i > k.length || Math.floor(i) !== i) throw __err("keyIndex " + i + " is out of range 1.." + k.length); + return k[i - 1]; + }; + // Script value → engine JSON for `prop.set` / `prop.addKey`. + P.__toEngine = function (v, info, time) { + var t = info.valueType; + if (t === "TEXT_DOCUMENT") { + var td = v instanceof TextDocument ? v : new TextDocument(String(v)); + return __get("textdoc", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid, time: time, attrs: td.__attrs() }); + } + if (t === "SHAPE") { + if (!v || !v.vertices) throw __err("value must be a Shape"); + var n = v.vertices.length; + function fit(a) { var o = []; for (var i = 0; i < n; i++) o.push(a && a[i] ? [a[i][0], a[i][1]] : [0, 0]); return o; } + return { vertices: fit(v.vertices), in_tangents: fit(v.inTangents), out_tangents: fit(v.outTangents), closed: v.closed !== false }; + } + if (t === "LAYER_INDEX") { + var idx = v && v.__id !== undefined ? v.index : __num(v); + if (!idx) return null; + return this.__layer.containingComp.layer(idx).__id; + } + if (info.type === "enum") return __num(v) - 1; + if (info.type === "bool") return typeof v === "boolean" ? v : __num(v) !== 0; + if (info.type === "color") { + if (!(v instanceof Array) || v.length < 3) throw __err("value must be an array of 3 or 4 numbers"); + return [v[0], v[1], v[2], v.length > 3 ? v[3] : 1]; + } + if (info.dims > 1) { + if (!(v instanceof Array)) throw __err("value must be an array of " + info.dims + " numbers"); + var o = []; + for (var d = 0; d < info.dims; d++) o.push(d < v.length ? __num(v[d]) : (d === 2 ? 0 : __num(v[v.length - 1]))); + return o; + } + if (v instanceof Array) throw __err("value must be a number, not an array"); + return __num(v); + }; + P.__fromEngine = function (v, info) { + if (info.valueType === "TEXT_DOCUMENT") return TextDocument.__from(v); + if (info.valueType === "SHAPE") { + var s = new Shape(); + s.vertices = v.vertices; + s.inTangents = v.inTangents; + s.outTangents = v.outTangents; + s.closed = v.closed; + return s; + } + if (info.type === "color" && v instanceof Array) return v.slice(); + return v; + }; + P.valueAtTime = function (t, preExpression) { + var info = this.__info(); + var v = __get("value", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid, time: __num(t, "time"), pre: !!preExpression }); + return this.__fromEngine(v, info); + }; + P.setValue = function (v) { + var info = this.__info(); + if (info.numKeys > 0) throw __err("Can not use setValue on \"" + info.name + "\": it has keyframes (use setValueAtTime or setValueAtKey)"); + if (info.hidden) throw __err("\"" + info.name + "\" can not be set"); + __call("prop.set", this.__ref({ value: this.__toEngine(v, info) })); + }; + P.setValueAtTime = function (t, v) { + var info = this.__info(); + t = __num(t, "time"); + __call("prop.addKey", this.__ref({ time: this.__layer.__layerTime(t), value: this.__toEngine(v, info, t) })); + }; + P.setValuesAtTimes = function (ts, vs) { + if (!(ts instanceof Array) || !(vs instanceof Array) || ts.length !== vs.length) throw __err("setValuesAtTimes: times and values must be arrays of the same length"); + for (var i = 0; i < ts.length; i++) this.setValueAtTime(ts[i], vs[i]); + }; + P.setValueAtKey = function (i, v) { + var k = this.__key(i), info = this.__info(); + __call("keys.set", this.__ref({ time: k.layerTime, value: this.__toEngine(v, info, k.time) })); + }; + P.addKey = function (t) { + t = __num(t, "time"); + __call("prop.addKey", this.__ref({ time: this.__layer.__layerTime(t) })); + return this.nearestKeyIndex(t); + }; + P.nearestKeyIndex = function (t) { + var k = this.__keys(), best = 0, d = Infinity; + if (!k.length) throw __err("\"" + this.name + "\" has no keyframes"); + for (var i = 0; i < k.length; i++) { + var e = Math.abs(k[i].time - t); + if (e < d) { d = e; best = i + 1; } + } + return best; + }; + P.keyTime = function (i) { return this.__key(i).time; }; + P.keyValue = function (i) { return this.__fromEngine(this.__key(i).value, this.__info()); }; + // Select key `i` (only) so the selection-based keyframe commands act on it. + P.__select = function (i) { + var k = this.__key(i); + __call("keys.select", { comp: this.__layer.__comp, keys: [{ layer: this.__layer.__id, prop: this.__uid, time: k.layerTime }] }); + return k; + }; + P.removeKey = function (i) { + this.__select(i); + __call("keys.delete", { comp: this.__layer.__comp }); + }; + P.keyInInterpolationType = function (i) { return KeyframeInterpolationType[this.__key(i).inInterp]; }; + P.keyOutInterpolationType = function (i) { return KeyframeInterpolationType[this.__key(i).outInterp]; }; + P.isInterpolationTypeValid = function (t) { return t === 6612 || t === 6613 || t === 6614; }; + P.setInterpolationTypeAtKey = function (i, inType, outType) { + if (outType === undefined) outType = inType; + if (!__interpKey[inType] || !__interpKey[outType]) throw __err("Bad KeyframeInterpolationType"); + this.__select(i); + __call("keys.interpolation", { comp: this.__layer.__comp, "in": __interpKey[inType], out: __interpKey[outType] }); + }; + P.keyInTemporalEase = function (i) { return this.__key(i).inEase.map(function (e) { return new KeyframeEase(e.speed, e.influence); }); }; + P.keyOutTemporalEase = function (i) { return this.__key(i).outEase.map(function (e) { return new KeyframeEase(e.speed, e.influence); }); }; + P.setTemporalEaseAtKey = function (i, inEase, outEase) { + if (outEase === undefined) outEase = inEase; + if (!(inEase instanceof Array) || !(outEase instanceof Array)) throw __err("setTemporalEaseAtKey: eases must be arrays of KeyframeEase"); + this.__select(i); + function sp(a) { return a.map(function (e) { return __num(e.speed, "speed"); }); } + function inf(a) { + return a.map(function (e) { + var x = __num(e.influence, "influence"); + if (x < 0.1 || x > 100) throw __err("influence must be between 0.1 and 100"); + return x; + }); + } + __call("keys.velocity", { comp: this.__layer.__comp, inSpeed: sp(inEase), inInfluence: inf(inEase), outSpeed: sp(outEase), outInfluence: inf(outEase) }); + }; + P.keyTemporalContinuous = function (i) { return !!this.__key(i).continuous; }; + P.setTemporalContinuousAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, continuous: !!b }); }; + P.keyTemporalAutoBezier = function (i) { return !!this.__key(i).autoBezier; }; + P.setTemporalAutoBezierAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, autoBezier: !!b }); }; + P.keyRoving = function (i) { return !!this.__key(i).roving; }; + P.setRovingAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, roving: !!b }); }; + P.keySpatialInTangent = function (i) { return this.__key(i).spatialIn.slice(0, this.__info().dims); }; + P.keySpatialOutTangent = function (i) { return this.__key(i).spatialOut.slice(0, this.__info().dims); }; + P.keySpatialContinuous = function (i) { return !!this.__key(i).spatialContinuous; }; + P.keySpatialAutoBezier = function (i) { return !!this.__key(i).spatialAutoBezier; }; + P.setSpatialTangentsAtKey = function (i, inT, outT) { + var k = this.__key(i); + __call("keys.setSpatialTangents", this.__ref({ time: k.layerTime, "in": inT, out: outT === undefined ? inT : outT })); + }; + P.setSpatialContinuousAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, spatial: b ? "continuousBezier" : "bezier" }); }; + P.setSpatialAutoBezierAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, spatial: b ? "autoBezier" : "bezier" }); }; + P.keySelected = function (i) { return !!this.__key(i).selected; }; + P.setSelectedAtKey = function (i, b) { + var k = this.__key(i); + if (b) { + __call("keys.select", { comp: this.__layer.__comp, add: true, keys: [{ layer: this.__layer.__id, prop: this.__uid, time: k.layerTime }] }); + } else { + var keep = [], all = this.__keys(); + for (var j = 0; j < all.length; j++) if (all[j].selected && j !== i - 1) keep.push({ layer: this.__layer.__id, prop: this.__uid, time: all[j].layerTime }); + __call("keys.select", { comp: this.__layer.__comp, keys: keep }); + } + }; + P.keyLabel = function () { return 0; }; + P.setLabelAtKey = function () {}; + P.addToMotionGraphicsTemplate = function () { return false; }; + P.canAddToMotionGraphicsTemplate = function () { return false; }; +})(); + +// Layer / composition markers (`layer.marker`, `comp.markerProperty`): keyframes are markers. +function MarkerProperty(comp, layer) { + this.__compId = comp; + this.__layerRef = layer; + this.__kind = "Property"; +} +MarkerProperty.prototype = { + constructor: MarkerProperty, + __p: function (o) { + o = o || {}; + o.comp = this.__compId; + if (this.__layerRef) o.layer = this.__layerRef.__id; + return o; + }, + __list: function () { return __get("markers", this.__p()); }, + get name() { return "Marker"; }, + get matchName() { return "ADBE Marker"; }, + get propertyValueType() { return PropertyValueType.MARKER; }, + get propertyType() { return PropertyType.PROPERTY; }, + get numKeys() { return this.__list().length; }, + get value() { return null; }, + get canSetExpression() { return false; }, + get canVaryOverTime() { return true; }, + get isTimeVarying() { return this.numKeys > 0; }, + get parentProperty() { return this.__layerRef || null; }, + __at: function (i) { + var l = this.__list(); + if (i < 1 || i > l.length) throw __err("marker index " + i + " is out of range 1.." + l.length); + return l[i - 1]; + }, + keyTime: function (i) { return this.__at(i).time; }, + keyValue: function (i) { + var m = this.__at(i); + var v = new MarkerValue(m.comment, m.chapter, m.url, m.frameTarget, m.cuePointName); + v.duration = m.duration; + v.eventCuePoint = m.eventCuePoint; + v.label = m.label; + v.protectedRegion = m.protectedRegion; + v.__params = m.params || []; + return v; + }, + nearestKeyIndex: function (t) { + var l = this.__list(), best = 0, d = Infinity; + for (var i = 0; i < l.length; i++) if (Math.abs(l[i].time - t) < d) { d = Math.abs(l[i].time - t); best = i + 1; } + if (!best) throw __err("there are no markers"); + return best; + }, + setValueAtTime: function (t, mv) { + t = __num(t, "time"); + if (!(mv instanceof MarkerValue)) throw __err("value must be a MarkerValue"); + var p = this.__p({ time: t, duration: mv.duration || 0, comment: mv.comment, chapter: mv.chapter, url: mv.url, frameTarget: mv.frameTarget, protected: !!mv.protectedRegion, label: mv.label || 0 }); + p.cuePoint = mv.cuePointName ? { name: mv.cuePointName, navigation: !mv.eventCuePoint, params: mv.__params } : null; + var l = this.__list(), at = -1; + for (var i = 0; i < l.length; i++) if (Math.abs(l[i].time - t) < 1e-6) at = i; + if (at >= 0) p.index = at; + else p["new"] = true; + __call("markers.set", p); + }, + setValueAtKey: function (i, mv) { this.setValueAtTime(this.keyTime(i), mv); }, + removeKey: function (i) { this.__at(i); __call("markers.delete", this.__p({ index: i - 1 })); }, + toString: function () { return "[object Property]"; }, +}; + +// ------------------------------------------------------------------ layers + +function __layer(compId, id) { + if (id === null || id === undefined) return null; + var i = __get("layer", { comp: compId, layer: id }); + switch (i.kind) { + case "text": return new TextLayer(compId, id); + case "shape": return new ShapeLayer(compId, id); + case "camera": return new CameraLayer(compId, id); + case "light": return new LightLayer(compId, id); + default: return new AVLayer(compId, id); + } +} + +function Layer() {} +Layer.prototype = { + __init: function (comp, id, kind) { + this.__comp = comp; + this.__id = id; + this.__kind = kind; + return new Proxy(this, __attr); + }, + __info: function () { return __get("layer", { comp: this.__comp, layer: this.__id }); }, + __p: function (o) { + o = o || {}; + o.comp = this.__comp; + o.layers = [this.__id]; + return o; + }, + __p1: function (o) { + o = o || {}; + o.comp = this.__comp; + o.layer = this.__id; + return o; + }, + __switch: function (sw, v) { __call("layer.setSwitch", this.__p({ "switch": sw, value: !!v })); }, + // Comp seconds → layer seconds (keyframe times are layer time). + __layerTime: function (t) { + var i = this.__info(); + return (t - i.startTime) * 100 / (i.stretch || 100); + }, + toString: function () { return "[object " + this.__kind + "]"; }, + get id() { return this.__id; }, + get name() { return this.__info().name; }, + set name(v) { __call("layer.rename", this.__p1({ name: String(v) })); }, + get index() { return this.__info().index; }, + get matchName() { return this.__info().matchName; }, + get containingComp() { return new CompItem(this.__comp); }, + get propertyDepth() { return 0; }, + get propertyType() { return PropertyType.INDEXED_GROUP; }, + get parentProperty() { return null; }, + get isModified() { return false; }, + get elided() { return false; }, + get isEffect() { return false; }, + get isMask() { return false; }, + get canSetEnabled() { return true; }, + get enabled() { return this.__info().enabled; }, + set enabled(v) { this.__switch("video", v); }, + get active() { return this.__info().active; }, + get solo() { return this.__info().solo; }, + set solo(v) { this.__switch("solo", v); }, + get locked() { return this.__info().locked; }, + set locked(v) { this.__switch("lock", v); }, + get shy() { return this.__info().shy; }, + set shy(v) { this.__switch("shy", v); }, + get audioEnabled() { return this.__info().audioEnabled; }, + set audioEnabled(v) { this.__switch("audio", v); }, + get hasVideo() { return this.__info().hasVideo; }, + get hasAudio() { return this.__info().hasAudio; }, + get audioActive() { var i = this.__info(); return i.hasAudio && i.audioEnabled; }, + get nullLayer() { return this.__info().nullLayer; }, + get isNameSet() { return true; }, + get inPoint() { return this.__info().inPoint; }, + set inPoint(v) { __call("layer.timing", this.__p({ "in": __num(v, "inPoint") })); }, + get outPoint() { return this.__info().outPoint; }, + set outPoint(v) { __call("layer.timing", this.__p({ out: __num(v, "outPoint") })); }, + get startTime() { return this.__info().startTime; }, + set startTime(v) { __call("layer.timing", this.__p({ start: __num(v, "startTime") })); }, + get stretch() { return this.__info().stretch; }, + set stretch(v) { __call("layer.timeStretch", this.__p({ percent: __num(v, "stretch") })); }, + get time() { return this.__info().time; }, + get label() { return this.__info().label; }, + set label(v) { + var names = __get("labels"); + var n = names[__num(v, "label")]; + if (n === undefined) throw __err("label must be 0-16"); + __call("edit.label", this.__p({ label: n })); + }, + get comment() { return this.__info().comment; }, + get selected() { return this.__info().selected; }, + set selected(v) { + var sel = this.containingComp.__info().selectedLayers; + var on = sel.indexOf(this.__id) >= 0; + if (!!v !== on) __call("layer.select", { comp: this.__comp, layers: [this.__id], toggle: true }); + }, + get selectedProperties() { + var c = this.containingComp.__info().selectedProperties, out = []; + for (var i = 0; i < c.length; i++) if (c[i][0] === this.__id) out.push(__propOf(this, __get("node", { comp: this.__comp, layer: this.__id, uid: c[i][1] }))); + return out; + }, + get parent() { var p = this.__info().parent; return p === null ? null : __layer(this.__comp, p); }, + set parent(v) { __call("layer.setParent", this.__p({ parent: v ? v.__id : null })); }, + setParentWithJump: function (v) { __call("layer.setParent", this.__p({ parent: v ? v.__id : null, compensate: false })); }, + get autoOrient() { return AutoOrientType[{ Off: "NO_AUTO_ORIENT", AlongPath: "ALONG_PATH", TowardsCamera: "CHARACTERS_TOWARD_CAMERA", TowardsPointOfInterest: "CAMERA_OR_POINT_OF_INTEREST" }[this.__info().autoOrient]]; }, + set autoOrient(v) { + var m = { 4212: "off", 4213: "alongPath", 4214: "towardsPointOfInterest", 4215: "towardsCamera" }[v]; + if (!m) throw __err("Bad AutoOrientType value " + v); + __call("layer.autoOrient", this.__p({ mode: m })); + }, + get marker() { return new MarkerProperty(this.__comp, this); }, + get numProperties() { return __get("node", { comp: this.__comp, layer: this.__id, uid: 0 }).numProperties; }, + property: function (key) { + if (key === undefined || key === null) throw __err("property(): missing name or index"); + if (key === "Marker" || key === "ADBE Marker" || key === "marker") return this.marker; + var k = typeof key === "number" ? key : String(key); + return __propOf(this, __get("child", { comp: this.__comp, layer: this.__id, uid: 0, key: k })); + }, + propertyGroup: function () { return null; }, + canAddProperty: function () { return false; }, + addProperty: function (n) { throw __err("Can not add \"" + n + "\" to a layer: use a property group (Effects, Masks, Contents)"); }, + remove: function () { __call("edit.clear", this.__p()); }, + duplicate: function () { + var before = this.containingComp.__info().layers; + __call("edit.duplicate", this.__p()); + var after = this.containingComp.__info().layers; + for (var i = 0; i < after.length; i++) if (before.indexOf(after[i]) < 0) return __layer(this.__comp, after[i]); + return null; + }, + copyToComp: function (comp) { + var c = __call("edit.copy", this.__p()); + var before = comp.__info().layers; + __call("comp.open", { comp: comp.__id }); + __call("edit.paste", { comp: comp.__id }); + return c; + }, + moveToBeginning: function () { __call("layer.arrange", this.__p({ to: "front" })); }, + moveToEnd: function () { __call("layer.arrange", this.__p({ to: "back" })); }, + moveBefore: function (other) { + var me = this.index, to = other.index; + __call("layer.arrange", this.__p({ to: "index", index: me < to ? to - 1 : to })); + }, + moveAfter: function (other) { + var me = this.index, to = other.index; + __call("layer.arrange", this.__p({ to: "index", index: me < to ? to : to + 1 })); + }, + activeAtTime: function (t) { var i = this.__info(); return i.enabled && t >= i.inPoint && t < i.outPoint; }, + applyPreset: function (f) { __call("anim.applyPreset", this.__p({ path: __str(f) })); }, +}; + +function AVLayer(comp, id) { return this.__init(comp, id, "AVLayer"); } +AVLayer.prototype = Object.create(Layer.prototype); +AVLayer.prototype.constructor = AVLayer; +(function () { + var P = AVLayer.prototype; + function def(name, get, set) { Object.defineProperty(P, name, { get: get, set: set }); } + def("source", function () { return __item(this.__info().source); }); + def("width", function () { return this.__info().width; }); + def("height", function () { return this.__info().height; }); + def("threeDLayer", function () { return this.__info().threeDLayer; }, function (v) { this.__switch("threeD", v); }); + def("adjustmentLayer", function () { return this.__info().adjustmentLayer; }, function (v) { this.__switch("adjustment", v); }); + def("guideLayer", function () { return this.__info().guideLayer; }, function (v) { this.__switch("guide", v); }); + def("motionBlur", function () { return this.__info().motionBlur; }, function (v) { this.__switch("motionBlur", v); }); + def("effectsActive", function () { return this.__info().effectsActive; }, function (v) { this.__switch("fx", v); }); + def("collapseTransformation", function () { return this.__info().collapse; }, function (v) { this.__switch("collapse", v); }); + def("preserveTransparency", function () { return this.__info().preserveTransparency; }, function (v) { this.__switch("preserveTransparency", v); }); + def("quality", function () { return LayerQuality[this.__info().quality.toUpperCase()]; }, function (v) { + var q = __enumKey(LayerQuality, v); + if (!q) throw __err("Bad LayerQuality value " + v); + __call("layer.quality", this.__p({ quality: q.toLowerCase() })); + }); + def("samplingQuality", function () { return LayerSamplingQuality[this.__info().samplingQuality.toUpperCase()]; }, function (v) { + var q = __enumKey(LayerSamplingQuality, v); + if (!q) throw __err("Bad LayerSamplingQuality value " + v); + __call("layer.sampling", this.__p({ sampling: q.toLowerCase() })); + }); + def("frameBlendingType", function () { return { Off: 4012, FrameMix: 4013, PixelMotion: 4014 }[this.__info().frameBlending]; }, function (v) { + var m = { 4012: "off", 4013: "frameMix", 4014: "pixelMotion" }[v]; + if (!m) throw __err("Bad FrameBlendingType value " + v); + __call("layer.frameBlending", this.__p({ mode: m })); + }); + def("frameBlending", function () { return this.__info().frameBlending !== "Off"; }); + def("blendingMode", function () { return __blendNames[this.__info().blendingMode]; }, function (v) { + __call("layer.setBlendMode", this.__p({ mode: __blendLabel(v) })); + }); + def("trackMatteType", function () { + var m = this.__info().trackMatte; + return m ? __matteEnum[m.kind] : TrackMatteType.NO_TRACK_MATTE; + }, function (v) { + if (v === TrackMatteType.NO_TRACK_MATTE) { __call("layer.trackMatte", this.__p({ op: "none" })); return; } + var k = __matteKind[v]; + if (!k) throw __err("Bad TrackMatteType value " + v); + var m = this.__info().trackMatte; + if (m) __call("layer.setTrackMatte", this.__p1({ matte: m.layer, kind: k })); + else __call("layer.trackMatte", this.__p({ op: k })); + }); + def("trackMatteLayer", function () { var m = this.__info().trackMatte; return m ? __layer(this.__comp, m.layer) : null; }); + def("hasTrackMatte", function () { return !!this.__info().trackMatte; }); + def("isTrackMatte", function () { return this.__info().isTrackMatte; }); + def("timeRemapEnabled", function () { return this.__info().timeRemapEnabled; }, function (v) { + __call("layer.enableTimeRemap", this.__p({ value: !!v })); + }); + def("canSetTimeRemapEnabled", function () { var s = this.source; return !!s && !(s instanceof FootageItem && s.mainSource.isStill); }); + def("canSetCollapseTransformation", function () { return true; }); + def("isNameFromSource", function () { var s = this.source; return !!s && s.name === this.name; }); + def("environmentLayer", function () { return false; }); + P.setTrackMatte = function (layer, type) { + if (!layer || type === TrackMatteType.NO_TRACK_MATTE) { this.removeTrackMatte(); return; } + var k = __matteKind[type]; + if (!k) throw __err("Bad TrackMatteType value " + type); + __call("layer.setTrackMatte", this.__p1({ matte: layer.__id, kind: k })); + }; + P.removeTrackMatte = function () { __call("layer.setTrackMatte", this.__p1({ matte: null })); }; + P.sourceRectAtTime = function (t, extents) { + return __get("sourceRect", { comp: this.__comp, layer: this.__id, time: __num(t, "time"), extents: !!extents }); + }; + P.sourcePointToComp = function (p) { return p; }; + P.compPointToSource = function (p) { return p; }; + P.replaceSource = function () { throw __err("replaceSource is not supported yet"); }; + P.openInViewer = function () { __call("layer.openLayer", this.__p1()); return null; }; +})(); + +function TextLayer(comp, id) { return this.__init(comp, id, "TextLayer"); } +TextLayer.prototype = Object.create(AVLayer.prototype); +TextLayer.prototype.constructor = TextLayer; +Object.defineProperty(TextLayer.prototype, "source", { get: function () { return null; } }); + +function ShapeLayer(comp, id) { return this.__init(comp, id, "ShapeLayer"); } +ShapeLayer.prototype = Object.create(AVLayer.prototype); +ShapeLayer.prototype.constructor = ShapeLayer; +Object.defineProperty(ShapeLayer.prototype, "source", { get: function () { return null; } }); + +function CameraLayer(comp, id) { return this.__init(comp, id, "CameraLayer"); } +CameraLayer.prototype = Object.create(Layer.prototype); +CameraLayer.prototype.constructor = CameraLayer; + +function LightLayer(comp, id) { return this.__init(comp, id, "LightLayer"); } +LightLayer.prototype = Object.create(Layer.prototype); +LightLayer.prototype.constructor = LightLayer; +Object.defineProperty(LightLayer.prototype, "lightType", { + get: function () { return LightType[this.__info().lightType.toUpperCase()]; }, + set: function (v) { + var k = __enumKey(LightType, v); + if (!k) throw __err("Bad LightType value " + v); + __call("layer.lightSettings", this.__p1({ kind: k.charAt(0) + k.substring(1).toLowerCase() })); + }, +}); + +// ------------------------------------------------------------------ collections + +function LayerCollection(comp) { + this.__comp = comp; + return new Proxy(this, __coll); +} +LayerCollection.prototype = { + constructor: LayerCollection, + __ids: function () { return __get("item", { id: this.__comp }).layers; }, + get length() { return this.__ids().length; }, + __at: function (i) { var ids = this.__ids(); return i >= 1 && i <= ids.length ? __layer(this.__comp, ids[i - 1]) : undefined; }, + __new: function (r) { return __layer(this.__comp, r.layer); }, + __dur: function (layer, duration) { + if (duration !== undefined && duration !== null) { + var i = layer.__info(); + __call("layer.timing", { comp: this.__comp, layers: [layer.__id], out: i.inPoint + __num(duration, "duration") }); + } + return layer; + }, + add: function (item, duration) { + if (!item || item.id === undefined) throw __err("add(): expected an item"); + var l = this.__new(__call("layer.addItem", { comp: this.__comp, item: item.id, time: 0 })); + return this.__dur(l, duration); + }, + addSolid: function (color, name, width, height, pixelAspect, duration) { + var p = { comp: this.__comp, color: [color[0], color[1], color[2]] }; + if (name !== undefined) p.name = String(name); + if (width !== undefined) p.width = Math.round(__num(width, "width")); + if (height !== undefined) p.height = Math.round(__num(height, "height")); + return this.__dur(this.__new(__call("layer.newSolid", p)), duration); + }, + addNull: function (duration) { return this.__dur(this.__new(__call("layer.newNull", { comp: this.__comp })), duration); }, + addText: function (text) { + var t = text instanceof TextDocument ? text : new TextDocument(text === undefined ? "" : String(text)); + var p = t.__attrs(); + p.comp = this.__comp; + if (p.text === undefined) p.text = t.text; + return this.__new(__call("layer.newText", p)); + }, + addBoxText: function (size, text) { + var c = __get("item", { id: this.__comp }); + var w = size[0], h = size[1]; + var t = text instanceof TextDocument ? text : new TextDocument(text === undefined ? "" : String(text)); + var p = t.__attrs(); + p.comp = this.__comp; + if (p.text === undefined) p.text = t.text; + p.box = [(c.width - w) / 2, (c.height - h) / 2, w, h]; + return this.__new(__call("layer.newText", p)); + }, + addShape: function () { return this.__new(__call("layer.newShape", { comp: this.__comp, kind: "none" })); }, + addCamera: function (name, center) { + var p = { comp: this.__comp, name: String(name) }; + if (center) p.poi = [center[0], center[1], 0]; + return this.__new(__call("layer.newCamera", p)); + }, + addLight: function (name, center) { + var p = { comp: this.__comp, name: String(name), kind: "Point" }; + if (center) p.poi = [center[0], center[1], 0]; + return this.__new(__call("layer.newLight", p)); + }, + precompose: function (indices, name, moveAllAttributes) { + var ids = this.__ids(), sel = []; + for (var i = 0; i < indices.length; i++) sel.push(ids[indices[i] - 1]); + var r = __call("layer.precompose", { comp: this.__comp, layers: sel, name: String(name), mode: moveAllAttributes === false ? "leave" : "move" }); + return new CompItem(r.comp); + }, + byName: function (name) { + var ids = this.__ids(); + for (var i = 0; i < ids.length; i++) { + var l = __get("layer", { comp: this.__comp, layer: ids[i] }); + if (l.name === name) return __layer(this.__comp, ids[i]); + } + return null; + }, + toString: function () { return "[object LayerCollection]"; }, +}; + +function ItemCollection(folder) { + this.__folder = folder; + return new Proxy(this, __coll); +} +ItemCollection.prototype = { + constructor: ItemCollection, + __ids: function () { + if (this.__folder === 0) return __get("project").items; + return __get("item", { id: this.__folder }).children; + }, + get length() { return this.__ids().length; }, + __at: function (i) { var ids = this.__ids(); return i >= 1 && i <= ids.length ? __item(ids[i - 1]) : undefined; }, + __place: function (id) { + if (this.__folder) __call("project.move", { items: [id], folder: this.__folder }); + return __item(id); + }, + addComp: function (name, width, height, pixelAspect, duration, frameRate) { + var r = __call("comp.new", { + name: String(name), width: Math.round(__num(width, "width")), height: Math.round(__num(height, "height")), + pixelAspect: __num(pixelAspect, "pixelAspect"), duration: __num(duration, "duration"), frameRate: __num(frameRate, "frameRate"), open: false, + }); + return this.__place(r.comp); + }, + addFolder: function (name) { + var r = __call("project.newFolder", { name: String(name) }); + var id = r && typeof r === "object" ? (r.folder || r.item || r.id) : r; + return this.__place(id); + }, + toString: function () { return "[object ItemCollection]"; }, +}; + +// ------------------------------------------------------------------ items + +function __item(id) { + if (id === null || id === undefined) return null; + if (id === 0) return new FolderItem(0); + var i = __get("item", { id: id }); + switch (i.kind) { + case "comp": return new CompItem(id); + case "folder": return new FolderItem(id); + default: return new FootageItem(id); + } +} + +function Item() {} +Item.prototype = { + __info: function () { return __get("item", { id: this.__id }); }, + toString: function () { return "[object " + this.__kind + "]"; }, + get id() { return this.__id; }, + get name() { return this.__info().name; }, + set name(v) { __call("project.rename", { item: this.__id, name: String(v) }); }, + get typeName() { return this.__info().typeName; }, + get comment() { return this.__info().comment; }, + set comment(v) { __call("project.setComment", { items: [this.__id], comment: String(v) }); }, + get label() { return this.__info().label; }, + set label(v) { __call("project.setLabel", { items: [this.__id], label: __num(v, "label") }); }, + get parentFolder() { return __item(this.__info().parent); }, + set parentFolder(f) { __call("project.move", { items: [this.__id], folder: f && f.__id ? f.__id : null }); }, + get selected() { return this.__info().selected; }, + set selected(v) { if (v) __call("project.select", { items: [this.__id], add: true }); }, + get usedIn() { return this.__info().usedIn.map(function (c) { return new CompItem(c); }); }, + remove: function () { __call("project.delete", { items: [this.__id] }); }, +}; + +function AVItem() {} +AVItem.prototype = Object.create(Item.prototype); +(function () { + function def(name, get, set) { Object.defineProperty(AVItem.prototype, name, { get: get, set: set }); } + def("width", function () { return this.__info().width; }, function (v) { this.__settings({ width: Math.round(__num(v, "width")) }); }); + def("height", function () { return this.__info().height; }, function (v) { this.__settings({ height: Math.round(__num(v, "height")) }); }); + def("pixelAspect", function () { return this.__info().pixelAspect; }, function (v) { this.__settings({ pixelAspect: __num(v) }); }); + def("duration", function () { return this.__info().duration; }, function (v) { this.__settings({ duration: __num(v, "duration") }); }); + def("frameRate", function () { return this.__info().frameRate; }, function (v) { this.__settings({ frameRate: __num(v, "frameRate") }); }); + def("frameDuration", function () { var f = this.__info().frameRate; return f ? 1 / f : 0; }, function (v) { this.__settings({ frameRate: 1 / __num(v) }); }); + def("hasVideo", function () { return this.__info().hasVideo; }); + def("hasAudio", function () { return this.__info().hasAudio; }); + def("footageMissing", function () { return !!this.__info().footageMissing; }); + def("useProxy", function () { return false; }); + def("proxySource", function () { return null; }); + def("isMediaReplacementCompatible", function () { return false; }); + def("time", function () { return 0; }); +})(); +AVItem.prototype.__settings = function () { throw __err("can't change this item's settings from a script"); }; + +function CompItem(id) { + this.__id = id; + this.__kind = "CompItem"; +} +CompItem.prototype = Object.create(AVItem.prototype); +CompItem.prototype.constructor = CompItem; +(function () { + var P = CompItem.prototype; + function def(name, get, set) { Object.defineProperty(P, name, { get: get, set: set }); } + P.__settings = function (o) { o.comp = this.__id; __call("comp.settings", o); }; + def("typeName", function () { return "Composition"; }); + def("layers", function () { return new LayerCollection(this.__id); }); + def("numLayers", function () { return this.__info().layers.length; }); + def("selectedLayers", function () { var c = this.__id; return this.__info().selectedLayers.map(function (l) { return __layer(c, l); }); }); + def("selectedProperties", function () { + var c = this.__id, out = []; + this.__info().selectedProperties.forEach(function (lp) { + var L = __layer(c, lp[0]); + out.push(__propOf(L, __get("node", { comp: c, layer: lp[0], uid: lp[1] }))); + }); + return out; + }); + def("activeCamera", function () { + var ids = this.__info().layers; + for (var i = 0; i < ids.length; i++) { + var l = __get("layer", { comp: this.__id, layer: ids[i] }); + if (l.kind === "camera" && l.enabled && l.active) return __layer(this.__id, ids[i]); + } + return null; + }); + def("time", function () { return this.__info().time; }, function (v) { __call("time.set", { comp: this.__id, time: __num(v, "time") }); }); + def("workAreaStart", function () { return this.__info().workAreaStart; }, function (v) { + var i = this.__info(); + __call("comp.workArea", { comp: this.__id, start: __num(v), end: __num(v) + i.workAreaDuration }); + }); + def("workAreaDuration", function () { return this.__info().workAreaDuration; }, function (v) { + var i = this.__info(); + __call("comp.workArea", { comp: this.__id, start: i.workAreaStart, end: i.workAreaStart + __num(v) }); + }); + def("bgColor", function () { return this.__info().bgColor; }, function (v) { this.__settings({ background: [v[0], v[1], v[2]] }); }); + def("displayStartTime", function () { return this.__info().displayStartTime; }, function (v) { this.__settings({ startTime: __num(v) }); }); + def("hideShyLayers", function () { return this.__info().hideShyLayers; }, function (v) { __call("comp.setSwitch", { comp: this.__id, "switch": "hideShy", value: !!v }); }); + def("motionBlur", function () { return this.__info().motionBlur; }, function (v) { __call("comp.setSwitch", { comp: this.__id, "switch": "motionBlur", value: !!v }); }); + def("frameBlending", function () { return this.__info().frameBlending; }, function (v) { __call("comp.setSwitch", { comp: this.__id, "switch": "frameBlending", value: !!v }); }); + def("draft3d", function () { return this.__info().draft3d; }, function (v) { __call("comp.setSwitch", { comp: this.__id, "switch": "draft3d", value: !!v }); }); + def("shutterAngle", function () { return this.__info().shutterAngle; }, function (v) { this.__settings({ shutterAngle: __num(v) }); }); + def("shutterPhase", function () { return this.__info().shutterPhase; }, function (v) { this.__settings({ shutterPhase: __num(v) }); }); + def("motionBlurSamplesPerFrame", function () { return this.__info().motionBlurSamplesPerFrame; }, function (v) { this.__settings({ motionBlurSamples: __num(v) }); }); + def("renderer", function () { return this.__info().renderer; }, function (v) { + this.__settings({ renderer: String(v).toLowerCase().indexOf("advanced") >= 0 ? "advanced3D" : "classic3D" }); + }); + def("renderers", function () { return ["ADBE Classic 3D", "ADBE Advanced 3d"]; }); + def("markerProperty", function () { return new MarkerProperty(this.__id, null); }); + def("hasVideo", function () { return true; }); + P.layer = function (a, b) { + var ids = this.__info().layers; + if (typeof a === "number") { + if (b !== undefined) { + // layer(otherLayer, relIndex) + throw __err("layer(index, relIndex) is not supported"); + } + if (a < 1 || a > ids.length || Math.floor(a) !== a) throw __err("layer index " + a + " is out of range 1.." + ids.length); + return __layer(this.__id, ids[a - 1]); + } + if (a && a.__id !== undefined && typeof b === "number") { + var j = a.index + b; + return j >= 1 && j <= ids.length ? __layer(this.__id, ids[j - 1]) : null; + } + return new LayerCollection(this.__id).byName(String(a)); + }; + P.openInViewer = function () { __call("comp.open", { comp: this.__id }); return null; }; + P.duplicate = function () { return __item(__call("project.duplicate", { items: [this.__id] }).items[0]); }; + P.exportAsMotionGraphicsTemplate = function () { return false; }; +})(); + +function FootageItem(id) { + this.__id = id; + this.__kind = "FootageItem"; +} +FootageItem.prototype = Object.create(AVItem.prototype); +FootageItem.prototype.constructor = FootageItem; +Object.defineProperty(FootageItem.prototype, "file", { + get: function () { var f = this.__info().file; return f ? new File(f) : null; }, +}); +Object.defineProperty(FootageItem.prototype, "mainSource", { + get: function () { + var i = this.__info(); + if (i.kind === "solid") return { isStill: true, color: i.color, hasAlpha: false, toString: function () { return "[object SolidSource]"; } }; + return { isStill: !!i.isStill, file: i.file ? new File(i.file) : null, missingFootagePath: i.footageMissing ? i.file : "", loop: i.loop, hasAlpha: false, toString: function () { return "[object FileSource]"; } }; + }, +}); +FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f) }); }; +FootageItem.prototype.replaceWithSolid = function (color, name, w, h) { + __call("file.replaceWithSolid", { item: this.__id, color: [color[0], color[1], color[2]], width: w, height: h }); + if (name !== undefined) this.name = name; +}; +FootageItem.prototype.openInViewer = function () { return null; }; + +function FolderItem(id) { + this.__id = id; + this.__kind = "FolderItem"; +} +FolderItem.prototype = Object.create(Item.prototype); +FolderItem.prototype.constructor = FolderItem; +(function () { + var P = FolderItem.prototype; + P.__info = function () { + if (this.__id === 0) return { id: 0, name: "Root", typeName: "Folder", comment: "", label: 0, parent: null, selected: false, usedIn: [], children: [] }; + return __get("item", { id: this.__id }); + }; + Object.defineProperty(P, "items", { get: function () { return new ItemCollection(this.__id); } }); + Object.defineProperty(P, "numItems", { get: function () { return new ItemCollection(this.__id).length; } }); + Object.defineProperty(P, "parentFolder", { + get: function () { return this.__id === 0 ? null : __item(this.__info().parent); }, + set: function (f) { __call("project.move", { items: [this.__id], folder: f && f.__id ? f.__id : null }); }, + }); + P.item = function (i) { + var v = new ItemCollection(this.__id).__at(__num(i, "index")); + if (v === undefined) throw __err("item index " + i + " is out of range"); + return v; + }; +})(); + +// ------------------------------------------------------------------ render queue + +function OutputModule(rq, n) { + this.__rq = rq; + this.__n = n; +} +OutputModule.prototype = { + constructor: OutputModule, + __info: function () { return this.__rq.__info(); }, + get name() { var i = this.__info(); return this.__n === 1 ? i.outputModuleSummary : i.extraOutputs[this.__n - 2].summary; }, + get file() { + var i = this.__info(); + var p = this.__n === 1 ? i.outputPath : i.extraOutputs[this.__n - 2].outputPath; + return p ? new File(p) : null; + }, + set file(f) { __call("renderQueue.setOutput", { item: this.__rq.__id, module: this.__n, path: __str(f) }); }, + get templates() { return ["H.264", "ProRes", "PNG Sequence", "JPEG Sequence", "TIFF Sequence", "OpenEXR Sequence", "GIF"]; }, + applyTemplate: function (name) { + var n = String(name).toLowerCase(); + var f = n.indexOf("prores") >= 0 ? "prores" : n.indexOf("png") >= 0 || n.indexOf("lossless") >= 0 ? "png" : n.indexOf("jpeg") >= 0 || n.indexOf("jpg") >= 0 ? "jpeg" + : n.indexOf("tiff") >= 0 ? "tiff" : n.indexOf("exr") >= 0 ? "exr" : n.indexOf("gif") >= 0 ? "gif" : n.indexOf("264") >= 0 ? "h264" : null; + if (!f) throw __err("Unknown output module template \"" + name + "\""); + __call("renderQueue.setOutputModule", { item: this.__rq.__id, module: this.__n, format: f }); + }, + remove: function () { throw __err("removing output modules is not supported"); }, + toString: function () { return "[object OutputModule]"; }, +}; + +var __rqStatus = { "Unqueued": 3014, "Queued": 3015, "Needs Output": 3013, "Rendering": 3016, "Done": 3019, "User Stopped": 3017, "Failed": 3018 }; + +function RenderQueueItem(id) { this.__id = id; } +RenderQueueItem.prototype = { + constructor: RenderQueueItem, + __info: function () { + var l = __call("renderQueue.list", {}).items; + for (var i = 0; i < l.length; i++) if (l[i].id === this.__id) return l[i]; + throw __err("the render queue item no longer exists"); + }, + get comp() { return new CompItem(this.__info().comp); }, + get status() { + var s = this.__info().statusLabel; + for (var k in __rqStatus) if (s.indexOf(k) === 0) return __rqStatus[k]; + return RQItemStatus.QUEUED; + }, + get render() { return this.__info().render; }, + set render(v) { __call("renderQueue.setRender", { item: this.__id, render: !!v }); }, + get numOutputModules() { return this.__info().outputModules; }, + get outputModules() { + var n = this.numOutputModules, out = []; + for (var i = 1; i <= n; i++) out.push(new OutputModule(this, i)); + return out; + }, + outputModule: function (i) { + if (i < 1 || i > this.numOutputModules) throw __err("output module index " + i + " is out of range"); + return new OutputModule(this, i); + }, + get timeSpanStart() { var s = this.__info().settings; return s.start !== undefined ? s.start : 0; }, + set timeSpanStart(v) { __call("renderQueue.setRenderSettings", { item: this.__id, timeSpan: "custom", start: __num(v), end: __num(v) + this.timeSpanDuration }); }, + get timeSpanDuration() { var s = this.__info().settings; return s.end !== undefined && s.start !== undefined ? s.end - s.start : this.comp.duration; }, + set timeSpanDuration(v) { var s = this.timeSpanStart; __call("renderQueue.setRenderSettings", { item: this.__id, timeSpan: "custom", start: s, end: s + __num(v) }); }, + get elapsedSeconds() { return this.__info().render_time || 0; }, + get startTime() { var s = this.__info().started; return s ? new Date(s * 1000) : null; }, + get logType() { return LogType.ERRORS_ONLY; }, + set logType(v) {}, + get skipFrames() { return 0; }, + outputModuleAdd: function () { __call("render.addOutputModule", { item: this.__id }); }, + remove: function () { __call("renderQueue.remove", { item: this.__id }); }, + duplicate: function () { + var before = __call("renderQueue.list", {}).items.map(function (i) { return i.id; }); + __call("renderQueue.duplicate", { item: this.__id }); + var after = __call("renderQueue.list", {}).items; + for (var i = 0; i < after.length; i++) if (before.indexOf(after[i].id) < 0) return new RenderQueueItem(after[i].id); + return null; + }, + applyTemplate: function () {}, + getSettings: function () { return this.__info().settings; }, + setSettings: function (o) { __call("renderQueue.setRenderSettings", Object.assign({ item: this.__id }, o)); }, + toString: function () { return "[object RenderQueueItem]"; }, +}; + +function RQItemCollection() { return new Proxy(this, __coll); } +RQItemCollection.prototype = { + constructor: RQItemCollection, + __list: function () { return __call("renderQueue.list", {}).items; }, + get length() { return this.__list().length; }, + __at: function (i) { var l = this.__list(); return i >= 1 && i <= l.length ? new RenderQueueItem(l[i - 1].id) : undefined; }, + add: function (comp) { + if (!(comp instanceof CompItem)) throw __err("add(): expected a CompItem"); + __call("renderQueue.add", { comp: comp.__id }); + var l = this.__list(); + return new RenderQueueItem(l[l.length - 1].id); + }, + toString: function () { return "[object RQItemCollection]"; }, +}; + +function RenderQueue() {} +RenderQueue.prototype = { + constructor: RenderQueue, + get items() { return new RQItemCollection(); }, + get numItems() { return new RQItemCollection().length; }, + get rendering() { return !!__call("renderQueue.list", {}).rendering; }, + get canQueueInAME() { return false; }, + get queueNotify() { return false; }, + set queueNotify(v) {}, + item: function (i) { + var v = new RQItemCollection().__at(__num(i, "index")); + if (v === undefined) throw __err("render queue index " + i + " is out of range"); + return v; + }, + render: function () { __call("renderQueue.render", { wait: true }); }, + stopRendering: function () { __call("renderQueue.stop", {}); }, + pauseRendering: function () {}, + showWindow: function () {}, + queueInAME: function () { throw __err("Adobe Media Encoder is not available: render with app.project.renderQueue.render()"); }, + toString: function () { return "[object RenderQueue]"; }, +}; + +// ------------------------------------------------------------------ project and app + +function ImportOptions(file) { + if (!(this instanceof ImportOptions)) return new ImportOptions(file); + this.file = file || null; + this.importAs = ImportAsType.FOOTAGE; + this.sequence = false; + this.forceAlphabetical = false; + this.rangeStart = 0; + this.rangeEnd = 0; +} +ImportOptions.prototype.canImportAs = function (t) { return t === ImportAsType.FOOTAGE; }; + +function Project() {} +Project.prototype = { + constructor: Project, + __info: function () { return __get("project"); }, + get items() { return new ItemCollection(0); }, + get numItems() { return this.__info().items.length; }, + get rootFolder() { return new FolderItem(0); }, + get activeItem() { return __item(this.__info().active); }, + get selection() { return this.__info().selection.map(__item); }, + get file() { var p = this.__info().path; return p ? new File(p) : null; }, + get dirty() { return this.__info().dirty; }, + get renderQueue() { return new RenderQueue(); }, + get bitsPerChannel() { return this.__info().bitsPerChannel; }, + set bitsPerChannel(v) { __call("file.projectSettings", { bitDepth: __num(v) }); }, + get linearBlending() { return this.__info().linearBlending; }, + set linearBlending(v) { __call("file.projectSettings", { blendLinear: !!v }); }, + get linearizeWorkingSpace() { return this.__info().linearizeWorkingSpace; }, + set linearizeWorkingSpace(v) { __call("file.projectSettings", { linearize: !!v }); }, + get workingSpace() { return this.__info().workingSpace; }, + set workingSpace(v) { __call("file.projectSettings", { workingSpace: String(v) || "none" }); }, + get timeDisplayType() { return this.__info().timeDisplayFrames ? TimeDisplayType.FRAMES : TimeDisplayType.TIMECODE; }, + set timeDisplayType(v) { __call("file.projectSettings", { timeDisplay: v === TimeDisplayType.FRAMES ? "frames" : "timecode" }); }, + get expressionEngine() { return "javascript-1.0"; }, + set expressionEngine(v) {}, + item: function (i) { + var v = new ItemCollection(0).__at(__num(i, "index")); + if (v === undefined) throw __err("item index " + i + " is out of range 1.." + this.numItems); + return v; + }, + itemByID: function (id) { + try { return __item(__num(id)); } catch (e) { return null; } + }, + layerByID: function () { return null; }, + save: function (file) { + if (file) __call("file.saveAs", { path: __str(file) }); + else __call("file.save", {}); + return true; + }, + saveWithDialog: function () { if (this.file) return this.save(); return false; }, + close: function (opt) { + if (opt === CloseOptions.SAVE_CHANGES && this.file) this.save(); + __call("file.newProject", {}); + return true; + }, + importFile: function (opts) { + var f = opts instanceof ImportOptions ? opts.file : opts; + if (!f) throw __err("importFile(): no file"); + var r = __call("file.import", { paths: [__str(f)] }); + if (r.errors && r.errors.length) throw __err(r.errors.join("; ")); + return __item(r.items[0]); + }, + importFileWithDialog: function () { return null; }, + importPlaceholder: function (name, w, h, rate, dur) { + var r = __call("file.importPlaceholder", { name: String(name), width: w, height: h, frameRate: rate, duration: dur }); + var id = r && typeof r === "object" ? (r.item || r.id) : r; + return __item(id); + }, + consolidateFootage: function () { return __call("file.consolidateFootage", {}); }, + removeUnusedFootage: function () { return __call("file.removeUnusedFootage", {}); }, + reduceProject: function (items) { + __call("project.select", { items: items.map(function (i) { return i.__id; }) }); + return __call("file.reduceProject", {}); + }, + autoFixExpressions: function () {}, + showWindow: function () {}, + toString: function () { return "[object Project]"; }, +}; + +var __tasks = {}; +var app = { + get project() { return new Project(); }, + get version() { return __get("app").version; }, + get buildName() { return __get("app").buildName; }, + get buildNumber() { return 0; }, + get isWatchFolder() { return false; }, + get isRenderEngine() { return false; }, + get isoLanguage() { return "en_US"; }, + get language() { return 0; }, + get memoryInUse() { return 0; }, + get effects() { return __get("effects").map(function (e) { return { displayName: e.name, matchName: e.matchName, category: e.category, version: "1.0" }; }); }, + get activeViewer() { return null; }, + get availableGPUAccelTypes() { return []; }, + exitAfterLaunchAndEval: false, + exitCode: 0, + saveProjectOnCrash: true, + onError: null, + settings: { + __s: {}, + haveSetting: function (sec, key) { return (sec + "/" + key) in this.__s; }, + getSetting: function (sec, key) { return this.__s[sec + "/" + key] || ""; }, + saveSetting: function (sec, key, v) { this.__s[sec + "/" + key] = String(v); }, + }, + preferences: { + havePref: function () { return false; }, + getPrefAsString: function () { return ""; }, + getPrefAsLong: function () { return 0; }, + getPrefAsBool: function () { return false; }, + getPrefAsFloat: function () { return 0; }, + savePrefAsString: function () {}, + savePrefAsLong: function () {}, + savePrefAsBool: function () {}, + savePrefAsFloat: function () {}, + reload: function () {}, + }, + newProject: function () { __call("file.newProject", {}); return new Project(); }, + open: function (file) { + if (!file) return null; + __call("file.open", { path: __str(file) }); + return new Project(); + }, + quit: function () {}, + beginUndoGroup: function (name) { __undo("begin", name === undefined ? "Script" : String(name)); }, + endUndoGroup: function () { __undo("end", ""); }, + beginSuppressDialogs: function () {}, + endSuppressDialogs: function () {}, + purge: function () { __call("edit.purge", { what: "all" }); }, + findMenuCommandId: function (name) { return __get("menuCommandId", { name: String(name) }); }, + executeCommand: function (id) { + var cmd = typeof id === "number" ? __get("commandAt", { index: id }) : String(id); + if (!cmd) throw __err("Unknown command id " + id); + return __call(cmd, {}); + }, + scheduleTask: function (code, delay, repeat) { return __task("schedule", String(code), !!repeat); }, + cancelTask: function (id) { __task("cancel", id); }, + setMemoryUsageLimits: function () {}, + setMultiFrameRenderingConfig: function () {}, + setSavePreferencesOnQuit: function () {}, + activate: function () {}, + watchFolder: function () { throw __err("watch folders are not supported"); }, + pauseWatchFolder: function () {}, + endWatchFolder: function () {}, + parseSwatchFile: function () { throw __err("parseSwatchFile is not supported"); }, + // EffectCraft: run any engine command by id with parameters (see `command.list`). + run: function (id, params) { return __call(String(id), params || {}); }, + toString: function () { return "[object Application]"; }, +}; + +// The completion value of a script, as JSON text for the host. +function __result(v) { + if (v === undefined) return undefined; + if (v === null || typeof v === "number" || typeof v === "boolean" || typeof v === "string") return JSON.stringify(v); + if (v instanceof Layer || v instanceof Item || v instanceof PropertyBase || v instanceof Project || v instanceof TextDocument) { + var d = { type: v.toString() }; + try { d.name = v.name; } catch (e) {} + if (v instanceof TextDocument) d.text = v.text; + return JSON.stringify(d); + } + try { + var s = JSON.stringify(v); + return s === undefined ? JSON.stringify(String(v)) : s; + } catch (e) { + return JSON.stringify(String(v)); + } +} diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs new file mode 100644 index 00000000..61b49595 --- /dev/null +++ b/crates/script/src/runtime.rs @@ -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, + pub error: Option, +} + +/// An uncaught script error. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ScriptError { + pub message: String, + pub line: Option, + pub column: Option, + 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, + /// Open undo groups: (name, project snapshot when the outermost group began). + groups: Vec<(String, Arc)>, + /// `app.scheduleTask` queue: (id, code, repeat). + tasks: Vec<(u32, String, bool)>, + next_task: u32, + name: String, +} + +thread_local! { + static ACTIVE: RefCell> = 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>> = const { RefCell::new(None) }; +} + +fn with_active(f: impl FnOnce(&mut Active) -> Result) -> Result { + 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) -> boa_engine::JsError { + JsNativeError::error().with_message(msg.into()).into() +} + +fn arg_string(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) { + 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 { + 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 { + 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 { + 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 { + 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 = 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 = 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 { + 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); 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, Option) { + 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 { + 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 +} diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs new file mode 100644 index 00000000..f4a4fcb6 --- /dev/null +++ b/crates/script/src/tests.rs @@ -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"]); +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 99bc7c5e..d1a40946 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -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 { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index da5eaec2..32d471b3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -697,7 +697,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("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, diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 3f737502..bdd05305 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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), diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 1824fec5..e9a40e61 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -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 { diff --git a/crates/ui-egui/src/panels/script_console.rs b/crates/ui-egui/src/panels/script_console.rs new file mode 100644 index 00000000..3c258f6a --- /dev/null +++ b/crates/ui-egui/src/panels/script_console.rs @@ -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, + /// Previously run inputs (Up/Down recall). + #[serde(default)] + pub history: Vec, +} + +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) { + 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); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d1350103..7d423b8a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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, 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"), diff --git a/crates/ui-egui/tests/ui_script_console.rs b/crates/ui-egui/tests/ui_script_console.rs new file mode 100644 index 00000000..a8068c9b --- /dev/null +++ b/crates/ui-egui/tests/ui_script_console.rs @@ -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()); +} diff --git a/docs/agents.md b/docs/agents.md index 510bc356..40dac3c9 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 033ed6cb..dceb0e0c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 `-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, diff --git a/docs/parity.md b/docs/parity.md index d259fec2..02fa2175 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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) | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 76ff6fa3..2532f2a2 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -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"), ];