@hubfluencer/mcp 0.13.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
@@ -29510,6 +29510,13 @@ function normalizeStatus(kind, slug, data) {
29510
29510
  status.failure_attempt = d.failure_attempt;
29511
29511
  if (typeof d.generation_attempt === "number")
29512
29512
  status.generation_attempt = d.generation_attempt;
29513
+ if (typeof d.segments_completed === "number")
29514
+ status.segments_completed = d.segments_completed;
29515
+ if (typeof d.segments_total === "number")
29516
+ status.segments_total = d.segments_total;
29517
+ if (Array.isArray(d.active_segment_positions) && d.active_segment_positions.every((position) => typeof position === "number")) {
29518
+ status.active_segment_positions = d.active_segment_positions;
29519
+ }
29513
29520
  if (typeof d.latest_render_free === "boolean") {
29514
29521
  status.latest_render_free = d.latest_render_free;
29515
29522
  }
@@ -30259,7 +30266,7 @@ function readStoredCredentials() {
30259
30266
  // package.json
30260
30267
  var package_default = {
30261
30268
  name: "@hubfluencer/mcp",
30262
- version: "0.13.0",
30269
+ version: "0.15.0",
30263
30270
  description: "Model Context Protocol server for Hubfluencer — let AI agents generate post-ready shorts and editor ads.",
30264
30271
  license: "MIT",
30265
30272
  author: "Monocursive <contact@monocursive.com>",
@@ -30578,6 +30585,9 @@ var getStatusOutput = exports_external.object({
30578
30585
  failure_source: exports_external.string().optional().describe("Short only: worker/recovery layer that finalized the failure."),
30579
30586
  failure_attempt: exports_external.number().optional().describe("Short only: paid generation attempt number that failed."),
30580
30587
  generation_attempt: exports_external.number().optional().describe("Short only: current paid attempt; echo when cancelling."),
30588
+ segments_completed: exports_external.number().optional(),
30589
+ segments_total: exports_external.number().optional(),
30590
+ active_segment_positions: exports_external.array(exports_external.number()).optional(),
30581
30591
  latest_render_free: exports_external.boolean().optional(),
30582
30592
  last_free_rerender_failed: exports_external.boolean().optional()
30583
30593
  }).passthrough();
@@ -30598,6 +30608,9 @@ var waitForCompletionOutput = exports_external.object({
30598
30608
  failure_source: exports_external.string().optional().describe("Short only: worker/recovery layer that finalized the failure."),
30599
30609
  failure_attempt: exports_external.number().optional().describe("Short only: paid generation attempt number that failed."),
30600
30610
  generation_attempt: exports_external.number().optional().describe("Short only: current paid attempt; echo when cancelling."),
30611
+ segments_completed: exports_external.number().optional(),
30612
+ segments_total: exports_external.number().optional(),
30613
+ active_segment_positions: exports_external.array(exports_external.number()).optional(),
30601
30614
  latest_render_free: exports_external.boolean().optional(),
30602
30615
  last_free_rerender_failed: exports_external.boolean().optional(),
30603
30616
  saved_to: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
@@ -32127,10 +32140,33 @@ registerTool("get_credits", {
32127
32140
  }));
32128
32141
  registerTool("list_voices", {
32129
32142
  title: "List voices",
32130
- 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.",
32131
32144
  inputSchema: {},
32132
32145
  annotations: { title: "List voices", ...RO }
32133
- }, tool(async (_args, client) => ok(await client.get("/voices"))));
32146
+ }, tool(async (_args, client) => {
32147
+ const response = asRecord(await client.get("/voices"));
32148
+ const data = asRecord(response.data ?? response);
32149
+ const voices = Array.isArray(data.voices) ? data.voices.map(asRecord) : [];
32150
+ return ok({
32151
+ voices: voices.map((voice) => {
32152
+ const verified = Array.isArray(voice.verified_languages) ? voice.verified_languages.map(asRecord) : [];
32153
+ return {
32154
+ voice_id: voice.voice_id,
32155
+ name: voice.name,
32156
+ category: voice.category,
32157
+ description: voice.description,
32158
+ labels: voice.labels,
32159
+ preview_url: voice.preview_url,
32160
+ languages: [
32161
+ ...new Set(verified.map((language) => language.language).filter((language) => typeof language === "string"))
32162
+ ]
32163
+ };
32164
+ }),
32165
+ has_more: data.has_more,
32166
+ total_count: data.total_count,
32167
+ next_page_token: data.next_page_token
32168
+ });
32169
+ }));
32134
32170
  registerTool("list_projects", {
32135
32171
  title: "List recent projects",
32136
32172
  description: "Lists the caller's recent shorts and editor/video-factory projects, each normalized to " + "{slug, stage, terminal, ready, video_url}. By default the shorts listing is limited to " + "in-progress/failed; pass include_completed:true to also surface finished shorts — useful to " + "recover a lost slug. Capped to the most recent items per kind (use get_status for authoritative state).",
@@ -32301,7 +32337,7 @@ registerTool("update_short", {
32301
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.`),
32302
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)."),
32303
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)."),
32304
- 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."),
32305
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."),
32306
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.")
32307
32343
  },
@@ -32369,7 +32405,13 @@ registerTool("update_short", {
32369
32405
  const res = await client.patch(`/shorts/${args.slug}`, body);
32370
32406
  const data = asRecord(res).data ?? res;
32371
32407
  return ok({
32372
- ...asRecord(data),
32408
+ slug: args.slug,
32409
+ kind: "short",
32410
+ stage: asRecord(data).stage,
32411
+ status: asRecord(data).status,
32412
+ updated: body,
32413
+ end_card_mode: asRecord(data).end_card_mode,
32414
+ resolved_cta_text: asRecord(data).resolved_cta_text,
32373
32415
  next: "Saved. Only text/style/CTA/end-card/logo changed on an already-generated short? " + `rerender_short({ slug: "${args.slug}" }) applies it for 0 credits. Footage/music edits ` + "(prompt, product image, format, visual_language, theme, music_*, language) need " + "generate_short (15 credits)."
32374
32416
  });
32375
32417
  }));
@@ -32511,19 +32553,21 @@ registerTool("create_tracking_video", {
32511
32553
  }));
32512
32554
  registerTool("generate_short", {
32513
32555
  title: "Generate (render) a short",
32514
- 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).",
32515
32557
  inputSchema: {
32516
32558
  slug: exports_external.string().describe("Short slug from create_short"),
32517
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).")
32518
32560
  },
32519
32561
  annotations: { title: "Generate short", ...WRITE }
32520
32562
  }, tool(async (args, client) => {
32563
+ const submit = () => client.post(`/shorts/${args.slug}/generate`, args.skip_auto_text ? { skip_auto_text: true } : undefined);
32521
32564
  try {
32522
- const res = await client.post(`/shorts/${args.slug}/generate`, args.skip_auto_text ? { skip_auto_text: true } : undefined);
32565
+ const res = await submit();
32523
32566
  const status = normalizeStatus("short", args.slug, asRecord(res).data);
32524
32567
  return ok(status);
32525
32568
  } catch (e) {
32526
- if (e.status === 409) {
32569
+ const statusCode = e.status;
32570
+ if (statusCode === 409) {
32527
32571
  return ok({
32528
32572
  kind: "short",
32529
32573
  slug: args.slug,
@@ -32534,6 +32578,35 @@ registerTool("generate_short", {
32534
32578
  error: null
32535
32579
  });
32536
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
+ }
32537
32610
  throw e;
32538
32611
  }
32539
32612
  }));
@@ -32805,9 +32878,9 @@ registerTool("edit_slider_slide", {
32805
32878
  }));
32806
32879
  registerTool("create_editor_ad", {
32807
32880
  title: "Create, configure, and price a multi-scene editor ad",
32808
- 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.)",
32809
32882
  inputSchema: {
32810
- 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."),
32811
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."),
32812
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)."),
32813
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."),
@@ -33586,14 +33659,14 @@ registerTool("generate_voice", {
33586
33659
  }));
33587
33660
  registerTool("generate_music", {
33588
33661
  title: "Generate the background music (5 credits)",
33589
- 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.",
33590
33663
  inputSchema: {
33591
33664
  slug: exports_external.string().describe("Editor project slug"),
33592
33665
  description: exports_external.string().max(1200).optional().describe("Music direction prompt (≤1200 chars, truncated if longer)"),
33593
33666
  mood: exports_external.string().optional().describe("Optional mood hint"),
33594
33667
  genre: exports_external.string().optional().describe("Optional genre hint"),
33595
33668
  tempo: exports_external.string().optional().describe("Optional tempo hint"),
33596
- instruments: exports_external.string().optional().describe("Optional instruments hint")
33669
+ instruments: exports_external.array(exports_external.string().min(1).max(80)).max(10).optional().describe('Optional instrument hints, e.g. ["piano", "strings"]')
33597
33670
  },
33598
33671
  annotations: { title: "Generate music", ...WRITE, idempotentHint: false }
33599
33672
  }, tool(async (args, client) => {
@@ -33942,11 +34015,11 @@ registerTool("set_short_product", {
33942
34015
  }));
33943
34016
  registerTool("set_short_logo", {
33944
34017
  title: "Set a Short brand logo overlay (0 credits)",
33945
- 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.",
33946
34019
  inputSchema: {
33947
34020
  slug: exports_external.string().describe("Short slug"),
33948
34021
  file_path: exports_external.string().describe("Local logo image (.png/.jpg/.jpeg)"),
33949
- 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)"),
33950
34023
  treatment: exports_external.enum(["throughout", "end_card", "none"]).optional().describe("When the logo appears (default throughout)")
33951
34024
  },
33952
34025
  annotations: { title: "Set Short logo", ...WRITE, idempotentHint: false }
@@ -33978,7 +34051,7 @@ registerTool("set_short_poster", {
33978
34051
  }));
33979
34052
  registerTool("get_status", {
33980
34053
  title: "Get generation status",
33981
- 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.",
33982
34055
  inputSchema: { slug: exports_external.string(), kind: kindSchema },
33983
34056
  outputSchema: getStatusOutput,
33984
34057
  annotations: { title: "Get status", ...RO }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubfluencer/mcp",
3
- "version": "0.13.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/core.ts CHANGED
@@ -277,6 +277,12 @@ export interface NormalizedStatus {
277
277
  failure_attempt?: number;
278
278
  /** Short only: current paid attempt; echo when cancelling. */
279
279
  generation_attempt?: number;
280
+ /** Short only: completed/current Veo clip count for useful long-poll progress. */
281
+ segments_completed?: number;
282
+ /** Short only: total current Veo clips in the paid attempt. */
283
+ segments_total?: number;
284
+ /** Short only: clip positions that are pending or processing. */
285
+ active_segment_positions?: number[];
280
286
  /** Editor only: the newest delivered render no longer matches the live project. */
281
287
  stale?: boolean;
282
288
  /** Editor only: current scene ids that are still generating. */
@@ -365,6 +371,18 @@ export function normalizeStatus(
365
371
  status.failure_attempt = d.failure_attempt;
366
372
  if (typeof d.generation_attempt === "number")
367
373
  status.generation_attempt = d.generation_attempt;
374
+ if (typeof d.segments_completed === "number")
375
+ status.segments_completed = d.segments_completed;
376
+ if (typeof d.segments_total === "number")
377
+ status.segments_total = d.segments_total;
378
+ if (
379
+ Array.isArray(d.active_segment_positions) &&
380
+ d.active_segment_positions.every(
381
+ (position) => typeof position === "number",
382
+ )
383
+ ) {
384
+ status.active_segment_positions = d.active_segment_positions as number[];
385
+ }
368
386
  // Additive free-re-render bookkeeping (2026-07 API): surfaced only when
369
387
  // the server actually sends the booleans, so older payloads normalize
370
388
  // byte-identically. A failed free re-render does NOT flip stage to
package/src/index.ts CHANGED
@@ -1412,12 +1412,46 @@ 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
  },
1420
- tool(async (_args, client) => ok(await client.get("/voices"))),
1421
+ tool(async (_args, client) => {
1422
+ const response = asRecord(await client.get("/voices"));
1423
+ const data = asRecord(response.data ?? response);
1424
+ const voices = Array.isArray(data.voices) ? data.voices.map(asRecord) : [];
1425
+
1426
+ return ok({
1427
+ voices: voices.map((voice) => {
1428
+ const verified = Array.isArray(voice.verified_languages)
1429
+ ? voice.verified_languages.map(asRecord)
1430
+ : [];
1431
+ return {
1432
+ voice_id: voice.voice_id,
1433
+ name: voice.name,
1434
+ category: voice.category,
1435
+ description: voice.description,
1436
+ labels: voice.labels,
1437
+ preview_url: voice.preview_url,
1438
+ languages: [
1439
+ ...new Set(
1440
+ verified
1441
+ .map((language) => language.language)
1442
+ .filter(
1443
+ (language): language is string =>
1444
+ typeof language === "string",
1445
+ ),
1446
+ ),
1447
+ ],
1448
+ };
1449
+ }),
1450
+ has_more: data.has_more,
1451
+ total_count: data.total_count,
1452
+ next_page_token: data.next_page_token,
1453
+ });
1454
+ }),
1421
1455
  );
1422
1456
 
1423
1457
  // ── Projects ──────────────────────────────────────────────────────────────
@@ -1990,8 +2024,9 @@ registerTool(
1990
2024
  .enum(["top-left", "top-right", "bottom-left", "bottom-right"])
1991
2025
  .optional()
1992
2026
  .describe(
1993
- "Corner for the deterministic brand-logo overlay (set the image with set_short_logo). Moving it is " +
1994
- "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.",
1995
2030
  ),
1996
2031
  short_logo_treatment: z
1997
2032
  .enum(["throughout", "end_card", "none"])
@@ -2113,7 +2148,13 @@ registerTool(
2113
2148
  );
2114
2149
  const data = asRecord(res).data ?? res;
2115
2150
  return ok({
2116
- ...asRecord(data),
2151
+ slug: args.slug,
2152
+ kind: "short",
2153
+ stage: asRecord(data).stage,
2154
+ status: asRecord(data).status,
2155
+ updated: body,
2156
+ end_card_mode: asRecord(data).end_card_mode,
2157
+ resolved_cta_text: asRecord(data).resolved_cta_text,
2117
2158
  next:
2118
2159
  "Saved. Only text/style/CTA/end-card/logo changed on an already-generated short? " +
2119
2160
  `rerender_short({ slug: "${args.slug}" }) applies it for 0 credits. Footage/music edits ` +
@@ -2502,7 +2543,9 @@ registerTool(
2502
2543
  "duplicate while a render is in flight is reported as in-progress (no double charge), and a failed " +
2503
2544
  "short can be re-generated. If the draft's headline/subheadline/text_beats are all blank the server " +
2504
2545
  "auto-writes the headline + caption beats before rendering (pass skip_auto_text:true for a deliberately " +
2505
- "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).",
2506
2549
  inputSchema: {
2507
2550
  slug: z.string().describe("Short slug from create_short"),
2508
2551
  skip_auto_text: z
@@ -2524,17 +2567,21 @@ registerTool(
2524
2567
  // of a failed short. Double-charge is prevented server-side — the batch
2525
2568
  // claim's conditional update rolls back the losing transaction's credit
2526
2569
  // deduction — and a 409 means a render is already running.
2527
- try {
2528
- const res = await client.post<{ data: unknown }>(
2570
+ const submit = () =>
2571
+ client.post<{ data: unknown }>(
2529
2572
  `/shorts/${args.slug}/generate`,
2530
2573
  // Only send a body when the caller opts out of server auto-copy —
2531
2574
  // the bare POST stays byte-identical to the pre-0.8.2 wire shape.
2532
2575
  args.skip_auto_text ? { skip_auto_text: true } : undefined,
2533
2576
  );
2577
+
2578
+ try {
2579
+ const res = await submit();
2534
2580
  const status = normalizeStatus("short", args.slug, asRecord(res).data);
2535
2581
  return ok(status);
2536
2582
  } catch (e) {
2537
- if ((e as { status?: number }).status === 409) {
2583
+ const statusCode = (e as { status?: number }).status;
2584
+ if (statusCode === 409) {
2538
2585
  return ok({
2539
2586
  kind: "short",
2540
2587
  slug: args.slug,
@@ -2545,6 +2592,47 @@ registerTool(
2545
2592
  error: null,
2546
2593
  });
2547
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
+ }
2548
2636
  throw e;
2549
2637
  }
2550
2638
  }),
@@ -3092,7 +3180,9 @@ registerTool(
3092
3180
  "wait_for_completion (kind=editor). Prefer make_video for a prompt-only one-shot. " +
3093
3181
  "A product image is analyzed into semantic project context and sent as a product-identity reference to every " +
3094
3182
  "generated Editor scene. The exact photo is also linked as the closing image unless a separate closing image " +
3095
- "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, " +
3096
3186
  "products, visual continuity, absences, and composition directly in the brief. (Editor ads carry their copy " +
3097
3187
  "in-scene/narration, not as a title overlay; for an on-screen title/subtitle use a short with " +
3098
3188
  "headline/subheadline instead.)",
@@ -3100,7 +3190,11 @@ registerTool(
3100
3190
  product_prompt: z
3101
3191
  .string()
3102
3192
  .min(10)
3103
- .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
+ ),
3104
3198
  product_subject: z
3105
3199
  .string()
3106
3200
  .max(300)
@@ -4767,7 +4861,8 @@ registerTool(
4767
4861
  title: "Generate the background music (5 credits)",
4768
4862
  description:
4769
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" +
4770
- "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.",
4771
4866
  inputSchema: {
4772
4867
  slug: z.string().describe("Editor project slug"),
4773
4868
  description: z
@@ -4778,7 +4873,11 @@ registerTool(
4778
4873
  mood: z.string().optional().describe("Optional mood hint"),
4779
4874
  genre: z.string().optional().describe("Optional genre hint"),
4780
4875
  tempo: z.string().optional().describe("Optional tempo hint"),
4781
- instruments: z.string().optional().describe("Optional instruments hint"),
4876
+ instruments: z
4877
+ .array(z.string().min(1).max(80))
4878
+ .max(10)
4879
+ .optional()
4880
+ .describe('Optional instrument hints, e.g. ["piano", "strings"]'),
4782
4881
  },
4783
4882
  annotations: { title: "Generate music", ...WRITE, idempotentHint: false },
4784
4883
  },
@@ -4790,7 +4889,7 @@ registerTool(
4790
4889
  mood?: string;
4791
4890
  genre?: string;
4792
4891
  tempo?: string;
4793
- instruments?: string;
4892
+ instruments?: string[];
4794
4893
  },
4795
4894
  client,
4796
4895
  ) => {
@@ -5551,14 +5650,18 @@ registerTool(
5551
5650
  description:
5552
5651
  "Uploads an exact brand logo for deterministic compositing over a Short. The image is never sent to the " +
5553
5652
  "AI video provider, so lettering is preserved. PNG/JPEG, ≤5 MiB, 0 credits. Choose a corner and whether " +
5554
- "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.",
5555
5656
  inputSchema: {
5556
5657
  slug: z.string().describe("Short slug"),
5557
5658
  file_path: z.string().describe("Local logo image (.png/.jpg/.jpeg)"),
5558
5659
  position: z
5559
5660
  .enum(["top-left", "top-right", "bottom-left", "bottom-right"])
5560
5661
  .optional()
5561
- .describe("Logo corner (default top-right)"),
5662
+ .describe(
5663
+ "Preferred logo corner (default top-right; auto-relocated to avoid text)",
5664
+ ),
5562
5665
  treatment: z
5563
5666
  .enum(["throughout", "end_card", "none"])
5564
5667
  .optional()
@@ -5657,7 +5760,8 @@ registerTool(
5657
5760
  "Returns a normalized status {stage, terminal, ready, video_url, error} for a short, editor, " +
5658
5761
  "tracking, or slider project. ready:true means the post-ready MP4 is at video_url — except for a " +
5659
5762
  "slider (a carousel of stills, not a video): its video_url is always null, ready:true means every " +
5660
- "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.",
5661
5765
  inputSchema: { slug: z.string(), kind: kindSchema },
5662
5766
  outputSchema: getStatusOutput,
5663
5767
  annotations: { title: "Get status", ...RO },
@@ -95,6 +95,9 @@ export const getStatusOutput = z
95
95
  .number()
96
96
  .optional()
97
97
  .describe("Short only: current paid attempt; echo when cancelling."),
98
+ segments_completed: z.number().optional(),
99
+ segments_total: z.number().optional(),
100
+ active_segment_positions: z.array(z.number()).optional(),
98
101
  latest_render_free: z.boolean().optional(),
99
102
  last_free_rerender_failed: z.boolean().optional(),
100
103
  })
@@ -142,6 +145,9 @@ export const waitForCompletionOutput = z
142
145
  .number()
143
146
  .optional()
144
147
  .describe("Short only: current paid attempt; echo when cancelling."),
148
+ segments_completed: z.number().optional(),
149
+ segments_total: z.number().optional(),
150
+ active_segment_positions: z.array(z.number()).optional(),
145
151
  latest_render_free: z.boolean().optional(),
146
152
  last_free_rerender_failed: z.boolean().optional(),
147
153
  saved_to: z.union([z.string(), z.null()]).optional(),