@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/README.md +1 -1
- package/bin/server.js +115 -19
- package/package.json +2 -2
- package/panel/CSXS/manifest.xml +2 -2
- package/panel/client/main.js +104 -3
- package/panel/jsx/bundle.jsx +252 -56
- package/panel/package.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@engine-room/after-effects-mcp",
|
|
3
|
-
"version": "0.5.
|
|
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
|
|
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"
|
package/panel/CSXS/manifest.xml
CHANGED
|
@@ -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.
|
|
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.
|
|
5
|
+
<Extension Id="games.engine-room.ae-mcp.panel" Version="0.5.2" />
|
|
6
6
|
</ExtensionList>
|
|
7
7
|
<ExecutionEnvironment>
|
|
8
8
|
<HostList>
|
package/panel/client/main.js
CHANGED
|
@@ -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
|
-
|
|
868
|
-
|
|
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({
|
|
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");
|
package/panel/jsx/bundle.jsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
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
|
-
|
|
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
|
|
2193
|
-
var p =
|
|
2194
|
-
if (
|
|
2195
|
-
|
|
2196
|
-
|
|
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
|
|
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
|
-
//
|
|
2787
|
-
//
|
|
2788
|
-
// a
|
|
2789
|
-
// `downsample: 1` (issue #72).
|
|
2790
|
-
// as a matter of course, so that
|
|
2791
|
-
//
|
|
2792
|
-
//
|
|
2793
|
-
//
|
|
2794
|
-
//
|
|
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
|
|
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
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2866
|
-
|
|
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
|
-
|
|
2870
|
-
ll.solo
|
|
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
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
//
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
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
|
|
4960
|
-
// which effects.jsx also uses for list_effects and
|
|
4961
|
-
//
|
|
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
|
-
|
|
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,
|