@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/README.md +7 -5
- package/bin/server.js +1379 -196
- package/package.json +1 -1
- package/panel/CSXS/manifest.xml +2 -2
- package/panel/client/contactsheet.js +347 -0
- package/panel/client/framereader.js +248 -0
- package/panel/client/main.js +400 -95
- package/panel/client/pngcodec.js +122 -0
- package/panel/jsx/bundle.jsx +2444 -116
- 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.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",
|
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.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.
|
|
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
|
+
};
|