@engine-room/after-effects-mcp 0.3.1 → 0.5.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.
@@ -69,13 +69,33 @@ function __newJobId() {
69
69
  }
70
70
 
71
71
  // ---------- Undo wrapper ----------
72
- // __UNDO_OPEN tracks only the group *dispatch* opened. Nothing else may set it:
73
- // it is what lets withoutUndoGroup() reopen exactly what it closed, and leave
74
- // undo state alone entirely when we never opened a group in the first place.
72
+ // __UNDO_OPEN tracks whether a group *this bundle* opened is currently open.
73
+ // withUndo() is the only thing that may set it — dispatch and run_batch both
74
+ // group through there — because it is what lets withoutUndoGroup() reopen
75
+ // exactly what it closed, and leave undo state alone entirely when we never
76
+ // opened a group in the first place.
75
77
  var __UNDO_OPEN = false;
76
78
 
77
- function withUndo(name, fn) {
79
+ // Every undo group this bundle opens is counted here, and __beginUndoGroup is
80
+ // the only place allowed to call app.beginUndoGroup. run_batch reports the
81
+ // number of undo steps it cost as the *delta* of this counter, so the number an
82
+ // agent repeats to the user as "press Cmd-Z N times" is measured rather than
83
+ // predicted — a chunk that threw still opened its group, and a batched op that
84
+ // called withoutUndoGroup() really did split itself into two steps.
85
+ var __UNDO_GROUPS = 0;
86
+
87
+ function __beginUndoGroup(name) {
88
+ __UNDO_GROUPS += 1;
78
89
  app.beginUndoGroup(name || "AE MCP");
90
+ }
91
+
92
+ function __undoGroupsOpened() { return __UNDO_GROUPS; }
93
+
94
+ // An undo group must open and close inside ONE evalScript call. After Effects
95
+ // discards one that spans two — see the note in CLAUDE.md; measured, not
96
+ // inferred. Everything that groups goes through here for that reason.
97
+ function withUndo(name, fn) {
98
+ __beginUndoGroup(name || "AE MCP");
79
99
  __UNDO_OPEN = true;
80
100
  try { return fn(); }
81
101
  finally { __UNDO_OPEN = false; app.endUndoGroup(); }
@@ -91,15 +111,28 @@ function withoutUndoGroup(fn) {
91
111
  app.endUndoGroup();
92
112
  __UNDO_OPEN = false;
93
113
  try { return fn(); }
94
- finally { app.beginUndoGroup("AE MCP: continue"); __UNDO_OPEN = true; }
114
+ finally { __beginUndoGroup("AE MCP: continue"); __UNDO_OPEN = true; }
95
115
  }
96
116
 
97
117
  // ---------- Error helper ----------
118
+ // A handler that can say more about *where* it failed than "an error happened"
119
+ // attaches that on `aeDetail` and it is copied onto the result verbatim —
120
+ // run_jsx maps After Effects' line number back onto the script the caller
121
+ // actually submitted (issue #46). The bag is free-form here on purpose; the
122
+ // panel forwards a named list of fields, which is where the contract is kept.
98
123
  function __mkError(e) {
99
124
  var msg = e && e.message ? String(e.message) : String(e);
100
125
  var stack = e && e.stack ? String(e.stack) : "";
101
126
  var line = e && typeof e.line !== "undefined" ? e.line : null;
102
- return { ok: false, error: msg, stack: stack, line: line };
127
+ var out = { ok: false, error: msg, stack: stack, line: line };
128
+ var detail = null;
129
+ try { if (e && e.aeDetail) detail = e.aeDetail; } catch (ed) {}
130
+ if (detail) {
131
+ for (var k in detail) {
132
+ if (detail.hasOwnProperty(k)) out[k] = detail[k];
133
+ }
134
+ }
135
+ return out;
103
136
  }
104
137
 
105
138
  // ---------- Main dispatch ----------
@@ -262,10 +295,221 @@ OPS.set_comp = function (args) {
262
295
  return __compSummary(c);
263
296
  };
264
297
 
298
+ // A solid layer's source is a FootageItem in the project's Solids folder, and
299
+ // removing the comp removes the layer, not the item (issue #83). The solids
300
+ // this comp used are collected from ITS OWN layers before the removal and
301
+ // nothing else — deleting comp A must never reach a solid that only some other
302
+ // comp uses — and after it, `usedIn` decides: an item some other comp still
303
+ // places is kept and reported with the comps using it. The helpers live in
304
+ // footage.jsx beside purge_unused_footage, the project-wide sweep.
265
305
  OPS.delete_comp = function (args) {
266
306
  var c = getCompById(args.compId);
307
+ var compId = c.id;
308
+ var compName = c.name;
309
+ var purge = (args.purgeUnusedSolids === true);
310
+
311
+ var solids = __solidItemsOf(c);
312
+
267
313
  c.remove();
268
- return { ok: true };
314
+
315
+ var out = { ok: true, compId: compId, name: compName };
316
+ var kept = [];
317
+ var toRemove = [];
318
+ var gone = [];
319
+ for (var i = 0; i < solids.length; i++) {
320
+ // Re-fetched by id after the removal rather than held across it — the
321
+ // handle staleness this repo has measured elsewhere is not worth risking
322
+ // for one lookup per solid.
323
+ var item = app.project.itemByID(solids[i].id);
324
+ if (!item) { gone.push(solids[i]); continue; }
325
+ var uses = __usedInList(item);
326
+ if (uses.length > 0) {
327
+ kept.push({ id: item.id, name: item.name, usedIn: uses });
328
+ continue;
329
+ }
330
+ toRemove.push({ item: item, id: item.id, name: item.name, kind: "solid" });
331
+ }
332
+ if (gone.length > 0) out.solidsAlreadyGone = gone;
333
+
334
+ if (!purge) {
335
+ out.unusedSolidsLeft = toRemove.length;
336
+ if (toRemove.length > 0) {
337
+ out.note = toRemove.length + " solid item(s) this comp used are now used by nothing and remain in the project's Solids folder. " +
338
+ "Pass purgeUnusedSolids:true to remove them with the comp, or purge_unused_footage to sweep the whole project.";
339
+ }
340
+ return out;
341
+ }
342
+
343
+ var r = __removeItems(toRemove);
344
+ out.removedSolids = r.removed;
345
+ out.keptSolids = kept;
346
+ if (r.failed) {
347
+ var msg = "delete_comp removed comp \"" + compName + "\" (id " + compId + "), then failed purging its solids at \"" +
348
+ r.failed.name + "\" (id " + r.failed.id + "): " + r.error + ".";
349
+ if (r.removed.length > 0) msg += " Removed before it: " + __nameList(r.removed, 20) + ".";
350
+ else msg += " No solid had been removed yet.";
351
+ if (r.notAttempted.length > 0) msg += " Not attempted: " + __nameList(r.notAttempted, 20) + ".";
352
+ msg += " The comp is gone, so do not call delete_comp for it again. One Undo in After Effects restores the comp and " +
353
+ "these solids together; purge_unused_footage removes whatever is still left.";
354
+ throw new Error(msg);
355
+ }
356
+ if (kept.length > 0) {
357
+ out.note = kept.length + " solid item(s) were kept because another comp still uses them; see keptSolids.usedIn.";
358
+ }
359
+ return out;
360
+ };
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // duplicate_comp
364
+ // ---------------------------------------------------------------------------
365
+ // There was duplicate_layer, create_comp and delete_comp but no way to copy a
366
+ // comp, so every rig workflow detoured through run_jsx and CompItem.duplicate()
367
+ // (issue #54). Two things that detour never got right:
368
+ //
369
+ // * AE's own Duplicate is SHALLOW. The copy's precomp layers point at the
370
+ // same source comps as the original, so "make a variant of this rig" and
371
+ // then editing the variant edits the original too. `deep:true` duplicates
372
+ // the nested comps as well and re-points the copy's layers at them, which
373
+ // is the entire value of the flag.
374
+ // * The same nested comp usually appears on several layers. Duplicating per
375
+ // layer fans out one copy per reference; __dupNested keeps a map from
376
+ // original id to its copy and reuses it, and registers the copy *before*
377
+ // recursing so a cycle terminates instead of recursing for ever.
378
+
379
+ // AE happily allows two project items with the same name, which makes a
380
+ // deep-duplicated rig unreadable in the project panel. Appending a counter is
381
+ // the smaller evil, and the chosen name is reported either way.
382
+ function __dupNameTaken(name) {
383
+ for (var i = 1; i <= app.project.numItems; i++) {
384
+ if (app.project.item(i).name === name) return true;
385
+ }
386
+ return false;
387
+ }
388
+
389
+ function __dupUniqueName(base) {
390
+ if (!__dupNameTaken(base)) return base;
391
+ for (var n = 2; n < 1000; n++) {
392
+ var candidate = base + " " + n;
393
+ if (!__dupNameTaken(candidate)) return candidate;
394
+ }
395
+ return base;
396
+ }
397
+
398
+ // Depth is a backstop, not the cycle guard — `seen` is. AE refuses to nest a
399
+ // comp inside itself, but nothing here should recurse for ever if a future
400
+ // build ever allows it.
401
+ var __DUP_MAX_DEPTH = 32;
402
+
403
+ function __dupNested(src, opts, depth) {
404
+ var key = "C" + src.id;
405
+ if (opts.seen.hasOwnProperty(key)) return opts.seen[key];
406
+ if (depth > __DUP_MAX_DEPTH) {
407
+ throw new Error("nested comps are more than " + __DUP_MAX_DEPTH + " deep below the comp being duplicated");
408
+ }
409
+ var srcId = src.id;
410
+ var srcName = src.name;
411
+ var dup = src.duplicate();
412
+ opts.seen[key] = dup;
413
+ if (opts.nameSuffix) dup.name = __dupUniqueName(srcName + opts.nameSuffix);
414
+ opts.created.push({ fromCompId: srcId, fromName: srcName, compId: dup.id, name: dup.name });
415
+ __dupRepoint(dup, opts, depth);
416
+ return dup;
417
+ }
418
+
419
+ // Re-point every precomp layer of a freshly duplicated comp at the duplicate of
420
+ // its source rather than the original. Layers whose source is footage, and
421
+ // layers with no source at all, are left alone.
422
+ function __dupRepoint(comp, opts, depth) {
423
+ for (var i = 1; i <= comp.numLayers; i++) {
424
+ var l = comp.layer(i);
425
+ if (!(l instanceof AVLayer)) continue;
426
+ var srcItem = null;
427
+ try { srcItem = l.source; } catch (e) { continue; }
428
+ if (!srcItem || !(srcItem instanceof CompItem)) continue;
429
+ var replacement = __dupNested(srcItem, opts, depth + 1);
430
+ // fixExpressions:false — the layer keeps its name and its own properties,
431
+ // so there is nothing for AE to rewrite, and letting it rewrite expressions
432
+ // on a rig is a change nobody asked for.
433
+ l.replaceSource(replacement, false);
434
+ opts.repointed += 1;
435
+ }
436
+ }
437
+
438
+ // A `folderId` argument resolved to a FolderItem, or a thrown error naming the
439
+ // id and what it actually is. Shared by duplicate_comp and purge_unused_footage.
440
+ function __folderArg(id) {
441
+ var f = app.project.itemByID(id);
442
+ if (!f) throw new Error("No project item with id " + id + " to use as folderId");
443
+ if (!(f instanceof FolderItem)) {
444
+ throw new Error(
445
+ "folderId " + id + ' ("' + f.name + '") is a ' + __itemKind(f) +
446
+ ", not a project folder. Pass the id of a folder from get_project_summary, or omit folderId."
447
+ );
448
+ }
449
+ return f;
450
+ }
451
+
452
+ OPS.duplicate_comp = function (args) {
453
+ var src = getCompById(args.compId);
454
+ var folder = null;
455
+ if (args.folderId !== undefined && args.folderId !== null) folder = __folderArg(args.folderId);
456
+
457
+ var opts = { seen: {}, created: [], repointed: 0, nameSuffix: null };
458
+ if (args.nameSuffix) opts.nameSuffix = args.nameSuffix;
459
+
460
+ // Captured as primitives before the duplicate. Some AE calls invalidate every
461
+ // handle held across them (exportAsMotionGraphicsTemplate is the measured
462
+ // one), so nothing below reads `src` again.
463
+ var srcId = src.id;
464
+ var srcName = src.name;
465
+ var dup = src.duplicate();
466
+ var newId = dup.id;
467
+ if (args.name) dup.name = args.name;
468
+
469
+ if (args.deep) {
470
+ opts.seen["C" + srcId] = dup;
471
+ try {
472
+ __dupRepoint(dup, opts, 1);
473
+ } catch (e) {
474
+ // The copy and any nested copies made before the failure are real and
475
+ // nothing rolled them back. Reporting {ok:true} over a half-built rig, or
476
+ // an error that does not name what exists, are the same class of lie.
477
+ var madeIds = [];
478
+ madeIds.push(String(newId));
479
+ for (var m = 0; m < opts.created.length; m++) madeIds.push(String(opts.created[m].compId));
480
+ throw new Error(
481
+ "duplicate_comp deep failed part-way: " + e.message +
482
+ ". These comps were created and still exist: ids " + madeIds.join(", ") +
483
+ ". Undo once in After Effects to back the whole thing out, or delete_comp them."
484
+ );
485
+ }
486
+ }
487
+
488
+ // Re-fetch by id rather than trusting the handle held across the duplication.
489
+ var made = app.project.itemByID(newId);
490
+ if (!made) throw new Error("duplicate_comp created comp " + newId + " but it could not be read back");
491
+ if (folder) made.parentFolder = folder;
492
+
493
+ var out = __compSummary(made);
494
+ out.fromCompId = srcId;
495
+ out.fromName = srcName;
496
+ out.deep = !!args.deep;
497
+ if (folder) {
498
+ out.folderId = folder.id;
499
+ out.folderName = folder.name;
500
+ }
501
+ if (args.deep) {
502
+ out.nestedDuplicated = opts.created;
503
+ out.nestedCount = opts.created.length;
504
+ out.layersRepointed = opts.repointed;
505
+ if (opts.created.length === 0) {
506
+ out.note = "deep:true had nothing to do - this comp has no precomp layers.";
507
+ }
508
+ } else {
509
+ out.note = "Shallow copy: its precomp layers still point at the SAME nested comps as the original, " +
510
+ "so editing one of those edits both. Pass deep:true to duplicate the nested comps too.";
511
+ }
512
+ return out;
269
513
  };
270
514
 
271
515
  OPS.set_active_comp = function (args) {
@@ -371,15 +615,33 @@ OPS.create_text_layer = function (args) {
371
615
  var align = args.anchorAlign === undefined ? "left" : args.anchorAlign;
372
616
  var wantJustify = align !== "none" && __JUSTIFICATION[align] !== undefined;
373
617
  var wantTracking = args.tracking !== undefined || align !== "none";
618
+ var verified = null;
374
619
  if (args.font || args.size || args.color || wantJustify || wantTracking) {
375
620
  var srcText = l.property("Source Text");
376
621
  var td = srcText.value;
622
+ // The same TextDocument round trip set_text does, so the same read-back
623
+ // (issue #93 / #94, see __verifyJustification in text.jsx): the requested
624
+ // justification if there is one, otherwise whatever addText() gave the
625
+ // layer — 'none' promised to leave it alone, and that is checked too.
626
+ var justificationWanted = wantJustify ? __JUSTIFICATION[align] : td.justification;
377
627
  if (args.font) td.font = args.font;
378
628
  if (args.size) td.fontSize = args.size;
379
629
  if (args.color) { td.applyFill = true; td.fillColor = [args.color[0], args.color[1], args.color[2]]; }
380
630
  if (wantTracking) td.tracking = (args.tracking !== undefined ? args.tracking : 0);
381
- if (wantJustify) td.justification = __JUSTIFICATION[align];
631
+ if (wantJustify) td.justification = justificationWanted;
382
632
  srcText.setValue(td);
633
+ try {
634
+ verified = __verifyJustification(srcText, justificationWanted);
635
+ } catch (e) {
636
+ // A throw here would leave a layer the caller has no id for, and the
637
+ // natural next move — call again — makes two. Same rule as
638
+ // add_shape_content: take the node back, then say so. The message is
639
+ // amended on the same Error object so nothing about it is lost.
640
+ try { l.remove(); } catch (e2) {}
641
+ e.message = String(e.message) + " The new layer was removed, so nothing was created; " +
642
+ "create it with anchorAlign:\"none\" and set the alignment afterwards with set_text.";
643
+ throw e;
644
+ }
383
645
  }
384
646
  if (align !== "none") {
385
647
  l.property("Transform").property("Anchor Point").setValue([0, 0, 0]);
@@ -388,7 +650,12 @@ OPS.create_text_layer = function (args) {
388
650
  var p = args.position;
389
651
  l.property("Transform").property("Position").setValue(p.length === 3 ? p : [p[0], p[1]]);
390
652
  }
391
- return __layerSummary(l);
653
+ var out = __layerSummary(l);
654
+ if (verified) {
655
+ out.justification = __justificationName(verified.justification);
656
+ out.justificationReasserted = verified.reasserted;
657
+ }
658
+ return out;
392
659
  };
393
660
 
394
661
  OPS.create_solid_layer = function (args) {
@@ -415,12 +682,59 @@ OPS.create_adjustment_layer = function (args) {
415
682
  return __layerSummary(l);
416
683
  };
417
684
 
685
+ // Where a new shape layer's origin goes (issue #51).
686
+ //
687
+ // AE's addShape() leaves Position at the comp centre with the Anchor Point at
688
+ // [0,0], so layer space is offset from comp space by half a frame. Every path
689
+ // this toolset can write — set_shape_path vertices, add_shape_content vertices,
690
+ // a rect/ellipse `position` — is expressed in *layer* space, and nothing in the
691
+ // response says where that space starts. An agent authoring in comp pixels gets
692
+ // the whole drawing shifted by (width/2, height/2), and the check that would
693
+ // catch it is a downsampled screenshot.
694
+ //
695
+ // Default [0,0] makes layer space and comp space the same space for a fresh
696
+ // shape layer, which is the only arrangement in which a vertex list means what
697
+ // it says. "center" is AE's own spawn point, kept as one word so the old
698
+ // behaviour is still one argument away.
699
+ function __shapeSpawnPosition(comp, position) {
700
+ if (position === undefined || position === null) return [0, 0];
701
+ if (position === "center") return [comp.width / 2, comp.height / 2];
702
+ if (position instanceof Array && position.length >= 2) {
703
+ if (position.length === 3) return [position[0], position[1], position[2]];
704
+ return [position[0], position[1]];
705
+ }
706
+ throw new Error(
707
+ "create_shape_layer: position must be [x,y], [x,y,z] or the string \"center\" (After Effects' own " +
708
+ "spawn point, the comp centre). Got: " + String(position)
709
+ );
710
+ }
711
+
418
712
  OPS.create_shape_layer = function (args) {
419
713
  var c = getCompById(args.compId);
714
+ var pos = __shapeSpawnPosition(c, args.position);
420
715
  var l = c.layers.addShape();
421
716
  if (args.name) l.name = args.name;
717
+ try {
718
+ l.property("Transform").property("Position").setValue(pos);
719
+ } catch (ePos) {
720
+ // The shape that can still get here is three components on a 2D layer, which
721
+ // AE refuses. Leaving an empty shape layer in the timeline behind a thrown
722
+ // error is the half-built failure add_shape_content refuses to produce.
723
+ try { l.remove(); } catch (eRm) {}
724
+ throw new Error(
725
+ "create_shape_layer could not set position to [" + pos.join(", ") + "]: " + ePos.message +
726
+ ". (A three-component position needs a 3D layer; create it 2D and set threeDLayer with set_layer first.) " +
727
+ "The empty layer was removed, so nothing changed."
728
+ );
729
+ }
422
730
  // shapes payload kept loose for v1 — the agent can use add_shape_content for detail
423
- return __layerSummary(l);
731
+ var out = __layerSummary(l);
732
+ // Read both back rather than echoing what we asked for: together they are the
733
+ // coordinate space every subsequent path on this layer is measured in, and a
734
+ // caller should never have to render a frame to find out what it is.
735
+ out.position = l.property("Transform").property("Position").value;
736
+ out.anchorPoint = l.property("Transform").property("Anchor Point").value;
737
+ return out;
424
738
  };
425
739
 
426
740
  OPS.create_precomp_layer = function (args) {
@@ -824,165 +1138,894 @@ OPS.parent_layer = function (args) {
824
1138
  return out;
825
1139
  };
826
1140
 
1141
+ // Layers do not reorder with moveTo. `moveTo` is a PropertyBase method for
1142
+ // re-ranking a property inside an indexed group, and calling it on a Layer
1143
+ // throws "parent is not an INDEXED_GROUP" — which is every call this op has
1144
+ // ever served (issue #70). AE's layer-level primitives are moveBefore,
1145
+ // moveAfter, moveToBeginning and moveToEnd, and all four take a *layer*, never
1146
+ // an index. (The one legitimate moveTo in this codebase is the shape-property
1147
+ // one in shapes.jsx; it is on a PropertyBase and stays.)
1148
+ //
1149
+ // Two ways in, because reorder is the op that invalidates indexes:
1150
+ //
1151
+ // * `beforeLayerId` / `afterLayerId` — place this layer relative to another
1152
+ // one by id. Nothing about the answer depends on the stack not having moved
1153
+ // since the caller last read it, which is why these are the preferred form.
1154
+ // * `toIndex` — the index the layer ends up at, counting from the front.
1155
+ // Stated as the *landing* index because "the index it displaces" and "the
1156
+ // index it lands on" differ by one when moving down the stack and the
1157
+ // caller cannot tell which one a tool meant.
1158
+ //
1159
+ // The two directions need different primitives, and swapping them is off by one
1160
+ // with no error. Moving up (toIndex < from), the layer currently at the target
1161
+ // is pushed down, so moveBefore lands exactly on it. Moving down, that layer
1162
+ // shifts up by one as this one leaves, so moveAfter is what lands on the target.
827
1163
  OPS.reorder_layer = function (args) {
828
1164
  var c = getCompById(args.compId);
829
1165
  var l = getLayerById(c, args.layerId);
830
- l.moveTo(args.toIndex);
831
- return __layerSummary(l);
1166
+ var from = l.index;
1167
+ var n = c.numLayers;
1168
+ var to;
1169
+
1170
+ if (args.beforeLayerId !== undefined && args.beforeLayerId !== null) {
1171
+ var b = getLayerById(c, args.beforeLayerId);
1172
+ if (b.id === l.id) throw new Error("reorder_layer: beforeLayerId is the layer being moved (" + l.id + ").");
1173
+ l.moveBefore(b);
1174
+ } else if (args.afterLayerId !== undefined && args.afterLayerId !== null) {
1175
+ var a = getLayerById(c, args.afterLayerId);
1176
+ if (a.id === l.id) throw new Error("reorder_layer: afterLayerId is the layer being moved (" + l.id + ").");
1177
+ l.moveAfter(a);
1178
+ } else if (args.toIndex !== undefined && args.toIndex !== null) {
1179
+ to = Math.round(args.toIndex);
1180
+ if (to < 1) to = 1;
1181
+ if (to > n) to = n;
1182
+ if (to === from) {
1183
+ // Nothing to do. Saying so beats an AE call that is a no-op anyway.
1184
+ } else if (to === 1) {
1185
+ l.moveToBeginning();
1186
+ } else if (to === n) {
1187
+ l.moveToEnd();
1188
+ } else if (to < from) {
1189
+ l.moveBefore(c.layer(to));
1190
+ } else {
1191
+ l.moveAfter(c.layer(to));
1192
+ }
1193
+ } else {
1194
+ throw new Error("reorder_layer: pass one of toIndex, beforeLayerId or afterLayerId.");
1195
+ }
1196
+
1197
+ // Report both ends of the move. The landing index is read back off the layer
1198
+ // rather than assumed, so a result can never claim a position AE did not
1199
+ // give it — and `movedFrom === index` is how a caller sees a no-op.
1200
+ var out = __layerSummary(l);
1201
+ out.movedFrom = from;
1202
+ return out;
832
1203
  };
833
1204
 
834
1205
 
835
- // ===== transforms.jsx =====
1206
+ // ===== snapshot.jsx =====
836
1207
 
837
- // transforms.jsx — fast-path for setting common transform properties.
1208
+ // snapshot.jsx — a cheap structural fingerprint of a comp, and the diff
1209
+ // between two of them.
1210
+ //
1211
+ // Verifying a write today means reading the comp back and comparing by eye,
1212
+ // and every one of those reads is re-sent on every later request for the rest
1213
+ // of the session. A fingerprint is the other end of that trade: it records
1214
+ // what an agent actually checks after a write — which layers exist, what they
1215
+ // are called, where they sit in time, what they are parented to, how many
1216
+ // keyframes/expressions/effects they carry — and nothing else. The diff of two
1217
+ // of them is a few dozen tokens for "3 layers added: ids 512-514; layer 498
1218
+ // Opacity keys 0 -> 4" (issue #52).
1219
+ //
1220
+ // Cheapness is the whole point, so the walk stops at the layer's own Transform
1221
+ // group plus four named properties. It never opens an effect's parameters or a
1222
+ // shape layer's Contents — those are what make get_layer_full expensive, and a
1223
+ // fingerprint that costs as much as the read it replaces is worth nothing.
1224
+ //
1225
+ // That has a cost of its own, and per the "a scoped read must say what it left
1226
+ // out" rule it is stated rather than left to be discovered: a diff can only
1227
+ // report a field it records. __DIFF_COVERS travels with every diff for exactly
1228
+ // that reason — "no differences" must never be read as "identical".
1229
+
1230
+ var __DIFF_COVERS = "Compares recorded fields only - not property values, expression text, " +
1231
+ "effect parameters, masks or shape contents. \"No differences\" means none of the recorded fields moved, " +
1232
+ "not that the two states render identically.";
1233
+
1234
+ var __FP_COVERS = "A snapshot records, per layer: id, name, index, type, inPoint, outPoint, startTime, " +
1235
+ "parentId, enabled, keyframe counts per Transform property (plus Marker, Time Remap and Source Text), " +
1236
+ "expression count and effect count. Per comp: name, size, duration, frame rate, work area and markers. " +
1237
+ "It does NOT record property values, expression text, effect parameters, mask shapes or shape contents, " +
1238
+ "because reading those costs as much as the read this replaces.";
1239
+
1240
+ // Times are floats out of AE and are compared, not displayed, so they are
1241
+ // rounded once here rather than at every comparison site.
1242
+ var __FP_PRECISION = 1000000;
1243
+
1244
+ function __fpRound(n) {
1245
+ if (typeof n !== "number") return n;
1246
+ if (!isFinite(n)) return null;
1247
+ return Math.round(n * __FP_PRECISION) / __FP_PRECISION;
1248
+ }
838
1249
 
839
- function __setOrKeyAtTime(prop, value, time, keyframe) {
840
- if (keyframe && time !== undefined && time !== null) {
841
- prop.setValueAtTime(time, value);
842
- } else if (time !== undefined && time !== null && !keyframe) {
843
- // Set value at time only meaningful if property has keyframes; else set static.
844
- if (prop.numKeys > 0) prop.setValueAtTime(time, value);
845
- else prop.setValue(value);
846
- } else {
847
- prop.setValue(value);
1250
+ // Properties worth a fingerprint that do not live under Transform. Each is one
1251
+ // guarded lookup; a layer that has none of them pays four null checks.
1252
+ var __FP_EXTRA_PROPS = ["Marker", "Time Remap", "Source Text", "Audio Levels"];
1253
+
1254
+ function __fpCountProperty(p, out) {
1255
+ if (!p) return;
1256
+ var keys = 0;
1257
+ try { keys = p.numKeys; } catch (e) { return; }
1258
+ if (typeof keys !== "number") return;
1259
+ if (keys > 0) out.keyCounts[p.name] = keys;
1260
+ try {
1261
+ if (p.canSetExpression && p.expression) out.expressionCount += 1;
1262
+ } catch (e2) {}
1263
+ }
1264
+
1265
+ function __fpLayer(l) {
1266
+ var out = {
1267
+ id: l.id,
1268
+ name: l.name,
1269
+ index: l.index,
1270
+ type: __layerKind(l),
1271
+ inPoint: __fpRound(l.inPoint),
1272
+ outPoint: __fpRound(l.outPoint),
1273
+ startTime: __fpRound(l.startTime),
1274
+ parentId: null,
1275
+ enabled: l.enabled,
1276
+ keyCounts: {},
1277
+ expressionCount: 0,
1278
+ effectCount: 0
1279
+ };
1280
+ try { if (l.parent) out.parentId = l.parent.id; } catch (e0) {}
1281
+
1282
+ var tr = null;
1283
+ try { tr = l.property("Transform"); } catch (e1) {}
1284
+ if (tr) {
1285
+ for (var i = 1; i <= tr.numProperties; i++) __fpCountProperty(tr.property(i), out);
848
1286
  }
1287
+ for (var j = 0; j < __FP_EXTRA_PROPS.length; j++) {
1288
+ var extra = null;
1289
+ try { extra = l.property(__FP_EXTRA_PROPS[j]); } catch (e2) {}
1290
+ __fpCountProperty(extra, out);
1291
+ }
1292
+ // Effects are counted, never walked. The parameter tree is the expensive
1293
+ // part and an agent checking "did my effect land" only needs the count plus
1294
+ // list_effects when it did not.
1295
+ try {
1296
+ var fx = l.property("Effects");
1297
+ if (fx) out.effectCount = fx.numProperties;
1298
+ } catch (e3) {}
1299
+ return out;
849
1300
  }
850
1301
 
851
- OPS.set_transform = function (args) {
852
- var c = getCompById(args.compId);
853
- var l = getLayerById(c, args.layerId);
854
- var t = args.time;
855
- var kf = !!args.keyframe;
856
- var tr = l.property("Transform");
857
- var p = args.properties || {};
858
- if (p.position !== undefined) __setOrKeyAtTime(tr.property("Position"), p.position, t, kf);
859
- if (p.scale !== undefined) __setOrKeyAtTime(tr.property("Scale"), p.scale, t, kf);
860
- if (p.rotation !== undefined) {
861
- var rotProp = l.threeDLayer ? tr.property("Z Rotation") : tr.property("Rotation");
862
- __setOrKeyAtTime(rotProp, p.rotation, t, kf);
1302
+ function __fpCompMarkers(c) {
1303
+ var out = [];
1304
+ var mp = null;
1305
+ try { mp = c.markerProperty; } catch (e) { return out; }
1306
+ if (!mp) return out;
1307
+ var n = 0;
1308
+ try { n = mp.numKeys; } catch (e2) { return out; }
1309
+ for (var i = 1; i <= n; i++) {
1310
+ var mv = mp.keyValue(i);
1311
+ out.push(String(__fpRound(mp.keyTime(i))) + "|" + String(mv.comment) + "|" + String(__fpRound(mv.duration)));
863
1312
  }
864
- if (p.anchorPoint !== undefined) __setOrKeyAtTime(tr.property("Anchor Point"), p.anchorPoint, t, kf);
865
- if (p.opacity !== undefined) __setOrKeyAtTime(tr.property("Opacity"), p.opacity, t, kf);
866
- if (p.orientation !== undefined && l.threeDLayer) __setOrKeyAtTime(tr.property("Orientation"), p.orientation, t, kf);
867
- if (p.xRotation !== undefined && l.threeDLayer) __setOrKeyAtTime(tr.property("X Rotation"), p.xRotation, t, kf);
868
- if (p.yRotation !== undefined && l.threeDLayer) __setOrKeyAtTime(tr.property("Y Rotation"), p.yRotation, t, kf);
869
- if (p.zRotation !== undefined && l.threeDLayer) __setOrKeyAtTime(tr.property("Z Rotation"), p.zRotation, t, kf);
870
- return { ok: true };
871
- };
1313
+ return out;
1314
+ }
872
1315
 
1316
+ function __compFingerprint(compId) {
1317
+ var c = getCompById(compId);
1318
+ var fp = {
1319
+ compId: c.id,
1320
+ name: c.name,
1321
+ width: c.width,
1322
+ height: c.height,
1323
+ duration: __fpRound(c.duration),
1324
+ frameRate: __fpRound(c.frameRate),
1325
+ workAreaStart: __fpRound(c.workAreaStart),
1326
+ workAreaDuration: __fpRound(c.workAreaDuration),
1327
+ numLayers: c.numLayers,
1328
+ markers: __fpCompMarkers(c),
1329
+ layers: []
1330
+ };
1331
+ for (var i = 1; i <= c.numLayers; i++) fp.layers.push(__fpLayer(c.layer(i)));
1332
+ return fp;
1333
+ }
873
1334
 
874
- // ===== keyframes.jsx =====
1335
+ // ---------------------------------------------------------------------------
1336
+ // The diff
1337
+ // ---------------------------------------------------------------------------
1338
+ // Pure: two fingerprints in, one object out, no AE. That is what lets it be
1339
+ // tested against synthetic input (tests/unit/comp-snapshot.mjs) — there is no
1340
+ // ExtendScript runtime on a runner and this is the part that has to be right.
1341
+
1342
+ // Below a frame at any sane frame rate, above the float noise a round-trip
1343
+ // through JSON leaves behind. Without it a comp re-read after a frame-rate
1344
+ // change reports every layer "retimed" by 1e-15.
1345
+ var __DIFF_EPS = 0.000001;
1346
+
1347
+ function __diffMoved(a, b) {
1348
+ if (typeof a !== "number") return a !== b;
1349
+ if (typeof b !== "number") return true;
1350
+ return Math.abs(a - b) > __DIFF_EPS;
1351
+ }
875
1352
 
876
- // keyframes.jsx — generic keyframe ops.
1353
+ // ES3 has no Map. The "L" prefix keeps a numeric id away from anything already
1354
+ // on Object.prototype.
1355
+ function __fpById(fp) {
1356
+ var m = {};
1357
+ for (var i = 0; i < fp.layers.length; i++) m["L" + fp.layers[i].id] = fp.layers[i];
1358
+ return m;
1359
+ }
877
1360
 
878
- var __INTERP_MAP = {
879
- linear: KeyframeInterpolationType.LINEAR,
880
- bezier: KeyframeInterpolationType.BEZIER,
881
- hold: KeyframeInterpolationType.HOLD,
882
- };
1361
+ function __diffKeyCounts(a, b) {
1362
+ var out = null;
1363
+ var seen = {};
1364
+ var k;
1365
+ for (k in a) {
1366
+ if (!a.hasOwnProperty(k)) continue;
1367
+ seen[k] = true;
1368
+ var bv = 0;
1369
+ if (b.hasOwnProperty(k)) bv = b[k];
1370
+ if (a[k] !== bv) {
1371
+ if (!out) out = {};
1372
+ out[k] = { from: a[k], to: bv };
1373
+ }
1374
+ }
1375
+ for (k in b) {
1376
+ if (!b.hasOwnProperty(k)) continue;
1377
+ if (seen[k]) continue;
1378
+ if (!out) out = {};
1379
+ out[k] = { from: 0, to: b[k] };
1380
+ }
1381
+ return out;
1382
+ }
883
1383
 
884
- function __findKeyIndexAtTime(prop, time, eps) {
885
- eps = eps || 0.001;
886
- for (var i = 1; i <= prop.numKeys; i++) {
887
- if (Math.abs(prop.keyTime(i) - time) < eps) return i;
1384
+ var __DIFF_EXACT_FIELDS = ["name", "type", "enabled"];
1385
+ var __DIFF_TIME_FIELDS = ["inPoint", "outPoint", "startTime"];
1386
+ var __DIFF_COUNT_FIELDS = ["expressionCount", "effectCount"];
1387
+
1388
+ // `index` is deliberately not compared here: inserting one layer shifts every
1389
+ // index below it, which would report twenty changed layers for one addition.
1390
+ // Relative order is compared separately, in __diffReordered.
1391
+ function __diffLayer(a, b) {
1392
+ var ch = {};
1393
+ var n = 0;
1394
+ var i, k;
1395
+ for (i = 0; i < __DIFF_EXACT_FIELDS.length; i++) {
1396
+ k = __DIFF_EXACT_FIELDS[i];
1397
+ if (a[k] !== b[k]) { ch[k] = { from: a[k], to: b[k] }; n += 1; }
888
1398
  }
889
- return -1;
1399
+ for (i = 0; i < __DIFF_TIME_FIELDS.length; i++) {
1400
+ k = __DIFF_TIME_FIELDS[i];
1401
+ if (__diffMoved(a[k], b[k])) { ch[k] = { from: a[k], to: b[k] }; n += 1; }
1402
+ }
1403
+ for (i = 0; i < __DIFF_COUNT_FIELDS.length; i++) {
1404
+ k = __DIFF_COUNT_FIELDS[i];
1405
+ if (a[k] !== b[k]) { ch[k] = { from: a[k], to: b[k] }; n += 1; }
1406
+ }
1407
+ if (a.parentId !== b.parentId) { ch.parentId = { from: a.parentId, to: b.parentId }; n += 1; }
1408
+ var kc = __diffKeyCounts(a.keyCounts || {}, b.keyCounts || {});
1409
+ if (kc) { ch.keyCounts = kc; n += 1; }
1410
+ if (n === 0) return null;
1411
+ return { id: b.id, name: b.name, changes: ch };
890
1412
  }
891
1413
 
892
- function __applyInterpolationToKey(prop, keyIndex, interp) {
893
- if (!interp) return;
894
- var inT = interp["in"] && __INTERP_MAP[interp["in"]] ? __INTERP_MAP[interp["in"]] : prop.keyInInterpolationType(keyIndex);
895
- var outT = interp["out"] && __INTERP_MAP[interp["out"]] ? __INTERP_MAP[interp["out"]] : prop.keyOutInterpolationType(keyIndex);
896
- prop.setInterpolationTypeAtKey(keyIndex, inT, outT);
897
- if (interp.easeIn || interp.easeOut) {
898
- // AE's setTemporalEaseAtKey expects an array of KeyframeEase per dimension —
899
- // BUT spatial properties (Position, Anchor Point) use a single ease entry that
900
- // applies along the motion path, regardless of 2D/3D. Non-spatial multi-dim
901
- // properties (Scale, Color) need one entry per dimension.
902
- var dim;
903
- if (prop.isSpatial) {
904
- dim = 1;
905
- } else {
906
- dim = (prop.value && prop.value.length) ? prop.value.length : 1;
907
- }
908
- var inEase = interp.easeIn || { influence: 33, speed: 0 };
909
- var outEase = interp.easeOut || { influence: 33, speed: 0 };
910
- var inArr = []; var outArr = [];
911
- for (var d = 0; d < dim; d++) {
912
- inArr.push(new KeyframeEase(inEase.speed, inEase.influence));
913
- outArr.push(new KeyframeEase(outEase.speed, outEase.influence));
914
- }
915
- prop.setTemporalEaseAtKey(keyIndex, inArr, outArr);
1414
+ // Only layers present on both sides, and only when their order relative to one
1415
+ // another actually changed. A layer added or removed elsewhere in the stack is
1416
+ // not a reorder of anything.
1417
+ function __diffReordered(before, after, aMap, bMap) {
1418
+ var seqA = [];
1419
+ var seqB = [];
1420
+ var i;
1421
+ for (i = 0; i < before.layers.length; i++) {
1422
+ var idA = before.layers[i].id;
1423
+ if (bMap.hasOwnProperty("L" + idA)) seqA.push(idA);
1424
+ }
1425
+ for (i = 0; i < after.layers.length; i++) {
1426
+ var idB = after.layers[i].id;
1427
+ if (aMap.hasOwnProperty("L" + idB)) seqB.push(idB);
916
1428
  }
1429
+ if (seqA.length !== seqB.length) return null;
1430
+ var same = true;
1431
+ for (i = 0; i < seqA.length; i++) {
1432
+ if (seqA[i] !== seqB[i]) { same = false; break; }
1433
+ }
1434
+ if (same) return null;
1435
+ var posA = {};
1436
+ for (i = 0; i < seqA.length; i++) posA["L" + seqA[i]] = i;
1437
+ var moved = [];
1438
+ for (i = 0; i < seqB.length; i++) {
1439
+ var id = seqB[i];
1440
+ if (posA["L" + id] === i) continue;
1441
+ moved.push({
1442
+ id: id,
1443
+ name: bMap["L" + id].name,
1444
+ fromIndex: aMap["L" + id].index,
1445
+ toIndex: bMap["L" + id].index
1446
+ });
1447
+ }
1448
+ if (moved.length === 0) return null;
1449
+ return moved;
917
1450
  }
918
1451
 
919
- OPS.add_keyframe = function (args) {
920
- var c = getCompById(args.compId);
921
- var l = getLayerById(c, args.layerId);
922
- var prop = walkProperty(l, args.propertyPath);
923
- prop.setValueAtTime(args.time, args.value);
924
- if (args.interpolation) {
925
- var idx = __findKeyIndexAtTime(prop, args.time);
926
- if (idx > 0) __applyInterpolationToKey(prop, idx, args.interpolation);
1452
+ function __diffMarkers(a, b) {
1453
+ if (a.length !== b.length) return { from: a.length, to: b.length };
1454
+ for (var i = 0; i < a.length; i++) {
1455
+ if (a[i] !== b[i]) return { count: a.length, edited: true };
927
1456
  }
928
- return { ok: true, keyIndex: __findKeyIndexAtTime(prop, args.time) };
929
- };
1457
+ return null;
1458
+ }
930
1459
 
931
- OPS.remove_keyframe = function (args) {
932
- var c = getCompById(args.compId);
933
- var l = getLayerById(c, args.layerId);
934
- var prop = walkProperty(l, args.propertyPath);
935
- var idx = __findKeyIndexAtTime(prop, args.time);
936
- if (idx < 1) throw new Error("No keyframe at time " + args.time);
937
- prop.removeKey(idx);
938
- return { ok: true };
939
- };
1460
+ function __diffCompFields(a, b) {
1461
+ var ch = {};
1462
+ var n = 0;
1463
+ if (a.name !== b.name) { ch.name = { from: a.name, to: b.name }; n += 1; }
1464
+ if (a.width !== b.width || a.height !== b.height) {
1465
+ ch.size = { from: [a.width, a.height], to: [b.width, b.height] };
1466
+ n += 1;
1467
+ }
1468
+ if (__diffMoved(a.duration, b.duration)) { ch.duration = { from: a.duration, to: b.duration }; n += 1; }
1469
+ if (__diffMoved(a.frameRate, b.frameRate)) { ch.frameRate = { from: a.frameRate, to: b.frameRate }; n += 1; }
1470
+ if (__diffMoved(a.workAreaStart, b.workAreaStart) || __diffMoved(a.workAreaDuration, b.workAreaDuration)) {
1471
+ ch.workArea = {
1472
+ from: [a.workAreaStart, a.workAreaDuration],
1473
+ to: [b.workAreaStart, b.workAreaDuration]
1474
+ };
1475
+ n += 1;
1476
+ }
1477
+ var mk = __diffMarkers(a.markers || [], b.markers || []);
1478
+ if (mk) { ch.markers = mk; n += 1; }
1479
+ if (n === 0) return null;
1480
+ return ch;
1481
+ }
940
1482
 
941
- OPS.get_keyframes = noUndo(function (args) {
942
- var c = getCompById(args.compId);
943
- var l = getLayerById(c, args.layerId);
944
- var prop = walkProperty(l, args.propertyPath);
945
- var keys = [];
946
- for (var i = 1; i <= prop.numKeys; i++) {
947
- var ease = null;
948
- try {
949
- var inE = prop.keyInTemporalEase(i);
950
- var outE = prop.keyOutTemporalEase(i);
951
- ease = {
952
- easeIn: { influence: inE[0].influence, speed: inE[0].speed },
953
- easeOut: { influence: outE[0].influence, speed: outE[0].speed },
954
- };
955
- } catch (e) {}
956
- var tangents = null;
957
- if (prop.isSpatial) {
958
- try { tangents = { inTangent: prop.keyInSpatialTangent(i), outTangent: prop.keyOutSpatialTangent(i) }; }
959
- catch (e2) {}
1483
+ // ---------- summary prose ----------
1484
+
1485
+ function __diffPlural(n) {
1486
+ if (n === 1) return "";
1487
+ return "s";
1488
+ }
1489
+
1490
+ // "512-514" for a run, "512, 517" otherwise. Ids come out of AE in creation
1491
+ // order, so a batch of new layers is nearly always consecutive and this is the
1492
+ // difference between a readable line and a wall of numbers.
1493
+ function __diffIdList(ids) {
1494
+ if (!ids.length) return "";
1495
+ var sorted = [];
1496
+ for (var i = 0; i < ids.length; i++) sorted.push(ids[i]);
1497
+ sorted.sort(function (x, y) { return x - y; });
1498
+ var parts = [];
1499
+ var start = sorted[0];
1500
+ var prev = sorted[0];
1501
+ for (var j = 1; j <= sorted.length; j++) {
1502
+ var cur = sorted[j];
1503
+ var breaks = true;
1504
+ if (j < sorted.length && cur === prev + 1) breaks = false;
1505
+ if (breaks) {
1506
+ if (start === prev) parts.push(String(start));
1507
+ else if (prev === start + 1) parts.push(String(start) + ", " + String(prev));
1508
+ else parts.push(String(start) + "-" + String(prev));
1509
+ start = cur;
960
1510
  }
961
- keys.push({
962
- index: i,
963
- time: prop.keyTime(i),
964
- value: prop.keyValue(i),
965
- interpolation: {
966
- "in": __invInterp(prop.keyInInterpolationType(i)),
967
- "out": __invInterp(prop.keyOutInterpolationType(i)),
968
- },
969
- ease: ease,
970
- tangents: tangents,
971
- });
1511
+ prev = cur;
972
1512
  }
973
- return keys;
974
- });
1513
+ return parts.join(", ");
1514
+ }
975
1515
 
976
- function __invInterp(t) {
977
- if (t === KeyframeInterpolationType.LINEAR) return "linear";
978
- if (t === KeyframeInterpolationType.BEZIER) return "bezier";
979
- if (t === KeyframeInterpolationType.HOLD) return "hold";
980
- return "unknown";
1516
+ function __diffParentLabel(v) {
1517
+ if (v === null || v === undefined) return "none";
1518
+ return String(v);
981
1519
  }
982
1520
 
983
- OPS.set_interpolation = function (args) {
984
- var c = getCompById(args.compId);
985
- var l = getLayerById(c, args.layerId);
1521
+ function __diffLayerPhrases(entry) {
1522
+ var label = "layer " + entry.id;
1523
+ var c = entry.changes;
1524
+ var out = [];
1525
+ if (c.name) out.push(label + ' renamed "' + c.name.from + '" -> "' + c.name.to + '"');
1526
+ if (c.enabled) {
1527
+ if (c.enabled.to) out.push(label + " enabled");
1528
+ else out.push(label + " disabled");
1529
+ }
1530
+ var times = [];
1531
+ if (c.inPoint) times.push("in " + c.inPoint.from + " -> " + c.inPoint.to);
1532
+ if (c.outPoint) times.push("out " + c.outPoint.from + " -> " + c.outPoint.to);
1533
+ if (c.startTime) times.push("start " + c.startTime.from + " -> " + c.startTime.to);
1534
+ if (times.length) out.push(label + " retimed (" + times.join(", ") + ")");
1535
+ if (c.parentId) {
1536
+ out.push(label + " re-parented " + __diffParentLabel(c.parentId.from) + " -> " + __diffParentLabel(c.parentId.to));
1537
+ }
1538
+ if (c.keyCounts) {
1539
+ for (var k in c.keyCounts) {
1540
+ if (!c.keyCounts.hasOwnProperty(k)) continue;
1541
+ out.push(label + " " + k + " keys " + c.keyCounts[k].from + " -> " + c.keyCounts[k].to);
1542
+ }
1543
+ }
1544
+ if (c.effectCount) out.push(label + " effects " + c.effectCount.from + " -> " + c.effectCount.to);
1545
+ if (c.expressionCount) {
1546
+ out.push(label + " expressions " + c.expressionCount.from + " -> " + c.expressionCount.to);
1547
+ }
1548
+ if (c.type) out.push(label + " type " + c.type.from + " -> " + c.type.to);
1549
+ return out;
1550
+ }
1551
+
1552
+ function __diffCompPhrases(ch) {
1553
+ var out = [];
1554
+ if (ch.name) out.push('comp renamed "' + ch.name.from + '" -> "' + ch.name.to + '"');
1555
+ if (ch.size) {
1556
+ out.push("comp resized " + ch.size.from[0] + "x" + ch.size.from[1] + " -> " + ch.size.to[0] + "x" + ch.size.to[1]);
1557
+ }
1558
+ if (ch.duration) out.push("comp duration " + ch.duration.from + " -> " + ch.duration.to);
1559
+ if (ch.frameRate) out.push("comp frame rate " + ch.frameRate.from + " -> " + ch.frameRate.to);
1560
+ if (ch.workArea) out.push("comp work area moved");
1561
+ if (ch.markers) {
1562
+ if (ch.markers.edited) out.push("comp markers edited");
1563
+ else out.push("comp markers " + ch.markers.from + " -> " + ch.markers.to);
1564
+ }
1565
+ return out;
1566
+ }
1567
+
1568
+ // One line. Past this many clauses the rest is counted rather than spelled out
1569
+ // — the structured fields carry everything, and a summary nobody reads to the
1570
+ // end is worse than a short one that names where to look.
1571
+ var __DIFF_SUMMARY_CLAUSES = 8;
1572
+
1573
+ function __diffSummary(d) {
1574
+ var parts = [];
1575
+ var i;
1576
+ if (d.added) {
1577
+ var addedIds = [];
1578
+ for (i = 0; i < d.added.length; i++) addedIds.push(d.added[i].id);
1579
+ parts.push(d.added.length + " layer" + __diffPlural(d.added.length) + " added: ids " + __diffIdList(addedIds));
1580
+ }
1581
+ if (d.removed) {
1582
+ var removedIds = [];
1583
+ for (i = 0; i < d.removed.length; i++) removedIds.push(d.removed[i].id);
1584
+ parts.push(d.removed.length + " layer" + __diffPlural(d.removed.length) + " removed: ids " + __diffIdList(removedIds));
1585
+ }
1586
+ if (d.reordered) {
1587
+ parts.push(d.reordered.length + " layer" + __diffPlural(d.reordered.length) + " moved in the stack");
1588
+ }
1589
+ if (d.changed) {
1590
+ for (i = 0; i < d.changed.length; i++) {
1591
+ var phrases = __diffLayerPhrases(d.changed[i]);
1592
+ for (var p = 0; p < phrases.length; p++) parts.push(phrases[p]);
1593
+ }
1594
+ }
1595
+ if (d.comp) {
1596
+ var compPhrases = __diffCompPhrases(d.comp);
1597
+ for (i = 0; i < compPhrases.length; i++) parts.push(compPhrases[i]);
1598
+ }
1599
+ if (parts.length === 0) {
1600
+ return "No differences in the recorded fields. Property values, expression text, effect parameters " +
1601
+ "and shape contents are not compared, so this is not a claim that nothing changed at all.";
1602
+ }
1603
+ if (parts.length > __DIFF_SUMMARY_CLAUSES) {
1604
+ var extra = parts.length - __DIFF_SUMMARY_CLAUSES;
1605
+ parts = parts.slice(0, __DIFF_SUMMARY_CLAUSES);
1606
+ parts.push("and " + extra + " more change" + __diffPlural(extra) + " (see the fields below)");
1607
+ }
1608
+ return parts.join("; ");
1609
+ }
1610
+
1611
+ // Two fingerprints -> only what moved. Unchanged layers are counted, never
1612
+ // listed: listing them is the cost this whole thing exists to avoid.
1613
+ function __diffFingerprints(before, after) {
1614
+ var aMap = __fpById(before);
1615
+ var bMap = __fpById(after);
1616
+ var added = [];
1617
+ var removed = [];
1618
+ var changed = [];
1619
+ var unchanged = 0;
1620
+ var i, l;
1621
+ for (i = 0; i < after.layers.length; i++) {
1622
+ l = after.layers[i];
1623
+ var prev = aMap["L" + l.id];
1624
+ if (!prev) {
1625
+ added.push({ id: l.id, name: l.name, index: l.index, type: l.type });
1626
+ continue;
1627
+ }
1628
+ var d = __diffLayer(prev, l);
1629
+ if (d) changed.push(d);
1630
+ else unchanged += 1;
1631
+ }
1632
+ for (i = 0; i < before.layers.length; i++) {
1633
+ l = before.layers[i];
1634
+ if (bMap.hasOwnProperty("L" + l.id)) continue;
1635
+ removed.push({ id: l.id, name: l.name, index: l.index, type: l.type });
1636
+ }
1637
+ var reordered = __diffReordered(before, after, aMap, bMap);
1638
+ var comp = __diffCompFields(before, after);
1639
+
1640
+ var out = { compId: after.compId, compName: after.name };
1641
+ if (added.length) out.added = added;
1642
+ if (removed.length) out.removed = removed;
1643
+ if (changed.length) out.changed = changed;
1644
+ if (reordered) out.reordered = reordered;
1645
+ if (comp) out.comp = comp;
1646
+ out.unchangedLayers = unchanged;
1647
+ var count = added.length + removed.length + changed.length;
1648
+ if (reordered) count += reordered.length;
1649
+ if (comp) count += 1;
1650
+ out.changeCount = count;
1651
+ out.summary = __diffSummary(out);
1652
+ out.covers = __DIFF_COVERS;
1653
+ return out;
1654
+ }
1655
+
1656
+ // ---------------------------------------------------------------------------
1657
+ // Ops
1658
+ // ---------------------------------------------------------------------------
1659
+ // Both are internal: the tools an agent sees are snapshot_comp and diff_comp,
1660
+ // and those are half server-resident. Only the panel can read After Effects,
1661
+ // and only the server can remember anything between calls — so the server
1662
+ // keeps the fingerprint and these two gather it. See snapshots/store.ts.
1663
+
1664
+ OPS._comp_fingerprint = noUndo(function (args) {
1665
+ return __compFingerprint(args.compId);
1666
+ });
1667
+
1668
+ OPS._comp_diff = noUndo(function (args) {
1669
+ var before = args.since;
1670
+ if (!before || !before.layers) {
1671
+ throw new Error("_comp_diff needs a stored fingerprint in `since` (the MCP server supplies it from the snapshot id)");
1672
+ }
1673
+ var compId = args.compId;
1674
+ if (compId === undefined || compId === null) compId = before.compId;
1675
+ var after = __compFingerprint(compId);
1676
+ return { diff: __diffFingerprints(before, after), fingerprint: after };
1677
+ });
1678
+
1679
+ // ---------------------------------------------------------------------------
1680
+ // diff:true on the write ops
1681
+ // ---------------------------------------------------------------------------
1682
+ // The before-fingerprint has to be taken inside the same bridge call as the
1683
+ // write, or it is not a before at all: a separate snapshot_comp is a second
1684
+ // round-trip during which anything can happen, and the agent has to remember to
1685
+ // make it. run_jsx and run_batch therefore fingerprint, run, fingerprint again,
1686
+ // and diff — one call, one answer.
1687
+
1688
+ function __diffPushUnique(list, v) {
1689
+ for (var i = 0; i < list.length; i++) {
1690
+ if (list[i] === v) return;
1691
+ }
1692
+ list.push(v);
1693
+ }
1694
+
1695
+ // Explicit diffCompId wins. Otherwise a batch names its own comps in its ops,
1696
+ // and a script gets the comp the user is looking at. When none of the three
1697
+ // yields anything the diff is refused with a reason rather than quietly
1698
+ // skipped — a missing diff that looks like "nothing changed" is the failure
1699
+ // this whole feature exists to prevent.
1700
+ function __diffCompIds(args, ops) {
1701
+ var ids = [];
1702
+ if (args && args.diffCompId !== undefined && args.diffCompId !== null) {
1703
+ __diffPushUnique(ids, args.diffCompId);
1704
+ return ids;
1705
+ }
1706
+ if (ops) {
1707
+ for (var i = 0; i < ops.length; i++) {
1708
+ var a = ops[i].args;
1709
+ if (a && typeof a.compId === "number") __diffPushUnique(ids, a.compId);
1710
+ }
1711
+ if (ids.length > 0) return ids;
1712
+ }
1713
+ var active = null;
1714
+ try { active = app.project.activeItem; } catch (e) {}
1715
+ if (active && active instanceof CompItem) __diffPushUnique(ids, active.id);
1716
+ return ids;
1717
+ }
1718
+
1719
+ function __diffStart(args, ops) {
1720
+ if (!args || !args.diff) return null;
1721
+ var ids = __diffCompIds(args, ops);
1722
+ if (ids.length === 0) {
1723
+ return {
1724
+ unavailable: true,
1725
+ reason: "no compId appeared in this call and no composition is open in the viewer — pass diffCompId to name the comp to fingerprint"
1726
+ };
1727
+ }
1728
+ var state = { ids: ids, before: [], unavailable: false };
1729
+ for (var i = 0; i < ids.length; i++) {
1730
+ try {
1731
+ state.before.push(__compFingerprint(ids[i]));
1732
+ } catch (e) {
1733
+ return { unavailable: true, reason: "comp " + ids[i] + " could not be fingerprinted before the call: " + e.message };
1734
+ }
1735
+ }
1736
+ return state;
1737
+ }
1738
+
1739
+ function __diffFinish(state) {
1740
+ if (!state) return null;
1741
+ if (state.unavailable) {
1742
+ return {
1743
+ unavailable: true,
1744
+ reason: state.reason,
1745
+ summary: "No diff was taken: " + state.reason + ". The call itself is unaffected."
1746
+ };
1747
+ }
1748
+ var comps = [];
1749
+ var total = 0;
1750
+ for (var i = 0; i < state.before.length; i++) {
1751
+ var b = state.before[i];
1752
+ var after = null;
1753
+ try {
1754
+ after = __compFingerprint(b.compId);
1755
+ } catch (e) {
1756
+ comps.push({
1757
+ compId: b.compId,
1758
+ compName: b.name,
1759
+ gone: true,
1760
+ changeCount: 1,
1761
+ summary: "comp " + b.compId + ' ("' + b.name + '") can no longer be read: ' + e.message
1762
+ });
1763
+ total += 1;
1764
+ continue;
1765
+ }
1766
+ var d = __diffFingerprints(b, after);
1767
+ comps.push(d);
1768
+ total += d.changeCount;
1769
+ }
1770
+ if (comps.length === 1) return comps[0];
1771
+ var parts = [];
1772
+ for (var j = 0; j < comps.length; j++) parts.push("comp " + comps[j].compId + ": " + comps[j].summary);
1773
+ return { comps: comps, changeCount: total, summary: parts.join(" | "), covers: __DIFF_COVERS };
1774
+ }
1775
+
1776
+ // A script or batch that threw still changed whatever it changed before it
1777
+ // stopped, and nothing rolls back. Finding that stop point by reading the comp
1778
+ // back is one of the three cases issue #52 was opened for, so the diff is put
1779
+ // where the agent will actually see it: on the error. The error object itself
1780
+ // is mutated rather than replaced, so `line` and `stack` survive for the
1781
+ // caller's own reporting.
1782
+ function __diffAnnotateError(e, state) {
1783
+ if (!state) return;
1784
+ var d = null;
1785
+ try { d = __diffFinish(state); } catch (e2) { return; }
1786
+ if (!d || !d.summary) return;
1787
+ try {
1788
+ e.message = String(e.message) + " || Changed before it stopped: " + d.summary +
1789
+ " (nothing rolls back - read the state back rather than re-running)";
1790
+ } catch (e3) {}
1791
+ }
1792
+
1793
+ // run_jsx returns whatever the script returned, which may be a number or a
1794
+ // string with nowhere to hang a diff on. With diff:true it is enveloped
1795
+ // instead. The null envelope __rjResult already builds is the right shape
1796
+ // already, so that one is extended rather than nested inside a second one.
1797
+ function __rjWithDiff(out, diff, undoGroupName) {
1798
+ if (out && typeof out === "object" && !(out instanceof Array) &&
1799
+ out.ok === true && out.returned === null && out.note) {
1800
+ out.diff = diff;
1801
+ return out;
1802
+ }
1803
+ return { ok: true, returned: out, undoGroup: undoGroupName, diff: diff };
1804
+ }
1805
+
1806
+
1807
+ // ===== transforms.jsx =====
1808
+
1809
+ // transforms.jsx — fast-path for setting common transform properties.
1810
+
1811
+ function __setOrKeyAtTime(prop, value, time, keyframe) {
1812
+ if (keyframe && time !== undefined && time !== null) {
1813
+ prop.setValueAtTime(time, value);
1814
+ } else if (time !== undefined && time !== null && !keyframe) {
1815
+ // Set value at time only meaningful if property has keyframes; else set static.
1816
+ if (prop.numKeys > 0) prop.setValueAtTime(time, value);
1817
+ else prop.setValue(value);
1818
+ } else {
1819
+ prop.setValue(value);
1820
+ }
1821
+ }
1822
+
1823
+ OPS.set_transform = function (args) {
1824
+ var c = getCompById(args.compId);
1825
+ var l = getLayerById(c, args.layerId);
1826
+ var t = args.time;
1827
+ var kf = !!args.keyframe;
1828
+ var tr = l.property("Transform");
1829
+ var p = args.properties || {};
1830
+ if (p.position !== undefined) __setOrKeyAtTime(tr.property("Position"), p.position, t, kf);
1831
+ if (p.scale !== undefined) __setOrKeyAtTime(tr.property("Scale"), p.scale, t, kf);
1832
+ if (p.rotation !== undefined) {
1833
+ var rotProp = l.threeDLayer ? tr.property("Z Rotation") : tr.property("Rotation");
1834
+ __setOrKeyAtTime(rotProp, p.rotation, t, kf);
1835
+ }
1836
+ if (p.anchorPoint !== undefined) __setOrKeyAtTime(tr.property("Anchor Point"), p.anchorPoint, t, kf);
1837
+ if (p.opacity !== undefined) __setOrKeyAtTime(tr.property("Opacity"), p.opacity, t, kf);
1838
+ if (p.orientation !== undefined && l.threeDLayer) __setOrKeyAtTime(tr.property("Orientation"), p.orientation, t, kf);
1839
+ if (p.xRotation !== undefined && l.threeDLayer) __setOrKeyAtTime(tr.property("X Rotation"), p.xRotation, t, kf);
1840
+ if (p.yRotation !== undefined && l.threeDLayer) __setOrKeyAtTime(tr.property("Y Rotation"), p.yRotation, t, kf);
1841
+ if (p.zRotation !== undefined && l.threeDLayer) __setOrKeyAtTime(tr.property("Z Rotation"), p.zRotation, t, kf);
1842
+ return { ok: true };
1843
+ };
1844
+
1845
+
1846
+ // ===== keyframes.jsx =====
1847
+
1848
+ // keyframes.jsx — generic keyframe ops.
1849
+
1850
+ var __INTERP_MAP = {
1851
+ linear: KeyframeInterpolationType.LINEAR,
1852
+ bezier: KeyframeInterpolationType.BEZIER,
1853
+ hold: KeyframeInterpolationType.HOLD,
1854
+ };
1855
+
1856
+ function __findKeyIndexAtTime(prop, time, eps) {
1857
+ eps = eps || 0.001;
1858
+ for (var i = 1; i <= prop.numKeys; i++) {
1859
+ if (Math.abs(prop.keyTime(i) - time) < eps) return i;
1860
+ }
1861
+ return -1;
1862
+ }
1863
+
1864
+ // ---------- ease array sizing (issue #50) ----------
1865
+ //
1866
+ // setTemporalEaseAtKey wants an array of KeyframeEase whose length belongs to
1867
+ // the *property*, and it is not derivable from the value: a 2D layer Scale
1868
+ // wants 3, a shape Ellipse Size wants 2 while its value reads [w,h], Opacity
1869
+ // and a slider want 1, and a spatial Position wants 1 whether the layer is 2D
1870
+ // or 3D because the ease runs along the motion path. Get it wrong and AE throws
1871
+ // "parameter 2" and says nothing else — no property name, no expected count.
1872
+ //
1873
+ // So: derive the likely count from the property, then try the others. The
1874
+ // derivation is the fast path; the retry is the safety net, because the Ellipse
1875
+ // Size case is precisely the one no table gets right from the outside. Whatever
1876
+ // AE accepted is reported back, so the answer for a given property stops being
1877
+ // folklore and becomes something a caller can read off a result.
1878
+ var __EASE_ARITIES = [1, 2, 3, 4];
1879
+
1880
+ /** The count to try first, from the property rather than from the value passed in. */
1881
+ function __easePropertyArity(prop) {
1882
+ // Spatial first and unconditionally: a 3D Position is ThreeD_SPATIAL and
1883
+ // still takes one entry, so the value type must not get a say here.
1884
+ if (prop.isSpatial) return 1;
1885
+ var vt = null;
1886
+ try { vt = prop.propertyValueType; } catch (eType) {}
1887
+ if (vt !== null && vt !== undefined && typeof PropertyValueType !== "undefined") {
1888
+ if (vt === PropertyValueType.OneD) return 1;
1889
+ if (vt === PropertyValueType.TwoD) return 2;
1890
+ if (vt === PropertyValueType.TwoD_SPATIAL) return 1;
1891
+ if (vt === PropertyValueType.ThreeD) return 3;
1892
+ if (vt === PropertyValueType.ThreeD_SPATIAL) return 1;
1893
+ if (vt === PropertyValueType.COLOR) return 4;
1894
+ }
1895
+ var v = null;
1896
+ try { v = prop.value; } catch (eVal) {}
1897
+ if (v && v.length) return v.length;
1898
+ return 1;
1899
+ }
1900
+
1901
+ /** Derived count first, then every other plausible one, no repeats. */
1902
+ function __easeArityCandidates(prop) {
1903
+ var first = __easePropertyArity(prop);
1904
+ var out = [first];
1905
+ for (var i = 0; i < __EASE_ARITIES.length; i++) {
1906
+ if (__EASE_ARITIES[i] !== first) out.push(__EASE_ARITIES[i]);
1907
+ }
1908
+ return out;
1909
+ }
1910
+
1911
+ function __easeArray(ease, n) {
1912
+ var arr = [];
1913
+ for (var i = 0; i < n; i++) arr.push(new KeyframeEase(ease.speed, ease.influence));
1914
+ return arr;
1915
+ }
1916
+
1917
+ /**
1918
+ * Apply one {influence, speed} pair to every dimension of `prop` at `keyIndex`,
1919
+ * and return the number of KeyframeEase entries After Effects accepted.
1920
+ *
1921
+ * Throws only when no count works, naming every one it tried. An ease that
1922
+ * quietly failed to land is invisible until someone watches the render, which
1923
+ * is the same class of lie as a swallowed error.
1924
+ */
1925
+ function __applyTemporalEase(prop, keyIndex, easeIn, easeOut) {
1926
+ var inEase = easeIn || { influence: 33, speed: 0 };
1927
+ var outEase = easeOut || { influence: 33, speed: 0 };
1928
+ var candidates = __easeArityCandidates(prop);
1929
+ var lastMessage = "";
1930
+ for (var i = 0; i < candidates.length; i++) {
1931
+ var n = candidates[i];
1932
+ try {
1933
+ prop.setTemporalEaseAtKey(keyIndex, __easeArray(inEase, n), __easeArray(outEase, n));
1934
+ return n;
1935
+ } catch (e) {
1936
+ lastMessage = (e && e.message) ? String(e.message) : String(e);
1937
+ }
1938
+ }
1939
+ var label = "the property";
1940
+ try { label = "'" + prop.name + "'"; } catch (eName) {}
1941
+ throw new Error(
1942
+ "Could not set the temporal ease on " + label + " at key " + keyIndex + ": After Effects rejected ease " +
1943
+ "arrays of " + candidates.join(", ") + " entries. Last error from AE: " + lastMessage
1944
+ );
1945
+ }
1946
+
1947
+ /** Returns the ease arity that was used, or null when no ease was requested. */
1948
+ function __applyInterpolationToKey(prop, keyIndex, interp) {
1949
+ if (!interp) return null;
1950
+ var inT = interp["in"] && __INTERP_MAP[interp["in"]] ? __INTERP_MAP[interp["in"]] : prop.keyInInterpolationType(keyIndex);
1951
+ var outT = interp["out"] && __INTERP_MAP[interp["out"]] ? __INTERP_MAP[interp["out"]] : prop.keyOutInterpolationType(keyIndex);
1952
+ prop.setInterpolationTypeAtKey(keyIndex, inT, outT);
1953
+ if (interp.easeIn || interp.easeOut) {
1954
+ return __applyTemporalEase(prop, keyIndex, interp.easeIn, interp.easeOut);
1955
+ }
1956
+ return null;
1957
+ }
1958
+
1959
+ OPS.add_keyframe = function (args) {
1960
+ var c = getCompById(args.compId);
1961
+ var l = getLayerById(c, args.layerId);
1962
+ var prop = walkProperty(l, args.propertyPath);
1963
+ prop.setValueAtTime(args.time, args.value);
1964
+ var easeDimensions = null;
1965
+ if (args.interpolation) {
1966
+ var idx = __findKeyIndexAtTime(prop, args.time);
1967
+ if (idx > 0) easeDimensions = __applyInterpolationToKey(prop, idx, args.interpolation);
1968
+ }
1969
+ var out = { ok: true, keyIndex: __findKeyIndexAtTime(prop, args.time) };
1970
+ if (easeDimensions !== null) out.easeDimensions = easeDimensions;
1971
+ return out;
1972
+ };
1973
+
1974
+ OPS.remove_keyframe = function (args) {
1975
+ var c = getCompById(args.compId);
1976
+ var l = getLayerById(c, args.layerId);
1977
+ var prop = walkProperty(l, args.propertyPath);
1978
+ var idx = __findKeyIndexAtTime(prop, args.time);
1979
+ if (idx < 1) throw new Error("No keyframe at time " + args.time);
1980
+ prop.removeKey(idx);
1981
+ return { ok: true };
1982
+ };
1983
+
1984
+ OPS.get_keyframes = noUndo(function (args) {
1985
+ var c = getCompById(args.compId);
1986
+ var l = getLayerById(c, args.layerId);
1987
+ var prop = walkProperty(l, args.propertyPath);
1988
+ var keys = [];
1989
+ for (var i = 1; i <= prop.numKeys; i++) {
1990
+ var ease = null;
1991
+ try {
1992
+ var inE = prop.keyInTemporalEase(i);
1993
+ var outE = prop.keyOutTemporalEase(i);
1994
+ ease = {
1995
+ easeIn: { influence: inE[0].influence, speed: inE[0].speed },
1996
+ easeOut: { influence: outE[0].influence, speed: outE[0].speed },
1997
+ };
1998
+ } catch (e) {}
1999
+ var tangents = null;
2000
+ if (prop.isSpatial) {
2001
+ try { tangents = { inTangent: prop.keyInSpatialTangent(i), outTangent: prop.keyOutSpatialTangent(i) }; }
2002
+ catch (e2) {}
2003
+ }
2004
+ keys.push({
2005
+ index: i,
2006
+ time: prop.keyTime(i),
2007
+ value: prop.keyValue(i),
2008
+ interpolation: {
2009
+ "in": __invInterp(prop.keyInInterpolationType(i)),
2010
+ "out": __invInterp(prop.keyOutInterpolationType(i)),
2011
+ },
2012
+ ease: ease,
2013
+ tangents: tangents,
2014
+ });
2015
+ }
2016
+ return keys;
2017
+ });
2018
+
2019
+ function __invInterp(t) {
2020
+ if (t === KeyframeInterpolationType.LINEAR) return "linear";
2021
+ if (t === KeyframeInterpolationType.BEZIER) return "bezier";
2022
+ if (t === KeyframeInterpolationType.HOLD) return "hold";
2023
+ return "unknown";
2024
+ }
2025
+
2026
+ OPS.set_interpolation = function (args) {
2027
+ var c = getCompById(args.compId);
2028
+ var l = getLayerById(c, args.layerId);
986
2029
  var prop = walkProperty(l, args.propertyPath);
987
2030
  __applyInterpolationToKey(prop, args.keyIndex, { "in": args["in"], "out": args["out"] });
988
2031
  return { ok: true };
@@ -992,8 +2035,13 @@ OPS.set_temporal_ease = function (args) {
992
2035
  var c = getCompById(args.compId);
993
2036
  var l = getLayerById(c, args.layerId);
994
2037
  var prop = walkProperty(l, args.propertyPath);
995
- __applyInterpolationToKey(prop, args.keyIndex, { easeIn: args.easeIn, easeOut: args.easeOut });
996
- return { ok: true };
2038
+ // A call with neither ease would previously return {ok:true} having done
2039
+ // nothing at all, which reads as "the ease is set" to whoever asked for it.
2040
+ if (!args.easeIn && !args.easeOut) {
2041
+ throw new Error("set_temporal_ease needs easeIn, easeOut or both — nothing was changed.");
2042
+ }
2043
+ var n = __applyTemporalEase(prop, args.keyIndex, args.easeIn, args.easeOut);
2044
+ return { ok: true, easeDimensions: n };
997
2045
  };
998
2046
 
999
2047
  OPS.set_spatial_tangents = function (args) {
@@ -1010,11 +2058,54 @@ OPS.set_spatial_tangents = function (args) {
1010
2058
 
1011
2059
  // expressions.jsx — get/set/toggle/clear expressions on any property.
1012
2060
 
2061
+ // After Effects reports a broken expression on `Property.expressionError` and
2062
+ // nowhere a script can otherwise see: assigning `.expression` succeeds whatever
2063
+ // the text says, and the failure is a warning banner in the UI (issue #97).
2064
+ // The field is filled in when the expression is *evaluated*, which a bare
2065
+ // assignment does not necessarily do — so the value is read first, inside a
2066
+ // try, purely to force one evaluation. Whether AE would populate the field
2067
+ // without that nudge cannot be settled offline; forcing it costs one evaluation
2068
+ // and makes the answer independent of the question. `.value` throws on a
2069
+ // property that has none (a group), which is also not an expression error.
2070
+ function __expressionError(prop) {
2071
+ try { var forced = prop.value; } catch (e) {}
2072
+ var err = "";
2073
+ try { err = prop.expressionError; } catch (e2) { err = ""; }
2074
+ return err ? String(err) : "";
2075
+ }
2076
+
2077
+ function __propertyPathLabel(path) {
2078
+ var parts = [];
2079
+ for (var i = 0; i < path.length; i++) parts.push(String(path[i]));
2080
+ return parts.join(" > ");
2081
+ }
2082
+
2083
+ // Throws if the expression on `prop` does not evaluate, naming the property,
2084
+ // AE's own message and both ways forward. The expression stays written — the
2085
+ // message says so — because nothing here rolls back and a caller fixing a
2086
+ // typo wants the property it was aimed at, not a cleared one.
2087
+ function __assertExpressionEvaluates(prop, path, verb) {
2088
+ var err = __expressionError(prop);
2089
+ if (!err) return;
2090
+ var enabledNow;
2091
+ try { enabledNow = !!prop.expressionEnabled; } catch (e) { enabledNow = null; }
2092
+ throw new Error(
2093
+ "Expression on " + __propertyPathLabel(path) + " was " + verb +
2094
+ " but After Effects cannot evaluate it: " + err +
2095
+ (enabledNow === false ? " (After Effects has disabled it.)" : "") +
2096
+ " Fix the expression and call set_expression again, or clear_expression to remove it."
2097
+ );
2098
+ }
2099
+
1013
2100
  OPS.get_expression = noUndo(function (args) {
1014
2101
  var c = getCompById(args.compId);
1015
2102
  var l = getLayerById(c, args.layerId);
1016
2103
  var prop = walkProperty(l, args.propertyPath);
1017
- return { expression: prop.expression || "", enabled: !!prop.expressionEnabled };
2104
+ return {
2105
+ expression: prop.expression || "",
2106
+ enabled: !!prop.expressionEnabled,
2107
+ expressionError: __expressionError(prop),
2108
+ };
1018
2109
  });
1019
2110
 
1020
2111
  OPS.set_expression = function (args) {
@@ -1023,6 +2114,7 @@ OPS.set_expression = function (args) {
1023
2114
  var prop = walkProperty(l, args.propertyPath);
1024
2115
  prop.expression = args.expression || "";
1025
2116
  prop.expressionEnabled = true;
2117
+ __assertExpressionEvaluates(prop, args.propertyPath, "written");
1026
2118
  return { ok: true };
1027
2119
  };
1028
2120
 
@@ -1031,6 +2123,9 @@ OPS.toggle_expression = function (args) {
1031
2123
  var l = getLayerById(c, args.layerId);
1032
2124
  var prop = walkProperty(l, args.propertyPath);
1033
2125
  prop.expressionEnabled = !!args.enabled;
2126
+ // Disabling cannot fail this way; enabling can surface an error that has
2127
+ // been sitting on the property since it was disabled.
2128
+ if (args.enabled) __assertExpressionEvaluates(prop, args.propertyPath, "enabled");
1034
2129
  return { ok: true };
1035
2130
  };
1036
2131
 
@@ -1184,12 +2279,63 @@ var __JUSTIFICATION = {
1184
2279
  full: ParagraphJustification.FULL_JUSTIFY_LASTLINE_LEFT,
1185
2280
  };
1186
2281
 
1187
- OPS.set_text = function (args) {
1188
- var c = getCompById(args.compId);
1189
- var l = getLayerById(c, args.layerId);
2282
+ // The name for an enum value, for results and errors. AE has more FULL_JUSTIFY
2283
+ // variants than the map offers, so a value the map does not know comes back as
2284
+ // its number rather than as the nearest name.
2285
+ function __justificationName(v) {
2286
+ for (var k in __JUSTIFICATION) {
2287
+ if (__JUSTIFICATION.hasOwnProperty(k) && __JUSTIFICATION[k] === v) return k;
2288
+ }
2289
+ return String(v);
2290
+ }
2291
+
2292
+ // Issue #93: on AE 26.3 the TextDocument round trip — read `.value`, change
2293
+ // `.text`, `setValue()` — has come back with justification reset to CENTER
2294
+ // even though the document that went in still said LEFT; issue #94 reports the
2295
+ // setter storing RIGHT when handed CENTER from a script. Nothing raises in
2296
+ // either case and the mechanism is unknown, so the only guard that can be built
2297
+ // blind is the one this repo asks for anyway: read the property back and
2298
+ // compare with what should be there. A second write of the justification alone
2299
+ // has been seen to land (`set_text({justification})` on its own always did), so
2300
+ // one re-assert is tried before giving up. If that also disagrees, the caller
2301
+ // is told what was expected and what the layer actually shows — a text layer
2302
+ // that silently re-centres is the failure this codebase refuses everywhere.
2303
+ //
2304
+ // `expected` is an enum value: the requested one, or the one read before the
2305
+ // mutation. Returns {justification, reasserted}; throws when it cannot deliver.
2306
+ function __verifyJustification(src, expected) {
2307
+ var actual = src.value.justification;
2308
+ if (actual === expected) return { justification: actual, reasserted: false };
2309
+ var again = src.value;
2310
+ again.justification = expected;
2311
+ src.setValue(again);
2312
+ actual = src.value.justification;
2313
+ if (actual === expected) return { justification: actual, reasserted: true };
2314
+ throw new Error(
2315
+ "Source Text justification did not land: expected " + __justificationName(expected) +
2316
+ " (" + String(expected) + "), the layer reads " + __justificationName(actual) +
2317
+ " (" + String(actual) + ") after a second write. The other fields were written. " +
2318
+ "Read the layer back with get_layer_full include:[\"text\"] before relying on its alignment."
2319
+ );
2320
+ }
2321
+
2322
+ OPS.set_text = function (args) {
2323
+ var c = getCompById(args.compId);
2324
+ var l = getLayerById(c, args.layerId);
1190
2325
  if (!(l instanceof TextLayer)) throw new Error("Layer is not a TextLayer");
1191
2326
  var src = l.property("Source Text");
1192
2327
  var td = src.value;
2328
+ // Captured before anything is touched: "undefined fields unchanged" is a
2329
+ // claim about this value, and it is what the read-back is checked against
2330
+ // when no justification was asked for.
2331
+ var justificationBefore = td.justification;
2332
+ var justificationWanted = justificationBefore;
2333
+ if (args.justification !== undefined) {
2334
+ if (__JUSTIFICATION[args.justification] === undefined) {
2335
+ throw new Error("Unknown justification '" + String(args.justification) + "'; expected one of left, center, right, full. Nothing was changed.");
2336
+ }
2337
+ justificationWanted = __JUSTIFICATION[args.justification];
2338
+ }
1193
2339
  if (args.text !== undefined) td.text = args.text;
1194
2340
  if (args.font !== undefined) td.font = args.font;
1195
2341
  if (args.size !== undefined) td.fontSize = args.size;
@@ -1198,9 +2344,7 @@ OPS.set_text = function (args) {
1198
2344
  if (args.strokeWidth !== undefined) td.strokeWidth = args.strokeWidth;
1199
2345
  if (args.tracking !== undefined) td.tracking = args.tracking;
1200
2346
  if (args.leading !== undefined) td.leading = args.leading;
1201
- if (args.justification !== undefined) {
1202
- if (__JUSTIFICATION[args.justification]) td.justification = __JUSTIFICATION[args.justification];
1203
- }
2347
+ if (args.justification !== undefined) td.justification = justificationWanted;
1204
2348
  if (args.applyFill !== undefined) td.applyFill = args.applyFill;
1205
2349
  if (args.applyStroke !== undefined) td.applyStroke = args.applyStroke;
1206
2350
  if (args.fauxBold !== undefined) td.fauxBold = args.fauxBold;
@@ -1209,7 +2353,12 @@ OPS.set_text = function (args) {
1209
2353
  if (args.smallCaps !== undefined) td.smallCaps = args.smallCaps;
1210
2354
  if (args.baselineShift !== undefined) td.baselineShift = args.baselineShift;
1211
2355
  src.setValue(td);
1212
- return { ok: true };
2356
+ var verified = __verifyJustification(src, justificationWanted);
2357
+ return {
2358
+ ok: true,
2359
+ justification: __justificationName(verified.justification),
2360
+ justificationReasserted: verified.reasserted,
2361
+ };
1213
2362
  };
1214
2363
 
1215
2364
  OPS.add_text_animator = function (args) {
@@ -1600,10 +2749,26 @@ var __SCREENSHOT_TARGET_PX = 1280;
1600
2749
  // The correct downsample was always derivable from the comp, and an agent that
1601
2750
  // forgot it got a full-resolution 4K frame — the most expensive accident
1602
2751
  // available through these tools. So derive it, and let an explicit value win.
2752
+ function __autoDownsampleFor(comp, targetPx) {
2753
+ var longEdge = comp.width > comp.height ? comp.width : comp.height;
2754
+ var n = Math.ceil(longEdge / targetPx);
2755
+ if (!(n > 1)) return 1;
2756
+ return n > 8 ? 8 : n;
2757
+ }
2758
+
1603
2759
  // 1080p -> 2 (960px), 4K -> 3 (1280px), and anything already small -> 1.
1604
2760
  function __autoDownsample(comp) {
1605
- var longEdge = comp.width > comp.height ? comp.width : comp.height;
1606
- var n = Math.ceil(longEdge / __SCREENSHOT_TARGET_PX);
2761
+ return __autoDownsampleFor(comp, __SCREENSHOT_TARGET_PX);
2762
+ }
2763
+
2764
+ // A contact sheet of N frames has to cost about what one frame costs, so each
2765
+ // tile gets 1/sqrt(N) of the single-frame long edge — N tiles of 1/N the area
2766
+ // each. Expressed as a multiple of what this comp's *single* frame would have
2767
+ // used rather than as its own target, for two reasons: it cannot drift from
2768
+ // __autoDownsample, and the factor is an integer, so deriving from the target
2769
+ // instead would let the rounding leave a 1080p sheet at nearly twice the budget.
2770
+ function __tileDownsample(comp, count) {
2771
+ var n = Math.ceil(__autoDownsample(comp) * Math.sqrt(count));
1607
2772
  if (!(n > 1)) return 1;
1608
2773
  return n > 8 ? 8 : n;
1609
2774
  }
@@ -1617,24 +2782,68 @@ function __resolveDownsample(comp, requested) {
1617
2782
  // reduced frame directly instead of writing full size and resampling
1618
2783
  // afterwards. That is faster (a quarter of the pixels at factor 2) and needs no
1619
2784
  // external image tool, which is what makes it work off macOS.
2785
+ //
2786
+ // Factor 1 is *set*, not skipped. Skipping it rendered at whatever the user had
2787
+ // left the viewer on, so a comp parked at Quarter answered `downsample: 1` with
2788
+ // a quarter-size frame and `downsample: 2` came back **larger** than
2789
+ // `downsample: 1` (issue #72). Designers leave heavy comps at Quarter or Third
2790
+ // as a matter of course, so that was the common case rather than a corner. The
2791
+ // panel reads the real dimensions out of the PNG's IHDR, which means the
2792
+ // response stayed honest while the *picture* was not the one asked for — the
2793
+ // worse of the two failures, because an agent that can see a frame believes it.
2794
+ // Every factor reaching this function now names the resolution it renders at.
1620
2795
  function __saveFrameAt(comp, time, file, factor) {
1621
- if (factor <= 1) {
1622
- comp.saveFrameToPng(time, file);
1623
- return;
1624
- }
2796
+ var f = (factor > 1) ? Math.round(factor) : 1;
1625
2797
  var previous = comp.resolutionFactor;
1626
2798
  try {
1627
- comp.resolutionFactor = [factor, factor];
2799
+ comp.resolutionFactor = [f, f];
1628
2800
  comp.saveFrameToPng(time, file);
1629
2801
  } finally {
1630
2802
  // Restore unconditionally — a failed render must never leave the user
1631
- // looking at a half-resolution comp.
2803
+ // looking at a half-resolution comp. This covers factor 1 too: the render
2804
+ // happens at Full and the viewer goes back to Quarter afterwards.
1632
2805
  comp.resolutionFactor = previous;
1633
2806
  }
1634
2807
  }
1635
2808
 
2809
+ // Several times in one call. Every requested time gets an entry, in order,
2810
+ // whether or not it rendered — the panel draws a marked block for the ones that
2811
+ // did not, so the sheet it composes still lines up with the times that were
2812
+ // asked for. Dropping a failed tile would silently renumber the rest, which is
2813
+ // the same class of lie as swallowing an error.
2814
+ function __contactSheetFrames(comp, args) {
2815
+ var times = args.times;
2816
+ var ds;
2817
+ if (args.downsample === undefined || args.downsample === null) {
2818
+ ds = __tileDownsample(comp, times.length);
2819
+ } else {
2820
+ ds = __clampDownsample(args.downsample);
2821
+ }
2822
+ var tiles = [];
2823
+ for (var i = 0; i < times.length; i++) {
2824
+ var entry = { time: times[i], downsample: ds };
2825
+ try {
2826
+ var p = __tmpPngPath();
2827
+ __saveFrameAt(comp, times[i], new File(p), ds);
2828
+ entry.path = p;
2829
+ } catch (e) {
2830
+ // One time that will not render must not cost the other five.
2831
+ entry.error = String(e && e.message ? e.message : e);
2832
+ }
2833
+ tiles.push(entry);
2834
+ }
2835
+ return {
2836
+ contactSheet: true,
2837
+ tiles: tiles,
2838
+ downsample: ds,
2839
+ width: comp.width, height: comp.height,
2840
+ times: times, compId: comp.id
2841
+ };
2842
+ }
2843
+
1636
2844
  OPS.screenshot_frame = noUndo(function (args) {
1637
2845
  var c = getCompById(args.compId);
2846
+ if (args.times && args.times.length) return __contactSheetFrames(c, args);
1638
2847
  var t = (args.time !== undefined && args.time !== null) ? args.time : c.time;
1639
2848
  var ds = __resolveDownsample(c, args.downsample);
1640
2849
  var path = __tmpPngPath();
@@ -1789,22 +2998,53 @@ function __brokenSvgAdvice() {
1789
2998
  );
1790
2999
  }
1791
3000
 
1792
- OPS.import_footage = function (args) {
1793
- var path = args && args.path;
1794
- if (typeof path !== "string" || path.length === 0) throw new Error("path is required");
1795
-
1796
- var file = new File(path);
1797
- if (!file.exists) throw new Error("No file at " + path);
1798
-
3001
+ /**
3002
+ * The bare import — ImportOptions, importFile, and the two ways AE can decline.
3003
+ * Kept as its own function because it is the only import in the codebase and
3004
+ * anything else that needs a file in the project (audio.jsx) must go through
3005
+ * exactly this, not a second copy that drifts.
3006
+ */
3007
+ function __importFile(file, path, sequence) {
1799
3008
  var opts = new ImportOptions(file);
1800
- if (args.sequence === true) {
3009
+ if (sequence === true) {
1801
3010
  if (!opts.canImportAs(ImportAsType.FOOTAGE)) throw new Error("Cannot import " + path + " as footage, so it cannot be a sequence either");
1802
3011
  opts.importAs = ImportAsType.FOOTAGE;
1803
3012
  opts.sequence = true;
1804
3013
  }
1805
-
1806
3014
  var item = app.project.importFile(opts);
1807
3015
  if (!item) throw new Error("After Effects returned no item for " + path);
3016
+ return item;
3017
+ }
3018
+
3019
+ /**
3020
+ * fsName -> project item, for every item in the project that came from a file.
3021
+ * One pass: the caller with N paths to resolve would otherwise walk the project
3022
+ * N times, and a project with a few hundred items makes that visible.
3023
+ */
3024
+ function __itemPathMap() {
3025
+ var map = {};
3026
+ var proj = app.project;
3027
+ for (var i = 1; i <= proj.numItems; i++) {
3028
+ var it = proj.item(i);
3029
+ var key = null;
3030
+ try {
3031
+ if (it.mainSource && it.mainSource.file) key = String(it.mainSource.file.fsName);
3032
+ } catch (e) {}
3033
+ // First one wins: two items on the same file are a duplicate import, and
3034
+ // reusing the earlier is what the user would have done by hand.
3035
+ if (key && !map.hasOwnProperty(key)) map[key] = it;
3036
+ }
3037
+ return map;
3038
+ }
3039
+
3040
+ OPS.import_footage = function (args) {
3041
+ var path = args && args.path;
3042
+ if (typeof path !== "string" || path.length === 0) throw new Error("path is required");
3043
+
3044
+ var file = new File(path);
3045
+ if (!file.exists) throw new Error("No file at " + path);
3046
+
3047
+ var item = __importFile(file, path, args.sequence);
1808
3048
 
1809
3049
  if (typeof args.name === "string" && args.name.length > 0) item.name = args.name;
1810
3050
 
@@ -1843,29 +3083,985 @@ OPS.import_footage = function (args) {
1843
3083
  );
1844
3084
  };
1845
3085
 
1846
- OPS.create_footage_layer = function (args) {
1847
- var comp = getCompById(args.compId);
1848
- var item = app.project.itemByID(args.itemId);
1849
- if (!item) throw new Error("No project item with id " + args.itemId);
1850
- if (item instanceof FolderItem) throw new Error("Item " + args.itemId + " (\"" + item.name + "\") is a folder, not footage");
3086
+ OPS.create_footage_layer = function (args) {
3087
+ var comp = getCompById(args.compId);
3088
+ var item = app.project.itemByID(args.itemId);
3089
+ if (!item) throw new Error("No project item with id " + args.itemId);
3090
+ if (item instanceof FolderItem) throw new Error("Item " + args.itemId + " (\"" + item.name + "\") is a folder, not footage");
3091
+
3092
+ var layer = comp.layers.add(item);
3093
+ if (typeof args.name === "string" && args.name.length > 0) layer.name = args.name;
3094
+ if (args.position !== undefined && args.position !== null) {
3095
+ layer.property("Transform").property("Position").setValue(args.position);
3096
+ }
3097
+ if (args.startTime !== undefined && args.startTime !== null) layer.startTime = args.startTime;
3098
+
3099
+ return {
3100
+ layerId: layer.id,
3101
+ index: layer.index,
3102
+ name: layer.name,
3103
+ compId: comp.id,
3104
+ itemId: item.id,
3105
+ inPoint: layer.inPoint,
3106
+ outPoint: layer.outPoint
3107
+ };
3108
+ };
3109
+
3110
+ // ---------------------------------------------------------------------------
3111
+ // Unused footage, and the solids delete_comp leaves behind (issue #83)
3112
+ // ---------------------------------------------------------------------------
3113
+ // A solid layer's source is a FootageItem in the project's Solids folder, not
3114
+ // something the comp owns. Deleting the comp (or the layer) deletes the layer
3115
+ // and leaves the item: nothing in AE's DOM ties the two lifetimes together and
3116
+ // nothing warns, so a session that builds and discards comps grows the project
3117
+ // bin without bound — a verification pass had to sweep 1,863 of them out by
3118
+ // name. Once orphaned they are indistinguishable from solids still in use
3119
+ // unless something reads `usedIn`, which is what everything below does.
3120
+ //
3121
+ // Two ops share these helpers. `delete_comp` (comps.jsx) can purge the solids
3122
+ // the deleted comp referenced — only those, and only the ones nothing else
3123
+ // still uses — and `purge_unused_footage` is the project-wide sweep: AE's own
3124
+ // Remove Unused Footage, with a dryRun and a report of what went.
3125
+
3126
+ /** The item behind a solid or adjustment layer: a FootageItem whose source is a SolidSource. */
3127
+ function __isSolidItem(it) {
3128
+ if (!(it instanceof FootageItem)) return false;
3129
+ var src = null;
3130
+ try { src = it.mainSource; } catch (e) { return false; }
3131
+ if (!src) return false;
3132
+ return (src instanceof SolidSource);
3133
+ }
3134
+
3135
+ /** "solid" | "placeholder" | "footage". Only ever called on a FootageItem — a comp or folder is never footage. */
3136
+ function __footageKind(it) {
3137
+ if (__isSolidItem(it)) return "solid";
3138
+ try {
3139
+ if (typeof PlaceholderSource !== "undefined" && it.mainSource instanceof PlaceholderSource) return "placeholder";
3140
+ } catch (e) {}
3141
+ return "footage";
3142
+ }
3143
+
3144
+ /**
3145
+ * The comps an item is placed in, as {id, name}. Read from After Effects on
3146
+ * every call rather than inferred from anything this bundle remembers — the
3147
+ * whole safety of a purge is that the answer is AE's, at the moment of asking.
3148
+ */
3149
+ function __usedInList(it) {
3150
+ var out = [];
3151
+ var comps = null;
3152
+ try { comps = it.usedIn; } catch (e) { comps = null; }
3153
+ if (!comps) return out;
3154
+ for (var i = 0; i < comps.length; i++) {
3155
+ var c = comps[i];
3156
+ if (!c) continue;
3157
+ out.push({ id: c.id, name: c.name });
3158
+ }
3159
+ return out;
3160
+ }
3161
+
3162
+ /** True when `it` sits in `folder` or in any folder nested under it. */
3163
+ function __isInFolder(it, folder) {
3164
+ var cur = null;
3165
+ try { cur = it.parentFolder; } catch (e) { return false; }
3166
+ for (var depth = 0; cur && depth < 64; depth++) {
3167
+ if (cur.id === folder.id) return true;
3168
+ var next = null;
3169
+ try { next = cur.parentFolder; } catch (e2) { next = null; }
3170
+ cur = next;
3171
+ }
3172
+ return false;
3173
+ }
3174
+
3175
+ /** The distinct solid items this comp's own layers point at, as {id, name}. Nothing from any other comp. */
3176
+ function __solidItemsOf(comp) {
3177
+ var out = [];
3178
+ var seen = {};
3179
+ for (var i = 1; i <= comp.numLayers; i++) {
3180
+ var l = comp.layer(i);
3181
+ if (!(l instanceof AVLayer)) continue;
3182
+ var src = null;
3183
+ try { src = l.source; } catch (e) { continue; }
3184
+ if (!src || !__isSolidItem(src)) continue;
3185
+ var key = "I" + src.id;
3186
+ if (seen.hasOwnProperty(key)) continue;
3187
+ seen[key] = true;
3188
+ out.push({ id: src.id, name: src.name });
3189
+ }
3190
+ return out;
3191
+ }
3192
+
3193
+ function __itemReport(entry) {
3194
+ var r = { id: entry.id, name: entry.name, kind: entry.kind };
3195
+ if (entry.footageMissing) r.footageMissing = true;
3196
+ return r;
3197
+ }
3198
+
3199
+ function __nameList(reports, cap) {
3200
+ var names = [];
3201
+ for (var i = 0; i < reports.length && i < cap; i++) names.push("\"" + reports[i].name + "\" (id " + reports[i].id + ")");
3202
+ var s = names.join(", ");
3203
+ if (reports.length > cap) s += " and " + (reports.length - cap) + " more";
3204
+ return s;
3205
+ }
3206
+
3207
+ /**
3208
+ * Remove a list of project items. Entries are {item, id, name, kind, index?}.
3209
+ *
3210
+ * Highest project index first when indices are known, so no removal shifts an
3211
+ * item still to be visited — the entries were collected in one pass over
3212
+ * app.project.item(i) and removed in a second, never removed mid-walk. It stops
3213
+ * at the first failure: nothing rolls back, so the caller is handed what went
3214
+ * before the failure and what was never attempted, and reports that, rather
3215
+ * than a sweep that claims to have finished.
3216
+ */
3217
+ function __removeItems(entries) {
3218
+ var ordered = entries.slice(0);
3219
+ ordered.sort(function (a, b) { return (b.index || 0) - (a.index || 0); });
3220
+ var removed = [];
3221
+ for (var i = 0; i < ordered.length; i++) {
3222
+ var entry = ordered[i];
3223
+ try {
3224
+ entry.item.remove();
3225
+ } catch (e) {
3226
+ var pending = [];
3227
+ for (var j = i + 1; j < ordered.length; j++) pending.push(__itemReport(ordered[j]));
3228
+ return {
3229
+ removed: removed,
3230
+ failed: __itemReport(entry),
3231
+ error: (e && e.message) ? String(e.message) : String(e),
3232
+ notAttempted: pending
3233
+ };
3234
+ }
3235
+ removed.push(__itemReport(entry));
3236
+ }
3237
+ return { removed: removed, failed: null, error: null, notAttempted: [] };
3238
+ }
3239
+
3240
+ OPS.purge_unused_footage = noUndoWhen(
3241
+ // A dry run is not an undo step. A sweep that changed nothing appearing in
3242
+ // the user's undo history would make "this changed nothing" false in the one
3243
+ // place they can see it — the same stance place_audio_cues takes.
3244
+ function (args) { return !!(args && args.dryRun === true); },
3245
+ function (args) {
3246
+ args = args || {};
3247
+ var solidsOnly = (args.solidsOnly !== false);
3248
+ var dryRun = (args.dryRun === true);
3249
+ var folder = null;
3250
+ if (args.folderId !== undefined && args.folderId !== null) folder = __folderArg(args.folderId);
3251
+ var what = "footage item";
3252
+ if (solidsOnly) what = "solid";
3253
+
3254
+ // One pass to decide, a second to remove. Removing inside the walk would
3255
+ // shift every index above the removed item under the loop's feet.
3256
+ var candidates = [];
3257
+ var scanned = 0;
3258
+ var inUse = 0;
3259
+ var proj = app.project;
3260
+ for (var i = 1; i <= proj.numItems; i++) {
3261
+ var it = proj.item(i);
3262
+ // A comp is an AVItem with a usedIn of its own, and a nested comp nothing
3263
+ // uses any more is exactly what a naive sweep would delete. Only footage.
3264
+ if (!(it instanceof FootageItem)) continue;
3265
+ if (solidsOnly && !__isSolidItem(it)) continue;
3266
+ if (folder && !__isInFolder(it, folder)) continue;
3267
+ scanned += 1;
3268
+ if (__usedInList(it).length > 0) { inUse += 1; continue; }
3269
+ var missing = false;
3270
+ try { missing = !!it.footageMissing; } catch (eMissing) {}
3271
+ candidates.push({ item: it, id: it.id, name: it.name, kind: __footageKind(it), index: i, footageMissing: missing });
3272
+ }
3273
+ // Sorted here, once, so a dry run lists items in exactly the order a real
3274
+ // run removes them — the plan has to be the truth about order too.
3275
+ candidates.sort(function (a, b) { return b.index - a.index; });
3276
+
3277
+ var out = { solidsOnly: solidsOnly, scanned: scanned, inUse: inUse };
3278
+ var where = "in the project";
3279
+ if (folder) {
3280
+ out.folderId = folder.id;
3281
+ out.folderName = folder.name;
3282
+ where = "in folder \"" + folder.name + "\"";
3283
+ }
3284
+ var nothingWhy = "every " + what + " " + where + " is still used in a comp";
3285
+ if (scanned === 0) nothingWhy = "no " + what + "s " + where;
3286
+
3287
+ if (dryRun) {
3288
+ var would = [];
3289
+ for (var w = 0; w < candidates.length; w++) would.push(__itemReport(candidates[w]));
3290
+ out.dryRun = true;
3291
+ out.wouldRemove = would;
3292
+ out.wouldRemoveCount = would.length;
3293
+ if (would.length === 0) {
3294
+ out.note = "Nothing to remove: " + nothingWhy + ". Nothing was changed and this call is not an undo step.";
3295
+ } else {
3296
+ out.note = "Nothing was removed and this call is not an undo step. Call again without dryRun to remove these " +
3297
+ would.length + " " + what + (would.length === 1 ? "" : "s") + " " + where + ".";
3298
+ }
3299
+ return out;
3300
+ }
3301
+
3302
+ var r = __removeItems(candidates);
3303
+ out.removed = r.removed;
3304
+ out.removedCount = r.removed.length;
3305
+ if (r.failed) {
3306
+ var msg = "purge_unused_footage stopped at \"" + r.failed.name + "\" (id " + r.failed.id + ", " + r.failed.kind + "): " + r.error + ".";
3307
+ if (r.removed.length > 0) {
3308
+ msg += " " + r.removed.length + " item(s) were removed before it: " + __nameList(r.removed, 20) + ".";
3309
+ } else {
3310
+ msg += " Nothing had been removed yet.";
3311
+ }
3312
+ msg += " " + r.notAttempted.length + " unused item(s) were not attempted." +
3313
+ " Nothing rolls back; one Undo in After Effects restores what this call removed. Run again, or with dryRun:true, to see what is left.";
3314
+ throw new Error(msg);
3315
+ }
3316
+ if (r.removed.length === 0) {
3317
+ out.note = "Nothing to remove: " + nothingWhy + ".";
3318
+ } else {
3319
+ out.note = "Removed " + r.removed.length + " unused " + what + (r.removed.length === 1 ? "" : "s") + " " + where + ", as one undo step.";
3320
+ }
3321
+ return out;
3322
+ }
3323
+ );
3324
+
3325
+
3326
+ // ===== audio.jsx =====
3327
+
3328
+ // audio.jsx — place a list of sound effects into a comp in one pass.
3329
+ //
3330
+ // Scoring a scene is 40-90 layers: import or reuse the file, add a layer, set
3331
+ // its start time, set its level in dB, name it, sometimes trim it. Done through
3332
+ // the general tools that is dozens of round trips; done through run_jsx it is a
3333
+ // hand-written loop that has to know the one thing nothing tells you —
3334
+ // `layer.property("ADBE Audio Levels")` returns **null** on an audio layer,
3335
+ // because Audio Levels lives under the layer's "Audio" group and the only
3336
+ // reliable handle is the `layer.audioLevels` shortcut (issue #48).
3337
+ //
3338
+ // Two properties carry the design:
3339
+ //
3340
+ // - Nothing is created until every cue has been checked. A run that dies on
3341
+ // cue 30 of 90 leaves 29 sound effects in someone's timeline and an error
3342
+ // that does not say which ones — the same half-built failure
3343
+ // add_shape_content refuses to produce. So: plan first with no side
3344
+ // effects, throw naming the offending cue indices, and if a creation still
3345
+ // fails, remove everything this call made before rethrowing.
3346
+ // - A file named by several cues is imported once. Repeated imports of one
3347
+ // .wav are the normal shape of a cue list ("whoosh" nine times), and each
3348
+ // one would otherwise add another project item. Anything already in the
3349
+ // project from that path is reused rather than imported a second time.
3350
+ //
3351
+ // Three per-cue options (issue #85) — `loop`, `fadeIn`/`fadeOut`, `stretch` —
3352
+ // are the patterns every scoring pass used to re-implement in a run_jsx
3353
+ // follow-up. Each one moves something After Effects then quietly resets, so
3354
+ // the order inside __placeAudioCue is load-bearing; see the comment there.
3355
+
3356
+ var __MAX_AUDIO_CUES = 200;
3357
+ var __CUE_TIME_EPS = 1e-6;
3358
+
3359
+ // The level a fade starts from and ends at when the caller gives none. -48 dB
3360
+ // is the bottom of the range After Effects' own Audio Levels slider offers, and
3361
+ // 1/256 of the recorded amplitude — near-silence for a fade to begin from while
3362
+ // staying inside the range a user can drag the property through by hand.
3363
+ var __DEFAULT_FADE_FLOOR_DB = -48;
3364
+
3365
+ // AE's layer label colours. Users can rename them in preferences, but the tool
3366
+ // takes an index and the indices do not move, so the names are accepted as a
3367
+ // convenience and translated here. 0 is "None".
3368
+ var __LABEL_NAMES = {
3369
+ none: 0, red: 1, yellow: 2, aqua: 3, pink: 4, lavender: 5, peach: 6,
3370
+ seafoam: 7, blue: 8, green: 9, purple: 10, orange: 11, brown: 12,
3371
+ fuchsia: 13, cyan: 14, sandstone: 15, darkgreen: 16
3372
+ };
3373
+
3374
+ function __labelKey(s) {
3375
+ return String(s).toLowerCase().replace(/[^a-z0-9]/g, "");
3376
+ }
3377
+
3378
+ function __labelNameList() {
3379
+ var names = [];
3380
+ for (var k in __LABEL_NAMES) {
3381
+ if (__LABEL_NAMES.hasOwnProperty(k)) names.push(k);
3382
+ }
3383
+ return names.join(", ");
3384
+ }
3385
+
3386
+ /** 0..16, or a thrown error naming everything it would have taken. */
3387
+ function __resolveLabel(label) {
3388
+ if (typeof label === "number") {
3389
+ if (label !== Math.floor(label) || label < 0 || label > 16) {
3390
+ throw new Error("label must be a whole number 0-16 or a colour name (" + __labelNameList() + "); got " + label);
3391
+ }
3392
+ return label;
3393
+ }
3394
+ var key = __labelKey(label);
3395
+ if (__LABEL_NAMES.hasOwnProperty(key)) return __LABEL_NAMES[key];
3396
+ throw new Error("unknown label \"" + label + "\" — use 0-16 or one of: " + __labelNameList());
3397
+ }
3398
+
3399
+ function __basename(p) {
3400
+ var s = String(p).replace(/\\/g, "/");
3401
+ var slash = s.lastIndexOf("/");
3402
+ if (slash >= 0) s = s.substring(slash + 1);
3403
+ return s;
3404
+ }
3405
+
3406
+ function __stripExtension(name) {
3407
+ var s = String(name);
3408
+ var dot = s.lastIndexOf(".");
3409
+ if (dot > 0) return s.substring(0, dot);
3410
+ return s;
3411
+ }
3412
+
3413
+ function __isFiniteNumber(v) {
3414
+ return typeof v === "number" && isFinite(v);
3415
+ }
3416
+
3417
+ /**
3418
+ * The Audio Levels property of a layer.
3419
+ *
3420
+ * `layer.audioLevels` is the shortcut that works. `layer.property("ADBE Audio
3421
+ * Levels")` returns null on an audio layer — the property sits inside the
3422
+ * layer's "Audio" group, not on the layer — and a null there is what silently
3423
+ * turns a scripted level into no level at all. The group walk is only a
3424
+ * fallback for a layer whose shortcut is somehow absent; if neither answers,
3425
+ * the caller is told rather than left with an unset level.
3426
+ */
3427
+ function __audioLevelsProperty(layer) {
3428
+ var p = null;
3429
+ try { p = layer.audioLevels; } catch (e) {}
3430
+ if (p) return p;
3431
+ try {
3432
+ var g = layer.property("Audio");
3433
+ if (g) p = g.property("Audio Levels");
3434
+ } catch (e2) {}
3435
+ return p;
3436
+ }
3437
+
3438
+ /**
3439
+ * The Time Remap property of a layer that has remapping enabled. Same shape of
3440
+ * trap as Audio Levels: `layer.property("ADBE Time Remap")` is null on an
3441
+ * audio layer even after `timeRemapEnabled = true`, and the shortcut
3442
+ * `layer.timeRemap` is the handle that answers. The match-name lookup is kept
3443
+ * only as a fallback; a null from both is reported, never worked around.
3444
+ */
3445
+ function __timeRemapProperty(layer) {
3446
+ var p = null;
3447
+ try { p = layer.timeRemap; } catch (e) {}
3448
+ if (p) return p;
3449
+ try { p = layer.property("ADBE Time Remapping"); } catch (e2) {}
3450
+ return p;
3451
+ }
3452
+
3453
+ /** null when the item can carry an audio cue, else the reason it cannot. */
3454
+ function __audioItemProblem(item) {
3455
+ if (item instanceof FolderItem) return "\"" + item.name + "\" is a folder, not footage";
3456
+ if (item instanceof CompItem) return "\"" + item.name + "\" is a comp — use create_precomp_layer for that";
3457
+ if (item.footageMissing) return "\"" + item.name + "\" is offline; the file it points at is missing";
3458
+ // Explicitly `=== false`, not `!item.hasAudio`: an item that does not report
3459
+ // the flag at all must not be refused on the strength of a missing property.
3460
+ if (item.hasAudio === false) return "\"" + item.name + "\" has no audio track";
3461
+ return null;
3462
+ }
3463
+
3464
+ function __sourceReport(item, path) {
3465
+ return { itemId: item.id, name: item.name, path: path };
3466
+ }
3467
+
3468
+ /** The duration of a project item, or null when it does not say. */
3469
+ function __itemDuration(item) {
3470
+ if (!item) return null;
3471
+ var d = null;
3472
+ try { d = item.duration; } catch (e) {}
3473
+ if (__isFiniteNumber(d)) return d;
3474
+ return null;
3475
+ }
3476
+
3477
+ /**
3478
+ * Where a planned cue's layer will start and end, in comp time, given what is
3479
+ * known about its source. `end` is null when it cannot be known yet — an
3480
+ * unlooped cue on a file that has not been imported, with no outPoint to cap
3481
+ * it — and the caller decides what to do with that.
3482
+ */
3483
+ function __cueExtent(p, itemDuration) {
3484
+ var start = p.time;
3485
+ if (p.inPoint !== null) start = p.inPoint;
3486
+ var end = null;
3487
+ if (p.loop) {
3488
+ // A loop has no natural end, so its end is the one the caller gave or the
3489
+ // comp's, whichever the planner settled on.
3490
+ end = p.loopEnd;
3491
+ } else {
3492
+ var natural = null;
3493
+ if (__isFiniteNumber(itemDuration)) natural = p.time + itemDuration * p.stretchFactor;
3494
+ end = p.outPoint;
3495
+ // AE clamps an out point to what the source can supply, so a requested
3496
+ // outPoint past the file's end is not where the layer will end.
3497
+ if (end === null || (natural !== null && natural < end)) end = natural;
3498
+ }
3499
+ return { start: start, end: end };
3500
+ }
3501
+
3502
+ function __fmtSeconds(n) {
3503
+ // Rounded for prose only; nothing downstream reads these numbers back.
3504
+ return String(Math.round(n * 1000) / 1000) + "s";
3505
+ }
3506
+
3507
+ /**
3508
+ * null when the cue's fades fit inside its extent; a reason string when they
3509
+ * do not; and the string "unknown" when the extent cannot be computed yet.
3510
+ * Pure — used at plan time and again after the imports, on the same plan.
3511
+ */
3512
+ function __fadeFitProblem(p, extent) {
3513
+ var total = p.fadeIn + p.fadeOut;
3514
+ if (total <= 0 && !p.loop) return null;
3515
+ if (extent.end === null) {
3516
+ if (total <= 0) return null;
3517
+ return "unknown";
3518
+ }
3519
+ var dur = extent.end - extent.start;
3520
+ var span = " (from " + __fmtSeconds(extent.start) + " to " + __fmtSeconds(extent.end) + ")";
3521
+ if (dur <= __CUE_TIME_EPS) {
3522
+ if (p.loop) {
3523
+ var why = "the comp's end, since no outPoint was given";
3524
+ if (p.outPoint !== null) why = "its outPoint";
3525
+ return "inPoint " + __fmtSeconds(extent.start) + " is not before the end of the loop at " + __fmtSeconds(extent.end) + " (" + why + ")";
3526
+ }
3527
+ return "inPoint " + __fmtSeconds(extent.start) + " is past where the cue ends" + span;
3528
+ }
3529
+ if (total <= 0) return null;
3530
+ if (total > dur + __CUE_TIME_EPS) {
3531
+ var what;
3532
+ if (p.fadeIn > 0 && p.fadeOut > 0) {
3533
+ what = "fadeIn " + __fmtSeconds(p.fadeIn) + " + fadeOut " + __fmtSeconds(p.fadeOut) + " = " + __fmtSeconds(total);
3534
+ } else if (p.fadeIn > 0) {
3535
+ what = "fadeIn " + __fmtSeconds(p.fadeIn);
3536
+ } else {
3537
+ what = "fadeOut " + __fmtSeconds(p.fadeOut);
3538
+ }
3539
+ var tail = "";
3540
+ if (!p.loop && p.outPoint === null) tail = " — the file's own length, with no outPoint to extend it and no loop";
3541
+ return what + " is longer than the cue, which runs " + __fmtSeconds(dur) + span + tail;
3542
+ }
3543
+ return null;
3544
+ }
3545
+
3546
+ /**
3547
+ * Turn the cue list into a plan, touching nothing. Every problem found is
3548
+ * collected with the index of the cue that caused it rather than thrown on the
3549
+ * spot, so one call reports all of them instead of one per round trip.
3550
+ */
3551
+ function __planAudioCues(comp, args) {
3552
+ var cues = args.cues;
3553
+ if (!(cues instanceof Array) || cues.length === 0) {
3554
+ throw new Error("place_audio_cues needs a non-empty `cues` array.");
3555
+ }
3556
+ if (cues.length > __MAX_AUDIO_CUES) {
3557
+ throw new Error(
3558
+ "place_audio_cues was given " + cues.length + " cues and the limit is " + __MAX_AUDIO_CUES + " per call. " +
3559
+ "ExtendScript is single-threaded, so one long run freezes After Effects' interface for its whole duration. " +
3560
+ "Split the list into calls of " + __MAX_AUDIO_CUES + " or fewer."
3561
+ );
3562
+ }
3563
+
3564
+ var prefix = "SFX_";
3565
+ if (typeof args.namePrefix === "string") prefix = args.namePrefix;
3566
+
3567
+ // One floor for the whole list: it is a property of the mix, not of a cue.
3568
+ var floorDb = __DEFAULT_FADE_FLOOR_DB;
3569
+ if (args.fadeFloorDb !== undefined && args.fadeFloorDb !== null) {
3570
+ if (!__isFiniteNumber(args.fadeFloorDb)) {
3571
+ throw new Error("fadeFloorDb must be a number of decibels (the default is " + __DEFAULT_FADE_FLOOR_DB + "); got " + String(args.fadeFloorDb));
3572
+ }
3573
+ floorDb = args.fadeFloorDb;
3574
+ }
3575
+
3576
+ var byPath = __itemPathMap();
3577
+ var problems = [];
3578
+ var planned = [];
3579
+ var toImport = [];
3580
+ var toImportSeen = {};
3581
+
3582
+ for (var i = 0; i < cues.length; i++) {
3583
+ var cue = cues[i];
3584
+ if (!cue) { problems.push({ cue: i, reason: "is empty" }); continue; }
3585
+
3586
+ var hasId = (cue.footageId !== undefined && cue.footageId !== null);
3587
+ var hasPath = (typeof cue.path === "string" && cue.path.length > 0);
3588
+ if (hasId && hasPath) { problems.push({ cue: i, reason: "has both footageId and path — give exactly one" }); continue; }
3589
+ if (!hasId && !hasPath) { problems.push({ cue: i, reason: "has neither footageId nor path — give exactly one" }); continue; }
3590
+
3591
+ var time = cue.time;
3592
+ if (!__isFiniteNumber(time)) {
3593
+ problems.push({ cue: i, reason: "time must be a number of seconds; got " + String(time) });
3594
+ continue;
3595
+ }
3596
+ if (time < -__CUE_TIME_EPS || time > comp.duration + __CUE_TIME_EPS) {
3597
+ problems.push({ cue: i, reason: "time " + time + "s is outside the comp, which runs 0 to " + comp.duration + "s" });
3598
+ continue;
3599
+ }
3600
+
3601
+ var levelDb = 0;
3602
+ if (cue.levelDb !== undefined && cue.levelDb !== null) {
3603
+ if (!__isFiniteNumber(cue.levelDb)) {
3604
+ problems.push({ cue: i, reason: "levelDb must be a number of decibels (0 is unedited); got " + String(cue.levelDb) });
3605
+ continue;
3606
+ }
3607
+ levelDb = cue.levelDb;
3608
+ }
3609
+
3610
+ var inPoint = null;
3611
+ var outPoint = null;
3612
+ var trimBad = false;
3613
+ if (cue.inPoint !== undefined && cue.inPoint !== null) {
3614
+ if (!__isFiniteNumber(cue.inPoint)) {
3615
+ problems.push({ cue: i, reason: "inPoint must be a comp time in seconds; got " + String(cue.inPoint) });
3616
+ trimBad = true;
3617
+ } else {
3618
+ inPoint = cue.inPoint;
3619
+ }
3620
+ }
3621
+ if (!trimBad && cue.outPoint !== undefined && cue.outPoint !== null) {
3622
+ if (!__isFiniteNumber(cue.outPoint)) {
3623
+ problems.push({ cue: i, reason: "outPoint must be a comp time in seconds; got " + String(cue.outPoint) });
3624
+ trimBad = true;
3625
+ } else {
3626
+ outPoint = cue.outPoint;
3627
+ }
3628
+ }
3629
+ if (trimBad) continue;
3630
+ // in/out are absolute comp times, like everywhere else in these tools, so
3631
+ // they are measured against `time` rather than against the file.
3632
+ if (inPoint !== null && inPoint < time - __CUE_TIME_EPS) {
3633
+ problems.push({ cue: i, reason: "inPoint " + inPoint + "s is before the cue's own time " + time + "s; both are comp times" });
3634
+ continue;
3635
+ }
3636
+ var trimStart = time;
3637
+ if (inPoint !== null) trimStart = inPoint;
3638
+ if (outPoint !== null && outPoint <= trimStart + __CUE_TIME_EPS) {
3639
+ problems.push({ cue: i, reason: "outPoint " + outPoint + "s is not after the cue starts at " + trimStart + "s" });
3640
+ continue;
3641
+ }
3642
+
3643
+ var label = null;
3644
+ if (cue.label !== undefined && cue.label !== null) {
3645
+ try {
3646
+ label = __resolveLabel(cue.label);
3647
+ } catch (eLabel) {
3648
+ problems.push({ cue: i, reason: eLabel.message });
3649
+ continue;
3650
+ }
3651
+ }
3652
+
3653
+ // The #85 options. Type-checked here, before the source is resolved, so a
3654
+ // cue with a bad stretch AND a missing file reports the first of the two
3655
+ // it hits — every cue still gets a line, which is what matters.
3656
+ var loop = false;
3657
+ if (cue.loop !== undefined && cue.loop !== null) {
3658
+ if (cue.loop !== true && cue.loop !== false) {
3659
+ problems.push({ cue: i, reason: "loop must be true or false; got " + String(cue.loop) });
3660
+ continue;
3661
+ }
3662
+ loop = cue.loop;
3663
+ }
3664
+ var stretch = null;
3665
+ if (cue.stretch !== undefined && cue.stretch !== null) {
3666
+ if (!__isFiniteNumber(cue.stretch) || cue.stretch <= 0) {
3667
+ problems.push({ cue: i, reason: "stretch must be a percentage greater than 0 (100 is unchanged, 200 is half speed); got " + String(cue.stretch) });
3668
+ continue;
3669
+ }
3670
+ stretch = cue.stretch;
3671
+ }
3672
+ var fadeIn = 0;
3673
+ var fadeOut = 0;
3674
+ var fadeBad = false;
3675
+ if (cue.fadeIn !== undefined && cue.fadeIn !== null) {
3676
+ if (!__isFiniteNumber(cue.fadeIn) || cue.fadeIn < 0) {
3677
+ problems.push({ cue: i, reason: "fadeIn must be a number of seconds, 0 or more; got " + String(cue.fadeIn) });
3678
+ fadeBad = true;
3679
+ } else {
3680
+ fadeIn = cue.fadeIn;
3681
+ }
3682
+ }
3683
+ if (!fadeBad && cue.fadeOut !== undefined && cue.fadeOut !== null) {
3684
+ if (!__isFiniteNumber(cue.fadeOut) || cue.fadeOut < 0) {
3685
+ problems.push({ cue: i, reason: "fadeOut must be a number of seconds, 0 or more; got " + String(cue.fadeOut) });
3686
+ fadeBad = true;
3687
+ } else {
3688
+ fadeOut = cue.fadeOut;
3689
+ }
3690
+ }
3691
+ if (fadeBad) continue;
3692
+ if ((fadeIn > 0 || fadeOut > 0) && floorDb >= levelDb) {
3693
+ problems.push({
3694
+ cue: i,
3695
+ reason: "fadeFloorDb " + floorDb + " is not below the cue's level " + levelDb + " dB, so its fade would go nowhere — lower the floor or raise the level"
3696
+ });
3697
+ continue;
3698
+ }
3699
+
3700
+ // Resolve the source. A footageId names an item that must already be
3701
+ // usable; a path is either something the project already holds or an
3702
+ // import this call will do exactly once.
3703
+ var source = null;
3704
+ var defaultName = null;
3705
+ if (hasId) {
3706
+ var item = app.project.itemByID(cue.footageId);
3707
+ if (!item) { problems.push({ cue: i, reason: "no project item with id " + cue.footageId }); continue; }
3708
+ var why = __audioItemProblem(item);
3709
+ if (why) { problems.push({ cue: i, reason: why }); continue; }
3710
+ source = { kind: "item", item: item, fsName: null, path: null };
3711
+ defaultName = item.name;
3712
+ } else {
3713
+ var file = new File(cue.path);
3714
+ if (!file.exists) { problems.push({ cue: i, reason: "no file at " + cue.path }); continue; }
3715
+ var fsName = String(file.fsName);
3716
+ var existing = null;
3717
+ if (byPath.hasOwnProperty(fsName)) existing = byPath[fsName];
3718
+ if (existing) {
3719
+ var whyExisting = __audioItemProblem(existing);
3720
+ if (whyExisting) { problems.push({ cue: i, reason: whyExisting + " (already in the project from " + cue.path + ")" }); continue; }
3721
+ source = { kind: "reused", item: existing, fsName: fsName, path: cue.path };
3722
+ } else {
3723
+ if (!toImportSeen.hasOwnProperty(fsName)) {
3724
+ toImportSeen[fsName] = true;
3725
+ toImport.push({ fsName: fsName, path: cue.path, file: file });
3726
+ }
3727
+ source = { kind: "import", item: null, fsName: fsName, path: cue.path };
3728
+ }
3729
+ // The caller named a path, so the path's basename is the honest default
3730
+ // even when the project item it resolves to was renamed by hand.
3731
+ defaultName = __basename(cue.path);
3732
+ }
3733
+
3734
+ var name = prefix + __stripExtension(defaultName);
3735
+ if (typeof cue.name === "string" && cue.name.length > 0) name = cue.name;
3736
+
3737
+ var stretchFactor = 1;
3738
+ if (stretch !== null) stretchFactor = stretch / 100;
3739
+ // A looped cue needs an end, because a loop has none of its own.
3740
+ var loopEnd = null;
3741
+ if (loop) {
3742
+ loopEnd = comp.duration;
3743
+ if (outPoint !== null) loopEnd = outPoint;
3744
+ }
3745
+
3746
+ var p = {
3747
+ index: i, name: name, time: time, levelDb: levelDb,
3748
+ inPoint: inPoint, outPoint: outPoint, label: label, source: source,
3749
+ loop: loop, loopEnd: loopEnd, stretch: stretch, stretchFactor: stretchFactor,
3750
+ fadeIn: fadeIn, fadeOut: fadeOut,
3751
+ // true when the fade could not be measured against the file yet
3752
+ fadeUnchecked: false
3753
+ };
3754
+
3755
+ // Does the cue fit? Known now for a loop (its end is chosen here), for a
3756
+ // footageId or reused item (After Effects reports its duration) and for a
3757
+ // capped outPoint; not yet for an unlooped cue on a file still to import.
3758
+ var fit = __fadeFitProblem(p, __cueExtent(p, __itemDuration(source.item)));
3759
+ if (fit === "unknown") {
3760
+ p.fadeUnchecked = true;
3761
+ } else if (fit !== null) {
3762
+ problems.push({ cue: i, reason: fit });
3763
+ continue;
3764
+ }
3765
+
3766
+ planned.push(p);
3767
+ }
3768
+
3769
+ return { prefix: prefix, planned: planned, problems: problems, toImport: toImport, floorDb: floorDb };
3770
+ }
3771
+
3772
+ function __audioProblemLines(problems) {
3773
+ var lines = [];
3774
+ for (var i = 0; i < problems.length; i++) {
3775
+ lines.push("cue " + problems[i].cue + ": " + problems[i].reason);
3776
+ }
3777
+ return lines.join("; ");
3778
+ }
3779
+
3780
+ function __audioProblemMessage(problems, total) {
3781
+ return (
3782
+ "place_audio_cues placed nothing — " + problems.length + " of " + total + " cues cannot be placed. " +
3783
+ __audioProblemLines(problems) + ". Every cue is checked before anything is created, so the comp and project are " +
3784
+ "untouched. Fix these and call again; dryRun:true checks a list without placing it."
3785
+ );
3786
+ }
3787
+
3788
+ /**
3789
+ * Counts and the two lists a caller cannot work out alone — what would be
3790
+ * imported and what is already there — plus the failing cues. Never the
3791
+ * resolved list: a 7-cue "all fine" used to cost ~1.5k tokens of echo (#88).
3792
+ */
3793
+ function __audioDryRunReport(comp, plan, total) {
3794
+ var wouldImport = [];
3795
+ for (var j = 0; j < plan.toImport.length; j++) wouldImport.push(plan.toImport[j].path);
3796
+
3797
+ var wouldReuse = [];
3798
+ var reuseSeen = {};
3799
+ var looped = 0;
3800
+ var faded = 0;
3801
+ var stretched = 0;
3802
+ var fadesUnchecked = 0;
3803
+ for (var i = 0; i < plan.planned.length; i++) {
3804
+ var p = plan.planned[i];
3805
+ if (p.source.item && !reuseSeen.hasOwnProperty(String(p.source.item.id))) {
3806
+ reuseSeen[String(p.source.item.id)] = true;
3807
+ var r = { itemId: p.source.item.id, name: p.source.item.name };
3808
+ if (p.source.path) r.path = p.source.path;
3809
+ wouldReuse.push(r);
3810
+ }
3811
+ if (p.loop) looped++;
3812
+ if (p.fadeIn > 0 || p.fadeOut > 0) faded++;
3813
+ if (p.stretch !== null) stretched++;
3814
+ if (p.fadeUnchecked) fadesUnchecked++;
3815
+ }
3816
+
3817
+ var out = {
3818
+ dryRun: true,
3819
+ ok: plan.problems.length === 0,
3820
+ compId: comp.id,
3821
+ compName: comp.name,
3822
+ cueCount: total,
3823
+ wouldPlace: plan.planned.length,
3824
+ wouldImport: wouldImport,
3825
+ wouldReuse: wouldReuse,
3826
+ problems: plan.problems,
3827
+ note: "Nothing was imported, created or changed, and this call is not an undo step."
3828
+ };
3829
+ if (looped > 0) out.looped = looped;
3830
+ if (faded > 0) out.faded = faded;
3831
+ if (stretched > 0) out.stretched = stretched;
3832
+ if (wouldImport.length > 0) {
3833
+ var fadeNote = "";
3834
+ if (fadesUnchecked > 0) {
3835
+ fadeNote = " — and, for " + fadesUnchecked + " cue(s) with a fade, whether the fade fits the file's length —";
3836
+ }
3837
+ out.unverified =
3838
+ wouldImport.length + " of these files are not in the project yet. They exist on disk, but whether each " +
3839
+ "carries an audio track" + fadeNote + " is only knowable once After Effects has imported it; a real run checks " +
3840
+ "that and refuses the whole call if one does not.";
3841
+ }
3842
+ return out;
3843
+ }
3844
+
3845
+ /** Undo everything this call made, newest first. Layers before items: an item still in use cannot go. */
3846
+ function __rollbackAudioCues(layers, items) {
3847
+ for (var i = layers.length - 1; i >= 0; i--) {
3848
+ try { layers[i].remove(); } catch (e) {}
3849
+ }
3850
+ for (var j = items.length - 1; j >= 0; j--) {
3851
+ try { items[j].remove(); } catch (e2) {}
3852
+ }
3853
+ }
3854
+
3855
+ /**
3856
+ * The Time Remap expression that loops a cue. It is the pattern measured in
3857
+ * issue #85 — comp time since the layer began, wrapped at the file's length —
3858
+ * with two substitutions: `startTime` rather than `inPoint`, so trimming the
3859
+ * in point hides the front of the file the way it does on every other layer
3860
+ * instead of delaying it; and the source's duration read live off the layer,
3861
+ * so a relinked file keeps looping. An expression cannot read a layer's
3862
+ * stretch, so a stretched cue has its factor baked in, and that constant goes
3863
+ * stale if the stretch is changed by hand afterwards.
3864
+ */
3865
+ function __loopExpression(p) {
3866
+ var elapsed = "(time - startTime)";
3867
+ if (p.stretch !== null && p.stretch !== 100) elapsed = "((time - startTime) * 100 / " + p.stretch + ")";
3868
+ return elapsed + " % thisLayer.source.duration";
3869
+ }
3870
+
3871
+ /**
3872
+ * `created` is the rollback list, and the layer joins it the instant it exists
3873
+ * rather than once it is fully configured. A cue that dies between add() and
3874
+ * the last setValue is exactly the case rollback is for, and a layer that had
3875
+ * not been registered yet would be the one thing left behind.
3876
+ *
3877
+ * The order after that is not free to change. Each of the #85 options moves
3878
+ * something After Effects then resets: `stretch` moves startTime, so startTime
3879
+ * is set after it; enabling time remap resets the out point, so the extent is
3880
+ * set after that; and the fade keys sit at the in/out the layer actually has,
3881
+ * so they go last. Out point before in point throughout — extending first is
3882
+ * what lets an in point past the file's natural end land on a looped layer.
3883
+ */
3884
+ function __placeAudioCue(comp, p, item, created, floorDb) {
3885
+ var layer = comp.layers.add(item);
3886
+ created.push(layer);
3887
+ layer.name = p.name;
3888
+ if (p.stretch !== null) layer.stretch = p.stretch;
3889
+ // startTime first: it slides the whole layer and would drag any trim with it.
3890
+ layer.startTime = p.time;
3891
+ var levels = __audioLevelsProperty(layer);
3892
+ if (!levels) {
3893
+ throw new Error(
3894
+ "the layer created for \"" + item.name + "\" has no Audio Levels property, so its level could not be set"
3895
+ );
3896
+ }
3897
+ if (p.label !== null) layer.label = p.label;
3898
+
3899
+ if (p.loop) {
3900
+ // `=== false`, as with hasAudio: a layer that does not answer the question
3901
+ // is not refused on the strength of a missing property.
3902
+ if (layer.canSetTimeRemapEnabled === false) {
3903
+ throw new Error(
3904
+ "After Effects reports that time remapping cannot be enabled on the layer created for \"" + item.name + "\", so it cannot loop"
3905
+ );
3906
+ }
3907
+ layer.timeRemapEnabled = true;
3908
+ var remap = __timeRemapProperty(layer);
3909
+ if (!remap) {
3910
+ throw new Error(
3911
+ "time remapping was enabled on the layer created for \"" + item.name + "\" but its Time Remap property could not be found, so the loop expression could not be set"
3912
+ );
3913
+ }
3914
+ // The two keyframes AE created when remapping was enabled stay exactly
3915
+ // where they are. Removing them hides the property, and the next write to
3916
+ // it throws (issue #86); the expression overrides them anyway.
3917
+ remap.expression = __loopExpression(p);
3918
+ layer.outPoint = p.loopEnd;
3919
+ if (p.inPoint !== null) layer.inPoint = p.inPoint;
3920
+ } else {
3921
+ if (p.outPoint !== null) layer.outPoint = p.outPoint;
3922
+ if (p.inPoint !== null) layer.inPoint = p.inPoint;
3923
+ }
3924
+
3925
+ // AE's Audio Levels is itself in decibels, one entry per channel.
3926
+ var level = [p.levelDb, p.levelDb];
3927
+ if (p.fadeIn > 0 || p.fadeOut > 0) {
3928
+ var inP = layer.inPoint;
3929
+ var outP = layer.outPoint;
3930
+ var dur = outP - inP;
3931
+ // The planner measured the fade against what it could know; this is the
3932
+ // extent After Effects actually gave the layer, and the last line.
3933
+ if (p.fadeIn + p.fadeOut > dur + __CUE_TIME_EPS) {
3934
+ throw new Error(
3935
+ "its fades (" + __fmtSeconds(p.fadeIn) + " in, " + __fmtSeconds(p.fadeOut) + " out) are longer than the " +
3936
+ __fmtSeconds(dur) + " After Effects gave the layer (" + __fmtSeconds(inP) + " to " + __fmtSeconds(outP) + ")"
3937
+ );
3938
+ }
3939
+ var floor = [floorDb, floorDb];
3940
+ if (p.fadeIn > 0) {
3941
+ levels.setValueAtTime(inP, floor);
3942
+ levels.setValueAtTime(inP + p.fadeIn, level);
3943
+ }
3944
+ if (p.fadeOut > 0) {
3945
+ levels.setValueAtTime(outP - p.fadeOut, level);
3946
+ levels.setValueAtTime(outP, floor);
3947
+ }
3948
+ } else {
3949
+ levels.setValue(level);
3950
+ }
3951
+ return layer;
3952
+ }
3953
+
3954
+ /**
3955
+ * What the caller gets back per cue: the id it has to carry forward, the name,
3956
+ * the time, and — only where an option could have changed it — what After
3957
+ * Effects actually did. Never an echo of the request.
3958
+ */
3959
+ function __placedEntry(layer, p) {
3960
+ var entry = { layerId: layer.id, name: layer.name, time: p.time };
3961
+ // Read the trim back: AE clamps an in/out point to what the source can
3962
+ // actually supply, and the caller should see what it got.
3963
+ if (p.inPoint !== null) entry.inPoint = layer.inPoint;
3964
+ if (p.outPoint !== null || p.loop || p.stretch !== null) entry.outPoint = layer.outPoint;
3965
+ if (p.loop) entry.looped = true;
3966
+ if (p.stretch !== null) entry.stretch = layer.stretch;
3967
+ if (p.fadeIn > 0) entry.fadeIn = p.fadeIn;
3968
+ if (p.fadeOut > 0) entry.fadeOut = p.fadeOut;
3969
+ return entry;
3970
+ }
3971
+
3972
+ OPS.place_audio_cues = noUndoWhen(
3973
+ // dryRun is not an undo step either. A plan that quietly appeared in the
3974
+ // user's undo history would make "this changed nothing" false in the one
3975
+ // place they can see it.
3976
+ function (args) { return !!(args && args.dryRun === true); },
3977
+ function (args) {
3978
+ var comp = getCompById(args.compId);
3979
+ var total = 0;
3980
+ if (args.cues instanceof Array) total = args.cues.length;
3981
+ var plan = __planAudioCues(comp, args);
3982
+
3983
+ if (args.dryRun === true) return __audioDryRunReport(comp, plan, total);
3984
+ if (plan.problems.length > 0) throw new Error(__audioProblemMessage(plan.problems, total));
3985
+
3986
+ var createdLayers = [];
3987
+ var importedItems = [];
3988
+ var importedReport = [];
3989
+ var reusedReport = [];
3990
+ var reusedSeen = {};
3991
+ var placed = [];
3992
+ var anyFade = false;
3993
+
3994
+ try {
3995
+ // One import per distinct file, before any layer exists, so a bad file
3996
+ // costs nothing but the import itself.
3997
+ var imported = {};
3998
+ for (var i = 0; i < plan.toImport.length; i++) {
3999
+ var spec = plan.toImport[i];
4000
+ var newItem = __importFile(spec.file, spec.path, false);
4001
+ importedItems.push(newItem);
4002
+ var why = __audioItemProblem(newItem);
4003
+ if (why) throw new Error("imported " + spec.path + " and then found that " + why);
4004
+ imported[spec.fsName] = newItem;
4005
+ importedReport.push(__sourceReport(newItem, spec.path));
4006
+ }
4007
+
4008
+ // The fades the planner could not measure — on files it had not seen —
4009
+ // are measured now, with every duration known and no layer yet made, so
4010
+ // a misfit still names every offending cue at once and costs only the
4011
+ // imports, which the rollback takes back.
4012
+ var lateProblems = [];
4013
+ for (var f = 0; f < plan.planned.length; f++) {
4014
+ var pf = plan.planned[f];
4015
+ if (!pf.fadeUnchecked) continue;
4016
+ var fit = __fadeFitProblem(pf, __cueExtent(pf, __itemDuration(imported[pf.source.fsName])));
4017
+ if (fit === "unknown") {
4018
+ fit = "After Effects did not report a duration for the imported " + pf.source.path + ", so its fade cannot be checked";
4019
+ }
4020
+ if (fit !== null) lateProblems.push({ cue: pf.index, reason: fit });
4021
+ }
4022
+ if (lateProblems.length > 0) {
4023
+ throw new Error(
4024
+ "the fades on " + lateProblems.length + " cue(s) do not fit the files once imported — " + __audioProblemLines(lateProblems)
4025
+ );
4026
+ }
4027
+
4028
+ for (var k = 0; k < plan.planned.length; k++) {
4029
+ var p = plan.planned[k];
4030
+ var item = p.source.item;
4031
+ if (!item) item = imported[p.source.fsName];
4032
+ if (p.source.kind !== "import" && !reusedSeen.hasOwnProperty(String(item.id))) {
4033
+ reusedSeen[String(item.id)] = true;
4034
+ reusedReport.push(__sourceReport(item, p.source.path));
4035
+ }
4036
+ var layer;
4037
+ try {
4038
+ layer = __placeAudioCue(comp, p, item, createdLayers, plan.floorDb);
4039
+ } catch (eCue) {
4040
+ throw new Error("cue " + p.index + " (\"" + p.name + "\" at " + p.time + "s): " + eCue.message);
4041
+ }
4042
+ if (p.fadeIn > 0 || p.fadeOut > 0) anyFade = true;
4043
+ placed.push(__placedEntry(layer, p));
4044
+ }
4045
+ } catch (e) {
4046
+ __rollbackAudioCues(createdLayers, importedItems);
4047
+ throw new Error(
4048
+ "place_audio_cues failed on " + ((e && e.message) ? e.message : String(e)) +
4049
+ ". The " + createdLayers.length + " layer(s) and " + importedItems.length +
4050
+ " import(s) it had already made were removed, so the comp and project are as they were."
4051
+ );
4052
+ }
1851
4053
 
1852
- var layer = comp.layers.add(item);
1853
- if (typeof args.name === "string" && args.name.length > 0) layer.name = args.name;
1854
- if (args.position !== undefined && args.position !== null) {
1855
- layer.property("Transform").property("Position").setValue(args.position);
4054
+ var out = {
4055
+ compId: comp.id,
4056
+ placed: placed,
4057
+ count: placed.length,
4058
+ sources: { imported: importedReport, reused: reusedReport },
4059
+ levelUnit: "dB"
4060
+ };
4061
+ if (anyFade) out.fadeFloorDb = plan.floorDb;
4062
+ return out;
1856
4063
  }
1857
- if (args.startTime !== undefined && args.startTime !== null) layer.startTime = args.startTime;
1858
-
1859
- return {
1860
- layerId: layer.id,
1861
- index: layer.index,
1862
- name: layer.name,
1863
- compId: comp.id,
1864
- itemId: item.id,
1865
- inPoint: layer.inPoint,
1866
- outPoint: layer.outPoint
1867
- };
1868
- };
4064
+ );
1869
4065
 
1870
4066
 
1871
4067
  // ===== mogrt.jsx =====
@@ -1901,6 +4097,19 @@ OPS.create_footage_layer = function (args) {
1901
4097
  // a project writes Untitled.mogrt over the last one.
1902
4098
  // * The export invalidates every object reference held across it, including
1903
4099
  // `app.project`. See the re-fetch below.
4100
+ //
4101
+ // And one thing suppression costs, which is the whole of issue #71.
4102
+ // exportAsMotionGraphicsTemplate reports a boolean and nothing else — AE never
4103
+ // says *why* it declined — so once dialogs are suppressed a refusal and a
4104
+ // success-that-wrote-nothing look identical from a script. That silence is
4105
+ // evidence of nothing in particular, and it must not be read as "a dialog
4106
+ // blocked us": under beginSuppressDialogs() a blocking dialog is the one cause
4107
+ // ruled out by construction, because suppressing them is exactly what that call
4108
+ // did. A comp with an empty Essential Graphics panel is not exportable, and it
4109
+ // used to come back blaming a dialog that did not exist and offering a remedy
4110
+ // (retry unsuppressed and click it) that led nowhere. So: check every
4111
+ // precondition that can be checked *before* the export and name it, and when
4112
+ // the export still fails, report the cause as unknown rather than as a dialog.
1904
4113
 
1905
4114
  /** Distinct fonts used by the text layers in a comp, following nested comps. */
1906
4115
  function __collectFonts(comp, depth, seenComps, fonts) {
@@ -1937,6 +4146,106 @@ function __mogrtFileName(name) {
1937
4146
  return String(name) + ".mogrt";
1938
4147
  }
1939
4148
 
4149
+ /** Trailing separators off, so two spellings of the same folder compare equal. */
4150
+ function __trimTrailingSeparator(p) {
4151
+ var s = String(p);
4152
+ while (s.length > 1) {
4153
+ var last = s.charAt(s.length - 1);
4154
+ if (last !== "/" && last !== "\\") break;
4155
+ s = s.substring(0, s.length - 1);
4156
+ }
4157
+ return s;
4158
+ }
4159
+
4160
+ function __isBlank(s) {
4161
+ var t = String(s);
4162
+ for (var i = 0; i < t.length; i++) {
4163
+ var ch = t.charAt(i);
4164
+ if (ch !== " " && ch !== "\t" && ch !== "\n" && ch !== "\r") return false;
4165
+ }
4166
+ return true;
4167
+ }
4168
+
4169
+ /**
4170
+ * How many controllers the comp's Essential Graphics panel holds, or null when
4171
+ * the host will not say.
4172
+ *
4173
+ * null is not zero. A read that throws means this AE cannot be asked, and
4174
+ * refusing an export on the strength of a question that was never answered
4175
+ * would be its own confidently wrong diagnosis. Only an explicit 0 refuses.
4176
+ */
4177
+ function __controllerCount(comp) {
4178
+ var n = null;
4179
+ try { n = comp.motionGraphicsTemplateControllerCount; } catch (e) { return null; }
4180
+ if (typeof n !== "number") return null;
4181
+ return n;
4182
+ }
4183
+
4184
+ /**
4185
+ * Whether After Effects can actually write into the destination folder.
4186
+ *
4187
+ * ExtendScript has no permission API, so the only reliable test is to write.
4188
+ * A folder that exists and refuses writes is another cause the export reports
4189
+ * as nothing at all, and it is worth naming before spending the export rather
4190
+ * than after. The probe is removed on every path; it never outlives the call.
4191
+ */
4192
+ function __folderIsWritable(folder) {
4193
+ var probe = new File(__joinPath(folder.fsName, ".ae-mcp-write-probe"));
4194
+ var opened = false;
4195
+ try {
4196
+ opened = probe.open("w");
4197
+ if (opened) probe.write("");
4198
+ } catch (eW) {
4199
+ opened = false;
4200
+ }
4201
+ try { probe.close(); } catch (eC) {}
4202
+ try { if (probe.exists) probe.remove(); } catch (eR) {}
4203
+ return opened === true;
4204
+ }
4205
+
4206
+ /**
4207
+ * The failure message for an export that wrote nothing.
4208
+ *
4209
+ * Built rather than fixed, because the only honest message names what was
4210
+ * actually established and stops there. `suppressed` is the hinge: with dialogs
4211
+ * suppressed a modal cannot be the cause and must not be mentioned as one
4212
+ * (issue #71); without suppression it is the first thing to look at. Everything
4213
+ * after that is unknown, and says so — AE's own UI is the only place the real
4214
+ * reason exists, which is why the diagnostic step is to run the export by hand.
4215
+ */
4216
+ function __exportFailureMessage(info) {
4217
+ var lines = [];
4218
+ lines.push(
4219
+ "After Effects did not write a template. It returned " + String(info.exported) +
4220
+ " and there is no file at " + info.outPath + "."
4221
+ );
4222
+ lines.push(
4223
+ "Ruled out before the export ran: the project is saved (" + info.projectPath + "); the destination " +
4224
+ "folder exists and accepts writes; the template name resolves to that folder; and the comp has " +
4225
+ info.controllerText + "."
4226
+ );
4227
+ if (!info.suppressed) {
4228
+ lines.push(
4229
+ "Dialogs were NOT suppressed for this call, so a modal dialog waiting in After Effects is the " +
4230
+ "likeliest cause — switch to After Effects and look for one. Retry with the default " +
4231
+ "suppressDialogs:true once it is cleared."
4232
+ );
4233
+ } else {
4234
+ lines.push(
4235
+ "Dialogs were suppressed, so a modal dialog cannot be what stopped this — do not go looking for " +
4236
+ "one to click. After Effects reported no reason at all, and there is no reason for a script to " +
4237
+ "read, so the cause is unknown."
4238
+ );
4239
+ lines.push(
4240
+ "To find out what After Effects would have said, ask the user to export the same comp by hand: " +
4241
+ "Window > Essential Graphics, select the comp, then Export Motion Graphics Template. After " +
4242
+ "Effects shows its own error there. Running this tool again with suppressDialogs:false shows the " +
4243
+ "same dialog, but it will freeze this connection until someone clicks it."
4244
+ );
4245
+ }
4246
+ return lines.join(" ");
4247
+ }
4248
+
1940
4249
  OPS.export_mogrt = noUndo(function (args) {
1941
4250
  var comp = getCompById(args.compId);
1942
4251
 
@@ -1952,6 +4261,29 @@ OPS.export_mogrt = noUndo(function (args) {
1952
4261
  );
1953
4262
  }
1954
4263
 
4264
+ // Every precondition that can be established from a script runs here, before
4265
+ // the project is touched at all: the template is not renamed, the project is
4266
+ // not saved and no export is attempted until they all pass. A refusal below
4267
+ // therefore costs the user nothing and names its own remedy — which is the
4268
+ // whole point, since a failure *after* the export names nothing.
4269
+
4270
+ // The Essential Graphics panel has to have something in it. AE will not build
4271
+ // a template with no controllers, and under suppression it declines without a
4272
+ // word — which is what made this look like a dialog (issue #71). An explicit
4273
+ // 0 is the whole diagnosis; a count the host refuses to give is not.
4274
+ var preControllerCount = __controllerCount(comp);
4275
+ if (preControllerCount === 0) {
4276
+ throw new Error(
4277
+ "This comp has 0 Essential Graphics controllers, and After Effects cannot export a template " +
4278
+ "from a comp with an empty Essential Graphics panel. It refuses without writing a file and " +
4279
+ "without reporting anything a script can read, so nothing was exported and nothing in the " +
4280
+ "project was changed. To fix it, put at least one property into the panel: in After Effects " +
4281
+ "open Window > Essential Graphics, pick this comp in the panel's dropdown, then drag a layer " +
4282
+ "property into it — a text layer's Source Text, a colour, a slider, a position. Then call " +
4283
+ "export_mogrt again."
4284
+ );
4285
+ }
4286
+
1955
4287
  var templateName = (typeof args.name === "string" && args.name.length > 0) ? args.name : null;
1956
4288
  var previousTemplateName = null;
1957
4289
  try { previousTemplateName = comp.motionGraphicsTemplateName; } catch (e) {}
@@ -1959,7 +4291,16 @@ OPS.export_mogrt = noUndo(function (args) {
1959
4291
  // AE's own default is "Untitled" for a template built by script, which
1960
4292
  // silently collides with every other comp in the project. The comp name is
1961
4293
  // what the user would have typed. A name they *did* type is left alone.
1962
- templateName = (!previousTemplateName || previousTemplateName === "Untitled") ? comp.name : previousTemplateName;
4294
+ if (!previousTemplateName || previousTemplateName === "Untitled") {
4295
+ templateName = comp.name;
4296
+ } else {
4297
+ templateName = previousTemplateName;
4298
+ }
4299
+ }
4300
+ if (__isBlank(templateName)) {
4301
+ throw new Error(
4302
+ "The template name is empty, and it becomes the .mogrt filename. Pass a `name`."
4303
+ );
1963
4304
  }
1964
4305
 
1965
4306
  var destDir = (typeof args.destDir === "string" && args.destDir.length > 0)
@@ -1974,6 +4315,22 @@ OPS.export_mogrt = noUndo(function (args) {
1974
4315
 
1975
4316
  var outPath = __joinPath(folder.fsName, __mogrtFileName(templateName));
1976
4317
  var outFile = new File(outPath);
4318
+
4319
+ // The name becomes a filename, so a separator in it sends the write somewhere
4320
+ // else — or nowhere, silently. Measured rather than pattern-matched: ask the
4321
+ // File where it actually landed and compare. That works the same on both
4322
+ // platforms, which a list of illegal characters would not.
4323
+ var resolvedParent = null;
4324
+ try { resolvedParent = outFile.parent.fsName; } catch (eP) {}
4325
+ if (resolvedParent !== null &&
4326
+ __trimTrailingSeparator(resolvedParent) !== __trimTrailingSeparator(folder.fsName)) {
4327
+ throw new Error(
4328
+ "The template name \"" + templateName + "\" is used as the .mogrt filename, and it resolves to " +
4329
+ outPath + ", which is not inside " + folder.fsName + ". Pass a `name` with no slashes in it, and " +
4330
+ "a `destDir` if you want it written somewhere else."
4331
+ );
4332
+ }
4333
+
1977
4334
  var existed = outFile.exists;
1978
4335
  if (existed && args.overwrite !== true) {
1979
4336
  throw new Error(
@@ -1982,6 +4339,17 @@ OPS.export_mogrt = noUndo(function (args) {
1982
4339
  );
1983
4340
  }
1984
4341
 
4342
+ // Last of the checkable preconditions, and the last thing that costs nothing
4343
+ // to get wrong. A folder that will not accept a file is one more cause the
4344
+ // export reports as silence.
4345
+ if (!__folderIsWritable(folder)) {
4346
+ throw new Error(
4347
+ "After Effects cannot write into " + folder.fsName + " — a test file could not be created there. " +
4348
+ "Nothing was exported. Pass a `destDir` the user can write to, or ask them to fix the folder's " +
4349
+ "permissions."
4350
+ );
4351
+ }
4352
+
1985
4353
  var fonts = [];
1986
4354
  try { __collectFonts(comp, 0, [], fonts); } catch (eF) {}
1987
4355
 
@@ -1995,6 +4363,7 @@ OPS.export_mogrt = noUndo(function (args) {
1995
4363
  // Everything the result needs, read *before* the export. See below.
1996
4364
  var compId = comp.id;
1997
4365
  var compName = comp.name;
4366
+ var projectPath = app.project.file.fsName;
1998
4367
 
1999
4368
  var suppress = args.suppressDialogs !== false;
2000
4369
  var suppressed = false;
@@ -2023,10 +4392,21 @@ OPS.export_mogrt = noUndo(function (args) {
2023
4392
  // codebase refuses to pass on.
2024
4393
  var written = new File(outPath);
2025
4394
  if (!written.exists) {
2026
- throw new Error(
2027
- "After Effects reported " + String(exported) + " for the export but no file appeared at " + outPath +
2028
- ". If suppressDialogs was false, a modal dialog in After Effects may have cancelled it."
2029
- );
4395
+ var controllerText;
4396
+ if (preControllerCount === null) {
4397
+ controllerText = "controllers this version of After Effects would not count";
4398
+ } else if (preControllerCount === 1) {
4399
+ controllerText = "1 Essential Graphics controller";
4400
+ } else {
4401
+ controllerText = String(preControllerCount) + " Essential Graphics controllers";
4402
+ }
4403
+ throw new Error(__exportFailureMessage({
4404
+ exported: exported,
4405
+ outPath: outPath,
4406
+ projectPath: projectPath,
4407
+ suppressed: suppress,
4408
+ controllerText: controllerText
4409
+ }));
2030
4410
  }
2031
4411
  var bytes = written.length;
2032
4412
  if (!(bytes > 0)) throw new Error("The exported template at " + outPath + " is empty (0 bytes).");
@@ -2042,7 +4422,9 @@ OPS.export_mogrt = noUndo(function (args) {
2042
4422
  createdDir: createdDir,
2043
4423
  projectSaved: app.project.file.fsName,
2044
4424
  dialogsSuppressed: suppress,
2045
- controllerCount: comp.motionGraphicsTemplateControllerCount,
4425
+ // Re-read rather than echoed: the export is the only thing that can change
4426
+ // it, and a host that will not answer says null instead of a made-up 0.
4427
+ controllerCount: __controllerCount(comp),
2046
4428
  fonts: fonts
2047
4429
  };
2048
4430
  if (!suppress) {
@@ -2175,6 +4557,11 @@ OPS.set_house_style = noUndo(function (args) {
2175
4557
  }
2176
4558
 
2177
4559
  file.encoding = "UTF-8";
4560
+ // ExtendScript's default lineFeed on macOS is "Macintosh", which turns every
4561
+ // "\n" into a bare "\r" on disk (measured on 26.3: a guide written through
4562
+ // this op read back fine, because File.read normalises, but opened as one
4563
+ // long line in a text editor). The file is meant to be hand-edited.
4564
+ file.lineFeed = "Unix";
2178
4565
  if (!file.open("w")) throw new Error("Could not open " + file.fsName + " for writing");
2179
4566
  try {
2180
4567
  if (!file.write(content)) throw new Error("Write failed for " + file.fsName);
@@ -2196,32 +4583,66 @@ OPS.set_house_style = noUndo(function (args) {
2196
4583
 
2197
4584
  // batch.jsx — execute many ops in one ExtendScript pass.
2198
4585
  // For huge batches, registers a job and processes in chunks via _continue_job.
4586
+ //
4587
+ // UNDO GROUPING, which is the whole design constraint here (issue #69):
4588
+ // After Effects **discards** an undo group opened in one `evalScript` call and
4589
+ // closed in another. Measured, not inferred — a 600-op batch that opened its
4590
+ // group in `run_batch` and closed it from a later `_continue_job` produced ~600
4591
+ // undo steps, and AE's Edit menu read `Undo New Solid` rather than the group's
4592
+ // name. So a group only survives if it opens and closes inside one call, and
4593
+ // that gives the two paths below genuinely different answers:
4594
+ //
4595
+ // inline (<= 500 ops, or any size with singleUndo) — the whole batch runs in
4596
+ // one call, so one group round it is real. One undo step.
4597
+ // chunked (> 500 ops) — the batch spans N calls, so the most AE will keep is
4598
+ // one group per chunk. N undo steps, counted and reported, never claimed
4599
+ // to be one.
4600
+
4601
+ // The inline cutoff. Inline stays sub-second for typical create/keyframe ops up
4602
+ // to a few hundred; the async-job overhead (jobId envelope, polling, progress
4603
+ // notifications) is only worth it for genuinely long jobs.
4604
+ var __BATCH_INLINE_MAX = 500;
4605
+ // Chunk size for the async path. The panel reads this back off the envelope
4606
+ // rather than holding its own copy, so the two cannot drift.
4607
+ var __BATCH_CHUNK = 25;
4608
+ // The ceiling on singleUndo. Past this, one blocking ExtendScript call freezes
4609
+ // After Effects for long enough that the user will think it has hung, with no
4610
+ // progress events to say otherwise — so it is refused rather than attempted.
4611
+ var __BATCH_SINGLE_UNDO_MAX = 2000;
2199
4612
 
2200
4613
  OPS.run_batch = function (args) {
2201
4614
  var ops = args.ops || [];
2202
4615
  var transactional = args.transactional !== false;
2203
4616
  var name = args.undoGroupName || "AE MCP Batch";
2204
- // Short batches: run inline synchronously. Inline stays sub-second for
2205
- // typical create/keyframe ops up to a few hundred; the async-job overhead
2206
- // (jobId envelope, polling, progress notifications) is only worth it for
2207
- // genuinely long jobs.
2208
- if (ops.length <= 500) {
2209
- var results = []; var errors = [];
2210
- for (var i = 0; i < ops.length; i++) {
2211
- var step = ops[i];
2212
- try {
2213
- var handler = OPS[step.op];
2214
- if (!handler) throw new Error("Unknown op: " + step.op);
2215
- results.push(handler(step.args || {}));
2216
- } catch (e) {
2217
- errors.push({ index: i, op: step.op, error: e.message });
2218
- if (transactional) throw new Error("Batch failed at op[" + i + "] " + step.op + ": " + e.message);
2219
- }
2220
- }
2221
- return { results: results, errors: errors, total: ops.length };
4617
+ var singleUndo = args.singleUndo === true;
4618
+
4619
+ // Refused before anything else happens, including the diff fingerprint: a
4620
+ // call that is not going to run should cost nothing.
4621
+ if (singleUndo && ops.length > __BATCH_SINGLE_UNDO_MAX) {
4622
+ throw new Error(
4623
+ "singleUndo refuses " + ops.length + " ops (the limit is " + __BATCH_SINGLE_UNDO_MAX + "). " +
4624
+ "One undo step means the whole batch runs inside a single ExtendScript call, which " +
4625
+ "freezes After Effects' interface for the whole of it and reports no progress; at this " +
4626
+ "size the user would reasonably think it had hung. Either split the work into batches of " +
4627
+ __BATCH_SINGLE_UNDO_MAX + " or fewer, or drop singleUndo and accept one undo step per " +
4628
+ "chunk of " + __BATCH_CHUNK + " (the result says exactly how many)."
4629
+ );
2222
4630
  }
4631
+
4632
+ // diff:true fingerprints the comps this batch names, before and after, inside
4633
+ // this one call — see snapshot.jsx. Null unless asked for.
4634
+ var diffState = __diffStart(args, ops);
4635
+
4636
+ if (ops.length <= __BATCH_INLINE_MAX || singleUndo) {
4637
+ return __batchInline(ops, transactional, name, diffState);
4638
+ }
4639
+
2223
4640
  // Long batches: register job, return jobId; panel polls _continue_job.
4641
+ // No undo group is opened here on purpose. One opened now would be closed
4642
+ // from a different call and AE would throw it away, which is exactly how this
4643
+ // op spent three releases reporting one undo step and delivering hundreds.
2224
4644
  var jobId = __newJobId();
4645
+ var estimate = Math.ceil(ops.length / __BATCH_CHUNK);
2225
4646
  JOBS[jobId] = {
2226
4647
  cursor: 0,
2227
4648
  total: ops.length,
@@ -2231,48 +4652,212 @@ OPS.run_batch = function (args) {
2231
4652
  cancelled: false,
2232
4653
  transactional: transactional,
2233
4654
  name: name,
4655
+ chunkSize: __BATCH_CHUNK,
4656
+ // Counted as groups are actually opened, never predicted. See __UNDO_GROUPS.
4657
+ undoSteps: 0,
4658
+ // Carried across the chunked continuations: the before-fingerprint has to
4659
+ // outlive the call that took it, or a long batch could never be diffed.
4660
+ diffState: diffState,
4661
+ };
4662
+ return {
4663
+ jobId: jobId,
4664
+ async: true,
4665
+ total: ops.length,
4666
+ chunkSize: __BATCH_CHUNK,
4667
+ // Labelled an estimate because it is one: a transactional failure ends the
4668
+ // batch early, and the exact count comes back with the final result.
4669
+ undoStepsEstimate: estimate,
4670
+ undoGroupName: name,
4671
+ note: "This batch runs in " + estimate + " chunks of " + __BATCH_CHUNK + " and will land as " +
4672
+ "about " + estimate + " undo steps, NOT one — After Effects discards an undo group that " +
4673
+ "spans two script calls, so a chunked batch can only group per chunk. The final result " +
4674
+ "reports the exact number. If the user needs a single Cmd-Z, re-send with singleUndo:true " +
4675
+ "(up to " + __BATCH_SINGLE_UNDO_MAX + " ops), which runs the whole batch in one blocking " +
4676
+ "call with no progress events.",
2234
4677
  };
2235
- // Wrap async run already inside an undoGroup chain. We open it now, the
2236
- // continuations stay inside it until finalization.
2237
- app.beginUndoGroup(name);
2238
- JOBS[jobId].undoOpen = true;
2239
- return { jobId: jobId, async: true, total: ops.length };
2240
4678
  };
2241
- // run_batch is allowed to manage its own undo (we open/close it manually for long jobs).
4679
+ // run_batch is allowed to manage its own undo: the inline path opens exactly one
4680
+ // group of its own, and the chunked path opens one per continuation.
2242
4681
  OPS.run_batch.__meta = { noUndo: true };
2243
4682
 
4683
+ // The inline path: every op inside one undo group inside one evalScript, which
4684
+ // is the only shape After Effects keeps.
4685
+ //
4686
+ // withUndo() rather than a bare beginUndoGroup/endUndoGroup pair, for two
4687
+ // reasons: its `finally` closes the group when a transactional op throws, and it
4688
+ // is the only thing that keeps __UNDO_OPEN honest, so a batched op that calls
4689
+ // withoutUndoGroup() still works instead of silently doing nothing.
4690
+ function __batchInline(ops, transactional, name, diffState) {
4691
+ var results = [];
4692
+ var errors = [];
4693
+ var before = __undoGroupsOpened();
4694
+ // A run_batch listed as an op inside another run_batch would otherwise open a
4695
+ // second group inside the first, and AE's groups do not nest: the inner
4696
+ // endUndoGroup closes the outer one and the rest of the outer batch writes
4697
+ // ungrouped. Run inside the group that is already open instead, and say so —
4698
+ // an inner batch reporting "one undo step" of its own would be a second lie
4699
+ // in the same shape as the one this fixes.
4700
+ var nested = __UNDO_OPEN;
4701
+ // An empty batch opens nothing. AE records no undo step for a group that
4702
+ // changed nothing, so opening one would make `undoSteps: 1` an over-report on
4703
+ // the one call where the truth is unambiguous.
4704
+ var empty = ops.length === 0;
4705
+ var run = function () {
4706
+ for (var i = 0; i < ops.length; i++) {
4707
+ var step = ops[i];
4708
+ try {
4709
+ var handler = OPS[step.op];
4710
+ if (!handler) throw new Error("Unknown op: " + step.op);
4711
+ results.push(handler(step.args || {}));
4712
+ } catch (e) {
4713
+ errors.push({ index: i, op: step.op, error: e.message });
4714
+ if (transactional) {
4715
+ // Nothing rolls back, so the half-applied state is what the caller
4716
+ // has to reason about. The diff says where it stopped.
4717
+ var failure = new Error("Batch failed at op[" + i + "] " + step.op + ": " + e.message);
4718
+ __diffAnnotateError(failure, diffState);
4719
+ throw failure;
4720
+ }
4721
+ }
4722
+ }
4723
+ };
4724
+ try {
4725
+ if (nested || empty) run();
4726
+ else withUndo(name, run);
4727
+ } catch (e) {
4728
+ // The group is already closed by withUndo's finally. Say what it cost, so a
4729
+ // failed batch is as clear about its undo history as a successful one.
4730
+ try {
4731
+ e.message = String(e.message) + " || " +
4732
+ __batchUndoNote(__undoGroupsOpened() - before, name, nested, false);
4733
+ } catch (e2) {}
4734
+ throw e;
4735
+ }
4736
+ var out = {
4737
+ results: results,
4738
+ errors: errors,
4739
+ total: ops.length,
4740
+ undoSteps: __undoGroupsOpened() - before,
4741
+ undoGroupName: name,
4742
+ };
4743
+ if (nested) out.nested = true;
4744
+ out.note = __batchUndoNote(out.undoSteps, name, nested, false);
4745
+ if (diffState) out.diff = __diffFinish(diffState);
4746
+ return out;
4747
+ }
4748
+
4749
+ // The one sentence every batch result carries about its own undo history.
4750
+ // Written for an agent that is about to tell a person how to undo the work.
4751
+ // `steps` is always the measured count, so this describes what happened rather
4752
+ // than what the path intended.
4753
+ function __batchUndoNote(steps, name, nested, chunked) {
4754
+ if (nested) {
4755
+ return "This batch ran inside an undo group that was already open (a run_batch nested in " +
4756
+ "another one), so it adds no undo step of its own — its work is part of the outer step.";
4757
+ }
4758
+ if (steps <= 0) {
4759
+ return "This batch opened no undo group, so it added no undo steps.";
4760
+ }
4761
+ if (steps === 1) {
4762
+ // A chunked batch reaches one step only by stopping after its first chunk —
4763
+ // cancelled, or failed in it — and that group is named "(1)", not the bare
4764
+ // name. Quoting the wrong one would send the user looking for a menu entry
4765
+ // that is not there.
4766
+ return 'This batch is one undo step ("' + name + (chunked ? " (1)" : "") +
4767
+ '"): a single Cmd-Z (Ctrl-Z on Windows) undoes all of it.';
4768
+ }
4769
+ if (chunked) {
4770
+ return "This batch landed as " + steps + " undo steps, NOT one — named \"" + name + " (1)\" " +
4771
+ 'through "' + name + " (" + steps + ')". After Effects discards an undo group that spans ' +
4772
+ "two script calls, so a chunked batch can only group per chunk. Undoing all of it takes up " +
4773
+ "to " + steps + " presses of Cmd-Z (Ctrl-Z on Windows) — tell the user that number rather " +
4774
+ "than saying one. (A chunk whose ops changed nothing records no step, so that is the " +
4775
+ "ceiling.) singleUndo:true buys a single step back, at the cost of freezing AE's interface " +
4776
+ "for the whole batch and emitting no progress.";
4777
+ }
4778
+ return "This batch landed as " + steps + " undo steps rather than one: an op inside it closed " +
4779
+ "the batch's undo group and opened a new one (withoutUndoGroup — After Effects refuses " +
4780
+ "copyToComp on a parented or expression-linked layer while a group is open). Undoing all of " +
4781
+ "it takes up to " + steps + " presses of Cmd-Z (Ctrl-Z on Windows).";
4782
+ }
4783
+
2244
4784
  // Continuation step. Returns one chunk's worth of progress.
4785
+ //
4786
+ // Cancellation, which used to need bookkeeping across calls and no longer does:
4787
+ // _cancel_job is its own evalScript and the panel serializes those, so a cancel
4788
+ // can only ever be observed *between* chunks. A chunk's group opens and closes
4789
+ // inside this one call, so there is never an open group for a cancel — or a
4790
+ // throw, or a dropped WebSocket — to leave behind. That is what the old
4791
+ // `j.undoOpen` flag was for, and it was tracking a group AE had already dropped.
2245
4792
  OPS._continue_job = noUndo(function (args) {
2246
4793
  var jobId = args.jobId;
2247
4794
  var j = JOBS[jobId];
2248
4795
  if (!j) throw new Error("No job: " + jobId);
2249
4796
  if (j.cancelled) {
2250
- if (j.undoOpen) { app.endUndoGroup(); j.undoOpen = false; }
2251
- return { done: true, cancelled: true, jobId: jobId, results: j.results, errors: j.errors };
4797
+ var stopped = {
4798
+ done: true, cancelled: true, jobId: jobId,
4799
+ results: j.results, errors: j.errors,
4800
+ undoSteps: j.undoSteps, undoGroupName: j.name,
4801
+ note: __batchUndoNote(j.undoSteps, j.name, false, true),
4802
+ };
4803
+ if (j.diffState) stopped.diff = __diffFinish(j.diffState);
4804
+ return stopped;
2252
4805
  }
2253
- var chunkSize = args.chunkSize || 25;
4806
+ var chunkSize = args.chunkSize || j.chunkSize || __BATCH_CHUNK;
2254
4807
  var endAt = Math.min(j.cursor + chunkSize, j.total);
2255
- for (; j.cursor < endAt; j.cursor++) {
2256
- var step = j.ops[j.cursor];
2257
- try {
2258
- var handler = OPS[step.op];
2259
- if (!handler) throw new Error("Unknown op: " + step.op);
2260
- j.results.push(handler(step.args || {}));
2261
- } catch (e) {
2262
- j.errors.push({ index: j.cursor, op: step.op, error: e.message });
2263
- if (j.transactional) {
2264
- if (j.undoOpen) { app.endUndoGroup(); j.undoOpen = false; }
2265
- // Attempt rollback via undo
2266
- try { app.executeCommand(app.findMenuCommandId("Undo")); } catch (e2) {}
2267
- return { done: true, failed: true, jobId: jobId, error: e.message, atIndex: j.cursor, results: j.results, errors: j.errors };
4808
+ var before = __undoGroupsOpened();
4809
+ var failure = null;
4810
+ // One group per chunk, opened and closed inside this call. The chunk number
4811
+ // is in the name so the Edit menu says which part of the batch a step is.
4812
+ withUndo(j.name + " (" + (j.undoSteps + 1) + ")", function () {
4813
+ for (; j.cursor < endAt; j.cursor++) {
4814
+ var step = j.ops[j.cursor];
4815
+ try {
4816
+ var handler = OPS[step.op];
4817
+ if (!handler) throw new Error("Unknown op: " + step.op);
4818
+ j.results.push(handler(step.args || {}));
4819
+ } catch (e) {
4820
+ j.errors.push({ index: j.cursor, op: step.op, error: e.message });
4821
+ if (j.transactional) {
4822
+ failure = { error: e.message, atIndex: j.cursor };
4823
+ return;
4824
+ }
2268
4825
  }
2269
4826
  }
4827
+ });
4828
+ j.undoSteps += __undoGroupsOpened() - before;
4829
+
4830
+ if (failure) {
4831
+ // No rollback is attempted. The old code fired one `Undo` menu command
4832
+ // here, which undoes at most the last step even when it works at all —
4833
+ // menu commands depend on host focus and the active selection, neither of
4834
+ // which this bridge has. Reporting a rollback that did not happen is the
4835
+ // same class of lie as swallowing an error, so it says plainly what is on
4836
+ // disk and how many steps it took.
4837
+ var failed = {
4838
+ done: true, failed: true, jobId: jobId,
4839
+ error: failure.error, atIndex: failure.atIndex,
4840
+ results: j.results, errors: j.errors,
4841
+ rolledBack: false,
4842
+ undoSteps: j.undoSteps, undoGroupName: j.name,
4843
+ note: __batchUndoNote(j.undoSteps, j.name, false, true) +
4844
+ " Nothing was rolled back: the ops before op[" + failure.atIndex + "] are applied and " +
4845
+ "stay applied. Read the state back rather than re-running the batch.",
4846
+ };
4847
+ if (j.diffState) failed.diff = __diffFinish(j.diffState);
4848
+ return failed;
2270
4849
  }
2271
4850
  if (j.cursor >= j.total) {
2272
- if (j.undoOpen) { app.endUndoGroup(); j.undoOpen = false; }
2273
- return { done: true, jobId: jobId, results: j.results, errors: j.errors, total: j.total };
4851
+ var done = {
4852
+ done: true, jobId: jobId,
4853
+ results: j.results, errors: j.errors, total: j.total,
4854
+ undoSteps: j.undoSteps, undoGroupName: j.name,
4855
+ note: __batchUndoNote(j.undoSteps, j.name, false, true),
4856
+ };
4857
+ if (j.diffState) done.diff = __diffFinish(j.diffState);
4858
+ return done;
2274
4859
  }
2275
- return { done: false, jobId: jobId, progress: j.cursor, total: j.total };
4860
+ return { done: false, jobId: jobId, progress: j.cursor, total: j.total, undoSteps: j.undoSteps };
2276
4861
  });
2277
4862
 
2278
4863
  OPS._cancel_job = noUndo(function (args) {
@@ -2285,7 +4870,10 @@ OPS._cancel_job = noUndo(function (args) {
2285
4870
  OPS._get_job = noUndo(function (args) {
2286
4871
  var j = JOBS[args.jobId];
2287
4872
  if (!j) return null;
2288
- return { cursor: j.cursor, total: j.total, cancelled: j.cancelled, errorCount: j.errors.length };
4873
+ return {
4874
+ cursor: j.cursor, total: j.total, cancelled: j.cancelled,
4875
+ errorCount: j.errors.length, undoSteps: j.undoSteps,
4876
+ };
2289
4877
  });
2290
4878
 
2291
4879
 
@@ -2787,7 +5375,16 @@ OPS.get_project_summary = noUndo(function (args) {
2787
5375
  };
2788
5376
  });
2789
5377
 
5378
+ // A search, so the default is the opposite of list_layers: absent `include`
5379
+ // means the core alone — id/index/name/sourceType plus the comp — because the
5380
+ // reason to call this is to learn which layers exist and what to address them
5381
+ // by, not to read them (issue #87: nine matches cost ~2k tokens, re-sent on
5382
+ // every later request). `include: []` and omitting it are therefore the same
5383
+ // here, and the section names are list_layers' own, read through the same
5384
+ // __layerSummary. What was included is echoed, so a bounded answer is never
5385
+ // read as a full one.
2790
5386
  OPS.find_layers = noUndo(function (args) {
5387
+ var sections = (args && args.include) ? args.include : [];
2791
5388
  var out = [];
2792
5389
  var comps = [];
2793
5390
  if (args.compId) comps.push(getCompById(args.compId));
@@ -2814,16 +5411,125 @@ OPS.find_layers = noUndo(function (args) {
2814
5411
  }
2815
5412
  if (!hit) continue;
2816
5413
  }
2817
- var s = __layerSummary(l);
5414
+ var s = __layerSummary(l, sections);
2818
5415
  s.compId = c.id;
2819
5416
  s.compName = c.name;
2820
5417
  out.push(s);
2821
5418
  }
2822
5419
  }
2823
- return out;
5420
+ return { matches: out, count: out.length, compsSearched: comps.length, included: sections };
2824
5421
  });
2825
5422
 
2826
5423
 
5424
+ // ===== helpers.jsx =====
5425
+
5426
+ // helpers.jsx — the scope a run_jsx script runs in.
5427
+ //
5428
+ // Everything in this file is a global function, which is what makes it visible
5429
+ // to `eval`d script bodies and to anything loaded through run_jsx's
5430
+ // `libraries`. It exists because the run_jsx description promised "helpers in
5431
+ // scope" and never said which, so every session re-derived the same four or
5432
+ // five functions — find-a-layer-by-id, ease-with-the-right-array-size, a shape
5433
+ // builder that zeros the position (issue #53). Each of these was written from
5434
+ // scratch in the transcript, at token cost, with a fresh chance of getting the
5435
+ // AE quirk wrong.
5436
+ //
5437
+ // The rule for adding one: it has to be something an agent would otherwise
5438
+ // write badly, not merely something it would write often. All four below wrap
5439
+ // a documented AE trap.
5440
+ //
5441
+ // These are listed by signature in the run_jsx tool description. If you change
5442
+ // one, change that too — it is the only place a caller ever sees them.
5443
+
5444
+ // getCompById by a shorter name, because that is the name agents guess.
5445
+ function compById(id) {
5446
+ return getCompById(id);
5447
+ }
5448
+
5449
+ // getLayerById, but the comp may be given as an id. An agent holding
5450
+ // (compId, layerId) — the pair every tool returns — can use it directly.
5451
+ function layerById(comp, layerId) {
5452
+ var c = comp;
5453
+ if (typeof c === "number") c = getCompById(c);
5454
+ return getLayerById(c, layerId);
5455
+ }
5456
+
5457
+ // {influence, speed}, or a bare number read as influence. AE's own default
5458
+ // ease is 33% influence at zero speed, so that is what an omitted field gets.
5459
+ function __hEaseSpec(spec) {
5460
+ if (spec === null || spec === undefined) return { influence: 33, speed: 0 };
5461
+ if (typeof spec === "number") return { influence: spec, speed: 0 };
5462
+ var inf = 33;
5463
+ var spd = 0;
5464
+ if (typeof spec.influence === "number") inf = spec.influence;
5465
+ if (typeof spec.speed === "number") spd = spec.speed;
5466
+ return { influence: inf, speed: spd };
5467
+ }
5468
+
5469
+ // ease(prop, keyIndex, easeIn, easeOut) — sizes its own ease array.
5470
+ //
5471
+ // setTemporalEaseAtKey wants one KeyframeEase per dimension and the count is
5472
+ // NOT derivable from the value: a spatial property takes exactly one whatever
5473
+ // its dimension (the ease runs along the motion path), a 2D layer's Scale takes
5474
+ // three, a shape's Ellipse Size takes two, Opacity and sliders take one. The
5475
+ // wrong count throws "parameter 2" and says nothing else (issue #50).
5476
+ //
5477
+ // The sizing lives in `__applyTemporalEase` in keyframes.jsx, which is what
5478
+ // `set_temporal_ease` and `add_keyframe` use. This helper is the same function
5479
+ // with a friendlier signature — deliberately not a second implementation, so a
5480
+ // script written through run_jsx and the same work done through the tools can
5481
+ // never disagree about what a property wanted.
5482
+ //
5483
+ // easeOut omitted means the same ease on both sides. Returns the number of
5484
+ // entries that worked, so a caller can see what the property actually wanted.
5485
+ function ease(prop, keyIndex, easeIn, easeOut) {
5486
+ var inSpec = __hEaseSpec(easeIn);
5487
+ var outSpec = __hEaseSpec(easeOut === undefined ? easeIn : easeOut);
5488
+ return __applyTemporalEase(prop, keyIndex, inSpec, outSpec);
5489
+ }
5490
+
5491
+ // addKeys(prop, [[time, value], ...]) — or [{time, value}, ...].
5492
+ // Returns the key index of each, in the order given, so the next call can ease
5493
+ // them without searching for them again.
5494
+ function addKeys(prop, pairs) {
5495
+ if (!pairs || !pairs.length) return [];
5496
+ var out = [];
5497
+ for (var i = 0; i < pairs.length; i++) {
5498
+ var p = pairs[i];
5499
+ var t;
5500
+ var v;
5501
+ if (p instanceof Array) {
5502
+ t = p[0];
5503
+ v = p[1];
5504
+ } else {
5505
+ t = p.time;
5506
+ v = p.value;
5507
+ }
5508
+ prop.setValueAtTime(t, v);
5509
+ out.push(prop.nearestKeyIndex(t));
5510
+ }
5511
+ return out;
5512
+ }
5513
+
5514
+ // shape(comp, {name, position}) — a shape layer that lands at [0,0].
5515
+ //
5516
+ // AE spawns a scripted shape layer at the comp centre with its anchor at
5517
+ // (0,0), so paths authored in comp pixels come out offset by half a frame —
5518
+ // easy to miss on a downsampled screenshot (issue #51). Position [0,0] makes
5519
+ // the layer's coordinate space the comp's, which is what path vertices assume.
5520
+ function shape(comp, opts) {
5521
+ var c = comp;
5522
+ if (typeof c === "number") c = getCompById(c);
5523
+ var o = opts || {};
5524
+ var l = c.layers.addShape();
5525
+ if (o.name) l.name = o.name;
5526
+ var pos = o.position ? o.position : [0, 0];
5527
+ var value = (pos.length === 3) ? pos : [pos[0], pos[1]];
5528
+ l.property("Transform").property("Position").setValue(value);
5529
+ return l;
5530
+ }
5531
+
5532
+
2827
5533
  // ===== raw.jsx =====
2828
5534
 
2829
5535
  // raw.jsx — escape hatch. Eval arbitrary ExtendScript and return the value.
@@ -2949,17 +5655,369 @@ function __rjResult(result, undoGroupName) {
2949
5655
  };
2950
5656
  }
2951
5657
 
5658
+ // ---------- Mapping a failure back onto the caller's own script ----------
5659
+ // A failed run_jsx reported After Effects' line number with nothing to measure
5660
+ // it against, and that number does not count from where the caller thinks it
5661
+ // does: the same "line 22" pointed at two different statements in consecutive
5662
+ // calls (issue #46). Nothing rolls back, so an agent that cannot locate the
5663
+ // throw has to read the whole project back to find out where the script stopped.
5664
+ //
5665
+ // Two halves, and the second is the one that actually helps. The caller's line
5666
+ // 1 sits a *counted* distance down the evaluated source: with no libraries the
5667
+ // prefix carries no newline and the distance is zero, and with libraries it is
5668
+ // however many lines of library text were inlined ahead of the script.
5669
+ // __rjBuildSource measures the preamble it actually built rather than asserting
5670
+ // a constant, so the number can never drift from the string it describes —
5671
+ // a hand-written constant beside a string is exactly how the two came apart the
5672
+ // first time. Then the mapped line is reported with its TEXT, which an agent
5673
+ // can act on without trusting any numbering at all.
5674
+ //
5675
+ // The mapping refuses to guess: a line that falls outside the submitted source
5676
+ // is reported as unmappable, never clamped — unless it lands in a library that
5677
+ // was inlined ahead of it, which is a real file with real lines and is named.
5678
+ // A confident wrong line number is worse than none — it sends the reader to a
5679
+ // statement that did not fail.
5680
+ var __RJ_WRAP_PREFIX = "(function(){ ";
5681
+ // The closer sits on its own line. Appended to the caller's last line, a script
5682
+ // ending in a `//` comment commented out its own `})()` and failed to parse for
5683
+ // a reason nothing in the error mentioned.
5684
+ var __RJ_WRAP_SUFFIX = "\n})()";
5685
+
5686
+ // No regex literal: tests/unit/jsx-ternary.mjs strips string and comment
5687
+ // literals from these sources and does not understand regex literals, so one
5688
+ // here could desynchronise its scan of this file. (audio.jsx and footage.jsx do
5689
+ // carry a few; they survive only because their contents happen to pair up.)
5690
+ function __rjLines(s) {
5691
+ return String(s).split("\r\n").join("\n").split("\r").join("\n").split("\n");
5692
+ }
5693
+
5694
+ // The preamble of a call with no libraries: the bare wrapper opener, which
5695
+ // carries no newline, so zero. __rjBuildSource recomputes this per call once
5696
+ // libraries are in it; this is the default __rjSourceInfo falls back on when a
5697
+ // caller hands it no layout.
5698
+ var __RJ_PREAMBLE_LINES = __rjLines(__RJ_WRAP_PREFIX).length - 1;
5699
+
5700
+ function __rjTrim(s) {
5701
+ var t = String(s);
5702
+ var a = 0;
5703
+ var b = t.length;
5704
+ while (a < b && (t.charAt(a) === " " || t.charAt(a) === "\t")) a++;
5705
+ while (b > a && (t.charAt(b - 1) === " " || t.charAt(b - 1) === "\t")) b--;
5706
+ return t.substring(a, b);
5707
+ }
5708
+
5709
+ function __rjClip(s, max) {
5710
+ var t = String(s);
5711
+ if (t.length <= max) return t;
5712
+ return t.substring(0, max) + " ...";
5713
+ }
5714
+
5715
+ // What After Effects reported, mapped onto the source the caller submitted.
5716
+ // Nothing here is fabricated: when the number does not land inside the script,
5717
+ // sourceLine stays null and the server says the number could not be mapped.
5718
+ //
5719
+ // `layout` is what __rjBuildSource returned for this call — how many lines of
5720
+ // preamble sit ahead of the caller's line 1, and where each inlined library
5721
+ // landed in the evaluated source. Omitted, it means the bare wrapper: no
5722
+ // libraries and a zero-line preamble, which is every call without `libraries`.
5723
+ function __rjSourceInfo(e, code, scriptPath, layout) {
5724
+ var lay = layout ? layout : { preambleLines: __RJ_PREAMBLE_LINES, segments: [] };
5725
+ var preamble = lay.preambleLines;
5726
+ var segs = lay.segments ? lay.segments : [];
5727
+ var lines = __rjLines(code);
5728
+ var info = {
5729
+ lineCount: lines.length,
5730
+ rawLine: null,
5731
+ sourceLine: null,
5732
+ sourceText: null
5733
+ };
5734
+ if (scriptPath) info.sourceName = String(scriptPath);
5735
+
5736
+ var raw = null;
5737
+ try {
5738
+ if (e && typeof e.line === "number" && isFinite(e.line)) raw = e.line;
5739
+ } catch (e1) {}
5740
+ info.rawLine = raw;
5741
+
5742
+ // Which line of the *evaluated source* — the wrapper, the inlined libraries
5743
+ // and the caller's script together — the failure sits on. After Effects' own
5744
+ // `line` already counts from there, which is what makes it usable at all.
5745
+ var wrapperLine = null;
5746
+
5747
+ // ExtendScript's Error also carries `source` (the text the error was raised
5748
+ // in) with `start`/`end` character offsets into it, and the documentation
5749
+ // presents those as the better answer, because they need no assumption about
5750
+ // what `line` counts from. On After Effects 2026 they are not offsets at all.
5751
+ //
5752
+ // Probed inside AE, catching from a four-line script that throws on line 4:
5753
+ //
5754
+ // eval("(function(){ var a=1;\nvar b=2;\nvar c=3;\nnope.boom();\n})()")
5755
+ // caught -> { "line": 4, "start": 0, "end": 0, "srcLen": 57 }
5756
+ //
5757
+ // `line` is already correct. `start` and `end` came back 0 on every error
5758
+ // measured, however far into the source it was raised. Read as an offset, a
5759
+ // zero start puts *every* failure on line 1 and prints line 1's text — which
5760
+ // is exactly what issue #46 still did after it was reported fixed, with the
5761
+ // true number demoted to the parenthetical afterwards.
5762
+ //
5763
+ // So the branch survives only for offsets that could actually be real: 0/0 is
5764
+ // After Effects declining to say, not After Effects pointing at the first
5765
+ // character. Do not restore this from the documentation.
5766
+ try {
5767
+ var src = null;
5768
+ if (e && typeof e.source === "string") src = e.source;
5769
+ var start = null;
5770
+ if (e && typeof e.start === "number" && isFinite(e.start)) start = e.start;
5771
+ var end = null;
5772
+ if (e && typeof e.end === "number" && isFinite(e.end)) end = e.end;
5773
+ var offsetsUsable = false;
5774
+ if (start !== null && start > 0) offsetsUsable = true;
5775
+ if (start === 0 && end !== null && end > 0) offsetsUsable = true;
5776
+ if (offsetsUsable && src !== null &&
5777
+ src.substring(0, __RJ_WRAP_PREFIX.length) === __RJ_WRAP_PREFIX) {
5778
+ wrapperLine = __rjLines(src.substring(0, start)).length;
5779
+ }
5780
+ } catch (e2) {}
5781
+ if (wrapperLine === null) wrapperLine = raw;
5782
+ if (wrapperLine === null) return info;
5783
+
5784
+ var mapped = wrapperLine - preamble;
5785
+ if (mapped >= 1 && mapped <= lines.length) {
5786
+ info.sourceLine = mapped;
5787
+ info.sourceText = __rjClip(__rjTrim(lines[mapped - 1]), 200);
5788
+ return info;
5789
+ }
5790
+
5791
+ // Not the caller's script. A library inlined ahead of it is a real file with
5792
+ // real lines, so name it and point into it rather than reporting the number
5793
+ // as unmappable: that file is where the reader has to look, and naming the
5794
+ // script instead would send them to a line that did not fail.
5795
+ for (var s = 0; s < segs.length; s++) {
5796
+ var seg = segs[s];
5797
+ var count = seg.lines.length - 1;
5798
+ var within = wrapperLine - seg.firstLine + 1;
5799
+ if (within >= 1 && within <= count) {
5800
+ info.sourceName = seg.path;
5801
+ info.lineCount = count;
5802
+ info.sourceLine = within;
5803
+ info.sourceText = __rjClip(__rjTrim(seg.lines[within - 1]), 200);
5804
+ return info;
5805
+ }
5806
+ }
5807
+ return info;
5808
+ }
5809
+
5810
+ // Rethrow with the mapping attached. dispatch()'s catch copies whatever sits on
5811
+ // `aeDetail` onto the error result; the panel forwards the fields it names and
5812
+ // the server prints the line's text. A plain object rather than an Error
5813
+ // because ExtendScript will not reliably let us write `line` on one, and
5814
+ // __mkError only ever reads message/stack/line.
5815
+ function __rjThrowWithSource(e, code, scriptPath, layout) {
5816
+ var info = __rjSourceInfo(e, code, scriptPath, layout);
5817
+ var msg = "";
5818
+ try { if (e && e.message) msg = String(e.message); } catch (e1) {}
5819
+ if (!msg) {
5820
+ try { msg = String(e); } catch (e2) { msg = "ExtendScript error"; }
5821
+ }
5822
+ var stack = "";
5823
+ try { if (e && e.stack) stack = String(e.stack); } catch (e3) {}
5824
+ throw { message: msg, stack: stack, line: info.rawLine, aeDetail: info };
5825
+ }
5826
+
5827
+ // ---------- Helper libraries ----------
5828
+ // A library's source is inlined into the SAME eval as the caller's script,
5829
+ // ahead of it. That is not the obvious design, and it is the only one that
5830
+ // works.
5831
+ //
5832
+ // The first version used $.evalFile, on the documented premise that it
5833
+ // evaluates at global scope — load once, call for the rest of the After Effects
5834
+ // session (issue #53). Probed inside AE 2026, calling $.evalFile from the body
5835
+ // of an eval'd script, on a library declaring `function rig2()` and
5836
+ // `var RIGVAR = 3`:
5837
+ //
5838
+ // {"exists":true, "typeofRig2_local":"function", "typeofRIGVAR_local":"number",
5839
+ // "globalRig2":"undefined", "viaGlobal":null}
5840
+ // // and on the next run_jsx call, in the same AE session:
5841
+ // {"typeofRig2":"undefined", "viaGlobal":"undefined"}
5842
+ //
5843
+ // $.evalFile evaluates into the *calling function's* scope, exactly as eval
5844
+ // does. Everything a library defined therefore lived inside the loader and was
5845
+ // gone before the wrapper ran, so `libraries` answered "Function rig is
5846
+ // undefined" every time, for every library, in every session. The per-session
5847
+ // cache keyed on a content hash has gone with it: nothing was ever left loaded
5848
+ // to reuse, so the only work it ever skipped was work whose result had already
5849
+ // been discarded.
5850
+ //
5851
+ // One eval means one scope: a library's `function helper(){}` is a declaration
5852
+ // in the same function body as the script, so the script can call it. Two
5853
+ // consequences, both handled here rather than left to surprise someone:
5854
+ //
5855
+ // * A library is re-evaluated on every call. That is the price of the scoping
5856
+ // After Effects actually has. Keep libraries to declarations, not to work.
5857
+ // * The library text shifts the caller's line 1 down, which is precisely the
5858
+ // failure issue #46 was about. __rjBuildSource *counts* the preamble it
5859
+ // built rather than asserting a constant, and __rjSourceInfo subtracts that
5860
+ // count — so a caller's line 1 is line 1 of their own script whether they
5861
+ // passed libraries or not.
5862
+
5863
+ // No regex literal, for the same reason __rjLines has none.
5864
+ function __rjIsBlank(s) {
5865
+ var t = String(s);
5866
+ for (var i = 0; i < t.length; i++) {
5867
+ var c = t.charAt(i);
5868
+ if (c !== " " && c !== "\t" && c !== "\n" && c !== "\r") return false;
5869
+ }
5870
+ return true;
5871
+ }
5872
+
5873
+ // Every library ends in a newline before the next one — or the caller's script
5874
+ // — follows it. Appended directly, a library whose last line is a `//` comment
5875
+ // would comment out whatever came after it: the same trap __RJ_WRAP_SUFFIX
5876
+ // exists for at the other end of the wrapper.
5877
+ function __rjEndWithNewline(s) {
5878
+ var t = String(s);
5879
+ if (t.length === 0) return "\n";
5880
+ var last = t.charAt(t.length - 1);
5881
+ if (last === "\n" || last === "\r") return t;
5882
+ return t + "\n";
5883
+ }
5884
+
5885
+ // The server reads library files and sends {path, text}, exactly as it does for
5886
+ // scriptPath — one place reads files, and its errors can name the path it was
5887
+ // given. Arriving with a path and no text means the call did not come through
5888
+ // the run_jsx tool: a run_batch step, whose args are never validated, or a
5889
+ // hand-rolled POST /op.
5890
+ function __rjLibrarySource(lib) {
5891
+ var p = null;
5892
+ var text = null;
5893
+ if (typeof lib === "string") {
5894
+ p = lib;
5895
+ } else if (lib) {
5896
+ p = lib.path;
5897
+ if (typeof lib.text === "string") text = lib.text;
5898
+ }
5899
+ if (!p) throw new Error("run_jsx: a libraries entry has no path.");
5900
+ if (text === null) {
5901
+ throw new Error(
5902
+ "run_jsx library \"" + p + "\" arrived with no source text. The server reads library files " +
5903
+ "and substitutes their contents, so this call did not go through the run_jsx tool — " +
5904
+ "run_batch steps and direct /op posts must pass {path, text} themselves."
5905
+ );
5906
+ }
5907
+ if (__rjIsBlank(text)) throw new Error("run_jsx library is empty: " + p);
5908
+ return { path: String(p), text: __rjEndWithNewline(text) };
5909
+ }
5910
+
5911
+ // Parse each library on its own before any of it reaches the shared eval.
5912
+ // Inlining means a library that does not parse takes the whole wrapper with it,
5913
+ // and the line After Effects reports for a syntax error is wherever its parser
5914
+ // gave up — frequently inside the caller's script, which would blame the wrong
5915
+ // file for someone else's missing brace. An uncalled function expression forces
5916
+ // a full parse of exactly this library and nothing else, so the failure names
5917
+ // it and counts from its own line 1.
5918
+ function __rjCheckLibraryParses(lib) {
5919
+ try {
5920
+ eval("(function(){ " + lib.text + "})");
5921
+ } catch (e) {
5922
+ var m = "";
5923
+ try { m = (e && e.message) ? String(e.message) : String(e); } catch (e1) { m = "unknown error"; }
5924
+ var ln = null;
5925
+ try { if (e && typeof e.line === "number" && isFinite(e.line)) ln = e.line; } catch (e2) {}
5926
+ var libLines = __rjLines(lib.text);
5927
+ var count = libLines.length - 1;
5928
+ var detail = {
5929
+ lineCount: count,
5930
+ rawLine: ln,
5931
+ sourceLine: null,
5932
+ sourceText: null,
5933
+ sourceName: lib.path
5934
+ };
5935
+ if (ln !== null && ln >= 1 && ln <= count) {
5936
+ detail.sourceLine = ln;
5937
+ detail.sourceText = __rjClip(__rjTrim(libLines[ln - 1]), 200);
5938
+ }
5939
+ throw {
5940
+ message: "run_jsx library failed to parse: " + lib.path + " — " + m,
5941
+ stack: "",
5942
+ line: ln,
5943
+ aeDetail: detail
5944
+ };
5945
+ }
5946
+ }
5947
+
5948
+ // The evaluated source for one call, plus the map that turns any line of it
5949
+ // back into a line of a file the caller knows about.
5950
+ //
5951
+ // `preambleLines` is COUNTED from the text that precedes the script rather than
5952
+ // assumed. That is the invariant issue #46 turned on, and it now has to hold
5953
+ // for a preamble whose length changes from call to call: it can never drift
5954
+ // from the string it describes, however many libraries there are.
5955
+ function __rjBuildSource(code, libraries) {
5956
+ var prefix = __RJ_WRAP_PREFIX;
5957
+ var segments = [];
5958
+ if (libraries && libraries.length) {
5959
+ for (var i = 0; i < libraries.length; i++) {
5960
+ var lib = __rjLibrarySource(libraries[i]);
5961
+ __rjCheckLibraryParses(lib);
5962
+ segments.push({
5963
+ path: lib.path,
5964
+ // The wrapper opener carries no newline, so the first library's line 1
5965
+ // shares line 1 of the evaluated source with it.
5966
+ firstLine: __rjLines(prefix).length,
5967
+ lines: __rjLines(lib.text)
5968
+ });
5969
+ prefix = prefix + lib.text;
5970
+ }
5971
+ }
5972
+ return {
5973
+ wrapper: prefix + code + __RJ_WRAP_SUFFIX,
5974
+ preambleLines: __rjLines(prefix).length - 1,
5975
+ segments: segments
5976
+ };
5977
+ }
5978
+
2952
5979
  // undoGroup:false is a per-call opt-out, read by dispatch() through the
2953
5980
  // predicate form of __meta.noUndo. It exists because AE refuses copyToComp for
2954
5981
  // a layer with a parent or a linked expression while an undo group is open,
2955
5982
  // which is exactly the layer worth copying (issue #30).
2956
5983
  OPS.run_jsx = noUndoWhen(function (args) { return args.undoGroup === false; }, function (args) {
2957
5984
  var code = args.code || "";
2958
- // We wrap in a function so `return` works.
2959
- var wrapper = "(function(){ " + code + " })()";
5985
+ // scriptPath is resolved to code by the *server* before the payload is built.
5986
+ // Arriving here with a path and no code means it came in some other way — a
5987
+ // run_batch step, whose args are never validated, or a hand-rolled POST /op.
5988
+ // Running an empty script would return the "completed with no return value"
5989
+ // envelope, which is a success result for a file nobody read.
5990
+ if (!code && args.scriptPath) {
5991
+ throw new Error(
5992
+ "run_jsx received scriptPath \"" + args.scriptPath + "\" with no code. The server reads that " +
5993
+ "file and substitutes it, so this path did not go through the run_jsx tool — run_batch steps " +
5994
+ "and direct /op posts must pass `code` themselves."
5995
+ );
5996
+ }
5997
+ if (!code) throw new Error("run_jsx needs `code` — an empty script would report success for nothing.");
5998
+ // We wrap in a function so `return` works, with any libraries inlined ahead
5999
+ // of the script so they share its scope. Assembled and parse-checked before
6000
+ // anything runs, and outside the try below: a library that fails to parse is
6001
+ // not a line in the caller's script and must not be reported as one.
6002
+ var built = __rjBuildSource(code, args.libraries);
2960
6003
  // Which undo step to look for in AE if the script has to be backed out. The
2961
6004
  // name mirrors dispatch()'s default ("AE MCP: " + op); false means the caller
2962
6005
  // asked for no group and the changes landed as whatever steps AE recorded.
2963
6006
  var undoGroupName = (args.undoGroup === false) ? false : "AE MCP: run_jsx";
2964
- return __rjResult(eval(wrapper), undoGroupName);
6007
+ // diff:true fingerprints the comp before and after, inside this one call —
6008
+ // see snapshot.jsx. Null unless asked for, so the ordinary path is untouched.
6009
+ var __d = __diffStart(args, null);
6010
+ var __value;
6011
+ try {
6012
+ __value = eval(built.wrapper);
6013
+ } catch (e) {
6014
+ // Annotate before mapping the line: __rjThrowWithSource reads e.message,
6015
+ // so the diff note has to be on it by then, and the source mapping is what
6016
+ // makes the reported line the caller's own (#46).
6017
+ __diffAnnotateError(e, __d);
6018
+ __rjThrowWithSource(e, code, args.scriptPath, built);
6019
+ }
6020
+ var __out = __rjResult(__value, undoGroupName);
6021
+ if (__d) return __rjWithDiff(__out, __diffFinish(__d), undoGroupName);
6022
+ return __out;
2965
6023
  });