@clipform/mcp-server 1.47.1 → 2.0.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.
@@ -6,7 +6,7 @@ import {
6
6
  getWorkflowText,
7
7
  objectType,
8
8
  registerPrompts
9
- } from "./chunk-MV27JVSL.js";
9
+ } from "./chunk-6U3CVTLZ.js";
10
10
  import {
11
11
  GUIDE_TYPES,
12
12
  QUIZ_VARIANTS,
@@ -14,7 +14,7 @@ import {
14
14
  getGuideUri,
15
15
  guideFallbackText,
16
16
  registerResources
17
- } from "./chunk-UH77CDNO.js";
17
+ } from "./chunk-AP6UGWKC.js";
18
18
  import {
19
19
  ACTIVE_NODE_TYPES,
20
20
  BUSINESS,
@@ -23,18 +23,17 @@ import {
23
23
  KEN_BURNS_EFFECTS,
24
24
  KEN_BURNS_FIT_MODES,
25
25
  KEN_BURNS_PRESETS,
26
- LABS_COMPOSITION_IDS,
27
26
  MCP_TOOL_TIERS,
28
27
  NODE_TYPES,
29
28
  NON_COUNTABLE_TYPES,
30
- PUBLIC_COMPOSITION_IDS,
31
29
  SLIDESHOW_TRANSITIONS,
32
- SOCIAL_COMPOSITION_IDS,
33
30
  callApi,
34
31
  errorResult,
32
+ exposedCompositionIds,
35
33
  resolveFormType,
34
+ structuredResult,
36
35
  textResult
37
- } from "./chunk-PJNOCHKR.js";
36
+ } from "./chunk-GDUJLYR4.js";
38
37
  import {
39
38
  __commonJS,
40
39
  __export,
@@ -16987,14 +16986,9 @@ import { fileURLToPath } from "url";
16987
16986
  var _endScreen = NODE_TYPES.end_screen.config_schema.properties;
16988
16987
  var END_SCREEN_ICONS = _endScreen.icon.enum;
16989
16988
  var END_SCREEN_CTA_TYPES = _endScreen.cta_type.enum;
16990
- var PUBLIC_COMPOSITIONS = PUBLIC_COMPOSITION_IDS;
16991
- var LABS_COMPOSITIONS = LABS_COMPOSITION_IDS;
16992
- var SOCIAL_COMPOSITIONS = SOCIAL_COMPOSITION_IDS;
16993
- var EXPOSED_COMPOSITIONS = [
16994
- ...PUBLIC_COMPOSITIONS,
16995
- ...process.env.CLIPFORM_LABS === "1" ? LABS_COMPOSITIONS : [],
16996
- ...process.env.CLIPFORM_SOCIAL === "1" ? SOCIAL_COMPOSITIONS : []
16997
- ];
16989
+ var EXPOSED_COMPOSITIONS = exposedCompositionIds(
16990
+ process.env.NODE_ENV !== "production"
16991
+ );
16998
16992
 
16999
16993
  // src/lib/schemas.ts
17000
16994
  var ACTIVE_NODE_TYPES2 = ACTIVE_NODE_TYPES;
@@ -17038,7 +17032,7 @@ function generateConfigSummary(configSchema, typeKey) {
17038
17032
  parts.push(`${key} ({${childKeys}})`);
17039
17033
  }
17040
17034
  }
17041
- if (typeKey === "contact") {
17035
+ if (typeKey === "details") {
17042
17036
  parts.push(`Available field IDs: ${CONTACT_FIELD_IDS.join(", ")}`);
17043
17037
  }
17044
17038
  return parts.length > 0 ? `Config: ${parts.join(", ")}` : "";
@@ -17072,6 +17066,12 @@ var NODE_TYPES_DESCRIPTION = (() => {
17072
17066
  })();
17073
17067
  var CONFIG_FIELD_DESCRIPTION = "Type-specific configuration keyed by node type. Omit to use defaults. Per-type keys, shapes, and defaults are listed in the clipform_create_form description; craft guidance via clipform_get_guide.";
17074
17068
  var STYLE_PRESET_ENUM = external_exports.enum(KEN_BURNS_PRESETS).describe(`Ken Burns style preset: ${KEN_BURNS_PRESETS.join(", ")}`);
17069
+ var CaptionSchema = external_exports.object({
17070
+ start: external_exports.number().describe("Segment start time in seconds"),
17071
+ end: external_exports.number().describe("Segment end time in seconds"),
17072
+ text: external_exports.string().describe("Full segment text"),
17073
+ words: external_exports.array(external_exports.object({ word: external_exports.string(), start: external_exports.number(), end: external_exports.number() })).optional().describe("Per-word timestamps within the segment")
17074
+ });
17075
17075
  var OptionSchema = external_exports.object({
17076
17076
  content: external_exports.string().describe("Option text"),
17077
17077
  score: external_exports.number().optional().describe("Score value for this option (for scored quizzes). Use 1 for correct, 0 for wrong. Scored options automatically enable correct-answer marking (record_scores) on the node."),
@@ -17131,7 +17131,7 @@ Example: A form that asks a question, collects contact info, then finishes:
17131
17131
  title: "Quick Survey",
17132
17132
  nodes: [
17133
17133
  { type: "open", prompt: "What's your biggest challenge?" },
17134
- { type: "contact", prompt: "Leave your details", config: { fields: [{ id: "first_name", required: true }, { id: "email", required: true }] } },
17134
+ { type: "details", prompt: "Leave your details", config: { fields: [{ id: "first_name", required: true }, { id: "email", required: true }] } },
17135
17135
  { type: "end_screen", prompt: "Thanks for your response!" }
17136
17136
  ]
17137
17137
  }`,
@@ -17149,7 +17149,7 @@ Example: A form that asks a question, collects contact info, then finishes:
17149
17149
  form_id: external_exports.string().describe("Form UUID - pass to follow-up tools"),
17150
17150
  viewer_url: external_exports.string().describe("Live form URL for respondents"),
17151
17151
  dashboard_url: external_exports.string().optional().describe("Builder URL (sign in to edit)"),
17152
- nodes: external_exports.array(external_exports.object({ id: external_exports.string(), type: external_exports.string(), prompt: external_exports.string() })).describe("Created nodes in order - IDs for upload_node_media / update_node"),
17152
+ nodes: external_exports.array(external_exports.object({ id: external_exports.string(), type: external_exports.string(), prompt: external_exports.string() })).describe("Created nodes in order - IDs for attach_node_media / update_node"),
17153
17153
  plan: external_exports.object({ name: external_exports.string(), auth_mode: external_exports.string() }).optional().describe("Plan and auth context for this session")
17154
17154
  },
17155
17155
  annotations: {
@@ -17257,7 +17257,7 @@ Example: A form that asks a question, collects contact info, then finishes:
17257
17257
  `Title: ${title}`,
17258
17258
  `Form ID: ${formId}`,
17259
17259
  ``,
17260
- `Node IDs (use directly with upload_node_media / update_node - no get_form round-trip needed):`,
17260
+ `Node IDs (use directly with attach_node_media / update_node - no get_form round-trip needed):`,
17261
17261
  ...createdNodes.map((n) => `- [${n.type}] "${truncate(n.prompt)}": ${n.id}`),
17262
17262
  ``,
17263
17263
  `FORM URL (live - share this with respondents): ${data.viewer_url}`
@@ -17269,7 +17269,7 @@ Example: A form that asks a question, collects contact info, then finishes:
17269
17269
  ``,
17270
17270
  `The form is live and accessible at the FORM URL above. Continue with media/narration steps if needed - changes appear immediately.`,
17271
17271
  `The URLs above are exact values; constructed or guessed URLs return 404. Use the FORM URL and DASHBOARD URL as shown.`,
17272
- `Pass form_id on follow-up tools (get_form, add_node, upload_node_media, etc.).`
17272
+ `Pass form_id on follow-up tools (get_form, add_node, attach_node_media, etc.).`
17273
17273
  );
17274
17274
  if (planContext) {
17275
17275
  const nodePart = planContext.node_limit === null ? "unlimited nodes" : `up to ${planContext.node_limit} nodes per form`;
@@ -17325,6 +17325,21 @@ function registerListFormsTool(server) {
17325
17325
  sort: external_exports.enum(["created_at", "updated_at"]).optional().describe("Sort field (default: created_at)"),
17326
17326
  order: external_exports.enum(["asc", "desc"]).optional().describe("Sort order (default: desc, newest first)")
17327
17327
  },
17328
+ outputSchema: {
17329
+ forms: external_exports.array(
17330
+ external_exports.object({
17331
+ id: external_exports.string(),
17332
+ title: external_exports.string(),
17333
+ share_id: external_exports.string(),
17334
+ is_live: external_exports.boolean(),
17335
+ has_unpublished_changes: external_exports.boolean(),
17336
+ created_at: external_exports.string(),
17337
+ updated_at: external_exports.string(),
17338
+ tags: external_exports.array(external_exports.object({ id: external_exports.string(), name: external_exports.string(), color: external_exports.string().nullable() }))
17339
+ })
17340
+ ).describe("Matching forms (empty if none)"),
17341
+ next_cursor: external_exports.string().nullable().describe("Pass as cursor to fetch the next page, or null when no more")
17342
+ },
17328
17343
  annotations: {
17329
17344
  readOnlyHint: true,
17330
17345
  destructiveHint: false,
@@ -17346,8 +17361,21 @@ function registerListFormsTool(server) {
17346
17361
  return errorResult(result.error);
17347
17362
  }
17348
17363
  const data = result.data;
17364
+ const structured = {
17365
+ forms: data.forms.map((f) => ({
17366
+ id: f.id,
17367
+ title: f.title,
17368
+ share_id: f.share_id,
17369
+ is_live: f.is_live,
17370
+ has_unpublished_changes: f.has_unpublished_changes,
17371
+ created_at: f.created_at,
17372
+ updated_at: f.updated_at,
17373
+ tags: f.tags.map((t) => ({ id: t.id, name: t.name, color: t.color }))
17374
+ })),
17375
+ next_cursor: data.next_cursor
17376
+ };
17349
17377
  if (data.forms.length === 0) {
17350
- return textResult("No forms found matching the criteria.");
17378
+ return structuredResult("No forms found matching the criteria.", structured);
17351
17379
  }
17352
17380
  const lines = [`Found ${data.forms.length} form(s):
17353
17381
  `];
@@ -17361,14 +17389,36 @@ function registerListFormsTool(server) {
17361
17389
  `More results available. Pass cursor: "${data.next_cursor}" to get the next page.`
17362
17390
  );
17363
17391
  }
17364
- return textResult(lines.join("\n"));
17392
+ return structuredResult(lines.join("\n"), structured);
17365
17393
  }
17366
17394
  );
17367
17395
  }
17368
17396
 
17369
17397
  // src/lib/format-form.ts
17398
+ function getFormNodes(data) {
17399
+ return data.node ?? data.nodes ?? [];
17400
+ }
17401
+ function projectFormState(data) {
17402
+ return {
17403
+ form_id: String(data.form_id ?? ""),
17404
+ title: String(data.title ?? ""),
17405
+ is_live: Boolean(data.is_live),
17406
+ has_unpublished_changes: Boolean(data.has_unpublished_changes),
17407
+ nodes: getFormNodes(data).map((q) => {
17408
+ const options = q.node_options ?? q.options;
17409
+ return {
17410
+ id: q.id,
17411
+ type: q.type,
17412
+ prompt: q.prompt ?? "",
17413
+ required: Boolean(q.required),
17414
+ ...q.config && Object.keys(q.config).length > 0 ? { config: q.config } : {},
17415
+ ...options && options.length > 0 ? { options: options.map((o) => ({ id: o.id, content: o.content })) } : {}
17416
+ };
17417
+ })
17418
+ };
17419
+ }
17370
17420
  function formatFormState(data) {
17371
- const nodes = data.node ?? data.nodes ?? [];
17421
+ const nodes = getFormNodes(data);
17372
17422
  const lines = [
17373
17423
  `Form: ${data.title}`,
17374
17424
  `Form ID: ${data.form_id}`,
@@ -17408,6 +17458,22 @@ function registerGetFormTool(server) {
17408
17458
  inputSchema: {
17409
17459
  form_id: external_exports.string().uuid().describe("The form UUID (returned by clipform_create_form, not the short share_id from the URL)")
17410
17460
  },
17461
+ outputSchema: {
17462
+ form_id: external_exports.string().describe("Form UUID"),
17463
+ title: external_exports.string().describe("Form title"),
17464
+ is_live: external_exports.boolean().describe("Whether the form is published"),
17465
+ has_unpublished_changes: external_exports.boolean().describe("Live form has edits not yet republished"),
17466
+ nodes: external_exports.array(
17467
+ external_exports.object({
17468
+ id: external_exports.string(),
17469
+ type: external_exports.string(),
17470
+ prompt: external_exports.string(),
17471
+ required: external_exports.boolean(),
17472
+ config: external_exports.record(external_exports.unknown()).optional(),
17473
+ options: external_exports.array(external_exports.object({ id: external_exports.string(), content: external_exports.string() })).optional()
17474
+ })
17475
+ ).describe("Nodes in sequential order - IDs for update_node / attach_node_media")
17476
+ },
17411
17477
  annotations: {
17412
17478
  readOnlyHint: true,
17413
17479
  destructiveHint: false,
@@ -17420,7 +17486,7 @@ function registerGetFormTool(server) {
17420
17486
  if (!result.ok) {
17421
17487
  return errorResult(result.error);
17422
17488
  }
17423
- return textResult(formatFormState(result.data));
17489
+ return structuredResult(formatFormState(result.data), projectFormState(result.data));
17424
17490
  }
17425
17491
  );
17426
17492
  }
@@ -17597,6 +17663,10 @@ function registerAddNodeTool(server) {
17597
17663
  "Insert after this node ID. Omit to append before the end screen."
17598
17664
  )
17599
17665
  },
17666
+ outputSchema: {
17667
+ node_id: external_exports.string().describe("ID of the created node - pass to update_node / attach_node_media"),
17668
+ type: external_exports.string().describe("Node type that was added")
17669
+ },
17600
17670
  annotations: {
17601
17671
  readOnlyHint: false,
17602
17672
  destructiveHint: false,
@@ -17615,8 +17685,10 @@ function registerAddNodeTool(server) {
17615
17685
  if (!result.ok) {
17616
17686
  return errorResult(result.error);
17617
17687
  }
17618
- return textResult(
17619
- `Node added. ID: ${result.data.node_id} | Type: ${node.type}`
17688
+ const nodeId = result.data.node_id;
17689
+ return structuredResult(
17690
+ `Node added. ID: ${nodeId} | Type: ${node.type}`,
17691
+ { node_id: nodeId, type: node.type }
17620
17692
  );
17621
17693
  }
17622
17694
  );
@@ -17629,7 +17701,9 @@ var NodeUpdateSchema = external_exports.object({
17629
17701
  label: external_exports.string().optional().describe("Short label for the node (used in logic builder)"),
17630
17702
  type: external_exports.enum(ACTIVE_NODE_TYPES2).optional().describe("Change the node type"),
17631
17703
  required: external_exports.boolean().optional().describe("Whether an answer is required"),
17632
- config: external_exports.record(external_exports.unknown()).optional().describe(CONFIG_FIELD_DESCRIPTION),
17704
+ config: external_exports.record(external_exports.unknown()).optional().describe(
17705
+ `${CONFIG_FIELD_DESCRIPTION} Merged shallowly into the stored config - only the keys you pass are changed. Set a key to null to delete it; nested objects are replaced wholesale. When type is changed in the same call, config replaces the stored configuration instead of merging.`
17706
+ ),
17633
17707
  options: external_exports.array(OptionSchema).optional().describe(
17634
17708
  "Replace all options (for choice nodes). Omit to keep existing options."
17635
17709
  )
@@ -17644,6 +17718,16 @@ function registerUpdateNodeTool(server) {
17644
17718
  form_id: external_exports.string().uuid().describe("The form UUID (returned by clipform_create_form, not the short share_id from the URL)"),
17645
17719
  updates: external_exports.array(NodeUpdateSchema).min(1).max(20).describe("One or more node updates to apply")
17646
17720
  },
17721
+ outputSchema: {
17722
+ results: external_exports.array(
17723
+ external_exports.object({
17724
+ node_id: external_exports.string(),
17725
+ ok: external_exports.boolean(),
17726
+ warnings: external_exports.array(external_exports.string()).optional(),
17727
+ error: external_exports.string().optional()
17728
+ })
17729
+ ).describe("One result per requested update, in order")
17730
+ },
17647
17731
  annotations: {
17648
17732
  readOnlyHint: false,
17649
17733
  destructiveHint: false,
@@ -17653,6 +17737,7 @@ function registerUpdateNodeTool(server) {
17653
17737
  },
17654
17738
  async ({ form_id, updates }) => {
17655
17739
  const allLines = [];
17740
+ const results = [];
17656
17741
  for (const { node_id, prompt, label, type, required: required2, config: config2, options } of updates) {
17657
17742
  const body = {};
17658
17743
  if (prompt !== void 0) body.prompt = prompt;
@@ -17667,6 +17752,7 @@ function registerUpdateNodeTool(server) {
17667
17752
  );
17668
17753
  if (!result.ok) {
17669
17754
  allLines.push(`Node ${node_id}: FAILED \u2014 ${result.error}`);
17755
+ results.push({ node_id, ok: false, error: result.error });
17670
17756
  continue;
17671
17757
  }
17672
17758
  const changes = [];
@@ -17682,8 +17768,9 @@ function registerUpdateNodeTool(server) {
17682
17768
  if (warnings?.length) {
17683
17769
  for (const w of warnings) allLines.push(`Node ${node_id} warning: ${w}`);
17684
17770
  }
17771
+ results.push({ node_id, ok: true, ...warnings?.length ? { warnings } : {} });
17685
17772
  }
17686
- return textResult(allLines.join("\n"));
17773
+ return structuredResult(allLines.join("\n"), { results });
17687
17774
  }
17688
17775
  );
17689
17776
  }
@@ -17728,10 +17815,8 @@ function registerDeleteNodeTool(server) {
17728
17815
  );
17729
17816
  }
17730
17817
 
17731
- // src/tools/upload-node-media.ts
17732
- var MEDIA_SUPPORTED_TYPES = Object.entries(NODE_TYPES).filter(([, def]) => def.supports_media).map(([type]) => type);
17733
- var MediaItemSchema = external_exports.object({
17734
- node_id: external_exports.string().describe("The node ID"),
17818
+ // src/tools/upload-media-asset.ts
17819
+ var MediaAssetItemSchema = external_exports.object({
17735
17820
  media_type: external_exports.enum(["video", "still"]).describe("Type of media"),
17736
17821
  media_source: external_exports.enum(["uploaded", "recorded"]).default("uploaded").describe("How the media was created"),
17737
17822
  url: external_exports.string().url().optional().describe("Public URL of the media file. When provided, the server fetches and stores it directly."),
@@ -17748,21 +17833,31 @@ var MediaItemSchema = external_exports.object({
17748
17833
  })
17749
17834
  ).optional().describe("Per-word timestamps within the segment")
17750
17835
  })
17751
- ).optional().describe("Word-level captions from clipform_generate_tts. Required for per-word highlighting - pass the full objects including 'words' arrays."),
17752
- show_captions: external_exports.boolean().optional().describe("Display captions/subtitles on the node. Defaults to on; pass false to hide them."),
17753
- fit_media: external_exports.boolean().optional().describe("Show the whole frame (contain) instead of cover-cropping. ALWAYS set true when attaching renders from clipform_render_composition or clipform_generate_video - they are composed 9:16 frames that must never be cropped. Leave unset for user-supplied media (arbitrary aspect ratios want the cover default).")
17836
+ ).optional().describe("Word-level captions from clipform_generate_tts. Required for per-word highlighting - pass the full objects including 'words' arrays.")
17754
17837
  });
17755
- function registerUploadNodeMediaTool(server) {
17838
+ function registerUploadMediaAssetTool(server) {
17756
17839
  server.registerTool(
17757
- "clipform_upload_node_media",
17840
+ "clipform_upload_media_asset",
17758
17841
  {
17759
- title: "Upload Node Media",
17760
- description: `Upload media for one or more nodes. Pass one item or many (max 10). Multiple items upload sequentially.
17842
+ title: "Upload Media Asset",
17843
+ description: `Put one or more media files into your workspace media library (max 10, uploaded sequentially). This is step one of attaching media to a node - follow up with clipform_attach_node_media to place the returned media_asset_id on a node.
17761
17844
 
17762
- When a public URL is provided, the media is fetched and stored automatically. Only works on node types that support media (${MEDIA_SUPPORTED_TYPES.join(", ")}). For video: ingested via Mux. For image: stored in Supabase. Each upload lands in your workspace media library and is then attached to the node (so it is reusable across nodes), matching the builder. Captions from clipform_generate_tts enable per-word highlighting in the viewer. When attaching renders from clipform_render_composition or clipform_generate_video, set fit_media: true on each item.`,
17845
+ When a public URL is provided, the media is fetched and stored automatically. For video: ingested via Mux. For image: stored in Supabase. Captions from clipform_generate_tts enable per-word highlighting in the viewer once attached.`,
17763
17846
  inputSchema: {
17764
- form_id: external_exports.string().uuid().describe("The form UUID (returned by clipform_create_form, not the short share_id from the URL)"),
17765
- items: external_exports.array(MediaItemSchema).min(1).max(10).describe("One or more media items to upload")
17847
+ items: external_exports.array(MediaAssetItemSchema).min(1).max(10).describe("One or more media items to create as workspace library assets")
17848
+ },
17849
+ outputSchema: {
17850
+ results: external_exports.array(
17851
+ external_exports.object({
17852
+ media_asset_id: external_exports.string().optional(),
17853
+ media_type: external_exports.string(),
17854
+ stored: external_exports.boolean().describe("Bytes already stored (URL-based); false when an upload_url is pending"),
17855
+ upload_url: external_exports.string().optional(),
17856
+ upload_method: external_exports.string().optional(),
17857
+ ok: external_exports.boolean(),
17858
+ error: external_exports.string().optional()
17859
+ })
17860
+ ).describe("One result per item, in order")
17766
17861
  },
17767
17862
  annotations: {
17768
17863
  readOnlyHint: false,
@@ -17771,20 +17866,24 @@ When a public URL is provided, the media is fetched and stored automatically. On
17771
17866
  openWorldHint: false
17772
17867
  }
17773
17868
  },
17774
- async ({ form_id, items }) => {
17775
- const workspaceId = getMcpAuth()?.workspace_id;
17869
+ async ({ items }) => {
17870
+ const meResult = await callApi("/me", { method: "GET" });
17871
+ if (!meResult.ok) {
17872
+ return errorResult(`Unable to determine workspace: ${meResult.error}`);
17873
+ }
17874
+ const me = meResult.data;
17875
+ const workspaceId = me.workspace?.id ?? null;
17876
+ if (!workspaceId) {
17877
+ return errorResult(
17878
+ "No workspace available. This usually means authentication is misconfigured - check your API key or OAuth connection."
17879
+ );
17880
+ }
17776
17881
  const lines = [];
17882
+ const results = [];
17777
17883
  let successCount = 0;
17778
17884
  for (let i = 0; i < items.length; i++) {
17779
17885
  const item = items[i];
17780
- if (items.length > 1) lines.push(`--- Item ${i + 1} (node ${item.node_id}) ---`);
17781
- if (!workspaceId) {
17782
- lines.push(
17783
- "FAILED: no workspace in context - reconnect the MCP or set CLIPFORM_API_KEY so media can land in your library."
17784
- );
17785
- lines.push("");
17786
- continue;
17787
- }
17886
+ if (items.length > 1) lines.push(`--- Item ${i + 1} (${item.media_type}) ---`);
17788
17887
  const createBody = {
17789
17888
  media_type: item.media_type,
17790
17889
  media_source: item.media_source
@@ -17795,21 +17894,105 @@ When a public URL is provided, the media is fetched and stored automatically. On
17795
17894
  if (!created.ok) {
17796
17895
  lines.push(`FAILED: ${created.error}`);
17797
17896
  lines.push("");
17897
+ results.push({ media_type: item.media_type, stored: false, ok: false, error: created.error });
17798
17898
  continue;
17799
17899
  }
17800
17900
  const mediaAssetId = created.data.media_asset_id;
17801
17901
  if (!mediaAssetId) {
17802
- lines.push(`FAILED: upload did not return a media asset id (route ${String(created.data.route ?? "unknown")}).`);
17902
+ const error2 = `upload did not return a media asset id (route ${String(created.data.route ?? "unknown")}).`;
17903
+ lines.push(`FAILED: ${error2}`);
17803
17904
  lines.push("");
17905
+ results.push({ media_type: item.media_type, stored: false, ok: false, error: error2 });
17804
17906
  continue;
17805
17907
  }
17908
+ successCount++;
17909
+ const uploadUrl = created.data.upload_url;
17910
+ const uploadMethod = created.data.upload_method;
17911
+ results.push({
17912
+ media_asset_id: mediaAssetId,
17913
+ media_type: item.media_type,
17914
+ ok: true,
17915
+ stored: !uploadUrl,
17916
+ ...uploadUrl ? { upload_url: uploadUrl } : {},
17917
+ ...uploadUrl && uploadMethod ? { upload_method: uploadMethod } : {}
17918
+ });
17919
+ const resultLines = [`Media asset ID: ${mediaAssetId}`];
17920
+ if (uploadUrl) {
17921
+ resultLines.push(
17922
+ `Upload URL: ${uploadUrl}`,
17923
+ `Upload method: ${uploadMethod}`,
17924
+ `Upload the file directly to the upload URL using ${uploadMethod === "tus" ? "TUS resumable upload" : "HTTP PUT"}.`
17925
+ );
17926
+ } else {
17927
+ resultLines.push(`Media stored in the workspace library.`);
17928
+ }
17929
+ if (item.captions) {
17930
+ resultLines.push(`Captions: ${item.captions.length} segments saved`);
17931
+ }
17932
+ lines.push(resultLines.join("\n"));
17933
+ lines.push("");
17934
+ }
17935
+ if (items.length > 1) {
17936
+ lines.unshift(`Media asset upload: ${successCount}/${items.length} succeeded
17937
+ `);
17938
+ }
17939
+ lines.push(`Attach these to nodes with clipform_attach_node_media.`);
17940
+ if (successCount === 0) return errorResult(lines.join("\n"));
17941
+ return structuredResult(lines.join("\n"), { results });
17942
+ }
17943
+ );
17944
+ }
17945
+
17946
+ // src/tools/attach-node-media.ts
17947
+ var MEDIA_SUPPORTED_TYPES = Object.entries(NODE_TYPES).filter(([, def]) => def.supports_media).map(([type]) => type);
17948
+ var AttachMediaItemSchema = external_exports.object({
17949
+ node_id: external_exports.string().describe("The node ID"),
17950
+ media_asset_id: external_exports.string().describe("A media asset ID returned by clipform_upload_media_asset"),
17951
+ fit_media: external_exports.boolean().optional().describe("Show the whole frame (contain) instead of cover-cropping. ALWAYS set true when attaching renders from clipform_render_composition or clipform_generate_video - they are composed 9:16 frames that must never be cropped. Leave unset for user-supplied media (arbitrary aspect ratios want the cover default)."),
17952
+ show_captions: external_exports.boolean().optional().describe("Display captions/subtitles on the node. Defaults to on; pass false to hide them.")
17953
+ });
17954
+ function registerAttachNodeMediaTool(server) {
17955
+ server.registerTool(
17956
+ "clipform_attach_node_media",
17957
+ {
17958
+ title: "Attach Node Media",
17959
+ description: `Attach an existing workspace media asset (from clipform_upload_media_asset) to one or more nodes (max 10). Pass one item or many; multiple items attach sequentially. Only works on node types that support media (${MEDIA_SUPPORTED_TYPES.join(", ")}). A media asset is reusable - attach the same media_asset_id to several nodes.`,
17960
+ inputSchema: {
17961
+ form_id: external_exports.string().uuid().describe("The form UUID (returned by clipform_create_form, not the short share_id from the URL)"),
17962
+ items: external_exports.array(AttachMediaItemSchema).min(1).max(10).describe("One or more attachments to make")
17963
+ },
17964
+ outputSchema: {
17965
+ results: external_exports.array(
17966
+ external_exports.object({
17967
+ node_id: external_exports.string(),
17968
+ ok: external_exports.boolean(),
17969
+ media_asset_id: external_exports.string().optional(),
17970
+ error: external_exports.string().optional()
17971
+ })
17972
+ ).describe("One result per item, in order")
17973
+ },
17974
+ annotations: {
17975
+ readOnlyHint: false,
17976
+ destructiveHint: false,
17977
+ idempotentHint: false,
17978
+ openWorldHint: false
17979
+ }
17980
+ },
17981
+ async ({ form_id, items }) => {
17982
+ const lines = [];
17983
+ const results = [];
17984
+ let successCount = 0;
17985
+ for (let i = 0; i < items.length; i++) {
17986
+ const item = items[i];
17987
+ if (items.length > 1) lines.push(`--- Item ${i + 1} (node ${item.node_id}) ---`);
17806
17988
  const attached = await callApi(`/forms/${form_id}/nodes/${item.node_id}/media/attach`, {
17807
17989
  method: "POST",
17808
- body: { media_asset_id: mediaAssetId }
17990
+ body: { media_asset_id: item.media_asset_id }
17809
17991
  });
17810
17992
  if (!attached.ok) {
17811
- lines.push(`FAILED to attach the uploaded media to the node: ${attached.error}`);
17993
+ lines.push(`FAILED to attach media to the node: ${attached.error}`);
17812
17994
  lines.push("");
17995
+ results.push({ node_id: item.node_id, ok: false, error: attached.error });
17813
17996
  continue;
17814
17997
  }
17815
17998
  const presentation = {};
@@ -17826,29 +18009,18 @@ When a public URL is provided, the media is fetched and stored automatically. On
17826
18009
  }
17827
18010
  }
17828
18011
  successCount++;
17829
- const resultLines = [];
17830
- if (created.data.upload_url) {
17831
- resultLines.push(
17832
- `Upload URL: ${created.data.upload_url}`,
17833
- `Upload method: ${created.data.upload_method}`,
17834
- `Upload the file directly to the upload URL using ${created.data.upload_method === "tus" ? "TUS resumable upload" : "HTTP PUT"}.`
17835
- );
17836
- } else {
17837
- resultLines.push(`Media stored in the workspace library and attached to the node.`);
17838
- }
17839
- if (item.captions) {
17840
- resultLines.push(`Captions: ${item.captions.length} segments saved`);
17841
- }
18012
+ results.push({ node_id: item.node_id, ok: true, media_asset_id: item.media_asset_id });
18013
+ const resultLines = [`Media attached to the node.`];
17842
18014
  if (presentationNote) resultLines.push(presentationNote);
17843
18015
  lines.push(resultLines.join("\n"));
17844
18016
  lines.push("");
17845
18017
  }
17846
18018
  if (items.length > 1) {
17847
- lines.unshift(`Media upload: ${successCount}/${items.length} succeeded
18019
+ lines.unshift(`Media attach: ${successCount}/${items.length} succeeded
17848
18020
  `);
17849
18021
  }
17850
18022
  if (successCount === 0) return errorResult(lines.join("\n"));
17851
- return textResult(lines.join("\n"));
18023
+ return structuredResult(lines.join("\n"), { results });
17852
18024
  }
17853
18025
  );
17854
18026
  }
@@ -17864,6 +18036,14 @@ function registerGetNodeMediaTool(server) {
17864
18036
  form_id: external_exports.string().uuid().describe("The form UUID (returned by clipform_create_form, not the short share_id from the URL)"),
17865
18037
  node_id: external_exports.string().describe("The node ID")
17866
18038
  },
18039
+ outputSchema: {
18040
+ media: external_exports.object({
18041
+ media_type: external_exports.string(),
18042
+ status: external_exports.string(),
18043
+ duration: external_exports.number().optional(),
18044
+ transcription_status: external_exports.string().nullable().optional()
18045
+ }).nullable().describe("Attached media (null if the node has none)")
18046
+ },
17867
18047
  annotations: {
17868
18048
  readOnlyHint: true,
17869
18049
  destructiveHint: false,
@@ -17880,15 +18060,22 @@ function registerGetNodeMediaTool(server) {
17880
18060
  }
17881
18061
  const media = result.data.media;
17882
18062
  if (!media) {
17883
- return textResult("No media attached to this node.");
18063
+ return structuredResult("No media attached to this node.", { media: null });
17884
18064
  }
17885
- return textResult(
18065
+ const projected = {
18066
+ media_type: String(media.media_type),
18067
+ status: String(media.status),
18068
+ ...typeof media.duration === "number" ? { duration: media.duration } : {},
18069
+ transcription_status: media.transcription_status == null ? null : String(media.transcription_status)
18070
+ };
18071
+ return structuredResult(
17886
18072
  [
17887
18073
  `Type: ${media.media_type}`,
17888
18074
  `Status: ${media.status}`,
17889
18075
  media.duration ? `Duration: ${media.duration}s` : "",
17890
18076
  `Transcription: ${media.transcription_status}`
17891
- ].filter(Boolean).join("\n")
18077
+ ].filter(Boolean).join("\n"),
18078
+ { media: projected }
17892
18079
  );
17893
18080
  }
17894
18081
  );
@@ -18042,6 +18229,20 @@ Returns: article title, source, author, date, URL, description, and image URL pe
18042
18229
  query: external_exports.string().describe("News search query (e.g. 'Iran war 2026', 'Australian Open final', 'UK election')"),
18043
18230
  count: external_exports.number().min(1).max(15).default(5).optional().describe("Max results per provider (default 5)")
18044
18231
  },
18232
+ outputSchema: {
18233
+ query: external_exports.string(),
18234
+ articles: external_exports.array(
18235
+ external_exports.object({
18236
+ title: external_exports.string().optional(),
18237
+ source: external_exports.string().optional(),
18238
+ description: external_exports.string().optional(),
18239
+ author: external_exports.string().optional(),
18240
+ published_at: external_exports.string().optional(),
18241
+ url: external_exports.string(),
18242
+ image_url: external_exports.string().optional()
18243
+ })
18244
+ ).describe("Matching articles (empty if none)")
18245
+ },
18045
18246
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
18046
18247
  },
18047
18248
  async ({ query, count }) => {
@@ -18050,7 +18251,16 @@ Returns: article title, source, author, date, URL, description, and image URL pe
18050
18251
  });
18051
18252
  if (!result.ok) return errorResult(result.error);
18052
18253
  const results = result.data.results;
18053
- if (!results.length) return textResult(`No news articles found for "${query}".`);
18254
+ if (!results.length) return structuredResult(`No news articles found for "${query}".`, { query, articles: [] });
18255
+ const articles = results.map((article) => ({
18256
+ ...article.title ? { title: article.title } : {},
18257
+ ...article.source ? { source: article.source } : {},
18258
+ ...article.description ? { description: article.description } : {},
18259
+ ...article.author ? { author: article.author } : {},
18260
+ ...article.publishedAt ? { published_at: article.publishedAt } : {},
18261
+ url: article.url,
18262
+ ...article.imageUrl ? { image_url: article.imageUrl } : {}
18263
+ }));
18054
18264
  const lines = [`Found ${results.length} articles for "${query}":
18055
18265
  `];
18056
18266
  for (const article of results) {
@@ -18062,7 +18272,7 @@ Returns: article title, source, author, date, URL, description, and image URL pe
18062
18272
  if (article.imageUrl) lines.push(` Image: ${article.imageUrl}`);
18063
18273
  lines.push("");
18064
18274
  }
18065
- return textResult(lines.join("\n"));
18275
+ return structuredResult(lines.join("\n"), { query, articles });
18066
18276
  }
18067
18277
  );
18068
18278
  }
@@ -18118,6 +18328,12 @@ Supports any public YouTube video with captions enabled. Does NOT work for priva
18118
18328
  }
18119
18329
 
18120
18330
  // src/tools/generate-tts.ts
18331
+ var CaptionSchema2 = external_exports.object({
18332
+ start: external_exports.number(),
18333
+ end: external_exports.number(),
18334
+ text: external_exports.string(),
18335
+ words: external_exports.array(external_exports.object({ word: external_exports.string(), start: external_exports.number(), end: external_exports.number() })).optional()
18336
+ });
18121
18337
  var TtsItemSchema = external_exports.object({
18122
18338
  text: external_exports.string().min(1).max(5e3).describe("Narration text"),
18123
18339
  voice: external_exports.enum(["ryan", "sonia", "andrew", "ava", "guy"]).optional().default("ryan").describe("Voice: ryan (British male, clear/articulate), sonia (British female, warm/bright), andrew (American male, smooth/neutral), ava (American female, vibrant/friendly), guy (American male, deep/authoritative). Pick based on the form's topic and audience."),
@@ -18138,6 +18354,16 @@ Pass one item or many (max 10) - multiple items run in parallel. Returns audio U
18138
18354
  inputSchema: {
18139
18355
  items: external_exports.array(TtsItemSchema).min(1).max(10).describe("One or more TTS items to generate")
18140
18356
  },
18357
+ outputSchema: {
18358
+ results: external_exports.array(
18359
+ external_exports.object({
18360
+ ok: external_exports.boolean(),
18361
+ audio_url: external_exports.string().optional(),
18362
+ captions: external_exports.array(CaptionSchema2).optional().describe("Pass as the captions param to upload_media_asset"),
18363
+ error: external_exports.string().optional()
18364
+ })
18365
+ ).describe("One result per item, in order")
18366
+ },
18141
18367
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }
18142
18368
  },
18143
18369
  async ({ items }) => {
@@ -18151,6 +18377,7 @@ Pass one item or many (max 10) - multiple items run in parallel. Returns audio U
18151
18377
  )
18152
18378
  );
18153
18379
  const lines = [];
18380
+ const structured = [];
18154
18381
  let successCount = 0;
18155
18382
  for (let i = 0; i < results.length; i++) {
18156
18383
  const r = results[i];
@@ -18167,11 +18394,17 @@ Pass one item or many (max 10) - multiple items run in parallel. Returns audio U
18167
18394
  ...Array.isArray(c.words) ? { words: c.words.map((w) => ({ word: w.word, start: w.start, end: w.end })) } : {}
18168
18395
  }));
18169
18396
  lines.push(`Captions: ${JSON.stringify(captions)}`);
18397
+ structured.push({
18398
+ ok: true,
18399
+ ...data.audioUrl ? { audio_url: String(data.audioUrl) } : {},
18400
+ captions
18401
+ });
18170
18402
  } else {
18171
18403
  const error2 = r.status === "rejected" ? r.reason?.message || String(r.reason) : r.value.error;
18172
18404
  const status = r.status === "fulfilled" ? r.value.status : 0;
18173
18405
  const hint = status === 500 || status === 0 ? " (transient - retrying may help)" : "";
18174
18406
  lines.push(`FAILED: ${error2}${hint}`);
18407
+ structured.push({ ok: false, error: String(error2) });
18175
18408
  }
18176
18409
  lines.push("");
18177
18410
  }
@@ -18181,7 +18414,7 @@ Pass one item or many (max 10) - multiple items run in parallel. Returns audio U
18181
18414
  }
18182
18415
  lines.push(`Pass the complete Captions JSON as the "captions" parameter when uploading.`);
18183
18416
  if (successCount === 0) return errorResult(lines.join("\n"));
18184
- return textResult(lines.join("\n"));
18417
+ return structuredResult(lines.join("\n"), { results: structured });
18185
18418
  }
18186
18419
  );
18187
18420
  }
@@ -18192,7 +18425,7 @@ function registerSearchMediaTool(server) {
18192
18425
  "clipform_search_media",
18193
18426
  {
18194
18427
  title: "Search Media",
18195
- description: `Search images or stock video clips. Pass one query or many (max 10) - multiple queries run in one call instead of separate tool calls. Use results to feed into clipform_generate_video for narrated slideshow videos, or upload directly as still images via clipform_upload_node_media. All results are pre-cleared for commercial use. Results include a description (alt text where the provider has it) - use it to pick visually distinct images.
18428
+ description: `Search images or stock video clips. Pass one query or many (max 10) - multiple queries run in one call instead of separate tool calls. Use results to feed into clipform_generate_video for narrated slideshow videos, or upload directly as still images via clipform_upload_media_asset then clipform_attach_node_media. All results are pre-cleared for commercial use. Results include a description (alt text where the provider has it) - use it to pick visually distinct images.
18196
18429
 
18197
18430
  Example: { queries: [{ query: "saturn rings" }, { query: "mars surface", count: 3 }] } returns portrait images for both.`,
18198
18431
  inputSchema: {
@@ -18205,10 +18438,30 @@ Example: { queries: [{ query: "saturn rings" }, { query: "mars surface", count:
18205
18438
  })
18206
18439
  ).min(1).max(10).describe("One or more search queries to run")
18207
18440
  },
18441
+ outputSchema: {
18442
+ searches: external_exports.array(
18443
+ external_exports.object({
18444
+ query: external_exports.string(),
18445
+ kind: external_exports.enum(["image", "video"]),
18446
+ items: external_exports.array(
18447
+ external_exports.object({
18448
+ title: external_exports.string().optional(),
18449
+ url: external_exports.string(),
18450
+ width: external_exports.number().optional(),
18451
+ height: external_exports.number().optional(),
18452
+ duration: external_exports.number().optional(),
18453
+ source: external_exports.string().optional(),
18454
+ attribution: external_exports.string().optional()
18455
+ })
18456
+ )
18457
+ })
18458
+ ).describe("Results grouped by query (items empty if a query found nothing or failed)")
18459
+ },
18208
18460
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
18209
18461
  },
18210
18462
  async ({ queries }) => {
18211
18463
  const allLines = [];
18464
+ const searches = [];
18212
18465
  for (const q of queries) {
18213
18466
  const { query, count, orientation } = q;
18214
18467
  const kind = q.kind ?? "image";
@@ -18218,16 +18471,32 @@ Example: { queries: [{ query: "saturn rings" }, { query: "mars surface", count:
18218
18471
  if (queries.length > 1) allLines.push(`--- "${query}" (${kind}) ---`);
18219
18472
  if (!result.ok) {
18220
18473
  allLines.push(`FAILED: ${result.error}`, "");
18474
+ searches.push({ query, kind, items: [] });
18221
18475
  continue;
18222
18476
  }
18223
18477
  const results = result.data.results;
18224
18478
  if (!results.length) {
18225
18479
  allLines.push(`No ${kind}s found.`, "");
18480
+ searches.push({ query, kind, items: [] });
18226
18481
  continue;
18227
18482
  }
18483
+ const shown = results.slice(0, 10);
18484
+ searches.push({
18485
+ query,
18486
+ kind,
18487
+ items: shown.map((item) => ({
18488
+ ...item.title ? { title: item.title } : {},
18489
+ url: item.url,
18490
+ ...typeof item.width === "number" ? { width: item.width } : {},
18491
+ ...typeof item.height === "number" ? { height: item.height } : {},
18492
+ ...kind === "video" && typeof item.duration === "number" ? { duration: item.duration } : {},
18493
+ ...item.source ? { source: item.source } : {},
18494
+ ...item.attribution ? { attribution: item.attribution } : {}
18495
+ }))
18496
+ });
18228
18497
  allLines.push(`Found ${results.length} ${kind}s:
18229
18498
  `);
18230
- for (const item of results.slice(0, 10)) {
18499
+ for (const item of shown) {
18231
18500
  allLines.push(`- ${item.title}`);
18232
18501
  allLines.push(` URL: ${item.url}`);
18233
18502
  const meta = [];
@@ -18242,11 +18511,16 @@ Example: { queries: [{ query: "saturn rings" }, { query: "mars surface", count:
18242
18511
  allLines.push("");
18243
18512
  }
18244
18513
  }
18245
- return textResult(allLines.join("\n"));
18514
+ return structuredResult(allLines.join("\n"), { searches });
18246
18515
  }
18247
18516
  );
18248
18517
  }
18249
18518
 
18519
+ // src/lib/render-routing.ts
18520
+ var RENDER_ROUTING_RULE = `Choosing a render tool: for a recognisable form/quiz beat (guess-the-city, this-or-that, mystery reveal, multiple choice, photo montage...) reach for a video template first (clipform_list_video_templates + clipform_render_video_template) - it is a one-call recipe. Use clipform_generate_video for a narrated or audio-synced media montage (images/clips timed to a voice track). Use clipform_render_composition only when neither fits and you need a custom layer stack.`;
18521
+ var MONTAGE_NARRATION_RULE = `Montage disambiguation: choose clipform_generate_video when the montage is narrated or synced to an audio track; choose the slideshow video template when it is silent (motion + transitions only, no voice-over).`;
18522
+ var RENDER_ATTACH_RULE = `A render for a form node is not done until it is attached to that node. Pass node_id (and form_id) so the completed render attaches itself automatically - do not poll clipform_check_render to completion or manually chain clipform_upload_media_asset + clipform_attach_node_media; fire the render and move on.`;
18523
+
18250
18524
  // ../../node_modules/.pnpm/uuid@11.1.1/node_modules/uuid/dist/esm/stringify.js
18251
18525
  var byteToHex = [];
18252
18526
  for (let i = 0; i < 256; ++i) {
@@ -18303,7 +18577,11 @@ var jobs = /* @__PURE__ */ new Map();
18303
18577
  var MAX_AGE_MS = 30 * 60 * 1e3;
18304
18578
  var RENDER_TIMING = {
18305
18579
  expectedRange: "15-120 seconds",
18306
- pollDelay: "~15 seconds"
18580
+ pollDelay: "~15 seconds",
18581
+ /** Rounder phrasing for chat/email ETA acks (#1964) - not exact enough for
18582
+ * a poll instruction like the two above, but right for "still rendering,
18583
+ * you'll hear back in..." copy. Never hardcode minutes in api code. */
18584
+ etaPhrase: "a couple of minutes"
18307
18585
  };
18308
18586
  function createJob(tool) {
18309
18587
  const job = {
@@ -18315,11 +18593,12 @@ function createJob(tool) {
18315
18593
  jobs.set(job.id, job);
18316
18594
  return job;
18317
18595
  }
18318
- function completeJob(id, result) {
18596
+ function completeJob(id, result, attach) {
18319
18597
  const job = jobs.get(id);
18320
18598
  if (job) {
18321
18599
  job.status = "complete";
18322
18600
  job.result = result;
18601
+ if (attach) job.attach = attach;
18323
18602
  }
18324
18603
  }
18325
18604
  function failJob(id, error2) {
@@ -18339,14 +18618,133 @@ function pruneJobs() {
18339
18618
  }
18340
18619
  }
18341
18620
 
18621
+ // src/lib/auto-attach.ts
18622
+ async function createRenderPlaceholder(input) {
18623
+ try {
18624
+ const me = await callApi("/me", { method: "GET" });
18625
+ if (!me.ok) return void 0;
18626
+ const workspaceId = me.data?.workspace?.id;
18627
+ if (!workspaceId) return void 0;
18628
+ const created = await callApi(`/workspaces/${workspaceId}/media`, {
18629
+ method: "POST",
18630
+ body: { media_type: input.mediaType, media_source: "uploaded", pending: true }
18631
+ });
18632
+ if (!created.ok) return void 0;
18633
+ const assetId = created.data?.media_asset_id;
18634
+ if (!assetId) return void 0;
18635
+ const attached = await callApi(`/forms/${input.formId}/nodes/${input.nodeId}/media/attach`, {
18636
+ method: "POST",
18637
+ body: { media_asset_id: assetId }
18638
+ });
18639
+ if (!attached.ok) return void 0;
18640
+ await callApi(`/forms/${input.formId}/nodes/${input.nodeId}/media`, {
18641
+ method: "PATCH",
18642
+ body: { fit_media: true, show_captions: true }
18643
+ }).catch(() => void 0);
18644
+ return { workspaceId, assetId };
18645
+ } catch {
18646
+ return void 0;
18647
+ }
18648
+ }
18649
+ async function failPlaceholder(placeholder, error2) {
18650
+ try {
18651
+ await callApi(`/workspaces/${placeholder.workspaceId}/media/${placeholder.assetId}/complete`, {
18652
+ method: "POST",
18653
+ body: { error: error2 }
18654
+ });
18655
+ } catch {
18656
+ }
18657
+ }
18658
+ async function autoAttachRender(input) {
18659
+ if (input.placeholder) {
18660
+ const completed = await completeViaPlaceholder(input.placeholder, input);
18661
+ if (completed) return completed;
18662
+ }
18663
+ return legacyAutoAttach(input);
18664
+ }
18665
+ async function completeViaPlaceholder(placeholder, input) {
18666
+ try {
18667
+ const body = { url: input.publicUrl };
18668
+ if (input.captions?.length) body.captions = input.captions;
18669
+ const completed = await callApi(`/workspaces/${placeholder.workspaceId}/media/${placeholder.assetId}/complete`, {
18670
+ method: "POST",
18671
+ body
18672
+ });
18673
+ if (!completed.ok) return void 0;
18674
+ let republished = false;
18675
+ const form = await callApi(`/forms/${input.formId}`, { method: "GET" });
18676
+ if (form.ok && form.data?.is_live === true) {
18677
+ const republish = await callApi(`/forms/${input.formId}`, { method: "PATCH", body: { is_live: true } });
18678
+ republished = republish.ok;
18679
+ }
18680
+ return { attached: true, media_asset_id: placeholder.assetId, republished };
18681
+ } catch {
18682
+ return void 0;
18683
+ }
18684
+ }
18685
+ async function legacyAutoAttach(input) {
18686
+ try {
18687
+ const me = await callApi("/me", { method: "GET" });
18688
+ if (!me.ok) return { attached: false, error: `Unable to determine workspace: ${me.error}` };
18689
+ const workspaceId = me.data?.workspace?.id;
18690
+ if (!workspaceId) return { attached: false, error: "No workspace available for auto-attach." };
18691
+ const createBody = {
18692
+ media_type: input.mediaType,
18693
+ media_source: "uploaded",
18694
+ url: input.publicUrl
18695
+ };
18696
+ if (input.captions?.length) createBody.captions = input.captions;
18697
+ const created = await callApi(`/workspaces/${workspaceId}/media`, { method: "POST", body: createBody });
18698
+ if (!created.ok) return { attached: false, error: `Failed to create media asset: ${created.error}` };
18699
+ const mediaAssetId = created.data?.media_asset_id;
18700
+ if (!mediaAssetId) return { attached: false, error: "Media asset creation did not return an id." };
18701
+ const attached = await callApi(`/forms/${input.formId}/nodes/${input.nodeId}/media/attach`, {
18702
+ method: "POST",
18703
+ body: { media_asset_id: mediaAssetId }
18704
+ });
18705
+ if (!attached.ok) {
18706
+ return { attached: false, media_asset_id: mediaAssetId, error: `Failed to attach to node: ${attached.error}` };
18707
+ }
18708
+ await callApi(`/forms/${input.formId}/nodes/${input.nodeId}/media`, {
18709
+ method: "PATCH",
18710
+ body: { fit_media: true, show_captions: true }
18711
+ }).catch(() => void 0);
18712
+ let republished = false;
18713
+ const form = await callApi(`/forms/${input.formId}`, { method: "GET" });
18714
+ if (form.ok && form.data?.is_live === true) {
18715
+ const republish = await callApi(`/forms/${input.formId}`, { method: "PATCH", body: { is_live: true } });
18716
+ republished = republish.ok;
18717
+ }
18718
+ return { attached: true, media_asset_id: mediaAssetId, republished };
18719
+ } catch (err) {
18720
+ return { attached: false, error: err instanceof Error ? err.message : String(err) };
18721
+ }
18722
+ }
18723
+ function attachStructuredFields(attach) {
18724
+ if (!attach) return {};
18725
+ return {
18726
+ attached: attach.attached,
18727
+ ...attach.media_asset_id ? { media_asset_id: attach.media_asset_id } : {},
18728
+ ...attach.republished !== void 0 ? { republished: attach.republished } : {},
18729
+ ...attach.error ? { attach_error: attach.error } : {}
18730
+ };
18731
+ }
18732
+ function attachSummaryLine(nodeId, attach) {
18733
+ return attach.attached ? `Attached to node ${nodeId}${attach.republished ? " and republished." : "."}` : `Auto-attach to node ${nodeId} failed: ${attach.error}. Attach manually via clipform_upload_media_asset then clipform_attach_node_media.`;
18734
+ }
18735
+
18342
18736
  // src/tools/render-composition.ts
18343
18737
  var RenderItemSchema = external_exports.object({
18344
18738
  compositionId: external_exports.string().describe("The composition ID (see clipform_list_compositions)"),
18345
18739
  outputFormat: external_exports.enum(["mp4", "png"]).default("mp4").describe("Output format (default: mp4)"),
18346
- inputProps: external_exports.record(external_exports.unknown()).optional().describe("Props matching the composition's schema - validated strictly")
18740
+ inputProps: external_exports.record(external_exports.unknown()).optional().describe("Props matching the composition's schema - validated strictly"),
18741
+ node_id: external_exports.string().optional().describe("Form node to attach this item's render to automatically once it completes. Requires the batch's top-level form_id."),
18742
+ captions: external_exports.array(CaptionSchema).optional().describe("Word-level captions from clipform_generate_tts - carried onto the attached media asset. Only used when node_id is set.")
18347
18743
  });
18348
- function fireRender(item) {
18744
+ function fireRender(item, formId) {
18349
18745
  const job = createJob("clipform_render_composition");
18746
+ const mediaType = item.outputFormat === "png" ? "still" : "video";
18747
+ const placeholderPromise = item.node_id && formId ? createRenderPlaceholder({ formId, nodeId: item.node_id, mediaType }) : Promise.resolve(void 0);
18350
18748
  callApi("/internal/render", {
18351
18749
  timeoutMs: 3e5,
18352
18750
  // local renders pay a cold webpack bundle; Lambda is fast but bursty
@@ -18355,10 +18753,39 @@ function fireRender(item) {
18355
18753
  outputFormat: item.outputFormat,
18356
18754
  inputProps: item.inputProps ?? {}
18357
18755
  }
18358
- }).then((r) => {
18359
- if (r.ok) completeJob(job.id, r.data);
18360
- else failJob(job.id, r.error);
18361
- }).catch((err) => failJob(job.id, err instanceof Error ? err.message : String(err)));
18756
+ }).then(async (r) => {
18757
+ if (!r.ok) {
18758
+ failJob(job.id, r.error);
18759
+ const placeholder = await placeholderPromise;
18760
+ if (placeholder) await failPlaceholder(placeholder, r.error);
18761
+ return;
18762
+ }
18763
+ let attach;
18764
+ if (item.node_id && formId) {
18765
+ const publicUrl = r.data.public_url;
18766
+ const placeholder = await placeholderPromise;
18767
+ if (publicUrl) {
18768
+ attach = await autoAttachRender({
18769
+ formId,
18770
+ nodeId: item.node_id,
18771
+ publicUrl,
18772
+ mediaType,
18773
+ captions: item.captions,
18774
+ placeholder
18775
+ });
18776
+ } else {
18777
+ const message = "Render completed without a public_url; nothing to attach.";
18778
+ attach = { attached: false, error: message };
18779
+ if (placeholder) await failPlaceholder(placeholder, message);
18780
+ }
18781
+ }
18782
+ completeJob(job.id, r.data, attach);
18783
+ }).catch(async (err) => {
18784
+ const message = err instanceof Error ? err.message : String(err);
18785
+ failJob(job.id, message);
18786
+ const placeholder = await placeholderPromise;
18787
+ if (placeholder) await failPlaceholder(placeholder, message);
18788
+ });
18362
18789
  return job;
18363
18790
  }
18364
18791
  function registerRenderCompositionTool(server) {
@@ -18370,59 +18797,130 @@ function registerRenderCompositionTool(server) {
18370
18797
 
18371
18798
  For narrated Ken Burns slideshows from images, use clipform_generate_video instead. Output formats: mp4 (H.264, best for social media) or png (single frame). Returns a public URL when complete.
18372
18799
 
18373
- For multi-render builds (e.g. one clip per quiz question), pass everything in ONE call via items (max 10): all renders fire in parallel and you get one job ID each - then collect the URLs in a single clipform_check_render call with job_ids. Single render: pass compositionId/inputProps at the top level (wait: true blocks and returns the URL; wait: false returns a job ID).`,
18800
+ For multi-render builds (e.g. one clip per quiz question), pass everything in ONE call via items (max 10): all renders fire in parallel and you get one job ID each - then collect the URLs in a single clipform_check_render call with job_ids. Single render: pass compositionId/inputProps at the top level (wait: true blocks and returns the URL; wait: false returns a job ID).
18801
+
18802
+ ${RENDER_ROUTING_RULE}
18803
+ ${RENDER_ATTACH_RULE} In batch mode, set node_id per item (see items) and form_id once at the top level.`,
18374
18804
  inputSchema: {
18375
18805
  compositionId: external_exports.string().optional().describe(`Single render: the composition ID. Call clipform_list_compositions to see available options (e.g. ${EXPOSED_COMPOSITIONS.map((id) => `'${id}'`).join(", ")}). Use items instead for multiple renders.`),
18376
18806
  outputFormat: external_exports.enum(["mp4", "png"]).default("mp4").describe("Single render: output format (default: mp4)"),
18377
18807
  inputProps: external_exports.record(external_exports.unknown()).optional().describe("Single render: props object matching the composition's schema from clipform_list_compositions. Validated STRICTLY - unknown or missing props fail with the schema in the error, nothing renders silently with defaults. For map compositions (Map), wide shots (camera.zoom < 9) may round lat/lng to 1 decimal place to improve cache hit rates; close shots and pin drops should keep full precision \u2014 the pin lands exactly on `target`."),
18378
18808
  wait: external_exports.boolean().optional().default(true).describe("Single render only: true (default) blocks until the render is ready and returns its URL; false returns a job ID for clipform_check_render. Batch items always run fire-and-poll."),
18379
- items: external_exports.array(RenderItemSchema).min(1).max(10).optional().describe("Batch mode: multiple renders in one call. All fire in parallel; returns one job ID per item - collect with clipform_check_render (job_ids). Use this whenever rendering more than one clip.")
18809
+ items: external_exports.array(RenderItemSchema).min(1).max(10).optional().describe("Batch mode: multiple renders in one call. All fire in parallel; returns one job ID per item - collect with clipform_check_render (job_ids). Use this whenever rendering more than one clip."),
18810
+ node_id: external_exports.string().optional().describe("Single render only: form node to attach this render to automatically once it completes. Requires form_id. For batch mode, set node_id per item instead."),
18811
+ form_id: external_exports.string().uuid().optional().describe("The form UUID - required when node_id is set (single render) or any item sets node_id (batch)."),
18812
+ captions: external_exports.array(CaptionSchema).optional().describe("Single render only: word-level captions from clipform_generate_tts, carried onto the attached media asset. Only used when node_id is set. For batch mode, set captions per item instead.")
18813
+ },
18814
+ outputSchema: {
18815
+ jobs: external_exports.array(
18816
+ external_exports.object({
18817
+ status: external_exports.enum(["rendering", "complete"]),
18818
+ job_id: external_exports.string().optional().describe("Present when status is 'rendering' - pass to check_render"),
18819
+ public_url: external_exports.string().optional().describe("Present when status is 'complete' - attach via upload_media_asset then attach_node_media (fit_media: true), unless node_id was set (auto-attached)"),
18820
+ composition_id: external_exports.string().optional(),
18821
+ output_format: external_exports.string().optional(),
18822
+ attached: external_exports.boolean().optional().describe("True when node_id was provided and the render was attached to the node automatically (present once the attach outcome is known)."),
18823
+ media_asset_id: external_exports.string().optional().describe("The workspace media asset created from this render, when attached."),
18824
+ republished: external_exports.boolean().optional().describe("True when the form was live and was republished to include this media."),
18825
+ attach_error: external_exports.string().optional().describe("Present when node_id was provided but auto-attach failed - the render itself still succeeded.")
18826
+ })
18827
+ ).describe("One entry per render (single or batch)")
18380
18828
  },
18381
18829
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }
18382
18830
  },
18383
- async ({ compositionId, outputFormat, inputProps, wait, items }) => {
18831
+ async ({ compositionId, outputFormat, inputProps, wait, items, node_id, form_id, captions }) => {
18384
18832
  if (items?.length && compositionId) {
18385
18833
  return errorResult("Pass EITHER items (batch) OR compositionId (single render), not both.");
18386
18834
  }
18387
18835
  if (!items?.length && !compositionId) {
18388
18836
  return errorResult("Pass compositionId for a single render, or items for a batch.");
18389
18837
  }
18838
+ if (node_id && !form_id) {
18839
+ return errorResult("form_id is required when node_id is set.");
18840
+ }
18841
+ if (items?.some((i) => i.node_id) && !form_id) {
18842
+ return errorResult("form_id is required when any item sets node_id.");
18843
+ }
18390
18844
  const unknown2 = (items ?? [{ compositionId, outputFormat, inputProps }]).map((i) => i.compositionId).filter((id) => !EXPOSED_COMPOSITIONS.includes(id));
18391
18845
  if (unknown2.length) {
18392
18846
  return errorResult(`Composition${unknown2.length > 1 ? "s" : ""} ${unknown2.map((id) => `"${id}"`).join(", ")} not available. Call clipform_list_compositions to see available options.`);
18393
18847
  }
18394
18848
  if (items?.length) {
18395
- const jobs2 = items.map((item) => ({ item, job: fireRender(item) }));
18396
- return textResult(
18849
+ const jobs2 = items.map((item) => ({ item, job: fireRender(item, form_id) }));
18850
+ return structuredResult(
18397
18851
  [
18398
18852
  `${jobs2.length} renders started in parallel:`,
18399
- ...jobs2.map(({ item, job }) => `- ${item.compositionId} (${item.outputFormat}): job ${job.id}`),
18853
+ ...jobs2.map(({ item, job }) => `- ${item.compositionId} (${item.outputFormat}): job ${job.id}${item.node_id ? ` -> attaches to node ${item.node_id}` : ""}`),
18400
18854
  ``,
18401
18855
  `Collect with ONE clipform_check_render call (job_ids: [...]). Renders typically take ${RENDER_TIMING.expectedRange}.`
18402
- ].join("\n")
18856
+ ].join("\n"),
18857
+ {
18858
+ jobs: jobs2.map(({ item, job }) => ({
18859
+ status: "rendering",
18860
+ job_id: job.id,
18861
+ composition_id: item.compositionId,
18862
+ output_format: item.outputFormat
18863
+ }))
18864
+ }
18403
18865
  );
18404
18866
  }
18405
18867
  if (wait === false) {
18406
- const job = fireRender({ compositionId, outputFormat, inputProps });
18407
- return textResult(
18868
+ const job = fireRender({ compositionId, outputFormat, inputProps, node_id, captions }, form_id);
18869
+ return structuredResult(
18408
18870
  [
18409
18871
  `Render started (${compositionId}).`,
18410
18872
  `Job ID: ${job.id}`,
18411
- `Fire any remaining renders now, then poll clipform_check_render. Renders typically take ${RENDER_TIMING.expectedRange}.`
18412
- ].join("\n")
18873
+ node_id ? `Will attach to node ${node_id} automatically once it completes - no need to poll to completion.` : `Fire any remaining renders now, then poll clipform_check_render. Renders typically take ${RENDER_TIMING.expectedRange}.`
18874
+ ].join("\n"),
18875
+ {
18876
+ jobs: [{ status: "rendering", job_id: job.id, composition_id: compositionId, output_format: outputFormat }]
18877
+ }
18413
18878
  );
18414
18879
  }
18880
+ const mediaTypeSingle = outputFormat === "png" ? "still" : "video";
18881
+ const placeholderPromise = node_id && form_id ? createRenderPlaceholder({ formId: form_id, nodeId: node_id, mediaType: mediaTypeSingle }) : Promise.resolve(void 0);
18415
18882
  const result = await callApi("/internal/render", {
18416
18883
  timeoutMs: 3e5,
18417
18884
  body: { compositionId, outputFormat, inputProps: inputProps ?? {} }
18418
18885
  });
18419
- if (!result.ok) return errorResult(result.error);
18886
+ if (!result.ok) {
18887
+ const placeholder = await placeholderPromise;
18888
+ if (placeholder) await failPlaceholder(placeholder, result.error);
18889
+ return errorResult(result.error);
18890
+ }
18420
18891
  const data = result.data;
18421
- return textResult(
18892
+ let attach;
18893
+ if (node_id && form_id) {
18894
+ const placeholder = await placeholderPromise;
18895
+ if (data.public_url) {
18896
+ attach = await autoAttachRender({
18897
+ formId: form_id,
18898
+ nodeId: node_id,
18899
+ publicUrl: data.public_url,
18900
+ mediaType: mediaTypeSingle,
18901
+ captions,
18902
+ placeholder
18903
+ });
18904
+ } else if (placeholder) {
18905
+ await failPlaceholder(placeholder, "Render completed without a public_url; nothing to attach.");
18906
+ }
18907
+ }
18908
+ return structuredResult(
18422
18909
  [
18423
18910
  `Render complete. Public URL: ${data.public_url}`,
18424
- `Attach via clipform_upload_node_media with fit_media: true (composed 9:16 frame - contain, never crop).`
18425
- ].join("\n")
18911
+ attach ? attachSummaryLine(node_id, attach) : `Attach via clipform_upload_media_asset then clipform_attach_node_media with fit_media: true (composed 9:16 frame - contain, never crop).`
18912
+ ].join("\n"),
18913
+ {
18914
+ jobs: [
18915
+ {
18916
+ status: "complete",
18917
+ ...data.public_url ? { public_url: data.public_url } : {},
18918
+ composition_id: compositionId,
18919
+ output_format: outputFormat,
18920
+ ...attachStructuredFields(attach)
18921
+ }
18922
+ ]
18923
+ }
18426
18924
  );
18427
18925
  }
18428
18926
  );
@@ -18443,6 +18941,19 @@ function registerSearchMusicTool(server) {
18443
18941
  maxDuration: external_exports.number().optional().describe("Maximum duration in seconds"),
18444
18942
  tags: external_exports.array(external_exports.string()).optional().describe("Genre/mood tags to filter by")
18445
18943
  },
18944
+ outputSchema: {
18945
+ query: external_exports.string(),
18946
+ tracks: external_exports.array(
18947
+ external_exports.object({
18948
+ title: external_exports.string().optional(),
18949
+ artist: external_exports.string().optional(),
18950
+ url: external_exports.string(),
18951
+ duration: external_exports.number().optional(),
18952
+ source: external_exports.string().optional(),
18953
+ license: external_exports.string().optional()
18954
+ })
18955
+ ).describe("Matching tracks (empty if none); pass a url as the audio track to generate_video")
18956
+ },
18446
18957
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
18447
18958
  },
18448
18959
  async ({ query, count, instrumentalOnly, minDuration, maxDuration, tags }) => {
@@ -18451,7 +18962,15 @@ function registerSearchMusicTool(server) {
18451
18962
  });
18452
18963
  if (!result.ok) return errorResult(result.error);
18453
18964
  const results = result.data.results;
18454
- if (!results.length) return textResult(`No music found for "${query}".`);
18965
+ if (!results.length) return structuredResult(`No music found for "${query}".`, { query, tracks: [] });
18966
+ const tracks = results.map((item) => ({
18967
+ ...item.title ? { title: item.title } : {},
18968
+ ...item.artist ? { artist: item.artist } : {},
18969
+ url: item.url,
18970
+ ...typeof item.duration === "number" ? { duration: item.duration } : {},
18971
+ ...item.source ? { source: item.source } : {},
18972
+ ...item.license ? { license: item.license } : {}
18973
+ }));
18455
18974
  const lines = [`Found ${results.length} tracks for "${query}":
18456
18975
  `];
18457
18976
  for (const item of results) {
@@ -18462,7 +18981,7 @@ function registerSearchMusicTool(server) {
18462
18981
  if (item.license) lines.push(` License: ${item.license}`);
18463
18982
  lines.push("");
18464
18983
  }
18465
- return textResult(lines.join("\n"));
18984
+ return structuredResult(lines.join("\n"), { query, tracks });
18466
18985
  }
18467
18986
  );
18468
18987
  }
@@ -18503,6 +19022,19 @@ function registerListCompositionsTool(server) {
18503
19022
  title: "List Compositions",
18504
19023
  description: `Browse available video compositions and their expected props schemas. Call this before using clipform_render_composition to discover visual styles and their input props. For narrated slideshows from images, use clipform_generate_video instead.`,
18505
19024
  inputSchema: {},
19025
+ outputSchema: {
19026
+ compositions: external_exports.array(
19027
+ external_exports.object({
19028
+ id: external_exports.string(),
19029
+ width: external_exports.number(),
19030
+ height: external_exports.number(),
19031
+ fps: external_exports.number(),
19032
+ durationInFrames: external_exports.number(),
19033
+ props: external_exports.string().describe("Compact props summary (name*: type)"),
19034
+ propsSchema: external_exports.record(external_exports.unknown()).optional().describe("Full JSON Schema for inputProps")
19035
+ })
19036
+ ).describe("Available compositions - pass an id to render_composition")
19037
+ },
18506
19038
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
18507
19039
  },
18508
19040
  async () => {
@@ -18517,7 +19049,18 @@ function registerListCompositionsTool(server) {
18517
19049
  }
18518
19050
  const all = result.data.compositions;
18519
19051
  const compositions = all.filter((c) => EXPOSED_COMPOSITIONS.includes(c.id));
18520
- if (!compositions.length) return textResult("No compositions found.");
19052
+ if (!compositions.length) return structuredResult("No compositions found.", { compositions: [] });
19053
+ const structured = {
19054
+ compositions: compositions.map((comp) => ({
19055
+ id: comp.id,
19056
+ width: comp.width,
19057
+ height: comp.height,
19058
+ fps: comp.fps,
19059
+ durationInFrames: comp.durationInFrames,
19060
+ props: summarizeSchema(comp.propsSchema),
19061
+ ...comp.propsSchema ? { propsSchema: comp.propsSchema } : {}
19062
+ }))
19063
+ };
18521
19064
  const lines = [`Available compositions (${compositions.length}):
18522
19065
  `];
18523
19066
  for (const comp of compositions) {
@@ -18527,7 +19070,183 @@ function registerListCompositionsTool(server) {
18527
19070
  }
18528
19071
  lines.push("");
18529
19072
  lines.push("Use clipform_render_composition with a compositionId to render. inputProps are validated strictly against the schema - unknown or missing props fail.");
18530
- return textResult(lines.join("\n"));
19073
+ return structuredResult(lines.join("\n"), structured);
19074
+ }
19075
+ );
19076
+ }
19077
+
19078
+ // src/tools/list-video-templates.ts
19079
+ function registerListVideoTemplatesTool(server) {
19080
+ server.registerTool(
19081
+ "clipform_list_video_templates",
19082
+ {
19083
+ title: "List Video Templates",
19084
+ description: `Browse available video templates - curated Scene arrangements (bed + overlay + sane defaults) that render through the Scene composition from a small controls object. Call this before clipform_render_video_template to see template names and their exposed controls. For a fully custom layer stack, assemble Scene layers directly via clipform_render_composition instead.`,
19085
+ inputSchema: {},
19086
+ outputSchema: {
19087
+ templates: external_exports.array(
19088
+ external_exports.object({
19089
+ name: external_exports.string(),
19090
+ tier: external_exports.string(),
19091
+ description: external_exports.string(),
19092
+ keywords: external_exports.array(external_exports.string()).describe("Natural-language phrases a user's intent might match against this template"),
19093
+ controls: external_exports.array(external_exports.string()).describe("Control names, '?'-suffixed when optional"),
19094
+ exampleControls: external_exports.record(external_exports.unknown()).optional().describe("A worked, ready-to-render controls object for this template - copy and adapt it")
19095
+ })
19096
+ ).describe("Available video templates - pass a name + controls to clipform_render_video_template")
19097
+ },
19098
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
19099
+ },
19100
+ async () => {
19101
+ const result = await callApi("/internal/video-templates", { method: "GET" });
19102
+ if (!result.ok) {
19103
+ if (result.status === 0 || result.status === 404) {
19104
+ return errorResult("Video templates are not available (API server may not be running). Start the API with 'npm run dev --workspace=apps/api'.");
19105
+ }
19106
+ return errorResult(result.error);
19107
+ }
19108
+ const templates = result.data.templates ?? [];
19109
+ if (!templates.length) {
19110
+ return structuredResult("No video templates found.", { templates: [] });
19111
+ }
19112
+ const lines = [`Available video templates (${templates.length}):
19113
+ `];
19114
+ for (const t of templates) {
19115
+ lines.push(`- **${t.name}** - ${t.description}`);
19116
+ lines.push(` use when: ${t.keywords.join(", ")}`);
19117
+ lines.push(` controls: ${t.controls.join(", ")}`);
19118
+ if (t.exampleControls) {
19119
+ lines.push(` example: ${JSON.stringify(t.exampleControls)}`);
19120
+ }
19121
+ }
19122
+ lines.push("");
19123
+ lines.push("Match the user's intent against each template's description/use-when keywords before picking one. Use clipform_render_video_template with a template name + controls to render (renders through Scene).");
19124
+ return structuredResult(lines.join("\n"), {
19125
+ templates: templates.map((t) => ({
19126
+ name: t.name,
19127
+ tier: t.tier,
19128
+ description: t.description,
19129
+ keywords: t.keywords,
19130
+ controls: t.controls,
19131
+ ...t.exampleControls ? { exampleControls: t.exampleControls } : {}
19132
+ }))
19133
+ });
19134
+ }
19135
+ );
19136
+ }
19137
+
19138
+ // src/tools/render-video-template.ts
19139
+ function registerRenderVideoTemplateTool(server) {
19140
+ server.registerTool(
19141
+ "clipform_render_video_template",
19142
+ {
19143
+ title: "Render Video Template",
19144
+ description: `Render a curated video template (a pre-arranged Scene: bed + overlay + sane defaults) to MP4 or PNG from a small controls object, instead of hand-assembling Scene layers. Call clipform_list_video_templates first for available template names and their controls. Renders through the Scene composition under the hood.
19145
+
19146
+ ${RENDER_ROUTING_RULE}
19147
+ ${RENDER_ATTACH_RULE}`,
19148
+ inputSchema: {
19149
+ template: external_exports.string().describe("Video template name (see clipform_list_video_templates)."),
19150
+ controls: external_exports.record(external_exports.unknown()).describe("Template-specific controls (see clipform_list_video_templates for the exposed control surface)."),
19151
+ outputFormat: external_exports.enum(["mp4", "png"]).default("mp4").describe("Output format (default: mp4)"),
19152
+ wait: external_exports.boolean().optional().default(true).describe("true (default) blocks until the render is ready and returns its URL; false returns a job ID for clipform_check_render."),
19153
+ node_id: external_exports.string().optional().describe("Form node to attach this render to automatically once it completes - skips the manual clipform_upload_media_asset + clipform_attach_node_media steps and republishes the form if it's currently live. Requires form_id."),
19154
+ form_id: external_exports.string().uuid().optional().describe("The form UUID (required when node_id is set)."),
19155
+ captions: external_exports.array(CaptionSchema).optional().describe("Word-level captions from clipform_generate_tts - carried onto the attached media asset. Only used when node_id is set.")
19156
+ },
19157
+ outputSchema: {
19158
+ status: external_exports.enum(["rendering", "complete"]),
19159
+ job_id: external_exports.string().optional().describe("Present when status is 'rendering' - pass to clipform_check_render"),
19160
+ public_url: external_exports.string().optional().describe("Present when status is 'complete' - attach via upload_media_asset then attach_node_media (fit_media: true), unless node_id was set (auto-attached)"),
19161
+ template: external_exports.string().optional(),
19162
+ output_format: external_exports.string().optional(),
19163
+ attached: external_exports.boolean().optional().describe("True when node_id was provided and the render was attached to the node automatically (present once the attach outcome is known)."),
19164
+ media_asset_id: external_exports.string().optional().describe("The workspace media asset created from this render, when attached."),
19165
+ republished: external_exports.boolean().optional().describe("True when the form was live and was republished to include this media."),
19166
+ attach_error: external_exports.string().optional().describe("Present when node_id was provided but auto-attach failed - the render itself still succeeded.")
19167
+ },
19168
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }
19169
+ },
19170
+ async ({ template, controls, outputFormat, wait, node_id, form_id, captions }) => {
19171
+ if (node_id && !form_id) {
19172
+ return errorResult("form_id is required when node_id is set.");
19173
+ }
19174
+ const renderBody = { template, controls: controls ?? {}, outputFormat };
19175
+ const mediaType = outputFormat === "png" ? "still" : "video";
19176
+ if (wait === false) {
19177
+ const job = createJob("clipform_render_video_template");
19178
+ const placeholderPromise2 = node_id && form_id ? createRenderPlaceholder({ formId: form_id, nodeId: node_id, mediaType }) : Promise.resolve(void 0);
19179
+ callApi("/internal/render-template", {
19180
+ timeoutMs: 3e5,
19181
+ body: renderBody
19182
+ }).then(async (r) => {
19183
+ if (!r.ok) {
19184
+ failJob(job.id, r.error);
19185
+ const placeholder = await placeholderPromise2;
19186
+ if (placeholder) await failPlaceholder(placeholder, r.error);
19187
+ return;
19188
+ }
19189
+ let attach2;
19190
+ if (node_id && form_id) {
19191
+ const publicUrl = r.data.public_url;
19192
+ const placeholder = await placeholderPromise2;
19193
+ if (publicUrl) {
19194
+ attach2 = await autoAttachRender({ formId: form_id, nodeId: node_id, publicUrl, mediaType, captions, placeholder });
19195
+ } else {
19196
+ const message = "Render completed without a public_url; nothing to attach.";
19197
+ attach2 = { attached: false, error: message };
19198
+ if (placeholder) await failPlaceholder(placeholder, message);
19199
+ }
19200
+ }
19201
+ completeJob(job.id, r.data, attach2);
19202
+ }).catch(async (err) => {
19203
+ const message = err instanceof Error ? err.message : String(err);
19204
+ failJob(job.id, message);
19205
+ const placeholder = await placeholderPromise2;
19206
+ if (placeholder) await failPlaceholder(placeholder, message);
19207
+ });
19208
+ return structuredResult(
19209
+ [
19210
+ `Render started (template: ${template}).`,
19211
+ `Job ID: ${job.id}`,
19212
+ node_id ? `Will attach to node ${node_id} automatically once it completes - no need to poll to completion.` : `Poll clipform_check_render. Renders typically take ${RENDER_TIMING.expectedRange}.`
19213
+ ].join("\n"),
19214
+ { status: "rendering", job_id: job.id, template, output_format: outputFormat }
19215
+ );
19216
+ }
19217
+ const placeholderPromise = node_id && form_id ? createRenderPlaceholder({ formId: form_id, nodeId: node_id, mediaType }) : Promise.resolve(void 0);
19218
+ const result = await callApi("/internal/render-template", {
19219
+ timeoutMs: 3e5,
19220
+ body: renderBody
19221
+ });
19222
+ if (!result.ok) {
19223
+ const placeholder = await placeholderPromise;
19224
+ if (placeholder) await failPlaceholder(placeholder, result.error);
19225
+ return errorResult(result.error);
19226
+ }
19227
+ const data = result.data;
19228
+ let attach;
19229
+ if (node_id && form_id) {
19230
+ const placeholder = await placeholderPromise;
19231
+ if (data.public_url) {
19232
+ attach = await autoAttachRender({ formId: form_id, nodeId: node_id, publicUrl: data.public_url, mediaType, captions, placeholder });
19233
+ } else if (placeholder) {
19234
+ await failPlaceholder(placeholder, "Render completed without a public_url; nothing to attach.");
19235
+ }
19236
+ }
19237
+ return structuredResult(
19238
+ [
19239
+ `Render complete. Public URL: ${data.public_url}`,
19240
+ attach ? attachSummaryLine(node_id, attach) : `Attach via clipform_upload_media_asset then clipform_attach_node_media with fit_media: true (composed 9:16 frame - contain, never crop).`
19241
+ ].join("\n"),
19242
+ {
19243
+ status: "complete",
19244
+ ...data.public_url ? { public_url: data.public_url } : {},
19245
+ template,
19246
+ output_format: outputFormat,
19247
+ ...attachStructuredFields(attach)
19248
+ }
19249
+ );
18531
19250
  }
18532
19251
  );
18533
19252
  }
@@ -18542,6 +19261,11 @@ function registerListAssetsTool(server) {
18542
19261
  inputSchema: {
18543
19262
  type: external_exports.enum(["sfx", "animation", "font", "all"]).default("all").optional().describe("Asset type to list (default: all)")
18544
19263
  },
19264
+ outputSchema: {
19265
+ sfx: external_exports.array(external_exports.object({ name: external_exports.string(), description: external_exports.string().nullable().optional(), path: external_exports.string().optional() })),
19266
+ animations: external_exports.array(external_exports.object({ name: external_exports.string(), description: external_exports.string().nullable().optional(), path: external_exports.string().optional() })),
19267
+ fonts: external_exports.array(external_exports.object({ name: external_exports.string(), weights: external_exports.array(external_exports.union([external_exports.string(), external_exports.number()])).optional() }))
19268
+ },
18545
19269
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
18546
19270
  },
18547
19271
  async ({ type }) => {
@@ -18552,6 +19276,11 @@ function registerListAssetsTool(server) {
18552
19276
  if (!result.ok) return errorResult(result.error);
18553
19277
  const data = result.data;
18554
19278
  const lines = [];
19279
+ const structured = {
19280
+ sfx: (data.sfx ?? []).map((s) => ({ name: s.name, description: s.description ?? null, path: s.path })),
19281
+ animations: (data.animations ?? []).map((a) => ({ name: a.name, description: a.description ?? null, path: a.path })),
19282
+ fonts: (data.fonts ?? []).map((f) => ({ name: f.name, ...Array.isArray(f.weights) ? { weights: f.weights } : {} }))
19283
+ };
18555
19284
  if (data.sfx?.length) {
18556
19285
  lines.push(`## Sound Effects (${data.sfx.length})
18557
19286
  `);
@@ -18576,7 +19305,7 @@ function registerListAssetsTool(server) {
18576
19305
  }
18577
19306
  lines.push("");
18578
19307
  }
18579
- return textResult(lines.length ? lines.join("\n") : "No assets found.");
19308
+ return structuredResult(lines.length ? lines.join("\n") : "No assets found.", structured);
18580
19309
  }
18581
19310
  );
18582
19311
  }
@@ -18591,7 +19320,11 @@ function registerGenerateVideoTool(server) {
18591
19320
 
18592
19321
  Items: type "image" (Ken Burns motion) or "video" (cover-cropped, muted by default). Duration matches audio_url or set duration_seconds explicitly.
18593
19322
 
18594
- For multi-question builds, pass wait: false on every render: each call returns a job ID immediately, so all renders run in parallel - then collect URLs with clipform_check_render. Sequential waiting renders take ${RENDER_TIMING.expectedRange} EACH.`,
19323
+ For multi-question builds, pass wait: false on every render: each call returns a job ID immediately, so all renders run in parallel - then collect URLs with clipform_check_render. Sequential waiting renders take ${RENDER_TIMING.expectedRange} EACH.
19324
+
19325
+ ${RENDER_ROUTING_RULE}
19326
+ ${MONTAGE_NARRATION_RULE}
19327
+ ${RENDER_ATTACH_RULE}`,
18595
19328
  inputSchema: {
18596
19329
  items: external_exports.array(
18597
19330
  external_exports.object({
@@ -18625,11 +19358,27 @@ For multi-question builds, pass wait: false on every render: each call returns a
18625
19358
  contrast: external_exports.number().positive().optional().describe("Contrast boost before mapping (default 1.1)")
18626
19359
  }).optional().describe("Two-tone editorial recolour on image items - desaturates then maps to a shadow->highlight palette. Pair with a halftone texture for a screen-print poster look."),
18627
19360
  background_color: external_exports.string().optional().describe("Background color (default '#000')"),
18628
- wait: external_exports.boolean().optional().default(true).describe("true (default) blocks until the video is ready and returns its URL. false returns a job ID immediately - fire all renders first, then poll clipform_check_render. Use false whenever rendering more than one video.")
19361
+ wait: external_exports.boolean().optional().default(true).describe("true (default) blocks until the video is ready and returns its URL. false returns a job ID immediately - fire all renders first, then poll clipform_check_render. Use false whenever rendering more than one video."),
19362
+ node_id: external_exports.string().optional().describe("Form node to attach this render to automatically once it completes - skips the manual clipform_upload_media_asset + clipform_attach_node_media steps and republishes the form if it's currently live. Requires form_id."),
19363
+ form_id: external_exports.string().uuid().optional().describe("The form UUID (required when node_id is set)."),
19364
+ captions: external_exports.array(CaptionSchema).optional().describe("Word-level captions from clipform_generate_tts - carried onto the attached media asset. Only used when node_id is set.")
19365
+ },
19366
+ outputSchema: {
19367
+ status: external_exports.enum(["rendering", "complete"]).describe("'rendering' when wait:false (poll check_render); 'complete' with a public_url when wait:true"),
19368
+ job_id: external_exports.string().optional().describe("Present when status is 'rendering' - pass to check_render"),
19369
+ public_url: external_exports.string().optional().describe("Present when status is 'complete' - attach via upload_media_asset then attach_node_media (fit_media: true), unless node_id was set (auto-attached)"),
19370
+ duration_seconds: external_exports.number().optional(),
19371
+ attached: external_exports.boolean().optional().describe("True when node_id was provided and the render was attached to the node automatically (present once the attach outcome is known)."),
19372
+ media_asset_id: external_exports.string().optional().describe("The workspace media asset created from this render, when attached."),
19373
+ republished: external_exports.boolean().optional().describe("True when the form was live and was republished to include this media."),
19374
+ attach_error: external_exports.string().optional().describe("Present when node_id was provided but auto-attach failed - the render itself still succeeded.")
18629
19375
  },
18630
19376
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }
18631
19377
  },
18632
- async ({ items, audio_url, background_audio_url, background_audio_volume, duration_seconds, random_effects, transition, style_preset, texture, duotone, background_color, wait }) => {
19378
+ async ({ items, audio_url, background_audio_url, background_audio_volume, duration_seconds, random_effects, transition, style_preset, texture, duotone, background_color, wait, node_id, form_id, captions }) => {
19379
+ if (node_id && !form_id) {
19380
+ return errorResult("form_id is required when node_id is set.");
19381
+ }
18633
19382
  const workspace_id = getMcpAuth()?.workspace_id;
18634
19383
  const default_style = style_preset || texture || duotone ? {
18635
19384
  ...style_preset ? { preset: style_preset } : {},
@@ -18654,34 +19403,95 @@ For multi-question builds, pass wait: false on every render: each call returns a
18654
19403
  });
18655
19404
  if (wait === false) {
18656
19405
  const job = createJob("clipform_generate_video");
18657
- apiCall().then((r) => {
18658
- if (r.ok) completeJob(job.id, r.data);
18659
- else failJob(job.id, r.error);
18660
- }).catch((err) => failJob(job.id, err instanceof Error ? err.message : String(err)));
18661
- return textResult(
19406
+ const placeholderPromise2 = node_id && form_id ? createRenderPlaceholder({ formId: form_id, nodeId: node_id, mediaType: "video" }) : Promise.resolve(void 0);
19407
+ apiCall().then(async (r) => {
19408
+ if (!r.ok) {
19409
+ failJob(job.id, r.error);
19410
+ const placeholder = await placeholderPromise2;
19411
+ if (placeholder) await failPlaceholder(placeholder, r.error);
19412
+ return;
19413
+ }
19414
+ let attach2;
19415
+ if (node_id && form_id) {
19416
+ const publicUrl = r.data.public_url;
19417
+ const placeholder = await placeholderPromise2;
19418
+ if (publicUrl) {
19419
+ attach2 = await autoAttachRender({ formId: form_id, nodeId: node_id, publicUrl, mediaType: "video", captions, placeholder });
19420
+ } else {
19421
+ const message = "Render completed without a public_url; nothing to attach.";
19422
+ attach2 = { attached: false, error: message };
19423
+ if (placeholder) await failPlaceholder(placeholder, message);
19424
+ }
19425
+ }
19426
+ completeJob(job.id, r.data, attach2);
19427
+ }).catch(async (err) => {
19428
+ const message = err instanceof Error ? err.message : String(err);
19429
+ failJob(job.id, message);
19430
+ const placeholder = await placeholderPromise2;
19431
+ if (placeholder) await failPlaceholder(placeholder, message);
19432
+ });
19433
+ return structuredResult(
18662
19434
  [
18663
19435
  `Render started (${items.length} item${items.length > 1 ? "s" : ""}).`,
18664
19436
  `Job ID: ${job.id}`,
18665
- `Fire any remaining renders now, then poll clipform_check_render. Renders typically take ${RENDER_TIMING.expectedRange}.`
18666
- ].join("\n")
19437
+ node_id ? `Will attach to node ${node_id} automatically once it completes - no need to poll to completion.` : `Fire any remaining renders now, then poll clipform_check_render. Renders typically take ${RENDER_TIMING.expectedRange}.`
19438
+ ].join("\n"),
19439
+ { status: "rendering", job_id: job.id }
18667
19440
  );
18668
19441
  }
19442
+ const placeholderPromise = node_id && form_id ? createRenderPlaceholder({ formId: form_id, nodeId: node_id, mediaType: "video" }) : Promise.resolve(void 0);
18669
19443
  const result = await apiCall();
18670
- if (!result.ok) return errorResult(result.error);
19444
+ if (!result.ok) {
19445
+ const placeholder = await placeholderPromise;
19446
+ if (placeholder) await failPlaceholder(placeholder, result.error);
19447
+ return errorResult(result.error);
19448
+ }
18671
19449
  const data = result.data;
18672
- return textResult(
19450
+ let attach;
19451
+ if (node_id && form_id) {
19452
+ const placeholder = await placeholderPromise;
19453
+ if (data.public_url) {
19454
+ attach = await autoAttachRender({ formId: form_id, nodeId: node_id, publicUrl: data.public_url, mediaType: "video", captions, placeholder });
19455
+ } else if (placeholder) {
19456
+ await failPlaceholder(placeholder, "Render completed without a public_url; nothing to attach.");
19457
+ }
19458
+ }
19459
+ return structuredResult(
18673
19460
  [
18674
19461
  `Video rendered (${items.length} item${items.length > 1 ? "s" : ""}).`,
18675
19462
  `Public URL: ${data.public_url}`,
18676
19463
  data.duration_seconds ? `Duration: ${data.duration_seconds}s` : "",
18677
- `Attach via clipform_upload_node_media with fit_media: true (composed 9:16 frame - contain, never crop).`
18678
- ].filter(Boolean).join("\n")
19464
+ attach ? attachSummaryLine(node_id, attach) : `Attach via clipform_upload_media_asset then clipform_attach_node_media with fit_media: true (composed 9:16 frame - contain, never crop).`
19465
+ ].filter(Boolean).join("\n"),
19466
+ {
19467
+ status: "complete",
19468
+ ...data.public_url ? { public_url: data.public_url } : {},
19469
+ ...typeof data.duration_seconds === "number" ? { duration_seconds: data.duration_seconds } : {},
19470
+ ...attachStructuredFields(attach)
19471
+ }
18679
19472
  );
18680
19473
  }
18681
19474
  );
18682
19475
  }
18683
19476
 
18684
19477
  // src/tools/check-render.ts
19478
+ function projectJob(id, job) {
19479
+ if (!job) return { job_id: id, status: "not_found" };
19480
+ if (job.status === "rendering") {
19481
+ return { job_id: id, status: "rendering", elapsed_seconds: Math.round((Date.now() - job.createdAt) / 1e3) };
19482
+ }
19483
+ if (job.status === "failed") {
19484
+ return { job_id: id, status: "failed", ...job.error ? { error: job.error } : {} };
19485
+ }
19486
+ const data = job.result;
19487
+ return {
19488
+ job_id: id,
19489
+ status: "complete",
19490
+ ...data?.public_url ? { public_url: data.public_url } : {},
19491
+ ...typeof data?.duration_seconds === "number" ? { duration_seconds: data.duration_seconds } : {},
19492
+ ...attachStructuredFields(job.attach)
19493
+ };
19494
+ }
18685
19495
  var LOST_JOB_RECOVERY = [
18686
19496
  `Jobs expire after 30 minutes and are lost if the server restarts - the render itself may still have completed.`,
18687
19497
  `To recover, re-run the original render call with identical arguments:`,
@@ -18704,20 +19514,37 @@ function describeJob(id, job) {
18704
19514
  ...data.public_url ? [data.public_url] : [],
18705
19515
  ...data.duration_seconds ? [`${data.duration_seconds}s`] : []
18706
19516
  ].join(" | ");
18707
- return { line: `- ${id}: complete${extras ? ` - ${extras}` : ""}`, done: true, failed: false };
19517
+ const attachNote = job.attach ? job.attach.attached ? ` (attached${job.attach.republished ? " + republished" : ""})` : ` (attach FAILED: ${job.attach.error})` : "";
19518
+ return { line: `- ${id}: complete${extras ? ` - ${extras}` : ""}${attachNote}`, done: true, failed: false };
18708
19519
  }
18709
19520
  function registerCheckRenderTool(server) {
18710
19521
  server.registerTool(
18711
19522
  "clipform_check_render",
18712
19523
  {
18713
19524
  title: "Check Render Status",
18714
- description: `Check the status of render jobs started by clipform_generate_video or clipform_render_composition.
19525
+ description: `Check the status of render jobs started by clipform_generate_video, clipform_render_video_template, or clipform_render_composition.
18715
19526
 
18716
- Pass job_ids to check a whole batch in ONE call - one line of status per job. Pass job_id for a single job. Returns the output URL for each completed render. Typical render time: 10-60 seconds.`,
19527
+ Pass job_ids to check a whole batch in ONE call - one line of status per job. Pass job_id for a single job. Returns the output URL for each completed render. Typical render time: 10-60 seconds. Attach is automatic when node_id was provided to the render tool - no need to poll to completion, and the attach outcome is reported here once known.`,
18717
19528
  inputSchema: {
18718
19529
  job_id: external_exports.string().uuid().optional().describe("A single job ID returned by the render tool"),
18719
19530
  job_ids: external_exports.array(external_exports.string().uuid()).min(1).max(20).optional().describe("Multiple job IDs - check the whole batch in one call instead of one call per job")
18720
19531
  },
19532
+ outputSchema: {
19533
+ jobs: external_exports.array(
19534
+ external_exports.object({
19535
+ job_id: external_exports.string(),
19536
+ status: external_exports.enum(["rendering", "complete", "failed", "not_found"]),
19537
+ public_url: external_exports.string().optional(),
19538
+ duration_seconds: external_exports.number().optional(),
19539
+ elapsed_seconds: external_exports.number().optional(),
19540
+ error: external_exports.string().optional(),
19541
+ attached: external_exports.boolean().optional().describe("True when the render's node_id was set and it attached to the node automatically."),
19542
+ media_asset_id: external_exports.string().optional().describe("The workspace media asset created from this render, when attached."),
19543
+ republished: external_exports.boolean().optional().describe("True when the form was live and was republished to include this media."),
19544
+ attach_error: external_exports.string().optional().describe("Present when node_id was set but auto-attach failed - the render itself still succeeded.")
19545
+ })
19546
+ ).describe("One entry per queried job; attach completed public_urls via upload_media_asset then attach_node_media unless already auto-attached")
19547
+ },
18721
19548
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
18722
19549
  },
18723
19550
  async ({ job_id, job_ids }) => {
@@ -18733,38 +19560,44 @@ Pass job_ids to check a whole batch in ONE call - one line of status per job. Pa
18733
19560
  }
18734
19561
  if (job.status === "rendering") {
18735
19562
  const elapsed = Math.round((Date.now() - job.createdAt) / 1e3);
18736
- return textResult(
19563
+ return structuredResult(
18737
19564
  [
18738
19565
  `Status: rendering (${elapsed}s elapsed)`,
18739
19566
  `Tool: ${job.tool}`,
18740
19567
  ``,
18741
19568
  `Still in progress. Check again in ${RENDER_TIMING.pollDelay}.`
18742
- ].join("\n")
19569
+ ].join("\n"),
19570
+ { jobs: [projectJob(ids[0], job)] }
18743
19571
  );
18744
19572
  }
18745
19573
  if (job.status === "failed") {
18746
19574
  return errorResult(`Render failed: ${job.error}`);
18747
19575
  }
18748
19576
  const data = job.result;
18749
- return textResult(
19577
+ const attachLines = job.attach ? [
19578
+ job.attach.attached ? `Attached to node automatically${job.attach.republished ? " and republished." : "."}` : `Auto-attach failed: ${job.attach.error}. Attach manually via clipform_upload_media_asset then clipform_attach_node_media.`
19579
+ ] : [`Attach via clipform_upload_media_asset then clipform_attach_node_media with fit_media: true (composed 9:16 frame - contain, never crop).`];
19580
+ return structuredResult(
18750
19581
  [
18751
19582
  `Render complete.`,
18752
19583
  ...data.public_url ? [`Public URL: ${data.public_url}`] : [],
18753
19584
  ...data.duration_seconds ? [`Duration: ${data.duration_seconds}s`] : [],
18754
- `Attach via clipform_upload_node_media with fit_media: true (composed 9:16 frame - contain, never crop).`
18755
- ].join("\n")
19585
+ ...attachLines
19586
+ ].join("\n"),
19587
+ { jobs: [projectJob(ids[0], job)] }
18756
19588
  );
18757
19589
  }
18758
19590
  const results = ids.map((id) => describeJob(id, getJob(id)));
18759
19591
  const pending = results.filter((r) => !r.done).length;
18760
19592
  const lost = results.some((r) => r.line.includes("NOT FOUND"));
18761
- return textResult(
19593
+ return structuredResult(
18762
19594
  [
18763
19595
  `Render batch: ${results.length - pending}/${results.length} finished${pending ? ` - check again in ${RENDER_TIMING.pollDelay}` : ""}.`,
18764
19596
  ...results.map((r) => r.line),
18765
- ...pending === 0 ? [``, `Attach completed renders via clipform_upload_node_media with fit_media: true (composed 9:16 frames - contain, never crop).`] : [],
19597
+ ...pending === 0 ? [``, `Attach any renders not already auto-attached (see above) via clipform_upload_media_asset then clipform_attach_node_media with fit_media: true (composed 9:16 frames - contain, never crop).`] : [],
18766
19598
  ...lost ? [``, LOST_JOB_RECOVERY] : []
18767
- ].join("\n")
19599
+ ].join("\n"),
19600
+ { jobs: ids.map((id) => projectJob(id, getJob(id))) }
18768
19601
  );
18769
19602
  }
18770
19603
  );
@@ -19053,7 +19886,8 @@ function createServer(options) {
19053
19886
  registerAddNodeTool(server);
19054
19887
  registerUpdateNodeTool(server);
19055
19888
  registerDeleteNodeTool(server);
19056
- registerUploadNodeMediaTool(server);
19889
+ registerUploadMediaAssetTool(server);
19890
+ registerAttachNodeMediaTool(server);
19057
19891
  registerGetNodeMediaTool(server);
19058
19892
  registerDeleteNodeMediaTool(server);
19059
19893
  registerSetNodeLogicTool(server);
@@ -19066,6 +19900,8 @@ function createServer(options) {
19066
19900
  registerRenderCompositionTool(server);
19067
19901
  registerSearchMusicTool(server);
19068
19902
  registerListCompositionsTool(server);
19903
+ registerListVideoTemplatesTool(server);
19904
+ registerRenderVideoTemplateTool(server);
19069
19905
  registerListAssetsTool(server);
19070
19906
  registerCheckRenderTool(server);
19071
19907
  registerFetchBoundaryTool(server);
@@ -19078,6 +19914,7 @@ function createServer(options) {
19078
19914
 
19079
19915
  export {
19080
19916
  JSONRPCMessageSchema,
19917
+ RENDER_TIMING,
19081
19918
  createServer
19082
19919
  };
19083
- //# sourceMappingURL=chunk-JT7YWEQP.js.map
19920
+ //# sourceMappingURL=chunk-RX7V2UCM.js.map