@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.
@@ -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 (!fromEnv || fromFiles.port !== fromEnv.port || fromFiles.token !== fromEnv.token) {
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
- // Try a fast probe in case the user already has it running.
106
- try {
107
- const c = await readCredentials();
108
- // Busy (stalled main process) is not "not running": wait for it
109
- // instead of launching a second copy.
110
- if ((await waitForHealth(c.port)).state === "up") return c;
111
- } catch {
112
- /* not running */
113
- }
114
-
115
- const bin = findInstalledApp();
116
- try {
117
- // appLaunchEnv drops ELECTRON_RUN_AS_NODE: inherited, it made the
118
- // "launch" start a Node REPL that exits instead of PandaStudio.
119
- const child = spawn(bin, [], { detached: true, stdio: "ignore", env: appLaunchEnv() });
120
- child.unref();
121
- } catch (err) {
122
- throw new Error(`failed to launch PandaStudio (${bin}): ${err?.message ?? err}`);
123
- }
124
-
125
- const deadline = Date.now() + timeoutSec * 1000;
126
- while (Date.now() < deadline) {
127
- try {
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
- res = await postCall(creds, command, args);
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 / speed (startMs, endMs, speed, rampIn, rampOut, preservePitch, freezeMs, reverse, reverseAudio), fx (atMs, durationMs, opacity, blendMode, speed, zIndex), annotation (zIndex), overlay (x, y, width, height in canvas %, layer, opacity, blendMode, muted, volume, loop, motionBlur, zIndex), adjustment (startMs, endMs, blendMode), motion (motionBlur), clip-transform (startMs, endMs, preset, transitionMs, easing, bezier), background-effect (as project_add_background_effect), audio-overlay (startMs, endMs, sourceStartMs, volume 0-2, fadeIn, fadeOut).",
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.221.0",
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",