@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/panel/client/main.js
CHANGED
|
@@ -100,13 +100,15 @@
|
|
|
100
100
|
}
|
|
101
101
|
throw new Error("could not load " + name + " from any of: " + failures.join("; "));
|
|
102
102
|
}
|
|
103
|
-
var pngCodec, frameCacheModule, mogrtModule;
|
|
103
|
+
var pngCodec, frameCacheModule, mogrtModule, contactSheetModule, frameReaderModule;
|
|
104
104
|
try {
|
|
105
105
|
pngCodec = requireSibling("pngcodec.js");
|
|
106
106
|
frameCacheModule = requireSibling("framecache.js");
|
|
107
107
|
mogrtModule = requireSibling("mogrt.js");
|
|
108
|
+
contactSheetModule = requireSibling("contactsheet.js");
|
|
109
|
+
frameReaderModule = requireSibling("framereader.js");
|
|
108
110
|
} catch (e) {
|
|
109
|
-
setStatus("cannot start —
|
|
111
|
+
setStatus("cannot start — one of the panel's file-processing modules is missing", "err");
|
|
110
112
|
log("error", "loading the panel's file-processing modules from " + __dirname + " failed: " + e.message);
|
|
111
113
|
log("error", "Quit After Effects, run the setup_panel tool, then reopen it.");
|
|
112
114
|
return;
|
|
@@ -148,6 +150,20 @@
|
|
|
148
150
|
var err = new Error(msg);
|
|
149
151
|
err.aeStack = parsed.stack;
|
|
150
152
|
err.aeLine = parsed.line;
|
|
153
|
+
// Where the failure sits in the caller's *own* source, when the handler
|
|
154
|
+
// could work it out — run_jsx maps AE's line number back onto the
|
|
155
|
+
// script that was submitted (issue #46). Forwarded field by field on
|
|
156
|
+
// purpose: the server prints these, and relaying a free-form bag would
|
|
157
|
+
// let the two drift apart with nothing to notice.
|
|
158
|
+
if (parsed.sourceLine !== undefined || parsed.rawLine !== undefined) {
|
|
159
|
+
err.aeSource = {
|
|
160
|
+
sourceLine: parsed.sourceLine,
|
|
161
|
+
sourceText: parsed.sourceText,
|
|
162
|
+
sourceName: parsed.sourceName,
|
|
163
|
+
rawLine: parsed.rawLine,
|
|
164
|
+
lineCount: parsed.lineCount
|
|
165
|
+
};
|
|
166
|
+
}
|
|
151
167
|
throw err;
|
|
152
168
|
}
|
|
153
169
|
return parsed.result;
|
|
@@ -212,39 +228,16 @@
|
|
|
212
228
|
}
|
|
213
229
|
|
|
214
230
|
// ---------- Vision: read PNG and base64-encode (for screenshot_* ops) ----------
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
try {
|
|
226
|
-
var stat2 = fs.statSync(file);
|
|
227
|
-
if (stat2.size === stat.size) return resolve(stat2.size);
|
|
228
|
-
} catch (e) {}
|
|
229
|
-
if (Date.now() > deadline) return reject(new Error("PNG file write timed out"));
|
|
230
|
-
poll();
|
|
231
|
-
}, 30);
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
} catch (e) {}
|
|
236
|
-
if (Date.now() > deadline) return reject(new Error("PNG file did not appear: " + file));
|
|
237
|
-
setTimeout(poll, 40);
|
|
238
|
-
})();
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
// Read the true pixel dimensions out of the PNG's IHDR chunk rather than
|
|
242
|
-
// computing them, so what we report is always what the client received.
|
|
243
|
-
function pngDimensions(buf) {
|
|
244
|
-
if (buf.length < 24) return null;
|
|
245
|
-
if (buf.readUInt32BE(12) !== 0x49484452) return null; // "IHDR"
|
|
246
|
-
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
247
|
-
}
|
|
231
|
+
// Deciding when After Effects has finished writing a frame is issue #45, and
|
|
232
|
+
// it lives in framereader.js so a unit test can drive it with a file it grows
|
|
233
|
+
// by hand. What matters here: `waitForCompletePng` resolves only with bytes
|
|
234
|
+
// that parse end-to-end as a PNG, deletes the temp file on every path, and
|
|
235
|
+
// rejects with FRAME_INCOMPLETE or RENDER_TIMEOUT — never with one message
|
|
236
|
+
// covering both.
|
|
237
|
+
var waitForCompletePng = frameReaderModule.waitForCompletePng;
|
|
238
|
+
var dressFrameError = frameReaderModule.dressFrameError;
|
|
239
|
+
var frameError = frameReaderModule.frameError;
|
|
240
|
+
var unlinkQuietly = frameReaderModule.unlinkQuietly;
|
|
248
241
|
|
|
249
242
|
function sha256(bytes) {
|
|
250
243
|
return crypto.createHash("sha256").update(bytes).digest("hex");
|
|
@@ -259,19 +252,25 @@
|
|
|
259
252
|
// and hands back the decoded samples so a frame can be recognised if After
|
|
260
253
|
// Effects serves the same buffer again for a different request.
|
|
261
254
|
function readPngFrame(file) {
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
return
|
|
266
|
-
var buf = fs.readFileSync(file);
|
|
267
|
-
try { fs.unlinkSync(file); } catch (e) {}
|
|
255
|
+
// waitForCompletePng owns the temp file and removes it on every path,
|
|
256
|
+
// success or failure. A file left behind is one a later read could find,
|
|
257
|
+
// which is how a corrupt frame would start coming back for free.
|
|
258
|
+
return waitForCompletePng(file).then(function (buf) {
|
|
268
259
|
var norm;
|
|
269
260
|
try {
|
|
270
261
|
norm = pngCodec.normalizePng(buf);
|
|
271
262
|
} catch (e) {
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
263
|
+
// The completeness gate above guarantees this file ends in a well-formed
|
|
264
|
+
// IEND, so a truncation error here would mean the gate is broken. Refuse
|
|
265
|
+
// rather than ship: "never report success for work that didn't happen"
|
|
266
|
+
// outranks the fallback below.
|
|
267
|
+
if (/truncated PNG/.test(e.message)) {
|
|
268
|
+
throw frameError("FRAME_INCOMPLETE", "the file passed the completeness check and then failed to parse (" + e.message + ")");
|
|
269
|
+
}
|
|
270
|
+
// A complete PNG this parser cannot read is still a PNG the client
|
|
271
|
+
// might. Ship it unchanged rather than throwing away a render that did
|
|
272
|
+
// happen — but say so, and fall back to hashing the file so the stale
|
|
273
|
+
// check survives.
|
|
275
274
|
log("warn", "could not normalise the frame (" + e.message + "); sending it unconverted");
|
|
276
275
|
var dims = pngDimensions(buf);
|
|
277
276
|
return {
|
|
@@ -299,6 +298,11 @@
|
|
|
299
298
|
empty: false,
|
|
300
299
|
converted: norm.converted,
|
|
301
300
|
sourceBitDepth: norm.bitDepth,
|
|
301
|
+
// Decoded samples, kept only so the contact sheet can composite them
|
|
302
|
+
// without decoding the same PNG a second time. Null on the passthrough
|
|
303
|
+
// path, where the pixels were never interpreted.
|
|
304
|
+
pixels: norm.decoded ? norm.hashInput : null,
|
|
305
|
+
channels: norm.channels,
|
|
302
306
|
hash: sha256(norm.hashInput),
|
|
303
307
|
// The pixels could not be read, so "empty" was never evaluated and the
|
|
304
308
|
// hash is of the compressed stream. Both are still better than nothing;
|
|
@@ -311,6 +315,55 @@
|
|
|
311
315
|
});
|
|
312
316
|
}
|
|
313
317
|
|
|
318
|
+
// Read the true pixel dimensions out of the PNG's IHDR chunk rather than
|
|
319
|
+
// computing them, so what we report is always what the client received.
|
|
320
|
+
function pngDimensions(buf) {
|
|
321
|
+
if (buf.length < 24) return null;
|
|
322
|
+
if (buf.readUInt32BE(12) !== 0x49484452) return null; // "IHDR"
|
|
323
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---------- Rendering one frame, with one bounded retry ----------
|
|
327
|
+
function renderFrameOnce(op, args) {
|
|
328
|
+
return runOp(op, args).then(function (info) {
|
|
329
|
+
return readPngFrame(info.path).then(function (img) {
|
|
330
|
+
return { info: info, img: img };
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Render a frame, and re-render exactly once if what came back was corrupt.
|
|
337
|
+
*
|
|
338
|
+
* Safe to repeat, which is the only reason this is here. Both screenshot ops
|
|
339
|
+
* are read-only: `screenshot_frame` renders to a temp file, `screenshot_layer`
|
|
340
|
+
* additionally solos a layer and restores every solo state in a `finally`.
|
|
341
|
+
* Neither leaves anything in the project, so a second run is
|
|
342
|
+
* indistinguishable from one run — this is not `run_jsx`, where re-running a
|
|
343
|
+
* script that already had its side effects duplicates them (issue #43).
|
|
344
|
+
*
|
|
345
|
+
* Only on FRAME_INCOMPLETE, never on RENDER_TIMEOUT. A timeout has already
|
|
346
|
+
* spent the 120s budget; a second one would take the op past the server's
|
|
347
|
+
* 300s ceiling and turn a precise "the render did not finish" into a bridge
|
|
348
|
+
* timeout, whose remedy ("do not restart anything, wait") is a different
|
|
349
|
+
* remedy for a different problem. A corrupt read, by contrast, fails within
|
|
350
|
+
* FRAME_STALL_MS, and the evidence in #45 is that a retry sometimes succeeds
|
|
351
|
+
* — a light comp returned one truncated frame and then rendered.
|
|
352
|
+
*
|
|
353
|
+
* The temp file of a failed attempt is deleted before the retry (in
|
|
354
|
+
* `waitForCompletePng`), and ExtendScript names a fresh one per call, so a
|
|
355
|
+
* retry can never be handed the previous attempt's bytes.
|
|
356
|
+
*/
|
|
357
|
+
function renderFrameWithRetry(op, args) {
|
|
358
|
+
return renderFrameOnce(op, args).catch(function (first) {
|
|
359
|
+
if (first.code !== "FRAME_INCOMPLETE") throw dressFrameError(first, 1);
|
|
360
|
+
log("warn", op + ": " + first.detail + " — re-rendering once");
|
|
361
|
+
return renderFrameOnce(op, args).catch(function (second) {
|
|
362
|
+
throw dressFrameError(second, 2);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
314
367
|
// ---------- Stale render detection ----------
|
|
315
368
|
// The request identity a render is expected to be unique to. Built from what
|
|
316
369
|
// ExtendScript resolved, not from the arguments, so a defaulted time or
|
|
@@ -350,10 +403,17 @@
|
|
|
350
403
|
}
|
|
351
404
|
|
|
352
405
|
// ---------- Long job continuation loop ----------
|
|
353
|
-
|
|
406
|
+
// Each turn of this loop is one evalScript, and each one now opens and closes
|
|
407
|
+
// its own undo group inside itself — a group cannot span two calls, because
|
|
408
|
+
// After Effects discards one that does (issue #69). So the chunk size is not
|
|
409
|
+
// only a pacing knob any more: it decides how many undo steps a long batch
|
|
410
|
+
// costs the user. It comes off the job envelope so the JSX side owns the
|
|
411
|
+
// number and the two cannot drift.
|
|
412
|
+
function driveJob(jobId, progressToken, chunkSize) {
|
|
354
413
|
var totalGuess = null;
|
|
414
|
+
var size = chunkSize || 25;
|
|
355
415
|
function step() {
|
|
356
|
-
return runOp("_continue_job", { jobId: jobId, chunkSize:
|
|
416
|
+
return runOp("_continue_job", { jobId: jobId, chunkSize: size }).then(function (res) {
|
|
357
417
|
if (res.total) totalGuess = res.total;
|
|
358
418
|
if (!res.done) {
|
|
359
419
|
broadcast({ type: "progress", jobId: jobId, progress: res.progress, total: res.total, message: "running" });
|
|
@@ -361,11 +421,37 @@
|
|
|
361
421
|
return new Promise(function (r) { setTimeout(r, 0); }).then(step);
|
|
362
422
|
}
|
|
363
423
|
if (res.failed) {
|
|
364
|
-
|
|
365
|
-
|
|
424
|
+
// A failed job reaches the server as an error string and nothing else,
|
|
425
|
+
// so what the batch cost has to travel inside it or it is lost: the
|
|
426
|
+
// ops before the failure are applied and stay applied.
|
|
427
|
+
var failedMsg = (res.error || "batch failed") + (res.note ? " || " + res.note : "");
|
|
428
|
+
broadcast({ type: "error", jobId: jobId, error: failedMsg });
|
|
429
|
+
return { done: true, failed: true, jobId: jobId, error: res.error, results: res.results, errors: res.errors, atIndex: res.atIndex, undoSteps: res.undoSteps, note: res.note };
|
|
366
430
|
}
|
|
367
|
-
|
|
368
|
-
|
|
431
|
+
// `undoSteps` is the measured number of undo groups the batch opened,
|
|
432
|
+
// and `diff` is the batch's own before/after — both are only ever seen
|
|
433
|
+
// by the agent if they are carried on this event.
|
|
434
|
+
broadcast({
|
|
435
|
+
type: "complete",
|
|
436
|
+
jobId: jobId,
|
|
437
|
+
result: {
|
|
438
|
+
results: res.results,
|
|
439
|
+
errors: res.errors,
|
|
440
|
+
total: res.total || totalGuess,
|
|
441
|
+
cancelled: !!res.cancelled,
|
|
442
|
+
undoSteps: res.undoSteps,
|
|
443
|
+
undoGroupName: res.undoGroupName,
|
|
444
|
+
note: res.note,
|
|
445
|
+
diff: res.diff,
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
return {
|
|
449
|
+
done: true, jobId: jobId,
|
|
450
|
+
results: res.results, errors: res.errors,
|
|
451
|
+
total: res.total || totalGuess, cancelled: !!res.cancelled,
|
|
452
|
+
undoSteps: res.undoSteps, undoGroupName: res.undoGroupName,
|
|
453
|
+
note: res.note, diff: res.diff,
|
|
454
|
+
};
|
|
369
455
|
});
|
|
370
456
|
}
|
|
371
457
|
return step();
|
|
@@ -380,9 +466,12 @@
|
|
|
380
466
|
// successful export because its thumbnail could not be improved would be the
|
|
381
467
|
// worse trade. The result says plainly that the thumbnail is still AE's.
|
|
382
468
|
function patchMogrtThumbnail(info) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
469
|
+
// The same completeness gate the screenshot path uses. The poster frame is
|
|
470
|
+
// written by the same asynchronous `saveFrameToPng`, so a half-written one
|
|
471
|
+
// is just as possible here — and it would be resampled into the template's
|
|
472
|
+
// thumbnail rather than merely displayed.
|
|
473
|
+
// It takes the poster file with it, on success and on failure alike.
|
|
474
|
+
return waitForCompletePng(info.posterPngPath).then(function (poster) {
|
|
386
475
|
var patched = mogrtModule.patchThumbnail(fs.readFileSync(info.path), poster);
|
|
387
476
|
// Write via a sibling temp file and rename, so an interrupted write
|
|
388
477
|
// cannot leave a half-rewritten template where a valid one used to be.
|
|
@@ -399,7 +488,7 @@
|
|
|
399
488
|
letterboxed: patched.letterboxed || undefined,
|
|
400
489
|
};
|
|
401
490
|
}).catch(function (e) {
|
|
402
|
-
|
|
491
|
+
unlinkQuietly(info.posterPngPath);
|
|
403
492
|
log("warn", "could not patch the .mogrt thumbnail: " + e.message);
|
|
404
493
|
return {
|
|
405
494
|
patched: false,
|
|
@@ -410,6 +499,203 @@
|
|
|
410
499
|
});
|
|
411
500
|
}
|
|
412
501
|
|
|
502
|
+
// ---------- Contact sheet ----------
|
|
503
|
+
//
|
|
504
|
+
// ExtendScript renders every requested time and hands back one temp path per
|
|
505
|
+
// tile; everything from here on is pixels, which `packages/jsx` has no way to
|
|
506
|
+
// touch. One tile that fails is drawn as a marked block rather than dropped —
|
|
507
|
+
// the sheet has to keep mapping onto the times that were asked for, or an
|
|
508
|
+
// agent counting frames left to right reads the wrong one as the right one.
|
|
509
|
+
|
|
510
|
+
/** One line, for a note inside a tile rather than a whole error message. */
|
|
511
|
+
function shortReason(err) {
|
|
512
|
+
var s = err && err.detail ? err.detail : (err && err.message ? err.message : String(err));
|
|
513
|
+
return String(s).split("\n")[0];
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function readSheetTile(info, tile) {
|
|
517
|
+
if (tile.error) {
|
|
518
|
+
return Promise.resolve({ time: tile.time, status: "failed", note: tile.error });
|
|
519
|
+
}
|
|
520
|
+
return readPngFrame(tile.path).then(function (img) {
|
|
521
|
+
return { time: tile.time, status: "ok", img: img };
|
|
522
|
+
}, function (err) {
|
|
523
|
+
if (err.code !== "FRAME_INCOMPLETE") {
|
|
524
|
+
return { time: tile.time, status: "failed", note: shortReason(err) };
|
|
525
|
+
}
|
|
526
|
+
// The same bounded retry the single-frame path gets, and safe for the same
|
|
527
|
+
// reason — but re-rendering only this one time. The already-derived
|
|
528
|
+
// downsample is passed explicitly so the per-tile factor cannot drift from
|
|
529
|
+
// the rest of the sheet.
|
|
530
|
+
log("warn", "contact sheet tile at " + tile.time + "s: " + shortReason(err) + " — re-rendering once");
|
|
531
|
+
return runOp("screenshot_frame", {
|
|
532
|
+
compId: info.compId,
|
|
533
|
+
time: tile.time,
|
|
534
|
+
downsample: info.downsample,
|
|
535
|
+
}).then(function (retry) {
|
|
536
|
+
return readPngFrame(retry.path).then(function (img) {
|
|
537
|
+
return { time: tile.time, status: "ok", img: img };
|
|
538
|
+
});
|
|
539
|
+
}).catch(function (again) {
|
|
540
|
+
return { time: tile.time, status: "failed", note: shortReason(again) };
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function finishContactSheet(info, read) {
|
|
546
|
+
var i;
|
|
547
|
+
var ds = info.downsample;
|
|
548
|
+
// Pass one: classify. Every `match` is computed before any `remember`, so a
|
|
549
|
+
// sheet's own tiles cannot collide with each other through the cache.
|
|
550
|
+
var seen = {};
|
|
551
|
+
for (i = 0; i < read.length; i++) {
|
|
552
|
+
var t = read[i];
|
|
553
|
+
if (t.status !== "ok") continue;
|
|
554
|
+
if (t.img.empty) { t.status = "empty"; t.img = null; continue; }
|
|
555
|
+
var ident = { compId: info.compId, layerId: null, time: t.time, downsample: ds };
|
|
556
|
+
t.key = frameKey("screenshot_frame", ident);
|
|
557
|
+
var match = frameCache.match(t.key, t.img.hash);
|
|
558
|
+
if (match) {
|
|
559
|
+
// Pixels identical to an *earlier, different* request are the #29 stale
|
|
560
|
+
// buffer, and a tile of them is not a picture of this time. Mark the
|
|
561
|
+
// block rather than showing it: an agent that can see a frame believes
|
|
562
|
+
// the frame.
|
|
563
|
+
t.status = "stale";
|
|
564
|
+
t.note = "identical to " + match.label + ", rendered " +
|
|
565
|
+
Math.max(1, Math.round(match.ageMs / 1000)) + "s earlier";
|
|
566
|
+
t.img = null;
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
// Two tiles of one sheet matching each other is what a static comp looks
|
|
570
|
+
// like — the caller asked for several times on purpose, and refusing the
|
|
571
|
+
// second would be a false alarm on a correct answer. Flagged, not refused.
|
|
572
|
+
if (seen[t.img.hash] !== undefined) {
|
|
573
|
+
t.note = "pixel-identical to the " + contactSheetModule.formatTime(seen[t.img.hash]) +
|
|
574
|
+
" tile — nothing changed between them";
|
|
575
|
+
} else {
|
|
576
|
+
seen[t.img.hash] = t.time;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
var specs = [];
|
|
581
|
+
var ok = 0;
|
|
582
|
+
for (i = 0; i < read.length; i++) {
|
|
583
|
+
var r = read[i];
|
|
584
|
+
var spec = { time: r.time, status: r.status, note: r.note };
|
|
585
|
+
if (r.status === "ok") {
|
|
586
|
+
var px = r.img.pixels;
|
|
587
|
+
var channels = r.img.channels;
|
|
588
|
+
if (!px) {
|
|
589
|
+
// The passthrough path never interpreted the pixels. A sheet needs
|
|
590
|
+
// them, so decode strictly here; a frame that cannot be decoded
|
|
591
|
+
// becomes a marked block rather than a wrong one.
|
|
592
|
+
try {
|
|
593
|
+
var dec = pngCodec.decodePng8(r.img.buffer);
|
|
594
|
+
px = dec.pixels;
|
|
595
|
+
channels = dec.channels;
|
|
596
|
+
} catch (e) {
|
|
597
|
+
// Both records move together: `spec` is what gets drawn, `r` is what
|
|
598
|
+
// the cache loop below reads, and a frame that is not delivered must
|
|
599
|
+
// not be remembered as if it were.
|
|
600
|
+
spec.status = "failed";
|
|
601
|
+
r.status = "failed";
|
|
602
|
+
spec.note = "the frame could not be decoded for tiling (" + e.message + ")";
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (spec.status === "ok") {
|
|
606
|
+
spec.pixels = px;
|
|
607
|
+
spec.channels = channels;
|
|
608
|
+
spec.width = r.img.width;
|
|
609
|
+
spec.height = r.img.height;
|
|
610
|
+
ok++;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
specs.push(spec);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (!ok) {
|
|
617
|
+
var why = [];
|
|
618
|
+
for (i = 0; i < specs.length; i++) {
|
|
619
|
+
why.push(contactSheetModule.formatTime(specs[i].time) + ": " + specs[i].status +
|
|
620
|
+
(specs[i].note ? " (" + specs[i].note + ")" : ""));
|
|
621
|
+
}
|
|
622
|
+
var dead = new Error(
|
|
623
|
+
"No tile of the contact sheet rendered, so there is no sheet to send. Per time:\n" +
|
|
624
|
+
why.join("\n") + "\n\n" +
|
|
625
|
+
"A sheet of coloured blocks is not a screenshot. Retry at a higher `downsample`, " +
|
|
626
|
+
"or screenshot the shot precomps one at a time; if it repeats, read keyframes " +
|
|
627
|
+
"(get_keyframes / get_layer_full) instead."
|
|
628
|
+
);
|
|
629
|
+
dead.code = "CONTACT_SHEET_FAILED";
|
|
630
|
+
throw dead;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// The expected cell size, used only for the cells with no picture. A tile
|
|
634
|
+
// that rendered is the authority on its own dimensions.
|
|
635
|
+
var sheet = contactSheetModule.composeContactSheet(specs, {
|
|
636
|
+
cellWidth: Math.round(info.width / ds),
|
|
637
|
+
cellHeight: Math.round(info.height / ds),
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// Only what is actually being delivered goes into the cache.
|
|
641
|
+
for (i = 0; i < read.length; i++) {
|
|
642
|
+
if (read[i].status === "ok" && read[i].key) {
|
|
643
|
+
frameCache.remember(read[i].key, read[i].img.hash, {
|
|
644
|
+
label: frameLabel("screenshot_frame", { compId: info.compId, time: read[i].time, downsample: ds }),
|
|
645
|
+
bytes: read[i].img.buffer.length,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
var bad = [];
|
|
651
|
+
for (i = 0; i < sheet.tiles.length; i++) {
|
|
652
|
+
if (sheet.tiles[i].status !== "ok") {
|
|
653
|
+
bad.push(sheet.tiles[i].label + (sheet.tiles[i].note ? " — " + sheet.tiles[i].note : ""));
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return {
|
|
658
|
+
contactSheet: true,
|
|
659
|
+
width: sheet.width,
|
|
660
|
+
height: sheet.height,
|
|
661
|
+
cols: sheet.cols,
|
|
662
|
+
rows: sheet.rows,
|
|
663
|
+
cellWidth: sheet.cellWidth,
|
|
664
|
+
cellHeight: sheet.cellHeight,
|
|
665
|
+
fullWidth: info.width,
|
|
666
|
+
fullHeight: info.height,
|
|
667
|
+
downsample: ds,
|
|
668
|
+
compId: info.compId,
|
|
669
|
+
tiles: sheet.tiles,
|
|
670
|
+
mimeType: "image/png",
|
|
671
|
+
base64: sheet.buffer.toString("base64"),
|
|
672
|
+
bytes: sheet.buffer.length,
|
|
673
|
+
// Named and counted, because a sheet that looks complete and is not is the
|
|
674
|
+
// same class of lie as a swallowed error.
|
|
675
|
+
warning: bad.length
|
|
676
|
+
? bad.length + " of " + sheet.tiles.length + " tiles are marked blocks, not frames: " + bad.join("; ")
|
|
677
|
+
: undefined,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function renderContactSheet(args) {
|
|
682
|
+
return runOp("screenshot_frame", args).then(function (info) {
|
|
683
|
+
var jsxTiles = info.tiles || [];
|
|
684
|
+
// Sequentially: `evalScript` is a mutex anyway, a per-tile retry re-enters
|
|
685
|
+
// ExtendScript, and reading them in order keeps the panel log legible.
|
|
686
|
+
var read = [];
|
|
687
|
+
var chain = Promise.resolve();
|
|
688
|
+
for (var i = 0; i < jsxTiles.length; i++) {
|
|
689
|
+
chain = chain.then(function (tile) {
|
|
690
|
+
return function () {
|
|
691
|
+
return readSheetTile(info, tile).then(function (res) { read.push(res); });
|
|
692
|
+
};
|
|
693
|
+
}(jsxTiles[i]));
|
|
694
|
+
}
|
|
695
|
+
return chain.then(function () { return finishContactSheet(info, read); });
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
413
699
|
// ---------- Op handler with vision/job specialization ----------
|
|
414
700
|
function handleOp(op, args, progressToken) {
|
|
415
701
|
// Export writes the .mogrt; the thumbnail it wants is a second file that
|
|
@@ -430,36 +716,19 @@
|
|
|
430
716
|
}
|
|
431
717
|
// Vision ops: run JSX, then read PNG and base64-encode on Node side.
|
|
432
718
|
if (op === "screenshot_frame" || op === "screenshot_layer") {
|
|
433
|
-
|
|
434
|
-
return
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
fullWidth: info.width,
|
|
445
|
-
fullHeight: info.height,
|
|
446
|
-
downsample: info.downsample,
|
|
447
|
-
time: info.time,
|
|
448
|
-
compId: info.compId,
|
|
449
|
-
layerId: info.layerId,
|
|
450
|
-
reason: "Every pixel of this frame is fully transparent — the frame is empty. " +
|
|
451
|
-
"No image was sent because there is nothing in it to see. Usually this means " +
|
|
452
|
-
"the time is outside the layers' in/out points, the layers are disabled or " +
|
|
453
|
-
"have zero opacity, or the wrong comp was addressed.",
|
|
454
|
-
};
|
|
455
|
-
}
|
|
456
|
-
var key = frameKey(op, info);
|
|
457
|
-
var match = frameCache.match(key, img.hash);
|
|
458
|
-
if (match) throw staleFrameError(frameLabel(op, info), match, img.buffer.length);
|
|
459
|
-
frameCache.remember(key, img.hash, { label: frameLabel(op, info), bytes: img.buffer.length });
|
|
719
|
+
if (op === "screenshot_frame" && args && args.times && args.times.length) {
|
|
720
|
+
return renderContactSheet(args);
|
|
721
|
+
}
|
|
722
|
+
return renderFrameWithRetry(op, args).then(function (r) {
|
|
723
|
+
var info = r.info;
|
|
724
|
+
var img = r.img;
|
|
725
|
+
// A frame with nothing in it is a fact about the composition, not a
|
|
726
|
+
// failure — and the ~5KB PNG that encodes it is the one decoders
|
|
727
|
+
// reject. Report it, cheaply, instead of shipping an image nobody can
|
|
728
|
+
// read and calling that a successful screenshot.
|
|
729
|
+
if (img.empty) {
|
|
460
730
|
return {
|
|
461
|
-
|
|
462
|
-
// comp size, so they cannot disagree with the image sent.
|
|
731
|
+
empty: true,
|
|
463
732
|
width: img.width,
|
|
464
733
|
height: img.height,
|
|
465
734
|
fullWidth: info.width,
|
|
@@ -468,16 +737,41 @@
|
|
|
468
737
|
time: info.time,
|
|
469
738
|
compId: info.compId,
|
|
470
739
|
layerId: info.layerId,
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
// metadata stays quiet on the ordinary path.
|
|
476
|
-
converted: img.converted ? true : undefined,
|
|
477
|
-
sourceBitDepth: img.converted ? img.sourceBitDepth : undefined,
|
|
478
|
-
warning: img.warning,
|
|
740
|
+
reason: "Every pixel of this frame is fully transparent — the frame is empty. " +
|
|
741
|
+
"No image was sent because there is nothing in it to see. Usually this means " +
|
|
742
|
+
"the time is outside the layers' in/out points, the layers are disabled or " +
|
|
743
|
+
"have zero opacity, or the wrong comp was addressed.",
|
|
479
744
|
};
|
|
480
|
-
}
|
|
745
|
+
}
|
|
746
|
+
// Only a frame that read cleanly ever reaches the cache. A corrupt read
|
|
747
|
+
// is rejected above and never remembered — before the completeness gate
|
|
748
|
+
// existed, a truncated file was hashed and recorded here, so the next
|
|
749
|
+
// truncation at the same byte count was reported as a stale buffer and
|
|
750
|
+
// the real fault was hidden behind the wrong diagnosis.
|
|
751
|
+
var key = frameKey(op, info);
|
|
752
|
+
var match = frameCache.match(key, img.hash);
|
|
753
|
+
if (match) throw staleFrameError(frameLabel(op, info), match, img.buffer.length);
|
|
754
|
+
frameCache.remember(key, img.hash, { label: frameLabel(op, info), bytes: img.buffer.length });
|
|
755
|
+
return {
|
|
756
|
+
// Dimensions come from the PNG itself, not from arithmetic on the
|
|
757
|
+
// comp size, so they cannot disagree with the image sent.
|
|
758
|
+
width: img.width,
|
|
759
|
+
height: img.height,
|
|
760
|
+
fullWidth: info.width,
|
|
761
|
+
fullHeight: info.height,
|
|
762
|
+
downsample: info.downsample,
|
|
763
|
+
time: info.time,
|
|
764
|
+
compId: info.compId,
|
|
765
|
+
layerId: info.layerId,
|
|
766
|
+
mimeType: "image/png",
|
|
767
|
+
base64: img.buffer.toString("base64"),
|
|
768
|
+
bytes: img.buffer.length,
|
|
769
|
+
// Only present when the frame was not 8-bit as rendered, so the
|
|
770
|
+
// metadata stays quiet on the ordinary path.
|
|
771
|
+
converted: img.converted ? true : undefined,
|
|
772
|
+
sourceBitDepth: img.converted ? img.sourceBitDepth : undefined,
|
|
773
|
+
warning: img.warning,
|
|
774
|
+
};
|
|
481
775
|
});
|
|
482
776
|
}
|
|
483
777
|
// Long batches: returns {jobId, async, total}. We kick off background drive
|
|
@@ -485,10 +779,21 @@
|
|
|
485
779
|
if (op === "run_batch") {
|
|
486
780
|
return runOp(op, args).then(function (res) {
|
|
487
781
|
if (res && res.async && res.jobId) {
|
|
488
|
-
driveJob(res.jobId, progressToken).catch(function (e) {
|
|
782
|
+
driveJob(res.jobId, progressToken, res.chunkSize).catch(function (e) {
|
|
489
783
|
broadcast({ type: "error", jobId: res.jobId, error: e.message });
|
|
490
784
|
});
|
|
491
|
-
|
|
785
|
+
// The undo fields ride out with the envelope so the agent is told the
|
|
786
|
+
// batch will be several steps *before* it goes and says otherwise —
|
|
787
|
+
// the final count arrives much later, on the completion event.
|
|
788
|
+
return {
|
|
789
|
+
jobId: res.jobId,
|
|
790
|
+
async: true,
|
|
791
|
+
total: res.total,
|
|
792
|
+
chunkSize: res.chunkSize,
|
|
793
|
+
undoStepsEstimate: res.undoStepsEstimate,
|
|
794
|
+
undoGroupName: res.undoGroupName,
|
|
795
|
+
note: res.note,
|
|
796
|
+
};
|
|
492
797
|
}
|
|
493
798
|
return res; // small batch: inline result already
|
|
494
799
|
});
|
|
@@ -547,7 +852,7 @@
|
|
|
547
852
|
// `code` marks a failure the panel diagnosed itself rather than
|
|
548
853
|
// one ExtendScript raised, so the server can present it as what
|
|
549
854
|
// it is instead of prefixing it as an After Effects error.
|
|
550
|
-
res.end(JSON.stringify({ ok: false, error: err.message, code: err.code, stack: err.aeStack, line: err.aeLine }));
|
|
855
|
+
res.end(JSON.stringify({ ok: false, error: err.message, code: err.code, stack: err.aeStack, line: err.aeLine, source: err.aeSource }));
|
|
551
856
|
});
|
|
552
857
|
return;
|
|
553
858
|
}
|