@engine-room/after-effects-mcp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@engine-room/after-effects-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Control Adobe After Effects with AI — describe the animation you want and it gets built: layers, keyframes, effects, expressions and text, all editable afterwards.",
6
6
  "license": "MIT",
@@ -1,8 +1,8 @@
1
1
  <?xml version="1.0" encoding="UTF-8"?>
2
- <ExtensionManifest Version="11.0" ExtensionBundleId="games.engine-room.ae-mcp" ExtensionBundleVersion="0.2.0"
2
+ <ExtensionManifest Version="11.0" ExtensionBundleId="games.engine-room.ae-mcp" ExtensionBundleVersion="0.3.0"
3
3
  ExtensionBundleName="AE MCP Bridge" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
4
4
  <ExtensionList>
5
- <Extension Id="games.engine-room.ae-mcp.panel" Version="0.2.0" />
5
+ <Extension Id="games.engine-room.ae-mcp.panel" Version="0.3.0" />
6
6
  </ExtensionList>
7
7
  <ExecutionEnvironment>
8
8
  <HostList>
@@ -0,0 +1,98 @@
1
+ // framecache.js — recent screenshot identity, for spotting a stale render.
2
+ //
3
+ // After Effects' render path sometimes answers a screenshot request with a
4
+ // buffer it produced for an earlier one, and reports success either way. Nothing
5
+ // else in the response distinguishes the two: the temp file is freshly named,
6
+ // the op returns ok, the PNG is well formed. The one signal that survives is
7
+ // content identity — two *different* requests coming back with byte-identical
8
+ // pixels is not a coincidence, it is one buffer handed out twice. Reported and
9
+ // evidenced as issue #29: byte-identical results for unrelated comps at
10
+ // unrelated times, and at different downsample factors, which cannot even be the
11
+ // same number of pixels.
12
+ //
13
+ // This lives in the panel rather than in the MCP server for two reasons. The
14
+ // panel is the only thing that sees every render — the documented workaround for
15
+ // this bug POSTs /op directly, and the check has to hold for that caller too.
16
+ // And it outlives any one MCP client process, which is the right lifetime for
17
+ // "what has this After Effects session rendered recently".
18
+ //
19
+ // Kept in its own file so tests/unit/frame-cache.mjs can require it without a
20
+ // DOM or a running After Effects.
21
+
22
+ "use strict";
23
+
24
+ // Enough history to catch the bug — it shows up within a handful of calls — and
25
+ // small enough that a long session cannot turn a linear scan into a cost.
26
+ var DEFAULT_LIMIT = 24;
27
+ // Long enough to span a working sequence of screenshots, short enough that a
28
+ // comp genuinely edited between two visits is not compared against its own past.
29
+ var DEFAULT_TTL_MS = 10 * 60 * 1000;
30
+
31
+ function createFrameCache(options) {
32
+ var opts = options || {};
33
+ var limit = opts.limit || DEFAULT_LIMIT;
34
+ var ttlMs = opts.ttlMs || DEFAULT_TTL_MS;
35
+ var now = opts.now || function () { return Date.now(); };
36
+ var entries = [];
37
+
38
+ function prune() {
39
+ var cutoff = now() - ttlMs;
40
+ var kept = [];
41
+ for (var i = 0; i < entries.length; i++) {
42
+ if (entries[i].at > cutoff) kept.push(entries[i]);
43
+ }
44
+ entries = kept;
45
+ }
46
+
47
+ return {
48
+ /**
49
+ * The most recent *different* request that produced these exact pixels, or
50
+ * null. Matching the same key proves nothing: repeating a screenshot is
51
+ * allowed to return the same picture, and an agent retrying after a stale
52
+ * report must not be told the retry is stale too.
53
+ */
54
+ match: function (key, hash) {
55
+ prune();
56
+ for (var i = entries.length - 1; i >= 0; i--) {
57
+ if (entries[i].hash === hash && entries[i].key !== key) {
58
+ return {
59
+ key: entries[i].key,
60
+ label: entries[i].label,
61
+ bytes: entries[i].bytes,
62
+ ageMs: now() - entries[i].at,
63
+ };
64
+ }
65
+ }
66
+ return null;
67
+ },
68
+
69
+ /**
70
+ * Record a frame that was actually delivered. Only successful, non-empty
71
+ * renders belong here: an empty frame is legitimately identical to every
72
+ * other empty frame, and remembering rejected ones would make the first
73
+ * stale buffer poison every later request that happens to repeat it.
74
+ */
75
+ remember: function (key, hash, meta) {
76
+ prune();
77
+ // One entry per request key, so re-running the same screenshot replaces
78
+ // its own record instead of filling the window with copies of itself.
79
+ var kept = [];
80
+ for (var i = 0; i < entries.length; i++) {
81
+ if (entries[i].key !== key) kept.push(entries[i]);
82
+ }
83
+ entries = kept;
84
+ entries.push({
85
+ key: key,
86
+ hash: hash,
87
+ at: now(),
88
+ label: (meta && meta.label) || key,
89
+ bytes: (meta && meta.bytes) || 0,
90
+ });
91
+ while (entries.length > limit) entries.shift();
92
+ },
93
+
94
+ size: function () { prune(); return entries.length; },
95
+ };
96
+ }
97
+
98
+ module.exports = { createFrameCache: createFrameCache };
@@ -11,14 +11,12 @@
11
11
  var os = require("os");
12
12
  var http = require("http");
13
13
  var crypto = require("crypto");
14
- var WebSocket;
15
- try { WebSocket = require("ws"); }
16
- catch (e) {
17
- // ws is bundled in packages/ae-panel/node_modules; resolve manually if normal require fails.
18
- var alt = path.join(__dirname, "..", "node_modules", "ws");
19
- WebSocket = require(alt);
20
- }
21
14
 
15
+ // The DOM handles and the logger come first so that everything below is able
16
+ // to report its own failure. `require("ws")` used to run before this point,
17
+ // so when it threw the exception escaped this whole function before a single
18
+ // line could be written — the panel sat on "starting…" indefinitely with the
19
+ // reason nowhere to be seen.
22
20
  var $status = document.getElementById("status");
23
21
  var $port = document.getElementById("port");
24
22
  var $ae = document.getElementById("ae");
@@ -41,8 +39,80 @@
41
39
  while ($log.childNodes.length > 80) $log.removeChild($log.lastChild);
42
40
  }
43
41
 
44
- var cs = new CSInterface();
45
- var extDir = cs.getSystemPath(SystemPath.EXTENSION);
42
+ // CSInterface comes from the plain <script> tag before this one, so it needs
43
+ // no require and is available here — which matters, because the extension
44
+ // path it reports is the only trustworthy anchor for everything below.
45
+ //
46
+ // __dirname is NOT that anchor. CEP anchors it at the *extension root* (where
47
+ // the manifest and node_modules live), not at the folder holding this file.
48
+ // That is why require("ws") resolves and a require of a file sitting right
49
+ // next to main.js does not.
50
+ var cs, extDir, clientDir;
51
+ try {
52
+ cs = new CSInterface();
53
+ extDir = cs.getSystemPath(SystemPath.EXTENSION);
54
+ clientDir = path.join(extDir, "client");
55
+ } catch (e) {
56
+ setStatus("cannot start — CSInterface is unavailable", "err");
57
+ log("error", "new CSInterface() failed: " + e.message);
58
+ return;
59
+ }
60
+
61
+ var WebSocket;
62
+ try { WebSocket = require("ws"); }
63
+ catch (primary) {
64
+ // ws is bundled at the extension root; resolve manually if normal require fails.
65
+ var alt = path.join(extDir, "node_modules", "ws");
66
+ try { WebSocket = require(alt); }
67
+ catch (fallback) {
68
+ // Nothing below can be built without ws, so this stops here — but it
69
+ // stops saying why, and naming the fix, rather than looking like a panel
70
+ // that is still starting up.
71
+ setStatus("cannot start — the ws module is missing", "err");
72
+ log("error", "require('ws') failed: " + primary.message);
73
+ log("error", "and " + alt + ": " + fallback.message);
74
+ log("error", "Quit After Effects, run the setup_panel tool, then reopen it.");
75
+ return;
76
+ }
77
+ }
78
+
79
+ // The screenshot checks live in their own files so a Node test can require
80
+ // them without a DOM: there is no After Effects on a CI runner, and neither
81
+ // real image code nor stale-buffer bookkeeping should be written blind.
82
+ // Same failure discipline as ws above — say what is missing, name the fix.
83
+ //
84
+ // The list is ordered by how much it is trusted, not by convenience.
85
+ // clientDir comes from CSInterface and is what the shipped layout actually
86
+ // is; the __dirname forms are kept behind it because a host build that
87
+ // anchors __dirname somewhere else again should degrade to a warning in the
88
+ // log rather than a panel that will not start.
89
+ function requireSibling(name) {
90
+ var candidates = [
91
+ path.join(clientDir, name),
92
+ path.join(__dirname, name),
93
+ path.join(__dirname, "client", name),
94
+ "./" + name,
95
+ ];
96
+ var failures = [];
97
+ for (var i = 0; i < candidates.length; i++) {
98
+ try { return require(candidates[i]); }
99
+ catch (e) { failures.push(candidates[i] + " (" + e.message.split("\n")[0] + ")"); }
100
+ }
101
+ throw new Error("could not load " + name + " from any of: " + failures.join("; "));
102
+ }
103
+ var pngCodec, frameCacheModule, mogrtModule;
104
+ try {
105
+ pngCodec = requireSibling("pngcodec.js");
106
+ frameCacheModule = requireSibling("framecache.js");
107
+ mogrtModule = requireSibling("mogrt.js");
108
+ } catch (e) {
109
+ setStatus("cannot start — pngcodec.js, framecache.js or mogrt.js is missing", "err");
110
+ log("error", "loading the panel's file-processing modules from " + __dirname + " failed: " + e.message);
111
+ log("error", "Quit After Effects, run the setup_panel tool, then reopen it.");
112
+ return;
113
+ }
114
+ var frameCache = frameCacheModule.createFrameCache();
115
+
46
116
  var bundlePath = path.join(extDir, "jsx", "bundle.jsx");
47
117
 
48
118
  // ---------- ExtendScript evalScript: serialized via Promise chain ----------
@@ -176,26 +246,109 @@
176
246
  return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
177
247
  }
178
248
 
249
+ function sha256(bytes) {
250
+ return crypto.createHash("sha256").update(bytes).digest("hex");
251
+ }
252
+
179
253
  // Downsampling happens in ExtendScript via the comp's resolutionFactor, so
180
254
  // the file on disk is already the right size by the time we get here.
181
- function readPngAsBase64(file) {
255
+ //
256
+ // What is *not* already right is the bit depth: a 16-bit project renders
257
+ // 16-bit-per-channel PNGs, which many decoders refuse outright. pngcodec
258
+ // converts those to 8-bit, tells us when the frame is entirely transparent,
259
+ // and hands back the decoded samples so a frame can be recognised if After
260
+ // Effects serves the same buffer again for a different request.
261
+ function readPngFrame(file) {
182
262
  // A cold render of a heavy 4K comp can take well over 15s — measured on a
183
263
  // real project. Five seconds silently failed screenshots that were simply
184
264
  // still rendering.
185
265
  return waitForPngFile(file, 120000).then(function () {
186
266
  var buf = fs.readFileSync(file);
187
- var b64 = buf.toString("base64");
188
267
  try { fs.unlinkSync(file); } catch (e) {}
189
- var dims = pngDimensions(buf);
268
+ var norm;
269
+ try {
270
+ norm = pngCodec.normalizePng(buf);
271
+ } 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.
275
+ log("warn", "could not normalise the frame (" + e.message + "); sending it unconverted");
276
+ var dims = pngDimensions(buf);
277
+ return {
278
+ buffer: buf,
279
+ width: dims ? dims.width : null,
280
+ height: dims ? dims.height : null,
281
+ empty: false,
282
+ hash: sha256(buf),
283
+ warning: "This frame could not be normalised to 8-bit (" + e.message +
284
+ ") and was sent exactly as After Effects wrote it. If it fails to decode, " +
285
+ "that is why.",
286
+ };
287
+ }
288
+ if (norm.empty) {
289
+ return { empty: true, width: norm.width, height: norm.height };
290
+ }
291
+ if (norm.converted) {
292
+ log("info", "converted a " + norm.bitDepth + "-bit frame to 8-bit (" +
293
+ buf.length + " -> " + norm.buffer.length + " bytes)");
294
+ }
190
295
  return {
191
- base64: b64,
192
- bytes: buf.length,
193
- width: dims ? dims.width : null,
194
- height: dims ? dims.height : null,
296
+ buffer: norm.buffer,
297
+ width: norm.width,
298
+ height: norm.height,
299
+ empty: false,
300
+ converted: norm.converted,
301
+ sourceBitDepth: norm.bitDepth,
302
+ hash: sha256(norm.hashInput),
303
+ // The pixels could not be read, so "empty" was never evaluated and the
304
+ // hash is of the compressed stream. Both are still better than nothing;
305
+ // saying which is what stops a later reader trusting the wrong one.
306
+ warning: norm.passthrough
307
+ ? "This frame's pixels were not inspected (" + norm.passthrough +
308
+ "), so it was sent exactly as After Effects wrote it."
309
+ : undefined,
195
310
  };
196
311
  });
197
312
  }
198
313
 
314
+ // ---------- Stale render detection ----------
315
+ // The request identity a render is expected to be unique to. Built from what
316
+ // ExtendScript resolved, not from the arguments, so a defaulted time or
317
+ // downsample is compared as the value actually rendered.
318
+ function frameKey(op, info) {
319
+ var layer = (info.layerId === undefined || info.layerId === null) ? "-" : info.layerId;
320
+ return [op, info.compId, layer, Number(info.time).toFixed(6), info.downsample].join("|");
321
+ }
322
+ function frameLabel(op, info) {
323
+ return "comp " + info.compId +
324
+ ((info.layerId === undefined || info.layerId === null) ? "" : " layer " + info.layerId) +
325
+ " @ " + Number(info.time).toFixed(3) + "s downsample " + info.downsample;
326
+ }
327
+ // An error rather than a warning attached to the image, because an agent that
328
+ // can see the picture will believe the picture. It carries the whole
329
+ // workaround: there is nothing this panel can do to make the render happen.
330
+ function staleFrameError(label, match, bytes) {
331
+ var ago = Math.max(1, Math.round(match.ageMs / 1000));
332
+ var err = new Error(
333
+ "Stale frame: After Effects returned pixels identical to an earlier, different " +
334
+ "request (" + match.label + ", " + match.bytes + " bytes, " + ago + "s ago), so this " +
335
+ "image is not a picture of " + label + " (" + bytes + " bytes) and has not been sent.\n" +
336
+ "\n" +
337
+ "What to do next:\n" +
338
+ "1. Wait a few seconds — back-to-back screenshots trigger this far more often.\n" +
339
+ "2. Retry with a higher `downsample` (6 has worked where 3-4 stayed stale).\n" +
340
+ "3. If it repeats, verify the animation by reading keyframes (get_keyframes / " +
341
+ "get_layer_full) instead. That is exact; a picture is not.\n" +
342
+ "\n" +
343
+ "Do NOT start disabling layers to make the render succeed: this is a limit of the " +
344
+ "panel's render path, not a problem with the project. If those two frames really " +
345
+ "are identical — a static comp — a different `downsample` will render a different " +
346
+ "number of pixels and confirm it."
347
+ );
348
+ err.code = "STALE_FRAME";
349
+ return err;
350
+ }
351
+
199
352
  // ---------- Long job continuation loop ----------
200
353
  function driveJob(jobId, progressToken) {
201
354
  var totalGuess = null;
@@ -218,12 +371,92 @@
218
371
  return step();
219
372
  }
220
373
 
374
+ // ---------- Motion Graphics template thumbnail ----------
375
+ // ExtendScript can render the poster frame but cannot rewrite a zip, so the
376
+ // JSX side hands back a PNG path and the archive surgery happens here.
377
+ //
378
+ // A failed patch is reported on an otherwise successful result, never thrown:
379
+ // the .mogrt is already written and valid at this point, and throwing away a
380
+ // successful export because its thumbnail could not be improved would be the
381
+ // worse trade. The result says plainly that the thumbnail is still AE's.
382
+ 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) {}
386
+ var patched = mogrtModule.patchThumbnail(fs.readFileSync(info.path), poster);
387
+ // Write via a sibling temp file and rename, so an interrupted write
388
+ // cannot leave a half-rewritten template where a valid one used to be.
389
+ var tmp = info.path + ".tmp-thumb";
390
+ fs.writeFileSync(tmp, patched.buffer);
391
+ fs.renameSync(tmp, info.path);
392
+ return {
393
+ patched: true,
394
+ posterTime: info.posterTime,
395
+ width: patched.width,
396
+ height: patched.height,
397
+ sourceWidth: patched.sourceWidth,
398
+ sourceHeight: patched.sourceHeight,
399
+ letterboxed: patched.letterboxed || undefined,
400
+ };
401
+ }).catch(function (e) {
402
+ try { fs.unlinkSync(info.posterPngPath); } catch (e2) {}
403
+ log("warn", "could not patch the .mogrt thumbnail: " + e.message);
404
+ return {
405
+ patched: false,
406
+ posterTime: info.posterTime,
407
+ reason: "The template exported correctly, but its thumbnail could not be replaced (" +
408
+ e.message + "). It still shows the one After Effects wrote.",
409
+ };
410
+ });
411
+ }
412
+
221
413
  // ---------- Op handler with vision/job specialization ----------
222
414
  function handleOp(op, args, progressToken) {
415
+ // Export writes the .mogrt; the thumbnail it wants is a second file that
416
+ // has to be folded into it afterwards.
417
+ if (op === "export_mogrt") {
418
+ return runOp(op, args).then(function (info) {
419
+ if (!info || !info.posterPngPath) return info;
420
+ return patchMogrtThumbnail(info).then(function (thumbnail) {
421
+ var out = {};
422
+ for (var k in info) { if (info.hasOwnProperty(k) && k !== "posterPngPath") out[k] = info[k]; }
423
+ out.thumbnail = thumbnail;
424
+ // The file changed size when the thumbnail was replaced, and the
425
+ // reported number has to be the one on disk.
426
+ try { out.bytes = fs.statSync(info.path).size; } catch (e) {}
427
+ return out;
428
+ });
429
+ });
430
+ }
223
431
  // Vision ops: run JSX, then read PNG and base64-encode on Node side.
224
432
  if (op === "screenshot_frame" || op === "screenshot_layer") {
225
433
  return runOp(op, args).then(function (info) {
226
- return readPngAsBase64(info.path).then(function (img) {
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 });
227
460
  return {
228
461
  // Dimensions come from the PNG itself, not from arithmetic on the
229
462
  // comp size, so they cannot disagree with the image sent.
@@ -236,8 +469,13 @@
236
469
  compId: info.compId,
237
470
  layerId: info.layerId,
238
471
  mimeType: "image/png",
239
- base64: img.base64,
240
- bytes: img.bytes,
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,
241
479
  };
242
480
  });
243
481
  });
@@ -306,7 +544,10 @@
306
544
  .catch(function (err) {
307
545
  log("error", body.op + ": " + err.message);
308
546
  res.statusCode = 500; res.setHeader("content-type", "application/json");
309
- res.end(JSON.stringify({ ok: false, error: err.message, stack: err.aeStack, line: err.aeLine }));
547
+ // `code` marks a failure the panel diagnosed itself rather than
548
+ // one ExtendScript raised, so the server can present it as what
549
+ // 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 }));
310
551
  });
311
552
  return;
312
553
  }