@engine-room/after-effects-mcp 0.1.2 → 0.2.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 +237 -77
- package/bin/server.js +647 -252
- package/package.json +4 -4
- package/panel/CSXS/manifest.xml +2 -2
- package/panel/client/csinterface.js +7 -0
- package/panel/client/main.js +57 -9
- package/panel/jsx/bundle.jsx +118 -1
- package/panel/package.json +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@engine-room/after-effects-mcp",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"win32"
|
|
44
44
|
],
|
|
45
45
|
"engines": {
|
|
46
|
-
"node": ">=
|
|
46
|
+
"node": ">=22"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
49
|
"build": "tsc -p tsconfig.json",
|
|
@@ -53,13 +53,13 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
56
|
-
"ws": "^8.
|
|
56
|
+
"ws": "^8.21.3",
|
|
57
57
|
"zod": "^3.23.8",
|
|
58
58
|
"zod-to-json-schema": "^3.23.5"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@engineroom/shared": "*",
|
|
62
|
-
"@types/node": "^
|
|
62
|
+
"@types/node": "^22",
|
|
63
63
|
"@types/ws": "^8.5.13",
|
|
64
64
|
"esbuild": "^0.28.2",
|
|
65
65
|
"typescript": "^5.6.0"
|
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.1
|
|
2
|
+
<ExtensionManifest Version="11.0" ExtensionBundleId="games.engine-room.ae-mcp" ExtensionBundleVersion="0.2.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.1
|
|
5
|
+
<Extension Id="games.engine-room.ae-mcp.panel" Version="0.2.1" />
|
|
6
6
|
</ExtensionList>
|
|
7
7
|
<ExecutionEnvironment>
|
|
8
8
|
<HostList>
|
|
@@ -11,6 +11,13 @@ CSInterface.prototype.evalScript = function (script, callback) {
|
|
|
11
11
|
CSInterface.prototype.getSystemPath = function (pathType) {
|
|
12
12
|
var path = window.__adobe_cep__.getSystemPath(pathType);
|
|
13
13
|
if (path && path.indexOf("file://") === 0) path = decodeURIComponent(path.substring(7));
|
|
14
|
+
// CEP hands back a file URL, so on Windows the drive letter arrives still
|
|
15
|
+
// carrying the URL's leading slash: /C:/Users/... Node reads that as
|
|
16
|
+
// root-relative — path.join turns it into \C:\...\jsx\bundle.jsx, which no
|
|
17
|
+
// fs call can open, and the panel reports the bundle missing when it is
|
|
18
|
+
// sitting right there. Strip it here rather than at each call site: this is
|
|
19
|
+
// the one place a URL becomes a native path.
|
|
20
|
+
if (path) path = path.replace(/^\/([A-Za-z]:)/, "$1");
|
|
14
21
|
return path;
|
|
15
22
|
};
|
|
16
23
|
|
package/panel/client/main.js
CHANGED
|
@@ -10,14 +10,13 @@
|
|
|
10
10
|
var fs = require("fs");
|
|
11
11
|
var os = require("os");
|
|
12
12
|
var http = require("http");
|
|
13
|
-
var
|
|
14
|
-
try { WebSocket = require("ws"); }
|
|
15
|
-
catch (e) {
|
|
16
|
-
// ws is bundled in packages/ae-panel/node_modules; resolve manually if normal require fails.
|
|
17
|
-
var alt = path.join(__dirname, "..", "node_modules", "ws");
|
|
18
|
-
WebSocket = require(alt);
|
|
19
|
-
}
|
|
13
|
+
var crypto = require("crypto");
|
|
20
14
|
|
|
15
|
+
// The DOM handles and the logger come first so that everything below is able
|
|
16
|
+
// to report its own failure. `require("ws")` used to run before this point,
|
|
17
|
+
// so when it threw the exception escaped this whole function before a single
|
|
18
|
+
// line could be written — the panel sat on "starting…" indefinitely with the
|
|
19
|
+
// reason nowhere to be seen.
|
|
21
20
|
var $status = document.getElementById("status");
|
|
22
21
|
var $port = document.getElementById("port");
|
|
23
22
|
var $ae = document.getElementById("ae");
|
|
@@ -40,6 +39,24 @@
|
|
|
40
39
|
while ($log.childNodes.length > 80) $log.removeChild($log.lastChild);
|
|
41
40
|
}
|
|
42
41
|
|
|
42
|
+
var WebSocket;
|
|
43
|
+
try { WebSocket = require("ws"); }
|
|
44
|
+
catch (primary) {
|
|
45
|
+
// ws is bundled in packages/ae-panel/node_modules; resolve manually if normal require fails.
|
|
46
|
+
var alt = path.join(__dirname, "..", "node_modules", "ws");
|
|
47
|
+
try { WebSocket = require(alt); }
|
|
48
|
+
catch (fallback) {
|
|
49
|
+
// Nothing below can be built without ws, so this stops here — but it
|
|
50
|
+
// stops saying why, and naming the fix, rather than looking like a panel
|
|
51
|
+
// that is still starting up.
|
|
52
|
+
setStatus("cannot start — the ws module is missing", "err");
|
|
53
|
+
log("error", "require('ws') failed: " + primary.message);
|
|
54
|
+
log("error", "and " + alt + ": " + fallback.message);
|
|
55
|
+
log("error", "Quit After Effects, run the setup_panel tool, then reopen it.");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
var cs = new CSInterface();
|
|
44
61
|
var extDir = cs.getSystemPath(SystemPath.EXTENSION);
|
|
45
62
|
var bundlePath = path.join(extDir, "jsx", "bundle.jsx");
|
|
@@ -84,6 +101,24 @@
|
|
|
84
101
|
}
|
|
85
102
|
|
|
86
103
|
// ---------- Load bundle.jsx ----------
|
|
104
|
+
// Hash of the bundle *actually evaluated into ExtendScript*, reported on
|
|
105
|
+
// /health. The server compares it against the bundle it ships to decide
|
|
106
|
+
// whether the running panel understands the ops it is about to send.
|
|
107
|
+
//
|
|
108
|
+
// It has to be captured here rather than read off disk on demand: after
|
|
109
|
+
// setup_panel refreshes the extension folder, the file on disk is new while
|
|
110
|
+
// this process is still running the old code, and that gap — between
|
|
111
|
+
// installing an update and restarting AE — is exactly when calls fail.
|
|
112
|
+
var loadedBundleHash = null;
|
|
113
|
+
|
|
114
|
+
function hashFile(file) {
|
|
115
|
+
try {
|
|
116
|
+
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
117
|
+
} catch (e) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
87
122
|
function loadJsxBundle() {
|
|
88
123
|
setStatus("Loading bundle.jsx…");
|
|
89
124
|
if (!fs.existsSync(bundlePath)) {
|
|
@@ -95,7 +130,12 @@
|
|
|
95
130
|
var loadScript = "$.evalFile(" + JSON.stringify(bundlePath) + "); typeof dispatch === 'function' ? 'ok' : 'no-dispatch';";
|
|
96
131
|
return new Promise(function (resolve, reject) {
|
|
97
132
|
cs.evalScript(loadScript, function (raw) {
|
|
98
|
-
if (raw === "ok") {
|
|
133
|
+
if (raw === "ok") {
|
|
134
|
+
loadedBundleHash = hashFile(bundlePath);
|
|
135
|
+
$jsx.textContent = "loaded";
|
|
136
|
+
$jsx.className = "ok";
|
|
137
|
+
resolve();
|
|
138
|
+
}
|
|
99
139
|
else { $jsx.textContent = "fail: " + raw; $jsx.className = "err"; reject(new Error("dispatch not defined after loading bundle.jsx: " + raw)); }
|
|
100
140
|
});
|
|
101
141
|
});
|
|
@@ -245,7 +285,15 @@
|
|
|
245
285
|
if (url === "/health") {
|
|
246
286
|
res.statusCode = 200;
|
|
247
287
|
res.setHeader("content-type", "application/json");
|
|
248
|
-
|
|
288
|
+
// bundleHash identifies the code this panel is *running*, which is what
|
|
289
|
+
// the server needs to know before sending an op the panel may predate.
|
|
290
|
+
res.end(JSON.stringify({
|
|
291
|
+
ok: true,
|
|
292
|
+
port: port,
|
|
293
|
+
bundleLoaded: true,
|
|
294
|
+
bundleHash: loadedBundleHash,
|
|
295
|
+
ts: Date.now()
|
|
296
|
+
}));
|
|
249
297
|
return;
|
|
250
298
|
}
|
|
251
299
|
|
package/panel/jsx/bundle.jsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Auto-generated bundle. Do not edit directly — edit files in packages/jsx/.
|
|
2
|
-
// Generated 2026-08-
|
|
2
|
+
// Generated 2026-08-12T07:48:29.949Z
|
|
3
3
|
|
|
4
4
|
// ===== core.jsx =====
|
|
5
5
|
|
|
@@ -1222,6 +1222,123 @@ OPS.screenshot_layer = noUndo(function (args) {
|
|
|
1222
1222
|
});
|
|
1223
1223
|
|
|
1224
1224
|
|
|
1225
|
+
// ===== style.jsx =====
|
|
1226
|
+
|
|
1227
|
+
// style.jsx — the project's house style, read from and written to a plain
|
|
1228
|
+
// markdown file sitting next to the .aep.
|
|
1229
|
+
//
|
|
1230
|
+
// Why here rather than in the MCP server: the bridge into After Effects is the
|
|
1231
|
+
// one channel every client has, because the whole product already depends on it.
|
|
1232
|
+
// A server-side file would need a project folder, and the clients that matter
|
|
1233
|
+
// most here are exactly the ones that do not have one — Claude Desktop starts
|
|
1234
|
+
// its servers at the filesystem root. Reading the style over the bridge needs no
|
|
1235
|
+
// working directory, no filesystem tools on the client, and no configuration.
|
|
1236
|
+
//
|
|
1237
|
+
// The cost is that the project must have been saved once. `app.project.file` is
|
|
1238
|
+
// null until then, and there is no folder to write into. That is reported, never
|
|
1239
|
+
// guessed around.
|
|
1240
|
+
|
|
1241
|
+
var HOUSE_STYLE_FILENAME = "house-style.md";
|
|
1242
|
+
|
|
1243
|
+
/** The .aep's folder, or null when the project has never been saved. */
|
|
1244
|
+
function __projectFolder() {
|
|
1245
|
+
var f = app.project.file;
|
|
1246
|
+
if (!f) return null;
|
|
1247
|
+
return f.parent;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function __houseStyleFile() {
|
|
1251
|
+
var folder = __projectFolder();
|
|
1252
|
+
if (!folder) return null;
|
|
1253
|
+
return new File(folder.fsName + "/" + HOUSE_STYLE_FILENAME);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** Shared shape for "there is nowhere to put it", so both ops explain it the same way. */
|
|
1257
|
+
function __unsavedProject() {
|
|
1258
|
+
return {
|
|
1259
|
+
found: false,
|
|
1260
|
+
projectSaved: false,
|
|
1261
|
+
path: null,
|
|
1262
|
+
content: null,
|
|
1263
|
+
reason:
|
|
1264
|
+
"This After Effects project has never been saved, so there is no folder to keep the style guide in. " +
|
|
1265
|
+
"Ask the user to save the project, then try again."
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
OPS.get_house_style = noUndo(function () {
|
|
1270
|
+
var file = __houseStyleFile();
|
|
1271
|
+
if (!file) return __unsavedProject();
|
|
1272
|
+
|
|
1273
|
+
if (!file.exists) {
|
|
1274
|
+
return {
|
|
1275
|
+
found: false,
|
|
1276
|
+
projectSaved: true,
|
|
1277
|
+
path: file.fsName,
|
|
1278
|
+
content: null,
|
|
1279
|
+
reason: "No style guide for this project yet. Offer to capture one from a comp the user likes."
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// UTF-8 explicitly: ExtendScript otherwise decodes with the system encoding,
|
|
1284
|
+
// which mangles any non-ASCII the designer typed (curly quotes, em dashes,
|
|
1285
|
+
// accented font names).
|
|
1286
|
+
file.encoding = "UTF-8";
|
|
1287
|
+
if (!file.open("r")) throw new Error("Could not open " + file.fsName + " for reading");
|
|
1288
|
+
var content;
|
|
1289
|
+
try { content = file.read(); }
|
|
1290
|
+
finally { file.close(); }
|
|
1291
|
+
|
|
1292
|
+
return {
|
|
1293
|
+
found: true,
|
|
1294
|
+
projectSaved: true,
|
|
1295
|
+
path: file.fsName,
|
|
1296
|
+
content: content,
|
|
1297
|
+
bytes: content.length
|
|
1298
|
+
};
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
OPS.set_house_style = noUndo(function (args) {
|
|
1302
|
+
var content = args && args.content;
|
|
1303
|
+
if (typeof content !== "string" || content.length === 0) {
|
|
1304
|
+
throw new Error("content is required and must not be empty");
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
var file = __houseStyleFile();
|
|
1308
|
+
if (!file) {
|
|
1309
|
+
var unsaved = __unsavedProject();
|
|
1310
|
+
throw new Error(unsaved.reason);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
var existed = file.exists;
|
|
1314
|
+
// Replacing someone's hand-written style guide is not something to do on a
|
|
1315
|
+
// half-remembered version of it, so the caller has to have read it first and
|
|
1316
|
+
// send the whole document back. Refusing here is cheaper than an apology.
|
|
1317
|
+
if (existed && args.overwrite !== true) {
|
|
1318
|
+
throw new Error(
|
|
1319
|
+
"A style guide already exists at " + file.fsName + ". Read it with get_house_style, " +
|
|
1320
|
+
"merge your changes into the full document, and call again with overwrite: true."
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
file.encoding = "UTF-8";
|
|
1325
|
+
if (!file.open("w")) throw new Error("Could not open " + file.fsName + " for writing");
|
|
1326
|
+
try {
|
|
1327
|
+
if (!file.write(content)) throw new Error("Write failed for " + file.fsName);
|
|
1328
|
+
} finally {
|
|
1329
|
+
file.close();
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
return {
|
|
1333
|
+
ok: true,
|
|
1334
|
+
path: file.fsName,
|
|
1335
|
+
bytes: content.length,
|
|
1336
|
+
created: !existed,
|
|
1337
|
+
replaced: existed
|
|
1338
|
+
};
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
|
|
1225
1342
|
// ===== batch.jsx =====
|
|
1226
1343
|
|
|
1227
1344
|
// batch.jsx — execute many ops in one ExtendScript pass.
|
package/panel/package.json
CHANGED