@writepanda/mcp 1.222.0 → 1.225.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 CHANGED
@@ -71,6 +71,7 @@ Restart the client afterwards. The server auto-launches PandaStudio if it isn't
71
71
  | `caption_*` | 3 | Toggle, templates, per-style overrides |
72
72
  | `media_*` | 3 | Generate images, music, narration |
73
73
  | `preview_*` / `job_*` / `agent_*` / `audio_*` / `timeline_*` | 14 | Live preview overlay, async job polling, agent sessions, audio cleanup, time-base conversion |
74
+ | `review_*` | 5 | The pins, drawings and picked graphics the user marked on the preview: list them (with frame crops), resolve them when done, add / update / delete |
74
75
 
75
76
  Plus `pandastudio_call` — a raw `verb.noun` escape hatch for anything not yet a first-class tool.
76
77
 
@@ -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";
@@ -239,3 +239,171 @@ export function appLaunchEnv(env = process.env) {
239
239
  }
240
240
  return out;
241
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
@@ -42,11 +42,10 @@ import {
42
42
  cleanFileValue,
43
43
  configDir,
44
44
  connectInApp,
45
+ ensureApp,
45
46
  fileCredentials,
46
47
  IN_APP_UNAVAILABLE,
47
48
  isInAppCaller,
48
- probeHealth,
49
- waitForHealth,
50
49
  } from "./appConnection.mjs";
51
50
  import { portableSchema } from "./portableSchema.mjs";
52
51
 
@@ -56,10 +55,6 @@ import { portableSchema } from "./portableSchema.mjs";
56
55
 
57
56
  const IN_APP = isInAppCaller();
58
57
 
59
- async function readCredentials() {
60
- return fileCredentials(configDir());
61
- }
62
-
63
58
  function findInstalledApp() {
64
59
  if (process.env.PANDASTUDIO_BIN) return process.env.PANDASTUDIO_BIN;
65
60
  // The app records its own location (it can live anywhere on Windows,
@@ -102,46 +97,34 @@ function findInstalledApp() {
102
97
  return candidates.find((c) => existsSync(c)) ?? candidates[0];
103
98
  }
104
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".
105
104
  async function autoLaunchAndWait(timeoutSec = 60) {
106
- // Try a fast probe in case the user already has it running.
107
- try {
108
- const c = await readCredentials();
109
- // Busy (stalled main process) is not "not running": wait for it
110
- // instead of launching a second copy.
111
- if ((await waitForHealth(c.port)).state === "up") return c;
112
- } catch {
113
- /* not running */
114
- }
115
-
116
- const bin = findInstalledApp();
117
- try {
118
- // appLaunchEnv drops ELECTRON_RUN_AS_NODE: inherited, it made the
119
- // "launch" start a Node REPL that exits instead of PandaStudio.
120
- // windowsHide: false: this is the desktop app, whose window must show.
121
- const child = spawn(bin, [], {
122
- detached: true,
123
- stdio: "ignore",
124
- env: appLaunchEnv(),
125
- windowsHide: false,
126
- });
127
- child.unref();
128
- } catch (err) {
129
- throw new Error(`failed to launch PandaStudio (${bin}): ${err?.message ?? err}`);
130
- }
131
-
132
- const deadline = Date.now() + timeoutSec * 1000;
133
- while (Date.now() < deadline) {
134
- try {
135
- const c = await readCredentials();
136
- if ((await probeHealth(c.port, 2000)).state === "up") return c;
137
- } catch {
138
- /* still booting */
139
- }
140
- await new Promise((r) => setTimeout(r, 500));
141
- }
142
- throw new Error(
143
- `PandaStudio did not become ready within ${timeoutSec}s. Open PandaStudio, go to Settings → Automation and turn on Local automation.`,
144
- );
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
+ });
145
128
  }
146
129
 
147
130
  // The app answers /v1/call when the command finishes, and some commands run
@@ -1057,6 +1040,77 @@ const TOOLS = [
1057
1040
  },
1058
1041
  command: "workspace.contents",
1059
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
+ },
1060
1114
  {
1061
1115
  name: "workspace_get_brand",
1062
1116
  description:
@@ -1074,6 +1128,7 @@ const TOOLS = [
1074
1128
  brand: {
1075
1129
  type: ["object", "null"],
1076
1130
  properties: {
1131
+ savedColors: { type: "array", items: { type: "string" }, maxItems: 24 },
1077
1132
  name: { type: "string" },
1078
1133
  tagline: { type: "string" },
1079
1134
  colors: {
@@ -1524,6 +1579,35 @@ const TOOLS = [
1524
1579
  },
1525
1580
  command: "matte.bake",
1526
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
+ },
1527
1611
  {
1528
1612
  name: "project_read",
1529
1613
  description:
@@ -1787,6 +1871,15 @@ const TOOLS = [
1787
1871
  path: { type: "string" },
1788
1872
  overlayId: { type: "string" },
1789
1873
  slots: { type: "object", description: "Templates: changed slots only." },
1874
+ variables: {
1875
+ type: "object",
1876
+ description:
1877
+ "Typed values by id (motion_variables lists them). Validated by type/range/step/options; template slots and HyperFrames HTML declarations alike. Images embedded.",
1878
+ },
1879
+ preset: {
1880
+ type: "string",
1881
+ description: "Saved variable preset name (motion_list_presets); variables apply on top.",
1882
+ },
1790
1883
  background: { type: "string", enum: ["solid", "transparent", "glass"] },
1791
1884
  html: { type: "string", description: "HTML graphics: full replacement." },
1792
1885
  durationMs: { type: "number", description: "text-behind length." },
@@ -1955,6 +2048,24 @@ const TOOLS = [
1955
2048
  },
1956
2049
  command: "project.set-overlay-chroma-key",
1957
2050
  },
2051
+ {
2052
+ name: "project_remove_overlay_background",
2053
+ description:
2054
+ "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.",
2055
+ inputSchema: {
2056
+ type: "object",
2057
+ properties: {
2058
+ id: { type: "string" },
2059
+ path: { type: "string" },
2060
+ regionId: { type: "string" },
2061
+ restore: { type: "boolean" },
2062
+ engine: { type: "string", enum: ["auto", "vision", "birefnet"] },
2063
+ expectedRevision: { type: "number" },
2064
+ },
2065
+ required: ["regionId"],
2066
+ },
2067
+ command: "project.remove-overlay-background",
2068
+ },
1958
2069
  {
1959
2070
  name: "project_add_fx",
1960
2071
  description: "Add an FX overlay (bundled fxId or custom src).",
@@ -2072,6 +2183,40 @@ const TOOLS = [
2072
2183
  },
2073
2184
  command: "project.add-title-behind",
2074
2185
  },
2186
+ {
2187
+ name: "project_add_3d_captions",
2188
+ description:
2189
+ "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.",
2190
+ inputSchema: {
2191
+ type: "object",
2192
+ properties: {
2193
+ id: { type: "string" },
2194
+ path: { type: "string" },
2195
+ startMs: {
2196
+ type: "number",
2197
+ description: "Edited-timeline start (a pause between sentences).",
2198
+ },
2199
+ endMs: { type: "number", description: "Edited-timeline end, 5-20 s after startMs." },
2200
+ style: { type: "string", enum: ["bold", "editorial"], description: "Default bold." },
2201
+ heroWords: {
2202
+ type: "array",
2203
+ items: { type: "string" },
2204
+ description:
2205
+ "Words/short phrases to feature big. Default: the most stressed word per beat.",
2206
+ },
2207
+ accent: { type: "string", description: "Hero colour, CSS hex." },
2208
+ grade: { type: "boolean", description: "Film finish (curves, grain). Default true." },
2209
+ hideCaptions: {
2210
+ type: "boolean",
2211
+ description: "Hide editor captions over the range. Default true.",
2212
+ },
2213
+ force: { type: "boolean", description: "Run despite a moving camera or extreme close-up." },
2214
+ expectedRevision: { type: "number" },
2215
+ },
2216
+ required: ["id", "startMs", "endMs"],
2217
+ },
2218
+ command: "project.add-3d-captions",
2219
+ },
2075
2220
 
2076
2221
  // ── compose: visual regions ─────────────────────────────────────
2077
2222
  {
@@ -2968,6 +3113,28 @@ const TOOLS = [
2968
3113
  },
2969
3114
  command: "project.add-freeze-frame",
2970
3115
  },
3116
+ {
3117
+ name: "project_close_gap",
3118
+ description:
3119
+ "Close a gap on the timeline (the editor's right-click Close gap), one undo step. Times are EDITED ms (project_read / timeline_source_to_edited). lane main (default): ripple-delete startMs..endMs from the video; later content, captions and anchored graphics move back with their words; inserted pauses in the range are shortened or removed. lane media[:i] / audio[:i] / effects[:i] (zero-based sub-lane, packed by start time like the timeline rows): the range must be empty in that lane; every later item in that lane shifts left by the gap. Speed changes never move.",
3120
+ inputSchema: {
3121
+ type: "object",
3122
+ properties: {
3123
+ id: { type: "string", description: "Project id (or pass path)." },
3124
+ path: { type: "string", description: "Project file path (or pass id)." },
3125
+ startMs: { type: "number", description: "Gap start, edited ms." },
3126
+ endMs: { type: "number", description: "Gap end, edited ms (> startMs)." },
3127
+ lane: {
3128
+ type: "string",
3129
+ description: "main (default), or media[:i], audio[:i], effects[:i] for a sub-lane.",
3130
+ },
3131
+ expectedRevision: { type: "number" },
3132
+ },
3133
+ required: ["startMs", "endMs"],
3134
+ },
3135
+ command: "project.close-gap",
3136
+ },
3137
+
2971
3138
  {
2972
3139
  name: "project_remove_pause",
2973
3140
  description:
@@ -3240,7 +3407,7 @@ const TOOLS = [
3240
3407
  {
3241
3408
  name: "project_remove_region",
3242
3409
  description:
3243
- "Delete a region by id (editor.*Regions, motion-element = editor.motionElements, or audioOverlays); linked peers go too.",
3410
+ "Delete a region by id (editor.*Regions, motion-element = editor.motionElements, or audioOverlays); only this region is removed by default. Pass group=true to also remove linked peers.",
3244
3411
  inputSchema: {
3245
3412
  type: "object",
3246
3413
  properties: {
@@ -3248,6 +3415,7 @@ const TOOLS = [
3248
3415
  path: { type: "string" },
3249
3416
  regionType: { type: "string", enum: [...REGION_TYPES, "motion-element"] },
3250
3417
  regionId: { type: "string" },
3418
+ group: { type: "boolean", description: "Remove all linked peers too. Default false." },
3251
3419
  expectedRevision: { type: "number" },
3252
3420
  },
3253
3421
  required: ["regionType", "regionId"],
@@ -3346,7 +3514,7 @@ const TOOLS = [
3346
3514
  {
3347
3515
  name: "project_update_region",
3348
3516
  description:
3349
- "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 (startMs, endMs, opacity, blendMode, speed, zIndex), annotation (startMs, endMs, text = the words shown (content / textContent also work), x, y, width, height, the style fields below, 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). 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.",
3517
+ "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.",
3350
3518
  inputSchema: {
3351
3519
  type: "object",
3352
3520
  properties: {
@@ -3975,6 +4143,97 @@ const TOOLS = [
3975
4143
  },
3976
4144
  command: "project.remove-volume-keyframe",
3977
4145
  },
4146
+ {
4147
+ name: "project_set_audio_carve",
4148
+ description:
4149
+ "Voiceover carve on a music bed (audio overlay): while the main voice or a voiceover speaks, dip the bed's speech band (default 1000-4000 Hz) instead of lowering the whole track. amount 0..1 = deepest cut 2..18 dB (default 0.25 = 6 dB). Combine with a lighter project_set_audio_ducking. Omitted fields keep their value. Export accurate; the editor preview does not carve.",
4150
+ inputSchema: {
4151
+ type: "object",
4152
+ properties: {
4153
+ id: { type: "string" },
4154
+ path: { type: "string" },
4155
+ trackId: { type: "string", description: "The music bed's audio overlay id, e.g. audio-1." },
4156
+ enabled: { type: "boolean", description: "Carve on (default) or off." },
4157
+ amount: { type: "number", minimum: 0, maximum: 1 },
4158
+ lowHz: {
4159
+ type: "number",
4160
+ minimum: 160,
4161
+ maximum: 6000,
4162
+ description: "Band low edge (default 1000).",
4163
+ },
4164
+ highHz: {
4165
+ type: "number",
4166
+ minimum: 160,
4167
+ maximum: 6000,
4168
+ description: "Band high edge, above lowHz (default 4000).",
4169
+ },
4170
+ remove: { type: "boolean", description: "Delete the carve settings." },
4171
+ expectedRevision: { type: "number" },
4172
+ },
4173
+ required: ["trackId"],
4174
+ },
4175
+ command: "project.set-audio-carve",
4176
+ },
4177
+ {
4178
+ name: "project_set_audio_fx",
4179
+ description:
4180
+ 'Replace a track\'s ordered FX rack. trackId = audio overlay id or "main" (the main voice, one rack for all clips). fx = [{type, enabled?, params?}], [] clears. Types (params, ranges): highpass/lowpass (frequency Hz, q 0.1-20, poles "1"|"2"); lowshelf/highshelf (frequency, gain -40..40 dB); peaking (frequency, gain, q; a 3-band EQ = three peaking at 200/1000/4000 Hz); compressor (threshold -60..0 dB, ratio 1-20, attack 0.01-2000 ms, release 0.01-9000 ms, knee 1-8, makeup 0-36 dB, mix 0-1); deesser (intensity 0-1, frequency 0-1); reverb (size 0.05-1, damping 0-1, wet 0-1, dry 0-1); gain (gain -60..12 dB). Export applies all; preview: overlays hear EQ/gain/reverb, compressor approximated, de-esser export only; main voice previews gain only.',
4181
+ inputSchema: {
4182
+ type: "object",
4183
+ properties: {
4184
+ id: { type: "string" },
4185
+ path: { type: "string" },
4186
+ trackId: { type: "string", description: 'Audio overlay id, or "main".' },
4187
+ main: { type: "boolean", description: 'Same as trackId "main".' },
4188
+ fx: {
4189
+ type: "array",
4190
+ items: {
4191
+ type: "object",
4192
+ properties: {
4193
+ type: {
4194
+ type: "string",
4195
+ enum: [
4196
+ "highpass",
4197
+ "lowpass",
4198
+ "lowshelf",
4199
+ "highshelf",
4200
+ "peaking",
4201
+ "compressor",
4202
+ "deesser",
4203
+ "reverb",
4204
+ "gain",
4205
+ ],
4206
+ },
4207
+ enabled: { type: "boolean" },
4208
+ params: { type: "object" },
4209
+ },
4210
+ required: ["type"],
4211
+ },
4212
+ },
4213
+ expectedRevision: { type: "number" },
4214
+ },
4215
+ required: ["fx"],
4216
+ },
4217
+ command: "project.set-audio-fx",
4218
+ },
4219
+ {
4220
+ name: "project_set_audio_buses",
4221
+ description:
4222
+ "Mix group gains in dB (-60..12): voice (main track, voiceovers, overlay video sound), music (music beds), sfx (sound effects). Multiplies every track in the group after its own volume, ducking and FX. Omitted buses keep their value; reset sets all to 0 first. Preview and export match (preview caps each track at 100%).",
4223
+ inputSchema: {
4224
+ type: "object",
4225
+ properties: {
4226
+ id: { type: "string" },
4227
+ path: { type: "string" },
4228
+ voice: { type: "number", minimum: -60, maximum: 12 },
4229
+ music: { type: "number", minimum: -60, maximum: 12 },
4230
+ sfx: { type: "number", minimum: -60, maximum: 12 },
4231
+ reset: { type: "boolean" },
4232
+ expectedRevision: { type: "number" },
4233
+ },
4234
+ },
4235
+ command: "project.set-audio-buses",
4236
+ },
3978
4237
  {
3979
4238
  name: "project_set_audio_ducking",
3980
4239
  description:
@@ -4681,6 +4940,24 @@ const TOOLS = [
4681
4940
  inputSchema: {
4682
4941
  type: "object",
4683
4942
  properties: {
4943
+ area: {
4944
+ type: "object",
4945
+ description: "Display-local points. Native region capture. Minimum 32 x 32 points.",
4946
+ properties: {
4947
+ displayId: { type: "number" },
4948
+ x: { type: "number" },
4949
+ y: { type: "number" },
4950
+ width: { type: "number", minimum: 32 },
4951
+ height: { type: "number", minimum: 32 },
4952
+ },
4953
+ required: ["displayId", "x", "y", "width", "height"],
4954
+ additionalProperties: false,
4955
+ },
4956
+ aspectRatio: {
4957
+ type: "string",
4958
+ enum: ["Free", "16:9", "9:16", "1:1", "4:3"],
4959
+ description: "Lock area aspect. Without area, fit on the selected display.",
4960
+ },
4684
4961
  source: { type: "string", description: "From recording_list_sources." },
4685
4962
  fps: { type: "number", description: "1-60, default 30." },
4686
4963
  systemAudio: { type: "boolean", description: "Mic is never captured." },
@@ -5164,6 +5441,25 @@ const TOOLS = [
5164
5441
  },
5165
5442
  command: "media.generate-music",
5166
5443
  },
5444
+ {
5445
+ name: "media_remove_background",
5446
+ description:
5447
+ "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 }.",
5448
+ inputSchema: {
5449
+ type: "object",
5450
+ properties: {
5451
+ path: { type: "string", description: "Absolute image path." },
5452
+ trim: {
5453
+ type: "boolean",
5454
+ description: "Crop the empty margin (default true); false keeps the canvas size.",
5455
+ },
5456
+ outPath: { type: "string", description: "Optional absolute .png path." },
5457
+ engine: { type: "string", enum: ["auto", "vision", "birefnet"] },
5458
+ },
5459
+ required: ["path"],
5460
+ },
5461
+ command: "media.remove-background",
5462
+ },
5167
5463
  {
5168
5464
  name: "media_import",
5169
5465
  description:
@@ -5387,7 +5683,7 @@ const TOOLS = [
5387
5683
  {
5388
5684
  name: "motion_list",
5389
5685
  description:
5390
- "List motion-graphic templates (slots, family, tags; render with motion_generate) and slot-less registryBlocks (edit htmlPath, render with motion_render_html). Retired templates are hidden unless includeRetired.",
5686
+ "List motion-graphic templates (each with typed variables: type, default, min/max/step, options; plus slots, family, tags) and registryBlocks (with any declared variables). Render templates with motion_generate, blocks with motion_render_html. Retired templates are hidden unless includeRetired.",
5391
5687
  inputSchema: {
5392
5688
  type: "object",
5393
5689
  properties: {
@@ -5405,6 +5701,58 @@ const TOOLS = [
5405
5701
  },
5406
5702
  command: "motion.list",
5407
5703
  },
5704
+ {
5705
+ name: "motion_variables",
5706
+ description:
5707
+ "Typed variables of a graphic (text, multiline, number, color, image, font, boolean, enum, list) with defaults/constraints, its current values and preset templateKey. source = an overlay's generatedFrom from project_read (template or HyperFrames HTML).",
5708
+ inputSchema: {
5709
+ type: "object",
5710
+ properties: { source: { type: "object" } },
5711
+ required: ["source"],
5712
+ },
5713
+ command: "motion.variables",
5714
+ },
5715
+ {
5716
+ name: "motion_list_presets",
5717
+ description:
5718
+ "List the active workspace's saved variable presets {name, templateKey, values}. Pass source or templateId to list only those for that graphic.",
5719
+ inputSchema: {
5720
+ type: "object",
5721
+ properties: { source: { type: "object" }, templateId: { type: "string" } },
5722
+ },
5723
+ command: "motion.list-presets",
5724
+ },
5725
+ {
5726
+ name: "motion_save_preset",
5727
+ description:
5728
+ "Save a named variable set (e.g. 'Client A lower third') for graphics of the same template in this workspace; same name replaces. Images are embedded.",
5729
+ inputSchema: {
5730
+ type: "object",
5731
+ properties: {
5732
+ source: { type: "object" },
5733
+ name: { type: "string" },
5734
+ variables: { type: "object" },
5735
+ },
5736
+ required: ["source", "name", "variables"],
5737
+ },
5738
+ command: "motion.save-preset",
5739
+ },
5740
+ {
5741
+ name: "motion_delete_preset",
5742
+ description:
5743
+ "Delete a saved variable preset by name; identify its template with source, templateId or templateKey.",
5744
+ inputSchema: {
5745
+ type: "object",
5746
+ properties: {
5747
+ name: { type: "string" },
5748
+ source: { type: "object" },
5749
+ templateId: { type: "string" },
5750
+ templateKey: { type: "string" },
5751
+ },
5752
+ required: ["name"],
5753
+ },
5754
+ command: "motion.delete-preset",
5755
+ },
5408
5756
  {
5409
5757
  name: "motion_generate",
5410
5758
  description:
@@ -5414,6 +5762,15 @@ const TOOLS = [
5414
5762
  properties: {
5415
5763
  templateId: { type: "string" },
5416
5764
  slots: { type: "object", description: "By key; image slots take a file path." },
5765
+ variables: {
5766
+ type: "object",
5767
+ description:
5768
+ "Typed values by id (templates[].variables from motion_list), validated, overriding slots. Images: path, URL or {url}; fonts: name or {name}.",
5769
+ },
5770
+ preset: {
5771
+ type: "string",
5772
+ description: "Saved variable preset name for this template (motion_list_presets).",
5773
+ },
5417
5774
  aspectRatio: { type: "string", enum: ["16:9", "9:16", "1:1"] },
5418
5775
  background: {
5419
5776
  type: "string",
@@ -5426,7 +5783,7 @@ const TOOLS = [
5426
5783
  description: "Length for templates with a durationRangeMs (clamped); ignored otherwise.",
5427
5784
  },
5428
5785
  },
5429
- required: ["templateId", "slots"],
5786
+ required: ["templateId"],
5430
5787
  },
5431
5788
  command: "motion.generate",
5432
5789
  },
@@ -5469,7 +5826,8 @@ const TOOLS = [
5469
5826
  },
5470
5827
  {
5471
5828
  name: "motion_catalog_item",
5472
- description: "One catalog item: variables, htmlPath, demoPath and a mount snippet.",
5829
+ description:
5830
+ "One catalog item: variables (raw and typedVariables), htmlPath, demoPath and a mount snippet.",
5473
5831
  inputSchema: {
5474
5832
  type: "object",
5475
5833
  properties: { name: { type: "string" } },
@@ -5527,6 +5885,23 @@ const TOOLS = [
5527
5885
  },
5528
5886
  command: "motion.render-film",
5529
5887
  },
5888
+ {
5889
+ name: "motion_edit_element",
5890
+ description:
5891
+ 'Edit one graphic element, write stored HTML and re-render in place with the same timeline timing. Async jobId. selector: #id or [data-panda-element="handle"]. patch: text, x/y (paired translation in px), width/height (px), rotation (degrees), layer (z-index), styles (color, background-color, font-family, font-size, font-weight, border-radius, opacity). Mapped text-only edits preserve template slots; structural edits become custom HTML.',
5892
+ inputSchema: {
5893
+ type: "object",
5894
+ properties: {
5895
+ id: { type: "string" },
5896
+ regionId: { type: "string" },
5897
+ selector: { type: "string" },
5898
+ patch: { type: "object" },
5899
+ expectedRevision: { type: "number" },
5900
+ },
5901
+ required: ["id", "regionId", "selector", "patch"],
5902
+ },
5903
+ command: "motion.edit-element",
5904
+ },
5530
5905
  {
5531
5906
  name: "motion_render_html",
5532
5907
  description:
@@ -6190,6 +6565,83 @@ const TOOLS = [
6190
6565
  command: "job.get",
6191
6566
  },
6192
6567
 
6568
+ // ── review items (pins / drawings the user placed on the preview) ──
6569
+ {
6570
+ name: "review_list",
6571
+ description:
6572
+ "The review items the user marked on the preview (numbered pins and drawings, each with the graphic or element under it), open ones by default. Each: id, timeMs and optional range (edited ms), position {x,y} (0..1 of the frame), drawing points, targetIds (graphic / element / region / clip ids), note, sentAt, and imagePath: a PNG crop of the frame with the drawing burned in. Defaults to the open project.",
6573
+ inputSchema: {
6574
+ type: "object",
6575
+ properties: {
6576
+ id: { type: "string", description: "Project id. Omit for the open project." },
6577
+ path: { type: "string" },
6578
+ includeResolved: { type: "boolean", description: "Also list resolved items." },
6579
+ },
6580
+ },
6581
+ command: "review.list",
6582
+ },
6583
+ {
6584
+ name: "review_resolve",
6585
+ description:
6586
+ "Mark review items done after you applied them: their pins leave the preview and the Pending edits tray. Needs the project id (or path) and the item ids.",
6587
+ inputSchema: {
6588
+ type: "object",
6589
+ properties: {
6590
+ id: { type: "string" },
6591
+ path: { type: "string" },
6592
+ annotationIds: { type: "array", items: { type: "string" } },
6593
+ },
6594
+ required: ["annotationIds"],
6595
+ },
6596
+ command: "review.resolve",
6597
+ },
6598
+
6599
+ {
6600
+ name: "review_add",
6601
+ description:
6602
+ "Add a review item (numbered pin) to the project. annotation: { timeMs (edited ms), position {x,y} 0..1, note, kind comment|freehand, targetIds?, range? {startMs,endMs}, drawing? [{x,y}], id? }.",
6603
+ inputSchema: {
6604
+ type: "object",
6605
+ properties: {
6606
+ id: { type: "string" },
6607
+ path: { type: "string" },
6608
+ annotation: { type: "object" },
6609
+ },
6610
+ required: ["annotation"],
6611
+ },
6612
+ command: "review.add",
6613
+ },
6614
+ {
6615
+ name: "review_update",
6616
+ description:
6617
+ "Change a review item's fields (note, range, position, targetIds, resolved); other fields are kept.",
6618
+ inputSchema: {
6619
+ type: "object",
6620
+ properties: {
6621
+ id: { type: "string" },
6622
+ path: { type: "string" },
6623
+ annotationId: { type: "string" },
6624
+ annotation: { type: "object", description: "Fields to change." },
6625
+ },
6626
+ required: ["annotationId", "annotation"],
6627
+ },
6628
+ command: "review.update",
6629
+ },
6630
+ {
6631
+ name: "review_delete",
6632
+ description: "Delete a review item. Use review_resolve for items you completed.",
6633
+ inputSchema: {
6634
+ type: "object",
6635
+ properties: {
6636
+ id: { type: "string" },
6637
+ path: { type: "string" },
6638
+ annotationId: { type: "string" },
6639
+ },
6640
+ required: ["annotationId"],
6641
+ },
6642
+ command: "review.delete",
6643
+ },
6644
+
6193
6645
  // ── timeline helpers ────────────────────────────────────────────
6194
6646
  {
6195
6647
  name: "timeline_source_to_edited",
@@ -6221,6 +6673,35 @@ const TOOLS = [
6221
6673
  command: "timeline.edited-to-source",
6222
6674
  },
6223
6675
 
6676
+ {
6677
+ name: "agent_run_list",
6678
+ description:
6679
+ "List a project's agent runs that changed it (newest last): runId, sessionId, prompt, and whether each is currently undone.",
6680
+ inputSchema: {
6681
+ type: "object",
6682
+ properties: {
6683
+ projectId: { type: "string" },
6684
+ sessionId: { type: "string", description: "Only runs from this chat" },
6685
+ },
6686
+ required: ["projectId"],
6687
+ },
6688
+ command: "agent.run-list",
6689
+ },
6690
+ {
6691
+ name: "agent_run_restore",
6692
+ description:
6693
+ "Undo an agent run (restore the whole project as it was before the run), or redo it when it is undone. If edits were made after the run it changes nothing and answers confirmationRequired with laterEdits and a revision: show the user what would be lost, and only after they agree call again with confirmRevision set to that revision.",
6694
+ inputSchema: {
6695
+ type: "object",
6696
+ properties: {
6697
+ projectId: { type: "string" },
6698
+ runId: { type: "string" },
6699
+ confirmRevision: { type: "number" },
6700
+ },
6701
+ required: ["projectId", "runId"],
6702
+ },
6703
+ command: "agent.run-restore",
6704
+ },
6224
6705
  ...["read", "search"].map((action) => ({
6225
6706
  name: `agent_session_${action}`,
6226
6707
  description:
@@ -6302,6 +6783,8 @@ Publishing has daily limits (YouTube uploads and updates per license, social pos
6302
6783
 
6303
6784
  PandaStudio problems (a capability gap like an imported font the export can't draw, a command failing unexpectedly, a render not matching the project): call system_report_issue yourself, then tell the user you reported it. Not for user mistakes, missing keys or taste.
6304
6785
 
6786
+ Review items: the user can pin notes and drawings on the preview and send them in one turn (listed in the editor context and the message, crops attached in item order). Apply each one, then review_resolve with its id. review_list shows the open ones with a crop image path.
6787
+
6305
6788
  Destination decides everything: infer it (Shorts/TikTok/vertical/<=90s: shorts; YouTube/tutorial/>8 min: youtube-long; LinkedIn; Loom/internal), else ask once.
6306
6789
 
6307
6790
  Full playbook (per-destination defaults, motion graphics, recipes, publishing): the PandaStudio skill. If you can't load it as a skill, call skill_read with no arguments at the start, then read the sections and reference docs the task needs. The rules above are only a summary.`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writepanda/mcp",
3
- "version": "1.222.0",
3
+ "version": "1.225.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",