@engine-room/after-effects-mcp 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 ----------
@@ -268,6 +301,155 @@ OPS.delete_comp = function (args) {
268
301
  return { ok: true };
269
302
  };
270
303
 
304
+ // ---------------------------------------------------------------------------
305
+ // duplicate_comp
306
+ // ---------------------------------------------------------------------------
307
+ // There was duplicate_layer, create_comp and delete_comp but no way to copy a
308
+ // comp, so every rig workflow detoured through run_jsx and CompItem.duplicate()
309
+ // (issue #54). Two things that detour never got right:
310
+ //
311
+ // * AE's own Duplicate is SHALLOW. The copy's precomp layers point at the
312
+ // same source comps as the original, so "make a variant of this rig" and
313
+ // then editing the variant edits the original too. `deep:true` duplicates
314
+ // the nested comps as well and re-points the copy's layers at them, which
315
+ // is the entire value of the flag.
316
+ // * The same nested comp usually appears on several layers. Duplicating per
317
+ // layer fans out one copy per reference; __dupNested keeps a map from
318
+ // original id to its copy and reuses it, and registers the copy *before*
319
+ // recursing so a cycle terminates instead of recursing for ever.
320
+
321
+ // AE happily allows two project items with the same name, which makes a
322
+ // deep-duplicated rig unreadable in the project panel. Appending a counter is
323
+ // the smaller evil, and the chosen name is reported either way.
324
+ function __dupNameTaken(name) {
325
+ for (var i = 1; i <= app.project.numItems; i++) {
326
+ if (app.project.item(i).name === name) return true;
327
+ }
328
+ return false;
329
+ }
330
+
331
+ function __dupUniqueName(base) {
332
+ if (!__dupNameTaken(base)) return base;
333
+ for (var n = 2; n < 1000; n++) {
334
+ var candidate = base + " " + n;
335
+ if (!__dupNameTaken(candidate)) return candidate;
336
+ }
337
+ return base;
338
+ }
339
+
340
+ // Depth is a backstop, not the cycle guard — `seen` is. AE refuses to nest a
341
+ // comp inside itself, but nothing here should recurse for ever if a future
342
+ // build ever allows it.
343
+ var __DUP_MAX_DEPTH = 32;
344
+
345
+ function __dupNested(src, opts, depth) {
346
+ var key = "C" + src.id;
347
+ if (opts.seen.hasOwnProperty(key)) return opts.seen[key];
348
+ if (depth > __DUP_MAX_DEPTH) {
349
+ throw new Error("nested comps are more than " + __DUP_MAX_DEPTH + " deep below the comp being duplicated");
350
+ }
351
+ var srcId = src.id;
352
+ var srcName = src.name;
353
+ var dup = src.duplicate();
354
+ opts.seen[key] = dup;
355
+ if (opts.nameSuffix) dup.name = __dupUniqueName(srcName + opts.nameSuffix);
356
+ opts.created.push({ fromCompId: srcId, fromName: srcName, compId: dup.id, name: dup.name });
357
+ __dupRepoint(dup, opts, depth);
358
+ return dup;
359
+ }
360
+
361
+ // Re-point every precomp layer of a freshly duplicated comp at the duplicate of
362
+ // its source rather than the original. Layers whose source is footage, and
363
+ // layers with no source at all, are left alone.
364
+ function __dupRepoint(comp, opts, depth) {
365
+ for (var i = 1; i <= comp.numLayers; i++) {
366
+ var l = comp.layer(i);
367
+ if (!(l instanceof AVLayer)) continue;
368
+ var srcItem = null;
369
+ try { srcItem = l.source; } catch (e) { continue; }
370
+ if (!srcItem || !(srcItem instanceof CompItem)) continue;
371
+ var replacement = __dupNested(srcItem, opts, depth + 1);
372
+ // fixExpressions:false — the layer keeps its name and its own properties,
373
+ // so there is nothing for AE to rewrite, and letting it rewrite expressions
374
+ // on a rig is a change nobody asked for.
375
+ l.replaceSource(replacement, false);
376
+ opts.repointed += 1;
377
+ }
378
+ }
379
+
380
+ OPS.duplicate_comp = function (args) {
381
+ var src = getCompById(args.compId);
382
+ var folder = null;
383
+ if (args.folderId !== undefined && args.folderId !== null) {
384
+ var f = app.project.itemByID(args.folderId);
385
+ if (!f) throw new Error("No project item with id " + args.folderId + " to use as folderId");
386
+ if (!(f instanceof FolderItem)) {
387
+ throw new Error(
388
+ "folderId " + args.folderId + ' ("' + f.name + '") is a ' + __itemKind(f) +
389
+ ", not a project folder. Pass the id of a folder from get_project_summary, or omit folderId."
390
+ );
391
+ }
392
+ folder = f;
393
+ }
394
+
395
+ var opts = { seen: {}, created: [], repointed: 0, nameSuffix: null };
396
+ if (args.nameSuffix) opts.nameSuffix = args.nameSuffix;
397
+
398
+ // Captured as primitives before the duplicate. Some AE calls invalidate every
399
+ // handle held across them (exportAsMotionGraphicsTemplate is the measured
400
+ // one), so nothing below reads `src` again.
401
+ var srcId = src.id;
402
+ var srcName = src.name;
403
+ var dup = src.duplicate();
404
+ var newId = dup.id;
405
+ if (args.name) dup.name = args.name;
406
+
407
+ if (args.deep) {
408
+ opts.seen["C" + srcId] = dup;
409
+ try {
410
+ __dupRepoint(dup, opts, 1);
411
+ } catch (e) {
412
+ // The copy and any nested copies made before the failure are real and
413
+ // nothing rolled them back. Reporting {ok:true} over a half-built rig, or
414
+ // an error that does not name what exists, are the same class of lie.
415
+ var madeIds = [];
416
+ madeIds.push(String(newId));
417
+ for (var m = 0; m < opts.created.length; m++) madeIds.push(String(opts.created[m].compId));
418
+ throw new Error(
419
+ "duplicate_comp deep failed part-way: " + e.message +
420
+ ". These comps were created and still exist: ids " + madeIds.join(", ") +
421
+ ". Undo once in After Effects to back the whole thing out, or delete_comp them."
422
+ );
423
+ }
424
+ }
425
+
426
+ // Re-fetch by id rather than trusting the handle held across the duplication.
427
+ var made = app.project.itemByID(newId);
428
+ if (!made) throw new Error("duplicate_comp created comp " + newId + " but it could not be read back");
429
+ if (folder) made.parentFolder = folder;
430
+
431
+ var out = __compSummary(made);
432
+ out.fromCompId = srcId;
433
+ out.fromName = srcName;
434
+ out.deep = !!args.deep;
435
+ if (folder) {
436
+ out.folderId = folder.id;
437
+ out.folderName = folder.name;
438
+ }
439
+ if (args.deep) {
440
+ out.nestedDuplicated = opts.created;
441
+ out.nestedCount = opts.created.length;
442
+ out.layersRepointed = opts.repointed;
443
+ if (opts.created.length === 0) {
444
+ out.note = "deep:true had nothing to do - this comp has no precomp layers.";
445
+ }
446
+ } else {
447
+ out.note = "Shallow copy: its precomp layers still point at the SAME nested comps as the original, " +
448
+ "so editing one of those edits both. Pass deep:true to duplicate the nested comps too.";
449
+ }
450
+ return out;
451
+ };
452
+
271
453
  OPS.set_active_comp = function (args) {
272
454
  var c = getCompById(args.compId);
273
455
  c.openInViewer();
@@ -415,12 +597,59 @@ OPS.create_adjustment_layer = function (args) {
415
597
  return __layerSummary(l);
416
598
  };
417
599
 
600
+ // Where a new shape layer's origin goes (issue #51).
601
+ //
602
+ // AE's addShape() leaves Position at the comp centre with the Anchor Point at
603
+ // [0,0], so layer space is offset from comp space by half a frame. Every path
604
+ // this toolset can write — set_shape_path vertices, add_shape_content vertices,
605
+ // a rect/ellipse `position` — is expressed in *layer* space, and nothing in the
606
+ // response says where that space starts. An agent authoring in comp pixels gets
607
+ // the whole drawing shifted by (width/2, height/2), and the check that would
608
+ // catch it is a downsampled screenshot.
609
+ //
610
+ // Default [0,0] makes layer space and comp space the same space for a fresh
611
+ // shape layer, which is the only arrangement in which a vertex list means what
612
+ // it says. "center" is AE's own spawn point, kept as one word so the old
613
+ // behaviour is still one argument away.
614
+ function __shapeSpawnPosition(comp, position) {
615
+ if (position === undefined || position === null) return [0, 0];
616
+ if (position === "center") return [comp.width / 2, comp.height / 2];
617
+ if (position instanceof Array && position.length >= 2) {
618
+ if (position.length === 3) return [position[0], position[1], position[2]];
619
+ return [position[0], position[1]];
620
+ }
621
+ throw new Error(
622
+ "create_shape_layer: position must be [x,y], [x,y,z] or the string \"center\" (After Effects' own " +
623
+ "spawn point, the comp centre). Got: " + String(position)
624
+ );
625
+ }
626
+
418
627
  OPS.create_shape_layer = function (args) {
419
628
  var c = getCompById(args.compId);
629
+ var pos = __shapeSpawnPosition(c, args.position);
420
630
  var l = c.layers.addShape();
421
631
  if (args.name) l.name = args.name;
632
+ try {
633
+ l.property("Transform").property("Position").setValue(pos);
634
+ } catch (ePos) {
635
+ // The shape that can still get here is three components on a 2D layer, which
636
+ // AE refuses. Leaving an empty shape layer in the timeline behind a thrown
637
+ // error is the half-built failure add_shape_content refuses to produce.
638
+ try { l.remove(); } catch (eRm) {}
639
+ throw new Error(
640
+ "create_shape_layer could not set position to [" + pos.join(", ") + "]: " + ePos.message +
641
+ ". (A three-component position needs a 3D layer; create it 2D and set threeDLayer with set_layer first.) " +
642
+ "The empty layer was removed, so nothing changed."
643
+ );
644
+ }
422
645
  // shapes payload kept loose for v1 — the agent can use add_shape_content for detail
423
- return __layerSummary(l);
646
+ var out = __layerSummary(l);
647
+ // Read both back rather than echoing what we asked for: together they are the
648
+ // coordinate space every subsequent path on this layer is measured in, and a
649
+ // caller should never have to render a frame to find out what it is.
650
+ out.position = l.property("Transform").property("Position").value;
651
+ out.anchorPoint = l.property("Transform").property("Anchor Point").value;
652
+ return out;
424
653
  };
425
654
 
426
655
  OPS.create_precomp_layer = function (args) {
@@ -824,14 +1053,672 @@ OPS.parent_layer = function (args) {
824
1053
  return out;
825
1054
  };
826
1055
 
1056
+ // Layers do not reorder with moveTo. `moveTo` is a PropertyBase method for
1057
+ // re-ranking a property inside an indexed group, and calling it on a Layer
1058
+ // throws "parent is not an INDEXED_GROUP" — which is every call this op has
1059
+ // ever served (issue #70). AE's layer-level primitives are moveBefore,
1060
+ // moveAfter, moveToBeginning and moveToEnd, and all four take a *layer*, never
1061
+ // an index. (The one legitimate moveTo in this codebase is the shape-property
1062
+ // one in shapes.jsx; it is on a PropertyBase and stays.)
1063
+ //
1064
+ // Two ways in, because reorder is the op that invalidates indexes:
1065
+ //
1066
+ // * `beforeLayerId` / `afterLayerId` — place this layer relative to another
1067
+ // one by id. Nothing about the answer depends on the stack not having moved
1068
+ // since the caller last read it, which is why these are the preferred form.
1069
+ // * `toIndex` — the index the layer ends up at, counting from the front.
1070
+ // Stated as the *landing* index because "the index it displaces" and "the
1071
+ // index it lands on" differ by one when moving down the stack and the
1072
+ // caller cannot tell which one a tool meant.
1073
+ //
1074
+ // The two directions need different primitives, and swapping them is off by one
1075
+ // with no error. Moving up (toIndex < from), the layer currently at the target
1076
+ // is pushed down, so moveBefore lands exactly on it. Moving down, that layer
1077
+ // shifts up by one as this one leaves, so moveAfter is what lands on the target.
827
1078
  OPS.reorder_layer = function (args) {
828
1079
  var c = getCompById(args.compId);
829
1080
  var l = getLayerById(c, args.layerId);
830
- l.moveTo(args.toIndex);
831
- return __layerSummary(l);
1081
+ var from = l.index;
1082
+ var n = c.numLayers;
1083
+ var to;
1084
+
1085
+ if (args.beforeLayerId !== undefined && args.beforeLayerId !== null) {
1086
+ var b = getLayerById(c, args.beforeLayerId);
1087
+ if (b.id === l.id) throw new Error("reorder_layer: beforeLayerId is the layer being moved (" + l.id + ").");
1088
+ l.moveBefore(b);
1089
+ } else if (args.afterLayerId !== undefined && args.afterLayerId !== null) {
1090
+ var a = getLayerById(c, args.afterLayerId);
1091
+ if (a.id === l.id) throw new Error("reorder_layer: afterLayerId is the layer being moved (" + l.id + ").");
1092
+ l.moveAfter(a);
1093
+ } else if (args.toIndex !== undefined && args.toIndex !== null) {
1094
+ to = Math.round(args.toIndex);
1095
+ if (to < 1) to = 1;
1096
+ if (to > n) to = n;
1097
+ if (to === from) {
1098
+ // Nothing to do. Saying so beats an AE call that is a no-op anyway.
1099
+ } else if (to === 1) {
1100
+ l.moveToBeginning();
1101
+ } else if (to === n) {
1102
+ l.moveToEnd();
1103
+ } else if (to < from) {
1104
+ l.moveBefore(c.layer(to));
1105
+ } else {
1106
+ l.moveAfter(c.layer(to));
1107
+ }
1108
+ } else {
1109
+ throw new Error("reorder_layer: pass one of toIndex, beforeLayerId or afterLayerId.");
1110
+ }
1111
+
1112
+ // Report both ends of the move. The landing index is read back off the layer
1113
+ // rather than assumed, so a result can never claim a position AE did not
1114
+ // give it — and `movedFrom === index` is how a caller sees a no-op.
1115
+ var out = __layerSummary(l);
1116
+ out.movedFrom = from;
1117
+ return out;
832
1118
  };
833
1119
 
834
1120
 
1121
+ // ===== snapshot.jsx =====
1122
+
1123
+ // snapshot.jsx — a cheap structural fingerprint of a comp, and the diff
1124
+ // between two of them.
1125
+ //
1126
+ // Verifying a write today means reading the comp back and comparing by eye,
1127
+ // and every one of those reads is re-sent on every later request for the rest
1128
+ // of the session. A fingerprint is the other end of that trade: it records
1129
+ // what an agent actually checks after a write — which layers exist, what they
1130
+ // are called, where they sit in time, what they are parented to, how many
1131
+ // keyframes/expressions/effects they carry — and nothing else. The diff of two
1132
+ // of them is a few dozen tokens for "3 layers added: ids 512-514; layer 498
1133
+ // Opacity keys 0 -> 4" (issue #52).
1134
+ //
1135
+ // Cheapness is the whole point, so the walk stops at the layer's own Transform
1136
+ // group plus four named properties. It never opens an effect's parameters or a
1137
+ // shape layer's Contents — those are what make get_layer_full expensive, and a
1138
+ // fingerprint that costs as much as the read it replaces is worth nothing.
1139
+ //
1140
+ // That has a cost of its own, and per the "a scoped read must say what it left
1141
+ // out" rule it is stated rather than left to be discovered: a diff can only
1142
+ // report a field it records. __DIFF_COVERS travels with every diff for exactly
1143
+ // that reason — "no differences" must never be read as "identical".
1144
+
1145
+ var __DIFF_COVERS = "Compares recorded fields only - not property values, expression text, " +
1146
+ "effect parameters, masks or shape contents. \"No differences\" means none of the recorded fields moved, " +
1147
+ "not that the two states render identically.";
1148
+
1149
+ var __FP_COVERS = "A snapshot records, per layer: id, name, index, type, inPoint, outPoint, startTime, " +
1150
+ "parentId, enabled, keyframe counts per Transform property (plus Marker, Time Remap and Source Text), " +
1151
+ "expression count and effect count. Per comp: name, size, duration, frame rate, work area and markers. " +
1152
+ "It does NOT record property values, expression text, effect parameters, mask shapes or shape contents, " +
1153
+ "because reading those costs as much as the read this replaces.";
1154
+
1155
+ // Times are floats out of AE and are compared, not displayed, so they are
1156
+ // rounded once here rather than at every comparison site.
1157
+ var __FP_PRECISION = 1000000;
1158
+
1159
+ function __fpRound(n) {
1160
+ if (typeof n !== "number") return n;
1161
+ if (!isFinite(n)) return null;
1162
+ return Math.round(n * __FP_PRECISION) / __FP_PRECISION;
1163
+ }
1164
+
1165
+ // Properties worth a fingerprint that do not live under Transform. Each is one
1166
+ // guarded lookup; a layer that has none of them pays four null checks.
1167
+ var __FP_EXTRA_PROPS = ["Marker", "Time Remap", "Source Text", "Audio Levels"];
1168
+
1169
+ function __fpCountProperty(p, out) {
1170
+ if (!p) return;
1171
+ var keys = 0;
1172
+ try { keys = p.numKeys; } catch (e) { return; }
1173
+ if (typeof keys !== "number") return;
1174
+ if (keys > 0) out.keyCounts[p.name] = keys;
1175
+ try {
1176
+ if (p.canSetExpression && p.expression) out.expressionCount += 1;
1177
+ } catch (e2) {}
1178
+ }
1179
+
1180
+ function __fpLayer(l) {
1181
+ var out = {
1182
+ id: l.id,
1183
+ name: l.name,
1184
+ index: l.index,
1185
+ type: __layerKind(l),
1186
+ inPoint: __fpRound(l.inPoint),
1187
+ outPoint: __fpRound(l.outPoint),
1188
+ startTime: __fpRound(l.startTime),
1189
+ parentId: null,
1190
+ enabled: l.enabled,
1191
+ keyCounts: {},
1192
+ expressionCount: 0,
1193
+ effectCount: 0
1194
+ };
1195
+ try { if (l.parent) out.parentId = l.parent.id; } catch (e0) {}
1196
+
1197
+ var tr = null;
1198
+ try { tr = l.property("Transform"); } catch (e1) {}
1199
+ if (tr) {
1200
+ for (var i = 1; i <= tr.numProperties; i++) __fpCountProperty(tr.property(i), out);
1201
+ }
1202
+ for (var j = 0; j < __FP_EXTRA_PROPS.length; j++) {
1203
+ var extra = null;
1204
+ try { extra = l.property(__FP_EXTRA_PROPS[j]); } catch (e2) {}
1205
+ __fpCountProperty(extra, out);
1206
+ }
1207
+ // Effects are counted, never walked. The parameter tree is the expensive
1208
+ // part and an agent checking "did my effect land" only needs the count plus
1209
+ // list_effects when it did not.
1210
+ try {
1211
+ var fx = l.property("Effects");
1212
+ if (fx) out.effectCount = fx.numProperties;
1213
+ } catch (e3) {}
1214
+ return out;
1215
+ }
1216
+
1217
+ function __fpCompMarkers(c) {
1218
+ var out = [];
1219
+ var mp = null;
1220
+ try { mp = c.markerProperty; } catch (e) { return out; }
1221
+ if (!mp) return out;
1222
+ var n = 0;
1223
+ try { n = mp.numKeys; } catch (e2) { return out; }
1224
+ for (var i = 1; i <= n; i++) {
1225
+ var mv = mp.keyValue(i);
1226
+ out.push(String(__fpRound(mp.keyTime(i))) + "|" + String(mv.comment) + "|" + String(__fpRound(mv.duration)));
1227
+ }
1228
+ return out;
1229
+ }
1230
+
1231
+ function __compFingerprint(compId) {
1232
+ var c = getCompById(compId);
1233
+ var fp = {
1234
+ compId: c.id,
1235
+ name: c.name,
1236
+ width: c.width,
1237
+ height: c.height,
1238
+ duration: __fpRound(c.duration),
1239
+ frameRate: __fpRound(c.frameRate),
1240
+ workAreaStart: __fpRound(c.workAreaStart),
1241
+ workAreaDuration: __fpRound(c.workAreaDuration),
1242
+ numLayers: c.numLayers,
1243
+ markers: __fpCompMarkers(c),
1244
+ layers: []
1245
+ };
1246
+ for (var i = 1; i <= c.numLayers; i++) fp.layers.push(__fpLayer(c.layer(i)));
1247
+ return fp;
1248
+ }
1249
+
1250
+ // ---------------------------------------------------------------------------
1251
+ // The diff
1252
+ // ---------------------------------------------------------------------------
1253
+ // Pure: two fingerprints in, one object out, no AE. That is what lets it be
1254
+ // tested against synthetic input (tests/unit/comp-snapshot.mjs) — there is no
1255
+ // ExtendScript runtime on a runner and this is the part that has to be right.
1256
+
1257
+ // Below a frame at any sane frame rate, above the float noise a round-trip
1258
+ // through JSON leaves behind. Without it a comp re-read after a frame-rate
1259
+ // change reports every layer "retimed" by 1e-15.
1260
+ var __DIFF_EPS = 0.000001;
1261
+
1262
+ function __diffMoved(a, b) {
1263
+ if (typeof a !== "number") return a !== b;
1264
+ if (typeof b !== "number") return true;
1265
+ return Math.abs(a - b) > __DIFF_EPS;
1266
+ }
1267
+
1268
+ // ES3 has no Map. The "L" prefix keeps a numeric id away from anything already
1269
+ // on Object.prototype.
1270
+ function __fpById(fp) {
1271
+ var m = {};
1272
+ for (var i = 0; i < fp.layers.length; i++) m["L" + fp.layers[i].id] = fp.layers[i];
1273
+ return m;
1274
+ }
1275
+
1276
+ function __diffKeyCounts(a, b) {
1277
+ var out = null;
1278
+ var seen = {};
1279
+ var k;
1280
+ for (k in a) {
1281
+ if (!a.hasOwnProperty(k)) continue;
1282
+ seen[k] = true;
1283
+ var bv = 0;
1284
+ if (b.hasOwnProperty(k)) bv = b[k];
1285
+ if (a[k] !== bv) {
1286
+ if (!out) out = {};
1287
+ out[k] = { from: a[k], to: bv };
1288
+ }
1289
+ }
1290
+ for (k in b) {
1291
+ if (!b.hasOwnProperty(k)) continue;
1292
+ if (seen[k]) continue;
1293
+ if (!out) out = {};
1294
+ out[k] = { from: 0, to: b[k] };
1295
+ }
1296
+ return out;
1297
+ }
1298
+
1299
+ var __DIFF_EXACT_FIELDS = ["name", "type", "enabled"];
1300
+ var __DIFF_TIME_FIELDS = ["inPoint", "outPoint", "startTime"];
1301
+ var __DIFF_COUNT_FIELDS = ["expressionCount", "effectCount"];
1302
+
1303
+ // `index` is deliberately not compared here: inserting one layer shifts every
1304
+ // index below it, which would report twenty changed layers for one addition.
1305
+ // Relative order is compared separately, in __diffReordered.
1306
+ function __diffLayer(a, b) {
1307
+ var ch = {};
1308
+ var n = 0;
1309
+ var i, k;
1310
+ for (i = 0; i < __DIFF_EXACT_FIELDS.length; i++) {
1311
+ k = __DIFF_EXACT_FIELDS[i];
1312
+ if (a[k] !== b[k]) { ch[k] = { from: a[k], to: b[k] }; n += 1; }
1313
+ }
1314
+ for (i = 0; i < __DIFF_TIME_FIELDS.length; i++) {
1315
+ k = __DIFF_TIME_FIELDS[i];
1316
+ if (__diffMoved(a[k], b[k])) { ch[k] = { from: a[k], to: b[k] }; n += 1; }
1317
+ }
1318
+ for (i = 0; i < __DIFF_COUNT_FIELDS.length; i++) {
1319
+ k = __DIFF_COUNT_FIELDS[i];
1320
+ if (a[k] !== b[k]) { ch[k] = { from: a[k], to: b[k] }; n += 1; }
1321
+ }
1322
+ if (a.parentId !== b.parentId) { ch.parentId = { from: a.parentId, to: b.parentId }; n += 1; }
1323
+ var kc = __diffKeyCounts(a.keyCounts || {}, b.keyCounts || {});
1324
+ if (kc) { ch.keyCounts = kc; n += 1; }
1325
+ if (n === 0) return null;
1326
+ return { id: b.id, name: b.name, changes: ch };
1327
+ }
1328
+
1329
+ // Only layers present on both sides, and only when their order relative to one
1330
+ // another actually changed. A layer added or removed elsewhere in the stack is
1331
+ // not a reorder of anything.
1332
+ function __diffReordered(before, after, aMap, bMap) {
1333
+ var seqA = [];
1334
+ var seqB = [];
1335
+ var i;
1336
+ for (i = 0; i < before.layers.length; i++) {
1337
+ var idA = before.layers[i].id;
1338
+ if (bMap.hasOwnProperty("L" + idA)) seqA.push(idA);
1339
+ }
1340
+ for (i = 0; i < after.layers.length; i++) {
1341
+ var idB = after.layers[i].id;
1342
+ if (aMap.hasOwnProperty("L" + idB)) seqB.push(idB);
1343
+ }
1344
+ if (seqA.length !== seqB.length) return null;
1345
+ var same = true;
1346
+ for (i = 0; i < seqA.length; i++) {
1347
+ if (seqA[i] !== seqB[i]) { same = false; break; }
1348
+ }
1349
+ if (same) return null;
1350
+ var posA = {};
1351
+ for (i = 0; i < seqA.length; i++) posA["L" + seqA[i]] = i;
1352
+ var moved = [];
1353
+ for (i = 0; i < seqB.length; i++) {
1354
+ var id = seqB[i];
1355
+ if (posA["L" + id] === i) continue;
1356
+ moved.push({
1357
+ id: id,
1358
+ name: bMap["L" + id].name,
1359
+ fromIndex: aMap["L" + id].index,
1360
+ toIndex: bMap["L" + id].index
1361
+ });
1362
+ }
1363
+ if (moved.length === 0) return null;
1364
+ return moved;
1365
+ }
1366
+
1367
+ function __diffMarkers(a, b) {
1368
+ if (a.length !== b.length) return { from: a.length, to: b.length };
1369
+ for (var i = 0; i < a.length; i++) {
1370
+ if (a[i] !== b[i]) return { count: a.length, edited: true };
1371
+ }
1372
+ return null;
1373
+ }
1374
+
1375
+ function __diffCompFields(a, b) {
1376
+ var ch = {};
1377
+ var n = 0;
1378
+ if (a.name !== b.name) { ch.name = { from: a.name, to: b.name }; n += 1; }
1379
+ if (a.width !== b.width || a.height !== b.height) {
1380
+ ch.size = { from: [a.width, a.height], to: [b.width, b.height] };
1381
+ n += 1;
1382
+ }
1383
+ if (__diffMoved(a.duration, b.duration)) { ch.duration = { from: a.duration, to: b.duration }; n += 1; }
1384
+ if (__diffMoved(a.frameRate, b.frameRate)) { ch.frameRate = { from: a.frameRate, to: b.frameRate }; n += 1; }
1385
+ if (__diffMoved(a.workAreaStart, b.workAreaStart) || __diffMoved(a.workAreaDuration, b.workAreaDuration)) {
1386
+ ch.workArea = {
1387
+ from: [a.workAreaStart, a.workAreaDuration],
1388
+ to: [b.workAreaStart, b.workAreaDuration]
1389
+ };
1390
+ n += 1;
1391
+ }
1392
+ var mk = __diffMarkers(a.markers || [], b.markers || []);
1393
+ if (mk) { ch.markers = mk; n += 1; }
1394
+ if (n === 0) return null;
1395
+ return ch;
1396
+ }
1397
+
1398
+ // ---------- summary prose ----------
1399
+
1400
+ function __diffPlural(n) {
1401
+ if (n === 1) return "";
1402
+ return "s";
1403
+ }
1404
+
1405
+ // "512-514" for a run, "512, 517" otherwise. Ids come out of AE in creation
1406
+ // order, so a batch of new layers is nearly always consecutive and this is the
1407
+ // difference between a readable line and a wall of numbers.
1408
+ function __diffIdList(ids) {
1409
+ if (!ids.length) return "";
1410
+ var sorted = [];
1411
+ for (var i = 0; i < ids.length; i++) sorted.push(ids[i]);
1412
+ sorted.sort(function (x, y) { return x - y; });
1413
+ var parts = [];
1414
+ var start = sorted[0];
1415
+ var prev = sorted[0];
1416
+ for (var j = 1; j <= sorted.length; j++) {
1417
+ var cur = sorted[j];
1418
+ var breaks = true;
1419
+ if (j < sorted.length && cur === prev + 1) breaks = false;
1420
+ if (breaks) {
1421
+ if (start === prev) parts.push(String(start));
1422
+ else if (prev === start + 1) parts.push(String(start) + ", " + String(prev));
1423
+ else parts.push(String(start) + "-" + String(prev));
1424
+ start = cur;
1425
+ }
1426
+ prev = cur;
1427
+ }
1428
+ return parts.join(", ");
1429
+ }
1430
+
1431
+ function __diffParentLabel(v) {
1432
+ if (v === null || v === undefined) return "none";
1433
+ return String(v);
1434
+ }
1435
+
1436
+ function __diffLayerPhrases(entry) {
1437
+ var label = "layer " + entry.id;
1438
+ var c = entry.changes;
1439
+ var out = [];
1440
+ if (c.name) out.push(label + ' renamed "' + c.name.from + '" -> "' + c.name.to + '"');
1441
+ if (c.enabled) {
1442
+ if (c.enabled.to) out.push(label + " enabled");
1443
+ else out.push(label + " disabled");
1444
+ }
1445
+ var times = [];
1446
+ if (c.inPoint) times.push("in " + c.inPoint.from + " -> " + c.inPoint.to);
1447
+ if (c.outPoint) times.push("out " + c.outPoint.from + " -> " + c.outPoint.to);
1448
+ if (c.startTime) times.push("start " + c.startTime.from + " -> " + c.startTime.to);
1449
+ if (times.length) out.push(label + " retimed (" + times.join(", ") + ")");
1450
+ if (c.parentId) {
1451
+ out.push(label + " re-parented " + __diffParentLabel(c.parentId.from) + " -> " + __diffParentLabel(c.parentId.to));
1452
+ }
1453
+ if (c.keyCounts) {
1454
+ for (var k in c.keyCounts) {
1455
+ if (!c.keyCounts.hasOwnProperty(k)) continue;
1456
+ out.push(label + " " + k + " keys " + c.keyCounts[k].from + " -> " + c.keyCounts[k].to);
1457
+ }
1458
+ }
1459
+ if (c.effectCount) out.push(label + " effects " + c.effectCount.from + " -> " + c.effectCount.to);
1460
+ if (c.expressionCount) {
1461
+ out.push(label + " expressions " + c.expressionCount.from + " -> " + c.expressionCount.to);
1462
+ }
1463
+ if (c.type) out.push(label + " type " + c.type.from + " -> " + c.type.to);
1464
+ return out;
1465
+ }
1466
+
1467
+ function __diffCompPhrases(ch) {
1468
+ var out = [];
1469
+ if (ch.name) out.push('comp renamed "' + ch.name.from + '" -> "' + ch.name.to + '"');
1470
+ if (ch.size) {
1471
+ out.push("comp resized " + ch.size.from[0] + "x" + ch.size.from[1] + " -> " + ch.size.to[0] + "x" + ch.size.to[1]);
1472
+ }
1473
+ if (ch.duration) out.push("comp duration " + ch.duration.from + " -> " + ch.duration.to);
1474
+ if (ch.frameRate) out.push("comp frame rate " + ch.frameRate.from + " -> " + ch.frameRate.to);
1475
+ if (ch.workArea) out.push("comp work area moved");
1476
+ if (ch.markers) {
1477
+ if (ch.markers.edited) out.push("comp markers edited");
1478
+ else out.push("comp markers " + ch.markers.from + " -> " + ch.markers.to);
1479
+ }
1480
+ return out;
1481
+ }
1482
+
1483
+ // One line. Past this many clauses the rest is counted rather than spelled out
1484
+ // — the structured fields carry everything, and a summary nobody reads to the
1485
+ // end is worse than a short one that names where to look.
1486
+ var __DIFF_SUMMARY_CLAUSES = 8;
1487
+
1488
+ function __diffSummary(d) {
1489
+ var parts = [];
1490
+ var i;
1491
+ if (d.added) {
1492
+ var addedIds = [];
1493
+ for (i = 0; i < d.added.length; i++) addedIds.push(d.added[i].id);
1494
+ parts.push(d.added.length + " layer" + __diffPlural(d.added.length) + " added: ids " + __diffIdList(addedIds));
1495
+ }
1496
+ if (d.removed) {
1497
+ var removedIds = [];
1498
+ for (i = 0; i < d.removed.length; i++) removedIds.push(d.removed[i].id);
1499
+ parts.push(d.removed.length + " layer" + __diffPlural(d.removed.length) + " removed: ids " + __diffIdList(removedIds));
1500
+ }
1501
+ if (d.reordered) {
1502
+ parts.push(d.reordered.length + " layer" + __diffPlural(d.reordered.length) + " moved in the stack");
1503
+ }
1504
+ if (d.changed) {
1505
+ for (i = 0; i < d.changed.length; i++) {
1506
+ var phrases = __diffLayerPhrases(d.changed[i]);
1507
+ for (var p = 0; p < phrases.length; p++) parts.push(phrases[p]);
1508
+ }
1509
+ }
1510
+ if (d.comp) {
1511
+ var compPhrases = __diffCompPhrases(d.comp);
1512
+ for (i = 0; i < compPhrases.length; i++) parts.push(compPhrases[i]);
1513
+ }
1514
+ if (parts.length === 0) {
1515
+ return "No differences in the recorded fields. Property values, expression text, effect parameters " +
1516
+ "and shape contents are not compared, so this is not a claim that nothing changed at all.";
1517
+ }
1518
+ if (parts.length > __DIFF_SUMMARY_CLAUSES) {
1519
+ var extra = parts.length - __DIFF_SUMMARY_CLAUSES;
1520
+ parts = parts.slice(0, __DIFF_SUMMARY_CLAUSES);
1521
+ parts.push("and " + extra + " more change" + __diffPlural(extra) + " (see the fields below)");
1522
+ }
1523
+ return parts.join("; ");
1524
+ }
1525
+
1526
+ // Two fingerprints -> only what moved. Unchanged layers are counted, never
1527
+ // listed: listing them is the cost this whole thing exists to avoid.
1528
+ function __diffFingerprints(before, after) {
1529
+ var aMap = __fpById(before);
1530
+ var bMap = __fpById(after);
1531
+ var added = [];
1532
+ var removed = [];
1533
+ var changed = [];
1534
+ var unchanged = 0;
1535
+ var i, l;
1536
+ for (i = 0; i < after.layers.length; i++) {
1537
+ l = after.layers[i];
1538
+ var prev = aMap["L" + l.id];
1539
+ if (!prev) {
1540
+ added.push({ id: l.id, name: l.name, index: l.index, type: l.type });
1541
+ continue;
1542
+ }
1543
+ var d = __diffLayer(prev, l);
1544
+ if (d) changed.push(d);
1545
+ else unchanged += 1;
1546
+ }
1547
+ for (i = 0; i < before.layers.length; i++) {
1548
+ l = before.layers[i];
1549
+ if (bMap.hasOwnProperty("L" + l.id)) continue;
1550
+ removed.push({ id: l.id, name: l.name, index: l.index, type: l.type });
1551
+ }
1552
+ var reordered = __diffReordered(before, after, aMap, bMap);
1553
+ var comp = __diffCompFields(before, after);
1554
+
1555
+ var out = { compId: after.compId, compName: after.name };
1556
+ if (added.length) out.added = added;
1557
+ if (removed.length) out.removed = removed;
1558
+ if (changed.length) out.changed = changed;
1559
+ if (reordered) out.reordered = reordered;
1560
+ if (comp) out.comp = comp;
1561
+ out.unchangedLayers = unchanged;
1562
+ var count = added.length + removed.length + changed.length;
1563
+ if (reordered) count += reordered.length;
1564
+ if (comp) count += 1;
1565
+ out.changeCount = count;
1566
+ out.summary = __diffSummary(out);
1567
+ out.covers = __DIFF_COVERS;
1568
+ return out;
1569
+ }
1570
+
1571
+ // ---------------------------------------------------------------------------
1572
+ // Ops
1573
+ // ---------------------------------------------------------------------------
1574
+ // Both are internal: the tools an agent sees are snapshot_comp and diff_comp,
1575
+ // and those are half server-resident. Only the panel can read After Effects,
1576
+ // and only the server can remember anything between calls — so the server
1577
+ // keeps the fingerprint and these two gather it. See snapshots/store.ts.
1578
+
1579
+ OPS._comp_fingerprint = noUndo(function (args) {
1580
+ return __compFingerprint(args.compId);
1581
+ });
1582
+
1583
+ OPS._comp_diff = noUndo(function (args) {
1584
+ var before = args.since;
1585
+ if (!before || !before.layers) {
1586
+ throw new Error("_comp_diff needs a stored fingerprint in `since` (the MCP server supplies it from the snapshot id)");
1587
+ }
1588
+ var compId = args.compId;
1589
+ if (compId === undefined || compId === null) compId = before.compId;
1590
+ var after = __compFingerprint(compId);
1591
+ return { diff: __diffFingerprints(before, after), fingerprint: after };
1592
+ });
1593
+
1594
+ // ---------------------------------------------------------------------------
1595
+ // diff:true on the write ops
1596
+ // ---------------------------------------------------------------------------
1597
+ // The before-fingerprint has to be taken inside the same bridge call as the
1598
+ // write, or it is not a before at all: a separate snapshot_comp is a second
1599
+ // round-trip during which anything can happen, and the agent has to remember to
1600
+ // make it. run_jsx and run_batch therefore fingerprint, run, fingerprint again,
1601
+ // and diff — one call, one answer.
1602
+
1603
+ function __diffPushUnique(list, v) {
1604
+ for (var i = 0; i < list.length; i++) {
1605
+ if (list[i] === v) return;
1606
+ }
1607
+ list.push(v);
1608
+ }
1609
+
1610
+ // Explicit diffCompId wins. Otherwise a batch names its own comps in its ops,
1611
+ // and a script gets the comp the user is looking at. When none of the three
1612
+ // yields anything the diff is refused with a reason rather than quietly
1613
+ // skipped — a missing diff that looks like "nothing changed" is the failure
1614
+ // this whole feature exists to prevent.
1615
+ function __diffCompIds(args, ops) {
1616
+ var ids = [];
1617
+ if (args && args.diffCompId !== undefined && args.diffCompId !== null) {
1618
+ __diffPushUnique(ids, args.diffCompId);
1619
+ return ids;
1620
+ }
1621
+ if (ops) {
1622
+ for (var i = 0; i < ops.length; i++) {
1623
+ var a = ops[i].args;
1624
+ if (a && typeof a.compId === "number") __diffPushUnique(ids, a.compId);
1625
+ }
1626
+ if (ids.length > 0) return ids;
1627
+ }
1628
+ var active = null;
1629
+ try { active = app.project.activeItem; } catch (e) {}
1630
+ if (active && active instanceof CompItem) __diffPushUnique(ids, active.id);
1631
+ return ids;
1632
+ }
1633
+
1634
+ function __diffStart(args, ops) {
1635
+ if (!args || !args.diff) return null;
1636
+ var ids = __diffCompIds(args, ops);
1637
+ if (ids.length === 0) {
1638
+ return {
1639
+ unavailable: true,
1640
+ reason: "no compId appeared in this call and no composition is open in the viewer — pass diffCompId to name the comp to fingerprint"
1641
+ };
1642
+ }
1643
+ var state = { ids: ids, before: [], unavailable: false };
1644
+ for (var i = 0; i < ids.length; i++) {
1645
+ try {
1646
+ state.before.push(__compFingerprint(ids[i]));
1647
+ } catch (e) {
1648
+ return { unavailable: true, reason: "comp " + ids[i] + " could not be fingerprinted before the call: " + e.message };
1649
+ }
1650
+ }
1651
+ return state;
1652
+ }
1653
+
1654
+ function __diffFinish(state) {
1655
+ if (!state) return null;
1656
+ if (state.unavailable) {
1657
+ return {
1658
+ unavailable: true,
1659
+ reason: state.reason,
1660
+ summary: "No diff was taken: " + state.reason + ". The call itself is unaffected."
1661
+ };
1662
+ }
1663
+ var comps = [];
1664
+ var total = 0;
1665
+ for (var i = 0; i < state.before.length; i++) {
1666
+ var b = state.before[i];
1667
+ var after = null;
1668
+ try {
1669
+ after = __compFingerprint(b.compId);
1670
+ } catch (e) {
1671
+ comps.push({
1672
+ compId: b.compId,
1673
+ compName: b.name,
1674
+ gone: true,
1675
+ changeCount: 1,
1676
+ summary: "comp " + b.compId + ' ("' + b.name + '") can no longer be read: ' + e.message
1677
+ });
1678
+ total += 1;
1679
+ continue;
1680
+ }
1681
+ var d = __diffFingerprints(b, after);
1682
+ comps.push(d);
1683
+ total += d.changeCount;
1684
+ }
1685
+ if (comps.length === 1) return comps[0];
1686
+ var parts = [];
1687
+ for (var j = 0; j < comps.length; j++) parts.push("comp " + comps[j].compId + ": " + comps[j].summary);
1688
+ return { comps: comps, changeCount: total, summary: parts.join(" | "), covers: __DIFF_COVERS };
1689
+ }
1690
+
1691
+ // A script or batch that threw still changed whatever it changed before it
1692
+ // stopped, and nothing rolls back. Finding that stop point by reading the comp
1693
+ // back is one of the three cases issue #52 was opened for, so the diff is put
1694
+ // where the agent will actually see it: on the error. The error object itself
1695
+ // is mutated rather than replaced, so `line` and `stack` survive for the
1696
+ // caller's own reporting.
1697
+ function __diffAnnotateError(e, state) {
1698
+ if (!state) return;
1699
+ var d = null;
1700
+ try { d = __diffFinish(state); } catch (e2) { return; }
1701
+ if (!d || !d.summary) return;
1702
+ try {
1703
+ e.message = String(e.message) + " || Changed before it stopped: " + d.summary +
1704
+ " (nothing rolls back - read the state back rather than re-running)";
1705
+ } catch (e3) {}
1706
+ }
1707
+
1708
+ // run_jsx returns whatever the script returned, which may be a number or a
1709
+ // string with nowhere to hang a diff on. With diff:true it is enveloped
1710
+ // instead. The null envelope __rjResult already builds is the right shape
1711
+ // already, so that one is extended rather than nested inside a second one.
1712
+ function __rjWithDiff(out, diff, undoGroupName) {
1713
+ if (out && typeof out === "object" && !(out instanceof Array) &&
1714
+ out.ok === true && out.returned === null && out.note) {
1715
+ out.diff = diff;
1716
+ return out;
1717
+ }
1718
+ return { ok: true, returned: out, undoGroup: undoGroupName, diff: diff };
1719
+ }
1720
+
1721
+
835
1722
  // ===== transforms.jsx =====
836
1723
 
837
1724
  // transforms.jsx — fast-path for setting common transform properties.
@@ -889,50 +1776,121 @@ function __findKeyIndexAtTime(prop, time, eps) {
889
1776
  return -1;
890
1777
  }
891
1778
 
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);
1779
+ // ---------- ease array sizing (issue #50) ----------
1780
+ //
1781
+ // setTemporalEaseAtKey wants an array of KeyframeEase whose length belongs to
1782
+ // the *property*, and it is not derivable from the value: a 2D layer Scale
1783
+ // wants 3, a shape Ellipse Size wants 2 while its value reads [w,h], Opacity
1784
+ // and a slider want 1, and a spatial Position wants 1 whether the layer is 2D
1785
+ // or 3D because the ease runs along the motion path. Get it wrong and AE throws
1786
+ // "parameter 2" and says nothing else no property name, no expected count.
1787
+ //
1788
+ // So: derive the likely count from the property, then try the others. The
1789
+ // derivation is the fast path; the retry is the safety net, because the Ellipse
1790
+ // Size case is precisely the one no table gets right from the outside. Whatever
1791
+ // AE accepted is reported back, so the answer for a given property stops being
1792
+ // folklore and becomes something a caller can read off a result.
1793
+ var __EASE_ARITIES = [1, 2, 3, 4];
1794
+
1795
+ /** The count to try first, from the property rather than from the value passed in. */
1796
+ function __easePropertyArity(prop) {
1797
+ // Spatial first and unconditionally: a 3D Position is ThreeD_SPATIAL and
1798
+ // still takes one entry, so the value type must not get a say here.
1799
+ if (prop.isSpatial) return 1;
1800
+ var vt = null;
1801
+ try { vt = prop.propertyValueType; } catch (eType) {}
1802
+ if (vt !== null && vt !== undefined && typeof PropertyValueType !== "undefined") {
1803
+ if (vt === PropertyValueType.OneD) return 1;
1804
+ if (vt === PropertyValueType.TwoD) return 2;
1805
+ if (vt === PropertyValueType.TwoD_SPATIAL) return 1;
1806
+ if (vt === PropertyValueType.ThreeD) return 3;
1807
+ if (vt === PropertyValueType.ThreeD_SPATIAL) return 1;
1808
+ if (vt === PropertyValueType.COLOR) return 4;
916
1809
  }
1810
+ var v = null;
1811
+ try { v = prop.value; } catch (eVal) {}
1812
+ if (v && v.length) return v.length;
1813
+ return 1;
917
1814
  }
918
1815
 
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);
1816
+ /** Derived count first, then every other plausible one, no repeats. */
1817
+ function __easeArityCandidates(prop) {
1818
+ var first = __easePropertyArity(prop);
1819
+ var out = [first];
1820
+ for (var i = 0; i < __EASE_ARITIES.length; i++) {
1821
+ if (__EASE_ARITIES[i] !== first) out.push(__EASE_ARITIES[i]);
927
1822
  }
928
- return { ok: true, keyIndex: __findKeyIndexAtTime(prop, args.time) };
929
- };
1823
+ return out;
1824
+ }
930
1825
 
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);
1826
+ function __easeArray(ease, n) {
1827
+ var arr = [];
1828
+ for (var i = 0; i < n; i++) arr.push(new KeyframeEase(ease.speed, ease.influence));
1829
+ return arr;
1830
+ }
1831
+
1832
+ /**
1833
+ * Apply one {influence, speed} pair to every dimension of `prop` at `keyIndex`,
1834
+ * and return the number of KeyframeEase entries After Effects accepted.
1835
+ *
1836
+ * Throws only when no count works, naming every one it tried. An ease that
1837
+ * quietly failed to land is invisible until someone watches the render, which
1838
+ * is the same class of lie as a swallowed error.
1839
+ */
1840
+ function __applyTemporalEase(prop, keyIndex, easeIn, easeOut) {
1841
+ var inEase = easeIn || { influence: 33, speed: 0 };
1842
+ var outEase = easeOut || { influence: 33, speed: 0 };
1843
+ var candidates = __easeArityCandidates(prop);
1844
+ var lastMessage = "";
1845
+ for (var i = 0; i < candidates.length; i++) {
1846
+ var n = candidates[i];
1847
+ try {
1848
+ prop.setTemporalEaseAtKey(keyIndex, __easeArray(inEase, n), __easeArray(outEase, n));
1849
+ return n;
1850
+ } catch (e) {
1851
+ lastMessage = (e && e.message) ? String(e.message) : String(e);
1852
+ }
1853
+ }
1854
+ var label = "the property";
1855
+ try { label = "'" + prop.name + "'"; } catch (eName) {}
1856
+ throw new Error(
1857
+ "Could not set the temporal ease on " + label + " at key " + keyIndex + ": After Effects rejected ease " +
1858
+ "arrays of " + candidates.join(", ") + " entries. Last error from AE: " + lastMessage
1859
+ );
1860
+ }
1861
+
1862
+ /** Returns the ease arity that was used, or null when no ease was requested. */
1863
+ function __applyInterpolationToKey(prop, keyIndex, interp) {
1864
+ if (!interp) return null;
1865
+ var inT = interp["in"] && __INTERP_MAP[interp["in"]] ? __INTERP_MAP[interp["in"]] : prop.keyInInterpolationType(keyIndex);
1866
+ var outT = interp["out"] && __INTERP_MAP[interp["out"]] ? __INTERP_MAP[interp["out"]] : prop.keyOutInterpolationType(keyIndex);
1867
+ prop.setInterpolationTypeAtKey(keyIndex, inT, outT);
1868
+ if (interp.easeIn || interp.easeOut) {
1869
+ return __applyTemporalEase(prop, keyIndex, interp.easeIn, interp.easeOut);
1870
+ }
1871
+ return null;
1872
+ }
1873
+
1874
+ OPS.add_keyframe = function (args) {
1875
+ var c = getCompById(args.compId);
1876
+ var l = getLayerById(c, args.layerId);
1877
+ var prop = walkProperty(l, args.propertyPath);
1878
+ prop.setValueAtTime(args.time, args.value);
1879
+ var easeDimensions = null;
1880
+ if (args.interpolation) {
1881
+ var idx = __findKeyIndexAtTime(prop, args.time);
1882
+ if (idx > 0) easeDimensions = __applyInterpolationToKey(prop, idx, args.interpolation);
1883
+ }
1884
+ var out = { ok: true, keyIndex: __findKeyIndexAtTime(prop, args.time) };
1885
+ if (easeDimensions !== null) out.easeDimensions = easeDimensions;
1886
+ return out;
1887
+ };
1888
+
1889
+ OPS.remove_keyframe = function (args) {
1890
+ var c = getCompById(args.compId);
1891
+ var l = getLayerById(c, args.layerId);
1892
+ var prop = walkProperty(l, args.propertyPath);
1893
+ var idx = __findKeyIndexAtTime(prop, args.time);
936
1894
  if (idx < 1) throw new Error("No keyframe at time " + args.time);
937
1895
  prop.removeKey(idx);
938
1896
  return { ok: true };
@@ -992,8 +1950,13 @@ OPS.set_temporal_ease = function (args) {
992
1950
  var c = getCompById(args.compId);
993
1951
  var l = getLayerById(c, args.layerId);
994
1952
  var prop = walkProperty(l, args.propertyPath);
995
- __applyInterpolationToKey(prop, args.keyIndex, { easeIn: args.easeIn, easeOut: args.easeOut });
996
- return { ok: true };
1953
+ // A call with neither ease would previously return {ok:true} having done
1954
+ // nothing at all, which reads as "the ease is set" to whoever asked for it.
1955
+ if (!args.easeIn && !args.easeOut) {
1956
+ throw new Error("set_temporal_ease needs easeIn, easeOut or both — nothing was changed.");
1957
+ }
1958
+ var n = __applyTemporalEase(prop, args.keyIndex, args.easeIn, args.easeOut);
1959
+ return { ok: true, easeDimensions: n };
997
1960
  };
998
1961
 
999
1962
  OPS.set_spatial_tangents = function (args) {
@@ -1600,10 +2563,26 @@ var __SCREENSHOT_TARGET_PX = 1280;
1600
2563
  // The correct downsample was always derivable from the comp, and an agent that
1601
2564
  // forgot it got a full-resolution 4K frame — the most expensive accident
1602
2565
  // available through these tools. So derive it, and let an explicit value win.
2566
+ function __autoDownsampleFor(comp, targetPx) {
2567
+ var longEdge = comp.width > comp.height ? comp.width : comp.height;
2568
+ var n = Math.ceil(longEdge / targetPx);
2569
+ if (!(n > 1)) return 1;
2570
+ return n > 8 ? 8 : n;
2571
+ }
2572
+
1603
2573
  // 1080p -> 2 (960px), 4K -> 3 (1280px), and anything already small -> 1.
1604
2574
  function __autoDownsample(comp) {
1605
- var longEdge = comp.width > comp.height ? comp.width : comp.height;
1606
- var n = Math.ceil(longEdge / __SCREENSHOT_TARGET_PX);
2575
+ return __autoDownsampleFor(comp, __SCREENSHOT_TARGET_PX);
2576
+ }
2577
+
2578
+ // A contact sheet of N frames has to cost about what one frame costs, so each
2579
+ // tile gets 1/sqrt(N) of the single-frame long edge — N tiles of 1/N the area
2580
+ // each. Expressed as a multiple of what this comp's *single* frame would have
2581
+ // used rather than as its own target, for two reasons: it cannot drift from
2582
+ // __autoDownsample, and the factor is an integer, so deriving from the target
2583
+ // instead would let the rounding leave a 1080p sheet at nearly twice the budget.
2584
+ function __tileDownsample(comp, count) {
2585
+ var n = Math.ceil(__autoDownsample(comp) * Math.sqrt(count));
1607
2586
  if (!(n > 1)) return 1;
1608
2587
  return n > 8 ? 8 : n;
1609
2588
  }
@@ -1617,24 +2596,68 @@ function __resolveDownsample(comp, requested) {
1617
2596
  // reduced frame directly instead of writing full size and resampling
1618
2597
  // afterwards. That is faster (a quarter of the pixels at factor 2) and needs no
1619
2598
  // external image tool, which is what makes it work off macOS.
2599
+ //
2600
+ // Factor 1 is *set*, not skipped. Skipping it rendered at whatever the user had
2601
+ // left the viewer on, so a comp parked at Quarter answered `downsample: 1` with
2602
+ // a quarter-size frame and `downsample: 2` came back **larger** than
2603
+ // `downsample: 1` (issue #72). Designers leave heavy comps at Quarter or Third
2604
+ // as a matter of course, so that was the common case rather than a corner. The
2605
+ // panel reads the real dimensions out of the PNG's IHDR, which means the
2606
+ // response stayed honest while the *picture* was not the one asked for — the
2607
+ // worse of the two failures, because an agent that can see a frame believes it.
2608
+ // Every factor reaching this function now names the resolution it renders at.
1620
2609
  function __saveFrameAt(comp, time, file, factor) {
1621
- if (factor <= 1) {
1622
- comp.saveFrameToPng(time, file);
1623
- return;
1624
- }
2610
+ var f = (factor > 1) ? Math.round(factor) : 1;
1625
2611
  var previous = comp.resolutionFactor;
1626
2612
  try {
1627
- comp.resolutionFactor = [factor, factor];
2613
+ comp.resolutionFactor = [f, f];
1628
2614
  comp.saveFrameToPng(time, file);
1629
2615
  } finally {
1630
2616
  // Restore unconditionally — a failed render must never leave the user
1631
- // looking at a half-resolution comp.
2617
+ // looking at a half-resolution comp. This covers factor 1 too: the render
2618
+ // happens at Full and the viewer goes back to Quarter afterwards.
1632
2619
  comp.resolutionFactor = previous;
1633
2620
  }
1634
2621
  }
1635
2622
 
2623
+ // Several times in one call. Every requested time gets an entry, in order,
2624
+ // whether or not it rendered — the panel draws a marked block for the ones that
2625
+ // did not, so the sheet it composes still lines up with the times that were
2626
+ // asked for. Dropping a failed tile would silently renumber the rest, which is
2627
+ // the same class of lie as swallowing an error.
2628
+ function __contactSheetFrames(comp, args) {
2629
+ var times = args.times;
2630
+ var ds;
2631
+ if (args.downsample === undefined || args.downsample === null) {
2632
+ ds = __tileDownsample(comp, times.length);
2633
+ } else {
2634
+ ds = __clampDownsample(args.downsample);
2635
+ }
2636
+ var tiles = [];
2637
+ for (var i = 0; i < times.length; i++) {
2638
+ var entry = { time: times[i], downsample: ds };
2639
+ try {
2640
+ var p = __tmpPngPath();
2641
+ __saveFrameAt(comp, times[i], new File(p), ds);
2642
+ entry.path = p;
2643
+ } catch (e) {
2644
+ // One time that will not render must not cost the other five.
2645
+ entry.error = String(e && e.message ? e.message : e);
2646
+ }
2647
+ tiles.push(entry);
2648
+ }
2649
+ return {
2650
+ contactSheet: true,
2651
+ tiles: tiles,
2652
+ downsample: ds,
2653
+ width: comp.width, height: comp.height,
2654
+ times: times, compId: comp.id
2655
+ };
2656
+ }
2657
+
1636
2658
  OPS.screenshot_frame = noUndo(function (args) {
1637
2659
  var c = getCompById(args.compId);
2660
+ if (args.times && args.times.length) return __contactSheetFrames(c, args);
1638
2661
  var t = (args.time !== undefined && args.time !== null) ? args.time : c.time;
1639
2662
  var ds = __resolveDownsample(c, args.downsample);
1640
2663
  var path = __tmpPngPath();
@@ -1789,22 +2812,53 @@ function __brokenSvgAdvice() {
1789
2812
  );
1790
2813
  }
1791
2814
 
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
-
2815
+ /**
2816
+ * The bare import — ImportOptions, importFile, and the two ways AE can decline.
2817
+ * Kept as its own function because it is the only import in the codebase and
2818
+ * anything else that needs a file in the project (audio.jsx) must go through
2819
+ * exactly this, not a second copy that drifts.
2820
+ */
2821
+ function __importFile(file, path, sequence) {
1799
2822
  var opts = new ImportOptions(file);
1800
- if (args.sequence === true) {
2823
+ if (sequence === true) {
1801
2824
  if (!opts.canImportAs(ImportAsType.FOOTAGE)) throw new Error("Cannot import " + path + " as footage, so it cannot be a sequence either");
1802
2825
  opts.importAs = ImportAsType.FOOTAGE;
1803
2826
  opts.sequence = true;
1804
2827
  }
1805
-
1806
2828
  var item = app.project.importFile(opts);
1807
2829
  if (!item) throw new Error("After Effects returned no item for " + path);
2830
+ return item;
2831
+ }
2832
+
2833
+ /**
2834
+ * fsName -> project item, for every item in the project that came from a file.
2835
+ * One pass: the caller with N paths to resolve would otherwise walk the project
2836
+ * N times, and a project with a few hundred items makes that visible.
2837
+ */
2838
+ function __itemPathMap() {
2839
+ var map = {};
2840
+ var proj = app.project;
2841
+ for (var i = 1; i <= proj.numItems; i++) {
2842
+ var it = proj.item(i);
2843
+ var key = null;
2844
+ try {
2845
+ if (it.mainSource && it.mainSource.file) key = String(it.mainSource.file.fsName);
2846
+ } catch (e) {}
2847
+ // First one wins: two items on the same file are a duplicate import, and
2848
+ // reusing the earlier is what the user would have done by hand.
2849
+ if (key && !map.hasOwnProperty(key)) map[key] = it;
2850
+ }
2851
+ return map;
2852
+ }
2853
+
2854
+ OPS.import_footage = function (args) {
2855
+ var path = args && args.path;
2856
+ if (typeof path !== "string" || path.length === 0) throw new Error("path is required");
2857
+
2858
+ var file = new File(path);
2859
+ if (!file.exists) throw new Error("No file at " + path);
2860
+
2861
+ var item = __importFile(file, path, args.sequence);
1808
2862
 
1809
2863
  if (typeof args.name === "string" && args.name.length > 0) item.name = args.name;
1810
2864
 
@@ -1868,6 +2922,432 @@ OPS.create_footage_layer = function (args) {
1868
2922
  };
1869
2923
 
1870
2924
 
2925
+ // ===== audio.jsx =====
2926
+
2927
+ // audio.jsx — place a list of sound effects into a comp in one pass.
2928
+ //
2929
+ // Scoring a scene is 40-90 layers: import or reuse the file, add a layer, set
2930
+ // its start time, set its level in dB, name it, sometimes trim it. Done through
2931
+ // the general tools that is dozens of round trips; done through run_jsx it is a
2932
+ // hand-written loop that has to know the one thing nothing tells you —
2933
+ // `layer.property("ADBE Audio Levels")` returns **null** on an audio layer,
2934
+ // because Audio Levels lives under the layer's "Audio" group and the only
2935
+ // reliable handle is the `layer.audioLevels` shortcut (issue #48).
2936
+ //
2937
+ // Two properties carry the design:
2938
+ //
2939
+ // - Nothing is created until every cue has been checked. A run that dies on
2940
+ // cue 30 of 90 leaves 29 sound effects in someone's timeline and an error
2941
+ // that does not say which ones — the same half-built failure
2942
+ // add_shape_content refuses to produce. So: plan first with no side
2943
+ // effects, throw naming the offending cue indices, and if a creation still
2944
+ // fails, remove everything this call made before rethrowing.
2945
+ // - A file named by several cues is imported once. Repeated imports of one
2946
+ // .wav are the normal shape of a cue list ("whoosh" nine times), and each
2947
+ // one would otherwise add another project item. Anything already in the
2948
+ // project from that path is reused rather than imported a second time.
2949
+
2950
+ var __MAX_AUDIO_CUES = 200;
2951
+ var __CUE_TIME_EPS = 1e-6;
2952
+
2953
+ // AE's layer label colours. Users can rename them in preferences, but the tool
2954
+ // takes an index and the indices do not move, so the names are accepted as a
2955
+ // convenience and translated here. 0 is "None".
2956
+ var __LABEL_NAMES = {
2957
+ none: 0, red: 1, yellow: 2, aqua: 3, pink: 4, lavender: 5, peach: 6,
2958
+ seafoam: 7, blue: 8, green: 9, purple: 10, orange: 11, brown: 12,
2959
+ fuchsia: 13, cyan: 14, sandstone: 15, darkgreen: 16
2960
+ };
2961
+
2962
+ function __labelKey(s) {
2963
+ return String(s).toLowerCase().replace(/[^a-z0-9]/g, "");
2964
+ }
2965
+
2966
+ function __labelNameList() {
2967
+ var names = [];
2968
+ for (var k in __LABEL_NAMES) {
2969
+ if (__LABEL_NAMES.hasOwnProperty(k)) names.push(k);
2970
+ }
2971
+ return names.join(", ");
2972
+ }
2973
+
2974
+ /** 0..16, or a thrown error naming everything it would have taken. */
2975
+ function __resolveLabel(label) {
2976
+ if (typeof label === "number") {
2977
+ if (label !== Math.floor(label) || label < 0 || label > 16) {
2978
+ throw new Error("label must be a whole number 0-16 or a colour name (" + __labelNameList() + "); got " + label);
2979
+ }
2980
+ return label;
2981
+ }
2982
+ var key = __labelKey(label);
2983
+ if (__LABEL_NAMES.hasOwnProperty(key)) return __LABEL_NAMES[key];
2984
+ throw new Error("unknown label \"" + label + "\" — use 0-16 or one of: " + __labelNameList());
2985
+ }
2986
+
2987
+ function __basename(p) {
2988
+ var s = String(p).replace(/\\/g, "/");
2989
+ var slash = s.lastIndexOf("/");
2990
+ if (slash >= 0) s = s.substring(slash + 1);
2991
+ return s;
2992
+ }
2993
+
2994
+ function __stripExtension(name) {
2995
+ var s = String(name);
2996
+ var dot = s.lastIndexOf(".");
2997
+ if (dot > 0) return s.substring(0, dot);
2998
+ return s;
2999
+ }
3000
+
3001
+ /**
3002
+ * The Audio Levels property of a layer.
3003
+ *
3004
+ * `layer.audioLevels` is the shortcut that works. `layer.property("ADBE Audio
3005
+ * Levels")` returns null on an audio layer — the property sits inside the
3006
+ * layer's "Audio" group, not on the layer — and a null there is what silently
3007
+ * turns a scripted level into no level at all. The group walk is only a
3008
+ * fallback for a layer whose shortcut is somehow absent; if neither answers,
3009
+ * the caller is told rather than left with an unset level.
3010
+ */
3011
+ function __audioLevelsProperty(layer) {
3012
+ var p = null;
3013
+ try { p = layer.audioLevels; } catch (e) {}
3014
+ if (p) return p;
3015
+ try {
3016
+ var g = layer.property("Audio");
3017
+ if (g) p = g.property("Audio Levels");
3018
+ } catch (e2) {}
3019
+ return p;
3020
+ }
3021
+
3022
+ /** null when the item can carry an audio cue, else the reason it cannot. */
3023
+ function __audioItemProblem(item) {
3024
+ if (item instanceof FolderItem) return "\"" + item.name + "\" is a folder, not footage";
3025
+ if (item instanceof CompItem) return "\"" + item.name + "\" is a comp — use create_precomp_layer for that";
3026
+ if (item.footageMissing) return "\"" + item.name + "\" is offline; the file it points at is missing";
3027
+ // Explicitly `=== false`, not `!item.hasAudio`: an item that does not report
3028
+ // the flag at all must not be refused on the strength of a missing property.
3029
+ if (item.hasAudio === false) return "\"" + item.name + "\" has no audio track";
3030
+ return null;
3031
+ }
3032
+
3033
+ function __sourceReport(item, path) {
3034
+ return { itemId: item.id, name: item.name, path: path };
3035
+ }
3036
+
3037
+ /**
3038
+ * Turn the cue list into a plan, touching nothing. Every problem found is
3039
+ * collected with the index of the cue that caused it rather than thrown on the
3040
+ * spot, so one call reports all of them instead of one per round trip.
3041
+ */
3042
+ function __planAudioCues(comp, args) {
3043
+ var cues = args.cues;
3044
+ if (!(cues instanceof Array) || cues.length === 0) {
3045
+ throw new Error("place_audio_cues needs a non-empty `cues` array.");
3046
+ }
3047
+ if (cues.length > __MAX_AUDIO_CUES) {
3048
+ throw new Error(
3049
+ "place_audio_cues was given " + cues.length + " cues and the limit is " + __MAX_AUDIO_CUES + " per call. " +
3050
+ "ExtendScript is single-threaded, so one long run freezes After Effects' interface for its whole duration. " +
3051
+ "Split the list into calls of " + __MAX_AUDIO_CUES + " or fewer."
3052
+ );
3053
+ }
3054
+
3055
+ var prefix = "SFX_";
3056
+ if (typeof args.namePrefix === "string") prefix = args.namePrefix;
3057
+
3058
+ var byPath = __itemPathMap();
3059
+ var problems = [];
3060
+ var planned = [];
3061
+ var toImport = [];
3062
+ var toImportSeen = {};
3063
+
3064
+ for (var i = 0; i < cues.length; i++) {
3065
+ var cue = cues[i];
3066
+ if (!cue) { problems.push({ cue: i, reason: "is empty" }); continue; }
3067
+
3068
+ var hasId = (cue.footageId !== undefined && cue.footageId !== null);
3069
+ var hasPath = (typeof cue.path === "string" && cue.path.length > 0);
3070
+ if (hasId && hasPath) { problems.push({ cue: i, reason: "has both footageId and path — give exactly one" }); continue; }
3071
+ if (!hasId && !hasPath) { problems.push({ cue: i, reason: "has neither footageId nor path — give exactly one" }); continue; }
3072
+
3073
+ var time = cue.time;
3074
+ if (typeof time !== "number" || !isFinite(time)) {
3075
+ problems.push({ cue: i, reason: "time must be a number of seconds; got " + String(time) });
3076
+ continue;
3077
+ }
3078
+ if (time < -__CUE_TIME_EPS || time > comp.duration + __CUE_TIME_EPS) {
3079
+ problems.push({ cue: i, reason: "time " + time + "s is outside the comp, which runs 0 to " + comp.duration + "s" });
3080
+ continue;
3081
+ }
3082
+
3083
+ var levelDb = 0;
3084
+ if (cue.levelDb !== undefined && cue.levelDb !== null) {
3085
+ if (typeof cue.levelDb !== "number" || !isFinite(cue.levelDb)) {
3086
+ problems.push({ cue: i, reason: "levelDb must be a number of decibels (0 is unedited); got " + String(cue.levelDb) });
3087
+ continue;
3088
+ }
3089
+ levelDb = cue.levelDb;
3090
+ }
3091
+
3092
+ var inPoint = null;
3093
+ var outPoint = null;
3094
+ var trimBad = false;
3095
+ if (cue.inPoint !== undefined && cue.inPoint !== null) {
3096
+ if (typeof cue.inPoint !== "number" || !isFinite(cue.inPoint)) {
3097
+ problems.push({ cue: i, reason: "inPoint must be a comp time in seconds; got " + String(cue.inPoint) });
3098
+ trimBad = true;
3099
+ } else {
3100
+ inPoint = cue.inPoint;
3101
+ }
3102
+ }
3103
+ if (!trimBad && cue.outPoint !== undefined && cue.outPoint !== null) {
3104
+ if (typeof cue.outPoint !== "number" || !isFinite(cue.outPoint)) {
3105
+ problems.push({ cue: i, reason: "outPoint must be a comp time in seconds; got " + String(cue.outPoint) });
3106
+ trimBad = true;
3107
+ } else {
3108
+ outPoint = cue.outPoint;
3109
+ }
3110
+ }
3111
+ if (trimBad) continue;
3112
+ // in/out are absolute comp times, like everywhere else in these tools, so
3113
+ // they are measured against `time` rather than against the file.
3114
+ if (inPoint !== null && inPoint < time - __CUE_TIME_EPS) {
3115
+ problems.push({ cue: i, reason: "inPoint " + inPoint + "s is before the cue's own time " + time + "s; both are comp times" });
3116
+ continue;
3117
+ }
3118
+ var trimStart = time;
3119
+ if (inPoint !== null) trimStart = inPoint;
3120
+ if (outPoint !== null && outPoint <= trimStart + __CUE_TIME_EPS) {
3121
+ problems.push({ cue: i, reason: "outPoint " + outPoint + "s is not after the cue starts at " + trimStart + "s" });
3122
+ continue;
3123
+ }
3124
+
3125
+ var label = null;
3126
+ if (cue.label !== undefined && cue.label !== null) {
3127
+ try {
3128
+ label = __resolveLabel(cue.label);
3129
+ } catch (eLabel) {
3130
+ problems.push({ cue: i, reason: eLabel.message });
3131
+ continue;
3132
+ }
3133
+ }
3134
+
3135
+ // Resolve the source. A footageId names an item that must already be
3136
+ // usable; a path is either something the project already holds or an
3137
+ // import this call will do exactly once.
3138
+ var source = null;
3139
+ var defaultName = null;
3140
+ if (hasId) {
3141
+ var item = app.project.itemByID(cue.footageId);
3142
+ if (!item) { problems.push({ cue: i, reason: "no project item with id " + cue.footageId }); continue; }
3143
+ var why = __audioItemProblem(item);
3144
+ if (why) { problems.push({ cue: i, reason: why }); continue; }
3145
+ source = { kind: "item", item: item, fsName: null, path: null };
3146
+ defaultName = item.name;
3147
+ } else {
3148
+ var file = new File(cue.path);
3149
+ if (!file.exists) { problems.push({ cue: i, reason: "no file at " + cue.path }); continue; }
3150
+ var fsName = String(file.fsName);
3151
+ var existing = null;
3152
+ if (byPath.hasOwnProperty(fsName)) existing = byPath[fsName];
3153
+ if (existing) {
3154
+ var whyExisting = __audioItemProblem(existing);
3155
+ if (whyExisting) { problems.push({ cue: i, reason: whyExisting + " (already in the project from " + cue.path + ")" }); continue; }
3156
+ source = { kind: "reused", item: existing, fsName: fsName, path: cue.path };
3157
+ } else {
3158
+ if (!toImportSeen.hasOwnProperty(fsName)) {
3159
+ toImportSeen[fsName] = true;
3160
+ toImport.push({ fsName: fsName, path: cue.path, file: file });
3161
+ }
3162
+ source = { kind: "import", item: null, fsName: fsName, path: cue.path };
3163
+ }
3164
+ // The caller named a path, so the path's basename is the honest default
3165
+ // even when the project item it resolves to was renamed by hand.
3166
+ defaultName = __basename(cue.path);
3167
+ }
3168
+
3169
+ var name = prefix + __stripExtension(defaultName);
3170
+ if (typeof cue.name === "string" && cue.name.length > 0) name = cue.name;
3171
+
3172
+ planned.push({
3173
+ index: i, name: name, time: time, levelDb: levelDb,
3174
+ inPoint: inPoint, outPoint: outPoint, label: label, source: source
3175
+ });
3176
+ }
3177
+
3178
+ return { prefix: prefix, planned: planned, problems: problems, toImport: toImport };
3179
+ }
3180
+
3181
+ function __audioProblemMessage(problems, total) {
3182
+ var lines = [];
3183
+ for (var i = 0; i < problems.length; i++) {
3184
+ lines.push("cue " + problems[i].cue + ": " + problems[i].reason);
3185
+ }
3186
+ return (
3187
+ "place_audio_cues placed nothing — " + problems.length + " of " + total + " cues cannot be placed. " +
3188
+ lines.join("; ") + ". Every cue is checked before anything is created, so the comp and project are " +
3189
+ "untouched. Fix these and call again; dryRun:true checks a list without placing it."
3190
+ );
3191
+ }
3192
+
3193
+ function __audioDryRunReport(comp, plan, total) {
3194
+ var cues = [];
3195
+ for (var i = 0; i < plan.planned.length; i++) {
3196
+ var p = plan.planned[i];
3197
+ var src = { kind: p.source.kind };
3198
+ if (p.source.item) {
3199
+ src.itemId = p.source.item.id;
3200
+ src.name = p.source.item.name;
3201
+ }
3202
+ if (p.source.path) src.path = p.source.path;
3203
+ cues.push({
3204
+ cue: p.index, name: p.name, time: p.time, levelDb: p.levelDb,
3205
+ inPoint: p.inPoint, outPoint: p.outPoint, label: p.label, source: src
3206
+ });
3207
+ }
3208
+ var wouldImport = [];
3209
+ for (var j = 0; j < plan.toImport.length; j++) wouldImport.push(plan.toImport[j].path);
3210
+
3211
+ var out = {
3212
+ dryRun: true,
3213
+ ok: plan.problems.length === 0,
3214
+ compId: comp.id,
3215
+ compName: comp.name,
3216
+ cueCount: total,
3217
+ wouldPlace: cues.length,
3218
+ wouldImport: wouldImport,
3219
+ problems: plan.problems,
3220
+ cues: cues,
3221
+ note: "Nothing was imported, created or changed, and this call is not an undo step."
3222
+ };
3223
+ if (wouldImport.length > 0) {
3224
+ out.unverified =
3225
+ wouldImport.length + " of these files are not in the project yet. They exist on disk, but whether each " +
3226
+ "carries an audio track is only knowable once After Effects has imported it — a real run checks that and " +
3227
+ "refuses the whole call if one does not.";
3228
+ }
3229
+ return out;
3230
+ }
3231
+
3232
+ /** Undo everything this call made, newest first. Layers before items: an item still in use cannot go. */
3233
+ function __rollbackAudioCues(layers, items) {
3234
+ for (var i = layers.length - 1; i >= 0; i--) {
3235
+ try { layers[i].remove(); } catch (e) {}
3236
+ }
3237
+ for (var j = items.length - 1; j >= 0; j--) {
3238
+ try { items[j].remove(); } catch (e2) {}
3239
+ }
3240
+ }
3241
+
3242
+ /**
3243
+ * `created` is the rollback list, and the layer joins it the instant it exists
3244
+ * rather than once it is fully configured. A cue that dies between add() and
3245
+ * the last setValue is exactly the case rollback is for, and a layer that had
3246
+ * not been registered yet would be the one thing left behind.
3247
+ */
3248
+ function __placeAudioCue(comp, p, item, created) {
3249
+ var layer = comp.layers.add(item);
3250
+ created.push(layer);
3251
+ layer.name = p.name;
3252
+ // startTime first: it slides the whole layer and would drag any trim with it.
3253
+ layer.startTime = p.time;
3254
+ var levels = __audioLevelsProperty(layer);
3255
+ if (!levels) {
3256
+ throw new Error(
3257
+ "the layer created for \"" + item.name + "\" has no Audio Levels property, so its level could not be set"
3258
+ );
3259
+ }
3260
+ // AE's Audio Levels is itself in decibels, one entry per channel.
3261
+ levels.setValue([p.levelDb, p.levelDb]);
3262
+ if (p.label !== null) layer.label = p.label;
3263
+ if (p.inPoint !== null) layer.inPoint = p.inPoint;
3264
+ if (p.outPoint !== null) layer.outPoint = p.outPoint;
3265
+ return layer;
3266
+ }
3267
+
3268
+ OPS.place_audio_cues = noUndoWhen(
3269
+ // dryRun is not an undo step either. A plan that quietly appeared in the
3270
+ // user's undo history would make "this changed nothing" false in the one
3271
+ // place they can see it.
3272
+ function (args) { return !!(args && args.dryRun === true); },
3273
+ function (args) {
3274
+ var comp = getCompById(args.compId);
3275
+ var total = 0;
3276
+ if (args.cues instanceof Array) total = args.cues.length;
3277
+ var plan = __planAudioCues(comp, args);
3278
+
3279
+ if (args.dryRun === true) return __audioDryRunReport(comp, plan, total);
3280
+ if (plan.problems.length > 0) throw new Error(__audioProblemMessage(plan.problems, total));
3281
+
3282
+ var createdLayers = [];
3283
+ var importedItems = [];
3284
+ var importedReport = [];
3285
+ var reusedReport = [];
3286
+ var reusedSeen = {};
3287
+ var placed = [];
3288
+
3289
+ try {
3290
+ // One import per distinct file, before any layer exists, so a bad file
3291
+ // costs nothing but the import itself.
3292
+ var imported = {};
3293
+ for (var i = 0; i < plan.toImport.length; i++) {
3294
+ var spec = plan.toImport[i];
3295
+ var newItem = __importFile(spec.file, spec.path, false);
3296
+ importedItems.push(newItem);
3297
+ var why = __audioItemProblem(newItem);
3298
+ if (why) throw new Error("imported " + spec.path + " and then found that " + why);
3299
+ imported[spec.fsName] = newItem;
3300
+ importedReport.push(__sourceReport(newItem, spec.path));
3301
+ }
3302
+
3303
+ for (var k = 0; k < plan.planned.length; k++) {
3304
+ var p = plan.planned[k];
3305
+ var item = p.source.item;
3306
+ if (!item) item = imported[p.source.fsName];
3307
+ if (p.source.kind !== "import" && !reusedSeen.hasOwnProperty(String(item.id))) {
3308
+ reusedSeen[String(item.id)] = true;
3309
+ reusedReport.push(__sourceReport(item, p.source.path));
3310
+ }
3311
+ var layer;
3312
+ try {
3313
+ layer = __placeAudioCue(comp, p, item, createdLayers);
3314
+ } catch (eCue) {
3315
+ throw new Error("cue " + p.index + " (\"" + p.name + "\" at " + p.time + "s): " + eCue.message);
3316
+ }
3317
+ placed.push({
3318
+ layerId: layer.id,
3319
+ index: layer.index,
3320
+ name: layer.name,
3321
+ time: p.time,
3322
+ levelDb: p.levelDb,
3323
+ itemId: item.id,
3324
+ // Read the trim back: AE clamps an in/out point to what the source
3325
+ // can actually supply, and the caller should see what it got.
3326
+ inPoint: layer.inPoint,
3327
+ outPoint: layer.outPoint,
3328
+ label: layer.label
3329
+ });
3330
+ }
3331
+ } catch (e) {
3332
+ __rollbackAudioCues(createdLayers, importedItems);
3333
+ throw new Error(
3334
+ "place_audio_cues failed on " + ((e && e.message) ? e.message : String(e)) +
3335
+ ". The " + createdLayers.length + " layer(s) and " + importedItems.length +
3336
+ " import(s) it had already made were removed, so the comp and project are as they were."
3337
+ );
3338
+ }
3339
+
3340
+ return {
3341
+ compId: comp.id,
3342
+ placed: placed,
3343
+ count: placed.length,
3344
+ sources: { imported: importedReport, reused: reusedReport },
3345
+ levelUnit: "dB"
3346
+ };
3347
+ }
3348
+ );
3349
+
3350
+
1871
3351
  // ===== mogrt.jsx =====
1872
3352
 
1873
3353
  // mogrt.jsx — export a comp as a Motion Graphics template without the three
@@ -1901,6 +3381,19 @@ OPS.create_footage_layer = function (args) {
1901
3381
  // a project writes Untitled.mogrt over the last one.
1902
3382
  // * The export invalidates every object reference held across it, including
1903
3383
  // `app.project`. See the re-fetch below.
3384
+ //
3385
+ // And one thing suppression costs, which is the whole of issue #71.
3386
+ // exportAsMotionGraphicsTemplate reports a boolean and nothing else — AE never
3387
+ // says *why* it declined — so once dialogs are suppressed a refusal and a
3388
+ // success-that-wrote-nothing look identical from a script. That silence is
3389
+ // evidence of nothing in particular, and it must not be read as "a dialog
3390
+ // blocked us": under beginSuppressDialogs() a blocking dialog is the one cause
3391
+ // ruled out by construction, because suppressing them is exactly what that call
3392
+ // did. A comp with an empty Essential Graphics panel is not exportable, and it
3393
+ // used to come back blaming a dialog that did not exist and offering a remedy
3394
+ // (retry unsuppressed and click it) that led nowhere. So: check every
3395
+ // precondition that can be checked *before* the export and name it, and when
3396
+ // the export still fails, report the cause as unknown rather than as a dialog.
1904
3397
 
1905
3398
  /** Distinct fonts used by the text layers in a comp, following nested comps. */
1906
3399
  function __collectFonts(comp, depth, seenComps, fonts) {
@@ -1937,6 +3430,106 @@ function __mogrtFileName(name) {
1937
3430
  return String(name) + ".mogrt";
1938
3431
  }
1939
3432
 
3433
+ /** Trailing separators off, so two spellings of the same folder compare equal. */
3434
+ function __trimTrailingSeparator(p) {
3435
+ var s = String(p);
3436
+ while (s.length > 1) {
3437
+ var last = s.charAt(s.length - 1);
3438
+ if (last !== "/" && last !== "\\") break;
3439
+ s = s.substring(0, s.length - 1);
3440
+ }
3441
+ return s;
3442
+ }
3443
+
3444
+ function __isBlank(s) {
3445
+ var t = String(s);
3446
+ for (var i = 0; i < t.length; i++) {
3447
+ var ch = t.charAt(i);
3448
+ if (ch !== " " && ch !== "\t" && ch !== "\n" && ch !== "\r") return false;
3449
+ }
3450
+ return true;
3451
+ }
3452
+
3453
+ /**
3454
+ * How many controllers the comp's Essential Graphics panel holds, or null when
3455
+ * the host will not say.
3456
+ *
3457
+ * null is not zero. A read that throws means this AE cannot be asked, and
3458
+ * refusing an export on the strength of a question that was never answered
3459
+ * would be its own confidently wrong diagnosis. Only an explicit 0 refuses.
3460
+ */
3461
+ function __controllerCount(comp) {
3462
+ var n = null;
3463
+ try { n = comp.motionGraphicsTemplateControllerCount; } catch (e) { return null; }
3464
+ if (typeof n !== "number") return null;
3465
+ return n;
3466
+ }
3467
+
3468
+ /**
3469
+ * Whether After Effects can actually write into the destination folder.
3470
+ *
3471
+ * ExtendScript has no permission API, so the only reliable test is to write.
3472
+ * A folder that exists and refuses writes is another cause the export reports
3473
+ * as nothing at all, and it is worth naming before spending the export rather
3474
+ * than after. The probe is removed on every path; it never outlives the call.
3475
+ */
3476
+ function __folderIsWritable(folder) {
3477
+ var probe = new File(__joinPath(folder.fsName, ".ae-mcp-write-probe"));
3478
+ var opened = false;
3479
+ try {
3480
+ opened = probe.open("w");
3481
+ if (opened) probe.write("");
3482
+ } catch (eW) {
3483
+ opened = false;
3484
+ }
3485
+ try { probe.close(); } catch (eC) {}
3486
+ try { if (probe.exists) probe.remove(); } catch (eR) {}
3487
+ return opened === true;
3488
+ }
3489
+
3490
+ /**
3491
+ * The failure message for an export that wrote nothing.
3492
+ *
3493
+ * Built rather than fixed, because the only honest message names what was
3494
+ * actually established and stops there. `suppressed` is the hinge: with dialogs
3495
+ * suppressed a modal cannot be the cause and must not be mentioned as one
3496
+ * (issue #71); without suppression it is the first thing to look at. Everything
3497
+ * after that is unknown, and says so — AE's own UI is the only place the real
3498
+ * reason exists, which is why the diagnostic step is to run the export by hand.
3499
+ */
3500
+ function __exportFailureMessage(info) {
3501
+ var lines = [];
3502
+ lines.push(
3503
+ "After Effects did not write a template. It returned " + String(info.exported) +
3504
+ " and there is no file at " + info.outPath + "."
3505
+ );
3506
+ lines.push(
3507
+ "Ruled out before the export ran: the project is saved (" + info.projectPath + "); the destination " +
3508
+ "folder exists and accepts writes; the template name resolves to that folder; and the comp has " +
3509
+ info.controllerText + "."
3510
+ );
3511
+ if (!info.suppressed) {
3512
+ lines.push(
3513
+ "Dialogs were NOT suppressed for this call, so a modal dialog waiting in After Effects is the " +
3514
+ "likeliest cause — switch to After Effects and look for one. Retry with the default " +
3515
+ "suppressDialogs:true once it is cleared."
3516
+ );
3517
+ } else {
3518
+ lines.push(
3519
+ "Dialogs were suppressed, so a modal dialog cannot be what stopped this — do not go looking for " +
3520
+ "one to click. After Effects reported no reason at all, and there is no reason for a script to " +
3521
+ "read, so the cause is unknown."
3522
+ );
3523
+ lines.push(
3524
+ "To find out what After Effects would have said, ask the user to export the same comp by hand: " +
3525
+ "Window > Essential Graphics, select the comp, then Export Motion Graphics Template. After " +
3526
+ "Effects shows its own error there. Running this tool again with suppressDialogs:false shows the " +
3527
+ "same dialog, but it will freeze this connection until someone clicks it."
3528
+ );
3529
+ }
3530
+ return lines.join(" ");
3531
+ }
3532
+
1940
3533
  OPS.export_mogrt = noUndo(function (args) {
1941
3534
  var comp = getCompById(args.compId);
1942
3535
 
@@ -1952,6 +3545,29 @@ OPS.export_mogrt = noUndo(function (args) {
1952
3545
  );
1953
3546
  }
1954
3547
 
3548
+ // Every precondition that can be established from a script runs here, before
3549
+ // the project is touched at all: the template is not renamed, the project is
3550
+ // not saved and no export is attempted until they all pass. A refusal below
3551
+ // therefore costs the user nothing and names its own remedy — which is the
3552
+ // whole point, since a failure *after* the export names nothing.
3553
+
3554
+ // The Essential Graphics panel has to have something in it. AE will not build
3555
+ // a template with no controllers, and under suppression it declines without a
3556
+ // word — which is what made this look like a dialog (issue #71). An explicit
3557
+ // 0 is the whole diagnosis; a count the host refuses to give is not.
3558
+ var preControllerCount = __controllerCount(comp);
3559
+ if (preControllerCount === 0) {
3560
+ throw new Error(
3561
+ "This comp has 0 Essential Graphics controllers, and After Effects cannot export a template " +
3562
+ "from a comp with an empty Essential Graphics panel. It refuses without writing a file and " +
3563
+ "without reporting anything a script can read, so nothing was exported and nothing in the " +
3564
+ "project was changed. To fix it, put at least one property into the panel: in After Effects " +
3565
+ "open Window > Essential Graphics, pick this comp in the panel's dropdown, then drag a layer " +
3566
+ "property into it — a text layer's Source Text, a colour, a slider, a position. Then call " +
3567
+ "export_mogrt again."
3568
+ );
3569
+ }
3570
+
1955
3571
  var templateName = (typeof args.name === "string" && args.name.length > 0) ? args.name : null;
1956
3572
  var previousTemplateName = null;
1957
3573
  try { previousTemplateName = comp.motionGraphicsTemplateName; } catch (e) {}
@@ -1959,7 +3575,16 @@ OPS.export_mogrt = noUndo(function (args) {
1959
3575
  // AE's own default is "Untitled" for a template built by script, which
1960
3576
  // silently collides with every other comp in the project. The comp name is
1961
3577
  // what the user would have typed. A name they *did* type is left alone.
1962
- templateName = (!previousTemplateName || previousTemplateName === "Untitled") ? comp.name : previousTemplateName;
3578
+ if (!previousTemplateName || previousTemplateName === "Untitled") {
3579
+ templateName = comp.name;
3580
+ } else {
3581
+ templateName = previousTemplateName;
3582
+ }
3583
+ }
3584
+ if (__isBlank(templateName)) {
3585
+ throw new Error(
3586
+ "The template name is empty, and it becomes the .mogrt filename. Pass a `name`."
3587
+ );
1963
3588
  }
1964
3589
 
1965
3590
  var destDir = (typeof args.destDir === "string" && args.destDir.length > 0)
@@ -1974,6 +3599,22 @@ OPS.export_mogrt = noUndo(function (args) {
1974
3599
 
1975
3600
  var outPath = __joinPath(folder.fsName, __mogrtFileName(templateName));
1976
3601
  var outFile = new File(outPath);
3602
+
3603
+ // The name becomes a filename, so a separator in it sends the write somewhere
3604
+ // else — or nowhere, silently. Measured rather than pattern-matched: ask the
3605
+ // File where it actually landed and compare. That works the same on both
3606
+ // platforms, which a list of illegal characters would not.
3607
+ var resolvedParent = null;
3608
+ try { resolvedParent = outFile.parent.fsName; } catch (eP) {}
3609
+ if (resolvedParent !== null &&
3610
+ __trimTrailingSeparator(resolvedParent) !== __trimTrailingSeparator(folder.fsName)) {
3611
+ throw new Error(
3612
+ "The template name \"" + templateName + "\" is used as the .mogrt filename, and it resolves to " +
3613
+ outPath + ", which is not inside " + folder.fsName + ". Pass a `name` with no slashes in it, and " +
3614
+ "a `destDir` if you want it written somewhere else."
3615
+ );
3616
+ }
3617
+
1977
3618
  var existed = outFile.exists;
1978
3619
  if (existed && args.overwrite !== true) {
1979
3620
  throw new Error(
@@ -1982,6 +3623,17 @@ OPS.export_mogrt = noUndo(function (args) {
1982
3623
  );
1983
3624
  }
1984
3625
 
3626
+ // Last of the checkable preconditions, and the last thing that costs nothing
3627
+ // to get wrong. A folder that will not accept a file is one more cause the
3628
+ // export reports as silence.
3629
+ if (!__folderIsWritable(folder)) {
3630
+ throw new Error(
3631
+ "After Effects cannot write into " + folder.fsName + " — a test file could not be created there. " +
3632
+ "Nothing was exported. Pass a `destDir` the user can write to, or ask them to fix the folder's " +
3633
+ "permissions."
3634
+ );
3635
+ }
3636
+
1985
3637
  var fonts = [];
1986
3638
  try { __collectFonts(comp, 0, [], fonts); } catch (eF) {}
1987
3639
 
@@ -1995,6 +3647,7 @@ OPS.export_mogrt = noUndo(function (args) {
1995
3647
  // Everything the result needs, read *before* the export. See below.
1996
3648
  var compId = comp.id;
1997
3649
  var compName = comp.name;
3650
+ var projectPath = app.project.file.fsName;
1998
3651
 
1999
3652
  var suppress = args.suppressDialogs !== false;
2000
3653
  var suppressed = false;
@@ -2023,10 +3676,21 @@ OPS.export_mogrt = noUndo(function (args) {
2023
3676
  // codebase refuses to pass on.
2024
3677
  var written = new File(outPath);
2025
3678
  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
- );
3679
+ var controllerText;
3680
+ if (preControllerCount === null) {
3681
+ controllerText = "controllers this version of After Effects would not count";
3682
+ } else if (preControllerCount === 1) {
3683
+ controllerText = "1 Essential Graphics controller";
3684
+ } else {
3685
+ controllerText = String(preControllerCount) + " Essential Graphics controllers";
3686
+ }
3687
+ throw new Error(__exportFailureMessage({
3688
+ exported: exported,
3689
+ outPath: outPath,
3690
+ projectPath: projectPath,
3691
+ suppressed: suppress,
3692
+ controllerText: controllerText
3693
+ }));
2030
3694
  }
2031
3695
  var bytes = written.length;
2032
3696
  if (!(bytes > 0)) throw new Error("The exported template at " + outPath + " is empty (0 bytes).");
@@ -2042,7 +3706,9 @@ OPS.export_mogrt = noUndo(function (args) {
2042
3706
  createdDir: createdDir,
2043
3707
  projectSaved: app.project.file.fsName,
2044
3708
  dialogsSuppressed: suppress,
2045
- controllerCount: comp.motionGraphicsTemplateControllerCount,
3709
+ // Re-read rather than echoed: the export is the only thing that can change
3710
+ // it, and a host that will not answer says null instead of a made-up 0.
3711
+ controllerCount: __controllerCount(comp),
2046
3712
  fonts: fonts
2047
3713
  };
2048
3714
  if (!suppress) {
@@ -2196,32 +3862,66 @@ OPS.set_house_style = noUndo(function (args) {
2196
3862
 
2197
3863
  // batch.jsx — execute many ops in one ExtendScript pass.
2198
3864
  // For huge batches, registers a job and processes in chunks via _continue_job.
3865
+ //
3866
+ // UNDO GROUPING, which is the whole design constraint here (issue #69):
3867
+ // After Effects **discards** an undo group opened in one `evalScript` call and
3868
+ // closed in another. Measured, not inferred — a 600-op batch that opened its
3869
+ // group in `run_batch` and closed it from a later `_continue_job` produced ~600
3870
+ // undo steps, and AE's Edit menu read `Undo New Solid` rather than the group's
3871
+ // name. So a group only survives if it opens and closes inside one call, and
3872
+ // that gives the two paths below genuinely different answers:
3873
+ //
3874
+ // inline (<= 500 ops, or any size with singleUndo) — the whole batch runs in
3875
+ // one call, so one group round it is real. One undo step.
3876
+ // chunked (> 500 ops) — the batch spans N calls, so the most AE will keep is
3877
+ // one group per chunk. N undo steps, counted and reported, never claimed
3878
+ // to be one.
3879
+
3880
+ // The inline cutoff. Inline stays sub-second for typical create/keyframe ops up
3881
+ // to a few hundred; the async-job overhead (jobId envelope, polling, progress
3882
+ // notifications) is only worth it for genuinely long jobs.
3883
+ var __BATCH_INLINE_MAX = 500;
3884
+ // Chunk size for the async path. The panel reads this back off the envelope
3885
+ // rather than holding its own copy, so the two cannot drift.
3886
+ var __BATCH_CHUNK = 25;
3887
+ // The ceiling on singleUndo. Past this, one blocking ExtendScript call freezes
3888
+ // After Effects for long enough that the user will think it has hung, with no
3889
+ // progress events to say otherwise — so it is refused rather than attempted.
3890
+ var __BATCH_SINGLE_UNDO_MAX = 2000;
2199
3891
 
2200
3892
  OPS.run_batch = function (args) {
2201
3893
  var ops = args.ops || [];
2202
3894
  var transactional = args.transactional !== false;
2203
3895
  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 };
3896
+ var singleUndo = args.singleUndo === true;
3897
+
3898
+ // Refused before anything else happens, including the diff fingerprint: a
3899
+ // call that is not going to run should cost nothing.
3900
+ if (singleUndo && ops.length > __BATCH_SINGLE_UNDO_MAX) {
3901
+ throw new Error(
3902
+ "singleUndo refuses " + ops.length + " ops (the limit is " + __BATCH_SINGLE_UNDO_MAX + "). " +
3903
+ "One undo step means the whole batch runs inside a single ExtendScript call, which " +
3904
+ "freezes After Effects' interface for the whole of it and reports no progress; at this " +
3905
+ "size the user would reasonably think it had hung. Either split the work into batches of " +
3906
+ __BATCH_SINGLE_UNDO_MAX + " or fewer, or drop singleUndo and accept one undo step per " +
3907
+ "chunk of " + __BATCH_CHUNK + " (the result says exactly how many)."
3908
+ );
3909
+ }
3910
+
3911
+ // diff:true fingerprints the comps this batch names, before and after, inside
3912
+ // this one call — see snapshot.jsx. Null unless asked for.
3913
+ var diffState = __diffStart(args, ops);
3914
+
3915
+ if (ops.length <= __BATCH_INLINE_MAX || singleUndo) {
3916
+ return __batchInline(ops, transactional, name, diffState);
2222
3917
  }
3918
+
2223
3919
  // Long batches: register job, return jobId; panel polls _continue_job.
3920
+ // No undo group is opened here on purpose. One opened now would be closed
3921
+ // from a different call and AE would throw it away, which is exactly how this
3922
+ // op spent three releases reporting one undo step and delivering hundreds.
2224
3923
  var jobId = __newJobId();
3924
+ var estimate = Math.ceil(ops.length / __BATCH_CHUNK);
2225
3925
  JOBS[jobId] = {
2226
3926
  cursor: 0,
2227
3927
  total: ops.length,
@@ -2231,48 +3931,212 @@ OPS.run_batch = function (args) {
2231
3931
  cancelled: false,
2232
3932
  transactional: transactional,
2233
3933
  name: name,
3934
+ chunkSize: __BATCH_CHUNK,
3935
+ // Counted as groups are actually opened, never predicted. See __UNDO_GROUPS.
3936
+ undoSteps: 0,
3937
+ // Carried across the chunked continuations: the before-fingerprint has to
3938
+ // outlive the call that took it, or a long batch could never be diffed.
3939
+ diffState: diffState,
3940
+ };
3941
+ return {
3942
+ jobId: jobId,
3943
+ async: true,
3944
+ total: ops.length,
3945
+ chunkSize: __BATCH_CHUNK,
3946
+ // Labelled an estimate because it is one: a transactional failure ends the
3947
+ // batch early, and the exact count comes back with the final result.
3948
+ undoStepsEstimate: estimate,
3949
+ undoGroupName: name,
3950
+ note: "This batch runs in " + estimate + " chunks of " + __BATCH_CHUNK + " and will land as " +
3951
+ "about " + estimate + " undo steps, NOT one — After Effects discards an undo group that " +
3952
+ "spans two script calls, so a chunked batch can only group per chunk. The final result " +
3953
+ "reports the exact number. If the user needs a single Cmd-Z, re-send with singleUndo:true " +
3954
+ "(up to " + __BATCH_SINGLE_UNDO_MAX + " ops), which runs the whole batch in one blocking " +
3955
+ "call with no progress events.",
2234
3956
  };
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
3957
  };
2241
- // run_batch is allowed to manage its own undo (we open/close it manually for long jobs).
3958
+ // run_batch is allowed to manage its own undo: the inline path opens exactly one
3959
+ // group of its own, and the chunked path opens one per continuation.
2242
3960
  OPS.run_batch.__meta = { noUndo: true };
2243
3961
 
3962
+ // The inline path: every op inside one undo group inside one evalScript, which
3963
+ // is the only shape After Effects keeps.
3964
+ //
3965
+ // withUndo() rather than a bare beginUndoGroup/endUndoGroup pair, for two
3966
+ // reasons: its `finally` closes the group when a transactional op throws, and it
3967
+ // is the only thing that keeps __UNDO_OPEN honest, so a batched op that calls
3968
+ // withoutUndoGroup() still works instead of silently doing nothing.
3969
+ function __batchInline(ops, transactional, name, diffState) {
3970
+ var results = [];
3971
+ var errors = [];
3972
+ var before = __undoGroupsOpened();
3973
+ // A run_batch listed as an op inside another run_batch would otherwise open a
3974
+ // second group inside the first, and AE's groups do not nest: the inner
3975
+ // endUndoGroup closes the outer one and the rest of the outer batch writes
3976
+ // ungrouped. Run inside the group that is already open instead, and say so —
3977
+ // an inner batch reporting "one undo step" of its own would be a second lie
3978
+ // in the same shape as the one this fixes.
3979
+ var nested = __UNDO_OPEN;
3980
+ // An empty batch opens nothing. AE records no undo step for a group that
3981
+ // changed nothing, so opening one would make `undoSteps: 1` an over-report on
3982
+ // the one call where the truth is unambiguous.
3983
+ var empty = ops.length === 0;
3984
+ var run = function () {
3985
+ for (var i = 0; i < ops.length; i++) {
3986
+ var step = ops[i];
3987
+ try {
3988
+ var handler = OPS[step.op];
3989
+ if (!handler) throw new Error("Unknown op: " + step.op);
3990
+ results.push(handler(step.args || {}));
3991
+ } catch (e) {
3992
+ errors.push({ index: i, op: step.op, error: e.message });
3993
+ if (transactional) {
3994
+ // Nothing rolls back, so the half-applied state is what the caller
3995
+ // has to reason about. The diff says where it stopped.
3996
+ var failure = new Error("Batch failed at op[" + i + "] " + step.op + ": " + e.message);
3997
+ __diffAnnotateError(failure, diffState);
3998
+ throw failure;
3999
+ }
4000
+ }
4001
+ }
4002
+ };
4003
+ try {
4004
+ if (nested || empty) run();
4005
+ else withUndo(name, run);
4006
+ } catch (e) {
4007
+ // The group is already closed by withUndo's finally. Say what it cost, so a
4008
+ // failed batch is as clear about its undo history as a successful one.
4009
+ try {
4010
+ e.message = String(e.message) + " || " +
4011
+ __batchUndoNote(__undoGroupsOpened() - before, name, nested, false);
4012
+ } catch (e2) {}
4013
+ throw e;
4014
+ }
4015
+ var out = {
4016
+ results: results,
4017
+ errors: errors,
4018
+ total: ops.length,
4019
+ undoSteps: __undoGroupsOpened() - before,
4020
+ undoGroupName: name,
4021
+ };
4022
+ if (nested) out.nested = true;
4023
+ out.note = __batchUndoNote(out.undoSteps, name, nested, false);
4024
+ if (diffState) out.diff = __diffFinish(diffState);
4025
+ return out;
4026
+ }
4027
+
4028
+ // The one sentence every batch result carries about its own undo history.
4029
+ // Written for an agent that is about to tell a person how to undo the work.
4030
+ // `steps` is always the measured count, so this describes what happened rather
4031
+ // than what the path intended.
4032
+ function __batchUndoNote(steps, name, nested, chunked) {
4033
+ if (nested) {
4034
+ return "This batch ran inside an undo group that was already open (a run_batch nested in " +
4035
+ "another one), so it adds no undo step of its own — its work is part of the outer step.";
4036
+ }
4037
+ if (steps <= 0) {
4038
+ return "This batch opened no undo group, so it added no undo steps.";
4039
+ }
4040
+ if (steps === 1) {
4041
+ // A chunked batch reaches one step only by stopping after its first chunk —
4042
+ // cancelled, or failed in it — and that group is named "(1)", not the bare
4043
+ // name. Quoting the wrong one would send the user looking for a menu entry
4044
+ // that is not there.
4045
+ return 'This batch is one undo step ("' + name + (chunked ? " (1)" : "") +
4046
+ '"): a single Cmd-Z (Ctrl-Z on Windows) undoes all of it.';
4047
+ }
4048
+ if (chunked) {
4049
+ return "This batch landed as " + steps + " undo steps, NOT one — named \"" + name + " (1)\" " +
4050
+ 'through "' + name + " (" + steps + ')". After Effects discards an undo group that spans ' +
4051
+ "two script calls, so a chunked batch can only group per chunk. Undoing all of it takes up " +
4052
+ "to " + steps + " presses of Cmd-Z (Ctrl-Z on Windows) — tell the user that number rather " +
4053
+ "than saying one. (A chunk whose ops changed nothing records no step, so that is the " +
4054
+ "ceiling.) singleUndo:true buys a single step back, at the cost of freezing AE's interface " +
4055
+ "for the whole batch and emitting no progress.";
4056
+ }
4057
+ return "This batch landed as " + steps + " undo steps rather than one: an op inside it closed " +
4058
+ "the batch's undo group and opened a new one (withoutUndoGroup — After Effects refuses " +
4059
+ "copyToComp on a parented or expression-linked layer while a group is open). Undoing all of " +
4060
+ "it takes up to " + steps + " presses of Cmd-Z (Ctrl-Z on Windows).";
4061
+ }
4062
+
2244
4063
  // Continuation step. Returns one chunk's worth of progress.
4064
+ //
4065
+ // Cancellation, which used to need bookkeeping across calls and no longer does:
4066
+ // _cancel_job is its own evalScript and the panel serializes those, so a cancel
4067
+ // can only ever be observed *between* chunks. A chunk's group opens and closes
4068
+ // inside this one call, so there is never an open group for a cancel — or a
4069
+ // throw, or a dropped WebSocket — to leave behind. That is what the old
4070
+ // `j.undoOpen` flag was for, and it was tracking a group AE had already dropped.
2245
4071
  OPS._continue_job = noUndo(function (args) {
2246
4072
  var jobId = args.jobId;
2247
4073
  var j = JOBS[jobId];
2248
4074
  if (!j) throw new Error("No job: " + jobId);
2249
4075
  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 };
4076
+ var stopped = {
4077
+ done: true, cancelled: true, jobId: jobId,
4078
+ results: j.results, errors: j.errors,
4079
+ undoSteps: j.undoSteps, undoGroupName: j.name,
4080
+ note: __batchUndoNote(j.undoSteps, j.name, false, true),
4081
+ };
4082
+ if (j.diffState) stopped.diff = __diffFinish(j.diffState);
4083
+ return stopped;
2252
4084
  }
2253
- var chunkSize = args.chunkSize || 25;
4085
+ var chunkSize = args.chunkSize || j.chunkSize || __BATCH_CHUNK;
2254
4086
  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 };
4087
+ var before = __undoGroupsOpened();
4088
+ var failure = null;
4089
+ // One group per chunk, opened and closed inside this call. The chunk number
4090
+ // is in the name so the Edit menu says which part of the batch a step is.
4091
+ withUndo(j.name + " (" + (j.undoSteps + 1) + ")", function () {
4092
+ for (; j.cursor < endAt; j.cursor++) {
4093
+ var step = j.ops[j.cursor];
4094
+ try {
4095
+ var handler = OPS[step.op];
4096
+ if (!handler) throw new Error("Unknown op: " + step.op);
4097
+ j.results.push(handler(step.args || {}));
4098
+ } catch (e) {
4099
+ j.errors.push({ index: j.cursor, op: step.op, error: e.message });
4100
+ if (j.transactional) {
4101
+ failure = { error: e.message, atIndex: j.cursor };
4102
+ return;
4103
+ }
2268
4104
  }
2269
4105
  }
4106
+ });
4107
+ j.undoSteps += __undoGroupsOpened() - before;
4108
+
4109
+ if (failure) {
4110
+ // No rollback is attempted. The old code fired one `Undo` menu command
4111
+ // here, which undoes at most the last step even when it works at all —
4112
+ // menu commands depend on host focus and the active selection, neither of
4113
+ // which this bridge has. Reporting a rollback that did not happen is the
4114
+ // same class of lie as swallowing an error, so it says plainly what is on
4115
+ // disk and how many steps it took.
4116
+ var failed = {
4117
+ done: true, failed: true, jobId: jobId,
4118
+ error: failure.error, atIndex: failure.atIndex,
4119
+ results: j.results, errors: j.errors,
4120
+ rolledBack: false,
4121
+ undoSteps: j.undoSteps, undoGroupName: j.name,
4122
+ note: __batchUndoNote(j.undoSteps, j.name, false, true) +
4123
+ " Nothing was rolled back: the ops before op[" + failure.atIndex + "] are applied and " +
4124
+ "stay applied. Read the state back rather than re-running the batch.",
4125
+ };
4126
+ if (j.diffState) failed.diff = __diffFinish(j.diffState);
4127
+ return failed;
2270
4128
  }
2271
4129
  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 };
4130
+ var done = {
4131
+ done: true, jobId: jobId,
4132
+ results: j.results, errors: j.errors, total: j.total,
4133
+ undoSteps: j.undoSteps, undoGroupName: j.name,
4134
+ note: __batchUndoNote(j.undoSteps, j.name, false, true),
4135
+ };
4136
+ if (j.diffState) done.diff = __diffFinish(j.diffState);
4137
+ return done;
2274
4138
  }
2275
- return { done: false, jobId: jobId, progress: j.cursor, total: j.total };
4139
+ return { done: false, jobId: jobId, progress: j.cursor, total: j.total, undoSteps: j.undoSteps };
2276
4140
  });
2277
4141
 
2278
4142
  OPS._cancel_job = noUndo(function (args) {
@@ -2285,7 +4149,10 @@ OPS._cancel_job = noUndo(function (args) {
2285
4149
  OPS._get_job = noUndo(function (args) {
2286
4150
  var j = JOBS[args.jobId];
2287
4151
  if (!j) return null;
2288
- return { cursor: j.cursor, total: j.total, cancelled: j.cancelled, errorCount: j.errors.length };
4152
+ return {
4153
+ cursor: j.cursor, total: j.total, cancelled: j.cancelled,
4154
+ errorCount: j.errors.length, undoSteps: j.undoSteps,
4155
+ };
2289
4156
  });
2290
4157
 
2291
4158
 
@@ -2464,14 +4331,97 @@ function __serializeText(layer) {
2464
4331
  } catch (e) { return null; }
2465
4332
  }
2466
4333
 
2467
- function __serializeShapeContents(group, depth) {
4334
+ // ---------- shape contents ----------
4335
+ //
4336
+ // Two of the groups hanging off every vector group are fixed-shape and almost
4337
+ // never the reason anyone reads a shape layer.
4338
+ //
4339
+ // Material Options is the 48-property 3D extrusion model. It means something
4340
+ // only for an extruded shape under the Cinema 4D renderer, and on the 2D shape
4341
+ // layers that are nearly all of them it is inert — while being most of the
4342
+ // weight of a shape read: one 68x68 circle in one group came back as 13KB of
4343
+ // shape JSON, 10KB of it material properties (issue #42). Skipped unless
4344
+ // `shapeMaterials` asks for it, and the skip is counted and explained in the
4345
+ // response rather than being silent.
4346
+ var __SHAPE_MATERIALS = "ADBE Vector Materials Group";
4347
+ var __SHAPE_TRANSFORM = "ADBE Vector Transform Group";
4348
+
4349
+ // A group Transform still at its creation values says nothing that
4350
+ // `atDefaults: true` does not. Tested by value rather than through
4351
+ // PropertyBase.isModified: the values are the contract, they can be asserted
4352
+ // with no AE to run in, and a property this table does not know about — a
4353
+ // future AE adding one — has to fail the test rather than be folded away
4354
+ // unread.
4355
+ var __VECTOR_TRANSFORM_DEFAULTS = [
4356
+ ["ADBE Vector Anchor", [0, 0]],
4357
+ ["ADBE Vector Position", [0, 0]],
4358
+ ["ADBE Vector Scale", [100, 100]],
4359
+ ["ADBE Vector Skew", 0],
4360
+ ["ADBE Vector Skew Axis", 0],
4361
+ ["ADBE Vector Rotation", 0],
4362
+ ["ADBE Vector Group Opacity", 100]
4363
+ ];
4364
+
4365
+ function __isPropertyGroup(p) {
4366
+ return p.propertyType === PropertyType.NAMED_GROUP || p.propertyType === PropertyType.INDEXED_GROUP;
4367
+ }
4368
+
4369
+ function __vectorTransformDefault(matchName) {
4370
+ for (var i = 0; i < __VECTOR_TRANSFORM_DEFAULTS.length; i++) {
4371
+ if (__VECTOR_TRANSFORM_DEFAULTS[i][0] === matchName) return __VECTOR_TRANSFORM_DEFAULTS[i][1];
4372
+ }
4373
+ return null;
4374
+ }
4375
+
4376
+ function __sameVectorValue(a, b) {
4377
+ if (b instanceof Array) {
4378
+ if (!(a instanceof Array) || a.length !== b.length) return false;
4379
+ for (var i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false; }
4380
+ return true;
4381
+ }
4382
+ return a === b;
4383
+ }
4384
+
4385
+ function __isIdentityVectorTransform(tg) {
4386
+ if (!tg || !tg.numProperties) return false;
4387
+ for (var i = 1; i <= tg.numProperties; i++) {
4388
+ var p = tg.property(i);
4389
+ // Animated or expression-driven is never "at defaults", whatever it reads
4390
+ // at this instant.
4391
+ if (p.numKeys > 0) return false;
4392
+ try { if (p.canSetExpression && p.expression) return false; } catch (e) {}
4393
+ var def = __vectorTransformDefault(p.matchName);
4394
+ if (def === null) return false;
4395
+ var v;
4396
+ try { v = p.value; } catch (e2) { return false; }
4397
+ if (!__sameVectorValue(v, def)) return false;
4398
+ }
4399
+ return true;
4400
+ }
4401
+
4402
+ // Carries the caller's choices down the walk and collects what was left out, so
4403
+ // the omissions can be named once at the top of the section instead of being
4404
+ // repeated on every group.
4405
+ function __shapeOpts(args) {
4406
+ return {
4407
+ materials: !!(args && args.shapeMaterials),
4408
+ compact: !!(args && args.shapeDetail === "compact"),
4409
+ materialsOmitted: 0
4410
+ };
4411
+ }
4412
+
4413
+ function __serializeShapeContents(group, depth, opts) {
2468
4414
  if (!group || !group.numProperties) return [];
2469
4415
  var out = [];
2470
4416
  for (var i = 1; i <= group.numProperties; i++) {
2471
4417
  var p = group.property(i);
4418
+ if (p.matchName === __SHAPE_MATERIALS && !opts.materials) { opts.materialsOmitted += 1; continue; }
4419
+ // `index` stays the real one whatever was skipped, so a path built from
4420
+ // this response still addresses the node it names.
2472
4421
  var entry = { name: p.name, matchName: p.matchName, index: i };
2473
- if (p.propertyType === PropertyType.NAMED_GROUP || p.propertyType === PropertyType.INDEXED_GROUP) {
2474
- if (depth > 0) entry.children = __serializeShapeContents(p, depth - 1);
4422
+ if (__isPropertyGroup(p)) {
4423
+ if (p.matchName === __SHAPE_TRANSFORM && __isIdentityVectorTransform(p)) entry.atDefaults = true;
4424
+ else if (depth > 0) entry.children = __serializeShapeContents(p, depth - 1, opts);
2475
4425
  // Say where the walk stopped. A group that simply has no `children` key
2476
4426
  // reads as empty, which for a deep shape tree is a lie.
2477
4427
  else if (p.numProperties > 0) entry.childrenOmitted = p.numProperties;
@@ -2483,6 +4433,120 @@ function __serializeShapeContents(group, depth) {
2483
4433
  return out;
2484
4434
  }
2485
4435
 
4436
+ // ---------- compact shape serialization ----------
4437
+ //
4438
+ // One line per group, with that group's own leaf properties folded onto it.
4439
+ // The full form spends four JSON lines on every property it reports; the same
4440
+ // lamp layer is around 450 characters here against 2,800 full (and 13,000
4441
+ // before the material groups came out). It is a reading format, not a lesser
4442
+ // one: the write tools address nodes by name, and every name is still on the
4443
+ // line. `shapeDetail` stays "full" by default all the same — a caller that
4444
+ // never heard of it has to keep getting exactly what it always got.
4445
+
4446
+ function __compactNumber(n) {
4447
+ if (typeof n !== "number") return String(n);
4448
+ if (isNaN(n) || !isFinite(n)) return String(n);
4449
+ // Four decimals round-trips an 8-bit colour channel and keeps float noise
4450
+ // (0.6627450980392157 for one byte) out of a format whose point is brevity.
4451
+ return String(Math.round(n * 10000) / 10000);
4452
+ }
4453
+
4454
+ function __compactLeafValue(p) {
4455
+ var v;
4456
+ try { v = p.value; } catch (e) { return "?"; }
4457
+ // A path's value is a Shape object, which is a wall of vertex arrays in full
4458
+ // and unreadable in one line. Its size and closedness are what you check.
4459
+ try {
4460
+ if (v && v.vertices && v.vertices.length !== undefined) {
4461
+ return "path(" + v.vertices.length + (v.closed ? " verts, closed)" : " verts, open)");
4462
+ }
4463
+ } catch (e2) {}
4464
+ if (v instanceof Array) {
4465
+ var parts = [];
4466
+ for (var i = 0; i < v.length; i++) parts.push(__compactNumber(v[i]));
4467
+ return "[" + parts.join(",") + "]";
4468
+ }
4469
+ if (typeof v === "number") return __compactNumber(v);
4470
+ return String(v);
4471
+ }
4472
+
4473
+ function __compactLeaves(g) {
4474
+ var parts = [];
4475
+ for (var i = 1; i <= g.numProperties; i++) {
4476
+ var p = g.property(i);
4477
+ if (__isPropertyGroup(p)) continue;
4478
+ var s = p.name + "=" + __compactLeafValue(p);
4479
+ if (p.numKeys > 0) s += " [" + p.numKeys + " keys]";
4480
+ try { if (p.canSetExpression && p.expression) s += " [expr]"; } catch (e) {}
4481
+ parts.push(s);
4482
+ }
4483
+ return parts.join(" ");
4484
+ }
4485
+
4486
+ // Only the "ADBE " prefix comes off: "ADBE Vector Group" and "ADBE Vectors
4487
+ // Group" are different nodes, so anything cleverer would collide.
4488
+ function __compactKind(matchName) {
4489
+ return (matchName.substring(0, 5) === "ADBE ") ? matchName.substring(5) : matchName;
4490
+ }
4491
+
4492
+ function __hasGroupChild(g) {
4493
+ for (var i = 1; i <= g.numProperties; i++) { if (__isPropertyGroup(g.property(i))) return true; }
4494
+ return false;
4495
+ }
4496
+
4497
+ function __compactShapeContents(group, depth, indent, lines, opts) {
4498
+ if (!group || !group.numProperties) return lines;
4499
+ // Leaves sitting directly on the group being walked have no line of their
4500
+ // own to fold onto; at the root, give them one.
4501
+ if (indent === "") {
4502
+ var rootLeaves = __compactLeaves(group);
4503
+ if (rootLeaves) lines.push(rootLeaves);
4504
+ }
4505
+ for (var i = 1; i <= group.numProperties; i++) {
4506
+ var p = group.property(i);
4507
+ if (!__isPropertyGroup(p)) continue;
4508
+ if (p.matchName === __SHAPE_MATERIALS && !opts.materials) { opts.materialsOmitted += 1; continue; }
4509
+ var line = indent + p.name + " " + __compactKind(p.matchName);
4510
+ if (p.matchName === __SHAPE_TRANSFORM && __isIdentityVectorTransform(p)) {
4511
+ lines.push(line + " (at defaults)");
4512
+ continue;
4513
+ }
4514
+ var leaves = __compactLeaves(p);
4515
+ lines.push(leaves ? line + " " + leaves : line);
4516
+ if (depth > 0) __compactShapeContents(p, depth - 1, indent + " ", lines, opts);
4517
+ // The leaves are already on the line above, so only unwalked sub-groups
4518
+ // are missing — and saying so is the same rule as `childrenOmitted`.
4519
+ else if (__hasGroupChild(p)) lines.push(indent + " (sub-groups not walked — raise shapeDepth)");
4520
+ }
4521
+ return lines;
4522
+ }
4523
+
4524
+ // The whole `shape` section, with its own omissions named on it.
4525
+ function __serializeShape(layer, depth, args) {
4526
+ var opts = __shapeOpts(args);
4527
+ var shape = { depth: depth };
4528
+ try {
4529
+ var contents = layer.property("Contents");
4530
+ if (opts.compact) {
4531
+ shape.detail = "compact";
4532
+ shape.contents = __compactShapeContents(contents, depth, "", [], opts);
4533
+ } else {
4534
+ shape.contents = __serializeShapeContents(contents, depth, opts);
4535
+ }
4536
+ } catch (e) {
4537
+ // An unreadable Contents used to leave the section off entirely, which
4538
+ // reads as "this shape layer has no shapes".
4539
+ shape.error = (e && e.message) ? String(e.message) : String(e);
4540
+ }
4541
+ if (opts.materialsOmitted > 0) {
4542
+ shape.materialsOmitted = opts.materialsOmitted;
4543
+ shape.materialsNote = "Material Options omitted on " + opts.materialsOmitted + " shape group" +
4544
+ (opts.materialsOmitted === 1 ? "" : "s") + " — 48 3D-extrusion properties each, meaningful only for an " +
4545
+ "extruded shape under the Cinema 4D renderer. Pass shapeMaterials:true to read them.";
4546
+ }
4547
+ return shape;
4548
+ }
4549
+
2486
4550
  OPS.get_layer_full = noUndo(function (args) {
2487
4551
  var c = getCompById(args.compId);
2488
4552
  var l = getLayerById(c, args.layerId);
@@ -2526,8 +4590,7 @@ OPS.get_layer_full = noUndo(function (args) {
2526
4590
  if (l instanceof TextLayer && __wantsSection(want, "text")) out.text = __serializeText(l);
2527
4591
  if (l instanceof ShapeLayer && __wantsSection(want, "shape")) {
2528
4592
  var depth = (args && args.shapeDepth !== undefined && args.shapeDepth !== null) ? args.shapeDepth : 4;
2529
- try { out.shape = { depth: depth, contents: __serializeShapeContents(l.property("Contents"), depth) }; }
2530
- catch (e) {}
4593
+ out.shape = __serializeShape(l, depth, args);
2531
4594
  }
2532
4595
  if (__wantsSection(want, "source")) {
2533
4596
  if (l.source && l.source instanceof CompItem) {
@@ -2628,6 +4691,115 @@ OPS.find_layers = noUndo(function (args) {
2628
4691
  });
2629
4692
 
2630
4693
 
4694
+ // ===== helpers.jsx =====
4695
+
4696
+ // helpers.jsx — the scope a run_jsx script runs in.
4697
+ //
4698
+ // Everything in this file is a global function, which is what makes it visible
4699
+ // to `eval`d script bodies and to anything loaded through run_jsx's
4700
+ // `libraries`. It exists because the run_jsx description promised "helpers in
4701
+ // scope" and never said which, so every session re-derived the same four or
4702
+ // five functions — find-a-layer-by-id, ease-with-the-right-array-size, a shape
4703
+ // builder that zeros the position (issue #53). Each of these was written from
4704
+ // scratch in the transcript, at token cost, with a fresh chance of getting the
4705
+ // AE quirk wrong.
4706
+ //
4707
+ // The rule for adding one: it has to be something an agent would otherwise
4708
+ // write badly, not merely something it would write often. All four below wrap
4709
+ // a documented AE trap.
4710
+ //
4711
+ // These are listed by signature in the run_jsx tool description. If you change
4712
+ // one, change that too — it is the only place a caller ever sees them.
4713
+
4714
+ // getCompById by a shorter name, because that is the name agents guess.
4715
+ function compById(id) {
4716
+ return getCompById(id);
4717
+ }
4718
+
4719
+ // getLayerById, but the comp may be given as an id. An agent holding
4720
+ // (compId, layerId) — the pair every tool returns — can use it directly.
4721
+ function layerById(comp, layerId) {
4722
+ var c = comp;
4723
+ if (typeof c === "number") c = getCompById(c);
4724
+ return getLayerById(c, layerId);
4725
+ }
4726
+
4727
+ // {influence, speed}, or a bare number read as influence. AE's own default
4728
+ // ease is 33% influence at zero speed, so that is what an omitted field gets.
4729
+ function __hEaseSpec(spec) {
4730
+ if (spec === null || spec === undefined) return { influence: 33, speed: 0 };
4731
+ if (typeof spec === "number") return { influence: spec, speed: 0 };
4732
+ var inf = 33;
4733
+ var spd = 0;
4734
+ if (typeof spec.influence === "number") inf = spec.influence;
4735
+ if (typeof spec.speed === "number") spd = spec.speed;
4736
+ return { influence: inf, speed: spd };
4737
+ }
4738
+
4739
+ // ease(prop, keyIndex, easeIn, easeOut) — sizes its own ease array.
4740
+ //
4741
+ // setTemporalEaseAtKey wants one KeyframeEase per dimension and the count is
4742
+ // NOT derivable from the value: a spatial property takes exactly one whatever
4743
+ // its dimension (the ease runs along the motion path), a 2D layer's Scale takes
4744
+ // three, a shape's Ellipse Size takes two, Opacity and sliders take one. The
4745
+ // wrong count throws "parameter 2" and says nothing else (issue #50).
4746
+ //
4747
+ // The sizing lives in `__applyTemporalEase` in keyframes.jsx, which is what
4748
+ // `set_temporal_ease` and `add_keyframe` use. This helper is the same function
4749
+ // with a friendlier signature — deliberately not a second implementation, so a
4750
+ // script written through run_jsx and the same work done through the tools can
4751
+ // never disagree about what a property wanted.
4752
+ //
4753
+ // easeOut omitted means the same ease on both sides. Returns the number of
4754
+ // entries that worked, so a caller can see what the property actually wanted.
4755
+ function ease(prop, keyIndex, easeIn, easeOut) {
4756
+ var inSpec = __hEaseSpec(easeIn);
4757
+ var outSpec = __hEaseSpec(easeOut === undefined ? easeIn : easeOut);
4758
+ return __applyTemporalEase(prop, keyIndex, inSpec, outSpec);
4759
+ }
4760
+
4761
+ // addKeys(prop, [[time, value], ...]) — or [{time, value}, ...].
4762
+ // Returns the key index of each, in the order given, so the next call can ease
4763
+ // them without searching for them again.
4764
+ function addKeys(prop, pairs) {
4765
+ if (!pairs || !pairs.length) return [];
4766
+ var out = [];
4767
+ for (var i = 0; i < pairs.length; i++) {
4768
+ var p = pairs[i];
4769
+ var t;
4770
+ var v;
4771
+ if (p instanceof Array) {
4772
+ t = p[0];
4773
+ v = p[1];
4774
+ } else {
4775
+ t = p.time;
4776
+ v = p.value;
4777
+ }
4778
+ prop.setValueAtTime(t, v);
4779
+ out.push(prop.nearestKeyIndex(t));
4780
+ }
4781
+ return out;
4782
+ }
4783
+
4784
+ // shape(comp, {name, position}) — a shape layer that lands at [0,0].
4785
+ //
4786
+ // AE spawns a scripted shape layer at the comp centre with its anchor at
4787
+ // (0,0), so paths authored in comp pixels come out offset by half a frame —
4788
+ // easy to miss on a downsampled screenshot (issue #51). Position [0,0] makes
4789
+ // the layer's coordinate space the comp's, which is what path vertices assume.
4790
+ function shape(comp, opts) {
4791
+ var c = comp;
4792
+ if (typeof c === "number") c = getCompById(c);
4793
+ var o = opts || {};
4794
+ var l = c.layers.addShape();
4795
+ if (o.name) l.name = o.name;
4796
+ var pos = o.position ? o.position : [0, 0];
4797
+ var value = (pos.length === 3) ? pos : [pos[0], pos[1]];
4798
+ l.property("Transform").property("Position").setValue(value);
4799
+ return l;
4800
+ }
4801
+
4802
+
2631
4803
  // ===== raw.jsx =====
2632
4804
 
2633
4805
  // raw.jsx — escape hatch. Eval arbitrary ExtendScript and return the value.
@@ -2729,11 +4901,349 @@ function __rjSerialize(v, depth, stack, budget) {
2729
4901
  return out;
2730
4902
  }
2731
4903
 
2732
- function __rjResult(result) {
2733
- // A script with no `return` returns undefined at the top level; that is
2734
- // genuinely "nothing", not an unserializable value.
2735
- if (typeof result === "undefined") return null;
2736
- return __rjSerialize(result, 0, [], { n: 0 });
4904
+ // A script whose last statement is a bare expression completes and yields
4905
+ // undefined with every side effect already applied. Handing back a bare
4906
+ // `null` for that made "ran fine, returned nothing" identical on the wire to
4907
+ // "did not run", and the natural response to a suspected failure is to run the
4908
+ // script again. Nothing here rolls back, so a second run of a non-idempotent
4909
+ // script duplicates layers, re-applies moveTo, writes keyframes on top of
4910
+ // keyframes (issue #43) — and the guidance to prefer few large scripts means
4911
+ // the ones most likely to be re-run are the most destructive to re-run.
4912
+ //
4913
+ // So a null result is never returned bare: it comes back as an envelope that
4914
+ // says the script finished. An explicit `return null` is folded into the same
4915
+ // envelope, because the ambiguity is in the value, not in how it was produced.
4916
+ function __rjResult(result, undoGroupName) {
4917
+ var serialized = (typeof result === "undefined") ? null : __rjSerialize(result, 0, [], { n: 0 });
4918
+ if (serialized !== null) return serialized;
4919
+ return {
4920
+ ok: true,
4921
+ returned: null,
4922
+ undoGroup: undoGroupName,
4923
+ note: "Completed with no `return` value — this is not a failure. Use `return X` to send a value back. " +
4924
+ "Anything the script changed is already applied and nothing rolls back, so read the state back rather than re-running it."
4925
+ };
4926
+ }
4927
+
4928
+ // ---------- Mapping a failure back onto the caller's own script ----------
4929
+ // A failed run_jsx reported After Effects' line number with nothing to measure
4930
+ // it against, and that number does not count from where the caller thinks it
4931
+ // does: the same "line 22" pointed at two different statements in consecutive
4932
+ // calls (issue #46). Nothing rolls back, so an agent that cannot locate the
4933
+ // throw has to read the whole project back to find out where the script stopped.
4934
+ //
4935
+ // Two halves, and the second is the one that actually helps. The caller's line
4936
+ // 1 sits a *counted* distance down the evaluated source: with no libraries the
4937
+ // prefix carries no newline and the distance is zero, and with libraries it is
4938
+ // however many lines of library text were inlined ahead of the script.
4939
+ // __rjBuildSource measures the preamble it actually built rather than asserting
4940
+ // a constant, so the number can never drift from the string it describes —
4941
+ // a hand-written constant beside a string is exactly how the two came apart the
4942
+ // first time. Then the mapped line is reported with its TEXT, which an agent
4943
+ // can act on without trusting any numbering at all.
4944
+ //
4945
+ // The mapping refuses to guess: a line that falls outside the submitted source
4946
+ // is reported as unmappable, never clamped — unless it lands in a library that
4947
+ // was inlined ahead of it, which is a real file with real lines and is named.
4948
+ // A confident wrong line number is worse than none — it sends the reader to a
4949
+ // statement that did not fail.
4950
+ var __RJ_WRAP_PREFIX = "(function(){ ";
4951
+ // The closer sits on its own line. Appended to the caller's last line, a script
4952
+ // ending in a `//` comment commented out its own `})()` and failed to parse for
4953
+ // a reason nothing in the error mentioned.
4954
+ var __RJ_WRAP_SUFFIX = "\n})()";
4955
+
4956
+ // No regex literal: tests/unit/jsx-ternary.mjs strips string and comment
4957
+ // literals from these sources and does not understand regex literals, so one
4958
+ // here could desynchronise its scan of this file. (audio.jsx and footage.jsx do
4959
+ // carry a few; they survive only because their contents happen to pair up.)
4960
+ function __rjLines(s) {
4961
+ return String(s).split("\r\n").join("\n").split("\r").join("\n").split("\n");
4962
+ }
4963
+
4964
+ // The preamble of a call with no libraries: the bare wrapper opener, which
4965
+ // carries no newline, so zero. __rjBuildSource recomputes this per call once
4966
+ // libraries are in it; this is the default __rjSourceInfo falls back on when a
4967
+ // caller hands it no layout.
4968
+ var __RJ_PREAMBLE_LINES = __rjLines(__RJ_WRAP_PREFIX).length - 1;
4969
+
4970
+ function __rjTrim(s) {
4971
+ var t = String(s);
4972
+ var a = 0;
4973
+ var b = t.length;
4974
+ while (a < b && (t.charAt(a) === " " || t.charAt(a) === "\t")) a++;
4975
+ while (b > a && (t.charAt(b - 1) === " " || t.charAt(b - 1) === "\t")) b--;
4976
+ return t.substring(a, b);
4977
+ }
4978
+
4979
+ function __rjClip(s, max) {
4980
+ var t = String(s);
4981
+ if (t.length <= max) return t;
4982
+ return t.substring(0, max) + " ...";
4983
+ }
4984
+
4985
+ // What After Effects reported, mapped onto the source the caller submitted.
4986
+ // Nothing here is fabricated: when the number does not land inside the script,
4987
+ // sourceLine stays null and the server says the number could not be mapped.
4988
+ //
4989
+ // `layout` is what __rjBuildSource returned for this call — how many lines of
4990
+ // preamble sit ahead of the caller's line 1, and where each inlined library
4991
+ // landed in the evaluated source. Omitted, it means the bare wrapper: no
4992
+ // libraries and a zero-line preamble, which is every call without `libraries`.
4993
+ function __rjSourceInfo(e, code, scriptPath, layout) {
4994
+ var lay = layout ? layout : { preambleLines: __RJ_PREAMBLE_LINES, segments: [] };
4995
+ var preamble = lay.preambleLines;
4996
+ var segs = lay.segments ? lay.segments : [];
4997
+ var lines = __rjLines(code);
4998
+ var info = {
4999
+ lineCount: lines.length,
5000
+ rawLine: null,
5001
+ sourceLine: null,
5002
+ sourceText: null
5003
+ };
5004
+ if (scriptPath) info.sourceName = String(scriptPath);
5005
+
5006
+ var raw = null;
5007
+ try {
5008
+ if (e && typeof e.line === "number" && isFinite(e.line)) raw = e.line;
5009
+ } catch (e1) {}
5010
+ info.rawLine = raw;
5011
+
5012
+ // Which line of the *evaluated source* — the wrapper, the inlined libraries
5013
+ // and the caller's script together — the failure sits on. After Effects' own
5014
+ // `line` already counts from there, which is what makes it usable at all.
5015
+ var wrapperLine = null;
5016
+
5017
+ // ExtendScript's Error also carries `source` (the text the error was raised
5018
+ // in) with `start`/`end` character offsets into it, and the documentation
5019
+ // presents those as the better answer, because they need no assumption about
5020
+ // what `line` counts from. On After Effects 2026 they are not offsets at all.
5021
+ //
5022
+ // Probed inside AE, catching from a four-line script that throws on line 4:
5023
+ //
5024
+ // eval("(function(){ var a=1;\nvar b=2;\nvar c=3;\nnope.boom();\n})()")
5025
+ // caught -> { "line": 4, "start": 0, "end": 0, "srcLen": 57 }
5026
+ //
5027
+ // `line` is already correct. `start` and `end` came back 0 on every error
5028
+ // measured, however far into the source it was raised. Read as an offset, a
5029
+ // zero start puts *every* failure on line 1 and prints line 1's text — which
5030
+ // is exactly what issue #46 still did after it was reported fixed, with the
5031
+ // true number demoted to the parenthetical afterwards.
5032
+ //
5033
+ // So the branch survives only for offsets that could actually be real: 0/0 is
5034
+ // After Effects declining to say, not After Effects pointing at the first
5035
+ // character. Do not restore this from the documentation.
5036
+ try {
5037
+ var src = null;
5038
+ if (e && typeof e.source === "string") src = e.source;
5039
+ var start = null;
5040
+ if (e && typeof e.start === "number" && isFinite(e.start)) start = e.start;
5041
+ var end = null;
5042
+ if (e && typeof e.end === "number" && isFinite(e.end)) end = e.end;
5043
+ var offsetsUsable = false;
5044
+ if (start !== null && start > 0) offsetsUsable = true;
5045
+ if (start === 0 && end !== null && end > 0) offsetsUsable = true;
5046
+ if (offsetsUsable && src !== null &&
5047
+ src.substring(0, __RJ_WRAP_PREFIX.length) === __RJ_WRAP_PREFIX) {
5048
+ wrapperLine = __rjLines(src.substring(0, start)).length;
5049
+ }
5050
+ } catch (e2) {}
5051
+ if (wrapperLine === null) wrapperLine = raw;
5052
+ if (wrapperLine === null) return info;
5053
+
5054
+ var mapped = wrapperLine - preamble;
5055
+ if (mapped >= 1 && mapped <= lines.length) {
5056
+ info.sourceLine = mapped;
5057
+ info.sourceText = __rjClip(__rjTrim(lines[mapped - 1]), 200);
5058
+ return info;
5059
+ }
5060
+
5061
+ // Not the caller's script. A library inlined ahead of it is a real file with
5062
+ // real lines, so name it and point into it rather than reporting the number
5063
+ // as unmappable: that file is where the reader has to look, and naming the
5064
+ // script instead would send them to a line that did not fail.
5065
+ for (var s = 0; s < segs.length; s++) {
5066
+ var seg = segs[s];
5067
+ var count = seg.lines.length - 1;
5068
+ var within = wrapperLine - seg.firstLine + 1;
5069
+ if (within >= 1 && within <= count) {
5070
+ info.sourceName = seg.path;
5071
+ info.lineCount = count;
5072
+ info.sourceLine = within;
5073
+ info.sourceText = __rjClip(__rjTrim(seg.lines[within - 1]), 200);
5074
+ return info;
5075
+ }
5076
+ }
5077
+ return info;
5078
+ }
5079
+
5080
+ // Rethrow with the mapping attached. dispatch()'s catch copies whatever sits on
5081
+ // `aeDetail` onto the error result; the panel forwards the fields it names and
5082
+ // the server prints the line's text. A plain object rather than an Error
5083
+ // because ExtendScript will not reliably let us write `line` on one, and
5084
+ // __mkError only ever reads message/stack/line.
5085
+ function __rjThrowWithSource(e, code, scriptPath, layout) {
5086
+ var info = __rjSourceInfo(e, code, scriptPath, layout);
5087
+ var msg = "";
5088
+ try { if (e && e.message) msg = String(e.message); } catch (e1) {}
5089
+ if (!msg) {
5090
+ try { msg = String(e); } catch (e2) { msg = "ExtendScript error"; }
5091
+ }
5092
+ var stack = "";
5093
+ try { if (e && e.stack) stack = String(e.stack); } catch (e3) {}
5094
+ throw { message: msg, stack: stack, line: info.rawLine, aeDetail: info };
5095
+ }
5096
+
5097
+ // ---------- Helper libraries ----------
5098
+ // A library's source is inlined into the SAME eval as the caller's script,
5099
+ // ahead of it. That is not the obvious design, and it is the only one that
5100
+ // works.
5101
+ //
5102
+ // The first version used $.evalFile, on the documented premise that it
5103
+ // evaluates at global scope — load once, call for the rest of the After Effects
5104
+ // session (issue #53). Probed inside AE 2026, calling $.evalFile from the body
5105
+ // of an eval'd script, on a library declaring `function rig2()` and
5106
+ // `var RIGVAR = 3`:
5107
+ //
5108
+ // {"exists":true, "typeofRig2_local":"function", "typeofRIGVAR_local":"number",
5109
+ // "globalRig2":"undefined", "viaGlobal":null}
5110
+ // // and on the next run_jsx call, in the same AE session:
5111
+ // {"typeofRig2":"undefined", "viaGlobal":"undefined"}
5112
+ //
5113
+ // $.evalFile evaluates into the *calling function's* scope, exactly as eval
5114
+ // does. Everything a library defined therefore lived inside the loader and was
5115
+ // gone before the wrapper ran, so `libraries` answered "Function rig is
5116
+ // undefined" every time, for every library, in every session. The per-session
5117
+ // cache keyed on a content hash has gone with it: nothing was ever left loaded
5118
+ // to reuse, so the only work it ever skipped was work whose result had already
5119
+ // been discarded.
5120
+ //
5121
+ // One eval means one scope: a library's `function helper(){}` is a declaration
5122
+ // in the same function body as the script, so the script can call it. Two
5123
+ // consequences, both handled here rather than left to surprise someone:
5124
+ //
5125
+ // * A library is re-evaluated on every call. That is the price of the scoping
5126
+ // After Effects actually has. Keep libraries to declarations, not to work.
5127
+ // * The library text shifts the caller's line 1 down, which is precisely the
5128
+ // failure issue #46 was about. __rjBuildSource *counts* the preamble it
5129
+ // built rather than asserting a constant, and __rjSourceInfo subtracts that
5130
+ // count — so a caller's line 1 is line 1 of their own script whether they
5131
+ // passed libraries or not.
5132
+
5133
+ // No regex literal, for the same reason __rjLines has none.
5134
+ function __rjIsBlank(s) {
5135
+ var t = String(s);
5136
+ for (var i = 0; i < t.length; i++) {
5137
+ var c = t.charAt(i);
5138
+ if (c !== " " && c !== "\t" && c !== "\n" && c !== "\r") return false;
5139
+ }
5140
+ return true;
5141
+ }
5142
+
5143
+ // Every library ends in a newline before the next one — or the caller's script
5144
+ // — follows it. Appended directly, a library whose last line is a `//` comment
5145
+ // would comment out whatever came after it: the same trap __RJ_WRAP_SUFFIX
5146
+ // exists for at the other end of the wrapper.
5147
+ function __rjEndWithNewline(s) {
5148
+ var t = String(s);
5149
+ if (t.length === 0) return "\n";
5150
+ var last = t.charAt(t.length - 1);
5151
+ if (last === "\n" || last === "\r") return t;
5152
+ return t + "\n";
5153
+ }
5154
+
5155
+ // The server reads library files and sends {path, text}, exactly as it does for
5156
+ // scriptPath — one place reads files, and its errors can name the path it was
5157
+ // given. Arriving with a path and no text means the call did not come through
5158
+ // the run_jsx tool: a run_batch step, whose args are never validated, or a
5159
+ // hand-rolled POST /op.
5160
+ function __rjLibrarySource(lib) {
5161
+ var p = null;
5162
+ var text = null;
5163
+ if (typeof lib === "string") {
5164
+ p = lib;
5165
+ } else if (lib) {
5166
+ p = lib.path;
5167
+ if (typeof lib.text === "string") text = lib.text;
5168
+ }
5169
+ if (!p) throw new Error("run_jsx: a libraries entry has no path.");
5170
+ if (text === null) {
5171
+ throw new Error(
5172
+ "run_jsx library \"" + p + "\" arrived with no source text. The server reads library files " +
5173
+ "and substitutes their contents, so this call did not go through the run_jsx tool — " +
5174
+ "run_batch steps and direct /op posts must pass {path, text} themselves."
5175
+ );
5176
+ }
5177
+ if (__rjIsBlank(text)) throw new Error("run_jsx library is empty: " + p);
5178
+ return { path: String(p), text: __rjEndWithNewline(text) };
5179
+ }
5180
+
5181
+ // Parse each library on its own before any of it reaches the shared eval.
5182
+ // Inlining means a library that does not parse takes the whole wrapper with it,
5183
+ // and the line After Effects reports for a syntax error is wherever its parser
5184
+ // gave up — frequently inside the caller's script, which would blame the wrong
5185
+ // file for someone else's missing brace. An uncalled function expression forces
5186
+ // a full parse of exactly this library and nothing else, so the failure names
5187
+ // it and counts from its own line 1.
5188
+ function __rjCheckLibraryParses(lib) {
5189
+ try {
5190
+ eval("(function(){ " + lib.text + "})");
5191
+ } catch (e) {
5192
+ var m = "";
5193
+ try { m = (e && e.message) ? String(e.message) : String(e); } catch (e1) { m = "unknown error"; }
5194
+ var ln = null;
5195
+ try { if (e && typeof e.line === "number" && isFinite(e.line)) ln = e.line; } catch (e2) {}
5196
+ var libLines = __rjLines(lib.text);
5197
+ var count = libLines.length - 1;
5198
+ var detail = {
5199
+ lineCount: count,
5200
+ rawLine: ln,
5201
+ sourceLine: null,
5202
+ sourceText: null,
5203
+ sourceName: lib.path
5204
+ };
5205
+ if (ln !== null && ln >= 1 && ln <= count) {
5206
+ detail.sourceLine = ln;
5207
+ detail.sourceText = __rjClip(__rjTrim(libLines[ln - 1]), 200);
5208
+ }
5209
+ throw {
5210
+ message: "run_jsx library failed to parse: " + lib.path + " — " + m,
5211
+ stack: "",
5212
+ line: ln,
5213
+ aeDetail: detail
5214
+ };
5215
+ }
5216
+ }
5217
+
5218
+ // The evaluated source for one call, plus the map that turns any line of it
5219
+ // back into a line of a file the caller knows about.
5220
+ //
5221
+ // `preambleLines` is COUNTED from the text that precedes the script rather than
5222
+ // assumed. That is the invariant issue #46 turned on, and it now has to hold
5223
+ // for a preamble whose length changes from call to call: it can never drift
5224
+ // from the string it describes, however many libraries there are.
5225
+ function __rjBuildSource(code, libraries) {
5226
+ var prefix = __RJ_WRAP_PREFIX;
5227
+ var segments = [];
5228
+ if (libraries && libraries.length) {
5229
+ for (var i = 0; i < libraries.length; i++) {
5230
+ var lib = __rjLibrarySource(libraries[i]);
5231
+ __rjCheckLibraryParses(lib);
5232
+ segments.push({
5233
+ path: lib.path,
5234
+ // The wrapper opener carries no newline, so the first library's line 1
5235
+ // shares line 1 of the evaluated source with it.
5236
+ firstLine: __rjLines(prefix).length,
5237
+ lines: __rjLines(lib.text)
5238
+ });
5239
+ prefix = prefix + lib.text;
5240
+ }
5241
+ }
5242
+ return {
5243
+ wrapper: prefix + code + __RJ_WRAP_SUFFIX,
5244
+ preambleLines: __rjLines(prefix).length - 1,
5245
+ segments: segments
5246
+ };
2737
5247
  }
2738
5248
 
2739
5249
  // undoGroup:false is a per-call opt-out, read by dispatch() through the
@@ -2742,7 +5252,42 @@ function __rjResult(result) {
2742
5252
  // which is exactly the layer worth copying (issue #30).
2743
5253
  OPS.run_jsx = noUndoWhen(function (args) { return args.undoGroup === false; }, function (args) {
2744
5254
  var code = args.code || "";
2745
- // We wrap in a function so `return` works.
2746
- var wrapper = "(function(){ " + code + " })()";
2747
- return __rjResult(eval(wrapper));
5255
+ // scriptPath is resolved to code by the *server* before the payload is built.
5256
+ // Arriving here with a path and no code means it came in some other way — a
5257
+ // run_batch step, whose args are never validated, or a hand-rolled POST /op.
5258
+ // Running an empty script would return the "completed with no return value"
5259
+ // envelope, which is a success result for a file nobody read.
5260
+ if (!code && args.scriptPath) {
5261
+ throw new Error(
5262
+ "run_jsx received scriptPath \"" + args.scriptPath + "\" with no code. The server reads that " +
5263
+ "file and substitutes it, so this path did not go through the run_jsx tool — run_batch steps " +
5264
+ "and direct /op posts must pass `code` themselves."
5265
+ );
5266
+ }
5267
+ if (!code) throw new Error("run_jsx needs `code` — an empty script would report success for nothing.");
5268
+ // We wrap in a function so `return` works, with any libraries inlined ahead
5269
+ // of the script so they share its scope. Assembled and parse-checked before
5270
+ // anything runs, and outside the try below: a library that fails to parse is
5271
+ // not a line in the caller's script and must not be reported as one.
5272
+ var built = __rjBuildSource(code, args.libraries);
5273
+ // Which undo step to look for in AE if the script has to be backed out. The
5274
+ // name mirrors dispatch()'s default ("AE MCP: " + op); false means the caller
5275
+ // asked for no group and the changes landed as whatever steps AE recorded.
5276
+ var undoGroupName = (args.undoGroup === false) ? false : "AE MCP: run_jsx";
5277
+ // diff:true fingerprints the comp before and after, inside this one call —
5278
+ // see snapshot.jsx. Null unless asked for, so the ordinary path is untouched.
5279
+ var __d = __diffStart(args, null);
5280
+ var __value;
5281
+ try {
5282
+ __value = eval(built.wrapper);
5283
+ } catch (e) {
5284
+ // Annotate before mapping the line: __rjThrowWithSource reads e.message,
5285
+ // so the diff note has to be on it by then, and the source mapping is what
5286
+ // makes the reported line the caller's own (#46).
5287
+ __diffAnnotateError(e, __d);
5288
+ __rjThrowWithSource(e, code, args.scriptPath, built);
5289
+ }
5290
+ var __out = __rjResult(__value, undoGroupName);
5291
+ if (__d) return __rjWithDiff(__out, __diffFinish(__d), undoGroupName);
5292
+ return __out;
2748
5293
  });