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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/server.js CHANGED
@@ -336,6 +336,7 @@ var WsEvent = z.discriminatedUnion("type", [
336
336
  // ../shared/dist/schemas.js
337
337
  var schemas_exports = {};
338
338
  __export(schemas_exports, {
339
+ ARRAY_ELEMENT: () => ARRAY_ELEMENT,
339
340
  AddEffect: () => AddEffect,
340
341
  AddKeyframe: () => AddKeyframe,
341
342
  AddMarker: () => AddMarker,
@@ -343,7 +344,9 @@ __export(schemas_exports, {
343
344
  AddShapeContent: () => AddShapeContent,
344
345
  AddTextAnimator: () => AddTextAnimator,
345
346
  AeGuide: () => AeGuide,
347
+ AudioCue: () => AudioCue,
346
348
  AwaitJob: () => AwaitJob,
349
+ CROSS_FIELD_RULE: () => CROSS_FIELD_RULE,
347
350
  CancelJob: () => CancelJob,
348
351
  CheckSetup: () => CheckSetup,
349
352
  ClearExpression: () => ClearExpression,
@@ -361,6 +364,8 @@ __export(schemas_exports, {
361
364
  CreateTextLayer: () => CreateTextLayer,
362
365
  DeleteComp: () => DeleteComp,
363
366
  DeleteLayer: () => DeleteLayer,
367
+ DiffComp: () => DiffComp,
368
+ DuplicateComp: () => DuplicateComp,
364
369
  DuplicateLayer: () => DuplicateLayer,
365
370
  ExportMogrt: () => ExportMogrt,
366
371
  FindLayers: () => FindLayers,
@@ -383,8 +388,10 @@ __export(schemas_exports, {
383
388
  ListLayers: () => ListLayers,
384
389
  LogIssue: () => LogIssue,
385
390
  MarkIssueReported: () => MarkIssueReported,
391
+ OpMutation: () => OpMutation,
386
392
  OpSchemas: () => OpSchemas,
387
393
  ParentLayer: () => ParentLayer,
394
+ PlaceAudioCues: () => PlaceAudioCues,
388
395
  PropertyPath: () => PropertyPath,
389
396
  RemoveEffect: () => RemoveEffect,
390
397
  RemoveKeyframe: () => RemoveKeyframe,
@@ -412,10 +419,18 @@ __export(schemas_exports, {
412
419
  SetTransform: () => SetTransform,
413
420
  SetupPanel: () => SetupPanel,
414
421
  ShapeContent: () => ShapeContent,
422
+ SnapshotComp: () => SnapshotComp,
415
423
  ToggleExpression: () => ToggleExpression,
416
424
  Vec2: () => Vec2,
417
425
  Vec3: () => Vec3,
418
- VecAny: () => VecAny
426
+ VecAny: () => VecAny,
427
+ crossFieldJsonSchema: () => crossFieldJsonSchema,
428
+ crossFieldMessage: () => crossFieldMessage,
429
+ crossFieldPresent: () => crossFieldPresent,
430
+ crossFieldRuleOf: () => crossFieldRuleOf,
431
+ crossFieldRulesIn: () => crossFieldRulesIn,
432
+ crossFieldSatisfied: () => crossFieldSatisfied,
433
+ objectShapeOf: () => objectShapeOf
419
434
  });
420
435
  import { z as z2 } from "zod";
421
436
  var Color = z2.tuple([z2.number(), z2.number(), z2.number()]).describe("RGB 0..1");
@@ -431,6 +446,117 @@ var Interpolation = z2.object({
431
446
  easeOut: z2.object({ influence: z2.number(), speed: z2.number() }).optional()
432
447
  });
433
448
  var includeParam = (sections, hint) => z2.array(z2.enum(sections)).optional().describe(`Sections to return: ${sections.join(", ")}. Omit for all of them; [] for ${hint}.`);
449
+ var CROSS_FIELD_RULE = /* @__PURE__ */ Symbol.for("engineRoom.aeMcp.crossFieldRule");
450
+ var backticked = (fields) => fields.map((f) => "`" + f + "`");
451
+ var joinWith = (parts, conjunction) => {
452
+ if (parts.length <= 1)
453
+ return parts.join("");
454
+ if (parts.length === 2)
455
+ return `${parts[0]} ${conjunction} ${parts[1]}`;
456
+ return `${parts.slice(0, -1).join(", ")} ${conjunction} ${parts[parts.length - 1]}`;
457
+ };
458
+ function crossFieldMessage(rule, present) {
459
+ const want = joinWith(backticked(rule.fields), "or");
460
+ const got = joinWith(backticked(present), "and");
461
+ const head = rule.kind === "exactlyOne" ? `Pass exactly one of ${want}` : rule.kind === "atLeastOne" ? `Pass at least one of ${want}` : `Pass at most one of ${want}`;
462
+ const body = present.length === 0 ? rule.fields.length === 2 ? " \u2014 neither was passed." : " \u2014 none of them was passed." : ` \u2014 got ${got}. Keep the one you meant and drop ${present.length === 2 ? "the other" : "the others"}.`;
463
+ return head + body + (rule.then ? " " + rule.then : "");
464
+ }
465
+ function crossFieldPresent(rule, value) {
466
+ if (!value || typeof value !== "object")
467
+ return [];
468
+ const obj = value;
469
+ return rule.fields.filter((f) => obj[f] !== void 0);
470
+ }
471
+ function crossFieldSatisfied(rule, value) {
472
+ const n = crossFieldPresent(rule, value).length;
473
+ if (rule.kind === "exactlyOne")
474
+ return n === 1;
475
+ if (rule.kind === "atLeastOne")
476
+ return n >= 1;
477
+ return n <= 1;
478
+ }
479
+ function crossFieldJsonSchema(rule) {
480
+ const each = rule.fields.map((f) => ({ required: [f] }));
481
+ if (rule.kind === "exactlyOne")
482
+ return { oneOf: each };
483
+ if (rule.kind === "atLeastOne")
484
+ return { anyOf: each };
485
+ const pairs = [];
486
+ for (let i = 0; i < rule.fields.length; i++) {
487
+ for (let j = i + 1; j < rule.fields.length; j++) {
488
+ pairs.push({ required: [rule.fields[i], rule.fields[j]] });
489
+ }
490
+ }
491
+ return { not: pairs.length === 1 ? pairs[0] : { anyOf: pairs } };
492
+ }
493
+ function crossField(schema, rule) {
494
+ const refined = schema.superRefine((value, ctx) => {
495
+ if (crossFieldSatisfied(rule, value))
496
+ return;
497
+ ctx.addIssue({
498
+ code: z2.ZodIssueCode.custom,
499
+ message: crossFieldMessage(rule, crossFieldPresent(rule, value)),
500
+ // The first field of the rule, so the issue has somewhere to point. The
501
+ // message names all of them, which is the part that matters.
502
+ path: [rule.fields[0]]
503
+ });
504
+ });
505
+ Object.defineProperty(refined, CROSS_FIELD_RULE, { value: rule, enumerable: false });
506
+ return refined;
507
+ }
508
+ function crossFieldRuleOf(node) {
509
+ if (!node || typeof node !== "object")
510
+ return void 0;
511
+ return node[CROSS_FIELD_RULE];
512
+ }
513
+ var ARRAY_ELEMENT = "[]";
514
+ function crossFieldRulesIn(schema) {
515
+ const found = [];
516
+ const walk = (node, path10, depth) => {
517
+ const def = node?._def;
518
+ if (!def || depth > 16)
519
+ return;
520
+ switch (def.typeName) {
521
+ case "ZodEffects":
522
+ found.push({ path: [...path10], rule: crossFieldRuleOf(node) });
523
+ return walk(def.schema, path10, depth + 1);
524
+ case "ZodOptional":
525
+ case "ZodDefault":
526
+ case "ZodNullable":
527
+ case "ZodReadonly":
528
+ return walk(def.innerType, path10, depth + 1);
529
+ case "ZodObject": {
530
+ const shape = def.shape();
531
+ for (const key of Object.keys(shape))
532
+ walk(shape[key], [...path10, key], depth + 1);
533
+ return;
534
+ }
535
+ case "ZodArray":
536
+ return walk(def.type, [...path10, ARRAY_ELEMENT], depth + 1);
537
+ case "ZodUnion":
538
+ case "ZodDiscriminatedUnion": {
539
+ const options = def.options;
540
+ const list = Array.isArray(options) ? options : [...options.values()];
541
+ for (const opt of list)
542
+ walk(opt, path10, depth + 1);
543
+ return;
544
+ }
545
+ default:
546
+ return;
547
+ }
548
+ };
549
+ walk(schema, [], 0);
550
+ return found;
551
+ }
552
+ function objectShapeOf(schema) {
553
+ let cur = schema;
554
+ for (let i = 0; i < 16 && cur?._def?.typeName === "ZodEffects"; i++) {
555
+ cur = cur._def.schema;
556
+ }
557
+ const shape = cur?.shape;
558
+ return shape;
559
+ }
434
560
  var ListComps = z2.object({
435
561
  include: includeParam(["size", "timing", "bg", "counts"], "id + name only")
436
562
  }).strict();
@@ -458,6 +584,22 @@ var SetComp = z2.object({
458
584
  });
459
585
  var DeleteComp = z2.object({ compId: z2.number() });
460
586
  var SetActiveComp = z2.object({ compId: z2.number() });
587
+ var DuplicateComp = z2.object({
588
+ compId: z2.number(),
589
+ name: z2.string().optional().describe("Name for the copy. Omit to take AE's own ('<name> 2')."),
590
+ folderId: z2.number().optional().describe("Project folder to put the copy in. Must be a folder item id from get_project_summary."),
591
+ deep: z2.boolean().default(false).optional().describe("Also duplicate the nested precomps and re-point the copy's layers at them. Off by default, which matches AE's own Duplicate: a shallow copy shares its nested comps with the original, so editing one edits both."),
592
+ nameSuffix: z2.string().optional().describe("With deep:true, name each duplicated nested comp '<original><nameSuffix>'. Omit to let AE name them.")
593
+ }).strict();
594
+ var SnapshotComp = z2.object({
595
+ compId: z2.number(),
596
+ includeFingerprint: z2.boolean().default(false).optional().describe("Return the fingerprint itself as well as its id. Off by default \u2014 returning it reintroduces exactly the context cost a snapshot exists to avoid.")
597
+ }).strict();
598
+ var DiffComp = z2.object({
599
+ since: z2.string().describe("A snapshotId from an earlier snapshot_comp or diff_comp."),
600
+ compId: z2.number().optional().describe("Defaults to the comp the snapshot was taken of; pass it only to assert which comp you mean."),
601
+ includeFingerprint: z2.boolean().default(false).optional().describe("Return the new fingerprint as well as the diff. Off by default.")
602
+ }).strict();
461
603
  var ListLayers = z2.object({
462
604
  compId: z2.number(),
463
605
  include: includeParam(["flags", "timing", "parent"], "the id/index/name/type map alone")
@@ -491,7 +633,8 @@ var CreateShapeLayer = z2.object({
491
633
  shapes: z2.array(z2.record(z2.string(), z2.unknown())).default([]),
492
634
  fill: Color.optional(),
493
635
  stroke: Color.optional(),
494
- strokeWidth: z2.number().nonnegative().optional()
636
+ strokeWidth: z2.number().nonnegative().optional(),
637
+ position: z2.union([Vec2, Vec3, z2.literal("center")]).optional().describe("Where the layer's origin goes. Defaults to [0,0], which makes the layer's coordinate space the comp's \u2014 so vertices and shape positions you write afterwards are in comp pixels. 'center' is After Effects' own spawn point (the comp centre), which offsets every path you add by half a frame. The Anchor Point stays at [0,0] either way. A new shape layer is 2D, so use [x,y]; a three-component position needs set_layer threeDLayer:true first.")
495
638
  });
496
639
  var CreateSolidLayer = z2.object({
497
640
  compId: z2.number(),
@@ -524,14 +667,22 @@ var SetLayer = z2.object({
524
667
  shy: z2.boolean().optional(),
525
668
  solo: z2.boolean().optional(),
526
669
  threeDLayer: z2.boolean().optional(),
527
- blendingMode: z2.string().optional(),
670
+ // These two are keys into After Effects' own `BlendingMode` and
671
+ // `TrackMatteType` enumerations, looked up by name in `layers.jsx`. A name
672
+ // that is not in the enumeration is **ignored**, and the call still reports
673
+ // success — so the accepted spelling has to be visible here, and a value you
674
+ // are unsure of has to be read back off the layer rather than assumed.
675
+ blendingMode: z2.string().optional().describe("After Effects' BlendingMode constant name, upper case with underscores: NORMAL, MULTIPLY, SCREEN, OVERLAY, ADD, SUBTRACT, DIFFERENCE, DARKEN, LIGHTEN, COLOR_DODGE, COLOR_BURN, LINEAR_DODGE, LINEAR_BURN, SOFT_LIGHT, HARD_LIGHT, VIVID_LIGHT, LINEAR_LIGHT, PIN_LIGHT, HUE, SATURATION, COLOR, LUMINOSITY, STENCIL_ALPHA, STENCIL_LUMA, ALPHA_ADD, DISSOLVE. A name After Effects does not recognise leaves the mode unchanged without erroring, so read it back with get_layer_full if you are guessing."),
528
676
  label: z2.number().int().min(0).max(16).optional(),
529
677
  inPoint: z2.number().optional(),
530
678
  outPoint: z2.number().optional(),
531
679
  startTime: z2.number().optional(),
532
680
  stretch: z2.number().optional(),
533
681
  preserveTransparency: z2.boolean().optional(),
534
- trackMatte: z2.object({ type: z2.string(), layerId: z2.number().optional() }).optional()
682
+ trackMatte: z2.object({
683
+ type: z2.string().describe("NO_TRACK_MATTE, ALPHA, ALPHA_INVERTED, LUMA or LUMA_INVERTED. Anything else leaves the matte unchanged without erroring."),
684
+ layerId: z2.number().optional()
685
+ }).optional()
535
686
  });
536
687
  var ParentLayer = z2.object({
537
688
  compId: z2.number(),
@@ -539,7 +690,22 @@ var ParentLayer = z2.object({
539
690
  parentLayerId: z2.number().nullable(),
540
691
  preserveTransform: z2.boolean().default(true).optional().describe("Keep the layer visually where it is (what AE's UI does). Leave on unless you want the layer to jump into the parent's coordinate space.")
541
692
  });
542
- var ReorderLayer = z2.object({ compId: z2.number(), layerId: z2.number(), toIndex: z2.number().int().positive() });
693
+ var ReorderLayer = crossField(
694
+ z2.object({
695
+ compId: z2.number(),
696
+ layerId: z2.number(),
697
+ toIndex: z2.number().int().positive().optional().describe("Where the layer ENDS UP: its 1-based index after the move, counting from the front (1 renders in front of everything, numLayers is the back). Clamped to the stack. Prefer beforeLayerId/afterLayerId when you are placing this layer relative to another one \u2014 an index you read earlier may already be stale."),
698
+ beforeLayerId: z2.number().optional().describe("Put this layer directly IN FRONT OF (above) the layer with this id."),
699
+ afterLayerId: z2.number().optional().describe("Put this layer directly BEHIND (below) the layer with this id.")
700
+ }),
701
+ // One destination, resolved before it can reach ExtendScript — two readings of
702
+ // "where does it go" are a contract the schema should never let through.
703
+ {
704
+ kind: "exactlyOne",
705
+ fields: ["toIndex", "beforeLayerId", "afterLayerId"],
706
+ then: "`beforeLayerId`/`afterLayerId` are the safer two: this is the op that shifts every index below it, so an index read before the move may already be stale."
707
+ }
708
+ );
543
709
  var SetTransform = z2.object({
544
710
  compId: z2.number(),
545
711
  layerId: z2.number(),
@@ -584,14 +750,24 @@ var SetInterpolation = z2.object({
584
750
  in: z2.enum(["linear", "bezier", "hold"]).optional(),
585
751
  out: z2.enum(["linear", "bezier", "hold"]).optional()
586
752
  });
587
- var SetTemporalEase = z2.object({
588
- compId: z2.number(),
589
- layerId: z2.number(),
590
- propertyPath: PropertyPath,
591
- keyIndex: z2.number().int().positive(),
592
- easeIn: z2.object({ influence: z2.number(), speed: z2.number() }).optional(),
593
- easeOut: z2.object({ influence: z2.number(), speed: z2.number() }).optional()
594
- });
753
+ var SetTemporalEase = crossField(
754
+ z2.object({
755
+ compId: z2.number(),
756
+ layerId: z2.number(),
757
+ propertyPath: PropertyPath,
758
+ keyIndex: z2.number().int().positive(),
759
+ easeIn: z2.object({ influence: z2.number(), speed: z2.number() }).optional().describe("One influence/speed pair, applied to every dimension of the property. At least one of easeIn/easeOut is required."),
760
+ easeOut: z2.object({ influence: z2.number(), speed: z2.number() }).optional().describe("One influence/speed pair, applied to every dimension of the property.")
761
+ }),
762
+ // `keyframes.jsx` has always refused this, and still does — but it refused
763
+ // from inside After Effects, which meant a round trip and a write lease spent
764
+ // on a call that was never going to change anything.
765
+ {
766
+ kind: "atLeastOne",
767
+ fields: ["easeIn", "easeOut"],
768
+ then: "Each is one {influence, speed} pair; this tool sizes the ease array for the property itself."
769
+ }
770
+ );
595
771
  var SetSpatialTangents = z2.object({
596
772
  compId: z2.number(),
597
773
  layerId: z2.number(),
@@ -607,16 +783,27 @@ var ClearExpression = z2.object({ compId: z2.number(), layerId: z2.number(), pro
607
783
  var ListEffects = z2.object({ compId: z2.number(), layerId: z2.number() });
608
784
  var AddEffect = z2.object({ compId: z2.number(), layerId: z2.number(), matchName: z2.string() });
609
785
  var RemoveEffect = z2.object({ compId: z2.number(), layerId: z2.number(), effectIndex: z2.number().int().positive() });
610
- var SetEffectParam = z2.object({
611
- compId: z2.number(),
612
- layerId: z2.number(),
613
- effectIndex: z2.number().int().positive(),
614
- paramName: z2.string().optional(),
615
- paramMatchName: z2.string().optional(),
616
- value: VecAny,
617
- time: z2.number().optional(),
618
- keyframe: z2.boolean().default(false).optional()
619
- });
786
+ var SetEffectParam = crossField(
787
+ z2.object({
788
+ compId: z2.number(),
789
+ layerId: z2.number(),
790
+ effectIndex: z2.number().int().positive(),
791
+ paramName: z2.string().optional().describe("The parameter's display name as list_effects reports it, e.g. 'Blurriness'."),
792
+ paramMatchName: z2.string().optional().describe("The parameter's matchName, e.g. 'ADBE Gaussian Blur 2-0001'. Tried before paramName when both are given, so it is the one to use when a display name is ambiguous or localised."),
793
+ value: VecAny,
794
+ time: z2.number().optional(),
795
+ keyframe: z2.boolean().default(false).optional()
796
+ }),
797
+ // Neither one is not a call with a default — it is a call that resolves no
798
+ // property at all, and `effects.jsx` answers it with a bare "Effect param not
799
+ // found", which reads like the *name* was wrong rather than absent.
800
+ // Both together is legal and useful: matchName wins, name is the fallback.
801
+ {
802
+ kind: "atLeastOne",
803
+ fields: ["paramName", "paramMatchName"],
804
+ then: "list_effects on the layer reports both for every parameter of every effect."
805
+ }
806
+ );
620
807
  var SetEffectEnabled = z2.object({ compId: z2.number(), layerId: z2.number(), effectIndex: z2.number().int().positive(), enabled: z2.boolean() });
621
808
  var ListAvailableEffects = z2.object({
622
809
  filter: z2.string().optional(),
@@ -755,7 +942,9 @@ var AddMask = z2.object({
755
942
  inTangents: z2.array(Vec2).optional(),
756
943
  outTangents: z2.array(Vec2).optional(),
757
944
  closed: z2.boolean().default(true).optional(),
758
- mode: z2.string().default("ADD").optional()
945
+ // A key into AE's `MaskMode`, looked up by name in `masks.jsx`; an
946
+ // unrecognised one leaves the mode alone rather than erroring.
947
+ mode: z2.string().default("ADD").optional().describe("Mask blend mode: ADD (default), NONE, SUBTRACT, INTERSECT, LIGHTEN, DARKEN or DIFFERENCE. Anything else leaves the mode unchanged without erroring.")
759
948
  });
760
949
  var SetMask = z2.object({
761
950
  compId: z2.number(),
@@ -765,7 +954,7 @@ var SetMask = z2.object({
765
954
  inTangents: z2.array(Vec2).optional(),
766
955
  outTangents: z2.array(Vec2).optional(),
767
956
  closed: z2.boolean().optional(),
768
- mode: z2.string().optional(),
957
+ mode: z2.string().optional().describe("Mask blend mode: ADD, NONE, SUBTRACT, INTERSECT, LIGHTEN, DARKEN or DIFFERENCE. Anything else leaves the mode unchanged without erroring."),
769
958
  inverted: z2.boolean().optional(),
770
959
  expansion: z2.number().optional(),
771
960
  feather: Vec2.optional(),
@@ -788,22 +977,48 @@ var RemoveMarker = z2.object({
788
977
  layerId: z2.number().optional(),
789
978
  markerIndex: z2.number().int().positive()
790
979
  });
791
- var downsampleParam = z2.number().int().min(1).max(8).optional().describe("Render at 1/N resolution. Omit and one is chosen from the comp size (long edge ~1280px: 2 at 1080p, 3 at 4K). Pass 1 for a full-resolution frame.");
792
- var ScreenshotFrame = z2.object({
793
- compId: z2.number(),
794
- time: z2.number().optional(),
795
- downsample: downsampleParam
796
- });
980
+ var downsampleParam = z2.number().int().min(1).max(8).optional().describe("Render at 1/N resolution. Omit and one is chosen from the comp size (long edge ~1280px: 2 at 1080p, 3 at 4K). Pass 1 for a full-resolution frame. The factor is always exactly what you asked for: the render sets the comp's resolution and puts it back, so a viewer left on Quarter or Third does not change the size of the frame you get.");
981
+ var ScreenshotFrame = crossField(
982
+ z2.object({
983
+ compId: z2.number(),
984
+ time: z2.number().optional(),
985
+ /**
986
+ * Several times in one call, returned as one tiled sheet.
987
+ *
988
+ * Judging motion is a single visual question, and answering it used to cost
989
+ * one call per frame — three image blocks resident for the rest of the
990
+ * session, and three chances for After Effects to re-serve a stale buffer.
991
+ * Capped at six because past that each tile is too small to read at the
992
+ * pixel budget of one frame.
993
+ */
994
+ times: z2.array(z2.number()).min(2).max(6).optional().describe("2-6 times to render into one tiled contact sheet, in order, with the time burned into each tile. Cheaper than one call per frame and the whole point of it is judging motion. Mutually exclusive with `time`."),
995
+ downsample: downsampleParam
996
+ }),
997
+ // Enforced here rather than in the panel: two readings of "which frame did
998
+ // you want" reaching ExtendScript at all is a contract the schema should
999
+ // never have let through. `atMostOne` and not `exactlyOne` — neither is a
1000
+ // legal call, and means the comp's current time.
1001
+ {
1002
+ kind: "atMostOne",
1003
+ fields: ["time", "times"],
1004
+ then: "`time` is one frame, `times` is a contact sheet of 2-6 of them; omit both for the comp's current time."
1005
+ }
1006
+ );
797
1007
  var ScreenshotLayer = z2.object({
798
1008
  compId: z2.number(),
799
1009
  layerId: z2.number(),
800
1010
  time: z2.number().optional(),
801
1011
  downsample: downsampleParam
802
1012
  });
1013
+ var diffParam = z2.boolean().default(false).optional().describe("Fingerprint the comp before and after this call and append only what changed (layers added/removed/renamed/retimed/re-parented, keyframe counts, expression and effect counts). A few dozen tokens instead of reading the comp back. If the call fails, the diff of what landed before it stopped is appended to the error.");
1014
+ var diffCompIdParam = z2.number().optional().describe("Which comp `diff` should fingerprint. Defaults to the comps this call names, else the comp open in the viewer.");
803
1015
  var RunBatch = z2.object({
804
1016
  ops: z2.array(z2.object({ op: z2.string(), args: z2.unknown() })),
805
- transactional: z2.boolean().default(true).optional(),
806
- undoGroupName: z2.string().default("AE MCP Batch").optional()
1017
+ transactional: z2.boolean().default(true).optional().describe("Stop at the first failing op instead of running the rest. Nothing rolls back either way \u2014 the ops before the failure stay applied, and the error says where it stopped."),
1018
+ undoGroupName: z2.string().default("AE MCP Batch").optional().describe(`What the user sees in After Effects' Edit > Undo menu. A chunked batch numbers its steps from this, e.g. "AE MCP Batch (3)".`),
1019
+ singleUndo: z2.boolean().default(false).optional().describe("Force the whole batch into ONE undo step, whatever its size, by running it in a single blocking ExtendScript call. Up to 2000 ops. The cost is real: After Effects' interface is frozen for the entire batch and no progress is reported, so the user sees nothing until it finishes. Without it a batch over 500 ops is chunked and lands as one undo step per chunk of 25 \u2014 the result reports the exact count. Reach for this only when the user has to be able to undo the work with a single Cmd-Z."),
1020
+ diff: diffParam,
1021
+ diffCompId: diffCompIdParam
807
1022
  });
808
1023
  var GetProjectSummary = z2.object({}).strict();
809
1024
  var FindLayers = z2.object({
@@ -812,10 +1027,26 @@ var FindLayers = z2.object({
812
1027
  type: z2.string().optional(),
813
1028
  hasEffectMatchName: z2.string().optional()
814
1029
  });
815
- var RunJsx = z2.object({
816
- code: z2.string(),
817
- undoGroup: z2.boolean().default(true).optional().describe("Wrap the script in one undo step. Set false only for the operations AE refuses while an undo group is open \u2014 copyToComp on a layer with a parent or a linked expression. The script's changes then land as whatever undo steps AE records on its own.")
818
- });
1030
+ var RunJsx = crossField(
1031
+ z2.object({
1032
+ code: z2.string().optional().describe("The ExtendScript to run. Exactly one of `code` or `scriptPath`."),
1033
+ scriptPath: z2.string().optional().describe("Absolute path to a .jsx file to run instead of `code`. The server reads it, so a long script never enters the conversation. Exactly one of `code` or `scriptPath`."),
1034
+ libraries: z2.array(z2.string()).optional().describe("Absolute paths to .jsx files inlined ahead of the script, sharing its scope, so their functions are callable from it. The server reads them, so their text never enters the conversation. They are re-evaluated on every call \u2014 keep them to declarations, not to work. Put shared helpers here rather than pasting them into every script."),
1035
+ undoGroup: z2.boolean().default(true).optional().describe("Wrap the script in one undo step. Set false only for the operations AE refuses while an undo group is open \u2014 copyToComp on a layer with a parent or a linked expression. The script's changes then land as whatever undo steps AE records on its own."),
1036
+ diff: diffParam,
1037
+ diffCompId: diffCompIdParam
1038
+ }),
1039
+ // `resolveRunJsxSource` refuses both and neither too, and keeps doing so —
1040
+ // it is reachable from `run_batch`, whose steps are never zod-validated. What
1041
+ // the rule buys here is the half that runs *before* the call: declared, it
1042
+ // reaches the emitted JSON Schema, so the model sees the choice rather than
1043
+ // discovering it.
1044
+ {
1045
+ kind: "exactlyOne",
1046
+ fields: ["code", "scriptPath"],
1047
+ then: "Prefer `scriptPath` for anything long \u2014 the server reads the file, so the script never enters the conversation."
1048
+ }
1049
+ );
819
1050
  var ImportFootage = z2.object({
820
1051
  path: z2.string().min(1).describe("Absolute path to the file to import."),
821
1052
  name: z2.string().optional().describe("Rename the project item after import. Omit to keep the filename."),
@@ -829,6 +1060,22 @@ var CreateFootageLayer = z2.object({
829
1060
  position: VecAny.optional(),
830
1061
  startTime: z2.number().optional()
831
1062
  });
1063
+ var AudioCue = z2.object({
1064
+ footageId: z2.number().optional().describe("Project item id of an already-imported sound. Give this or `path`, not both."),
1065
+ path: z2.string().min(1).optional().describe("Absolute path to a sound file. Imported once per call however many cues name it, and an item already in the project from that path is reused rather than imported again."),
1066
+ time: z2.number().describe("Comp time in seconds where the cue starts."),
1067
+ levelDb: z2.number().optional().describe("Level in decibels, the same unit After Effects shows. 0 is the file untouched, -6 is roughly half as loud, negative is quieter. Defaults to 0, written explicitly so the result is the same on every machine."),
1068
+ name: z2.string().optional().describe("Layer name. Defaults to namePrefix + the file's basename without its extension."),
1069
+ inPoint: z2.number().optional().describe("Trim the layer's in point to this COMP time. Must not be earlier than `time`. Omit to play from the start of the file."),
1070
+ outPoint: z2.number().optional().describe("Trim the layer's out point to this COMP time. Omit to play to the end of the file."),
1071
+ label: z2.union([z2.number().int().min(0).max(16), z2.string()]).optional().describe("AE label colour, as an index 0-16 or a name (red, yellow, aqua, pink, lavender, peach, seafoam, blue, green, purple, orange, brown, fuchsia, cyan, sandstone, darkgreen).")
1072
+ }).strict();
1073
+ var PlaceAudioCues = z2.object({
1074
+ compId: z2.number(),
1075
+ cues: z2.array(AudioCue).min(1).max(200),
1076
+ namePrefix: z2.string().default("SFX_").optional().describe('Prefix for cues that do not name themselves. Pass "" for no prefix.'),
1077
+ dryRun: z2.boolean().default(false).optional().describe("Resolve and check the whole list without importing, creating or changing anything \u2014 not even an undo step. Reports which paths do not exist and what would be placed.")
1078
+ }).strict();
832
1079
  var ExportMogrt = z2.object({
833
1080
  compId: z2.number(),
834
1081
  destDir: z2.string().optional().describe("Folder to write the .mogrt into. Defaults to the folder holding the .aep."),
@@ -837,7 +1084,9 @@ var ExportMogrt = z2.object({
837
1084
  posterTime: z2.number().optional().describe("Comp time to render as the template's still thumbnail, replacing the black one AE writes. Omit to leave AE's thumbnail alone."),
838
1085
  suppressDialogs: z2.boolean().default(true).optional().describe("Suppress the modal font warning during export. Leave true: an unsuppressed dialog freezes the bridge until someone clicks it in AE. Set false only to see the dialog deliberately.")
839
1086
  }).strict();
840
- var GetHouseStyle = z2.object({}).strict();
1087
+ var GetHouseStyle = z2.object({
1088
+ detail: z2.enum(["summary", "full"]).default("summary").optional().describe("'summary' (default) is a few hundred tokens: palette as named hexes, type, motion defaults, layout rules, and what it could not summarise. 'full' returns the whole document \u2014 use it before editing the guide with set_house_style, or when the summary is not enough.")
1089
+ }).strict();
841
1090
  var SetHouseStyle = z2.object({
842
1091
  content: z2.string().min(1).describe("The complete style guide as markdown. Replaces the file, so send the whole document."),
843
1092
  overwrite: z2.boolean().default(false).optional().describe("Required to replace an existing guide. Read it with get_house_style and merge first \u2014 this is not a patch.")
@@ -847,9 +1096,9 @@ var SetupPanel = z2.object({
847
1096
  enableDebugMode: z2.boolean().default(true).optional().describe("Also enable Adobe's PlayerDebugMode preference, which AE requires to load this unsigned panel. Default true."),
848
1097
  force: z2.boolean().default(false).optional().describe("Replace an existing symlinked (development) install with a copy. Default false.")
849
1098
  }).strict();
850
- var GUIDE_TOPICS = ["ae-setup", "after-effects", "style-guide"];
1099
+ var GUIDE_TOPICS = ["ae-setup", "after-effects", "extendscript-gotchas", "style-guide", "whats-new"];
851
1100
  var AeGuide = z2.object({
852
- topic: z2.enum(GUIDE_TOPICS).describe("after-effects: building, animating, easing, expressions, the traps. style-guide: capturing the user's look. ae-setup: connecting to AE when a tool cannot reach it.")
1101
+ topic: z2.enum(GUIDE_TOPICS).describe("after-effects: building, animating, easing, expressions, the traps \u2014 start here. extendscript-gotchas: read before writing raw ExtendScript for run_jsx. whats-new: what changed recently, when a call behaves differently from what you expected. style-guide: capturing the user's look. ae-setup: connecting to AE when a tool cannot reach it.")
853
1102
  }).strict();
854
1103
  var InitProject = z2.object({
855
1104
  dir: z2.string().optional().describe("Folder to create or fill, absolute or relative to the server's working directory. Ask the user if you do not know; do not invent one."),
@@ -862,17 +1111,20 @@ var LogIssue = z2.object({
862
1111
  symptom: z2.string().min(3).describe("What went wrong, including the exact error text and the call that produced it."),
863
1112
  workaround: z2.string().min(3).describe("What actually worked \u2014 concrete enough for the next session to apply without rediscovering it."),
864
1113
  cause: z2.string().optional().describe("Why it happens, if you worked it out."),
865
- tools: z2.array(z2.string()).optional().describe("Tool names involved, e.g. ['set_temporal_ease'].")
1114
+ tools: z2.array(z2.string()).optional().describe("Tool names involved, e.g. ['set_temporal_ease']."),
1115
+ scope: z2.enum(["project", "user"]).default("project").optional().describe("'project' (default) for this project's footage, comps or files. 'user' for how these tools or After Effects behave \u2014 that journal travels with the person, so every future project starts knowing it. Reported back as 'home' when there is no project folder to write into.")
866
1116
  }).strict();
867
1117
  var ListKnownIssues = z2.object({
868
1118
  status: z2.enum(["all", "unreported", "reported"]).default("all").optional(),
869
1119
  tool: z2.string().optional().describe("Only entries about this tool, e.g. 'set_temporal_ease'. Omit for everything."),
870
1120
  query: z2.string().optional().describe("Free-text filter: every whitespace-separated term must appear in an entry's title, symptom or tools."),
871
- id: z2.string().optional().describe("Read one entry in full \u2014 cause and workaround included \u2014 by the id from a previous listing. Ignores the filters."),
872
- detail: z2.enum(["index", "full"]).default("index").optional().describe("'index' (default) is one line per entry: id, title, tools, counts and a one-line summary \u2014 read the one you need with `id`. 'full' returns every matching entry's whole body and costs thousands of tokens.")
1121
+ id: z2.string().optional().describe("Read one entry in full \u2014 cause and workaround included \u2014 by the id from a previous listing. Ignores the filters. Ids are unique only within a journal, so prefix with the entry's scope ('user:my-entry') when the listing shows one in each."),
1122
+ detail: z2.enum(["index", "full"]).default("index").optional().describe("'index' (default) is one line per entry: id, title, tools, counts and a one-line summary \u2014 read the one you need with `id`. 'full' returns every matching entry's whole body and costs thousands of tokens."),
1123
+ scope: z2.enum(["all", "project", "user"]).default("all").optional().describe("Which journal to read. 'all' (default) merges this project's with the user's cross-project one and tags every entry with the scope it came from."),
1124
+ limit: z2.number().int().positive().max(500).default(50).optional().describe("Most entries to return. Anything held back is counted in `omitted`.")
873
1125
  }).strict();
874
1126
  var MarkIssueReported = z2.object({
875
- id: z2.string().describe("The entry id returned by log_issue or list_known_issues."),
1127
+ id: z2.string().describe("The entry id returned by log_issue or list_known_issues. Prefix with its scope ('user:my-entry') when the same id exists in both journals."),
876
1128
  url: z2.string().optional().describe("Link to the issue that was opened.")
877
1129
  }).strict();
878
1130
  var AwaitJob = z2.object({ jobId: z2.string(), timeoutMs: z2.number().int().positive().default(6e5).optional() });
@@ -887,6 +1139,9 @@ var OpSchemas = {
887
1139
  set_comp: SetComp,
888
1140
  delete_comp: DeleteComp,
889
1141
  set_active_comp: SetActiveComp,
1142
+ duplicate_comp: DuplicateComp,
1143
+ snapshot_comp: SnapshotComp,
1144
+ diff_comp: DiffComp,
890
1145
  // layers
891
1146
  list_layers: ListLayers,
892
1147
  get_layer_full: GetLayerFull,
@@ -949,6 +1204,8 @@ var OpSchemas = {
949
1204
  // footage
950
1205
  import_footage: ImportFootage,
951
1206
  create_footage_layer: CreateFootageLayer,
1207
+ // audio
1208
+ place_audio_cues: PlaceAudioCues,
952
1209
  // motion graphics templates
953
1210
  export_mogrt: ExportMogrt,
954
1211
  // raw
@@ -971,6 +1228,109 @@ var OpSchemas = {
971
1228
  list_known_issues: ListKnownIssues,
972
1229
  mark_issue_reported: MarkIssueReported
973
1230
  };
1231
+ var OpMutation = {
1232
+ // comps
1233
+ list_comps: "read",
1234
+ get_comp: "read",
1235
+ get_comp_tree: "read",
1236
+ create_comp: "write",
1237
+ set_comp: "write",
1238
+ delete_comp: "write",
1239
+ set_active_comp: "write",
1240
+ duplicate_comp: "write",
1241
+ // Fingerprints: they forward a read to the panel and keep the answer in the
1242
+ // server. Nothing is written to the project or the undo stack, so they must
1243
+ // not queue — the point of a diff is checking on a write that is in flight.
1244
+ snapshot_comp: "read",
1245
+ diff_comp: "read",
1246
+ // layers
1247
+ list_layers: "read",
1248
+ get_layer_full: "read",
1249
+ create_text_layer: "write",
1250
+ create_shape_layer: "write",
1251
+ create_solid_layer: "write",
1252
+ create_null_layer: "write",
1253
+ create_adjustment_layer: "write",
1254
+ create_precomp_layer: "write",
1255
+ create_camera_layer: "write",
1256
+ create_light_layer: "write",
1257
+ duplicate_layer: "write",
1258
+ delete_layer: "write",
1259
+ set_layer: "write",
1260
+ parent_layer: "write",
1261
+ reorder_layer: "write",
1262
+ // transforms
1263
+ set_transform: "write",
1264
+ // keyframes
1265
+ add_keyframe: "write",
1266
+ remove_keyframe: "write",
1267
+ get_keyframes: "read",
1268
+ set_interpolation: "write",
1269
+ set_temporal_ease: "write",
1270
+ set_spatial_tangents: "write",
1271
+ // expressions
1272
+ get_expression: "read",
1273
+ set_expression: "write",
1274
+ toggle_expression: "write",
1275
+ clear_expression: "write",
1276
+ // effects
1277
+ list_effects: "read",
1278
+ add_effect: "write",
1279
+ remove_effect: "write",
1280
+ set_effect_param: "write",
1281
+ set_effect_enabled: "write",
1282
+ list_available_effects: "read",
1283
+ // text
1284
+ set_text: "write",
1285
+ add_text_animator: "write",
1286
+ // shapes
1287
+ set_shape_path: "write",
1288
+ add_shape_content: "write",
1289
+ set_shape_property: "write",
1290
+ // masks
1291
+ add_mask: "write",
1292
+ set_mask: "write",
1293
+ remove_mask: "write",
1294
+ // markers
1295
+ add_marker: "write",
1296
+ remove_marker: "write",
1297
+ // vision — read-only despite being the slowest thing here. `screenshot_*`
1298
+ // borrows the comp's resolutionFactor and restores it in a `finally`;
1299
+ // nothing in the project changes.
1300
+ screenshot_frame: "read",
1301
+ screenshot_layer: "read",
1302
+ // batch
1303
+ run_batch: "write",
1304
+ // explore
1305
+ get_project_summary: "read",
1306
+ find_layers: "read",
1307
+ // footage
1308
+ import_footage: "write",
1309
+ create_footage_layer: "write",
1310
+ // audio cues — imports footage and adds layers.
1311
+ place_audio_cues: "write",
1312
+ // motion graphics templates — saves the project before exporting.
1313
+ export_mogrt: "write",
1314
+ // raw — the script is the caller's and may do anything. Assume the worst.
1315
+ run_jsx: "write",
1316
+ // house style — written over the bridge into a file beside the .aep.
1317
+ get_house_style: "read",
1318
+ set_house_style: "write",
1319
+ // jobs
1320
+ await_job: "server",
1321
+ get_job: "server",
1322
+ cancel_job: "server",
1323
+ // setup
1324
+ check_setup: "server",
1325
+ setup_panel: "server",
1326
+ init_project: "server",
1327
+ // guidance
1328
+ ae_guide: "server",
1329
+ // issue journal
1330
+ log_issue: "server",
1331
+ list_known_issues: "server",
1332
+ mark_issue_reported: "server"
1333
+ };
974
1334
 
975
1335
  // src/util/errors.ts
976
1336
  var BridgeUnreachableError = class _BridgeUnreachableError extends Error {
@@ -1044,17 +1404,131 @@ var AeError = class extends Error {
1044
1404
  * already read as complete instructions, so the caller uses this to decide
1045
1405
  * whether an `AE:` prefix would help or just obscure them.
1046
1406
  */
1047
- constructor(message, stack_, line, code) {
1407
+ constructor(message, stack_, line, code, source) {
1048
1408
  super(message);
1049
1409
  this.stack_ = stack_;
1050
1410
  this.line = line;
1051
1411
  this.code = code;
1412
+ this.source = source;
1052
1413
  this.name = "AeError";
1053
1414
  }
1054
1415
  stack_;
1055
1416
  line;
1056
1417
  code;
1418
+ source;
1419
+ };
1420
+ function aeErrorText(e) {
1421
+ const head = `AE: ${e.message}`;
1422
+ const s = e.source;
1423
+ if (!s) return e.line ? `${head} (line ${e.line})` : head;
1424
+ const where = s.sourceName ?? "the script you submitted";
1425
+ const total = s.lineCount ? `, ${s.lineCount} lines` : "";
1426
+ const lines = [head];
1427
+ if (s.sourceLine) {
1428
+ lines.push(` at line ${s.sourceLine} of ${where}${total}:`);
1429
+ if (s.sourceText) lines.push(` ${s.sourceText}`);
1430
+ if (s.rawLine != null && s.rawLine !== s.sourceLine) {
1431
+ lines.push(` (After Effects reported line ${s.rawLine}.)`);
1432
+ }
1433
+ } else if (s.rawLine != null) {
1434
+ lines.push(
1435
+ ` After Effects reported line ${s.rawLine}, which does not fall inside ${where}${total} \u2014 trust the message, not the number.`
1436
+ );
1437
+ }
1438
+ lines.push(
1439
+ " Everything before the failure already ran and nothing rolls back: read the state back rather than re-running the script."
1440
+ );
1441
+ return lines.join("\n");
1442
+ }
1443
+ var WriteQueueWaitError = class _WriteQueueWaitError extends Error {
1444
+ constructor(op, waitedMs, behind) {
1445
+ super(_WriteQueueWaitError.message(op, waitedMs, behind));
1446
+ this.op = op;
1447
+ this.waitedMs = waitedMs;
1448
+ this.behind = behind;
1449
+ this.name = "WriteQueueWaitError";
1450
+ }
1451
+ op;
1452
+ waitedMs;
1453
+ behind;
1454
+ static message(op, waitedMs, behind) {
1455
+ const secs = Math.round(waitedMs / 1e3);
1456
+ return [
1457
+ `\`${op}\` waited ${secs}s behind \`${behind}\` for the After Effects write queue and was dropped without running.`,
1458
+ "",
1459
+ "This is neither a lost connection nor a busy bridge. The panel is fine and this",
1460
+ "call never reached After Effects, so nothing in the project was changed.",
1461
+ "",
1462
+ "Writes are serialized because After Effects runs one script at a time and applies",
1463
+ "changes in the order it receives them; two in flight block each other and land in",
1464
+ "an order nobody chose. Something in front of this call is taking a very long time",
1465
+ "\u2014 most often a long run_batch, occasionally a modal dialog in After Effects",
1466
+ "blocking the script in front.",
1467
+ "",
1468
+ "What to do, in order:",
1469
+ "1. Nothing was written, so re-sending this call is safe \u2014 unlike a bridge timeout.",
1470
+ " Wait for the work in front to finish first, or it will just queue again.",
1471
+ "2. Find out what is in front: get_job or await_job for a batch. Reads are not",
1472
+ " queued, so list_/get_ calls still work and will tell you the current state.",
1473
+ "3. Ask the user to look at After Effects for a dialog nobody has clicked.",
1474
+ `4. If the work in front is legitimately this long, raise the limit: start the`,
1475
+ " server with AE_MCP_WRITE_QUEUE_WAIT_MS set to a larger number of milliseconds."
1476
+ ].join("\n");
1477
+ }
1478
+ };
1479
+ var WriteQueueFullError = class _WriteQueueFullError extends Error {
1480
+ constructor(op, maxDepth, behind) {
1481
+ super(_WriteQueueFullError.message(op, maxDepth, behind));
1482
+ this.op = op;
1483
+ this.maxDepth = maxDepth;
1484
+ this.behind = behind;
1485
+ this.name = "WriteQueueFullError";
1486
+ }
1487
+ op;
1488
+ maxDepth;
1489
+ behind;
1490
+ static message(op, maxDepth, behind) {
1491
+ return [
1492
+ `The After Effects write queue is full \u2014 ${maxDepth} calls are already waiting, so \`${op}\` was refused.`,
1493
+ "",
1494
+ `Nothing was written. Writes are serialized so they land in the order you sent them,`,
1495
+ `and \`${behind}\` is holding the queue up.`,
1496
+ "",
1497
+ "Stop issuing writes and let it drain \u2014 reads are not queued, so list_/get_ calls",
1498
+ "still work. If you have this much independent work to do, send it as one",
1499
+ "run_batch instead of as many calls: that is one ExtendScript pass and one place in",
1500
+ "the queue, and its result reports how many undo steps it actually made.",
1501
+ "AE_MCP_WRITE_QUEUE_DEPTH raises the limit if you really need it raised."
1502
+ ].join("\n");
1503
+ }
1057
1504
  };
1505
+ var WriteQueueCancelledError = class extends Error {
1506
+ constructor(op) {
1507
+ super(`\`${op}\` was cancelled while waiting for the After Effects write queue. It never ran, and nothing was changed.`);
1508
+ this.op = op;
1509
+ this.name = "WriteQueueCancelledError";
1510
+ }
1511
+ op;
1512
+ };
1513
+ function invalidArgsText(op, e) {
1514
+ const issues = e?.issues;
1515
+ if (!Array.isArray(issues) || issues.length === 0) {
1516
+ return `Invalid arguments for ${op}: ${e.message}`;
1517
+ }
1518
+ const lines = issues.map((raw) => {
1519
+ const issue = raw;
1520
+ const where = Array.isArray(issue.path) && issue.path.length ? issue.path.join(".") : null;
1521
+ let text = issue.message ?? "invalid";
1522
+ if (issue.code === "invalid_type" && issue.received === "undefined") {
1523
+ text = "required, and was not passed";
1524
+ } else if (issue.code === "invalid_type" && issue.expected) {
1525
+ text = `expected ${issue.expected}, got ${issue.received ?? "something else"}`;
1526
+ }
1527
+ return where ? ` - ${where}: ${text}` : ` - ${text}`;
1528
+ });
1529
+ const head = issues.length === 1 ? `Invalid arguments for ${op}:` : `Invalid arguments for ${op} (${issues.length} problems):`;
1530
+ return [head, ...lines].join("\n");
1531
+ }
1058
1532
 
1059
1533
  // src/util/logger.ts
1060
1534
  var logger = {
@@ -1091,7 +1565,7 @@ function discoverPort() {
1091
1565
 
1092
1566
  // src/bridge/httpClient.ts
1093
1567
  var DEFAULT_OP_TIMEOUT_MS = 12e4;
1094
- var SLOW_OPS = /* @__PURE__ */ new Set(["run_batch", "run_jsx", "screenshot_frame", "screenshot_layer", "export_mogrt", "import_footage"]);
1568
+ var SLOW_OPS = /* @__PURE__ */ new Set(["run_batch", "run_jsx", "screenshot_frame", "screenshot_layer", "export_mogrt", "import_footage", "place_audio_cues"]);
1095
1569
  var SLOW_OP_TIMEOUT_MS = 3e5;
1096
1570
  function opTimeoutMs(op) {
1097
1571
  const raw = process.env.AE_MCP_OP_TIMEOUT_MS?.trim();
@@ -1145,7 +1619,7 @@ var HttpClient = class {
1145
1619
  throw new AeError(`Bridge returned non-JSON (HTTP ${resp.status})`);
1146
1620
  }
1147
1621
  if (!data.ok) {
1148
- throw new AeError(data.error, data.stack, data.line, data.code);
1622
+ throw new AeError(data.error, data.stack, data.line, data.code, data.source);
1149
1623
  }
1150
1624
  return data.result;
1151
1625
  }
@@ -1225,6 +1699,168 @@ var WsClient = class {
1225
1699
  }
1226
1700
  };
1227
1701
 
1702
+ // src/bridge/writeQueue.ts
1703
+ var DEFAULT_MAX_WAIT_MS = 6e5;
1704
+ var DEFAULT_MAX_DEPTH = 64;
1705
+ function queueWaitMs() {
1706
+ return envNumber("AE_MCP_WRITE_QUEUE_WAIT_MS", DEFAULT_MAX_WAIT_MS);
1707
+ }
1708
+ function queueMaxDepth() {
1709
+ return envNumber("AE_MCP_WRITE_QUEUE_DEPTH", DEFAULT_MAX_DEPTH);
1710
+ }
1711
+ function envNumber(name, fallback) {
1712
+ const raw = process.env[name]?.trim();
1713
+ if (!raw) return fallback;
1714
+ const n = Number.parseInt(raw, 10);
1715
+ if (Number.isFinite(n) && n > 0) return n;
1716
+ logger.warn(`Ignoring ${name}=${raw} \u2014 expected a positive number.`);
1717
+ return fallback;
1718
+ }
1719
+ var WriteQueue = class {
1720
+ holder = null;
1721
+ waiting = [];
1722
+ maxWaitMs;
1723
+ maxDepth;
1724
+ constructor(opts = {}) {
1725
+ this.maxWaitMs = opts.maxWaitMs ?? queueWaitMs();
1726
+ this.maxDepth = opts.maxDepth ?? queueMaxDepth();
1727
+ }
1728
+ /** For tests and diagnostics. */
1729
+ get depth() {
1730
+ return this.waiting.filter((w) => !w.done).length;
1731
+ }
1732
+ get held() {
1733
+ return this.holder;
1734
+ }
1735
+ /**
1736
+ * The longest a lease may be held past its call by `extendUntil` — a leak
1737
+ * guard for a job that never reports completion (a dropped WS, say).
1738
+ *
1739
+ * Twice the wait ceiling on purpose. Anything already queued behind that job
1740
+ * has hit its own deadline and gone by then, so expiring the hold can never
1741
+ * hand the lock to a writer while the batch is still going.
1742
+ */
1743
+ get holdCeilingMs() {
1744
+ return this.maxWaitMs * 2;
1745
+ }
1746
+ /**
1747
+ * Wait for the lock, then return the lease.
1748
+ *
1749
+ * Rejects rather than resolving late when the request was cancelled, when the
1750
+ * wait ran past `maxWaitMs`, or when the queue is full. In every one of those
1751
+ * cases the caller must not go on to hit the bridge — nothing was written,
1752
+ * and the error says so, which is what makes re-sending safe there and unsafe
1753
+ * after a bridge timeout.
1754
+ */
1755
+ acquire(op, signal) {
1756
+ if (signal?.aborted) {
1757
+ return Promise.reject(new WriteQueueCancelledError(op));
1758
+ }
1759
+ if (this.holder === null && this.depth === 0) {
1760
+ this.holder = op;
1761
+ return Promise.resolve(this.makeLease(null));
1762
+ }
1763
+ if (this.depth >= this.maxDepth) {
1764
+ return Promise.reject(new WriteQueueFullError(op, this.maxDepth, this.holder ?? "another write"));
1765
+ }
1766
+ const behind = this.holder ?? this.waiting.find((w) => !w.done)?.op ?? "another write";
1767
+ return new Promise((resolve, reject) => {
1768
+ const waiter = {
1769
+ op,
1770
+ enqueuedAt: Date.now(),
1771
+ behind,
1772
+ done: false,
1773
+ settle: resolve,
1774
+ fail: reject,
1775
+ cleanup: () => {
1776
+ }
1777
+ };
1778
+ const timer = setTimeout(() => {
1779
+ if (waiter.done) return;
1780
+ waiter.done = true;
1781
+ waiter.cleanup();
1782
+ reject(new WriteQueueWaitError(op, this.maxWaitMs, waiter.behind));
1783
+ this.pump();
1784
+ }, this.maxWaitMs);
1785
+ timer.unref?.();
1786
+ const onAbort = () => {
1787
+ if (waiter.done) return;
1788
+ waiter.done = true;
1789
+ waiter.cleanup();
1790
+ reject(new WriteQueueCancelledError(op));
1791
+ this.pump();
1792
+ };
1793
+ waiter.cleanup = () => {
1794
+ clearTimeout(timer);
1795
+ signal?.removeEventListener("abort", onAbort);
1796
+ };
1797
+ signal?.addEventListener("abort", onAbort, { once: true });
1798
+ this.waiting.push(waiter);
1799
+ });
1800
+ }
1801
+ makeLease(wait) {
1802
+ let released = false;
1803
+ let hold = null;
1804
+ const queue = this;
1805
+ return {
1806
+ wait,
1807
+ extendUntil(p) {
1808
+ hold = p;
1809
+ if (released) settleHold();
1810
+ },
1811
+ release() {
1812
+ if (released) return;
1813
+ released = true;
1814
+ if (hold) settleHold();
1815
+ else queue.handOff();
1816
+ }
1817
+ };
1818
+ function settleHold() {
1819
+ const p = hold;
1820
+ hold = null;
1821
+ if (!p) return;
1822
+ const ceiling = queue.holdCeilingMs;
1823
+ let fired = false;
1824
+ const done = () => {
1825
+ if (fired) return;
1826
+ fired = true;
1827
+ queue.handOff();
1828
+ };
1829
+ const t = setTimeout(() => {
1830
+ if (fired) return;
1831
+ logger.warn(
1832
+ `A write held the After Effects queue for over ${Math.round(ceiling / 1e3)}s without its job reporting completion; releasing it.`
1833
+ );
1834
+ done();
1835
+ }, ceiling);
1836
+ t.unref?.();
1837
+ p.then(done, done).finally(() => clearTimeout(t));
1838
+ }
1839
+ }
1840
+ handOff() {
1841
+ this.holder = null;
1842
+ this.pump();
1843
+ }
1844
+ pump() {
1845
+ if (this.holder !== null) return;
1846
+ while (this.waiting.length > 0) {
1847
+ const next = this.waiting.shift();
1848
+ if (next.done) continue;
1849
+ next.done = true;
1850
+ next.cleanup();
1851
+ this.holder = next.op;
1852
+ next.settle(this.makeLease({ queuedBehind: next.behind, waitedMs: Date.now() - next.enqueuedAt }));
1853
+ return;
1854
+ }
1855
+ }
1856
+ };
1857
+ function mergeWait(value, wait) {
1858
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
1859
+ const obj = value;
1860
+ if ("queuedBehind" in obj || "waitedMs" in obj) return null;
1861
+ return { ...obj, ...wait };
1862
+ }
1863
+
1228
1864
  // src/jobs/manager.ts
1229
1865
  var JobManager = class {
1230
1866
  jobs = /* @__PURE__ */ new Map();
@@ -1324,6 +1960,54 @@ var JobManager = class {
1324
1960
  }
1325
1961
  };
1326
1962
 
1963
+ // src/snapshots/store.ts
1964
+ var DEFAULT_MAX = 32;
1965
+ var SnapshotStore = class {
1966
+ constructor(max = DEFAULT_MAX) {
1967
+ this.max = max;
1968
+ }
1969
+ max;
1970
+ snapshots = /* @__PURE__ */ new Map();
1971
+ seq = 0;
1972
+ store(fingerprint) {
1973
+ this.seq += 1;
1974
+ const snapshot = {
1975
+ id: `snap_${this.seq}`,
1976
+ compId: fingerprint.compId,
1977
+ compName: fingerprint.name,
1978
+ layerCount: Array.isArray(fingerprint.layers) ? fingerprint.layers.length : 0,
1979
+ takenAt: Date.now(),
1980
+ fingerprint
1981
+ };
1982
+ this.snapshots.set(snapshot.id, snapshot);
1983
+ while (this.snapshots.size > this.max) {
1984
+ const oldest = this.snapshots.keys().next();
1985
+ if (oldest.done) break;
1986
+ this.snapshots.delete(oldest.value);
1987
+ }
1988
+ return snapshot;
1989
+ }
1990
+ get(id) {
1991
+ return this.snapshots.get(id);
1992
+ }
1993
+ size() {
1994
+ return this.snapshots.size;
1995
+ }
1996
+ ids() {
1997
+ return [...this.snapshots.keys()];
1998
+ }
1999
+ /**
2000
+ * Why an id is not here, and what to do instead. An agent hitting this has
2001
+ * already done the work it wanted to verify, so "unknown snapshot" on its own
2002
+ * would strand it.
2003
+ */
2004
+ missingMessage(id) {
2005
+ const held = this.ids();
2006
+ const listing = held.length === 0 ? "No snapshots are held in this session yet." : `Held right now: ${held.join(", ")}.`;
2007
+ return `No snapshot "${id}". Snapshots live in this server's memory for the length of the session: they are gone when the MCP server restarts, and the oldest is dropped once ${this.max} are held. ${listing} Take a fresh one with snapshot_comp, do the work, then diff against that id. To verify a write you have already made, read the comp back with list_layers instead \u2014 a diff can only compare against a snapshot taken beforehand.`;
2008
+ }
2009
+ };
2010
+
1327
2011
  // src/tools/descriptions.ts
1328
2012
  var descriptions = {
1329
2013
  // ---------- comps ----------
@@ -1334,11 +2018,14 @@ var descriptions = {
1334
2018
  set_comp: "Modify a comp (name, dims, fps, duration, work area, bg). Undefined fields unchanged.",
1335
2019
  delete_comp: "Delete a comp. Reversible only via AE's Undo.",
1336
2020
  set_active_comp: "Focus a comp in the viewer/timeline.",
2021
+ duplicate_comp: "Copy a comp. Returns the new comp id, so you never have to find it by name. Use this instead of run_jsx + CompItem.duplicate(). By default the copy is SHALLOW, exactly like AE's own Duplicate: its precomp layers point at the same nested comps as the original, so editing one of those edits both. `deep:true` duplicates the nested comps too and re-points the copy at them \u2014 that is what 'a variant of this rig' means. A nested comp used by several layers is duplicated once and reused. `folderId` files the copy in a project folder; `nameSuffix` names the nested copies '<original><suffix>'.",
2022
+ snapshot_comp: "Take a cheap structural fingerprint of a comp and keep it in this server's memory. Returns a snapshotId and nothing else worth tokens \u2014 call it BEFORE a write, then diff_comp afterwards to learn what the write actually did, instead of reading the comp back and comparing by eye. It records per layer: id, name, index, type, in/out/start, parent, enabled, keyframe counts, expression count, effect count; per comp: size, duration, frame rate, work area, markers. It does NOT record property values, expression text, effect parameters, masks or shape contents. Nothing is written into the AE project, and snapshots are gone when the session ends.",
2023
+ diff_comp: "What changed in a comp since a snapshot: layers added, removed, renamed, retimed, re-parented, keyframe counts that moved, expressions and effects gained or lost \u2014 and nothing at all for unchanged layers, which are only counted. A few dozen tokens where list_layers + get_layer_full is thousands, and it answers the three questions worth asking after a write: which layer is the new one, where did a failed script stop, and did the assembly land. Only the recorded fields are compared, so 'no differences' means none of THOSE moved, not that the comp is identical. Returns a fresh snapshotId so you can keep diffing forward.",
1337
2024
  // ---------- layers ----------
1338
2025
  list_layers: "Layers in a comp, one-line each. Use get_layer_full for details. Pass `include` to trim it \u2014 `include: []` returns just id/index/name/type, the cheapest way to learn what is in a comp.",
1339
2026
  get_layer_full: "Full state of one layer: transform + keyframes + expressions, effects, masks, markers, parenting, text/shape/footage extras, and sourceRect (visible bounds). Always prefer over multiple smaller queries. Bound the answer on a heavy layer: `include` picks the sections you need, `maxKeyframes` caps the keyframes per property, `shapeDepth` limits the Contents walk. Anything dropped is named and counted in the response, so a bounded read is never mistaken for a complete one. On a shape layer, `shapeDetail: 'compact'` returns one indented line per group \u2014 `name matchName prop=value prop=value`, with `[3 keys]`/`[expr]` marking animated properties and `(at defaults)` a group Transform nobody has touched \u2014 which is a fraction of the size and still names every node the write tools address. Material Options (48 3D-extrusion properties per group, inert on a 2D shape layer) is left out of both forms and counted in `materialsOmitted`; pass `shapeMaterials:true` for the extruded-3D case.",
1340
2027
  create_text_layer: "Text layer with optional font/size/color/position/tracking. anchorAlign (default 'left') aligns the text by setting paragraph justification and leaving the anchor at [0,0], so position means the start of the baseline AND stays right when the text is changed later. Tracking is set to 0 unless you pass one, because AE otherwise inherits the user's Character panel. anchorAlign 'none' keeps AE's raw defaults.",
1341
- create_shape_layer: "Empty shape layer; fill via add_shape_content.",
2028
+ create_shape_layer: "Empty shape layer; fill via add_shape_content. Position defaults to [0,0] with Anchor Point [0,0], so the layer's coordinate space IS the comp's and every vertex, rect/ellipse position and path you add afterwards is in comp pixels. (After Effects' own default is the comp centre, which silently offsets a drawing authored in comp coordinates by half a frame \u2014 pass position:'center' if you want that, or any [x,y] to place the origin yourself.) The result echoes the position and anchor point it ended up with.",
1342
2029
  create_solid_layer: "Solid-color layer. color is RGB 0..1.",
1343
2030
  create_null_layer: "Null parent layer.",
1344
2031
  create_adjustment_layer: "Adjustment layer \u2014 effects on it apply to layers below.",
@@ -1349,15 +2036,15 @@ var descriptions = {
1349
2036
  delete_layer: "Remove a layer.",
1350
2037
  set_layer: "Update layer metadata (name/enabled/locked/shy/solo/3D/blend/label/in-out/stretch/trackMatte). Undefined fields unchanged.",
1351
2038
  parent_layer: "Set/clear a layer's parent (parentLayerId=null to unparent). The layer stays visually put: AE's own compensation double-counts when the parent was itself re-parented in the same call, so this recomputes the world transform and corrects position/scale/rotation, reporting any correction in `correction`. Read `correction.notes` \u2014 3D layers, cameras and lights are not corrected, and with a keyframed ancestor the fix is only exact at the comp's current time. Pass preserveTransform:false to let the layer jump instead.",
1352
- reorder_layer: "Move layer to 1-based stack index.",
2039
+ reorder_layer: "Move a layer in the render stack. Pass exactly one destination: `beforeLayerId` puts it directly in front of (above) that layer, `afterLayerId` directly behind it, `toIndex` at an absolute 1-based position where 1 is the front and numLayers the back. `toIndex` is where the layer ENDS UP, not the slot it displaces \u2014 the two differ by one when moving down the stack. Prefer the id forms: this is the op that shifts every index below it, so an index you read earlier may already be stale. The result carries `movedFrom` and the landed `index`, read back off the layer; equal values mean it was already there.",
1353
2040
  // ---------- transforms ----------
1354
2041
  set_transform: "Set any of position/scale/rotation/anchorPoint/opacity (+3D orientation/per-axis on 3D). keyframe:true + time sets keyframes.",
1355
2042
  // ---------- keyframes ----------
1356
- add_keyframe: "Keyframe at `time` on propertyPath (e.g. ['Transform','Position']). Optional in/out interpolation + ease.",
2043
+ add_keyframe: "Keyframe at `time` on propertyPath (e.g. ['Transform','Position']). Optional in/out interpolation + ease \u2014 one influence/speed pair, expanded to however many entries the property needs (see set_temporal_ease). The count used comes back as `easeDimensions`.",
1357
2044
  remove_keyframe: "Remove keyframe at `time`.",
1358
2045
  get_keyframes: "All keyframes on a property: time, value, interpolation, ease, spatial tangents.",
1359
2046
  set_interpolation: "Set in/out interpolation type of a specific keyframe.",
1360
- set_temporal_ease: "Set influence+speed for in/out ease of a keyframe.",
2047
+ set_temporal_ease: "Set influence+speed for the in/out ease of one keyframe. Pass at least one of `easeIn`/`easeOut` \u2014 neither is refused, since it would change nothing. Pass ONE {influence, speed} pair per side and it is applied to every dimension of the property \u2014 you never size the ease array yourself. That matters because AE's own setTemporalEaseAtKey wants a per-property number of entries that is not derivable from the value (2D Scale takes 2, a shape Ellipse Size takes 3, Opacity and sliders take 1, and spatial Position takes 1 whether the layer is 2D or 3D), and the wrong count throws a bare 'parameter 2'. This derives the count, retries the alternatives, and reports what worked as `easeDimensions` \u2014 worth reading if you are also easing that property from run_jsx.",
1361
2048
  set_spatial_tangents: "Set in/out spatial tangents for a position-style keyframe.",
1362
2049
  // ---------- expressions ----------
1363
2050
  get_expression: "Expression text + enabled state on a property.",
@@ -1368,7 +2055,7 @@ var descriptions = {
1368
2055
  list_effects: "All effects on a layer with current param values + keyframes/expressions.",
1369
2056
  add_effect: "Add effect by matchName (use list_available_effects \u2014 matchNames are stable across AE versions; display names aren't). A wrong matchName fails immediately and cheaply, so try the standard one (ADBE Gaussian Blur 2, ADBE Slider Control) before searching.",
1370
2057
  remove_effect: "Remove effect by 1-based index.",
1371
- set_effect_param: "Set an effect param by name/matchName. keyframe:true+time for keyframed value.",
2058
+ set_effect_param: "Set one parameter of one effect. Pass at least one of `paramName` (the display name, e.g. 'Blurriness') or `paramMatchName` (e.g. 'ADBE Gaussian Blur 2-0001'); list_effects reports both for every parameter on the layer. Give both and the matchName wins, with the name as fallback \u2014 worth doing when a display name is ambiguous or the user's After Effects is localised. `keyframe:true` plus `time` writes a keyframed value instead of a static one.",
1372
2059
  set_effect_enabled: "Toggle an effect on/off without removing.",
1373
2060
  list_available_effects: "Effects installed in this AE: displayName, matchName, category. **Always pass `filter`** to substring-search: the full list is 250-450+ entries and the cost is in returning them, not in reading them, so an unfiltered call takes seconds every time while a filtered one takes a fraction of one (measured on AE 26.3: 446 entries in 3.9s, 22 filtered in 0.17s). `refresh:true` re-reads after installing a plugin; the enumeration is cached per AE session, which is why refresh exists. Never loop over `app.effects` in run_jsx: it is slow enough to block the bridge past its timeout and looks like a crash.",
1374
2061
  // ---------- text ----------
@@ -1386,22 +2073,24 @@ var descriptions = {
1386
2073
  add_marker: "Add a marker on a layer (layerId) or on the comp. time + optional duration/comment/label/chapter/url/frameTarget.",
1387
2074
  remove_marker: "Remove a marker by 1-based index.",
1388
2075
  // ---------- vision ----------
1389
- screenshot_frame: "ONE-OFF visual check of a comp at a time. Base64 PNG. Use only at key moments \u2014 never per-frame or in a loop. For motion, 2-3 snapshots + get_layer_full property values. A downsample is chosen from the comp size unless you pass one \u2014 omit it, and pass downsample:1 only when you genuinely need full resolution. The result reports the dimensions actually returned, and warns if a requested downsample could not be applied. Not every result is an image: a 'Stale frame' error means AE re-served an earlier render \u2014 space calls a few seconds apart, retry with a higher downsample, and confirm motion by reading keyframes instead; `empty:true` means every pixel is transparent, so check the time, in/out points and enabled state. Never disable layers to make a frame render.",
1390
- screenshot_layer: "ONE-OFF visual check of a single layer (solo'd) at a time. Same one-off rule and same downsample guidance as screenshot_frame. The same 'Stale frame' and `empty:true` non-image results apply.",
2076
+ screenshot_frame: "ONE-OFF visual check of a comp. Base64 PNG. Use only at key moments \u2014 never per-frame or in a loop. To judge MOTION, pass `times` (2-6 values) and get one tiled contact sheet with the time burned into each tile: one call, one image, about the pixel cost of a single frame \u2014 always cheaper and safer than several separate calls. `time` and `times` are mutually exclusive. Confirm the numbers with get_layer_full / get_keyframes; a picture is not exact. A downsample is chosen from the comp size (per tile, for a sheet) unless you pass one \u2014 omit it, and pass downsample:1 only when you genuinely need full resolution. Not every result is an image: 'Stale frame' means AE re-served an earlier render, 'Corrupt frame' means the file AE wrote is not a whole PNG (retry at downsample 6-8, or screenshot the precomps separately), 'Render timed out' means it is still rendering (wait, don't retry immediately), and `empty:true` means every pixel is transparent \u2014 check the time, in/out points and enabled state. On a sheet, a bad tile is drawn as a marked block and named in `warning`; the rest of the sheet is still good. Never disable layers to make a frame render.",
2077
+ screenshot_layer: "ONE-OFF visual check of a single layer (solo'd) at a time. Same one-off rule and same downsample guidance as screenshot_frame. The same 'Stale frame', 'Corrupt frame', 'Render timed out' and `empty:true` non-image results apply. No `times` here \u2014 contact sheets are screenshot_frame only.",
1391
2078
  // ---------- batch ----------
1392
- run_batch: "Many ops in one ExtendScript pass, one undo step. >500 ops returns a jobId + streams progress; use await_job. transactional:true (default) rolls back on first error.",
2079
+ run_batch: 'Many ops in one ExtendScript pass \u2014 far faster than the same ops as separate calls, and far fewer undo steps. **Up to 500 ops it is exactly one undo step.** Over 500 it is chunked into a background job (returns a jobId, streams progress, finish with await_job) and lands as **one undo step per chunk of 25** \u2014 around 24 steps for 600 ops \u2014 because After Effects discards an undo group that spans two script calls. Every result reports the measured count in `undoSteps` with a `note`: read it before you tell anyone how to undo the work, and never say "one Cmd-Z" for a chunked batch. `singleUndo:true` forces one undo step at any size up to 2000 ops, by running the whole batch in one blocking call \u2014 After Effects\' interface is frozen for the duration and no progress is reported, so use it only when a single Cmd-Z actually matters to the user. Over 2000 it is refused rather than freezing AE. transactional:true (default) stops at the first error; nothing rolls back either way \u2014 the ops before it stay applied, so read the state back rather than re-running. `diff:true` appends a structural diff of the comps the batch touched \u2014 what it added, retimed, re-parented and keyframed \u2014 and on a failure that diff rides on the error, which is the cheapest way to find where a half-applied batch stopped.',
1393
2080
  // ---------- explore ----------
1394
2081
  get_project_summary: "Project state: path, item count, active item, flat item list with type (comp | footage | solid | folder | unknown \u2014 same vocabulary as a layer's sourceType).",
1395
2082
  find_layers: "Search across one or all comps for layers matching name/type/effect filters.",
1396
2083
  // ---------- raw ----------
1397
- run_jsx: 'Escape hatch: arbitrary ExtendScript in an undo group. `comp`/`app`/`OPS`/helpers in scope. `return X` sends a value back \u2014 arrays and nested objects come back whole. Anything that cannot be JSON is replaced in place by a marker string, never dropped: `"[function]"`, `"[undefined]"`, `"[circular]"`, `"[max depth]"`, `"[NaN]"`, and live AE objects as `"[CompItem \\"Main\\" #12]"` \u2014 a handle to pass to a real read tool, not a walk of the object. An empty result therefore means the script really returned nothing. A script with no explicit `return` \u2014 including one ending in a bare expression, which does NOT return its value \u2014 comes back as `{ok:true, returned:null, undoGroup, note}`. That means it ran to completion; it did **not** fail, so do not re-run it. Nothing rolls back, so re-running a mutating script applies it twice. AE refuses copyToComp for a layer with a parent or a linked expression while an undo group is open: call `withoutUndoGroup(function(){ \u2026 })` around just that part, or pass undoGroup:false for the whole script (its changes then land as whatever undo steps AE records on its own, not one). Keep loops short \u2014 ExtendScript is single-threaded and freezes the user\'s UI.',
2084
+ run_jsx: 'Escape hatch: arbitrary ExtendScript in an undo group. Pass exactly one of `code` (inline) or `scriptPath` (absolute) to run a .jsx file the server reads \u2014 use the path for anything long, so the script never enters the conversation. `libraries` (absolute .jsx paths) are read by the server too and inlined ahead of the script in the same scope, so their functions are callable from it; they are re-evaluated on every call, so keep them to declarations rather than to work. Keep shared helpers there instead of pasting them into every script. In scope: `app`, the full `OPS` table (every tool on this server, e.g. `OPS.set_transform({compId, layerId, position:[0,0]})`), and these helpers \u2014 `compById(id)`, `getCompById(id)`, `layerById(compOrId, layerId)`, `getLayerById(comp, layerId)`, `walkProperty(layer, ["Transform","Position"])`, `addKeys(prop, [[t, v], \u2026])` \u2192 key indices, `ease(prop, keyIndex, easeIn, easeOut)` which sizes the KeyframeEase array itself (a bare number means influence; omit easeOut for the same both sides), `shape(comp, {name, position})` which lands the layer at [0,0] rather than the comp centre, and `withoutUndoGroup(fn)`. `app.executeCommand()` menu commands silently no-op here \u2014 they depend on host focus and the active selection, neither of which this bridge has \u2014 so use the API equivalents (`CompItem.duplicate()`, `layer.duplicate()`). `return X` sends a value back \u2014 arrays and nested objects come back whole. Anything that cannot be JSON is replaced in place by a marker string, never dropped: `"[function]"`, `"[undefined]"`, `"[circular]"`, `"[max depth]"`, `"[NaN]"`, and live AE objects as `"[CompItem \\"Main\\" #12]"` \u2014 a handle to pass to a real read tool, not a walk of the object. An empty result therefore means the script really returned nothing. A script with no explicit `return` \u2014 including one ending in a bare expression, which does NOT return its value \u2014 comes back as `{ok:true, returned:null, undoGroup, note}`. That means it ran to completion; it did **not** fail, so do not re-run it. Nothing rolls back, so re-running a mutating script applies it twice. A failure names the line of YOUR script and prints its text; when the number cannot be mapped it says so rather than guessing. Everything before that line already ran and nothing rolls back, so read the state back \u2014 never re-run the script to see if it fails again. AE refuses copyToComp for a layer with a parent or a linked expression while an undo group is open: call `withoutUndoGroup(function(){ \u2026 })` around just that part, or pass undoGroup:false for the whole script (its changes then land as whatever undo steps AE records on its own, not one). Keep loops short \u2014 ExtendScript is single-threaded and freezes the user\'s UI. `diff:true` fingerprints the comp before and after the script and appends only what changed, so the script reports what it actually did instead of you reading the comp back; with it the answer is always `{ok, returned, diff}`. If the script throws, that diff is appended to the error \u2014 which is how you find where a half-applied script stopped, since nothing rolls back.',
1398
2085
  // ---------- footage ----------
1399
2086
  import_footage: "Import a file (video, image, audio, SVG, PSD/AI) into the project. Returns the item id \u2014 pass it to create_footage_layer to place it. Validates what AE actually produced: an SVG whose viewBox asks for one aspect ratio and imports at another is a known AE bug that renders as nothing with no error, so the item is deleted and the call throws with the workaround. `force:true` keeps it and reports the problem in `validation` instead.",
1400
2087
  create_footage_layer: "Place an imported project item into a comp as a layer. Takes the itemId from import_footage or get_project_summary. For a comp use create_precomp_layer instead.",
2088
+ // ---------- audio ----------
2089
+ place_audio_cues: "Score a scene in one call: a list of cues, each a sound at a comp time with a level in dB, becomes one audio layer each \u2014 imported if needed, named, trimmed, labelled \u2014 in a single undo step. Reach for it whenever you are placing more than two or three sound effects; the alternative is dozens of round trips or a run_jsx loop that has to know that `layer.property('ADBE Audio Levels')` returns null on an audio layer. Every cue names its sound with **exactly one** of `path` (a file to use \u2014 imported once however many cues name it, and an item already in the project from that path is reused) or `footageId` (one already imported); both together, or neither, is reported against that cue's index with nothing placed. `time` is when the cue starts; `levelDb` is decibels (0 = the file as recorded, negative = quieter), defaulting to 0. `inPoint`/`outPoint` trim in COMP time, not file time. It is all-or-nothing: every cue is validated \u2014 file exists, item has audio, time inside the comp \u2014 before a single layer is made, and if a later one still fails, everything this call created is removed and the error names the cue. Use `dryRun:true` to check a cue list against the project without touching anything, including the undo stack; it reports which paths do not exist. Max 200 cues per call.",
1401
2090
  // ---------- motion graphics templates ----------
1402
- export_mogrt: "Export a comp as a .mogrt for Premiere. Handles the three things that make a scripted export look like a hung connection: it saves the project first (removes AE's modal save prompt), suppresses the modal font warning that otherwise freezes this connection until someone clicks OK in AE, and runs outside the undo group. `name` defaults to the comp name \u2014 AE's own default is the literal 'Untitled', so every export would otherwise overwrite the same file. Pass `posterTime` to render that frame as the template's thumbnail, replacing the black one AE writes; the export still succeeds if only the thumbnail fails. Needs the project saved once by hand first. `fonts` in the result lists the fonts the template will require \u2014 tell the user, since non-Adobe ones make Premiere flag the template.",
2091
+ export_mogrt: "Export a comp as a .mogrt for Premiere. Handles the three things that make a scripted export look like a hung connection: it saves the project first (removes AE's modal save prompt), suppresses the modal font warning that otherwise freezes this connection until someone clicks OK in AE, and runs outside the undo group. **The comp needs at least one property in its Essential Graphics panel** \u2014 After Effects will not build a template from an empty one, and it refuses silently, so this tool checks the controller count first and refuses before exporting rather than reporting a cause it cannot know. Fix it in AE: Window > Essential Graphics, pick the comp, drag a layer property in. `name` defaults to the comp name \u2014 AE's own default is the literal 'Untitled', so every export would otherwise overwrite the same file. Pass `posterTime` to render that frame as the template's thumbnail, replacing the black one AE writes; the export still succeeds if only the thumbnail fails. Needs the project saved once by hand first. `fonts` in the result lists the fonts the template will require \u2014 tell the user, since non-Adobe ones make Premiere flag the template. If an export still fails, read the message before acting: it lists what was checked, and it only names a modal dialog when dialogs were left unsuppressed. Under the default suppression a dialog is impossible, the cause is genuinely unknown, and the way to see AE's own reason is for the user to run the same export by hand from the Essential Graphics panel.",
1403
2092
  // ---------- house style ----------
1404
- get_house_style: "The user's palette, type, motion and layout defaults for the project that is open, read from `house-style.md` beside the .aep. Call it once before building anything so your work matches the rest of theirs. `found:false` means none exists yet \u2014 build with sensible defaults and offer to capture one afterwards. Cheap; never a reason to skip.",
2093
+ get_house_style: "The user's palette, type, motion and layout defaults for the project that is open, read from `house-style.md` beside the .aep. Call it once before building anything so your work matches the rest of theirs. Returns a few-hundred-token summary by default \u2014 palette as named hexes, type, motion, layout, and a note naming anything it could not summarise; pass `detail:'full'` for the whole document, which you need before editing the guide with set_house_style. `found:false` means none exists yet \u2014 build with sensible defaults and offer to capture one afterwards. Cheap; never a reason to skip.",
1405
2094
  set_house_style: "Write the project's style guide. Replaces the whole file, so read it first and send the merged document \u2014 `overwrite:true` is required to replace an existing one. The project must have been saved at least once, since the file lives beside the .aep. Use the style-guide topic of ae_guide for how to capture a style worth writing down.",
1406
2095
  // ---------- guidance ----------
1407
2096
  ae_guide: "The full working guidance for these tools, by topic. Read `after-effects` before a first substantial build in a session, `style-guide` when capturing or editing the user's look, `ae-setup` when a tool cannot reach After Effects. Covers the traps that silently produce wrong output and are not visible from any single tool's schema.",
@@ -1414,20 +2103,102 @@ var descriptions = {
1414
2103
  setup_panel: "Install or refresh the After Effects panel and enable the Adobe preference AE needs to load it. Run this when check_setup reports the panel is missing, out of date, or older than what AE is running. It writes to the user's Adobe CEP extensions folder and sets a user-level Adobe preference \u2014 tell the user what it will do before calling it. Prefer running it while AE is CLOSED: the panel then loads when they open it, with no restart. If AE is already open they must quit and reopen it, and until they do, the old panel keeps answering. If the preference was newly enabled, a one-time Mac reboot may also be needed.",
1415
2104
  init_project: "Set up a working folder for one video, series or client: a project brief and a pointer to the house style, written in whichever layout this client reads. Run it when the user is starting out or asks to set up a project. It writes files to disk \u2014 say which folder before calling, and pass `dir` explicitly unless the client already told the server where it is working. It never overwrites anything and reports every path it wrote.",
1416
2105
  // ---------- issue journal ----------
1417
- list_known_issues: "Problems earlier sessions hit with these tools, with the workarounds that worked. Read it when a tool fails in a way you don't immediately understand \u2014 pass `tool` or `query` to narrow it \u2014 and before nontrivial work. It can save you rediscovering a fix that already cost someone an hour. Returns a one-line index by default; the cause and the workaround are in the entry, so follow up with `id` on anything that looks like your problem. `detail:'full'` dumps every matching entry and is rarely worth it. Also returns the repo and server version needed to report one.",
1418
- log_issue: "Record a problem you hit and the workaround that got past it, so the next session doesn't rediscover it. Log only what cost real effort and will recur: a tool failing for a non-obvious reason, an argument shape the schema didn't imply, AE behaving unlike the docs. Not your own typos, not one-off user mistakes. Call list_known_issues first and reuse the same title to extend an existing entry rather than duplicating it. If the result comes back with reported:false, then AFTER you have finished the actual work, close your reply by telling the user in plain language that something took much longer than it should have and offering to pass it to the people who maintain this tool \u2014 phrase it for a motion designer, in terms of what actually happened, and don't say 'GitHub issue' or 'bug report' unless they say it first.",
2106
+ list_known_issues: "Problems earlier sessions hit with these tools, with the workarounds that worked. Read it when a tool fails in a way you don't immediately understand \u2014 pass `tool` or `query` to narrow it \u2014 and before nontrivial work. It can save you rediscovering a fix that already cost someone an hour. Merges two journals and tags every entry with the one it came from: `project` (or `home`, the fallback when there is no project folder) for this project's own notes, `user` for tool and After Effects behaviour carried across every project. Returns a one-line index by default; the cause and the workaround are in the entry, so follow up with `id` on anything that looks like your problem \u2014 ids are unique only within a journal, so use the `scope:id` form the `next` pointer shows. `detail:'full'` dumps every matching entry and is rarely worth it. Also returns the repo and server version needed to report one.",
2107
+ log_issue: "Record a problem you hit and the workaround that got past it, so the next session doesn't rediscover it. Log only what cost real effort and will recur: a tool failing for a non-obvious reason, an argument shape the schema didn't imply, AE behaving unlike the docs. Not your own typos, not one-off user mistakes. Call list_known_issues first and reuse the same title to extend an existing entry rather than duplicating it. Choose the scope by what the entry is *about*: leave it at the default `project` for this project's footage, comps or files, and pass `scope:'user'` when it is about how these tools or After Effects behave, so the next project starts already knowing it. If the result comes back with reported:false, then AFTER you have finished the actual work, close your reply by telling the user in plain language that something took much longer than it should have and offering to pass it to the people who maintain this tool \u2014 phrase it for a motion designer, in terms of what actually happened, and don't say 'GitHub issue' or 'bug report' unless they say it first.",
1419
2108
  mark_issue_reported: "Record that a journal entry has been sent to the maintainers, with the resulting URL. Call it only once the issue really exists, so later sessions don't ask the user to report the same thing twice."
1420
2109
  };
1421
2110
 
2111
+ // src/tools/runJsxSource.ts
2112
+ import fs3 from "node:fs";
2113
+ import path4 from "node:path";
2114
+ var MAX_SCRIPT_BYTES = 512 * 1024;
2115
+ var MAX_LIBRARIES = 16;
2116
+ var MAX_TOTAL_BYTES = 1024 * 1024;
2117
+ function readScriptFile(p, what) {
2118
+ if (!path4.isAbsolute(p)) {
2119
+ throw new Error(
2120
+ `${what} must be an absolute path \u2014 got "${p}". Relative paths have no meaning here: the server resolves them against its own working directory, which is not the user's project folder (Claude Desktop starts it at "/").`
2121
+ );
2122
+ }
2123
+ let stat;
2124
+ try {
2125
+ stat = fs3.statSync(p);
2126
+ } catch (e) {
2127
+ throw new Error(`${what} could not be read: ${p} (${e.code ?? e.message}).`);
2128
+ }
2129
+ if (stat.isDirectory()) throw new Error(`${what} is a directory, not a file: ${p}`);
2130
+ if (!stat.isFile()) throw new Error(`${what} is not a regular file: ${p}`);
2131
+ if (stat.size > MAX_SCRIPT_BYTES) {
2132
+ throw new Error(
2133
+ `${what} is ${stat.size} bytes, over the ${MAX_SCRIPT_BYTES}-byte limit: ${p}. Split it into smaller scripts and run them in sequence. Moving the bulk into a \`libraries\` file does not help: libraries are inlined ahead of the script on every call, so they count against the same budget.`
2134
+ );
2135
+ }
2136
+ let text;
2137
+ try {
2138
+ text = fs3.readFileSync(p, "utf8");
2139
+ } catch (e) {
2140
+ throw new Error(`${what} could not be read: ${p} (${e.message}).`);
2141
+ }
2142
+ if (text.trim() === "") throw new Error(`${what} is empty: ${p}`);
2143
+ return { text, bytes: stat.size };
2144
+ }
2145
+ function resolveRunJsxSource(args) {
2146
+ const hasCode = typeof args.code === "string" && args.code.length > 0;
2147
+ const hasPath = typeof args.scriptPath === "string" && args.scriptPath.length > 0;
2148
+ if (hasCode && hasPath) {
2149
+ throw new Error(
2150
+ "run_jsx takes either `code` or `scriptPath`, not both. Pass the path on its own \u2014 the file is read here, so its text never has to enter the conversation."
2151
+ );
2152
+ }
2153
+ if (!hasCode && !hasPath) {
2154
+ throw new Error(
2155
+ "run_jsx needs either `code` (ExtendScript inline) or `scriptPath` (an absolute path to a .jsx file)."
2156
+ );
2157
+ }
2158
+ const { libraries: requestedLibraries, ...passthrough } = args;
2159
+ const out = { ...passthrough, code: "" };
2160
+ if (hasPath) {
2161
+ const p = args.scriptPath;
2162
+ out.code = readScriptFile(p, "run_jsx `scriptPath`").text;
2163
+ out.scriptPath = p;
2164
+ } else {
2165
+ out.code = args.code;
2166
+ delete out.scriptPath;
2167
+ }
2168
+ if (requestedLibraries && requestedLibraries.length > 0) {
2169
+ if (requestedLibraries.length > MAX_LIBRARIES) {
2170
+ throw new Error(
2171
+ `run_jsx takes at most ${MAX_LIBRARIES} libraries, got ${requestedLibraries.length}.`
2172
+ );
2173
+ }
2174
+ const libs = [];
2175
+ const seen = /* @__PURE__ */ new Set();
2176
+ for (const raw of requestedLibraries) {
2177
+ if (seen.has(raw)) continue;
2178
+ seen.add(raw);
2179
+ const { text, bytes } = readScriptFile(raw, `run_jsx library "${raw}"`);
2180
+ libs.push({ path: raw, text, bytes });
2181
+ }
2182
+ out.libraries = libs;
2183
+ }
2184
+ const total = Buffer.byteLength(out.code, "utf8") + (out.libraries ?? []).reduce((n, l) => n + l.bytes, 0);
2185
+ if (total > MAX_TOTAL_BYTES) {
2186
+ throw new Error(
2187
+ `run_jsx would send ${total} bytes of source (script plus ${out.libraries?.length ?? 0} libraries), over the ${MAX_TOTAL_BYTES}-byte limit for one call. Libraries are inlined ahead of the script \u2014 they have to be, or their functions are not in its scope \u2014 so every byte travels on every call. Pass only the libraries this script actually uses.`
2188
+ );
2189
+ }
2190
+ return out;
2191
+ }
2192
+
1422
2193
  // src/setup/check.ts
1423
2194
  import crypto2 from "node:crypto";
1424
- import fs5 from "node:fs";
1425
- import path6 from "node:path";
2195
+ import fs6 from "node:fs";
2196
+ import path7 from "node:path";
1426
2197
 
1427
2198
  // src/setup/paths.ts
1428
- import fs3 from "node:fs";
2199
+ import fs4 from "node:fs";
1429
2200
  import os3 from "node:os";
1430
- import path4 from "node:path";
2201
+ import path5 from "node:path";
1431
2202
  import { createRequire } from "node:module";
1432
2203
  import { fileURLToPath } from "node:url";
1433
2204
  var BUNDLE_ID = "games.engine-room.ae-mcp";
@@ -1435,22 +2206,22 @@ function isSupportedPlatform() {
1435
2206
  return process.platform === "darwin" || process.platform === "win32";
1436
2207
  }
1437
2208
  function packageRoot() {
1438
- let dir = path4.dirname(fileURLToPath(import.meta.url));
2209
+ let dir = path5.dirname(fileURLToPath(import.meta.url));
1439
2210
  for (let i = 0; i < 8; i++) {
1440
- if (fs3.existsSync(path4.join(dir, "package.json"))) return dir;
1441
- const parent = path4.dirname(dir);
2211
+ if (fs4.existsSync(path5.join(dir, "package.json"))) return dir;
2212
+ const parent = path5.dirname(dir);
1442
2213
  if (parent === dir) break;
1443
2214
  dir = parent;
1444
2215
  }
1445
- return path4.dirname(fileURLToPath(import.meta.url));
2216
+ return path5.dirname(fileURLToPath(import.meta.url));
1446
2217
  }
1447
2218
  function executableDir() {
1448
- return path4.dirname(process.execPath);
2219
+ return path5.dirname(process.execPath);
1449
2220
  }
1450
2221
  function packageVersion() {
1451
2222
  for (const dir of [packageRoot(), executableDir()]) {
1452
2223
  try {
1453
- const pkg = JSON.parse(fs3.readFileSync(path4.join(dir, "package.json"), "utf8"));
2224
+ const pkg = JSON.parse(fs4.readFileSync(path5.join(dir, "package.json"), "utf8"));
1454
2225
  if (typeof pkg.version === "string") return pkg.version;
1455
2226
  } catch {
1456
2227
  }
@@ -1462,31 +2233,31 @@ function panelSourceDir() {
1462
2233
  // The live workspace copy comes first so a git checkout always installs
1463
2234
  // what the developer is editing, never a stale vendored copy left behind by
1464
2235
  // a previous `npm pack`. Only the second path exists in the tarball.
1465
- path4.resolve(packageRoot(), "..", "ae-panel"),
1466
- path4.join(packageRoot(), "panel"),
2236
+ path5.resolve(packageRoot(), "..", "ae-panel"),
2237
+ path5.join(packageRoot(), "panel"),
1467
2238
  // Compiled single-file build: the panel ships beside the executable.
1468
- path4.join(executableDir(), "panel")
2239
+ path5.join(executableDir(), "panel")
1469
2240
  ];
1470
2241
  for (const dir of candidates) {
1471
- if (fs3.existsSync(path4.join(dir, "CSXS", "manifest.xml"))) return dir;
2242
+ if (fs4.existsSync(path5.join(dir, "CSXS", "manifest.xml"))) return dir;
1472
2243
  }
1473
2244
  return null;
1474
2245
  }
1475
2246
  function cepExtensionsDir() {
1476
2247
  if (process.platform === "win32") {
1477
- const appData = process.env.APPDATA ?? path4.join(os3.homedir(), "AppData", "Roaming");
1478
- return path4.join(appData, "Adobe", "CEP", "extensions");
2248
+ const appData = process.env.APPDATA ?? path5.join(os3.homedir(), "AppData", "Roaming");
2249
+ return path5.join(appData, "Adobe", "CEP", "extensions");
1479
2250
  }
1480
- return path4.join(os3.homedir(), "Library", "Application Support", "Adobe", "CEP", "extensions");
2251
+ return path5.join(os3.homedir(), "Library", "Application Support", "Adobe", "CEP", "extensions");
1481
2252
  }
1482
2253
  function installedPanelDir() {
1483
- return path4.join(cepExtensionsDir(), BUNDLE_ID);
2254
+ return path5.join(cepExtensionsDir(), BUNDLE_ID);
1484
2255
  }
1485
2256
  function isWsModuleDir(dir) {
1486
2257
  try {
1487
- const pkg = JSON.parse(fs3.readFileSync(path4.join(dir, "package.json"), "utf8"));
2258
+ const pkg = JSON.parse(fs4.readFileSync(path5.join(dir, "package.json"), "utf8"));
1488
2259
  if (pkg.name !== "ws") return false;
1489
- return fs3.existsSync(path4.join(dir, "index.js")) && fs3.existsSync(path4.join(dir, "lib", "websocket.js"));
2260
+ return fs4.existsSync(path5.join(dir, "index.js")) && fs4.existsSync(path5.join(dir, "lib", "websocket.js"));
1490
2261
  } catch {
1491
2262
  return false;
1492
2263
  }
@@ -1496,15 +2267,15 @@ function wsModuleDir() {
1496
2267
  try {
1497
2268
  const require2 = createRequire(import.meta.url);
1498
2269
  const entry = require2.resolve("ws");
1499
- if (path4.isAbsolute(entry)) {
1500
- const marker = `${path4.sep}node_modules${path4.sep}ws${path4.sep}`;
2270
+ if (path5.isAbsolute(entry)) {
2271
+ const marker = `${path5.sep}node_modules${path5.sep}ws${path5.sep}`;
1501
2272
  const idx = entry.lastIndexOf(marker);
1502
- candidates.push(idx >= 0 ? entry.slice(0, idx + marker.length - 1) : path4.dirname(entry));
2273
+ candidates.push(idx >= 0 ? entry.slice(0, idx + marker.length - 1) : path5.dirname(entry));
1503
2274
  }
1504
2275
  } catch {
1505
2276
  }
1506
- candidates.push(path4.join(executableDir(), "node_modules", "ws"));
1507
- candidates.push(path4.join(packageRoot(), "node_modules", "ws"));
2277
+ candidates.push(path5.join(executableDir(), "node_modules", "ws"));
2278
+ candidates.push(path5.join(packageRoot(), "node_modules", "ws"));
1508
2279
  for (const dir of candidates) {
1509
2280
  if (isWsModuleDir(dir)) return dir;
1510
2281
  }
@@ -1512,56 +2283,56 @@ function wsModuleDir() {
1512
2283
  }
1513
2284
  function listPanelFiles(dir, prefix = "") {
1514
2285
  const out = [];
1515
- for (const entry of fs3.readdirSync(dir, { withFileTypes: true })) {
2286
+ for (const entry of fs4.readdirSync(dir, { withFileTypes: true })) {
1516
2287
  if (entry.name === "node_modules" || entry.name === ".DS_Store") continue;
1517
2288
  const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
1518
- if (entry.isDirectory()) out.push(...listPanelFiles(path4.join(dir, entry.name), rel));
2289
+ if (entry.isDirectory()) out.push(...listPanelFiles(path5.join(dir, entry.name), rel));
1519
2290
  else out.push(rel);
1520
2291
  }
1521
2292
  return out;
1522
2293
  }
1523
2294
  function sameContents(a, b) {
1524
2295
  try {
1525
- if (fs3.statSync(a).size !== fs3.statSync(b).size) return false;
1526
- return fs3.readFileSync(a).equals(fs3.readFileSync(b));
2296
+ if (fs4.statSync(a).size !== fs4.statSync(b).size) return false;
2297
+ return fs4.readFileSync(a).equals(fs4.readFileSync(b));
1527
2298
  } catch {
1528
2299
  return false;
1529
2300
  }
1530
2301
  }
1531
2302
  function panelInstallDiff(source, installed) {
1532
- return listPanelFiles(source).filter((rel) => !sameContents(path4.join(source, rel), path4.join(installed, rel))).sort();
2303
+ return listPanelFiles(source).filter((rel) => !sameContents(path5.join(source, rel), path5.join(installed, rel))).sort();
1533
2304
  }
1534
2305
  function copyRecursive(src, dst) {
1535
- const stat = fs3.lstatSync(src);
2306
+ const stat = fs4.lstatSync(src);
1536
2307
  if (stat.isDirectory()) {
1537
- fs3.mkdirSync(dst, { recursive: true });
1538
- for (const entry of fs3.readdirSync(src)) {
1539
- copyRecursive(path4.join(src, entry), path4.join(dst, entry));
2308
+ fs4.mkdirSync(dst, { recursive: true });
2309
+ for (const entry of fs4.readdirSync(src)) {
2310
+ copyRecursive(path5.join(src, entry), path5.join(dst, entry));
1540
2311
  }
1541
2312
  } else if (stat.isSymbolicLink()) {
1542
- fs3.symlinkSync(fs3.readlinkSync(src), dst);
2313
+ fs4.symlinkSync(fs4.readlinkSync(src), dst);
1543
2314
  } else {
1544
- fs3.copyFileSync(src, dst);
2315
+ fs4.copyFileSync(src, dst);
1545
2316
  }
1546
2317
  }
1547
2318
 
1548
2319
  // src/setup/panelVersion.ts
1549
2320
  import crypto from "node:crypto";
1550
- import fs4 from "node:fs";
1551
- import path5 from "node:path";
2321
+ import fs5 from "node:fs";
2322
+ import path6 from "node:path";
1552
2323
  var cachedSourceHash;
1553
2324
  function sourceBundleHash() {
1554
2325
  if (cachedSourceHash !== void 0) return cachedSourceHash;
1555
2326
  const source = panelSourceDir();
1556
- cachedSourceHash = source ? hashFile(path5.join(source, "jsx", "bundle.jsx")) : null;
2327
+ cachedSourceHash = source ? hashFile(path6.join(source, "jsx", "bundle.jsx")) : null;
1557
2328
  return cachedSourceHash;
1558
2329
  }
1559
2330
  function installedBundleHash(installedPanel) {
1560
- return hashFile(path5.join(installedPanel, "jsx", "bundle.jsx"));
2331
+ return hashFile(path6.join(installedPanel, "jsx", "bundle.jsx"));
1561
2332
  }
1562
2333
  function hashFile(file) {
1563
2334
  try {
1564
- return crypto.createHash("sha256").update(fs4.readFileSync(file)).digest("hex");
2335
+ return crypto.createHash("sha256").update(fs5.readFileSync(file)).digest("hex");
1565
2336
  } catch {
1566
2337
  return null;
1567
2338
  }
@@ -1681,7 +2452,7 @@ var BRIDGE_PROBE_MS = 2e3;
1681
2452
  var BRIDGE_BUSY_FIX = "This is a timeout, not a refused connection \u2014 something is listening, it just did not answer in time. After Effects is most likely busy running a script, or waiting on a modal dialog nobody has clicked. Wait and run check_setup again, up to about a minute, before restarting anything; it usually clears on its own. On macOS, if the user has switched to another desktop, ask them to switch back to the one After Effects is on.";
1682
2453
  function sha256(file) {
1683
2454
  try {
1684
- return crypto2.createHash("sha256").update(fs5.readFileSync(file)).digest("hex");
2455
+ return crypto2.createHash("sha256").update(fs6.readFileSync(file)).digest("hex");
1685
2456
  } catch {
1686
2457
  return null;
1687
2458
  }
@@ -1729,7 +2500,7 @@ async function checkSetup() {
1729
2500
  fix: debugMode.on ? void 0 : `Run the setup_panel tool. After Effects only loads unsigned panels when ${debugModeLocation()} is set.`
1730
2501
  });
1731
2502
  const installed = installedPanelDir();
1732
- const isInstalled = fs5.existsSync(path6.join(installed, "CSXS", "manifest.xml"));
2503
+ const isInstalled = fs6.existsSync(path7.join(installed, "CSXS", "manifest.xml"));
1733
2504
  checks.push({
1734
2505
  name: "panelInstalled",
1735
2506
  ok: isInstalled,
@@ -1749,7 +2520,7 @@ async function checkSetup() {
1749
2520
  });
1750
2521
  }
1751
2522
  if (isInstalled) {
1752
- const panelWs = path6.join(installed, "node_modules", "ws");
2523
+ const panelWs = path7.join(installed, "node_modules", "ws");
1753
2524
  const wsOk = isWsModuleDir(panelWs);
1754
2525
  checks.push({
1755
2526
  name: "panelDependencies",
@@ -1774,7 +2545,7 @@ async function checkSetup() {
1774
2545
  fix: bridge.ok ? void 0 : bridge.timedOut ? BRIDGE_BUSY_FIX : "If the other checks pass, restart After Effects so the panel reloads."
1775
2546
  });
1776
2547
  if (bridge.ok && source) {
1777
- const assessment = assessPanel(bridge.bundleHash, sha256(path6.join(installed, "jsx", "bundle.jsx")), {
2548
+ const assessment = assessPanel(bridge.bundleHash, sha256(path7.join(installed, "jsx", "bundle.jsx")), {
1778
2549
  installComplete
1779
2550
  });
1780
2551
  const ok = assessment.state === "current";
@@ -1844,8 +2615,8 @@ function buildNextSteps(checks, ready, bridgeTimedOut = false) {
1844
2615
  }
1845
2616
 
1846
2617
  // src/setup/install.ts
1847
- import fs6 from "node:fs";
1848
- import path7 from "node:path";
2618
+ import fs7 from "node:fs";
2619
+ import path8 from "node:path";
1849
2620
  async function installPanel(opts = {}) {
1850
2621
  const actions = [];
1851
2622
  const notes = [];
@@ -1858,7 +2629,7 @@ async function installPanel(opts = {}) {
1858
2629
  if (!source) {
1859
2630
  throw new Error("Could not find the CEP panel assets that ship with this server. Reinstall the package.");
1860
2631
  }
1861
- if (!fs6.existsSync(path7.join(source, "jsx", "bundle.jsx"))) {
2632
+ if (!fs7.existsSync(path8.join(source, "jsx", "bundle.jsx"))) {
1862
2633
  throw new Error(`The panel at ${source} has no jsx/bundle.jsx. In a git checkout, run \`npm run build:jsx\` first.`);
1863
2634
  }
1864
2635
  const ws = wsModuleDir();
@@ -1868,9 +2639,9 @@ async function installPanel(opts = {}) {
1868
2639
  );
1869
2640
  }
1870
2641
  const target = installedPanelDir();
1871
- const existing = fs6.lstatSync(target, { throwIfNoEntry: false });
2642
+ const existing = fs7.lstatSync(target, { throwIfNoEntry: false });
1872
2643
  if (existing?.isSymbolicLink() && !opts.force) {
1873
- const linkTarget = fs6.readlinkSync(target);
2644
+ const linkTarget = fs7.readlinkSync(target);
1874
2645
  return {
1875
2646
  ok: true,
1876
2647
  panelPath: target,
@@ -1884,15 +2655,15 @@ async function installPanel(opts = {}) {
1884
2655
  };
1885
2656
  }
1886
2657
  if (existing) {
1887
- fs6.rmSync(target, { recursive: true, force: true });
2658
+ fs7.rmSync(target, { recursive: true, force: true });
1888
2659
  actions.push("Removed the previously installed panel.");
1889
2660
  }
1890
- fs6.mkdirSync(path7.dirname(target), { recursive: true });
2661
+ fs7.mkdirSync(path8.dirname(target), { recursive: true });
1891
2662
  copyRecursive(source, target);
1892
2663
  actions.push(`Installed the panel to ${target}.`);
1893
- const dest = path7.join(target, "node_modules", "ws");
1894
- fs6.mkdirSync(path7.dirname(dest), { recursive: true });
1895
- fs6.rmSync(dest, { recursive: true, force: true });
2664
+ const dest = path8.join(target, "node_modules", "ws");
2665
+ fs7.mkdirSync(path8.dirname(dest), { recursive: true });
2666
+ fs7.rmSync(dest, { recursive: true, force: true });
1896
2667
  copyRecursive(ws, dest);
1897
2668
  if (!isWsModuleDir(dest)) {
1898
2669
  throw new Error(
@@ -1933,17 +2704,27 @@ var GUIDES = [
1933
2704
  {
1934
2705
  name: "ae-setup",
1935
2706
  description: "Diagnose and repair the connection between the AE MCP tools and After Effects \u2014 panel not installed, AE not running, Adobe debug preference off, bridge not responding. Load when an After Effects tool reports it cannot reach AE, or when the user is setting this up for the first time.",
1936
- body: '# Getting After Effects connected\n\nThe tools talk to a small panel that runs **inside** After Effects. Three things must be true for that to work: the panel is installed, Adobe is willing to load it, and AE is open.\n\nAssume the person you are helping is a motion designer, not a developer. They should never need to open a terminal \u2014 you have tools for all of this.\n\n## Always start with check_setup\n\n`check_setup` is read-only and safe to call at any time. It returns a `checks` array and a `nextSteps` list already written in plain language.\n\n**Relay `nextSteps` to the user directly.** Do not paraphrase it into jargon, and do not invent steps it did not mention.\n\n## A timeout is not a disconnection\n\nBefore you start any repair, check which failure you actually have. "The panel did not answer within N seconds" and "cannot reach the panel" are opposite diagnoses:\n\n- **Did not answer** \u2014 something is listening; it is just too busy to reply. After Effects is single-threaded, so a long script or a modal dialog waiting for a click blocks it completely. Nothing is broken and nothing needs installing.\n- **Cannot reach** \u2014 nothing is listening. That is the case the repair path below is for.\n\nOn a timeout, `check_setup` says so itself: `bridgeReachable` reports that the port accepted the connection but did not answer in time, and `nextSteps` tells you to wait. Follow it. Re-running `setup_panel` or restarting After Effects here costs the user their work-in-progress for nothing, and both are the wrong move. Poll `check_setup` for about a minute; it usually clears on its own.\n\nTwo things to ask about while waiting: whether a dialog is sitting behind another window in After Effects, and \u2014 on macOS \u2014 whether they have switched to another desktop. Calls have been reported to stall while the user is on a different Space and to complete as soon as they come back.\n\n## Install before they open After Effects, if you still can\n\nThe panel loads at launch and only at launch. So the order matters, and it is\nthe opposite of what people assume:\n\n- **After Effects is closed** \u2014 install now. When they open it, the panel is\n simply there. No restart, nothing to ask for. This is the good path, and on a\n first-time setup you can usually get it.\n- **After Effects is open** \u2014 install, then they have to quit and reopen it.\n Unavoidable, but worth avoiding: if they have not opened AE yet in this\n conversation, do the install *first* and tell them to open it after.\n\n`check_setup` reports `afterEffectsRunning`, so you always know which case you\nare in before you say anything.\n\n## The repair path\n\n1. **`check_setup`** \u2014 find out what is actually wrong.\n2. **`setup_panel`** \u2014 if the panel is missing or out of date. Tell the user what it will do *before* you call it: it copies the panel into their Adobe extensions folder and switches on the Adobe preference that permits unsigned panels. Both changes are user-level and reversible.\n3. **Get the panel loaded.** If AE was closed, ask them to open it. If it was already open, ask them to quit and reopen it. You cannot do either for them.\n4. **`check_setup`** again to confirm.\n\n## What the individual failures mean\n\n| Check | Meaning when it fails |\n|---|---|\n| `platform` | Not macOS or Windows. After Effects only runs on those two, so there is nothing to fix. |\n| `panelAssetsPresent` | The server package is incomplete \u2014 it needs reinstalling. |\n| `cepDebugMode` | Adobe refuses to load unsigned panels until this preference is on. `setup_panel` sets it. |\n| `panelInstalled` | The panel is not in the Adobe extensions folder yet. `setup_panel` installs it. |\n| `panelUpToDate` | The files on disk are older than this server. Run `setup_panel`. |\n| `panelRunningCurrent` | AE is *running* an older panel than these tools ship. This is the one that predicts whether calls will actually work \u2014 `panelUpToDate` can pass while this fails, for the whole window between installing an update and restarting AE. |\n| `afterEffectsRunning` | AE is closed. If the panel also needs installing, install it now and then ask them to open AE \u2014 that saves a restart. |\n| `bridgeReachable` | Everything is installed but the panel isn\'t answering. Read the detail: if the port **timed out**, After Effects is busy and you should wait, not restart. If nothing is listening at all, restarting AE almost always fixes it. |\n\n## The reboot case\n\n`cepDebugMode` is an Adobe preference that, on some macOS builds, only takes effect after a **restart of the Mac** \u2014 not just of After Effects. If `setup_panel` reports `rebootRecommended: true` and restarting AE alone did not fix it, ask the user to reboot once. This is a one-time cost, never needed again.\n\n## When a tool says the panel is out of date\n\nYou may get an error saying the panel is older than these tools, or that it does\nnot recognise an op. That is a version mismatch, not a broken tool, and the\nmessage tells you which of the two fixes applies:\n\n- **"updated on disk \u2026 still running the previous version"** \u2014 `setup_panel` has\n already done its part. Only a restart of After Effects will help; running it\n again will not.\n- **anything else** \u2014 run `setup_panel`, then get AE restarted.\n\nEither way, do not retry the failed call until the user confirms AE has\nrestarted. Say it as a version mismatch in plain language, not as a failure:\ntheir tools moved ahead of the panel, and it takes a restart to catch up.\n\n## If it still will not connect\n\nAsk the user to open **Window > Extensions > AE MCP Bridge** inside After Effects. That panel shows its own status and a log, and will say whether it started, which port it took, or what error it hit. Have them read it back to you.\n\nA common cause is a stale install: the panel loaded an older script bundle than the server expects. `check_setup`\'s `panelUpToDate` catches that \u2014 the fix is `setup_panel` followed by an AE restart.'
2707
+ body: '# Getting After Effects connected\n\nThe tools talk to a small panel that runs **inside** After Effects. Three things must be true for that to work: the panel is installed, Adobe is willing to load it, and AE is open.\n\nAssume the person you are helping is a motion designer, not a developer. They should never need to open a terminal \u2014 you have tools for all of this.\n\n## Always start with check_setup\n\n`check_setup` is read-only and safe to call at any time. It returns a `checks` array and a `nextSteps` list already written in plain language.\n\n**Relay `nextSteps` to the user directly.** Do not paraphrase it into jargon, and do not invent steps it did not mention.\n\n## A timeout is not a disconnection\n\nBefore you start any repair, work out which of **three** failures you have. They\nread alike and their remedies contradict each other:\n\n- **Did not answer in time** \u2014 something is listening; it is just too busy to\n reply. After Effects is single-threaded, so a long script or a modal dialog\n waiting for a click blocks it completely. Nothing is broken and nothing needs\n installing. The call **did** reach After Effects and may still be running, so\n do not re-send it.\n- **Cannot reach** \u2014 nothing is listening. That is the case the repair path\n below is for.\n- **Waited behind another op for the write queue and was dropped** \u2014 the panel\n is fine and this call never left the server, so nothing in the project was\n changed. Writes are serialized so that they land in the order the agent issued\n them and nothing drops into the middle of work the user asked for as one\n thing; something in front is taking a very long time, usually a long\n `run_batch`. Re-sending **is** safe here, once the work in\n front has finished; this is the one of the three where it is. Find out what is\n in front with `get_job` or `await_job` \u2014 reads are never queued, so `list_`\n and `get_` calls still answer.\n\nThe message itself tells you which one you have; the queue error says in as many\nwords that nothing was written. Never collapse them into "the bridge is playing\nup", because "re-send it" and "do not re-send it" are the two answers.\n\nOn a timeout, `check_setup` says so itself: `bridgeReachable` reports that the port accepted the connection but did not answer in time, and `nextSteps` tells you to wait. Follow it. Re-running `setup_panel` or restarting After Effects here costs the user their work-in-progress for nothing, and both are the wrong move. Poll `check_setup` for about a minute; it usually clears on its own.\n\nTwo things to ask about while waiting: whether a dialog is sitting behind another window in After Effects, and \u2014 on macOS \u2014 whether they have switched to another desktop. Calls have been reported to stall while the user is on a different Space and to complete as soon as they come back.\n\nA **full** write queue is a fourth message and a different instruction again:\ntoo many calls are already waiting, so stop issuing writes and let it drain. If\nyou have that much independent work, send it as one `run_batch` \u2014 one\nExtendScript pass and one place in the queue, instead of dozens of each.\n\n## Install before they open After Effects, if you still can\n\nThe panel loads at launch and only at launch. So the order matters, and it is\nthe opposite of what people assume:\n\n- **After Effects is closed** \u2014 install now. When they open it, the panel is\n simply there. No restart, nothing to ask for. This is the good path, and on a\n first-time setup you can usually get it.\n- **After Effects is open** \u2014 install, then they have to quit and reopen it.\n Unavoidable, but worth avoiding: if they have not opened AE yet in this\n conversation, do the install *first* and tell them to open it after.\n\n`check_setup` reports `afterEffectsRunning`, so you always know which case you\nare in before you say anything.\n\n## The repair path\n\n1. **`check_setup`** \u2014 find out what is actually wrong.\n2. **`setup_panel`** \u2014 if the panel is missing or out of date. Tell the user what it will do *before* you call it: it copies the panel into their Adobe extensions folder and switches on the Adobe preference that permits unsigned panels. Both changes are user-level and reversible.\n3. **Get the panel loaded.** If AE was closed, ask them to open it. If it was already open, ask them to quit and reopen it. You cannot do either for them.\n4. **`check_setup`** again to confirm.\n\n## What the individual failures mean\n\n| Check | Meaning when it fails |\n|---|---|\n| `platform` | Not macOS or Windows. After Effects only runs on those two, so there is nothing to fix. |\n| `panelAssetsPresent` | The server package is incomplete \u2014 it needs reinstalling. |\n| `cepDebugMode` | Adobe refuses to load unsigned panels until this preference is on. `setup_panel` sets it. |\n| `panelInstalled` | The panel is not in the Adobe extensions folder yet. `setup_panel` installs it. |\n| `panelUpToDate` | The files on disk are older than this server. Run `setup_panel`. |\n| `panelRunningCurrent` | AE is *running* an older panel than these tools ship. This is the one that predicts whether calls will actually work \u2014 `panelUpToDate` can pass while this fails, for the whole window between installing an update and restarting AE. |\n| `afterEffectsRunning` | AE is closed. If the panel also needs installing, install it now and then ask them to open AE \u2014 that saves a restart. |\n| `bridgeReachable` | Everything is installed but the panel isn\'t answering. Read the detail: if the port **timed out**, After Effects is busy and you should wait, not restart. If nothing is listening at all, restarting AE almost always fixes it. |\n\n## The reboot case\n\n`cepDebugMode` is an Adobe preference that, on some macOS builds, only takes effect after a **restart of the Mac** \u2014 not just of After Effects. If `setup_panel` reports `rebootRecommended: true` and restarting AE alone did not fix it, ask the user to reboot once. This is a one-time cost, never needed again.\n\n## When a tool says the panel is out of date\n\nYou may get an error saying the panel is older than these tools, or that it does\nnot recognise an op. That is a version mismatch, not a broken tool, and the\nmessage tells you which of the two fixes applies:\n\n- **"updated on disk \u2026 still running the previous version"** \u2014 `setup_panel` has\n already done its part. Only a restart of After Effects will help; running it\n again will not.\n- **anything else** \u2014 run `setup_panel`, then get AE restarted.\n\nEither way, do not retry the failed call until the user confirms AE has\nrestarted. Say it as a version mismatch in plain language, not as a failure:\ntheir tools moved ahead of the panel, and it takes a restart to catch up.\n\n## If it still will not connect\n\nAsk the user to open **Window > Extensions > AE MCP Bridge** inside After Effects. That panel shows its own status and a log, and will say whether it started, which port it took, or what error it hit. Have them read it back to you.\n\nA common cause is a stale install: the panel loaded an older script bundle than the server expects. `check_setup`\'s `panelUpToDate` catches that \u2014 the fix is `setup_panel` followed by an AE restart.'
1937
2708
  },
1938
2709
  {
1939
2710
  name: "after-effects",
1940
2711
  description: "How to drive Adobe After Effects well through the AE MCP tools \u2014 orienting in a project, building and animating layers, keyframes and easing, expressions, effects, text and shapes, and the gotchas that silently produce wrong output. Load whenever a task involves After Effects, motion graphics, comps, layers, or keyframes.",
1941
- body: '# Driving After Effects\n\nYou have direct control of a live After Effects session. The user sees every change immediately, and every tool call is a real undo step in their project. Work like a motion designer at the keyboard, not like a script that fires blind.\n\n## Read the house style first\n\n`get_house_style` returns the style guide for the project that is currently open\n\u2014 palette, type, motion defaults, layout rules \u2014 read from `house-style.md`\nsitting next to the `.aep` file. Call it once at the start of any build task and\nfollow what it says. It costs one cheap call and it is the difference between\nwork that matches everything else the user has made and work that does not.\n\nIf it reports `found: false`, build with sensible defaults and offer once, at the\nend, to capture a style guide from what you just made. Don\'t nag about it.\n\n## Orient before you touch anything\n\nNever guess at project state. Cheap reads exist for exactly this:\n\n| Question | Tool |\n|---|---|\n| What\'s in this project? | `get_project_summary` |\n| What comps exist? | `list_comps` |\n| What\'s in this comp? | `get_comp_tree` |\n| Everything about one layer | `get_layer_full` \u2B50 |\n| Where is a layer, by name/type/effect? | `find_layers` |\n\n`get_layer_full` is the one to reach for. It returns transforms **with their keyframes and expressions**, effects with every parameter, masks, markers, and `sourceRect` (the layer\'s visible bounds) in a single call. Prefer one `get_layer_full` over four narrow queries \u2014 it is faster and it shows you context you did not know to ask for.\n\n### Ask for what you need\n\nA tool result stays in your context for the rest of the session, so a read you cannot bound is paid for on every later call. All of these reads take an `include` list:\n\n- `list_comps` / `list_layers` with `include: []` return the id-to-name map alone, which is what orientation actually needs.\n- `get_layer_full` takes `include` (`transform`, `effects`, `masks`, `markers`, `bounds`, `text`, `shape`, `source`), plus `maxKeyframes` to cap the keyframes per property and `shapeDepth` to limit the Contents walk on a heavy shape layer.\n\nOmit them all and you get everything, as before. Whatever they leave out is named and counted in the response \u2014 a bounded read never looks like a complete one.\n\n**Reading a shape layer, use `shapeDetail: "compact"`.** It returns one indented line per group \u2014 the group\'s name, its matchName, then its own properties as `name=value`, with `[3 keys]` or `[expr]` on the animated ones and `(at defaults)` for a group Transform nobody has touched. Every name the write tools address a node by is still on the line, and it costs a fraction of the full JSON form. Reach for `"full"` when you need exact values, keyframe detail or indices.\n\nOne thing is left out of both forms: **Material Options**, the 48-property 3D extrusion block AE hangs off every vector group. It only means anything for an extruded shape under the Cinema 4D renderer, and on the 2D shape layers that are nearly all of them it was most of the weight of the read \u2014 a single 68px circle cost 4,400 tokens, of which the geometry was about 40. `materialsOmitted` counts what was skipped; `shapeMaterials: true` brings it back.\n\n## Identify things by ID, never by index\n\nEvery comp and layer has a stable numeric `id`. Layer `index` is a 1-based position that **shifts whenever layers are added, deleted, or reordered**. Store `(compId, layerId)` and pass those. An index captured before a `create_*` call may point at a different layer by the time you use it.\n\nThe same trap bites inside `run_jsx`: a `comp.layer(1)` wrapper is index-bound, not a handle. After a `copyToComp` inserts the copy at index 1, a reference you took earlier silently resolves to the *new* layer \u2014 which is how a script ends up parenting a layer to itself. Re-resolve by id or name after anything that inserts a layer.\n\n## Read, then write, then verify\n\n1. Read the current state (`get_layer_full`).\n2. Make the change.\n3. Verify by reading back the properties \u2014 not by screenshotting.\n\nProperty values are the ground truth. A screenshot tells you something *looks* wrong; `get_layer_full` tells you *why*.\n\n## Screenshots are a diagnostic, not a feedback loop\n\n`screenshot_frame` and `screenshot_layer` are **one-off checks**. Do not screenshot every frame, do not scrub through time, do not screenshot after every edit.\n\n- Take at most 2\u20133 across an animation \u2014 typically start, middle, end.\n- **The `downsample` is picked from the comp size** unless you pass one \u2014 2 at 1080p, 3 at 4K, aiming at a long edge around 1280px. Pass `downsample: 1` only when you genuinely need full resolution: a full 4K frame is large enough to blow out your context in one call.\n- The result reports the dimensions actually returned and the factor actually applied \u2014 trust those numbers rather than assuming.\n- **Space them out.** Rapid back-to-back requests are far more likely to come back stale than requests a few seconds apart.\n\nTwo results are not images, and both are information rather than something to retry blindly:\n\n- **`Stale frame` (an error)** \u2014 After Effects returned the pixels it had already rendered for a *different* request, which the error names. Pause a few seconds and retry with a higher `downsample`; `6` has worked where `3`\u2013`4` stayed stale. If it repeats, read the keyframes instead.\n- **`empty: true`** \u2014 every pixel at that time is fully transparent, so no image was sent. That is a fact about the composition: usually the wrong time, a layer outside its in/out points, disabled, or at zero opacity.\n\n**Never disable layers to make a screenshot render.** A frame that will not render is a limit of the panel\'s render path, not project content that needs fixing \u2014 and it is very easy to leave someone\'s comp switched off afterwards.\n\nTo check motion, read the keyframe values. That is exact; a picture is not.\n\n## Bulk work goes through run_batch\n\nBuilding 40 layers with 40 separate calls is slow and produces 40 undo steps. `run_batch` runs many ops in one ExtendScript pass as a **single undo step**, which is also what the user expects when they ask to undo "that thing you just built".\n\n- `transactional: true` (the default) rolls back the whole batch on the first error.\n- Over 500 ops it returns a `jobId` and streams progress; call `await_job(jobId)` for the final result.\n\n## Keyframes and easing\n\n`add_keyframe` sets a value at a time. Interpolation is separate:\n\n- `set_interpolation` \u2014 linear / bezier / hold, per keyframe, in and out.\n- `set_temporal_ease` \u2014 influence and speed, the "easy ease" controls.\n- `set_spatial_tangents` \u2014 the shape of a motion path through a position keyframe.\n\n**The array-size trap.** `set_temporal_ease` wants one ease entry *per dimension* for ordinary multi-dimensional properties (Scale, Color), but exactly **one** entry for spatial properties (Position, Anchor Point) regardless of whether the layer is 2D or 3D \u2014 because the ease applies along the motion path, not per axis. If you see `Value array does not have 1 elements`, you fed a spatial property one entry per axis.\n\n## Expressions\n\n`set_expression` takes a `propertyPath` such as `["Transform","Position"]` or `["Effects","Gaussian Blur","Blurriness"]`. Expressions are ExtendScript-flavoured JavaScript evaluated by AE per frame.\n\nExpressions are usually a better answer than dense keyframes for anything procedural \u2014 wiggle, loops, counters, follow-through, time remapping. They stay editable by the user afterwards, where a wall of baked keyframes does not.\n\nUse `get_expression` to read one back and `toggle_expression` to disable without deleting.\n\n## Effects\n\nEffects are added by **matchName**, not display name: `add_effect({matchName: "ADBE Gaussian Blur 2"})`. If you do not know a matchName, call `list_available_effects({filter: "blur"})` \u2014 do not guess. `list_effects` shows what is already on a layer, with every parameter.\n\nSet parameters with `set_effect_param` by parameter name (e.g. `"Blurriness"`).\n\n**Never enumerate `app.effects` yourself in `run_jsx`.** There are around 250 of them and reading the table is slow enough to block the bridge past its timeout, which looks exactly like a crash and costs a minute of everyone\'s time. `list_available_effects` does the same enumeration once and caches it for the session, so `filter` searches are free after the first call. A wrong matchName also fails instantly and clearly, so trying `ADBE Slider Control` is cheaper than searching for it.\n\n## Text\n\n`create_text_layer` defaults to `anchorAlign: "left"`, which sets **paragraph justification** and leaves the anchor point at `[0,0]`, so `position` is the start of the first baseline. Pass `"center"` or `"right"` for those, `"none"` for AE\'s raw behaviour. Because the alignment is justification rather than a measured offset, it stays correct when the text changes later \u2014 retyped, driven by an expression, or edited through Essential Graphics in Premiere. Never "fix" alignment by writing an anchor point computed from `sourceRectAtTime()`: it is right once and wrong from the next edit onward.\n\nTracking is set to `0` unless you pass one, because AE\'s `addText()` otherwise inherits whatever the user\'s Character panel was last left on.\n\n`set_text` controls font, size, colour, tracking, leading and justification. To auto-fit a background to text, read `sourceRect` from `get_layer_full` and size the shape from its width and height plus padding.\n\n## Shapes\n\n`add_shape_content` builds one node at a time under `Contents` \u2014 `rect`, `ellipse`, `star`, `path`, `fill`, `stroke`, `trim`, `repeater`, `merge`, `group`. Properties are set with friendly names in the same call (`size`, `position`, `roundness`, `color`, `width`, `lineCap`, \u2026).\n\nThis tool is **all-or-nothing**: if a key cannot be applied, the whole node is removed and you get an error naming the bad key. A success result therefore means everything landed. Don\'t add defensive re-reads for it, but do read the error carefully \u2014 it usually means the property is named differently on that node type, and `get_layer_full` will show you the real name.\n\nFor a custom path, use `{type: "path", vertices: [[x,y], \u2026], closed: true}`. The key is `vertices`, not `points`.\n\n**Render order is the opposite of the layer stack.** Inside `Contents`, index 1 renders in *front*, and each `add_shape_content` call appends behind the previous one. So build **front-to-back**: details, text plates and traffic-light dots first, the big background rectangle last. Getting it backwards is silent \u2014 no error, just a solid slab where your artwork should be. `zOrder: "front"` will place a node at index 1 for you, but it needs an internal `moveTo`, which has been seen to disturb *nested* renders of the comp in AE 26.3; prefer ordering your calls. If an existing layer is already in the wrong order, rebuild it rather than reordering, and verify with a screenshot of a comp that **nests** it, not just the comp that owns it.\n\n**Node references go stale.** Adding a sibling to a group invalidates a reference you already hold to another node in it \u2014 add a Stroke and an earlier Fill reference starts throwing `Object is invalid`. Add every node first, then set values and expressions by addressing nodes by name.\n\n## The escape hatch\n\n`run_jsx` executes arbitrary ExtendScript with `app`, `comp`, `OPS` and the helper functions in scope. Reach for it when a needed operation has no tool \u2014 duplicating a comp, driving the render queue, batch-renaming.\n\nExtendScript is **single-threaded**, so a long synchronous loop freezes the user\'s AE UI. Keep the script short.\n\n`return X` sends the whole value back \u2014 arrays and nested objects included. Values that cannot be represented (functions, live AE objects, cycles) come back as a marker string in place, never dropped \u2014 a live object as `"[AVLayer \\"Hero\\" #616]"`, which is a handle to pass to `get_layer_full`, not a copy of the layer. So an empty result genuinely means the script returned nothing; never read one as "nothing happened".\n\n**A bare expression is not a return.** `"ping";` as the last line yields nothing, and so does any script that just does its work. That case comes back as `{ok: true, returned: null, undoGroup, note}` \u2014 an envelope that says *the script ran to completion*. Do not re-run it. Nothing rolls back, so a second run of a script that duplicated a layer, reordered content or wrote keyframes applies all of it twice; read the state back instead, and add an explicit `return` if you want a value.\n\nAE refuses `copyToComp` for a layer with a parent or a linked expression **while an undo group is open**, which is exactly the rig you wanted to copy. Wrap that one call in `withoutUndoGroup(function () { \u2026 })`, or pass `undoGroup: false` for the whole script. Nothing rolls back on error, so a script that fails halfway leaves its earlier changes applied \u2014 read the state back before re-running one that mutates.\n\nSet the parent first and the transform after, never the reverse. `parent_layer` keeps the layer where it is; raw `layer.parent = x` inside a script does not do so reliably two levels deep, so after scripted parenting audit scale and rotation as well as position.\n\n### Exporting a Motion Graphics template\n\nUse **`export_mogrt`**. Do not drive `comp.exportAsMotionGraphicsTemplate` from `run_jsx` \u2014 the tool exists because that call raises modal dialogs, and a modal dialog freezes this whole connection until someone clicks it in After Effects.\n\n`export_mogrt` handles all of it: it saves the project first (which is what removes AE\'s "the project needs to be saved" prompt, and it has to happen per export because exporting dirties the project again), it suppresses the font warning, and it runs outside the undo group so there is no "undo group mismatch" afterwards. Measured on 26.3: suppressed, an export of a comp using a non-Adobe font returns in about three seconds; unsuppressed, the same export sat past sixty and wrote nothing until the dialog was clicked.\n\nThree things worth knowing before you call it:\n\n- **The project must have been saved once, by hand.** There is no folder to save into otherwise, and the tool refuses rather than raising a dialog the user was not expecting.\n- **`name` is the filename.** It defaults to the comp name, because AE\'s own default is the literal `Untitled` \u2014 leave it to AE and every template in the project overwrites the same file.\n- **`fonts` in the result lists what the template will require.** Tell the user about any non-Adobe ones: Premiere flags the template as needing fonts it cannot supply, and that is worth hearing from you rather than discovering later.\n\n**The thumbnail.** AE writes the comp\'s *first frame* into the template, so anything that fades up from nothing gets a black one. Pass `posterTime` with a moment that actually shows the design and it is rendered and swapped in. If only the thumbnail fails the export still succeeds \u2014 check `thumbnail.patched` in the result.\n\nAlso note that `comp.setMotionGraphicsControllerName(index, \u2026)` numbers controllers in **reverse order of addition**: index 1 is the one you added last.\n\n**If any long call seems to have hung, assume a dialog before you assume a crash** \u2014 it may be behind another window. `comp.saveFrameToPng(...)` from `run_jsx` raises the save prompt the same way; use `screenshot_frame`, which does not.\n\n### Importing footage, and the SVG trap\n\nUse **`import_footage`**, then **`create_footage_layer`** to place the item in a comp. (For a comp as a layer, `create_precomp_layer`.)\n\n`import_footage` checks what AE actually produced, because one case fails silently: an SVG with a very large `viewBox` (say `0 0 278050 333334`) imports with **fabricated dimensions and renders as nothing**, no error at any stage. Verified on 26.3 \u2014 that viewBox yields a 15906x5654 item that will not even rasterize. The tool compares the aspect ratio the file asks for against the one AE produced, and on a mismatch it deletes the item and throws, rather than handing you an asset that looks healthy in the project panel and renders empty.\n\nIf you hit that, the workarounds are:\n\n- **Simple flat SVGs** \u2014 rebuild the path as a shape layer with the real vertices, scaled down to a sane coordinate space (divide by `333.334` for a 1000px version), set the fill from the SVG, and set `ADBE Vector Fill Rule` to `2` when the SVG says `fill-rule="evenodd"`. Done this way the result is pixel-accurate.\n- **Complex SVGs** \u2014 rasterise to PNG outside AE, or normalise the `viewBox` to a small coordinate space before importing.\n\n`force: true` keeps the item and reports the problem in `validation` instead of throwing. It is for when you know the dimensions are wrong and want it anyway \u2014 not a way past the error.\n\n## When something costs you real time\n\nThese tools have rough edges, and the same ones catch every session. Two tools\nexist so that each one is only paid for once.\n\n**`list_known_issues`** \u2014 what earlier sessions hit and how they got past it.\nRead it when a tool fails in a way you do not immediately understand, before you\nstart guessing. The answer is often already there. It comes back as a one-line\nindex, so open the entry that looks like your failure with\n`list_known_issues({id})` \u2014 the cause and the workaround are in the entry, not in\nthe index. `tool` and `query` narrow it further.\n\n**`log_issue`** \u2014 write down what you worked out, the moment you work it out.\n\nLog something when all three are true: it cost real effort, it was the tool\'s\nfault rather than yours, and the next session would hit it too. A schema that\naccepts an argument AE then rejects, an error message that names the wrong\nthing, a property whose real name is nothing like its display name. Not your own\ntypos. Not "I forgot the layer was 3D".\n\nWrite the entry for someone who has not seen the failure: the exact error text,\nthe call that produced it, and a workaround concrete enough to apply directly.\nReuse the existing title when you are extending an entry \u2014 that keeps one good\nrecord instead of five thin ones.\n\n### Then offer to pass it on\n\nIf `log_issue` comes back with `reported: false`, mention it to the user \u2014 but\nfinish the actual work first, and put it at the very end, after you have told\nthem what you built. It is a footnote, not the headline.\n\nSay it the way you would to a colleague who does not write code. What you were\ntrying to do, that it fought back, that you got there anyway, and that you can\nsend it to the people who maintain the tool so the next person does not lose the\nsame time. Something like:\n\n> Done \u2014 the lower third is in. One thing worth mentioning: getting the ease\n> onto that position keyframe took a lot longer than it should have, because the\n> tool kept rejecting a value it had just asked for. I found a way around it and\n> made a note. Want me to send it to the people who maintain this so they can\n> fix it properly?\n\nDo not say "GitHub issue", "file a bug" or "open a ticket" unless they say it\nfirst. If they say yes, use the **report-ae-issue** prompt this server provides\n(`/report-ae-issue` where your client exposes prompts as commands) \u2014 it handles\nthe rest. If they say no, drop it; the note stays and can be offered again\nanother time.\n\nNever claim you have reported something you have not.\n\n## When something is not connected\n\nIf a tool reports it cannot reach After Effects, call `check_setup` and relay its `nextSteps` to the user in plain language. Do not try to diagnose CEP by hand.\n\n**A timeout is not proof the bridge is dead.** The error that says the panel did not answer in time is a different thing from the one that says the panel cannot be reached. Because ExtendScript is single-threaded, a busy After Effects cannot answer anything \u2014 so a long script, or a modal dialog nobody has clicked, is indistinguishable from a crash at this layer. It normally recovers on its own within a minute.\n\nSo when a call times out: do not re-send it (you would queue the same work twice), do not restart After Effects, and do not run `setup_panel`. Poll `check_setup` for about a minute first. Two causes worth asking about directly:\n\n- **A dialog is waiting.** Ask the user to check After Effects for a prompt hiding behind another window.\n- **They changed desktop.** On macOS, calls have been reported to stall while the user is on a different Space and to complete as soon as they return. If they have wandered off, ask them to switch back to the desktop After Effects is on before you diagnose anything else.\n\nIf a specific operation of yours legitimately needs longer than the limit, the user can raise it by setting `AE_MCP_OP_TIMEOUT_MS` in the server\'s environment.'
2712
+ body: '# Driving After Effects\n\nYou have direct control of a live After Effects session. The user sees every change immediately, and every tool call is a real undo step in their project. Work like a motion designer at the keyboard, not like a script that fires blind.\n\nTwo things sit *behind* this guide rather than in it, so they are not resident in\nevery session. Both are reachable either way \u2014 `ae_guide({topic: \u2026})` from any\nclient, or the file beside this one if you loaded this as a skill:\n\n- **`extendscript-gotchas`** (`references/extendscript-gotchas.md`) \u2014 read it\n before writing any raw ExtendScript for `run_jsx`. Property lookups that return\n null, what `copyToComp` really does, comp time versus layer time, the reserved\n words that stop a script before its first line.\n- **`whats-new`** (`references/whats-new.md`) \u2014 what changed recently. Read it\n when a call behaves differently from what you expected, or the user says a tool\n used to work another way.\n\n## Read the house style first\n\n`get_house_style` returns the style guide for the project that is currently open\n\u2014 palette, type, motion defaults, layout rules \u2014 read from `house-style.md`\nsitting next to the `.aep` file. Call it once at the start of any build task and\nfollow what it says. It costs one cheap call and it is the difference between\nwork that matches everything else the user has made and work that does not.\n\nWhat comes back is a **digest**, not the document: the palette as named hexes,\nthe type, the motion defaults, the layout rules, and a note naming anything it\ncould not summarise. That is a few hundred tokens and it is what you build from.\nPass `detail: "full"` when you need the guide\'s own wording \u2014 and always before\n`set_house_style`, which replaces the whole file, so you have to send the merged\ndocument rather than a patch. A guide written as unstructured prose comes back\n`structured: false` with its opening text, which is an honest "I could not read\nthis as a spec", not an empty answer.\n\nIf it reports `found: false`, build with sensible defaults and offer once, at the\nend, to capture a style guide from what you just made. Don\'t nag about it.\n\n## Orient before you touch anything\n\nNever guess at project state. Cheap reads exist for exactly this:\n\n| Question | Tool |\n|---|---|\n| What\'s in this project? | `get_project_summary` |\n| What comps exist? | `list_comps` |\n| What\'s in this comp? | `get_comp_tree` |\n| Everything about one layer | `get_layer_full` \u2B50 |\n| Where is a layer, by name/type/effect? | `find_layers` |\n| What did my last change actually do? | `snapshot_comp` \u2192 `diff_comp` \u2B50 |\n\n**Making a variant of something** is `duplicate_comp`, which returns the new id\nso you never go looking for it by name. Its default is a **shallow** copy, the\nsame as AE\'s own Duplicate: the copy\'s precomp layers point at the *same* nested\ncomps, so editing one edits both. That is right for "another version of this\nshot" and wrong for "a variant of this rig" \u2014 for that pass `deep: true`, which\nduplicates the nested comps too and re-points the copy at them.\n\n`get_layer_full` is the one to reach for. It returns transforms **with their keyframes and expressions**, effects with every parameter, masks, markers, and `sourceRect` (the layer\'s visible bounds) in a single call. Prefer one `get_layer_full` over four narrow queries \u2014 it is faster and it shows you context you did not know to ask for.\n\n### Ask for what you need\n\nA tool result stays in your context for the rest of the session, so a read you cannot bound is paid for on every later call. All of these reads take an `include` list:\n\n- `list_comps` / `list_layers` with `include: []` return the id-to-name map alone, which is what orientation actually needs.\n- `get_layer_full` takes `include` (`transform`, `effects`, `masks`, `markers`, `bounds`, `text`, `shape`, `source`), plus `maxKeyframes` to cap the keyframes per property and `shapeDepth` to limit the Contents walk on a heavy shape layer.\n\nOmit them all and you get everything, as before. Whatever they leave out is named and counted in the response \u2014 a bounded read never looks like a complete one.\n\n**Reading a shape layer, use `shapeDetail: "compact"`.** It returns one indented line per group \u2014 the group\'s name, its matchName, then its own properties as `name=value`, with `[3 keys]` or `[expr]` on the animated ones and `(at defaults)` for a group Transform nobody has touched. Every name the write tools address a node by is still on the line, and it costs a fraction of the full JSON form. Reach for `"full"` when you need exact values, keyframe detail or indices.\n\nOne thing is left out of both forms: **Material Options**, the 48-property 3D extrusion block AE hangs off every vector group. It only means anything for an extruded shape under the Cinema 4D renderer, and on the 2D shape layers that are nearly all of them it was most of the weight of the read \u2014 a single 68px circle cost 4,400 tokens, of which the geometry was about 40. `materialsOmitted` counts what was skipped; `shapeMaterials: true` brings it back.\n\n## Identify things by ID, never by index\n\nEvery comp and layer has a stable numeric `id`. Layer `index` is a 1-based position that **shifts whenever layers are added, deleted, or reordered**. Store `(compId, layerId)` and pass those. An index captured before a `create_*` call may point at a different layer by the time you use it.\n\nThe same trap bites inside `run_jsx`: a `comp.layer(1)` wrapper is index-bound, not a handle. Once a `copyToComp` or a `duplicate()` has shifted the destination\'s indices, a reference you took earlier silently resolves to a *different* layer \u2014 which is how a script ends up parenting a layer to itself. Re-resolve by id or name after anything that inserts a layer.\n\n### Reordering the layer stack\n\n`reorder_layer` takes **exactly one** destination. Prefer the id forms:\n`beforeLayerId` puts the layer directly in front of (above) that layer,\n`afterLayerId` directly behind it. `toIndex` is absolute \u2014 1 is the front,\n`numLayers` the back \u2014 and it means the index the layer **ends up at**, not the\nslot it displaces; those two readings differ by one when moving down the stack.\n\nReach for an index only when you genuinely mean "put it on top" or "send it to\nthe back". This is the op that shifts every index below it, so an index you read\nbefore the move may already be stale by the time you use it \u2014 which is the same\nreason nothing else here is addressed by index. The result carries `movedFrom`\nalongside the landed `index`, read back off the layer; equal values mean it was\nalready there.\n\n## Read, then write, then verify\n\n1. Read the current state (`get_layer_full`).\n2. Make the change.\n3. Verify by reading back the properties \u2014 not by screenshotting.\n\nProperty values are the ground truth. A screenshot tells you something *looks* wrong; `get_layer_full` tells you *why*.\n\n### Verify with a diff, not a second full read\n\nRe-reading a comp to see what changed makes you compare two large answers by eye,\nand you pay for both for the rest of the session. Fingerprint it instead:\n\n- `snapshot_comp({compId})` **before** the write returns a `snapshotId` and\n almost nothing else. `diff_comp({since})` afterwards returns only what moved \u2014\n layers added, removed, renamed, retimed, re-parented, keyframe counts that\n changed, expressions and effects gained or lost \u2014 and a count of the layers\n that did not. Tens of tokens where the two reads were thousands.\n- `run_jsx` and `run_batch` take `diff: true`, which does the same thing *inside*\n the call, so there is no window between the write and the fingerprint. On a\n failure the diff rides on the error, which is the cheapest way to find where a\n half-applied script stopped \u2014 nothing rolls back, so that is the question you\n will actually have.\n\nKnow what a fingerprint does **not** record: property values, expression text,\neffect parameters, masks and shape contents. So `changeCount: 0` means none of\nthe recorded fields moved \u2014 not that the comp is unchanged. Retype a text layer\nor change a colour and the diff is empty and correct. For "is this value right",\nread the property.\n\n## Screenshots are a diagnostic, not a feedback loop\n\n`screenshot_frame` and `screenshot_layer` are **one-off checks**. Do not screenshot every frame, do not scrub through time, do not screenshot after every edit.\n\n**To judge motion, ask for a contact sheet \u2014 one call, not three.**\n`screenshot_frame({compId, times: [0, 1, 2]})` takes two to six times and returns\na *single* tiled image with the time burned into each tile, held to roughly the\npixel budget of one frame. It is cheaper than separate calls, it is one image\nresident in your context instead of three, and it gives After Effects one render\nrequest instead of three back-to-back ones \u2014 which is the pattern most likely to\ncome back stale. `time` and `times` are mutually exclusive. There is no `times`\non `screenshot_layer`.\n\n- **An image is the most expensive result this server returns**, and like every result it stays in your context for the rest of the session. One sheet is a budget for a whole build.\n- **The `downsample` is picked from the comp size** unless you pass one \u2014 2 at 1080p, 3 at 4K, aiming at a long edge around 1280px, and per tile on a sheet. Pass `downsample: 1` only when you genuinely need full resolution: a full 4K frame is large enough to blow out your context in one call. It does now mean full resolution \u2014 the render sets the comp\'s resolution explicitly and restores it afterwards, so a viewer the designer left on Quarter no longer silently changes the size of the frame you asked for.\n- The result reports the dimensions actually returned and the factor actually applied \u2014 trust those numbers rather than assuming.\n- **Space single frames out.** Rapid back-to-back requests are far more likely to come back stale than requests a few seconds apart. A sheet does this for you.\n\n### The four results that are not a picture\n\nThey have different causes and opposite remedies, so read which one you got\nbefore you retry anything.\n\n- **`Stale frame`** \u2014 After Effects returned pixels it had already rendered for a *different* request, which the error names. Wait a few seconds and retry at a higher `downsample`; `6` has worked where `3`\u2013`4` stayed stale. If two frames of a genuinely static comp really are identical, a different `downsample` renders a different number of pixels and proves it.\n- **`Corrupt frame`** \u2014 the render stopped writing and the file is not a whole PNG, so nothing was sent. **This is not a timeout.** It tracks how heavy the comp is: retry at `downsample` 6\u20138, or screenshot the shot precomps one at a time instead of the assembly.\n- **`Render timed out`** \u2014 After Effects was still working when the panel gave up. The render is probably still going, so wait a few seconds before doing anything else; a retry issued now queues behind it.\n- **`empty: true`** \u2014 every pixel at that time is fully transparent, so no image was sent. That is a fact about the composition: usually the wrong time, a layer outside its in/out points, disabled, or at zero opacity.\n\nOn a contact sheet a single bad tile is drawn as a marked block and named in `warning` \u2014 the rest of the sheet is still good, so read it rather than re-requesting the whole thing.\n\n**Never disable layers to make a screenshot render.** A frame that will not render is a limit of the panel\'s render path, not project content that needs fixing \u2014 and it is very easy to leave someone\'s comp switched off afterwards.\n\nTo check motion exactly, read the keyframe values. A picture tells you it looks wrong; `get_keyframes` tells you why.\n\n## Bulk work goes through run_batch\n\nBuilding 40 layers with 40 separate calls is slow. `run_batch` runs many ops in\none ExtendScript pass \u2014 far faster, and far fewer undo steps.\n\n**How many undo steps depends on the size, and you have to read it rather than\nassume it.** After Effects discards an undo group opened in one script call and\nclosed in another, which is not written down anywhere in Adobe\'s documentation\nand was measured on 26.3.\n\n- **Up to 500 ops: exactly one undo step.** The whole batch runs inside one\n script call, so the group round it survives. One Cmd-Z takes the lot.\n- **Over 500 ops: one undo step per chunk of 25** \u2014 about 24 for 600 ops. It\n returns a `jobId`, streams progress, and finishes with `await_job(jobId)`.\n- **`singleUndo: true` forces one step at any size up to 2000 ops**, by running\n the whole thing in one blocking call. The cost is real and the user sees it:\n After Effects\' interface is frozen for the entire batch and no progress is\n reported. Over 2000 it is refused rather than freezing AE for minutes.\n\n**Read `undoSteps` before you tell anyone how to undo the work.** Every result\ncarries the *measured* count and a `note` in plain words, and the `{jobId}`\nenvelope carries `undoStepsEstimate` \u2014 which is the only number you have at the\nmoment you would otherwise be promising the user a single Cmd-Z. Never say "one\nundo" for a chunked batch.\n\n**Nothing rolls back, on either setting.** `transactional: true` (the default)\n*stops* at the first failing op; the ops before it stay applied, and the result\nsays `rolledBack: false` and names where it stopped. `transactional: false` runs\nthe rest and collects the errors. Either way, read the state back \u2014 `diff: true`\nappends a structural diff of the comps the batch touched, and on a failure that\ndiff rides on the error, which is the cheapest way to find the stop point.\n\n**You do not have to issue writes one at a time.** The server holds a single\nwrite lock for the whole session, so independent writing calls sent together run\nin the order you issued them, one after another, and a long `run_batch` holds the\nlock until its last chunk lands \u2014 so another call cannot land in the middle of\nwork the user asked for as one thing. A call that had to wait says so with\n`queuedBehind` and `waitedMs`; a call that did not is unchanged. Reads are never\nqueued, so `list_layers`, `get_layer_full` and `await_job` still answer while a\nbatch runs.\n\nPrefer `run_batch` anyway when the work is one user action: one ExtendScript pass\nrather than many, and far fewer undo steps than the same ops sent separately.\n\n## Keyframes and easing\n\n`add_keyframe` sets a value at a time. Interpolation is separate:\n\n- `set_interpolation` \u2014 linear / bezier / hold, per keyframe, in and out.\n- `set_temporal_ease` \u2014 influence and speed, the "easy ease" controls.\n- `set_spatial_tangents` \u2014 the shape of a motion path through a position keyframe.\n\n**Pass one `{influence, speed}` pair per side and nothing else.** `set_temporal_ease` and `add_keyframe` size the ease array themselves and report what the property wanted as `easeDimensions`. That number is worth glancing at, because it is not derivable from the value: a 2D layer\'s Scale takes 2, a shape\'s Ellipse Size takes 3, Opacity and sliders take 1, and a spatial property \u2014 Position, Anchor Point \u2014 takes exactly 1 whether the layer is 2D or 3D, since the ease runs along the motion path rather than per axis. Getting it wrong by hand throws a bare `parameter 2`, which is why you no longer do it by hand. If you are easing a property from `run_jsx` instead, use the `ease()` helper \u2014 it is the same sizing code, not a second copy of it.\n\n## Rigging\n\nNulls, parents and retimed layers. Parenting carries less than people expect, and\neach of the four below has cost a review round more than once.\n\n**Opacity does not propagate through parenting.** Scale, rotation and position\nride the parent; opacity never does. Every text or child layer under a shape that\npops or stamps in needs its own matching opacity keys, or it sits there on screen\nbefore its parent has revealed anything.\n\n**Parent world layers to a camera null *before* you key the null, at a time where\nit is still at identity** \u2014 anchor and position at the comp centre, scale 100.\nParenting compensation is a no-op there, so the children keep plain world\ncoordinates and the keyframes they already had. To look at a world target `T` at\nzoom `s`: key the null\'s scale to `s` and its position to `C + (C \u2212 T)\xB7s/100`,\nwith `C` the comp centre. Children of a precomp layer get parented while that\nparent is at rest, and AE rewrites their position and divides their scale for you.\n\n**Anything flown out of frame is still there when the camera moves.** A layer\nparked at y = \u2212900 comes straight back into shot on a whip-up. Cut its opacity\nonce it is clear rather than trusting the frame edge to hide it.\n\n**Expressions run on comp time, not layer time.** Retiming a layer with\n`startTime` moves its keyframes and leaves its expressions exactly where they\nwere, so a freeze written against `time` replays from zero. Offset `time` inside\nthe expression, or key the value instead.\n\n## Expressions\n\n`set_expression` takes a `propertyPath` such as `["Transform","Position"]` or `["Effects","Gaussian Blur","Blurriness"]`. Expressions are ExtendScript-flavoured JavaScript evaluated by AE per frame.\n\nExpressions are usually a better answer than dense keyframes for anything procedural \u2014 wiggle, loops, counters, follow-through, time remapping. They stay editable by the user afterwards, where a wall of baked keyframes does not.\n\nUse `get_expression` to read one back and `toggle_expression` to disable without deleting.\n\n## Effects\n\nEffects are added by **matchName**, not display name: `add_effect({matchName: "ADBE Gaussian Blur 2"})`. If you do not know a matchName, call `list_available_effects({filter: "blur"})` \u2014 do not guess. `list_effects` shows what is already on a layer, with every parameter.\n\nSet parameters with `set_effect_param` by parameter name (e.g. `"Blurriness"`).\n\n**Never enumerate `app.effects` yourself in `run_jsx`.** There are around 250 of them and reading the table is slow enough to block the bridge past its timeout, which looks exactly like a crash and costs a minute of everyone\'s time. `list_available_effects` does the same enumeration once and caches it for the session, so `filter` searches are free after the first call. A wrong matchName also fails instantly and clearly, so trying `ADBE Slider Control` is cheaper than searching for it.\n\n## Text\n\n`create_text_layer` defaults to `anchorAlign: "left"`, which sets **paragraph justification** and leaves the anchor point at `[0,0]`, so `position` is the start of the first baseline. Pass `"center"` or `"right"` for those, `"none"` for AE\'s raw behaviour. Because the alignment is justification rather than a measured offset, it stays correct when the text changes later \u2014 retyped, driven by an expression, or edited through Essential Graphics in Premiere. Never "fix" alignment by writing an anchor point computed from `sourceRectAtTime()`: it is right once and wrong from the next edit onward.\n\nTracking is set to `0` unless you pass one, because AE\'s `addText()` otherwise inherits whatever the user\'s Character panel was last left on.\n\n`set_text` controls font, size, colour, tracking, leading and justification. To auto-fit a background to text, read `sourceRect` from `get_layer_full` and size the shape from its width and height plus padding.\n\n## Shapes\n\n`create_shape_layer` puts the new layer\'s origin at `[0,0]` with its anchor at `[0,0]`, so **the layer\'s coordinate space is the comp\'s** and every vertex, rect position and path you add afterwards is in comp pixels. After Effects\' own spawn point is the comp centre, which silently offsets a drawing authored in comp coordinates by half a frame; pass `position: "center"` if you want that back, or any `[x,y]` to place the origin yourself. The result echoes the position and anchor it ended up with.\n\n`add_shape_content` builds one node at a time under `Contents` \u2014 `rect`, `ellipse`, `star`, `path`, `fill`, `stroke`, `trim`, `repeater`, `merge`, `group`. Properties are set with friendly names in the same call (`size`, `position`, `roundness`, `color`, `width`, `lineCap`, \u2026).\n\nThis tool is **all-or-nothing**: if a key cannot be applied, the whole node is removed and you get an error naming the bad key. A success result therefore means everything landed. Don\'t add defensive re-reads for it, but do read the error carefully \u2014 it usually means the property is named differently on that node type, and `get_layer_full` will show you the real name.\n\nFor a custom path, use `{type: "path", vertices: [[x,y], \u2026], closed: true}`. The key is `vertices`, not `points`.\n\n**Render order is the opposite of the layer stack.** Inside `Contents`, index 1 renders in *front*, and each `add_shape_content` call appends behind the previous one. So build **front-to-back**: details, text plates and traffic-light dots first, the big background rectangle last. Getting it backwards is silent \u2014 no error, just a solid slab where your artwork should be. `zOrder: "front"` will place a node at index 1 for you, but it needs an internal `moveTo`, which has been seen to disturb *nested* renders of the comp in AE 26.3; prefer ordering your calls. If existing shape *content* is already in the wrong order, rebuild it rather than reordering, and verify with a screenshot of a comp that **nests** it, not just the comp that owns it. None of this applies to the layer stack: moving whole layers is `reorder_layer`, which is a different mechanism with none of these caveats.\n\n**Node references go stale.** Adding a sibling to a group invalidates a reference you already hold to another node in it \u2014 add a Stroke and an earlier Fill reference starts throwing `Object is invalid`. Add every node first, then set values and expressions by addressing nodes by name.\n\n## Sound\n\n`place_audio_cues` scores a scene in one call: a list of cues \u2014 each a file (or an\nalready-imported `footageId`), a comp `time` and a `levelDb` \u2014 becomes one\naudio layer each, imported once however many cues name the same file, named,\ntrimmed, labelled, in a single undo step. Reach for it the moment you are placing\nmore than two or three sounds.\n\nIt is all-or-nothing: every cue is checked (the file exists, the item has an\naudio track, the time is inside the comp) before a single layer is made, and if a\nlater one still fails, everything the call created is removed and the error names\nthe cue by index. `dryRun: true` checks a list against the project without\nimporting, creating, or even adding an undo step. `levelDb` is decibels, AE\'s own\nunit \u2014 `0` is the file as recorded, negative is quieter \u2014 and `inPoint`/`outPoint`\ntrim in **comp** time, not file time.\n\n## The escape hatch\n\n`run_jsx` executes arbitrary ExtendScript. Reach for it when a needed operation has no tool \u2014 driving the render queue, batch-renaming, a bulk edit no single op expresses. Check the tool list first: duplicating a comp, easing a keyframe, reordering a layer and placing a shape layer at a sane origin all have tools now, and each of them wraps a trap you would otherwise hit. `reorder_layer` is worth singling out \u2014 it was broken for three releases, so an agent may have learned to route around it; the way round it in ExtendScript is `layer.moveTo()`, which does not exist and never worked.\n\n**Read the gotchas before you write one** \u2014 `ae_guide({topic: "extendscript-gotchas"})`, or `references/extendscript-gotchas.md` beside this file. It is the list of things that fail while naming something else: a property lookup that returns null and surfaces twenty lines later, a `copyToComp` that does not insert where you think, a reserved word that stops the whole script before its first line. Every item on it has already cost somebody an aborted run.\n\nThe reference also carries the two things that make a script cheap to write: `scriptPath` and `libraries`, which keep a long script and its shared helpers out of the conversation entirely, and the helpers already in scope \u2014 including the whole `OPS` table, so every tool on this server is callable from inside a script.\n\nExtendScript is **single-threaded**, so a long synchronous loop freezes the user\'s AE UI. Keep the script short.\n\n`return X` sends the whole value back \u2014 arrays and nested objects included. Values that cannot be represented (functions, live AE objects, cycles) come back as a marker string in place, never dropped \u2014 a live object as `"[AVLayer \\"Hero\\" #616]"`, which is a handle to pass to `get_layer_full`, not a copy of the layer. So an empty result genuinely means the script returned nothing; never read one as "nothing happened".\n\n**A bare expression is not a return.** `"ping";` as the last line yields nothing, and so does any script that just does its work. That case comes back as `{ok: true, returned: null, undoGroup, note}` \u2014 an envelope that says *the script ran to completion*. Do not re-run it. Nothing rolls back, so a second run of a script that duplicated a layer, reordered content or wrote keyframes applies all of it twice; read the state back instead, and add an explicit `return` if you want a value.\n\n**On a failure, read the error before you touch anything.** It names the line of *your* script and prints that line\'s text; when the number cannot be mapped honestly it says so rather than guessing at one. Everything above that line already ran and nothing rolls back \u2014 so find out what landed, never re-run the script to see whether it fails again. `diff: true` is the fast way to find out: it fingerprints the comp around the script and appends only what changed, and on a throw that diff rides on the error message.\n\n### Exporting a Motion Graphics template\n\nUse **`export_mogrt`**. Do not drive `comp.exportAsMotionGraphicsTemplate` from `run_jsx`. That call raises modal dialogs, and a modal dialog freezes this whole connection until someone clicks it in After Effects \u2014 but suppressing them, which is what you have to do, costs you the only channel AE has for saying why an export failed. It answers with a bare boolean and nothing else. So the tool checks every precondition it can *before* exporting, which is the half of its job you cannot do from a script.\n\n`export_mogrt` handles all of it: it saves the project first (which is what removes AE\'s "the project needs to be saved" prompt, and it has to happen per export because exporting dirties the project again), it suppresses the font warning, and it runs outside the undo group so there is no "undo group mismatch" afterwards. Measured on 26.3: suppressed, an export of a comp using a non-Adobe font returns in about three seconds; unsuppressed, the same export sat past sixty and wrote nothing until the dialog was clicked.\n\nFour things worth knowing before you call it. The first is the one that actually stops exports:\n\n- **The comp needs at least one property in its Essential Graphics panel.** After Effects will not build a template from an empty one, and it refuses silently \u2014 no file, no dialog, not a word. The tool checks the controller count first and refuses before touching anything. The fix is in AE and the user has to do it: Window > Essential Graphics, pick the comp, drag a layer property in.\n- **The project must have been saved once, by hand.** There is no folder to save into otherwise, and the tool refuses rather than raising a dialog the user was not expecting.\n- **`name` is the filename.** It defaults to the comp name, because AE\'s own default is the literal `Untitled` \u2014 leave it to AE and every template in the project overwrites the same file.\n- **`fonts` in the result lists what the template will require.** Tell the user about any non-Adobe ones: Premiere flags the template as needing fonts it cannot supply, and that is worth hearing from you rather than discovering later.\n\n**If an export fails anyway, read the message rather than guessing.** It lists what was checked and ruled out, and it only names a modal dialog when dialogs were left *unsuppressed*. Under the default suppression a dialog is impossible by construction, so the cause is genuinely unknown \u2014 say so, and tell the user the one place AE\'s own reason exists: exporting the same comp by hand from the Essential Graphics panel, where AE shows its error in the interface. Do not send them looking for a dialog that cannot be there.\n\n**The thumbnail.** AE writes the comp\'s *first frame* into the template, so anything that fades up from nothing gets a black one. Pass `posterTime` with a moment that actually shows the design and it is rendered and swapped in. If only the thumbnail fails the export still succeeds \u2014 check `thumbnail.patched` in the result.\n\nAlso note that `comp.setMotionGraphicsControllerName(index, \u2026)` numbers controllers in **reverse order of addition**: index 1 is the one you added last.\n\n**If any long call seems to have hung, assume a dialog before you assume a crash** \u2014 it may be behind another window. `comp.saveFrameToPng(...)` from `run_jsx` raises the save prompt the same way; use `screenshot_frame`, which does not.\n\n### Importing footage, and the SVG trap\n\nUse **`import_footage`**, then **`create_footage_layer`** to place the item in a comp. (For a comp as a layer, `create_precomp_layer`.)\n\n`import_footage` checks what AE actually produced, because one case fails silently: an SVG with a very large `viewBox` (say `0 0 278050 333334`) imports with **fabricated dimensions and renders as nothing**, no error at any stage. Verified on 26.3 \u2014 that viewBox yields a 15906x5654 item that will not even rasterize. The tool compares the aspect ratio the file asks for against the one AE produced, and on a mismatch it deletes the item and throws, rather than handing you an asset that looks healthy in the project panel and renders empty.\n\nIf you hit that, the workarounds are:\n\n- **Simple flat SVGs** \u2014 rebuild the path as a shape layer with the real vertices, scaled down to a sane coordinate space (divide by `333.334` for a 1000px version), set the fill from the SVG, and set `ADBE Vector Fill Rule` to `2` when the SVG says `fill-rule="evenodd"`. Done this way the result is pixel-accurate.\n- **Complex SVGs** \u2014 rasterise to PNG outside AE, or normalise the `viewBox` to a small coordinate space before importing.\n\n`force: true` keeps the item and reports the problem in `validation` instead of throwing. It is for when you know the dimensions are wrong and want it anyway \u2014 not a way past the error.\n\n## When something costs you real time\n\nThese tools have rough edges, and the same ones catch every session. Two tools\nexist so that each one is only paid for once.\n\n**`list_known_issues`** \u2014 what earlier sessions hit and how they got past it.\nRead it when a tool fails in a way you do not immediately understand, before you\nstart guessing. The answer is often already there. It comes back as a one-line\nindex, so open the entry that looks like your failure with\n`list_known_issues({id})` \u2014 the cause and the workaround are in the entry, not in\nthe index. `tool` and `query` narrow it further.\n\nThere are two journals and every entry says which it came from. `project` is\nthis project\'s own notes; `user` travels with the person across every project.\nIds are only unique within a journal, so open an entry with the qualified form\nthe listing\'s `next` pointer shows you \u2014 `list_known_issues({id: "user:\u2026"})`.\n\n**`log_issue`** \u2014 write down what you worked out, the moment you work it out.\n\nLog something when all three are true: it cost real effort, it was the tool\'s\nfault rather than yours, and the next session would hit it too. A schema that\naccepts an argument AE then rejects, an error message that names the wrong\nthing, a property whose real name is nothing like its display name. Not your own\ntypos. Not "I forgot the layer was 3D".\n\nWrite the entry for someone who has not seen the failure: the exact error text,\nthe call that produced it, and a workaround concrete enough to apply directly.\nReuse the existing title when you are extending an entry \u2014 that keeps one good\nrecord instead of five thin ones.\n\n**Pick the scope by what the entry is about, not by where you are.** Leave it at\nthe default `project` for this project\'s footage, comps or files. Pass\n`scope: "user"` when it is about how these tools or After Effects behave \u2014 that\nis nearly everything worth logging, and it is the difference between the next\nproject starting out knowing it and re-learning it.\n\n### Then offer to pass it on\n\nIf `log_issue` comes back with `reported: false`, mention it to the user \u2014 but\nfinish the actual work first, and put it at the very end, after you have told\nthem what you built. It is a footnote, not the headline.\n\nSay it the way you would to a colleague who does not write code. What you were\ntrying to do, that it fought back, that you got there anyway, and that you can\nsend it to the people who maintain the tool so the next person does not lose the\nsame time. Something like:\n\n> Done \u2014 the lower third is in. One thing worth mentioning: getting the ease\n> onto that position keyframe took a lot longer than it should have, because the\n> tool kept rejecting a value it had just asked for. I found a way around it and\n> made a note. Want me to send it to the people who maintain this so they can\n> fix it properly?\n\nDo not say "GitHub issue", "file a bug" or "open a ticket" unless they say it\nfirst. If they say yes, use the **report-ae-issue** prompt this server provides\n(`/report-ae-issue` where your client exposes prompts as commands) \u2014 it handles\nthe rest. If they say no, drop it; the note stays and can be offered again\nanother time.\n\nNever claim you have reported something you have not.\n\n## When something is not connected\n\nIf a tool reports it cannot reach After Effects, call `check_setup` and relay its `nextSteps` to the user in plain language. Do not try to diagnose CEP by hand.\n\n**A timeout is not proof the bridge is dead.** The error that says the panel did not answer in time is a different thing from the one that says the panel cannot be reached. Because ExtendScript is single-threaded, a busy After Effects cannot answer anything \u2014 so a long script, or a modal dialog nobody has clicked, is indistinguishable from a crash at this layer. It normally recovers on its own within a minute.\n\nSo when a call times out: do not re-send it (you would queue the same work twice), do not restart After Effects, and do not run `setup_panel`. Poll `check_setup` for about a minute first. Two causes worth asking about directly:\n\n- **A dialog is waiting.** Ask the user to check After Effects for a prompt hiding behind another window.\n- **They changed desktop.** On macOS, calls have been reported to stall while the user is on a different Space and to complete as soon as they return. If they have wandered off, ask them to switch back to the desktop After Effects is on before you diagnose anything else.\n\nIf a specific operation of yours legitimately needs longer than the limit, the user can raise it by setting `AE_MCP_OP_TIMEOUT_MS` in the server\'s environment.\n\n**A dropped write is the opposite case, and it is safe to re-send.** If a call\ncomes back saying it *waited behind* another op for the write queue and was\ndropped without running, the bridge is fine and nothing reached After Effects.\nSomething in front is slow \u2014 usually a long `run_batch`, occasionally a modal\ndialog. Find out what with `get_job` or `await_job`; reads are never queued, so\n`list_*` and `get_*` still answer and will tell you the current state. Then\nre-send once the work in front has finished. That is exactly what you must *not*\ndo after a bridge timeout, so read which of the two you got.'
2713
+ },
2714
+ {
2715
+ name: "extendscript-gotchas",
2716
+ description: "The ExtendScript facts that cost a run_jsx script an aborted run \u2014 property names that return null, shape node types that hide their own properties, what copyToComp actually does, comp time versus layer time, and the reserved words that stop a script before its first line. Read this before writing raw ExtendScript, not after it fails.",
2717
+ body: '# ExtendScript gotchas\n\nEverything here was paid for once already. One video\'s build \u2014 fourteen scenes,\neach assembled by a fresh agent, roughly a thousand layers \u2014 lost at least one\naborted script to every item below, and three to some of them. Measured on After\nEffects 2026 (26.3) through this server at 0.3.1.\n\n**Adobe\'s scripting documentation describes an ExtendScript that After Effects\n2026 does not implement.** This is not a general caution; it is the single\nlargest source of shipped bugs in this project, and every instance was found by\nmeasuring rather than by reading. In one release: `$.evalFile` is documented as\nevaluating at global scope and in fact evaluates into the calling function\'s\nscope, so a helper library defined nothing anything could call. `Error.start`\nand `Error.end` are documented as character offsets into the source and are\nin fact `0` on every error, however far in the throw was \u2014 so code that trusted\nthem reported line 1 for everything. An undo group opened in one script call and\nclosed in another is silently discarded, so a 600-op batch that claimed to be one\nundo step was six hundred. `CompItem.posterTime` does not exist at all. And\n`exportAsMotionGraphicsTemplate` invalidates `app.project` itself, not just the\ncomp you passed it.\n\nNone of these raised. Each one returned exactly what success returns. So when\nthis page and Adobe\'s reference disagree, this page was measured; and if you are\nabout to rely on a documented behaviour that nothing here mentions, write the\nthree-line probe that proves it before you build on it.\n\nRead it **before** you write the script. Most of these fail in a way that names\nthe wrong thing: a null returned here surfaces as `null is not an object` twenty\nlines later, and a reserved word means **nothing in the script runs at all**\nwhile the error points at one line in the middle.\n\nThree rules that make all of it cheaper:\n\n- **Nothing rolls back.** A script that fails halfway leaves everything before\n the failure applied. A failure names the line of your script and prints its\n text, so start there \u2014 then read the state back, or pass `diff: true` and let\n the call tell you what landed. Never re-run a mutating script to see whether\n it fails again.\n- **Prefer the tool.** Most of what follows is a trap that only exists because\n you dropped to raw scripting. `add_shape_content`, `set_temporal_ease`,\n `create_shape_layer`, `place_audio_cues`, `duplicate_comp` and `parent_layer`\n resolve names, sizes and orders for you and fail loudly when they cannot. Each\n item below names the tool that already handles it, where there is one.\n- **Do not paste what is already in scope.** See "What you already have" at the\n end before you write a helper of your own.\n\n## Property names that return null\n\n`layer.property(matchName)` returns **null** for a name that does not exist on\nthat layer. It does not throw, so the failure lands later, somewhere else.\n\n- **2D rotation is `ADBE Rotate Z`.** `layer.property("ADBE Rotation")` is null.\n `layer.transform.rotation` is the safe form.\n- **Audio levels are `layer.audioLevels`.**\n `layer.property("ADBE Audio Levels")` is null on an audio layer, which is the\n trap a hand-written sound-placement loop hits on its first cue. *The tool\n already does:* `place_audio_cues` places a whole cue list in one undo step,\n all-or-nothing, with a `dryRun`.\n- **Time remap is an attribute, not a property lookup.**\n `layer.property("ADBE Time Remap")` is null on audio layers and on precomp\n layers, *even after* setting `timeRemapEnabled = true`. Use `layer.timeRemap`.\n Audio levels are `layer.audioLevels` the same way.\n- **`instanceof` is unreliable on host objects.** `x instanceof Layer` cannot be\n trusted to identify a layer kind. Probe instead: a shape layer is one where\n `property("ADBE Root Vectors Group")` is non-null.\n\nWhen a lookup might miss, check for null on the line that does the lookup. That\nturns a misleading error twenty lines away into an accurate one here.\n\n## Shapes\n\n- **`comp.layers.addShape()` spawns the layer at the comp centre** (960,540 at\n 1080p, 1920,1080 at 4K) with its anchor at (0,0). If you then build contents in\n comp coordinates the whole drawing lands offset by half a frame. Zero the\n position immediately, before you add anything to `Contents`. *The tool already\n does:* `create_shape_layer` defaults to `[0,0]` (`position: "center"` restores\n AE\'s spawn point), and the `shape(comp, {name, position})` helper does it\n inside a script.\n- **Polystar type is `1 = Star`, `2 = Polygon`** on `ADBE Vector Star Type`.\n Type 2 *hides* Inner Radius, so setting inner radius after choosing polygon\n throws `property is hidden`. A gear is type 1.\n- **Stroke dashes do not take.** `addProperty("ADBE Vector Stroke Dash 1")`\n followed by `setValue` throws the same hidden-property error on this build. A\n dashed or hazard-tape band that does work: a small square plus a Repeater\n (`ADBE Vector Repeater Transform` \u2192 `ADBE Vector Repeater Position` set to\n `[2 * side, 0]`) in a group in front, over a plain rect in a group behind.\n- **Render order is the reverse of the layer stack.** Index 1 in `Contents`\n renders in front and `addProperty` appends to the end, so the first node you\n add is the one on top. Build front-to-back.\n- **`moveTo` is a shape-node method, not a layer method.** It re-ranks a\n `PropertyBase` inside an indexed group, so `layer.moveTo(n)` throws\n `parent is not an INDEXED_GROUP` \u2014 an error naming a concept the caller never\n mentioned. Layers move with `moveBefore` / `moveAfter` / `moveToBeginning` /\n `moveToEnd`, all four of which take a **layer**, never an index, and the two\n directions need different primitives: moving up, `moveBefore` lands on the\n target; moving down, the target shifts up as the layer leaves, so `moveAfter`\n is the one that lands on it. Getting that backwards is off by one with no\n error. *The tool already does:* `reorder_layer`, with `beforeLayerId` /\n `afterLayerId` / `toIndex`.\n- **A node reference goes stale when a sibling is added.** Hold a Fill, add a\n Stroke to the same group, and the Fill reference starts throwing\n `Object is invalid`. Add every node first, then re-fetch by name before\n setting values.\n\n## Layers and comps\n\n- **`copyToComp` does not put the copy at index 1.** The first copy lands on\n top; each later copy lands *below the previous copy*, so `dest.layer(1)` keeps\n handing back the same layer while you think you are collecting new ones.\n Identify the copy by diffing the set of layer ids before and after the call.\n- **`copyToComp` needs the undo group closed.** AE refuses to copy a layer that\n has a parent or a linked expression while an undo group is open \u2014 which is\n exactly the rig worth copying. Wrap that one call in\n `withoutUndoGroup(function () { \u2026 })`, or pass `undoGroup: false` for the whole\n script and accept whatever undo steps AE records on its own.\n- **A copied layer carries parent-relative values.** If it was parented to a null\n that was not at identity, it renders offset in the destination. Check the first\n frame against the source; fix it with an intermediate null carrying the inverse\n offset, re-parenting while the new parent is at identity.\n- **A comp layer renders nothing before its `startTime`.** You cannot hold a\n rigged precomp at its pre-animation state by pushing `startTime` past the shot\n \u2014 you get an empty frame. Freeze the start by duplicating the comp and\n stripping the keys; freeze the end past the source duration with time remap and\n `Math.min(time + off, dur - 0.1)`.\n- **Set the parent first, the transform second.** Raw `layer.parent = x` inside a\n script does not reliably preserve where the layer sits two levels deep, so\n after scripted parenting audit scale and rotation as well as position.\n `parent_layer` does this correctly and takes `preserveTransform`.\n- **`app.executeCommand(id)` silently no-ops** through this bridge. Menu commands\n depend on host focus and the active selection, and this bridge has neither. It\n returns without complaint, which is how `run_batch`\'s "transactional rollback"\n spent three releases firing `findMenuCommandId("Undo")` and undoing nothing \u2014\n while reporting that it had. Use the API equivalents \u2014\n `CompItem.duplicate()`, `layer.duplicate()`.\n *The tool already does:* `duplicate_comp` returns the new comp id, and\n `deep: true` duplicates the nested comps and re-points the copy at them, which\n `CompItem.duplicate()` alone does not.\n\n## Undo groups\n\n- **An undo group does not survive a script boundary.** `beginUndoGroup` in one\n `evalScript` call and `endUndoGroup` in another produces no group at all \u2014\n After Effects discards it, `endUndoGroup()` returns exactly as it does on\n success, and the only place the truth is visible is AE\'s Edit menu. Anything\n spanning calls has to open and close its own group per call and count them.\n *The tool already does:* `run_batch` reports the measured `undoSteps`, and\n `singleUndo: true` buys one step by staying inside a single call.\n- **`copyToComp` needs the group closed**, which is the opposite problem \u2014 see\n Layers and comps above.\n\n## Keyframes and expressions\n\n- **`setTemporalEaseAtKey` sizes its ease array per property, not per value\n dimension.** A 2D layer\'s Scale wants 3, a shape Ellipse Size wants 2, a slider\n or Opacity wants 1, and a *spatial* property \u2014 Position, Anchor Point \u2014 wants\n exactly 1 whether the layer is 2D or 3D, because the ease runs along the motion\n path rather than per axis. The wrong count throws about `parameter 2` or\n `Value array does not have 1 elements`. If you are scripting this by hand, try\n 1 then 2 then 3 in a try/catch \u2014 or, better, do not: the `ease(prop, keyIndex,\n easeIn, easeOut)` helper is in scope and *is* the sizing code\n `set_temporal_ease` uses, not a second copy of it, so a script and a tool call\n can never disagree about what a property wanted. A bare number means\n influence; omitting `easeOut` uses the same ease both sides; the return value\n is the number of entries that worked. *The tool already does:*\n `set_temporal_ease` and `add_keyframe` take one `{influence, speed}` pair per\n side and report `easeDimensions`.\n- **The key lookup is `nearestKeyIndex(t)`**, not `nearestKeyAtTime`.\n- **Expression `time` is comp time, not layer time.** Shifting a layer\'s\n `startTime` moves its keyframes and leaves its expressions where they were, so\n a `Math.min(time, 0.75)` freeze replays from zero in the shifted copy. Rewrite\n `\\btime\\b` to `(time + offset)` in the expressions of any layer you retime.\n\n## The language itself\n\nExtendScript reserves words JavaScript does not: **`short`, `int`, `char`,\n`byte`, `long`, `float`, `double`, `boolean`**. `var short = \u2026` fails with\n`Illegal use of reserved word` and **nothing in the script runs** \u2014 every\nside effect you expected is simply absent, which reads exactly like a bridge\nfailure. `s`, `n`, `count`, `flag` cost nothing.\n\nThe rest of the dialect is ES3: no `let`/`const`, no arrow functions, no\ntemplate literals, no `Object.keys`, no destructuring, no trailing commas.\n\nIt is also **single-threaded**, so a long synchronous loop freezes the user\'s AE\nwindow and looks to this server exactly like a crash. Keep a script to work that\nfinishes in seconds; anything longer belongs in `run_batch`.\n\n## When a script fails halfway\n\n**The error names the line of your script and prints its text.** Believe it \u2014\nand when it says the number could not be mapped, believe that too: it is refusing\nto guess rather than pointing you at a plausible wrong line.\n\nEverything above that line already ran. To find out exactly what landed, pass\n`diff: true` and the call appends what changed to the error itself; otherwise\nread it back with `diff_comp`, `get_comp_tree` or `find_layers`. **Never re-run\nthe script to see whether it fails again** \u2014 nothing rolled back, so the lines\nabove the failure apply a second time.\n\nAnd remember what a *successful* script with no `return` looks like:\n`{ok: true, returned: null, undoGroup, note}`. That is completion, not failure.\nRe-running it applies every mutation a second time.\n\n## What you already have\n\nBefore you write a helper, check it is not in scope. Every one of these wraps a\ntrap on this page, and a version you write yourself re-derives the bug.\n\n- **`OPS`** \u2014 the whole tool table, callable from inside a script:\n `OPS.set_transform({compId, layerId, position: [0, 0]})`. Anything a tool\n already does well, do it this way rather than reaching into the DOM.\n- **`compById(id)` / `layerById(compOrId, layerId)`** \u2014 the pair every tool\n returns, resolved. No index arithmetic.\n- **`walkProperty(layer, ["Transform", "Position"])`** \u2014 a property path,\n resolved the same way the tools resolve it.\n- **`addKeys(prop, [[t, v], \u2026])`** \u2014 returns the key index of each, in order, so\n the next call can ease them without searching.\n- **`ease(prop, keyIndex, easeIn, easeOut)`** \u2014 sizes the KeyframeEase array.\n- **`shape(comp, {name, position})`** \u2014 a shape layer at `[0,0]`.\n- **`withoutUndoGroup(fn)`** \u2014 closes the undo group around one statement, which\n is what `copyToComp` needs.\n\nAnd two arguments rather than more code: **`scriptPath`** runs an absolute `.jsx`\npath so a long script never enters the conversation at all, and **`libraries`**\ntakes absolute `.jsx` paths and inlines them ahead of the script, in the same\nscope, so their functions are callable from it. The server reads both, so\nneither file\'s text enters the conversation. A library is re-evaluated on every\ncall \u2014 keep libraries to declarations rather than to work \u2014 and a failure inside\none is reported against that file, by name and line. Keep shared helpers in a\nlibrary instead of pasting them into every script.'
1942
2718
  },
1943
2719
  {
1944
2720
  name: "style-guide",
1945
2721
  description: "Help a motion designer capture their house style \u2014 palette, type, motion and layout \u2014 into the house-style.md file that sits next to their After Effects project and shapes everything built afterwards. Load when the user asks to create, edit or review their style guide, when they say work does not look like theirs, or when get_house_style reports none exists.",
1946
2722
  body: '# Capturing a house style\n\nA house style is the difference between an assistant that builds *a* lower third\nand one that builds *their* lower third. It lives in `house-style.md` beside the\n`.aep` file, and `get_house_style` reads it before any build task.\n\nYour job here is to get one written with as little effort from the user as\npossible. They are a motion designer. They know exactly what their work looks\nlike and will struggle to dictate it as a specification \u2014 so do not ask them to.\n\n## Two ways in. Prefer the first.\n\n### 1. Read it off work they already like\n\nThis is far better than any questionnaire, because it produces real numbers\ninstead of adjectives.\n\n1. Ask which comp to learn from \u2014 "point me at something that looks the way you\n want everything to look."\n2. `get_comp` for size and frame rate, then `get_layer_full` on the layers that\n carry the look: the text, the background, the accent shapes.\n3. Pull out the concrete values \u2014 hex colours, font families and sizes, tracking,\n corner radii, stroke widths, the position of things relative to the frame.\n4. Read the keyframes too. `get_keyframes` plus the ease settings tell you the\n timing signature: how long a standard in-animation takes, whether it\n overshoots, whether anything is ever linear.\n5. Show them what you found, in their language, and ask what to change:\n\n > Here\'s what I read off that comp: near-black background `#0B0D12`, white\n > text in Inter Semibold at 64px with slightly tight tracking, one green\n > accent `#3DC46E`. Things scale in over about 0.4s with an overshoot to 108%\n > and easy ease on both ends. Nothing sits perfectly still \u2014 there\'s a slow\n > wiggle on the chip. Does that sound like your style, or was that comp a\n > one-off?\n\n6. Write it with `set_house_style`.\n\n### 2. Ask, when there is nothing to read\n\nOnly if the project is empty or they have no reference. Keep it to four\nquestions, and offer concrete options rather than open ones \u2014 "dark or light\nbackground?" beats "what\'s your palette?". Then build one small example, show it\nwith `screenshot_frame`, and refine from their reaction. Reacting is easier than\nspecifying.\n\n## What makes a guide that actually works\n\n**Numbers, not adjectives.** `#131521 at 92% opacity` is usable. "Dark and clean"\nis not. If a corner radius, a stroke width or a hold duration matters, write the\nnumber. Anything vague will be silently reinterpreted every time it is read.\n\n**Rules, not just values.** The most valuable lines are the prohibitions: "never\nput text directly on footage \u2014 always on a rounded chip", "keep total runtime\nunder 8 seconds", "no linear motion unless something mechanical is moving".\nThose are what stop work drifting.\n\n**Only what you verified.** Do not pad the file with plausible-sounding defaults\nthey never asked for. A short guide that is true beats a complete one that is\nhalf invented. Leave a heading empty rather than filling it with a guess.\n\n## Keep it current\n\nWhen the user corrects the same thing twice \u2014 "no, the accent green, not the\nblue" \u2014 that is a missing rule, not a one-off. Offer to add it:\n\n> I\'ve had to switch that green twice now. Want me to put it in the style guide\n> so it\'s the default from here?\n\nRead the existing guide with `get_house_style` before writing, and preserve what\nis already there. `set_house_style` replaces the whole file, so send back the\nfull document, not just your additions.\n\n## The one thing to warn them about\n\n`house-style.md` is written next to the `.aep`, so **the project has to have been\nsaved at least once** \u2014 an unsaved project has no folder to write into, and\n`get_house_style` will say so. If that happens, ask them to save the project\nfirst, then write the guide.\n\nThe file is plain markdown. Tell them where it is and that they can edit it in\nany text editor without going through you.\n\n## Starting point\n\nWhen writing a guide from scratch, this is the shape to fill in. Drop headings\nyou have nothing real to put under.\n\n```markdown\n# House style\n\n## Palette\n| Role | Colour | Notes |\n|---|---|---|\n| Background | `#0B0D12` | |\n| Primary text | `#FFFFFF` | |\n| Accent | `#3DC46E` | Emphasis and positive values |\n| Negative | `#E03333` | |\n\n## Type\n- Headings: Inter Semibold, 56\u201372px, tracking -10\n- Body: Inter Regular, 28\u201334px\n- Left-aligned unless stated otherwise\n\n## Motion\n- Standard in: scale 0 \u2192 108 \u2192 100, easy ease, ~0.4s\n- Standard out: scale \u2192 0, ~0.3s\n- Easy ease on everything; no linear motion unless mechanical\n- Subtle wiggle on position so nothing sits perfectly still\n\n## Layout\n- 1920\xD71080 at 30fps\n- 120px safe margin from every edge\n- Lower thirds sit bottom-left, above the margin\n\n## Rules\n- Never put text directly on footage \u2014 always on a rounded chip\n- Total runtime under 8 seconds\n```'
2723
+ },
2724
+ {
2725
+ name: "whats-new",
2726
+ description: "What changed in the After Effects tools recently, newest first \u2014 the behaviour differences worth knowing if you last used an older build, and the version gate you will meet after an upgrade. Read it when a call behaves differently from what you expected, or when the user says a tool used to do something else.",
2727
+ body: '# What changed\n\nRead this when something behaves differently from what you expected, or when the\nuser tells you a tool used to work another way. It is the release notes an agent\nneeds rather than the ones a human reads.\n\n**When this topic and a tool\'s own schema disagree, believe the schema.** It is\nrefreshed at each release and a build in between can be ahead of it.\n\n## 0.4.0\n\nThe release where verifying a change stopped meaning reading the whole thing\nback \u2014 and where a live pass against After Effects 2026 found four things this\nserver had been claiming that were never true. Those are first, because an agent\nthat learned the old story will otherwise repeat it to a user.\n\n### Things that were never true, and are now fixed\n\n- **`run_batch` was never one undo step, and over 500 ops it still is not.** The\n guarantee it shipped with did not exist on either path: After Effects discards\n an undo group opened in one script call and closed in another, so a 600-op\n batch landed as about six hundred separate steps while reporting one. Now: up\n to 500 ops is genuinely one step; over 500 is **one step per chunk of 25**,\n around 24 for 600 ops, with the measured count in `undoSteps` and a `note`\n saying it in words. `singleUndo: true` forces one step up to 2000 ops by\n running the batch in a single blocking call \u2014 which freezes AE\'s interface for\n the duration and reports no progress. Read `undoSteps` before you tell anyone\n how to undo the work.\n- **`transactional: true` never rolled anything back.** It fired one menu-command\n Undo, and menu commands silently do nothing over this bridge \u2014 and one Undo is\n one step, not a batch. It stops at the first failure, as it always really did,\n and now says `rolledBack: false` and names the stop point. The ops before the\n failure stay applied. Use `diff: true` to see what landed.\n- **`reorder_layer` had never worked at all.** Every call it ever served threw\n `parent is not an INDEXED_GROUP`, because it used a shape-node method on a\n layer. It now takes exactly one of `beforeLayerId`, `afterLayerId` or\n `toIndex`, prefers the id forms because this is the op that invalidates\n indexes, and `toIndex` means the index the layer **ends up at**.\n- **`downsample: 1` was not full resolution.** It skipped setting the comp\'s\n resolution at all, so it inherited the viewer\'s Resolution dropdown \u2014 on a comp\n a designer had left at Quarter, `downsample: 1` returned a quarter-size frame\n and `downsample: 2` returned one four times larger. The response was honest\n about the dimensions; the picture was not the one asked for. Factor 1 is now\n set explicitly like any other and restored afterwards.\n\n### Two calls changed under you\n\n- **`create_shape_layer` now spawns at `[0,0]`**, with the anchor at `[0,0]`, so\n the layer\'s coordinate space *is* the comp\'s and every vertex, rect position\n and path you add afterwards is in comp pixels. After Effects\' own spawn point\n is the comp centre, which silently offsets a drawing authored in comp\n coordinates by half a frame \u2014 easy to miss on a downsampled screenshot.\n `position: "center"` restores AE\'s behaviour, and any `[x,y]` places the origin\n yourself. The result echoes what it ended up with.\n- **`get_house_style` returns a digest, not the document.** A few hundred tokens:\n palette as named hexes, type, motion, layout, and a note naming anything it\n could not summarise. Pass `detail: "full"` for the whole thing \u2014 you need it\n before `set_house_style`, which replaces the file rather than patching it. An\n unstructured guide comes back `structured: false` with its opening text, which\n is an honest "I could not read this as a spec" rather than an empty answer.\n\n### Verify with a diff instead of a second read\n\n- **`snapshot_comp` and `diff_comp`.** Fingerprint a comp before a write, then\n ask what moved: layers added, removed, renamed, retimed, re-parented, keyframe\n counts, expressions and effects gained or lost \u2014 and a count of the layers that\n did not move. Tens of tokens where two full reads were thousands. It does\n **not** record property values, expression text, effect parameters, masks or\n shape contents, so `changeCount: 0` means none of the recorded fields moved,\n not that the comp is identical.\n- **`diff: true` on `run_jsx` and `run_batch`** does the same inside the call, so\n there is no window between the write and the fingerprint \u2014 and on a failure the\n diff rides on the error, which is how you find where a half-applied script\n stopped.\n\n### Screenshots\n\n- **`screenshot_frame` takes `times` (2\u20136) and returns one tiled contact sheet**,\n each tile labelled with its own time, held to roughly the pixel budget of a\n single frame. Judging motion is one call now, not three: one image in your\n context instead of three, and one render request instead of three back-to-back\n ones, which is the pattern that provoked stale frames. `time` and `times` are\n mutually exclusive; there is no `times` on `screenshot_layer`.\n- **Three distinct failures, with opposite remedies.** `Stale frame` (AE served\n an earlier render \u2014 wait, retry higher), `Corrupt frame` (the file is not a\n whole PNG; not a timeout \u2014 retry at downsample 6\u20138 or shoot the precomps\n separately), `Render timed out` (still rendering \u2014 wait before retrying, or a\n retry queues behind it). A frame that is genuinely all-transparent is still\n `empty: true` with no image. On a sheet, one bad tile is drawn as a marked\n block and named in `warning`; the rest of the sheet is good.\n\n### run_jsx\n\n- **A failure names the line of *your* script and prints its text**, and says so\n honestly when the number cannot be mapped rather than guessing. This one was\n claimed once before it was true: until the live pass every error said line 1\n and printed line 1\'s text, with the real number only in the parenthetical after\n it, because AE reports `Error.start`/`Error.end` as `0` on every error rather\n than as the character offsets its documentation describes.\n- **`diff: true` on `run_jsx` now reaches After Effects.** The server was\n building a fresh argument object and dropping every field it did not enumerate,\n so the flag was discarded before the call left. The same flag on `run_batch`\n always worked, which is what hid it.\n- **`scriptPath` and `libraries`** keep a long script and its shared helpers out\n of the conversation entirely. Libraries are inlined ahead of the script in the\n same scope, so their functions are callable from it; they are re-evaluated on\n every call, so keep them to declarations rather than to work.\n- **Helpers in scope**, each wrapping a trap: `compById`, `layerById`,\n `walkProperty`, `addKeys`, `ease` (which sizes the KeyframeEase array using the\n same code `set_temporal_ease` uses, not a copy), `shape` (which lands at\n `[0,0]`), and `withoutUndoGroup`. The whole `OPS` table is callable too.\n\n### New tools, and one thing you no longer have to get right\n\n- **`duplicate_comp`** returns the new comp id, so you never look for the copy by\n name. Shallow by default like AE\'s own Duplicate \u2014 the copy\'s precomp layers\n point at the *same* nested comps \u2014 with `deep: true` for a real variant.\n- **`place_audio_cues`** scores a scene in one call: a cue list becomes one audio\n layer each, imported once per file, named, trimmed, levelled in dB, in a single\n undo step, all-or-nothing, with a `dryRun`.\n- **`set_temporal_ease` and `add_keyframe` size the ease array themselves.** Pass\n one `{influence, speed}` pair per side; the count that worked comes back as\n `easeDimensions`. The bare `parameter 2` failure is no longer yours to avoid.\n- **`export_mogrt` refuses an empty Essential Graphics panel up front**, naming\n the controller count. It used to attempt the export and then blame a modal\n dialog, which under the default dialog suppression is the one cause ruled out\n by construction. When an export does fail now, the message says what was\n checked and \u2014 if dialogs were suppressed \u2014 that the cause is genuinely unknown,\n because AE answers with a bare boolean and has no way to say why.\n\n### Underneath\n\n- **Writes are serialized, one at a time, for the whole session**, so two writes\n issued together run in the order you issued them and a long `run_batch` holds\n the lock until its last chunk lands \u2014 nothing else drops into the middle of\n work the user asked for as one thing. A call that waited says `queuedBehind` and `waitedMs`. Reads are\n never queued. There is a new diagnosis to tell apart from a bridge timeout: a\n call *dropped while waiting for the write queue* never reached After Effects,\n so re-sending it is safe \u2014 which is the opposite of what a timeout means.\n- **The issue journal has two scopes.** `project` is this project\'s notes; `user`\n travels with the person across every project, so log tool and After Effects\n behaviour there. Ids are unique only within a journal, so open an entry with\n the qualified form the listing shows (`user:\u2026`).\n- **The guidance moved.** What the server sends every session is now a short\n pointer rather than a summary, because it was resident in every request the\n user ever made. The narrative is `ae_guide({topic: "after-effects"})`, with\n `extendscript-gotchas` behind it for raw `run_jsx` and this topic for changes.\n In Claude Code and claude.ai the same text is the `after-effects` skill and the\n files in its `references/` folder \u2014 load one carrier, not both.\n- **Rigging is written down**, in the main guide: opacity does not propagate\n through parenting, and a camera null is parented to while it is still at\n identity.\n\n## 0.3.1\n\n- **Shape reads got much cheaper.** `get_layer_full` no longer returns\n `ADBE Vector Materials Group` \u2014 the 48-property 3D extrusion model AE hangs\n off every vector group, which means nothing on a 2D shape layer. It\n was around three quarters of the bytes of a shape read: one 68px circle cost\n 4,400 tokens, of which the geometry was about 40. `materialsOmitted` counts\n what was dropped; `shapeMaterials: true` brings it back for a genuinely\n extruded shape. A group Transform still at its creation values collapses to\n `atDefaults: true` the same way.\n- **`shapeDetail: "compact"`** returns one indented line per group instead of\n the JSON tree \u2014 every name the write tools address a node by, with `[3 keys]`\n or `[expr]` marking the animated properties. Measured end to end on one real\n layer: 13,369 characters before this release, 3,052 once the materials block\n went, 643 compact. Reach for `"full"` only when you need exact values or\n keyframe indices.\n- **`run_jsx` never answers a bare `null`.** A script whose last statement is a\n bare expression completes and returns nothing \u2014 `"ping";` does not return\n `"ping"` \u2014 and that used to be indistinguishable on the wire from a script\n that never ran. It now comes back as\n `{ok: true, returned: null, undoGroup, note}`, which means *it ran to\n completion*. Do not re-run it: nothing rolls back, so a second run of a script\n that duplicated a layer or wrote keyframes does all of it twice. A returned\n value still comes back bare, falsy ones included.\n\n## 0.3.0\n\n- **Bounded reads.** `list_comps`, `list_layers` and `get_layer_full` take\n `include` to name the sections you want, plus `maxKeyframes` and `shapeDepth`\n on the deep read. Omit them all and you get everything, exactly as before.\n Anything left out is named and counted in the response (`included`,\n `keyframesOmitted`, `childrenOmitted`), so a short answer never passes for a\n complete one. This matters more than it sounds: a tool result is re-sent to\n you on every later request for the rest of the session, so one unbounded read\n is paid for many times.\n- **Screenshots downsample themselves.** Omit `downsample` and it is derived\n from the comp \u2014 2 at 1080p, 3 at 4K, aiming at a long edge near 1280px. The\n result reports the size actually returned. Pass `downsample: 1` only when you\n genuinely need full resolution.\n- **A stale frame is now an error, not a picture.** AE sometimes re-serves a\n frame it rendered for an unrelated request, at a different time and even at a\n different downsample factor, with nothing in the response to say so. Those are\n refused with `Stale frame` naming the request whose pixels came back. Wait a\n few seconds and retry at a higher `downsample`.\n- **A fully transparent frame comes back as `{empty: true, reason}`** with no\n image. That is a fact about the composition \u2014 wrong time, layer outside its\n in/out points, disabled, zero opacity \u2014 not something to retry.\n- **Footage import**, with `import_footage` and `create_footage_layer`. The\n import refuses an SVG whose `viewBox` asks for one aspect ratio and imports at\n another: that is a real AE bug which renders as nothing at all, with no error\n at any stage.\n- **`export_mogrt`**, which suppresses the modal dialogs that otherwise freeze\n this whole connection until someone clicks them in After Effects. Never drive\n `exportAsMotionGraphicsTemplate` from `run_jsx`.\n- **Text alignment is justification**, not a measured anchor offset, so it stays\n correct when the text changes later. Tracking is set explicitly rather than\n inherited from the user\'s Character panel.\n\n## Older, but you will meet it first\n\n**The panel is versioned separately from the tools, and it does not update\nitself.** It loads at After Effects launch and only at launch, so after any\nupgrade the tools can be newer than the code answering them. When that happens a\ncall returns a remediation message rather than a confusing `Unknown op` \u2014 relay\nit. The distinction worth reading carefully: if it says the panel is *installed*\nbut the running one is older, running `setup_panel` again changes nothing and\nonly quitting and reopening After Effects will.'
1947
2728
  }
1948
2729
  ];
1949
2730
  var PROMPTS = [
@@ -1963,7 +2744,7 @@ var PROMPTS = [
1963
2744
  name: "report-ae-issue",
1964
2745
  description: "Send a problem you hit with the After Effects tools to the people who maintain them",
1965
2746
  argumentHint: "[what went wrong, in your own words]",
1966
- body: '# Report a problem with the After Effects tools\n\nThe user wants to tell the maintainers about something that did not work. They are\nmost likely a motion designer, not a developer: they may never have seen GitHub,\nand they should not have to. Do the technical part yourself and only ask them\nthings they can actually answer.\n\n`$ARGUMENTS` is what they typed, if anything.\n\n## 1. Find out what to report\n\nCall `list_known_issues` with `status: "unreported"`. It returns a one-line index\nof what earlier sessions wrote down, plus `repo`, `newIssueUrl`, `serverVersion`\nand `platform`. Once they have chosen, read each chosen entry in full with\n`list_known_issues({id})` \u2014 the draft below needs the symptom and workaround\ntext, which the index does not carry.\n\n- **Entries exist** \u2014 show them as a short numbered list, one plain sentence each\n ("Text layers ended up in the wrong place when a font was missing"), not the\n raw titles. Ask which to send; offer "all of them" as an option.\n- **No entries, but `$ARGUMENTS` describes something** \u2014 work from that. Ask what\n they were trying to do and what happened instead, then `log_issue` it so it is\n recorded before you send it.\n- **Nothing either way** \u2014 say there is nothing recorded to send, and that you\n will write things down as you hit them from now on. Stop there.\n\n## 2. Draft it\n\nShort. A maintainer should understand the problem in fifteen seconds.\n\n**Title:** one line, concrete. `set_temporal_ease fails on Position with "Value\narray does not have 1 elements"` \u2014 not `Keyframe bug`.\n\n**Body:** four short sections, a couple of sentences each.\n\n```markdown\n**What happens**\n<the failing call and the exact error, or the wrong result>\n\n**Why** (if known)\n<one line \u2014 omit this section entirely if unknown>\n\n**Workaround**\n<what got past it>\n\n**Environment**\nafter-effects-mcp <serverVersion> \xB7 <platform> \xB7 After Effects 2026\n```\n\nInclude the failing call and error text verbatim \u2014 that is the part that makes it\nfixable. Leave out the user\'s own content: comp and layer names from their\nproject, file paths, client names, anything about the video they are making. If a\ndetail like that is load-bearing, replace it with a placeholder.\n\n## 3. Show it and get a yes\n\nShow the finished title and body and ask whether to send it. This posts publicly\nto a repository under their name if `gh` is authenticated, so it needs a real\nanswer, not an assumption. If they want to change the wording, change it.\n\n## 4. Send it\n\nTry `gh` first:\n\n```bash\ngh issue create --repo <repo> --title "<title>" --body "<body>"\n```\n\nIf `gh` is missing or not authenticated, do not try to install or configure it.\nBuild a prefilled link instead \u2014 URL-encode the title and body onto\n`<newIssueUrl>` as `?title=\u2026&body=\u2026` \u2014 and give it to them with one line of\ninstruction: open this, it will already be filled in, press the green button. A\nGitHub account is needed to press it; if they do not have one, say so plainly and\noffer to write the text out for them to send another way.\n\n## 5. Close the loop\n\nOn success, call `mark_issue_reported` with the entry `id` and the URL, so no\nlater session asks them to report the same thing twice. Then tell them where it\nwent, in one sentence, with the link.\n\nIf they decline, leave the entry alone \u2014 it stays unreported and can be offered\nagain another day. Do not mark it.'
2747
+ body: '# Report a problem with the After Effects tools\n\nThe user wants to tell the maintainers about something that did not work. They are\nmost likely a motion designer, not a developer: they may never have seen GitHub,\nand they should not have to. Do the technical part yourself and only ask them\nthings they can actually answer.\n\n`$ARGUMENTS` is what they typed, if anything.\n\n## 1. Find out what to report\n\nCall `list_known_issues` with `status: "unreported"`. It returns a one-line index\nof what earlier sessions wrote down, plus `repo`, `newIssueUrl`, `serverVersion`\nand `platform`. Once they have chosen, read each chosen entry in full with\n`list_known_issues({id})` \u2014 the draft below needs the symptom and workaround\ntext, which the index does not carry.\n\n- **Entries exist** \u2014 show them as a short numbered list, one plain sentence each\n ("Text layers ended up in the wrong place when a font was missing"), not the\n raw titles. Ask which to send; offer "all of them" as an option.\n- **No entries, but `$ARGUMENTS` describes something** \u2014 work from that. Ask what\n they were trying to do and what happened instead, then `log_issue` it so it is\n recorded before you send it.\n- **Nothing either way** \u2014 say there is nothing recorded to send, and that you\n will write things down as you hit them from now on. Stop there.\n\n## 2. Draft it\n\nShort. A maintainer should understand the problem in fifteen seconds.\n\n**Title:** one line, concrete. `set_temporal_ease fails on Position with "Value\narray does not have 1 elements"` \u2014 not `Keyframe bug`.\n\n**Body:** four short sections, a couple of sentences each.\n\n```markdown\n**What happens**\n<the failing call and the exact error, or the wrong result>\n\n**Why** (if known)\n<one line \u2014 omit this section entirely if unknown>\n\n**Workaround**\n<what got past it>\n\n**Environment**\nafter-effects-mcp <serverVersion> \xB7 <platform> \xB7 After Effects 2026\n```\n\nInclude the failing call and error text verbatim \u2014 that is the part that makes it\nfixable. Leave out the user\'s own content: comp and layer names from their\nproject, file paths, client names, anything about the video they are making. If a\ndetail like that is load-bearing, replace it with a placeholder.\n\n## 3. Show it and get a yes\n\nShow the finished title and body and ask whether to send it. This posts publicly\nto a repository under their name if `gh` is authenticated, so it needs a real\nanswer, not an assumption. If they want to change the wording, change it.\n\n## 4. Send it\n\nTry `gh` first:\n\n```bash\ngh issue create --repo <repo> --title "<title>" --body "<body>"\n```\n\nIf `gh` is missing or not authenticated, do not try to install or configure it.\nBuild a prefilled link instead \u2014 URL-encode the title and body onto\n`<newIssueUrl>` as `?title=\u2026&body=\u2026` \u2014 and give it to them with one line of\ninstruction: open this, it will already be filled in, press the green button. A\nGitHub account is needed to press it; if they do not have one, say so plainly and\noffer to write the text out for them to send another way.\n\n## 5. Close the loop\n\nOn success, call `mark_issue_reported` with the entry `id` and the URL, so no\nlater session asks them to report the same thing twice. Use the same\nscope-qualified id the listing gave you (`user:\u2026`, `project:\u2026`): the two\njournals can hold the same slug, reporting one says nothing about the other, and\nan unqualified id leaves which one moved to chance. Then tell them where it went,\nin one sentence, with the link.\n\nIf they decline, leave the entry alone \u2014 it stays unreported and can be offered\nagain another day. Do not mark it.'
1967
2748
  }
1968
2749
  ];
1969
2750
  var GUIDE_NAMES = GUIDES.map((g) => g.name);
@@ -1973,40 +2754,57 @@ function getGuide(name) {
1973
2754
  function getPrompt(name) {
1974
2755
  return PROMPTS.find((p) => p.name === name);
1975
2756
  }
1976
- var SERVER_INSTRUCTIONS = "You are driving a live After Effects session through this server. The user sees\nevery change as it happens and every call is a real undo step in their project.\n\nSix things that are not obvious from the tool list:\n\n1. Read the house style before you build. `get_house_style` returns the user's\n palette, type and motion defaults for the project that is open. One cheap call.\n2. Orient before you touch anything. `get_layer_full` returns a layer's\n transforms with keyframes and expressions, every effect and parameter, masks,\n markers and visible bounds in a single call \u2014 prefer it over several narrow reads.\n3. Identify by id, never by index. Layer `index` shifts whenever layers are\n added, deleted or reordered. Carry `(compId, layerId)`.\n4. Verify by reading properties back, not by screenshotting. Screenshots are\n one-off diagnostics: 2-3 across an animation, never per frame.\n5. Bulk work goes through `run_batch` \u2014 one ExtendScript pass, one undo step.\n6. When a tool fails in a way you do not understand, call `list_known_issues`\n before guessing; an earlier session may have solved it already. When you solve\n a new one, `log_issue` it.\n\nCall `ae_guide` for the full guidance on any of this \u2014 topics: ae-setup, after-effects, style-guide.\nIf a tool reports it cannot reach After Effects, call `check_setup` and relay\nits `nextSteps` verbatim; do not diagnose CEP by hand.";
2757
+ var SERVER_INSTRUCTIONS = 'You are driving a live After Effects session through this server. The user sees\nevery change as it happens and every call is a real undo step in their project.\n\nRead the guidance before you build. `ae_guide({topic: "after-effects"})` covers\norienting in a project, keyframes and easing, expressions, effects, text, shapes,\nand the traps that silently produce wrong output. In Claude Code and claude.ai\nthe `after-effects` skill is the same text \u2014 load one carrier, not both.\nTopics: ae-setup, after-effects, extendscript-gotchas, style-guide, whats-new.\n\nThree habits that matter before that call returns:\n\n- Identify by `id`, never by `index` \u2014 a layer\'s index shifts on every insert.\n- Bound your reads (`include`, `shapeDetail: "compact"`) and treat screenshots as\n one-off diagnostics rather than a feedback loop. Every tool result is re-sent\n to you on every later request in the session.\n- If a tool reports it cannot reach After Effects, call `check_setup` and relay\n its `nextSteps` verbatim; do not diagnose CEP by hand.';
1977
2758
 
1978
2759
  // src/issues/journal.ts
1979
- import fs7 from "node:fs";
2760
+ import fs8 from "node:fs";
1980
2761
  import os4 from "node:os";
1981
- import path8 from "node:path";
2762
+ import path9 from "node:path";
1982
2763
  var REPO = "Engine-Room-Games/after-effects-mcp";
1983
2764
  var NEW_ISSUE_URL = `https://github.com/${REPO}/issues/new`;
1984
2765
  var SECTION_SYMPTOM = "What went wrong";
1985
2766
  var SECTION_CAUSE = "Why";
1986
2767
  var SECTION_WORKAROUND = "What worked";
1987
2768
  function journalRoot() {
1988
- const override = process.env.AE_MCP_HOME?.trim();
1989
- if (override && override.length > 0) return { dir: override, scope: "project" };
2769
+ const override = journalOverride();
2770
+ if (override) return { dir: override, scope: "project" };
1990
2771
  const cwd = process.cwd();
1991
- const unusable = cwd === path8.parse(cwd).root || cwd === os4.homedir();
2772
+ const unusable = cwd === path9.parse(cwd).root || cwd === os4.homedir();
1992
2773
  if (!unusable) {
1993
2774
  try {
1994
- fs7.accessSync(cwd, fs7.constants.W_OK);
1995
- return { dir: path8.join(cwd, ".ae-mcp"), scope: "project" };
2775
+ fs8.accessSync(cwd, fs8.constants.W_OK);
2776
+ return { dir: path9.join(cwd, ".ae-mcp"), scope: "project" };
1996
2777
  } catch {
1997
2778
  }
1998
2779
  }
1999
- return { dir: path8.join(os4.homedir(), ".after-effects-mcp"), scope: "home" };
2780
+ return { dir: path9.join(os4.homedir(), ".after-effects-mcp"), scope: "home" };
2781
+ }
2782
+ function userJournalRoot() {
2783
+ const override = journalOverride();
2784
+ if (override) return { dir: path9.join(override, "user"), scope: "user" };
2785
+ return { dir: path9.join(os4.homedir(), ".ae-mcp"), scope: "user" };
2786
+ }
2787
+ function journalOverride() {
2788
+ const override = process.env.AE_MCP_HOME?.trim();
2789
+ return override && override.length > 0 ? override : null;
2790
+ }
2791
+ function journals() {
2792
+ const project = journalRoot();
2793
+ const user = userJournalRoot();
2794
+ if (path9.resolve(project.dir) === path9.resolve(user.dir)) return [project];
2795
+ return [project, user];
2796
+ }
2797
+ function issuesDir(journal) {
2798
+ return path9.join(journal.dir, "issues");
2000
2799
  }
2001
- function journalDir() {
2002
- return path8.join(journalRoot().dir, "issues");
2800
+ function journalFor(scope) {
2801
+ return scope === "user" ? userJournalRoot() : journalRoot();
2003
2802
  }
2004
- function ensureJournalDir() {
2005
- const { dir } = journalRoot();
2006
- const issues = path8.join(dir, "issues");
2007
- fs7.mkdirSync(issues, { recursive: true });
2008
- const ignore = path8.join(dir, ".gitignore");
2009
- if (!fs7.existsSync(ignore)) fs7.writeFileSync(ignore, "*\n", "utf8");
2803
+ function ensureJournalDir(journal) {
2804
+ const issues = issuesDir(journal);
2805
+ fs8.mkdirSync(issues, { recursive: true });
2806
+ const ignore = path9.join(journal.dir, ".gitignore");
2807
+ if (!fs8.existsSync(ignore)) fs8.writeFileSync(ignore, "*\n", "utf8");
2010
2808
  return issues;
2011
2809
  }
2012
2810
  function slugify(text) {
@@ -2019,10 +2817,10 @@ function today() {
2019
2817
  function oneLine(text) {
2020
2818
  return text.replace(/\s+/g, " ").trim();
2021
2819
  }
2022
- function entryPath(id) {
2023
- const dir = path8.resolve(journalDir());
2024
- const file = path8.resolve(dir, `${id}.md`);
2025
- if (path8.dirname(file) !== dir) throw new Error(`Invalid issue id: ${id}`);
2820
+ function entryPath(id, journal) {
2821
+ const dir = path9.resolve(issuesDir(journal));
2822
+ const file = path9.resolve(dir, `${id}.md`);
2823
+ if (path9.dirname(file) !== dir) throw new Error(`Invalid issue id: ${id}`);
2026
2824
  return file;
2027
2825
  }
2028
2826
  function render(entry) {
@@ -2063,7 +2861,7 @@ function readSections(body) {
2063
2861
  });
2064
2862
  return sections;
2065
2863
  }
2066
- function parse(text, fallbackId) {
2864
+ function parse(text, fallbackId, scope = "project") {
2067
2865
  const meta = {};
2068
2866
  let body = text;
2069
2867
  const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
@@ -2080,6 +2878,8 @@ function parse(text, fallbackId) {
2080
2878
  return {
2081
2879
  id: meta.id || fallbackId,
2082
2880
  title: meta.title || fallbackId.replace(/-/g, " "),
2881
+ // The folder decides this, never the file — see IssueEntry.scope.
2882
+ scope,
2083
2883
  tools: (meta.tools ?? "").split(",").map((t) => t.trim()).filter((t) => t.length > 0),
2084
2884
  firstSeen: meta.firstSeen || "",
2085
2885
  lastSeen: meta.lastSeen || meta.firstSeen || "",
@@ -2093,20 +2893,22 @@ function parse(text, fallbackId) {
2093
2893
  workaround: sections.get(SECTION_WORKAROUND.toLowerCase()) ?? ""
2094
2894
  };
2095
2895
  }
2096
- function readEntry(file) {
2896
+ function readEntry(file, scope) {
2097
2897
  try {
2098
- return parse(fs7.readFileSync(file, "utf8"), path8.basename(file, ".md"));
2898
+ return parse(fs8.readFileSync(file, "utf8"), path9.basename(file, ".md"), scope);
2099
2899
  } catch {
2100
2900
  return null;
2101
2901
  }
2102
2902
  }
2103
2903
  function logIssue(input) {
2904
+ const journal = journalFor(input.scope ?? "project");
2104
2905
  const id = slugify(input.title);
2105
- const file = entryPath(id);
2106
- const existing = fs7.existsSync(file) ? readEntry(file) : null;
2906
+ const file = entryPath(id, journal);
2907
+ const existing = fs8.existsSync(file) ? readEntry(file, journal.scope) : null;
2107
2908
  const entry = {
2108
2909
  id,
2109
2910
  title: oneLine(input.title),
2911
+ scope: journal.scope,
2110
2912
  tools: input.tools ?? existing?.tools ?? [],
2111
2913
  firstSeen: existing?.firstSeen || today(),
2112
2914
  lastSeen: today(),
@@ -2114,7 +2916,9 @@ function logIssue(input) {
2114
2916
  // worth reporting, and the count is the only evidence of that.
2115
2917
  occurrences: (existing?.occurrences ?? 0) + 1,
2116
2918
  // Reporting state belongs to the entry, not to this sighting — a fresh
2117
- // description of a known problem must not un-report it.
2919
+ // description of a known problem must not un-report it. And it belongs to
2920
+ // the entry *in this scope*: the two journals are separate records of
2921
+ // separate claims, so reporting one says nothing about the other.
2118
2922
  reported: existing?.reported ?? false,
2119
2923
  issueUrl: existing?.issueUrl,
2120
2924
  symptom: input.symptom,
@@ -2123,18 +2927,22 @@ function logIssue(input) {
2123
2927
  cause: input.cause ?? existing?.cause,
2124
2928
  workaround: input.workaround
2125
2929
  };
2126
- ensureJournalDir();
2127
- fs7.writeFileSync(file, render(entry), "utf8");
2930
+ ensureJournalDir(journal);
2931
+ fs8.writeFileSync(file, render(entry), "utf8");
2932
+ const alsoIn = journals().filter((j) => j.scope !== journal.scope).filter((j) => fs8.existsSync(entryPath(id, j))).map((j) => j.scope);
2128
2933
  return {
2129
2934
  id,
2130
2935
  path: file,
2936
+ scope: journal.scope,
2131
2937
  occurrences: entry.occurrences,
2132
2938
  previouslyLogged: existing !== null,
2133
2939
  reported: entry.reported,
2134
- issueUrl: entry.issueUrl
2940
+ issueUrl: entry.issueUrl,
2941
+ ...alsoIn.length > 0 ? { alsoIn } : {}
2135
2942
  };
2136
2943
  }
2137
2944
  var SUMMARY_CHARS = 160;
2945
+ var DEFAULT_LIMIT = 50;
2138
2946
  function summarize(entry) {
2139
2947
  const text = oneLine(entry.symptom || entry.workaround || "");
2140
2948
  return text.length > SUMMARY_CHARS ? `${text.slice(0, SUMMARY_CHARS).trimEnd()}\u2026` : text;
@@ -2143,6 +2951,7 @@ function toIndexEntry(e) {
2143
2951
  return {
2144
2952
  id: e.id,
2145
2953
  title: e.title,
2954
+ scope: e.scope,
2146
2955
  tools: e.tools,
2147
2956
  lastSeen: e.lastSeen,
2148
2957
  occurrences: e.occurrences,
@@ -2150,25 +2959,57 @@ function toIndexEntry(e) {
2150
2959
  summary: summarize(e)
2151
2960
  };
2152
2961
  }
2153
- function readAllEntries() {
2154
- const dir = journalDir();
2962
+ function readJournalEntries(journal) {
2963
+ const dir = issuesDir(journal);
2155
2964
  try {
2156
- return fs7.readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => readEntry(path8.join(dir, f))).filter((e) => e !== null);
2965
+ return fs8.readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => readEntry(path9.join(dir, f), journal.scope)).filter((e) => e !== null);
2157
2966
  } catch {
2158
2967
  return [];
2159
2968
  }
2160
2969
  }
2970
+ function selectedJournals(filter) {
2971
+ if (filter === "all") return journals();
2972
+ if (filter === "user") return journals().filter((j) => j.scope === "user");
2973
+ return journals().filter((j) => j.scope === "project" || j.scope === "home");
2974
+ }
2975
+ function readAllEntries(filter = "all") {
2976
+ return selectedJournals(filter).flatMap(readJournalEntries);
2977
+ }
2978
+ var SCOPE_PREFIX = /^(project|home|user)\s*:\s*(.+)$/i;
2979
+ function resolveEntry(raw, entries) {
2980
+ const qualified = SCOPE_PREFIX.exec(raw.trim());
2981
+ if (qualified) {
2982
+ const scope = qualified[1].toLowerCase();
2983
+ const slug2 = slugify(qualified[2]);
2984
+ const hit = entries.find((e) => e.scope === scope && e.id === slug2);
2985
+ if (hit) return { found: hit, alsoIn: [] };
2986
+ }
2987
+ const slug = slugify(raw);
2988
+ const matches = entries.filter((e) => e.id === slug);
2989
+ if (matches.length === 0) return null;
2990
+ return { found: matches[0], alsoIn: matches.slice(1).map((e) => e.scope) };
2991
+ }
2992
+ function knownIds(entries) {
2993
+ return entries.map((e) => `${e.scope}:${e.id}`).join(", ");
2994
+ }
2161
2995
  function matchesQuery(entry, terms) {
2162
2996
  const haystack = `${entry.title} ${entry.symptom} ${entry.tools.join(" ")}`.toLowerCase();
2163
2997
  return terms.every((t) => haystack.includes(t));
2164
2998
  }
2165
2999
  function listIssues(options = {}) {
2166
- const { scope } = journalRoot();
2167
- const dir = journalDir();
2168
- const entries = readAllEntries();
3000
+ const project = journalRoot();
3001
+ const filter = options.scope ?? "all";
3002
+ const consulted = selectedJournals(filter);
3003
+ const perJournal = consulted.map((j) => ({ journal: j, entries: readJournalEntries(j) }));
3004
+ const entries = perJournal.flatMap((p) => p.entries);
2169
3005
  const envelope = {
2170
- dir,
2171
- scope,
3006
+ dir: issuesDir(project),
3007
+ scope: project.scope,
3008
+ journals: perJournal.map(({ journal, entries: e }) => ({
3009
+ scope: journal.scope,
3010
+ dir: issuesDir(journal),
3011
+ count: e.length
3012
+ })),
2172
3013
  repo: REPO,
2173
3014
  newIssueUrl: NEW_ISSUE_URL,
2174
3015
  serverVersion: packageVersion(),
@@ -2176,13 +3017,23 @@ function listIssues(options = {}) {
2176
3017
  };
2177
3018
  const wantedId = options.id?.trim();
2178
3019
  if (wantedId) {
2179
- const found = entries.find((e) => e.id === slugify(wantedId));
2180
- if (!found) {
3020
+ const resolved = resolveEntry(wantedId, entries);
3021
+ if (!resolved) {
2181
3022
  throw new Error(
2182
- `No journal entry with id "${wantedId}".` + (entries.length > 0 ? ` Known ids: ${entries.map((e) => e.id).join(", ")}` : " The journal is empty.")
3023
+ `No journal entry with id "${wantedId}".` + (entries.length > 0 ? ` Known ids: ${knownIds(entries)}` : " The journal is empty.")
2183
3024
  );
2184
3025
  }
2185
- return { ...envelope, detail: "full", count: 1, issues: [found] };
3026
+ return {
3027
+ ...envelope,
3028
+ detail: "full",
3029
+ count: 1,
3030
+ issues: [resolved.found],
3031
+ // Same slug in the other journal: say so, and name the call that opens it,
3032
+ // rather than letting one of the two silently win.
3033
+ ...resolved.alsoIn.length > 0 ? {
3034
+ next: `Also written down in the ${resolved.alsoIn.join(" and ")} journal \u2014 list_known_issues({ id: "${resolved.alsoIn[0]}:${resolved.found.id}" }) reads that one.`
3035
+ } : {}
3036
+ };
2186
3037
  }
2187
3038
  const status = options.status ?? "all";
2188
3039
  const wanted = options.tool?.trim().toLowerCase();
@@ -2195,33 +3046,243 @@ function listIssues(options = {}) {
2195
3046
  return e.tools.some((t) => t.toLowerCase() === wanted) || e.title.toLowerCase().includes(wanted);
2196
3047
  });
2197
3048
  filtered.sort((a, b) => b.lastSeen.localeCompare(a.lastSeen) || b.occurrences - a.occurrences);
3049
+ const limit = options.limit && options.limit > 0 ? options.limit : DEFAULT_LIMIT;
3050
+ const shown = filtered.slice(0, limit);
3051
+ const omitted = filtered.length - shown.length;
2198
3052
  const detail = options.detail ?? "index";
3053
+ const first = shown[0];
2199
3054
  return {
2200
3055
  ...envelope,
2201
3056
  detail,
2202
3057
  count: filtered.length,
2203
- issues: detail === "full" ? filtered : filtered.map(toIndexEntry),
3058
+ issues: detail === "full" ? shown : shown.map(toIndexEntry),
3059
+ // Truncation is named and counted. A short answer that looked complete
3060
+ // would be the same class of lie as a swallowed error.
3061
+ ...omitted > 0 ? { omitted } : {},
2204
3062
  // The reason to read this journal is that something failed, so an index
2205
3063
  // that stopped short of the workaround would be worse than useless. Say
2206
- // how to reach it, every time there is one to reach.
2207
- ...detail === "index" && filtered.length > 0 ? { next: 'list_known_issues({ id: "<id>" }) for the cause and the workaround.' } : {}
3064
+ // how to reach it, every time there is one to reach — and since ids are
3065
+ // only unique within a journal, spell the scope-qualified form out on a
3066
+ // real entry rather than leaving the caller to guess the syntax.
3067
+ ...detail === "index" && first ? {
3068
+ next: `list_known_issues({ id: "${first.scope}:${first.id}" }) for the cause and the workaround. Prefix any id from this list with its own scope.` + (omitted > 0 ? ` ${omitted} more matched \u2014 narrow with tool/query, or raise limit.` : "")
3069
+ } : {}
2208
3070
  };
2209
3071
  }
2210
3072
  function markReported(id, url) {
2211
- const file = entryPath(slugify(id));
2212
- const entry = fs7.existsSync(file) ? readEntry(file) : null;
2213
- if (!entry) {
2214
- const known = readAllEntries().map((e) => e.id);
3073
+ const entries = readAllEntries();
3074
+ const resolved = resolveEntry(id, entries);
3075
+ if (!resolved) {
2215
3076
  throw new Error(
2216
- `No journal entry with id "${id}".` + (known.length > 0 ? ` Known ids: ${known.join(", ")}` : "")
3077
+ `No journal entry with id "${id}".` + (entries.length > 0 ? ` Known ids: ${knownIds(entries)}` : "")
2217
3078
  );
2218
3079
  }
3080
+ const entry = resolved.found;
3081
+ const journal = journals().find((j) => j.scope === entry.scope);
3082
+ if (!journal) throw new Error(`Journal for scope "${entry.scope}" is no longer readable.`);
2219
3083
  entry.reported = true;
2220
3084
  if (url) entry.issueUrl = oneLine(url);
2221
- fs7.writeFileSync(file, render(entry), "utf8");
3085
+ fs8.writeFileSync(entryPath(entry.id, journal), render(entry), "utf8");
2222
3086
  return entry;
2223
3087
  }
2224
3088
 
3089
+ // src/style/summary.ts
3090
+ var HEX = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b/g;
3091
+ var SECTION_KEYWORDS = [
3092
+ { key: "palette", words: ["palette", "colour", "color", "swatch"] },
3093
+ { key: "type", words: ["type", "typograph", "font", "lettering", "typeface"] },
3094
+ { key: "motion", words: ["motion", "animation", "timing", "easing", "ease", "transition"] },
3095
+ // "Rules" is in the template this project's own style-guide guide hands out,
3096
+ // and it holds constraints of exactly the kind the layout bullets carry. It
3097
+ // would otherwise be named as unsummarised on almost every guide written.
3098
+ {
3099
+ key: "layout",
3100
+ words: ["layout", "grid", "spacing", "composition", "margin", "safe area", "framing", "rule", "constraint"]
3101
+ }
3102
+ ];
3103
+ var MAX_PALETTE = 24;
3104
+ var MAX_TYPE_LINES = 3;
3105
+ var MAX_LAYOUT_LINES = 8;
3106
+ var MAX_MOTION_CHARS = 240;
3107
+ var MAX_LINE_CHARS = 160;
3108
+ var MAX_SECTIONS_OMITTED = 12;
3109
+ var HEAD_CHARS = 900;
3110
+ function findHeadings(lines) {
3111
+ const headings = [];
3112
+ let fenced = false;
3113
+ let frontmatter = /^---\s*$/.test(lines[0] ?? "");
3114
+ for (let i = 0; i < lines.length; i++) {
3115
+ const line = lines[i];
3116
+ if (/^\s*(```|~~~)/.test(line)) {
3117
+ fenced = !fenced;
3118
+ continue;
3119
+ }
3120
+ if (fenced) continue;
3121
+ if (frontmatter) {
3122
+ if (i > 0 && /^---\s*$/.test(line)) frontmatter = false;
3123
+ continue;
3124
+ }
3125
+ const atx = /^ {0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
3126
+ if (atx) {
3127
+ headings.push({ level: atx[1].length, text: atx[2].trim(), from: i + 1 });
3128
+ continue;
3129
+ }
3130
+ const underline = /^ {0,3}(={3,}|-{3,})\s*$/.exec(line);
3131
+ if (underline && i > 0) {
3132
+ const above = lines[i - 1].trim();
3133
+ if (above.length > 0 && !/^ {0,3}#{1,6}\s/.test(above) && !/^[-=*_\s]+$/.test(above)) {
3134
+ headings.push({ level: underline[1].startsWith("=") ? 1 : 2, text: above, from: i + 1 });
3135
+ }
3136
+ }
3137
+ }
3138
+ return headings;
3139
+ }
3140
+ function classifyAll(heading) {
3141
+ const text = heading.toLowerCase();
3142
+ const kinds = /* @__PURE__ */ new Set();
3143
+ for (const { key, words } of SECTION_KEYWORDS) {
3144
+ if (words.some((w) => text.includes(w))) kinds.add(key);
3145
+ }
3146
+ return kinds;
3147
+ }
3148
+ function ownLines(lines, headings, index) {
3149
+ const here = headings[index];
3150
+ const next = headings[index + 1];
3151
+ const end = next === void 0 ? lines.length : next.from - (isSetext(lines, next) ? 2 : 1);
3152
+ return lines.slice(here.from, Math.max(here.from, end));
3153
+ }
3154
+ function isSetext(lines, heading) {
3155
+ const underline = lines[heading.from - 1];
3156
+ return underline !== void 0 && /^ {0,3}(={3,}|-{3,})\s*$/.test(underline);
3157
+ }
3158
+ function condense(line) {
3159
+ let text = line.trim();
3160
+ if (/^\|/.test(text)) {
3161
+ const cells = text.split("|").map((c) => c.trim()).filter((c) => c.length > 0);
3162
+ if (cells.every((c) => /^:?-{2,}:?$/.test(c))) return "";
3163
+ text = cells.join(" \xB7 ");
3164
+ }
3165
+ text = text.replace(/^#{1,6}\s+/, "").replace(/^[-*+]\s+/, "").replace(/^\d+[.)]\s+/, "").replace(/^>\s?/, "").replace(/\*\*|__|`/g, "").trim();
3166
+ return text.length > MAX_LINE_CHARS ? `${text.slice(0, MAX_LINE_CHARS).trimEnd()}\u2026` : text;
3167
+ }
3168
+ function contentLines(lines) {
3169
+ return lines.map(condense).filter((l) => l.length > 0 && !/^```/.test(l));
3170
+ }
3171
+ function readPalette(lines) {
3172
+ const found = [];
3173
+ const seen = /* @__PURE__ */ new Set();
3174
+ for (const raw of lines) {
3175
+ HEX.lastIndex = 0;
3176
+ const hexes = raw.match(HEX);
3177
+ if (!hexes) continue;
3178
+ const isTableRow = /^\s*\|/.test(raw);
3179
+ let name = "";
3180
+ if (isTableRow) {
3181
+ const cells = raw.split("|").map((c) => c.trim().replace(/\*\*|__|`/g, "").trim()).filter((c) => c.length > 0);
3182
+ name = cells.find((c) => !/^#[0-9a-fA-F]{3,8}$/.test(c) && !/^:?-{2,}:?$/.test(c)) ?? "";
3183
+ } else {
3184
+ name = raw.slice(0, raw.indexOf(hexes[0])).replace(/^\s*#{1,6}\s+/, "").replace(/^\s*[-*+]\s+/, "").replace(/^\s*\d+[.)]\s+/, "").replace(/^\s*>\s?/, "").replace(/\*\*|__|`/g, "").replace(/[|]/g, " ").replace(/\s+/g, " ").trim().replace(/[:=–—-]+$/, "").trim();
3185
+ }
3186
+ if (name.length > 40) name = `${name.slice(0, 40).trimEnd()}\u2026`;
3187
+ for (const hex of hexes) {
3188
+ const key = hex.toLowerCase();
3189
+ if (seen.has(key)) continue;
3190
+ seen.add(key);
3191
+ found.push({ name, hex });
3192
+ name = "";
3193
+ if (found.length >= MAX_PALETTE) return found;
3194
+ }
3195
+ }
3196
+ return found;
3197
+ }
3198
+ function summarizeHouseStyle(content) {
3199
+ const lines = content.split(/\r\n|\r|\n/);
3200
+ const headings = findHeadings(lines);
3201
+ const buckets = { type: [], motion: [], layout: [] };
3202
+ const omitted = [];
3203
+ const absorbed = new Array(headings.length).fill(false);
3204
+ const ownCache = /* @__PURE__ */ new Map();
3205
+ const own = (i) => {
3206
+ let value = ownCache.get(i);
3207
+ if (!value) ownCache.set(i, value = contentLines(ownLines(lines, headings, i)));
3208
+ return value;
3209
+ };
3210
+ headings.forEach((heading, i) => {
3211
+ if (absorbed[i]) return;
3212
+ const kinds = classifyAll(heading.text);
3213
+ if (kinds.size === 0) {
3214
+ if (own(i).length > 0 && !omitted.includes(heading.text)) omitted.push(heading.text);
3215
+ return;
3216
+ }
3217
+ const body = [...own(i)];
3218
+ for (let j = i + 1; j < headings.length && headings[j].level > heading.level; j++) {
3219
+ if (classifyAll(headings[j].text).size > 0) break;
3220
+ absorbed[j] = true;
3221
+ const sub = own(j);
3222
+ if (sub.length === 0) continue;
3223
+ body.push(`${headings[j].text}: ${sub[0]}`, ...sub.slice(1));
3224
+ }
3225
+ const bucketed = [...kinds].filter((k) => k !== "palette");
3226
+ if (bucketed.length === 0 || body.length === 0) return;
3227
+ for (const kind of bucketed) buckets[kind].push(...body);
3228
+ });
3229
+ const palette = readPalette(lines);
3230
+ const type = buckets.type.slice(0, MAX_TYPE_LINES);
3231
+ const layout = buckets.layout.slice(0, MAX_LAYOUT_LINES);
3232
+ const motionJoined = buckets.motion.join("; ");
3233
+ const motion = motionJoined.length > MAX_MOTION_CHARS ? `${motionJoined.slice(0, MAX_MOTION_CHARS).trimEnd()}\u2026` : motionJoined;
3234
+ const structured = palette.length > 0 || type.length > 0 || layout.length > 0 || motion.length > 0;
3235
+ const chars = content.length;
3236
+ if (!structured) {
3237
+ const head = content.trim().slice(0, HEAD_CHARS);
3238
+ return {
3239
+ summary: {
3240
+ structured: false,
3241
+ palette: [],
3242
+ type: [],
3243
+ motion: "",
3244
+ layout: [],
3245
+ head: head + (content.trim().length > head.length ? "\u2026" : ""),
3246
+ ...omitted.length > 0 ? { sectionsOmitted: omitted.slice(0, MAX_SECTIONS_OMITTED) } : {}
3247
+ },
3248
+ note: `This style guide has no palette, type, motion or layout section this summariser could recognise, so the text above is the opening of the document verbatim and nothing has been interpreted. Call get_house_style({ detail: "full" }) to read all ${chars} characters.`
3249
+ };
3250
+ }
3251
+ const dropped = [];
3252
+ if (buckets.type.length > type.length) dropped.push(`${buckets.type.length - type.length} more type lines`);
3253
+ if (buckets.layout.length > layout.length)
3254
+ dropped.push(`${buckets.layout.length - layout.length} more layout lines`);
3255
+ if (omitted.length > 0) dropped.push(`sections: ${omitted.slice(0, MAX_SECTIONS_OMITTED).join(", ")}`);
3256
+ return {
3257
+ summary: {
3258
+ structured: true,
3259
+ palette,
3260
+ type,
3261
+ motion,
3262
+ layout,
3263
+ ...omitted.length > 0 ? { sectionsOmitted: omitted.slice(0, MAX_SECTIONS_OMITTED) } : {}
3264
+ },
3265
+ note: `This is a summary, not the style guide. The document is ${chars} characters; call get_house_style({ detail: "full" }) for all of it before editing it or when a detail here is not enough.` + (dropped.length > 0 ? ` Not summarised \u2014 ${dropped.join("; ")}.` : "")
3266
+ };
3267
+ }
3268
+ function applyHouseStyleDetail(result, detail) {
3269
+ if (!result || typeof result !== "object" || Array.isArray(result)) return result;
3270
+ const r = result;
3271
+ if (typeof r.content !== "string" || r.content.length === 0) return result;
3272
+ if (detail === "full") return { ...r, detail: "full" };
3273
+ const { summary, note } = summarizeHouseStyle(r.content);
3274
+ const { content, ...rest } = r;
3275
+ return {
3276
+ ...rest,
3277
+ detail: "summary",
3278
+ // The caller has to be able to judge whether the full read is worth it.
3279
+ characters: content.length,
3280
+ lines: content.split(/\r\n|\r|\n/).length,
3281
+ summary,
3282
+ note
3283
+ };
3284
+ }
3285
+
2225
3286
  // src/util/pngImage.ts
2226
3287
  function imageContent(meta, base64) {
2227
3288
  return {
@@ -2249,13 +3310,18 @@ var SERVER_OPS = /* @__PURE__ */ new Set([
2249
3310
  "list_known_issues",
2250
3311
  "mark_issue_reported"
2251
3312
  ]);
3313
+ var SNAPSHOT_OPS = /* @__PURE__ */ new Set(["snapshot_comp", "diff_comp"]);
3314
+ function isWriteOp(name) {
3315
+ const effect = schemas_exports.OpMutation[name];
3316
+ return effect === void 0 ? true : effect === "write";
3317
+ }
2252
3318
  var GUIDE_URI_PREFIX = "ae://guide/";
2253
3319
  var AwaitJobSchema = schemas_exports.AwaitJob;
2254
3320
  var GetJobSchema = schemas_exports.GetJob;
2255
3321
  var CancelJobSchema = schemas_exports.CancelJob;
2256
3322
  function createServer() {
2257
3323
  const server = new Server(
2258
- { name: "after-effects-mcp", version: "0.3.1" },
3324
+ { name: "after-effects-mcp", version: "0.4.0" },
2259
3325
  {
2260
3326
  capabilities: { tools: {}, logging: {}, prompts: {}, resources: {} },
2261
3327
  // Clients that honour this fold it into the system prompt, which is the
@@ -2266,6 +3332,8 @@ function createServer() {
2266
3332
  );
2267
3333
  const bridge = new HttpClient();
2268
3334
  const jobs = new JobManager();
3335
+ const writes = new WriteQueue();
3336
+ const snapshots = new SnapshotStore();
2269
3337
  const ws = new WsClient(bridge.port, jobs);
2270
3338
  ws.start();
2271
3339
  const panelGate = createPanelGate(bridge);
@@ -2274,18 +3342,11 @@ function createServer() {
2274
3342
  (e) => logger.warn(`Bridge not reachable yet: ${e.message}`)
2275
3343
  );
2276
3344
  server.setRequestHandler(ListToolsRequestSchema, async () => {
2277
- const tools = Object.keys(OpSchemas2).map((name) => {
2278
- const schema = OpSchemas2[name];
2279
- const jsonSchema = zodToJsonSchema(schema, {
2280
- target: "jsonSchema7",
2281
- $refStrategy: "none"
2282
- });
2283
- return {
2284
- name,
2285
- description: descriptions[name] ?? `AE op: ${name}`,
2286
- inputSchema: toDraft2020(jsonSchema)
2287
- };
2288
- });
3345
+ const tools = Object.keys(OpSchemas2).map((name) => ({
3346
+ name,
3347
+ description: descriptions[name] ?? `AE op: ${name}`,
3348
+ inputSchema: toolInputSchema(name)
3349
+ }));
2289
3350
  return { tools };
2290
3351
  });
2291
3352
  server.setRequestHandler(ListPromptsRequestSchema, async () => ({
@@ -2378,7 +3439,7 @@ function createServer() {
2378
3439
  }
2379
3440
  if (name === "log_issue") {
2380
3441
  const a = schemas_exports.LogIssue.parse(rawArgs);
2381
- return textResult(logIssue(a));
3442
+ return textResult(logIssue({ ...a, scope: a.scope ?? "project" }));
2382
3443
  }
2383
3444
  if (name === "list_known_issues") {
2384
3445
  const a = schemas_exports.ListKnownIssues.parse(rawArgs);
@@ -2390,28 +3451,49 @@ function createServer() {
2390
3451
  id: a.id,
2391
3452
  // Compact unless asked otherwise: the full corpus is thousands of
2392
3453
  // tokens that stay in the transcript for the rest of the session.
2393
- detail: a.detail ?? "index"
3454
+ detail: a.detail ?? "index",
3455
+ // Both journals by default — the whole point of the user one is
3456
+ // that a fresh project folder does not start ignorant.
3457
+ scope: a.scope ?? "all",
3458
+ limit: a.limit
2394
3459
  })
2395
3460
  );
2396
3461
  }
2397
3462
  if (name === "mark_issue_reported") {
2398
3463
  const a = schemas_exports.MarkIssueReported.parse(rawArgs);
2399
3464
  const entry = markReported(a.id, a.url);
2400
- return textResult({ ok: true, id: entry.id, reported: true, issueUrl: entry.issueUrl });
3465
+ return textResult({ ok: true, id: entry.id, scope: entry.scope, reported: true, issueUrl: entry.issueUrl });
2401
3466
  }
2402
3467
  } catch (e) {
2403
- return errorResult(e.message);
3468
+ return errorResult(invalidArgsText(name, e));
2404
3469
  }
2405
3470
  }
2406
3471
  let args;
2407
3472
  try {
2408
3473
  args = OpSchemas2[name].parse(rawArgs);
2409
3474
  } catch (e) {
2410
- return errorResult(`Invalid arguments for ${name}: ${e.message}`);
3475
+ return errorResult(invalidArgsText(name, e));
3476
+ }
3477
+ if (name === "run_jsx") {
3478
+ try {
3479
+ args = resolveRunJsxSource(args);
3480
+ } catch (e) {
3481
+ return errorResult(e.message);
3482
+ }
2411
3483
  }
2412
3484
  const staleness = await panelGate.check();
2413
3485
  if (staleness) return errorResult(staleness);
3486
+ let lease = null;
3487
+ if (isWriteOp(name)) {
3488
+ try {
3489
+ lease = await writes.acquire(name, extra?.signal);
3490
+ } catch (e) {
3491
+ return errorResult(e.message);
3492
+ }
3493
+ }
3494
+ const wait = lease?.wait ?? null;
2414
3495
  try {
3496
+ if (SNAPSHOT_OPS.has(name)) return await runSnapshotOp(name, args, bridge, snapshots);
2415
3497
  const result = await bridge.runOp(name, args, progressToken);
2416
3498
  if (ASYNC_OPS.has(name) && isAsyncEnvelope(result)) {
2417
3499
  const env = result;
@@ -2424,7 +3506,19 @@ function createServer() {
2424
3506
  });
2425
3507
  });
2426
3508
  }
2427
- return textResult({ jobId: env.jobId, async: true, total: env.total });
3509
+ lease?.extendUntil(jobs.waitFor(env.jobId, writes.holdCeilingMs));
3510
+ return textResult(
3511
+ {
3512
+ jobId: env.jobId,
3513
+ async: true,
3514
+ total: env.total,
3515
+ chunkSize: env.chunkSize,
3516
+ undoStepsEstimate: env.undoStepsEstimate,
3517
+ undoGroupName: env.undoGroupName,
3518
+ note: env.note
3519
+ },
3520
+ wait
3521
+ );
2428
3522
  }
2429
3523
  if (VISION_OPS.has(name) && isEmptyFrameResult(result)) return textResult(result);
2430
3524
  if (VISION_OPS.has(name) && isVisionResult(result)) {
@@ -2446,12 +3540,25 @@ function createServer() {
2446
3540
  sourceBitDepth: v.sourceBitDepth,
2447
3541
  // Surfaced when a requested downsample could not be applied, so the
2448
3542
  // agent knows it is looking at a full-resolution frame.
2449
- warning: v.warning
3543
+ warning: v.warning,
3544
+ // Present only for a `times` call. `tiles` is what makes the sheet
3545
+ // readable as data as well as a picture — cell rectangle, time and
3546
+ // status per tile, in the order they were asked for.
3547
+ contactSheet: v.contactSheet,
3548
+ cols: v.cols,
3549
+ rows: v.rows,
3550
+ cellWidth: v.cellWidth,
3551
+ cellHeight: v.cellHeight,
3552
+ tiles: v.tiles
2450
3553
  },
2451
3554
  v.base64
2452
3555
  );
2453
3556
  }
2454
- return textResult(result);
3557
+ if (name === "get_house_style") {
3558
+ const detail = args.detail ?? "summary";
3559
+ return textResult(applyHouseStyleDetail(result, detail), wait);
3560
+ }
3561
+ return textResult(result, wait);
2455
3562
  } catch (e) {
2456
3563
  if (e instanceof BridgeTimeoutError) return errorResult(e.message);
2457
3564
  if (e instanceof BridgeUnreachableError) return errorResult(e.message);
@@ -2461,13 +3568,53 @@ function createServer() {
2461
3568
  panelGate.invalidate();
2462
3569
  return errorResult(unknownOpMessage(name));
2463
3570
  }
2464
- return errorResult(`AE: ${e.message}${e.line ? ` (line ${e.line})` : ""}`);
3571
+ return errorResult(aeErrorText(e));
2465
3572
  }
2466
3573
  return errorResult(e.message);
3574
+ } finally {
3575
+ lease?.release();
2467
3576
  }
2468
3577
  });
2469
3578
  return server;
2470
3579
  }
3580
+ async function runSnapshotOp(name, args, bridge, snapshots) {
3581
+ if (name === "snapshot_comp") {
3582
+ const a = args;
3583
+ const fingerprint = await bridge.runOp("_comp_fingerprint", { compId: a.compId });
3584
+ const snap = snapshots.store(fingerprint);
3585
+ return textResult({
3586
+ snapshotId: snap.id,
3587
+ compId: snap.compId,
3588
+ compName: snap.compName,
3589
+ layers: snap.layerCount,
3590
+ takenAt: new Date(snap.takenAt).toISOString(),
3591
+ next: `Do the work, then diff_comp({ since: "${snap.id}" }).`,
3592
+ lifetime: "Held in this MCP server's memory for the length of the session, not written into the After Effects project.",
3593
+ covers: SNAPSHOT_COVERS,
3594
+ fingerprint: a.includeFingerprint ? fingerprint : void 0
3595
+ });
3596
+ }
3597
+ const d = args;
3598
+ const previous = snapshots.get(d.since);
3599
+ if (!previous) return errorResult(snapshots.missingMessage(d.since));
3600
+ if (d.compId !== void 0 && d.compId !== previous.compId) {
3601
+ return errorResult(
3602
+ `Snapshot ${d.since} is of comp ${previous.compId} ("${previous.compName}"), not comp ${d.compId}. A diff only means anything against a snapshot of the same comp \u2014 omit compId, or snapshot_comp(${d.compId}) first.`
3603
+ );
3604
+ }
3605
+ const res = await bridge.runOp("_comp_diff", {
3606
+ compId: previous.compId,
3607
+ since: previous.fingerprint
3608
+ });
3609
+ const next = snapshots.store(res.fingerprint);
3610
+ return textResult({
3611
+ ...res.diff,
3612
+ since: d.since,
3613
+ snapshotId: next.id,
3614
+ fingerprint: d.includeFingerprint ? res.fingerprint : void 0
3615
+ });
3616
+ }
3617
+ var SNAPSHOT_COVERS = "Records layer id/name/index/type, in/out/start, parent, enabled, keyframe counts, expression count and effect count, plus comp size/duration/frame rate/work area/markers. Property values, expression text, effect parameters and shape contents are not recorded.";
2471
3618
  function createPanelGate(bridge) {
2472
3619
  const RECHECK_MS = 6e4;
2473
3620
  let verdict = null;
@@ -2508,10 +3655,12 @@ async function clientRoots(server) {
2508
3655
  return void 0;
2509
3656
  }
2510
3657
  }
2511
- function textResult(value) {
2512
- return {
2513
- content: [{ type: "text", text: JSON.stringify(value, null, 2) }]
2514
- };
3658
+ function textResult(value, wait) {
3659
+ const json = (v) => ({ type: "text", text: JSON.stringify(v, null, 2) });
3660
+ if (!wait) return { content: [json(value)] };
3661
+ const merged = mergeWait(value, wait);
3662
+ if (merged) return { content: [json(merged)] };
3663
+ return { content: [json(value), json(wait)] };
2515
3664
  }
2516
3665
  function errorResult(message) {
2517
3666
  return {
@@ -2519,6 +3668,7 @@ function errorResult(message) {
2519
3668
  isError: true
2520
3669
  };
2521
3670
  }
3671
+ var JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema";
2522
3672
  function toDraft2020(node) {
2523
3673
  if (Array.isArray(node)) return node.map(toDraft2020);
2524
3674
  if (!node || typeof node !== "object") return node;
@@ -2533,8 +3683,41 @@ function toDraft2020(node) {
2533
3683
  out.items = false;
2534
3684
  }
2535
3685
  }
3686
+ if (typeof out.$schema === "string") out.$schema = JSON_SCHEMA_DIALECT;
2536
3687
  return out;
2537
3688
  }
3689
+ function withCrossFieldConstraints(zodSchema, jsonSchema, opName) {
3690
+ for (const { path: path10, rule } of schemas_exports.crossFieldRulesIn(zodSchema)) {
3691
+ const where = path10.join(".") || "(root)";
3692
+ if (!rule) {
3693
+ logger.warn(
3694
+ `${opName} carries a refinement at ${where} that is not a declared cross-field rule. It is enforced on every call and absent from the schema the model is shown, so an agent can only learn it by having a call rejected. Declare it with crossField() in packages/shared/src/schemas.ts.`
3695
+ );
3696
+ continue;
3697
+ }
3698
+ let node = jsonSchema;
3699
+ for (const step of path10) {
3700
+ node = step === schemas_exports.ARRAY_ELEMENT ? node?.items : node?.properties?.[step];
3701
+ if (!node || typeof node !== "object") break;
3702
+ }
3703
+ if (!node || typeof node !== "object") {
3704
+ logger.warn(
3705
+ `${opName}: the cross-field rule at ${where} has no matching node in the emitted JSON Schema, so it ships enforced but invisible.`
3706
+ );
3707
+ continue;
3708
+ }
3709
+ const fragment = schemas_exports.crossFieldJsonSchema(rule);
3710
+ const collides = Object.keys(fragment).some((k) => k in node);
3711
+ if (collides) (node.allOf ??= []).push(fragment);
3712
+ else Object.assign(node, fragment);
3713
+ }
3714
+ return jsonSchema;
3715
+ }
3716
+ function toolInputSchema(opName) {
3717
+ const schema = OpSchemas2[opName];
3718
+ const jsonSchema = zodToJsonSchema(schema, { target: "jsonSchema7", $refStrategy: "none" });
3719
+ return withCrossFieldConstraints(schema, toDraft2020(jsonSchema), opName);
3720
+ }
2538
3721
  function isAsyncEnvelope(v) {
2539
3722
  return !!(v && typeof v === "object" && "async" in v && v.async === true && "jobId" in v);
2540
3723
  }
@@ -2585,7 +3768,7 @@ ${USAGE}`);
2585
3768
  await server.connect(transport);
2586
3769
  logger.info("MCP server running on stdio");
2587
3770
  }
2588
- var VERSION = "0.3.1";
3771
+ var VERSION = "0.4.0";
2589
3772
  main().catch((e) => {
2590
3773
  logger.error("fatal", e.message);
2591
3774
  process.exit(1);