@engine-room/after-effects-mcp 0.5.1 → 0.6.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 +126 -185
- package/bin/server.js +12567 -2478
- package/package.json +8 -8
- package/panel/CSXS/manifest.xml +44 -4
- package/panel/META-INF/signatures.xml +182 -0
- package/panel/client/csinterface.js +32 -0
- package/panel/client/main.js +522 -632
- package/panel/client/panelhash.js +79 -0
- package/panel/client/status.html +27 -0
- package/panel/client/status.js +112 -0
- package/panel/jsx/bundle.jsx +9032 -2393
- package/panel/mimetype +1 -0
- package/panel/node_modules/ws/LICENSE +20 -0
- package/panel/node_modules/ws/README.md +548 -0
- package/panel/node_modules/ws/browser.js +8 -0
- package/panel/node_modules/ws/index.js +22 -0
- package/panel/node_modules/ws/lib/buffer-util.js +131 -0
- package/panel/node_modules/ws/lib/constants.js +19 -0
- package/panel/node_modules/ws/lib/event-target.js +292 -0
- package/panel/node_modules/ws/lib/extension.js +203 -0
- package/panel/node_modules/ws/lib/limiter.js +55 -0
- package/panel/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/panel/node_modules/ws/lib/receiver.js +743 -0
- package/panel/node_modules/ws/lib/sender.js +607 -0
- package/panel/node_modules/ws/lib/stream.js +161 -0
- package/panel/node_modules/ws/lib/subprotocol.js +62 -0
- package/panel/node_modules/ws/lib/validation.js +152 -0
- package/panel/node_modules/ws/lib/websocket-server.js +562 -0
- package/panel/node_modules/ws/lib/websocket.js +1421 -0
- package/panel/node_modules/ws/package.json +74 -0
- package/panel/node_modules/ws/wrapper.mjs +21 -0
- package/panel/package.json +2 -2
- package/panel/client/contactsheet.js +0 -347
- package/panel/client/framecache.js +0 -98
- package/panel/client/framereader.js +0 -248
- package/panel/client/mogrt.js +0 -328
- package/panel/client/pngcodec.js +0 -524
|
@@ -1,248 +0,0 @@
|
|
|
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
|
-
};
|
package/panel/client/mogrt.js
DELETED
|
@@ -1,328 +0,0 @@
|
|
|
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
|
-
};
|