@hubfluencer/mcp 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,7 +68,7 @@ claude mcp add hubfluencer --env HUBFLUENCER_API_TOKEN=YOUR_TOKEN -- npx -y @hub
68
68
 
69
69
  ## Tools
70
70
 
71
- 91 tools, grouped by workflow. Every tool prices its action in its own `description` — costs
71
+ 92 tools, grouped by workflow. Every tool prices its action in its own `description` — costs
72
72
  below are the ground truth. **"$0" means it spends no video credits**; planning/AI-helper tools
73
73
  instead draw a **free daily quota of 20 AI assists** (top up with `unlock_ai_assists`: 1 credit
74
74
  → +10). Reads need the `video:read` scope; writes need `video:generate`. Nothing here needs
@@ -79,9 +79,10 @@ instead draw a **free daily quota of 20 AI assists** (top up with `unlock_ai_ass
79
79
  | Tool | What it does | Cost |
80
80
  |---|---|---|
81
81
  | **`make_video`** | **One shot: prompt → finished MP4** (create → **price** → start → poll → download). `kind:"auto"` picks a multi-scene editor ad for ad/promo/story briefs, a short for simple ones (reported as `kind_inferred`). Pass `dry_run:true` to preview the cost without spending, or `max_credits` to cap it. | short **15**, editor ~**28** (`dry_run`/`max_credits` gate it) |
82
- | `create_short` | Create a short draft (12s vertical: two 6s AI segments + title overlay + music; 14s with an end-card poster). Set `headline`/`subheadline`/`music_vibe`/`visual_language` so it isn't bare. Optional opt-in conversion graphics: `offer_text`/`cta_text`/`badge_text`/`star_rating` (off unless you set them). | **0** |
83
- | `update_short` | Patch an existing short draft — brief (`product_prompt`), copy, styling, or the opt-in conversion graphics; only the fields you pass change. Clear one with `""` (`[]` for `text_beats`, `0` for `star_rating`). Re-run `generate_short` to apply. | **0** |
84
- | `generate_short` | Render an existing short. Safe to re-call (in-flight = reported, no double charge; a failed short can re-generate). | **15** |
82
+ | `create_short` | Create a short draft (12s vertical: two 6s AI segments + title overlay + music; 14s when it ends on a poster or brand-lockup end card). Set `headline` (≈4 words, ≤40 chars), `subheadline`, `music_vibe`, and an explicit `visual_language` (`kinetic_creator` is a strong default) — and **always `cta_text`** (a short without a CTA doesn't convert; blank `cta_text` → the server renders a neutral localized ask, e.g. "Learn more" — a truly CTA-less short is not currently supported). End-card fields: `closing_claim` (≤80), `brand_name` (≤40), `end_card` (`"auto"`\|`"none"`). Other opt-in conversion graphics: `offer_text`/`badge_text`/`star_rating` only what you can substantiate. | **0** |
83
+ | `update_short` | Patch an existing short draft — brief (`product_prompt`), copy, styling, end-card fields (`closing_claim`/`brand_name`/`poster_includes_lockup`/`end_card`), or the opt-in conversion graphics; only the fields you pass change. Clear one with `""` (`[]` for `text_beats`, `0` for `star_rating`). Apply text/style/CTA/end-card edits to a generated short free with `rerender_short`; footage/music edits need `generate_short`. | **0** |
84
+ | `generate_short` | Render an existing short — re-rolls the footage + music (fresh AI generation). If headline/subheadline/beats are all blank the server auto-writes the copy first (`skip_auto_text:true` for a deliberately bare clip). Safe to re-call (in-flight = reported, no double charge; a failed short can re-generate). | **15** |
85
+ | `rerender_short` | **FREE re-render** — applies the short's current text/style/CTA/end-card state over the already-paid footage + music. Use after `update_short` for copy/style/CTA tweaks; 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** |
85
86
  | `generate_short_text` | AI-draft editable headline / subheadline / caption beats for a short. | 1 assist |
86
87
  | `create_editor_ad` | Create an editor project **and** run autopilot end-to-end. Style it with `creative_format` + `visual_language` + `theme`. Cast modes need prerequisite assets, so configure them through `create_editor_draft` instead. | credits (est. first) |
87
88
  | `start_autopilot` | Run autopilot on an **existing** editor draft (resume a `make_video` `dry_run`, or after a top-up). | credits |
@@ -127,7 +128,7 @@ Reads are confined to `HUBFLUENCER_INPUT_DIR`. All $0.
127
128
  | `set_closing_image` | Set the ~2s end-card image (upload a file, or reuse the product image with `from_product:true`). |
128
129
  | `set_logo` | Upload a brand logo overlay (editor only; optional treatment/position/duration). |
129
130
  | `set_short_product` | Attach a product image to a **short**. |
130
- | `set_short_poster` | Set a short's end-card poster (extends the render 12s → 14s). |
131
+ | `set_short_poster` | Set a short's end-card poster (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. |
131
132
 
132
133
  ### Status & delivery
133
134
 
@@ -221,6 +222,7 @@ edit are **0 credits** — you only pay to generate/render.
221
222
  |---|---|
222
223
  | Any draft create / edit / reorder | 0 |
223
224
  | Short render | **15** |
225
+ | Short re-render (`rerender_short` — text/style/CTA/end-card over existing footage) | **0** |
224
226
  | Editor AI scene (`generate_segment`) | **5** (4 each in a batch of ≥3) |
225
227
  | Editor scene re-roll (`regenerate_segment`, new version of a completed scene) | **4** |
226
228
  | Editor voice | **3** |
@@ -242,9 +244,18 @@ It prices the job and only charges if it's affordable (and within `max_credits`
242
244
  If it returns `terminal:false`, the render is still going — call `wait_for_completion` with the returned slug.
243
245
 
244
246
  **Granular (control/recovery):**
245
- `create_short` → optional `generate_short_text` → `generate_short` → `wait_for_completion {kind:"short"}` → `download_result`, or
247
+ `create_short` → optional `generate_hook_variations` (test hook angles free) / `generate_short_text` → `generate_short` → `wait_for_completion {kind:"short"}` → `download_result`, or
246
248
  `create_editor_ad` → `wait_for_completion {kind:"editor"}` → `download_result`.
247
249
 
250
+ **Iterate a short for free (never re-roll):** the first `generate_short` (15 credits) buys the footage +
251
+ music; after that, every text/style/CTA/end-card tweak re-renders over them for **0 credits** —
252
+ `update_short` (new hook/CTA/style) → `rerender_short` → compare → repeat → keep the winner. Only
253
+ footage/music-affecting edits (`product_prompt`, product image, `creative_format`, `visual_language`,
254
+ `theme`, `music_vibe`, `music_instruments`, `language`) need another `generate_short`. Edits that
255
+ add/remove the end card (poster, `end_card`) can shift the video between 12s and 14s; the pinned music
256
+ bed is faded to fit rather than regenerated — for perfectly synced music after such a change, use
257
+ `generate_short`.
258
+
248
259
  **Image carousel:**
249
260
  `create_slider({ prompt: "5 tips for…", mode: "ad_driven", slide_count: 5 })` → `generate_slider` →
250
261
  poll `get_slider` until `completed:true`, then download each `slides[].image_url` and post them with the
@@ -274,6 +285,11 @@ start a NEW project (omit `slug`, pass `video_path`).
274
285
  > Result URLs are presigned and expire (~24h). Download promptly. Publishing to
275
286
  > TikTok/Instagram requires a human-linked social account and is out of scope —
276
287
  > return the MP4 + a suggested caption instead.
288
+ >
289
+ > **Label synthetic media.** These videos are AI-generated: TikTok requires an
290
+ > AI-generated content label (and attaches C2PA credentials since 2025), and Meta
291
+ > requires disclosure for AI-generated content in paid ads. Remind the user to
292
+ > enable the platform's AI label when posting.
277
293
 
278
294
  ## Troubleshooting
279
295
 
package/dist/index.js CHANGED
@@ -29489,7 +29489,7 @@ function normalizeStatus(kind, slug, data) {
29489
29489
  const videoUrl = latest.video_url ?? null;
29490
29490
  if (kind === "short") {
29491
29491
  const stage = d.stage ?? "unknown";
29492
- return {
29492
+ const status = {
29493
29493
  kind,
29494
29494
  slug,
29495
29495
  stage,
@@ -29498,6 +29498,13 @@ function normalizeStatus(kind, slug, data) {
29498
29498
  video_url: videoUrl,
29499
29499
  error: d.error_message ?? null
29500
29500
  };
29501
+ if (typeof d.latest_render_free === "boolean") {
29502
+ status.latest_render_free = d.latest_render_free;
29503
+ }
29504
+ if (typeof d.last_free_rerender_failed === "boolean") {
29505
+ status.last_free_rerender_failed = d.last_free_rerender_failed;
29506
+ }
29507
+ return status;
29501
29508
  }
29502
29509
  if (kind === "tracking") {
29503
29510
  const result = asRecord(d.result);
@@ -30120,7 +30127,7 @@ function readStoredCredentials() {
30120
30127
  // package.json
30121
30128
  var package_default = {
30122
30129
  name: "@hubfluencer/mcp",
30123
- version: "0.8.1",
30130
+ version: "0.8.2",
30124
30131
  description: "Model Context Protocol server for Hubfluencer — let AI agents generate post-ready shorts and editor ads.",
30125
30132
  license: "MIT",
30126
30133
  author: "Monocursive <contact@monocursive.com>",
@@ -30429,7 +30436,9 @@ var getStatusOutput = exports_external.object({
30429
30436
  terminal: exports_external.boolean().optional(),
30430
30437
  ready: exports_external.boolean().optional(),
30431
30438
  video_url: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30432
- error: exports_external.union([exports_external.string(), exports_external.null()]).optional()
30439
+ error: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30440
+ latest_render_free: exports_external.boolean().optional(),
30441
+ last_free_rerender_failed: exports_external.boolean().optional()
30433
30442
  }).passthrough();
30434
30443
  var waitForCompletionOutput = exports_external.object({
30435
30444
  kind: exports_external.string().optional(),
@@ -30439,6 +30448,8 @@ var waitForCompletionOutput = exports_external.object({
30439
30448
  ready: exports_external.boolean().optional(),
30440
30449
  video_url: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30441
30450
  error: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30451
+ latest_render_free: exports_external.boolean().optional(),
30452
+ last_free_rerender_failed: exports_external.boolean().optional(),
30442
30453
  saved_to: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30443
30454
  timed_out: exports_external.boolean().optional()
30444
30455
  }).passthrough();
@@ -30482,7 +30493,9 @@ var projectRow = exports_external.object({
30482
30493
  terminal: exports_external.boolean().optional(),
30483
30494
  ready: exports_external.boolean().optional(),
30484
30495
  video_url: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30485
- error: exports_external.union([exports_external.string(), exports_external.null()]).optional()
30496
+ error: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30497
+ latest_render_free: exports_external.boolean().optional(),
30498
+ last_free_rerender_failed: exports_external.boolean().optional()
30486
30499
  }).passthrough();
30487
30500
  var listProjectsOutput = exports_external.object({
30488
30501
  shorts: exports_external.array(projectRow).optional(),
@@ -31467,7 +31480,7 @@ DELEGATE THE CONTENT OPERATION (the full loop — steps 1-3 are $0)
31467
31480
  8. RECURRING: create_series + plan_series_episodes → materialize_episode drafts each episode; get_series_dashboard shows upcoming/drafted/published lanes; mark_episode_posted closes a recurring show's loop.
31468
31481
 
31469
31482
  CREDITS (spent server-side; each tool prices before charging — see its description)
31470
- - Free (0 credits): every draft create/edit, reorder, all slider re-composites, and the whole onboard/plan/pack/materialize path (steps 1-3). get_credits shows the balance.
31483
+ - Free (0 credits): every draft create/edit, reorder, all slider re-composites, short re-renders (rerender_short — applies text/style/CTA/end-card edits over a short's already-paid footage), and the whole onboard/plan/pack/materialize path (steps 1-3). get_credits shows the balance.
31471
31484
  - Charged: short render 15; editor AI scene 5 (4 in a batch of ≥3); voice 3; music 5; editor render 0 (auto-charges ungenerated scenes); slider 1/slide; tracking render 1 (refunded on failure). make_video/create_* report the estimate first — pass dry_run to preview, max_credits to cap.
31472
31485
  - AI ASSISTS (all generate_*/enhance/suggest/plan/hook tools) draw a FREE daily quota (20/day), NOT credits. On 429, set auto_unlock:true to spend 1 credit for +10 assists (retried once), or write the copy yourself and use the set_* tools.
31473
31486
 
@@ -31520,7 +31533,7 @@ async function pollSegmentToTerminal(client, slug, segmentId, extra, budgetMs, i
31520
31533
  }
31521
31534
  registerTool("make_video", {
31522
31535
  title: "Make a video from a prompt (one shot)",
31523
- description: "The simplest path: give a prompt, get a finished MP4. Creates the project (free), PRICES it against " + "your live credit balance, then — only if it's affordable and within max_credits — starts generation, " + "polls to completion (emitting progress), and (if save_path is given) downloads the result. " + "Spends credits (15 for a short; a multi-scene editor ad ~28). Pass dry_run:true to preview the cost " + "WITHOUT charging (returns {estimated_credits, available_credits, slug}); pass max_credits to cap the " + "spend. kind defaults to 'auto' — a multi-scene editor ad for ad/promo/story briefs, a single-clip short " + "for simple/short ones; the chosen kind is reported back as kind_inferred. If it returns terminal=false " + "the render is still running — call wait_for_completion with the returned slug to finish. " + "BE PROACTIVE WITH BRANDING: pass headline (the on-screen TITLE) and subheadline (secondary title) plus " + "music_vibe so a one-shot short isn't bare. creative_format + visual_language apply to BOTH kinds (editor and " + "shorts); headline/subheadline/music_vibe/text_* and the conversion graphics are SHORTS-only and ignored for " + "editor; theme applies to editor (genre overlay) and is legacy-only for shorts (used when visual_language is unset). " + "For richer branding — a product image, brand logo, or " + "closing card — drive the granular path instead: create_short + set_short_product/set_short_poster, or " + "create_editor_draft + set_product/set_logo/set_closing_image, then start_autopilot/generate_short.",
31536
+ description: "The simplest path: give a prompt, get a finished MP4. Creates the project (free), PRICES it against " + "your live credit balance, then — only if it's affordable and within max_credits — starts generation, " + "polls to completion (emitting progress), and (if save_path is given) downloads the result. " + "Spends credits (15 for a short; a multi-scene editor ad ~28). Pass dry_run:true to preview the cost " + "WITHOUT charging (returns {estimated_credits, available_credits, slug}); pass max_credits to cap the " + "spend. kind defaults to 'auto' — a multi-scene editor ad for ad/promo/story briefs, a single-clip short " + "for simple/short ones; the chosen kind is reported back as kind_inferred. If it returns terminal=false " + "the render is still running — call wait_for_completion with the returned slug to finish. " + "BE PROACTIVE WITH BRANDING: pass headline (the on-screen TITLE) and subheadline (secondary title) plus " + "music_vibe so a one-shot short isn't bare. Always set cta_text — a short without a CTA doesn't convert " + "(plus offer_text only if there's a real deal). Note: blank cta_text → the server renders a neutral " + "localized ask (e.g. 'Learn more'); a truly CTA-less short is not currently supported. " + "Keep headline ≈4 words (≤40 chars). Set visual_language " + "explicitly (kinetic_creator is a strong default). If headline/subheadline/text_beats are left blank the " + "server auto-writes the headline + caption beats at generate time; pass skip_auto_text:true for a " + "deliberately bare clip. creative_format + visual_language apply to BOTH kinds (editor and " + "shorts); headline/subheadline/music_vibe/text_* and the conversion graphics are SHORTS-only and ignored for " + "editor; theme applies to editor (genre overlay) and is legacy-only for shorts (used when visual_language is unset). " + "For richer branding — a product image, brand logo, or " + "closing card — drive the granular path instead: create_short + set_short_product/set_short_poster, or " + "create_editor_draft + set_product/set_logo/set_closing_image, then start_autopilot/generate_short. " + "For a SHORT, iterating after the first render is FREE: update_short (copy/style/CTA/end-card) → " + "rerender_short re-renders over the already-paid footage for 0 credits.",
31524
31537
  inputSchema: {
31525
31538
  prompt: exports_external.string().describe("What the ad/video should be about (min 10 chars)"),
31526
31539
  kind: exports_external.string().optional().describe("'short' (fast, 1 clip), 'editor' (multi-scene), or 'auto' (default — inferred)"),
@@ -31529,16 +31542,17 @@ registerTool("make_video", {
31529
31542
  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.'),
31530
31543
  aspect: exports_external.string().optional().describe("Aspect ratio for editor: 9:16 (default), 16:9, or 1:1"),
31531
31544
  voice_id: exports_external.string().optional().describe("Preferred narration voice id for editor ads (see list_voices); shorts have no voiceover"),
31532
- headline: exports_external.string().optional().describe("SHORTS only: the on-screen TITLE overlay (160). Set this so the short isn't bare."),
31545
+ headline: exports_external.string().max(80).optional().describe("SHORTS only: the on-screen TITLE overlay. ≈4 words; 40 chars hits hardest; hard cap 80 — " + "longer hooks wrap past the safe area. Set this so the short isn't bare."),
31533
31546
  subheadline: exports_external.string().optional().describe("SHORTS only: the secondary title / supporting line (≤200)"),
31534
31547
  text_beats: exports_external.array(exports_external.string()).optional().describe("SHORTS only: caption beats shown sequentially instead of a static subheadline (≤8, each ≤120 chars)."),
31535
31548
  headline_color: exports_external.string().optional().describe("SHORTS only: headline hex color, e.g. #ffffff"),
31536
31549
  subheadline_color: exports_external.string().optional().describe("SHORTS only: subheadline hex color, e.g. #ffffff"),
31537
31550
  accent_color: exports_external.string().optional().describe("SHORTS only: 6-digit brand accent hex color, e.g. #09EFBE"),
31538
31551
  offer_text: exports_external.string().optional().describe("SHORTS only: optional offer chip, e.g. -40% (≤16 chars)"),
31539
- cta_text: exports_external.string().optional().describe("SHORTS only: optional CTA pill, e.g. Shop now (≤24 chars)"),
31540
- badge_text: exports_external.string().optional().describe("SHORTS only: optional badge stamp, e.g. BEST SELLER (≤24 chars)"),
31541
- star_rating: exports_external.number().int().min(0).max(5).optional().describe("SHORTS only: optional 0..5 star rating under the subheadline"),
31552
+ cta_text: exports_external.string().optional().describe("SHORTS only: CTA pill, e.g. Shop now (≤24 chars). Always set it — a short without a CTA doesn't convert."),
31553
+ badge_text: exports_external.string().optional().describe("SHORTS only: optional badge stamp, e.g. BEST SELLER (≤24 chars). Only badges you can " + "substantiate — fabricated social proof is deceptive advertising."),
31554
+ star_rating: exports_external.number().int().min(0).max(5).optional().describe("SHORTS only: optional 0..5 star rating under the subheadline. Only ratings you can " + "substantiate — fabricated social proof is deceptive advertising."),
31555
+ skip_auto_text: exports_external.boolean().optional().describe("SHORTS only: when headline/subheadline/text_beats are blank the server auto-writes the copy at " + "generate time; pass true to skip that and render a deliberately bare clip (default false)."),
31542
31556
  text_position: exports_external.enum(["top", "center", "bottom"]).optional().describe("SHORTS only: title overlay position (default bottom)"),
31543
31557
  text_animation: exports_external.enum([
31544
31558
  "auto",
@@ -31622,7 +31636,7 @@ registerTool("make_video", {
31622
31636
  creative_format: args.creative_format,
31623
31637
  visual_language: args.visual_language,
31624
31638
  theme: args.theme
31625
- }, idemKey("make-short", args.prompt, args.language ?? "", args.headline ?? "", args.subheadline ?? "", JSON.stringify(args.text_beats ?? []), args.headline_color ?? "", args.subheadline_color ?? "", args.accent_color ?? "", args.offer_text ?? "", args.cta_text ?? "", args.badge_text ?? "", String(args.star_rating ?? ""), args.text_position ?? "", args.text_animation ?? "", args.font_family ?? "", args.music_vibe ?? "", JSON.stringify(args.music_instruments ?? []), args.creative_format ?? "", args.visual_language ?? "", args.theme ?? ""));
31639
+ }, idemKey("make-short", args.prompt, args.language ?? "", args.headline ?? "", args.subheadline ?? "", JSON.stringify(args.text_beats ?? []), args.headline_color ?? "", args.subheadline_color ?? "", args.accent_color ?? "", args.offer_text ?? "", args.cta_text ?? "", args.badge_text ?? "", String(args.star_rating ?? ""), args.text_position ?? "", args.text_animation ?? "", args.font_family ?? "", args.music_vibe ?? "", JSON.stringify(args.music_instruments ?? []), args.creative_format ?? "", args.visual_language ?? "", args.theme ?? "", args.skip_auto_text ? "bare" : "auto-copy"));
31626
31640
  slug = created.data.slug;
31627
31641
  } else {
31628
31642
  const created = await client.post("/editor", {
@@ -31646,7 +31660,7 @@ registerTool("make_video", {
31646
31660
  estimated_credits = typeof cost.total === "number" ? cost.total : null;
31647
31661
  available_credits = typeof cost.available_credits === "number" ? cost.available_credits : null;
31648
31662
  } catch {}
31649
- const resume = kind === "short" ? `generate_short({ slug: "${slug}" })` : `start_autopilot({ slug: "${slug}" })`;
31663
+ const resume = kind === "short" ? `generate_short({ slug: "${slug}"${args.skip_auto_text ? ", skip_auto_text: true" : ""} })` : `start_autopilot({ slug: "${slug}" })`;
31650
31664
  const affordable = available_credits === null || estimated_credits === null || available_credits >= estimated_credits;
31651
31665
  const overCap = args.max_credits != null && estimated_credits != null && estimated_credits > args.max_credits;
31652
31666
  if (args.dry_run || overCap || !affordable) {
@@ -31668,7 +31682,7 @@ registerTool("make_video", {
31668
31682
  startState = startStateDiscriminator(kind, asRecord(cur).data);
31669
31683
  } catch {}
31670
31684
  if (kind === "short") {
31671
- await client.post(`/shorts/${slug}/generate`, undefined, `gen-short:${slug}:${startState}`);
31685
+ await client.post(`/shorts/${slug}/generate`, args.skip_auto_text ? { skip_auto_text: true } : undefined, `gen-short:${slug}:${startState}${args.skip_auto_text ? ":bare" : ""}`);
31672
31686
  } else {
31673
31687
  await client.post(`/editor/${slug}/autopilot`, undefined, makeVideoAutopilotKey(slug, args, startState));
31674
31688
  }
@@ -31754,11 +31768,11 @@ registerTool("list_projects", {
31754
31768
  }));
31755
31769
  registerTool("create_short", {
31756
31770
  title: "Create a short (draft)",
31757
- description: "Creates a short draft from a product prompt (min 10 chars). A short is a 12s vertical (two 6s AI " + "segments + an on-screen title overlay + music; 14s when an end-card poster is set). Returns the slug, " + "costs 0 credits. Follow with generate_short to render. " + "BE PROACTIVE: don't ship a bare clip — set a headline (the on-screen TITLE) and subheadline (secondary " + "title), and pick a music_vibe plus visual_language that fit the brand. To brand it further, attach a product image " + "(set_short_product) or an end-card poster (set_short_poster) before generate_short. (Shorts have no logo " + "overlay — that's an editor-only feature; use create_editor_ad for logo branding.)",
31771
+ description: "Creates a short draft from a product prompt (min 10 chars). A short is a 12s vertical (two 6s AI " + "segments + an on-screen title overlay + music; 14s when it ends on a poster or brand-lockup end card). " + "Returns the slug, costs 0 credits. Follow with generate_short to render. Iteration is FREE after that " + "first generate: the 15 credits bought the footage + music, and every later text/style/CTA/end-card " + "tweak re-renders over them for 0 credits (update_short → rerender_short). " + "BE PROACTIVE: don't ship a bare clip — set a headline (the on-screen TITLE) and subheadline (secondary " + "title), and pick a music_vibe plus visual_language that fit the brand. " + "Always set cta_text — a short without a CTA doesn't convert (plus offer_text only if there's a real " + "deal). Note: blank cta_text → the server renders a neutral localized ask (e.g. 'Learn more'); a truly " + "CTA-less short is not currently supported. " + "Keep headline ≈4 words (≤40 chars). Set visual_language explicitly (kinetic_creator is a strong " + "default). If headline/subheadline/text_beats are left blank, the server auto-writes the headline + " + "caption beats at generate time (pass skip_auto_text:true to generate_short for a deliberately bare clip). " + "To brand it further, attach a product image " + "(set_short_product) or an end-card poster (set_short_poster) before generate_short. (Shorts have no logo " + "overlay — that's an editor-only feature; use create_editor_ad for logo branding.)",
31758
31772
  inputSchema: {
31759
31773
  product_prompt: exports_external.string().min(10).describe("What the short should be about"),
31760
31774
  language: exports_external.enum(LANGUAGES).optional().describe('Language code, e.g. "en" (default). Bare ISO-639-1 code only — no region/script subtag like "en-US".'),
31761
- headline: exports_external.string().max(160).optional().describe("The on-screen TITLE composited over the short (poster text overlay). ≤160 chars. Set this."),
31775
+ headline: exports_external.string().max(80).optional().describe("The on-screen TITLE composited over the short (poster text overlay). ≈4 words; 40 chars hits " + "hardest; hard cap 80 — longer hooks wrap past the safe area. Set this."),
31762
31776
  subheadline: exports_external.string().max(200).optional().describe("The SECONDARY title / supporting line under the headline. ≤200 chars."),
31763
31777
  creative_format: exports_external.enum(CREATIVE_FORMATS).optional().describe("Optional structure: problem_solution, mistake_fix, myth_vs_reality, before_after, proof_demo, product_reveal. Omit for Auto."),
31764
31778
  visual_language: exports_external.enum(VISUAL_LANGUAGES).optional().describe("Visual language for Veo direction and render styling. Good default: kinetic_creator."),
@@ -31781,10 +31795,13 @@ registerTool("create_short", {
31781
31795
  subheadline_color: exports_external.string().optional().describe("Subheadline hex color, e.g. #ffffff"),
31782
31796
  accent_color: exports_external.string().optional().describe("6-digit brand accent hex color, e.g. #09EFBE"),
31783
31797
  offer_text: exports_external.string().max(16).optional().describe('Optional offer chip, e.g. "-40%" or "2 FOR 1"'),
31784
- cta_text: exports_external.string().max(24).optional().describe('Optional CTA pill, e.g. "Shop now"'),
31785
- badge_text: exports_external.string().max(24).optional().describe('Optional badge stamp, e.g. "BEST SELLER"'),
31786
- star_rating: exports_external.number().int().min(0).max(5).optional().describe("Optional 0..5 star rating under the subheadline"),
31798
+ cta_text: exports_external.string().max(24).optional().describe(`CTA pill, e.g. "Shop now". Always set it — a short without a CTA doesn't convert.`),
31799
+ badge_text: exports_external.string().max(24).optional().describe('Optional badge stamp, e.g. "BEST SELLER". Only badges you can substantiate — fabricated social ' + "proof is deceptive advertising."),
31800
+ star_rating: exports_external.number().int().min(0).max(5).optional().describe("Optional 0..5 star rating under the subheadline. Only ratings you can substantiate — fabricated " + "social proof is deceptive advertising."),
31787
31801
  text_beats: exports_external.array(exports_external.string().max(120)).max(8).optional().describe("Optional caption beats shown sequentially instead of the static subheadline."),
31802
+ closing_claim: exports_external.string().max(80).optional().describe("End-card claim shown on the closing slate (≤80 chars) — a rephrase of the promise, not a new " + "claim. Falls back to the headline when unset."),
31803
+ 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."),
31804
+ 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)."),
31788
31805
  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). Its creative format, visual language, font, accent, and default CTA fill " + "any blank short fields and a snapshot is frozen onto the short. Must be one of your own profiles.")
31789
31806
  },
31790
31807
  outputSchema: createDraftOutput,
@@ -31831,23 +31848,29 @@ registerTool("create_short", {
31831
31848
  body.star_rating = args.star_rating;
31832
31849
  if (args.text_beats !== undefined)
31833
31850
  body.text_beats = args.text_beats;
31851
+ if (args.closing_claim !== undefined)
31852
+ body.closing_claim = args.closing_claim;
31853
+ if (args.brand_name !== undefined)
31854
+ body.brand_name = args.brand_name;
31855
+ if (args.end_card !== undefined)
31856
+ body.end_card = args.end_card;
31834
31857
  if (args.brand_profile_id !== undefined)
31835
31858
  body.brand_profile_id = args.brand_profile_id;
31836
- const res = await client.post("/shorts", body, idemKey("create-short", args.product_prompt, args.language ?? "", args.headline ?? "", args.subheadline ?? "", args.creative_format ?? "", args.visual_language ?? "", args.theme ?? "", args.music_vibe ?? "", JSON.stringify(args.music_instruments ?? []), args.text_position ?? "", args.text_animation ?? "", args.font_family ?? "", args.headline_color ?? "", args.subheadline_color ?? "", args.accent_color ?? "", args.offer_text ?? "", args.cta_text ?? "", args.badge_text ?? "", String(args.star_rating ?? ""), JSON.stringify(args.text_beats ?? []), String(args.brand_profile_id ?? "")));
31859
+ const res = await client.post("/shorts", body, idemKey("create-short", args.product_prompt, args.language ?? "", args.headline ?? "", args.subheadline ?? "", args.creative_format ?? "", args.visual_language ?? "", args.theme ?? "", args.music_vibe ?? "", JSON.stringify(args.music_instruments ?? []), args.text_position ?? "", args.text_animation ?? "", args.font_family ?? "", args.headline_color ?? "", args.subheadline_color ?? "", args.accent_color ?? "", args.offer_text ?? "", args.cta_text ?? "", args.badge_text ?? "", String(args.star_rating ?? ""), JSON.stringify(args.text_beats ?? []), args.closing_claim ?? "", args.brand_name ?? "", args.end_card ?? "", String(args.brand_profile_id ?? "")));
31837
31860
  return ok({
31838
31861
  slug: res.data.slug,
31839
31862
  kind: "short",
31840
- next: "set_short_product / set_short_poster (optional branding), then generate_short"
31863
+ next: "set_short_product / set_short_poster (optional branding); generate_hook_variations to test hook angles before you spend; then generate_short. After that first render, hook/CTA/style iteration is free: update_short → rerender_short (0 credits)."
31841
31864
  });
31842
31865
  }));
31843
31866
  registerTool("update_short", {
31844
31867
  title: "Update a short draft (0 credits)",
31845
- description: "Patches an existing short draft — only the fields you pass change; omitted fields are left untouched. " + "0 credits, no re-render (call generate_short afterwards to apply). Use this to edit the brief, copy, " + "styling, or the optional conversion graphics after create_short. " + "CLEARING a field: the conversion graphics (offer_text, cta_text, badge_text, star_rating) and " + 'text_beats are OPT-IN and OFF by default — pass "" (or 0 for star_rating, [] for text_beats) to REMOVE ' + "one that was set. The four conversion graphics are user-supplied only and never written by the AI copy " + "assist; text_beats (like headline/subheadline) IS rewritten by generate_short_text, so hand-crafted " + "beats don't survive a later assist call. " + "404 for an unknown/foreign slug.",
31868
+ description: "Patches an existing short draft — only the fields you pass change; omitted fields are left untouched. " + "0 credits, no re-render by itself. APPLYING the edit: changed only text/style/CTA/end-card fields " + "(headline, subheadline, text_beats, cta_text, offer_text, badge_text, star_rating, colors, font, " + "position, animation, closing_claim, brand_name, poster_includes_lockup, end_card) on an " + "already-generated short? " + "rerender_short applies them for 0 credits. Footage/music-affecting edits (product_prompt, product " + "image, creative_format, visual_language, theme, music_vibe, music_instruments, language) need " + "generate_short (15 credits) again. Use this to edit the brief, copy, " + "styling, or the optional conversion graphics after create_short. " + "Always set cta_text — a short without a CTA doesn't convert (plus offer_text only if there's a real " + "deal). Note: blank cta_text → the server renders a neutral localized ask (e.g. 'Learn more'); a truly " + "CTA-less short is not currently supported. " + "Keep headline ≈4 words (≤40 chars). Set visual_language explicitly (kinetic_creator is a strong " + "default). " + "CLEARING a field: the conversion graphics (offer_text, cta_text, badge_text, star_rating) and " + 'text_beats are OPT-IN and OFF by default — pass "" (or 0 for star_rating, [] for text_beats) to REMOVE ' + "one that was set. The four conversion graphics are user-supplied only and never written by the AI copy " + "assist; text_beats (like headline/subheadline) IS rewritten by generate_short_text, so hand-crafted " + "beats don't survive a later assist call. " + "404 for an unknown/foreign slug.",
31846
31869
  inputSchema: {
31847
31870
  slug: exports_external.string().describe("Short slug (from create_short)"),
31848
31871
  product_prompt: exports_external.string().min(10).optional().describe("Rewrite the brief (what the short is about, ≥10 chars); drives the next generate_short."),
31849
31872
  language: exports_external.enum(LANGUAGES).optional().describe('Language code, e.g. "en". Bare ISO-639-1 code only — no region/script subtag like "en-US".'),
31850
- headline: exports_external.string().max(160).optional().describe("On-screen TITLE overlay. ≤160 chars."),
31873
+ headline: exports_external.string().max(80).optional().describe("On-screen TITLE overlay. ≈4 words; 40 chars hits hardest; hard cap 80 — longer hooks wrap " + "past the safe area."),
31851
31874
  subheadline: exports_external.string().max(200).optional().describe("Secondary title / supporting line. ≤200 chars."),
31852
31875
  creative_format: exports_external.enum(CREATIVE_FORMATS).optional().describe("Structure: problem_solution, mistake_fix, myth_vs_reality, before_after, proof_demo, product_reveal."),
31853
31876
  visual_language: exports_external.enum(VISUAL_LANGUAGES).optional().describe("Visual language for Veo direction and render styling."),
@@ -31870,10 +31893,14 @@ registerTool("update_short", {
31870
31893
  subheadline_color: exports_external.string().optional().describe("Subheadline hex color, e.g. #ffffff"),
31871
31894
  accent_color: exports_external.string().optional().describe("6-digit brand accent hex color, e.g. #09EFBE"),
31872
31895
  offer_text: exports_external.string().max(16).optional().describe('Offer chip (opt-in), e.g. "-40%". Pass "" to remove it.'),
31873
- cta_text: exports_external.string().max(24).optional().describe('CTA pill (opt-in), e.g. "Shop now". Pass "" to remove it.'),
31874
- badge_text: exports_external.string().max(24).optional().describe('Badge stamp (opt-in), e.g. "BEST SELLER". Pass "" to remove it.'),
31875
- star_rating: exports_external.number().int().min(0).max(5).optional().describe("0..5 star rating under the subheadline (opt-in). Pass 0 to remove it."),
31896
+ cta_text: exports_external.string().max(24).optional().describe(`CTA pill, e.g. "Shop now". Always set it — a short without a CTA doesn't convert. Pass "" to remove it.`),
31897
+ badge_text: exports_external.string().max(24).optional().describe('Badge stamp (opt-in), e.g. "BEST SELLER". Only badges you can substantiate — fabricated social ' + 'proof is deceptive advertising. Pass "" to remove it.'),
31898
+ star_rating: exports_external.number().int().min(0).max(5).optional().describe("0..5 star rating under the subheadline (opt-in). Only ratings you can substantiate — fabricated " + "social proof is deceptive advertising. Pass 0 to remove it."),
31876
31899
  text_beats: exports_external.array(exports_external.string().max(120)).max(8).optional().describe("Caption beats shown sequentially instead of the static subheadline. Pass [] to clear."),
31900
+ closing_claim: exports_external.string().max(80).optional().describe("End-card claim shown on the closing slate (≤80 chars) — a rephrase of the promise, not a new " + 'claim. Falls back to the headline when unset. Pass "" to remove it.'),
31901
+ 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.`),
31902
+ 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)."),
31903
+ 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)."),
31877
31904
  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.")
31878
31905
  },
31879
31906
  annotations: {
@@ -31923,10 +31950,22 @@ registerTool("update_short", {
31923
31950
  body.star_rating = args.star_rating;
31924
31951
  if (args.text_beats !== undefined)
31925
31952
  body.text_beats = args.text_beats;
31953
+ if (args.closing_claim !== undefined)
31954
+ body.closing_claim = args.closing_claim;
31955
+ if (args.brand_name !== undefined)
31956
+ body.brand_name = args.brand_name;
31957
+ if (args.end_card !== undefined)
31958
+ body.end_card = args.end_card;
31959
+ if (args.poster_includes_lockup !== undefined)
31960
+ body.poster_includes_lockup = args.poster_includes_lockup;
31926
31961
  if (args.brand_profile_id !== undefined)
31927
31962
  body.brand_profile_id = args.brand_profile_id;
31928
31963
  const res = await client.patch(`/shorts/${args.slug}`, body);
31929
- return ok(asRecord(res).data ?? res);
31964
+ const data = asRecord(res).data ?? res;
31965
+ return ok({
31966
+ ...asRecord(data),
31967
+ next: "Saved. Only text/style/CTA/end-card 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)."
31968
+ });
31930
31969
  }));
31931
31970
  async function getTracking(client, slug, deadline) {
31932
31971
  const res = await client.get(`/tracking/${slug}`, undefined, deadline);
@@ -32066,12 +32105,15 @@ registerTool("create_tracking_video", {
32066
32105
  }));
32067
32106
  registerTool("generate_short", {
32068
32107
  title: "Generate (render) a short",
32069
- description: "Deducts 15 credits and starts the render pipeline for an existing short. 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. Then poll with get_status or wait_for_completion (kind=short).",
32070
- inputSchema: { slug: exports_external.string().describe("Short slug from create_short") },
32108
+ 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).",
32109
+ inputSchema: {
32110
+ slug: exports_external.string().describe("Short slug from create_short"),
32111
+ 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).")
32112
+ },
32071
32113
  annotations: { title: "Generate short", ...WRITE }
32072
32114
  }, tool(async (args, client) => {
32073
32115
  try {
32074
- const res = await client.post(`/shorts/${args.slug}/generate`);
32116
+ const res = await client.post(`/shorts/${args.slug}/generate`, args.skip_auto_text ? { skip_auto_text: true } : undefined);
32075
32117
  const status = normalizeStatus("short", args.slug, asRecord(res).data);
32076
32118
  return ok(status);
32077
32119
  } catch (e) {
@@ -32089,6 +32131,44 @@ registerTool("generate_short", {
32089
32131
  throw e;
32090
32132
  }
32091
32133
  }));
32134
+ registerTool("rerender_short", {
32135
+ title: "Re-render a short (FREE — 0 credits)",
32136
+ description: "FREE re-render: applies the short's current text/style/CTA/end-card state over the already-paid " + "footage and music — 0 credits. Use after update_short for copy/style/CTA tweaks. Footage or music " + "changes need generate_short (15 credits) instead. Edits that add/remove the end card (poster, " + "end_card) can shift the video between 12s and 14s; the pinned music bed is faded to fit rather than " + "regenerated — for perfectly synced music after such a change, use generate_short. " + "Requires a completed generation to re-render over " + "(422 short_not_ready otherwise — run generate_short once first); while any generation/render is in " + "flight it reports in-progress (409) rather than duplicating. Then poll with get_status or " + "wait_for_completion (kind=short). A failed free re-render leaves the delivered video intact — retry " + "rerender_short, it stays free (the status fields latest_render_free / last_free_rerender_failed " + "report this when the server sends them).",
32137
+ inputSchema: {
32138
+ slug: exports_external.string().describe("Short slug (from create_short)")
32139
+ },
32140
+ annotations: { title: "Re-render short", ...WRITE, idempotentHint: false }
32141
+ }, tool(async (args, client) => {
32142
+ try {
32143
+ const res = await client.post(`/shorts/${args.slug}/rerender`, undefined, idemKey("rerender-short", args.slug, randomUUID()));
32144
+ const status = normalizeStatus("short", args.slug, asRecord(res).data);
32145
+ return ok({
32146
+ ...status,
32147
+ next: `wait_for_completion({ slug: "${args.slug}", kind: "short" }) — or poll get_status. A re-render is fast (no AI generation).`
32148
+ });
32149
+ } catch (e) {
32150
+ const he = e;
32151
+ if (he.status === 409) {
32152
+ return ok({
32153
+ kind: "short",
32154
+ slug: args.slug,
32155
+ stage: "processing",
32156
+ terminal: false,
32157
+ ready: false,
32158
+ video_url: null,
32159
+ error: null,
32160
+ next: `A render is already running — wait_for_completion({ slug: "${args.slug}", kind: "short" }).`
32161
+ });
32162
+ }
32163
+ if (he.status === 422 && he.code === "short_not_ready") {
32164
+ return fail("This short has no completed footage + music to re-render over (short_not_ready). " + `Run generate_short({ slug: "${args.slug}" }) once (15 credits) — after that first render, every ` + "text/style/CTA/end-card tweak re-renders free with rerender_short.");
32165
+ }
32166
+ if (he.status === 422 && he.code === "short_paid_regeneration_required") {
32167
+ return fail("A footage or music input changed after the last paid render " + "(short_paid_regeneration_required). The pinned assets no longer match the draft. " + `Run generate_short({ slug: "${args.slug}" }) (15 credits) before using free re-renders again.`);
32168
+ }
32169
+ throw e;
32170
+ }
32171
+ }));
32092
32172
  registerTool("generate_short_text", {
32093
32173
  title: "Generate short overlay text (AI assist)",
32094
32174
  description: "Generates editable headline, subheadline, and caption beats for an existing short draft. " + "CONSUMES 1 AI ASSIST (free daily quota) and spends no video credits. The server reads the saved " + "short fields, so update/create the draft with the current product prompt, creative_format, visual_language, " + "language, and seed copy first. On 429 set auto_unlock:true (1 credit -> +10 assists, retried once) or write the text yourself.",
@@ -33087,12 +33167,16 @@ registerTool("set_short_poster", {
33087
33167
  description: "Uploads a local image as the SHORT's end-card poster — a closing still shown at the end (extends the " + "render from 12s to 14s). Accepted: " + IMAGE_EXTS.map((e) => `.${e}`).join(", ") + " (≤20 MB), confined to HUBFLUENCER_INPUT_DIR/cwd. 0 credits. There is one poster per short; calling " + "again replaces it. (Shorts have no logo overlay — for a brand logo use an editor project + set_logo.)",
33088
33168
  inputSchema: {
33089
33169
  slug: exports_external.string().describe("Short slug (from create_short)"),
33090
- file_path: exports_external.string().describe("Local poster image (.jpg/.jpeg/.png)")
33170
+ file_path: exports_external.string().describe("Local poster image (.jpg/.jpeg/.png)"),
33171
+ 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).")
33091
33172
  },
33092
33173
  annotations: { title: "Set short poster", ...WRITE, idempotentHint: false }
33093
33174
  }, tool(async (args, client) => {
33094
33175
  const { s3_key } = await uploadShortPoster(client, args.slug, args.file_path);
33095
- const res = await client.post(`/shorts/${args.slug}/poster/confirm`, { s3_key });
33176
+ const res = await client.post(`/shorts/${args.slug}/poster/confirm`, {
33177
+ s3_key,
33178
+ poster_includes_lockup: args.poster_includes_lockup ?? false
33179
+ });
33096
33180
  return ok(asRecord(res).data ?? res);
33097
33181
  }));
33098
33182
  registerTool("get_status", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubfluencer/mcp",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
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
@@ -265,6 +265,14 @@ export interface NormalizedStatus {
265
265
  ready: boolean;
266
266
  video_url: string | null;
267
267
  error: string | null;
268
+ /** Short only (additive, absent on older APIs): the newest render row is a free re-render. */
269
+ latest_render_free?: boolean;
270
+ /**
271
+ * Short only (additive): the newest render row is a FAILED free re-render.
272
+ * The delivered video stays intact (stage/latest_render fall back to the
273
+ * latest completed render), so the agent can just retry rerender_short — free.
274
+ */
275
+ last_free_rerender_failed?: boolean;
268
276
  }
269
277
 
270
278
  /** The structured fields of an API error envelope errMessage formats from. */
@@ -318,7 +326,7 @@ export function normalizeStatus(
318
326
 
319
327
  if (kind === "short") {
320
328
  const stage = (d.stage as string) ?? "unknown";
321
- return {
329
+ const status: NormalizedStatus = {
322
330
  kind,
323
331
  slug,
324
332
  stage,
@@ -327,6 +335,19 @@ export function normalizeStatus(
327
335
  video_url: videoUrl,
328
336
  error: (d.error_message as string) ?? null,
329
337
  };
338
+ // Additive free-re-render bookkeeping (2026-07 API): surfaced only when
339
+ // the server actually sends the booleans, so older payloads normalize
340
+ // byte-identically. A failed free re-render does NOT flip stage to
341
+ // "failed" server-side — the delivered video stays visible — so these
342
+ // flags are the only signal that the newest render row was a free
343
+ // re-render (or a failed one worth retrying, still free).
344
+ if (typeof d.latest_render_free === "boolean") {
345
+ status.latest_render_free = d.latest_render_free;
346
+ }
347
+ if (typeof d.last_free_rerender_failed === "boolean") {
348
+ status.last_free_rerender_failed = d.last_free_rerender_failed;
349
+ }
350
+ return status;
330
351
  }
331
352
 
332
353
  if (kind === "tracking") {
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * One-shot : make_video({ prompt }) → finished MP4 (create→start→poll→download)
11
11
  * Shorts : create_short → generate_short → wait_for_completion → download_result
12
+ * (then iterate free: update_short → rerender_short, 0 credits)
12
13
  * Editor : create_editor_ad (creates + starts autopilot) → wait_for_completion → download_result
13
14
  * Granular : create_editor_draft → generate_scenario|set_scenario → get_editor
14
15
  * → apply_scenario (scenario → scene prompts) | set_scene_count + set_segment_prompt
@@ -514,7 +515,7 @@ DELEGATE THE CONTENT OPERATION (the full loop — steps 1-3 are $0)
514
515
  8. RECURRING: create_series + plan_series_episodes → materialize_episode drafts each episode; get_series_dashboard shows upcoming/drafted/published lanes; mark_episode_posted closes a recurring show's loop.
515
516
 
516
517
  CREDITS (spent server-side; each tool prices before charging — see its description)
517
- - Free (0 credits): every draft create/edit, reorder, all slider re-composites, and the whole onboard/plan/pack/materialize path (steps 1-3). get_credits shows the balance.
518
+ - Free (0 credits): every draft create/edit, reorder, all slider re-composites, short re-renders (rerender_short — applies text/style/CTA/end-card edits over a short's already-paid footage), and the whole onboard/plan/pack/materialize path (steps 1-3). get_credits shows the balance.
518
519
  - Charged: short render 15; editor AI scene 5 (4 in a batch of ≥3); voice 3; music 5; editor render 0 (auto-charges ungenerated scenes); slider 1/slide; tracking render 1 (refunded on failure). make_video/create_* report the estimate first — pass dry_run to preview, max_credits to cap.
519
520
  - AI ASSISTS (all generate_*/enhance/suggest/plan/hook tools) draw a FREE daily quota (20/day), NOT credits. On 429, set auto_unlock:true to spend 1 credit for +10 assists (retried once), or write the copy yourself and use the set_* tools.
520
521
 
@@ -638,12 +639,20 @@ registerTool(
638
639
  "for simple/short ones; the chosen kind is reported back as kind_inferred. If it returns terminal=false " +
639
640
  "the render is still running — call wait_for_completion with the returned slug to finish. " +
640
641
  "BE PROACTIVE WITH BRANDING: pass headline (the on-screen TITLE) and subheadline (secondary title) plus " +
641
- "music_vibe so a one-shot short isn't bare. creative_format + visual_language apply to BOTH kinds (editor and " +
642
+ "music_vibe so a one-shot short isn't bare. Always set cta_text a short without a CTA doesn't convert " +
643
+ "(plus offer_text only if there's a real deal). Note: blank cta_text → the server renders a neutral " +
644
+ "localized ask (e.g. 'Learn more'); a truly CTA-less short is not currently supported. " +
645
+ "Keep headline ≈4 words (≤40 chars). Set visual_language " +
646
+ "explicitly (kinetic_creator is a strong default). If headline/subheadline/text_beats are left blank the " +
647
+ "server auto-writes the headline + caption beats at generate time; pass skip_auto_text:true for a " +
648
+ "deliberately bare clip. creative_format + visual_language apply to BOTH kinds (editor and " +
642
649
  "shorts); headline/subheadline/music_vibe/text_* and the conversion graphics are SHORTS-only and ignored for " +
643
650
  "editor; theme applies to editor (genre overlay) and is legacy-only for shorts (used when visual_language is unset). " +
644
651
  "For richer branding — a product image, brand logo, or " +
645
652
  "closing card — drive the granular path instead: create_short + set_short_product/set_short_poster, or " +
646
- "create_editor_draft + set_product/set_logo/set_closing_image, then start_autopilot/generate_short.",
653
+ "create_editor_draft + set_product/set_logo/set_closing_image, then start_autopilot/generate_short. " +
654
+ "For a SHORT, iterating after the first render is FREE: update_short (copy/style/CTA/end-card) → " +
655
+ "rerender_short re-renders over the already-paid footage for 0 credits.",
647
656
  // Schema kept intentionally flat (no .min/.max/.int chains) — the SDK's
648
657
  // generic inference on registerTool hits TS2589 ("excessively deep") on
649
658
  // larger schemas with chained validators. Ranges are enforced in code.
@@ -691,9 +700,11 @@ registerTool(
691
700
  ),
692
701
  headline: z
693
702
  .string()
703
+ .max(80)
694
704
  .optional()
695
705
  .describe(
696
- "SHORTS only: the on-screen TITLE overlay (160). Set this so the short isn't bare.",
706
+ "SHORTS only: the on-screen TITLE overlay. ≈4 words; 40 chars hits hardest; hard cap 80 " +
707
+ "longer hooks wrap past the safe area. Set this so the short isn't bare.",
697
708
  ),
698
709
  subheadline: z
699
710
  .string()
@@ -724,12 +735,15 @@ registerTool(
724
735
  cta_text: z
725
736
  .string()
726
737
  .optional()
727
- .describe("SHORTS only: optional CTA pill, e.g. Shop now (≤24 chars)"),
738
+ .describe(
739
+ "SHORTS only: CTA pill, e.g. Shop now (≤24 chars). Always set it — a short without a CTA doesn't convert.",
740
+ ),
728
741
  badge_text: z
729
742
  .string()
730
743
  .optional()
731
744
  .describe(
732
- "SHORTS only: optional badge stamp, e.g. BEST SELLER (≤24 chars)",
745
+ "SHORTS only: optional badge stamp, e.g. BEST SELLER (≤24 chars). Only badges you can " +
746
+ "substantiate — fabricated social proof is deceptive advertising.",
733
747
  ),
734
748
  star_rating: z
735
749
  .number()
@@ -738,7 +752,15 @@ registerTool(
738
752
  .max(5)
739
753
  .optional()
740
754
  .describe(
741
- "SHORTS only: optional 0..5 star rating under the subheadline",
755
+ "SHORTS only: optional 0..5 star rating under the subheadline. Only ratings you can " +
756
+ "substantiate — fabricated social proof is deceptive advertising.",
757
+ ),
758
+ skip_auto_text: z
759
+ .boolean()
760
+ .optional()
761
+ .describe(
762
+ "SHORTS only: when headline/subheadline/text_beats are blank the server auto-writes the copy at " +
763
+ "generate time; pass true to skip that and render a deliberately bare clip (default false).",
742
764
  ),
743
765
  text_position: z
744
766
  .enum(["top", "center", "bottom"])
@@ -849,6 +871,7 @@ registerTool(
849
871
  cta_text?: string;
850
872
  badge_text?: string;
851
873
  star_rating?: number;
874
+ skip_auto_text?: boolean;
852
875
  text_position?: string;
853
876
  text_animation?: string;
854
877
  font_family?: string;
@@ -975,6 +998,7 @@ registerTool(
975
998
  args.creative_format ?? "",
976
999
  args.visual_language ?? "",
977
1000
  args.theme ?? "",
1001
+ args.skip_auto_text ? "bare" : "auto-copy",
978
1002
  ),
979
1003
  );
980
1004
  slug = created.data.slug;
@@ -1024,7 +1048,7 @@ registerTool(
1024
1048
 
1025
1049
  const resume =
1026
1050
  kind === "short"
1027
- ? `generate_short({ slug: "${slug}" })`
1051
+ ? `generate_short({ slug: "${slug}"${args.skip_auto_text ? ", skip_auto_text: true" : ""} })`
1028
1052
  : `start_autopilot({ slug: "${slug}" })`;
1029
1053
  const affordable =
1030
1054
  available_credits === null ||
@@ -1081,11 +1105,15 @@ registerTool(
1081
1105
  if (kind === "short") {
1082
1106
  await client.post(
1083
1107
  `/shorts/${slug}/generate`,
1084
- undefined,
1108
+ // Only send a body when the caller opts out of server auto-copy —
1109
+ // the bare POST stays byte-identical to the pre-0.8.2 wire shape.
1110
+ args.skip_auto_text ? { skip_auto_text: true } : undefined,
1085
1111
  // gen-short:<slug> + the failure-state discriminator: a fresh/in-flight
1086
1112
  // short reuses "gen-short:<slug>:start" (transport-retry dedupe), a
1087
1113
  // re-run after a failed render gets a fresh key and re-renders.
1088
- `gen-short:${slug}:${startState}`,
1114
+ // skip_auto_text is folded in so a bare-clip run never replays a
1115
+ // cached auto-copy run's response (or vice versa) on the same slug.
1116
+ `gen-short:${slug}:${startState}${args.skip_auto_text ? ":bare" : ""}`,
1089
1117
  );
1090
1118
  } else {
1091
1119
  await client.post(
@@ -1282,10 +1310,19 @@ registerTool(
1282
1310
  title: "Create a short (draft)",
1283
1311
  description:
1284
1312
  "Creates a short draft from a product prompt (min 10 chars). A short is a 12s vertical (two 6s AI " +
1285
- "segments + an on-screen title overlay + music; 14s when an end-card poster is set). Returns the slug, " +
1286
- "costs 0 credits. Follow with generate_short to render. " +
1313
+ "segments + an on-screen title overlay + music; 14s when it ends on a poster or brand-lockup end card). " +
1314
+ "Returns the slug, costs 0 credits. Follow with generate_short to render. Iteration is FREE after that " +
1315
+ "first generate: the 15 credits bought the footage + music, and every later text/style/CTA/end-card " +
1316
+ "tweak re-renders over them for 0 credits (update_short → rerender_short). " +
1287
1317
  "BE PROACTIVE: don't ship a bare clip — set a headline (the on-screen TITLE) and subheadline (secondary " +
1288
- "title), and pick a music_vibe plus visual_language that fit the brand. To brand it further, attach a product image " +
1318
+ "title), and pick a music_vibe plus visual_language that fit the brand. " +
1319
+ "Always set cta_text — a short without a CTA doesn't convert (plus offer_text only if there's a real " +
1320
+ "deal). Note: blank cta_text → the server renders a neutral localized ask (e.g. 'Learn more'); a truly " +
1321
+ "CTA-less short is not currently supported. " +
1322
+ "Keep headline ≈4 words (≤40 chars). Set visual_language explicitly (kinetic_creator is a strong " +
1323
+ "default). If headline/subheadline/text_beats are left blank, the server auto-writes the headline + " +
1324
+ "caption beats at generate time (pass skip_auto_text:true to generate_short for a deliberately bare clip). " +
1325
+ "To brand it further, attach a product image " +
1289
1326
  "(set_short_product) or an end-card poster (set_short_poster) before generate_short. (Shorts have no logo " +
1290
1327
  "overlay — that's an editor-only feature; use create_editor_ad for logo branding.)",
1291
1328
  inputSchema: {
@@ -1301,10 +1338,11 @@ registerTool(
1301
1338
  ),
1302
1339
  headline: z
1303
1340
  .string()
1304
- .max(160)
1341
+ .max(80)
1305
1342
  .optional()
1306
1343
  .describe(
1307
- "The on-screen TITLE composited over the short (poster text overlay). ≤160 chars. Set this.",
1344
+ "The on-screen TITLE composited over the short (poster text overlay). ≈4 words; 40 chars hits " +
1345
+ "hardest; hard cap 80 — longer hooks wrap past the safe area. Set this.",
1308
1346
  ),
1309
1347
  subheadline: z
1310
1348
  .string()
@@ -1395,19 +1433,27 @@ registerTool(
1395
1433
  .string()
1396
1434
  .max(24)
1397
1435
  .optional()
1398
- .describe('Optional CTA pill, e.g. "Shop now"'),
1436
+ .describe(
1437
+ 'CTA pill, e.g. "Shop now". Always set it — a short without a CTA doesn\'t convert.',
1438
+ ),
1399
1439
  badge_text: z
1400
1440
  .string()
1401
1441
  .max(24)
1402
1442
  .optional()
1403
- .describe('Optional badge stamp, e.g. "BEST SELLER"'),
1443
+ .describe(
1444
+ 'Optional badge stamp, e.g. "BEST SELLER". Only badges you can substantiate — fabricated social ' +
1445
+ "proof is deceptive advertising.",
1446
+ ),
1404
1447
  star_rating: z
1405
1448
  .number()
1406
1449
  .int()
1407
1450
  .min(0)
1408
1451
  .max(5)
1409
1452
  .optional()
1410
- .describe("Optional 0..5 star rating under the subheadline"),
1453
+ .describe(
1454
+ "Optional 0..5 star rating under the subheadline. Only ratings you can substantiate — fabricated " +
1455
+ "social proof is deceptive advertising.",
1456
+ ),
1411
1457
  text_beats: z
1412
1458
  .array(z.string().max(120))
1413
1459
  .max(8)
@@ -1415,6 +1461,30 @@ registerTool(
1415
1461
  .describe(
1416
1462
  "Optional caption beats shown sequentially instead of the static subheadline.",
1417
1463
  ),
1464
+ closing_claim: z
1465
+ .string()
1466
+ .max(80)
1467
+ .optional()
1468
+ .describe(
1469
+ "End-card claim shown on the closing slate (≤80 chars) — a rephrase of the promise, not a new " +
1470
+ "claim. Falls back to the headline when unset.",
1471
+ ),
1472
+ brand_name: z
1473
+ .string()
1474
+ .max(40)
1475
+ .optional()
1476
+ .describe(
1477
+ "Brand/product display name (≤40 chars) shown big on the closing brand-lockup slate when no " +
1478
+ "poster is set. Server falls back to your brand profile's name when unset.",
1479
+ ),
1480
+ end_card: z
1481
+ .enum(["auto", "none"])
1482
+ .optional()
1483
+ .describe(
1484
+ '"auto" (default): end on the poster, or on a brand-lockup slate (brand_name + claim + CTA pill) ' +
1485
+ 'when no poster is set — a 14s render. "none": no end card at all (12s, for deliberate ' +
1486
+ "loop-style shorts).",
1487
+ ),
1418
1488
  brand_profile_id: z
1419
1489
  .number()
1420
1490
  .int()
@@ -1451,6 +1521,9 @@ registerTool(
1451
1521
  badge_text?: string;
1452
1522
  star_rating?: number;
1453
1523
  text_beats?: string[];
1524
+ closing_claim?: string;
1525
+ brand_name?: string;
1526
+ end_card?: string;
1454
1527
  brand_profile_id?: number;
1455
1528
  },
1456
1529
  client,
@@ -1487,6 +1560,10 @@ registerTool(
1487
1560
  if (args.badge_text !== undefined) body.badge_text = args.badge_text;
1488
1561
  if (args.star_rating !== undefined) body.star_rating = args.star_rating;
1489
1562
  if (args.text_beats !== undefined) body.text_beats = args.text_beats;
1563
+ if (args.closing_claim !== undefined)
1564
+ body.closing_claim = args.closing_claim;
1565
+ if (args.brand_name !== undefined) body.brand_name = args.brand_name;
1566
+ if (args.end_card !== undefined) body.end_card = args.end_card;
1490
1567
  if (args.brand_profile_id !== undefined)
1491
1568
  body.brand_profile_id = args.brand_profile_id;
1492
1569
  const res = await client.post<{ data: { slug: string } }>(
@@ -1514,13 +1591,16 @@ registerTool(
1514
1591
  args.badge_text ?? "",
1515
1592
  String(args.star_rating ?? ""),
1516
1593
  JSON.stringify(args.text_beats ?? []),
1594
+ args.closing_claim ?? "",
1595
+ args.brand_name ?? "",
1596
+ args.end_card ?? "",
1517
1597
  String(args.brand_profile_id ?? ""),
1518
1598
  ),
1519
1599
  );
1520
1600
  return ok({
1521
1601
  slug: res.data.slug,
1522
1602
  kind: "short",
1523
- next: "set_short_product / set_short_poster (optional branding), then generate_short",
1603
+ next: "set_short_product / set_short_poster (optional branding); generate_hook_variations to test hook angles before you spend; then generate_short. After that first render, hook/CTA/style iteration is free: update_short → rerender_short (0 credits).",
1524
1604
  });
1525
1605
  },
1526
1606
  ),
@@ -1532,8 +1612,19 @@ registerTool(
1532
1612
  title: "Update a short draft (0 credits)",
1533
1613
  description:
1534
1614
  "Patches an existing short draft — only the fields you pass change; omitted fields are left untouched. " +
1535
- "0 credits, no re-render (call generate_short afterwards to apply). Use this to edit the brief, copy, " +
1615
+ "0 credits, no re-render by itself. APPLYING the edit: changed only text/style/CTA/end-card fields " +
1616
+ "(headline, subheadline, text_beats, cta_text, offer_text, badge_text, star_rating, colors, font, " +
1617
+ "position, animation, closing_claim, brand_name, poster_includes_lockup, end_card) on an " +
1618
+ "already-generated short? " +
1619
+ "rerender_short applies them for 0 credits. Footage/music-affecting edits (product_prompt, product " +
1620
+ "image, creative_format, visual_language, theme, music_vibe, music_instruments, language) need " +
1621
+ "generate_short (15 credits) again. Use this to edit the brief, copy, " +
1536
1622
  "styling, or the optional conversion graphics after create_short. " +
1623
+ "Always set cta_text — a short without a CTA doesn't convert (plus offer_text only if there's a real " +
1624
+ "deal). Note: blank cta_text → the server renders a neutral localized ask (e.g. 'Learn more'); a truly " +
1625
+ "CTA-less short is not currently supported. " +
1626
+ "Keep headline ≈4 words (≤40 chars). Set visual_language explicitly (kinetic_creator is a strong " +
1627
+ "default). " +
1537
1628
  "CLEARING a field: the conversion graphics (offer_text, cta_text, badge_text, star_rating) and " +
1538
1629
  'text_beats are OPT-IN and OFF by default — pass "" (or 0 for star_rating, [] for text_beats) to REMOVE ' +
1539
1630
  "one that was set. The four conversion graphics are user-supplied only and never written by the AI copy " +
@@ -1557,9 +1648,12 @@ registerTool(
1557
1648
  ),
1558
1649
  headline: z
1559
1650
  .string()
1560
- .max(160)
1651
+ .max(80)
1561
1652
  .optional()
1562
- .describe("On-screen TITLE overlay. ≤160 chars."),
1653
+ .describe(
1654
+ "On-screen TITLE overlay. ≈4 words; ≤40 chars hits hardest; hard cap 80 — longer hooks wrap " +
1655
+ "past the safe area.",
1656
+ ),
1563
1657
  subheadline: z
1564
1658
  .string()
1565
1659
  .max(200)
@@ -1645,13 +1739,16 @@ registerTool(
1645
1739
  .string()
1646
1740
  .max(24)
1647
1741
  .optional()
1648
- .describe('CTA pill (opt-in), e.g. "Shop now". Pass "" to remove it.'),
1742
+ .describe(
1743
+ 'CTA pill, e.g. "Shop now". Always set it — a short without a CTA doesn\'t convert. Pass "" to remove it.',
1744
+ ),
1649
1745
  badge_text: z
1650
1746
  .string()
1651
1747
  .max(24)
1652
1748
  .optional()
1653
1749
  .describe(
1654
- 'Badge stamp (opt-in), e.g. "BEST SELLER". Pass "" to remove it.',
1750
+ 'Badge stamp (opt-in), e.g. "BEST SELLER". Only badges you can substantiate — fabricated social ' +
1751
+ 'proof is deceptive advertising. Pass "" to remove it.',
1655
1752
  ),
1656
1753
  star_rating: z
1657
1754
  .number()
@@ -1660,7 +1757,8 @@ registerTool(
1660
1757
  .max(5)
1661
1758
  .optional()
1662
1759
  .describe(
1663
- "0..5 star rating under the subheadline (opt-in). Pass 0 to remove it.",
1760
+ "0..5 star rating under the subheadline (opt-in). Only ratings you can substantiate — fabricated " +
1761
+ "social proof is deceptive advertising. Pass 0 to remove it.",
1664
1762
  ),
1665
1763
  text_beats: z
1666
1764
  .array(z.string().max(120))
@@ -1669,6 +1767,37 @@ registerTool(
1669
1767
  .describe(
1670
1768
  "Caption beats shown sequentially instead of the static subheadline. Pass [] to clear.",
1671
1769
  ),
1770
+ closing_claim: z
1771
+ .string()
1772
+ .max(80)
1773
+ .optional()
1774
+ .describe(
1775
+ "End-card claim shown on the closing slate (≤80 chars) — a rephrase of the promise, not a new " +
1776
+ 'claim. Falls back to the headline when unset. Pass "" to remove it.',
1777
+ ),
1778
+ brand_name: z
1779
+ .string()
1780
+ .max(40)
1781
+ .optional()
1782
+ .describe(
1783
+ "Brand/product display name (≤40 chars) shown big on the closing brand-lockup slate when no " +
1784
+ 'poster is set. Server falls back to your brand profile\'s name when unset. Pass "" to remove it.',
1785
+ ),
1786
+ end_card: z
1787
+ .enum(["auto", "none"])
1788
+ .optional()
1789
+ .describe(
1790
+ '"auto" (default): end on the poster, or on a brand-lockup slate (brand_name + claim + CTA pill) ' +
1791
+ 'when no poster is set — a 14s render. "none": no end card at all (12s, for deliberate ' +
1792
+ "loop-style shorts).",
1793
+ ),
1794
+ poster_includes_lockup: z
1795
+ .boolean()
1796
+ .optional()
1797
+ .describe(
1798
+ "Set true if your poster already contains brand name/CTA/badges — suppresses the overlaid " +
1799
+ "claim/CTA so they don't collide (default false).",
1800
+ ),
1672
1801
  brand_profile_id: z
1673
1802
  .number()
1674
1803
  .int()
@@ -1710,6 +1839,10 @@ registerTool(
1710
1839
  badge_text?: string;
1711
1840
  star_rating?: number;
1712
1841
  text_beats?: string[];
1842
+ closing_claim?: string;
1843
+ brand_name?: string;
1844
+ end_card?: string;
1845
+ poster_includes_lockup?: boolean;
1713
1846
  brand_profile_id?: number;
1714
1847
  },
1715
1848
  client,
@@ -1750,6 +1883,12 @@ registerTool(
1750
1883
  if (args.badge_text !== undefined) body.badge_text = args.badge_text;
1751
1884
  if (args.star_rating !== undefined) body.star_rating = args.star_rating;
1752
1885
  if (args.text_beats !== undefined) body.text_beats = args.text_beats;
1886
+ if (args.closing_claim !== undefined)
1887
+ body.closing_claim = args.closing_claim;
1888
+ if (args.brand_name !== undefined) body.brand_name = args.brand_name;
1889
+ if (args.end_card !== undefined) body.end_card = args.end_card;
1890
+ if (args.poster_includes_lockup !== undefined)
1891
+ body.poster_includes_lockup = args.poster_includes_lockup;
1753
1892
  if (args.brand_profile_id !== undefined)
1754
1893
  body.brand_profile_id = args.brand_profile_id;
1755
1894
 
@@ -1757,7 +1896,15 @@ registerTool(
1757
1896
  `/shorts/${args.slug}`,
1758
1897
  body,
1759
1898
  );
1760
- return ok(asRecord(res).data ?? res);
1899
+ const data = asRecord(res).data ?? res;
1900
+ return ok({
1901
+ ...asRecord(data),
1902
+ next:
1903
+ "Saved. Only text/style/CTA/end-card changed on an already-generated short? " +
1904
+ `rerender_short({ slug: "${args.slug}" }) applies it for 0 credits. Footage/music edits ` +
1905
+ "(prompt, product image, format, visual_language, theme, music_*, language) need " +
1906
+ "generate_short (15 credits).",
1907
+ });
1761
1908
  },
1762
1909
  ),
1763
1910
  );
@@ -2133,16 +2280,30 @@ registerTool(
2133
2280
  {
2134
2281
  title: "Generate (render) a short",
2135
2282
  description:
2136
- "Deducts 15 credits and starts the render pipeline for an existing short. Safe to call again: a " +
2283
+ "Deducts 15 credits and starts the render pipeline for an existing short this RE-ROLLS the footage " +
2284
+ "and music (a fresh AI generation). For text/style/CTA/end-card tweaks on an already-generated short, " +
2285
+ "use rerender_short instead: it re-applies the overlay over the existing footage for 0 credits. " +
2286
+ "Safe to call again: a " +
2137
2287
  "duplicate while a render is in flight is reported as in-progress (no double charge), and a failed " +
2138
- "short can be re-generated. Then poll with get_status or wait_for_completion (kind=short).",
2139
- inputSchema: { slug: z.string().describe("Short slug from create_short") },
2288
+ "short can be re-generated. If the draft's headline/subheadline/text_beats are all blank the server " +
2289
+ "auto-writes the headline + caption beats before rendering (pass skip_auto_text:true for a deliberately " +
2290
+ "bare clip). Then poll with get_status or wait_for_completion (kind=short).",
2291
+ inputSchema: {
2292
+ slug: z.string().describe("Short slug from create_short"),
2293
+ skip_auto_text: z
2294
+ .boolean()
2295
+ .optional()
2296
+ .describe(
2297
+ "When the draft has no headline/subheadline/text_beats the server auto-writes the copy; pass " +
2298
+ "true to skip that and render a deliberately bare clip (default false).",
2299
+ ),
2300
+ },
2140
2301
  // Not idempotent: a repeat after a terminal (failed) render re-charges 15
2141
2302
  // credits (the whole point of allowing a re-generation), so a blind retry is
2142
2303
  // NOT free — only an in-flight duplicate dedupes (server 409). See below.
2143
2304
  annotations: { title: "Generate short", ...WRITE },
2144
2305
  },
2145
- tool(async (args: { slug: string }, client) => {
2306
+ tool(async (args: { slug: string; skip_auto_text?: boolean }, client) => {
2146
2307
  // No stable idempotency key (see generate_slider): a per-slug key would
2147
2308
  // replay the first response for 24h and block an intentional re-generation
2148
2309
  // of a failed short. Double-charge is prevented server-side — the batch
@@ -2151,6 +2312,9 @@ registerTool(
2151
2312
  try {
2152
2313
  const res = await client.post<{ data: unknown }>(
2153
2314
  `/shorts/${args.slug}/generate`,
2315
+ // Only send a body when the caller opts out of server auto-copy —
2316
+ // the bare POST stays byte-identical to the pre-0.8.2 wire shape.
2317
+ args.skip_auto_text ? { skip_auto_text: true } : undefined,
2154
2318
  );
2155
2319
  const status = normalizeStatus("short", args.slug, asRecord(res).data);
2156
2320
  return ok(status);
@@ -2171,6 +2335,89 @@ registerTool(
2171
2335
  }),
2172
2336
  );
2173
2337
 
2338
+ registerTool(
2339
+ "rerender_short",
2340
+ {
2341
+ title: "Re-render a short (FREE — 0 credits)",
2342
+ description:
2343
+ "FREE re-render: applies the short's current text/style/CTA/end-card state over the already-paid " +
2344
+ "footage and music — 0 credits. Use after update_short for copy/style/CTA tweaks. Footage or music " +
2345
+ "changes need generate_short (15 credits) instead. Edits that add/remove the end card (poster, " +
2346
+ "end_card) can shift the video between 12s and 14s; the pinned music bed is faded to fit rather than " +
2347
+ "regenerated — for perfectly synced music after such a change, use generate_short. " +
2348
+ "Requires a completed generation to re-render over " +
2349
+ "(422 short_not_ready otherwise — run generate_short once first); while any generation/render is in " +
2350
+ "flight it reports in-progress (409) rather than duplicating. Then poll with get_status or " +
2351
+ "wait_for_completion (kind=short). A failed free re-render leaves the delivered video intact — retry " +
2352
+ "rerender_short, it stays free (the status fields latest_render_free / last_free_rerender_failed " +
2353
+ "report this when the server sends them).",
2354
+ inputSchema: {
2355
+ slug: z.string().describe("Short slug (from create_short)"),
2356
+ },
2357
+ // Free, but not idempotent in the MCP sense: each call is a genuine new
2358
+ // re-render of the short's CURRENT state — the whole point of the
2359
+ // update_short → rerender_short loop (see the key note below).
2360
+ annotations: { title: "Re-render short", ...WRITE, idempotentHint: false },
2361
+ },
2362
+ tool(async (args: { slug: string }, client) => {
2363
+ try {
2364
+ const res = await client.post<{ data: unknown }>(
2365
+ `/shorts/${args.slug}/rerender`,
2366
+ undefined,
2367
+ // Each call is a genuine new re-render attempt (the core loop is
2368
+ // update_short → rerender_short, repeated per variant). A per-invocation
2369
+ // nonce keeps the server's 24h idempotency cache from replaying a PRIOR
2370
+ // rerender's response on a deliberate re-application after an edit.
2371
+ // (The client never auto-retries non-GETs, so the nonce buys no
2372
+ // transport-retry protection — it exists purely to defeat the replay
2373
+ // cache.) A concurrent duplicate is independently blocked server-side
2374
+ // (409 while any work is active) — and the call is 0 credits, so there
2375
+ // is no charge to protect. Mirrors start_autopilot's
2376
+ // idemKey("autopilot", slug, randomUUID()).
2377
+ idemKey("rerender-short", args.slug, randomUUID()),
2378
+ );
2379
+ const status = normalizeStatus("short", args.slug, asRecord(res).data);
2380
+ return ok({
2381
+ ...status,
2382
+ next: `wait_for_completion({ slug: "${args.slug}", kind: "short" }) — or poll get_status. A re-render is fast (no AI generation).`,
2383
+ });
2384
+ } catch (e) {
2385
+ const he = e as { status?: number; code?: string };
2386
+ if (he.status === 409) {
2387
+ // A generation/render is already in flight — report in-progress
2388
+ // (same shape as generate_short's 409 handling), never a hard error.
2389
+ return ok({
2390
+ kind: "short",
2391
+ slug: args.slug,
2392
+ stage: "processing",
2393
+ terminal: false,
2394
+ ready: false,
2395
+ video_url: null,
2396
+ error: null,
2397
+ next: `A render is already running — wait_for_completion({ slug: "${args.slug}", kind: "short" }).`,
2398
+ });
2399
+ }
2400
+ if (he.status === 422 && he.code === "short_not_ready") {
2401
+ // Nothing rendered yet to re-render over: the free path needs the
2402
+ // paid footage+music to exist first.
2403
+ return fail(
2404
+ "This short has no completed footage + music to re-render over (short_not_ready). " +
2405
+ `Run generate_short({ slug: "${args.slug}" }) once (15 credits) — after that first render, every ` +
2406
+ "text/style/CTA/end-card tweak re-renders free with rerender_short.",
2407
+ );
2408
+ }
2409
+ if (he.status === 422 && he.code === "short_paid_regeneration_required") {
2410
+ return fail(
2411
+ "A footage or music input changed after the last paid render " +
2412
+ "(short_paid_regeneration_required). The pinned assets no longer match the draft. " +
2413
+ `Run generate_short({ slug: "${args.slug}" }) (15 credits) before using free re-renders again.`,
2414
+ );
2415
+ }
2416
+ throw e;
2417
+ }
2418
+ }),
2419
+ );
2420
+
2174
2421
  registerTool(
2175
2422
  "generate_short_text",
2176
2423
  {
@@ -4376,21 +4623,43 @@ registerTool(
4376
4623
  inputSchema: {
4377
4624
  slug: z.string().describe("Short slug (from create_short)"),
4378
4625
  file_path: z.string().describe("Local poster image (.jpg/.jpeg/.png)"),
4626
+ poster_includes_lockup: z
4627
+ .boolean()
4628
+ .optional()
4629
+ .describe(
4630
+ "Set true if your poster already contains brand name/CTA/badges — suppresses the overlaid " +
4631
+ "claim/CTA so they don't collide (default false).",
4632
+ ),
4379
4633
  },
4380
4634
  annotations: { title: "Set short poster", ...WRITE, idempotentHint: false },
4381
4635
  },
4382
- tool(async (args: { slug: string; file_path: string }, client) => {
4383
- const { s3_key } = await uploadShortPoster(
4636
+ tool(
4637
+ async (
4638
+ args: {
4639
+ slug: string;
4640
+ file_path: string;
4641
+ poster_includes_lockup?: boolean;
4642
+ },
4384
4643
  client,
4385
- args.slug,
4386
- args.file_path,
4387
- );
4388
- const res = await client.post<{ data: unknown }>(
4389
- `/shorts/${args.slug}/poster/confirm`,
4390
- { s3_key },
4391
- );
4392
- return ok(asRecord(res).data ?? res);
4393
- }),
4644
+ ) => {
4645
+ const { s3_key } = await uploadShortPoster(
4646
+ client,
4647
+ args.slug,
4648
+ args.file_path,
4649
+ );
4650
+ const res = await client.post<{ data: unknown }>(
4651
+ `/shorts/${args.slug}/poster/confirm`,
4652
+ {
4653
+ s3_key,
4654
+ // A new poster replaces the old poster's content contract too:
4655
+ // omission means the declared default (overlays enabled), not
4656
+ // "preserve whatever the previous poster said".
4657
+ poster_includes_lockup: args.poster_includes_lockup ?? false,
4658
+ },
4659
+ );
4660
+ return ok(asRecord(res).data ?? res);
4661
+ },
4662
+ ),
4394
4663
  );
4395
4664
 
4396
4665
  // ── Status / waiting ──────────────────────────────────────────────────────────
@@ -62,6 +62,10 @@ export const getStatusOutput = z
62
62
  ready: z.boolean().optional(),
63
63
  video_url: z.union([z.string(), z.null()]).optional(),
64
64
  error: z.union([z.string(), z.null()]).optional(),
65
+ // Short only, additive (normalizeStatus forwards them gated on typeof
66
+ // boolean, so the precise type can never reject a payload).
67
+ latest_render_free: z.boolean().optional(),
68
+ last_free_rerender_failed: z.boolean().optional(),
65
69
  })
66
70
  .passthrough();
67
71
 
@@ -75,6 +79,8 @@ export const waitForCompletionOutput = z
75
79
  ready: z.boolean().optional(),
76
80
  video_url: z.union([z.string(), z.null()]).optional(),
77
81
  error: z.union([z.string(), z.null()]).optional(),
82
+ latest_render_free: z.boolean().optional(),
83
+ last_free_rerender_failed: z.boolean().optional(),
78
84
  saved_to: z.union([z.string(), z.null()]).optional(),
79
85
  timed_out: z.boolean().optional(),
80
86
  })
@@ -147,6 +153,8 @@ const projectRow = z
147
153
  ready: z.boolean().optional(),
148
154
  video_url: z.union([z.string(), z.null()]).optional(),
149
155
  error: z.union([z.string(), z.null()]).optional(),
156
+ latest_render_free: z.boolean().optional(),
157
+ last_free_rerender_failed: z.boolean().optional(),
150
158
  })
151
159
  .passthrough();
152
160