@engine-room/after-effects-mcp 0.3.1 → 0.4.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.3.1",
3
+ "version": "0.4.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.3.1"
2
+ <ExtensionManifest Version="11.0" ExtensionBundleId="games.engine-room.ae-mcp" ExtensionBundleVersion="0.4.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.3.1" />
5
+ <Extension Id="games.engine-room.ae-mcp.panel" Version="0.4.0" />
6
6
  </ExtensionList>
7
7
  <ExecutionEnvironment>
8
8
  <HostList>
@@ -0,0 +1,347 @@
1
+ // contactsheet.js — tile several rendered frames into one labelled sheet.
2
+ //
3
+ // Judging motion is one visual question — "does it move the way I think it
4
+ // does" — and answering it used to cost three `screenshot_frame` calls. Three
5
+ // calls is three image blocks resident in the context for the rest of the
6
+ // session, three chances for After Effects to re-serve a stale buffer, and no
7
+ // guarantee the agent lines the frames up in the right order. One sheet is one
8
+ // image, one op, and the order is in the picture. Issue #56.
9
+ //
10
+ // The compositing happens here rather than in ExtendScript for the obvious
11
+ // reason: `packages/jsx` has no pixels, no PNG encoder and no way to draw a
12
+ // character. The panel already decodes and re-encodes frames for the 16-bit
13
+ // conversion, so the sheet is a few hundred lines on top of machinery that
14
+ // exists.
15
+ //
16
+ // Two rules the layout has to keep:
17
+ //
18
+ // * Every requested time gets a cell, in order, whether or not it rendered.
19
+ // A sheet that silently drops the tile that failed no longer maps onto the
20
+ // times that were asked for, and an agent counting tiles left to right
21
+ // would read the wrong frame as the right one.
22
+ // * The time is burned into the picture. Metadata beside an image is not
23
+ // what a model looks at when it is comparing three frames.
24
+ //
25
+ // Node builtins only, and a CommonJS module, for the same reasons as
26
+ // pngcodec.js: `client/main.js` requires it inside CEP's mixed context and
27
+ // `tests/unit/contact-sheet.mjs` requires the same file under plain Node.
28
+
29
+ "use strict";
30
+
31
+ var pngCodec = require("./pngcodec.js");
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Layout
35
+ // ---------------------------------------------------------------------------
36
+
37
+ // Columns per tile count. Not a formula, because the two things a formula gets
38
+ // wrong are the ones that matter: three frames of an animation read as a strip
39
+ // and should stay on one row, and five should not leave a hole in the middle of
40
+ // a 2-wide grid. Two to six is the whole domain — the schema caps `times` there
41
+ // — so the table is complete rather than a heuristic.
42
+ var COLUMNS_FOR = { 2: 2, 3: 3, 4: 2, 5: 3, 6: 3 };
43
+
44
+ function layoutFor(count) {
45
+ var cols = COLUMNS_FOR[count];
46
+ if (!cols) cols = Math.ceil(Math.sqrt(count));
47
+ return { cols: cols, rows: Math.ceil(count / cols) };
48
+ }
49
+
50
+ // The gutter is opaque on purpose. A transparent one would be invisible behind
51
+ // a transparent overlay comp — which is exactly the kind of comp somebody
52
+ // screenshots three times — and the tiles would run together.
53
+ var GUTTER = 4;
54
+ var BACKGROUND = [38, 38, 38, 255];
55
+ var TILE_BORDER = [150, 150, 150, 255];
56
+
57
+ // Flat blocks for the cells that have no picture. Each carries its status word
58
+ // in the label chip as well; the colour alone is a hint, never the statement.
59
+ var STATUS_FILL = {
60
+ empty: [22, 22, 28, 255],
61
+ stale: [74, 30, 30, 255],
62
+ failed: [74, 50, 20, 255],
63
+ };
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // A 5x7 bitmap font, five column bytes per glyph, bit 0 = top row.
67
+ //
68
+ // Burning text into a frame needs a font, and a font file would be a second
69
+ // thing to ship into the CEP extension and keep in sync — the problem `ws`
70
+ // already causes once. Fifty glyphs of pixels weigh nothing and render
71
+ // identically on both platforms, which a system font would not.
72
+ // ---------------------------------------------------------------------------
73
+ var FONT = {
74
+ "0": [0x3e, 0x51, 0x49, 0x45, 0x3e],
75
+ "1": [0x00, 0x42, 0x7f, 0x40, 0x00],
76
+ "2": [0x42, 0x61, 0x51, 0x49, 0x46],
77
+ "3": [0x21, 0x41, 0x45, 0x4b, 0x31],
78
+ "4": [0x18, 0x14, 0x12, 0x7f, 0x10],
79
+ "5": [0x27, 0x45, 0x45, 0x45, 0x39],
80
+ "6": [0x3c, 0x4a, 0x49, 0x49, 0x30],
81
+ "7": [0x01, 0x71, 0x09, 0x05, 0x03],
82
+ "8": [0x36, 0x49, 0x49, 0x49, 0x36],
83
+ "9": [0x06, 0x49, 0x49, 0x29, 0x1e],
84
+ ".": [0x00, 0x60, 0x60, 0x00, 0x00],
85
+ "-": [0x08, 0x08, 0x08, 0x08, 0x08],
86
+ ":": [0x00, 0x36, 0x36, 0x00, 0x00],
87
+ " ": [0x00, 0x00, 0x00, 0x00, 0x00],
88
+ s: [0x48, 0x54, 0x54, 0x54, 0x20],
89
+ A: [0x7e, 0x11, 0x11, 0x11, 0x7e],
90
+ B: [0x7f, 0x49, 0x49, 0x49, 0x36],
91
+ C: [0x3e, 0x41, 0x41, 0x41, 0x22],
92
+ D: [0x7f, 0x41, 0x41, 0x22, 0x1c],
93
+ E: [0x7f, 0x49, 0x49, 0x49, 0x41],
94
+ F: [0x7f, 0x09, 0x09, 0x09, 0x01],
95
+ G: [0x3e, 0x41, 0x49, 0x49, 0x7a],
96
+ H: [0x7f, 0x08, 0x08, 0x08, 0x7f],
97
+ I: [0x00, 0x41, 0x7f, 0x41, 0x00],
98
+ J: [0x20, 0x40, 0x41, 0x3f, 0x01],
99
+ K: [0x7f, 0x08, 0x14, 0x22, 0x41],
100
+ L: [0x7f, 0x40, 0x40, 0x40, 0x40],
101
+ M: [0x7f, 0x02, 0x0c, 0x02, 0x7f],
102
+ N: [0x7f, 0x04, 0x08, 0x10, 0x7f],
103
+ O: [0x3e, 0x41, 0x41, 0x41, 0x3e],
104
+ P: [0x7f, 0x09, 0x09, 0x09, 0x06],
105
+ Q: [0x3e, 0x41, 0x51, 0x21, 0x5e],
106
+ R: [0x7f, 0x09, 0x19, 0x29, 0x46],
107
+ S: [0x46, 0x49, 0x49, 0x49, 0x31],
108
+ T: [0x01, 0x01, 0x7f, 0x01, 0x01],
109
+ U: [0x3f, 0x40, 0x40, 0x40, 0x3f],
110
+ V: [0x1f, 0x20, 0x40, 0x20, 0x1f],
111
+ W: [0x3f, 0x40, 0x38, 0x40, 0x3f],
112
+ X: [0x63, 0x14, 0x08, 0x14, 0x63],
113
+ Y: [0x07, 0x08, 0x70, 0x08, 0x07],
114
+ Z: [0x61, 0x51, 0x49, 0x45, 0x43],
115
+ };
116
+ var GLYPH_W = 5;
117
+ var GLYPH_H = 7;
118
+ var ADVANCE = GLYPH_W + 1;
119
+
120
+ function glyphFor(ch) {
121
+ if (FONT[ch]) return FONT[ch];
122
+ var up = ch.toUpperCase();
123
+ if (FONT[up]) return FONT[up];
124
+ return FONT[" "];
125
+ }
126
+
127
+ /** Width in pixels of `text` at `scale`, with no trailing inter-glyph gap. */
128
+ function textWidth(text, scale) {
129
+ if (!text.length) return 0;
130
+ return (text.length * ADVANCE - 1) * scale;
131
+ }
132
+
133
+ /**
134
+ * Format a time for the label: shortest form that is still unambiguous.
135
+ * 0 -> "0s", 1.5 -> "1.5s", 2.25 -> "2.25s", 0.041666 -> "0.042s".
136
+ *
137
+ * Three decimals because a 30fps frame is 0.033s apart from its neighbour and
138
+ * two would print two adjacent frames identically — the one case where the
139
+ * label would actively mislead.
140
+ */
141
+ function formatTime(t) {
142
+ if (typeof t !== "number" || !isFinite(t)) return "?s";
143
+ var s = t.toFixed(3);
144
+ // Trim the zeros the fixed form adds, but never the digit before the point.
145
+ s = s.replace(/0+$/, "").replace(/\.$/, "");
146
+ return s + "s";
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Drawing
151
+ // ---------------------------------------------------------------------------
152
+
153
+ function setPixel(sheet, sheetW, sheetH, x, y, rgba) {
154
+ if (x < 0 || y < 0 || x >= sheetW || y >= sheetH) return;
155
+ var o = (y * sheetW + x) * 4;
156
+ sheet[o] = rgba[0];
157
+ sheet[o + 1] = rgba[1];
158
+ sheet[o + 2] = rgba[2];
159
+ sheet[o + 3] = rgba[3];
160
+ }
161
+
162
+ function fillRect(sheet, sheetW, sheetH, x, y, w, h, rgba) {
163
+ for (var j = 0; j < h; j++) {
164
+ for (var i = 0; i < w; i++) setPixel(sheet, sheetW, sheetH, x + i, y + j, rgba);
165
+ }
166
+ }
167
+
168
+ function strokeRect(sheet, sheetW, sheetH, x, y, w, h, rgba) {
169
+ for (var i = 0; i < w; i++) {
170
+ setPixel(sheet, sheetW, sheetH, x + i, y, rgba);
171
+ setPixel(sheet, sheetW, sheetH, x + i, y + h - 1, rgba);
172
+ }
173
+ for (var j = 0; j < h; j++) {
174
+ setPixel(sheet, sheetW, sheetH, x, y + j, rgba);
175
+ setPixel(sheet, sheetW, sheetH, x + w - 1, y + j, rgba);
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Draw `text` in an opaque chip whose top-left corner is (x, y).
181
+ *
182
+ * Opaque rather than blended: the chip sits on top of whatever the frame
183
+ * happens to be, and a translucent one over a busy frame is the one case where
184
+ * the label becomes unreadable — which defeats the whole point of burning it in.
185
+ *
186
+ * Returns the chip's { x, y, width, height } so a caller can assert where it
187
+ * landed.
188
+ */
189
+ function drawLabel(sheet, sheetW, sheetH, x, y, text, scale, ink, chip) {
190
+ var pad = 2 * scale;
191
+ var w = textWidth(text, scale) + pad * 2;
192
+ var h = GLYPH_H * scale + pad * 2;
193
+ fillRect(sheet, sheetW, sheetH, x, y, w, h, chip);
194
+ var penX = x + pad;
195
+ for (var c = 0; c < text.length; c++) {
196
+ var glyph = glyphFor(text.charAt(c));
197
+ for (var col = 0; col < GLYPH_W; col++) {
198
+ var bits = glyph[col];
199
+ for (var row = 0; row < GLYPH_H; row++) {
200
+ if (!(bits & (1 << row))) continue;
201
+ fillRect(
202
+ sheet, sheetW, sheetH,
203
+ penX + col * scale, y + pad + row * scale,
204
+ scale, scale, ink
205
+ );
206
+ }
207
+ }
208
+ penX += ADVANCE * scale;
209
+ }
210
+ return { x: x, y: y, width: w, height: h };
211
+ }
212
+
213
+ /**
214
+ * Copy one decoded frame into a cell, converting whatever channel count it
215
+ * arrived with to the sheet's RGBA.
216
+ *
217
+ * Frames larger than the cell are cropped rather than resampled. They should
218
+ * never be — every tile in a sheet is rendered at the same downsample from the
219
+ * same comp — but a crop keeps a surprise visible in the picture, where a
220
+ * silent resample would make a wrong frame look like a right one.
221
+ */
222
+ function blitTile(sheet, sheetW, sheetH, dstX, dstY, cellW, cellH, tile) {
223
+ var ch = tile.channels;
224
+ var w = Math.min(tile.width, cellW);
225
+ var h = Math.min(tile.height, cellH);
226
+ for (var y = 0; y < h; y++) {
227
+ for (var x = 0; x < w; x++) {
228
+ var si = (y * tile.width + x) * ch;
229
+ var r, g, b, a;
230
+ if (ch === 1) { r = g = b = tile.pixels[si]; a = 255; }
231
+ else if (ch === 2) { r = g = b = tile.pixels[si]; a = tile.pixels[si + 1]; }
232
+ else if (ch === 3) { r = tile.pixels[si]; g = tile.pixels[si + 1]; b = tile.pixels[si + 2]; a = 255; }
233
+ else { r = tile.pixels[si]; g = tile.pixels[si + 1]; b = tile.pixels[si + 2]; a = tile.pixels[si + 3]; }
234
+ var o = ((dstY + y) * sheetW + (dstX + x)) * 4;
235
+ sheet[o] = r;
236
+ sheet[o + 1] = g;
237
+ sheet[o + 2] = b;
238
+ sheet[o + 3] = a;
239
+ }
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Compose the sheet.
245
+ *
246
+ * `tiles` is one entry per *requested* time, in order:
247
+ * { time, status, pixels?, width?, height?, channels?, note? }
248
+ * where status is "ok" for a frame that rendered and anything in STATUS_FILL
249
+ * ("empty", "stale", "failed") for one that did not.
250
+ *
251
+ * `opts.cellWidth`/`cellHeight` are what a tile is expected to measure — passed
252
+ * in rather than taken from the first tile, because the first tile is exactly
253
+ * the one that may have failed and have no dimensions at all.
254
+ *
255
+ * Returns { buffer, width, height, cols, rows, cellWidth, cellHeight, tiles },
256
+ * where each entry of `tiles` carries the cell rectangle it was drawn into.
257
+ */
258
+ function composeContactSheet(tiles, opts) {
259
+ if (!tiles || !tiles.length) throw new Error("composeContactSheet needs at least one tile");
260
+ var o = opts || {};
261
+ var cellW = o.cellWidth;
262
+ var cellH = o.cellHeight;
263
+ for (var t = 0; t < tiles.length; t++) {
264
+ if (tiles[t].status === "ok" && tiles[t].width && tiles[t].height) {
265
+ // A rendered frame is the authority on its own size; the expectation is
266
+ // only a fallback for the cells that have no picture.
267
+ if (tiles[t].width > cellW) cellW = tiles[t].width;
268
+ if (tiles[t].height > cellH) cellH = tiles[t].height;
269
+ }
270
+ }
271
+ if (!(cellW > 0) || !(cellH > 0)) throw new Error("composeContactSheet needs a positive cell size");
272
+
273
+ var lay = layoutFor(tiles.length);
274
+ var cols = lay.cols;
275
+ var rows = lay.rows;
276
+ var gutter = o.gutter === undefined ? GUTTER : o.gutter;
277
+ var sheetW = cols * cellW + (cols + 1) * gutter;
278
+ var sheetH = rows * cellH + (rows + 1) * gutter;
279
+
280
+ var sheet = Buffer.alloc(sheetW * sheetH * 4);
281
+ fillRect(sheet, sheetW, sheetH, 0, 0, sheetW, sheetH, BACKGROUND);
282
+
283
+ // One label size for the whole sheet, from the cell width, so every tile is
284
+ // annotated identically and a 4K sheet does not get a 6px caption.
285
+ var scale = o.labelScale;
286
+ if (!scale) scale = Math.max(1, Math.min(4, Math.round(cellW / 300)));
287
+
288
+ var out = [];
289
+ for (var i = 0; i < tiles.length; i++) {
290
+ var tile = tiles[i];
291
+ var col = i % cols;
292
+ var row = Math.floor(i / cols);
293
+ var x = gutter + col * (cellW + gutter);
294
+ var y = gutter + row * (cellH + gutter);
295
+
296
+ if (tile.status === "ok" && tile.pixels) {
297
+ blitTile(sheet, sheetW, sheetH, x, y, cellW, cellH, tile);
298
+ } else {
299
+ var fill = STATUS_FILL[tile.status] || STATUS_FILL.failed;
300
+ fillRect(sheet, sheetW, sheetH, x, y, cellW, cellH, fill);
301
+ }
302
+ strokeRect(sheet, sheetW, sheetH, x, y, cellW, cellH, TILE_BORDER);
303
+
304
+ var text = formatTime(tile.time);
305
+ if (tile.status !== "ok") text += " " + String(tile.status).toUpperCase();
306
+ var chip = drawLabel(
307
+ sheet, sheetW, sheetH,
308
+ x + gutter, y + gutter,
309
+ text, scale,
310
+ [255, 255, 255, 255], [0, 0, 0, 255]
311
+ );
312
+
313
+ out.push({
314
+ time: tile.time,
315
+ status: tile.status,
316
+ x: x,
317
+ y: y,
318
+ width: cellW,
319
+ height: cellH,
320
+ label: text,
321
+ labelRect: chip,
322
+ note: tile.note,
323
+ });
324
+ }
325
+
326
+ return {
327
+ buffer: pngCodec.encodePng8(sheetW, sheetH, 6, sheet),
328
+ width: sheetW,
329
+ height: sheetH,
330
+ cols: cols,
331
+ rows: rows,
332
+ cellWidth: cellW,
333
+ cellHeight: cellH,
334
+ tiles: out,
335
+ };
336
+ }
337
+
338
+ module.exports = {
339
+ composeContactSheet: composeContactSheet,
340
+ // Exported for the tests, which have to be able to state the expected sheet
341
+ // size without re-deriving the layout from the code under test.
342
+ layoutFor: layoutFor,
343
+ formatTime: formatTime,
344
+ textWidth: textWidth,
345
+ GLYPH_H: GLYPH_H,
346
+ GUTTER: GUTTER,
347
+ };
@@ -0,0 +1,248 @@
1
+ // framereader.js — wait for After Effects to finish writing a frame.
2
+ //
3
+ // `saveFrameToPng` is asynchronous: it returns before the bytes are on disk, so
4
+ // the panel has to decide for itself when the file is finished. Getting that
5
+ // decision wrong is issue #45.
6
+ //
7
+ // What it used to be: two `stat` calls 30ms apart reporting the same size. That
8
+ // is not completion, it is a pause — and on a heavy comp (~88 layers, nested
9
+ // precomps) the writer pauses routinely. The half-written file was read, shipped
10
+ // and reported as a successful screenshot, and the agent got `truncated PNG:
11
+ // chunk IDAT runs past the end of the file` for a render that was still
12
+ // happening. Worse, the old passthrough path hashed those truncated bytes into
13
+ // the stale-frame cache, so the next truncation at the same byte count came back
14
+ // as "stale frame" — the wrong diagnosis, with the wrong remedy, for a bug that
15
+ // was never about staleness.
16
+ //
17
+ // What it is now: a PNG is finished when it *is* a PNG — ends in a zero-length
18
+ // IEND chunk with every chunk length from the signature adding up to exactly
19
+ // that. A partial write cannot satisfy that test, so it can no longer be
20
+ // delivered; it can only time out or be reported as corrupt.
21
+ //
22
+ // The two failures are kept apart on purpose, exactly as `BridgeTimeoutError`
23
+ // and `BridgeUnreachableError` are kept apart on the server: "the render did not
24
+ // finish in time" and "the file After Effects wrote is not a whole PNG" have
25
+ // opposite remedies, and one sentence covering both would send half the readers
26
+ // the wrong way.
27
+ //
28
+ // Node builtins only, and a CommonJS module, so `client/main.js` can require it
29
+ // inside CEP's mixed context and `tests/unit/frame-integrity.mjs` can require
30
+ // the same file under plain Node. There is no After Effects on a CI runner and
31
+ // this is the code the bug lived in, so it must be exercisable without one.
32
+
33
+ "use strict";
34
+
35
+ var fs = require("fs");
36
+ var pngCodec = require("./pngcodec.js");
37
+
38
+ // How long a frame that has not arrived is still allowed to be on its way. A
39
+ // cold render of a heavy 4K comp was measured taking well over 15s; the original
40
+ // 5s silently failed screenshots that were merely still rendering.
41
+ var FRAME_RENDER_BUDGET_MS = 120000;
42
+
43
+ // How long a file may sit at exactly the same size, still not a whole PNG,
44
+ // before the write is called abandoned rather than slow.
45
+ //
46
+ // Deliberately far short of the budget above. A corrupt frame has to fail fast
47
+ // enough that one automatic re-render still fits inside the server's 300s
48
+ // ceiling for this op — and a PNG write that has begun is a few hundred KB of
49
+ // deflate output, not something that legitimately stalls for seconds.
50
+ var FRAME_STALL_MS = 6000;
51
+
52
+ var FRAME_POLL_MS = 40;
53
+
54
+ function frameError(code, detail) {
55
+ var e = new Error(detail);
56
+ e.code = code;
57
+ e.detail = detail;
58
+ return e;
59
+ }
60
+
61
+ function unlinkQuietly(file) {
62
+ try { fs.unlinkSync(file); } catch (e) {}
63
+ }
64
+
65
+ /** True when the last 12 bytes are a zero-length IEND — the file ends where a PNG ends. */
66
+ function endsWithIend(file, size) {
67
+ if (size < 12) return false;
68
+ var fd = null;
69
+ try {
70
+ fd = fs.openSync(file, "r");
71
+ var tail = Buffer.alloc(12);
72
+ fs.readSync(fd, tail, 0, 12, size - 12);
73
+ return tail.readUInt32BE(0) === 0 && tail.toString("ascii", 4, 8) === "IEND";
74
+ } catch (e) {
75
+ return false;
76
+ } finally {
77
+ if (fd !== null) { try { fs.closeSync(fd); } catch (e2) {} }
78
+ }
79
+ }
80
+
81
+ function readHead(file, n) {
82
+ var fd = null;
83
+ try {
84
+ fd = fs.openSync(file, "r");
85
+ var head = Buffer.alloc(n);
86
+ var got = fs.readSync(fd, head, 0, n, 0);
87
+ return head.subarray(0, got);
88
+ } catch (e) {
89
+ return null;
90
+ } finally {
91
+ if (fd !== null) { try { fs.closeSync(fd); } catch (e2) {} }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Resolve with the bytes of a complete PNG, or reject saying which failure it
97
+ * was. The temp file is removed on every path — a file left behind is one a
98
+ * later read could find, which is how a corrupt frame would start coming back
99
+ * for free.
100
+ *
101
+ * The tail probe in front of the full read is what keeps this cheap enough to
102
+ * run every 40ms on a multi-megabyte file: IEND is the last chunk in the
103
+ * format, so a file whose final twelve bytes are not one is definitively
104
+ * unfinished and does not need reading at all. The whole chunk walk still runs
105
+ * before any bytes are returned.
106
+ *
107
+ * Rejections:
108
+ * FRAME_INCOMPLETE the file exists, stopped changing, and is not a PNG.
109
+ * Re-rendering can fix it, and this fails within stallMs.
110
+ * RENDER_TIMEOUT it never got there inside the budget. Re-rendering
111
+ * immediately would only spend the budget a second time.
112
+ */
113
+ function waitForCompletePng(file, opts) {
114
+ var o = opts || {};
115
+ var budgetMs = o.budgetMs || FRAME_RENDER_BUDGET_MS;
116
+ var stallMs = o.stallMs || FRAME_STALL_MS;
117
+ var pollMs = o.pollMs || FRAME_POLL_MS;
118
+ var deadline = Date.now() + budgetMs;
119
+ return new Promise(function (resolve, reject) {
120
+ var lastSize = -1;
121
+ var lastGrewAt = Date.now();
122
+ var headChecked = false;
123
+ var reason = "After Effects has not written the file yet";
124
+
125
+ function fail(code, detail) {
126
+ unlinkQuietly(file);
127
+ reject(frameError(code, detail));
128
+ }
129
+
130
+ (function poll() {
131
+ var size = -1;
132
+ try {
133
+ if (fs.existsSync(file)) size = fs.statSync(file).size;
134
+ } catch (e) { size = -1; }
135
+
136
+ if (size >= 0) {
137
+ if (size !== lastSize) {
138
+ lastSize = size;
139
+ lastGrewAt = Date.now();
140
+ }
141
+ // Something that is not a PNG at all will not become one by gaining
142
+ // bytes. Say so now rather than at the end of a two-minute budget.
143
+ if (!headChecked && size >= 8) {
144
+ headChecked = true;
145
+ var head = readHead(file, 8);
146
+ if (head) {
147
+ var early = pngCodec.inspectPngStructure(head);
148
+ if (!early.complete && !early.growable) return fail("FRAME_INCOMPLETE", early.reason);
149
+ }
150
+ }
151
+ if (endsWithIend(file, size)) {
152
+ var buf = null;
153
+ try { buf = fs.readFileSync(file); } catch (e) { buf = null; }
154
+ if (buf) {
155
+ var st = pngCodec.inspectPngStructure(buf);
156
+ if (st.complete) {
157
+ unlinkQuietly(file);
158
+ return resolve(buf);
159
+ }
160
+ if (!st.growable) return fail("FRAME_INCOMPLETE", st.reason);
161
+ reason = st.reason;
162
+ }
163
+ } else if (size > 0) {
164
+ reason = "the file reached " + size + " bytes with no IEND chunk";
165
+ }
166
+ if (size > 0 && Date.now() - lastGrewAt > stallMs) {
167
+ return fail(
168
+ "FRAME_INCOMPLETE",
169
+ "After Effects wrote " + size + " bytes, stopped for " +
170
+ Math.round((Date.now() - lastGrewAt) / 1000) + "s, and " + reason
171
+ );
172
+ }
173
+ }
174
+
175
+ if (Date.now() > deadline) {
176
+ return fail(
177
+ "RENDER_TIMEOUT",
178
+ "nothing complete arrived within " + Math.round(budgetMs / 1000) + "s (" + reason + ")"
179
+ );
180
+ }
181
+ setTimeout(poll, pollMs);
182
+ })();
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Turn a one-sentence reader failure into the message an agent acts on.
188
+ *
189
+ * `attempts` is how many renders were spent, so the advice does not tell
190
+ * somebody to retry something that has already been retried for them.
191
+ *
192
+ * Anything without one of the two codes is returned untouched: an ExtendScript
193
+ * error travelling through here is not this module's to rewrite.
194
+ */
195
+ function dressFrameError(err, attempts) {
196
+ if (!err || (err.code !== "FRAME_INCOMPLETE" && err.code !== "RENDER_TIMEOUT")) return err;
197
+ var tried = attempts > 1 ? " Both the first attempt and one automatic re-render failed the same way." : "";
198
+ var lines;
199
+ if (err.code === "FRAME_INCOMPLETE") {
200
+ lines = [
201
+ "Corrupt frame: " + err.detail + ", so the file is not a whole PNG and nothing was sent." + tried,
202
+ "",
203
+ "This is NOT a timeout. The render stopped writing and what it left behind is",
204
+ "incomplete — a truncated PNG decodes to a wrong picture or to none at all, and",
205
+ "neither is a screenshot. It correlates with how heavy the comp is (issue #45).",
206
+ "",
207
+ "What to do next:",
208
+ "1. Retry at a higher `downsample` (6 or 8). A smaller frame is a smaller write,",
209
+ " and completes where a large one did not.",
210
+ "2. On a heavy assembled comp, screenshot the shot precomps one at a time rather",
211
+ " than the assembly.",
212
+ "3. If it repeats, verify the animation by reading keyframes (get_keyframes /",
213
+ " get_layer_full) instead. That is exact; a picture is not.",
214
+ "",
215
+ "Do NOT disable layers to make the render succeed: this is a limit of the render",
216
+ "path, not a problem with the project.",
217
+ ];
218
+ } else {
219
+ lines = [
220
+ "Render timed out: " + err.detail + "." + tried,
221
+ "",
222
+ "This is NOT a corrupt file and NOT a lost bridge. After Effects was still working",
223
+ "on the frame when the panel gave up waiting, so the render is most likely still",
224
+ "running and nothing has gone wrong with the project.",
225
+ "",
226
+ "What to do next:",
227
+ "1. Wait a few seconds before doing anything else — a re-render started now would",
228
+ " queue behind the one still going.",
229
+ "2. Then retry at a higher `downsample` (6 or 8). Fewer pixels is less render.",
230
+ "3. On a heavy assembled comp, screenshot the shot precomps one at a time.",
231
+ "4. If it repeats, read keyframes (get_keyframes / get_layer_full) instead of",
232
+ " looking at it. That is exact and costs nothing to render.",
233
+ ];
234
+ }
235
+ var out = new Error(lines.join("\n"));
236
+ out.code = err.code;
237
+ out.detail = err.detail;
238
+ return out;
239
+ }
240
+
241
+ module.exports = {
242
+ waitForCompletePng: waitForCompletePng,
243
+ dressFrameError: dressFrameError,
244
+ frameError: frameError,
245
+ unlinkQuietly: unlinkQuietly,
246
+ FRAME_RENDER_BUDGET_MS: FRAME_RENDER_BUDGET_MS,
247
+ FRAME_STALL_MS: FRAME_STALL_MS,
248
+ };