@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.
@@ -0,0 +1,328 @@
1
+ // mogrt.js — replace the still thumbnail inside an exported .mogrt.
2
+ //
3
+ // A .mogrt is a zip holding project.aegraphic, definition.json, thumb.mp4 and
4
+ // thumb.png. After Effects has no scriptable poster time, and the export
5
+ // ignores comp.time, so thumb.png is whatever AE picked — in practice black
6
+ // (issue #23). The frame the user actually wants can be rendered by
7
+ // saveFrameToPng; getting it into the archive is what this file does.
8
+ //
9
+ // Why the panel rather than the MCP server: the panel is already the layer that
10
+ // post-processes files After Effects has just written (see pngcodec.js), and it
11
+ // is on the same machine as AE by construction rather than by convention. It
12
+ // also means export_mogrt stays an ordinary forwarded op with no new branch in
13
+ // the server.
14
+ //
15
+ // Node builtins only, and CommonJS, for the same reasons as pngcodec.js: it has
16
+ // to load inside CEP's mixed context and be requireable by a unit test, because
17
+ // there is no After Effects on a CI runner and this is real archive surgery.
18
+ //
19
+ // The zip work is deliberately narrow. Entries other than the one being
20
+ // replaced are copied across as their original compressed bytes — never
21
+ // re-compressed — so the only entry this code can possibly corrupt is the one
22
+ // it means to rewrite.
23
+
24
+ "use strict";
25
+
26
+ var zlib = require("zlib");
27
+ var pngCodec = require("./pngcodec.js");
28
+
29
+ var THUMB_ENTRY = "thumb.png";
30
+
31
+ var LOCAL_SIG = 0x04034b50;
32
+ var CENTRAL_SIG = 0x02014b50;
33
+ var EOCD_SIG = 0x06054b50;
34
+ var ZIP64_EOCD_LOCATOR_SIG = 0x07064b50;
35
+ var EOCD_MIN_SIZE = 22;
36
+ var ZIP64_SENTINEL = 0xffffffff;
37
+
38
+ // ---------- zip reading ----------
39
+
40
+ function findEocd(buf) {
41
+ // The EOCD is last, but a zip comment can follow it — scan back over the
42
+ // largest comment the format allows plus the record itself.
43
+ var minStart = Math.max(0, buf.length - (0xffff + EOCD_MIN_SIZE));
44
+ for (var i = buf.length - EOCD_MIN_SIZE; i >= minStart; i--) {
45
+ if (buf.readUInt32LE(i) !== EOCD_SIG) continue;
46
+ var commentLength = buf.readUInt16LE(i + 20);
47
+ if (i + EOCD_MIN_SIZE + commentLength === buf.length) return i;
48
+ }
49
+ throw new Error("not a zip: no end-of-central-directory record");
50
+ }
51
+
52
+ /**
53
+ * Parse the central directory. It, not the local headers, is the authoritative
54
+ * record of sizes — a local header is allowed to carry zeros and defer to a
55
+ * data descriptor after the entry, which is exactly the case that silently
56
+ * truncates a naive rewriter.
57
+ */
58
+ function readEntries(buf) {
59
+ var eocd = findEocd(buf);
60
+
61
+ // Zip64 changes the offsets these fields hold. A .mogrt is a handful of MB,
62
+ // so rather than implement it we refuse: corrupting somebody's template is a
63
+ // much worse outcome than declining to touch an archive this was not built for.
64
+ if (eocd >= 20 && buf.readUInt32LE(eocd - 20) === ZIP64_EOCD_LOCATOR_SIG) {
65
+ throw new Error("zip64 archives are not supported");
66
+ }
67
+ var count = buf.readUInt16LE(eocd + 10);
68
+ var cdSize = buf.readUInt32LE(eocd + 12);
69
+ var cdOffset = buf.readUInt32LE(eocd + 16);
70
+ if (cdOffset === ZIP64_SENTINEL || cdSize === ZIP64_SENTINEL || count === 0xffff) {
71
+ throw new Error("zip64 archives are not supported");
72
+ }
73
+ if (cdOffset + cdSize > buf.length) throw new Error("corrupt zip: central directory runs past the end of the file");
74
+
75
+ var entries = [];
76
+ var pos = cdOffset;
77
+ for (var n = 0; n < count; n++) {
78
+ if (buf.readUInt32LE(pos) !== CENTRAL_SIG) throw new Error("corrupt zip: bad central directory signature at entry " + n);
79
+ var nameLength = buf.readUInt16LE(pos + 28);
80
+ var extraLength = buf.readUInt16LE(pos + 30);
81
+ var commentLength = buf.readUInt16LE(pos + 32);
82
+ var localOffset = buf.readUInt32LE(pos + 42);
83
+ if (localOffset === ZIP64_SENTINEL) throw new Error("zip64 archives are not supported");
84
+
85
+ var entry = {
86
+ versionMadeBy: buf.readUInt16LE(pos + 4),
87
+ versionNeeded: buf.readUInt16LE(pos + 6),
88
+ flags: buf.readUInt16LE(pos + 8),
89
+ method: buf.readUInt16LE(pos + 10),
90
+ modTime: buf.readUInt16LE(pos + 12),
91
+ modDate: buf.readUInt16LE(pos + 14),
92
+ crc32: buf.readUInt32LE(pos + 16),
93
+ compressedSize: buf.readUInt32LE(pos + 20),
94
+ uncompressedSize: buf.readUInt32LE(pos + 24),
95
+ internalAttrs: buf.readUInt16LE(pos + 36),
96
+ externalAttrs: buf.readUInt32LE(pos + 38),
97
+ name: buf.toString("utf8", pos + 46, pos + 46 + nameLength),
98
+ localOffset: localOffset,
99
+ };
100
+ if (entry.compressedSize === ZIP64_SENTINEL || entry.uncompressedSize === ZIP64_SENTINEL) {
101
+ throw new Error("zip64 archives are not supported");
102
+ }
103
+
104
+ // The compressed bytes start after the *local* header, whose name and extra
105
+ // lengths can differ from the central directory's.
106
+ if (buf.readUInt32LE(localOffset) !== LOCAL_SIG) throw new Error("corrupt zip: bad local header for " + entry.name);
107
+ var dataStart = localOffset + 30 + buf.readUInt16LE(localOffset + 26) + buf.readUInt16LE(localOffset + 28);
108
+ var dataEnd = dataStart + entry.compressedSize;
109
+ if (dataEnd > buf.length) throw new Error("corrupt zip: " + entry.name + " runs past the end of the file");
110
+ entry.data = buf.subarray(dataStart, dataEnd);
111
+
112
+ entries.push(entry);
113
+ pos += 46 + nameLength + extraLength + commentLength;
114
+ }
115
+ return entries;
116
+ }
117
+
118
+ function inflateEntry(entry) {
119
+ if (entry.method === 0) return Buffer.from(entry.data);
120
+ if (entry.method === 8) return zlib.inflateRawSync(entry.data);
121
+ throw new Error("unsupported zip compression method " + entry.method + " for " + entry.name);
122
+ }
123
+
124
+ // ---------- zip writing ----------
125
+
126
+ /**
127
+ * Rebuild an archive from parsed entries. Extra fields and comments are
128
+ * dropped: in a .mogrt they hold timestamps, and carrying them would mean
129
+ * keeping the local and central copies consistent for no gain. Data descriptors
130
+ * are folded away — every size is known here, so the flag bit that says
131
+ * "look after the entry for them" is cleared.
132
+ */
133
+ function writeZip(entries) {
134
+ var parts = [];
135
+ var central = [];
136
+ var offset = 0;
137
+
138
+ for (var i = 0; i < entries.length; i++) {
139
+ var e = entries[i];
140
+ var nameBuf = Buffer.from(e.name, "utf8");
141
+ var flags = e.flags & ~0x0008; // no data descriptor
142
+
143
+ var local = Buffer.alloc(30);
144
+ local.writeUInt32LE(LOCAL_SIG, 0);
145
+ local.writeUInt16LE(e.versionNeeded, 4);
146
+ local.writeUInt16LE(flags, 6);
147
+ local.writeUInt16LE(e.method, 8);
148
+ local.writeUInt16LE(e.modTime, 10);
149
+ local.writeUInt16LE(e.modDate, 12);
150
+ local.writeUInt32LE(e.crc32, 14);
151
+ local.writeUInt32LE(e.data.length, 18);
152
+ local.writeUInt32LE(e.uncompressedSize, 22);
153
+ local.writeUInt16LE(nameBuf.length, 26);
154
+ local.writeUInt16LE(0, 28);
155
+
156
+ parts.push(local, nameBuf, e.data);
157
+
158
+ var cd = Buffer.alloc(46);
159
+ cd.writeUInt32LE(CENTRAL_SIG, 0);
160
+ cd.writeUInt16LE(e.versionMadeBy, 4);
161
+ cd.writeUInt16LE(e.versionNeeded, 6);
162
+ cd.writeUInt16LE(flags, 8);
163
+ cd.writeUInt16LE(e.method, 10);
164
+ cd.writeUInt16LE(e.modTime, 12);
165
+ cd.writeUInt16LE(e.modDate, 14);
166
+ cd.writeUInt32LE(e.crc32, 16);
167
+ cd.writeUInt32LE(e.data.length, 20);
168
+ cd.writeUInt32LE(e.uncompressedSize, 24);
169
+ cd.writeUInt16LE(nameBuf.length, 28);
170
+ cd.writeUInt16LE(0, 30); // extra
171
+ cd.writeUInt16LE(0, 32); // comment
172
+ cd.writeUInt16LE(0, 34); // disk number
173
+ cd.writeUInt16LE(e.internalAttrs, 36);
174
+ cd.writeUInt32LE(e.externalAttrs, 38);
175
+ cd.writeUInt32LE(offset, 42);
176
+ central.push(cd, nameBuf);
177
+
178
+ offset += local.length + nameBuf.length + e.data.length;
179
+ }
180
+
181
+ var cdBuf = Buffer.concat(central);
182
+ var eocd = Buffer.alloc(EOCD_MIN_SIZE);
183
+ eocd.writeUInt32LE(EOCD_SIG, 0);
184
+ eocd.writeUInt16LE(0, 4);
185
+ eocd.writeUInt16LE(0, 6);
186
+ eocd.writeUInt16LE(entries.length, 8);
187
+ eocd.writeUInt16LE(entries.length, 10);
188
+ eocd.writeUInt32LE(cdBuf.length, 12);
189
+ eocd.writeUInt32LE(offset, 16);
190
+ eocd.writeUInt16LE(0, 20);
191
+
192
+ return Buffer.concat(parts.concat([cdBuf, eocd]));
193
+ }
194
+
195
+ // ---------- resampling ----------
196
+
197
+ /**
198
+ * Box-filter downscale, then centre the result inside `outW`x`outH` without
199
+ * changing its aspect ratio. Padding is fully transparent where the image has
200
+ * an alpha channel and black where it does not.
201
+ *
202
+ * A box filter rather than bilinear because this is always a reduction, often
203
+ * by 3x or more, and averaging every source pixel that lands in a destination
204
+ * pixel is both the correct answer for that and cheaper than sampling.
205
+ */
206
+ function resampleFit(src, srcW, srcH, channels, outW, outH) {
207
+ var scale = Math.min(outW / srcW, outH / srcH);
208
+ var drawW = Math.max(1, Math.min(outW, Math.round(srcW * scale)));
209
+ var drawH = Math.max(1, Math.min(outH, Math.round(srcH * scale)));
210
+ var offsetX = Math.floor((outW - drawW) / 2);
211
+ var offsetY = Math.floor((outH - drawH) / 2);
212
+
213
+ var out = Buffer.alloc(outW * outH * channels, 0);
214
+ var acc = new Float64Array(channels);
215
+
216
+ for (var dy = 0; dy < drawH; dy++) {
217
+ var sy0 = Math.floor((dy * srcH) / drawH);
218
+ var sy1 = Math.max(sy0 + 1, Math.floor(((dy + 1) * srcH) / drawH));
219
+ if (sy1 > srcH) sy1 = srcH;
220
+ for (var dx = 0; dx < drawW; dx++) {
221
+ var sx0 = Math.floor((dx * srcW) / drawW);
222
+ var sx1 = Math.max(sx0 + 1, Math.floor(((dx + 1) * srcW) / drawW));
223
+ if (sx1 > srcW) sx1 = srcW;
224
+
225
+ for (var c = 0; c < channels; c++) acc[c] = 0;
226
+ var n = 0;
227
+ for (var sy = sy0; sy < sy1; sy++) {
228
+ var rowBase = sy * srcW * channels;
229
+ for (var sx = sx0; sx < sx1; sx++) {
230
+ var base = rowBase + sx * channels;
231
+ for (var ci = 0; ci < channels; ci++) acc[ci] += src[base + ci];
232
+ n++;
233
+ }
234
+ }
235
+ var dst = ((dy + offsetY) * outW + (dx + offsetX)) * channels;
236
+ for (var co = 0; co < channels; co++) out[dst + co] = Math.round(acc[co] / n);
237
+ }
238
+ }
239
+ return { data: out, drawWidth: drawW, drawHeight: drawH, offsetX: offsetX, offsetY: offsetY };
240
+ }
241
+
242
+ // ---------- the operation ----------
243
+
244
+ /**
245
+ * Replace thumb.png inside a .mogrt with `posterPng`, resized to the exact
246
+ * dimensions of the thumbnail AE already wrote.
247
+ *
248
+ * Matching AE's dimensions rather than hardcoding 640x360 is the point: that is
249
+ * what a 16:9 comp produced here, but nothing documents the rule for other
250
+ * shapes, and reading it costs one IHDR.
251
+ *
252
+ * Returns a description of what changed. Throws if the archive has no
253
+ * thumb.png, or is not a zip this code is prepared to rewrite — the caller
254
+ * treats that as "the export succeeded, the thumbnail did not", never as a
255
+ * failed export.
256
+ */
257
+ function patchThumbnail(mogrtBuf, posterPng) {
258
+ var entries = readEntries(mogrtBuf);
259
+
260
+ var target = null;
261
+ for (var i = 0; i < entries.length; i++) {
262
+ if (entries[i].name === THUMB_ENTRY) { target = entries[i]; break; }
263
+ }
264
+ if (!target) {
265
+ var names = [];
266
+ for (var j = 0; j < entries.length; j++) names.push(entries[j].name);
267
+ throw new Error("no " + THUMB_ENTRY + " in the template (entries: " + names.join(", ") + ")");
268
+ }
269
+
270
+ var existing = pngCodec.decodePng8(inflateEntry(target));
271
+ var poster = pngCodec.decodePng8(posterPng);
272
+
273
+ // Encode at the thumbnail's own colour type so an archive whose thumbnail has
274
+ // no alpha does not gain one, and vice versa.
275
+ var channels = existing.channels;
276
+ var source = poster.channels === channels
277
+ ? poster.pixels
278
+ : convertChannels(poster.pixels, poster.channels, channels, poster.width * poster.height);
279
+
280
+ var fitted = resampleFit(source, poster.width, poster.height, channels, existing.width, existing.height);
281
+ var encoded = pngCodec.encodePng8(existing.width, existing.height, existing.colorType, fitted.data);
282
+
283
+ target.data = zlib.deflateRawSync(encoded, { level: 6 });
284
+ target.method = 8;
285
+ target.crc32 = pngCodec.crc32(encoded);
286
+ target.uncompressedSize = encoded.length;
287
+
288
+ return {
289
+ buffer: writeZip(entries),
290
+ width: existing.width,
291
+ height: existing.height,
292
+ sourceWidth: poster.width,
293
+ sourceHeight: poster.height,
294
+ // Present when the poster's aspect ratio did not match the thumbnail's, so
295
+ // a caller can say why there are bars rather than leaving them a surprise.
296
+ letterboxed: fitted.drawWidth !== existing.width || fitted.drawHeight !== existing.height,
297
+ bytes: encoded.length,
298
+ };
299
+ }
300
+
301
+ /** Grow or shrink a pixel buffer between grey/greyA/RGB/RGBA. */
302
+ function convertChannels(src, from, to, pixelCount) {
303
+ var out = Buffer.alloc(pixelCount * to);
304
+ for (var p = 0; p < pixelCount; p++) {
305
+ var s = p * from;
306
+ var d = p * to;
307
+ var r, g, b, a;
308
+ if (from === 1) { r = g = b = src[s]; a = 255; }
309
+ else if (from === 2) { r = g = b = src[s]; a = src[s + 1]; }
310
+ else if (from === 3) { r = src[s]; g = src[s + 1]; b = src[s + 2]; a = 255; }
311
+ else { r = src[s]; g = src[s + 1]; b = src[s + 2]; a = src[s + 3]; }
312
+
313
+ if (to === 1) { out[d] = Math.round((r * 299 + g * 587 + b * 114) / 1000); }
314
+ else if (to === 2) { out[d] = Math.round((r * 299 + g * 587 + b * 114) / 1000); out[d + 1] = a; }
315
+ else if (to === 3) { out[d] = r; out[d + 1] = g; out[d + 2] = b; }
316
+ else { out[d] = r; out[d + 1] = g; out[d + 2] = b; out[d + 3] = a; }
317
+ }
318
+ return out;
319
+ }
320
+
321
+ module.exports = {
322
+ patchThumbnail: patchThumbnail,
323
+ // Exported for the unit test, which needs to build and inspect archives.
324
+ readEntries: readEntries,
325
+ writeZip: writeZip,
326
+ inflateEntry: inflateEntry,
327
+ resampleFit: resampleFit,
328
+ };