@hubfluencer/mcp 0.11.0 → 0.12.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
@@ -84,10 +84,10 @@ instead draw a **free daily quota of 20 AI assists** (top up with `unlock_ai_ass
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
85
  | `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
86
  | `generate_short_text` | AI-draft editable headline / subheadline / caption beats for a short. | 1 assist |
87
- | `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. For a mascot/hero product that must remain identical with no humans, pass `cast_mode:"product_only"` plus `product_image_path`. | **0** until capped launch |
87
+ | `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
88
  | `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
89
  | `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
- | `cancel_editor_batch` | Cancel an awaiting/running/paused editor batch. Destructive after scene dispatch: prepaid credits are not refunded (an awaiting-preview batch is refunded server-side). | **0 additional** |
90
+ | `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** |
91
91
  | `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
92
  | `generate_slider` | Render a carousel (copy → AI backgrounds → composite). Poll `get_slider` until `completed`. | **1 / slide** (3–10) |
93
93
  | `get_slider` | Read a carousel's status and, when completed, the per-slide image URLs + caption + hashtags. | **0** |
@@ -99,7 +99,7 @@ instead draw a **free daily quota of 20 AI assists** (top up with `unlock_ai_ass
99
99
 
100
100
  | Tool | What it does | Cost |
101
101
  |---|---|---|
102
- | `create_editor_draft` | Editor project, **no** autopilot. Accepts `creative_format` / `visual_language` / `theme` / `cast_mode` (`product_only` needs `set_product` before generation; `presenter_led` needs a configured presenter). | **0** |
102
+ | `create_editor_draft` | Editor project, **no** autopilot. Accepts `creative_format` / `visual_language` / `theme`; the prompt is the complete scene-content contract. | **0** |
103
103
  | `get_editor` | Full project state by default for compatibility; pass `response_format:"concise"` for a smaller review projection. | **0** |
104
104
  | `set_scenario` / `generate_scenario` | Write your own scenario (free) **or** AI-draft it. `generate_scenario` can also persist `creative_format` / `visual_language` / `theme`. | free / 1 assist |
105
105
  | `apply_scenario` | Compile the **ready** scenario into per-scene prompts on the timeline (`segments_count` 0 or 3..10; omitted uses the generated scenario count, then 5; AI scenes replaced, uploads preserved). Polls until applied (~2 min budget). | **0** |
@@ -108,11 +108,11 @@ instead draw a **free daily quota of 20 AI assists** (top up with `unlock_ai_ass
108
108
  | `generate_segment` | Render one **pending/failed** scene (re-call to retry a failed one). | **5** |
109
109
  | `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
110
  | `generate_all_segments` | Render every not-yet-completed scene in order (continuity preserved); capped ~280s → re-run to continue. | **5 / segment** |
111
- | `set_narration_script` / `generate_narration` | Write your own voiceover script (free) **or** AI-draft it. | free / 1 assist |
111
+ | `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
112
  | `generate_voice` | Synthesize the voiceover. `voice_id` required (see `list_voices`). | **3** |
113
113
  | `generate_music` | Generate background music (each call is a fresh take). | **5** |
114
114
  | `render` | Final MP4. Auto-charges any still-ungenerated scenes first (4/scene in a batch of ≥3). | **0** |
115
- | `list_renders` | List every render version for an editor project (id, version, status, video_url, error) — find a failed render or recover a finished URL. | **0** |
115
+ | `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
116
  | `retry_render` | Re-run a **failed** render from its exact saved snapshot (recovery without re-driving the pipeline). | **0** |
117
117
  | `enhance_prompt` / `suggest_next_scene` / `suggest_music_prompt` | AI helpers along the way. | 1 assist each |
118
118
 
@@ -130,8 +130,7 @@ images at **8 MiB (8,388,608 bytes)**; closing/short/catalog images at **20 MiB
130
130
  | `upload_video` | Upload a local clip into an editor project (presign → PUT, resumable multipart for large files → confirm → poll to ready) and, by default, add it to the timeline. An uploaded clip keeps its native duration. |
131
131
  | `add_segment_from_upload` | Append a **ready** upload to the timeline as a new scene. |
132
132
  | `use_asset_for_segment` | Set a specific scene's video from a ready upload **or** a completed scene in the same project (pass exactly one). Returns downstream continuity impact plus narration/voice/music staleness that must be resolved before render. |
133
- | `set_product` | Upload an editor product image (JPEG/PNG, ≤8 MiB) and attach it as the project product. |
134
- | `set_product_placement` | Set where the product appears on pending AI scenes: `throughout` or `end`. |
133
+ | `set_product` | Upload an editor product image (JPEG/PNG, ≤8 MiB). Its pixels guide product identity in every generated AI scene and its confirmed description grounds scenario/narration. The exact photo is also linked as the closing image unless a separate closing image was authored. Small generated label text can still vary. |
135
134
  | `set_closing_image` | Set the ~2s end-card image (JPEG/PNG, ≤20 MiB; upload a file, or reuse the product image with `from_product:true`). |
136
135
  | `set_logo` | Upload a brand logo overlay (JPEG/PNG, ≤5 MiB; editor only; optional treatment/position/duration). |
137
136
  | `set_short_product` | Attach a product image (JPEG/PNG, ≤20 MiB) to a **short**. |
@@ -154,6 +153,25 @@ does not match the live timeline; regenerate the reported stale scenes/audio and
154
153
  An absent/`null` server `is_stale` is **unknown** (for example a legacy render), not proof that
155
154
  the render is current. Failure stages take precedence over active stages and are terminal.
156
155
 
156
+ Editor state uses `narration_status` (`none | generating | ready | failed`),
157
+ `current_audio`, and `current_music` (selected version status `pending | processing |
158
+ completed | failed`). Batch state is `idle | preparing | awaiting_previews | running |
159
+ paused_for_retry | paused_insufficient_credits | completed | cancelled | failed`.
160
+ Only `paused_for_retry` is resumed by `retry_editor_batch`; `cancel_editor_batch` works
161
+ 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
+ `expected_batch_status` with the REST command, so a phase change between poll and command
166
+ is a safe 409 `batch_command_stale` (re-poll and retry) instead of acting on newer work;
167
+ the REST endpoints reject editor commands that omit these fields with 409
168
+ `batch_command_required` (client >= 0.12 sends them automatically). For
169
+ `paused_insufficient_credits`, top up or reduce scope and invoke generation/render again.
170
+
171
+ For raw REST Autopilot calls, reuse one `Idempotency-Key` only for transport retries of the
172
+ same logical launch. Mint a fresh key for a deliberate resume, restart, or changed cap; a
173
+ successful response is replayed for 24 hours under the old key.
174
+
157
175
  ### Campaign engine — onboard a product, plan, and draft (the delegation surface)
158
176
 
159
177
  Everything here is **$0** (draft creates + planning against the free assist quota). Generation
package/dist/index.js CHANGED
@@ -29555,6 +29555,7 @@ function normalizeStatus(kind, slug, data) {
29555
29555
  const failedSegments = segments.filter((segment) => segment.status === "failed");
29556
29556
  const batchStatus = d.batch_generation_status ?? null;
29557
29557
  const batchActive = batchStatus === "running" || batchStatus === "preparing" || batchStatus === "awaiting_previews";
29558
+ const autoRenderHandoffActive = batchStatus === "completed" && d.auto_render_after_batch === true;
29558
29559
  const batchRetryRequired = batchStatus === "paused_for_retry";
29559
29560
  const batchInsufficientCredits = batchStatus === "paused_insufficient_credits";
29560
29561
  const batchFailed = batchStatus === "failed";
@@ -29599,7 +29600,7 @@ function normalizeStatus(kind, slug, data) {
29599
29600
  currentMusic.error_message
29600
29601
  ].find((value) => typeof value === "string" && value.trim().length > 0);
29601
29602
  const audioActive = currentAudio.status === "pending" || currentAudio.status === "processing" || currentMusic.status === "pending" || currentMusic.status === "processing" || newerAudioVersions.some(versionActive) || newerMusicVersions.some(versionActive);
29602
- const childWorkActive = batchActive || renderActive || scenarioActive || narrationActive || audioActive || activeSegments.length > 0;
29603
+ const childWorkActive = batchActive || autoRenderHandoffActive || renderActive || scenarioActive || narrationActive || audioActive || activeSegments.length > 0;
29603
29604
  const autopilotStartedAt = typeof d.autopilot_started_at === "string" ? Date.parse(d.autopilot_started_at) : Number.NaN;
29604
29605
  const now = Date.now();
29605
29606
  const autopilotResumeGraceActive = autopilotActive && Number.isFinite(autopilotStartedAt) && autopilotStartedAt <= now && now - autopilotStartedAt <= 120000;
@@ -29642,6 +29643,8 @@ function normalizeStatus(kind, slug, data) {
29642
29643
  stage = "segment_processing";
29643
29644
  else if (batchActive)
29644
29645
  stage = "segment_generation";
29646
+ else if (autoRenderHandoffActive)
29647
+ stage = "rendering";
29645
29648
  else if (renderActive)
29646
29649
  stage = "rendering";
29647
29650
  else if (scenarioActive)
@@ -29661,7 +29664,7 @@ function normalizeStatus(kind, slug, data) {
29661
29664
  terminal,
29662
29665
  ready,
29663
29666
  video_url: videoUrl,
29664
- error: ready ? null : batchRetryRequired ? "Batch generation is paused after a failed scene. Call retry_editor_batch or cancel_editor_batch before continuing." : (parentFailureActionable ? d.autopilot_error_message : null) ?? actionableChildError ?? (batchInsufficientCredits ? "Batch generation paused: not enough credits to finish. Top up or reduce scope, then resume." : null),
29667
+ error: ready ? null : batchRetryRequired ? "Batch generation is paused after a failed scene. Call retry_editor_batch or cancel_editor_batch before continuing." : (parentFailureActionable ? d.autopilot_error_message : null) ?? actionableChildError ?? (batchInsufficientCredits ? "Batch generation paused: not enough credits to finish. Top up or reduce scope, then invoke the generation/render path again; retry_editor_batch only applies to paused_for_retry." : null),
29665
29668
  stale,
29666
29669
  active_segment_ids: activeSegments.map((segment) => segment.id).filter((id) => typeof id === "number" || typeof id === "string"),
29667
29670
  stale_segment_ids: staleSegments.map((segment) => segment.id).filter((id) => typeof id === "number" || typeof id === "string")
@@ -29694,10 +29697,10 @@ function makeVideoAutopilotKey(slug, a, startState) {
29694
29697
  return idemKey("autopilot", slug, a.prompt, a.product_subject ?? "", a.project_intent ?? "", a.language ?? "en", a.aspect ?? "", a.voice_id ?? "", a.creative_format ?? "", a.visual_language ?? "", a.theme ?? "", startState);
29695
29698
  }
29696
29699
  function editorAdCreateKey(a) {
29697
- return idemKey("create-editor", a.product_prompt ?? "", a.product_subject ?? "", a.project_intent ?? "", a.language ?? "en", a.creative_format ?? "", a.visual_language ?? "", a.theme ?? "", a.voice_id ?? "", a.export_aspect_ratio ?? "", a.product_image_path ?? "", a.product_image_identity ?? "", a.product_description ?? "", a.closing_image_path ?? "", a.closing_image_identity ?? "", a.logo_path ?? "", a.logo_identity ?? "", a.logo_treatment ?? "", a.logo_position ?? "", a.logo_duration_seconds === undefined ? "" : String(a.logo_duration_seconds), a.cast_mode ?? "");
29700
+ return idemKey("create-editor", a.product_prompt ?? "", a.product_subject ?? "", a.project_intent ?? "", a.language ?? "en", a.creative_format ?? "", a.visual_language ?? "", a.theme ?? "", a.voice_id ?? "", a.export_aspect_ratio ?? "", a.product_image_path ?? "", a.product_image_identity ?? "", a.product_description ?? "", a.closing_image_path ?? "", a.closing_image_identity ?? "", a.logo_path ?? "", a.logo_identity ?? "", a.logo_treatment ?? "", a.logo_position ?? "", a.logo_duration_seconds === undefined ? "" : String(a.logo_duration_seconds));
29698
29701
  }
29699
29702
  function editorAdAutopilotKey(slug, a) {
29700
- return idemKey("autopilot", slug, a.product_prompt ?? "", a.product_subject ?? "", a.project_intent ?? "", a.language ?? "en", a.creative_format ?? "", a.visual_language ?? "", a.theme ?? "", a.voice_id ?? "", a.export_aspect_ratio ?? "", a.product_image_path ?? "", a.product_description ?? "", a.closing_image_path ?? "", a.logo_path ?? "", a.logo_treatment ?? "", a.logo_position ?? "", a.logo_duration_seconds === undefined ? "" : String(a.logo_duration_seconds), a.cast_mode ?? "", a.max_credits === undefined ? "" : String(a.max_credits));
29703
+ return idemKey("autopilot", slug, a.product_prompt ?? "", a.product_subject ?? "", a.project_intent ?? "", a.language ?? "en", a.creative_format ?? "", a.visual_language ?? "", a.theme ?? "", a.voice_id ?? "", a.export_aspect_ratio ?? "", a.product_image_path ?? "", a.product_description ?? "", a.closing_image_path ?? "", a.logo_path ?? "", a.logo_treatment ?? "", a.logo_position ?? "", a.logo_duration_seconds === undefined ? "" : String(a.logo_duration_seconds), a.max_credits === undefined ? "" : String(a.max_credits));
29701
29704
  }
29702
29705
  function sliderCreateKey(a) {
29703
29706
  return idemKey("create-slider", a.prompt, a.mode ?? "", a.template ?? "", a.slide_count === undefined ? "" : String(a.slide_count), a.aspect_ratio ?? "", a.accent_color ?? "", a.text_position ?? "");
@@ -30254,7 +30257,7 @@ function readStoredCredentials() {
30254
30257
  // package.json
30255
30258
  var package_default = {
30256
30259
  name: "@hubfluencer/mcp",
30257
- version: "0.11.0",
30260
+ version: "0.12.0",
30258
30261
  description: "Model Context Protocol server for Hubfluencer — let AI agents generate post-ready shorts and editor ads.",
30259
30262
  license: "MIT",
30260
30263
  author: "Monocursive <contact@monocursive.com>",
@@ -31566,9 +31569,9 @@ async function editorAutopilotQuote(client, slug, overrides = {}) {
31566
31569
  const params = new URLSearchParams;
31567
31570
  if (overrides.language)
31568
31571
  params.set("language", overrides.language);
31569
- if (overrides.product_subject)
31572
+ if (overrides.product_subject !== undefined)
31570
31573
  params.set("product_subject", overrides.product_subject);
31571
- if (overrides.product_prompt)
31574
+ if (overrides.product_prompt !== undefined)
31572
31575
  params.set("product_prompt", overrides.product_prompt);
31573
31576
  if (overrides.restart)
31574
31577
  params.set("restart", "true");
@@ -32756,7 +32759,7 @@ registerTool("edit_slider_slide", {
32756
32759
  }));
32757
32760
  registerTool("create_editor_ad", {
32758
32761
  title: "Create, configure, and price a multi-scene editor ad",
32759
- description: "Creates an editor project, attaches any supplied local product/logo/closing assets BEFORE generation, " + "and prices autopilot (server-orchestrated " + "scenario → segments → narration → voice → music → render). Each AI-generated scene is a fixed 8 seconds. " + "Without max_credits it STOPS after the free draft/configuration and returns the live estimate. It starts " + "the paid pipeline only when max_credits is supplied and covers the estimate. Then poll with get_status / " + "wait_for_completion (kind=editor). Prefer make_video for a prompt-only one-shot. " + "Supplying assets here is safer than adding them after autopilot because the scenario and scenes are grounded " + "in the real product from the start. (Editor ads carry their copy in-scene/narration, not as a " + "title overlay; for an on-screen title/subtitle use a short with headline/subheadline instead.) " + "For a mascot or hero product that must stay visually identical with no human presenter, set " + "cast_mode:'product_only' together with product_image_path. Presenter-led casting still belongs on " + "create_editor_draft because it requires a separately configured presenter.",
32762
+ description: "Creates an editor project, attaches any supplied local product/logo/closing assets BEFORE generation, " + "and prices autopilot (server-orchestrated " + "scenario → segments → narration → voice → music → render). Each AI-generated scene is a fixed 8 seconds. " + "Without max_credits it STOPS after the free draft/configuration and returns the live estimate. It starts " + "the paid pipeline only when max_credits is supplied and covers the estimate. Then poll with get_status / " + "wait_for_completion (kind=editor). Prefer make_video for a prompt-only one-shot. " + "A product image is analyzed into semantic project context and sent as a product-identity reference to every " + "generated Editor scene. The exact photo is also linked as the closing image unless a separate closing image " + "was authored; generated fine label text can still vary. Scene prompts are the complete content contract: describe any people, " + "products, visual continuity, absences, and composition directly in the brief. (Editor ads carry their copy " + "in-scene/narration, not as a title overlay; for an on-screen title/subtitle use a short with " + "headline/subheadline instead.)",
32760
32763
  inputSchema: {
32761
32764
  product_prompt: exports_external.string().min(10).describe("Brief for the ad (min 10 chars)"),
32762
32765
  product_subject: exports_external.string().max(300).optional().describe('The concrete product or main subject the video must be about (e.g. "Bordeaux red wine, dark green ' + 'bottle with a gold label"). Distinct from the freeform brief: hard-grounds the AI scenario + narration ' + "so it cannot invent an unrelated product. Strongly recommended for ads."),
@@ -32767,14 +32770,13 @@ registerTool("create_editor_ad", {
32767
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.'),
32768
32771
  voice_id: exports_external.string().optional().describe("Preferred narration voice id (see list_voices); omit for the default voice"),
32769
32772
  export_aspect_ratio: exports_external.enum(["9:16", "16:9", "1:1"]).optional().describe('Aspect ratio (default "9:16")'),
32770
- product_image_path: exports_external.string().optional().describe("Local product image (.jpg/.jpeg/.png, ≤8 MiB) to attach before autopilot."),
32771
- product_description: exports_external.string().max(500).optional().describe("Exact product/character description used with product_image_path."),
32773
+ 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."),
32774
+ product_description: exports_external.string().max(500).optional().describe("Product description used to ground the scenario and narration when product_image_path is supplied."),
32772
32775
  closing_image_path: exports_external.string().optional().describe("Local closing-card image (.jpg/.jpeg/.png, ≤20 MiB)."),
32773
32776
  logo_path: exports_external.string().optional().describe("Local logo (.jpg/.jpeg/.png, ≤5 MiB) to overlay."),
32774
32777
  logo_treatment: exports_external.enum(["intro", "outro", "both", "none"]).optional().describe('When the logo bumper plays: "intro", "outro", "both" (default), or "none". Requires logo_path.'),
32775
32778
  logo_position: exports_external.enum(["top-left", "top-right", "bottom-left", "bottom-right"]).optional().describe('Logo corner: "top-left", "top-right" (default), "bottom-left", or "bottom-right". Requires logo_path.'),
32776
32779
  logo_duration_seconds: exports_external.number().min(0.5).max(3).optional().describe("Logo bumper duration in seconds, 0.5–3 (default 1.5). Requires logo_path."),
32777
- cast_mode: exports_external.literal("product_only").optional().describe("Lock every AI scene to the supplied product/mascot reference with no people. Requires product_image_path."),
32778
32780
  max_credits: exports_external.number().int().nonnegative().optional().describe("Explicit spend cap. Omit to create/configure/price only; no credits are charged.")
32779
32781
  },
32780
32782
  outputSchema: createEditorOutput,
@@ -32791,9 +32793,6 @@ registerTool("create_editor_ad", {
32791
32793
  const promptErr = validateProductPrompt(args.product_prompt);
32792
32794
  if (promptErr)
32793
32795
  return fail(promptErr);
32794
- if (args.cast_mode === "product_only" && !args.product_image_path) {
32795
- return fail("cast_mode product_only requires product_image_path so identity can be locked before generation.");
32796
- }
32797
32796
  if ((args.logo_treatment !== undefined || args.logo_position !== undefined || args.logo_duration_seconds !== undefined) && !args.logo_path) {
32798
32797
  return fail("logo_treatment/logo_position/logo_duration_seconds require logo_path (the logo image they style). Provide logo_path or drop these settings.");
32799
32798
  }
@@ -32821,8 +32820,7 @@ registerTool("create_editor_ad", {
32821
32820
  visual_language: args.visual_language,
32822
32821
  theme: args.theme,
32823
32822
  voice_id: args.voice_id,
32824
- export_aspect_ratio: args.export_aspect_ratio,
32825
- cast_mode: args.cast_mode
32823
+ export_aspect_ratio: args.export_aspect_ratio
32826
32824
  }, editorAdCreateKey({
32827
32825
  ...args,
32828
32826
  product_image_identity: productImage ? preparedUploadIdentity(productImage) : undefined,
@@ -32897,8 +32895,8 @@ registerTool("start_autopilot", {
32897
32895
  inputSchema: {
32898
32896
  slug: exports_external.string().describe("Editor project slug"),
32899
32897
  language: exports_external.enum(LANGUAGES).optional().describe('Optional language override applied before launch. Bare ISO-639-1 code only, e.g. "fr" — no ' + "region/script subtag. Drives scenario, narration, voice, and captions."),
32900
- product_subject: exports_external.string().max(300).optional().describe("Optional locked product / main subject applied before launch so the AI cannot invent an " + "unrelated product."),
32901
- product_prompt: exports_external.string().optional().describe("Optional freeform brief to set/replace before launch."),
32898
+ product_subject: exports_external.string().max(300).optional().describe("Optional locked product / main subject applied before launch so the AI cannot invent an " + "unrelated product. Omit to keep the stored subject; send blank to clear it."),
32899
+ product_prompt: exports_external.string().optional().describe("Optional freeform brief to set/replace before launch. Omit to keep the stored brief; send blank to clear it."),
32902
32900
  max_credits: exports_external.number().int().nonnegative().optional().describe("Explicit spend cap. Omit to quote only; no credits are charged."),
32903
32901
  restart: exports_external.boolean().optional().describe("Set true only on a completed project to quote/build a fresh creative run. The delivered timeline stays available until replacement apply.")
32904
32902
  },
@@ -32956,7 +32954,10 @@ registerTool("retry_editor_batch", {
32956
32954
  idempotentHint: false
32957
32955
  }
32958
32956
  }, tool(async (args, client) => {
32959
- const res = await client.post(`/video-factories/${args.slug}/retry-batch-segment`);
32957
+ const command = await observedEditorBatchCommand(client, args.slug, [
32958
+ "paused_for_retry"
32959
+ ]);
32960
+ const res = await client.post(`/video-factories/${args.slug}/retry-batch-segment`, command);
32960
32961
  return ok({
32961
32962
  ...asRecord(asRecord(res).data ?? res),
32962
32963
  slug: args.slug,
@@ -32967,7 +32968,7 @@ registerTool("retry_editor_batch", {
32967
32968
  }));
32968
32969
  registerTool("cancel_editor_batch", {
32969
32970
  title: "Cancel an editor batch",
32970
- description: "Cancels an editor batch in awaiting_previews, running, or paused_for_retry so the project can be edited or " + "Autopilot can be started again. Cancellation is destructive: dispatched scene work is stopped and its prepaid " + "credits are not refunded; only an awaiting_previews batch (before dispatch) is refunded by the server. Spends " + "0 additional credits. Scope: video:generate.",
32971
+ description: "Cancels an editor batch only while it is preparing or paused_for_retry so the project can be edited or " + "Autopilot can be started again. Preparing cancellation releases the uncharged preflight. Cancelling a paused " + "batch is destructive: dispatched scene work is stopped and its prepaid credits are not refunded. Spends " + "0 additional credits. Scope: video:generate.",
32971
32972
  inputSchema: { slug: exports_external.string().describe("Editor project slug") },
32972
32973
  annotations: {
32973
32974
  title: "Cancel editor batch",
@@ -32977,7 +32978,11 @@ registerTool("cancel_editor_batch", {
32977
32978
  idempotentHint: false
32978
32979
  }
32979
32980
  }, tool(async (args, client) => {
32980
- const res = await client.post(`/video-factories/${args.slug}/cancel-batch`);
32981
+ const command = await observedEditorBatchCommand(client, args.slug, [
32982
+ "preparing",
32983
+ "paused_for_retry"
32984
+ ]);
32985
+ const res = await client.post(`/video-factories/${args.slug}/cancel-batch`, command);
32981
32986
  return ok({
32982
32987
  ...asRecord(asRecord(res).data ?? res),
32983
32988
  slug: args.slug,
@@ -32985,6 +32990,22 @@ registerTool("cancel_editor_batch", {
32985
32990
  next: "get_status"
32986
32991
  });
32987
32992
  }));
32993
+ async function observedEditorBatchCommand(client, slug, allowedStatuses) {
32994
+ const response = await client.get(`/editor/${slug}`);
32995
+ const state = asRecord(asRecord(response).data);
32996
+ const status = state.batch_generation_status;
32997
+ if (typeof status !== "string" || !allowedStatuses.includes(status)) {
32998
+ throw new Error(`Editor batch is not in an allowed phase (${allowedStatuses.join(" or ")}); refresh status before retrying.`);
32999
+ }
33000
+ const runId = state.batch_generation_run_id;
33001
+ if (runId !== null && (typeof runId !== "string" || runId.trim().length === 0)) {
33002
+ throw new Error("Editor state did not include a valid batch_generation_run_id; refresh status before retrying.");
33003
+ }
33004
+ return {
33005
+ expected_batch_run_id: runId,
33006
+ expected_batch_status: status
33007
+ };
33008
+ }
32988
33009
  registerTool("get_ai_assists", {
32989
33010
  title: "Get AI assist quota",
32990
33011
  description: "Returns the free daily AI-assist quota {used, limit, bonus, remaining, resets_at, unlock_cost, " + "unlock_batch_size}. AI assists power helper calls (generate_scenario, generate_narration, " + "enhance_prompt, suggest_next_scene, suggest_music_prompt). Default limit is 20/day. When remaining " + "hits 0 those helpers return 429 — either unlock_ai_assists (1 credit → +10) or write the content " + "yourself with set_scenario / set_narration_script / set_segment_prompt (those are free, no quota).",
@@ -33011,7 +33032,6 @@ registerTool("create_editor_draft", {
33011
33032
  creative_format: exports_external.enum(CREATIVE_FORMATS).optional().describe("Narrative arc / segment structure. Omit for Auto/generic."),
33012
33033
  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."),
33013
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.'),
33014
- cast_mode: exports_external.enum(["product_only", "presenter_led"]).optional().describe("Who/what anchors every AI scene. product_only = a product-led ad with NO people; requires a " + "product image (set_product) before generation and generates every scene from persistent product " + "reference images — the best product fidelity. presenter_led = one locked presenter appears in " + "every scene; requires a presenter to be configured first (in the app / REST presenter endpoints " + "— no MCP tool yet), else generation fails with presenter_required. Omit for the default auto " + "behavior."),
33015
33035
  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"),
33016
33036
  export_aspect_ratio: exports_external.enum(["9:16", "16:9", "1:1"]).optional().describe('Aspect ratio (default "9:16")'),
33017
33037
  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).")
@@ -33030,14 +33050,13 @@ registerTool("create_editor_draft", {
33030
33050
  creative_format: args.creative_format,
33031
33051
  visual_language: args.visual_language,
33032
33052
  theme: args.theme,
33033
- cast_mode: args.cast_mode,
33034
33053
  voice_id: args.voice_id,
33035
33054
  export_aspect_ratio: args.export_aspect_ratio,
33036
33055
  project_intent: args.project_intent
33037
33056
  };
33038
33057
  if (prompt)
33039
33058
  body.product_prompt = prompt;
33040
- 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 ?? "", args.cast_mode ?? ""));
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 ?? ""));
33041
33060
  return ok({
33042
33061
  slug: created.data.slug,
33043
33062
  kind: "editor",
@@ -33060,6 +33079,8 @@ function conciseEditorState(value) {
33060
33079
  "scenario_apply_status",
33061
33080
  "scenario_prompt",
33062
33081
  "batch_generation_status",
33082
+ "batch_generation_run_id",
33083
+ "auto_render_after_batch",
33063
33084
  "narration_status",
33064
33085
  "narration_script",
33065
33086
  "narration_stale",
@@ -33084,8 +33105,6 @@ function conciseEditorState(value) {
33084
33105
  status: segment.status,
33085
33106
  source_type: segment.source_type,
33086
33107
  use_previous_frame: segment.use_previous_frame,
33087
- protagonist_presence: segment.protagonist_presence,
33088
- cast_mode_snapshot: segment.cast_mode_snapshot,
33089
33108
  video_stale: segment.video_stale,
33090
33109
  error_code: segment.error_code,
33091
33110
  error_message: segment.error_message,
@@ -33095,7 +33114,7 @@ function conciseEditorState(value) {
33095
33114
  }
33096
33115
  registerTool("get_editor", {
33097
33116
  title: "Get editor state (review step)",
33098
- description: "Returns the full editor state by default for backward compatibility. Pass response_format:'concise' for a smaller review projection containing orchestration, scenario, scene prompts/status, narration, audio, render, spend, and quota.",
33117
+ description: "Returns the full editor state by default for backward compatibility. Pass response_format:'concise' for a smaller review projection containing orchestration, scenario, scene prompts/status, narration_status, current_audio, current_music, latest_render, batch_generation_status, spend, and quota.",
33099
33118
  inputSchema: {
33100
33119
  slug: exports_external.string().describe("Editor project slug"),
33101
33120
  response_format: exports_external.enum(["concise", "full"]).default("full")
@@ -33124,8 +33143,8 @@ registerTool("generate_scenario", {
33124
33143
  inputSchema: {
33125
33144
  slug: exports_external.string().describe("Editor project slug"),
33126
33145
  segments_count: exports_external.number().int().min(3).max(10).optional().describe("How many scenes (3–10, server default 5)"),
33127
- creative_format: exports_external.enum(CREATIVE_FORMATS).optional().describe("Persist the narrative arc / segment structure before generating. Omit to leave it unchanged; pass an empty string to clear."),
33128
- visual_language: exports_external.enum(VISUAL_LANGUAGES).optional().describe("Persist the visual style / render look before generating. When set it drives the look and a theme of 'none' is ignored."),
33146
+ 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
+ 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."),
33129
33148
  theme: exports_external.string().optional().describe("Persist the genre overlay / theme before generating (e.g. realistic, cinematic, anime, none)."),
33130
33149
  auto_unlock: exports_external.boolean().optional().describe("On 429, spend 1 credit to unlock +10 assists and retry once (default false)")
33131
33150
  },
@@ -33429,10 +33448,10 @@ registerTool("generate_all_segments", {
33429
33448
  }));
33430
33449
  registerTool("set_narration_script", {
33431
33450
  title: "Set the narration script",
33432
- description: "Writes your own narration script (1–12000 chars) instead of generating one. Free — no credits, no AI " + "assist. Use this in place of generate_narration when you want to control the voiceover text. Changing the narration after generating voice/music marks them stale — regenerate (generate_voice / generate_music) before render.",
33451
+ description: "Writes your own narration script (1–10000 chars) instead of generating one. Free — no credits, no AI " + "assist. Use this in place of generate_narration when you want to control the voiceover text. Changing the narration after generating voice/music marks them stale — regenerate (generate_voice / generate_music) before render.",
33433
33452
  inputSchema: {
33434
33453
  slug: exports_external.string().describe("Editor project slug"),
33435
- script: exports_external.string().min(1).max(12000).describe("Narration / voiceover text (1–12000 chars)")
33454
+ script: exports_external.string().min(1).max(1e4).describe("Narration / voiceover text (1–10000 chars)")
33436
33455
  },
33437
33456
  annotations: { title: "Set narration", ...WRITE, idempotentHint: true }
33438
33457
  }, tool(async (args, client) => {
@@ -33443,7 +33462,7 @@ registerTool("set_narration_script", {
33443
33462
  }));
33444
33463
  registerTool("generate_narration", {
33445
33464
  title: "Generate the narration (AI assist)",
33446
- description: "Generates the narration script with AI from the timeline. CONSUMES 1 AI ASSIST (free daily quota) — " + "it is free of CREDITS but NOT free of quota. On 429 set auto_unlock:true (1 credit → +10 assists, " + "retried once) or write the script yourself with set_narration_script.",
33465
+ description: "Generates one coherent narration script with AI from the complete timeline. Every current scene must be completed with a known positive duration, and at least one scene must not keep original upload audio. CONSUMES 1 AI ASSIST (free daily quota) only when a fresh job is queued; an exact retry is deduplicated without consuming another assist — " + "it is free of CREDITS but NOT free of quota. On 429 set auto_unlock:true (1 credit → +10 assists, " + "retried once) or write the script yourself with set_narration_script.",
33447
33466
  inputSchema: {
33448
33467
  slug: exports_external.string().describe("Editor project slug"),
33449
33468
  auto_unlock: exports_external.boolean().optional().describe("On 429, spend 1 credit to unlock +10 assists and retry once (default false)")
@@ -33459,7 +33478,7 @@ registerTool("generate_narration", {
33459
33478
  }));
33460
33479
  registerTool("generate_voice", {
33461
33480
  title: "Generate the voiceover (3 credits)",
33462
- description: "Synthesizes the voiceover from the narration script. Costs 3 credits. Safe to retry; re-call after changing the narration or voice to regenerate." + "voice_id is REQUIRED — pick one from list_voices (≤64 chars, [A-Za-z0-9_-] only). 402 fails cleanly. Editing the scenario/narration/timeline after this marks the voice stale, and render will 422 (editor_voice_stale) until you re-run generate_voice.",
33481
+ description: "Synthesizes the voiceover from the narration script. Costs 3 credits. Safe to retry; re-call after changing the narration or voice to regenerate." + " All current scenes must be completed with known durations first. voice_id is REQUIRED — pick one from list_voices (≤64 chars, [A-Za-z0-9_-] only). 402 fails cleanly. Editing the scenario/narration/timeline after this marks the voice stale, and render will 422 (editor_voice_stale) until you re-run generate_voice.",
33463
33482
  inputSchema: {
33464
33483
  slug: exports_external.string().describe("Editor project slug"),
33465
33484
  voice_id: exports_external.string().regex(/^[A-Za-z0-9_-]+$/).max(64).describe("Narration voice id from list_voices (required)")
@@ -33571,7 +33590,7 @@ registerTool("suggest_music_prompt", {
33571
33590
  }));
33572
33591
  registerTool("list_renders", {
33573
33592
  title: "List renders for an editor project",
33574
- description: "Lists every render version for an editor project: {id, version, status, video_url, duration_seconds, " + "error_message, inserted_at}. Use it to find a FAILED render to retry, or to recover a finished video_url " + "(presigned, ~24h). Read-only.",
33593
+ description: "Lists 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}. Use it to find a " + "FAILED render to retry, or to recover a finished video_url (presigned, ~24h). status is pending | " + "processing | completed | failed; is_stale null means unknown, not current. Read-only.",
33575
33594
  inputSchema: { slug: exports_external.string().describe("Editor project slug") },
33576
33595
  annotations: { title: "List renders", ...RO }
33577
33596
  }, tool(async (args, client) => {
@@ -33744,7 +33763,7 @@ registerTool("use_asset_for_segment", {
33744
33763
  }));
33745
33764
  registerTool("set_product", {
33746
33765
  title: "Attach a product image (from a local file)",
33747
- 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. The FIRST product added " + "defaults every pending AI scene to feature it 'throughout' (change with set_product_placement). Optional " + "description (≤500 chars) guides how it's woven into scenes.",
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.",
33748
33767
  inputSchema: {
33749
33768
  slug: exports_external.string().describe("Editor project slug"),
33750
33769
  file_path: exports_external.string().describe("Local product image path (.jpg/.jpeg/.png)"),
@@ -33756,22 +33775,6 @@ registerTool("set_product", {
33756
33775
  const res = await client.post(`/editor/${args.slug}/product/confirm`, { s3_key, product_description: args.description });
33757
33776
  return ok(asRecord(res).data ?? res);
33758
33777
  }));
33759
- registerTool("set_product_placement", {
33760
- title: "Set default product placement on pending scenes (0 credits)",
33761
- description: "Sets where the product appears across pending (never-generated) AI scenes. mode is 'throughout' (every scene) " + "or 'end' (closing scene only; requires a first+last-frame capable model). 0 credits. Add a product first with " + "set_product. (There is no 'none' — to remove the product, that's a separate action.)",
33762
- inputSchema: {
33763
- slug: exports_external.string().describe("Editor project slug"),
33764
- mode: exports_external.enum(["throughout", "end"]).describe("'throughout' or 'end'")
33765
- },
33766
- annotations: {
33767
- title: "Set product placement",
33768
- ...WRITE,
33769
- idempotentHint: true
33770
- }
33771
- }, tool(async (args, client) => {
33772
- const res = await client.post(`/editor/${args.slug}/product/apply-default-placement`, { placement: args.mode });
33773
- return ok(asRecord(res).data ?? res);
33774
- }));
33775
33778
  registerTool("set_closing_image", {
33776
33779
  title: "Set the closing-card image (0 credits)",
33777
33780
  description: "Sets the optional ~2s end-card image. Either upload a local file (file_path; .jpg/.jpeg/.png ≤20 MiB, confined " + "to HUBFLUENCER_INPUT_DIR/cwd) OR reuse the project's product image (from_product:true — a 0-credit server-side " + "copy, no upload). If a closing image already exists, pass overwrite:true to replace it (else " + "editor_closing_image_exists).",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubfluencer/mcp",
3
- "version": "0.11.0",
3
+ "version": "0.12.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
@@ -464,6 +464,13 @@ export function normalizeStatus(
464
464
  batchStatus === "running" ||
465
465
  batchStatus === "preparing" ||
466
466
  batchStatus === "awaiting_previews";
467
+ // The scene batch commits `completed` before the durable auto-render handoff
468
+ // creates its VideoResult row. While the persisted intent remains armed, the
469
+ // server still owns the project and a reconciler may create the render at any
470
+ // moment. Treat that narrow gap as active work so wait_for_completion cannot
471
+ // return an idle/terminal result just before the render appears.
472
+ const autoRenderHandoffActive =
473
+ batchStatus === "completed" && d.auto_render_after_batch === true;
467
474
  const batchRetryRequired = batchStatus === "paused_for_retry";
468
475
  // A parked batch that ran out of credits: it cannot progress without the user
469
476
  // topping up or reducing scope, so it is a needs-user-action TERMINAL state
@@ -567,6 +574,7 @@ export function normalizeStatus(
567
574
  newerMusicVersions.some(versionActive);
568
575
  const childWorkActive =
569
576
  batchActive ||
577
+ autoRenderHandoffActive ||
570
578
  renderActive ||
571
579
  scenarioActive ||
572
580
  narrationActive ||
@@ -670,6 +678,7 @@ export function normalizeStatus(
670
678
  else if (batchInsufficientCredits) stage = "insufficient_credits";
671
679
  else if (activeSegments.length > 0) stage = "segment_processing";
672
680
  else if (batchActive) stage = "segment_generation";
681
+ else if (autoRenderHandoffActive) stage = "rendering";
673
682
  else if (renderActive) stage = "rendering";
674
683
  else if (scenarioActive) stage = "scenario_processing";
675
684
  else if (narrationActive) stage = "narration_processing";
@@ -705,7 +714,7 @@ export function normalizeStatus(
705
714
  : null) ??
706
715
  actionableChildError ??
707
716
  (batchInsufficientCredits
708
- ? "Batch generation paused: not enough credits to finish. Top up or reduce scope, then resume."
717
+ ? "Batch generation paused: not enough credits to finish. Top up or reduce scope, then invoke the generation/render path again; retry_editor_batch only applies to paused_for_retry."
709
718
  : null)),
710
719
  stale,
711
720
  active_segment_ids: activeSegments
@@ -856,7 +865,6 @@ export interface EditorAdKeyArgs {
856
865
  logo_treatment?: string;
857
866
  logo_position?: string;
858
867
  logo_duration_seconds?: number;
859
- cast_mode?: string;
860
868
  /** Identities of the retained, pre-create asset byte snapshots. */
861
869
  product_image_identity?: string;
862
870
  closing_image_identity?: string;
@@ -896,7 +904,6 @@ export function editorAdCreateKey(a: EditorAdKeyArgs): string {
896
904
  a.logo_duration_seconds === undefined
897
905
  ? ""
898
906
  : String(a.logo_duration_seconds),
899
- a.cast_mode ?? "",
900
907
  );
901
908
  }
902
909
 
@@ -931,7 +938,6 @@ export function editorAdAutopilotKey(slug: string, a: EditorAdKeyArgs): string {
931
938
  a.logo_duration_seconds === undefined
932
939
  ? ""
933
940
  : String(a.logo_duration_seconds),
934
- a.cast_mode ?? "",
935
941
  a.max_credits === undefined ? "" : String(a.max_credits),
936
942
  );
937
943
  }
@@ -1076,10 +1082,9 @@ export function validateLanguage(language: string | undefined): string | null {
1076
1082
  export function validateProductPrompt(
1077
1083
  prompt: string | undefined,
1078
1084
  ): string | null {
1079
- // A trimmed-empty string is a no-op on every path the server exposes:
1080
- // pre-launch override handling drops blank values (leave the stored value
1081
- // untouched) and the editor prompt is valid when blank, so treat "" the same
1082
- // as undefined. Still reject 1–9 non-blank chars (too short to be a brief).
1085
+ // Empty is valid but its meaning is endpoint-specific: draft creation omits
1086
+ // it, while Autopilot treats an explicitly present blank as an atomic clear.
1087
+ // Still reject 1–9 non-blank chars (too short to be a brief).
1083
1088
  if (prompt === undefined) return null;
1084
1089
  const len = prompt.trim().length;
1085
1090
  if (len === 0) return null;
package/src/index.ts CHANGED
@@ -171,9 +171,9 @@ async function editorAutopilotQuote(
171
171
  try {
172
172
  const params = new URLSearchParams();
173
173
  if (overrides.language) params.set("language", overrides.language);
174
- if (overrides.product_subject)
174
+ if (overrides.product_subject !== undefined)
175
175
  params.set("product_subject", overrides.product_subject);
176
- if (overrides.product_prompt)
176
+ if (overrides.product_prompt !== undefined)
177
177
  params.set("product_prompt", overrides.product_prompt);
178
178
  if (overrides.restart) params.set("restart", "true");
179
179
  const query = params.toString();
@@ -3020,12 +3020,12 @@ registerTool(
3020
3020
  "Without max_credits it STOPS after the free draft/configuration and returns the live estimate. It starts " +
3021
3021
  "the paid pipeline only when max_credits is supplied and covers the estimate. Then poll with get_status / " +
3022
3022
  "wait_for_completion (kind=editor). Prefer make_video for a prompt-only one-shot. " +
3023
- "Supplying assets here is safer than adding them after autopilot because the scenario and scenes are grounded " +
3024
- "in the real product from the start. (Editor ads carry their copy in-scene/narration, not as a " +
3025
- "title overlay; for an on-screen title/subtitle use a short with headline/subheadline instead.) " +
3026
- "For a mascot or hero product that must stay visually identical with no human presenter, set " +
3027
- "cast_mode:'product_only' together with product_image_path. Presenter-led casting still belongs on " +
3028
- "create_editor_draft because it requires a separately configured presenter.",
3023
+ "A product image is analyzed into semantic project context and sent as a product-identity reference to every " +
3024
+ "generated Editor scene. The exact photo is also linked as the closing image unless a separate closing image " +
3025
+ "was authored; generated fine label text can still vary. Scene prompts are the complete content contract: describe any people, " +
3026
+ "products, visual continuity, absences, and composition directly in the brief. (Editor ads carry their copy " +
3027
+ "in-scene/narration, not as a title overlay; for an on-screen title/subtitle use a short with " +
3028
+ "headline/subheadline instead.)",
3029
3029
  inputSchema: {
3030
3030
  product_prompt: z
3031
3031
  .string()
@@ -3085,14 +3085,14 @@ registerTool(
3085
3085
  .string()
3086
3086
  .optional()
3087
3087
  .describe(
3088
- "Local product image (.jpg/.jpeg/.png, ≤8 MiB) to attach before autopilot.",
3088
+ "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.",
3089
3089
  ),
3090
3090
  product_description: z
3091
3091
  .string()
3092
3092
  .max(500)
3093
3093
  .optional()
3094
3094
  .describe(
3095
- "Exact product/character description used with product_image_path.",
3095
+ "Product description used to ground the scenario and narration when product_image_path is supplied.",
3096
3096
  ),
3097
3097
  closing_image_path: z
3098
3098
  .string()
@@ -3122,12 +3122,6 @@ registerTool(
3122
3122
  .describe(
3123
3123
  "Logo bumper duration in seconds, 0.5–3 (default 1.5). Requires logo_path.",
3124
3124
  ),
3125
- cast_mode: z
3126
- .literal("product_only")
3127
- .optional()
3128
- .describe(
3129
- "Lock every AI scene to the supplied product/mascot reference with no people. Requires product_image_path.",
3130
- ),
3131
3125
  max_credits: z
3132
3126
  .number()
3133
3127
  .int()
@@ -3163,7 +3157,6 @@ registerTool(
3163
3157
  | "bottom-left"
3164
3158
  | "bottom-right";
3165
3159
  logo_duration_seconds?: number;
3166
- cast_mode?: "product_only";
3167
3160
  max_credits?: number;
3168
3161
  },
3169
3162
  client,
@@ -3177,11 +3170,6 @@ registerTool(
3177
3170
  if (langErr) return fail(langErr);
3178
3171
  const promptErr = validateProductPrompt(args.product_prompt);
3179
3172
  if (promptErr) return fail(promptErr);
3180
- if (args.cast_mode === "product_only" && !args.product_image_path) {
3181
- return fail(
3182
- "cast_mode product_only requires product_image_path so identity can be locked before generation.",
3183
- );
3184
- }
3185
3173
  // Fail fast on settings that would otherwise be silently dropped: the
3186
3174
  // logo PATCH is gated on logo_path and the product_description is only
3187
3175
  // sent with product_image_path, so a logo_* / product_description passed
@@ -3241,7 +3229,6 @@ registerTool(
3241
3229
  theme: args.theme,
3242
3230
  voice_id: args.voice_id,
3243
3231
  export_aspect_ratio: args.export_aspect_ratio,
3244
- cast_mode: args.cast_mode,
3245
3232
  },
3246
3233
  // Fold every create-affecting param into the key so two calls that
3247
3234
  // differ only by style/theme/voice/aspect get distinct drafts.
@@ -3401,12 +3388,14 @@ registerTool(
3401
3388
  .optional()
3402
3389
  .describe(
3403
3390
  "Optional locked product / main subject applied before launch so the AI cannot invent an " +
3404
- "unrelated product.",
3391
+ "unrelated product. Omit to keep the stored subject; send blank to clear it.",
3405
3392
  ),
3406
3393
  product_prompt: z
3407
3394
  .string()
3408
3395
  .optional()
3409
- .describe("Optional freeform brief to set/replace before launch."),
3396
+ .describe(
3397
+ "Optional freeform brief to set/replace before launch. Omit to keep the stored brief; send blank to clear it.",
3398
+ ),
3410
3399
  max_credits: z
3411
3400
  .number()
3412
3401
  .int()
@@ -3530,8 +3519,12 @@ registerTool(
3530
3519
  },
3531
3520
  },
3532
3521
  tool(async (args: { slug: string }, client) => {
3522
+ const command = await observedEditorBatchCommand(client, args.slug, [
3523
+ "paused_for_retry",
3524
+ ]);
3533
3525
  const res = await client.post<{ data: unknown }>(
3534
3526
  `/video-factories/${args.slug}/retry-batch-segment`,
3527
+ command,
3535
3528
  );
3536
3529
  return ok({
3537
3530
  ...asRecord(asRecord(res).data ?? res),
@@ -3548,9 +3541,9 @@ registerTool(
3548
3541
  {
3549
3542
  title: "Cancel an editor batch",
3550
3543
  description:
3551
- "Cancels an editor batch in awaiting_previews, running, or paused_for_retry so the project can be edited or " +
3552
- "Autopilot can be started again. Cancellation is destructive: dispatched scene work is stopped and its prepaid " +
3553
- "credits are not refunded; only an awaiting_previews batch (before dispatch) is refunded by the server. Spends " +
3544
+ "Cancels an editor batch only while it is preparing or paused_for_retry so the project can be edited or " +
3545
+ "Autopilot can be started again. Preparing cancellation releases the uncharged preflight. Cancelling a paused " +
3546
+ "batch is destructive: dispatched scene work is stopped and its prepaid credits are not refunded. Spends " +
3554
3547
  "0 additional credits. Scope: video:generate.",
3555
3548
  inputSchema: { slug: z.string().describe("Editor project slug") },
3556
3549
  annotations: {
@@ -3562,8 +3555,13 @@ registerTool(
3562
3555
  },
3563
3556
  },
3564
3557
  tool(async (args: { slug: string }, client) => {
3558
+ const command = await observedEditorBatchCommand(client, args.slug, [
3559
+ "preparing",
3560
+ "paused_for_retry",
3561
+ ]);
3565
3562
  const res = await client.post<{ data: unknown }>(
3566
3563
  `/video-factories/${args.slug}/cancel-batch`,
3564
+ command,
3567
3565
  );
3568
3566
  return ok({
3569
3567
  ...asRecord(asRecord(res).data ?? res),
@@ -3574,6 +3572,50 @@ registerTool(
3574
3572
  }),
3575
3573
  );
3576
3574
 
3575
+ type EditorBatchCommandStatus = "preparing" | "paused_for_retry";
3576
+
3577
+ /**
3578
+ * Reads the exact Editor batch ownership token immediately before issuing a
3579
+ * recovery command. The API validates both fields under the factory row lock,
3580
+ * so a replacement run or phase transition between this GET and the POST is a
3581
+ * safe `batch_command_stale` conflict rather than acting on newer work.
3582
+ */
3583
+ async function observedEditorBatchCommand(
3584
+ client: HubfluencerClient,
3585
+ slug: string,
3586
+ allowedStatuses: readonly EditorBatchCommandStatus[],
3587
+ ): Promise<{
3588
+ expected_batch_run_id: string | null;
3589
+ expected_batch_status: EditorBatchCommandStatus;
3590
+ }> {
3591
+ const response = await client.get<{ data: unknown }>(`/editor/${slug}`);
3592
+ const state = asRecord(asRecord(response).data);
3593
+ const status = state.batch_generation_status;
3594
+ if (
3595
+ typeof status !== "string" ||
3596
+ !allowedStatuses.includes(status as EditorBatchCommandStatus)
3597
+ ) {
3598
+ throw new Error(
3599
+ `Editor batch is not in an allowed phase (${allowedStatuses.join(" or ")}); refresh status before retrying.`,
3600
+ );
3601
+ }
3602
+
3603
+ const runId = state.batch_generation_run_id;
3604
+ if (
3605
+ runId !== null &&
3606
+ (typeof runId !== "string" || runId.trim().length === 0)
3607
+ ) {
3608
+ throw new Error(
3609
+ "Editor state did not include a valid batch_generation_run_id; refresh status before retrying.",
3610
+ );
3611
+ }
3612
+
3613
+ return {
3614
+ expected_batch_run_id: runId,
3615
+ expected_batch_status: status as EditorBatchCommandStatus,
3616
+ };
3617
+ }
3618
+
3577
3619
  // ── AI assists (free daily quota) ───────────────────────────────────────────
3578
3620
 
3579
3621
  registerTool(
@@ -3676,17 +3718,6 @@ registerTool(
3676
3718
  .describe(
3677
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.',
3678
3720
  ),
3679
- cast_mode: z
3680
- .enum(["product_only", "presenter_led"])
3681
- .optional()
3682
- .describe(
3683
- "Who/what anchors every AI scene. product_only = a product-led ad with NO people; requires a " +
3684
- "product image (set_product) before generation and generates every scene from persistent product " +
3685
- "reference images — the best product fidelity. presenter_led = one locked presenter appears in " +
3686
- "every scene; requires a presenter to be configured first (in the app / REST presenter endpoints " +
3687
- "— no MCP tool yet), else generation fails with presenter_required. Omit for the default auto " +
3688
- "behavior.",
3689
- ),
3690
3721
  voice_id: z
3691
3722
  .string()
3692
3723
  .regex(/^[A-Za-z0-9_-]+$/)
@@ -3723,7 +3754,6 @@ registerTool(
3723
3754
  creative_format?: string;
3724
3755
  visual_language?: string;
3725
3756
  theme?: string;
3726
- cast_mode?: string;
3727
3757
  voice_id?: string;
3728
3758
  export_aspect_ratio?: string;
3729
3759
  project_intent?: string;
@@ -3737,7 +3767,6 @@ registerTool(
3737
3767
  creative_format: args.creative_format,
3738
3768
  visual_language: args.visual_language,
3739
3769
  theme: args.theme,
3740
- cast_mode: args.cast_mode,
3741
3770
  voice_id: args.voice_id,
3742
3771
  export_aspect_ratio: args.export_aspect_ratio,
3743
3772
  project_intent: args.project_intent,
@@ -3760,7 +3789,6 @@ registerTool(
3760
3789
  args.theme ?? "",
3761
3790
  args.voice_id ?? "",
3762
3791
  args.export_aspect_ratio ?? "",
3763
- args.cast_mode ?? "",
3764
3792
  ),
3765
3793
  );
3766
3794
  return ok({
@@ -3788,6 +3816,8 @@ function conciseEditorState(value: unknown): Record<string, unknown> {
3788
3816
  "scenario_apply_status",
3789
3817
  "scenario_prompt",
3790
3818
  "batch_generation_status",
3819
+ "batch_generation_run_id",
3820
+ "auto_render_after_batch",
3791
3821
  "narration_status",
3792
3822
  "narration_script",
3793
3823
  "narration_stale",
@@ -3812,8 +3842,6 @@ function conciseEditorState(value: unknown): Record<string, unknown> {
3812
3842
  status: segment.status,
3813
3843
  source_type: segment.source_type,
3814
3844
  use_previous_frame: segment.use_previous_frame,
3815
- protagonist_presence: segment.protagonist_presence,
3816
- cast_mode_snapshot: segment.cast_mode_snapshot,
3817
3845
  video_stale: segment.video_stale,
3818
3846
  error_code: segment.error_code,
3819
3847
  error_message: segment.error_message,
@@ -3828,7 +3856,7 @@ registerTool(
3828
3856
  {
3829
3857
  title: "Get editor state (review step)",
3830
3858
  description:
3831
- "Returns the full editor state by default for backward compatibility. Pass response_format:'concise' for a smaller review projection containing orchestration, scenario, scene prompts/status, narration, audio, render, spend, and quota.",
3859
+ "Returns the full editor state by default for backward compatibility. Pass response_format:'concise' for a smaller review projection containing orchestration, scenario, scene prompts/status, narration_status, current_audio, current_music, latest_render, batch_generation_status, spend, and quota.",
3832
3860
  inputSchema: {
3833
3861
  slug: z.string().describe("Editor project slug"),
3834
3862
  response_format: z.enum(["concise", "full"]).default("full"),
@@ -3898,16 +3926,16 @@ registerTool(
3898
3926
  .optional()
3899
3927
  .describe("How many scenes (3–10, server default 5)"),
3900
3928
  creative_format: z
3901
- .enum(CREATIVE_FORMATS)
3929
+ .union([z.enum(CREATIVE_FORMATS), z.literal("")])
3902
3930
  .optional()
3903
3931
  .describe(
3904
3932
  "Persist the narrative arc / segment structure before generating. Omit to leave it unchanged; pass an empty string to clear.",
3905
3933
  ),
3906
3934
  visual_language: z
3907
- .enum(VISUAL_LANGUAGES)
3935
+ .union([z.enum(VISUAL_LANGUAGES), z.literal("")])
3908
3936
  .optional()
3909
3937
  .describe(
3910
- "Persist the visual style / render look before generating. When set it drives the look and a theme of 'none' is ignored.",
3938
+ "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.",
3911
3939
  ),
3912
3940
  theme: z
3913
3941
  .string()
@@ -4479,15 +4507,15 @@ registerTool(
4479
4507
  {
4480
4508
  title: "Set the narration script",
4481
4509
  description:
4482
- "Writes your own narration script (1–12000 chars) instead of generating one. Free — no credits, no AI " +
4510
+ "Writes your own narration script (1–10000 chars) instead of generating one. Free — no credits, no AI " +
4483
4511
  "assist. Use this in place of generate_narration when you want to control the voiceover text. Changing the narration after generating voice/music marks them stale — regenerate (generate_voice / generate_music) before render.",
4484
4512
  inputSchema: {
4485
4513
  slug: z.string().describe("Editor project slug"),
4486
4514
  script: z
4487
4515
  .string()
4488
4516
  .min(1)
4489
- .max(12000)
4490
- .describe("Narration / voiceover text (1–12000 chars)"),
4517
+ .max(10000)
4518
+ .describe("Narration / voiceover text (1–10000 chars)"),
4491
4519
  },
4492
4520
  annotations: { title: "Set narration", ...WRITE, idempotentHint: true },
4493
4521
  },
@@ -4504,7 +4532,7 @@ registerTool(
4504
4532
  {
4505
4533
  title: "Generate the narration (AI assist)",
4506
4534
  description:
4507
- "Generates the narration script with AI from the timeline. CONSUMES 1 AI ASSIST (free daily quota) — " +
4535
+ "Generates one coherent narration script with AI from the complete timeline. Every current scene must be completed with a known positive duration, and at least one scene must not keep original upload audio. CONSUMES 1 AI ASSIST (free daily quota) only when a fresh job is queued; an exact retry is deduplicated without consuming another assist — " +
4508
4536
  "it is free of CREDITS but NOT free of quota. On 429 set auto_unlock:true (1 credit → +10 assists, " +
4509
4537
  "retried once) or write the script yourself with set_narration_script.",
4510
4538
  inputSchema: {
@@ -4536,7 +4564,7 @@ registerTool(
4536
4564
  title: "Generate the voiceover (3 credits)",
4537
4565
  description:
4538
4566
  "Synthesizes the voiceover from the narration script. Costs 3 credits. Safe to retry; re-call after changing the narration or voice to regenerate." +
4539
- "voice_id is REQUIRED — pick one from list_voices (≤64 chars, [A-Za-z0-9_-] only). 402 fails cleanly. Editing the scenario/narration/timeline after this marks the voice stale, and render will 422 (editor_voice_stale) until you re-run generate_voice.",
4567
+ " All current scenes must be completed with known durations first. voice_id is REQUIRED — pick one from list_voices (≤64 chars, [A-Za-z0-9_-] only). 402 fails cleanly. Editing the scenario/narration/timeline after this marks the voice stale, and render will 422 (editor_voice_stale) until you re-run generate_voice.",
4540
4568
  inputSchema: {
4541
4569
  slug: z.string().describe("Editor project slug"),
4542
4570
  voice_id: z
@@ -4775,9 +4803,10 @@ registerTool(
4775
4803
  {
4776
4804
  title: "List renders for an editor project",
4777
4805
  description:
4778
- "Lists every render version for an editor project: {id, version, status, video_url, duration_seconds, " +
4779
- "error_message, inserted_at}. Use it to find a FAILED render to retry, or to recover a finished video_url " +
4780
- "(presigned, ~24h). Read-only.",
4806
+ "Lists every render version for an editor project: {id, version, status, video_url, thumbnail_url, " +
4807
+ "duration_seconds, error_message, retryable, combination_hash, is_stale, inserted_at}. Use it to find a " +
4808
+ "FAILED render to retry, or to recover a finished video_url (presigned, ~24h). status is pending | " +
4809
+ "processing | completed | failed; is_stale null means unknown, not current. Read-only.",
4781
4810
  inputSchema: { slug: z.string().describe("Editor project slug") },
4782
4811
  annotations: { title: "List renders", ...RO },
4783
4812
  },
@@ -5129,9 +5158,9 @@ registerTool(
5129
5158
  description:
5130
5159
  "Uploads a local product image and attaches it to the project as the product. Accepted: " +
5131
5160
  IMAGE_EXTS.map((e) => `.${e}`).join(", ") +
5132
- " (≤8 MiB), local path confined to HUBFLUENCER_INPUT_DIR (or cwd). 0 credits. The FIRST product added " +
5133
- "defaults every pending AI scene to feature it 'throughout' (change with set_product_placement). Optional " +
5134
- "description (≤500 chars) guides how it's woven into scenes.",
5161
+ " (≤8 MiB), local path confined to HUBFLUENCER_INPUT_DIR (or cwd). 0 credits. Its pixels guide product identity " +
5162
+ "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.",
5135
5164
  inputSchema: {
5136
5165
  slug: z.string().describe("Editor project slug"),
5137
5166
  file_path: z
@@ -5165,33 +5194,6 @@ registerTool(
5165
5194
  ),
5166
5195
  );
5167
5196
 
5168
- registerTool(
5169
- "set_product_placement",
5170
- {
5171
- title: "Set default product placement on pending scenes (0 credits)",
5172
- description:
5173
- "Sets where the product appears across pending (never-generated) AI scenes. mode is 'throughout' (every scene) " +
5174
- "or 'end' (closing scene only; requires a first+last-frame capable model). 0 credits. Add a product first with " +
5175
- "set_product. (There is no 'none' — to remove the product, that's a separate action.)",
5176
- inputSchema: {
5177
- slug: z.string().describe("Editor project slug"),
5178
- mode: z.enum(["throughout", "end"]).describe("'throughout' or 'end'"),
5179
- },
5180
- annotations: {
5181
- title: "Set product placement",
5182
- ...WRITE,
5183
- idempotentHint: true,
5184
- },
5185
- },
5186
- tool(async (args: { slug: string; mode: "throughout" | "end" }, client) => {
5187
- const res = await client.post<{ data: unknown }>(
5188
- `/editor/${args.slug}/product/apply-default-placement`,
5189
- { placement: args.mode },
5190
- );
5191
- return ok(asRecord(res).data ?? res);
5192
- }),
5193
- );
5194
-
5195
5197
  registerTool(
5196
5198
  "set_closing_image",
5197
5199
  {