@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.
@@ -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 — pngcodec.js, framecache.js or mogrt.js is missing", "err");
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
- function waitForPngFile(file, maxMs) {
216
- var deadline = Date.now() + (maxMs || 3000);
217
- return new Promise(function (resolve, reject) {
218
- (function poll() {
219
- try {
220
- if (fs.existsSync(file)) {
221
- var stat = fs.statSync(file);
222
- // Reasonable size + ensure not currently being written (re-check)
223
- if (stat.size > 64) {
224
- setTimeout(function () {
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
- // A cold render of a heavy 4K comp can take well over 15s — measured on a
263
- // real project. Five seconds silently failed screenshots that were simply
264
- // still rendering.
265
- return waitForPngFile(file, 120000).then(function () {
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
- // A PNG this parser cannot read is still a PNG the client might. Ship
273
- // it unchanged rather than throwing away a render that did happen but
274
- // say so, and fall back to hashing the file so the stale check survives.
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
- function driveJob(jobId, progressToken) {
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: 25 }).then(function (res) {
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
- broadcast({ type: "error", jobId: jobId, error: res.error || "batch failed" });
365
- return { done: true, failed: true, jobId: jobId, error: res.error, results: res.results, errors: res.errors, atIndex: res.atIndex };
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
- broadcast({ type: "complete", jobId: jobId, result: { results: res.results, errors: res.errors, total: res.total || totalGuess, cancelled: !!res.cancelled } });
368
- return { done: true, jobId: jobId, results: res.results, errors: res.errors, total: res.total || totalGuess, cancelled: !!res.cancelled };
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
- return waitForPngFile(info.posterPngPath, 120000).then(function () {
384
- var poster = fs.readFileSync(info.posterPngPath);
385
- try { fs.unlinkSync(info.posterPngPath); } catch (e) {}
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
- try { fs.unlinkSync(info.posterPngPath); } catch (e2) {}
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
- return runOp(op, args).then(function (info) {
434
- return readPngFrame(info.path).then(function (img) {
435
- // A frame with nothing in it is a fact about the composition, not a
436
- // failure and the ~5KB PNG that encodes it is the one decoders
437
- // reject. Report it, cheaply, instead of shipping an image nobody can
438
- // read and calling that a successful screenshot.
439
- if (img.empty) {
440
- return {
441
- empty: true,
442
- width: img.width,
443
- height: img.height,
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
- // Dimensions come from the PNG itself, not from arithmetic on the
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
- mimeType: "image/png",
472
- base64: img.buffer.toString("base64"),
473
- bytes: img.buffer.length,
474
- // Only present when the frame was not 8-bit as rendered, so the
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
- return { jobId: res.jobId, async: true, total: res.total };
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
  }