M14.4: scripting with an After Effects-style JavaScript object model

New crate effectcraft-script (L4): boa runtime with app/Project/CompItem/
Layer/PropertyGroup/Property/TextDocument/MarkerValue/RenderQueue/File
object model over engine commands (undoable, undo groups), AE match-name
table, file/network security gate. Entry points: script.run command,
File > Scripts > Run Script File (.jsx/.js), Window > Script Console,
effectcraft-cli script, MCP run_script. Engine: project.delete/duplicate,
prop.renameGroup/setGroupEnabled/removeGroup/duplicateGroup/moveGroup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 22:13:09 -04:00
co-authored by Claude Opus 5.5
parent e911cc5c93
commit 78cd313d3c
36 changed files with 4621 additions and 28 deletions
+64
View File
@@ -0,0 +1,64 @@
//! # effectcraft-script (L4)
//!
//! Scripting with an After Effects-style object model, on the boa JavaScript engine (the one the
//! expressions use). Scripts written for After Effects' documented scripting API mostly run
//! unchanged:
//!
//! ```text
//! app.beginUndoGroup("Build");
//! var comp = app.project.items.addComp("Main", 1920, 1080, 1, 10, 30);
//! var solid = comp.layers.addSolid([1, 0, 0], "Red", 1920, 1080, 1);
//! solid.property("ADBE Transform Group").property("ADBE Position").setValueAtTime(0, [0, 540]);
//! solid.Effects.addProperty("ADBE Gaussian Blur 2").property("Blurriness").setValue(20);
//! app.endUndoGroup();
//! ```
//!
//! * **Object model** (`prelude.js`): `app` (project, open/newProject, undo groups,
//! `executeCommand`/`findMenuCommandId`, `scheduleTask`), `Project`, `ItemCollection`,
//! `CompItem`/`FootageItem`/`FolderItem`, `LayerCollection`, `AVLayer`/`TextLayer`/
//! `ShapeLayer`/`CameraLayer`/`LightLayer`, `PropertyGroup`/`Property` (values, keyframes,
//! eases, expressions), `MaskPropertyGroup`, `TextDocument`, `Shape`, `KeyframeEase`,
//! `MarkerValue`, `RenderQueue`/`RenderQueueItem`/`OutputModule`, `ImportOptions`, `File`/
//! `Folder`, `$`, `alert`/`writeLn`, and the enums (`BlendingMode`, `KeyframeInterpolationType`,
//! `TrackMatteType`, `LightType`, `ParagraphJustification`, `PropertyValueType`…).
//! * **Edits are engine commands**: every mutating call runs a command through
//! [`Session::execute`](effectcraft_engine::Session::execute), so it is undoable, journaled
//! and identical to the UI's action; `app.beginUndoGroup`/`endUndoGroup` fold everything in
//! between into one undo step.
//! * **Match names** ([`matchnames`]): After Effects' documented match names for our properties
//! and effects (`ADBE Transform Group`, `ADBE Position`, `ADBE Gaussian Blur 2`…).
//! * **Security**: scripts can import/open/save projects and render, but `File` reads are limited
//! to the project's folder and writes (and the network) are off unless Preferences ▸ Scripting
//! & Expressions ▸ Allow Scripts to Write Files and Access Network is on.
//!
//! Entry points: [`install`] sets [`Session::script`](effectcraft_engine::Session::script), which
//! the `script.run` command, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`), the Script Console
//! panel, `effectcraft-cli script` and the MCP `run_script` tool use.
#![recursion_limit = "256"]
pub mod matchnames;
mod model;
mod runtime;
use effectcraft_engine::{ScriptRequest, Session};
pub use runtime::{Outcome, ScriptError, run};
/// The [`effectcraft_engine::ScriptRunner`] this crate provides.
pub fn runner(s: &mut Session, req: &ScriptRequest) -> serde_json::Value {
run(s, req).to_json()
}
/// Enable scripting on a session.
pub fn install(s: &mut Session) {
s.script = Some(runner);
}
/// Run `code` as a one-off script named `name`.
pub fn run_code(s: &mut Session, code: &str, name: &str) -> Outcome {
run(s, &ScriptRequest { code, name, console: false })
}
#[cfg(test)]
mod tests;
+443
View File
@@ -0,0 +1,443 @@
//! After Effects-compatible match names for our property tree and effects, so common scripts
//! (`layer.property("ADBE Transform Group").property("ADBE Position")`,
//! `layer.Effects.addProperty("ADBE Gaussian Blur 2")`) work unchanged.
//!
//! The names are the stable identifiers After Effects documents for scripting (Scripting Guide
//! ▸ match names). Properties without a documented equivalent keep our own match id
//! (`perChar3d`, `ec.blur.bilateral`), which `property()` and `addProperty()` accept too.
use effectcraft_project::{LayerSource, Node};
/// Match name of a layer (its root property group).
pub fn layer(source: &LayerSource) -> &'static str {
match source {
LayerSource::Text => "ADBE Text Layer",
LayerSource::Shape => "ADBE Vector Layer",
LayerSource::Camera => "ADBE Camera Layer",
LayerSource::Light { .. } => "ADBE Light Layer",
_ => "ADBE AV Layer",
}
}
/// Top-level groups of a layer.
const LAYER_GROUPS: &[(&str, &str)] = &[
("transform", "ADBE Transform Group"),
("masks", "ADBE Mask Parade"),
("effects", "ADBE Effect Parade"),
("text", "ADBE Text Properties"),
("contents", "ADBE Root Vectors Group"),
("materialOptions", "ADBE Material Options Group"),
("cameraOptions", "ADBE Camera Options Group"),
("lightOptions", "ADBE Light Options Group"),
("audio", "ADBE Audio Group"),
("layerStyles", "ADBE Layer Styles"),
("motionTrackers", "ADBE MTrackers"),
("timeRemap", "ADBE Time Remapping"),
];
const TRANSFORM: &[(&str, &str)] = &[
("anchor", "ADBE Anchor Point"),
// Cameras and lights store the Point of Interest where AVLayers keep the Anchor Point.
("poi", "ADBE Anchor Point"),
("position", "ADBE Position"),
("positionX", "ADBE Position_0"),
("positionY", "ADBE Position_1"),
("positionZ", "ADBE Position_2"),
("scale", "ADBE Scale"),
("orientation", "ADBE Orientation"),
("rotationX", "ADBE Rotate X"),
("rotationY", "ADBE Rotate Y"),
("rotation", "ADBE Rotate Z"),
("opacity", "ADBE Opacity"),
];
const MASK: &[(&str, &str)] =
&[("path", "ADBE Mask Shape"), ("feather", "ADBE Mask Feather"), ("opacity", "ADBE Mask Opacity"), ("expansion", "ADBE Mask Offset")];
const TEXT: &[(&str, &str)] = &[
("sourceText", "ADBE Text Document"),
("pathOptions", "ADBE Text Path Options"),
("moreOptions", "ADBE Text More Options"),
("animators", "ADBE Text Animators"),
];
const TEXT_ANIMATOR: &[(&str, &str)] = &[("selectors", "ADBE Text Selectors"), ("properties", "ADBE Text Animator Properties")];
const CAMERA: &[(&str, &str)] = &[
("zoom", "ADBE Camera Zoom"),
("dof", "ADBE Camera Depth of Field"),
("focusDistance", "ADBE Camera Focus Distance"),
("aperture", "ADBE Camera Aperture"),
("blurLevel", "ADBE Camera Blur Level"),
];
const LIGHT: &[(&str, &str)] = &[
("intensity", "ADBE Light Intensity"),
("color", "ADBE Light Color"),
("coneAngle", "ADBE Light Cone Angle"),
("coneFeather", "ADBE Light Cone Feather 2"),
("falloff", "ADBE Light Falloff Type"),
("radius", "ADBE Light Falloff Start"),
("falloffDistance", "ADBE Light Falloff Distance"),
("castsShadows", "ADBE Light Casts Shadows"),
("shadowDarkness", "ADBE Light Shadow Darkness"),
("shadowDiffusion", "ADBE Light Shadow Diffusion"),
];
const MATERIAL: &[(&str, &str)] = &[
("castsShadows", "ADBE Casts Shadows"),
("lightTransmission", "ADBE Light Transmission"),
("acceptsShadows", "ADBE Accepts Shadows"),
("acceptsLights", "ADBE Accepts Lights"),
("ambient", "ADBE Ambient Coefficient"),
("diffuse", "ADBE Diffuse Coefficient"),
("specularIntensity", "ADBE Specular Coefficient"),
("specularShininess", "ADBE Shininess Coefficient"),
("metal", "ADBE Metal Coefficient"),
];
/// Shape items (Contents ▸ Add): our match id → AE match name.
pub const SHAPE_ITEMS: &[(&str, &str)] = &[
("group", "ADBE Vector Group"),
("rect", "ADBE Vector Shape - Rect"),
("ellipse", "ADBE Vector Shape - Ellipse"),
("star", "ADBE Vector Shape - Star"),
("path", "ADBE Vector Shape - Group"),
("fill", "ADBE Vector Graphic - Fill"),
("stroke", "ADBE Vector Graphic - Stroke"),
("gfill", "ADBE Vector Graphic - G-Fill"),
("gstroke", "ADBE Vector Graphic - G-Stroke"),
("merge", "ADBE Vector Filter - Merge"),
("offset", "ADBE Vector Filter - Offset"),
("pucker", "ADBE Vector Filter - PB"),
("repeater", "ADBE Vector Filter - Repeater"),
("round", "ADBE Vector Filter - RC"),
("trim", "ADBE Vector Filter - Trim"),
("twist", "ADBE Vector Filter - Twist"),
("wiggle", "ADBE Vector Filter - Roughen"),
("zigzag", "ADBE Vector Filter - Zigzag"),
];
/// Properties inside shape items: (item match id, property match id, AE match name).
const SHAPE_PROPS: &[(&str, &str, &str)] = &[
("group", "blend", "ADBE Vector Blend Mode"),
("group", "contents", "ADBE Vectors Group"),
("group", "transform", "ADBE Vector Transform Group"),
("transform", "anchor", "ADBE Vector Anchor"),
("transform", "position", "ADBE Vector Position"),
("transform", "scale", "ADBE Vector Scale"),
("transform", "skew", "ADBE Vector Skew"),
("transform", "skewAxis", "ADBE Vector Skew Axis"),
("transform", "rotation", "ADBE Vector Rotation"),
("transform", "opacity", "ADBE Vector Group Opacity"),
("rect", "direction", "ADBE Vector Shape Direction"),
("rect", "size", "ADBE Vector Rect Size"),
("rect", "position", "ADBE Vector Rect Position"),
("rect", "roundness", "ADBE Vector Rect Roundness"),
("ellipse", "direction", "ADBE Vector Shape Direction"),
("ellipse", "size", "ADBE Vector Ellipse Size"),
("ellipse", "position", "ADBE Vector Ellipse Position"),
("star", "direction", "ADBE Vector Shape Direction"),
("star", "type", "ADBE Vector Star Type"),
("star", "points", "ADBE Vector Star Points"),
("star", "position", "ADBE Vector Star Position"),
("star", "rotation", "ADBE Vector Star Rotation"),
("star", "innerRadius", "ADBE Vector Star Inner Radius"),
("star", "outerRadius", "ADBE Vector Star Outer Radius"),
("star", "innerRoundness", "ADBE Vector Star Inner Roundess"),
("star", "outerRoundness", "ADBE Vector Star Outer Roundess"),
("path", "direction", "ADBE Vector Shape Direction"),
("path", "path", "ADBE Vector Shape"),
("fill", "blend", "ADBE Vector Blend Mode"),
("fill", "composite", "ADBE Vector Composite Order"),
("fill", "rule", "ADBE Vector Fill Rule"),
("fill", "color", "ADBE Vector Fill Color"),
("fill", "opacity", "ADBE Vector Fill Opacity"),
("stroke", "blend", "ADBE Vector Blend Mode"),
("stroke", "composite", "ADBE Vector Composite Order"),
("stroke", "color", "ADBE Vector Stroke Color"),
("stroke", "opacity", "ADBE Vector Stroke Opacity"),
("stroke", "width", "ADBE Vector Stroke Width"),
("stroke", "cap", "ADBE Vector Stroke Line Cap"),
("stroke", "join", "ADBE Vector Stroke Line Join"),
("stroke", "miter", "ADBE Vector Stroke Miter Limit"),
("stroke", "dashes", "ADBE Vector Stroke Dashes"),
("trim", "start", "ADBE Vector Trim Start"),
("trim", "end", "ADBE Vector Trim End"),
("trim", "offset", "ADBE Vector Trim Offset"),
("trim", "mode", "ADBE Vector Trim Type"),
("repeater", "copies", "ADBE Vector Repeater Copies"),
("repeater", "offset", "ADBE Vector Repeater Offset"),
("repeater", "composite", "ADBE Vector Repeater Order"),
("repeater", "transform", "ADBE Vector Repeater Transform"),
("round", "radius", "ADBE Vector RoundCorner Radius"),
("pucker", "amount", "ADBE Vector PuckerBloat Amount"),
("twist", "angle", "ADBE Vector Twist Angle"),
("twist", "center", "ADBE Vector Twist Center"),
("merge", "mode", "ADBE Vector Merge Type"),
];
/// Effects: our effect id → AE match name (the documented ones; the rest keep our id).
pub const EFFECTS: &[(&str, &str)] = &[
("ec.blur.gaussian", "ADBE Gaussian Blur 2"),
("ec.blur.fastbox", "ADBE Box Blur2"),
("ec.blur.directional", "ADBE Motion Blur"),
("ec.blur.radial", "ADBE Radial Blur"),
("ec.blur.cameralens", "ADBE Camera Lens Blur"),
("ec.blur.channel", "ADBE Channel Blur"),
("ec.blur.compound", "ADBE Compound Blur"),
("ec.blur.sharpen", "ADBE Sharpen"),
("ec.blur.smart", "ADBE Smart Blur"),
("ec.blur.unsharp", "ADBE Unsharp Mask2"),
("ec.channel.invert", "ADBE Invert"),
("ec.channel.setmatte", "ADBE Set Matte3"),
("ec.channel.setchannels", "ADBE Set Channels"),
("ec.channel.shiftchannels", "ADBE Shift Channels"),
("ec.channel.minimax", "ADBE Minimax"),
("ec.channel.blend", "ADBE Blend"),
("ec.channel.arithmetic", "ADBE Arithmetic"),
("ec.channel.calculations", "ADBE Calculations"),
("ec.channel.solidcomposite", "ADBE Solid Composite"),
("ec.color.brightnesscontrast", "ADBE Brightness & Contrast 2"),
("ec.color.curves", "ADBE CurvesCustom"),
("ec.color.levels", "ADBE Easy Levels2"),
("ec.color.levelsic", "ADBE Pro Levels2"),
("ec.color.huesaturation", "ADBE HUE SATURATION"),
("ec.color.exposure", "ADBE Exposure2"),
("ec.color.tint", "ADBE Tint"),
("ec.color.tritone", "ADBE Tritone"),
("ec.color.blackwhite", "ADBE Black&White"),
("ec.color.channelmixer", "ADBE CHANNEL MIXER"),
("ec.color.colorbalance", "ADBE Color Balance 2"),
("ec.color.colorbalancehls", "ADBE Color Balance (HLS)"),
("ec.color.changecolor", "ADBE Change Color"),
("ec.color.changetocolor", "ADBE Change To Color"),
("ec.color.colorama", "APC Colorama"),
("ec.color.leavecolor", "ADBE Leave Color"),
("ec.color.photofilter", "ADBE Photo Filter"),
("ec.color.vibrance", "ADBE Vibrance"),
("ec.color.lumetri", "ADBE Lumetri"),
("ec.color.shadowhighlight", "ADBE Shadow/Highlight"),
("ec.color.selectivecolor", "ADBE Selective Color"),
("ec.color.gammapedestalgain", "ADBE Gamma/Pedestal/Gain"),
("ec.color.equalize", "ADBE Equalize"),
("ec.color.autocontrast", "ADBE AutoContrast"),
("ec.color.autolevels", "ADBE AutoLevels"),
("ec.color.autocolor", "ADBE AutoColor"),
("ec.control.slider", "ADBE Slider Control"),
("ec.control.angle", "ADBE Angle Control"),
("ec.control.checkbox", "ADBE Checkbox Control"),
("ec.control.color", "ADBE Color Control"),
("ec.control.point", "ADBE Point Control"),
("ec.control.point3d", "ADBE Point3D Control"),
("ec.control.layer", "ADBE Layer Control"),
("ec.control.dropdown", "ADBE Dropdown Control"),
("ec.distort.transform", "ADBE Geometry2"),
("ec.distort.cornerpin", "ADBE Corner Pin"),
("ec.distort.bulge", "ADBE Bulge"),
("ec.distort.mirror", "ADBE Mirror"),
("ec.distort.offset", "ADBE Offset"),
("ec.distort.ripple", "ADBE Ripple"),
("ec.distort.spherize", "ADBE Spherize"),
("ec.distort.twirl", "ADBE Twirl"),
("ec.distort.wavewarp", "ADBE Wave Warp"),
("ec.distort.turbulentdisplace", "ADBE Turbulent Displace"),
("ec.distort.displacementmap", "ADBE Displacement Map"),
("ec.distort.meshwarp", "ADBE MESH WARP"),
("ec.distort.polar", "ADBE Polar Coordinates"),
("ec.distort.opticscompensation", "ADBE Optics Compensation"),
("ec.distort.magnify", "ADBE Magnify"),
("ec.distort.warpstabilizer", "ADBE SubspaceStabilizer"),
("ec.distort.puppet", "ADBE FreePin3"),
("ec.generate.fill", "ADBE Fill"),
("ec.generate.gradientramp", "ADBE Ramp"),
("ec.generate.fourcolor", "ADBE 4ColorGradient"),
("ec.generate.stroke", "ADBE Stroke"),
("ec.generate.grid", "ADBE Grid"),
("ec.generate.checkerboard", "ADBE Checkerboard"),
("ec.generate.circle", "ADBE Circle"),
("ec.generate.ellipse", "ADBE Ellipse"),
("ec.generate.cellpattern", "ADBE Cell Pattern"),
("ec.generate.lensflare", "ADBE Lens Flare"),
("ec.generate.vegas", "ADBE Vegas"),
("ec.generate.writeon", "ADBE Write On"),
("ec.generate.audiospectrum", "ADBE AudSpect"),
("ec.generate.audiowaveform", "ADBE AudWave"),
("ec.generate.beam", "ADBE Laser"),
("ec.generate.radiowaves", "ADBE Radio Waves"),
("ec.generate.scribble", "ADBE Scribble Fill"),
("ec.generate.paintbucket", "ADBE Paint Bucket"),
("ec.key.colorkey", "ADBE Color Key"),
("ec.key.luma", "ADBE Luma Key"),
("ec.key.linearcolor", "ADBE Linear Color Key2"),
("ec.key.colorrange", "ADBE Color Range"),
("ec.key.colordifference", "ADBE Color Difference Key"),
("ec.key.extract", "ADBE Extract"),
("ec.key.spill", "ADBE Spill2"),
("ec.matte.simplechoker", "ADBE Simple Choker"),
("ec.matte.mattechoker", "ADBE Matte Choker"),
("ec.noise.noise", "ADBE Noise"),
("ec.noise.fractal", "ADBE Fractal Noise"),
("ec.noise.median", "ADBE Median"),
("ec.noise.addgrain", "VISINF Grain Implant"),
("ec.noise.dustscratches", "ADBE Dust & Scratches"),
("ec.noise.turbulent", "ADBE Turbulent Noise"),
("ec.perspective.dropshadow", "ADBE Drop Shadow"),
("ec.perspective.radialshadow", "ADBE Radial Shadow"),
("ec.perspective.bevelalpha", "ADBE Bevel Alpha"),
("ec.perspective.beveledges", "ADBE Bevel Edges"),
("ec.perspective.3dglasses", "ADBE 3D Glasses2"),
("ec.stylize.glow", "ADBE Glo2"),
("ec.stylize.mosaic", "ADBE Mosaic"),
("ec.stylize.posterize", "ADBE Posterize"),
("ec.stylize.threshold", "ADBE Threshold2"),
("ec.stylize.findedges", "ADBE Find Edges"),
("ec.stylize.emboss", "ADBE Emboss"),
("ec.stylize.coloremboss", "ADBE Color Emboss"),
("ec.stylize.roughenedges", "ADBE Roughen Edges"),
("ec.stylize.scatter", "ADBE Scatter"),
("ec.stylize.strobe", "ADBE Strobe"),
("ec.stylize.motiontile", "ADBE Tile"),
("ec.stylize.texturize", "ADBE Texturize"),
("ec.stylize.cartoon", "ADBE Cartoonify"),
("ec.stylize.brushstrokes", "ADBE Brush Strokes"),
("ec.text.numbers", "ADBE Numbers2"),
("ec.text.timecode", "ADBE Timecode"),
("ec.time.echo", "ADBE Echo"),
("ec.time.posterizetime", "ADBE Posterize Time"),
("ec.time.timedisplacement", "ADBE Time Displacement"),
("ec.time.timedifference", "ADBE Time Difference"),
("ec.time.pixelmotionblur", "ADBE Pixel Motion Blur"),
("ec.time.timewarp", "ADBE Timewarp"),
("ec.transition.linearwipe", "ADBE Linear Wipe"),
("ec.transition.radialwipe", "ADBE Radial Wipe"),
("ec.transition.venetian", "ADBE Venetian Blinds"),
("ec.transition.blockdissolve", "ADBE Block Dissolve"),
("ec.transition.gradientwipe", "ADBE Gradient Wipe"),
("ec.transition.iriswipe", "ADBE Iris Wipe"),
("ec.utility.applylut", "ADBE Apply Color LUT2"),
("ec.utility.cineon", "ADBE Cineon Converter2"),
("ec.utility.growbounds", "ADBE Grow Bounds"),
];
fn find<'a>(t: &'a [(&str, &'a str)], k: &str) -> Option<&'a str> {
t.iter().find(|(a, _)| *a == k).map(|(_, b)| *b)
}
/// AE match name of an effect id (falls back to the id).
pub fn effect(id: &str) -> String {
find(EFFECTS, id).map(str::to_string).unwrap_or_else(|| id.to_string())
}
/// Our effect id for an AE effect match name.
pub fn effect_from_match(m: &str) -> Option<&'static str> {
EFFECTS.iter().find(|(_, b)| b.eq_ignore_ascii_case(m)).map(|(a, _)| *a)
}
/// Our shape item kind (`layer.addShapeItem`) for an AE match name, our match id or a display
/// name (`Rectangle`, `Fill`, `Trim Paths`).
pub fn shape_kind(name: &str) -> Option<&'static str> {
if let Some((k, _)) = SHAPE_ITEMS.iter().find(|(k, m)| m.eq_ignore_ascii_case(name) || k.eq_ignore_ascii_case(name)) {
return Some(k);
}
let n: String = name.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase();
Some(match n.as_str() {
"rectangle" | "rectanglepath" => "rect",
"ellipsepath" => "ellipse",
"polystar" | "polystarpath" => "star",
"polygon" => "polygon",
"group" | "vectorgroup" => "group",
"fill" => "fill",
"stroke" => "stroke",
"gradientfill" => "gfill",
"gradientstroke" => "gstroke",
"trimpaths" | "trim" => "trim",
"repeater" => "repeater",
"roundcorners" => "round",
"offsetpaths" => "offset",
"puckerbloat" | "puckerandbloat" => "pucker",
"twist" => "twist",
"zigzag" => "zigzag",
"wigglepaths" => "wiggle",
"mergepaths" => "merge",
_ => return None,
})
}
/// Match name of the node at the end of `chain` (layer root's children → … → node). `effect`
/// params are numbered `<effect match name>-0001…` in parameter order, like After Effects.
pub fn node(chain: &[&Node]) -> String {
let Some(last) = chain.last() else { return String::new() };
let id = last.match_id();
let parent = chain.len().checked_sub(2).map(|i| chain[i]);
let grand = chain.len().checked_sub(3).map(|i| chain[i]);
let top = chain.first().map(|n| n.match_id()).unwrap_or("");
let own = || id.to_string();
if chain.len() == 1 {
return find(LAYER_GROUPS, id).map(str::to_string).unwrap_or_else(own);
}
let pid = parent.map(|p| p.match_id()).unwrap_or("");
match top {
"transform" if chain.len() == 2 => find(TRANSFORM, id).map(str::to_string).unwrap_or_else(own),
"masks" if chain.len() == 2 => "ADBE Mask Atom".into(),
"masks" if chain.len() == 3 => find(MASK, id).map(str::to_string).unwrap_or_else(own),
"effects" if chain.len() == 2 => effect(id),
"effects" if chain.len() == 3 => {
let fx = parent.and_then(Node::as_group);
match fx {
Some(g) => {
let i = g.children.iter().position(|c| c.uid() == last.uid()).unwrap_or(0) + 1;
format!("{}-{i:04}", effect(&g.match_id))
}
None => own(),
}
}
"text" if chain.len() == 2 => find(TEXT, id).map(str::to_string).unwrap_or_else(own),
"text" if pid == "animators" => "ADBE Text Animator".into(),
"text" if grand.map(|g| g.match_id()) == Some("animators") => find(TEXT_ANIMATOR, id).map(str::to_string).unwrap_or_else(own),
"cameraOptions" => find(CAMERA, id).map(str::to_string).unwrap_or_else(own),
"lightOptions" => find(LIGHT, id).map(str::to_string).unwrap_or_else(own),
"materialOptions" => find(MATERIAL, id).map(str::to_string).unwrap_or_else(own),
"audio" if id == "levels" => "ADBE Audio Levels".into(),
"contents" => {
// Items sit in a `contents` list; their properties under the item.
if pid == "contents" {
if id == "star" || id == "polygon" {
return "ADBE Vector Shape - Star".into();
}
return find(SHAPE_ITEMS, id).map(str::to_string).unwrap_or_else(own);
}
if let Some((_, _, m)) = SHAPE_PROPS.iter().find(|(item, prop, _)| *item == pid && *prop == id) {
return m.to_string();
}
own()
}
_ => own(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn effect_names_round_trip() {
for (id, m) in EFFECTS {
assert_eq!(effect(id), *m);
assert_eq!(effect_from_match(m), Some(*id));
}
assert_eq!(effect("ec.blur.ccvector"), "ec.blur.ccvector");
let mut ids: Vec<&str> = EFFECTS.iter().map(|(a, _)| *a).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), EFFECTS.len(), "duplicate effect ids");
}
#[test]
fn shape_kinds() {
assert_eq!(shape_kind("ADBE Vector Shape - Rect"), Some("rect"));
assert_eq!(shape_kind("Trim Paths"), Some("trim"));
assert_eq!(shape_kind("ADBE Vector Graphic - Fill"), Some("fill"));
assert_eq!(shape_kind("nope"), None);
}
}
+679
View File
@@ -0,0 +1,679 @@
//! Read-only queries behind the object model (`__query(kind, json)`): project, items, layers,
//! property nodes, values and keyframes as JSON shaped for the prelude. Every edit goes through
//! engine commands instead (`__exec`), so it is undoable and journaled.
use effectcraft_engine::Session;
use effectcraft_engine::color::Label;
use effectcraft_keyframe::{Interp, TextDoc, Value as KV};
use effectcraft_project::{Comp, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, PropGroup};
use effectcraft_render::EvalCtx;
use effectcraft_time::Tick;
use serde_json::{Value as J, json};
use crate::matchnames;
type R = Result<J, String>;
fn u(a: &J, k: &str) -> Option<u64> {
a.get(k).and_then(J::as_u64)
}
fn f(a: &J, k: &str) -> Option<f64> {
a.get(k).and_then(J::as_f64)
}
fn comp_of<'a>(s: &'a Session, a: &J) -> Result<(ItemId, &'a Comp), String> {
let id = ItemId(u(a, "comp").ok_or("missing comp")?);
s.project.comp(id).map(|c| (id, c)).ok_or_else(|| "the composition no longer exists".to_string())
}
fn layer_of<'a>(s: &'a Session, a: &J) -> Result<(ItemId, &'a Comp, &'a Layer), String> {
let (cid, c) = comp_of(s, a)?;
let l = c.layer(LayerId(u(a, "layer").ok_or("missing layer")?)).ok_or("the layer no longer exists (it was deleted)")?;
Ok((cid, c, l))
}
pub fn query(s: &mut Session, kind: &str, a: &J) -> R {
match kind {
"project" => Ok(project(s)),
"item" => item(s, a),
"layer" => layer(s, a),
"node" => node(s, a),
"child" => child(s, a),
"value" => value(s, a),
"keys" => keys(s, a),
"textdoc" => textdoc(s, a),
"markers" => markers(s, a),
"sourceRect" => source_rect(s, a),
"resolveAdd" => resolve_add(s, a),
"menuCommandId" => Ok(menu_command_id(a.get("name").and_then(J::as_str).unwrap_or(""))),
"commandAt" => {
let i = u(a, "index").unwrap_or(0) as usize;
Ok(effectcraft_engine::command_specs().get(i.wrapping_sub(1)).map(|c| json!(c.id)).unwrap_or(J::Null))
}
"exprError" => {
let code = a.get("code").and_then(J::as_str).unwrap_or("");
Ok(match s.expr_check {
Some(check) => json!(check(code).err().unwrap_or_default()),
None => json!(""),
})
}
"labels" => Ok(json!(Label::ALL.iter().map(|l| l.name()).collect::<Vec<_>>())),
"effects" => Ok(json!(
effectcraft_engine::effects::registry()
.iter()
.map(|e| json!({"id": e.id, "name": e.name, "matchName": matchnames::effect(e.id), "category": e.category}))
.collect::<Vec<_>>()
)),
_ => Err(format!("unknown query `{kind}`")),
}
}
fn project(s: &Session) -> J {
let p = &s.project;
let items: Vec<u64> = p.items.keys().map(|k| k.0).collect();
let active = s.active_comp_id().map(|c| c.0).or_else(|| (s.state.project_selection.len() == 1).then(|| s.state.project_selection[0].0));
json!({
"items": items,
"active": active,
"selection": s.state.project_selection.iter().map(|i| i.0).collect::<Vec<_>>(),
"path": s.path,
"dirty": s.is_dirty(),
"bitsPerChannel": p.settings.bit_depth.bits(),
"linearBlending": p.settings.blend_linear,
"linearizeWorkingSpace": p.settings.linearize,
"workingSpace": p.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default(),
"timeDisplayFrames": p.settings.time_display == effectcraft_project::TimeDisplayStyle::Frames,
"renderQueue": p.render_queue.len(),
"revision": s.revision,
})
}
fn label_index(l: Label) -> usize {
Label::ALL.iter().position(|x| *x == l).unwrap_or(0)
}
fn item(s: &Session, a: &J) -> R {
let id = ItemId(u(a, "id").ok_or("missing id")?);
let it = s.project.item(id).ok_or("the item no longer exists (it was deleted)")?;
let mut o = json!({
"id": id.0,
"name": it.name,
"typeName": it.type_name(),
"parent": it.parent.map(|p| p.0).unwrap_or(0),
"comment": it.comment,
"label": label_index(it.label),
"selected": s.state.project_selection.contains(&id),
});
let used_in: Vec<u64> = s.project.comps().filter(|(_, c)| c.layers.iter().any(|l| l.source.item() == Some(id))).map(|(cid, _)| cid.0).collect();
o["usedIn"] = json!(used_in);
match &it.kind {
ItemKind::Folder => {
o["kind"] = json!("folder");
o["children"] = json!(s.project.items.values().filter(|c| c.parent == Some(id)).map(|c| c.id.0).collect::<Vec<_>>());
}
ItemKind::Comp(c) => {
let cid = id;
o["kind"] = json!("comp");
let sel: Vec<u64> = s.state.selected_layers.iter().filter(|l| c.layer(**l).is_some()).map(|l| l.0).collect();
let fps = c.frame_rate.as_f64();
if let Some(m) = o.as_object_mut() {
m.extend(
json!({
"width": c.width, "height": c.height, "pixelAspect": c.pixel_aspect,
"duration": c.duration.seconds(), "frameRate": fps, "frameDuration": c.frame_duration().seconds(),
"layers": c.layers.iter().map(|l| l.id.0).collect::<Vec<_>>(),
"selectedLayers": sel,
"selectedProperties": s.state.selected_props.iter().filter(|(l, _)| c.layer(*l).is_some()).map(|(l, p)| json!([l.0, p])).collect::<Vec<_>>(),
"time": s.time_of(cid).seconds(),
"workAreaStart": c.work_area.0.seconds(), "workAreaDuration": (c.work_area.1 - c.work_area.0).seconds(),
"bgColor": c.background, "displayStartTime": c.display_start.seconds(),
"hideShyLayers": c.hide_shy, "motionBlur": c.enable_motion_blur, "frameBlending": c.enable_frame_blending,
"draft3d": c.draft_3d, "shutterAngle": c.shutter_angle, "shutterPhase": c.shutter_phase,
"motionBlurSamplesPerFrame": c.motion_blur_samples,
"renderer": match c.renderer { effectcraft_project::Renderer::Classic3D => "ADBE Classic 3D", effectcraft_project::Renderer::Advanced3D => "ADBE Advanced 3d" },
"open": s.state.open_comps.contains(&cid), "active": s.active_comp_id() == Some(cid),
"hasVideo": true, "hasAudio": c.layers.iter().any(|l| l.switches.audio && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. })),
"posterTime": c.poster_time.seconds(),
})
.as_object()
.cloned()
.unwrap_or_default(),
)
}
}
ItemKind::Footage(fo) => {
o["kind"] = json!("footage");
o["file"] = json!(fo.path);
o["width"] = json!(fo.width);
o["height"] = json!(fo.height);
o["pixelAspect"] = json!(fo.pixel_aspect);
o["duration"] = json!(if fo.kind == effectcraft_project::FootageKind::Still { 0.0 } else { fo.duration.seconds() });
o["frameRate"] = json!(if fo.kind == effectcraft_project::FootageKind::Still { 0.0 } else { fo.frame_rate.as_f64() });
o["hasVideo"] = json!(fo.has_video);
o["hasAudio"] = json!(fo.has_audio);
o["footageMissing"] = json!(fo.missing);
o["isStill"] = json!(fo.kind == effectcraft_project::FootageKind::Still);
o["loop"] = json!(fo.loop_count);
}
ItemKind::Solid(so) => {
o["kind"] = json!("solid");
o["width"] = json!(so.width);
o["height"] = json!(so.height);
o["pixelAspect"] = json!(so.pixel_aspect);
o["color"] = json!(so.color);
o["duration"] = json!(0.0);
o["frameRate"] = json!(0.0);
o["hasVideo"] = json!(true);
o["hasAudio"] = json!(false);
o["isStill"] = json!(true);
}
}
Ok(o)
}
fn layer(s: &Session, a: &J) -> R {
let (cid, c, l) = layer_of(s, a)?;
let kind = match &l.source {
LayerSource::Text => "text",
LayerSource::Shape => "shape",
LayerSource::Camera => "camera",
LayerSource::Light { .. } => "light",
_ => "av",
};
let (w, h) = effectcraft_render::source_size(&s.project, l);
let t = s.time_of(cid);
Ok(json!({
"id": l.id.0,
"comp": cid.0,
"kind": kind,
"name": l.name,
"index": c.index_of(l.id).unwrap_or(0),
"matchName": matchnames::layer(&l.source),
"root": l.props.uid,
"source": l.source.item().map(|i| i.0),
"nullLayer": matches!(l.source, LayerSource::Null),
"lightType": match &l.source { LayerSource::Light { kind } => kind.label(), _ => "" },
"enabled": l.switches.video,
"audioEnabled": l.switches.audio,
"solo": l.switches.solo,
"locked": l.switches.locked,
"shy": l.switches.shy,
"collapse": l.switches.collapse,
"quality": format!("{:?}", l.switches.quality),
"samplingQuality": format!("{:?}", l.switches.sampling),
"effectsActive": l.switches.effects,
"frameBlending": format!("{:?}", l.switches.frame_blend),
"motionBlur": l.switches.motion_blur,
"adjustmentLayer": l.switches.adjustment,
"threeDLayer": l.switches.three_d,
"guideLayer": l.switches.guide,
"preserveTransparency": l.preserve_transparency,
"inPoint": l.in_point.seconds(),
"outPoint": l.out_point.seconds(),
"startTime": l.start_time.seconds(),
"stretch": l.stretch,
"time": l.layer_time(t).seconds(),
"active": l.switches.video && l.is_active_at(t),
"parent": l.parent.map(|p| p.0),
"blendingMode": l.blend_mode.label(),
"trackMatte": l.track_matte.map(|m| json!({"layer": m.layer.0, "kind": format!("{:?}", m.kind)})),
"isTrackMatte": c.layers.iter().any(|x| x.track_matte.is_some_and(|m| m.layer == l.id)),
"label": label_index(l.label),
"comment": l.comment,
"selected": s.state.selected_layers.contains(&l.id),
"autoOrient": format!("{:?}", l.auto_orient),
"width": w, "height": h,
"hasVideo": l.source.is_av(),
"hasAudio": matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. }) && l.props.sub("audio").is_some(),
"timeRemapEnabled": l.props.prop("timeRemap").is_some(),
}))
}
/// Node at the end of a uid chain below the layer root, and the chain itself.
fn chain(l: &Layer, uid: u64) -> Option<Vec<&Node>> {
l.props.node_chain(uid)
}
/// PropertyValueType of a property.
fn value_type(p: &effectcraft_project::Property) -> &'static str {
if matches!(p.ui, ParamUi::Mask) {
return "MASK_INDEX";
}
match &p.value {
KV::Scalar(_) | KV::Bool(_) | KV::Enum(_) => "OneD",
KV::Vec2(_) => {
if p.spatial {
"TwoD_SPATIAL"
} else {
"TwoD"
}
}
KV::Vec3(_) => {
if p.spatial {
"ThreeD_SPATIAL"
} else {
"ThreeD"
}
}
KV::Color(_) => "COLOR",
KV::Path(_) => "SHAPE",
KV::Text(_) => "TEXT_DOCUMENT",
KV::Layer(_) => "LAYER_INDEX",
KV::Gradient(_) | KV::Str(_) => "CUSTOM_VALUE",
}
}
/// Groups whose children are instances (effects, masks, shape items, animators).
fn indexed_parade(g: &PropGroup) -> bool {
matches!(g.match_id.as_str(), "effects" | "masks" | "contents" | "animators" | "selectors" | "layerStyles" | "motionTrackers")
}
fn node_json(c: &Comp, l: &Layer, uid: u64) -> R {
if uid == 0 || uid == l.props.uid {
let n = l.props.children.len();
return Ok(json!({
"uid": l.props.uid, "kind": "group", "name": l.name, "matchName": matchnames::layer(&l.source), "matchId": "layer",
"index": c.index_of(l.id).unwrap_or(0), "depth": 0, "parent": null, "isLayer": true, "numProperties": n,
"children": l.props.children.iter().map(Node::uid).collect::<Vec<_>>(), "propertyType": "NAMED_GROUP",
"enabled": l.switches.video, "canSetEnabled": true,
}));
}
let ch = chain(l, uid).ok_or("the property no longer exists (it was deleted)")?;
let node = *ch.last().ok_or("bad property")?;
let parent_group: &PropGroup = if ch.len() >= 2 { ch[ch.len() - 2].as_group().ok_or("bad tree")? } else { &l.props };
let index = parent_group.children.iter().position(|x| x.uid() == uid).map(|i| i + 1).unwrap_or(0);
let parent_uid = if ch.len() >= 2 { ch[ch.len() - 2].uid() } else { l.props.uid };
let match_name = matchnames::node(&ch);
let instance_parent = indexed_parade(parent_group) && ch.len() >= 2 || (ch.len() == 2 && ch[0].match_id() == "contents");
let mut o = json!({
"uid": uid, "name": node.name(), "matchName": match_name, "matchId": node.match_id(), "index": index,
"depth": ch.len(), "parent": parent_uid, "isLayer": false,
"isEffect": matches!(node.as_group().map(|g| &g.kind), Some(GroupKind::Effect { .. })),
"isMask": matches!(node.as_group().map(|g| &g.kind), Some(GroupKind::Mask { .. })),
});
match node {
Node::Group(g) => {
let instance = matches!(g.kind, GroupKind::Indexed | GroupKind::Effect { .. } | GroupKind::Mask { .. } | GroupKind::Tracker { .. });
o["kind"] = json!("group");
o["numProperties"] = json!(g.children.len());
o["children"] = json!(g.children.iter().map(Node::uid).collect::<Vec<_>>());
o["propertyType"] = json!(if indexed_parade(g) { "INDEXED_GROUP" } else { "NAMED_GROUP" });
o["enabled"] = json!(g.enabled);
o["canSetEnabled"] = json!(instance);
o["instance"] = json!(instance || instance_parent);
o["effectId"] = json!(match &g.kind {
GroupKind::Effect { effect } => Some(effect.clone()),
_ => None,
});
if let GroupKind::Mask { mode, inverted, color, locked, .. } = &g.kind {
o["maskMode"] = json!(mode.label());
o["inverted"] = json!(inverted);
o["color"] = json!([color[0] as f64 / 255.0, color[1] as f64 / 255.0, color[2] as f64 / 255.0]);
o["locked"] = json!(locked);
}
}
Node::Prop(p) => {
o["kind"] = json!("prop");
o["propertyType"] = json!("PROPERTY");
o["valueType"] = json!(value_type(p));
o["type"] = json!(p.value.kind_name());
o["dims"] = json!(p.value.dims());
o["spatial"] = json!(p.spatial);
o["numKeys"] = json!(p.keys.len());
o["expression"] = json!(p.expr.as_ref().map(|e| e.text.clone()).unwrap_or_default());
o["expressionEnabled"] = json!(p.has_expression());
o["canVaryOverTime"] = json!(!p.static_only);
o["canSetExpression"] = json!(!p.static_only && !matches!(p.ui, ParamUi::Hidden));
o["hidden"] = json!(matches!(p.ui, ParamUi::Hidden));
o["enabled"] = json!(true);
o["canSetEnabled"] = json!(false);
o["isModified"] = json!(p.is_animated() || p.has_expression());
if let ParamUi::Slider { min, max, .. } = p.ui {
o["min"] = json!(min);
o["max"] = json!(max);
}
if let ParamUi::Popup { options } = &p.ui {
o["options"] = json!(options);
}
o["ui"] = json!(match &p.ui {
ParamUi::Percent => "%",
ParamUi::Angle => "degrees",
ParamUi::Pixels | ParamUi::Point | ParamUi::Point3 => "pixels",
_ => "",
});
// Position with Separate Dimensions.
o["separated"] = json!(node.match_id() == "position" && parent_group.match_id == "transform" && parent_group.get("positionX").is_some());
o["separationFollower"] = json!(matches!(node.match_id(), "positionX" | "positionY" | "positionZ"));
}
}
Ok(o)
}
fn node(s: &Session, a: &J) -> R {
let (_, c, l) = layer_of(s, a)?;
node_json(c, l, u(a, "uid").unwrap_or(0))
}
/// Normalized name for loose lookups: `Anchor Point`, `anchorPoint`, `anchor_point` → `anchorpoint`.
fn loose(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase()
}
/// AE-style aliases for our match ids (attribute names scripts use).
fn alias(k: &str) -> Option<&'static str> {
Some(match k {
"anchorpoint" => "anchor",
"pointofinterest" => "poi",
"xrotation" => "rotationX",
"yrotation" => "rotationY",
"zrotation" => "rotation",
"xposition" => "positionX",
"yposition" => "positionY",
"zposition" => "positionZ",
"effect" | "effects" => "effects",
"mask" | "masks" => "masks",
"content" | "contents" => "contents",
"maskshape" | "maskpath" => "path",
"maskfeather" => "feather",
"maskopacity" => "opacity",
"maskexpansion" | "maskoffset" => "expansion",
"sourcetext" => "sourceText",
"timeremap" => "timeRemap",
"audiolevels" => "levels",
_ => return None,
})
}
fn child(s: &Session, a: &J) -> R {
let (_, c, l) = layer_of(s, a)?;
let uid = u(a, "uid").unwrap_or(0);
let g: &PropGroup = if uid == 0 || uid == l.props.uid { &l.props } else { l.props.find_group(uid).ok_or("not a property group")? };
let key = a.get("key").cloned().unwrap_or(J::Null);
let found = match &key {
J::Number(n) => n.as_u64().and_then(|i| g.children.get((i as usize).checked_sub(1)?)),
J::String(k) => {
let chain_of = |n: &Node| -> String {
let mut ch = if uid == 0 || uid == l.props.uid { vec![] } else { chain(l, uid).unwrap_or_default() };
ch.push(n);
matchnames::node(&ch)
};
let lk = loose(k);
g.children
.iter()
.find(|n| chain_of(n) == *k)
.or_else(|| g.children.iter().find(|n| n.name() == k))
.or_else(|| g.children.iter().find(|n| n.match_id() == k))
.or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k)))
.or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk))
.or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m)))
}
_ => None,
};
match found {
Some(n) => node_json(c, l, n.uid()),
None => Ok(J::Null),
}
}
/// A value as the object model shows it: popups 1-based, checkboxes 0/1, layer references as
/// layer indices, paths as `{vertices, inTangents, outTangents, closed}`, text as the document.
fn js_value(c: &Comp, v: &KV) -> J {
match v {
KV::Bool(b) => json!(if *b { 1 } else { 0 }),
KV::Enum(i) => json!(*i + 1),
KV::Layer(l) => json!(l.and_then(|id| c.index_of(LayerId(id))).unwrap_or(0)),
KV::Path(p) => json!({"vertices": p.vertices, "inTangents": p.in_tangents, "outTangents": p.out_tangents, "closed": p.closed}),
KV::Text(d) => text_json(d),
v => v.to_json(),
}
}
/// A text document for `TextDocument`: the serialized document plus the first character's style.
fn text_json(d: &TextDoc) -> J {
let mut doc = serde_json::to_value(d).unwrap_or(J::Null);
let st = d.style_at(0);
if let Some(o) = doc.as_object_mut() {
o.insert("first".into(), effectcraft_keyframe::text_doc::char_style_json(&st));
o.insert("para".into(), effectcraft_keyframe::text_doc::para_style_json(&d.para(0)));
}
doc
}
fn value(s: &Session, a: &J) -> R {
let (cid, c, l) = layer_of(s, a)?;
let uid = u(a, "uid").ok_or("missing uid")?;
let p = l.props.find(uid).ok_or("the property no longer exists (it was deleted)")?;
let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
let pre = a.get("pre").and_then(J::as_bool).unwrap_or(false);
let v = if pre || !p.has_expression() {
p.value_at(l.layer_time(t))
} else {
let mut ctx = EvalCtx::new(&s.project, cid, c, t);
ctx.expr = s.expr.as_deref();
ctx.value(l, p)
};
Ok(js_value(c, &v))
}
fn interp(i: Interp) -> &'static str {
match i {
Interp::Linear => "LINEAR",
Interp::Bezier => "BEZIER",
Interp::Hold => "HOLD",
}
}
fn keys(s: &Session, a: &J) -> R {
let (cid, c, l) = layer_of(s, a)?;
let uid = u(a, "uid").ok_or("missing uid")?;
let p = l.props.find(uid).ok_or("the property no longer exists (it was deleted)")?;
let sel: Vec<Tick> = s.state.selected_keys.iter().filter(|k| k.layer == l.id && k.prop == uid).map(|k| k.time).collect();
let _ = cid;
let ease = |e: &[effectcraft_keyframe::Ease], n: usize| -> J {
let n = n.max(1);
// Influence is stored as a fraction; scripts see percent.
let mut v: Vec<J> = e.iter().map(|x| json!({"speed": x.speed, "influence": x.influence * 100.0})).collect();
let d = e.first().cloned().unwrap_or_default();
while v.len() < n {
v.push(json!({"speed": d.speed, "influence": d.influence * 100.0}));
}
J::Array(v)
};
let dims = if p.spatial { 1 } else { p.value.dims().max(1) };
let out: Vec<J> = p
.keys
.iter()
.map(|k| {
json!({
"time": l.comp_time(k.time).seconds(),
"layerTime": k.time.seconds(),
"value": js_value(c, &k.value),
"inInterp": interp(k.in_interp), "outInterp": interp(k.out_interp),
"inEase": ease(&k.in_ease, dims), "outEase": ease(&k.out_ease, dims),
"continuous": k.continuous, "autoBezier": k.auto_bezier, "roving": k.roving,
"spatialIn": k.spatial_in, "spatialOut": k.spatial_out,
"spatialContinuous": k.spatial_continuous, "spatialAutoBezier": k.spatial_auto,
"selected": sel.contains(&k.time),
})
})
.collect();
Ok(J::Array(out))
}
/// Apply `layer.setText` attribute keys to the Source Text document at `time` and return the new
/// document (for `prop.set` / `prop.addKey`).
fn textdoc(s: &Session, a: &J) -> R {
let (cid, _, l) = layer_of(s, a)?;
let uid = u(a, "uid").ok_or("missing uid")?;
let p = l.props.find(uid).ok_or("the property no longer exists")?;
let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
let KV::Text(doc) = p.value_at(l.layer_time(t)) else { return Err("not a Source Text property".into()) };
let mut d = *doc;
if let Some(attrs) = a.get("attrs").and_then(J::as_object) {
if let Some(text) = attrs.get("text").and_then(J::as_str) {
d.set_text(text);
}
for (k, v) in attrs {
if k == "text" || v.is_null() {
continue;
}
match d.set_attr(k, v, None) {
Ok(true) => {}
Ok(false) => return Err(format!("TextDocument: unknown attribute `{k}`")),
Err(e) => return Err(format!("TextDocument.{k}: {e}")),
}
}
}
serde_json::to_value(&d).map_err(|e| e.to_string())
}
fn markers(s: &Session, a: &J) -> R {
let (_, c) = comp_of(s, a)?;
let (list, to_comp): (&[effectcraft_project::Marker], Option<&Layer>) = match u(a, "layer") {
Some(lid) => {
let l = c.layer(LayerId(lid)).ok_or("the layer no longer exists")?;
(&l.markers, Some(l))
}
None => (&c.markers, None),
};
let out: Vec<J> = list
.iter()
.map(|m| {
let t = to_comp.map(|l| l.comp_time(m.time)).unwrap_or(m.time);
json!({
"time": t.seconds(), "duration": m.duration.seconds(), "comment": m.comment, "chapter": m.chapter, "url": m.url,
"frameTarget": m.frame_target, "label": label_index(m.label), "protectedRegion": m.protected,
"cuePointName": m.cue_point.as_ref().map(|c| c.name.clone()).unwrap_or_default(),
"eventCuePoint": m.cue_point.as_ref().is_some_and(|c| !c.navigation),
"params": m.cue_point.as_ref().map(|c| c.params.clone()).unwrap_or_default(),
})
})
.collect();
Ok(J::Array(out))
}
fn source_rect(s: &Session, a: &J) -> R {
let (cid, c, l) = layer_of(s, a)?;
let t = f(a, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
let mut ctx = EvalCtx::new(&s.project, cid, c, t);
ctx.expr = s.expr.as_deref();
let r = match &l.source {
LayerSource::Text => {
let paths: Vec<_> = effectcraft_render::text::glyph_paths(&ctx, l).into_iter().map(|(p, _)| p).collect();
effectcraft_path::bounds(&paths).map(|b| [b.y0, b.x0, b.width(), b.height()]).unwrap_or([0.0; 4])
}
LayerSource::Shape => match l.props.sub("contents") {
Some(contents) => {
let buf = effectcraft_render::shapes::render(&ctx, l, contents, 1.0);
if buf.img.width <= 4 { [0.0; 4] } else { [2.0 - buf.offset[1], 2.0 - buf.offset[0], buf.img.width as f64 - 4.0, buf.img.height as f64 - 4.0] }
}
None => [0.0; 4],
},
_ => {
let (w, h) = effectcraft_render::source_size(&s.project, l);
[0.0, 0.0, w as f64, h as f64]
}
};
Ok(json!({"top": r[0], "left": r[1], "width": r[2], "height": r[3]}))
}
/// What `PropertyGroup.addProperty(name)` adds to the group `uid`.
fn resolve_add(s: &Session, a: &J) -> R {
let (_, _, l) = layer_of(s, a)?;
let uid = u(a, "uid").unwrap_or(0);
let name = a.get("name").and_then(J::as_str).unwrap_or("");
let g: &PropGroup = if uid == 0 || uid == l.props.uid { &l.props } else { l.props.find_group(uid).ok_or("not a property group")? };
let in_contents = g.match_id == "contents";
let in_group = l.props.sub("contents").is_some_and(|c| c.uid == g.uid || c.find_group(g.uid).is_some());
Ok(match g.match_id.as_str() {
"effects" => {
let id = matchnames::effect_from_match(name).map(str::to_string).or_else(|| effectcraft_engine::effects::lookup(name).map(|e| e.id.to_string()));
match id {
Some(id) => json!({"kind": "effect", "effect": id}),
None => json!({"error": format!("Can not add a property with name \"{name}\" to this PropertyGroup (unknown effect)")}),
}
}
"masks" => {
if name == "ADBE Mask Atom" || loose(name) == "mask" {
json!({"kind": "mask"})
} else {
json!({"error": format!("Can not add \"{name}\" to Masks (use \"ADBE Mask Atom\")")})
}
}
"animators" => json!({"kind": "animator"}),
_ if in_contents && in_group => match matchnames::shape_kind(name) {
Some(k) => {
json!({"kind": "shape", "shape": k, "group": if l.props.sub("contents").is_some_and(|c| c.uid == g.uid) { J::Null } else { json!(g.uid) }})
}
None => json!({"error": format!("Can not add \"{name}\" to Contents")}),
},
"properties" if l.props.group("text/animators").is_some_and(|a| a.find_group(g.uid).is_some()) => {
let animator = l.props.group("text/animators").and_then(|a| a.groups().find(|x| x.find_group(g.uid).is_some()).map(|x| x.uid));
match text_animator_property(name) {
Some(k) => json!({"kind": "animatorProperty", "property": k, "animator": animator}),
None => json!({"error": format!("Can not add \"{name}\" to an animator's Properties")}),
}
}
"selectors" if l.props.group("text/animators").is_some_and(|a| a.find_group(g.uid).is_some()) => {
let animator = l.props.group("text/animators").and_then(|a| a.groups().find(|x| x.find_group(g.uid).is_some()).map(|x| x.uid));
let kind = match name {
"ADBE Text Wiggly Selector" => "wiggly",
"ADBE Text Expressible Selector" => "expression",
n if loose(n).contains("wiggly") => "wiggly",
n if loose(n).contains("expression") => "expression",
_ => "range",
};
json!({"kind": "selector", "selector": kind, "animator": animator})
}
_ => json!({"error": format!("Can not add a property to \"{}\"", g.name)}),
})
}
/// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match
/// name or a display name.
fn text_animator_property(name: &str) -> Option<&'static str> {
const AE: &[(&str, &str)] = &[
("ADBE Text Anchor Point 3D", "anchor"),
("ADBE Text Position 3D", "position"),
("ADBE Text Scale 3D", "scale"),
("ADBE Text Skew", "skew"),
("ADBE Text Rotation", "rotation"),
("ADBE Text Opacity", "opacity"),
("ADBE Text Fill Color", "fillColor"),
("ADBE Text Fill Hue", "fillHue"),
("ADBE Text Fill Saturation", "fillSaturation"),
("ADBE Text Fill Brightness", "fillBrightness"),
("ADBE Text Fill Opacity", "fillOpacity"),
("ADBE Text Stroke Color", "strokeColor"),
("ADBE Text Stroke Hue", "strokeHue"),
("ADBE Text Stroke Saturation", "strokeSaturation"),
("ADBE Text Stroke Brightness", "strokeBrightness"),
("ADBE Text Stroke Opacity", "strokeOpacity"),
("ADBE Text Stroke Width", "strokeWidth"),
("ADBE Text Tracking Amount", "tracking"),
("ADBE Text Line Anchor", "lineAnchor"),
("ADBE Text Line Spacing", "lineSpacing"),
("ADBE Text Character Offset", "characterOffset"),
("ADBE Text Character Replace", "characterValue"),
("ADBE Text Blur", "blur"),
];
if let Some((_, k)) = AE.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) {
return Some(k);
}
let n = loose(name);
AE.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking"))
}
/// `app.findMenuCommandId(name)`: 1-based index of the command whose label matches (menu items
/// first), 0 when none does.
fn menu_command_id(name: &str) -> J {
let norm = |s: &str| s.trim().trim_end_matches("...").trim_end_matches('…').trim().to_ascii_lowercase();
let n = norm(name);
let specs = effectcraft_engine::command_specs();
let pick = specs
.iter()
.position(|c| !c.menu.is_empty() && norm(c.label) == n)
.or_else(|| specs.iter().position(|c| norm(c.label) == n))
.or_else(|| specs.iter().position(|c| c.id == name));
json!(pick.map(|i| i + 1).unwrap_or(0))
}
File diff suppressed because it is too large Load Diff
+546
View File
@@ -0,0 +1,546 @@
//! The boa runtime: native functions (`__exec`, `__query`, `__print`, `__undo`, `__task`,
//! `__file`), the session hand-off while a script runs, undo groups and error reporting.
use std::cell::RefCell;
use std::mem::ManuallyDrop;
use std::sync::Arc;
use boa_engine::{Context, JsNativeError, JsResult, JsString, JsValue, NativeFunction, Source, js_string};
use effectcraft_engine::project::Project;
use effectcraft_engine::{ScriptRequest, Session};
use serde_json::{Value as J, json};
const PRELUDE: &str = include_str!("prelude.js");
/// Engine commands that create layers: like After Effects, scripts add new layers at the top of
/// the stack (the UI inserts above the selection).
const CREATES_LAYER: &[&str] =
&["layer.newSolid", "layer.newNull", "layer.newText", "layer.newShape", "layer.newAdjustment", "layer.newCamera", "layer.newLight", "layer.addItem"];
/// What a script run produced.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Outcome {
/// The value of the script's last expression statement (JSON when it converts; model objects
/// as strings like `"[object CompItem]"`).
pub result: J,
/// `writeLn` / `$.writeln` / `alert` output, one entry per line.
pub output: Vec<String>,
pub error: Option<ScriptError>,
}
/// An uncaught script error.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ScriptError {
pub message: String,
pub line: Option<u32>,
pub column: Option<u32>,
pub file: String,
}
impl Outcome {
pub fn ok(&self) -> bool {
self.error.is_none()
}
pub fn to_json(&self) -> J {
json!({
"ok": self.ok(),
"result": self.result,
"output": self.output.join("\n"),
"error": self.error.as_ref().map(|e| json!({"message": e.message, "line": e.line, "column": e.column, "file": e.file})),
})
}
}
/// The session and per-run state while a script runs (the natives reach it here; nothing
/// borrowed ever enters the JS heap).
struct Active {
session: Session,
output: Vec<String>,
/// Open undo groups: (name, project snapshot when the outermost group began).
groups: Vec<(String, Arc<Project>)>,
/// `app.scheduleTask` queue: (id, code, repeat).
tasks: Vec<(u32, String, bool)>,
next_task: u32,
name: String,
}
thread_local! {
static ACTIVE: RefCell<Option<Active>> = const { RefCell::new(None) };
/// The Script Console's persistent context (never dropped, like the expression runtime: boa's
/// own thread-local heap may be torn down first at thread exit).
static CONSOLE: RefCell<Option<ManuallyDrop<Context>>> = const { RefCell::new(None) };
}
fn with_active<T>(f: impl FnOnce(&mut Active) -> Result<T, String>) -> Result<T, String> {
ACTIVE.with(|a| match a.try_borrow_mut() {
Ok(mut g) => match g.as_mut() {
Some(act) => f(act),
None => Err("no script is running".into()),
},
Err(_) => Err("scripts can't run scripts".into()),
})
}
fn throw(msg: impl Into<String>) -> boa_engine::JsError {
JsNativeError::error().with_message(msg.into()).into()
}
fn arg_string(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult<String> {
Ok(args.get(i).cloned().unwrap_or_default().to_string(ctx)?.to_std_string_escaped())
}
fn arg_json(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult<J> {
let s = arg_string(args, i, ctx)?;
if s.is_empty() || s == "undefined" {
return Ok(json!({}));
}
serde_json::from_str(&s).map_err(|e| throw(format!("bad parameters: {e}")))
}
fn js_str(s: &str) -> JsValue {
JsValue::from(JsString::from(s))
}
/// `__exec(commandId, paramsJson)` → result JSON. Runs an engine command (undoable) with the
/// target comp made active for its duration.
fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let id = arg_string(args, 0, ctx)?;
let params = arg_json(args, 1, ctx)?;
if id == "script.run" || id == "file.runScript" {
return Err(throw("scripts can't run scripts"));
}
let r = with_active(|a| Ok(exec(&mut a.session, &id, params)));
match r {
Ok(Ok(v)) => Ok(js_str(&v.to_string())),
Ok(Err(e)) | Err(e) => Err(throw(e)),
}
}
/// Run a command for a script: the `comp` it targets is active while it runs (commands act on
/// the active comp), then the user's active comp comes back.
pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result<J, String> {
use effectcraft_engine::project::ItemId;
let target = params.get("comp").and_then(J::as_u64).map(ItemId).filter(|c| s.project.comp(*c).is_some());
let prev = s.state.active_comp;
if let Some(c) = target
&& prev != Some(c)
{
s.state.active_comp = Some(c);
}
if CREATES_LAYER.contains(&id) {
s.state.selected_layers.clear();
}
let r = s.execute(id, params);
if let Some(c) = target
&& prev != Some(c)
&& s.state.active_comp == Some(c)
&& id != "comp.open"
{
match prev {
Some(p) if s.project.comp(p).is_some() => s.state.active_comp = Some(p),
_ => {
if !s.state.open_comps.contains(&c) {
s.state.open_comps.push(c);
}
}
}
}
r.map_err(|e| e.to_string())
}
/// `__query(kind, json)` → JSON.
fn native_query(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let kind = arg_string(args, 0, ctx)?;
let a = arg_json(args, 1, ctx)?;
let r = with_active(|act| match kind.as_str() {
"app" => Ok(json!({
"version": format!("26.0 (EffectCraft {})", env!("CARGO_PKG_VERSION")),
"buildName": env!("CARGO_PKG_VERSION"),
"scriptName": act.name,
"allowFiles": act.session.prefs.scripting.allow_scripts_write_files,
"projectDir": project_dir(&act.session),
})),
_ => crate::model::query(&mut act.session, &kind, &a),
});
match r {
Ok(v) => Ok(js_str(&v.to_string())),
Err(e) => Err(throw(e)),
}
}
/// `__print(text)`: `writeLn`, `$.writeln`, `alert`.
fn native_print(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let text = arg_string(args, 0, ctx)?;
let _ = with_active(|a| {
a.output.extend(text.split('\n').map(str::to_string));
Ok(())
});
Ok(JsValue::undefined())
}
/// `__undo("begin" | "end", name)`: After Effects' `app.beginUndoGroup` / `endUndoGroup`. Every
/// command run inside the outermost group folds into one undo step named after the group.
fn native_undo(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let op = arg_string(args, 0, ctx)?;
let name = arg_string(args, 1, ctx)?;
with_active(|a| {
match op.as_str() {
"begin" => {
let snap = a.session.project.clone();
a.groups.push((if name.is_empty() || name == "undefined" { "Script".into() } else { name }, snap));
}
_ => {
if a.groups.len() == 1 {
let (name, snap) = a.groups.pop().unwrap_or_default();
collapse(&mut a.session, &name, &snap);
} else {
a.groups.pop();
}
}
}
Ok(())
})
.map_err(throw)?;
Ok(JsValue::undefined())
}
/// Fold the undo steps recorded since the project was `snap` into one step called `name`.
fn collapse(s: &mut Session, name: &str, snap: &Arc<Project>) {
let h = &mut s.history;
let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) else {
// Nothing was recorded (or history was reset by New / Open Project).
return;
};
let first = h.undo[start].1.clone();
h.undo.truncate(start);
h.undo.push((name.to_string(), first));
h.merge_key = None;
}
/// `__task("schedule", code, repeat)` → id; `__task("cancel", id)`.
fn native_task(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let op = arg_string(args, 0, ctx)?;
if op == "cancel" {
let id = args.get(1).cloned().unwrap_or_default().to_number(ctx)? as u32;
let _ = with_active(|a| {
a.tasks.retain(|t| t.0 != id);
Ok(())
});
return Ok(JsValue::undefined());
}
let code = arg_string(args, 1, ctx)?;
let repeat = args.get(2).cloned().unwrap_or_default().to_boolean();
let id = with_active(|a| {
a.next_task += 1;
let id = a.next_task;
a.tasks.push((id, code, repeat));
Ok(id)
})
.map_err(throw)?;
Ok(JsValue::from(id))
}
fn project_dir(s: &Session) -> Option<String> {
let p = s.path.as_deref()?;
std::path::Path::new(p).parent().map(|d| d.to_string_lossy().to_string())
}
/// Lexically normalized absolute path (`a/./b/../c` → `a/c`).
fn normalize(p: &str) -> std::path::PathBuf {
use std::path::{Component, Path, PathBuf};
let path = Path::new(p);
let abs = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(path) };
let mut out = PathBuf::new();
for c in abs.components() {
match c {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
c => out.push(c),
}
}
// Resolve symlinks where the file (or its folder) exists, so links can't escape the project.
std::fs::canonicalize(&out)
.or_else(|_| out.parent().map(std::fs::canonicalize).unwrap_or(Ok(out.clone())).map(|d| d.join(out.file_name().unwrap_or_default())))
.unwrap_or(out)
}
const GATE: &str = "Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network";
/// The file-access gate: reads under the project's folder are always allowed; other reads, all
/// writes and listing other folders need the Allow Scripts to Write Files preference.
fn check_access(s: &Session, path: &str, write: bool) -> Result<std::path::PathBuf, String> {
let p = normalize(path);
if s.prefs.scripting.allow_scripts_write_files {
return Ok(p);
}
if write {
return Err(format!("Unable to write {path}: scripts can't write files unless {GATE} is on"));
}
if let Some(dir) = project_dir(s) {
let d = normalize(&dir);
if p.starts_with(&d) {
return Ok(p);
}
}
Err(format!("Unable to access {path}: scripts can only read files in the project's folder unless {GATE} is on"))
}
/// `__file(op, path, data)`: `read` → text, `write`/`append` → bytes, `exists` → bool, `list` →
/// names, `mkdir`, `remove`, `isDir`.
fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let op = arg_string(args, 0, ctx)?;
let path = arg_string(args, 1, ctx)?;
let data = if args.len() > 2 { arg_string(args, 2, ctx)? } else { String::new() };
let r: Result<J, String> = with_active(|a| {
let s = &a.session;
match op.as_str() {
"access" => Ok(json!(check_access(s, &path, false)?.exists())),
"exists" | "isDir" => {
// Unreadable locations report "missing" instead of failing.
let Ok(p) = check_access(s, &path, false) else { return Ok(json!(false)) };
Ok(json!(if op == "exists" { p.exists() } else { p.is_dir() }))
}
"read" => {
let p = check_access(s, &path, false)?;
let bytes = s.services.read_file(&p.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?;
Ok(json!(String::from_utf8_lossy(&bytes)))
}
"write" | "append" => {
let p = check_access(s, &path, true)?;
let ps = p.to_string_lossy().to_string();
let mut bytes = if op == "append" { s.services.read_file(&ps).unwrap_or_default() } else { vec![] };
bytes.extend_from_slice(data.as_bytes());
s.services.write_file(&ps, &bytes).map_err(|e| format!("Unable to write {path}: {e}"))?;
Ok(json!(bytes.len()))
}
"list" => {
let p = check_access(s, &path, false)?;
let mut names: Vec<String> = std::fs::read_dir(&p)
.map_err(|e| format!("Unable to list {path}: {e}"))?
.filter_map(|e| e.ok())
.map(|e| e.path().to_string_lossy().to_string())
.collect();
names.sort();
Ok(json!(names))
}
"mkdir" => {
let p = check_access(s, &path, true)?;
std::fs::create_dir_all(&p).map_err(|e| format!("Unable to create {path}: {e}"))?;
Ok(json!(true))
}
"remove" => {
let p = check_access(s, &path, true)?;
let r = if p.is_dir() { std::fs::remove_dir(&p) } else { std::fs::remove_file(&p) };
Ok(json!(r.is_ok()))
}
"network" => Err(if s.prefs.scripting.allow_scripts_write_files {
"network access from scripts is not supported in EffectCraft".to_string()
} else {
format!("scripts can't access the network unless {GATE} is on")
}),
_ => Err(format!("unknown file operation `{op}`")),
}
});
match r {
Ok(v) => Ok(js_str(&v.to_string())),
Err(e) => Err(throw(e)),
}
}
fn new_context() -> Result<Context, String> {
let mut ctx = Context::default();
let limits = ctx.runtime_limits_mut();
limits.set_loop_iteration_limit(200_000_000);
limits.set_recursion_limit(2000);
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 6] = [
(js_string!("__exec"), 2, native_exec),
(js_string!("__query"), 2, native_query),
(js_string!("__print"), 1, native_print),
(js_string!("__undo"), 2, native_undo),
(js_string!("__task"), 3, native_task),
(js_string!("__file"), 3, native_file),
];
for (name, len, f) in natives {
ctx.register_global_callable(name, len, NativeFunction::from_fn_ptr(f)).map_err(|e| e.to_string())?;
}
ctx.eval(Source::from_bytes(PRELUDE)).map_err(|e| format!("script prelude: {e}"))?;
Ok(ctx)
}
/// Turn a boa error into a message with line and column (`code` is the user's script).
fn script_error(e: &boa_engine::JsError, ctx: &mut Context, file: &str, code: &str) -> ScriptError {
// Thrown values: `throw new Error("x")`, `throw "x"`, native errors.
let display = e.to_string();
let message = match e.as_opaque() {
Some(v) => {
let o = v.as_object();
let msg = o.as_ref().and_then(|o| o.get(js_string!("message"), ctx).ok()).filter(|m| !m.is_undefined());
let name = o.as_ref().and_then(|o| o.get(js_string!("name"), ctx).ok()).filter(|m| !m.is_undefined());
match (name, msg) {
(Some(n), Some(m)) => {
let n = n.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default();
let m = m.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default();
if n == "Error" { m } else { format!("{n}: {m}") }
}
_ => v.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_else(|_| display.clone()),
}
}
None => {
let first = display.lines().next().unwrap_or("").trim();
let first = first.split(" at line ").next().unwrap_or(first).trim();
let first = match first.rfind(" (") {
Some(i) if first.ends_with(')') && first[i..].contains(':') => first[..i].trim(),
_ => first,
};
first.strip_prefix("Error: ").unwrap_or(first).to_string()
}
};
let (line, column) = position(&display, file, code);
ScriptError { message, line, column, file: file.to_string() }
}
/// Line and column of an error: `at line N, col M` (syntax errors), else the innermost backtrace
/// frame in the user's script (`file:line:col`), else (names boa can't place) the first use of
/// the undefined name in `code`.
fn position(msg: &str, file: &str, code: &str) -> (Option<u32>, Option<u32>) {
if let Some(i) = msg.find("at line ") {
let rest = &msg[i + 8..];
let line = rest.split(|c: char| !c.is_ascii_digit()).next().and_then(|n| n.parse().ok());
let col = rest.find("col ").and_then(|j| rest[j + 4..].split(|c: char| !c.is_ascii_digit()).next()).and_then(|n| n.parse().ok());
return (line, col);
}
// `(file:12:5)`: the digits after the file name.
let pick = |l: &str| -> Option<(u32, u32)> {
let i = l.find(&format!("({file}:"))? + file.len() + 2;
let mut it = l[i..].split([':', ')']);
Some((it.next()?.parse().ok()?, it.next()?.parse().ok()?))
};
let frames: Vec<&str> = msg.lines().skip(1).collect();
if let Some((a, b)) = frames.iter().find_map(|l| pick(l)).or_else(|| msg.lines().next().and_then(pick)) {
return (Some(a), Some(b));
}
// `ReferenceError: name is not defined` without a position.
let first = msg.lines().next().unwrap_or("");
if let Some(name) = first.split("ReferenceError: ").nth(1).and_then(|r| r.split(" is not defined").next()) {
for (n, line) in code.lines().enumerate() {
let b = line.as_bytes();
let mut from = 0;
while let Some(i) = line[from..].find(name).map(|i| i + from) {
let before = i.checked_sub(1).map(|j| b[j]);
let after = b.get(i + name.len()).copied();
let ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'$';
if !before.is_some_and(|c| ident(c) || c == b'.') && !after.is_some_and(ident) {
return (Some(n as u32 + 1), Some(i as u32 + 1));
}
from = i + name.len();
}
}
}
(None, None)
}
/// Evaluate `code` in `ctx` and convert the result.
fn eval_in(ctx: &mut Context, code: &str, file: &str) -> Result<J, ScriptError> {
let src = Source::from_bytes(code.as_bytes()).with_path(std::path::Path::new(file));
let script = boa_engine::Script::parse(src, None, ctx).map_err(|e| script_error(&e, ctx, file, code))?;
let v = script.evaluate(ctx).map_err(|e| script_error(&e, ctx, file, code))?;
Ok(result_json(&v, ctx))
}
/// The script's completion value as JSON (`__result` in the prelude does the conversion).
fn result_json(v: &JsValue, ctx: &mut Context) -> J {
if v.is_undefined() {
return J::Null;
}
let global = ctx.global_object();
let Some(f) = global.get(js_string!("__result"), ctx).ok().and_then(|f| f.as_object()) else { return J::Null };
match f.call(&JsValue::undefined(), std::slice::from_ref(v), ctx) {
Ok(s) if s.is_string() => {
let s = s.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default();
serde_json::from_str(&s).unwrap_or(J::String(s))
}
_ => J::Null,
}
}
/// Restores the caller's session when a run ends (on every exit path).
struct Restore<'a> {
target: &'a mut Session,
}
impl Drop for Restore<'_> {
fn drop(&mut self) {
if let Some(a) = ACTIVE.with(|c| c.borrow_mut().take()) {
*self.target = a.session;
}
}
}
/// Run a script against `session`: the object model sees and edits that session through engine
/// commands. Script errors come back in [`Outcome::error`].
pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome {
let running = ACTIVE.with(|a| a.try_borrow().map(|g| g.is_some()).unwrap_or(true));
if running {
return Outcome {
error: Some(ScriptError { message: "scripts can't run scripts".into(), file: req.name.into(), ..Default::default() }),
..Default::default()
};
}
let s = std::mem::take(session);
ACTIVE.with(|a| *a.borrow_mut() = Some(Active { session: s, output: vec![], groups: vec![], tasks: vec![], next_task: 0, name: req.name.to_string() }));
let guard = Restore { target: session };
let file = if req.name.is_empty() { "script" } else { req.name };
let mut result = if req.console {
CONSOLE.with(|c| {
let mut c = c.borrow_mut();
if c.is_none() {
match new_context() {
Ok(ctx) => *c = Some(ManuallyDrop::new(ctx)),
Err(e) => return Err(ScriptError { message: e, file: file.into(), ..Default::default() }),
}
}
let ctx = c.as_mut().map(|m| &mut **m).expect("console context");
eval_in(ctx, req.code, file)
})
} else {
match new_context() {
Ok(mut ctx) => {
let r = eval_in(&mut ctx, req.code, file);
// app.scheduleTask: run the queued one-shot tasks after the script, in order.
let tasks = with_active(|a| Ok(std::mem::take(&mut a.tasks))).unwrap_or_default();
let mut r = r;
for (_, code, repeat) in tasks {
if repeat {
continue;
}
if let Err(e) = eval_in(&mut ctx, &code, "scheduled task")
&& r.is_ok()
{
r = Err(e);
}
}
r
}
Err(e) => Err(ScriptError { message: e, file: file.into(), ..Default::default() }),
}
};
// Close undo groups left open; drop selections that no longer exist.
let output = with_active(|a| {
if let Some((name, snap)) = a.groups.first().cloned() {
collapse(&mut a.session, &name, &snap);
a.groups.clear();
}
a.session.sanitize_state();
Ok(std::mem::take(&mut a.output))
})
.unwrap_or_default();
drop(guard);
let mut out = Outcome { output, ..Default::default() };
match result.as_mut() {
Ok(v) => out.result = std::mem::take(v),
Err(e) => out.error = Some(e.clone()),
}
out
}
+392
View File
@@ -0,0 +1,392 @@
//! End-to-end scripts against a headless session: build with the object model, read back through
//! the engine.
use std::sync::Arc;
use effectcraft_engine::Session;
use effectcraft_engine::keyframe::Value as KV;
use effectcraft_engine::project::{Comp, ItemId, Layer};
use serde_json::{Value, json};
use crate::{Outcome, run_code};
fn session() -> Session {
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), expr_check: Some(effectcraft_expr::check_syntax), ..Default::default() };
crate::install(&mut s);
s
}
fn ok(s: &mut Session, code: &str) -> Outcome {
let o = run_code(s, code, "test.jsx");
assert!(o.error.is_none(), "script failed: {:?}\noutput: {:?}", o.error, o.output);
o
}
fn comp_named<'a>(s: &'a Session, name: &str) -> (ItemId, &'a Comp) {
let it = s.project.items.values().find(|i| i.name == name && i.as_comp().is_some()).unwrap_or_else(|| panic!("no comp {name}"));
(it.id, it.as_comp().unwrap())
}
fn layer<'a>(c: &'a Comp, name: &str) -> &'a Layer {
c.layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("no layer {name}"))
}
#[test]
fn builds_a_comp_end_to_end() {
let mut s = session();
let o = ok(
&mut s,
r#"
app.beginUndoGroup("Build");
var comp = app.project.items.addComp("Main", 640, 360, 1, 4, 25);
var bg = comp.layers.addSolid([0.2, 0.4, 0.6], "BG", 640, 360, 1);
var txt = comp.layers.addText("Hello");
txt.name = "Title";
var pos = txt.property("ADBE Transform Group").property("ADBE Position");
pos.setValueAtTime(0, [100, 180]);
pos.setValueAtTime(2, [540, 180]);
txt.transform.opacity.expression = "50 + 25";
var blur = bg.Effects.addProperty("ADBE Gaussian Blur 2");
blur.property("Blurriness").setValue(12);
var nul = comp.layers.addNull();
txt.setParentWithJump(nul);
bg.blendingMode = BlendingMode.MULTIPLY;
var rq = app.project.renderQueue.items.add(comp);
rq.outputModule(1).file = new File("/tmp/effectcraft-script-test/out.mp4");
app.endUndoGroup();
writeLn("layers: " + comp.numLayers);
[comp.numLayers, pos.numKeys, pos.keyValue(2)[0], txt.transform.opacity.value, comp.layer(1).name]
"#,
);
assert_eq!(o.output, vec!["layers: 3"]);
assert_eq!(o.result, json!([3, 2, 540, 75, "Null 1"]));
let (cid, c) = comp_named(&s, "Main");
assert_eq!((c.width, c.height), (640, 360));
assert_eq!(c.duration.seconds(), 4.0);
// AE adds new layers at the top.
let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["Null 1", "Title", "BG"]);
let title = layer(c, "Title");
let p = title.props.prop("transform/position").unwrap();
assert_eq!(p.keys.len(), 2);
assert_eq!(p.keys[1].value, KV::Vec3([540.0, 180.0, 0.0]));
assert_eq!(title.props.prop("transform/opacity").unwrap().expr.as_ref().unwrap().text, "50 + 25");
assert_eq!(title.parent, Some(layer(c, "Null 1").id));
let bg = layer(c, "BG");
assert_eq!(bg.blend_mode, effectcraft_engine::color::BlendMode::Multiply);
let fx = bg.props.group("effects/#1").unwrap();
assert_eq!(fx.match_id, "ec.blur.gaussian");
assert_eq!(bg.props.prop("effects/#1/blurriness").unwrap().value, KV::Scalar(12.0));
assert_eq!(s.project.render_queue.len(), 1);
assert_eq!(s.project.render_queue[0].comp, cid);
// One undo step for the whole group.
assert_eq!(s.history.undo.len(), 1);
assert_eq!(s.history.undo[0].0, "Build");
assert!(s.undo());
assert!(s.project.items.is_empty());
}
#[test]
fn undo_steps_without_a_group() {
let mut s = session();
ok(&mut s, r#"var c = app.project.items.addComp("A", 100, 100, 1, 1, 24); c.layers.addNull(); c.layers.addNull();"#);
assert_eq!(s.history.undo.len(), 3);
// An unbalanced group is closed when the script ends.
ok(&mut s, r#"app.beginUndoGroup("Two"); var c = app.project.item(1); c.layers.addNull(); c.layers.addNull();"#);
assert_eq!(s.history.undo.len(), 4);
assert_eq!(s.history.undo[3].0, "Two");
// Nested groups fold into the outermost one.
ok(
&mut s,
r#"app.beginUndoGroup("Outer"); app.beginUndoGroup("Inner"); app.project.item(1).layers.addNull(); app.endUndoGroup(); app.project.item(1).layers.addNull(); app.endUndoGroup();"#,
);
assert_eq!(s.history.undo.len(), 5);
assert_eq!(s.history.undo[4].0, "Outer");
s.undo();
assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 4);
}
#[test]
fn errors_report_line_and_column() {
let mut s = session();
let o = run_code(&mut s, "var a = 1;\nvar b = 2;\nfoo.bar();\n", "broken.jsx");
let e = o.error.expect("error");
assert_eq!(e.line, Some(3), "{e:?}");
assert!(e.message.contains("foo"), "{e:?}");
assert_eq!(e.file, "broken.jsx");
let o = run_code(&mut s, "var a = 1;\n\nvar = ;\n", "syntax.jsx");
let e = o.error.expect("syntax error");
assert_eq!(e.line, Some(3), "{e:?}");
let o = run_code(&mut s, "writeLn('before');\nthrow new Error('custom failure');\n", "throw.jsx");
let e = o.error.expect("thrown");
assert_eq!(e.message, "custom failure");
assert_eq!(e.line, Some(2), "{e:?}");
assert_eq!(o.output, vec!["before"]);
// Errors from the object model point at the user's line.
let o = run_code(&mut s, "var c = app.project.items.addComp('C', 10, 10, 1, 1, 24);\n\nc.layer(5);\n", "range.jsx");
let e = o.error.expect("range error");
assert!(e.message.contains("out of range"), "{e:?}");
assert_eq!(e.line, Some(3), "{e:?}");
}
#[test]
fn match_names_and_lookups() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("M", 200, 200, 1, 2, 30);
var l = c.layers.addSolid([1, 1, 1], "S", 200, 200, 1);
var tr = l.property("ADBE Transform Group");
var fx = l.property("ADBE Effect Parade").addProperty("Gaussian Blur");
var m = l.property("ADBE Mask Parade").addProperty("ADBE Mask Atom");
var sh = c.layers.addShape();
var grp = sh.property("ADBE Root Vectors Group").addProperty("ADBE Vector Group");
var rect = grp.property("ADBE Vectors Group").addProperty("ADBE Vector Shape - Rect");
var fill = grp.property("Contents").addProperty("ADBE Vector Graphic - Fill");
fill.property("ADBE Vector Fill Color").setValue([1, 0, 0]);
rect.property("ADBE Vector Rect Size").setValue([50, 60]);
[
l.matchName, tr.matchName, tr.property("ADBE Position").matchName, tr.property("Anchor Point").matchName,
tr.property(1).name, tr.anchorPoint.matchName, l.transform.position.propertyValueType == PropertyValueType.ThreeD_SPATIAL,
fx.matchName, fx.property(1).matchName, fx.property("ADBE Gaussian Blur 2-0001").name, m.matchName,
m.property("ADBE Mask Shape").name, sh.matchName, grp.matchName, rect.matchName, fill.matchName,
tr.propertyType == PropertyType.NAMED_GROUP, l.Effects.propertyType == PropertyType.INDEXED_GROUP,
tr.position.parentProperty.matchName, tr.position.propertyDepth, l.property("Nope") === null,
fx.isEffect, m.isMask, l.Effects.numProperties
]
"#,
);
assert_eq!(
o.result,
json!([
"ADBE AV Layer",
"ADBE Transform Group",
"ADBE Position",
"ADBE Anchor Point",
"Anchor Point",
"ADBE Anchor Point",
true,
"ADBE Gaussian Blur 2",
"ADBE Gaussian Blur 2-0001",
"Blurriness",
"ADBE Mask Atom",
"Mask Path",
"ADBE Vector Layer",
"ADBE Vector Group",
"ADBE Vector Shape - Rect",
"ADBE Vector Graphic - Fill",
true,
true,
"ADBE Transform Group",
2,
true,
true,
true,
1
])
);
let (_, c) = comp_named(&s, "M");
let sh = c.layers.iter().find(|l| l.props.sub("contents").is_some()).unwrap();
assert_eq!(sh.props.prop("contents/#1/contents/rect/size").unwrap().value, KV::Vec2([50.0, 60.0]));
assert_eq!(sh.props.prop("contents/#1/contents/fill/color").unwrap().value, KV::Color([1.0, 0.0, 0.0, 1.0]));
}
#[test]
fn keyframes_eases_and_interpolation() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("K", 100, 100, 1, 5, 25);
var l = c.layers.addNull();
var r = l.transform.rotation;
r.setValuesAtTimes([0, 1, 2], [0, 90, 180]);
r.setInterpolationTypeAtKey(2, KeyframeInterpolationType.HOLD, KeyframeInterpolationType.LINEAR);
r.setTemporalEaseAtKey(3, [new KeyframeEase(0, 75)]);
var idx = r.addKey(1.5);
r.removeKey(1);
var out = [r.numKeys, idx, r.keyTime(1), r.keyInInterpolationType(1) == KeyframeInterpolationType.HOLD,
r.keyOutInterpolationType(1) == KeyframeInterpolationType.LINEAR, r.keyInTemporalEase(3)[0].influence,
r.valueAtTime(1, false), r.isTimeVarying, r.nearestKeyIndex(1.9)];
l.startTime = 1;
out.push(r.keyTime(1));
out
"#,
);
let v = o.result.as_array().unwrap();
assert_eq!(v[0], json!(3));
assert_eq!(v[1], json!(3));
assert_eq!(v[2], json!(1));
assert_eq!(v[3], json!(true));
assert_eq!(v[4], json!(true));
assert!((v[5].as_f64().unwrap() - 75.0).abs() < 1e-6, "{v:?}");
assert_eq!(v[6], json!(90));
assert_eq!(v[7], json!(true));
assert_eq!(v[8], json!(3));
// Moving the layer moves its keys (layer time): key 1 now sits at 2 s comp time.
assert_eq!(v[9], json!(2));
}
#[test]
fn text_documents() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("T", 400, 200, 1, 2, 30);
var t = c.layers.addText("abc");
var st = t.property("ADBE Text Properties").property("ADBE Text Document");
var doc = st.value;
doc.text = "Changed";
doc.fontSize = 40;
doc.fillColor = [1, 0, 0];
doc.justification = ParagraphJustification.CENTER_JUSTIFY;
doc.applyStroke = true;
doc.strokeWidth = 3;
st.setValue(doc);
var d2 = t.text.sourceText.value;
[d2.text, d2.fontSize, d2.fillColor, d2.justification == ParagraphJustification.CENTER_JUSTIFY, d2.strokeWidth, t instanceof TextLayer, t.name]
"#,
);
assert_eq!(o.result, json!(["Changed", 40, [1, 0, 0], true, 3, true, "abc"]));
let (_, c) = comp_named(&s, "T");
let KV::Text(d) = &c.layers[0].props.prop("text/sourceText").unwrap().value else { panic!() };
assert_eq!(d.text, "Changed");
assert_eq!(d.style_at(0).size, 40.0);
}
#[test]
fn layers_items_and_markers() {
let mut s = session();
let o = ok(
&mut s,
r#"
var f = app.project.items.addFolder("Comps");
var c = f.items.addComp("Inner", 100, 100, 1, 3, 30);
var main = app.project.items.addComp("Outer", 200, 200, 1, 6, 30);
var nested = main.layers.add(c);
var a = main.layers.addSolid([1, 0, 0], "A", 50, 50, 1, 2);
var b = main.layers.addSolid([0, 1, 0], "B", 50, 50, 1);
b.moveAfter(nested);
a.enabled = false; a.solo = true; a.locked = true; a.shy = true;
a.threeDLayer = true;
var cam = main.layers.addCamera("Cam", [100, 100]);
var lt = main.layers.addLight("Key", [100, 100]);
lt.lightType = LightType.SPOT;
var d = a.duplicate();
d.name = "A copy";
var mv = new MarkerValue("hello");
mv.duration = 0.5;
main.markerProperty.setValueAtTime(1, mv);
b.marker.setValueAtTime(0.5, new MarkerValue("layer marker"));
b.inPoint = 1; b.outPoint = 3;
var pc = main.layers.precompose([b.index], "Pre", true);
[c.parentFolder.name, nested.source.name, a.outPoint, a.enabled, a.solo, a.locked, a.shy, a.threeDLayer,
cam instanceof CameraLayer, lt.lightType == LightType.SPOT, main.markerProperty.numKeys, main.markerProperty.keyValue(1).comment,
main.markerProperty.keyValue(1).duration, pc.name, pc instanceof CompItem, main.layer("A copy").index, app.project.numItems,
main.layers.byName("Pre") !== null, f.numItems]
"#,
);
assert_eq!(o.result, json!(["Comps", "Inner", 2, false, true, true, true, true, true, true, 1, "hello", 0.5, "Pre", true, 3, 7, true, 1]));
let (_, c) = comp_named(&s, "Outer");
assert_eq!(c.markers.len(), 1);
assert!(
c.layers
.iter()
.any(|l| l.name == "Key"
&& matches!(l.source, effectcraft_engine::project::LayerSource::Light { kind: effectcraft_engine::project::LightKind::Spot }))
);
}
#[test]
fn security_gate_for_files() {
let dir = std::env::temp_dir().join(format!("ec-script-gate-{}", std::process::id()));
let proj = dir.join("proj");
std::fs::create_dir_all(&proj).unwrap();
std::fs::write(proj.join("data.txt"), "inside").unwrap();
std::fs::write(dir.join("secret.txt"), "outside").unwrap();
let mut s = session();
s.path = Some(proj.join("p.ecproj").to_string_lossy().to_string());
let q = |p: &std::path::Path| serde_json::to_string(&p.to_string_lossy()).unwrap();
// Reading inside the project folder works.
let o = ok(&mut s, &format!("var f = new File({}); f.open('r'); var t = f.read(); f.close(); t", q(&proj.join("data.txt"))));
assert_eq!(o.result, json!("inside"));
// Outside it (also via `..`) is refused.
for path in [dir.join("secret.txt"), proj.join("../secret.txt")] {
let o = run_code(&mut s, &format!("var f = new File({}); f.open('r');", q(&path)), "gate.jsx");
let e = o.error.expect("blocked");
assert!(e.message.contains("Allow Scripts to Write Files"), "{e:?}");
}
// Writing is refused anywhere, the network too.
let o = run_code(&mut s, &format!("var f = new File({}); f.open('w'); f.write('x'); f.close();", q(&proj.join("out.txt"))), "gate.jsx");
assert!(o.error.is_some());
assert!(!proj.join("out.txt").exists());
assert!(run_code(&mut s, "new Socket()", "net.jsx").error.is_some());
assert!(run_code(&mut s, "system.callSystem('ls')", "sys.jsx").error.is_some());
// With the preference on, scripts can write and read anywhere.
s.prefs.scripting.allow_scripts_write_files = true;
ok(&mut s, &format!("var f = new File({}); f.open('w'); f.writeln('written'); f.close();", q(&dir.join("w.txt"))));
assert_eq!(std::fs::read_to_string(dir.join("w.txt")).unwrap(), "written\n");
let o = ok(&mut s, &format!("var f = new File({}); f.open('r'); f.readln()", q(&dir.join("secret.txt"))));
assert_eq!(o.result, json!("outside"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn engine_entry_points() {
let mut s = session();
// script.run reports errors in the result, not as a command failure.
let r = s.execute("script.run", json!({"code": "writeLn('hi'); 1 + 2"})).unwrap();
assert_eq!(r["ok"], json!(true));
assert_eq!(r["result"], json!(3));
assert_eq!(r["output"], json!("hi"));
let r = s.execute("script.run", json!({"code": "\nnope()"})).unwrap();
assert_eq!(r["ok"], json!(false));
assert_eq!(r["error"]["line"], json!(2));
// Scripts can't run scripts.
let r = s.execute("script.run", json!({"code": "app.run('script.run', {code: '1'})"})).unwrap();
assert!(r["error"]["message"].as_str().unwrap().contains("scripts can't run scripts"), "{r}");
// File ▸ Scripts ▸ Run Script File… runs .jsx as JavaScript.
let dir = std::env::temp_dir().join(format!("ec-script-file-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("make.jsx");
std::fs::write(&f, "app.project.items.addComp('From File', 320, 240, 1, 2, 24);").unwrap();
s.execute("file.runScript", json!({"path": f.to_string_lossy()})).unwrap();
assert!(s.project.items.values().any(|i| i.name == "From File"));
std::fs::write(&f, "\n\nbroken(;").unwrap();
let e = s.execute("file.runScript", json!({"path": f.to_string_lossy()})).unwrap_err().to_string();
assert!(e.contains("make.jsx (line 3)"), "{e}");
// JSON command scripts still work.
let j = dir.join("steps.json");
std::fs::write(&j, r#"[{"command": "comp.new", "params": {"name": "From Steps"}}]"#).unwrap();
s.execute("file.runScript", json!({"path": j.to_string_lossy()})).unwrap();
assert!(s.project.items.values().any(|i| i.name == "From Steps"));
// Menu commands by name.
let o = ok(&mut s, "app.findMenuCommandId('New Project') > 0 && app.findMenuCommandId('No Such Thing') === 0");
assert_eq!(o.result, json!(true));
let o = ok(&mut s, "app.executeCommand(app.findMenuCommandId('New Project')); app.project.numItems");
assert_eq!(o.result, json!(0));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn console_keeps_variables() {
let mut s = session();
let req = |code: &'static str| effectcraft_engine::ScriptRequest { code, name: "console", console: true };
let r = crate::run(&mut s, &req("var counter = 41;"));
assert!(r.error.is_none(), "{r:?}");
let r = crate::run(&mut s, &req("counter + 1"));
assert_eq!(r.result, json!(42));
let _ = Value::Null;
}
#[test]
fn schedule_task_runs_after_the_script() {
let mut s = session();
let o = ok(
&mut s,
"app.scheduleTask('writeLn(\"later\")', 100, false); var id = app.scheduleTask('writeLn(\"never\")', 10, false); app.cancelTask(id); writeLn('now');",
);
assert_eq!(o.output, vec!["now", "later"]);
}