The desktop app opened the demo project whenever it wasn't given a project file, so File ▸ New Composition on the Home screen added "Comp 4" to the three demo comps. As in After Effects, a launch now starts with an empty Untitled Project. --demo opens the demo (Help ▸ Open Demo Project and the Home screen tile still do); --empty is kept. The arguments are parsed by a function now, with a test (no_arguments_start_an_empty_project). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
36 KiB
Driving EffectCraft from agents
Everything in EffectCraft is an engine command with a stable id and JSON params. Menus, shortcuts, panel gestures, the CLI, the control channel and MCP all go through the same commands, so anything a user can do, an agent can do too. There are three ways in:
| Interface | Best for | Needs a window |
|---|---|---|
MCP (effectcraft-cli mcp) |
Claude Code and other MCP clients | no (headless), or yes with --bridge |
CLI (effectcraft-cli exec/get/set/...) |
one-shot scripting and CI; JSON output with --json |
no |
Control channel (effectcraft --control 9877) |
driving and seeing the live UI; see control-protocol.md | yes |
MCP setup
Build once with cargo build --release -p effectcraft-cli, then register the server. For Claude
Code, add a .mcp.json at the project root:
{
"mcpServers": {
"effectcraft": {
"command": "/path/to/effectcraft/target/release/effectcraft-cli",
"args": ["mcp"]
}
}
}
This repository ships a ready-made .mcp.json: effectcraft (headless, demo
project loaded) and effectcraft-app (bridged to a desktop app started with --control 9877). Both
run through cargo run --release, so the first start compiles; run
cargo build --release -p effectcraft-cli once beforehand to avoid an MCP startup timeout.
You can also register it from the command line:
claude mcp add effectcraft -- /path/to/target/release/effectcraft-cli mcp. Other clients (Claude
Desktop, Cursor and the like) take the same command and args.
- Headless (
["mcp"]): an in-process session with no window. Add"--demo"or"--project", "file.ecproj"to start with content. Startup is instant. - Bridge (
["mcp", "--bridge", "9877"]): drives a runningeffectcraft --control 9877, so you see every change live. Bridge mode addsscreenshotand theui_*tools.
The server speaks JSON-RPC 2.0 over stdio, one message per line, and supports MCP protocol versions
2025-06-18, 2025-03-26 and 2024-11-05 (initialize, ping, tools/list, tools/call).
Tools
| Tool | What it does |
|---|---|
list_commands {filter?, enabled_only?, schemas?} |
Discover command ids, their param docs (and, with schemas, a JSON Schema each), and whether each can run now. |
describe_command {command} |
One command in full: label, menu, shortcut, params doc, JSON schema, enabled / why. |
execute_command {command, params?} |
Run any command (undoable). Unknown parameter keys are rejected with the accepted list. |
get_state |
Editor state (active comp, time, selections, tool) plus app: version, command and effect counts, export formats, parity summary. |
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?, depth?, flat?} |
A layer's property tree (depth limits how many group levels expand). Every node has a path. |
get_property {layer, path, comp?, time?} |
Value at a time, keyframes and expression. |
set_property {layer, path, value?, time?, expression?, comp?} |
Sets a static value. With time it sets a keyframe; with expression it sets an expression. |
add_keyframe {layer, path, time+value | keys:[...], interpolation?, comp?} |
Adds keys, then optionally applies linear/bezier/hold/easyEase. |
list_effects {filter?} |
Effect ids, names, categories, GPU / 32-bpc support and parameters. |
list_fonts {query?, rescan?} |
Font families text layers can use, bundled and installed, with their styles, origin and own-language name; rescan picks up fonts installed since launch. |
add_effect {layer, effect, values?, comp?} |
Apply an effect and set its parameters in one call and one undo step (a failing value leaves nothing applied); returns the instance path (effects/#n) and its parameter paths. |
render_frame {comp?, time?, max_side?, path?, inline?, transparent?} |
Returns a PNG image of a frame; transparent: true keeps the alpha (as an RGB + Alpha render writes it) instead of compositing over the comp background. |
open_project {path | demo | new} / save_project {path?} |
Open and save files. |
undo {steps?} / redo {steps?} |
History. |
history {goto?} |
The branching undo history (History panel): every state with its index, id, label, branch depth and whether it's current; goto (an index or id) jumps to any state, on any branch, without losing the others. |
script_ui {action?, window?, widget?, value?, result?} |
Drive ScriptUI windows that scripts opened: list (default) the open windows, get a window's control tree, click a button / checkbox / radio button, set text, a slider or a list item, close a window (result: what a dialog's show() returns, default 2 = Cancel). |
batch {steps: [{command, params}], label?, atomic?} |
Several commands in one call and one undo step (engine.batch); "$N.key" in a param is step N's result ("$1.layer"). A failing step rolls the batch back and names the step. |
(bridge) screenshot {panel?, id?, max_side?, path?}, ui_inspect, ui_elements {prefix?}, ui_click, ui_drag, ui_key, ui_type, ui_set, control {method, params} |
Look at and operate the live window. |
Layers are referenced by id (from get_comp), "#n" (1-based index from the top) or name. Comps are
referenced by id or name, and default to the active comp. Property paths come from get_layer, for
example transform/position, transform/opacity, effects/#1/blurriness or @57 (by uid). Times
are in seconds. Keyframe times are layer time, which equals comp time unless the layer is offset or
stretched.
Command params are checked against each command's params doc. An unknown key, such as
layer.select {"index": 2}, returns an error that lists the accepted keys (here layers, add, toggle)
instead of being ignored.
A typical session
execute_command {"command":"comp.new","params":{"name":"Intro","width":1920,"height":1080,"frameRate":30,"duration":4}}execute_command {"command":"layer.newText","params":{"text":"Hello","size":160,"fill":"#ffffff"}}returns{"layer": 2}.add_keyframe {"layer":2,"path":"transform/position","keys":[{"time":0,"value":[-300,540]},{"time":1.5,"value":[960,540]}],"interpolation":"easyEase"}execute_command {"command":"effect.apply","params":{"layer":2,"effect":"Gaussian Blur"}}, thenset_property {"layer":2,"path":"effects/#1/blurriness","value":8}render_frame {"time":1.0}lets you look at the result, andsave_project {"path":"intro.ecproj"}saves it.
Cookbook
Short recipes; every step is one tool call.
| Goal | Calls |
|---|---|
| Orient yourself | get_state → get_project → get_comp {} |
| Find the right command | list_commands {"filter":"mask"} → describe_command {"command":"mask.new"} (params + JSON Schema) |
| Title card | execute_command comp.new {...} → execute_command layer.newText {"text":"Hi","size":120} → render_frame {"time":0} |
| Animate | add_keyframe {"layer":2,"path":"transform/scale","keys":[{"time":0,"value":[0,0]},{"time":0.5,"value":[100,100]}],"interpolation":"easyEase"} |
| Effect with settings | list_effects {"filter":"glow"} → add_effect {"layer":2,"effect":"Glow","values":{"threshold":40,"radius":30}} → set_property {"layer":2,"path":"effects/#1/intensity","value":2} |
| Key a green screen | add_effect {"layer":2,"effect":"Keylight (1.2)"} → execute_command effect.pickColor {"layer":2,"effect":1,"param":"screenColour","x":20,"y":20,"average":true} (a screen pixel, layer space; samples the effect's input) → set_property {"layer":2,"path":"effects/#1/screenMatte/clipBlack","value":10} → render_frame {"transparent":true} |
| Drive with an expression | set_property {"layer":2,"path":"transform/rotation","expression":"time*90"} |
| Inspect a layer | get_layer {"layer":2,"flat":true} (every property with its path, value, key count) |
| Check the result | render_frame {"time":1.5,"max_side":640}; in bridge mode screenshot {"panel":"Timeline"} |
| Undo a mistake | undo {}, or history {} then history {"goto": n} |
| Missing media after opening | execute_command footage.check {"wait":true} → {checked, missing, probed}; then file.findMissing {"what":"footage"} |
| Save | save_project {"path":"out.ecproj"} |
The desktop app checks footage in the background after every open (a "Checking footage" job in
the Progress panel, jobs.list); headless sessions run footage.check when they want it.
Cookbook (from end-to-end QA)
The scenarios in crates/automation/tests/qa/ and apps/effectcraft-cli/tests/qa_template.rs
build real projects through these interfaces; they are worked examples of everything below.
- Name things when you create them:
layer.newText,layer.newShape,layer.newSolid,layer.newNull,layer.newCameraandlayer.newLighttakename, so later calls can say"layer": "Title". A reference that matches no layer is an error, never a silent no-op. - Nested comps:
comp.settings {preserveFrameRate, preserveResolution}(Composition Settings ▸ Advanced);comp.info/get_compreport both. A precomp layer shows nothing outside its nested comp's span. - Several footage items at once:
file.newCompFromSelection {single, dimensionsFrom, duration, sequence, overlap, overlapDuration, transition, addToRenderQueue}is one undo step (select the items first:project.select). - Locked layers are left alone:
edit.clear,layer.arrange,layer.transformandlayer.centerAnchorskip them and fail when every target is locked ("… is locked"); Select All andlayer.selectNext/selectPreviousstep over them. Unlock withlayer.setSwitch {"layers": [...], "switch": "lock", "value": false}orlayer.unlockAll. - Solid Settings:
layer.settings {layer, name?, color?, width?, height?, pixelAspect?, affectAll?}edits a solid or adjustment layer's solid (and renames the layer and the solid). A solid shared by several layers (duplicates share theirs) changes for all of them unlessaffectAll: false, which gives this layer its own copy; the reply names it ({layer, solid}). Cameras and lights take their own settings through the same command. - Use the paths replies give you:
effect.applyreturns{effects: [uid], paths: ["effects/#2"]};layer.addShapeItem {kind, group?: uid|path}returns{uid, path}(e.g.contents/group/contents/gfill), ready forset_property.@uidworks as a whole path. - One call, one undo step:
batch {"steps":[{"command":"layer.newShape","params":{"kind":"ellipse","name":"Ring"}},{"command":"layer.addShapeItem","params":{"layer":"$1.layer","kind":"trim"}},{"command":"prop.addKey","params":{"layer":"$1.layer","path":"transform/opacity","time":0,"value":0}}]}(a string param that is exactly"$N"or"$N.key"is replaced;$2.pathis the trim's path). - Expressions:
set_property/get_propertyreplies carryevaluated(what renders) next tovalue(the keyframed value) andexpressionErrorwhen the expression fails. An expression with a syntax error is kept but disabled, so it rendersvalueand reports the syntax error. - Keyframes like a person:
keys.select {keys, toggle: true}(Shift+click),keys.selectEqual/selectPrevious/selectFollowing,keys.move {delta, merge}(steps sharing a merge key are one drag: each applies to the keys as they were before it, so a key passed over survives) andkeys.transform {…, merge, fromStart: true}(values are the whole transform since the drag started). Keys of locked layers can't be selected. Copy / paste:keys.copy, thenkeys.pasteat the current time (keys copied from several layers go to as many selected layers in order).time.nextKey/time.previousKey/keys.selectAlltakevisible: [{layer, prop}]to act only on those properties (the Timeline passes its revealed ones, as J / K and Ctrl+Alt+A do). - Easing with Keyframe Velocity: select keys by path,
keys.select {"keys":[{"layer":"Ring","path":"contents/trim/end","time":0}]}, thenkeys.velocity {"outSpeed":0,"outInfluence":33.33}. - Gradients:
set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}or{"colors":[[0,"#0080ff"],[1,[1,1,0]]],"opacities":[[0,1],[1,0.5]]}. - Image sequences import at 30 fps (Settings ▸ Import);
file.interpretFootage {"items":[id], "frameRate":12}conforms them (the item and layers that ran to its end get the new length). - Masks:
layer.addMask {layer, shape: rect|ellipse, rect}first, thenlayer.mask.set {field: feather|expansion|opacity, value}. - Renders:
renderQueue.add {comp, format: h264|prores|webm|png…, channels: rgba, proresProfile: 4444, output}thenrenderQueue.render {"wait": true}. Check a render by importing it (file.import,file.newCompFromSelection) andrender_frame {transparent: true}for its alpha (ProRes 4444 and WebM VP9 alpha both read back as straight alpha). - Essential Graphics: controls can be addressed by name (
essential.set {"layer":"#1", "control":"Title","value":"John Smith"}); a command that targets an explicitcompruns even when the active comp would disable it (essential.exportTemplate {"comp":"Lower Third", …}). - History: Levels of Undo defaults to 32 (
prefs.set {"key":"general.undoLevels","value":99});historylists branches andhistory {"goto": id}jumps between them. A command that changes nothing (a value set to what it already is) records no undo step.get_statereportsdirty(unsaved changes); it follows undo, so undoing back to the saved state clears it. - Unsaved changes: commands run through MCP /
engine.executenever ask to save. In the desktop app,ui.menu.invokeof a command that closes a modified project (file.open,file.newProject,file.closeProject,file.revert…) and the window's close button show the Save / Don't Save / Cancel prompt (ui_clickondialog.unsaved.save/dontSave/cancel;dialog.unsaved.revertfor Revert); the control channel'sapp.quit {"force": true}skips it.
Motion tracking
Trackers live on the tracked layer (Motion Trackers ▸ Tracker n ▸ Track Point n) and are driven
with track.* commands. track.analyze runs in the background in the app (poll track.status);
pass "wait": true to block until it finishes, which is what the CLI and headless MCP want.
execute_command {"command":"track.motion","params":{"layer":"clip.mov"}}creates a Transform tracker (Motion Target: the layer above). Usetrack.stabilizefor Stabilize, ortrack.newwithkindtransform|stabilize|affine|perspective|rawandrotation/scale.execute_command {"command":"track.setPoint","params":{"point":1,"center":[812,440],"featureSize":[40,40],"searchSize":[96,96]}}execute_command {"command":"track.analyze","params":{"direction":"forward","wait":true}}keys Feature Center, Confidence and Attach Point on every frame from the current time to the layer's end.execute_command {"command":"track.apply","params":{"dimensions":"xy"}}keys the target's Position (and Rotation/Scale), the layer's own Anchor Point and Position for Stabilize, or a Corner Pin effect foraffine/perspectivetracks.track.optionssets the channel, blur/enhance, adapt-feature and "If Confidence is Below" behaviour;track.statusreports the track points.
effectcraft-cli run clip.ecproj track.motion '{"layer":"#2"}' \
track.setPoint '{"point":1,"center":[812,440]}' \
track.analyze '{"wait":true}' track.apply '{}' --save
Mask tracking and Mask Interpolation
track.mask follows the pixels inside a mask and keys its Mask Path on every frame (vertices and
tangents move with the fitted motion). method is position, positionScale,
positionScaleRotation (the default, also kept as the Tracker panel's Method), positionScaleRotationSkew,
perspective, faceOutline (Face Tracking (Outline Only): the mask, drawn around a face, becomes
the face outline on every frame) or faceDetailed (Face Tracking (Detailed Features): also keys a
Face Track Points effect with one point per landmark — eyebrows, eyes, pupils, nose tip,
nostrils, mouth corners and lips, chin, jaw); direction is forward|backward|frameForward|frameBackward from the current
time (or start / end in seconds). Like track.analyze it runs in the background in the app
(progress in track.status under mask, track.stop cancels and keeps what was tracked) and is
one undo step; "wait": true blocks. track.extractFaceMeasurements {layer?} (Extract & Copy Face
Measurements) keys a Face Measurements effect from the Face Track Points (head position, scale and
orientation X/Y/Z, eye openness, eyebrow distance from eye, mouth openness, width and offset), puts
those keys on the keyframe clipboard and returns them per frame plus a tab-separated clipboard
text.
Face tracking's trained model works like Roto Brush's (below): face.models lists the built-in
tracker and MediaPipe Face Landmarker with its authors and licence; face.model.download {id} /
face.model.install {path, id?} install it (verified); face.model.select {id: classical|mediapipe-face} chooses it; face.model.remove {id} deletes it. Downloads and loading
run in the background; "wait": true on download, install or select returns once the model is in
use (or with its error), which scripts need before tracking. track.mask with a
face method returns faceModel, the engine it tries first (the classical one when the model finds
no face in the mask).
effectcraft-cli run clip.ecproj face.model.download '{"id":"mediapipe-face","wait":true}' face.model.select '{"id":"mediapipe-face","wait":true}' \
track.mask '{"layer":"#1","mask":1,"method":"faceDetailed","direction":"forward","wait":true}' --save
mask.interpolate (Window ▸ Mask Interpolation ▸ Apply) adds in-between Mask Path keys between
each pair of selected Mask Path keys (or the existing keys at times, in seconds), giving both
ends the same vertex count with a matched correspondence. Options: keyframeRate (number or
"auto" = the comp rate), keyframeFields, linearVertexPaths, bendingResistance, quality,
addVertices (number or false) with addVerticesUnit pixels|total|percent, matchingMethod
auto|curve|polyline, oneToOne, firstVerticesMatch. mask.interpolationOptions sets the
panel's defaults.
effectcraft-cli exec track.mask '{"layer":"#2","mask":1,"method":"perspective","wait":true}' clip.ecproj --save
effectcraft-cli exec mask.interpolate '{"layer":"#2","mask":"Mask 1","times":[0,2],"addVertices":10}' clip.ecproj --save --json
Warp Stabilizer
Effect ▸ Distort ▸ Warp Stabilizer (effect.apply {"effect":"Warp Stabilizer"}), Animation ▸ Warp
Stabilizer VFX and the Tracker panel's button (track.warpStabilizer, which applies the effect and
starts analysing) stabilize a layer. warp.analyze {layer?, effect?, wait?} analyses every frame
between the layer's In and Out points in the background ("Analyzing in background (step 1 of 2)",
then "Stabilizing..."; warp.status reports progress, banner, whether the effect is
analyzed, and the current frame's warp matrix, autoScale and crop); warp.cancel stops it
without writing anything. The analysis is stored in the effect (saved with the project) and is
one undo step; trimming, slipping, time-remapping or replacing the layer's source clears it and
the app re-analyses automatically (headless: run warp.analyze again). Settings are ordinary
properties under the instance's groups, e.g. effects/#1/stabilization/result (0 Smooth Motion,
1 No Motion), effects/#1/stabilization/smoothness, effects/#1/stabilization/method,
effects/#1/borders/framing (0 Stabilize Only … 3 Stabilize, Synthesize Edges),
effects/#1/borders/autoScale/maximumScale, effects/#1/advanced/showTrackPoints. Method 3
(Subspace Warp, the default) bends a mesh per frame to the smoothed feature trajectories, so
scenes with depth stabilise where one perspective transform cannot;
effects/#1/advanced/rollingShutterRipple (0 Automatic Reduction, 1 Enhanced Reduction: a finer,
softer mesh) tunes it.
effectcraft-cli run shaky.ecproj track.warpStabilizer '{"layer":"#1","wait":true}' \
prop.set '{"layer":"#1","path":"effects/#1/stabilization/result","value":1}' --save
effectcraft-cli exec warp.analyze '{"layer":"#1","wait":true}' shaky.ecproj --save --json
effectcraft-cli exec warp.status '{"layer":"#1"}' shaky.ecproj --json
3D Camera Tracker
Animation ▸ Track Camera and the Tracker panel's button (track.camera {layer?, shotType?: fixed|variable|specify, aov?, solveMethod?: auto|typical|flat|tripod, detailed?, lensDistortion?, undistort?, wait?}) apply
Effect ▸ Perspective ▸ 3D Camera Tracker (or reuse the layer's) and analyse it in the background:
"Analyzing in background (step 1 of 2)" tracks features, "Solving camera" solves.
camera.analyze {layer?, effect?, wait?} re-runs it, camera.cancel stops it without writing
anything. camera.solveStatus reports progress/banner, analyzed, solved, methodUsed,
averageError (pixels), focalLength, horizontalAngleOfView, the current frame's camera
(position, orientation, zoom), groundPlane and the solved lensDistortion (k1, k2, normalised by
half the frame diagonal) when Solve Lens Distortion (effects/#1/advanced/lensDistortion, also on
with Detailed Analysis) is on; effects/#1/advanced/undistort renders the footage undistorted so
the solved camera's 3D layers line up. camera.points {time?} lists the solved points
visible now with their id, comp position, depth, world position and error.
Select points with camera.selectPoints {points, add?, toggle?} (the viewer's click / Shift-click /
marquee), then the right-click menu's commands: camera.setGroundPlane {points?} (Set Ground Plane
and Origin), camera.createFromSolve {kind: text|solid|null|shadowCatcher, points? | target: {center, normal, size?}, multiple?} (Create Text / Solid / Null / Shadow Catcher, Camera and Light,
Create Multiple …), camera.deletePoints {points?, wait?} (re-solves; with Auto-delete Points
Across Time the same feature's other tracks go too) and camera.create (the Create Camera button).
Each is one undo step; the first create adds the one-node "3D Tracker Camera" keyed on every frame.
Changing the layer's frames clears the analysis; changing effects/#1/shotType,
effects/#1/horizontalAngleOfView, effects/#1/advanced/solveMethod re-solves the stored tracks.
effectcraft-cli run shot.ecproj track.camera '{"layer":"#1","wait":true}' \
camera.points '{}' --json
effectcraft-cli run shot.ecproj camera.createFromSolve '{"kind":"solid","points":[12,40,77]}' --save
Roto Brush & Refine Edge
The Roto Brush tool (Alt+W cycles Roto Brush / Refine Edge) paints in the Layer panel; agents use
roto.stroke {layer, kind: fg|bg|refine|refineErase, points: [[x, y], …], frame?, radius?}
(layer pixels, layer frames). The first foreground stroke applies the Roto Brush & Refine Edge
effect and sets the base frame with a span of 20 frames each side. roto.propagate {layer, direction?: forward|backward|both, to?, wait?} segments the span (in the background unless
wait); strokes on any other frame correct it and propagation restarts from there.
roto.span {start?, end?}, roto.freeze {wait?} / roto.unfreeze, roto.clearStrokes {frame?, kind?}, roto.cancel and roto.options {diameter?, refineDiameter?, view?: alphaBoundary|alpha| alphaOverlay|none} complete the set; every edit is one undo step. roto.status {layer, frame?, matte?, compute?, compareTo?} reports the base frame, span, computed and stroked frames, frozen
state, job progress and, for a frame, the matte's area, centroid, RLE matte and IoU against a
reference. Matte settings are ordinary properties (effects/#1/rotoBrushMatte/searchRadius,
effects/#1/refineEdgeMatte/decontaminateEdgeColors, …).
Trained model (Roto Brush 2.0 / 3.0):
roto.modelslists the built-in engine and every registered model, with its authors, licence, size, URL, SHA-256 and whether it is installed, selected and active.roto.model.download {id}fetches and verifies the official weights in the background;roto.model.install {path, id?}installs a file you already have.roto.model.select {id: classical|mobilesam}chooses which one Roto Brush uses. Loading runs in the background;"wait": true(also on download and install) returns once it is in use.roto.model.remove {id}deletes the installed weights.- The
versionproperty (effects/#1/version: 0 = 1.0, 1 = 2.0, 2 = 3.0) picks between the classic engine (1.0) and the chosen model (2.0, 3.0).
effectcraft-cli run clip.ecproj roto.model.install '{"path":"mobile_sam.pt"}' roto.model.select '{"id":"mobilesam","wait":true}' \
roto.stroke '{"layer":"#1","points":[[300,200],[360,230]],"radius":10}' roto.propagate '{"layer":"#1","wait":true}' --save
effectcraft-cli run clip.ecproj roto.stroke '{"layer":"#1","points":[[300,200],[360,230]],"radius":10}' \
roto.stroke '{"layer":"#1","kind":"bg","points":[[40,40],[600,40]],"radius":12}' \
roto.propagate '{"layer":"#1","wait":true}' roto.freeze '{"layer":"#1","wait":true}' --save
Text: styles and editing
Source Text holds character style runs and per-paragraph settings. layer.setText changes the
whole layer, or only characters range: [start, end] (character indices): character attributes
(font, size, fill, tracking, kerning: metrics|optical|<1/1000 em>, tsume,
baseline: superscript|subscript, allCaps…) split the text into runs; paragraph attributes
(justify, indentLeft, indentFirst, spaceBefore, direction, composer,
hangingPunctuation…) apply to the paragraphs the range touches. Editing mirrors the Type tool:
layer.newText {"text":"", "box":[100,100,600,300], "edit":true}makes paragraph text and starts editing (positioninstead ofboxmakes point text;vertical: truevertical type).text.insert {"text":"Hello world"}types at the caret (replacing the selection);text.setSelection {"start":6,"end":11}selects "world";text.moveCaret {"to":"wordLeft", "extend":true}moves like the arrow keys;text.delete {"word":true}is Alt+Backspace.layer.setText {"range":[6,11], "size":40, "fill":"#ff5500"}styles the selection (the Character panel does the same); with an empty range it sets the style the next typed text takes.edit.copy,edit.paste,edit.pasteTextMatchFormattingandedit.pasteTextFormattingOnlywork on the selected text;text.endEditcommits (an empty Type-tool layer is removed).
Fonts: text.fonts (MCP list_fonts) lists every family a text layer can use, bundled and installed, with its
styles, origin (bundled, system, user) and own-language name ({"query":"gothic"} filters
by name; {"rescan":true} first picks up fonts installed since launch). Pass a listed family and
one of its styles to layer.setText {"font", "style"}; text.fontFeatures says which OpenType
options a font draws with its own glyphs.
In expressions, text.sourceText.style / getStyleAt(i, t) read styles and the setters
(setFontSize(v, start?, count?), setFillColor, setText, setJustification…) return a
styled document.
Scripts and ScriptUI windows
Scripts that build ScriptUI windows publish them to the session; agents drive them like a user:
scriptui.list→[{window, title, kind: dialog|palette|window|panel, script, modal, size}].scriptui.get {"window": id}→ the control tree (type,name,text,value,checked,items,selection, laid-outbounds,handlers…).scriptui.click {"widget": "ok"}presses a button / toggles a checkbox / picks a radio button or tab;scriptui.set {"widget": "#4", "value": "Shot_"}types into edit text, moves a slider or picks a list item (index or text);scriptui.close {"result": 2}closes. Controls are addressed by id,#id,properties.nameor text;windowcan be omitted when one window is open. Each returns the handler run's{ok, output, error}.
A dialog's show() waits for the user: the script.run / file.runScript reply carries
"waiting": true, and the script continues (its final output arrives in the reply of the click
that closes the dialog). File ▸ Scripts: file.scripts.list, file.runScript {"name": …},
file.installScript / file.installScriptUIPanel {"path": …}, window.scriptPanel {"name": …}.
Timeline: layer order, properties and shortcuts
layer.arrange {"layers": [...], "above": "Layer Name"}moves layers to just above another one (what dragging Timeline rows does: the selected layers move together, one undo step);to: front|forward|backward|backandindexstill work.- The reveal shortcuts are UI commands (
ui.menu.invokeor the keys themselves): A Anchor Point, P Position, S Scale, R Rotation, T Opacity, M Mask Path, F Mask Feather, E Effects, L Audio Levels, U keyframed properties; pressed twice quickly AA Material Options, PP paint / Roto Brush / Puppet, SS selected properties, RR Time Remap, TT Mask Opacity, MM all mask properties, FF missing effects, EE Expressions, LL Waveform, UU everything changed from its default. Shift+key adds or removes a set; the same key again hides it; Ctrl/Cmd+` twirls the selected layers open or closed. With no layer selected they apply to every layer. keys.toggleTransform {"layers"?: [...], "prop": "position"}(Alt+Shift+A/P/S/R/T) adds or removes a keyframe at the current time on that Transform property of each layer, in one undo step (separated X/Y/Z Position; Orientation and X/Y/Z Rotation on 3D layers).- Rename a layer with Enter (or double-click a name): the whole name is selected, Enter or a
click elsewhere commits, Escape cancels;
layer.rename {layer, name}does it directly.
History, puppet recording, plug-ins
edit.history.listlists every undo state as a tree (undoing then editing keeps the undone states as a branch);edit.history.goto {"index": n}(orid, orsteps) jumps to any of them. MCP: thehistorytool.puppet.recordPin {"layer": "#1", "pin": "Puppet Pin 1", "samples": [[t, x, y]…]}records a drag (t = seconds since it began, layer space) into Position keys at the comp frame rate from the current time;puppet.recordOptions {speed, smoothing, useDraftDeformation, showMesh}.puppet.selectPins {"layer": "#1", "pins": ["Puppet Pin 1"], "add"?, "toggle"?}selects pins like the viewer's click / Shift-click (pins: []deselects); Edit ▸ Clear (Delete) then removes the selected pins, not their layer (puppet.removePin {pins?}). Advanced and Bend pins turn by their ring and scale by its square in the viewer (Shift: 15° / 5 % steps; automation idsviewer.puppetPin.<uid>.rotate/.scale), orpuppet.setPin {rotation, scale}. A drag outside the art (or Alt-drag) marquee-selects pins; Edit ▸ Select All with a pin selected selects every pin of that kind;puppet.recordPin {..., "pins": [...]}records several pins moving together (⌘/Ctrl-drag one of several selected pins).- Rigging pins:
paths.pointsFollowNulls {"layer": "#1", "pins"?: [...]}gives each Position / Advanced pin a null that drives it (parent the nulls to each other to build limbs);paths.nullsFollowPoints {..., "pins": [...]}makes nulls that ride on pins (parent props to them). Withoutpinsthey use the selected pins, or every pin of a layer with no path. - Follow-through (hair, cloth, tails):
puppet.follow {"leader": "Puppet Pin 1", "pins": [...], "delay": 0.1, "amount": 100, "cascade": true}makes the pins trail the leader's motion (the k-th nearest by k × delay withcascade) through Position expressions; withoutleader/pinsthe first selected pin leads the other selected pins. effect.plugins.load {"path": "x.wasm"}/effect.plugins.list: WebAssembly effect plug-ins (plugins.md), theneffect.applyby id like a built-in.
CLI
Each invocation runs a headless engine with no window. It opens the demo project unless you pass
--project F.ecproj, a positional *.ecproj or --empty. Add --json for one compact JSON document
on stdout. Errors print {"error": ...} and exit with status 1; usage errors, such as an unknown
option, exit with status 2 before anything runs.
effectcraft-cli info --json
effectcraft-cli commands --filter keys
effectcraft-cli exec --list --schemas --json # every command with its params JSON Schema
effectcraft-cli exec comp.new --params '{"name":"Main","width":1280,"height":720}' --empty --save-as main.ecproj
effectcraft-cli exec layer.newSolid '{"color":"#3366ff"}' main.ecproj --save
effectcraft-cli props Main '#1' --project main.ecproj # flat property list with paths
effectcraft-cli set Main '#1' transform/opacity 40 main.ecproj --save
effectcraft-cli set Main '#1' transform/position '[100,360]' --time 0 main.ecproj --save
effectcraft-cli get Main '#1' transform/position --time 0.5 main.ecproj --json
effectcraft-cli run main.ecproj comp.open '{"comp":"Main"}' time.set '{"time":1}' --json
effectcraft-cli render-frame main.ecproj --time 1 --max-side 640 --out f.png --json
effectcraft-cli exec layer.newNull --bridge 9877 # same commands, against the live app
effectcraft-cli exec file.exportLottie '{"comp":"Main","path":"main.json","includeExpressions":true}' main.ecproj --json
effectcraft-cli exec file.importLottie '{"path":"anim.json"}' main.ecproj --save
Scripts written against After Effects' documented scripting API run with script:
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.
file.importLottie returns {comp, items, warnings} and opens the new composition.
Premiere Pro interop goes through timeline interchange (MCP: the execute_command tool; control channel: engine.execute
with the same ids):
effectcraft-cli exec file.importTimeline '{"path":"edit.xml"}' main.ecproj --save # FCP7 XML / .fcpxml / .otio / .edl / .aaf / .omf
effectcraft-cli exec file.exportTimeline '{"comp":"Main","path":"Main.xml"}' main.ecproj --json
effectcraft-cli exec file.exportTimeline '{"comp":"Main","path":"Main.otio","prerender":"none"}' main.ecproj
file.importTimeline returns {format, folder, comps, allComps, items, missing, warnings}
(missing lists media imported as placeholders). file.exportTimeline writes Final Cut Pro XML
(.xml, which Premiere Pro opens with File ▸ Import) unless format or the extension says
fcpxml, otio, edl, aaf or omf; prerender (unsupported by default, all, none) controls which
layers are rendered to ProRes 4444 movies next to the document. It returns {path, format, sequences, videoTracks, audioTracks, clips, prerendered, warnings}. Native .prproj files are
not read or written.
<comp> is an id or name, or - for the active comp. <value> is JSON (50, [960,540],
"#ff0000") or a bare string.
Seeing the UI
To work on the UI, start the app with cargo run -p effectcraft -- --control 9877 (add --demo to
open the demo project; without it the app starts with an empty project) and use MCP bridge
mode or the raw control channel. A good loop is: ui_elements to find an id, ui_click or ui_drag
to act, then screenshot {"panel":"Timeline"} to check the result. render_frame shows the
composition itself at any zoom.