@engine-room/after-effects-mcp 0.2.1 → 0.3.1

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.1",
3
+ "version": "0.3.1",
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.1"
2
+ <ExtensionManifest Version="11.0" ExtensionBundleId="games.engine-room.ae-mcp" ExtensionBundleVersion="0.3.1"
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.1" />
5
+ <Extension Id="games.engine-room.ae-mcp.panel" Version="0.3.1" />
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 };
@@ -39,11 +39,30 @@
39
39
  while ($log.childNodes.length > 80) $log.removeChild($log.lastChild);
40
40
  }
41
41
 
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
+
42
61
  var WebSocket;
43
62
  try { WebSocket = require("ws"); }
44
63
  catch (primary) {
45
- // ws is bundled in packages/ae-panel/node_modules; resolve manually if normal require fails.
46
- var alt = path.join(__dirname, "..", "node_modules", "ws");
64
+ // ws is bundled at the extension root; resolve manually if normal require fails.
65
+ var alt = path.join(extDir, "node_modules", "ws");
47
66
  try { WebSocket = require(alt); }
48
67
  catch (fallback) {
49
68
  // Nothing below can be built without ws, so this stops here — but it
@@ -57,8 +76,43 @@
57
76
  }
58
77
  }
59
78
 
60
- var cs = new CSInterface();
61
- var extDir = cs.getSystemPath(SystemPath.EXTENSION);
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
+
62
116
  var bundlePath = path.join(extDir, "jsx", "bundle.jsx");
63
117
 
64
118
  // ---------- ExtendScript evalScript: serialized via Promise chain ----------
@@ -192,26 +246,109 @@
192
246
  return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
193
247
  }
194
248
 
249
+ function sha256(bytes) {
250
+ return crypto.createHash("sha256").update(bytes).digest("hex");
251
+ }
252
+
195
253
  // Downsampling happens in ExtendScript via the comp's resolutionFactor, so
196
254
  // the file on disk is already the right size by the time we get here.
197
- 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) {
198
262
  // A cold render of a heavy 4K comp can take well over 15s — measured on a
199
263
  // real project. Five seconds silently failed screenshots that were simply
200
264
  // still rendering.
201
265
  return waitForPngFile(file, 120000).then(function () {
202
266
  var buf = fs.readFileSync(file);
203
- var b64 = buf.toString("base64");
204
267
  try { fs.unlinkSync(file); } catch (e) {}
205
- 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
+ }
206
295
  return {
207
- base64: b64,
208
- bytes: buf.length,
209
- width: dims ? dims.width : null,
210
- 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,
211
310
  };
212
311
  });
213
312
  }
214
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
+
215
352
  // ---------- Long job continuation loop ----------
216
353
  function driveJob(jobId, progressToken) {
217
354
  var totalGuess = null;
@@ -234,12 +371,92 @@
234
371
  return step();
235
372
  }
236
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
+
237
413
  // ---------- Op handler with vision/job specialization ----------
238
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
+ }
239
431
  // Vision ops: run JSX, then read PNG and base64-encode on Node side.
240
432
  if (op === "screenshot_frame" || op === "screenshot_layer") {
241
433
  return runOp(op, args).then(function (info) {
242
- 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 });
243
460
  return {
244
461
  // Dimensions come from the PNG itself, not from arithmetic on the
245
462
  // comp size, so they cannot disagree with the image sent.
@@ -252,8 +469,13 @@
252
469
  compId: info.compId,
253
470
  layerId: info.layerId,
254
471
  mimeType: "image/png",
255
- base64: img.base64,
256
- 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,
257
479
  };
258
480
  });
259
481
  });
@@ -322,7 +544,10 @@
322
544
  .catch(function (err) {
323
545
  log("error", body.op + ": " + err.message);
324
546
  res.statusCode = 500; res.setHeader("content-type", "application/json");
325
- 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 }));
326
551
  });
327
552
  return;
328
553
  }