mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Shape and text layers have comp-sized effect bounds centred on their origin, but effects measured positions from the origin: a point at its default "layer centre" sat at the comp's bottom-right corner, so Twirl on a centred shape changed nothing and Bulge only its bottom-right quarter. As in After Effects, effects now work in effect space, measured from the top-left of the layer's effect bounds. The renderer moves the layer buffer into effect space for the whole stack (Buf::rebase, CPU and GPU alike) and hands effects everything else in it: masks, other layers' pixels for layer parameters (each in its own effect space, so Set Matte and displacement maps see a whole shape layer), the layer's own frames (layer_input, also what Puppet, Roto Brush and the Layer panel read) and the comp camera (CompScene::rebased). Layer bounds are now right for every effect, so pin_rect and EffectEnv::bounds_origin go and layer_rect serves edge pinning too. Adjustment layers still run on the comp below as it is. effect_bounds / EvalCtx::effect_to_comp replace the repeated "comp size when there is no source" code; the viewer's effect point handles and eyedroppers, Puppet pins, the Layer panel (paint, Roto Brush) and the tracker's Corner Pin output use them. Project schema 2: opening a schema 1 project moves positions held by effects on shape and text layers by half the comp size, so set points keep their place (point parameters with keyframes, Paint paths and clone positions, Puppet seeds and pins, Liquify and Roto Brush strokes). Points still at their default become the layer centre; expression controls, adjustment layers and expressions are left as they are (docs/architecture.md). The disk cache's render version is bumped. Tests: default_effect_points_sit_at_the_centre_of_a_shape_layer and layer_parameters_see_a_whole_shape_layer (render, both fail before), effect_points_on_a_shape_layer (GPU vs CPU), opening_a_schema_1_project_moves_shape_layer_effect_points_into_effect_space (engine), and the effects migration tests. Part of #227 Co-Authored-By: Claude Opus 5.5 <[email protected]>
511 lines
37 KiB
Markdown
511 lines
37 KiB
Markdown
# 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](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:
|
||
|
||
```json
|
||
{
|
||
"mcpServers": {
|
||
"effectcraft": {
|
||
"command": "/path/to/effectcraft/target/release/effectcraft-cli",
|
||
"args": ["mcp"]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
This repository ships a ready-made [`.mcp.json`](../.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. It renders on the CPU
|
||
unless you add `"--gpu"` (`["mcp", "--gpu"]`), which attaches the GPU compositor on a device of
|
||
its own; if no adapter is usable the server says why and exits. `get_project`'s `renderer`
|
||
(`render.backend`) reports `active` (`gpu` / `cpu`) and, on the CPU, `why`.
|
||
- **Bridge** (`["mcp", "--bridge", "9877"]`): drives a running `effectcraft --control 9877`, so you
|
||
see every change live. Bridge mode adds `screenshot` and the `ui_*` 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
|
||
|
||
1. `execute_command {"command":"comp.new","params":{"name":"Intro","width":1920,"height":1080,"frameRate":30,"duration":4}}`
|
||
2. `execute_command {"command":"layer.newText","params":{"text":"Hello","size":160,"fill":"#ffffff"}}` returns `{"layer": 2}`.
|
||
3. `add_keyframe {"layer":2,"path":"transform/position","keys":[{"time":0,"value":[-300,540]},{"time":1.5,"value":[960,540]}],"interpolation":"easyEase"}`
|
||
4. `execute_command {"command":"effect.apply","params":{"layer":2,"effect":"Gaussian Blur"}}`, then `set_property {"layer":2,"path":"effects/#1/blurriness","value":8}`
|
||
5. `render_frame {"time":1.0}` lets you look at the result, and `save_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.newCamera` and `layer.newLight` take `name`, 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_comp` report 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.transform` and
|
||
`layer.centerAnchor` skip them and fail when every target is locked ("… is locked"); Select
|
||
All and `layer.selectNext` / `selectPrevious` step over them. Unlock with
|
||
`layer.setSwitch {"layers": [...], "switch": "lock", "value": false}` or `layer.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 unless
|
||
`affectAll: 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.apply` returns `{effects: [uid], paths:
|
||
["effects/#2"]}`; `layer.addShapeItem {kind, group?: uid|path}` returns `{uid, path}` (e.g.
|
||
`contents/group/contents/gfill`), ready for `set_property`. `@uid` works 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.path` is the trim's path).
|
||
* **Expressions**: `set_property`/`get_property` replies carry `evaluated` (what renders) next to
|
||
`value` (the keyframed value) and `expressionError` when the expression fails. An expression with
|
||
a syntax error is kept but disabled, so it renders `value` and 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) and
|
||
`keys.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`, then `keys.paste`
|
||
at the current time (keys copied from several layers go to as many selected layers in order).
|
||
`time.nextKey` / `time.previousKey` / `keys.selectAll` take `visible: [{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}]}`,
|
||
then `keys.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, then `layer.mask.set
|
||
{field: feather|expansion|opacity, value}`.
|
||
* **Renders**: `renderQueue.add {comp, format: h264|prores|webm|png…, channels: rgba,
|
||
proresProfile: 4444, output}` then `renderQueue.render {"wait": true}`. Check a render by
|
||
importing it (`file.import`, `file.newCompFromSelection`) and `render_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 explicit `comp` runs 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}`);
|
||
`history` lists branches and `history {"goto": id}` jumps between them. A command that changes
|
||
nothing (a value set to what it already is) records no undo step. `get_state` reports `dirty`
|
||
(unsaved changes); it follows undo, so undoing back to the saved state clears it.
|
||
* **Unsaved changes**: commands run through MCP / `engine.execute` never ask to save. In the
|
||
desktop app, `ui.menu.invoke` of 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_click` on `dialog.unsaved.save` / `dontSave` / `cancel`;
|
||
`dialog.unsaved.revert` for Revert); the control channel's `app.quit {"force": true}` skips it.
|
||
|
||
### Effect positions
|
||
|
||
Effect points, Puppet pins (`puppet.addPin`, `puppet.recordPin`), Paint, Liquify and Roto Brush
|
||
strokes and `effect.pickColor` are in **effect space**: layer pixels from the top-left of the
|
||
layer's effect bounds. For footage, solids and precomps that is the layer's own pixel grid. Shape
|
||
and text layers have comp-sized bounds centred on their content's origin, so in a `w` × `h` comp
|
||
the point `[w / 2, h / 2]` is that origin (a new effect's default point). `prop.get` / `prop.set`
|
||
read and write these values as they are.
|
||
|
||
### 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.
|
||
|
||
1. `execute_command {"command":"track.motion","params":{"layer":"clip.mov"}}` creates a Transform
|
||
tracker (Motion Target: the layer above). Use `track.stabilize` for Stabilize, or `track.new` with
|
||
`kind` `transform|stabilize|affine|perspective|raw` and `rotation`/`scale`.
|
||
2. `execute_command {"command":"track.setPoint","params":{"point":1,"center":[812,440],"featureSize":[40,40],"searchSize":[96,96]}}`
|
||
3. `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.
|
||
4. `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 for `affine`/`perspective` tracks. `track.options` sets the channel, blur/enhance,
|
||
adapt-feature and "If Confidence is Below" behaviour; `track.status` reports the track points.
|
||
|
||
```sh
|
||
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).
|
||
|
||
```sh
|
||
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.
|
||
|
||
```sh
|
||
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.
|
||
|
||
```sh
|
||
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.
|
||
|
||
```sh
|
||
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?}`
|
||
(effect space, 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.models` lists 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 `version` property (`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).
|
||
|
||
```sh
|
||
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
|
||
```
|
||
|
||
```sh
|
||
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:
|
||
|
||
1. `layer.newText {"text":"", "box":[100,100,600,300], "edit":true}` makes paragraph text and
|
||
starts editing (`position` instead of `box` makes point text; `vertical: true` vertical type).
|
||
2. `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.
|
||
3. `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.
|
||
4. `edit.copy`, `edit.paste`, `edit.pasteTextMatchFormatting` and `edit.pasteTextFormattingOnly`
|
||
work on the selected text; `text.endEdit` commits (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:
|
||
|
||
1. `scriptui.list` → `[{window, title, kind: dialog|palette|window|panel, script, modal, size}]`.
|
||
2. `scriptui.get {"window": id}` → the control tree (`type`, `name`, `text`, `value`, `checked`,
|
||
`items`, `selection`, laid-out `bounds`, `handlers`…).
|
||
3. `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.name` or text; `window` can 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|back` and `index` still work.
|
||
* The reveal shortcuts are UI commands (`ui.menu.invoke` or 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.list` lists every undo state as a tree (undoing then editing keeps the undone
|
||
states as a branch); `edit.history.goto {"index": n}` (or `id`, or `steps`) jumps to any of
|
||
them. MCP: the `history` tool.
|
||
* `puppet.recordPin {"layer": "#1", "pin": "Puppet Pin 1", "samples": [[t, x, y]…]}` records a
|
||
drag (t = seconds since it began, effect 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 ids
|
||
`viewer.puppetPin.<uid>.rotate` / `.scale`), or `puppet.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). Without `pins` they 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 with `cascade`) through Position expressions; without `leader` /
|
||
`pins` the first selected pin leads the other selected pins.
|
||
* `effect.plugins.load {"path": "x.wasm"}` / `effect.plugins.list`: WebAssembly effect plug-ins
|
||
([plugins.md](plugins.md)), then `effect.apply` by 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.
|
||
|
||
```sh
|
||
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`:
|
||
|
||
```sh
|
||
effectcraft-cli script build.jsx --save-as main.ecproj # empty project unless one is given
|
||
effectcraft-cli script --eval 'app.project.item(1).numLayers' main.ecproj --json
|
||
effectcraft-cli script tweak.jsx --bridge 9877 # against the live app
|
||
```
|
||
|
||
`writeLn`/`$.writeln`/`alert` output is printed, then the value of the last expression. A script
|
||
error exits with status 1 and `file:line:col: message`. Scripts may read files only in the
|
||
project's folder and may not write files or use the network unless the user turns on Preferences ▸
|
||
Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network.
|
||
|
||
`file.exportLottie` returns `{path, bytes, warnings}`: the warnings list every feature Lottie
|
||
cannot express (most effects, cameras and lights, layer styles, audio, video footage…), so an
|
||
agent can check what a player will not show. A `.lottie` path writes a dotLottie archive.
|
||
`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):
|
||
|
||
```sh
|
||
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.
|