@engine-room/after-effects-mcp 0.2.1 → 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.
- package/README.md +20 -1
- package/bin/server.js +275 -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 +1187 -157
- package/panel/package.json +1 -1
|
@@ -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
|
+
};
|