@engine-room/after-effects-mcp 0.5.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@engine-room/after-effects-mcp",
3
- "version": "0.5.0",
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.0"
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.0" />
5
+ <Extension Id="games.engine-room.ae-mcp.panel" Version="0.5.2" />
6
6
  </ExtensionList>
7
7
  <ExecutionEnvironment>
8
8
  <HostList>
@@ -234,6 +234,75 @@
234
234
  } catch (e) {}
235
235
  }
236
236
 
237
+ // ---------- Bridge token (issue #106) ----------
238
+ // Why a token and not a CORS rule, and why per port: docs/fragile-areas-bridge.md.
239
+ // One per panel session. 384 is 0600 for engines that predate 0o literals.
240
+ var sessionToken = crypto.randomBytes(32).toString("hex");
241
+ var TOKEN_MODE = 384;
242
+
243
+ function tokenFile(port) { return path.join(portDir, "token-" + port); }
244
+
245
+ function writeTokenFile(port) {
246
+ try { fs.mkdirSync(portDir, { recursive: true }); } catch (e) {}
247
+ try {
248
+ fs.writeFileSync(tokenFile(port), sessionToken, { encoding: "utf8", mode: TOKEN_MODE });
249
+ // writeFileSync applies `mode` only when it creates the file.
250
+ try { fs.chmodSync(tokenFile(port), TOKEN_MODE); } catch (e) {}
251
+ } catch (e) {
252
+ log("error", "could not write the bridge token file " + tokenFile(port) + ": " + e.message +
253
+ " — the MCP server will not be able to authenticate, and every tool call will be refused.");
254
+ }
255
+ }
256
+
257
+ // Same rule as the port file: only this panel's own entry.
258
+ function removeTokenFile(port) {
259
+ try {
260
+ if (fs.readFileSync(tokenFile(port), "utf8").trim() === sessionToken) fs.unlinkSync(tokenFile(port));
261
+ } catch (e) {}
262
+ }
263
+
264
+ function sameToken(given) {
265
+ if (typeof given !== "string" || given.length !== sessionToken.length) return false;
266
+ try {
267
+ return crypto.timingSafeEqual(Buffer.from(given, "utf8"), Buffer.from(sessionToken, "utf8"));
268
+ } catch (e) {
269
+ return false;
270
+ }
271
+ }
272
+
273
+ // The token is what holds. The Origin check is depth: browsers always send one,
274
+ // Node's fetch and `ws` never do.
275
+ function authFailure(req) {
276
+ var h = (req && req.headers) || {};
277
+ if (h.origin) return "it came from a web page (Origin: " + h.origin + ")";
278
+ if (!sameToken(h["x-ae-mcp-token"])) return "the bridge token was missing or did not match";
279
+ return null;
280
+ }
281
+
282
+ // Written for an MCP server older than this panel: it relays `error` verbatim,
283
+ // so the remedy has to be in here.
284
+ function denialMessage() {
285
+ return "This After Effects bridge requires a token and this request did not carry a valid one. " +
286
+ "Nothing was run in After Effects. The panel writes the token beside its port file, in " + portDir +
287
+ ", each time it binds; a local process can read it and a web page cannot, which is the entire point " +
288
+ "(issue #106). If you are the MCP server: you are older than the panel After Effects is running — " +
289
+ "update after-effects-mcp to 0.5.1 or newer and reconnect it.";
290
+ }
291
+
292
+ // Rate-limited: a page firing in a loop must not fill the panel's log.
293
+ var denied = 0;
294
+ var deniedLoggedAt = 0;
295
+ function noteDenial(what, why) {
296
+ denied += 1;
297
+ var now = Date.now();
298
+ if (denied === 1 || now - deniedLoggedAt > 10000) {
299
+ deniedLoggedAt = now;
300
+ log("warn", "refused a request to " + what + ": " + why +
301
+ (denied > 1 ? " (" + denied + " refused so far)" : "") +
302
+ ". If this is the MCP server rather than a web page, it is older than this panel: update it and reconnect.");
303
+ }
304
+ }
305
+
237
306
  // ---------- Config ----------
238
307
  // A CEP panel cannot read an environment variable the user set: it runs in
239
308
  // After Effects' process, with Adobe's environment. So its two knobs live in
@@ -864,8 +933,9 @@
864
933
  return new Promise(function (resolve, reject) {
865
934
  var server = http.createServer(function (req, res) {
866
935
  var url = req.url || "/";
867
- res.setHeader("Access-Control-Allow-Origin", "*");
868
- res.setHeader("Access-Control-Allow-Headers", "content-type");
936
+
937
+ // No CORS headers, deliberately: nothing that legitimately calls this
938
+ // bridge is a web page (issue #106).
869
939
 
870
940
  if (url === "/health") {
871
941
  res.statusCode = 200;
@@ -883,6 +953,10 @@
883
953
  port: addr && addr.port ? addr.port : port,
884
954
  bundleLoaded: true,
885
955
  bundleHash: loadedBundleHash,
956
+ // The gate exists, never anything about the token. Absent on a
957
+ // pre-#106 panel, which is how the server tells "needs a token"
958
+ // from "too old to want one".
959
+ auth: true,
886
960
  ts: Date.now()
887
961
  }));
888
962
  return;
@@ -893,6 +967,17 @@
893
967
  return;
894
968
  }
895
969
 
970
+ // Every route below reaches After Effects. Checked before the body is
971
+ // read, so an unidentified caller cannot make us buffer anything.
972
+ var denial = authFailure(req);
973
+ if (denial) {
974
+ noteDenial(url, denial);
975
+ res.statusCode = 403;
976
+ res.setHeader("content-type", "application/json");
977
+ res.end(JSON.stringify({ ok: false, error: denialMessage(), code: "bridge_unauthorized" }));
978
+ return;
979
+ }
980
+
896
981
  var bufs = [];
897
982
  req.on("data", function (c) { bufs.push(c); });
898
983
  req.on("end", function () {
@@ -1017,8 +1102,22 @@
1017
1102
  return startHttp(p).then(function (server) {
1018
1103
  // Read back rather than assumed: port 0 binds an ephemeral one.
1019
1104
  var bound = server.address().port;
1105
+ // The socket is already accepting, so the token must be on disk before
1106
+ // this promise resolves — not later in the boot.
1107
+ writeTokenFile(bound);
1020
1108
  // Mount WS on the same HTTP server.
1021
- var wss = new WebSocket.Server({ server: server, path: "/events" });
1109
+ var wss = new WebSocket.Server({
1110
+ server: server,
1111
+ path: "/events",
1112
+ // WebSockets ignore the same-origin policy entirely, so without this
1113
+ // any page could watch the user's event stream (issue #106).
1114
+ verifyClient: function (info, cb) {
1115
+ var why = authFailure(info.req);
1116
+ if (!why) return cb(true);
1117
+ noteDenial("/events", why);
1118
+ cb(false, 403, "Forbidden");
1119
+ },
1120
+ });
1022
1121
  wss.on("connection", function (ws) {
1023
1122
  wsClients.add(ws);
1024
1123
  ws.on("close", function () { wsClients.delete(ws); });
@@ -1097,6 +1196,7 @@
1097
1196
  try { if (typeof s.server.closeAllConnections === "function") s.server.closeAllConnections(); } catch (e) {}
1098
1197
  try { s.server.close(); } catch (e) {}
1099
1198
  removePortFile(s.port);
1199
+ removeTokenFile(s.port);
1100
1200
  }
1101
1201
  if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
1102
1202
  window.addEventListener("beforeunload", shutdown);
@@ -1126,6 +1226,7 @@
1126
1226
  setStatus("ready", "ok");
1127
1227
  log("info", "Bridge listening on http://127.0.0.1:" + s.port +
1128
1228
  (s.port !== config.port && config.port !== 0 ? " (configured port " + config.port + " was taken — see above)" : ""));
1229
+ log("info", "Calls are authenticated: the MCP server reads this session's token from " + tokenFile(s.port) + ".");
1129
1230
  })
1130
1231
  .catch(function (e) {
1131
1232
  setStatus("failed", "err");
@@ -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.0",
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": {