@engine-room/after-effects-mcp 0.5.1 → 0.5.2

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
@@ -858,6 +858,7 @@ __export(schemas_exports, {
858
858
  DiffComp: () => DiffComp,
859
859
  DuplicateComp: () => DuplicateComp,
860
860
  DuplicateLayer: () => DuplicateLayer,
861
+ EFFECT_PARAMS_DEFAULT: () => EFFECT_PARAMS_DEFAULT,
861
862
  ExportMogrt: () => ExportMogrt,
862
863
  FindLayers: () => FindLayers,
863
864
  GUIDE_TOPICS: () => GUIDE_TOPICS,
@@ -1101,12 +1102,18 @@ var ListLayers = z2.object({
1101
1102
  compId: z2.number(),
1102
1103
  include: includeParam(LAYER_SUMMARY_SECTIONS, "the id/index/name/type map alone")
1103
1104
  });
1105
+ var EFFECT_PARAMS_DEFAULT = 200;
1106
+ var effectParamFields = {
1107
+ maxParams: z2.number().int().positive().optional().describe(`Cap the parameters serialized per effect. Default ${EFFECT_PARAMS_DEFAULT}, applied even when omitted, because a third-party effect can carry thousands (Trapcode Particular: 7,401). Every effect reports paramCount; a capped one also reports paramsOmitted and paramsTruncated, so a capped read is never mistaken for a complete one.`),
1108
+ paramFilter: z2.string().min(1).optional().describe("Case-insensitive regex over each parameter's display name and matchName; only matches are returned (still under maxParams) and paramsMatched counts them. The way to read a few parameters of a heavy effect, e.g. 'velocity|emitter'.")
1109
+ };
1104
1110
  var GetLayerFull = z2.object({
1105
1111
  compId: z2.number(),
1106
1112
  layerId: z2.number(),
1107
1113
  includeChildren: z2.boolean().default(false).optional(),
1108
1114
  include: includeParam(["transform", "effects", "masks", "markers", "bounds", "text", "shape", "source"], "the layer header alone"),
1109
1115
  maxKeyframes: z2.number().int().positive().optional().describe("Cap the keyframes serialized per property. Over the cap you get the first and last few plus a count of what was omitted \u2014 never a silent truncation. Omit for all of them."),
1116
+ ...effectParamFields,
1110
1117
  shapeDepth: z2.number().int().min(0).max(4).optional().describe("How deep to walk a shape layer's Contents tree. Default 4; drop to 1-2 on heavy shape layers."),
1111
1118
  // Not a member of `include`: that list's contract is that omitting it returns
1112
1119
  // everything, and materials have to be off unless asked for.
@@ -1277,7 +1284,7 @@ var GetExpression = z2.object({ compId: z2.number(), layerId: z2.number(), prope
1277
1284
  var SetExpression = z2.object({ compId: z2.number(), layerId: z2.number(), propertyPath: PropertyPath, expression: z2.string() });
1278
1285
  var ToggleExpression = z2.object({ compId: z2.number(), layerId: z2.number(), propertyPath: PropertyPath, enabled: z2.boolean() });
1279
1286
  var ClearExpression = z2.object({ compId: z2.number(), layerId: z2.number(), propertyPath: PropertyPath });
1280
- var ListEffects = z2.object({ compId: z2.number(), layerId: z2.number() });
1287
+ var ListEffects = z2.object({ compId: z2.number(), layerId: z2.number(), ...effectParamFields });
1281
1288
  var AddEffect = z2.object({ compId: z2.number(), layerId: z2.number(), matchName: z2.string() });
1282
1289
  var RemoveEffect = z2.object({ compId: z2.number(), layerId: z2.number(), effectIndex: z2.number().int().positive() });
1283
1290
  var SetEffectParam = crossField(
@@ -1285,15 +1292,15 @@ var SetEffectParam = crossField(
1285
1292
  compId: z2.number(),
1286
1293
  layerId: z2.number(),
1287
1294
  effectIndex: z2.number().int().positive(),
1288
- paramName: z2.string().optional().describe("The parameter's display name as list_effects reports it, e.g. 'Blurriness'."),
1289
- 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."),
1295
+ paramName: z2.string().optional().describe("The parameter's display name as list_effects reports it, in After Effects' UI language \u2014 'Blurriness' on an English install, not on a localised one. Prefer paramMatchName."),
1296
+ paramMatchName: z2.string().optional().describe("Preferred. The parameter's matchName, e.g. 'ADBE Gaussian Blur 2-0001' \u2014 the same in every UI language. Tried before paramName when both are given."),
1290
1297
  value: VecAny,
1291
1298
  time: z2.number().optional(),
1292
1299
  keyframe: z2.boolean().default(false).optional()
1293
1300
  }),
1294
1301
  // Neither one is not a call with a default — it is a call that resolves no
1295
1302
  // property at all, and `effects.jsx` answers it with a bare "Effect param not
1296
- // found", which reads like the *name* was wrong rather than absent.
1303
+ // found"-style miss, which reads like the *name* was wrong rather than absent.
1297
1304
  // Both together is legal and useful: matchName wins, name is the fallback.
1298
1305
  {
1299
1306
  kind: "atLeastOne",
@@ -1830,8 +1837,12 @@ var OpMutation = {
1830
1837
  add_marker: "write",
1831
1838
  remove_marker: "write",
1832
1839
  // vision — read-only despite being the slowest thing here. `screenshot_*`
1833
- // borrows the comp's resolutionFactor and restores it in a `finally`;
1834
- // nothing in the project changes.
1840
+ // borrows the comp's resolutionFactor (and screenshot_layer the solo
1841
+ // switches) and restores them in a `finally`; nothing in the project changes.
1842
+ // The borrow can cost one undo step, "AE MCP: screenshot", when the comp was
1843
+ // not already at the requested factor (#116) — a step whose net effect is
1844
+ // nil, so landing between two chunks of a long batch moves nothing the batch
1845
+ // wrote. Queuing a render behind every write would cost far more than that.
1835
1846
  screenshot_frame: "read",
1836
1847
  screenshot_layer: "read",
1837
1848
  // batch
@@ -2490,7 +2501,7 @@ var descriptions = {
2490
2501
  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.",
2491
2502
  // ---------- layers ----------
2492
2503
  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.",
2493
- 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.",
2504
+ 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. Effect parameters are the one thing bounded even when you pass nothing: at most 200 per effect unless you raise `maxParams`, because a third-party effect can carry thousands (Trapcode Particular: 7,401). A capped effect says so in `paramsOmitted`/`paramsTruncated`; pass `paramFilter` (a regex over parameter name/matchName) to read just the ones you need. 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.",
2494
2505
  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. The justification is read back after the write (AE 26.3 has been seen to store a different alignment from the one set \u2014 issues #93/#94): the result carries `justification` as read and `justificationReasserted` when a second write was needed, and if it still disagrees the layer is removed and the call throws, so a success result is a layer aligned the way it says.",
2495
2506
  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.",
2496
2507
  create_solid_layer: "Solid-color layer. color is RGB 0..1.",
@@ -2519,10 +2530,10 @@ var descriptions = {
2519
2530
  toggle_expression: "Enable/disable an expression without clearing. Enabling verifies the same way set_expression does: if AE reports an error on the expression once it is enabled, the call throws with the message rather than returning ok.",
2520
2531
  clear_expression: "Remove an expression.",
2521
2532
  // ---------- effects ----------
2522
- list_effects: "All effects on a layer with current param values + keyframes/expressions.",
2523
- 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.",
2533
+ list_effects: "All effects on a layer with current param values + keyframes/expressions. Bounded by default: at most 200 parameters per effect (`maxParams` raises it), because a third-party effect can carry thousands \u2014 Trapcode Particular has 7,401, about 1 MB in one read. Every effect reports `paramCount`; a capped one adds `paramsOmitted` and a `paramsTruncated` note, so never read a capped list as the whole effect. To read a few parameters of a heavy effect, pass `paramFilter` (case-insensitive regex over name/matchName) rather than raising the cap.",
2534
+ 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. Returns the new effect with at most 200 of its parameters, the rest counted in `paramsOmitted`; use list_effects with `paramFilter` or `maxParams` to read more.",
2524
2535
  remove_effect: "Remove effect by 1-based index.",
2525
- 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.",
2536
+ set_effect_param: "Set one parameter of one effect. Pass at least one of `paramMatchName` or `paramName`, and prefer `paramMatchName` (e.g. 'ADBE Gaussian Blur 2-0001', 'ADBE Drop Shadow-0004'): it is the same in every language, while `paramName` is the display name in the user's UI language \u2014 an English 'Distance' does not match on a Chinese or German After Effects. list_effects reports both for every parameter on the layer. Give both and the matchName wins, with the name as fallback. A miss lists the effect's parameters (name and matchName) so you can retry. `keyframe:true` plus `time` writes a keyframed value instead of a static one.",
2526
2537
  set_effect_enabled: "Toggle an effect on/off without removing.",
2527
2538
  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.",
2528
2539
  // ---------- text ----------
@@ -3369,7 +3380,7 @@ var GUIDES = [
3369
3380
  {
3370
3381
  name: "after-effects",
3371
3382
  description: "How to drive Adobe After Effects well through the AE MCP tools \u2014 orienting in a project, bounding every read, verifying a write with a diff, screenshots as a one-off diagnostic with their cost in tokens, write ordering, and the three bridge failures whose remedies contradict each other. Load whenever a task involves After Effects, motion graphics, comps, layers, or keyframes; it points at one reference per subject for everything else.",
3372
- body: '# Driving After Effects\n\nYou have direct control of a live After Effects session. The user sees every\nchange immediately, and every tool call is a real undo step in their project.\nWork like a motion designer at the keyboard, not like a script that fires blind.\n\nThis file holds only what silently produces wrong output on *any* task. Each\nsubject sits behind it in a reference, loaded when a task reaches that subject\nand never otherwise. From any client, `ae_guide({topic: \u2026})`; loaded as a\nskill, the file beside this one:\n\n| When the task reaches\u2026 | Topic | File |\n|---|---|---|\n| keyframes, easing, rigging, expressions | `animation` | `references/animation.md` |\n| a shape layer and its Contents | `shapes` | `references/shapes.md` |\n| a text layer | `text` | `references/text.md` |\n| shots placed into a master comp, markers, retiming a shot | `assembly` | `references/assembly.md` |\n| any raw ExtendScript for `run_jsx` \u2014 read it **before** writing the script | `extendscript-gotchas` | `references/extendscript-gotchas.md` |\n| sound effects and beds | `sound` | `references/sound.md` |\n| a `.mogrt` export, importing footage, or the solids a deleted comp leaves behind | `mogrt-and-footage` | `references/mogrt-and-footage.md` |\n| a tool that fought back, and what to do with what you learned | `issue-journal` | `references/issue-journal.md` |\n| a call that behaves differently from what you remember | `whats-new` | `references/whats-new.md` |\n\nTwo topics are siblings rather than references: `style-guide`, for capturing the\nuser\'s look, and `ae-setup`, for a connection that is not working.\n\n## Read the house style first\n\n`get_house_style` returns the project\'s style guide \u2014 palette, type, motion\ndefaults, layout rules \u2014 read from `house-style.md` beside the `.aep`. Call it\nonce at the start of any build task and follow it; 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** of a few hundred tokens, which is what you build\nfrom. Pass `detail: "full"` only when you need the guide\'s own wording \u2014 and\nalways before `set_house_style`, which replaces the whole file, so you send the\nmerged document rather than a patch. `structured: false` means the guide could\nnot be read as a spec and its opening text is returned instead; `found: false`\nmeans there is none \u2014 build with sensible defaults and offer once, at the end,\nto capture one from what you made. Do not nag about it.\n\n## Orient before you touch anything\n\nNever guess at project state. Cheap reads exist for exactly this, and the\nbounded form is the one to reach for:\n\n| Question | Call |\n|---|---|\n| What is in this project? | `get_project_summary` |\n| What comps exist? | `list_comps({include: []})` \u2014 ids and names |\n| What is in this comp? | `list_layers({compId, include: []})` \u2014 id, index, name, type; `get_comp_tree` for the nesting |\n| Where is a layer, by name, type or effect? | `find_layers` \u2014 `{matches, count, compsSearched, included}`; a match is id, index, name, sourceType, compId and compName, and `include` (`flags`, `timing`, `parent` \u2014 the `list_layers` names) widens it. Unlike `list_layers`, omitting `include` here does **not** return everything |\n| One layer, in depth | `get_layer_full({compId, layerId, include: [\u2026]})` \u2B50 |\n| What did my last change actually do? | `snapshot_comp` \u2192 `diff_comp` \u2B50 |\n\n**Bound every read.** A tool result is re-sent to you on every later request\nfor the rest of the session, so an unbounded read is paid for once when you\nmake it and again on every request until the session ends. `include: []` on the\nlist calls; on `get_layer_full`, `include` names the sections you want\n(`transform`, `effects`, `masks`, `markers`, `bounds`, `text`, `shape`,\n`source`), `maxKeyframes` caps the keyframes per property, `shapeDepth` limits\nthe Contents walk, and `shapeDetail: "compact"` reads a shape layer as one line\nper group. Omit them all and you get everything. Whatever a bound leaves out is\nnamed and counted in the response, so a bounded read never passes for a\ncomplete one.\n\n**One bounded `get_layer_full` beats several narrow calls.** It returns\ntransforms with their keyframes and expressions, effects with every parameter,\nmasks, markers and `sourceRect` (the layer\'s visible bounds) in one answer, so\nwhen you need three of those, name the three in `include` rather than making\nthree calls. The bound is the `include` list, not the number of calls.\n\n**Making a variant of something** is `duplicate_comp`, which returns the new id\nso you never look for the copy 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 and re-points the copy at them.\n\n**Effects are added by matchName**, not display name:\n`add_effect({matchName: "ADBE Gaussian Blur 2"})`, then `set_effect_param` by\nparameter name. If you do not know a matchName, `list_available_effects({filter:\n"blur"})` \u2014 always with a `filter`, since the unfiltered list is several hundred\nentries and takes seconds every time. `list_effects` shows what is already on a\nlayer with every parameter.\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\nposition that **shifts whenever layers are added, deleted or reordered**. Store\n`(compId, layerId)` and pass those; an index captured before a `create_*` call\nmay point at a different layer by the time you use it.\n\n`reorder_layer` is the op that shifts every index below it, so it takes\n**exactly one** destination and the id forms are the ones to use:\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 means the index the layer **ends up at**, not the\nslot it displaces. Reach for it only when you genuinely mean "on top" or "at the\nback". The result carries `movedFrom` beside the landed `index`.\n\n## Read, then write, then verify\n\n1. Read the current state (a bounded `get_layer_full`).\n2. Make the change.\n3. Verify by reading back \u2014 property values are the ground truth. A screenshot\n tells you something *looks* wrong; the property tells you *why*.\n\n**Verify with a diff, not a second full read.** Re-reading a comp to see what\nchanged makes you compare two large answers by eye, and you pay for both for the\nrest of the session. `snapshot_comp({compId})` before the write returns a\n`snapshotId` and almost nothing else; `diff_comp({since})` afterwards returns\nonly what moved \u2014 layers added, removed, renamed, retimed, re-parented, keyframe\ncounts, expressions and effects gained or lost \u2014 and a count of the layers that\ndid not. `run_jsx` and `run_batch` take `diff: true`, which does the same\n*inside* the call, so there is no window between the write and the fingerprint.\n\nA fingerprint records no property values, expression text, effect parameters,\nmasks or shape contents, so `changeCount: 0` means none of the recorded fields\nmoved \u2014 not that the comp is unchanged. For "is this value right", read the\nproperty.\n\n**A failed write is a partial write.** Nothing rolls back, on any tool: a\n`run_jsx` that throws on its fourth line has done the first three, and a\n`run_batch` that stops at op 30 has done 29. So find out what landed \u2014 on a\nfailure the `diff: true` result rides on the error, which is the cheapest way \u2014\nand never re-send the same call to see whether it fails again: the half that\nworked is applied a second time.\n\n## Screenshots are a diagnostic, not a feedback loop\n\n`screenshot_frame` and `screenshot_layer` are **one-off checks**. Do not\nscreenshot every frame, do not scrub through time, do not screenshot after every\nedit.\n\n**An image is the most expensive result this server returns, and it stays in\nyour context for the rest of the session.** Measured at 4K on 26.3: a frame at\n`downsample: 3` \u2014 the factor picked for a 4K comp when you pass none, a\n1280\xD7720 image \u2014 costs about 1,230 tokens; `downsample: 2` about 2,765; a full\n4K frame at `downsample: 1` can blow out your context in one call. Budget a\nbuild in a handful of sheets, not in frames.\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\nreturns a *single* tiled image with the time burned into each tile, held to\nroughly the pixel budget of one frame. `time` and `times` are mutually\nexclusive; there is no `times` on `screenshot_layer`.\n\nThe `downsample` is picked from the comp size unless you pass one \u2014 2 at 1080p,\n3 at 4K, aiming at a long edge near 1280px, per tile on a sheet. `downsample: 1`\nis genuinely full resolution: the render sets the comp\'s resolution explicitly\nand restores it afterwards, whatever the viewer\'s Resolution dropdown says. The\nresult reports the dimensions and the factor actually applied \u2014 trust those.\nSpace single frames out; rapid back-to-back requests are the pattern most likely\nto come back stale, and a sheet does the spacing for you.\n\nFour results are not a picture, and their remedies point in different\ndirections, so read which one you got before you retry anything:\n\n- **`Stale frame`** \u2014 After Effects re-served pixels it rendered for a\n *different* request, which the error names. Wait a few seconds and retry at a\n higher `downsample`; if two frames of a genuinely static comp really are\n identical, a different factor renders a different number of pixels and proves\n it.\n- **`Corrupt frame`** \u2014 the render stopped writing and the file is not a whole\n PNG, so nothing was sent. **Not a timeout.** It tracks how heavy the comp is:\n retry at `downsample` 6\u20138, or screenshot the shot precomps one at a time\n instead of the assembly.\n- **`Render timed out`** \u2014 After Effects was still working when the panel gave\n up, and probably still is. Wait a few seconds before doing anything else; a\n retry issued now queues behind it.\n- **`empty: true`** \u2014 every pixel at that time is fully transparent. That is a\n fact about the composition: the wrong time, a layer outside its in/out points,\n disabled, or at zero opacity.\n\nOn a sheet a single bad tile is drawn as a marked block and named in `warning`;\nthe rest of the sheet is still good, so read it rather than re-requesting.\n\n**Never disable layers to make a screenshot render.** A frame that will not\nrender is a limit of the render path, not project content that needs fixing \u2014\nand it is very easy to leave someone\'s comp switched off afterwards.\n\n## Bulk work, and the order writes land in\n\n`run_batch` runs many ops in one ExtendScript pass \u2014 far faster than the same\nops as separate calls, and far fewer undo steps. **Up to 500 ops it is one undo\nstep.** Over 500 it returns a `jobId` at once, before the first chunk runs, and\nlands as **one undo step per chunk of 25** \u2014 about 24 for 600 ops \u2014 because\nAfter Effects discards an undo group that spans two script calls.\n`await_job(jobId)` finishes it, and is the call that carries progress: send it\nwith a progress token and `notifications/progress` arrive while it waits. None\ncan ride on `run_batch` itself, whose response is already back; `get_job` polls\nthe same state without a token. `singleUndo: true` forces one step at any size\nup to 2000 by running the whole batch in one blocking call, with AE\'s interface\nfrozen for the duration. Every result carries the *measured* `undoSteps`: read\nit before you tell anyone how many Cmd-Z the work takes. `transactional: true`\n(the default) stops at the first failing op; `false` runs the rest and collects\nthe errors.\nNeither rolls anything back \u2014 `diff: true` shows what landed.\n\n**You do not have to issue writes one at a time.** The server runs one write at\na time for the whole session, in the order you issued them, and a long\n`run_batch` holds that lock until its last chunk lands \u2014 so nothing drops into\nthe middle of work the user asked for as one thing. A call that had to wait says\nso with `queuedBehind` and `waitedMs`. Reads are never queued: `list_*`,\n`get_*`, the screenshots and `await_job` answer while a batch runs.\n\n## Three failures that read alike, with remedies that contradict\n\n- **Did not answer in time.** The call reached After Effects, which is\n single-threaded, so a long script or a modal dialog nobody has clicked blocks\n every reply. It may still be running: do **not** re-send it, do not restart\n After Effects, do not run `setup_panel`. Poll `check_setup` for about a\n minute, and ask the user whether a dialog is hiding behind another window. A\n call that legitimately needs longer gets it from `AE_MCP_OP_TIMEOUT_MS` in the\n server\'s environment.\n- **Cannot reach After Effects.** The connection was refused, so nothing\n reached After Effects and nothing changed. The server looks again on its own\n first \u2014 7777, then the port file \u2014 and follows a panel that has moved, so a\n refusal that reaches you is one that search did not resolve. Call\n `check_setup` and relay its `nextSteps` to the user in plain language; the\n repair path is the `ae-setup` topic. If it reports the panel answering on a\n port other than the one the call named, retry the call or reconnect the MCP\n server and never restart After Effects for it: something *is* listening, and\n a restart costs the user their work in progress. Do not diagnose CEP by hand.\n- **Waited behind another op for the write queue and was dropped.** Nothing\n reached After Effects and nothing changed. Something in front is slow \u2014 usually\n a long `run_batch`; find it with `get_job` or `await_job` \u2014 then re-send once\n it has finished. That is the one of the three where re-sending is right.'
3383
+ body: '# Driving After Effects\n\nYou have direct control of a live After Effects session. The user sees every\nchange immediately, and every tool call that changes something is a real undo\nstep in their project.\nWork like a motion designer at the keyboard, not like a script that fires blind.\n\nThis file holds only what silently produces wrong output on *any* task. Each\nsubject sits behind it in a reference, loaded when a task reaches that subject\nand never otherwise. From any client, `ae_guide({topic: \u2026})`; loaded as a\nskill, the file beside this one:\n\n| When the task reaches\u2026 | Topic | File |\n|---|---|---|\n| keyframes, easing, rigging, expressions | `animation` | `references/animation.md` |\n| a shape layer and its Contents | `shapes` | `references/shapes.md` |\n| a text layer | `text` | `references/text.md` |\n| shots placed into a master comp, markers, retiming a shot | `assembly` | `references/assembly.md` |\n| any raw ExtendScript for `run_jsx` \u2014 read it **before** writing the script | `extendscript-gotchas` | `references/extendscript-gotchas.md` |\n| sound effects and beds | `sound` | `references/sound.md` |\n| a `.mogrt` export, importing footage, or the solids a deleted comp leaves behind | `mogrt-and-footage` | `references/mogrt-and-footage.md` |\n| a tool that fought back, and what to do with what you learned | `issue-journal` | `references/issue-journal.md` |\n| a call that behaves differently from what you remember | `whats-new` | `references/whats-new.md` |\n\nTwo topics are siblings rather than references: `style-guide`, for capturing the\nuser\'s look, and `ae-setup`, for a connection that is not working.\n\n## Read the house style first\n\n`get_house_style` returns the project\'s style guide \u2014 palette, type, motion\ndefaults, layout rules \u2014 read from `house-style.md` beside the `.aep`. Call it\nonce at the start of any build task and follow it; 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** of a few hundred tokens, which is what you build\nfrom. Pass `detail: "full"` only when you need the guide\'s own wording \u2014 and\nalways before `set_house_style`, which replaces the whole file, so you send the\nmerged document rather than a patch. `structured: false` means the guide could\nnot be read as a spec and its opening text is returned instead; `found: false`\nmeans there is none \u2014 build with sensible defaults and offer once, at the end,\nto capture one from what you made. Do not nag about it.\n\n## Orient before you touch anything\n\nNever guess at project state. Cheap reads exist for exactly this, and the\nbounded form is the one to reach for:\n\n| Question | Call |\n|---|---|\n| What is in this project? | `get_project_summary` |\n| What comps exist? | `list_comps({include: []})` \u2014 ids and names |\n| What is in this comp? | `list_layers({compId, include: []})` \u2014 id, index, name, type; `get_comp_tree` for the nesting |\n| Where is a layer, by name, type or effect? | `find_layers` \u2014 `{matches, count, compsSearched, included}`; a match is id, index, name, sourceType, compId and compName, and `include` (`flags`, `timing`, `parent` \u2014 the `list_layers` names) widens it. Unlike `list_layers`, omitting `include` here does **not** return everything |\n| One layer, in depth | `get_layer_full({compId, layerId, include: [\u2026]})` \u2B50 |\n| What did my last change actually do? | `snapshot_comp` \u2192 `diff_comp` \u2B50 |\n\n**Bound every read.** A tool result is re-sent to you on every later request\nfor the rest of the session, so an unbounded read is paid for once when you\nmake it and again on every request until the session ends. `include: []` on the\nlist calls; on `get_layer_full`, `include` names the sections you want\n(`transform`, `effects`, `masks`, `markers`, `bounds`, `text`, `shape`,\n`source`), `maxKeyframes` caps the keyframes per property, `shapeDepth` limits\nthe Contents walk, and `shapeDetail: "compact"` reads a shape layer as one line\nper group. Omit them all and you get everything. Whatever a bound leaves out is\nnamed and counted in the response, so a bounded read never passes for a\ncomplete one.\n\n**One bounded `get_layer_full` beats several narrow calls.** It returns\ntransforms with their keyframes and expressions, effects with every parameter,\nmasks, markers and `sourceRect` (the layer\'s visible bounds) in one answer, so\nwhen you need three of those, name the three in `include` rather than making\nthree calls. The bound is the `include` list, not the number of calls.\n\n**Making a variant of something** is `duplicate_comp`, which returns the new id\nso you never look for the copy 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 and re-points the copy at them.\n\n**Effects are added by matchName**, not display name:\n`add_effect({matchName: "ADBE Gaussian Blur 2"})`, then `set_effect_param` by\n`paramMatchName` \u2014 display names are translated, so a `paramName` that works in\nEnglish misses on a German or Japanese install. If you do not know a matchName,\n`list_available_effects({filter: "blur"})` \u2014 always with a `filter`, since the\nunfiltered list is several hundred entries and takes seconds every time.\n`list_effects` shows what is already on a layer, up to 200 parameters per effect\n(`maxParams` and `paramFilter` reach the rest).\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\nposition that **shifts whenever layers are added, deleted or reordered**. Store\n`(compId, layerId)` and pass those; an index captured before a `create_*` call\nmay point at a different layer by the time you use it.\n\n`reorder_layer` is the op that shifts every index below it, so it takes\n**exactly one** destination and the id forms are the ones to use:\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 means the index the layer **ends up at**, not the\nslot it displaces. Reach for it only when you genuinely mean "on top" or "at the\nback". The result carries `movedFrom` beside the landed `index`.\n\n## Read, then write, then verify\n\n1. Read the current state (a bounded `get_layer_full`).\n2. Make the change.\n3. Verify by reading back \u2014 property values are the ground truth. A screenshot\n tells you something *looks* wrong; the property tells you *why*.\n\n**Verify with a diff, not a second full read.** Re-reading a comp to see what\nchanged makes you compare two large answers by eye, and you pay for both for the\nrest of the session. `snapshot_comp({compId})` before the write returns a\n`snapshotId` and almost nothing else; `diff_comp({since})` afterwards returns\nonly what moved \u2014 layers added, removed, renamed, retimed, re-parented, keyframe\ncounts, expressions and effects gained or lost \u2014 and a count of the layers that\ndid not. `run_jsx` and `run_batch` take `diff: true`, which does the same\n*inside* the call, so there is no window between the write and the fingerprint.\n\nA fingerprint records no property values, expression text, effect parameters,\nmasks or shape contents, so `changeCount: 0` means none of the recorded fields\nmoved \u2014 not that the comp is unchanged. For "is this value right", read the\nproperty.\n\n**A failed write is a partial write.** Nothing rolls back, on any tool: a\n`run_jsx` that throws on its fourth line has done the first three, and a\n`run_batch` that stops at op 30 has done 29. So find out what landed \u2014 on a\nfailure the `diff: true` result rides on the error, which is the cheapest way \u2014\nand never re-send the same call to see whether it fails again: the half that\nworked is applied a second time.\n\n## Screenshots are a diagnostic, not a feedback loop\n\n`screenshot_frame` and `screenshot_layer` are **one-off checks**. Do not\nscreenshot every frame, do not scrub through time, do not screenshot after every\nedit.\n\n**An image is the most expensive result this server returns, and it stays in\nyour context for the rest of the session.** Measured at 4K on 26.3: a frame at\n`downsample: 3` \u2014 the factor picked for a 4K comp when you pass none, a\n1280\xD7720 image \u2014 costs about 1,230 tokens; `downsample: 2` about 2,765; a full\n4K frame at `downsample: 1` can blow out your context in one call. Budget a\nbuild in a handful of sheets, not in frames.\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\nreturns a *single* tiled image with the time burned into each tile, held to\nroughly the pixel budget of one frame. `time` and `times` are mutually\nexclusive; there is no `times` on `screenshot_layer`.\n\nThe `downsample` is picked from the comp size unless you pass one \u2014 2 at 1080p,\n3 at 4K, aiming at a long edge near 1280px, per tile on a sheet. `downsample: 1`\nis genuinely full resolution: the render sets the comp\'s resolution explicitly\nand restores it afterwards, whatever the viewer\'s Resolution dropdown says. The\nresult reports the dimensions and the factor actually applied \u2014 trust those.\nSpace single frames out; rapid back-to-back requests are the pattern most likely\nto come back stale, and a sheet does the spacing for you.\n\nFour results are not a picture, and their remedies point in different\ndirections, so read which one you got before you retry anything:\n\n- **`Stale frame`** \u2014 After Effects re-served pixels it rendered for a\n *different* request, which the error names. Wait a few seconds and retry at a\n higher `downsample`; if two frames of a genuinely static comp really are\n identical, a different factor renders a different number of pixels and proves\n it.\n- **`Corrupt frame`** \u2014 the render stopped writing and the file is not a whole\n PNG, so nothing was sent. **Not a timeout.** It tracks how heavy the comp is:\n retry at `downsample` 6\u20138, or screenshot the shot precomps one at a time\n instead of the assembly.\n- **`Render timed out`** \u2014 After Effects was still working when the panel gave\n up, and probably still is. Wait a few seconds before doing anything else; a\n retry issued now queues behind it.\n- **`empty: true`** \u2014 every pixel at that time is fully transparent. That is a\n fact about the composition: the wrong time, a layer outside its in/out points,\n disabled, or at zero opacity.\n\nOn a sheet a single bad tile is drawn as a marked block and named in `warning`;\nthe rest of the sheet is still good, so read it rather than re-requesting.\n\n**Never disable layers to make a screenshot render.** A frame that will not\nrender is a limit of the render path, not project content that needs fixing \u2014\nand it is very easy to leave someone\'s comp switched off afterwards.\n\n## Bulk work, and the order writes land in\n\n`run_batch` runs many ops in one ExtendScript pass \u2014 far faster than the same\nops as separate calls, and far fewer undo steps. **Up to 500 ops it is one undo\nstep.** Over 500 it returns a `jobId` at once, before the first chunk runs, and\nlands as **one undo step per chunk of 25** \u2014 about 24 for 600 ops \u2014 because\nAfter Effects discards an undo group that spans two script calls.\n`await_job(jobId)` finishes it, and is the call that carries progress: send it\nwith a progress token and `notifications/progress` arrive while it waits. None\ncan ride on `run_batch` itself, whose response is already back; `get_job` polls\nthe same state without a token. `singleUndo: true` forces one step at any size\nup to 2000 by running the whole batch in one blocking call, with AE\'s interface\nfrozen for the duration. Every result carries the *measured* `undoSteps`: read\nit before you tell anyone how many Cmd-Z the work takes. `transactional: true`\n(the default) stops at the first failing op; `false` runs the rest and collects\nthe errors.\nNeither rolls anything back \u2014 `diff: true` shows what landed.\n\n**You do not have to issue writes one at a time.** The server runs one write at\na time for the whole session, in the order you issued them, and a long\n`run_batch` holds that lock until its last chunk lands \u2014 so nothing drops into\nthe middle of work the user asked for as one thing. A call that had to wait says\nso with `queuedBehind` and `waitedMs`. Reads are never queued: `list_*`,\n`get_*`, the screenshots and `await_job` answer while a batch runs.\n\n## Three failures that read alike, with remedies that contradict\n\n- **Did not answer in time.** The call reached After Effects, which is\n single-threaded, so a long script or a modal dialog nobody has clicked blocks\n every reply. It may still be running: do **not** re-send it, do not restart\n After Effects, do not run `setup_panel`. Poll `check_setup` for about a\n minute, and ask the user whether a dialog is hiding behind another window. A\n call that legitimately needs longer gets it from `AE_MCP_OP_TIMEOUT_MS` in the\n server\'s environment.\n- **Cannot reach After Effects.** The connection was refused, so nothing\n reached After Effects and nothing changed. The server looks again on its own\n first \u2014 7777, then the port file \u2014 and follows a panel that has moved, so a\n refusal that reaches you is one that search did not resolve. Call\n `check_setup` and relay its `nextSteps` to the user in plain language; the\n repair path is the `ae-setup` topic. If it reports the panel answering on a\n port other than the one the call named, retry the call or reconnect the MCP\n server and never restart After Effects for it: something *is* listening, and\n a restart costs the user their work in progress. Do not diagnose CEP by hand.\n- **Waited behind another op for the write queue and was dropped.** Nothing\n reached After Effects and nothing changed. Something in front is slow \u2014 usually\n a long `run_batch`; find it with `get_job` or `await_job` \u2014 then re-send once\n it has finished. That is the one of the three where re-sending is right.'
3373
3384
  },
3374
3385
  {
3375
3386
  name: "animation",
@@ -3384,7 +3395,7 @@ var GUIDES = [
3384
3395
  {
3385
3396
  name: "extendscript-gotchas",
3386
3397
  description: "The run_jsx reference \u2014 when to script instead of calling a tool, how much one call can do before the timeout, scriptPath and libraries as the normal way to build, the helpers already in scope, and the ExtendScript facts that abort a script while naming something else (property lookups that return null, a `.value` read at the playhead rather than at a time, effects addressed by a name that is not unique, a track matte that switches its matte's video off, ease arrays sized per property, a TextDocument that stores the wrong justification, output-module settings that are read-only, the reserved words that stop a script before its first line). Read it before writing raw ExtendScript, not after it fails.",
3387
- body: '# Scripting After Effects with run_jsx\n\n`run_jsx` executes arbitrary ExtendScript inside After Effects. Reach for it\nwhen a needed operation has no tool \u2014 the render queue, a bulk edit no single op\nexpresses, a rig that is easier to describe as a loop than as forty calls. Check\nthe tool list first: duplicating a comp, easing a keyframe, reordering a layer,\nplacing a shape layer at a sane origin and scoring a scene all have tools, and\neach wraps a trap on this page. The whole `OPS` table is in scope, so a script\ncan call any tool as a function and keep to the DOM for the part no tool covers.\n\n## How much one call can do\n\n- **Timeouts.** A call gets 120 s by default; `run_jsx`, `run_batch`, the two\n screenshot ops, `export_mogrt`, `import_footage` and `place_audio_cues` get\n 300 s. `AE_MCP_OP_TIMEOUT_MS` in the server\'s environment raises every one of\n them. ExtendScript is single-threaded, so After Effects\' interface is frozen\n for as long as the script runs, and a script that outlives its limit looks to\n the server exactly like a dead bridge \u2014 the "did not answer in time" failure \u2014\n while it carries on running to the end.\n- **In practice, about 60 layers\' worth of creation and keying per call** is\n where a scene build stays well inside the limit (reported from a\n fourteen-scene, thousand-layer build). Split a big build by scene, not by op;\n the same op repeated hundreds of times is `run_batch`, which chunks itself.\n- **Source size** \u2014 the script plus every library it names \u2014 is capped at 1 MB\n per call, because libraries are inlined into the call, so every byte travels\n every time.\n\n## Build from files, not from the conversation\n\nInline `code` is for a probe or a one-liner. A build is written as files:\n\n- **`scriptPath`** runs an absolute `.jsx` path. The server reads it, so the\n script is paid for once, on disk, rather than once per call in the\n conversation and again on every later request.\n- **`libraries`** takes absolute `.jsx` paths and inlines them ahead of the\n script, in the same scope, so their functions are callable from it. A library\n is re-read and re-evaluated on every call, so keep it to declarations rather\n than to work; it is parse-checked on its own before the script runs, and a\n failure inside one is reported against that file, by name and line.\n\nThe rule: a script file is paid for once; a helper shared by two scripts belongs\nin a library, never pasted into both. `code` and `scriptPath` are exclusive.\n\nPass **`diff: true`** on any script that writes. It fingerprints the comp\naround the call and appends only what changed \u2014 layers added, renamed, retimed,\nkeyframe counts \u2014 so the script reports what it did instead of you reading the\ncomp back, and on a throw the diff rides on the error, which is how you find\nwhere a half-applied script stopped.\n\n## What you already have\n\nBefore you write a helper, check it is not in scope. Each of these wraps a trap\non this page, and a version you write yourself re-derives the bug.\n\n- **`OPS`** \u2014 the whole tool table:\n `OPS.set_transform({compId, layerId, position: [0, 0]})`. Anything a tool\n already does well, do 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 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 itself; see "Keyframes and easing" below for why that is not optional.\n- **`shape(comp, {name, position})`** \u2014 a shape layer whose origin is `[0,0]`.\n- **`withoutUndoGroup(fn)`** \u2014 closes the undo group around one statement, which\n is what `copyToComp` needs.\n\n## Adobe\'s documentation describes an ExtendScript After Effects 2026 does not implement\n\nEvery item here is measured on 26.3, and several contradict Adobe\'s reference:\n`$.evalFile` evaluates into the calling function\'s scope, not the global one;\n`Error.start` and `Error.end` are `0` on every error, however far in the throw\nwas; an undo group opened in one script call and closed in another is\ndiscarded; `CompItem.posterTime` does not exist; `exportAsMotionGraphicsTemplate`\ninvalidates `app.project` itself, not only the comp passed to it. None of them\nraise \u2014 each returns exactly what success returns. When this page and Adobe\'s\nreference disagree, this page was measured; and before you build on a\ndocumented behaviour nothing here mentions, write the three-line probe that\nproves it.\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, as\n`null is not an object`. When a lookup might miss, check for null on the line\nthat does it.\n\n- **2D rotation is `ADBE Rotate Z`.** `layer.property("ADBE Rotation")` is\n null; `layer.transform.rotation` is the safe form.\n- **Audio levels are `layer.audioLevels`.** `layer.property("ADBE Audio\n Levels")` is null on an audio layer \u2014 the level is never set, on every layer,\n silently. *The tool already does:* `place_audio_cues`.\n- **Time remap is `layer.timeRemap`.** `layer.property("ADBE Time Remap")` is\n null on audio layers and on precomp layers, even after\n `timeRemapEnabled = true`.\n- **`instanceof` is unreliable on host objects.** Probe instead: a shape layer\n is one where `property("ADBE Root Vectors Group")` is non-null, and a\n project item\'s kind is `item.typeName` \u2014 `"Composition"`, `"Footage"`,\n `"Folder"` \u2014 a string compare that needs no class.\n\n## Reading and writing values\n\n- **`.value` is the value at the comp\'s current time** \u2014 wherever the user\n left the playhead, not time zero \u2014 so on a keyed or expression-driven\n property it is one sample from a moment you did not choose. Read at the\n time you mean with `valueAtTime(t, false)`, the post-expression value\n (`true` is pre-expression). To bake a driven property, sample every time\n you need first, then remove the expression, then the keys: each of those\n changes what the next read returns.\n- **`setValue` on a comp that is not open in a viewer can throw with the\n value already applied.** Open the comp first \u2014 `comp.openInViewer()`, or\n `set_active_comp` from outside \u2014 and on such a throw read the property back\n before retrying: the write may already be there.\n\n## Layers and comps\n\n- **A `comp.layer(n)` reference is index-bound, not a handle.** Once a\n `copyToComp` or a `duplicate()` has shifted the destination\'s indices, a\n reference taken earlier resolves to a *different* layer \u2014 which is how a script\n parents a layer to itself. Re-resolve by id or name after anything that\n inserts a layer.\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. Identify a copy by diffing the set of layer ids\n before and after the call, or pass `diff: true`.\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 exactly the\n rig worth copying. Wrap that one call in `withoutUndoGroup(function () { \u2026 })`,\n or pass `undoGroup: false` for the whole script and accept whatever undo steps\n AE records on its own.\n- **A copied layer carries parent-relative values.** Parented to a null that\n 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\n inverse offset, re-parenting while the new parent is at identity.\n- **Set the parent first, the transform second.** Raw `layer.parent = x` does\n not reliably preserve where a layer sits two levels deep, so after scripted\n parenting audit scale and rotation as well as position. *The tool already\n does:* `parent_layer` with `preserveTransform`.\n- **`app.executeCommand(id)` silently no-ops** through this bridge. Menu\n commands depend on host focus and the active selection, and the bridge has\n neither; the call returns without complaint and nothing happens. Use the API\n equivalents \u2014 `CompItem.duplicate()`, `layer.duplicate()`. *The tool already\n does:* `duplicate_comp`, whose `deep: true` also duplicates the nested comps,\n which `CompItem.duplicate()` alone does not.\n- **`moveTo` is a shape-node method, not a layer method.** `layer.moveTo(n)`\n throws `parent is not an INDEXED_GROUP`, naming a concept the caller never\n mentioned. Layers move with `moveBefore` / `moveAfter` / `moveToBeginning` /\n `moveToEnd` \u2014 the first two 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. *The tool already does:* `reorder_layer`.\n- **Assigning a track matte switches the matte layer\'s video off.** That is\n After Effects\' own default, and `setTrackMatte` and `trackMatteType` both\n do it. A layer that is both a matte and visible art gets `enabled = true`\n again after every call that points a matte at it; read the switch back\n rather than assuming.\n- **`replaceSource` keeps the layer\'s old timing.** `startTime`, `inPoint`\n and `outPoint` stay where the previous source put them, so a longer or\n shorter file arrives trimmed to the old one. After the swap write\n `startTime`, then `inPoint`, then `outPoint`, and read them back; on a\n time-remapped layer turn `timeRemapEnabled` off before the swap and on\n again after it, so the default keys are rebuilt for the new duration.\n- **Removing a comp removes every layer that nests it**, in every comp,\n without a word. The `assembly` topic has the replacement flow.\n- The layer stack builds back-to-front \u2014 every `layers.add*()` lands at\n index 1, on top \u2014 which the `shapes` topic states beside the `Contents`\n order it is the opposite of.\n\n## Effects\n\n- **Effects are addressed by display name, and the name is not unique.**\n `effects.property("Slider Control")` returns the *first* effect with that\n name, so a second slider added under the default name is unreachable by\n name and a write meant for it silently drives the first. Name each control\n as you add it (`fx.name = "Zoom"`), or remove a same-named one first; from\n outside, `list_effects` reports every effect\'s name and index and\n `set_effect_param` takes the index.\n- **Adding an effect invalidates every effect reference held on that layer**,\n a second Slider Control included: the group re-indexes and an earlier\n handle throws `Object is invalid`, the way a shape node reference goes\n stale when a sibling is added. Add every effect first, then resolve each by\n name and set its parameters.\n\n## Shapes\n\nRender order inside `Contents`, references that go stale when a sibling is\nadded, and the coordinate space of a new shape layer bite the tool path as much\nas a script, so they are in the `shapes` topic. The raw-scripting traps:\n\n- **`comp.layers.addShape()` spawns at the comp centre** with the anchor at\n `(0,0)`, so contents authored in comp coordinates land offset by half a frame.\n Zero the position before adding anything to `Contents` \u2014 the `shape()` helper\n does.\n- **Polystar type is `1 = Star`, `2 = Polygon`** on `ADBE Vector Star Type`.\n Type 2 *hides* Inner Radius, so setting it after choosing polygon throws\n `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. A dashed band\n that does work: a small square plus a Repeater (`ADBE Vector Repeater\n Transform` \u2192 `ADBE Vector Repeater Position` set to `[2 * side, 0]`) in a\n group in front, over a plain rect in a group behind.\n\n## Text\n\n- **Do not write `justification` into a `TextDocument` from a script.** On\n 26.3, `d.justification = ParagraphJustification.CENTER_JUSTIFY;\n prop.setValue(d)` on a layer made with `addText()` lands as RIGHT (`7414`):\n the enum reads correctly in that scope (`CENTER_JUSTIFY` is `7415`, LEFT\n `7413`), the setter stores the wrong value, and LEFT only appears to work\n because it is `addText()`\'s default. Set font, size, colour and tracking\n through the `TextDocument` if you like, then set justification through the\n tool \u2014 `OPS.set_text({compId, layerId, justification: "center"})` inside the\n script, or `set_text` / `create_text_layer`\'s `anchorAlign` from outside \u2014\n and verify with `get_layer_full({\u2026, include: ["bounds"]})`: a centred layer\'s\n `sourceRect.left` is about `-width / 2`, a left-justified one\'s about `0`.\n The tool path gets a check a script does not: `set_text` and\n `create_text_layer` read the justification back after the write, re-assert it\n once if it moved, and throw naming expected and actual if it still disagrees,\n so the `justification` they return is what the layer shows.\n\n## Keyframes and easing\n\n- **`setTemporalEaseAtKey` wants one `KeyframeEase` per ease dimension, and\n that count belongs to the property, not to its value.** Measured on 26.3:\n Opacity and sliders take 1; a 2D layer\'s **Scale takes 3** while its value\n reads `[x, y]`; a shape\'s **Ellipse Size takes 2** while its value reads\n `[w, h]`; Position and Anchor Point take 1 whether the layer is 2D or 3D,\n because the ease runs along the motion path. Each of those is the opposite of\n the sensible guess \u2014 and one session on 26.3 had a 2D layer\'s Position\n accept only **3** through raw `setTemporalEaseAtKey`, a single observation\n not yet reproduced. The point is that the count is not derivable, from the\n value\'s length or from this list. The wrong one throws `Value array does not\n have N elements` about `parameter 2` and aborts the script there. So never\n size it by hand: `ease(prop, keyIndex, easeIn, easeOut)` is in scope and is\n the same code `set_temporal_ease` runs \u2014 it walks 1, 2, 3, 4 until one takes\n and returns the count that worked; the tools report the same number as\n `easeDimensions`. A bare number means influence; omitting `easeOut` uses the\n same ease both sides. And **put eases after every geometry and key write in\n the script**, so an ease that still fails loses nothing above it.\n- **The key lookup is `nearestKeyIndex(t)`**, not `nearestKeyAtTime`.\n- **Removing the last Time Remap key hides the property.** After\n `while (tr.numKeys) tr.removeKey(1)` the next `setValueAtTime` (or\n `addKeys`) throws `Can not "set value at time" with this property, because\n the property or a parent property is hidden`, the script dies there, and the\n layer is left remap-enabled with no keys. Setting `timeRemapEnabled = true`\n already creates the two keys a loop needs \u2014 `[inPoint, 0]` and\n `[inPoint + source duration, source duration]` \u2014 so there is no reason to\n clear them: edit those keys in place, or put an expression over them \u2014\n `(time - startTime) % thisLayer.source.duration` loops \u2014 which overrides\n them without touching them. If your keys must sit at other times, add them\n **before** removing the two defaults: only a property with no keys at all\n hides. And re-assert `outPoint` after `timeRemapEnabled = true`, because\n enabling it resets the layer\'s end \u2014 on a remapped layer set `outPoint`\n before `inPoint` and read both back, since an in point past the source\'s\n natural end moves the layer instead of trimming it until the out point has\n let it extend. If the keys are already gone, toggle `timeRemapEnabled` off\n and on to get the defaults back. *The tool already does:* `place_audio_cues`\n with `loop: true`.\n\n## Render queue and output modules\n\n- **`OutputModule.setSettings` rejects Format, Channels, Depth and Color as\n read-only** \u2014 `Invalid Value for key: <Depth>. Property is read-only`, and\n likewise `<Channels>`, `<Format>`, `<Color>` \u2014 although\n `getSettings(GetSettingsFormat.SPEC)` lists full enum tables for all four.\n SPEC reports a setting\'s domain, not whether it is writable;\n `GetSettingsFormat.STRING_SETTABLE` is the honest list, and it omits all four.\n Measured on 26.3 from four starting states (after `applyTemplate` of a ProRes\n template, after the stock alpha templates, after a TIFF sequence, and on a\n fresh output module setting Format alone), so it is not codec gating. "Video\n Codec" is not in SPEC at all; it lives behind the QuickTime Format Options\n dialog, which has no scripting surface.\n- **So codec, bit depth and alpha cannot be set from a script.** A person builds\n the output module once in the UI \u2014 Edit > Templates > Output Module > New, or\n Make Template from the Render Queue\'s Output Module menu \u2014 under a known name,\n and scripts (`om.applyTemplate(name)`) and `aerender -OMtemplate "<name>"`\n apply it by name. Templates live in AE\'s preferences, so one creation covers\n every project on the machine. Before trusting a template, apply it and read\n `getSettings(GetSettingsFormat.STRING)` back: an alpha-incapable codec such as\n ProRes 422 HQ reports Channels `RGB` and will not be talked out of it.\n- **A failed `setSettings` throws before any cleanup line runs**, so a temporary\n render-queue item added earlier in the script stays in the user\'s queue. Add\n temporary items inside `try { \u2026 } finally { item.remove(); }`, or sweep the\n queue at the top of the next call.\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 opens and closes its own group per call. *The tool already\n does:* `run_batch` reports the measured `undoSteps`.\n- **`copyToComp` needs the group closed**, which is the opposite problem \u2014 see\n "Layers and comps".\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 side\neffect you expected is simply absent, which reads exactly like a bridge failure.\n`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\n## Reading the result, and a failure\n\n`return X` sends the whole value back \u2014 arrays and nested objects included.\nValues that cannot be represented come back as a marker string in place, never\ndropped: a live AE object as `"[AVLayer \\"Hero\\" #616]"`, which is a handle to\npass to `get_layer_full`, not a copy of the layer. An empty result therefore\nmeans the script returned nothing.\n\n**A bare expression is not a return.** `"ping";` as the last line yields\nnothing, and so does any script that just does its work. That case comes back\nas `{ok: true, returned: null, undoGroup, note}` \u2014 an envelope that says *the\nscript ran to completion*. Do not re-run it; add an explicit `return` if you\nwant a value.\n\n**On a failure, read the error before you touch anything.** It names the line\nof *your* script and prints that line\'s text; a line inside a library is\nattributed to that file; and when the number cannot be mapped it says so rather\nthan guessing. Everything above that line already ran and nothing rolls back,\nso find out what landed \u2014 `diff: true` appends it to the error; otherwise\n`diff_comp`, `get_comp_tree` or `find_layers` \u2014 and never re-run the script to\nsee whether it fails again: the lines above the failure apply a second time.'
3398
+ body: '# Scripting After Effects with run_jsx\n\n`run_jsx` executes arbitrary ExtendScript inside After Effects. Reach for it\nwhen a needed operation has no tool \u2014 the render queue, a bulk edit no single op\nexpresses, a rig that is easier to describe as a loop than as forty calls. Check\nthe tool list first: duplicating a comp, easing a keyframe, reordering a layer,\nplacing a shape layer at a sane origin and scoring a scene all have tools, and\neach wraps a trap on this page. The whole `OPS` table is in scope, so a script\ncan call any tool as a function and keep to the DOM for the part no tool covers.\n\n## How much one call can do\n\n- **Timeouts.** A call gets 120 s by default; `run_jsx`, `run_batch`, the two\n screenshot ops, `export_mogrt`, `import_footage` and `place_audio_cues` get\n 300 s. `AE_MCP_OP_TIMEOUT_MS` in the server\'s environment raises every one of\n them. ExtendScript is single-threaded, so After Effects\' interface is frozen\n for as long as the script runs, and a script that outlives its limit looks to\n the server exactly like a dead bridge \u2014 the "did not answer in time" failure \u2014\n while it carries on running to the end.\n- **In practice, about 60 layers\' worth of creation and keying per call** is\n where a scene build stays well inside the limit (reported from a\n fourteen-scene, thousand-layer build). Split a big build by scene, not by op;\n the same op repeated hundreds of times is `run_batch`, which chunks itself.\n- **Source size** \u2014 the script plus every library it names \u2014 is capped at 1 MB\n per call, because libraries are inlined into the call, so every byte travels\n every time.\n\n## Build from files, not from the conversation\n\nInline `code` is for a probe or a one-liner. A build is written as files:\n\n- **`scriptPath`** runs an absolute `.jsx` path. The server reads it, so the\n script is paid for once, on disk, rather than once per call in the\n conversation and again on every later request.\n- **`libraries`** takes absolute `.jsx` paths and inlines them ahead of the\n script, in the same scope, so their functions are callable from it. A library\n is re-read and re-evaluated on every call, so keep it to declarations rather\n than to work; it is parse-checked on its own before the script runs, and a\n failure inside one is reported against that file, by name and line.\n\nThe rule: a script file is paid for once; a helper shared by two scripts belongs\nin a library, never pasted into both. `code` and `scriptPath` are exclusive.\n\nPass **`diff: true`** on any script that writes. It fingerprints the comp\naround the call and appends only what changed \u2014 layers added, renamed, retimed,\nkeyframe counts \u2014 so the script reports what it did instead of you reading the\ncomp back, and on a throw the diff rides on the error, which is how you find\nwhere a half-applied script stopped.\n\n## What you already have\n\nBefore you write a helper, check it is not in scope. Each of these wraps a trap\non this page, and a version you write yourself re-derives the bug.\n\n- **`OPS`** \u2014 the whole tool table:\n `OPS.set_transform({compId, layerId, position: [0, 0]})`. Anything a tool\n already does well, do 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 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 itself; see "Keyframes and easing" below for why that is not optional.\n- **`shape(comp, {name, position})`** \u2014 a shape layer whose origin is `[0,0]`.\n- **`withoutUndoGroup(fn)`** \u2014 closes the undo group around one statement, which\n is what `copyToComp` needs.\n\n## Adobe\'s documentation describes an ExtendScript After Effects 2026 does not implement\n\nEvery item here is measured on 26.3, and several contradict Adobe\'s reference:\n`$.evalFile` evaluates into the calling function\'s scope, not the global one;\n`Error.start` and `Error.end` are `0` on every error, however far in the throw\nwas; an undo group opened in one script call and closed in another is\ndiscarded; `CompItem.posterTime` does not exist; `exportAsMotionGraphicsTemplate`\ninvalidates `app.project` itself, not only the comp passed to it. None of them\nraise \u2014 each returns exactly what success returns. When this page and Adobe\'s\nreference disagree, this page was measured; and before you build on a\ndocumented behaviour nothing here mentions, write the three-line probe that\nproves it.\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, as\n`null is not an object`. When a lookup might miss, check for null on the line\nthat does it.\n\n- **2D rotation is `ADBE Rotate Z`.** `layer.property("ADBE Rotation")` is\n null; `layer.transform.rotation` is the safe form.\n- **Audio levels are `layer.audioLevels`.** `layer.property("ADBE Audio\n Levels")` is null on an audio layer \u2014 the level is never set, on every layer,\n silently. *The tool already does:* `place_audio_cues`.\n- **Time remap is `layer.timeRemap`.** `layer.property("ADBE Time Remap")` is\n null on audio layers and on precomp layers, even after\n `timeRemapEnabled = true`.\n- **`instanceof` is unreliable on host objects.** `layer instanceof AVLayer`\n is false for shape and text layers, though both have a full 2D transform\n (issue #120). Probe instead: a shape layer\n is one where `property("ADBE Root Vectors Group")` is non-null, and a\n project item\'s kind is `item.typeName` \u2014 `"Composition"`, `"Footage"`,\n `"Folder"` \u2014 a string compare that needs no class.\n\n## Reading and writing values\n\n- **`.value` is the value at the comp\'s current time** \u2014 wherever the user\n left the playhead, not time zero \u2014 so on a keyed or expression-driven\n property it is one sample from a moment you did not choose. Read at the\n time you mean with `valueAtTime(t, false)`, the post-expression value\n (`true` is pre-expression). To bake a driven property, sample every time\n you need first, then remove the expression, then the keys: each of those\n changes what the next read returns.\n- **`setValue` on a comp that is not open in a viewer can throw with the\n value already applied.** Open the comp first \u2014 `comp.openInViewer()`, or\n `set_active_comp` from outside \u2014 and on such a throw read the property back\n before retrying: the write may already be there.\n\n## Layers and comps\n\n- **A `comp.layer(n)` reference is index-bound, not a handle.** Once a\n `copyToComp` or a `duplicate()` has shifted the destination\'s indices, a\n reference taken earlier resolves to a *different* layer \u2014 which is how a script\n parents a layer to itself. Re-resolve by id or name after anything that\n inserts a layer.\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. Identify a copy by diffing the set of layer ids\n before and after the call, or pass `diff: true`.\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 exactly the\n rig worth copying. Wrap that one call in `withoutUndoGroup(function () { \u2026 })`,\n or pass `undoGroup: false` for the whole script and accept whatever undo steps\n AE records on its own.\n- **A copied layer carries parent-relative values.** Parented to a null that\n 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\n inverse offset, re-parenting while the new parent is at identity.\n- **Set the parent first, the transform second.** Raw `layer.parent = x` does\n not reliably preserve where a layer sits two levels deep, so after scripted\n parenting audit scale and rotation as well as position. *The tool already\n does:* `parent_layer` with `preserveTransform`.\n- **`app.executeCommand(id)` silently no-ops** through this bridge. Menu\n commands depend on host focus and the active selection, and the bridge has\n neither; the call returns without complaint and nothing happens. Use the API\n equivalents \u2014 `CompItem.duplicate()`, `layer.duplicate()`. *The tool already\n does:* `duplicate_comp`, whose `deep: true` also duplicates the nested comps,\n which `CompItem.duplicate()` alone does not.\n- **`moveTo` is a shape-node method, not a layer method.** `layer.moveTo(n)`\n throws `parent is not an INDEXED_GROUP`, naming a concept the caller never\n mentioned. Layers move with `moveBefore` / `moveAfter` / `moveToBeginning` /\n `moveToEnd` \u2014 the first two 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. *The tool already does:* `reorder_layer`.\n- **Assigning a track matte switches the matte layer\'s video off.** That is\n After Effects\' own default, and `setTrackMatte` and `trackMatteType` both\n do it. A layer that is both a matte and visible art gets `enabled = true`\n again after every call that points a matte at it; read the switch back\n rather than assuming.\n- **`replaceSource` keeps the layer\'s old timing.** `startTime`, `inPoint`\n and `outPoint` stay where the previous source put them, so a longer or\n shorter file arrives trimmed to the old one. After the swap write\n `startTime`, then `inPoint`, then `outPoint`, and read them back; on a\n time-remapped layer turn `timeRemapEnabled` off before the swap and on\n again after it, so the default keys are rebuilt for the new duration.\n- **Removing a comp removes every layer that nests it**, in every comp,\n without a word. The `assembly` topic has the replacement flow.\n- The layer stack builds back-to-front \u2014 every `layers.add*()` lands at\n index 1, on top \u2014 which the `shapes` topic states beside the `Contents`\n order it is the opposite of.\n\n## Effects\n\n- **Effects are addressed by display name, and the name is not unique.**\n `effects.property("Slider Control")` returns the *first* effect with that\n name, so a second slider added under the default name is unreachable by\n name and a write meant for it silently drives the first. Name each control\n as you add it (`fx.name = "Zoom"`), or remove a same-named one first; from\n outside, `list_effects` reports every effect\'s name and index and\n `set_effect_param` takes the index.\n- **Adding an effect invalidates every effect reference held on that layer**,\n a second Slider Control included: the group re-indexes and an earlier\n handle throws `Object is invalid`, the way a shape node reference goes\n stale when a sibling is added. Add every effect first, then resolve each by\n name and set its parameters.\n\n## Shapes\n\nRender order inside `Contents`, references that go stale when a sibling is\nadded, and the coordinate space of a new shape layer bite the tool path as much\nas a script, so they are in the `shapes` topic. The raw-scripting traps:\n\n- **`comp.layers.addShape()` spawns at the comp centre** with the anchor at\n `(0,0)`, so contents authored in comp coordinates land offset by half a frame.\n Zero the position before adding anything to `Contents` \u2014 the `shape()` helper\n does.\n- **Polystar type is `1 = Star`, `2 = Polygon`** on `ADBE Vector Star Type`.\n Type 2 *hides* Inner Radius, so setting it after choosing polygon throws\n `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. A dashed band\n that does work: a small square plus a Repeater (`ADBE Vector Repeater\n Transform` \u2192 `ADBE Vector Repeater Position` set to `[2 * side, 0]`) in a\n group in front, over a plain rect in a group behind.\n\n## Text\n\n- **Do not write `justification` into a `TextDocument` from a script.** On\n 26.3, `d.justification = ParagraphJustification.CENTER_JUSTIFY;\n prop.setValue(d)` on a layer made with `addText()` lands as RIGHT (`7414`):\n the enum reads correctly in that scope (`CENTER_JUSTIFY` is `7415`, LEFT\n `7413`), the setter stores the wrong value, and LEFT only appears to work\n because it is `addText()`\'s default. Set font, size, colour and tracking\n through the `TextDocument` if you like, then set justification through the\n tool \u2014 `OPS.set_text({compId, layerId, justification: "center"})` inside the\n script, or `set_text` / `create_text_layer`\'s `anchorAlign` from outside \u2014\n and verify with `get_layer_full({\u2026, include: ["bounds"]})`: a centred layer\'s\n `sourceRect.left` is about `-width / 2`, a left-justified one\'s about `0`.\n The tool path gets a check a script does not: `set_text` and\n `create_text_layer` read the justification back after the write, re-assert it\n once if it moved, and throw naming expected and actual if it still disagrees,\n so the `justification` they return is what the layer shows.\n\n## Keyframes and easing\n\n- **`setTemporalEaseAtKey` wants one `KeyframeEase` per ease dimension, and\n that count belongs to the property, not to its value.** Measured on 26.3:\n Opacity and sliders take 1; a 2D layer\'s **Scale takes 3** while its value\n reads `[x, y]`; a shape\'s **Ellipse Size takes 2** while its value reads\n `[w, h]`; Position and Anchor Point take 1 whether the layer is 2D or 3D,\n because the ease runs along the motion path. Each of those is the opposite of\n the sensible guess \u2014 and one session on 26.3 had a 2D layer\'s Position\n accept only **3** through raw `setTemporalEaseAtKey`, a single observation\n not yet reproduced. The point is that the count is not derivable, from the\n value\'s length or from this list. The wrong one throws `Value array does not\n have N elements` about `parameter 2` and aborts the script there. So never\n size it by hand: `ease(prop, keyIndex, easeIn, easeOut)` is in scope and is\n the same code `set_temporal_ease` runs \u2014 it walks 1, 2, 3, 4 until one takes\n and returns the count that worked; the tools report the same number as\n `easeDimensions`. A bare number means influence; omitting `easeOut` uses the\n same ease both sides. And **put eases after every geometry and key write in\n the script**, so an ease that still fails loses nothing above it.\n- **The key lookup is `nearestKeyIndex(t)`**, not `nearestKeyAtTime`.\n- **Removing the last Time Remap key hides the property.** After\n `while (tr.numKeys) tr.removeKey(1)` the next `setValueAtTime` (or\n `addKeys`) throws `Can not "set value at time" with this property, because\n the property or a parent property is hidden`, the script dies there, and the\n layer is left remap-enabled with no keys. Setting `timeRemapEnabled = true`\n already creates the two keys a loop needs \u2014 `[inPoint, 0]` and\n `[inPoint + source duration, source duration]` \u2014 so there is no reason to\n clear them: edit those keys in place, or put an expression over them \u2014\n `(time - startTime) % thisLayer.source.duration` loops \u2014 which overrides\n them without touching them. If your keys must sit at other times, add them\n **before** removing the two defaults: only a property with no keys at all\n hides. And re-assert `outPoint` after `timeRemapEnabled = true`, because\n enabling it resets the layer\'s end \u2014 on a remapped layer set `outPoint`\n before `inPoint` and read both back, since an in point past the source\'s\n natural end moves the layer instead of trimming it until the out point has\n let it extend. If the keys are already gone, toggle `timeRemapEnabled` off\n and on to get the defaults back. *The tool already does:* `place_audio_cues`\n with `loop: true`.\n\n## Render queue and output modules\n\n- **`OutputModule.setSettings` rejects Format, Channels, Depth and Color as\n read-only** \u2014 `Invalid Value for key: <Depth>. Property is read-only`, and\n likewise `<Channels>`, `<Format>`, `<Color>` \u2014 although\n `getSettings(GetSettingsFormat.SPEC)` lists full enum tables for all four.\n SPEC reports a setting\'s domain, not whether it is writable;\n `GetSettingsFormat.STRING_SETTABLE` is the honest list, and it omits all four.\n Measured on 26.3 from four starting states (after `applyTemplate` of a ProRes\n template, after the stock alpha templates, after a TIFF sequence, and on a\n fresh output module setting Format alone), so it is not codec gating. "Video\n Codec" is not in SPEC at all; it lives behind the QuickTime Format Options\n dialog, which has no scripting surface.\n- **So codec, bit depth and alpha cannot be set from a script.** A person builds\n the output module once in the UI \u2014 Edit > Templates > Output Module > New, or\n Make Template from the Render Queue\'s Output Module menu \u2014 under a known name,\n and scripts (`om.applyTemplate(name)`) and `aerender -OMtemplate "<name>"`\n apply it by name. Templates live in AE\'s preferences, so one creation covers\n every project on the machine. Before trusting a template, apply it and read\n `getSettings(GetSettingsFormat.STRING)` back: an alpha-incapable codec such as\n ProRes 422 HQ reports Channels `RGB` and will not be talked out of it.\n- **A failed `setSettings` throws before any cleanup line runs**, so a temporary\n render-queue item added earlier in the script stays in the user\'s queue. Add\n temporary items inside `try { \u2026 } finally { item.remove(); }`, or sweep the\n queue at the top of the next call.\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 opens and closes its own group per call. *The tool already\n does:* `run_batch` reports the measured `undoSteps`.\n- **`copyToComp` needs the group closed**, which is the opposite problem \u2014 see\n "Layers and comps".\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 side\neffect you expected is simply absent, which reads exactly like a bridge failure.\n`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\n## Reading the result, and a failure\n\n`return X` sends the whole value back \u2014 arrays and nested objects included.\nValues that cannot be represented come back as a marker string in place, never\ndropped: a live AE object as `"[AVLayer \\"Hero\\" #616]"`, which is a handle to\npass to `get_layer_full`, not a copy of the layer. An empty result therefore\nmeans the script returned nothing.\n\n**A bare expression is not a return.** `"ping";` as the last line yields\nnothing, and so does any script that just does its work. That case comes back\nas `{ok: true, returned: null, undoGroup, note}` \u2014 an envelope that says *the\nscript ran to completion*. Do not re-run it; add an explicit `return` if you\nwant a value.\n\n**On a failure, read the error before you touch anything.** It names the line\nof *your* script and prints that line\'s text; a line inside a library is\nattributed to that file; and when the number cannot be mapped it says so rather\nthan guessing. Everything above that line already ran and nothing rolls back,\nso find out what landed \u2014 `diff: true` appends it to the error; otherwise\n`diff_comp`, `get_comp_tree` or `find_layers` \u2014 and never re-run the script to\nsee whether it fails again: the lines above the failure apply a second time.'
3388
3399
  },
3389
3400
  {
3390
3401
  name: "issue-journal",
@@ -3419,7 +3430,7 @@ var GUIDES = [
3419
3430
  {
3420
3431
  name: "whats-new",
3421
3432
  description: "What changed in the After Effects tools, one section per release, newest first \u2014 each entry leads with the rule as it now stands and quotes the old rule it replaces, so a project's own notes can be found and corrected. Read it when a call behaves differently from what you expected, when the user says a tool used to do something else, or with `since` to see only the releases after the one this project last absorbed.",
3422
- body: "<!--\nFormat contract. `ae_guide({topic: \"whats-new\", since: \"<version>\"})` filters\nthis file by machine and the absorb-release prompt greps project docs with it:\n\n- One `## <semver>` section per release, newest first. No other `##` heading\n below the first release heading; group inside a release with `###`.\n- Everything above the first `## <semver>` is the preamble. It is returned with\n every filtered read, so it stays short.\n- An entry is one bullet. It opens with a **bold phrase stating the rule as it\n now stands**, present tense, then the reason and what changed.\n- A bullet whose rule replaces something a project's own docs may say carries\n one or more indented `supersedes:` lines directly under it, each quoting the\n OLD rule in the words a project doc would have used. They are grep targets:\n short, literal, no markdown.\n-->\n\n# 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: one section per release, newest first.\nEvery entry leads with the rule as it now stands; a `supersedes:` line under it\nquotes the old rule in the words a project's own notes would have used, so those\nnotes can be searched for and corrected. Pass `since` \u2014 as in\n`ae_guide({topic: \"whats-new\", since: \"0.4.0\"})` \u2014 to get only the releases\nafter that version; the absorb-release prompt is the flow that applies them to\na project once and records the version so the history is not read again.\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**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.\n\n## 0.5.1\n\nOne security fix, reported from outside the project. It changes nothing about\nhow you drive After Effects \u2014 but it does add a fifth way a call can fail, and\nthat one has a remedy of its own.\n\n- **The bridge authenticates every call, and a refused one is `bridgeToken` in\n `check_setup` rather than a broken panel.** The panel's HTTP server is on\n `127.0.0.1`, which a web page in the user's browser can reach, and `/op`\n reaches `run_jsx` \u2014 an unrestricted `eval` inside After Effects. It now\n requires a per-session token that the panel writes beside its port file, in\n `~/.engineroom-ae-mcp/token-<port>`, and that only a local process can read\n (issue #106). Nothing changes for you when it is working. When it is not, the\n failure reads \"the bridge token did not match\", and its remedy contradicts\n every other bridge failure: the panel is running, it answered immediately, the\n call never reached After Effects, and re-sending, restarting After Effects and\n `setup_panel` are all the wrong move. Run `check_setup` and relay `nextSteps`.\n supersedes: the bridge has no authentication\n supersedes: any local process can post to the bridge\n- **An MCP server older than the running panel is refused, and says so.** The\n panel loads at After Effects launch and only at launch, so after this upgrade\n a session can have a new panel answering an old server. That combination gets\n a refusal naming the fix \u2014 update the tools and reconnect \u2014 rather than a\n confusing failure. The reverse, a new server against an old panel, works\n unchanged: the header is simply ignored.\n\n## 0.5.0\n\nThe release where the guidance split into a core and references, the journal\nstarted coming to you instead of you going to it, and three more things After\nEffects does silently \u2014 a batch's progress no client ever saw, a broken\nexpression reported as ok, a justification that did not land \u2014 became\nvisible. Two results changed shape; those are first, because a caller reading\nthe old shape gets nothing rather than an error.\n\n### Two results changed shape\n\n- **`find_layers` returns `{matches, count, compsSearched, included}`, and a\n match is id, index, name, sourceType, compId and compName unless `include`\n asks for more.** It used to answer a bare array of full layer summaries \u2014\n flags, timing, parent, on every match \u2014 with no way to bound it, so nine name\n matches cost about two thousand tokens, re-sent on every later request.\n `include` takes the `list_layers` section names (`flags`, `timing`,\n `parent`); unlike `list_layers`, omitting it means the bounded record, not\n everything, and the result echoes `included`. A caller that read the old\n array reads `matches` now.\n supersedes: find_layers cannot be bounded\n supersedes: find_layers returns an array of layer summaries\n- **`place_audio_cues` results carry one small entry per cue \u2014 `layerId`,\n `name`, `time`, plus what an option changed \u2014 and `dryRun` answers counts,\n `wouldImport`, `wouldReuse` and the failing cues only.** The per-cue\n `levelDb`, `label`, `index` and `itemId` were echoes of the request, and a\n seven-cue dry run that found nothing wrong cost about 1,500 tokens of them.\n Read a level off the layer, not out of the result.\n supersedes: place_audio_cues echoes the levelDb written for every cue\n supersedes: dryRun on place_audio_cues returns the resolved cue list\n\n### Three silences, now reported\n\n- **`run_batch` over 500 ops returns its `jobId` before the first chunk runs,\n and progress is delivered on `await_job` \u2014 send that call with a progress\n token and `notifications/progress` arrive while it waits.** Nothing can ride\n on the `run_batch` call itself: its response is already back, and a\n notification sent on a request's token after its response is one every\n spec-compliant client has already stopped listening for \u2014 which is where\n every progress message for a long batch went before 0.5.0, seen by no real\n client. `get_job` polls the same state without a token.\n supersedes: run_batch progress arrives on the run_batch call\n supersedes: pass a progressToken on run_batch to see its progress\n- **`set_expression` throws when After Effects cannot evaluate the expression,\n and `get_expression` returns `expressionError`.** Assigning an expression\n succeeds whatever the text says, and AE's only report is a warning banner in\n its interface; the tool now evaluates the property after the write and fails\n with AE's message and the property path. The expression stays written \u2014 fix\n it and call again, or `clear_expression`. `toggle_expression({enabled:\n true})` verifies the same way. A non-empty `expressionError` means the\n property is not being driven by that expression, whatever `enabled` says.\n supersedes: set_expression reports success for a broken expression\n supersedes: ask the user to check the property for an expression warning banner\n- **`set_text` and `create_text_layer` read the justification back after every\n write, re-assert it once if it moved, and throw naming expected and actual\n if it still disagrees.** On 26.3 a `set_text` that changed only `text` has\n re-centred a left-aligned layer. Both results carry `justification` as a\n name and `justificationReasserted`; a failing `create_text_layer` removes\n the layer it made, so nothing is left without an id. An unknown\n justification name is refused rather than ignored.\n supersedes: set_text leaves justification alone when you do not pass it\n supersedes: verify alignment by sourceRect after every set_text\n\n### Deleting a comp, and what it leaves behind\n\n- **`delete_comp` reports `unusedSolidsLeft`, and `purgeUnusedSolids: true`\n removes the comp's own now-unused solids in the same undo step.** A solid's\n source is a footage item in the Solids folder, and deleting the comp only\n ever removed the layer, so a session that built and discarded comps left\n hundreds behind. Only the deleted comp's own solids are considered; one\n another comp still uses is kept and reported in `keptSolids` with that comp.\n supersedes: delete_comp removes a comp and its solids\n supersedes: sweep the Solids folder by hand after deleting comps\n- **`purge_unused_footage` sweeps the project: solids only by default,\n `solidsOnly: false` for every unused footage item, `folderId` to scope it,\n `dryRun: true` to list `wouldRemove` without an undo step.** Never a comp or\n a folder \u2014 a nested comp nothing uses is not footage.\n supersedes: remove unused footage with run_jsx\n\n### Sound\n\n- **`place_audio_cues` takes `loop`, `fadeIn`/`fadeOut` and `stretch` per cue,\n and `fadeFloorDb` per call.** `loop: true` time-remaps with a wrap-around\n expression, keeps AE's two default keys and re-asserts the out point; fades\n keyframe Audio Levels at the layer's real in and out between the floor\n (default `-48`) and the cue's level, and a fade that does not fit is refused\n by cue index; `stretch` is a percentage with the start time and trims\n re-asserted after it. A stretched loop bakes the factor into its expression,\n so a later hand change to stretch leaves the loop at the old rate. All of it\n is validated before anything is created and rolled back with the rest.\n supersedes: loop a bed with run_jsx and timeRemap after place_audio_cues\n supersedes: fade audio in run_jsx after placing the cues\n\n### The journal comes to you\n\n- **A failed call ends with `Known from earlier sessions: <scope:id> \u2014 <title>`\n for up to three entries matching its tool and error text, and the\n `list_known_issues({id})` call that opens the first.** Nobody reads the\n journal ahead of time any more; the pointer arrives with the failure, and a\n match moves the entry's `lastSeen`.\n supersedes: read list_known_issues before nontrivial work\n supersedes: check list_known_issues before guessing\n- **`log_issue` takes `errorText` and `kind`, and the same tool with the same\n error text extends the existing entry even under a new title** \u2014 the result\n says `mergedBy: \"errorText\"` and keeps the existing title. `kind:\n \"ae-quirk\"` marks a permanent After Effects behaviour; the default\n `tool-bug` is presumed fixed once last seen on an older server. Entries\n record `firstVersion` and `lastVersion`.\n supersedes: call list_known_issues first so you reuse the title when logging\n- **Entries archive themselves \u2014 unseen for 30 days, or a `tool-bug` last seen\n on an older server \u2014 and `archive_issue({id, reason})` retires one by\n hand.** Archived entries are hidden from the index and counted in\n `archivedCount`; `includeArchived: true` lists them, a read by `id` always\n works, and a fresh `log_issue` reopens one with `reopened: true`. Index\n lines carry `kind`, `lastSeen` and `lastVersion` and no summary sentence,\n sorted by tool match and then most recently seen.\n supersedes: the journal only grows, entries are never removed\n- **The report flow checks the tracker before drafting.** An entry a closed\n report already covers is archived with that URL; one an open report covers\n is marked reported. Bodies go through a file, and \"all of them\" is one\n approval.\n\n### Reading the history once\n\n- **`ae_guide({topic: \"whats-new\", since: \"<version>\"})` returns only the\n releases after that version, opening with the server's own version**, and\n this file has a shape a machine can filter: one section per release, every\n entry leading with the rule as it now stands, `supersedes:` lines under it\n quoting the old rule. The absorb-release prompt (`/absorb-release` where\n prompts are commands) applies the delta to a project's notes once and\n records the version in the `Tools version last absorbed:` line\n `init_project` writes into AGENTS.md.\n supersedes: read the whole whats-new guide after every update\n\n### The guidance\n\n- **The `after-effects` topic is a core of what silently produces wrong output\n on any task, and each subject is its own topic** \u2014 `animation`, `shapes`,\n `text`, `assembly`, `extendscript-gotchas`, `sound`, `mogrt-and-footage`,\n `issue-journal`, `whats-new` \u2014 twelve `ae_guide` topics in all with\n `style-guide` and `ae-setup`. In Claude Code and claude.ai the references\n are the files in the `after-effects` skill's `references/` folder, opened\n only when the core points at them. Keyframes, easing and rigging are in\n `animation`; raw scripting, with the ease-arity table, in\n `extendscript-gotchas` and nowhere else.\n supersedes: the after-effects skill covers keyframes text and shapes in one file\n supersedes: the ease arity table is in the main guide\n- **Four more After Effects facts, measured on 26.3, in `extendscript-gotchas`:**\n removing the last Time Remap key hides the property, so edit AE's two\n default keys in place or put an expression over them, and re-assert\n `outPoint` after enabling remapping; `TextDocument.justification` written\n from a script lands as RIGHT when handed CENTER, so set it through\n `set_text`; `OutputModule.setSettings` rejects Format, Channels, Depth and\n Color as read-only, so build an output template in the UI and apply it by\n name; and the ease arity is not derivable from a value's dimension, so\n never size a `KeyframeEase` array by hand.\n supersedes: clear the Time Remap keys before adding your own\n supersedes: set justification on the TextDocument from run_jsx\n supersedes: set the output module codec with setSettings from run_jsx\n- **The references carry the craft rules a project would otherwise keep in\n its own notes** (issue #103). `extendscript-gotchas`: a `.value` read at\n the playhead, a `setValue` that throws with the value applied on a comp not\n in a viewer, effects addressed by a name that is not unique and references\n that go stale when one is added, a track matte that switches its matte's\n video off, `replaceSource` keeping the old timing, `item.typeName`, Time\n Remap keys added before the defaults are removed. `animation`: a held pose\n as a hold key, a waypoint that must not stop, parenting at rest and before\n the pop with the parent-space arithmetic, a mirrored parent mirroring its\n text, expression text built from numbers, a shake as an expression over\n keys. `assembly`: a precomp clamped at its source, reversing with time\n remap, controls shared across every instance, a resized comp shifting what\n nests it, collapse transformations and motion blur across a nesting,\n replacing a nested comp, and verifying an assembly, a small detail, a\n doubtful tile and a cut. `sound`: peak for a one-shot and RMS for a bed\n with the onset on the beat, a trim clamped at the file's end, a bed that\n follows a move, a float WAV that will not place. `shapes`: one fill per\n group, the layer stack built back-to-front, a group's pivot, seams between\n shapes, centring a repeated run.\n\n### Connecting, and staying connected\n\n- **The server follows the panel's port.** A call refused on the port the\n server remembered makes it look again \u2014 7777 first, then the port file \u2014\n switch to whichever answers as the panel, and re-send the call once. Only a\n refused connection triggers this; a timed-out call reached After Effects and\n is never re-sent (issue #92). `AE_MCP_PORT` is a hard pin: with it set, no\n other port is ever tried.\n supersedes: reconnect the MCP server when ops fail on a port check_setup does not report\n- **`check_setup` reports the port that actually answers**, flags a stale\n port file, and carries a new `portAgreement` check. When calls go to one\n port and the panel answers on another, its advice is to retry or reconnect\n the MCP server. It never tells you to restart After Effects for that.\n supersedes: cannot reach the panel means nothing is listening so restart After Effects\n- **The panel waits instead of moving when an older copy of itself holds\n 7777.** It says so in its own window, names the holder, and takes the port\n over the moment that copy exits, with no restart. Two After Effects\n instances on one machine opt back into walking with `\"allowPortWalk\": true`\n in `~/.engineroom-ae-mcp/config.json`; the same file's `\"port\"` moves the\n panel. The port file only ever names a port that was really bound and is\n removed when the panel unloads.\n supersedes: the panel walks up to the next free port when 7777 is taken\n- **A panel that never loads on Windows gets a diagnosis, not a restart\n loop.** `check_setup` reads CEP's own log and, when CEP refused the panel's\n signature \u2014 an Adobe CEP 12 bug that ignores PlayerDebugMode \u2014 reports\n `panelSignature` with the steps to self-sign the installed panel with\n Adobe's ZXPSignCmd (issue #91). With no log yet, it gives the one command\n that turns CEP logging on. `setup_panel` now turns that logging on itself\n where it was never set, warns when it is about to replace a self-signed\n panel (`signedInstallOverwritten`, since the fresh copy needs signing\n again), and reports a signed panel as up to date rather than partial.\n supersedes: check_setup all green but the panel never loads means restart After Effects again\n- **`check_setup` under an `AE_MCP_PORT` pin still asks 7777 and the port\n file, and a pinned wrong port is reported as the pin, not as a dead panel.**\n The pin decides where ops go and is never walked past; the diagnosis says so,\n names the port the panel is really answering on, and tells you to change or\n unset `AE_MCP_PORT` and reconnect the MCP server \u2014 never to restart After\n Effects. A refused op under a pin says the same in its own message.\n\n### Found by the live pass\n\n- **`init_project` refuses the home directory and the filesystem root however\n they were arrived at, an explicit `dir` included.** Before this only the\n working-directory fallback was guarded, so `dir: \"~\"` resolved by a client,\n or `dir: \"/\"`, scaffolded `AGENTS.md` and a `renders/` folder into a home\n directory and reported success.\n- **`set_house_style` writes `house-style.md` with Unix line endings.**\n ExtendScript's default on macOS turned every line break into a bare carriage\n return: the tools read the file back correctly, so the round trip looked\n fine, but a designer opening the file in an editor saw one long line.\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### Four things that were never true, and are now fixed\n\n- **`run_batch` is one undo step up to 500 ops; over 500 it is one step per\n chunk of 25, and `singleUndo: true` makes it one step up to 2000.** The\n one-step guarantee it shipped with before 0.4.0 did not exist on either path:\n After Effects discards an undo group opened in one script call and closed in\n another, so a 600-op batch landed as about six hundred separate steps while\n reporting one. Now up to 500 ops is genuinely one step; over 500 is one step\n per chunk of 25 \u2014 around 24 for 600 ops \u2014 with the measured count in\n `undoSteps` and a `note` saying it in words. `singleUndo: true` runs the batch\n in a single blocking call, which freezes AE's interface for the duration and\n reports no progress. Read `undoSteps` before you tell anyone how to undo the\n work.\n supersedes: run_batch is one undo step\n supersedes: a long run_batch is one undo step\n- **`transactional: true` stops at the first failure, rolls nothing back, and\n says so: `rolledBack: false` with the stop point named.** It never rolled\n anything back \u2014 it fired one menu-command Undo, and menu commands silently do\n nothing over this bridge; one Undo is one step, not a batch, in any case. The\n ops before the failure stay applied. Use `diff: true` to see what landed.\n supersedes: transactional: true rolls the batch back on failure\n- **`reorder_layer` works, and takes exactly one of `beforeLayerId`,\n `afterLayerId` or `toIndex`.** Every call it served before 0.4.0 threw\n `parent is not an INDEXED_GROUP`, because it used a shape-node method on a\n layer. Prefer the id forms, because this is the op that invalidates indexes;\n `toIndex` means the index the layer **ends up at**.\n supersedes: reorder_layer is broken, use run_jsx\n supersedes: reorder_layer throws parent is not an INDEXED_GROUP\n- **`downsample: 1` is full resolution, whatever the viewer's Resolution\n dropdown is set to.** Before 0.4.0 factor 1 skipped setting the comp's\n resolution at all, so it inherited the viewer's setting \u2014 on a comp a designer\n had left at Quarter, `downsample: 1` returned a quarter-size frame and\n `downsample: 2` returned one four times larger. The response was honest about\n the dimensions; the picture was not the one asked for. Factor 1 is now set\n explicitly like any other and restored afterwards.\n supersedes: downsample: 1 means the viewer's resolution\n supersedes: set the viewer to Full before a downsample: 1 screenshot\n\n### Two calls changed under you\n\n- **`create_shape_layer` spawns at `[0,0]`**, with the anchor at `[0,0]`, so the\n layer's coordinate space *is* the comp's and every vertex, rect position and\n path you add afterwards is in comp pixels. After Effects' own spawn point is\n the comp centre, which silently offsets a drawing authored in comp coordinates\n by half a frame \u2014 easy to miss on a downsampled screenshot. `position:\n \"center\"` restores AE's behaviour, and any `[x,y]` places the origin yourself.\n The result echoes what it ended up with.\n supersedes: a new shape layer spawns at the comp centre\n supersedes: shape paths are offset by half a frame, subtract the comp centre\n- **`get_house_style` returns a digest, not the document; `detail: \"full\"`\n returns the document.** The digest is a few hundred tokens: palette as named\n hexes, type, motion, layout, and a note naming anything it could not summarise.\n You need the full document before `set_house_style`, which replaces the file\n rather than patching it. An unstructured guide comes back `structured: false`\n with its opening text, which is an honest \"I could not read this as a spec\"\n rather than an empty answer.\n supersedes: do not call get_house_style, read the digest in these notes instead\n supersedes: get_house_style returns the whole style guide\n\n### Verify with a diff instead of a second read\n\n- **`snapshot_comp` and `diff_comp` fingerprint a comp and report what moved.**\n Fingerprint before a write, then ask: layers added, removed, renamed,\n retimed, re-parented, keyframe counts, expressions and effects gained or lost\n \u2014 and a count of the layers that did not move. Tens of tokens where two full\n reads were thousands. It does **not** record property values, expression\n text, effect parameters, masks or shape contents, so `changeCount: 0` means\n none of the recorded fields moved, not that the comp is identical.\n supersedes: verify a write by reading the comp back with list_layers and get_layer_full\n- **`diff: true` on `run_jsx` and `run_batch` does the same inside the call**,\n so there is no window between the write and the fingerprint \u2014 and on a\n failure the diff rides on the error, which is how you find where a\n half-applied script stopped.\n\n### Screenshots\n\n- **`screenshot_frame` takes `times` (2\u20136) and returns one tiled contact\n sheet**, each tile labelled with its own time, held to roughly the pixel\n budget of a single frame. Judging motion is one call now, not three: one\n image in your context instead of three, and one render request instead of\n three back-to-back ones, which is the pattern that provoked stale frames.\n `time` and `times` are mutually exclusive; there is no `times` on\n `screenshot_layer`.\n supersedes: screenshot each time separately to judge motion\n- **Three screenshot failures, each with its own remedy.** `Stale frame` (AE\n served an earlier render \u2014 wait, retry higher), `Corrupt frame` (the file is\n not a 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 supersedes: truncated PNG from screenshot_frame means retry it\n\n### run_jsx\n\n- **A `run_jsx` failure names the line of *your* script and prints its text**,\n and says so honestly when the number cannot be mapped rather than guessing.\n This was claimed once before it was true: until the 0.4.0 live pass every\n error said line 1 and printed line 1's text, with the real number only in the\n parenthetical after it, because AE reports `Error.start`/`Error.end` as `0`\n on every error rather than as the character offsets its documentation\n describes.\n supersedes: run_jsx line numbers are wrong, count from the wrapper\n supersedes: run_jsx reports line 1 for every error\n- **`diff: true` on `run_jsx` reaches After Effects.** The server was building\n a fresh argument object and dropping every field it did not enumerate, so the\n flag was discarded before the call left. The same flag on `run_batch` always\n worked, which is what hid it.\n supersedes: diff: true on run_jsx does nothing\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\n the same scope, so their functions are callable from it; they are\n re-evaluated on every call, so keep them to declarations rather than to work.\n- **Helpers are in scope**, each wrapping a trap: `compById`, `layerById`,\n `walkProperty`, `addKeys`, `ease` (which sizes the KeyframeEase array using\n the 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\n by name. Shallow by default like AE's own Duplicate \u2014 the copy's precomp\n layers point at the *same* nested comps \u2014 with `deep: true` for a real\n variant.\n supersedes: duplicate a comp with run_jsx and find the copy by name\n- **`place_audio_cues` scores a scene in one call**: a cue list becomes one\n audio layer each, imported once per file, named, trimmed, levelled in dB, in a\n single undo step, all-or-nothing, with a `dryRun`.\n supersedes: place audio with run_jsx, one layer at a time\n- **`set_temporal_ease` and `add_keyframe` size the ease array themselves.**\n Pass one `{influence, speed}` pair per side; the count that worked comes back\n as `easeDimensions`. The bare `parameter 2` failure is no longer yours to\n avoid.\n supersedes: set_temporal_ease fails with parameter 2, pass the right number of KeyframeEase objects by hand\n supersedes: set_temporal_ease fails on Scale, use run_jsx\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\n unknown, because AE answers with a bare boolean and has no way to say why.\n supersedes: export_mogrt fails with a modal dialog when the comp has no Essential Graphics controllers\n\n### Underneath\n\n- **Writes are serialized, one at a time, for the whole session**, so two\n writes issued together run in the order you issued them and a long\n `run_batch` holds the lock until its last chunk lands \u2014 nothing else drops\n into the middle of work the user asked for as one thing. A call that waited\n says `queuedBehind` and `waitedMs`. Reads are never queued. There is a new\n diagnosis to tell apart from a bridge timeout: a call *dropped while waiting\n for the write queue* never reached After Effects, so re-sending it is safe \u2014\n which is the opposite of what a timeout means.\n supersedes: never issue two writes in the same turn, the order is not guaranteed\n- **The issue journal has two scopes.** `project` is this project's notes;\n `user` travels with the person across every project, so log tool and After\n Effects behaviour there. Ids are unique only within a journal, so open an\n entry with the qualified form the listing shows (`user:\u2026`).\n- **The guidance lives in `ae_guide` and the skill, not in what the server sends\n every session.** What the server sends is a short pointer rather than a\n summary, because it was resident in every request the user ever made. The\n narrative is `ae_guide({topic: \"after-effects\"})`, with `extendscript-gotchas`\n behind it for raw `run_jsx` and this topic for changes. In Claude Code and\n claude.ai the same text is the `after-effects` skill and the files in its\n `references/` folder \u2014 load one carrier, not both.\n- **Rigging is written down**, in the `animation` topic: 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- **`get_layer_full` omits `ADBE Vector Materials Group` unless\n `shapeMaterials: true`.** That is the 48-property 3D extrusion model AE hangs\n off every vector group, which means nothing on a 2D shape layer. It was around\n three quarters of the bytes of a shape read: one 68px circle cost 4,400\n tokens, of which the geometry was about 40. `materialsOmitted` counts what was\n dropped; `shapeMaterials: true` brings it back for a genuinely extruded shape.\n A group Transform still at its creation values collapses to `atDefaults:\n true` the same way.\n supersedes: shape reads are huge, avoid get_layer_full on shape layers\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 0.3.1, 3,052 once the materials block went,\n 643 compact. Reach for `\"full\"` only when you need exact values or keyframe\n 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 `{ok: true, returned: null, undoGroup,\n note}`, which means *it ran to completion*. Do not re-run it: nothing rolls\n back, so a second run of a script that duplicated a layer or wrote keyframes\n does all of it twice. A returned value still comes back bare, falsy ones\n included.\n supersedes: a null from run_jsx means the script did not run, run it again\n\n## 0.3.0\n\n- **`list_comps`, `list_layers` and `get_layer_full` take `include` to name the\n sections you want**, plus `maxKeyframes` and `shapeDepth` on the deep read.\n Omit them all and you get everything, exactly as before. Anything left out is\n named and counted in the response (`included`, `keyframesOmitted`,\n `childrenOmitted`), so a short answer never passes for a complete one. This\n matters more than it sounds: a tool result is re-sent to you on every later\n request for the rest of the session, so one unbounded read is paid for many\n times.\n supersedes: get_layer_full returns everything, there is no way to bound it\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 supersedes: always pass downsample on screenshot_frame or it returns full resolution\n- **A stale frame is an error, not a picture.** AE sometimes re-serves a frame\n 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\n are refused with `Stale frame` naming the request whose pixels came back.\n Wait a 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- **`import_footage` and `create_footage_layer` bring footage in**, and 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 supersedes: import footage with run_jsx and importFile\n- **`export_mogrt` exports a Motion Graphics template and suppresses the modal\n dialogs** that otherwise freeze this whole connection until someone clicks\n them in After Effects. Never drive `exportAsMotionGraphicsTemplate` from\n `run_jsx`.\n supersedes: export a mogrt with run_jsx and exportAsMotionGraphicsTemplate\n- **Text alignment is justification**, not a measured anchor offset, so it\n stays correct when the text changes later. Tracking is set explicitly rather\n than inherited from the user's Character panel.\n supersedes: anchorAlign moves the anchor point, re-align text after every edit"
3433
+ body: "<!--\nFormat contract. `ae_guide({topic: \"whats-new\", since: \"<version>\"})` filters\nthis file by machine and the absorb-release prompt greps project docs with it:\n\n- One `## <semver>` section per release, newest first. No other `##` heading\n below the first release heading; group inside a release with `###`.\n- Everything above the first `## <semver>` is the preamble. It is returned with\n every filtered read, so it stays short.\n- An entry is one bullet. It opens with a **bold phrase stating the rule as it\n now stands**, present tense, then the reason and what changed.\n- A bullet whose rule replaces something a project's own docs may say carries\n one or more indented `supersedes:` lines directly under it, each quoting the\n OLD rule in the words a project doc would have used. They are grep targets:\n short, literal, no markdown.\n-->\n\n# 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: one section per release, newest first.\nEvery entry leads with the rule as it now stands; a `supersedes:` line under it\nquotes the old rule in the words a project's own notes would have used, so those\nnotes can be searched for and corrected. Pass `since` \u2014 as in\n`ae_guide({topic: \"whats-new\", since: \"0.4.0\"})` \u2014 to get only the releases\nafter that version; the absorb-release prompt is the flow that applies them to\na project once and records the version so the history is not read again.\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**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.\n\n## 0.5.2\n\nFour fixes, all reported from outside the project. Two change what a result\nlooks like: an effect read can now come back cut short, and a screenshot leaves\nat most one undo step instead of many.\n\n- **`list_effects`, `get_layer_full`'s effects and `add_effect` return at most\n 200 parameters per effect, and say what they left out.** A trimmed effect\n carries `paramsOmitted` and a `paramsTruncated` note; raise `maxParams`, or\n pass `paramFilter` (a regex over name and matchName) to read only the\n parameters you need. Built-in effects never reach the cap; a particle plugin\n used to return a megabyte in one call (issue #115).\n supersedes: list_effects returns every parameter\n- **A screenshot leaves at most one undo step, named `AE MCP: screenshot`, and\n none when the comp is already at the resolution asked for.** Rendering\n borrows the comp's resolution and, for `screenshot_layer`, the solo\n switches; each borrowed assignment used to be its own anonymous\n \"Resolution change\" step, burying the user's real edits (issue #116). A\n frame or tile the panel has to retry is a new call and adds one more.\n supersedes: every screenshot adds Resolution change undo steps\n- **Set effect parameters by `paramMatchName`; `paramName` is the UI\n language's display name.** A miss now names the effect, lists its\n parameters as name = matchName, and says when After Effects is not in\n English; `paramName` also matches regardless of case (issue #121).\n supersedes: set_effect_param by parameter name\n- **`parent_layer` keeps shape and text layers in place.** Only a camera or\n light in the chain skips the correction now; shape and text layers used to\n be refused as if they were one, because `instanceof AVLayer` is false for\n them (issue #120).\n supersedes: not corrected: a camera or light in the chain\n\n## 0.5.1\n\nOne security fix, reported from outside the project. It changes nothing about\nhow you drive After Effects \u2014 but it does add a fifth way a call can fail, and\nthat one has a remedy of its own.\n\n- **The bridge authenticates every call, and a refused one is `bridgeToken` in\n `check_setup` rather than a broken panel.** The panel's HTTP server is on\n `127.0.0.1`, which a web page in the user's browser can reach, and `/op`\n reaches `run_jsx` \u2014 an unrestricted `eval` inside After Effects. It now\n requires a per-session token that the panel writes beside its port file, in\n `~/.engineroom-ae-mcp/token-<port>`, and that only a local process can read\n (issue #106). Nothing changes for you when it is working. When it is not, the\n failure reads \"the bridge token did not match\", and its remedy contradicts\n every other bridge failure: the panel is running, it answered immediately, the\n call never reached After Effects, and re-sending, restarting After Effects and\n `setup_panel` are all the wrong move. Run `check_setup` and relay `nextSteps`.\n supersedes: the bridge has no authentication\n supersedes: any local process can post to the bridge\n- **An MCP server older than the running panel is refused, and says so.** The\n panel loads at After Effects launch and only at launch, so after this upgrade\n a session can have a new panel answering an old server. That combination gets\n a refusal naming the fix \u2014 update the tools and reconnect \u2014 rather than a\n confusing failure. The reverse, a new server against an old panel, works\n unchanged: the header is simply ignored.\n\n## 0.5.0\n\nThe release where the guidance split into a core and references, the journal\nstarted coming to you instead of you going to it, and three more things After\nEffects does silently \u2014 a batch's progress no client ever saw, a broken\nexpression reported as ok, a justification that did not land \u2014 became\nvisible. Two results changed shape; those are first, because a caller reading\nthe old shape gets nothing rather than an error.\n\n### Two results changed shape\n\n- **`find_layers` returns `{matches, count, compsSearched, included}`, and a\n match is id, index, name, sourceType, compId and compName unless `include`\n asks for more.** It used to answer a bare array of full layer summaries \u2014\n flags, timing, parent, on every match \u2014 with no way to bound it, so nine name\n matches cost about two thousand tokens, re-sent on every later request.\n `include` takes the `list_layers` section names (`flags`, `timing`,\n `parent`); unlike `list_layers`, omitting it means the bounded record, not\n everything, and the result echoes `included`. A caller that read the old\n array reads `matches` now.\n supersedes: find_layers cannot be bounded\n supersedes: find_layers returns an array of layer summaries\n- **`place_audio_cues` results carry one small entry per cue \u2014 `layerId`,\n `name`, `time`, plus what an option changed \u2014 and `dryRun` answers counts,\n `wouldImport`, `wouldReuse` and the failing cues only.** The per-cue\n `levelDb`, `label`, `index` and `itemId` were echoes of the request, and a\n seven-cue dry run that found nothing wrong cost about 1,500 tokens of them.\n Read a level off the layer, not out of the result.\n supersedes: place_audio_cues echoes the levelDb written for every cue\n supersedes: dryRun on place_audio_cues returns the resolved cue list\n\n### Three silences, now reported\n\n- **`run_batch` over 500 ops returns its `jobId` before the first chunk runs,\n and progress is delivered on `await_job` \u2014 send that call with a progress\n token and `notifications/progress` arrive while it waits.** Nothing can ride\n on the `run_batch` call itself: its response is already back, and a\n notification sent on a request's token after its response is one every\n spec-compliant client has already stopped listening for \u2014 which is where\n every progress message for a long batch went before 0.5.0, seen by no real\n client. `get_job` polls the same state without a token.\n supersedes: run_batch progress arrives on the run_batch call\n supersedes: pass a progressToken on run_batch to see its progress\n- **`set_expression` throws when After Effects cannot evaluate the expression,\n and `get_expression` returns `expressionError`.** Assigning an expression\n succeeds whatever the text says, and AE's only report is a warning banner in\n its interface; the tool now evaluates the property after the write and fails\n with AE's message and the property path. The expression stays written \u2014 fix\n it and call again, or `clear_expression`. `toggle_expression({enabled:\n true})` verifies the same way. A non-empty `expressionError` means the\n property is not being driven by that expression, whatever `enabled` says.\n supersedes: set_expression reports success for a broken expression\n supersedes: ask the user to check the property for an expression warning banner\n- **`set_text` and `create_text_layer` read the justification back after every\n write, re-assert it once if it moved, and throw naming expected and actual\n if it still disagrees.** On 26.3 a `set_text` that changed only `text` has\n re-centred a left-aligned layer. Both results carry `justification` as a\n name and `justificationReasserted`; a failing `create_text_layer` removes\n the layer it made, so nothing is left without an id. An unknown\n justification name is refused rather than ignored.\n supersedes: set_text leaves justification alone when you do not pass it\n supersedes: verify alignment by sourceRect after every set_text\n\n### Deleting a comp, and what it leaves behind\n\n- **`delete_comp` reports `unusedSolidsLeft`, and `purgeUnusedSolids: true`\n removes the comp's own now-unused solids in the same undo step.** A solid's\n source is a footage item in the Solids folder, and deleting the comp only\n ever removed the layer, so a session that built and discarded comps left\n hundreds behind. Only the deleted comp's own solids are considered; one\n another comp still uses is kept and reported in `keptSolids` with that comp.\n supersedes: delete_comp removes a comp and its solids\n supersedes: sweep the Solids folder by hand after deleting comps\n- **`purge_unused_footage` sweeps the project: solids only by default,\n `solidsOnly: false` for every unused footage item, `folderId` to scope it,\n `dryRun: true` to list `wouldRemove` without an undo step.** Never a comp or\n a folder \u2014 a nested comp nothing uses is not footage.\n supersedes: remove unused footage with run_jsx\n\n### Sound\n\n- **`place_audio_cues` takes `loop`, `fadeIn`/`fadeOut` and `stretch` per cue,\n and `fadeFloorDb` per call.** `loop: true` time-remaps with a wrap-around\n expression, keeps AE's two default keys and re-asserts the out point; fades\n keyframe Audio Levels at the layer's real in and out between the floor\n (default `-48`) and the cue's level, and a fade that does not fit is refused\n by cue index; `stretch` is a percentage with the start time and trims\n re-asserted after it. A stretched loop bakes the factor into its expression,\n so a later hand change to stretch leaves the loop at the old rate. All of it\n is validated before anything is created and rolled back with the rest.\n supersedes: loop a bed with run_jsx and timeRemap after place_audio_cues\n supersedes: fade audio in run_jsx after placing the cues\n\n### The journal comes to you\n\n- **A failed call ends with `Known from earlier sessions: <scope:id> \u2014 <title>`\n for up to three entries matching its tool and error text, and the\n `list_known_issues({id})` call that opens the first.** Nobody reads the\n journal ahead of time any more; the pointer arrives with the failure, and a\n match moves the entry's `lastSeen`.\n supersedes: read list_known_issues before nontrivial work\n supersedes: check list_known_issues before guessing\n- **`log_issue` takes `errorText` and `kind`, and the same tool with the same\n error text extends the existing entry even under a new title** \u2014 the result\n says `mergedBy: \"errorText\"` and keeps the existing title. `kind:\n \"ae-quirk\"` marks a permanent After Effects behaviour; the default\n `tool-bug` is presumed fixed once last seen on an older server. Entries\n record `firstVersion` and `lastVersion`.\n supersedes: call list_known_issues first so you reuse the title when logging\n- **Entries archive themselves \u2014 unseen for 30 days, or a `tool-bug` last seen\n on an older server \u2014 and `archive_issue({id, reason})` retires one by\n hand.** Archived entries are hidden from the index and counted in\n `archivedCount`; `includeArchived: true` lists them, a read by `id` always\n works, and a fresh `log_issue` reopens one with `reopened: true`. Index\n lines carry `kind`, `lastSeen` and `lastVersion` and no summary sentence,\n sorted by tool match and then most recently seen.\n supersedes: the journal only grows, entries are never removed\n- **The report flow checks the tracker before drafting.** An entry a closed\n report already covers is archived with that URL; one an open report covers\n is marked reported. Bodies go through a file, and \"all of them\" is one\n approval.\n\n### Reading the history once\n\n- **`ae_guide({topic: \"whats-new\", since: \"<version>\"})` returns only the\n releases after that version, opening with the server's own version**, and\n this file has a shape a machine can filter: one section per release, every\n entry leading with the rule as it now stands, `supersedes:` lines under it\n quoting the old rule. The absorb-release prompt (`/absorb-release` where\n prompts are commands) applies the delta to a project's notes once and\n records the version in the `Tools version last absorbed:` line\n `init_project` writes into AGENTS.md.\n supersedes: read the whole whats-new guide after every update\n\n### The guidance\n\n- **The `after-effects` topic is a core of what silently produces wrong output\n on any task, and each subject is its own topic** \u2014 `animation`, `shapes`,\n `text`, `assembly`, `extendscript-gotchas`, `sound`, `mogrt-and-footage`,\n `issue-journal`, `whats-new` \u2014 twelve `ae_guide` topics in all with\n `style-guide` and `ae-setup`. In Claude Code and claude.ai the references\n are the files in the `after-effects` skill's `references/` folder, opened\n only when the core points at them. Keyframes, easing and rigging are in\n `animation`; raw scripting, with the ease-arity table, in\n `extendscript-gotchas` and nowhere else.\n supersedes: the after-effects skill covers keyframes text and shapes in one file\n supersedes: the ease arity table is in the main guide\n- **Four more After Effects facts, measured on 26.3, in `extendscript-gotchas`:**\n removing the last Time Remap key hides the property, so edit AE's two\n default keys in place or put an expression over them, and re-assert\n `outPoint` after enabling remapping; `TextDocument.justification` written\n from a script lands as RIGHT when handed CENTER, so set it through\n `set_text`; `OutputModule.setSettings` rejects Format, Channels, Depth and\n Color as read-only, so build an output template in the UI and apply it by\n name; and the ease arity is not derivable from a value's dimension, so\n never size a `KeyframeEase` array by hand.\n supersedes: clear the Time Remap keys before adding your own\n supersedes: set justification on the TextDocument from run_jsx\n supersedes: set the output module codec with setSettings from run_jsx\n- **The references carry the craft rules a project would otherwise keep in\n its own notes** (issue #103). `extendscript-gotchas`: a `.value` read at\n the playhead, a `setValue` that throws with the value applied on a comp not\n in a viewer, effects addressed by a name that is not unique and references\n that go stale when one is added, a track matte that switches its matte's\n video off, `replaceSource` keeping the old timing, `item.typeName`, Time\n Remap keys added before the defaults are removed. `animation`: a held pose\n as a hold key, a waypoint that must not stop, parenting at rest and before\n the pop with the parent-space arithmetic, a mirrored parent mirroring its\n text, expression text built from numbers, a shake as an expression over\n keys. `assembly`: a precomp clamped at its source, reversing with time\n remap, controls shared across every instance, a resized comp shifting what\n nests it, collapse transformations and motion blur across a nesting,\n replacing a nested comp, and verifying an assembly, a small detail, a\n doubtful tile and a cut. `sound`: peak for a one-shot and RMS for a bed\n with the onset on the beat, a trim clamped at the file's end, a bed that\n follows a move, a float WAV that will not place. `shapes`: one fill per\n group, the layer stack built back-to-front, a group's pivot, seams between\n shapes, centring a repeated run.\n\n### Connecting, and staying connected\n\n- **The server follows the panel's port.** A call refused on the port the\n server remembered makes it look again \u2014 7777 first, then the port file \u2014\n switch to whichever answers as the panel, and re-send the call once. Only a\n refused connection triggers this; a timed-out call reached After Effects and\n is never re-sent (issue #92). `AE_MCP_PORT` is a hard pin: with it set, no\n other port is ever tried.\n supersedes: reconnect the MCP server when ops fail on a port check_setup does not report\n- **`check_setup` reports the port that actually answers**, flags a stale\n port file, and carries a new `portAgreement` check. When calls go to one\n port and the panel answers on another, its advice is to retry or reconnect\n the MCP server. It never tells you to restart After Effects for that.\n supersedes: cannot reach the panel means nothing is listening so restart After Effects\n- **The panel waits instead of moving when an older copy of itself holds\n 7777.** It says so in its own window, names the holder, and takes the port\n over the moment that copy exits, with no restart. Two After Effects\n instances on one machine opt back into walking with `\"allowPortWalk\": true`\n in `~/.engineroom-ae-mcp/config.json`; the same file's `\"port\"` moves the\n panel. The port file only ever names a port that was really bound and is\n removed when the panel unloads.\n supersedes: the panel walks up to the next free port when 7777 is taken\n- **A panel that never loads on Windows gets a diagnosis, not a restart\n loop.** `check_setup` reads CEP's own log and, when CEP refused the panel's\n signature \u2014 an Adobe CEP 12 bug that ignores PlayerDebugMode \u2014 reports\n `panelSignature` with the steps to self-sign the installed panel with\n Adobe's ZXPSignCmd (issue #91). With no log yet, it gives the one command\n that turns CEP logging on. `setup_panel` now turns that logging on itself\n where it was never set, warns when it is about to replace a self-signed\n panel (`signedInstallOverwritten`, since the fresh copy needs signing\n again), and reports a signed panel as up to date rather than partial.\n supersedes: check_setup all green but the panel never loads means restart After Effects again\n- **`check_setup` under an `AE_MCP_PORT` pin still asks 7777 and the port\n file, and a pinned wrong port is reported as the pin, not as a dead panel.**\n The pin decides where ops go and is never walked past; the diagnosis says so,\n names the port the panel is really answering on, and tells you to change or\n unset `AE_MCP_PORT` and reconnect the MCP server \u2014 never to restart After\n Effects. A refused op under a pin says the same in its own message.\n\n### Found by the live pass\n\n- **`init_project` refuses the home directory and the filesystem root however\n they were arrived at, an explicit `dir` included.** Before this only the\n working-directory fallback was guarded, so `dir: \"~\"` resolved by a client,\n or `dir: \"/\"`, scaffolded `AGENTS.md` and a `renders/` folder into a home\n directory and reported success.\n- **`set_house_style` writes `house-style.md` with Unix line endings.**\n ExtendScript's default on macOS turned every line break into a bare carriage\n return: the tools read the file back correctly, so the round trip looked\n fine, but a designer opening the file in an editor saw one long line.\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### Four things that were never true, and are now fixed\n\n- **`run_batch` is one undo step up to 500 ops; over 500 it is one step per\n chunk of 25, and `singleUndo: true` makes it one step up to 2000.** The\n one-step guarantee it shipped with before 0.4.0 did not exist on either path:\n After Effects discards an undo group opened in one script call and closed in\n another, so a 600-op batch landed as about six hundred separate steps while\n reporting one. Now up to 500 ops is genuinely one step; over 500 is one step\n per chunk of 25 \u2014 around 24 for 600 ops \u2014 with the measured count in\n `undoSteps` and a `note` saying it in words. `singleUndo: true` runs the batch\n in a single blocking call, which freezes AE's interface for the duration and\n reports no progress. Read `undoSteps` before you tell anyone how to undo the\n work.\n supersedes: run_batch is one undo step\n supersedes: a long run_batch is one undo step\n- **`transactional: true` stops at the first failure, rolls nothing back, and\n says so: `rolledBack: false` with the stop point named.** It never rolled\n anything back \u2014 it fired one menu-command Undo, and menu commands silently do\n nothing over this bridge; one Undo is one step, not a batch, in any case. The\n ops before the failure stay applied. Use `diff: true` to see what landed.\n supersedes: transactional: true rolls the batch back on failure\n- **`reorder_layer` works, and takes exactly one of `beforeLayerId`,\n `afterLayerId` or `toIndex`.** Every call it served before 0.4.0 threw\n `parent is not an INDEXED_GROUP`, because it used a shape-node method on a\n layer. Prefer the id forms, because this is the op that invalidates indexes;\n `toIndex` means the index the layer **ends up at**.\n supersedes: reorder_layer is broken, use run_jsx\n supersedes: reorder_layer throws parent is not an INDEXED_GROUP\n- **`downsample: 1` is full resolution, whatever the viewer's Resolution\n dropdown is set to.** Before 0.4.0 factor 1 skipped setting the comp's\n resolution at all, so it inherited the viewer's setting \u2014 on a comp a designer\n had left at Quarter, `downsample: 1` returned a quarter-size frame and\n `downsample: 2` returned one four times larger. The response was honest about\n the dimensions; the picture was not the one asked for. Factor 1 is now set\n explicitly like any other and restored afterwards.\n supersedes: downsample: 1 means the viewer's resolution\n supersedes: set the viewer to Full before a downsample: 1 screenshot\n\n### Two calls changed under you\n\n- **`create_shape_layer` spawns at `[0,0]`**, with the anchor at `[0,0]`, so the\n layer's coordinate space *is* the comp's and every vertex, rect position and\n path you add afterwards is in comp pixels. After Effects' own spawn point is\n the comp centre, which silently offsets a drawing authored in comp coordinates\n by half a frame \u2014 easy to miss on a downsampled screenshot. `position:\n \"center\"` restores AE's behaviour, and any `[x,y]` places the origin yourself.\n The result echoes what it ended up with.\n supersedes: a new shape layer spawns at the comp centre\n supersedes: shape paths are offset by half a frame, subtract the comp centre\n- **`get_house_style` returns a digest, not the document; `detail: \"full\"`\n returns the document.** The digest is a few hundred tokens: palette as named\n hexes, type, motion, layout, and a note naming anything it could not summarise.\n You need the full document before `set_house_style`, which replaces the file\n rather than patching it. An unstructured guide comes back `structured: false`\n with its opening text, which is an honest \"I could not read this as a spec\"\n rather than an empty answer.\n supersedes: do not call get_house_style, read the digest in these notes instead\n supersedes: get_house_style returns the whole style guide\n\n### Verify with a diff instead of a second read\n\n- **`snapshot_comp` and `diff_comp` fingerprint a comp and report what moved.**\n Fingerprint before a write, then ask: layers added, removed, renamed,\n retimed, re-parented, keyframe counts, expressions and effects gained or lost\n \u2014 and a count of the layers that did not move. Tens of tokens where two full\n reads were thousands. It does **not** record property values, expression\n text, effect parameters, masks or shape contents, so `changeCount: 0` means\n none of the recorded fields moved, not that the comp is identical.\n supersedes: verify a write by reading the comp back with list_layers and get_layer_full\n- **`diff: true` on `run_jsx` and `run_batch` does the same inside the call**,\n so there is no window between the write and the fingerprint \u2014 and on a\n failure the diff rides on the error, which is how you find where a\n half-applied script stopped.\n\n### Screenshots\n\n- **`screenshot_frame` takes `times` (2\u20136) and returns one tiled contact\n sheet**, each tile labelled with its own time, held to roughly the pixel\n budget of a single frame. Judging motion is one call now, not three: one\n image in your context instead of three, and one render request instead of\n three back-to-back ones, which is the pattern that provoked stale frames.\n `time` and `times` are mutually exclusive; there is no `times` on\n `screenshot_layer`.\n supersedes: screenshot each time separately to judge motion\n- **Three screenshot failures, each with its own remedy.** `Stale frame` (AE\n served an earlier render \u2014 wait, retry higher), `Corrupt frame` (the file is\n not a 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 supersedes: truncated PNG from screenshot_frame means retry it\n\n### run_jsx\n\n- **A `run_jsx` failure names the line of *your* script and prints its text**,\n and says so honestly when the number cannot be mapped rather than guessing.\n This was claimed once before it was true: until the 0.4.0 live pass every\n error said line 1 and printed line 1's text, with the real number only in the\n parenthetical after it, because AE reports `Error.start`/`Error.end` as `0`\n on every error rather than as the character offsets its documentation\n describes.\n supersedes: run_jsx line numbers are wrong, count from the wrapper\n supersedes: run_jsx reports line 1 for every error\n- **`diff: true` on `run_jsx` reaches After Effects.** The server was building\n a fresh argument object and dropping every field it did not enumerate, so the\n flag was discarded before the call left. The same flag on `run_batch` always\n worked, which is what hid it.\n supersedes: diff: true on run_jsx does nothing\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\n the same scope, so their functions are callable from it; they are\n re-evaluated on every call, so keep them to declarations rather than to work.\n- **Helpers are in scope**, each wrapping a trap: `compById`, `layerById`,\n `walkProperty`, `addKeys`, `ease` (which sizes the KeyframeEase array using\n the 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\n by name. Shallow by default like AE's own Duplicate \u2014 the copy's precomp\n layers point at the *same* nested comps \u2014 with `deep: true` for a real\n variant.\n supersedes: duplicate a comp with run_jsx and find the copy by name\n- **`place_audio_cues` scores a scene in one call**: a cue list becomes one\n audio layer each, imported once per file, named, trimmed, levelled in dB, in a\n single undo step, all-or-nothing, with a `dryRun`.\n supersedes: place audio with run_jsx, one layer at a time\n- **`set_temporal_ease` and `add_keyframe` size the ease array themselves.**\n Pass one `{influence, speed}` pair per side; the count that worked comes back\n as `easeDimensions`. The bare `parameter 2` failure is no longer yours to\n avoid.\n supersedes: set_temporal_ease fails with parameter 2, pass the right number of KeyframeEase objects by hand\n supersedes: set_temporal_ease fails on Scale, use run_jsx\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\n unknown, because AE answers with a bare boolean and has no way to say why.\n supersedes: export_mogrt fails with a modal dialog when the comp has no Essential Graphics controllers\n\n### Underneath\n\n- **Writes are serialized, one at a time, for the whole session**, so two\n writes issued together run in the order you issued them and a long\n `run_batch` holds the lock until its last chunk lands \u2014 nothing else drops\n into the middle of work the user asked for as one thing. A call that waited\n says `queuedBehind` and `waitedMs`. Reads are never queued. There is a new\n diagnosis to tell apart from a bridge timeout: a call *dropped while waiting\n for the write queue* never reached After Effects, so re-sending it is safe \u2014\n which is the opposite of what a timeout means.\n supersedes: never issue two writes in the same turn, the order is not guaranteed\n- **The issue journal has two scopes.** `project` is this project's notes;\n `user` travels with the person across every project, so log tool and After\n Effects behaviour there. Ids are unique only within a journal, so open an\n entry with the qualified form the listing shows (`user:\u2026`).\n- **The guidance lives in `ae_guide` and the skill, not in what the server sends\n every session.** What the server sends is a short pointer rather than a\n summary, because it was resident in every request the user ever made. The\n narrative is `ae_guide({topic: \"after-effects\"})`, with `extendscript-gotchas`\n behind it for raw `run_jsx` and this topic for changes. In Claude Code and\n claude.ai the same text is the `after-effects` skill and the files in its\n `references/` folder \u2014 load one carrier, not both.\n- **Rigging is written down**, in the `animation` topic: 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- **`get_layer_full` omits `ADBE Vector Materials Group` unless\n `shapeMaterials: true`.** That is the 48-property 3D extrusion model AE hangs\n off every vector group, which means nothing on a 2D shape layer. It was around\n three quarters of the bytes of a shape read: one 68px circle cost 4,400\n tokens, of which the geometry was about 40. `materialsOmitted` counts what was\n dropped; `shapeMaterials: true` brings it back for a genuinely extruded shape.\n A group Transform still at its creation values collapses to `atDefaults:\n true` the same way.\n supersedes: shape reads are huge, avoid get_layer_full on shape layers\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 0.3.1, 3,052 once the materials block went,\n 643 compact. Reach for `\"full\"` only when you need exact values or keyframe\n 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 `{ok: true, returned: null, undoGroup,\n note}`, which means *it ran to completion*. Do not re-run it: nothing rolls\n back, so a second run of a script that duplicated a layer or wrote keyframes\n does all of it twice. A returned value still comes back bare, falsy ones\n included.\n supersedes: a null from run_jsx means the script did not run, run it again\n\n## 0.3.0\n\n- **`list_comps`, `list_layers` and `get_layer_full` take `include` to name the\n sections you want**, plus `maxKeyframes` and `shapeDepth` on the deep read.\n Omit them all and you get everything, exactly as before. Anything left out is\n named and counted in the response (`included`, `keyframesOmitted`,\n `childrenOmitted`), so a short answer never passes for a complete one. This\n matters more than it sounds: a tool result is re-sent to you on every later\n request for the rest of the session, so one unbounded read is paid for many\n times.\n supersedes: get_layer_full returns everything, there is no way to bound it\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 supersedes: always pass downsample on screenshot_frame or it returns full resolution\n- **A stale frame is an error, not a picture.** AE sometimes re-serves a frame\n 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\n are refused with `Stale frame` naming the request whose pixels came back.\n Wait a 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- **`import_footage` and `create_footage_layer` bring footage in**, and 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 supersedes: import footage with run_jsx and importFile\n- **`export_mogrt` exports a Motion Graphics template and suppresses the modal\n dialogs** that otherwise freeze this whole connection until someone clicks\n them in After Effects. Never drive `exportAsMotionGraphicsTemplate` from\n `run_jsx`.\n supersedes: export a mogrt with run_jsx and exportAsMotionGraphicsTemplate\n- **Text alignment is justification**, not a measured anchor offset, so it\n stays correct when the text changes later. Tracking is set explicitly rather\n than inherited from the user's Character panel.\n supersedes: anchorAlign moves the anchor point, re-align text after every edit"
3423
3434
  }
3424
3435
  ];
3425
3436
  var PROMPTS = [
@@ -4325,7 +4336,7 @@ var GetJobSchema = schemas_exports.GetJob;
4325
4336
  var CancelJobSchema = schemas_exports.CancelJob;
4326
4337
  function createServer(opts = {}) {
4327
4338
  const server = new Server(
4328
- { name: "after-effects-mcp", version: "0.5.1" },
4339
+ { name: "after-effects-mcp", version: "0.5.2" },
4329
4340
  {
4330
4341
  capabilities: { tools: {}, logging: {}, prompts: {}, resources: {} },
4331
4342
  // Clients that honour this fold it into the system prompt, which is the
@@ -4827,7 +4838,7 @@ ${USAGE}`);
4827
4838
  await server.connect(transport);
4828
4839
  logger.info("MCP server running on stdio");
4829
4840
  }
4830
- var VERSION = "0.5.1";
4841
+ var VERSION = "0.5.2";
4831
4842
  main().catch((e) => {
4832
4843
  logger.error("fatal", e.message);
4833
4844
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@engine-room/after-effects-mcp",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "type": "module",
5
5
  "description": "Control Adobe After Effects with AI — describe the animation you want and it gets built: layers, keyframes, effects, expressions and text, all editable afterwards.",
6
6
  "license": "MIT",
@@ -52,7 +52,7 @@
52
52
  "clean": "rm -rf dist bin panel"
53
53
  },
54
54
  "dependencies": {
55
- "@modelcontextprotocol/sdk": "^1.0.4",
55
+ "@modelcontextprotocol/sdk": "^1.32.0",
56
56
  "ws": "^8.21.3",
57
57
  "zod": "^3.23.8",
58
58
  "zod-to-json-schema": "^3.23.5"
@@ -1,8 +1,8 @@
1
1
  <?xml version="1.0" encoding="UTF-8"?>
2
- <ExtensionManifest Version="11.0" ExtensionBundleId="games.engine-room.ae-mcp" ExtensionBundleVersion="0.5.1"
2
+ <ExtensionManifest Version="11.0" ExtensionBundleId="games.engine-room.ae-mcp" ExtensionBundleVersion="0.5.2"
3
3
  ExtensionBundleName="AE MCP Bridge" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
4
4
  <ExtensionList>
5
- <Extension Id="games.engine-room.ae-mcp.panel" Version="0.5.1" />
5
+ <Extension Id="games.engine-room.ae-mcp.panel" Version="0.5.2" />
6
6
  </ExtensionList>
7
7
  <ExecutionEnvironment>
8
8
  <HostList>
@@ -934,7 +934,10 @@ function __ptLocalMatrix(l, t) {
934
934
  function __ptChain(l, t, depth) {
935
935
  if (!l) return { ok: true, m: __ptMId(), wb: [0, 0], animated: false };
936
936
  if (depth > 32) return { ok: false, reason: "parent chain deeper than 32 layers" };
937
- if (!(l instanceof AVLayer)) return { ok: false, reason: 'a camera or light in the chain ("' + l.name + '") has no 2D transform' };
937
+ // Name the exclusions rather than testing for AVLayer: in ExtendScript a
938
+ // ShapeLayer or TextLayer is not `instanceof AVLayer`, despite Adobe's class
939
+ // docs, so that test refused every shape and text layer as a "camera" (#120).
940
+ if (l instanceof CameraLayer || l instanceof LightLayer) return { ok: false, reason: 'a camera or light in the chain ("' + l.name + '") has no 2D transform' };
938
941
  if (l.threeDLayer) return { ok: false, reason: 'a 3D layer in the chain ("' + l.name + '")' };
939
942
  var up = __ptChain(l.parent, t, depth + 1);
940
943
  if (!up.ok) return up;
@@ -2142,10 +2145,61 @@ OPS.clear_expression = function (args) {
2142
2145
 
2143
2146
  // effects.jsx — effect graph ops.
2144
2147
 
2145
- function __serializeEffect(eff) {
2146
- var out = { name: eff.name, matchName: eff.matchName, enabled: eff.enabled, index: eff.propertyIndex, params: [] };
2147
- for (var i = 1; i <= eff.numProperties; i++) {
2148
+ // Effect parameters are bounded by default (issue #115). Every built-in
2149
+ // effect has under 15 parameters and the heaviest common third-party ones
2150
+ // (Saber, Warp Stabilizer) under 100, but Trapcode Particular exposes 7,401 —
2151
+ // one list_effects on it was 1 MB, ~265k tokens, re-sent on every later
2152
+ // request. 200 returns every ordinary effect whole and caps a pathological one
2153
+ // at a few thousand tokens. This is a deliberate exception to "absent means
2154
+ // everything", the same way find_layers' bounded default is: what is left out
2155
+ // is always counted (paramCount, paramsOmitted) and named (paramsTruncated),
2156
+ // so a capped effect never passes for a complete one.
2157
+ var __EFFECT_PARAMS_DEFAULT = 200;
2158
+
2159
+ // One parse of maxParams/paramFilter per call, shared by every effect in it.
2160
+ // paramFilter is a case-insensitive regex over each parameter's name and
2161
+ // matchName, the same convention as find_layers' name filter.
2162
+ function __effectParamOpts(args) {
2163
+ var max = (args && args.maxParams > 0) ? args.maxParams : __EFFECT_PARAMS_DEFAULT;
2164
+ var re = null;
2165
+ if (args && typeof args.paramFilter === "string" && args.paramFilter.length > 0) {
2166
+ try { re = new RegExp(args.paramFilter, "i"); }
2167
+ catch (e) { throw new Error("paramFilter is not a valid regular expression: " + args.paramFilter); }
2168
+ }
2169
+ return { maxParams: max, paramFilter: re, paramFilterSource: re ? args.paramFilter : null };
2170
+ }
2171
+
2172
+ function __paramsTruncationNote(total, returned, matched, popts) {
2173
+ if (popts.paramFilter) {
2174
+ var head = "paramFilter /" + popts.paramFilterSource + "/ matched " + matched + " of " + total + " parameters";
2175
+ if (matched > returned) {
2176
+ return head + "; first " + returned + " returned, " + (matched - returned) + " more matches omitted — raise maxParams";
2177
+ }
2178
+ return head + "; the other " + (total - matched) + " omitted";
2179
+ }
2180
+ return "first " + returned + " of " + total + " parameters; " + (total - returned) + " omitted — raise maxParams " +
2181
+ "(up to " + total + "), or pass paramFilter, a regex over parameter name/matchName, to read only the ones you need";
2182
+ }
2183
+
2184
+ // `popts` comes from __effectParamOpts; absent means the default cap, never
2185
+ // "everything".
2186
+ function __serializeEffect(eff, popts) {
2187
+ if (!popts) popts = __effectParamOpts(null);
2188
+ var max = popts.maxParams;
2189
+ var re = popts.paramFilter;
2190
+ var total = eff.numProperties;
2191
+ var out = { name: eff.name, matchName: eff.matchName, enabled: eff.enabled, index: eff.propertyIndex, paramCount: total, params: [] };
2192
+ var matched = 0;
2193
+ for (var i = 1; i <= total; i++) {
2148
2194
  var p = eff.property(i);
2195
+ if (re && !(re.test(p.name) || re.test(p.matchName))) continue;
2196
+ matched++;
2197
+ if (out.params.length >= max) {
2198
+ // Unfiltered, the rest is only counted, so stop walking; filtered, keep
2199
+ // going so the matched count is exact.
2200
+ if (!re) break;
2201
+ continue;
2202
+ }
2149
2203
  var entry = { name: p.name, matchName: p.matchName, propertyType: String(p.propertyType) };
2150
2204
  try { entry.value = p.value; } catch (e) {}
2151
2205
  if (p.canSetExpression && p.expression) entry.expression = p.expression;
@@ -2157,6 +2211,13 @@ function __serializeEffect(eff) {
2157
2211
  }
2158
2212
  out.params.push(entry);
2159
2213
  }
2214
+ if (re) out.paramsMatched = matched;
2215
+ else matched = total;
2216
+ var omitted = total - out.params.length;
2217
+ if (omitted > 0) {
2218
+ out.paramsOmitted = omitted;
2219
+ out.paramsTruncated = __paramsTruncationNote(total, out.params.length, matched, popts);
2220
+ }
2160
2221
  return out;
2161
2222
  }
2162
2223
 
@@ -2165,39 +2226,109 @@ OPS.list_effects = noUndo(function (args) {
2165
2226
  var l = getLayerById(c, args.layerId);
2166
2227
  var fx = l.property("Effects");
2167
2228
  if (!fx) return [];
2229
+ var popts = __effectParamOpts(args);
2168
2230
  var out = [];
2169
- for (var i = 1; i <= fx.numProperties; i++) out.push(__serializeEffect(fx.property(i)));
2231
+ for (var i = 1; i <= fx.numProperties; i++) out.push(__serializeEffect(fx.property(i), popts));
2170
2232
  return out;
2171
2233
  });
2172
2234
 
2235
+ // The new effect comes back under the default parameter cap: adding Particular
2236
+ // should not return 265k tokens nobody asked to read.
2173
2237
  OPS.add_effect = function (args) {
2174
2238
  var c = getCompById(args.compId);
2175
2239
  var l = getLayerById(c, args.layerId);
2176
2240
  var fx = l.property("Effects");
2177
2241
  var eff = fx.addProperty(args.matchName);
2178
- return __serializeEffect(eff);
2242
+ return __serializeEffect(eff, __effectParamOpts(null));
2179
2243
  };
2180
2244
 
2181
2245
  OPS.remove_effect = function (args) {
2182
2246
  var c = getCompById(args.compId);
2183
2247
  var l = getLayerById(c, args.layerId);
2184
- var fx = l.property("Effects");
2185
- fx.property(args.effectIndex).remove();
2248
+ __effectAt(l, args.effectIndex).remove();
2186
2249
  return { ok: true };
2187
2250
  };
2188
2251
 
2252
+ // Resolve an effect by its 1-based index, or say what is there. A bare
2253
+ // `property(n)` past the end answers null and the caller's next line throws a
2254
+ // TypeError that names nothing. The group is looked up by matchName, which is
2255
+ // the same in every UI language.
2256
+ function __effectAt(layer, index) {
2257
+ var fx = layer.property("ADBE Effect Parade");
2258
+ var n = fx ? fx.numProperties : 0;
2259
+ var eff = (fx && index >= 1 && index <= n) ? fx.property(index) : null;
2260
+ if (!eff) {
2261
+ throw new Error(
2262
+ "No effect at effectIndex " + index + " on layer '" + layer.name + "': it has " + n +
2263
+ " effect" + (n === 1 ? "" : "s") + ". list_effects reports each one with its index."
2264
+ );
2265
+ }
2266
+ return eff;
2267
+ }
2268
+
2269
+ // The cap on parameters named in a miss. Most effects have under twenty; a
2270
+ // Trapcode-sized one has thousands, and the error must not become the payload.
2271
+ var __PARAM_LIST_CAP = 40;
2272
+
2273
+ // Issue #121. `Property.property(name)` matches the display name in the UI's
2274
+ // language only, so an English 'Distance' is null on a zh_CN After Effects
2275
+ // while 'ADBE Drop Shadow-0004' resolves everywhere. The display-name lookup
2276
+ // stays (it is what a localised user would type), but a miss now names the
2277
+ // effect, lists what it does have, and points at the language-proof field.
2278
+ function __findEffectParam(eff, args) {
2279
+ var i;
2280
+ if (args.paramMatchName) {
2281
+ for (i = 1; i <= eff.numProperties; i++) {
2282
+ if (eff.property(i).matchName === args.paramMatchName) return eff.property(i);
2283
+ }
2284
+ }
2285
+ if (args.paramName) {
2286
+ var p = null;
2287
+ try { p = eff.property(args.paramName); } catch (e) { p = null; }
2288
+ if (p) return p;
2289
+ // A case slip is not a localisation problem; forgive it before failing.
2290
+ var want = String(args.paramName).toLowerCase();
2291
+ for (i = 1; i <= eff.numProperties; i++) {
2292
+ if (String(eff.property(i).name).toLowerCase() === want) return eff.property(i);
2293
+ }
2294
+ }
2295
+ return null;
2296
+ }
2297
+
2298
+ function __effectParamMissMessage(eff, args, lang) {
2299
+ var asked = [];
2300
+ if (args.paramMatchName) asked.push("paramMatchName '" + args.paramMatchName + "'");
2301
+ if (args.paramName) asked.push("paramName '" + args.paramName + "'");
2302
+ var msg = "Effect '" + eff.name + "' (" + eff.matchName + ") has no parameter matching " + asked.join(" or ") + ".";
2303
+
2304
+ var n = eff.numProperties;
2305
+ var shown = [];
2306
+ for (var i = 1; i <= n && shown.length < __PARAM_LIST_CAP; i++) {
2307
+ var q = eff.property(i);
2308
+ shown.push("'" + q.name + "' = " + q.matchName);
2309
+ }
2310
+ msg += " Its parameters (name = matchName): " + shown.join("; ");
2311
+ if (n > shown.length) msg += "; ... and " + (n - shown.length) + " more not listed (list_effects has them all)";
2312
+ msg += ".";
2313
+
2314
+ if (args.paramName) {
2315
+ msg += " Display names are in After Effects' UI language";
2316
+ if (lang && lang !== "en_US") msg += ", and this one runs in " + lang + ", so an English name will not match";
2317
+ msg += ". Use paramMatchName instead: it is the same in every language.";
2318
+ }
2319
+ return msg;
2320
+ }
2321
+
2189
2322
  OPS.set_effect_param = function (args) {
2190
2323
  var c = getCompById(args.compId);
2191
2324
  var l = getLayerById(c, args.layerId);
2192
- var eff = l.property("Effects").property(args.effectIndex);
2193
- var p = null;
2194
- if (args.paramMatchName) {
2195
- for (var i = 1; i <= eff.numProperties; i++) {
2196
- if (eff.property(i).matchName === args.paramMatchName) { p = eff.property(i); break; }
2197
- }
2325
+ var eff = __effectAt(l, args.effectIndex);
2326
+ var p = __findEffectParam(eff, args);
2327
+ if (!p) {
2328
+ var lang = null;
2329
+ try { lang = app.isoLanguage; } catch (e) { lang = null; }
2330
+ throw new Error(__effectParamMissMessage(eff, args, lang));
2198
2331
  }
2199
- if (!p && args.paramName) p = eff.property(args.paramName);
2200
- if (!p) throw new Error("Effect param not found");
2201
2332
  if (args.keyframe && args.time !== undefined) p.setValueAtTime(args.time, args.value);
2202
2333
  else if (args.time !== undefined && p.numKeys > 0) p.setValueAtTime(args.time, args.value);
2203
2334
  else p.setValue(args.value);
@@ -2207,7 +2338,7 @@ OPS.set_effect_param = function (args) {
2207
2338
  OPS.set_effect_enabled = function (args) {
2208
2339
  var c = getCompById(args.compId);
2209
2340
  var l = getLayerById(c, args.layerId);
2210
- l.property("Effects").property(args.effectIndex).enabled = !!args.enabled;
2341
+ __effectAt(l, args.effectIndex).enabled = !!args.enabled;
2211
2342
  return { ok: true };
2212
2343
  };
2213
2344
 
@@ -2783,18 +2914,38 @@ function __resolveDownsample(comp, requested) {
2783
2914
  // afterwards. That is faster (a quarter of the pixels at factor 2) and needs no
2784
2915
  // external image tool, which is what makes it work off macOS.
2785
2916
  //
2786
- // Factor 1 is *set*, not skipped. Skipping it rendered at whatever the user had
2787
- // left the viewer on, so a comp parked at Quarter answered `downsample: 1` with
2788
- // a quarter-size frame and `downsample: 2` came back **larger** than
2789
- // `downsample: 1` (issue #72). Designers leave heavy comps at Quarter or Third
2790
- // as a matter of course, so that was the common case rather than a corner. The
2791
- // panel reads the real dimensions out of the PNG's IHDR, which means the
2792
- // response stayed honest while the *picture* was not the one asked for — the
2793
- // worse of the two failures, because an agent that can see a frame believes it.
2794
- // Every factor reaching this function now names the resolution it renders at.
2917
+ // The factor always *names* the resolution the render happens at. Leaving it to
2918
+ // the viewer rendered at whatever the user had left the Resolution dropdown on,
2919
+ // so a comp parked at Quarter answered `downsample: 1` with a quarter-size frame
2920
+ // and `downsample: 2` came back **larger** than `downsample: 1` (issue #72).
2921
+ // Designers leave heavy comps at Quarter or Third as a matter of course, so that
2922
+ // was the common case rather than a corner. The panel reads the real dimensions
2923
+ // out of the PNG's IHDR, which means the response stayed honest while the
2924
+ // *picture* was not the one asked for — the worse of the two failures, because
2925
+ // an agent that can see a frame believes it.
2926
+ //
2927
+ // What is skipped is only the *assignment*, and only when the comp is already
2928
+ // at the factor asked for. Every assignment to resolutionFactor is its own undo
2929
+ // step in After Effects ("Resolution change"), so set-and-restore cost two steps
2930
+ // per rendered frame — ten for one frame plus a four-tile sheet — and buried the
2931
+ // user's real edits under no-ops (issue #116). When the factor already matches,
2932
+ // the render happens at the requested resolution with nothing assigned, which
2933
+ // keeps #72's guarantee and leaves the project untouched.
2934
+ function __factorIs(rf, f) {
2935
+ return !!rf && rf[0] === f && rf[1] === f;
2936
+ }
2937
+
2938
+ function __factorFor(factor) {
2939
+ return (factor > 1) ? Math.round(factor) : 1;
2940
+ }
2941
+
2795
2942
  function __saveFrameAt(comp, time, file, factor) {
2796
- var f = (factor > 1) ? Math.round(factor) : 1;
2943
+ var f = __factorFor(factor);
2797
2944
  var previous = comp.resolutionFactor;
2945
+ if (__factorIs(previous, f)) {
2946
+ comp.saveFrameToPng(time, file);
2947
+ return;
2948
+ }
2798
2949
  try {
2799
2950
  comp.resolutionFactor = [f, f];
2800
2951
  comp.saveFrameToPng(time, file);
@@ -2806,6 +2957,32 @@ function __saveFrameAt(comp, time, file, factor) {
2806
2957
  }
2807
2958
  }
2808
2959
 
2960
+ // The one undo step a screenshot is allowed to cost (issue #116).
2961
+ //
2962
+ // A screenshot changes nothing, but it has to *borrow* the comp's resolution
2963
+ // and, for screenshot_layer, every layer's solo switch — and After Effects
2964
+ // records each of those assignments as its own step, restore included. Grouped,
2965
+ // the borrow and the restore collapse into one step the user can recognise by
2966
+ // name in Edit > History, instead of 2 + 2N anonymous ones.
2967
+ //
2968
+ // Three rules, all of which the tests hold:
2969
+ // * no group at all when nothing will be assigned — a comp already at the
2970
+ // requested factor, e.g. Full at downsample 1 — so that case costs zero steps;
2971
+ // * no group of our own when one of ours is already open (a screenshot inside
2972
+ // run_batch, or the dispatcher's group around another op): AE's groups do
2973
+ // not nest, and an inner endUndoGroup would close the outer one and leave
2974
+ // the rest of that batch ungrouped. The borrow joins the step already open;
2975
+ // * the group goes through withUndo, so it opens and closes inside this one
2976
+ // evalScript call and is counted by __beginUndoGroup like every other.
2977
+ // The ops stay `noUndo` at the dispatcher for exactly this reason: the decision
2978
+ // depends on the comp's state, which dispatch() cannot see.
2979
+ var __SCREENSHOT_UNDO_NAME = "AE MCP: screenshot";
2980
+
2981
+ function __screenshotGroup(needed, fn) {
2982
+ if (!needed || __UNDO_OPEN) return fn();
2983
+ return withUndo(__SCREENSHOT_UNDO_NAME, fn);
2984
+ }
2985
+
2809
2986
  // Several times in one call. Every requested time gets an entry, in order,
2810
2987
  // whether or not it rendered — the panel draws a marked block for the ones that
2811
2988
  // did not, so the sheet it composes still lines up with the times that were
@@ -2820,18 +2997,22 @@ function __contactSheetFrames(comp, args) {
2820
2997
  ds = __clampDownsample(args.downsample);
2821
2998
  }
2822
2999
  var tiles = [];
2823
- for (var i = 0; i < times.length; i++) {
2824
- var entry = { time: times[i], downsample: ds };
2825
- try {
2826
- var p = __tmpPngPath();
2827
- __saveFrameAt(comp, times[i], new File(p), ds);
2828
- entry.path = p;
2829
- } catch (e) {
2830
- // One time that will not render must not cost the other five.
2831
- entry.error = String(e && e.message ? e.message : e);
3000
+ // Every tile renders at the same factor, so the whole sheet is one undo step
3001
+ // at most rather than two per tile.
3002
+ __screenshotGroup(!__factorIs(comp.resolutionFactor, __factorFor(ds)), function () {
3003
+ for (var i = 0; i < times.length; i++) {
3004
+ var entry = { time: times[i], downsample: ds };
3005
+ try {
3006
+ var p = __tmpPngPath();
3007
+ __saveFrameAt(comp, times[i], new File(p), ds);
3008
+ entry.path = p;
3009
+ } catch (e) {
3010
+ // One time that will not render must not cost the other five.
3011
+ entry.error = String(e && e.message ? e.message : e);
3012
+ }
3013
+ tiles.push(entry);
2832
3014
  }
2833
- tiles.push(entry);
2834
- }
3015
+ });
2835
3016
  return {
2836
3017
  contactSheet: true,
2837
3018
  tiles: tiles,
@@ -2849,7 +3030,9 @@ OPS.screenshot_frame = noUndo(function (args) {
2849
3030
  var path = __tmpPngPath();
2850
3031
  var f = new File(path);
2851
3032
  // saveFrameToPng is async-ish; the panel polls the file's existence/size.
2852
- __saveFrameAt(c, t, f, ds);
3033
+ __screenshotGroup(!__factorIs(c.resolutionFactor, __factorFor(ds)), function () {
3034
+ __saveFrameAt(c, t, f, ds);
3035
+ });
2853
3036
  return {
2854
3037
  path: path,
2855
3038
  width: c.width, height: c.height,
@@ -2862,26 +3045,35 @@ OPS.screenshot_layer = noUndo(function (args) {
2862
3045
  var c = getCompById(args.compId);
2863
3046
  var l = getLayerById(c, args.layerId);
2864
3047
  var t = (args.time !== undefined && args.time !== null) ? args.time : c.time;
2865
- // Capture all current solo states; solo target; capture; restore.
2866
- var prevSolo = [];
3048
+ // Solo the target alone, render, put every switch back. Only the switches
3049
+ // that are actually wrong get assigned: each assignment is an undo step of
3050
+ // its own, and a layer already in the right state has nothing to restore.
3051
+ var changed = [];
2867
3052
  for (var i = 1; i <= c.numLayers; i++) {
2868
3053
  var ll = c.layer(i);
2869
- prevSolo.push({ idx: i, solo: ll.solo });
2870
- ll.solo = false;
3054
+ var want = (ll.id === l.id);
3055
+ if (ll.solo !== want) changed.push({ layer: ll, was: ll.solo, want: want });
2871
3056
  }
2872
- l.solo = true;
2873
3057
  var ds = __resolveDownsample(c, args.downsample);
2874
3058
  var path = __tmpPngPath();
2875
3059
  var f = new File(path);
2876
- try {
2877
- __saveFrameAt(c, t, f, ds);
2878
- } finally {
2879
- // restore
2880
- l.solo = false;
2881
- for (var j = 0; j < prevSolo.length; j++) {
2882
- try { c.layer(prevSolo[j].idx).solo = prevSolo[j].solo; } catch (e) {}
3060
+ var needed = changed.length > 0 || !__factorIs(c.resolutionFactor, __factorFor(ds));
3061
+ __screenshotGroup(needed, function () {
3062
+ // Inside the try, so a switch AE refuses part-way still restores the ones
3063
+ // that were already flipped.
3064
+ var applied = 0;
3065
+ try {
3066
+ for (var k = 0; k < changed.length; k++) {
3067
+ changed[k].layer.solo = changed[k].want;
3068
+ applied = k + 1;
3069
+ }
3070
+ __saveFrameAt(c, t, f, ds);
3071
+ } finally {
3072
+ for (var j = applied - 1; j >= 0; j--) {
3073
+ try { changed[j].layer.solo = changed[j].was; } catch (e) {}
3074
+ }
2883
3075
  }
2884
- }
3076
+ });
2885
3077
  return {
2886
3078
  path: path,
2887
3079
  width: c.width, height: c.height,
@@ -4956,9 +5148,10 @@ function __serializeTransformGroup(tg, opts) {
4956
5148
  return out;
4957
5149
  }
4958
5150
 
4959
- // The cap is applied after the fact rather than inside __serializeEffect,
4960
- // which effects.jsx also uses for list_effects and add_effect — those return
4961
- // one effect and have no size problem to solve.
5151
+ // The keyframe cap is applied after the fact rather than inside
5152
+ // __serializeEffect, which effects.jsx also uses for list_effects and
5153
+ // add_effect. The parameter cap (maxParams/paramFilter, issue #115) is applied
5154
+ // inside it, by all three.
4962
5155
  function __capEffectKeyframes(effects, max) {
4963
5156
  if (!(max > 0)) return effects;
4964
5157
  for (var i = 0; i < effects.length; i++) {
@@ -4986,7 +5179,8 @@ function __serializeEffects(layer, opts) {
4986
5179
  var fx = layer.property("Effects");
4987
5180
  if (!fx || fx.numProperties === 0) return [];
4988
5181
  var arr = [];
4989
- for (var i = 1; i <= fx.numProperties; i++) arr.push(__serializeEffect(fx.property(i)));
5182
+ var popts = (opts && opts.effectParams) ? opts.effectParams : __effectParamOpts(null);
5183
+ for (var i = 1; i <= fx.numProperties; i++) arr.push(__serializeEffect(fx.property(i), popts));
4990
5184
  return __capEffectKeyframes(arr, (opts && opts.maxKeyframes) ? opts.maxKeyframes : 0);
4991
5185
  }
4992
5186
 
@@ -5276,6 +5470,8 @@ OPS.get_layer_full = noUndo(function (args) {
5276
5470
  // absent by default, and absent means "everything", exactly as before.
5277
5471
  var want = (args && args.include) ? args.include : null;
5278
5472
  var opts = { maxKeyframes: (args && args.maxKeyframes > 0) ? args.maxKeyframes : 0 };
5473
+ // Effect parameters are the exception: bounded by default (issue #115).
5474
+ if (__wantsSection(want, "effects")) opts.effectParams = __effectParamOpts(args);
5279
5475
  var out = {
5280
5476
  id: l.id,
5281
5477
  index: l.index,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@engineroom/ae-panel",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "private": true,
5
5
  "description": "Invisible CEP extension hosting an HTTP+WS bridge inside After Effects.",
6
6
  "dependencies": {