@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/README.md +20 -1
- package/bin/server.js +279 -53
- package/package.json +1 -1
- package/panel/CSXS/manifest.xml +2 -2
- package/panel/client/framecache.js +98 -0
- package/panel/client/main.js +240 -15
- package/panel/client/mogrt.js +328 -0
- package/panel/client/pngcodec.js +402 -0
- package/panel/jsx/bundle.jsx +1408 -161
- package/panel/package.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@engine-room/after-effects-mcp",
|
|
3
|
-
"version": "0.
|
|
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",
|
package/panel/CSXS/manifest.xml
CHANGED
|
@@ -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
|
+
<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.
|
|
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 };
|
package/panel/client/main.js
CHANGED
|
@@ -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
|
|
46
|
-
var alt = path.join(
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
}
|