@hubfluencer/mcp 0.14.0 → 0.16.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
@@ -85,8 +85,8 @@ instead draw a **free daily quota of 20 AI assists** (top up with `unlock_ai_ass
85
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** |
86
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** |
87
87
  | `generate_short_text` | AI-draft editable headline / subheadline / caption beats for a short. | 1 assist |
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 |
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 |
88
+ | `create_editor_ad` | Create an editor project, attach optional local product/logo/closing assets, and return the live autopilot quote. Pass `segments_count` (3–10) as the exact structural target. 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 |
89
+ | `start_autopilot` | Quote autopilot on an **existing** editor draft when called with only `slug`; `segments_count` (3–10) overrides an older brief and is reflected in the matching quote. 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 |
90
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** |
91
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
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** |
@@ -111,9 +111,9 @@ instead draw a **free daily quota of 20 AI assists** (top up with `unlock_ai_ass
111
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** |
112
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** |
113
113
  | `generate_all_segments` | Render every not-yet-completed scene in order (continuity preserved); capped ~280s → re-run to continue. | **5 / segment** |
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 |
114
+ | `set_narration_script` / `generate_narration` | Write the exact words to read aloud (free)—never pass a production prompt, camera directions, or a creative brief as `script`—**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 |
115
115
  | `generate_voice` | Synthesize the voiceover. `voice_id` required (see `list_voices`). | **3** |
116
- | `generate_music` | Generate background music (each call is a fresh take). | **5** |
116
+ | `generate_music` | Generate background music (each call is a fresh take). Optional `instruments` is a string array (≤10 non-empty hints, ≤80 chars each), not a comma-separated string. | **5** |
117
117
  | `render` | Final MP4. Auto-charges and generates any pending or failed scenes first (4/scene in a batch of ≥3). | **0** |
118
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** |
119
119
  | `retry_render` | Re-run a **failed** render from its exact saved snapshot (recovery without re-driving the pipeline). | **0** |
@@ -137,19 +137,27 @@ images at **8 MiB (8,388,608 bytes)**; closing/short/catalog images at **20 MiB
137
137
  | `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`). |
138
138
  | `set_logo` | Upload a brand logo overlay (JPEG/PNG, ≤5 MiB; editor only; optional treatment/position/duration). |
139
139
  | `set_short_product` | Attach a product image (JPEG/PNG, ≤20 MiB) to a **short**. |
140
- | `set_short_logo` | Attach an exact deterministic logo overlay (JPEG/PNG, ≤5 MiB) to a **short**; choose its corner and `throughout`/`end_card`/`none` (hide) treatment. The logo is never sent to the video model. |
141
- | `set_short_poster` | Set a short's end-card poster (JPEG/PNG, ≤20 MiB; extends the render 12s → 14s). Pass `poster_includes_lockup:true` if the poster already carries brand name/CTA/badges — suppresses the overlaid claim/CTA so they don't collide. |
140
+ | `set_short_logo` | Attach an exact deterministic logo overlay (JPEG/PNG, ≤5 MiB) to a **short**. The corner is preferred and Forge moves it away from text/CTA/chips. Omit treatment to auto-select: transparent marks use `throughout`, opaque square lockups use `end_card`; explicit `throughout`/`end_card`/`none` still wins. The logo is never sent to the video model. |
141
+ | `set_short_poster` | Set a short's end-card poster (JPEG/PNG, ≤20 MiB; extends the render 12s → 14s). Phone-shaped portrait art is cover-cropped to 9:16; square, classic portrait, and landscape art is contained over a blurred backdrop. Pass `poster_includes_lockup:true` if the poster already carries brand name/CTA/badges — suppresses the overlaid claim/CTA and an end-card-only logo so they don't collide. |
142
142
 
143
143
  ### Status & delivery
144
144
 
145
145
  | Tool | What it does |
146
146
  |---|---|
147
- | `get_status` | Normalized `{stage, terminal, ready, video_url, error}` for a short / editor / tracking / slider. |
147
+ | `get_status` | Normalized `{stage, terminal, ready, video_url, error}` for a short / editor / tracking / slider. While a paid short is rendering, also exposes `segments_completed`, `segments_total`, and `active_segment_positions`; `generation_attempt` is the cancellation fence. |
148
148
  | `wait_for_completion` | Block-poll until terminal (bounded ≤280s; returns `terminal:false` to re-call). `save_path` downloads the MP4 when ready. |
149
149
  | `download_result` | Get (or save, to `HUBFLUENCER_OUTPUT_DIR`) the finished MP4 URL. Not for sliders — use `get_slider`. |
150
- | `get_credits` / `list_voices` / `list_projects` | Credit balance; narration voices; recent projects (`include_completed:true` to recover a finished short's slug). |
150
+ | `get_credits` / `list_voices` / `list_projects` | Credit balance; narration voices (including normalized verified `languages`); recent projects (`include_completed:true` to recover a finished short's slug). |
151
151
  | `get_ai_assists` / `unlock_ai_assists` | The free daily assist quota / spend 1 credit for +10. |
152
152
 
153
+ ### Creative export contract
154
+
155
+ - Production prompts (`product_prompt`, `scenario_prompt`, and scene `prompt`) are visual directions. Narration scripts are read aloud verbatim, so `set_narration_script.script` must contain only the exact spoken words—never camera notes, prompt syntax, labels, or scene directions. Never copy a creative brief into `script`; prefer `generate_narration` or Autopilot unless the user supplied final voice-over copy.
156
+ - Do not rely on the video model to spell essential brand names, UI, signs, prices, offers, or CTAs. Put exact copy in Hubfluencer overlays, narration, or supplied ending artwork.
157
+ - `short_logo_position` is a preferred corner. Forge moves the logo away from headline, captions, CTA, offer, and badge areas. Omit treatment so transparent marks default to `throughout` and opaque square lockups default to `end_card`. Use a tight transparent wordmark; use square/full-bleed art as the poster. `end_card` logos are suppressed when `poster_includes_lockup:true` to avoid double branding.
158
+ - Short posters fill the ending frame: phone-shaped portrait art is cover-cropped, while square, classic portrait, and landscape art is contained over a blurred backdrop. Editor closing images are always cover-cropped. Keep important content in the central 9:16 safe area. An Editor closing image gets no generic CTA, so put the CTA in the artwork or state it in the preceding narration/scene.
159
+ - Each Editor AI scene is 8 seconds. Request 3 scenes (about 24 seconds) for a concise social ad unless the brief needs a longer story.
160
+
153
161
  For editor projects, `video_url` may remain the last delivered MP4 after the timeline changes.
154
162
  It is current only when `ready:true` and `stale:false`. `stale:true` means the delivered render
155
163
  does not match the live timeline; regenerate the reported stale scenes/audio and render again.
package/dist/index.js CHANGED
@@ -29578,6 +29578,7 @@ function normalizeStatus(kind, slug, data) {
29578
29578
  const renderStale = latest.is_stale === true;
29579
29579
  const renderFresh = latest.is_stale === false;
29580
29580
  const stale = renderStale || staleSegments.length > 0 || audioStale;
29581
+ const autopilotContractIncomplete = autopilot === "completed" && d.autopilot_contract_satisfied === false && !stale;
29581
29582
  const renderActive = renderStatus === "pending" || renderStatus === "rendering" || renderStatus === "processing";
29582
29583
  const scenarioActive = d.scenario_status === "generating" || d.scenario_apply_status === "pending" || d.scenario_apply_status === "applying";
29583
29584
  const narrationActive = d.narration_status === "generating";
@@ -29617,7 +29618,7 @@ function normalizeStatus(kind, slug, data) {
29617
29618
  const batchRecoveryActive = autopilotFailed && batchActive;
29618
29619
  const childFailureActionable = !batchRecoveryActive && (!autopilotActive || !childWorkActive && !autopilotResumeGraceActive);
29619
29620
  const workActive = autopilotActive || childWorkActive;
29620
- const deliveredArtifactSupersedesOrchestrationFailure = renderStatus === "completed" && Boolean(videoUrl) && renderFresh && !stale && !workActive && failedSegments.length === 0 && !audioFailed;
29621
+ const deliveredArtifactSupersedesOrchestrationFailure = renderStatus === "completed" && Boolean(videoUrl) && renderFresh && !stale && !workActive && failedSegments.length === 0 && !audioFailed && !autopilotContractIncomplete;
29621
29622
  const ready = deliveredArtifactSupersedesOrchestrationFailure;
29622
29623
  const parentFailureActionable = autopilotFailed && !batchRecoveryActive;
29623
29624
  const renderFailed = renderStatus === "failed" && childFailureActionable;
@@ -29648,6 +29649,8 @@ function normalizeStatus(kind, slug, data) {
29648
29649
  stage = "audio_failed";
29649
29650
  else if (batchInsufficientCredits)
29650
29651
  stage = "insufficient_credits";
29652
+ else if (autopilotContractIncomplete)
29653
+ stage = "contract_incomplete";
29651
29654
  else if (activeSegments.length > 0)
29652
29655
  stage = "segment_processing";
29653
29656
  else if (batchActive)
@@ -29673,10 +29676,13 @@ function normalizeStatus(kind, slug, data) {
29673
29676
  terminal,
29674
29677
  ready,
29675
29678
  video_url: videoUrl,
29676
- 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),
29679
+ 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) ?? (autopilotContractIncomplete ? "Autopilot completed without every quoted deliverable. Resume Autopilot to repair the missing component." : null),
29677
29680
  stale,
29678
29681
  active_segment_ids: activeSegments.map((segment) => segment.id).filter((id) => typeof id === "number" || typeof id === "string"),
29679
- stale_segment_ids: staleSegments.map((segment) => segment.id).filter((id) => typeof id === "number" || typeof id === "string")
29682
+ stale_segment_ids: staleSegments.map((segment) => segment.id).filter((id) => typeof id === "number" || typeof id === "string"),
29683
+ ...typeof d.autopilot_contract_satisfied === "boolean" ? {
29684
+ autopilot_contract_satisfied: d.autopilot_contract_satisfied
29685
+ } : {}
29680
29686
  };
29681
29687
  }
29682
29688
  function startStateDiscriminator(kind, data) {
@@ -29700,16 +29706,16 @@ function idemKey(...parts) {
29700
29706
  return createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 32);
29701
29707
  }
29702
29708
  function makeVideoEditorCreateKey(a) {
29703
- return idemKey("make-editor", a.prompt, a.product_subject ?? "", a.project_intent ?? "", a.language ?? "en", a.aspect ?? "", a.voice_id ?? "", a.creative_format ?? "", a.visual_language ?? "", a.theme ?? "");
29709
+ return idemKey("make-editor", a.prompt, a.segments_count === undefined ? "" : String(a.segments_count), a.product_subject ?? "", a.project_intent ?? "", a.language ?? "en", a.aspect ?? "", a.voice_id ?? "", a.creative_format ?? "", a.visual_language ?? "", a.theme ?? "");
29704
29710
  }
29705
29711
  function makeVideoAutopilotKey(slug, a, startState) {
29706
- 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);
29712
+ return idemKey("autopilot", slug, a.prompt, a.segments_count === undefined ? "" : String(a.segments_count), a.product_subject ?? "", a.project_intent ?? "", a.language ?? "en", a.aspect ?? "", a.voice_id ?? "", a.creative_format ?? "", a.visual_language ?? "", a.theme ?? "", startState);
29707
29713
  }
29708
29714
  function editorAdCreateKey(a) {
29709
- 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));
29715
+ return idemKey("create-editor", a.product_prompt ?? "", a.segments_count === undefined ? "" : String(a.segments_count), 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));
29710
29716
  }
29711
29717
  function editorAdAutopilotKey(slug, a) {
29712
- 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));
29718
+ return idemKey("autopilot", slug, a.product_prompt ?? "", a.segments_count === undefined ? "" : String(a.segments_count), 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));
29713
29719
  }
29714
29720
  function sliderCreateKey(a) {
29715
29721
  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 ?? "");
@@ -30266,7 +30272,7 @@ function readStoredCredentials() {
30266
30272
  // package.json
30267
30273
  var package_default = {
30268
30274
  name: "@hubfluencer/mcp",
30269
- version: "0.14.0",
30275
+ version: "0.16.0",
30270
30276
  description: "Model Context Protocol server for Hubfluencer — let AI agents generate post-ready shorts and editor ads.",
30271
30277
  license: "MIT",
30272
30278
  author: "Monocursive <contact@monocursive.com>",
@@ -30992,6 +30998,38 @@ function assetSubscriptionErrorText(status, code, message) {
30992
30998
  return `${message} The reusable asset catalog requires an ACTIVE SUBSCRIPTION; ` + "this is a billing gate, not a scope or credit issue, so retrying won't help — " + "subscribe in the app first. (Generating videos/carousels does not require this.)";
30993
30999
  }
30994
31000
 
31001
+ // src/polling.ts
31002
+ async function pollWithFinalReconciliation({
31003
+ read,
31004
+ isTerminal: isTerminal2,
31005
+ budgetMs,
31006
+ intervalMs,
31007
+ onPoll,
31008
+ now = Date.now,
31009
+ sleep: sleep3 = (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds))
31010
+ }) {
31011
+ const deadline = now() + budgetMs;
31012
+ let value = await read(deadline);
31013
+ let pollNumber = 0;
31014
+ while (!isTerminal2(value)) {
31015
+ const remainingMs = deadline - now();
31016
+ if (remainingMs <= 0)
31017
+ break;
31018
+ await onPoll?.(value, ++pollNumber);
31019
+ await sleep3(Math.min(intervalMs, remainingMs));
31020
+ if (now() >= deadline)
31021
+ break;
31022
+ value = await read(deadline);
31023
+ }
31024
+ if (!isTerminal2(value)) {
31025
+ const finalReadDeadline = now() + Math.min(5000, Math.max(1000, intervalMs));
31026
+ try {
31027
+ value = await read(finalReadDeadline);
31028
+ } catch {}
31029
+ }
31030
+ return value;
31031
+ }
31032
+
30995
31033
  // src/series-calendar.ts
30996
31034
  var SERIES_FIELDS = [
30997
31035
  "brand_profile_id",
@@ -31590,6 +31628,8 @@ async function editorAutopilotQuote(client, slug, overrides = {}) {
31590
31628
  params.set("product_subject", overrides.product_subject);
31591
31629
  if (overrides.product_prompt !== undefined)
31592
31630
  params.set("product_prompt", overrides.product_prompt);
31631
+ if (overrides.segments_count !== undefined)
31632
+ params.set("segments_count", String(overrides.segments_count));
31593
31633
  if (overrides.restart)
31594
31634
  params.set("restart", "true");
31595
31635
  const query = params.toString();
@@ -31597,9 +31637,13 @@ async function editorAutopilotQuote(client, slug, overrides = {}) {
31597
31637
  const cost = asRecord(asRecord(response).data);
31598
31638
  const estimated_credits = typeof cost.total === "number" ? cost.total : null;
31599
31639
  const available_credits = typeof cost.available_credits === "number" ? cost.available_credits : null;
31640
+ const scene_count = typeof cost.scene_count === "number" ? cost.scene_count : null;
31641
+ const target_duration_seconds = typeof cost.target_duration_seconds === "number" ? cost.target_duration_seconds : null;
31600
31642
  return {
31601
31643
  estimated_credits,
31602
31644
  available_credits,
31645
+ scene_count,
31646
+ target_duration_seconds,
31603
31647
  affordable: estimated_credits !== null && available_credits !== null && available_credits >= estimated_credits
31604
31648
  };
31605
31649
  } catch (error2) {
@@ -31610,6 +31654,8 @@ async function editorAutopilotQuote(client, slug, overrides = {}) {
31610
31654
  return {
31611
31655
  estimated_credits: null,
31612
31656
  available_credits: null,
31657
+ scene_count: null,
31658
+ target_duration_seconds: null,
31613
31659
  affordable: false
31614
31660
  };
31615
31661
  }
@@ -31885,15 +31931,13 @@ AUTH: set HUBFLUENCER_API_TOKEN (Settings → Access tokens) or run \`npx -y @hu
31885
31931
  var server = new McpServer({ name: "hubfluencer", version: VERSION }, { instructions: SERVER_INSTRUCTIONS });
31886
31932
  var registerTool = server.registerTool.bind(server);
31887
31933
  async function pollToTerminal(client, kind, slug, extra, budgetMs, intervalMs) {
31888
- const deadline = Date.now() + budgetMs;
31889
- let status = await fetchStatus(client, kind, slug, deadline);
31890
- let n = 0;
31891
- while (!status.terminal && Date.now() + intervalMs <= deadline) {
31892
- await reportProgress(extra, ++n, `stage: ${status.stage}`);
31893
- await sleep4(intervalMs);
31894
- status = await fetchStatus(client, kind, slug, deadline);
31895
- }
31896
- return status;
31934
+ return pollWithFinalReconciliation({
31935
+ read: (deadline) => fetchStatus(client, kind, slug, deadline),
31936
+ isTerminal: (status) => status.terminal,
31937
+ budgetMs,
31938
+ intervalMs,
31939
+ onPoll: (status, pollNumber) => reportProgress(extra, pollNumber, `stage: ${status.stage}`)
31940
+ });
31897
31941
  }
31898
31942
  async function pollSegmentToTerminal(client, slug, segmentId, extra, deadline, intervalMs) {
31899
31943
  const sid = String(segmentId);
@@ -31928,6 +31972,7 @@ registerTool("make_video", {
31928
31972
  kind: exports_external.string().optional().describe("'short' (fast, 1 clip), 'editor' (multi-scene), or 'auto' (default — inferred)"),
31929
31973
  product_subject: exports_external.string().max(300).optional().describe('EDITOR only: the concrete product / main subject the video must be about (e.g. "Bordeaux red ' + 'wine, dark green bottle"). Hard-grounds the AI so it cannot invent an unrelated product. ' + "Recommended for ads."),
31930
31974
  project_intent: exports_external.enum(["social_ad", "creative_story"]).optional().describe("EDITOR only: social_ad for promotional content (product focus + CTA) or creative_story (default)."),
31975
+ segments_count: exports_external.number().int().min(3).max(10).optional().describe("EDITOR only: exact number of 8-second AI scenes (3–10). Use 3 for a concise ~24s ad."),
31931
31976
  language: exports_external.enum(LANGUAGES).optional().describe('Language (default "en"). Bare ISO-639-1 code only — no region/script subtag ' + '(use "pt", never "pt-BR"/"en-US"). Drives scenario, narration, voice, and captions.'),
31932
31977
  aspect: exports_external.string().optional().describe("Aspect ratio for editor: 9:16 (default), 16:9, or 1:1"),
31933
31978
  voice_id: exports_external.string().optional().describe("Preferred narration voice id for editor ads (see list_voices); shorts have no voiceover"),
@@ -31979,6 +32024,9 @@ registerTool("make_video", {
31979
32024
  return fail(kindErr);
31980
32025
  const requestedKind = args.kind === "short" || args.kind === "editor" ? args.kind : undefined;
31981
32026
  const kind = requestedKind ?? inferKind(args.prompt);
32027
+ if (kind !== "editor" && args.segments_count !== undefined) {
32028
+ return fail("segments_count is only supported for editor videos.");
32029
+ }
31982
32030
  const waitSeconds = Math.min(280, Math.max(10, args.max_wait_seconds ?? 240));
31983
32031
  const budgetMs = waitSeconds * 1000;
31984
32032
  if (args.star_rating !== undefined && (args.star_rating < 0 || args.star_rating > 5)) {
@@ -32031,6 +32079,7 @@ registerTool("make_video", {
32031
32079
  const created = await client.post("/editor", {
32032
32080
  language: args.language ?? "en",
32033
32081
  product_prompt: args.prompt,
32082
+ segments_count: args.segments_count,
32034
32083
  product_subject: args.product_subject,
32035
32084
  project_intent: args.project_intent,
32036
32085
  export_aspect_ratio: args.aspect,
@@ -32140,7 +32189,7 @@ registerTool("get_credits", {
32140
32189
  }));
32141
32190
  registerTool("list_voices", {
32142
32191
  title: "List voices",
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.",
32192
+ description: "Lists available narration voices with voice_id, preview metadata, and verified language codes. " + "Choose a voice whose languages includes the editor project's language, then pass its voice_id to " + "create_editor_ad / make_video / generate_voice. Shorts have no voiceover.",
32144
32193
  inputSchema: {},
32145
32194
  annotations: { title: "List voices", ...RO }
32146
32195
  }, tool(async (_args, client) => {
@@ -32337,7 +32386,7 @@ registerTool("update_short", {
32337
32386
  brand_name: exports_external.string().max(40).optional().describe("Brand/product display name (≤40 chars) shown big on the closing brand-lockup slate when no " + `poster is set. Server falls back to your brand profile's name when unset. Pass "" to remove it.`),
32338
32387
  end_card: exports_external.enum(["auto", "none"]).optional().describe('"auto" (default): end on the poster, or on a brand-lockup slate (brand_name + claim + CTA pill) ' + 'when no poster is set — a 14s render. "none": no end card at all (12s, for deliberate ' + "loop-style shorts)."),
32339
32388
  poster_includes_lockup: exports_external.boolean().optional().describe("Set true if your poster already contains brand name/CTA/badges — suppresses the overlaid " + "claim/CTA so they don't collide (default false)."),
32340
- short_logo_position: exports_external.enum(["top-left", "top-right", "bottom-left", "bottom-right"]).optional().describe("Corner for the deterministic brand-logo overlay (set the image with set_short_logo). Moving it is " + "a FREE re-render — logo fields never cost credits."),
32389
+ short_logo_position: exports_external.enum(["top-left", "top-right", "bottom-left", "bottom-right"]).optional().describe("Preferred corner for the deterministic brand-logo overlay (set the image with set_short_logo). " + "Forge moves it to a clear rail when text/CTA/chips occupy that corner. Moving it is a FREE " + "re-render — logo fields never cost credits."),
32341
32390
  short_logo_treatment: exports_external.enum(["throughout", "end_card", "none"]).optional().describe('When the brand logo shows: "throughout" the footage, only on the "end_card", or "none" to hide it. ' + "FREE re-render."),
32342
32391
  brand_profile_id: exports_external.number().int().optional().describe("Seed brand defaults — colors/font/CTA — from a saved brand profile (id from " + "list_brand_profiles). Best set at create_short time (that path also freezes a brand snapshot); " + "on update it sets the linked profile. Must be one of your own profiles.")
32343
32392
  },
@@ -32553,19 +32602,21 @@ registerTool("create_tracking_video", {
32553
32602
  }));
32554
32603
  registerTool("generate_short", {
32555
32604
  title: "Generate (render) a short",
32556
- description: "Deducts 15 credits and starts the render pipeline for an existing short — this RE-ROLLS the footage " + "and music (a fresh AI generation). For text/style/CTA/end-card tweaks on an already-generated short, " + "use rerender_short instead: it re-applies the overlay over the existing footage for 0 credits. " + "Safe to call again: a " + "duplicate while a render is in flight is reported as in-progress (no double charge), and a failed " + "short can be re-generated. If the draft's headline/subheadline/text_beats are all blank the server " + "auto-writes the headline + caption beats before rendering (pass skip_auto_text:true for a deliberately " + "bare clip). Then poll with get_status or wait_for_completion (kind=short).",
32605
+ description: "Deducts 15 credits and starts the render pipeline for an existing short — this RE-ROLLS the footage " + "and music (a fresh AI generation). For text/style/CTA/end-card tweaks on an already-generated short, " + "use rerender_short instead: it re-applies the overlay over the existing footage for 0 credits. " + "Safe to call again: a " + "duplicate while a render is in flight is reported as in-progress (no double charge), and a failed " + "short can be re-generated. If the draft's headline/subheadline/text_beats are all blank the server " + "auto-writes the headline + caption beats before rendering (pass skip_auto_text:true for a deliberately " + "bare clip). A transient 5xx is reconciled against the project state: accepted work is returned as " + "in-progress, while an unchanged draft is retried once. Then poll with get_status or " + "wait_for_completion (kind=short).",
32557
32606
  inputSchema: {
32558
32607
  slug: exports_external.string().describe("Short slug from create_short"),
32559
32608
  skip_auto_text: exports_external.boolean().optional().describe("When the draft has no headline/subheadline/text_beats the server auto-writes the copy; pass " + "true to skip that and render a deliberately bare clip (default false).")
32560
32609
  },
32561
32610
  annotations: { title: "Generate short", ...WRITE }
32562
32611
  }, tool(async (args, client) => {
32612
+ const submit = () => client.post(`/shorts/${args.slug}/generate`, args.skip_auto_text ? { skip_auto_text: true } : undefined);
32563
32613
  try {
32564
- const res = await client.post(`/shorts/${args.slug}/generate`, args.skip_auto_text ? { skip_auto_text: true } : undefined);
32614
+ const res = await submit();
32565
32615
  const status = normalizeStatus("short", args.slug, asRecord(res).data);
32566
32616
  return ok(status);
32567
32617
  } catch (e) {
32568
- if (e.status === 409) {
32618
+ const statusCode = e.status;
32619
+ if (statusCode === 409) {
32569
32620
  return ok({
32570
32621
  kind: "short",
32571
32622
  slug: args.slug,
@@ -32576,6 +32627,35 @@ registerTool("generate_short", {
32576
32627
  error: null
32577
32628
  });
32578
32629
  }
32630
+ if (statusCode !== undefined && statusCode >= 500) {
32631
+ let reconciled;
32632
+ try {
32633
+ reconciled = await fetchStatus(client, "short", args.slug);
32634
+ } catch (_reconcileError) {
32635
+ throw e;
32636
+ }
32637
+ if (reconciled.stage === "rendering")
32638
+ return ok(reconciled);
32639
+ if (reconciled.stage !== "draft")
32640
+ throw e;
32641
+ try {
32642
+ const retried = await submit();
32643
+ return ok(normalizeStatus("short", args.slug, asRecord(retried).data));
32644
+ } catch (retryError) {
32645
+ if (retryError.status === 409) {
32646
+ return ok({
32647
+ kind: "short",
32648
+ slug: args.slug,
32649
+ stage: "processing",
32650
+ terminal: false,
32651
+ ready: false,
32652
+ video_url: null,
32653
+ error: null
32654
+ });
32655
+ }
32656
+ throw retryError;
32657
+ }
32658
+ }
32579
32659
  throw e;
32580
32660
  }
32581
32661
  }));
@@ -32847,9 +32927,9 @@ registerTool("edit_slider_slide", {
32847
32927
  }));
32848
32928
  registerTool("create_editor_ad", {
32849
32929
  title: "Create, configure, and price a multi-scene editor ad",
32850
- 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.)",
32930
+ 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. Do not depend on the video model for exact readable " + "brand names, UI, signs, prices, or CTA copy; put essential copy in narration or deterministic Short/poster overlays. " + "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.)",
32851
32931
  inputSchema: {
32852
- product_prompt: exports_external.string().min(10).describe("Brief for the ad (min 10 chars)"),
32932
+ product_prompt: exports_external.string().min(10).describe("Creative brief for the ad (min 10 chars), not literal voiceover copy. Autopilot uses it to draft " + "narration. For exact spoken words, use the granular editor path and pass only read-aloud text to " + "set_narration_script."),
32853
32933
  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."),
32854
32934
  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)."),
32855
32935
  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."),
@@ -32858,6 +32938,7 @@ registerTool("create_editor_ad", {
32858
32938
  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.'),
32859
32939
  voice_id: exports_external.string().optional().describe("Preferred narration voice id (see list_voices); omit for the default voice"),
32860
32940
  export_aspect_ratio: exports_external.enum(["9:16", "16:9", "1:1"]).optional().describe('Aspect ratio (default "9:16")'),
32941
+ segments_count: exports_external.number().int().min(3).max(10).optional().describe("Exact number of 8-second AI scenes (3–10). Use 3 for a concise ~24s ad; omit to retain the server default."),
32861
32942
  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."),
32862
32943
  product_description: exports_external.string().max(500).optional().describe("Product description used to ground the scenario and narration when product_image_path is supplied."),
32863
32944
  closing_image_path: exports_external.string().optional().describe("Local closing-card image (.jpg/.jpeg/.png, ≤20 MiB)."),
@@ -32902,6 +32983,7 @@ registerTool("create_editor_ad", {
32902
32983
  const created = await client.post("/editor", {
32903
32984
  language: args.language ?? "en",
32904
32985
  product_prompt: args.product_prompt,
32986
+ segments_count: args.segments_count,
32905
32987
  product_subject: args.product_subject,
32906
32988
  project_intent: args.project_intent,
32907
32989
  creative_format: args.creative_format,
@@ -32948,7 +33030,9 @@ registerTool("create_editor_ad", {
32948
33030
  logoSettings.logo_duration_seconds = args.logo_duration_seconds;
32949
33031
  if (args.logo_path && Object.keys(logoSettings).length > 0)
32950
33032
  await client.patch(`/editor/${slug}/logo`, logoSettings);
32951
- const quote = await editorAutopilotQuote(client, slug);
33033
+ const quote = await editorAutopilotQuote(client, slug, {
33034
+ segments_count: args.segments_count
33035
+ });
32952
33036
  const overCap = args.max_credits !== undefined && quote.estimated_credits !== null && quote.estimated_credits > args.max_credits;
32953
33037
  if (args.max_credits === undefined || quote.estimated_credits === null || overCap || !quote.affordable) {
32954
33038
  const note = args.max_credits === undefined ? `Free draft configured. Estimated ${quote.estimated_credits ?? "?"} credits (have ${quote.available_credits ?? "?"}); nothing charged. Relaunch with start_autopilot({ slug: "${slug}", max_credits: ${quote.estimated_credits ?? "<approved cap>"} }).` : quote.estimated_credits === null ? "Pricing is temporarily unavailable, so the paid pipeline was not started. Retry the quote later." : overCap ? `Estimated ${quote.estimated_credits} credits exceeds max_credits ${args.max_credits}; nothing charged.` : `Not enough credits (needs ${quote.estimated_credits}, have ${quote.available_credits ?? "?"}); nothing charged.`;
@@ -32961,7 +33045,10 @@ registerTool("create_editor_ad", {
32961
33045
  next: "start_autopilot"
32962
33046
  });
32963
33047
  }
32964
- const started = await client.post(`/editor/${slug}/autopilot`, { max_credits: args.max_credits }, editorAdAutopilotKey(slug, args));
33048
+ const started = await client.post(`/editor/${slug}/autopilot`, {
33049
+ max_credits: args.max_credits,
33050
+ segments_count: args.segments_count
33051
+ }, editorAdAutopilotKey(slug, args));
32965
33052
  const status = normalizeStatus("editor", slug, asRecord(started).data);
32966
33053
  return ok({
32967
33054
  slug,
@@ -32986,6 +33073,7 @@ registerTool("start_autopilot", {
32986
33073
  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."),
32987
33074
  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."),
32988
33075
  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."),
33076
+ segments_count: exports_external.number().int().min(3).max(10).optional().describe("Optional exact number of 8-second AI scenes (3–10). Omit to keep the stored structural target."),
32989
33077
  max_credits: exports_external.number().int().nonnegative().optional().describe("Explicit spend cap. Omit to quote only; no credits are charged."),
32990
33078
  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.")
32991
33079
  },
@@ -33006,6 +33094,8 @@ registerTool("start_autopilot", {
33006
33094
  overrides.product_subject = args.product_subject;
33007
33095
  if (args.product_prompt !== undefined)
33008
33096
  overrides.product_prompt = args.product_prompt;
33097
+ if (args.segments_count !== undefined)
33098
+ overrides.segments_count = args.segments_count;
33009
33099
  if (args.restart)
33010
33100
  overrides.restart = true;
33011
33101
  const quote = await editorAutopilotQuote(client, slug, overrides);
@@ -33628,7 +33718,7 @@ registerTool("generate_voice", {
33628
33718
  }));
33629
33719
  registerTool("generate_music", {
33630
33720
  title: "Generate the background music (5 credits)",
33631
- description: "Generates background music. Costs 5 credits. Each call produces a fresh take (music is non-deterministic) — call again for another. `description` is an optional" + "music-direction prompt (≤1200 chars; longer is silently truncated server-side). 402 fails cleanly.",
33721
+ description: "Generates background music. Costs 5 credits. Each call produces a fresh take (music is non-deterministic) — call again for another. `description` is an optional" + " music-direction prompt (≤1200 chars; longer is silently truncated server-side). `instruments` is an " + "optional array of up to 10 non-empty hints (≤80 chars each), never a comma-separated string. 402 fails cleanly.",
33632
33722
  inputSchema: {
33633
33723
  slug: exports_external.string().describe("Editor project slug"),
33634
33724
  description: exports_external.string().max(1200).optional().describe("Music direction prompt (≤1200 chars, truncated if longer)"),
@@ -33984,12 +34074,12 @@ registerTool("set_short_product", {
33984
34074
  }));
33985
34075
  registerTool("set_short_logo", {
33986
34076
  title: "Set a Short brand logo overlay (0 credits)",
33987
- description: "Uploads an exact brand logo for deterministic compositing over a Short. The image is never sent to the " + "AI video provider, so lettering is preserved. PNG/JPEG, ≤5 MiB, 0 credits. Choose a corner and whether " + "it appears throughout the footage or only on the end card.",
34077
+ description: "Uploads an exact brand logo for deterministic compositing over a Short. The image is never sent to the " + "AI video provider, so lettering is preserved. PNG/JPEG, ≤5 MiB, 0 credits. Choose a corner and whether " + "it appears throughout the footage or only on the end card. The corner is a preference: Forge moves the " + "logo to a clear rail when headline/caption/CTA/chip overlays occupy it. Prefer a tight transparent PNG " + "wordmark; use square/full-bleed artwork as a poster instead of a watermark.",
33988
34078
  inputSchema: {
33989
34079
  slug: exports_external.string().describe("Short slug"),
33990
34080
  file_path: exports_external.string().describe("Local logo image (.png/.jpg/.jpeg)"),
33991
- position: exports_external.enum(["top-left", "top-right", "bottom-left", "bottom-right"]).optional().describe("Logo corner (default top-right)"),
33992
- treatment: exports_external.enum(["throughout", "end_card", "none"]).optional().describe("When the logo appears (default throughout)")
34081
+ position: exports_external.enum(["top-left", "top-right", "bottom-left", "bottom-right"]).optional().describe("Preferred logo corner (default top-right; auto-relocated to avoid text)"),
34082
+ treatment: exports_external.enum(["throughout", "end_card", "none"]).optional().describe("When the logo appears. Omit for automatic treatment: transparent wordmarks run throughout; opaque square artwork moves to the end card.")
33993
34083
  },
33994
34084
  annotations: { title: "Set Short logo", ...WRITE, idempotentHint: false }
33995
34085
  }, tool(async (args, client) => {
@@ -33997,7 +34087,7 @@ registerTool("set_short_logo", {
33997
34087
  const res = await client.post(`/shorts/${args.slug}/logo/confirm`, {
33998
34088
  s3_key,
33999
34089
  position: args.position ?? "top-right",
34000
- treatment: args.treatment ?? "throughout"
34090
+ ...args.treatment ? { treatment: args.treatment } : {}
34001
34091
  });
34002
34092
  return ok(asRecord(res).data ?? res);
34003
34093
  }));
@@ -34020,7 +34110,7 @@ registerTool("set_short_poster", {
34020
34110
  }));
34021
34111
  registerTool("get_status", {
34022
34112
  title: "Get generation status",
34023
- description: "Returns a normalized status {stage, terminal, ready, video_url, error} for a short, editor, " + "tracking, or slider project. ready:true means the post-ready MP4 is at video_url — except for a " + "slider (a carousel of stills, not a video): its video_url is always null, ready:true means every " + "slide is composited, and you read the per-slide image URLs with get_slider.",
34113
+ description: "Returns a normalized status {stage, terminal, ready, video_url, error} for a short, editor, " + "tracking, or slider project. ready:true means the post-ready MP4 is at video_url — except for a " + "slider (a carousel of stills, not a video): its video_url is always null, ready:true means every " + "slide is composited, and you read the per-slide image URLs with get_slider. During paid short " + "generation, segments_completed / segments_total and active_segment_positions report real clip progress.",
34024
34114
  inputSchema: { slug: exports_external.string(), kind: kindSchema },
34025
34115
  outputSchema: getStatusOutput,
34026
34116
  annotations: { title: "Get status", ...RO }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubfluencer/mcp",
3
- "version": "0.14.0",
3
+ "version": "0.16.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
@@ -289,6 +289,8 @@ export interface NormalizedStatus {
289
289
  active_segment_ids?: Array<number | string>;
290
290
  /** Editor only: completed current scene ids invalidated by continuity edits. */
291
291
  stale_segment_ids?: Array<number | string>;
292
+ /** Editor Autopilot only: server-verified quoted delivery contract. */
293
+ autopilot_contract_satisfied?: boolean;
292
294
  /** Short only (additive, absent on older APIs): the newest render row is a free re-render. */
293
295
  latest_render_free?: boolean;
294
296
  /**
@@ -515,6 +517,21 @@ export function normalizeStatus(
515
517
  // Legacy/null/omitted freshness is unknown and must never be promoted to ready.
516
518
  const renderFresh = latest.is_stale === false;
517
519
  const stale = renderStale || staleSegments.length > 0 || audioStale;
520
+ // A completed Autopilot run the API reports as NOT satisfying its delivery
521
+ // contract — gated on a FRESH timeline. The server recomputes
522
+ // autopilot_contract_satisfied LIVE for completed runs, folding in staleness,
523
+ // so any ordinary post-delivery edit (e.g. adding narration makes music stale)
524
+ // flips it false even though the run genuinely delivered. Gating on !stale
525
+ // keeps this signal to the real missing-component case — canonically a legacy
526
+ // "music-only" run where nothing is stale but voice/narration were never
527
+ // produced — and lets the stale/editing_required semantics win everywhere once
528
+ // an edit lands. Declared after `stale` (which it depends on); the raw
529
+ // autopilot_contract_satisfied wire field is still passed through verbatim
530
+ // below, only this derived stage/error signal is freshness-gated.
531
+ const autopilotContractIncomplete =
532
+ autopilot === "completed" &&
533
+ d.autopilot_contract_satisfied === false &&
534
+ !stale;
518
535
  // A render in flight is active work too — fold it into workActive so the
519
536
  // terminal computation below can never fire while a render is queued/running.
520
537
  // "pending" here is NOT an idle default: a video_results row only exists once
@@ -638,7 +655,8 @@ export function normalizeStatus(
638
655
  !stale &&
639
656
  !workActive &&
640
657
  failedSegments.length === 0 &&
641
- !audioFailed;
658
+ !audioFailed &&
659
+ !autopilotContractIncomplete;
642
660
  const ready = deliveredArtifactSupersedesOrchestrationFailure;
643
661
  const parentFailureActionable = autopilotFailed && !batchRecoveryActive;
644
662
  const renderFailed = renderStatus === "failed" && childFailureActionable;
@@ -698,6 +716,7 @@ export function normalizeStatus(
698
716
  stage = "segment_failed";
699
717
  else if (childFailureActionable && audioFailed) stage = "audio_failed";
700
718
  else if (batchInsufficientCredits) stage = "insufficient_credits";
719
+ else if (autopilotContractIncomplete) stage = "contract_incomplete";
701
720
  else if (activeSegments.length > 0) stage = "segment_processing";
702
721
  else if (batchActive) stage = "segment_generation";
703
722
  else if (autoRenderHandoffActive) stage = "rendering";
@@ -727,6 +746,13 @@ export function normalizeStatus(
727
746
  terminal,
728
747
  ready,
729
748
  video_url: videoUrl,
749
+ // Mirror the stage chain's precedence EXACTLY so the error text always
750
+ // matches the stage the caller sees. batch-retry, then the actionable
751
+ // parent/child failures, then insufficient-credits, and only last the
752
+ // contract-incomplete message — because a completed-autopilot editor with a
753
+ // later paused manual batch reports stage "batch_retry_required", so it must
754
+ // read the retry guidance, not "Resume Autopilot" (the one action
755
+ // start_autopilot refuses while a batch is paused).
730
756
  error: ready
731
757
  ? null
732
758
  : batchRetryRequired
@@ -737,6 +763,9 @@ export function normalizeStatus(
737
763
  actionableChildError ??
738
764
  (batchInsufficientCredits
739
765
  ? "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."
766
+ : null) ??
767
+ (autopilotContractIncomplete
768
+ ? "Autopilot completed without every quoted deliverable. Resume Autopilot to repair the missing component."
740
769
  : null)),
741
770
  stale,
742
771
  active_segment_ids: activeSegments
@@ -751,6 +780,12 @@ export function normalizeStatus(
751
780
  (id): id is number | string =>
752
781
  typeof id === "number" || typeof id === "string",
753
782
  ),
783
+ ...(typeof d.autopilot_contract_satisfied === "boolean"
784
+ ? {
785
+ autopilot_contract_satisfied:
786
+ d.autopilot_contract_satisfied as boolean,
787
+ }
788
+ : {}),
754
789
  };
755
790
  }
756
791
 
@@ -817,6 +852,7 @@ export function idemKey(...parts: string[]): string {
817
852
  /** make_video's editor draft-create key (`POST /editor`). */
818
853
  export interface MakeVideoEditorCreateArgs {
819
854
  prompt: string;
855
+ segments_count?: number;
820
856
  product_subject?: string;
821
857
  project_intent?: string;
822
858
  language?: string;
@@ -831,6 +867,7 @@ export function makeVideoEditorCreateKey(a: MakeVideoEditorCreateArgs): string {
831
867
  return idemKey(
832
868
  "make-editor",
833
869
  a.prompt,
870
+ a.segments_count === undefined ? "" : String(a.segments_count),
834
871
  a.product_subject ?? "",
835
872
  a.project_intent ?? "",
836
873
  a.language ?? "en",
@@ -857,6 +894,7 @@ export function makeVideoAutopilotKey(
857
894
  "autopilot",
858
895
  slug,
859
896
  a.prompt,
897
+ a.segments_count === undefined ? "" : String(a.segments_count),
860
898
  a.product_subject ?? "",
861
899
  a.project_intent ?? "",
862
900
  a.language ?? "en",
@@ -872,6 +910,7 @@ export function makeVideoAutopilotKey(
872
910
  /** create_editor_ad's draft-create and autopilot-start key argument set. */
873
911
  export interface EditorAdKeyArgs {
874
912
  product_prompt?: string;
913
+ segments_count?: number;
875
914
  product_subject?: string;
876
915
  project_intent?: string;
877
916
  language?: string;
@@ -906,6 +945,7 @@ export function editorAdCreateKey(a: EditorAdKeyArgs): string {
906
945
  return idemKey(
907
946
  "create-editor",
908
947
  a.product_prompt ?? "",
948
+ a.segments_count === undefined ? "" : String(a.segments_count),
909
949
  a.product_subject ?? "",
910
950
  a.project_intent ?? "",
911
951
  a.language ?? "en",
@@ -943,6 +983,7 @@ export function editorAdAutopilotKey(slug: string, a: EditorAdKeyArgs): string {
943
983
  "autopilot",
944
984
  slug,
945
985
  a.product_prompt ?? "",
986
+ a.segments_count === undefined ? "" : String(a.segments_count),
946
987
  a.product_subject ?? "",
947
988
  a.project_intent ?? "",
948
989
  a.language ?? "en",
package/src/index.ts CHANGED
@@ -101,6 +101,7 @@ import {
101
101
  validatePerformanceMetrics,
102
102
  validateReviewOutput,
103
103
  } from "./perf-review-assets.js";
104
+ import { pollWithFinalReconciliation } from "./polling.js";
104
105
  import {
105
106
  buildScheduledPostBody,
106
107
  calendarScopeErrorText,
@@ -161,11 +162,14 @@ async function editorAutopilotQuote(
161
162
  language?: string;
162
163
  product_subject?: string;
163
164
  product_prompt?: string;
165
+ segments_count?: number;
164
166
  restart?: boolean;
165
167
  } = {},
166
168
  ): Promise<{
167
169
  estimated_credits: number | null;
168
170
  available_credits: number | null;
171
+ scene_count: number | null;
172
+ target_duration_seconds: number | null;
169
173
  affordable: boolean;
170
174
  }> {
171
175
  try {
@@ -175,6 +179,8 @@ async function editorAutopilotQuote(
175
179
  params.set("product_subject", overrides.product_subject);
176
180
  if (overrides.product_prompt !== undefined)
177
181
  params.set("product_prompt", overrides.product_prompt);
182
+ if (overrides.segments_count !== undefined)
183
+ params.set("segments_count", String(overrides.segments_count));
178
184
  if (overrides.restart) params.set("restart", "true");
179
185
  const query = params.toString();
180
186
  const response = await client.get<{ data: unknown }>(
@@ -187,9 +193,17 @@ async function editorAutopilotQuote(
187
193
  typeof cost.available_credits === "number"
188
194
  ? cost.available_credits
189
195
  : null;
196
+ const scene_count =
197
+ typeof cost.scene_count === "number" ? cost.scene_count : null;
198
+ const target_duration_seconds =
199
+ typeof cost.target_duration_seconds === "number"
200
+ ? cost.target_duration_seconds
201
+ : null;
190
202
  return {
191
203
  estimated_credits,
192
204
  available_credits,
205
+ scene_count,
206
+ target_duration_seconds,
193
207
  affordable:
194
208
  estimated_credits !== null &&
195
209
  available_credits !== null &&
@@ -213,6 +227,8 @@ async function editorAutopilotQuote(
213
227
  return {
214
228
  estimated_credits: null,
215
229
  available_credits: null,
230
+ scene_count: null,
231
+ target_duration_seconds: null,
216
232
  affordable: false,
217
233
  };
218
234
  }
@@ -674,15 +690,14 @@ async function pollToTerminal(
674
690
  budgetMs: number,
675
691
  intervalMs: number,
676
692
  ): Promise<NormalizedStatus> {
677
- const deadline = Date.now() + budgetMs;
678
- let status = await fetchStatus(client, kind, slug, deadline);
679
- let n = 0;
680
- while (!status.terminal && Date.now() + intervalMs <= deadline) {
681
- await reportProgress(extra, ++n, `stage: ${status.stage}`);
682
- await sleep(intervalMs);
683
- status = await fetchStatus(client, kind, slug, deadline);
684
- }
685
- return status;
693
+ return pollWithFinalReconciliation({
694
+ read: (deadline) => fetchStatus(client, kind, slug, deadline),
695
+ isTerminal: (status) => status.terminal,
696
+ budgetMs,
697
+ intervalMs,
698
+ onPoll: (status, pollNumber) =>
699
+ reportProgress(extra, pollNumber, `stage: ${status.stage}`),
700
+ });
686
701
  }
687
702
 
688
703
  /**
@@ -799,6 +814,15 @@ registerTool(
799
814
  .describe(
800
815
  "EDITOR only: social_ad for promotional content (product focus + CTA) or creative_story (default).",
801
816
  ),
817
+ segments_count: z
818
+ .number()
819
+ .int()
820
+ .min(3)
821
+ .max(10)
822
+ .optional()
823
+ .describe(
824
+ "EDITOR only: exact number of 8-second AI scenes (3–10). Use 3 for a concise ~24s ad.",
825
+ ),
802
826
  language: z
803
827
  .enum(LANGUAGES)
804
828
  .optional()
@@ -978,6 +1002,7 @@ registerTool(
978
1002
  kind?: string;
979
1003
  product_subject?: string;
980
1004
  project_intent?: string;
1005
+ segments_count?: number;
981
1006
  language?: string;
982
1007
  aspect?: string;
983
1008
  voice_id?: string;
@@ -1022,6 +1047,9 @@ registerTool(
1022
1047
  const requestedKind =
1023
1048
  args.kind === "short" || args.kind === "editor" ? args.kind : undefined;
1024
1049
  const kind: Kind = requestedKind ?? inferKind(args.prompt);
1050
+ if (kind !== "editor" && args.segments_count !== undefined) {
1051
+ return fail("segments_count is only supported for editor videos.");
1052
+ }
1025
1053
  const waitSeconds = Math.min(
1026
1054
  280,
1027
1055
  Math.max(10, args.max_wait_seconds ?? 240),
@@ -1128,6 +1156,7 @@ registerTool(
1128
1156
  {
1129
1157
  language: args.language ?? "en",
1130
1158
  product_prompt: args.prompt,
1159
+ segments_count: args.segments_count,
1131
1160
  product_subject: args.product_subject,
1132
1161
  project_intent: args.project_intent,
1133
1162
  export_aspect_ratio: args.aspect,
@@ -1412,8 +1441,9 @@ registerTool(
1412
1441
  {
1413
1442
  title: "List voices",
1414
1443
  description:
1415
- "Lists available narration voices (id + name). Pass a voice id as voice_id to create_editor_ad / " +
1416
- "make_video to pick the narration voice for an editor ad. Shorts have no voiceover.",
1444
+ "Lists available narration voices with voice_id, preview metadata, and verified language codes. " +
1445
+ "Choose a voice whose languages includes the editor project's language, then pass its voice_id to " +
1446
+ "create_editor_ad / make_video / generate_voice. Shorts have no voiceover.",
1417
1447
  inputSchema: {},
1418
1448
  annotations: { title: "List voices", ...RO },
1419
1449
  },
@@ -2023,8 +2053,9 @@ registerTool(
2023
2053
  .enum(["top-left", "top-right", "bottom-left", "bottom-right"])
2024
2054
  .optional()
2025
2055
  .describe(
2026
- "Corner for the deterministic brand-logo overlay (set the image with set_short_logo). Moving it is " +
2027
- "a FREE re-render logo fields never cost credits.",
2056
+ "Preferred corner for the deterministic brand-logo overlay (set the image with set_short_logo). " +
2057
+ "Forge moves it to a clear rail when text/CTA/chips occupy that corner. Moving it is a FREE " +
2058
+ "re-render — logo fields never cost credits.",
2028
2059
  ),
2029
2060
  short_logo_treatment: z
2030
2061
  .enum(["throughout", "end_card", "none"])
@@ -2541,7 +2572,9 @@ registerTool(
2541
2572
  "duplicate while a render is in flight is reported as in-progress (no double charge), and a failed " +
2542
2573
  "short can be re-generated. If the draft's headline/subheadline/text_beats are all blank the server " +
2543
2574
  "auto-writes the headline + caption beats before rendering (pass skip_auto_text:true for a deliberately " +
2544
- "bare clip). Then poll with get_status or wait_for_completion (kind=short).",
2575
+ "bare clip). A transient 5xx is reconciled against the project state: accepted work is returned as " +
2576
+ "in-progress, while an unchanged draft is retried once. Then poll with get_status or " +
2577
+ "wait_for_completion (kind=short).",
2545
2578
  inputSchema: {
2546
2579
  slug: z.string().describe("Short slug from create_short"),
2547
2580
  skip_auto_text: z
@@ -2563,17 +2596,21 @@ registerTool(
2563
2596
  // of a failed short. Double-charge is prevented server-side — the batch
2564
2597
  // claim's conditional update rolls back the losing transaction's credit
2565
2598
  // deduction — and a 409 means a render is already running.
2566
- try {
2567
- const res = await client.post<{ data: unknown }>(
2599
+ const submit = () =>
2600
+ client.post<{ data: unknown }>(
2568
2601
  `/shorts/${args.slug}/generate`,
2569
2602
  // Only send a body when the caller opts out of server auto-copy —
2570
2603
  // the bare POST stays byte-identical to the pre-0.8.2 wire shape.
2571
2604
  args.skip_auto_text ? { skip_auto_text: true } : undefined,
2572
2605
  );
2606
+
2607
+ try {
2608
+ const res = await submit();
2573
2609
  const status = normalizeStatus("short", args.slug, asRecord(res).data);
2574
2610
  return ok(status);
2575
2611
  } catch (e) {
2576
- if ((e as { status?: number }).status === 409) {
2612
+ const statusCode = (e as { status?: number }).status;
2613
+ if (statusCode === 409) {
2577
2614
  return ok({
2578
2615
  kind: "short",
2579
2616
  slug: args.slug,
@@ -2584,6 +2621,47 @@ registerTool(
2584
2621
  error: null,
2585
2622
  });
2586
2623
  }
2624
+
2625
+ if (statusCode !== undefined && statusCode >= 500) {
2626
+ let reconciled: NormalizedStatus;
2627
+ try {
2628
+ reconciled = await fetchStatus(client, "short", args.slug);
2629
+ } catch (_reconcileError) {
2630
+ // Preserve the original POST failure when the read-side check also
2631
+ // fails; the client must never blind-retry a possibly accepted debit.
2632
+ throw e;
2633
+ }
2634
+
2635
+ // Only the active render stage proves this POST crossed the paid
2636
+ // launch transaction. A previous completed/failed result can still be
2637
+ // visible after an early 5xx; returning it here would masquerade stale
2638
+ // work as the newly accepted generation.
2639
+ if (reconciled.stage === "rendering") return ok(reconciled);
2640
+ if (reconciled.stage !== "draft") throw e;
2641
+
2642
+ // A Short launch changes the durable factory out of draft in the
2643
+ // same transaction as the debit. Still-draft therefore proves the
2644
+ // failed response accepted no paid work, so one retry is safe.
2645
+ try {
2646
+ const retried = await submit();
2647
+ return ok(
2648
+ normalizeStatus("short", args.slug, asRecord(retried).data),
2649
+ );
2650
+ } catch (retryError) {
2651
+ if ((retryError as { status?: number }).status === 409) {
2652
+ return ok({
2653
+ kind: "short",
2654
+ slug: args.slug,
2655
+ stage: "processing",
2656
+ terminal: false,
2657
+ ready: false,
2658
+ video_url: null,
2659
+ error: null,
2660
+ });
2661
+ }
2662
+ throw retryError;
2663
+ }
2664
+ }
2587
2665
  throw e;
2588
2666
  }
2589
2667
  }),
@@ -3131,7 +3209,9 @@ registerTool(
3131
3209
  "wait_for_completion (kind=editor). Prefer make_video for a prompt-only one-shot. " +
3132
3210
  "A product image is analyzed into semantic project context and sent as a product-identity reference to every " +
3133
3211
  "generated Editor scene. The exact photo is also linked as the closing image unless a separate closing image " +
3134
- "was authored; generated fine label text can still vary. Scene prompts are the complete content contract: describe any people, " +
3212
+ "was authored; generated fine label text can still vary. Do not depend on the video model for exact readable " +
3213
+ "brand names, UI, signs, prices, or CTA copy; put essential copy in narration or deterministic Short/poster overlays. " +
3214
+ "Scene prompts are the complete content contract: describe any people, " +
3135
3215
  "products, visual continuity, absences, and composition directly in the brief. (Editor ads carry their copy " +
3136
3216
  "in-scene/narration, not as a title overlay; for an on-screen title/subtitle use a short with " +
3137
3217
  "headline/subheadline instead.)",
@@ -3139,7 +3219,11 @@ registerTool(
3139
3219
  product_prompt: z
3140
3220
  .string()
3141
3221
  .min(10)
3142
- .describe("Brief for the ad (min 10 chars)"),
3222
+ .describe(
3223
+ "Creative brief for the ad (min 10 chars), not literal voiceover copy. Autopilot uses it to draft " +
3224
+ "narration. For exact spoken words, use the granular editor path and pass only read-aloud text to " +
3225
+ "set_narration_script.",
3226
+ ),
3143
3227
  product_subject: z
3144
3228
  .string()
3145
3229
  .max(300)
@@ -3190,6 +3274,15 @@ registerTool(
3190
3274
  .enum(["9:16", "16:9", "1:1"])
3191
3275
  .optional()
3192
3276
  .describe('Aspect ratio (default "9:16")'),
3277
+ segments_count: z
3278
+ .number()
3279
+ .int()
3280
+ .min(3)
3281
+ .max(10)
3282
+ .optional()
3283
+ .describe(
3284
+ "Exact number of 8-second AI scenes (3–10). Use 3 for a concise ~24s ad; omit to retain the server default.",
3285
+ ),
3193
3286
  product_image_path: z
3194
3287
  .string()
3195
3288
  .optional()
@@ -3255,6 +3348,7 @@ registerTool(
3255
3348
  theme?: string;
3256
3349
  voice_id?: string;
3257
3350
  export_aspect_ratio?: string;
3351
+ segments_count?: number;
3258
3352
  product_image_path?: string;
3259
3353
  product_description?: string;
3260
3354
  closing_image_path?: string;
@@ -3331,6 +3425,7 @@ registerTool(
3331
3425
  {
3332
3426
  language: args.language ?? "en",
3333
3427
  product_prompt: args.product_prompt,
3428
+ segments_count: args.segments_count,
3334
3429
  product_subject: args.product_subject,
3335
3430
  project_intent: args.project_intent,
3336
3431
  creative_format: args.creative_format,
@@ -3409,7 +3504,9 @@ registerTool(
3409
3504
  if (args.logo_path && Object.keys(logoSettings).length > 0)
3410
3505
  await client.patch(`/editor/${slug}/logo`, logoSettings);
3411
3506
 
3412
- const quote = await editorAutopilotQuote(client, slug);
3507
+ const quote = await editorAutopilotQuote(client, slug, {
3508
+ segments_count: args.segments_count,
3509
+ });
3413
3510
  const overCap =
3414
3511
  args.max_credits !== undefined &&
3415
3512
  quote.estimated_credits !== null &&
@@ -3440,7 +3537,10 @@ registerTool(
3440
3537
 
3441
3538
  const started = await client.post<{ data: unknown }>(
3442
3539
  `/editor/${slug}/autopilot`,
3443
- { max_credits: args.max_credits },
3540
+ {
3541
+ max_credits: args.max_credits,
3542
+ segments_count: args.segments_count,
3543
+ },
3444
3544
  // Fold the create-affecting params into the start key so it tracks
3445
3545
  // inputs, not just the slug. Self-consistent per-tool only: this key
3446
3546
  // is compared against retries of *this* tool on *this* slug. The
@@ -3506,6 +3606,15 @@ registerTool(
3506
3606
  .describe(
3507
3607
  "Optional freeform brief to set/replace before launch. Omit to keep the stored brief; send blank to clear it.",
3508
3608
  ),
3609
+ segments_count: z
3610
+ .number()
3611
+ .int()
3612
+ .min(3)
3613
+ .max(10)
3614
+ .optional()
3615
+ .describe(
3616
+ "Optional exact number of 8-second AI scenes (3–10). Omit to keep the stored structural target.",
3617
+ ),
3509
3618
  max_credits: z
3510
3619
  .number()
3511
3620
  .int()
@@ -3531,6 +3640,7 @@ registerTool(
3531
3640
  language?: string;
3532
3641
  product_subject?: string;
3533
3642
  product_prompt?: string;
3643
+ segments_count?: number;
3534
3644
  max_credits?: number;
3535
3645
  restart?: boolean;
3536
3646
  },
@@ -3546,6 +3656,7 @@ registerTool(
3546
3656
  language?: string;
3547
3657
  product_subject?: string;
3548
3658
  product_prompt?: string;
3659
+ segments_count?: number;
3549
3660
  restart?: boolean;
3550
3661
  } = {};
3551
3662
  if (args.language !== undefined) overrides.language = args.language;
@@ -3553,6 +3664,8 @@ registerTool(
3553
3664
  overrides.product_subject = args.product_subject;
3554
3665
  if (args.product_prompt !== undefined)
3555
3666
  overrides.product_prompt = args.product_prompt;
3667
+ if (args.segments_count !== undefined)
3668
+ overrides.segments_count = args.segments_count;
3556
3669
  if (args.restart) overrides.restart = true;
3557
3670
 
3558
3671
  const quote = await editorAutopilotQuote(client, slug, overrides);
@@ -4806,7 +4919,8 @@ registerTool(
4806
4919
  title: "Generate the background music (5 credits)",
4807
4920
  description:
4808
4921
  "Generates background music. Costs 5 credits. Each call produces a fresh take (music is non-deterministic) — call again for another. `description` is an optional" +
4809
- "music-direction prompt (≤1200 chars; longer is silently truncated server-side). 402 fails cleanly.",
4922
+ " music-direction prompt (≤1200 chars; longer is silently truncated server-side). `instruments` is an " +
4923
+ "optional array of up to 10 non-empty hints (≤80 chars each), never a comma-separated string. 402 fails cleanly.",
4810
4924
  inputSchema: {
4811
4925
  slug: z.string().describe("Editor project slug"),
4812
4926
  description: z
@@ -5594,18 +5708,24 @@ registerTool(
5594
5708
  description:
5595
5709
  "Uploads an exact brand logo for deterministic compositing over a Short. The image is never sent to the " +
5596
5710
  "AI video provider, so lettering is preserved. PNG/JPEG, ≤5 MiB, 0 credits. Choose a corner and whether " +
5597
- "it appears throughout the footage or only on the end card.",
5711
+ "it appears throughout the footage or only on the end card. The corner is a preference: Forge moves the " +
5712
+ "logo to a clear rail when headline/caption/CTA/chip overlays occupy it. Prefer a tight transparent PNG " +
5713
+ "wordmark; use square/full-bleed artwork as a poster instead of a watermark.",
5598
5714
  inputSchema: {
5599
5715
  slug: z.string().describe("Short slug"),
5600
5716
  file_path: z.string().describe("Local logo image (.png/.jpg/.jpeg)"),
5601
5717
  position: z
5602
5718
  .enum(["top-left", "top-right", "bottom-left", "bottom-right"])
5603
5719
  .optional()
5604
- .describe("Logo corner (default top-right)"),
5720
+ .describe(
5721
+ "Preferred logo corner (default top-right; auto-relocated to avoid text)",
5722
+ ),
5605
5723
  treatment: z
5606
5724
  .enum(["throughout", "end_card", "none"])
5607
5725
  .optional()
5608
- .describe("When the logo appears (default throughout)"),
5726
+ .describe(
5727
+ "When the logo appears. Omit for automatic treatment: transparent wordmarks run throughout; opaque square artwork moves to the end card.",
5728
+ ),
5609
5729
  },
5610
5730
  annotations: { title: "Set Short logo", ...WRITE, idempotentHint: false },
5611
5731
  },
@@ -5630,7 +5750,7 @@ registerTool(
5630
5750
  {
5631
5751
  s3_key,
5632
5752
  position: args.position ?? "top-right",
5633
- treatment: args.treatment ?? "throughout",
5753
+ ...(args.treatment ? { treatment: args.treatment } : {}),
5634
5754
  },
5635
5755
  );
5636
5756
  return ok(asRecord(res).data ?? res);
@@ -5700,7 +5820,8 @@ registerTool(
5700
5820
  "Returns a normalized status {stage, terminal, ready, video_url, error} for a short, editor, " +
5701
5821
  "tracking, or slider project. ready:true means the post-ready MP4 is at video_url — except for a " +
5702
5822
  "slider (a carousel of stills, not a video): its video_url is always null, ready:true means every " +
5703
- "slide is composited, and you read the per-slide image URLs with get_slider.",
5823
+ "slide is composited, and you read the per-slide image URLs with get_slider. During paid short " +
5824
+ "generation, segments_completed / segments_total and active_segment_positions report real clip progress.",
5704
5825
  inputSchema: { slug: z.string(), kind: kindSchema },
5705
5826
  outputSchema: getStatusOutput,
5706
5827
  annotations: { title: "Get status", ...RO },
package/src/polling.ts ADDED
@@ -0,0 +1,51 @@
1
+ export interface PollOptions<T> {
2
+ read: (deadline: number) => Promise<T>;
3
+ isTerminal: (value: T) => boolean;
4
+ budgetMs: number;
5
+ intervalMs: number;
6
+ onPoll?: (value: T, pollNumber: number) => Promise<void>;
7
+ now?: () => number;
8
+ sleep?: (milliseconds: number) => Promise<void>;
9
+ }
10
+
11
+ /**
12
+ * Poll within a fixed budget, then perform one separately bounded
13
+ * reconciliation read. The final read closes the common race where work
14
+ * finishes during the last partial interval and the caller otherwise returns
15
+ * the stale pre-completion snapshot.
16
+ */
17
+ export async function pollWithFinalReconciliation<T>({
18
+ read,
19
+ isTerminal,
20
+ budgetMs,
21
+ intervalMs,
22
+ onPoll,
23
+ now = Date.now,
24
+ sleep = (milliseconds) =>
25
+ new Promise<void>((resolve) => setTimeout(resolve, milliseconds)),
26
+ }: PollOptions<T>): Promise<T> {
27
+ const deadline = now() + budgetMs;
28
+ let value = await read(deadline);
29
+ let pollNumber = 0;
30
+
31
+ while (!isTerminal(value)) {
32
+ const remainingMs = deadline - now();
33
+ if (remainingMs <= 0) break;
34
+ await onPoll?.(value, ++pollNumber);
35
+ await sleep(Math.min(intervalMs, remainingMs));
36
+ if (now() >= deadline) break;
37
+ value = await read(deadline);
38
+ }
39
+
40
+ if (!isTerminal(value)) {
41
+ const finalReadDeadline =
42
+ now() + Math.min(5_000, Math.max(1_000, intervalMs));
43
+ try {
44
+ value = await read(finalReadDeadline);
45
+ } catch {
46
+ // The normal timeout/resume contract uses the last successful state.
47
+ }
48
+ }
49
+
50
+ return value;
51
+ }