@writepanda/mcp 1.221.0 → 1.224.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/bin/appConnection.mjs +211 -3
- package/bin/server.mjs +247 -43
- package/package.json +1 -1
package/bin/appConnection.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
//
|
|
17
17
|
// Kept free of MCP-sdk imports so the pure parts are unit-testable.
|
|
18
18
|
|
|
19
|
-
import { readFile } from "node:fs/promises";
|
|
19
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
20
20
|
import http from "node:http";
|
|
21
21
|
import os from "node:os";
|
|
22
22
|
import path from "node:path";
|
|
@@ -144,21 +144,30 @@ export async function connectInApp({
|
|
|
144
144
|
busyWaitMs = 20_000,
|
|
145
145
|
probe = probeHealth,
|
|
146
146
|
readFiles = fileCredentials,
|
|
147
|
+
excludePort = null,
|
|
147
148
|
} = {}) {
|
|
148
149
|
const attempts = [];
|
|
149
150
|
const candidates = [];
|
|
150
151
|
const fromEnv = envCredentials(env);
|
|
151
|
-
if (fromEnv) candidates.push(fromEnv);
|
|
152
|
+
if (fromEnv && fromEnv.port !== excludePort) candidates.push(fromEnv);
|
|
152
153
|
let fileError = null;
|
|
153
154
|
try {
|
|
154
155
|
const fromFiles = await readFiles(dir);
|
|
155
|
-
if (
|
|
156
|
+
if (
|
|
157
|
+
fromFiles.port !== excludePort &&
|
|
158
|
+
(!fromEnv || fromFiles.port !== fromEnv.port || fromFiles.token !== fromEnv.token)
|
|
159
|
+
) {
|
|
156
160
|
candidates.push(fromFiles);
|
|
157
161
|
}
|
|
158
162
|
} catch (err) {
|
|
159
163
|
fileError = err?.message ?? String(err);
|
|
160
164
|
}
|
|
161
165
|
if (candidates.length === 0) {
|
|
166
|
+
if (excludePort !== null) {
|
|
167
|
+
throw unavailable(
|
|
168
|
+
`nothing is listening on 127.0.0.1:${excludePort} any more and PandaStudio hasn't advertised another address (${fileError ?? `${dir} still names that port`}).`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
162
171
|
throw unavailable(
|
|
163
172
|
`PandaStudio didn't pass its automation address to the tools and ${fileError ?? "the token/port files are missing"}.`,
|
|
164
173
|
);
|
|
@@ -175,6 +184,37 @@ export async function connectInApp({
|
|
|
175
184
|
throw unavailable(`PandaStudio's automation server didn't answer. ${attempts.join("; ")}.`);
|
|
176
185
|
}
|
|
177
186
|
|
|
187
|
+
/** A socket error meaning nothing accepted the connection: the request never
|
|
188
|
+
* reached the app, so resending it can't run a command twice. */
|
|
189
|
+
export function isConnectionRefused(err) {
|
|
190
|
+
const code = err?.code ?? err?.cause?.code;
|
|
191
|
+
return code === "ECONNREFUSED" || code === "EADDRNOTAVAIL";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Send `call(creds)`. When the connection is REFUSED, the port we hold is
|
|
196
|
+
* stale: the app restarted its automation server on another port (a fallback
|
|
197
|
+
* from a busy preferred port, a Settings change, a relaunch) after the
|
|
198
|
+
* credentials were captured, e.g. in the environment of a long-lived agent
|
|
199
|
+
* process. Re-resolve once with `reresolve(staleCreds)` (which re-reads the
|
|
200
|
+
* token/port files) and resend to the new address. Only a refused connection
|
|
201
|
+
* is retried; any other failure may have reached the app.
|
|
202
|
+
*
|
|
203
|
+
* Returns { creds, res } with the credentials that worked. If re-resolving
|
|
204
|
+
* fails, its error (which says why) is thrown; if it yields the same address,
|
|
205
|
+
* the original error is.
|
|
206
|
+
*/
|
|
207
|
+
export async function callWithReconnect(creds, call, reresolve) {
|
|
208
|
+
try {
|
|
209
|
+
return { creds, res: await call(creds) };
|
|
210
|
+
} catch (err) {
|
|
211
|
+
if (!isConnectionRefused(err)) throw err;
|
|
212
|
+
const fresh = await reresolve(creds);
|
|
213
|
+
if (!fresh || (fresh.port === creds.port && fresh.token === creds.token)) throw err;
|
|
214
|
+
return { creds: fresh, res: await call(fresh) };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
178
218
|
/**
|
|
179
219
|
* Environment for LAUNCHING the desktop app. This server runs as Electron in
|
|
180
220
|
* node mode (ELECTRON_RUN_AS_NODE=1), and a child inherits that: spawning
|
|
@@ -199,3 +239,171 @@ export function appLaunchEnv(env = process.env) {
|
|
|
199
239
|
}
|
|
200
240
|
return out;
|
|
201
241
|
}
|
|
242
|
+
|
|
243
|
+
// ── Launching the app (external agents only) ─────────────────────────────
|
|
244
|
+
//
|
|
245
|
+
// Jared (Windows, 2.1.15): "PandaStudio seemed to restart with every
|
|
246
|
+
// request". Every call that didn't find a healthy server at the port file's
|
|
247
|
+
// address launched PandaStudio.exe again: while the app was busy for longer
|
|
248
|
+
// than the busy wait, while it was still booting, and after another process
|
|
249
|
+
// had replaced or deleted the token/port files. Now a launch happens only
|
|
250
|
+
// when nothing says PandaStudio is running, and at most once per
|
|
251
|
+
// LAUNCH_DEBOUNCE_MS across every MCP server / CLI process on the machine.
|
|
252
|
+
|
|
253
|
+
/** Default automation port (Settings → Automation). */
|
|
254
|
+
export const DEFAULT_AUTOMATION_PORT = 7878;
|
|
255
|
+
/** An instance.json heartbeat older than this is a dead (or hung) app. */
|
|
256
|
+
export const INSTANCE_STALE_MS = 20_000;
|
|
257
|
+
/** Don't launch again within this long of the last launch, from anywhere. */
|
|
258
|
+
export const LAUNCH_DEBOUNCE_MS = 90_000;
|
|
259
|
+
|
|
260
|
+
/** Is a process with this pid running? (EPERM: it exists, owned by someone else.) */
|
|
261
|
+
export function pidAlive(pid) {
|
|
262
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
263
|
+
try {
|
|
264
|
+
process.kill(pid, 0);
|
|
265
|
+
return true;
|
|
266
|
+
} catch (err) {
|
|
267
|
+
return err?.code === "EPERM";
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The running app's instance.json (pid + heartbeat), or null. */
|
|
272
|
+
export async function readInstanceRecord(dir = configDir()) {
|
|
273
|
+
try {
|
|
274
|
+
const v = JSON.parse(cleanFileValue(await readFile(path.join(dir, "instance.json"), "utf-8")));
|
|
275
|
+
return v && typeof v === "object" ? v : null;
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** A fresh heartbeat from a process that still exists. */
|
|
282
|
+
export function instanceAlive(record, now = Date.now(), isAlive = pidAlive) {
|
|
283
|
+
if (!record || !Number.isInteger(record.pid)) return false;
|
|
284
|
+
const beat = Number(record.heartbeatAt);
|
|
285
|
+
if (!Number.isFinite(beat) || now - beat > INSTANCE_STALE_MS) return false;
|
|
286
|
+
return isAlive(record.pid);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function launchMarkerPath(dir) {
|
|
290
|
+
return path.join(dir, "launch-requested");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Milliseconds since any process last launched the app, or null. */
|
|
294
|
+
export async function lastLaunchAgeMs(dir = configDir(), now = Date.now()) {
|
|
295
|
+
try {
|
|
296
|
+
const at = Number(cleanFileValue(await readFile(launchMarkerPath(dir), "utf-8")));
|
|
297
|
+
if (!Number.isFinite(at) || at > now) return null;
|
|
298
|
+
return now - at;
|
|
299
|
+
} catch {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function recordLaunch(dir = configDir(), now = Date.now()) {
|
|
305
|
+
try {
|
|
306
|
+
await mkdir(dir, { recursive: true });
|
|
307
|
+
await writeFile(launchMarkerPath(dir), String(now), "utf-8");
|
|
308
|
+
} catch {
|
|
309
|
+
/* best-effort: the in-process flag still stops a second launch */
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* What to do this round:
|
|
315
|
+
* use — the port file's server answered
|
|
316
|
+
* wait — a PandaStudio is running (busy, booting, files being rewritten,
|
|
317
|
+
* or Local automation off) or one was launched moments ago
|
|
318
|
+
* launch — nothing is running and nobody launched it recently
|
|
319
|
+
*
|
|
320
|
+
* healthState / preferredState: probe of the port file's address / of the
|
|
321
|
+
* default port ("up" | "busy" | "down", or null when not probed).
|
|
322
|
+
*/
|
|
323
|
+
export function launchDecision({
|
|
324
|
+
healthState = null,
|
|
325
|
+
preferredState = null,
|
|
326
|
+
instanceIsAlive = false,
|
|
327
|
+
launchAgeMs = null,
|
|
328
|
+
debounceMs = LAUNCH_DEBOUNCE_MS,
|
|
329
|
+
}) {
|
|
330
|
+
if (healthState === "up") return "use";
|
|
331
|
+
if (healthState === "busy") return "wait";
|
|
332
|
+
if (instanceIsAlive) return "wait";
|
|
333
|
+
if (preferredState === "up" || preferredState === "busy") return "wait";
|
|
334
|
+
if (launchAgeMs !== null && launchAgeMs < debounceMs) return "wait";
|
|
335
|
+
return "launch";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Reach the running app, launching it only when none is running. Returns the
|
|
340
|
+
* credentials whose server answered. `launch()` starts the app (throws when
|
|
341
|
+
* it can't). Everything else is injectable for tests.
|
|
342
|
+
*/
|
|
343
|
+
export async function ensureApp({
|
|
344
|
+
dir = configDir(),
|
|
345
|
+
launch,
|
|
346
|
+
timeoutMs = 60_000,
|
|
347
|
+
pollMs = 500,
|
|
348
|
+
preferredPort = DEFAULT_AUTOMATION_PORT,
|
|
349
|
+
probe = probeHealth,
|
|
350
|
+
readFiles = fileCredentials,
|
|
351
|
+
readInstance = readInstanceRecord,
|
|
352
|
+
isAlive = pidAlive,
|
|
353
|
+
launchAge = lastLaunchAgeMs,
|
|
354
|
+
markLaunch = recordLaunch,
|
|
355
|
+
now = Date.now,
|
|
356
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
357
|
+
} = {}) {
|
|
358
|
+
const start = now();
|
|
359
|
+
let launched = false;
|
|
360
|
+
let last = { healthState: null, instanceIsAlive: false, recentLaunch: false };
|
|
361
|
+
for (;;) {
|
|
362
|
+
let creds = null;
|
|
363
|
+
let healthState = null;
|
|
364
|
+
try {
|
|
365
|
+
creds = await readFiles(dir);
|
|
366
|
+
healthState = (await probe(creds.port, 1500)).state;
|
|
367
|
+
} catch {
|
|
368
|
+
/* no files yet: not running, booting, or they're being rewritten */
|
|
369
|
+
}
|
|
370
|
+
if (healthState === "up") return creds;
|
|
371
|
+
const alive = instanceAlive(await readInstance(dir), now(), isAlive);
|
|
372
|
+
let preferredState = null;
|
|
373
|
+
if (!alive && healthState !== "busy" && creds?.port !== preferredPort) {
|
|
374
|
+
preferredState = (await probe(preferredPort, 1000)).state;
|
|
375
|
+
}
|
|
376
|
+
const age = launched ? 0 : await launchAge(dir, now());
|
|
377
|
+
const decision = launchDecision({
|
|
378
|
+
healthState,
|
|
379
|
+
preferredState,
|
|
380
|
+
instanceIsAlive: alive,
|
|
381
|
+
launchAgeMs: age,
|
|
382
|
+
});
|
|
383
|
+
last = { healthState, preferredState, instanceIsAlive: alive };
|
|
384
|
+
if (decision === "launch") {
|
|
385
|
+
await markLaunch(dir, now());
|
|
386
|
+
launched = true;
|
|
387
|
+
await launch();
|
|
388
|
+
}
|
|
389
|
+
if (now() - start >= timeoutMs) throw new Error(notReadyMessage(last, launched, timeoutMs));
|
|
390
|
+
await sleep(pollMs);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function notReadyMessage({ healthState, preferredState, instanceIsAlive }, launched, timeoutMs) {
|
|
395
|
+
const secs = Math.round(timeoutMs / 1000);
|
|
396
|
+
if (preferredState === "up" && !instanceIsAlive) {
|
|
397
|
+
return `A PandaStudio is answering on 127.0.0.1:${DEFAULT_AUTOMATION_PORT} but its access files in ${configDir()} are missing or stale. Quit PandaStudio completely and open it again.`;
|
|
398
|
+
}
|
|
399
|
+
if (healthState === "busy") {
|
|
400
|
+
return `PandaStudio is running but busy: it didn't answer for ${secs}s (a heavy export or import can do this). Try again in a moment; nothing was relaunched.`;
|
|
401
|
+
}
|
|
402
|
+
if (instanceIsAlive) {
|
|
403
|
+
return `PandaStudio is running but its automation server didn't answer within ${secs}s. In PandaStudio open Settings → Automation and make sure Local automation is on.`;
|
|
404
|
+
}
|
|
405
|
+
if (launched) {
|
|
406
|
+
return `PandaStudio did not become ready within ${secs}s. Open PandaStudio, go to Settings → Automation and turn on Local automation.`;
|
|
407
|
+
}
|
|
408
|
+
return `PandaStudio was started moments ago and isn't ready yet (waited ${secs}s). Open PandaStudio and check Settings → Automation → Local automation is on.`;
|
|
409
|
+
}
|
package/bin/server.mjs
CHANGED
|
@@ -38,14 +38,14 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
38
38
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
39
39
|
import {
|
|
40
40
|
appLaunchEnv,
|
|
41
|
+
callWithReconnect,
|
|
41
42
|
cleanFileValue,
|
|
42
43
|
configDir,
|
|
43
44
|
connectInApp,
|
|
45
|
+
ensureApp,
|
|
44
46
|
fileCredentials,
|
|
45
47
|
IN_APP_UNAVAILABLE,
|
|
46
48
|
isInAppCaller,
|
|
47
|
-
probeHealth,
|
|
48
|
-
waitForHealth,
|
|
49
49
|
} from "./appConnection.mjs";
|
|
50
50
|
import { portableSchema } from "./portableSchema.mjs";
|
|
51
51
|
|
|
@@ -55,10 +55,6 @@ import { portableSchema } from "./portableSchema.mjs";
|
|
|
55
55
|
|
|
56
56
|
const IN_APP = isInAppCaller();
|
|
57
57
|
|
|
58
|
-
async function readCredentials() {
|
|
59
|
-
return fileCredentials(configDir());
|
|
60
|
-
}
|
|
61
|
-
|
|
62
58
|
function findInstalledApp() {
|
|
63
59
|
if (process.env.PANDASTUDIO_BIN) return process.env.PANDASTUDIO_BIN;
|
|
64
60
|
// The app records its own location (it can live anywhere on Windows,
|
|
@@ -101,40 +97,34 @@ function findInstalledApp() {
|
|
|
101
97
|
return candidates.find((c) => existsSync(c)) ?? candidates[0];
|
|
102
98
|
}
|
|
103
99
|
|
|
100
|
+
// Reach the running app; launch it only when nothing says it is running
|
|
101
|
+
// (ensureApp in appConnection.mjs: port-file health, instance.json heartbeat,
|
|
102
|
+
// the default port, and a machine-wide launch debounce). Launching on every
|
|
103
|
+
// call that missed the server restarted PandaStudio "with every request".
|
|
104
104
|
async function autoLaunchAndWait(timeoutSec = 60) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const c = await readCredentials();
|
|
129
|
-
if ((await probeHealth(c.port, 2000)).state === "up") return c;
|
|
130
|
-
} catch {
|
|
131
|
-
/* still booting */
|
|
132
|
-
}
|
|
133
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
134
|
-
}
|
|
135
|
-
throw new Error(
|
|
136
|
-
`PandaStudio did not become ready within ${timeoutSec}s. Open PandaStudio, go to Settings → Automation and turn on Local automation.`,
|
|
137
|
-
);
|
|
105
|
+
return ensureApp({
|
|
106
|
+
timeoutMs: timeoutSec * 1000,
|
|
107
|
+
launch: async () => {
|
|
108
|
+
const bin = findInstalledApp();
|
|
109
|
+
try {
|
|
110
|
+
// appLaunchEnv drops ELECTRON_RUN_AS_NODE: inherited, it made the
|
|
111
|
+
// "launch" start a Node REPL that exits instead of PandaStudio.
|
|
112
|
+
// windowsHide: false: this is the desktop app, whose window must show.
|
|
113
|
+
const child = spawn(bin, [], {
|
|
114
|
+
detached: true,
|
|
115
|
+
stdio: "ignore",
|
|
116
|
+
env: appLaunchEnv(),
|
|
117
|
+
windowsHide: false,
|
|
118
|
+
});
|
|
119
|
+
child.on("error", () => {
|
|
120
|
+
/* reported by the readiness wait */
|
|
121
|
+
});
|
|
122
|
+
child.unref();
|
|
123
|
+
} catch (err) {
|
|
124
|
+
throw new Error(`failed to launch PandaStudio (${bin}): ${err?.message ?? err}`);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
});
|
|
138
128
|
}
|
|
139
129
|
|
|
140
130
|
// The app answers /v1/call when the command finishes, and some commands run
|
|
@@ -180,9 +170,16 @@ async function callPandastudio(command, args = {}) {
|
|
|
180
170
|
let creds = IN_APP ? await connectInApp() : await autoLaunchAndWait();
|
|
181
171
|
let res;
|
|
182
172
|
try {
|
|
183
|
-
|
|
173
|
+
// A refused connection means the address went stale between the
|
|
174
|
+
// health probe and the call (the app moved its automation server):
|
|
175
|
+
// re-read where it is now and resend once.
|
|
176
|
+
({ creds, res } = await callWithReconnect(
|
|
177
|
+
creds,
|
|
178
|
+
(c) => postCall(c, command, args),
|
|
179
|
+
(stale) => (IN_APP ? connectInApp({ excludePort: stale.port }) : autoLaunchAndWait()),
|
|
180
|
+
));
|
|
184
181
|
} catch (err) {
|
|
185
|
-
if (!IN_APP) throw err;
|
|
182
|
+
if (!IN_APP || err?.inAppUnavailable) throw err;
|
|
186
183
|
throw new Error(
|
|
187
184
|
`${IN_APP_UNAVAILABLE}: the call to 127.0.0.1:${creds.port} failed (${err?.code ?? err?.message ?? err}).`,
|
|
188
185
|
);
|
|
@@ -1043,6 +1040,77 @@ const TOOLS = [
|
|
|
1043
1040
|
},
|
|
1044
1041
|
command: "workspace.contents",
|
|
1045
1042
|
},
|
|
1043
|
+
{
|
|
1044
|
+
name: "brand_list_assets",
|
|
1045
|
+
description: "List reusable assets in the active workspace brand library.",
|
|
1046
|
+
inputSchema: {
|
|
1047
|
+
type: "object",
|
|
1048
|
+
properties: {},
|
|
1049
|
+
required: [],
|
|
1050
|
+
},
|
|
1051
|
+
command: "brand.list-assets",
|
|
1052
|
+
},
|
|
1053
|
+
{
|
|
1054
|
+
name: "brand_add_asset",
|
|
1055
|
+
description: "Copy a local image, video or audio file into the workspace brand library.",
|
|
1056
|
+
inputSchema: {
|
|
1057
|
+
type: "object",
|
|
1058
|
+
properties: {
|
|
1059
|
+
path: {
|
|
1060
|
+
type: "string",
|
|
1061
|
+
},
|
|
1062
|
+
name: {
|
|
1063
|
+
type: "string",
|
|
1064
|
+
},
|
|
1065
|
+
role: {
|
|
1066
|
+
type: "string",
|
|
1067
|
+
enum: ["logo", "outro", "intro", "cta", "watermark", "other"],
|
|
1068
|
+
},
|
|
1069
|
+
},
|
|
1070
|
+
required: ["path"],
|
|
1071
|
+
},
|
|
1072
|
+
command: "brand.add-asset",
|
|
1073
|
+
},
|
|
1074
|
+
{
|
|
1075
|
+
name: "brand_remove_asset",
|
|
1076
|
+
description: "Remove an asset from the library. Existing timeline media remains available.",
|
|
1077
|
+
inputSchema: {
|
|
1078
|
+
type: "object",
|
|
1079
|
+
properties: {
|
|
1080
|
+
id: {
|
|
1081
|
+
type: "string",
|
|
1082
|
+
},
|
|
1083
|
+
},
|
|
1084
|
+
required: ["id"],
|
|
1085
|
+
},
|
|
1086
|
+
command: "brand.remove-asset",
|
|
1087
|
+
},
|
|
1088
|
+
{
|
|
1089
|
+
name: "project_add_brand_asset",
|
|
1090
|
+
description:
|
|
1091
|
+
"Insert a brand asset at edited atMs (default 0). Images are overlays, videos clips, audio tracks. Outros append.",
|
|
1092
|
+
inputSchema: {
|
|
1093
|
+
type: "object",
|
|
1094
|
+
properties: {
|
|
1095
|
+
id: {
|
|
1096
|
+
type: "string",
|
|
1097
|
+
},
|
|
1098
|
+
assetId: {
|
|
1099
|
+
type: "string",
|
|
1100
|
+
},
|
|
1101
|
+
atMs: {
|
|
1102
|
+
type: "number",
|
|
1103
|
+
minimum: 0,
|
|
1104
|
+
},
|
|
1105
|
+
placement: {
|
|
1106
|
+
type: "string",
|
|
1107
|
+
enum: ["clip", "overlay", "append"],
|
|
1108
|
+
},
|
|
1109
|
+
},
|
|
1110
|
+
required: ["id", "assetId"],
|
|
1111
|
+
},
|
|
1112
|
+
command: "project.add-brand-asset",
|
|
1113
|
+
},
|
|
1046
1114
|
{
|
|
1047
1115
|
name: "workspace_get_brand",
|
|
1048
1116
|
description:
|
|
@@ -1060,6 +1128,7 @@ const TOOLS = [
|
|
|
1060
1128
|
brand: {
|
|
1061
1129
|
type: ["object", "null"],
|
|
1062
1130
|
properties: {
|
|
1131
|
+
savedColors: { type: "array", items: { type: "string" }, maxItems: 24 },
|
|
1063
1132
|
name: { type: "string" },
|
|
1064
1133
|
tagline: { type: "string" },
|
|
1065
1134
|
colors: {
|
|
@@ -1510,6 +1579,35 @@ const TOOLS = [
|
|
|
1510
1579
|
},
|
|
1511
1580
|
command: "matte.bake",
|
|
1512
1581
|
},
|
|
1582
|
+
{
|
|
1583
|
+
name: "matte_export_cutout",
|
|
1584
|
+
description:
|
|
1585
|
+
"Export the person cut out of a clip as a transparent file for other tools (bakes the matte first if needed). format webm (VP9 + alpha, default; OBS, web), mov (ProRes 4444 + alpha; Premiere, Final Cut, Resolve, After Effects, Keynote) or png (one frame at atMs; thumbnails, Canva). Source size, follows the edit (cuts skipped, 1x speed), clip audio unless audio=false; inverse also writes the background plate. Returns jobId; job_wait gives outputPath, backgroundPath, durationMs, usedIn.",
|
|
1586
|
+
inputSchema: {
|
|
1587
|
+
type: "object",
|
|
1588
|
+
properties: {
|
|
1589
|
+
id: { type: "string" },
|
|
1590
|
+
path: { type: "string" },
|
|
1591
|
+
clip: {
|
|
1592
|
+
type: "string",
|
|
1593
|
+
description: "Clip id. Default: the camera clip / first clip with a person.",
|
|
1594
|
+
},
|
|
1595
|
+
source: { type: "string", enum: ["camera", "main"] },
|
|
1596
|
+
format: { type: "string", enum: ["webm", "mov", "png"] },
|
|
1597
|
+
startMs: { type: "number", description: "Edited-timeline start; default the clip start." },
|
|
1598
|
+
endMs: { type: "number", description: "Edited-timeline end; default the clip end." },
|
|
1599
|
+
atMs: { type: "number", description: "png: edited-timeline time of the frame." },
|
|
1600
|
+
inverse: {
|
|
1601
|
+
type: "boolean",
|
|
1602
|
+
description: "Also write the background with the person removed.",
|
|
1603
|
+
},
|
|
1604
|
+
audio: { type: "boolean", description: "webm/mov: include the clip audio (default true)." },
|
|
1605
|
+
outPath: { type: "string", description: "Absolute output path. Default: beside exports." },
|
|
1606
|
+
},
|
|
1607
|
+
required: ["id"],
|
|
1608
|
+
},
|
|
1609
|
+
command: "matte.export-cutout",
|
|
1610
|
+
},
|
|
1513
1611
|
{
|
|
1514
1612
|
name: "project_read",
|
|
1515
1613
|
description:
|
|
@@ -1941,6 +2039,24 @@ const TOOLS = [
|
|
|
1941
2039
|
},
|
|
1942
2040
|
command: "project.set-overlay-chroma-key",
|
|
1943
2041
|
},
|
|
2042
|
+
{
|
|
2043
|
+
name: "project_remove_overlay_background",
|
|
2044
|
+
description:
|
|
2045
|
+
"Cut the subject out of an IMAGE overlay (on-device; any background). Swaps its source to <name>-cutout.png at the same size and remembers the original; restore=true puts it back.",
|
|
2046
|
+
inputSchema: {
|
|
2047
|
+
type: "object",
|
|
2048
|
+
properties: {
|
|
2049
|
+
id: { type: "string" },
|
|
2050
|
+
path: { type: "string" },
|
|
2051
|
+
regionId: { type: "string" },
|
|
2052
|
+
restore: { type: "boolean" },
|
|
2053
|
+
engine: { type: "string", enum: ["auto", "vision", "birefnet"] },
|
|
2054
|
+
expectedRevision: { type: "number" },
|
|
2055
|
+
},
|
|
2056
|
+
required: ["regionId"],
|
|
2057
|
+
},
|
|
2058
|
+
command: "project.remove-overlay-background",
|
|
2059
|
+
},
|
|
1944
2060
|
{
|
|
1945
2061
|
name: "project_add_fx",
|
|
1946
2062
|
description: "Add an FX overlay (bundled fxId or custom src).",
|
|
@@ -2058,6 +2174,40 @@ const TOOLS = [
|
|
|
2058
2174
|
},
|
|
2059
2175
|
command: "project.add-title-behind",
|
|
2060
2176
|
},
|
|
2177
|
+
{
|
|
2178
|
+
name: "project_add_3d_captions",
|
|
2179
|
+
description:
|
|
2180
|
+
"3D camera captions on a talking-head HOOK (5-20 s, 16:9, locked medium shot): the camera whips in and trucks between caption groups at different depths, a hero word hides behind the head, an optional ring of words turns in front of the speaker, a staircase finale. Rendered from the clip's footage, matte and transcript; placed over [startMs, endMs] (editor captions hidden there). Do it after the cuts. Refuses a moving camera or extreme close-up. ASYNC: job_wait gives overlayId, beats[].still (look at each), qc, warnings. Re-run replaces; reference/3d-captions.md.",
|
|
2181
|
+
inputSchema: {
|
|
2182
|
+
type: "object",
|
|
2183
|
+
properties: {
|
|
2184
|
+
id: { type: "string" },
|
|
2185
|
+
path: { type: "string" },
|
|
2186
|
+
startMs: {
|
|
2187
|
+
type: "number",
|
|
2188
|
+
description: "Edited-timeline start (a pause between sentences).",
|
|
2189
|
+
},
|
|
2190
|
+
endMs: { type: "number", description: "Edited-timeline end, 5-20 s after startMs." },
|
|
2191
|
+
style: { type: "string", enum: ["bold", "editorial"], description: "Default bold." },
|
|
2192
|
+
heroWords: {
|
|
2193
|
+
type: "array",
|
|
2194
|
+
items: { type: "string" },
|
|
2195
|
+
description:
|
|
2196
|
+
"Words/short phrases to feature big. Default: the most stressed word per beat.",
|
|
2197
|
+
},
|
|
2198
|
+
accent: { type: "string", description: "Hero colour, CSS hex." },
|
|
2199
|
+
grade: { type: "boolean", description: "Film finish (curves, grain). Default true." },
|
|
2200
|
+
hideCaptions: {
|
|
2201
|
+
type: "boolean",
|
|
2202
|
+
description: "Hide editor captions over the range. Default true.",
|
|
2203
|
+
},
|
|
2204
|
+
force: { type: "boolean", description: "Run despite a moving camera or extreme close-up." },
|
|
2205
|
+
expectedRevision: { type: "number" },
|
|
2206
|
+
},
|
|
2207
|
+
required: ["id", "startMs", "endMs"],
|
|
2208
|
+
},
|
|
2209
|
+
command: "project.add-3d-captions",
|
|
2210
|
+
},
|
|
2061
2211
|
|
|
2062
2212
|
// ── compose: visual regions ─────────────────────────────────────
|
|
2063
2213
|
{
|
|
@@ -3332,7 +3482,7 @@ const TOOLS = [
|
|
|
3332
3482
|
{
|
|
3333
3483
|
name: "project_update_region",
|
|
3334
3484
|
description:
|
|
3335
|
-
"Patch a region; unspecified fields are kept. Patchable: zoom (depth, focusX, focusY, focusMode static|auto), trim
|
|
3485
|
+
"Patch a region; unspecified fields are kept. Put the fields in patch. Every type takes startMs, endMs (edited ms; trim and speed are source ms); a retimed region is re-anchored at its new place, so later cuts and editor reloads keep it there. Patchable: zoom (depth, focusX, focusY, followCursor, focusMode static|auto), trim (startMs, endMs), speed (speed, rampIn, rampOut, preservePitch, freezeMs, holdMs, reverse, reverseAudio), fx (opacity, blendMode, speed, zIndex), annotation (text = the words shown, content, textContent, x, y, width, height, fontSize, fontFamily, textColor, color, backgroundColor, fontWeight, fontStyle, textDecoration, textAlign, style, zIndex), overlay (x, y, width, height in canvas %, layer, opacity, blendMode, muted, volume, loop, motionBlur, zIndex), adjustment (blendMode, fadeIn, fadeOut), motion (motionBlur), clip-transform (preset, transitionMs, easing, bezier, cameraFit, backgroundColor), background-effect (mode, strength, backgroundImage, backgroundFit, outline, outlineWidth, outlineColor, outlineShadow, matteContract, matteFeather), audio-overlay (sourceStartMs, volume 0-2, fadeIn, fadeOut in ms, 0 or null removes; fadeInMs, fadeOutMs are aliases). A key the region type doesn't have is not applied: the result lists it in ignoredKeys with a warning, and the call fails when nothing in the patch applies.",
|
|
3336
3486
|
inputSchema: {
|
|
3337
3487
|
type: "object",
|
|
3338
3488
|
properties: {
|
|
@@ -4667,6 +4817,24 @@ const TOOLS = [
|
|
|
4667
4817
|
inputSchema: {
|
|
4668
4818
|
type: "object",
|
|
4669
4819
|
properties: {
|
|
4820
|
+
area: {
|
|
4821
|
+
type: "object",
|
|
4822
|
+
description: "Display-local points. Native region capture. Minimum 32 x 32 points.",
|
|
4823
|
+
properties: {
|
|
4824
|
+
displayId: { type: "number" },
|
|
4825
|
+
x: { type: "number" },
|
|
4826
|
+
y: { type: "number" },
|
|
4827
|
+
width: { type: "number", minimum: 32 },
|
|
4828
|
+
height: { type: "number", minimum: 32 },
|
|
4829
|
+
},
|
|
4830
|
+
required: ["displayId", "x", "y", "width", "height"],
|
|
4831
|
+
additionalProperties: false,
|
|
4832
|
+
},
|
|
4833
|
+
aspectRatio: {
|
|
4834
|
+
type: "string",
|
|
4835
|
+
enum: ["Free", "16:9", "9:16", "1:1", "4:3"],
|
|
4836
|
+
description: "Lock area aspect. Without area, fit on the selected display.",
|
|
4837
|
+
},
|
|
4670
4838
|
source: { type: "string", description: "From recording_list_sources." },
|
|
4671
4839
|
fps: { type: "number", description: "1-60, default 30." },
|
|
4672
4840
|
systemAudio: { type: "boolean", description: "Mic is never captured." },
|
|
@@ -5150,6 +5318,25 @@ const TOOLS = [
|
|
|
5150
5318
|
},
|
|
5151
5319
|
command: "media.generate-music",
|
|
5152
5320
|
},
|
|
5321
|
+
{
|
|
5322
|
+
name: "media_remove_background",
|
|
5323
|
+
description:
|
|
5324
|
+
"Remove a still image's background on this computer (people, products, pets, objects, logos). Writes <name>-cutout.png next to it; returns { path, width, height, engine }.",
|
|
5325
|
+
inputSchema: {
|
|
5326
|
+
type: "object",
|
|
5327
|
+
properties: {
|
|
5328
|
+
path: { type: "string", description: "Absolute image path." },
|
|
5329
|
+
trim: {
|
|
5330
|
+
type: "boolean",
|
|
5331
|
+
description: "Crop the empty margin (default true); false keeps the canvas size.",
|
|
5332
|
+
},
|
|
5333
|
+
outPath: { type: "string", description: "Optional absolute .png path." },
|
|
5334
|
+
engine: { type: "string", enum: ["auto", "vision", "birefnet"] },
|
|
5335
|
+
},
|
|
5336
|
+
required: ["path"],
|
|
5337
|
+
},
|
|
5338
|
+
command: "media.remove-background",
|
|
5339
|
+
},
|
|
5153
5340
|
{
|
|
5154
5341
|
name: "media_import",
|
|
5155
5342
|
description:
|
|
@@ -5689,7 +5876,7 @@ const TOOLS = [
|
|
|
5689
5876
|
{
|
|
5690
5877
|
name: "asset_list_fonts",
|
|
5691
5878
|
description:
|
|
5692
|
-
"Font families text can use: bundled, scriptFallbacks (Noto, used per character for Indic/Arabic/Hebrew/Thai text), custom (the user's imported fonts; prefer one covering the video's language), system with includeSystem. Pass one to caption_set_style fontFamily.",
|
|
5879
|
+
"Font families text can use (a missing one: asset_import_font): bundled, scriptFallbacks (Noto, used per character for Indic/Arabic/Hebrew/Thai text), custom (the user's imported fonts; prefer one covering the video's language), system with includeSystem. Pass one to caption_set_style fontFamily.",
|
|
5693
5880
|
inputSchema: {
|
|
5694
5881
|
type: "object",
|
|
5695
5882
|
properties: {
|
|
@@ -5698,6 +5885,23 @@ const TOOLS = [
|
|
|
5698
5885
|
},
|
|
5699
5886
|
command: "asset.list-fonts",
|
|
5700
5887
|
},
|
|
5888
|
+
{
|
|
5889
|
+
name: "asset_import_font",
|
|
5890
|
+
description:
|
|
5891
|
+
'Import a font like the font picker does, so captions, annotations and motion elements can use it: a local file (path: .ttf/.otf/.woff/.woff2, 30 MB max) or a Google Fonts family (google: "Montserrat", downloaded now). Only fonts the user owns or openly licensed ones. Returns { family } to pass as fontFamily; an already imported family is reused.',
|
|
5892
|
+
inputSchema: {
|
|
5893
|
+
type: "object",
|
|
5894
|
+
properties: {
|
|
5895
|
+
path: { type: "string", description: "Absolute font file path. Give path or google." },
|
|
5896
|
+
google: { type: "string", description: "Google Fonts family name. Give path or google." },
|
|
5897
|
+
family: {
|
|
5898
|
+
type: "string",
|
|
5899
|
+
description: "path only: name to register it under (default: the font's own family).",
|
|
5900
|
+
},
|
|
5901
|
+
},
|
|
5902
|
+
},
|
|
5903
|
+
command: "asset.import-font",
|
|
5904
|
+
},
|
|
5701
5905
|
|
|
5702
5906
|
// ── project-aware LLM ──────────────────────────────────────────
|
|
5703
5907
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@writepanda/mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.224.0",
|
|
4
4
|
"description": "Model Context Protocol server for PandaStudio. Exposes the desktop video editor's automation surface to Cursor, Continue, Cline, Claude Desktop, and any MCP-compliant client.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pandastudio",
|