@hubfluencer/mcp 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -9,7 +9,8 @@
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
- * Editor : create_editor_ad (creates + starts autopilot)wait_for_completion download_result
12
+ * (then iterate free: update_shortrerender_short, 0 credits)
13
+ * Editor : create_editor_ad (create + assets + quote; max_credits launches) → 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
15
16
  * → generate_segment(s) (+ regenerate_segment to re-roll a completed scene)
@@ -115,7 +116,14 @@ import {
115
116
  } from "./series-calendar.js";
116
117
  import {
117
118
  CATALOG_ASSET_EXTS,
119
+ closePreparedUploadFile,
118
120
  IMAGE_EXTS,
121
+ MAX_CLOSING_IMAGE_BYTES,
122
+ MAX_LOGO_BYTES,
123
+ MAX_PRODUCT_IMAGE_BYTES,
124
+ type PreparedUploadFile,
125
+ preparedUploadIdentity,
126
+ prepareImageUpload,
119
127
  resolveVideoReadPath,
120
128
  uploadCatalogAsset,
121
129
  uploadImageFile,
@@ -145,6 +153,68 @@ async function fetchStatus(
145
153
  return normalizeStatus(kind, slug, asRecord(res).data);
146
154
  }
147
155
 
156
+ async function editorAutopilotQuote(
157
+ client: HubfluencerClient,
158
+ slug: string,
159
+ overrides: {
160
+ language?: string;
161
+ product_subject?: string;
162
+ product_prompt?: string;
163
+ } = {},
164
+ ): Promise<{
165
+ estimated_credits: number | null;
166
+ available_credits: number | null;
167
+ affordable: boolean;
168
+ }> {
169
+ try {
170
+ const params = new URLSearchParams();
171
+ if (overrides.language) params.set("language", overrides.language);
172
+ if (overrides.product_subject)
173
+ params.set("product_subject", overrides.product_subject);
174
+ if (overrides.product_prompt)
175
+ params.set("product_prompt", overrides.product_prompt);
176
+ const query = params.toString();
177
+ const response = await client.get<{ data: unknown }>(
178
+ `/editor/${slug}/autopilot/cost${query ? `?${query}` : ""}`,
179
+ );
180
+ const cost = asRecord(asRecord(response).data);
181
+ const estimated_credits =
182
+ typeof cost.total === "number" ? cost.total : null;
183
+ const available_credits =
184
+ typeof cost.available_credits === "number"
185
+ ? cost.available_credits
186
+ : null;
187
+ return {
188
+ estimated_credits,
189
+ available_credits,
190
+ affordable:
191
+ estimated_credits !== null &&
192
+ available_credits !== null &&
193
+ available_credits >= estimated_credits,
194
+ };
195
+ } catch (error) {
196
+ const apiError = error as Partial<HubfluencerError>;
197
+ if (
198
+ typeof apiError.status === "number" &&
199
+ apiError.status >= 400 &&
200
+ apiError.status < 500 &&
201
+ apiError.status !== 429
202
+ ) {
203
+ // Authentication, scope, validation, and unknown-slug errors are stable
204
+ // and actionable. Preserve them rather than disguising them as a pricing
205
+ // outage. Only transient/network/server failures use the fail-closed quote.
206
+ throw error;
207
+ }
208
+ // A spend cap cannot be enforced without a live price. Fail closed: callers
209
+ // receive a free draft/quote response and can retry the preflight later.
210
+ return {
211
+ estimated_credits: null,
212
+ available_credits: null,
213
+ affordable: false,
214
+ };
215
+ }
216
+ }
217
+
148
218
  function isRecompositeInProgressConflict(e: unknown): boolean {
149
219
  const err = e as Partial<HubfluencerError>;
150
220
  if (err?.status !== 409) return false;
@@ -500,26 +570,26 @@ const WRITE = {
500
570
  const SERVER_INSTRUCTIONS = `Hubfluencer turns a prompt — or a whole product page — into finished, post-ready vertical videos and image carousels, and can run the surrounding content operation (plan → draft → review → schedule → measure → repeat).
501
571
 
502
572
  MAKE ONE ASSET (simplest)
503
- - 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.
573
+ - 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_editor_ad creates/configures/prices an editor and launches only with max_credits; create_short creates a short draft; create_slider makes an image carousel; create_tracking_video burns a CV overlay onto a local clip.
504
574
  - Granular (full control): create a draft, then drive the pipeline step by step (create_editor_draft → generate_scenario → apply_scenario → review/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).
505
575
 
506
576
  DELEGATE THE CONTENT OPERATION (the full loop — steps 1-3 are $0)
507
577
  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.
508
578
  2. PLAN ($0, free quota — not credits): plan_campaign proposes angles/formats + a per-item credit estimate; generate_hook_variations writes hook options. create_campaign(url | product_profile_id) does 1-2 end-to-end and returns a pack of editable DRAFTS.
509
579
  3. PACK & MATERIALIZE ($0): create_campaign_pack / add_pack_variations assemble items; materialize_pack_item turns each into a 0-credit draft (editor_ad/short → a video project, carousel → a slider). list_campaign_packs lists packs; get_campaign_pack({ id }) shows every item's draft slug + credit estimate.
510
- 4. GENERATE per draft (CREDITS — a separate, explicit step): run start_autopilot/generate_short/generate_slider on each draft slug. Nothing above this line spends video credits.
580
+ 4. GENERATE per draft (CREDITS — a separate, explicit step): start_autopilot({slug}) quotes free; after approval, start_autopilot({slug,max_credits:approved_cap}) launches. Run generate_short/generate_slider for approved short/carousel drafts. Nothing above this line spends video credits.
511
581
  5. REVIEW: create_review_link mints a no-login page where a human approves/comments (list_review_links / revoke_review_link manage them). Gate spend or posting on approval.
512
582
  6. CALENDAR (reminder-only): schedule_post / list_scheduled_posts / reschedule_post / cancel_scheduled_post stage a calendar that pushes REMINDERS to the user — it does NOT auto-publish (posting to TikTok/IG stays a human step in the app). After the user posts the presigned export, call mark_scheduled_posted.
513
583
  7. MEASURE → DOUBLE DOWN: record_performance logs views/engagement (tag creatives via set_item_attributes); get_recommendations gives a CAUTIOUS, non-causal read of your own best hook/format (present its confidence + note as-is, never upgrade them); make_more_like_winner spins fresh hooks from a proven item — feed a winner back into add_pack_variations → step 4.
514
584
  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
585
 
516
586
  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
- - 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.
587
+ - 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.
588
+ - 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 supports dry_run/max_credits; create_editor_ad/start_autopilot require max_credits before launching.
519
589
  - 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
590
 
521
591
  WAITING & RESUMING
522
- - Renders run async and can take minutes. wait_for_completion polls a budget capped at 280s; if it returns terminal=false / timed_out, just CALL IT AGAIN with the same slug. make_video returns the slug + a resume hint on timeout.
592
+ - Renders run async and can take minutes. wait_for_completion polls a budget capped at 280s; if it returns terminal=false / timed_out, just CALL IT AGAIN with the same slug. make_video returns the slug + a resume hint on timeout. A terminal result is not always ready: an editor can go terminal in a needs-action stage (editing_required = stale after edits, regenerate the flagged scenes/audio; insufficient_credits = batch parked, top up / reduce scope; idle = draft with nothing generating, start generation first) — act on those instead of re-polling.
523
593
  - Creates and charged actions are idempotency-keyed: a transport retry is safe; a genuine re-run after a terminal FAILURE restarts rather than replaying the dead attempt.
524
594
 
525
595
  LOCAL FILES (sandboxed)
@@ -638,12 +708,20 @@ registerTool(
638
708
  "for simple/short ones; the chosen kind is reported back as kind_inferred. If it returns terminal=false " +
639
709
  "the render is still running — call wait_for_completion with the returned slug to finish. " +
640
710
  "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 " +
711
+ "music_vibe so a one-shot short isn't bare. Always set cta_text a short without a CTA doesn't convert " +
712
+ "(plus offer_text only if there's a real deal). Note: blank cta_text → the server renders a neutral " +
713
+ "localized ask (e.g. 'Learn more'); a truly CTA-less short is not currently supported. " +
714
+ "Keep headline ≈4 words (≤40 chars). Set visual_language " +
715
+ "explicitly (kinetic_creator is a strong default). If headline/subheadline/text_beats are left blank the " +
716
+ "server auto-writes the headline + caption beats at generate time; pass skip_auto_text:true for a " +
717
+ "deliberately bare clip. creative_format + visual_language apply to BOTH kinds (editor and " +
642
718
  "shorts); headline/subheadline/music_vibe/text_* and the conversion graphics are SHORTS-only and ignored for " +
643
719
  "editor; theme applies to editor (genre overlay) and is legacy-only for shorts (used when visual_language is unset). " +
644
720
  "For richer branding — a product image, brand logo, or " +
645
721
  "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.",
722
+ "create_editor_draft + set_product/set_logo/set_closing_image, then start_autopilot/generate_short. " +
723
+ "For a SHORT, iterating after the first render is FREE: update_short (copy/style/CTA/end-card) → " +
724
+ "rerender_short re-renders over the already-paid footage for 0 credits.",
647
725
  // Schema kept intentionally flat (no .min/.max/.int chains) — the SDK's
648
726
  // generic inference on registerTool hits TS2589 ("excessively deep") on
649
727
  // larger schemas with chained validators. Ranges are enforced in code.
@@ -691,9 +769,11 @@ registerTool(
691
769
  ),
692
770
  headline: z
693
771
  .string()
772
+ .max(80)
694
773
  .optional()
695
774
  .describe(
696
- "SHORTS only: the on-screen TITLE overlay (160). Set this so the short isn't bare.",
775
+ "SHORTS only: the on-screen TITLE overlay. ≈4 words; 40 chars hits hardest; hard cap 80 " +
776
+ "longer hooks wrap past the safe area. Set this so the short isn't bare.",
697
777
  ),
698
778
  subheadline: z
699
779
  .string()
@@ -724,12 +804,15 @@ registerTool(
724
804
  cta_text: z
725
805
  .string()
726
806
  .optional()
727
- .describe("SHORTS only: optional CTA pill, e.g. Shop now (≤24 chars)"),
807
+ .describe(
808
+ "SHORTS only: CTA pill, e.g. Shop now (≤24 chars). Always set it — a short without a CTA doesn't convert.",
809
+ ),
728
810
  badge_text: z
729
811
  .string()
730
812
  .optional()
731
813
  .describe(
732
- "SHORTS only: optional badge stamp, e.g. BEST SELLER (≤24 chars)",
814
+ "SHORTS only: optional badge stamp, e.g. BEST SELLER (≤24 chars). Only badges you can " +
815
+ "substantiate — fabricated social proof is deceptive advertising.",
733
816
  ),
734
817
  star_rating: z
735
818
  .number()
@@ -738,7 +821,15 @@ registerTool(
738
821
  .max(5)
739
822
  .optional()
740
823
  .describe(
741
- "SHORTS only: optional 0..5 star rating under the subheadline",
824
+ "SHORTS only: optional 0..5 star rating under the subheadline. Only ratings you can " +
825
+ "substantiate — fabricated social proof is deceptive advertising.",
826
+ ),
827
+ skip_auto_text: z
828
+ .boolean()
829
+ .optional()
830
+ .describe(
831
+ "SHORTS only: when headline/subheadline/text_beats are blank the server auto-writes the copy at " +
832
+ "generate time; pass true to skip that and render a deliberately bare clip (default false).",
742
833
  ),
743
834
  text_position: z
744
835
  .enum(["top", "center", "bottom"])
@@ -821,6 +912,8 @@ registerTool(
821
912
  ),
822
913
  max_credits: z
823
914
  .number()
915
+ .int()
916
+ .nonnegative()
824
917
  .optional()
825
918
  .describe(
826
919
  "Spend cap: refuse to start (no charge) if the priced estimate exceeds this. Returns the estimate.",
@@ -849,6 +942,7 @@ registerTool(
849
942
  cta_text?: string;
850
943
  badge_text?: string;
851
944
  star_rating?: number;
945
+ skip_auto_text?: boolean;
852
946
  text_position?: string;
853
947
  text_animation?: string;
854
948
  font_family?: string;
@@ -975,6 +1069,7 @@ registerTool(
975
1069
  args.creative_format ?? "",
976
1070
  args.visual_language ?? "",
977
1071
  args.theme ?? "",
1072
+ args.skip_auto_text ? "bare" : "auto-copy",
978
1073
  ),
979
1074
  );
980
1075
  slug = created.data.slug;
@@ -1000,9 +1095,9 @@ registerTool(
1000
1095
  slug = created.data.slug;
1001
1096
  }
1002
1097
 
1003
- // 2) Preflight cost vs the live balance BEFORE charging. Best-effort: if
1004
- // pricing can't be read we proceed (the charge itself still 402s
1005
- // cleanly), surfacing nulls rather than blocking on a transient error.
1098
+ // 2) Preflight cost vs the live balance BEFORE charging. Shorts retain
1099
+ // their fixed-price compatibility behavior, but an editor without a
1100
+ // readable quote must never fall through to the API's uncapped launch.
1006
1101
  const costPath =
1007
1102
  kind === "short"
1008
1103
  ? `/shorts/${slug}/cost`
@@ -1019,13 +1114,14 @@ registerTool(
1019
1114
  ? cost.available_credits
1020
1115
  : null;
1021
1116
  } catch {
1022
- // pricing unavailable fall through (do not block an affordable run)
1117
+ // The editor branch below fails closed unless the caller supplied an
1118
+ // explicit cap that the launch endpoint can enforce independently.
1023
1119
  }
1024
1120
 
1025
1121
  const resume =
1026
1122
  kind === "short"
1027
- ? `generate_short({ slug: "${slug}" })`
1028
- : `start_autopilot({ slug: "${slug}" })`;
1123
+ ? `generate_short({ slug: "${slug}"${args.skip_auto_text ? ", skip_auto_text: true" : ""} })`
1124
+ : `start_autopilot({ slug: "${slug}", max_credits: ${estimated_credits ?? "<approved cap>"} })`;
1029
1125
  const affordable =
1030
1126
  available_credits === null ||
1031
1127
  estimated_credits === null ||
@@ -1034,17 +1130,28 @@ registerTool(
1034
1130
  args.max_credits != null &&
1035
1131
  estimated_credits != null &&
1036
1132
  estimated_credits > args.max_credits;
1037
-
1038
- // 3) Stop BEFORE charging on dry_run, an over-cap estimate, or a balance
1039
- // we already know is too low. The free draft is returned so the caller
1040
- // can top up / raise the cap and resume — no wasted charge, no orphan
1041
- // on retry (the create idempotency key reuses this slug).
1042
- if (args.dry_run || overCap || !affordable) {
1133
+ const editorPriceUnavailableWithoutCap =
1134
+ kind === "editor" &&
1135
+ estimated_credits === null &&
1136
+ args.max_credits == null;
1137
+
1138
+ // 3) Stop BEFORE charging on dry_run, an uncapped editor with no live
1139
+ // quote, an over-cap estimate, or a balance we already know is too
1140
+ // low. The free draft is returned so the caller can retry the quote,
1141
+ // top up, or raise the cap without an orphan or surprise charge.
1142
+ if (
1143
+ args.dry_run ||
1144
+ editorPriceUnavailableWithoutCap ||
1145
+ overCap ||
1146
+ !affordable
1147
+ ) {
1043
1148
  const note = args.dry_run
1044
1149
  ? `Dry run — created a free draft and priced it (${estimated_credits ?? "?"} credits, have ${available_credits ?? "?"}). To run it: ${resume}.`
1045
- : overCap
1046
- ? `Estimated ${estimated_credits} credits exceeds max_credits ${args.max_credits}; nothing charged. Raise max_credits, or run ${resume}.`
1047
- : `Not enough credits (needs ${estimated_credits ?? "?"}, have ${available_credits ?? "?"}); nothing charged. Top up, then run ${resume}.`;
1150
+ : editorPriceUnavailableWithoutCap
1151
+ ? `Pricing is temporarily unavailable, so the editor was left as a free draft and nothing was charged. Retry the quote, then run start_autopilot({ slug: "${slug}", max_credits: <approved cap> }).`
1152
+ : overCap
1153
+ ? `Estimated ${estimated_credits} credits exceeds max_credits ${args.max_credits}; nothing charged. Raise max_credits, or run ${resume}.`
1154
+ : `Not enough credits (needs ${estimated_credits ?? "?"}, have ${available_credits ?? "?"}); nothing charged. Top up, then run ${resume}.`;
1048
1155
  return ok({
1049
1156
  slug,
1050
1157
  kind,
@@ -1081,16 +1188,31 @@ registerTool(
1081
1188
  if (kind === "short") {
1082
1189
  await client.post(
1083
1190
  `/shorts/${slug}/generate`,
1084
- undefined,
1191
+ // Only send a body when the caller opts out of server auto-copy —
1192
+ // the bare POST stays byte-identical to the pre-0.8.2 wire shape.
1193
+ args.skip_auto_text ? { skip_auto_text: true } : undefined,
1085
1194
  // gen-short:<slug> + the failure-state discriminator: a fresh/in-flight
1086
1195
  // short reuses "gen-short:<slug>:start" (transport-retry dedupe), a
1087
1196
  // re-run after a failed render gets a fresh key and re-renders.
1088
- `gen-short:${slug}:${startState}`,
1197
+ // skip_auto_text is folded in so a bare-clip run never replays a
1198
+ // cached auto-copy run's response (or vice versa) on the same slug.
1199
+ `gen-short:${slug}:${startState}${args.skip_auto_text ? ":bare" : ""}`,
1089
1200
  );
1090
1201
  } else {
1202
+ const approvedCap = args.max_credits ?? estimated_credits;
1203
+ if (approvedCap === null) {
1204
+ // Belt-and-suspenders invariant behind the fail-closed branch above:
1205
+ // never let a future condition refactor revive an uncapped editor POST.
1206
+ throw new Error(
1207
+ "Editor autopilot launch requires max_credits or a live quote",
1208
+ );
1209
+ }
1091
1210
  await client.post(
1092
1211
  `/editor/${slug}/autopilot`,
1093
- undefined,
1212
+ // Use the caller's explicit cap when given, otherwise the live
1213
+ // estimate. The API recomputes under its launch lock and rejects a
1214
+ // price increase before any state change or charge.
1215
+ { max_credits: approvedCap },
1094
1216
  // Fold the create-affecting params AND the failure-state discriminator
1095
1217
  // into the start key so it tracks the inputs + current state, not just
1096
1218
  // the slug. This key is only compared against a retry of *this* tool on
@@ -1282,10 +1404,19 @@ registerTool(
1282
1404
  title: "Create a short (draft)",
1283
1405
  description:
1284
1406
  "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. " +
1407
+ "segments + an on-screen title overlay + music; 14s when it ends on a poster or brand-lockup end card). " +
1408
+ "Returns the slug, costs 0 credits. Follow with generate_short to render. Iteration is FREE after that " +
1409
+ "first generate: the 15 credits bought the footage + music, and every later text/style/CTA/end-card " +
1410
+ "tweak re-renders over them for 0 credits (update_short → rerender_short). " +
1287
1411
  "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 " +
1412
+ "title), and pick a music_vibe plus visual_language that fit the brand. " +
1413
+ "Always set cta_text — a short without a CTA doesn't convert (plus offer_text only if there's a real " +
1414
+ "deal). Note: blank cta_text → the server renders a neutral localized ask (e.g. 'Learn more'); a truly " +
1415
+ "CTA-less short is not currently supported. " +
1416
+ "Keep headline ≈4 words (≤40 chars). Set visual_language explicitly (kinetic_creator is a strong " +
1417
+ "default). If headline/subheadline/text_beats are left blank, the server auto-writes the headline + " +
1418
+ "caption beats at generate time (pass skip_auto_text:true to generate_short for a deliberately bare clip). " +
1419
+ "To brand it further, attach a product image " +
1289
1420
  "(set_short_product) or an end-card poster (set_short_poster) before generate_short. (Shorts have no logo " +
1290
1421
  "overlay — that's an editor-only feature; use create_editor_ad for logo branding.)",
1291
1422
  inputSchema: {
@@ -1301,10 +1432,11 @@ registerTool(
1301
1432
  ),
1302
1433
  headline: z
1303
1434
  .string()
1304
- .max(160)
1435
+ .max(80)
1305
1436
  .optional()
1306
1437
  .describe(
1307
- "The on-screen TITLE composited over the short (poster text overlay). ≤160 chars. Set this.",
1438
+ "The on-screen TITLE composited over the short (poster text overlay). ≈4 words; 40 chars hits " +
1439
+ "hardest; hard cap 80 — longer hooks wrap past the safe area. Set this.",
1308
1440
  ),
1309
1441
  subheadline: z
1310
1442
  .string()
@@ -1395,19 +1527,27 @@ registerTool(
1395
1527
  .string()
1396
1528
  .max(24)
1397
1529
  .optional()
1398
- .describe('Optional CTA pill, e.g. "Shop now"'),
1530
+ .describe(
1531
+ 'CTA pill, e.g. "Shop now". Always set it — a short without a CTA doesn\'t convert.',
1532
+ ),
1399
1533
  badge_text: z
1400
1534
  .string()
1401
1535
  .max(24)
1402
1536
  .optional()
1403
- .describe('Optional badge stamp, e.g. "BEST SELLER"'),
1537
+ .describe(
1538
+ 'Optional badge stamp, e.g. "BEST SELLER". Only badges you can substantiate — fabricated social ' +
1539
+ "proof is deceptive advertising.",
1540
+ ),
1404
1541
  star_rating: z
1405
1542
  .number()
1406
1543
  .int()
1407
1544
  .min(0)
1408
1545
  .max(5)
1409
1546
  .optional()
1410
- .describe("Optional 0..5 star rating under the subheadline"),
1547
+ .describe(
1548
+ "Optional 0..5 star rating under the subheadline. Only ratings you can substantiate — fabricated " +
1549
+ "social proof is deceptive advertising.",
1550
+ ),
1411
1551
  text_beats: z
1412
1552
  .array(z.string().max(120))
1413
1553
  .max(8)
@@ -1415,6 +1555,30 @@ registerTool(
1415
1555
  .describe(
1416
1556
  "Optional caption beats shown sequentially instead of the static subheadline.",
1417
1557
  ),
1558
+ closing_claim: z
1559
+ .string()
1560
+ .max(80)
1561
+ .optional()
1562
+ .describe(
1563
+ "End-card claim shown on the closing slate (≤80 chars) — a rephrase of the promise, not a new " +
1564
+ "claim. Falls back to the headline when unset.",
1565
+ ),
1566
+ brand_name: z
1567
+ .string()
1568
+ .max(40)
1569
+ .optional()
1570
+ .describe(
1571
+ "Brand/product display name (≤40 chars) shown big on the closing brand-lockup slate when no " +
1572
+ "poster is set. Server falls back to your brand profile's name when unset.",
1573
+ ),
1574
+ end_card: z
1575
+ .enum(["auto", "none"])
1576
+ .optional()
1577
+ .describe(
1578
+ '"auto" (default): end on the poster, or on a brand-lockup slate (brand_name + claim + CTA pill) ' +
1579
+ 'when no poster is set — a 14s render. "none": no end card at all (12s, for deliberate ' +
1580
+ "loop-style shorts).",
1581
+ ),
1418
1582
  brand_profile_id: z
1419
1583
  .number()
1420
1584
  .int()
@@ -1451,6 +1615,9 @@ registerTool(
1451
1615
  badge_text?: string;
1452
1616
  star_rating?: number;
1453
1617
  text_beats?: string[];
1618
+ closing_claim?: string;
1619
+ brand_name?: string;
1620
+ end_card?: string;
1454
1621
  brand_profile_id?: number;
1455
1622
  },
1456
1623
  client,
@@ -1487,6 +1654,10 @@ registerTool(
1487
1654
  if (args.badge_text !== undefined) body.badge_text = args.badge_text;
1488
1655
  if (args.star_rating !== undefined) body.star_rating = args.star_rating;
1489
1656
  if (args.text_beats !== undefined) body.text_beats = args.text_beats;
1657
+ if (args.closing_claim !== undefined)
1658
+ body.closing_claim = args.closing_claim;
1659
+ if (args.brand_name !== undefined) body.brand_name = args.brand_name;
1660
+ if (args.end_card !== undefined) body.end_card = args.end_card;
1490
1661
  if (args.brand_profile_id !== undefined)
1491
1662
  body.brand_profile_id = args.brand_profile_id;
1492
1663
  const res = await client.post<{ data: { slug: string } }>(
@@ -1514,13 +1685,16 @@ registerTool(
1514
1685
  args.badge_text ?? "",
1515
1686
  String(args.star_rating ?? ""),
1516
1687
  JSON.stringify(args.text_beats ?? []),
1688
+ args.closing_claim ?? "",
1689
+ args.brand_name ?? "",
1690
+ args.end_card ?? "",
1517
1691
  String(args.brand_profile_id ?? ""),
1518
1692
  ),
1519
1693
  );
1520
1694
  return ok({
1521
1695
  slug: res.data.slug,
1522
1696
  kind: "short",
1523
- next: "set_short_product / set_short_poster (optional branding), then generate_short",
1697
+ 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
1698
  });
1525
1699
  },
1526
1700
  ),
@@ -1532,8 +1706,19 @@ registerTool(
1532
1706
  title: "Update a short draft (0 credits)",
1533
1707
  description:
1534
1708
  "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, " +
1709
+ "0 credits, no re-render by itself. APPLYING the edit: changed only text/style/CTA/end-card fields " +
1710
+ "(headline, subheadline, text_beats, cta_text, offer_text, badge_text, star_rating, colors, font, " +
1711
+ "position, animation, closing_claim, brand_name, poster_includes_lockup, end_card) on an " +
1712
+ "already-generated short? " +
1713
+ "rerender_short applies them for 0 credits. Footage/music-affecting edits (product_prompt, product " +
1714
+ "image, creative_format, visual_language, theme, music_vibe, music_instruments, language) need " +
1715
+ "generate_short (15 credits) again. Use this to edit the brief, copy, " +
1536
1716
  "styling, or the optional conversion graphics after create_short. " +
1717
+ "Always set cta_text — a short without a CTA doesn't convert (plus offer_text only if there's a real " +
1718
+ "deal). Note: blank cta_text → the server renders a neutral localized ask (e.g. 'Learn more'); a truly " +
1719
+ "CTA-less short is not currently supported. " +
1720
+ "Keep headline ≈4 words (≤40 chars). Set visual_language explicitly (kinetic_creator is a strong " +
1721
+ "default). " +
1537
1722
  "CLEARING a field: the conversion graphics (offer_text, cta_text, badge_text, star_rating) and " +
1538
1723
  'text_beats are OPT-IN and OFF by default — pass "" (or 0 for star_rating, [] for text_beats) to REMOVE ' +
1539
1724
  "one that was set. The four conversion graphics are user-supplied only and never written by the AI copy " +
@@ -1557,9 +1742,12 @@ registerTool(
1557
1742
  ),
1558
1743
  headline: z
1559
1744
  .string()
1560
- .max(160)
1745
+ .max(80)
1561
1746
  .optional()
1562
- .describe("On-screen TITLE overlay. ≤160 chars."),
1747
+ .describe(
1748
+ "On-screen TITLE overlay. ≈4 words; ≤40 chars hits hardest; hard cap 80 — longer hooks wrap " +
1749
+ "past the safe area.",
1750
+ ),
1563
1751
  subheadline: z
1564
1752
  .string()
1565
1753
  .max(200)
@@ -1645,13 +1833,16 @@ registerTool(
1645
1833
  .string()
1646
1834
  .max(24)
1647
1835
  .optional()
1648
- .describe('CTA pill (opt-in), e.g. "Shop now". Pass "" to remove it.'),
1836
+ .describe(
1837
+ 'CTA pill, e.g. "Shop now". Always set it — a short without a CTA doesn\'t convert. Pass "" to remove it.',
1838
+ ),
1649
1839
  badge_text: z
1650
1840
  .string()
1651
1841
  .max(24)
1652
1842
  .optional()
1653
1843
  .describe(
1654
- 'Badge stamp (opt-in), e.g. "BEST SELLER". Pass "" to remove it.',
1844
+ 'Badge stamp (opt-in), e.g. "BEST SELLER". Only badges you can substantiate — fabricated social ' +
1845
+ 'proof is deceptive advertising. Pass "" to remove it.',
1655
1846
  ),
1656
1847
  star_rating: z
1657
1848
  .number()
@@ -1660,7 +1851,8 @@ registerTool(
1660
1851
  .max(5)
1661
1852
  .optional()
1662
1853
  .describe(
1663
- "0..5 star rating under the subheadline (opt-in). Pass 0 to remove it.",
1854
+ "0..5 star rating under the subheadline (opt-in). Only ratings you can substantiate — fabricated " +
1855
+ "social proof is deceptive advertising. Pass 0 to remove it.",
1664
1856
  ),
1665
1857
  text_beats: z
1666
1858
  .array(z.string().max(120))
@@ -1669,6 +1861,37 @@ registerTool(
1669
1861
  .describe(
1670
1862
  "Caption beats shown sequentially instead of the static subheadline. Pass [] to clear.",
1671
1863
  ),
1864
+ closing_claim: z
1865
+ .string()
1866
+ .max(80)
1867
+ .optional()
1868
+ .describe(
1869
+ "End-card claim shown on the closing slate (≤80 chars) — a rephrase of the promise, not a new " +
1870
+ 'claim. Falls back to the headline when unset. Pass "" to remove it.',
1871
+ ),
1872
+ brand_name: z
1873
+ .string()
1874
+ .max(40)
1875
+ .optional()
1876
+ .describe(
1877
+ "Brand/product display name (≤40 chars) shown big on the closing brand-lockup slate when no " +
1878
+ 'poster is set. Server falls back to your brand profile\'s name when unset. Pass "" to remove it.',
1879
+ ),
1880
+ end_card: z
1881
+ .enum(["auto", "none"])
1882
+ .optional()
1883
+ .describe(
1884
+ '"auto" (default): end on the poster, or on a brand-lockup slate (brand_name + claim + CTA pill) ' +
1885
+ 'when no poster is set — a 14s render. "none": no end card at all (12s, for deliberate ' +
1886
+ "loop-style shorts).",
1887
+ ),
1888
+ poster_includes_lockup: z
1889
+ .boolean()
1890
+ .optional()
1891
+ .describe(
1892
+ "Set true if your poster already contains brand name/CTA/badges — suppresses the overlaid " +
1893
+ "claim/CTA so they don't collide (default false).",
1894
+ ),
1672
1895
  brand_profile_id: z
1673
1896
  .number()
1674
1897
  .int()
@@ -1710,6 +1933,10 @@ registerTool(
1710
1933
  badge_text?: string;
1711
1934
  star_rating?: number;
1712
1935
  text_beats?: string[];
1936
+ closing_claim?: string;
1937
+ brand_name?: string;
1938
+ end_card?: string;
1939
+ poster_includes_lockup?: boolean;
1713
1940
  brand_profile_id?: number;
1714
1941
  },
1715
1942
  client,
@@ -1750,6 +1977,12 @@ registerTool(
1750
1977
  if (args.badge_text !== undefined) body.badge_text = args.badge_text;
1751
1978
  if (args.star_rating !== undefined) body.star_rating = args.star_rating;
1752
1979
  if (args.text_beats !== undefined) body.text_beats = args.text_beats;
1980
+ if (args.closing_claim !== undefined)
1981
+ body.closing_claim = args.closing_claim;
1982
+ if (args.brand_name !== undefined) body.brand_name = args.brand_name;
1983
+ if (args.end_card !== undefined) body.end_card = args.end_card;
1984
+ if (args.poster_includes_lockup !== undefined)
1985
+ body.poster_includes_lockup = args.poster_includes_lockup;
1753
1986
  if (args.brand_profile_id !== undefined)
1754
1987
  body.brand_profile_id = args.brand_profile_id;
1755
1988
 
@@ -1757,7 +1990,15 @@ registerTool(
1757
1990
  `/shorts/${args.slug}`,
1758
1991
  body,
1759
1992
  );
1760
- return ok(asRecord(res).data ?? res);
1993
+ const data = asRecord(res).data ?? res;
1994
+ return ok({
1995
+ ...asRecord(data),
1996
+ next:
1997
+ "Saved. Only text/style/CTA/end-card changed on an already-generated short? " +
1998
+ `rerender_short({ slug: "${args.slug}" }) applies it for 0 credits. Footage/music edits ` +
1999
+ "(prompt, product image, format, visual_language, theme, music_*, language) need " +
2000
+ "generate_short (15 credits).",
2001
+ });
1761
2002
  },
1762
2003
  ),
1763
2004
  );
@@ -2133,16 +2374,30 @@ registerTool(
2133
2374
  {
2134
2375
  title: "Generate (render) a short",
2135
2376
  description:
2136
- "Deducts 15 credits and starts the render pipeline for an existing short. Safe to call again: a " +
2377
+ "Deducts 15 credits and starts the render pipeline for an existing short this RE-ROLLS the footage " +
2378
+ "and music (a fresh AI generation). For text/style/CTA/end-card tweaks on an already-generated short, " +
2379
+ "use rerender_short instead: it re-applies the overlay over the existing footage for 0 credits. " +
2380
+ "Safe to call again: a " +
2137
2381
  "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") },
2382
+ "short can be re-generated. If the draft's headline/subheadline/text_beats are all blank the server " +
2383
+ "auto-writes the headline + caption beats before rendering (pass skip_auto_text:true for a deliberately " +
2384
+ "bare clip). Then poll with get_status or wait_for_completion (kind=short).",
2385
+ inputSchema: {
2386
+ slug: z.string().describe("Short slug from create_short"),
2387
+ skip_auto_text: z
2388
+ .boolean()
2389
+ .optional()
2390
+ .describe(
2391
+ "When the draft has no headline/subheadline/text_beats the server auto-writes the copy; pass " +
2392
+ "true to skip that and render a deliberately bare clip (default false).",
2393
+ ),
2394
+ },
2140
2395
  // Not idempotent: a repeat after a terminal (failed) render re-charges 15
2141
2396
  // credits (the whole point of allowing a re-generation), so a blind retry is
2142
2397
  // NOT free — only an in-flight duplicate dedupes (server 409). See below.
2143
2398
  annotations: { title: "Generate short", ...WRITE },
2144
2399
  },
2145
- tool(async (args: { slug: string }, client) => {
2400
+ tool(async (args: { slug: string; skip_auto_text?: boolean }, client) => {
2146
2401
  // No stable idempotency key (see generate_slider): a per-slug key would
2147
2402
  // replay the first response for 24h and block an intentional re-generation
2148
2403
  // of a failed short. Double-charge is prevented server-side — the batch
@@ -2151,6 +2406,9 @@ registerTool(
2151
2406
  try {
2152
2407
  const res = await client.post<{ data: unknown }>(
2153
2408
  `/shorts/${args.slug}/generate`,
2409
+ // Only send a body when the caller opts out of server auto-copy —
2410
+ // the bare POST stays byte-identical to the pre-0.8.2 wire shape.
2411
+ args.skip_auto_text ? { skip_auto_text: true } : undefined,
2154
2412
  );
2155
2413
  const status = normalizeStatus("short", args.slug, asRecord(res).data);
2156
2414
  return ok(status);
@@ -2171,6 +2429,89 @@ registerTool(
2171
2429
  }),
2172
2430
  );
2173
2431
 
2432
+ registerTool(
2433
+ "rerender_short",
2434
+ {
2435
+ title: "Re-render a short (FREE — 0 credits)",
2436
+ description:
2437
+ "FREE re-render: applies the short's current text/style/CTA/end-card state over the already-paid " +
2438
+ "footage and music — 0 credits. Use after update_short for copy/style/CTA tweaks. Footage or music " +
2439
+ "changes need generate_short (15 credits) instead. Edits that add/remove the end card (poster, " +
2440
+ "end_card) can shift the video between 12s and 14s; when the pinned music bed is shorter than the " +
2441
+ "export it crossfades into a repeated continuation, then fades at the export end, without regeneration. " +
2442
+ "Requires a completed generation to re-render over " +
2443
+ "(422 short_not_ready otherwise — run generate_short once first); while any generation/render is in " +
2444
+ "flight it reports in-progress (409) rather than duplicating. Then poll with get_status or " +
2445
+ "wait_for_completion (kind=short). A failed free re-render leaves the delivered video intact — retry " +
2446
+ "rerender_short, it stays free (the status fields latest_render_free / last_free_rerender_failed " +
2447
+ "report this when the server sends them).",
2448
+ inputSchema: {
2449
+ slug: z.string().describe("Short slug (from create_short)"),
2450
+ },
2451
+ // Free, but not idempotent in the MCP sense: each call is a genuine new
2452
+ // re-render of the short's CURRENT state — the whole point of the
2453
+ // update_short → rerender_short loop (see the key note below).
2454
+ annotations: { title: "Re-render short", ...WRITE, idempotentHint: false },
2455
+ },
2456
+ tool(async (args: { slug: string }, client) => {
2457
+ try {
2458
+ const res = await client.post<{ data: unknown }>(
2459
+ `/shorts/${args.slug}/rerender`,
2460
+ undefined,
2461
+ // Each call is a genuine new re-render attempt (the core loop is
2462
+ // update_short → rerender_short, repeated per variant). A per-invocation
2463
+ // nonce keeps the server's 24h idempotency cache from replaying a PRIOR
2464
+ // rerender's response on a deliberate re-application after an edit.
2465
+ // (The client never auto-retries non-GETs, so the nonce buys no
2466
+ // transport-retry protection — it exists purely to defeat the replay
2467
+ // cache.) A concurrent duplicate is independently blocked server-side
2468
+ // (409 while any work is active) — and the call is 0 credits, so there
2469
+ // is no charge to protect. Mirrors start_autopilot's
2470
+ // idemKey("autopilot", slug, randomUUID()).
2471
+ idemKey("rerender-short", args.slug, randomUUID()),
2472
+ );
2473
+ const status = normalizeStatus("short", args.slug, asRecord(res).data);
2474
+ return ok({
2475
+ ...status,
2476
+ next: `wait_for_completion({ slug: "${args.slug}", kind: "short" }) — or poll get_status. A re-render is fast (no AI generation).`,
2477
+ });
2478
+ } catch (e) {
2479
+ const he = e as { status?: number; code?: string };
2480
+ if (he.status === 409) {
2481
+ // A generation/render is already in flight — report in-progress
2482
+ // (same shape as generate_short's 409 handling), never a hard error.
2483
+ return ok({
2484
+ kind: "short",
2485
+ slug: args.slug,
2486
+ stage: "processing",
2487
+ terminal: false,
2488
+ ready: false,
2489
+ video_url: null,
2490
+ error: null,
2491
+ next: `A render is already running — wait_for_completion({ slug: "${args.slug}", kind: "short" }).`,
2492
+ });
2493
+ }
2494
+ if (he.status === 422 && he.code === "short_not_ready") {
2495
+ // Nothing rendered yet to re-render over: the free path needs the
2496
+ // paid footage+music to exist first.
2497
+ return fail(
2498
+ "This short has no completed footage + music to re-render over (short_not_ready). " +
2499
+ `Run generate_short({ slug: "${args.slug}" }) once (15 credits) — after that first render, every ` +
2500
+ "text/style/CTA/end-card tweak re-renders free with rerender_short.",
2501
+ );
2502
+ }
2503
+ if (he.status === 422 && he.code === "short_paid_regeneration_required") {
2504
+ return fail(
2505
+ "A footage or music input changed after the last paid render " +
2506
+ "(short_paid_regeneration_required). The pinned assets no longer match the draft. " +
2507
+ `Run generate_short({ slug: "${args.slug}" }) (15 credits) before using free re-renders again.`,
2508
+ );
2509
+ }
2510
+ throw e;
2511
+ }
2512
+ }),
2513
+ );
2514
+
2174
2515
  registerTool(
2175
2516
  "generate_short_text",
2176
2517
  {
@@ -2570,18 +2911,20 @@ registerTool(
2570
2911
  registerTool(
2571
2912
  "create_editor_ad",
2572
2913
  {
2573
- title: "Create a multi-scene editor ad (autopilot)",
2914
+ title: "Create, configure, and price a multi-scene editor ad",
2574
2915
  description:
2575
- "Creates an editor project from a product prompt and starts autopilot (server-orchestrated " +
2916
+ "Creates an editor project, attaches any supplied local product/logo/closing assets BEFORE generation, " +
2917
+ "and prices autopilot (server-orchestrated " +
2576
2918
  "scenario → segments → narration → voice → music → render). Each AI-generated scene is a fixed 8 seconds. " +
2577
- "Returns the slug. Costs credits. Then poll with get_status / wait_for_completion (kind=editor). Prefer " +
2578
- "make_video for the one-shot path. " +
2579
- "BRANDING: to attach the user's own product image, brand logo, or closing card, create the draft with " +
2580
- "create_editor_draft first, call set_product / set_logo / set_closing_image (all 0 credits), then " +
2581
- "start_autopilot autopilot weaves them in. (Editor ads carry their copy in-scene/narration, not as a " +
2919
+ "Without max_credits it STOPS after the free draft/configuration and returns the live estimate. It starts " +
2920
+ "the paid pipeline only when max_credits is supplied and covers the estimate. Then poll with get_status / " +
2921
+ "wait_for_completion (kind=editor). Prefer make_video for a prompt-only one-shot. " +
2922
+ "Supplying assets here is safer than adding them after autopilot because the scenario and scenes are grounded " +
2923
+ "in the real product from the start. (Editor ads carry their copy in-scene/narration, not as a " +
2582
2924
  "title overlay; for an on-screen title/subtitle use a short with headline/subheadline instead.) " +
2583
- "For product_only or presenter_led cast modes, use create_editor_draft and configure the required " +
2584
- "product/presenter before start_autopilot; this one-shot tool intentionally uses automatic casting.",
2925
+ "For a mascot or hero product that must stay visually identical with no human presenter, set " +
2926
+ "cast_mode:'product_only' together with product_image_path. Presenter-led casting still belongs on " +
2927
+ "create_editor_draft because it requires a separately configured presenter.",
2585
2928
  inputSchema: {
2586
2929
  product_prompt: z
2587
2930
  .string()
@@ -2637,6 +2980,61 @@ registerTool(
2637
2980
  .enum(["9:16", "16:9", "1:1"])
2638
2981
  .optional()
2639
2982
  .describe('Aspect ratio (default "9:16")'),
2983
+ product_image_path: z
2984
+ .string()
2985
+ .optional()
2986
+ .describe(
2987
+ "Local product image (.jpg/.jpeg/.png, ≤8 MiB) to attach before autopilot.",
2988
+ ),
2989
+ product_description: z
2990
+ .string()
2991
+ .max(500)
2992
+ .optional()
2993
+ .describe(
2994
+ "Exact product/character description used with product_image_path.",
2995
+ ),
2996
+ closing_image_path: z
2997
+ .string()
2998
+ .optional()
2999
+ .describe("Local closing-card image (.jpg/.jpeg/.png, ≤20 MiB)."),
3000
+ logo_path: z
3001
+ .string()
3002
+ .optional()
3003
+ .describe("Local logo (.jpg/.jpeg/.png, ≤1 MiB) to overlay."),
3004
+ logo_treatment: z
3005
+ .enum(["intro", "outro", "both", "none"])
3006
+ .optional()
3007
+ .describe(
3008
+ 'When the logo bumper plays: "intro", "outro", "both" (default), or "none". Requires logo_path.',
3009
+ ),
3010
+ logo_position: z
3011
+ .enum(["top-left", "top-right", "bottom-left", "bottom-right"])
3012
+ .optional()
3013
+ .describe(
3014
+ 'Logo corner: "top-left", "top-right" (default), "bottom-left", or "bottom-right". Requires logo_path.',
3015
+ ),
3016
+ logo_duration_seconds: z
3017
+ .number()
3018
+ .min(0.5)
3019
+ .max(3)
3020
+ .optional()
3021
+ .describe(
3022
+ "Logo bumper duration in seconds, 0.5–3 (default 1.5). Requires logo_path.",
3023
+ ),
3024
+ cast_mode: z
3025
+ .literal("product_only")
3026
+ .optional()
3027
+ .describe(
3028
+ "Lock every AI scene to the supplied product/mascot reference with no people. Requires product_image_path.",
3029
+ ),
3030
+ max_credits: z
3031
+ .number()
3032
+ .int()
3033
+ .nonnegative()
3034
+ .optional()
3035
+ .describe(
3036
+ "Explicit spend cap. Omit to create/configure/price only; no credits are charged.",
3037
+ ),
2640
3038
  },
2641
3039
  outputSchema: createEditorOutput,
2642
3040
  annotations: { title: "Create editor ad", ...WRITE, idempotentHint: true },
@@ -2653,47 +3051,219 @@ registerTool(
2653
3051
  theme?: string;
2654
3052
  voice_id?: string;
2655
3053
  export_aspect_ratio?: string;
3054
+ product_image_path?: string;
3055
+ product_description?: string;
3056
+ closing_image_path?: string;
3057
+ logo_path?: string;
3058
+ logo_treatment?: "intro" | "outro" | "both" | "none";
3059
+ logo_position?:
3060
+ | "top-left"
3061
+ | "top-right"
3062
+ | "bottom-left"
3063
+ | "bottom-right";
3064
+ logo_duration_seconds?: number;
3065
+ cast_mode?: "product_only";
3066
+ max_credits?: number;
2656
3067
  },
2657
3068
  client,
2658
3069
  ) => {
2659
- const langErr = validateLanguage(args.language);
2660
- if (langErr) return fail(langErr);
2661
- const promptErr = validateProductPrompt(args.product_prompt);
2662
- if (promptErr) return fail(promptErr);
3070
+ const prepared: PreparedUploadFile[] = [];
3071
+ let productImage: PreparedUploadFile | undefined;
3072
+ let closingImage: PreparedUploadFile | undefined;
3073
+ let logo: PreparedUploadFile | undefined;
3074
+ try {
3075
+ const langErr = validateLanguage(args.language);
3076
+ if (langErr) return fail(langErr);
3077
+ const promptErr = validateProductPrompt(args.product_prompt);
3078
+ if (promptErr) return fail(promptErr);
3079
+ if (args.cast_mode === "product_only" && !args.product_image_path) {
3080
+ return fail(
3081
+ "cast_mode product_only requires product_image_path so identity can be locked before generation.",
3082
+ );
3083
+ }
3084
+ // Fail fast on settings that would otherwise be silently dropped: the
3085
+ // logo PATCH is gated on logo_path and the product_description is only
3086
+ // sent with product_image_path, so a logo_* / product_description passed
3087
+ // without its asset is a caller mistake. Reject BEFORE creating even a
3088
+ // free draft (consistent with the asset validation just below), rather
3089
+ // than accepting the call and ignoring the field.
3090
+ if (
3091
+ (args.logo_treatment !== undefined ||
3092
+ args.logo_position !== undefined ||
3093
+ args.logo_duration_seconds !== undefined) &&
3094
+ !args.logo_path
3095
+ ) {
3096
+ return fail(
3097
+ "logo_treatment/logo_position/logo_duration_seconds require logo_path (the logo image they style). Provide logo_path or drop these settings.",
3098
+ );
3099
+ }
3100
+ if (
3101
+ args.product_description !== undefined &&
3102
+ !args.product_image_path
3103
+ ) {
3104
+ return fail(
3105
+ "product_description requires product_image_path (the product image it describes). Provide product_image_path or drop product_description.",
3106
+ );
3107
+ }
2663
3108
 
2664
- const created = await client.post<{ data: { slug: string } }>(
2665
- "/editor",
2666
- {
2667
- language: args.language ?? "en",
2668
- product_prompt: args.product_prompt,
2669
- product_subject: args.product_subject,
2670
- project_intent: args.project_intent,
2671
- creative_format: args.creative_format,
2672
- visual_language: args.visual_language,
2673
- theme: args.theme,
2674
- voice_id: args.voice_id,
2675
- export_aspect_ratio: args.export_aspect_ratio,
2676
- },
2677
- // Fold every create-affecting param into the key so two calls that
2678
- // differ only by style/theme/voice/aspect get distinct drafts.
2679
- // Composition lives in core.ts, shared with the golden-pin test.
2680
- editorAdCreateKey(args),
2681
- );
2682
- const slug = created.data.slug;
2683
- const started = await client.post<{ data: unknown }>(
2684
- `/editor/${slug}/autopilot`,
2685
- undefined,
2686
- // Fold the create-affecting params into the start key so it tracks
2687
- // inputs, not just the slug. Self-consistent per-tool only: this key
2688
- // is compared against retries of *this* tool on *this* slug. The
2689
- // field set/order differs from make_video / start_autopilot on
2690
- // purpose (server cache is path+key scoped, slug is unique per
2691
- // draft), so the keys are not interchangeable across tools.
2692
- // Composition lives in core.ts, shared with the golden-pin test.
2693
- editorAdAutopilotKey(slug, args),
2694
- );
2695
- const status = normalizeStatus("editor", slug, asRecord(started).data);
2696
- return ok({ slug, kind: "editor", status, next: "wait_for_completion" });
3109
+ // Validate every local asset before creating even a free draft, so a typo,
3110
+ // traversal attempt, bad extension, or oversized logo cannot leave an
3111
+ // orphaned project behind.
3112
+ if (args.product_image_path) {
3113
+ productImage = await prepareImageUpload(
3114
+ args.product_image_path,
3115
+ MAX_PRODUCT_IMAGE_BYTES,
3116
+ );
3117
+ prepared.push(productImage);
3118
+ }
3119
+ if (args.closing_image_path) {
3120
+ closingImage = await prepareImageUpload(
3121
+ args.closing_image_path,
3122
+ MAX_CLOSING_IMAGE_BYTES,
3123
+ );
3124
+ prepared.push(closingImage);
3125
+ }
3126
+ if (args.logo_path) {
3127
+ logo = await prepareImageUpload(args.logo_path, MAX_LOGO_BYTES);
3128
+ prepared.push(logo);
3129
+ }
3130
+
3131
+ const created = await client.post<{ data: { slug: string } }>(
3132
+ "/editor",
3133
+ {
3134
+ language: args.language ?? "en",
3135
+ product_prompt: args.product_prompt,
3136
+ product_subject: args.product_subject,
3137
+ project_intent: args.project_intent,
3138
+ creative_format: args.creative_format,
3139
+ visual_language: args.visual_language,
3140
+ theme: args.theme,
3141
+ voice_id: args.voice_id,
3142
+ export_aspect_ratio: args.export_aspect_ratio,
3143
+ cast_mode: args.cast_mode,
3144
+ },
3145
+ // Fold every create-affecting param into the key so two calls that
3146
+ // differ only by style/theme/voice/aspect get distinct drafts.
3147
+ // Composition lives in core.ts, shared with the golden-pin test.
3148
+ editorAdCreateKey({
3149
+ ...args,
3150
+ product_image_identity: productImage
3151
+ ? preparedUploadIdentity(productImage)
3152
+ : undefined,
3153
+ closing_image_identity: closingImage
3154
+ ? preparedUploadIdentity(closingImage)
3155
+ : undefined,
3156
+ logo_identity: logo ? preparedUploadIdentity(logo) : undefined,
3157
+ }),
3158
+ );
3159
+ const slug = created.data.slug;
3160
+ let configured = {} as Record<string, unknown>;
3161
+ if (
3162
+ args.product_image_path ||
3163
+ args.closing_image_path ||
3164
+ args.logo_path
3165
+ ) {
3166
+ const current = await client.get<{ data: unknown }>(
3167
+ `/editor/${slug}`,
3168
+ );
3169
+ configured = asRecord(asRecord(current).data);
3170
+ }
3171
+
3172
+ if (productImage && !configured.product_url) {
3173
+ const { s3_key } = await uploadImageFile(
3174
+ client,
3175
+ `/editor/${slug}/product/presign`,
3176
+ productImage,
3177
+ MAX_PRODUCT_IMAGE_BYTES,
3178
+ );
3179
+ await client.post(`/editor/${slug}/product/confirm`, {
3180
+ s3_key,
3181
+ product_description: args.product_description,
3182
+ });
3183
+ }
3184
+ if (closingImage && !configured.closing_image_url) {
3185
+ const { s3_key } = await uploadImageFile(
3186
+ client,
3187
+ `/editor/${slug}/closing-image/presign`,
3188
+ closingImage,
3189
+ MAX_CLOSING_IMAGE_BYTES,
3190
+ );
3191
+ await client.post(`/editor/${slug}/closing-image/confirm`, {
3192
+ s3_key,
3193
+ });
3194
+ }
3195
+ if (logo && !configured.logo_url) {
3196
+ const { s3_key } = await uploadImageFile(
3197
+ client,
3198
+ `/editor/${slug}/logo/presign`,
3199
+ logo,
3200
+ MAX_LOGO_BYTES,
3201
+ );
3202
+ await client.post(`/editor/${slug}/logo/confirm`, { s3_key });
3203
+ }
3204
+ const logoSettings: Record<string, unknown> = {};
3205
+ if (args.logo_treatment !== undefined)
3206
+ logoSettings.logo_treatment = args.logo_treatment;
3207
+ if (args.logo_position !== undefined)
3208
+ logoSettings.logo_position = args.logo_position;
3209
+ if (args.logo_duration_seconds !== undefined)
3210
+ logoSettings.logo_duration_seconds = args.logo_duration_seconds;
3211
+ if (args.logo_path && Object.keys(logoSettings).length > 0)
3212
+ await client.patch(`/editor/${slug}/logo`, logoSettings);
3213
+
3214
+ const quote = await editorAutopilotQuote(client, slug);
3215
+ const overCap =
3216
+ args.max_credits !== undefined &&
3217
+ quote.estimated_credits !== null &&
3218
+ quote.estimated_credits > args.max_credits;
3219
+ if (
3220
+ args.max_credits === undefined ||
3221
+ quote.estimated_credits === null ||
3222
+ overCap ||
3223
+ !quote.affordable
3224
+ ) {
3225
+ const note =
3226
+ args.max_credits === undefined
3227
+ ? `Free draft configured. Estimated ${quote.estimated_credits ?? "?"} credits (have ${quote.available_credits ?? "?"}); nothing charged. Relaunch with start_autopilot({ slug: "${slug}", max_credits: ${quote.estimated_credits ?? "<approved cap>"} }).`
3228
+ : quote.estimated_credits === null
3229
+ ? "Pricing is temporarily unavailable, so the paid pipeline was not started. Retry the quote later."
3230
+ : overCap
3231
+ ? `Estimated ${quote.estimated_credits} credits exceeds max_credits ${args.max_credits}; nothing charged.`
3232
+ : `Not enough credits (needs ${quote.estimated_credits}, have ${quote.available_credits ?? "?"}); nothing charged.`;
3233
+ return ok({
3234
+ slug,
3235
+ kind: "editor",
3236
+ ...quote,
3237
+ charged: false,
3238
+ note,
3239
+ next: "start_autopilot",
3240
+ });
3241
+ }
3242
+
3243
+ const started = await client.post<{ data: unknown }>(
3244
+ `/editor/${slug}/autopilot`,
3245
+ { max_credits: args.max_credits },
3246
+ // Fold the create-affecting params into the start key so it tracks
3247
+ // inputs, not just the slug. Self-consistent per-tool only: this key
3248
+ // is compared against retries of *this* tool on *this* slug. The
3249
+ // field set/order differs from make_video / start_autopilot on
3250
+ // purpose (server cache is path+key scoped, slug is unique per
3251
+ // draft), so the keys are not interchangeable across tools.
3252
+ // Composition lives in core.ts, shared with the golden-pin test.
3253
+ editorAdAutopilotKey(slug, args),
3254
+ );
3255
+ const status = normalizeStatus("editor", slug, asRecord(started).data);
3256
+ return ok({
3257
+ slug,
3258
+ kind: "editor",
3259
+ status,
3260
+ ...quote,
3261
+ charged: true,
3262
+ next: "wait_for_completion",
3263
+ });
3264
+ } finally {
3265
+ await Promise.all(prepared.map(closePreparedUploadFile));
3266
+ }
2697
3267
  },
2698
3268
  ),
2699
3269
  );
@@ -2704,8 +3274,9 @@ registerTool(
2704
3274
  title: "Start autopilot on an existing editor draft",
2705
3275
  description:
2706
3276
  "Starts server-orchestrated autopilot (scenario → segments → narration → voice → music → render) on an " +
2707
- "EXISTING editor project/draft. Spends credits preview the estimate first with the autopilot/cost endpoint " +
2708
- "or make_video({ dry_run:true }). Each call is a genuine start attempt (like tapping Launch in the app): " +
3277
+ "EXISTING editor project/draft. Without max_credits it returns the live quote and charges nothing. Supply an " +
3278
+ "explicit max_credits cap to authorize launch; the tool refuses if pricing is unavailable, the estimate exceeds " +
3279
+ "the cap, or the balance is insufficient. Each authorized call is a genuine start attempt (like tapping Launch in the app): " +
2709
3280
  "if a run is already in progress for this slug the server returns already_running — it never double-starts " +
2710
3281
  "or double-charges. Once finished, failed, or cancelled, calling again relaunches it (resume after a top-up, raising your spend cap, or a mid-pipeline failure). Then poll with " +
2711
3282
  'wait_for_completion({ slug, kind: "editor" }).',
@@ -2730,7 +3301,16 @@ registerTool(
2730
3301
  .string()
2731
3302
  .optional()
2732
3303
  .describe("Optional freeform brief to set/replace before launch."),
3304
+ max_credits: z
3305
+ .number()
3306
+ .int()
3307
+ .nonnegative()
3308
+ .optional()
3309
+ .describe(
3310
+ "Explicit spend cap. Omit to quote only; no credits are charged.",
3311
+ ),
2733
3312
  },
3313
+ outputSchema: createEditorOutput,
2734
3314
  annotations: { title: "Start autopilot", ...WRITE, idempotentHint: false },
2735
3315
  },
2736
3316
  tool(
@@ -2740,6 +3320,7 @@ registerTool(
2740
3320
  language?: string;
2741
3321
  product_subject?: string;
2742
3322
  product_prompt?: string;
3323
+ max_credits?: number;
2743
3324
  },
2744
3325
  client,
2745
3326
  ) => {
@@ -2749,16 +3330,47 @@ registerTool(
2749
3330
  if (promptErr) return fail(promptErr);
2750
3331
 
2751
3332
  const slug = args.slug;
2752
- const overrides: Record<string, unknown> = {};
3333
+ const overrides: {
3334
+ language?: string;
3335
+ product_subject?: string;
3336
+ product_prompt?: string;
3337
+ } = {};
2753
3338
  if (args.language !== undefined) overrides.language = args.language;
2754
3339
  if (args.product_subject !== undefined)
2755
3340
  overrides.product_subject = args.product_subject;
2756
3341
  if (args.product_prompt !== undefined)
2757
3342
  overrides.product_prompt = args.product_prompt;
2758
3343
 
3344
+ const quote = await editorAutopilotQuote(client, slug, overrides);
3345
+ const overCap =
3346
+ args.max_credits !== undefined &&
3347
+ quote.estimated_credits !== null &&
3348
+ quote.estimated_credits > args.max_credits;
3349
+ if (
3350
+ args.max_credits === undefined ||
3351
+ quote.estimated_credits === null ||
3352
+ overCap ||
3353
+ !quote.affordable
3354
+ ) {
3355
+ return ok({
3356
+ slug,
3357
+ kind: "editor",
3358
+ ...quote,
3359
+ charged: false,
3360
+ note:
3361
+ args.max_credits === undefined
3362
+ ? `Quote only: estimated ${quote.estimated_credits ?? "?"} credits (have ${quote.available_credits ?? "?"}). Pass max_credits to authorize launch.`
3363
+ : quote.estimated_credits === null
3364
+ ? "Pricing is unavailable; launch refused because the spend cap cannot be enforced."
3365
+ : overCap
3366
+ ? `Estimated ${quote.estimated_credits} credits exceeds max_credits ${args.max_credits}; nothing charged.`
3367
+ : `Not enough credits (needs ${quote.estimated_credits}, have ${quote.available_credits ?? "?"}); nothing charged.`,
3368
+ });
3369
+ }
3370
+
2759
3371
  const started = await client.post<{ data: unknown }>(
2760
3372
  `/editor/${slug}/autopilot`,
2761
- Object.keys(overrides).length > 0 ? overrides : undefined,
3373
+ { ...overrides, max_credits: args.max_credits },
2762
3374
  // Each call is a genuine new start attempt (matching the in-app Launch
2763
3375
  // button). A per-invocation nonce makes the idempotency key unique so the
2764
3376
  // server's 24h idempotency cache can't replay a PRIOR start's response on
@@ -2773,6 +3385,8 @@ registerTool(
2773
3385
  slug,
2774
3386
  kind: "editor",
2775
3387
  status,
3388
+ ...quote,
3389
+ charged: true,
2776
3390
  next: "wait_for_completion",
2777
3391
  });
2778
3392
  },
@@ -2840,7 +3454,7 @@ registerTool(
2840
3454
  title: "Create an editor draft (no autopilot)",
2841
3455
  description:
2842
3456
  "Creates an editor project from a product prompt and stops (NO autopilot). Costs 0 credits. Returns the " +
2843
- "slug. Use this to drive the pipeline step by step (vs create_editor_ad which runs autopilot end to end). " +
3457
+ "slug. Use this to drive the pipeline step by step (vs create_editor_ad which configures, quotes, and can launch autopilot with a spend cap). " +
2844
3458
  "Each AI-generated scene renders to a fixed 8 seconds (uploaded clips keep their own length). " +
2845
3459
  "product_prompt is optional content: send 10–5000 chars, or omit it entirely (empty is treated as omit).",
2846
3460
  inputSchema: {
@@ -3921,7 +4535,7 @@ registerTool(
3921
4535
  "duration. Runs presign → PUT (resumable multipart for large files) → confirm, then polls until the " +
3922
4536
  "upload is processed (ready). file_path is a LOCAL path confined to HUBFLUENCER_INPUT_DIR (or cwd). Accepted: " +
3923
4537
  VIDEO_EXTS.map((e) => `.${e}`).join(", ") +
3924
- " (≤500 MB, ≤5 min). Costs 0 credits. If add_to_timeline is false (default true) the clip is uploaded but not " +
4538
+ " (≤500 MB decimal = 500,000,000 bytes, ≤5 min). Costs 0 credits. If add_to_timeline is false (default true) the clip is uploaded but not " +
3925
4539
  "placed — call add_segment_from_upload later.",
3926
4540
  inputSchema: {
3927
4541
  slug: z.string().describe("Editor project slug"),
@@ -4068,7 +4682,8 @@ registerTool(
4068
4682
  description:
4069
4683
  "Sets a specific scene's video from an existing asset — either a ready upload (upload_id) OR a completed scene " +
4070
4684
  "in the same project (source_segment_id). Provide EXACTLY ONE. 0 credits. Use it to reuse one uploaded clip " +
4071
- "across scenes, or to swap a generated scene for your own footage.",
4685
+ "across scenes, or to swap a generated scene for your own footage. The response includes continuity impact and " +
4686
+ "current narration/voice/music staleness. If the replacement duration differs, regenerate stale audio before render.",
4072
4687
  inputSchema: {
4073
4688
  slug: z.string().describe("Editor project slug"),
4074
4689
  segment_id: z
@@ -4111,7 +4726,58 @@ registerTool(
4111
4726
  `/editor/${args.slug}/segments/${String(args.segment_id)}/use-asset`,
4112
4727
  body,
4113
4728
  );
4114
- return ok(asRecord(res).data ?? res);
4729
+ const segment = asRecord(asRecord(res).data ?? res);
4730
+ const impact = asRecord(segment.impact);
4731
+ const staleSegmentIds = Array.isArray(impact.stale_video_segment_ids)
4732
+ ? impact.stale_video_segment_ids
4733
+ : [];
4734
+ const requiresRegeneration =
4735
+ impact.requires_regeneration === true || staleSegmentIds.length > 0;
4736
+ // The swap has already SUCCEEDED (the POST above returned). The follow-up
4737
+ // GET only enriches the response with project-level audio staleness — a
4738
+ // transient failure on that read must NOT turn a successful swap into a
4739
+ // thrown error, or the agent would believe the swap failed and retry it,
4740
+ // minting a redundant segment version. Guard it: on failure return success
4741
+ // with the mutation's own impact data and audio-staleness marked unknown
4742
+ // (null) rather than propagating.
4743
+ let timeline: Record<string, unknown>;
4744
+ try {
4745
+ const current = await client.get<{ data: unknown }>(
4746
+ `/editor/${args.slug}`,
4747
+ );
4748
+ const editor = asRecord(asRecord(current).data);
4749
+ timeline = {
4750
+ narration_stale: editor.narration_stale === true,
4751
+ voice_stale: editor.voice_stale === true,
4752
+ music_stale: editor.music_stale === true,
4753
+ stale_segment_ids: staleSegmentIds,
4754
+ requires_regeneration: requiresRegeneration,
4755
+ };
4756
+ } catch {
4757
+ timeline = {
4758
+ // Enrichment GET failed after a successful swap — audio staleness is
4759
+ // unknown (null, not false) so the agent doesn't assume "not stale".
4760
+ narration_stale: null,
4761
+ voice_stale: null,
4762
+ music_stale: null,
4763
+ stale_segment_ids: staleSegmentIds,
4764
+ requires_regeneration: requiresRegeneration,
4765
+ enrichment_unavailable: true,
4766
+ };
4767
+ }
4768
+ return ok({
4769
+ ...segment,
4770
+ timeline,
4771
+ next:
4772
+ timeline.requires_regeneration ||
4773
+ timeline.narration_stale ||
4774
+ timeline.voice_stale ||
4775
+ timeline.music_stale
4776
+ ? "Regenerate the reported stale scenes/audio before render."
4777
+ : timeline.enrichment_unavailable
4778
+ ? "Scene swap succeeded, but audio staleness could not be verified (enrichment fetch failed) — call get_status before render to confirm."
4779
+ : "Timeline is current and can be rendered.",
4780
+ });
4115
4781
  },
4116
4782
  ),
4117
4783
  );
@@ -4123,7 +4789,7 @@ registerTool(
4123
4789
  description:
4124
4790
  "Uploads a local product image and attaches it to the project as the product. Accepted: " +
4125
4791
  IMAGE_EXTS.map((e) => `.${e}`).join(", ") +
4126
- " (≤20 MB), local path confined to HUBFLUENCER_INPUT_DIR (or cwd). 0 credits. The FIRST product added " +
4792
+ " (≤8 MiB), local path confined to HUBFLUENCER_INPUT_DIR (or cwd). 0 credits. The FIRST product added " +
4127
4793
  "defaults every pending AI scene to feature it 'throughout' (change with set_product_placement). Optional " +
4128
4794
  "description (≤500 chars) guides how it's woven into scenes.",
4129
4795
  inputSchema: {
@@ -4148,6 +4814,7 @@ registerTool(
4148
4814
  client,
4149
4815
  `/editor/${args.slug}/product/presign`,
4150
4816
  args.file_path,
4817
+ MAX_PRODUCT_IMAGE_BYTES,
4151
4818
  );
4152
4819
  const res = await client.post<{ data: unknown }>(
4153
4820
  `/editor/${args.slug}/product/confirm`,
@@ -4190,7 +4857,7 @@ registerTool(
4190
4857
  {
4191
4858
  title: "Set the closing-card image (0 credits)",
4192
4859
  description:
4193
- "Sets the optional ~2s end-card image. Either upload a local file (file_path; .jpg/.jpeg/.png ≤20 MB, confined " +
4860
+ "Sets the optional ~2s end-card image. Either upload a local file (file_path; .jpg/.jpeg/.png ≤20 MiB, confined " +
4194
4861
  "to HUBFLUENCER_INPUT_DIR/cwd) OR reuse the project's product image (from_product:true — a 0-credit server-side " +
4195
4862
  "copy, no upload). If a closing image already exists, pass overwrite:true to replace it (else " +
4196
4863
  "editor_closing_image_exists).",
@@ -4241,6 +4908,7 @@ registerTool(
4241
4908
  client,
4242
4909
  `/editor/${args.slug}/closing-image/presign`,
4243
4910
  args.file_path,
4911
+ MAX_CLOSING_IMAGE_BYTES,
4244
4912
  );
4245
4913
  const res = await client.post<{ data: unknown }>(
4246
4914
  `/editor/${args.slug}/closing-image/confirm`,
@@ -4256,7 +4924,7 @@ registerTool(
4256
4924
  {
4257
4925
  title: "Set a brand logo overlay (0 credits)",
4258
4926
  description:
4259
- "Uploads a local brand logo and overlays it on the video. Accepted: .png/.jpg/.jpeg (≤20 MB; PNG keeps " +
4927
+ "Uploads a local brand logo and overlays it on the video. Accepted: .png/.jpg/.jpeg (≤1 MiB; PNG keeps " +
4260
4928
  "transparency), confined to HUBFLUENCER_INPUT_DIR/cwd. 0 credits. Optional placement controls: treatment, " +
4261
4929
  "position, duration_seconds.",
4262
4930
  inputSchema: {
@@ -4286,6 +4954,7 @@ registerTool(
4286
4954
  client,
4287
4955
  `/editor/${args.slug}/logo/presign`,
4288
4956
  args.file_path,
4957
+ MAX_LOGO_BYTES,
4289
4958
  );
4290
4959
  const confirmed = await client.post<{ data: unknown }>(
4291
4960
  `/editor/${args.slug}/logo/confirm`,
@@ -4324,7 +4993,7 @@ registerTool(
4324
4993
  "Uploads a local product image and attaches it to a SHORT so the product is woven into the footage. " +
4325
4994
  "Accepted: " +
4326
4995
  IMAGE_EXTS.map((e) => `.${e}`).join(", ") +
4327
- " (≤20 MB), local path confined to HUBFLUENCER_INPUT_DIR (or cwd). 0 credits. Optional description " +
4996
+ " (≤20 MiB = 20,971,520 bytes), local path confined to HUBFLUENCER_INPUT_DIR (or cwd). 0 credits. Optional description " +
4328
4997
  "(≤500 chars) guides how it's featured. Attach BEFORE generate_short so the product is part of the scenes. " +
4329
4998
  "(This is the shorts equivalent of set_product for editor projects.)",
4330
4999
  inputSchema: {
@@ -4371,26 +5040,48 @@ registerTool(
4371
5040
  "Uploads a local image as the SHORT's end-card poster — a closing still shown at the end (extends the " +
4372
5041
  "render from 12s to 14s). Accepted: " +
4373
5042
  IMAGE_EXTS.map((e) => `.${e}`).join(", ") +
4374
- " (≤20 MB), confined to HUBFLUENCER_INPUT_DIR/cwd. 0 credits. There is one poster per short; calling " +
5043
+ " (≤20 MiB = 20,971,520 bytes), confined to HUBFLUENCER_INPUT_DIR/cwd. 0 credits. There is one poster per short; calling " +
4375
5044
  "again replaces it. (Shorts have no logo overlay — for a brand logo use an editor project + set_logo.)",
4376
5045
  inputSchema: {
4377
5046
  slug: z.string().describe("Short slug (from create_short)"),
4378
5047
  file_path: z.string().describe("Local poster image (.jpg/.jpeg/.png)"),
5048
+ poster_includes_lockup: z
5049
+ .boolean()
5050
+ .optional()
5051
+ .describe(
5052
+ "Set true if your poster already contains brand name/CTA/badges — suppresses the overlaid " +
5053
+ "claim/CTA so they don't collide (default false).",
5054
+ ),
4379
5055
  },
4380
5056
  annotations: { title: "Set short poster", ...WRITE, idempotentHint: false },
4381
5057
  },
4382
- tool(async (args: { slug: string; file_path: string }, client) => {
4383
- const { s3_key } = await uploadShortPoster(
5058
+ tool(
5059
+ async (
5060
+ args: {
5061
+ slug: string;
5062
+ file_path: string;
5063
+ poster_includes_lockup?: boolean;
5064
+ },
4384
5065
  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
- }),
5066
+ ) => {
5067
+ const { s3_key } = await uploadShortPoster(
5068
+ client,
5069
+ args.slug,
5070
+ args.file_path,
5071
+ );
5072
+ const res = await client.post<{ data: unknown }>(
5073
+ `/shorts/${args.slug}/poster/confirm`,
5074
+ {
5075
+ s3_key,
5076
+ // A new poster replaces the old poster's content contract too:
5077
+ // omission means the declared default (overlays enabled), not
5078
+ // "preserve whatever the previous poster said".
5079
+ poster_includes_lockup: args.poster_includes_lockup ?? false,
5080
+ },
5081
+ );
5082
+ return ok(asRecord(res).data ?? res);
5083
+ },
5084
+ ),
4394
5085
  );
4395
5086
 
4396
5087
  // ── Status / waiting ──────────────────────────────────────────────────────────
@@ -4419,8 +5110,13 @@ registerTool(
4419
5110
  {
4420
5111
  title: "Wait for a render to finish",
4421
5112
  description:
4422
- "Polls status (emitting progress) until terminal (ready/failed) or the wait budget is exhausted. " +
5113
+ "Polls status (emitting progress) until terminal or the wait budget is exhausted. " +
4423
5114
  "Generation can take several minutes; if it returns terminal=false, call again to keep waiting. " +
5115
+ "terminal=true does NOT always mean ready=true: an editor can go terminal in a needs-action stage " +
5116
+ '(stage "editing_required" — a delivered render is stale after edits, regenerate the reported scenes/audio; ' +
5117
+ 'stage "insufficient_credits" — a batch is parked, top up or reduce scope; ' +
5118
+ 'stage "idle" — a draft with nothing generating, start generation first) that only a further tool call ' +
5119
+ "can advance — do NOT just re-poll those. " +
4424
5120
  "Pass save_path to download the finished MP4 when it's ready (e.g. to resume a make_video that timed out mid-render). " +
4425
5121
  'Works for kind:"slider" too, but a carousel has no MP4 — it just goes terminal when composited; read the slide ' +
4426
5122
  "images with get_slider (save_path is ignored for sliders).",
@@ -5796,7 +6492,7 @@ registerTool(
5796
6492
  return ok({
5797
6493
  ...episode,
5798
6494
  next: episode.draft_slug
5799
- ? "Generate this draft EXPLICITLY (a separate, PAID step): editor_ad/short start_autopilot({ slug }); carousel generate_slider({ slug })."
6495
+ ? "Price this draft with start_autopilot({ slug }); after approval launch it with start_autopilot({ slug, max_credits: approved_cap }). Carousel generation remains a separate paid generate_slider call."
5800
6496
  : "Draft created — review it with list_series_episodes.",
5801
6497
  });
5802
6498
  }),
@@ -6603,7 +7299,7 @@ registerTool(
6603
7299
  title: "Upload a local file to your reusable asset catalog",
6604
7300
  description:
6605
7301
  "Uploads a local IMAGE or VIDEO into your reusable asset catalog so it can be reused across projects. " +
6606
- `Allowed types: ${CATALOG_ASSET_EXTS.map((e) => `.${e}`).join(", ")} — images ≤20 MiB, videos ≤500 MB. ` +
7302
+ `Allowed types: ${CATALOG_ASSET_EXTS.map((e) => `.${e}`).join(", ")} — images ≤20 MiB (20,971,520 bytes), videos ≤500 MB decimal (500,000,000 bytes). ` +
6607
7303
  "Runs presign → PUT → confirm; the confirm HEADs the object and validates size/type, so a mismatch 422s. " +
6608
7304
  "For a VIDEO you MUST pass duration_seconds (≤60; the server rejects a video with no/over-long duration). " +
6609
7305
  "Spends 0 credits. Scope: video:generate. Rate limit: 30/min. Requires an ACTIVE SUBSCRIPTION (402 " +