@hubfluencer/mcp 0.7.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/src/index.ts CHANGED
@@ -9,9 +9,11 @@
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
- * → set_scene_countset_segment_prompt generate_segment(s)
15
+ * → apply_scenario (scenario scene prompts) | set_scene_count + set_segment_prompt
16
+ * → generate_segment(s) (+ regenerate_segment to re-roll a completed scene)
15
17
  * → set_narration_script|generate_narration → generate_voice + generate_music
16
18
  * → render → wait_for_completion → download_result
17
19
  */
@@ -500,7 +502,7 @@ const SERVER_INSTRUCTIONS = `Hubfluencer turns a prompt — or a whole product p
500
502
 
501
503
  MAKE ONE ASSET (simplest)
502
504
  - make_video({ prompt }) creates, generates, and returns a finished MP4 — it picks a single-clip short or a multi-scene editor ad from the prompt (override with kind:"short"|"editor"). create_short / create_editor_ad are the per-product one-shots; create_slider makes an image carousel; create_tracking_video burns a CV overlay onto a local clip.
503
- - Granular (full control): create a draft, then drive the pipeline step by step (create_editor_draft → generate_scenario → set_scene_count → set_segment_prompt → generate_segment(s) → generate_voice + generate_music → render). Poll with get_status / wait_for_completion; fetch with download_result (a slider has no MP4 — read its slides with get_slider).
505
+ - Granular (full control): create a draft, then drive the pipeline step by step (create_editor_draft → generate_scenario → apply_scenarioreview/edit prompts (set_segment_prompt) → generate_segment(s) → generate_voice + generate_music → render; regenerate_segment re-rolls a completed scene as a new version). Poll with get_status / wait_for_completion; fetch with download_result (a slider has no MP4 — read its slides with get_slider).
504
506
 
505
507
  DELEGATE THE CONTENT OPERATION (the full loop — steps 1-3 are $0)
506
508
  1. ONBOARD a product: extract_product_page(url) pulls facts + imagery (SSRF-hardened, 0cr); import_product_image saves its picture/logo. Persist identity once with create_product_profile + create_brand_profile so every later draft inherits it instead of re-describing the brand.
@@ -513,7 +515,7 @@ DELEGATE THE CONTENT OPERATION (the full loop — steps 1-3 are $0)
513
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.
514
516
 
515
517
  CREDITS (spent server-side; each tool prices before charging — see its description)
516
- - 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.
517
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.
518
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.
519
521
 
@@ -637,12 +639,20 @@ registerTool(
637
639
  "for simple/short ones; the chosen kind is reported back as kind_inferred. If it returns terminal=false " +
638
640
  "the render is still running — call wait_for_completion with the returned slug to finish. " +
639
641
  "BE PROACTIVE WITH BRANDING: pass headline (the on-screen TITLE) and subheadline (secondary title) plus " +
640
- "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 " +
641
649
  "shorts); headline/subheadline/music_vibe/text_* and the conversion graphics are SHORTS-only and ignored for " +
642
650
  "editor; theme applies to editor (genre overlay) and is legacy-only for shorts (used when visual_language is unset). " +
643
651
  "For richer branding — a product image, brand logo, or " +
644
652
  "closing card — drive the granular path instead: create_short + set_short_product/set_short_poster, or " +
645
- "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.",
646
656
  // Schema kept intentionally flat (no .min/.max/.int chains) — the SDK's
647
657
  // generic inference on registerTool hits TS2589 ("excessively deep") on
648
658
  // larger schemas with chained validators. Ranges are enforced in code.
@@ -690,9 +700,11 @@ registerTool(
690
700
  ),
691
701
  headline: z
692
702
  .string()
703
+ .max(80)
693
704
  .optional()
694
705
  .describe(
695
- "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.",
696
708
  ),
697
709
  subheadline: z
698
710
  .string()
@@ -723,12 +735,15 @@ registerTool(
723
735
  cta_text: z
724
736
  .string()
725
737
  .optional()
726
- .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
+ ),
727
741
  badge_text: z
728
742
  .string()
729
743
  .optional()
730
744
  .describe(
731
- "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.",
732
747
  ),
733
748
  star_rating: z
734
749
  .number()
@@ -737,7 +752,15 @@ registerTool(
737
752
  .max(5)
738
753
  .optional()
739
754
  .describe(
740
- "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).",
741
764
  ),
742
765
  text_position: z
743
766
  .enum(["top", "center", "bottom"])
@@ -848,6 +871,7 @@ registerTool(
848
871
  cta_text?: string;
849
872
  badge_text?: string;
850
873
  star_rating?: number;
874
+ skip_auto_text?: boolean;
851
875
  text_position?: string;
852
876
  text_animation?: string;
853
877
  font_family?: string;
@@ -974,6 +998,7 @@ registerTool(
974
998
  args.creative_format ?? "",
975
999
  args.visual_language ?? "",
976
1000
  args.theme ?? "",
1001
+ args.skip_auto_text ? "bare" : "auto-copy",
977
1002
  ),
978
1003
  );
979
1004
  slug = created.data.slug;
@@ -1023,7 +1048,7 @@ registerTool(
1023
1048
 
1024
1049
  const resume =
1025
1050
  kind === "short"
1026
- ? `generate_short({ slug: "${slug}" })`
1051
+ ? `generate_short({ slug: "${slug}"${args.skip_auto_text ? ", skip_auto_text: true" : ""} })`
1027
1052
  : `start_autopilot({ slug: "${slug}" })`;
1028
1053
  const affordable =
1029
1054
  available_credits === null ||
@@ -1080,11 +1105,15 @@ registerTool(
1080
1105
  if (kind === "short") {
1081
1106
  await client.post(
1082
1107
  `/shorts/${slug}/generate`,
1083
- 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,
1084
1111
  // gen-short:<slug> + the failure-state discriminator: a fresh/in-flight
1085
1112
  // short reuses "gen-short:<slug>:start" (transport-retry dedupe), a
1086
1113
  // re-run after a failed render gets a fresh key and re-renders.
1087
- `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" : ""}`,
1088
1117
  );
1089
1118
  } else {
1090
1119
  await client.post(
@@ -1281,10 +1310,19 @@ registerTool(
1281
1310
  title: "Create a short (draft)",
1282
1311
  description:
1283
1312
  "Creates a short draft from a product prompt (min 10 chars). A short is a 12s vertical (two 6s AI " +
1284
- "segments + an on-screen title overlay + music; 14s when an end-card poster is set). Returns the slug, " +
1285
- "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). " +
1286
1317
  "BE PROACTIVE: don't ship a bare clip — set a headline (the on-screen TITLE) and subheadline (secondary " +
1287
- "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 " +
1288
1326
  "(set_short_product) or an end-card poster (set_short_poster) before generate_short. (Shorts have no logo " +
1289
1327
  "overlay — that's an editor-only feature; use create_editor_ad for logo branding.)",
1290
1328
  inputSchema: {
@@ -1300,10 +1338,11 @@ registerTool(
1300
1338
  ),
1301
1339
  headline: z
1302
1340
  .string()
1303
- .max(160)
1341
+ .max(80)
1304
1342
  .optional()
1305
1343
  .describe(
1306
- "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.",
1307
1346
  ),
1308
1347
  subheadline: z
1309
1348
  .string()
@@ -1394,19 +1433,27 @@ registerTool(
1394
1433
  .string()
1395
1434
  .max(24)
1396
1435
  .optional()
1397
- .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
+ ),
1398
1439
  badge_text: z
1399
1440
  .string()
1400
1441
  .max(24)
1401
1442
  .optional()
1402
- .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
+ ),
1403
1447
  star_rating: z
1404
1448
  .number()
1405
1449
  .int()
1406
1450
  .min(0)
1407
1451
  .max(5)
1408
1452
  .optional()
1409
- .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
+ ),
1410
1457
  text_beats: z
1411
1458
  .array(z.string().max(120))
1412
1459
  .max(8)
@@ -1414,6 +1461,30 @@ registerTool(
1414
1461
  .describe(
1415
1462
  "Optional caption beats shown sequentially instead of the static subheadline.",
1416
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
+ ),
1417
1488
  brand_profile_id: z
1418
1489
  .number()
1419
1490
  .int()
@@ -1450,6 +1521,9 @@ registerTool(
1450
1521
  badge_text?: string;
1451
1522
  star_rating?: number;
1452
1523
  text_beats?: string[];
1524
+ closing_claim?: string;
1525
+ brand_name?: string;
1526
+ end_card?: string;
1453
1527
  brand_profile_id?: number;
1454
1528
  },
1455
1529
  client,
@@ -1486,6 +1560,10 @@ registerTool(
1486
1560
  if (args.badge_text !== undefined) body.badge_text = args.badge_text;
1487
1561
  if (args.star_rating !== undefined) body.star_rating = args.star_rating;
1488
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;
1489
1567
  if (args.brand_profile_id !== undefined)
1490
1568
  body.brand_profile_id = args.brand_profile_id;
1491
1569
  const res = await client.post<{ data: { slug: string } }>(
@@ -1513,13 +1591,16 @@ registerTool(
1513
1591
  args.badge_text ?? "",
1514
1592
  String(args.star_rating ?? ""),
1515
1593
  JSON.stringify(args.text_beats ?? []),
1594
+ args.closing_claim ?? "",
1595
+ args.brand_name ?? "",
1596
+ args.end_card ?? "",
1516
1597
  String(args.brand_profile_id ?? ""),
1517
1598
  ),
1518
1599
  );
1519
1600
  return ok({
1520
1601
  slug: res.data.slug,
1521
1602
  kind: "short",
1522
- 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).",
1523
1604
  });
1524
1605
  },
1525
1606
  ),
@@ -1531,8 +1612,19 @@ registerTool(
1531
1612
  title: "Update a short draft (0 credits)",
1532
1613
  description:
1533
1614
  "Patches an existing short draft — only the fields you pass change; omitted fields are left untouched. " +
1534
- "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, " +
1535
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). " +
1536
1628
  "CLEARING a field: the conversion graphics (offer_text, cta_text, badge_text, star_rating) and " +
1537
1629
  'text_beats are OPT-IN and OFF by default — pass "" (or 0 for star_rating, [] for text_beats) to REMOVE ' +
1538
1630
  "one that was set. The four conversion graphics are user-supplied only and never written by the AI copy " +
@@ -1556,9 +1648,12 @@ registerTool(
1556
1648
  ),
1557
1649
  headline: z
1558
1650
  .string()
1559
- .max(160)
1651
+ .max(80)
1560
1652
  .optional()
1561
- .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
+ ),
1562
1657
  subheadline: z
1563
1658
  .string()
1564
1659
  .max(200)
@@ -1644,13 +1739,16 @@ registerTool(
1644
1739
  .string()
1645
1740
  .max(24)
1646
1741
  .optional()
1647
- .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
+ ),
1648
1745
  badge_text: z
1649
1746
  .string()
1650
1747
  .max(24)
1651
1748
  .optional()
1652
1749
  .describe(
1653
- '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.',
1654
1752
  ),
1655
1753
  star_rating: z
1656
1754
  .number()
@@ -1659,7 +1757,8 @@ registerTool(
1659
1757
  .max(5)
1660
1758
  .optional()
1661
1759
  .describe(
1662
- "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.",
1663
1762
  ),
1664
1763
  text_beats: z
1665
1764
  .array(z.string().max(120))
@@ -1668,6 +1767,37 @@ registerTool(
1668
1767
  .describe(
1669
1768
  "Caption beats shown sequentially instead of the static subheadline. Pass [] to clear.",
1670
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
+ ),
1671
1801
  brand_profile_id: z
1672
1802
  .number()
1673
1803
  .int()
@@ -1709,6 +1839,10 @@ registerTool(
1709
1839
  badge_text?: string;
1710
1840
  star_rating?: number;
1711
1841
  text_beats?: string[];
1842
+ closing_claim?: string;
1843
+ brand_name?: string;
1844
+ end_card?: string;
1845
+ poster_includes_lockup?: boolean;
1712
1846
  brand_profile_id?: number;
1713
1847
  },
1714
1848
  client,
@@ -1749,6 +1883,12 @@ registerTool(
1749
1883
  if (args.badge_text !== undefined) body.badge_text = args.badge_text;
1750
1884
  if (args.star_rating !== undefined) body.star_rating = args.star_rating;
1751
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;
1752
1892
  if (args.brand_profile_id !== undefined)
1753
1893
  body.brand_profile_id = args.brand_profile_id;
1754
1894
 
@@ -1756,7 +1896,15 @@ registerTool(
1756
1896
  `/shorts/${args.slug}`,
1757
1897
  body,
1758
1898
  );
1759
- 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
+ });
1760
1908
  },
1761
1909
  ),
1762
1910
  );
@@ -2132,16 +2280,30 @@ registerTool(
2132
2280
  {
2133
2281
  title: "Generate (render) a short",
2134
2282
  description:
2135
- "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 " +
2136
2287
  "duplicate while a render is in flight is reported as in-progress (no double charge), and a failed " +
2137
- "short can be re-generated. Then poll with get_status or wait_for_completion (kind=short).",
2138
- 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
+ },
2139
2301
  // Not idempotent: a repeat after a terminal (failed) render re-charges 15
2140
2302
  // credits (the whole point of allowing a re-generation), so a blind retry is
2141
2303
  // NOT free — only an in-flight duplicate dedupes (server 409). See below.
2142
2304
  annotations: { title: "Generate short", ...WRITE },
2143
2305
  },
2144
- tool(async (args: { slug: string }, client) => {
2306
+ tool(async (args: { slug: string; skip_auto_text?: boolean }, client) => {
2145
2307
  // No stable idempotency key (see generate_slider): a per-slug key would
2146
2308
  // replay the first response for 24h and block an intentional re-generation
2147
2309
  // of a failed short. Double-charge is prevented server-side — the batch
@@ -2150,6 +2312,9 @@ registerTool(
2150
2312
  try {
2151
2313
  const res = await client.post<{ data: unknown }>(
2152
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,
2153
2318
  );
2154
2319
  const status = normalizeStatus("short", args.slug, asRecord(res).data);
2155
2320
  return ok(status);
@@ -2170,6 +2335,89 @@ registerTool(
2170
2335
  }),
2171
2336
  );
2172
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
+
2173
2421
  registerTool(
2174
2422
  "generate_short_text",
2175
2423
  {
@@ -2578,7 +2826,9 @@ registerTool(
2578
2826
  "BRANDING: to attach the user's own product image, brand logo, or closing card, create the draft with " +
2579
2827
  "create_editor_draft first, call set_product / set_logo / set_closing_image (all 0 credits), then " +
2580
2828
  "start_autopilot — autopilot weaves them in. (Editor ads carry their copy in-scene/narration, not as a " +
2581
- "title overlay; for an on-screen title/subtitle use a short with headline/subheadline instead.)",
2829
+ "title overlay; for an on-screen title/subtitle use a short with headline/subheadline instead.) " +
2830
+ "For product_only or presenter_led cast modes, use create_editor_draft and configure the required " +
2831
+ "product/presenter before start_autopilot; this one-shot tool intentionally uses automatic casting.",
2582
2832
  inputSchema: {
2583
2833
  product_prompt: z
2584
2834
  .string()
@@ -2826,7 +3076,8 @@ registerTool(
2826
3076
  // ad with full control (review the scenario, write its own prompts, etc.)
2827
3077
  // instead of one-shot autopilot (create_editor_ad / make_video).
2828
3078
  // create_editor_draft → generate_scenario|set_scenario → get_editor (REVIEW)
2829
- // → set_scene_countset_segment_prompt generate_segment|generate_all_segments
3079
+ // → apply_scenario (scenario per-scene prompts) | set_scene_count + set_segment_prompt
3080
+ // → generate_segment|generate_all_segments (+ regenerate_segment for completed scenes)
2830
3081
  // → set_narration_script|generate_narration → generate_voice + generate_music
2831
3082
  // → render → wait_for_completion(kind:"editor") → download_result.
2832
3083
 
@@ -2877,6 +3128,17 @@ registerTool(
2877
3128
  .describe(
2878
3129
  'Visual theme / genre overlay (default "realistic"). One of: none (no imposed style — segments follow your prompts literally), realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, minimalist, cyberpunk.',
2879
3130
  ),
3131
+ cast_mode: z
3132
+ .enum(["product_only", "presenter_led"])
3133
+ .optional()
3134
+ .describe(
3135
+ "Who/what anchors every AI scene. product_only = a product-led ad with NO people; requires a " +
3136
+ "product image (set_product) before generation and generates every scene from persistent product " +
3137
+ "reference images — the best product fidelity. presenter_led = one locked presenter appears in " +
3138
+ "every scene; requires a presenter to be configured first (in the app / REST presenter endpoints " +
3139
+ "— no MCP tool yet), else generation fails with presenter_required. Omit for the default auto " +
3140
+ "behavior.",
3141
+ ),
2880
3142
  voice_id: z
2881
3143
  .string()
2882
3144
  .regex(/^[A-Za-z0-9_-]+$/)
@@ -2913,6 +3175,7 @@ registerTool(
2913
3175
  creative_format?: string;
2914
3176
  visual_language?: string;
2915
3177
  theme?: string;
3178
+ cast_mode?: string;
2916
3179
  voice_id?: string;
2917
3180
  export_aspect_ratio?: string;
2918
3181
  project_intent?: string;
@@ -2926,6 +3189,7 @@ registerTool(
2926
3189
  creative_format: args.creative_format,
2927
3190
  visual_language: args.visual_language,
2928
3191
  theme: args.theme,
3192
+ cast_mode: args.cast_mode,
2929
3193
  voice_id: args.voice_id,
2930
3194
  export_aspect_ratio: args.export_aspect_ratio,
2931
3195
  project_intent: args.project_intent,
@@ -2948,12 +3212,13 @@ registerTool(
2948
3212
  args.theme ?? "",
2949
3213
  args.voice_id ?? "",
2950
3214
  args.export_aspect_ratio ?? "",
3215
+ args.cast_mode ?? "",
2951
3216
  ),
2952
3217
  );
2953
3218
  return ok({
2954
3219
  slug: created.data.slug,
2955
3220
  kind: "editor",
2956
- next: "generate_scenario | set_scenario, then get_editor to review",
3221
+ next: "generate_scenario | set_scenario, then get_editor to review, then apply_scenario",
2957
3222
  });
2958
3223
  },
2959
3224
  ),
@@ -3011,7 +3276,10 @@ registerTool(
3011
3276
  "(quota exhausted) either set auto_unlock:true to spend 1 credit for +10 assists and retry once, or " +
3012
3277
  "write the scenario yourself with set_scenario. Server default segments_count is 5. " +
3013
3278
  "Optionally pass creative_format / visual_language / theme to persist the project\u2019s creative style before " +
3014
- "generating (this is the granular path\u2019s way to set/change style; PATCH /scenario does not).",
3279
+ "generating (this is the granular path\u2019s way to set/change style; PATCH /scenario does not). " +
3280
+ "Generation is ASYNC: get_editor shows scenario_status generating \u2192 ready (a few seconds). NEXT STEP: " +
3281
+ "once ready, review the scenario with get_editor, then call apply_scenario to turn it into per-scene " +
3282
+ "segment prompts on the timeline.",
3015
3283
  inputSchema: {
3016
3284
  slug: z.string().describe("Editor project slug"),
3017
3285
  segments_count: z
@@ -3083,6 +3351,111 @@ registerTool(
3083
3351
  ),
3084
3352
  );
3085
3353
 
3354
+ registerTool(
3355
+ "apply_scenario",
3356
+ {
3357
+ title: "Apply the scenario to the timeline (per-scene prompts)",
3358
+ description:
3359
+ "Turns the READY scenario into per-scene segment prompts on the timeline — the missing step between " +
3360
+ "generate_scenario and generate_segment in the granular flow. Existing AI segments are REPLACED with " +
3361
+ "freshly generated scene prompts (uploaded clips are preserved); segments_count 0 removes the AI scenes " +
3362
+ "and keeps the scenario only. Free — no credits, no AI assist (rate limit 15/min). Requires " +
3363
+ 'scenario_status "ready" (422 scenario_not_ready otherwise): after generate_scenario wait until ' +
3364
+ "get_editor shows ready (a few seconds); set_scenario is ready immediately. The apply itself runs async " +
3365
+ "server-side — this tool polls get_editor (bounded ~2 min) until scenario_apply_status leaves " +
3366
+ '"applying" and returns the materialized prompts; if it reports timed_out, keep polling get_editor ' +
3367
+ "yourself. Then review/edit with set_segment_prompt and generate with generate_segment / " +
3368
+ "generate_all_segments (or just render).",
3369
+ inputSchema: {
3370
+ slug: z.string().describe("Editor project slug"),
3371
+ segments_count: z
3372
+ .union([z.literal(0), z.number().int().min(3).max(10)])
3373
+ .optional()
3374
+ .describe(
3375
+ "How many AI scenes to create on the timeline — 0 or any integer from 3 to 10. Omit to use " +
3376
+ "the scene count saved by generate_scenario (or 5 when none is stored). " +
3377
+ "0 keeps the scenario only and preserves manual timeline control.",
3378
+ ),
3379
+ },
3380
+ annotations: { title: "Apply scenario", ...WRITE, idempotentHint: false },
3381
+ },
3382
+ tool(
3383
+ async (args: { slug: string; segments_count?: number }, client, extra) => {
3384
+ const body: Record<string, unknown> = {};
3385
+ if (args.segments_count !== undefined)
3386
+ body.segments_count = args.segments_count;
3387
+ const res = await client.post<{ data: unknown }>(
3388
+ `/editor/${args.slug}/apply-scenario`,
3389
+ body,
3390
+ );
3391
+ const accepted = asRecord(asRecord(res).data);
3392
+
3393
+ // The 202 only STARTS the apply — the controller flips
3394
+ // scenario_apply_status to "applying" before responding, and an async
3395
+ // worker compiles the scenario into per-scene prompts. Poll the project
3396
+ // until the status leaves "applying" ("idle" = success, "failed" =
3397
+ // terminal) so the agent gets the materialized prompts back instead of
3398
+ // having to poll itself. The budget is far below the render loops' 280s
3399
+ // ceiling: an apply is a single LLM call (typically seconds).
3400
+ const APPLY_BUDGET_MS = 120_000;
3401
+ const APPLY_POLL_MS = 2_500;
3402
+ const deadline = Date.now() + APPLY_BUDGET_MS;
3403
+ const readEditor = async () => {
3404
+ // Bound each poll GET's retries by the loop deadline (see fetchStatus).
3405
+ const r = await client.get<{ data: unknown }>(
3406
+ `/editor/${args.slug}`,
3407
+ undefined,
3408
+ deadline,
3409
+ );
3410
+ return asRecord(asRecord(r).data);
3411
+ };
3412
+ let n = 0;
3413
+ let editor = await readEditor();
3414
+ while (
3415
+ editor.scenario_apply_status === "applying" &&
3416
+ Date.now() + APPLY_POLL_MS <= deadline
3417
+ ) {
3418
+ await reportProgress(extra, ++n, "applying scenario to timeline");
3419
+ await sleep(APPLY_POLL_MS);
3420
+ editor = await readEditor();
3421
+ }
3422
+
3423
+ if (editor.scenario_apply_status === "applying") {
3424
+ return ok({
3425
+ slug: args.slug,
3426
+ applied: false,
3427
+ timed_out: true,
3428
+ note: 'The apply is still running server-side. Poll get_editor until scenario_apply_status is no longer "applying", then review the scene prompts.',
3429
+ });
3430
+ }
3431
+ if (editor.scenario_apply_status === "failed") {
3432
+ return fail(
3433
+ "Scenario apply failed server-side. Re-run apply_scenario to retry, or build the timeline yourself with set_scene_count + set_segment_prompt.",
3434
+ );
3435
+ }
3436
+ const segments = Array.isArray(editor.segments)
3437
+ ? (editor.segments as Record<string, unknown>[])
3438
+ : [];
3439
+ return ok({
3440
+ slug: args.slug,
3441
+ applied: true,
3442
+ ai_segments_replaced: accepted.ai_segments_replaced,
3443
+ uploads_preserved: accepted.uploads_preserved,
3444
+ target_ai_segments: accepted.target_ai_segments,
3445
+ scene_count: segments.length,
3446
+ segments: segments.map((s) => ({
3447
+ id: s.id,
3448
+ position: s.position,
3449
+ source_type: s.source_type,
3450
+ status: s.status,
3451
+ prompt: s.prompt,
3452
+ })),
3453
+ next: "Review/edit prompts (set_segment_prompt), then generate_segment / generate_all_segments — or just render (it auto-charges ungenerated scenes at the batch rate)",
3454
+ });
3455
+ },
3456
+ ),
3457
+ );
3458
+
3086
3459
  registerTool(
3087
3460
  "set_scene_count",
3088
3461
  {
@@ -3214,8 +3587,10 @@ registerTool(
3214
3587
  {
3215
3588
  title: "Generate one segment (5 credits)",
3216
3589
  description:
3217
- "Renders the video for one scene. Each AI-generated scene is a fixed 8 seconds long. Costs 5 credits. " +
3218
- "Re-call to retry a failed scene or regenerate after a prompt edit — the server blocks regenerating one that's still processing, so no double-charge. Visual" +
3590
+ "Renders the video for one PENDING or FAILED scene. Each AI-generated scene is a fixed 8 seconds long. " +
3591
+ "Costs 5 credits. Re-call to retry a failed scene or after a prompt edit — the server blocks one that's " +
3592
+ "still processing (editor_segment_busy), so no double-charge; a COMPLETED scene returns " +
3593
+ "editor_segment_completed — re-roll those with regenerate_segment instead. Visual " +
3219
3594
  "continuity is handled server-side (this scene uses the previous scene's last frame). 402 " +
3220
3595
  "credits_insufficient fails cleanly. Generate scenes in position order for correct continuity.",
3221
3596
  inputSchema: {
@@ -3236,6 +3611,54 @@ registerTool(
3236
3611
  }),
3237
3612
  );
3238
3613
 
3614
+ registerTool(
3615
+ "regenerate_segment",
3616
+ {
3617
+ title: "Regenerate a completed segment (new version, 4 credits)",
3618
+ description:
3619
+ "Re-rolls a COMPLETED scene as a NEW VERSION of that segment — the previous version is kept (switch back " +
3620
+ "in the app). Costs 4 credits. Use generate_segment for pending/failed scenes; use THIS for a completed " +
3621
+ "scene you want different — optionally with a new prompt (omit it to re-roll the current prompt). The new " +
3622
+ "version starts rendering immediately; watch it with get_editor (status processing → completed|failed). " +
3623
+ "KNOWN LIMITATION: scenes chain visual continuity off the previous scene's last frame, so regenerating a " +
3624
+ "MID-TIMELINE scene can degrade the transition into the FOLLOWING scene (it was seeded from the old " +
3625
+ "version's last frame). A scene still processing returns editor_segment_busy (no double charge); 402 " +
3626
+ "credits_insufficient fails cleanly.",
3627
+ inputSchema: {
3628
+ slug: z.string().describe("Editor project slug"),
3629
+ segment_id: z
3630
+ .union([z.number(), z.string()])
3631
+ .describe("Segment id (from get_editor)"),
3632
+ prompt: z
3633
+ .string()
3634
+ .min(1)
3635
+ .max(2000)
3636
+ .optional()
3637
+ .describe(
3638
+ "Optional new scene prompt for the regenerated version (1–2000 chars); omit to reuse the current prompt",
3639
+ ),
3640
+ },
3641
+ annotations: {
3642
+ title: "Regenerate segment",
3643
+ ...WRITE,
3644
+ idempotentHint: false,
3645
+ },
3646
+ },
3647
+ tool(
3648
+ async (
3649
+ args: { slug: string; segment_id: number | string; prompt?: string },
3650
+ client,
3651
+ ) => {
3652
+ const res = await client.post<{ data: unknown }>(
3653
+ `/editor/${args.slug}/segments/${String(args.segment_id)}/regenerate`,
3654
+ args.prompt !== undefined ? { prompt: args.prompt } : undefined,
3655
+ undefined,
3656
+ );
3657
+ return ok(asRecord(res).data ?? res);
3658
+ },
3659
+ ),
3660
+ );
3661
+
3239
3662
  registerTool(
3240
3663
  "generate_all_segments",
3241
3664
  {
@@ -4200,21 +4623,43 @@ registerTool(
4200
4623
  inputSchema: {
4201
4624
  slug: z.string().describe("Short slug (from create_short)"),
4202
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
+ ),
4203
4633
  },
4204
4634
  annotations: { title: "Set short poster", ...WRITE, idempotentHint: false },
4205
4635
  },
4206
- tool(async (args: { slug: string; file_path: string }, client) => {
4207
- const { s3_key } = await uploadShortPoster(
4636
+ tool(
4637
+ async (
4638
+ args: {
4639
+ slug: string;
4640
+ file_path: string;
4641
+ poster_includes_lockup?: boolean;
4642
+ },
4208
4643
  client,
4209
- args.slug,
4210
- args.file_path,
4211
- );
4212
- const res = await client.post<{ data: unknown }>(
4213
- `/shorts/${args.slug}/poster/confirm`,
4214
- { s3_key },
4215
- );
4216
- return ok(asRecord(res).data ?? res);
4217
- }),
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
+ ),
4218
4663
  );
4219
4664
 
4220
4665
  // ── Status / waiting ──────────────────────────────────────────────────────────