@hubfluencer/mcp 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,7 +68,7 @@ claude mcp add hubfluencer --env HUBFLUENCER_API_TOKEN=YOUR_TOKEN -- npx -y @hub
68
68
 
69
69
  ## Tools
70
70
 
71
- 95 tools, grouped by workflow. Every tool prices its action in its own `description` — costs
71
+ 97 tools, grouped by workflow. Every tool prices its action in its own `description` — costs
72
72
  below are the ground truth. **"$0" means it spends no video credits**; planning/AI-helper tools
73
73
  instead draw a **free daily quota of 20 AI assists** (top up with `unlock_ai_assists`: 1 credit
74
74
  → +10). Reads need the `video:read` scope; writes need `video:generate`. Nothing here needs
@@ -82,12 +82,14 @@ instead draw a **free daily quota of 20 AI assists** (top up with `unlock_ai_ass
82
82
  | `create_short` | Create a short draft (12s vertical: two 6s AI segments + title overlay + music; 14s when it ends on a poster or brand-lockup end card). Set `headline` (≈4 words, ≤40 chars), `subheadline`, `music_vibe`, and an explicit `visual_language` (`kinetic_creator` is a strong default) — and **always `cta_text`** (a short without a CTA doesn't convert; blank `cta_text` → the server renders a neutral localized ask, e.g. "Learn more" — a truly CTA-less short is not currently supported). End-card fields: `closing_claim` (≤80), `brand_name` (≤40), `end_card` (`"auto"`\|`"none"`). Other opt-in conversion graphics: `offer_text`/`badge_text`/`star_rating` — only what you can substantiate. | **0** |
83
83
  | `update_short` | Patch an existing short draft — brief (`product_prompt`), copy, styling, end-card fields (`closing_claim`/`brand_name`/`poster_includes_lockup`/`end_card`), logo overlay placement (`short_logo_position`/`short_logo_treatment`), or the opt-in conversion graphics; only the fields you pass change. Clear one with `""` (`[]` for `text_beats`, `0` for `star_rating`). Apply text/style/CTA/end-card/logo edits to a generated short free with `rerender_short`; footage/music edits need `generate_short`. | **0** |
84
84
  | `generate_short` | Render an existing short — re-rolls the footage + music (fresh AI generation). If headline/subheadline/beats are all blank the server auto-writes the copy first (`skip_auto_text:true` for a deliberately bare clip). Safe to re-call (in-flight = reported, no double charge; a failed short can re-generate). | **15** |
85
+ | `cancel_short_generation` | Stop the caller-observed paid short attempt, fence late workers from publishing, preserve the draft, and restore its 15 credits idempotently. Requires `generation_attempt` from `get_status`; stale commands cannot cancel a newer run. | **full refund** |
85
86
  | `rerender_short` | **FREE re-render** — applies the short's current text/style/CTA/end-card/logo state over the already-paid footage + music. Use after `update_short` for copy/style/CTA tweaks, or after a logo change (`set_short_logo`, or `short_logo_position`/`short_logo_treatment`); footage or music changes need `generate_short` instead. Needs one completed generation first (422 `short_not_ready` otherwise); changed footage/music inputs return 422 `short_paid_regeneration_required`. A failed free re-render leaves the delivered video intact — retry `rerender_short`, it stays free. | **0** |
86
87
  | `generate_short_text` | AI-draft editable headline / subheadline / caption beats for a short. | 1 assist |
87
88
  | `create_editor_ad` | Create an editor project, attach optional local product/logo/closing assets, and return the live autopilot quote. Omit `max_credits` to stop free; provide an approved cap to launch. Write every required person, product, object, and continuity decision directly in the prompt. | **0** until capped launch |
88
89
  | `start_autopilot` | Quote autopilot on an **existing** editor draft when called with only `slug`; launch only with an explicit `max_credits` that covers the estimate and an adequate balance. On a completed project, pass `restart:true` to quote and build a fresh creative run; omit it to resume pending edits. | **0** quote; credits on capped launch |
89
90
  | `retry_editor_batch` | Recover `batch_retry_required` by atomically re-queueing the failed scene and resuming its prepaid batch. If the parent Autopilot run is terminal, quote/relaunch it separately afterward. | **0 additional** |
90
91
  | `cancel_editor_batch` | Cancel an editor batch only while it is `preparing` or `paused_for_retry` (awaiting-previews/running batches are worker-owned and not cancellable). Preparing cancellation releases the uncharged preflight; paused cancellation is destructive — prepaid credits are not refunded. | **0 additional** |
92
+ | `cancel_autopilot` | Stop a **running** Autopilot run: flips `autopilot_status` to `cancelled` so the chain (scenario → scenes → narration → voice → music → render) advances no further. Charged scenes stay charged and all completed work is kept; a step already enqueued may still finish. Idempotent — a no-op when Autopilot isn't running. (Use `cancel_editor_batch` for a non-Autopilot prepaid batch, `cancel_segment_generation` for one scene.) | **0 additional** |
91
93
  | `create_slider` | Create an image-carousel draft: one prompt → N still slides + a ready-to-post caption + hashtags. `mode` `creative` (story) or `ad_driven` (facts). | **0** |
92
94
  | `generate_slider` | Render a carousel (copy → AI backgrounds → composite). Poll `get_slider` until `completed`. | **1 / slide** (3–10) |
93
95
  | `get_slider` | Read a carousel's status and, when completed, the per-slide image URLs + caption + hashtags. | **0** |
@@ -106,12 +108,13 @@ instead draw a **free daily quota of 20 AI assists** (top up with `unlock_ai_ass
106
108
  | `set_scene_count` | Grow/shrink to N scenes (1–20); deletes only trailing un-generated scenes. Each AI scene is a fixed 8s. | **0** |
107
109
  | `set_segment_prompt` | Write one scene's prompt (1–2000 chars), identified by segment id. | **0** |
108
110
  | `generate_segment` | Render one **pending/failed** scene (re-call to retry a failed one). | **5** |
111
+ | `cancel_segment_generation` | Stop the caller-observed individually generated scene, fence its provider job from publishing, and restore any charged credits idempotently. Requires that scene's `generation_claim_token` from `get_editor`; stale commands cannot cancel a newer attempt. Batch/Autopilot scenes use their workflow-level controls. | **full refund** |
109
112
  | `regenerate_segment` | Re-roll a **completed** scene as a new version (optional new `prompt`). Mid-timeline: the next scene's transition can degrade because it was seeded from the old version's last frame. | **4** |
110
113
  | `generate_all_segments` | Render every not-yet-completed scene in order (continuity preserved); capped ~280s → re-run to continue. | **5 / segment** |
111
114
  | `set_narration_script` / `generate_narration` | Write your own voiceover script (free) **or** AI-draft one coherent script after every scene has completed with a known positive duration. AI drafting requires at least one scene without retained original audio and consumes an assist only when a fresh job is queued (exact retries dedupe for free). | free / 1 assist |
112
115
  | `generate_voice` | Synthesize the voiceover. `voice_id` required (see `list_voices`). | **3** |
113
116
  | `generate_music` | Generate background music (each call is a fresh take). | **5** |
114
- | `render` | Final MP4. Auto-charges any still-ungenerated scenes first (4/scene in a batch of ≥3). | **0** |
117
+ | `render` | Final MP4. Auto-charges and generates any pending or failed scenes first (4/scene in a batch of ≥3). | **0** |
115
118
  | `list_renders` | List every render version for an editor project (`id`, `version`, `status`, `video_url`, `thumbnail_url`, `duration_seconds`, `error_message`, `retryable`, `combination_hash`, `is_stale`, `inserted_at`) — find a failed render or recover a finished URL. | **0** |
116
119
  | `retry_render` | Re-run a **failed** render from its exact saved snapshot (recovery without re-driving the pipeline). | **0** |
117
120
  | `enhance_prompt` / `suggest_next_scene` / `suggest_music_prompt` | AI helpers along the way. | 1 assist each |
@@ -159,9 +162,15 @@ completed | failed`). Batch state is `idle | preparing | awaiting_previews | run
159
162
  paused_for_retry | paused_insufficient_credits | completed | cancelled | failed`.
160
163
  Only `paused_for_retry` is resumed by `retry_editor_batch`; `cancel_editor_batch` works
161
164
  only while the batch is `preparing` or `paused_for_retry` — awaiting-previews/running
162
- batches are worker-owned and cannot be cancelled. Preparing cancellation releases the
163
- uncharged preflight (nothing was charged yet); paused cancellation is destructive and
164
- non-refundable. Both tools re-read the editor state and echo `expected_batch_run_id` +
165
+ batches are worker-owned and cannot be cancelled. While `paused_for_retry` you may first
166
+ REPAIR the one failed scene before retrying: `set_segment_prompt` edits its prompt (and
167
+ `use_previous_frame`), but only for the exact failed position the server names in
168
+ `batch_generation_failed_position` — the rest of the paused batch stays mutation-locked, and
169
+ explicit paused retries are capped (`free_retries_exhausted`). Preparing cancellation releases
170
+ the uncharged preflight (nothing was charged yet); paused cancellation is destructive and
171
+ non-refundable. To stop a **running** parent Autopilot run (not a paused batch), use
172
+ `cancel_autopilot` — it flips `autopilot_status` to `cancelled`, keeps completed work and
173
+ charged scenes, and is a no-op once the run has finished. Both tools re-read the editor state and echo `expected_batch_run_id` +
165
174
  `expected_batch_status` with the REST command, so a phase change between poll and command
166
175
  is a safe 409 `batch_command_stale` (re-poll and retry) instead of acting on newer work;
167
176
  the REST endpoints reject editor commands that omit these fields with 409
@@ -281,7 +290,7 @@ If it returns `terminal:false`, the render is still going — call `wait_for_com
281
290
  `create_short` → optional `generate_hook_variations` (test hook angles free) / `generate_short_text` → `generate_short` → `wait_for_completion {kind:"short"}` → `download_result`, or
282
291
  `create_editor_ad` (free configure + quote) → obtain spend approval → `start_autopilot {slug, max_credits}` → `wait_for_completion {kind:"editor"}` → `download_result`.
283
292
 
284
- If waiting returns `stage:"batch_retry_required"`, call `retry_editor_batch` to continue the prepaid failed scene, or `cancel_editor_batch` to abandon the batch. Resolve that child batch before calling `start_autopilot` again; a retry does not itself authorize a new parent Autopilot run.
293
+ If waiting returns `stage:"batch_retry_required"`, call `retry_editor_batch` to continue the prepaid failed scene (optionally repairing its prompt first with `set_segment_prompt` — see the batch-state notes above), or `cancel_editor_batch` to abandon the batch. Resolve that child batch before calling `start_autopilot` again; a retry does not itself authorize a new parent Autopilot run. To halt a **running** Autopilot run outright, call `cancel_autopilot`.
285
294
 
286
295
  **Iterate a short for free (never re-roll):** the first `generate_short` (15 credits) buys the footage +
287
296
  music; after that, every text/style/CTA/end-card/logo tweak re-renders over them for **0 credits** —
package/dist/index.js CHANGED
@@ -29508,6 +29508,15 @@ function normalizeStatus(kind, slug, data) {
29508
29508
  status.failure_source = d.failure_source;
29509
29509
  if (typeof d.failure_attempt === "number")
29510
29510
  status.failure_attempt = d.failure_attempt;
29511
+ if (typeof d.generation_attempt === "number")
29512
+ status.generation_attempt = d.generation_attempt;
29513
+ if (typeof d.segments_completed === "number")
29514
+ status.segments_completed = d.segments_completed;
29515
+ if (typeof d.segments_total === "number")
29516
+ status.segments_total = d.segments_total;
29517
+ if (Array.isArray(d.active_segment_positions) && d.active_segment_positions.every((position) => typeof position === "number")) {
29518
+ status.active_segment_positions = d.active_segment_positions;
29519
+ }
29511
29520
  if (typeof d.latest_render_free === "boolean") {
29512
29521
  status.latest_render_free = d.latest_render_free;
29513
29522
  }
@@ -30257,7 +30266,7 @@ function readStoredCredentials() {
30257
30266
  // package.json
30258
30267
  var package_default = {
30259
30268
  name: "@hubfluencer/mcp",
30260
- version: "0.12.0",
30269
+ version: "0.14.0",
30261
30270
  description: "Model Context Protocol server for Hubfluencer — let AI agents generate post-ready shorts and editor ads.",
30262
30271
  license: "MIT",
30263
30272
  author: "Monocursive <contact@monocursive.com>",
@@ -30575,6 +30584,10 @@ var getStatusOutput = exports_external.object({
30575
30584
  failure_details: exports_external.record(exports_external.string(), exports_external.unknown()).optional().describe("Short only: structured provider/QC details for recovery."),
30576
30585
  failure_source: exports_external.string().optional().describe("Short only: worker/recovery layer that finalized the failure."),
30577
30586
  failure_attempt: exports_external.number().optional().describe("Short only: paid generation attempt number that failed."),
30587
+ generation_attempt: exports_external.number().optional().describe("Short only: current paid attempt; echo when cancelling."),
30588
+ segments_completed: exports_external.number().optional(),
30589
+ segments_total: exports_external.number().optional(),
30590
+ active_segment_positions: exports_external.array(exports_external.number()).optional(),
30578
30591
  latest_render_free: exports_external.boolean().optional(),
30579
30592
  last_free_rerender_failed: exports_external.boolean().optional()
30580
30593
  }).passthrough();
@@ -30594,6 +30607,10 @@ var waitForCompletionOutput = exports_external.object({
30594
30607
  failure_details: exports_external.record(exports_external.string(), exports_external.unknown()).optional().describe("Short only: structured provider/QC details for recovery."),
30595
30608
  failure_source: exports_external.string().optional().describe("Short only: worker/recovery layer that finalized the failure."),
30596
30609
  failure_attempt: exports_external.number().optional().describe("Short only: paid generation attempt number that failed."),
30610
+ generation_attempt: exports_external.number().optional().describe("Short only: current paid attempt; echo when cancelling."),
30611
+ segments_completed: exports_external.number().optional(),
30612
+ segments_total: exports_external.number().optional(),
30613
+ active_segment_positions: exports_external.array(exports_external.number()).optional(),
30597
30614
  latest_render_free: exports_external.boolean().optional(),
30598
30615
  last_free_rerender_failed: exports_external.boolean().optional(),
30599
30616
  saved_to: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
@@ -31798,6 +31815,23 @@ var VISUAL_LANGUAGES = [
31798
31815
  "startup_explainer",
31799
31816
  "luxury_minimal"
31800
31817
  ];
31818
+ var SCENARIO_THEMES = [
31819
+ "none",
31820
+ "realistic",
31821
+ "cinematic",
31822
+ "anime",
31823
+ "sci_fi",
31824
+ "fantasy",
31825
+ "noir",
31826
+ "superhero",
31827
+ "horror",
31828
+ "mockumentary",
31829
+ "sports",
31830
+ "gaming",
31831
+ "retro_80s",
31832
+ "minimalist",
31833
+ "cyberpunk"
31834
+ ];
31801
31835
  var LANGUAGES = [
31802
31836
  "en",
31803
31837
  "fr",
@@ -31924,7 +31958,7 @@ registerTool("make_video", {
31924
31958
  music_instruments: exports_external.array(exports_external.string()).optional().describe('SHORTS only: instrument hints woven into the music (≤10, each ≤40 chars), e.g. ["piano", "strings"]'),
31925
31959
  creative_format: exports_external.enum(CREATIVE_FORMATS).optional().describe("SHORTS + EDITOR: narrative arc / segment structure. Omit for Auto/generic."),
31926
31960
  visual_language: exports_external.enum(VISUAL_LANGUAGES).optional().describe("SHORTS + EDITOR: visual style direction and render look. Default app choice is kinetic_creator."),
31927
- theme: exports_external.string().optional().describe("Visual theme: editor genre overlay, and legacy shorts fallback only when visual_language is unset. " + "For editor, when visual_language is set it drives the look and 'none' is ignored. " + "none (literal — no imposed style), realistic (default), cinematic, " + "anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, " + "minimalist, cyberpunk"),
31961
+ theme: exports_external.enum(SCENARIO_THEMES).optional().describe("Visual theme: editor genre overlay, and legacy shorts fallback only when visual_language is unset. " + "For editor, visual_language drives the look when present; make_video defaults theme to none " + "(literal — no imposed genre style). Options: none, realistic, cinematic, " + "anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, " + "minimalist, cyberpunk"),
31928
31962
  save_path: exports_external.string().optional().describe("Optional .mp4 path to download to (confined to HUBFLUENCER_OUTPUT_DIR or cwd)"),
31929
31963
  max_wait_seconds: exports_external.number().optional().describe("Block budget seconds (default 240, capped 10–280)"),
31930
31964
  dry_run: exports_external.boolean().optional().describe("Preview only: create a free draft, price it, and STOP before spending credits. " + "Returns {estimated_credits, available_credits, slug}. Resume with generate_short / start_autopilot."),
@@ -32003,8 +32037,8 @@ registerTool("make_video", {
32003
32037
  voice_id: args.voice_id,
32004
32038
  creative_format: args.creative_format,
32005
32039
  visual_language: args.visual_language,
32006
- theme: args.theme
32007
- }, makeVideoEditorCreateKey(args));
32040
+ theme: args.theme ?? "none"
32041
+ }, makeVideoEditorCreateKey({ ...args, theme: args.theme ?? "none" }));
32008
32042
  slug = created.data.slug;
32009
32043
  }
32010
32044
  const costPath = kind === "short" ? `/shorts/${slug}/cost` : `/editor/${slug}/autopilot/cost`;
@@ -32109,7 +32143,30 @@ registerTool("list_voices", {
32109
32143
  description: "Lists available narration voices (id + name). Pass a voice id as voice_id to create_editor_ad / " + "make_video to pick the narration voice for an editor ad. Shorts have no voiceover.",
32110
32144
  inputSchema: {},
32111
32145
  annotations: { title: "List voices", ...RO }
32112
- }, tool(async (_args, client) => ok(await client.get("/voices"))));
32146
+ }, tool(async (_args, client) => {
32147
+ const response = asRecord(await client.get("/voices"));
32148
+ const data = asRecord(response.data ?? response);
32149
+ const voices = Array.isArray(data.voices) ? data.voices.map(asRecord) : [];
32150
+ return ok({
32151
+ voices: voices.map((voice) => {
32152
+ const verified = Array.isArray(voice.verified_languages) ? voice.verified_languages.map(asRecord) : [];
32153
+ return {
32154
+ voice_id: voice.voice_id,
32155
+ name: voice.name,
32156
+ category: voice.category,
32157
+ description: voice.description,
32158
+ labels: voice.labels,
32159
+ preview_url: voice.preview_url,
32160
+ languages: [
32161
+ ...new Set(verified.map((language) => language.language).filter((language) => typeof language === "string"))
32162
+ ]
32163
+ };
32164
+ }),
32165
+ has_more: data.has_more,
32166
+ total_count: data.total_count,
32167
+ next_page_token: data.next_page_token
32168
+ });
32169
+ }));
32113
32170
  registerTool("list_projects", {
32114
32171
  title: "List recent projects",
32115
32172
  description: "Lists the caller's recent shorts and editor/video-factory projects, each normalized to " + "{slug, stage, terminal, ready, video_url}. By default the shorts listing is limited to " + "in-progress/failed; pass include_completed:true to also surface finished shorts — useful to " + "recover a lost slug. Capped to the most recent items per kind (use get_status for authoritative state).",
@@ -32155,7 +32212,7 @@ registerTool("create_short", {
32155
32212
  subheadline: exports_external.string().max(200).optional().describe("The SECONDARY title / supporting line under the headline. ≤200 chars."),
32156
32213
  creative_format: exports_external.enum(CREATIVE_FORMATS).optional().describe("Optional structure: problem_solution, mistake_fix, myth_vs_reality, before_after, proof_demo, product_reveal. Omit for Auto."),
32157
32214
  visual_language: exports_external.enum(VISUAL_LANGUAGES).optional().describe("Visual language for Veo direction and render styling. Good default: kinetic_creator."),
32158
- theme: exports_external.string().optional().describe("Deprecated for shorts: legacy visual theme used only when visual_language is unset."),
32215
+ theme: exports_external.enum(SCENARIO_THEMES).optional().describe("Deprecated for shorts: legacy visual theme used only when visual_language is unset."),
32159
32216
  music_vibe: exports_external.string().optional().describe("Background-music mood. Recognized: Upbeat (default), Cinematic, Minimal, Luxury, Playful, Jazz."),
32160
32217
  music_instruments: exports_external.array(exports_external.string().max(40)).max(10).optional().describe('Optional instrument hints (≤10, each ≤40 chars), e.g. ["piano", "strings"]'),
32161
32218
  text_position: exports_external.enum(["top", "center", "bottom"]).optional().describe("Where the title overlay sits (default bottom)"),
@@ -32253,7 +32310,7 @@ registerTool("update_short", {
32253
32310
  subheadline: exports_external.string().max(200).optional().describe("Secondary title / supporting line. ≤200 chars."),
32254
32311
  creative_format: exports_external.enum(CREATIVE_FORMATS).optional().describe("Structure: problem_solution, mistake_fix, myth_vs_reality, before_after, proof_demo, product_reveal."),
32255
32312
  visual_language: exports_external.enum(VISUAL_LANGUAGES).optional().describe("Visual language for Veo direction and render styling."),
32256
- theme: exports_external.string().optional().describe("Deprecated for shorts: legacy visual theme used only when visual_language is unset."),
32313
+ theme: exports_external.enum(SCENARIO_THEMES).optional().describe("Deprecated for shorts: legacy visual theme used only when visual_language is unset."),
32257
32314
  music_vibe: exports_external.string().optional().describe("Background-music mood: Upbeat, Cinematic, Minimal, Luxury, Playful, Jazz."),
32258
32315
  music_instruments: exports_external.array(exports_external.string().max(40)).max(10).optional().describe('Instrument hints (≤10, each ≤40 chars), e.g. ["piano", "strings"]'),
32259
32316
  text_position: exports_external.enum(["top", "center", "bottom"]).optional().describe("Where the title overlay sits."),
@@ -32348,7 +32405,13 @@ registerTool("update_short", {
32348
32405
  const res = await client.patch(`/shorts/${args.slug}`, body);
32349
32406
  const data = asRecord(res).data ?? res;
32350
32407
  return ok({
32351
- ...asRecord(data),
32408
+ slug: args.slug,
32409
+ kind: "short",
32410
+ stage: asRecord(data).stage,
32411
+ status: asRecord(data).status,
32412
+ updated: body,
32413
+ end_card_mode: asRecord(data).end_card_mode,
32414
+ resolved_cta_text: asRecord(data).resolved_cta_text,
32352
32415
  next: "Saved. Only text/style/CTA/end-card/logo changed on an already-generated short? " + `rerender_short({ slug: "${args.slug}" }) applies it for 0 credits. Footage/music edits ` + "(prompt, product image, format, visual_language, theme, music_*, language) need " + "generate_short (15 credits)."
32353
32416
  });
32354
32417
  }));
@@ -32516,6 +32579,31 @@ registerTool("generate_short", {
32516
32579
  throw e;
32517
32580
  }
32518
32581
  }));
32582
+ registerTool("cancel_short_generation", {
32583
+ title: "Cancel a running short generation",
32584
+ description: "Stops the caller-observed paid Short attempt, fences late provider callbacks, and refunds the 15-credit " + "generation exactly once. Pass generation_attempt from get_status so a delayed command cannot cancel a " + "newer run on the same draft. The draft is preserved for editing and regeneration. Safe to repeat with " + "the same attempt after a successful cancellation. Scope: video:generate.",
32585
+ inputSchema: {
32586
+ slug: exports_external.string().describe("Slug of the processing short"),
32587
+ generation_attempt: exports_external.number().int().min(0).describe("generation_attempt from the latest get_status response")
32588
+ },
32589
+ annotations: {
32590
+ title: "Cancel short generation",
32591
+ readOnlyHint: false,
32592
+ destructiveHint: true,
32593
+ openWorldHint: true,
32594
+ idempotentHint: true
32595
+ }
32596
+ }, tool(async (args, client) => {
32597
+ const res = await client.post(`/shorts/${args.slug}/cancel`, { generation_attempt: args.generation_attempt }, undefined);
32598
+ const data = asRecord(asRecord(res).data ?? res);
32599
+ return ok({
32600
+ ...data,
32601
+ slug: args.slug,
32602
+ charged: false,
32603
+ refund: "refunded" in data ? data.refunded : "settled (idempotent — no new credits minted on repeat)",
32604
+ next: "Edit the preserved draft, then call generate_short when ready."
32605
+ });
32606
+ }));
32519
32607
  registerTool("rerender_short", {
32520
32608
  title: "Re-render a short (FREE — 0 credits)",
32521
32609
  description: "FREE re-render: applies the short's current text/style/CTA/end-card/logo state over the already-paid " + "footage and music — 0 credits. Use after update_short for copy/style/CTA tweaks, or after a logo change " + "(set_short_logo, or short_logo_position/short_logo_treatment via update_short). Footage or music " + "changes need generate_short (15 credits) instead. Edits that add/remove the end card (poster, " + "end_card) can shift the video between 12s and 14s; when the pinned music bed is shorter than the " + "export it crossfades into a repeated continuation, then fades at the export end, without regeneration. " + "Requires a completed generation to re-render over " + "(422 short_not_ready otherwise — run generate_short once first); while any generation/render is in " + "flight it reports in-progress (409) rather than duplicating. Then poll with get_status or " + "wait_for_completion (kind=short). A failed free re-render leaves the delivered video intact — retry " + "rerender_short, it stays free (the status fields latest_render_free / last_free_rerender_failed " + "report this when the server sends them).",
@@ -32767,7 +32855,7 @@ registerTool("create_editor_ad", {
32767
32855
  language: exports_external.enum(LANGUAGES).optional().describe('Language (default "en"). Bare ISO-639-1 code only — no region/script subtag (use "es", not "es-MX"). ' + "Drives the WHOLE video: scenario, narration script, narration voice, and captions. " + "Ask the user / infer from their locale."),
32768
32856
  creative_format: exports_external.enum(CREATIVE_FORMATS).optional().describe("Narrative arc / segment structure. Omit for Auto/generic."),
32769
32857
  visual_language: exports_external.enum(VISUAL_LANGUAGES).optional().describe("Visual style direction and render look. Good default: kinetic_creator. When set it drives the look and a theme of 'none' is ignored."),
32770
- theme: exports_external.string().optional().describe('Visual theme / genre overlay (default "realistic"). One of: none (no imposed style — segments follow your prompts literally), realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, minimalist, cyberpunk.'),
32858
+ theme: exports_external.enum(SCENARIO_THEMES).optional().describe('Optional visual theme / genre overlay (default "none", so scene prompts drive the look). One of: none, realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, minimalist, cyberpunk.'),
32771
32859
  voice_id: exports_external.string().optional().describe("Preferred narration voice id (see list_voices); omit for the default voice"),
32772
32860
  export_aspect_ratio: exports_external.enum(["9:16", "16:9", "1:1"]).optional().describe('Aspect ratio (default "9:16")'),
32773
32861
  product_image_path: exports_external.string().optional().describe("Local product image (.jpg/.jpeg/.png, ≤8 MiB) used as the product-identity reference for every generated Editor scene and linked as the exact closing image unless one was already authored."),
@@ -32818,11 +32906,12 @@ registerTool("create_editor_ad", {
32818
32906
  project_intent: args.project_intent,
32819
32907
  creative_format: args.creative_format,
32820
32908
  visual_language: args.visual_language,
32821
- theme: args.theme,
32909
+ theme: args.theme ?? "none",
32822
32910
  voice_id: args.voice_id,
32823
32911
  export_aspect_ratio: args.export_aspect_ratio
32824
32912
  }, editorAdCreateKey({
32825
32913
  ...args,
32914
+ theme: args.theme ?? "none",
32826
32915
  product_image_identity: productImage ? preparedUploadIdentity(productImage) : undefined,
32827
32916
  closing_image_identity: closingImage ? preparedUploadIdentity(closingImage) : undefined,
32828
32917
  logo_identity: logo ? preparedUploadIdentity(logo) : undefined
@@ -32946,7 +33035,7 @@ registerTool("start_autopilot", {
32946
33035
  }));
32947
33036
  registerTool("retry_editor_batch", {
32948
33037
  title: "Retry a paused editor batch",
32949
- description: "Recovers an editor whose normalized status is batch_retry_required by atomically re-queueing the failed " + "scene and resuming its prepaid batch. This spends 0 additional credits: the batch generation was already " + "paid. It does not authorize or restart a failed parent Autopilot run; after retrying, wait for the batch " + "to settle. If the parent is then terminal, call start_autopilot without max_credits for a fresh quote and " + "relaunch only with an approved cap. A batch parked on " + "insufficient_credits needs a top-up/scope change instead. Scope: video:generate.",
33038
+ description: "Recovers an editor whose normalized status is batch_retry_required by atomically re-queueing the failed " + "scene and resuming its prepaid batch. This spends 0 additional credits: the batch generation was already " + "paid. Before retrying you may REPAIR the one failed scene: while paused_for_retry, set_segment_prompt can " + "edit that scene's prompt (and use_previous_frame) — but only for the exact failed position the server " + "names in batch_generation_failed_position (from get_editor); the rest of the paused batch stays " + "mutation-locked. Explicit paused retries are capped — once the failure code is free_retries_exhausted a " + "further retry is rejected (start a fresh run instead). It does not authorize or restart a failed parent " + "Autopilot run; after retrying, wait for the batch " + "to settle. If the parent is then terminal, call start_autopilot without max_credits for a fresh quote and " + "relaunch only with an approved cap. A batch parked on " + "insufficient_credits needs a top-up/scope change instead. Scope: video:generate.",
32950
33039
  inputSchema: { slug: exports_external.string().describe("Editor project slug") },
32951
33040
  annotations: {
32952
33041
  title: "Retry editor batch",
@@ -33031,7 +33120,7 @@ registerTool("create_editor_draft", {
33031
33120
  language: exports_external.enum(LANGUAGES).optional().describe('Language code, e.g. "en" (default). Bare ISO-639-1 code only — no region/script subtag like ' + '"en-US". Drives scenario, narration, voice, and captions.'),
33032
33121
  creative_format: exports_external.enum(CREATIVE_FORMATS).optional().describe("Narrative arc / segment structure. Omit for Auto/generic."),
33033
33122
  visual_language: exports_external.enum(VISUAL_LANGUAGES).optional().describe("Visual style direction and render look. Good default: kinetic_creator. When set it drives the look and a theme of 'none' is ignored."),
33034
- theme: exports_external.string().optional().describe('Visual theme / genre overlay (default "realistic"). One of: none (no imposed style — segments follow your prompts literally), realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, minimalist, cyberpunk.'),
33123
+ theme: exports_external.enum(SCENARIO_THEMES).optional().describe('Optional visual theme / genre overlay (default "none", so scene prompts drive the look). One of: none, realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, minimalist, cyberpunk.'),
33035
33124
  voice_id: exports_external.string().regex(/^[A-Za-z0-9_-]+$/).max(64).optional().describe("Preferred narration voice id (see list_voices); ≤64 chars, [A-Za-z0-9_-] only"),
33036
33125
  export_aspect_ratio: exports_external.enum(["9:16", "16:9", "1:1"]).optional().describe('Aspect ratio (default "9:16")'),
33037
33126
  project_intent: exports_external.enum(["social_ad", "creative_story"]).optional().describe("social_ad for promotional/ad content (product focus + CTA), creative_story for brand " + "narratives (default).")
@@ -33049,14 +33138,14 @@ registerTool("create_editor_draft", {
33049
33138
  product_subject: args.product_subject,
33050
33139
  creative_format: args.creative_format,
33051
33140
  visual_language: args.visual_language,
33052
- theme: args.theme,
33141
+ theme: args.theme ?? "none",
33053
33142
  voice_id: args.voice_id,
33054
33143
  export_aspect_ratio: args.export_aspect_ratio,
33055
33144
  project_intent: args.project_intent
33056
33145
  };
33057
33146
  if (prompt)
33058
33147
  body.product_prompt = prompt;
33059
- const created = await client.post("/editor", body, idemKey("create-editor-draft", prompt ?? "", args.product_subject ?? "", args.project_intent ?? "", args.language ?? "en", args.creative_format ?? "", args.visual_language ?? "", args.theme ?? "", args.voice_id ?? "", args.export_aspect_ratio ?? ""));
33148
+ const created = await client.post("/editor", body, idemKey("create-editor-draft", prompt ?? "", args.product_subject ?? "", args.project_intent ?? "", args.language ?? "en", args.creative_format ?? "", args.visual_language ?? "", args.theme ?? "none", args.voice_id ?? "", args.export_aspect_ratio ?? ""));
33060
33149
  return ok({
33061
33150
  slug: created.data.slug,
33062
33151
  kind: "editor",
@@ -33080,6 +33169,7 @@ function conciseEditorState(value) {
33080
33169
  "scenario_prompt",
33081
33170
  "batch_generation_status",
33082
33171
  "batch_generation_run_id",
33172
+ "batch_generation_failed_position",
33083
33173
  "auto_render_after_batch",
33084
33174
  "narration_status",
33085
33175
  "narration_script",
@@ -33104,6 +33194,7 @@ function conciseEditorState(value) {
33104
33194
  prompt: segment.prompt,
33105
33195
  status: segment.status,
33106
33196
  source_type: segment.source_type,
33197
+ generation_claim_token: segment.generation_claim_token,
33107
33198
  use_previous_frame: segment.use_previous_frame,
33108
33199
  video_stale: segment.video_stale,
33109
33200
  error_code: segment.error_code,
@@ -33145,7 +33236,7 @@ registerTool("generate_scenario", {
33145
33236
  segments_count: exports_external.number().int().min(3).max(10).optional().describe("How many scenes (3–10, server default 5)"),
33146
33237
  creative_format: exports_external.union([exports_external.enum(CREATIVE_FORMATS), exports_external.literal("")]).optional().describe("Persist the narrative arc / segment structure before generating. Omit to leave it unchanged; pass an empty string to clear."),
33147
33238
  visual_language: exports_external.union([exports_external.enum(VISUAL_LANGUAGES), exports_external.literal("")]).optional().describe("Persist the visual style / render look before generating. Omit to leave it unchanged; pass an empty string to clear. When set it drives the look and a theme of 'none' is ignored."),
33148
- theme: exports_external.string().optional().describe("Persist the genre overlay / theme before generating (e.g. realistic, cinematic, anime, none)."),
33239
+ theme: exports_external.enum(SCENARIO_THEMES).optional().describe("Persist the genre overlay / theme before generating (e.g. realistic, cinematic, anime, none)."),
33149
33240
  auto_unlock: exports_external.boolean().optional().describe("On 429, spend 1 credit to unlock +10 assists and retry once (default false)")
33150
33241
  },
33151
33242
  annotations: {
@@ -33300,6 +33391,50 @@ registerTool("generate_segment", {
33300
33391
  const res = await client.post(`/editor/${args.slug}/segments/${String(args.segment_id)}/generate`, undefined, undefined);
33301
33392
  return ok(asRecord(res).data ?? res);
33302
33393
  }));
33394
+ registerTool("cancel_segment_generation", {
33395
+ title: "Cancel one editor scene generation",
33396
+ description: "Stops one caller-observed, individually processing Editor scene; its generation_claim_token fence " + "prevents a delayed command from cancelling a newer attempt. The token is returned by get_editor. " + "Refunds that scene's charge idempotently (a free retry creates no refund). Use cancel_editor_batch or " + "cancel_autopilot for workflow-owned scenes. After cancellation, edit the prompt if needed and call " + "generate_segment again. Scope: video:generate.",
33397
+ inputSchema: {
33398
+ slug: exports_external.string().describe("Editor project slug"),
33399
+ segment_id: exports_external.union([exports_external.number(), exports_external.string()]).describe("Processing segment id (from get_editor)"),
33400
+ generation_claim_token: exports_external.string().nullable().describe("generation_claim_token from that processing segment")
33401
+ },
33402
+ annotations: {
33403
+ title: "Cancel segment generation",
33404
+ readOnlyHint: false,
33405
+ destructiveHint: true,
33406
+ openWorldHint: true,
33407
+ idempotentHint: true
33408
+ }
33409
+ }, tool(async (args, client) => {
33410
+ const res = await client.post(`/editor/${args.slug}/segments/${String(args.segment_id)}/cancel`, { expected_generation_claim_token: args.generation_claim_token }, undefined);
33411
+ return ok({
33412
+ ...asRecord(asRecord(res).data ?? res),
33413
+ slug: args.slug,
33414
+ charged: false,
33415
+ next: "set_segment_prompt or generate_segment"
33416
+ });
33417
+ }));
33418
+ registerTool("cancel_autopilot", {
33419
+ title: "Cancel a running Autopilot run",
33420
+ description: "Stops an in-flight Autopilot run on an editor project: sets autopilot_status to cancelled so the " + "orchestration chain (scenario → scenes → narration → voice → music → render) advances no further. Use " + "this when Autopilot is still running and you want to halt it. Any scenes already charged stay charged and " + "all completed work (generated scenes, voice, music, a finished render) is kept — a step already enqueued " + "may still finish, but nothing new is started. Idempotent: a no-op that returns the project unchanged when " + "Autopilot is not running (already completed, failed, cancelled, or never launched), so it is safe to " + "repeat. It does NOT itself refund or re-run anything. For a non-Autopilot prepaid batch " + "(batch_generation_status preparing / paused_for_retry) use cancel_editor_batch; to stop one " + "individually-generated scene use cancel_segment_generation. Scope: video:generate.",
33421
+ inputSchema: { slug: exports_external.string().describe("Editor project slug") },
33422
+ annotations: {
33423
+ title: "Cancel autopilot",
33424
+ readOnlyHint: false,
33425
+ destructiveHint: true,
33426
+ openWorldHint: true,
33427
+ idempotentHint: true
33428
+ }
33429
+ }, tool(async (args, client) => {
33430
+ const res = await client.post(`/editor/${args.slug}/autopilot/cancel`, undefined, undefined);
33431
+ return ok({
33432
+ ...asRecord(asRecord(res).data ?? res),
33433
+ slug: args.slug,
33434
+ charged: false,
33435
+ next: "get_status"
33436
+ });
33437
+ }));
33303
33438
  registerTool("regenerate_segment", {
33304
33439
  title: "Regenerate a completed segment (new version, 4 credits)",
33305
33440
  description: "Re-rolls a COMPLETED scene as a NEW VERSION of that segment — the previous version is kept (switch back " + "in the app). Costs 4 credits. Use generate_segment for pending/failed scenes; use THIS for a completed " + "scene you want different — optionally with a new prompt (omit it to re-roll the current prompt). The new " + "version starts rendering immediately; watch it with get_editor (status processing → completed|failed). " + "KNOWN LIMITATION: scenes chain visual continuity off the previous scene's last frame, so regenerating a " + "MID-TIMELINE scene can degrade the transition into the FOLLOWING scene (it was seeded from the old " + "version's last frame). A scene still processing returns editor_segment_busy (no double charge); 402 " + "credits_insufficient fails cleanly.",
@@ -33322,7 +33457,7 @@ registerTool("regenerate_segment", {
33322
33457
  }));
33323
33458
  registerTool("generate_all_segments", {
33324
33459
  title: "Generate all pending segments (sequential)",
33325
- description: "Generates every not-yet-completed scene (pending or previously failed) in position order, waiting for each to finish before starting" + "the next (preserves visual continuity). Each AI-generated scene is a fixed 8 seconds, so N scenes ≈ N×8s of footage. Costs 5 credits per segment generated. Stops and reports what " + "completed if a scene errors on submit (e.g. 402 credits_insufficient) or fails while rendering; if a " + "scene is still rendering past the budget it returns timed_out so you can re-run to continue (completed " + "scenes are skipped). The whole call is capped at ~280s: on a project with many scenes it processes as " + "many as fit, returns timed_out, and you re-run to continue (it picks up the remaining pending scenes). " + "Tip: to generate everything at lowest cost, skip this and just call render — it auto-generates any ungenerated scenes at the batch rate (4 credits/scene for ≥3); use this tool when you want to review or stop per scene. May take several minutes.",
33460
+ description: "Generates every not-yet-completed scene (pending or previously failed) in position order, waiting for each to finish before starting " + "the next (preserves visual continuity). Each AI-generated scene is a fixed 8 seconds, so N scenes ≈ N×8s of footage. Costs 5 credits per segment generated. Stops and reports what " + "completed if a scene errors on submit (e.g. 402 credits_insufficient) or fails while rendering; if a " + "scene is still rendering past the budget it returns timed_out so you can re-run to continue (completed " + "scenes are skipped). The whole call is capped at ~280s: on a project with many scenes it processes as " + "many as fit, returns timed_out, and you re-run to continue (it picks up the remaining pending scenes). " + "Tip: to generate everything at lowest cost, skip this and just call render — it auto-generates any ungenerated scenes at the batch rate (4 credits/scene for ≥3); use this tool when you want to review or stop per scene. May take several minutes.",
33326
33461
  inputSchema: { slug: exports_external.string().describe("Editor project slug") },
33327
33462
  annotations: {
33328
33463
  title: "Generate all segments",
@@ -33500,7 +33635,7 @@ registerTool("generate_music", {
33500
33635
  mood: exports_external.string().optional().describe("Optional mood hint"),
33501
33636
  genre: exports_external.string().optional().describe("Optional genre hint"),
33502
33637
  tempo: exports_external.string().optional().describe("Optional tempo hint"),
33503
- instruments: exports_external.string().optional().describe("Optional instruments hint")
33638
+ instruments: exports_external.array(exports_external.string().min(1).max(80)).max(10).optional().describe('Optional instrument hints, e.g. ["piano", "strings"]')
33504
33639
  },
33505
33640
  annotations: { title: "Generate music", ...WRITE, idempotentHint: false }
33506
33641
  }, tool(async (args, client) => {
@@ -33523,7 +33658,7 @@ registerTool("generate_music", {
33523
33658
  }));
33524
33659
  registerTool("render", {
33525
33660
  title: "Render the final editor video (0 credits)",
33526
- description: "Renders the final MP4 from the generated segments + voice + music. Costs 0 credits (any still-ungenerated " + "segments are auto-charged at generation cost first). Render NEVER generates or charges for voice/music; a new " + "music charge comes only from generate_music or an already-running autopilot child job. Re-rendering reflects the current segments/voice/music (it does not replay a prior render). Returns the latest_render; " + 'pair with wait_for_completion({ slug, kind: "editor" }) then download_result. ' + "STALENESS: editing the scenario or narration AFTER generating voice/music marks those audio tracks stale, and render then 422s with editor_narration_stale / editor_voice_stale / editor_music_stale — regenerate the named track (generate_narration → generate_voice, and/or generate_music) before rendering.",
33661
+ description: "Renders the final MP4 from the generated segments + voice + music. Costs 0 credits. Pending or previously " + "failed AI scenes are generated first and charged as a new batch. Render NEVER generates or charges for voice/music; a new " + "music charge comes only from generate_music or an already-running autopilot child job. Re-rendering reflects the current segments/voice/music (it does not replay a prior render). Returns the latest_render; " + 'pair with wait_for_completion({ slug, kind: "editor" }) then download_result. ' + "STALENESS: editing the scenario or narration AFTER generating voice/music marks those audio tracks stale, and render then 422s with editor_narration_stale / editor_voice_stale / editor_music_stale — regenerate the named track (generate_narration → generate_voice, and/or generate_music) before rendering.",
33527
33662
  inputSchema: { slug: exports_external.string().describe("Editor project slug") },
33528
33663
  annotations: { title: "Render", ...WRITE, idempotentHint: false }
33529
33664
  }, tool(async (args, client) => {
@@ -33763,7 +33898,7 @@ registerTool("use_asset_for_segment", {
33763
33898
  }));
33764
33899
  registerTool("set_product", {
33765
33900
  title: "Attach a product image (from a local file)",
33766
- description: "Uploads a local product image and attaches it to the project as the product. Accepted: " + IMAGE_EXTS.map((e) => `.${e}`).join(", ") + " (≤8 MiB), local path confined to HUBFLUENCER_INPUT_DIR (or cwd). 0 credits. Its pixels guide product identity " + "in every generated Editor scene and its confirmed description grounds scenario and narration. The exact photo " + "is linked as the closing image unless a separate closing image was authored; generated fine label text can still vary.",
33901
+ description: "Uploads a local product image and attaches it to the project as the product. Accepted: " + IMAGE_EXTS.map((e) => `.${e}`).join(", ") + " (≤8 MiB), local path confined to HUBFLUENCER_INPUT_DIR (or cwd). 0 credits. Its pixels guide product identity " + "in every generated Editor scene and its confirmed description grounds scenario and narration. The exact photo " + "is linked as the closing image unless a separate closing image was authored; generated fine label text can still vary. " + "WARNING: replacing the product photo on a project that already has generated AI scenes invalidates their product " + "identity — those scenes were built from the old photo and must be regenerated to match the new one.",
33767
33902
  inputSchema: {
33768
33903
  slug: exports_external.string().describe("Editor project slug"),
33769
33904
  file_path: exports_external.string().describe("Local product image path (.jpg/.jpeg/.png)"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubfluencer/mcp",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Model Context Protocol server for Hubfluencer — let AI agents generate post-ready shorts and editor ads.",
5
5
  "license": "MIT",
6
6
  "author": "Monocursive <contact@monocursive.com>",
package/src/core.ts CHANGED
@@ -275,6 +275,14 @@ export interface NormalizedStatus {
275
275
  failure_source?: string;
276
276
  /** Short only: paid generation attempt number that failed. */
277
277
  failure_attempt?: number;
278
+ /** Short only: current paid attempt; echo when cancelling. */
279
+ generation_attempt?: number;
280
+ /** Short only: completed/current Veo clip count for useful long-poll progress. */
281
+ segments_completed?: number;
282
+ /** Short only: total current Veo clips in the paid attempt. */
283
+ segments_total?: number;
284
+ /** Short only: clip positions that are pending or processing. */
285
+ active_segment_positions?: number[];
278
286
  /** Editor only: the newest delivered render no longer matches the live project. */
279
287
  stale?: boolean;
280
288
  /** Editor only: current scene ids that are still generating. */
@@ -361,6 +369,20 @@ export function normalizeStatus(
361
369
  status.failure_source = d.failure_source;
362
370
  if (typeof d.failure_attempt === "number")
363
371
  status.failure_attempt = d.failure_attempt;
372
+ if (typeof d.generation_attempt === "number")
373
+ status.generation_attempt = d.generation_attempt;
374
+ if (typeof d.segments_completed === "number")
375
+ status.segments_completed = d.segments_completed;
376
+ if (typeof d.segments_total === "number")
377
+ status.segments_total = d.segments_total;
378
+ if (
379
+ Array.isArray(d.active_segment_positions) &&
380
+ d.active_segment_positions.every(
381
+ (position) => typeof position === "number",
382
+ )
383
+ ) {
384
+ status.active_segment_positions = d.active_segment_positions as number[];
385
+ }
364
386
  // Additive free-re-render bookkeeping (2026-07 API): surfaced only when
365
387
  // the server actually sends the booleans, so older payloads normalize
366
388
  // byte-identically. A failed free re-render does NOT flip stage to
package/src/index.ts CHANGED
@@ -565,6 +565,25 @@ const VISUAL_LANGUAGES = [
565
565
  "startup_explainer",
566
566
  "luxury_minimal",
567
567
  ] as const;
568
+ // Closed API enum from VideoFactories.ScenarioThemes. Keeping this explicit in
569
+ // MCP prevents an agent typo from spending a round trip on an avoidable 422.
570
+ const SCENARIO_THEMES = [
571
+ "none",
572
+ "realistic",
573
+ "cinematic",
574
+ "anime",
575
+ "sci_fi",
576
+ "fantasy",
577
+ "noir",
578
+ "superhero",
579
+ "horror",
580
+ "mockumentary",
581
+ "sports",
582
+ "gaming",
583
+ "retro_80s",
584
+ "minimalist",
585
+ "cyberpunk",
586
+ ] as const;
568
587
  // The languages the API's VideoFactory.language accepts (video_factory.ex
569
588
  // @supported_languages, validate_inclusion at create + autopilot). It's a CLOSED
570
589
  // set of bare ISO-639-1 codes — the server rejects region/script subtags ("en",
@@ -914,12 +933,12 @@ registerTool(
914
933
  "SHORTS + EDITOR: visual style direction and render look. Default app choice is kinetic_creator.",
915
934
  ),
916
935
  theme: z
917
- .string()
936
+ .enum(SCENARIO_THEMES)
918
937
  .optional()
919
938
  .describe(
920
939
  "Visual theme: editor genre overlay, and legacy shorts fallback only when visual_language is unset. " +
921
- "For editor, when visual_language is set it drives the look and 'none' is ignored. " +
922
- "none (literal — no imposed style), realistic (default), cinematic, " +
940
+ "For editor, visual_language drives the look when present; make_video defaults theme to none " +
941
+ "(literal — no imposed genre style). Options: none, realistic, cinematic, " +
923
942
  "anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, " +
924
943
  "minimalist, cyberpunk",
925
944
  ),
@@ -1115,12 +1134,14 @@ registerTool(
1115
1134
  voice_id: args.voice_id,
1116
1135
  creative_format: args.creative_format,
1117
1136
  visual_language: args.visual_language,
1118
- theme: args.theme,
1137
+ // Prompt-first default: do not silently inject the API's legacy
1138
+ // realistic/neutral art direction when the caller authored a look.
1139
+ theme: args.theme ?? "none",
1119
1140
  },
1120
1141
  // Fold every create-affecting param into the key so two calls that
1121
1142
  // differ only by style/theme/voice/aspect get distinct drafts (shared
1122
1143
  // with the golden-pin test via core.ts).
1123
- makeVideoEditorCreateKey(args),
1144
+ makeVideoEditorCreateKey({ ...args, theme: args.theme ?? "none" }),
1124
1145
  );
1125
1146
  slug = created.data.slug;
1126
1147
  }
@@ -1396,7 +1417,40 @@ registerTool(
1396
1417
  inputSchema: {},
1397
1418
  annotations: { title: "List voices", ...RO },
1398
1419
  },
1399
- tool(async (_args, client) => ok(await client.get("/voices"))),
1420
+ tool(async (_args, client) => {
1421
+ const response = asRecord(await client.get("/voices"));
1422
+ const data = asRecord(response.data ?? response);
1423
+ const voices = Array.isArray(data.voices) ? data.voices.map(asRecord) : [];
1424
+
1425
+ return ok({
1426
+ voices: voices.map((voice) => {
1427
+ const verified = Array.isArray(voice.verified_languages)
1428
+ ? voice.verified_languages.map(asRecord)
1429
+ : [];
1430
+ return {
1431
+ voice_id: voice.voice_id,
1432
+ name: voice.name,
1433
+ category: voice.category,
1434
+ description: voice.description,
1435
+ labels: voice.labels,
1436
+ preview_url: voice.preview_url,
1437
+ languages: [
1438
+ ...new Set(
1439
+ verified
1440
+ .map((language) => language.language)
1441
+ .filter(
1442
+ (language): language is string =>
1443
+ typeof language === "string",
1444
+ ),
1445
+ ),
1446
+ ],
1447
+ };
1448
+ }),
1449
+ has_more: data.has_more,
1450
+ total_count: data.total_count,
1451
+ next_page_token: data.next_page_token,
1452
+ });
1453
+ }),
1400
1454
  );
1401
1455
 
1402
1456
  // ── Projects ──────────────────────────────────────────────────────────────
@@ -1530,7 +1584,7 @@ registerTool(
1530
1584
  "Visual language for Veo direction and render styling. Good default: kinetic_creator.",
1531
1585
  ),
1532
1586
  theme: z
1533
- .string()
1587
+ .enum(SCENARIO_THEMES)
1534
1588
  .optional()
1535
1589
  .describe(
1536
1590
  "Deprecated for shorts: legacy visual theme used only when visual_language is unset.",
@@ -1837,7 +1891,7 @@ registerTool(
1837
1891
  .optional()
1838
1892
  .describe("Visual language for Veo direction and render styling."),
1839
1893
  theme: z
1840
- .string()
1894
+ .enum(SCENARIO_THEMES)
1841
1895
  .optional()
1842
1896
  .describe(
1843
1897
  "Deprecated for shorts: legacy visual theme used only when visual_language is unset.",
@@ -2092,7 +2146,13 @@ registerTool(
2092
2146
  );
2093
2147
  const data = asRecord(res).data ?? res;
2094
2148
  return ok({
2095
- ...asRecord(data),
2149
+ slug: args.slug,
2150
+ kind: "short",
2151
+ stage: asRecord(data).stage,
2152
+ status: asRecord(data).status,
2153
+ updated: body,
2154
+ end_card_mode: asRecord(data).end_card_mode,
2155
+ resolved_cta_text: asRecord(data).resolved_cta_text,
2096
2156
  next:
2097
2157
  "Saved. Only text/style/CTA/end-card/logo changed on an already-generated short? " +
2098
2158
  `rerender_short({ slug: "${args.slug}" }) applies it for 0 credits. Footage/music edits ` +
@@ -2529,6 +2589,55 @@ registerTool(
2529
2589
  }),
2530
2590
  );
2531
2591
 
2592
+ registerTool(
2593
+ "cancel_short_generation",
2594
+ {
2595
+ title: "Cancel a running short generation",
2596
+ description:
2597
+ "Stops the caller-observed paid Short attempt, fences late provider callbacks, and refunds the 15-credit " +
2598
+ "generation exactly once. Pass generation_attempt from get_status so a delayed command cannot cancel a " +
2599
+ "newer run on the same draft. The draft is preserved for editing and regeneration. Safe to repeat with " +
2600
+ "the same attempt after a successful cancellation. Scope: video:generate.",
2601
+ inputSchema: {
2602
+ slug: z.string().describe("Slug of the processing short"),
2603
+ generation_attempt: z
2604
+ .number()
2605
+ .int()
2606
+ .min(0)
2607
+ .describe("generation_attempt from the latest get_status response"),
2608
+ },
2609
+ annotations: {
2610
+ title: "Cancel short generation",
2611
+ readOnlyHint: false,
2612
+ destructiveHint: true,
2613
+ openWorldHint: true,
2614
+ idempotentHint: true,
2615
+ },
2616
+ },
2617
+ tool(async (args: { slug: string; generation_attempt: number }, client) => {
2618
+ const res = await client.post<{ data: unknown }>(
2619
+ `/shorts/${args.slug}/cancel`,
2620
+ { generation_attempt: args.generation_attempt },
2621
+ undefined,
2622
+ );
2623
+ const data = asRecord(asRecord(res).data ?? res);
2624
+ return ok({
2625
+ ...data,
2626
+ slug: args.slug,
2627
+ charged: false,
2628
+ // The cancel endpoint returns the short's STATE, not a refund receipt,
2629
+ // so don't assert refunded:true on every call — a repeat cancel is a
2630
+ // no-op that mints no new credits. Echo the server's own flag if it ever
2631
+ // sends one; otherwise describe the honest idempotent settlement.
2632
+ refund:
2633
+ "refunded" in data
2634
+ ? data.refunded
2635
+ : "settled (idempotent — no new credits minted on repeat)",
2636
+ next: "Edit the preserved draft, then call generate_short when ready.",
2637
+ });
2638
+ }),
2639
+ );
2640
+
2532
2641
  registerTool(
2533
2642
  "rerender_short",
2534
2643
  {
@@ -3066,10 +3175,10 @@ registerTool(
3066
3175
  "Visual style direction and render look. Good default: kinetic_creator. When set it drives the look and a theme of 'none' is ignored.",
3067
3176
  ),
3068
3177
  theme: z
3069
- .string()
3178
+ .enum(SCENARIO_THEMES)
3070
3179
  .optional()
3071
3180
  .describe(
3072
- 'Visual theme / genre overlay (default "realistic"). One of: none (no imposed style — segments follow your prompts literally), realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, minimalist, cyberpunk.',
3181
+ 'Optional visual theme / genre overlay (default "none", so scene prompts drive the look). One of: none, realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, minimalist, cyberpunk.',
3073
3182
  ),
3074
3183
  voice_id: z
3075
3184
  .string()
@@ -3226,7 +3335,7 @@ registerTool(
3226
3335
  project_intent: args.project_intent,
3227
3336
  creative_format: args.creative_format,
3228
3337
  visual_language: args.visual_language,
3229
- theme: args.theme,
3338
+ theme: args.theme ?? "none",
3230
3339
  voice_id: args.voice_id,
3231
3340
  export_aspect_ratio: args.export_aspect_ratio,
3232
3341
  },
@@ -3235,6 +3344,7 @@ registerTool(
3235
3344
  // Composition lives in core.ts, shared with the golden-pin test.
3236
3345
  editorAdCreateKey({
3237
3346
  ...args,
3347
+ theme: args.theme ?? "none",
3238
3348
  product_image_identity: productImage
3239
3349
  ? preparedUploadIdentity(productImage)
3240
3350
  : undefined,
@@ -3507,7 +3617,12 @@ registerTool(
3507
3617
  description:
3508
3618
  "Recovers an editor whose normalized status is batch_retry_required by atomically re-queueing the failed " +
3509
3619
  "scene and resuming its prepaid batch. This spends 0 additional credits: the batch generation was already " +
3510
- "paid. It does not authorize or restart a failed parent Autopilot run; after retrying, wait for the batch " +
3620
+ "paid. Before retrying you may REPAIR the one failed scene: while paused_for_retry, set_segment_prompt can " +
3621
+ "edit that scene's prompt (and use_previous_frame) — but only for the exact failed position the server " +
3622
+ "names in batch_generation_failed_position (from get_editor); the rest of the paused batch stays " +
3623
+ "mutation-locked. Explicit paused retries are capped — once the failure code is free_retries_exhausted a " +
3624
+ "further retry is rejected (start a fresh run instead). It does not authorize or restart a failed parent " +
3625
+ "Autopilot run; after retrying, wait for the batch " +
3511
3626
  "to settle. If the parent is then terminal, call start_autopilot without max_credits for a fresh quote and " +
3512
3627
  "relaunch only with an approved cap. A batch parked on " +
3513
3628
  "insufficient_credits needs a top-up/scope change instead. Scope: video:generate.",
@@ -3713,10 +3828,10 @@ registerTool(
3713
3828
  "Visual style direction and render look. Good default: kinetic_creator. When set it drives the look and a theme of 'none' is ignored.",
3714
3829
  ),
3715
3830
  theme: z
3716
- .string()
3831
+ .enum(SCENARIO_THEMES)
3717
3832
  .optional()
3718
3833
  .describe(
3719
- 'Visual theme / genre overlay (default "realistic"). One of: none (no imposed style — segments follow your prompts literally), realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, minimalist, cyberpunk.',
3834
+ 'Optional visual theme / genre overlay (default "none", so scene prompts drive the look). One of: none, realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, minimalist, cyberpunk.',
3720
3835
  ),
3721
3836
  voice_id: z
3722
3837
  .string()
@@ -3766,7 +3881,7 @@ registerTool(
3766
3881
  product_subject: args.product_subject,
3767
3882
  creative_format: args.creative_format,
3768
3883
  visual_language: args.visual_language,
3769
- theme: args.theme,
3884
+ theme: args.theme ?? "none",
3770
3885
  voice_id: args.voice_id,
3771
3886
  export_aspect_ratio: args.export_aspect_ratio,
3772
3887
  project_intent: args.project_intent,
@@ -3786,7 +3901,7 @@ registerTool(
3786
3901
  args.language ?? "en",
3787
3902
  args.creative_format ?? "",
3788
3903
  args.visual_language ?? "",
3789
- args.theme ?? "",
3904
+ args.theme ?? "none",
3790
3905
  args.voice_id ?? "",
3791
3906
  args.export_aspect_ratio ?? "",
3792
3907
  ),
@@ -3817,6 +3932,7 @@ function conciseEditorState(value: unknown): Record<string, unknown> {
3817
3932
  "scenario_prompt",
3818
3933
  "batch_generation_status",
3819
3934
  "batch_generation_run_id",
3935
+ "batch_generation_failed_position",
3820
3936
  "auto_render_after_batch",
3821
3937
  "narration_status",
3822
3938
  "narration_script",
@@ -3841,6 +3957,7 @@ function conciseEditorState(value: unknown): Record<string, unknown> {
3841
3957
  prompt: segment.prompt,
3842
3958
  status: segment.status,
3843
3959
  source_type: segment.source_type,
3960
+ generation_claim_token: segment.generation_claim_token,
3844
3961
  use_previous_frame: segment.use_previous_frame,
3845
3962
  video_stale: segment.video_stale,
3846
3963
  error_code: segment.error_code,
@@ -3938,7 +4055,7 @@ registerTool(
3938
4055
  "Persist the visual style / render look before generating. Omit to leave it unchanged; pass an empty string to clear. When set it drives the look and a theme of 'none' is ignored.",
3939
4056
  ),
3940
4057
  theme: z
3941
- .string()
4058
+ .enum(SCENARIO_THEMES)
3942
4059
  .optional()
3943
4060
  .describe(
3944
4061
  "Persist the genre overlay / theme before generating (e.g. realistic, cinematic, anime, none).",
@@ -4247,6 +4364,96 @@ registerTool(
4247
4364
  }),
4248
4365
  );
4249
4366
 
4367
+ registerTool(
4368
+ "cancel_segment_generation",
4369
+ {
4370
+ title: "Cancel one editor scene generation",
4371
+ description:
4372
+ "Stops one caller-observed, individually processing Editor scene; its generation_claim_token fence " +
4373
+ "prevents a delayed command from cancelling a newer attempt. The token is returned by get_editor. " +
4374
+ "Refunds that scene's charge idempotently (a free retry creates no refund). Use cancel_editor_batch or " +
4375
+ "cancel_autopilot for workflow-owned scenes. After cancellation, edit the prompt if needed and call " +
4376
+ "generate_segment again. Scope: video:generate.",
4377
+ inputSchema: {
4378
+ slug: z.string().describe("Editor project slug"),
4379
+ segment_id: z
4380
+ .union([z.number(), z.string()])
4381
+ .describe("Processing segment id (from get_editor)"),
4382
+ generation_claim_token: z
4383
+ .string()
4384
+ .nullable()
4385
+ .describe("generation_claim_token from that processing segment"),
4386
+ },
4387
+ annotations: {
4388
+ title: "Cancel segment generation",
4389
+ readOnlyHint: false,
4390
+ destructiveHint: true,
4391
+ openWorldHint: true,
4392
+ idempotentHint: true,
4393
+ },
4394
+ },
4395
+ tool(
4396
+ async (
4397
+ args: {
4398
+ slug: string;
4399
+ segment_id: number | string;
4400
+ generation_claim_token: string | null;
4401
+ },
4402
+ client,
4403
+ ) => {
4404
+ const res = await client.post<{ data: unknown }>(
4405
+ `/editor/${args.slug}/segments/${String(args.segment_id)}/cancel`,
4406
+ { expected_generation_claim_token: args.generation_claim_token },
4407
+ undefined,
4408
+ );
4409
+ return ok({
4410
+ ...asRecord(asRecord(res).data ?? res),
4411
+ slug: args.slug,
4412
+ charged: false,
4413
+ next: "set_segment_prompt or generate_segment",
4414
+ });
4415
+ },
4416
+ ),
4417
+ );
4418
+
4419
+ registerTool(
4420
+ "cancel_autopilot",
4421
+ {
4422
+ title: "Cancel a running Autopilot run",
4423
+ description:
4424
+ "Stops an in-flight Autopilot run on an editor project: sets autopilot_status to cancelled so the " +
4425
+ "orchestration chain (scenario → scenes → narration → voice → music → render) advances no further. Use " +
4426
+ "this when Autopilot is still running and you want to halt it. Any scenes already charged stay charged and " +
4427
+ "all completed work (generated scenes, voice, music, a finished render) is kept — a step already enqueued " +
4428
+ "may still finish, but nothing new is started. Idempotent: a no-op that returns the project unchanged when " +
4429
+ "Autopilot is not running (already completed, failed, cancelled, or never launched), so it is safe to " +
4430
+ "repeat. It does NOT itself refund or re-run anything. For a non-Autopilot prepaid batch " +
4431
+ "(batch_generation_status preparing / paused_for_retry) use cancel_editor_batch; to stop one " +
4432
+ "individually-generated scene use cancel_segment_generation. Scope: video:generate.",
4433
+ inputSchema: { slug: z.string().describe("Editor project slug") },
4434
+ annotations: {
4435
+ title: "Cancel autopilot",
4436
+ readOnlyHint: false,
4437
+ destructiveHint: true,
4438
+ openWorldHint: true,
4439
+ idempotentHint: true,
4440
+ },
4441
+ },
4442
+ tool(async (args: { slug: string }, client) => {
4443
+ const res = await client.post<{ data: unknown }>(
4444
+ `/editor/${args.slug}/autopilot/cancel`,
4445
+ undefined,
4446
+ undefined,
4447
+ );
4448
+ return ok({
4449
+ ...asRecord(asRecord(res).data ?? res),
4450
+ slug: args.slug,
4451
+ charged: false,
4452
+ next: "get_status",
4453
+ });
4454
+ }),
4455
+ );
4456
+
4250
4457
  registerTool(
4251
4458
  "regenerate_segment",
4252
4459
  {
@@ -4308,7 +4515,7 @@ registerTool(
4308
4515
  {
4309
4516
  title: "Generate all pending segments (sequential)",
4310
4517
  description:
4311
- "Generates every not-yet-completed scene (pending or previously failed) in position order, waiting for each to finish before starting" +
4518
+ "Generates every not-yet-completed scene (pending or previously failed) in position order, waiting for each to finish before starting " +
4312
4519
  "the next (preserves visual continuity). Each AI-generated scene is a fixed 8 seconds, so N scenes ≈ N×8s of footage. Costs 5 credits per segment generated. Stops and reports what " +
4313
4520
  "completed if a scene errors on submit (e.g. 402 credits_insufficient) or fails while rendering; if a " +
4314
4521
  "scene is still rendering past the budget it returns timed_out so you can re-run to continue (completed " +
@@ -4610,7 +4817,11 @@ registerTool(
4610
4817
  mood: z.string().optional().describe("Optional mood hint"),
4611
4818
  genre: z.string().optional().describe("Optional genre hint"),
4612
4819
  tempo: z.string().optional().describe("Optional tempo hint"),
4613
- instruments: z.string().optional().describe("Optional instruments hint"),
4820
+ instruments: z
4821
+ .array(z.string().min(1).max(80))
4822
+ .max(10)
4823
+ .optional()
4824
+ .describe('Optional instrument hints, e.g. ["piano", "strings"]'),
4614
4825
  },
4615
4826
  annotations: { title: "Generate music", ...WRITE, idempotentHint: false },
4616
4827
  },
@@ -4622,7 +4833,7 @@ registerTool(
4622
4833
  mood?: string;
4623
4834
  genre?: string;
4624
4835
  tempo?: string;
4625
- instruments?: string;
4836
+ instruments?: string[];
4626
4837
  },
4627
4838
  client,
4628
4839
  ) => {
@@ -4655,8 +4866,8 @@ registerTool(
4655
4866
  {
4656
4867
  title: "Render the final editor video (0 credits)",
4657
4868
  description:
4658
- "Renders the final MP4 from the generated segments + voice + music. Costs 0 credits (any still-ungenerated " +
4659
- "segments are auto-charged at generation cost first). Render NEVER generates or charges for voice/music; a new " +
4869
+ "Renders the final MP4 from the generated segments + voice + music. Costs 0 credits. Pending or previously " +
4870
+ "failed AI scenes are generated first and charged as a new batch. Render NEVER generates or charges for voice/music; a new " +
4660
4871
  "music charge comes only from generate_music or an already-running autopilot child job. Re-rendering reflects the current segments/voice/music (it does not replay a prior render). Returns the latest_render; " +
4661
4872
  'pair with wait_for_completion({ slug, kind: "editor" }) then download_result. ' +
4662
4873
  "STALENESS: editing the scenario or narration AFTER generating voice/music marks those audio tracks stale, and render then 422s with editor_narration_stale / editor_voice_stale / editor_music_stale — regenerate the named track (generate_narration → generate_voice, and/or generate_music) before rendering.",
@@ -5160,7 +5371,9 @@ registerTool(
5160
5371
  IMAGE_EXTS.map((e) => `.${e}`).join(", ") +
5161
5372
  " (≤8 MiB), local path confined to HUBFLUENCER_INPUT_DIR (or cwd). 0 credits. Its pixels guide product identity " +
5162
5373
  "in every generated Editor scene and its confirmed description grounds scenario and narration. The exact photo " +
5163
- "is linked as the closing image unless a separate closing image was authored; generated fine label text can still vary.",
5374
+ "is linked as the closing image unless a separate closing image was authored; generated fine label text can still vary. " +
5375
+ "WARNING: replacing the product photo on a project that already has generated AI scenes invalidates their product " +
5376
+ "identity — those scenes were built from the old photo and must be regenerated to match the new one.",
5164
5377
  inputSchema: {
5165
5378
  slug: z.string().describe("Editor project slug"),
5166
5379
  file_path: z
@@ -91,6 +91,13 @@ export const getStatusOutput = z
91
91
  .number()
92
92
  .optional()
93
93
  .describe("Short only: paid generation attempt number that failed."),
94
+ generation_attempt: z
95
+ .number()
96
+ .optional()
97
+ .describe("Short only: current paid attempt; echo when cancelling."),
98
+ segments_completed: z.number().optional(),
99
+ segments_total: z.number().optional(),
100
+ active_segment_positions: z.array(z.number()).optional(),
94
101
  latest_render_free: z.boolean().optional(),
95
102
  last_free_rerender_failed: z.boolean().optional(),
96
103
  })
@@ -134,6 +141,13 @@ export const waitForCompletionOutput = z
134
141
  .number()
135
142
  .optional()
136
143
  .describe("Short only: paid generation attempt number that failed."),
144
+ generation_attempt: z
145
+ .number()
146
+ .optional()
147
+ .describe("Short only: current paid attempt; echo when cancelling."),
148
+ segments_completed: z.number().optional(),
149
+ segments_total: z.number().optional(),
150
+ active_segment_positions: z.array(z.number()).optional(),
137
151
  latest_render_free: z.boolean().optional(),
138
152
  last_free_rerender_failed: z.boolean().optional(),
139
153
  saved_to: z.union([z.string(), z.null()]).optional(),