A Node controller sends JSON commands over a WebSocket to a CEP panel inside AE, which runs them as ExtendScript and returns JSON. On top sits an autonomous, spec-driven pipeline that builds clips and self-corrects them by rendering, reviewing, and revising. Everything is driven programmatically over the socket, with no manual After Effects work.
A title sequence built and rendered entirely through aftr. No manual After Effects work.Video made for tilion / fortress.Watch the full demo
Wire aftr into Claude Code in two commands. Start the controller (it serves the MCP endpoint), then point Claude Code at it.
npx aftr-studio controller # no install, needs Node 18+
pip install aftr-studio && aftr controller # pip
docker run -p 8787:8787 ghcr.io/arman-luthra/aftr # docker
claude mcp add --transport http aftr http://127.0.0.1:8787/mcp
Open After Effects and the panel (Window > Extensions > aftr; deploy it once from a clone with npm run deploy:panel
). Then ask Claude Code in plain language:
Create a 1080p 5s comp, add a title "LAUNCH" with fire behind it, animate blurFade, then render to mp4.
Claude Code sees every command as a tool: it calls ae_status
first, lists the set with ae_list_commands
, and runs anything through ae_command
. Prefer a stdio server? Use claude mcp add aftr -- npx -y aftr-studio mcp
(keep the controller running too).
| Method | Command |
|---|---|
| npm (global CLI) | npm install -g aftr-studio , then aftr controller |
| npx (no install) | npx aftr-studio controller |
| pip (Python launcher) | pip install aftr-studio , then aftr controller (needs Node 18+) |
| Docker | docker run --rm -p 8787:8787 ghcr.io/arman-luthra/aftr |
| From source | clone this repo (below) |
The npm, npx, and Docker paths run the controller, MCP server (aftr mcp
), and simulator (aftr sim
). Deploying the CEP panel into After Effects is done from a clone (npm run deploy:panel
), since it self-signs and installs the extension. The pip package is a thin launcher that forwards to the npm CLI through npx
.
git clone https://github.com/Arman-Luthra/aftr.git
cd aftr
npm install
npm run build:jsx && npm test
npm run deploy:panel # then quit and relaunch AE, Window > Extensions > aftr
npm run controller # http://127.0.0.1:8787
Then drive it from the UI, REST, WebSocket, or as a library:
node examples/flaming-title.mjs "ON FIRE"
Requires After Effects 2024 to 2026, Node 18+, and ffmpeg
on your PATH
. Full Windows and macOS setup is in section 4.
| Capability | What it does |
|---|---|
| ~100 commands | Comps, layers, keyframes with easing, expressions, effects (by matchName or display name, including deeply nested params and third-party plugins), masks, layer styles, text animators, render. |
| Discovery | listFonts , listInstalledEffects (returns matchNames), findEffectMatchName , listPlugins , getEnvironment . Find what's installed instead of guessing. |
| One-call VFX | fireEffect , smokeEffect , glitchEffect , neonGlow , cinematicGrade , and a friendly applyLumetri grade. |
| Text animation | Four Animate-panel presets (wordReveal , charScale , bunchRotate , blurFade ) plus a full range-selector builder. |
| Autonomous pipeline | Declarative segment specs realize, render, get a visual review, take a structured delta, re-render, and concat. It self-corrects. |
| MCP server | Drive AE from Claude Desktop, Claude Code, or any MCP client. Every command is a tool (npm run mcp ). |
| Fast and batched | batch runs N edits in one round-trip and one undo (sub-200 ms for 20 ops). Non-blocking aerender streams progress events, with a parallel render engine. |
| Testable without AE | A headless simulator runs the real JSX against a mock DOM. 96 tests plus e2e, CI on Node 18, 20, and 22. |
| Cross-platform | Windows and macOS, one-command sign and deploy. |
ββββββββββββββββββββββββββββββββββββββββββββββββ
β Controller (Node + ws + Express + web UI) β <- you / the UI / an agent drive this
β β’ hosts a WebSocket server (panel + agents) β
β β’ REST surface + interactive UI β
β β’ the HLD orchestrator (agentic loop) β
βββββββββββββββββ¬βββββββββββββββββββββββββββββββββ
β WebSocket (JSON request/response/event envelopes)
βββββββββββββββββΌβββββββββββββββββββββββββββββββββ
β CEP panel (inside After Effects) β β
β β’ WebSocket CLIENT, dials the controller β β build once,
β β’ routes commands to the JSX dispatch layer β β identical on Win & Mac
β β’ spawns aerender for non-blocking renders β β
βββββββββββββββββ¬βββββββββββββββββββββββββββββββββ β
β evalScript (string in / JSON string out)
βββββββββββββββββΌβββββββββββββββββββββββββββββββββ β
β JSX command layer (host.jsx + commands/*.jsx) β β
β β’ dispatch(command, paramsJson) -> JSON β β
β β’ ~90 commands: layers, keyframes, effects, β β
β text animators, masks, VFX presets, render β β
βββββββββββββββββ¬βββββββββββββββββββββββββββββββββ β
β AE scripting DOM (app.project, comps, layers)
βββββββββββββββββΌβββββββββββββββββββββββββββββββββ
β After Effects β
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
What you can do with itPrerequisitesQuick start, no AE needed (the simulator)Full setup with real After EffectsRun orderDriving the bridgeCommand vocabularyVFX and text presetsThe orchestrator (autonomous pipeline)ConfigurationDev workflow (hot reload + signing)ArchitectureProject structureTestingTroubleshootingCross-platform notes
Control AE from code: create comps and layers (solids, text, shapes, nulls, cameras, lights, adjustment, footage), set any transform or effect property, add keyframes with easing, write expressions, and parent, trim, move, or duplicate layers, all over a socket.
Add any effect by matchName and drive any of its parameters, even deeply nested ones, animated via keyframes or expressions.
Animate text with four ready-made Animate-panel styles (wordReveal
, charScale
, bunchRotate
, blurFade
) plus a full text-animator builder (Based On, Shape, Ease High to Low, keyframed Offset and Start).
Reach for one-call VFX presets: fireEffect
(realistic fire), smokeEffect
, glitchEffect
, neonGlow
, cinematicGrade
, and a friendly applyLumetri
grade.
Batch and introspect: run many edits in one round-trip and one undo (batch
), then read back layer or comp state (getLayerDetails
, getProperty
).
Render without blocking: render
spawns aerender
and streams progress
and renderComplete
events, so AE stays responsive.
Run the autonomous pipeline: describe a video as JSON segments, and the orchestrator realizes each one (applySpec
), renders it scoped, reviews it visually, applies a structured spec delta if it's wrong, re-renders until it passes, then concatenates the result with FFmpeg.
| Requirement | Notes |
|---|---|
| Adobe After Effects | 2024 (24.x), 2025 (25.x), or 2026 (26.x). Licensed and activated. Record your exact version. |
| Node.js 18+ | The controller, simulator, and build scripts are Node (ESM). |
| FFmpeg | Needed by the orchestrator's visual reviewer and final concat. ffmpeg must be on your PATH . |
| Git | To clone and push. |
Check them:
node --version # v18+ (v22 recommended)
ffmpeg -version # any recent build
CEP version maps to AE version: AE 2024 uses CEP 11 (CSXS.11
); AE 2025 and 2026 use CEP 12 (CSXS.12
). The panel manifest targets [24.0,99.9]
and a low required runtime, so it loads on all of them.
You can validate the entire architecture (controller-to-bridge protocol, command dispatch, render plumbing) without After Effects, using the headless simulator. It is a Node process that speaks the exact same WebSocket protocol as the real panel and runs the real bundled JSX against a mock AE DOM.
git clone https://github.com/Arman-Luthra/aftr.git
cd aftr
npm install
npm run build:jsx
npm test
npm run e2e
Or run it interactively:
npm run controller
npm run sim
curl -X POST http://127.0.0.1:8787/command \
-H "Content-Type: application/json" \
-d '{"command":"createComp","params":{"name":"Main","width":1920,"height":1080}}'
This is how M0
, M3
, and M4
(architecture, round-trip, vocabulary) are validated with zero AE. The real panel drops in unchanged for M1
, M2
, and M5
.
The panel is a CEP extension. On modern AE (2025+), the old trick of enabling unsigned panels via PlayerDebugMode
is unreliable: AE ignores it and rejects unsigned extensions with "Signature verification failed." The reliable, supported path is to self-sign and install the panel, which this repo automates with one command:
npm run deploy:panel
deploy:panel
builds the JSX bundle, creates a self-signed dev certificate (cached in dist/
), signs panel/
into a .zxp
, and unpacks the signed extension into your per-user CEP extensions folder.
It works on both Windows and macOS (the zxp-sign-cmd tool ships the signer for both). After it runs, fully quit and relaunch After Effects, then open Window > Extensions > aftr.
A signed extension is a snapshot. After you edit panel source, re-run npm run deploy:panel
(and reopen AE) to make it permanent, or use the hot-reload dev loop (see section 11) while iterating.
npm install
npm run deploy:panel
The signed panel installs to:
%APPDATA%\Adobe\CEP\extensions\com.ae-bridge.panel
You may also set debug mode for remote DevTools, but it is not required once signed:
reg add HKCU\Software\Adobe\CSXS.12 /v PlayerDebugMode /t REG_SZ /d 1 /f
(use CSXS.11
for AE 2024).
npm install
npm run deploy:panel
The signed panel installs to:
~/Library/Application Support/Adobe/CEP/extensions/com.ae-bridge.panel
macOS specifics:
- If the signer binary is quarantined by Gatekeeper, clear it once:
xattr -dr com.apple.quarantine node_modules/zxp-sign-cmd
(path may vary). aerender
lives at the app root:/Applications/Adobe After Effects <year>/aerender
. The controller and panel auto-detect it. If your install is non-standard, setAE_BRIDGE_AERENDER
(seesection 10).- Optional debug mode:
defaults write com.adobe.CSXS.12 PlayerDebugMode 1
(useCSXS.11
for AE 2024). Not required when signed.
- Start the controller (hosts the WS server, REST, and UI on a fixed port):
npm run controller # http://127.0.0.1:8787
- Launch After Effects.
- Open the panel: Window > Extensions > aftr. It dials the controller and the status dot goes green (
AE v26.x
). - Open the UI at http://127.0.0.1:8787: buttons per command, a live event log, and a JSON response viewer.
To run an autonomous demo (see section 9): npm run orchestrate
, npm run orchestrate:fire
, or npm run showreel
.
There are several equivalent ways to send commands, all using the same JSON envelope.
REST (one-shot):
curl -X POST http://127.0.0.1:8787/command \
-H "Content-Type: application/json" \
-d '{"command":"createComp","params":{"name":"Hello","width":1920,"height":1080,"duration":6,"frameRate":30}}'
WebSocket (programmatic, for agents): connect to ws://127.0.0.1:8787/agent
, send
{"id":"1","type":"command","command":"ping","params":{}}
, and receive
{"type":"result","id":"1","ok":true,"result":{...}}
. Panel events (ready, log, progress, renderComplete) are broadcast to every connected agent.
The web UI at /
renders a control per command (with a JSON-params box for the rich ones), a live log, and the last response.
As a library, the bundled agent client makes scripting simple. See examples/:
node examples/flaming-title.mjs "ON FIRE" # build a flaming title programmatically
node examples/animated-text.mjs # the four text-animation presets in one batch
js
import { AgentClient } from './controller/src/orchestrator/agentClient.js';
const ae = new AgentClient();
await ae.connect();
const { compId } = await ae.must('createComp', { name: 'Hi', width: 1080, height: 1080, duration: 5, frameRate: 30 });
await ae.must('fireEffect', { compId });
await ae.must('addTextLayer', { compId, text: 'ON FIRE', fontSize: 150, justification: 2, position: [540, 300], name: 't' });
await ae.must('applyTextPreset', { compId, layer: 't', preset: 'blurFade' });
Drive After Effects from any MCP client (Claude Desktop, Claude Code, and others). The MCP server (controller/src/mcp.js
) is a thin stdio adapter that forwards each tool call to the running controller, so the AE panel, controller, and your project all behave exactly as above; the client just gets tools.
npm run controller
claude mcp add aftr -- node /ABSOLUTE/PATH/aftr/controller/src/mcp.js
// 2b) Claude Desktop: claude_desktop_config.json
{
"mcpServers": {
"aftr": {
"command": "node",
"args": ["/ABSOLUTE/PATH/aftr/controller/src/mcp.js"],
"env": { "AE_BRIDGE_URL": "http://127.0.0.1:8787", "AE_MCP_TOOLS": "core" }
}
}
}
The client then sees:
ae_status
: is AE connected? (call this first if anything returnsNO_PANEL
)ae_list_commands
: every command and descriptionae_command
: run any command ({ command, params }
), the universal escape hatchae_createComp
,ae_addTextLayer
,ae_deepGlow
,ae_shadowStudio
,ae_applySpec
,ae_render
, and other curated per-command tools
| Env | Default | Meaning |
|---|---|---|
AE_BRIDGE_URL |
||
http://127.0.0.1:8787 |
||
| controller to forward to | ||
AE_MCP_TOOLS |
||
core |
||
core is the curated set plus meta tools; all exposes every registered command as its own tool |
||
AE_BRIDGE_ALLOW_DEV |
||
| (unset) | 1 to surface dev-only commands |
The MCP server is a client of the controller, so run npm run controller
(not a second one) to avoid a port clash; the MCP process just needs to reach it.
Run the bridge on your machine and let other people send a video, have your AE render it, and download the result, over HTTP or MCP.
AE_BRIDGE_HOST=0.0.0.0 AE_BRIDGE_TOKEN=your-secret npm run controller
| Route | Purpose |
|---|---|
GET /studio |
|
| a tiny browser page: pick a file, upload, see and download outputs | |
POST /media/upload?name=clip.mp4 |
|
send a video (raw body), returns { id, file, path, url } (lands on the host) |
|
POST /media/fetch `{ url |
dataBase64, name? }` |
| host pulls a video by URL (easiest for remote callers) | |
GET /media/list |
|
| list incoming and rendered files, each with a download URL | |
GET /media/file/<name> |
|
| download a video | |
POST /media/render { compId, name? } |
|
render, transcode to H.264 .mp4, returns { downloadUrl } . Waits up to AE_BRIDGE_RENDER_WAIT_MS (default 150s); longer renders return { pending, jobId } |
|
GET /media/render/<jobId> |
|
poll a long render, returns { status, downloadUrl } when done |
Renders are tracked as jobs, so a long render never blows the MCP client's ~180s call timeout, and every output is transcoded to a universally playable H.264 .mp4
(via ffmpeg, auto-detected or set with AE_BRIDGE_FFMPEG
).
curl -X POST --data-binary @in.mp4 -H "Content-Type: application/octet-stream" \
"http://HOST:8787/media/upload?name=in.mp4&token=your-secret"
curl -X POST -H "Content-Type: application/json" -H "Authorization: Bearer your-secret" \
-d '{"compId":3,"name":"out.mp4"}' http://HOST:8787/media/render
Expose it to the internet with a tunnel (no router config):
cloudflared tunnel --url http://localhost:8787 # or: ngrok http 8787
Remote MCP works two ways. The controller also serves MCP over HTTP at POST /mcp
(Streamable HTTP), so the tunnel URL is a pluggable MCP link:
claude mcp add --transport http aftr https://your-tunnel.example/mcp \
--header "Authorization: Bearer your-secret"
// (b) stdio adapter pointed at the tunnel
{ "mcpServers": { "aftr": {
"command": "node", "args": ["/path/aftr/controller/src/mcp.js"],
"env": { "AE_BRIDGE_URL": "https://your-tunnel.example", "AE_BRIDGE_TOKEN": "your-secret" }
}}}
Remote callers get the video tools too: ae_upload_video
({ url }
, host fetches it), ae_media_info
, ae_media_list
, ae_render_and_download
({ compId }
, returns a download URL or { pending, jobId }
for long renders), and ae_render_result
({ jobId }
to poll).
This exposes programmatic control of your After Effects. Always set AE_BRIDGE_TOKEN
before binding to 0.0.0.0
or tunneling. With a token set, /command
, /media/*
, /mcp
, and the /bridge
and /agent
WebSockets all require it (the local AE panel is exempted as a direct-loopback connection). Only share the token with people you trust.
Onboarding teammates? Hand them docs/TEAM-GUIDE.md, a no-repo-needed guide to connecting, the tool catalog, and copy-paste recipes (including URL to rendered video in two calls).
// request (controller to panel)
{ "id": "uuid", "type": "command", "command": "createComp", "params": { ... } }
// result (panel to controller)
{ "id": "uuid", "type": "result", "ok": true, "result": { ... } }
{ "id": "uuid", "type": "result", "ok": false, "error": "width must be a positive integer" }
// event (panel to controller, unsolicited)
{ "type": "event", "event": "ready", "data": { "ae": "26.3", "project": "Untitled" } }
{ "type": "event", "event": "progress", "data": { "jobId": "r1", "percent": 42 } }
~90 commands, grouped. List them live at GET /api/commands
. Highlights:
Project and comp:
ping
, getProjectInfo
, getAppInfo
, listComps
, createComp
, setCompSettings
, addCompMarker
, duplicateComp
, setActiveComp
, setCompTime
, getCompTime
, setWorkArea
, clearComp
, createFolder
, moveToFolder
, renameItem
, deleteItem
, setProxy
, getProjectItems
, saveProject
.
Layers:
addSolid
, addTextLayer
, addNull
, addAdjustmentLayer
, addCamera
, addLight
, addShape
, addPathShape
(custom bezier paths), addFootageLayer
, setLayerProperty
, setParent
, trimLayer
, moveLayer
, duplicateLayer
, deleteLayer
, getLayers
, setBlendMode
, setTrackMatte
, setLayerFlag
, addLayerMarker
, setTimeStretch
, enableTimeRemap
, replaceSource
, alignLayer
, sequenceLayers
.
Animation:
setKeyframe
, setKeyframes
(bulk + ease), setEase
, setInterpolation
, removeKeyframes
, setExpression
, removeExpression
, enableExpression
.
Effects:
addEffect
(by matchName), setEffectParam
, listEffects
, addExpressionControl
. Any nested effect param is reachable via a property path through setLayerProperty
or setExpression
, for example ["ADBE Effect Parade","FN","ADBE Fractal Noise-0012"]
.
Text:
setTextDocument
, addTextAnimator
(full Animate-panel range selector), applyTextPreset
.
Masks and styles:
addMask
, addRectMask
, setMaskProperty
, addLayerStyle
, setLayerStyleEnabled
.
Footage and render:
importFootage
, compFromFootage
, render
(non-blocking aerender, scoped startFrame
and endFrame
), addToRenderQueue
, listRenderQueue
, setOutputModule
, clearRenderQueue
.
Grading and VFX presets:
applyLumetri
, lumetriParams
, cinematicGrade
, fireEffect
, smokeEffect
, glitchEffect
, neonGlow
.
Discovery (read-only):
listFonts
(postScriptName authoritative, family and style derived), listInstalledEffects
(best-effort, probes a known set, returns {name, matchName}
), findEffectMatchName
(resolve a display name to a matchName), listPlugins
(best-effort, scans .aex
and .plugin
install dirs), getEnvironment
(AE version, OS, ExtendScript, font count, memory, GPU). Use these to discover the matchNames that addEffect
needs.
App and orchestration:
executeMenuCommand
(run any AE menu item by name or id), findMenuCommand
, undo
, redo
, purge
, getSelection
, keystroke
(best-effort OS keys), batch
, applySpec
, removeLayersByPrefix
.
runJSX
(raw ExtendScript) exists as a dev-only escape hatch, gated behind AE_BRIDGE_ALLOW_DEV=1
.
Realistic fire, one call:
{ "command": "fireEffect", "params": { "compId": 1, "center": [960,660], "size": 1.0, "embers": true } }
Builds an animated Fractal-Noise flame plus Turbulent Displace, CC Toner colorize, rising embers, ambient glow, and a Glow adjustment, masked into a flame shape.
Other presets:
{ "command": "smokeEffect", "params": { "compId": 1, "size": 1.1, "color": [0.7,0.7,0.8] } }
{ "command": "glitchEffect", "params": { "compId": 1, "layer": "title", "amount": 50, "shake": 18 } }
{ "command": "neonGlow", "params": { "compId": 1, "layer": "title", "radius": 28 } }
{ "command": "cinematicGrade", "params": { "compId": 1, "layer": "footage", "warm": true } }
{ "command": "applyLumetri", "params": { "compId": 1, "layer": "footage",
"settings": { "saturation": 140, "contrast": 20, "temperature": 12, "vibrance": 25 } } }
Pro text styles, one call:
{ "command": "applyTextPreset", "params": { "compId": 1, "layer": "title", "preset": "blurFade" } }
preset
is one of wordReveal
, charScale
, bunchRotate
, blurFade
. For full control use addTextAnimator
(properties plus advanced range selector plus keyframed Offset and Start with ease modes).
Describe a video as a manifest (segments with time or frame ranges) plus per-segment specs (declarative treatments). The orchestrator, a client of the bridge over /agent
, runs the loop:
spec -> applySpec (realize) -> scoped aerender (render) -> visual review (FFmpeg) ->
structured delta (revise) -> re-render -> pass -> FFmpeg concat (assemble)
A spec is a list of treatments:
{
"segment_id": 0,
"intent": "A flaming title",
"treatments": [
{ "type": "fire", "center": [960,660], "size": 1.0 },
{ "type": "title", "text": "ON FIRE", "fontSize": 150, "fillColor": [1,0.95,0.8],
"justification": 2, "position": [960,300], "animator": "blurFade",
"grade": { "warm": true }, "glitch": { "amount": 40 }, "neon": { "radius": 25 },
"effects": [ { "effect": "Glow", "params": { "Glow Radius": 40 } }, { "effect": "Tint" } ] }
]
}
Supported treatment types: fire
, smoke
, title
(with animator
, grade
, glitch
, and neon
modifiers), color_grade
, procedural_motion
, responsive_box
, transition
. Every segment is idempotent: re-running wipes and rebuilds its seg{id}_*
layers, so state never drifts.
Effects by display name: any treatment may carry an effects: [{ effect, params }]
array (and color_grade
takes a single effect
). Write the display name you'd pick from the Effect menu, like "Glow"
or "Lumetri Color"
, and the executor resolves it to the matchName (ADBE Glo2
, ADBE Lumetri
) for you, validating that it's installed first. Authors never touch matchNames; discover what's available with listInstalledEffects (which caches per session, so repeated probes are free).
Third-party plugins: two installed-effect wrappers ship friendly params (resolved to the plugin's stable matchNames under the hood, because plugins like these reuse internal param names):
| Command / spec modifier | Plugin | Friendly params |
|---|---|---|
deepGlow |
||
Deep Glow 2 (Plugin Everything, PEDG2 ) |
||
radius, exposure, threshold, glowMode, color, colorOuter, tintStrength |
||
shadowStudio |
||
Shadow Studio 3 (Plugin Everything, PESS3 ) |
||
lightDirection, shadowLength, lightRadius, softness, color, opacityStart, opacityEnd, samples |
{ "type": "title", "text": "NEON",
"deepGlow": { "radius": 200, "exposure": 1.2, "color": [1, 0.4, 0.1] },
"shadowStudio": { "lightDirection": 120, "shadowLength": 300 } }
Wiring any other plugin is one call: introspectEffect { name: "Your Plugin" }
adds it to a throwaway layer and dumps the full parameter tree (each param's name
, matchName
, value type, and default). Copy the matchNames into a map and you're done. Both wrappers above were built this way; pass raw params: { "PEDG2-0017": 250 }
to reach anything not in the friendly map. Re-run introspectEffect
after a plugin version bump in case matchNames shift.
Reviewers are pluggable (controller/src/orchestrator/reviewers.js
):
brightnessReviewer
(default): an FFmpegsignalstats
check that the segment actually rendered visible content; if it's blank it emits a structured delta.claudeReviewer
: a slot that judges intent with a vision model; setANTHROPIC_API_KEY
to enable.
Demos:
npm run orchestrate # 2 segments; one is intentionally broken and self-corrects
npm run orchestrate:fire # "a fiery title card" plus a self-correcting segment
npm run showreel # 4-VFX parallel showreel (fire / smoke / glitch / neon)
Parallel rendering: runPipelineParallel
builds all segments fast (sequential edits plus one save), then renders pending segments concurrently in rounds, revising only failures. aerender
already multi-frame-renders across all cores, so high concurrency oversubscribes a single machine. The default is concurrency: 2
; raise it only across multiple machines or for short, startup-bound renders.
Root config.json
is the single source of truth for the endpoint (only the panel's wsUrl
changes when moving to the cloud):
{ "host": "127.0.0.1", "port": 8787, "wsPath": "/bridge", "agentPath": "/agent",
"commandTimeoutMs": 30000, "reconnect": { "initialDelayMs": 500, "maxDelayMs": 10000, "factor": 1.8 } }
Environment overrides:
| Env var | Default | Purpose |
|---|---|---|
AE_BRIDGE_HOST |
||
127.0.0.1 |
||
| Bind address | ||
AE_BRIDGE_PORT |
||
8787 |
||
| Port | ||
AE_BRIDGE_TIMEOUT_MS |
||
30000 |
||
| Command timeout | ||
AE_BRIDGE_ALLOW_DEV |
||
0 |
||
Enable runJSX (1 to allow) |
||
AE_BRIDGE_LOG_LEVEL |
||
info |
||
debug , info , warn , or error |
||
AE_BRIDGE_AERENDER |
||
| auto-detect | Full path to aerender if auto-detection fails |
|
ANTHROPIC_API_KEY |
||
| (unset) | Enables the vision-model reviewer in the orchestrator |
The panel reads panel/config.json
for its wsUrl
(falls back to ws://127.0.0.1:8787/bridge
).
Edit JSX and hot-reload without reopening AE: node tools/hot.mjs
rebuilds the bundle and evals it into the running panel via the CEF DevTools protocol (it never touches the signed file, so the signature stays valid). It requires the panel open and the .debug
remote-debug port available.
Make it permanent: npm run deploy:panel
re-signs and redeploys (then quit and reopen AE).
Inspect the panel live: with the .debug
file present, open the CEF DevTools at the port in panel/.debug
.
The bundled JSX must be ASCII, because $.evalFile
mis-decodes BOM-less UTF-8. The bundler sanitizes non-ASCII, and the panel reads the file as explicit UTF-8 and eval()
s it.
Controller (controller/
): Express plus ws
. aeClient.js
tracks the single panel socket and provides sendCommand()
(a Promise that is id-correlated, has a timeout, rejects on disconnect, and always resolves to a uniform {ok,...}
shape). agentHub.js
relays commands from agents and the UI and broadcasts panel events. server.js
path-routes /bridge
(panel) versus /agent
(clients). orchestrator/
holds the agentic pipeline.
Panel (panel/
): a hand-rolled CEP extension. index.html
loads Adobe's real CSInterface.js
, bridge.js
(evalScript-to-Promise plus bundle ), render.js
(non-blocking aerender), keystroke.js
, and main.js
(WS client with reconnect and backoff). CSXS/manifest.xml
enables Node and targets AEFT. The JSX lives in panel/jsx/
and is bundled to bundle.jsx
.
JSX layer (panel/jsx/
): host.jsx
(dispatch plus AEB.*
helpers plus a JSON polyfill) and commands/*.jsx
(one file per group). ES3 only.
Shared (shared/
): the wire protocol plus command registry, consumed by the controller and simulator (single source of truth).
Simulator (simulator/
): runs the real bundled JSX in a Node vm
against a mock AE DOM, so the architecture is testable without AE.
The panel is the load-bearing, build-once part, identical locally and (later) in the cloud. The controller is a swappable front end.
aftr/
βββ README.md
βββ package.json # workspaces + scripts
βββ config.json # endpoint config
βββ controller/
β βββ src/
β β βββ server.js # WS server + Express + static UI
β β βββ aeClient.js # panel socket + sendCommand (id-correlated, timeout)
β β βββ agentHub.js # agent/UI relay + event broadcast
β β βββ protocol.js, commands.js, log.js
β β βββ orchestrator/ # agentClient, reviewers, engine, demos
β βββ ui/ # interactive control panel (index.html, ui.js, styles.css)
β βββ test/ # aeClient, agentHub, e2e
βββ panel/ # the CEP extension (the bridge)
β βββ CSXS/manifest.xml
β βββ index.html, .debug, config.json
β βββ src/ # csInterface.js (real Adobe lib), bridge.js, render.js, keystroke.js, main.js
β βββ jsx/ # json2.js, host.jsx, commands/*.jsx (bundle.jsx is generated)
β βββ build/ # bundle-jsx.js, sign-and-deploy.mjs
β βββ tools/sendkeys.ps1
βββ shared/src/ # protocol.js, commands.js, validate.js, config.js
βββ simulator/src/ # index.js, mockAeDom.js, jsxRunner.js, render.js
npm test # unit (protocol/commands) + simulator (JSX dispatch on mock DOM) + controller
npm run e2e # controller + simulator full round-trip, incl. render progress/complete events + output file
The headless suite proves the protocol, id-correlation, timeout and disconnect handling, command validation, and the full JSX command set against the mock AE DOM, with no After Effects required.
| Milestone | Validated by |
|---|---|
| M0 panel loads | Panel appears under Window > Extensions (signed); simulator validates the connection |
| M1 read path | getProjectInfo returns the real project |
| M2 write path | createComp creates a comp, confirmed via listComps |
| M3 round-trip | controller to WS to bridge to JSX to result to REST (e2e) |
| M4 vocabulary | all commands validated both sides; errors surface as {ok:false,error} |
| M5 render | non-blocking aerender ; progress and renderComplete events; real output file |
"Signature verification failed" or the panel won't appear under Window > Extensions.
Modern AE ignores PlayerDebugMode
. Run npm run deploy:panel
(signs and installs), then fully quit and relaunch AE. CEP reads signatures at startup, so a restart is required after (re)deploying.
The panel connects but every command returns "EvalScript error."
The panel must use the real Adobe CSInterface.js
(already vendored at panel/src/csInterface.js
). A stub or missing lib makes evalScript
always fail. Also ensure npm run build:jsx
ran so panel/jsx/bundle.jsx
exists.
The JSX bundle fails to load.
$.evalFile
mis-decodes BOM-less UTF-8. The bundler emits ASCII and the reads UTF-8 then eval()
s, so keep your source ASCII (the bundler sanitizes anyway). ExtendScript is ES3: no let
, const
, or arrow functions, and no chained ternaries a?b:c?d:e
, so use if/else
.
render
says aerender not found.
Set AE_BRIDGE_AERENDER
to the full path. Windows: β¦\Support Files\aerender.exe
. macOS: /Applications/Adobe After Effects <year>/aerender
.
render
errors "No comp was found".
aerender
reads the saved .aep
. Save first (saveProject
with a path), since a fresh "Untitled" project has nothing on disk.
Orchestrator or agent commands time out.
Make sure the controller is running and the panel is connected (GET /api/status
). Agent replies are correlated by your sent id
.
Keystroke does nothing.
OS keystroke injection is best-effort (Windows foreground-lock blocks background SendKeys
). Use executeMenuCommand
for any menu or shortcut action; it's a direct scripting call and always works.
Modal dialogs freeze the bridge.
Avoid JSX that opens a dialog (or alert()
); it blocks evalScript
.
| Windows | macOS | |
|---|---|---|
| CEP extensions folder | %APPDATA%\Adobe\CEP\extensions |
|
~/Library/Application Support/Adobe/CEP/extensions |
||
aerender location |
||
β¦\Support Files\aerender.exe |
||
/Applications/Adobe After Effects <year>/aerender |
||
| Signing tool | provided by zxp-sign-cmd |
|
provided by zxp-sign-cmd (clear quarantine if needed) |
||
| Keystroke layer | .NET SendKeys (panel/tools/sendkeys.ps1 ) |
|
osascript System Events |
||
| Optional debug mode | HKCU\Software\Adobe\CSXS.12 PlayerDebugMode=1 |
|
defaults write com.adobe.CSXS.12 PlayerDebugMode 1 |
deploy:panel
, the controller, the simulator, the bundler, and the orchestrator are all cross-platform Node. The CEP panel itself is identical on both, so build once and run anywhere AE runs.
MIT. Use it, fork it, ship it.
Watch the full demo: https://www.youtube.com/watch?v=QkjTwlqPhNc