@engine-room/after-effects-mcp 0.5.2 → 0.6.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.
Files changed (37) hide show
  1. package/README.md +126 -185
  2. package/bin/server.js +12555 -2477
  3. package/package.json +8 -8
  4. package/panel/CSXS/manifest.xml +44 -4
  5. package/panel/META-INF/signatures.xml +182 -0
  6. package/panel/client/csinterface.js +32 -0
  7. package/panel/client/main.js +522 -632
  8. package/panel/client/panelhash.js +79 -0
  9. package/panel/client/status.html +27 -0
  10. package/panel/client/status.js +112 -0
  11. package/panel/jsx/bundle.jsx +8957 -2514
  12. package/panel/mimetype +1 -0
  13. package/panel/node_modules/ws/LICENSE +20 -0
  14. package/panel/node_modules/ws/README.md +548 -0
  15. package/panel/node_modules/ws/browser.js +8 -0
  16. package/panel/node_modules/ws/index.js +22 -0
  17. package/panel/node_modules/ws/lib/buffer-util.js +131 -0
  18. package/panel/node_modules/ws/lib/constants.js +19 -0
  19. package/panel/node_modules/ws/lib/event-target.js +292 -0
  20. package/panel/node_modules/ws/lib/extension.js +203 -0
  21. package/panel/node_modules/ws/lib/limiter.js +55 -0
  22. package/panel/node_modules/ws/lib/permessage-deflate.js +530 -0
  23. package/panel/node_modules/ws/lib/receiver.js +743 -0
  24. package/panel/node_modules/ws/lib/sender.js +607 -0
  25. package/panel/node_modules/ws/lib/stream.js +161 -0
  26. package/panel/node_modules/ws/lib/subprotocol.js +62 -0
  27. package/panel/node_modules/ws/lib/validation.js +152 -0
  28. package/panel/node_modules/ws/lib/websocket-server.js +562 -0
  29. package/panel/node_modules/ws/lib/websocket.js +1421 -0
  30. package/panel/node_modules/ws/package.json +74 -0
  31. package/panel/node_modules/ws/wrapper.mjs +21 -0
  32. package/panel/package.json +2 -2
  33. package/panel/client/contactsheet.js +0 -347
  34. package/panel/client/framecache.js +0 -98
  35. package/panel/client/framereader.js +0 -248
  36. package/panel/client/mogrt.js +0 -328
  37. package/panel/client/pngcodec.js +0 -524
@@ -23,12 +23,39 @@
23
23
  var $jsx = document.getElementById("jsx");
24
24
  var $reqs = document.getElementById("reqs");
25
25
  var $log = document.getElementById("log");
26
- function setStatus(text, klass) {
27
- $status.textContent = text;
28
- $status.className = klass || "";
26
+ // The bridge runs as a hidden extension (issue #283), so this page's DOM is
27
+ // seen by nobody unless the manifest is flipped back to a visible Panel. What
28
+ // it shows is mirrored to the separate status panel (client/status.js) over
29
+ // a CEP event: the whole state, at most every 250ms, and at once when the
30
+ // status panel asks. The status panel never runs a bridge of its own.
31
+ // Event types are their own namespace, never an extension id.
32
+ var STATUS_EVENT = "games.engine-room.ae-mcp.event.status";
33
+ var STATUS_REQUEST = "games.engine-room.ae-mcp.event.status-request";
34
+ var mirror = { status: "", statusClass: "", port: "", portClass: "", ae: "", jsx: "", jsxClass: "", reqs: 0, log: [] };
35
+ var mirrorCs = null;
36
+ var mirrorTimer = null;
37
+ function publishNow() {
38
+ mirrorTimer = null;
39
+ if (!mirrorCs) return;
40
+ try {
41
+ var ev = new CSEvent(STATUS_EVENT, "APPLICATION");
42
+ ev.data = JSON.stringify(mirror);
43
+ mirrorCs.dispatchEvent(ev);
44
+ } catch (e) {}
45
+ }
46
+ function publish() {
47
+ if (mirrorCs && mirrorTimer === null) mirrorTimer = setTimeout(publishNow, 250);
29
48
  }
49
+ function setField(name, $el, text, klass) {
50
+ $el.textContent = text;
51
+ if (klass !== undefined) $el.className = klass;
52
+ mirror[name] = text;
53
+ if (klass !== undefined) mirror[name + "Class"] = klass;
54
+ publish();
55
+ }
56
+ function setStatus(text, klass) { setField("status", $status, text, klass || ""); }
30
57
  var __reqCount = 0;
31
- function bumpReq() { __reqCount += 1; $reqs.textContent = String(__reqCount); }
58
+ function bumpReq() { __reqCount += 1; $reqs.textContent = String(__reqCount); mirror.reqs = __reqCount; publish(); }
32
59
  function log(level, msg) {
33
60
  var d = document.createElement("div");
34
61
  d.className = (level === "error" ? "err" : level === "warn" ? "warn" : "");
@@ -37,6 +64,9 @@
37
64
  $log.insertBefore(d, $log.firstChild);
38
65
  // Trim
39
66
  while ($log.childNodes.length > 80) $log.removeChild($log.lastChild);
67
+ mirror.log.unshift({ level: d.className, text: d.textContent });
68
+ if (mirror.log.length > 80) mirror.log.length = 80;
69
+ publish();
40
70
  }
41
71
 
42
72
  // CSInterface comes from the plain <script> tag before this one, so it needs
@@ -52,6 +82,13 @@
52
82
  cs = new CSInterface();
53
83
  extDir = cs.getSystemPath(SystemPath.EXTENSION);
54
84
  clientDir = path.join(extDir, "client");
85
+ // Only where CEP provides events: without them there is no status panel
86
+ // to tell, and nothing is scheduled.
87
+ if (window.__adobe_cep__ && window.__adobe_cep__.dispatchEvent && window.__adobe_cep__.addEventListener) {
88
+ mirrorCs = cs;
89
+ cs.addEventListener(STATUS_REQUEST, publishNow);
90
+ publish();
91
+ }
55
92
  } catch (e) {
56
93
  setStatus("cannot start — CSInterface is unavailable", "err");
57
94
  log("error", "new CSInterface() failed: " + e.message);
@@ -76,10 +113,9 @@
76
113
  }
77
114
  }
78
115
 
79
- // The screenshot checks live in their own files so a Node test can require
80
- // them without a DOM: there is no After Effects on a CI runner, and neither
81
- // real image code nor stale-buffer bookkeeping should be written blind.
82
- // Same failure discipline as ws above — say what is missing, name the fix.
116
+ // The panel's own modules live in their own files so a Node test can require
117
+ // them without a DOM. Same failure discipline as ws above — say what is
118
+ // missing, name the fix.
83
119
  //
84
120
  // The list is ordered by how much it is trusted, not by convenience.
85
121
  // clientDir comes from CSInterface and is what the shipped layout actually
@@ -100,45 +136,151 @@
100
136
  }
101
137
  throw new Error("could not load " + name + " from any of: " + failures.join("; "));
102
138
  }
103
- var pngCodec, frameCacheModule, mogrtModule, contactSheetModule, frameReaderModule;
139
+ var panelHashModule;
104
140
  try {
105
- pngCodec = requireSibling("pngcodec.js");
106
- frameCacheModule = requireSibling("framecache.js");
107
- mogrtModule = requireSibling("mogrt.js");
108
- contactSheetModule = requireSibling("contactsheet.js");
109
- frameReaderModule = requireSibling("framereader.js");
141
+ panelHashModule = requireSibling("panelhash.js");
110
142
  } catch (e) {
111
- setStatus("cannot start — one of the panel's file-processing modules is missing", "err");
112
- log("error", "loading the panel's file-processing modules from " + __dirname + " failed: " + e.message);
143
+ setStatus("cannot start — one of the panel's modules is missing", "err");
144
+ log("error", "loading the panel's modules from " + __dirname + " failed: " + e.message);
113
145
  log("error", "Quit After Effects, run the setup_panel tool, then reopen it.");
114
146
  return;
115
147
  }
116
- var frameCache = frameCacheModule.createFrameCache();
148
+
149
+ // ---------- Which panel code is this? (issue #243) ----------
150
+ // Every file of this panel except bundle.jsx, hashed once, now: the files
151
+ // CEP loaded at launch are the ones this process runs until After Effects
152
+ // restarts, whatever setup_panel writes over them later. Reported on /health
153
+ // beside bundleHash, and the server's version gate needs both to match the
154
+ // panel it ships — bundleHash alone let a current bundle beside old client
155
+ // code through. The algorithm is panelhash.js, ported to the server as
156
+ // setup/panelHash.ts; the signature files a self-signed install adds are
157
+ // left out of both, so a signed install is a current install.
158
+ var loadedClientHash = panelHashModule.panelClientHash(extDir);
159
+ if (!loadedClientHash) log("warn", "could not hash the panel's own files in " + extDir + "; the MCP server cannot confirm this panel is current and will ask for it to be reinstalled");
117
160
 
118
161
  var bundlePath = path.join(extDir, "jsx", "bundle.jsx");
119
162
 
120
163
  // ---------- ExtendScript evalScript: serialized via Promise chain ----------
164
+ // `opts.gone`: asked when the call's turn comes, before anything is handed to
165
+ // After Effects. A reason (string) means the MCP server has already given up
166
+ // on the call — its deadline passed, or it closed the request — so the call
167
+ // is dropped instead of run late and out of order behind whatever held After
168
+ // Effects. A call already handed to evalScript is never touched. `opts.label`
169
+ // is the op, reported on /health while it runs (`evalBusy`); absent for the
170
+ // bundle load.
171
+ var DEADLINE_GRACE_MS = 1000;
172
+ // Milliseconds on a monotonic clock: no wall-clock change or sleep moves it.
173
+ function monoNow() {
174
+ if (typeof performance !== "undefined" && performance && typeof performance.now === "function") return performance.now();
175
+ if (typeof process !== "undefined" && process.hrtime) { var t = process.hrtime(); return t[0] * 1000 + t[1] / 1e6; }
176
+ return Date.now();
177
+ }
121
178
  var __evalChain = Promise.resolve();
122
- function evalScript(script) {
179
+ var __evalPending = 0;
180
+ var __evalRunning = null;
181
+ function evalScript(script, opts) {
182
+ opts = opts || {};
183
+ __evalPending += 1;
123
184
  var p = __evalChain.then(function () {
185
+ // One turn of the event loop first, so Node reads the sockets that closed
186
+ // while ExtendScript held this thread — a request read only once AE let
187
+ // go may already have been abandoned — and `gone` sees it.
188
+ return opts.gone ? new Promise(function (r) { setTimeout(r, 0); }) : null;
189
+ }).then(function () {
190
+ var why = opts.gone ? opts.gone() : null;
191
+ if (why) {
192
+ var gone = new Error("dropped before it ran: " + why);
193
+ gone.code = "REQUEST_CLOSED";
194
+ throw gone;
195
+ }
124
196
  return new Promise(function (resolve, reject) {
197
+ __evalRunning = { op: opts.label || "script", since: Date.now() };
125
198
  cs.evalScript(script, function (raw) {
126
- if (raw === "EvalScript error.") return reject(new Error("ExtendScript eval failed (syntax or runtime)"));
199
+ __evalRunning = null;
200
+ if (raw === "EvalScript error.") {
201
+ // The bundle load (no label) is a whole file, and there this
202
+ // answer is what a parse or runtime error in it looks like.
203
+ if (!opts.label) return reject(new Error("ExtendScript eval failed (syntax or runtime)"));
204
+ // An op is one fixed line whose own failures come back as JSON, so
205
+ // this bare answer is most often After Effects declining to run it
206
+ // while the user has it held. The server words it per op.
207
+ var refused = new Error("After Effects answered \"EvalScript error.\" for " + opts.label +
208
+ " — most often it declined to run it while busy (a dialog, a menu, a text edit, a preview " +
209
+ "or a render). Read back before re-sending.");
210
+ refused.code = "EVAL_SCRIPT_ERROR";
211
+ return reject(refused);
212
+ }
127
213
  resolve(raw);
128
214
  });
129
215
  });
130
216
  });
217
+ var settle = function () { __evalPending -= 1; };
218
+ p.then(settle, settle);
131
219
  // Keep the chain alive even on rejection so subsequent calls aren't blocked.
132
220
  __evalChain = p.catch(function () { return null; });
133
221
  return p;
134
222
  }
135
223
 
224
+ function evalBusyNow() {
225
+ return __evalRunning ? { op: __evalRunning.op, ms: Date.now() - __evalRunning.since } : null;
226
+ }
227
+
228
+ // ---------- The bundle's one global (issue #185) ----------
229
+ // After Effects runs every extension's evalScript, File > Scripts, ScriptUI
230
+ // panels and osascript in one shared ExtendScript engine. The bundle keeps
231
+ // everything in a closure and publishes only `__AEMCP` (scripts/bundle-jsx.mjs),
232
+ // so another script can no longer replace `dispatch` or `OPS` under us. It can
233
+ // still delete or overwrite `__AEMCP` itself, so every op checks it is ours
234
+ // first and answers NS_MISSING — before running anything — when it is not.
235
+ var NS_MISSING = "__AEMCP_MISSING__";
236
+ var NS_READY = "(typeof __AEMCP === 'object' && __AEMCP !== null && " +
237
+ "__AEMCP.id === 'games.engine-room.ae-mcp' && typeof __AEMCP.call === 'function')";
238
+
239
+ // U+2028 and U+2029 are legal raw inside JSON but are line terminators in an
240
+ // ES3 string literal, and JSON.stringify leaves them raw — so a text layer or
241
+ // run_jsx script containing one became an unterminated literal and the whole
242
+ // op died as "EvalScript error." (issue #163). Escaped in the JSON text, they
243
+ // survive both the literal and ExtendScript's JSON.parse as the same
244
+ // characters; the literal is escaped too, as a second line of defence.
245
+ function escapeLineSeparators(json) {
246
+ return json.replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
247
+ }
248
+
249
+ function opScript(payload) {
250
+ // JSON.stringify on the panel side, JSON.parse on the ExtendScript side.
251
+ var literal = escapeLineSeparators(JSON.stringify(escapeLineSeparators(payload)));
252
+ return NS_READY + " ? __AEMCP.call(" + literal + ") : '" + NS_MISSING + "'";
253
+ }
254
+
255
+ // One reload for however many ops found the namespace gone at once.
256
+ var __healing = null;
257
+ function healNamespace(op) {
258
+ if (!__healing) {
259
+ log("warn", "__AEMCP is missing or not ours (another script in After Effects' shared engine " +
260
+ "replaced it) — reloading bundle.jsx before " + op);
261
+ __healing = loadJsxBundle({ sameAsRunning: true }).then(
262
+ function () { __healing = null; setStatus("ready", "ok"); },
263
+ function (e) { __healing = null; throw e; }
264
+ );
265
+ }
266
+ return __healing;
267
+ }
268
+
136
269
  // Run an op through the dispatch table. Returns parsed JS object.
137
- function runOp(op, args) {
270
+ // `gone` (optional): see evalScript — the op is dropped unrun once it answers a reason.
271
+ function runOp(op, args, gone) {
138
272
  var payload = JSON.stringify({ op: op, args: args || {} });
139
- // Use \uXXXX-safe JSON pass: JSON.stringify on the panel side, JSON.parse on ExtendScript side.
140
- var script = "JSON.stringify(dispatch(" + JSON.stringify(payload) + "))";
141
- return evalScript(script).then(function (raw) {
273
+ var evalOpts = { label: op, gone: gone };
274
+ return evalScript(opScript(payload), evalOpts).then(function (raw) {
275
+ if (raw !== NS_MISSING) return raw;
276
+ // Nothing ran: NS_MISSING is answered instead of the op, so sending it
277
+ // again after the reload cannot run it twice.
278
+ return healNamespace(op).then(function () { return evalScript(opScript(payload), evalOpts); });
279
+ }).then(function (raw) {
280
+ if (raw === NS_MISSING) {
281
+ throw new Error("The AE MCP bundle is not loaded in After Effects (__AEMCP missing even after reloading " +
282
+ "bundle.jsx). Nothing ran. Restart After Effects.");
283
+ }
142
284
  if (raw === "undefined" || raw === undefined || raw === null) {
143
285
  throw new Error("ExtendScript returned undefined for op " + op);
144
286
  }
@@ -161,7 +303,12 @@
161
303
  sourceText: parsed.sourceText,
162
304
  sourceName: parsed.sourceName,
163
305
  rawLine: parsed.rawLine,
164
- lineCount: parsed.lineCount
306
+ lineCount: parsed.lineCount,
307
+ // `_run_jsx` (stage 6) leaves the mapping to the server and sends
308
+ // these instead. Absent on every other failure, and JSON drops an
309
+ // undefined, so they never reach the server unless they were set.
310
+ wrapperLine: parsed.wrapperLine,
311
+ libraryIndex: parsed.libraryIndex
165
312
  };
166
313
  }
167
314
  throw err;
@@ -170,6 +317,25 @@
170
317
  });
171
318
  }
172
319
 
320
+ // ---------- What this panel supports ----------
321
+ // Reported on /health as `capabilities` (issue #220). Since issue #243 the
322
+ // server has no other path for any of these, and its version gate refuses a
323
+ // panel that is not the one it ships; the list is what that gate falls back
324
+ // on when it has no shipped panel to hash, so it must name everything the
325
+ // server relies on:
326
+ // project-path — `_project_path` answers the .aep's path with no project
327
+ // walk, so the server reads and writes house-style.md itself.
328
+ // server-images — `_screenshot_frame`, `_screenshot_layer` and
329
+ // `_export_mogrt` reach the bundle untouched and answer with
330
+ // file paths; the server reads the PNGs and patches the
331
+ // .mogrt itself.
332
+ // server-run-jsx — `_run_jsx` runs a script the server assembled, and a
333
+ // failure carries `wrapperLine`/`libraryIndex` (forwarded
334
+ // above) for the server to map onto the caller's file.
335
+ // server-drives-jobs — a chunked run_batch is handed back with
336
+ // `driver: "server"`, and the server sends each chunk.
337
+ var PANEL_CAPABILITIES = ["project-path", "server-images", "server-run-jsx", "server-drives-jobs"];
338
+
173
339
  // ---------- Load bundle.jsx ----------
174
340
  // Hash of the bundle *actually evaluated into ExtendScript*, reported on
175
341
  // /health. The server compares it against the bundle it ships to decide
@@ -189,25 +355,43 @@
189
355
  }
190
356
  }
191
357
 
192
- function loadJsxBundle() {
358
+ // `opts.sameAsRunning`: the automatic heal, which must re-load the code this
359
+ // panel is already running and nothing newer. Between setup_panel and the
360
+ // next After Effects launch the installed bundle.jsx is newer than this
361
+ // main.js; loading it would mix the two and move `loadedBundleHash` onto the
362
+ // new hash, so the version gate would call a stale panel current. The heal
363
+ // refuses instead and the running hash keeps saying "restart needed".
364
+ // `/reload-jsx` is the dev route and loads whatever is on disk.
365
+ function loadJsxBundle(opts) {
193
366
  setStatus("Loading bundle.jsx…");
194
367
  if (!fs.existsSync(bundlePath)) {
195
368
  setStatus("bundle.jsx missing", "err");
196
369
  log("error", "bundle.jsx not found at " + bundlePath + " — run `npm run build:jsx`");
197
- throw new Error("bundle.jsx missing");
370
+ return Promise.reject(new Error("bundle.jsx missing"));
371
+ }
372
+ var diskHash = hashFile(bundlePath);
373
+ if (opts && opts.sameAsRunning && loadedBundleHash && diskHash !== loadedBundleHash) {
374
+ setStatus("restart After Effects", "err");
375
+ return Promise.reject(new Error(
376
+ "The AE MCP bundle was removed from After Effects' shared script engine by another script, and the " +
377
+ "installed bundle.jsx is a newer version than this running panel, so it cannot be reloaded in place. " +
378
+ "Nothing ran. Restart After Effects."));
198
379
  }
199
380
  // Use $.evalFile from ExtendScript side so paths work cross-platform.
200
- var loadScript = "$.evalFile(" + JSON.stringify(bundlePath) + "); typeof dispatch === 'function' ? 'ok' : 'no-dispatch';";
201
- return new Promise(function (resolve, reject) {
202
- cs.evalScript(loadScript, function (raw) {
203
- if (raw === "ok") {
204
- loadedBundleHash = hashFile(bundlePath);
205
- $jsx.textContent = "loaded";
206
- $jsx.className = "ok";
207
- resolve();
208
- }
209
- else { $jsx.textContent = "fail: " + raw; $jsx.className = "err"; reject(new Error("dispatch not defined after loading bundle.jsx: " + raw)); }
210
- });
381
+ // Through the evalScript mutex like every other call: the heal triggers
382
+ // this while ops are queued, and a load must not interleave with one.
383
+ var loadScript = "$.evalFile(" + JSON.stringify(bundlePath) + "); " + NS_READY + " ? 'ok' : 'no-namespace';";
384
+ return evalScript(loadScript).then(function (raw) {
385
+ if (raw === "ok") {
386
+ loadedBundleHash = diskHash;
387
+ setField("jsx", $jsx, "loaded", "ok");
388
+ return;
389
+ }
390
+ setField("jsx", $jsx, "fail: " + raw, "err");
391
+ throw new Error("__AEMCP not defined after loading bundle.jsx: " + raw);
392
+ }, function (e) {
393
+ setField("jsx", $jsx, "fail: " + e.message, "err");
394
+ throw e;
211
395
  });
212
396
  }
213
397
 
@@ -312,11 +496,12 @@
312
496
  //
313
497
  // port the port to bind. Default 7777. 0 binds an ephemeral port,
314
498
  // which is what the offline tests use.
315
- // allowPortWalk when the port is held by another AE MCP panel, move to the
316
- // next free one instead of waiting for it. For people who run
317
- // two After Effects instances at once. Off by default: on one
318
- // machine the holder is a zombie, and walking around it is
319
- // what issue #92 was.
499
+ // allowPortWalk when the port is held by another AE MCP panel, or by
500
+ // something that accepts connections and never answers, move
501
+ // to the next free one instead of waiting for it. For people
502
+ // who run two After Effects instances at once. Off by default:
503
+ // on one machine the holder is a zombie or a busy panel, and
504
+ // walking around it is what issue #92 was.
320
505
  //
321
506
  // Anything unreadable is logged and ignored; the defaults are what shipped.
322
507
  var DEFAULT_PORT = 7777;
@@ -345,573 +530,70 @@
345
530
  return cfg;
346
531
  }
347
532
 
348
- // ---------- WS broadcast ----------
533
+ // ---------- Event socket ----------
534
+ // /events is still served (see startServers): the MCP server reads its
535
+ // reopening as a sign that After Effects restarted. No job events are sent
536
+ // on it since issue #243 — the server drives every batch itself.
349
537
  var wsClients = new Set();
350
- function broadcast(eventObj) {
351
- var str = JSON.stringify(eventObj);
352
- wsClients.forEach(function (c) {
353
- try { if (c.readyState === 1) c.send(str); } catch (e) {}
354
- });
355
- }
356
-
357
- // ---------- Vision: read PNG and base64-encode (for screenshot_* ops) ----------
358
- // Deciding when After Effects has finished writing a frame is issue #45, and
359
- // it lives in framereader.js so a unit test can drive it with a file it grows
360
- // by hand. What matters here: `waitForCompletePng` resolves only with bytes
361
- // that parse end-to-end as a PNG, deletes the temp file on every path, and
362
- // rejects with FRAME_INCOMPLETE or RENDER_TIMEOUT — never with one message
363
- // covering both.
364
- var waitForCompletePng = frameReaderModule.waitForCompletePng;
365
- var dressFrameError = frameReaderModule.dressFrameError;
366
- var frameError = frameReaderModule.frameError;
367
- var unlinkQuietly = frameReaderModule.unlinkQuietly;
368
-
369
- function sha256(bytes) {
370
- return crypto.createHash("sha256").update(bytes).digest("hex");
371
- }
372
-
373
- // Downsampling happens in ExtendScript via the comp's resolutionFactor, so
374
- // the file on disk is already the right size by the time we get here.
375
- //
376
- // What is *not* already right is the bit depth: a 16-bit project renders
377
- // 16-bit-per-channel PNGs, which many decoders refuse outright. pngcodec
378
- // converts those to 8-bit, tells us when the frame is entirely transparent,
379
- // and hands back the decoded samples so a frame can be recognised if After
380
- // Effects serves the same buffer again for a different request.
381
- function readPngFrame(file) {
382
- // waitForCompletePng owns the temp file and removes it on every path,
383
- // success or failure. A file left behind is one a later read could find,
384
- // which is how a corrupt frame would start coming back for free.
385
- return waitForCompletePng(file).then(function (buf) {
386
- var norm;
387
- try {
388
- norm = pngCodec.normalizePng(buf);
389
- } catch (e) {
390
- // The completeness gate above guarantees this file ends in a well-formed
391
- // IEND, so a truncation error here would mean the gate is broken. Refuse
392
- // rather than ship: "never report success for work that didn't happen"
393
- // outranks the fallback below.
394
- if (/truncated PNG/.test(e.message)) {
395
- throw frameError("FRAME_INCOMPLETE", "the file passed the completeness check and then failed to parse (" + e.message + ")");
396
- }
397
- // A complete PNG this parser cannot read is still a PNG the client
398
- // might. Ship it unchanged rather than throwing away a render that did
399
- // happen — but say so, and fall back to hashing the file so the stale
400
- // check survives.
401
- log("warn", "could not normalise the frame (" + e.message + "); sending it unconverted");
402
- var dims = pngDimensions(buf);
403
- return {
404
- buffer: buf,
405
- width: dims ? dims.width : null,
406
- height: dims ? dims.height : null,
407
- empty: false,
408
- hash: sha256(buf),
409
- warning: "This frame could not be normalised to 8-bit (" + e.message +
410
- ") and was sent exactly as After Effects wrote it. If it fails to decode, " +
411
- "that is why.",
412
- };
413
- }
414
- if (norm.empty) {
415
- return { empty: true, width: norm.width, height: norm.height };
416
- }
417
- if (norm.converted) {
418
- log("info", "converted a " + norm.bitDepth + "-bit frame to 8-bit (" +
419
- buf.length + " -> " + norm.buffer.length + " bytes)");
420
- }
421
- return {
422
- buffer: norm.buffer,
423
- width: norm.width,
424
- height: norm.height,
425
- empty: false,
426
- converted: norm.converted,
427
- sourceBitDepth: norm.bitDepth,
428
- // Decoded samples, kept only so the contact sheet can composite them
429
- // without decoding the same PNG a second time. Null on the passthrough
430
- // path, where the pixels were never interpreted.
431
- pixels: norm.decoded ? norm.hashInput : null,
432
- channels: norm.channels,
433
- hash: sha256(norm.hashInput),
434
- // The pixels could not be read, so "empty" was never evaluated and the
435
- // hash is of the compressed stream. Both are still better than nothing;
436
- // saying which is what stops a later reader trusting the wrong one.
437
- warning: norm.passthrough
438
- ? "This frame's pixels were not inspected (" + norm.passthrough +
439
- "), so it was sent exactly as After Effects wrote it."
440
- : undefined,
441
- };
442
- });
443
- }
444
-
445
- // Read the true pixel dimensions out of the PNG's IHDR chunk rather than
446
- // computing them, so what we report is always what the client received.
447
- function pngDimensions(buf) {
448
- if (buf.length < 24) return null;
449
- if (buf.readUInt32BE(12) !== 0x49484452) return null; // "IHDR"
450
- return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
451
- }
452
-
453
- // ---------- Rendering one frame, with one bounded retry ----------
454
- function renderFrameOnce(op, args) {
455
- return runOp(op, args).then(function (info) {
456
- return readPngFrame(info.path).then(function (img) {
457
- return { info: info, img: img };
458
- });
459
- });
460
- }
461
538
 
462
- /**
463
- * Render a frame, and re-render exactly once if what came back was corrupt.
464
- *
465
- * Safe to repeat, which is the only reason this is here. Both screenshot ops
466
- * are read-only: `screenshot_frame` renders to a temp file, `screenshot_layer`
467
- * additionally solos a layer and restores every solo state in a `finally`.
468
- * Neither leaves anything in the project, so a second run is
469
- * indistinguishable from one run — this is not `run_jsx`, where re-running a
470
- * script that already had its side effects duplicates them (issue #43).
471
- *
472
- * Only on FRAME_INCOMPLETE, never on RENDER_TIMEOUT. A timeout has already
473
- * spent the 120s budget; a second one would take the op past the server's
474
- * 300s ceiling and turn a precise "the render did not finish" into a bridge
475
- * timeout, whose remedy ("do not restart anything, wait") is a different
476
- * remedy for a different problem. A corrupt read, by contrast, fails within
477
- * FRAME_STALL_MS, and the evidence in #45 is that a retry sometimes succeeds
478
- * — a light comp returned one truncated frame and then rendered.
479
- *
480
- * The temp file of a failed attempt is deleted before the retry (in
481
- * `waitForCompletePng`), and ExtendScript names a fresh one per call, so a
482
- * retry can never be handed the previous attempt's bytes.
483
- */
484
- function renderFrameWithRetry(op, args) {
485
- return renderFrameOnce(op, args).catch(function (first) {
486
- if (first.code !== "FRAME_INCOMPLETE") throw dressFrameError(first, 1);
487
- log("warn", op + ": " + first.detail + " — re-rendering once");
488
- return renderFrameOnce(op, args).catch(function (second) {
489
- throw dressFrameError(second, 2);
490
- });
491
- });
539
+ // ---------- Batches in flight ----------
540
+ // A chunked run_batch is handed back to the server (`driver: "server"`),
541
+ // which sends each `_continue_job` itself, so progress, cancel and the
542
+ // job's state are the server's. Each chunk opens and closes its own undo
543
+ // group inside one evalScript (issue #69). Only what /reload-jsx needs is
544
+ // kept here: the job table lives in the bundle's closure, so a reload would
545
+ // drop each of these mid-run, and /reload-jsx reads this to refuse instead
546
+ // (issue #163). Jobs are tracked as their chunks pass through, and one no
547
+ // chunk has reached for SERVER_JOB_IDLE_MS is taken for a batch whose server
548
+ // stopped (it died), so it no longer blocks a reload.
549
+ var jobsInFlight = {};
550
+ var SERVER_JOB_IDLE_MS = 10 * 60 * 1000;
551
+
552
+ function trackServerJob(jobId, total) {
553
+ jobsInFlight[jobId] = { attempted: 0, total: (typeof total === "number") ? total : null, seenAt: Date.now() };
492
554
  }
493
555
 
494
- // ---------- Stale render detection ----------
495
- // The request identity a render is expected to be unique to. Built from what
496
- // ExtendScript resolved, not from the arguments, so a defaulted time or
497
- // downsample is compared as the value actually rendered.
498
- function frameKey(op, info) {
499
- var layer = (info.layerId === undefined || info.layerId === null) ? "-" : info.layerId;
500
- return [op, info.compId, layer, Number(info.time).toFixed(6), info.downsample].join("|");
501
- }
502
- function frameLabel(op, info) {
503
- return "comp " + info.compId +
504
- ((info.layerId === undefined || info.layerId === null) ? "" : " layer " + info.layerId) +
505
- " @ " + Number(info.time).toFixed(3) + "s downsample " + info.downsample;
506
- }
507
- // An error rather than a warning attached to the image, because an agent that
508
- // can see the picture will believe the picture. It carries the whole
509
- // workaround: there is nothing this panel can do to make the render happen.
510
- function staleFrameError(label, match, bytes) {
511
- var ago = Math.max(1, Math.round(match.ageMs / 1000));
512
- var err = new Error(
513
- "Stale frame: After Effects returned pixels identical to an earlier, different " +
514
- "request (" + match.label + ", " + match.bytes + " bytes, " + ago + "s ago), so this " +
515
- "image is not a picture of " + label + " (" + bytes + " bytes) and has not been sent.\n" +
516
- "\n" +
517
- "What to do next:\n" +
518
- "1. Wait a few seconds — back-to-back screenshots trigger this far more often.\n" +
519
- "2. Retry with a higher `downsample` (6 has worked where 3-4 stayed stale).\n" +
520
- "3. If it repeats, verify the animation by reading keyframes (get_keyframes / " +
521
- "get_layer_full) instead. That is exact; a picture is not.\n" +
522
- "\n" +
523
- "Do NOT start disabling layers to make the render succeed: this is a limit of the " +
524
- "panel's render path, not a problem with the project. If those two frames really " +
525
- "are identical — a static comp — a different `downsample` will render a different " +
526
- "number of pixels and confirm it."
527
- );
528
- err.code = "STALE_FRAME";
529
- return err;
530
- }
531
-
532
- // ---------- Long job continuation loop ----------
533
- // Each turn of this loop is one evalScript, and each one now opens and closes
534
- // its own undo group inside itself — a group cannot span two calls, because
535
- // After Effects discards one that does (issue #69). So the chunk size is not
536
- // only a pacing knob any more: it decides how many undo steps a long batch
537
- // costs the user. It comes off the job envelope so the JSX side owns the
538
- // number and the two cannot drift.
539
- function driveJob(jobId, progressToken, chunkSize) {
540
- var totalGuess = null;
541
- var size = chunkSize || 25;
542
- function step() {
543
- return runOp("_continue_job", { jobId: jobId, chunkSize: size }).then(function (res) {
544
- if (res.total) totalGuess = res.total;
545
- if (!res.done) {
546
- broadcast({ type: "progress", jobId: jobId, progress: res.progress, total: res.total, message: "running" });
547
- // Yield to UI so AE stays responsive.
548
- return new Promise(function (r) { setTimeout(r, 0); }).then(step);
549
- }
550
- if (res.failed) {
551
- // A failed job reaches the server as an error string and nothing else,
552
- // so what the batch cost has to travel inside it or it is lost: the
553
- // ops before the failure are applied and stay applied.
554
- var failedMsg = (res.error || "batch failed") + (res.note ? " || " + res.note : "");
555
- broadcast({ type: "error", jobId: jobId, error: failedMsg });
556
- return { done: true, failed: true, jobId: jobId, error: res.error, results: res.results, errors: res.errors, atIndex: res.atIndex, undoSteps: res.undoSteps, note: res.note };
557
- }
558
- // `undoSteps` is the measured number of undo groups the batch opened,
559
- // and `diff` is the batch's own before/after — both are only ever seen
560
- // by the agent if they are carried on this event.
561
- broadcast({
562
- type: "complete",
563
- jobId: jobId,
564
- result: {
565
- results: res.results,
566
- errors: res.errors,
567
- total: res.total || totalGuess,
568
- cancelled: !!res.cancelled,
569
- undoSteps: res.undoSteps,
570
- undoGroupName: res.undoGroupName,
571
- note: res.note,
572
- diff: res.diff,
573
- },
574
- });
575
- return {
576
- done: true, jobId: jobId,
577
- results: res.results, errors: res.errors,
578
- total: res.total || totalGuess, cancelled: !!res.cancelled,
579
- undoSteps: res.undoSteps, undoGroupName: res.undoGroupName,
580
- note: res.note, diff: res.diff,
581
- };
582
- });
583
- }
584
- return step();
585
- }
586
-
587
- // ---------- Motion Graphics template thumbnail ----------
588
- // ExtendScript can render the poster frame but cannot rewrite a zip, so the
589
- // JSX side hands back a PNG path and the archive surgery happens here.
590
- //
591
- // A failed patch is reported on an otherwise successful result, never thrown:
592
- // the .mogrt is already written and valid at this point, and throwing away a
593
- // successful export because its thumbnail could not be improved would be the
594
- // worse trade. The result says plainly that the thumbnail is still AE's.
595
- function patchMogrtThumbnail(info) {
596
- // The same completeness gate the screenshot path uses. The poster frame is
597
- // written by the same asynchronous `saveFrameToPng`, so a half-written one
598
- // is just as possible here — and it would be resampled into the template's
599
- // thumbnail rather than merely displayed.
600
- // It takes the poster file with it, on success and on failure alike.
601
- return waitForCompletePng(info.posterPngPath).then(function (poster) {
602
- var patched = mogrtModule.patchThumbnail(fs.readFileSync(info.path), poster);
603
- // Write via a sibling temp file and rename, so an interrupted write
604
- // cannot leave a half-rewritten template where a valid one used to be.
605
- var tmp = info.path + ".tmp-thumb";
606
- fs.writeFileSync(tmp, patched.buffer);
607
- fs.renameSync(tmp, info.path);
608
- return {
609
- patched: true,
610
- posterTime: info.posterTime,
611
- width: patched.width,
612
- height: patched.height,
613
- sourceWidth: patched.sourceWidth,
614
- sourceHeight: patched.sourceHeight,
615
- letterboxed: patched.letterboxed || undefined,
616
- };
617
- }).catch(function (e) {
618
- unlinkQuietly(info.posterPngPath);
619
- log("warn", "could not patch the .mogrt thumbnail: " + e.message);
620
- return {
621
- patched: false,
622
- posterTime: info.posterTime,
623
- reason: "The template exported correctly, but its thumbnail could not be replaced (" +
624
- e.message + "). It still shows the one After Effects wrote.",
625
- };
626
- });
627
- }
628
-
629
- // ---------- Contact sheet ----------
630
- //
631
- // ExtendScript renders every requested time and hands back one temp path per
632
- // tile; everything from here on is pixels, which `packages/jsx` has no way to
633
- // touch. One tile that fails is drawn as a marked block rather than dropped —
634
- // the sheet has to keep mapping onto the times that were asked for, or an
635
- // agent counting frames left to right reads the wrong one as the right one.
636
-
637
- /** One line, for a note inside a tile rather than a whole error message. */
638
- function shortReason(err) {
639
- var s = err && err.detail ? err.detail : (err && err.message ? err.message : String(err));
640
- return String(s).split("\n")[0];
556
+ function noteServerChunk(jobId, res) {
557
+ var j = jobsInFlight[jobId];
558
+ if (!j) return;
559
+ if (!res || res.done) { delete jobsInFlight[jobId]; return; }
560
+ if (typeof res.progress === "number") j.attempted = res.progress;
561
+ if (typeof res.total === "number") j.total = res.total;
562
+ j.seenAt = Date.now();
641
563
  }
642
564
 
643
- function readSheetTile(info, tile) {
644
- if (tile.error) {
645
- return Promise.resolve({ time: tile.time, status: "failed", note: tile.error });
646
- }
647
- return readPngFrame(tile.path).then(function (img) {
648
- return { time: tile.time, status: "ok", img: img };
649
- }, function (err) {
650
- if (err.code !== "FRAME_INCOMPLETE") {
651
- return { time: tile.time, status: "failed", note: shortReason(err) };
652
- }
653
- // The same bounded retry the single-frame path gets, and safe for the same
654
- // reason — but re-rendering only this one time. The already-derived
655
- // downsample is passed explicitly so the per-tile factor cannot drift from
656
- // the rest of the sheet.
657
- log("warn", "contact sheet tile at " + tile.time + "s: " + shortReason(err) + " — re-rendering once");
658
- return runOp("screenshot_frame", {
659
- compId: info.compId,
660
- time: tile.time,
661
- downsample: info.downsample,
662
- }).then(function (retry) {
663
- return readPngFrame(retry.path).then(function (img) {
664
- return { time: tile.time, status: "ok", img: img };
665
- });
666
- }).catch(function (again) {
667
- return { time: tile.time, status: "failed", note: shortReason(again) };
668
- });
669
- });
670
- }
671
-
672
- function finishContactSheet(info, read) {
673
- var i;
674
- var ds = info.downsample;
675
- // Pass one: classify. Every `match` is computed before any `remember`, so a
676
- // sheet's own tiles cannot collide with each other through the cache.
677
- var seen = {};
678
- for (i = 0; i < read.length; i++) {
679
- var t = read[i];
680
- if (t.status !== "ok") continue;
681
- if (t.img.empty) { t.status = "empty"; t.img = null; continue; }
682
- var ident = { compId: info.compId, layerId: null, time: t.time, downsample: ds };
683
- t.key = frameKey("screenshot_frame", ident);
684
- var match = frameCache.match(t.key, t.img.hash);
685
- if (match) {
686
- // Pixels identical to an *earlier, different* request are the #29 stale
687
- // buffer, and a tile of them is not a picture of this time. Mark the
688
- // block rather than showing it: an agent that can see a frame believes
689
- // the frame.
690
- t.status = "stale";
691
- t.note = "identical to " + match.label + ", rendered " +
692
- Math.max(1, Math.round(match.ageMs / 1000)) + "s earlier";
693
- t.img = null;
694
- continue;
695
- }
696
- // Two tiles of one sheet matching each other is what a static comp looks
697
- // like — the caller asked for several times on purpose, and refusing the
698
- // second would be a false alarm on a correct answer. Flagged, not refused.
699
- if (seen[t.img.hash] !== undefined) {
700
- t.note = "pixel-identical to the " + contactSheetModule.formatTime(seen[t.img.hash]) +
701
- " tile — nothing changed between them";
702
- } else {
703
- seen[t.img.hash] = t.time;
704
- }
705
- }
706
-
707
- var specs = [];
708
- var ok = 0;
709
- for (i = 0; i < read.length; i++) {
710
- var r = read[i];
711
- var spec = { time: r.time, status: r.status, note: r.note };
712
- if (r.status === "ok") {
713
- var px = r.img.pixels;
714
- var channels = r.img.channels;
715
- if (!px) {
716
- // The passthrough path never interpreted the pixels. A sheet needs
717
- // them, so decode strictly here; a frame that cannot be decoded
718
- // becomes a marked block rather than a wrong one.
719
- try {
720
- var dec = pngCodec.decodePng8(r.img.buffer);
721
- px = dec.pixels;
722
- channels = dec.channels;
723
- } catch (e) {
724
- // Both records move together: `spec` is what gets drawn, `r` is what
725
- // the cache loop below reads, and a frame that is not delivered must
726
- // not be remembered as if it were.
727
- spec.status = "failed";
728
- r.status = "failed";
729
- spec.note = "the frame could not be decoded for tiling (" + e.message + ")";
730
- }
731
- }
732
- if (spec.status === "ok") {
733
- spec.pixels = px;
734
- spec.channels = channels;
735
- spec.width = r.img.width;
736
- spec.height = r.img.height;
737
- ok++;
738
- }
739
- }
740
- specs.push(spec);
741
- }
742
-
743
- if (!ok) {
744
- var why = [];
745
- for (i = 0; i < specs.length; i++) {
746
- why.push(contactSheetModule.formatTime(specs[i].time) + ": " + specs[i].status +
747
- (specs[i].note ? " (" + specs[i].note + ")" : ""));
748
- }
749
- var dead = new Error(
750
- "No tile of the contact sheet rendered, so there is no sheet to send. Per time:\n" +
751
- why.join("\n") + "\n\n" +
752
- "A sheet of coloured blocks is not a screenshot. Retry at a higher `downsample`, " +
753
- "or screenshot the shot precomps one at a time; if it repeats, read keyframes " +
754
- "(get_keyframes / get_layer_full) instead."
755
- );
756
- dead.code = "CONTACT_SHEET_FAILED";
757
- throw dead;
758
- }
759
-
760
- // The expected cell size, used only for the cells with no picture. A tile
761
- // that rendered is the authority on its own dimensions.
762
- var sheet = contactSheetModule.composeContactSheet(specs, {
763
- cellWidth: Math.round(info.width / ds),
764
- cellHeight: Math.round(info.height / ds),
765
- });
766
-
767
- // Only what is actually being delivered goes into the cache.
768
- for (i = 0; i < read.length; i++) {
769
- if (read[i].status === "ok" && read[i].key) {
770
- frameCache.remember(read[i].key, read[i].img.hash, {
771
- label: frameLabel("screenshot_frame", { compId: info.compId, time: read[i].time, downsample: ds }),
772
- bytes: read[i].img.buffer.length,
773
- });
774
- }
775
- }
776
-
777
- var bad = [];
778
- for (i = 0; i < sheet.tiles.length; i++) {
779
- if (sheet.tiles[i].status !== "ok") {
780
- bad.push(sheet.tiles[i].label + (sheet.tiles[i].note ? " — " + sheet.tiles[i].note : ""));
781
- }
565
+ function jobsInFlightText() {
566
+ var parts = [];
567
+ var now = Date.now();
568
+ for (var id in jobsInFlight) {
569
+ if (!jobsInFlight.hasOwnProperty(id)) continue;
570
+ var j = jobsInFlight[id];
571
+ if (now - j.seenAt > SERVER_JOB_IDLE_MS) continue;
572
+ parts.push(id + " (" + j.attempted + " of " + (j.total === null ? "?" : j.total) + " ops attempted" +
573
+ ", driven by the MCP server)");
782
574
  }
783
-
784
- return {
785
- contactSheet: true,
786
- width: sheet.width,
787
- height: sheet.height,
788
- cols: sheet.cols,
789
- rows: sheet.rows,
790
- cellWidth: sheet.cellWidth,
791
- cellHeight: sheet.cellHeight,
792
- fullWidth: info.width,
793
- fullHeight: info.height,
794
- downsample: ds,
795
- compId: info.compId,
796
- tiles: sheet.tiles,
797
- mimeType: "image/png",
798
- base64: sheet.buffer.toString("base64"),
799
- bytes: sheet.buffer.length,
800
- // Named and counted, because a sheet that looks complete and is not is the
801
- // same class of lie as a swallowed error.
802
- warning: bad.length
803
- ? bad.length + " of " + sheet.tiles.length + " tiles are marked blocks, not frames: " + bad.join("; ")
804
- : undefined,
805
- };
806
- }
807
-
808
- function renderContactSheet(args) {
809
- return runOp("screenshot_frame", args).then(function (info) {
810
- var jsxTiles = info.tiles || [];
811
- // Sequentially: `evalScript` is a mutex anyway, a per-tile retry re-enters
812
- // ExtendScript, and reading them in order keeps the panel log legible.
813
- var read = [];
814
- var chain = Promise.resolve();
815
- for (var i = 0; i < jsxTiles.length; i++) {
816
- chain = chain.then(function (tile) {
817
- return function () {
818
- return readSheetTile(info, tile).then(function (res) { read.push(res); });
819
- };
820
- }(jsxTiles[i]));
821
- }
822
- return chain.then(function () { return finishContactSheet(info, read); });
823
- });
575
+ return parts.join(", ");
824
576
  }
825
577
 
826
- // ---------- Op handler with vision/job specialization ----------
827
- function handleOp(op, args, progressToken) {
828
- // Export writes the .mogrt; the thumbnail it wants is a second file that
829
- // has to be folded into it afterwards.
830
- if (op === "export_mogrt") {
831
- return runOp(op, args).then(function (info) {
832
- if (!info || !info.posterPngPath) return info;
833
- return patchMogrtThumbnail(info).then(function (thumbnail) {
834
- var out = {};
835
- for (var k in info) { if (info.hasOwnProperty(k) && k !== "posterPngPath") out[k] = info[k]; }
836
- out.thumbnail = thumbnail;
837
- // The file changed size when the thumbnail was replaced, and the
838
- // reported number has to be the one on disk.
839
- try { out.bytes = fs.statSync(info.path).size; } catch (e) {}
840
- return out;
841
- });
842
- });
843
- }
844
- // Vision ops: run JSX, then read PNG and base64-encode on Node side.
845
- if (op === "screenshot_frame" || op === "screenshot_layer") {
846
- if (op === "screenshot_frame" && args && args.times && args.times.length) {
847
- return renderContactSheet(args);
848
- }
849
- return renderFrameWithRetry(op, args).then(function (r) {
850
- var info = r.info;
851
- var img = r.img;
852
- // A frame with nothing in it is a fact about the composition, not a
853
- // failure — and the ~5KB PNG that encodes it is the one decoders
854
- // reject. Report it, cheaply, instead of shipping an image nobody can
855
- // read and calling that a successful screenshot.
856
- if (img.empty) {
857
- return {
858
- empty: true,
859
- width: img.width,
860
- height: img.height,
861
- fullWidth: info.width,
862
- fullHeight: info.height,
863
- downsample: info.downsample,
864
- time: info.time,
865
- compId: info.compId,
866
- layerId: info.layerId,
867
- reason: "Every pixel of this frame is fully transparent — the frame is empty. " +
868
- "No image was sent because there is nothing in it to see. Usually this means " +
869
- "the time is outside the layers' in/out points, the layers are disabled or " +
870
- "have zero opacity, or the wrong comp was addressed.",
871
- };
872
- }
873
- // Only a frame that read cleanly ever reaches the cache. A corrupt read
874
- // is rejected above and never remembered — before the completeness gate
875
- // existed, a truncated file was hashed and recorded here, so the next
876
- // truncation at the same byte count was reported as a stale buffer and
877
- // the real fault was hidden behind the wrong diagnosis.
878
- var key = frameKey(op, info);
879
- var match = frameCache.match(key, img.hash);
880
- if (match) throw staleFrameError(frameLabel(op, info), match, img.buffer.length);
881
- frameCache.remember(key, img.hash, { label: frameLabel(op, info), bytes: img.buffer.length });
882
- return {
883
- // Dimensions come from the PNG itself, not from arithmetic on the
884
- // comp size, so they cannot disagree with the image sent.
885
- width: img.width,
886
- height: img.height,
887
- fullWidth: info.width,
888
- fullHeight: info.height,
889
- downsample: info.downsample,
890
- time: info.time,
891
- compId: info.compId,
892
- layerId: info.layerId,
893
- mimeType: "image/png",
894
- base64: img.buffer.toString("base64"),
895
- bytes: img.buffer.length,
896
- // Only present when the frame was not 8-bit as rendered, so the
897
- // metadata stays quiet on the ordinary path.
898
- converted: img.converted ? true : undefined,
899
- sourceBitDepth: img.converted ? img.sourceBitDepth : undefined,
900
- warning: img.warning,
901
- };
902
- });
903
- }
904
- // Long batches: returns {jobId, async, total}. We kick off background drive
905
- // and return the jobId immediately.
578
+ // ---------- Op handler ----------
579
+ // Everything goes to the bundle as sent. Screenshots, the .mogrt thumbnail,
580
+ // the house style's file I/O and run_jsx's assembly are the server's (issue
581
+ // #220), and the copies of that work that used to live here are gone (issue
582
+ // #243). What is left is bookkeeping for /reload-jsx.
583
+ function handleOp(op, args, gone) {
906
584
  if (op === "run_batch") {
907
- return runOp(op, args).then(function (res) {
585
+ // The driver flag is the server's word to this panel, never an op
586
+ // argument: ExtendScript does not see it.
587
+ if (args && args.__driver !== undefined) {
588
+ var stripped = {};
589
+ for (var ak in args) { if (args.hasOwnProperty(ak) && ak !== "__driver") stripped[ak] = args[ak]; }
590
+ args = stripped;
591
+ }
592
+ return runOp(op, args, gone).then(function (res) {
908
593
  if (res && res.async && res.jobId) {
909
- driveJob(res.jobId, progressToken, res.chunkSize).catch(function (e) {
910
- broadcast({ type: "error", jobId: res.jobId, error: e.message });
911
- });
594
+ trackServerJob(res.jobId, res.total);
912
595
  // The undo fields ride out with the envelope so the agent is told the
913
- // batch will be several steps *before* it goes and says otherwise —
914
- // the final count arrives much later, on the completion event.
596
+ // batch will be several steps *before* it goes and says otherwise.
915
597
  return {
916
598
  jobId: res.jobId,
917
599
  async: true,
@@ -920,19 +602,40 @@
920
602
  undoStepsEstimate: res.undoStepsEstimate,
921
603
  undoGroupName: res.undoGroupName,
922
604
  note: res.note,
605
+ aeWarnings: res.aeWarnings,
606
+ driver: "server",
923
607
  };
924
608
  }
925
609
  return res; // small batch: inline result already
926
610
  });
927
611
  }
928
- return runOp(op, args);
612
+ // One chunk of a batch: passed through untouched, watched only for
613
+ // /reload-jsx.
614
+ if (op === "_continue_job" && args && jobsInFlight[args.jobId]) {
615
+ var cj = args.jobId;
616
+ return runOp(op, args, gone).then(function (res) {
617
+ noteServerChunk(cj, res);
618
+ return res;
619
+ }, function (e) {
620
+ noteServerChunk(cj, null);
621
+ throw e;
622
+ });
623
+ }
624
+ return runOp(op, args, gone);
929
625
  }
930
626
 
931
627
  // ---------- HTTP server ----------
932
628
  function startHttp(port) {
933
629
  return new Promise(function (resolve, reject) {
934
630
  var server = http.createServer(function (req, res) {
631
+ // When the request arrived, on a clock no wall-clock change moves.
632
+ var receivedAt = monoNow();
935
633
  var url = req.url || "/";
634
+ // Only /reload-jsx takes a query (`?force=1`); every other route is
635
+ // still matched on the whole url, as before.
636
+ var qAt = url.indexOf("?");
637
+ var route = qAt < 0 ? url : url.slice(0, qAt);
638
+ var query = qAt < 0 ? "" : url.slice(qAt + 1);
936
639
 
937
640
  // No CORS headers, deliberately: nothing that legitimately calls this
938
641
  // bridge is a web page (issue #106).
@@ -953,10 +656,22 @@
953
656
  port: addr && addr.port ? addr.port : port,
954
657
  bundleLoaded: true,
955
658
  bundleHash: loadedBundleHash,
659
+ // Every other file of this panel, hashed at launch (issue #243). The
660
+ // server's gate needs both hashes to match the panel it ships.
661
+ clientHash: loadedClientHash,
956
662
  // The gate exists, never anything about the token. Absent on a
957
663
  // pre-#106 panel, which is how the server tells "needs a token"
958
664
  // from "too old to want one".
959
665
  auth: true,
666
+ // What this panel can do (issue #220). The server's gate reads it
667
+ // only when it has no shipped panel to compare the hashes with.
668
+ capabilities: PANEL_CAPABILITIES,
669
+ // The script handed to After Effects with no answer yet, or null.
670
+ // The server's busy guard reads it after a timeout: answered here
671
+ // with this set, After Effects is still on that script. (While
672
+ // ExtendScript holds this thread, /health is not answered at all.)
673
+ evalBusy: evalBusyNow(),
674
+ evalQueued: Math.max(0, __evalPending - (__evalRunning ? 1 : 0)),
960
675
  ts: Date.now()
961
676
  }));
962
677
  return;
@@ -990,12 +705,49 @@
990
705
 
991
706
  if (url === "/op") {
992
707
  bumpReq();
993
- handleOp(body.op, body.args, body.progressToken)
708
+ // Queued behind a stuck call, an op would otherwise still run once
709
+ // After Effects frees up — late, out of order, after the agent was
710
+ // told it timed out. So it is dropped at its turn once the server
711
+ // has given up on it. The server sends its own timeout as
712
+ // `timeoutMs` (relative), and the panel runs the same span from
713
+ // the request's arrival on a monotonic clock — arrival is after
714
+ // the server started its timer, so this deadline is never earlier
715
+ // than the server's, and DEADLINE_GRACE_MS on top makes a dropped
716
+ // call only ever one the server already reported as timed out.
717
+ // It needs no socket event to reach this thread in time; the
718
+ // request closing is a second signal. No `timeoutMs` (an older
719
+ // server): only the close is checked.
720
+ var replied = false;
721
+ var closedEarly = false;
722
+ res.on("close", function () { if (!replied) closedEarly = true; });
723
+ var limitMs = typeof body.timeoutMs === "number" && isFinite(body.timeoutMs) && body.timeoutMs > 0 ? body.timeoutMs : null;
724
+ var gone = function () {
725
+ var over = limitMs === null ? 0 : monoNow() - receivedAt - limitMs;
726
+ if (limitMs !== null && over > DEADLINE_GRACE_MS) {
727
+ return "the MCP server's deadline for it passed " + Math.round(over / 1000) +
728
+ "s ago while it was queued behind another call";
729
+ }
730
+ if (closedEarly || (req.socket && req.socket.destroyed)) {
731
+ return "the MCP server closed its request (it gave up waiting) while it was queued behind another call";
732
+ }
733
+ return null;
734
+ };
735
+ handleOp(body.op, body.args, gone)
994
736
  .then(function (result) {
737
+ replied = true;
995
738
  res.statusCode = 200; res.setHeader("content-type", "application/json");
996
739
  res.end(JSON.stringify({ ok: true, result: result }));
997
740
  })
998
741
  .catch(function (err) {
742
+ replied = true;
743
+ if (err && err.code === "REQUEST_CLOSED") {
744
+ log("warn", body.op + " was not run, " + err.message.replace(/^dropped before it ran: /, "") +
745
+ " — dropped rather than run late.");
746
+ // No answer: the server has already reported this call as
747
+ // timed out, and nothing it could read here would be newer.
748
+ try { res.destroy(); } catch (e) {}
749
+ return;
750
+ }
999
751
  log("error", body.op + ": " + err.message);
1000
752
  res.statusCode = 500; res.setHeader("content-type", "application/json");
1001
753
  // `code` marks a failure the panel diagnosed itself rather than
@@ -1019,8 +771,25 @@
1019
771
  return;
1020
772
  }
1021
773
 
1022
- if (url === "/reload-jsx") {
774
+ if (route === "/reload-jsx") {
1023
775
  // Dev-only: re-evalFile the bundle. Useful while editing .jsx without restarting AE.
776
+ // A reload replaces the bundle's job table, so every batch in flight
777
+ // stops mid-run with part of it applied (issue #163). Refused while
778
+ // one runs; `?force=1` reloads anyway, and the batch then reports
779
+ // exactly what had landed.
780
+ var busy = jobsInFlightText();
781
+ if (busy && !/(^|&)force=1(&|$)/.test(query)) {
782
+ res.statusCode = 409;
783
+ res.setHeader("content-type", "application/json");
784
+ res.end(JSON.stringify({
785
+ ok: false,
786
+ code: "batch_in_flight",
787
+ error: "Not reloaded: a run_batch is still running — " + busy + ". Reloading the bundle now " +
788
+ "would stop it mid-run with part of it applied. Wait for it to finish (await_job), or cancel " +
789
+ "it (cancel_job), then reload. POST /reload-jsx?force=1 reloads anyway.",
790
+ }));
791
+ return;
792
+ }
1024
793
  loadJsxBundle()
1025
794
  .then(function () { res.statusCode = 200; res.end(JSON.stringify({ ok: true })); })
1026
795
  .catch(function (e) { res.statusCode = 500; res.end(JSON.stringify({ ok: false, error: e.message })); });
@@ -1047,13 +816,20 @@
1047
816
 
1048
817
  // Never rejects: every outcome is a classification the caller acts on.
1049
818
  // panel — another AE MCP panel; its health body is attached
1050
- // other — something answered, but not as the panel
1051
- // silent — accepted the connection and said nothing, or errored
819
+ // other — something answered over HTTP, but not as the panel
820
+ // silent — accepted the connection and said nothing at all. The one case
821
+ // that can be a panel whose After Effects is busy (issue #288)
822
+ // gone — the connection was refused: the holder exited after the bind
823
+ // failed, so the port has just come free
824
+ // odd — anything else: it hung up, spoke something other than HTTP, or
825
+ // started an answer and stalled. A panel does none of these
1052
826
  function identifyHolder(port) {
1053
827
  return new Promise(function (resolve) {
1054
828
  var done = false;
829
+ var answering = false;
1055
830
  function finish(r) { if (!done) { done = true; resolve(r); } }
1056
831
  var req = http.get({ host: "127.0.0.1", port: port, path: "/health", timeout: 1500 }, function (res) {
832
+ answering = true;
1057
833
  var bufs = [];
1058
834
  res.on("data", function (c) { bufs.push(c); });
1059
835
  res.on("end", function () {
@@ -1062,16 +838,26 @@
1062
838
  if (res.statusCode === 200 && isOurHealth(body)) finish({ kind: "panel", health: body });
1063
839
  else finish({ kind: "other", detail: "answered HTTP " + res.statusCode + (body ? " with an unfamiliar body" : " with no JSON") });
1064
840
  });
1065
- res.on("error", function (e) { finish({ kind: "silent", detail: e.message }); });
841
+ res.on("error", function (e) { finish({ kind: "odd", detail: e.message }); });
842
+ });
843
+ req.on("timeout", function () {
844
+ var said = answering ? "started an answer and stalled" : "accepted the connection but did not answer within 1.5s";
845
+ finish({ kind: answering ? "odd" : "silent", detail: said });
846
+ req.destroy();
847
+ });
848
+ req.on("error", function (e) {
849
+ finish({ kind: e.code === "ECONNREFUSED" ? "gone" : "odd", detail: e.message });
1066
850
  });
1067
- req.on("timeout", function () { req.destroy(); finish({ kind: "silent", detail: "accepted the connection but did not answer within 1.5s" }); });
1068
- req.on("error", function (e) { finish({ kind: "silent", detail: e.message }); });
1069
851
  });
1070
852
  }
1071
853
 
1072
854
  // 7777 through 7799 was the old ceiling; kept as a distance so a configured
1073
855
  // start port walks the same number of steps.
1074
856
  var WALK_LIMIT = 22;
857
+ var MAX_PORT = 65535;
858
+ // A port that refuses connections and will not bind is not free after all
859
+ // (a socket bound and not listening), so this is a few tries, not a wait.
860
+ var GONE_RETRIES = 3;
1075
861
 
1076
862
  /**
1077
863
  * Bind the configured port, and decide what to do when it is taken.
@@ -1080,23 +866,34 @@
1080
866
  * whatever it got to the port file. On one machine nothing else ever holds
1081
867
  * 7777, so the thing being walked around was always a previous instance of
1082
868
  * this same panel — a CEPHtmlEngine that lingered across an After Effects
1083
- * relaunch, or a second panel window — and the walk routed round it in
869
+ * relaunch, or a second After Effects — and the walk routed round it in
1084
870
  * silence, leaving the port file as a second source of truth that could
1085
871
  * disagree with the socket. Four incidents in a week (issue #92).
1086
872
  *
1087
873
  * So the holder is identified first. If it is one of us, this panel does not
1088
874
  * move: it says so in its status and log, and retries the bind on an interval
1089
875
  * so it takes the port over the moment the other one exits, with no restart.
1090
- * If it is something else, or answers nothing, the walk happens as before —
1091
- * loudly, naming the holder and the port chosen, because that is the one
1092
- * case where the port file legitimately says something other than 7777.
1093
- * `allowPortWalk` in config.json opts back into walking past our own panel,
1094
- * for people who really run two After Effects instances.
876
+ * If it is something else, the walk happens as before — loudly, naming the
877
+ * holder and the port chosen, because that is the one case where the port
878
+ * file legitimately says something other than 7777.
879
+ *
880
+ * A holder that accepts the connection and then says nothing is treated as
881
+ * ours (issue #288). /health goes unanswered while ExtendScript holds a
882
+ * panel's thread, so a silent holder is most often a panel whose After Effects
883
+ * is busy, and walking past it is the same drift as above. The panel keeps
884
+ * trying the bind and re-asks every time. Only silence counts: a holder that
885
+ * hangs up or speaks something other than HTTP walks like any other stranger.
886
+ * `allowPortWalk` in config.json opts back into walking past both, for people
887
+ * who really run two After Effects instances.
1095
888
  */
1096
889
  function startServers(cfg) {
1097
890
  var startPort = cfg.port;
1098
891
  var retryMs = cfg.bindRetryMs;
1099
- var waits = 0;
892
+ // One count each: a holder can go from silent to answering between retries,
893
+ // and each kind of wait has its own first-time explanation to give.
894
+ var panelWaits = 0;
895
+ var silentWaits = 0;
896
+ var goneRetries = 0;
1100
897
 
1101
898
  function bindWith(p) {
1102
899
  return startHttp(p).then(function (server) {
@@ -1128,8 +925,13 @@
1128
925
 
1129
926
  function walk(p, tries, why) {
1130
927
  var next = p + 1;
1131
- if (tries >= WALK_LIMIT) {
1132
- throw new Error("ports " + startPort + " through " + p + " are all in use — nothing free to bind. Last holder: " + why);
928
+ // There is no port past 65535: asking for one made listen() throw a
929
+ // RangeError that named no port the user had configured (issue #260).
930
+ var offTheTop = next > MAX_PORT;
931
+ if (tries >= WALK_LIMIT || offTheTop) {
932
+ throw new Error("ports " + startPort + " through " + p + " are all in use — nothing free to bind" +
933
+ (offTheTop ? " (" + MAX_PORT + " is the highest port there is; set a lower \"port\" in " + configFile + ")" : "") +
934
+ ". Last holder: " + why);
1133
935
  }
1134
936
  log("warn", "port " + p + " is held by " + why + " — moving to port " + next + ". " +
1135
937
  "The MCP server finds this port through the port file; if tool calls fail on " + p +
@@ -1143,7 +945,7 @@
1143
945
  ? String(holder.health.bundleHash).slice(0, 12)
1144
946
  : "no bundleHash — a panel older than 0.3";
1145
947
  setStatus("waiting — port " + p + " is held by another AE MCP panel", "warn");
1146
- if (waits === 0) {
948
+ if (panelWaits === 0) {
1147
949
  log("warn", "port " + p + " is already held by another AE MCP panel (bundle " + hash + ", " +
1148
950
  (same ? "the same version as this one" : "a DIFFERENT version from this one") + "). " +
1149
951
  "Not moving to another port: that would leave the port file pointing at whichever panel bound last, " +
@@ -1153,10 +955,26 @@
1153
955
  "panel process is still running: quit After Effects fully, check Activity Monitor / Task Manager for a " +
1154
956
  "leftover CEPHtmlEngine, and reopen it. To run two After Effects at once instead, set " +
1155
957
  "\"allowPortWalk\": true in " + configFile + ".");
1156
- } else if (waits % 30 === 0) {
1157
- log("info", "still waiting for port " + p + " (" + waits + " attempts)");
958
+ } else if (panelWaits % 30 === 0) {
959
+ log("info", "still waiting for port " + p + " (" + panelWaits + " attempts)");
960
+ }
961
+ panelWaits += 1;
962
+ return new Promise(function (r) { setTimeout(r, retryMs); }).then(function () { return attempt(p, 0); });
963
+ }
964
+
965
+ function waitForSilentHolder(p, holder) {
966
+ setStatus("waiting — port " + p + " is held by something that does not answer", "warn");
967
+ if (silentWaits === 0) {
968
+ log("warn", "port " + p + " is held by something that did not identify itself (" + holder.detail + "). " +
969
+ "Not moving to another port: an AE MCP panel does not answer while After Effects is busy with a script, " +
970
+ "and moving would leave two panels and a port file pointing at the wrong one (issues #92, #288).");
971
+ log("warn", "Retrying the bind every " + (retryMs / 1000) + "s; this panel takes the port the moment it " +
972
+ "is free, with no restart. If this persists and nothing is busy, another program holds port " + p + ": " +
973
+ "set a different \"port\" in " + configFile + ", or \"allowPortWalk\": true to move past it.");
974
+ } else if (silentWaits % 30 === 0) {
975
+ log("info", "still waiting for port " + p + " (" + silentWaits + " attempts)");
1158
976
  }
1159
- waits += 1;
977
+ silentWaits += 1;
1160
978
  return new Promise(function (r) { setTimeout(r, retryMs); }).then(function () { return attempt(p, 0); });
1161
979
  }
1162
980
 
@@ -1170,6 +988,13 @@
1170
988
  }
1171
989
  return waitForHolder(p, holder);
1172
990
  }
991
+ if (holder.kind === "silent" && !cfg.allowPortWalk) return waitForSilentHolder(p, holder);
992
+ // The holder left between the failed bind and the question. Walking
993
+ // now would be the #92 drift, on a port nothing holds any more.
994
+ if (holder.kind === "gone" && goneRetries < GONE_RETRIES) {
995
+ goneRetries += 1;
996
+ return new Promise(function (r) { setTimeout(r, retryMs); }).then(function () { return attempt(p, tries); });
997
+ }
1173
998
  var why = holder.kind === "other"
1174
999
  ? "something that is not an AE MCP panel (" + holder.detail + ")"
1175
1000
  : "something that did not identify itself (" + holder.detail + ")";
@@ -1181,7 +1006,7 @@
1181
1006
  }
1182
1007
 
1183
1008
  // ---------- Shutdown ----------
1184
- // When the panel window closes or After Effects quits, give the port back and
1009
+ // When the bridge extension unloads (After Effects quits), give the port back and
1185
1010
  // take this panel's port-file entry with it. A panel that exits cleanly must
1186
1011
  // not leave a file naming a port nobody listens on — that is the other half
1187
1012
  // of how the port file came to disagree with the socket (issue #92). A
@@ -1203,11 +1028,65 @@
1203
1028
  window.addEventListener("unload", shutdown);
1204
1029
  }
1205
1030
 
1031
+ // ---------- A host with no user in it (issue #288) ----------
1032
+ // CEP loads this extension into every process that calls itself AEFT, and two
1033
+ // of those are hidden render engines with no window and no project of the
1034
+ // user's: `aerendercore -m -livelink`, which Premiere's Dynamic Link starts,
1035
+ // and `aerendercore -noui`, which aerender starts, render_queue included. A
1036
+ // bridge there takes the port, and every tool call lands in an After Effects
1037
+ // the user cannot see.
1038
+ //
1039
+ // Measured on 26.5 in both engines: app.isUISuppressed is true, and false in
1040
+ // a normal After Effects at launch, with a project opened by file, and during
1041
+ // suppressed dialogs; it is read-only. app.isRenderEngine is false in both
1042
+ // engines, so it does not answer this. CEP's host environment and capabilities
1043
+ // are identical in all three.
1044
+ //
1045
+ // A version without the property answers "interactive" at once and boots as
1046
+ // it always has. Nothing is bound before this resolves, so an engine too busy
1047
+ // to answer at all stays unbound. "EvalScript error." is not an answer: a busy
1048
+ // engine can say it too, and booting on it would put the bridge in that
1049
+ // engine. So it is asked again, and only a host that gives no answer through
1050
+ // every try boots as normal. Failing open there is deliberate: a host that
1051
+ // still refuses scripts also refuses the bundle load, which fails the boot
1052
+ // before anything binds, so the load is the backstop.
1053
+ //
1054
+ // "headless" is asked twice, a second apart, and a host that says it and then
1055
+ // cannot be asked again counts as headless. No transient true has been
1056
+ // measured in a normal After Effects, but one would cost the user the bridge
1057
+ // until a restart, while the recheck costs a real engine one second of a
1058
+ // process nobody is waiting on. The first "interactive" is taken as final, so
1059
+ // a normal launch pays nothing for any of this.
1060
+ var HEADLESS_CHECK = "app.isUISuppressed === true ? 'headless' : 'interactive'";
1061
+ var HEADLESS_TRIES = 5;
1062
+ var HEADLESS_RETRY_MS = 1000;
1063
+ function isHeadlessHost() {
1064
+ function ask(tries, saidHeadless) {
1065
+ function again(said) {
1066
+ if (tries <= 1) {
1067
+ if (!said) log("warn", "After Effects gave no answer to the render engine check, so the bridge is starting as normal.");
1068
+ return said;
1069
+ }
1070
+ return new Promise(function (r) { setTimeout(r, HEADLESS_RETRY_MS); })
1071
+ .then(function () { return ask(tries - 1, said); });
1072
+ }
1073
+ return evalScript(HEADLESS_CHECK, { label: "render engine check" }).then(
1074
+ function (raw) {
1075
+ if (raw === "interactive") return false;
1076
+ if (raw !== "headless") return again(saidHeadless);
1077
+ return saidHeadless ? true : again(true);
1078
+ },
1079
+ function () { return again(saidHeadless); }
1080
+ );
1081
+ }
1082
+ return ask(HEADLESS_TRIES, false);
1083
+ }
1084
+
1206
1085
  // ---------- Boot sequence ----------
1207
1086
  setStatus("Starting…");
1208
1087
  try {
1209
1088
  var host = cs.getHostEnvironment();
1210
- $ae.textContent = (host && host.appName ? host.appName : "AE") + " " + (host && host.appVersion ? host.appVersion : "");
1089
+ setField("ae", $ae, (host && host.appName ? host.appName : "AE") + " " + (host && host.appVersion ? host.appVersion : ""));
1211
1090
  } catch (e) {}
1212
1091
 
1213
1092
  var config = readConfig();
@@ -1215,18 +1094,29 @@
1215
1094
  log("info", "read " + config.source + ": port " + config.port + (config.allowPortWalk ? ", allowPortWalk on" : ""));
1216
1095
  }
1217
1096
 
1218
- loadJsxBundle()
1219
- .then(function () { return startServers(config); })
1220
- .then(function (s) {
1221
- running = s;
1222
- // The only write of the port file, and only with the port actually bound.
1223
- writePortFile(s.port);
1224
- $port.textContent = String(s.port);
1225
- $port.className = "ok";
1226
- setStatus("ready", "ok");
1227
- log("info", "Bridge listening on http://127.0.0.1:" + s.port +
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) + ".");
1097
+ function bootBridge() {
1098
+ return loadJsxBundle()
1099
+ .then(function () { return startServers(config); })
1100
+ .then(announceBridge);
1101
+ }
1102
+
1103
+ function announceBridge(s) {
1104
+ running = s;
1105
+ // The only write of the port file, and only with the port actually bound.
1106
+ writePortFile(s.port);
1107
+ setField("port", $port, String(s.port), "ok");
1108
+ setStatus("ready", "ok");
1109
+ log("info", "Bridge listening on http://127.0.0.1:" + s.port +
1110
+ (s.port !== config.port && config.port !== 0 ? " (configured port " + config.port + " was taken — see above)" : ""));
1111
+ log("info", "Calls are authenticated: the MCP server reads this session's token from " + tokenFile(s.port) + ".");
1112
+ }
1113
+
1114
+ isHeadlessHost()
1115
+ .then(function (headless) {
1116
+ if (!headless) return bootBridge();
1117
+ // No port, no port file, no token, no bundle in the engine.
1118
+ setStatus("not started — this is a render engine, not After Effects", "warn");
1119
+ log("info", "This host is a hidden render engine (Premiere Dynamic Link or aerender), so the bridge stays off here.");
1230
1120
  })
1231
1121
  .catch(function (e) {
1232
1122
  setStatus("failed", "err");