@hubfluencer/mcp 0.14.0 → 0.15.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
@@ -113,7 +113,7 @@ instead draw a **free daily quota of 20 AI assists** (top up with `unlock_ai_ass
113
113
  | `generate_all_segments` | Render every not-yet-completed scene in order (continuity preserved); capped ~280s → re-run to continue. | **5 / segment** |
114
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 |
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**; choose its preferred corner and `throughout`/`end_card`/`none` (hide) treatment. Forge moves it away from text/CTA/chips. 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. Prefer `generate_narration` or Autopilot when exact wording is unnecessary.
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. 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
@@ -30266,7 +30266,7 @@ function readStoredCredentials() {
30266
30266
  // package.json
30267
30267
  var package_default = {
30268
30268
  name: "@hubfluencer/mcp",
30269
- version: "0.14.0",
30269
+ version: "0.15.0",
30270
30270
  description: "Model Context Protocol server for Hubfluencer — let AI agents generate post-ready shorts and editor ads.",
30271
30271
  license: "MIT",
30272
30272
  author: "Monocursive <contact@monocursive.com>",
@@ -32140,7 +32140,7 @@ registerTool("get_credits", {
32140
32140
  }));
32141
32141
  registerTool("list_voices", {
32142
32142
  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.",
32143
+ 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
32144
  inputSchema: {},
32145
32145
  annotations: { title: "List voices", ...RO }
32146
32146
  }, tool(async (_args, client) => {
@@ -32337,7 +32337,7 @@ registerTool("update_short", {
32337
32337
  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
32338
  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
32339
  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."),
32340
+ 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
32341
  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
32342
  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
32343
  },
@@ -32553,19 +32553,21 @@ registerTool("create_tracking_video", {
32553
32553
  }));
32554
32554
  registerTool("generate_short", {
32555
32555
  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).",
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). 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
32557
  inputSchema: {
32558
32558
  slug: exports_external.string().describe("Short slug from create_short"),
32559
32559
  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
32560
  },
32561
32561
  annotations: { title: "Generate short", ...WRITE }
32562
32562
  }, tool(async (args, client) => {
32563
+ const submit = () => client.post(`/shorts/${args.slug}/generate`, args.skip_auto_text ? { skip_auto_text: true } : undefined);
32563
32564
  try {
32564
- const res = await client.post(`/shorts/${args.slug}/generate`, args.skip_auto_text ? { skip_auto_text: true } : undefined);
32565
+ const res = await submit();
32565
32566
  const status = normalizeStatus("short", args.slug, asRecord(res).data);
32566
32567
  return ok(status);
32567
32568
  } catch (e) {
32568
- if (e.status === 409) {
32569
+ const statusCode = e.status;
32570
+ if (statusCode === 409) {
32569
32571
  return ok({
32570
32572
  kind: "short",
32571
32573
  slug: args.slug,
@@ -32576,6 +32578,35 @@ registerTool("generate_short", {
32576
32578
  error: null
32577
32579
  });
32578
32580
  }
32581
+ if (statusCode !== undefined && statusCode >= 500) {
32582
+ let reconciled;
32583
+ try {
32584
+ reconciled = await fetchStatus(client, "short", args.slug);
32585
+ } catch (_reconcileError) {
32586
+ throw e;
32587
+ }
32588
+ if (reconciled.stage === "rendering")
32589
+ return ok(reconciled);
32590
+ if (reconciled.stage !== "draft")
32591
+ throw e;
32592
+ try {
32593
+ const retried = await submit();
32594
+ return ok(normalizeStatus("short", args.slug, asRecord(retried).data));
32595
+ } catch (retryError) {
32596
+ if (retryError.status === 409) {
32597
+ return ok({
32598
+ kind: "short",
32599
+ slug: args.slug,
32600
+ stage: "processing",
32601
+ terminal: false,
32602
+ ready: false,
32603
+ video_url: null,
32604
+ error: null
32605
+ });
32606
+ }
32607
+ throw retryError;
32608
+ }
32609
+ }
32579
32610
  throw e;
32580
32611
  }
32581
32612
  }));
@@ -32847,9 +32878,9 @@ registerTool("edit_slider_slide", {
32847
32878
  }));
32848
32879
  registerTool("create_editor_ad", {
32849
32880
  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.)",
32881
+ 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
32882
  inputSchema: {
32852
- product_prompt: exports_external.string().min(10).describe("Brief for the ad (min 10 chars)"),
32883
+ 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
32884
  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
32885
  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
32886
  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."),
@@ -33628,7 +33659,7 @@ registerTool("generate_voice", {
33628
33659
  }));
33629
33660
  registerTool("generate_music", {
33630
33661
  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.",
33662
+ 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
33663
  inputSchema: {
33633
33664
  slug: exports_external.string().describe("Editor project slug"),
33634
33665
  description: exports_external.string().max(1200).optional().describe("Music direction prompt (≤1200 chars, truncated if longer)"),
@@ -33984,11 +34015,11 @@ registerTool("set_short_product", {
33984
34015
  }));
33985
34016
  registerTool("set_short_logo", {
33986
34017
  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.",
34018
+ 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
34019
  inputSchema: {
33989
34020
  slug: exports_external.string().describe("Short slug"),
33990
34021
  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)"),
34022
+ 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)"),
33992
34023
  treatment: exports_external.enum(["throughout", "end_card", "none"]).optional().describe("When the logo appears (default throughout)")
33993
34024
  },
33994
34025
  annotations: { title: "Set Short logo", ...WRITE, idempotentHint: false }
@@ -34020,7 +34051,7 @@ registerTool("set_short_poster", {
34020
34051
  }));
34021
34052
  registerTool("get_status", {
34022
34053
  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.",
34054
+ 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
34055
  inputSchema: { slug: exports_external.string(), kind: kindSchema },
34025
34056
  outputSchema: getStatusOutput,
34026
34057
  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.15.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/index.ts CHANGED
@@ -1412,8 +1412,9 @@ registerTool(
1412
1412
  {
1413
1413
  title: "List voices",
1414
1414
  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.",
1415
+ "Lists available narration voices with voice_id, preview metadata, and verified language codes. " +
1416
+ "Choose a voice whose languages includes the editor project's language, then pass its voice_id to " +
1417
+ "create_editor_ad / make_video / generate_voice. Shorts have no voiceover.",
1417
1418
  inputSchema: {},
1418
1419
  annotations: { title: "List voices", ...RO },
1419
1420
  },
@@ -2023,8 +2024,9 @@ registerTool(
2023
2024
  .enum(["top-left", "top-right", "bottom-left", "bottom-right"])
2024
2025
  .optional()
2025
2026
  .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.",
2027
+ "Preferred corner for the deterministic brand-logo overlay (set the image with set_short_logo). " +
2028
+ "Forge moves it to a clear rail when text/CTA/chips occupy that corner. Moving it is a FREE " +
2029
+ "re-render — logo fields never cost credits.",
2028
2030
  ),
2029
2031
  short_logo_treatment: z
2030
2032
  .enum(["throughout", "end_card", "none"])
@@ -2541,7 +2543,9 @@ registerTool(
2541
2543
  "duplicate while a render is in flight is reported as in-progress (no double charge), and a failed " +
2542
2544
  "short can be re-generated. If the draft's headline/subheadline/text_beats are all blank the server " +
2543
2545
  "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).",
2546
+ "bare clip). A transient 5xx is reconciled against the project state: accepted work is returned as " +
2547
+ "in-progress, while an unchanged draft is retried once. Then poll with get_status or " +
2548
+ "wait_for_completion (kind=short).",
2545
2549
  inputSchema: {
2546
2550
  slug: z.string().describe("Short slug from create_short"),
2547
2551
  skip_auto_text: z
@@ -2563,17 +2567,21 @@ registerTool(
2563
2567
  // of a failed short. Double-charge is prevented server-side — the batch
2564
2568
  // claim's conditional update rolls back the losing transaction's credit
2565
2569
  // deduction — and a 409 means a render is already running.
2566
- try {
2567
- const res = await client.post<{ data: unknown }>(
2570
+ const submit = () =>
2571
+ client.post<{ data: unknown }>(
2568
2572
  `/shorts/${args.slug}/generate`,
2569
2573
  // Only send a body when the caller opts out of server auto-copy —
2570
2574
  // the bare POST stays byte-identical to the pre-0.8.2 wire shape.
2571
2575
  args.skip_auto_text ? { skip_auto_text: true } : undefined,
2572
2576
  );
2577
+
2578
+ try {
2579
+ const res = await submit();
2573
2580
  const status = normalizeStatus("short", args.slug, asRecord(res).data);
2574
2581
  return ok(status);
2575
2582
  } catch (e) {
2576
- if ((e as { status?: number }).status === 409) {
2583
+ const statusCode = (e as { status?: number }).status;
2584
+ if (statusCode === 409) {
2577
2585
  return ok({
2578
2586
  kind: "short",
2579
2587
  slug: args.slug,
@@ -2584,6 +2592,47 @@ registerTool(
2584
2592
  error: null,
2585
2593
  });
2586
2594
  }
2595
+
2596
+ if (statusCode !== undefined && statusCode >= 500) {
2597
+ let reconciled: NormalizedStatus;
2598
+ try {
2599
+ reconciled = await fetchStatus(client, "short", args.slug);
2600
+ } catch (_reconcileError) {
2601
+ // Preserve the original POST failure when the read-side check also
2602
+ // fails; the client must never blind-retry a possibly accepted debit.
2603
+ throw e;
2604
+ }
2605
+
2606
+ // Only the active render stage proves this POST crossed the paid
2607
+ // launch transaction. A previous completed/failed result can still be
2608
+ // visible after an early 5xx; returning it here would masquerade stale
2609
+ // work as the newly accepted generation.
2610
+ if (reconciled.stage === "rendering") return ok(reconciled);
2611
+ if (reconciled.stage !== "draft") throw e;
2612
+
2613
+ // A Short launch changes the durable factory out of draft in the
2614
+ // same transaction as the debit. Still-draft therefore proves the
2615
+ // failed response accepted no paid work, so one retry is safe.
2616
+ try {
2617
+ const retried = await submit();
2618
+ return ok(
2619
+ normalizeStatus("short", args.slug, asRecord(retried).data),
2620
+ );
2621
+ } catch (retryError) {
2622
+ if ((retryError as { status?: number }).status === 409) {
2623
+ return ok({
2624
+ kind: "short",
2625
+ slug: args.slug,
2626
+ stage: "processing",
2627
+ terminal: false,
2628
+ ready: false,
2629
+ video_url: null,
2630
+ error: null,
2631
+ });
2632
+ }
2633
+ throw retryError;
2634
+ }
2635
+ }
2587
2636
  throw e;
2588
2637
  }
2589
2638
  }),
@@ -3131,7 +3180,9 @@ registerTool(
3131
3180
  "wait_for_completion (kind=editor). Prefer make_video for a prompt-only one-shot. " +
3132
3181
  "A product image is analyzed into semantic project context and sent as a product-identity reference to every " +
3133
3182
  "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, " +
3183
+ "was authored; generated fine label text can still vary. Do not depend on the video model for exact readable " +
3184
+ "brand names, UI, signs, prices, or CTA copy; put essential copy in narration or deterministic Short/poster overlays. " +
3185
+ "Scene prompts are the complete content contract: describe any people, " +
3135
3186
  "products, visual continuity, absences, and composition directly in the brief. (Editor ads carry their copy " +
3136
3187
  "in-scene/narration, not as a title overlay; for an on-screen title/subtitle use a short with " +
3137
3188
  "headline/subheadline instead.)",
@@ -3139,7 +3190,11 @@ registerTool(
3139
3190
  product_prompt: z
3140
3191
  .string()
3141
3192
  .min(10)
3142
- .describe("Brief for the ad (min 10 chars)"),
3193
+ .describe(
3194
+ "Creative brief for the ad (min 10 chars), not literal voiceover copy. Autopilot uses it to draft " +
3195
+ "narration. For exact spoken words, use the granular editor path and pass only read-aloud text to " +
3196
+ "set_narration_script.",
3197
+ ),
3143
3198
  product_subject: z
3144
3199
  .string()
3145
3200
  .max(300)
@@ -4806,7 +4861,8 @@ registerTool(
4806
4861
  title: "Generate the background music (5 credits)",
4807
4862
  description:
4808
4863
  "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.",
4864
+ " music-direction prompt (≤1200 chars; longer is silently truncated server-side). `instruments` is an " +
4865
+ "optional array of up to 10 non-empty hints (≤80 chars each), never a comma-separated string. 402 fails cleanly.",
4810
4866
  inputSchema: {
4811
4867
  slug: z.string().describe("Editor project slug"),
4812
4868
  description: z
@@ -5594,14 +5650,18 @@ registerTool(
5594
5650
  description:
5595
5651
  "Uploads an exact brand logo for deterministic compositing over a Short. The image is never sent to the " +
5596
5652
  "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.",
5653
+ "it appears throughout the footage or only on the end card. The corner is a preference: Forge moves the " +
5654
+ "logo to a clear rail when headline/caption/CTA/chip overlays occupy it. Prefer a tight transparent PNG " +
5655
+ "wordmark; use square/full-bleed artwork as a poster instead of a watermark.",
5598
5656
  inputSchema: {
5599
5657
  slug: z.string().describe("Short slug"),
5600
5658
  file_path: z.string().describe("Local logo image (.png/.jpg/.jpeg)"),
5601
5659
  position: z
5602
5660
  .enum(["top-left", "top-right", "bottom-left", "bottom-right"])
5603
5661
  .optional()
5604
- .describe("Logo corner (default top-right)"),
5662
+ .describe(
5663
+ "Preferred logo corner (default top-right; auto-relocated to avoid text)",
5664
+ ),
5605
5665
  treatment: z
5606
5666
  .enum(["throughout", "end_card", "none"])
5607
5667
  .optional()
@@ -5700,7 +5760,8 @@ registerTool(
5700
5760
  "Returns a normalized status {stage, terminal, ready, video_url, error} for a short, editor, " +
5701
5761
  "tracking, or slider project. ready:true means the post-ready MP4 is at video_url — except for a " +
5702
5762
  "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.",
5763
+ "slide is composited, and you read the per-slide image URLs with get_slider. During paid short " +
5764
+ "generation, segments_completed / segments_total and active_segment_positions report real clip progress.",
5704
5765
  inputSchema: { slug: z.string(), kind: kindSchema },
5705
5766
  outputSchema: getStatusOutput,
5706
5767
  annotations: { title: "Get status", ...RO },