@engine-room/after-effects-mcp 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -8
- package/bin/server.js +2684 -527
- package/package.json +1 -1
- package/panel/CSXS/manifest.xml +2 -2
- package/panel/client/contactsheet.js +347 -0
- package/panel/client/framereader.js +248 -0
- package/panel/client/main.js +619 -112
- package/panel/client/pngcodec.js +122 -0
- package/panel/jsx/bundle.jsx +3299 -241
- package/panel/package.json +1 -1
package/panel/client/main.js
CHANGED
|
@@ -100,13 +100,15 @@
|
|
|
100
100
|
}
|
|
101
101
|
throw new Error("could not load " + name + " from any of: " + failures.join("; "));
|
|
102
102
|
}
|
|
103
|
-
var pngCodec, frameCacheModule, mogrtModule;
|
|
103
|
+
var pngCodec, frameCacheModule, mogrtModule, contactSheetModule, frameReaderModule;
|
|
104
104
|
try {
|
|
105
105
|
pngCodec = requireSibling("pngcodec.js");
|
|
106
106
|
frameCacheModule = requireSibling("framecache.js");
|
|
107
107
|
mogrtModule = requireSibling("mogrt.js");
|
|
108
|
+
contactSheetModule = requireSibling("contactsheet.js");
|
|
109
|
+
frameReaderModule = requireSibling("framereader.js");
|
|
108
110
|
} catch (e) {
|
|
109
|
-
setStatus("cannot start —
|
|
111
|
+
setStatus("cannot start — one of the panel's file-processing modules is missing", "err");
|
|
110
112
|
log("error", "loading the panel's file-processing modules from " + __dirname + " failed: " + e.message);
|
|
111
113
|
log("error", "Quit After Effects, run the setup_panel tool, then reopen it.");
|
|
112
114
|
return;
|
|
@@ -148,6 +150,20 @@
|
|
|
148
150
|
var err = new Error(msg);
|
|
149
151
|
err.aeStack = parsed.stack;
|
|
150
152
|
err.aeLine = parsed.line;
|
|
153
|
+
// Where the failure sits in the caller's *own* source, when the handler
|
|
154
|
+
// could work it out — run_jsx maps AE's line number back onto the
|
|
155
|
+
// script that was submitted (issue #46). Forwarded field by field on
|
|
156
|
+
// purpose: the server prints these, and relaying a free-form bag would
|
|
157
|
+
// let the two drift apart with nothing to notice.
|
|
158
|
+
if (parsed.sourceLine !== undefined || parsed.rawLine !== undefined) {
|
|
159
|
+
err.aeSource = {
|
|
160
|
+
sourceLine: parsed.sourceLine,
|
|
161
|
+
sourceText: parsed.sourceText,
|
|
162
|
+
sourceName: parsed.sourceName,
|
|
163
|
+
rawLine: parsed.rawLine,
|
|
164
|
+
lineCount: parsed.lineCount
|
|
165
|
+
};
|
|
166
|
+
}
|
|
151
167
|
throw err;
|
|
152
168
|
}
|
|
153
169
|
return parsed.result;
|
|
@@ -196,10 +212,68 @@
|
|
|
196
212
|
}
|
|
197
213
|
|
|
198
214
|
// ---------- Port file (so MCP server can discover) ----------
|
|
215
|
+
// Written only once a port is actually bound, and only with that port. The
|
|
216
|
+
// file is a hint for the server, never the authority — the socket is — and
|
|
217
|
+
// the one way it lies is by describing a bind that did not happen or a panel
|
|
218
|
+
// that has since gone (issue #92). So it is written after `listen` succeeds
|
|
219
|
+
// and removed again on unload, and never with a port this panel merely tried.
|
|
220
|
+
var portDir = path.join(os.homedir(), ".engineroom-ae-mcp");
|
|
221
|
+
var portFile = path.join(portDir, "port");
|
|
222
|
+
var configFile = path.join(portDir, "config.json");
|
|
223
|
+
|
|
199
224
|
function writePortFile(port) {
|
|
200
|
-
|
|
201
|
-
try { fs.
|
|
202
|
-
|
|
225
|
+
try { fs.mkdirSync(portDir, { recursive: true }); } catch (e) {}
|
|
226
|
+
try { fs.writeFileSync(portFile, String(port), "utf8"); } catch (e) { log("warn", "could not write port file: " + e.message); }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Only this panel's own entry. Another panel may have written the file since
|
|
230
|
+
// — a second After Effects instance, say — and its port is not ours to erase.
|
|
231
|
+
function removePortFile(port) {
|
|
232
|
+
try {
|
|
233
|
+
if (fs.readFileSync(portFile, "utf8").trim() === String(port)) fs.unlinkSync(portFile);
|
|
234
|
+
} catch (e) {}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------- Config ----------
|
|
238
|
+
// A CEP panel cannot read an environment variable the user set: it runs in
|
|
239
|
+
// After Effects' process, with Adobe's environment. So its two knobs live in
|
|
240
|
+
// a JSON file beside the port file, ~/.engineroom-ae-mcp/config.json:
|
|
241
|
+
//
|
|
242
|
+
// { "port": 7777, "allowPortWalk": false }
|
|
243
|
+
//
|
|
244
|
+
// port the port to bind. Default 7777. 0 binds an ephemeral port,
|
|
245
|
+
// which is what the offline tests use.
|
|
246
|
+
// allowPortWalk when the port is held by another AE MCP panel, move to the
|
|
247
|
+
// next free one instead of waiting for it. For people who run
|
|
248
|
+
// two After Effects instances at once. Off by default: on one
|
|
249
|
+
// machine the holder is a zombie, and walking around it is
|
|
250
|
+
// what issue #92 was.
|
|
251
|
+
//
|
|
252
|
+
// Anything unreadable is logged and ignored; the defaults are what shipped.
|
|
253
|
+
var DEFAULT_PORT = 7777;
|
|
254
|
+
function readConfig() {
|
|
255
|
+
var cfg = { port: DEFAULT_PORT, allowPortWalk: false, bindRetryMs: 2000, source: null };
|
|
256
|
+
var raw;
|
|
257
|
+
try { raw = fs.readFileSync(configFile, "utf8"); } catch (e) { return cfg; }
|
|
258
|
+
var parsed;
|
|
259
|
+
try { parsed = JSON.parse(raw); }
|
|
260
|
+
catch (e) { log("warn", "ignoring " + configFile + " — not valid JSON (" + e.message + ")"); return cfg; }
|
|
261
|
+
if (!parsed || typeof parsed !== "object") {
|
|
262
|
+
log("warn", "ignoring " + configFile + " — expected a JSON object");
|
|
263
|
+
return cfg;
|
|
264
|
+
}
|
|
265
|
+
cfg.source = configFile;
|
|
266
|
+
if (parsed.port !== undefined) {
|
|
267
|
+
if (typeof parsed.port === "number" && parsed.port >= 0 && parsed.port <= 65535 && parsed.port === Math.floor(parsed.port)) cfg.port = parsed.port;
|
|
268
|
+
else log("warn", "ignoring \"port\" in " + configFile + " — expected a whole number 0-65535, got " + JSON.stringify(parsed.port));
|
|
269
|
+
}
|
|
270
|
+
if (parsed.allowPortWalk !== undefined) {
|
|
271
|
+
if (typeof parsed.allowPortWalk === "boolean") cfg.allowPortWalk = parsed.allowPortWalk;
|
|
272
|
+
else log("warn", "ignoring \"allowPortWalk\" in " + configFile + " — expected true or false");
|
|
273
|
+
}
|
|
274
|
+
// Undocumented on purpose: how often to retry a held port. Tests shorten it.
|
|
275
|
+
if (typeof parsed.bindRetryMs === "number" && parsed.bindRetryMs >= 50) cfg.bindRetryMs = parsed.bindRetryMs;
|
|
276
|
+
return cfg;
|
|
203
277
|
}
|
|
204
278
|
|
|
205
279
|
// ---------- WS broadcast ----------
|
|
@@ -212,39 +286,16 @@
|
|
|
212
286
|
}
|
|
213
287
|
|
|
214
288
|
// ---------- Vision: read PNG and base64-encode (for screenshot_* ops) ----------
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
try {
|
|
226
|
-
var stat2 = fs.statSync(file);
|
|
227
|
-
if (stat2.size === stat.size) return resolve(stat2.size);
|
|
228
|
-
} catch (e) {}
|
|
229
|
-
if (Date.now() > deadline) return reject(new Error("PNG file write timed out"));
|
|
230
|
-
poll();
|
|
231
|
-
}, 30);
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
} catch (e) {}
|
|
236
|
-
if (Date.now() > deadline) return reject(new Error("PNG file did not appear: " + file));
|
|
237
|
-
setTimeout(poll, 40);
|
|
238
|
-
})();
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
// Read the true pixel dimensions out of the PNG's IHDR chunk rather than
|
|
242
|
-
// computing them, so what we report is always what the client received.
|
|
243
|
-
function pngDimensions(buf) {
|
|
244
|
-
if (buf.length < 24) return null;
|
|
245
|
-
if (buf.readUInt32BE(12) !== 0x49484452) return null; // "IHDR"
|
|
246
|
-
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
247
|
-
}
|
|
289
|
+
// Deciding when After Effects has finished writing a frame is issue #45, and
|
|
290
|
+
// it lives in framereader.js so a unit test can drive it with a file it grows
|
|
291
|
+
// by hand. What matters here: `waitForCompletePng` resolves only with bytes
|
|
292
|
+
// that parse end-to-end as a PNG, deletes the temp file on every path, and
|
|
293
|
+
// rejects with FRAME_INCOMPLETE or RENDER_TIMEOUT — never with one message
|
|
294
|
+
// covering both.
|
|
295
|
+
var waitForCompletePng = frameReaderModule.waitForCompletePng;
|
|
296
|
+
var dressFrameError = frameReaderModule.dressFrameError;
|
|
297
|
+
var frameError = frameReaderModule.frameError;
|
|
298
|
+
var unlinkQuietly = frameReaderModule.unlinkQuietly;
|
|
248
299
|
|
|
249
300
|
function sha256(bytes) {
|
|
250
301
|
return crypto.createHash("sha256").update(bytes).digest("hex");
|
|
@@ -259,19 +310,25 @@
|
|
|
259
310
|
// and hands back the decoded samples so a frame can be recognised if After
|
|
260
311
|
// Effects serves the same buffer again for a different request.
|
|
261
312
|
function readPngFrame(file) {
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
return
|
|
266
|
-
var buf = fs.readFileSync(file);
|
|
267
|
-
try { fs.unlinkSync(file); } catch (e) {}
|
|
313
|
+
// waitForCompletePng owns the temp file and removes it on every path,
|
|
314
|
+
// success or failure. A file left behind is one a later read could find,
|
|
315
|
+
// which is how a corrupt frame would start coming back for free.
|
|
316
|
+
return waitForCompletePng(file).then(function (buf) {
|
|
268
317
|
var norm;
|
|
269
318
|
try {
|
|
270
319
|
norm = pngCodec.normalizePng(buf);
|
|
271
320
|
} catch (e) {
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
321
|
+
// The completeness gate above guarantees this file ends in a well-formed
|
|
322
|
+
// IEND, so a truncation error here would mean the gate is broken. Refuse
|
|
323
|
+
// rather than ship: "never report success for work that didn't happen"
|
|
324
|
+
// outranks the fallback below.
|
|
325
|
+
if (/truncated PNG/.test(e.message)) {
|
|
326
|
+
throw frameError("FRAME_INCOMPLETE", "the file passed the completeness check and then failed to parse (" + e.message + ")");
|
|
327
|
+
}
|
|
328
|
+
// A complete PNG this parser cannot read is still a PNG the client
|
|
329
|
+
// might. Ship it unchanged rather than throwing away a render that did
|
|
330
|
+
// happen — but say so, and fall back to hashing the file so the stale
|
|
331
|
+
// check survives.
|
|
275
332
|
log("warn", "could not normalise the frame (" + e.message + "); sending it unconverted");
|
|
276
333
|
var dims = pngDimensions(buf);
|
|
277
334
|
return {
|
|
@@ -299,6 +356,11 @@
|
|
|
299
356
|
empty: false,
|
|
300
357
|
converted: norm.converted,
|
|
301
358
|
sourceBitDepth: norm.bitDepth,
|
|
359
|
+
// Decoded samples, kept only so the contact sheet can composite them
|
|
360
|
+
// without decoding the same PNG a second time. Null on the passthrough
|
|
361
|
+
// path, where the pixels were never interpreted.
|
|
362
|
+
pixels: norm.decoded ? norm.hashInput : null,
|
|
363
|
+
channels: norm.channels,
|
|
302
364
|
hash: sha256(norm.hashInput),
|
|
303
365
|
// The pixels could not be read, so "empty" was never evaluated and the
|
|
304
366
|
// hash is of the compressed stream. Both are still better than nothing;
|
|
@@ -311,6 +373,55 @@
|
|
|
311
373
|
});
|
|
312
374
|
}
|
|
313
375
|
|
|
376
|
+
// Read the true pixel dimensions out of the PNG's IHDR chunk rather than
|
|
377
|
+
// computing them, so what we report is always what the client received.
|
|
378
|
+
function pngDimensions(buf) {
|
|
379
|
+
if (buf.length < 24) return null;
|
|
380
|
+
if (buf.readUInt32BE(12) !== 0x49484452) return null; // "IHDR"
|
|
381
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---------- Rendering one frame, with one bounded retry ----------
|
|
385
|
+
function renderFrameOnce(op, args) {
|
|
386
|
+
return runOp(op, args).then(function (info) {
|
|
387
|
+
return readPngFrame(info.path).then(function (img) {
|
|
388
|
+
return { info: info, img: img };
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Render a frame, and re-render exactly once if what came back was corrupt.
|
|
395
|
+
*
|
|
396
|
+
* Safe to repeat, which is the only reason this is here. Both screenshot ops
|
|
397
|
+
* are read-only: `screenshot_frame` renders to a temp file, `screenshot_layer`
|
|
398
|
+
* additionally solos a layer and restores every solo state in a `finally`.
|
|
399
|
+
* Neither leaves anything in the project, so a second run is
|
|
400
|
+
* indistinguishable from one run — this is not `run_jsx`, where re-running a
|
|
401
|
+
* script that already had its side effects duplicates them (issue #43).
|
|
402
|
+
*
|
|
403
|
+
* Only on FRAME_INCOMPLETE, never on RENDER_TIMEOUT. A timeout has already
|
|
404
|
+
* spent the 120s budget; a second one would take the op past the server's
|
|
405
|
+
* 300s ceiling and turn a precise "the render did not finish" into a bridge
|
|
406
|
+
* timeout, whose remedy ("do not restart anything, wait") is a different
|
|
407
|
+
* remedy for a different problem. A corrupt read, by contrast, fails within
|
|
408
|
+
* FRAME_STALL_MS, and the evidence in #45 is that a retry sometimes succeeds
|
|
409
|
+
* — a light comp returned one truncated frame and then rendered.
|
|
410
|
+
*
|
|
411
|
+
* The temp file of a failed attempt is deleted before the retry (in
|
|
412
|
+
* `waitForCompletePng`), and ExtendScript names a fresh one per call, so a
|
|
413
|
+
* retry can never be handed the previous attempt's bytes.
|
|
414
|
+
*/
|
|
415
|
+
function renderFrameWithRetry(op, args) {
|
|
416
|
+
return renderFrameOnce(op, args).catch(function (first) {
|
|
417
|
+
if (first.code !== "FRAME_INCOMPLETE") throw dressFrameError(first, 1);
|
|
418
|
+
log("warn", op + ": " + first.detail + " — re-rendering once");
|
|
419
|
+
return renderFrameOnce(op, args).catch(function (second) {
|
|
420
|
+
throw dressFrameError(second, 2);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
314
425
|
// ---------- Stale render detection ----------
|
|
315
426
|
// The request identity a render is expected to be unique to. Built from what
|
|
316
427
|
// ExtendScript resolved, not from the arguments, so a defaulted time or
|
|
@@ -350,10 +461,17 @@
|
|
|
350
461
|
}
|
|
351
462
|
|
|
352
463
|
// ---------- Long job continuation loop ----------
|
|
353
|
-
|
|
464
|
+
// Each turn of this loop is one evalScript, and each one now opens and closes
|
|
465
|
+
// its own undo group inside itself — a group cannot span two calls, because
|
|
466
|
+
// After Effects discards one that does (issue #69). So the chunk size is not
|
|
467
|
+
// only a pacing knob any more: it decides how many undo steps a long batch
|
|
468
|
+
// costs the user. It comes off the job envelope so the JSX side owns the
|
|
469
|
+
// number and the two cannot drift.
|
|
470
|
+
function driveJob(jobId, progressToken, chunkSize) {
|
|
354
471
|
var totalGuess = null;
|
|
472
|
+
var size = chunkSize || 25;
|
|
355
473
|
function step() {
|
|
356
|
-
return runOp("_continue_job", { jobId: jobId, chunkSize:
|
|
474
|
+
return runOp("_continue_job", { jobId: jobId, chunkSize: size }).then(function (res) {
|
|
357
475
|
if (res.total) totalGuess = res.total;
|
|
358
476
|
if (!res.done) {
|
|
359
477
|
broadcast({ type: "progress", jobId: jobId, progress: res.progress, total: res.total, message: "running" });
|
|
@@ -361,11 +479,37 @@
|
|
|
361
479
|
return new Promise(function (r) { setTimeout(r, 0); }).then(step);
|
|
362
480
|
}
|
|
363
481
|
if (res.failed) {
|
|
364
|
-
|
|
365
|
-
|
|
482
|
+
// A failed job reaches the server as an error string and nothing else,
|
|
483
|
+
// so what the batch cost has to travel inside it or it is lost: the
|
|
484
|
+
// ops before the failure are applied and stay applied.
|
|
485
|
+
var failedMsg = (res.error || "batch failed") + (res.note ? " || " + res.note : "");
|
|
486
|
+
broadcast({ type: "error", jobId: jobId, error: failedMsg });
|
|
487
|
+
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 };
|
|
366
488
|
}
|
|
367
|
-
|
|
368
|
-
|
|
489
|
+
// `undoSteps` is the measured number of undo groups the batch opened,
|
|
490
|
+
// and `diff` is the batch's own before/after — both are only ever seen
|
|
491
|
+
// by the agent if they are carried on this event.
|
|
492
|
+
broadcast({
|
|
493
|
+
type: "complete",
|
|
494
|
+
jobId: jobId,
|
|
495
|
+
result: {
|
|
496
|
+
results: res.results,
|
|
497
|
+
errors: res.errors,
|
|
498
|
+
total: res.total || totalGuess,
|
|
499
|
+
cancelled: !!res.cancelled,
|
|
500
|
+
undoSteps: res.undoSteps,
|
|
501
|
+
undoGroupName: res.undoGroupName,
|
|
502
|
+
note: res.note,
|
|
503
|
+
diff: res.diff,
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
return {
|
|
507
|
+
done: true, jobId: jobId,
|
|
508
|
+
results: res.results, errors: res.errors,
|
|
509
|
+
total: res.total || totalGuess, cancelled: !!res.cancelled,
|
|
510
|
+
undoSteps: res.undoSteps, undoGroupName: res.undoGroupName,
|
|
511
|
+
note: res.note, diff: res.diff,
|
|
512
|
+
};
|
|
369
513
|
});
|
|
370
514
|
}
|
|
371
515
|
return step();
|
|
@@ -380,9 +524,12 @@
|
|
|
380
524
|
// successful export because its thumbnail could not be improved would be the
|
|
381
525
|
// worse trade. The result says plainly that the thumbnail is still AE's.
|
|
382
526
|
function patchMogrtThumbnail(info) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
527
|
+
// The same completeness gate the screenshot path uses. The poster frame is
|
|
528
|
+
// written by the same asynchronous `saveFrameToPng`, so a half-written one
|
|
529
|
+
// is just as possible here — and it would be resampled into the template's
|
|
530
|
+
// thumbnail rather than merely displayed.
|
|
531
|
+
// It takes the poster file with it, on success and on failure alike.
|
|
532
|
+
return waitForCompletePng(info.posterPngPath).then(function (poster) {
|
|
386
533
|
var patched = mogrtModule.patchThumbnail(fs.readFileSync(info.path), poster);
|
|
387
534
|
// Write via a sibling temp file and rename, so an interrupted write
|
|
388
535
|
// cannot leave a half-rewritten template where a valid one used to be.
|
|
@@ -399,7 +546,7 @@
|
|
|
399
546
|
letterboxed: patched.letterboxed || undefined,
|
|
400
547
|
};
|
|
401
548
|
}).catch(function (e) {
|
|
402
|
-
|
|
549
|
+
unlinkQuietly(info.posterPngPath);
|
|
403
550
|
log("warn", "could not patch the .mogrt thumbnail: " + e.message);
|
|
404
551
|
return {
|
|
405
552
|
patched: false,
|
|
@@ -410,6 +557,203 @@
|
|
|
410
557
|
});
|
|
411
558
|
}
|
|
412
559
|
|
|
560
|
+
// ---------- Contact sheet ----------
|
|
561
|
+
//
|
|
562
|
+
// ExtendScript renders every requested time and hands back one temp path per
|
|
563
|
+
// tile; everything from here on is pixels, which `packages/jsx` has no way to
|
|
564
|
+
// touch. One tile that fails is drawn as a marked block rather than dropped —
|
|
565
|
+
// the sheet has to keep mapping onto the times that were asked for, or an
|
|
566
|
+
// agent counting frames left to right reads the wrong one as the right one.
|
|
567
|
+
|
|
568
|
+
/** One line, for a note inside a tile rather than a whole error message. */
|
|
569
|
+
function shortReason(err) {
|
|
570
|
+
var s = err && err.detail ? err.detail : (err && err.message ? err.message : String(err));
|
|
571
|
+
return String(s).split("\n")[0];
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function readSheetTile(info, tile) {
|
|
575
|
+
if (tile.error) {
|
|
576
|
+
return Promise.resolve({ time: tile.time, status: "failed", note: tile.error });
|
|
577
|
+
}
|
|
578
|
+
return readPngFrame(tile.path).then(function (img) {
|
|
579
|
+
return { time: tile.time, status: "ok", img: img };
|
|
580
|
+
}, function (err) {
|
|
581
|
+
if (err.code !== "FRAME_INCOMPLETE") {
|
|
582
|
+
return { time: tile.time, status: "failed", note: shortReason(err) };
|
|
583
|
+
}
|
|
584
|
+
// The same bounded retry the single-frame path gets, and safe for the same
|
|
585
|
+
// reason — but re-rendering only this one time. The already-derived
|
|
586
|
+
// downsample is passed explicitly so the per-tile factor cannot drift from
|
|
587
|
+
// the rest of the sheet.
|
|
588
|
+
log("warn", "contact sheet tile at " + tile.time + "s: " + shortReason(err) + " — re-rendering once");
|
|
589
|
+
return runOp("screenshot_frame", {
|
|
590
|
+
compId: info.compId,
|
|
591
|
+
time: tile.time,
|
|
592
|
+
downsample: info.downsample,
|
|
593
|
+
}).then(function (retry) {
|
|
594
|
+
return readPngFrame(retry.path).then(function (img) {
|
|
595
|
+
return { time: tile.time, status: "ok", img: img };
|
|
596
|
+
});
|
|
597
|
+
}).catch(function (again) {
|
|
598
|
+
return { time: tile.time, status: "failed", note: shortReason(again) };
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function finishContactSheet(info, read) {
|
|
604
|
+
var i;
|
|
605
|
+
var ds = info.downsample;
|
|
606
|
+
// Pass one: classify. Every `match` is computed before any `remember`, so a
|
|
607
|
+
// sheet's own tiles cannot collide with each other through the cache.
|
|
608
|
+
var seen = {};
|
|
609
|
+
for (i = 0; i < read.length; i++) {
|
|
610
|
+
var t = read[i];
|
|
611
|
+
if (t.status !== "ok") continue;
|
|
612
|
+
if (t.img.empty) { t.status = "empty"; t.img = null; continue; }
|
|
613
|
+
var ident = { compId: info.compId, layerId: null, time: t.time, downsample: ds };
|
|
614
|
+
t.key = frameKey("screenshot_frame", ident);
|
|
615
|
+
var match = frameCache.match(t.key, t.img.hash);
|
|
616
|
+
if (match) {
|
|
617
|
+
// Pixels identical to an *earlier, different* request are the #29 stale
|
|
618
|
+
// buffer, and a tile of them is not a picture of this time. Mark the
|
|
619
|
+
// block rather than showing it: an agent that can see a frame believes
|
|
620
|
+
// the frame.
|
|
621
|
+
t.status = "stale";
|
|
622
|
+
t.note = "identical to " + match.label + ", rendered " +
|
|
623
|
+
Math.max(1, Math.round(match.ageMs / 1000)) + "s earlier";
|
|
624
|
+
t.img = null;
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
// Two tiles of one sheet matching each other is what a static comp looks
|
|
628
|
+
// like — the caller asked for several times on purpose, and refusing the
|
|
629
|
+
// second would be a false alarm on a correct answer. Flagged, not refused.
|
|
630
|
+
if (seen[t.img.hash] !== undefined) {
|
|
631
|
+
t.note = "pixel-identical to the " + contactSheetModule.formatTime(seen[t.img.hash]) +
|
|
632
|
+
" tile — nothing changed between them";
|
|
633
|
+
} else {
|
|
634
|
+
seen[t.img.hash] = t.time;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
var specs = [];
|
|
639
|
+
var ok = 0;
|
|
640
|
+
for (i = 0; i < read.length; i++) {
|
|
641
|
+
var r = read[i];
|
|
642
|
+
var spec = { time: r.time, status: r.status, note: r.note };
|
|
643
|
+
if (r.status === "ok") {
|
|
644
|
+
var px = r.img.pixels;
|
|
645
|
+
var channels = r.img.channels;
|
|
646
|
+
if (!px) {
|
|
647
|
+
// The passthrough path never interpreted the pixels. A sheet needs
|
|
648
|
+
// them, so decode strictly here; a frame that cannot be decoded
|
|
649
|
+
// becomes a marked block rather than a wrong one.
|
|
650
|
+
try {
|
|
651
|
+
var dec = pngCodec.decodePng8(r.img.buffer);
|
|
652
|
+
px = dec.pixels;
|
|
653
|
+
channels = dec.channels;
|
|
654
|
+
} catch (e) {
|
|
655
|
+
// Both records move together: `spec` is what gets drawn, `r` is what
|
|
656
|
+
// the cache loop below reads, and a frame that is not delivered must
|
|
657
|
+
// not be remembered as if it were.
|
|
658
|
+
spec.status = "failed";
|
|
659
|
+
r.status = "failed";
|
|
660
|
+
spec.note = "the frame could not be decoded for tiling (" + e.message + ")";
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (spec.status === "ok") {
|
|
664
|
+
spec.pixels = px;
|
|
665
|
+
spec.channels = channels;
|
|
666
|
+
spec.width = r.img.width;
|
|
667
|
+
spec.height = r.img.height;
|
|
668
|
+
ok++;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
specs.push(spec);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (!ok) {
|
|
675
|
+
var why = [];
|
|
676
|
+
for (i = 0; i < specs.length; i++) {
|
|
677
|
+
why.push(contactSheetModule.formatTime(specs[i].time) + ": " + specs[i].status +
|
|
678
|
+
(specs[i].note ? " (" + specs[i].note + ")" : ""));
|
|
679
|
+
}
|
|
680
|
+
var dead = new Error(
|
|
681
|
+
"No tile of the contact sheet rendered, so there is no sheet to send. Per time:\n" +
|
|
682
|
+
why.join("\n") + "\n\n" +
|
|
683
|
+
"A sheet of coloured blocks is not a screenshot. Retry at a higher `downsample`, " +
|
|
684
|
+
"or screenshot the shot precomps one at a time; if it repeats, read keyframes " +
|
|
685
|
+
"(get_keyframes / get_layer_full) instead."
|
|
686
|
+
);
|
|
687
|
+
dead.code = "CONTACT_SHEET_FAILED";
|
|
688
|
+
throw dead;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// The expected cell size, used only for the cells with no picture. A tile
|
|
692
|
+
// that rendered is the authority on its own dimensions.
|
|
693
|
+
var sheet = contactSheetModule.composeContactSheet(specs, {
|
|
694
|
+
cellWidth: Math.round(info.width / ds),
|
|
695
|
+
cellHeight: Math.round(info.height / ds),
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
// Only what is actually being delivered goes into the cache.
|
|
699
|
+
for (i = 0; i < read.length; i++) {
|
|
700
|
+
if (read[i].status === "ok" && read[i].key) {
|
|
701
|
+
frameCache.remember(read[i].key, read[i].img.hash, {
|
|
702
|
+
label: frameLabel("screenshot_frame", { compId: info.compId, time: read[i].time, downsample: ds }),
|
|
703
|
+
bytes: read[i].img.buffer.length,
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
var bad = [];
|
|
709
|
+
for (i = 0; i < sheet.tiles.length; i++) {
|
|
710
|
+
if (sheet.tiles[i].status !== "ok") {
|
|
711
|
+
bad.push(sheet.tiles[i].label + (sheet.tiles[i].note ? " — " + sheet.tiles[i].note : ""));
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return {
|
|
716
|
+
contactSheet: true,
|
|
717
|
+
width: sheet.width,
|
|
718
|
+
height: sheet.height,
|
|
719
|
+
cols: sheet.cols,
|
|
720
|
+
rows: sheet.rows,
|
|
721
|
+
cellWidth: sheet.cellWidth,
|
|
722
|
+
cellHeight: sheet.cellHeight,
|
|
723
|
+
fullWidth: info.width,
|
|
724
|
+
fullHeight: info.height,
|
|
725
|
+
downsample: ds,
|
|
726
|
+
compId: info.compId,
|
|
727
|
+
tiles: sheet.tiles,
|
|
728
|
+
mimeType: "image/png",
|
|
729
|
+
base64: sheet.buffer.toString("base64"),
|
|
730
|
+
bytes: sheet.buffer.length,
|
|
731
|
+
// Named and counted, because a sheet that looks complete and is not is the
|
|
732
|
+
// same class of lie as a swallowed error.
|
|
733
|
+
warning: bad.length
|
|
734
|
+
? bad.length + " of " + sheet.tiles.length + " tiles are marked blocks, not frames: " + bad.join("; ")
|
|
735
|
+
: undefined,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function renderContactSheet(args) {
|
|
740
|
+
return runOp("screenshot_frame", args).then(function (info) {
|
|
741
|
+
var jsxTiles = info.tiles || [];
|
|
742
|
+
// Sequentially: `evalScript` is a mutex anyway, a per-tile retry re-enters
|
|
743
|
+
// ExtendScript, and reading them in order keeps the panel log legible.
|
|
744
|
+
var read = [];
|
|
745
|
+
var chain = Promise.resolve();
|
|
746
|
+
for (var i = 0; i < jsxTiles.length; i++) {
|
|
747
|
+
chain = chain.then(function (tile) {
|
|
748
|
+
return function () {
|
|
749
|
+
return readSheetTile(info, tile).then(function (res) { read.push(res); });
|
|
750
|
+
};
|
|
751
|
+
}(jsxTiles[i]));
|
|
752
|
+
}
|
|
753
|
+
return chain.then(function () { return finishContactSheet(info, read); });
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
413
757
|
// ---------- Op handler with vision/job specialization ----------
|
|
414
758
|
function handleOp(op, args, progressToken) {
|
|
415
759
|
// Export writes the .mogrt; the thumbnail it wants is a second file that
|
|
@@ -430,36 +774,19 @@
|
|
|
430
774
|
}
|
|
431
775
|
// Vision ops: run JSX, then read PNG and base64-encode on Node side.
|
|
432
776
|
if (op === "screenshot_frame" || op === "screenshot_layer") {
|
|
433
|
-
|
|
434
|
-
return
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
fullWidth: info.width,
|
|
445
|
-
fullHeight: info.height,
|
|
446
|
-
downsample: info.downsample,
|
|
447
|
-
time: info.time,
|
|
448
|
-
compId: info.compId,
|
|
449
|
-
layerId: info.layerId,
|
|
450
|
-
reason: "Every pixel of this frame is fully transparent — the frame is empty. " +
|
|
451
|
-
"No image was sent because there is nothing in it to see. Usually this means " +
|
|
452
|
-
"the time is outside the layers' in/out points, the layers are disabled or " +
|
|
453
|
-
"have zero opacity, or the wrong comp was addressed.",
|
|
454
|
-
};
|
|
455
|
-
}
|
|
456
|
-
var key = frameKey(op, info);
|
|
457
|
-
var match = frameCache.match(key, img.hash);
|
|
458
|
-
if (match) throw staleFrameError(frameLabel(op, info), match, img.buffer.length);
|
|
459
|
-
frameCache.remember(key, img.hash, { label: frameLabel(op, info), bytes: img.buffer.length });
|
|
777
|
+
if (op === "screenshot_frame" && args && args.times && args.times.length) {
|
|
778
|
+
return renderContactSheet(args);
|
|
779
|
+
}
|
|
780
|
+
return renderFrameWithRetry(op, args).then(function (r) {
|
|
781
|
+
var info = r.info;
|
|
782
|
+
var img = r.img;
|
|
783
|
+
// A frame with nothing in it is a fact about the composition, not a
|
|
784
|
+
// failure — and the ~5KB PNG that encodes it is the one decoders
|
|
785
|
+
// reject. Report it, cheaply, instead of shipping an image nobody can
|
|
786
|
+
// read and calling that a successful screenshot.
|
|
787
|
+
if (img.empty) {
|
|
460
788
|
return {
|
|
461
|
-
|
|
462
|
-
// comp size, so they cannot disagree with the image sent.
|
|
789
|
+
empty: true,
|
|
463
790
|
width: img.width,
|
|
464
791
|
height: img.height,
|
|
465
792
|
fullWidth: info.width,
|
|
@@ -468,16 +795,41 @@
|
|
|
468
795
|
time: info.time,
|
|
469
796
|
compId: info.compId,
|
|
470
797
|
layerId: info.layerId,
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
// metadata stays quiet on the ordinary path.
|
|
476
|
-
converted: img.converted ? true : undefined,
|
|
477
|
-
sourceBitDepth: img.converted ? img.sourceBitDepth : undefined,
|
|
478
|
-
warning: img.warning,
|
|
798
|
+
reason: "Every pixel of this frame is fully transparent — the frame is empty. " +
|
|
799
|
+
"No image was sent because there is nothing in it to see. Usually this means " +
|
|
800
|
+
"the time is outside the layers' in/out points, the layers are disabled or " +
|
|
801
|
+
"have zero opacity, or the wrong comp was addressed.",
|
|
479
802
|
};
|
|
480
|
-
}
|
|
803
|
+
}
|
|
804
|
+
// Only a frame that read cleanly ever reaches the cache. A corrupt read
|
|
805
|
+
// is rejected above and never remembered — before the completeness gate
|
|
806
|
+
// existed, a truncated file was hashed and recorded here, so the next
|
|
807
|
+
// truncation at the same byte count was reported as a stale buffer and
|
|
808
|
+
// the real fault was hidden behind the wrong diagnosis.
|
|
809
|
+
var key = frameKey(op, info);
|
|
810
|
+
var match = frameCache.match(key, img.hash);
|
|
811
|
+
if (match) throw staleFrameError(frameLabel(op, info), match, img.buffer.length);
|
|
812
|
+
frameCache.remember(key, img.hash, { label: frameLabel(op, info), bytes: img.buffer.length });
|
|
813
|
+
return {
|
|
814
|
+
// Dimensions come from the PNG itself, not from arithmetic on the
|
|
815
|
+
// comp size, so they cannot disagree with the image sent.
|
|
816
|
+
width: img.width,
|
|
817
|
+
height: img.height,
|
|
818
|
+
fullWidth: info.width,
|
|
819
|
+
fullHeight: info.height,
|
|
820
|
+
downsample: info.downsample,
|
|
821
|
+
time: info.time,
|
|
822
|
+
compId: info.compId,
|
|
823
|
+
layerId: info.layerId,
|
|
824
|
+
mimeType: "image/png",
|
|
825
|
+
base64: img.buffer.toString("base64"),
|
|
826
|
+
bytes: img.buffer.length,
|
|
827
|
+
// Only present when the frame was not 8-bit as rendered, so the
|
|
828
|
+
// metadata stays quiet on the ordinary path.
|
|
829
|
+
converted: img.converted ? true : undefined,
|
|
830
|
+
sourceBitDepth: img.converted ? img.sourceBitDepth : undefined,
|
|
831
|
+
warning: img.warning,
|
|
832
|
+
};
|
|
481
833
|
});
|
|
482
834
|
}
|
|
483
835
|
// Long batches: returns {jobId, async, total}. We kick off background drive
|
|
@@ -485,10 +837,21 @@
|
|
|
485
837
|
if (op === "run_batch") {
|
|
486
838
|
return runOp(op, args).then(function (res) {
|
|
487
839
|
if (res && res.async && res.jobId) {
|
|
488
|
-
driveJob(res.jobId, progressToken).catch(function (e) {
|
|
840
|
+
driveJob(res.jobId, progressToken, res.chunkSize).catch(function (e) {
|
|
489
841
|
broadcast({ type: "error", jobId: res.jobId, error: e.message });
|
|
490
842
|
});
|
|
491
|
-
|
|
843
|
+
// The undo fields ride out with the envelope so the agent is told the
|
|
844
|
+
// batch will be several steps *before* it goes and says otherwise —
|
|
845
|
+
// the final count arrives much later, on the completion event.
|
|
846
|
+
return {
|
|
847
|
+
jobId: res.jobId,
|
|
848
|
+
async: true,
|
|
849
|
+
total: res.total,
|
|
850
|
+
chunkSize: res.chunkSize,
|
|
851
|
+
undoStepsEstimate: res.undoStepsEstimate,
|
|
852
|
+
undoGroupName: res.undoGroupName,
|
|
853
|
+
note: res.note,
|
|
854
|
+
};
|
|
492
855
|
}
|
|
493
856
|
return res; // small batch: inline result already
|
|
494
857
|
});
|
|
@@ -509,9 +872,15 @@
|
|
|
509
872
|
res.setHeader("content-type", "application/json");
|
|
510
873
|
// bundleHash identifies the code this panel is *running*, which is what
|
|
511
874
|
// the server needs to know before sending an op the panel may predate.
|
|
875
|
+
// `ok` + `bundleHash` is also how another copy of this panel, and the
|
|
876
|
+
// server's discovery, recognise this as an AE MCP panel — see
|
|
877
|
+
// isOurHealth below and isPanelHealth in the server's discovery.ts.
|
|
878
|
+
// The port is read off the socket, not the argument: port 0 binds an
|
|
879
|
+
// ephemeral one and the argument would say 0.
|
|
880
|
+
var addr = server.address();
|
|
512
881
|
res.end(JSON.stringify({
|
|
513
882
|
ok: true,
|
|
514
|
-
port: port,
|
|
883
|
+
port: addr && addr.port ? addr.port : port,
|
|
515
884
|
bundleLoaded: true,
|
|
516
885
|
bundleHash: loadedBundleHash,
|
|
517
886
|
ts: Date.now()
|
|
@@ -547,7 +916,7 @@
|
|
|
547
916
|
// `code` marks a failure the panel diagnosed itself rather than
|
|
548
917
|
// one ExtendScript raised, so the server can present it as what
|
|
549
918
|
// it is instead of prefixing it as an After Effects error.
|
|
550
|
-
res.end(JSON.stringify({ ok: false, error: err.message, code: err.code, stack: err.aeStack, line: err.aeLine }));
|
|
919
|
+
res.end(JSON.stringify({ ok: false, error: err.message, code: err.code, stack: err.aeStack, line: err.aeLine, source: err.aeSource }));
|
|
551
920
|
});
|
|
552
921
|
return;
|
|
553
922
|
}
|
|
@@ -582,26 +951,156 @@
|
|
|
582
951
|
});
|
|
583
952
|
}
|
|
584
953
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
954
|
+
// ---------- Who holds the port? ----------
|
|
955
|
+
// The same test the server's discovery makes (isPanelHealth in discovery.ts):
|
|
956
|
+
// `ok` plus `bundleHash` — or `bundleLoaded`, on a panel from before the hash
|
|
957
|
+
// existed — is what nothing else on a loopback port would answer with.
|
|
958
|
+
function isOurHealth(body) {
|
|
959
|
+
return !!body && typeof body === "object" && body.ok === true &&
|
|
960
|
+
("bundleHash" in body || "bundleLoaded" in body);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Never rejects: every outcome is a classification the caller acts on.
|
|
964
|
+
// panel — another AE MCP panel; its health body is attached
|
|
965
|
+
// other — something answered, but not as the panel
|
|
966
|
+
// silent — accepted the connection and said nothing, or errored
|
|
967
|
+
function identifyHolder(port) {
|
|
968
|
+
return new Promise(function (resolve) {
|
|
969
|
+
var done = false;
|
|
970
|
+
function finish(r) { if (!done) { done = true; resolve(r); } }
|
|
971
|
+
var req = http.get({ host: "127.0.0.1", port: port, path: "/health", timeout: 1500 }, function (res) {
|
|
972
|
+
var bufs = [];
|
|
973
|
+
res.on("data", function (c) { bufs.push(c); });
|
|
974
|
+
res.on("end", function () {
|
|
975
|
+
var body = null;
|
|
976
|
+
try { body = JSON.parse(Buffer.concat(bufs).toString("utf8")); } catch (e) {}
|
|
977
|
+
if (res.statusCode === 200 && isOurHealth(body)) finish({ kind: "panel", health: body });
|
|
978
|
+
else finish({ kind: "other", detail: "answered HTTP " + res.statusCode + (body ? " with an unfamiliar body" : " with no JSON") });
|
|
979
|
+
});
|
|
980
|
+
res.on("error", function (e) { finish({ kind: "silent", detail: e.message }); });
|
|
981
|
+
});
|
|
982
|
+
req.on("timeout", function () { req.destroy(); finish({ kind: "silent", detail: "accepted the connection but did not answer within 1.5s" }); });
|
|
983
|
+
req.on("error", function (e) { finish({ kind: "silent", detail: e.message }); });
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// 7777 through 7799 was the old ceiling; kept as a distance so a configured
|
|
988
|
+
// start port walks the same number of steps.
|
|
989
|
+
var WALK_LIMIT = 22;
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Bind the configured port, and decide what to do when it is taken.
|
|
993
|
+
*
|
|
994
|
+
* It used to walk: EADDRINUSE, try the next port, up to 7799, then write
|
|
995
|
+
* whatever it got to the port file. On one machine nothing else ever holds
|
|
996
|
+
* 7777, so the thing being walked around was always a previous instance of
|
|
997
|
+
* this same panel — a CEPHtmlEngine that lingered across an After Effects
|
|
998
|
+
* relaunch, or a second panel window — and the walk routed round it in
|
|
999
|
+
* silence, leaving the port file as a second source of truth that could
|
|
1000
|
+
* disagree with the socket. Four incidents in a week (issue #92).
|
|
1001
|
+
*
|
|
1002
|
+
* So the holder is identified first. If it is one of us, this panel does not
|
|
1003
|
+
* move: it says so in its status and log, and retries the bind on an interval
|
|
1004
|
+
* so it takes the port over the moment the other one exits, with no restart.
|
|
1005
|
+
* If it is something else, or answers nothing, the walk happens as before —
|
|
1006
|
+
* loudly, naming the holder and the port chosen, because that is the one
|
|
1007
|
+
* case where the port file legitimately says something other than 7777.
|
|
1008
|
+
* `allowPortWalk` in config.json opts back into walking past our own panel,
|
|
1009
|
+
* for people who really run two After Effects instances.
|
|
1010
|
+
*/
|
|
1011
|
+
function startServers(cfg) {
|
|
1012
|
+
var startPort = cfg.port;
|
|
1013
|
+
var retryMs = cfg.bindRetryMs;
|
|
1014
|
+
var waits = 0;
|
|
1015
|
+
|
|
1016
|
+
function bindWith(p) {
|
|
589
1017
|
return startHttp(p).then(function (server) {
|
|
1018
|
+
// Read back rather than assumed: port 0 binds an ephemeral one.
|
|
1019
|
+
var bound = server.address().port;
|
|
590
1020
|
// Mount WS on the same HTTP server.
|
|
591
1021
|
var wss = new WebSocket.Server({ server: server, path: "/events" });
|
|
592
1022
|
wss.on("connection", function (ws) {
|
|
593
1023
|
wsClients.add(ws);
|
|
594
1024
|
ws.on("close", function () { wsClients.delete(ws); });
|
|
595
1025
|
});
|
|
596
|
-
return { port:
|
|
597
|
-
})
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
1026
|
+
return { port: bound, server: server, wss: wss };
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function walk(p, tries, why) {
|
|
1031
|
+
var next = p + 1;
|
|
1032
|
+
if (tries >= WALK_LIMIT) {
|
|
1033
|
+
throw new Error("ports " + startPort + " through " + p + " are all in use — nothing free to bind. Last holder: " + why);
|
|
1034
|
+
}
|
|
1035
|
+
log("warn", "port " + p + " is held by " + why + " — moving to port " + next + ". " +
|
|
1036
|
+
"The MCP server finds this port through the port file; if tool calls fail on " + p +
|
|
1037
|
+
" while check_setup passes, this is why, and the server re-checks the port on the next refused call.");
|
|
1038
|
+
return attempt(next, tries + 1);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function waitForHolder(p, holder) {
|
|
1042
|
+
var same = holder.health.bundleHash && holder.health.bundleHash === loadedBundleHash;
|
|
1043
|
+
var hash = holder.health.bundleHash
|
|
1044
|
+
? String(holder.health.bundleHash).slice(0, 12)
|
|
1045
|
+
: "no bundleHash — a panel older than 0.3";
|
|
1046
|
+
setStatus("waiting — port " + p + " is held by another AE MCP panel", "warn");
|
|
1047
|
+
if (waits === 0) {
|
|
1048
|
+
log("warn", "port " + p + " is already held by another AE MCP panel (bundle " + hash + ", " +
|
|
1049
|
+
(same ? "the same version as this one" : "a DIFFERENT version from this one") + "). " +
|
|
1050
|
+
"Not moving to another port: that would leave the port file pointing at whichever panel bound last, " +
|
|
1051
|
+
"and the MCP server talking to the wrong one (issue #92).");
|
|
1052
|
+
log("warn", "Retrying the bind every " + (retryMs / 1000) + "s; this panel takes the port over the moment " +
|
|
1053
|
+
"the other one exits, with no restart. If After Effects was relaunched and this persists, the previous " +
|
|
1054
|
+
"panel process is still running: quit After Effects fully, check Activity Monitor / Task Manager for a " +
|
|
1055
|
+
"leftover CEPHtmlEngine, and reopen it. To run two After Effects at once instead, set " +
|
|
1056
|
+
"\"allowPortWalk\": true in " + configFile + ".");
|
|
1057
|
+
} else if (waits % 30 === 0) {
|
|
1058
|
+
log("info", "still waiting for port " + p + " (" + waits + " attempts)");
|
|
1059
|
+
}
|
|
1060
|
+
waits += 1;
|
|
1061
|
+
return new Promise(function (r) { setTimeout(r, retryMs); }).then(function () { return attempt(p, 0); });
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function attempt(p, tries) {
|
|
1065
|
+
return bindWith(p).catch(function (e) {
|
|
1066
|
+
if (e.code !== "EADDRINUSE") throw e;
|
|
1067
|
+
return identifyHolder(p).then(function (holder) {
|
|
1068
|
+
if (holder.kind === "panel") {
|
|
1069
|
+
if (cfg.allowPortWalk) {
|
|
1070
|
+
return walk(p, tries, "another AE MCP panel, and allowPortWalk is on in " + configFile);
|
|
1071
|
+
}
|
|
1072
|
+
return waitForHolder(p, holder);
|
|
1073
|
+
}
|
|
1074
|
+
var why = holder.kind === "other"
|
|
1075
|
+
? "something that is not an AE MCP panel (" + holder.detail + ")"
|
|
1076
|
+
: "something that did not identify itself (" + holder.detail + ")";
|
|
1077
|
+
return walk(p, tries, why);
|
|
1078
|
+
});
|
|
602
1079
|
});
|
|
603
1080
|
}
|
|
604
|
-
return attempt(startPort);
|
|
1081
|
+
return attempt(startPort, 0);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// ---------- Shutdown ----------
|
|
1085
|
+
// When the panel window closes or After Effects quits, give the port back and
|
|
1086
|
+
// take this panel's port-file entry with it. A panel that exits cleanly must
|
|
1087
|
+
// not leave a file naming a port nobody listens on — that is the other half
|
|
1088
|
+
// of how the port file came to disagree with the socket (issue #92). A
|
|
1089
|
+
// process that dies without unloading still leaves the file; the server's
|
|
1090
|
+
// discovery treats the file as a hint for exactly that reason.
|
|
1091
|
+
var running = null;
|
|
1092
|
+
function shutdown() {
|
|
1093
|
+
if (!running) return;
|
|
1094
|
+
var s = running;
|
|
1095
|
+
running = null;
|
|
1096
|
+
try { s.wss.close(); } catch (e) {}
|
|
1097
|
+
try { if (typeof s.server.closeAllConnections === "function") s.server.closeAllConnections(); } catch (e) {}
|
|
1098
|
+
try { s.server.close(); } catch (e) {}
|
|
1099
|
+
removePortFile(s.port);
|
|
1100
|
+
}
|
|
1101
|
+
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
|
1102
|
+
window.addEventListener("beforeunload", shutdown);
|
|
1103
|
+
window.addEventListener("unload", shutdown);
|
|
605
1104
|
}
|
|
606
1105
|
|
|
607
1106
|
// ---------- Boot sequence ----------
|
|
@@ -611,14 +1110,22 @@
|
|
|
611
1110
|
$ae.textContent = (host && host.appName ? host.appName : "AE") + " " + (host && host.appVersion ? host.appVersion : "");
|
|
612
1111
|
} catch (e) {}
|
|
613
1112
|
|
|
1113
|
+
var config = readConfig();
|
|
1114
|
+
if (config.source) {
|
|
1115
|
+
log("info", "read " + config.source + ": port " + config.port + (config.allowPortWalk ? ", allowPortWalk on" : ""));
|
|
1116
|
+
}
|
|
1117
|
+
|
|
614
1118
|
loadJsxBundle()
|
|
615
|
-
.then(function () { return startServers(
|
|
1119
|
+
.then(function () { return startServers(config); })
|
|
616
1120
|
.then(function (s) {
|
|
1121
|
+
running = s;
|
|
1122
|
+
// The only write of the port file, and only with the port actually bound.
|
|
617
1123
|
writePortFile(s.port);
|
|
618
1124
|
$port.textContent = String(s.port);
|
|
619
1125
|
$port.className = "ok";
|
|
620
1126
|
setStatus("ready", "ok");
|
|
621
|
-
log("info", "Bridge listening on http://127.0.0.1:" + s.port
|
|
1127
|
+
log("info", "Bridge listening on http://127.0.0.1:" + s.port +
|
|
1128
|
+
(s.port !== config.port && config.port !== 0 ? " (configured port " + config.port + " was taken — see above)" : ""));
|
|
622
1129
|
})
|
|
623
1130
|
.catch(function (e) {
|
|
624
1131
|
setStatus("failed", "err");
|