@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.
@@ -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 — pngcodec.js, framecache.js or mogrt.js is missing", "err");
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
- var dir = path.join(os.homedir(), ".engineroom-ae-mcp");
201
- try { fs.mkdirSync(dir, { recursive: true }); } catch (e) {}
202
- try { fs.writeFileSync(path.join(dir, "port"), String(port), "utf8"); } catch (e) { log("warn", "could not write port file: " + e.message); }
225
+ try { fs.mkdirSync(portDir, { recursive: true }); } catch (e) {}
226
+ try { fs.writeFileSync(portFile, String(port), "utf8"); } catch (e) { log("warn", "could not write port file: " + e.message); }
227
+ }
228
+
229
+ // Only this panel's own entry. Another panel may have written the file since
230
+ // — a second After Effects instance, say — and its port is not ours to erase.
231
+ function removePortFile(port) {
232
+ try {
233
+ if (fs.readFileSync(portFile, "utf8").trim() === String(port)) fs.unlinkSync(portFile);
234
+ } catch (e) {}
235
+ }
236
+
237
+ // ---------- Config ----------
238
+ // A CEP panel cannot read an environment variable the user set: it runs in
239
+ // After Effects' process, with Adobe's environment. So its two knobs live in
240
+ // a JSON file beside the port file, ~/.engineroom-ae-mcp/config.json:
241
+ //
242
+ // { "port": 7777, "allowPortWalk": false }
243
+ //
244
+ // port the port to bind. Default 7777. 0 binds an ephemeral port,
245
+ // which is what the offline tests use.
246
+ // allowPortWalk when the port is held by another AE MCP panel, move to the
247
+ // next free one instead of waiting for it. For people who run
248
+ // two After Effects instances at once. Off by default: on one
249
+ // machine the holder is a zombie, and walking around it is
250
+ // what issue #92 was.
251
+ //
252
+ // Anything unreadable is logged and ignored; the defaults are what shipped.
253
+ var DEFAULT_PORT = 7777;
254
+ function readConfig() {
255
+ var cfg = { port: DEFAULT_PORT, allowPortWalk: false, bindRetryMs: 2000, source: null };
256
+ var raw;
257
+ try { raw = fs.readFileSync(configFile, "utf8"); } catch (e) { return cfg; }
258
+ var parsed;
259
+ try { parsed = JSON.parse(raw); }
260
+ catch (e) { log("warn", "ignoring " + configFile + " — not valid JSON (" + e.message + ")"); return cfg; }
261
+ if (!parsed || typeof parsed !== "object") {
262
+ log("warn", "ignoring " + configFile + " — expected a JSON object");
263
+ return cfg;
264
+ }
265
+ cfg.source = configFile;
266
+ if (parsed.port !== undefined) {
267
+ if (typeof parsed.port === "number" && parsed.port >= 0 && parsed.port <= 65535 && parsed.port === Math.floor(parsed.port)) cfg.port = parsed.port;
268
+ else log("warn", "ignoring \"port\" in " + configFile + " — expected a whole number 0-65535, got " + JSON.stringify(parsed.port));
269
+ }
270
+ if (parsed.allowPortWalk !== undefined) {
271
+ if (typeof parsed.allowPortWalk === "boolean") cfg.allowPortWalk = parsed.allowPortWalk;
272
+ else log("warn", "ignoring \"allowPortWalk\" in " + configFile + " — expected true or false");
273
+ }
274
+ // Undocumented on purpose: how often to retry a held port. Tests shorten it.
275
+ if (typeof parsed.bindRetryMs === "number" && parsed.bindRetryMs >= 50) cfg.bindRetryMs = parsed.bindRetryMs;
276
+ return cfg;
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
- function waitForPngFile(file, maxMs) {
216
- var deadline = Date.now() + (maxMs || 3000);
217
- return new Promise(function (resolve, reject) {
218
- (function poll() {
219
- try {
220
- if (fs.existsSync(file)) {
221
- var stat = fs.statSync(file);
222
- // Reasonable size + ensure not currently being written (re-check)
223
- if (stat.size > 64) {
224
- setTimeout(function () {
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
- // A cold render of a heavy 4K comp can take well over 15s — measured on a
263
- // real project. Five seconds silently failed screenshots that were simply
264
- // still rendering.
265
- return waitForPngFile(file, 120000).then(function () {
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
- // A PNG this parser cannot read is still a PNG the client might. Ship
273
- // it unchanged rather than throwing away a render that did happen — but
274
- // say so, and fall back to hashing the file so the stale check survives.
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
- function driveJob(jobId, progressToken) {
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: 25 }).then(function (res) {
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
- broadcast({ type: "error", jobId: jobId, error: res.error || "batch failed" });
365
- return { done: true, failed: true, jobId: jobId, error: res.error, results: res.results, errors: res.errors, atIndex: res.atIndex };
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
- broadcast({ type: "complete", jobId: jobId, result: { results: res.results, errors: res.errors, total: res.total || totalGuess, cancelled: !!res.cancelled } });
368
- return { done: true, jobId: jobId, results: res.results, errors: res.errors, total: res.total || totalGuess, cancelled: !!res.cancelled };
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
- return waitForPngFile(info.posterPngPath, 120000).then(function () {
384
- var poster = fs.readFileSync(info.posterPngPath);
385
- try { fs.unlinkSync(info.posterPngPath); } catch (e) {}
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
- try { fs.unlinkSync(info.posterPngPath); } catch (e2) {}
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
- return runOp(op, args).then(function (info) {
434
- return readPngFrame(info.path).then(function (img) {
435
- // A frame with nothing in it is a fact about the composition, not a
436
- // failure — and the ~5KB PNG that encodes it is the one decoders
437
- // reject. Report it, cheaply, instead of shipping an image nobody can
438
- // read and calling that a successful screenshot.
439
- if (img.empty) {
440
- return {
441
- empty: true,
442
- width: img.width,
443
- height: img.height,
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
- // Dimensions come from the PNG itself, not from arithmetic on the
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
- mimeType: "image/png",
472
- base64: img.buffer.toString("base64"),
473
- bytes: img.buffer.length,
474
- // Only present when the frame was not 8-bit as rendered, so the
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
- return { jobId: res.jobId, async: true, total: res.total };
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
- function startServers(startPort) {
586
- var tries = 0;
587
- function attempt(p) {
588
- tries++;
954
+ // ---------- Who holds the port? ----------
955
+ // The same test the server's discovery makes (isPanelHealth in discovery.ts):
956
+ // `ok` plus `bundleHash` — or `bundleLoaded`, on a panel from before the hash
957
+ // existed — is what nothing else on a loopback port would answer with.
958
+ function isOurHealth(body) {
959
+ return !!body && typeof body === "object" && body.ok === true &&
960
+ ("bundleHash" in body || "bundleLoaded" in body);
961
+ }
962
+
963
+ // Never rejects: every outcome is a classification the caller acts on.
964
+ // panel — another AE MCP panel; its health body is attached
965
+ // other — something answered, but not as the panel
966
+ // silent — accepted the connection and said nothing, or errored
967
+ function identifyHolder(port) {
968
+ return new Promise(function (resolve) {
969
+ var done = false;
970
+ function finish(r) { if (!done) { done = true; resolve(r); } }
971
+ var req = http.get({ host: "127.0.0.1", port: port, path: "/health", timeout: 1500 }, function (res) {
972
+ var bufs = [];
973
+ res.on("data", function (c) { bufs.push(c); });
974
+ res.on("end", function () {
975
+ var body = null;
976
+ try { body = JSON.parse(Buffer.concat(bufs).toString("utf8")); } catch (e) {}
977
+ if (res.statusCode === 200 && isOurHealth(body)) finish({ kind: "panel", health: body });
978
+ else finish({ kind: "other", detail: "answered HTTP " + res.statusCode + (body ? " with an unfamiliar body" : " with no JSON") });
979
+ });
980
+ res.on("error", function (e) { finish({ kind: "silent", detail: e.message }); });
981
+ });
982
+ req.on("timeout", function () { req.destroy(); finish({ kind: "silent", detail: "accepted the connection but did not answer within 1.5s" }); });
983
+ req.on("error", function (e) { finish({ kind: "silent", detail: e.message }); });
984
+ });
985
+ }
986
+
987
+ // 7777 through 7799 was the old ceiling; kept as a distance so a configured
988
+ // start port walks the same number of steps.
989
+ var WALK_LIMIT = 22;
990
+
991
+ /**
992
+ * Bind the configured port, and decide what to do when it is taken.
993
+ *
994
+ * It used to walk: EADDRINUSE, try the next port, up to 7799, then write
995
+ * whatever it got to the port file. On one machine nothing else ever holds
996
+ * 7777, so the thing being walked around was always a previous instance of
997
+ * this same panel — a CEPHtmlEngine that lingered across an After Effects
998
+ * relaunch, or a second panel window — and the walk routed round it in
999
+ * silence, leaving the port file as a second source of truth that could
1000
+ * disagree with the socket. Four incidents in a week (issue #92).
1001
+ *
1002
+ * So the holder is identified first. If it is one of us, this panel does not
1003
+ * move: it says so in its status and log, and retries the bind on an interval
1004
+ * so it takes the port over the moment the other one exits, with no restart.
1005
+ * If it is something else, or answers nothing, the walk happens as before —
1006
+ * loudly, naming the holder and the port chosen, because that is the one
1007
+ * case where the port file legitimately says something other than 7777.
1008
+ * `allowPortWalk` in config.json opts back into walking past our own panel,
1009
+ * for people who really run two After Effects instances.
1010
+ */
1011
+ function startServers(cfg) {
1012
+ var startPort = cfg.port;
1013
+ var retryMs = cfg.bindRetryMs;
1014
+ var waits = 0;
1015
+
1016
+ function bindWith(p) {
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: p, server: server, wss: wss };
597
- }).catch(function (e) {
598
- if (e.code === "EADDRINUSE" && tries < 23 && p < 7799) {
599
- return attempt(p + 1);
600
- }
601
- throw e;
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(7777); })
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");