@writepanda/mcp 1.180.0 → 1.191.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/server.mjs CHANGED
@@ -29,7 +29,6 @@
29
29
 
30
30
  import { spawn } from "node:child_process";
31
31
  import { existsSync, readFileSync } from "node:fs";
32
- import fs from "node:fs/promises";
33
32
  import http from "node:http";
34
33
  import os from "node:os";
35
34
  import path from "node:path";
@@ -37,78 +36,26 @@ import process from "node:process";
37
36
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
38
37
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
39
38
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
39
+ import {
40
+ appLaunchEnv,
41
+ cleanFileValue,
42
+ configDir,
43
+ connectInApp,
44
+ fileCredentials,
45
+ IN_APP_UNAVAILABLE,
46
+ isInAppCaller,
47
+ probeHealth,
48
+ waitForHealth,
49
+ } from "./appConnection.mjs";
40
50
 
41
51
  // ── Credentials + transport ───────────────────────────────────────────
52
+ //
53
+ // Finding and reaching the app lives in appConnection.mjs (unit-tested).
42
54
 
43
- function configDir() {
44
- if (process.env.PANDASTUDIO_CONFIG_DIR) return process.env.PANDASTUDIO_CONFIG_DIR;
45
- if (process.platform === "win32") {
46
- const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
47
- return path.join(appData, "pandastudio");
48
- }
49
- return path.join(os.homedir(), ".config", "pandastudio");
50
- }
51
-
52
- const TOKEN_FILE = path.join(configDir(), "token");
53
- const PORT_FILE = path.join(configDir(), "port");
55
+ const IN_APP = isInAppCaller();
54
56
 
55
57
  async function readCredentials() {
56
- const port = Number((await fs.readFile(PORT_FILE, "utf-8")).trim());
57
- const token = (await fs.readFile(TOKEN_FILE, "utf-8")).trim();
58
- if (!Number.isInteger(port) || port <= 0) throw new Error(`invalid port in ${PORT_FILE}`);
59
- if (!token) throw new Error(`empty token in ${TOKEN_FILE}`);
60
- return { port, token };
61
- }
62
-
63
- async function probeHealth(port, timeoutMs = 1500) {
64
- const ctrl = new AbortController();
65
- const t = setTimeout(() => ctrl.abort(), timeoutMs);
66
- try {
67
- const res = await fetch(`http://127.0.0.1:${port}/v1/health`, { signal: ctrl.signal });
68
- clearTimeout(t);
69
- return res.ok;
70
- } catch {
71
- clearTimeout(t);
72
- return false;
73
- }
74
- }
75
-
76
- /**
77
- * One health probe: "up" (answered), "busy" (the socket is open but no answer
78
- * within `timeoutMs`: the app's main process is stalled, e.g. creating an
79
- * editor window on a loaded machine) or "down" (connection refused / reset:
80
- * not running).
81
- */
82
- async function probeHealthState(port, timeoutMs) {
83
- const ctrl = new AbortController();
84
- const t = setTimeout(() => ctrl.abort(), timeoutMs);
85
- try {
86
- const res = await fetch(`http://127.0.0.1:${port}/v1/health`, { signal: ctrl.signal });
87
- return res.ok ? "up" : "down";
88
- } catch {
89
- return ctrl.signal.aborted ? "busy" : "down";
90
- } finally {
91
- clearTimeout(t);
92
- }
93
- }
94
-
95
- /**
96
- * Is the app reachable? A busy app is waited for (up to `busyWaitMs`) rather
97
- * than reported as not running: a single 1.5 s probe used to fail whenever the
98
- * main process stalled for longer (window creation takes ~0.4 s idle and
99
- * several seconds under load), so agents saw "not reachable" at random.
100
- */
101
- async function appReachable(port, { probeMs = 1500, busyWaitMs = 30_000, onBusy } = {}) {
102
- const deadline = Date.now() + busyWaitMs;
103
- let warned = false;
104
- for (;;) {
105
- const s = await probeHealthState(port, probeMs);
106
- if (s === "up") return true;
107
- if (s === "down" || Date.now() >= deadline) return false;
108
- if (!warned && onBusy) onBusy();
109
- warned = true;
110
- probeMs = Math.min(probeMs * 2, 5000);
111
- }
58
+ return fileCredentials(configDir());
112
59
  }
113
60
 
114
61
  function findInstalledApp() {
@@ -116,7 +63,7 @@ function findInstalledApp() {
116
63
  // The app records its own location (it can live anywhere on Windows,
117
64
  // and in ~/Applications on a Mac).
118
65
  try {
119
- const recorded = readFileSync(path.join(configDir(), "app-path"), "utf-8").trim();
66
+ const recorded = cleanFileValue(readFileSync(path.join(configDir(), "app-path"), "utf-8"));
120
67
  if (recorded && existsSync(recorded)) return recorded;
121
68
  } catch {
122
69
  /* older app, or never launched */
@@ -159,14 +106,16 @@ async function autoLaunchAndWait(timeoutSec = 60) {
159
106
  const c = await readCredentials();
160
107
  // Busy (stalled main process) is not "not running": wait for it
161
108
  // instead of launching a second copy.
162
- if (await appReachable(c.port)) return c;
109
+ if ((await waitForHealth(c.port)).state === "up") return c;
163
110
  } catch {
164
111
  /* not running */
165
112
  }
166
113
 
167
114
  const bin = findInstalledApp();
168
115
  try {
169
- const child = spawn(bin, [], { detached: true, stdio: "ignore", env: process.env });
116
+ // appLaunchEnv drops ELECTRON_RUN_AS_NODE: inherited, it made the
117
+ // "launch" start a Node REPL that exits instead of PandaStudio.
118
+ const child = spawn(bin, [], { detached: true, stdio: "ignore", env: appLaunchEnv() });
170
119
  child.unref();
171
120
  } catch (err) {
172
121
  throw new Error(`failed to launch PandaStudio (${bin}): ${err?.message ?? err}`);
@@ -176,7 +125,7 @@ async function autoLaunchAndWait(timeoutSec = 60) {
176
125
  while (Date.now() < deadline) {
177
126
  try {
178
127
  const c = await readCredentials();
179
- if (await probeHealth(c.port, 2000)) return c;
128
+ if ((await probeHealth(c.port, 2000)).state === "up") return c;
180
129
  } catch {
181
130
  /* still booting */
182
131
  }
@@ -211,18 +160,50 @@ function httpRequestLocal(port, pathSuffix, { method = "GET", headers = {}, body
211
160
  });
212
161
  }
213
162
 
214
- async function callPandastudio(command, args = {}) {
215
- const creds = await autoLaunchAndWait();
216
- const res = await httpRequestLocal(creds.port, "/v1/call", {
163
+ async function postCall(creds, command, args) {
164
+ return httpRequestLocal(creds.port, "/v1/call", {
217
165
  method: "POST",
218
166
  headers: {
219
167
  "Content-Type": "application/json",
220
168
  Authorization: `Bearer ${creds.token}`,
221
169
  // Set by the app when it runs this server for its own chat panel.
222
- ...(process.env.PANDASTUDIO_CALLER === "in-app" ? { "X-PandaStudio-Caller": "in-app" } : {}),
170
+ ...(IN_APP ? { "X-PandaStudio-Caller": "in-app" } : {}),
223
171
  },
224
172
  body: JSON.stringify({ command, args }),
225
173
  });
174
+ }
175
+
176
+ async function callPandastudio(command, args = {}) {
177
+ // In-app: the app is running (it spawned us). Never launch it, and fail in
178
+ // seconds with the real reason instead of outliving the client's timeout.
179
+ let creds = IN_APP ? await connectInApp() : await autoLaunchAndWait();
180
+ let res;
181
+ try {
182
+ res = await postCall(creds, command, args);
183
+ } catch (err) {
184
+ if (!IN_APP) throw err;
185
+ throw new Error(
186
+ `${IN_APP_UNAVAILABLE}: the call to 127.0.0.1:${creds.port} failed (${err?.code ?? err?.message ?? err}).`,
187
+ );
188
+ }
189
+ if (IN_APP && res.status === 401) {
190
+ // The app restarted its automation server (new token) after it
191
+ // spawned us. The files hold the current one.
192
+ try {
193
+ const fresh = await fileCredentials(configDir());
194
+ if (fresh.token !== creds.token || fresh.port !== creds.port) {
195
+ creds = fresh;
196
+ res = await postCall(creds, command, args);
197
+ }
198
+ } catch {
199
+ /* reported below */
200
+ }
201
+ if (res.status === 401) {
202
+ throw new Error(
203
+ `${IN_APP_UNAVAILABLE}: PandaStudio rejected the tools' access token (its automation server restarted).`,
204
+ );
205
+ }
206
+ }
226
207
  const text = res.text;
227
208
  let body;
228
209
  try {
@@ -565,12 +546,75 @@ const ASSETS_PROP = {
565
546
  };
566
547
  const ANCHOR_PROP = {
567
548
  type: "number",
568
- description: "SOURCE ms (a transcript word's startMs) to pin to, surviving cuts.",
549
+ description: "SOURCE ms of a transcript word; survives cuts.",
569
550
  };
570
551
  const ANCHOR_END_PROP = { type: "number", description: "Anchor end, SOURCE ms." };
571
552
  const KEYFRAME_TARGET_DOC =
572
553
  "main = motion region (its target decides fields); overlay: x y scale rotation opacity backdropBlur keyStrength; annotation: x y scale rotation opacity; adjustment: effects, amount, lookIntensity; focus: x y width height roundness feathering blurAmount pixelSize maskOpacity; background-effect: amount strength; mask (regionId = overlay id): x y width height roundness feather expand.";
573
554
 
555
+ const MOTION_ELEMENT_TYPES = [
556
+ "keyword",
557
+ "chip",
558
+ "stamp",
559
+ "count",
560
+ "steps",
561
+ "slam",
562
+ "behind",
563
+ "lowerThird",
564
+ "highlight",
565
+ "endCard",
566
+ "progress",
567
+ "iconPop",
568
+ "frame",
569
+ "sticker",
570
+ ];
571
+ const MOTION_ELEMENT_PROPS = {
572
+ type: { type: "string", enum: MOTION_ELEMENT_TYPES },
573
+ content: {
574
+ type: ["object", "string", "array"],
575
+ description:
576
+ 'keyword/chip/stamp/slam/behind/highlight { text } (or a bare string); count { value, to?, prefix?, suffix?, label?, decimals? } (value "20-30" = range); steps { items[], cues? (ms offsets from start) }; lowerThird { title, subtitle? }; endCard { lines: [a, b] }; progress { label?, fromMs?, toMs? }; iconPop { emoji } | { icon }; frame { look: viewfinder | selection | corners, label? }; sticker { image: transparent PNG path, label? }. Latin script only.',
577
+ },
578
+ atMs: { type: "number", description: "Start, edited ms. Or wordId." },
579
+ wordId: {
580
+ type: "string",
581
+ description: "Start on this transcript word; follows it through cuts.",
582
+ },
583
+ offsetMs: { type: "number", description: "Shift from atMs/wordId (may be negative)." },
584
+ durationMs: { type: "number", description: "Default endWordId, else the type's length." },
585
+ endWordId: { type: "string", description: "Run to the end of this word." },
586
+ cueWordIds: {
587
+ type: "array",
588
+ items: { type: "string" },
589
+ description: "steps: one word id per item.",
590
+ },
591
+ style: {
592
+ type: "object",
593
+ properties: {
594
+ family: { type: "string", enum: ["bold", "editorial", "clean", "playful", "paper"] },
595
+ accent: { type: "string" },
596
+ highlight: { type: "string" },
597
+ text: { type: "string" },
598
+ font: { type: "string" },
599
+ size: { type: "string", enum: ["s", "m", "l", "xl"] },
600
+ outline: { type: "boolean" },
601
+ },
602
+ description: "Default: the brand kit.",
603
+ },
604
+ zone: {
605
+ type: ["string", "object"],
606
+ description:
607
+ "auto (clear of the face) | top | upper | center | lower | bottom | { x, y, w? } (% of frame; x,y = centre).",
608
+ },
609
+ layer: { type: "string", enum: ["front", "behind"] },
610
+ sound: {
611
+ type: "string",
612
+ enum: ["auto", "none", "pop", "whoosh", "hit", "tick", "bell"],
613
+ description: "Role for project_compose_soundtrack.",
614
+ },
615
+ zIndex: { type: "number" },
616
+ anchor: { type: "string", enum: ["auto", "free"] },
617
+ };
574
618
  const TOOLS = [
575
619
  // ── system + discovery ──────────────────────────────────────────
576
620
  {
@@ -660,7 +704,7 @@ const TOOLS = [
660
704
  {
661
705
  name: "system_set_transcription_language",
662
706
  description:
663
- "Set the workspace transcription language. Non-auto uses Whisper, a ~1.1 GB download: tell the user first (system.is-whisper-model-downloaded via pandastudio_call).",
707
+ "Set the workspace transcription language. English = auto (english/en are saved as auto). Non-auto uses Whisper, a ~1.1 GB download: tell the user first (system.is-whisper-model-downloaded via pandastudio_call).",
664
708
  inputSchema: {
665
709
  type: "object",
666
710
  properties: {
@@ -668,6 +712,8 @@ const TOOLS = [
668
712
  type: "string",
669
713
  enum: [
670
714
  "auto",
715
+ "english",
716
+ "en",
671
717
  "chinese",
672
718
  "japanese",
673
719
  "korean",
@@ -1347,6 +1393,11 @@ const TOOLS = [
1347
1393
  slots: { type: "object", description: "Templates: changed slots only." },
1348
1394
  background: { type: "string", enum: ["solid", "transparent", "glass"] },
1349
1395
  html: { type: "string", description: "HTML graphics: full replacement." },
1396
+ durationMs: { type: "number", description: "text-behind length." },
1397
+ templateId: {
1398
+ type: "string",
1399
+ description: "Switch template (e.g. a retired one's replacement); slots map across.",
1400
+ },
1350
1401
  expectedRevision: { type: "number" },
1351
1402
  },
1352
1403
  required: ["overlayId"],
@@ -1526,16 +1577,33 @@ const TOOLS = [
1526
1577
  },
1527
1578
  {
1528
1579
  name: "project_add_transition",
1529
- description: "Place a transition centred on a cut (atMs = a clip boundary).",
1580
+ description:
1581
+ "Place a transition centred on a cut (a clip boundary or a jump cut). kind overlay (fade-black, flash, glitch, torn-paper…) draws a WebM over the cut; kind native (zoom-blur, whip-left, whip-right, whip-up, spin) moves the footage itself (captions/graphics stay put), 350 ms default. Snaps atMs to a cut within snapMs (default 500) and places the transition's default sound.",
1530
1582
  inputSchema: {
1531
1583
  type: "object",
1532
1584
  properties: {
1533
1585
  id: { type: "string" },
1534
1586
  path: { type: "string" },
1535
- transitionId: { type: "string", description: "asset_list_transitions id." },
1536
- file: { type: "string", description: "Instead of transitionId." },
1537
- atMs: { type: "number" },
1538
- durationMs: { type: "number", description: "Default 1000." },
1587
+ transitionId: {
1588
+ type: "string",
1589
+ description: "asset_list_transitions id (overlay or native kind).",
1590
+ },
1591
+ file: { type: "string", description: "Custom overlay WebM, instead of transitionId." },
1592
+ atMs: { type: "number", description: "The cut (edited ms)." },
1593
+ durationMs: {
1594
+ type: "number",
1595
+ description: "Default: the catalog's (1000 overlays, 900 torn-paper, 350 native).",
1596
+ },
1597
+ snapMs: {
1598
+ type: "number",
1599
+ description: "Snap to the nearest cut within this many ms. Default 500; 0 = exact.",
1600
+ },
1601
+ sound: {
1602
+ type: "string",
1603
+ description:
1604
+ "Bundled sound id / path. Default: the transition's defaultSoundId. none = silent.",
1605
+ },
1606
+ soundVolume: { type: "number", description: "0-1." },
1539
1607
  expectedRevision: { type: "number" },
1540
1608
  },
1541
1609
  required: ["atMs"],
@@ -1545,7 +1613,7 @@ const TOOLS = [
1545
1613
  {
1546
1614
  name: "project_add_lower_third",
1547
1615
  description:
1548
- "Render an lt-* nameplate and place it at atMs in one call (designs: motion_list category lower-third). ASYNC: { jobId }; job_wait gives result.overlayId once placed.",
1616
+ "Render a nameplate and place it at atMs in one call, laid out for the project's aspect. ASYNC: { jobId }; job_wait gives result.overlayId once placed.",
1549
1617
  inputSchema: {
1550
1618
  type: "object",
1551
1619
  properties: {
@@ -1553,12 +1621,16 @@ const TOOLS = [
1553
1621
  path: { type: "string" },
1554
1622
  name: { type: "string" },
1555
1623
  title: { type: "string", description: "Second line (role / handle)." },
1556
- templateId: { type: "string", description: "Default lt-vox-marker." },
1624
+ templateId: {
1625
+ type: "string",
1626
+ description:
1627
+ "lt-vox-marker (default, bold marker) | lt-glass-card | lt-minimal-line | lt-bold-bar | lt-logo-name (slots.logo) | lt-duo (slots.name2/title2).",
1628
+ },
1557
1629
  atMs: { type: "number" },
1558
1630
  aspectRatio: {
1559
1631
  type: "string",
1560
1632
  enum: ["16:9", "9:16", "1:1"],
1561
- description: "Match the project.",
1633
+ description: "Default: the project's aspect.",
1562
1634
  },
1563
1635
  slots: { type: "object", description: "Extra slot values (motion_list)." },
1564
1636
  soundUrl: { type: "string", description: 'Default bundled:sound/mouse-click; "none".' },
@@ -1575,6 +1647,29 @@ const TOOLS = [
1575
1647
  },
1576
1648
  command: "project.add-lower-third",
1577
1649
  },
1650
+ {
1651
+ name: "project_add_title_behind",
1652
+ description:
1653
+ "1-3 huge words at head height BEHIND the presenter. ASYNC: job_wait gives regionId, face.",
1654
+ inputSchema: {
1655
+ type: "object",
1656
+ properties: {
1657
+ id: { type: "string" },
1658
+ path: { type: "string" },
1659
+ text: { type: "string" },
1660
+ atMs: { type: "number" },
1661
+ style: { type: "string", enum: ["3d", "bold", "serif"] },
1662
+ durationMs: { type: "number", description: "Default 3000." },
1663
+ accentColor: { type: "string" },
1664
+ position: { type: "string", enum: ["auto", "top", "center"] },
1665
+ animation: { type: "string", enum: ["in-out", "in", "none"] },
1666
+ behind: { type: "boolean", description: "false: in front (no face)." },
1667
+ anchorSourceMs: ANCHOR_PROP,
1668
+ },
1669
+ required: ["text", "atMs"],
1670
+ },
1671
+ command: "project.add-title-behind",
1672
+ },
1578
1673
 
1579
1674
  // ── compose: visual regions ─────────────────────────────────────
1580
1675
  {
@@ -2062,7 +2157,7 @@ const TOOLS = [
2062
2157
  {
2063
2158
  name: "project_render_frame",
2064
2159
  description:
2065
- "Render the composited frame at an edited time to a PNG (matches export). Returns { path, timeMs, maskRect (video rect in 0-1 image fractions, for mapping boxes to spotlight coords), warnings? } (tell the user warnings).",
2160
+ "Render the composited frame at an edited time to a PNG (matches export). Returns { path, timeMs (snapped to the export's frame grid, fps), maskRect (video rect in 0-1 image fractions, for mapping boxes to spotlight coords), warnings? } (tell the user warnings).",
2066
2161
  inputSchema: {
2067
2162
  type: "object",
2068
2163
  properties: {
@@ -2464,13 +2559,14 @@ const TOOLS = [
2464
2559
  // ── compose: edit existing regions ─────────────────────────────
2465
2560
  {
2466
2561
  name: "project_remove_region",
2467
- description: "Delete a region by id (editor.*Regions or audioOverlays); linked peers go too.",
2562
+ description:
2563
+ "Delete a region by id (editor.*Regions, motion-element = editor.motionElements, or audioOverlays); linked peers go too.",
2468
2564
  inputSchema: {
2469
2565
  type: "object",
2470
2566
  properties: {
2471
2567
  id: { type: "string" },
2472
2568
  path: { type: "string" },
2473
- regionType: { type: "string", enum: REGION_TYPES },
2569
+ regionType: { type: "string", enum: [...REGION_TYPES, "motion-element"] },
2474
2570
  regionId: { type: "string" },
2475
2571
  expectedRevision: { type: "number" },
2476
2572
  },
@@ -2500,6 +2596,7 @@ const TOOLS = [
2500
2596
  "spotlight",
2501
2597
  "motion",
2502
2598
  "adjustment",
2599
+ "motion-element",
2503
2600
  "audio-overlay",
2504
2601
  ],
2505
2602
  },
@@ -2649,12 +2746,14 @@ const TOOLS = [
2649
2746
  },
2650
2747
  {
2651
2748
  name: "project_set_crop",
2652
- description: "Crop the main video's source frame (0-1 fractions). Default 0,0,1,1 (no crop).",
2749
+ description:
2750
+ "Crop the main video's source frame (0-1 fractions), e.g. to cut baked-in black bars. Without clipId it applies to the project and every clip (a clip's own crop, like auto-reframe's, wins otherwise). Default 0,0,1,1 (no crop).",
2653
2751
  inputSchema: {
2654
2752
  type: "object",
2655
2753
  properties: {
2656
2754
  id: { type: "string" },
2657
2755
  path: { type: "string" },
2756
+ clipId: { type: "string", description: "Crop only this clip." },
2658
2757
  x: { type: "number" },
2659
2758
  y: { type: "number" },
2660
2759
  width: { type: "number" },
@@ -2792,6 +2891,10 @@ const TOOLS = [
2792
2891
  type: ["boolean", "string"],
2793
2892
  description: "streaming / true (-14 LUFS, default), podcast (-16), off / false.",
2794
2893
  },
2894
+ frameRate: {
2895
+ type: ["number", "string"],
2896
+ description: "auto (default) | 30 | 60 | source (fastest source).",
2897
+ },
2795
2898
  expectedRevision: { type: "number" },
2796
2899
  },
2797
2900
  },
@@ -2893,6 +2996,47 @@ const TOOLS = [
2893
2996
  },
2894
2997
  command: "project.add-audio",
2895
2998
  },
2999
+ {
3000
+ name: "project_add_sound_cues",
3001
+ description:
3002
+ "Place many timed SFX in one write (one undo). Each cue = an audio overlay. Same group again REPLACES its cues ([] clears). Returns overlayIds + warnings.",
3003
+ inputSchema: {
3004
+ type: "object",
3005
+ properties: {
3006
+ id: { type: "string" },
3007
+ path: { type: "string" },
3008
+ cues: {
3009
+ type: "array",
3010
+ maxItems: 500,
3011
+ items: {
3012
+ type: "object",
3013
+ properties: {
3014
+ sound: {
3015
+ type: "string",
3016
+ description: "Bundled id or absolute path.",
3017
+ },
3018
+ atMs: { type: "number", description: "Edited-timeline ms." },
3019
+ volume: { type: "number", description: "0-2." },
3020
+ durationMs: { type: "number" },
3021
+ sourceStartMs: { type: "number" },
3022
+ fadeInMs: { type: "number" },
3023
+ fadeOutMs: { type: "number" },
3024
+ anchorSourceMs: {
3025
+ type: "number",
3026
+ description: "SOURCE ms word anchor; omit for graphics.",
3027
+ },
3028
+ },
3029
+ required: ["sound", "atMs"],
3030
+ },
3031
+ },
3032
+ group: { type: "string", description: 'e.g. "sfx".' },
3033
+ volume: { type: "number", description: "Cue default, 0.8." },
3034
+ expectedRevision: { type: "number" },
3035
+ },
3036
+ required: ["cues"],
3037
+ },
3038
+ command: "project.add-sound-cues",
3039
+ },
2896
3040
  {
2897
3041
  name: "project_remove_audio",
2898
3042
  description:
@@ -3169,13 +3313,14 @@ const TOOLS = [
3169
3313
  {
3170
3314
  name: "transcript_transcribe",
3171
3315
  description:
3172
- "Transcribe clips with word timestamps. ASYNC: { jobId }, job_wait. Tell the user about result droppedWordEdits[] (fixes that may need redoing).",
3316
+ "Transcribe clips with word timestamps (default: only clips without words). To REDO a transcript (wrong language, bad run) fix the language first, then force: true (all clips) or clipId. Word fixes are re-applied; tell the user about result droppedWordEdits[] (fixes that may need redoing). ASYNC: { jobId }, job_wait.",
3173
3317
  inputSchema: {
3174
3318
  type: "object",
3175
3319
  properties: {
3176
3320
  id: { type: "string" },
3177
3321
  path: { type: "string" },
3178
- clipId: { type: "string" },
3322
+ clipId: { type: "string", description: "Only this clip, even if already transcribed." },
3323
+ force: { type: "boolean", description: "Re-transcribe clips that already have words." },
3179
3324
  },
3180
3325
  },
3181
3326
  command: "transcript.transcribe",
@@ -3364,7 +3509,8 @@ const TOOLS = [
3364
3509
  },
3365
3510
  {
3366
3511
  name: "caption_set_template",
3367
- description: "Pick a caption template (default glowStack).",
3512
+ description:
3513
+ "Pick a caption template (default glowStack); clears style overrides. Emphasis templates (hormoziEmphasis, tiltedBox, serifItalic, condensedCaps, scriptKeyword) style the IMPORTANT word of each phrase, not the spoken one; emphasis is detected on apply when missing (result.emphasis).",
3368
3514
  inputSchema: {
3369
3515
  type: "object",
3370
3516
  properties: {
@@ -3389,6 +3535,11 @@ const TOOLS = [
3389
3535
  "matrixDecode",
3390
3536
  "glitchRgb",
3391
3537
  "blendDifference",
3538
+ "hormoziEmphasis",
3539
+ "tiltedBox",
3540
+ "serifItalic",
3541
+ "condensedCaps",
3542
+ "scriptKeyword",
3392
3543
  ],
3393
3544
  },
3394
3545
  expectedRevision: { type: "number" },
@@ -3400,7 +3551,8 @@ const TOOLS = [
3400
3551
 
3401
3552
  {
3402
3553
  name: "caption_set_style",
3403
- description: "Override caption style fields (template kept); pass only what changes.",
3554
+ description:
3555
+ "Override caption style fields (template kept); pass only what changes. highlightMode emphasis/both detects emphasis words when missing (result.emphasis).",
3404
3556
  inputSchema: {
3405
3557
  type: "object",
3406
3558
  properties: {
@@ -3417,11 +3569,45 @@ const TOOLS = [
3417
3569
  strokeWidth: { type: "number" },
3418
3570
  fontSize: { type: "string", description: "e.g. '2.6rem' (1-5rem)." },
3419
3571
  uppercase: { type: "boolean" },
3572
+ highlightMode: {
3573
+ type: "string",
3574
+ enum: ["spoken", "emphasis", "both"],
3575
+ description: "spoken = word being said; emphasis = key words all group long.",
3576
+ },
3577
+ emphasisColor: { type: "string" },
3578
+ emphasisBackgroundColor: { type: "string", description: "'transparent' = no box." },
3579
+ emphasisScale: { type: "number", description: "0.5-2." },
3580
+ emphasisItalic: { type: "boolean" },
3581
+ emphasisFontFamily: {
3582
+ type: "string",
3583
+ description: "Engine font, e.g. Great Vibes, Playfair Display, Bebas Neue, Anton.",
3584
+ },
3585
+ boxRotation: { type: "number", description: "Box tilt degrees 0-15." },
3420
3586
  expectedRevision: { type: "number" },
3421
3587
  },
3422
3588
  },
3423
3589
  command: "caption.set-style",
3424
3590
  },
3591
+ {
3592
+ name: "caption_mark_emphasis",
3593
+ description:
3594
+ "Set the key words emphasis caption styles mark. No wordIds: detect from the speech map (strong moments + numbers; hand marks kept unless reset). wordIds: mark (emphasis true) or unmark (false). Returns { emphasizedWords, words }.",
3595
+ inputSchema: {
3596
+ type: "object",
3597
+ properties: {
3598
+ id: { type: "string" },
3599
+ path: { type: "string" },
3600
+ wordIds: { type: "array", items: { type: "string" }, description: "From transcript_get." },
3601
+ emphasis: { type: "boolean", description: "With wordIds; default true." },
3602
+ detect: { type: "boolean" },
3603
+ reset: { type: "boolean", description: "Detection replaces hand marks too." },
3604
+ minGapMs: { type: "number", description: "Default 1200." },
3605
+ everyMs: { type: "number", description: "Default 2500; lower = more words." },
3606
+ expectedRevision: { type: "number" },
3607
+ },
3608
+ },
3609
+ command: "caption.mark-emphasis",
3610
+ },
3425
3611
  {
3426
3612
  name: "caption_move",
3427
3613
  description:
@@ -3455,14 +3641,20 @@ const TOOLS = [
3455
3641
  {
3456
3642
  name: "media_generate_image",
3457
3643
  description:
3458
- "Generate one image (Replicate gpt-image-2, needs Replicate connected). Returns { imagePath }. 3:2 for 16:9, 2:3 for 9:16.",
3644
+ "Generate one image on the user's own image connector (auto: Replicate, else Higgsfield; their credits). Returns { imagePath, provider, model, transparent }. transparent=true: trimmed PNG sticker. No connector: code NO_IMAGE_CONNECTOR, so ask the user for their own images. Each call is a paid generation.",
3459
3645
  inputSchema: {
3460
3646
  type: "object",
3461
3647
  properties: {
3462
3648
  prompt: { type: "string", description: "Keep text out of the image." },
3463
- aspectRatio: { type: "string", enum: ["1:1", "3:2", "2:3"] },
3649
+ aspectRatio: {
3650
+ type: "string",
3651
+ enum: ["1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"],
3652
+ description: "Default 3:2. 16:9 / 9:16 for full-frame video stills.",
3653
+ },
3464
3654
  quality: { type: "string", enum: ["low", "medium", "high"] },
3465
3655
  referenceImagePath: { type: "string", description: "Style reference (path or URL)." },
3656
+ transparent: { type: "boolean", description: "Background removed (sticker PNG)." },
3657
+ provider: { type: "string", enum: ["auto", "replicate", "higgsfield"] },
3466
3658
  outputName: { type: "string" },
3467
3659
  },
3468
3660
  required: ["prompt"],
@@ -3613,6 +3805,350 @@ const TOOLS = [
3613
3805
  },
3614
3806
  command: "media.generate-presenter",
3615
3807
  },
3808
+ {
3809
+ name: "project_inspect_footage",
3810
+ description:
3811
+ "Import checks before styling camera footage: flat/log (needs a grade), a flat green/blue backdrop (key it), rotation (vertical phone/camera clips). Returns per clip { flat, backdrop, rotation, contrast, blackLevel, saturation, suggestions:[{ why, verb, args }] }; run the suggested verbs.",
3812
+ inputSchema: {
3813
+ type: "object",
3814
+ properties: { id: { type: "string" }, path: { type: "string" }, clipId: { type: "string" } },
3815
+ },
3816
+ command: "project.inspect-footage",
3817
+ },
3818
+ {
3819
+ name: "project_speech_map",
3820
+ description:
3821
+ "The words the speaker STRESSES and where they land on the edited timeline (any language, incl. code-switched speech): measured from the audio (louder, sharp attack, pause before, drawn out) plus numbers and English key terms. Returns { durationMs, moments:[{ atMs, text, phrase, strength 1-3, emphasis, reasons, suggest }] }. Use as cues: zooms on strength 2-3, keyword graphics on the phrase, hits on strength 3. Needs a transcript.",
3822
+ inputSchema: {
3823
+ type: "object",
3824
+ properties: {
3825
+ id: { type: "string" },
3826
+ path: { type: "string" },
3827
+ minGapMs: { type: "number", description: "Default 2500." },
3828
+ everyMs: {
3829
+ type: "number",
3830
+ description:
3831
+ "~one moment per this long. Default 5000; Shorts 3000-4000; long-form 8000-15000.",
3832
+ },
3833
+ words: { type: "boolean", description: "Also return every word's emphasis." },
3834
+ },
3835
+ },
3836
+ command: "project.speech-map",
3837
+ },
3838
+ {
3839
+ name: "project_compose_soundtrack",
3840
+ description:
3841
+ "Score this edit: music + sound design composed FROM THE PROJECT'S OWN TIMELINE (zooms, graphics, pops, cuts, speech, stressed words) and placed under the voice with ducking; takes over zoom/overlay default sounds; replaces the previous score on re-run. Call LAST, after cuts/zooms/graphics. dryRun returns the score for media_compose_soundtrack. Returns { audioPath, scorePath, events, heroMoments, acts, soundsTakenOver, replaced }.",
3842
+ inputSchema: {
3843
+ type: "object",
3844
+ properties: {
3845
+ id: { type: "string" },
3846
+ path: { type: "string" },
3847
+ style: { type: "string", enum: ["talking-head", "short", "promo", "calm"] },
3848
+ mood: { type: "string", enum: ["minor", "major"] },
3849
+ bpm: { type: "number" },
3850
+ sfx: { type: "boolean", description: "Default true." },
3851
+ useSpeech: {
3852
+ type: "boolean",
3853
+ description: "Hero hits on the most stressed words. Default true.",
3854
+ },
3855
+ cues: {
3856
+ type: "array",
3857
+ items: {
3858
+ type: "object",
3859
+ properties: {
3860
+ atMs: { type: "number" },
3861
+ strength: { type: "number" },
3862
+ label: { type: "string" },
3863
+ },
3864
+ required: ["atMs"],
3865
+ },
3866
+ description: "Extra moments, e.g. a graphic's own cue times.",
3867
+ },
3868
+ musicVolume: { type: "number", description: "0-2. Default by style." },
3869
+ takeOverSounds: { type: "boolean", description: "Default true." },
3870
+ dryRun: { type: "boolean" },
3871
+ seed: { type: "number" },
3872
+ },
3873
+ },
3874
+ command: "project.compose-soundtrack",
3875
+ },
3876
+ // ── native motion elements ──────────────────────────────────────
3877
+ {
3878
+ name: "project_add_motion_element",
3879
+ description:
3880
+ "Native motion element: an editable, word-timed graphic the engine draws (preview = export, no HTML render): keyword headline, chip, stamp, count (number/range rolls up), steps, slam (+ camera kick), behind (word behind the speaker), lowerThird, highlight, endCard, progress, iconPop, frame (viewfinder / selection box / corners around the speaker). Anchor with wordId (follows the word through cuts) or atMs. Brand kit by default. Returns { elementId, element }.",
3881
+ inputSchema: {
3882
+ type: "object",
3883
+ properties: {
3884
+ id: { type: "string" },
3885
+ path: { type: "string" },
3886
+ ...MOTION_ELEMENT_PROPS,
3887
+ expectedRevision: { type: "number" },
3888
+ },
3889
+ required: ["type", "content"],
3890
+ },
3891
+ command: "project.add-motion-element",
3892
+ },
3893
+ {
3894
+ name: "project_add_motion_elements",
3895
+ description:
3896
+ "Add many native motion elements in one edit (one revision). All or nothing: an invalid one rejects the batch with 'element <i>: <reason>'. Returns { elementIds, elements }.",
3897
+ inputSchema: {
3898
+ type: "object",
3899
+ properties: {
3900
+ id: { type: "string" },
3901
+ path: { type: "string" },
3902
+ elements: {
3903
+ type: "array",
3904
+ items: {
3905
+ type: "object",
3906
+ properties: MOTION_ELEMENT_PROPS,
3907
+ required: ["type", "content"],
3908
+ },
3909
+ },
3910
+ expectedRevision: { type: "number" },
3911
+ },
3912
+ required: ["elements"],
3913
+ },
3914
+ command: "project.add-motion-elements",
3915
+ },
3916
+ {
3917
+ name: "project_update_motion_element",
3918
+ description:
3919
+ "Change a native motion element; pass only what changes. style merges (field null = default); zone/layer/sound/zIndex null = default; atMs/wordId re-anchors and keeps the length. Editing a style-edit element makes it yours (re-runs keep it).",
3920
+ inputSchema: {
3921
+ type: "object",
3922
+ properties: {
3923
+ id: { type: "string" },
3924
+ path: { type: "string" },
3925
+ elementId: { type: "string" },
3926
+ ...MOTION_ELEMENT_PROPS,
3927
+ expectedRevision: { type: "number" },
3928
+ },
3929
+ required: ["elementId"],
3930
+ },
3931
+ command: "project.update-motion-element",
3932
+ },
3933
+ {
3934
+ name: "project_remove_motion_element",
3935
+ description: "Remove a native motion element by id.",
3936
+ inputSchema: {
3937
+ type: "object",
3938
+ properties: {
3939
+ id: { type: "string" },
3940
+ path: { type: "string" },
3941
+ elementId: { type: "string" },
3942
+ expectedRevision: { type: "number" },
3943
+ },
3944
+ required: ["elementId"],
3945
+ },
3946
+ command: "project.remove-motion-element",
3947
+ },
3948
+ {
3949
+ name: "project_list_motion_elements",
3950
+ description:
3951
+ "List placed native motion elements (id, type, span, content, style, zone, layer, sound, wordId, origin) with one-line summaries. catalog=true adds every type's content shape and defaults.",
3952
+ inputSchema: {
3953
+ type: "object",
3954
+ properties: {
3955
+ id: { type: "string" },
3956
+ path: { type: "string" },
3957
+ type: { type: "string", enum: MOTION_ELEMENT_TYPES },
3958
+ catalog: { type: "boolean" },
3959
+ },
3960
+ },
3961
+ command: "project.list-motion-elements",
3962
+ },
3963
+ {
3964
+ name: "project_style_edit",
3965
+ description:
3966
+ "Style a talking-head / Short in one call: inspect-footage (applyFixes applies grade/key), speech-map, native motion elements on the stressed moments (numbers → count, strength-3 English statement → slam, other heroes → keyword/stamp, code-switch key terms → chip, first moment → hook, last 4 s → end card, long-form topic turns → lower third) + silent zooms (1.5x/1.25x, 1.8x hero), then compose-soundtrack. paper-cut = the Vox paper-cut look (serif ink on torn paper strips, newsprint backdrop, speaker cut out with a white keyline in the lower half, paper foley, no zooms; look=false keeps your background/framing). Latin text only: for non-Latin speech pass texts { wordId: 'English keyword' } (else zoom only). Re-runs replace only what style-edit placed. Run after cuts. dryRun returns the plan.",
3967
+ inputSchema: {
3968
+ type: "object",
3969
+ properties: {
3970
+ id: { type: "string" },
3971
+ path: { type: "string" },
3972
+ style: {
3973
+ type: "string",
3974
+ enum: ["bold-short", "editorial-long", "clean-tutorial", "paper-cut"],
3975
+ },
3976
+ format: {
3977
+ type: "string",
3978
+ enum: ["short", "long"],
3979
+ description: "Density; default from aspect (9:16 = short).",
3980
+ },
3981
+ applyFixes: { type: "boolean", description: "Apply inspect-footage fixes. Default false." },
3982
+ backdrop: {
3983
+ type: "string",
3984
+ description:
3985
+ "Background behind a keyed-out speaker (applyFixes) and the paper-cut backdrop: wallpaper id, colour or CSS gradient. Default: the style's studio gradient when no background is set; paper-cut uses /wallpapers/paper-cream.jpg.",
3986
+ },
3987
+ look: {
3988
+ type: "boolean",
3989
+ description:
3990
+ "paper-cut only: also set the look (paper backdrop, speaker cutout + white keyline, lower-half framing). Default true.",
3991
+ },
3992
+ texts: {
3993
+ type: "object",
3994
+ additionalProperties: { type: "string" },
3995
+ description: "{ [wordId]: 'English keyword' } overrides.",
3996
+ },
3997
+ endCard: {
3998
+ type: "array",
3999
+ items: { type: "string" },
4000
+ description: "[line1, line2]. Default: hook + hero line.",
4001
+ },
4002
+ accent: { type: "string", description: "Accent colour for every element; default brand." },
4003
+ highlight: { type: "string", description: "Emphasis colour; default brand." },
4004
+ zooms: { type: "boolean", description: "Default true." },
4005
+ score: { type: "boolean", description: "Run compose-soundtrack. Default true." },
4006
+ mood: { type: "string", enum: ["minor", "major"] },
4007
+ dryRun: { type: "boolean" },
4008
+ expectedRevision: { type: "number" },
4009
+ },
4010
+ },
4011
+ command: "project.style-edit",
4012
+ },
4013
+ {
4014
+ name: "media_compose_soundtrack",
4015
+ description:
4016
+ "Compose + render an original soundtrack (music, beats, SFX) from a score on the SAME timeline as the edit, so hits land on their moments. Synthesized locally: no key, no licensing, ~1 s per 15 s. Plan cues from the edit first, set bpm so cuts land on beats, then place sounds on cues. Returns { audioPath, scorePath, durationMs, lufs, truePeakDb, events, cues, warnings }; place with project_add_audio. Format + recipes: skill reference/soundtrack.md.",
4017
+ inputSchema: {
4018
+ type: "object",
4019
+ properties: {
4020
+ score: {
4021
+ type: "object",
4022
+ description: "The score. Pass score or scorePath.",
4023
+ properties: {
4024
+ duration: { type: "number", description: "Seconds (max 600)." },
4025
+ seed: { type: "number" },
4026
+ cues: {
4027
+ type: "object",
4028
+ description: 'Named moments: { "slam": 3.0, "logo": "slam+9.95" }.',
4029
+ additionalProperties: { type: ["number", "string"] },
4030
+ },
4031
+ events: {
4032
+ type: "array",
4033
+ items: {
4034
+ type: "object",
4035
+ properties: {
4036
+ at: { type: ["number", "string"], description: 'Seconds, a cue, or "cue+0.2".' },
4037
+ sound: {
4038
+ type: "string",
4039
+ enum: [
4040
+ "kick",
4041
+ "snare",
4042
+ "clap",
4043
+ "hat",
4044
+ "openhat",
4045
+ "tick",
4046
+ "click",
4047
+ "blip",
4048
+ "pop",
4049
+ "typing",
4050
+ "whoosh",
4051
+ "riser",
4052
+ "downlifter",
4053
+ "impact",
4054
+ "boom",
4055
+ "subdrop",
4056
+ "shimmer",
4057
+ "bass",
4058
+ "pluck",
4059
+ "pad",
4060
+ "lead",
4061
+ "bell",
4062
+ "keys",
4063
+ ],
4064
+ },
4065
+ gain: { type: "number" },
4066
+ pan: { type: "number", description: "-1 left .. 1 right" },
4067
+ dur: { type: "number" },
4068
+ note: { type: ["string", "number"], description: '"A3" or MIDI' },
4069
+ notes: { type: "array", items: { type: ["string", "number"] } },
4070
+ chord: { type: "string", description: '"Am", "Fmaj7"' },
4071
+ from: { type: "number", description: "Sweep start Hz" },
4072
+ to: { type: "number", description: "Sweep end Hz" },
4073
+ bright: { type: "number", description: "0 dark .. 1 bright" },
4074
+ reverb: { type: "number", description: "0..1 send" },
4075
+ repeat: {
4076
+ type: "object",
4077
+ properties: {
4078
+ count: { type: "number" },
4079
+ every: { type: "number" },
4080
+ gainStep: { type: "number" },
4081
+ alternatePan: { type: "boolean" },
4082
+ jitter: { type: "number" },
4083
+ },
4084
+ },
4085
+ },
4086
+ required: ["at", "sound"],
4087
+ },
4088
+ },
4089
+ sections: {
4090
+ type: "array",
4091
+ items: {
4092
+ type: "object",
4093
+ properties: {
4094
+ from: { type: ["number", "string"] },
4095
+ to: { type: ["number", "string"] },
4096
+ bpm: { type: "number" },
4097
+ chords: { type: "array", items: { type: "string" } },
4098
+ chordBeats: { type: "number", description: "Beats per chord, default 4." },
4099
+ drums: {
4100
+ type: "string",
4101
+ enum: ["four-on-floor", "backbeat", "half-time", "breakbeat", "none"],
4102
+ },
4103
+ hats: { type: "string", enum: ["16ths", "8ths", "offbeat", "none"] },
4104
+ bass: { type: "string", enum: ["pulse", "octave", "root", "sustain", "none"] },
4105
+ arp: { type: "string", enum: ["up", "down", "updown", "none"] },
4106
+ arpRate: { type: "string", enum: ["4ths", "8ths", "16ths"] },
4107
+ pad: { type: ["boolean", "number"] },
4108
+ whooshOnChange: { type: "boolean" },
4109
+ melody: {
4110
+ type: "object",
4111
+ properties: {
4112
+ notes: { type: "array", items: { type: ["string", "number", "null"] } },
4113
+ rate: { type: "string", enum: ["4ths", "8ths", "16ths"] },
4114
+ sound: { type: "string", enum: ["pluck", "lead", "bell", "keys"] },
4115
+ gain: { type: "number" },
4116
+ length: { type: "number" },
4117
+ reverb: { type: "number" },
4118
+ },
4119
+ },
4120
+ gain: { type: "number" },
4121
+ mix: {
4122
+ type: "object",
4123
+ description: "Per-part levels 0-2: drums, hats, bass, arp, pad, melody.",
4124
+ },
4125
+ },
4126
+ required: ["from", "to", "bpm"],
4127
+ },
4128
+ },
4129
+ master: {
4130
+ type: "object",
4131
+ properties: {
4132
+ lufs: { type: "number", description: "Default -14." },
4133
+ ceiling: { type: "number", description: "True-peak dBFS, default -1." },
4134
+ fadeIn: { type: "number" },
4135
+ fadeOut: { type: "number" },
4136
+ drive: { type: "number", description: "1 clean .. 4 heavy, default 1.3." },
4137
+ room: { type: "number", description: "Reverb size 0..1." },
4138
+ },
4139
+ },
4140
+ },
4141
+ required: ["duration"],
4142
+ },
4143
+ scorePath: {
4144
+ type: "string",
4145
+ description: "Absolute path to a score JSON (e.g. a previous scorePath).",
4146
+ },
4147
+ name: { type: "string", description: "File name stem." },
4148
+ },
4149
+ },
4150
+ command: "media.compose-soundtrack",
4151
+ },
3616
4152
  {
3617
4153
  name: "media_generate_music",
3618
4154
  description:
@@ -3654,6 +4190,28 @@ const TOOLS = [
3654
4190
  },
3655
4191
  command: "media.import",
3656
4192
  },
4193
+ {
4194
+ name: "media_download_url",
4195
+ description:
4196
+ "Download a video or its audio from a page link (YouTube, Vimeo...). Only content the user has rights to. ASYNC: job_wait gives { path, title, durationMs }.",
4197
+ inputSchema: {
4198
+ type: "object",
4199
+ properties: {
4200
+ url: { type: "string" },
4201
+ format: { type: "string", enum: ["video", "audio"] },
4202
+ maxHeight: { type: "number", description: "Default 1080." },
4203
+ startMs: { type: "number" },
4204
+ endMs: { type: "number" },
4205
+ maxMinutes: { type: "number", description: "Default 120." },
4206
+ name: { type: "string" },
4207
+ addToProject: { type: "string", description: "Project id." },
4208
+ as: { type: "string", enum: ["clip", "overlay", "audio"] },
4209
+ atMs: { type: "number" },
4210
+ },
4211
+ required: ["url"],
4212
+ },
4213
+ command: "media.download-url",
4214
+ },
3657
4215
  {
3658
4216
  name: "media_generate_sound_effect",
3659
4217
  description:
@@ -3743,7 +4301,7 @@ const TOOLS = [
3743
4301
  recipe: {
3744
4302
  type: "object",
3745
4303
  description:
3746
- "{ id?, title, description, category, footage?, aspectRatio?, fields: [{ key, label, type: text|longtext|select|color, default?, options?, required?, help? }], prompt, style?: { aspectRatio?, colorCorrection?, lut?: { preset, intensity? }, background?: { mode, image? }, cameraLayout?, captions?: { enabled, templateId? }, wallpaper? }, checklist: string[] }",
4304
+ "{ id?, title, description, category, footage?, aspectRatio?, fields: [{ key, label, type: text|longtext|select|color|images, default?, options?, required?, help?, minCount?, maxCount?, optional? }], prompt, style?: { aspectRatio?, colorCorrection?, lut?: { preset, intensity? }, background?: { mode, image? }, cameraLayout?, captions?: { enabled, templateId? }, wallpaper? }, checklist: string[] }",
3747
4305
  },
3748
4306
  },
3749
4307
  required: ["recipe"],
@@ -3802,8 +4360,22 @@ const TOOLS = [
3802
4360
  {
3803
4361
  name: "motion_list",
3804
4362
  description:
3805
- "List motion-graphic templates (slots; render with motion_generate) and slot-less registryBlocks (edit htmlPath, render with motion_render_html).",
3806
- inputSchema: { type: "object", properties: {} },
4363
+ "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.",
4364
+ inputSchema: {
4365
+ type: "object",
4366
+ properties: {
4367
+ family: {
4368
+ type: "string",
4369
+ description:
4370
+ "titles|text-behind|captions|callouts|lists-steps|stats-data|comparisons|product|panels|social-proof|social|intro-outro|end-cards|lower-thirds (comma-separated ok).",
4371
+ },
4372
+ tags: { type: "string", description: "Any of these tags, comma-separated." },
4373
+ query: { type: "string", description: "Search, e.g. 'subscribe', 'bar chart'." },
4374
+ aspect: { type: "string", enum: ["16:9", "9:16", "1:1"] },
4375
+ includeRetired: { type: "boolean" },
4376
+ includeBlocks: { type: "boolean", description: "Default true." },
4377
+ },
4378
+ },
3807
4379
  command: "motion.list",
3808
4380
  },
3809
4381
  {
@@ -3935,7 +4507,11 @@ const TOOLS = [
3935
4507
  aspectRatio: { type: "string", enum: ["16:9", "9:16", "1:1"] },
3936
4508
  width: { type: "number" },
3937
4509
  height: { type: "number" },
3938
- durationMs: { type: "number", description: "Default 2500." },
4510
+ durationMs: {
4511
+ type: "number",
4512
+ description:
4513
+ "Omit when the root declares data-duration (that is the length rendered; a mismatch warns). Else default 2500. Max 600000 (10 min).",
4514
+ },
3939
4515
  frameRate: { type: "number" },
3940
4516
  outputName: { type: "string" },
3941
4517
  audioPath: { type: "string", description: "Muxed in (not when transparent)." },
@@ -3949,16 +4525,21 @@ const TOOLS = [
3949
4525
  {
3950
4526
  name: "motion_screenshot",
3951
4527
  description:
3952
- "Capture one PNG frame of an HTML composition, as motion_render_html would. Returns { outputPath, previewPath } (inspect previewPath).",
4528
+ "Capture one PNG frame of an HTML composition (or a template with slots), as motion_render_html / motion_generate would. Returns { outputPath, previewPath } (inspect previewPath).",
3953
4529
  inputSchema: {
3954
4530
  type: "object",
3955
4531
  properties: {
3956
- html: { type: "string", description: "Or htmlPath." },
4532
+ html: { type: "string", description: "Or htmlPath, or templateId." },
3957
4533
  htmlPath: { type: "string" },
4534
+ templateId: { type: "string", description: "Template id; fill with slots." },
4535
+ slots: { type: "object" },
3958
4536
  aspectRatio: { type: "string", enum: ["16:9", "9:16", "1:1"] },
3959
4537
  width: { type: "number" },
3960
4538
  height: { type: "number" },
3961
- atMs: { type: "number", description: "Max 30000." },
4539
+ atMs: {
4540
+ type: "number",
4541
+ description: "Clamped to the composition's data-duration; result atMs = time captured.",
4542
+ },
3962
4543
  transparent: { type: "boolean" },
3963
4544
  assets: ASSETS_PROP,
3964
4545
  outputName: { type: "string" },
@@ -4005,8 +4586,23 @@ const TOOLS = [
4005
4586
  },
4006
4587
  {
4007
4588
  name: "asset_list_sounds",
4008
- description: "List bundled sound effects (id, name, category, path).",
4009
- inputSchema: { type: "object", properties: {} },
4589
+ description:
4590
+ "List bundled SFX (~190: id, category, tags, durationMs, variantGroup, path). Filter; summary=true gives categories + groups only.",
4591
+ inputSchema: {
4592
+ type: "object",
4593
+ properties: {
4594
+ category: {
4595
+ type: "string",
4596
+ description:
4597
+ "ui|notification|motion|digital|impact|typing|outcome|ambience, comma-separated.",
4598
+ },
4599
+ tag: { type: "string", description: 'e.g. "click,soft" (all match).' },
4600
+ mood: { type: "string" },
4601
+ group: { type: "string", description: "Variant group." },
4602
+ query: { type: "string" },
4603
+ summary: { type: "boolean" },
4604
+ },
4605
+ },
4010
4606
  command: "asset.list-sounds",
4011
4607
  },
4012
4608
  {
@@ -4018,7 +4614,7 @@ const TOOLS = [
4018
4614
  {
4019
4615
  name: "asset_list_transitions",
4020
4616
  description:
4021
- "List bundled transitions (id, title, category, durationSeconds) for project_add_transition.",
4617
+ "List bundled transitions (id, title, kind overlay|native, effect, category, durationSeconds, defaultSoundId) for project_add_transition.",
4022
4618
  inputSchema: { type: "object", properties: {} },
4023
4619
  command: "asset.list-transitions",
4024
4620
  },
@@ -4105,6 +4701,10 @@ const TOOLS = [
4105
4701
  type: ["boolean", "string"],
4106
4702
  description: "streaming / podcast / off; omit = project setting.",
4107
4703
  },
4704
+ frameRate: {
4705
+ type: ["number", "string"],
4706
+ description: "30 | 60 | source | auto; omit = project setting.",
4707
+ },
4108
4708
  },
4109
4709
  },
4110
4710
  command: "export.start",
@@ -4175,7 +4775,7 @@ const TOOLS = [
4175
4775
  {
4176
4776
  name: "export_generate_thumbnail",
4177
4777
  description:
4178
- "Generate a YouTube thumbnail for an export (Replicate, spends credits). Pass subject + hook, or omit to derive from the transcript. Returns { imagePath }.",
4778
+ "Generate a YouTube thumbnail for an export (user's image connector: Replicate, else Higgsfield; spends their credits). Pass subject + hook, or omit to derive from the transcript. Returns { imagePath }.",
4179
4779
  inputSchema: {
4180
4780
  type: "object",
4181
4781
  properties: {
@@ -4478,7 +5078,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
4478
5078
  };
4479
5079
  });
4480
5080
 
4481
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
5081
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
4482
5082
  const tool = TOOLS.find((t) => t.name === request.params.name);
4483
5083
  if (!tool) {
4484
5084
  return {
@@ -4509,7 +5109,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4509
5109
  }
4510
5110
 
4511
5111
  try {
4512
- const result = await callPandastudio(command, dispatchArgs);
5112
+ const result = await withProgressHeartbeat(request, extra, () =>
5113
+ callPandastudio(command, dispatchArgs),
5114
+ );
4513
5115
  // Format the response for the MCP client. Default is a text
4514
5116
  // block carrying the JSON result. For `motion.screenshot` and
4515
5117
  // `motion.verify-frames` we ALSO inline the downscaled preview
@@ -4559,6 +5161,36 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4559
5161
  }
4560
5162
  });
4561
5163
 
5164
+ /**
5165
+ * Keep a long call alive on the client side. MCP clients time a request out
5166
+ * after 60 s unless progress arrives (opencode, the in-app agent's runtime,
5167
+ * sends every tool call with a progress token and resets its timer on each
5168
+ * notification). job.wait, renders and presenter takes run for minutes, so
5169
+ * while the app works we report progress every 15 s. Clients that sent no
5170
+ * progress token get nothing.
5171
+ */
5172
+ const HEARTBEAT_MS = Number(process.env.PANDASTUDIO_MCP_PROGRESS_MS) || 15_000;
5173
+
5174
+ async function withProgressHeartbeat(request, extra, run) {
5175
+ const progressToken = request.params?._meta?.progressToken;
5176
+ if (progressToken === undefined || typeof extra?.sendNotification !== "function") return run();
5177
+ let beats = 0;
5178
+ const timer = setInterval(() => {
5179
+ beats += 1;
5180
+ extra
5181
+ .sendNotification({
5182
+ method: "notifications/progress",
5183
+ params: { progressToken, progress: beats, message: "PandaStudio is working" },
5184
+ })
5185
+ .catch(() => undefined);
5186
+ }, HEARTBEAT_MS);
5187
+ try {
5188
+ return await run();
5189
+ } finally {
5190
+ clearInterval(timer);
5191
+ }
5192
+ }
5193
+
4562
5194
  /** Decide which (if any) preview PNGs to inline as image content
4563
5195
  * blocks for a given tool result. Only motion.screenshot (one
4564
5196
  * preview) and motion.verify-frames (one per frame) qualify today.