@engine-room/after-effects-mcp 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/server.js +92 -7
- package/package.json +1 -1
- package/panel/CSXS/manifest.xml +2 -2
- package/panel/client/main.js +104 -3
- package/panel/package.json +1 -1
package/README.md
CHANGED
|
@@ -356,7 +356,7 @@ Adding a tool takes three edits: a zod schema in `packages/shared/src/schemas.ts
|
|
|
356
356
|
|
|
357
357
|
Guidance prose is written once in `packages/mcp-server/src/{guides,prompts}/*.md` and generated into the MCP resources, the `ae_guide` tool, the server's `instructions`, and the Claude Code skills and commands. Never edit the generated copies.
|
|
358
358
|
|
|
359
|
-
Releases are cut from a Mac with a Developer ID certificate; see `scripts/sign-and-notarize.sh` for the environment it expects. See [CLAUDE.md](https://github.com/Engine-Room-Games/after-effects-mcp/blob/main/CLAUDE.md) for the architecture
|
|
359
|
+
Releases are cut from a Mac with a Developer ID certificate; see `scripts/sign-and-notarize.sh` for the environment it expects. See [CLAUDE.md](https://github.com/Engine-Room-Games/after-effects-mcp/blob/main/CLAUDE.md) for the architecture and the ExtendScript conventions, and [docs/](https://github.com/Engine-Room-Games/after-effects-mcp/tree/main/docs) for the known-fragile areas and the verification recipes.
|
|
360
360
|
|
|
361
361
|
</details>
|
|
362
362
|
|
package/bin/server.js
CHANGED
|
@@ -440,6 +440,55 @@ var BridgeUnreachableError = class _BridgeUnreachableError extends Error {
|
|
|
440
440
|
].filter(Boolean).join("\n");
|
|
441
441
|
}
|
|
442
442
|
};
|
|
443
|
+
var BridgeAuthError = class _BridgeAuthError extends Error {
|
|
444
|
+
constructor(port, sentToken) {
|
|
445
|
+
super(_BridgeAuthError.message(port, sentToken));
|
|
446
|
+
this.port = port;
|
|
447
|
+
this.sentToken = sentToken;
|
|
448
|
+
this.name = "BridgeAuthError";
|
|
449
|
+
}
|
|
450
|
+
port;
|
|
451
|
+
sentToken;
|
|
452
|
+
static message(port, sentToken) {
|
|
453
|
+
return [
|
|
454
|
+
`The After Effects panel on port ${port} refused this call: the bridge token did not match.`,
|
|
455
|
+
"",
|
|
456
|
+
"This is not a lost connection and not a busy bridge. The panel is running and",
|
|
457
|
+
"answered immediately \u2014 it answered by refusing. The call never reached After",
|
|
458
|
+
"Effects, so nothing in the project changed and nothing needs undoing.",
|
|
459
|
+
"",
|
|
460
|
+
"The panel requires a token because the bridge is an HTTP server on 127.0.0.1, and",
|
|
461
|
+
"loopback is not a boundary a browser respects: without it, any page open in the",
|
|
462
|
+
"user's browser could drive After Effects. The panel writes the token beside its",
|
|
463
|
+
`port file, at ${tokenFileHint(port)}, every time it binds.`,
|
|
464
|
+
"",
|
|
465
|
+
"What to do, in order:",
|
|
466
|
+
...sentToken ? [
|
|
467
|
+
"1. This server did send a token and the panel rejected it, so the file it read",
|
|
468
|
+
" belongs to a panel that is no longer the one answering \u2014 usually a previous",
|
|
469
|
+
" After Effects session whose panel process outlived it.",
|
|
470
|
+
"2. Quit After Effects completely and reopen it. That rebinds the panel and",
|
|
471
|
+
" rewrites the token, and the next call succeeds.",
|
|
472
|
+
"3. If it persists, check for a leftover CEPHtmlEngine in Activity Monitor or",
|
|
473
|
+
" Task Manager holding the port \u2014 the same zombie issue #92 is about."
|
|
474
|
+
] : [
|
|
475
|
+
"1. This server found no token file for that port, so it sent none. Either the",
|
|
476
|
+
" panel could not write it \u2014 its own log in After Effects says so \u2014 or this",
|
|
477
|
+
" server and the panel disagree about where home is, which happens when the",
|
|
478
|
+
" MCP client runs the server sandboxed or as a different user.",
|
|
479
|
+
"2. Ask the user to look at the panel in After Effects (Window > Extensions).",
|
|
480
|
+
" It logs the exact path it wrote the token to when it starts.",
|
|
481
|
+
"3. If the panel never started, run check_setup and relay its nextSteps."
|
|
482
|
+
],
|
|
483
|
+
"",
|
|
484
|
+
"Do NOT re-send this call until the cause is fixed; it will be refused identically.",
|
|
485
|
+
"Running setup_panel does not help: this is not a broken install."
|
|
486
|
+
].join("\n");
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
function tokenFileHint(port) {
|
|
490
|
+
return `${process.platform === "win32" ? "%USERPROFILE%\\.engineroom-ae-mcp\\token-" : "~/.engineroom-ae-mcp/token-"}${port}`;
|
|
491
|
+
}
|
|
443
492
|
var BridgeTimeoutError = class _BridgeTimeoutError extends Error {
|
|
444
493
|
constructor(port, timeoutMs, opts = {}) {
|
|
445
494
|
super(_BridgeTimeoutError.message(port, timeoutMs, opts));
|
|
@@ -624,6 +673,21 @@ var DEFAULT_PORT = 7777;
|
|
|
624
673
|
function portFilePath() {
|
|
625
674
|
return path4.join(os3.homedir(), ".engineroom-ae-mcp", "port");
|
|
626
675
|
}
|
|
676
|
+
function tokenFilePath(port) {
|
|
677
|
+
return path4.join(os3.homedir(), ".engineroom-ae-mcp", `token-${port}`);
|
|
678
|
+
}
|
|
679
|
+
function panelToken(port) {
|
|
680
|
+
try {
|
|
681
|
+
const txt = fs3.readFileSync(tokenFilePath(port), "utf8").trim();
|
|
682
|
+
return txt.length > 0 ? txt : null;
|
|
683
|
+
} catch {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
function authHeaders(port) {
|
|
688
|
+
const token = panelToken(port);
|
|
689
|
+
return token ? { "x-ae-mcp-token": token } : {};
|
|
690
|
+
}
|
|
627
691
|
function pinnedPort() {
|
|
628
692
|
const envPort = process.env.AE_MCP_PORT;
|
|
629
693
|
if (!envPort) return null;
|
|
@@ -1922,7 +1986,7 @@ var HttpClient = class {
|
|
|
1922
1986
|
try {
|
|
1923
1987
|
resp = await fetch(`${this.base}/op`, {
|
|
1924
1988
|
method: "POST",
|
|
1925
|
-
headers: { "content-type": "application/json" },
|
|
1989
|
+
headers: { "content-type": "application/json", ...authHeaders(this.port) },
|
|
1926
1990
|
body: JSON.stringify({ op, args: args ?? {}, progressToken }),
|
|
1927
1991
|
signal: AbortSignal.timeout(timeoutMs)
|
|
1928
1992
|
});
|
|
@@ -1930,6 +1994,9 @@ var HttpClient = class {
|
|
|
1930
1994
|
if (isTimeoutError(e)) throw new BridgeTimeoutError(this.port, timeoutMs, { op });
|
|
1931
1995
|
throw new BridgeUnreachableError(this.port, e);
|
|
1932
1996
|
}
|
|
1997
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
1998
|
+
throw new BridgeAuthError(this.port, panelToken(this.port) !== null);
|
|
1999
|
+
}
|
|
1933
2000
|
let data;
|
|
1934
2001
|
try {
|
|
1935
2002
|
data = await resp.json();
|
|
@@ -1982,7 +2049,7 @@ ${summary}`;
|
|
|
1982
2049
|
try {
|
|
1983
2050
|
await fetch(`${this.base}/cancel`, {
|
|
1984
2051
|
method: "POST",
|
|
1985
|
-
headers: { "content-type": "application/json" },
|
|
2052
|
+
headers: { "content-type": "application/json", ...authHeaders(this.port) },
|
|
1986
2053
|
body: JSON.stringify({ jobId })
|
|
1987
2054
|
});
|
|
1988
2055
|
} catch (e) {
|
|
@@ -2032,7 +2099,7 @@ var WsClient = class {
|
|
|
2032
2099
|
connect() {
|
|
2033
2100
|
if (this.stopped) return Promise.resolve();
|
|
2034
2101
|
const url = `ws://127.0.0.1:${this.bridge.port}/events`;
|
|
2035
|
-
const ws = new WebSocket(url);
|
|
2102
|
+
const ws = new WebSocket(url, { headers: authHeaders(this.bridge.port) });
|
|
2036
2103
|
this.ws = ws;
|
|
2037
2104
|
return new Promise((resolve) => {
|
|
2038
2105
|
ws.on("open", () => {
|
|
@@ -2976,6 +3043,16 @@ async function checkSetup(opts = {}) {
|
|
|
2976
3043
|
}
|
|
2977
3044
|
checks.push({ name: "portAgreement", ok: agrees, detail, fix });
|
|
2978
3045
|
}
|
|
3046
|
+
if (bridge.answering?.health?.auth === true) {
|
|
3047
|
+
const port = bridge.answering.port;
|
|
3048
|
+
const have = panelToken(port) !== null;
|
|
3049
|
+
checks.push({
|
|
3050
|
+
name: "bridgeToken",
|
|
3051
|
+
ok: have,
|
|
3052
|
+
detail: have ? `the panel's token for port ${port} is readable at ${tokenFilePath(port)}` : `the panel on port ${port} requires a token and none is readable at ${tokenFilePath(port)}`,
|
|
3053
|
+
fix: have ? void 0 : `Every tool call will be refused until this is resolved, and the panel itself is fine \u2014 do not restart After Effects for it, and do not run setup_panel. The panel writes that file each time it binds, so either it could not (its log inside After Effects says so, under Window > Extensions) or this server is reading a different home directory than the panel wrote to, which happens when the MCP client runs it sandboxed or as another user. Ask the user to open the panel and read the path it logged at startup.`
|
|
3054
|
+
});
|
|
3055
|
+
}
|
|
2979
3056
|
if (bridge.answering && source) {
|
|
2980
3057
|
const assessment = assessPanel(bridge.answering.health?.bundleHash, sha256(path8.join(installed, "jsx", "bundle.jsx")), {
|
|
2981
3058
|
installComplete
|
|
@@ -3049,6 +3126,14 @@ function buildNextSteps(checks, ready, bridgeTimedOut = false) {
|
|
|
3049
3126
|
"Nothing about the install is wrong; setup_panel would change nothing here."
|
|
3050
3127
|
];
|
|
3051
3128
|
}
|
|
3129
|
+
const token = by("bridgeToken");
|
|
3130
|
+
if (token && token.ok === false) {
|
|
3131
|
+
return [
|
|
3132
|
+
`Do not restart After Effects and do not run setup_panel \u2014 the panel is running and answering. ${token.detail}.`,
|
|
3133
|
+
token.fix,
|
|
3134
|
+
"Until the token is readable every tool call is refused before it reaches After Effects, so nothing in the user's project is being changed or half-changed."
|
|
3135
|
+
];
|
|
3136
|
+
}
|
|
3052
3137
|
const installSound = by("panelInstalled")?.ok !== false && by("panelUpToDate")?.ok !== false && by("panelDependencies")?.ok !== false && by("cepDebugMode")?.ok !== false;
|
|
3053
3138
|
if (bridgeTimedOut && installSound) {
|
|
3054
3139
|
const out = [
|
|
@@ -3279,7 +3364,7 @@ var GUIDES = [
|
|
|
3279
3364
|
{
|
|
3280
3365
|
name: "ae-setup",
|
|
3281
3366
|
description: "Diagnose and repair the connection between the AE MCP tools and After Effects \u2014 panel not installed, AE not running, Adobe debug preference off, bridge not responding, bridge on the wrong port, CEP refusing the panel's signature. Load when an After Effects tool reports it cannot reach AE, or when the user is setting this up for the first time.",
|
|
3282
|
-
body: '# Getting After Effects connected\n\nThe tools talk to a small panel that runs **inside** After Effects. Three things must be true for that to work: the panel is installed, Adobe is willing to load it, and AE is open.\n\nAssume the person you are helping is a motion designer, not a developer. They should never need to open a terminal \u2014 you have tools for all of this.\n\n## Always start with check_setup\n\n`check_setup` is read-only and safe to call at any time. It returns a `checks` array and a `nextSteps` list already written in plain language.\n\n**Relay `nextSteps` to the user directly.** Do not paraphrase it into jargon, and do not invent steps it did not mention.\n\n## A timeout is not a disconnection\n\nBefore you start any repair, work out which of **four** failures you have. They\nread alike and their remedies contradict each other:\n\n- **Did not answer in time** \u2014 something is listening; it is just too busy to\n reply. After Effects is single-threaded, so a long script or a modal dialog\n waiting for a click blocks it completely. Nothing is broken and nothing needs\n installing. The call **did** reach After Effects and may still be running, so\n do not re-send it.\n- **Cannot reach** \u2014 nothing is listening on the port the server tried. That\n is the case the repair path below is for \u2014 *unless* the next case applies,\n so read the message to the end: it now says which other ports it looked at\n and what it found there.\n- **Cannot reach on port X, while `check_setup` reports the panel on port Y**\n \u2014 the server\'s idea of the port went stale, not the panel. The panel is\n running and answering; the server was started from a port file that no\n longer described it. `check_setup` reports this as its own check,\n `portAgreement`, with both numbers. Retry the failed call: the server\n re-checks the port whenever a call is refused and switches to the port that\n answers by itself. If it still fails, reconnect the MCP server (restart the\n client\'s connection to it, not After Effects). **Never restart After Effects\n for this** \u2014 something is listening, and a restart costs the user their work\n in progress for nothing.\n- **Waited behind another op for the write queue and was dropped** \u2014 the panel\n is fine and this call never left the server, so nothing in the project was\n changed. Writes are serialized so that they land in the order the agent issued\n them and nothing drops into the middle of work the user asked for as one\n thing; something in front is taking a very long time, usually a long\n `run_batch`. Re-sending **is** safe here, once the work in\n front has finished; this is the one of the four where it is. Find out what is\n in front with `get_job` or `await_job` \u2014 reads are never queued, so `list_`\n and `get_` calls still answer.\n\nThe message itself tells you which one you have; the queue error says in as many\nwords that nothing was written. Never collapse them into "the bridge is playing\nup", because "re-send it", "do not re-send it" and "do not restart it" are three\ndifferent answers.\n\nOn a timeout, `check_setup` says so itself: `bridgeReachable` reports that the port accepted the connection but did not answer in time, and `nextSteps` tells you to wait. Follow it. Re-running `setup_panel` or restarting After Effects here costs the user their work-in-progress for nothing, and both are the wrong move. Poll `check_setup` for about a minute; it usually clears on its own.\n\nTwo things to ask about while waiting: whether a dialog is sitting behind another window in After Effects, and \u2014 on macOS \u2014 whether they have switched to another desktop. Calls have been reported to stall while the user is on a different Space and to complete as soon as they come back.\n\nA **full** write queue is a fifth message and a different instruction again:\ntoo many calls are already waiting, so stop issuing writes and let it drain. If\nyou have that much independent work, send it as one `run_batch` \u2014 one\nExtendScript pass and one place in the queue, instead of dozens of each.\n\n## Install before they open After Effects, if you still can\n\nThe panel loads at launch and only at launch. So the order matters, and it is\nthe opposite of what people assume:\n\n- **After Effects is closed** \u2014 install now. When they open it, the panel is\n simply there. No restart, nothing to ask for. This is the good path, and on a\n first-time setup you can usually get it.\n- **After Effects is open** \u2014 install, then they have to quit and reopen it.\n Unavoidable, but worth avoiding: if they have not opened AE yet in this\n conversation, do the install *first* and tell them to open it after.\n\n`check_setup` reports `afterEffectsRunning`, so you always know which case you\nare in before you say anything.\n\n## The repair path\n\n1. **`check_setup`** \u2014 find out what is actually wrong.\n2. **`setup_panel`** \u2014 if the panel is missing or out of date. Tell the user what it will do *before* you call it: it copies the panel into their Adobe extensions folder and switches on two Adobe preferences \u2014 the one that permits unsigned panels, and CEP\'s own log (only where it was never set), so that if AE ever refuses to load the panel the reason is recorded. All of it is user-level and reversible. One caution: if `check_setup` showed the install as **self-signed**, `setup_panel` replaces it with an unsigned copy and the signing has to be done again \u2014 say so before calling it.\n3. **Get the panel loaded.** If AE was closed, ask them to open it. If it was already open, ask them to quit and reopen it. You cannot do either for them.\n4. **`check_setup`** again to confirm.\n\n## What the individual failures mean\n\n| Check | Meaning when it fails |\n|---|---|\n| `platform` | Not macOS or Windows. After Effects only runs on those two, so there is nothing to fix. |\n| `panelAssetsPresent` | The server package is incomplete \u2014 it needs reinstalling. |\n| `cepDebugMode` | Adobe refuses to load unsigned panels until this preference is on. `setup_panel` sets it. |\n| `panelInstalled` | The panel is not in the Adobe extensions folder yet. `setup_panel` installs it. When it passes, the detail also says whether the install is **self-signed** \u2014 see the last section. |\n| `panelUpToDate` | The files on disk are older than this server. Run `setup_panel`. A self-signed install\'s extra files (`META-INF/`, `mimetype`) do not count as differences. |\n| `panelRunningCurrent` | AE is *running* an older panel than these tools ship. This is the one that predicts whether calls will actually work \u2014 `panelUpToDate` can pass while this fails, for the whole window between installing an update and restarting AE. |\n| `afterEffectsRunning` | AE is closed. If the panel also needs installing, install it now and then ask them to open AE \u2014 that saves a restart. |\n| `bridgeReachable` | Everything is installed but the panel isn\'t answering **on any port** \u2014 it asks the default port and the port file\'s port both, and reports whichever answers. Read the detail: if a port **timed out**, After Effects is busy and you should wait, not restart. If nothing is listening anywhere, one restart of AE is reasonable, because the panel loads only at launch; if that has already been tried, read `panelSignature` and `nextSteps` before suggesting a second. |\n| `portAgreement` | The panel answers, but on a different port from the one tool calls go to. The server is stale, the panel is fine. Retry the call, or reconnect the MCP server. Never restart After Effects for this. |\n| `panelSignature` | CEP\'s own log says it refused to load the panel because its signature failed verification \u2014 even with `cepDebugMode` on. An Adobe CEP 12 bug, seen on Windows. No restart and no reinstall helps; `nextSteps` carries the self-signing fix. When it *passes* on a silent bridge, its `evidence` says whether the log was clean or there was no log to read. |\n\n## The reboot case\n\n`cepDebugMode` is an Adobe preference that, on some macOS builds, only takes effect after a **restart of the Mac** \u2014 not just of After Effects. If `setup_panel` reports `rebootRecommended: true` and restarting AE alone did not fix it, ask the user to reboot once. This is a one-time cost, never needed again.\n\n## When a tool says the panel is out of date\n\nYou may get an error saying the panel is older than these tools, or that it does\nnot recognise an op. That is a version mismatch, not a broken tool, and the\nmessage tells you which of the two fixes applies:\n\n- **"updated on disk \u2026 still running the previous version"** \u2014 `setup_panel` has\n already done its part. Only a restart of After Effects will help; running it\n again will not.\n- **anything else** \u2014 run `setup_panel`, then get AE restarted.\n\nEither way, do not retry the failed call until the user confirms AE has\nrestarted. Say it as a version mismatch in plain language, not as a failure:\ntheir tools moved ahead of the panel, and it takes a restart to catch up.\n\n## The port, and why it is not the port file\n\nThe panel binds port 7777. On one machine nothing else ever uses it, so if\nthe port is taken when the panel starts, the holder is a previous copy of the\npanel itself \u2014 a leftover from a relaunch, or a second panel window. The panel\ndoes not move to another port in that case: it says so in its own status\n("waiting \u2014 port 7777 is held by another AE MCP panel"), and retries until the\nold one exits, then takes over with no restart. If a user reports that status\nand it does not clear, the leftover process is still running; quitting After\nEffects fully and reopening it clears it.\n\nThe panel moves only when something that is *not* an AE MCP panel holds the\nport, and it logs which port it chose and why. People who really run two\nAfter Effects instances at once can opt back into moving past their own panel\nwith a small file the panel reads at launch,\n`~/.engineroom-ae-mcp/config.json` (`%USERPROFILE%\\.engineroom-ae-mcp\\config.json`\non Windows): `{ "port": 7777, "allowPortWalk": true }`. The server is told\nwhich port to use with `AE_MCP_PORT`, and with that set it never looks\nelsewhere.\n\nWhatever the panel bound goes in the port file beside that config. The server\nreads it once at startup as a hint and no more: the socket is the authority,\nand when a call is refused the server asks 7777 and the file\'s port which one\nactually answers.\n\n## The panel is installed, AE is open, and nothing is listening\n\nIf every check passes except `bridgeReachable`, After Effects has already been\nrestarted once since the install, and nothing appears when the user opens\n**Window > Extensions > AE MCP Bridge** \u2014 no window, no error \u2014 then the\nlikely cause is CEP refusing the panel\'s signature despite `cepDebugMode`\nbeing on. It is an Adobe CEP 12 bug, seen on Windows with After Effects 2026,\nand nothing about the install is wrong. Do not send the user round a third\nrestart.\n\n`check_setup` reads CEP\'s own log and reports `panelSignature` when it finds\nthe refusal. If there is no log to read, `nextSteps` says how to turn CEP\nlogging on (one command, which it spells out; `setup_panel` sets it on new\ninstalls), after which one relaunch of AE and one more `check_setup` gives\na definite answer.\n\nThe fix is to sign the installed panel folder with Adobe\'s own `ZXPSignCmd`\nand unzip the result back over the folder in place. `nextSteps` carries the\nexact commands. Two things to tell the user: the signing survives updates to\nthe server but **not** a reinstall \u2014 `setup_panel` puts an unsigned copy back\nand warns when it does \u2014 and `check_setup` keeps reporting a signed install as\nup to date, so the extra files it adds are nothing to worry about.\n\n## If it still will not connect\n\nAsk the user to open **Window > Extensions > AE MCP Bridge** inside After Effects. That panel shows its own status and a log, and will say whether it started, which port it took, whether it is waiting for a port another panel holds, or what error it hit. Have them read it back to you.\n\nA common cause is a stale install: the panel loaded an older script bundle than the server expects. `check_setup`\'s `panelUpToDate` catches that \u2014 the fix is `setup_panel` followed by an AE restart.'
|
|
3367
|
+
body: '# Getting After Effects connected\n\nThe tools talk to a small panel that runs **inside** After Effects. Three things must be true for that to work: the panel is installed, Adobe is willing to load it, and AE is open.\n\nAssume the person you are helping is a motion designer, not a developer. They should never need to open a terminal \u2014 you have tools for all of this.\n\n## Always start with check_setup\n\n`check_setup` is read-only and safe to call at any time. It returns a `checks` array and a `nextSteps` list already written in plain language.\n\n**Relay `nextSteps` to the user directly.** Do not paraphrase it into jargon, and do not invent steps it did not mention.\n\n## A timeout is not a disconnection\n\nBefore you start any repair, work out which of **five** failures you have. They\nread alike and their remedies contradict each other:\n\n- **Did not answer in time** \u2014 something is listening; it is just too busy to\n reply. After Effects is single-threaded, so a long script or a modal dialog\n waiting for a click blocks it completely. Nothing is broken and nothing needs\n installing. The call **did** reach After Effects and may still be running, so\n do not re-send it.\n- **Cannot reach** \u2014 nothing is listening on the port the server tried. That\n is the case the repair path below is for \u2014 *unless* the next case applies,\n so read the message to the end: it now says which other ports it looked at\n and what it found there.\n- **Cannot reach on port X, while `check_setup` reports the panel on port Y**\n \u2014 the server\'s idea of the port went stale, not the panel. The panel is\n running and answering; the server was started from a port file that no\n longer described it. `check_setup` reports this as its own check,\n `portAgreement`, with both numbers. Retry the failed call: the server\n re-checks the port whenever a call is refused and switches to the port that\n answers by itself. If it still fails, reconnect the MCP server (restart the\n client\'s connection to it, not After Effects). **Never restart After Effects\n for this** \u2014 something is listening, and a restart costs the user their work\n in progress for nothing.\n- **Refused: the bridge token did not match** \u2014 the panel is running and\n answered straight away, and what it answered was no. The call never reached\n After Effects, so nothing in the project changed. The bridge requires a token\n because it is an HTTP server on `127.0.0.1` and a web page in the user\'s\n browser can reach that; the panel writes one beside its port file each time it\n binds. **Do not restart After Effects, do not run `setup_panel`, and do not\n re-send the call** \u2014 it will be refused identically until the cause is fixed.\n Run `check_setup`: its `bridgeToken` check says whether the token file is\n readable, and `nextSteps` carries the rest. The usual causes are an MCP server\n older than the running panel (update it and reconnect) or a server that cannot\n see the file the panel wrote, which happens when the client runs it sandboxed\n or as another user.\n- **Waited behind another op for the write queue and was dropped** \u2014 the panel\n is fine and this call never left the server, so nothing in the project was\n changed. Writes are serialized so that they land in the order the agent issued\n them and nothing drops into the middle of work the user asked for as one\n thing; something in front is taking a very long time, usually a long\n `run_batch`. Re-sending **is** safe here, once the work in\n front has finished; this is the one of the four where it is. Find out what is\n in front with `get_job` or `await_job` \u2014 reads are never queued, so `list_`\n and `get_` calls still answer.\n\nThe message itself tells you which one you have; the queue error says in as many\nwords that nothing was written. Never collapse them into "the bridge is playing\nup", because "re-send it", "do not re-send it" and "do not restart it" are three\ndifferent answers.\n\nOn a timeout, `check_setup` says so itself: `bridgeReachable` reports that the port accepted the connection but did not answer in time, and `nextSteps` tells you to wait. Follow it. Re-running `setup_panel` or restarting After Effects here costs the user their work-in-progress for nothing, and both are the wrong move. Poll `check_setup` for about a minute; it usually clears on its own.\n\nTwo things to ask about while waiting: whether a dialog is sitting behind another window in After Effects, and \u2014 on macOS \u2014 whether they have switched to another desktop. Calls have been reported to stall while the user is on a different Space and to complete as soon as they come back.\n\nA **full** write queue is a fifth message and a different instruction again:\ntoo many calls are already waiting, so stop issuing writes and let it drain. If\nyou have that much independent work, send it as one `run_batch` \u2014 one\nExtendScript pass and one place in the queue, instead of dozens of each.\n\n## Install before they open After Effects, if you still can\n\nThe panel loads at launch and only at launch. So the order matters, and it is\nthe opposite of what people assume:\n\n- **After Effects is closed** \u2014 install now. When they open it, the panel is\n simply there. No restart, nothing to ask for. This is the good path, and on a\n first-time setup you can usually get it.\n- **After Effects is open** \u2014 install, then they have to quit and reopen it.\n Unavoidable, but worth avoiding: if they have not opened AE yet in this\n conversation, do the install *first* and tell them to open it after.\n\n`check_setup` reports `afterEffectsRunning`, so you always know which case you\nare in before you say anything.\n\n## The repair path\n\n1. **`check_setup`** \u2014 find out what is actually wrong.\n2. **`setup_panel`** \u2014 if the panel is missing or out of date. Tell the user what it will do *before* you call it: it copies the panel into their Adobe extensions folder and switches on two Adobe preferences \u2014 the one that permits unsigned panels, and CEP\'s own log (only where it was never set), so that if AE ever refuses to load the panel the reason is recorded. All of it is user-level and reversible. One caution: if `check_setup` showed the install as **self-signed**, `setup_panel` replaces it with an unsigned copy and the signing has to be done again \u2014 say so before calling it.\n3. **Get the panel loaded.** If AE was closed, ask them to open it. If it was already open, ask them to quit and reopen it. You cannot do either for them.\n4. **`check_setup`** again to confirm.\n\n## What the individual failures mean\n\n| Check | Meaning when it fails |\n|---|---|\n| `platform` | Not macOS or Windows. After Effects only runs on those two, so there is nothing to fix. |\n| `panelAssetsPresent` | The server package is incomplete \u2014 it needs reinstalling. |\n| `cepDebugMode` | Adobe refuses to load unsigned panels until this preference is on. `setup_panel` sets it. |\n| `panelInstalled` | The panel is not in the Adobe extensions folder yet. `setup_panel` installs it. When it passes, the detail also says whether the install is **self-signed** \u2014 see the last section. |\n| `panelUpToDate` | The files on disk are older than this server. Run `setup_panel`. A self-signed install\'s extra files (`META-INF/`, `mimetype`) do not count as differences. |\n| `panelRunningCurrent` | AE is *running* an older panel than these tools ship. This is the one that predicts whether calls will actually work \u2014 `panelUpToDate` can pass while this fails, for the whole window between installing an update and restarting AE. |\n| `afterEffectsRunning` | AE is closed. If the panel also needs installing, install it now and then ask them to open AE \u2014 that saves a restart. |\n| `bridgeReachable` | Everything is installed but the panel isn\'t answering **on any port** \u2014 it asks the default port and the port file\'s port both, and reports whichever answers. Read the detail: if a port **timed out**, After Effects is busy and you should wait, not restart. If nothing is listening anywhere, one restart of AE is reasonable, because the panel loads only at launch; if that has already been tried, read `panelSignature` and `nextSteps` before suggesting a second. |\n| `bridgeToken` | The panel answers but requires a token this server cannot produce, so every call is refused before it reaches After Effects. The panel is fine: do not restart it and do not run `setup_panel`. The detail names the file the token should be in; `nextSteps` says what to do when it is not there. |\n| `portAgreement` | The panel answers, but on a different port from the one tool calls go to. The server is stale, the panel is fine. Retry the call, or reconnect the MCP server. Never restart After Effects for this. |\n| `panelSignature` | CEP\'s own log says it refused to load the panel because its signature failed verification \u2014 even with `cepDebugMode` on. An Adobe CEP 12 bug, seen on Windows. No restart and no reinstall helps; `nextSteps` carries the self-signing fix. When it *passes* on a silent bridge, its `evidence` says whether the log was clean or there was no log to read. |\n\n## The reboot case\n\n`cepDebugMode` is an Adobe preference that, on some macOS builds, only takes effect after a **restart of the Mac** \u2014 not just of After Effects. If `setup_panel` reports `rebootRecommended: true` and restarting AE alone did not fix it, ask the user to reboot once. This is a one-time cost, never needed again.\n\n## When a tool says the panel is out of date\n\nYou may get an error saying the panel is older than these tools, or that it does\nnot recognise an op. That is a version mismatch, not a broken tool, and the\nmessage tells you which of the two fixes applies:\n\n- **"updated on disk \u2026 still running the previous version"** \u2014 `setup_panel` has\n already done its part. Only a restart of After Effects will help; running it\n again will not.\n- **anything else** \u2014 run `setup_panel`, then get AE restarted.\n\nEither way, do not retry the failed call until the user confirms AE has\nrestarted. Say it as a version mismatch in plain language, not as a failure:\ntheir tools moved ahead of the panel, and it takes a restart to catch up.\n\n## The port, and why it is not the port file\n\nThe panel binds port 7777. On one machine nothing else ever uses it, so if\nthe port is taken when the panel starts, the holder is a previous copy of the\npanel itself \u2014 a leftover from a relaunch, or a second panel window. The panel\ndoes not move to another port in that case: it says so in its own status\n("waiting \u2014 port 7777 is held by another AE MCP panel"), and retries until the\nold one exits, then takes over with no restart. If a user reports that status\nand it does not clear, the leftover process is still running; quitting After\nEffects fully and reopening it clears it.\n\nThe panel moves only when something that is *not* an AE MCP panel holds the\nport, and it logs which port it chose and why. People who really run two\nAfter Effects instances at once can opt back into moving past their own panel\nwith a small file the panel reads at launch,\n`~/.engineroom-ae-mcp/config.json` (`%USERPROFILE%\\.engineroom-ae-mcp\\config.json`\non Windows): `{ "port": 7777, "allowPortWalk": true }`. The server is told\nwhich port to use with `AE_MCP_PORT`, and with that set it never looks\nelsewhere.\n\nWhatever the panel bound goes in the port file beside that config. The server\nreads it once at startup as a hint and no more: the socket is the authority,\nand when a call is refused the server asks 7777 and the file\'s port which one\nactually answers.\n\n## The panel is installed, AE is open, and nothing is listening\n\nIf every check passes except `bridgeReachable`, After Effects has already been\nrestarted once since the install, and nothing appears when the user opens\n**Window > Extensions > AE MCP Bridge** \u2014 no window, no error \u2014 then the\nlikely cause is CEP refusing the panel\'s signature despite `cepDebugMode`\nbeing on. It is an Adobe CEP 12 bug, seen on Windows with After Effects 2026,\nand nothing about the install is wrong. Do not send the user round a third\nrestart.\n\n`check_setup` reads CEP\'s own log and reports `panelSignature` when it finds\nthe refusal. If there is no log to read, `nextSteps` says how to turn CEP\nlogging on (one command, which it spells out; `setup_panel` sets it on new\ninstalls), after which one relaunch of AE and one more `check_setup` gives\na definite answer.\n\nThe fix is to sign the installed panel folder with Adobe\'s own `ZXPSignCmd`\nand unzip the result back over the folder in place. `nextSteps` carries the\nexact commands. Two things to tell the user: the signing survives updates to\nthe server but **not** a reinstall \u2014 `setup_panel` puts an unsigned copy back\nand warns when it does \u2014 and `check_setup` keeps reporting a signed install as\nup to date, so the extra files it adds are nothing to worry about.\n\n## If it still will not connect\n\nAsk the user to open **Window > Extensions > AE MCP Bridge** inside After Effects. That panel shows its own status and a log, and will say whether it started, which port it took, whether it is waiting for a port another panel holds, or what error it hit. Have them read it back to you.\n\nA common cause is a stale install: the panel loaded an older script bundle than the server expects. `check_setup`\'s `panelUpToDate` catches that \u2014 the fix is `setup_panel` followed by an AE restart.'
|
|
3283
3368
|
},
|
|
3284
3369
|
{
|
|
3285
3370
|
name: "after-effects",
|
|
@@ -3334,7 +3419,7 @@ var GUIDES = [
|
|
|
3334
3419
|
{
|
|
3335
3420
|
name: "whats-new",
|
|
3336
3421
|
description: "What changed in the After Effects tools, one section per release, newest first \u2014 each entry leads with the rule as it now stands and quotes the old rule it replaces, so a project's own notes can be found and corrected. Read it when a call behaves differently from what you expected, when the user says a tool used to do something else, or with `since` to see only the releases after the one this project last absorbed.",
|
|
3337
|
-
body: "<!--\nFormat contract. `ae_guide({topic: \"whats-new\", since: \"<version>\"})` filters\nthis file by machine and the absorb-release prompt greps project docs with it:\n\n- One `## <semver>` section per release, newest first. No other `##` heading\n below the first release heading; group inside a release with `###`.\n- Everything above the first `## <semver>` is the preamble. It is returned with\n every filtered read, so it stays short.\n- An entry is one bullet. It opens with a **bold phrase stating the rule as it\n now stands**, present tense, then the reason and what changed.\n- A bullet whose rule replaces something a project's own docs may say carries\n one or more indented `supersedes:` lines directly under it, each quoting the\n OLD rule in the words a project doc would have used. They are grep targets:\n short, literal, no markdown.\n-->\n\n# What changed\n\nRead this when something behaves differently from what you expected, or when the\nuser tells you a tool used to work another way. It is the release notes an agent\nneeds rather than the ones a human reads: one section per release, newest first.\nEvery entry leads with the rule as it now stands; a `supersedes:` line under it\nquotes the old rule in the words a project's own notes would have used, so those\nnotes can be searched for and corrected. Pass `since` \u2014 as in\n`ae_guide({topic: \"whats-new\", since: \"0.4.0\"})` \u2014 to get only the releases\nafter that version; the absorb-release prompt is the flow that applies them to\na project once and records the version so the history is not read again.\n\n**When this topic and a tool's own schema disagree, believe the schema.** It is\nrefreshed at each release and a build in between can be ahead of it.\n\n**The panel is versioned separately from the tools, and it does not update\nitself.** It loads at After Effects launch and only at launch, so after any\nupgrade the tools can be newer than the code answering them. When that happens a\ncall returns a remediation message rather than a confusing `Unknown op` \u2014 relay\nit. The distinction worth reading carefully: if it says the panel is *installed*\nbut the running one is older, running `setup_panel` again changes nothing and\nonly quitting and reopening After Effects will.\n\n## 0.5.0\n\nThe release where the guidance split into a core and references, the journal\nstarted coming to you instead of you going to it, and three more things After\nEffects does silently \u2014 a batch's progress no client ever saw, a broken\nexpression reported as ok, a justification that did not land \u2014 became\nvisible. Two results changed shape; those are first, because a caller reading\nthe old shape gets nothing rather than an error.\n\n### Two results changed shape\n\n- **`find_layers` returns `{matches, count, compsSearched, included}`, and a\n match is id, index, name, sourceType, compId and compName unless `include`\n asks for more.** It used to answer a bare array of full layer summaries \u2014\n flags, timing, parent, on every match \u2014 with no way to bound it, so nine name\n matches cost about two thousand tokens, re-sent on every later request.\n `include` takes the `list_layers` section names (`flags`, `timing`,\n `parent`); unlike `list_layers`, omitting it means the bounded record, not\n everything, and the result echoes `included`. A caller that read the old\n array reads `matches` now.\n supersedes: find_layers cannot be bounded\n supersedes: find_layers returns an array of layer summaries\n- **`place_audio_cues` results carry one small entry per cue \u2014 `layerId`,\n `name`, `time`, plus what an option changed \u2014 and `dryRun` answers counts,\n `wouldImport`, `wouldReuse` and the failing cues only.** The per-cue\n `levelDb`, `label`, `index` and `itemId` were echoes of the request, and a\n seven-cue dry run that found nothing wrong cost about 1,500 tokens of them.\n Read a level off the layer, not out of the result.\n supersedes: place_audio_cues echoes the levelDb written for every cue\n supersedes: dryRun on place_audio_cues returns the resolved cue list\n\n### Three silences, now reported\n\n- **`run_batch` over 500 ops returns its `jobId` before the first chunk runs,\n and progress is delivered on `await_job` \u2014 send that call with a progress\n token and `notifications/progress` arrive while it waits.** Nothing can ride\n on the `run_batch` call itself: its response is already back, and a\n notification sent on a request's token after its response is one every\n spec-compliant client has already stopped listening for \u2014 which is where\n every progress message for a long batch went before 0.5.0, seen by no real\n client. `get_job` polls the same state without a token.\n supersedes: run_batch progress arrives on the run_batch call\n supersedes: pass a progressToken on run_batch to see its progress\n- **`set_expression` throws when After Effects cannot evaluate the expression,\n and `get_expression` returns `expressionError`.** Assigning an expression\n succeeds whatever the text says, and AE's only report is a warning banner in\n its interface; the tool now evaluates the property after the write and fails\n with AE's message and the property path. The expression stays written \u2014 fix\n it and call again, or `clear_expression`. `toggle_expression({enabled:\n true})` verifies the same way. A non-empty `expressionError` means the\n property is not being driven by that expression, whatever `enabled` says.\n supersedes: set_expression reports success for a broken expression\n supersedes: ask the user to check the property for an expression warning banner\n- **`set_text` and `create_text_layer` read the justification back after every\n write, re-assert it once if it moved, and throw naming expected and actual\n if it still disagrees.** On 26.3 a `set_text` that changed only `text` has\n re-centred a left-aligned layer. Both results carry `justification` as a\n name and `justificationReasserted`; a failing `create_text_layer` removes\n the layer it made, so nothing is left without an id. An unknown\n justification name is refused rather than ignored.\n supersedes: set_text leaves justification alone when you do not pass it\n supersedes: verify alignment by sourceRect after every set_text\n\n### Deleting a comp, and what it leaves behind\n\n- **`delete_comp` reports `unusedSolidsLeft`, and `purgeUnusedSolids: true`\n removes the comp's own now-unused solids in the same undo step.** A solid's\n source is a footage item in the Solids folder, and deleting the comp only\n ever removed the layer, so a session that built and discarded comps left\n hundreds behind. Only the deleted comp's own solids are considered; one\n another comp still uses is kept and reported in `keptSolids` with that comp.\n supersedes: delete_comp removes a comp and its solids\n supersedes: sweep the Solids folder by hand after deleting comps\n- **`purge_unused_footage` sweeps the project: solids only by default,\n `solidsOnly: false` for every unused footage item, `folderId` to scope it,\n `dryRun: true` to list `wouldRemove` without an undo step.** Never a comp or\n a folder \u2014 a nested comp nothing uses is not footage.\n supersedes: remove unused footage with run_jsx\n\n### Sound\n\n- **`place_audio_cues` takes `loop`, `fadeIn`/`fadeOut` and `stretch` per cue,\n and `fadeFloorDb` per call.** `loop: true` time-remaps with a wrap-around\n expression, keeps AE's two default keys and re-asserts the out point; fades\n keyframe Audio Levels at the layer's real in and out between the floor\n (default `-48`) and the cue's level, and a fade that does not fit is refused\n by cue index; `stretch` is a percentage with the start time and trims\n re-asserted after it. A stretched loop bakes the factor into its expression,\n so a later hand change to stretch leaves the loop at the old rate. All of it\n is validated before anything is created and rolled back with the rest.\n supersedes: loop a bed with run_jsx and timeRemap after place_audio_cues\n supersedes: fade audio in run_jsx after placing the cues\n\n### The journal comes to you\n\n- **A failed call ends with `Known from earlier sessions: <scope:id> \u2014 <title>`\n for up to three entries matching its tool and error text, and the\n `list_known_issues({id})` call that opens the first.** Nobody reads the\n journal ahead of time any more; the pointer arrives with the failure, and a\n match moves the entry's `lastSeen`.\n supersedes: read list_known_issues before nontrivial work\n supersedes: check list_known_issues before guessing\n- **`log_issue` takes `errorText` and `kind`, and the same tool with the same\n error text extends the existing entry even under a new title** \u2014 the result\n says `mergedBy: \"errorText\"` and keeps the existing title. `kind:\n \"ae-quirk\"` marks a permanent After Effects behaviour; the default\n `tool-bug` is presumed fixed once last seen on an older server. Entries\n record `firstVersion` and `lastVersion`.\n supersedes: call list_known_issues first so you reuse the title when logging\n- **Entries archive themselves \u2014 unseen for 30 days, or a `tool-bug` last seen\n on an older server \u2014 and `archive_issue({id, reason})` retires one by\n hand.** Archived entries are hidden from the index and counted in\n `archivedCount`; `includeArchived: true` lists them, a read by `id` always\n works, and a fresh `log_issue` reopens one with `reopened: true`. Index\n lines carry `kind`, `lastSeen` and `lastVersion` and no summary sentence,\n sorted by tool match and then most recently seen.\n supersedes: the journal only grows, entries are never removed\n- **The report flow checks the tracker before drafting.** An entry a closed\n report already covers is archived with that URL; one an open report covers\n is marked reported. Bodies go through a file, and \"all of them\" is one\n approval.\n\n### Reading the history once\n\n- **`ae_guide({topic: \"whats-new\", since: \"<version>\"})` returns only the\n releases after that version, opening with the server's own version**, and\n this file has a shape a machine can filter: one section per release, every\n entry leading with the rule as it now stands, `supersedes:` lines under it\n quoting the old rule. The absorb-release prompt (`/absorb-release` where\n prompts are commands) applies the delta to a project's notes once and\n records the version in the `Tools version last absorbed:` line\n `init_project` writes into AGENTS.md.\n supersedes: read the whole whats-new guide after every update\n\n### The guidance\n\n- **The `after-effects` topic is a core of what silently produces wrong output\n on any task, and each subject is its own topic** \u2014 `animation`, `shapes`,\n `text`, `assembly`, `extendscript-gotchas`, `sound`, `mogrt-and-footage`,\n `issue-journal`, `whats-new` \u2014 twelve `ae_guide` topics in all with\n `style-guide` and `ae-setup`. In Claude Code and claude.ai the references\n are the files in the `after-effects` skill's `references/` folder, opened\n only when the core points at them. Keyframes, easing and rigging are in\n `animation`; raw scripting, with the ease-arity table, in\n `extendscript-gotchas` and nowhere else.\n supersedes: the after-effects skill covers keyframes text and shapes in one file\n supersedes: the ease arity table is in the main guide\n- **Four more After Effects facts, measured on 26.3, in `extendscript-gotchas`:**\n removing the last Time Remap key hides the property, so edit AE's two\n default keys in place or put an expression over them, and re-assert\n `outPoint` after enabling remapping; `TextDocument.justification` written\n from a script lands as RIGHT when handed CENTER, so set it through\n `set_text`; `OutputModule.setSettings` rejects Format, Channels, Depth and\n Color as read-only, so build an output template in the UI and apply it by\n name; and the ease arity is not derivable from a value's dimension, so\n never size a `KeyframeEase` array by hand.\n supersedes: clear the Time Remap keys before adding your own\n supersedes: set justification on the TextDocument from run_jsx\n supersedes: set the output module codec with setSettings from run_jsx\n- **The references carry the craft rules a project would otherwise keep in\n its own notes** (issue #103). `extendscript-gotchas`: a `.value` read at\n the playhead, a `setValue` that throws with the value applied on a comp not\n in a viewer, effects addressed by a name that is not unique and references\n that go stale when one is added, a track matte that switches its matte's\n video off, `replaceSource` keeping the old timing, `item.typeName`, Time\n Remap keys added before the defaults are removed. `animation`: a held pose\n as a hold key, a waypoint that must not stop, parenting at rest and before\n the pop with the parent-space arithmetic, a mirrored parent mirroring its\n text, expression text built from numbers, a shake as an expression over\n keys. `assembly`: a precomp clamped at its source, reversing with time\n remap, controls shared across every instance, a resized comp shifting what\n nests it, collapse transformations and motion blur across a nesting,\n replacing a nested comp, and verifying an assembly, a small detail, a\n doubtful tile and a cut. `sound`: peak for a one-shot and RMS for a bed\n with the onset on the beat, a trim clamped at the file's end, a bed that\n follows a move, a float WAV that will not place. `shapes`: one fill per\n group, the layer stack built back-to-front, a group's pivot, seams between\n shapes, centring a repeated run.\n\n### Connecting, and staying connected\n\n- **The server follows the panel's port.** A call refused on the port the\n server remembered makes it look again \u2014 7777 first, then the port file \u2014\n switch to whichever answers as the panel, and re-send the call once. Only a\n refused connection triggers this; a timed-out call reached After Effects and\n is never re-sent (issue #92). `AE_MCP_PORT` is a hard pin: with it set, no\n other port is ever tried.\n supersedes: reconnect the MCP server when ops fail on a port check_setup does not report\n- **`check_setup` reports the port that actually answers**, flags a stale\n port file, and carries a new `portAgreement` check. When calls go to one\n port and the panel answers on another, its advice is to retry or reconnect\n the MCP server. It never tells you to restart After Effects for that.\n supersedes: cannot reach the panel means nothing is listening so restart After Effects\n- **The panel waits instead of moving when an older copy of itself holds\n 7777.** It says so in its own window, names the holder, and takes the port\n over the moment that copy exits, with no restart. Two After Effects\n instances on one machine opt back into walking with `\"allowPortWalk\": true`\n in `~/.engineroom-ae-mcp/config.json`; the same file's `\"port\"` moves the\n panel. The port file only ever names a port that was really bound and is\n removed when the panel unloads.\n supersedes: the panel walks up to the next free port when 7777 is taken\n- **A panel that never loads on Windows gets a diagnosis, not a restart\n loop.** `check_setup` reads CEP's own log and, when CEP refused the panel's\n signature \u2014 an Adobe CEP 12 bug that ignores PlayerDebugMode \u2014 reports\n `panelSignature` with the steps to self-sign the installed panel with\n Adobe's ZXPSignCmd (issue #91). With no log yet, it gives the one command\n that turns CEP logging on. `setup_panel` now turns that logging on itself\n where it was never set, warns when it is about to replace a self-signed\n panel (`signedInstallOverwritten`, since the fresh copy needs signing\n again), and reports a signed panel as up to date rather than partial.\n supersedes: check_setup all green but the panel never loads means restart After Effects again\n- **`check_setup` under an `AE_MCP_PORT` pin still asks 7777 and the port\n file, and a pinned wrong port is reported as the pin, not as a dead panel.**\n The pin decides where ops go and is never walked past; the diagnosis says so,\n names the port the panel is really answering on, and tells you to change or\n unset `AE_MCP_PORT` and reconnect the MCP server \u2014 never to restart After\n Effects. A refused op under a pin says the same in its own message.\n\n### Found by the live pass\n\n- **`init_project` refuses the home directory and the filesystem root however\n they were arrived at, an explicit `dir` included.** Before this only the\n working-directory fallback was guarded, so `dir: \"~\"` resolved by a client,\n or `dir: \"/\"`, scaffolded `AGENTS.md` and a `renders/` folder into a home\n directory and reported success.\n- **`set_house_style` writes `house-style.md` with Unix line endings.**\n ExtendScript's default on macOS turned every line break into a bare carriage\n return: the tools read the file back correctly, so the round trip looked\n fine, but a designer opening the file in an editor saw one long line.\n\n## 0.4.0\n\nThe release where verifying a change stopped meaning reading the whole thing\nback \u2014 and where a live pass against After Effects 2026 found four things this\nserver had been claiming that were never true. Those are first, because an agent\nthat learned the old story will otherwise repeat it to a user.\n\n### Four things that were never true, and are now fixed\n\n- **`run_batch` is one undo step up to 500 ops; over 500 it is one step per\n chunk of 25, and `singleUndo: true` makes it one step up to 2000.** The\n one-step guarantee it shipped with before 0.4.0 did not exist on either path:\n After Effects discards an undo group opened in one script call and closed in\n another, so a 600-op batch landed as about six hundred separate steps while\n reporting one. Now up to 500 ops is genuinely one step; over 500 is one step\n per chunk of 25 \u2014 around 24 for 600 ops \u2014 with the measured count in\n `undoSteps` and a `note` saying it in words. `singleUndo: true` runs the batch\n in a single blocking call, which freezes AE's interface for the duration and\n reports no progress. Read `undoSteps` before you tell anyone how to undo the\n work.\n supersedes: run_batch is one undo step\n supersedes: a long run_batch is one undo step\n- **`transactional: true` stops at the first failure, rolls nothing back, and\n says so: `rolledBack: false` with the stop point named.** It never rolled\n anything back \u2014 it fired one menu-command Undo, and menu commands silently do\n nothing over this bridge; one Undo is one step, not a batch, in any case. The\n ops before the failure stay applied. Use `diff: true` to see what landed.\n supersedes: transactional: true rolls the batch back on failure\n- **`reorder_layer` works, and takes exactly one of `beforeLayerId`,\n `afterLayerId` or `toIndex`.** Every call it served before 0.4.0 threw\n `parent is not an INDEXED_GROUP`, because it used a shape-node method on a\n layer. Prefer the id forms, because this is the op that invalidates indexes;\n `toIndex` means the index the layer **ends up at**.\n supersedes: reorder_layer is broken, use run_jsx\n supersedes: reorder_layer throws parent is not an INDEXED_GROUP\n- **`downsample: 1` is full resolution, whatever the viewer's Resolution\n dropdown is set to.** Before 0.4.0 factor 1 skipped setting the comp's\n resolution at all, so it inherited the viewer's setting \u2014 on a comp a designer\n had left at Quarter, `downsample: 1` returned a quarter-size frame and\n `downsample: 2` returned one four times larger. The response was honest about\n the dimensions; the picture was not the one asked for. Factor 1 is now set\n explicitly like any other and restored afterwards.\n supersedes: downsample: 1 means the viewer's resolution\n supersedes: set the viewer to Full before a downsample: 1 screenshot\n\n### Two calls changed under you\n\n- **`create_shape_layer` spawns at `[0,0]`**, with the anchor at `[0,0]`, so the\n layer's coordinate space *is* the comp's and every vertex, rect position and\n path you add afterwards is in comp pixels. After Effects' own spawn point is\n the comp centre, which silently offsets a drawing authored in comp coordinates\n by half a frame \u2014 easy to miss on a downsampled screenshot. `position:\n \"center\"` restores AE's behaviour, and any `[x,y]` places the origin yourself.\n The result echoes what it ended up with.\n supersedes: a new shape layer spawns at the comp centre\n supersedes: shape paths are offset by half a frame, subtract the comp centre\n- **`get_house_style` returns a digest, not the document; `detail: \"full\"`\n returns the document.** The digest is a few hundred tokens: palette as named\n hexes, type, motion, layout, and a note naming anything it could not summarise.\n You need the full document before `set_house_style`, which replaces the file\n rather than patching it. An unstructured guide comes back `structured: false`\n with its opening text, which is an honest \"I could not read this as a spec\"\n rather than an empty answer.\n supersedes: do not call get_house_style, read the digest in these notes instead\n supersedes: get_house_style returns the whole style guide\n\n### Verify with a diff instead of a second read\n\n- **`snapshot_comp` and `diff_comp` fingerprint a comp and report what moved.**\n Fingerprint before a write, then ask: layers added, removed, renamed,\n retimed, re-parented, keyframe counts, expressions and effects gained or lost\n \u2014 and a count of the layers that did not move. Tens of tokens where two full\n reads were thousands. It does **not** record property values, expression\n text, effect parameters, masks or shape contents, so `changeCount: 0` means\n none of the recorded fields moved, not that the comp is identical.\n supersedes: verify a write by reading the comp back with list_layers and get_layer_full\n- **`diff: true` on `run_jsx` and `run_batch` does the same inside the call**,\n so there is no window between the write and the fingerprint \u2014 and on a\n failure the diff rides on the error, which is how you find where a\n half-applied script stopped.\n\n### Screenshots\n\n- **`screenshot_frame` takes `times` (2\u20136) and returns one tiled contact\n sheet**, each tile labelled with its own time, held to roughly the pixel\n budget of a single frame. Judging motion is one call now, not three: one\n image in your context instead of three, and one render request instead of\n three back-to-back ones, which is the pattern that provoked stale frames.\n `time` and `times` are mutually exclusive; there is no `times` on\n `screenshot_layer`.\n supersedes: screenshot each time separately to judge motion\n- **Three screenshot failures, each with its own remedy.** `Stale frame` (AE\n served an earlier render \u2014 wait, retry higher), `Corrupt frame` (the file is\n not a whole PNG; not a timeout \u2014 retry at downsample 6\u20138 or shoot the precomps\n separately), `Render timed out` (still rendering \u2014 wait before retrying, or a\n retry queues behind it). A frame that is genuinely all-transparent is still\n `empty: true` with no image. On a sheet, one bad tile is drawn as a marked\n block and named in `warning`; the rest of the sheet is good.\n supersedes: truncated PNG from screenshot_frame means retry it\n\n### run_jsx\n\n- **A `run_jsx` failure names the line of *your* script and prints its text**,\n and says so honestly when the number cannot be mapped rather than guessing.\n This was claimed once before it was true: until the 0.4.0 live pass every\n error said line 1 and printed line 1's text, with the real number only in the\n parenthetical after it, because AE reports `Error.start`/`Error.end` as `0`\n on every error rather than as the character offsets its documentation\n describes.\n supersedes: run_jsx line numbers are wrong, count from the wrapper\n supersedes: run_jsx reports line 1 for every error\n- **`diff: true` on `run_jsx` reaches After Effects.** The server was building\n a fresh argument object and dropping every field it did not enumerate, so the\n flag was discarded before the call left. The same flag on `run_batch` always\n worked, which is what hid it.\n supersedes: diff: true on run_jsx does nothing\n- **`scriptPath` and `libraries` keep a long script and its shared helpers out\n of the conversation entirely.** Libraries are inlined ahead of the script in\n the same scope, so their functions are callable from it; they are\n re-evaluated on every call, so keep them to declarations rather than to work.\n- **Helpers are in scope**, each wrapping a trap: `compById`, `layerById`,\n `walkProperty`, `addKeys`, `ease` (which sizes the KeyframeEase array using\n the same code `set_temporal_ease` uses, not a copy), `shape` (which lands at\n `[0,0]`), and `withoutUndoGroup`. The whole `OPS` table is callable too.\n\n### New tools, and one thing you no longer have to get right\n\n- **`duplicate_comp` returns the new comp id**, so you never look for the copy\n by name. Shallow by default like AE's own Duplicate \u2014 the copy's precomp\n layers point at the *same* nested comps \u2014 with `deep: true` for a real\n variant.\n supersedes: duplicate a comp with run_jsx and find the copy by name\n- **`place_audio_cues` scores a scene in one call**: a cue list becomes one\n audio layer each, imported once per file, named, trimmed, levelled in dB, in a\n single undo step, all-or-nothing, with a `dryRun`.\n supersedes: place audio with run_jsx, one layer at a time\n- **`set_temporal_ease` and `add_keyframe` size the ease array themselves.**\n Pass one `{influence, speed}` pair per side; the count that worked comes back\n as `easeDimensions`. The bare `parameter 2` failure is no longer yours to\n avoid.\n supersedes: set_temporal_ease fails with parameter 2, pass the right number of KeyframeEase objects by hand\n supersedes: set_temporal_ease fails on Scale, use run_jsx\n- **`export_mogrt` refuses an empty Essential Graphics panel up front**, naming\n the controller count. It used to attempt the export and then blame a modal\n dialog, which under the default dialog suppression is the one cause ruled out\n by construction. When an export does fail now, the message says what was\n checked and \u2014 if dialogs were suppressed \u2014 that the cause is genuinely\n unknown, because AE answers with a bare boolean and has no way to say why.\n supersedes: export_mogrt fails with a modal dialog when the comp has no Essential Graphics controllers\n\n### Underneath\n\n- **Writes are serialized, one at a time, for the whole session**, so two\n writes issued together run in the order you issued them and a long\n `run_batch` holds the lock until its last chunk lands \u2014 nothing else drops\n into the middle of work the user asked for as one thing. A call that waited\n says `queuedBehind` and `waitedMs`. Reads are never queued. There is a new\n diagnosis to tell apart from a bridge timeout: a call *dropped while waiting\n for the write queue* never reached After Effects, so re-sending it is safe \u2014\n which is the opposite of what a timeout means.\n supersedes: never issue two writes in the same turn, the order is not guaranteed\n- **The issue journal has two scopes.** `project` is this project's notes;\n `user` travels with the person across every project, so log tool and After\n Effects behaviour there. Ids are unique only within a journal, so open an\n entry with the qualified form the listing shows (`user:\u2026`).\n- **The guidance lives in `ae_guide` and the skill, not in what the server sends\n every session.** What the server sends is a short pointer rather than a\n summary, because it was resident in every request the user ever made. The\n narrative is `ae_guide({topic: \"after-effects\"})`, with `extendscript-gotchas`\n behind it for raw `run_jsx` and this topic for changes. In Claude Code and\n claude.ai the same text is the `after-effects` skill and the files in its\n `references/` folder \u2014 load one carrier, not both.\n- **Rigging is written down**, in the `animation` topic: opacity does not propagate\n through parenting, and a camera null is parented to while it is still at\n identity.\n\n## 0.3.1\n\n- **`get_layer_full` omits `ADBE Vector Materials Group` unless\n `shapeMaterials: true`.** That is the 48-property 3D extrusion model AE hangs\n off every vector group, which means nothing on a 2D shape layer. It was around\n three quarters of the bytes of a shape read: one 68px circle cost 4,400\n tokens, of which the geometry was about 40. `materialsOmitted` counts what was\n dropped; `shapeMaterials: true` brings it back for a genuinely extruded shape.\n A group Transform still at its creation values collapses to `atDefaults:\n true` the same way.\n supersedes: shape reads are huge, avoid get_layer_full on shape layers\n- **`shapeDetail: \"compact\"` returns one indented line per group** instead of\n the JSON tree \u2014 every name the write tools address a node by, with `[3 keys]`\n or `[expr]` marking the animated properties. Measured end to end on one real\n layer: 13,369 characters before 0.3.1, 3,052 once the materials block went,\n 643 compact. Reach for `\"full\"` only when you need exact values or keyframe\n indices.\n- **`run_jsx` never answers a bare `null`.** A script whose last statement is a\n bare expression completes and returns nothing \u2014 `\"ping\";` does not return\n `\"ping\"` \u2014 and that used to be indistinguishable on the wire from a script\n that never ran. It now comes back as `{ok: true, returned: null, undoGroup,\n note}`, which means *it ran to completion*. Do not re-run it: nothing rolls\n back, so a second run of a script that duplicated a layer or wrote keyframes\n does all of it twice. A returned value still comes back bare, falsy ones\n included.\n supersedes: a null from run_jsx means the script did not run, run it again\n\n## 0.3.0\n\n- **`list_comps`, `list_layers` and `get_layer_full` take `include` to name the\n sections you want**, plus `maxKeyframes` and `shapeDepth` on the deep read.\n Omit them all and you get everything, exactly as before. Anything left out is\n named and counted in the response (`included`, `keyframesOmitted`,\n `childrenOmitted`), so a short answer never passes for a complete one. This\n matters more than it sounds: a tool result is re-sent to you on every later\n request for the rest of the session, so one unbounded read is paid for many\n times.\n supersedes: get_layer_full returns everything, there is no way to bound it\n- **Screenshots downsample themselves.** Omit `downsample` and it is derived\n from the comp \u2014 2 at 1080p, 3 at 4K, aiming at a long edge near 1280px. The\n result reports the size actually returned. Pass `downsample: 1` only when you\n genuinely need full resolution.\n supersedes: always pass downsample on screenshot_frame or it returns full resolution\n- **A stale frame is an error, not a picture.** AE sometimes re-serves a frame\n it rendered for an unrelated request, at a different time and even at a\n different downsample factor, with nothing in the response to say so. Those\n are refused with `Stale frame` naming the request whose pixels came back.\n Wait a few seconds and retry at a higher `downsample`.\n- **A fully transparent frame comes back as `{empty: true, reason}`** with no\n image. That is a fact about the composition \u2014 wrong time, layer outside its\n in/out points, disabled, zero opacity \u2014 not something to retry.\n- **`import_footage` and `create_footage_layer` bring footage in**, and the\n import refuses an SVG whose `viewBox` asks for one aspect ratio and imports at\n another: that is a real AE bug which renders as nothing at all, with no error\n at any stage.\n supersedes: import footage with run_jsx and importFile\n- **`export_mogrt` exports a Motion Graphics template and suppresses the modal\n dialogs** that otherwise freeze this whole connection until someone clicks\n them in After Effects. Never drive `exportAsMotionGraphicsTemplate` from\n `run_jsx`.\n supersedes: export a mogrt with run_jsx and exportAsMotionGraphicsTemplate\n- **Text alignment is justification**, not a measured anchor offset, so it\n stays correct when the text changes later. Tracking is set explicitly rather\n than inherited from the user's Character panel.\n supersedes: anchorAlign moves the anchor point, re-align text after every edit"
|
|
3422
|
+
body: "<!--\nFormat contract. `ae_guide({topic: \"whats-new\", since: \"<version>\"})` filters\nthis file by machine and the absorb-release prompt greps project docs with it:\n\n- One `## <semver>` section per release, newest first. No other `##` heading\n below the first release heading; group inside a release with `###`.\n- Everything above the first `## <semver>` is the preamble. It is returned with\n every filtered read, so it stays short.\n- An entry is one bullet. It opens with a **bold phrase stating the rule as it\n now stands**, present tense, then the reason and what changed.\n- A bullet whose rule replaces something a project's own docs may say carries\n one or more indented `supersedes:` lines directly under it, each quoting the\n OLD rule in the words a project doc would have used. They are grep targets:\n short, literal, no markdown.\n-->\n\n# What changed\n\nRead this when something behaves differently from what you expected, or when the\nuser tells you a tool used to work another way. It is the release notes an agent\nneeds rather than the ones a human reads: one section per release, newest first.\nEvery entry leads with the rule as it now stands; a `supersedes:` line under it\nquotes the old rule in the words a project's own notes would have used, so those\nnotes can be searched for and corrected. Pass `since` \u2014 as in\n`ae_guide({topic: \"whats-new\", since: \"0.4.0\"})` \u2014 to get only the releases\nafter that version; the absorb-release prompt is the flow that applies them to\na project once and records the version so the history is not read again.\n\n**When this topic and a tool's own schema disagree, believe the schema.** It is\nrefreshed at each release and a build in between can be ahead of it.\n\n**The panel is versioned separately from the tools, and it does not update\nitself.** It loads at After Effects launch and only at launch, so after any\nupgrade the tools can be newer than the code answering them. When that happens a\ncall returns a remediation message rather than a confusing `Unknown op` \u2014 relay\nit. The distinction worth reading carefully: if it says the panel is *installed*\nbut the running one is older, running `setup_panel` again changes nothing and\nonly quitting and reopening After Effects will.\n\n## 0.5.1\n\nOne security fix, reported from outside the project. It changes nothing about\nhow you drive After Effects \u2014 but it does add a fifth way a call can fail, and\nthat one has a remedy of its own.\n\n- **The bridge authenticates every call, and a refused one is `bridgeToken` in\n `check_setup` rather than a broken panel.** The panel's HTTP server is on\n `127.0.0.1`, which a web page in the user's browser can reach, and `/op`\n reaches `run_jsx` \u2014 an unrestricted `eval` inside After Effects. It now\n requires a per-session token that the panel writes beside its port file, in\n `~/.engineroom-ae-mcp/token-<port>`, and that only a local process can read\n (issue #106). Nothing changes for you when it is working. When it is not, the\n failure reads \"the bridge token did not match\", and its remedy contradicts\n every other bridge failure: the panel is running, it answered immediately, the\n call never reached After Effects, and re-sending, restarting After Effects and\n `setup_panel` are all the wrong move. Run `check_setup` and relay `nextSteps`.\n supersedes: the bridge has no authentication\n supersedes: any local process can post to the bridge\n- **An MCP server older than the running panel is refused, and says so.** The\n panel loads at After Effects launch and only at launch, so after this upgrade\n a session can have a new panel answering an old server. That combination gets\n a refusal naming the fix \u2014 update the tools and reconnect \u2014 rather than a\n confusing failure. The reverse, a new server against an old panel, works\n unchanged: the header is simply ignored.\n\n## 0.5.0\n\nThe release where the guidance split into a core and references, the journal\nstarted coming to you instead of you going to it, and three more things After\nEffects does silently \u2014 a batch's progress no client ever saw, a broken\nexpression reported as ok, a justification that did not land \u2014 became\nvisible. Two results changed shape; those are first, because a caller reading\nthe old shape gets nothing rather than an error.\n\n### Two results changed shape\n\n- **`find_layers` returns `{matches, count, compsSearched, included}`, and a\n match is id, index, name, sourceType, compId and compName unless `include`\n asks for more.** It used to answer a bare array of full layer summaries \u2014\n flags, timing, parent, on every match \u2014 with no way to bound it, so nine name\n matches cost about two thousand tokens, re-sent on every later request.\n `include` takes the `list_layers` section names (`flags`, `timing`,\n `parent`); unlike `list_layers`, omitting it means the bounded record, not\n everything, and the result echoes `included`. A caller that read the old\n array reads `matches` now.\n supersedes: find_layers cannot be bounded\n supersedes: find_layers returns an array of layer summaries\n- **`place_audio_cues` results carry one small entry per cue \u2014 `layerId`,\n `name`, `time`, plus what an option changed \u2014 and `dryRun` answers counts,\n `wouldImport`, `wouldReuse` and the failing cues only.** The per-cue\n `levelDb`, `label`, `index` and `itemId` were echoes of the request, and a\n seven-cue dry run that found nothing wrong cost about 1,500 tokens of them.\n Read a level off the layer, not out of the result.\n supersedes: place_audio_cues echoes the levelDb written for every cue\n supersedes: dryRun on place_audio_cues returns the resolved cue list\n\n### Three silences, now reported\n\n- **`run_batch` over 500 ops returns its `jobId` before the first chunk runs,\n and progress is delivered on `await_job` \u2014 send that call with a progress\n token and `notifications/progress` arrive while it waits.** Nothing can ride\n on the `run_batch` call itself: its response is already back, and a\n notification sent on a request's token after its response is one every\n spec-compliant client has already stopped listening for \u2014 which is where\n every progress message for a long batch went before 0.5.0, seen by no real\n client. `get_job` polls the same state without a token.\n supersedes: run_batch progress arrives on the run_batch call\n supersedes: pass a progressToken on run_batch to see its progress\n- **`set_expression` throws when After Effects cannot evaluate the expression,\n and `get_expression` returns `expressionError`.** Assigning an expression\n succeeds whatever the text says, and AE's only report is a warning banner in\n its interface; the tool now evaluates the property after the write and fails\n with AE's message and the property path. The expression stays written \u2014 fix\n it and call again, or `clear_expression`. `toggle_expression({enabled:\n true})` verifies the same way. A non-empty `expressionError` means the\n property is not being driven by that expression, whatever `enabled` says.\n supersedes: set_expression reports success for a broken expression\n supersedes: ask the user to check the property for an expression warning banner\n- **`set_text` and `create_text_layer` read the justification back after every\n write, re-assert it once if it moved, and throw naming expected and actual\n if it still disagrees.** On 26.3 a `set_text` that changed only `text` has\n re-centred a left-aligned layer. Both results carry `justification` as a\n name and `justificationReasserted`; a failing `create_text_layer` removes\n the layer it made, so nothing is left without an id. An unknown\n justification name is refused rather than ignored.\n supersedes: set_text leaves justification alone when you do not pass it\n supersedes: verify alignment by sourceRect after every set_text\n\n### Deleting a comp, and what it leaves behind\n\n- **`delete_comp` reports `unusedSolidsLeft`, and `purgeUnusedSolids: true`\n removes the comp's own now-unused solids in the same undo step.** A solid's\n source is a footage item in the Solids folder, and deleting the comp only\n ever removed the layer, so a session that built and discarded comps left\n hundreds behind. Only the deleted comp's own solids are considered; one\n another comp still uses is kept and reported in `keptSolids` with that comp.\n supersedes: delete_comp removes a comp and its solids\n supersedes: sweep the Solids folder by hand after deleting comps\n- **`purge_unused_footage` sweeps the project: solids only by default,\n `solidsOnly: false` for every unused footage item, `folderId` to scope it,\n `dryRun: true` to list `wouldRemove` without an undo step.** Never a comp or\n a folder \u2014 a nested comp nothing uses is not footage.\n supersedes: remove unused footage with run_jsx\n\n### Sound\n\n- **`place_audio_cues` takes `loop`, `fadeIn`/`fadeOut` and `stretch` per cue,\n and `fadeFloorDb` per call.** `loop: true` time-remaps with a wrap-around\n expression, keeps AE's two default keys and re-asserts the out point; fades\n keyframe Audio Levels at the layer's real in and out between the floor\n (default `-48`) and the cue's level, and a fade that does not fit is refused\n by cue index; `stretch` is a percentage with the start time and trims\n re-asserted after it. A stretched loop bakes the factor into its expression,\n so a later hand change to stretch leaves the loop at the old rate. All of it\n is validated before anything is created and rolled back with the rest.\n supersedes: loop a bed with run_jsx and timeRemap after place_audio_cues\n supersedes: fade audio in run_jsx after placing the cues\n\n### The journal comes to you\n\n- **A failed call ends with `Known from earlier sessions: <scope:id> \u2014 <title>`\n for up to three entries matching its tool and error text, and the\n `list_known_issues({id})` call that opens the first.** Nobody reads the\n journal ahead of time any more; the pointer arrives with the failure, and a\n match moves the entry's `lastSeen`.\n supersedes: read list_known_issues before nontrivial work\n supersedes: check list_known_issues before guessing\n- **`log_issue` takes `errorText` and `kind`, and the same tool with the same\n error text extends the existing entry even under a new title** \u2014 the result\n says `mergedBy: \"errorText\"` and keeps the existing title. `kind:\n \"ae-quirk\"` marks a permanent After Effects behaviour; the default\n `tool-bug` is presumed fixed once last seen on an older server. Entries\n record `firstVersion` and `lastVersion`.\n supersedes: call list_known_issues first so you reuse the title when logging\n- **Entries archive themselves \u2014 unseen for 30 days, or a `tool-bug` last seen\n on an older server \u2014 and `archive_issue({id, reason})` retires one by\n hand.** Archived entries are hidden from the index and counted in\n `archivedCount`; `includeArchived: true` lists them, a read by `id` always\n works, and a fresh `log_issue` reopens one with `reopened: true`. Index\n lines carry `kind`, `lastSeen` and `lastVersion` and no summary sentence,\n sorted by tool match and then most recently seen.\n supersedes: the journal only grows, entries are never removed\n- **The report flow checks the tracker before drafting.** An entry a closed\n report already covers is archived with that URL; one an open report covers\n is marked reported. Bodies go through a file, and \"all of them\" is one\n approval.\n\n### Reading the history once\n\n- **`ae_guide({topic: \"whats-new\", since: \"<version>\"})` returns only the\n releases after that version, opening with the server's own version**, and\n this file has a shape a machine can filter: one section per release, every\n entry leading with the rule as it now stands, `supersedes:` lines under it\n quoting the old rule. The absorb-release prompt (`/absorb-release` where\n prompts are commands) applies the delta to a project's notes once and\n records the version in the `Tools version last absorbed:` line\n `init_project` writes into AGENTS.md.\n supersedes: read the whole whats-new guide after every update\n\n### The guidance\n\n- **The `after-effects` topic is a core of what silently produces wrong output\n on any task, and each subject is its own topic** \u2014 `animation`, `shapes`,\n `text`, `assembly`, `extendscript-gotchas`, `sound`, `mogrt-and-footage`,\n `issue-journal`, `whats-new` \u2014 twelve `ae_guide` topics in all with\n `style-guide` and `ae-setup`. In Claude Code and claude.ai the references\n are the files in the `after-effects` skill's `references/` folder, opened\n only when the core points at them. Keyframes, easing and rigging are in\n `animation`; raw scripting, with the ease-arity table, in\n `extendscript-gotchas` and nowhere else.\n supersedes: the after-effects skill covers keyframes text and shapes in one file\n supersedes: the ease arity table is in the main guide\n- **Four more After Effects facts, measured on 26.3, in `extendscript-gotchas`:**\n removing the last Time Remap key hides the property, so edit AE's two\n default keys in place or put an expression over them, and re-assert\n `outPoint` after enabling remapping; `TextDocument.justification` written\n from a script lands as RIGHT when handed CENTER, so set it through\n `set_text`; `OutputModule.setSettings` rejects Format, Channels, Depth and\n Color as read-only, so build an output template in the UI and apply it by\n name; and the ease arity is not derivable from a value's dimension, so\n never size a `KeyframeEase` array by hand.\n supersedes: clear the Time Remap keys before adding your own\n supersedes: set justification on the TextDocument from run_jsx\n supersedes: set the output module codec with setSettings from run_jsx\n- **The references carry the craft rules a project would otherwise keep in\n its own notes** (issue #103). `extendscript-gotchas`: a `.value` read at\n the playhead, a `setValue` that throws with the value applied on a comp not\n in a viewer, effects addressed by a name that is not unique and references\n that go stale when one is added, a track matte that switches its matte's\n video off, `replaceSource` keeping the old timing, `item.typeName`, Time\n Remap keys added before the defaults are removed. `animation`: a held pose\n as a hold key, a waypoint that must not stop, parenting at rest and before\n the pop with the parent-space arithmetic, a mirrored parent mirroring its\n text, expression text built from numbers, a shake as an expression over\n keys. `assembly`: a precomp clamped at its source, reversing with time\n remap, controls shared across every instance, a resized comp shifting what\n nests it, collapse transformations and motion blur across a nesting,\n replacing a nested comp, and verifying an assembly, a small detail, a\n doubtful tile and a cut. `sound`: peak for a one-shot and RMS for a bed\n with the onset on the beat, a trim clamped at the file's end, a bed that\n follows a move, a float WAV that will not place. `shapes`: one fill per\n group, the layer stack built back-to-front, a group's pivot, seams between\n shapes, centring a repeated run.\n\n### Connecting, and staying connected\n\n- **The server follows the panel's port.** A call refused on the port the\n server remembered makes it look again \u2014 7777 first, then the port file \u2014\n switch to whichever answers as the panel, and re-send the call once. Only a\n refused connection triggers this; a timed-out call reached After Effects and\n is never re-sent (issue #92). `AE_MCP_PORT` is a hard pin: with it set, no\n other port is ever tried.\n supersedes: reconnect the MCP server when ops fail on a port check_setup does not report\n- **`check_setup` reports the port that actually answers**, flags a stale\n port file, and carries a new `portAgreement` check. When calls go to one\n port and the panel answers on another, its advice is to retry or reconnect\n the MCP server. It never tells you to restart After Effects for that.\n supersedes: cannot reach the panel means nothing is listening so restart After Effects\n- **The panel waits instead of moving when an older copy of itself holds\n 7777.** It says so in its own window, names the holder, and takes the port\n over the moment that copy exits, with no restart. Two After Effects\n instances on one machine opt back into walking with `\"allowPortWalk\": true`\n in `~/.engineroom-ae-mcp/config.json`; the same file's `\"port\"` moves the\n panel. The port file only ever names a port that was really bound and is\n removed when the panel unloads.\n supersedes: the panel walks up to the next free port when 7777 is taken\n- **A panel that never loads on Windows gets a diagnosis, not a restart\n loop.** `check_setup` reads CEP's own log and, when CEP refused the panel's\n signature \u2014 an Adobe CEP 12 bug that ignores PlayerDebugMode \u2014 reports\n `panelSignature` with the steps to self-sign the installed panel with\n Adobe's ZXPSignCmd (issue #91). With no log yet, it gives the one command\n that turns CEP logging on. `setup_panel` now turns that logging on itself\n where it was never set, warns when it is about to replace a self-signed\n panel (`signedInstallOverwritten`, since the fresh copy needs signing\n again), and reports a signed panel as up to date rather than partial.\n supersedes: check_setup all green but the panel never loads means restart After Effects again\n- **`check_setup` under an `AE_MCP_PORT` pin still asks 7777 and the port\n file, and a pinned wrong port is reported as the pin, not as a dead panel.**\n The pin decides where ops go and is never walked past; the diagnosis says so,\n names the port the panel is really answering on, and tells you to change or\n unset `AE_MCP_PORT` and reconnect the MCP server \u2014 never to restart After\n Effects. A refused op under a pin says the same in its own message.\n\n### Found by the live pass\n\n- **`init_project` refuses the home directory and the filesystem root however\n they were arrived at, an explicit `dir` included.** Before this only the\n working-directory fallback was guarded, so `dir: \"~\"` resolved by a client,\n or `dir: \"/\"`, scaffolded `AGENTS.md` and a `renders/` folder into a home\n directory and reported success.\n- **`set_house_style` writes `house-style.md` with Unix line endings.**\n ExtendScript's default on macOS turned every line break into a bare carriage\n return: the tools read the file back correctly, so the round trip looked\n fine, but a designer opening the file in an editor saw one long line.\n\n## 0.4.0\n\nThe release where verifying a change stopped meaning reading the whole thing\nback \u2014 and where a live pass against After Effects 2026 found four things this\nserver had been claiming that were never true. Those are first, because an agent\nthat learned the old story will otherwise repeat it to a user.\n\n### Four things that were never true, and are now fixed\n\n- **`run_batch` is one undo step up to 500 ops; over 500 it is one step per\n chunk of 25, and `singleUndo: true` makes it one step up to 2000.** The\n one-step guarantee it shipped with before 0.4.0 did not exist on either path:\n After Effects discards an undo group opened in one script call and closed in\n another, so a 600-op batch landed as about six hundred separate steps while\n reporting one. Now up to 500 ops is genuinely one step; over 500 is one step\n per chunk of 25 \u2014 around 24 for 600 ops \u2014 with the measured count in\n `undoSteps` and a `note` saying it in words. `singleUndo: true` runs the batch\n in a single blocking call, which freezes AE's interface for the duration and\n reports no progress. Read `undoSteps` before you tell anyone how to undo the\n work.\n supersedes: run_batch is one undo step\n supersedes: a long run_batch is one undo step\n- **`transactional: true` stops at the first failure, rolls nothing back, and\n says so: `rolledBack: false` with the stop point named.** It never rolled\n anything back \u2014 it fired one menu-command Undo, and menu commands silently do\n nothing over this bridge; one Undo is one step, not a batch, in any case. The\n ops before the failure stay applied. Use `diff: true` to see what landed.\n supersedes: transactional: true rolls the batch back on failure\n- **`reorder_layer` works, and takes exactly one of `beforeLayerId`,\n `afterLayerId` or `toIndex`.** Every call it served before 0.4.0 threw\n `parent is not an INDEXED_GROUP`, because it used a shape-node method on a\n layer. Prefer the id forms, because this is the op that invalidates indexes;\n `toIndex` means the index the layer **ends up at**.\n supersedes: reorder_layer is broken, use run_jsx\n supersedes: reorder_layer throws parent is not an INDEXED_GROUP\n- **`downsample: 1` is full resolution, whatever the viewer's Resolution\n dropdown is set to.** Before 0.4.0 factor 1 skipped setting the comp's\n resolution at all, so it inherited the viewer's setting \u2014 on a comp a designer\n had left at Quarter, `downsample: 1` returned a quarter-size frame and\n `downsample: 2` returned one four times larger. The response was honest about\n the dimensions; the picture was not the one asked for. Factor 1 is now set\n explicitly like any other and restored afterwards.\n supersedes: downsample: 1 means the viewer's resolution\n supersedes: set the viewer to Full before a downsample: 1 screenshot\n\n### Two calls changed under you\n\n- **`create_shape_layer` spawns at `[0,0]`**, with the anchor at `[0,0]`, so the\n layer's coordinate space *is* the comp's and every vertex, rect position and\n path you add afterwards is in comp pixels. After Effects' own spawn point is\n the comp centre, which silently offsets a drawing authored in comp coordinates\n by half a frame \u2014 easy to miss on a downsampled screenshot. `position:\n \"center\"` restores AE's behaviour, and any `[x,y]` places the origin yourself.\n The result echoes what it ended up with.\n supersedes: a new shape layer spawns at the comp centre\n supersedes: shape paths are offset by half a frame, subtract the comp centre\n- **`get_house_style` returns a digest, not the document; `detail: \"full\"`\n returns the document.** The digest is a few hundred tokens: palette as named\n hexes, type, motion, layout, and a note naming anything it could not summarise.\n You need the full document before `set_house_style`, which replaces the file\n rather than patching it. An unstructured guide comes back `structured: false`\n with its opening text, which is an honest \"I could not read this as a spec\"\n rather than an empty answer.\n supersedes: do not call get_house_style, read the digest in these notes instead\n supersedes: get_house_style returns the whole style guide\n\n### Verify with a diff instead of a second read\n\n- **`snapshot_comp` and `diff_comp` fingerprint a comp and report what moved.**\n Fingerprint before a write, then ask: layers added, removed, renamed,\n retimed, re-parented, keyframe counts, expressions and effects gained or lost\n \u2014 and a count of the layers that did not move. Tens of tokens where two full\n reads were thousands. It does **not** record property values, expression\n text, effect parameters, masks or shape contents, so `changeCount: 0` means\n none of the recorded fields moved, not that the comp is identical.\n supersedes: verify a write by reading the comp back with list_layers and get_layer_full\n- **`diff: true` on `run_jsx` and `run_batch` does the same inside the call**,\n so there is no window between the write and the fingerprint \u2014 and on a\n failure the diff rides on the error, which is how you find where a\n half-applied script stopped.\n\n### Screenshots\n\n- **`screenshot_frame` takes `times` (2\u20136) and returns one tiled contact\n sheet**, each tile labelled with its own time, held to roughly the pixel\n budget of a single frame. Judging motion is one call now, not three: one\n image in your context instead of three, and one render request instead of\n three back-to-back ones, which is the pattern that provoked stale frames.\n `time` and `times` are mutually exclusive; there is no `times` on\n `screenshot_layer`.\n supersedes: screenshot each time separately to judge motion\n- **Three screenshot failures, each with its own remedy.** `Stale frame` (AE\n served an earlier render \u2014 wait, retry higher), `Corrupt frame` (the file is\n not a whole PNG; not a timeout \u2014 retry at downsample 6\u20138 or shoot the precomps\n separately), `Render timed out` (still rendering \u2014 wait before retrying, or a\n retry queues behind it). A frame that is genuinely all-transparent is still\n `empty: true` with no image. On a sheet, one bad tile is drawn as a marked\n block and named in `warning`; the rest of the sheet is good.\n supersedes: truncated PNG from screenshot_frame means retry it\n\n### run_jsx\n\n- **A `run_jsx` failure names the line of *your* script and prints its text**,\n and says so honestly when the number cannot be mapped rather than guessing.\n This was claimed once before it was true: until the 0.4.0 live pass every\n error said line 1 and printed line 1's text, with the real number only in the\n parenthetical after it, because AE reports `Error.start`/`Error.end` as `0`\n on every error rather than as the character offsets its documentation\n describes.\n supersedes: run_jsx line numbers are wrong, count from the wrapper\n supersedes: run_jsx reports line 1 for every error\n- **`diff: true` on `run_jsx` reaches After Effects.** The server was building\n a fresh argument object and dropping every field it did not enumerate, so the\n flag was discarded before the call left. The same flag on `run_batch` always\n worked, which is what hid it.\n supersedes: diff: true on run_jsx does nothing\n- **`scriptPath` and `libraries` keep a long script and its shared helpers out\n of the conversation entirely.** Libraries are inlined ahead of the script in\n the same scope, so their functions are callable from it; they are\n re-evaluated on every call, so keep them to declarations rather than to work.\n- **Helpers are in scope**, each wrapping a trap: `compById`, `layerById`,\n `walkProperty`, `addKeys`, `ease` (which sizes the KeyframeEase array using\n the same code `set_temporal_ease` uses, not a copy), `shape` (which lands at\n `[0,0]`), and `withoutUndoGroup`. The whole `OPS` table is callable too.\n\n### New tools, and one thing you no longer have to get right\n\n- **`duplicate_comp` returns the new comp id**, so you never look for the copy\n by name. Shallow by default like AE's own Duplicate \u2014 the copy's precomp\n layers point at the *same* nested comps \u2014 with `deep: true` for a real\n variant.\n supersedes: duplicate a comp with run_jsx and find the copy by name\n- **`place_audio_cues` scores a scene in one call**: a cue list becomes one\n audio layer each, imported once per file, named, trimmed, levelled in dB, in a\n single undo step, all-or-nothing, with a `dryRun`.\n supersedes: place audio with run_jsx, one layer at a time\n- **`set_temporal_ease` and `add_keyframe` size the ease array themselves.**\n Pass one `{influence, speed}` pair per side; the count that worked comes back\n as `easeDimensions`. The bare `parameter 2` failure is no longer yours to\n avoid.\n supersedes: set_temporal_ease fails with parameter 2, pass the right number of KeyframeEase objects by hand\n supersedes: set_temporal_ease fails on Scale, use run_jsx\n- **`export_mogrt` refuses an empty Essential Graphics panel up front**, naming\n the controller count. It used to attempt the export and then blame a modal\n dialog, which under the default dialog suppression is the one cause ruled out\n by construction. When an export does fail now, the message says what was\n checked and \u2014 if dialogs were suppressed \u2014 that the cause is genuinely\n unknown, because AE answers with a bare boolean and has no way to say why.\n supersedes: export_mogrt fails with a modal dialog when the comp has no Essential Graphics controllers\n\n### Underneath\n\n- **Writes are serialized, one at a time, for the whole session**, so two\n writes issued together run in the order you issued them and a long\n `run_batch` holds the lock until its last chunk lands \u2014 nothing else drops\n into the middle of work the user asked for as one thing. A call that waited\n says `queuedBehind` and `waitedMs`. Reads are never queued. There is a new\n diagnosis to tell apart from a bridge timeout: a call *dropped while waiting\n for the write queue* never reached After Effects, so re-sending it is safe \u2014\n which is the opposite of what a timeout means.\n supersedes: never issue two writes in the same turn, the order is not guaranteed\n- **The issue journal has two scopes.** `project` is this project's notes;\n `user` travels with the person across every project, so log tool and After\n Effects behaviour there. Ids are unique only within a journal, so open an\n entry with the qualified form the listing shows (`user:\u2026`).\n- **The guidance lives in `ae_guide` and the skill, not in what the server sends\n every session.** What the server sends is a short pointer rather than a\n summary, because it was resident in every request the user ever made. The\n narrative is `ae_guide({topic: \"after-effects\"})`, with `extendscript-gotchas`\n behind it for raw `run_jsx` and this topic for changes. In Claude Code and\n claude.ai the same text is the `after-effects` skill and the files in its\n `references/` folder \u2014 load one carrier, not both.\n- **Rigging is written down**, in the `animation` topic: opacity does not propagate\n through parenting, and a camera null is parented to while it is still at\n identity.\n\n## 0.3.1\n\n- **`get_layer_full` omits `ADBE Vector Materials Group` unless\n `shapeMaterials: true`.** That is the 48-property 3D extrusion model AE hangs\n off every vector group, which means nothing on a 2D shape layer. It was around\n three quarters of the bytes of a shape read: one 68px circle cost 4,400\n tokens, of which the geometry was about 40. `materialsOmitted` counts what was\n dropped; `shapeMaterials: true` brings it back for a genuinely extruded shape.\n A group Transform still at its creation values collapses to `atDefaults:\n true` the same way.\n supersedes: shape reads are huge, avoid get_layer_full on shape layers\n- **`shapeDetail: \"compact\"` returns one indented line per group** instead of\n the JSON tree \u2014 every name the write tools address a node by, with `[3 keys]`\n or `[expr]` marking the animated properties. Measured end to end on one real\n layer: 13,369 characters before 0.3.1, 3,052 once the materials block went,\n 643 compact. Reach for `\"full\"` only when you need exact values or keyframe\n indices.\n- **`run_jsx` never answers a bare `null`.** A script whose last statement is a\n bare expression completes and returns nothing \u2014 `\"ping\";` does not return\n `\"ping\"` \u2014 and that used to be indistinguishable on the wire from a script\n that never ran. It now comes back as `{ok: true, returned: null, undoGroup,\n note}`, which means *it ran to completion*. Do not re-run it: nothing rolls\n back, so a second run of a script that duplicated a layer or wrote keyframes\n does all of it twice. A returned value still comes back bare, falsy ones\n included.\n supersedes: a null from run_jsx means the script did not run, run it again\n\n## 0.3.0\n\n- **`list_comps`, `list_layers` and `get_layer_full` take `include` to name the\n sections you want**, plus `maxKeyframes` and `shapeDepth` on the deep read.\n Omit them all and you get everything, exactly as before. Anything left out is\n named and counted in the response (`included`, `keyframesOmitted`,\n `childrenOmitted`), so a short answer never passes for a complete one. This\n matters more than it sounds: a tool result is re-sent to you on every later\n request for the rest of the session, so one unbounded read is paid for many\n times.\n supersedes: get_layer_full returns everything, there is no way to bound it\n- **Screenshots downsample themselves.** Omit `downsample` and it is derived\n from the comp \u2014 2 at 1080p, 3 at 4K, aiming at a long edge near 1280px. The\n result reports the size actually returned. Pass `downsample: 1` only when you\n genuinely need full resolution.\n supersedes: always pass downsample on screenshot_frame or it returns full resolution\n- **A stale frame is an error, not a picture.** AE sometimes re-serves a frame\n it rendered for an unrelated request, at a different time and even at a\n different downsample factor, with nothing in the response to say so. Those\n are refused with `Stale frame` naming the request whose pixels came back.\n Wait a few seconds and retry at a higher `downsample`.\n- **A fully transparent frame comes back as `{empty: true, reason}`** with no\n image. That is a fact about the composition \u2014 wrong time, layer outside its\n in/out points, disabled, zero opacity \u2014 not something to retry.\n- **`import_footage` and `create_footage_layer` bring footage in**, and the\n import refuses an SVG whose `viewBox` asks for one aspect ratio and imports at\n another: that is a real AE bug which renders as nothing at all, with no error\n at any stage.\n supersedes: import footage with run_jsx and importFile\n- **`export_mogrt` exports a Motion Graphics template and suppresses the modal\n dialogs** that otherwise freeze this whole connection until someone clicks\n them in After Effects. Never drive `exportAsMotionGraphicsTemplate` from\n `run_jsx`.\n supersedes: export a mogrt with run_jsx and exportAsMotionGraphicsTemplate\n- **Text alignment is justification**, not a measured anchor offset, so it\n stays correct when the text changes later. Tracking is set explicitly rather\n than inherited from the user's Character panel.\n supersedes: anchorAlign moves the anchor point, re-align text after every edit"
|
|
3338
3423
|
}
|
|
3339
3424
|
];
|
|
3340
3425
|
var PROMPTS = [
|
|
@@ -4240,7 +4325,7 @@ var GetJobSchema = schemas_exports.GetJob;
|
|
|
4240
4325
|
var CancelJobSchema = schemas_exports.CancelJob;
|
|
4241
4326
|
function createServer(opts = {}) {
|
|
4242
4327
|
const server = new Server(
|
|
4243
|
-
{ name: "after-effects-mcp", version: "0.5.
|
|
4328
|
+
{ name: "after-effects-mcp", version: "0.5.1" },
|
|
4244
4329
|
{
|
|
4245
4330
|
capabilities: { tools: {}, logging: {}, prompts: {}, resources: {} },
|
|
4246
4331
|
// Clients that honour this fold it into the system prompt, which is the
|
|
@@ -4742,7 +4827,7 @@ ${USAGE}`);
|
|
|
4742
4827
|
await server.connect(transport);
|
|
4743
4828
|
logger.info("MCP server running on stdio");
|
|
4744
4829
|
}
|
|
4745
|
-
var VERSION = "0.5.
|
|
4830
|
+
var VERSION = "0.5.1";
|
|
4746
4831
|
main().catch((e) => {
|
|
4747
4832
|
logger.error("fatal", e.message);
|
|
4748
4833
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@engine-room/after-effects-mcp",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Control Adobe After Effects with AI — describe the animation you want and it gets built: layers, keyframes, effects, expressions and text, all editable afterwards.",
|
|
6
6
|
"license": "MIT",
|
package/panel/CSXS/manifest.xml
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<ExtensionManifest Version="11.0" ExtensionBundleId="games.engine-room.ae-mcp" ExtensionBundleVersion="0.5.
|
|
2
|
+
<ExtensionManifest Version="11.0" ExtensionBundleId="games.engine-room.ae-mcp" ExtensionBundleVersion="0.5.1"
|
|
3
3
|
ExtensionBundleName="AE MCP Bridge" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
|
4
4
|
<ExtensionList>
|
|
5
|
-
<Extension Id="games.engine-room.ae-mcp.panel" Version="0.5.
|
|
5
|
+
<Extension Id="games.engine-room.ae-mcp.panel" Version="0.5.1" />
|
|
6
6
|
</ExtensionList>
|
|
7
7
|
<ExecutionEnvironment>
|
|
8
8
|
<HostList>
|
package/panel/client/main.js
CHANGED
|
@@ -234,6 +234,75 @@
|
|
|
234
234
|
} catch (e) {}
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
// ---------- Bridge token (issue #106) ----------
|
|
238
|
+
// Why a token and not a CORS rule, and why per port: docs/fragile-areas-bridge.md.
|
|
239
|
+
// One per panel session. 384 is 0600 for engines that predate 0o literals.
|
|
240
|
+
var sessionToken = crypto.randomBytes(32).toString("hex");
|
|
241
|
+
var TOKEN_MODE = 384;
|
|
242
|
+
|
|
243
|
+
function tokenFile(port) { return path.join(portDir, "token-" + port); }
|
|
244
|
+
|
|
245
|
+
function writeTokenFile(port) {
|
|
246
|
+
try { fs.mkdirSync(portDir, { recursive: true }); } catch (e) {}
|
|
247
|
+
try {
|
|
248
|
+
fs.writeFileSync(tokenFile(port), sessionToken, { encoding: "utf8", mode: TOKEN_MODE });
|
|
249
|
+
// writeFileSync applies `mode` only when it creates the file.
|
|
250
|
+
try { fs.chmodSync(tokenFile(port), TOKEN_MODE); } catch (e) {}
|
|
251
|
+
} catch (e) {
|
|
252
|
+
log("error", "could not write the bridge token file " + tokenFile(port) + ": " + e.message +
|
|
253
|
+
" — the MCP server will not be able to authenticate, and every tool call will be refused.");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Same rule as the port file: only this panel's own entry.
|
|
258
|
+
function removeTokenFile(port) {
|
|
259
|
+
try {
|
|
260
|
+
if (fs.readFileSync(tokenFile(port), "utf8").trim() === sessionToken) fs.unlinkSync(tokenFile(port));
|
|
261
|
+
} catch (e) {}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function sameToken(given) {
|
|
265
|
+
if (typeof given !== "string" || given.length !== sessionToken.length) return false;
|
|
266
|
+
try {
|
|
267
|
+
return crypto.timingSafeEqual(Buffer.from(given, "utf8"), Buffer.from(sessionToken, "utf8"));
|
|
268
|
+
} catch (e) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// The token is what holds. The Origin check is depth: browsers always send one,
|
|
274
|
+
// Node's fetch and `ws` never do.
|
|
275
|
+
function authFailure(req) {
|
|
276
|
+
var h = (req && req.headers) || {};
|
|
277
|
+
if (h.origin) return "it came from a web page (Origin: " + h.origin + ")";
|
|
278
|
+
if (!sameToken(h["x-ae-mcp-token"])) return "the bridge token was missing or did not match";
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Written for an MCP server older than this panel: it relays `error` verbatim,
|
|
283
|
+
// so the remedy has to be in here.
|
|
284
|
+
function denialMessage() {
|
|
285
|
+
return "This After Effects bridge requires a token and this request did not carry a valid one. " +
|
|
286
|
+
"Nothing was run in After Effects. The panel writes the token beside its port file, in " + portDir +
|
|
287
|
+
", each time it binds; a local process can read it and a web page cannot, which is the entire point " +
|
|
288
|
+
"(issue #106). If you are the MCP server: you are older than the panel After Effects is running — " +
|
|
289
|
+
"update after-effects-mcp to 0.5.1 or newer and reconnect it.";
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Rate-limited: a page firing in a loop must not fill the panel's log.
|
|
293
|
+
var denied = 0;
|
|
294
|
+
var deniedLoggedAt = 0;
|
|
295
|
+
function noteDenial(what, why) {
|
|
296
|
+
denied += 1;
|
|
297
|
+
var now = Date.now();
|
|
298
|
+
if (denied === 1 || now - deniedLoggedAt > 10000) {
|
|
299
|
+
deniedLoggedAt = now;
|
|
300
|
+
log("warn", "refused a request to " + what + ": " + why +
|
|
301
|
+
(denied > 1 ? " (" + denied + " refused so far)" : "") +
|
|
302
|
+
". If this is the MCP server rather than a web page, it is older than this panel: update it and reconnect.");
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
237
306
|
// ---------- Config ----------
|
|
238
307
|
// A CEP panel cannot read an environment variable the user set: it runs in
|
|
239
308
|
// After Effects' process, with Adobe's environment. So its two knobs live in
|
|
@@ -864,8 +933,9 @@
|
|
|
864
933
|
return new Promise(function (resolve, reject) {
|
|
865
934
|
var server = http.createServer(function (req, res) {
|
|
866
935
|
var url = req.url || "/";
|
|
867
|
-
|
|
868
|
-
|
|
936
|
+
|
|
937
|
+
// No CORS headers, deliberately: nothing that legitimately calls this
|
|
938
|
+
// bridge is a web page (issue #106).
|
|
869
939
|
|
|
870
940
|
if (url === "/health") {
|
|
871
941
|
res.statusCode = 200;
|
|
@@ -883,6 +953,10 @@
|
|
|
883
953
|
port: addr && addr.port ? addr.port : port,
|
|
884
954
|
bundleLoaded: true,
|
|
885
955
|
bundleHash: loadedBundleHash,
|
|
956
|
+
// The gate exists, never anything about the token. Absent on a
|
|
957
|
+
// pre-#106 panel, which is how the server tells "needs a token"
|
|
958
|
+
// from "too old to want one".
|
|
959
|
+
auth: true,
|
|
886
960
|
ts: Date.now()
|
|
887
961
|
}));
|
|
888
962
|
return;
|
|
@@ -893,6 +967,17 @@
|
|
|
893
967
|
return;
|
|
894
968
|
}
|
|
895
969
|
|
|
970
|
+
// Every route below reaches After Effects. Checked before the body is
|
|
971
|
+
// read, so an unidentified caller cannot make us buffer anything.
|
|
972
|
+
var denial = authFailure(req);
|
|
973
|
+
if (denial) {
|
|
974
|
+
noteDenial(url, denial);
|
|
975
|
+
res.statusCode = 403;
|
|
976
|
+
res.setHeader("content-type", "application/json");
|
|
977
|
+
res.end(JSON.stringify({ ok: false, error: denialMessage(), code: "bridge_unauthorized" }));
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
896
981
|
var bufs = [];
|
|
897
982
|
req.on("data", function (c) { bufs.push(c); });
|
|
898
983
|
req.on("end", function () {
|
|
@@ -1017,8 +1102,22 @@
|
|
|
1017
1102
|
return startHttp(p).then(function (server) {
|
|
1018
1103
|
// Read back rather than assumed: port 0 binds an ephemeral one.
|
|
1019
1104
|
var bound = server.address().port;
|
|
1105
|
+
// The socket is already accepting, so the token must be on disk before
|
|
1106
|
+
// this promise resolves — not later in the boot.
|
|
1107
|
+
writeTokenFile(bound);
|
|
1020
1108
|
// Mount WS on the same HTTP server.
|
|
1021
|
-
var wss = new WebSocket.Server({
|
|
1109
|
+
var wss = new WebSocket.Server({
|
|
1110
|
+
server: server,
|
|
1111
|
+
path: "/events",
|
|
1112
|
+
// WebSockets ignore the same-origin policy entirely, so without this
|
|
1113
|
+
// any page could watch the user's event stream (issue #106).
|
|
1114
|
+
verifyClient: function (info, cb) {
|
|
1115
|
+
var why = authFailure(info.req);
|
|
1116
|
+
if (!why) return cb(true);
|
|
1117
|
+
noteDenial("/events", why);
|
|
1118
|
+
cb(false, 403, "Forbidden");
|
|
1119
|
+
},
|
|
1120
|
+
});
|
|
1022
1121
|
wss.on("connection", function (ws) {
|
|
1023
1122
|
wsClients.add(ws);
|
|
1024
1123
|
ws.on("close", function () { wsClients.delete(ws); });
|
|
@@ -1097,6 +1196,7 @@
|
|
|
1097
1196
|
try { if (typeof s.server.closeAllConnections === "function") s.server.closeAllConnections(); } catch (e) {}
|
|
1098
1197
|
try { s.server.close(); } catch (e) {}
|
|
1099
1198
|
removePortFile(s.port);
|
|
1199
|
+
removeTokenFile(s.port);
|
|
1100
1200
|
}
|
|
1101
1201
|
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
|
1102
1202
|
window.addEventListener("beforeunload", shutdown);
|
|
@@ -1126,6 +1226,7 @@
|
|
|
1126
1226
|
setStatus("ready", "ok");
|
|
1127
1227
|
log("info", "Bridge listening on http://127.0.0.1:" + s.port +
|
|
1128
1228
|
(s.port !== config.port && config.port !== 0 ? " (configured port " + config.port + " was taken — see above)" : ""));
|
|
1229
|
+
log("info", "Calls are authenticated: the MCP server reads this session's token from " + tokenFile(s.port) + ".");
|
|
1129
1230
|
})
|
|
1130
1231
|
.catch(function (e) {
|
|
1131
1232
|
setStatus("failed", "err");
|