@engine-room/after-effects-mcp 0.4.0 → 0.5.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@engine-room/after-effects-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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",
@@ -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.4.0"
2
+ <ExtensionManifest Version="11.0" ExtensionBundleId="games.engine-room.ae-mcp" ExtensionBundleVersion="0.5.0"
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.4.0" />
5
+ <Extension Id="games.engine-room.ae-mcp.panel" Version="0.5.0" />
6
6
  </ExtensionList>
7
7
  <ExecutionEnvironment>
8
8
  <HostList>
@@ -212,10 +212,68 @@
212
212
  }
213
213
 
214
214
  // ---------- Port file (so MCP server can discover) ----------
215
+ // Written only once a port is actually bound, and only with that port. The
216
+ // file is a hint for the server, never the authority — the socket is — and
217
+ // the one way it lies is by describing a bind that did not happen or a panel
218
+ // that has since gone (issue #92). So it is written after `listen` succeeds
219
+ // and removed again on unload, and never with a port this panel merely tried.
220
+ var portDir = path.join(os.homedir(), ".engineroom-ae-mcp");
221
+ var portFile = path.join(portDir, "port");
222
+ var configFile = path.join(portDir, "config.json");
223
+
215
224
  function writePortFile(port) {
216
- var dir = path.join(os.homedir(), ".engineroom-ae-mcp");
217
- try { fs.mkdirSync(dir, { recursive: true }); } catch (e) {}
218
- try { fs.writeFileSync(path.join(dir, "port"), String(port), "utf8"); } catch (e) { log("warn", "could not write port file: " + e.message); }
225
+ try { fs.mkdirSync(portDir, { recursive: true }); } catch (e) {}
226
+ try { fs.writeFileSync(portFile, String(port), "utf8"); } catch (e) { log("warn", "could not write port file: " + e.message); }
227
+ }
228
+
229
+ // Only this panel's own entry. Another panel may have written the file since
230
+ // — a second After Effects instance, say — and its port is not ours to erase.
231
+ function removePortFile(port) {
232
+ try {
233
+ if (fs.readFileSync(portFile, "utf8").trim() === String(port)) fs.unlinkSync(portFile);
234
+ } catch (e) {}
235
+ }
236
+
237
+ // ---------- Config ----------
238
+ // A CEP panel cannot read an environment variable the user set: it runs in
239
+ // After Effects' process, with Adobe's environment. So its two knobs live in
240
+ // a JSON file beside the port file, ~/.engineroom-ae-mcp/config.json:
241
+ //
242
+ // { "port": 7777, "allowPortWalk": false }
243
+ //
244
+ // port the port to bind. Default 7777. 0 binds an ephemeral port,
245
+ // which is what the offline tests use.
246
+ // allowPortWalk when the port is held by another AE MCP panel, move to the
247
+ // next free one instead of waiting for it. For people who run
248
+ // two After Effects instances at once. Off by default: on one
249
+ // machine the holder is a zombie, and walking around it is
250
+ // what issue #92 was.
251
+ //
252
+ // Anything unreadable is logged and ignored; the defaults are what shipped.
253
+ var DEFAULT_PORT = 7777;
254
+ function readConfig() {
255
+ var cfg = { port: DEFAULT_PORT, allowPortWalk: false, bindRetryMs: 2000, source: null };
256
+ var raw;
257
+ try { raw = fs.readFileSync(configFile, "utf8"); } catch (e) { return cfg; }
258
+ var parsed;
259
+ try { parsed = JSON.parse(raw); }
260
+ catch (e) { log("warn", "ignoring " + configFile + " — not valid JSON (" + e.message + ")"); return cfg; }
261
+ if (!parsed || typeof parsed !== "object") {
262
+ log("warn", "ignoring " + configFile + " — expected a JSON object");
263
+ return cfg;
264
+ }
265
+ cfg.source = configFile;
266
+ if (parsed.port !== undefined) {
267
+ if (typeof parsed.port === "number" && parsed.port >= 0 && parsed.port <= 65535 && parsed.port === Math.floor(parsed.port)) cfg.port = parsed.port;
268
+ else log("warn", "ignoring \"port\" in " + configFile + " — expected a whole number 0-65535, got " + JSON.stringify(parsed.port));
269
+ }
270
+ if (parsed.allowPortWalk !== undefined) {
271
+ if (typeof parsed.allowPortWalk === "boolean") cfg.allowPortWalk = parsed.allowPortWalk;
272
+ else log("warn", "ignoring \"allowPortWalk\" in " + configFile + " — expected true or false");
273
+ }
274
+ // Undocumented on purpose: how often to retry a held port. Tests shorten it.
275
+ if (typeof parsed.bindRetryMs === "number" && parsed.bindRetryMs >= 50) cfg.bindRetryMs = parsed.bindRetryMs;
276
+ return cfg;
219
277
  }
220
278
 
221
279
  // ---------- WS broadcast ----------
@@ -814,9 +872,15 @@
814
872
  res.setHeader("content-type", "application/json");
815
873
  // bundleHash identifies the code this panel is *running*, which is what
816
874
  // the server needs to know before sending an op the panel may predate.
875
+ // `ok` + `bundleHash` is also how another copy of this panel, and the
876
+ // server's discovery, recognise this as an AE MCP panel — see
877
+ // isOurHealth below and isPanelHealth in the server's discovery.ts.
878
+ // The port is read off the socket, not the argument: port 0 binds an
879
+ // ephemeral one and the argument would say 0.
880
+ var addr = server.address();
817
881
  res.end(JSON.stringify({
818
882
  ok: true,
819
- port: port,
883
+ port: addr && addr.port ? addr.port : port,
820
884
  bundleLoaded: true,
821
885
  bundleHash: loadedBundleHash,
822
886
  ts: Date.now()
@@ -887,26 +951,156 @@
887
951
  });
888
952
  }
889
953
 
890
- function startServers(startPort) {
891
- var tries = 0;
892
- function attempt(p) {
893
- tries++;
954
+ // ---------- Who holds the port? ----------
955
+ // The same test the server's discovery makes (isPanelHealth in discovery.ts):
956
+ // `ok` plus `bundleHash` — or `bundleLoaded`, on a panel from before the hash
957
+ // existed — is what nothing else on a loopback port would answer with.
958
+ function isOurHealth(body) {
959
+ return !!body && typeof body === "object" && body.ok === true &&
960
+ ("bundleHash" in body || "bundleLoaded" in body);
961
+ }
962
+
963
+ // Never rejects: every outcome is a classification the caller acts on.
964
+ // panel — another AE MCP panel; its health body is attached
965
+ // other — something answered, but not as the panel
966
+ // silent — accepted the connection and said nothing, or errored
967
+ function identifyHolder(port) {
968
+ return new Promise(function (resolve) {
969
+ var done = false;
970
+ function finish(r) { if (!done) { done = true; resolve(r); } }
971
+ var req = http.get({ host: "127.0.0.1", port: port, path: "/health", timeout: 1500 }, function (res) {
972
+ var bufs = [];
973
+ res.on("data", function (c) { bufs.push(c); });
974
+ res.on("end", function () {
975
+ var body = null;
976
+ try { body = JSON.parse(Buffer.concat(bufs).toString("utf8")); } catch (e) {}
977
+ if (res.statusCode === 200 && isOurHealth(body)) finish({ kind: "panel", health: body });
978
+ else finish({ kind: "other", detail: "answered HTTP " + res.statusCode + (body ? " with an unfamiliar body" : " with no JSON") });
979
+ });
980
+ res.on("error", function (e) { finish({ kind: "silent", detail: e.message }); });
981
+ });
982
+ req.on("timeout", function () { req.destroy(); finish({ kind: "silent", detail: "accepted the connection but did not answer within 1.5s" }); });
983
+ req.on("error", function (e) { finish({ kind: "silent", detail: e.message }); });
984
+ });
985
+ }
986
+
987
+ // 7777 through 7799 was the old ceiling; kept as a distance so a configured
988
+ // start port walks the same number of steps.
989
+ var WALK_LIMIT = 22;
990
+
991
+ /**
992
+ * Bind the configured port, and decide what to do when it is taken.
993
+ *
994
+ * It used to walk: EADDRINUSE, try the next port, up to 7799, then write
995
+ * whatever it got to the port file. On one machine nothing else ever holds
996
+ * 7777, so the thing being walked around was always a previous instance of
997
+ * this same panel — a CEPHtmlEngine that lingered across an After Effects
998
+ * relaunch, or a second panel window — and the walk routed round it in
999
+ * silence, leaving the port file as a second source of truth that could
1000
+ * disagree with the socket. Four incidents in a week (issue #92).
1001
+ *
1002
+ * So the holder is identified first. If it is one of us, this panel does not
1003
+ * move: it says so in its status and log, and retries the bind on an interval
1004
+ * so it takes the port over the moment the other one exits, with no restart.
1005
+ * If it is something else, or answers nothing, the walk happens as before —
1006
+ * loudly, naming the holder and the port chosen, because that is the one
1007
+ * case where the port file legitimately says something other than 7777.
1008
+ * `allowPortWalk` in config.json opts back into walking past our own panel,
1009
+ * for people who really run two After Effects instances.
1010
+ */
1011
+ function startServers(cfg) {
1012
+ var startPort = cfg.port;
1013
+ var retryMs = cfg.bindRetryMs;
1014
+ var waits = 0;
1015
+
1016
+ function bindWith(p) {
894
1017
  return startHttp(p).then(function (server) {
1018
+ // Read back rather than assumed: port 0 binds an ephemeral one.
1019
+ var bound = server.address().port;
895
1020
  // Mount WS on the same HTTP server.
896
1021
  var wss = new WebSocket.Server({ server: server, path: "/events" });
897
1022
  wss.on("connection", function (ws) {
898
1023
  wsClients.add(ws);
899
1024
  ws.on("close", function () { wsClients.delete(ws); });
900
1025
  });
901
- return { port: p, server: server, wss: wss };
902
- }).catch(function (e) {
903
- if (e.code === "EADDRINUSE" && tries < 23 && p < 7799) {
904
- return attempt(p + 1);
905
- }
906
- throw e;
1026
+ return { port: bound, server: server, wss: wss };
907
1027
  });
908
1028
  }
909
- return attempt(startPort);
1029
+
1030
+ function walk(p, tries, why) {
1031
+ var next = p + 1;
1032
+ if (tries >= WALK_LIMIT) {
1033
+ throw new Error("ports " + startPort + " through " + p + " are all in use — nothing free to bind. Last holder: " + why);
1034
+ }
1035
+ log("warn", "port " + p + " is held by " + why + " — moving to port " + next + ". " +
1036
+ "The MCP server finds this port through the port file; if tool calls fail on " + p +
1037
+ " while check_setup passes, this is why, and the server re-checks the port on the next refused call.");
1038
+ return attempt(next, tries + 1);
1039
+ }
1040
+
1041
+ function waitForHolder(p, holder) {
1042
+ var same = holder.health.bundleHash && holder.health.bundleHash === loadedBundleHash;
1043
+ var hash = holder.health.bundleHash
1044
+ ? String(holder.health.bundleHash).slice(0, 12)
1045
+ : "no bundleHash — a panel older than 0.3";
1046
+ setStatus("waiting — port " + p + " is held by another AE MCP panel", "warn");
1047
+ if (waits === 0) {
1048
+ log("warn", "port " + p + " is already held by another AE MCP panel (bundle " + hash + ", " +
1049
+ (same ? "the same version as this one" : "a DIFFERENT version from this one") + "). " +
1050
+ "Not moving to another port: that would leave the port file pointing at whichever panel bound last, " +
1051
+ "and the MCP server talking to the wrong one (issue #92).");
1052
+ log("warn", "Retrying the bind every " + (retryMs / 1000) + "s; this panel takes the port over the moment " +
1053
+ "the other one exits, with no restart. If After Effects was relaunched and this persists, the previous " +
1054
+ "panel process is still running: quit After Effects fully, check Activity Monitor / Task Manager for a " +
1055
+ "leftover CEPHtmlEngine, and reopen it. To run two After Effects at once instead, set " +
1056
+ "\"allowPortWalk\": true in " + configFile + ".");
1057
+ } else if (waits % 30 === 0) {
1058
+ log("info", "still waiting for port " + p + " (" + waits + " attempts)");
1059
+ }
1060
+ waits += 1;
1061
+ return new Promise(function (r) { setTimeout(r, retryMs); }).then(function () { return attempt(p, 0); });
1062
+ }
1063
+
1064
+ function attempt(p, tries) {
1065
+ return bindWith(p).catch(function (e) {
1066
+ if (e.code !== "EADDRINUSE") throw e;
1067
+ return identifyHolder(p).then(function (holder) {
1068
+ if (holder.kind === "panel") {
1069
+ if (cfg.allowPortWalk) {
1070
+ return walk(p, tries, "another AE MCP panel, and allowPortWalk is on in " + configFile);
1071
+ }
1072
+ return waitForHolder(p, holder);
1073
+ }
1074
+ var why = holder.kind === "other"
1075
+ ? "something that is not an AE MCP panel (" + holder.detail + ")"
1076
+ : "something that did not identify itself (" + holder.detail + ")";
1077
+ return walk(p, tries, why);
1078
+ });
1079
+ });
1080
+ }
1081
+ return attempt(startPort, 0);
1082
+ }
1083
+
1084
+ // ---------- Shutdown ----------
1085
+ // When the panel window closes or After Effects quits, give the port back and
1086
+ // take this panel's port-file entry with it. A panel that exits cleanly must
1087
+ // not leave a file naming a port nobody listens on — that is the other half
1088
+ // of how the port file came to disagree with the socket (issue #92). A
1089
+ // process that dies without unloading still leaves the file; the server's
1090
+ // discovery treats the file as a hint for exactly that reason.
1091
+ var running = null;
1092
+ function shutdown() {
1093
+ if (!running) return;
1094
+ var s = running;
1095
+ running = null;
1096
+ try { s.wss.close(); } catch (e) {}
1097
+ try { if (typeof s.server.closeAllConnections === "function") s.server.closeAllConnections(); } catch (e) {}
1098
+ try { s.server.close(); } catch (e) {}
1099
+ removePortFile(s.port);
1100
+ }
1101
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
1102
+ window.addEventListener("beforeunload", shutdown);
1103
+ window.addEventListener("unload", shutdown);
910
1104
  }
911
1105
 
912
1106
  // ---------- Boot sequence ----------
@@ -916,14 +1110,22 @@
916
1110
  $ae.textContent = (host && host.appName ? host.appName : "AE") + " " + (host && host.appVersion ? host.appVersion : "");
917
1111
  } catch (e) {}
918
1112
 
1113
+ var config = readConfig();
1114
+ if (config.source) {
1115
+ log("info", "read " + config.source + ": port " + config.port + (config.allowPortWalk ? ", allowPortWalk on" : ""));
1116
+ }
1117
+
919
1118
  loadJsxBundle()
920
- .then(function () { return startServers(7777); })
1119
+ .then(function () { return startServers(config); })
921
1120
  .then(function (s) {
1121
+ running = s;
1122
+ // The only write of the port file, and only with the port actually bound.
922
1123
  writePortFile(s.port);
923
1124
  $port.textContent = String(s.port);
924
1125
  $port.className = "ok";
925
1126
  setStatus("ready", "ok");
926
- log("info", "Bridge listening on http://127.0.0.1:" + s.port);
1127
+ log("info", "Bridge listening on http://127.0.0.1:" + s.port +
1128
+ (s.port !== config.port && config.port !== 0 ? " (configured port " + config.port + " was taken — see above)" : ""));
927
1129
  })
928
1130
  .catch(function (e) {
929
1131
  setStatus("failed", "err");