@engine-room/after-effects-mcp 0.2.0 → 0.3.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.
@@ -1,5 +1,4 @@
1
1
  // Auto-generated bundle. Do not edit directly — edit files in packages/jsx/.
2
- // Generated 2026-08-12T07:06:29.484Z
3
2
 
4
3
  // ===== core.jsx =====
5
4
 
@@ -70,10 +69,29 @@ function __newJobId() {
70
69
  }
71
70
 
72
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.
75
+ var __UNDO_OPEN = false;
76
+
73
77
  function withUndo(name, fn) {
74
78
  app.beginUndoGroup(name || "AE MCP");
79
+ __UNDO_OPEN = true;
75
80
  try { return fn(); }
76
- finally { app.endUndoGroup(); }
81
+ finally { __UNDO_OPEN = false; app.endUndoGroup(); }
82
+ }
83
+
84
+ // Run fn with the dispatcher's undo group closed, then reopen it.
85
+ // After Effects refuses a handful of operations while an undo group is open —
86
+ // copyToComp on a layer with a parent or a linked expression is the one that
87
+ // bites (issue #30). No-op when no group of ours is open, so it is always safe
88
+ // to call. The work inside becomes its own undo step, separate from the rest.
89
+ function withoutUndoGroup(fn) {
90
+ if (!__UNDO_OPEN) return fn();
91
+ app.endUndoGroup();
92
+ __UNDO_OPEN = false;
93
+ try { return fn(); }
94
+ finally { app.beginUndoGroup("AE MCP: continue"); __UNDO_OPEN = true; }
77
95
  }
78
96
 
79
97
  // ---------- Error helper ----------
@@ -97,7 +115,13 @@ function dispatch(payloadJson) {
97
115
  try {
98
116
  var handler = OPS[op];
99
117
  var meta = handler.__meta || {};
100
- if (meta.noUndo) {
118
+ // __meta.noUndo is a boolean for handlers that never want a group, or a
119
+ // predicate over args for ops where the caller decides per call (run_jsx).
120
+ // Keeping the predicate on the handler is what stops one op's opt-out from
121
+ // leaking into the next: dispatch never remembers anything between calls.
122
+ var skipUndo = meta.noUndo;
123
+ if (typeof skipUndo === "function") skipUndo = skipUndo(args);
124
+ if (skipUndo) {
101
125
  return { ok: true, result: handler(args) };
102
126
  }
103
127
  var result = withUndo(meta.undoName || ("AE MCP: " + op), function () { return handler(args); });
@@ -109,6 +133,8 @@ function dispatch(payloadJson) {
109
133
 
110
134
  // Helper for handlers that don't want an undo group (read-only ops, job continuations).
111
135
  function noUndo(fn) { fn.__meta = { noUndo: true }; return fn; }
136
+ // Same, but the handler decides per call from its own args.
137
+ function noUndoWhen(pred, fn) { fn.__meta = { noUndo: pred }; return fn; }
112
138
  function undoNamed(name, fn) { fn.__meta = { undoName: name }; return fn; }
113
139
 
114
140
 
@@ -165,27 +191,42 @@ function colorOrDefault(c, fallback) {
165
191
 
166
192
  // comps.jsx — composition ops.
167
193
 
168
- function __compSummary(c) {
169
- return {
170
- id: c.id,
171
- name: c.name,
172
- width: c.width,
173
- height: c.height,
174
- duration: c.duration,
175
- frameRate: c.frameRate,
176
- pixelAspect: c.pixelAspect,
177
- bgColor: [c.bgColor[0], c.bgColor[1], c.bgColor[2]],
178
- numLayers: c.numLayers,
179
- workAreaStart: c.workAreaStart,
180
- workAreaDuration: c.workAreaDuration,
181
- };
194
+ // The section filter every read op shares. `sections` is the caller's `include`
195
+ // array: null/undefined means "all of them", which is what every caller written
196
+ // before `include` existed passes. An empty array means the identifying core
197
+ // only. Defined here because comps.jsx is the first module in the bundle that
198
+ // needs it; layers.jsx and explore.jsx use the same one.
199
+ function __wantsSection(sections, name) {
200
+ if (!sections) return true;
201
+ for (var i = 0; i < sections.length; i++) if (sections[i] === name) return true;
202
+ return false;
203
+ }
204
+
205
+ function __compSummary(c, sections) {
206
+ // id and name are the map an agent orients with, so they are never optional.
207
+ var out = { id: c.id, name: c.name };
208
+ if (__wantsSection(sections, "size")) {
209
+ out.width = c.width;
210
+ out.height = c.height;
211
+ out.pixelAspect = c.pixelAspect;
212
+ }
213
+ if (__wantsSection(sections, "timing")) {
214
+ out.duration = c.duration;
215
+ out.frameRate = c.frameRate;
216
+ out.workAreaStart = c.workAreaStart;
217
+ out.workAreaDuration = c.workAreaDuration;
218
+ }
219
+ if (__wantsSection(sections, "bg")) out.bgColor = [c.bgColor[0], c.bgColor[1], c.bgColor[2]];
220
+ if (__wantsSection(sections, "counts")) out.numLayers = c.numLayers;
221
+ return out;
182
222
  }
183
223
 
184
224
  OPS.list_comps = noUndo(function (args) {
225
+ var sections = (args && args.include) ? args.include : null;
185
226
  var out = [];
186
227
  for (var i = 1; i <= app.project.numItems; i++) {
187
228
  var it = app.project.item(i);
188
- if (it instanceof CompItem) out.push(__compSummary(it));
229
+ if (it instanceof CompItem) out.push(__compSummary(it, sections));
189
230
  }
190
231
  return out;
191
232
  });
@@ -272,32 +313,40 @@ function __layerKind(l) {
272
313
  return "unknown";
273
314
  }
274
315
 
275
- function __layerSummary(l) {
276
- var parent = l.parent ? l.parent.id : null;
277
- return {
316
+ // `sections` is the caller's `include` array (see __wantsSection in comps.jsx).
317
+ // Null means every section, so every existing caller is unaffected; the core —
318
+ // the id/index/name/type map an agent orients with — is always present.
319
+ function __layerSummary(l, sections) {
320
+ var out = {
278
321
  id: l.id,
279
322
  index: l.index,
280
323
  name: l.name,
281
- enabled: l.enabled,
282
- solo: l.solo,
283
- locked: l.locked,
284
- shy: l.shy,
285
- threeDLayer: l.threeDLayer,
286
- label: l.label,
287
- inPoint: l.inPoint,
288
- outPoint: l.outPoint,
289
- startTime: l.startTime,
290
- stretch: l.stretch,
291
324
  sourceType: __layerKind(l),
292
- parent: parent,
293
- blendingMode: l.blendingMode,
294
325
  };
326
+ if (__wantsSection(sections, "flags")) {
327
+ out.enabled = l.enabled;
328
+ out.solo = l.solo;
329
+ out.locked = l.locked;
330
+ out.shy = l.shy;
331
+ out.threeDLayer = l.threeDLayer;
332
+ out.label = l.label;
333
+ out.blendingMode = l.blendingMode;
334
+ }
335
+ if (__wantsSection(sections, "timing")) {
336
+ out.inPoint = l.inPoint;
337
+ out.outPoint = l.outPoint;
338
+ out.startTime = l.startTime;
339
+ out.stretch = l.stretch;
340
+ }
341
+ if (__wantsSection(sections, "parent")) out.parent = l.parent ? l.parent.id : null;
342
+ return out;
295
343
  }
296
344
 
297
345
  OPS.list_layers = noUndo(function (args) {
298
346
  var c = getCompById(args.compId);
347
+ var sections = (args && args.include) ? args.include : null;
299
348
  var out = [];
300
- for (var i = 1; i <= c.numLayers; i++) out.push(__layerSummary(c.layer(i)));
349
+ for (var i = 1; i <= c.numLayers; i++) out.push(__layerSummary(c.layer(i), sections));
301
350
  return out;
302
351
  });
303
352
 
@@ -305,27 +354,35 @@ OPS.create_text_layer = function (args) {
305
354
  var c = getCompById(args.compId);
306
355
  var l = c.layers.addText(args.text || "");
307
356
  if (args.name) l.name = args.name;
308
- // Apply font/size/color through TextDocument first sourceRectAtTime depends on these.
309
- if (args.font || args.size || args.color) {
357
+ // `anchorAlign` is paragraph justification, not geometry. Offsetting the
358
+ // anchor to the measured bbox edge (what this did before) looks right at
359
+ // creation and is wrong the moment the Source Text changes — retyped, driven
360
+ // by an expression, or edited through Essential Graphics in Premiere — because
361
+ // the anchor stays baked for the old string and the layout jumps. Justifying
362
+ // instead keeps the alignment live and leaves the anchor at the origin, which
363
+ // is also what sourceRectAtTime()-driven backgrounds expect.
364
+ //
365
+ // Tracking is normalised for a different reason: addText() inherits the
366
+ // workspace's Character panel, so an untouched layer arrives with whatever
367
+ // that was last left on (-20 is common) and the same call renders differently
368
+ // on two machines. `tracking` sets it explicitly; omitting it means 0.
369
+ //
370
+ // 'none' opts out of all of it and leaves AE's raw defaults alone.
371
+ var align = args.anchorAlign === undefined ? "left" : args.anchorAlign;
372
+ var wantJustify = align !== "none" && __JUSTIFICATION[align] !== undefined;
373
+ var wantTracking = args.tracking !== undefined || align !== "none";
374
+ if (args.font || args.size || args.color || wantJustify || wantTracking) {
310
375
  var srcText = l.property("Source Text");
311
376
  var td = srcText.value;
312
377
  if (args.font) td.font = args.font;
313
378
  if (args.size) td.fontSize = args.size;
314
379
  if (args.color) { td.applyFill = true; td.fillColor = [args.color[0], args.color[1], args.color[2]]; }
380
+ if (wantTracking) td.tracking = (args.tracking !== undefined ? args.tracking : 0);
381
+ if (wantJustify) td.justification = __JUSTIFICATION[align];
315
382
  srcText.setValue(td);
316
383
  }
317
- // AE's addText() puts the anchor at the bbox center, which surprises agents
318
- // who expect position to mean the left edge. Default to 'left' so position
319
- // semantically matches the visible start of the text. 'none' opts out.
320
- var align = args.anchorAlign === undefined ? "left" : args.anchorAlign;
321
384
  if (align !== "none") {
322
- try {
323
- var __rect = l.sourceRectAtTime(c.time, false);
324
- var __ax = __rect.left;
325
- if (align === "center") __ax = __rect.left + __rect.width / 2;
326
- else if (align === "right") __ax = __rect.left + __rect.width;
327
- l.property("Transform").property("Anchor Point").setValue([__ax, 0, 0]);
328
- } catch (__e) {}
385
+ l.property("Transform").property("Anchor Point").setValue([0, 0, 0]);
329
386
  }
330
387
  if (args.position) {
331
388
  var p = args.position;
@@ -443,15 +500,328 @@ OPS.set_layer = function (args) {
443
500
  return __layerSummary(l);
444
501
  };
445
502
 
503
+ // ---------------------------------------------------------------------------
504
+ // Parenting that does not move the layer
505
+ // ---------------------------------------------------------------------------
506
+ // AE compensates a child's transform when you assign layer.parent, but within
507
+ // one script evaluation it reads the parent from stale data if that parent was
508
+ // itself re-parented earlier in the same evaluation: the compensation runs
509
+ // against the pre-move parent and the child lands at W - parentWorld twice
510
+ // over, and a parent whose scale is not 100 leaves children at 333% (issue
511
+ // #28). run_batch and run_jsx are one evaluation, so this is their normal case.
512
+ //
513
+ // So we do not trust it. We compute the child's world transform ourselves from
514
+ // property values before the assignment, compute it again after, and correct
515
+ // the local transform when the two disagree. When AE gets it right the two
516
+ // agree and nothing is written at all — that no-op is what makes
517
+ // preserveTransform safe to default to true.
518
+ //
519
+ // The matrices are ours because AE's scripting DOM has no toComp/toWorld —
520
+ // those exist only in the expression language. Limits, all reported in the
521
+ // result rather than worked around:
522
+ // * 2D only. AE's 3D chain (orientation plus three rotations, composed in
523
+ // AE's own order) is not reimplementable here with any confidence, so a 3D
524
+ // layer, camera or light anywhere in either chain skips the correction.
525
+ // * One time. Everything is measured at the comp's current time. A keyframed
526
+ // or expression-driven ancestor makes the world transform time-varying, and
527
+ // no static correction is right at every frame.
528
+ // * Position only when AE is provably wrong. See __PARENT_POS_MODEL below.
529
+
530
+ // Well below anything visible, well above double-precision noise through a
531
+ // chain of matrix products. Nothing is written inside these — the point is that
532
+ // a correct compensation is left exactly as AE wrote it.
533
+ var __PT_TOL_POS = 1e-3; // comp pixels
534
+ var __PT_TOL_SCALE = 1e-3; // percentage points
535
+ var __PT_TOL_ROT = 1e-3; // degrees
536
+
537
+ // A child's Position is measured in its parent's space, and there are two
538
+ // readings of where that space starts: at the parent's layer-space origin (so
539
+ // Position is a point pushed through the parent's own matrix) or at the
540
+ // parent's anchor point. They differ by exactly the parent's anchor, mapped up
541
+ // the chain, and coincide whenever every anchor above is [0,0] — which covers
542
+ // nulls, shapes and point text, i.e. most rigs. Rather than bet on one, we
543
+ // compute both, and only rewrite Position when AE's own answer matches
544
+ // *neither* — that is the only case where AE is provably wrong, and it is the
545
+ // bug. When AE matches one of them we learn which for the rest of the session.
546
+ var __PARENT_POS_MODEL = null;
547
+
548
+ // Duck-typed rather than `instanceof Array` so it holds for anything indexable
549
+ // AE hands back, and a bare number promotes to a vector.
550
+ function __ptV2(v) {
551
+ if (v === null || v === undefined) return [0, 0];
552
+ if (typeof v === "number") return [v, 0];
553
+ return [v[0] || 0, v[1] || 0];
554
+ }
555
+
556
+ // 2D affine as [a, b, c, d, tx, ty]: x' = a*x + c*y + tx, y' = b*x + d*y + ty
557
+ function __ptMId() { return [1, 0, 0, 1, 0, 0]; }
558
+ function __ptMMul(m, n) { // apply n first, then m
559
+ return [
560
+ m[0] * n[0] + m[2] * n[1],
561
+ m[1] * n[0] + m[3] * n[1],
562
+ m[0] * n[2] + m[2] * n[3],
563
+ m[1] * n[2] + m[3] * n[3],
564
+ m[0] * n[4] + m[2] * n[5] + m[4],
565
+ m[1] * n[4] + m[3] * n[5] + m[5]
566
+ ];
567
+ }
568
+ function __ptMPoint(m, p) { return [m[0] * p[0] + m[2] * p[1] + m[4], m[1] * p[0] + m[3] * p[1] + m[5]]; }
569
+ function __ptMVec(m, p) { return [m[0] * p[0] + m[2] * p[1], m[1] * p[0] + m[3] * p[1]]; }
570
+ function __ptMLinear(m) { return [m[0], m[1], m[2], m[3], 0, 0]; }
571
+ function __ptMInvert(m) {
572
+ var det = m[0] * m[3] - m[1] * m[2];
573
+ if (!isFinite(det) || Math.abs(det) < 1e-12) return null; // a scale of 0 somewhere
574
+ var a = m[3] / det, b = -m[1] / det, cc = -m[2] / det, d = m[0] / det;
575
+ return [a, b, cc, d, -(a * m[4] + cc * m[5]), -(b * m[4] + d * m[5])];
576
+ }
577
+ function __ptNear(a, b, tol) {
578
+ if (!a || !b) return false;
579
+ return Math.abs(a[0] - b[0]) < tol && Math.abs(a[1] - b[1]) < tol;
580
+ }
581
+ function __ptWrapDeg(d) { while (d > 180) d -= 360; while (d <= -180) d += 360; return d; }
582
+ function __ptR4(n) { return Math.round(n * 10000) / 10000; } // for prose only; reported deltas keep full precision
583
+
584
+ function __ptProps(l) {
585
+ var tr = l.property("Transform");
586
+ return {
587
+ position: tr.property("Position"),
588
+ anchor: tr.property("Anchor Point"),
589
+ scale: tr.property("Scale"),
590
+ rotation: tr.property("Rotation")
591
+ };
592
+ }
593
+
594
+ function __ptAnimated(p) {
595
+ var list = [p.position, p.anchor, p.scale, p.rotation];
596
+ for (var i = 0; i < list.length; i++) {
597
+ if (!list[i]) continue;
598
+ if (list[i].numKeys > 0) return true;
599
+ try { if (list[i].expressionEnabled) return true; } catch (e) {}
600
+ }
601
+ return false;
602
+ }
603
+
604
+ // T(position) · R(rotation) · S(scale/100) · T(-anchor). This is the same
605
+ // matrix the layer uses for its own points, which is what lets a child's
606
+ // Position be fed straight through its parent's copy of it.
607
+ function __ptLocalMatrix(l, t) {
608
+ var p = __ptProps(l);
609
+ var pos = __ptV2(p.position.valueAtTime(t, false));
610
+ var anc = __ptV2(p.anchor.valueAtTime(t, false));
611
+ var sc = __ptV2(p.scale.valueAtTime(t, false));
612
+ var rad = p.rotation.valueAtTime(t, false) * Math.PI / 180;
613
+ var cos = Math.cos(rad), sin = Math.sin(rad);
614
+ var rs = [cos * sc[0] / 100, sin * sc[0] / 100, -sin * sc[1] / 100, cos * sc[1] / 100, 0, 0];
615
+ return __ptMMul(__ptMMul([1, 0, 0, 1, pos[0], pos[1]], rs), [1, 0, 0, 1, -anc[0], -anc[1]]);
616
+ }
617
+
618
+ // Walks up the parent chain. `m` is the layer's full world matrix; `wb` is its
619
+ // world anchor position under the anchor-relative reading of Position.
620
+ function __ptChain(l, t, depth) {
621
+ if (!l) return { ok: true, m: __ptMId(), wb: [0, 0], animated: false };
622
+ if (depth > 32) return { ok: false, reason: "parent chain deeper than 32 layers" };
623
+ if (!(l instanceof AVLayer)) return { ok: false, reason: 'a camera or light in the chain ("' + l.name + '") has no 2D transform' };
624
+ if (l.threeDLayer) return { ok: false, reason: 'a 3D layer in the chain ("' + l.name + '")' };
625
+ var up = __ptChain(l.parent, t, depth + 1);
626
+ if (!up.ok) return up;
627
+ var p = __ptProps(l);
628
+ var off = __ptMVec(up.m, __ptV2(p.position.valueAtTime(t, false)));
629
+ return {
630
+ ok: true,
631
+ m: __ptMMul(up.m, __ptLocalMatrix(l, t)),
632
+ wb: [up.wb[0] + off[0], up.wb[1] + off[1]],
633
+ animated: up.animated || __ptAnimated(p)
634
+ };
635
+ }
636
+
637
+ // Applies fn to a property's static value, or to every keyframe. AE's own
638
+ // compensation rewrites every key, so ours does too: the correction is a
639
+ // constant change of frame, not a change of animation.
640
+ function __ptAdjust(prop, fn) {
641
+ if (prop.numKeys > 0) {
642
+ for (var i = 1; i <= prop.numKeys; i++) prop.setValueAtKey(i, fn(prop.keyValue(i)));
643
+ return prop.numKeys;
644
+ }
645
+ prop.setValue(fn(prop.value));
646
+ return 0;
647
+ }
648
+
649
+ function __ptExprDriven(prop) {
650
+ try { return !!prop.expressionEnabled; } catch (e) { return false; }
651
+ }
652
+
446
653
  OPS.parent_layer = function (args) {
447
654
  var c = getCompById(args.compId);
448
655
  var l = getLayerById(c, args.layerId);
449
- if (args.parentLayerId === null) {
450
- l.parent = null;
656
+ var newParent = null;
657
+ if (args.parentLayerId !== null && args.parentLayerId !== undefined) {
658
+ newParent = getLayerById(c, args.parentLayerId);
659
+ }
660
+ var preserve = args.preserveTransform !== false;
661
+
662
+ if (!preserve) {
663
+ l.parent = newParent;
664
+ var plain = __layerSummary(l);
665
+ plain.preserveTransform = false;
666
+ return plain;
667
+ }
668
+
669
+ var t = c.time;
670
+ var notes = [];
671
+ var before = __ptChain(l, t, 0);
672
+ var oldParent = __ptChain(l.parent, t, 0);
673
+ var posChildBefore = __ptV2(__ptProps(l).position.valueAtTime(t, false));
674
+
675
+ // The parenting itself always happens, whether or not we can check it.
676
+ l.parent = newParent;
677
+
678
+ var out = __layerSummary(l);
679
+ out.preserveTransform = true;
680
+ var corr = {
681
+ applied: false,
682
+ atTime: t,
683
+ positionDelta: null,
684
+ scaleDelta: null,
685
+ rotationDelta: null,
686
+ keysAdjusted: 0,
687
+ positionModel: null,
688
+ timeVarying: false,
689
+ notes: notes
690
+ };
691
+ out.correction = corr;
692
+
693
+ if (!before.ok || !oldParent.ok) {
694
+ notes.push("not corrected: " + (before.reason || oldParent.reason));
695
+ return out;
696
+ }
697
+ var after = __ptChain(newParent, t, 0);
698
+ if (!after.ok) {
699
+ notes.push("not corrected: " + after.reason);
700
+ return out;
701
+ }
702
+ var invAfter = __ptMInvert(after.m);
703
+ if (!invAfter) {
704
+ notes.push("not corrected: the new parent chain has a scale of 0 and cannot be inverted");
705
+ return out;
706
+ }
707
+ corr.timeVarying = !!(before.animated || after.animated);
708
+ if (corr.timeVarying) {
709
+ notes.push("a transform in the chain is keyframed or expression-driven, so the world transform varies over time; corrected at comp time " + t);
710
+ }
711
+
712
+ var props = __ptProps(l);
713
+ // The child's world anchor position before the assignment, under each of the
714
+ // two readings of what a child's Position means in its parent's space.
715
+ var waAnchorRel = __ptV2(before.wb);
716
+ var waLayerSpace = __ptMPoint(oldParent.m, posChildBefore);
717
+
718
+ // --- rotation and scale -------------------------------------------------
719
+ // The linear part of a world matrix is the product of R·S up the chain, which
720
+ // is the same under both readings of Position. This half is model-free.
721
+ //
722
+ // desired = inv(Lin(new parent chain)) · Lin(child's world before), i.e. the
723
+ // child's own R·S that reproduces the world it had. Split it back into a
724
+ // rotation and an axis-aligned scale: column 0 is R·(sx,0) and column 1 is
725
+ // R·(0,sy), so |column 0| is |sx| and its angle is the rotation. A mirrored
726
+ // layer can be written either as a negative sx or as a negative sy plus 180
727
+ // degrees, so we keep whichever sign the layer already carries rather than
728
+ // rewriting an equivalent transform for no reason.
729
+ var desired = __ptMMul(__ptMLinear(invAfter), __ptMLinear(before.m));
730
+ var scNow = __ptV2(props.scale.valueAtTime(t, false));
731
+ var col0x = desired[0], col0y = desired[1];
732
+ var negX = (scNow[0] < 0);
733
+ if (negX) { col0x = -col0x; col0y = -col0y; }
734
+ var sxNew = Math.sqrt(col0x * col0x + col0y * col0y);
735
+ if (negX) sxNew = -sxNew;
736
+ var theta = Math.atan2(col0y, col0x);
737
+ var cs = Math.cos(theta), sn = Math.sin(theta);
738
+ var skew = cs * desired[2] + sn * desired[3];
739
+ var syNew = -sn * desired[2] + cs * desired[3];
740
+ if (Math.abs(skew) > 1e-6) {
741
+ notes.push("the new parent shears the layer (non-uniform scale under rotation); position/scale/rotation cannot express it exactly, residual skew " + __ptR4(skew));
742
+ }
743
+
744
+ var rotNow = props.rotation.valueAtTime(t, false);
745
+ var rotDelta = __ptWrapDeg(theta * 180 / Math.PI - rotNow);
746
+ if (Math.abs(rotDelta) > __PT_TOL_ROT) {
747
+ if (__ptExprDriven(props.rotation)) {
748
+ notes.push("rotation is expression-driven; the " + __ptR4(rotDelta) + "deg correction was not written");
749
+ } else {
750
+ corr.keysAdjusted += __ptAdjust(props.rotation, function (v) { return v + rotDelta; });
751
+ corr.rotationDelta = rotDelta;
752
+ corr.applied = true;
753
+ }
754
+ }
755
+
756
+ var scTarget = [sxNew * 100, syNew * 100];
757
+ var dsx = scTarget[0] - scNow[0], dsy = scTarget[1] - scNow[1];
758
+ if (Math.abs(dsx) > __PT_TOL_SCALE || Math.abs(dsy) > __PT_TOL_SCALE) {
759
+ if (__ptExprDriven(props.scale)) {
760
+ notes.push("scale is expression-driven; the correction to [" + __ptR4(scTarget[0]) + ", " + __ptR4(scTarget[1]) + "] was not written");
761
+ } else if (Math.abs(scNow[0]) < 1e-9 || Math.abs(scNow[1]) < 1e-9) {
762
+ notes.push("scale is 0 on an axis, so it cannot be scaled back to [" + __ptR4(scTarget[0]) + ", " + __ptR4(scTarget[1]) + "]");
763
+ } else {
764
+ // Multiplicative, so a keyframed scale keeps the shape of its animation.
765
+ var fx = scTarget[0] / scNow[0], fy = scTarget[1] / scNow[1];
766
+ corr.keysAdjusted += __ptAdjust(props.scale, function (v) {
767
+ var o = [];
768
+ for (var i = 0; i < v.length; i++) {
769
+ var f = fy;
770
+ if (i === 0) f = fx;
771
+ o.push(v[i] * f);
772
+ }
773
+ return o;
774
+ });
775
+ corr.scaleDelta = [dsx, dsy];
776
+ corr.applied = true;
777
+ }
778
+ }
779
+
780
+ // --- position -----------------------------------------------------------
781
+ // Recomputed after the linear correction, since the child's own R·S does not
782
+ // affect where its anchor lands but AE may have rewritten Position too.
783
+ var pAfter = __ptV2(props.position.valueAtTime(t, false));
784
+ var candLayerSpace = __ptMPoint(invAfter, waLayerSpace);
785
+ var candAnchorRel = __ptMVec(invAfter, [waAnchorRel[0] - after.wb[0], waAnchorRel[1] - after.wb[1]]);
786
+ var hitLayerSpace = __ptNear(pAfter, candLayerSpace, __PT_TOL_POS);
787
+ var hitAnchorRel = __ptNear(pAfter, candAnchorRel, __PT_TOL_POS);
788
+ var ambiguous = !__ptNear(candLayerSpace, candAnchorRel, __PT_TOL_POS);
789
+
790
+ if (hitLayerSpace || hitAnchorRel) {
791
+ // AE placed it where one of the two readings says it belongs — nothing to
792
+ // fix. Learn the reading when the two candidates actually disagree.
793
+ if (ambiguous && hitLayerSpace !== hitAnchorRel) {
794
+ __PARENT_POS_MODEL = hitLayerSpace ? "layer-space" : "anchor-relative";
795
+ }
796
+ corr.positionModel = __PARENT_POS_MODEL;
451
797
  } else {
452
- l.parent = getLayerById(c, args.parentLayerId);
798
+ var model = __PARENT_POS_MODEL || "layer-space";
799
+ var target = candLayerSpace;
800
+ if (model === "anchor-relative") target = candAnchorRel;
801
+ corr.positionModel = model;
802
+ if (ambiguous && !__PARENT_POS_MODEL) {
803
+ notes.push("the parent chain has a non-zero anchor point and this session has not yet seen AE agree with either reading of a child's Position, so the layer-space reading was assumed; the two differ by [" + __ptR4(candLayerSpace[0] - candAnchorRel[0]) + ", " + __ptR4(candLayerSpace[1] - candAnchorRel[1]) + "]");
804
+ }
805
+ var dx = target[0] - pAfter[0], dy = target[1] - pAfter[1];
806
+ if (__ptExprDriven(props.position)) {
807
+ notes.push("position is expression-driven; the [" + __ptR4(dx) + ", " + __ptR4(dy) + "] correction was not written");
808
+ } else {
809
+ corr.keysAdjusted += __ptAdjust(props.position, function (v) {
810
+ var o = [];
811
+ for (var i = 0; i < v.length; i++) {
812
+ var d = 0;
813
+ if (i === 0) d = dx;
814
+ else if (i === 1) d = dy;
815
+ o.push(v[i] + d);
816
+ }
817
+ return o;
818
+ });
819
+ corr.positionDelta = [dx, dy];
820
+ corr.applied = true;
821
+ }
453
822
  }
454
- return __layerSummary(l);
823
+
824
+ return out;
455
825
  };
456
826
 
457
827
  OPS.reorder_layer = function (args) {
@@ -746,28 +1116,57 @@ OPS.set_effect_enabled = function (args) {
746
1116
  return { ok: true };
747
1117
  };
748
1118
 
749
- OPS.list_available_effects = noUndo(function (args) {
1119
+ // Enumerating app.effects is very slow — around 250 entries in AE 26.3, slow
1120
+ // enough that a hand-rolled loop over it in run_jsx blocks the panel's socket
1121
+ // past the server's timeout and presents as a dead bridge (issue #26). The
1122
+ // table only changes when a plugin is installed, which needs an AE restart,
1123
+ // which reloads this bundle — so caching it for the life of the session is
1124
+ // exact, not merely close enough. Refreshing is available for the case where a
1125
+ // user swears they just installed something.
1126
+ var __availableEffects = null;
1127
+
1128
+ function __enumerateEffects() {
750
1129
  // app.effects is an array of {displayName, matchName, category, version} on modern AE.
751
- var out = [];
1130
+ var fx;
752
1131
  try {
753
- var fx = app.effects;
754
- for (var i = 0; i < fx.length; i++) {
755
- out.push({ displayName: fx[i].displayName, matchName: fx[i].matchName, category: fx[i].category });
756
- }
1132
+ fx = app.effects;
757
1133
  } catch (e) {
758
- // Fallback: return a curated subset of common match names.
759
- out = [
760
- { displayName: "Gaussian Blur", matchName: "ADBE Gaussian Blur 2", category: "Blur & Sharpen" },
761
- { displayName: "Fast Box Blur", matchName: "ADBE Box Blur2", category: "Blur & Sharpen" },
762
- { displayName: "Glow", matchName: "ADBE Glo2", category: "Stylize" },
763
- { displayName: "Drop Shadow", matchName: "ADBE Drop Shadow", category: "Perspective" },
764
- { displayName: "Curves", matchName: "ADBE CurvesCustom", category: "Color Correction" },
765
- { displayName: "Levels", matchName: "ADBE Easy Levels2", category: "Color Correction" },
766
- { displayName: "Fill", matchName: "ADBE Fill", category: "Generate" },
767
- { displayName: "CC Light Sweep", matchName: "CC Light Sweep", category: "Generate" },
768
- ];
1134
+ fx = null;
1135
+ }
1136
+ if (!fx || fx.length === undefined) {
1137
+ // No silent curated substitute here. Handing back eight effects that look
1138
+ // like the whole list makes "not installed" indistinguishable from "not
1139
+ // enumerated", and an agent then rules out an effect that is right there.
1140
+ throw new Error(
1141
+ "Cannot read app.effects on this After Effects build, so the installed-effect list is unavailable. " +
1142
+ "Add effects by matchName directly add_effect fails immediately and clearly on a wrong one. " +
1143
+ "Common matchNames: ADBE Gaussian Blur 2 (Gaussian Blur), ADBE Box Blur2 (Fast Box Blur), " +
1144
+ "ADBE Glo2 (Glow), ADBE Drop Shadow, ADBE CurvesCustom (Curves), ADBE Easy Levels2 (Levels), " +
1145
+ "ADBE Fill, CC Light Sweep."
1146
+ );
1147
+ }
1148
+ var out = [];
1149
+ for (var i = 0; i < fx.length; i++) {
1150
+ out.push({ displayName: fx[i].displayName, matchName: fx[i].matchName, category: fx[i].category });
769
1151
  }
770
1152
  return out;
1153
+ }
1154
+
1155
+ OPS.list_available_effects = noUndo(function (args) {
1156
+ if (!args) args = {};
1157
+ // Only a successful enumeration is cached; a throw leaves the cache empty so
1158
+ // the next call retries rather than repeating a stale failure.
1159
+ if (args.refresh || !__availableEffects) __availableEffects = __enumerateEffects();
1160
+ if (!args.filter) return __availableEffects;
1161
+
1162
+ var q = String(args.filter).toLowerCase();
1163
+ var hits = [];
1164
+ for (var i = 0; i < __availableEffects.length; i++) {
1165
+ var entry = __availableEffects[i];
1166
+ var hay = String(entry.displayName) + " " + String(entry.matchName) + " " + String(entry.category);
1167
+ if (hay.toLowerCase().indexOf(q) !== -1) hits.push(entry);
1168
+ }
1169
+ return hits;
771
1170
  });
772
1171
 
773
1172
 
@@ -775,6 +1174,16 @@ OPS.list_available_effects = noUndo(function (args) {
775
1174
 
776
1175
  // text.jsx — text layer styling.
777
1176
 
1177
+ // Shared with create_text_layer, which implements `anchorAlign` as live
1178
+ // paragraph justification rather than a one-time anchor offset. One map so the
1179
+ // two cannot drift.
1180
+ var __JUSTIFICATION = {
1181
+ left: ParagraphJustification.LEFT_JUSTIFY,
1182
+ center: ParagraphJustification.CENTER_JUSTIFY,
1183
+ right: ParagraphJustification.RIGHT_JUSTIFY,
1184
+ full: ParagraphJustification.FULL_JUSTIFY_LASTLINE_LEFT,
1185
+ };
1186
+
778
1187
  OPS.set_text = function (args) {
779
1188
  var c = getCompById(args.compId);
780
1189
  var l = getLayerById(c, args.layerId);
@@ -790,13 +1199,7 @@ OPS.set_text = function (args) {
790
1199
  if (args.tracking !== undefined) td.tracking = args.tracking;
791
1200
  if (args.leading !== undefined) td.leading = args.leading;
792
1201
  if (args.justification !== undefined) {
793
- var jmap = {
794
- left: ParagraphJustification.LEFT_JUSTIFY,
795
- center: ParagraphJustification.CENTER_JUSTIFY,
796
- right: ParagraphJustification.RIGHT_JUSTIFY,
797
- full: ParagraphJustification.FULL_JUSTIFY_LASTLINE_LEFT,
798
- };
799
- if (jmap[args.justification]) td.justification = jmap[args.justification];
1202
+ if (__JUSTIFICATION[args.justification]) td.justification = __JUSTIFICATION[args.justification];
800
1203
  }
801
1204
  if (args.applyFill !== undefined) td.applyFill = args.applyFill;
802
1205
  if (args.applyStroke !== undefined) td.applyStroke = args.applyStroke;
@@ -975,6 +1378,42 @@ OPS.add_shape_content = function (args) {
975
1378
  }
976
1379
 
977
1380
  var node = parent.addProperty(match);
1381
+
1382
+ // Render order. addProperty always appends to the END of the group, and in a
1383
+ // shape layer index 1 renders in FRONT — so an appended node lands at the back
1384
+ // and the first thing added is the thing on top. That is the opposite of the
1385
+ // layer stack, and building back-to-front silently produces a solid slab.
1386
+ //
1387
+ // "back" is therefore what already happened and needs no move. "front" is the
1388
+ // only case that reorders, and moveTo is the only primitive AE offers for it —
1389
+ // see the note in the tool description about nested renders. Do it here, on a
1390
+ // node with nothing in it yet, so a failure costs an empty node and nothing
1391
+ // else, and re-fetch afterwards: a structural change invalidates references
1392
+ // into the group (the same trap that makes a Fill reference go stale when a
1393
+ // Stroke is added beside it).
1394
+ if (args.zOrder === "front" && node.propertyIndex !== 1) {
1395
+ try {
1396
+ node.moveTo(1);
1397
+ } catch (eMove) {
1398
+ try { node.remove(); } catch (eRm) {}
1399
+ throw new Error(
1400
+ "add_shape_content created the '" + type + "' node but could not move it to the front of the " +
1401
+ "render stack: " + eMove.message + ". The node was removed, so nothing changed."
1402
+ );
1403
+ }
1404
+ node = parent.property(1);
1405
+ // Cheap identity check on the re-fetch. If moveTo ever lands the node
1406
+ // somewhere other than index 1, setting properties on whatever is there
1407
+ // would corrupt a node the caller never asked about.
1408
+ if (!node || node.matchName !== match) {
1409
+ throw new Error(
1410
+ "add_shape_content moved the '" + type + "' node towards the front of the render stack but did not " +
1411
+ "find it at index 1 afterwards. No properties were set on it. Read the layer back with get_layer_full " +
1412
+ "before retrying, and add contents front-to-back instead of using zOrder."
1413
+ );
1414
+ }
1415
+ }
1416
+
978
1417
  var applied = [];
979
1418
  var failed = [];
980
1419
 
@@ -1153,6 +1592,27 @@ function __clampDownsample(v) {
1153
1592
  return n > 8 ? 8 : n;
1154
1593
  }
1155
1594
 
1595
+ // The long edge we aim a screenshot at. ~1280px is still legible for checking
1596
+ // type and layout, and costs roughly 1.2k image tokens instead of the 11k a
1597
+ // full-resolution 4K frame costs.
1598
+ var __SCREENSHOT_TARGET_PX = 1280;
1599
+
1600
+ // The correct downsample was always derivable from the comp, and an agent that
1601
+ // forgot it got a full-resolution 4K frame — the most expensive accident
1602
+ // available through these tools. So derive it, and let an explicit value win.
1603
+ // 1080p -> 2 (960px), 4K -> 3 (1280px), and anything already small -> 1.
1604
+ function __autoDownsample(comp) {
1605
+ var longEdge = comp.width > comp.height ? comp.width : comp.height;
1606
+ var n = Math.ceil(longEdge / __SCREENSHOT_TARGET_PX);
1607
+ if (!(n > 1)) return 1;
1608
+ return n > 8 ? 8 : n;
1609
+ }
1610
+
1611
+ function __resolveDownsample(comp, requested) {
1612
+ if (requested === undefined || requested === null) return __autoDownsample(comp);
1613
+ return __clampDownsample(requested);
1614
+ }
1615
+
1156
1616
  // saveFrameToPng honours the comp's resolutionFactor, so AE can render the
1157
1617
  // reduced frame directly instead of writing full size and resampling
1158
1618
  // afterwards. That is faster (a quarter of the pixels at factor 2) and needs no
@@ -1176,7 +1636,7 @@ function __saveFrameAt(comp, time, file, factor) {
1176
1636
  OPS.screenshot_frame = noUndo(function (args) {
1177
1637
  var c = getCompById(args.compId);
1178
1638
  var t = (args.time !== undefined && args.time !== null) ? args.time : c.time;
1179
- var ds = __clampDownsample(args.downsample);
1639
+ var ds = __resolveDownsample(c, args.downsample);
1180
1640
  var path = __tmpPngPath();
1181
1641
  var f = new File(path);
1182
1642
  // saveFrameToPng is async-ish; the panel polls the file's existence/size.
@@ -1201,7 +1661,7 @@ OPS.screenshot_layer = noUndo(function (args) {
1201
1661
  ll.solo = false;
1202
1662
  }
1203
1663
  l.solo = true;
1204
- var ds = __clampDownsample(args.downsample);
1664
+ var ds = __resolveDownsample(c, args.downsample);
1205
1665
  var path = __tmpPngPath();
1206
1666
  var f = new File(path);
1207
1667
  try {
@@ -1222,6 +1682,399 @@ OPS.screenshot_layer = noUndo(function (args) {
1222
1682
  });
1223
1683
 
1224
1684
 
1685
+ // ===== footage.jsx =====
1686
+
1687
+ // footage.jsx — import a file into the project, and refuse to hand back an
1688
+ // asset that After Effects imported wrongly.
1689
+ //
1690
+ // Import exists here for one reason: it is the only place a viewBox check has
1691
+ // to live. AE's SVG importer fabricates pixel dimensions for an SVG whose
1692
+ // viewBox uses a very large coordinate space, and then rasterizes nothing — no
1693
+ // error at any stage, a footage item that looks healthy in the project panel,
1694
+ // and an empty frame wherever it is placed (issue #33). The agent doing the
1695
+ // import is the only thing in the loop that knows an import happened, so a
1696
+ // guide can tell it what to compare; a tool can compare it.
1697
+ //
1698
+ // The check is the one from that report: the aspect ratio the SVG asks for
1699
+ // against the aspect ratio AE produced. It needs the file path and the
1700
+ // resulting item together, which is exactly and only what an import op has.
1701
+
1702
+ var __SVG_ASPECT_TOLERANCE = 0.02; // 2% — comfortably past rounding, far short of the 3x that a broken import produces.
1703
+ var __SVG_SNIFF_BYTES = 16384;
1704
+
1705
+ function __lowerExt(path) {
1706
+ var dot = String(path).lastIndexOf(".");
1707
+ if (dot < 0) return "";
1708
+ return String(path).substring(dot + 1).toLowerCase();
1709
+ }
1710
+
1711
+ /** First `max` bytes of a file as text, or null if it cannot be read. */
1712
+ function __readHead(file, max) {
1713
+ var text = null;
1714
+ try {
1715
+ file.encoding = "UTF-8";
1716
+ if (!file.open("r")) return null;
1717
+ try { text = file.read(max); } finally { file.close(); }
1718
+ } catch (e) { return null; }
1719
+ return text;
1720
+ }
1721
+
1722
+ /**
1723
+ * viewBox / width / height off the root <svg> element. Deliberately a regex
1724
+ * rather than a parser: we need four numbers out of the first tag, not a DOM,
1725
+ * and ExtendScript has no XML reader that is worth the failure modes.
1726
+ */
1727
+ function __parseSvgViewBox(text) {
1728
+ if (!text) return null;
1729
+ var m = text.match(/\bviewBox\s*=\s*["']([^"']+)["']/);
1730
+ if (!m) return null;
1731
+ var parts = m[1].replace(/,/g, " ").replace(/^\s+|\s+$/g, "").split(/\s+/);
1732
+ if (parts.length < 4) return null;
1733
+ var w = parseFloat(parts[2]);
1734
+ var h = parseFloat(parts[3]);
1735
+ if (!isFinite(w) || !isFinite(h) || w <= 0 || h <= 0) return null;
1736
+ return { minX: parseFloat(parts[0]), minY: parseFloat(parts[1]), width: w, height: h, raw: m[1] };
1737
+ }
1738
+
1739
+ /**
1740
+ * Compare what the SVG asked for against what AE produced.
1741
+ *
1742
+ * Aspect ratio rather than absolute size on purpose: AE is entitled to pick the
1743
+ * pixel dimensions for a vector file with no width/height, and does so
1744
+ * sensibly for ordinary SVGs. What it is not entitled to do is change the
1745
+ * shape, and in the broken case it does — the report's example asks for
1746
+ * 278050x333334 (0.83) and gets 15906x5654 (2.81).
1747
+ */
1748
+ function __validateSvgImport(item, file) {
1749
+ var vb = __parseSvgViewBox(__readHead(file, __SVG_SNIFF_BYTES));
1750
+ if (!vb) return { ok: true, checked: false, reason: "no viewBox on the root <svg> element; nothing to compare against" };
1751
+
1752
+ var w = item.width, h = item.height;
1753
+ if (!(w > 0) || !(h > 0)) {
1754
+ return {
1755
+ ok: false, checked: true, viewBox: vb.raw, itemWidth: w, itemHeight: h,
1756
+ reason: "After Effects imported this SVG with dimensions " + w + "x" + h + "."
1757
+ };
1758
+ }
1759
+
1760
+ var expected = vb.width / vb.height;
1761
+ var actual = w / h;
1762
+ var drift = Math.abs(actual - expected) / expected;
1763
+ var out = {
1764
+ checked: true,
1765
+ viewBox: vb.raw,
1766
+ expectedAspect: Math.round(expected * 10000) / 10000,
1767
+ actualAspect: Math.round(actual * 10000) / 10000,
1768
+ itemWidth: w,
1769
+ itemHeight: h
1770
+ };
1771
+ if (drift <= __SVG_ASPECT_TOLERANCE) { out.ok = true; return out; }
1772
+ out.ok = false;
1773
+ out.reason =
1774
+ "After Effects imported this SVG as " + w + "x" + h + " (aspect " + out.actualAspect + "), but its " +
1775
+ "viewBox \"" + vb.raw + "\" asks for aspect " + out.expectedAspect + ". AE fabricates dimensions for an SVG " +
1776
+ "with a very large viewBox coordinate space and then rasterizes nothing, so this item renders empty " +
1777
+ "wherever it is placed, with no error.";
1778
+ return out;
1779
+ }
1780
+
1781
+ /** The advice is the same whichever way the item failed, so it is written once. */
1782
+ function __brokenSvgAdvice() {
1783
+ return (
1784
+ " Workarounds: for a simple flat SVG, read its path data and rebuild it as a shape layer with " +
1785
+ "set_shape_path, scaling the coordinates down to a sane space (divide by 333.334 for a 1000px " +
1786
+ "version) and setting ADBE Vector Fill Rule to 2 if the SVG says fill-rule=\"evenodd\" — that is " +
1787
+ "pixel-accurate. For a complex one, normalise the viewBox to a small range or rasterize to PNG " +
1788
+ "outside AE, then import that. Pass force:true to keep the item anyway."
1789
+ );
1790
+ }
1791
+
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
+
1799
+ var opts = new ImportOptions(file);
1800
+ if (args.sequence === true) {
1801
+ if (!opts.canImportAs(ImportAsType.FOOTAGE)) throw new Error("Cannot import " + path + " as footage, so it cannot be a sequence either");
1802
+ opts.importAs = ImportAsType.FOOTAGE;
1803
+ opts.sequence = true;
1804
+ }
1805
+
1806
+ var item = app.project.importFile(opts);
1807
+ if (!item) throw new Error("After Effects returned no item for " + path);
1808
+
1809
+ if (typeof args.name === "string" && args.name.length > 0) item.name = args.name;
1810
+
1811
+ var result = {
1812
+ itemId: item.id,
1813
+ name: item.name,
1814
+ path: path,
1815
+ width: item.width,
1816
+ height: item.height,
1817
+ duration: item.duration,
1818
+ frameRate: (item.frameRate !== undefined) ? item.frameRate : null,
1819
+ isStill: !!item.footageMissing ? null : (item.duration === 0),
1820
+ footageMissing: !!item.footageMissing
1821
+ };
1822
+
1823
+ if (__lowerExt(path) !== "svg") return result;
1824
+
1825
+ var v = __validateSvgImport(item, file);
1826
+ result.validation = v;
1827
+ if (v.ok) return result;
1828
+
1829
+ // A silently empty asset is the failure this whole op exists to prevent, so
1830
+ // it is not something to return with a warning attached and hope is read.
1831
+ // Remove what we created and say why — the same all-or-nothing stance
1832
+ // add_shape_content takes when a key will not resolve.
1833
+ if (args.force === true) {
1834
+ result.warning = v.reason + __brokenSvgAdvice();
1835
+ return result;
1836
+ }
1837
+ var name = item.name;
1838
+ try { item.remove(); result.removed = true; }
1839
+ catch (e) { result.removed = false; }
1840
+ throw new Error(
1841
+ v.reason + " The item (\"" + name + "\") has been removed from the project so nothing places it by mistake." +
1842
+ __brokenSvgAdvice()
1843
+ );
1844
+ };
1845
+
1846
+ OPS.create_footage_layer = function (args) {
1847
+ var comp = getCompById(args.compId);
1848
+ var item = app.project.itemByID(args.itemId);
1849
+ if (!item) throw new Error("No project item with id " + args.itemId);
1850
+ if (item instanceof FolderItem) throw new Error("Item " + args.itemId + " (\"" + item.name + "\") is a folder, not footage");
1851
+
1852
+ var layer = comp.layers.add(item);
1853
+ if (typeof args.name === "string" && args.name.length > 0) layer.name = args.name;
1854
+ if (args.position !== undefined && args.position !== null) {
1855
+ layer.property("Transform").property("Position").setValue(args.position);
1856
+ }
1857
+ if (args.startTime !== undefined && args.startTime !== null) layer.startTime = args.startTime;
1858
+
1859
+ return {
1860
+ layerId: layer.id,
1861
+ index: layer.index,
1862
+ name: layer.name,
1863
+ compId: comp.id,
1864
+ itemId: item.id,
1865
+ inPoint: layer.inPoint,
1866
+ outPoint: layer.outPoint
1867
+ };
1868
+ };
1869
+
1870
+
1871
+ // ===== mogrt.jsx =====
1872
+
1873
+ // mogrt.jsx — export a comp as a Motion Graphics template without the three
1874
+ // modal dialogs that make a scripted export look like a hung bridge (issue #23).
1875
+ //
1876
+ // Measured against AE 26.3 rather than assumed, because the mechanism was
1877
+ // filed as untested:
1878
+ //
1879
+ // * WITHOUT app.beginSuppressDialogs(), a comp using a non-Adobe font raises
1880
+ // "The following 1 fonts were not synced from Adobe … Click OK to continue"
1881
+ // and the export blocks. ExtendScript is single-threaded, so the panel
1882
+ // cannot service its socket while that dialog is up: the call sat past 60s
1883
+ // and no file was written until someone clicked OK.
1884
+ // * WITH it, the same export returned in a couple of seconds and produced a
1885
+ // valid .mogrt. So suppression is the thing that fixes it, and it is on by
1886
+ // default here.
1887
+ //
1888
+ // The other two dialogs are handled by construction. `app.project.save()`
1889
+ // immediately before the export removes the "project needs to be saved" prompt
1890
+ // deterministically — and the export itself dirties the project, so saving once
1891
+ // at the start of a session is not enough, it has to be per export. The
1892
+ // "undo group mismatch" warning is a consequence of running a non-undoable
1893
+ // export inside dispatch()'s undo group, so this op opts out of the group
1894
+ // entirely via noUndo.
1895
+ //
1896
+ // Two traps that are not in the report, both found by measurement here:
1897
+ //
1898
+ // * The output filename comes from `comp.motionGraphicsTemplateName`, NOT
1899
+ // from the comp name, and it defaults to the literal "Untitled" for a
1900
+ // template assembled by script. Left alone, every export from every comp in
1901
+ // a project writes Untitled.mogrt over the last one.
1902
+ // * The export invalidates every object reference held across it, including
1903
+ // `app.project`. See the re-fetch below.
1904
+
1905
+ /** Distinct fonts used by the text layers in a comp, following nested comps. */
1906
+ function __collectFonts(comp, depth, seenComps, fonts) {
1907
+ if (depth > 4) return;
1908
+ for (var c = 0; c < seenComps.length; c++) { if (seenComps[c] === comp.id) return; }
1909
+ seenComps.push(comp.id);
1910
+
1911
+ for (var i = 1; i <= comp.numLayers; i++) {
1912
+ var layer = comp.layer(i);
1913
+ try {
1914
+ if (layer instanceof TextLayer) {
1915
+ var doc = layer.property("Source Text").value;
1916
+ var f = doc.font;
1917
+ if (f) {
1918
+ var have = false;
1919
+ for (var k = 0; k < fonts.length; k++) { if (fonts[k] === f) { have = true; break; } }
1920
+ if (!have) fonts.push(f);
1921
+ }
1922
+ } else if (layer.source && (layer.source instanceof CompItem)) {
1923
+ __collectFonts(layer.source, depth + 1, seenComps, fonts);
1924
+ }
1925
+ } catch (e) {}
1926
+ }
1927
+ }
1928
+
1929
+ function __joinPath(dir, leaf) {
1930
+ var d = String(dir);
1931
+ if (d.charAt(d.length - 1) === "/" || d.charAt(d.length - 1) === "\\") d = d.substring(0, d.length - 1);
1932
+ return d + "/" + leaf;
1933
+ }
1934
+
1935
+ /** AE writes <motionGraphicsTemplateName>.mogrt into the folder it is given. */
1936
+ function __mogrtFileName(name) {
1937
+ return String(name) + ".mogrt";
1938
+ }
1939
+
1940
+ OPS.export_mogrt = noUndo(function (args) {
1941
+ var comp = getCompById(args.compId);
1942
+
1943
+ // The save prompt is the first dialog, and the only way to remove it without
1944
+ // guessing is to have somewhere to save to. A project that has never been
1945
+ // saved has no file and no folder — the user has to do that once by hand,
1946
+ // which is worth saying rather than raising a dialog they did not expect.
1947
+ if (!app.project.file) {
1948
+ throw new Error(
1949
+ "This After Effects project has never been saved, so the export cannot save it first and " +
1950
+ "After Effects would raise a modal 'save the project?' dialog that freezes this connection " +
1951
+ "until someone clicks it. Ask the user to save the project once, then call export_mogrt again."
1952
+ );
1953
+ }
1954
+
1955
+ var templateName = (typeof args.name === "string" && args.name.length > 0) ? args.name : null;
1956
+ var previousTemplateName = null;
1957
+ try { previousTemplateName = comp.motionGraphicsTemplateName; } catch (e) {}
1958
+ if (!templateName) {
1959
+ // AE's own default is "Untitled" for a template built by script, which
1960
+ // silently collides with every other comp in the project. The comp name is
1961
+ // what the user would have typed. A name they *did* type is left alone.
1962
+ templateName = (!previousTemplateName || previousTemplateName === "Untitled") ? comp.name : previousTemplateName;
1963
+ }
1964
+
1965
+ var destDir = (typeof args.destDir === "string" && args.destDir.length > 0)
1966
+ ? args.destDir
1967
+ : app.project.file.parent.fsName;
1968
+ var folder = new Folder(destDir);
1969
+ var createdDir = false;
1970
+ if (!folder.exists) {
1971
+ if (!folder.create()) throw new Error("Could not create the destination folder " + destDir);
1972
+ createdDir = true;
1973
+ }
1974
+
1975
+ var outPath = __joinPath(folder.fsName, __mogrtFileName(templateName));
1976
+ var outFile = new File(outPath);
1977
+ var existed = outFile.exists;
1978
+ if (existed && args.overwrite !== true) {
1979
+ throw new Error(
1980
+ "A template already exists at " + outPath + ". Pass overwrite: true to replace it, or a " +
1981
+ "different `name`."
1982
+ );
1983
+ }
1984
+
1985
+ var fonts = [];
1986
+ try { __collectFonts(comp, 0, [], fonts); } catch (eF) {}
1987
+
1988
+ if (templateName !== previousTemplateName) comp.motionGraphicsTemplateName = templateName;
1989
+
1990
+ // Saving is what removes the save prompt, and it has to happen per export:
1991
+ // exporting dirties the project, so a project saved before the first export
1992
+ // is dirty again before the second.
1993
+ app.project.save();
1994
+
1995
+ // Everything the result needs, read *before* the export. See below.
1996
+ var compId = comp.id;
1997
+ var compName = comp.name;
1998
+
1999
+ var suppress = args.suppressDialogs !== false;
2000
+ var suppressed = false;
2001
+ var exported;
2002
+ try {
2003
+ if (suppress) { app.beginSuppressDialogs(); suppressed = true; }
2004
+ exported = comp.exportAsMotionGraphicsTemplate(true, folder.fsName);
2005
+ } finally {
2006
+ // Unconditional: leaving dialogs suppressed would silence every warning in
2007
+ // the rest of the user's session, including ones they need to see.
2008
+ if (suppressed) { try { app.endSuppressDialogs(false); } catch (eS) {} }
2009
+ }
2010
+
2011
+ // exportAsMotionGraphicsTemplate invalidates every object reference held
2012
+ // across it — the CompItem, and `app.project` itself. Measured, not assumed:
2013
+ // after a successful export, `comp.name` and a captured `app.project.file`
2014
+ // both throw "Object is invalid", while a fresh `app.project.itemByID(id)`
2015
+ // returns a working comp. Reading through a stale handle here would report a
2016
+ // failure for an export that had already written a valid file, which is the
2017
+ // same lie as swallowing an error, just pointing the other way.
2018
+ comp = getCompById(compId);
2019
+
2020
+ // exportAsMotionGraphicsTemplate returns a boolean, and a false is the whole
2021
+ // failure report AE offers. Check the file too — a truthy return with nothing
2022
+ // on disk is exactly the kind of success-for-work-that-did-not-happen this
2023
+ // codebase refuses to pass on.
2024
+ var written = new File(outPath);
2025
+ 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
+ );
2030
+ }
2031
+ var bytes = written.length;
2032
+ if (!(bytes > 0)) throw new Error("The exported template at " + outPath + " is empty (0 bytes).");
2033
+
2034
+ var result = {
2035
+ ok: true,
2036
+ path: outPath,
2037
+ bytes: bytes,
2038
+ name: templateName,
2039
+ compId: compId,
2040
+ compName: compName,
2041
+ replaced: existed,
2042
+ createdDir: createdDir,
2043
+ projectSaved: app.project.file.fsName,
2044
+ dialogsSuppressed: suppress,
2045
+ controllerCount: comp.motionGraphicsTemplateControllerCount,
2046
+ fonts: fonts
2047
+ };
2048
+ if (!suppress) {
2049
+ result.warning =
2050
+ "Dialogs were not suppressed. If this call took a long time, a modal font warning was waiting " +
2051
+ "in After Effects.";
2052
+ }
2053
+
2054
+ // The thumbnail. AE has no scriptable poster time — CompItem.posterTime does
2055
+ // not exist — and the export ignores comp.time, so thumb.png inside the
2056
+ // .mogrt is whatever AE decided, usually black. Render the requested frame
2057
+ // here and let the panel put it into the archive: ExtendScript can write a
2058
+ // PNG but cannot rewrite a zip, and the panel is already the layer that
2059
+ // post-processes files AE has just written.
2060
+ if (args.posterTime !== undefined && args.posterTime !== null) {
2061
+ var t = args.posterTime;
2062
+ if (t < 0 || t > comp.duration) {
2063
+ throw new Error(
2064
+ "posterTime " + t + " is outside the comp's 0.." + comp.duration + "s. The template was still " +
2065
+ "exported to " + outPath + " with After Effects' own thumbnail."
2066
+ );
2067
+ }
2068
+ var posterPath = __tmpPngPath();
2069
+ __saveFrameAt(comp, t, new File(posterPath), 1);
2070
+ result.posterPngPath = posterPath;
2071
+ result.posterTime = t;
2072
+ }
2073
+
2074
+ return result;
2075
+ });
2076
+
2077
+
1225
2078
  // ===== style.jsx =====
1226
2079
 
1227
2080
  // style.jsx — the project's house style, read from and written to a plain
@@ -1440,7 +2293,46 @@ OPS._get_job = noUndo(function (args) {
1440
2293
 
1441
2294
  // explore.jsx — rich one-shot inspection. The whole reason this MCP exists.
1442
2295
 
1443
- function __serializeProperty(p, deep) {
2296
+ // Which keyframes survive a cap, or null when none need to go. Keeping the
2297
+ // first few and the last few rather than a prefix means the shape of the
2298
+ // animation — where it starts, where it ends — is still readable.
2299
+ function __keyframeWindow(total, max) {
2300
+ if (!(max > 0) || total <= max) return null;
2301
+ var head = Math.ceil(max / 2);
2302
+ return { head: head, tail: max - head, total: total, omitted: total - max };
2303
+ }
2304
+
2305
+ // Truncation is never silent: an agent that cannot see what was dropped will
2306
+ // read a partial answer as the whole one.
2307
+ function __truncationNote(w) {
2308
+ return "first " + w.head + " and last " + w.tail + " of " + w.total + " keyframes; " +
2309
+ w.omitted + " omitted — raise maxKeyframes, or read them all with get_keyframes";
2310
+ }
2311
+
2312
+ function __serializeKeyframe(p, k) {
2313
+ var entry = { index: k, time: p.keyTime(k), value: p.keyValue(k) };
2314
+ try {
2315
+ entry["in"] = String(p.keyInInterpolationType(k));
2316
+ entry["out"] = String(p.keyOutInterpolationType(k));
2317
+ } catch (e1) {}
2318
+ try {
2319
+ var inE = p.keyInTemporalEase(k);
2320
+ var outE = p.keyOutTemporalEase(k);
2321
+ entry.easeIn = { influence: inE[0].influence, speed: inE[0].speed };
2322
+ entry.easeOut = { influence: outE[0].influence, speed: outE[0].speed };
2323
+ } catch (e2) {}
2324
+ if (p.isSpatial) {
2325
+ try {
2326
+ entry.inTangent = p.keyInSpatialTangent(k);
2327
+ entry.outTangent = p.keyOutSpatialTangent(k);
2328
+ } catch (e3) {}
2329
+ }
2330
+ return entry;
2331
+ }
2332
+
2333
+ // `opts.maxKeyframes` bounds the response; 0 or absent means every keyframe,
2334
+ // which is what every caller written before the cap existed gets.
2335
+ function __serializeProperty(p, opts) {
1444
2336
  var out = {
1445
2337
  name: p.name,
1446
2338
  matchName: p.matchName,
@@ -1451,49 +2343,66 @@ function __serializeProperty(p, deep) {
1451
2343
  try { out.value = p.value; } catch (e) {}
1452
2344
  if (p.canSetExpression && p.expression) out.expression = p.expression;
1453
2345
  if (p.numKeys > 0) {
2346
+ var total = p.numKeys;
2347
+ var w = __keyframeWindow(total, (opts && opts.maxKeyframes) ? opts.maxKeyframes : 0);
1454
2348
  out.keyframes = [];
1455
- for (var k = 1; k <= p.numKeys; k++) {
1456
- var entry = { index: k, time: p.keyTime(k), value: p.keyValue(k) };
1457
- try {
1458
- entry["in"] = String(p.keyInInterpolationType(k));
1459
- entry["out"] = String(p.keyOutInterpolationType(k));
1460
- } catch (e1) {}
1461
- try {
1462
- var inE = p.keyInTemporalEase(k);
1463
- var outE = p.keyOutTemporalEase(k);
1464
- entry.easeIn = { influence: inE[0].influence, speed: inE[0].speed };
1465
- entry.easeOut = { influence: outE[0].influence, speed: outE[0].speed };
1466
- } catch (e2) {}
1467
- if (p.isSpatial) {
1468
- try {
1469
- entry.inTangent = p.keyInSpatialTangent(k);
1470
- entry.outTangent = p.keyOutSpatialTangent(k);
1471
- } catch (e3) {}
1472
- }
1473
- out.keyframes.push(entry);
2349
+ for (var k = 1; k <= total; k++) {
2350
+ if (w && k > w.head && k <= total - w.tail) continue;
2351
+ out.keyframes.push(__serializeKeyframe(p, k));
2352
+ }
2353
+ if (w) {
2354
+ out.keyframeCount = w.total;
2355
+ out.keyframesOmitted = w.omitted;
2356
+ out.keyframesTruncated = __truncationNote(w);
1474
2357
  }
1475
2358
  }
1476
2359
  return out;
1477
2360
  }
1478
2361
 
1479
- function __serializeTransformGroup(tg) {
2362
+ function __serializeTransformGroup(tg, opts) {
1480
2363
  var out = {};
1481
2364
  for (var i = 1; i <= tg.numProperties; i++) {
1482
2365
  var p = tg.property(i);
1483
- out[p.name] = __serializeProperty(p);
2366
+ out[p.name] = __serializeProperty(p, opts);
1484
2367
  }
1485
2368
  return out;
1486
2369
  }
1487
2370
 
1488
- function __serializeEffects(layer) {
2371
+ // The cap is applied after the fact rather than inside __serializeEffect,
2372
+ // which effects.jsx also uses for list_effects and add_effect — those return
2373
+ // one effect and have no size problem to solve.
2374
+ function __capEffectKeyframes(effects, max) {
2375
+ if (!(max > 0)) return effects;
2376
+ for (var i = 0; i < effects.length; i++) {
2377
+ var params = effects[i].params;
2378
+ for (var j = 0; j < params.length; j++) {
2379
+ var keys = params[j].keyframes;
2380
+ if (!keys) continue;
2381
+ var w = __keyframeWindow(keys.length, max);
2382
+ if (!w) continue;
2383
+ var kept = [];
2384
+ for (var k = 0; k < w.total; k++) {
2385
+ if (k >= w.head && k < w.total - w.tail) continue;
2386
+ kept.push(keys[k]);
2387
+ }
2388
+ params[j].keyframes = kept;
2389
+ params[j].keyframeCount = w.total;
2390
+ params[j].keyframesOmitted = w.omitted;
2391
+ params[j].keyframesTruncated = __truncationNote(w);
2392
+ }
2393
+ }
2394
+ return effects;
2395
+ }
2396
+
2397
+ function __serializeEffects(layer, opts) {
1489
2398
  var fx = layer.property("Effects");
1490
2399
  if (!fx || fx.numProperties === 0) return [];
1491
2400
  var arr = [];
1492
2401
  for (var i = 1; i <= fx.numProperties; i++) arr.push(__serializeEffect(fx.property(i)));
1493
- return arr;
2402
+ return __capEffectKeyframes(arr, (opts && opts.maxKeyframes) ? opts.maxKeyframes : 0);
1494
2403
  }
1495
2404
 
1496
- function __serializeMasks(layer) {
2405
+ function __serializeMasks(layer, opts) {
1497
2406
  var masks = layer.property("Masks");
1498
2407
  if (!masks || masks.numProperties === 0) return [];
1499
2408
  var out = [];
@@ -1505,10 +2414,10 @@ function __serializeMasks(layer) {
1505
2414
  mode: String(m.maskMode),
1506
2415
  inverted: m.inverted,
1507
2416
  };
1508
- try { entry.shape = __serializeProperty(m.property("ADBE Mask Shape")); } catch (e1) {}
1509
- try { entry.opacity = __serializeProperty(m.property("ADBE Mask Opacity")); } catch (e2) {}
1510
- try { entry.expansion = __serializeProperty(m.property("ADBE Mask Offset")); } catch (e3) {}
1511
- try { entry.feather = __serializeProperty(m.property("ADBE Mask Feather")); } catch (e4) {}
2417
+ try { entry.shape = __serializeProperty(m.property("ADBE Mask Shape"), opts); } catch (e1) {}
2418
+ try { entry.opacity = __serializeProperty(m.property("ADBE Mask Opacity"), opts); } catch (e2) {}
2419
+ try { entry.expansion = __serializeProperty(m.property("ADBE Mask Offset"), opts); } catch (e3) {}
2420
+ try { entry.feather = __serializeProperty(m.property("ADBE Mask Feather"), opts); } catch (e4) {}
1512
2421
  out.push(entry);
1513
2422
  }
1514
2423
  return out;
@@ -1563,6 +2472,9 @@ function __serializeShapeContents(group, depth) {
1563
2472
  var entry = { name: p.name, matchName: p.matchName, index: i };
1564
2473
  if (p.propertyType === PropertyType.NAMED_GROUP || p.propertyType === PropertyType.INDEXED_GROUP) {
1565
2474
  if (depth > 0) entry.children = __serializeShapeContents(p, depth - 1);
2475
+ // Say where the walk stopped. A group that simply has no `children` key
2476
+ // reads as empty, which for a deep shape tree is a lie.
2477
+ else if (p.numProperties > 0) entry.childrenOmitted = p.numProperties;
1566
2478
  } else {
1567
2479
  try { entry.value = p.value; } catch (e) {}
1568
2480
  }
@@ -1574,6 +2486,11 @@ function __serializeShapeContents(group, depth) {
1574
2486
  OPS.get_layer_full = noUndo(function (args) {
1575
2487
  var c = getCompById(args.compId);
1576
2488
  var l = getLayerById(c, args.layerId);
2489
+ // `include` bounds the response by section, `maxKeyframes` and `shapeDepth`
2490
+ // bound the two things that make a single layer weigh 250KB. All three are
2491
+ // absent by default, and absent means "everything", exactly as before.
2492
+ var want = (args && args.include) ? args.include : null;
2493
+ var opts = { maxKeyframes: (args && args.maxKeyframes > 0) ? args.maxKeyframes : 0 };
1577
2494
  var out = {
1578
2495
  id: l.id,
1579
2496
  index: l.index,
@@ -1592,44 +2509,69 @@ OPS.get_layer_full = noUndo(function (args) {
1592
2509
  preserveTransparency: l.preserveTransparency,
1593
2510
  parent: l.parent ? { layerId: l.parent.id, name: l.parent.name } : null,
1594
2511
  sourceType: __layerKind(l),
1595
- transform: __serializeTransformGroup(l.property("Transform")),
1596
- effects: __serializeEffects(l),
1597
- masks: __serializeMasks(l),
1598
- markers: __serializeMarkers(l),
1599
2512
  };
2513
+ if (__wantsSection(want, "transform")) out.transform = __serializeTransformGroup(l.property("Transform"), opts);
2514
+ if (__wantsSection(want, "effects")) out.effects = __serializeEffects(l, opts);
2515
+ if (__wantsSection(want, "masks")) out.masks = __serializeMasks(l, opts);
2516
+ if (__wantsSection(want, "markers")) out.markers = __serializeMarkers(l);
1600
2517
  // Visual bounds at the comp's current time — cheap to fetch and removes a
1601
2518
  // class of "I need to screenshot to know where this renders" round-trips.
1602
2519
  // Coordinates are in the layer's local space (origin at the Anchor Point).
1603
- try {
1604
- var __rect = l.sourceRectAtTime(c.time, false);
1605
- out.sourceRect = { left: __rect.left, top: __rect.top, width: __rect.width, height: __rect.height, time: c.time };
1606
- } catch (__e) {}
1607
- if (l instanceof TextLayer) out.text = __serializeText(l);
1608
- if (l instanceof ShapeLayer) {
1609
- try { out.shape = { contents: __serializeShapeContents(l.property("Contents"), 4) }; }
2520
+ if (__wantsSection(want, "bounds")) {
2521
+ try {
2522
+ var __rect = l.sourceRectAtTime(c.time, false);
2523
+ out.sourceRect = { left: __rect.left, top: __rect.top, width: __rect.width, height: __rect.height, time: c.time };
2524
+ } catch (__e) {}
2525
+ }
2526
+ if (l instanceof TextLayer && __wantsSection(want, "text")) out.text = __serializeText(l);
2527
+ if (l instanceof ShapeLayer && __wantsSection(want, "shape")) {
2528
+ var depth = (args && args.shapeDepth !== undefined && args.shapeDepth !== null) ? args.shapeDepth : 4;
2529
+ try { out.shape = { depth: depth, contents: __serializeShapeContents(l.property("Contents"), depth) }; }
1610
2530
  catch (e) {}
1611
2531
  }
1612
- if (l.source && l.source instanceof CompItem) {
1613
- out.precomp = { compId: l.source.id, compName: l.source.name };
1614
- if (args.includeChildren) {
1615
- out.children = [];
1616
- for (var i = 1; i <= l.source.numLayers; i++) out.children.push(__layerSummary(l.source.layer(i)));
2532
+ if (__wantsSection(want, "source")) {
2533
+ if (l.source && l.source instanceof CompItem) {
2534
+ out.precomp = { compId: l.source.id, compName: l.source.name };
2535
+ if (args.includeChildren) {
2536
+ out.children = [];
2537
+ for (var i = 1; i <= l.source.numLayers; i++) out.children.push(__layerSummary(l.source.layer(i)));
2538
+ }
2539
+ } else if (l.source && l.source instanceof FootageItem) {
2540
+ var src = l.source;
2541
+ out.footage = {
2542
+ itemId: src.id,
2543
+ name: src.name,
2544
+ hasAlpha: src.hasAlpha,
2545
+ duration: src.duration,
2546
+ width: src.width,
2547
+ height: src.height,
2548
+ };
2549
+ try { if (src.file) out.footage.path = src.file.fsName; } catch (e2) {}
1617
2550
  }
1618
- } else if (l.source && l.source instanceof FootageItem) {
1619
- var src = l.source;
1620
- out.footage = {
1621
- itemId: src.id,
1622
- name: src.name,
1623
- hasAlpha: src.hasAlpha,
1624
- duration: src.duration,
1625
- width: src.width,
1626
- height: src.height,
1627
- };
1628
- try { if (src.file) out.footage.path = src.file.fsName; } catch (e2) {}
1629
2551
  }
2552
+ // Echo the scoping back, so a bounded answer is never read as a full one.
2553
+ if (want) out.included = want;
1630
2554
  return out;
1631
2555
  });
1632
2556
 
2557
+ // Project item kind. An if/else chain, not a chained ternary: this build of
2558
+ // ExtendScript parses `a ? x : b ? y : z` left-associatively, so the first
2559
+ // truthy branch became the next condition and every item fell through to
2560
+ // "folder" (issues #21/#22). tests/unit/jsx-ternary.mjs keeps it that way.
2561
+ // "solid" mirrors __layerKind in layers.jsx so an item and a layer that share a
2562
+ // source describe it with the same word.
2563
+ function __itemKind(it) {
2564
+ if (it instanceof CompItem) return "comp";
2565
+ if (it instanceof FolderItem) return "folder";
2566
+ if (it instanceof FootageItem) {
2567
+ try {
2568
+ if (it.mainSource && it.mainSource.color !== undefined) return "solid";
2569
+ } catch (e) {}
2570
+ return "footage";
2571
+ }
2572
+ return "unknown";
2573
+ }
2574
+
1633
2575
  OPS.get_project_summary = noUndo(function (args) {
1634
2576
  var p = app.project;
1635
2577
  var items = [];
@@ -1638,7 +2580,7 @@ OPS.get_project_summary = noUndo(function (args) {
1638
2580
  items.push({
1639
2581
  id: it.id,
1640
2582
  name: it.name,
1641
- type: (it instanceof CompItem) ? "comp" : (it instanceof FootageItem) ? "footage" : (it instanceof FolderItem) ? "folder" : "unknown",
2583
+ type: __itemKind(it),
1642
2584
  });
1643
2585
  }
1644
2586
  return {
@@ -1690,29 +2632,117 @@ OPS.find_layers = noUndo(function (args) {
1690
2632
 
1691
2633
  // raw.jsx — escape hatch. Eval arbitrary ExtendScript and return the value.
1692
2634
 
1693
- OPS.run_jsx = function (args) {
1694
- var code = args.code || "";
1695
- // We wrap in a function so `return` works.
1696
- var wrapper = "(function(){ " + code + " })()";
1697
- var result;
1698
- try { result = eval(wrapper); }
1699
- catch (e) { throw e; }
1700
- // ExtendScript objects can be unserializable; coerce sparingly.
1701
- if (typeof result === "undefined") return null;
1702
- if (result === null) return null;
1703
- var t = typeof result;
1704
- if (t === "number" || t === "string" || t === "boolean") return result;
1705
- if (result instanceof Array) return result;
1706
- // Object: pull plain own props
2635
+ // ---------- Result serialization ----------
2636
+ // The returned value has to survive JSON.stringify in the panel. Anything that
2637
+ // cannot be represented is replaced *in place* by a short marker string, never
2638
+ // dropped: the old coercion kept scalars only, so {done:[...], skipped:[...]}
2639
+ // came back as {} — indistinguishable from "the script did nothing" while its
2640
+ // mutations had already landed, which invites re-running a mutating script
2641
+ // (issue #31). An empty result must mean the script returned nothing.
2642
+ //
2643
+ // The walk is deliberately opt-in: only arrays and plain objects are recursed
2644
+ // into. Live AE objects (Layer, Property, CompItem …) have huge and partly
2645
+ // throwing property graphs, so they degrade to "[AVLayer \"name\"]" rather than
2646
+ // being walked.
2647
+
2648
+ var __RJ_MAX_DEPTH = 12;
2649
+ var __RJ_MAX_NODES = 50000;
2650
+
2651
+ function __rjIsPlainObject(v) {
2652
+ try { if (v.constructor === Object) return true; } catch (e) {}
2653
+ try { if (v.reflect && v.reflect.name === "Object") return true; } catch (e2) {}
2654
+ return false;
2655
+ }
2656
+
2657
+ function __rjTypeName(v) {
2658
+ var n = null;
2659
+ try { if (v.reflect && v.reflect.name) n = String(v.reflect.name); } catch (e) {}
2660
+ if (!n) {
2661
+ try {
2662
+ n = String(v);
2663
+ if (n.substring(0, 8) === "[object ") n = n.substring(8, n.length - 1);
2664
+ } catch (e2) { n = "object"; }
2665
+ }
2666
+ if (n.length > 48) n = n.substring(0, 48);
2667
+ return n;
2668
+ }
2669
+
2670
+ // Short, identifiable stand-in for a value we refuse to walk. The name and id
2671
+ // are worth the two guarded reads: they are what turns "something was here"
2672
+ // into a handle the caller can pass to get_comp or get_layer_full.
2673
+ function __rjMarker(v) {
2674
+ var extra = "";
2675
+ try {
2676
+ if (typeof v.name === "string" && v.name.length > 0 && v.name.length < 64) extra = ' "' + v.name + '"';
2677
+ } catch (e) {}
2678
+ try {
2679
+ if (typeof v.id === "number") extra += " #" + v.id;
2680
+ } catch (e2) {}
2681
+ return "[" + __rjTypeName(v) + extra + "]";
2682
+ }
2683
+
2684
+ // `stack` holds the ancestors currently being walked, so a repeated reference
2685
+ // that is not a cycle still serializes. ES3 has no Set — a linear scan over a
2686
+ // depth-limited stack is cheap enough.
2687
+ function __rjSerialize(v, depth, stack, budget) {
2688
+ if (v === null) return null;
2689
+ var t = typeof v;
2690
+ if (t === "undefined") return "[undefined]";
2691
+ if (t === "boolean" || t === "string") return v;
2692
+ if (t === "number") {
2693
+ if (isNaN(v)) return "[NaN]";
2694
+ if (!isFinite(v)) return v > 0 ? "[Infinity]" : "[-Infinity]";
2695
+ return v;
2696
+ }
2697
+ if (t === "function") return "[function]";
2698
+ if (t !== "object") return "[" + t + "]";
2699
+ if (v instanceof Date) return String(v);
2700
+
2701
+ budget.n += 1;
2702
+ if (budget.n > __RJ_MAX_NODES) return "[truncated: node limit]";
2703
+
2704
+ var isArray = (v instanceof Array);
2705
+ if (!isArray && !__rjIsPlainObject(v)) return __rjMarker(v);
2706
+
2707
+ for (var s = 0; s < stack.length; s++) { if (stack[s] === v) return "[circular]"; }
2708
+ if (depth >= __RJ_MAX_DEPTH) return "[max depth]";
2709
+
2710
+ stack.push(v);
2711
+ var out;
1707
2712
  try {
1708
- var out = {};
1709
- for (var k in result) {
1710
- if (result.hasOwnProperty(k)) {
1711
- var v = result[k];
1712
- var vt = typeof v;
1713
- if (v === null || vt === "number" || vt === "string" || vt === "boolean") out[k] = v;
2713
+ if (isArray) {
2714
+ out = [];
2715
+ for (var i = 0; i < v.length; i++) out.push(__rjSerialize(v[i], depth + 1, stack, budget));
2716
+ } else {
2717
+ out = {};
2718
+ for (var k in v) {
2719
+ if (!v.hasOwnProperty(k)) continue;
2720
+ var val;
2721
+ try { val = v[k]; }
2722
+ catch (eg) { out[k] = "[threw: " + (eg && eg.message ? eg.message : String(eg)) + "]"; continue; }
2723
+ out[k] = __rjSerialize(val, depth + 1, stack, budget);
1714
2724
  }
1715
2725
  }
1716
- return out;
1717
- } catch (e2) { return String(result); }
1718
- };
2726
+ } finally {
2727
+ stack.pop();
2728
+ }
2729
+ return out;
2730
+ }
2731
+
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 });
2737
+ }
2738
+
2739
+ // undoGroup:false is a per-call opt-out, read by dispatch() through the
2740
+ // predicate form of __meta.noUndo. It exists because AE refuses copyToComp for
2741
+ // a layer with a parent or a linked expression while an undo group is open,
2742
+ // which is exactly the layer worth copying (issue #30).
2743
+ OPS.run_jsx = noUndoWhen(function (args) { return args.undoGroup === false; }, function (args) {
2744
+ var code = args.code || "";
2745
+ // We wrap in a function so `return` works.
2746
+ var wrapper = "(function(){ " + code + " })()";
2747
+ return __rjResult(eval(wrapper));
2748
+ });