M14.4: scripting with an After Effects-style JavaScript object model
New crate effectcraft-script (L4): boa runtime with app/Project/CompItem/ Layer/PropertyGroup/Property/TextDocument/MarkerValue/RenderQueue/File object model over engine commands (undoable, undo groups), AE match-name table, file/network security gate. Entry points: script.run command, File > Scripts > Run Script File (.jsx/.js), Window > Script Console, effectcraft-cli script, MCP run_script. Engine: project.delete/duplicate, prop.renameGroup/setGroupEnabled/removeGroup/duplicateGroup/moveGroup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e911cc5c93
commit
78cd313d3c
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
//! effectcraft-cli render F.ecproj --queue render the project's Render Queue
|
||||
//! effectcraft-cli bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu] render timings
|
||||
//! (--gpu: CPU vs GPU ms/frame for every comp at Full and Half)
|
||||
//! effectcraft-cli script FILE.jsx [F.ecproj] | --eval CODE run an After Effects-style script
|
||||
//! effectcraft-cli mcp [--bridge PORT] MCP server on stdio
|
||||
//!
|
||||
//! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty)
|
||||
|
|
@ -32,7 +33,7 @@ use effectcraft_automation::tools::{self, Reply};
|
|||
use effectcraft_automation::{Backend, McpServer};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|set|render-frame|render|mcp> [args] [--json]
|
||||
const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|set|render-frame|render|script|mcp> [args] [--json]
|
||||
info project + engine summary
|
||||
commands [--filter TEXT] [--enabled] list engine commands
|
||||
exec <command-id> [--params JSON] run one engine command
|
||||
|
|
@ -47,6 +48,9 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
|||
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu] per-layer/effect render timings;
|
||||
--play N renders N consecutive frames with/without the layer cache;
|
||||
--gpu compares CPU and GPU ms/frame for every comp at Full and Half
|
||||
script FILE.jsx [F.ecproj] | --eval CODE run JavaScript with the After Effects-style object model
|
||||
(app.project, comps, layers, properties…); prints writeLn
|
||||
output and the result; errors exit 1 with file:line:col
|
||||
mcp [--bridge PORT] MCP server (JSON-RPC over stdio)
|
||||
options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge PORT --json --gpu
|
||||
<comp>: id or name, '-' = active comp; <layer>: id, '#n' or name; <value>: JSON or bare string";
|
||||
|
|
@ -68,6 +72,7 @@ const VALUED: &[&str] = &[
|
|||
"--out",
|
||||
"--expression",
|
||||
"--depth",
|
||||
"--eval",
|
||||
// render
|
||||
"--format",
|
||||
"--start",
|
||||
|
|
@ -243,6 +248,49 @@ fn tool(b: &mut Backend, name: &str, a: Value) -> Result<Value, Failure> {
|
|||
}
|
||||
}
|
||||
|
||||
/// `script FILE.jsx [project]` / `script --eval CODE`: run JavaScript against a headless session
|
||||
/// (an empty project unless one is given, or `--demo`) or the running app (`--bridge`).
|
||||
fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
let (code, name) = match (args.opt("--eval"), args.pos.first()) {
|
||||
(Some(c), _) => (c.to_string(), "eval".to_string()),
|
||||
(None, Some(f)) => (std::fs::read_to_string(f).map_err(|e| Failure::Error(format!("cannot read {f}: {e}")))?, f.clone()),
|
||||
(None, None) => return usage_err("script needs a .jsx/.js file or --eval CODE"),
|
||||
};
|
||||
let mut b = backend(args, false)?;
|
||||
let r = b.exec("script.run", json!({"code": code, "name": name}))?;
|
||||
let saved = maybe_save(&mut b, args)?;
|
||||
if json_out {
|
||||
let mut o = r.clone();
|
||||
if let Some(p) = &saved {
|
||||
o["saved"] = json!(p);
|
||||
}
|
||||
emit(&o, true);
|
||||
} else {
|
||||
if let Some(out) = r["output"].as_str().filter(|o| !o.is_empty()) {
|
||||
println!("{out}");
|
||||
}
|
||||
if !r["result"].is_null() && r["error"].is_null() {
|
||||
println!("{}", r["result"]);
|
||||
}
|
||||
if let Some(p) = saved {
|
||||
eprintln!("saved {p}");
|
||||
}
|
||||
}
|
||||
if let Some(e) = r.get("error").filter(|e| !e.is_null()) {
|
||||
let pos = match (e["line"].as_u64(), e["column"].as_u64()) {
|
||||
(Some(l), Some(c)) => format!(":{l}:{c}"),
|
||||
(Some(l), None) => format!(":{l}"),
|
||||
_ => String::new(),
|
||||
};
|
||||
let msg = format!("{}{pos}: {}", e["file"].as_str().unwrap_or(&name), e["message"].as_str().unwrap_or("script error"));
|
||||
if json_out {
|
||||
std::process::exit(1);
|
||||
}
|
||||
return Err(Failure::Error(msg));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Print a result: compact JSON with `--json`, else pretty JSON.
|
||||
fn emit(v: &Value, json_out: bool) {
|
||||
if json_out {
|
||||
|
|
@ -315,6 +363,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
}
|
||||
}
|
||||
}
|
||||
"script" => script_cmd(args, json_out)?,
|
||||
"exec" => {
|
||||
let Some(id) = args.pos.first().cloned() else { return usage_err("exec needs a command id") };
|
||||
let params = match (args.opt("--params"), args.pos.get(1).map(String::as_str)) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,3 +14,6 @@ effectcraft-engine = { workspace = true }
|
|||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
effectcraft-script = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,15 @@ fn execute_command(b: &mut Backend, a: &Value) -> Result<Reply> {
|
|||
json_reply(b.exec(id, params)?)
|
||||
}
|
||||
|
||||
fn run_script(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
let code = get(a, "code").and_then(Value::as_str).ok_or_else(|| Error::BadArgs("missing `code` (JavaScript)".into()))?;
|
||||
let mut p = json!({"code": code});
|
||||
if let Some(n) = get(a, "name") {
|
||||
p["name"] = n.clone();
|
||||
}
|
||||
json_reply(b.exec("script.run", p)?)
|
||||
}
|
||||
|
||||
fn get_project(b: &mut Backend, _: &Value) -> Result<Reply> {
|
||||
json_reply(b.exec("project.summary", json!({}))?)
|
||||
}
|
||||
|
|
@ -386,6 +395,21 @@ static TOOLS: &[ToolDef] = &[
|
|||
},
|
||||
run: execute_command,
|
||||
},
|
||||
ToolDef {
|
||||
name: "run_script",
|
||||
description: "Run JavaScript with an After Effects-style scripting object model (app, app.project, items.addComp, comp.layers.addSolid/addText/addShape/addNull/addCamera/addLight, layer.property(\"ADBE Transform Group\").property(\"ADBE Position\").setValueAtTime(t, v), Effects.addProperty(\"ADBE Gaussian Blur 2\"), expressions, markers, renderQueue…). Every edit is an undoable engine command; app.beginUndoGroup/endUndoGroup make one undo step. Returns {ok, result (last expression value), output (writeLn/$.writeln/alert text), error: {message, line, column} | null}. File access is limited to the project folder unless the user allows scripts to write files.",
|
||||
bridge_only: false,
|
||||
schema: || {
|
||||
schema(
|
||||
json!({
|
||||
"code": {"type": "string", "description": "JavaScript source, e.g. `var c = app.project.items.addComp(\"Main\", 1920, 1080, 1, 5, 30); c.layers.addText(\"Hi\"); c.numLayers`."},
|
||||
"name": {"type": "string", "description": "Script name used in error messages (default `script`)."}
|
||||
}),
|
||||
&["code"],
|
||||
)
|
||||
},
|
||||
run: run_script,
|
||||
},
|
||||
ToolDef {
|
||||
name: "get_project",
|
||||
description: "Project overview: file path, dirty flag, bit depth, every item (id, name, type Composition/Footage/Solid/Folder, size, duration, layer count), the active comp id, current time (s), selected layers and the undo stack.",
|
||||
|
|
|
|||
|
|
@ -358,9 +358,30 @@ fn collect_files(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
// ---------------------------------------------------------------- scripts
|
||||
|
||||
/// Run a command script: a JSON array or JSON lines of `{"command": id, "params": {…}}` (also
|
||||
/// accepts the control channel's `{"method": "engine.execute", "params": {"id", "params"}}`).
|
||||
/// File ▸ Scripts ▸ Run Script File…: `.jsx` / `.js` files run as JavaScript against the After
|
||||
/// Effects-style object model ([`Session::script`]); anything else is a command script: a JSON
|
||||
/// array or JSON lines of `{"command": id, "params": {…}}` (also accepts the control channel's
|
||||
/// `{"method": "engine.execute", "params": {"id", "params"}}`).
|
||||
fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(path) = str_p(p, "path")
|
||||
&& p.get("steps").is_none()
|
||||
&& is_js_script(path)
|
||||
{
|
||||
if path.to_ascii_lowercase().ends_with(".jsxbin") {
|
||||
return Err(bad("file.runScript", "binary .jsxbin scripts are not supported: run the .jsx source"));
|
||||
}
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
let code = String::from_utf8_lossy(&bytes).to_string();
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let out = run_js(s, &code, path, false)?;
|
||||
if let Some(e) = out.get("error").filter(|e| !e.is_null()) {
|
||||
let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default();
|
||||
let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error");
|
||||
s.events.push(crate::Event::Toast { message: format!("{name}{line}: {msg}"), error: true });
|
||||
return Err(EngineError::Other(format!("{name}{line}: {msg}")));
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
let steps: Vec<Value> = match (p.get("steps"), str_p(p, "path")) {
|
||||
(Some(Value::Array(a)), _) => a.clone(),
|
||||
(_, Some(path)) => {
|
||||
|
|
@ -388,7 +409,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
_ => return Err(bad("file.runScript", format!("step {}: needs `command`", i + 1))),
|
||||
};
|
||||
if id == "file.runScript" {
|
||||
if id == "file.runScript" || id == "script.run" {
|
||||
return Err(bad("file.runScript", "scripts can't run scripts"));
|
||||
}
|
||||
let r = s.execute(&id, params).map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?;
|
||||
|
|
@ -397,6 +418,28 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"steps": results.len(), "results": results}))
|
||||
}
|
||||
|
||||
/// `.jsx` / `.js` (and `.jsxbin`, rejected) are JavaScript; `.json` / `.jsonl` / other files are
|
||||
/// command scripts.
|
||||
fn is_js_script(path: &str) -> bool {
|
||||
let l = path.to_ascii_lowercase();
|
||||
l.ends_with(".jsx") || l.ends_with(".js") || l.ends_with(".jsxbin")
|
||||
}
|
||||
|
||||
/// Run JavaScript through the session's scripting engine.
|
||||
fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result<Value> {
|
||||
let runner = s.script.ok_or_else(|| EngineError::Other("JavaScript scripting is not available in this build".into()))?;
|
||||
Ok(runner(s, &crate::ScriptRequest { code, name, console }))
|
||||
}
|
||||
|
||||
/// `script.run`: run JavaScript (the After Effects scripting object model: `app.project`, comps,
|
||||
/// layers, properties…) and report `{ok, result, output, error}`; script errors are reported in
|
||||
/// `error` (with line and column), not as a command failure.
|
||||
fn script_run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let code = str_p(p, "code").ok_or_else(|| bad("script.run", "missing `code` (JavaScript)"))?.to_string();
|
||||
let name = str_p(p, "name").unwrap_or("script").to_string();
|
||||
run_js(s, &code, &name, super::b_p(p, "console").unwrap_or(false))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- footage
|
||||
|
||||
fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -638,10 +681,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Run Script File...",
|
||||
["File", "Scripts"],
|
||||
None,
|
||||
"{path (.jsonl/.json command script) | steps: [{command, params}]}",
|
||||
"{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | steps: [{command, params}]}",
|
||||
always,
|
||||
run_script
|
||||
),
|
||||
cmd!(
|
||||
"script.run",
|
||||
"Run Script",
|
||||
[],
|
||||
None,
|
||||
"{code (JavaScript: app, app.project, CompItem, Layer, Property… like After Effects scripting), name?, console? (Script Console context)} → {ok, result, output, error: {message, line, column} | null}",
|
||||
always,
|
||||
script_run
|
||||
),
|
||||
cmd!(
|
||||
"file.interpretFootage",
|
||||
"Main...",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ pub mod paint;
|
|||
mod paths;
|
||||
mod project_items;
|
||||
mod prop;
|
||||
mod prop_groups;
|
||||
pub mod puppet;
|
||||
mod query;
|
||||
mod render_queue;
|
||||
|
|
@ -105,6 +106,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(text_edit::specs());
|
||||
v.extend(layer_time::specs());
|
||||
v.extend(prop::specs());
|
||||
v.extend(prop_groups::specs());
|
||||
v.extend(project_items::specs());
|
||||
v.extend(anim::specs());
|
||||
v.extend(anim_tools::specs());
|
||||
|
|
|
|||
|
|
@ -140,6 +140,92 @@ fn set_comment(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Delete project items (folders with their contents). Layers that use a deleted item and Render
|
||||
/// Queue items of deleted comps go too, like After Effects' Edit ▸ Clear in the Project panel.
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "project.delete";
|
||||
let ids = items_p(s, p, c)?;
|
||||
let mut doomed: Vec<ItemId> = vec![];
|
||||
for id in s.project.items.keys() {
|
||||
if ids.iter().any(|d| is_within(&s.project, *id, *d)) {
|
||||
doomed.push(*id);
|
||||
}
|
||||
}
|
||||
let n = doomed.len();
|
||||
s.edit("Delete Items", None, |proj, st| {
|
||||
for id in &doomed {
|
||||
proj.items.remove(id);
|
||||
}
|
||||
let comp_ids: Vec<ItemId> = proj.comps().map(|(id, _)| *id).collect();
|
||||
for cid in comp_ids {
|
||||
let uses = proj.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.item().is_some_and(|i| doomed.contains(&i))));
|
||||
if !uses {
|
||||
continue;
|
||||
}
|
||||
if let Some(comp) = proj.comp_mut(cid) {
|
||||
let gone: Vec<effectcraft_project::LayerId> =
|
||||
comp.layers.iter().filter(|l| l.source.item().is_some_and(|i| doomed.contains(&i))).map(|l| l.id).collect();
|
||||
comp.layers.retain(|l| !gone.contains(&l.id));
|
||||
for l in &mut comp.layers {
|
||||
if l.parent.is_some_and(|x| gone.contains(&x)) {
|
||||
l.parent = None;
|
||||
}
|
||||
if l.track_matte.is_some_and(|m| gone.contains(&m.layer)) {
|
||||
l.track_matte = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
proj.render_queue.retain(|r| !doomed.contains(&r.comp));
|
||||
st.project_selection.retain(|i| !doomed.contains(i));
|
||||
Ok(())
|
||||
})?;
|
||||
s.sanitize_state();
|
||||
Ok(json!(n))
|
||||
}
|
||||
|
||||
/// Duplicate project items (Edit ▸ Duplicate in the Project panel): comps get fresh layer ids and
|
||||
/// a numbered name; the copies are selected.
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "project.duplicate";
|
||||
let ids = items_p(s, p, c)?;
|
||||
let mut created = vec![];
|
||||
s.edit("Duplicate", None, |proj, st| {
|
||||
for id in &ids {
|
||||
let it = proj.item(*id).ok_or(EngineError::Other("item vanished".into()))?.clone();
|
||||
if it.is_folder() {
|
||||
return Err(bad(c, "folders can't be duplicated"));
|
||||
}
|
||||
let base = it.name.trim_end_matches(|ch: char| ch.is_ascii_digit()).trim_end().to_string();
|
||||
let name = (2..).map(|i| format!("{base} {i}")).find(|n| proj.find_by_name(n).is_none()).unwrap_or_default();
|
||||
let mut kind = it.kind.clone();
|
||||
if let effectcraft_project::ItemKind::Comp(comp) = &mut kind {
|
||||
let comp = std::sync::Arc::make_mut(comp);
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for l in &mut comp.layers {
|
||||
let new = effectcraft_project::LayerId(proj.alloc());
|
||||
map.insert(l.id, new);
|
||||
l.id = new;
|
||||
let mut next = proj.next_id;
|
||||
l.props.reassign_uids(&mut next);
|
||||
proj.next_id = next + 1;
|
||||
}
|
||||
for l in &mut comp.layers {
|
||||
l.parent = l.parent.and_then(|p| map.get(&p).copied());
|
||||
if let Some(m) = &mut l.track_matte {
|
||||
m.layer = map.get(&m.layer).copied().unwrap_or(m.layer);
|
||||
}
|
||||
}
|
||||
}
|
||||
let nid = proj.add_item(&name, it.label, it.parent, kind);
|
||||
created.push(nid);
|
||||
}
|
||||
st.project_selection = created.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"items": created.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("project.select", "Select Project Items", [], None, "{items: [id|name], add?}", always, select),
|
||||
|
|
@ -147,5 +233,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("project.move", "Move to Folder", [], None, "{items?: [id|name] (default: selected), folder: id|name|null (root)}", always, move_items),
|
||||
cmd!("project.setLabel", "Item Label", [], None, "{items?, label: name|index}", always, set_label),
|
||||
cmd!("project.setComment", "Item Comment", [], None, "{items?, comment}", always, set_comment),
|
||||
cmd!(
|
||||
"project.delete",
|
||||
"Delete Project Items",
|
||||
[],
|
||||
None,
|
||||
"{items?: [id|name] (default: selected)} (folders with their contents; layers using the items go too)",
|
||||
always,
|
||||
delete
|
||||
),
|
||||
cmd!("project.duplicate", "Duplicate Project Items", [], None, "{items?: [id|name] (default: selected)} → {items}", always, duplicate),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
//! Generic edits of property-tree groups by uid: rename, enable, remove, duplicate and reorder an
|
||||
//! effect, mask, shape item or text animator (what scripting's `PropertyGroup.name`, `enabled`,
|
||||
//! `remove()`, `duplicate()` and `moveTo()` do, and what agents need without a UI selection).
|
||||
|
||||
use effectcraft_project::{GroupKind, ItemId, LayerId, Node, PropGroup, Uid};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
/// Groups that can be renamed, removed, duplicated and reordered: instances in indexed lists.
|
||||
fn is_instance(g: &PropGroup) -> bool {
|
||||
matches!(g.kind, GroupKind::Indexed | GroupKind::Effect { .. } | GroupKind::Mask { .. } | GroupKind::Tracker { .. })
|
||||
}
|
||||
|
||||
/// `{layer, prop: uid}` → (comp, layer, group uid), checking that the group is an instance.
|
||||
fn group_ref(s: &Session, p: &Value, cmd: &str, need_instance: bool) -> Result<(ItemId, LayerId, Uid)> {
|
||||
let (cid, lid) = layer_p(s, p, cmd)?;
|
||||
let uid = p.get("prop").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `prop` (group uid)"))?;
|
||||
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
let g = l.props.find_group(uid).ok_or_else(|| bad(cmd, format!("no property group @{uid}")))?;
|
||||
if need_instance && !is_instance(g) {
|
||||
return Err(bad(cmd, format!("`{}` is a fixed group (only effects, masks, shape items and animators)", g.name)));
|
||||
}
|
||||
Ok((cid, lid, uid))
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.renameGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?.to_string();
|
||||
if name.trim().is_empty() {
|
||||
return Err(bad(c, "the name can't be empty"));
|
||||
}
|
||||
s.edit("Rename", None, |proj, _| {
|
||||
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
g.name = name.clone();
|
||||
Ok(json!(name))
|
||||
})
|
||||
}
|
||||
|
||||
fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.setGroupEnabled";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
let v = b_p(p, "value");
|
||||
s.edit("Toggle", None, |proj, _| {
|
||||
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
g.enabled = v.unwrap_or(!g.enabled);
|
||||
Ok(json!(g.enabled))
|
||||
})
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.removeGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
s.edit("Delete", None, |proj, st| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
parent.children.retain(|n| n.uid() != uid);
|
||||
st.selected_props.retain(|(_, u)| *u != uid);
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.duplicateGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
s.edit("Duplicate", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
let i = parent.children.iter().position(|n| n.uid() == uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
let Node::Group(mut g) = parent.children[i].clone() else { return Err(bad(c, "not a group")) };
|
||||
g.reassign_uids(&mut next);
|
||||
let new = g.uid;
|
||||
parent.children.insert(i + 1, Node::Group(g));
|
||||
proj.next_id = next + 1;
|
||||
Ok(json!({"prop": new}))
|
||||
})
|
||||
}
|
||||
|
||||
fn move_to(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.moveGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
let to = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing `index` (1-based)"))? as usize;
|
||||
s.edit("Reorder", None, |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
let i = parent.children.iter().position(|n| n.uid() == uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
if to < 1 || to > parent.children.len() {
|
||||
return Err(bad(c, format!("index {to} is out of range 1..{}", parent.children.len())));
|
||||
}
|
||||
let n = parent.children.remove(i);
|
||||
parent.children.insert(to - 1, n);
|
||||
Ok(json!(to))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("prop.renameGroup", "Rename Property Group", [], None, "{layer?, prop: uid, name}", has_layers, rename),
|
||||
cmd!("prop.setGroupEnabled", "Enable Property Group", [], None, "{layer?, prop: uid, value?}", has_layers, set_enabled),
|
||||
cmd!("prop.removeGroup", "Delete Property Group", [], None, "{layer?, prop: uid}", has_layers, remove),
|
||||
cmd!("prop.duplicateGroup", "Duplicate Property Group", [], None, "{layer?, prop: uid} → {prop: new uid}", has_layers, duplicate),
|
||||
cmd!("prop.moveGroup", "Reorder Property Group", [], None, "{layer?, prop: uid, index (1-based among its siblings)}", has_layers, move_to),
|
||||
]
|
||||
}
|
||||
|
|
@ -114,7 +114,8 @@ fn add_animator(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Animator {}", anims.children.len() + 1));
|
||||
let mut g = build::text_animator(&mut ids, &name, vec![]);
|
||||
let added: usize = kinds.iter().map(|k| add_props(&mut ids, &mut g, k, three)).sum();
|
||||
if added == 0 {
|
||||
// `properties: []` adds an empty animator (scripting's addProperty("ADBE Text Animator")).
|
||||
if added == 0 && !kinds.is_empty() {
|
||||
return Err(bad(C, "unknown animator property"));
|
||||
}
|
||||
let uid = g.uid;
|
||||
|
|
|
|||
|
|
@ -285,8 +285,25 @@ pub struct Session {
|
|||
pub autosave: autosave::AutoSaveState,
|
||||
/// The viewer snapshot (Take Snapshot / Show Snapshot).
|
||||
pub snapshot: Option<viewer::Snapshot>,
|
||||
/// The JavaScript scripting engine (set by the host that links `effectcraft-script`):
|
||||
/// `script.run`, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`) and the Script Console.
|
||||
pub script: Option<ScriptRunner>,
|
||||
}
|
||||
|
||||
/// A script to run (see [`Session::script`]).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ScriptRequest<'a> {
|
||||
pub code: &'a str,
|
||||
/// File name shown in errors and `$.fileName` (`console` for the Script Console).
|
||||
pub name: &'a str,
|
||||
/// Run in the Script Console's persistent context (variables survive between runs).
|
||||
pub console: bool,
|
||||
}
|
||||
|
||||
/// Runs a script against the session and reports `{ok, result, output, error: {message, line,
|
||||
/// column, file} | null}`.
|
||||
pub type ScriptRunner = fn(&mut Session, &ScriptRequest) -> Value;
|
||||
|
||||
impl Default for Session {
|
||||
fn default() -> Self {
|
||||
Session {
|
||||
|
|
@ -319,6 +336,7 @@ impl Default for Session {
|
|||
shortcut_table: std::sync::OnceLock::new(),
|
||||
autosave: autosave::AutoSaveState::default(),
|
||||
snapshot: None,
|
||||
script: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -942,6 +942,7 @@ Window
|
|||
Preview | window.panel {"panel":"preview"} | Cmd+3
|
||||
Progress | window.unavailablePanel {"panel":"progress"}
|
||||
Properties | window.panel {"panel":"properties"}
|
||||
Script Console | window.panel {"panel":"scriptConsole"}
|
||||
Smoother | window.panel {"panel":"smoother"}
|
||||
Tools | window.panel {"panel":"tools"} | Cmd+1
|
||||
Tracker | window.panel {"panel":"tracker"}
|
||||
|
|
|
|||
|
|
@ -39,3 +39,60 @@ fn move_into_folder_rename_label_comment_with_undo() {
|
|||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.item(comp).unwrap().comment, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_and_duplicate_items() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newNull", json!({})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
|
||||
s.execute("renderQueue.add", json!({"comp": inner.0})).unwrap();
|
||||
// Duplicate: a new comp with fresh layer ids.
|
||||
let r = s.execute("project.duplicate", json!({"items": [inner.0]})).unwrap();
|
||||
let dup = ItemId(r["items"][0].as_u64().unwrap());
|
||||
assert_eq!(s.project.item(dup).unwrap().name, "Inner 2");
|
||||
let a = s.project.comp(inner).unwrap().layers[0].id;
|
||||
let b = s.project.comp(dup).unwrap().layers[0].id;
|
||||
assert_ne!(a, b);
|
||||
// Delete: layers that use the item and its render items go too; undoable.
|
||||
s.execute("project.delete", json!({"items": ["Inner"]})).unwrap();
|
||||
assert!(s.project.item(inner).is_none());
|
||||
assert!(s.project.comp(outer).unwrap().layers.is_empty());
|
||||
assert!(s.project.render_queue.is_empty());
|
||||
s.undo();
|
||||
assert!(s.project.item(inner).is_some());
|
||||
assert_eq!(s.project.comp(outer).unwrap().layers.len(), 1);
|
||||
// Folders take their contents.
|
||||
let f = ItemId(s.execute("project.newFolder", json!({"name": "F"})).unwrap()["item"].as_u64().unwrap());
|
||||
s.execute("project.move", json!({"items": [dup.0], "folder": f.0})).unwrap();
|
||||
s.execute("project.delete", json!({"items": [f.0]})).unwrap();
|
||||
assert!(s.project.item(dup).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_group_edits() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
let a = s.execute("effect.apply", json!({"layer": l, "effect": "Gaussian Blur"})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
let b = s.execute("effect.apply", json!({"layer": l, "effect": "Invert"})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
let fx = |s: &Session| -> Vec<(u64, String, bool)> {
|
||||
s.active_comp().unwrap().layers[0].props.sub("effects").unwrap().groups().map(|g| (g.uid, g.name.clone(), g.enabled)).collect()
|
||||
};
|
||||
s.execute("prop.renameGroup", json!({"layer": l, "prop": a, "name": "Soft"})).unwrap();
|
||||
s.execute("prop.setGroupEnabled", json!({"layer": l, "prop": a, "value": false})).unwrap();
|
||||
s.execute("prop.moveGroup", json!({"layer": l, "prop": b, "index": 1})).unwrap();
|
||||
let d = s.execute("prop.duplicateGroup", json!({"layer": l, "prop": a})).unwrap()["prop"].as_u64().unwrap();
|
||||
let v = fx(&s);
|
||||
assert_eq!(v.iter().map(|x| x.0).collect::<Vec<_>>(), [b, a, d]);
|
||||
assert_eq!(v[1].1, "Soft");
|
||||
assert!(!v[1].2);
|
||||
s.execute("prop.removeGroup", json!({"layer": l, "prop": a})).unwrap();
|
||||
assert_eq!(fx(&s).len(), 2);
|
||||
// Fixed groups can't be removed.
|
||||
let tr = s.active_comp().unwrap().layers[0].props.sub("transform").unwrap().uid;
|
||||
assert!(s.execute("prop.removeGroup", json!({"layer": l, "prop": tr})).is_err());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,443 @@
|
|||
//! After Effects-compatible match names for our property tree and effects, so common scripts
|
||||
//! (`layer.property("ADBE Transform Group").property("ADBE Position")`,
|
||||
//! `layer.Effects.addProperty("ADBE Gaussian Blur 2")`) work unchanged.
|
||||
//!
|
||||
//! The names are the stable identifiers After Effects documents for scripting (Scripting Guide
|
||||
//! ▸ match names). Properties without a documented equivalent keep our own match id
|
||||
//! (`perChar3d`, `ec.blur.bilateral`), which `property()` and `addProperty()` accept too.
|
||||
|
||||
use effectcraft_project::{LayerSource, Node};
|
||||
|
||||
/// Match name of a layer (its root property group).
|
||||
pub fn layer(source: &LayerSource) -> &'static str {
|
||||
match source {
|
||||
LayerSource::Text => "ADBE Text Layer",
|
||||
LayerSource::Shape => "ADBE Vector Layer",
|
||||
LayerSource::Camera => "ADBE Camera Layer",
|
||||
LayerSource::Light { .. } => "ADBE Light Layer",
|
||||
_ => "ADBE AV Layer",
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level groups of a layer.
|
||||
const LAYER_GROUPS: &[(&str, &str)] = &[
|
||||
("transform", "ADBE Transform Group"),
|
||||
("masks", "ADBE Mask Parade"),
|
||||
("effects", "ADBE Effect Parade"),
|
||||
("text", "ADBE Text Properties"),
|
||||
("contents", "ADBE Root Vectors Group"),
|
||||
("materialOptions", "ADBE Material Options Group"),
|
||||
("cameraOptions", "ADBE Camera Options Group"),
|
||||
("lightOptions", "ADBE Light Options Group"),
|
||||
("audio", "ADBE Audio Group"),
|
||||
("layerStyles", "ADBE Layer Styles"),
|
||||
("motionTrackers", "ADBE MTrackers"),
|
||||
("timeRemap", "ADBE Time Remapping"),
|
||||
];
|
||||
|
||||
const TRANSFORM: &[(&str, &str)] = &[
|
||||
("anchor", "ADBE Anchor Point"),
|
||||
// Cameras and lights store the Point of Interest where AVLayers keep the Anchor Point.
|
||||
("poi", "ADBE Anchor Point"),
|
||||
("position", "ADBE Position"),
|
||||
("positionX", "ADBE Position_0"),
|
||||
("positionY", "ADBE Position_1"),
|
||||
("positionZ", "ADBE Position_2"),
|
||||
("scale", "ADBE Scale"),
|
||||
("orientation", "ADBE Orientation"),
|
||||
("rotationX", "ADBE Rotate X"),
|
||||
("rotationY", "ADBE Rotate Y"),
|
||||
("rotation", "ADBE Rotate Z"),
|
||||
("opacity", "ADBE Opacity"),
|
||||
];
|
||||
|
||||
const MASK: &[(&str, &str)] =
|
||||
&[("path", "ADBE Mask Shape"), ("feather", "ADBE Mask Feather"), ("opacity", "ADBE Mask Opacity"), ("expansion", "ADBE Mask Offset")];
|
||||
|
||||
const TEXT: &[(&str, &str)] = &[
|
||||
("sourceText", "ADBE Text Document"),
|
||||
("pathOptions", "ADBE Text Path Options"),
|
||||
("moreOptions", "ADBE Text More Options"),
|
||||
("animators", "ADBE Text Animators"),
|
||||
];
|
||||
|
||||
const TEXT_ANIMATOR: &[(&str, &str)] = &[("selectors", "ADBE Text Selectors"), ("properties", "ADBE Text Animator Properties")];
|
||||
|
||||
const CAMERA: &[(&str, &str)] = &[
|
||||
("zoom", "ADBE Camera Zoom"),
|
||||
("dof", "ADBE Camera Depth of Field"),
|
||||
("focusDistance", "ADBE Camera Focus Distance"),
|
||||
("aperture", "ADBE Camera Aperture"),
|
||||
("blurLevel", "ADBE Camera Blur Level"),
|
||||
];
|
||||
|
||||
const LIGHT: &[(&str, &str)] = &[
|
||||
("intensity", "ADBE Light Intensity"),
|
||||
("color", "ADBE Light Color"),
|
||||
("coneAngle", "ADBE Light Cone Angle"),
|
||||
("coneFeather", "ADBE Light Cone Feather 2"),
|
||||
("falloff", "ADBE Light Falloff Type"),
|
||||
("radius", "ADBE Light Falloff Start"),
|
||||
("falloffDistance", "ADBE Light Falloff Distance"),
|
||||
("castsShadows", "ADBE Light Casts Shadows"),
|
||||
("shadowDarkness", "ADBE Light Shadow Darkness"),
|
||||
("shadowDiffusion", "ADBE Light Shadow Diffusion"),
|
||||
];
|
||||
|
||||
const MATERIAL: &[(&str, &str)] = &[
|
||||
("castsShadows", "ADBE Casts Shadows"),
|
||||
("lightTransmission", "ADBE Light Transmission"),
|
||||
("acceptsShadows", "ADBE Accepts Shadows"),
|
||||
("acceptsLights", "ADBE Accepts Lights"),
|
||||
("ambient", "ADBE Ambient Coefficient"),
|
||||
("diffuse", "ADBE Diffuse Coefficient"),
|
||||
("specularIntensity", "ADBE Specular Coefficient"),
|
||||
("specularShininess", "ADBE Shininess Coefficient"),
|
||||
("metal", "ADBE Metal Coefficient"),
|
||||
];
|
||||
|
||||
/// Shape items (Contents ▸ Add): our match id → AE match name.
|
||||
pub const SHAPE_ITEMS: &[(&str, &str)] = &[
|
||||
("group", "ADBE Vector Group"),
|
||||
("rect", "ADBE Vector Shape - Rect"),
|
||||
("ellipse", "ADBE Vector Shape - Ellipse"),
|
||||
("star", "ADBE Vector Shape - Star"),
|
||||
("path", "ADBE Vector Shape - Group"),
|
||||
("fill", "ADBE Vector Graphic - Fill"),
|
||||
("stroke", "ADBE Vector Graphic - Stroke"),
|
||||
("gfill", "ADBE Vector Graphic - G-Fill"),
|
||||
("gstroke", "ADBE Vector Graphic - G-Stroke"),
|
||||
("merge", "ADBE Vector Filter - Merge"),
|
||||
("offset", "ADBE Vector Filter - Offset"),
|
||||
("pucker", "ADBE Vector Filter - PB"),
|
||||
("repeater", "ADBE Vector Filter - Repeater"),
|
||||
("round", "ADBE Vector Filter - RC"),
|
||||
("trim", "ADBE Vector Filter - Trim"),
|
||||
("twist", "ADBE Vector Filter - Twist"),
|
||||
("wiggle", "ADBE Vector Filter - Roughen"),
|
||||
("zigzag", "ADBE Vector Filter - Zigzag"),
|
||||
];
|
||||
|
||||
/// Properties inside shape items: (item match id, property match id, AE match name).
|
||||
const SHAPE_PROPS: &[(&str, &str, &str)] = &[
|
||||
("group", "blend", "ADBE Vector Blend Mode"),
|
||||
("group", "contents", "ADBE Vectors Group"),
|
||||
("group", "transform", "ADBE Vector Transform Group"),
|
||||
("transform", "anchor", "ADBE Vector Anchor"),
|
||||
("transform", "position", "ADBE Vector Position"),
|
||||
("transform", "scale", "ADBE Vector Scale"),
|
||||
("transform", "skew", "ADBE Vector Skew"),
|
||||
("transform", "skewAxis", "ADBE Vector Skew Axis"),
|
||||
("transform", "rotation", "ADBE Vector Rotation"),
|
||||
("transform", "opacity", "ADBE Vector Group Opacity"),
|
||||
("rect", "direction", "ADBE Vector Shape Direction"),
|
||||
("rect", "size", "ADBE Vector Rect Size"),
|
||||
("rect", "position", "ADBE Vector Rect Position"),
|
||||
("rect", "roundness", "ADBE Vector Rect Roundness"),
|
||||
("ellipse", "direction", "ADBE Vector Shape Direction"),
|
||||
("ellipse", "size", "ADBE Vector Ellipse Size"),
|
||||
("ellipse", "position", "ADBE Vector Ellipse Position"),
|
||||
("star", "direction", "ADBE Vector Shape Direction"),
|
||||
("star", "type", "ADBE Vector Star Type"),
|
||||
("star", "points", "ADBE Vector Star Points"),
|
||||
("star", "position", "ADBE Vector Star Position"),
|
||||
("star", "rotation", "ADBE Vector Star Rotation"),
|
||||
("star", "innerRadius", "ADBE Vector Star Inner Radius"),
|
||||
("star", "outerRadius", "ADBE Vector Star Outer Radius"),
|
||||
("star", "innerRoundness", "ADBE Vector Star Inner Roundess"),
|
||||
("star", "outerRoundness", "ADBE Vector Star Outer Roundess"),
|
||||
("path", "direction", "ADBE Vector Shape Direction"),
|
||||
("path", "path", "ADBE Vector Shape"),
|
||||
("fill", "blend", "ADBE Vector Blend Mode"),
|
||||
("fill", "composite", "ADBE Vector Composite Order"),
|
||||
("fill", "rule", "ADBE Vector Fill Rule"),
|
||||
("fill", "color", "ADBE Vector Fill Color"),
|
||||
("fill", "opacity", "ADBE Vector Fill Opacity"),
|
||||
("stroke", "blend", "ADBE Vector Blend Mode"),
|
||||
("stroke", "composite", "ADBE Vector Composite Order"),
|
||||
("stroke", "color", "ADBE Vector Stroke Color"),
|
||||
("stroke", "opacity", "ADBE Vector Stroke Opacity"),
|
||||
("stroke", "width", "ADBE Vector Stroke Width"),
|
||||
("stroke", "cap", "ADBE Vector Stroke Line Cap"),
|
||||
("stroke", "join", "ADBE Vector Stroke Line Join"),
|
||||
("stroke", "miter", "ADBE Vector Stroke Miter Limit"),
|
||||
("stroke", "dashes", "ADBE Vector Stroke Dashes"),
|
||||
("trim", "start", "ADBE Vector Trim Start"),
|
||||
("trim", "end", "ADBE Vector Trim End"),
|
||||
("trim", "offset", "ADBE Vector Trim Offset"),
|
||||
("trim", "mode", "ADBE Vector Trim Type"),
|
||||
("repeater", "copies", "ADBE Vector Repeater Copies"),
|
||||
("repeater", "offset", "ADBE Vector Repeater Offset"),
|
||||
("repeater", "composite", "ADBE Vector Repeater Order"),
|
||||
("repeater", "transform", "ADBE Vector Repeater Transform"),
|
||||
("round", "radius", "ADBE Vector RoundCorner Radius"),
|
||||
("pucker", "amount", "ADBE Vector PuckerBloat Amount"),
|
||||
("twist", "angle", "ADBE Vector Twist Angle"),
|
||||
("twist", "center", "ADBE Vector Twist Center"),
|
||||
("merge", "mode", "ADBE Vector Merge Type"),
|
||||
];
|
||||
|
||||
/// Effects: our effect id → AE match name (the documented ones; the rest keep our id).
|
||||
pub const EFFECTS: &[(&str, &str)] = &[
|
||||
("ec.blur.gaussian", "ADBE Gaussian Blur 2"),
|
||||
("ec.blur.fastbox", "ADBE Box Blur2"),
|
||||
("ec.blur.directional", "ADBE Motion Blur"),
|
||||
("ec.blur.radial", "ADBE Radial Blur"),
|
||||
("ec.blur.cameralens", "ADBE Camera Lens Blur"),
|
||||
("ec.blur.channel", "ADBE Channel Blur"),
|
||||
("ec.blur.compound", "ADBE Compound Blur"),
|
||||
("ec.blur.sharpen", "ADBE Sharpen"),
|
||||
("ec.blur.smart", "ADBE Smart Blur"),
|
||||
("ec.blur.unsharp", "ADBE Unsharp Mask2"),
|
||||
("ec.channel.invert", "ADBE Invert"),
|
||||
("ec.channel.setmatte", "ADBE Set Matte3"),
|
||||
("ec.channel.setchannels", "ADBE Set Channels"),
|
||||
("ec.channel.shiftchannels", "ADBE Shift Channels"),
|
||||
("ec.channel.minimax", "ADBE Minimax"),
|
||||
("ec.channel.blend", "ADBE Blend"),
|
||||
("ec.channel.arithmetic", "ADBE Arithmetic"),
|
||||
("ec.channel.calculations", "ADBE Calculations"),
|
||||
("ec.channel.solidcomposite", "ADBE Solid Composite"),
|
||||
("ec.color.brightnesscontrast", "ADBE Brightness & Contrast 2"),
|
||||
("ec.color.curves", "ADBE CurvesCustom"),
|
||||
("ec.color.levels", "ADBE Easy Levels2"),
|
||||
("ec.color.levelsic", "ADBE Pro Levels2"),
|
||||
("ec.color.huesaturation", "ADBE HUE SATURATION"),
|
||||
("ec.color.exposure", "ADBE Exposure2"),
|
||||
("ec.color.tint", "ADBE Tint"),
|
||||
("ec.color.tritone", "ADBE Tritone"),
|
||||
("ec.color.blackwhite", "ADBE Black&White"),
|
||||
("ec.color.channelmixer", "ADBE CHANNEL MIXER"),
|
||||
("ec.color.colorbalance", "ADBE Color Balance 2"),
|
||||
("ec.color.colorbalancehls", "ADBE Color Balance (HLS)"),
|
||||
("ec.color.changecolor", "ADBE Change Color"),
|
||||
("ec.color.changetocolor", "ADBE Change To Color"),
|
||||
("ec.color.colorama", "APC Colorama"),
|
||||
("ec.color.leavecolor", "ADBE Leave Color"),
|
||||
("ec.color.photofilter", "ADBE Photo Filter"),
|
||||
("ec.color.vibrance", "ADBE Vibrance"),
|
||||
("ec.color.lumetri", "ADBE Lumetri"),
|
||||
("ec.color.shadowhighlight", "ADBE Shadow/Highlight"),
|
||||
("ec.color.selectivecolor", "ADBE Selective Color"),
|
||||
("ec.color.gammapedestalgain", "ADBE Gamma/Pedestal/Gain"),
|
||||
("ec.color.equalize", "ADBE Equalize"),
|
||||
("ec.color.autocontrast", "ADBE AutoContrast"),
|
||||
("ec.color.autolevels", "ADBE AutoLevels"),
|
||||
("ec.color.autocolor", "ADBE AutoColor"),
|
||||
("ec.control.slider", "ADBE Slider Control"),
|
||||
("ec.control.angle", "ADBE Angle Control"),
|
||||
("ec.control.checkbox", "ADBE Checkbox Control"),
|
||||
("ec.control.color", "ADBE Color Control"),
|
||||
("ec.control.point", "ADBE Point Control"),
|
||||
("ec.control.point3d", "ADBE Point3D Control"),
|
||||
("ec.control.layer", "ADBE Layer Control"),
|
||||
("ec.control.dropdown", "ADBE Dropdown Control"),
|
||||
("ec.distort.transform", "ADBE Geometry2"),
|
||||
("ec.distort.cornerpin", "ADBE Corner Pin"),
|
||||
("ec.distort.bulge", "ADBE Bulge"),
|
||||
("ec.distort.mirror", "ADBE Mirror"),
|
||||
("ec.distort.offset", "ADBE Offset"),
|
||||
("ec.distort.ripple", "ADBE Ripple"),
|
||||
("ec.distort.spherize", "ADBE Spherize"),
|
||||
("ec.distort.twirl", "ADBE Twirl"),
|
||||
("ec.distort.wavewarp", "ADBE Wave Warp"),
|
||||
("ec.distort.turbulentdisplace", "ADBE Turbulent Displace"),
|
||||
("ec.distort.displacementmap", "ADBE Displacement Map"),
|
||||
("ec.distort.meshwarp", "ADBE MESH WARP"),
|
||||
("ec.distort.polar", "ADBE Polar Coordinates"),
|
||||
("ec.distort.opticscompensation", "ADBE Optics Compensation"),
|
||||
("ec.distort.magnify", "ADBE Magnify"),
|
||||
("ec.distort.warpstabilizer", "ADBE SubspaceStabilizer"),
|
||||
("ec.distort.puppet", "ADBE FreePin3"),
|
||||
("ec.generate.fill", "ADBE Fill"),
|
||||
("ec.generate.gradientramp", "ADBE Ramp"),
|
||||
("ec.generate.fourcolor", "ADBE 4ColorGradient"),
|
||||
("ec.generate.stroke", "ADBE Stroke"),
|
||||
("ec.generate.grid", "ADBE Grid"),
|
||||
("ec.generate.checkerboard", "ADBE Checkerboard"),
|
||||
("ec.generate.circle", "ADBE Circle"),
|
||||
("ec.generate.ellipse", "ADBE Ellipse"),
|
||||
("ec.generate.cellpattern", "ADBE Cell Pattern"),
|
||||
("ec.generate.lensflare", "ADBE Lens Flare"),
|
||||
("ec.generate.vegas", "ADBE Vegas"),
|
||||
("ec.generate.writeon", "ADBE Write On"),
|
||||
("ec.generate.audiospectrum", "ADBE AudSpect"),
|
||||
("ec.generate.audiowaveform", "ADBE AudWave"),
|
||||
("ec.generate.beam", "ADBE Laser"),
|
||||
("ec.generate.radiowaves", "ADBE Radio Waves"),
|
||||
("ec.generate.scribble", "ADBE Scribble Fill"),
|
||||
("ec.generate.paintbucket", "ADBE Paint Bucket"),
|
||||
("ec.key.colorkey", "ADBE Color Key"),
|
||||
("ec.key.luma", "ADBE Luma Key"),
|
||||
("ec.key.linearcolor", "ADBE Linear Color Key2"),
|
||||
("ec.key.colorrange", "ADBE Color Range"),
|
||||
("ec.key.colordifference", "ADBE Color Difference Key"),
|
||||
("ec.key.extract", "ADBE Extract"),
|
||||
("ec.key.spill", "ADBE Spill2"),
|
||||
("ec.matte.simplechoker", "ADBE Simple Choker"),
|
||||
("ec.matte.mattechoker", "ADBE Matte Choker"),
|
||||
("ec.noise.noise", "ADBE Noise"),
|
||||
("ec.noise.fractal", "ADBE Fractal Noise"),
|
||||
("ec.noise.median", "ADBE Median"),
|
||||
("ec.noise.addgrain", "VISINF Grain Implant"),
|
||||
("ec.noise.dustscratches", "ADBE Dust & Scratches"),
|
||||
("ec.noise.turbulent", "ADBE Turbulent Noise"),
|
||||
("ec.perspective.dropshadow", "ADBE Drop Shadow"),
|
||||
("ec.perspective.radialshadow", "ADBE Radial Shadow"),
|
||||
("ec.perspective.bevelalpha", "ADBE Bevel Alpha"),
|
||||
("ec.perspective.beveledges", "ADBE Bevel Edges"),
|
||||
("ec.perspective.3dglasses", "ADBE 3D Glasses2"),
|
||||
("ec.stylize.glow", "ADBE Glo2"),
|
||||
("ec.stylize.mosaic", "ADBE Mosaic"),
|
||||
("ec.stylize.posterize", "ADBE Posterize"),
|
||||
("ec.stylize.threshold", "ADBE Threshold2"),
|
||||
("ec.stylize.findedges", "ADBE Find Edges"),
|
||||
("ec.stylize.emboss", "ADBE Emboss"),
|
||||
("ec.stylize.coloremboss", "ADBE Color Emboss"),
|
||||
("ec.stylize.roughenedges", "ADBE Roughen Edges"),
|
||||
("ec.stylize.scatter", "ADBE Scatter"),
|
||||
("ec.stylize.strobe", "ADBE Strobe"),
|
||||
("ec.stylize.motiontile", "ADBE Tile"),
|
||||
("ec.stylize.texturize", "ADBE Texturize"),
|
||||
("ec.stylize.cartoon", "ADBE Cartoonify"),
|
||||
("ec.stylize.brushstrokes", "ADBE Brush Strokes"),
|
||||
("ec.text.numbers", "ADBE Numbers2"),
|
||||
("ec.text.timecode", "ADBE Timecode"),
|
||||
("ec.time.echo", "ADBE Echo"),
|
||||
("ec.time.posterizetime", "ADBE Posterize Time"),
|
||||
("ec.time.timedisplacement", "ADBE Time Displacement"),
|
||||
("ec.time.timedifference", "ADBE Time Difference"),
|
||||
("ec.time.pixelmotionblur", "ADBE Pixel Motion Blur"),
|
||||
("ec.time.timewarp", "ADBE Timewarp"),
|
||||
("ec.transition.linearwipe", "ADBE Linear Wipe"),
|
||||
("ec.transition.radialwipe", "ADBE Radial Wipe"),
|
||||
("ec.transition.venetian", "ADBE Venetian Blinds"),
|
||||
("ec.transition.blockdissolve", "ADBE Block Dissolve"),
|
||||
("ec.transition.gradientwipe", "ADBE Gradient Wipe"),
|
||||
("ec.transition.iriswipe", "ADBE Iris Wipe"),
|
||||
("ec.utility.applylut", "ADBE Apply Color LUT2"),
|
||||
("ec.utility.cineon", "ADBE Cineon Converter2"),
|
||||
("ec.utility.growbounds", "ADBE Grow Bounds"),
|
||||
];
|
||||
|
||||
fn find<'a>(t: &'a [(&str, &'a str)], k: &str) -> Option<&'a str> {
|
||||
t.iter().find(|(a, _)| *a == k).map(|(_, b)| *b)
|
||||
}
|
||||
|
||||
/// AE match name of an effect id (falls back to the id).
|
||||
pub fn effect(id: &str) -> String {
|
||||
find(EFFECTS, id).map(str::to_string).unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
/// Our effect id for an AE effect match name.
|
||||
pub fn effect_from_match(m: &str) -> Option<&'static str> {
|
||||
EFFECTS.iter().find(|(_, b)| b.eq_ignore_ascii_case(m)).map(|(a, _)| *a)
|
||||
}
|
||||
|
||||
/// Our shape item kind (`layer.addShapeItem`) for an AE match name, our match id or a display
|
||||
/// name (`Rectangle`, `Fill`, `Trim Paths`).
|
||||
pub fn shape_kind(name: &str) -> Option<&'static str> {
|
||||
if let Some((k, _)) = SHAPE_ITEMS.iter().find(|(k, m)| m.eq_ignore_ascii_case(name) || k.eq_ignore_ascii_case(name)) {
|
||||
return Some(k);
|
||||
}
|
||||
let n: String = name.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase();
|
||||
Some(match n.as_str() {
|
||||
"rectangle" | "rectanglepath" => "rect",
|
||||
"ellipsepath" => "ellipse",
|
||||
"polystar" | "polystarpath" => "star",
|
||||
"polygon" => "polygon",
|
||||
"group" | "vectorgroup" => "group",
|
||||
"fill" => "fill",
|
||||
"stroke" => "stroke",
|
||||
"gradientfill" => "gfill",
|
||||
"gradientstroke" => "gstroke",
|
||||
"trimpaths" | "trim" => "trim",
|
||||
"repeater" => "repeater",
|
||||
"roundcorners" => "round",
|
||||
"offsetpaths" => "offset",
|
||||
"puckerbloat" | "puckerandbloat" => "pucker",
|
||||
"twist" => "twist",
|
||||
"zigzag" => "zigzag",
|
||||
"wigglepaths" => "wiggle",
|
||||
"mergepaths" => "merge",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Match name of the node at the end of `chain` (layer root's children → … → node). `effect`
|
||||
/// params are numbered `<effect match name>-0001…` in parameter order, like After Effects.
|
||||
pub fn node(chain: &[&Node]) -> String {
|
||||
let Some(last) = chain.last() else { return String::new() };
|
||||
let id = last.match_id();
|
||||
let parent = chain.len().checked_sub(2).map(|i| chain[i]);
|
||||
let grand = chain.len().checked_sub(3).map(|i| chain[i]);
|
||||
let top = chain.first().map(|n| n.match_id()).unwrap_or("");
|
||||
let own = || id.to_string();
|
||||
if chain.len() == 1 {
|
||||
return find(LAYER_GROUPS, id).map(str::to_string).unwrap_or_else(own);
|
||||
}
|
||||
let pid = parent.map(|p| p.match_id()).unwrap_or("");
|
||||
match top {
|
||||
"transform" if chain.len() == 2 => find(TRANSFORM, id).map(str::to_string).unwrap_or_else(own),
|
||||
"masks" if chain.len() == 2 => "ADBE Mask Atom".into(),
|
||||
"masks" if chain.len() == 3 => find(MASK, id).map(str::to_string).unwrap_or_else(own),
|
||||
"effects" if chain.len() == 2 => effect(id),
|
||||
"effects" if chain.len() == 3 => {
|
||||
let fx = parent.and_then(Node::as_group);
|
||||
match fx {
|
||||
Some(g) => {
|
||||
let i = g.children.iter().position(|c| c.uid() == last.uid()).unwrap_or(0) + 1;
|
||||
format!("{}-{i:04}", effect(&g.match_id))
|
||||
}
|
||||
None => own(),
|
||||
}
|
||||
}
|
||||
"text" if chain.len() == 2 => find(TEXT, id).map(str::to_string).unwrap_or_else(own),
|
||||
"text" if pid == "animators" => "ADBE Text Animator".into(),
|
||||
"text" if grand.map(|g| g.match_id()) == Some("animators") => find(TEXT_ANIMATOR, id).map(str::to_string).unwrap_or_else(own),
|
||||
"cameraOptions" => find(CAMERA, id).map(str::to_string).unwrap_or_else(own),
|
||||
"lightOptions" => find(LIGHT, id).map(str::to_string).unwrap_or_else(own),
|
||||
"materialOptions" => find(MATERIAL, id).map(str::to_string).unwrap_or_else(own),
|
||||
"audio" if id == "levels" => "ADBE Audio Levels".into(),
|
||||
"contents" => {
|
||||
// Items sit in a `contents` list; their properties under the item.
|
||||
if pid == "contents" {
|
||||
if id == "star" || id == "polygon" {
|
||||
return "ADBE Vector Shape - Star".into();
|
||||
}
|
||||
return find(SHAPE_ITEMS, id).map(str::to_string).unwrap_or_else(own);
|
||||
}
|
||||
if let Some((_, _, m)) = SHAPE_PROPS.iter().find(|(item, prop, _)| *item == pid && *prop == id) {
|
||||
return m.to_string();
|
||||
}
|
||||
own()
|
||||
}
|
||||
_ => own(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn effect_names_round_trip() {
|
||||
for (id, m) in EFFECTS {
|
||||
assert_eq!(effect(id), *m);
|
||||
assert_eq!(effect_from_match(m), Some(*id));
|
||||
}
|
||||
assert_eq!(effect("ec.blur.ccvector"), "ec.blur.ccvector");
|
||||
let mut ids: Vec<&str> = EFFECTS.iter().map(|(a, _)| *a).collect();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), EFFECTS.len(), "duplicate effect ids");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_kinds() {
|
||||
assert_eq!(shape_kind("ADBE Vector Shape - Rect"), Some("rect"));
|
||||
assert_eq!(shape_kind("Trim Paths"), Some("trim"));
|
||||
assert_eq!(shape_kind("ADBE Vector Graphic - Fill"), Some("fill"));
|
||||
assert_eq!(shape_kind("nope"), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,679 @@
|
|||
//! Read-only queries behind the object model (`__query(kind, json)`): project, items, layers,
|
||||
//! property nodes, values and keyframes as JSON shaped for the prelude. Every edit goes through
|
||||
//! engine commands instead (`__exec`), so it is undoable and journaled.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::color::Label;
|
||||
use effectcraft_keyframe::{Interp, TextDoc, Value as KV};
|
||||
use effectcraft_project::{Comp, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, PropGroup};
|
||||
use effectcraft_render::EvalCtx;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value as J, json};
|
||||
|
||||
use crate::matchnames;
|
||||
|
||||
type R = Result<J, String>;
|
||||
|
||||
fn u(a: &J, k: &str) -> Option<u64> {
|
||||
a.get(k).and_then(J::as_u64)
|
||||
}
|
||||
fn f(a: &J, k: &str) -> Option<f64> {
|
||||
a.get(k).and_then(J::as_f64)
|
||||
}
|
||||
|
||||
fn comp_of<'a>(s: &'a Session, a: &J) -> Result<(ItemId, &'a Comp), String> {
|
||||
let id = ItemId(u(a, "comp").ok_or("missing comp")?);
|
||||
s.project.comp(id).map(|c| (id, c)).ok_or_else(|| "the composition no longer exists".to_string())
|
||||
}
|
||||
|
||||
fn layer_of<'a>(s: &'a Session, a: &J) -> Result<(ItemId, &'a Comp, &'a Layer), String> {
|
||||
let (cid, c) = comp_of(s, a)?;
|
||||
let l = c.layer(LayerId(u(a, "layer").ok_or("missing layer")?)).ok_or("the layer no longer exists (it was deleted)")?;
|
||||
Ok((cid, c, l))
|
||||
}
|
||||
|
||||
pub fn query(s: &mut Session, kind: &str, a: &J) -> R {
|
||||
match kind {
|
||||
"project" => Ok(project(s)),
|
||||
"item" => item(s, a),
|
||||
"layer" => layer(s, a),
|
||||
"node" => node(s, a),
|
||||
"child" => child(s, a),
|
||||
"value" => value(s, a),
|
||||
"keys" => keys(s, a),
|
||||
"textdoc" => textdoc(s, a),
|
||||
"markers" => markers(s, a),
|
||||
"sourceRect" => source_rect(s, a),
|
||||
"resolveAdd" => resolve_add(s, a),
|
||||
"menuCommandId" => Ok(menu_command_id(a.get("name").and_then(J::as_str).unwrap_or(""))),
|
||||
"commandAt" => {
|
||||
let i = u(a, "index").unwrap_or(0) as usize;
|
||||
Ok(effectcraft_engine::command_specs().get(i.wrapping_sub(1)).map(|c| json!(c.id)).unwrap_or(J::Null))
|
||||
}
|
||||
"exprError" => {
|
||||
let code = a.get("code").and_then(J::as_str).unwrap_or("");
|
||||
Ok(match s.expr_check {
|
||||
Some(check) => json!(check(code).err().unwrap_or_default()),
|
||||
None => json!(""),
|
||||
})
|
||||
}
|
||||
"labels" => Ok(json!(Label::ALL.iter().map(|l| l.name()).collect::<Vec<_>>())),
|
||||
"effects" => Ok(json!(
|
||||
effectcraft_engine::effects::registry()
|
||||
.iter()
|
||||
.map(|e| json!({"id": e.id, "name": e.name, "matchName": matchnames::effect(e.id), "category": e.category}))
|
||||
.collect::<Vec<_>>()
|
||||
)),
|
||||
_ => Err(format!("unknown query `{kind}`")),
|
||||
}
|
||||
}
|
||||
|
||||
fn project(s: &Session) -> J {
|
||||
let p = &s.project;
|
||||
let items: Vec<u64> = p.items.keys().map(|k| k.0).collect();
|
||||
let active = s.active_comp_id().map(|c| c.0).or_else(|| (s.state.project_selection.len() == 1).then(|| s.state.project_selection[0].0));
|
||||
json!({
|
||||
"items": items,
|
||||
"active": active,
|
||||
"selection": s.state.project_selection.iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
"path": s.path,
|
||||
"dirty": s.is_dirty(),
|
||||
"bitsPerChannel": p.settings.bit_depth.bits(),
|
||||
"linearBlending": p.settings.blend_linear,
|
||||
"linearizeWorkingSpace": p.settings.linearize,
|
||||
"workingSpace": p.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default(),
|
||||
"timeDisplayFrames": p.settings.time_display == effectcraft_project::TimeDisplayStyle::Frames,
|
||||
"renderQueue": p.render_queue.len(),
|
||||
"revision": s.revision,
|
||||
})
|
||||
}
|
||||
|
||||
fn label_index(l: Label) -> usize {
|
||||
Label::ALL.iter().position(|x| *x == l).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn item(s: &Session, a: &J) -> R {
|
||||
let id = ItemId(u(a, "id").ok_or("missing id")?);
|
||||
let it = s.project.item(id).ok_or("the item no longer exists (it was deleted)")?;
|
||||
let mut o = json!({
|
||||
"id": id.0,
|
||||
"name": it.name,
|
||||
"typeName": it.type_name(),
|
||||
"parent": it.parent.map(|p| p.0).unwrap_or(0),
|
||||
"comment": it.comment,
|
||||
"label": label_index(it.label),
|
||||
"selected": s.state.project_selection.contains(&id),
|
||||
});
|
||||
let used_in: Vec<u64> = s.project.comps().filter(|(_, c)| c.layers.iter().any(|l| l.source.item() == Some(id))).map(|(cid, _)| cid.0).collect();
|
||||
o["usedIn"] = json!(used_in);
|
||||
match &it.kind {
|
||||
ItemKind::Folder => {
|
||||
o["kind"] = json!("folder");
|
||||
o["children"] = json!(s.project.items.values().filter(|c| c.parent == Some(id)).map(|c| c.id.0).collect::<Vec<_>>());
|
||||
}
|
||||
ItemKind::Comp(c) => {
|
||||
let cid = id;
|
||||
o["kind"] = json!("comp");
|
||||
let sel: Vec<u64> = s.state.selected_layers.iter().filter(|l| c.layer(**l).is_some()).map(|l| l.0).collect();
|
||||
let fps = c.frame_rate.as_f64();
|
||||
if let Some(m) = o.as_object_mut() {
|
||||
m.extend(
|
||||
json!({
|
||||
"width": c.width, "height": c.height, "pixelAspect": c.pixel_aspect,
|
||||
"duration": c.duration.seconds(), "frameRate": fps, "frameDuration": c.frame_duration().seconds(),
|
||||
"layers": c.layers.iter().map(|l| l.id.0).collect::<Vec<_>>(),
|
||||
"selectedLayers": sel,
|
||||
"selectedProperties": s.state.selected_props.iter().filter(|(l, _)| c.layer(*l).is_some()).map(|(l, p)| json!([l.0, p])).collect::<Vec<_>>(),
|
||||
"time": s.time_of(cid).seconds(),
|
||||
"workAreaStart": c.work_area.0.seconds(), "workAreaDuration": (c.work_area.1 - c.work_area.0).seconds(),
|
||||
"bgColor": c.background, "displayStartTime": c.display_start.seconds(),
|
||||
"hideShyLayers": c.hide_shy, "motionBlur": c.enable_motion_blur, "frameBlending": c.enable_frame_blending,
|
||||
"draft3d": c.draft_3d, "shutterAngle": c.shutter_angle, "shutterPhase": c.shutter_phase,
|
||||
"motionBlurSamplesPerFrame": c.motion_blur_samples,
|
||||
"renderer": match c.renderer { effectcraft_project::Renderer::Classic3D => "ADBE Classic 3D", effectcraft_project::Renderer::Advanced3D => "ADBE Advanced 3d" },
|
||||
"open": s.state.open_comps.contains(&cid), "active": s.active_comp_id() == Some(cid),
|
||||
"hasVideo": true, "hasAudio": c.layers.iter().any(|l| l.switches.audio && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. })),
|
||||
"posterTime": c.poster_time.seconds(),
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
}
|
||||
ItemKind::Footage(fo) => {
|
||||
o["kind"] = json!("footage");
|
||||
o["file"] = json!(fo.path);
|
||||
o["width"] = json!(fo.width);
|
||||
o["height"] = json!(fo.height);
|
||||
o["pixelAspect"] = json!(fo.pixel_aspect);
|
||||
o["duration"] = json!(if fo.kind == effectcraft_project::FootageKind::Still { 0.0 } else { fo.duration.seconds() });
|
||||
o["frameRate"] = json!(if fo.kind == effectcraft_project::FootageKind::Still { 0.0 } else { fo.frame_rate.as_f64() });
|
||||
o["hasVideo"] = json!(fo.has_video);
|
||||
o["hasAudio"] = json!(fo.has_audio);
|
||||
o["footageMissing"] = json!(fo.missing);
|
||||
o["isStill"] = json!(fo.kind == effectcraft_project::FootageKind::Still);
|
||||
o["loop"] = json!(fo.loop_count);
|
||||
}
|
||||
ItemKind::Solid(so) => {
|
||||
o["kind"] = json!("solid");
|
||||
o["width"] = json!(so.width);
|
||||
o["height"] = json!(so.height);
|
||||
o["pixelAspect"] = json!(so.pixel_aspect);
|
||||
o["color"] = json!(so.color);
|
||||
o["duration"] = json!(0.0);
|
||||
o["frameRate"] = json!(0.0);
|
||||
o["hasVideo"] = json!(true);
|
||||
o["hasAudio"] = json!(false);
|
||||
o["isStill"] = json!(true);
|
||||
}
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
fn layer(s: &Session, a: &J) -> R {
|
||||
let (cid, c, l) = layer_of(s, a)?;
|
||||
let kind = match &l.source {
|
||||
LayerSource::Text => "text",
|
||||
LayerSource::Shape => "shape",
|
||||
LayerSource::Camera => "camera",
|
||||
LayerSource::Light { .. } => "light",
|
||||
_ => "av",
|
||||
};
|
||||
let (w, h) = effectcraft_render::source_size(&s.project, l);
|
||||
let t = s.time_of(cid);
|
||||
Ok(json!({
|
||||
"id": l.id.0,
|
||||
"comp": cid.0,
|
||||
"kind": kind,
|
||||
"name": l.name,
|
||||
"index": c.index_of(l.id).unwrap_or(0),
|
||||
"matchName": matchnames::layer(&l.source),
|
||||
"root": l.props.uid,
|
||||
"source": l.source.item().map(|i| i.0),
|
||||
"nullLayer": matches!(l.source, LayerSource::Null),
|
||||
"lightType": match &l.source { LayerSource::Light { kind } => kind.label(), _ => "" },
|
||||
"enabled": l.switches.video,
|
||||
"audioEnabled": l.switches.audio,
|
||||
"solo": l.switches.solo,
|
||||
"locked": l.switches.locked,
|
||||
"shy": l.switches.shy,
|
||||
"collapse": l.switches.collapse,
|
||||
"quality": format!("{:?}", l.switches.quality),
|
||||
"samplingQuality": format!("{:?}", l.switches.sampling),
|
||||
"effectsActive": l.switches.effects,
|
||||
"frameBlending": format!("{:?}", l.switches.frame_blend),
|
||||
"motionBlur": l.switches.motion_blur,
|
||||
"adjustmentLayer": l.switches.adjustment,
|
||||
"threeDLayer": l.switches.three_d,
|
||||
"guideLayer": l.switches.guide,
|
||||
"preserveTransparency": l.preserve_transparency,
|
||||
"inPoint": l.in_point.seconds(),
|
||||
"outPoint": l.out_point.seconds(),
|
||||
"startTime": l.start_time.seconds(),
|
||||
"stretch": l.stretch,
|
||||
"time": l.layer_time(t).seconds(),
|
||||
"active": l.switches.video && l.is_active_at(t),
|
||||
"parent": l.parent.map(|p| p.0),
|
||||
"blendingMode": l.blend_mode.label(),
|
||||
"trackMatte": l.track_matte.map(|m| json!({"layer": m.layer.0, "kind": format!("{:?}", m.kind)})),
|
||||
"isTrackMatte": c.layers.iter().any(|x| x.track_matte.is_some_and(|m| m.layer == l.id)),
|
||||
"label": label_index(l.label),
|
||||
"comment": l.comment,
|
||||
"selected": s.state.selected_layers.contains(&l.id),
|
||||
"autoOrient": format!("{:?}", l.auto_orient),
|
||||
"width": w, "height": h,
|
||||
"hasVideo": l.source.is_av(),
|
||||
"hasAudio": matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. }) && l.props.sub("audio").is_some(),
|
||||
"timeRemapEnabled": l.props.prop("timeRemap").is_some(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Node at the end of a uid chain below the layer root, and the chain itself.
|
||||
fn chain(l: &Layer, uid: u64) -> Option<Vec<&Node>> {
|
||||
l.props.node_chain(uid)
|
||||
}
|
||||
|
||||
/// PropertyValueType of a property.
|
||||
fn value_type(p: &effectcraft_project::Property) -> &'static str {
|
||||
if matches!(p.ui, ParamUi::Mask) {
|
||||
return "MASK_INDEX";
|
||||
}
|
||||
match &p.value {
|
||||
KV::Scalar(_) | KV::Bool(_) | KV::Enum(_) => "OneD",
|
||||
KV::Vec2(_) => {
|
||||
if p.spatial {
|
||||
"TwoD_SPATIAL"
|
||||
} else {
|
||||
"TwoD"
|
||||
}
|
||||
}
|
||||
KV::Vec3(_) => {
|
||||
if p.spatial {
|
||||
"ThreeD_SPATIAL"
|
||||
} else {
|
||||
"ThreeD"
|
||||
}
|
||||
}
|
||||
KV::Color(_) => "COLOR",
|
||||
KV::Path(_) => "SHAPE",
|
||||
KV::Text(_) => "TEXT_DOCUMENT",
|
||||
KV::Layer(_) => "LAYER_INDEX",
|
||||
KV::Gradient(_) | KV::Str(_) => "CUSTOM_VALUE",
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups whose children are instances (effects, masks, shape items, animators).
|
||||
fn indexed_parade(g: &PropGroup) -> bool {
|
||||
matches!(g.match_id.as_str(), "effects" | "masks" | "contents" | "animators" | "selectors" | "layerStyles" | "motionTrackers")
|
||||
}
|
||||
|
||||
fn node_json(c: &Comp, l: &Layer, uid: u64) -> R {
|
||||
if uid == 0 || uid == l.props.uid {
|
||||
let n = l.props.children.len();
|
||||
return Ok(json!({
|
||||
"uid": l.props.uid, "kind": "group", "name": l.name, "matchName": matchnames::layer(&l.source), "matchId": "layer",
|
||||
"index": c.index_of(l.id).unwrap_or(0), "depth": 0, "parent": null, "isLayer": true, "numProperties": n,
|
||||
"children": l.props.children.iter().map(Node::uid).collect::<Vec<_>>(), "propertyType": "NAMED_GROUP",
|
||||
"enabled": l.switches.video, "canSetEnabled": true,
|
||||
}));
|
||||
}
|
||||
let ch = chain(l, uid).ok_or("the property no longer exists (it was deleted)")?;
|
||||
let node = *ch.last().ok_or("bad property")?;
|
||||
let parent_group: &PropGroup = if ch.len() >= 2 { ch[ch.len() - 2].as_group().ok_or("bad tree")? } else { &l.props };
|
||||
let index = parent_group.children.iter().position(|x| x.uid() == uid).map(|i| i + 1).unwrap_or(0);
|
||||
let parent_uid = if ch.len() >= 2 { ch[ch.len() - 2].uid() } else { l.props.uid };
|
||||
let match_name = matchnames::node(&ch);
|
||||
let instance_parent = indexed_parade(parent_group) && ch.len() >= 2 || (ch.len() == 2 && ch[0].match_id() == "contents");
|
||||
let mut o = json!({
|
||||
"uid": uid, "name": node.name(), "matchName": match_name, "matchId": node.match_id(), "index": index,
|
||||
"depth": ch.len(), "parent": parent_uid, "isLayer": false,
|
||||
"isEffect": matches!(node.as_group().map(|g| &g.kind), Some(GroupKind::Effect { .. })),
|
||||
"isMask": matches!(node.as_group().map(|g| &g.kind), Some(GroupKind::Mask { .. })),
|
||||
});
|
||||
match node {
|
||||
Node::Group(g) => {
|
||||
let instance = matches!(g.kind, GroupKind::Indexed | GroupKind::Effect { .. } | GroupKind::Mask { .. } | GroupKind::Tracker { .. });
|
||||
o["kind"] = json!("group");
|
||||
o["numProperties"] = json!(g.children.len());
|
||||
o["children"] = json!(g.children.iter().map(Node::uid).collect::<Vec<_>>());
|
||||
o["propertyType"] = json!(if indexed_parade(g) { "INDEXED_GROUP" } else { "NAMED_GROUP" });
|
||||
o["enabled"] = json!(g.enabled);
|
||||
o["canSetEnabled"] = json!(instance);
|
||||
o["instance"] = json!(instance || instance_parent);
|
||||
o["effectId"] = json!(match &g.kind {
|
||||
GroupKind::Effect { effect } => Some(effect.clone()),
|
||||
_ => None,
|
||||
});
|
||||
if let GroupKind::Mask { mode, inverted, color, locked, .. } = &g.kind {
|
||||
o["maskMode"] = json!(mode.label());
|
||||
o["inverted"] = json!(inverted);
|
||||
o["color"] = json!([color[0] as f64 / 255.0, color[1] as f64 / 255.0, color[2] as f64 / 255.0]);
|
||||
o["locked"] = json!(locked);
|
||||
}
|
||||
}
|
||||
Node::Prop(p) => {
|
||||
o["kind"] = json!("prop");
|
||||
o["propertyType"] = json!("PROPERTY");
|
||||
o["valueType"] = json!(value_type(p));
|
||||
o["type"] = json!(p.value.kind_name());
|
||||
o["dims"] = json!(p.value.dims());
|
||||
o["spatial"] = json!(p.spatial);
|
||||
o["numKeys"] = json!(p.keys.len());
|
||||
o["expression"] = json!(p.expr.as_ref().map(|e| e.text.clone()).unwrap_or_default());
|
||||
o["expressionEnabled"] = json!(p.has_expression());
|
||||
o["canVaryOverTime"] = json!(!p.static_only);
|
||||
o["canSetExpression"] = json!(!p.static_only && !matches!(p.ui, ParamUi::Hidden));
|
||||
o["hidden"] = json!(matches!(p.ui, ParamUi::Hidden));
|
||||
o["enabled"] = json!(true);
|
||||
o["canSetEnabled"] = json!(false);
|
||||
o["isModified"] = json!(p.is_animated() || p.has_expression());
|
||||
if let ParamUi::Slider { min, max, .. } = p.ui {
|
||||
o["min"] = json!(min);
|
||||
o["max"] = json!(max);
|
||||
}
|
||||
if let ParamUi::Popup { options } = &p.ui {
|
||||
o["options"] = json!(options);
|
||||
}
|
||||
o["ui"] = json!(match &p.ui {
|
||||
ParamUi::Percent => "%",
|
||||
ParamUi::Angle => "degrees",
|
||||
ParamUi::Pixels | ParamUi::Point | ParamUi::Point3 => "pixels",
|
||||
_ => "",
|
||||
});
|
||||
// Position with Separate Dimensions.
|
||||
o["separated"] = json!(node.match_id() == "position" && parent_group.match_id == "transform" && parent_group.get("positionX").is_some());
|
||||
o["separationFollower"] = json!(matches!(node.match_id(), "positionX" | "positionY" | "positionZ"));
|
||||
}
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
fn node(s: &Session, a: &J) -> R {
|
||||
let (_, c, l) = layer_of(s, a)?;
|
||||
node_json(c, l, u(a, "uid").unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Normalized name for loose lookups: `Anchor Point`, `anchorPoint`, `anchor_point` → `anchorpoint`.
|
||||
fn loose(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// AE-style aliases for our match ids (attribute names scripts use).
|
||||
fn alias(k: &str) -> Option<&'static str> {
|
||||
Some(match k {
|
||||
"anchorpoint" => "anchor",
|
||||
"pointofinterest" => "poi",
|
||||
"xrotation" => "rotationX",
|
||||
"yrotation" => "rotationY",
|
||||
"zrotation" => "rotation",
|
||||
"xposition" => "positionX",
|
||||
"yposition" => "positionY",
|
||||
"zposition" => "positionZ",
|
||||
"effect" | "effects" => "effects",
|
||||
"mask" | "masks" => "masks",
|
||||
"content" | "contents" => "contents",
|
||||
"maskshape" | "maskpath" => "path",
|
||||
"maskfeather" => "feather",
|
||||
"maskopacity" => "opacity",
|
||||
"maskexpansion" | "maskoffset" => "expansion",
|
||||
"sourcetext" => "sourceText",
|
||||
"timeremap" => "timeRemap",
|
||||
"audiolevels" => "levels",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn child(s: &Session, a: &J) -> R {
|
||||
let (_, c, l) = layer_of(s, a)?;
|
||||
let uid = u(a, "uid").unwrap_or(0);
|
||||
let g: &PropGroup = if uid == 0 || uid == l.props.uid { &l.props } else { l.props.find_group(uid).ok_or("not a property group")? };
|
||||
let key = a.get("key").cloned().unwrap_or(J::Null);
|
||||
let found = match &key {
|
||||
J::Number(n) => n.as_u64().and_then(|i| g.children.get((i as usize).checked_sub(1)?)),
|
||||
J::String(k) => {
|
||||
let chain_of = |n: &Node| -> String {
|
||||
let mut ch = if uid == 0 || uid == l.props.uid { vec![] } else { chain(l, uid).unwrap_or_default() };
|
||||
ch.push(n);
|
||||
matchnames::node(&ch)
|
||||
};
|
||||
let lk = loose(k);
|
||||
g.children
|
||||
.iter()
|
||||
.find(|n| chain_of(n) == *k)
|
||||
.or_else(|| g.children.iter().find(|n| n.name() == k))
|
||||
.or_else(|| g.children.iter().find(|n| n.match_id() == k))
|
||||
.or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k)))
|
||||
.or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk))
|
||||
.or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m)))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match found {
|
||||
Some(n) => node_json(c, l, n.uid()),
|
||||
None => Ok(J::Null),
|
||||
}
|
||||
}
|
||||
|
||||
/// A value as the object model shows it: popups 1-based, checkboxes 0/1, layer references as
|
||||
/// layer indices, paths as `{vertices, inTangents, outTangents, closed}`, text as the document.
|
||||
fn js_value(c: &Comp, v: &KV) -> J {
|
||||
match v {
|
||||
KV::Bool(b) => json!(if *b { 1 } else { 0 }),
|
||||
KV::Enum(i) => json!(*i + 1),
|
||||
KV::Layer(l) => json!(l.and_then(|id| c.index_of(LayerId(id))).unwrap_or(0)),
|
||||
KV::Path(p) => json!({"vertices": p.vertices, "inTangents": p.in_tangents, "outTangents": p.out_tangents, "closed": p.closed}),
|
||||
KV::Text(d) => text_json(d),
|
||||
v => v.to_json(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A text document for `TextDocument`: the serialized document plus the first character's style.
|
||||
fn text_json(d: &TextDoc) -> J {
|
||||
let mut doc = serde_json::to_value(d).unwrap_or(J::Null);
|
||||
let st = d.style_at(0);
|
||||
if let Some(o) = doc.as_object_mut() {
|
||||
o.insert("first".into(), effectcraft_keyframe::text_doc::char_style_json(&st));
|
||||
o.insert("para".into(), effectcraft_keyframe::text_doc::para_style_json(&d.para(0)));
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
fn value(s: &Session, a: &J) -> R {
|
||||
let (cid, c, l) = layer_of(s, a)?;
|
||||
let uid = u(a, "uid").ok_or("missing uid")?;
|
||||
let p = l.props.find(uid).ok_or("the property no longer exists (it was deleted)")?;
|
||||
let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
|
||||
let pre = a.get("pre").and_then(J::as_bool).unwrap_or(false);
|
||||
let v = if pre || !p.has_expression() {
|
||||
p.value_at(l.layer_time(t))
|
||||
} else {
|
||||
let mut ctx = EvalCtx::new(&s.project, cid, c, t);
|
||||
ctx.expr = s.expr.as_deref();
|
||||
ctx.value(l, p)
|
||||
};
|
||||
Ok(js_value(c, &v))
|
||||
}
|
||||
|
||||
fn interp(i: Interp) -> &'static str {
|
||||
match i {
|
||||
Interp::Linear => "LINEAR",
|
||||
Interp::Bezier => "BEZIER",
|
||||
Interp::Hold => "HOLD",
|
||||
}
|
||||
}
|
||||
|
||||
fn keys(s: &Session, a: &J) -> R {
|
||||
let (cid, c, l) = layer_of(s, a)?;
|
||||
let uid = u(a, "uid").ok_or("missing uid")?;
|
||||
let p = l.props.find(uid).ok_or("the property no longer exists (it was deleted)")?;
|
||||
let sel: Vec<Tick> = s.state.selected_keys.iter().filter(|k| k.layer == l.id && k.prop == uid).map(|k| k.time).collect();
|
||||
let _ = cid;
|
||||
let ease = |e: &[effectcraft_keyframe::Ease], n: usize| -> J {
|
||||
let n = n.max(1);
|
||||
// Influence is stored as a fraction; scripts see percent.
|
||||
let mut v: Vec<J> = e.iter().map(|x| json!({"speed": x.speed, "influence": x.influence * 100.0})).collect();
|
||||
let d = e.first().cloned().unwrap_or_default();
|
||||
while v.len() < n {
|
||||
v.push(json!({"speed": d.speed, "influence": d.influence * 100.0}));
|
||||
}
|
||||
J::Array(v)
|
||||
};
|
||||
let dims = if p.spatial { 1 } else { p.value.dims().max(1) };
|
||||
let out: Vec<J> = p
|
||||
.keys
|
||||
.iter()
|
||||
.map(|k| {
|
||||
json!({
|
||||
"time": l.comp_time(k.time).seconds(),
|
||||
"layerTime": k.time.seconds(),
|
||||
"value": js_value(c, &k.value),
|
||||
"inInterp": interp(k.in_interp), "outInterp": interp(k.out_interp),
|
||||
"inEase": ease(&k.in_ease, dims), "outEase": ease(&k.out_ease, dims),
|
||||
"continuous": k.continuous, "autoBezier": k.auto_bezier, "roving": k.roving,
|
||||
"spatialIn": k.spatial_in, "spatialOut": k.spatial_out,
|
||||
"spatialContinuous": k.spatial_continuous, "spatialAutoBezier": k.spatial_auto,
|
||||
"selected": sel.contains(&k.time),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(J::Array(out))
|
||||
}
|
||||
|
||||
/// Apply `layer.setText` attribute keys to the Source Text document at `time` and return the new
|
||||
/// document (for `prop.set` / `prop.addKey`).
|
||||
fn textdoc(s: &Session, a: &J) -> R {
|
||||
let (cid, _, l) = layer_of(s, a)?;
|
||||
let uid = u(a, "uid").ok_or("missing uid")?;
|
||||
let p = l.props.find(uid).ok_or("the property no longer exists")?;
|
||||
let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
|
||||
let KV::Text(doc) = p.value_at(l.layer_time(t)) else { return Err("not a Source Text property".into()) };
|
||||
let mut d = *doc;
|
||||
if let Some(attrs) = a.get("attrs").and_then(J::as_object) {
|
||||
if let Some(text) = attrs.get("text").and_then(J::as_str) {
|
||||
d.set_text(text);
|
||||
}
|
||||
for (k, v) in attrs {
|
||||
if k == "text" || v.is_null() {
|
||||
continue;
|
||||
}
|
||||
match d.set_attr(k, v, None) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => return Err(format!("TextDocument: unknown attribute `{k}`")),
|
||||
Err(e) => return Err(format!("TextDocument.{k}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::to_value(&d).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn markers(s: &Session, a: &J) -> R {
|
||||
let (_, c) = comp_of(s, a)?;
|
||||
let (list, to_comp): (&[effectcraft_project::Marker], Option<&Layer>) = match u(a, "layer") {
|
||||
Some(lid) => {
|
||||
let l = c.layer(LayerId(lid)).ok_or("the layer no longer exists")?;
|
||||
(&l.markers, Some(l))
|
||||
}
|
||||
None => (&c.markers, None),
|
||||
};
|
||||
let out: Vec<J> = list
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let t = to_comp.map(|l| l.comp_time(m.time)).unwrap_or(m.time);
|
||||
json!({
|
||||
"time": t.seconds(), "duration": m.duration.seconds(), "comment": m.comment, "chapter": m.chapter, "url": m.url,
|
||||
"frameTarget": m.frame_target, "label": label_index(m.label), "protectedRegion": m.protected,
|
||||
"cuePointName": m.cue_point.as_ref().map(|c| c.name.clone()).unwrap_or_default(),
|
||||
"eventCuePoint": m.cue_point.as_ref().is_some_and(|c| !c.navigation),
|
||||
"params": m.cue_point.as_ref().map(|c| c.params.clone()).unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(J::Array(out))
|
||||
}
|
||||
|
||||
fn source_rect(s: &Session, a: &J) -> R {
|
||||
let (cid, c, l) = layer_of(s, a)?;
|
||||
let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
|
||||
let mut ctx = EvalCtx::new(&s.project, cid, c, t);
|
||||
ctx.expr = s.expr.as_deref();
|
||||
let r = match &l.source {
|
||||
LayerSource::Text => {
|
||||
let paths: Vec<_> = effectcraft_render::text::glyph_paths(&ctx, l).into_iter().map(|(p, _)| p).collect();
|
||||
effectcraft_path::bounds(&paths).map(|b| [b.y0, b.x0, b.width(), b.height()]).unwrap_or([0.0; 4])
|
||||
}
|
||||
LayerSource::Shape => match l.props.sub("contents") {
|
||||
Some(contents) => {
|
||||
let buf = effectcraft_render::shapes::render(&ctx, l, contents, 1.0);
|
||||
if buf.img.width <= 4 { [0.0; 4] } else { [2.0 - buf.offset[1], 2.0 - buf.offset[0], buf.img.width as f64 - 4.0, buf.img.height as f64 - 4.0] }
|
||||
}
|
||||
None => [0.0; 4],
|
||||
},
|
||||
_ => {
|
||||
let (w, h) = effectcraft_render::source_size(&s.project, l);
|
||||
[0.0, 0.0, w as f64, h as f64]
|
||||
}
|
||||
};
|
||||
Ok(json!({"top": r[0], "left": r[1], "width": r[2], "height": r[3]}))
|
||||
}
|
||||
|
||||
/// What `PropertyGroup.addProperty(name)` adds to the group `uid`.
|
||||
fn resolve_add(s: &Session, a: &J) -> R {
|
||||
let (_, _, l) = layer_of(s, a)?;
|
||||
let uid = u(a, "uid").unwrap_or(0);
|
||||
let name = a.get("name").and_then(J::as_str).unwrap_or("");
|
||||
let g: &PropGroup = if uid == 0 || uid == l.props.uid { &l.props } else { l.props.find_group(uid).ok_or("not a property group")? };
|
||||
let in_contents = g.match_id == "contents";
|
||||
let in_group = l.props.sub("contents").is_some_and(|c| c.uid == g.uid || c.find_group(g.uid).is_some());
|
||||
Ok(match g.match_id.as_str() {
|
||||
"effects" => {
|
||||
let id = matchnames::effect_from_match(name).map(str::to_string).or_else(|| effectcraft_engine::effects::lookup(name).map(|e| e.id.to_string()));
|
||||
match id {
|
||||
Some(id) => json!({"kind": "effect", "effect": id}),
|
||||
None => json!({"error": format!("Can not add a property with name \"{name}\" to this PropertyGroup (unknown effect)")}),
|
||||
}
|
||||
}
|
||||
"masks" => {
|
||||
if name == "ADBE Mask Atom" || loose(name) == "mask" {
|
||||
json!({"kind": "mask"})
|
||||
} else {
|
||||
json!({"error": format!("Can not add \"{name}\" to Masks (use \"ADBE Mask Atom\")")})
|
||||
}
|
||||
}
|
||||
"animators" => json!({"kind": "animator"}),
|
||||
_ if in_contents && in_group => match matchnames::shape_kind(name) {
|
||||
Some(k) => {
|
||||
json!({"kind": "shape", "shape": k, "group": if l.props.sub("contents").is_some_and(|c| c.uid == g.uid) { J::Null } else { json!(g.uid) }})
|
||||
}
|
||||
None => json!({"error": format!("Can not add \"{name}\" to Contents")}),
|
||||
},
|
||||
"properties" if l.props.group("text/animators").is_some_and(|a| a.find_group(g.uid).is_some()) => {
|
||||
let animator = l.props.group("text/animators").and_then(|a| a.groups().find(|x| x.find_group(g.uid).is_some()).map(|x| x.uid));
|
||||
match text_animator_property(name) {
|
||||
Some(k) => json!({"kind": "animatorProperty", "property": k, "animator": animator}),
|
||||
None => json!({"error": format!("Can not add \"{name}\" to an animator's Properties")}),
|
||||
}
|
||||
}
|
||||
"selectors" if l.props.group("text/animators").is_some_and(|a| a.find_group(g.uid).is_some()) => {
|
||||
let animator = l.props.group("text/animators").and_then(|a| a.groups().find(|x| x.find_group(g.uid).is_some()).map(|x| x.uid));
|
||||
let kind = match name {
|
||||
"ADBE Text Wiggly Selector" => "wiggly",
|
||||
"ADBE Text Expressible Selector" => "expression",
|
||||
n if loose(n).contains("wiggly") => "wiggly",
|
||||
n if loose(n).contains("expression") => "expression",
|
||||
_ => "range",
|
||||
};
|
||||
json!({"kind": "selector", "selector": kind, "animator": animator})
|
||||
}
|
||||
_ => json!({"error": format!("Can not add a property to \"{}\"", g.name)}),
|
||||
})
|
||||
}
|
||||
|
||||
/// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match
|
||||
/// name or a display name.
|
||||
fn text_animator_property(name: &str) -> Option<&'static str> {
|
||||
const AE: &[(&str, &str)] = &[
|
||||
("ADBE Text Anchor Point 3D", "anchor"),
|
||||
("ADBE Text Position 3D", "position"),
|
||||
("ADBE Text Scale 3D", "scale"),
|
||||
("ADBE Text Skew", "skew"),
|
||||
("ADBE Text Rotation", "rotation"),
|
||||
("ADBE Text Opacity", "opacity"),
|
||||
("ADBE Text Fill Color", "fillColor"),
|
||||
("ADBE Text Fill Hue", "fillHue"),
|
||||
("ADBE Text Fill Saturation", "fillSaturation"),
|
||||
("ADBE Text Fill Brightness", "fillBrightness"),
|
||||
("ADBE Text Fill Opacity", "fillOpacity"),
|
||||
("ADBE Text Stroke Color", "strokeColor"),
|
||||
("ADBE Text Stroke Hue", "strokeHue"),
|
||||
("ADBE Text Stroke Saturation", "strokeSaturation"),
|
||||
("ADBE Text Stroke Brightness", "strokeBrightness"),
|
||||
("ADBE Text Stroke Opacity", "strokeOpacity"),
|
||||
("ADBE Text Stroke Width", "strokeWidth"),
|
||||
("ADBE Text Tracking Amount", "tracking"),
|
||||
("ADBE Text Line Anchor", "lineAnchor"),
|
||||
("ADBE Text Line Spacing", "lineSpacing"),
|
||||
("ADBE Text Character Offset", "characterOffset"),
|
||||
("ADBE Text Character Replace", "characterValue"),
|
||||
("ADBE Text Blur", "blur"),
|
||||
];
|
||||
if let Some((_, k)) = AE.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) {
|
||||
return Some(k);
|
||||
}
|
||||
let n = loose(name);
|
||||
AE.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking"))
|
||||
}
|
||||
|
||||
/// `app.findMenuCommandId(name)`: 1-based index of the command whose label matches (menu items
|
||||
/// first), 0 when none does.
|
||||
fn menu_command_id(name: &str) -> J {
|
||||
let norm = |s: &str| s.trim().trim_end_matches("...").trim_end_matches('…').trim().to_ascii_lowercase();
|
||||
let n = norm(name);
|
||||
let specs = effectcraft_engine::command_specs();
|
||||
let pick = specs
|
||||
.iter()
|
||||
.position(|c| !c.menu.is_empty() && norm(c.label) == n)
|
||||
.or_else(|| specs.iter().position(|c| norm(c.label) == n))
|
||||
.or_else(|| specs.iter().position(|c| c.id == name));
|
||||
json!(pick.map(|i| i + 1).unwrap_or(0))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,546 @@
|
|||
//! The boa runtime: native functions (`__exec`, `__query`, `__print`, `__undo`, `__task`,
|
||||
//! `__file`), the session hand-off while a script runs, undo groups and error reporting.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::mem::ManuallyDrop;
|
||||
use std::sync::Arc;
|
||||
|
||||
use boa_engine::{Context, JsNativeError, JsResult, JsString, JsValue, NativeFunction, Source, js_string};
|
||||
use effectcraft_engine::project::Project;
|
||||
use effectcraft_engine::{ScriptRequest, Session};
|
||||
use serde_json::{Value as J, json};
|
||||
|
||||
const PRELUDE: &str = include_str!("prelude.js");
|
||||
|
||||
/// Engine commands that create layers: like After Effects, scripts add new layers at the top of
|
||||
/// the stack (the UI inserts above the selection).
|
||||
const CREATES_LAYER: &[&str] =
|
||||
&["layer.newSolid", "layer.newNull", "layer.newText", "layer.newShape", "layer.newAdjustment", "layer.newCamera", "layer.newLight", "layer.addItem"];
|
||||
|
||||
/// What a script run produced.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Outcome {
|
||||
/// The value of the script's last expression statement (JSON when it converts; model objects
|
||||
/// as strings like `"[object CompItem]"`).
|
||||
pub result: J,
|
||||
/// `writeLn` / `$.writeln` / `alert` output, one entry per line.
|
||||
pub output: Vec<String>,
|
||||
pub error: Option<ScriptError>,
|
||||
}
|
||||
|
||||
/// An uncaught script error.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ScriptError {
|
||||
pub message: String,
|
||||
pub line: Option<u32>,
|
||||
pub column: Option<u32>,
|
||||
pub file: String,
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
pub fn ok(&self) -> bool {
|
||||
self.error.is_none()
|
||||
}
|
||||
pub fn to_json(&self) -> J {
|
||||
json!({
|
||||
"ok": self.ok(),
|
||||
"result": self.result,
|
||||
"output": self.output.join("\n"),
|
||||
"error": self.error.as_ref().map(|e| json!({"message": e.message, "line": e.line, "column": e.column, "file": e.file})),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The session and per-run state while a script runs (the natives reach it here; nothing
|
||||
/// borrowed ever enters the JS heap).
|
||||
struct Active {
|
||||
session: Session,
|
||||
output: Vec<String>,
|
||||
/// Open undo groups: (name, project snapshot when the outermost group began).
|
||||
groups: Vec<(String, Arc<Project>)>,
|
||||
/// `app.scheduleTask` queue: (id, code, repeat).
|
||||
tasks: Vec<(u32, String, bool)>,
|
||||
next_task: u32,
|
||||
name: String,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static ACTIVE: RefCell<Option<Active>> = const { RefCell::new(None) };
|
||||
/// The Script Console's persistent context (never dropped, like the expression runtime: boa's
|
||||
/// own thread-local heap may be torn down first at thread exit).
|
||||
static CONSOLE: RefCell<Option<ManuallyDrop<Context>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn with_active<T>(f: impl FnOnce(&mut Active) -> Result<T, String>) -> Result<T, String> {
|
||||
ACTIVE.with(|a| match a.try_borrow_mut() {
|
||||
Ok(mut g) => match g.as_mut() {
|
||||
Some(act) => f(act),
|
||||
None => Err("no script is running".into()),
|
||||
},
|
||||
Err(_) => Err("scripts can't run scripts".into()),
|
||||
})
|
||||
}
|
||||
|
||||
fn throw(msg: impl Into<String>) -> boa_engine::JsError {
|
||||
JsNativeError::error().with_message(msg.into()).into()
|
||||
}
|
||||
|
||||
fn arg_string(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult<String> {
|
||||
Ok(args.get(i).cloned().unwrap_or_default().to_string(ctx)?.to_std_string_escaped())
|
||||
}
|
||||
|
||||
fn arg_json(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult<J> {
|
||||
let s = arg_string(args, i, ctx)?;
|
||||
if s.is_empty() || s == "undefined" {
|
||||
return Ok(json!({}));
|
||||
}
|
||||
serde_json::from_str(&s).map_err(|e| throw(format!("bad parameters: {e}")))
|
||||
}
|
||||
|
||||
fn js_str(s: &str) -> JsValue {
|
||||
JsValue::from(JsString::from(s))
|
||||
}
|
||||
|
||||
/// `__exec(commandId, paramsJson)` → result JSON. Runs an engine command (undoable) with the
|
||||
/// target comp made active for its duration.
|
||||
fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let id = arg_string(args, 0, ctx)?;
|
||||
let params = arg_json(args, 1, ctx)?;
|
||||
if id == "script.run" || id == "file.runScript" {
|
||||
return Err(throw("scripts can't run scripts"));
|
||||
}
|
||||
let r = with_active(|a| Ok(exec(&mut a.session, &id, params)));
|
||||
match r {
|
||||
Ok(Ok(v)) => Ok(js_str(&v.to_string())),
|
||||
Ok(Err(e)) | Err(e) => Err(throw(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a command for a script: the `comp` it targets is active while it runs (commands act on
|
||||
/// the active comp), then the user's active comp comes back.
|
||||
pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result<J, String> {
|
||||
use effectcraft_engine::project::ItemId;
|
||||
let target = params.get("comp").and_then(J::as_u64).map(ItemId).filter(|c| s.project.comp(*c).is_some());
|
||||
let prev = s.state.active_comp;
|
||||
if let Some(c) = target
|
||||
&& prev != Some(c)
|
||||
{
|
||||
s.state.active_comp = Some(c);
|
||||
}
|
||||
if CREATES_LAYER.contains(&id) {
|
||||
s.state.selected_layers.clear();
|
||||
}
|
||||
let r = s.execute(id, params);
|
||||
if let Some(c) = target
|
||||
&& prev != Some(c)
|
||||
&& s.state.active_comp == Some(c)
|
||||
&& id != "comp.open"
|
||||
{
|
||||
match prev {
|
||||
Some(p) if s.project.comp(p).is_some() => s.state.active_comp = Some(p),
|
||||
_ => {
|
||||
if !s.state.open_comps.contains(&c) {
|
||||
s.state.open_comps.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
r.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `__query(kind, json)` → JSON.
|
||||
fn native_query(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let kind = arg_string(args, 0, ctx)?;
|
||||
let a = arg_json(args, 1, ctx)?;
|
||||
let r = with_active(|act| match kind.as_str() {
|
||||
"app" => Ok(json!({
|
||||
"version": format!("26.0 (EffectCraft {})", env!("CARGO_PKG_VERSION")),
|
||||
"buildName": env!("CARGO_PKG_VERSION"),
|
||||
"scriptName": act.name,
|
||||
"allowFiles": act.session.prefs.scripting.allow_scripts_write_files,
|
||||
"projectDir": project_dir(&act.session),
|
||||
})),
|
||||
_ => crate::model::query(&mut act.session, &kind, &a),
|
||||
});
|
||||
match r {
|
||||
Ok(v) => Ok(js_str(&v.to_string())),
|
||||
Err(e) => Err(throw(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `__print(text)`: `writeLn`, `$.writeln`, `alert`.
|
||||
fn native_print(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let text = arg_string(args, 0, ctx)?;
|
||||
let _ = with_active(|a| {
|
||||
a.output.extend(text.split('\n').map(str::to_string));
|
||||
Ok(())
|
||||
});
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// `__undo("begin" | "end", name)`: After Effects' `app.beginUndoGroup` / `endUndoGroup`. Every
|
||||
/// command run inside the outermost group folds into one undo step named after the group.
|
||||
fn native_undo(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let op = arg_string(args, 0, ctx)?;
|
||||
let name = arg_string(args, 1, ctx)?;
|
||||
with_active(|a| {
|
||||
match op.as_str() {
|
||||
"begin" => {
|
||||
let snap = a.session.project.clone();
|
||||
a.groups.push((if name.is_empty() || name == "undefined" { "Script".into() } else { name }, snap));
|
||||
}
|
||||
_ => {
|
||||
if a.groups.len() == 1 {
|
||||
let (name, snap) = a.groups.pop().unwrap_or_default();
|
||||
collapse(&mut a.session, &name, &snap);
|
||||
} else {
|
||||
a.groups.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.map_err(throw)?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
/// Fold the undo steps recorded since the project was `snap` into one step called `name`.
|
||||
fn collapse(s: &mut Session, name: &str, snap: &Arc<Project>) {
|
||||
let h = &mut s.history;
|
||||
let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) else {
|
||||
// Nothing was recorded (or history was reset by New / Open Project).
|
||||
return;
|
||||
};
|
||||
let first = h.undo[start].1.clone();
|
||||
h.undo.truncate(start);
|
||||
h.undo.push((name.to_string(), first));
|
||||
h.merge_key = None;
|
||||
}
|
||||
|
||||
/// `__task("schedule", code, repeat)` → id; `__task("cancel", id)`.
|
||||
fn native_task(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let op = arg_string(args, 0, ctx)?;
|
||||
if op == "cancel" {
|
||||
let id = args.get(1).cloned().unwrap_or_default().to_number(ctx)? as u32;
|
||||
let _ = with_active(|a| {
|
||||
a.tasks.retain(|t| t.0 != id);
|
||||
Ok(())
|
||||
});
|
||||
return Ok(JsValue::undefined());
|
||||
}
|
||||
let code = arg_string(args, 1, ctx)?;
|
||||
let repeat = args.get(2).cloned().unwrap_or_default().to_boolean();
|
||||
let id = with_active(|a| {
|
||||
a.next_task += 1;
|
||||
let id = a.next_task;
|
||||
a.tasks.push((id, code, repeat));
|
||||
Ok(id)
|
||||
})
|
||||
.map_err(throw)?;
|
||||
Ok(JsValue::from(id))
|
||||
}
|
||||
|
||||
fn project_dir(s: &Session) -> Option<String> {
|
||||
let p = s.path.as_deref()?;
|
||||
std::path::Path::new(p).parent().map(|d| d.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Lexically normalized absolute path (`a/./b/../c` → `a/c`).
|
||||
fn normalize(p: &str) -> std::path::PathBuf {
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
let path = Path::new(p);
|
||||
let abs = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(path) };
|
||||
let mut out = PathBuf::new();
|
||||
for c in abs.components() {
|
||||
match c {
|
||||
Component::ParentDir => {
|
||||
out.pop();
|
||||
}
|
||||
Component::CurDir => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
// Resolve symlinks where the file (or its folder) exists, so links can't escape the project.
|
||||
std::fs::canonicalize(&out)
|
||||
.or_else(|_| out.parent().map(std::fs::canonicalize).unwrap_or(Ok(out.clone())).map(|d| d.join(out.file_name().unwrap_or_default())))
|
||||
.unwrap_or(out)
|
||||
}
|
||||
|
||||
const GATE: &str = "Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network";
|
||||
|
||||
/// The file-access gate: reads under the project's folder are always allowed; other reads, all
|
||||
/// writes and listing other folders need the Allow Scripts to Write Files preference.
|
||||
fn check_access(s: &Session, path: &str, write: bool) -> Result<std::path::PathBuf, String> {
|
||||
let p = normalize(path);
|
||||
if s.prefs.scripting.allow_scripts_write_files {
|
||||
return Ok(p);
|
||||
}
|
||||
if write {
|
||||
return Err(format!("Unable to write {path}: scripts can't write files unless {GATE} is on"));
|
||||
}
|
||||
if let Some(dir) = project_dir(s) {
|
||||
let d = normalize(&dir);
|
||||
if p.starts_with(&d) {
|
||||
return Ok(p);
|
||||
}
|
||||
}
|
||||
Err(format!("Unable to access {path}: scripts can only read files in the project's folder unless {GATE} is on"))
|
||||
}
|
||||
|
||||
/// `__file(op, path, data)`: `read` → text, `write`/`append` → bytes, `exists` → bool, `list` →
|
||||
/// names, `mkdir`, `remove`, `isDir`.
|
||||
fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let op = arg_string(args, 0, ctx)?;
|
||||
let path = arg_string(args, 1, ctx)?;
|
||||
let data = if args.len() > 2 { arg_string(args, 2, ctx)? } else { String::new() };
|
||||
let r: Result<J, String> = with_active(|a| {
|
||||
let s = &a.session;
|
||||
match op.as_str() {
|
||||
"access" => Ok(json!(check_access(s, &path, false)?.exists())),
|
||||
"exists" | "isDir" => {
|
||||
// Unreadable locations report "missing" instead of failing.
|
||||
let Ok(p) = check_access(s, &path, false) else { return Ok(json!(false)) };
|
||||
Ok(json!(if op == "exists" { p.exists() } else { p.is_dir() }))
|
||||
}
|
||||
"read" => {
|
||||
let p = check_access(s, &path, false)?;
|
||||
let bytes = s.services.read_file(&p.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?;
|
||||
Ok(json!(String::from_utf8_lossy(&bytes)))
|
||||
}
|
||||
"write" | "append" => {
|
||||
let p = check_access(s, &path, true)?;
|
||||
let ps = p.to_string_lossy().to_string();
|
||||
let mut bytes = if op == "append" { s.services.read_file(&ps).unwrap_or_default() } else { vec![] };
|
||||
bytes.extend_from_slice(data.as_bytes());
|
||||
s.services.write_file(&ps, &bytes).map_err(|e| format!("Unable to write {path}: {e}"))?;
|
||||
Ok(json!(bytes.len()))
|
||||
}
|
||||
"list" => {
|
||||
let p = check_access(s, &path, false)?;
|
||||
let mut names: Vec<String> = std::fs::read_dir(&p)
|
||||
.map_err(|e| format!("Unable to list {path}: {e}"))?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path().to_string_lossy().to_string())
|
||||
.collect();
|
||||
names.sort();
|
||||
Ok(json!(names))
|
||||
}
|
||||
"mkdir" => {
|
||||
let p = check_access(s, &path, true)?;
|
||||
std::fs::create_dir_all(&p).map_err(|e| format!("Unable to create {path}: {e}"))?;
|
||||
Ok(json!(true))
|
||||
}
|
||||
"remove" => {
|
||||
let p = check_access(s, &path, true)?;
|
||||
let r = if p.is_dir() { std::fs::remove_dir(&p) } else { std::fs::remove_file(&p) };
|
||||
Ok(json!(r.is_ok()))
|
||||
}
|
||||
"network" => Err(if s.prefs.scripting.allow_scripts_write_files {
|
||||
"network access from scripts is not supported in EffectCraft".to_string()
|
||||
} else {
|
||||
format!("scripts can't access the network unless {GATE} is on")
|
||||
}),
|
||||
_ => Err(format!("unknown file operation `{op}`")),
|
||||
}
|
||||
});
|
||||
match r {
|
||||
Ok(v) => Ok(js_str(&v.to_string())),
|
||||
Err(e) => Err(throw(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_context() -> Result<Context, String> {
|
||||
let mut ctx = Context::default();
|
||||
let limits = ctx.runtime_limits_mut();
|
||||
limits.set_loop_iteration_limit(200_000_000);
|
||||
limits.set_recursion_limit(2000);
|
||||
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 6] = [
|
||||
(js_string!("__exec"), 2, native_exec),
|
||||
(js_string!("__query"), 2, native_query),
|
||||
(js_string!("__print"), 1, native_print),
|
||||
(js_string!("__undo"), 2, native_undo),
|
||||
(js_string!("__task"), 3, native_task),
|
||||
(js_string!("__file"), 3, native_file),
|
||||
];
|
||||
for (name, len, f) in natives {
|
||||
ctx.register_global_callable(name, len, NativeFunction::from_fn_ptr(f)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
ctx.eval(Source::from_bytes(PRELUDE)).map_err(|e| format!("script prelude: {e}"))?;
|
||||
Ok(ctx)
|
||||
}
|
||||
|
||||
/// Turn a boa error into a message with line and column (`code` is the user's script).
|
||||
fn script_error(e: &boa_engine::JsError, ctx: &mut Context, file: &str, code: &str) -> ScriptError {
|
||||
// Thrown values: `throw new Error("x")`, `throw "x"`, native errors.
|
||||
let display = e.to_string();
|
||||
let message = match e.as_opaque() {
|
||||
Some(v) => {
|
||||
let o = v.as_object();
|
||||
let msg = o.as_ref().and_then(|o| o.get(js_string!("message"), ctx).ok()).filter(|m| !m.is_undefined());
|
||||
let name = o.as_ref().and_then(|o| o.get(js_string!("name"), ctx).ok()).filter(|m| !m.is_undefined());
|
||||
match (name, msg) {
|
||||
(Some(n), Some(m)) => {
|
||||
let n = n.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default();
|
||||
let m = m.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default();
|
||||
if n == "Error" { m } else { format!("{n}: {m}") }
|
||||
}
|
||||
_ => v.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_else(|_| display.clone()),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let first = display.lines().next().unwrap_or("").trim();
|
||||
let first = first.split(" at line ").next().unwrap_or(first).trim();
|
||||
let first = match first.rfind(" (") {
|
||||
Some(i) if first.ends_with(')') && first[i..].contains(':') => first[..i].trim(),
|
||||
_ => first,
|
||||
};
|
||||
first.strip_prefix("Error: ").unwrap_or(first).to_string()
|
||||
}
|
||||
};
|
||||
let (line, column) = position(&display, file, code);
|
||||
ScriptError { message, line, column, file: file.to_string() }
|
||||
}
|
||||
|
||||
/// Line and column of an error: `at line N, col M` (syntax errors), else the innermost backtrace
|
||||
/// frame in the user's script (`file:line:col`), else (names boa can't place) the first use of
|
||||
/// the undefined name in `code`.
|
||||
fn position(msg: &str, file: &str, code: &str) -> (Option<u32>, Option<u32>) {
|
||||
if let Some(i) = msg.find("at line ") {
|
||||
let rest = &msg[i + 8..];
|
||||
let line = rest.split(|c: char| !c.is_ascii_digit()).next().and_then(|n| n.parse().ok());
|
||||
let col = rest.find("col ").and_then(|j| rest[j + 4..].split(|c: char| !c.is_ascii_digit()).next()).and_then(|n| n.parse().ok());
|
||||
return (line, col);
|
||||
}
|
||||
// `(file:12:5)`: the digits after the file name.
|
||||
let pick = |l: &str| -> Option<(u32, u32)> {
|
||||
let i = l.find(&format!("({file}:"))? + file.len() + 2;
|
||||
let mut it = l[i..].split([':', ')']);
|
||||
Some((it.next()?.parse().ok()?, it.next()?.parse().ok()?))
|
||||
};
|
||||
let frames: Vec<&str> = msg.lines().skip(1).collect();
|
||||
if let Some((a, b)) = frames.iter().find_map(|l| pick(l)).or_else(|| msg.lines().next().and_then(pick)) {
|
||||
return (Some(a), Some(b));
|
||||
}
|
||||
// `ReferenceError: name is not defined` without a position.
|
||||
let first = msg.lines().next().unwrap_or("");
|
||||
if let Some(name) = first.split("ReferenceError: ").nth(1).and_then(|r| r.split(" is not defined").next()) {
|
||||
for (n, line) in code.lines().enumerate() {
|
||||
let b = line.as_bytes();
|
||||
let mut from = 0;
|
||||
while let Some(i) = line[from..].find(name).map(|i| i + from) {
|
||||
let before = i.checked_sub(1).map(|j| b[j]);
|
||||
let after = b.get(i + name.len()).copied();
|
||||
let ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'$';
|
||||
if !before.is_some_and(|c| ident(c) || c == b'.') && !after.is_some_and(ident) {
|
||||
return (Some(n as u32 + 1), Some(i as u32 + 1));
|
||||
}
|
||||
from = i + name.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, None)
|
||||
}
|
||||
|
||||
/// Evaluate `code` in `ctx` and convert the result.
|
||||
fn eval_in(ctx: &mut Context, code: &str, file: &str) -> Result<J, ScriptError> {
|
||||
let src = Source::from_bytes(code.as_bytes()).with_path(std::path::Path::new(file));
|
||||
let script = boa_engine::Script::parse(src, None, ctx).map_err(|e| script_error(&e, ctx, file, code))?;
|
||||
let v = script.evaluate(ctx).map_err(|e| script_error(&e, ctx, file, code))?;
|
||||
Ok(result_json(&v, ctx))
|
||||
}
|
||||
|
||||
/// The script's completion value as JSON (`__result` in the prelude does the conversion).
|
||||
fn result_json(v: &JsValue, ctx: &mut Context) -> J {
|
||||
if v.is_undefined() {
|
||||
return J::Null;
|
||||
}
|
||||
let global = ctx.global_object();
|
||||
let Some(f) = global.get(js_string!("__result"), ctx).ok().and_then(|f| f.as_object()) else { return J::Null };
|
||||
match f.call(&JsValue::undefined(), std::slice::from_ref(v), ctx) {
|
||||
Ok(s) if s.is_string() => {
|
||||
let s = s.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default();
|
||||
serde_json::from_str(&s).unwrap_or(J::String(s))
|
||||
}
|
||||
_ => J::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores the caller's session when a run ends (on every exit path).
|
||||
struct Restore<'a> {
|
||||
target: &'a mut Session,
|
||||
}
|
||||
|
||||
impl Drop for Restore<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let Some(a) = ACTIVE.with(|c| c.borrow_mut().take()) {
|
||||
*self.target = a.session;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a script against `session`: the object model sees and edits that session through engine
|
||||
/// commands. Script errors come back in [`Outcome::error`].
|
||||
pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome {
|
||||
let running = ACTIVE.with(|a| a.try_borrow().map(|g| g.is_some()).unwrap_or(true));
|
||||
if running {
|
||||
return Outcome {
|
||||
error: Some(ScriptError { message: "scripts can't run scripts".into(), file: req.name.into(), ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
let s = std::mem::take(session);
|
||||
ACTIVE.with(|a| *a.borrow_mut() = Some(Active { session: s, output: vec![], groups: vec![], tasks: vec![], next_task: 0, name: req.name.to_string() }));
|
||||
let guard = Restore { target: session };
|
||||
let file = if req.name.is_empty() { "script" } else { req.name };
|
||||
let mut result = if req.console {
|
||||
CONSOLE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
if c.is_none() {
|
||||
match new_context() {
|
||||
Ok(ctx) => *c = Some(ManuallyDrop::new(ctx)),
|
||||
Err(e) => return Err(ScriptError { message: e, file: file.into(), ..Default::default() }),
|
||||
}
|
||||
}
|
||||
let ctx = c.as_mut().map(|m| &mut **m).expect("console context");
|
||||
eval_in(ctx, req.code, file)
|
||||
})
|
||||
} else {
|
||||
match new_context() {
|
||||
Ok(mut ctx) => {
|
||||
let r = eval_in(&mut ctx, req.code, file);
|
||||
// app.scheduleTask: run the queued one-shot tasks after the script, in order.
|
||||
let tasks = with_active(|a| Ok(std::mem::take(&mut a.tasks))).unwrap_or_default();
|
||||
let mut r = r;
|
||||
for (_, code, repeat) in tasks {
|
||||
if repeat {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = eval_in(&mut ctx, &code, "scheduled task")
|
||||
&& r.is_ok()
|
||||
{
|
||||
r = Err(e);
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => Err(ScriptError { message: e, file: file.into(), ..Default::default() }),
|
||||
}
|
||||
};
|
||||
// Close undo groups left open; drop selections that no longer exist.
|
||||
let output = with_active(|a| {
|
||||
if let Some((name, snap)) = a.groups.first().cloned() {
|
||||
collapse(&mut a.session, &name, &snap);
|
||||
a.groups.clear();
|
||||
}
|
||||
a.session.sanitize_state();
|
||||
Ok(std::mem::take(&mut a.output))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
drop(guard);
|
||||
let mut out = Outcome { output, ..Default::default() };
|
||||
match result.as_mut() {
|
||||
Ok(v) => out.result = std::mem::take(v),
|
||||
Err(e) => out.error = Some(e.clone()),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -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"]);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -697,7 +697,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
|
|||
"file.exportLottie" => ("path", Ask::Save("Animation.json")),
|
||||
"file.importLottie" => ("path", Ask::Open(&["json", "lottie"])),
|
||||
"render.saveCurrentPreview" => ("path", Ask::Save("Preview.mp4")),
|
||||
"file.runScript" => ("path", Ask::Open(&["jsonl", "json", "txt"])),
|
||||
"file.runScript" => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
|
||||
"file.replaceFootage" => ("path", Ask::Import),
|
||||
"file.collectFiles" => ("folder", Ask::Save("Collected Files")),
|
||||
_ => return None,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
//! Window ▸ Script Console: a JavaScript REPL over the After Effects-style scripting object model
|
||||
//! (`app.project`, comps, layers, properties…). Each run goes through the `script.run` command
|
||||
//! in the console's persistent context, so variables survive between runs; edits are undoable.
|
||||
|
||||
use egui::{Align2, Rect, Sense, pos2, vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::EffectcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// One line of console output.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ConsoleLine {
|
||||
pub text: String,
|
||||
/// `input`, `output`, `result` or `error`.
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// Script Console state (serde, so agents can read it).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ScriptConsole {
|
||||
pub input: String,
|
||||
pub log: Vec<ConsoleLine>,
|
||||
/// Previously run inputs (Up/Down recall).
|
||||
#[serde(default)]
|
||||
pub history: Vec<String>,
|
||||
}
|
||||
|
||||
impl ScriptConsole {
|
||||
fn push(&mut self, kind: &str, text: &str) {
|
||||
for l in text.lines() {
|
||||
self.log.push(ConsoleLine { text: l.to_string(), kind: kind.to_string() });
|
||||
}
|
||||
if self.log.len() > 2000 {
|
||||
let extra = self.log.len() - 2000;
|
||||
self.log.drain(..extra);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a `script.run` result.
|
||||
pub fn record(&mut self, code: &str, r: &Result<Value, String>) {
|
||||
self.push("input", &format!("> {}", code.trim()));
|
||||
match r {
|
||||
Ok(v) => {
|
||||
if let Some(o) = v["output"].as_str().filter(|o| !o.is_empty()) {
|
||||
self.push("output", o);
|
||||
}
|
||||
if let Some(e) = v.get("error").filter(|e| !e.is_null()) {
|
||||
let line = e["line"].as_u64().map(|l| format!("line {l}: ")).unwrap_or_default();
|
||||
self.push("error", &format!("{line}{}", e["message"].as_str().unwrap_or("error")));
|
||||
} else if !v["result"].is_null() {
|
||||
self.push("result", &v["result"].to_string());
|
||||
}
|
||||
}
|
||||
Err(e) => self.push("error", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the console input.
|
||||
pub fn run(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
let code = app.ui.script_console.input.trim().to_string();
|
||||
if code.is_empty() {
|
||||
return;
|
||||
}
|
||||
let r = crate::menus::invoke(app, ctx, "script.run", json!({"code": code, "name": "Script Console", "console": true}));
|
||||
let con = &mut app.ui.script_console;
|
||||
con.record(&code, &r);
|
||||
if con.history.last() != Some(&code) {
|
||||
con.history.push(code);
|
||||
}
|
||||
con.input.clear();
|
||||
}
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let input_h = 86.0;
|
||||
let bar_h = 26.0;
|
||||
let out_rect = Rect::from_min_max(rect.min, pos2(rect.max.x, rect.max.y - input_h - bar_h));
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
p.rect_filled(out_rect, 0.0, t.field_bg);
|
||||
// Output, newest at the bottom.
|
||||
let line_h = 16.0;
|
||||
let visible = ((out_rect.height() - 8.0) / line_h).max(1.0) as usize;
|
||||
let log = &app.ui.script_console.log;
|
||||
let start = log.len().saturating_sub(visible);
|
||||
for (i, l) in log[start..].iter().enumerate() {
|
||||
let color = match l.kind.as_str() {
|
||||
"error" => t.danger,
|
||||
"input" => t.text_faint,
|
||||
"result" => t.accent,
|
||||
_ => t.text,
|
||||
};
|
||||
p.text(pos2(out_rect.min.x + 8.0, out_rect.min.y + 4.0 + i as f32 * line_h), Align2::LEFT_TOP, &l.text, Tokens::mono(11.5), color);
|
||||
}
|
||||
if log.is_empty() {
|
||||
p.text(
|
||||
out_rect.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
"JavaScript with the After Effects scripting object model — try app.project.numItems",
|
||||
Tokens::ui(12.0),
|
||||
t.text_faint,
|
||||
);
|
||||
}
|
||||
app.auto.add("scriptConsole.output", out_rect, "Script Console output");
|
||||
// Input.
|
||||
let in_rect = Rect::from_min_size(pos2(rect.min.x + 4.0, out_rect.max.y + 4.0), vec2(rect.width() - 8.0, input_h - 8.0));
|
||||
let mut run_now = false;
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(in_rect));
|
||||
let te = egui::TextEdit::multiline(&mut app.ui.script_console.input)
|
||||
.id(egui::Id::new("script-console-input"))
|
||||
.font(Tokens::mono(12.0))
|
||||
.desired_width(in_rect.width())
|
||||
.desired_rows(4)
|
||||
.hint_text("Script… (⌘/Ctrl+Enter runs)");
|
||||
let resp = child.add_sized(in_rect.size(), te);
|
||||
if resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command) {
|
||||
run_now = true;
|
||||
}
|
||||
app.auto.add("scriptConsole.input", in_rect, "Script Console input");
|
||||
// Buttons.
|
||||
let bar = Rect::from_min_size(pos2(rect.min.x, rect.max.y - bar_h), vec2(rect.width(), bar_h));
|
||||
let run_r = Rect::from_min_size(pos2(bar.max.x - 64.0, bar.min.y + 2.0), vec2(56.0, 20.0));
|
||||
let clear_r = Rect::from_min_size(pos2(bar.max.x - 128.0, bar.min.y + 2.0), vec2(56.0, 20.0));
|
||||
let run_resp = ui.interact(run_r, egui::Id::new("script-console-run"), Sense::click());
|
||||
p.rect_filled(run_r, 3.0, if run_resp.hovered() { t.accent_hover } else { t.accent });
|
||||
p.text(run_r.center(), Align2::CENTER_CENTER, "Run", Tokens::ui(12.0), egui::Color32::WHITE);
|
||||
let clear_resp = ui.interact(clear_r, egui::Id::new("script-console-clear"), Sense::click());
|
||||
p.rect_stroke(clear_r, 3.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||||
p.text(clear_r.center(), Align2::CENTER_CENTER, "Clear", Tokens::ui(12.0), t.text);
|
||||
app.auto.add("scriptConsole.run", run_r, "Run");
|
||||
app.auto.add("scriptConsole.clear", clear_r, "Clear");
|
||||
if run_resp.clicked() {
|
||||
run_now = true;
|
||||
}
|
||||
if clear_resp.clicked() {
|
||||
app.ui.script_console.log.clear();
|
||||
}
|
||||
if run_now {
|
||||
let ctx = ui.ctx().clone();
|
||||
run(app, &ctx);
|
||||
}
|
||||
}
|
||||
|
|
@ -476,6 +476,9 @@ impl Default for AnimToolsState {
|
|||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct UiState {
|
||||
pub tool: Tool,
|
||||
/// Window ▸ Script Console input, output and history.
|
||||
#[serde(default)]
|
||||
pub script_console: crate::panels::script_console::ScriptConsole,
|
||||
/// Tool shown in each toolbar slot.
|
||||
pub slot_tools: Vec<Tool>,
|
||||
pub workspace: String,
|
||||
|
|
@ -576,6 +579,7 @@ impl Default for UiState {
|
|||
fn default() -> Self {
|
||||
UiState {
|
||||
tool: Tool::Selection,
|
||||
script_console: Default::default(),
|
||||
slot_tools: Tool::SLOTS.iter().map(|s| s[0]).collect(),
|
||||
workspace: "Default".into(),
|
||||
dock: crate::dock::workspace("Default"),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -30,13 +30,15 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
|
|||
| L3 | `gpu` | The GPU compositor and GPU effects on wgpu compute shaders (Metal, Vulkan, Direct3D 12, WebGPU), checked against the CPU renderer |
|
||||
| L3 | `lottie` | Lottie JSON / dotLottie import and export (layers, precomps, eased and spatial keyframes, shapes, masks, mattes) with a warnings list for what Lottie cannot express |
|
||||
| L4 | `engine` | `Session`: project, undo history, editor state, the command registry and menus |
|
||||
| L4 | `host` | A fully wired `Session` (media, expressions, exporter) for the frontends |
|
||||
| L4 | `script` | Scripting: JavaScript (boa) with an After Effects-style object model (`app.project`, comps, layers, properties, render queue) whose edits run engine commands; AE match names |
|
||||
| L4 | `host` | A fully wired `Session` (media, expressions, scripting, exporter) for the frontends |
|
||||
| L5 | `ui-egui` | The desktop interface: docking, panels, viewer, timeline, graph editor, dialogs, control channel |
|
||||
| L5 | `automation` | The MCP server, headless or bridged to the running app |
|
||||
| L6 | apps `effectcraft`, `effectcraft-cli`, `effectcraft-web` | Desktop app; command-line tool (render, exec, get/set, MCP); the browser app (wasm32, [web.md](web.md)) |
|
||||
|
||||
Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `effects → project, text, path, track`,
|
||||
`media / expr / export / gpu → render`, `export → media`, `lottie → format`, `host → engine`.
|
||||
`media / expr / export / gpu → render`, `export → media`, `lottie → format`, `host → engine, script`,
|
||||
`script → engine`.
|
||||
|
||||
## 2. Time
|
||||
|
||||
|
|
@ -182,6 +184,28 @@ Expressions are JavaScript, run by boa, with After Effects' object model (`thisC
|
|||
`time`, `value`, `wiggle`, `loopOut`, vector maths on arrays, and so on). A syntax error keeps the
|
||||
text, disables the expression and shows a warning, like After Effects.
|
||||
|
||||
## 5a. Scripting
|
||||
|
||||
`effectcraft-script` runs JavaScript (boa, a fresh context per run; the Script Console keeps one)
|
||||
with an object model written from the behaviour the After Effects Scripting Guide documents:
|
||||
`app`, `Project`, `ItemCollection`, `CompItem`/`FootageItem`/`FolderItem`, `LayerCollection`,
|
||||
`AVLayer`/`TextLayer`/`ShapeLayer`/`CameraLayer`/`LightLayer`, `PropertyGroup`/`Property`
|
||||
(values, keyframes, eases, expressions), `TextDocument`, `Shape`, `KeyframeEase`, `MarkerValue`,
|
||||
`RenderQueue`/`RenderQueueItem`/`OutputModule`, `File`/`Folder` and the enums. The model lives in
|
||||
`prelude.js`; it reads the session through `__query` (JSON snapshots of items, layers, property
|
||||
nodes, values and keys) and edits only through `__exec`, which runs engine commands, so every
|
||||
scripted edit is undoable and identical to the UI's. While a script runs the session is moved
|
||||
into a thread-local the natives borrow (nothing borrowed enters the JS heap); the comp a call
|
||||
targets is made active for that command only. `app.beginUndoGroup`/`endUndoGroup` fold the undo
|
||||
steps in between into one named step. Match names (`ADBE Transform Group`, `ADBE Position`,
|
||||
`ADBE Gaussian Blur 2`, `ADBE Vector Shape - Rect`…) come from a table in `matchnames.rs`; effect
|
||||
parameters are `<effect match name>-0001…`. `File` reads are limited to the project's folder, and
|
||||
writes and the network are refused, unless Preferences ▸ Scripting & Expressions ▸ Allow Scripts
|
||||
to Write Files and Access Network is on. Entry points: the `script.run` command
|
||||
(`Session::script`, set by the host), File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`; `.json`
|
||||
command scripts still run as steps), Window ▸ Script Console, `effectcraft-cli script` and the
|
||||
MCP `run_script` tool.
|
||||
|
||||
## 6. Commands, the UI seam and automation
|
||||
|
||||
Every action is a command: an id, a label, a menu path, a shortcut, a parameter description,
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue