@engine-room/after-effects-mcp 0.5.1 → 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.
- package/README.md +126 -185
- package/bin/server.js +12567 -2478
- package/package.json +8 -8
- package/panel/CSXS/manifest.xml +44 -4
- package/panel/META-INF/signatures.xml +182 -0
- package/panel/client/csinterface.js +32 -0
- package/panel/client/main.js +522 -632
- package/panel/client/panelhash.js +79 -0
- package/panel/client/status.html +27 -0
- package/panel/client/status.js +112 -0
- package/panel/jsx/bundle.jsx +9032 -2393
- package/panel/mimetype +1 -0
- package/panel/node_modules/ws/LICENSE +20 -0
- package/panel/node_modules/ws/README.md +548 -0
- package/panel/node_modules/ws/browser.js +8 -0
- package/panel/node_modules/ws/index.js +22 -0
- package/panel/node_modules/ws/lib/buffer-util.js +131 -0
- package/panel/node_modules/ws/lib/constants.js +19 -0
- package/panel/node_modules/ws/lib/event-target.js +292 -0
- package/panel/node_modules/ws/lib/extension.js +203 -0
- package/panel/node_modules/ws/lib/limiter.js +55 -0
- package/panel/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/panel/node_modules/ws/lib/receiver.js +743 -0
- package/panel/node_modules/ws/lib/sender.js +607 -0
- package/panel/node_modules/ws/lib/stream.js +161 -0
- package/panel/node_modules/ws/lib/subprotocol.js +62 -0
- package/panel/node_modules/ws/lib/validation.js +152 -0
- package/panel/node_modules/ws/lib/websocket-server.js +562 -0
- package/panel/node_modules/ws/lib/websocket.js +1421 -0
- package/panel/node_modules/ws/package.json +74 -0
- package/panel/node_modules/ws/wrapper.mjs +21 -0
- package/panel/package.json +2 -2
- package/panel/client/contactsheet.js +0 -347
- package/panel/client/framecache.js +0 -98
- package/panel/client/framereader.js +0 -248
- package/panel/client/mogrt.js +0 -328
- package/panel/client/pngcodec.js +0 -524
package/panel/client/main.js
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
80
|
-
// them without a DOM
|
|
81
|
-
//
|
|
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
|
|
139
|
+
var panelHashModule;
|
|
104
140
|
try {
|
|
105
|
-
|
|
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
|
|
112
|
-
log("error", "loading the panel's
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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,
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
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
|
-
// ----------
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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: "
|
|
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
|
|
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,
|
|
1091
|
-
*
|
|
1092
|
-
*
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1132
|
-
|
|
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 (
|
|
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 (
|
|
1157
|
-
log("info", "still waiting for port " + p + " (" +
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
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");
|