@hubfluencer/mcp 0.3.0 → 0.5.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
@@ -317,6 +317,27 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
317
317
 
318
318
  const kindSchema = z.enum(["short", "editor"]).describe("Project kind");
319
319
 
320
+ // Creative-style value lists shared by SHORTS and EDITOR (single source of
321
+ // truth, mirroring the API's VideoFactory.creative_formats/0 +
322
+ // visual_languages/0). creative_format = narrative arc; visual_language =
323
+ // production treatment / render look.
324
+ const CREATIVE_FORMATS = [
325
+ "problem_solution",
326
+ "mistake_fix",
327
+ "myth_vs_reality",
328
+ "before_after",
329
+ "proof_demo",
330
+ "product_reveal",
331
+ ] as const;
332
+ const VISUAL_LANGUAGES = [
333
+ "kinetic_creator",
334
+ "premium_editorial",
335
+ "cinematic_product",
336
+ "ugc_realism",
337
+ "startup_explainer",
338
+ "luxury_minimal",
339
+ ] as const;
340
+
320
341
  const RO = { readOnlyHint: true, destructiveHint: false, openWorldHint: true };
321
342
  const WRITE = {
322
343
  readOnlyHint: false,
@@ -324,7 +345,7 @@ const WRITE = {
324
345
  openWorldHint: true,
325
346
  };
326
347
 
327
- const server = new McpServer({ name: "hubfluencer", version: "0.3.0" });
348
+ const server = new McpServer({ name: "hubfluencer", version: "0.5.0" });
328
349
 
329
350
  // The SDK's `registerTool` is generic over the Zod input shape; with this many
330
351
  // tools its conditional types hit TS2589 ("excessively deep"). Inputs are still
@@ -427,8 +448,10 @@ registerTool(
427
448
  "for simple/short ones; the chosen kind is reported back as kind_inferred. If it returns terminal=false " +
428
449
  "the render is still running — call wait_for_completion with the returned slug to finish. " +
429
450
  "BE PROACTIVE WITH BRANDING: pass headline (the on-screen TITLE) and subheadline (secondary title) plus " +
430
- "music_vibe/theme so a one-shot short isn't bare these apply to SHORTS (music_vibe/headline/subheadline " +
431
- "are ignored for editor; theme applies to both). For richer branding a product image, brand logo, or " +
451
+ "music_vibe so a one-shot short isn't bare. creative_format + visual_language apply to BOTH kinds (editor and " +
452
+ "shorts); headline/subheadline/music_vibe/text_* and the conversion graphics are SHORTS-only and ignored for " +
453
+ "editor; theme applies to editor (genre overlay) and is legacy-only for shorts (used when visual_language is unset). " +
454
+ "For richer branding — a product image, brand logo, or " +
432
455
  "closing card — drive the granular path instead: create_short + set_short_product/set_short_poster, or " +
433
456
  "create_editor_draft + set_product/set_logo/set_closing_image, then start_autopilot/generate_short.",
434
457
  // Schema kept intentionally flat (no .min/.max/.int chains) — the SDK's
@@ -468,17 +491,91 @@ registerTool(
468
491
  .string()
469
492
  .optional()
470
493
  .describe("SHORTS only: the secondary title / supporting line (≤200)"),
494
+ text_beats: z
495
+ .array(z.string())
496
+ .optional()
497
+ .describe(
498
+ "SHORTS only: caption beats shown sequentially instead of a static subheadline (≤8, each ≤120 chars).",
499
+ ),
500
+ headline_color: z
501
+ .string()
502
+ .optional()
503
+ .describe("SHORTS only: headline hex color, e.g. #ffffff"),
504
+ subheadline_color: z
505
+ .string()
506
+ .optional()
507
+ .describe("SHORTS only: subheadline hex color, e.g. #ffffff"),
508
+ accent_color: z
509
+ .string()
510
+ .optional()
511
+ .describe("SHORTS only: 6-digit brand accent hex color, e.g. #09EFBE"),
512
+ offer_text: z
513
+ .string()
514
+ .optional()
515
+ .describe("SHORTS only: optional offer chip, e.g. -40% (≤16 chars)"),
516
+ cta_text: z
517
+ .string()
518
+ .optional()
519
+ .describe("SHORTS only: optional CTA pill, e.g. Shop now (≤24 chars)"),
520
+ badge_text: z
521
+ .string()
522
+ .optional()
523
+ .describe(
524
+ "SHORTS only: optional badge stamp, e.g. BEST SELLER (≤24 chars)",
525
+ ),
526
+ star_rating: z
527
+ .number()
528
+ .optional()
529
+ .describe(
530
+ "SHORTS only: optional 0..5 star rating under the subheadline",
531
+ ),
532
+ text_position: z
533
+ .enum(["top", "center", "bottom"])
534
+ .optional()
535
+ .describe("SHORTS only: title overlay position (default bottom)"),
536
+ text_animation: z
537
+ .enum([
538
+ "reveal",
539
+ "typewriter",
540
+ "fade_in",
541
+ "pop",
542
+ "bounce",
543
+ "word_stagger",
544
+ "word_spotlight",
545
+ ])
546
+ .optional()
547
+ .describe("SHORTS only: title overlay animation (default fade_in)"),
548
+ font_family: z
549
+ .string()
550
+ .optional()
551
+ .describe(
552
+ "SHORTS only: overlay font family, e.g. ShortFontSpaceGrotesk",
553
+ ),
471
554
  music_vibe: z
472
555
  .string()
473
556
  .optional()
474
557
  .describe(
475
558
  "SHORTS only: music mood — Upbeat (default), Cinematic, Minimal, Luxury, Playful, Jazz",
476
559
  ),
560
+ creative_format: z
561
+ .enum(CREATIVE_FORMATS)
562
+ .optional()
563
+ .describe(
564
+ "SHORTS + EDITOR: narrative arc / segment structure. Omit for Auto/generic.",
565
+ ),
566
+ visual_language: z
567
+ .enum(VISUAL_LANGUAGES)
568
+ .optional()
569
+ .describe(
570
+ "SHORTS + EDITOR: visual style direction and render look. Default app choice is kinetic_creator.",
571
+ ),
477
572
  theme: z
478
573
  .string()
479
574
  .optional()
480
575
  .describe(
481
- "Visual theme (both kinds): none (literal no imposed style), realistic (default), cinematic, " +
576
+ "Visual theme: editor genre overlay, and legacy shorts fallback only when visual_language is unset. " +
577
+ "For editor, when visual_language is set it drives the look and 'none' is ignored. " +
578
+ "none (literal — no imposed style), realistic (default), cinematic, " +
482
579
  "anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, " +
483
580
  "minimalist, cyberpunk",
484
581
  ),
@@ -518,7 +615,20 @@ registerTool(
518
615
  voice_id?: string;
519
616
  headline?: string;
520
617
  subheadline?: string;
618
+ text_beats?: string[];
619
+ headline_color?: string;
620
+ subheadline_color?: string;
621
+ accent_color?: string;
622
+ offer_text?: string;
623
+ cta_text?: string;
624
+ badge_text?: string;
625
+ star_rating?: number;
626
+ text_position?: string;
627
+ text_animation?: string;
628
+ font_family?: string;
521
629
  music_vibe?: string;
630
+ creative_format?: string;
631
+ visual_language?: string;
522
632
  theme?: string;
523
633
  save_path?: string;
524
634
  max_wait_seconds?: number;
@@ -559,7 +669,20 @@ registerTool(
559
669
  language: args.language,
560
670
  headline: args.headline,
561
671
  subheadline: args.subheadline,
672
+ text_beats: args.text_beats,
673
+ headline_color: args.headline_color,
674
+ subheadline_color: args.subheadline_color,
675
+ accent_color: args.accent_color,
676
+ offer_text: args.offer_text,
677
+ cta_text: args.cta_text,
678
+ badge_text: args.badge_text,
679
+ star_rating: args.star_rating,
680
+ short_text_position: args.text_position,
681
+ short_text_animation: args.text_animation,
682
+ short_font_family: args.font_family,
562
683
  music_vibe: args.music_vibe,
684
+ creative_format: args.creative_format,
685
+ visual_language: args.visual_language,
563
686
  theme: args.theme,
564
687
  },
565
688
  // Fold every create-affecting param into the key: two calls that
@@ -571,7 +694,20 @@ registerTool(
571
694
  args.language ?? "",
572
695
  args.headline ?? "",
573
696
  args.subheadline ?? "",
697
+ JSON.stringify(args.text_beats ?? []),
698
+ args.headline_color ?? "",
699
+ args.subheadline_color ?? "",
700
+ args.accent_color ?? "",
701
+ args.offer_text ?? "",
702
+ args.cta_text ?? "",
703
+ args.badge_text ?? "",
704
+ String(args.star_rating ?? ""),
705
+ args.text_position ?? "",
706
+ args.text_animation ?? "",
707
+ args.font_family ?? "",
574
708
  args.music_vibe ?? "",
709
+ args.creative_format ?? "",
710
+ args.visual_language ?? "",
575
711
  args.theme ?? "",
576
712
  ),
577
713
  );
@@ -584,6 +720,8 @@ registerTool(
584
720
  product_prompt: args.prompt,
585
721
  export_aspect_ratio: args.aspect,
586
722
  voice_id: args.voice_id,
723
+ creative_format: args.creative_format,
724
+ visual_language: args.visual_language,
587
725
  theme: args.theme,
588
726
  },
589
727
  idemKey(
@@ -592,6 +730,8 @@ registerTool(
592
730
  args.language ?? "en",
593
731
  args.aspect ?? "",
594
732
  args.voice_id ?? "",
733
+ args.creative_format ?? "",
734
+ args.visual_language ?? "",
595
735
  args.theme ?? "",
596
736
  ),
597
737
  );
@@ -844,7 +984,7 @@ registerTool(
844
984
  "segments + an on-screen title overlay + music; 14s when an end-card poster is set). Returns the slug, " +
845
985
  "costs 0 credits. Follow with generate_short to render. " +
846
986
  "BE PROACTIVE: don't ship a bare clip — set a headline (the on-screen TITLE) and subheadline (secondary " +
847
- "title), and pick a music_vibe and theme that fit the brand. To brand it further, attach a product image " +
987
+ "title), and pick a music_vibe plus visual_language that fit the brand. To brand it further, attach a product image " +
848
988
  "(set_short_product) or an end-card poster (set_short_poster) before generate_short. (Shorts have no logo " +
849
989
  "overlay — that's an editor-only feature; use create_editor_ad for logo branding.)",
850
990
  inputSchema: {
@@ -870,13 +1010,23 @@ registerTool(
870
1010
  .describe(
871
1011
  "The SECONDARY title / supporting line under the headline. ≤200 chars.",
872
1012
  ),
1013
+ creative_format: z
1014
+ .enum(CREATIVE_FORMATS)
1015
+ .optional()
1016
+ .describe(
1017
+ "Optional structure: problem_solution, mistake_fix, myth_vs_reality, before_after, proof_demo, product_reveal. Omit for Auto.",
1018
+ ),
1019
+ visual_language: z
1020
+ .enum(VISUAL_LANGUAGES)
1021
+ .optional()
1022
+ .describe(
1023
+ "Visual language for Veo direction and render styling. Good default: kinetic_creator.",
1024
+ ),
873
1025
  theme: z
874
1026
  .string()
875
1027
  .optional()
876
1028
  .describe(
877
- "Visual theme (default realistic). One of: none (no imposed style follows the prompt literally), " +
878
- "realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, " +
879
- "gaming, retro_80s, minimalist, cyberpunk.",
1029
+ "Deprecated for shorts: legacy visual theme used only when visual_language is unset.",
880
1030
  ),
881
1031
  music_vibe: z
882
1032
  .string()
@@ -893,7 +1043,15 @@ registerTool(
893
1043
  .optional()
894
1044
  .describe("Where the title overlay sits (default bottom)"),
895
1045
  text_animation: z
896
- .enum(["reveal", "typewriter", "fade_in", "pop", "bounce"])
1046
+ .enum([
1047
+ "reveal",
1048
+ "typewriter",
1049
+ "fade_in",
1050
+ "pop",
1051
+ "bounce",
1052
+ "word_stagger",
1053
+ "word_spotlight",
1054
+ ])
897
1055
  .optional()
898
1056
  .describe("Title overlay animation (default fade_in)"),
899
1057
  font_family: z
@@ -901,9 +1059,49 @@ registerTool(
901
1059
  .optional()
902
1060
  .describe(
903
1061
  "Overlay font (default ShortFontSpaceGrotesk). One of: ShortFontSpaceGrotesk, ShortFontMontserrat, " +
904
- "ShortFontTheBold, ShortFontImpact, ShortFontLato, ShortFontProximaNova, ShortFontAnton, " +
1062
+ "ShortFontTheBold, ShortFontImpact, ShortFontLato, ShortFontAnton, " +
905
1063
  "ShortFontBebasNeue, ShortFontOswald, ShortFontArchivoBlack, ShortFontPoppins, ShortFontInter, " +
906
- "ShortFontTikTokSans, ShortFontBangers.",
1064
+ "ShortFontTikTokSans, ShortFontBangers, ShortFontDMSerif, ShortFontPermanentMarker.",
1065
+ ),
1066
+ headline_color: z
1067
+ .string()
1068
+ .optional()
1069
+ .describe("Headline hex color, e.g. #ffffff"),
1070
+ subheadline_color: z
1071
+ .string()
1072
+ .optional()
1073
+ .describe("Subheadline hex color, e.g. #ffffff"),
1074
+ accent_color: z
1075
+ .string()
1076
+ .optional()
1077
+ .describe("6-digit brand accent hex color, e.g. #09EFBE"),
1078
+ offer_text: z
1079
+ .string()
1080
+ .max(16)
1081
+ .optional()
1082
+ .describe('Optional offer chip, e.g. "-40%" or "2 FOR 1"'),
1083
+ cta_text: z
1084
+ .string()
1085
+ .max(24)
1086
+ .optional()
1087
+ .describe('Optional CTA pill, e.g. "Shop now"'),
1088
+ badge_text: z
1089
+ .string()
1090
+ .max(24)
1091
+ .optional()
1092
+ .describe('Optional badge stamp, e.g. "BEST SELLER"'),
1093
+ star_rating: z
1094
+ .number()
1095
+ .min(0)
1096
+ .max(5)
1097
+ .optional()
1098
+ .describe("Optional 0..5 star rating under the subheadline"),
1099
+ text_beats: z
1100
+ .array(z.string().max(120))
1101
+ .max(8)
1102
+ .optional()
1103
+ .describe(
1104
+ "Optional caption beats shown sequentially instead of the static subheadline.",
907
1105
  ),
908
1106
  },
909
1107
  annotations: { title: "Create short", ...WRITE, idempotentHint: true },
@@ -915,12 +1113,22 @@ registerTool(
915
1113
  language?: string;
916
1114
  headline?: string;
917
1115
  subheadline?: string;
1116
+ creative_format?: string;
1117
+ visual_language?: string;
918
1118
  theme?: string;
919
1119
  music_vibe?: string;
920
1120
  music_instruments?: string[];
921
1121
  text_position?: string;
922
1122
  text_animation?: string;
923
1123
  font_family?: string;
1124
+ headline_color?: string;
1125
+ subheadline_color?: string;
1126
+ accent_color?: string;
1127
+ offer_text?: string;
1128
+ cta_text?: string;
1129
+ badge_text?: string;
1130
+ star_rating?: number;
1131
+ text_beats?: string[];
924
1132
  },
925
1133
  client,
926
1134
  ) => {
@@ -931,6 +1139,10 @@ registerTool(
931
1139
  if (args.language !== undefined) body.language = args.language;
932
1140
  if (args.headline !== undefined) body.headline = args.headline;
933
1141
  if (args.subheadline !== undefined) body.subheadline = args.subheadline;
1142
+ if (args.creative_format !== undefined)
1143
+ body.creative_format = args.creative_format;
1144
+ if (args.visual_language !== undefined)
1145
+ body.visual_language = args.visual_language;
934
1146
  if (args.theme !== undefined) body.theme = args.theme;
935
1147
  if (args.music_vibe !== undefined) body.music_vibe = args.music_vibe;
936
1148
  if (args.music_instruments !== undefined)
@@ -941,6 +1153,17 @@ registerTool(
941
1153
  body.short_text_animation = args.text_animation;
942
1154
  if (args.font_family !== undefined)
943
1155
  body.short_font_family = args.font_family;
1156
+ if (args.headline_color !== undefined)
1157
+ body.headline_color = args.headline_color;
1158
+ if (args.subheadline_color !== undefined)
1159
+ body.subheadline_color = args.subheadline_color;
1160
+ if (args.accent_color !== undefined)
1161
+ body.accent_color = args.accent_color;
1162
+ if (args.offer_text !== undefined) body.offer_text = args.offer_text;
1163
+ if (args.cta_text !== undefined) body.cta_text = args.cta_text;
1164
+ if (args.badge_text !== undefined) body.badge_text = args.badge_text;
1165
+ if (args.star_rating !== undefined) body.star_rating = args.star_rating;
1166
+ if (args.text_beats !== undefined) body.text_beats = args.text_beats;
944
1167
  const res = await client.post<{ data: { slug: string } }>(
945
1168
  "/shorts",
946
1169
  body,
@@ -950,6 +1173,22 @@ registerTool(
950
1173
  args.language ?? "",
951
1174
  args.headline ?? "",
952
1175
  args.subheadline ?? "",
1176
+ args.creative_format ?? "",
1177
+ args.visual_language ?? "",
1178
+ args.theme ?? "",
1179
+ args.music_vibe ?? "",
1180
+ JSON.stringify(args.music_instruments ?? []),
1181
+ args.text_position ?? "",
1182
+ args.text_animation ?? "",
1183
+ args.font_family ?? "",
1184
+ args.headline_color ?? "",
1185
+ args.subheadline_color ?? "",
1186
+ args.accent_color ?? "",
1187
+ args.offer_text ?? "",
1188
+ args.cta_text ?? "",
1189
+ args.badge_text ?? "",
1190
+ String(args.star_rating ?? ""),
1191
+ JSON.stringify(args.text_beats ?? []),
953
1192
  ),
954
1193
  );
955
1194
  return ok({
@@ -966,22 +1205,431 @@ registerTool(
966
1205
  {
967
1206
  title: "Generate (render) a short",
968
1207
  description:
969
- "Deducts 15 credits and starts the render pipeline for an existing short. Idempotent per slug " +
970
- "(safe to retry). Then poll with get_status or wait_for_completion (kind=short).",
1208
+ "Deducts 15 credits and starts the render pipeline for an existing short. Safe to call again: a " +
1209
+ "duplicate while a render is in flight is reported as in-progress (no double charge), and a failed " +
1210
+ "short can be re-generated. Then poll with get_status or wait_for_completion (kind=short).",
971
1211
  inputSchema: { slug: z.string().describe("Short slug from create_short") },
972
1212
  annotations: { title: "Generate short", ...WRITE, idempotentHint: true },
973
1213
  },
974
1214
  tool(async (args: { slug: string }, client) => {
975
- const res = await client.post<{ data: unknown }>(
976
- `/shorts/${args.slug}/generate`,
977
- undefined,
978
- `gen-short:${args.slug}`,
1215
+ // No stable idempotency key (see generate_slider): a per-slug key would
1216
+ // replay the first response for 24h and block an intentional re-generation
1217
+ // of a failed short. Double-charge is prevented server-side — the batch
1218
+ // claim's conditional update rolls back the losing transaction's credit
1219
+ // deduction — and a 409 means a render is already running.
1220
+ try {
1221
+ const res = await client.post<{ data: unknown }>(
1222
+ `/shorts/${args.slug}/generate`,
1223
+ );
1224
+ const status = normalizeStatus("short", args.slug, asRecord(res).data);
1225
+ return ok(status);
1226
+ } catch (e) {
1227
+ if ((e as { status?: number }).status === 409) {
1228
+ return ok({
1229
+ kind: "short",
1230
+ slug: args.slug,
1231
+ stage: "processing",
1232
+ terminal: false,
1233
+ ready: false,
1234
+ video_url: null,
1235
+ error: null,
1236
+ });
1237
+ }
1238
+ throw e;
1239
+ }
1240
+ }),
1241
+ );
1242
+
1243
+ registerTool(
1244
+ "generate_short_text",
1245
+ {
1246
+ title: "Generate short overlay text (AI assist)",
1247
+ description:
1248
+ "Generates editable headline, subheadline, and caption beats for an existing short draft. " +
1249
+ "CONSUMES 1 AI ASSIST (free daily quota) and spends no video credits. The server reads the saved " +
1250
+ "short fields, so update/create the draft with the current product prompt, creative_format, visual_language, " +
1251
+ "language, and seed copy first. On 429 set auto_unlock:true (1 credit -> +10 assists, retried once) or write the text yourself.",
1252
+ inputSchema: {
1253
+ slug: z.string().describe("Short slug from create_short"),
1254
+ auto_unlock: z
1255
+ .boolean()
1256
+ .optional()
1257
+ .describe(
1258
+ "On 429, spend 1 credit to unlock +10 assists and retry once (default false)",
1259
+ ),
1260
+ },
1261
+ annotations: {
1262
+ title: "Generate short text",
1263
+ ...WRITE,
1264
+ idempotentHint: false,
1265
+ },
1266
+ },
1267
+ tool(async (args: { slug: string; auto_unlock?: boolean }, client) => {
1268
+ const res = await withAssist(client, args.auto_unlock ?? false, () =>
1269
+ client.post<{ data: unknown }>(`/shorts/${args.slug}/text/generate`),
979
1270
  );
980
- const status = normalizeStatus("short", args.slug, asRecord(res).data);
981
- return ok(status);
1271
+ return ok(asRecord(res).data ?? res);
982
1272
  }),
983
1273
  );
984
1274
 
1275
+ // ── Sliders (image carousels) ────────────────────────────────────────────────
1276
+
1277
+ registerTool(
1278
+ "create_slider",
1279
+ {
1280
+ title: "Create an image slider / carousel (draft)",
1281
+ description:
1282
+ "Creates a carousel draft from a prompt (min 10 chars). One prompt produces N still slides (an AI " +
1283
+ "background + a composited headline/body + optional logo) PLUS a ready-to-post caption and hashtags. " +
1284
+ "Returns the slug, costs 0 credits. Follow with generate_slider to render, then get_slider to read the " +
1285
+ "slide image URLs + caption. mode 'creative' tells a story; 'ad_driven' lists product facts/benefits.",
1286
+ inputSchema: {
1287
+ prompt: z.string().min(10).describe("What the carousel is about"),
1288
+ mode: z
1289
+ .enum(["creative", "ad_driven"])
1290
+ .optional()
1291
+ .describe(
1292
+ "'creative' (storytelling) or 'ad_driven' (facts about the product). Default creative.",
1293
+ ),
1294
+ template: z
1295
+ .enum([
1296
+ "boldStatement",
1297
+ "editorialStory",
1298
+ "scrapbook",
1299
+ "featureGrid",
1300
+ "offerCard",
1301
+ "comparison",
1302
+ ])
1303
+ .optional()
1304
+ .describe(
1305
+ "Composition preset. boldStatement/editorialStory/scrapbook are creative; " +
1306
+ "featureGrid/offerCard/comparison are ad-driven. Defaults to the mode default.",
1307
+ ),
1308
+ slide_count: z
1309
+ .number()
1310
+ .optional()
1311
+ .describe("Number of slides, integer 3–10 (default 5)"),
1312
+ aspect_ratio: z
1313
+ .enum(["4:5", "1:1", "9:16"])
1314
+ .optional()
1315
+ .describe(
1316
+ "Canvas ratio (default 4:5, the highest-reach carousel ratio)",
1317
+ ),
1318
+ accent_color: z
1319
+ .string()
1320
+ .regex(/^#[0-9a-fA-F]{6}$/)
1321
+ .optional()
1322
+ .describe(
1323
+ "Brand accent: must start with #, exactly 6 hex digits, e.g. #09EFBE",
1324
+ ),
1325
+ text_position: z
1326
+ .enum(["top", "middle", "bottom"])
1327
+ .optional()
1328
+ .describe(
1329
+ "Vertical placement of the on-image copy across all slides. Omit for the template's natural placement.",
1330
+ ),
1331
+ },
1332
+ annotations: { title: "Create slider", ...WRITE, idempotentHint: true },
1333
+ },
1334
+ tool(
1335
+ async (
1336
+ args: {
1337
+ prompt: string;
1338
+ mode?: string;
1339
+ template?: string;
1340
+ slide_count?: number;
1341
+ aspect_ratio?: string;
1342
+ accent_color?: string;
1343
+ text_position?: string;
1344
+ },
1345
+ client,
1346
+ ) => {
1347
+ // slide_count is a flat z.number() (see registerTool TS2589 note), so
1348
+ // enforce the integer 3..10 range here, matching the server changeset.
1349
+ if (args.slide_count !== undefined) {
1350
+ if (
1351
+ !Number.isInteger(args.slide_count) ||
1352
+ args.slide_count < 3 ||
1353
+ args.slide_count > 10
1354
+ ) {
1355
+ return fail("slide_count must be an integer between 3 and 10");
1356
+ }
1357
+ }
1358
+ const body: Record<string, unknown> = { prompt: args.prompt };
1359
+ if (args.mode !== undefined) body.mode = args.mode;
1360
+ if (args.template !== undefined) body.template = args.template;
1361
+ if (args.slide_count !== undefined) body.slide_count = args.slide_count;
1362
+ if (args.aspect_ratio !== undefined)
1363
+ body.aspect_ratio = args.aspect_ratio;
1364
+ if (args.accent_color !== undefined)
1365
+ body.accent_color = args.accent_color;
1366
+ if (args.text_position !== undefined)
1367
+ body.text_position = args.text_position;
1368
+ const res = await client.post<{ data: { slug: string } }>(
1369
+ "/sliders",
1370
+ body,
1371
+ idemKey(
1372
+ "create-slider",
1373
+ args.prompt,
1374
+ args.mode ?? "",
1375
+ args.template ?? "",
1376
+ ),
1377
+ );
1378
+ return ok({
1379
+ slug: res.data.slug,
1380
+ kind: "slider",
1381
+ next: "generate_slider, then get_slider to read the slide URLs + caption",
1382
+ });
1383
+ },
1384
+ ),
1385
+ );
1386
+
1387
+ registerTool(
1388
+ "generate_slider",
1389
+ {
1390
+ title: "Generate (render) a carousel",
1391
+ description:
1392
+ "Deducts 1 credit per slide (3–10 slides) and starts the pipeline (copy → AI backgrounds → composite) for an existing " +
1393
+ "carousel. Safe to call again: a duplicate while a render is already in flight is reported as still " +
1394
+ "processing (no double charge), and a failed carousel can be re-generated. Then poll with get_slider " +
1395
+ "until status is 'completed' or 'failed'.",
1396
+ inputSchema: {
1397
+ slug: z.string().describe("Slider slug from create_slider"),
1398
+ },
1399
+ annotations: { title: "Generate slider", ...WRITE, idempotentHint: true },
1400
+ },
1401
+ tool(async (args: { slug: string }, client) => {
1402
+ // No stable idempotency key on purpose: a per-slug key would replay the
1403
+ // first response for 24h and silently block an intentional re-generation
1404
+ // (e.g. retrying a failed render). Double-charge is prevented server-side
1405
+ // by the atomic generation claim, which returns 409 when a render is
1406
+ // already running — surface that as "still processing" rather than an error.
1407
+ try {
1408
+ const res = await client.post<{ data: Record<string, unknown> }>(
1409
+ `/sliders/${args.slug}/generate`,
1410
+ );
1411
+ const data = asRecord(asRecord(res).data);
1412
+ return ok({
1413
+ slug: args.slug,
1414
+ kind: "slider",
1415
+ status: data.status ?? "processing",
1416
+ });
1417
+ } catch (e) {
1418
+ if ((e as { status?: number }).status === 409) {
1419
+ return ok({ slug: args.slug, kind: "slider", status: "processing" });
1420
+ }
1421
+ throw e;
1422
+ }
1423
+ }),
1424
+ );
1425
+
1426
+ registerTool(
1427
+ "get_slider",
1428
+ {
1429
+ title: "Get carousel status + deliverable",
1430
+ description:
1431
+ "Returns the carousel's status and, when completed, the per-slide image URLs (download these), the " +
1432
+ "per-slide headline/body/kicker, the caption, and the hashtags. status flows draft → processing → " +
1433
+ "completed | failed. completed:true means every slide image_url is ready to save. Once completed you " +
1434
+ "can restyle_slider (new template/accent) or edit_slider_slide (one slide's text) for free.",
1435
+ inputSchema: { slug: z.string().describe("Slider slug") },
1436
+ annotations: { title: "Get slider", ...RO },
1437
+ },
1438
+ tool(async (args: { slug: string }, client) => {
1439
+ const res = await client.get<{ data: Record<string, unknown> }>(
1440
+ `/sliders/${args.slug}`,
1441
+ );
1442
+ const data = asRecord(asRecord(res).data);
1443
+ const slides = Array.isArray(data.slides)
1444
+ ? (data.slides as Record<string, unknown>[])
1445
+ : [];
1446
+ const status = (data.status as string) ?? "unknown";
1447
+ return ok({
1448
+ slug: args.slug,
1449
+ kind: "slider",
1450
+ status,
1451
+ stage: status,
1452
+ terminal: status === "completed" || status === "failed",
1453
+ // Trust the server's terminal status. The API completes a slider in one
1454
+ // transaction that writes every rendered key, so "completed" always implies
1455
+ // all slides[].image_url are ready — re-deriving it here only created an
1456
+ // ambiguous terminal:true / completed:false dead-end on an edge response.
1457
+ completed: status === "completed",
1458
+ error: (data.error_message as string) ?? null,
1459
+ caption: (data.caption as string) ?? null,
1460
+ hashtags: Array.isArray(data.hashtags) ? data.hashtags : [],
1461
+ slides: slides.map((s) => ({
1462
+ position: s.position,
1463
+ image_url: s.image_url ?? null,
1464
+ headline: s.headline ?? null,
1465
+ body: s.body ?? null,
1466
+ kicker: s.kicker ?? null,
1467
+ status: s.status ?? null,
1468
+ })),
1469
+ });
1470
+ }),
1471
+ );
1472
+
1473
+ registerTool(
1474
+ "restyle_slider",
1475
+ {
1476
+ title: "Restyle a carousel (free re-composite of every slide)",
1477
+ description:
1478
+ "Re-composites ALL slides of a completed carousel under a new template, accent color and/or text " +
1479
+ "position — for FREE (0 credits). Reuses the already-generated AI backgrounds, so no new images are paid for. The " +
1480
+ "carousel must already be 'completed' (generate it first); a draft/processing carousel returns a " +
1481
+ "409 conflict. Runs async: it marks every slide processing, so poll get_slider until status is back " +
1482
+ "to 'completed' (or 'failed'). Omit a field to leave it unchanged.",
1483
+ inputSchema: {
1484
+ slug: z.string().describe("Slider slug from create_slider"),
1485
+ template: z
1486
+ .enum([
1487
+ "boldStatement",
1488
+ "editorialStory",
1489
+ "scrapbook",
1490
+ "featureGrid",
1491
+ "offerCard",
1492
+ "comparison",
1493
+ ])
1494
+ .optional()
1495
+ .describe(
1496
+ "New composition preset. boldStatement/editorialStory/scrapbook are creative; " +
1497
+ "featureGrid/offerCard/comparison are ad-driven.",
1498
+ ),
1499
+ accent_color: z
1500
+ .string()
1501
+ .regex(/^#[0-9a-fA-F]{6}$/)
1502
+ .optional()
1503
+ .describe(
1504
+ "New brand accent: must start with #, exactly 6 hex digits, e.g. #09EFBE",
1505
+ ),
1506
+ text_position: z
1507
+ .enum(["top", "middle", "bottom"])
1508
+ .optional()
1509
+ .describe(
1510
+ "New vertical placement of the on-image copy across all slides.",
1511
+ ),
1512
+ },
1513
+ annotations: { title: "Restyle slider", ...WRITE, idempotentHint: false },
1514
+ },
1515
+ tool(
1516
+ async (
1517
+ args: {
1518
+ slug: string;
1519
+ template?: string;
1520
+ accent_color?: string;
1521
+ text_position?: string;
1522
+ },
1523
+ client,
1524
+ ) => {
1525
+ const body: Record<string, unknown> = {};
1526
+ if (args.template !== undefined) body.template = args.template;
1527
+ if (args.accent_color !== undefined)
1528
+ body.accent_color = args.accent_color;
1529
+ if (args.text_position !== undefined)
1530
+ body.text_position = args.text_position;
1531
+ try {
1532
+ const res = await client.post<{ data: Record<string, unknown> }>(
1533
+ `/sliders/${args.slug}/restyle`,
1534
+ body,
1535
+ );
1536
+ const data = asRecord(asRecord(res).data);
1537
+ return ok({
1538
+ slug: args.slug,
1539
+ kind: "slider",
1540
+ status: data.status ?? "processing",
1541
+ next: "poll get_slider until status is 'completed' (free re-composite, 0 credits)",
1542
+ });
1543
+ } catch (e) {
1544
+ // 409 = carousel not completed yet, or a re-composite already running.
1545
+ if ((e as { status?: number }).status === 409) {
1546
+ return ok({ slug: args.slug, kind: "slider", status: "processing" });
1547
+ }
1548
+ throw e;
1549
+ }
1550
+ },
1551
+ ),
1552
+ );
1553
+
1554
+ registerTool(
1555
+ "edit_slider_slide",
1556
+ {
1557
+ title: "Edit one carousel slide's on-image text (free re-composite)",
1558
+ description:
1559
+ "Rewrites the on-image copy of a SINGLE slide (by position) and re-composites just that slide — for " +
1560
+ "FREE (0 credits). Reuses the slide's existing AI background, so no new image is paid for. The " +
1561
+ "carousel must already be 'completed' (a draft/processing carousel returns a 409 conflict). Runs " +
1562
+ "async: the slide goes back to processing, so poll get_slider until its status is 'completed'. Pass " +
1563
+ "only the fields you want to change. kicker = the small eyebrow/label line above the headline.",
1564
+ inputSchema: {
1565
+ slug: z.string().describe("Slider slug from create_slider"),
1566
+ position: z
1567
+ .number()
1568
+ .int()
1569
+ .min(1)
1570
+ .describe("1-based slide position (from get_slider)"),
1571
+ headline: z
1572
+ .string()
1573
+ .max(120)
1574
+ .optional()
1575
+ .describe("Slide headline, ≤120 chars"),
1576
+ body: z.string().max(600).optional().describe("Slide body, ≤600 chars"),
1577
+ kicker: z
1578
+ .string()
1579
+ .max(40)
1580
+ .optional()
1581
+ .describe("Small eyebrow/label line above the headline, ≤40 chars"),
1582
+ },
1583
+ annotations: {
1584
+ title: "Edit slider slide",
1585
+ ...WRITE,
1586
+ idempotentHint: false,
1587
+ },
1588
+ },
1589
+ tool(
1590
+ async (
1591
+ args: {
1592
+ slug: string;
1593
+ position: number;
1594
+ headline?: string;
1595
+ body?: string;
1596
+ kicker?: string;
1597
+ },
1598
+ client,
1599
+ ) => {
1600
+ const body: Record<string, unknown> = {};
1601
+ if (args.headline !== undefined) body.headline = args.headline;
1602
+ if (args.body !== undefined) body.body = args.body;
1603
+ if (args.kicker !== undefined) body.kicker = args.kicker;
1604
+ try {
1605
+ const res = await client.patch<{ data: Record<string, unknown> }>(
1606
+ `/sliders/${args.slug}/slides/${args.position}`,
1607
+ body,
1608
+ );
1609
+ const data = asRecord(asRecord(res).data);
1610
+ return ok({
1611
+ slug: args.slug,
1612
+ kind: "slider",
1613
+ position: args.position,
1614
+ status: data.status ?? "processing",
1615
+ next: "poll get_slider until the slide's status is 'completed' (free re-composite, 0 credits)",
1616
+ });
1617
+ } catch (e) {
1618
+ // 409 = carousel not completed yet, or a re-composite already running.
1619
+ if ((e as { status?: number }).status === 409) {
1620
+ return ok({
1621
+ slug: args.slug,
1622
+ kind: "slider",
1623
+ position: args.position,
1624
+ status: "processing",
1625
+ });
1626
+ }
1627
+ throw e;
1628
+ }
1629
+ },
1630
+ ),
1631
+ );
1632
+
985
1633
  // ── Editor ───────────────────────────────────────────────────────────────────
986
1634
 
987
1635
  registerTool(
@@ -1006,11 +1654,21 @@ registerTool(
1006
1654
  .string()
1007
1655
  .optional()
1008
1656
  .describe('Language code, e.g. "en" (default)'),
1657
+ creative_format: z
1658
+ .enum(CREATIVE_FORMATS)
1659
+ .optional()
1660
+ .describe("Narrative arc / segment structure. Omit for Auto/generic."),
1661
+ visual_language: z
1662
+ .enum(VISUAL_LANGUAGES)
1663
+ .optional()
1664
+ .describe(
1665
+ "Visual style direction and render look. Good default: kinetic_creator. When set it drives the look and a theme of 'none' is ignored.",
1666
+ ),
1009
1667
  theme: z
1010
1668
  .string()
1011
1669
  .optional()
1012
1670
  .describe(
1013
- 'Visual theme (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.',
1671
+ '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.',
1014
1672
  ),
1015
1673
  voice_id: z
1016
1674
  .string()
@@ -1031,11 +1689,24 @@ registerTool(
1031
1689
  {
1032
1690
  language: args.language ?? "en",
1033
1691
  product_prompt: args.product_prompt,
1692
+ creative_format: args.creative_format,
1693
+ visual_language: args.visual_language,
1034
1694
  theme: args.theme,
1035
1695
  voice_id: args.voice_id,
1036
1696
  export_aspect_ratio: args.export_aspect_ratio,
1037
1697
  },
1038
- idemKey("create-editor", String(args.product_prompt ?? "")),
1698
+ // Fold every create-affecting param into the key so two calls that
1699
+ // differ only by style/theme/voice/aspect get distinct drafts.
1700
+ idemKey(
1701
+ "create-editor",
1702
+ String(args.product_prompt ?? ""),
1703
+ String(args.language ?? "en"),
1704
+ String(args.creative_format ?? ""),
1705
+ String(args.visual_language ?? ""),
1706
+ String(args.theme ?? ""),
1707
+ String(args.voice_id ?? ""),
1708
+ String(args.export_aspect_ratio ?? ""),
1709
+ ),
1039
1710
  );
1040
1711
  const slug = created.data.slug;
1041
1712
  const started = await client.post<{ data: unknown }>(
@@ -1153,11 +1824,21 @@ registerTool(
1153
1824
  .max(10)
1154
1825
  .optional()
1155
1826
  .describe('Language code, e.g. "en" (default)'),
1827
+ creative_format: z
1828
+ .enum(CREATIVE_FORMATS)
1829
+ .optional()
1830
+ .describe("Narrative arc / segment structure. Omit for Auto/generic."),
1831
+ visual_language: z
1832
+ .enum(VISUAL_LANGUAGES)
1833
+ .optional()
1834
+ .describe(
1835
+ "Visual style direction and render look. Good default: kinetic_creator. When set it drives the look and a theme of 'none' is ignored.",
1836
+ ),
1156
1837
  theme: z
1157
1838
  .string()
1158
1839
  .optional()
1159
1840
  .describe(
1160
- 'Visual theme (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.',
1841
+ '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.',
1161
1842
  ),
1162
1843
  voice_id: z
1163
1844
  .string()
@@ -1187,6 +1868,8 @@ registerTool(
1187
1868
  args: {
1188
1869
  product_prompt?: string;
1189
1870
  language?: string;
1871
+ creative_format?: string;
1872
+ visual_language?: string;
1190
1873
  theme?: string;
1191
1874
  voice_id?: string;
1192
1875
  export_aspect_ratio?: string;
@@ -1197,6 +1880,8 @@ registerTool(
1197
1880
  const prompt = args.product_prompt?.trim();
1198
1881
  const body: Record<string, unknown> = {
1199
1882
  language: args.language ?? "en",
1883
+ creative_format: args.creative_format,
1884
+ visual_language: args.visual_language,
1200
1885
  theme: args.theme,
1201
1886
  voice_id: args.voice_id,
1202
1887
  export_aspect_ratio: args.export_aspect_ratio,
@@ -1206,7 +1891,14 @@ registerTool(
1206
1891
  const created = await client.post<{ data: { slug: string } }>(
1207
1892
  "/editor",
1208
1893
  body,
1209
- idemKey("create-editor-draft", prompt ?? "", args.language ?? "en"),
1894
+ idemKey(
1895
+ "create-editor-draft",
1896
+ prompt ?? "",
1897
+ args.language ?? "en",
1898
+ args.creative_format ?? "",
1899
+ args.visual_language ?? "",
1900
+ args.theme ?? "",
1901
+ ),
1210
1902
  );
1211
1903
  return ok({
1212
1904
  slug: created.data.slug,
@@ -1267,7 +1959,9 @@ registerTool(
1267
1959
  description:
1268
1960
  "Generates a scenario with AI. CONSUMES 1 AI ASSIST (free daily quota; check get_ai_assists). On 429 " +
1269
1961
  "(quota exhausted) either set auto_unlock:true to spend 1 credit for +10 assists and retry once, or " +
1270
- "write the scenario yourself with set_scenario. Server default segments_count is 5.",
1962
+ "write the scenario yourself with set_scenario. Server default segments_count is 5. " +
1963
+ "Optionally pass creative_format / visual_language / theme to persist the project\u2019s creative style before " +
1964
+ "generating (this is the granular path\u2019s way to set/change style; PATCH /scenario does not).",
1271
1965
  inputSchema: {
1272
1966
  slug: z.string().describe("Editor project slug"),
1273
1967
  segments_count: z
@@ -1277,6 +1971,24 @@ registerTool(
1277
1971
  .max(10)
1278
1972
  .optional()
1279
1973
  .describe("How many scenes (3–10, server default 5)"),
1974
+ creative_format: z
1975
+ .enum(CREATIVE_FORMATS)
1976
+ .optional()
1977
+ .describe(
1978
+ "Persist the narrative arc / segment structure before generating. Omit to leave it unchanged; pass an empty string to clear.",
1979
+ ),
1980
+ visual_language: z
1981
+ .enum(VISUAL_LANGUAGES)
1982
+ .optional()
1983
+ .describe(
1984
+ "Persist the visual style / render look before generating. When set it drives the look and a theme of 'none' is ignored.",
1985
+ ),
1986
+ theme: z
1987
+ .string()
1988
+ .optional()
1989
+ .describe(
1990
+ "Persist the genre overlay / theme before generating (e.g. realistic, cinematic, anime, none).",
1991
+ ),
1280
1992
  auto_unlock: z
1281
1993
  .boolean()
1282
1994
  .optional()
@@ -1295,6 +2007,9 @@ registerTool(
1295
2007
  args: {
1296
2008
  slug: string;
1297
2009
  segments_count?: number;
2010
+ creative_format?: string;
2011
+ visual_language?: string;
2012
+ theme?: string;
1298
2013
  auto_unlock?: boolean;
1299
2014
  },
1300
2015
  client,
@@ -1302,6 +2017,11 @@ registerTool(
1302
2017
  const body: Record<string, unknown> = {};
1303
2018
  if (args.segments_count !== undefined)
1304
2019
  body.segments_count = args.segments_count;
2020
+ if (args.creative_format !== undefined)
2021
+ body.creative_format = args.creative_format;
2022
+ if (args.visual_language !== undefined)
2023
+ body.visual_language = args.visual_language;
2024
+ if (args.theme !== undefined) body.theme = args.theme;
1305
2025
  const res = await withAssist(client, args.auto_unlock ?? false, () =>
1306
2026
  client.post<{ data: unknown }>(
1307
2027
  `/editor/${args.slug}/generate-scenario`,