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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,402 @@
1
+ // pngcodec.js — the panel's PNG normaliser.
2
+ //
3
+ // After Effects writes screenshot frames at the project's colour depth, so a
4
+ // 16-bit project produces 16-bit-per-channel PNGs. Plenty of decoders — the ones
5
+ // on the far side of an MCP image content block included — reject those outright
6
+ // with "Could not process image", so a perfectly good render arrives useless.
7
+ // This converts to 8 bits per channel before the panel base64-encodes, and
8
+ // passes anything already 8-bit through byte-for-byte untouched.
9
+ //
10
+ // It also does the two other things that need the pixels rather than the file:
11
+ //
12
+ // * reports a frame whose every pixel is fully transparent, because "this
13
+ // frame is empty" is the useful reading of a ~5KB PNG that decoders choke
14
+ // on, and it usually means the caller is looking at the wrong time or a
15
+ // disabled layer;
16
+ // * hands back the decoded samples so the caller can hash them. Two different
17
+ // screenshot requests that produce byte-identical pixels are how a stale
18
+ // render buffer shows itself (see framecache.js).
19
+ //
20
+ // Cost, since it is paid on every screenshot and not only on 16-bit ones: an
21
+ // already-8-bit frame is still inflated and un-filtered, because that is the
22
+ // only way to know whether it is empty and the only hash that cannot be thrown
23
+ // off by an encoder writing the same picture two ways. That is one pass over
24
+ // width*height*channels bytes — negligible at the downsample the guides ask
25
+ // for, and small next to a render measured in seconds even at full 4K. If it
26
+ // ever stops being negligible, note that an empty frame compresses to a few KB,
27
+ // so a size threshold could skip the decode and fall back to the IDAT hash.
28
+ //
29
+ // A CommonJS module on purpose: `client/main.js` requires it inside CEP's mixed
30
+ // context, and `tests/unit/png-codec.mjs` requires the same file under plain
31
+ // Node. There is no After Effects on a CI runner and this is real image code, so
32
+ // it has to be exercisable without one. Node builtins only — adding a runtime
33
+ // dependency here would mean shipping another directory into the CEP extension,
34
+ // which is exactly the problem `ws` already causes.
35
+
36
+ "use strict";
37
+
38
+ var zlib = require("zlib");
39
+
40
+ var SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
41
+
42
+ // Deflate level: the output is a diagnostic image that has to fit in an agent's
43
+ // context, so size matters — but a 4K frame is 33MB of samples and level 9 costs
44
+ // seconds on top of a render that already took seconds. 6 is within a few
45
+ // percent of 9 here and several times faster.
46
+ var DEFLATE_LEVEL = 6;
47
+
48
+ function channelsFor(colorType) {
49
+ if (colorType === 0) return 1; // greyscale
50
+ if (colorType === 2) return 3; // truecolour
51
+ if (colorType === 3) return 1; // indexed
52
+ if (colorType === 4) return 2; // greyscale + alpha
53
+ if (colorType === 6) return 4; // truecolour + alpha
54
+ return 0;
55
+ }
56
+
57
+ function alphaIndexFor(colorType) {
58
+ if (colorType === 4) return 1;
59
+ if (colorType === 6) return 3;
60
+ return -1;
61
+ }
62
+
63
+ // ---------- CRC-32, as specified in the PNG spec ----------
64
+ var CRC_TABLE = (function () {
65
+ var table = new Int32Array(256);
66
+ for (var n = 0; n < 256; n++) {
67
+ var c = n;
68
+ for (var k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
69
+ table[n] = c;
70
+ }
71
+ return table;
72
+ })();
73
+
74
+ function crc32(bytes) {
75
+ var c = 0xffffffff;
76
+ for (var i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
77
+ return (c ^ 0xffffffff) >>> 0;
78
+ }
79
+
80
+ // ---------- Reading ----------
81
+ function readChunks(buf) {
82
+ if (buf.length < 8) throw new Error("not a PNG: only " + buf.length + " bytes");
83
+ for (var i = 0; i < 8; i++) {
84
+ if (buf[i] !== SIGNATURE[i]) throw new Error("not a PNG: signature mismatch at byte " + i);
85
+ }
86
+ var chunks = [];
87
+ var pos = 8;
88
+ while (pos + 8 <= buf.length) {
89
+ var len = buf.readUInt32BE(pos);
90
+ var type = buf.toString("ascii", pos + 4, pos + 8);
91
+ var dataStart = pos + 8;
92
+ var dataEnd = dataStart + len;
93
+ if (dataEnd + 4 > buf.length) {
94
+ throw new Error("truncated PNG: chunk " + type + " runs past the end of the file");
95
+ }
96
+ chunks.push({ type: type, data: buf.subarray(dataStart, dataEnd) });
97
+ pos = dataEnd + 4;
98
+ if (type === "IEND") return chunks;
99
+ }
100
+ throw new Error("truncated PNG: no IEND chunk");
101
+ }
102
+
103
+ function parseHeader(chunks) {
104
+ if (!chunks.length || chunks[0].type !== "IHDR") {
105
+ throw new Error("malformed PNG: IHDR is not the first chunk");
106
+ }
107
+ var d = chunks[0].data;
108
+ if (d.length !== 13) throw new Error("malformed PNG: IHDR is " + d.length + " bytes, expected 13");
109
+ var h = {
110
+ width: d.readUInt32BE(0),
111
+ height: d.readUInt32BE(4),
112
+ bitDepth: d[8],
113
+ colorType: d[9],
114
+ compression: d[10],
115
+ filter: d[11],
116
+ interlace: d[12],
117
+ };
118
+ if (h.width === 0 || h.height === 0) {
119
+ throw new Error("malformed PNG: " + h.width + "x" + h.height);
120
+ }
121
+ return h;
122
+ }
123
+
124
+ // ---------- Filtering ----------
125
+ function paeth(a, b, c) {
126
+ var p = a + b - c;
127
+ var pa = p > a ? p - a : a - p;
128
+ var pb = p > b ? p - b : b - p;
129
+ var pc = p > c ? p - c : c - p;
130
+ if (pa <= pb && pa <= pc) return a;
131
+ if (pb <= pc) return b;
132
+ return c;
133
+ }
134
+
135
+ // Reverse the per-scanline filters into a flat sample buffer. `raw` is the
136
+ // inflated IDAT stream: one filter-type byte followed by rowBytes of data, per
137
+ // row. Every filter reads back from bytes already reconstructed, so this has to
138
+ // run in order and read out of `out` rather than out of `raw`.
139
+ function unfilter(raw, width, height, bitDepth, channels) {
140
+ var bpp = Math.ceil((bitDepth * channels) / 8);
141
+ var rowBytes = Math.ceil((bitDepth * channels * width) / 8);
142
+ var expected = (rowBytes + 1) * height;
143
+ if (raw.length < expected) {
144
+ throw new Error("PNG pixel data is " + raw.length + " bytes, expected " + expected);
145
+ }
146
+ var out = Buffer.alloc(rowBytes * height);
147
+ var pos = 0;
148
+ for (var y = 0; y < height; y++) {
149
+ var filter = raw[pos];
150
+ pos += 1;
151
+ var o = y * rowBytes;
152
+ var up = o - rowBytes;
153
+ for (var x = 0; x < rowBytes; x++) {
154
+ var cur = raw[pos + x];
155
+ var a = x >= bpp ? out[o + x - bpp] : 0;
156
+ var b = y > 0 ? out[up + x] : 0;
157
+ var c = (x >= bpp && y > 0) ? out[up + x - bpp] : 0;
158
+ var v;
159
+ if (filter === 0) v = cur;
160
+ else if (filter === 1) v = cur + a;
161
+ else if (filter === 2) v = cur + b;
162
+ else if (filter === 3) v = cur + ((a + b) >> 1);
163
+ else if (filter === 4) v = cur + paeth(a, b, c);
164
+ else throw new Error("unknown PNG row filter " + filter + " on row " + y);
165
+ out[o + x] = v & 0xff;
166
+ }
167
+ pos += rowBytes;
168
+ }
169
+ return { data: out, rowBytes: rowBytes, bpp: bpp };
170
+ }
171
+
172
+ // Re-apply a filter on the way out. Paeth on every row rather than picking the
173
+ // cheapest of the five per row: one pass instead of five, and on rendered frames
174
+ // it is within a few percent of the best choice.
175
+ function filterRowsPaeth(data, height, rowBytes, bpp) {
176
+ var out = Buffer.alloc((rowBytes + 1) * height);
177
+ var pos = 0;
178
+ for (var y = 0; y < height; y++) {
179
+ out[pos] = 4;
180
+ pos += 1;
181
+ var o = y * rowBytes;
182
+ var up = o - rowBytes;
183
+ for (var x = 0; x < rowBytes; x++) {
184
+ var a = x >= bpp ? data[o + x - bpp] : 0;
185
+ var b = y > 0 ? data[up + x] : 0;
186
+ var c = (x >= bpp && y > 0) ? data[up + x - bpp] : 0;
187
+ out[pos + x] = (data[o + x] - paeth(a, b, c)) & 0xff;
188
+ }
189
+ pos += rowBytes;
190
+ }
191
+ return out;
192
+ }
193
+
194
+ // ---------- Writing ----------
195
+ function chunk(type, data) {
196
+ var out = Buffer.alloc(data.length + 12);
197
+ out.writeUInt32BE(data.length, 0);
198
+ out.write(type, 4, 4, "ascii");
199
+ data.copy(out, 8);
200
+ out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
201
+ return out;
202
+ }
203
+
204
+ /**
205
+ * Emit an 8-bit PNG from flat samples. Ancillary chunks from the source are
206
+ * deliberately not carried over: the ones that would matter (tRNS, sBIT, bKGD)
207
+ * hold bit-depth-dependent values that would be wrong at 8 bits, and the rest
208
+ * describe colour intent that no decoder of a diagnostic screenshot acts on.
209
+ */
210
+ function encodePng8(width, height, colorType, data8) {
211
+ var channels = channelsFor(colorType);
212
+ if (channels === 0) throw new Error("cannot encode PNG colour type " + colorType);
213
+ var rowBytes = width * channels;
214
+ if (data8.length < rowBytes * height) {
215
+ throw new Error("sample buffer is " + data8.length + " bytes, expected " + rowBytes * height);
216
+ }
217
+ var ihdr = Buffer.alloc(13);
218
+ ihdr.writeUInt32BE(width, 0);
219
+ ihdr.writeUInt32BE(height, 4);
220
+ ihdr[8] = 8; // bit depth
221
+ ihdr[9] = colorType;
222
+ ihdr[10] = 0; // deflate
223
+ ihdr[11] = 0; // adaptive filtering
224
+ ihdr[12] = 0; // no interlace
225
+ var idat = zlib.deflateSync(filterRowsPaeth(data8, height, rowBytes, channels), {
226
+ level: DEFLATE_LEVEL,
227
+ });
228
+ return Buffer.concat([
229
+ Buffer.from(SIGNATURE),
230
+ chunk("IHDR", ihdr),
231
+ chunk("IDAT", idat),
232
+ chunk("IEND", Buffer.alloc(0)),
233
+ ]);
234
+ }
235
+
236
+ // 16-bit samples are big-endian pairs, and the high byte *is* the 8-bit value:
237
+ // promoting 8-bit to 16-bit multiplies by 257 (0x7f -> 0x7f7f), so taking the
238
+ // high byte is exact for anything that started life at 8 bits and off by at most
239
+ // 1/255 for anything that did not. Rounding through /257 would cost a divide per
240
+ // sample and change nothing anybody looking at a screenshot could see.
241
+ function narrow16to8(data16, sampleCount) {
242
+ var out = Buffer.alloc(sampleCount);
243
+ for (var i = 0; i < sampleCount; i++) out[i] = data16[i * 2];
244
+ return out;
245
+ }
246
+
247
+ function everyPixelTransparent(data8, channels, alphaIndex, pixelCount) {
248
+ for (var i = 0; i < pixelCount; i++) {
249
+ if (data8[i * channels + alphaIndex] !== 0) return false;
250
+ }
251
+ return true;
252
+ }
253
+
254
+ /**
255
+ * Normalise one PNG for delivery to an agent.
256
+ *
257
+ * Returns:
258
+ * buffer the PNG to send, or null when `empty` — an empty frame is
259
+ * reported, never shipped, so there is nothing to hand out
260
+ * width/height from IHDR, so they can never disagree with the pixels
261
+ * bitDepth of the *source*, before any conversion
262
+ * converted true when the bytes were re-encoded from 16-bit
263
+ * decoded true when the pixels were actually interpreted
264
+ * empty true when every pixel is fully transparent
265
+ * hashInput bytes to hash for the stale-render check
266
+ * hashBasis "pixels" when hashInput is decoded samples, "idat" when it is
267
+ * the compressed stream (metadata-free either way, so an
268
+ * embedded timestamp can never make two identical frames differ)
269
+ * passthrough why the pixels were not inspected, or null
270
+ *
271
+ * Throws on anything that is not a readable PNG. The caller is expected to treat
272
+ * that as "could not normalise", not as "the screenshot failed" — the render did
273
+ * happen, and shipping it unconverted with a warning beats discarding it.
274
+ */
275
+ function normalizePng(buf) {
276
+ if (!Buffer.isBuffer(buf)) throw new Error("normalizePng expects a Buffer");
277
+ var chunks = readChunks(buf);
278
+ var h = parseHeader(chunks);
279
+
280
+ var idatParts = [];
281
+ for (var i = 0; i < chunks.length; i++) {
282
+ if (chunks[i].type === "IDAT") idatParts.push(chunks[i].data);
283
+ }
284
+ if (!idatParts.length) throw new Error("malformed PNG: no IDAT chunk");
285
+ var idat = Buffer.concat(idatParts);
286
+
287
+ var channels = channelsFor(h.colorType);
288
+ var result = {
289
+ buffer: buf,
290
+ width: h.width,
291
+ height: h.height,
292
+ bitDepth: h.bitDepth,
293
+ colorType: h.colorType,
294
+ converted: false,
295
+ decoded: false,
296
+ empty: false,
297
+ hashInput: idat,
298
+ hashBasis: "idat",
299
+ passthrough: null,
300
+ };
301
+
302
+ if (h.compression !== 0 || h.filter !== 0) {
303
+ throw new Error("unsupported PNG: compression " + h.compression + ", filter method " + h.filter);
304
+ }
305
+ if (channels === 0) throw new Error("unsupported PNG colour type " + h.colorType);
306
+
307
+ // Adam7 interlacing, palettes and sub-byte samples all need machinery that
308
+ // would be written blind for a case saveFrameToPng does not produce. Pass the
309
+ // file through untouched and record why the pixels were not read, rather than
310
+ // guessing at them — the stale check still works off the IDAT bytes.
311
+ if (h.interlace !== 0) {
312
+ result.passthrough = "interlaced";
313
+ return result;
314
+ }
315
+ if (h.colorType === 3) {
316
+ result.passthrough = "indexed colour";
317
+ return result;
318
+ }
319
+ if (h.bitDepth !== 8 && h.bitDepth !== 16) {
320
+ result.passthrough = h.bitDepth + "-bit samples";
321
+ return result;
322
+ }
323
+
324
+ var un = unfilter(zlib.inflateSync(idat), h.width, h.height, h.bitDepth, channels);
325
+ var pixels = h.bitDepth === 16
326
+ ? narrow16to8(un.data, h.width * h.height * channels)
327
+ : un.data;
328
+
329
+ result.decoded = true;
330
+ result.hashInput = pixels;
331
+ result.hashBasis = "pixels";
332
+
333
+ var alphaIndex = alphaIndexFor(h.colorType);
334
+ if (alphaIndex >= 0 && everyPixelTransparent(pixels, channels, alphaIndex, h.width * h.height)) {
335
+ // Nothing to send, so nothing is encoded — and `buffer: null` makes it
336
+ // impossible for a caller to ship the frame by forgetting to check `empty`.
337
+ result.empty = true;
338
+ result.buffer = null;
339
+ return result;
340
+ }
341
+
342
+ if (h.bitDepth === 16) {
343
+ result.buffer = encodePng8(h.width, h.height, h.colorType, pixels);
344
+ result.converted = true;
345
+ }
346
+ return result;
347
+ }
348
+
349
+ /**
350
+ * Decode a PNG to flat 8-bit samples.
351
+ *
352
+ * `normalizePng` deliberately passes the awkward encodings through untouched,
353
+ * because a screenshot it cannot read is still a screenshot the client might.
354
+ * A caller that is going to *resample* the pixels has no such fallback — half a
355
+ * decode is not a smaller picture, it is a wrong one — so this throws on
356
+ * anything it cannot interpret exactly.
357
+ *
358
+ * Returns { width, height, colorType, channels, bitDepth, pixels }, where
359
+ * `pixels` is width*height*channels bytes and `bitDepth` is the source's.
360
+ */
361
+ function decodePng8(buf) {
362
+ if (!Buffer.isBuffer(buf)) throw new Error("decodePng8 expects a Buffer");
363
+ var chunks = readChunks(buf);
364
+ var h = parseHeader(chunks);
365
+ var channels = channelsFor(h.colorType);
366
+
367
+ if (h.compression !== 0 || h.filter !== 0) {
368
+ throw new Error("unsupported PNG: compression " + h.compression + ", filter method " + h.filter);
369
+ }
370
+ if (channels === 0) throw new Error("unsupported PNG colour type " + h.colorType);
371
+ if (h.interlace !== 0) throw new Error("unsupported PNG: interlaced");
372
+ if (h.colorType === 3) throw new Error("unsupported PNG: indexed colour");
373
+ if (h.bitDepth !== 8 && h.bitDepth !== 16) throw new Error("unsupported PNG: " + h.bitDepth + "-bit samples");
374
+
375
+ var idatParts = [];
376
+ for (var i = 0; i < chunks.length; i++) {
377
+ if (chunks[i].type === "IDAT") idatParts.push(chunks[i].data);
378
+ }
379
+ if (!idatParts.length) throw new Error("malformed PNG: no IDAT chunk");
380
+
381
+ var un = unfilter(zlib.inflateSync(Buffer.concat(idatParts)), h.width, h.height, h.bitDepth, channels);
382
+ var pixels = h.bitDepth === 16 ? narrow16to8(un.data, h.width * h.height * channels) : un.data;
383
+
384
+ return {
385
+ width: h.width,
386
+ height: h.height,
387
+ colorType: h.colorType,
388
+ channels: channels,
389
+ bitDepth: h.bitDepth,
390
+ pixels: pixels,
391
+ };
392
+ }
393
+
394
+ module.exports = {
395
+ normalizePng: normalizePng,
396
+ // For mogrt.js, which resamples a rendered frame into a template's thumbnail
397
+ // and needs the same decoder, encoder and CRC rather than a second copy of
398
+ // each. The zip format's CRC-32 is the one PNG uses, bit for bit.
399
+ decodePng8: decodePng8,
400
+ encodePng8: encodePng8,
401
+ crc32: crc32,
402
+ };