@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/README.md +42 -15
- package/dist/index.js +723 -203
- package/package.json +1 -1
- package/src/campaign.ts +1 -1
- package/src/client.ts +4 -1
- package/src/core.ts +329 -16
- package/src/index.ts +819 -123
- package/src/output-schemas.ts +21 -2
- package/src/uploads.ts +401 -163
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
|
-
*
|
|
12
|
+
* (then iterate free: update_short → rerender_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").
|
|
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):
|
|
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
|
|
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.
|
|
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
|
|
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(
|
|
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.
|
|
1004
|
-
//
|
|
1005
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
-
:
|
|
1046
|
-
? `
|
|
1047
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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(
|
|
1435
|
+
.max(80)
|
|
1305
1436
|
.optional()
|
|
1306
1437
|
.describe(
|
|
1307
|
-
"The on-screen TITLE composited over the short (poster text overlay). ≤
|
|
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(
|
|
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(
|
|
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(
|
|
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)
|
|
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
|
|
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(
|
|
1745
|
+
.max(80)
|
|
1561
1746
|
.optional()
|
|
1562
|
-
.describe(
|
|
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(
|
|
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".
|
|
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).
|
|
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
|
-
|
|
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
|
|
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.
|
|
2139
|
-
|
|
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
|
|
2914
|
+
title: "Create, configure, and price a multi-scene editor ad",
|
|
2574
2915
|
description:
|
|
2575
|
-
"Creates an editor project
|
|
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
|
-
"
|
|
2578
|
-
"
|
|
2579
|
-
"
|
|
2580
|
-
"
|
|
2581
|
-
"
|
|
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
|
|
2584
|
-
"
|
|
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
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
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
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
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.
|
|
2708
|
-
"
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
" (≤
|
|
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
|
|
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 (≤
|
|
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
|
|
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
|
|
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(
|
|
4383
|
-
|
|
5058
|
+
tool(
|
|
5059
|
+
async (
|
|
5060
|
+
args: {
|
|
5061
|
+
slug: string;
|
|
5062
|
+
file_path: string;
|
|
5063
|
+
poster_includes_lockup?: boolean;
|
|
5064
|
+
},
|
|
4384
5065
|
client,
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
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
|
|
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
|
-
? "
|
|
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 " +
|