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

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
@@ -468,7 +468,11 @@ var GetLayerFull = z2.object({
468
468
  includeChildren: z2.boolean().default(false).optional(),
469
469
  include: includeParam(["transform", "effects", "masks", "markers", "bounds", "text", "shape", "source"], "the layer header alone"),
470
470
  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."),
471
- 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.")
471
+ 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."),
472
+ // Not a member of `include`: that list's contract is that omitting it returns
473
+ // everything, and materials have to be off unless asked for.
474
+ shapeMaterials: z2.boolean().default(false).optional().describe("Include each shape group's Material Options \u2014 the 48-property 3D extrusion block. Off by default: it applies only to an extruded shape under the Cinema 4D renderer, and on an ordinary 2D shape layer it is most of the bytes of the read. What was skipped is counted in the response."),
475
+ shapeDetail: z2.enum(["full", "compact"]).optional().describe("How to serialize a shape layer's Contents. 'full' (default) is one JSON node per property. 'compact' is one indented line per group with that group's own properties folded onto it as name=value \u2014 several times smaller, and enough to see what a layer is made of and address its nodes by name.")
472
476
  });
473
477
  var CreateTextLayer = z2.object({
474
478
  compId: z2.number(),
@@ -1332,7 +1336,7 @@ var descriptions = {
1332
1336
  set_active_comp: "Focus a comp in the viewer/timeline.",
1333
1337
  // ---------- layers ----------
1334
1338
  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.",
1335
- 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.",
1339
+ 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.",
1336
1340
  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.",
1337
1341
  create_shape_layer: "Empty shape layer; fill via add_shape_content.",
1338
1342
  create_solid_layer: "Solid-color layer. color is RGB 0..1.",
@@ -1390,7 +1394,7 @@ var descriptions = {
1390
1394
  get_project_summary: "Project state: path, item count, active item, flat item list with type (comp | footage | solid | folder | unknown \u2014 same vocabulary as a layer's sourceType).",
1391
1395
  find_layers: "Search across one or all comps for layers matching name/type/effect filters.",
1392
1396
  // ---------- raw ----------
1393
- run_jsx: 'Escape hatch: arbitrary ExtendScript in an undo group. `comp`/`app`/`OPS`/helpers in scope. `return X` sends a value back \u2014 arrays and nested objects come back whole. Anything that cannot be JSON is replaced in place by a marker string, never dropped: `"[function]"`, `"[undefined]"`, `"[circular]"`, `"[max depth]"`, `"[NaN]"`, and live AE objects as `"[CompItem \\"Main\\" #12]"` \u2014 a handle to pass to a real read tool, not a walk of the object. An empty result therefore means the script really returned nothing. AE refuses copyToComp for a layer with a parent or a linked expression while an undo group is open: call `withoutUndoGroup(function(){ \u2026 })` around just that part, or pass undoGroup:false for the whole script (its changes then land as whatever undo steps AE records on its own, not one). Keep loops short \u2014 ExtendScript is single-threaded and freezes the user\'s UI.',
1397
+ run_jsx: 'Escape hatch: arbitrary ExtendScript in an undo group. `comp`/`app`/`OPS`/helpers in scope. `return X` sends a value back \u2014 arrays and nested objects come back whole. Anything that cannot be JSON is replaced in place by a marker string, never dropped: `"[function]"`, `"[undefined]"`, `"[circular]"`, `"[max depth]"`, `"[NaN]"`, and live AE objects as `"[CompItem \\"Main\\" #12]"` \u2014 a handle to pass to a real read tool, not a walk of the object. An empty result therefore means the script really returned nothing. A script with no explicit `return` \u2014 including one ending in a bare expression, which does NOT return its value \u2014 comes back as `{ok:true, returned:null, undoGroup, note}`. That means it ran to completion; it did **not** fail, so do not re-run it. Nothing rolls back, so re-running a mutating script applies it twice. AE refuses copyToComp for a layer with a parent or a linked expression while an undo group is open: call `withoutUndoGroup(function(){ \u2026 })` around just that part, or pass undoGroup:false for the whole script (its changes then land as whatever undo steps AE records on its own, not one). Keep loops short \u2014 ExtendScript is single-threaded and freezes the user\'s UI.',
1394
1398
  // ---------- footage ----------
1395
1399
  import_footage: "Import a file (video, image, audio, SVG, PSD/AI) into the project. Returns the item id \u2014 pass it to create_footage_layer to place it. Validates what AE actually produced: an SVG whose viewBox asks for one aspect ratio and imports at another is a known AE bug that renders as nothing with no error, so the item is deleted and the call throws with the workaround. `force:true` keeps it and reports the problem in `validation` instead.",
1396
1400
  create_footage_layer: "Place an imported project item into a comp as a layer. Takes the itemId from import_footage or get_project_summary. For a comp use create_precomp_layer instead.",
@@ -1934,7 +1938,7 @@ var GUIDES = [
1934
1938
  {
1935
1939
  name: "after-effects",
1936
1940
  description: "How to drive Adobe After Effects well through the AE MCP tools \u2014 orienting in a project, building and animating layers, keyframes and easing, expressions, effects, text and shapes, and the gotchas that silently produce wrong output. Load whenever a task involves After Effects, motion graphics, comps, layers, or keyframes.",
1937
- body: '# Driving After Effects\n\nYou have direct control of a live After Effects session. The user sees every change immediately, and every tool call is a real undo step in their project. Work like a motion designer at the keyboard, not like a script that fires blind.\n\n## Read the house style first\n\n`get_house_style` returns the style guide for the project that is currently open\n\u2014 palette, type, motion defaults, layout rules \u2014 read from `house-style.md`\nsitting next to the `.aep` file. Call it once at the start of any build task and\nfollow what it says. It costs one cheap call and it is the difference between\nwork that matches everything else the user has made and work that does not.\n\nIf it reports `found: false`, build with sensible defaults and offer once, at the\nend, to capture a style guide from what you just made. Don\'t nag about it.\n\n## Orient before you touch anything\n\nNever guess at project state. Cheap reads exist for exactly this:\n\n| Question | Tool |\n|---|---|\n| What\'s in this project? | `get_project_summary` |\n| What comps exist? | `list_comps` |\n| What\'s in this comp? | `get_comp_tree` |\n| Everything about one layer | `get_layer_full` \u2B50 |\n| Where is a layer, by name/type/effect? | `find_layers` |\n\n`get_layer_full` is the one to reach for. It returns transforms **with their keyframes and expressions**, effects with every parameter, masks, markers, and `sourceRect` (the layer\'s visible bounds) in a single call. Prefer one `get_layer_full` over four narrow queries \u2014 it is faster and it shows you context you did not know to ask for.\n\n### Ask for what you need\n\nA tool result stays in your context for the rest of the session, so a read you cannot bound is paid for on every later call. All of these reads take an `include` list:\n\n- `list_comps` / `list_layers` with `include: []` return the id-to-name map alone, which is what orientation actually needs.\n- `get_layer_full` takes `include` (`transform`, `effects`, `masks`, `markers`, `bounds`, `text`, `shape`, `source`), plus `maxKeyframes` to cap the keyframes per property and `shapeDepth` to limit the Contents walk on a heavy shape layer.\n\nOmit them all and you get everything, as before. Whatever they leave out is named and counted in the response \u2014 a bounded read never looks like a complete one.\n\n## Identify things by ID, never by index\n\nEvery comp and layer has a stable numeric `id`. Layer `index` is a 1-based position that **shifts whenever layers are added, deleted, or reordered**. Store `(compId, layerId)` and pass those. An index captured before a `create_*` call may point at a different layer by the time you use it.\n\nThe same trap bites inside `run_jsx`: a `comp.layer(1)` wrapper is index-bound, not a handle. After a `copyToComp` inserts the copy at index 1, a reference you took earlier silently resolves to the *new* layer \u2014 which is how a script ends up parenting a layer to itself. Re-resolve by id or name after anything that inserts a layer.\n\n## Read, then write, then verify\n\n1. Read the current state (`get_layer_full`).\n2. Make the change.\n3. Verify by reading back the properties \u2014 not by screenshotting.\n\nProperty values are the ground truth. A screenshot tells you something *looks* wrong; `get_layer_full` tells you *why*.\n\n## Screenshots are a diagnostic, not a feedback loop\n\n`screenshot_frame` and `screenshot_layer` are **one-off checks**. Do not screenshot every frame, do not scrub through time, do not screenshot after every edit.\n\n- Take at most 2\u20133 across an animation \u2014 typically start, middle, end.\n- **The `downsample` is picked from the comp size** unless you pass one \u2014 2 at 1080p, 3 at 4K, aiming at a long edge around 1280px. Pass `downsample: 1` only when you genuinely need full resolution: a full 4K frame is large enough to blow out your context in one call.\n- The result reports the dimensions actually returned and the factor actually applied \u2014 trust those numbers rather than assuming.\n- **Space them out.** Rapid back-to-back requests are far more likely to come back stale than requests a few seconds apart.\n\nTwo results are not images, and both are information rather than something to retry blindly:\n\n- **`Stale frame` (an error)** \u2014 After Effects returned the pixels it had already rendered for a *different* request, which the error names. Pause a few seconds and retry with a higher `downsample`; `6` has worked where `3`\u2013`4` stayed stale. If it repeats, read the keyframes instead.\n- **`empty: true`** \u2014 every pixel at that time is fully transparent, so no image was sent. That is a fact about the composition: usually the wrong time, a layer outside its in/out points, disabled, or at zero opacity.\n\n**Never disable layers to make a screenshot render.** A frame that will not render is a limit of the panel\'s render path, not project content that needs fixing \u2014 and it is very easy to leave someone\'s comp switched off afterwards.\n\nTo check motion, read the keyframe values. That is exact; a picture is not.\n\n## Bulk work goes through run_batch\n\nBuilding 40 layers with 40 separate calls is slow and produces 40 undo steps. `run_batch` runs many ops in one ExtendScript pass as a **single undo step**, which is also what the user expects when they ask to undo "that thing you just built".\n\n- `transactional: true` (the default) rolls back the whole batch on the first error.\n- Over 500 ops it returns a `jobId` and streams progress; call `await_job(jobId)` for the final result.\n\n## Keyframes and easing\n\n`add_keyframe` sets a value at a time. Interpolation is separate:\n\n- `set_interpolation` \u2014 linear / bezier / hold, per keyframe, in and out.\n- `set_temporal_ease` \u2014 influence and speed, the "easy ease" controls.\n- `set_spatial_tangents` \u2014 the shape of a motion path through a position keyframe.\n\n**The array-size trap.** `set_temporal_ease` wants one ease entry *per dimension* for ordinary multi-dimensional properties (Scale, Color), but exactly **one** entry for spatial properties (Position, Anchor Point) regardless of whether the layer is 2D or 3D \u2014 because the ease applies along the motion path, not per axis. If you see `Value array does not have 1 elements`, you fed a spatial property one entry per axis.\n\n## Expressions\n\n`set_expression` takes a `propertyPath` such as `["Transform","Position"]` or `["Effects","Gaussian Blur","Blurriness"]`. Expressions are ExtendScript-flavoured JavaScript evaluated by AE per frame.\n\nExpressions are usually a better answer than dense keyframes for anything procedural \u2014 wiggle, loops, counters, follow-through, time remapping. They stay editable by the user afterwards, where a wall of baked keyframes does not.\n\nUse `get_expression` to read one back and `toggle_expression` to disable without deleting.\n\n## Effects\n\nEffects are added by **matchName**, not display name: `add_effect({matchName: "ADBE Gaussian Blur 2"})`. If you do not know a matchName, call `list_available_effects({filter: "blur"})` \u2014 do not guess. `list_effects` shows what is already on a layer, with every parameter.\n\nSet parameters with `set_effect_param` by parameter name (e.g. `"Blurriness"`).\n\n**Never enumerate `app.effects` yourself in `run_jsx`.** There are around 250 of them and reading the table is slow enough to block the bridge past its timeout, which looks exactly like a crash and costs a minute of everyone\'s time. `list_available_effects` does the same enumeration once and caches it for the session, so `filter` searches are free after the first call. A wrong matchName also fails instantly and clearly, so trying `ADBE Slider Control` is cheaper than searching for it.\n\n## Text\n\n`create_text_layer` defaults to `anchorAlign: "left"`, which sets **paragraph justification** and leaves the anchor point at `[0,0]`, so `position` is the start of the first baseline. Pass `"center"` or `"right"` for those, `"none"` for AE\'s raw behaviour. Because the alignment is justification rather than a measured offset, it stays correct when the text changes later \u2014 retyped, driven by an expression, or edited through Essential Graphics in Premiere. Never "fix" alignment by writing an anchor point computed from `sourceRectAtTime()`: it is right once and wrong from the next edit onward.\n\nTracking is set to `0` unless you pass one, because AE\'s `addText()` otherwise inherits whatever the user\'s Character panel was last left on.\n\n`set_text` controls font, size, colour, tracking, leading and justification. To auto-fit a background to text, read `sourceRect` from `get_layer_full` and size the shape from its width and height plus padding.\n\n## Shapes\n\n`add_shape_content` builds one node at a time under `Contents` \u2014 `rect`, `ellipse`, `star`, `path`, `fill`, `stroke`, `trim`, `repeater`, `merge`, `group`. Properties are set with friendly names in the same call (`size`, `position`, `roundness`, `color`, `width`, `lineCap`, \u2026).\n\nThis tool is **all-or-nothing**: if a key cannot be applied, the whole node is removed and you get an error naming the bad key. A success result therefore means everything landed. Don\'t add defensive re-reads for it, but do read the error carefully \u2014 it usually means the property is named differently on that node type, and `get_layer_full` will show you the real name.\n\nFor a custom path, use `{type: "path", vertices: [[x,y], \u2026], closed: true}`. The key is `vertices`, not `points`.\n\n**Render order is the opposite of the layer stack.** Inside `Contents`, index 1 renders in *front*, and each `add_shape_content` call appends behind the previous one. So build **front-to-back**: details, text plates and traffic-light dots first, the big background rectangle last. Getting it backwards is silent \u2014 no error, just a solid slab where your artwork should be. `zOrder: "front"` will place a node at index 1 for you, but it needs an internal `moveTo`, which has been seen to disturb *nested* renders of the comp in AE 26.3; prefer ordering your calls. If an existing layer is already in the wrong order, rebuild it rather than reordering, and verify with a screenshot of a comp that **nests** it, not just the comp that owns it.\n\n**Node references go stale.** Adding a sibling to a group invalidates a reference you already hold to another node in it \u2014 add a Stroke and an earlier Fill reference starts throwing `Object is invalid`. Add every node first, then set values and expressions by addressing nodes by name.\n\n## The escape hatch\n\n`run_jsx` executes arbitrary ExtendScript with `app`, `comp`, `OPS` and the helper functions in scope. Reach for it when a needed operation has no tool \u2014 duplicating a comp, driving the render queue, batch-renaming.\n\nExtendScript is **single-threaded**, so a long synchronous loop freezes the user\'s AE UI. Keep the script short.\n\n`return X` sends the whole value back \u2014 arrays and nested objects included. Values that cannot be represented (functions, live AE objects, cycles) come back as a marker string in place, never dropped \u2014 a live object as `"[AVLayer \\"Hero\\" #616]"`, which is a handle to pass to `get_layer_full`, not a copy of the layer. So an empty result genuinely means the script returned nothing; never read one as "nothing happened".\n\nAE refuses `copyToComp` for a layer with a parent or a linked expression **while an undo group is open**, which is exactly the rig you wanted to copy. Wrap that one call in `withoutUndoGroup(function () { \u2026 })`, or pass `undoGroup: false` for the whole script. Nothing rolls back on error, so a script that fails halfway leaves its earlier changes applied \u2014 read the state back before re-running one that mutates.\n\nSet the parent first and the transform after, never the reverse. `parent_layer` keeps the layer where it is; raw `layer.parent = x` inside a script does not do so reliably two levels deep, so after scripted parenting audit scale and rotation as well as position.\n\n### Exporting a Motion Graphics template\n\nUse **`export_mogrt`**. Do not drive `comp.exportAsMotionGraphicsTemplate` from `run_jsx` \u2014 the tool exists because that call raises modal dialogs, and a modal dialog freezes this whole connection until someone clicks it in After Effects.\n\n`export_mogrt` handles all of it: it saves the project first (which is what removes AE\'s "the project needs to be saved" prompt, and it has to happen per export because exporting dirties the project again), it suppresses the font warning, and it runs outside the undo group so there is no "undo group mismatch" afterwards. Measured on 26.3: suppressed, an export of a comp using a non-Adobe font returns in about three seconds; unsuppressed, the same export sat past sixty and wrote nothing until the dialog was clicked.\n\nThree things worth knowing before you call it:\n\n- **The project must have been saved once, by hand.** There is no folder to save into otherwise, and the tool refuses rather than raising a dialog the user was not expecting.\n- **`name` is the filename.** It defaults to the comp name, because AE\'s own default is the literal `Untitled` \u2014 leave it to AE and every template in the project overwrites the same file.\n- **`fonts` in the result lists what the template will require.** Tell the user about any non-Adobe ones: Premiere flags the template as needing fonts it cannot supply, and that is worth hearing from you rather than discovering later.\n\n**The thumbnail.** AE writes the comp\'s *first frame* into the template, so anything that fades up from nothing gets a black one. Pass `posterTime` with a moment that actually shows the design and it is rendered and swapped in. If only the thumbnail fails the export still succeeds \u2014 check `thumbnail.patched` in the result.\n\nAlso note that `comp.setMotionGraphicsControllerName(index, \u2026)` numbers controllers in **reverse order of addition**: index 1 is the one you added last.\n\n**If any long call seems to have hung, assume a dialog before you assume a crash** \u2014 it may be behind another window. `comp.saveFrameToPng(...)` from `run_jsx` raises the save prompt the same way; use `screenshot_frame`, which does not.\n\n### Importing footage, and the SVG trap\n\nUse **`import_footage`**, then **`create_footage_layer`** to place the item in a comp. (For a comp as a layer, `create_precomp_layer`.)\n\n`import_footage` checks what AE actually produced, because one case fails silently: an SVG with a very large `viewBox` (say `0 0 278050 333334`) imports with **fabricated dimensions and renders as nothing**, no error at any stage. Verified on 26.3 \u2014 that viewBox yields a 15906x5654 item that will not even rasterize. The tool compares the aspect ratio the file asks for against the one AE produced, and on a mismatch it deletes the item and throws, rather than handing you an asset that looks healthy in the project panel and renders empty.\n\nIf you hit that, the workarounds are:\n\n- **Simple flat SVGs** \u2014 rebuild the path as a shape layer with the real vertices, scaled down to a sane coordinate space (divide by `333.334` for a 1000px version), set the fill from the SVG, and set `ADBE Vector Fill Rule` to `2` when the SVG says `fill-rule="evenodd"`. Done this way the result is pixel-accurate.\n- **Complex SVGs** \u2014 rasterise to PNG outside AE, or normalise the `viewBox` to a small coordinate space before importing.\n\n`force: true` keeps the item and reports the problem in `validation` instead of throwing. It is for when you know the dimensions are wrong and want it anyway \u2014 not a way past the error.\n\n## When something costs you real time\n\nThese tools have rough edges, and the same ones catch every session. Two tools\nexist so that each one is only paid for once.\n\n**`list_known_issues`** \u2014 what earlier sessions hit and how they got past it.\nRead it when a tool fails in a way you do not immediately understand, before you\nstart guessing. The answer is often already there. It comes back as a one-line\nindex, so open the entry that looks like your failure with\n`list_known_issues({id})` \u2014 the cause and the workaround are in the entry, not in\nthe index. `tool` and `query` narrow it further.\n\n**`log_issue`** \u2014 write down what you worked out, the moment you work it out.\n\nLog something when all three are true: it cost real effort, it was the tool\'s\nfault rather than yours, and the next session would hit it too. A schema that\naccepts an argument AE then rejects, an error message that names the wrong\nthing, a property whose real name is nothing like its display name. Not your own\ntypos. Not "I forgot the layer was 3D".\n\nWrite the entry for someone who has not seen the failure: the exact error text,\nthe call that produced it, and a workaround concrete enough to apply directly.\nReuse the existing title when you are extending an entry \u2014 that keeps one good\nrecord instead of five thin ones.\n\n### Then offer to pass it on\n\nIf `log_issue` comes back with `reported: false`, mention it to the user \u2014 but\nfinish the actual work first, and put it at the very end, after you have told\nthem what you built. It is a footnote, not the headline.\n\nSay it the way you would to a colleague who does not write code. What you were\ntrying to do, that it fought back, that you got there anyway, and that you can\nsend it to the people who maintain the tool so the next person does not lose the\nsame time. Something like:\n\n> Done \u2014 the lower third is in. One thing worth mentioning: getting the ease\n> onto that position keyframe took a lot longer than it should have, because the\n> tool kept rejecting a value it had just asked for. I found a way around it and\n> made a note. Want me to send it to the people who maintain this so they can\n> fix it properly?\n\nDo not say "GitHub issue", "file a bug" or "open a ticket" unless they say it\nfirst. If they say yes, use the **report-ae-issue** prompt this server provides\n(`/report-ae-issue` where your client exposes prompts as commands) \u2014 it handles\nthe rest. If they say no, drop it; the note stays and can be offered again\nanother time.\n\nNever claim you have reported something you have not.\n\n## When something is not connected\n\nIf a tool reports it cannot reach After Effects, call `check_setup` and relay its `nextSteps` to the user in plain language. Do not try to diagnose CEP by hand.\n\n**A timeout is not proof the bridge is dead.** The error that says the panel did not answer in time is a different thing from the one that says the panel cannot be reached. Because ExtendScript is single-threaded, a busy After Effects cannot answer anything \u2014 so a long script, or a modal dialog nobody has clicked, is indistinguishable from a crash at this layer. It normally recovers on its own within a minute.\n\nSo when a call times out: do not re-send it (you would queue the same work twice), do not restart After Effects, and do not run `setup_panel`. Poll `check_setup` for about a minute first. Two causes worth asking about directly:\n\n- **A dialog is waiting.** Ask the user to check After Effects for a prompt hiding behind another window.\n- **They changed desktop.** On macOS, calls have been reported to stall while the user is on a different Space and to complete as soon as they return. If they have wandered off, ask them to switch back to the desktop After Effects is on before you diagnose anything else.\n\nIf a specific operation of yours legitimately needs longer than the limit, the user can raise it by setting `AE_MCP_OP_TIMEOUT_MS` in the server\'s environment.'
1941
+ body: '# Driving After Effects\n\nYou have direct control of a live After Effects session. The user sees every change immediately, and every tool call is a real undo step in their project. Work like a motion designer at the keyboard, not like a script that fires blind.\n\n## Read the house style first\n\n`get_house_style` returns the style guide for the project that is currently open\n\u2014 palette, type, motion defaults, layout rules \u2014 read from `house-style.md`\nsitting next to the `.aep` file. Call it once at the start of any build task and\nfollow what it says. It costs one cheap call and it is the difference between\nwork that matches everything else the user has made and work that does not.\n\nIf it reports `found: false`, build with sensible defaults and offer once, at the\nend, to capture a style guide from what you just made. Don\'t nag about it.\n\n## Orient before you touch anything\n\nNever guess at project state. Cheap reads exist for exactly this:\n\n| Question | Tool |\n|---|---|\n| What\'s in this project? | `get_project_summary` |\n| What comps exist? | `list_comps` |\n| What\'s in this comp? | `get_comp_tree` |\n| Everything about one layer | `get_layer_full` \u2B50 |\n| Where is a layer, by name/type/effect? | `find_layers` |\n\n`get_layer_full` is the one to reach for. It returns transforms **with their keyframes and expressions**, effects with every parameter, masks, markers, and `sourceRect` (the layer\'s visible bounds) in a single call. Prefer one `get_layer_full` over four narrow queries \u2014 it is faster and it shows you context you did not know to ask for.\n\n### Ask for what you need\n\nA tool result stays in your context for the rest of the session, so a read you cannot bound is paid for on every later call. All of these reads take an `include` list:\n\n- `list_comps` / `list_layers` with `include: []` return the id-to-name map alone, which is what orientation actually needs.\n- `get_layer_full` takes `include` (`transform`, `effects`, `masks`, `markers`, `bounds`, `text`, `shape`, `source`), plus `maxKeyframes` to cap the keyframes per property and `shapeDepth` to limit the Contents walk on a heavy shape layer.\n\nOmit them all and you get everything, as before. Whatever they leave out is named and counted in the response \u2014 a bounded read never looks like a complete one.\n\n**Reading a shape layer, use `shapeDetail: "compact"`.** It returns one indented line per group \u2014 the group\'s name, its matchName, then its own properties as `name=value`, with `[3 keys]` or `[expr]` on the animated ones and `(at defaults)` for a group Transform nobody has touched. Every name the write tools address a node by is still on the line, and it costs a fraction of the full JSON form. Reach for `"full"` when you need exact values, keyframe detail or indices.\n\nOne thing is left out of both forms: **Material Options**, the 48-property 3D extrusion block AE hangs off every vector group. It only means anything for an extruded shape under the Cinema 4D renderer, and on the 2D shape layers that are nearly all of them it was most of the weight of the read \u2014 a single 68px circle cost 4,400 tokens, of which the geometry was about 40. `materialsOmitted` counts what was skipped; `shapeMaterials: true` brings it back.\n\n## Identify things by ID, never by index\n\nEvery comp and layer has a stable numeric `id`. Layer `index` is a 1-based position that **shifts whenever layers are added, deleted, or reordered**. Store `(compId, layerId)` and pass those. An index captured before a `create_*` call may point at a different layer by the time you use it.\n\nThe same trap bites inside `run_jsx`: a `comp.layer(1)` wrapper is index-bound, not a handle. After a `copyToComp` inserts the copy at index 1, a reference you took earlier silently resolves to the *new* layer \u2014 which is how a script ends up parenting a layer to itself. Re-resolve by id or name after anything that inserts a layer.\n\n## Read, then write, then verify\n\n1. Read the current state (`get_layer_full`).\n2. Make the change.\n3. Verify by reading back the properties \u2014 not by screenshotting.\n\nProperty values are the ground truth. A screenshot tells you something *looks* wrong; `get_layer_full` tells you *why*.\n\n## Screenshots are a diagnostic, not a feedback loop\n\n`screenshot_frame` and `screenshot_layer` are **one-off checks**. Do not screenshot every frame, do not scrub through time, do not screenshot after every edit.\n\n- Take at most 2\u20133 across an animation \u2014 typically start, middle, end.\n- **The `downsample` is picked from the comp size** unless you pass one \u2014 2 at 1080p, 3 at 4K, aiming at a long edge around 1280px. Pass `downsample: 1` only when you genuinely need full resolution: a full 4K frame is large enough to blow out your context in one call.\n- The result reports the dimensions actually returned and the factor actually applied \u2014 trust those numbers rather than assuming.\n- **Space them out.** Rapid back-to-back requests are far more likely to come back stale than requests a few seconds apart.\n\nTwo results are not images, and both are information rather than something to retry blindly:\n\n- **`Stale frame` (an error)** \u2014 After Effects returned the pixels it had already rendered for a *different* request, which the error names. Pause a few seconds and retry with a higher `downsample`; `6` has worked where `3`\u2013`4` stayed stale. If it repeats, read the keyframes instead.\n- **`empty: true`** \u2014 every pixel at that time is fully transparent, so no image was sent. That is a fact about the composition: usually the wrong time, a layer outside its in/out points, disabled, or at zero opacity.\n\n**Never disable layers to make a screenshot render.** A frame that will not render is a limit of the panel\'s render path, not project content that needs fixing \u2014 and it is very easy to leave someone\'s comp switched off afterwards.\n\nTo check motion, read the keyframe values. That is exact; a picture is not.\n\n## Bulk work goes through run_batch\n\nBuilding 40 layers with 40 separate calls is slow and produces 40 undo steps. `run_batch` runs many ops in one ExtendScript pass as a **single undo step**, which is also what the user expects when they ask to undo "that thing you just built".\n\n- `transactional: true` (the default) rolls back the whole batch on the first error.\n- Over 500 ops it returns a `jobId` and streams progress; call `await_job(jobId)` for the final result.\n\n## Keyframes and easing\n\n`add_keyframe` sets a value at a time. Interpolation is separate:\n\n- `set_interpolation` \u2014 linear / bezier / hold, per keyframe, in and out.\n- `set_temporal_ease` \u2014 influence and speed, the "easy ease" controls.\n- `set_spatial_tangents` \u2014 the shape of a motion path through a position keyframe.\n\n**The array-size trap.** `set_temporal_ease` wants one ease entry *per dimension* for ordinary multi-dimensional properties (Scale, Color), but exactly **one** entry for spatial properties (Position, Anchor Point) regardless of whether the layer is 2D or 3D \u2014 because the ease applies along the motion path, not per axis. If you see `Value array does not have 1 elements`, you fed a spatial property one entry per axis.\n\n## Expressions\n\n`set_expression` takes a `propertyPath` such as `["Transform","Position"]` or `["Effects","Gaussian Blur","Blurriness"]`. Expressions are ExtendScript-flavoured JavaScript evaluated by AE per frame.\n\nExpressions are usually a better answer than dense keyframes for anything procedural \u2014 wiggle, loops, counters, follow-through, time remapping. They stay editable by the user afterwards, where a wall of baked keyframes does not.\n\nUse `get_expression` to read one back and `toggle_expression` to disable without deleting.\n\n## Effects\n\nEffects are added by **matchName**, not display name: `add_effect({matchName: "ADBE Gaussian Blur 2"})`. If you do not know a matchName, call `list_available_effects({filter: "blur"})` \u2014 do not guess. `list_effects` shows what is already on a layer, with every parameter.\n\nSet parameters with `set_effect_param` by parameter name (e.g. `"Blurriness"`).\n\n**Never enumerate `app.effects` yourself in `run_jsx`.** There are around 250 of them and reading the table is slow enough to block the bridge past its timeout, which looks exactly like a crash and costs a minute of everyone\'s time. `list_available_effects` does the same enumeration once and caches it for the session, so `filter` searches are free after the first call. A wrong matchName also fails instantly and clearly, so trying `ADBE Slider Control` is cheaper than searching for it.\n\n## Text\n\n`create_text_layer` defaults to `anchorAlign: "left"`, which sets **paragraph justification** and leaves the anchor point at `[0,0]`, so `position` is the start of the first baseline. Pass `"center"` or `"right"` for those, `"none"` for AE\'s raw behaviour. Because the alignment is justification rather than a measured offset, it stays correct when the text changes later \u2014 retyped, driven by an expression, or edited through Essential Graphics in Premiere. Never "fix" alignment by writing an anchor point computed from `sourceRectAtTime()`: it is right once and wrong from the next edit onward.\n\nTracking is set to `0` unless you pass one, because AE\'s `addText()` otherwise inherits whatever the user\'s Character panel was last left on.\n\n`set_text` controls font, size, colour, tracking, leading and justification. To auto-fit a background to text, read `sourceRect` from `get_layer_full` and size the shape from its width and height plus padding.\n\n## Shapes\n\n`add_shape_content` builds one node at a time under `Contents` \u2014 `rect`, `ellipse`, `star`, `path`, `fill`, `stroke`, `trim`, `repeater`, `merge`, `group`. Properties are set with friendly names in the same call (`size`, `position`, `roundness`, `color`, `width`, `lineCap`, \u2026).\n\nThis tool is **all-or-nothing**: if a key cannot be applied, the whole node is removed and you get an error naming the bad key. A success result therefore means everything landed. Don\'t add defensive re-reads for it, but do read the error carefully \u2014 it usually means the property is named differently on that node type, and `get_layer_full` will show you the real name.\n\nFor a custom path, use `{type: "path", vertices: [[x,y], \u2026], closed: true}`. The key is `vertices`, not `points`.\n\n**Render order is the opposite of the layer stack.** Inside `Contents`, index 1 renders in *front*, and each `add_shape_content` call appends behind the previous one. So build **front-to-back**: details, text plates and traffic-light dots first, the big background rectangle last. Getting it backwards is silent \u2014 no error, just a solid slab where your artwork should be. `zOrder: "front"` will place a node at index 1 for you, but it needs an internal `moveTo`, which has been seen to disturb *nested* renders of the comp in AE 26.3; prefer ordering your calls. If an existing layer is already in the wrong order, rebuild it rather than reordering, and verify with a screenshot of a comp that **nests** it, not just the comp that owns it.\n\n**Node references go stale.** Adding a sibling to a group invalidates a reference you already hold to another node in it \u2014 add a Stroke and an earlier Fill reference starts throwing `Object is invalid`. Add every node first, then set values and expressions by addressing nodes by name.\n\n## The escape hatch\n\n`run_jsx` executes arbitrary ExtendScript with `app`, `comp`, `OPS` and the helper functions in scope. Reach for it when a needed operation has no tool \u2014 duplicating a comp, driving the render queue, batch-renaming.\n\nExtendScript is **single-threaded**, so a long synchronous loop freezes the user\'s AE UI. Keep the script short.\n\n`return X` sends the whole value back \u2014 arrays and nested objects included. Values that cannot be represented (functions, live AE objects, cycles) come back as a marker string in place, never dropped \u2014 a live object as `"[AVLayer \\"Hero\\" #616]"`, which is a handle to pass to `get_layer_full`, not a copy of the layer. So an empty result genuinely means the script returned nothing; never read one as "nothing happened".\n\n**A bare expression is not a return.** `"ping";` as the last line yields nothing, and so does any script that just does its work. That case comes back as `{ok: true, returned: null, undoGroup, note}` \u2014 an envelope that says *the script ran to completion*. Do not re-run it. Nothing rolls back, so a second run of a script that duplicated a layer, reordered content or wrote keyframes applies all of it twice; read the state back instead, and add an explicit `return` if you want a value.\n\nAE refuses `copyToComp` for a layer with a parent or a linked expression **while an undo group is open**, which is exactly the rig you wanted to copy. Wrap that one call in `withoutUndoGroup(function () { \u2026 })`, or pass `undoGroup: false` for the whole script. Nothing rolls back on error, so a script that fails halfway leaves its earlier changes applied \u2014 read the state back before re-running one that mutates.\n\nSet the parent first and the transform after, never the reverse. `parent_layer` keeps the layer where it is; raw `layer.parent = x` inside a script does not do so reliably two levels deep, so after scripted parenting audit scale and rotation as well as position.\n\n### Exporting a Motion Graphics template\n\nUse **`export_mogrt`**. Do not drive `comp.exportAsMotionGraphicsTemplate` from `run_jsx` \u2014 the tool exists because that call raises modal dialogs, and a modal dialog freezes this whole connection until someone clicks it in After Effects.\n\n`export_mogrt` handles all of it: it saves the project first (which is what removes AE\'s "the project needs to be saved" prompt, and it has to happen per export because exporting dirties the project again), it suppresses the font warning, and it runs outside the undo group so there is no "undo group mismatch" afterwards. Measured on 26.3: suppressed, an export of a comp using a non-Adobe font returns in about three seconds; unsuppressed, the same export sat past sixty and wrote nothing until the dialog was clicked.\n\nThree things worth knowing before you call it:\n\n- **The project must have been saved once, by hand.** There is no folder to save into otherwise, and the tool refuses rather than raising a dialog the user was not expecting.\n- **`name` is the filename.** It defaults to the comp name, because AE\'s own default is the literal `Untitled` \u2014 leave it to AE and every template in the project overwrites the same file.\n- **`fonts` in the result lists what the template will require.** Tell the user about any non-Adobe ones: Premiere flags the template as needing fonts it cannot supply, and that is worth hearing from you rather than discovering later.\n\n**The thumbnail.** AE writes the comp\'s *first frame* into the template, so anything that fades up from nothing gets a black one. Pass `posterTime` with a moment that actually shows the design and it is rendered and swapped in. If only the thumbnail fails the export still succeeds \u2014 check `thumbnail.patched` in the result.\n\nAlso note that `comp.setMotionGraphicsControllerName(index, \u2026)` numbers controllers in **reverse order of addition**: index 1 is the one you added last.\n\n**If any long call seems to have hung, assume a dialog before you assume a crash** \u2014 it may be behind another window. `comp.saveFrameToPng(...)` from `run_jsx` raises the save prompt the same way; use `screenshot_frame`, which does not.\n\n### Importing footage, and the SVG trap\n\nUse **`import_footage`**, then **`create_footage_layer`** to place the item in a comp. (For a comp as a layer, `create_precomp_layer`.)\n\n`import_footage` checks what AE actually produced, because one case fails silently: an SVG with a very large `viewBox` (say `0 0 278050 333334`) imports with **fabricated dimensions and renders as nothing**, no error at any stage. Verified on 26.3 \u2014 that viewBox yields a 15906x5654 item that will not even rasterize. The tool compares the aspect ratio the file asks for against the one AE produced, and on a mismatch it deletes the item and throws, rather than handing you an asset that looks healthy in the project panel and renders empty.\n\nIf you hit that, the workarounds are:\n\n- **Simple flat SVGs** \u2014 rebuild the path as a shape layer with the real vertices, scaled down to a sane coordinate space (divide by `333.334` for a 1000px version), set the fill from the SVG, and set `ADBE Vector Fill Rule` to `2` when the SVG says `fill-rule="evenodd"`. Done this way the result is pixel-accurate.\n- **Complex SVGs** \u2014 rasterise to PNG outside AE, or normalise the `viewBox` to a small coordinate space before importing.\n\n`force: true` keeps the item and reports the problem in `validation` instead of throwing. It is for when you know the dimensions are wrong and want it anyway \u2014 not a way past the error.\n\n## When something costs you real time\n\nThese tools have rough edges, and the same ones catch every session. Two tools\nexist so that each one is only paid for once.\n\n**`list_known_issues`** \u2014 what earlier sessions hit and how they got past it.\nRead it when a tool fails in a way you do not immediately understand, before you\nstart guessing. The answer is often already there. It comes back as a one-line\nindex, so open the entry that looks like your failure with\n`list_known_issues({id})` \u2014 the cause and the workaround are in the entry, not in\nthe index. `tool` and `query` narrow it further.\n\n**`log_issue`** \u2014 write down what you worked out, the moment you work it out.\n\nLog something when all three are true: it cost real effort, it was the tool\'s\nfault rather than yours, and the next session would hit it too. A schema that\naccepts an argument AE then rejects, an error message that names the wrong\nthing, a property whose real name is nothing like its display name. Not your own\ntypos. Not "I forgot the layer was 3D".\n\nWrite the entry for someone who has not seen the failure: the exact error text,\nthe call that produced it, and a workaround concrete enough to apply directly.\nReuse the existing title when you are extending an entry \u2014 that keeps one good\nrecord instead of five thin ones.\n\n### Then offer to pass it on\n\nIf `log_issue` comes back with `reported: false`, mention it to the user \u2014 but\nfinish the actual work first, and put it at the very end, after you have told\nthem what you built. It is a footnote, not the headline.\n\nSay it the way you would to a colleague who does not write code. What you were\ntrying to do, that it fought back, that you got there anyway, and that you can\nsend it to the people who maintain the tool so the next person does not lose the\nsame time. Something like:\n\n> Done \u2014 the lower third is in. One thing worth mentioning: getting the ease\n> onto that position keyframe took a lot longer than it should have, because the\n> tool kept rejecting a value it had just asked for. I found a way around it and\n> made a note. Want me to send it to the people who maintain this so they can\n> fix it properly?\n\nDo not say "GitHub issue", "file a bug" or "open a ticket" unless they say it\nfirst. If they say yes, use the **report-ae-issue** prompt this server provides\n(`/report-ae-issue` where your client exposes prompts as commands) \u2014 it handles\nthe rest. If they say no, drop it; the note stays and can be offered again\nanother time.\n\nNever claim you have reported something you have not.\n\n## When something is not connected\n\nIf a tool reports it cannot reach After Effects, call `check_setup` and relay its `nextSteps` to the user in plain language. Do not try to diagnose CEP by hand.\n\n**A timeout is not proof the bridge is dead.** The error that says the panel did not answer in time is a different thing from the one that says the panel cannot be reached. Because ExtendScript is single-threaded, a busy After Effects cannot answer anything \u2014 so a long script, or a modal dialog nobody has clicked, is indistinguishable from a crash at this layer. It normally recovers on its own within a minute.\n\nSo when a call times out: do not re-send it (you would queue the same work twice), do not restart After Effects, and do not run `setup_panel`. Poll `check_setup` for about a minute first. Two causes worth asking about directly:\n\n- **A dialog is waiting.** Ask the user to check After Effects for a prompt hiding behind another window.\n- **They changed desktop.** On macOS, calls have been reported to stall while the user is on a different Space and to complete as soon as they return. If they have wandered off, ask them to switch back to the desktop After Effects is on before you diagnose anything else.\n\nIf a specific operation of yours legitimately needs longer than the limit, the user can raise it by setting `AE_MCP_OP_TIMEOUT_MS` in the server\'s environment.'
1938
1942
  },
1939
1943
  {
1940
1944
  name: "style-guide",
@@ -2251,7 +2255,7 @@ var GetJobSchema = schemas_exports.GetJob;
2251
2255
  var CancelJobSchema = schemas_exports.CancelJob;
2252
2256
  function createServer() {
2253
2257
  const server = new Server(
2254
- { name: "after-effects-mcp", version: "0.3.0" },
2258
+ { name: "after-effects-mcp", version: "0.3.1" },
2255
2259
  {
2256
2260
  capabilities: { tools: {}, logging: {}, prompts: {}, resources: {} },
2257
2261
  // Clients that honour this fold it into the system prompt, which is the
@@ -2581,7 +2585,7 @@ ${USAGE}`);
2581
2585
  await server.connect(transport);
2582
2586
  logger.info("MCP server running on stdio");
2583
2587
  }
2584
- var VERSION = "0.3.0";
2588
+ var VERSION = "0.3.1";
2585
2589
  main().catch((e) => {
2586
2590
  logger.error("fatal", e.message);
2587
2591
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@engine-room/after-effects-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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",
@@ -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.3.0"
2
+ <ExtensionManifest Version="11.0" ExtensionBundleId="games.engine-room.ae-mcp" ExtensionBundleVersion="0.3.1"
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.3.0" />
5
+ <Extension Id="games.engine-room.ae-mcp.panel" Version="0.3.1" />
6
6
  </ExtensionList>
7
7
  <ExecutionEnvironment>
8
8
  <HostList>
@@ -2464,14 +2464,97 @@ function __serializeText(layer) {
2464
2464
  } catch (e) { return null; }
2465
2465
  }
2466
2466
 
2467
- function __serializeShapeContents(group, depth) {
2467
+ // ---------- shape contents ----------
2468
+ //
2469
+ // Two of the groups hanging off every vector group are fixed-shape and almost
2470
+ // never the reason anyone reads a shape layer.
2471
+ //
2472
+ // Material Options is the 48-property 3D extrusion model. It means something
2473
+ // only for an extruded shape under the Cinema 4D renderer, and on the 2D shape
2474
+ // layers that are nearly all of them it is inert — while being most of the
2475
+ // weight of a shape read: one 68x68 circle in one group came back as 13KB of
2476
+ // shape JSON, 10KB of it material properties (issue #42). Skipped unless
2477
+ // `shapeMaterials` asks for it, and the skip is counted and explained in the
2478
+ // response rather than being silent.
2479
+ var __SHAPE_MATERIALS = "ADBE Vector Materials Group";
2480
+ var __SHAPE_TRANSFORM = "ADBE Vector Transform Group";
2481
+
2482
+ // A group Transform still at its creation values says nothing that
2483
+ // `atDefaults: true` does not. Tested by value rather than through
2484
+ // PropertyBase.isModified: the values are the contract, they can be asserted
2485
+ // with no AE to run in, and a property this table does not know about — a
2486
+ // future AE adding one — has to fail the test rather than be folded away
2487
+ // unread.
2488
+ var __VECTOR_TRANSFORM_DEFAULTS = [
2489
+ ["ADBE Vector Anchor", [0, 0]],
2490
+ ["ADBE Vector Position", [0, 0]],
2491
+ ["ADBE Vector Scale", [100, 100]],
2492
+ ["ADBE Vector Skew", 0],
2493
+ ["ADBE Vector Skew Axis", 0],
2494
+ ["ADBE Vector Rotation", 0],
2495
+ ["ADBE Vector Group Opacity", 100]
2496
+ ];
2497
+
2498
+ function __isPropertyGroup(p) {
2499
+ return p.propertyType === PropertyType.NAMED_GROUP || p.propertyType === PropertyType.INDEXED_GROUP;
2500
+ }
2501
+
2502
+ function __vectorTransformDefault(matchName) {
2503
+ for (var i = 0; i < __VECTOR_TRANSFORM_DEFAULTS.length; i++) {
2504
+ if (__VECTOR_TRANSFORM_DEFAULTS[i][0] === matchName) return __VECTOR_TRANSFORM_DEFAULTS[i][1];
2505
+ }
2506
+ return null;
2507
+ }
2508
+
2509
+ function __sameVectorValue(a, b) {
2510
+ if (b instanceof Array) {
2511
+ if (!(a instanceof Array) || a.length !== b.length) return false;
2512
+ for (var i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false; }
2513
+ return true;
2514
+ }
2515
+ return a === b;
2516
+ }
2517
+
2518
+ function __isIdentityVectorTransform(tg) {
2519
+ if (!tg || !tg.numProperties) return false;
2520
+ for (var i = 1; i <= tg.numProperties; i++) {
2521
+ var p = tg.property(i);
2522
+ // Animated or expression-driven is never "at defaults", whatever it reads
2523
+ // at this instant.
2524
+ if (p.numKeys > 0) return false;
2525
+ try { if (p.canSetExpression && p.expression) return false; } catch (e) {}
2526
+ var def = __vectorTransformDefault(p.matchName);
2527
+ if (def === null) return false;
2528
+ var v;
2529
+ try { v = p.value; } catch (e2) { return false; }
2530
+ if (!__sameVectorValue(v, def)) return false;
2531
+ }
2532
+ return true;
2533
+ }
2534
+
2535
+ // Carries the caller's choices down the walk and collects what was left out, so
2536
+ // the omissions can be named once at the top of the section instead of being
2537
+ // repeated on every group.
2538
+ function __shapeOpts(args) {
2539
+ return {
2540
+ materials: !!(args && args.shapeMaterials),
2541
+ compact: !!(args && args.shapeDetail === "compact"),
2542
+ materialsOmitted: 0
2543
+ };
2544
+ }
2545
+
2546
+ function __serializeShapeContents(group, depth, opts) {
2468
2547
  if (!group || !group.numProperties) return [];
2469
2548
  var out = [];
2470
2549
  for (var i = 1; i <= group.numProperties; i++) {
2471
2550
  var p = group.property(i);
2551
+ if (p.matchName === __SHAPE_MATERIALS && !opts.materials) { opts.materialsOmitted += 1; continue; }
2552
+ // `index` stays the real one whatever was skipped, so a path built from
2553
+ // this response still addresses the node it names.
2472
2554
  var entry = { name: p.name, matchName: p.matchName, index: i };
2473
- if (p.propertyType === PropertyType.NAMED_GROUP || p.propertyType === PropertyType.INDEXED_GROUP) {
2474
- if (depth > 0) entry.children = __serializeShapeContents(p, depth - 1);
2555
+ if (__isPropertyGroup(p)) {
2556
+ if (p.matchName === __SHAPE_TRANSFORM && __isIdentityVectorTransform(p)) entry.atDefaults = true;
2557
+ else if (depth > 0) entry.children = __serializeShapeContents(p, depth - 1, opts);
2475
2558
  // Say where the walk stopped. A group that simply has no `children` key
2476
2559
  // reads as empty, which for a deep shape tree is a lie.
2477
2560
  else if (p.numProperties > 0) entry.childrenOmitted = p.numProperties;
@@ -2483,6 +2566,120 @@ function __serializeShapeContents(group, depth) {
2483
2566
  return out;
2484
2567
  }
2485
2568
 
2569
+ // ---------- compact shape serialization ----------
2570
+ //
2571
+ // One line per group, with that group's own leaf properties folded onto it.
2572
+ // The full form spends four JSON lines on every property it reports; the same
2573
+ // lamp layer is around 450 characters here against 2,800 full (and 13,000
2574
+ // before the material groups came out). It is a reading format, not a lesser
2575
+ // one: the write tools address nodes by name, and every name is still on the
2576
+ // line. `shapeDetail` stays "full" by default all the same — a caller that
2577
+ // never heard of it has to keep getting exactly what it always got.
2578
+
2579
+ function __compactNumber(n) {
2580
+ if (typeof n !== "number") return String(n);
2581
+ if (isNaN(n) || !isFinite(n)) return String(n);
2582
+ // Four decimals round-trips an 8-bit colour channel and keeps float noise
2583
+ // (0.6627450980392157 for one byte) out of a format whose point is brevity.
2584
+ return String(Math.round(n * 10000) / 10000);
2585
+ }
2586
+
2587
+ function __compactLeafValue(p) {
2588
+ var v;
2589
+ try { v = p.value; } catch (e) { return "?"; }
2590
+ // A path's value is a Shape object, which is a wall of vertex arrays in full
2591
+ // and unreadable in one line. Its size and closedness are what you check.
2592
+ try {
2593
+ if (v && v.vertices && v.vertices.length !== undefined) {
2594
+ return "path(" + v.vertices.length + (v.closed ? " verts, closed)" : " verts, open)");
2595
+ }
2596
+ } catch (e2) {}
2597
+ if (v instanceof Array) {
2598
+ var parts = [];
2599
+ for (var i = 0; i < v.length; i++) parts.push(__compactNumber(v[i]));
2600
+ return "[" + parts.join(",") + "]";
2601
+ }
2602
+ if (typeof v === "number") return __compactNumber(v);
2603
+ return String(v);
2604
+ }
2605
+
2606
+ function __compactLeaves(g) {
2607
+ var parts = [];
2608
+ for (var i = 1; i <= g.numProperties; i++) {
2609
+ var p = g.property(i);
2610
+ if (__isPropertyGroup(p)) continue;
2611
+ var s = p.name + "=" + __compactLeafValue(p);
2612
+ if (p.numKeys > 0) s += " [" + p.numKeys + " keys]";
2613
+ try { if (p.canSetExpression && p.expression) s += " [expr]"; } catch (e) {}
2614
+ parts.push(s);
2615
+ }
2616
+ return parts.join(" ");
2617
+ }
2618
+
2619
+ // Only the "ADBE " prefix comes off: "ADBE Vector Group" and "ADBE Vectors
2620
+ // Group" are different nodes, so anything cleverer would collide.
2621
+ function __compactKind(matchName) {
2622
+ return (matchName.substring(0, 5) === "ADBE ") ? matchName.substring(5) : matchName;
2623
+ }
2624
+
2625
+ function __hasGroupChild(g) {
2626
+ for (var i = 1; i <= g.numProperties; i++) { if (__isPropertyGroup(g.property(i))) return true; }
2627
+ return false;
2628
+ }
2629
+
2630
+ function __compactShapeContents(group, depth, indent, lines, opts) {
2631
+ if (!group || !group.numProperties) return lines;
2632
+ // Leaves sitting directly on the group being walked have no line of their
2633
+ // own to fold onto; at the root, give them one.
2634
+ if (indent === "") {
2635
+ var rootLeaves = __compactLeaves(group);
2636
+ if (rootLeaves) lines.push(rootLeaves);
2637
+ }
2638
+ for (var i = 1; i <= group.numProperties; i++) {
2639
+ var p = group.property(i);
2640
+ if (!__isPropertyGroup(p)) continue;
2641
+ if (p.matchName === __SHAPE_MATERIALS && !opts.materials) { opts.materialsOmitted += 1; continue; }
2642
+ var line = indent + p.name + " " + __compactKind(p.matchName);
2643
+ if (p.matchName === __SHAPE_TRANSFORM && __isIdentityVectorTransform(p)) {
2644
+ lines.push(line + " (at defaults)");
2645
+ continue;
2646
+ }
2647
+ var leaves = __compactLeaves(p);
2648
+ lines.push(leaves ? line + " " + leaves : line);
2649
+ if (depth > 0) __compactShapeContents(p, depth - 1, indent + " ", lines, opts);
2650
+ // The leaves are already on the line above, so only unwalked sub-groups
2651
+ // are missing — and saying so is the same rule as `childrenOmitted`.
2652
+ else if (__hasGroupChild(p)) lines.push(indent + " (sub-groups not walked — raise shapeDepth)");
2653
+ }
2654
+ return lines;
2655
+ }
2656
+
2657
+ // The whole `shape` section, with its own omissions named on it.
2658
+ function __serializeShape(layer, depth, args) {
2659
+ var opts = __shapeOpts(args);
2660
+ var shape = { depth: depth };
2661
+ try {
2662
+ var contents = layer.property("Contents");
2663
+ if (opts.compact) {
2664
+ shape.detail = "compact";
2665
+ shape.contents = __compactShapeContents(contents, depth, "", [], opts);
2666
+ } else {
2667
+ shape.contents = __serializeShapeContents(contents, depth, opts);
2668
+ }
2669
+ } catch (e) {
2670
+ // An unreadable Contents used to leave the section off entirely, which
2671
+ // reads as "this shape layer has no shapes".
2672
+ shape.error = (e && e.message) ? String(e.message) : String(e);
2673
+ }
2674
+ if (opts.materialsOmitted > 0) {
2675
+ shape.materialsOmitted = opts.materialsOmitted;
2676
+ shape.materialsNote = "Material Options omitted on " + opts.materialsOmitted + " shape group" +
2677
+ (opts.materialsOmitted === 1 ? "" : "s") + " — 48 3D-extrusion properties each, meaningful only for an " +
2678
+ "extruded shape under the Cinema 4D renderer. Pass shapeMaterials:true to read them.";
2679
+ }
2680
+ return shape;
2681
+ }
2682
+
2486
2683
  OPS.get_layer_full = noUndo(function (args) {
2487
2684
  var c = getCompById(args.compId);
2488
2685
  var l = getLayerById(c, args.layerId);
@@ -2526,8 +2723,7 @@ OPS.get_layer_full = noUndo(function (args) {
2526
2723
  if (l instanceof TextLayer && __wantsSection(want, "text")) out.text = __serializeText(l);
2527
2724
  if (l instanceof ShapeLayer && __wantsSection(want, "shape")) {
2528
2725
  var depth = (args && args.shapeDepth !== undefined && args.shapeDepth !== null) ? args.shapeDepth : 4;
2529
- try { out.shape = { depth: depth, contents: __serializeShapeContents(l.property("Contents"), depth) }; }
2530
- catch (e) {}
2726
+ out.shape = __serializeShape(l, depth, args);
2531
2727
  }
2532
2728
  if (__wantsSection(want, "source")) {
2533
2729
  if (l.source && l.source instanceof CompItem) {
@@ -2729,11 +2925,28 @@ function __rjSerialize(v, depth, stack, budget) {
2729
2925
  return out;
2730
2926
  }
2731
2927
 
2732
- function __rjResult(result) {
2733
- // A script with no `return` returns undefined at the top level; that is
2734
- // genuinely "nothing", not an unserializable value.
2735
- if (typeof result === "undefined") return null;
2736
- return __rjSerialize(result, 0, [], { n: 0 });
2928
+ // A script whose last statement is a bare expression completes and yields
2929
+ // undefined with every side effect already applied. Handing back a bare
2930
+ // `null` for that made "ran fine, returned nothing" identical on the wire to
2931
+ // "did not run", and the natural response to a suspected failure is to run the
2932
+ // script again. Nothing here rolls back, so a second run of a non-idempotent
2933
+ // script duplicates layers, re-applies moveTo, writes keyframes on top of
2934
+ // keyframes (issue #43) — and the guidance to prefer few large scripts means
2935
+ // the ones most likely to be re-run are the most destructive to re-run.
2936
+ //
2937
+ // So a null result is never returned bare: it comes back as an envelope that
2938
+ // says the script finished. An explicit `return null` is folded into the same
2939
+ // envelope, because the ambiguity is in the value, not in how it was produced.
2940
+ function __rjResult(result, undoGroupName) {
2941
+ var serialized = (typeof result === "undefined") ? null : __rjSerialize(result, 0, [], { n: 0 });
2942
+ if (serialized !== null) return serialized;
2943
+ return {
2944
+ ok: true,
2945
+ returned: null,
2946
+ undoGroup: undoGroupName,
2947
+ note: "Completed with no `return` value — this is not a failure. Use `return X` to send a value back. " +
2948
+ "Anything the script changed is already applied and nothing rolls back, so read the state back rather than re-running it."
2949
+ };
2737
2950
  }
2738
2951
 
2739
2952
  // undoGroup:false is a per-call opt-out, read by dispatch() through the
@@ -2744,5 +2957,9 @@ OPS.run_jsx = noUndoWhen(function (args) { return args.undoGroup === false; }, f
2744
2957
  var code = args.code || "";
2745
2958
  // We wrap in a function so `return` works.
2746
2959
  var wrapper = "(function(){ " + code + " })()";
2747
- return __rjResult(eval(wrapper));
2960
+ // Which undo step to look for in AE if the script has to be backed out. The
2961
+ // name mirrors dispatch()'s default ("AE MCP: " + op); false means the caller
2962
+ // asked for no group and the changes landed as whatever steps AE recorded.
2963
+ var undoGroupName = (args.undoGroup === false) ? false : "AE MCP: run_jsx";
2964
+ return __rjResult(eval(wrapper), undoGroupName);
2748
2965
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@engineroom/ae-panel",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "private": true,
5
5
  "description": "Invisible CEP extension hosting an HTTP+WS bridge inside After Effects.",
6
6
  "dependencies": {