@engine-room/after-effects-mcp 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -8
- package/bin/server.js +2684 -527
- package/package.json +1 -1
- package/panel/CSXS/manifest.xml +2 -2
- package/panel/client/contactsheet.js +347 -0
- package/panel/client/framereader.js +248 -0
- package/panel/client/main.js +619 -112
- package/panel/client/pngcodec.js +122 -0
- package/panel/jsx/bundle.jsx +3299 -241
- package/panel/package.json +1 -1
package/bin/server.js
CHANGED
|
@@ -9,12 +9,138 @@ var __export = (target, all) => {
|
|
|
9
9
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
10
|
|
|
11
11
|
// src/cli/init.ts
|
|
12
|
-
import
|
|
12
|
+
import path3 from "node:path";
|
|
13
13
|
|
|
14
14
|
// src/setup/scaffold.ts
|
|
15
|
+
import fs2 from "node:fs";
|
|
16
|
+
import os2 from "node:os";
|
|
17
|
+
import path2 from "node:path";
|
|
18
|
+
|
|
19
|
+
// src/setup/paths.ts
|
|
15
20
|
import fs from "node:fs";
|
|
16
21
|
import os from "node:os";
|
|
17
22
|
import path from "node:path";
|
|
23
|
+
import { createRequire } from "node:module";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
var BUNDLE_ID = "games.engine-room.ae-mcp";
|
|
26
|
+
function isSupportedPlatform() {
|
|
27
|
+
return process.platform === "darwin" || process.platform === "win32";
|
|
28
|
+
}
|
|
29
|
+
function packageRoot() {
|
|
30
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
for (let i = 0; i < 8; i++) {
|
|
32
|
+
if (fs.existsSync(path.join(dir, "package.json"))) return dir;
|
|
33
|
+
const parent = path.dirname(dir);
|
|
34
|
+
if (parent === dir) break;
|
|
35
|
+
dir = parent;
|
|
36
|
+
}
|
|
37
|
+
return path.dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
}
|
|
39
|
+
function executableDir() {
|
|
40
|
+
return path.dirname(process.execPath);
|
|
41
|
+
}
|
|
42
|
+
function packageVersion() {
|
|
43
|
+
for (const dir of [packageRoot(), executableDir()]) {
|
|
44
|
+
try {
|
|
45
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
46
|
+
if (typeof pkg.version === "string") return pkg.version;
|
|
47
|
+
} catch {
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return "unknown";
|
|
51
|
+
}
|
|
52
|
+
function panelSourceDir() {
|
|
53
|
+
const candidates = [
|
|
54
|
+
// The live workspace copy comes first so a git checkout always installs
|
|
55
|
+
// what the developer is editing, never a stale vendored copy left behind by
|
|
56
|
+
// a previous `npm pack`. Only the second path exists in the tarball.
|
|
57
|
+
path.resolve(packageRoot(), "..", "ae-panel"),
|
|
58
|
+
path.join(packageRoot(), "panel"),
|
|
59
|
+
// Compiled single-file build: the panel ships beside the executable.
|
|
60
|
+
path.join(executableDir(), "panel")
|
|
61
|
+
];
|
|
62
|
+
for (const dir of candidates) {
|
|
63
|
+
if (fs.existsSync(path.join(dir, "CSXS", "manifest.xml"))) return dir;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
function cepExtensionsDir() {
|
|
68
|
+
if (process.platform === "win32") {
|
|
69
|
+
const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
|
|
70
|
+
return path.join(appData, "Adobe", "CEP", "extensions");
|
|
71
|
+
}
|
|
72
|
+
return path.join(os.homedir(), "Library", "Application Support", "Adobe", "CEP", "extensions");
|
|
73
|
+
}
|
|
74
|
+
function installedPanelDir() {
|
|
75
|
+
return path.join(cepExtensionsDir(), BUNDLE_ID);
|
|
76
|
+
}
|
|
77
|
+
function signedInstallPresent(dir) {
|
|
78
|
+
return fs.existsSync(path.join(dir, "META-INF", "signatures.xml")) || fs.existsSync(path.join(dir, "META-INF")) || fs.existsSync(path.join(dir, "mimetype"));
|
|
79
|
+
}
|
|
80
|
+
function isWsModuleDir(dir) {
|
|
81
|
+
try {
|
|
82
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
83
|
+
if (pkg.name !== "ws") return false;
|
|
84
|
+
return fs.existsSync(path.join(dir, "index.js")) && fs.existsSync(path.join(dir, "lib", "websocket.js"));
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function wsModuleDir() {
|
|
90
|
+
const candidates = [];
|
|
91
|
+
try {
|
|
92
|
+
const require2 = createRequire(import.meta.url);
|
|
93
|
+
const entry = require2.resolve("ws");
|
|
94
|
+
if (path.isAbsolute(entry)) {
|
|
95
|
+
const marker = `${path.sep}node_modules${path.sep}ws${path.sep}`;
|
|
96
|
+
const idx = entry.lastIndexOf(marker);
|
|
97
|
+
candidates.push(idx >= 0 ? entry.slice(0, idx + marker.length - 1) : path.dirname(entry));
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
}
|
|
101
|
+
candidates.push(path.join(executableDir(), "node_modules", "ws"));
|
|
102
|
+
candidates.push(path.join(packageRoot(), "node_modules", "ws"));
|
|
103
|
+
for (const dir of candidates) {
|
|
104
|
+
if (isWsModuleDir(dir)) return dir;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
function listPanelFiles(dir, prefix = "") {
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
111
|
+
if (entry.name === "node_modules" || entry.name === ".DS_Store") continue;
|
|
112
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
113
|
+
if (entry.isDirectory()) out.push(...listPanelFiles(path.join(dir, entry.name), rel));
|
|
114
|
+
else out.push(rel);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
function sameContents(a, b) {
|
|
119
|
+
try {
|
|
120
|
+
if (fs.statSync(a).size !== fs.statSync(b).size) return false;
|
|
121
|
+
return fs.readFileSync(a).equals(fs.readFileSync(b));
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function panelInstallDiff(source, installed) {
|
|
127
|
+
return listPanelFiles(source).filter((rel) => !sameContents(path.join(source, rel), path.join(installed, rel))).sort();
|
|
128
|
+
}
|
|
129
|
+
function copyRecursive(src, dst) {
|
|
130
|
+
const stat = fs.lstatSync(src);
|
|
131
|
+
if (stat.isDirectory()) {
|
|
132
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
133
|
+
for (const entry of fs.readdirSync(src)) {
|
|
134
|
+
copyRecursive(path.join(src, entry), path.join(dst, entry));
|
|
135
|
+
}
|
|
136
|
+
} else if (stat.isSymbolicLink()) {
|
|
137
|
+
fs.symlinkSync(fs.readlinkSync(src), dst);
|
|
138
|
+
} else {
|
|
139
|
+
fs.copyFileSync(src, dst);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/setup/scaffold.ts
|
|
18
144
|
var ScaffoldError = class extends Error {
|
|
19
145
|
};
|
|
20
146
|
function detectClient(clientName) {
|
|
@@ -38,10 +164,10 @@ function mcpConfigFor(client) {
|
|
|
38
164
|
case "claude-code":
|
|
39
165
|
return { rel: ".mcp.json", json: standard };
|
|
40
166
|
case "cursor":
|
|
41
|
-
return { rel:
|
|
167
|
+
return { rel: path2.join(".cursor", "mcp.json"), json: standard };
|
|
42
168
|
case "vscode":
|
|
43
169
|
return {
|
|
44
|
-
rel:
|
|
170
|
+
rel: path2.join(".vscode", "mcp.json"),
|
|
45
171
|
json: JSON.stringify({ servers: { "after-effects": { type: "stdio", ...MCP_SERVER_ENTRY } } }, null, 2) + "\n"
|
|
46
172
|
};
|
|
47
173
|
default:
|
|
@@ -50,25 +176,30 @@ function mcpConfigFor(client) {
|
|
|
50
176
|
}
|
|
51
177
|
function globalConfigHint(client) {
|
|
52
178
|
const standard = JSON.stringify({ mcpServers: { "after-effects": MCP_SERVER_ENTRY } }, null, 2) + "\n";
|
|
53
|
-
const home =
|
|
179
|
+
const home = os2.homedir();
|
|
54
180
|
switch (client) {
|
|
55
181
|
case "claude-desktop":
|
|
56
182
|
return {
|
|
57
|
-
path: process.platform === "darwin" ?
|
|
183
|
+
path: process.platform === "darwin" ? path2.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json") : path2.join(process.env.APPDATA ?? path2.join(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json"),
|
|
58
184
|
json: standard
|
|
59
185
|
};
|
|
60
186
|
case "windsurf":
|
|
61
|
-
return { path:
|
|
187
|
+
return { path: path2.join(home, ".codeium", "windsurf", "mcp_config.json"), json: standard };
|
|
62
188
|
case "codex":
|
|
63
189
|
return {
|
|
64
|
-
path:
|
|
190
|
+
path: path2.join(home, ".codex", "config.toml"),
|
|
65
191
|
json: '[mcp_servers.after-effects]\ncommand = "npx"\nargs = ["-y", "@engine-room/after-effects-mcp"]\n'
|
|
66
192
|
};
|
|
67
193
|
default:
|
|
68
194
|
return void 0;
|
|
69
195
|
}
|
|
70
196
|
}
|
|
71
|
-
var
|
|
197
|
+
var ABSORBED_MARKER_PREFIX = "Tools version last absorbed:";
|
|
198
|
+
var ABSORBED_MARKER = new RegExp(`^\\s*${ABSORBED_MARKER_PREFIX}\\s*v?(\\d+\\.\\d+\\.\\d+)\\s*$`, "m");
|
|
199
|
+
function absorbedMarkerLine(version) {
|
|
200
|
+
return `${ABSORBED_MARKER_PREFIX} ${version}`;
|
|
201
|
+
}
|
|
202
|
+
var AGENTS_MD = (name, version) => `# ${name}
|
|
72
203
|
|
|
73
204
|
An After Effects project folder. The AE tools drive After Effects directly from
|
|
74
205
|
here \u2014 describe what you want and it gets built in the open project.
|
|
@@ -94,6 +225,14 @@ Check \`list_known_issues\` before guessing \u2014 an earlier session may alread
|
|
|
94
225
|
solved it. Anything newly worked out goes in with \`log_issue\`, and the
|
|
95
226
|
report-ae-issue prompt sends it to the maintainers.
|
|
96
227
|
|
|
228
|
+
## Tool updates
|
|
229
|
+
|
|
230
|
+
${absorbedMarkerLine(version)}
|
|
231
|
+
|
|
232
|
+
When the After Effects tools update, the absorb-release prompt reads what
|
|
233
|
+
changed since the version above, fixes any rule in this folder the update made
|
|
234
|
+
stale, and moves the version on. Leave the line where it is.
|
|
235
|
+
|
|
97
236
|
## Conventions for this project
|
|
98
237
|
|
|
99
238
|
<!-- Anything specific to this project rather than to your general style: naming
|
|
@@ -123,48 +262,51 @@ function pointerFiles(client) {
|
|
|
123
262
|
case "claude-code":
|
|
124
263
|
return ["CLAUDE.md"];
|
|
125
264
|
case "cursor":
|
|
126
|
-
return [
|
|
265
|
+
return [path2.join(".cursor", "rules", "after-effects.mdc")];
|
|
127
266
|
case "windsurf":
|
|
128
267
|
return [".windsurfrules"];
|
|
129
268
|
case "vscode":
|
|
130
|
-
return [
|
|
269
|
+
return [path2.join(".github", "copilot-instructions.md")];
|
|
131
270
|
default:
|
|
132
271
|
return [];
|
|
133
272
|
}
|
|
134
273
|
}
|
|
135
274
|
function resolveTarget(dir, roots) {
|
|
136
275
|
if (dir && dir.trim().length > 0) {
|
|
137
|
-
return { dir:
|
|
276
|
+
return refuseUnscoped({ dir: path2.resolve(dir.trim()), resolvedFrom: "argument" });
|
|
138
277
|
}
|
|
139
278
|
const root = roots?.find((r) => r && r.trim().length > 0);
|
|
140
|
-
if (root) return { dir:
|
|
279
|
+
if (root) return refuseUnscoped({ dir: path2.resolve(root), resolvedFrom: "client-root" });
|
|
141
280
|
const cwd = process.cwd();
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
281
|
+
return refuseUnscoped({ dir: cwd, resolvedFrom: "working-directory" });
|
|
282
|
+
}
|
|
283
|
+
function refuseUnscoped(target) {
|
|
284
|
+
const { dir, resolvedFrom } = target;
|
|
285
|
+
const isFilesystemRoot = dir === path2.parse(dir).root;
|
|
286
|
+
if (!isFilesystemRoot && dir !== os2.homedir()) return target;
|
|
287
|
+
const how = resolvedFrom === "argument" ? `\`dir\` is ${dir}` : resolvedFrom === "client-root" ? `the client says it is working in ${dir}` : `this client did not say which folder it is working in, and the server was started in ${dir}`;
|
|
288
|
+
throw new ScaffoldError(
|
|
289
|
+
`No project folder to write to: ${how}, which is not somewhere a project should be created. Ask the user which folder they want the project in \u2014 a new one is fine \u2014 and pass it as \`dir\`.`
|
|
290
|
+
);
|
|
149
291
|
}
|
|
150
292
|
function scaffold(opts) {
|
|
151
293
|
const { dir, resolvedFrom } = resolveTarget(opts.dir, opts.roots);
|
|
152
|
-
const name = opts.name?.trim() ||
|
|
153
|
-
const files = [["AGENTS.md", AGENTS_MD(name)]];
|
|
294
|
+
const name = opts.name?.trim() || path2.basename(dir);
|
|
295
|
+
const files = [["AGENTS.md", AGENTS_MD(name, packageVersion())]];
|
|
154
296
|
for (const rel of pointerFiles(opts.client)) files.push([rel, pointerContent(rel)]);
|
|
155
|
-
files.push([
|
|
297
|
+
files.push([path2.join("renders", ".gitkeep"), ""]);
|
|
156
298
|
const projectConfig = opts.withMcpConfig ? mcpConfigFor(opts.client) : void 0;
|
|
157
299
|
if (projectConfig) files.push([projectConfig.rel, projectConfig.json]);
|
|
158
|
-
const existing = files.map(([rel]) => rel).filter((rel) =>
|
|
300
|
+
const existing = files.map(([rel]) => rel).filter((rel) => fs2.existsSync(path2.join(dir, rel)));
|
|
159
301
|
if (existing.length > 0) {
|
|
160
302
|
throw new ScaffoldError(
|
|
161
303
|
`${dir} already has ${existing.join(", ")}. Nothing was written. This folder is already set up \u2014 or pick a different one.`
|
|
162
304
|
);
|
|
163
305
|
}
|
|
164
306
|
for (const [rel, content] of files) {
|
|
165
|
-
const full =
|
|
166
|
-
|
|
167
|
-
|
|
307
|
+
const full = path2.join(dir, rel);
|
|
308
|
+
fs2.mkdirSync(path2.dirname(full), { recursive: true });
|
|
309
|
+
fs2.writeFileSync(full, content, "utf8");
|
|
168
310
|
}
|
|
169
311
|
const hint = opts.withMcpConfig && !projectConfig ? globalConfigHint(opts.client) : void 0;
|
|
170
312
|
const nextSteps = [
|
|
@@ -246,7 +388,7 @@ Usage: npx @engine-room/after-effects-mcp init <directory> [--no-mcp] [--client
|
|
|
246
388
|
}
|
|
247
389
|
const lines = [`Created ${result.dir}`, ``];
|
|
248
390
|
for (const rel of result.written) {
|
|
249
|
-
if (rel.endsWith(".gitkeep")) lines.push(` ${pad(
|
|
391
|
+
if (rel.endsWith(".gitkeep")) lines.push(` ${pad(path3.dirname(rel) + "/")} exports land here`);
|
|
250
392
|
else if (rel === "AGENTS.md") lines.push(` ${pad(rel)} what this project is`);
|
|
251
393
|
else if (rel.endsWith("mcp.json")) lines.push(` ${pad(rel)} connects your client to After Effects`);
|
|
252
394
|
else lines.push(` ${pad(rel)} points your client at AGENTS.md`);
|
|
@@ -273,6 +415,290 @@ function pad(s) {
|
|
|
273
415
|
|
|
274
416
|
// src/server.ts
|
|
275
417
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
418
|
+
|
|
419
|
+
// src/bridge/discovery.ts
|
|
420
|
+
import fs3 from "node:fs";
|
|
421
|
+
import os3 from "node:os";
|
|
422
|
+
import path4 from "node:path";
|
|
423
|
+
|
|
424
|
+
// src/util/errors.ts
|
|
425
|
+
var BridgeUnreachableError = class _BridgeUnreachableError extends Error {
|
|
426
|
+
constructor(port, cause) {
|
|
427
|
+
super(_BridgeUnreachableError.message(port, cause));
|
|
428
|
+
this.port = port;
|
|
429
|
+
this.name = "BridgeUnreachableError";
|
|
430
|
+
}
|
|
431
|
+
port;
|
|
432
|
+
static message(port, cause) {
|
|
433
|
+
return [
|
|
434
|
+
`Cannot reach the After Effects panel at http://127.0.0.1:${port}.`,
|
|
435
|
+
cause ? `Underlying error: ${cause.message}` : "",
|
|
436
|
+
"",
|
|
437
|
+
"Call the check_setup tool to find out why, then relay its nextSteps to the user.",
|
|
438
|
+
"In most cases the fix is either running setup_panel (installs the panel) or",
|
|
439
|
+
"simply opening After Effects."
|
|
440
|
+
].filter(Boolean).join("\n");
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
var BridgeTimeoutError = class _BridgeTimeoutError extends Error {
|
|
444
|
+
constructor(port, timeoutMs, opts = {}) {
|
|
445
|
+
super(_BridgeTimeoutError.message(port, timeoutMs, opts));
|
|
446
|
+
this.port = port;
|
|
447
|
+
this.timeoutMs = timeoutMs;
|
|
448
|
+
this.name = "BridgeTimeoutError";
|
|
449
|
+
}
|
|
450
|
+
port;
|
|
451
|
+
timeoutMs;
|
|
452
|
+
static message(port, timeoutMs, opts = {}) {
|
|
453
|
+
const secs = Math.round(timeoutMs / 1e3);
|
|
454
|
+
const what = opts.op ? `\`${opts.op}\`` : "the call";
|
|
455
|
+
return [
|
|
456
|
+
`The After Effects panel at http://127.0.0.1:${port} did not answer within ${secs}s for ${what}.`,
|
|
457
|
+
"",
|
|
458
|
+
"This is a timeout, not a lost connection \u2014 the panel is very probably still",
|
|
459
|
+
"there. ExtendScript is single-threaded, so while After Effects is busy running a",
|
|
460
|
+
"script it cannot answer the bridge at all, and a busy AE looks exactly like a",
|
|
461
|
+
"dead one from here. It usually recovers on its own.",
|
|
462
|
+
"",
|
|
463
|
+
"What to do, in order:",
|
|
464
|
+
"1. Do not restart After Effects, and do not run setup_panel. Do not re-send the",
|
|
465
|
+
" call either \u2014 that would queue a second copy of the same work.",
|
|
466
|
+
"2. Call check_setup, and keep polling it for about a minute. The bridge normally",
|
|
467
|
+
" comes back by itself once the script finishes.",
|
|
468
|
+
"3. Ask the user to look at After Effects. A modal dialog \u2014 an unsaved-project",
|
|
469
|
+
" prompt, a missing-font warning \u2014 blocks it in the same way and may be hidden",
|
|
470
|
+
" behind another window. Only they can click it.",
|
|
471
|
+
"4. On macOS, if they have switched to another desktop (Space), ask them to switch",
|
|
472
|
+
" back to the one After Effects is on. Calls have been reported to stall until",
|
|
473
|
+
" they return, then complete normally.",
|
|
474
|
+
"5. Only if check_setup still reports the bridge down after a minute should you",
|
|
475
|
+
" treat this as a real disconnection and follow its nextSteps.",
|
|
476
|
+
opts.adjustable === false ? "" : `
|
|
477
|
+
If this work legitimately takes longer than ${secs}s, the limit is settable: start the
|
|
478
|
+
server with AE_MCP_OP_TIMEOUT_MS set to a larger number of milliseconds.`
|
|
479
|
+
].filter(Boolean).join("\n");
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
function isTimeoutError(e, depth = 0) {
|
|
483
|
+
if (!e || typeof e !== "object" || depth > 4) return false;
|
|
484
|
+
const name = e.name;
|
|
485
|
+
if (name === "TimeoutError" || name === "AbortError") return true;
|
|
486
|
+
const cause = e.cause;
|
|
487
|
+
return cause === e ? false : isTimeoutError(cause, depth + 1);
|
|
488
|
+
}
|
|
489
|
+
var AeError = class extends Error {
|
|
490
|
+
/**
|
|
491
|
+
* `code` is set only when the panel diagnosed the failure itself rather than
|
|
492
|
+
* relaying one from ExtendScript — `STALE_FRAME` is the first. Those messages
|
|
493
|
+
* already read as complete instructions, so the caller uses this to decide
|
|
494
|
+
* whether an `AE:` prefix would help or just obscure them.
|
|
495
|
+
*/
|
|
496
|
+
constructor(message, stack_, line, code, source) {
|
|
497
|
+
super(message);
|
|
498
|
+
this.stack_ = stack_;
|
|
499
|
+
this.line = line;
|
|
500
|
+
this.code = code;
|
|
501
|
+
this.source = source;
|
|
502
|
+
this.name = "AeError";
|
|
503
|
+
}
|
|
504
|
+
stack_;
|
|
505
|
+
line;
|
|
506
|
+
code;
|
|
507
|
+
source;
|
|
508
|
+
};
|
|
509
|
+
function aeErrorText(e) {
|
|
510
|
+
const head = `AE: ${e.message}`;
|
|
511
|
+
const s = e.source;
|
|
512
|
+
if (!s) return e.line ? `${head} (line ${e.line})` : head;
|
|
513
|
+
const where = s.sourceName ?? "the script you submitted";
|
|
514
|
+
const total = s.lineCount ? `, ${s.lineCount} lines` : "";
|
|
515
|
+
const lines = [head];
|
|
516
|
+
if (s.sourceLine) {
|
|
517
|
+
lines.push(` at line ${s.sourceLine} of ${where}${total}:`);
|
|
518
|
+
if (s.sourceText) lines.push(` ${s.sourceText}`);
|
|
519
|
+
if (s.rawLine != null && s.rawLine !== s.sourceLine) {
|
|
520
|
+
lines.push(` (After Effects reported line ${s.rawLine}.)`);
|
|
521
|
+
}
|
|
522
|
+
} else if (s.rawLine != null) {
|
|
523
|
+
lines.push(
|
|
524
|
+
` After Effects reported line ${s.rawLine}, which does not fall inside ${where}${total} \u2014 trust the message, not the number.`
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
lines.push(
|
|
528
|
+
" Everything before the failure already ran and nothing rolls back: read the state back rather than re-running the script."
|
|
529
|
+
);
|
|
530
|
+
return lines.join("\n");
|
|
531
|
+
}
|
|
532
|
+
var WriteQueueWaitError = class _WriteQueueWaitError extends Error {
|
|
533
|
+
constructor(op, waitedMs, behind) {
|
|
534
|
+
super(_WriteQueueWaitError.message(op, waitedMs, behind));
|
|
535
|
+
this.op = op;
|
|
536
|
+
this.waitedMs = waitedMs;
|
|
537
|
+
this.behind = behind;
|
|
538
|
+
this.name = "WriteQueueWaitError";
|
|
539
|
+
}
|
|
540
|
+
op;
|
|
541
|
+
waitedMs;
|
|
542
|
+
behind;
|
|
543
|
+
static message(op, waitedMs, behind) {
|
|
544
|
+
const secs = Math.round(waitedMs / 1e3);
|
|
545
|
+
return [
|
|
546
|
+
`\`${op}\` waited ${secs}s behind \`${behind}\` for the After Effects write queue and was dropped without running.`,
|
|
547
|
+
"",
|
|
548
|
+
"This is neither a lost connection nor a busy bridge. The panel is fine and this",
|
|
549
|
+
"call never reached After Effects, so nothing in the project was changed.",
|
|
550
|
+
"",
|
|
551
|
+
"Writes are serialized because After Effects runs one script at a time and applies",
|
|
552
|
+
"changes in the order it receives them; two in flight block each other and land in",
|
|
553
|
+
"an order nobody chose. Something in front of this call is taking a very long time",
|
|
554
|
+
"\u2014 most often a long run_batch, occasionally a modal dialog in After Effects",
|
|
555
|
+
"blocking the script in front.",
|
|
556
|
+
"",
|
|
557
|
+
"What to do, in order:",
|
|
558
|
+
"1. Nothing was written, so re-sending this call is safe \u2014 unlike a bridge timeout.",
|
|
559
|
+
" Wait for the work in front to finish first, or it will just queue again.",
|
|
560
|
+
"2. Find out what is in front: get_job or await_job for a batch. Reads are not",
|
|
561
|
+
" queued, so list_/get_ calls still work and will tell you the current state.",
|
|
562
|
+
"3. Ask the user to look at After Effects for a dialog nobody has clicked.",
|
|
563
|
+
`4. If the work in front is legitimately this long, raise the limit: start the`,
|
|
564
|
+
" server with AE_MCP_WRITE_QUEUE_WAIT_MS set to a larger number of milliseconds."
|
|
565
|
+
].join("\n");
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
var WriteQueueFullError = class _WriteQueueFullError extends Error {
|
|
569
|
+
constructor(op, maxDepth, behind) {
|
|
570
|
+
super(_WriteQueueFullError.message(op, maxDepth, behind));
|
|
571
|
+
this.op = op;
|
|
572
|
+
this.maxDepth = maxDepth;
|
|
573
|
+
this.behind = behind;
|
|
574
|
+
this.name = "WriteQueueFullError";
|
|
575
|
+
}
|
|
576
|
+
op;
|
|
577
|
+
maxDepth;
|
|
578
|
+
behind;
|
|
579
|
+
static message(op, maxDepth, behind) {
|
|
580
|
+
return [
|
|
581
|
+
`The After Effects write queue is full \u2014 ${maxDepth} calls are already waiting, so \`${op}\` was refused.`,
|
|
582
|
+
"",
|
|
583
|
+
`Nothing was written. Writes are serialized so they land in the order you sent them,`,
|
|
584
|
+
`and \`${behind}\` is holding the queue up.`,
|
|
585
|
+
"",
|
|
586
|
+
"Stop issuing writes and let it drain \u2014 reads are not queued, so list_/get_ calls",
|
|
587
|
+
"still work. If you have this much independent work to do, send it as one",
|
|
588
|
+
"run_batch instead of as many calls: that is one ExtendScript pass and one place in",
|
|
589
|
+
"the queue, and its result reports how many undo steps it actually made.",
|
|
590
|
+
"AE_MCP_WRITE_QUEUE_DEPTH raises the limit if you really need it raised."
|
|
591
|
+
].join("\n");
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
var WriteQueueCancelledError = class extends Error {
|
|
595
|
+
constructor(op) {
|
|
596
|
+
super(`\`${op}\` was cancelled while waiting for the After Effects write queue. It never ran, and nothing was changed.`);
|
|
597
|
+
this.op = op;
|
|
598
|
+
this.name = "WriteQueueCancelledError";
|
|
599
|
+
}
|
|
600
|
+
op;
|
|
601
|
+
};
|
|
602
|
+
function invalidArgsText(op, e) {
|
|
603
|
+
const issues = e?.issues;
|
|
604
|
+
if (!Array.isArray(issues) || issues.length === 0) {
|
|
605
|
+
return `Invalid arguments for ${op}: ${e.message}`;
|
|
606
|
+
}
|
|
607
|
+
const lines = issues.map((raw) => {
|
|
608
|
+
const issue = raw;
|
|
609
|
+
const where = Array.isArray(issue.path) && issue.path.length ? issue.path.join(".") : null;
|
|
610
|
+
let text = issue.message ?? "invalid";
|
|
611
|
+
if (issue.code === "invalid_type" && issue.received === "undefined") {
|
|
612
|
+
text = "required, and was not passed";
|
|
613
|
+
} else if (issue.code === "invalid_type" && issue.expected) {
|
|
614
|
+
text = `expected ${issue.expected}, got ${issue.received ?? "something else"}`;
|
|
615
|
+
}
|
|
616
|
+
return where ? ` - ${where}: ${text}` : ` - ${text}`;
|
|
617
|
+
});
|
|
618
|
+
const head = issues.length === 1 ? `Invalid arguments for ${op}:` : `Invalid arguments for ${op} (${issues.length} problems):`;
|
|
619
|
+
return [head, ...lines].join("\n");
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// src/bridge/discovery.ts
|
|
623
|
+
var DEFAULT_PORT = 7777;
|
|
624
|
+
function portFilePath() {
|
|
625
|
+
return path4.join(os3.homedir(), ".engineroom-ae-mcp", "port");
|
|
626
|
+
}
|
|
627
|
+
function pinnedPort() {
|
|
628
|
+
const envPort = process.env.AE_MCP_PORT;
|
|
629
|
+
if (!envPort) return null;
|
|
630
|
+
const n = parseInt(envPort, 10);
|
|
631
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
632
|
+
}
|
|
633
|
+
function portFilePort() {
|
|
634
|
+
try {
|
|
635
|
+
const f = portFilePath();
|
|
636
|
+
if (fs3.existsSync(f)) {
|
|
637
|
+
const txt = fs3.readFileSync(f, "utf8").trim();
|
|
638
|
+
const n = parseInt(txt, 10);
|
|
639
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
640
|
+
}
|
|
641
|
+
} catch {
|
|
642
|
+
}
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
function discoverPort() {
|
|
646
|
+
return pinnedPort() ?? portFilePort() ?? DEFAULT_PORT;
|
|
647
|
+
}
|
|
648
|
+
function portCandidates() {
|
|
649
|
+
const pinned = pinnedPort();
|
|
650
|
+
if (pinned !== null) return [pinned];
|
|
651
|
+
const out = [DEFAULT_PORT];
|
|
652
|
+
const fromFile = portFilePort();
|
|
653
|
+
if (fromFile !== null && fromFile !== DEFAULT_PORT) out.push(fromFile);
|
|
654
|
+
return out;
|
|
655
|
+
}
|
|
656
|
+
function diagnosticPortCandidates() {
|
|
657
|
+
const out = [...portCandidates()];
|
|
658
|
+
for (const p of [DEFAULT_PORT, portFilePort()]) {
|
|
659
|
+
if (p !== null && !out.includes(p)) out.push(p);
|
|
660
|
+
}
|
|
661
|
+
return out;
|
|
662
|
+
}
|
|
663
|
+
function isPanelHealth(body) {
|
|
664
|
+
if (!body || typeof body !== "object") return false;
|
|
665
|
+
const b = body;
|
|
666
|
+
return b.ok === true && ("bundleHash" in b || "bundleLoaded" in b);
|
|
667
|
+
}
|
|
668
|
+
var PROBE_TIMEOUT_MS = 2e3;
|
|
669
|
+
async function probePanel(port, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
670
|
+
try {
|
|
671
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
672
|
+
if (!res.ok) {
|
|
673
|
+
return { port, status: "other", detail: `port ${port} answered HTTP ${res.status}, which is not the panel` };
|
|
674
|
+
}
|
|
675
|
+
const body = await res.json().catch(() => null);
|
|
676
|
+
if (isPanelHealth(body)) {
|
|
677
|
+
return { port, status: "panel", health: body, detail: `the panel is answering on port ${port}` };
|
|
678
|
+
}
|
|
679
|
+
return { port, status: "other", detail: `port ${port} answered, but not as the After Effects panel` };
|
|
680
|
+
} catch (e) {
|
|
681
|
+
if (isTimeoutError(e)) {
|
|
682
|
+
return {
|
|
683
|
+
port,
|
|
684
|
+
status: "busy",
|
|
685
|
+
detail: `port ${port} accepted the connection but had not answered after ${Math.round(timeoutMs / 1e3)}s \u2014 a panel may be there and busy`
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
return { port, status: "none", detail: `nothing is listening on port ${port} (${e.message})` };
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
async function locatePanel(candidates = portCandidates()) {
|
|
692
|
+
const probed = [];
|
|
693
|
+
for (const port of candidates) {
|
|
694
|
+
const r = await probePanel(port);
|
|
695
|
+
probed.push(r);
|
|
696
|
+
if (r.status === "panel") return { found: r, probed };
|
|
697
|
+
}
|
|
698
|
+
return { found: null, probed };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// src/server.ts
|
|
276
702
|
import {
|
|
277
703
|
CallToolRequestSchema,
|
|
278
704
|
GetPromptRequestSchema,
|
|
@@ -336,6 +762,7 @@ var WsEvent = z.discriminatedUnion("type", [
|
|
|
336
762
|
// ../shared/dist/schemas.js
|
|
337
763
|
var schemas_exports = {};
|
|
338
764
|
__export(schemas_exports, {
|
|
765
|
+
ARRAY_ELEMENT: () => ARRAY_ELEMENT,
|
|
339
766
|
AddEffect: () => AddEffect,
|
|
340
767
|
AddKeyframe: () => AddKeyframe,
|
|
341
768
|
AddMarker: () => AddMarker,
|
|
@@ -343,7 +770,10 @@ __export(schemas_exports, {
|
|
|
343
770
|
AddShapeContent: () => AddShapeContent,
|
|
344
771
|
AddTextAnimator: () => AddTextAnimator,
|
|
345
772
|
AeGuide: () => AeGuide,
|
|
773
|
+
ArchiveIssue: () => ArchiveIssue,
|
|
774
|
+
AudioCue: () => AudioCue,
|
|
346
775
|
AwaitJob: () => AwaitJob,
|
|
776
|
+
CROSS_FIELD_RULE: () => CROSS_FIELD_RULE,
|
|
347
777
|
CancelJob: () => CancelJob,
|
|
348
778
|
CheckSetup: () => CheckSetup,
|
|
349
779
|
ClearExpression: () => ClearExpression,
|
|
@@ -361,6 +791,8 @@ __export(schemas_exports, {
|
|
|
361
791
|
CreateTextLayer: () => CreateTextLayer,
|
|
362
792
|
DeleteComp: () => DeleteComp,
|
|
363
793
|
DeleteLayer: () => DeleteLayer,
|
|
794
|
+
DiffComp: () => DiffComp,
|
|
795
|
+
DuplicateComp: () => DuplicateComp,
|
|
364
796
|
DuplicateLayer: () => DuplicateLayer,
|
|
365
797
|
ExportMogrt: () => ExportMogrt,
|
|
366
798
|
FindLayers: () => FindLayers,
|
|
@@ -376,6 +808,7 @@ __export(schemas_exports, {
|
|
|
376
808
|
ImportFootage: () => ImportFootage,
|
|
377
809
|
InitProject: () => InitProject,
|
|
378
810
|
Interpolation: () => Interpolation,
|
|
811
|
+
LAYER_SUMMARY_SECTIONS: () => LAYER_SUMMARY_SECTIONS,
|
|
379
812
|
ListAvailableEffects: () => ListAvailableEffects,
|
|
380
813
|
ListComps: () => ListComps,
|
|
381
814
|
ListEffects: () => ListEffects,
|
|
@@ -383,9 +816,12 @@ __export(schemas_exports, {
|
|
|
383
816
|
ListLayers: () => ListLayers,
|
|
384
817
|
LogIssue: () => LogIssue,
|
|
385
818
|
MarkIssueReported: () => MarkIssueReported,
|
|
819
|
+
OpMutation: () => OpMutation,
|
|
386
820
|
OpSchemas: () => OpSchemas,
|
|
387
821
|
ParentLayer: () => ParentLayer,
|
|
822
|
+
PlaceAudioCues: () => PlaceAudioCues,
|
|
388
823
|
PropertyPath: () => PropertyPath,
|
|
824
|
+
PurgeUnusedFootage: () => PurgeUnusedFootage,
|
|
389
825
|
RemoveEffect: () => RemoveEffect,
|
|
390
826
|
RemoveKeyframe: () => RemoveKeyframe,
|
|
391
827
|
RemoveMarker: () => RemoveMarker,
|
|
@@ -412,10 +848,18 @@ __export(schemas_exports, {
|
|
|
412
848
|
SetTransform: () => SetTransform,
|
|
413
849
|
SetupPanel: () => SetupPanel,
|
|
414
850
|
ShapeContent: () => ShapeContent,
|
|
851
|
+
SnapshotComp: () => SnapshotComp,
|
|
415
852
|
ToggleExpression: () => ToggleExpression,
|
|
416
853
|
Vec2: () => Vec2,
|
|
417
854
|
Vec3: () => Vec3,
|
|
418
|
-
VecAny: () => VecAny
|
|
855
|
+
VecAny: () => VecAny,
|
|
856
|
+
crossFieldJsonSchema: () => crossFieldJsonSchema,
|
|
857
|
+
crossFieldMessage: () => crossFieldMessage,
|
|
858
|
+
crossFieldPresent: () => crossFieldPresent,
|
|
859
|
+
crossFieldRuleOf: () => crossFieldRuleOf,
|
|
860
|
+
crossFieldRulesIn: () => crossFieldRulesIn,
|
|
861
|
+
crossFieldSatisfied: () => crossFieldSatisfied,
|
|
862
|
+
objectShapeOf: () => objectShapeOf
|
|
419
863
|
});
|
|
420
864
|
import { z as z2 } from "zod";
|
|
421
865
|
var Color = z2.tuple([z2.number(), z2.number(), z2.number()]).describe("RGB 0..1");
|
|
@@ -431,6 +875,117 @@ var Interpolation = z2.object({
|
|
|
431
875
|
easeOut: z2.object({ influence: z2.number(), speed: z2.number() }).optional()
|
|
432
876
|
});
|
|
433
877
|
var includeParam = (sections, hint) => z2.array(z2.enum(sections)).optional().describe(`Sections to return: ${sections.join(", ")}. Omit for all of them; [] for ${hint}.`);
|
|
878
|
+
var CROSS_FIELD_RULE = /* @__PURE__ */ Symbol.for("engineRoom.aeMcp.crossFieldRule");
|
|
879
|
+
var backticked = (fields) => fields.map((f) => "`" + f + "`");
|
|
880
|
+
var joinWith = (parts, conjunction) => {
|
|
881
|
+
if (parts.length <= 1)
|
|
882
|
+
return parts.join("");
|
|
883
|
+
if (parts.length === 2)
|
|
884
|
+
return `${parts[0]} ${conjunction} ${parts[1]}`;
|
|
885
|
+
return `${parts.slice(0, -1).join(", ")} ${conjunction} ${parts[parts.length - 1]}`;
|
|
886
|
+
};
|
|
887
|
+
function crossFieldMessage(rule, present) {
|
|
888
|
+
const want = joinWith(backticked(rule.fields), "or");
|
|
889
|
+
const got = joinWith(backticked(present), "and");
|
|
890
|
+
const head = rule.kind === "exactlyOne" ? `Pass exactly one of ${want}` : rule.kind === "atLeastOne" ? `Pass at least one of ${want}` : `Pass at most one of ${want}`;
|
|
891
|
+
const body = present.length === 0 ? rule.fields.length === 2 ? " \u2014 neither was passed." : " \u2014 none of them was passed." : ` \u2014 got ${got}. Keep the one you meant and drop ${present.length === 2 ? "the other" : "the others"}.`;
|
|
892
|
+
return head + body + (rule.then ? " " + rule.then : "");
|
|
893
|
+
}
|
|
894
|
+
function crossFieldPresent(rule, value) {
|
|
895
|
+
if (!value || typeof value !== "object")
|
|
896
|
+
return [];
|
|
897
|
+
const obj = value;
|
|
898
|
+
return rule.fields.filter((f) => obj[f] !== void 0);
|
|
899
|
+
}
|
|
900
|
+
function crossFieldSatisfied(rule, value) {
|
|
901
|
+
const n = crossFieldPresent(rule, value).length;
|
|
902
|
+
if (rule.kind === "exactlyOne")
|
|
903
|
+
return n === 1;
|
|
904
|
+
if (rule.kind === "atLeastOne")
|
|
905
|
+
return n >= 1;
|
|
906
|
+
return n <= 1;
|
|
907
|
+
}
|
|
908
|
+
function crossFieldJsonSchema(rule) {
|
|
909
|
+
const each = rule.fields.map((f) => ({ required: [f] }));
|
|
910
|
+
if (rule.kind === "exactlyOne")
|
|
911
|
+
return { oneOf: each };
|
|
912
|
+
if (rule.kind === "atLeastOne")
|
|
913
|
+
return { anyOf: each };
|
|
914
|
+
const pairs = [];
|
|
915
|
+
for (let i = 0; i < rule.fields.length; i++) {
|
|
916
|
+
for (let j = i + 1; j < rule.fields.length; j++) {
|
|
917
|
+
pairs.push({ required: [rule.fields[i], rule.fields[j]] });
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return { not: pairs.length === 1 ? pairs[0] : { anyOf: pairs } };
|
|
921
|
+
}
|
|
922
|
+
function crossField(schema, rule) {
|
|
923
|
+
const refined = schema.superRefine((value, ctx) => {
|
|
924
|
+
if (crossFieldSatisfied(rule, value))
|
|
925
|
+
return;
|
|
926
|
+
ctx.addIssue({
|
|
927
|
+
code: z2.ZodIssueCode.custom,
|
|
928
|
+
message: crossFieldMessage(rule, crossFieldPresent(rule, value)),
|
|
929
|
+
// The first field of the rule, so the issue has somewhere to point. The
|
|
930
|
+
// message names all of them, which is the part that matters.
|
|
931
|
+
path: [rule.fields[0]]
|
|
932
|
+
});
|
|
933
|
+
});
|
|
934
|
+
Object.defineProperty(refined, CROSS_FIELD_RULE, { value: rule, enumerable: false });
|
|
935
|
+
return refined;
|
|
936
|
+
}
|
|
937
|
+
function crossFieldRuleOf(node) {
|
|
938
|
+
if (!node || typeof node !== "object")
|
|
939
|
+
return void 0;
|
|
940
|
+
return node[CROSS_FIELD_RULE];
|
|
941
|
+
}
|
|
942
|
+
var ARRAY_ELEMENT = "[]";
|
|
943
|
+
function crossFieldRulesIn(schema) {
|
|
944
|
+
const found = [];
|
|
945
|
+
const walk = (node, path12, depth) => {
|
|
946
|
+
const def = node?._def;
|
|
947
|
+
if (!def || depth > 16)
|
|
948
|
+
return;
|
|
949
|
+
switch (def.typeName) {
|
|
950
|
+
case "ZodEffects":
|
|
951
|
+
found.push({ path: [...path12], rule: crossFieldRuleOf(node) });
|
|
952
|
+
return walk(def.schema, path12, depth + 1);
|
|
953
|
+
case "ZodOptional":
|
|
954
|
+
case "ZodDefault":
|
|
955
|
+
case "ZodNullable":
|
|
956
|
+
case "ZodReadonly":
|
|
957
|
+
return walk(def.innerType, path12, depth + 1);
|
|
958
|
+
case "ZodObject": {
|
|
959
|
+
const shape = def.shape();
|
|
960
|
+
for (const key of Object.keys(shape))
|
|
961
|
+
walk(shape[key], [...path12, key], depth + 1);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
case "ZodArray":
|
|
965
|
+
return walk(def.type, [...path12, ARRAY_ELEMENT], depth + 1);
|
|
966
|
+
case "ZodUnion":
|
|
967
|
+
case "ZodDiscriminatedUnion": {
|
|
968
|
+
const options = def.options;
|
|
969
|
+
const list = Array.isArray(options) ? options : [...options.values()];
|
|
970
|
+
for (const opt of list)
|
|
971
|
+
walk(opt, path12, depth + 1);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
default:
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
walk(schema, [], 0);
|
|
979
|
+
return found;
|
|
980
|
+
}
|
|
981
|
+
function objectShapeOf(schema) {
|
|
982
|
+
let cur = schema;
|
|
983
|
+
for (let i = 0; i < 16 && cur?._def?.typeName === "ZodEffects"; i++) {
|
|
984
|
+
cur = cur._def.schema;
|
|
985
|
+
}
|
|
986
|
+
const shape = cur?.shape;
|
|
987
|
+
return shape;
|
|
988
|
+
}
|
|
434
989
|
var ListComps = z2.object({
|
|
435
990
|
include: includeParam(["size", "timing", "bg", "counts"], "id + name only")
|
|
436
991
|
}).strict();
|
|
@@ -456,11 +1011,31 @@ var SetComp = z2.object({
|
|
|
456
1011
|
workAreaDuration: z2.number().positive().optional(),
|
|
457
1012
|
bgColor: Color.optional()
|
|
458
1013
|
});
|
|
459
|
-
var DeleteComp = z2.object({
|
|
1014
|
+
var DeleteComp = z2.object({
|
|
1015
|
+
compId: z2.number(),
|
|
1016
|
+
purgeUnusedSolids: z2.boolean().default(false).optional().describe("Also remove the solid footage items this comp's own layers used, once nothing else uses them. Off by default because a solid can be shared: one another comp still uses is kept and reported with the comps using it.")
|
|
1017
|
+
}).strict();
|
|
460
1018
|
var SetActiveComp = z2.object({ compId: z2.number() });
|
|
1019
|
+
var DuplicateComp = z2.object({
|
|
1020
|
+
compId: z2.number(),
|
|
1021
|
+
name: z2.string().optional().describe("Name for the copy. Omit to take AE's own ('<name> 2')."),
|
|
1022
|
+
folderId: z2.number().optional().describe("Project folder to put the copy in. Must be a folder item id from get_project_summary."),
|
|
1023
|
+
deep: z2.boolean().default(false).optional().describe("Also duplicate the nested precomps and re-point the copy's layers at them. Off by default, which matches AE's own Duplicate: a shallow copy shares its nested comps with the original, so editing one edits both."),
|
|
1024
|
+
nameSuffix: z2.string().optional().describe("With deep:true, name each duplicated nested comp '<original><nameSuffix>'. Omit to let AE name them.")
|
|
1025
|
+
}).strict();
|
|
1026
|
+
var SnapshotComp = z2.object({
|
|
1027
|
+
compId: z2.number(),
|
|
1028
|
+
includeFingerprint: z2.boolean().default(false).optional().describe("Return the fingerprint itself as well as its id. Off by default \u2014 returning it reintroduces exactly the context cost a snapshot exists to avoid.")
|
|
1029
|
+
}).strict();
|
|
1030
|
+
var DiffComp = z2.object({
|
|
1031
|
+
since: z2.string().describe("A snapshotId from an earlier snapshot_comp or diff_comp."),
|
|
1032
|
+
compId: z2.number().optional().describe("Defaults to the comp the snapshot was taken of; pass it only to assert which comp you mean."),
|
|
1033
|
+
includeFingerprint: z2.boolean().default(false).optional().describe("Return the new fingerprint as well as the diff. Off by default.")
|
|
1034
|
+
}).strict();
|
|
1035
|
+
var LAYER_SUMMARY_SECTIONS = ["flags", "timing", "parent"];
|
|
461
1036
|
var ListLayers = z2.object({
|
|
462
1037
|
compId: z2.number(),
|
|
463
|
-
include: includeParam(
|
|
1038
|
+
include: includeParam(LAYER_SUMMARY_SECTIONS, "the id/index/name/type map alone")
|
|
464
1039
|
});
|
|
465
1040
|
var GetLayerFull = z2.object({
|
|
466
1041
|
compId: z2.number(),
|
|
@@ -491,7 +1066,8 @@ var CreateShapeLayer = z2.object({
|
|
|
491
1066
|
shapes: z2.array(z2.record(z2.string(), z2.unknown())).default([]),
|
|
492
1067
|
fill: Color.optional(),
|
|
493
1068
|
stroke: Color.optional(),
|
|
494
|
-
strokeWidth: z2.number().nonnegative().optional()
|
|
1069
|
+
strokeWidth: z2.number().nonnegative().optional(),
|
|
1070
|
+
position: z2.union([Vec2, Vec3, z2.literal("center")]).optional().describe("Where the layer's origin goes. Defaults to [0,0], which makes the layer's coordinate space the comp's \u2014 so vertices and shape positions you write afterwards are in comp pixels. 'center' is After Effects' own spawn point (the comp centre), which offsets every path you add by half a frame. The Anchor Point stays at [0,0] either way. A new shape layer is 2D, so use [x,y]; a three-component position needs set_layer threeDLayer:true first.")
|
|
495
1071
|
});
|
|
496
1072
|
var CreateSolidLayer = z2.object({
|
|
497
1073
|
compId: z2.number(),
|
|
@@ -524,14 +1100,22 @@ var SetLayer = z2.object({
|
|
|
524
1100
|
shy: z2.boolean().optional(),
|
|
525
1101
|
solo: z2.boolean().optional(),
|
|
526
1102
|
threeDLayer: z2.boolean().optional(),
|
|
527
|
-
|
|
1103
|
+
// These two are keys into After Effects' own `BlendingMode` and
|
|
1104
|
+
// `TrackMatteType` enumerations, looked up by name in `layers.jsx`. A name
|
|
1105
|
+
// that is not in the enumeration is **ignored**, and the call still reports
|
|
1106
|
+
// success — so the accepted spelling has to be visible here, and a value you
|
|
1107
|
+
// are unsure of has to be read back off the layer rather than assumed.
|
|
1108
|
+
blendingMode: z2.string().optional().describe("After Effects' BlendingMode constant name, upper case with underscores: NORMAL, MULTIPLY, SCREEN, OVERLAY, ADD, SUBTRACT, DIFFERENCE, DARKEN, LIGHTEN, COLOR_DODGE, COLOR_BURN, LINEAR_DODGE, LINEAR_BURN, SOFT_LIGHT, HARD_LIGHT, VIVID_LIGHT, LINEAR_LIGHT, PIN_LIGHT, HUE, SATURATION, COLOR, LUMINOSITY, STENCIL_ALPHA, STENCIL_LUMA, ALPHA_ADD, DISSOLVE. A name After Effects does not recognise leaves the mode unchanged without erroring, so read it back with get_layer_full if you are guessing."),
|
|
528
1109
|
label: z2.number().int().min(0).max(16).optional(),
|
|
529
1110
|
inPoint: z2.number().optional(),
|
|
530
1111
|
outPoint: z2.number().optional(),
|
|
531
1112
|
startTime: z2.number().optional(),
|
|
532
1113
|
stretch: z2.number().optional(),
|
|
533
1114
|
preserveTransparency: z2.boolean().optional(),
|
|
534
|
-
trackMatte: z2.object({
|
|
1115
|
+
trackMatte: z2.object({
|
|
1116
|
+
type: z2.string().describe("NO_TRACK_MATTE, ALPHA, ALPHA_INVERTED, LUMA or LUMA_INVERTED. Anything else leaves the matte unchanged without erroring."),
|
|
1117
|
+
layerId: z2.number().optional()
|
|
1118
|
+
}).optional()
|
|
535
1119
|
});
|
|
536
1120
|
var ParentLayer = z2.object({
|
|
537
1121
|
compId: z2.number(),
|
|
@@ -539,7 +1123,22 @@ var ParentLayer = z2.object({
|
|
|
539
1123
|
parentLayerId: z2.number().nullable(),
|
|
540
1124
|
preserveTransform: z2.boolean().default(true).optional().describe("Keep the layer visually where it is (what AE's UI does). Leave on unless you want the layer to jump into the parent's coordinate space.")
|
|
541
1125
|
});
|
|
542
|
-
var ReorderLayer =
|
|
1126
|
+
var ReorderLayer = crossField(
|
|
1127
|
+
z2.object({
|
|
1128
|
+
compId: z2.number(),
|
|
1129
|
+
layerId: z2.number(),
|
|
1130
|
+
toIndex: z2.number().int().positive().optional().describe("Where the layer ENDS UP: its 1-based index after the move, counting from the front (1 renders in front of everything, numLayers is the back). Clamped to the stack. Prefer beforeLayerId/afterLayerId when you are placing this layer relative to another one \u2014 an index you read earlier may already be stale."),
|
|
1131
|
+
beforeLayerId: z2.number().optional().describe("Put this layer directly IN FRONT OF (above) the layer with this id."),
|
|
1132
|
+
afterLayerId: z2.number().optional().describe("Put this layer directly BEHIND (below) the layer with this id.")
|
|
1133
|
+
}),
|
|
1134
|
+
// One destination, resolved before it can reach ExtendScript — two readings of
|
|
1135
|
+
// "where does it go" are a contract the schema should never let through.
|
|
1136
|
+
{
|
|
1137
|
+
kind: "exactlyOne",
|
|
1138
|
+
fields: ["toIndex", "beforeLayerId", "afterLayerId"],
|
|
1139
|
+
then: "`beforeLayerId`/`afterLayerId` are the safer two: this is the op that shifts every index below it, so an index read before the move may already be stale."
|
|
1140
|
+
}
|
|
1141
|
+
);
|
|
543
1142
|
var SetTransform = z2.object({
|
|
544
1143
|
compId: z2.number(),
|
|
545
1144
|
layerId: z2.number(),
|
|
@@ -584,14 +1183,24 @@ var SetInterpolation = z2.object({
|
|
|
584
1183
|
in: z2.enum(["linear", "bezier", "hold"]).optional(),
|
|
585
1184
|
out: z2.enum(["linear", "bezier", "hold"]).optional()
|
|
586
1185
|
});
|
|
587
|
-
var SetTemporalEase =
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
})
|
|
1186
|
+
var SetTemporalEase = crossField(
|
|
1187
|
+
z2.object({
|
|
1188
|
+
compId: z2.number(),
|
|
1189
|
+
layerId: z2.number(),
|
|
1190
|
+
propertyPath: PropertyPath,
|
|
1191
|
+
keyIndex: z2.number().int().positive(),
|
|
1192
|
+
easeIn: z2.object({ influence: z2.number(), speed: z2.number() }).optional().describe("One influence/speed pair, applied to every dimension of the property. At least one of easeIn/easeOut is required."),
|
|
1193
|
+
easeOut: z2.object({ influence: z2.number(), speed: z2.number() }).optional().describe("One influence/speed pair, applied to every dimension of the property.")
|
|
1194
|
+
}),
|
|
1195
|
+
// `keyframes.jsx` has always refused this, and still does — but it refused
|
|
1196
|
+
// from inside After Effects, which meant a round trip and a write lease spent
|
|
1197
|
+
// on a call that was never going to change anything.
|
|
1198
|
+
{
|
|
1199
|
+
kind: "atLeastOne",
|
|
1200
|
+
fields: ["easeIn", "easeOut"],
|
|
1201
|
+
then: "Each is one {influence, speed} pair; this tool sizes the ease array for the property itself."
|
|
1202
|
+
}
|
|
1203
|
+
);
|
|
595
1204
|
var SetSpatialTangents = z2.object({
|
|
596
1205
|
compId: z2.number(),
|
|
597
1206
|
layerId: z2.number(),
|
|
@@ -607,16 +1216,27 @@ var ClearExpression = z2.object({ compId: z2.number(), layerId: z2.number(), pro
|
|
|
607
1216
|
var ListEffects = z2.object({ compId: z2.number(), layerId: z2.number() });
|
|
608
1217
|
var AddEffect = z2.object({ compId: z2.number(), layerId: z2.number(), matchName: z2.string() });
|
|
609
1218
|
var RemoveEffect = z2.object({ compId: z2.number(), layerId: z2.number(), effectIndex: z2.number().int().positive() });
|
|
610
|
-
var SetEffectParam =
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
1219
|
+
var SetEffectParam = crossField(
|
|
1220
|
+
z2.object({
|
|
1221
|
+
compId: z2.number(),
|
|
1222
|
+
layerId: z2.number(),
|
|
1223
|
+
effectIndex: z2.number().int().positive(),
|
|
1224
|
+
paramName: z2.string().optional().describe("The parameter's display name as list_effects reports it, e.g. 'Blurriness'."),
|
|
1225
|
+
paramMatchName: z2.string().optional().describe("The parameter's matchName, e.g. 'ADBE Gaussian Blur 2-0001'. Tried before paramName when both are given, so it is the one to use when a display name is ambiguous or localised."),
|
|
1226
|
+
value: VecAny,
|
|
1227
|
+
time: z2.number().optional(),
|
|
1228
|
+
keyframe: z2.boolean().default(false).optional()
|
|
1229
|
+
}),
|
|
1230
|
+
// Neither one is not a call with a default — it is a call that resolves no
|
|
1231
|
+
// property at all, and `effects.jsx` answers it with a bare "Effect param not
|
|
1232
|
+
// found", which reads like the *name* was wrong rather than absent.
|
|
1233
|
+
// Both together is legal and useful: matchName wins, name is the fallback.
|
|
1234
|
+
{
|
|
1235
|
+
kind: "atLeastOne",
|
|
1236
|
+
fields: ["paramName", "paramMatchName"],
|
|
1237
|
+
then: "list_effects on the layer reports both for every parameter of every effect."
|
|
1238
|
+
}
|
|
1239
|
+
);
|
|
620
1240
|
var SetEffectEnabled = z2.object({ compId: z2.number(), layerId: z2.number(), effectIndex: z2.number().int().positive(), enabled: z2.boolean() });
|
|
621
1241
|
var ListAvailableEffects = z2.object({
|
|
622
1242
|
filter: z2.string().optional(),
|
|
@@ -755,7 +1375,9 @@ var AddMask = z2.object({
|
|
|
755
1375
|
inTangents: z2.array(Vec2).optional(),
|
|
756
1376
|
outTangents: z2.array(Vec2).optional(),
|
|
757
1377
|
closed: z2.boolean().default(true).optional(),
|
|
758
|
-
|
|
1378
|
+
// A key into AE's `MaskMode`, looked up by name in `masks.jsx`; an
|
|
1379
|
+
// unrecognised one leaves the mode alone rather than erroring.
|
|
1380
|
+
mode: z2.string().default("ADD").optional().describe("Mask blend mode: ADD (default), NONE, SUBTRACT, INTERSECT, LIGHTEN, DARKEN or DIFFERENCE. Anything else leaves the mode unchanged without erroring.")
|
|
759
1381
|
});
|
|
760
1382
|
var SetMask = z2.object({
|
|
761
1383
|
compId: z2.number(),
|
|
@@ -765,7 +1387,7 @@ var SetMask = z2.object({
|
|
|
765
1387
|
inTangents: z2.array(Vec2).optional(),
|
|
766
1388
|
outTangents: z2.array(Vec2).optional(),
|
|
767
1389
|
closed: z2.boolean().optional(),
|
|
768
|
-
mode: z2.string().optional(),
|
|
1390
|
+
mode: z2.string().optional().describe("Mask blend mode: ADD, NONE, SUBTRACT, INTERSECT, LIGHTEN, DARKEN or DIFFERENCE. Anything else leaves the mode unchanged without erroring."),
|
|
769
1391
|
inverted: z2.boolean().optional(),
|
|
770
1392
|
expansion: z2.number().optional(),
|
|
771
1393
|
feather: Vec2.optional(),
|
|
@@ -788,34 +1410,81 @@ var RemoveMarker = z2.object({
|
|
|
788
1410
|
layerId: z2.number().optional(),
|
|
789
1411
|
markerIndex: z2.number().int().positive()
|
|
790
1412
|
});
|
|
791
|
-
var downsampleParam = z2.number().int().min(1).max(8).optional().describe("Render at 1/N resolution. Omit and one is chosen from the comp size (long edge ~1280px: 2 at 1080p, 3 at 4K). Pass 1 for a full-resolution frame.");
|
|
792
|
-
var ScreenshotFrame =
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
1413
|
+
var downsampleParam = z2.number().int().min(1).max(8).optional().describe("Render at 1/N resolution. Omit and one is chosen from the comp size (long edge ~1280px: 2 at 1080p, 3 at 4K). Pass 1 for a full-resolution frame. The factor is always exactly what you asked for: the render sets the comp's resolution and puts it back, so a viewer left on Quarter or Third does not change the size of the frame you get.");
|
|
1414
|
+
var ScreenshotFrame = crossField(
|
|
1415
|
+
z2.object({
|
|
1416
|
+
compId: z2.number(),
|
|
1417
|
+
time: z2.number().optional(),
|
|
1418
|
+
/**
|
|
1419
|
+
* Several times in one call, returned as one tiled sheet.
|
|
1420
|
+
*
|
|
1421
|
+
* Judging motion is a single visual question, and answering it used to cost
|
|
1422
|
+
* one call per frame — three image blocks resident for the rest of the
|
|
1423
|
+
* session, and three chances for After Effects to re-serve a stale buffer.
|
|
1424
|
+
* Capped at six because past that each tile is too small to read at the
|
|
1425
|
+
* pixel budget of one frame.
|
|
1426
|
+
*/
|
|
1427
|
+
times: z2.array(z2.number()).min(2).max(6).optional().describe("2-6 times to render into one tiled contact sheet, in order, with the time burned into each tile. Cheaper than one call per frame and the whole point of it is judging motion. Mutually exclusive with `time`."),
|
|
1428
|
+
downsample: downsampleParam
|
|
1429
|
+
}),
|
|
1430
|
+
// Enforced here rather than in the panel: two readings of "which frame did
|
|
1431
|
+
// you want" reaching ExtendScript at all is a contract the schema should
|
|
1432
|
+
// never have let through. `atMostOne` and not `exactlyOne` — neither is a
|
|
1433
|
+
// legal call, and means the comp's current time.
|
|
1434
|
+
{
|
|
1435
|
+
kind: "atMostOne",
|
|
1436
|
+
fields: ["time", "times"],
|
|
1437
|
+
then: "`time` is one frame, `times` is a contact sheet of 2-6 of them; omit both for the comp's current time."
|
|
1438
|
+
}
|
|
1439
|
+
);
|
|
797
1440
|
var ScreenshotLayer = z2.object({
|
|
798
1441
|
compId: z2.number(),
|
|
799
1442
|
layerId: z2.number(),
|
|
800
1443
|
time: z2.number().optional(),
|
|
801
1444
|
downsample: downsampleParam
|
|
802
1445
|
});
|
|
1446
|
+
var diffParam = z2.boolean().default(false).optional().describe("Fingerprint the comp before and after this call and append only what changed (layers added/removed/renamed/retimed/re-parented, keyframe counts, expression and effect counts). A few dozen tokens instead of reading the comp back. If the call fails, the diff of what landed before it stopped is appended to the error.");
|
|
1447
|
+
var diffCompIdParam = z2.number().optional().describe("Which comp `diff` should fingerprint. Defaults to the comps this call names, else the comp open in the viewer.");
|
|
803
1448
|
var RunBatch = z2.object({
|
|
804
1449
|
ops: z2.array(z2.object({ op: z2.string(), args: z2.unknown() })),
|
|
805
|
-
transactional: z2.boolean().default(true).optional(),
|
|
806
|
-
undoGroupName: z2.string().default("AE MCP Batch").optional()
|
|
1450
|
+
transactional: z2.boolean().default(true).optional().describe("Stop at the first failing op instead of running the rest. Nothing rolls back either way \u2014 the ops before the failure stay applied, and the error says where it stopped."),
|
|
1451
|
+
undoGroupName: z2.string().default("AE MCP Batch").optional().describe(`What the user sees in After Effects' Edit > Undo menu. A chunked batch numbers its steps from this, e.g. "AE MCP Batch (3)".`),
|
|
1452
|
+
singleUndo: z2.boolean().default(false).optional().describe("Force the whole batch into ONE undo step, whatever its size, by running it in a single blocking ExtendScript call. Up to 2000 ops. The cost is real: After Effects' interface is frozen for the entire batch and no progress is reported, so the user sees nothing until it finishes. Without it a batch over 500 ops is chunked and lands as one undo step per chunk of 25 \u2014 the result reports the exact count. Reach for this only when the user has to be able to undo the work with a single Cmd-Z."),
|
|
1453
|
+
diff: diffParam,
|
|
1454
|
+
diffCompId: diffCompIdParam
|
|
807
1455
|
});
|
|
808
1456
|
var GetProjectSummary = z2.object({}).strict();
|
|
809
1457
|
var FindLayers = z2.object({
|
|
810
1458
|
compId: z2.number().optional(),
|
|
811
1459
|
namePattern: z2.string().optional(),
|
|
812
1460
|
type: z2.string().optional(),
|
|
813
|
-
hasEffectMatchName: z2.string().optional()
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1461
|
+
hasEffectMatchName: z2.string().optional(),
|
|
1462
|
+
// Same section names as list_layers, opposite default: a search is for
|
|
1463
|
+
// learning which layers exist and what to address them by, so omitting this
|
|
1464
|
+
// means the core alone rather than everything (issue #87). `includeParam`
|
|
1465
|
+
// is not reused because its sentence says "omit for all of them".
|
|
1466
|
+
include: z2.array(z2.enum(LAYER_SUMMARY_SECTIONS)).optional().describe(`Sections to add to each match: ${LAYER_SUMMARY_SECTIONS.join(", ")} \u2014 the same names as list_layers. Omit it (or pass []) for id/index/name/sourceType plus compId/compName only; unlike list_layers, omitting it here does NOT return every section. The result echoes what was included.`)
|
|
818
1467
|
});
|
|
1468
|
+
var RunJsx = crossField(
|
|
1469
|
+
z2.object({
|
|
1470
|
+
code: z2.string().optional().describe("The ExtendScript to run. Exactly one of `code` or `scriptPath`."),
|
|
1471
|
+
scriptPath: z2.string().optional().describe("Absolute path to a .jsx file to run instead of `code`. The server reads it, so a long script never enters the conversation. Exactly one of `code` or `scriptPath`."),
|
|
1472
|
+
libraries: z2.array(z2.string()).optional().describe("Absolute paths to .jsx files inlined ahead of the script, sharing its scope, so their functions are callable from it. The server reads them, so their text never enters the conversation. They are re-evaluated on every call \u2014 keep them to declarations, not to work. Put shared helpers here rather than pasting them into every script."),
|
|
1473
|
+
undoGroup: z2.boolean().default(true).optional().describe("Wrap the script in one undo step. Set false only for the operations AE refuses while an undo group is open \u2014 copyToComp on a layer with a parent or a linked expression. The script's changes then land as whatever undo steps AE records on its own."),
|
|
1474
|
+
diff: diffParam,
|
|
1475
|
+
diffCompId: diffCompIdParam
|
|
1476
|
+
}),
|
|
1477
|
+
// `resolveRunJsxSource` refuses both and neither too, and keeps doing so —
|
|
1478
|
+
// it is reachable from `run_batch`, whose steps are never zod-validated. What
|
|
1479
|
+
// the rule buys here is the half that runs *before* the call: declared, it
|
|
1480
|
+
// reaches the emitted JSON Schema, so the model sees the choice rather than
|
|
1481
|
+
// discovering it.
|
|
1482
|
+
{
|
|
1483
|
+
kind: "exactlyOne",
|
|
1484
|
+
fields: ["code", "scriptPath"],
|
|
1485
|
+
then: "Prefer `scriptPath` for anything long \u2014 the server reads the file, so the script never enters the conversation."
|
|
1486
|
+
}
|
|
1487
|
+
);
|
|
819
1488
|
var ImportFootage = z2.object({
|
|
820
1489
|
path: z2.string().min(1).describe("Absolute path to the file to import."),
|
|
821
1490
|
name: z2.string().optional().describe("Rename the project item after import. Omit to keep the filename."),
|
|
@@ -829,6 +1498,32 @@ var CreateFootageLayer = z2.object({
|
|
|
829
1498
|
position: VecAny.optional(),
|
|
830
1499
|
startTime: z2.number().optional()
|
|
831
1500
|
});
|
|
1501
|
+
var PurgeUnusedFootage = z2.object({
|
|
1502
|
+
solidsOnly: z2.boolean().default(true).optional().describe("Only remove solids \u2014 the items behind solid and adjustment layers. Pass false to remove every footage item no comp uses, imported files and placeholders included, which is what AE's own Remove Unused Footage does. Comps and folders are never removed either way."),
|
|
1503
|
+
dryRun: z2.boolean().default(false).optional().describe("List what would be removed without removing anything \u2014 not even an undo step."),
|
|
1504
|
+
folderId: z2.number().optional().describe("Restrict the sweep to one project folder and everything nested under it. A folder id from get_project_summary; omit for the whole project.")
|
|
1505
|
+
}).strict();
|
|
1506
|
+
var AudioCue = z2.object({
|
|
1507
|
+
footageId: z2.number().optional().describe("Project item id of an already-imported sound. Give this or `path`, not both."),
|
|
1508
|
+
path: z2.string().min(1).optional().describe("Absolute path to a sound file. Imported once per call however many cues name it, and an item already in the project from that path is reused rather than imported again."),
|
|
1509
|
+
time: z2.number().describe("Comp time in seconds where the cue starts."),
|
|
1510
|
+
levelDb: z2.number().optional().describe("Level in decibels, the same unit After Effects shows. 0 is the file untouched, -6 is roughly half as loud, negative is quieter. Defaults to 0, written explicitly so the result is the same on every machine."),
|
|
1511
|
+
name: z2.string().optional().describe("Layer name. Defaults to namePrefix + the file's basename without its extension."),
|
|
1512
|
+
inPoint: z2.number().optional().describe("Trim the layer's in point to this COMP time. Must not be earlier than `time`. Omit to play from the start of the file."),
|
|
1513
|
+
outPoint: z2.number().optional().describe("Trim the layer's out point to this COMP time. Omit to play to the end of the file."),
|
|
1514
|
+
label: z2.union([z2.number().int().min(0).max(16), z2.string()]).optional().describe("AE label colour, as an index 0-16 or a name (red, yellow, aqua, pink, lavender, peach, seafoam, blue, green, purple, orange, brown, fuchsia, cyan, sandstone, darkgreen)."),
|
|
1515
|
+
loop: z2.boolean().optional().describe("Loop the sound until `outPoint`, or until the end of the comp when no outPoint is given \u2014 for beds and ambiences. Enables time remapping with a wrap-around expression on Time Remap; the layer's in point still trims the front of the file the way it does on any layer. Default false: the file plays once."),
|
|
1516
|
+
fadeIn: z2.number().min(0).optional().describe("Seconds to fade in from `fadeFloorDb` up to `levelDb`, as keyframes on Audio Levels starting at the layer's in point. Default 0 (no fade). fadeIn + fadeOut must fit inside the cue's placed length or the cue is refused by index."),
|
|
1517
|
+
fadeOut: z2.number().min(0).optional().describe("Seconds to fade out from `levelDb` down to `fadeFloorDb`, ending at the layer's out point. Default 0 (no fade). fadeIn + fadeOut must fit inside the cue's placed length or the cue is refused by index."),
|
|
1518
|
+
stretch: z2.number().positive().optional().describe("Time stretch as a percentage: 100 is unchanged, 200 plays at half speed (lower pitch, twice as long), 50 at double speed. Must be greater than 0. The layer's start time and any trim are re-asserted after it, since After Effects moves them when stretch changes. Default 100.")
|
|
1519
|
+
}).strict();
|
|
1520
|
+
var PlaceAudioCues = z2.object({
|
|
1521
|
+
compId: z2.number(),
|
|
1522
|
+
cues: z2.array(AudioCue).min(1).max(200),
|
|
1523
|
+
namePrefix: z2.string().default("SFX_").optional().describe('Prefix for cues that do not name themselves. Pass "" for no prefix.'),
|
|
1524
|
+
fadeFloorDb: z2.number().optional().describe("The level in decibels that every fadeIn starts from and every fadeOut ends at. Default -48, the bottom of After Effects' own Audio Levels slider and 1/256 of the recorded amplitude \u2014 near-silence. Must be below each faded cue's levelDb. One floor for the whole list."),
|
|
1525
|
+
dryRun: z2.boolean().default(false).optional().describe("Resolve and check the whole list without importing, creating or changing anything \u2014 not even an undo step. Returns counts, the files it would import, the items it would reuse and the failing cues by index \u2014 never the whole resolved list.")
|
|
1526
|
+
}).strict();
|
|
832
1527
|
var ExportMogrt = z2.object({
|
|
833
1528
|
compId: z2.number(),
|
|
834
1529
|
destDir: z2.string().optional().describe("Folder to write the .mogrt into. Defaults to the folder holding the .aep."),
|
|
@@ -837,19 +1532,35 @@ var ExportMogrt = z2.object({
|
|
|
837
1532
|
posterTime: z2.number().optional().describe("Comp time to render as the template's still thumbnail, replacing the black one AE writes. Omit to leave AE's thumbnail alone."),
|
|
838
1533
|
suppressDialogs: z2.boolean().default(true).optional().describe("Suppress the modal font warning during export. Leave true: an unsuppressed dialog freezes the bridge until someone clicks it in AE. Set false only to see the dialog deliberately.")
|
|
839
1534
|
}).strict();
|
|
840
|
-
var GetHouseStyle = z2.object({
|
|
1535
|
+
var GetHouseStyle = z2.object({
|
|
1536
|
+
detail: z2.enum(["summary", "full"]).default("summary").optional().describe("'summary' (default) is a few hundred tokens: palette as named hexes, type, motion defaults, layout rules, and what it could not summarise. 'full' returns the whole document \u2014 use it before editing the guide with set_house_style, or when the summary is not enough.")
|
|
1537
|
+
}).strict();
|
|
841
1538
|
var SetHouseStyle = z2.object({
|
|
842
1539
|
content: z2.string().min(1).describe("The complete style guide as markdown. Replaces the file, so send the whole document."),
|
|
843
1540
|
overwrite: z2.boolean().default(false).optional().describe("Required to replace an existing guide. Read it with get_house_style and merge first \u2014 this is not a patch.")
|
|
844
1541
|
}).strict();
|
|
845
1542
|
var CheckSetup = z2.object({}).strict();
|
|
846
1543
|
var SetupPanel = z2.object({
|
|
847
|
-
enableDebugMode: z2.boolean().default(true).optional().describe("Also
|
|
1544
|
+
enableDebugMode: z2.boolean().default(true).optional().describe("Also set Adobe's PlayerDebugMode preference, which AE requires to load this unsigned panel, and CEP's LogLevel where it was never set, so CEP's own log records why a panel failed to load (issue #91). Default true."),
|
|
848
1545
|
force: z2.boolean().default(false).optional().describe("Replace an existing symlinked (development) install with a copy. Default false.")
|
|
849
1546
|
}).strict();
|
|
850
|
-
var GUIDE_TOPICS = [
|
|
1547
|
+
var GUIDE_TOPICS = [
|
|
1548
|
+
"ae-setup",
|
|
1549
|
+
"after-effects",
|
|
1550
|
+
"animation",
|
|
1551
|
+
"assembly",
|
|
1552
|
+
"extendscript-gotchas",
|
|
1553
|
+
"issue-journal",
|
|
1554
|
+
"mogrt-and-footage",
|
|
1555
|
+
"shapes",
|
|
1556
|
+
"sound",
|
|
1557
|
+
"style-guide",
|
|
1558
|
+
"text",
|
|
1559
|
+
"whats-new"
|
|
1560
|
+
];
|
|
851
1561
|
var AeGuide = z2.object({
|
|
852
|
-
topic: z2.enum(GUIDE_TOPICS).describe("after-effects:
|
|
1562
|
+
topic: z2.enum(GUIDE_TOPICS).describe("after-effects: the core \u2014 orienting, bounded reads, verifying a write, screenshots, the bridge failures \u2014 start here; it points at one topic per subject. animation: keyframes, easing, rigging, expressions. shapes: shape layers and their Contents. text: text layers. assembly: shots placed into a master comp, markers, retiming. extendscript-gotchas: read before writing raw ExtendScript for run_jsx. sound: sound effects and levels. mogrt-and-footage: exporting a .mogrt, importing footage. issue-journal: when a tool fought back. whats-new: what changed, one section per release, when a call behaves differently from what you expected or a project is being brought up to date after an upgrade. style-guide: capturing the user's look. ae-setup: connecting to AE when a tool cannot reach it."),
|
|
1563
|
+
since: z2.string().regex(/^v?\d+\.\d+\.\d+$/, "must be a version like 0.4.0").optional().describe("whats-new only: return just the releases newer than this version \u2014 the version named is the one already absorbed and is excluded \u2014 behind the guide's preamble, or one line saying nothing is newer. Use the version a project's docs record as last absorbed. The answer opens with the server's own version. Rejected on any other topic, since there is nothing there to filter.")
|
|
853
1564
|
}).strict();
|
|
854
1565
|
var InitProject = z2.object({
|
|
855
1566
|
dir: z2.string().optional().describe("Folder to create or fill, absolute or relative to the server's working directory. Ask the user if you do not know; do not invent one."),
|
|
@@ -862,19 +1573,29 @@ var LogIssue = z2.object({
|
|
|
862
1573
|
symptom: z2.string().min(3).describe("What went wrong, including the exact error text and the call that produced it."),
|
|
863
1574
|
workaround: z2.string().min(3).describe("What actually worked \u2014 concrete enough for the next session to apply without rediscovering it."),
|
|
864
1575
|
cause: z2.string().optional().describe("Why it happens, if you worked it out."),
|
|
865
|
-
tools: z2.array(z2.string()).optional().describe("Tool names involved, e.g. ['set_temporal_ease'].")
|
|
1576
|
+
tools: z2.array(z2.string()).optional().describe("Tool names involved, e.g. ['set_temporal_ease']. With errorText, this is what a later failure is matched on."),
|
|
1577
|
+
errorText: z2.string().optional().describe("The exact error text the failing call returned, verbatim. The next time a call to one of `tools` fails with matching text, its error names this entry \u2014 and a later log_issue with the same tool and error text extends this entry instead of creating a second one under a different title."),
|
|
1578
|
+
kind: z2.enum(["tool-bug", "ae-quirk"]).default("tool-bug").optional().describe("'tool-bug' (default): something these tools get wrong, which a later release may fix \u2014 so the entry is archived once it was last seen on an older server than the one running. 'ae-quirk': After Effects itself behaving unlike its documentation; no release changes that, so it is never archived by version."),
|
|
1579
|
+
scope: z2.enum(["project", "user"]).default("project").optional().describe("'project' (default) for this project's footage, comps or files. 'user' for how these tools or After Effects behave \u2014 that journal travels with the person, so every future project starts knowing it. Reported back as 'home' when there is no project folder to write into.")
|
|
866
1580
|
}).strict();
|
|
867
1581
|
var ListKnownIssues = z2.object({
|
|
868
1582
|
status: z2.enum(["all", "unreported", "reported"]).default("all").optional(),
|
|
869
1583
|
tool: z2.string().optional().describe("Only entries about this tool, e.g. 'set_temporal_ease'. Omit for everything."),
|
|
870
1584
|
query: z2.string().optional().describe("Free-text filter: every whitespace-separated term must appear in an entry's title, symptom or tools."),
|
|
871
|
-
id: z2.string().optional().describe("Read one entry in full \u2014 cause and workaround included \u2014 by the id from a previous listing. Ignores the filters."),
|
|
872
|
-
detail: z2.enum(["index", "full"]).default("index").optional().describe("'index' (default) is one line per entry: id, title, tools,
|
|
1585
|
+
id: z2.string().optional().describe("Read one entry in full \u2014 cause and workaround included \u2014 by the id from a previous listing. Ignores the filters. Ids are unique only within a journal, so prefix with the entry's scope ('user:my-entry') when the listing shows one in each."),
|
|
1586
|
+
detail: z2.enum(["index", "full"]).default("index").optional().describe("'index' (default) is one line per entry: id, title, tools, kind, when and on which version it was last seen, counts \u2014 read the one you need with `id`. 'full' returns every matching entry's whole body and costs thousands of tokens."),
|
|
1587
|
+
scope: z2.enum(["all", "project", "user"]).default("all").optional().describe("Which journal to read. 'all' (default) merges this project's with the user's cross-project one and tags every entry with the scope it came from."),
|
|
1588
|
+
limit: z2.number().int().positive().max(500).default(50).optional().describe("Most entries to return. Anything held back is counted in `omitted`."),
|
|
1589
|
+
includeArchived: z2.boolean().default(false).optional().describe("Also list archived entries \u2014 not seen for 30 days, last seen on an older server than this one, or retired with archive_issue \u2014 each flagged with its reason. Default false: they are hidden and only counted in `archivedCount`. An `id` read always returns the entry, archived or not.")
|
|
873
1590
|
}).strict();
|
|
874
1591
|
var MarkIssueReported = z2.object({
|
|
875
|
-
id: z2.string().describe("The entry id returned by log_issue or list_known_issues."),
|
|
1592
|
+
id: z2.string().describe("The entry id returned by log_issue or list_known_issues. Prefix with its scope ('user:my-entry') when the same id exists in both journals."),
|
|
876
1593
|
url: z2.string().optional().describe("Link to the issue that was opened.")
|
|
877
1594
|
}).strict();
|
|
1595
|
+
var ArchiveIssue = z2.object({
|
|
1596
|
+
id: z2.string().describe("The entry id from log_issue or list_known_issues. Prefix with its scope ('user:my-entry') when the same id exists in both journals \u2014 only the one named moves."),
|
|
1597
|
+
reason: z2.string().min(3).describe("Why it is being retired, one line \u2014 the URL of the report that already covers it, the release that fixed it, or where the lesson was moved to.")
|
|
1598
|
+
}).strict();
|
|
878
1599
|
var AwaitJob = z2.object({ jobId: z2.string(), timeoutMs: z2.number().int().positive().default(6e5).optional() });
|
|
879
1600
|
var GetJob = z2.object({ jobId: z2.string() });
|
|
880
1601
|
var CancelJob = z2.object({ jobId: z2.string() });
|
|
@@ -887,6 +1608,9 @@ var OpSchemas = {
|
|
|
887
1608
|
set_comp: SetComp,
|
|
888
1609
|
delete_comp: DeleteComp,
|
|
889
1610
|
set_active_comp: SetActiveComp,
|
|
1611
|
+
duplicate_comp: DuplicateComp,
|
|
1612
|
+
snapshot_comp: SnapshotComp,
|
|
1613
|
+
diff_comp: DiffComp,
|
|
890
1614
|
// layers
|
|
891
1615
|
list_layers: ListLayers,
|
|
892
1616
|
get_layer_full: GetLayerFull,
|
|
@@ -949,6 +1673,9 @@ var OpSchemas = {
|
|
|
949
1673
|
// footage
|
|
950
1674
|
import_footage: ImportFootage,
|
|
951
1675
|
create_footage_layer: CreateFootageLayer,
|
|
1676
|
+
purge_unused_footage: PurgeUnusedFootage,
|
|
1677
|
+
// audio
|
|
1678
|
+
place_audio_cues: PlaceAudioCues,
|
|
952
1679
|
// motion graphics templates
|
|
953
1680
|
export_mogrt: ExportMogrt,
|
|
954
1681
|
// raw
|
|
@@ -969,91 +1696,116 @@ var OpSchemas = {
|
|
|
969
1696
|
// issue journal
|
|
970
1697
|
log_issue: LogIssue,
|
|
971
1698
|
list_known_issues: ListKnownIssues,
|
|
972
|
-
mark_issue_reported: MarkIssueReported
|
|
1699
|
+
mark_issue_reported: MarkIssueReported,
|
|
1700
|
+
archive_issue: ArchiveIssue
|
|
973
1701
|
};
|
|
974
|
-
|
|
975
|
-
//
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1702
|
+
var OpMutation = {
|
|
1703
|
+
// comps
|
|
1704
|
+
list_comps: "read",
|
|
1705
|
+
get_comp: "read",
|
|
1706
|
+
get_comp_tree: "read",
|
|
1707
|
+
create_comp: "write",
|
|
1708
|
+
set_comp: "write",
|
|
1709
|
+
delete_comp: "write",
|
|
1710
|
+
set_active_comp: "write",
|
|
1711
|
+
duplicate_comp: "write",
|
|
1712
|
+
// Fingerprints: they forward a read to the panel and keep the answer in the
|
|
1713
|
+
// server. Nothing is written to the project or the undo stack, so they must
|
|
1714
|
+
// not queue — the point of a diff is checking on a write that is in flight.
|
|
1715
|
+
snapshot_comp: "read",
|
|
1716
|
+
diff_comp: "read",
|
|
1717
|
+
// layers
|
|
1718
|
+
list_layers: "read",
|
|
1719
|
+
get_layer_full: "read",
|
|
1720
|
+
create_text_layer: "write",
|
|
1721
|
+
create_shape_layer: "write",
|
|
1722
|
+
create_solid_layer: "write",
|
|
1723
|
+
create_null_layer: "write",
|
|
1724
|
+
create_adjustment_layer: "write",
|
|
1725
|
+
create_precomp_layer: "write",
|
|
1726
|
+
create_camera_layer: "write",
|
|
1727
|
+
create_light_layer: "write",
|
|
1728
|
+
duplicate_layer: "write",
|
|
1729
|
+
delete_layer: "write",
|
|
1730
|
+
set_layer: "write",
|
|
1731
|
+
parent_layer: "write",
|
|
1732
|
+
reorder_layer: "write",
|
|
1733
|
+
// transforms
|
|
1734
|
+
set_transform: "write",
|
|
1735
|
+
// keyframes
|
|
1736
|
+
add_keyframe: "write",
|
|
1737
|
+
remove_keyframe: "write",
|
|
1738
|
+
get_keyframes: "read",
|
|
1739
|
+
set_interpolation: "write",
|
|
1740
|
+
set_temporal_ease: "write",
|
|
1741
|
+
set_spatial_tangents: "write",
|
|
1742
|
+
// expressions
|
|
1743
|
+
get_expression: "read",
|
|
1744
|
+
set_expression: "write",
|
|
1745
|
+
toggle_expression: "write",
|
|
1746
|
+
clear_expression: "write",
|
|
1747
|
+
// effects
|
|
1748
|
+
list_effects: "read",
|
|
1749
|
+
add_effect: "write",
|
|
1750
|
+
remove_effect: "write",
|
|
1751
|
+
set_effect_param: "write",
|
|
1752
|
+
set_effect_enabled: "write",
|
|
1753
|
+
list_available_effects: "read",
|
|
1754
|
+
// text
|
|
1755
|
+
set_text: "write",
|
|
1756
|
+
add_text_animator: "write",
|
|
1757
|
+
// shapes
|
|
1758
|
+
set_shape_path: "write",
|
|
1759
|
+
add_shape_content: "write",
|
|
1760
|
+
set_shape_property: "write",
|
|
1761
|
+
// masks
|
|
1762
|
+
add_mask: "write",
|
|
1763
|
+
set_mask: "write",
|
|
1764
|
+
remove_mask: "write",
|
|
1765
|
+
// markers
|
|
1766
|
+
add_marker: "write",
|
|
1767
|
+
remove_marker: "write",
|
|
1768
|
+
// vision — read-only despite being the slowest thing here. `screenshot_*`
|
|
1769
|
+
// borrows the comp's resolutionFactor and restores it in a `finally`;
|
|
1770
|
+
// nothing in the project changes.
|
|
1771
|
+
screenshot_frame: "read",
|
|
1772
|
+
screenshot_layer: "read",
|
|
1773
|
+
// batch
|
|
1774
|
+
run_batch: "write",
|
|
1775
|
+
// explore
|
|
1776
|
+
get_project_summary: "read",
|
|
1777
|
+
find_layers: "read",
|
|
1778
|
+
// footage
|
|
1779
|
+
import_footage: "write",
|
|
1780
|
+
create_footage_layer: "write",
|
|
1781
|
+
// Removes project items. A dryRun changes nothing, but the table is per op,
|
|
1782
|
+
// not per call — a dry run waiting behind a batch costs a little time and a
|
|
1783
|
+
// real one interleaving with it costs the batch's undo step.
|
|
1784
|
+
purge_unused_footage: "write",
|
|
1785
|
+
// audio cues — imports footage and adds layers.
|
|
1786
|
+
place_audio_cues: "write",
|
|
1787
|
+
// motion graphics templates — saves the project before exporting.
|
|
1788
|
+
export_mogrt: "write",
|
|
1789
|
+
// raw — the script is the caller's and may do anything. Assume the worst.
|
|
1790
|
+
run_jsx: "write",
|
|
1791
|
+
// house style — written over the bridge into a file beside the .aep.
|
|
1792
|
+
get_house_style: "read",
|
|
1793
|
+
set_house_style: "write",
|
|
1794
|
+
// jobs
|
|
1795
|
+
await_job: "server",
|
|
1796
|
+
get_job: "server",
|
|
1797
|
+
cancel_job: "server",
|
|
1798
|
+
// setup
|
|
1799
|
+
check_setup: "server",
|
|
1800
|
+
setup_panel: "server",
|
|
1801
|
+
init_project: "server",
|
|
1802
|
+
// guidance
|
|
1803
|
+
ae_guide: "server",
|
|
1804
|
+
// issue journal
|
|
1805
|
+
log_issue: "server",
|
|
1806
|
+
list_known_issues: "server",
|
|
1807
|
+
mark_issue_reported: "server",
|
|
1808
|
+
archive_issue: "server"
|
|
1057
1809
|
};
|
|
1058
1810
|
|
|
1059
1811
|
// src/util/logger.ts
|
|
@@ -1066,32 +1818,18 @@ var logger = {
|
|
|
1066
1818
|
}
|
|
1067
1819
|
};
|
|
1068
1820
|
|
|
1069
|
-
// src/bridge/discovery.ts
|
|
1070
|
-
import fs2 from "node:fs";
|
|
1071
|
-
import os2 from "node:os";
|
|
1072
|
-
import path3 from "node:path";
|
|
1073
|
-
var DEFAULT_PORT = 7777;
|
|
1074
|
-
function discoverPort() {
|
|
1075
|
-
const envPort = process.env.AE_MCP_PORT;
|
|
1076
|
-
if (envPort) {
|
|
1077
|
-
const n = parseInt(envPort, 10);
|
|
1078
|
-
if (Number.isFinite(n)) return n;
|
|
1079
|
-
}
|
|
1080
|
-
try {
|
|
1081
|
-
const f = path3.join(os2.homedir(), ".engineroom-ae-mcp", "port");
|
|
1082
|
-
if (fs2.existsSync(f)) {
|
|
1083
|
-
const txt = fs2.readFileSync(f, "utf8").trim();
|
|
1084
|
-
const n = parseInt(txt, 10);
|
|
1085
|
-
if (Number.isFinite(n)) return n;
|
|
1086
|
-
}
|
|
1087
|
-
} catch {
|
|
1088
|
-
}
|
|
1089
|
-
return DEFAULT_PORT;
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
1821
|
// src/bridge/httpClient.ts
|
|
1093
1822
|
var DEFAULT_OP_TIMEOUT_MS = 12e4;
|
|
1094
|
-
var SLOW_OPS = /* @__PURE__ */ new Set([
|
|
1823
|
+
var SLOW_OPS = /* @__PURE__ */ new Set([
|
|
1824
|
+
"run_batch",
|
|
1825
|
+
"run_jsx",
|
|
1826
|
+
"screenshot_frame",
|
|
1827
|
+
"screenshot_layer",
|
|
1828
|
+
"export_mogrt",
|
|
1829
|
+
"import_footage",
|
|
1830
|
+
"place_audio_cues",
|
|
1831
|
+
"purge_unused_footage"
|
|
1832
|
+
]);
|
|
1095
1833
|
var SLOW_OP_TIMEOUT_MS = 3e5;
|
|
1096
1834
|
function opTimeoutMs(op) {
|
|
1097
1835
|
const raw = process.env.AE_MCP_OP_TIMEOUT_MS?.trim();
|
|
@@ -1103,12 +1841,61 @@ function opTimeoutMs(op) {
|
|
|
1103
1841
|
return op && SLOW_OPS.has(op) ? SLOW_OP_TIMEOUT_MS : DEFAULT_OP_TIMEOUT_MS;
|
|
1104
1842
|
}
|
|
1105
1843
|
var HEALTH_TIMEOUT_MS = 2e3;
|
|
1844
|
+
var LISTENER_SETTLE_MS = 750;
|
|
1845
|
+
function probeSummary(located) {
|
|
1846
|
+
if (located.probed.length === 0) return "";
|
|
1847
|
+
const busy = located.probed.find((p) => p.status === "busy");
|
|
1848
|
+
const parts = located.probed.map((p) => p.detail);
|
|
1849
|
+
const head = `Also looked for the panel on port${located.probed.length > 1 ? "s" : ""} ${located.probed.map((p) => p.port).join(", ")}: ${parts.join("; ")}.`;
|
|
1850
|
+
const pinned = pinnedPort();
|
|
1851
|
+
const pin = pinned !== null ? ` AE_MCP_PORT pins this server to port ${pinned}, so no other port was tried; unset it (or point it at the port check_setup reports) to let the server look at ${DEFAULT_PORT} and the port file.` : "";
|
|
1852
|
+
return busy ? `${head} A listener on port ${busy.port} may be the panel with After Effects busy; the next call looks again.${pin}` : `${head}${pin}`;
|
|
1853
|
+
}
|
|
1106
1854
|
var HttpClient = class {
|
|
1107
1855
|
port;
|
|
1108
1856
|
base;
|
|
1109
|
-
|
|
1857
|
+
/** The port this client started on — what a later drift is measured against. */
|
|
1858
|
+
initialPort;
|
|
1859
|
+
listeners = [];
|
|
1860
|
+
candidatesFn;
|
|
1861
|
+
constructor(port, opts = {}) {
|
|
1110
1862
|
this.port = port ?? discoverPort();
|
|
1863
|
+
this.initialPort = this.port;
|
|
1111
1864
|
this.base = `http://127.0.0.1:${this.port}`;
|
|
1865
|
+
this.candidatesFn = opts.candidates ?? portCandidates;
|
|
1866
|
+
}
|
|
1867
|
+
/** The ports a rediscovery will ask, in order. Exposed so `check_setup` asks the same ones. */
|
|
1868
|
+
candidates() {
|
|
1869
|
+
return this.candidatesFn();
|
|
1870
|
+
}
|
|
1871
|
+
onPortChange(listener) {
|
|
1872
|
+
this.listeners.push(listener);
|
|
1873
|
+
}
|
|
1874
|
+
/**
|
|
1875
|
+
* Move to another port and let the dependants follow before anything else
|
|
1876
|
+
* is sent there. The wait is bounded: a listener that never settles must not
|
|
1877
|
+
* turn a port switch into a hang.
|
|
1878
|
+
*/
|
|
1879
|
+
async switchPort(next) {
|
|
1880
|
+
const previous = this.port;
|
|
1881
|
+
if (next === previous) return;
|
|
1882
|
+
this.port = next;
|
|
1883
|
+
this.base = `http://127.0.0.1:${next}`;
|
|
1884
|
+
logger.warn(`Bridge port changed: ${previous} -> ${next}`);
|
|
1885
|
+
await Promise.race([
|
|
1886
|
+
Promise.all(this.listeners.map((l) => Promise.resolve().then(() => l(next, previous)).catch((e) => {
|
|
1887
|
+
logger.warn(`port-change listener failed: ${e.message}`);
|
|
1888
|
+
}))),
|
|
1889
|
+
new Promise((r) => setTimeout(r, LISTENER_SETTLE_MS).unref?.())
|
|
1890
|
+
]);
|
|
1891
|
+
}
|
|
1892
|
+
/**
|
|
1893
|
+
* Ask the candidate ports where the panel is now. Returns the probe that
|
|
1894
|
+
* answered as the panel, or null — and the whole list of what was asked, so
|
|
1895
|
+
* a failure can say what it looked at.
|
|
1896
|
+
*/
|
|
1897
|
+
async rediscover() {
|
|
1898
|
+
return locatePanel(this.candidates());
|
|
1112
1899
|
}
|
|
1113
1900
|
// `bundleHash` is absent on panels installed before it was added; callers must
|
|
1114
1901
|
// treat undefined as "too old to say" rather than as a mismatch.
|
|
@@ -1124,7 +1911,12 @@ var HttpClient = class {
|
|
|
1124
1911
|
throw new BridgeUnreachableError(this.port, e);
|
|
1125
1912
|
}
|
|
1126
1913
|
}
|
|
1127
|
-
|
|
1914
|
+
/**
|
|
1915
|
+
* One POST to /op on the current port. Its own `AbortSignal.timeout`, created
|
|
1916
|
+
* here, so a retry after a port switch gets the full budget rather than the
|
|
1917
|
+
* remainder of a clock that started before the refusal.
|
|
1918
|
+
*/
|
|
1919
|
+
async postOp(op, args, progressToken) {
|
|
1128
1920
|
let resp;
|
|
1129
1921
|
const timeoutMs = opTimeoutMs(op);
|
|
1130
1922
|
try {
|
|
@@ -1145,10 +1937,47 @@ var HttpClient = class {
|
|
|
1145
1937
|
throw new AeError(`Bridge returned non-JSON (HTTP ${resp.status})`);
|
|
1146
1938
|
}
|
|
1147
1939
|
if (!data.ok) {
|
|
1148
|
-
throw new AeError(data.error, data.stack, data.line, data.code);
|
|
1940
|
+
throw new AeError(data.error, data.stack, data.line, data.code, data.source);
|
|
1149
1941
|
}
|
|
1150
1942
|
return data.result;
|
|
1151
1943
|
}
|
|
1944
|
+
/**
|
|
1945
|
+
* Forward an op, following the panel if it has moved.
|
|
1946
|
+
*
|
|
1947
|
+
* The port is discovered once, at construction, and every op posts there —
|
|
1948
|
+
* which is how `check_setup` could report the panel healthy on 7777 while
|
|
1949
|
+
* every op failed on 7778 for a week (issue #92): the panel had moved after
|
|
1950
|
+
* this server read the port file. So a *refused* connection is taken as a
|
|
1951
|
+
* cue to look again. Only a refusal, and this is the whole safety argument:
|
|
1952
|
+
* refused means the request never reached a panel, so sending it again
|
|
1953
|
+
* cannot run anything twice. A timeout is the opposite case — the call did
|
|
1954
|
+
* reach After Effects and may still be running — and must never be retried
|
|
1955
|
+
* (issue #43 is what a duplicated `run_jsx` costs).
|
|
1956
|
+
*
|
|
1957
|
+
* The retry happens only when the search turns up a *different* port that
|
|
1958
|
+
* answers as the panel. The same port answering again means the refusal was
|
|
1959
|
+
* something else, and that is the original error's story to tell.
|
|
1960
|
+
*/
|
|
1961
|
+
async runOp(op, args, progressToken) {
|
|
1962
|
+
try {
|
|
1963
|
+
return await this.postOp(op, args, progressToken);
|
|
1964
|
+
} catch (e) {
|
|
1965
|
+
if (!(e instanceof BridgeUnreachableError)) throw e;
|
|
1966
|
+
const refusedPort = this.port;
|
|
1967
|
+
const located = await this.rediscover();
|
|
1968
|
+
if (located.found && located.found.port !== refusedPort) {
|
|
1969
|
+
logger.warn(
|
|
1970
|
+
`\`${op}\` was refused on port ${refusedPort}; the panel is answering on port ${located.found.port}. Switching and re-sending once.`
|
|
1971
|
+
);
|
|
1972
|
+
await this.switchPort(located.found.port);
|
|
1973
|
+
return await this.postOp(op, args, progressToken);
|
|
1974
|
+
}
|
|
1975
|
+
const summary = probeSummary(located);
|
|
1976
|
+
if (summary) e.message += `
|
|
1977
|
+
${summary}`;
|
|
1978
|
+
throw e;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1152
1981
|
async cancel(jobId) {
|
|
1153
1982
|
try {
|
|
1154
1983
|
await fetch(`${this.base}/cancel`, {
|
|
@@ -1165,11 +1994,12 @@ var HttpClient = class {
|
|
|
1165
1994
|
// src/bridge/wsClient.ts
|
|
1166
1995
|
import WebSocket from "ws";
|
|
1167
1996
|
var WsClient = class {
|
|
1168
|
-
constructor(
|
|
1169
|
-
this.
|
|
1997
|
+
constructor(bridge, jobs) {
|
|
1998
|
+
this.bridge = bridge;
|
|
1170
1999
|
this.jobs = jobs;
|
|
2000
|
+
bridge.onPortChange(() => this.reconnect());
|
|
1171
2001
|
}
|
|
1172
|
-
|
|
2002
|
+
bridge;
|
|
1173
2003
|
jobs;
|
|
1174
2004
|
ws;
|
|
1175
2005
|
reconnectTimer;
|
|
@@ -1185,26 +2015,46 @@ var WsClient = class {
|
|
|
1185
2015
|
} catch {
|
|
1186
2016
|
}
|
|
1187
2017
|
}
|
|
2018
|
+
/** Drop the current socket and connect to wherever the bridge is now. Resolves on open, or on failure. */
|
|
2019
|
+
reconnect() {
|
|
2020
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
2021
|
+
const old = this.ws;
|
|
2022
|
+
this.ws = void 0;
|
|
2023
|
+
try {
|
|
2024
|
+
old?.removeAllListeners();
|
|
2025
|
+
old?.on("error", () => {
|
|
2026
|
+
});
|
|
2027
|
+
old?.close();
|
|
2028
|
+
} catch {
|
|
2029
|
+
}
|
|
2030
|
+
return this.connect();
|
|
2031
|
+
}
|
|
1188
2032
|
connect() {
|
|
1189
|
-
if (this.stopped) return;
|
|
1190
|
-
const url = `ws://127.0.0.1:${this.port}/events`;
|
|
2033
|
+
if (this.stopped) return Promise.resolve();
|
|
2034
|
+
const url = `ws://127.0.0.1:${this.bridge.port}/events`;
|
|
1191
2035
|
const ws = new WebSocket(url);
|
|
1192
2036
|
this.ws = ws;
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
2037
|
+
return new Promise((resolve) => {
|
|
2038
|
+
ws.on("open", () => {
|
|
2039
|
+
logger.debug("WS connected", url);
|
|
2040
|
+
resolve();
|
|
2041
|
+
});
|
|
2042
|
+
ws.on("message", (data) => {
|
|
2043
|
+
let evt;
|
|
2044
|
+
try {
|
|
2045
|
+
evt = JSON.parse(data.toString());
|
|
2046
|
+
} catch {
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
this.handleEvent(evt);
|
|
2050
|
+
});
|
|
2051
|
+
ws.on("close", () => {
|
|
2052
|
+
resolve();
|
|
2053
|
+
if (this.stopped || this.ws !== ws) return;
|
|
2054
|
+
this.reconnectTimer = setTimeout(() => this.connect(), 1500);
|
|
2055
|
+
});
|
|
2056
|
+
ws.on("error", () => {
|
|
2057
|
+
});
|
|
1208
2058
|
});
|
|
1209
2059
|
}
|
|
1210
2060
|
handleEvent(evt) {
|
|
@@ -1225,6 +2075,168 @@ var WsClient = class {
|
|
|
1225
2075
|
}
|
|
1226
2076
|
};
|
|
1227
2077
|
|
|
2078
|
+
// src/bridge/writeQueue.ts
|
|
2079
|
+
var DEFAULT_MAX_WAIT_MS = 6e5;
|
|
2080
|
+
var DEFAULT_MAX_DEPTH = 64;
|
|
2081
|
+
function queueWaitMs() {
|
|
2082
|
+
return envNumber("AE_MCP_WRITE_QUEUE_WAIT_MS", DEFAULT_MAX_WAIT_MS);
|
|
2083
|
+
}
|
|
2084
|
+
function queueMaxDepth() {
|
|
2085
|
+
return envNumber("AE_MCP_WRITE_QUEUE_DEPTH", DEFAULT_MAX_DEPTH);
|
|
2086
|
+
}
|
|
2087
|
+
function envNumber(name, fallback) {
|
|
2088
|
+
const raw = process.env[name]?.trim();
|
|
2089
|
+
if (!raw) return fallback;
|
|
2090
|
+
const n = Number.parseInt(raw, 10);
|
|
2091
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
2092
|
+
logger.warn(`Ignoring ${name}=${raw} \u2014 expected a positive number.`);
|
|
2093
|
+
return fallback;
|
|
2094
|
+
}
|
|
2095
|
+
var WriteQueue = class {
|
|
2096
|
+
holder = null;
|
|
2097
|
+
waiting = [];
|
|
2098
|
+
maxWaitMs;
|
|
2099
|
+
maxDepth;
|
|
2100
|
+
constructor(opts = {}) {
|
|
2101
|
+
this.maxWaitMs = opts.maxWaitMs ?? queueWaitMs();
|
|
2102
|
+
this.maxDepth = opts.maxDepth ?? queueMaxDepth();
|
|
2103
|
+
}
|
|
2104
|
+
/** For tests and diagnostics. */
|
|
2105
|
+
get depth() {
|
|
2106
|
+
return this.waiting.filter((w) => !w.done).length;
|
|
2107
|
+
}
|
|
2108
|
+
get held() {
|
|
2109
|
+
return this.holder;
|
|
2110
|
+
}
|
|
2111
|
+
/**
|
|
2112
|
+
* The longest a lease may be held past its call by `extendUntil` — a leak
|
|
2113
|
+
* guard for a job that never reports completion (a dropped WS, say).
|
|
2114
|
+
*
|
|
2115
|
+
* Twice the wait ceiling on purpose. Anything already queued behind that job
|
|
2116
|
+
* has hit its own deadline and gone by then, so expiring the hold can never
|
|
2117
|
+
* hand the lock to a writer while the batch is still going.
|
|
2118
|
+
*/
|
|
2119
|
+
get holdCeilingMs() {
|
|
2120
|
+
return this.maxWaitMs * 2;
|
|
2121
|
+
}
|
|
2122
|
+
/**
|
|
2123
|
+
* Wait for the lock, then return the lease.
|
|
2124
|
+
*
|
|
2125
|
+
* Rejects rather than resolving late when the request was cancelled, when the
|
|
2126
|
+
* wait ran past `maxWaitMs`, or when the queue is full. In every one of those
|
|
2127
|
+
* cases the caller must not go on to hit the bridge — nothing was written,
|
|
2128
|
+
* and the error says so, which is what makes re-sending safe there and unsafe
|
|
2129
|
+
* after a bridge timeout.
|
|
2130
|
+
*/
|
|
2131
|
+
acquire(op, signal) {
|
|
2132
|
+
if (signal?.aborted) {
|
|
2133
|
+
return Promise.reject(new WriteQueueCancelledError(op));
|
|
2134
|
+
}
|
|
2135
|
+
if (this.holder === null && this.depth === 0) {
|
|
2136
|
+
this.holder = op;
|
|
2137
|
+
return Promise.resolve(this.makeLease(null));
|
|
2138
|
+
}
|
|
2139
|
+
if (this.depth >= this.maxDepth) {
|
|
2140
|
+
return Promise.reject(new WriteQueueFullError(op, this.maxDepth, this.holder ?? "another write"));
|
|
2141
|
+
}
|
|
2142
|
+
const behind = this.holder ?? this.waiting.find((w) => !w.done)?.op ?? "another write";
|
|
2143
|
+
return new Promise((resolve, reject) => {
|
|
2144
|
+
const waiter = {
|
|
2145
|
+
op,
|
|
2146
|
+
enqueuedAt: Date.now(),
|
|
2147
|
+
behind,
|
|
2148
|
+
done: false,
|
|
2149
|
+
settle: resolve,
|
|
2150
|
+
fail: reject,
|
|
2151
|
+
cleanup: () => {
|
|
2152
|
+
}
|
|
2153
|
+
};
|
|
2154
|
+
const timer = setTimeout(() => {
|
|
2155
|
+
if (waiter.done) return;
|
|
2156
|
+
waiter.done = true;
|
|
2157
|
+
waiter.cleanup();
|
|
2158
|
+
reject(new WriteQueueWaitError(op, this.maxWaitMs, waiter.behind));
|
|
2159
|
+
this.pump();
|
|
2160
|
+
}, this.maxWaitMs);
|
|
2161
|
+
timer.unref?.();
|
|
2162
|
+
const onAbort = () => {
|
|
2163
|
+
if (waiter.done) return;
|
|
2164
|
+
waiter.done = true;
|
|
2165
|
+
waiter.cleanup();
|
|
2166
|
+
reject(new WriteQueueCancelledError(op));
|
|
2167
|
+
this.pump();
|
|
2168
|
+
};
|
|
2169
|
+
waiter.cleanup = () => {
|
|
2170
|
+
clearTimeout(timer);
|
|
2171
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2172
|
+
};
|
|
2173
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2174
|
+
this.waiting.push(waiter);
|
|
2175
|
+
});
|
|
2176
|
+
}
|
|
2177
|
+
makeLease(wait) {
|
|
2178
|
+
let released = false;
|
|
2179
|
+
let hold = null;
|
|
2180
|
+
const queue = this;
|
|
2181
|
+
return {
|
|
2182
|
+
wait,
|
|
2183
|
+
extendUntil(p) {
|
|
2184
|
+
hold = p;
|
|
2185
|
+
if (released) settleHold();
|
|
2186
|
+
},
|
|
2187
|
+
release() {
|
|
2188
|
+
if (released) return;
|
|
2189
|
+
released = true;
|
|
2190
|
+
if (hold) settleHold();
|
|
2191
|
+
else queue.handOff();
|
|
2192
|
+
}
|
|
2193
|
+
};
|
|
2194
|
+
function settleHold() {
|
|
2195
|
+
const p = hold;
|
|
2196
|
+
hold = null;
|
|
2197
|
+
if (!p) return;
|
|
2198
|
+
const ceiling = queue.holdCeilingMs;
|
|
2199
|
+
let fired = false;
|
|
2200
|
+
const done = () => {
|
|
2201
|
+
if (fired) return;
|
|
2202
|
+
fired = true;
|
|
2203
|
+
queue.handOff();
|
|
2204
|
+
};
|
|
2205
|
+
const t = setTimeout(() => {
|
|
2206
|
+
if (fired) return;
|
|
2207
|
+
logger.warn(
|
|
2208
|
+
`A write held the After Effects queue for over ${Math.round(ceiling / 1e3)}s without its job reporting completion; releasing it.`
|
|
2209
|
+
);
|
|
2210
|
+
done();
|
|
2211
|
+
}, ceiling);
|
|
2212
|
+
t.unref?.();
|
|
2213
|
+
p.then(done, done).finally(() => clearTimeout(t));
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
handOff() {
|
|
2217
|
+
this.holder = null;
|
|
2218
|
+
this.pump();
|
|
2219
|
+
}
|
|
2220
|
+
pump() {
|
|
2221
|
+
if (this.holder !== null) return;
|
|
2222
|
+
while (this.waiting.length > 0) {
|
|
2223
|
+
const next = this.waiting.shift();
|
|
2224
|
+
if (next.done) continue;
|
|
2225
|
+
next.done = true;
|
|
2226
|
+
next.cleanup();
|
|
2227
|
+
this.holder = next.op;
|
|
2228
|
+
next.settle(this.makeLease({ queuedBehind: next.behind, waitedMs: Date.now() - next.enqueuedAt }));
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
};
|
|
2233
|
+
function mergeWait(value, wait) {
|
|
2234
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
2235
|
+
const obj = value;
|
|
2236
|
+
if ("queuedBehind" in obj || "waitedMs" in obj) return null;
|
|
2237
|
+
return { ...obj, ...wait };
|
|
2238
|
+
}
|
|
2239
|
+
|
|
1228
2240
|
// src/jobs/manager.ts
|
|
1229
2241
|
var JobManager = class {
|
|
1230
2242
|
jobs = /* @__PURE__ */ new Map();
|
|
@@ -1241,10 +2253,34 @@ var JobManager = class {
|
|
|
1241
2253
|
this.jobs.set(jobId, s);
|
|
1242
2254
|
return s;
|
|
1243
2255
|
}
|
|
2256
|
+
/**
|
|
2257
|
+
* Forward this job's progress to `fn` until the job settles or the returned
|
|
2258
|
+
* function is called.
|
|
2259
|
+
*
|
|
2260
|
+
* The caller owns the lifetime, and the returned unbind is not optional: an
|
|
2261
|
+
* emitter is bound for the span of one MCP request — `await_job` — and one
|
|
2262
|
+
* left behind would keep sending `notifications/progress` on that request's
|
|
2263
|
+
* token after its response, which is exactly what a spec-compliant client
|
|
2264
|
+
* stops listening for the moment the response arrives (issue #82). `flush`
|
|
2265
|
+
* drops every emitter when the job finishes, so unbinding after that is a
|
|
2266
|
+
* no-op rather than an error.
|
|
2267
|
+
*/
|
|
1244
2268
|
bindProgressEmitter(jobId, fn) {
|
|
1245
2269
|
const arr = this.progressEmitters.get(jobId) ?? [];
|
|
1246
2270
|
arr.push(fn);
|
|
1247
2271
|
this.progressEmitters.set(jobId, arr);
|
|
2272
|
+
return () => this.unbindProgressEmitter(jobId, fn);
|
|
2273
|
+
}
|
|
2274
|
+
unbindProgressEmitter(jobId, fn) {
|
|
2275
|
+
const arr = this.progressEmitters.get(jobId);
|
|
2276
|
+
if (!arr) return;
|
|
2277
|
+
const i = arr.indexOf(fn);
|
|
2278
|
+
if (i >= 0) arr.splice(i, 1);
|
|
2279
|
+
if (arr.length === 0) this.progressEmitters.delete(jobId);
|
|
2280
|
+
}
|
|
2281
|
+
/** How many emitters a job currently has. Exists for the tests. */
|
|
2282
|
+
progressEmitterCount(jobId) {
|
|
2283
|
+
return this.progressEmitters.get(jobId)?.length ?? 0;
|
|
1248
2284
|
}
|
|
1249
2285
|
reportProgress(jobId, progress, total, message) {
|
|
1250
2286
|
const s = this.jobs.get(jobId);
|
|
@@ -1324,6 +2360,54 @@ var JobManager = class {
|
|
|
1324
2360
|
}
|
|
1325
2361
|
};
|
|
1326
2362
|
|
|
2363
|
+
// src/snapshots/store.ts
|
|
2364
|
+
var DEFAULT_MAX = 32;
|
|
2365
|
+
var SnapshotStore = class {
|
|
2366
|
+
constructor(max = DEFAULT_MAX) {
|
|
2367
|
+
this.max = max;
|
|
2368
|
+
}
|
|
2369
|
+
max;
|
|
2370
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
2371
|
+
seq = 0;
|
|
2372
|
+
store(fingerprint) {
|
|
2373
|
+
this.seq += 1;
|
|
2374
|
+
const snapshot = {
|
|
2375
|
+
id: `snap_${this.seq}`,
|
|
2376
|
+
compId: fingerprint.compId,
|
|
2377
|
+
compName: fingerprint.name,
|
|
2378
|
+
layerCount: Array.isArray(fingerprint.layers) ? fingerprint.layers.length : 0,
|
|
2379
|
+
takenAt: Date.now(),
|
|
2380
|
+
fingerprint
|
|
2381
|
+
};
|
|
2382
|
+
this.snapshots.set(snapshot.id, snapshot);
|
|
2383
|
+
while (this.snapshots.size > this.max) {
|
|
2384
|
+
const oldest = this.snapshots.keys().next();
|
|
2385
|
+
if (oldest.done) break;
|
|
2386
|
+
this.snapshots.delete(oldest.value);
|
|
2387
|
+
}
|
|
2388
|
+
return snapshot;
|
|
2389
|
+
}
|
|
2390
|
+
get(id) {
|
|
2391
|
+
return this.snapshots.get(id);
|
|
2392
|
+
}
|
|
2393
|
+
size() {
|
|
2394
|
+
return this.snapshots.size;
|
|
2395
|
+
}
|
|
2396
|
+
ids() {
|
|
2397
|
+
return [...this.snapshots.keys()];
|
|
2398
|
+
}
|
|
2399
|
+
/**
|
|
2400
|
+
* Why an id is not here, and what to do instead. An agent hitting this has
|
|
2401
|
+
* already done the work it wanted to verify, so "unknown snapshot" on its own
|
|
2402
|
+
* would strand it.
|
|
2403
|
+
*/
|
|
2404
|
+
missingMessage(id) {
|
|
2405
|
+
const held = this.ids();
|
|
2406
|
+
const listing = held.length === 0 ? "No snapshots are held in this session yet." : `Held right now: ${held.join(", ")}.`;
|
|
2407
|
+
return `No snapshot "${id}". Snapshots live in this server's memory for the length of the session: they are gone when the MCP server restarts, and the oldest is dropped once ${this.max} are held. ${listing} Take a fresh one with snapshot_comp, do the work, then diff against that id. To verify a write you have already made, read the comp back with list_layers instead \u2014 a diff can only compare against a snapshot taken beforehand.`;
|
|
2408
|
+
}
|
|
2409
|
+
};
|
|
2410
|
+
|
|
1327
2411
|
// src/tools/descriptions.ts
|
|
1328
2412
|
var descriptions = {
|
|
1329
2413
|
// ---------- comps ----------
|
|
@@ -1332,13 +2416,16 @@ var descriptions = {
|
|
|
1332
2416
|
get_comp_tree: "Comp + nested layer tree, recursing pre-comps to `depth`.",
|
|
1333
2417
|
create_comp: "Create a new comp. Returns id.",
|
|
1334
2418
|
set_comp: "Modify a comp (name, dims, fps, duration, work area, bg). Undefined fields unchanged.",
|
|
1335
|
-
delete_comp: "Delete a comp. Reversible only via AE's Undo.",
|
|
2419
|
+
delete_comp: "Delete a comp. Reversible only via AE's Undo. The solids its layers used are project items, not part of the comp, and After Effects leaves them behind in the Solids folder \u2014 the result counts them in `unusedSolidsLeft`. Pass `purgeUnusedSolids:true` to remove, in the same undo step, the ones nothing else uses; a solid another comp still uses is kept and reported in `keptSolids` with that comp's id. Only this comp's own solids are ever considered \u2014 for the whole project's orphans use purge_unused_footage.",
|
|
1336
2420
|
set_active_comp: "Focus a comp in the viewer/timeline.",
|
|
2421
|
+
duplicate_comp: "Copy a comp. Returns the new comp id, so you never have to find it by name. Use this instead of run_jsx + CompItem.duplicate(). By default the copy is SHALLOW, exactly like AE's own Duplicate: its precomp layers point at the same nested comps as the original, so editing one of those edits both. `deep:true` duplicates the nested comps too and re-points the copy at them \u2014 that is what 'a variant of this rig' means. A nested comp used by several layers is duplicated once and reused. `folderId` files the copy in a project folder; `nameSuffix` names the nested copies '<original><suffix>'.",
|
|
2422
|
+
snapshot_comp: "Take a cheap structural fingerprint of a comp and keep it in this server's memory. Returns a snapshotId and nothing else worth tokens \u2014 call it BEFORE a write, then diff_comp afterwards to learn what the write actually did, instead of reading the comp back and comparing by eye. It records per layer: id, name, index, type, in/out/start, parent, enabled, keyframe counts, expression count, effect count; per comp: size, duration, frame rate, work area, markers. It does NOT record property values, expression text, effect parameters, masks or shape contents. Nothing is written into the AE project, and snapshots are gone when the session ends.",
|
|
2423
|
+
diff_comp: "What changed in a comp since a snapshot: layers added, removed, renamed, retimed, re-parented, keyframe counts that moved, expressions and effects gained or lost \u2014 and nothing at all for unchanged layers, which are only counted. A few dozen tokens where list_layers + get_layer_full is thousands, and it answers the three questions worth asking after a write: which layer is the new one, where did a failed script stop, and did the assembly land. Only the recorded fields are compared, so 'no differences' means none of THOSE moved, not that the comp is identical. Returns a fresh snapshotId so you can keep diffing forward.",
|
|
1337
2424
|
// ---------- layers ----------
|
|
1338
2425
|
list_layers: "Layers in a comp, one-line each. Use get_layer_full for details. Pass `include` to trim it \u2014 `include: []` returns just id/index/name/type, the cheapest way to learn what is in a comp.",
|
|
1339
2426
|
get_layer_full: "Full state of one layer: transform + keyframes + expressions, effects, masks, markers, parenting, text/shape/footage extras, and sourceRect (visible bounds). Always prefer over multiple smaller queries. Bound the answer on a heavy layer: `include` picks the sections you need, `maxKeyframes` caps the keyframes per property, `shapeDepth` limits the Contents walk. Anything dropped is named and counted in the response, so a bounded read is never mistaken for a complete one. On a shape layer, `shapeDetail: 'compact'` returns one indented line per group \u2014 `name matchName prop=value prop=value`, with `[3 keys]`/`[expr]` marking animated properties and `(at defaults)` a group Transform nobody has touched \u2014 which is a fraction of the size and still names every node the write tools address. Material Options (48 3D-extrusion properties per group, inert on a 2D shape layer) is left out of both forms and counted in `materialsOmitted`; pass `shapeMaterials:true` for the extruded-3D case.",
|
|
1340
|
-
create_text_layer: "Text layer with optional font/size/color/position/tracking. anchorAlign (default 'left') aligns the text by setting paragraph justification and leaving the anchor at [0,0], so position means the start of the baseline AND stays right when the text is changed later. Tracking is set to 0 unless you pass one, because AE otherwise inherits the user's Character panel. anchorAlign 'none' keeps AE's raw defaults.",
|
|
1341
|
-
create_shape_layer: "Empty shape layer; fill via add_shape_content.",
|
|
2427
|
+
create_text_layer: "Text layer with optional font/size/color/position/tracking. anchorAlign (default 'left') aligns the text by setting paragraph justification and leaving the anchor at [0,0], so position means the start of the baseline AND stays right when the text is changed later. Tracking is set to 0 unless you pass one, because AE otherwise inherits the user's Character panel. anchorAlign 'none' keeps AE's raw defaults. The justification is read back after the write (AE 26.3 has been seen to store a different alignment from the one set \u2014 issues #93/#94): the result carries `justification` as read and `justificationReasserted` when a second write was needed, and if it still disagrees the layer is removed and the call throws, so a success result is a layer aligned the way it says.",
|
|
2428
|
+
create_shape_layer: "Empty shape layer; fill via add_shape_content. Position defaults to [0,0] with Anchor Point [0,0], so the layer's coordinate space IS the comp's and every vertex, rect/ellipse position and path you add afterwards is in comp pixels. (After Effects' own default is the comp centre, which silently offsets a drawing authored in comp coordinates by half a frame \u2014 pass position:'center' if you want that, or any [x,y] to place the origin yourself.) The result echoes the position and anchor point it ended up with.",
|
|
1342
2429
|
create_solid_layer: "Solid-color layer. color is RGB 0..1.",
|
|
1343
2430
|
create_null_layer: "Null parent layer.",
|
|
1344
2431
|
create_adjustment_layer: "Adjustment layer \u2014 effects on it apply to layers below.",
|
|
@@ -1349,30 +2436,30 @@ var descriptions = {
|
|
|
1349
2436
|
delete_layer: "Remove a layer.",
|
|
1350
2437
|
set_layer: "Update layer metadata (name/enabled/locked/shy/solo/3D/blend/label/in-out/stretch/trackMatte). Undefined fields unchanged.",
|
|
1351
2438
|
parent_layer: "Set/clear a layer's parent (parentLayerId=null to unparent). The layer stays visually put: AE's own compensation double-counts when the parent was itself re-parented in the same call, so this recomputes the world transform and corrects position/scale/rotation, reporting any correction in `correction`. Read `correction.notes` \u2014 3D layers, cameras and lights are not corrected, and with a keyframed ancestor the fix is only exact at the comp's current time. Pass preserveTransform:false to let the layer jump instead.",
|
|
1352
|
-
reorder_layer: "Move layer
|
|
2439
|
+
reorder_layer: "Move a layer in the render stack. Pass exactly one destination: `beforeLayerId` puts it directly in front of (above) that layer, `afterLayerId` directly behind it, `toIndex` at an absolute 1-based position where 1 is the front and numLayers the back. `toIndex` is where the layer ENDS UP, not the slot it displaces \u2014 the two differ by one when moving down the stack. Prefer the id forms: this is the op that shifts every index below it, so an index you read earlier may already be stale. The result carries `movedFrom` and the landed `index`, read back off the layer; equal values mean it was already there.",
|
|
1353
2440
|
// ---------- transforms ----------
|
|
1354
2441
|
set_transform: "Set any of position/scale/rotation/anchorPoint/opacity (+3D orientation/per-axis on 3D). keyframe:true + time sets keyframes.",
|
|
1355
2442
|
// ---------- keyframes ----------
|
|
1356
|
-
add_keyframe: "Keyframe at `time` on propertyPath (e.g. ['Transform','Position']). Optional in/out interpolation + ease.",
|
|
2443
|
+
add_keyframe: "Keyframe at `time` on propertyPath (e.g. ['Transform','Position']). Optional in/out interpolation + ease \u2014 one influence/speed pair, expanded to however many entries the property needs (see set_temporal_ease). The count used comes back as `easeDimensions`.",
|
|
1357
2444
|
remove_keyframe: "Remove keyframe at `time`.",
|
|
1358
2445
|
get_keyframes: "All keyframes on a property: time, value, interpolation, ease, spatial tangents.",
|
|
1359
2446
|
set_interpolation: "Set in/out interpolation type of a specific keyframe.",
|
|
1360
|
-
set_temporal_ease: "Set influence+speed for in/out ease of
|
|
2447
|
+
set_temporal_ease: "Set influence+speed for the in/out ease of one keyframe. Pass at least one of `easeIn`/`easeOut` \u2014 neither is refused, since it would change nothing. Pass ONE {influence, speed} pair per side and it is applied to every dimension of the property \u2014 you never size the ease array yourself. That matters because AE's own setTemporalEaseAtKey wants a per-property number of entries that is not derivable from the value (2D Scale takes 2, a shape Ellipse Size takes 3, Opacity and sliders take 1, and spatial Position takes 1 whether the layer is 2D or 3D), and the wrong count throws a bare 'parameter 2'. This derives the count, retries the alternatives, and reports what worked as `easeDimensions` \u2014 worth reading if you are also easing that property from run_jsx.",
|
|
1361
2448
|
set_spatial_tangents: "Set in/out spatial tangents for a position-style keyframe.",
|
|
1362
2449
|
// ---------- expressions ----------
|
|
1363
|
-
get_expression: "Expression text
|
|
1364
|
-
set_expression: "Set + enable an expression on a property.",
|
|
1365
|
-
toggle_expression: "Enable/disable an expression without clearing.",
|
|
2450
|
+
get_expression: "Expression text, enabled state and `expressionError` on a property. `expressionError` is After Effects' own message when the expression cannot be compiled or evaluated \u2014 the text of the warning banner in the AE UI \u2014 or an empty string when it runs clean. A non-empty one means the property is not being driven by that expression, whatever `enabled` says.",
|
|
2451
|
+
set_expression: "Set + enable an expression on a property, and verify it: after the write the property is evaluated and, if After Effects reports an error on it, the call THROWS with that message and the property path. So a success result means the expression compiles and runs; a failure means the text was written but AE cannot evaluate it \u2014 fix it and call set_expression again, or clear_expression to remove it. A broken expression never comes back as ok.",
|
|
2452
|
+
toggle_expression: "Enable/disable an expression without clearing. Enabling verifies the same way set_expression does: if AE reports an error on the expression once it is enabled, the call throws with the message rather than returning ok.",
|
|
1366
2453
|
clear_expression: "Remove an expression.",
|
|
1367
2454
|
// ---------- effects ----------
|
|
1368
2455
|
list_effects: "All effects on a layer with current param values + keyframes/expressions.",
|
|
1369
2456
|
add_effect: "Add effect by matchName (use list_available_effects \u2014 matchNames are stable across AE versions; display names aren't). A wrong matchName fails immediately and cheaply, so try the standard one (ADBE Gaussian Blur 2, ADBE Slider Control) before searching.",
|
|
1370
2457
|
remove_effect: "Remove effect by 1-based index.",
|
|
1371
|
-
set_effect_param: "Set
|
|
2458
|
+
set_effect_param: "Set one parameter of one effect. Pass at least one of `paramName` (the display name, e.g. 'Blurriness') or `paramMatchName` (e.g. 'ADBE Gaussian Blur 2-0001'); list_effects reports both for every parameter on the layer. Give both and the matchName wins, with the name as fallback \u2014 worth doing when a display name is ambiguous or the user's After Effects is localised. `keyframe:true` plus `time` writes a keyframed value instead of a static one.",
|
|
1372
2459
|
set_effect_enabled: "Toggle an effect on/off without removing.",
|
|
1373
2460
|
list_available_effects: "Effects installed in this AE: displayName, matchName, category. **Always pass `filter`** to substring-search: the full list is 250-450+ entries and the cost is in returning them, not in reading them, so an unfiltered call takes seconds every time while a filtered one takes a fraction of one (measured on AE 26.3: 446 entries in 3.9s, 22 filtered in 0.17s). `refresh:true` re-reads after installing a plugin; the enumeration is cached per AE session, which is why refresh exists. Never loop over `app.effects` in run_jsx: it is slow enough to block the bridge past its timeout and looks like a crash.",
|
|
1374
2461
|
// ---------- text ----------
|
|
1375
|
-
set_text: "Set text content + styling (font, size, fill/stroke, tracking, leading, justification). Undefined fields unchanged.",
|
|
2462
|
+
set_text: "Set text content + styling (font, size, fill/stroke, tracking, leading, justification). Undefined fields unchanged \u2014 and for justification that is verified, not assumed: on AE 26.3 the Source Text round trip has been seen to reset a left-aligned layer to centre when only `text` was changed (issue #93), so after writing, the justification is read back and compared with what should be there (the one you passed, or the one the layer had). If it moved it is written again on its own; if it still disagrees the call throws naming expected and actual rather than reporting a success the layer does not show. The result carries `justification` (the name read back) and `justificationReasserted` (true when the second write was needed).",
|
|
1376
2463
|
add_text_animator: "Add a text animator group (position/scale/rotation/opacity/tracking/skew/fillColor/strokeColor). Optional range selector.",
|
|
1377
2464
|
// ---------- shapes ----------
|
|
1378
2465
|
set_shape_path: "Replace a shape path with new vertices/tangents/closed.",
|
|
@@ -1386,182 +2473,147 @@ var descriptions = {
|
|
|
1386
2473
|
add_marker: "Add a marker on a layer (layerId) or on the comp. time + optional duration/comment/label/chapter/url/frameTarget.",
|
|
1387
2474
|
remove_marker: "Remove a marker by 1-based index.",
|
|
1388
2475
|
// ---------- vision ----------
|
|
1389
|
-
screenshot_frame: "ONE-OFF visual check of a comp
|
|
1390
|
-
screenshot_layer: "ONE-OFF visual check of a single layer (solo'd) at a time. Same one-off rule and same downsample guidance as screenshot_frame. The same 'Stale frame' and `empty:true` non-image results apply.",
|
|
2476
|
+
screenshot_frame: "ONE-OFF visual check of a comp. Base64 PNG. Use only at key moments \u2014 never per-frame or in a loop. To judge MOTION, pass `times` (2-6 values) and get one tiled contact sheet with the time burned into each tile: one call, one image, about the pixel cost of a single frame \u2014 always cheaper and safer than several separate calls. `time` and `times` are mutually exclusive. Confirm the numbers with get_layer_full / get_keyframes; a picture is not exact. A downsample is chosen from the comp size (per tile, for a sheet) unless you pass one \u2014 omit it, and pass downsample:1 only when you genuinely need full resolution. Not every result is an image: 'Stale frame' means AE re-served an earlier render, 'Corrupt frame' means the file AE wrote is not a whole PNG (retry at downsample 6-8, or screenshot the precomps separately), 'Render timed out' means it is still rendering (wait, don't retry immediately), and `empty:true` means every pixel is transparent \u2014 check the time, in/out points and enabled state. On a sheet, a bad tile is drawn as a marked block and named in `warning`; the rest of the sheet is still good. Never disable layers to make a frame render.",
|
|
2477
|
+
screenshot_layer: "ONE-OFF visual check of a single layer (solo'd) at a time. Same one-off rule and same downsample guidance as screenshot_frame. The same 'Stale frame', 'Corrupt frame', 'Render timed out' and `empty:true` non-image results apply. No `times` here \u2014 contact sheets are screenshot_frame only.",
|
|
1391
2478
|
// ---------- batch ----------
|
|
1392
|
-
run_batch:
|
|
2479
|
+
run_batch: 'Many ops in one ExtendScript pass \u2014 far faster than the same ops as separate calls, and far fewer undo steps. **Up to 500 ops it is exactly one undo step.** Over 500 it is chunked into a background job \u2014 this call returns a jobId at once, before the first chunk runs; finish with await_job, which blocks for the job and is where its progress is delivered (pass a progressToken on THAT call, not on this one; get_job polls without one) \u2014 and lands as **one undo step per chunk of 25** \u2014 around 24 steps for 600 ops \u2014 because After Effects discards an undo group that spans two script calls. Every result reports the measured count in `undoSteps` with a `note`: read it before you tell anyone how to undo the work, and never say "one Cmd-Z" for a chunked batch. `singleUndo:true` forces one undo step at any size up to 2000 ops, by running the whole batch in one blocking call \u2014 After Effects\' interface is frozen for the duration and no progress is reported, so use it only when a single Cmd-Z actually matters to the user. Over 2000 it is refused rather than freezing AE. transactional:true (default) stops at the first error; nothing rolls back either way \u2014 the ops before it stay applied, so read the state back rather than re-running. `diff:true` appends a structural diff of the comps the batch touched \u2014 what it added, retimed, re-parented and keyframed \u2014 and on a failure that diff rides on the error, which is the cheapest way to find where a half-applied batch stopped.',
|
|
1393
2480
|
// ---------- explore ----------
|
|
1394
2481
|
get_project_summary: "Project state: path, item count, active item, flat item list with type (comp | footage | solid | folder | unknown \u2014 same vocabulary as a layer's sourceType).",
|
|
1395
|
-
find_layers: "Search
|
|
2482
|
+
find_layers: "Search one comp (`compId`) or every comp for layers matching name (regex, case-insensitive), type and/or effect matchName filters. Returns `{matches, count, compsSearched, included}`; each match is id/index/name/sourceType plus compId/compName \u2014 enough to address the layer with any other tool. That is the whole record by default: pass `include` (flags, timing, parent \u2014 the same names as list_layers) to add sections to every match, and note that unlike list_layers an omitted `include` here means the bounded form, not everything. For one layer's details use get_layer_full on the id it gives you.",
|
|
1396
2483
|
// ---------- raw ----------
|
|
1397
|
-
run_jsx: 'Escape hatch: arbitrary ExtendScript in an undo group. `
|
|
2484
|
+
run_jsx: 'Escape hatch: arbitrary ExtendScript in an undo group. Pass exactly one of `code` (inline) or `scriptPath` (absolute) to run a .jsx file the server reads \u2014 use the path for anything long, so the script never enters the conversation. `libraries` (absolute .jsx paths) are read by the server too and inlined ahead of the script in the same scope, so their functions are callable from it; they are re-evaluated on every call, so keep them to declarations rather than to work. Keep shared helpers there instead of pasting them into every script. In scope: `app`, the full `OPS` table (every tool on this server, e.g. `OPS.set_transform({compId, layerId, position:[0,0]})`), and these helpers \u2014 `compById(id)`, `getCompById(id)`, `layerById(compOrId, layerId)`, `getLayerById(comp, layerId)`, `walkProperty(layer, ["Transform","Position"])`, `addKeys(prop, [[t, v], \u2026])` \u2192 key indices, `ease(prop, keyIndex, easeIn, easeOut)` which sizes the KeyframeEase array itself (a bare number means influence; omit easeOut for the same both sides), `shape(comp, {name, position})` which lands the layer at [0,0] rather than the comp centre, and `withoutUndoGroup(fn)`. `app.executeCommand()` menu commands silently no-op here \u2014 they depend on host focus and the active selection, neither of which this bridge has \u2014 so use the API equivalents (`CompItem.duplicate()`, `layer.duplicate()`). `return X` sends a value back \u2014 arrays and nested objects come back whole. Anything that cannot be JSON is replaced in place by a marker string, never dropped: `"[function]"`, `"[undefined]"`, `"[circular]"`, `"[max depth]"`, `"[NaN]"`, and live AE objects as `"[CompItem \\"Main\\" #12]"` \u2014 a handle to pass to a real read tool, not a walk of the object. An empty result therefore means the script really returned nothing. A script with no explicit `return` \u2014 including one ending in a bare expression, which does NOT return its value \u2014 comes back as `{ok:true, returned:null, undoGroup, note}`. That means it ran to completion; it did **not** fail, so do not re-run it. Nothing rolls back, so re-running a mutating script applies it twice. A failure names the line of YOUR script and prints its text; when the number cannot be mapped it says so rather than guessing. Everything before that line already ran and nothing rolls back, so read the state back \u2014 never re-run the script to see if it fails again. AE refuses copyToComp for a layer with a parent or a linked expression while an undo group is open: call `withoutUndoGroup(function(){ \u2026 })` around just that part, or pass undoGroup:false for the whole script (its changes then land as whatever undo steps AE records on its own, not one). Keep loops short \u2014 ExtendScript is single-threaded and freezes the user\'s UI. `diff:true` fingerprints the comp before and after the script and appends only what changed, so the script reports what it actually did instead of you reading the comp back; with it the answer is always `{ok, returned, diff}`. If the script throws, that diff is appended to the error \u2014 which is how you find where a half-applied script stopped, since nothing rolls back.',
|
|
1398
2485
|
// ---------- footage ----------
|
|
1399
2486
|
import_footage: "Import a file (video, image, audio, SVG, PSD/AI) into the project. Returns the item id \u2014 pass it to create_footage_layer to place it. Validates what AE actually produced: an SVG whose viewBox asks for one aspect ratio and imports at another is a known AE bug that renders as nothing with no error, so the item is deleted and the call throws with the workaround. `force:true` keeps it and reports the problem in `validation` instead.",
|
|
1400
2487
|
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.",
|
|
2488
|
+
purge_unused_footage: "Remove footage items no comp uses. By default only solids \u2014 the items behind solid and adjustment layers, which delete_comp and delete_layer leave behind in the Solids folder, so a session that builds and discards comps can leave hundreds. `solidsOnly:false` removes every unused footage item, imported files and placeholders too, which is what AE's own Remove Unused Footage does. Comps and folders are never touched: a nested comp nothing uses is not footage. `dryRun:true` lists what would go (`wouldRemove`) without removing anything or adding an undo step \u2014 use it first on a project you did not build. `folderId` limits the sweep to one folder and its subfolders. Reports `scanned`, `inUse` and `removed` (id, name, kind); a real run is one undo step. If one removal fails, the error names it, what was already removed and what was not attempted, since nothing rolls back.",
|
|
2489
|
+
// ---------- audio ----------
|
|
2490
|
+
place_audio_cues: "Score a scene in one call: a list of cues, each a sound at a comp time with a level in dB, becomes one audio layer each \u2014 imported if needed, named, trimmed, labelled \u2014 in a single undo step. Reach for it whenever you are placing more than two or three sound effects; the alternative is dozens of round trips or a run_jsx loop that has to know that `layer.property('ADBE Audio Levels')` returns null on an audio layer. Every cue names its sound with **exactly one** of `path` (a file to use \u2014 imported once however many cues name it, and an item already in the project from that path is reused) or `footageId` (one already imported); both together, or neither, is reported against that cue's index with nothing placed. `time` is when the cue starts; `levelDb` is decibels (0 = the file as recorded, negative = quieter), defaulting to 0. `inPoint`/`outPoint` trim in COMP time, not file time. Three per-cue options cover what scoring passes otherwise redo in run_jsx: `loop:true` repeats the sound to `outPoint` or the comp's end (time remapping plus a wrap-around expression on Time Remap; AE's own remap keyframes are kept, since removing them breaks the property); `fadeIn`/`fadeOut` in seconds put Audio Levels keyframes at the layer's real in/out, fading between `fadeFloorDb` (call-level, default -48) and `levelDb`; `stretch` is a percentage (100 unchanged, 200 half speed and lower pitch) and the start time and trims are re-asserted after it. A loop that is also stretched has the factor baked into its expression, so changing stretch by hand later leaves the loop at the old rate. A fade longer than the cue's placed length, a stretch of 0 or below, or a floor at or above the cue's level is refused by cue index with nothing placed; for a file not yet imported the fade is checked right after the import, before any layer exists. It is all-or-nothing: every cue is validated \u2014 file exists, item has audio, time inside the comp, options sane \u2014 before a single layer is made, and if a later one still fails, everything this call created is removed and the error names the cue. The result carries one small entry per cue (layerId, name, time, plus `looped`/`stretch`/`fadeIn`/`fadeOut` and read-back in/out only where an option set them) and `sources` naming what was imported or reused \u2014 not an echo of the request. Use `dryRun:true` to check a cue list against the project without touching anything, including the undo stack; it answers with counts, `wouldImport`, `wouldReuse` and the failing cues by index, never the whole resolved list. Max 200 cues per call.",
|
|
1401
2491
|
// ---------- motion graphics templates ----------
|
|
1402
|
-
export_mogrt: "Export a comp as a .mogrt for Premiere. Handles the three things that make a scripted export look like a hung connection: it saves the project first (removes AE's modal save prompt), suppresses the modal font warning that otherwise freezes this connection until someone clicks OK in AE, and runs outside the undo group. `name` defaults to the comp name \u2014 AE's own default is the literal 'Untitled', so every export would otherwise overwrite the same file. Pass `posterTime` to render that frame as the template's thumbnail, replacing the black one AE writes; the export still succeeds if only the thumbnail fails. Needs the project saved once by hand first. `fonts` in the result lists the fonts the template will require \u2014 tell the user, since non-Adobe ones make Premiere flag the template.",
|
|
2492
|
+
export_mogrt: "Export a comp as a .mogrt for Premiere. Handles the three things that make a scripted export look like a hung connection: it saves the project first (removes AE's modal save prompt), suppresses the modal font warning that otherwise freezes this connection until someone clicks OK in AE, and runs outside the undo group. **The comp needs at least one property in its Essential Graphics panel** \u2014 After Effects will not build a template from an empty one, and it refuses silently, so this tool checks the controller count first and refuses before exporting rather than reporting a cause it cannot know. Fix it in AE: Window > Essential Graphics, pick the comp, drag a layer property in. `name` defaults to the comp name \u2014 AE's own default is the literal 'Untitled', so every export would otherwise overwrite the same file. Pass `posterTime` to render that frame as the template's thumbnail, replacing the black one AE writes; the export still succeeds if only the thumbnail fails. Needs the project saved once by hand first. `fonts` in the result lists the fonts the template will require \u2014 tell the user, since non-Adobe ones make Premiere flag the template. If an export still fails, read the message before acting: it lists what was checked, and it only names a modal dialog when dialogs were left unsuppressed. Under the default suppression a dialog is impossible, the cause is genuinely unknown, and the way to see AE's own reason is for the user to run the same export by hand from the Essential Graphics panel.",
|
|
1403
2493
|
// ---------- house style ----------
|
|
1404
|
-
get_house_style: "The user's palette, type, motion and layout defaults for the project that is open, read from `house-style.md` beside the .aep. Call it once before building anything so your work matches the rest of theirs. `found:false` means none exists yet \u2014 build with sensible defaults and offer to capture one afterwards. Cheap; never a reason to skip.",
|
|
2494
|
+
get_house_style: "The user's palette, type, motion and layout defaults for the project that is open, read from `house-style.md` beside the .aep. Call it once before building anything so your work matches the rest of theirs. Returns a few-hundred-token summary by default \u2014 palette as named hexes, type, motion, layout, and a note naming anything it could not summarise; pass `detail:'full'` for the whole document, which you need before editing the guide with set_house_style. `found:false` means none exists yet \u2014 build with sensible defaults and offer to capture one afterwards. Cheap; never a reason to skip.",
|
|
1405
2495
|
set_house_style: "Write the project's style guide. Replaces the whole file, so read it first and send the merged document \u2014 `overwrite:true` is required to replace an existing one. The project must have been saved at least once, since the file lives beside the .aep. Use the style-guide topic of ae_guide for how to capture a style worth writing down.",
|
|
1406
2496
|
// ---------- guidance ----------
|
|
1407
|
-
ae_guide: "The full working guidance for these tools, by topic. Read `after-effects` before a first substantial build in a session, `style-guide` when capturing or editing the user's look, `ae-setup` when a tool cannot reach After Effects. Covers the traps that silently produce wrong output and are not visible from any single tool's schema.",
|
|
2497
|
+
ae_guide: "The full working guidance for these tools, by topic. Read `after-effects` before a first substantial build in a session, `style-guide` when capturing or editing the user's look, `ae-setup` when a tool cannot reach After Effects, `whats-new` when a call behaves differently from what you expected. Covers the traps that silently produce wrong output and are not visible from any single tool's schema. `whats-new` is one section per release, newest first, each entry leading with the rule as it now stands and a `supersedes:` line quoting the old rule a project's notes may still carry; pass `since` (a version, e.g. '0.4.0') to get only the releases after it, or one line saying nothing is newer \u2014 that is how the absorb-release prompt brings a project's docs up to date once per upgrade. The whats-new answer always opens with this server's own version. `since` is rejected on any other topic.",
|
|
1408
2498
|
// ---------- jobs ----------
|
|
1409
|
-
await_job: "Block until job is done. Default 10min timeout. Returns the same payload the tool would have.",
|
|
1410
|
-
get_job: "Non-blocking job status: progress/total/state/error.",
|
|
2499
|
+
await_job: "Block until a chunked run_batch job is done. Default 10min timeout. Returns the same payload the tool would have, `undoSteps` included. **This is the call that carries progress**: send it with a progressToken and notifications/progress arrive while it waits, every one before its response. run_batch's own response is back before the first chunk runs, so no progress can ride on that call \u2014 a token there is ignored.",
|
|
2500
|
+
get_job: "Non-blocking job status: progress/total/state/error. Poll this when you cannot keep an await_job open. It never sends notifications/progress \u2014 that is await_job's job.",
|
|
1411
2501
|
cancel_job: "Set cancel flag; chunked loop stops at next boundary.",
|
|
1412
2502
|
// ---------- setup ----------
|
|
1413
|
-
check_setup: "Diagnose the After Effects connection: panel installed, up to date, the version AE is actually running, Adobe debug preference on, AE running, bridge answering. Read-only and safe to call any time. Call this FIRST whenever another tool reports it cannot reach After Effects or says the panel is out of date, then relay `nextSteps` to the user in plain language. `panelRunningCurrent` is the one that predicts whether calls will work \u2014 it can fail while `panelUpToDate` passes, which means an update is installed but AE has not been restarted.",
|
|
1414
|
-
setup_panel: "Install or refresh the After Effects panel and enable the Adobe preference AE needs to load it. Run this when check_setup reports the panel is missing, out of date, or older than what AE is running. It writes to the user's Adobe CEP extensions folder and sets
|
|
1415
|
-
init_project: "Set up a working folder for one video, series or client: a project brief and a pointer to the house style, written in whichever layout this client reads. Run it when the user is starting out or asks to set up a project. It writes files to disk \u2014 say which folder before calling, and pass `dir` explicitly unless the client already told the server where it is working. It never overwrites anything and reports every path it wrote.",
|
|
2503
|
+
check_setup: "Diagnose the After Effects connection: panel installed, up to date, the version AE is actually running, Adobe debug preference on, AE running, bridge answering \u2014 on whichever port it answers, since the port file is only a hint \u2014 plus `portAgreement` (do tool calls go to the port the panel answers on?) and, when nothing answers at all, `panelSignature` (did CEP's own log say it refused to load the panel?). Read-only and safe to call any time. Call this FIRST whenever another tool reports it cannot reach After Effects or says the panel is out of date, then relay `nextSteps` to the user in plain language. `panelRunningCurrent` is the one that predicts whether calls will work \u2014 it can fail while `panelUpToDate` passes, which means an update is installed but AE has not been restarted. A failing `portAgreement` means the server's port went stale, not the panel: retry the call (the server re-discovers the port whenever a call is refused) or reconnect the MCP server \u2014 never restart After Effects for it, a panel is listening. A failing `panelSignature` means no restart and no reinstall will help; `nextSteps` carries the self-signing fix.",
|
|
2504
|
+
setup_panel: "Install or refresh the After Effects panel and enable the Adobe preference AE needs to load it. Run this when check_setup reports the panel is missing, out of date, or older than what AE is running. It writes to the user's Adobe CEP extensions folder and sets two user-level Adobe preferences \u2014 PlayerDebugMode, and CEP's LogLevel where it was never set, so that if AE ever refuses to load the panel its CEP log says why \u2014 tell the user what it will do before calling it. Prefer running it while AE is CLOSED: the panel then loads when they open it, with no restart. If AE is already open they must quit and reopen it, and until they do, the old panel keeps answering. If the preference was newly enabled, a one-time Mac reboot may also be needed. If the existing install had been self-signed (check_setup's `panelInstalled` says so), the fresh copy is unsigned and the result says the signing has to be redone \u2014 on a machine that needed signing, tell the user before calling this.",
|
|
2505
|
+
init_project: "Set up a working folder for one video, series or client: a project brief and a pointer to the house style, written in whichever layout this client reads. Run it when the user is starting out or asks to set up a project. It writes files to disk \u2014 say which folder before calling, and pass `dir` explicitly unless the client already told the server where it is working. It never overwrites anything and reports every path it wrote. AGENTS.md carries a `Tools version last absorbed: <version>` line stamped with this server's version; the absorb-release prompt reads it after an upgrade to apply only what changed since, then moves it on \u2014 leave the line in place.",
|
|
1416
2506
|
// ---------- issue journal ----------
|
|
1417
|
-
list_known_issues: "Problems earlier sessions hit with these tools, with the workarounds that worked.
|
|
1418
|
-
log_issue: "Record a problem you hit and the workaround that got past it, so the next session doesn't rediscover it. Log only what cost real effort and will recur: a tool failing for a non-obvious reason, an argument shape the schema didn't imply, AE behaving unlike the docs. Not your own typos, not one-off user mistakes.
|
|
1419
|
-
mark_issue_reported: "Record that a journal entry has been sent to the maintainers, with the resulting URL. Call it only once the issue really exists, so later sessions don't ask the user to report the same thing twice."
|
|
2507
|
+
list_known_issues: "Problems earlier sessions hit with these tools, with the workarounds that worked. You do not need to read this ahead of time: a failed call names the entries that match its tool and error text in the error itself, under `Known from earlier sessions:`. Call this when you are planning something that has bitten before (pass `tool` or `query`), when a failure's pointer names an entry you want to open, or when the user asks what is known. Merges two journals and tags every entry with the one it came from: `project` (or `home`, the fallback when there is no project folder) for this project's own notes, `user` for tool and After Effects behaviour carried across every project. Returns a one-line index by default, entries naming `tool` first and then most recently seen first; the cause and the workaround are in the entry, so follow up with `id` on anything that looks like your problem \u2014 ids are unique only within a journal, so use the `scope:id` form the `next` pointer shows. Archived entries (not seen for 30 days, last seen on an older server, or retired with archive_issue) are hidden and counted in `archivedCount`; `includeArchived:true` lists them, and an `id` read always works. `detail:'full'` dumps every matching entry and is rarely worth it. Also returns the repo and server version needed to report one.",
|
|
2508
|
+
log_issue: "Record a problem you hit and the workaround that got past it, so the next session doesn't rediscover it. Log only what cost real effort and will recur: a tool failing for a non-obvious reason, an argument shape the schema didn't imply, AE behaving unlike the docs. Not your own typos, not one-off user mistakes. Pass `tools` and the exact `errorText`: that is what lets a later failure be answered with this entry, and it is what merges a re-log into the existing entry even under a different title (the result says `mergedBy` and keeps the existing title). Reusing an existing title extends it too. Pass `kind:'ae-quirk'` for a permanent After Effects behaviour, so a new release does not archive it as fixed; the default `tool-bug` is presumed fixed once it was last seen on an older server than the one running. Choose the scope by what the entry is *about*: leave it at the default `project` for this project's footage, comps or files, and pass `scope:'user'` when it is about how these tools or After Effects behave, so the next project starts already knowing it. If the result says `reopened:true`, an entry that had been retired is back \u2014 tell the user the problem was supposed to be gone. If the result comes back with reported:false, then AFTER you have finished the actual work, close your reply by telling the user in plain language that something took much longer than it should have and offering to pass it to the people who maintain this tool \u2014 phrase it for a motion designer, in terms of what actually happened, and don't say 'GitHub issue' or 'bug report' unless they say it first.",
|
|
2509
|
+
mark_issue_reported: "Record that a journal entry has been sent to the maintainers, with the resulting URL. Call it only once the issue really exists, so later sessions don't ask the user to report the same thing twice.",
|
|
2510
|
+
archive_issue: "Retire a journal entry for good: it leaves the index (and the pointers a failed call shows still name it, marked archived with this reason) but the file stays, hand-editable, with `reason` in its frontmatter. Use it when a report already covers the problem (pass that URL as the reason), when a release fixed it, or when the lesson has been moved into the project's own notes. Entries that merely go quiet archive themselves \u2014 30 days unseen, or last seen on an older server \u2014 so this is for a decision, not for tidying. Use the scope-qualified id (`user:my-entry`) when both journals hold the slug; only the one named moves. A later log_issue with the same title or the same tool and error text brings it back and says so."
|
|
1420
2511
|
};
|
|
1421
2512
|
|
|
1422
|
-
// src/
|
|
1423
|
-
import
|
|
1424
|
-
import
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
var BUNDLE_ID = "games.engine-room.ae-mcp";
|
|
1434
|
-
function isSupportedPlatform() {
|
|
1435
|
-
return process.platform === "darwin" || process.platform === "win32";
|
|
1436
|
-
}
|
|
1437
|
-
function packageRoot() {
|
|
1438
|
-
let dir = path4.dirname(fileURLToPath(import.meta.url));
|
|
1439
|
-
for (let i = 0; i < 8; i++) {
|
|
1440
|
-
if (fs3.existsSync(path4.join(dir, "package.json"))) return dir;
|
|
1441
|
-
const parent = path4.dirname(dir);
|
|
1442
|
-
if (parent === dir) break;
|
|
1443
|
-
dir = parent;
|
|
1444
|
-
}
|
|
1445
|
-
return path4.dirname(fileURLToPath(import.meta.url));
|
|
1446
|
-
}
|
|
1447
|
-
function executableDir() {
|
|
1448
|
-
return path4.dirname(process.execPath);
|
|
1449
|
-
}
|
|
1450
|
-
function packageVersion() {
|
|
1451
|
-
for (const dir of [packageRoot(), executableDir()]) {
|
|
1452
|
-
try {
|
|
1453
|
-
const pkg = JSON.parse(fs3.readFileSync(path4.join(dir, "package.json"), "utf8"));
|
|
1454
|
-
if (typeof pkg.version === "string") return pkg.version;
|
|
1455
|
-
} catch {
|
|
1456
|
-
}
|
|
2513
|
+
// src/tools/runJsxSource.ts
|
|
2514
|
+
import fs4 from "node:fs";
|
|
2515
|
+
import path5 from "node:path";
|
|
2516
|
+
var MAX_SCRIPT_BYTES = 512 * 1024;
|
|
2517
|
+
var MAX_LIBRARIES = 16;
|
|
2518
|
+
var MAX_TOTAL_BYTES = 1024 * 1024;
|
|
2519
|
+
function readScriptFile(p, what) {
|
|
2520
|
+
if (!path5.isAbsolute(p)) {
|
|
2521
|
+
throw new Error(
|
|
2522
|
+
`${what} must be an absolute path \u2014 got "${p}". Relative paths have no meaning here: the server resolves them against its own working directory, which is not the user's project folder (Claude Desktop starts it at "/").`
|
|
2523
|
+
);
|
|
1457
2524
|
}
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
// what the developer is editing, never a stale vendored copy left behind by
|
|
1464
|
-
// a previous `npm pack`. Only the second path exists in the tarball.
|
|
1465
|
-
path4.resolve(packageRoot(), "..", "ae-panel"),
|
|
1466
|
-
path4.join(packageRoot(), "panel"),
|
|
1467
|
-
// Compiled single-file build: the panel ships beside the executable.
|
|
1468
|
-
path4.join(executableDir(), "panel")
|
|
1469
|
-
];
|
|
1470
|
-
for (const dir of candidates) {
|
|
1471
|
-
if (fs3.existsSync(path4.join(dir, "CSXS", "manifest.xml"))) return dir;
|
|
2525
|
+
let stat;
|
|
2526
|
+
try {
|
|
2527
|
+
stat = fs4.statSync(p);
|
|
2528
|
+
} catch (e) {
|
|
2529
|
+
throw new Error(`${what} could not be read: ${p} (${e.code ?? e.message}).`);
|
|
1472
2530
|
}
|
|
1473
|
-
|
|
1474
|
-
}
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
2531
|
+
if (stat.isDirectory()) throw new Error(`${what} is a directory, not a file: ${p}`);
|
|
2532
|
+
if (!stat.isFile()) throw new Error(`${what} is not a regular file: ${p}`);
|
|
2533
|
+
if (stat.size > MAX_SCRIPT_BYTES) {
|
|
2534
|
+
throw new Error(
|
|
2535
|
+
`${what} is ${stat.size} bytes, over the ${MAX_SCRIPT_BYTES}-byte limit: ${p}. Split it into smaller scripts and run them in sequence. Moving the bulk into a \`libraries\` file does not help: libraries are inlined ahead of the script on every call, so they count against the same budget.`
|
|
2536
|
+
);
|
|
1479
2537
|
}
|
|
1480
|
-
|
|
1481
|
-
}
|
|
1482
|
-
function installedPanelDir() {
|
|
1483
|
-
return path4.join(cepExtensionsDir(), BUNDLE_ID);
|
|
1484
|
-
}
|
|
1485
|
-
function isWsModuleDir(dir) {
|
|
2538
|
+
let text;
|
|
1486
2539
|
try {
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
} catch {
|
|
1491
|
-
return false;
|
|
2540
|
+
text = fs4.readFileSync(p, "utf8");
|
|
2541
|
+
} catch (e) {
|
|
2542
|
+
throw new Error(`${what} could not be read: ${p} (${e.message}).`);
|
|
1492
2543
|
}
|
|
2544
|
+
if (text.trim() === "") throw new Error(`${what} is empty: ${p}`);
|
|
2545
|
+
return { text, bytes: stat.size };
|
|
1493
2546
|
}
|
|
1494
|
-
function
|
|
1495
|
-
const
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
const idx = entry.lastIndexOf(marker);
|
|
1502
|
-
candidates.push(idx >= 0 ? entry.slice(0, idx + marker.length - 1) : path4.dirname(entry));
|
|
1503
|
-
}
|
|
1504
|
-
} catch {
|
|
1505
|
-
}
|
|
1506
|
-
candidates.push(path4.join(executableDir(), "node_modules", "ws"));
|
|
1507
|
-
candidates.push(path4.join(packageRoot(), "node_modules", "ws"));
|
|
1508
|
-
for (const dir of candidates) {
|
|
1509
|
-
if (isWsModuleDir(dir)) return dir;
|
|
2547
|
+
function resolveRunJsxSource(args) {
|
|
2548
|
+
const hasCode = typeof args.code === "string" && args.code.length > 0;
|
|
2549
|
+
const hasPath = typeof args.scriptPath === "string" && args.scriptPath.length > 0;
|
|
2550
|
+
if (hasCode && hasPath) {
|
|
2551
|
+
throw new Error(
|
|
2552
|
+
"run_jsx takes either `code` or `scriptPath`, not both. Pass the path on its own \u2014 the file is read here, so its text never has to enter the conversation."
|
|
2553
|
+
);
|
|
1510
2554
|
}
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
for (const entry of fs3.readdirSync(dir, { withFileTypes: true })) {
|
|
1516
|
-
if (entry.name === "node_modules" || entry.name === ".DS_Store") continue;
|
|
1517
|
-
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
1518
|
-
if (entry.isDirectory()) out.push(...listPanelFiles(path4.join(dir, entry.name), rel));
|
|
1519
|
-
else out.push(rel);
|
|
2555
|
+
if (!hasCode && !hasPath) {
|
|
2556
|
+
throw new Error(
|
|
2557
|
+
"run_jsx needs either `code` (ExtendScript inline) or `scriptPath` (an absolute path to a .jsx file)."
|
|
2558
|
+
);
|
|
1520
2559
|
}
|
|
1521
|
-
|
|
1522
|
-
}
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
}
|
|
1528
|
-
|
|
2560
|
+
const { libraries: requestedLibraries, ...passthrough } = args;
|
|
2561
|
+
const out = { ...passthrough, code: "" };
|
|
2562
|
+
if (hasPath) {
|
|
2563
|
+
const p = args.scriptPath;
|
|
2564
|
+
out.code = readScriptFile(p, "run_jsx `scriptPath`").text;
|
|
2565
|
+
out.scriptPath = p;
|
|
2566
|
+
} else {
|
|
2567
|
+
out.code = args.code;
|
|
2568
|
+
delete out.scriptPath;
|
|
1529
2569
|
}
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
const stat = fs3.lstatSync(src);
|
|
1536
|
-
if (stat.isDirectory()) {
|
|
1537
|
-
fs3.mkdirSync(dst, { recursive: true });
|
|
1538
|
-
for (const entry of fs3.readdirSync(src)) {
|
|
1539
|
-
copyRecursive(path4.join(src, entry), path4.join(dst, entry));
|
|
2570
|
+
if (requestedLibraries && requestedLibraries.length > 0) {
|
|
2571
|
+
if (requestedLibraries.length > MAX_LIBRARIES) {
|
|
2572
|
+
throw new Error(
|
|
2573
|
+
`run_jsx takes at most ${MAX_LIBRARIES} libraries, got ${requestedLibraries.length}.`
|
|
2574
|
+
);
|
|
1540
2575
|
}
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
2576
|
+
const libs = [];
|
|
2577
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2578
|
+
for (const raw of requestedLibraries) {
|
|
2579
|
+
if (seen.has(raw)) continue;
|
|
2580
|
+
seen.add(raw);
|
|
2581
|
+
const { text, bytes } = readScriptFile(raw, `run_jsx library "${raw}"`);
|
|
2582
|
+
libs.push({ path: raw, text, bytes });
|
|
2583
|
+
}
|
|
2584
|
+
out.libraries = libs;
|
|
2585
|
+
}
|
|
2586
|
+
const total = Buffer.byteLength(out.code, "utf8") + (out.libraries ?? []).reduce((n, l) => n + l.bytes, 0);
|
|
2587
|
+
if (total > MAX_TOTAL_BYTES) {
|
|
2588
|
+
throw new Error(
|
|
2589
|
+
`run_jsx would send ${total} bytes of source (script plus ${out.libraries?.length ?? 0} libraries), over the ${MAX_TOTAL_BYTES}-byte limit for one call. Libraries are inlined ahead of the script \u2014 they have to be, or their functions are not in its scope \u2014 so every byte travels on every call. Pass only the libraries this script actually uses.`
|
|
2590
|
+
);
|
|
1545
2591
|
}
|
|
2592
|
+
return out;
|
|
1546
2593
|
}
|
|
1547
2594
|
|
|
2595
|
+
// src/setup/check.ts
|
|
2596
|
+
import crypto2 from "node:crypto";
|
|
2597
|
+
import fs7 from "node:fs";
|
|
2598
|
+
import path8 from "node:path";
|
|
2599
|
+
|
|
1548
2600
|
// src/setup/panelVersion.ts
|
|
1549
2601
|
import crypto from "node:crypto";
|
|
1550
|
-
import
|
|
1551
|
-
import
|
|
2602
|
+
import fs5 from "node:fs";
|
|
2603
|
+
import path6 from "node:path";
|
|
1552
2604
|
var cachedSourceHash;
|
|
1553
2605
|
function sourceBundleHash() {
|
|
1554
2606
|
if (cachedSourceHash !== void 0) return cachedSourceHash;
|
|
1555
2607
|
const source = panelSourceDir();
|
|
1556
|
-
cachedSourceHash = source ? hashFile(
|
|
2608
|
+
cachedSourceHash = source ? hashFile(path6.join(source, "jsx", "bundle.jsx")) : null;
|
|
1557
2609
|
return cachedSourceHash;
|
|
1558
2610
|
}
|
|
1559
2611
|
function installedBundleHash(installedPanel) {
|
|
1560
|
-
return hashFile(
|
|
2612
|
+
return hashFile(path6.join(installedPanel, "jsx", "bundle.jsx"));
|
|
1561
2613
|
}
|
|
1562
2614
|
function hashFile(file) {
|
|
1563
2615
|
try {
|
|
1564
|
-
return crypto.createHash("sha256").update(
|
|
2616
|
+
return crypto.createHash("sha256").update(fs5.readFileSync(file)).digest("hex");
|
|
1565
2617
|
} catch {
|
|
1566
2618
|
return null;
|
|
1567
2619
|
}
|
|
@@ -1601,6 +2653,9 @@ function unknownOpMessage(op) {
|
|
|
1601
2653
|
|
|
1602
2654
|
// src/setup/platform.ts
|
|
1603
2655
|
import { execFile } from "node:child_process";
|
|
2656
|
+
import fs6 from "node:fs";
|
|
2657
|
+
import os4 from "node:os";
|
|
2658
|
+
import path7 from "node:path";
|
|
1604
2659
|
import { promisify } from "node:util";
|
|
1605
2660
|
var exec = promisify(execFile);
|
|
1606
2661
|
var CSXS_VERSIONS = [12, 11, 10, 9];
|
|
@@ -1675,37 +2730,153 @@ async function isAfterEffectsRunning() {
|
|
|
1675
2730
|
function debugModeLocation() {
|
|
1676
2731
|
return process.platform === "win32" ? "the PlayerDebugMode value under HKEY_CURRENT_USER\\Software\\Adobe\\CSXS.*" : "Adobe's PlayerDebugMode preference";
|
|
1677
2732
|
}
|
|
2733
|
+
function cepLogDir() {
|
|
2734
|
+
if (process.platform === "win32") {
|
|
2735
|
+
return process.env.TEMP ?? process.env.TMP ?? os4.tmpdir();
|
|
2736
|
+
}
|
|
2737
|
+
return path7.join(os4.homedir(), "Library", "Logs", "CSXS");
|
|
2738
|
+
}
|
|
2739
|
+
var CEP_LOG_NAME = /^(?:cep|csxs)(\d+)-AEFT\.log$/i;
|
|
2740
|
+
function cepLogPaths(dir = cepLogDir()) {
|
|
2741
|
+
let names;
|
|
2742
|
+
try {
|
|
2743
|
+
names = fs6.readdirSync(dir);
|
|
2744
|
+
} catch {
|
|
2745
|
+
return [];
|
|
2746
|
+
}
|
|
2747
|
+
return names.filter((n) => CEP_LOG_NAME.test(n)).map((n) => {
|
|
2748
|
+
const file = path7.join(dir, n);
|
|
2749
|
+
let mtime = 0;
|
|
2750
|
+
try {
|
|
2751
|
+
mtime = fs6.statSync(file).mtimeMs;
|
|
2752
|
+
} catch {
|
|
2753
|
+
}
|
|
2754
|
+
return { file, mtime };
|
|
2755
|
+
}).sort((a, b) => b.mtime - a.mtime).map((e) => e.file);
|
|
2756
|
+
}
|
|
2757
|
+
async function cepLogLevel(version) {
|
|
2758
|
+
try {
|
|
2759
|
+
if (process.platform === "win32") {
|
|
2760
|
+
const { stdout: stdout2 } = await exec("reg", ["query", `HKCU\\Software\\Adobe\\CSXS.${version}`, "/v", "LogLevel"]);
|
|
2761
|
+
const m = /LogLevel\s+REG_\w+\s+(\d+)/.exec(stdout2);
|
|
2762
|
+
return m ? Number(m[1]) : null;
|
|
2763
|
+
}
|
|
2764
|
+
const { stdout } = await exec("defaults", ["read", `com.adobe.CSXS.${version}`, "LogLevel"]);
|
|
2765
|
+
const n = Number(stdout.trim());
|
|
2766
|
+
return Number.isFinite(n) ? n : null;
|
|
2767
|
+
} catch {
|
|
2768
|
+
return null;
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
var CEP_LOG_LEVEL = 6;
|
|
2772
|
+
async function ensureCepLogging() {
|
|
2773
|
+
const set = [];
|
|
2774
|
+
const kept = [];
|
|
2775
|
+
for (const v of CSXS_VERSIONS) {
|
|
2776
|
+
const current = await cepLogLevel(v);
|
|
2777
|
+
if (current !== null) {
|
|
2778
|
+
kept.push({ version: v, level: current });
|
|
2779
|
+
continue;
|
|
2780
|
+
}
|
|
2781
|
+
try {
|
|
2782
|
+
if (process.platform === "win32") {
|
|
2783
|
+
await exec("reg", [
|
|
2784
|
+
"add",
|
|
2785
|
+
`HKCU\\Software\\Adobe\\CSXS.${v}`,
|
|
2786
|
+
"/v",
|
|
2787
|
+
"LogLevel",
|
|
2788
|
+
"/t",
|
|
2789
|
+
"REG_SZ",
|
|
2790
|
+
"/d",
|
|
2791
|
+
String(CEP_LOG_LEVEL),
|
|
2792
|
+
"/f"
|
|
2793
|
+
]);
|
|
2794
|
+
} else {
|
|
2795
|
+
await exec("defaults", ["write", `com.adobe.CSXS.${v}`, "LogLevel", String(CEP_LOG_LEVEL)]);
|
|
2796
|
+
}
|
|
2797
|
+
set.push(v);
|
|
2798
|
+
} catch {
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
return { set, kept };
|
|
2802
|
+
}
|
|
2803
|
+
function cepLogLevelCommand(version = CSXS_VERSIONS[0]) {
|
|
2804
|
+
return process.platform === "win32" ? `reg add HKCU\\Software\\Adobe\\CSXS.${version} /v LogLevel /t REG_SZ /d ${CEP_LOG_LEVEL} /f` : `defaults write com.adobe.CSXS.${version} LogLevel ${CEP_LOG_LEVEL}`;
|
|
2805
|
+
}
|
|
1678
2806
|
|
|
1679
2807
|
// src/setup/check.ts
|
|
1680
2808
|
var BRIDGE_PROBE_MS = 2e3;
|
|
1681
2809
|
var BRIDGE_BUSY_FIX = "This is a timeout, not a refused connection \u2014 something is listening, it just did not answer in time. After Effects is most likely busy running a script, or waiting on a modal dialog nobody has clicked. Wait and run check_setup again, up to about a minute, before restarting anything; it usually clears on its own. On macOS, if the user has switched to another desktop, ask them to switch back to the one After Effects is on.";
|
|
1682
2810
|
function sha256(file) {
|
|
1683
2811
|
try {
|
|
1684
|
-
return crypto2.createHash("sha256").update(
|
|
2812
|
+
return crypto2.createHash("sha256").update(fs7.readFileSync(file)).digest("hex");
|
|
1685
2813
|
} catch {
|
|
1686
2814
|
return null;
|
|
1687
2815
|
}
|
|
1688
2816
|
}
|
|
1689
|
-
async function
|
|
2817
|
+
async function probeBridge(candidates = diagnosticPortCandidates(), opPort) {
|
|
2818
|
+
const order = [];
|
|
2819
|
+
const push = (p) => {
|
|
2820
|
+
if (p !== void 0 && !order.includes(p)) order.push(p);
|
|
2821
|
+
};
|
|
2822
|
+
push(opPort);
|
|
2823
|
+
for (const c of candidates) push(c);
|
|
2824
|
+
const probed = [];
|
|
2825
|
+
let answering = null;
|
|
2826
|
+
let busy = null;
|
|
2827
|
+
for (const port of order) {
|
|
2828
|
+
const r = await probePanel(port, BRIDGE_PROBE_MS);
|
|
2829
|
+
probed.push(r);
|
|
2830
|
+
if (r.status === "panel" && !answering) answering = r;
|
|
2831
|
+
if (r.status === "busy" && !busy) busy = r;
|
|
2832
|
+
if (answering) break;
|
|
2833
|
+
}
|
|
2834
|
+
const atOpPort = opPort === void 0 ? null : probed.find((p) => p.port === opPort) ?? null;
|
|
2835
|
+
return { answering, busy, atOpPort, probed };
|
|
2836
|
+
}
|
|
2837
|
+
var CEP_LOG_TAIL_BYTES = 256 * 1024;
|
|
2838
|
+
function readTail(file, bytes) {
|
|
2839
|
+
const fd = fs7.openSync(file, "r");
|
|
1690
2840
|
try {
|
|
1691
|
-
const
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
2841
|
+
const size = fs7.fstatSync(fd).size;
|
|
2842
|
+
const start = Math.max(0, size - bytes);
|
|
2843
|
+
const buf = Buffer.alloc(size - start);
|
|
2844
|
+
fs7.readSync(fd, buf, 0, buf.length, start);
|
|
2845
|
+
return buf.toString("utf8");
|
|
2846
|
+
} finally {
|
|
2847
|
+
fs7.closeSync(fd);
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
function findSignatureFailure(logs, bundleId = BUNDLE_ID) {
|
|
2851
|
+
const needle = `Signature verification failed for extension ${bundleId}`;
|
|
2852
|
+
for (const file of logs) {
|
|
2853
|
+
let text;
|
|
2854
|
+
let mtime;
|
|
2855
|
+
try {
|
|
2856
|
+
text = readTail(file, CEP_LOG_TAIL_BYTES);
|
|
2857
|
+
mtime = fs7.statSync(file).mtime;
|
|
2858
|
+
} catch {
|
|
2859
|
+
continue;
|
|
2860
|
+
}
|
|
2861
|
+
const lines = text.split(/\r?\n/).filter((l) => l.includes(needle));
|
|
2862
|
+
if (lines.length > 0) {
|
|
2863
|
+
return { file, line: lines[lines.length - 1].trim().slice(0, 240), mtime };
|
|
1704
2864
|
}
|
|
1705
|
-
return { ok: false, detail: `no response on port ${port} (${e.message})` };
|
|
1706
2865
|
}
|
|
2866
|
+
return null;
|
|
2867
|
+
}
|
|
2868
|
+
function signatureWorkaroundSteps(installed) {
|
|
2869
|
+
return [
|
|
2870
|
+
"After Effects is refusing to load the panel because CEP's signature check is running even though PlayerDebugMode is on. That is an Adobe CEP 12 bug, not a broken install \u2014 nothing here needs reinstalling, and restarting After Effects again will not change it. The fix is to sign the installed panel folder yourself, with Adobe's own tool, so it passes the check.",
|
|
2871
|
+
"1. Download ZXPSignCmd from Adobe's CEP-Resources repository on GitHub (folder `ZXPSignCMD/4.1.3`, the build for this platform). It is a single command-line program.",
|
|
2872
|
+
'2. Make a self-signed certificate: `ZXPSignCmd -selfSignedCert US CA "AE MCP" "AE MCP Bridge" password cert.p12` (the country, state and names are free text; keep the password).',
|
|
2873
|
+
`3. Sign the installed panel folder into a zip: \`ZXPSignCmd -sign "${installed}" signed.zxp cert.p12 password -tsa http://timestamp.digicert.com\``,
|
|
2874
|
+
`4. Unzip signed.zxp back into that same folder, overwriting in place. That adds \`META-INF/signatures.xml\` and a \`mimetype\` file next to the panel's own files and changes nothing else. check_setup keeps reporting the panel as up to date with those extra files present.`,
|
|
2875
|
+
"5. Then restart After Effects \u2014 the signed panel loads on launch. Run check_setup to confirm.",
|
|
2876
|
+
"Caveat: setup_panel installs a fresh, unsigned copy. After any reinstall on this machine, steps 3-5 have to be done again."
|
|
2877
|
+
];
|
|
1707
2878
|
}
|
|
1708
|
-
async function checkSetup() {
|
|
2879
|
+
async function checkSetup(opts = {}) {
|
|
1709
2880
|
const checks = [];
|
|
1710
2881
|
const supported = isSupportedPlatform();
|
|
1711
2882
|
checks.push({
|
|
@@ -1729,11 +2900,12 @@ async function checkSetup() {
|
|
|
1729
2900
|
fix: debugMode.on ? void 0 : `Run the setup_panel tool. After Effects only loads unsigned panels when ${debugModeLocation()} is set.`
|
|
1730
2901
|
});
|
|
1731
2902
|
const installed = installedPanelDir();
|
|
1732
|
-
const isInstalled =
|
|
2903
|
+
const isInstalled = fs7.existsSync(path8.join(installed, "CSXS", "manifest.xml"));
|
|
2904
|
+
const signed = isInstalled && signedInstallPresent(installed);
|
|
1733
2905
|
checks.push({
|
|
1734
2906
|
name: "panelInstalled",
|
|
1735
2907
|
ok: isInstalled,
|
|
1736
|
-
detail: isInstalled ? installed : `not present at ${installed}`,
|
|
2908
|
+
detail: isInstalled ? `${installed}${signed ? " (self-signed: META-INF present)" : ""}` : `not present at ${installed}`,
|
|
1737
2909
|
fix: isInstalled ? void 0 : "Run the setup_panel tool to install it."
|
|
1738
2910
|
});
|
|
1739
2911
|
let installComplete = true;
|
|
@@ -1749,7 +2921,7 @@ async function checkSetup() {
|
|
|
1749
2921
|
});
|
|
1750
2922
|
}
|
|
1751
2923
|
if (isInstalled) {
|
|
1752
|
-
const panelWs =
|
|
2924
|
+
const panelWs = path8.join(installed, "node_modules", "ws");
|
|
1753
2925
|
const wsOk = isWsModuleDir(panelWs);
|
|
1754
2926
|
checks.push({
|
|
1755
2927
|
name: "panelDependencies",
|
|
@@ -1765,16 +2937,47 @@ async function checkSetup() {
|
|
|
1765
2937
|
detail: running ? "running" : "not running",
|
|
1766
2938
|
fix: running ? void 0 : "Launch After Effects. The panel starts automatically with it."
|
|
1767
2939
|
});
|
|
1768
|
-
const
|
|
1769
|
-
const
|
|
2940
|
+
const bridge = await probeBridge(opts.candidates, opts.opPort);
|
|
2941
|
+
const fromFile = portFilePort();
|
|
2942
|
+
const bridgeTimedOut = !bridge.answering && bridge.busy !== null;
|
|
2943
|
+
let bridgeDetail;
|
|
2944
|
+
if (bridge.answering) {
|
|
2945
|
+
bridgeDetail = `responding on port ${bridge.answering.port}`;
|
|
2946
|
+
if (fromFile !== null && fromFile !== bridge.answering.port) {
|
|
2947
|
+
bridgeDetail += ` (the port file says ${fromFile}, which is stale \u2014 the socket is the authority)`;
|
|
2948
|
+
}
|
|
2949
|
+
} else if (bridge.busy) {
|
|
2950
|
+
bridgeDetail = `port ${bridge.busy.port} accepted the connection but did not answer within ${BRIDGE_PROBE_MS / 1e3}s \u2014 After Effects is probably busy`;
|
|
2951
|
+
} else {
|
|
2952
|
+
const ports = bridge.probed.map((p) => p.port).join(", ");
|
|
2953
|
+
const reasons = bridge.probed.map((p) => p.detail).join("; ");
|
|
2954
|
+
bridgeDetail = `no response on port${bridge.probed.length > 1 ? "s" : ""} ${ports} (${reasons})`;
|
|
2955
|
+
}
|
|
1770
2956
|
checks.push({
|
|
1771
2957
|
name: "bridgeReachable",
|
|
1772
|
-
ok: bridge.
|
|
1773
|
-
detail:
|
|
1774
|
-
fix: bridge.
|
|
2958
|
+
ok: bridge.answering !== null,
|
|
2959
|
+
detail: bridgeDetail,
|
|
2960
|
+
fix: bridge.answering ? void 0 : bridgeTimedOut ? BRIDGE_BUSY_FIX : "If the other checks pass, restart After Effects so the panel reloads."
|
|
1775
2961
|
});
|
|
1776
|
-
if (
|
|
1777
|
-
const
|
|
2962
|
+
if (opts.opPort !== void 0 && (bridge.answering || bridge.busy)) {
|
|
2963
|
+
const opPort = opts.opPort;
|
|
2964
|
+
const agrees = bridge.answering !== null && bridge.answering.port === opPort;
|
|
2965
|
+
let detail;
|
|
2966
|
+
let fix;
|
|
2967
|
+
if (agrees) {
|
|
2968
|
+
detail = `tool calls go to port ${opPort} and the panel answers there`;
|
|
2969
|
+
} else if (bridge.answering) {
|
|
2970
|
+
const pinned = pinnedPort() === opPort;
|
|
2971
|
+
detail = pinned ? `tool calls are pinned to port ${opPort} by AE_MCP_PORT (${bridge.atOpPort?.detail ?? "not probed"}), but the panel is answering on port ${bridge.answering.port}` : `tool calls are being sent to port ${opPort} (${bridge.atOpPort?.detail ?? "not probed"}), but the panel is answering on port ${bridge.answering.port}`;
|
|
2972
|
+
fix = pinned ? `AE_MCP_PORT pins tool calls to port ${opPort}, and a pin is never walked past, so retrying changes nothing. Set AE_MCP_PORT to ${bridge.answering.port}, or unset it, then reconnect the MCP server (restart the client's connection to it). Do NOT restart After Effects for this: a panel is listening, and a restart would cost the user their work in progress for nothing.` : `The server's port is stale; the panel is fine. Retry the failed call \u2014 the server re-checks the port whenever a call is refused and switches to ${bridge.answering.port} by itself. If it still fails, reconnect the MCP server (restart the client's connection to it) so it starts on the right port. Do NOT restart After Effects for this: a panel is listening, and a restart would cost the user their work in progress for nothing.`;
|
|
2973
|
+
} else {
|
|
2974
|
+
detail = `tool calls are being sent to port ${opPort} (${bridge.atOpPort?.detail ?? "not probed"}); port ${bridge.busy.port} accepted a connection but is busy and could not be confirmed as the panel`;
|
|
2975
|
+
fix = `Wait for After Effects to finish what it is doing, then retry the call \u2014 once port ${bridge.busy.port} answers, the server switches to it on the next refused call. Do not restart After Effects: something is listening there.`;
|
|
2976
|
+
}
|
|
2977
|
+
checks.push({ name: "portAgreement", ok: agrees, detail, fix });
|
|
2978
|
+
}
|
|
2979
|
+
if (bridge.answering && source) {
|
|
2980
|
+
const assessment = assessPanel(bridge.answering.health?.bundleHash, sha256(path8.join(installed, "jsx", "bundle.jsx")), {
|
|
1778
2981
|
installComplete
|
|
1779
2982
|
});
|
|
1780
2983
|
const ok = assessment.state === "current";
|
|
@@ -1785,16 +2988,44 @@ async function checkSetup() {
|
|
|
1785
2988
|
fix: ok ? void 0 : assessment.message
|
|
1786
2989
|
});
|
|
1787
2990
|
}
|
|
1788
|
-
if (bridge.
|
|
2991
|
+
if (bridge.answering && !isInstalled) {
|
|
1789
2992
|
checks.push({
|
|
1790
2993
|
name: "panelIdentity",
|
|
1791
2994
|
ok: false,
|
|
1792
|
-
detail: `a panel is answering on port ${port}, but not the one at ${installed}`,
|
|
2995
|
+
detail: `a panel is answering on port ${bridge.answering.port}, but not the one at ${installed}`,
|
|
1793
2996
|
fix: `An older install is serving the bridge. Look in ${cepExtensionsDir()} for a differently named folder, remove it, then run setup_panel and restart After Effects.`
|
|
1794
2997
|
});
|
|
1795
2998
|
}
|
|
2999
|
+
if (running && isInstalled && !bridge.answering && !bridgeTimedOut) {
|
|
3000
|
+
const logs = cepLogPaths();
|
|
3001
|
+
const finding = findSignatureFailure(logs);
|
|
3002
|
+
if (finding) {
|
|
3003
|
+
checks.push({
|
|
3004
|
+
name: "panelSignature",
|
|
3005
|
+
ok: false,
|
|
3006
|
+
evidence: "failure-logged",
|
|
3007
|
+
detail: `the CEP log ${finding.file} (last written ${finding.mtime.toISOString()}) records: "${finding.line}" \u2014 CEP is enforcing signature verification on this panel even though PlayerDebugMode is on`,
|
|
3008
|
+
fix: "CEP is refusing the unsigned panel's signature despite PlayerDebugMode \u2014 an Adobe CEP 12 bug, not a broken install. No reinstall and no restart changes that; the panel folder has to be self-signed with Adobe's ZXPSignCmd. nextSteps carries the exact steps."
|
|
3009
|
+
});
|
|
3010
|
+
} else if (logs.length > 0) {
|
|
3011
|
+
const level = await cepLogLevel(CSXS_VERSIONS[0]);
|
|
3012
|
+
checks.push({
|
|
3013
|
+
name: "panelSignature",
|
|
3014
|
+
ok: true,
|
|
3015
|
+
evidence: "log-clean",
|
|
3016
|
+
detail: `no signature failure recorded in ${logs[0]}` + (level === null ? " (CEP LogLevel is not set, so the log may predate this launch)" : ` (CEP LogLevel ${level})`)
|
|
3017
|
+
});
|
|
3018
|
+
} else {
|
|
3019
|
+
checks.push({
|
|
3020
|
+
name: "panelSignature",
|
|
3021
|
+
ok: true,
|
|
3022
|
+
evidence: "no-log",
|
|
3023
|
+
detail: `no CEP log found in ${cepLogDir()} \u2014 CEP is not logging, so a signature refusal cannot be ruled out; nextSteps says how to turn the log on`
|
|
3024
|
+
});
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
1796
3027
|
const ready = checks.every((c) => c.ok);
|
|
1797
|
-
return { ready, checks, nextSteps: buildNextSteps(checks, ready,
|
|
3028
|
+
return { ready, checks, nextSteps: buildNextSteps(checks, ready, bridgeTimedOut) };
|
|
1798
3029
|
}
|
|
1799
3030
|
function buildNextSteps(checks, ready, bridgeTimedOut = false) {
|
|
1800
3031
|
if (ready) return ["Everything is connected. After Effects is ready to drive."];
|
|
@@ -1802,15 +3033,39 @@ function buildNextSteps(checks, ready, bridgeTimedOut = false) {
|
|
|
1802
3033
|
const steps = [];
|
|
1803
3034
|
if (by("platform")?.ok === false) return [by("platform").fix];
|
|
1804
3035
|
if (by("panelAssetsPresent")?.ok === false) return [by("panelAssetsPresent").fix];
|
|
3036
|
+
const agreement = by("portAgreement");
|
|
3037
|
+
if (agreement && agreement.ok === false && by("bridgeReachable")?.ok === true && /pinned to port/.test(agreement.detail)) {
|
|
3038
|
+
return [
|
|
3039
|
+
`Do not restart After Effects \u2014 the panel is running and answering. ${agreement.detail}.`,
|
|
3040
|
+
"AE_MCP_PORT pins the server to that port and a pin is never walked past, so retrying will not help: set AE_MCP_PORT to the port the panel answers on, or unset it, then reconnect the MCP server \u2014 restart the client's connection to it, not After Effects.",
|
|
3041
|
+
"Nothing about the install is wrong; setup_panel would change nothing here."
|
|
3042
|
+
];
|
|
3043
|
+
}
|
|
3044
|
+
if (agreement && agreement.ok === false && by("bridgeReachable")?.ok === true) {
|
|
3045
|
+
return [
|
|
3046
|
+
`Do not restart After Effects \u2014 the panel is running and answering. ${agreement.detail}.`,
|
|
3047
|
+
"Retry the call that failed. The server re-checks the port whenever a call is refused and switches to the port that answers, so the retry normally succeeds on its own.",
|
|
3048
|
+
"If it still fails, reconnect the MCP server \u2014 restart the client's connection to it, not After Effects \u2014 so it starts on the right port.",
|
|
3049
|
+
"Nothing about the install is wrong; setup_panel would change nothing here."
|
|
3050
|
+
];
|
|
3051
|
+
}
|
|
1805
3052
|
const installSound = by("panelInstalled")?.ok !== false && by("panelUpToDate")?.ok !== false && by("panelDependencies")?.ok !== false && by("cepDebugMode")?.ok !== false;
|
|
1806
3053
|
if (bridgeTimedOut && installSound) {
|
|
1807
|
-
|
|
3054
|
+
const out = [
|
|
1808
3055
|
"Wait \u2014 do not restart anything yet. The panel is answering its port but is too busy to reply, which almost always means After Effects is still running a script.",
|
|
1809
3056
|
"Run check_setup again in about ten seconds, and keep checking for up to a minute. It usually clears on its own.",
|
|
1810
3057
|
"While waiting, ask the user to look at After Effects: a dialog it is waiting on (unsaved project, missing fonts) blocks it the same way and may be hidden behind another window.",
|
|
1811
|
-
"On macOS, if they have moved to a different desktop, ask them to switch back to the one After Effects is on."
|
|
1812
|
-
"Only if it is still not answering after a minute should you treat it as disconnected and quit and reopen After Effects."
|
|
3058
|
+
"On macOS, if they have moved to a different desktop, ask them to switch back to the one After Effects is on."
|
|
1813
3059
|
];
|
|
3060
|
+
if (agreement && agreement.ok === false) {
|
|
3061
|
+
out.push(`${agreement.detail}. Once it answers, the server switches to it on the next refused call \u2014 no reconnect needed.`);
|
|
3062
|
+
}
|
|
3063
|
+
out.push("Only if it is still not answering after a minute should you treat it as disconnected and quit and reopen After Effects.");
|
|
3064
|
+
return out;
|
|
3065
|
+
}
|
|
3066
|
+
const signature = by("panelSignature");
|
|
3067
|
+
if (signature && signature.ok === false) {
|
|
3068
|
+
return signatureWorkaroundSteps(installedPanelDir());
|
|
1814
3069
|
}
|
|
1815
3070
|
const brokenInstall = by("panelUpToDate")?.ok === false || by("panelDependencies")?.ok === false;
|
|
1816
3071
|
const needsInstall = by("panelInstalled")?.ok === false || brokenInstall;
|
|
@@ -1837,15 +3092,30 @@ function buildNextSteps(checks, ready, bridgeTimedOut = false) {
|
|
|
1837
3092
|
);
|
|
1838
3093
|
}
|
|
1839
3094
|
if (steps.length === 0 && by("bridgeReachable")?.ok === false) {
|
|
1840
|
-
steps.push(
|
|
1841
|
-
|
|
3095
|
+
steps.push(
|
|
3096
|
+
"Everything is installed but the panel is not answering. If After Effects has not been restarted since the panel was installed, quit and reopen After Effects once \u2014 the panel loads only at launch."
|
|
3097
|
+
);
|
|
3098
|
+
if (signature?.evidence === "log-clean") {
|
|
3099
|
+
steps.push(
|
|
3100
|
+
`If it has already been restarted, the CEP log shows no signature refusal (${signature.detail}), so that is not the cause. Ask the user to open Window > Extensions > AE MCP Bridge in After Effects and read back what the panel says; if nothing opens at all, report it with log_issue.`
|
|
3101
|
+
);
|
|
3102
|
+
} else {
|
|
3103
|
+
steps.push(
|
|
3104
|
+
"If it has already been restarted and still does not answer, the likely cause is CEP refusing the unsigned panel's signature even though PlayerDebugMode is on \u2014 an Adobe CEP 12 bug, seen on Windows (issue #91). Nothing about the install is wrong, and restarting again will not help."
|
|
3105
|
+
);
|
|
3106
|
+
steps.push(
|
|
3107
|
+
`To confirm it, turn CEP's own logging up so it records why the panel did not load: run \`${cepLogLevelCommand()}\` (After Effects 2026 uses CSXS.${CSXS_VERSIONS[0]}; setup_panel sets this for new installs). Then relaunch After Effects and run check_setup again \u2014 it reads the CEP log, and if the signature check is what is failing it reports panelSignature with the exact fix.`
|
|
3108
|
+
);
|
|
3109
|
+
steps.push("Also ask the user to open Window > Extensions > AE MCP Bridge in AE: if nothing appears at all, that is the same symptom.");
|
|
3110
|
+
}
|
|
1842
3111
|
}
|
|
1843
3112
|
return steps;
|
|
1844
3113
|
}
|
|
1845
3114
|
|
|
1846
3115
|
// src/setup/install.ts
|
|
1847
|
-
import
|
|
1848
|
-
import
|
|
3116
|
+
import fs8 from "node:fs";
|
|
3117
|
+
import path9 from "node:path";
|
|
3118
|
+
var SIGNED_INSTALL_OVERWRITTEN_NOTE = "The previous install had been self-signed (META-INF/signatures.xml was present). This fresh copy is NOT signed. On a machine where CEP refuses unsigned panels despite PlayerDebugMode \u2014 the issue #91 case, seen on Windows \u2014 After Effects will not load it until it is signed again: repeat the ZXPSignCmd steps from check_setup's panelSignature nextSteps on the newly installed folder, then restart After Effects.";
|
|
1849
3119
|
async function installPanel(opts = {}) {
|
|
1850
3120
|
const actions = [];
|
|
1851
3121
|
const notes = [];
|
|
@@ -1858,7 +3128,7 @@ async function installPanel(opts = {}) {
|
|
|
1858
3128
|
if (!source) {
|
|
1859
3129
|
throw new Error("Could not find the CEP panel assets that ship with this server. Reinstall the package.");
|
|
1860
3130
|
}
|
|
1861
|
-
if (!
|
|
3131
|
+
if (!fs8.existsSync(path9.join(source, "jsx", "bundle.jsx"))) {
|
|
1862
3132
|
throw new Error(`The panel at ${source} has no jsx/bundle.jsx. In a git checkout, run \`npm run build:jsx\` first.`);
|
|
1863
3133
|
}
|
|
1864
3134
|
const ws = wsModuleDir();
|
|
@@ -1868,9 +3138,9 @@ async function installPanel(opts = {}) {
|
|
|
1868
3138
|
);
|
|
1869
3139
|
}
|
|
1870
3140
|
const target = installedPanelDir();
|
|
1871
|
-
const existing =
|
|
3141
|
+
const existing = fs8.lstatSync(target, { throwIfNoEntry: false });
|
|
1872
3142
|
if (existing?.isSymbolicLink() && !opts.force) {
|
|
1873
|
-
const linkTarget =
|
|
3143
|
+
const linkTarget = fs8.readlinkSync(target);
|
|
1874
3144
|
return {
|
|
1875
3145
|
ok: true,
|
|
1876
3146
|
panelPath: target,
|
|
@@ -1883,16 +3153,17 @@ async function installPanel(opts = {}) {
|
|
|
1883
3153
|
]
|
|
1884
3154
|
};
|
|
1885
3155
|
}
|
|
3156
|
+
const wasSigned = existing ? signedInstallPresent(target) : false;
|
|
1886
3157
|
if (existing) {
|
|
1887
|
-
|
|
1888
|
-
actions.push("Removed the previously installed panel.");
|
|
3158
|
+
fs8.rmSync(target, { recursive: true, force: true });
|
|
3159
|
+
actions.push(wasSigned ? "Removed the previously installed (self-signed) panel." : "Removed the previously installed panel.");
|
|
1889
3160
|
}
|
|
1890
|
-
|
|
3161
|
+
fs8.mkdirSync(path9.dirname(target), { recursive: true });
|
|
1891
3162
|
copyRecursive(source, target);
|
|
1892
3163
|
actions.push(`Installed the panel to ${target}.`);
|
|
1893
|
-
const dest =
|
|
1894
|
-
|
|
1895
|
-
|
|
3164
|
+
const dest = path9.join(target, "node_modules", "ws");
|
|
3165
|
+
fs8.mkdirSync(path9.dirname(dest), { recursive: true });
|
|
3166
|
+
fs8.rmSync(dest, { recursive: true, force: true });
|
|
1896
3167
|
copyRecursive(ws, dest);
|
|
1897
3168
|
if (!isWsModuleDir(dest)) {
|
|
1898
3169
|
throw new Error(
|
|
@@ -1915,6 +3186,15 @@ async function installPanel(opts = {}) {
|
|
|
1915
3186
|
}
|
|
1916
3187
|
}
|
|
1917
3188
|
}
|
|
3189
|
+
if (opts.enableDebugMode !== false) {
|
|
3190
|
+
const logging = await ensureCepLogging();
|
|
3191
|
+
if (logging.set.length > 0) {
|
|
3192
|
+
actions.push(
|
|
3193
|
+
`Set CEP's LogLevel to ${CEP_LOG_LEVEL} for CSXS ${logging.set.join(", ")} so that, if After Effects ever refuses to load the panel, its CEP log says why.`
|
|
3194
|
+
);
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
if (wasSigned) notes.push(SIGNED_INSTALL_OVERWRITTEN_NOTE);
|
|
1918
3198
|
if (rebootRecommended) {
|
|
1919
3199
|
notes.push("PlayerDebugMode was just turned on. Quit and reopen After Effects; if the panel still does not connect, restart the Mac once \u2014 on some macOS builds the preference only applies after a reboot.");
|
|
1920
3200
|
}
|
|
@@ -1924,29 +3204,146 @@ async function installPanel(opts = {}) {
|
|
|
1924
3204
|
actions,
|
|
1925
3205
|
restartAfterEffects: true,
|
|
1926
3206
|
rebootRecommended,
|
|
1927
|
-
notes
|
|
3207
|
+
notes,
|
|
3208
|
+
signedInstallOverwritten: wasSigned || void 0
|
|
1928
3209
|
};
|
|
1929
3210
|
}
|
|
1930
3211
|
|
|
3212
|
+
// src/tools/whatsNew.ts
|
|
3213
|
+
var RELEASE_HEADING = /^## v?(\d+\.\d+\.\d+)\s*$/;
|
|
3214
|
+
function parseSemver(v) {
|
|
3215
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
|
|
3216
|
+
if (!m) return null;
|
|
3217
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
3218
|
+
}
|
|
3219
|
+
function compareSemver(a, b) {
|
|
3220
|
+
const pa = parseSemver(a);
|
|
3221
|
+
const pb = parseSemver(b);
|
|
3222
|
+
if (!pa) throw new Error(`Not a version: ${JSON.stringify(a)}`);
|
|
3223
|
+
if (!pb) throw new Error(`Not a version: ${JSON.stringify(b)}`);
|
|
3224
|
+
for (let i = 0; i < 3; i++) {
|
|
3225
|
+
if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
|
|
3226
|
+
}
|
|
3227
|
+
return 0;
|
|
3228
|
+
}
|
|
3229
|
+
function splitReleases(body) {
|
|
3230
|
+
const lines = body.replace(/\r\n/g, "\n").split("\n");
|
|
3231
|
+
const preamble = [];
|
|
3232
|
+
const sections = [];
|
|
3233
|
+
let current = null;
|
|
3234
|
+
for (const line of lines) {
|
|
3235
|
+
const m = RELEASE_HEADING.exec(line);
|
|
3236
|
+
if (m) {
|
|
3237
|
+
if (current) sections.push({ version: current.version, text: current.lines.join("\n").trimEnd() });
|
|
3238
|
+
current = { version: m[1], lines: [line] };
|
|
3239
|
+
} else if (current) {
|
|
3240
|
+
current.lines.push(line);
|
|
3241
|
+
} else {
|
|
3242
|
+
preamble.push(line);
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
if (current) sections.push({ version: current.version, text: current.lines.join("\n").trimEnd() });
|
|
3246
|
+
return { preamble: preamble.join("\n").trimEnd(), sections };
|
|
3247
|
+
}
|
|
3248
|
+
function renderWhatsNew(body, opts) {
|
|
3249
|
+
const { preamble, sections } = splitReleases(body);
|
|
3250
|
+
const newest = sections.length > 0 ? sections[0].version : null;
|
|
3251
|
+
const server = `This server is after-effects-mcp ${opts.serverVersion}.`;
|
|
3252
|
+
if (opts.since === void 0) {
|
|
3253
|
+
return {
|
|
3254
|
+
text: `${server} Pass \`since\` to see only the releases after a version.
|
|
3255
|
+
|
|
3256
|
+
${body.trim()}`,
|
|
3257
|
+
versions: sections.map((s) => s.version),
|
|
3258
|
+
newest
|
|
3259
|
+
};
|
|
3260
|
+
}
|
|
3261
|
+
const since = opts.since.replace(/^v/, "");
|
|
3262
|
+
const newer = sections.filter((s) => compareSemver(s.version, since) > 0);
|
|
3263
|
+
if (newer.length === 0) {
|
|
3264
|
+
const described = newest ? `the newest release described here is ${newest}` : "this guide describes no releases";
|
|
3265
|
+
return {
|
|
3266
|
+
text: `${server} Nothing newer than ${since}: ${described}.`,
|
|
3267
|
+
versions: [],
|
|
3268
|
+
newest
|
|
3269
|
+
};
|
|
3270
|
+
}
|
|
3271
|
+
const versions = newer.map((s) => s.version);
|
|
3272
|
+
const header = `${server} Showing releases after ${since}: ${versions.join(", ")}.`;
|
|
3273
|
+
const parts = [header, preamble, ...newer.map((s) => s.text)].filter((p) => p.length > 0);
|
|
3274
|
+
return { text: parts.join("\n\n"), versions, newest };
|
|
3275
|
+
}
|
|
3276
|
+
|
|
1931
3277
|
// src/generated/content.ts
|
|
1932
3278
|
var GUIDES = [
|
|
1933
3279
|
{
|
|
1934
3280
|
name: "ae-setup",
|
|
1935
|
-
description: "Diagnose and repair the connection between the AE MCP tools and After Effects \u2014 panel not installed, AE not running, Adobe debug preference off, bridge not responding. Load when an After Effects tool reports it cannot reach AE, or when the user is setting this up for the first time.",
|
|
1936
|
-
body: '# Getting After Effects connected\n\nThe tools talk to a small panel that runs **inside** After Effects. Three things must be true for that to work: the panel is installed, Adobe is willing to load it, and AE is open.\n\nAssume the person you are helping is a motion designer, not a developer. They should never need to open a terminal \u2014 you have tools for all of this.\n\n## Always start with check_setup\n\n`check_setup` is read-only and safe to call at any time. It returns a `checks` array and a `nextSteps` list already written in plain language.\n\n**Relay `nextSteps` to the user directly.** Do not paraphrase it into jargon, and do not invent steps it did not mention.\n\n## A timeout is not a disconnection\n\nBefore you start any repair, check which failure you actually have. "The panel did not answer within N seconds" and "cannot reach the panel" are opposite diagnoses:\n\n- **Did not answer** \u2014 something is listening; it is just too busy to reply. After Effects is single-threaded, so a long script or a modal dialog waiting for a click blocks it completely. Nothing is broken and nothing needs installing.\n- **Cannot reach** \u2014 nothing is listening. That is the case the repair path below is for.\n\nOn a timeout, `check_setup` says so itself: `bridgeReachable` reports that the port accepted the connection but did not answer in time, and `nextSteps` tells you to wait. Follow it. Re-running `setup_panel` or restarting After Effects here costs the user their work-in-progress for nothing, and both are the wrong move. Poll `check_setup` for about a minute; it usually clears on its own.\n\nTwo things to ask about while waiting: whether a dialog is sitting behind another window in After Effects, and \u2014 on macOS \u2014 whether they have switched to another desktop. Calls have been reported to stall while the user is on a different Space and to complete as soon as they come back.\n\n## Install before they open After Effects, if you still can\n\nThe panel loads at launch and only at launch. So the order matters, and it is\nthe opposite of what people assume:\n\n- **After Effects is closed** \u2014 install now. When they open it, the panel is\n simply there. No restart, nothing to ask for. This is the good path, and on a\n first-time setup you can usually get it.\n- **After Effects is open** \u2014 install, then they have to quit and reopen it.\n Unavoidable, but worth avoiding: if they have not opened AE yet in this\n conversation, do the install *first* and tell them to open it after.\n\n`check_setup` reports `afterEffectsRunning`, so you always know which case you\nare in before you say anything.\n\n## The repair path\n\n1. **`check_setup`** \u2014 find out what is actually wrong.\n2. **`setup_panel`** \u2014 if the panel is missing or out of date. Tell the user what it will do *before* you call it: it copies the panel into their Adobe extensions folder and switches on the Adobe preference that permits unsigned panels. Both changes are user-level and reversible.\n3. **Get the panel loaded.** If AE was closed, ask them to open it. If it was already open, ask them to quit and reopen it. You cannot do either for them.\n4. **`check_setup`** again to confirm.\n\n## What the individual failures mean\n\n| Check | Meaning when it fails |\n|---|---|\n| `platform` | Not macOS or Windows. After Effects only runs on those two, so there is nothing to fix. |\n| `panelAssetsPresent` | The server package is incomplete \u2014 it needs reinstalling. |\n| `cepDebugMode` | Adobe refuses to load unsigned panels until this preference is on. `setup_panel` sets it. |\n| `panelInstalled` | The panel is not in the Adobe extensions folder yet. `setup_panel` installs it. |\n| `panelUpToDate` | The files on disk are older than this server. Run `setup_panel`. |\n| `panelRunningCurrent` | AE is *running* an older panel than these tools ship. This is the one that predicts whether calls will actually work \u2014 `panelUpToDate` can pass while this fails, for the whole window between installing an update and restarting AE. |\n| `afterEffectsRunning` | AE is closed. If the panel also needs installing, install it now and then ask them to open AE \u2014 that saves a restart. |\n| `bridgeReachable` | Everything is installed but the panel isn\'t answering. Read the detail: if the port **timed out**, After Effects is busy and you should wait, not restart. If nothing is listening at all, restarting AE almost always fixes it. |\n\n## The reboot case\n\n`cepDebugMode` is an Adobe preference that, on some macOS builds, only takes effect after a **restart of the Mac** \u2014 not just of After Effects. If `setup_panel` reports `rebootRecommended: true` and restarting AE alone did not fix it, ask the user to reboot once. This is a one-time cost, never needed again.\n\n## When a tool says the panel is out of date\n\nYou may get an error saying the panel is older than these tools, or that it does\nnot recognise an op. That is a version mismatch, not a broken tool, and the\nmessage tells you which of the two fixes applies:\n\n- **"updated on disk \u2026 still running the previous version"** \u2014 `setup_panel` has\n already done its part. Only a restart of After Effects will help; running it\n again will not.\n- **anything else** \u2014 run `setup_panel`, then get AE restarted.\n\nEither way, do not retry the failed call until the user confirms AE has\nrestarted. Say it as a version mismatch in plain language, not as a failure:\ntheir tools moved ahead of the panel, and it takes a restart to catch up.\n\n## If it still will not connect\n\nAsk the user to open **Window > Extensions > AE MCP Bridge** inside After Effects. That panel shows its own status and a log, and will say whether it started, which port it took, or what error it hit. Have them read it back to you.\n\nA common cause is a stale install: the panel loaded an older script bundle than the server expects. `check_setup`\'s `panelUpToDate` catches that \u2014 the fix is `setup_panel` followed by an AE restart.'
|
|
3281
|
+
description: "Diagnose and repair the connection between the AE MCP tools and After Effects \u2014 panel not installed, AE not running, Adobe debug preference off, bridge not responding, bridge on the wrong port, CEP refusing the panel's signature. Load when an After Effects tool reports it cannot reach AE, or when the user is setting this up for the first time.",
|
|
3282
|
+
body: '# Getting After Effects connected\n\nThe tools talk to a small panel that runs **inside** After Effects. Three things must be true for that to work: the panel is installed, Adobe is willing to load it, and AE is open.\n\nAssume the person you are helping is a motion designer, not a developer. They should never need to open a terminal \u2014 you have tools for all of this.\n\n## Always start with check_setup\n\n`check_setup` is read-only and safe to call at any time. It returns a `checks` array and a `nextSteps` list already written in plain language.\n\n**Relay `nextSteps` to the user directly.** Do not paraphrase it into jargon, and do not invent steps it did not mention.\n\n## A timeout is not a disconnection\n\nBefore you start any repair, work out which of **four** failures you have. They\nread alike and their remedies contradict each other:\n\n- **Did not answer in time** \u2014 something is listening; it is just too busy to\n reply. After Effects is single-threaded, so a long script or a modal dialog\n waiting for a click blocks it completely. Nothing is broken and nothing needs\n installing. The call **did** reach After Effects and may still be running, so\n do not re-send it.\n- **Cannot reach** \u2014 nothing is listening on the port the server tried. That\n is the case the repair path below is for \u2014 *unless* the next case applies,\n so read the message to the end: it now says which other ports it looked at\n and what it found there.\n- **Cannot reach on port X, while `check_setup` reports the panel on port Y**\n \u2014 the server\'s idea of the port went stale, not the panel. The panel is\n running and answering; the server was started from a port file that no\n longer described it. `check_setup` reports this as its own check,\n `portAgreement`, with both numbers. Retry the failed call: the server\n re-checks the port whenever a call is refused and switches to the port that\n answers by itself. If it still fails, reconnect the MCP server (restart the\n client\'s connection to it, not After Effects). **Never restart After Effects\n for this** \u2014 something is listening, and a restart costs the user their work\n in progress for nothing.\n- **Waited behind another op for the write queue and was dropped** \u2014 the panel\n is fine and this call never left the server, so nothing in the project was\n changed. Writes are serialized so that they land in the order the agent issued\n them and nothing drops into the middle of work the user asked for as one\n thing; something in front is taking a very long time, usually a long\n `run_batch`. Re-sending **is** safe here, once the work in\n front has finished; this is the one of the four where it is. Find out what is\n in front with `get_job` or `await_job` \u2014 reads are never queued, so `list_`\n and `get_` calls still answer.\n\nThe message itself tells you which one you have; the queue error says in as many\nwords that nothing was written. Never collapse them into "the bridge is playing\nup", because "re-send it", "do not re-send it" and "do not restart it" are three\ndifferent answers.\n\nOn a timeout, `check_setup` says so itself: `bridgeReachable` reports that the port accepted the connection but did not answer in time, and `nextSteps` tells you to wait. Follow it. Re-running `setup_panel` or restarting After Effects here costs the user their work-in-progress for nothing, and both are the wrong move. Poll `check_setup` for about a minute; it usually clears on its own.\n\nTwo things to ask about while waiting: whether a dialog is sitting behind another window in After Effects, and \u2014 on macOS \u2014 whether they have switched to another desktop. Calls have been reported to stall while the user is on a different Space and to complete as soon as they come back.\n\nA **full** write queue is a fifth message and a different instruction again:\ntoo many calls are already waiting, so stop issuing writes and let it drain. If\nyou have that much independent work, send it as one `run_batch` \u2014 one\nExtendScript pass and one place in the queue, instead of dozens of each.\n\n## Install before they open After Effects, if you still can\n\nThe panel loads at launch and only at launch. So the order matters, and it is\nthe opposite of what people assume:\n\n- **After Effects is closed** \u2014 install now. When they open it, the panel is\n simply there. No restart, nothing to ask for. This is the good path, and on a\n first-time setup you can usually get it.\n- **After Effects is open** \u2014 install, then they have to quit and reopen it.\n Unavoidable, but worth avoiding: if they have not opened AE yet in this\n conversation, do the install *first* and tell them to open it after.\n\n`check_setup` reports `afterEffectsRunning`, so you always know which case you\nare in before you say anything.\n\n## The repair path\n\n1. **`check_setup`** \u2014 find out what is actually wrong.\n2. **`setup_panel`** \u2014 if the panel is missing or out of date. Tell the user what it will do *before* you call it: it copies the panel into their Adobe extensions folder and switches on two Adobe preferences \u2014 the one that permits unsigned panels, and CEP\'s own log (only where it was never set), so that if AE ever refuses to load the panel the reason is recorded. All of it is user-level and reversible. One caution: if `check_setup` showed the install as **self-signed**, `setup_panel` replaces it with an unsigned copy and the signing has to be done again \u2014 say so before calling it.\n3. **Get the panel loaded.** If AE was closed, ask them to open it. If it was already open, ask them to quit and reopen it. You cannot do either for them.\n4. **`check_setup`** again to confirm.\n\n## What the individual failures mean\n\n| Check | Meaning when it fails |\n|---|---|\n| `platform` | Not macOS or Windows. After Effects only runs on those two, so there is nothing to fix. |\n| `panelAssetsPresent` | The server package is incomplete \u2014 it needs reinstalling. |\n| `cepDebugMode` | Adobe refuses to load unsigned panels until this preference is on. `setup_panel` sets it. |\n| `panelInstalled` | The panel is not in the Adobe extensions folder yet. `setup_panel` installs it. When it passes, the detail also says whether the install is **self-signed** \u2014 see the last section. |\n| `panelUpToDate` | The files on disk are older than this server. Run `setup_panel`. A self-signed install\'s extra files (`META-INF/`, `mimetype`) do not count as differences. |\n| `panelRunningCurrent` | AE is *running* an older panel than these tools ship. This is the one that predicts whether calls will actually work \u2014 `panelUpToDate` can pass while this fails, for the whole window between installing an update and restarting AE. |\n| `afterEffectsRunning` | AE is closed. If the panel also needs installing, install it now and then ask them to open AE \u2014 that saves a restart. |\n| `bridgeReachable` | Everything is installed but the panel isn\'t answering **on any port** \u2014 it asks the default port and the port file\'s port both, and reports whichever answers. Read the detail: if a port **timed out**, After Effects is busy and you should wait, not restart. If nothing is listening anywhere, one restart of AE is reasonable, because the panel loads only at launch; if that has already been tried, read `panelSignature` and `nextSteps` before suggesting a second. |\n| `portAgreement` | The panel answers, but on a different port from the one tool calls go to. The server is stale, the panel is fine. Retry the call, or reconnect the MCP server. Never restart After Effects for this. |\n| `panelSignature` | CEP\'s own log says it refused to load the panel because its signature failed verification \u2014 even with `cepDebugMode` on. An Adobe CEP 12 bug, seen on Windows. No restart and no reinstall helps; `nextSteps` carries the self-signing fix. When it *passes* on a silent bridge, its `evidence` says whether the log was clean or there was no log to read. |\n\n## The reboot case\n\n`cepDebugMode` is an Adobe preference that, on some macOS builds, only takes effect after a **restart of the Mac** \u2014 not just of After Effects. If `setup_panel` reports `rebootRecommended: true` and restarting AE alone did not fix it, ask the user to reboot once. This is a one-time cost, never needed again.\n\n## When a tool says the panel is out of date\n\nYou may get an error saying the panel is older than these tools, or that it does\nnot recognise an op. That is a version mismatch, not a broken tool, and the\nmessage tells you which of the two fixes applies:\n\n- **"updated on disk \u2026 still running the previous version"** \u2014 `setup_panel` has\n already done its part. Only a restart of After Effects will help; running it\n again will not.\n- **anything else** \u2014 run `setup_panel`, then get AE restarted.\n\nEither way, do not retry the failed call until the user confirms AE has\nrestarted. Say it as a version mismatch in plain language, not as a failure:\ntheir tools moved ahead of the panel, and it takes a restart to catch up.\n\n## The port, and why it is not the port file\n\nThe panel binds port 7777. On one machine nothing else ever uses it, so if\nthe port is taken when the panel starts, the holder is a previous copy of the\npanel itself \u2014 a leftover from a relaunch, or a second panel window. The panel\ndoes not move to another port in that case: it says so in its own status\n("waiting \u2014 port 7777 is held by another AE MCP panel"), and retries until the\nold one exits, then takes over with no restart. If a user reports that status\nand it does not clear, the leftover process is still running; quitting After\nEffects fully and reopening it clears it.\n\nThe panel moves only when something that is *not* an AE MCP panel holds the\nport, and it logs which port it chose and why. People who really run two\nAfter Effects instances at once can opt back into moving past their own panel\nwith a small file the panel reads at launch,\n`~/.engineroom-ae-mcp/config.json` (`%USERPROFILE%\\.engineroom-ae-mcp\\config.json`\non Windows): `{ "port": 7777, "allowPortWalk": true }`. The server is told\nwhich port to use with `AE_MCP_PORT`, and with that set it never looks\nelsewhere.\n\nWhatever the panel bound goes in the port file beside that config. The server\nreads it once at startup as a hint and no more: the socket is the authority,\nand when a call is refused the server asks 7777 and the file\'s port which one\nactually answers.\n\n## The panel is installed, AE is open, and nothing is listening\n\nIf every check passes except `bridgeReachable`, After Effects has already been\nrestarted once since the install, and nothing appears when the user opens\n**Window > Extensions > AE MCP Bridge** \u2014 no window, no error \u2014 then the\nlikely cause is CEP refusing the panel\'s signature despite `cepDebugMode`\nbeing on. It is an Adobe CEP 12 bug, seen on Windows with After Effects 2026,\nand nothing about the install is wrong. Do not send the user round a third\nrestart.\n\n`check_setup` reads CEP\'s own log and reports `panelSignature` when it finds\nthe refusal. If there is no log to read, `nextSteps` says how to turn CEP\nlogging on (one command, which it spells out; `setup_panel` sets it on new\ninstalls), after which one relaunch of AE and one more `check_setup` gives\na definite answer.\n\nThe fix is to sign the installed panel folder with Adobe\'s own `ZXPSignCmd`\nand unzip the result back over the folder in place. `nextSteps` carries the\nexact commands. Two things to tell the user: the signing survives updates to\nthe server but **not** a reinstall \u2014 `setup_panel` puts an unsigned copy back\nand warns when it does \u2014 and `check_setup` keeps reporting a signed install as\nup to date, so the extra files it adds are nothing to worry about.\n\n## If it still will not connect\n\nAsk the user to open **Window > Extensions > AE MCP Bridge** inside After Effects. That panel shows its own status and a log, and will say whether it started, which port it took, whether it is waiting for a port another panel holds, or what error it hit. Have them read it back to you.\n\nA common cause is a stale install: the panel loaded an older script bundle than the server expects. `check_setup`\'s `panelUpToDate` catches that \u2014 the fix is `setup_panel` followed by an AE restart.'
|
|
1937
3283
|
},
|
|
1938
3284
|
{
|
|
1939
3285
|
name: "after-effects",
|
|
1940
|
-
description: "How to drive Adobe After Effects well through the AE MCP tools \u2014 orienting in a project,
|
|
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.'
|
|
3286
|
+
description: "How to drive Adobe After Effects well through the AE MCP tools \u2014 orienting in a project, bounding every read, verifying a write with a diff, screenshots as a one-off diagnostic with their cost in tokens, write ordering, and the three bridge failures whose remedies contradict each other. Load whenever a task involves After Effects, motion graphics, comps, layers, or keyframes; it points at one reference per subject for everything else.",
|
|
3287
|
+
body: '# Driving After Effects\n\nYou have direct control of a live After Effects session. The user sees every\nchange immediately, and every tool call is a real undo step in their project.\nWork like a motion designer at the keyboard, not like a script that fires blind.\n\nThis file holds only what silently produces wrong output on *any* task. Each\nsubject sits behind it in a reference, loaded when a task reaches that subject\nand never otherwise. From any client, `ae_guide({topic: \u2026})`; loaded as a\nskill, the file beside this one:\n\n| When the task reaches\u2026 | Topic | File |\n|---|---|---|\n| keyframes, easing, rigging, expressions | `animation` | `references/animation.md` |\n| a shape layer and its Contents | `shapes` | `references/shapes.md` |\n| a text layer | `text` | `references/text.md` |\n| shots placed into a master comp, markers, retiming a shot | `assembly` | `references/assembly.md` |\n| any raw ExtendScript for `run_jsx` \u2014 read it **before** writing the script | `extendscript-gotchas` | `references/extendscript-gotchas.md` |\n| sound effects and beds | `sound` | `references/sound.md` |\n| a `.mogrt` export, importing footage, or the solids a deleted comp leaves behind | `mogrt-and-footage` | `references/mogrt-and-footage.md` |\n| a tool that fought back, and what to do with what you learned | `issue-journal` | `references/issue-journal.md` |\n| a call that behaves differently from what you remember | `whats-new` | `references/whats-new.md` |\n\nTwo topics are siblings rather than references: `style-guide`, for capturing the\nuser\'s look, and `ae-setup`, for a connection that is not working.\n\n## Read the house style first\n\n`get_house_style` returns the project\'s style guide \u2014 palette, type, motion\ndefaults, layout rules \u2014 read from `house-style.md` beside the `.aep`. Call it\nonce at the start of any build task and follow it; it is the difference between\nwork that matches everything else the user has made and work that does not.\n\nWhat comes back is a **digest** of a few hundred tokens, which is what you build\nfrom. Pass `detail: "full"` only when you need the guide\'s own wording \u2014 and\nalways before `set_house_style`, which replaces the whole file, so you send the\nmerged document rather than a patch. `structured: false` means the guide could\nnot be read as a spec and its opening text is returned instead; `found: false`\nmeans there is none \u2014 build with sensible defaults and offer once, at the end,\nto capture one from what you made. Do not nag about it.\n\n## Orient before you touch anything\n\nNever guess at project state. Cheap reads exist for exactly this, and the\nbounded form is the one to reach for:\n\n| Question | Call |\n|---|---|\n| What is in this project? | `get_project_summary` |\n| What comps exist? | `list_comps({include: []})` \u2014 ids and names |\n| What is in this comp? | `list_layers({compId, include: []})` \u2014 id, index, name, type; `get_comp_tree` for the nesting |\n| Where is a layer, by name, type or effect? | `find_layers` \u2014 `{matches, count, compsSearched, included}`; a match is id, index, name, sourceType, compId and compName, and `include` (`flags`, `timing`, `parent` \u2014 the `list_layers` names) widens it. Unlike `list_layers`, omitting `include` here does **not** return everything |\n| One layer, in depth | `get_layer_full({compId, layerId, include: [\u2026]})` \u2B50 |\n| What did my last change actually do? | `snapshot_comp` \u2192 `diff_comp` \u2B50 |\n\n**Bound every read.** A tool result is re-sent to you on every later request\nfor the rest of the session, so an unbounded read is paid for once when you\nmake it and again on every request until the session ends. `include: []` on the\nlist calls; on `get_layer_full`, `include` names the sections you want\n(`transform`, `effects`, `masks`, `markers`, `bounds`, `text`, `shape`,\n`source`), `maxKeyframes` caps the keyframes per property, `shapeDepth` limits\nthe Contents walk, and `shapeDetail: "compact"` reads a shape layer as one line\nper group. Omit them all and you get everything. Whatever a bound leaves out is\nnamed and counted in the response, so a bounded read never passes for a\ncomplete one.\n\n**One bounded `get_layer_full` beats several narrow calls.** It returns\ntransforms with their keyframes and expressions, effects with every parameter,\nmasks, markers and `sourceRect` (the layer\'s visible bounds) in one answer, so\nwhen you need three of those, name the three in `include` rather than making\nthree calls. The bound is the `include` list, not the number of calls.\n\n**Making a variant of something** is `duplicate_comp`, which returns the new id\nso you never look for the copy by name. Its default is a **shallow** copy, the\nsame as AE\'s own Duplicate: the copy\'s precomp layers point at the *same* nested\ncomps, so editing one edits both. That is right for "another version of this\nshot" and wrong for "a variant of this rig" \u2014 for that pass `deep: true`, which\nduplicates the nested comps and re-points the copy at them.\n\n**Effects are added by matchName**, not display name:\n`add_effect({matchName: "ADBE Gaussian Blur 2"})`, then `set_effect_param` by\nparameter name. If you do not know a matchName, `list_available_effects({filter:\n"blur"})` \u2014 always with a `filter`, since the unfiltered list is several hundred\nentries and takes seconds every time. `list_effects` shows what is already on a\nlayer with every parameter.\n\n## Identify things by id, never by index\n\nEvery comp and layer has a stable numeric `id`. Layer `index` is a 1-based\nposition that **shifts whenever layers are added, deleted or reordered**. Store\n`(compId, layerId)` and pass those; an index captured before a `create_*` call\nmay point at a different layer by the time you use it.\n\n`reorder_layer` is the op that shifts every index below it, so it takes\n**exactly one** destination and the id forms are the ones to use:\n`beforeLayerId` puts the layer directly in front of (above) that layer,\n`afterLayerId` directly behind it. `toIndex` is absolute \u2014 1 is the front,\n`numLayers` the back \u2014 and means the index the layer **ends up at**, not the\nslot it displaces. Reach for it only when you genuinely mean "on top" or "at the\nback". The result carries `movedFrom` beside the landed `index`.\n\n## Read, then write, then verify\n\n1. Read the current state (a bounded `get_layer_full`).\n2. Make the change.\n3. Verify by reading back \u2014 property values are the ground truth. A screenshot\n tells you something *looks* wrong; the property tells you *why*.\n\n**Verify with a diff, not a second full read.** Re-reading a comp to see what\nchanged makes you compare two large answers by eye, and you pay for both for the\nrest of the session. `snapshot_comp({compId})` before the write returns a\n`snapshotId` and almost nothing else; `diff_comp({since})` afterwards returns\nonly what moved \u2014 layers added, removed, renamed, retimed, re-parented, keyframe\ncounts, expressions and effects gained or lost \u2014 and a count of the layers that\ndid not. `run_jsx` and `run_batch` take `diff: true`, which does the same\n*inside* the call, so there is no window between the write and the fingerprint.\n\nA fingerprint records no property values, expression text, effect parameters,\nmasks or shape contents, so `changeCount: 0` means none of the recorded fields\nmoved \u2014 not that the comp is unchanged. For "is this value right", read the\nproperty.\n\n**A failed write is a partial write.** Nothing rolls back, on any tool: a\n`run_jsx` that throws on its fourth line has done the first three, and a\n`run_batch` that stops at op 30 has done 29. So find out what landed \u2014 on a\nfailure the `diff: true` result rides on the error, which is the cheapest way \u2014\nand never re-send the same call to see whether it fails again: the half that\nworked is applied a second time.\n\n## Screenshots are a diagnostic, not a feedback loop\n\n`screenshot_frame` and `screenshot_layer` are **one-off checks**. Do not\nscreenshot every frame, do not scrub through time, do not screenshot after every\nedit.\n\n**An image is the most expensive result this server returns, and it stays in\nyour context for the rest of the session.** Measured at 4K on 26.3: a frame at\n`downsample: 3` \u2014 the factor picked for a 4K comp when you pass none, a\n1280\xD7720 image \u2014 costs about 1,230 tokens; `downsample: 2` about 2,765; a full\n4K frame at `downsample: 1` can blow out your context in one call. Budget a\nbuild in a handful of sheets, not in frames.\n\n**To judge motion, ask for a contact sheet \u2014 one call, not three.**\n`screenshot_frame({compId, times: [0, 1, 2]})` takes two to six times and\nreturns a *single* tiled image with the time burned into each tile, held to\nroughly the pixel budget of one frame. `time` and `times` are mutually\nexclusive; there is no `times` on `screenshot_layer`.\n\nThe `downsample` is picked from the comp size unless you pass one \u2014 2 at 1080p,\n3 at 4K, aiming at a long edge near 1280px, per tile on a sheet. `downsample: 1`\nis genuinely full resolution: the render sets the comp\'s resolution explicitly\nand restores it afterwards, whatever the viewer\'s Resolution dropdown says. The\nresult reports the dimensions and the factor actually applied \u2014 trust those.\nSpace single frames out; rapid back-to-back requests are the pattern most likely\nto come back stale, and a sheet does the spacing for you.\n\nFour results are not a picture, and their remedies point in different\ndirections, so read which one you got before you retry anything:\n\n- **`Stale frame`** \u2014 After Effects re-served pixels it rendered for a\n *different* request, which the error names. Wait a few seconds and retry at a\n higher `downsample`; if two frames of a genuinely static comp really are\n identical, a different factor renders a different number of pixels and proves\n it.\n- **`Corrupt frame`** \u2014 the render stopped writing and the file is not a whole\n PNG, so nothing was sent. **Not a timeout.** It tracks how heavy the comp is:\n retry at `downsample` 6\u20138, or screenshot the shot precomps one at a time\n instead of the assembly.\n- **`Render timed out`** \u2014 After Effects was still working when the panel gave\n up, and probably still is. Wait a few seconds before doing anything else; a\n retry issued now queues behind it.\n- **`empty: true`** \u2014 every pixel at that time is fully transparent. That is a\n fact about the composition: the wrong time, a layer outside its in/out points,\n disabled, or at zero opacity.\n\nOn a sheet a single bad tile is drawn as a marked block and named in `warning`;\nthe rest of the sheet is still good, so read it rather than re-requesting.\n\n**Never disable layers to make a screenshot render.** A frame that will not\nrender is a limit of the render path, not project content that needs fixing \u2014\nand it is very easy to leave someone\'s comp switched off afterwards.\n\n## Bulk work, and the order writes land in\n\n`run_batch` runs many ops in one ExtendScript pass \u2014 far faster than the same\nops as separate calls, and far fewer undo steps. **Up to 500 ops it is one undo\nstep.** Over 500 it returns a `jobId` at once, before the first chunk runs, and\nlands as **one undo step per chunk of 25** \u2014 about 24 for 600 ops \u2014 because\nAfter Effects discards an undo group that spans two script calls.\n`await_job(jobId)` finishes it, and is the call that carries progress: send it\nwith a progress token and `notifications/progress` arrive while it waits. None\ncan ride on `run_batch` itself, whose response is already back; `get_job` polls\nthe same state without a token. `singleUndo: true` forces one step at any size\nup to 2000 by running the whole batch in one blocking call, with AE\'s interface\nfrozen for the duration. Every result carries the *measured* `undoSteps`: read\nit before you tell anyone how many Cmd-Z the work takes. `transactional: true`\n(the default) stops at the first failing op; `false` runs the rest and collects\nthe errors.\nNeither rolls anything back \u2014 `diff: true` shows what landed.\n\n**You do not have to issue writes one at a time.** The server runs one write at\na time for the whole session, in the order you issued them, and a long\n`run_batch` holds that lock until its last chunk lands \u2014 so nothing drops into\nthe middle of work the user asked for as one thing. A call that had to wait says\nso with `queuedBehind` and `waitedMs`. Reads are never queued: `list_*`,\n`get_*`, the screenshots and `await_job` answer while a batch runs.\n\n## Three failures that read alike, with remedies that contradict\n\n- **Did not answer in time.** The call reached After Effects, which is\n single-threaded, so a long script or a modal dialog nobody has clicked blocks\n every reply. It may still be running: do **not** re-send it, do not restart\n After Effects, do not run `setup_panel`. Poll `check_setup` for about a\n minute, and ask the user whether a dialog is hiding behind another window. A\n call that legitimately needs longer gets it from `AE_MCP_OP_TIMEOUT_MS` in the\n server\'s environment.\n- **Cannot reach After Effects.** The connection was refused, so nothing\n reached After Effects and nothing changed. The server looks again on its own\n first \u2014 7777, then the port file \u2014 and follows a panel that has moved, so a\n refusal that reaches you is one that search did not resolve. Call\n `check_setup` and relay its `nextSteps` to the user in plain language; the\n repair path is the `ae-setup` topic. If it reports the panel answering on a\n port other than the one the call named, retry the call or reconnect the MCP\n server and never restart After Effects for it: something *is* listening, and\n a restart costs the user their work in progress. Do not diagnose CEP by hand.\n- **Waited behind another op for the write queue and was dropped.** Nothing\n reached After Effects and nothing changed. Something in front is slow \u2014 usually\n a long `run_batch`; find it with `get_job` or `await_job` \u2014 then re-send once\n it has finished. That is the one of the three where re-sending is right.'
|
|
3288
|
+
},
|
|
3289
|
+
{
|
|
3290
|
+
name: "animation",
|
|
3291
|
+
description: "Keyframes, easing, rigging and expressions through the AE MCP tools \u2014 what each keyframe tool controls, the one ease pair per side that the tools size for you, a held pose as a hold key and a waypoint that must not stop, the parenting facts that cost review rounds (at rest, before the pop, in the parent's space, a mirrored parent), building expression text from numbers, a shake as an expression over keys, and why an expression's `time` does not follow a retimed layer. Load when a task animates, parents or drives anything with an expression.",
|
|
3292
|
+
body: '# Keyframes, rigging and expressions\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- `get_keyframes` reads them all back, with the ease on each key \u2014 the exact\n answer to "does it move right", where a screenshot is an impression.\n\n**Pass one `{influence, speed}` pair per side and nothing else.**\n`set_temporal_ease` and `add_keyframe` size the ease array for the property\nthemselves and report the count that worked as `easeDimensions`. That number is\nnot derivable from the value\'s dimension \u2014 After Effects wants a different count\nper property, and the measured table is in the `extendscript-gotchas` topic for\nthe day you script an ease by hand. Through the tools you never need it; from a\nscript, use the `ease()` helper, which is the same sizing code and not a second\ncopy of it.\n\n**A held pose is a hold keyframe, not two equal keys.** Between two\nequal-valued bezier keys the value still moves \u2014 the tangents are shaped by the\nneighbouring keys, so a pose drifts and a motion path bows through the hold.\nGive the key that starts the hold `set_interpolation({keyIndex, out: "hold"})`,\nor `interpolation` on the `add_keyframe` that makes it, and the value stays put\nuntil the next key.\n\n**A waypoint with speed zero is a stop.** Easing every key in a run brings the\nspeed to zero at each one, and the move visibly halts there. One move is one\nease \u2014 `set_temporal_ease` on the first and last key \u2014 with the keys between\nthem left linear (`set_interpolation({in: "linear", out: "linear"})`), so\nspeed passes through them instead of stopping.\n\nExpressions are usually a better answer than dense keyframes for anything\nprocedural \u2014 wiggle, loops, counters, follow-through, time remapping. They stay\neditable by the user afterwards, where a wall of baked keyframes does not.\n\n## Rigging\n\nNulls, parents and retimed layers. Parenting carries less than people expect,\nand each rule below has cost a review round more than once.\n\n**Opacity does not propagate through parenting.** Scale, rotation and position\nride the parent; opacity never does. Every text or child layer under a shape\nthat pops or stamps in needs its own matching opacity keys, or it sits there on\nscreen before its parent has revealed anything.\n\n**Parent world layers to a camera null *before* you key the null, at a time\nwhere it is still at identity** \u2014 anchor and position at the comp centre, scale\n100. Parenting compensation is a no-op there, so the children keep plain world\ncoordinates and the keyframes they already had. To look at a world target `T`\nat zoom `s`: key the null\'s scale to `s` and its position to `C + (C \u2212 T)\xB7s/100`,\nwith `C` the comp centre. Children of a precomp layer get parented while that\nparent is at rest, and AE rewrites their position and divides their scale for\nyou. `parent_layer` with `preserveTransform` does the arithmetic.\n\n**The same rule for any parent: parent while it is at rest, and before it is\nkeyed.** Keeping a child where it is means writing the inverse of the parent\'s\ntransform *at that moment* into the child, so a child parented under a tilt,\nmid-pop or at a scale other than 100 carries that inverse for good and reads\nwrong afterwards \u2014 and a parent whose first scale key is `0` has no inverse at\nall; a child parented there is divided by zero. Build, parent, then pop. Key a\nchild before parenting, while the parent is at identity, and its keys stay\nplain world values; a child keyed *after* parenting is keyed in the parent\'s\nspace \u2014 `world \u2212 parentPosition + parentAnchor`, and that difference divided by\nthe parent\'s scale when the parent is a scaled precomp. Either way, read the\nchild\'s position, scale and rotation back before trusting them; the result\'s\n`correction` says what was rewritten.\n\n**A parent mirrored with a negative scale mirrors its children, text included.**\nA rig flipped with scale `[\u2212100, 100]` shows every label backwards. Flip a text\nchild back with its own negative x scale about its own anchor \u2014 centre-justified,\nso the flip is about its centre \u2014 and when the parent flips mid-scene, do it by\nan expression on the child\'s scale that reads the sign of\n`parent.transform.scale[0]`, so the child follows every flip.\n\n**Anything flown out of frame is still there when the camera moves.** A layer\nparked at y = \u2212900 comes straight back into shot on a whip-up. Cut its opacity\nonce it is clear rather than trusting the frame edge to hide it.\n\n**Expressions run on comp time, not layer time.** Retiming a layer with\n`startTime` moves its keyframes and leaves its expressions exactly where they\nwere, so a freeze written against `time` \u2014 `Math.min(time, 0.75)` \u2014 replays\nfrom zero in the shifted layer. Offset `time` inside the expression\n(`time \u2212 startTime`, or rewrite `\\btime\\b` to `(time + offset)` in every\nexpression on a layer you retime), or key the value instead. A shot built in\nits own comp does not have this problem, which is the reason the `assembly`\ntopic builds shots in local time.\n\n## Expressions\n\n`set_expression` takes a `propertyPath` such as `["Transform", "Position"]` or\n`["Effects", "Gaussian Blur", "Blurriness"]`. Expressions are\nExtendScript-flavoured JavaScript evaluated by AE per frame.\n\n**`set_expression` throws when After Effects cannot evaluate what it wrote.**\nAssigning an expression succeeds whatever the text says; AE\'s only report is a\nwarning banner on the property in the interface, which you cannot see. So the\ntool evaluates the property after the write and, if AE reports an error on it,\nfails with AE\'s own message and the property path \u2014 a success result means the\nexpression compiles and runs. The expression is still on the property after\nsuch a failure: fix it and call `set_expression` again, or `clear_expression`\nto remove it. Do not add a second one on top. `toggle_expression({enabled:\ntrue})` verifies the same way, because enabling can surface an error that has\nsat on the property since it was disabled.\n\n`get_expression` reads one back as `expression`, `enabled` and\n`expressionError` \u2014 AE\'s message, or an empty string when it runs clean. A\nnon-empty `expressionError` means the property is not being driven by that\nexpression, whatever `enabled` says. `toggle_expression` disables one without\ndeleting it, and `clear_expression` removes it.\n\n**Build expression text from numbers explicitly.** A negative offset\nconcatenated after `time-` produces `time--2`, and an array concatenated bare\nloses its brackets (`960,540`). Write an offset as `"time-(" + t + ")"` and a\nvector as `"[" + v[0] + ", " + v[1] + "]"`. `set_expression` reports what does\nnot evaluate, with AE\'s own message, so a slip here is a failed call rather\nthan a property that quietly stops moving.\n\n**A shake on a keyed move is an expression over the keys, never more keys.**\nExtra keys fight the ease and cannot be moved with it; `value + \u2026` leaves the\nmove as keyed and adds the hit on top \u2014 a decaying sine per impact,\n`amplitude \xB7 sin(rate \xB7 d) \xB7 e^(\u2212decay \xB7 d)` with `d = time \u2212 hitTime`, summed\nover the hit times \u2014 and the null that carries the move keeps its keys\neditable.'
|
|
3293
|
+
},
|
|
3294
|
+
{
|
|
3295
|
+
name: "assembly",
|
|
3296
|
+
description: "Assembling shots into a master comp \u2014 each shot built in its own comp in local time and placed at its offset, comp markers at the beats, retiming, reversing or trimming a shot without touching its contents, the precomp that renders nothing before its startTime or past its source, what a nesting shares (controls, a resize, collapse transformations, motion blur), replacing a nested comp without losing the layers that nest it, and how an assembly, a small detail, a doubtful tile and a cut are verified. Load when a task has more than one scene, a narration to cut to, or a shot to move in time.",
|
|
3297
|
+
body: "# Assembling shots into a master\n\nEvery narrated job has this shape: several shots, each a few seconds, cut to\nbeats in a voice track, in one deliverable. Build it as **one comp per shot,\nplaced into a master**, and the three questions that otherwise cost a session \u2014\nwhere does this shot start, how do I move it, why is the frame black \u2014 stop\narising.\n\n## Build each shot in local time\n\nMake each shot its own comp (`create_comp`, sized and timed like the master)\nand animate it **from zero** \u2014 its first keyframe at `0`, its expressions\nwritten against the comp's own `time`. A shot comp has its own clock: when it\nis placed into the master, every keyframe and every expression inside it keeps\nworking wherever it lands, because nothing inside it ever knew about the\nmaster's timeline. That is the whole reason to build this way; a shot animated\nin the master's absolute time cannot be moved without re-keying it, and its\nexpressions do not follow \u2014 see the `animation` topic on comp time versus layer\ntime.\n\n## Place it at its offset\n\n`create_precomp_layer({compId: master, sourceCompId: shot})` places the shot as\na layer, then `set_layer({\u2026, startTime: offset})` puts its local zero at the\nbeat. `inPoint` and `outPoint` trim it in master time; `stretch` changes its\nspeed. All four leave the shot comp untouched, which is what \"retime a shot\"\nshould mean: move the layer, never the contents. To make a shot longer or\nshorter, change its content in the shot comp and then re-trim the layer.\n\n**A precomp layer cannot run past its source.** `outPoint` clamps to the shot\ncomp's duration without a word, and a layer trimmed past it \u2014 in a rig as much\nas in the master \u2014 simply ends there. Extend the shot comp's `duration` with\n`set_comp` first, then the layer's `outPoint`, and read the out point back\nfrom the result. To play a shot backwards, time-remap the layer from the\nshot's end to its start \u2014 `run_jsx`: `timeRemapEnabled = true`, then swap the\nvalues of the two default keys with `setValueAtKey` \u2014 and nothing inside the\nshot is touched.\n\n**A precomp layer renders nothing before its `startTime`.** So you cannot hold\na rigged shot at its pre-animation state by pushing `startTime` later than the\nmoment it appears \u2014 you get an empty frame until the layer starts. Freeze the\nstart by duplicating the shot comp and stripping the keys (`duplicate_comp`,\nwhich is a shallow copy by default \u2014 pass `deep: true` when the shot nests\ncomps of its own), or start the layer at the moment it should first be visible\nand let the shot's own opening hold. Freeze the *end* past the source duration\nwith time remap and `Math.min(time + off, dur \u2212 0.1)` on the layer, not by\nextending the shot comp.\n\n**Nothing on the master's timeline hides a shot that has run past its cut.**\nTrim `outPoint` at the cut, or the next shot renders on top of a tail that is\nstill animating.\n\n## What a nesting shares, and what it clips\n\n**A control inside a shot comp is shared by every layer that nests it.** Key a\nslider in the shot and every instance of the shot changes with it. A shot that\nmust differ per placement is its own comp \u2014 `duplicate_comp`, with `deep:\ntrue` when the shot nests comps of its own \u2014 keyed on its own control.\n\n**Changing a shot comp's size moves everything that nests it by half the\nchange.** A precomp layer's anchor sits at its source's centre, and after\n`set_comp` changes the width or height the anchor and the position of each\nnesting layer no longer agree, so the nested world jumps by half the\ndifference. Read every nesting layer's anchor point and position back and\nre-place it.\n\n**A precomp is clipped at its own bounds unless collapse transformations is\non.** A blur or a glow at a shot's edge is cut off at the frame, and a camera\nbelow 100 % shows the cut edge; a shot flown through at scale goes soft at 4\xD7.\nThe layer's collapse switch fixes both \u2014 vector contents render at the final\nresolution and past the bounds \u2014 and every instance of the shot needs it, not\njust one. It costs two things: an adjustment layer inside a collapsed shot acts\non the frame *behind* the shot rather than on the shot alone, and the shot\nlayer's own blending mode is ignored. Where either matters, leave collapse off\nand oversize the shot comp instead. No tool sets the switch; from `run_jsx`,\n`layerById(compId, layerId).collapseTransformation = true`.\n\n**Motion blur is a per-comp switch and a per-layer switch, and the layer\nswitch inside the shot is the one a build forgets.** The master's comp switch,\nthe precomp layer's switch and each moving layer's switch inside the shot all\nhave to be on before anything in the shot blurs; switch the shot comp's own on\ntoo, so the shot previews the same way opened alone. From `run_jsx`:\n`comp.motionBlur = true` on a comp, `layer.motionBlur = true` on a layer.\n\n**`delete_comp` on a shot takes every layer that nests it with it**, in the\nmaster and anywhere else, silently. To replace a shot with a rebuilt one, build\nthe new comp under a working name, point each nesting layer at it \u2014 `run_jsx`:\n`layer.replaceSource(newComp, false)`, then `startTime`, `inPoint` and\n`outPoint` written again, because the swap keeps the old timing \u2014 and delete\nthe old comp only once nothing nests it (`return compById(oldId).usedIn.length`\nreads `0`); then rename.\n\n## Markers at the beats\n\n`add_marker({compId, time, comment})` with no `layerId` puts a **comp marker**\non the master; with a `layerId` it marks the layer. Put one at every beat you\ncut to, with the beat's words in `comment`, before you place a single shot \u2014 the\noffsets then have a name, the user sees the structure in the timeline, and a\nlater session reads the plan off the comp instead of re-deriving it. `duration`\nturns a marker into a span, which suits a shot's whole run.\n\n## Checking an assembly\n\n**Verify the master by reading it back; screenshot the shots.**\n`get_comp_tree({compId: master})` shows the nesting, `list_layers({compId,\ninclude: [\"timing\"]})` shows where each shot starts and ends, and\n`snapshot_comp` \u2192 `diff_comp` confirms a placement moved only the layer you\nmeant. A master is the heaviest thing you can render and the first to come back\n`Corrupt frame`; a shot comp screenshots cheaply and shows the same picture. A\ncamera move across the master is a null parented while at identity \u2014 the\n`animation` topic has the arithmetic.\n\nFour rules for the screenshots you do take:\n\n- **A shot comp with no background renders its empty areas transparent, and\n what a transparent area looks like is the viewer, not the set** \u2014 white,\n often, or a sheet's dark gutter. Judge sky, ground and anything that reads\n against the background on the assembled comp over its real background, or\n put a temporary solid behind the shot.\n- **Detail under about 40 px is judged at `downsample: 1`, never from a\n downsampled frame** \u2014 a stroke, a label, a small icon blur or vanish at the\n default factor. `screenshot_layer` isolates the layer but is still a whole\n frame; for a detail in a heavy comp, a small temporary comp of a few hundred\n pixels that nests the shot scaled up around the detail is what makes full\n resolution affordable.\n- **A contact-sheet tile that contradicts a property read-back is re-rendered\n as a single frame before anything is changed.** A tile can be stale \u2014 the\n sheet names it when it can tell \u2014 and a read-back cannot, so the\n disagreement is a question about the picture, not about the property.\n- **A cut is verified with a Difference render.** In a temporary comp, the\n outgoing shot at its last frame under the incoming shot at its first\n (`create_precomp_layer` twice, `set_layer` with `startTime` so those two\n frames fall on the same comp time), the top layer at\n `set_layer({blendingMode: \"DIFFERENCE\"})`, both over one solid so both sides\n are opaque, collapse off on both \u2014 a collapsed layer ignores its blending\n mode. `screenshot_frame` at that time: anything not black is where the two\n frames disagree."
|
|
3298
|
+
},
|
|
3299
|
+
{
|
|
3300
|
+
name: "extendscript-gotchas",
|
|
3301
|
+
description: "The run_jsx reference \u2014 when to script instead of calling a tool, how much one call can do before the timeout, scriptPath and libraries as the normal way to build, the helpers already in scope, and the ExtendScript facts that abort a script while naming something else (property lookups that return null, a `.value` read at the playhead rather than at a time, effects addressed by a name that is not unique, a track matte that switches its matte's video off, ease arrays sized per property, a TextDocument that stores the wrong justification, output-module settings that are read-only, the reserved words that stop a script before its first line). Read it before writing raw ExtendScript, not after it fails.",
|
|
3302
|
+
body: '# Scripting After Effects with run_jsx\n\n`run_jsx` executes arbitrary ExtendScript inside After Effects. Reach for it\nwhen a needed operation has no tool \u2014 the render queue, a bulk edit no single op\nexpresses, a rig that is easier to describe as a loop than as forty calls. Check\nthe tool list first: duplicating a comp, easing a keyframe, reordering a layer,\nplacing a shape layer at a sane origin and scoring a scene all have tools, and\neach wraps a trap on this page. The whole `OPS` table is in scope, so a script\ncan call any tool as a function and keep to the DOM for the part no tool covers.\n\n## How much one call can do\n\n- **Timeouts.** A call gets 120 s by default; `run_jsx`, `run_batch`, the two\n screenshot ops, `export_mogrt`, `import_footage` and `place_audio_cues` get\n 300 s. `AE_MCP_OP_TIMEOUT_MS` in the server\'s environment raises every one of\n them. ExtendScript is single-threaded, so After Effects\' interface is frozen\n for as long as the script runs, and a script that outlives its limit looks to\n the server exactly like a dead bridge \u2014 the "did not answer in time" failure \u2014\n while it carries on running to the end.\n- **In practice, about 60 layers\' worth of creation and keying per call** is\n where a scene build stays well inside the limit (reported from a\n fourteen-scene, thousand-layer build). Split a big build by scene, not by op;\n the same op repeated hundreds of times is `run_batch`, which chunks itself.\n- **Source size** \u2014 the script plus every library it names \u2014 is capped at 1 MB\n per call, because libraries are inlined into the call, so every byte travels\n every time.\n\n## Build from files, not from the conversation\n\nInline `code` is for a probe or a one-liner. A build is written as files:\n\n- **`scriptPath`** runs an absolute `.jsx` path. The server reads it, so the\n script is paid for once, on disk, rather than once per call in the\n conversation and again on every later request.\n- **`libraries`** takes absolute `.jsx` paths and inlines them ahead of the\n script, in the same scope, so their functions are callable from it. A library\n is re-read and re-evaluated on every call, so keep it to declarations rather\n than to work; it is parse-checked on its own before the script runs, and a\n failure inside one is reported against that file, by name and line.\n\nThe rule: a script file is paid for once; a helper shared by two scripts belongs\nin a library, never pasted into both. `code` and `scriptPath` are exclusive.\n\nPass **`diff: true`** on any script that writes. It fingerprints the comp\naround the call and appends only what changed \u2014 layers added, renamed, retimed,\nkeyframe counts \u2014 so the script reports what it did instead of you reading the\ncomp back, and on a throw the diff rides on the error, which is how you find\nwhere a half-applied script stopped.\n\n## What you already have\n\nBefore you write a helper, check it is not in scope. Each of these wraps a trap\non this page, and a version you write yourself re-derives the bug.\n\n- **`OPS`** \u2014 the whole tool table:\n `OPS.set_transform({compId, layerId, position: [0, 0]})`. Anything a tool\n already does well, do this way rather than reaching into the DOM.\n- **`compById(id)` / `layerById(compOrId, layerId)`** \u2014 the pair every tool\n returns, resolved. No index arithmetic.\n- **`walkProperty(layer, ["Transform", "Position"])`** \u2014 a property path,\n resolved the way the tools resolve it.\n- **`addKeys(prop, [[t, v], \u2026])`** \u2014 returns the key index of each, in order, so\n the next call can ease them without searching.\n- **`ease(prop, keyIndex, easeIn, easeOut)`** \u2014 sizes the KeyframeEase array\n itself; see "Keyframes and easing" below for why that is not optional.\n- **`shape(comp, {name, position})`** \u2014 a shape layer whose origin is `[0,0]`.\n- **`withoutUndoGroup(fn)`** \u2014 closes the undo group around one statement, which\n is what `copyToComp` needs.\n\n## Adobe\'s documentation describes an ExtendScript After Effects 2026 does not implement\n\nEvery item here is measured on 26.3, and several contradict Adobe\'s reference:\n`$.evalFile` evaluates into the calling function\'s scope, not the global one;\n`Error.start` and `Error.end` are `0` on every error, however far in the throw\nwas; an undo group opened in one script call and closed in another is\ndiscarded; `CompItem.posterTime` does not exist; `exportAsMotionGraphicsTemplate`\ninvalidates `app.project` itself, not only the comp passed to it. None of them\nraise \u2014 each returns exactly what success returns. When this page and Adobe\'s\nreference disagree, this page was measured; and before you build on a\ndocumented behaviour nothing here mentions, write the three-line probe that\nproves it.\n\n## Property names that return null\n\n`layer.property(matchName)` returns **null** for a name that does not exist on\nthat layer. It does not throw, so the failure lands later, somewhere else, as\n`null is not an object`. When a lookup might miss, check for null on the line\nthat does it.\n\n- **2D rotation is `ADBE Rotate Z`.** `layer.property("ADBE Rotation")` is\n null; `layer.transform.rotation` is the safe form.\n- **Audio levels are `layer.audioLevels`.** `layer.property("ADBE Audio\n Levels")` is null on an audio layer \u2014 the level is never set, on every layer,\n silently. *The tool already does:* `place_audio_cues`.\n- **Time remap is `layer.timeRemap`.** `layer.property("ADBE Time Remap")` is\n null on audio layers and on precomp layers, even after\n `timeRemapEnabled = true`.\n- **`instanceof` is unreliable on host objects.** Probe instead: a shape layer\n is one where `property("ADBE Root Vectors Group")` is non-null, and a\n project item\'s kind is `item.typeName` \u2014 `"Composition"`, `"Footage"`,\n `"Folder"` \u2014 a string compare that needs no class.\n\n## Reading and writing values\n\n- **`.value` is the value at the comp\'s current time** \u2014 wherever the user\n left the playhead, not time zero \u2014 so on a keyed or expression-driven\n property it is one sample from a moment you did not choose. Read at the\n time you mean with `valueAtTime(t, false)`, the post-expression value\n (`true` is pre-expression). To bake a driven property, sample every time\n you need first, then remove the expression, then the keys: each of those\n changes what the next read returns.\n- **`setValue` on a comp that is not open in a viewer can throw with the\n value already applied.** Open the comp first \u2014 `comp.openInViewer()`, or\n `set_active_comp` from outside \u2014 and on such a throw read the property back\n before retrying: the write may already be there.\n\n## Layers and comps\n\n- **A `comp.layer(n)` reference is index-bound, not a handle.** Once a\n `copyToComp` or a `duplicate()` has shifted the destination\'s indices, a\n reference taken earlier resolves to a *different* layer \u2014 which is how a script\n parents a layer to itself. Re-resolve by id or name after anything that\n inserts a layer.\n- **`copyToComp` does not put the copy at index 1.** The first copy lands on\n top; each later copy lands *below the previous copy*, so `dest.layer(1)` keeps\n handing back the same layer. Identify a copy by diffing the set of layer ids\n before and after the call, or pass `diff: true`.\n- **`copyToComp` needs the undo group closed.** AE refuses to copy a layer that\n has a parent or a linked expression while an undo group is open \u2014 exactly the\n rig worth copying. Wrap that one call in `withoutUndoGroup(function () { \u2026 })`,\n or pass `undoGroup: false` for the whole script and accept whatever undo steps\n AE records on its own.\n- **A copied layer carries parent-relative values.** Parented to a null that\n was not at identity, it renders offset in the destination. Check the first\n frame against the source; fix it with an intermediate null carrying the\n inverse offset, re-parenting while the new parent is at identity.\n- **Set the parent first, the transform second.** Raw `layer.parent = x` does\n not reliably preserve where a layer sits two levels deep, so after scripted\n parenting audit scale and rotation as well as position. *The tool already\n does:* `parent_layer` with `preserveTransform`.\n- **`app.executeCommand(id)` silently no-ops** through this bridge. Menu\n commands depend on host focus and the active selection, and the bridge has\n neither; the call returns without complaint and nothing happens. Use the API\n equivalents \u2014 `CompItem.duplicate()`, `layer.duplicate()`. *The tool already\n does:* `duplicate_comp`, whose `deep: true` also duplicates the nested comps,\n which `CompItem.duplicate()` alone does not.\n- **`moveTo` is a shape-node method, not a layer method.** `layer.moveTo(n)`\n throws `parent is not an INDEXED_GROUP`, naming a concept the caller never\n mentioned. Layers move with `moveBefore` / `moveAfter` / `moveToBeginning` /\n `moveToEnd` \u2014 the first two take a **layer**, never an index, and the two\n directions need different primitives: moving up, `moveBefore` lands on the\n target; moving down the target shifts up as the layer leaves, so `moveAfter`\n is the one that lands on it. *The tool already does:* `reorder_layer`.\n- **Assigning a track matte switches the matte layer\'s video off.** That is\n After Effects\' own default, and `setTrackMatte` and `trackMatteType` both\n do it. A layer that is both a matte and visible art gets `enabled = true`\n again after every call that points a matte at it; read the switch back\n rather than assuming.\n- **`replaceSource` keeps the layer\'s old timing.** `startTime`, `inPoint`\n and `outPoint` stay where the previous source put them, so a longer or\n shorter file arrives trimmed to the old one. After the swap write\n `startTime`, then `inPoint`, then `outPoint`, and read them back; on a\n time-remapped layer turn `timeRemapEnabled` off before the swap and on\n again after it, so the default keys are rebuilt for the new duration.\n- **Removing a comp removes every layer that nests it**, in every comp,\n without a word. The `assembly` topic has the replacement flow.\n- The layer stack builds back-to-front \u2014 every `layers.add*()` lands at\n index 1, on top \u2014 which the `shapes` topic states beside the `Contents`\n order it is the opposite of.\n\n## Effects\n\n- **Effects are addressed by display name, and the name is not unique.**\n `effects.property("Slider Control")` returns the *first* effect with that\n name, so a second slider added under the default name is unreachable by\n name and a write meant for it silently drives the first. Name each control\n as you add it (`fx.name = "Zoom"`), or remove a same-named one first; from\n outside, `list_effects` reports every effect\'s name and index and\n `set_effect_param` takes the index.\n- **Adding an effect invalidates every effect reference held on that layer**,\n a second Slider Control included: the group re-indexes and an earlier\n handle throws `Object is invalid`, the way a shape node reference goes\n stale when a sibling is added. Add every effect first, then resolve each by\n name and set its parameters.\n\n## Shapes\n\nRender order inside `Contents`, references that go stale when a sibling is\nadded, and the coordinate space of a new shape layer bite the tool path as much\nas a script, so they are in the `shapes` topic. The raw-scripting traps:\n\n- **`comp.layers.addShape()` spawns at the comp centre** with the anchor at\n `(0,0)`, so contents authored in comp coordinates land offset by half a frame.\n Zero the position before adding anything to `Contents` \u2014 the `shape()` helper\n does.\n- **Polystar type is `1 = Star`, `2 = Polygon`** on `ADBE Vector Star Type`.\n Type 2 *hides* Inner Radius, so setting it after choosing polygon throws\n `property is hidden`. A gear is type 1.\n- **Stroke dashes do not take.** `addProperty("ADBE Vector Stroke Dash 1")`\n followed by `setValue` throws the same hidden-property error. A dashed band\n that does work: a small square plus a Repeater (`ADBE Vector Repeater\n Transform` \u2192 `ADBE Vector Repeater Position` set to `[2 * side, 0]`) in a\n group in front, over a plain rect in a group behind.\n\n## Text\n\n- **Do not write `justification` into a `TextDocument` from a script.** On\n 26.3, `d.justification = ParagraphJustification.CENTER_JUSTIFY;\n prop.setValue(d)` on a layer made with `addText()` lands as RIGHT (`7414`):\n the enum reads correctly in that scope (`CENTER_JUSTIFY` is `7415`, LEFT\n `7413`), the setter stores the wrong value, and LEFT only appears to work\n because it is `addText()`\'s default. Set font, size, colour and tracking\n through the `TextDocument` if you like, then set justification through the\n tool \u2014 `OPS.set_text({compId, layerId, justification: "center"})` inside the\n script, or `set_text` / `create_text_layer`\'s `anchorAlign` from outside \u2014\n and verify with `get_layer_full({\u2026, include: ["bounds"]})`: a centred layer\'s\n `sourceRect.left` is about `-width / 2`, a left-justified one\'s about `0`.\n The tool path gets a check a script does not: `set_text` and\n `create_text_layer` read the justification back after the write, re-assert it\n once if it moved, and throw naming expected and actual if it still disagrees,\n so the `justification` they return is what the layer shows.\n\n## Keyframes and easing\n\n- **`setTemporalEaseAtKey` wants one `KeyframeEase` per ease dimension, and\n that count belongs to the property, not to its value.** Measured on 26.3:\n Opacity and sliders take 1; a 2D layer\'s **Scale takes 3** while its value\n reads `[x, y]`; a shape\'s **Ellipse Size takes 2** while its value reads\n `[w, h]`; Position and Anchor Point take 1 whether the layer is 2D or 3D,\n because the ease runs along the motion path. Each of those is the opposite of\n the sensible guess \u2014 and one session on 26.3 had a 2D layer\'s Position\n accept only **3** through raw `setTemporalEaseAtKey`, a single observation\n not yet reproduced. The point is that the count is not derivable, from the\n value\'s length or from this list. The wrong one throws `Value array does not\n have N elements` about `parameter 2` and aborts the script there. So never\n size it by hand: `ease(prop, keyIndex, easeIn, easeOut)` is in scope and is\n the same code `set_temporal_ease` runs \u2014 it walks 1, 2, 3, 4 until one takes\n and returns the count that worked; the tools report the same number as\n `easeDimensions`. A bare number means influence; omitting `easeOut` uses the\n same ease both sides. And **put eases after every geometry and key write in\n the script**, so an ease that still fails loses nothing above it.\n- **The key lookup is `nearestKeyIndex(t)`**, not `nearestKeyAtTime`.\n- **Removing the last Time Remap key hides the property.** After\n `while (tr.numKeys) tr.removeKey(1)` the next `setValueAtTime` (or\n `addKeys`) throws `Can not "set value at time" with this property, because\n the property or a parent property is hidden`, the script dies there, and the\n layer is left remap-enabled with no keys. Setting `timeRemapEnabled = true`\n already creates the two keys a loop needs \u2014 `[inPoint, 0]` and\n `[inPoint + source duration, source duration]` \u2014 so there is no reason to\n clear them: edit those keys in place, or put an expression over them \u2014\n `(time - startTime) % thisLayer.source.duration` loops \u2014 which overrides\n them without touching them. If your keys must sit at other times, add them\n **before** removing the two defaults: only a property with no keys at all\n hides. And re-assert `outPoint` after `timeRemapEnabled = true`, because\n enabling it resets the layer\'s end \u2014 on a remapped layer set `outPoint`\n before `inPoint` and read both back, since an in point past the source\'s\n natural end moves the layer instead of trimming it until the out point has\n let it extend. If the keys are already gone, toggle `timeRemapEnabled` off\n and on to get the defaults back. *The tool already does:* `place_audio_cues`\n with `loop: true`.\n\n## Render queue and output modules\n\n- **`OutputModule.setSettings` rejects Format, Channels, Depth and Color as\n read-only** \u2014 `Invalid Value for key: <Depth>. Property is read-only`, and\n likewise `<Channels>`, `<Format>`, `<Color>` \u2014 although\n `getSettings(GetSettingsFormat.SPEC)` lists full enum tables for all four.\n SPEC reports a setting\'s domain, not whether it is writable;\n `GetSettingsFormat.STRING_SETTABLE` is the honest list, and it omits all four.\n Measured on 26.3 from four starting states (after `applyTemplate` of a ProRes\n template, after the stock alpha templates, after a TIFF sequence, and on a\n fresh output module setting Format alone), so it is not codec gating. "Video\n Codec" is not in SPEC at all; it lives behind the QuickTime Format Options\n dialog, which has no scripting surface.\n- **So codec, bit depth and alpha cannot be set from a script.** A person builds\n the output module once in the UI \u2014 Edit > Templates > Output Module > New, or\n Make Template from the Render Queue\'s Output Module menu \u2014 under a known name,\n and scripts (`om.applyTemplate(name)`) and `aerender -OMtemplate "<name>"`\n apply it by name. Templates live in AE\'s preferences, so one creation covers\n every project on the machine. Before trusting a template, apply it and read\n `getSettings(GetSettingsFormat.STRING)` back: an alpha-incapable codec such as\n ProRes 422 HQ reports Channels `RGB` and will not be talked out of it.\n- **A failed `setSettings` throws before any cleanup line runs**, so a temporary\n render-queue item added earlier in the script stays in the user\'s queue. Add\n temporary items inside `try { \u2026 } finally { item.remove(); }`, or sweep the\n queue at the top of the next call.\n\n## Undo groups\n\n- **An undo group does not survive a script boundary.** `beginUndoGroup` in one\n `evalScript` call and `endUndoGroup` in another produces no group at all \u2014\n After Effects discards it, `endUndoGroup()` returns exactly as it does on\n success, and the only place the truth is visible is AE\'s Edit menu. Anything\n spanning calls opens and closes its own group per call. *The tool already\n does:* `run_batch` reports the measured `undoSteps`.\n- **`copyToComp` needs the group closed**, which is the opposite problem \u2014 see\n "Layers and comps".\n\n## The language itself\n\nExtendScript reserves words JavaScript does not: **`short`, `int`, `char`,\n`byte`, `long`, `float`, `double`, `boolean`**. `var short = \u2026` fails with\n`Illegal use of reserved word` and **nothing in the script runs** \u2014 every side\neffect you expected is simply absent, which reads exactly like a bridge failure.\n`s`, `n`, `count`, `flag` cost nothing.\n\nThe rest of the dialect is ES3: no `let`/`const`, no arrow functions, no\ntemplate literals, no `Object.keys`, no destructuring, no trailing commas.\n\n## Reading the result, and a failure\n\n`return X` sends the whole value back \u2014 arrays and nested objects included.\nValues that cannot be represented come back as a marker string in place, never\ndropped: a live AE object as `"[AVLayer \\"Hero\\" #616]"`, which is a handle to\npass to `get_layer_full`, not a copy of the layer. An empty result therefore\nmeans the script returned nothing.\n\n**A bare expression is not a return.** `"ping";` as the last line yields\nnothing, and so does any script that just does its work. That case comes back\nas `{ok: true, returned: null, undoGroup, note}` \u2014 an envelope that says *the\nscript ran to completion*. Do not re-run it; add an explicit `return` if you\nwant a value.\n\n**On a failure, read the error before you touch anything.** It names the line\nof *your* script and prints that line\'s text; a line inside a library is\nattributed to that file; and when the number cannot be mapped it says so rather\nthan guessing. Everything above that line already ran and nothing rolls back,\nso find out what landed \u2014 `diff: true` appends it to the error; otherwise\n`diff_comp`, `get_comp_tree` or `find_layers` \u2014 and never re-run the script to\nsee whether it fails again: the lines above the failure apply a second time.'
|
|
3303
|
+
},
|
|
3304
|
+
{
|
|
3305
|
+
name: "issue-journal",
|
|
3306
|
+
description: 'What to do when a tool fights back \u2014 the journal entries a failed call brings you, when a workaround is worth writing down with log_issue and which scope it belongs in, retiring an entry that is no longer true, and how to offer the user the chance to pass a problem on without ever saying "GitHub issue". Load when a call has failed in a way you had to work around.',
|
|
3307
|
+
body: '# When something costs you real time\n\nThese tools have rough edges, and the same ones catch every session. The\njournal exists so that each one is only paid for once \u2014 and it comes to you,\nrather than you going to it.\n\n## A failure brings its own history\n\n**You do not have to read the journal before you start.** When a call fails,\nthe error itself ends with `Known from earlier sessions: <scope:id> \u2014 <title>`\nfor anything that matches its tool and its error text \u2014 up to three, most\nrecently seen first, the overflow counted \u2014 and names the\n`list_known_issues({id})` call that opens the first. The cause and the\nworkaround are in the entry, not in the index. A match is also a sighting: the\nentry\'s `lastSeen` and `lastVersion` move, so an entry that keeps biting stays\nlive.\n\nReach for the listing when you are planning something that has bitten before\n(`tool` or `query` narrows it) or when the user asks what is known. It is an\nindex \u2014 id, scope, title, tools, `kind`, `lastSeen`, `lastVersion`, counts \u2014\nwith the entries naming the tool first and then the most recently seen first,\nand a `next` pointer spelling out the call that opens the top one. Archived\nentries \u2014 unseen for 30 days, a `tool-bug` last seen on an older server than\nthe one running, or retired with `archive_issue` \u2014 are hidden and counted in\n`archivedCount`; `includeArchived: true` shows them, and a read by `id` always\nworks, archived or not, because a failure\'s pointer can name one (marked\narchived, with the reason).\n\nThere are two journals and every entry says which it came from. `project` is\nthis project\'s own notes; `user` travels with the person across every project.\nIds are only unique within a journal, so open an entry with the qualified form\nthe listing\'s `next` pointer shows you \u2014 `list_known_issues({id: "user:\u2026"})`.\n\n## Write it down the moment you work it out\n\n**`log_issue`** records what you learned. Log something when all three are\ntrue: it cost real effort, it was the tool\'s fault rather than yours, and the\nnext session would hit it too. A schema that accepts an argument AE then\nrejects, an error message that names the wrong thing, a property whose real\nname is nothing like its display name. Not your own typos. Not "I forgot the\nlayer was 3D".\n\nWrite the entry for someone who has not seen the failure: the call that\nproduced it, the exact error text, and a workaround concrete enough to apply\ndirectly. **Pass `tools` and the exact `errorText`.** That is what lets the\nnext failure be answered with your entry, and it is what folds a re-log of the\nsame error into the existing entry even if you gave it a different title \u2014 the\nresult says `mergedBy: "errorText"` and keeps the existing title. Logging under\nan existing title extends that entry too, so if the failure brought you an\nentry, extend it rather than writing a twin. Use `kind: "ae-quirk"` for a\npermanent After Effects behaviour, so a new release does not archive it as\nfixed; the default `tool-bug` is presumed fixed once it was last seen on an\nolder server than the one running. Every entry records the server version it\nwas first and last seen on. If the result says `reopened: true`, a problem\nthat was supposed to be gone is back \u2014 tell the user.\n\n**Pick the scope by what the entry is about, not by where you are.** Leave it\nat the default `project` for this project\'s footage, comps or files. Pass\n`scope: "user"` when it is about how these tools or After Effects behave \u2014 that\nis nearly everything worth logging, and it is the difference between the next\nproject starting out knowing it and re-learning it.\n\n## Retire what is no longer true\n\nAn entry has to be able to leave, and most leave on their own: one unseen for\n30 days, or a `tool-bug` last seen on an older server than the one running, is\narchived without anyone deciding \u2014 and comes back the moment a failure matches\nit or a `log_issue` lands on it. `archive_issue({id, reason})` is for a\ndecision: a report already covers the problem (pass its URL as the reason), a\nrelease fixed it, or the lesson has been promoted into the\n`extendscript-gotchas` topic or the project\'s own docs. An archived entry stays\non disk, leaves the index, and is still named by a matching failure \u2014 marked\narchived, with the reason \u2014 so the pointer leads somewhere; only a deliberate\n`log_issue` reopens it. A journal that only grows is one nobody reads.\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 checks\nthe tracker for a report that already covers the problem before drafting one,\narchiving the entry when that report is closed and marking it reported when it\nis open, and handles the rest.\nIf they say no, drop it; the note stays and can be offered again another time.\n\nNever claim you have reported something you have not.'
|
|
3308
|
+
},
|
|
3309
|
+
{
|
|
3310
|
+
name: "mogrt-and-footage",
|
|
3311
|
+
description: "Exporting a Motion Graphics template, importing footage and keeping the project bin clean through the AE MCP tools \u2014 export_mogrt and the preconditions it checks before touching the project, the thumbnail, the modal dialogs that freeze this connection, the SVG whose viewBox makes After Effects fabricate dimensions and render nothing, and the solids a deleted comp leaves behind. Load when a task exports a .mogrt, imports a file, or deletes comps.",
|
|
3312
|
+
body: "# Motion Graphics templates and footage\n\n## Exporting a Motion Graphics template\n\nUse **`export_mogrt`**. Do not drive `comp.exportAsMotionGraphicsTemplate`\nfrom `run_jsx`: that call raises modal dialogs, and a modal dialog freezes this\nwhole connection until someone clicks it in After Effects \u2014 but suppressing\nthem, which is what you have to do, costs you the only channel AE has for saying\nwhy an export failed. It answers with a bare boolean and nothing else. So the\ntool checks every precondition it can *before* exporting, which is the half of\nthe job a script cannot do.\n\n`export_mogrt` saves the project first (which removes AE's \"the project needs to\nbe saved\" prompt, and has to happen per export because exporting dirties the\nproject again), suppresses the font warning, and runs outside the undo group so\nthere is no \"undo group mismatch\" afterwards. Measured on 26.3: suppressed, an\nexport of a comp using a non-Adobe font returns in about three seconds;\nunsuppressed, the same export blocks past sixty and writes nothing until the\ndialog is clicked.\n\nFour things to know before you call it. The first is the one that actually\nstops exports:\n\n- **The comp needs at least one property in its Essential Graphics panel.**\n After Effects will not build a template from an empty one, and it refuses\n silently \u2014 no file, no dialog, not a word. The tool checks the controller\n count first and refuses before touching anything. The fix is in AE and the\n user has to do it: Window > Essential Graphics, pick the comp, drag a layer\n property in.\n- **The project must have been saved once, by hand.** There is no folder to\n save into otherwise, and the tool refuses rather than raising a dialog the\n user was not expecting.\n- **`name` is the filename.** It defaults to the comp name, because AE's own\n default is the literal `Untitled` \u2014 leave it to AE and every template in the\n project overwrites the same file. `overwrite: true` is required to replace an\n existing one.\n- **`fonts` in the result lists what the template will require.** Tell the user\n about any non-Adobe ones: Premiere flags the template as needing fonts it\n cannot supply, and that is worth hearing from you rather than discovering\n later.\n\n**If an export fails anyway, read the message rather than guessing.** It lists\nwhat was checked and ruled out, and it only names a modal dialog when dialogs\nwere left *unsuppressed*. Under the default suppression a dialog is impossible\nby construction, so the cause is genuinely unknown \u2014 say so, and tell the user\nthe one place AE's own reason exists: exporting the same comp by hand from the\nEssential Graphics panel, where AE shows its error in the interface. Do not\nsend them looking for a dialog that cannot be there.\n\n**The thumbnail.** AE writes the comp's *first frame* into the template, so\nanything that fades up from nothing gets a black one. Pass `posterTime` with a\nmoment that actually shows the design and it is rendered and swapped in. If\nonly the thumbnail fails the export still succeeds \u2014 check `thumbnail.patched`\nin the result.\n\n`comp.setMotionGraphicsControllerName(index, \u2026)` numbers controllers in\n**reverse order of addition**: index 1 is the one added last.\n\n**If any long call seems to have hung, assume a dialog before you assume a\ncrash** \u2014 it may be behind another window. `comp.saveFrameToPng(...)` from\n`run_jsx` raises the save prompt the same way; use `screenshot_frame`, which\ndoes not.\n\n## Importing footage, and the SVG trap\n\nUse **`import_footage`**, then **`create_footage_layer`** to place the item in\na comp. (For a comp as a layer, `create_precomp_layer`.)\n\n`import_footage` checks what AE actually produced, because one case fails\nsilently: an SVG with a very large `viewBox` (say `0 0 278050 333334`) imports\nwith **fabricated dimensions and renders as nothing**, no error at any stage.\nMeasured on 26.3: that viewBox yields a 15906\xD75654 item that will not even\nrasterize, and the numbers depend only on the viewBox. The tool compares the\naspect ratio the file asks for against the one AE produced, and on a mismatch it\ndeletes the item and throws, rather than handing you an asset that looks healthy\nin 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\n vertices, scaled down to a sane coordinate space (divide by `333.334` for a\n 1000px version), set the fill from the SVG, and set `ADBE Vector Fill Rule`\n to `2` when the SVG says `fill-rule=\"evenodd\"`. Done this way the result is\n pixel-accurate.\n- **Complex SVGs** \u2014 rasterise to PNG outside AE, or normalise the `viewBox` to\n a small coordinate space before importing.\n\n`force: true` keeps the item and reports the problem in `validation` instead of\nthrowing. It is for when you know the dimensions are wrong and want it anyway \u2014\nnot a way past the error.\n\n## Solids outlive their comps\n\nA solid or adjustment layer's source is a footage item in the project's Solids\nfolder, and `delete_comp` and `delete_layer` remove the layer, not the item \u2014\nso iterating on a rig by building and deleting comps silently fills the bin.\n`delete_comp` counts what it left in `unusedSolidsLeft`, with a note when that\nis not zero. Pass `purgeUnusedSolids: true` to take the comp's own now-unused\nsolids with it in the same undo step, reported as `removedSolids` and\n`keptSolids` \u2014 a solid another comp still uses is kept, with that comp named\nunder `usedIn`. Only the deleted comp's own solids are ever considered there;\n`purge_unused_footage` sweeps the whole project: solids only by default,\n`solidsOnly: false` for every unused footage item (what AE's own Remove Unused\nFootage does), `folderId` to scope it to one folder, and `dryRun: true` \u2014\nfirst, on a project you did not build \u2014 which answers `wouldRemove` without\nremoving anything or adding an undo step. Neither ever removes a solid another\ncomp still uses, or a nested comp: a comp nothing uses is not footage. A comp\nanother comp *does* nest is the opposite case \u2014 deleting it takes the nesting\nlayers with it \u2014 and the `assembly` topic has the replacement flow."
|
|
3313
|
+
},
|
|
3314
|
+
{
|
|
3315
|
+
name: "shapes",
|
|
3316
|
+
description: "Shape layers through the AE MCP tools \u2014 the origin a new shape layer spawns at and why it is the comp's coordinate space, add_shape_content and its all-or-nothing contract, one fill per group, the render order inside Contents that runs opposite to the layer stack (which builds back-to-front), node references that go stale, a group's pivot, seams between shapes, centring a repeated run, and reading a shape layer cheaply. Load when a task builds or edits a shape layer.",
|
|
3317
|
+
body: '# Shape layers\n\n## Where a new shape layer\'s origin is\n\n`create_shape_layer` puts the new layer\'s origin at `[0,0]` with its anchor at\n`[0,0]`, so **the layer\'s coordinate space is the comp\'s**: every vertex, rect\nposition and path you add afterwards is in comp pixels, the same space as the\ncomp size, another layer\'s position, `sourceRect` and a screenshot. After\nEffects\' own spawn point is the comp centre, which silently offsets a drawing\nauthored in comp coordinates by half a frame; pass `position: "center"` if you\nwant that back, or any `[x, y]` to place the origin yourself. The result echoes\nthe position and anchor it ended up with, so nobody has to render a frame to\nlearn the coordinate system.\n\n## Adding contents\n\n`add_shape_content` builds one node at a time under `Contents` \u2014 `rect`,\n`ellipse`, `star`, `path`, `fill`, `stroke`, `trim`, `repeater`, `merge`,\n`group`. Properties are set with friendly names in the same call (`size`,\n`position`, `roundness`, `color`, `width`, `lineCap`, \u2026). For a custom path,\n`{type: "path", vertices: [[x, y], \u2026], closed: true}` \u2014 the key is `vertices`,\nnot `points`. `set_shape_path` replaces a path\'s vertices and tangents later;\n`set_shape_property` sets one property on a node, optionally as a keyframe.\n\nThe tool is **all-or-nothing**: if a key cannot be applied, the whole node is\nremoved and the error names the bad keys, so a success result means everything\nlanded. Do not add defensive re-reads for it \u2014 but do read the error, since it\nusually means the property is named differently on that node type, and a\n`get_layer_full({\u2026, include: ["shape"]})` shows the real name.\n\n**A fill or stroke applies to every path above it in its group.** One Fill\nunder two paths fills both, so an open stroked path beside a filled shape in\nthe same group renders as a filled polygon. One group per path-and-paint\npairing: `{type: "group"}` first, then the path and its fill or stroke inside\nit through `parentGroupPath`.\n\n**Render order is the opposite of the layer stack.** Inside `Contents`, index 1\nrenders in *front*, and each `add_shape_content` call appends behind the\nprevious one. So build **front-to-back**: details, text plates and\ntraffic-light dots first, the big background rectangle last. Getting it\nbackwards is silent \u2014 no error, just a solid slab where the artwork should be.\n`zOrder: "front"` places a node at index 1 for you, but it needs an internal\n`moveTo`, which disturbs *nested* renders of the comp on AE 26.3; prefer\nordering the calls. If existing content is in the wrong order, rebuild it rather\nthan reordering, and verify with a screenshot of a comp that **nests** it, not\nonly the comp that owns it. None of this applies to the layer stack: moving\nwhole layers is `reorder_layer`, a different mechanism with none of these\ncaveats. The stack runs the other way: every new layer \u2014 `create_*`,\n`duplicate_layer`, a `layers.add*()` in a script \u2014 lands at index 1, on top,\nso a stack is built **back-to-front**, the plate first and the label after,\nand a script re-run from the middle re-stacks whatever it recreates over what\nwas already there.\n\n**Node references go stale.** Adding a sibling to a group invalidates a\nreference you already hold to another node in it \u2014 add a Stroke and an earlier\nFill reference starts throwing `Object is invalid`. Add every node first, then\nset values and expressions by addressing nodes by name. The tool re-fetches\nafter every insert for the same reason.\n\n## Animating a group\n\nA group drawn in comp coordinates pivots at the comp origin: its Transform\'s\nanchor point is `[0,0]`, which with the layer\'s origin at `[0,0]` is the comp\'s\ntop-left corner, so a scale or rotation keyed on the group swings the artwork\naround that corner. Before keying a group, set its anchor point to the pivot\nyou mean *and* its position to the same value \u2014 `set_shape_property` with\n`contentPath: ["Contents", "<group>", "Transform"]` and `property: "Anchor\nPoint"`, then `"Position"` \u2014 which leaves the artwork where it is and turns it\nabout the pivot.\n\n## Seams and runs\n\n**Two shapes that meet on a shared edge show the seam when they move.**\nAnti-aliasing leaves both edges partly transparent, so a hairline of whatever\nis behind flickers along the join under a camera move, worst over a dark\nground. Overlap the back shape 10\u201320 px under the front one rather than\nbutting them.\n\n**Centring a repeated run** \u2014 a Repeater tape, a dashed band \u2014 of copies `s`\nwide at pitch `p` across a width `W` starting at `left`:\n`copies = floor((W \u2212 s) / p) + 1`, and the first copy\'s centre is\n`left + (W \u2212 s \u2212 (copies \u2212 1)\xB7p) / 2 + s/2`. With that `copies` on the\nRepeater and that centre as the first element\'s position the run is\nsymmetrical whatever `W` is; dashes with equal gaps are `p = 2s`.\n\n## Reading a shape layer\n\nUse `shapeDetail: "compact"` on `get_layer_full`. It returns one indented line\nper group \u2014 the group\'s name, its matchName, then its own properties as\n`name=value`, with `[3 keys]` or `[expr]` on the animated ones and\n`(at defaults)` for a group Transform nobody has touched. Every name the write\ntools address a node by is still on the line, at a fraction of the cost of the\nJSON form. Reach for `"full"` when you need exact values, keyframe detail or\nindices, and `shapeDepth` to stop the walk at a depth on a heavy layer.\n\nOne thing is left out of both forms: **Material Options**, the 48-property 3D\nextrusion block AE hangs off every vector group. It only means anything for an\nextruded shape under the Cinema 4D renderer, and on a 2D shape layer it is most\nof the weight of the read \u2014 a single 68px circle costs 4,400 tokens with it, of\nwhich the geometry is about 40. `materialsOmitted` counts what was skipped;\n`shapeMaterials: true` brings it back.\n\n## Scripting shapes by hand\n\nThe raw-scripting traps \u2014 the spawn point of `addShape()`, polystar types that\nhide their own properties, stroke dashes that refuse to take \u2014 are in the\n`extendscript-gotchas` topic, along with the `shape()` helper that lands a\nscripted layer at `[0,0]`.'
|
|
3318
|
+
},
|
|
3319
|
+
{
|
|
3320
|
+
name: "sound",
|
|
3321
|
+
description: "Sound effects and beds through the AE MCP tools \u2014 place_audio_cues for a whole cue list in one undo step, what levelDb actually means and why a level copied from another cue is meaningless, peak for a one-shot and RMS for a bed with the onset on the beat, loops, fades and stretch per cue, a trim clamped at the file's end, a bed that follows a move, the dry run, a file that will not place, and the audio facts a hand-written loop gets wrong. Load when a task places, levels or times sound.",
|
|
3322
|
+
body: "# Sound\n\n`place_audio_cues` scores a scene in one call: a list of cues \u2014 each a file (or\nan already-imported `footageId`), a comp `time` and a `levelDb` \u2014 becomes one\naudio layer each, imported once however many cues name the same file, named,\ntrimmed, labelled, in a single undo step. Reach for it the moment you are\nplacing more than two or three sounds; the alternative is dozens of round trips\nor a `run_jsx` loop that has to know that `layer.property(\"ADBE Audio Levels\")`\nreturns null on an audio layer.\n\nIt is **all-or-nothing**: every cue is checked (the file exists, the item has\nan audio track, the time is inside the comp) before a single layer is made, and\nif a later one still fails, everything the call created is removed and the\nerror names the cue by index. `dryRun: true` checks a list against the project\nwithout importing, creating, or even adding an undo step, and answers with\ncounts, `wouldImport`, `wouldReuse` and the failing cues by index \u2014 never the\nresolved list. Each cue names its sound with exactly one of `path` or\n`footageId`; `inPoint` and `outPoint` trim in **comp** time, not file time. An\n`outPoint` past the file's placed length \u2014 its duration times `stretch` \u2014 is\nclamped to it without a word, so read the out point back from the result; a\nsound that must run longer is `loop: true`, not a longer trim.\n\n## Levels\n\n`levelDb` is decibels, After Effects' own unit: **`0` is the file as recorded**,\nnegative is quieter, and it is written explicitly even when you omit it. Two\nthings follow:\n\n- **A level copied from another cue is meaningless.** Files in one sound bank\n routinely differ by 30 dB as recorded \u2014 a whoosh mastered hot and a room tone\n mastered quiet \u2014 so `-6` on one is nothing like `-6` on the other. Measure\n before you set: peak and RMS of the file (`ffmpeg -af volumedetect`, `sox\n stat`, or whatever the shell has; where there is no shell, ask the user what\n the file sounds like against the others), then choose a level that brings it\n to where you want it, not a number that looked right last time.\n- **Read the level back off the layer.** The result carries one small entry\n per cue \u2014 `layerId`, `name`, `time`, and what an option changed \u2014 and does\n not echo the level; `run_jsx` with\n `return layerById(compId, layerId).audioLevels.value` reads it. Ask the user\n to preview if they can \u2014 a level is the one property in this toolset that no\n screenshot can check.\n- **Match a one-shot by peak and a bed by RMS.** A hit is heard at its peak\n and a bed at its average, so a bed levelled by peak sits too low and a hit\n levelled by RMS jumps out. And put the file's *onset* on the beat, not its\n start: a one-shot with silence before the hit goes at `time: beat \u2212 onset`\n with `inPoint: beat` to trim the silence, which is also how a hit is\n shortened under a visual shorter than the file.\n\n## Loops, fades and stretch\n\nThree per-cue options cover what a scoring pass otherwise redoes in `run_jsx`,\nand each moves something After Effects then quietly resets \u2014 which is the\nreason to take them from the tool rather than script them:\n\n- **`loop: true`** repeats the sound to the cue's `outPoint`, or to the end of\n the comp when there is none \u2014 beds and ambiences. It enables time remapping\n with a wrap-around expression on Time Remap, keeps the two keyframes AE\n creates (removing them hides the property), and re-asserts the out point,\n because enabling remapping resets it. `inPoint` still trims the front of the\n file the way it does on any layer.\n- **`fadeIn` / `fadeOut`**, in seconds, keyframe Audio Levels at the in and\n out point the layer actually has, between the call-level `fadeFloorDb`\n (default `-48`, the bottom of AE's own slider) and the cue's `levelDb`. A\n fade that does not fit the cue's placed length, or a floor at or above the\n cue's level, is refused by cue index with nothing placed; for a file not yet\n imported the fit is checked right after the import, before any layer exists.\n The result carries `fadeFloorDb` when any cue faded.\n- **`stretch`** is a percentage: `100` unchanged, `200` half speed at a lower\n pitch. The start time and any trims are re-asserted after it, because AE\n moves them when stretch changes. A loop that is also stretched has the factor\n baked into its expression, so changing the stretch by hand afterwards leaves\n the loop wrapping at the old rate \u2014 set it here, or place the cue again.\n\nEvery option is validated with the rest of the list before anything is\ncreated, and the result names what an option changed \u2014 `looped`, `stretch`,\n`fadeIn`, `fadeOut`, and the in and out point read back \u2014 only on the cues it\napplied to.\n\n## Timing\n\nCue times are comp times, and a cue placed in a shot comp is at that comp's\nlocal time, so a cue list for a master and a cue list for a shot are different\nlists \u2014 put a sound where the thing that makes it lives. Comp markers at the\nbeats (the `assembly` topic) are the right skeleton for a cue list, and a cue's\n`name` is worth setting to the beat it belongs to.\n\nA sound that belongs to a move follows the move. Under an eased camera, a bed\nrun at a constant rate and level comes apart from the picture at every ease;\ndrive the bed's Time Remap from the distance the camera has travelled and its\nlevel from the camera's `speed` (expressions on the audio layer reading the\nnull's position), so it slows, stops and rises with the move.\n\n## A file that will not place\n\nA file that imports with no audio track, or refuses to import at all, is worth\nchecking for its sample format before anything else: a 32-bit float WAV\n(format tag 3) is the usual case. Convert it to 16-bit PCM \u2014 `afconvert -f\nWAVE -d LEI16 in.wav out.wav` on macOS, `ffmpeg -i in.wav -c:a pcm_s16le\nout.wav` anywhere \u2014 and keep a sound bank at 16-bit PCM so it never comes up.\n\n## Scripting sound by hand\n\nThe raw-scripting facts \u2014 `layer.audioLevels` rather than a property lookup,\n`layer.timeRemap` for a loop, and the Time Remap keys that must not be cleared\n\u2014 are in the `extendscript-gotchas` topic."
|
|
1942
3323
|
},
|
|
1943
3324
|
{
|
|
1944
3325
|
name: "style-guide",
|
|
1945
3326
|
description: "Help a motion designer capture their house style \u2014 palette, type, motion and layout \u2014 into the house-style.md file that sits next to their After Effects project and shapes everything built afterwards. Load when the user asks to create, edit or review their style guide, when they say work does not look like theirs, or when get_house_style reports none exists.",
|
|
1946
|
-
body: '# Capturing a house style\n\nA house style is the difference between an assistant that builds *a* lower third\nand one that builds *their* lower third. It lives in `house-style.md` beside the\n`.aep` file, and `get_house_style` reads it before any build task.\n\nYour job here is to get one written with as little effort from the user as\npossible. They are a motion designer. They know exactly what their work looks\nlike and will struggle to dictate it as a specification \u2014 so do not ask them to.\n\n## Two ways in. Prefer the first.\n\n### 1. Read it off work they already like\n\nThis is far better than any questionnaire, because it produces real numbers\ninstead of adjectives.\n\n1. Ask which comp to learn from \u2014 "point me at something that looks the way you\n want everything to look."\n2. `get_comp` for size and frame rate, then `get_layer_full` on the layers that\n carry the look: the text, the background, the accent shapes.\n3. Pull out the concrete values \u2014 hex colours, font families and sizes, tracking,\n corner radii, stroke widths, the position of things relative to the frame.\n4. Read the keyframes too. `get_keyframes` plus the ease settings tell you the\n timing signature: how long a standard in-animation takes, whether it\n overshoots, whether anything is ever linear.\n5. Show them what you found, in their language, and ask what to change:\n\n > Here\'s what I read off that comp: near-black background `#0B0D12`, white\n > text in Inter Semibold at 64px with slightly tight tracking, one green\n > accent `#3DC46E`. Things scale in over about 0.4s with an overshoot to 108%\n > and easy ease on both ends. Nothing sits perfectly still \u2014 there\'s a slow\n > wiggle on the chip. Does that sound like your style, or was that comp a\n > one-off?\n\n6. Write it with `set_house_style`.\n\n### 2. Ask, when there is nothing to read\n\nOnly if the project is empty or they have no reference. Keep it to four\nquestions, and offer concrete options rather than open ones \u2014 "dark or light\nbackground?" beats "what\'s your palette?". Then build one small example, show it\nwith `screenshot_frame`, and refine from their reaction. Reacting is easier than\nspecifying.\n\n## What makes a guide that actually works\n\n**Numbers, not adjectives.** `#131521 at 92% opacity` is usable. "Dark and clean"\nis not. If a corner radius, a stroke width or a hold duration matters, write the\nnumber. Anything vague will be silently reinterpreted every time it is read.\n\n**Rules, not just values.** The most valuable lines are the prohibitions: "never\nput text directly on footage \u2014 always on a rounded chip", "keep total runtime\nunder 8 seconds", "no linear motion unless something mechanical is moving".\nThose are what stop work drifting.\n\n**Only what you verified.** Do not pad the file with plausible-sounding defaults\nthey never asked for. A short guide that is true beats a complete one that is\nhalf invented. Leave a heading empty rather than filling it with a guess.\n\n## Keep it current\n\nWhen the user corrects the same thing twice \u2014 "no, the accent green, not the\nblue" \u2014 that is a missing rule, not a one-off. Offer to add it:\n\n> I\'ve had to switch that green twice now. Want me to put it in the style guide\n> so it\'s the default from here?\n\nRead the existing guide with `get_house_style` before writing, and preserve what\nis already there. `set_house_style` replaces the whole file, so send back the\nfull document, not just your additions.\n\n## The one thing to warn them about\n\n`house-style.md` is written next to the `.aep`, so **the project has to have been\nsaved at least once** \u2014 an unsaved project has no folder to write into, and\n`get_house_style` will say so. If that happens, ask them to save the project\nfirst, then write the guide.\n\nThe file is plain markdown. Tell them where it is and that they can edit it in\nany text editor without going through you.\n\n## Starting point\n\nWhen writing a guide from scratch, this is the shape to fill in. Drop headings
|
|
3327
|
+
body: '# Capturing a house style\n\nA house style is the difference between an assistant that builds *a* lower third\nand one that builds *their* lower third. It lives in `house-style.md` beside the\n`.aep` file, and `get_house_style` reads it before any build task.\n\nYour job here is to get one written with as little effort from the user as\npossible. They are a motion designer. They know exactly what their work looks\nlike and will struggle to dictate it as a specification \u2014 so do not ask them to.\n\n## Two ways in. Prefer the first.\n\n### 1. Read it off work they already like\n\nThis is far better than any questionnaire, because it produces real numbers\ninstead of adjectives.\n\n1. Ask which comp to learn from \u2014 "point me at something that looks the way you\n want everything to look."\n2. `get_comp` for size and frame rate, then `get_layer_full` on the layers that\n carry the look: the text, the background, the accent shapes.\n3. Pull out the concrete values \u2014 hex colours, font families and sizes, tracking,\n corner radii, stroke widths, the position of things relative to the frame.\n4. Read the keyframes too. `get_keyframes` plus the ease settings tell you the\n timing signature: how long a standard in-animation takes, whether it\n overshoots, whether anything is ever linear.\n5. Show them what you found, in their language, and ask what to change:\n\n > Here\'s what I read off that comp: near-black background `#0B0D12`, white\n > text in Inter Semibold at 64px with slightly tight tracking, one green\n > accent `#3DC46E`. Things scale in over about 0.4s with an overshoot to 108%\n > and easy ease on both ends. Nothing sits perfectly still \u2014 there\'s a slow\n > wiggle on the chip. Does that sound like your style, or was that comp a\n > one-off?\n\n6. Write it with `set_house_style`.\n\n### 2. Ask, when there is nothing to read\n\nOnly if the project is empty or they have no reference. Keep it to four\nquestions, and offer concrete options rather than open ones \u2014 "dark or light\nbackground?" beats "what\'s your palette?". Then build one small example, show it\nwith `screenshot_frame`, and refine from their reaction. Reacting is easier than\nspecifying.\n\n## What makes a guide that actually works\n\n**Numbers, not adjectives.** `#131521 at 92% opacity` is usable. "Dark and clean"\nis not. If a corner radius, a stroke width or a hold duration matters, write the\nnumber. Anything vague will be silently reinterpreted every time it is read.\n\n**Rules, not just values.** The most valuable lines are the prohibitions: "never\nput text directly on footage \u2014 always on a rounded chip", "keep total runtime\nunder 8 seconds", "no linear motion unless something mechanical is moving".\nThose are what stop work drifting.\n\n**Only what you verified.** Do not pad the file with plausible-sounding defaults\nthey never asked for. A short guide that is true beats a complete one that is\nhalf invented. Leave a heading empty rather than filling it with a guess.\n\n## Keep it current\n\nWhen the user corrects the same thing twice \u2014 "no, the accent green, not the\nblue" \u2014 that is a missing rule, not a one-off. Offer to add it:\n\n> I\'ve had to switch that green twice now. Want me to put it in the style guide\n> so it\'s the default from here?\n\nRead the existing guide with `get_house_style` before writing, and preserve what\nis already there. `set_house_style` replaces the whole file, so send back the\nfull document, not just your additions.\n\n## The one thing to warn them about\n\n`house-style.md` is written next to the `.aep`, so **the project has to have been\nsaved at least once** \u2014 an unsaved project has no folder to write into, and\n`get_house_style` will say so. If that happens, ask them to save the project\nfirst, then write the guide.\n\nThe file is plain markdown. Tell them where it is and that they can edit it in\nany text editor without going through you.\n\n## The digest contract\n\n`get_house_style` answers with a digest by default, and the digest is built by\na parser that recognises a fixed vocabulary. **The template\'s headings are the\ncontract.** A guide written under them comes back `structured: true` with every\nsection read; a guide written under other headings comes back with those\nsections named in `sectionsOmitted`, or \u2014 when nothing at all is recognised \u2014\n`structured: false` with the document\'s opening text and no palette, which the\nnext session will read as "this project has no rules". So write the guide in\nthe shape below, and when the user\'s own guide uses different headings, tell\nthem which ones were skipped rather than letting them believe it was read.\n\nWhat the parser recognises:\n\n- **Headings** \u2014 markdown `#` headings of any level, and the underlined\n (`===` / `---`) forms. A heading is classified by the words in it:\n *palette, colour, color, swatch* \u2192 palette; *type, typography, font,\n lettering, typeface* \u2192 type; *motion, animation, timing, easing, ease,\n transition* \u2192 motion; *layout, grid, spacing, composition, margin, safe area,\n framing, rule, constraint* \u2192 layout. "Rules" is read as layout on purpose.\n A subsection under a classified heading is folded into it (`### Display`\n under `## Type`); a top-level title such as `# House style` with no prose of\n its own is fine.\n- **Colours** \u2014 every hex (`#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`) anywhere in\n the document, not only under Palette. It is named by the text before it on\n the line, or by the first non-hex cell of its table row, so `Accent: #3DC46E`\n and a table row whose first cell is *Accent* both come back as *Accent*. A\n colour written as `rgb(\u2026)` or by name is not a palette entry.\n- **Size** \u2014 up to 24 colours, 3 type lines, 8 layout lines and about 240\n characters of motion. Put the lines that matter most first under each\n heading; what is dropped is counted in the digest\'s `note`.\n\n## Starting point\n\nWhen writing a guide from scratch, this is the shape to fill in \u2014 its headings\nare the ones the digest reads. Drop headings you have nothing real to put\nunder.\n\n```markdown\n# House style\n\n## Palette\n| Role | Colour | Notes |\n|---|---|---|\n| Background | `#0B0D12` | |\n| Primary text | `#FFFFFF` | |\n| Accent | `#3DC46E` | Emphasis and positive values |\n| Negative | `#E03333` | |\n\n## Type\n- Headings: Inter Semibold, 56\u201372px, tracking -10\n- Body: Inter Regular, 28\u201334px\n- Left-aligned unless stated otherwise\n\n## Motion\n- Standard in: scale 0 \u2192 108 \u2192 100, easy ease, ~0.4s\n- Standard out: scale \u2192 0, ~0.3s\n- Easy ease on everything; no linear motion unless mechanical\n- Subtle wiggle on position so nothing sits perfectly still\n\n## Layout\n- 1920\xD71080 at 30fps\n- 120px safe margin from every edge\n- Lower thirds sit bottom-left, above the margin\n\n## Rules\n- Never put text directly on footage \u2014 always on a rounded chip\n- Total runtime under 8 seconds\n```'
|
|
3328
|
+
},
|
|
3329
|
+
{
|
|
3330
|
+
name: "text",
|
|
3331
|
+
description: "Text layers through the AE MCP tools \u2014 alignment as live paragraph justification rather than a measured anchor, tracking set explicitly so the Character panel cannot leak in, what set_text controls, and sizing a background to the text. Load when a task creates, restyles or lays out a text layer.",
|
|
3332
|
+
body: '# Text layers\n\n`create_text_layer` defaults to `anchorAlign: "left"`, which sets **paragraph\njustification** and leaves the anchor point at `[0,0]`, so `position` is the\nstart of the first baseline. Pass `"center"` or `"right"` for those, `"none"`\nfor AE\'s raw behaviour. Because the alignment is justification rather than a\nmeasured offset, it stays correct when the text changes later \u2014 retyped, driven\nby an expression, or edited through Essential Graphics in Premiere. Never "fix"\nalignment by writing an anchor point computed from `sourceRectAtTime()`: it is\nright once and wrong from the next edit onward.\n\nTracking is set to `0` unless you pass one, because AE\'s `addText()` otherwise\ninherits whatever the user\'s Character panel was last left on, and the same\ncall then renders differently on two machines.\n\n`set_text` controls the text and its styling \u2014 font, size, fill and stroke,\ntracking, leading, justification, faux bold and italic, caps, baseline shift.\n`add_text_animator` adds an animator group (position, scale, rotation, opacity,\ntracking, skew, fill and stroke colour) with an optional range selector, which\nis how per-character animation is built.\n\n**Verify alignment by the bounds, not by eye.** `get_layer_full({\u2026, include:\n["bounds"]})` returns `sourceRect`: a centred layer\'s `left` is about\n`-width / 2`, a left-justified layer\'s about `0`, a right-justified layer\'s\nabout `-width`. That is the check to make after a script has touched a\n`TextDocument`; the tools check their own writes, below.\n\n**To fit a background to text**, read `sourceRect` from the same call and size\nthe shape from its width and height plus padding, positioned from its `left`\nand `top`. A `sourceRectAtTime()` expression on the background does the same\nthing live, and stays right when the text is retyped.\n\n**The justification you got is the one in the result, not the one you asked\nfor.** On 26.3 the Source Text round trip can hand back a different alignment\nfrom the one written \u2014 a left-aligned layer re-centred by a `set_text` that\nchanged only `text`. So `set_text` and `create_text_layer` read the\njustification back after every write, write it once more on its own if it\nmoved, and throw naming expected and actual if it still disagrees;\n`create_text_layer` removes the layer first, so a failure never leaves a layer\nyou have no id for. Both results carry `justification` as a name and\n`justificationReasserted`, and an unknown justification name is refused rather\nthan ignored. Trust `justification` in the result over your own argument.\n\n**From `run_jsx`, set justification through the tool** \u2014\n`OPS.set_text({compId, layerId, justification: "center"})` inside the script \u2014\nrather than by assigning `TextDocument.justification`, which on 26.3 stores the\nwrong value and gets no read-back. The `extendscript-gotchas` topic has the\nmeasurement under Text.'
|
|
3333
|
+
},
|
|
3334
|
+
{
|
|
3335
|
+
name: "whats-new",
|
|
3336
|
+
description: "What changed in the After Effects tools, one section per release, newest first \u2014 each entry leads with the rule as it now stands and quotes the old rule it replaces, so a project's own notes can be found and corrected. Read it when a call behaves differently from what you expected, when the user says a tool used to do something else, or with `since` to see only the releases after the one this project last absorbed.",
|
|
3337
|
+
body: "<!--\nFormat contract. `ae_guide({topic: \"whats-new\", since: \"<version>\"})` filters\nthis file by machine and the absorb-release prompt greps project docs with it:\n\n- One `## <semver>` section per release, newest first. No other `##` heading\n below the first release heading; group inside a release with `###`.\n- Everything above the first `## <semver>` is the preamble. It is returned with\n every filtered read, so it stays short.\n- An entry is one bullet. It opens with a **bold phrase stating the rule as it\n now stands**, present tense, then the reason and what changed.\n- A bullet whose rule replaces something a project's own docs may say carries\n one or more indented `supersedes:` lines directly under it, each quoting the\n OLD rule in the words a project doc would have used. They are grep targets:\n short, literal, no markdown.\n-->\n\n# What changed\n\nRead this when something behaves differently from what you expected, or when the\nuser tells you a tool used to work another way. It is the release notes an agent\nneeds rather than the ones a human reads: one section per release, newest first.\nEvery entry leads with the rule as it now stands; a `supersedes:` line under it\nquotes the old rule in the words a project's own notes would have used, so those\nnotes can be searched for and corrected. Pass `since` \u2014 as in\n`ae_guide({topic: \"whats-new\", since: \"0.4.0\"})` \u2014 to get only the releases\nafter that version; the absorb-release prompt is the flow that applies them to\na project once and records the version so the history is not read again.\n\n**When this topic and a tool's own schema disagree, believe the schema.** It is\nrefreshed at each release and a build in between can be ahead of it.\n\n**The panel is versioned separately from the tools, and it does not update\nitself.** It loads at After Effects launch and only at launch, so after any\nupgrade the tools can be newer than the code answering them. When that happens a\ncall returns a remediation message rather than a confusing `Unknown op` \u2014 relay\nit. The distinction worth reading carefully: if it says the panel is *installed*\nbut the running one is older, running `setup_panel` again changes nothing and\nonly quitting and reopening After Effects will.\n\n## 0.5.0\n\nThe release where the guidance split into a core and references, the journal\nstarted coming to you instead of you going to it, and three more things After\nEffects does silently \u2014 a batch's progress no client ever saw, a broken\nexpression reported as ok, a justification that did not land \u2014 became\nvisible. Two results changed shape; those are first, because a caller reading\nthe old shape gets nothing rather than an error.\n\n### Two results changed shape\n\n- **`find_layers` returns `{matches, count, compsSearched, included}`, and a\n match is id, index, name, sourceType, compId and compName unless `include`\n asks for more.** It used to answer a bare array of full layer summaries \u2014\n flags, timing, parent, on every match \u2014 with no way to bound it, so nine name\n matches cost about two thousand tokens, re-sent on every later request.\n `include` takes the `list_layers` section names (`flags`, `timing`,\n `parent`); unlike `list_layers`, omitting it means the bounded record, not\n everything, and the result echoes `included`. A caller that read the old\n array reads `matches` now.\n supersedes: find_layers cannot be bounded\n supersedes: find_layers returns an array of layer summaries\n- **`place_audio_cues` results carry one small entry per cue \u2014 `layerId`,\n `name`, `time`, plus what an option changed \u2014 and `dryRun` answers counts,\n `wouldImport`, `wouldReuse` and the failing cues only.** The per-cue\n `levelDb`, `label`, `index` and `itemId` were echoes of the request, and a\n seven-cue dry run that found nothing wrong cost about 1,500 tokens of them.\n Read a level off the layer, not out of the result.\n supersedes: place_audio_cues echoes the levelDb written for every cue\n supersedes: dryRun on place_audio_cues returns the resolved cue list\n\n### Three silences, now reported\n\n- **`run_batch` over 500 ops returns its `jobId` before the first chunk runs,\n and progress is delivered on `await_job` \u2014 send that call with a progress\n token and `notifications/progress` arrive while it waits.** Nothing can ride\n on the `run_batch` call itself: its response is already back, and a\n notification sent on a request's token after its response is one every\n spec-compliant client has already stopped listening for \u2014 which is where\n every progress message for a long batch went before 0.5.0, seen by no real\n client. `get_job` polls the same state without a token.\n supersedes: run_batch progress arrives on the run_batch call\n supersedes: pass a progressToken on run_batch to see its progress\n- **`set_expression` throws when After Effects cannot evaluate the expression,\n and `get_expression` returns `expressionError`.** Assigning an expression\n succeeds whatever the text says, and AE's only report is a warning banner in\n its interface; the tool now evaluates the property after the write and fails\n with AE's message and the property path. The expression stays written \u2014 fix\n it and call again, or `clear_expression`. `toggle_expression({enabled:\n true})` verifies the same way. A non-empty `expressionError` means the\n property is not being driven by that expression, whatever `enabled` says.\n supersedes: set_expression reports success for a broken expression\n supersedes: ask the user to check the property for an expression warning banner\n- **`set_text` and `create_text_layer` read the justification back after every\n write, re-assert it once if it moved, and throw naming expected and actual\n if it still disagrees.** On 26.3 a `set_text` that changed only `text` has\n re-centred a left-aligned layer. Both results carry `justification` as a\n name and `justificationReasserted`; a failing `create_text_layer` removes\n the layer it made, so nothing is left without an id. An unknown\n justification name is refused rather than ignored.\n supersedes: set_text leaves justification alone when you do not pass it\n supersedes: verify alignment by sourceRect after every set_text\n\n### Deleting a comp, and what it leaves behind\n\n- **`delete_comp` reports `unusedSolidsLeft`, and `purgeUnusedSolids: true`\n removes the comp's own now-unused solids in the same undo step.** A solid's\n source is a footage item in the Solids folder, and deleting the comp only\n ever removed the layer, so a session that built and discarded comps left\n hundreds behind. Only the deleted comp's own solids are considered; one\n another comp still uses is kept and reported in `keptSolids` with that comp.\n supersedes: delete_comp removes a comp and its solids\n supersedes: sweep the Solids folder by hand after deleting comps\n- **`purge_unused_footage` sweeps the project: solids only by default,\n `solidsOnly: false` for every unused footage item, `folderId` to scope it,\n `dryRun: true` to list `wouldRemove` without an undo step.** Never a comp or\n a folder \u2014 a nested comp nothing uses is not footage.\n supersedes: remove unused footage with run_jsx\n\n### Sound\n\n- **`place_audio_cues` takes `loop`, `fadeIn`/`fadeOut` and `stretch` per cue,\n and `fadeFloorDb` per call.** `loop: true` time-remaps with a wrap-around\n expression, keeps AE's two default keys and re-asserts the out point; fades\n keyframe Audio Levels at the layer's real in and out between the floor\n (default `-48`) and the cue's level, and a fade that does not fit is refused\n by cue index; `stretch` is a percentage with the start time and trims\n re-asserted after it. A stretched loop bakes the factor into its expression,\n so a later hand change to stretch leaves the loop at the old rate. All of it\n is validated before anything is created and rolled back with the rest.\n supersedes: loop a bed with run_jsx and timeRemap after place_audio_cues\n supersedes: fade audio in run_jsx after placing the cues\n\n### The journal comes to you\n\n- **A failed call ends with `Known from earlier sessions: <scope:id> \u2014 <title>`\n for up to three entries matching its tool and error text, and the\n `list_known_issues({id})` call that opens the first.** Nobody reads the\n journal ahead of time any more; the pointer arrives with the failure, and a\n match moves the entry's `lastSeen`.\n supersedes: read list_known_issues before nontrivial work\n supersedes: check list_known_issues before guessing\n- **`log_issue` takes `errorText` and `kind`, and the same tool with the same\n error text extends the existing entry even under a new title** \u2014 the result\n says `mergedBy: \"errorText\"` and keeps the existing title. `kind:\n \"ae-quirk\"` marks a permanent After Effects behaviour; the default\n `tool-bug` is presumed fixed once last seen on an older server. Entries\n record `firstVersion` and `lastVersion`.\n supersedes: call list_known_issues first so you reuse the title when logging\n- **Entries archive themselves \u2014 unseen for 30 days, or a `tool-bug` last seen\n on an older server \u2014 and `archive_issue({id, reason})` retires one by\n hand.** Archived entries are hidden from the index and counted in\n `archivedCount`; `includeArchived: true` lists them, a read by `id` always\n works, and a fresh `log_issue` reopens one with `reopened: true`. Index\n lines carry `kind`, `lastSeen` and `lastVersion` and no summary sentence,\n sorted by tool match and then most recently seen.\n supersedes: the journal only grows, entries are never removed\n- **The report flow checks the tracker before drafting.** An entry a closed\n report already covers is archived with that URL; one an open report covers\n is marked reported. Bodies go through a file, and \"all of them\" is one\n approval.\n\n### Reading the history once\n\n- **`ae_guide({topic: \"whats-new\", since: \"<version>\"})` returns only the\n releases after that version, opening with the server's own version**, and\n this file has a shape a machine can filter: one section per release, every\n entry leading with the rule as it now stands, `supersedes:` lines under it\n quoting the old rule. The absorb-release prompt (`/absorb-release` where\n prompts are commands) applies the delta to a project's notes once and\n records the version in the `Tools version last absorbed:` line\n `init_project` writes into AGENTS.md.\n supersedes: read the whole whats-new guide after every update\n\n### The guidance\n\n- **The `after-effects` topic is a core of what silently produces wrong output\n on any task, and each subject is its own topic** \u2014 `animation`, `shapes`,\n `text`, `assembly`, `extendscript-gotchas`, `sound`, `mogrt-and-footage`,\n `issue-journal`, `whats-new` \u2014 twelve `ae_guide` topics in all with\n `style-guide` and `ae-setup`. In Claude Code and claude.ai the references\n are the files in the `after-effects` skill's `references/` folder, opened\n only when the core points at them. Keyframes, easing and rigging are in\n `animation`; raw scripting, with the ease-arity table, in\n `extendscript-gotchas` and nowhere else.\n supersedes: the after-effects skill covers keyframes text and shapes in one file\n supersedes: the ease arity table is in the main guide\n- **Four more After Effects facts, measured on 26.3, in `extendscript-gotchas`:**\n removing the last Time Remap key hides the property, so edit AE's two\n default keys in place or put an expression over them, and re-assert\n `outPoint` after enabling remapping; `TextDocument.justification` written\n from a script lands as RIGHT when handed CENTER, so set it through\n `set_text`; `OutputModule.setSettings` rejects Format, Channels, Depth and\n Color as read-only, so build an output template in the UI and apply it by\n name; and the ease arity is not derivable from a value's dimension, so\n never size a `KeyframeEase` array by hand.\n supersedes: clear the Time Remap keys before adding your own\n supersedes: set justification on the TextDocument from run_jsx\n supersedes: set the output module codec with setSettings from run_jsx\n- **The references carry the craft rules a project would otherwise keep in\n its own notes** (issue #103). `extendscript-gotchas`: a `.value` read at\n the playhead, a `setValue` that throws with the value applied on a comp not\n in a viewer, effects addressed by a name that is not unique and references\n that go stale when one is added, a track matte that switches its matte's\n video off, `replaceSource` keeping the old timing, `item.typeName`, Time\n Remap keys added before the defaults are removed. `animation`: a held pose\n as a hold key, a waypoint that must not stop, parenting at rest and before\n the pop with the parent-space arithmetic, a mirrored parent mirroring its\n text, expression text built from numbers, a shake as an expression over\n keys. `assembly`: a precomp clamped at its source, reversing with time\n remap, controls shared across every instance, a resized comp shifting what\n nests it, collapse transformations and motion blur across a nesting,\n replacing a nested comp, and verifying an assembly, a small detail, a\n doubtful tile and a cut. `sound`: peak for a one-shot and RMS for a bed\n with the onset on the beat, a trim clamped at the file's end, a bed that\n follows a move, a float WAV that will not place. `shapes`: one fill per\n group, the layer stack built back-to-front, a group's pivot, seams between\n shapes, centring a repeated run.\n\n### Connecting, and staying connected\n\n- **The server follows the panel's port.** A call refused on the port the\n server remembered makes it look again \u2014 7777 first, then the port file \u2014\n switch to whichever answers as the panel, and re-send the call once. Only a\n refused connection triggers this; a timed-out call reached After Effects and\n is never re-sent (issue #92). `AE_MCP_PORT` is a hard pin: with it set, no\n other port is ever tried.\n supersedes: reconnect the MCP server when ops fail on a port check_setup does not report\n- **`check_setup` reports the port that actually answers**, flags a stale\n port file, and carries a new `portAgreement` check. When calls go to one\n port and the panel answers on another, its advice is to retry or reconnect\n the MCP server. It never tells you to restart After Effects for that.\n supersedes: cannot reach the panel means nothing is listening so restart After Effects\n- **The panel waits instead of moving when an older copy of itself holds\n 7777.** It says so in its own window, names the holder, and takes the port\n over the moment that copy exits, with no restart. Two After Effects\n instances on one machine opt back into walking with `\"allowPortWalk\": true`\n in `~/.engineroom-ae-mcp/config.json`; the same file's `\"port\"` moves the\n panel. The port file only ever names a port that was really bound and is\n removed when the panel unloads.\n supersedes: the panel walks up to the next free port when 7777 is taken\n- **A panel that never loads on Windows gets a diagnosis, not a restart\n loop.** `check_setup` reads CEP's own log and, when CEP refused the panel's\n signature \u2014 an Adobe CEP 12 bug that ignores PlayerDebugMode \u2014 reports\n `panelSignature` with the steps to self-sign the installed panel with\n Adobe's ZXPSignCmd (issue #91). With no log yet, it gives the one command\n that turns CEP logging on. `setup_panel` now turns that logging on itself\n where it was never set, warns when it is about to replace a self-signed\n panel (`signedInstallOverwritten`, since the fresh copy needs signing\n again), and reports a signed panel as up to date rather than partial.\n supersedes: check_setup all green but the panel never loads means restart After Effects again\n- **`check_setup` under an `AE_MCP_PORT` pin still asks 7777 and the port\n file, and a pinned wrong port is reported as the pin, not as a dead panel.**\n The pin decides where ops go and is never walked past; the diagnosis says so,\n names the port the panel is really answering on, and tells you to change or\n unset `AE_MCP_PORT` and reconnect the MCP server \u2014 never to restart After\n Effects. A refused op under a pin says the same in its own message.\n\n### Found by the live pass\n\n- **`init_project` refuses the home directory and the filesystem root however\n they were arrived at, an explicit `dir` included.** Before this only the\n working-directory fallback was guarded, so `dir: \"~\"` resolved by a client,\n or `dir: \"/\"`, scaffolded `AGENTS.md` and a `renders/` folder into a home\n directory and reported success.\n- **`set_house_style` writes `house-style.md` with Unix line endings.**\n ExtendScript's default on macOS turned every line break into a bare carriage\n return: the tools read the file back correctly, so the round trip looked\n fine, but a designer opening the file in an editor saw one long line.\n\n## 0.4.0\n\nThe release where verifying a change stopped meaning reading the whole thing\nback \u2014 and where a live pass against After Effects 2026 found four things this\nserver had been claiming that were never true. Those are first, because an agent\nthat learned the old story will otherwise repeat it to a user.\n\n### Four things that were never true, and are now fixed\n\n- **`run_batch` is one undo step up to 500 ops; over 500 it is one step per\n chunk of 25, and `singleUndo: true` makes it one step up to 2000.** The\n one-step guarantee it shipped with before 0.4.0 did not exist on either path:\n After Effects discards an undo group opened in one script call and closed in\n another, so a 600-op batch landed as about six hundred separate steps while\n reporting one. Now up to 500 ops is genuinely one step; over 500 is one step\n per chunk of 25 \u2014 around 24 for 600 ops \u2014 with the measured count in\n `undoSteps` and a `note` saying it in words. `singleUndo: true` runs the batch\n in a single blocking call, which freezes AE's interface for the duration and\n reports no progress. Read `undoSteps` before you tell anyone how to undo the\n work.\n supersedes: run_batch is one undo step\n supersedes: a long run_batch is one undo step\n- **`transactional: true` stops at the first failure, rolls nothing back, and\n says so: `rolledBack: false` with the stop point named.** It never rolled\n anything back \u2014 it fired one menu-command Undo, and menu commands silently do\n nothing over this bridge; one Undo is one step, not a batch, in any case. The\n ops before the failure stay applied. Use `diff: true` to see what landed.\n supersedes: transactional: true rolls the batch back on failure\n- **`reorder_layer` works, and takes exactly one of `beforeLayerId`,\n `afterLayerId` or `toIndex`.** Every call it served before 0.4.0 threw\n `parent is not an INDEXED_GROUP`, because it used a shape-node method on a\n layer. Prefer the id forms, because this is the op that invalidates indexes;\n `toIndex` means the index the layer **ends up at**.\n supersedes: reorder_layer is broken, use run_jsx\n supersedes: reorder_layer throws parent is not an INDEXED_GROUP\n- **`downsample: 1` is full resolution, whatever the viewer's Resolution\n dropdown is set to.** Before 0.4.0 factor 1 skipped setting the comp's\n resolution at all, so it inherited the viewer's setting \u2014 on a comp a designer\n had left at Quarter, `downsample: 1` returned a quarter-size frame and\n `downsample: 2` returned one four times larger. The response was honest about\n the dimensions; the picture was not the one asked for. Factor 1 is now set\n explicitly like any other and restored afterwards.\n supersedes: downsample: 1 means the viewer's resolution\n supersedes: set the viewer to Full before a downsample: 1 screenshot\n\n### Two calls changed under you\n\n- **`create_shape_layer` spawns at `[0,0]`**, with the anchor at `[0,0]`, so the\n layer's coordinate space *is* the comp's and every vertex, rect position and\n path you add afterwards is in comp pixels. After Effects' own spawn point is\n the comp centre, which silently offsets a drawing authored in comp coordinates\n by half a frame \u2014 easy to miss on a downsampled screenshot. `position:\n \"center\"` restores AE's behaviour, and any `[x,y]` places the origin yourself.\n The result echoes what it ended up with.\n supersedes: a new shape layer spawns at the comp centre\n supersedes: shape paths are offset by half a frame, subtract the comp centre\n- **`get_house_style` returns a digest, not the document; `detail: \"full\"`\n returns the document.** The digest is a few hundred tokens: palette as named\n hexes, type, motion, layout, and a note naming anything it could not summarise.\n You need the full document before `set_house_style`, which replaces the file\n rather than patching it. An unstructured guide comes back `structured: false`\n with its opening text, which is an honest \"I could not read this as a spec\"\n rather than an empty answer.\n supersedes: do not call get_house_style, read the digest in these notes instead\n supersedes: get_house_style returns the whole style guide\n\n### Verify with a diff instead of a second read\n\n- **`snapshot_comp` and `diff_comp` fingerprint a comp and report what moved.**\n Fingerprint before a write, then ask: layers added, removed, renamed,\n retimed, re-parented, keyframe counts, expressions and effects gained or lost\n \u2014 and a count of the layers that did not move. Tens of tokens where two full\n reads were thousands. It does **not** record property values, expression\n text, effect parameters, masks or shape contents, so `changeCount: 0` means\n none of the recorded fields moved, not that the comp is identical.\n supersedes: verify a write by reading the comp back with list_layers and get_layer_full\n- **`diff: true` on `run_jsx` and `run_batch` does the same inside the call**,\n so there is no window between the write and the fingerprint \u2014 and on a\n failure the diff rides on the error, which is how you find where a\n half-applied script stopped.\n\n### Screenshots\n\n- **`screenshot_frame` takes `times` (2\u20136) and returns one tiled contact\n sheet**, each tile labelled with its own time, held to roughly the pixel\n budget of a single frame. Judging motion is one call now, not three: one\n image in your context instead of three, and one render request instead of\n three back-to-back ones, which is the pattern that provoked stale frames.\n `time` and `times` are mutually exclusive; there is no `times` on\n `screenshot_layer`.\n supersedes: screenshot each time separately to judge motion\n- **Three screenshot failures, each with its own remedy.** `Stale frame` (AE\n served an earlier render \u2014 wait, retry higher), `Corrupt frame` (the file is\n not a whole PNG; not a timeout \u2014 retry at downsample 6\u20138 or shoot the precomps\n separately), `Render timed out` (still rendering \u2014 wait before retrying, or a\n retry queues behind it). A frame that is genuinely all-transparent is still\n `empty: true` with no image. On a sheet, one bad tile is drawn as a marked\n block and named in `warning`; the rest of the sheet is good.\n supersedes: truncated PNG from screenshot_frame means retry it\n\n### run_jsx\n\n- **A `run_jsx` failure names the line of *your* script and prints its text**,\n and says so honestly when the number cannot be mapped rather than guessing.\n This was claimed once before it was true: until the 0.4.0 live pass every\n error said line 1 and printed line 1's text, with the real number only in the\n parenthetical after it, because AE reports `Error.start`/`Error.end` as `0`\n on every error rather than as the character offsets its documentation\n describes.\n supersedes: run_jsx line numbers are wrong, count from the wrapper\n supersedes: run_jsx reports line 1 for every error\n- **`diff: true` on `run_jsx` reaches After Effects.** The server was building\n a fresh argument object and dropping every field it did not enumerate, so the\n flag was discarded before the call left. The same flag on `run_batch` always\n worked, which is what hid it.\n supersedes: diff: true on run_jsx does nothing\n- **`scriptPath` and `libraries` keep a long script and its shared helpers out\n of the conversation entirely.** Libraries are inlined ahead of the script in\n the same scope, so their functions are callable from it; they are\n re-evaluated on every call, so keep them to declarations rather than to work.\n- **Helpers are in scope**, each wrapping a trap: `compById`, `layerById`,\n `walkProperty`, `addKeys`, `ease` (which sizes the KeyframeEase array using\n the same code `set_temporal_ease` uses, not a copy), `shape` (which lands at\n `[0,0]`), and `withoutUndoGroup`. The whole `OPS` table is callable too.\n\n### New tools, and one thing you no longer have to get right\n\n- **`duplicate_comp` returns the new comp id**, so you never look for the copy\n by name. Shallow by default like AE's own Duplicate \u2014 the copy's precomp\n layers point at the *same* nested comps \u2014 with `deep: true` for a real\n variant.\n supersedes: duplicate a comp with run_jsx and find the copy by name\n- **`place_audio_cues` scores a scene in one call**: a cue list becomes one\n audio layer each, imported once per file, named, trimmed, levelled in dB, in a\n single undo step, all-or-nothing, with a `dryRun`.\n supersedes: place audio with run_jsx, one layer at a time\n- **`set_temporal_ease` and `add_keyframe` size the ease array themselves.**\n Pass one `{influence, speed}` pair per side; the count that worked comes back\n as `easeDimensions`. The bare `parameter 2` failure is no longer yours to\n avoid.\n supersedes: set_temporal_ease fails with parameter 2, pass the right number of KeyframeEase objects by hand\n supersedes: set_temporal_ease fails on Scale, use run_jsx\n- **`export_mogrt` refuses an empty Essential Graphics panel up front**, naming\n the controller count. It used to attempt the export and then blame a modal\n dialog, which under the default dialog suppression is the one cause ruled out\n by construction. When an export does fail now, the message says what was\n checked and \u2014 if dialogs were suppressed \u2014 that the cause is genuinely\n unknown, because AE answers with a bare boolean and has no way to say why.\n supersedes: export_mogrt fails with a modal dialog when the comp has no Essential Graphics controllers\n\n### Underneath\n\n- **Writes are serialized, one at a time, for the whole session**, so two\n writes issued together run in the order you issued them and a long\n `run_batch` holds the lock until its last chunk lands \u2014 nothing else drops\n into the middle of work the user asked for as one thing. A call that waited\n says `queuedBehind` and `waitedMs`. Reads are never queued. There is a new\n diagnosis to tell apart from a bridge timeout: a call *dropped while waiting\n for the write queue* never reached After Effects, so re-sending it is safe \u2014\n which is the opposite of what a timeout means.\n supersedes: never issue two writes in the same turn, the order is not guaranteed\n- **The issue journal has two scopes.** `project` is this project's notes;\n `user` travels with the person across every project, so log tool and After\n Effects behaviour there. Ids are unique only within a journal, so open an\n entry with the qualified form the listing shows (`user:\u2026`).\n- **The guidance lives in `ae_guide` and the skill, not in what the server sends\n every session.** What the server sends is a short pointer rather than a\n summary, because it was resident in every request the user ever made. The\n narrative is `ae_guide({topic: \"after-effects\"})`, with `extendscript-gotchas`\n behind it for raw `run_jsx` and this topic for changes. In Claude Code and\n claude.ai the same text is the `after-effects` skill and the files in its\n `references/` folder \u2014 load one carrier, not both.\n- **Rigging is written down**, in the `animation` topic: opacity does not propagate\n through parenting, and a camera null is parented to while it is still at\n identity.\n\n## 0.3.1\n\n- **`get_layer_full` omits `ADBE Vector Materials Group` unless\n `shapeMaterials: true`.** That is the 48-property 3D extrusion model AE hangs\n off every vector group, which means nothing on a 2D shape layer. It was around\n three quarters of the bytes of a shape read: one 68px circle cost 4,400\n tokens, of which the geometry was about 40. `materialsOmitted` counts what was\n dropped; `shapeMaterials: true` brings it back for a genuinely extruded shape.\n A group Transform still at its creation values collapses to `atDefaults:\n true` the same way.\n supersedes: shape reads are huge, avoid get_layer_full on shape layers\n- **`shapeDetail: \"compact\"` returns one indented line per group** instead of\n the JSON tree \u2014 every name the write tools address a node by, with `[3 keys]`\n or `[expr]` marking the animated properties. Measured end to end on one real\n layer: 13,369 characters before 0.3.1, 3,052 once the materials block went,\n 643 compact. Reach for `\"full\"` only when you need exact values or keyframe\n indices.\n- **`run_jsx` never answers a bare `null`.** A script whose last statement is a\n bare expression completes and returns nothing \u2014 `\"ping\";` does not return\n `\"ping\"` \u2014 and that used to be indistinguishable on the wire from a script\n that never ran. It now comes back as `{ok: true, returned: null, undoGroup,\n note}`, which means *it ran to completion*. Do not re-run it: nothing rolls\n back, so a second run of a script that duplicated a layer or wrote keyframes\n does all of it twice. A returned value still comes back bare, falsy ones\n included.\n supersedes: a null from run_jsx means the script did not run, run it again\n\n## 0.3.0\n\n- **`list_comps`, `list_layers` and `get_layer_full` take `include` to name the\n sections you want**, plus `maxKeyframes` and `shapeDepth` on the deep read.\n Omit them all and you get everything, exactly as before. Anything left out is\n named and counted in the response (`included`, `keyframesOmitted`,\n `childrenOmitted`), so a short answer never passes for a complete one. This\n matters more than it sounds: a tool result is re-sent to you on every later\n request for the rest of the session, so one unbounded read is paid for many\n times.\n supersedes: get_layer_full returns everything, there is no way to bound it\n- **Screenshots downsample themselves.** Omit `downsample` and it is derived\n from the comp \u2014 2 at 1080p, 3 at 4K, aiming at a long edge near 1280px. The\n result reports the size actually returned. Pass `downsample: 1` only when you\n genuinely need full resolution.\n supersedes: always pass downsample on screenshot_frame or it returns full resolution\n- **A stale frame is an error, not a picture.** AE sometimes re-serves a frame\n it rendered for an unrelated request, at a different time and even at a\n different downsample factor, with nothing in the response to say so. Those\n are refused with `Stale frame` naming the request whose pixels came back.\n Wait a few seconds and retry at a higher `downsample`.\n- **A fully transparent frame comes back as `{empty: true, reason}`** with no\n image. That is a fact about the composition \u2014 wrong time, layer outside its\n in/out points, disabled, zero opacity \u2014 not something to retry.\n- **`import_footage` and `create_footage_layer` bring footage in**, and the\n import refuses an SVG whose `viewBox` asks for one aspect ratio and imports at\n another: that is a real AE bug which renders as nothing at all, with no error\n at any stage.\n supersedes: import footage with run_jsx and importFile\n- **`export_mogrt` exports a Motion Graphics template and suppresses the modal\n dialogs** that otherwise freeze this whole connection until someone clicks\n them in After Effects. Never drive `exportAsMotionGraphicsTemplate` from\n `run_jsx`.\n supersedes: export a mogrt with run_jsx and exportAsMotionGraphicsTemplate\n- **Text alignment is justification**, not a measured anchor offset, so it\n stays correct when the text changes later. Tracking is set explicitly rather\n than inherited from the user's Character panel.\n supersedes: anchorAlign moves the anchor point, re-align text after every edit"
|
|
1947
3338
|
}
|
|
1948
3339
|
];
|
|
1949
3340
|
var PROMPTS = [
|
|
3341
|
+
{
|
|
3342
|
+
name: "absorb-release",
|
|
3343
|
+
description: "After the After Effects tools update \u2014 read what changed since this project last caught up, fix the notes in this folder the update made stale, retire journal entries whose fix has shipped, and record the version so the history is not read again",
|
|
3344
|
+
argumentHint: "[the version this project last absorbed, if its docs do not say]",
|
|
3345
|
+
body: '# Absorb a tools update\n\nThe After Effects tools have updated, or the user thinks they have. A release\nchanges what the tools do, and what earlier sessions wrote down about them in\nthis project \u2014 workarounds in AGENTS.md or CLAUDE.md, entries in the issue\njournal \u2014 does not update itself. This flow reads the delta once, corrects those\nnotes, and records the version so no later session reads the history again.\n\n`$ARGUMENTS` is the version the project last absorbed, if the user typed one.\n\n## When to run this\n\nAt the start of a session, compare two versions:\n\n- **Recorded**: the line `Tools version last absorbed: <version>` in the\n project\'s docs. `init_project` writes it into AGENTS.md.\n- **Running**: the server\'s own version. `list_known_issues` returns it as\n `serverVersion`, and every `ae_guide({topic: "whats-new"})` answer opens with\n it.\n\nRunning newer than recorded, or no recorded line at all, is the cue. Two things\nupdate separately, so there can be two versions to look at: the MCP server (via\nnpx, the standalone binary, or the Claude Desktop bundle) and, in Claude Code,\nthe plugin that carries the skills and this command, whose version is in its\n`.claude-plugin/plugin.json`. The **server\'s** version is the one this flow\nrecords, because the server is what answers tool calls and what serves the\nwhats-new topic. If the plugin is ahead of the server, the skill text may\ndescribe tools the server does not have yet \u2014 say so, and suggest updating the\nserver rather than the notes.\n\n## 1. Find the recorded version\n\nLook for `Tools version last absorbed:` in AGENTS.md, then CLAUDE.md,\n`.cursor/rules/`, `.windsurfrules`, `.github/copilot-instructions.md` and any\nnotes under `.ae-mcp/`. `$ARGUMENTS` overrides whatever is found.\n\nNothing recorded and nothing typed: ask whether these tools have been used in\nthis project before. If they have, treat it as never absorbed and read the whole\nhistory \u2014 one long read, once. If the project is new, there is nothing to\nabsorb: go to step 5 and just record the version.\n\n## 2. Read the delta\n\nCall `ae_guide({topic: "whats-new", since: "<recorded>"})`. The first line\nnames the server version \u2014 keep it for step 5. If it says nothing is newer, tell\nthe user they are up to date, make sure the recorded line exists (step 5), and\nstop.\n\nOtherwise, every bullet leads with the rule as it now stands, and a\n`supersedes:` line under it quotes the old rule in the words a project\'s notes\nwould have used. Those lines are what the next step searches for.\n\n## 3. Correct the project\'s own notes\n\nFor every `supersedes:` line, search the files from step 1 for the old rule \u2014\nthe phrase, and its key words, because a note will rarely match word for word.\n`house-style.md` is about the look, not the tools; leave it alone.\n\nFor each hit, show the user the note as it stands, the rule that replaces it,\nand the edit you propose: a rewrite when the note still says something true, a\ndeletion when it only described a bug that is now fixed. A note that mentions a\nworkaround also teaches the workaround, so prefer deleting to rephrasing. These\nare their notes \u2014 collect the proposed edits, get one yes, then make them.\nNever edit silently.\n\nNo hits: say so in one line and move on.\n\n## 4. Retire journal entries whose fix has shipped\n\nCall `list_known_issues({status: "reported"})`. A reported entry carries the URL\nof the issue that was filed. If `gh` is installed and signed in (`gh auth\nstatus`), check each one:\n\n```bash\ngh issue view <number> --repo <repo> --json state,stateReason\n```\n\n- `CLOSED` with `stateReason: COMPLETED` \u2014 the fix shipped. Call\n `archive_issue({id, reason: "<url> closed"})`, using the scope-qualified id\n the listing shows (`user:\u2026`, `project:\u2026`).\n- `CLOSED` with `NOT_PLANNED` \u2014 the behaviour is staying, so the workaround is\n still worth having. Leave it.\n- `OPEN` \u2014 leave it.\n\nNever archive an entry tagged `kind: "ae-quirk"` on the strength of a closed\nissue: those describe After Effects itself, not a tool bug, and a release does\nnot change them.\n\nThen glance at the unreported entries against the delta: an entry whose symptom\na bullet says is fixed can be archived with the release as the reason \u2014 propose\nit, do not assume.\n\nIf `gh` is missing or not signed in, do not install or configure it. List the\nreported entries with their URLs so the user can check them, and leave the\njournal as it is.\n\n## 5. Record the version\n\nWrite `Tools version last absorbed: <server version>` \u2014 the version from the\nfirst line of step 2 \u2014 replacing the existing line, or adding it under a\n`## Tool updates` heading in AGENTS.md when there was none. No AGENTS.md: use\nthe file the project reads (CLAUDE.md, or the rules file found in step 1). No\nproject docs at all \u2014 a Claude Desktop session with no folder, say \u2014 tell the\nuser there is nowhere to write it, suggest `init_project` for when they next\nwant a project folder, and stop.\n\nAfter this the history is not loaded again for this project until the next\nupdate.\n\n## 6. Say what happened\n\nOne short paragraph, in plain language: which version, how many notes were\ncorrected, which journal entries were retired. No file paths they did not ask\nabout, and no talk of "supersedes lines" \u2014 that is machinery.'
|
|
3346
|
+
},
|
|
1950
3347
|
{
|
|
1951
3348
|
name: "create-style-guide",
|
|
1952
3349
|
description: "Capture or update the look of this project \u2014 palette, type, motion and layout \u2014 into the style guide that shapes everything built afterwards",
|
|
@@ -1963,7 +3360,7 @@ var PROMPTS = [
|
|
|
1963
3360
|
name: "report-ae-issue",
|
|
1964
3361
|
description: "Send a problem you hit with the After Effects tools to the people who maintain them",
|
|
1965
3362
|
argumentHint: "[what went wrong, in your own words]",
|
|
1966
|
-
body: '# Report a problem with the After Effects tools\n\nThe user wants to tell the maintainers about something that did not work. They are\nmost likely a motion designer, not a developer: they may never have seen GitHub,\nand they should not have to. Do the technical part yourself and only ask them\nthings they can actually answer.\n\n`$ARGUMENTS` is what they typed, if anything.\n\n## 1. Find out what to report\n\nCall `list_known_issues` with `status: "unreported"`. It returns a one-line index\nof what earlier sessions wrote down, plus `repo`, `newIssueUrl`, `serverVersion`\nand `platform`. Once they have chosen, read each chosen entry in full with\n`list_known_issues({id})` \u2014 the draft below
|
|
3363
|
+
body: '# Report a problem with the After Effects tools\n\nThe user wants to tell the maintainers about something that did not work. They are\nmost likely a motion designer, not a developer: they may never have seen GitHub,\nand they should not have to. Do the technical part yourself and only ask them\nthings they can actually answer.\n\n`$ARGUMENTS` is what they typed, if anything.\n\n## 1. Find out what to report\n\nCall `list_known_issues` with `status: "unreported"`. It returns a one-line index\nof what earlier sessions wrote down, plus `repo`, `newIssueUrl`, `serverVersion`\nand `platform`. Once they have chosen, read each chosen entry in full with\n`list_known_issues({id})` \u2014 the checks and the draft below need the symptom,\nerror text and workaround, which the index does not carry.\n\n- **Entries exist** \u2014 show them as a short numbered list, one plain sentence each\n ("Text layers ended up in the wrong place when a font was missing"), not the\n raw titles. Ask which to send; offer "all of them" as an option. Remember the\n answer: if they said "all of them", step 4 takes one approval for the lot, not\n one per entry.\n- **No entries, but `$ARGUMENTS` describes something** \u2014 work from that. Ask what\n they were trying to do and what happened instead, then `log_issue` it so it is\n recorded before you send it.\n- **Nothing either way** \u2014 say there is nothing recorded to send, and that you\n will write things down as you hit them from now on. Stop there.\n\n## 2. Check whether the maintainers already know\n\nBefore drafting anything, look at what has already been sent to them \u2014 sending\nthe same problem twice wastes their time and the user\'s. `repo` came back from\nstep 1.\n\n```bash\ngh issue list --repo <repo> --state all --limit 200 --json number,title,state,url\n```\n\nMatch each chosen entry against that list by the tool it names and by its error\ntext: a title that names the same tool and the same failure, or quotes the same\nerror, is the same problem however it is worded. When a title alone does not\nsettle it, read the body:\n\n```bash\ngh issue view <number> --repo <repo> --json title,body,state,url\n```\n\nFor every entry that matches something:\n\n- **The match is closed** \u2014 it has already been dealt with. Do not send it again.\n Call `archive_issue` with the entry\'s scope-qualified id and the existing link\n as the reason (`"already closed upstream: <url>"`), so it leaves the list and no\n later session offers it. Tell the user in one plain sentence that this one was\n already fixed or already handled, with the link; if the fix came in a newer\n version than `serverVersion`, say that updating is how to get it.\n- **The match is open** \u2014 it is known and still being looked at. Do not send a\n second copy. Call `mark_issue_reported` with the entry\'s scope-qualified id and\n the existing link, and tell the user it is already on the maintainers\' list,\n with the link. If they have something to add, it belongs on that one.\n\nOnly the entries with no match go on to step 3. If none are left, say so and\nstop.\n\nIf `gh` is missing or not signed in, skip this step, say so in one line, and\ncarry on \u2014 step 5 has the fallback for sending.\n\n## 3. Draft it\n\nShort. A maintainer should understand the problem in fifteen seconds.\n\n**Title:** one line, concrete. `set_temporal_ease fails on Position with "Value\narray does not have 1 elements"` \u2014 not `Keyframe bug`.\n\n**Body:** four short sections, a couple of sentences each.\n\n```markdown\n**What happens**\n<the failing call and the exact error, or the wrong result>\n\n**Why** (if known)\n<one line \u2014 omit this section entirely if unknown>\n\n**Workaround**\n<what got past it>\n\n**Environment**\nafter-effects-mcp <serverVersion> \xB7 <platform> \xB7 After Effects 2026\n```\n\nInclude the failing call and error text verbatim \u2014 that is the part that makes it\nfixable. Leave out the user\'s own content: comp and layer names from their\nproject, file paths, client names, anything about the video they are making. If a\ndetail like that is load-bearing, replace it with a placeholder.\n\n## 4. Show it and get a yes\n\nShow the finished title and body and ask whether to send it. This posts publicly\nto a repository under their name if `gh` is authenticated, so it needs a real\nanswer, not an assumption. If they want to change the wording, change it.\n\nIf they chose "all of them" in step 1, show every draft together, once, and take\none yes for the lot. Do not ask again for each one.\n\n## 5. Send it\n\nTry `gh` first. Write the body to a temporary file and pass the file \u2014 a\nmulti-line markdown body with backticks and quotes does not survive being quoted\non a command line:\n\n```bash\ngh issue create --repo <repo> --title "<title>" --body-file <temp-file>\n```\n\nOne call per draft; remove the temporary file afterwards.\n\nIf `gh` is missing or not authenticated, do not try to install or configure it.\nBuild a prefilled link instead \u2014 URL-encode the title and body onto\n`<newIssueUrl>` as `?title=\u2026&body=\u2026` \u2014 and give it to them with one line of\ninstruction: open this, it will already be filled in, press the green button. A\nGitHub account is needed to press it; if they do not have one, say so plainly and\noffer to write the text out for them to send another way.\n\n## 6. Close the loop\n\nOn success, call `mark_issue_reported` with the entry `id` and the URL, so no\nlater session asks them to report the same thing twice. Use the same\nscope-qualified id the listing gave you (`user:\u2026`, `project:\u2026`): the two\njournals can hold the same slug, reporting one says nothing about the other, and\nan unqualified id leaves which one moved to chance. Then tell them where it went,\nin one sentence, with the link.\n\nIf they decline, leave the entry alone \u2014 it stays unreported and can be offered\nagain another day. Do not mark it.'
|
|
1967
3364
|
}
|
|
1968
3365
|
];
|
|
1969
3366
|
var GUIDE_NAMES = GUIDES.map((g) => g.name);
|
|
@@ -1973,40 +3370,60 @@ function getGuide(name) {
|
|
|
1973
3370
|
function getPrompt(name) {
|
|
1974
3371
|
return PROMPTS.find((p) => p.name === name);
|
|
1975
3372
|
}
|
|
1976
|
-
var SERVER_INSTRUCTIONS =
|
|
3373
|
+
var SERVER_INSTRUCTIONS = 'You are driving a live After Effects session through this server. The user sees\nevery change as it happens and every call is a real undo step in their project.\n\nRead the guidance before you build. `ae_guide({topic: "after-effects"})` is the\ncore \u2014 orienting, bounded reads, verifying a write, screenshots, the bridge\nfailures \u2014 and points at one topic per subject (shapes, text, animation, raw\nscripting, sound, \u2026). In Claude Code and claude.ai the `after-effects` skill is\nthe same text \u2014 load one carrier, not both.\nTopics: ae-setup, after-effects, animation, assembly, extendscript-gotchas, issue-journal, mogrt-and-footage, shapes, sound, style-guide, text, whats-new.\n\nThree habits that matter before that call returns:\n\n- Identify by `id`, never by `index` \u2014 a layer\'s index shifts on every insert.\n- Bound your reads (`include`, `shapeDetail: "compact"`) and treat screenshots as\n one-off diagnostics rather than a feedback loop. Every tool result is re-sent\n to you on every later request in the session.\n- If a tool reports it cannot reach After Effects, call `check_setup` and relay\n its `nextSteps` verbatim; do not diagnose CEP by hand.';
|
|
1977
3374
|
|
|
1978
3375
|
// src/issues/journal.ts
|
|
1979
|
-
import
|
|
1980
|
-
import
|
|
1981
|
-
import
|
|
3376
|
+
import fs9 from "node:fs";
|
|
3377
|
+
import os5 from "node:os";
|
|
3378
|
+
import path10 from "node:path";
|
|
1982
3379
|
var REPO = "Engine-Room-Games/after-effects-mcp";
|
|
1983
3380
|
var NEW_ISSUE_URL = `https://github.com/${REPO}/issues/new`;
|
|
3381
|
+
var ARCHIVE_AFTER_DAYS = 30;
|
|
1984
3382
|
var SECTION_SYMPTOM = "What went wrong";
|
|
1985
3383
|
var SECTION_CAUSE = "Why";
|
|
1986
3384
|
var SECTION_WORKAROUND = "What worked";
|
|
3385
|
+
var ERROR_TEXT_CHARS = 400;
|
|
3386
|
+
var ISSUE_KINDS = ["tool-bug", "ae-quirk"];
|
|
1987
3387
|
function journalRoot() {
|
|
1988
|
-
const override =
|
|
1989
|
-
if (override
|
|
3388
|
+
const override = journalOverride();
|
|
3389
|
+
if (override) return { dir: override, scope: "project" };
|
|
1990
3390
|
const cwd = process.cwd();
|
|
1991
|
-
const unusable = cwd ===
|
|
3391
|
+
const unusable = cwd === path10.parse(cwd).root || cwd === os5.homedir();
|
|
1992
3392
|
if (!unusable) {
|
|
1993
3393
|
try {
|
|
1994
|
-
|
|
1995
|
-
return { dir:
|
|
3394
|
+
fs9.accessSync(cwd, fs9.constants.W_OK);
|
|
3395
|
+
return { dir: path10.join(cwd, ".ae-mcp"), scope: "project" };
|
|
1996
3396
|
} catch {
|
|
1997
3397
|
}
|
|
1998
3398
|
}
|
|
1999
|
-
return { dir:
|
|
3399
|
+
return { dir: path10.join(os5.homedir(), ".after-effects-mcp"), scope: "home" };
|
|
2000
3400
|
}
|
|
2001
|
-
function
|
|
2002
|
-
|
|
3401
|
+
function userJournalRoot() {
|
|
3402
|
+
const override = journalOverride();
|
|
3403
|
+
if (override) return { dir: path10.join(override, "user"), scope: "user" };
|
|
3404
|
+
return { dir: path10.join(os5.homedir(), ".ae-mcp"), scope: "user" };
|
|
2003
3405
|
}
|
|
2004
|
-
function
|
|
2005
|
-
const
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
3406
|
+
function journalOverride() {
|
|
3407
|
+
const override = process.env.AE_MCP_HOME?.trim();
|
|
3408
|
+
return override && override.length > 0 ? override : null;
|
|
3409
|
+
}
|
|
3410
|
+
function journals() {
|
|
3411
|
+
const project = journalRoot();
|
|
3412
|
+
const user = userJournalRoot();
|
|
3413
|
+
if (path10.resolve(project.dir) === path10.resolve(user.dir)) return [project];
|
|
3414
|
+
return [project, user];
|
|
3415
|
+
}
|
|
3416
|
+
function issuesDir(journal) {
|
|
3417
|
+
return path10.join(journal.dir, "issues");
|
|
3418
|
+
}
|
|
3419
|
+
function journalFor(scope) {
|
|
3420
|
+
return scope === "user" ? userJournalRoot() : journalRoot();
|
|
3421
|
+
}
|
|
3422
|
+
function ensureJournalDir(journal) {
|
|
3423
|
+
const issues = issuesDir(journal);
|
|
3424
|
+
fs9.mkdirSync(issues, { recursive: true });
|
|
3425
|
+
const ignore = path10.join(journal.dir, ".gitignore");
|
|
3426
|
+
if (!fs9.existsSync(ignore)) fs9.writeFileSync(ignore, "*\n", "utf8");
|
|
2010
3427
|
return issues;
|
|
2011
3428
|
}
|
|
2012
3429
|
function slugify(text) {
|
|
@@ -2019,10 +3436,16 @@ function today() {
|
|
|
2019
3436
|
function oneLine(text) {
|
|
2020
3437
|
return text.replace(/\s+/g, " ").trim();
|
|
2021
3438
|
}
|
|
2022
|
-
function
|
|
2023
|
-
|
|
2024
|
-
const
|
|
2025
|
-
if (
|
|
3439
|
+
function clipErrorText(text) {
|
|
3440
|
+
if (!text) return void 0;
|
|
3441
|
+
const line = oneLine(stripDecorations(text));
|
|
3442
|
+
if (line.length === 0) return void 0;
|
|
3443
|
+
return line.length > ERROR_TEXT_CHARS ? line.slice(0, ERROR_TEXT_CHARS) : line;
|
|
3444
|
+
}
|
|
3445
|
+
function entryPath(id, journal) {
|
|
3446
|
+
const dir = path10.resolve(issuesDir(journal));
|
|
3447
|
+
const file = path10.resolve(dir, `${id}.md`);
|
|
3448
|
+
if (path10.dirname(file) !== dir) throw new Error(`Invalid issue id: ${id}`);
|
|
2026
3449
|
return file;
|
|
2027
3450
|
}
|
|
2028
3451
|
function render(entry) {
|
|
@@ -2031,18 +3454,22 @@ function render(entry) {
|
|
|
2031
3454
|
`id: ${entry.id}`,
|
|
2032
3455
|
`title: ${oneLine(entry.title)}`,
|
|
2033
3456
|
`tools: ${entry.tools.join(", ")}`,
|
|
3457
|
+
`kind: ${entry.kind}`,
|
|
3458
|
+
`errorText: ${entry.errorText ?? ""}`,
|
|
2034
3459
|
`firstSeen: ${entry.firstSeen}`,
|
|
2035
3460
|
`lastSeen: ${entry.lastSeen}`,
|
|
3461
|
+
`firstVersion: ${entry.firstVersion ?? ""}`,
|
|
3462
|
+
`lastVersion: ${entry.lastVersion ?? ""}`,
|
|
2036
3463
|
`occurrences: ${entry.occurrences}`,
|
|
2037
3464
|
`reported: ${entry.reported}`,
|
|
2038
|
-
`issueUrl: ${entry.issueUrl ?? ""}
|
|
2039
|
-
"---",
|
|
2040
|
-
"",
|
|
2041
|
-
`## ${SECTION_SYMPTOM}`,
|
|
2042
|
-
"",
|
|
2043
|
-
entry.symptom.trim(),
|
|
2044
|
-
""
|
|
3465
|
+
`issueUrl: ${entry.issueUrl ?? ""}`
|
|
2045
3466
|
];
|
|
3467
|
+
if (entry.archived) {
|
|
3468
|
+
lines.push("archived: true");
|
|
3469
|
+
lines.push(`archivedReason: ${oneLine(entry.archivedReason ?? "")}`);
|
|
3470
|
+
lines.push(`archivedAt: ${entry.archivedAt ?? ""}`);
|
|
3471
|
+
}
|
|
3472
|
+
lines.push("---", "", `## ${SECTION_SYMPTOM}`, "", entry.symptom.trim(), "");
|
|
2046
3473
|
if (entry.cause && entry.cause.trim().length > 0) {
|
|
2047
3474
|
lines.push(`## ${SECTION_CAUSE}`, "", entry.cause.trim(), "");
|
|
2048
3475
|
}
|
|
@@ -2063,7 +3490,11 @@ function readSections(body) {
|
|
|
2063
3490
|
});
|
|
2064
3491
|
return sections;
|
|
2065
3492
|
}
|
|
2066
|
-
function
|
|
3493
|
+
function parseKind(raw) {
|
|
3494
|
+
const kind = (raw ?? "").trim().toLowerCase();
|
|
3495
|
+
return ISSUE_KINDS.includes(kind) ? kind : "tool-bug";
|
|
3496
|
+
}
|
|
3497
|
+
function parse(text, fallbackId, scope = "project") {
|
|
2067
3498
|
const meta = {};
|
|
2068
3499
|
let body = text;
|
|
2069
3500
|
const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
|
|
@@ -2076,16 +3507,26 @@ function parse(text, fallbackId) {
|
|
|
2076
3507
|
body = text.slice(fm[0].length);
|
|
2077
3508
|
}
|
|
2078
3509
|
const sections = readSections(body);
|
|
3510
|
+
const nonEmpty = (v) => v && v.length > 0 ? v : void 0;
|
|
2079
3511
|
const occurrences = Number.parseInt(meta.occurrences ?? "1", 10);
|
|
2080
3512
|
return {
|
|
2081
3513
|
id: meta.id || fallbackId,
|
|
2082
3514
|
title: meta.title || fallbackId.replace(/-/g, " "),
|
|
3515
|
+
// The folder decides this, never the file — see IssueEntry.scope.
|
|
3516
|
+
scope,
|
|
2083
3517
|
tools: (meta.tools ?? "").split(",").map((t) => t.trim()).filter((t) => t.length > 0),
|
|
3518
|
+
kind: parseKind(meta.kind),
|
|
3519
|
+
errorText: clipErrorText(meta.errorText),
|
|
2084
3520
|
firstSeen: meta.firstSeen || "",
|
|
2085
3521
|
lastSeen: meta.lastSeen || meta.firstSeen || "",
|
|
3522
|
+
firstVersion: nonEmpty(meta.firstVersion),
|
|
3523
|
+
lastVersion: nonEmpty(meta.lastVersion),
|
|
2086
3524
|
occurrences: Number.isFinite(occurrences) && occurrences > 0 ? occurrences : 1,
|
|
2087
3525
|
reported: meta.reported === "true",
|
|
2088
|
-
issueUrl: meta.issueUrl
|
|
3526
|
+
issueUrl: nonEmpty(meta.issueUrl),
|
|
3527
|
+
archived: meta.archived === "true",
|
|
3528
|
+
archivedReason: nonEmpty(meta.archivedReason),
|
|
3529
|
+
archivedAt: nonEmpty(meta.archivedAt),
|
|
2089
3530
|
// A hand-edited file with no recognised headings still has its text kept,
|
|
2090
3531
|
// rather than being silently reduced to an empty entry.
|
|
2091
3532
|
symptom: sections.get(SECTION_SYMPTOM.toLowerCase()) ?? (sections.size === 0 ? body.trim() : ""),
|
|
@@ -2093,135 +3534,673 @@ function parse(text, fallbackId) {
|
|
|
2093
3534
|
workaround: sections.get(SECTION_WORKAROUND.toLowerCase()) ?? ""
|
|
2094
3535
|
};
|
|
2095
3536
|
}
|
|
2096
|
-
function readEntry(file) {
|
|
3537
|
+
function readEntry(file, scope) {
|
|
2097
3538
|
try {
|
|
2098
|
-
return parse(
|
|
3539
|
+
return parse(fs9.readFileSync(file, "utf8"), path10.basename(file, ".md"), scope);
|
|
2099
3540
|
} catch {
|
|
2100
3541
|
return null;
|
|
2101
3542
|
}
|
|
2102
3543
|
}
|
|
3544
|
+
function writeEntry(entry, journal) {
|
|
3545
|
+
ensureJournalDir(journal);
|
|
3546
|
+
const file = entryPath(entry.id, journal);
|
|
3547
|
+
fs9.writeFileSync(file, render(entry), "utf8");
|
|
3548
|
+
return file;
|
|
3549
|
+
}
|
|
3550
|
+
function parseVersion(v) {
|
|
3551
|
+
const m = /^\s*v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(v);
|
|
3552
|
+
if (!m) return null;
|
|
3553
|
+
return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null };
|
|
3554
|
+
}
|
|
3555
|
+
function compareVersions(a, b) {
|
|
3556
|
+
const pa = parseVersion(a);
|
|
3557
|
+
const pb = parseVersion(b);
|
|
3558
|
+
if (!pa || !pb) return null;
|
|
3559
|
+
for (let i = 0; i < 3; i++) {
|
|
3560
|
+
if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] < pb.nums[i] ? -1 : 1;
|
|
3561
|
+
}
|
|
3562
|
+
if (pa.pre && !pb.pre) return -1;
|
|
3563
|
+
if (!pa.pre && pb.pre) return 1;
|
|
3564
|
+
if (pa.pre && pb.pre) return pa.pre < pb.pre ? -1 : pa.pre > pb.pre ? 1 : 0;
|
|
3565
|
+
return 0;
|
|
3566
|
+
}
|
|
3567
|
+
function daysBetween(from, to) {
|
|
3568
|
+
const a = Date.parse(from);
|
|
3569
|
+
const b = Date.parse(to);
|
|
3570
|
+
if (Number.isNaN(a) || Number.isNaN(b)) return null;
|
|
3571
|
+
return Math.floor((b - a) / 864e5);
|
|
3572
|
+
}
|
|
3573
|
+
function archiveContext() {
|
|
3574
|
+
return { today: today(), version: packageVersion() };
|
|
3575
|
+
}
|
|
3576
|
+
function archiveReason(entry, ctx) {
|
|
3577
|
+
if (entry.archived) return entry.archivedReason || "archived by hand";
|
|
3578
|
+
const age = daysBetween(entry.lastSeen, ctx.today);
|
|
3579
|
+
if (age !== null && age >= ARCHIVE_AFTER_DAYS) return `not seen for ${age} days`;
|
|
3580
|
+
if (entry.kind !== "ae-quirk" && entry.lastVersion) {
|
|
3581
|
+
const cmp = compareVersions(entry.lastVersion, ctx.version);
|
|
3582
|
+
if (cmp !== null && cmp < 0) {
|
|
3583
|
+
return `last seen on ${entry.lastVersion}; this server is ${ctx.version}, so it is presumed fixed until seen again`;
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3586
|
+
return null;
|
|
3587
|
+
}
|
|
3588
|
+
function toolsOverlap(a, b) {
|
|
3589
|
+
const set = new Set(a.map((t) => t.trim().toLowerCase()));
|
|
3590
|
+
return b.some((t) => set.has(t.trim().toLowerCase()));
|
|
3591
|
+
}
|
|
3592
|
+
function uniqueTools(...lists) {
|
|
3593
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3594
|
+
const out = [];
|
|
3595
|
+
for (const list of lists) {
|
|
3596
|
+
for (const t of list ?? []) {
|
|
3597
|
+
const key = t.trim().toLowerCase();
|
|
3598
|
+
if (key.length === 0 || seen.has(key)) continue;
|
|
3599
|
+
seen.add(key);
|
|
3600
|
+
out.push(t.trim());
|
|
3601
|
+
}
|
|
3602
|
+
}
|
|
3603
|
+
return out;
|
|
3604
|
+
}
|
|
3605
|
+
var DECORATION_CUTS = [
|
|
3606
|
+
/(?:^|\s)at line \d+ of /,
|
|
3607
|
+
/(?:^|\s)After Effects reported line/,
|
|
3608
|
+
/(?:^|\s)Everything before the failure already ran/,
|
|
3609
|
+
/\s\|\|\s*Changed before it stopped/,
|
|
3610
|
+
/(?:^|\s)Known from earlier sessions:/
|
|
3611
|
+
];
|
|
3612
|
+
function stripDecorations(text) {
|
|
3613
|
+
let out = text.replace(/^\s*AE\s*:\s*/i, "");
|
|
3614
|
+
for (const cut of DECORATION_CUTS) {
|
|
3615
|
+
const m = cut.exec(out);
|
|
3616
|
+
if (m) out = out.slice(0, m.index);
|
|
3617
|
+
}
|
|
3618
|
+
return out.replace(/\s*\(line \d+\)\s*$/, "").trim();
|
|
3619
|
+
}
|
|
3620
|
+
function normalizeErrorText(text, opts = {}) {
|
|
3621
|
+
let t = stripDecorations(text).toLowerCase().replace(/\b[a-z]:\\[^\s'"`]+/g, " ").replace(/file:\/\/[^\s'"`]+/g, " ").replace(/(?:^|[\s(=:,])\/[^\s'"`)]+/g, " ");
|
|
3622
|
+
if (!opts.keepQuotes) {
|
|
3623
|
+
t = t.replace(/"[^"\n]*"|`[^`\n]*`/g, " ").replace(/(?<![a-z])'[^'\n]*'(?![a-z])/g, " ");
|
|
3624
|
+
}
|
|
3625
|
+
return t.replace(/[^a-z]+/g, "");
|
|
3626
|
+
}
|
|
3627
|
+
var MATCH_MIN_CHARS = 20;
|
|
3628
|
+
var MATCH_PREFIX_CHARS = 60;
|
|
3629
|
+
function errorTextsMatch(a, b) {
|
|
3630
|
+
if (!a || !b) return false;
|
|
3631
|
+
const na = normalizeErrorText(a);
|
|
3632
|
+
const nb = normalizeErrorText(b);
|
|
3633
|
+
if (na.length === 0 || nb.length === 0) return false;
|
|
3634
|
+
if (na === nb) return true;
|
|
3635
|
+
const shorter = Math.min(na.length, nb.length);
|
|
3636
|
+
if (shorter >= MATCH_MIN_CHARS && (na.startsWith(nb) || nb.startsWith(na))) return true;
|
|
3637
|
+
return shorter >= MATCH_PREFIX_CHARS && na.slice(0, MATCH_PREFIX_CHARS) === nb.slice(0, MATCH_PREFIX_CHARS);
|
|
3638
|
+
}
|
|
3639
|
+
function entryMatchesFailure(entry, tool, errorText) {
|
|
3640
|
+
if (!toolsOverlap(entry.tools, [tool])) return false;
|
|
3641
|
+
if (entry.errorText) return errorTextsMatch(entry.errorText, errorText);
|
|
3642
|
+
const needle = normalizeErrorText(errorText).slice(0, MATCH_PREFIX_CHARS);
|
|
3643
|
+
if (needle.length < MATCH_MIN_CHARS) return false;
|
|
3644
|
+
return normalizeErrorText(entry.symptom, { keepQuotes: true }).includes(needle) || normalizeErrorText(entry.symptom).includes(needle);
|
|
3645
|
+
}
|
|
2103
3646
|
function logIssue(input) {
|
|
2104
|
-
const
|
|
2105
|
-
const
|
|
2106
|
-
const
|
|
3647
|
+
const journal = journalFor(input.scope ?? "project");
|
|
3648
|
+
const version = packageVersion();
|
|
3649
|
+
const tools = uniqueTools(input.tools);
|
|
3650
|
+
let id = slugify(input.title);
|
|
3651
|
+
let existing = fs9.existsSync(entryPath(id, journal)) ? readEntry(entryPath(id, journal), journal.scope) : null;
|
|
3652
|
+
let mergedBy;
|
|
3653
|
+
if (existing) {
|
|
3654
|
+
mergedBy = "title";
|
|
3655
|
+
} else if (input.errorText && tools.length > 0) {
|
|
3656
|
+
const errorText = input.errorText;
|
|
3657
|
+
const twin = readJournalEntries(journal).find(
|
|
3658
|
+
(e) => e.id !== id && tools.some((t) => entryMatchesFailure(e, t, errorText))
|
|
3659
|
+
);
|
|
3660
|
+
if (twin) {
|
|
3661
|
+
existing = twin;
|
|
3662
|
+
id = twin.id;
|
|
3663
|
+
mergedBy = "errorText";
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3666
|
+
const reopened = existing?.archived === true;
|
|
3667
|
+
const incomingError = clipErrorText(input.errorText);
|
|
2107
3668
|
const entry = {
|
|
2108
3669
|
id,
|
|
2109
|
-
title:
|
|
2110
|
-
|
|
3670
|
+
// On an error-text merge the existing title stays: it is the id, and the
|
|
3671
|
+
// one the index has been showing.
|
|
3672
|
+
title: mergedBy === "errorText" ? existing.title : oneLine(input.title),
|
|
3673
|
+
scope: journal.scope,
|
|
3674
|
+
tools: uniqueTools(existing?.tools, tools),
|
|
3675
|
+
kind: input.kind ?? existing?.kind ?? "tool-bug",
|
|
3676
|
+
// The recorded text is the one that has been matching, so a re-log of the
|
|
3677
|
+
// same error — a different number, a different name, pasted with its
|
|
3678
|
+
// decorations — leaves it alone. Only a genuinely different error under
|
|
3679
|
+
// this title replaces it: that is the caller's statement, not a paste.
|
|
3680
|
+
errorText: incomingError && existing?.errorText && errorTextsMatch(existing.errorText, incomingError) ? existing.errorText : incomingError ?? existing?.errorText,
|
|
2111
3681
|
firstSeen: existing?.firstSeen || today(),
|
|
2112
3682
|
lastSeen: today(),
|
|
3683
|
+
firstVersion: existing?.firstVersion || version,
|
|
3684
|
+
lastVersion: version,
|
|
2113
3685
|
// Repeats are worth counting: an entry seen five times is the one most
|
|
2114
3686
|
// worth reporting, and the count is the only evidence of that.
|
|
2115
3687
|
occurrences: (existing?.occurrences ?? 0) + 1,
|
|
2116
3688
|
// Reporting state belongs to the entry, not to this sighting — a fresh
|
|
2117
|
-
// description of a known problem must not un-report it.
|
|
3689
|
+
// description of a known problem must not un-report it. And it belongs to
|
|
3690
|
+
// the entry *in this scope*: the two journals are separate records of
|
|
3691
|
+
// separate claims, so reporting one says nothing about the other.
|
|
2118
3692
|
reported: existing?.reported ?? false,
|
|
2119
3693
|
issueUrl: existing?.issueUrl,
|
|
3694
|
+
// A deliberate new sighting outranks a retirement: the entry was archived
|
|
3695
|
+
// as gone, and here it is. The flag clears and the result says so.
|
|
3696
|
+
archived: false,
|
|
2120
3697
|
symptom: input.symptom,
|
|
2121
3698
|
// A cause worked out once is not lost because a later sighting was logged
|
|
2122
3699
|
// without one.
|
|
2123
3700
|
cause: input.cause ?? existing?.cause,
|
|
2124
3701
|
workaround: input.workaround
|
|
2125
3702
|
};
|
|
2126
|
-
|
|
2127
|
-
|
|
3703
|
+
const file = writeEntry(entry, journal);
|
|
3704
|
+
const alsoIn = journals().filter((j) => j.scope !== journal.scope).filter((j) => fs9.existsSync(entryPath(id, j))).map((j) => j.scope);
|
|
2128
3705
|
return {
|
|
2129
3706
|
id,
|
|
3707
|
+
title: entry.title,
|
|
2130
3708
|
path: file,
|
|
3709
|
+
scope: journal.scope,
|
|
3710
|
+
kind: entry.kind,
|
|
2131
3711
|
occurrences: entry.occurrences,
|
|
2132
3712
|
previouslyLogged: existing !== null,
|
|
3713
|
+
...mergedBy ? { mergedBy } : {},
|
|
2133
3714
|
reported: entry.reported,
|
|
2134
|
-
issueUrl: entry.issueUrl
|
|
3715
|
+
issueUrl: entry.issueUrl,
|
|
3716
|
+
lastVersion: version,
|
|
3717
|
+
...reopened ? { reopened: true, previousArchiveReason: existing?.archivedReason } : {},
|
|
3718
|
+
...alsoIn.length > 0 ? { alsoIn } : {},
|
|
3719
|
+
...mergedBy === "errorText" ? {
|
|
3720
|
+
note: `Merged into the existing entry "${entry.title}" (${journal.scope}:${id}): same tool, same error text. The title you passed was not used \u2014 reuse this one to extend it again.`
|
|
3721
|
+
} : {}
|
|
2135
3722
|
};
|
|
2136
3723
|
}
|
|
2137
|
-
var
|
|
2138
|
-
function
|
|
2139
|
-
|
|
2140
|
-
return text.length > SUMMARY_CHARS ? `${text.slice(0, SUMMARY_CHARS).trimEnd()}\u2026` : text;
|
|
3724
|
+
var DEFAULT_LIMIT = 50;
|
|
3725
|
+
function withVerdict(e, reason) {
|
|
3726
|
+
return reason === null ? { ...e, archived: false, archivedReason: void 0 } : { ...e, archived: true, archivedReason: reason };
|
|
2141
3727
|
}
|
|
2142
|
-
function toIndexEntry(e) {
|
|
3728
|
+
function toIndexEntry(e, reason) {
|
|
2143
3729
|
return {
|
|
2144
3730
|
id: e.id,
|
|
2145
3731
|
title: e.title,
|
|
3732
|
+
scope: e.scope,
|
|
2146
3733
|
tools: e.tools,
|
|
3734
|
+
kind: e.kind,
|
|
2147
3735
|
lastSeen: e.lastSeen,
|
|
3736
|
+
...e.lastVersion ? { lastVersion: e.lastVersion } : {},
|
|
2148
3737
|
occurrences: e.occurrences,
|
|
2149
3738
|
reported: e.reported,
|
|
2150
|
-
|
|
3739
|
+
...reason !== null ? { archived: true, archivedReason: reason } : {}
|
|
2151
3740
|
};
|
|
2152
3741
|
}
|
|
2153
|
-
function
|
|
2154
|
-
const dir =
|
|
3742
|
+
function readJournalEntries(journal) {
|
|
3743
|
+
const dir = issuesDir(journal);
|
|
2155
3744
|
try {
|
|
2156
|
-
return
|
|
3745
|
+
return fs9.readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => readEntry(path10.join(dir, f), journal.scope)).filter((e) => e !== null);
|
|
2157
3746
|
} catch {
|
|
2158
3747
|
return [];
|
|
2159
3748
|
}
|
|
2160
3749
|
}
|
|
3750
|
+
function selectedJournals(filter) {
|
|
3751
|
+
if (filter === "all") return journals();
|
|
3752
|
+
if (filter === "user") return journals().filter((j) => j.scope === "user");
|
|
3753
|
+
return journals().filter((j) => j.scope === "project" || j.scope === "home");
|
|
3754
|
+
}
|
|
3755
|
+
function readAllEntries(filter = "all") {
|
|
3756
|
+
return selectedJournals(filter).flatMap(readJournalEntries);
|
|
3757
|
+
}
|
|
3758
|
+
var SCOPE_PREFIX = /^(project|home|user)\s*:\s*(.+)$/i;
|
|
3759
|
+
function resolveEntry(raw, entries) {
|
|
3760
|
+
const qualified = SCOPE_PREFIX.exec(raw.trim());
|
|
3761
|
+
if (qualified) {
|
|
3762
|
+
const scope = qualified[1].toLowerCase();
|
|
3763
|
+
const slug2 = slugify(qualified[2]);
|
|
3764
|
+
const inScope = (e) => e.scope === scope || scope === "project" && e.scope === "home";
|
|
3765
|
+
const hit = entries.find((e) => inScope(e) && e.id === slug2);
|
|
3766
|
+
if (hit) return { found: hit, alsoIn: [] };
|
|
3767
|
+
}
|
|
3768
|
+
const slug = slugify(raw);
|
|
3769
|
+
const matches = entries.filter((e) => e.id === slug);
|
|
3770
|
+
if (matches.length === 0) return null;
|
|
3771
|
+
return { found: matches[0], alsoIn: matches.slice(1).map((e) => e.scope) };
|
|
3772
|
+
}
|
|
3773
|
+
function knownIds(entries) {
|
|
3774
|
+
return entries.map((e) => `${e.scope}:${e.id}`).join(", ");
|
|
3775
|
+
}
|
|
2161
3776
|
function matchesQuery(entry, terms) {
|
|
2162
3777
|
const haystack = `${entry.title} ${entry.symptom} ${entry.tools.join(" ")}`.toLowerCase();
|
|
2163
3778
|
return terms.every((t) => haystack.includes(t));
|
|
2164
3779
|
}
|
|
2165
3780
|
function listIssues(options = {}) {
|
|
2166
|
-
const
|
|
2167
|
-
const
|
|
2168
|
-
const
|
|
3781
|
+
const project = journalRoot();
|
|
3782
|
+
const filter = options.scope ?? "all";
|
|
3783
|
+
const consulted = selectedJournals(filter);
|
|
3784
|
+
const perJournal = consulted.map((j) => ({ journal: j, entries: readJournalEntries(j) }));
|
|
3785
|
+
const entries = perJournal.flatMap((p) => p.entries);
|
|
3786
|
+
const ctx = options.context ?? archiveContext();
|
|
2169
3787
|
const envelope = {
|
|
2170
|
-
dir,
|
|
2171
|
-
scope,
|
|
3788
|
+
dir: issuesDir(project),
|
|
3789
|
+
scope: project.scope,
|
|
3790
|
+
journals: perJournal.map(({ journal, entries: e }) => ({
|
|
3791
|
+
scope: journal.scope,
|
|
3792
|
+
dir: issuesDir(journal),
|
|
3793
|
+
count: e.length
|
|
3794
|
+
})),
|
|
2172
3795
|
repo: REPO,
|
|
2173
3796
|
newIssueUrl: NEW_ISSUE_URL,
|
|
2174
|
-
serverVersion:
|
|
3797
|
+
serverVersion: ctx.version,
|
|
2175
3798
|
platform: process.platform
|
|
2176
3799
|
};
|
|
2177
3800
|
const wantedId = options.id?.trim();
|
|
2178
3801
|
if (wantedId) {
|
|
2179
|
-
const
|
|
2180
|
-
if (!
|
|
3802
|
+
const resolved = resolveEntry(wantedId, entries);
|
|
3803
|
+
if (!resolved) {
|
|
2181
3804
|
throw new Error(
|
|
2182
|
-
`No journal entry with id "${wantedId}".` + (entries.length > 0 ? ` Known ids: ${entries
|
|
3805
|
+
`No journal entry with id "${wantedId}".` + (entries.length > 0 ? ` Known ids: ${knownIds(entries)}` : " The journal is empty.")
|
|
2183
3806
|
);
|
|
2184
3807
|
}
|
|
2185
|
-
return {
|
|
3808
|
+
return {
|
|
3809
|
+
...envelope,
|
|
3810
|
+
detail: "full",
|
|
3811
|
+
count: 1,
|
|
3812
|
+
issues: [withVerdict(resolved.found, archiveReason(resolved.found, ctx))],
|
|
3813
|
+
// Same slug in the other journal: say so, and name the call that opens it,
|
|
3814
|
+
// rather than letting one of the two silently win.
|
|
3815
|
+
...resolved.alsoIn.length > 0 ? {
|
|
3816
|
+
next: `Also written down in the ${resolved.alsoIn.join(" and ")} journal \u2014 list_known_issues({ id: "${resolved.alsoIn[0]}:${resolved.found.id}" }) reads that one.`
|
|
3817
|
+
} : {}
|
|
3818
|
+
};
|
|
2186
3819
|
}
|
|
2187
3820
|
const status = options.status ?? "all";
|
|
2188
3821
|
const wanted = options.tool?.trim().toLowerCase();
|
|
2189
3822
|
const terms = (options.query ?? "").toLowerCase().split(/\s+/).filter((t) => t.length > 0);
|
|
2190
|
-
const
|
|
3823
|
+
const namesTool = (e) => wanted !== void 0 && e.tools.some((t) => t.toLowerCase() === wanted);
|
|
3824
|
+
const matched = entries.filter((e) => {
|
|
2191
3825
|
const byStatus = status === "all" ? true : status === "reported" ? e.reported : !e.reported;
|
|
2192
3826
|
if (!byStatus) return false;
|
|
2193
3827
|
if (terms.length > 0 && !matchesQuery(e, terms)) return false;
|
|
2194
3828
|
if (!wanted) return true;
|
|
2195
|
-
return e
|
|
3829
|
+
return namesTool(e) || e.title.toLowerCase().includes(wanted);
|
|
2196
3830
|
});
|
|
2197
|
-
|
|
3831
|
+
const verdicts = new Map(matched.map((e) => [e, archiveReason(e, ctx)]));
|
|
3832
|
+
const archivedCount = matched.filter((e) => verdicts.get(e) !== null).length;
|
|
3833
|
+
const filtered = options.includeArchived ? matched : matched.filter((e) => verdicts.get(e) === null);
|
|
3834
|
+
filtered.sort(
|
|
3835
|
+
(a, b) => Number(namesTool(b)) - Number(namesTool(a)) || b.lastSeen.localeCompare(a.lastSeen) || b.occurrences - a.occurrences
|
|
3836
|
+
);
|
|
3837
|
+
const limit = options.limit && options.limit > 0 ? options.limit : DEFAULT_LIMIT;
|
|
3838
|
+
const shown = filtered.slice(0, limit);
|
|
3839
|
+
const omitted = filtered.length - shown.length;
|
|
2198
3840
|
const detail = options.detail ?? "index";
|
|
3841
|
+
const first = shown[0];
|
|
3842
|
+
const hidden = options.includeArchived ? 0 : archivedCount;
|
|
3843
|
+
const nextParts = [];
|
|
3844
|
+
if (detail === "index" && first) {
|
|
3845
|
+
nextParts.push(
|
|
3846
|
+
`list_known_issues({ id: "${first.scope}:${first.id}" }) for the cause and the workaround. Prefix any id from this list with its own scope.`
|
|
3847
|
+
);
|
|
3848
|
+
}
|
|
3849
|
+
if (omitted > 0) nextParts.push(`${omitted} more matched \u2014 narrow with tool/query, or raise limit.`);
|
|
3850
|
+
if (hidden > 0) {
|
|
3851
|
+
nextParts.push(
|
|
3852
|
+
`${hidden} archived ${hidden === 1 ? "entry is" : "entries are"} hidden (not seen for ${ARCHIVE_AFTER_DAYS} days, last seen on an older server, or retired with archive_issue) \u2014 includeArchived: true lists them.`
|
|
3853
|
+
);
|
|
3854
|
+
}
|
|
2199
3855
|
return {
|
|
2200
3856
|
...envelope,
|
|
2201
3857
|
detail,
|
|
2202
3858
|
count: filtered.length,
|
|
2203
|
-
|
|
3859
|
+
archivedCount,
|
|
3860
|
+
issues: detail === "full" ? shown.map((e) => withVerdict(e, verdicts.get(e) ?? null)) : shown.map((e) => toIndexEntry(e, verdicts.get(e) ?? null)),
|
|
3861
|
+
// Truncation is named and counted. A short answer that looked complete
|
|
3862
|
+
// would be the same class of lie as a swallowed error.
|
|
3863
|
+
...omitted > 0 ? { omitted } : {},
|
|
2204
3864
|
// The reason to read this journal is that something failed, so an index
|
|
2205
3865
|
// that stopped short of the workaround would be worse than useless. Say
|
|
2206
|
-
// how to reach it, every time there is one to reach
|
|
2207
|
-
|
|
3866
|
+
// how to reach it, every time there is one to reach — and since ids are
|
|
3867
|
+
// only unique within a journal, spell the scope-qualified form out on a
|
|
3868
|
+
// real entry rather than leaving the caller to guess the syntax.
|
|
3869
|
+
...nextParts.length > 0 ? { next: nextParts.join(" ") } : {}
|
|
2208
3870
|
};
|
|
2209
3871
|
}
|
|
2210
|
-
function
|
|
2211
|
-
const
|
|
2212
|
-
const
|
|
2213
|
-
if (!
|
|
2214
|
-
const known = readAllEntries().map((e) => e.id);
|
|
3872
|
+
function locate(id) {
|
|
3873
|
+
const entries = readAllEntries();
|
|
3874
|
+
const resolved = resolveEntry(id, entries);
|
|
3875
|
+
if (!resolved) {
|
|
2215
3876
|
throw new Error(
|
|
2216
|
-
`No journal entry with id "${id}".` + (
|
|
3877
|
+
`No journal entry with id "${id}".` + (entries.length > 0 ? ` Known ids: ${knownIds(entries)}` : "")
|
|
2217
3878
|
);
|
|
2218
3879
|
}
|
|
3880
|
+
const entry = resolved.found;
|
|
3881
|
+
const journal = journals().find((j) => j.scope === entry.scope);
|
|
3882
|
+
if (!journal) throw new Error(`Journal for scope "${entry.scope}" is no longer readable.`);
|
|
3883
|
+
return { entry, journal };
|
|
3884
|
+
}
|
|
3885
|
+
function markReported(id, url) {
|
|
3886
|
+
const { entry, journal } = locate(id);
|
|
2219
3887
|
entry.reported = true;
|
|
2220
3888
|
if (url) entry.issueUrl = oneLine(url);
|
|
2221
|
-
|
|
3889
|
+
writeEntry(entry, journal);
|
|
3890
|
+
return entry;
|
|
3891
|
+
}
|
|
3892
|
+
function archiveIssue(id, reason) {
|
|
3893
|
+
const { entry, journal } = locate(id);
|
|
3894
|
+
entry.archived = true;
|
|
3895
|
+
entry.archivedReason = oneLine(reason);
|
|
3896
|
+
entry.archivedAt = today();
|
|
3897
|
+
writeEntry(entry, journal);
|
|
2222
3898
|
return entry;
|
|
2223
3899
|
}
|
|
2224
3900
|
|
|
3901
|
+
// src/issues/failures.ts
|
|
3902
|
+
import fs10 from "node:fs";
|
|
3903
|
+
import path11 from "node:path";
|
|
3904
|
+
var MAX_MATCHES = 3;
|
|
3905
|
+
var JournalCache = class {
|
|
3906
|
+
files = /* @__PURE__ */ new Map();
|
|
3907
|
+
/** Files parsed since construction — the number the test watches to prove the cache is one. */
|
|
3908
|
+
reads = 0;
|
|
3909
|
+
entries(refs = journals()) {
|
|
3910
|
+
const out = [];
|
|
3911
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3912
|
+
for (const journal of refs) {
|
|
3913
|
+
const dir = issuesDir(journal);
|
|
3914
|
+
let names;
|
|
3915
|
+
try {
|
|
3916
|
+
names = fs10.readdirSync(dir).filter((f) => f.endsWith(".md"));
|
|
3917
|
+
} catch {
|
|
3918
|
+
continue;
|
|
3919
|
+
}
|
|
3920
|
+
for (const name of names) {
|
|
3921
|
+
const file = path11.join(dir, name);
|
|
3922
|
+
seen.add(file);
|
|
3923
|
+
let st;
|
|
3924
|
+
try {
|
|
3925
|
+
st = fs10.statSync(file);
|
|
3926
|
+
} catch {
|
|
3927
|
+
continue;
|
|
3928
|
+
}
|
|
3929
|
+
const cached = this.files.get(file);
|
|
3930
|
+
if (cached && cached.size === st.size && cached.mtimeMs === st.mtimeMs) {
|
|
3931
|
+
if (cached.entry) out.push(cached.entry);
|
|
3932
|
+
continue;
|
|
3933
|
+
}
|
|
3934
|
+
this.reads++;
|
|
3935
|
+
const entry = readEntry(file, journal.scope);
|
|
3936
|
+
this.files.set(file, { size: st.size, mtimeMs: st.mtimeMs, entry });
|
|
3937
|
+
if (entry) out.push(entry);
|
|
3938
|
+
}
|
|
3939
|
+
}
|
|
3940
|
+
for (const file of this.files.keys()) if (!seen.has(file)) this.files.delete(file);
|
|
3941
|
+
return out;
|
|
3942
|
+
}
|
|
3943
|
+
/**
|
|
3944
|
+
* After this module writes an entry, the cached copy is the one it wrote —
|
|
3945
|
+
* so the next lookup does not re-parse a file whose content it already holds
|
|
3946
|
+
* even on a filesystem whose mtime resolution would not show the write.
|
|
3947
|
+
*/
|
|
3948
|
+
refresh(file, entry) {
|
|
3949
|
+
try {
|
|
3950
|
+
const st = fs10.statSync(file);
|
|
3951
|
+
this.files.set(file, { size: st.size, mtimeMs: st.mtimeMs, entry });
|
|
3952
|
+
} catch {
|
|
3953
|
+
this.files.delete(file);
|
|
3954
|
+
}
|
|
3955
|
+
}
|
|
3956
|
+
};
|
|
3957
|
+
function matchFailure(cache, tool, errorText, ctx = archiveContext(), refs = journals()) {
|
|
3958
|
+
const hits = cache.entries(refs).filter((e) => entryMatchesFailure(e, tool, errorText)).sort((a, b) => b.lastSeen.localeCompare(a.lastSeen) || b.occurrences - a.occurrences);
|
|
3959
|
+
return {
|
|
3960
|
+
matches: hits.slice(0, MAX_MATCHES).map((entry) => ({ entry, archivedReason: archiveReason(entry, ctx) })),
|
|
3961
|
+
total: hits.length
|
|
3962
|
+
};
|
|
3963
|
+
}
|
|
3964
|
+
function describeMatches(found) {
|
|
3965
|
+
const lines = found.matches.map(({ entry, archivedReason }) => {
|
|
3966
|
+
const where = `${entry.scope}:${entry.id}`;
|
|
3967
|
+
const tail = archivedReason ? ` (archived: ${archivedReason})` : "";
|
|
3968
|
+
return `Known from earlier sessions: ${where} \u2014 ${entry.title}${tail}`;
|
|
3969
|
+
});
|
|
3970
|
+
const more = found.total - found.matches.length;
|
|
3971
|
+
if (more > 0) lines.push(`\u2026and ${more} more matching ${more === 1 ? "entry" : "entries"}.`);
|
|
3972
|
+
const first = found.matches[0];
|
|
3973
|
+
lines.push(
|
|
3974
|
+
`list_known_issues({ id: "${first.entry.scope}:${first.entry.id}" }) has the cause and the workaround.`
|
|
3975
|
+
);
|
|
3976
|
+
return lines.join("\n");
|
|
3977
|
+
}
|
|
3978
|
+
function bumpSighting(cache, match, ctx) {
|
|
3979
|
+
const { entry } = match;
|
|
3980
|
+
if (entry.lastSeen === ctx.today && entry.lastVersion === ctx.version) return;
|
|
3981
|
+
const journal = journals().find((j) => j.scope === entry.scope);
|
|
3982
|
+
if (!journal) return;
|
|
3983
|
+
try {
|
|
3984
|
+
entry.lastSeen = ctx.today;
|
|
3985
|
+
entry.lastVersion = ctx.version;
|
|
3986
|
+
const file = writeEntry(entry, journal);
|
|
3987
|
+
cache.refresh(file, entry);
|
|
3988
|
+
} catch (e) {
|
|
3989
|
+
logger.warn(`Could not update journal entry ${entry.scope}:${entry.id} after a matching failure: ${e.message}`);
|
|
3990
|
+
}
|
|
3991
|
+
}
|
|
3992
|
+
function annotateFailure(cache, tool, message, ctx) {
|
|
3993
|
+
try {
|
|
3994
|
+
const context = ctx ?? archiveContext();
|
|
3995
|
+
const found = matchFailure(cache, tool, message, context);
|
|
3996
|
+
if (found.matches.length === 0) return message;
|
|
3997
|
+
for (const match of found.matches) bumpSighting(cache, match, context);
|
|
3998
|
+
return `${message}
|
|
3999
|
+
|
|
4000
|
+
${describeMatches(found)}`;
|
|
4001
|
+
} catch (e) {
|
|
4002
|
+
logger.warn(`Journal lookup for a failed ${tool} call was skipped: ${e.message}`);
|
|
4003
|
+
return message;
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
|
|
4007
|
+
// src/style/summary.ts
|
|
4008
|
+
var HEX = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b/g;
|
|
4009
|
+
var SECTION_KEYWORDS = [
|
|
4010
|
+
{ key: "palette", words: ["palette", "colour", "color", "swatch"] },
|
|
4011
|
+
{ key: "type", words: ["type", "typograph", "font", "lettering", "typeface"] },
|
|
4012
|
+
{ key: "motion", words: ["motion", "animation", "timing", "easing", "ease", "transition"] },
|
|
4013
|
+
// "Rules" is in the template this project's own style-guide guide hands out,
|
|
4014
|
+
// and it holds constraints of exactly the kind the layout bullets carry. It
|
|
4015
|
+
// would otherwise be named as unsummarised on almost every guide written.
|
|
4016
|
+
{
|
|
4017
|
+
key: "layout",
|
|
4018
|
+
words: ["layout", "grid", "spacing", "composition", "margin", "safe area", "framing", "rule", "constraint"]
|
|
4019
|
+
}
|
|
4020
|
+
];
|
|
4021
|
+
var MAX_PALETTE = 24;
|
|
4022
|
+
var MAX_TYPE_LINES = 3;
|
|
4023
|
+
var MAX_LAYOUT_LINES = 8;
|
|
4024
|
+
var MAX_MOTION_CHARS = 240;
|
|
4025
|
+
var MAX_LINE_CHARS = 160;
|
|
4026
|
+
var MAX_SECTIONS_OMITTED = 12;
|
|
4027
|
+
var HEAD_CHARS = 900;
|
|
4028
|
+
function findHeadings(lines) {
|
|
4029
|
+
const headings = [];
|
|
4030
|
+
let fenced = false;
|
|
4031
|
+
let frontmatter = /^---\s*$/.test(lines[0] ?? "");
|
|
4032
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4033
|
+
const line = lines[i];
|
|
4034
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
4035
|
+
fenced = !fenced;
|
|
4036
|
+
continue;
|
|
4037
|
+
}
|
|
4038
|
+
if (fenced) continue;
|
|
4039
|
+
if (frontmatter) {
|
|
4040
|
+
if (i > 0 && /^---\s*$/.test(line)) frontmatter = false;
|
|
4041
|
+
continue;
|
|
4042
|
+
}
|
|
4043
|
+
const atx = /^ {0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
4044
|
+
if (atx) {
|
|
4045
|
+
headings.push({ level: atx[1].length, text: atx[2].trim(), from: i + 1 });
|
|
4046
|
+
continue;
|
|
4047
|
+
}
|
|
4048
|
+
const underline = /^ {0,3}(={3,}|-{3,})\s*$/.exec(line);
|
|
4049
|
+
if (underline && i > 0) {
|
|
4050
|
+
const above = lines[i - 1].trim();
|
|
4051
|
+
if (above.length > 0 && !/^ {0,3}#{1,6}\s/.test(above) && !/^[-=*_\s]+$/.test(above)) {
|
|
4052
|
+
headings.push({ level: underline[1].startsWith("=") ? 1 : 2, text: above, from: i + 1 });
|
|
4053
|
+
}
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
return headings;
|
|
4057
|
+
}
|
|
4058
|
+
function classifyAll(heading) {
|
|
4059
|
+
const text = heading.toLowerCase();
|
|
4060
|
+
const kinds = /* @__PURE__ */ new Set();
|
|
4061
|
+
for (const { key, words } of SECTION_KEYWORDS) {
|
|
4062
|
+
if (words.some((w) => text.includes(w))) kinds.add(key);
|
|
4063
|
+
}
|
|
4064
|
+
return kinds;
|
|
4065
|
+
}
|
|
4066
|
+
function ownLines(lines, headings, index) {
|
|
4067
|
+
const here = headings[index];
|
|
4068
|
+
const next = headings[index + 1];
|
|
4069
|
+
const end = next === void 0 ? lines.length : next.from - (isSetext(lines, next) ? 2 : 1);
|
|
4070
|
+
return lines.slice(here.from, Math.max(here.from, end));
|
|
4071
|
+
}
|
|
4072
|
+
function isSetext(lines, heading) {
|
|
4073
|
+
const underline = lines[heading.from - 1];
|
|
4074
|
+
return underline !== void 0 && /^ {0,3}(={3,}|-{3,})\s*$/.test(underline);
|
|
4075
|
+
}
|
|
4076
|
+
function condense(line) {
|
|
4077
|
+
let text = line.trim();
|
|
4078
|
+
if (/^\|/.test(text)) {
|
|
4079
|
+
const cells = text.split("|").map((c) => c.trim()).filter((c) => c.length > 0);
|
|
4080
|
+
if (cells.every((c) => /^:?-{2,}:?$/.test(c))) return "";
|
|
4081
|
+
text = cells.join(" \xB7 ");
|
|
4082
|
+
}
|
|
4083
|
+
text = text.replace(/^#{1,6}\s+/, "").replace(/^[-*+]\s+/, "").replace(/^\d+[.)]\s+/, "").replace(/^>\s?/, "").replace(/\*\*|__|`/g, "").trim();
|
|
4084
|
+
return text.length > MAX_LINE_CHARS ? `${text.slice(0, MAX_LINE_CHARS).trimEnd()}\u2026` : text;
|
|
4085
|
+
}
|
|
4086
|
+
function contentLines(lines) {
|
|
4087
|
+
return lines.map(condense).filter((l) => l.length > 0 && !/^```/.test(l));
|
|
4088
|
+
}
|
|
4089
|
+
function readPalette(lines) {
|
|
4090
|
+
const found = [];
|
|
4091
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4092
|
+
for (const raw of lines) {
|
|
4093
|
+
HEX.lastIndex = 0;
|
|
4094
|
+
const hexes = raw.match(HEX);
|
|
4095
|
+
if (!hexes) continue;
|
|
4096
|
+
const isTableRow = /^\s*\|/.test(raw);
|
|
4097
|
+
let name = "";
|
|
4098
|
+
if (isTableRow) {
|
|
4099
|
+
const cells = raw.split("|").map((c) => c.trim().replace(/\*\*|__|`/g, "").trim()).filter((c) => c.length > 0);
|
|
4100
|
+
name = cells.find((c) => !/^#[0-9a-fA-F]{3,8}$/.test(c) && !/^:?-{2,}:?$/.test(c)) ?? "";
|
|
4101
|
+
} else {
|
|
4102
|
+
name = raw.slice(0, raw.indexOf(hexes[0])).replace(/^\s*#{1,6}\s+/, "").replace(/^\s*[-*+]\s+/, "").replace(/^\s*\d+[.)]\s+/, "").replace(/^\s*>\s?/, "").replace(/\*\*|__|`/g, "").replace(/[|]/g, " ").replace(/\s+/g, " ").trim().replace(/[:=–—-]+$/, "").trim();
|
|
4103
|
+
}
|
|
4104
|
+
if (name.length > 40) name = `${name.slice(0, 40).trimEnd()}\u2026`;
|
|
4105
|
+
for (const hex of hexes) {
|
|
4106
|
+
const key = hex.toLowerCase();
|
|
4107
|
+
if (seen.has(key)) continue;
|
|
4108
|
+
seen.add(key);
|
|
4109
|
+
found.push({ name, hex });
|
|
4110
|
+
name = "";
|
|
4111
|
+
if (found.length >= MAX_PALETTE) return found;
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4114
|
+
return found;
|
|
4115
|
+
}
|
|
4116
|
+
function summarizeHouseStyle(content) {
|
|
4117
|
+
const lines = content.split(/\r\n|\r|\n/);
|
|
4118
|
+
const headings = findHeadings(lines);
|
|
4119
|
+
const buckets = { type: [], motion: [], layout: [] };
|
|
4120
|
+
const omitted = [];
|
|
4121
|
+
const absorbed = new Array(headings.length).fill(false);
|
|
4122
|
+
const ownCache = /* @__PURE__ */ new Map();
|
|
4123
|
+
const own = (i) => {
|
|
4124
|
+
let value = ownCache.get(i);
|
|
4125
|
+
if (!value) ownCache.set(i, value = contentLines(ownLines(lines, headings, i)));
|
|
4126
|
+
return value;
|
|
4127
|
+
};
|
|
4128
|
+
headings.forEach((heading, i) => {
|
|
4129
|
+
if (absorbed[i]) return;
|
|
4130
|
+
const kinds = classifyAll(heading.text);
|
|
4131
|
+
if (kinds.size === 0) {
|
|
4132
|
+
if (own(i).length > 0 && !omitted.includes(heading.text)) omitted.push(heading.text);
|
|
4133
|
+
return;
|
|
4134
|
+
}
|
|
4135
|
+
const body = [...own(i)];
|
|
4136
|
+
for (let j = i + 1; j < headings.length && headings[j].level > heading.level; j++) {
|
|
4137
|
+
if (classifyAll(headings[j].text).size > 0) break;
|
|
4138
|
+
absorbed[j] = true;
|
|
4139
|
+
const sub = own(j);
|
|
4140
|
+
if (sub.length === 0) continue;
|
|
4141
|
+
body.push(`${headings[j].text}: ${sub[0]}`, ...sub.slice(1));
|
|
4142
|
+
}
|
|
4143
|
+
const bucketed = [...kinds].filter((k) => k !== "palette");
|
|
4144
|
+
if (bucketed.length === 0 || body.length === 0) return;
|
|
4145
|
+
for (const kind of bucketed) buckets[kind].push(...body);
|
|
4146
|
+
});
|
|
4147
|
+
const palette = readPalette(lines);
|
|
4148
|
+
const type = buckets.type.slice(0, MAX_TYPE_LINES);
|
|
4149
|
+
const layout = buckets.layout.slice(0, MAX_LAYOUT_LINES);
|
|
4150
|
+
const motionJoined = buckets.motion.join("; ");
|
|
4151
|
+
const motion = motionJoined.length > MAX_MOTION_CHARS ? `${motionJoined.slice(0, MAX_MOTION_CHARS).trimEnd()}\u2026` : motionJoined;
|
|
4152
|
+
const structured = palette.length > 0 || type.length > 0 || layout.length > 0 || motion.length > 0;
|
|
4153
|
+
const chars = content.length;
|
|
4154
|
+
if (!structured) {
|
|
4155
|
+
const head = content.trim().slice(0, HEAD_CHARS);
|
|
4156
|
+
return {
|
|
4157
|
+
summary: {
|
|
4158
|
+
structured: false,
|
|
4159
|
+
palette: [],
|
|
4160
|
+
type: [],
|
|
4161
|
+
motion: "",
|
|
4162
|
+
layout: [],
|
|
4163
|
+
head: head + (content.trim().length > head.length ? "\u2026" : ""),
|
|
4164
|
+
...omitted.length > 0 ? { sectionsOmitted: omitted.slice(0, MAX_SECTIONS_OMITTED) } : {}
|
|
4165
|
+
},
|
|
4166
|
+
note: `This style guide has no palette, type, motion or layout section this summariser could recognise, so the text above is the opening of the document verbatim and nothing has been interpreted. Call get_house_style({ detail: "full" }) to read all ${chars} characters.`
|
|
4167
|
+
};
|
|
4168
|
+
}
|
|
4169
|
+
const dropped = [];
|
|
4170
|
+
if (buckets.type.length > type.length) dropped.push(`${buckets.type.length - type.length} more type lines`);
|
|
4171
|
+
if (buckets.layout.length > layout.length)
|
|
4172
|
+
dropped.push(`${buckets.layout.length - layout.length} more layout lines`);
|
|
4173
|
+
if (omitted.length > 0) dropped.push(`sections: ${omitted.slice(0, MAX_SECTIONS_OMITTED).join(", ")}`);
|
|
4174
|
+
return {
|
|
4175
|
+
summary: {
|
|
4176
|
+
structured: true,
|
|
4177
|
+
palette,
|
|
4178
|
+
type,
|
|
4179
|
+
motion,
|
|
4180
|
+
layout,
|
|
4181
|
+
...omitted.length > 0 ? { sectionsOmitted: omitted.slice(0, MAX_SECTIONS_OMITTED) } : {}
|
|
4182
|
+
},
|
|
4183
|
+
note: `This is a summary, not the style guide. The document is ${chars} characters; call get_house_style({ detail: "full" }) for all of it before editing it or when a detail here is not enough.` + (dropped.length > 0 ? ` Not summarised \u2014 ${dropped.join("; ")}.` : "")
|
|
4184
|
+
};
|
|
4185
|
+
}
|
|
4186
|
+
function applyHouseStyleDetail(result, detail) {
|
|
4187
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) return result;
|
|
4188
|
+
const r = result;
|
|
4189
|
+
if (typeof r.content !== "string" || r.content.length === 0) return result;
|
|
4190
|
+
if (detail === "full") return { ...r, detail: "full" };
|
|
4191
|
+
const { summary, note } = summarizeHouseStyle(r.content);
|
|
4192
|
+
const { content, ...rest } = r;
|
|
4193
|
+
return {
|
|
4194
|
+
...rest,
|
|
4195
|
+
detail: "summary",
|
|
4196
|
+
// The caller has to be able to judge whether the full read is worth it.
|
|
4197
|
+
characters: content.length,
|
|
4198
|
+
lines: content.split(/\r\n|\r|\n/).length,
|
|
4199
|
+
summary,
|
|
4200
|
+
note
|
|
4201
|
+
};
|
|
4202
|
+
}
|
|
4203
|
+
|
|
2225
4204
|
// src/util/pngImage.ts
|
|
2226
4205
|
function imageContent(meta, base64) {
|
|
2227
4206
|
return {
|
|
@@ -2247,15 +4226,21 @@ var SERVER_OPS = /* @__PURE__ */ new Set([
|
|
|
2247
4226
|
"ae_guide",
|
|
2248
4227
|
"log_issue",
|
|
2249
4228
|
"list_known_issues",
|
|
2250
|
-
"mark_issue_reported"
|
|
4229
|
+
"mark_issue_reported",
|
|
4230
|
+
"archive_issue"
|
|
2251
4231
|
]);
|
|
4232
|
+
var SNAPSHOT_OPS = /* @__PURE__ */ new Set(["snapshot_comp", "diff_comp"]);
|
|
4233
|
+
function isWriteOp(name) {
|
|
4234
|
+
const effect = schemas_exports.OpMutation[name];
|
|
4235
|
+
return effect === void 0 ? true : effect === "write";
|
|
4236
|
+
}
|
|
2252
4237
|
var GUIDE_URI_PREFIX = "ae://guide/";
|
|
2253
4238
|
var AwaitJobSchema = schemas_exports.AwaitJob;
|
|
2254
4239
|
var GetJobSchema = schemas_exports.GetJob;
|
|
2255
4240
|
var CancelJobSchema = schemas_exports.CancelJob;
|
|
2256
|
-
function createServer() {
|
|
4241
|
+
function createServer(opts = {}) {
|
|
2257
4242
|
const server = new Server(
|
|
2258
|
-
{ name: "after-effects-mcp", version: "0.
|
|
4243
|
+
{ name: "after-effects-mcp", version: "0.5.0" },
|
|
2259
4244
|
{
|
|
2260
4245
|
capabilities: { tools: {}, logging: {}, prompts: {}, resources: {} },
|
|
2261
4246
|
// Clients that honour this fold it into the system prompt, which is the
|
|
@@ -2264,28 +4249,33 @@ function createServer() {
|
|
|
2264
4249
|
instructions: SERVER_INSTRUCTIONS
|
|
2265
4250
|
}
|
|
2266
4251
|
);
|
|
2267
|
-
const bridge = new HttpClient();
|
|
4252
|
+
const bridge = opts.bridge ?? new HttpClient();
|
|
2268
4253
|
const jobs = new JobManager();
|
|
2269
|
-
const
|
|
4254
|
+
const writes = new WriteQueue();
|
|
4255
|
+
const snapshots = new SnapshotStore();
|
|
4256
|
+
const ws = new WsClient(bridge, jobs);
|
|
2270
4257
|
ws.start();
|
|
2271
4258
|
const panelGate = createPanelGate(bridge);
|
|
4259
|
+
const journalCache = new JournalCache();
|
|
4260
|
+
const fail = (tool, message) => errorResult(annotateFailure(journalCache, tool, message));
|
|
2272
4261
|
bridge.health().then(
|
|
2273
4262
|
(h) => logger.info(`Bridge healthy on port ${h.port}`),
|
|
2274
|
-
(e) =>
|
|
4263
|
+
async (e) => {
|
|
4264
|
+
logger.warn(`Bridge not reachable yet: ${e.message}`);
|
|
4265
|
+
if (!(e instanceof BridgeUnreachableError)) return;
|
|
4266
|
+
const located = await bridge.rediscover().catch(() => null);
|
|
4267
|
+
if (located?.found && located.found.port !== bridge.port) {
|
|
4268
|
+
await bridge.switchPort(located.found.port);
|
|
4269
|
+
logger.info(`Bridge healthy on port ${bridge.port}`);
|
|
4270
|
+
}
|
|
4271
|
+
}
|
|
2275
4272
|
);
|
|
2276
4273
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
2277
|
-
const tools = Object.keys(OpSchemas2).map((name) => {
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
});
|
|
2283
|
-
return {
|
|
2284
|
-
name,
|
|
2285
|
-
description: descriptions[name] ?? `AE op: ${name}`,
|
|
2286
|
-
inputSchema: toDraft2020(jsonSchema)
|
|
2287
|
-
};
|
|
2288
|
-
});
|
|
4274
|
+
const tools = Object.keys(OpSchemas2).map((name) => ({
|
|
4275
|
+
name,
|
|
4276
|
+
description: descriptions[name] ?? `AE op: ${name}`,
|
|
4277
|
+
inputSchema: toolInputSchema(name)
|
|
4278
|
+
}));
|
|
2289
4279
|
return { tools };
|
|
2290
4280
|
});
|
|
2291
4281
|
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
|
@@ -2334,8 +4324,13 @@ function createServer() {
|
|
|
2334
4324
|
try {
|
|
2335
4325
|
if (name === "await_job") {
|
|
2336
4326
|
const a = AwaitJobSchema.parse(rawArgs);
|
|
2337
|
-
const
|
|
2338
|
-
|
|
4327
|
+
const forward = progressToken !== void 0 ? forwardJobProgress(jobs, a.jobId, progressToken, extra) : null;
|
|
4328
|
+
try {
|
|
4329
|
+
const st = await jobs.waitFor(a.jobId, a.timeoutMs ?? 6e5);
|
|
4330
|
+
return textResult(st);
|
|
4331
|
+
} finally {
|
|
4332
|
+
await forward?.settle();
|
|
4333
|
+
}
|
|
2339
4334
|
}
|
|
2340
4335
|
if (name === "get_job") {
|
|
2341
4336
|
const a = GetJobSchema.parse(rawArgs);
|
|
@@ -2348,14 +4343,15 @@ function createServer() {
|
|
|
2348
4343
|
jobs.cancel(a.jobId);
|
|
2349
4344
|
return textResult({ ok: true });
|
|
2350
4345
|
}
|
|
4346
|
+
const setupOpts = () => ({ opPort: bridge.port, candidates: [...bridge.candidates(), ...diagnosticPortCandidates()] });
|
|
2351
4347
|
if (name === "check_setup") {
|
|
2352
|
-
return textResult(await checkSetup());
|
|
4348
|
+
return textResult(await checkSetup(setupOpts()));
|
|
2353
4349
|
}
|
|
2354
4350
|
if (name === "setup_panel") {
|
|
2355
4351
|
const a = schemas_exports.SetupPanel.parse(rawArgs);
|
|
2356
4352
|
const installed = await installPanel({ enableDebugMode: a.enableDebugMode, force: a.force });
|
|
2357
4353
|
panelGate.invalidate();
|
|
2358
|
-
return textResult({ ...installed, setup: await checkSetup() });
|
|
4354
|
+
return textResult({ ...installed, setup: await checkSetup(setupOpts()) });
|
|
2359
4355
|
}
|
|
2360
4356
|
if (name === "init_project") {
|
|
2361
4357
|
const a = schemas_exports.InitProject.parse(rawArgs);
|
|
@@ -2374,11 +4370,20 @@ function createServer() {
|
|
|
2374
4370
|
const a = schemas_exports.AeGuide.parse(rawArgs);
|
|
2375
4371
|
const guide = getGuide(a.topic);
|
|
2376
4372
|
if (!guide) return errorResult(`Unknown guide topic: ${a.topic}`);
|
|
4373
|
+
if (a.topic === "whats-new") {
|
|
4374
|
+
const rendered = renderWhatsNew(guide.body, { since: a.since, serverVersion: packageVersion() });
|
|
4375
|
+
return { content: [{ type: "text", text: rendered.text }] };
|
|
4376
|
+
}
|
|
4377
|
+
if (a.since !== void 0) {
|
|
4378
|
+
return errorResult(
|
|
4379
|
+
`\`since\` filters the whats-new topic only; the ${a.topic} topic has no release sections to filter. Call ae_guide({topic: "${a.topic}"}) without \`since\`, or ae_guide({topic: "whats-new", since: "${a.since}"}) for what changed.`
|
|
4380
|
+
);
|
|
4381
|
+
}
|
|
2377
4382
|
return { content: [{ type: "text", text: guide.body }] };
|
|
2378
4383
|
}
|
|
2379
4384
|
if (name === "log_issue") {
|
|
2380
4385
|
const a = schemas_exports.LogIssue.parse(rawArgs);
|
|
2381
|
-
return textResult(logIssue(a));
|
|
4386
|
+
return textResult(logIssue({ ...a, scope: a.scope ?? "project" }));
|
|
2382
4387
|
}
|
|
2383
4388
|
if (name === "list_known_issues") {
|
|
2384
4389
|
const a = schemas_exports.ListKnownIssues.parse(rawArgs);
|
|
@@ -2390,41 +4395,82 @@ function createServer() {
|
|
|
2390
4395
|
id: a.id,
|
|
2391
4396
|
// Compact unless asked otherwise: the full corpus is thousands of
|
|
2392
4397
|
// tokens that stay in the transcript for the rest of the session.
|
|
2393
|
-
detail: a.detail ?? "index"
|
|
4398
|
+
detail: a.detail ?? "index",
|
|
4399
|
+
// Both journals by default — the whole point of the user one is
|
|
4400
|
+
// that a fresh project folder does not start ignorant.
|
|
4401
|
+
scope: a.scope ?? "all",
|
|
4402
|
+
limit: a.limit,
|
|
4403
|
+
// Hidden unless asked: an archived entry is one that stopped
|
|
4404
|
+
// biting, was superseded by a release, or was retired on purpose.
|
|
4405
|
+
includeArchived: a.includeArchived ?? false
|
|
2394
4406
|
})
|
|
2395
4407
|
);
|
|
2396
4408
|
}
|
|
2397
4409
|
if (name === "mark_issue_reported") {
|
|
2398
4410
|
const a = schemas_exports.MarkIssueReported.parse(rawArgs);
|
|
2399
4411
|
const entry = markReported(a.id, a.url);
|
|
2400
|
-
return textResult({ ok: true, id: entry.id, reported: true, issueUrl: entry.issueUrl });
|
|
4412
|
+
return textResult({ ok: true, id: entry.id, scope: entry.scope, reported: true, issueUrl: entry.issueUrl });
|
|
4413
|
+
}
|
|
4414
|
+
if (name === "archive_issue") {
|
|
4415
|
+
const a = schemas_exports.ArchiveIssue.parse(rawArgs);
|
|
4416
|
+
const entry = archiveIssue(a.id, a.reason);
|
|
4417
|
+
return textResult({
|
|
4418
|
+
ok: true,
|
|
4419
|
+
id: entry.id,
|
|
4420
|
+
scope: entry.scope,
|
|
4421
|
+
archived: true,
|
|
4422
|
+
reason: entry.archivedReason,
|
|
4423
|
+
archivedAt: entry.archivedAt,
|
|
4424
|
+
note: "The file is kept and can be reopened by a later log_issue with the same title or the same tool and error text."
|
|
4425
|
+
});
|
|
2401
4426
|
}
|
|
2402
4427
|
} catch (e) {
|
|
2403
|
-
return
|
|
4428
|
+
return fail(name, invalidArgsText(name, e));
|
|
2404
4429
|
}
|
|
2405
4430
|
}
|
|
2406
4431
|
let args;
|
|
2407
4432
|
try {
|
|
2408
4433
|
args = OpSchemas2[name].parse(rawArgs);
|
|
2409
4434
|
} catch (e) {
|
|
2410
|
-
return
|
|
4435
|
+
return fail(name, invalidArgsText(name, e));
|
|
4436
|
+
}
|
|
4437
|
+
if (name === "run_jsx") {
|
|
4438
|
+
try {
|
|
4439
|
+
args = resolveRunJsxSource(args);
|
|
4440
|
+
} catch (e) {
|
|
4441
|
+
return fail(name, e.message);
|
|
4442
|
+
}
|
|
2411
4443
|
}
|
|
2412
4444
|
const staleness = await panelGate.check();
|
|
2413
4445
|
if (staleness) return errorResult(staleness);
|
|
4446
|
+
let lease = null;
|
|
4447
|
+
if (isWriteOp(name)) {
|
|
4448
|
+
try {
|
|
4449
|
+
lease = await writes.acquire(name, extra?.signal);
|
|
4450
|
+
} catch (e) {
|
|
4451
|
+
return fail(name, e.message);
|
|
4452
|
+
}
|
|
4453
|
+
}
|
|
4454
|
+
const wait = lease?.wait ?? null;
|
|
2414
4455
|
try {
|
|
2415
|
-
|
|
4456
|
+
if (SNAPSHOT_OPS.has(name)) return await runSnapshotOp(name, args, bridge, snapshots);
|
|
4457
|
+
const result = await bridge.runOp(name, args);
|
|
2416
4458
|
if (ASYNC_OPS.has(name) && isAsyncEnvelope(result)) {
|
|
2417
4459
|
const env = result;
|
|
2418
4460
|
jobs.register(env.jobId, env.total);
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
4461
|
+
lease?.extendUntil(jobs.waitFor(env.jobId, writes.holdCeilingMs));
|
|
4462
|
+
return textResult(
|
|
4463
|
+
{
|
|
4464
|
+
jobId: env.jobId,
|
|
4465
|
+
async: true,
|
|
4466
|
+
total: env.total,
|
|
4467
|
+
chunkSize: env.chunkSize,
|
|
4468
|
+
undoStepsEstimate: env.undoStepsEstimate,
|
|
4469
|
+
undoGroupName: env.undoGroupName,
|
|
4470
|
+
note: withProgressNote(env.note, env.jobId)
|
|
4471
|
+
},
|
|
4472
|
+
wait
|
|
4473
|
+
);
|
|
2428
4474
|
}
|
|
2429
4475
|
if (VISION_OPS.has(name) && isEmptyFrameResult(result)) return textResult(result);
|
|
2430
4476
|
if (VISION_OPS.has(name) && isVisionResult(result)) {
|
|
@@ -2446,32 +4492,107 @@ function createServer() {
|
|
|
2446
4492
|
sourceBitDepth: v.sourceBitDepth,
|
|
2447
4493
|
// Surfaced when a requested downsample could not be applied, so the
|
|
2448
4494
|
// agent knows it is looking at a full-resolution frame.
|
|
2449
|
-
warning: v.warning
|
|
4495
|
+
warning: v.warning,
|
|
4496
|
+
// Present only for a `times` call. `tiles` is what makes the sheet
|
|
4497
|
+
// readable as data as well as a picture — cell rectangle, time and
|
|
4498
|
+
// status per tile, in the order they were asked for.
|
|
4499
|
+
contactSheet: v.contactSheet,
|
|
4500
|
+
cols: v.cols,
|
|
4501
|
+
rows: v.rows,
|
|
4502
|
+
cellWidth: v.cellWidth,
|
|
4503
|
+
cellHeight: v.cellHeight,
|
|
4504
|
+
tiles: v.tiles
|
|
2450
4505
|
},
|
|
2451
4506
|
v.base64
|
|
2452
4507
|
);
|
|
2453
4508
|
}
|
|
2454
|
-
|
|
4509
|
+
if (name === "get_house_style") {
|
|
4510
|
+
const detail = args.detail ?? "summary";
|
|
4511
|
+
return textResult(applyHouseStyleDetail(result, detail), wait);
|
|
4512
|
+
}
|
|
4513
|
+
return textResult(result, wait);
|
|
2455
4514
|
} catch (e) {
|
|
2456
|
-
if (e instanceof BridgeTimeoutError) return
|
|
2457
|
-
if (e instanceof BridgeUnreachableError) return
|
|
4515
|
+
if (e instanceof BridgeTimeoutError) return fail(name, e.message);
|
|
4516
|
+
if (e instanceof BridgeUnreachableError) return fail(name, e.message);
|
|
2458
4517
|
if (e instanceof AeError) {
|
|
2459
|
-
if (e.code) return
|
|
4518
|
+
if (e.code) return fail(name, e.message);
|
|
2460
4519
|
if (/^Unknown op: /.test(e.message)) {
|
|
2461
4520
|
panelGate.invalidate();
|
|
2462
4521
|
return errorResult(unknownOpMessage(name));
|
|
2463
4522
|
}
|
|
2464
|
-
return
|
|
4523
|
+
return fail(name, aeErrorText(e));
|
|
2465
4524
|
}
|
|
2466
|
-
return
|
|
4525
|
+
return fail(name, e.message);
|
|
4526
|
+
} finally {
|
|
4527
|
+
lease?.release();
|
|
2467
4528
|
}
|
|
2468
4529
|
});
|
|
2469
4530
|
return server;
|
|
2470
4531
|
}
|
|
4532
|
+
function forwardJobProgress(jobs, jobId, progressToken, extra) {
|
|
4533
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
4534
|
+
const unbind = jobs.bindProgressEmitter(jobId, (_jid, progress, total, message) => {
|
|
4535
|
+
const p = extra.sendNotification({ method: "notifications/progress", params: { progressToken, progress, total, message } }).catch((e) => logger.warn(`progress notification for job ${jobId} was not sent: ${e.message}`));
|
|
4536
|
+
inFlight.add(p);
|
|
4537
|
+
void p.finally(() => inFlight.delete(p));
|
|
4538
|
+
});
|
|
4539
|
+
return {
|
|
4540
|
+
async settle() {
|
|
4541
|
+
unbind();
|
|
4542
|
+
await Promise.all(inFlight);
|
|
4543
|
+
}
|
|
4544
|
+
};
|
|
4545
|
+
}
|
|
4546
|
+
function withProgressNote(note, jobId) {
|
|
4547
|
+
const progress = `Follow it with await_job({jobId: "${jobId}"}), which blocks until the batch finishes and returns its result. Progress is delivered on THAT call: send it with a progressToken and notifications/progress arrive while it waits, every one before its response. None can ride on this run_batch call \u2014 its response is already back before the first chunk runs. get_job({jobId: "${jobId}"}) reads the same state without a token.`;
|
|
4548
|
+
return note ? `${note} ${progress}` : progress;
|
|
4549
|
+
}
|
|
4550
|
+
async function runSnapshotOp(name, args, bridge, snapshots) {
|
|
4551
|
+
if (name === "snapshot_comp") {
|
|
4552
|
+
const a = args;
|
|
4553
|
+
const fingerprint = await bridge.runOp("_comp_fingerprint", { compId: a.compId });
|
|
4554
|
+
const snap = snapshots.store(fingerprint);
|
|
4555
|
+
return textResult({
|
|
4556
|
+
snapshotId: snap.id,
|
|
4557
|
+
compId: snap.compId,
|
|
4558
|
+
compName: snap.compName,
|
|
4559
|
+
layers: snap.layerCount,
|
|
4560
|
+
takenAt: new Date(snap.takenAt).toISOString(),
|
|
4561
|
+
next: `Do the work, then diff_comp({ since: "${snap.id}" }).`,
|
|
4562
|
+
lifetime: "Held in this MCP server's memory for the length of the session, not written into the After Effects project.",
|
|
4563
|
+
covers: SNAPSHOT_COVERS,
|
|
4564
|
+
fingerprint: a.includeFingerprint ? fingerprint : void 0
|
|
4565
|
+
});
|
|
4566
|
+
}
|
|
4567
|
+
const d = args;
|
|
4568
|
+
const previous = snapshots.get(d.since);
|
|
4569
|
+
if (!previous) return errorResult(snapshots.missingMessage(d.since));
|
|
4570
|
+
if (d.compId !== void 0 && d.compId !== previous.compId) {
|
|
4571
|
+
return errorResult(
|
|
4572
|
+
`Snapshot ${d.since} is of comp ${previous.compId} ("${previous.compName}"), not comp ${d.compId}. A diff only means anything against a snapshot of the same comp \u2014 omit compId, or snapshot_comp(${d.compId}) first.`
|
|
4573
|
+
);
|
|
4574
|
+
}
|
|
4575
|
+
const res = await bridge.runOp("_comp_diff", {
|
|
4576
|
+
compId: previous.compId,
|
|
4577
|
+
since: previous.fingerprint
|
|
4578
|
+
});
|
|
4579
|
+
const next = snapshots.store(res.fingerprint);
|
|
4580
|
+
return textResult({
|
|
4581
|
+
...res.diff,
|
|
4582
|
+
since: d.since,
|
|
4583
|
+
snapshotId: next.id,
|
|
4584
|
+
fingerprint: d.includeFingerprint ? res.fingerprint : void 0
|
|
4585
|
+
});
|
|
4586
|
+
}
|
|
4587
|
+
var SNAPSHOT_COVERS = "Records layer id/name/index/type, in/out/start, parent, enabled, keyframe counts, expression count and effect count, plus comp size/duration/frame rate/work area/markers. Property values, expression text, effect parameters and shape contents are not recorded.";
|
|
2471
4588
|
function createPanelGate(bridge) {
|
|
2472
4589
|
const RECHECK_MS = 6e4;
|
|
2473
4590
|
let verdict = null;
|
|
2474
4591
|
let checkedAt = 0;
|
|
4592
|
+
bridge.onPortChange(() => {
|
|
4593
|
+
verdict = null;
|
|
4594
|
+
checkedAt = 0;
|
|
4595
|
+
});
|
|
2475
4596
|
return {
|
|
2476
4597
|
/** The message to return instead of forwarding, or null to proceed. */
|
|
2477
4598
|
async check() {
|
|
@@ -2508,10 +4629,12 @@ async function clientRoots(server) {
|
|
|
2508
4629
|
return void 0;
|
|
2509
4630
|
}
|
|
2510
4631
|
}
|
|
2511
|
-
function textResult(value) {
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
4632
|
+
function textResult(value, wait) {
|
|
4633
|
+
const json = (v) => ({ type: "text", text: JSON.stringify(v, null, 2) });
|
|
4634
|
+
if (!wait) return { content: [json(value)] };
|
|
4635
|
+
const merged = mergeWait(value, wait);
|
|
4636
|
+
if (merged) return { content: [json(merged)] };
|
|
4637
|
+
return { content: [json(value), json(wait)] };
|
|
2515
4638
|
}
|
|
2516
4639
|
function errorResult(message) {
|
|
2517
4640
|
return {
|
|
@@ -2519,6 +4642,7 @@ function errorResult(message) {
|
|
|
2519
4642
|
isError: true
|
|
2520
4643
|
};
|
|
2521
4644
|
}
|
|
4645
|
+
var JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema";
|
|
2522
4646
|
function toDraft2020(node) {
|
|
2523
4647
|
if (Array.isArray(node)) return node.map(toDraft2020);
|
|
2524
4648
|
if (!node || typeof node !== "object") return node;
|
|
@@ -2533,8 +4657,41 @@ function toDraft2020(node) {
|
|
|
2533
4657
|
out.items = false;
|
|
2534
4658
|
}
|
|
2535
4659
|
}
|
|
4660
|
+
if (typeof out.$schema === "string") out.$schema = JSON_SCHEMA_DIALECT;
|
|
2536
4661
|
return out;
|
|
2537
4662
|
}
|
|
4663
|
+
function withCrossFieldConstraints(zodSchema, jsonSchema, opName) {
|
|
4664
|
+
for (const { path: path12, rule } of schemas_exports.crossFieldRulesIn(zodSchema)) {
|
|
4665
|
+
const where = path12.join(".") || "(root)";
|
|
4666
|
+
if (!rule) {
|
|
4667
|
+
logger.warn(
|
|
4668
|
+
`${opName} carries a refinement at ${where} that is not a declared cross-field rule. It is enforced on every call and absent from the schema the model is shown, so an agent can only learn it by having a call rejected. Declare it with crossField() in packages/shared/src/schemas.ts.`
|
|
4669
|
+
);
|
|
4670
|
+
continue;
|
|
4671
|
+
}
|
|
4672
|
+
let node = jsonSchema;
|
|
4673
|
+
for (const step of path12) {
|
|
4674
|
+
node = step === schemas_exports.ARRAY_ELEMENT ? node?.items : node?.properties?.[step];
|
|
4675
|
+
if (!node || typeof node !== "object") break;
|
|
4676
|
+
}
|
|
4677
|
+
if (!node || typeof node !== "object") {
|
|
4678
|
+
logger.warn(
|
|
4679
|
+
`${opName}: the cross-field rule at ${where} has no matching node in the emitted JSON Schema, so it ships enforced but invisible.`
|
|
4680
|
+
);
|
|
4681
|
+
continue;
|
|
4682
|
+
}
|
|
4683
|
+
const fragment = schemas_exports.crossFieldJsonSchema(rule);
|
|
4684
|
+
const collides = Object.keys(fragment).some((k) => k in node);
|
|
4685
|
+
if (collides) (node.allOf ??= []).push(fragment);
|
|
4686
|
+
else Object.assign(node, fragment);
|
|
4687
|
+
}
|
|
4688
|
+
return jsonSchema;
|
|
4689
|
+
}
|
|
4690
|
+
function toolInputSchema(opName) {
|
|
4691
|
+
const schema = OpSchemas2[opName];
|
|
4692
|
+
const jsonSchema = zodToJsonSchema(schema, { target: "jsonSchema7", $refStrategy: "none" });
|
|
4693
|
+
return withCrossFieldConstraints(schema, toDraft2020(jsonSchema), opName);
|
|
4694
|
+
}
|
|
2538
4695
|
function isAsyncEnvelope(v) {
|
|
2539
4696
|
return !!(v && typeof v === "object" && "async" in v && v.async === true && "jobId" in v);
|
|
2540
4697
|
}
|
|
@@ -2585,7 +4742,7 @@ ${USAGE}`);
|
|
|
2585
4742
|
await server.connect(transport);
|
|
2586
4743
|
logger.info("MCP server running on stdio");
|
|
2587
4744
|
}
|
|
2588
|
-
var VERSION = "0.
|
|
4745
|
+
var VERSION = "0.5.0";
|
|
2589
4746
|
main().catch((e) => {
|
|
2590
4747
|
logger.error("fatal", e.message);
|
|
2591
4748
|
process.exit(1);
|