@hubfluencer/mcp 0.1.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/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/index.js +30790 -0
- package/dist/login.js +377 -0
- package/package.json +51 -0
- package/src/client.ts +206 -0
- package/src/core.ts +244 -0
- package/src/credentials.ts +48 -0
- package/src/index.ts +2310 -0
- package/src/login.ts +129 -0
- package/src/uploads.ts +369 -0
- package/tsconfig.json +19 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,2310 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Hubfluencer MCP server.
|
|
4
|
+
*
|
|
5
|
+
* Exposes a small, high-level tool set so an agent (e.g. Claude Code) can turn
|
|
6
|
+
* a text prompt into a finished, post-ready ad — without orchestrating the
|
|
7
|
+
* multi-step pipeline itself. Maps onto the public REST API; auth is an opaque
|
|
8
|
+
* bearer token passed through from the `HUBFLUENCER_API_TOKEN` env var.
|
|
9
|
+
*
|
|
10
|
+
* One-shot : make_video({ prompt }) → finished MP4 (create→start→poll→download)
|
|
11
|
+
* Shorts : create_short → generate_short → wait_for_completion → download_result
|
|
12
|
+
* Editor : create_editor_ad (creates + starts autopilot) → wait_for_completion → download_result
|
|
13
|
+
* Granular : create_editor_draft → generate_scenario|set_scenario → get_editor
|
|
14
|
+
* → set_scene_count → set_segment_prompt → generate_segment(s)
|
|
15
|
+
* → set_narration_script|generate_narration → generate_voice + generate_music
|
|
16
|
+
* → render → wait_for_completion → download_result
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { writeFile } from "node:fs/promises";
|
|
20
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
21
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import {
|
|
24
|
+
clientFromEnv,
|
|
25
|
+
type HubfluencerClient,
|
|
26
|
+
type HubfluencerError,
|
|
27
|
+
} from "./client.js";
|
|
28
|
+
import {
|
|
29
|
+
asRecord,
|
|
30
|
+
assertSafeFetchUrl,
|
|
31
|
+
idemKey,
|
|
32
|
+
inferKind,
|
|
33
|
+
type Kind,
|
|
34
|
+
type NormalizedStatus,
|
|
35
|
+
normalizeStatus,
|
|
36
|
+
resolveSavePath,
|
|
37
|
+
} from "./core.js";
|
|
38
|
+
import {
|
|
39
|
+
IMAGE_EXTS,
|
|
40
|
+
uploadImageFile,
|
|
41
|
+
uploadVideoFile,
|
|
42
|
+
VIDEO_EXTS,
|
|
43
|
+
} from "./uploads.js";
|
|
44
|
+
|
|
45
|
+
async function fetchStatus(
|
|
46
|
+
client: HubfluencerClient,
|
|
47
|
+
kind: Kind,
|
|
48
|
+
slug: string,
|
|
49
|
+
): Promise<NormalizedStatus> {
|
|
50
|
+
const path = kind === "short" ? `/shorts/${slug}` : `/editor/${slug}`;
|
|
51
|
+
const res = await client.get<{ data: unknown }>(path);
|
|
52
|
+
return normalizeStatus(kind, slug, asRecord(res).data);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The video_url comes back in API JSON — allow loopback http only when the API
|
|
56
|
+
// base itself is local (dev), mirroring uploads.ts and the base-URL exception.
|
|
57
|
+
const ALLOW_LOOPBACK_FETCH =
|
|
58
|
+
/^http:\/\/(localhost|127\.0\.0\.1|\[?::1\]?)/.test(
|
|
59
|
+
process.env.HUBFLUENCER_BASE_URL || "",
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
async function downloadTo(
|
|
63
|
+
videoUrl: string,
|
|
64
|
+
savePath: string,
|
|
65
|
+
): Promise<{ saved_to: string; bytes: number }> {
|
|
66
|
+
const target = resolveSavePath(savePath);
|
|
67
|
+
// The video_url is server-supplied — refuse an insecure or private/internal
|
|
68
|
+
// host so a compromised/MITM'd response can't drive an SSRF write to disk.
|
|
69
|
+
assertSafeFetchUrl(videoUrl, { allowLoopback: ALLOW_LOOPBACK_FETCH });
|
|
70
|
+
// Bound the download: a presigned URL could in theory hang or point at
|
|
71
|
+
// something huge, so cap both time and size instead of buffering blindly.
|
|
72
|
+
const MAX_BYTES = 1024 * 1024 * 1024; // 1 GB — generous for a short ad
|
|
73
|
+
const resp = await fetch(videoUrl, { signal: AbortSignal.timeout(120_000) });
|
|
74
|
+
if (!resp.ok)
|
|
75
|
+
throw new Error(`Failed to download video (HTTP ${resp.status}).`);
|
|
76
|
+
// Fast path: trust a present content-length to reject an oversize body before
|
|
77
|
+
// reading a single byte.
|
|
78
|
+
const declared = Number(resp.headers.get("content-length") ?? "");
|
|
79
|
+
if (Number.isFinite(declared) && declared > MAX_BYTES) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Video is ${declared} bytes — over the ${MAX_BYTES}-byte download cap.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
// Stream the body and abort as soon as the running total exceeds the cap, so
|
|
85
|
+
// a missing/lying content-length can't OOM the process by buffering blindly.
|
|
86
|
+
if (!resp.body) throw new Error("Download returned an empty body.");
|
|
87
|
+
const reader = resp.body.getReader();
|
|
88
|
+
const chunks: Buffer[] = [];
|
|
89
|
+
let total = 0;
|
|
90
|
+
try {
|
|
91
|
+
while (true) {
|
|
92
|
+
const { done, value } = await reader.read();
|
|
93
|
+
if (done) break;
|
|
94
|
+
if (!value) continue;
|
|
95
|
+
total += value.byteLength;
|
|
96
|
+
if (total > MAX_BYTES) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Video exceeds the ${MAX_BYTES}-byte download cap (aborted mid-stream).`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
chunks.push(Buffer.from(value));
|
|
102
|
+
}
|
|
103
|
+
} finally {
|
|
104
|
+
await reader.cancel().catch(() => {});
|
|
105
|
+
}
|
|
106
|
+
const buf = Buffer.concat(chunks, total);
|
|
107
|
+
await writeFile(target, buf);
|
|
108
|
+
return { saved_to: target, bytes: buf.length };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** An MCP `resource_link` content block — points at a media asset by URL. */
|
|
112
|
+
type ResourceLink = {
|
|
113
|
+
type: "resource_link";
|
|
114
|
+
uri: string;
|
|
115
|
+
name: string;
|
|
116
|
+
mimeType?: string;
|
|
117
|
+
description?: string;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Wraps a tool's JSON payload as a text block (the universal fallback) and,
|
|
122
|
+
* alongside it, a `structuredContent` copy for clients that consume typed
|
|
123
|
+
* output — plus any media `resource_link`s. Text stays the source of truth so
|
|
124
|
+
* older clients keep working.
|
|
125
|
+
*/
|
|
126
|
+
function ok(payload: unknown, links: ResourceLink[] = []) {
|
|
127
|
+
const content: Array<{ type: "text"; text: string } | ResourceLink> = [
|
|
128
|
+
{ type: "text", text: JSON.stringify(payload, null, 2) },
|
|
129
|
+
...links,
|
|
130
|
+
];
|
|
131
|
+
const result: {
|
|
132
|
+
content: typeof content;
|
|
133
|
+
structuredContent?: Record<string, unknown>;
|
|
134
|
+
} = { content };
|
|
135
|
+
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
|
136
|
+
result.structuredContent = payload as Record<string, unknown>;
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Builds a `resource_link` for a finished MP4 so clients can surface it as media. */
|
|
142
|
+
function mp4Link(url: string, slug: string): ResourceLink {
|
|
143
|
+
return {
|
|
144
|
+
type: "resource_link",
|
|
145
|
+
uri: url,
|
|
146
|
+
name: `${slug}.mp4`,
|
|
147
|
+
mimeType: "video/mp4",
|
|
148
|
+
description: "Finished, post-ready MP4 (presigned, ~24h TTL).",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function fail(message: string) {
|
|
153
|
+
return { isError: true, content: [{ type: "text" as const, text: message }] };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function errMessage(e: unknown): string {
|
|
157
|
+
const he = e as HubfluencerError;
|
|
158
|
+
if (he && typeof he.status === "number") {
|
|
159
|
+
const base = `API error (HTTP ${he.status}${he.code ? `, ${he.code}` : ""}): ${he.message}`;
|
|
160
|
+
// Surface the structured credit fields the server attaches to 402s so the
|
|
161
|
+
// agent can report exactly how short it is instead of a bare sentence.
|
|
162
|
+
const body = he.body as Record<string, unknown> | undefined;
|
|
163
|
+
if (
|
|
164
|
+
body &&
|
|
165
|
+
(body.required_credits != null || body.available_credits != null)
|
|
166
|
+
) {
|
|
167
|
+
const req = body.required_credits;
|
|
168
|
+
const have = body.available_credits;
|
|
169
|
+
return `${base} (needs ${req ?? "?"} credits, have ${have ?? "?"}).`;
|
|
170
|
+
}
|
|
171
|
+
return base;
|
|
172
|
+
}
|
|
173
|
+
return e instanceof Error ? e.message : String(e);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Idempotency key for an unlock purchase, keyed on the CURRENT quota snapshot
|
|
178
|
+
* (used + bonus), which advances after every successful unlock.
|
|
179
|
+
*
|
|
180
|
+
* Why not a constant key: unlock is a *repeatable* purchase (1 credit → +10
|
|
181
|
+
* assists, call again for more). The server caches idempotent POSTs for 24h, so
|
|
182
|
+
* a constant key would make every later unlock replay the first response —
|
|
183
|
+
* silently no-op'ing the purchase (no credit spent, no assists added) while
|
|
184
|
+
* still returning 200. Snapshotting means a transport retry of the SAME unlock
|
|
185
|
+
* replays safely (no double-charge), but a DISTINCT "buy another batch" call
|
|
186
|
+
* gets a fresh key and executes. Falls back to no key (each call executes) if
|
|
187
|
+
* the snapshot can't be read — safer than risking a replayed no-op.
|
|
188
|
+
*/
|
|
189
|
+
async function unlockKey(client: HubfluencerClient): Promise<string> {
|
|
190
|
+
try {
|
|
191
|
+
const res = await client.get<{ data: unknown }>("/ai-assists");
|
|
192
|
+
const d = asRecord(asRecord(res).data);
|
|
193
|
+
return idemKey(
|
|
194
|
+
"unlock-ai-assists",
|
|
195
|
+
String(d.used ?? ""),
|
|
196
|
+
String(d.bonus ?? ""),
|
|
197
|
+
);
|
|
198
|
+
} catch {
|
|
199
|
+
return "";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Idempotency key for voice synthesis, keyed on the INPUTS that determine the
|
|
205
|
+
* output: the current narration script + the chosen voice. Voice TTS is
|
|
206
|
+
* deterministic, so replaying an identical (narration, voice) call is correct
|
|
207
|
+
* and free, while regenerating after the narration or voice changes gets a
|
|
208
|
+
* fresh key and actually re-runs — a fixed slug+voice key would replay stale
|
|
209
|
+
* audio for 24h after a narration edit. Falls back to no key on read failure.
|
|
210
|
+
*/
|
|
211
|
+
async function voiceKey(
|
|
212
|
+
client: HubfluencerClient,
|
|
213
|
+
slug: string,
|
|
214
|
+
voiceId: string,
|
|
215
|
+
): Promise<string> {
|
|
216
|
+
try {
|
|
217
|
+
const res = await client.get<{ data: unknown }>(`/editor/${slug}`);
|
|
218
|
+
const d = asRecord(asRecord(res).data);
|
|
219
|
+
return idemKey(
|
|
220
|
+
"gen-voice",
|
|
221
|
+
slug,
|
|
222
|
+
voiceId,
|
|
223
|
+
String(d.narration_script ?? ""),
|
|
224
|
+
);
|
|
225
|
+
} catch {
|
|
226
|
+
return "";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Runs an assist-consuming call (`fn`), handling the daily AI-assist quota.
|
|
232
|
+
*
|
|
233
|
+
* AI assists are a free daily quota (20/day) shared by helper endpoints
|
|
234
|
+
* (generate-scenario, generate-narration, enhance-prompt, suggest-*). When the
|
|
235
|
+
* quota is exhausted the server returns 429 `ai_assist_quota_exceeded` with a
|
|
236
|
+
* structured body `{remaining, unlock_cost, can_unlock, ...}`.
|
|
237
|
+
*
|
|
238
|
+
* Behaviour (HARD CAP — at most one unlock + one retry, never a loop):
|
|
239
|
+
* - 429 + autoUnlock=false → re-throw so the caller surfaces the structured
|
|
240
|
+
* quota body to the agent (it can then unlock or write content itself).
|
|
241
|
+
* - 429 + autoUnlock=true → POST /ai-assists/unlock ONCE (1 credit → +10
|
|
242
|
+
* assists), then retry `fn` ONCE. If unlock itself 402s
|
|
243
|
+
* (credits_insufficient), throw — clean fail, no loop.
|
|
244
|
+
* - any non-429 error → re-throw unchanged (402 credit errors flow straight
|
|
245
|
+
* through to the caller's fail()).
|
|
246
|
+
*/
|
|
247
|
+
async function withAssist<T>(
|
|
248
|
+
client: HubfluencerClient,
|
|
249
|
+
autoUnlock: boolean,
|
|
250
|
+
fn: () => Promise<T>,
|
|
251
|
+
): Promise<T> {
|
|
252
|
+
try {
|
|
253
|
+
return await fn();
|
|
254
|
+
} catch (e) {
|
|
255
|
+
const he = e as HubfluencerError;
|
|
256
|
+
if (!he || he.status !== 429) throw e;
|
|
257
|
+
if (!autoUnlock) throw e;
|
|
258
|
+
// One unlock (1 credit → +10 assists), then one retry. A 402 here means
|
|
259
|
+
// the account is out of credits — let it propagate to a clean fail().
|
|
260
|
+
await client.post("/ai-assists/unlock", undefined, await unlockKey(client));
|
|
261
|
+
return await fn();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
interface ToolExtra {
|
|
266
|
+
_meta?: { progressToken?: string | number };
|
|
267
|
+
sendNotification?: (n: unknown) => Promise<void>;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Emits an MCP progress notification if the client requested one. Best-effort. */
|
|
271
|
+
async function reportProgress(
|
|
272
|
+
extra: ToolExtra | undefined,
|
|
273
|
+
progress: number,
|
|
274
|
+
message: string,
|
|
275
|
+
total?: number,
|
|
276
|
+
) {
|
|
277
|
+
const token = extra?._meta?.progressToken;
|
|
278
|
+
if (token === undefined || !extra?.sendNotification) return;
|
|
279
|
+
try {
|
|
280
|
+
await extra.sendNotification({
|
|
281
|
+
method: "notifications/progress",
|
|
282
|
+
params: { progressToken: token, progress, total, message },
|
|
283
|
+
});
|
|
284
|
+
} catch {
|
|
285
|
+
// progress is advisory — never fail the tool over it
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Wraps a tool body with uniform client resolution + error handling. */
|
|
290
|
+
function tool<A>(
|
|
291
|
+
fn: (
|
|
292
|
+
args: A,
|
|
293
|
+
client: HubfluencerClient,
|
|
294
|
+
extra: ToolExtra,
|
|
295
|
+
) => Promise<ReturnType<typeof ok>>,
|
|
296
|
+
) {
|
|
297
|
+
// `extra` is the SDK's RequestHandlerExtra; typed loosely here and narrowed
|
|
298
|
+
// to ToolExtra internally to avoid coupling to SDK generic variance.
|
|
299
|
+
return async (args: A, extra: unknown) => {
|
|
300
|
+
let client: HubfluencerClient;
|
|
301
|
+
try {
|
|
302
|
+
client = clientFromEnv();
|
|
303
|
+
} catch (e) {
|
|
304
|
+
return fail(errMessage(e));
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
return await fn(args, client, extra as ToolExtra);
|
|
308
|
+
} catch (e) {
|
|
309
|
+
return fail(errMessage(e));
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
315
|
+
|
|
316
|
+
const kindSchema = z.enum(["short", "editor"]).describe("Project kind");
|
|
317
|
+
|
|
318
|
+
const RO = { readOnlyHint: true, destructiveHint: false, openWorldHint: true };
|
|
319
|
+
const WRITE = {
|
|
320
|
+
readOnlyHint: false,
|
|
321
|
+
destructiveHint: false,
|
|
322
|
+
openWorldHint: true,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const server = new McpServer({ name: "hubfluencer", version: "0.1.0" });
|
|
326
|
+
|
|
327
|
+
// The SDK's `registerTool` is generic over the Zod input shape; with this many
|
|
328
|
+
// tools its conditional types hit TS2589 ("excessively deep"). Inputs are still
|
|
329
|
+
// validated by Zod at runtime (the real method runs); we bind it through a
|
|
330
|
+
// loose, non-generic signature so the heavy generic never instantiates at
|
|
331
|
+
// compile time. Handler arg types remain annotated per tool below.
|
|
332
|
+
type LooseRegister = (
|
|
333
|
+
name: string,
|
|
334
|
+
config: object,
|
|
335
|
+
handler: (...args: never[]) => unknown,
|
|
336
|
+
) => void;
|
|
337
|
+
const registerTool = server.registerTool.bind(
|
|
338
|
+
server,
|
|
339
|
+
) as unknown as LooseRegister;
|
|
340
|
+
|
|
341
|
+
async function pollToTerminal(
|
|
342
|
+
client: HubfluencerClient,
|
|
343
|
+
kind: Kind,
|
|
344
|
+
slug: string,
|
|
345
|
+
extra: ToolExtra,
|
|
346
|
+
budgetMs: number,
|
|
347
|
+
intervalMs: number,
|
|
348
|
+
): Promise<NormalizedStatus> {
|
|
349
|
+
const deadline = Date.now() + budgetMs;
|
|
350
|
+
let status = await fetchStatus(client, kind, slug);
|
|
351
|
+
let n = 0;
|
|
352
|
+
while (!status.terminal && Date.now() + intervalMs <= deadline) {
|
|
353
|
+
await reportProgress(extra, ++n, `stage: ${status.stage}`);
|
|
354
|
+
await sleep(intervalMs);
|
|
355
|
+
status = await fetchStatus(client, kind, slug);
|
|
356
|
+
}
|
|
357
|
+
return status;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Polls ONE editor segment until it reaches a terminal state ("completed" or
|
|
362
|
+
* "failed"), or the budget runs out. Returns the segment's final status plus
|
|
363
|
+
* any error_message.
|
|
364
|
+
*
|
|
365
|
+
* generate_all_segments must gate each scene on its OWN completion — the
|
|
366
|
+
* editor-level status (normalizeStatus) only goes terminal on a finished/failed
|
|
367
|
+
* RENDER, which never happens mid-batch during granular generation, so polling
|
|
368
|
+
* the project status would burn the whole budget every iteration and never
|
|
369
|
+
* observe a failed scene. Segment status flows pending → processing →
|
|
370
|
+
* completed | failed (set server-side in editor/generation.ex).
|
|
371
|
+
*/
|
|
372
|
+
async function pollSegmentToTerminal(
|
|
373
|
+
client: HubfluencerClient,
|
|
374
|
+
slug: string,
|
|
375
|
+
segmentId: string | number,
|
|
376
|
+
extra: ToolExtra,
|
|
377
|
+
budgetMs: number,
|
|
378
|
+
intervalMs: number,
|
|
379
|
+
): Promise<{ status: string; error: string | null; timed_out: boolean }> {
|
|
380
|
+
const deadline = Date.now() + budgetMs;
|
|
381
|
+
const sid = String(segmentId);
|
|
382
|
+
const read = async (): Promise<{ status: string; error: string | null }> => {
|
|
383
|
+
const res = await client.get<{ data: unknown }>(`/editor/${slug}`);
|
|
384
|
+
const data = asRecord(asRecord(res).data);
|
|
385
|
+
const segments = Array.isArray(data.segments)
|
|
386
|
+
? (data.segments as Record<string, unknown>[])
|
|
387
|
+
: [];
|
|
388
|
+
const seg = segments.find((s) => String(s.id) === sid);
|
|
389
|
+
return {
|
|
390
|
+
status: seg ? ((seg.status as string) ?? "unknown") : "unknown",
|
|
391
|
+
error: seg ? ((seg.error_message as string) ?? null) : null,
|
|
392
|
+
};
|
|
393
|
+
};
|
|
394
|
+
let n = 0;
|
|
395
|
+
let { status, error } = await read();
|
|
396
|
+
while (
|
|
397
|
+
status !== "completed" &&
|
|
398
|
+
status !== "failed" &&
|
|
399
|
+
Date.now() + intervalMs <= deadline
|
|
400
|
+
) {
|
|
401
|
+
await reportProgress(extra, ++n, `segment ${sid}: ${status}`);
|
|
402
|
+
await sleep(intervalMs);
|
|
403
|
+
({ status, error } = await read());
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
status,
|
|
407
|
+
error,
|
|
408
|
+
timed_out: status !== "completed" && status !== "failed",
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── One-shot: make_video ─────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
registerTool(
|
|
415
|
+
"make_video",
|
|
416
|
+
{
|
|
417
|
+
title: "Make a video from a prompt (one shot)",
|
|
418
|
+
description:
|
|
419
|
+
"The simplest path: give a prompt, get a finished MP4. Creates the project (free), PRICES it against " +
|
|
420
|
+
"your live credit balance, then — only if it's affordable and within max_credits — starts generation, " +
|
|
421
|
+
"polls to completion (emitting progress), and (if save_path is given) downloads the result. " +
|
|
422
|
+
"Spends credits (15 for a short; a multi-scene editor ad ~28). Pass dry_run:true to preview the cost " +
|
|
423
|
+
"WITHOUT charging (returns {estimated_credits, available_credits, slug}); pass max_credits to cap the " +
|
|
424
|
+
"spend. kind defaults to 'auto' — a multi-scene editor ad for ad/promo/story briefs, a single-clip short " +
|
|
425
|
+
"for simple/short ones; the chosen kind is reported back as kind_inferred. If it returns terminal=false " +
|
|
426
|
+
"the render is still running — call wait_for_completion with the returned slug to finish.",
|
|
427
|
+
// Schema kept intentionally flat (no .min/.max/.int chains) — the SDK's
|
|
428
|
+
// generic inference on registerTool hits TS2589 ("excessively deep") on
|
|
429
|
+
// larger schemas with chained validators. Ranges are enforced in code.
|
|
430
|
+
inputSchema: {
|
|
431
|
+
prompt: z
|
|
432
|
+
.string()
|
|
433
|
+
.describe("What the ad/video should be about (min 10 chars)"),
|
|
434
|
+
kind: z
|
|
435
|
+
.string()
|
|
436
|
+
.optional()
|
|
437
|
+
.describe(
|
|
438
|
+
"'short' (fast, 1 clip), 'editor' (multi-scene), or 'auto' (default — inferred)",
|
|
439
|
+
),
|
|
440
|
+
language: z
|
|
441
|
+
.string()
|
|
442
|
+
.optional()
|
|
443
|
+
.describe('Language code, e.g. "en" (default)'),
|
|
444
|
+
aspect: z
|
|
445
|
+
.string()
|
|
446
|
+
.optional()
|
|
447
|
+
.describe("Aspect ratio for editor: 9:16 (default), 16:9, or 1:1"),
|
|
448
|
+
voice_id: z
|
|
449
|
+
.string()
|
|
450
|
+
.optional()
|
|
451
|
+
.describe(
|
|
452
|
+
"Preferred narration voice id for editor ads (see list_voices); shorts have no voiceover",
|
|
453
|
+
),
|
|
454
|
+
save_path: z
|
|
455
|
+
.string()
|
|
456
|
+
.optional()
|
|
457
|
+
.describe(
|
|
458
|
+
"Optional .mp4 path to download to (confined to HUBFLUENCER_OUTPUT_DIR or cwd)",
|
|
459
|
+
),
|
|
460
|
+
max_wait_seconds: z
|
|
461
|
+
.number()
|
|
462
|
+
.optional()
|
|
463
|
+
.describe("Block budget seconds (default 240, capped 10–280)"),
|
|
464
|
+
dry_run: z
|
|
465
|
+
.boolean()
|
|
466
|
+
.optional()
|
|
467
|
+
.describe(
|
|
468
|
+
"Preview only: create a free draft, price it, and STOP before spending credits. " +
|
|
469
|
+
"Returns {estimated_credits, available_credits, slug}. Resume with generate_short / start_autopilot.",
|
|
470
|
+
),
|
|
471
|
+
max_credits: z
|
|
472
|
+
.number()
|
|
473
|
+
.optional()
|
|
474
|
+
.describe(
|
|
475
|
+
"Spend cap: refuse to start (no charge) if the priced estimate exceeds this. Returns the estimate.",
|
|
476
|
+
),
|
|
477
|
+
},
|
|
478
|
+
annotations: { title: "Make a video", ...WRITE, idempotentHint: false },
|
|
479
|
+
},
|
|
480
|
+
tool(
|
|
481
|
+
async (
|
|
482
|
+
args: {
|
|
483
|
+
prompt: string;
|
|
484
|
+
kind?: string;
|
|
485
|
+
language?: string;
|
|
486
|
+
aspect?: string;
|
|
487
|
+
voice_id?: string;
|
|
488
|
+
save_path?: string;
|
|
489
|
+
max_wait_seconds?: number;
|
|
490
|
+
dry_run?: boolean;
|
|
491
|
+
max_credits?: number;
|
|
492
|
+
},
|
|
493
|
+
client,
|
|
494
|
+
extra,
|
|
495
|
+
) => {
|
|
496
|
+
if (!args.prompt || args.prompt.trim().length < 10) {
|
|
497
|
+
return fail("prompt must be at least 10 characters.");
|
|
498
|
+
}
|
|
499
|
+
const requestedKind =
|
|
500
|
+
args.kind === "short" || args.kind === "editor" ? args.kind : undefined;
|
|
501
|
+
const kind: Kind = requestedKind ?? inferKind(args.prompt);
|
|
502
|
+
const waitSeconds = Math.min(
|
|
503
|
+
280,
|
|
504
|
+
Math.max(10, args.max_wait_seconds ?? 240),
|
|
505
|
+
);
|
|
506
|
+
const budgetMs = waitSeconds * 1000;
|
|
507
|
+
|
|
508
|
+
// Validate the save_path up front so we fail before spending credits.
|
|
509
|
+
if (args.save_path) resolveSavePath(args.save_path);
|
|
510
|
+
|
|
511
|
+
// 1) Create the project — FREE (0 credits) for both kinds. Creating
|
|
512
|
+
// first lets us price the job against the server's real cost endpoint
|
|
513
|
+
// before any charge; the stable idempotency key means a retried
|
|
514
|
+
// make_video (or a dry_run then a real run) reuses this same draft.
|
|
515
|
+
let slug: string;
|
|
516
|
+
if (kind === "short") {
|
|
517
|
+
const created = await client.post<{ data: { slug: string } }>(
|
|
518
|
+
"/shorts",
|
|
519
|
+
{ product_prompt: args.prompt, language: args.language },
|
|
520
|
+
// Fold every create-affecting param into the key: two calls that
|
|
521
|
+
// differ only in language must NOT dedup to the same draft, and a
|
|
522
|
+
// transport retry of an identical call must reuse it.
|
|
523
|
+
idemKey("make-short", args.prompt, args.language ?? ""),
|
|
524
|
+
);
|
|
525
|
+
slug = created.data.slug;
|
|
526
|
+
} else {
|
|
527
|
+
const created = await client.post<{ data: { slug: string } }>(
|
|
528
|
+
"/editor",
|
|
529
|
+
{
|
|
530
|
+
language: args.language ?? "en",
|
|
531
|
+
product_prompt: args.prompt,
|
|
532
|
+
export_aspect_ratio: args.aspect,
|
|
533
|
+
voice_id: args.voice_id,
|
|
534
|
+
},
|
|
535
|
+
idemKey(
|
|
536
|
+
"make-editor",
|
|
537
|
+
args.prompt,
|
|
538
|
+
args.language ?? "en",
|
|
539
|
+
args.aspect ?? "",
|
|
540
|
+
args.voice_id ?? "",
|
|
541
|
+
),
|
|
542
|
+
);
|
|
543
|
+
slug = created.data.slug;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// 2) Preflight cost vs the live balance BEFORE charging. Best-effort: if
|
|
547
|
+
// pricing can't be read we proceed (the charge itself still 402s
|
|
548
|
+
// cleanly), surfacing nulls rather than blocking on a transient error.
|
|
549
|
+
const costPath =
|
|
550
|
+
kind === "short"
|
|
551
|
+
? `/shorts/${slug}/cost`
|
|
552
|
+
: `/editor/${slug}/autopilot/cost`;
|
|
553
|
+
let estimated_credits: number | null = null;
|
|
554
|
+
let available_credits: number | null = null;
|
|
555
|
+
try {
|
|
556
|
+
const cost = asRecord(
|
|
557
|
+
asRecord(await client.get<{ data: unknown }>(costPath)).data,
|
|
558
|
+
);
|
|
559
|
+
estimated_credits = typeof cost.total === "number" ? cost.total : null;
|
|
560
|
+
available_credits =
|
|
561
|
+
typeof cost.available_credits === "number"
|
|
562
|
+
? cost.available_credits
|
|
563
|
+
: null;
|
|
564
|
+
} catch {
|
|
565
|
+
// pricing unavailable — fall through (do not block an affordable run)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const resume =
|
|
569
|
+
kind === "short"
|
|
570
|
+
? `generate_short({ slug: "${slug}" })`
|
|
571
|
+
: `start_autopilot({ slug: "${slug}" })`;
|
|
572
|
+
const affordable =
|
|
573
|
+
available_credits === null ||
|
|
574
|
+
estimated_credits === null ||
|
|
575
|
+
available_credits >= estimated_credits;
|
|
576
|
+
const overCap =
|
|
577
|
+
args.max_credits != null &&
|
|
578
|
+
estimated_credits != null &&
|
|
579
|
+
estimated_credits > args.max_credits;
|
|
580
|
+
|
|
581
|
+
// 3) Stop BEFORE charging on dry_run, an over-cap estimate, or a balance
|
|
582
|
+
// we already know is too low. The free draft is returned so the caller
|
|
583
|
+
// can top up / raise the cap and resume — no wasted charge, no orphan
|
|
584
|
+
// on retry (the create idempotency key reuses this slug).
|
|
585
|
+
if (args.dry_run || overCap || !affordable) {
|
|
586
|
+
const note = args.dry_run
|
|
587
|
+
? `Dry run — created a free draft and priced it (${estimated_credits ?? "?"} credits, have ${available_credits ?? "?"}). To run it: ${resume}.`
|
|
588
|
+
: overCap
|
|
589
|
+
? `Estimated ${estimated_credits} credits exceeds max_credits ${args.max_credits}; nothing charged. Raise max_credits, or run ${resume}.`
|
|
590
|
+
: `Not enough credits (needs ${estimated_credits ?? "?"}, have ${available_credits ?? "?"}); nothing charged. Top up, then run ${resume}.`;
|
|
591
|
+
return ok({
|
|
592
|
+
slug,
|
|
593
|
+
kind,
|
|
594
|
+
kind_inferred: requestedKind === undefined,
|
|
595
|
+
estimated_credits,
|
|
596
|
+
available_credits,
|
|
597
|
+
affordable,
|
|
598
|
+
charged: false,
|
|
599
|
+
note,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// 4) Affordable and authorized — start the charging pipeline.
|
|
604
|
+
if (kind === "short") {
|
|
605
|
+
await client.post(
|
|
606
|
+
`/shorts/${slug}/generate`,
|
|
607
|
+
undefined,
|
|
608
|
+
`gen-short:${slug}`,
|
|
609
|
+
);
|
|
610
|
+
} else {
|
|
611
|
+
await client.post(
|
|
612
|
+
`/editor/${slug}/autopilot`,
|
|
613
|
+
undefined,
|
|
614
|
+
`autopilot:${slug}`,
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
await reportProgress(extra, 0, `started ${kind} ${slug}`);
|
|
619
|
+
const status = await pollToTerminal(
|
|
620
|
+
client,
|
|
621
|
+
kind,
|
|
622
|
+
slug,
|
|
623
|
+
extra,
|
|
624
|
+
budgetMs,
|
|
625
|
+
15000,
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
let saved_to: string | null = null;
|
|
629
|
+
if (status.ready && status.video_url && args.save_path) {
|
|
630
|
+
saved_to = (await downloadTo(status.video_url, args.save_path))
|
|
631
|
+
.saved_to;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const result = {
|
|
635
|
+
...status,
|
|
636
|
+
kind_inferred: requestedKind === undefined,
|
|
637
|
+
estimated_credits,
|
|
638
|
+
available_credits,
|
|
639
|
+
charged: true,
|
|
640
|
+
saved_to,
|
|
641
|
+
timed_out: !status.terminal,
|
|
642
|
+
};
|
|
643
|
+
// Thread the caller's save_path into the resume hint so the one-shot
|
|
644
|
+
// "download to this path" intent survives a mid-render timeout —
|
|
645
|
+
// wait_for_completion accepts save_path and finishes the download.
|
|
646
|
+
const resumeArgs = `{ slug: "${slug}", kind: "${kind}"${
|
|
647
|
+
args.save_path ? `, save_path: "${args.save_path}"` : ""
|
|
648
|
+
} }`;
|
|
649
|
+
const links =
|
|
650
|
+
status.ready && status.video_url
|
|
651
|
+
? [mp4Link(status.video_url, slug)]
|
|
652
|
+
: [];
|
|
653
|
+
return ok(
|
|
654
|
+
{
|
|
655
|
+
...result,
|
|
656
|
+
note: status.ready
|
|
657
|
+
? "Done. Presigned ~24h URL — download promptly."
|
|
658
|
+
: status.terminal
|
|
659
|
+
? `Terminal (${status.stage}). ${status.error ?? ""}`.trim()
|
|
660
|
+
: `Still rendering — call wait_for_completion(${resumeArgs}).`,
|
|
661
|
+
},
|
|
662
|
+
links,
|
|
663
|
+
);
|
|
664
|
+
},
|
|
665
|
+
),
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
// ── Credits ───────────────────────────────────────────────────────────────
|
|
669
|
+
|
|
670
|
+
registerTool(
|
|
671
|
+
"get_credits",
|
|
672
|
+
{
|
|
673
|
+
title: "Get credit balance",
|
|
674
|
+
description:
|
|
675
|
+
"Returns the authenticated account's credit balance. A short costs 15 credits.",
|
|
676
|
+
inputSchema: {},
|
|
677
|
+
annotations: { title: "Get credits", ...RO },
|
|
678
|
+
},
|
|
679
|
+
tool(async (_args, client) => ok(await client.get("/studio/credits"))),
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
// ── Voices ────────────────────────────────────────────────────────────────
|
|
683
|
+
|
|
684
|
+
registerTool(
|
|
685
|
+
"list_voices",
|
|
686
|
+
{
|
|
687
|
+
title: "List voices",
|
|
688
|
+
description:
|
|
689
|
+
"Lists available narration voices (id + name). Pass a voice id as voice_id to create_editor_ad / " +
|
|
690
|
+
"make_video to pick the narration voice for an editor ad. Shorts have no voiceover.",
|
|
691
|
+
inputSchema: {},
|
|
692
|
+
annotations: { title: "List voices", ...RO },
|
|
693
|
+
},
|
|
694
|
+
tool(async (_args, client) => ok(await client.get("/voices"))),
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
// ── Projects ──────────────────────────────────────────────────────────────
|
|
698
|
+
|
|
699
|
+
registerTool(
|
|
700
|
+
"list_projects",
|
|
701
|
+
{
|
|
702
|
+
title: "List recent projects",
|
|
703
|
+
description:
|
|
704
|
+
"Lists the caller's recent shorts and editor/video-factory projects, each normalized to " +
|
|
705
|
+
"{slug, stage, terminal, ready, video_url}. By default the shorts listing is limited to " +
|
|
706
|
+
"in-progress/failed; pass include_completed:true to also surface finished shorts — useful to " +
|
|
707
|
+
"recover a lost slug. Capped to the most recent items per kind (use get_status for authoritative state).",
|
|
708
|
+
inputSchema: {
|
|
709
|
+
include_completed: z
|
|
710
|
+
.boolean()
|
|
711
|
+
.optional()
|
|
712
|
+
.describe("Also include completed shorts (default false)"),
|
|
713
|
+
limit: z
|
|
714
|
+
.number()
|
|
715
|
+
.optional()
|
|
716
|
+
.describe("Max items per kind (default 25, capped at 100)"),
|
|
717
|
+
},
|
|
718
|
+
annotations: { title: "List projects", ...RO },
|
|
719
|
+
},
|
|
720
|
+
tool(
|
|
721
|
+
async (args: { include_completed?: boolean; limit?: number }, client) => {
|
|
722
|
+
const cap = Math.min(100, Math.max(1, args.limit ?? 25));
|
|
723
|
+
const [shortsRaw, factoriesRaw] = await Promise.all([
|
|
724
|
+
client
|
|
725
|
+
.get<{ data: unknown }>(
|
|
726
|
+
"/shorts",
|
|
727
|
+
args.include_completed ? { include_completed: "true" } : undefined,
|
|
728
|
+
)
|
|
729
|
+
.catch(() => ({ data: [] })),
|
|
730
|
+
client
|
|
731
|
+
.get<{ data: unknown }>("/video-factories")
|
|
732
|
+
.catch(() => ({ data: [] })),
|
|
733
|
+
]);
|
|
734
|
+
|
|
735
|
+
const rows = (raw: unknown): Record<string, unknown>[] => {
|
|
736
|
+
const d = asRecord(raw).data;
|
|
737
|
+
return Array.isArray(d) ? (d as Record<string, unknown>[]) : [];
|
|
738
|
+
};
|
|
739
|
+
const shortRows = rows(shortsRaw);
|
|
740
|
+
const factoryRows = rows(factoriesRaw);
|
|
741
|
+
|
|
742
|
+
const shorts = shortRows
|
|
743
|
+
.slice(0, cap)
|
|
744
|
+
.map((s) => normalizeStatus("short", String(s.slug ?? ""), s));
|
|
745
|
+
// /video-factories spans editor + campaign + creative kinds; normalize
|
|
746
|
+
// each through the editor shape so the agent gets one consistent status
|
|
747
|
+
// envelope instead of a heavy, unlabeled raw payload.
|
|
748
|
+
const projects = factoryRows.slice(0, cap).map((f) => ({
|
|
749
|
+
...normalizeStatus("editor", String(f.slug ?? ""), f),
|
|
750
|
+
kind_detail: (f.kind as string) ?? null,
|
|
751
|
+
}));
|
|
752
|
+
|
|
753
|
+
return ok({
|
|
754
|
+
shorts,
|
|
755
|
+
projects,
|
|
756
|
+
note:
|
|
757
|
+
shortRows.length > cap || factoryRows.length > cap
|
|
758
|
+
? `Showing up to ${cap} per kind; more exist — raise limit or use get_status.`
|
|
759
|
+
: undefined,
|
|
760
|
+
});
|
|
761
|
+
},
|
|
762
|
+
),
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
// ── Shorts ──────────────────────────────────────────────────────────────────
|
|
766
|
+
|
|
767
|
+
registerTool(
|
|
768
|
+
"create_short",
|
|
769
|
+
{
|
|
770
|
+
title: "Create a short (draft)",
|
|
771
|
+
description:
|
|
772
|
+
"Creates a short draft from a product prompt (min 10 chars). Returns the slug. Costs 0 credits. " +
|
|
773
|
+
"Follow with generate_short to render. Prefer make_video for the one-shot path.",
|
|
774
|
+
inputSchema: {
|
|
775
|
+
product_prompt: z
|
|
776
|
+
.string()
|
|
777
|
+
.min(10)
|
|
778
|
+
.describe("What the short should be about"),
|
|
779
|
+
language: z
|
|
780
|
+
.string()
|
|
781
|
+
.optional()
|
|
782
|
+
.describe('Language code, e.g. "en" (default)'),
|
|
783
|
+
theme: z.string().optional(),
|
|
784
|
+
headline: z.string().optional(),
|
|
785
|
+
subheadline: z.string().optional(),
|
|
786
|
+
music_vibe: z.string().optional(),
|
|
787
|
+
},
|
|
788
|
+
annotations: { title: "Create short", ...WRITE, idempotentHint: true },
|
|
789
|
+
},
|
|
790
|
+
tool(async (args: Record<string, unknown>, client) => {
|
|
791
|
+
const res = await client.post<{ data: { slug: string } }>(
|
|
792
|
+
"/shorts",
|
|
793
|
+
args,
|
|
794
|
+
idemKey("create-short", String(args.product_prompt ?? "")),
|
|
795
|
+
);
|
|
796
|
+
return ok({ slug: res.data.slug, kind: "short", next: "generate_short" });
|
|
797
|
+
}),
|
|
798
|
+
);
|
|
799
|
+
|
|
800
|
+
registerTool(
|
|
801
|
+
"generate_short",
|
|
802
|
+
{
|
|
803
|
+
title: "Generate (render) a short",
|
|
804
|
+
description:
|
|
805
|
+
"Deducts 15 credits and starts the render pipeline for an existing short. Idempotent per slug " +
|
|
806
|
+
"(safe to retry). Then poll with get_status or wait_for_completion (kind=short).",
|
|
807
|
+
inputSchema: { slug: z.string().describe("Short slug from create_short") },
|
|
808
|
+
annotations: { title: "Generate short", ...WRITE, idempotentHint: true },
|
|
809
|
+
},
|
|
810
|
+
tool(async (args: { slug: string }, client) => {
|
|
811
|
+
const res = await client.post<{ data: unknown }>(
|
|
812
|
+
`/shorts/${args.slug}/generate`,
|
|
813
|
+
undefined,
|
|
814
|
+
`gen-short:${args.slug}`,
|
|
815
|
+
);
|
|
816
|
+
const status = normalizeStatus("short", args.slug, asRecord(res).data);
|
|
817
|
+
return ok(status);
|
|
818
|
+
}),
|
|
819
|
+
);
|
|
820
|
+
|
|
821
|
+
// ── Editor ───────────────────────────────────────────────────────────────────
|
|
822
|
+
|
|
823
|
+
registerTool(
|
|
824
|
+
"create_editor_ad",
|
|
825
|
+
{
|
|
826
|
+
title: "Create a multi-scene editor ad (autopilot)",
|
|
827
|
+
description:
|
|
828
|
+
"Creates an editor project from a product prompt and starts autopilot (server-orchestrated " +
|
|
829
|
+
"scenario → segments → narration → voice → music → render). Each AI-generated scene is a fixed 8 seconds. " +
|
|
830
|
+
"Returns the slug. Costs credits. Then poll with get_status / wait_for_completion (kind=editor). Prefer " +
|
|
831
|
+
"make_video for the one-shot path.",
|
|
832
|
+
inputSchema: {
|
|
833
|
+
product_prompt: z
|
|
834
|
+
.string()
|
|
835
|
+
.min(10)
|
|
836
|
+
.describe("Brief for the ad (min 10 chars)"),
|
|
837
|
+
language: z
|
|
838
|
+
.string()
|
|
839
|
+
.optional()
|
|
840
|
+
.describe('Language code, e.g. "en" (default)'),
|
|
841
|
+
theme: z
|
|
842
|
+
.string()
|
|
843
|
+
.optional()
|
|
844
|
+
.describe('Visual theme (default "realistic")'),
|
|
845
|
+
voice_id: z
|
|
846
|
+
.string()
|
|
847
|
+
.optional()
|
|
848
|
+
.describe(
|
|
849
|
+
"Preferred narration voice id (see list_voices); omit for the default voice",
|
|
850
|
+
),
|
|
851
|
+
export_aspect_ratio: z
|
|
852
|
+
.enum(["9:16", "16:9", "1:1"])
|
|
853
|
+
.optional()
|
|
854
|
+
.describe('Aspect ratio (default "9:16")'),
|
|
855
|
+
},
|
|
856
|
+
annotations: { title: "Create editor ad", ...WRITE, idempotentHint: true },
|
|
857
|
+
},
|
|
858
|
+
tool(async (args: Record<string, unknown>, client) => {
|
|
859
|
+
const created = await client.post<{ data: { slug: string } }>(
|
|
860
|
+
"/editor",
|
|
861
|
+
{
|
|
862
|
+
language: args.language ?? "en",
|
|
863
|
+
product_prompt: args.product_prompt,
|
|
864
|
+
theme: args.theme,
|
|
865
|
+
voice_id: args.voice_id,
|
|
866
|
+
export_aspect_ratio: args.export_aspect_ratio,
|
|
867
|
+
},
|
|
868
|
+
idemKey("create-editor", String(args.product_prompt ?? "")),
|
|
869
|
+
);
|
|
870
|
+
const slug = created.data.slug;
|
|
871
|
+
const started = await client.post<{ data: unknown }>(
|
|
872
|
+
`/editor/${slug}/autopilot`,
|
|
873
|
+
undefined,
|
|
874
|
+
`autopilot:${slug}`,
|
|
875
|
+
);
|
|
876
|
+
const status = normalizeStatus("editor", slug, asRecord(started).data);
|
|
877
|
+
return ok({ slug, kind: "editor", status, next: "wait_for_completion" });
|
|
878
|
+
}),
|
|
879
|
+
);
|
|
880
|
+
|
|
881
|
+
registerTool(
|
|
882
|
+
"start_autopilot",
|
|
883
|
+
{
|
|
884
|
+
title: "Start autopilot on an existing editor draft",
|
|
885
|
+
description:
|
|
886
|
+
"Starts server-orchestrated autopilot (scenario → segments → narration → voice → music → render) on an " +
|
|
887
|
+
"EXISTING editor project/draft. Spends credits — preview the estimate first with the autopilot/cost endpoint " +
|
|
888
|
+
"or make_video({ dry_run:true }). Idempotent per slug (safe to retry). Use this to run a draft created by " +
|
|
889
|
+
"make_video dry_run, or to resume after topping up / raising your spend cap. Then poll with " +
|
|
890
|
+
'wait_for_completion({ slug, kind: "editor" }).',
|
|
891
|
+
inputSchema: { slug: z.string().describe("Editor project slug") },
|
|
892
|
+
annotations: { title: "Start autopilot", ...WRITE, idempotentHint: true },
|
|
893
|
+
},
|
|
894
|
+
tool(async (args: { slug: string }, client) => {
|
|
895
|
+
const started = await client.post<{ data: unknown }>(
|
|
896
|
+
`/editor/${args.slug}/autopilot`,
|
|
897
|
+
undefined,
|
|
898
|
+
`autopilot:${args.slug}`,
|
|
899
|
+
);
|
|
900
|
+
const status = normalizeStatus("editor", args.slug, asRecord(started).data);
|
|
901
|
+
return ok({
|
|
902
|
+
slug: args.slug,
|
|
903
|
+
kind: "editor",
|
|
904
|
+
status,
|
|
905
|
+
next: "wait_for_completion",
|
|
906
|
+
});
|
|
907
|
+
}),
|
|
908
|
+
);
|
|
909
|
+
|
|
910
|
+
// ── AI assists (free daily quota) ───────────────────────────────────────────
|
|
911
|
+
|
|
912
|
+
registerTool(
|
|
913
|
+
"get_ai_assists",
|
|
914
|
+
{
|
|
915
|
+
title: "Get AI assist quota",
|
|
916
|
+
description:
|
|
917
|
+
"Returns the free daily AI-assist quota {used, limit, bonus, remaining, resets_at, unlock_cost, " +
|
|
918
|
+
"unlock_batch_size}. AI assists power helper calls (generate_scenario, generate_narration, " +
|
|
919
|
+
"enhance_prompt, suggest_next_scene, suggest_music_prompt). Default limit is 20/day. When remaining " +
|
|
920
|
+
"hits 0 those helpers return 429 — either unlock_ai_assists (1 credit → +10) or write the content " +
|
|
921
|
+
"yourself with set_scenario / set_narration_script / set_segment_prompt (those are free, no quota).",
|
|
922
|
+
inputSchema: {},
|
|
923
|
+
annotations: { title: "Get AI assists", ...RO },
|
|
924
|
+
},
|
|
925
|
+
tool(async (_args, client) => ok(await client.get("/ai-assists"))),
|
|
926
|
+
);
|
|
927
|
+
|
|
928
|
+
registerTool(
|
|
929
|
+
"unlock_ai_assists",
|
|
930
|
+
{
|
|
931
|
+
title: "Unlock more AI assists",
|
|
932
|
+
description:
|
|
933
|
+
"Spends 1 credit to add 10 AI assists to today's quota (bonus allowance). Call it again to buy another batch — each call adds 10 (a transport retry won't double-charge). " +
|
|
934
|
+
"Returns the fresh quota status. If the account is out of credits the server returns 402 " +
|
|
935
|
+
"credits_insufficient — this tool fails cleanly (it does NOT loop or retry).",
|
|
936
|
+
inputSchema: {},
|
|
937
|
+
annotations: {
|
|
938
|
+
title: "Unlock AI assists",
|
|
939
|
+
...WRITE,
|
|
940
|
+
idempotentHint: false,
|
|
941
|
+
},
|
|
942
|
+
},
|
|
943
|
+
tool(async (_args, client) =>
|
|
944
|
+
ok(
|
|
945
|
+
await client.post(
|
|
946
|
+
"/ai-assists/unlock",
|
|
947
|
+
undefined,
|
|
948
|
+
await unlockKey(client),
|
|
949
|
+
),
|
|
950
|
+
),
|
|
951
|
+
),
|
|
952
|
+
);
|
|
953
|
+
|
|
954
|
+
// ── Granular editor pipeline ─────────────────────────────────────────────────
|
|
955
|
+
//
|
|
956
|
+
// These tools expose the step-by-step editor pipeline so an agent can build an
|
|
957
|
+
// ad with full control (review the scenario, write its own prompts, etc.)
|
|
958
|
+
// instead of one-shot autopilot (create_editor_ad / make_video).
|
|
959
|
+
// create_editor_draft → generate_scenario|set_scenario → get_editor (REVIEW)
|
|
960
|
+
// → set_scene_count → set_segment_prompt → generate_segment|generate_all_segments
|
|
961
|
+
// → set_narration_script|generate_narration → generate_voice + generate_music
|
|
962
|
+
// → render → wait_for_completion(kind:"editor") → download_result.
|
|
963
|
+
|
|
964
|
+
registerTool(
|
|
965
|
+
"create_editor_draft",
|
|
966
|
+
{
|
|
967
|
+
title: "Create an editor draft (no autopilot)",
|
|
968
|
+
description:
|
|
969
|
+
"Creates an editor project from a product prompt and stops (NO autopilot). Costs 0 credits. Returns the " +
|
|
970
|
+
"slug. Use this to drive the pipeline step by step (vs create_editor_ad which runs autopilot end to end). " +
|
|
971
|
+
"Each AI-generated scene renders to a fixed 8 seconds (uploaded clips keep their own length). " +
|
|
972
|
+
"product_prompt is optional content: send 10–5000 chars, or omit it entirely (empty is treated as omit).",
|
|
973
|
+
inputSchema: {
|
|
974
|
+
product_prompt: z
|
|
975
|
+
.string()
|
|
976
|
+
.min(10)
|
|
977
|
+
.max(5000)
|
|
978
|
+
.optional()
|
|
979
|
+
.describe("Brief for the ad — 10–5000 chars, or omit entirely"),
|
|
980
|
+
language: z
|
|
981
|
+
.string()
|
|
982
|
+
.min(2)
|
|
983
|
+
.max(10)
|
|
984
|
+
.optional()
|
|
985
|
+
.describe('Language code, e.g. "en" (default)'),
|
|
986
|
+
theme: z
|
|
987
|
+
.string()
|
|
988
|
+
.optional()
|
|
989
|
+
.describe('Visual theme (default "realistic")'),
|
|
990
|
+
voice_id: z
|
|
991
|
+
.string()
|
|
992
|
+
.regex(/^[A-Za-z0-9_-]+$/)
|
|
993
|
+
.max(64)
|
|
994
|
+
.optional()
|
|
995
|
+
.describe(
|
|
996
|
+
"Preferred narration voice id (see list_voices); ≤64 chars, [A-Za-z0-9_-] only",
|
|
997
|
+
),
|
|
998
|
+
export_aspect_ratio: z
|
|
999
|
+
.enum(["9:16", "16:9", "1:1"])
|
|
1000
|
+
.optional()
|
|
1001
|
+
.describe('Aspect ratio (default "9:16")'),
|
|
1002
|
+
project_intent: z
|
|
1003
|
+
.enum(["social_ad", "creative_story"])
|
|
1004
|
+
.optional()
|
|
1005
|
+
.describe("Project intent (optional)"),
|
|
1006
|
+
},
|
|
1007
|
+
annotations: {
|
|
1008
|
+
title: "Create editor draft",
|
|
1009
|
+
...WRITE,
|
|
1010
|
+
idempotentHint: true,
|
|
1011
|
+
},
|
|
1012
|
+
},
|
|
1013
|
+
tool(
|
|
1014
|
+
async (
|
|
1015
|
+
args: {
|
|
1016
|
+
product_prompt?: string;
|
|
1017
|
+
language?: string;
|
|
1018
|
+
theme?: string;
|
|
1019
|
+
voice_id?: string;
|
|
1020
|
+
export_aspect_ratio?: string;
|
|
1021
|
+
project_intent?: string;
|
|
1022
|
+
},
|
|
1023
|
+
client,
|
|
1024
|
+
) => {
|
|
1025
|
+
const prompt = args.product_prompt?.trim();
|
|
1026
|
+
const body: Record<string, unknown> = {
|
|
1027
|
+
language: args.language ?? "en",
|
|
1028
|
+
theme: args.theme,
|
|
1029
|
+
voice_id: args.voice_id,
|
|
1030
|
+
export_aspect_ratio: args.export_aspect_ratio,
|
|
1031
|
+
project_intent: args.project_intent,
|
|
1032
|
+
};
|
|
1033
|
+
if (prompt) body.product_prompt = prompt;
|
|
1034
|
+
const created = await client.post<{ data: { slug: string } }>(
|
|
1035
|
+
"/editor",
|
|
1036
|
+
body,
|
|
1037
|
+
idemKey("create-editor-draft", prompt ?? "", args.language ?? "en"),
|
|
1038
|
+
);
|
|
1039
|
+
return ok({
|
|
1040
|
+
slug: created.data.slug,
|
|
1041
|
+
kind: "editor",
|
|
1042
|
+
next: "generate_scenario | set_scenario, then get_editor to review",
|
|
1043
|
+
});
|
|
1044
|
+
},
|
|
1045
|
+
),
|
|
1046
|
+
);
|
|
1047
|
+
|
|
1048
|
+
registerTool(
|
|
1049
|
+
"get_editor",
|
|
1050
|
+
{
|
|
1051
|
+
title: "Get full editor state (review step)",
|
|
1052
|
+
description:
|
|
1053
|
+
"Returns the full editor project state: scenario_prompt, segments[] (with prompts + status), " +
|
|
1054
|
+
"narration_script/status, music, latest_render, segments_count, ai_assist_quota. This is the REVIEW " +
|
|
1055
|
+
"step — call it after generating the scenario/segments to inspect and decide what to edit.",
|
|
1056
|
+
inputSchema: { slug: z.string().describe("Editor project slug") },
|
|
1057
|
+
annotations: { title: "Get editor", ...RO },
|
|
1058
|
+
},
|
|
1059
|
+
tool(async (args: { slug: string }, client) => {
|
|
1060
|
+
const res = await client.get<{ data: unknown }>(`/editor/${args.slug}`);
|
|
1061
|
+
return ok(asRecord(res).data);
|
|
1062
|
+
}),
|
|
1063
|
+
);
|
|
1064
|
+
|
|
1065
|
+
registerTool(
|
|
1066
|
+
"set_scenario",
|
|
1067
|
+
{
|
|
1068
|
+
title: "Set the editor scenario",
|
|
1069
|
+
description:
|
|
1070
|
+
"Writes your own scenario prompt (1–50000 chars) instead of generating one. Free — consumes NO AI " +
|
|
1071
|
+
"assist and NO credits. Use this in place of generate_scenario when you want full control over the story.",
|
|
1072
|
+
inputSchema: {
|
|
1073
|
+
slug: z.string().describe("Editor project slug"),
|
|
1074
|
+
scenario_prompt: z
|
|
1075
|
+
.string()
|
|
1076
|
+
.min(1)
|
|
1077
|
+
.max(50000)
|
|
1078
|
+
.describe("The scenario / story prompt (1–50000 chars)"),
|
|
1079
|
+
},
|
|
1080
|
+
annotations: { title: "Set scenario", ...WRITE, idempotentHint: true },
|
|
1081
|
+
},
|
|
1082
|
+
tool(async (args: { slug: string; scenario_prompt: string }, client) => {
|
|
1083
|
+
const res = await client.patch<{ data: unknown }>(
|
|
1084
|
+
`/editor/${args.slug}/scenario`,
|
|
1085
|
+
{ scenario_prompt: args.scenario_prompt },
|
|
1086
|
+
);
|
|
1087
|
+
return ok(asRecord(res).data ?? res);
|
|
1088
|
+
}),
|
|
1089
|
+
);
|
|
1090
|
+
|
|
1091
|
+
registerTool(
|
|
1092
|
+
"generate_scenario",
|
|
1093
|
+
{
|
|
1094
|
+
title: "Generate the editor scenario (AI assist)",
|
|
1095
|
+
description:
|
|
1096
|
+
"Generates a scenario with AI. CONSUMES 1 AI ASSIST (free daily quota; check get_ai_assists). On 429 " +
|
|
1097
|
+
"(quota exhausted) either set auto_unlock:true to spend 1 credit for +10 assists and retry once, or " +
|
|
1098
|
+
"write the scenario yourself with set_scenario. Server default segments_count is 5.",
|
|
1099
|
+
inputSchema: {
|
|
1100
|
+
slug: z.string().describe("Editor project slug"),
|
|
1101
|
+
segments_count: z
|
|
1102
|
+
.number()
|
|
1103
|
+
.int()
|
|
1104
|
+
.min(3)
|
|
1105
|
+
.max(10)
|
|
1106
|
+
.optional()
|
|
1107
|
+
.describe("How many scenes (3–10, server default 5)"),
|
|
1108
|
+
auto_unlock: z
|
|
1109
|
+
.boolean()
|
|
1110
|
+
.optional()
|
|
1111
|
+
.describe(
|
|
1112
|
+
"On 429, spend 1 credit to unlock +10 assists and retry once (default false)",
|
|
1113
|
+
),
|
|
1114
|
+
},
|
|
1115
|
+
annotations: {
|
|
1116
|
+
title: "Generate scenario",
|
|
1117
|
+
...WRITE,
|
|
1118
|
+
idempotentHint: false,
|
|
1119
|
+
},
|
|
1120
|
+
},
|
|
1121
|
+
tool(
|
|
1122
|
+
async (
|
|
1123
|
+
args: {
|
|
1124
|
+
slug: string;
|
|
1125
|
+
segments_count?: number;
|
|
1126
|
+
auto_unlock?: boolean;
|
|
1127
|
+
},
|
|
1128
|
+
client,
|
|
1129
|
+
) => {
|
|
1130
|
+
const body: Record<string, unknown> = {};
|
|
1131
|
+
if (args.segments_count !== undefined)
|
|
1132
|
+
body.segments_count = args.segments_count;
|
|
1133
|
+
const res = await withAssist(client, args.auto_unlock ?? false, () =>
|
|
1134
|
+
client.post<{ data: unknown }>(
|
|
1135
|
+
`/editor/${args.slug}/generate-scenario`,
|
|
1136
|
+
body,
|
|
1137
|
+
),
|
|
1138
|
+
);
|
|
1139
|
+
return ok(asRecord(res).data ?? res);
|
|
1140
|
+
},
|
|
1141
|
+
),
|
|
1142
|
+
);
|
|
1143
|
+
|
|
1144
|
+
registerTool(
|
|
1145
|
+
"set_scene_count",
|
|
1146
|
+
{
|
|
1147
|
+
title: "Set the number of scenes",
|
|
1148
|
+
description:
|
|
1149
|
+
"Adjusts the editor to exactly `count` scenes (1–20) by adding pending segments to grow, or deleting " +
|
|
1150
|
+
"only TRAILING un-generated (pending) segments to shrink. Never deletes completed/processing segments — " +
|
|
1151
|
+
"if a shrink would require that, it stops and reports. Free (no credits, no assist). Each AI-generated " +
|
|
1152
|
+
"scene is a fixed 8 seconds, so `count` scenes ≈ count×8s of AI footage (uploaded clips keep their own length).",
|
|
1153
|
+
inputSchema: {
|
|
1154
|
+
slug: z.string().describe("Editor project slug"),
|
|
1155
|
+
count: z
|
|
1156
|
+
.number()
|
|
1157
|
+
.int()
|
|
1158
|
+
.min(1)
|
|
1159
|
+
.max(20)
|
|
1160
|
+
.describe("Target scene count (1–20)"),
|
|
1161
|
+
},
|
|
1162
|
+
annotations: { title: "Set scene count", ...WRITE, idempotentHint: false },
|
|
1163
|
+
},
|
|
1164
|
+
tool(async (args: { slug: string; count: number }, client) => {
|
|
1165
|
+
const res = await client.get<{ data: unknown }>(`/editor/${args.slug}`);
|
|
1166
|
+
const data = asRecord(asRecord(res).data);
|
|
1167
|
+
const segments = Array.isArray(data.segments)
|
|
1168
|
+
? (data.segments as Record<string, unknown>[])
|
|
1169
|
+
: [];
|
|
1170
|
+
const current = segments.length;
|
|
1171
|
+
const target = args.count;
|
|
1172
|
+
|
|
1173
|
+
if (target > current) {
|
|
1174
|
+
for (let i = current; i < target; i++) {
|
|
1175
|
+
await client.post(
|
|
1176
|
+
`/editor/${args.slug}/segments`,
|
|
1177
|
+
{},
|
|
1178
|
+
idemKey("add-segment", args.slug, String(i)),
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
} else if (target < current) {
|
|
1182
|
+
// Delete only trailing pending segments (never completed/processing).
|
|
1183
|
+
const isPending = (s: Record<string, unknown>) => {
|
|
1184
|
+
const st = (s.status as string) ?? (s.generation_status as string);
|
|
1185
|
+
return (
|
|
1186
|
+
st === undefined ||
|
|
1187
|
+
st === "pending" ||
|
|
1188
|
+
st === "draft" ||
|
|
1189
|
+
st === "failed"
|
|
1190
|
+
);
|
|
1191
|
+
};
|
|
1192
|
+
let removable = 0;
|
|
1193
|
+
for (let i = segments.length - 1; i >= target; i--) {
|
|
1194
|
+
if (!isPending(segments[i])) break;
|
|
1195
|
+
removable++;
|
|
1196
|
+
}
|
|
1197
|
+
if (current - removable > target) {
|
|
1198
|
+
return fail(
|
|
1199
|
+
`Cannot shrink to ${target} scenes: only ${removable} trailing pending segment(s) are safe to ` +
|
|
1200
|
+
`delete (the rest are completed/processing). Currently ${current} scenes.`,
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
for (let i = segments.length - 1; i >= target; i--) {
|
|
1204
|
+
const seg = segments[i];
|
|
1205
|
+
// Mirror the safety pre-check exactly: never delete a segment that
|
|
1206
|
+
// isn't pending, so the two loops can't diverge (e.g. a future edit to
|
|
1207
|
+
// one). The pre-check already guarantees this range is all-pending; this
|
|
1208
|
+
// keeps the invariant local and self-consistent.
|
|
1209
|
+
if (!isPending(seg)) break;
|
|
1210
|
+
const id = seg.id;
|
|
1211
|
+
if (id === undefined) break;
|
|
1212
|
+
await client.del(`/editor/${args.slug}/segments/${String(id)}`);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
const after = await client.get<{ data: unknown }>(`/editor/${args.slug}`);
|
|
1217
|
+
const afterData = asRecord(asRecord(after).data);
|
|
1218
|
+
const finalCount = Array.isArray(afterData.segments)
|
|
1219
|
+
? (afterData.segments as unknown[]).length
|
|
1220
|
+
: undefined;
|
|
1221
|
+
return ok({ slug: args.slug, requested: target, scene_count: finalCount });
|
|
1222
|
+
}),
|
|
1223
|
+
);
|
|
1224
|
+
|
|
1225
|
+
registerTool(
|
|
1226
|
+
"set_segment_prompt",
|
|
1227
|
+
{
|
|
1228
|
+
title: "Set a segment prompt",
|
|
1229
|
+
description:
|
|
1230
|
+
"Writes the prompt for one scene/segment (1–2000 chars). Free — no credits, no assist. Identify the " +
|
|
1231
|
+
"segment by its id (from get_editor). Generate it afterwards with generate_segment.",
|
|
1232
|
+
inputSchema: {
|
|
1233
|
+
slug: z.string().describe("Editor project slug"),
|
|
1234
|
+
segment_id: z
|
|
1235
|
+
.union([z.number(), z.string()])
|
|
1236
|
+
.describe("Segment id (from get_editor)"),
|
|
1237
|
+
prompt: z
|
|
1238
|
+
.string()
|
|
1239
|
+
.min(1)
|
|
1240
|
+
.max(2000)
|
|
1241
|
+
.describe("Scene prompt (1–2000 chars)"),
|
|
1242
|
+
},
|
|
1243
|
+
annotations: {
|
|
1244
|
+
title: "Set segment prompt",
|
|
1245
|
+
...WRITE,
|
|
1246
|
+
idempotentHint: true,
|
|
1247
|
+
},
|
|
1248
|
+
},
|
|
1249
|
+
tool(
|
|
1250
|
+
async (
|
|
1251
|
+
args: { slug: string; segment_id: number | string; prompt: string },
|
|
1252
|
+
client,
|
|
1253
|
+
) => {
|
|
1254
|
+
const res = await client.patch<{ data: unknown }>(
|
|
1255
|
+
`/editor/${args.slug}/segments/${String(args.segment_id)}`,
|
|
1256
|
+
{ prompt: args.prompt },
|
|
1257
|
+
);
|
|
1258
|
+
return ok(asRecord(res).data ?? res);
|
|
1259
|
+
},
|
|
1260
|
+
),
|
|
1261
|
+
);
|
|
1262
|
+
|
|
1263
|
+
registerTool(
|
|
1264
|
+
"generate_segment",
|
|
1265
|
+
{
|
|
1266
|
+
title: "Generate one segment (5 credits)",
|
|
1267
|
+
description:
|
|
1268
|
+
"Renders the video for one scene. Each AI-generated scene is a fixed 8 seconds long. Costs 5 credits. " +
|
|
1269
|
+
"Re-call to retry a failed scene or regenerate after a prompt edit — the server blocks regenerating one that's still processing, so no double-charge. Visual" +
|
|
1270
|
+
"continuity is handled server-side (this scene uses the previous scene's last frame). 402 " +
|
|
1271
|
+
"credits_insufficient fails cleanly. Generate scenes in position order for correct continuity.",
|
|
1272
|
+
inputSchema: {
|
|
1273
|
+
slug: z.string().describe("Editor project slug"),
|
|
1274
|
+
segment_id: z
|
|
1275
|
+
.union([z.number(), z.string()])
|
|
1276
|
+
.describe("Segment id (from get_editor)"),
|
|
1277
|
+
},
|
|
1278
|
+
annotations: { title: "Generate segment", ...WRITE, idempotentHint: false },
|
|
1279
|
+
},
|
|
1280
|
+
tool(async (args: { slug: string; segment_id: number | string }, client) => {
|
|
1281
|
+
const res = await client.post<{ data: unknown }>(
|
|
1282
|
+
`/editor/${args.slug}/segments/${String(args.segment_id)}/generate`,
|
|
1283
|
+
undefined,
|
|
1284
|
+
undefined,
|
|
1285
|
+
);
|
|
1286
|
+
return ok(asRecord(res).data ?? res);
|
|
1287
|
+
}),
|
|
1288
|
+
);
|
|
1289
|
+
|
|
1290
|
+
registerTool(
|
|
1291
|
+
"generate_all_segments",
|
|
1292
|
+
{
|
|
1293
|
+
title: "Generate all pending segments (sequential)",
|
|
1294
|
+
description:
|
|
1295
|
+
"Generates every not-yet-completed scene (pending or previously failed) in position order, waiting for each to finish before starting" +
|
|
1296
|
+
"the next (preserves visual continuity). Each AI-generated scene is a fixed 8 seconds, so N scenes ≈ N×8s of footage. Costs 5 credits per segment generated. Stops and reports what " +
|
|
1297
|
+
"completed if a scene errors on submit (e.g. 402 credits_insufficient) or fails while rendering; if a " +
|
|
1298
|
+
"scene is still rendering past the budget it returns timed_out so you can re-run to continue (completed " +
|
|
1299
|
+
"scenes are skipped). Tip: to generate everything at lowest cost, skip this and just call render — it auto-generates any ungenerated scenes at the batch rate (4 credits/scene for ≥3); use this tool when you want to review or stop per scene. May take several minutes.",
|
|
1300
|
+
inputSchema: { slug: z.string().describe("Editor project slug") },
|
|
1301
|
+
annotations: {
|
|
1302
|
+
title: "Generate all segments",
|
|
1303
|
+
...WRITE,
|
|
1304
|
+
idempotentHint: false,
|
|
1305
|
+
},
|
|
1306
|
+
},
|
|
1307
|
+
tool(async (args: { slug: string }, client, extra) => {
|
|
1308
|
+
const res = await client.get<{ data: unknown }>(`/editor/${args.slug}`);
|
|
1309
|
+
const data = asRecord(asRecord(res).data);
|
|
1310
|
+
const segments = Array.isArray(data.segments)
|
|
1311
|
+
? (data.segments as Record<string, unknown>[])
|
|
1312
|
+
: [];
|
|
1313
|
+
const pending = segments.filter((s) => {
|
|
1314
|
+
const st = (s.status as string) ?? (s.generation_status as string);
|
|
1315
|
+
return (
|
|
1316
|
+
st === undefined ||
|
|
1317
|
+
st === "pending" ||
|
|
1318
|
+
st === "draft" ||
|
|
1319
|
+
st === "failed"
|
|
1320
|
+
);
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
const generated: Array<string | number> = [];
|
|
1324
|
+
let n = 0;
|
|
1325
|
+
for (const seg of pending) {
|
|
1326
|
+
const id = seg.id;
|
|
1327
|
+
if (id === undefined) continue;
|
|
1328
|
+
const sid = id as string | number;
|
|
1329
|
+
await reportProgress(
|
|
1330
|
+
extra,
|
|
1331
|
+
++n,
|
|
1332
|
+
`generating segment ${String(sid)} (${n}/${pending.length})`,
|
|
1333
|
+
pending.length,
|
|
1334
|
+
);
|
|
1335
|
+
try {
|
|
1336
|
+
await client.post(
|
|
1337
|
+
`/editor/${args.slug}/segments/${String(sid)}/generate`,
|
|
1338
|
+
undefined,
|
|
1339
|
+
undefined,
|
|
1340
|
+
);
|
|
1341
|
+
} catch (e) {
|
|
1342
|
+
return ok({
|
|
1343
|
+
slug: args.slug,
|
|
1344
|
+
generated,
|
|
1345
|
+
stopped_at: sid,
|
|
1346
|
+
error: errMessage(e),
|
|
1347
|
+
note: "Stopped on first error — fix the cause (often credits) and re-run; completed scenes are skipped, failed/pending are retried.",
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
// Gate on THIS segment finishing before starting the next, so the next
|
|
1351
|
+
// scene can build on this one's last frame (visual continuity). Poll the
|
|
1352
|
+
// segment itself — the project-level status never goes terminal mid-batch.
|
|
1353
|
+
const seg_result = await pollSegmentToTerminal(
|
|
1354
|
+
client,
|
|
1355
|
+
args.slug,
|
|
1356
|
+
sid,
|
|
1357
|
+
extra,
|
|
1358
|
+
6 * 60 * 1000,
|
|
1359
|
+
15000,
|
|
1360
|
+
);
|
|
1361
|
+
if (seg_result.status === "failed") {
|
|
1362
|
+
return ok({
|
|
1363
|
+
slug: args.slug,
|
|
1364
|
+
generated,
|
|
1365
|
+
stopped_at: sid,
|
|
1366
|
+
error: seg_result.error ?? "segment generation failed",
|
|
1367
|
+
note: "A scene failed — stopped here. Fix its prompt with set_segment_prompt if needed, then re-run generate_all_segments: it retries failed/pending scenes and skips completed ones.",
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
if (seg_result.timed_out) {
|
|
1371
|
+
return ok({
|
|
1372
|
+
slug: args.slug,
|
|
1373
|
+
generated,
|
|
1374
|
+
stopped_at: sid,
|
|
1375
|
+
timed_out: true,
|
|
1376
|
+
note: "A scene is still rendering after the poll budget. Re-run generate_all_segments later to continue (it retries failed/pending, skips completed), or watch it with get_editor.",
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
generated.push(sid);
|
|
1380
|
+
}
|
|
1381
|
+
return ok({
|
|
1382
|
+
slug: args.slug,
|
|
1383
|
+
generated,
|
|
1384
|
+
count: generated.length,
|
|
1385
|
+
note: "All pending segments generated. Next: narration + voice/music, then render.",
|
|
1386
|
+
});
|
|
1387
|
+
}),
|
|
1388
|
+
);
|
|
1389
|
+
|
|
1390
|
+
registerTool(
|
|
1391
|
+
"set_narration_script",
|
|
1392
|
+
{
|
|
1393
|
+
title: "Set the narration script",
|
|
1394
|
+
description:
|
|
1395
|
+
"Writes your own narration script (1–12000 chars) instead of generating one. Free — no credits, no AI " +
|
|
1396
|
+
"assist. Use this in place of generate_narration when you want to control the voiceover text. Changing the narration after generating voice/music marks them stale — regenerate (generate_voice / generate_music) before render.",
|
|
1397
|
+
inputSchema: {
|
|
1398
|
+
slug: z.string().describe("Editor project slug"),
|
|
1399
|
+
script: z
|
|
1400
|
+
.string()
|
|
1401
|
+
.min(1)
|
|
1402
|
+
.max(12000)
|
|
1403
|
+
.describe("Narration / voiceover text (1–12000 chars)"),
|
|
1404
|
+
},
|
|
1405
|
+
annotations: { title: "Set narration", ...WRITE, idempotentHint: true },
|
|
1406
|
+
},
|
|
1407
|
+
tool(async (args: { slug: string; script: string }, client) => {
|
|
1408
|
+
const res = await client.patch<{ data: unknown }>(`/editor/${args.slug}`, {
|
|
1409
|
+
narration_script: args.script,
|
|
1410
|
+
});
|
|
1411
|
+
return ok(asRecord(res).data ?? res);
|
|
1412
|
+
}),
|
|
1413
|
+
);
|
|
1414
|
+
|
|
1415
|
+
registerTool(
|
|
1416
|
+
"generate_narration",
|
|
1417
|
+
{
|
|
1418
|
+
title: "Generate the narration (AI assist)",
|
|
1419
|
+
description:
|
|
1420
|
+
"Generates the narration script with AI from the timeline. CONSUMES 1 AI ASSIST (free daily quota) — " +
|
|
1421
|
+
"it is free of CREDITS but NOT free of quota. On 429 set auto_unlock:true (1 credit → +10 assists, " +
|
|
1422
|
+
"retried once) or write the script yourself with set_narration_script.",
|
|
1423
|
+
inputSchema: {
|
|
1424
|
+
slug: z.string().describe("Editor project slug"),
|
|
1425
|
+
auto_unlock: z
|
|
1426
|
+
.boolean()
|
|
1427
|
+
.optional()
|
|
1428
|
+
.describe(
|
|
1429
|
+
"On 429, spend 1 credit to unlock +10 assists and retry once (default false)",
|
|
1430
|
+
),
|
|
1431
|
+
},
|
|
1432
|
+
annotations: {
|
|
1433
|
+
title: "Generate narration",
|
|
1434
|
+
...WRITE,
|
|
1435
|
+
idempotentHint: false,
|
|
1436
|
+
},
|
|
1437
|
+
},
|
|
1438
|
+
tool(async (args: { slug: string; auto_unlock?: boolean }, client) => {
|
|
1439
|
+
const res = await withAssist(client, args.auto_unlock ?? false, () =>
|
|
1440
|
+
client.post<{ data: unknown }>(`/editor/${args.slug}/generate-narration`),
|
|
1441
|
+
);
|
|
1442
|
+
return ok(asRecord(res).data ?? res);
|
|
1443
|
+
}),
|
|
1444
|
+
);
|
|
1445
|
+
|
|
1446
|
+
registerTool(
|
|
1447
|
+
"generate_voice",
|
|
1448
|
+
{
|
|
1449
|
+
title: "Generate the voiceover (3 credits)",
|
|
1450
|
+
description:
|
|
1451
|
+
"Synthesizes the voiceover from the narration script. Costs 3 credits. Safe to retry; re-call after changing the narration or voice to regenerate." +
|
|
1452
|
+
"voice_id is REQUIRED — pick one from list_voices (≤64 chars, [A-Za-z0-9_-] only). 402 fails cleanly. Editing the scenario/narration/timeline after this marks the voice stale, and render will 422 (editor_voice_stale) until you re-run generate_voice.",
|
|
1453
|
+
inputSchema: {
|
|
1454
|
+
slug: z.string().describe("Editor project slug"),
|
|
1455
|
+
voice_id: z
|
|
1456
|
+
.string()
|
|
1457
|
+
.regex(/^[A-Za-z0-9_-]+$/)
|
|
1458
|
+
.max(64)
|
|
1459
|
+
.describe("Narration voice id from list_voices (required)"),
|
|
1460
|
+
},
|
|
1461
|
+
annotations: { title: "Generate voice", ...WRITE, idempotentHint: true },
|
|
1462
|
+
},
|
|
1463
|
+
tool(async (args: { slug: string; voice_id: string }, client) => {
|
|
1464
|
+
const res = await client.post<{ data: unknown }>(
|
|
1465
|
+
`/editor/${args.slug}/generate-voice`,
|
|
1466
|
+
{ voice_id: args.voice_id },
|
|
1467
|
+
await voiceKey(client, args.slug, args.voice_id),
|
|
1468
|
+
);
|
|
1469
|
+
return ok(asRecord(res).data ?? res);
|
|
1470
|
+
}),
|
|
1471
|
+
);
|
|
1472
|
+
|
|
1473
|
+
registerTool(
|
|
1474
|
+
"generate_music",
|
|
1475
|
+
{
|
|
1476
|
+
title: "Generate the background music (5 credits)",
|
|
1477
|
+
description:
|
|
1478
|
+
"Generates background music. Costs 5 credits. Each call produces a fresh take (music is non-deterministic) — call again for another. `description` is an optional" +
|
|
1479
|
+
"music-direction prompt (≤1200 chars; longer is silently truncated server-side). 402 fails cleanly.",
|
|
1480
|
+
inputSchema: {
|
|
1481
|
+
slug: z.string().describe("Editor project slug"),
|
|
1482
|
+
description: z
|
|
1483
|
+
.string()
|
|
1484
|
+
.max(1200)
|
|
1485
|
+
.optional()
|
|
1486
|
+
.describe("Music direction prompt (≤1200 chars, truncated if longer)"),
|
|
1487
|
+
mood: z.string().optional().describe("Optional mood hint"),
|
|
1488
|
+
genre: z.string().optional().describe("Optional genre hint"),
|
|
1489
|
+
tempo: z.string().optional().describe("Optional tempo hint"),
|
|
1490
|
+
instruments: z.string().optional().describe("Optional instruments hint"),
|
|
1491
|
+
},
|
|
1492
|
+
annotations: { title: "Generate music", ...WRITE, idempotentHint: false },
|
|
1493
|
+
},
|
|
1494
|
+
tool(
|
|
1495
|
+
async (
|
|
1496
|
+
args: {
|
|
1497
|
+
slug: string;
|
|
1498
|
+
description?: string;
|
|
1499
|
+
mood?: string;
|
|
1500
|
+
genre?: string;
|
|
1501
|
+
tempo?: string;
|
|
1502
|
+
instruments?: string;
|
|
1503
|
+
},
|
|
1504
|
+
client,
|
|
1505
|
+
) => {
|
|
1506
|
+
const body: Record<string, unknown> = {};
|
|
1507
|
+
if (args.description !== undefined) body.prompt = args.description;
|
|
1508
|
+
if (args.mood !== undefined) body.mood = args.mood;
|
|
1509
|
+
if (args.genre !== undefined) body.genre = args.genre;
|
|
1510
|
+
if (args.tempo !== undefined) body.tempo = args.tempo;
|
|
1511
|
+
if (args.instruments !== undefined) body.instruments = args.instruments;
|
|
1512
|
+
const res = await client.post<{ data: unknown }>(
|
|
1513
|
+
`/editor/${args.slug}/generate-music`,
|
|
1514
|
+
body,
|
|
1515
|
+
undefined,
|
|
1516
|
+
);
|
|
1517
|
+
return ok(asRecord(res).data ?? res);
|
|
1518
|
+
},
|
|
1519
|
+
),
|
|
1520
|
+
);
|
|
1521
|
+
|
|
1522
|
+
registerTool(
|
|
1523
|
+
"render",
|
|
1524
|
+
{
|
|
1525
|
+
title: "Render the final editor video (0 credits)",
|
|
1526
|
+
description:
|
|
1527
|
+
"Renders the final MP4 from the generated segments + voice + music. Costs 0 credits (any still-ungenerated " +
|
|
1528
|
+
"segments are auto-charged at generation cost first). Re-rendering reflects the current segments/voice/music (it does not replay a prior render). Returns the latest_render; " +
|
|
1529
|
+
'pair with wait_for_completion({ slug, kind: "editor" }) then download_result. ' +
|
|
1530
|
+
"STALENESS: editing the scenario or narration AFTER generating voice/music marks those audio tracks stale, and render then 422s with editor_narration_stale / editor_voice_stale / editor_music_stale — regenerate the named track (generate_narration → generate_voice, and/or generate_music) before rendering.",
|
|
1531
|
+
inputSchema: { slug: z.string().describe("Editor project slug") },
|
|
1532
|
+
annotations: { title: "Render", ...WRITE, idempotentHint: false },
|
|
1533
|
+
},
|
|
1534
|
+
tool(async (args: { slug: string }, client) => {
|
|
1535
|
+
try {
|
|
1536
|
+
const res = await client.post<{ data: unknown }>(
|
|
1537
|
+
`/editor/${args.slug}/render`,
|
|
1538
|
+
undefined,
|
|
1539
|
+
undefined,
|
|
1540
|
+
);
|
|
1541
|
+
return ok(asRecord(res).data ?? res);
|
|
1542
|
+
} catch (e) {
|
|
1543
|
+
// Turn the staleness 422s into an actionable instruction naming the
|
|
1544
|
+
// exact regenerate step, instead of a bare code the agent must decode.
|
|
1545
|
+
// These codes are machine-stable server-side.
|
|
1546
|
+
const he = e as HubfluencerError;
|
|
1547
|
+
const fix: Record<string, string> = {
|
|
1548
|
+
editor_narration_stale:
|
|
1549
|
+
"The narration is stale (timeline changed). Regenerate it (generate_narration or set_narration_script), then generate_voice, then render.",
|
|
1550
|
+
editor_voice_stale:
|
|
1551
|
+
"The voiceover is stale (narration/timeline changed). Call generate_voice again, then render.",
|
|
1552
|
+
editor_music_stale:
|
|
1553
|
+
"The background music is stale (timeline changed). Call generate_music again, then render.",
|
|
1554
|
+
};
|
|
1555
|
+
if (he?.code && fix[he.code]) {
|
|
1556
|
+
return fail(`${errMessage(e)} → ${fix[he.code]}`);
|
|
1557
|
+
}
|
|
1558
|
+
throw e;
|
|
1559
|
+
}
|
|
1560
|
+
}),
|
|
1561
|
+
);
|
|
1562
|
+
|
|
1563
|
+
registerTool(
|
|
1564
|
+
"enhance_prompt",
|
|
1565
|
+
{
|
|
1566
|
+
title: "Enhance a segment prompt (AI assist)",
|
|
1567
|
+
description:
|
|
1568
|
+
"Rewrites/expands one segment's prompt with AI. CONSUMES 1 AI ASSIST (free daily quota). On 429 set " +
|
|
1569
|
+
"auto_unlock:true (1 credit → +10 assists, retried once) or edit the prompt yourself with " +
|
|
1570
|
+
"set_segment_prompt.",
|
|
1571
|
+
inputSchema: {
|
|
1572
|
+
slug: z.string().describe("Editor project slug"),
|
|
1573
|
+
segment_id: z
|
|
1574
|
+
.union([z.number(), z.string()])
|
|
1575
|
+
.describe("Segment id (from get_editor)"),
|
|
1576
|
+
auto_unlock: z
|
|
1577
|
+
.boolean()
|
|
1578
|
+
.optional()
|
|
1579
|
+
.describe(
|
|
1580
|
+
"On 429, spend 1 credit to unlock +10 assists and retry once (default false)",
|
|
1581
|
+
),
|
|
1582
|
+
},
|
|
1583
|
+
annotations: { title: "Enhance prompt", ...WRITE, idempotentHint: false },
|
|
1584
|
+
},
|
|
1585
|
+
tool(
|
|
1586
|
+
async (
|
|
1587
|
+
args: {
|
|
1588
|
+
slug: string;
|
|
1589
|
+
segment_id: number | string;
|
|
1590
|
+
auto_unlock?: boolean;
|
|
1591
|
+
},
|
|
1592
|
+
client,
|
|
1593
|
+
) => {
|
|
1594
|
+
const res = await withAssist(client, args.auto_unlock ?? false, () =>
|
|
1595
|
+
client.post<{ data: unknown }>(
|
|
1596
|
+
`/editor/${args.slug}/segments/${String(args.segment_id)}/enhance-prompt`,
|
|
1597
|
+
),
|
|
1598
|
+
);
|
|
1599
|
+
return ok(asRecord(res).data ?? res);
|
|
1600
|
+
},
|
|
1601
|
+
),
|
|
1602
|
+
);
|
|
1603
|
+
|
|
1604
|
+
registerTool(
|
|
1605
|
+
"suggest_next_scene",
|
|
1606
|
+
{
|
|
1607
|
+
title: "Suggest the next scene (AI assist)",
|
|
1608
|
+
description:
|
|
1609
|
+
"Suggests a prompt for the next scene with AI. CONSUMES 1 AI ASSIST (free daily quota). On 429 set " +
|
|
1610
|
+
"auto_unlock:true (1 credit → +10 assists, retried once) or write the next scene yourself.",
|
|
1611
|
+
inputSchema: {
|
|
1612
|
+
slug: z.string().describe("Editor project slug"),
|
|
1613
|
+
auto_unlock: z
|
|
1614
|
+
.boolean()
|
|
1615
|
+
.optional()
|
|
1616
|
+
.describe(
|
|
1617
|
+
"On 429, spend 1 credit to unlock +10 assists and retry once (default false)",
|
|
1618
|
+
),
|
|
1619
|
+
},
|
|
1620
|
+
annotations: {
|
|
1621
|
+
title: "Suggest next scene",
|
|
1622
|
+
...WRITE,
|
|
1623
|
+
idempotentHint: false,
|
|
1624
|
+
},
|
|
1625
|
+
},
|
|
1626
|
+
tool(async (args: { slug: string; auto_unlock?: boolean }, client) => {
|
|
1627
|
+
const res = await withAssist(client, args.auto_unlock ?? false, () =>
|
|
1628
|
+
client.post<{ data: unknown }>(`/editor/${args.slug}/suggest-next-scene`),
|
|
1629
|
+
);
|
|
1630
|
+
return ok(asRecord(res).data ?? res);
|
|
1631
|
+
}),
|
|
1632
|
+
);
|
|
1633
|
+
|
|
1634
|
+
registerTool(
|
|
1635
|
+
"suggest_music_prompt",
|
|
1636
|
+
{
|
|
1637
|
+
title: "Suggest a music prompt (AI assist)",
|
|
1638
|
+
description:
|
|
1639
|
+
"Suggests a background-music direction prompt with AI. CONSUMES 1 AI ASSIST (free daily quota). On 429 " +
|
|
1640
|
+
"set auto_unlock:true (1 credit → +10 assists, retried once) or write the music direction yourself and " +
|
|
1641
|
+
"pass it to generate_music.",
|
|
1642
|
+
inputSchema: {
|
|
1643
|
+
slug: z.string().describe("Editor project slug"),
|
|
1644
|
+
auto_unlock: z
|
|
1645
|
+
.boolean()
|
|
1646
|
+
.optional()
|
|
1647
|
+
.describe(
|
|
1648
|
+
"On 429, spend 1 credit to unlock +10 assists and retry once (default false)",
|
|
1649
|
+
),
|
|
1650
|
+
},
|
|
1651
|
+
annotations: {
|
|
1652
|
+
title: "Suggest music prompt",
|
|
1653
|
+
...WRITE,
|
|
1654
|
+
idempotentHint: false,
|
|
1655
|
+
},
|
|
1656
|
+
},
|
|
1657
|
+
tool(async (args: { slug: string; auto_unlock?: boolean }, client) => {
|
|
1658
|
+
const res = await withAssist(client, args.auto_unlock ?? false, () =>
|
|
1659
|
+
client.post<{ data: unknown }>(
|
|
1660
|
+
`/editor/${args.slug}/suggest-music-prompt`,
|
|
1661
|
+
),
|
|
1662
|
+
);
|
|
1663
|
+
return ok(asRecord(res).data ?? res);
|
|
1664
|
+
}),
|
|
1665
|
+
);
|
|
1666
|
+
|
|
1667
|
+
// ── Renders: list / retry (R5) ───────────────────────────────────────────────
|
|
1668
|
+
|
|
1669
|
+
registerTool(
|
|
1670
|
+
"list_renders",
|
|
1671
|
+
{
|
|
1672
|
+
title: "List renders for an editor project",
|
|
1673
|
+
description:
|
|
1674
|
+
"Lists every render version for an editor project: {id, version, status, video_url, duration_seconds, " +
|
|
1675
|
+
"error_message, inserted_at}. Use it to find a FAILED render to retry, or to recover a finished video_url " +
|
|
1676
|
+
"(presigned, ~24h). Read-only.",
|
|
1677
|
+
inputSchema: { slug: z.string().describe("Editor project slug") },
|
|
1678
|
+
annotations: { title: "List renders", ...RO },
|
|
1679
|
+
},
|
|
1680
|
+
tool(async (args: { slug: string }, client) => {
|
|
1681
|
+
const res = await client.get<{ data: unknown }>(
|
|
1682
|
+
`/editor/${args.slug}/renders`,
|
|
1683
|
+
);
|
|
1684
|
+
return ok({ slug: args.slug, renders: asRecord(res).data ?? [] });
|
|
1685
|
+
}),
|
|
1686
|
+
);
|
|
1687
|
+
|
|
1688
|
+
registerTool(
|
|
1689
|
+
"retry_render",
|
|
1690
|
+
{
|
|
1691
|
+
title: "Retry a failed render (0 credits)",
|
|
1692
|
+
description:
|
|
1693
|
+
"Re-runs a FAILED render from its exact saved snapshot (segments/voice/music as they were) — recovery without " +
|
|
1694
|
+
"re-driving the pipeline. Get render_id from list_renders. Only failed renders are retryable " +
|
|
1695
|
+
'(editor_render_not_retryable otherwise). Then wait_for_completion({ slug, kind: "editor" }).',
|
|
1696
|
+
inputSchema: {
|
|
1697
|
+
slug: z.string().describe("Editor project slug"),
|
|
1698
|
+
render_id: z
|
|
1699
|
+
.union([z.number(), z.string()])
|
|
1700
|
+
.describe("Render id from list_renders"),
|
|
1701
|
+
},
|
|
1702
|
+
annotations: { title: "Retry render", ...WRITE, idempotentHint: false },
|
|
1703
|
+
},
|
|
1704
|
+
tool(async (args: { slug: string; render_id: number | string }, client) => {
|
|
1705
|
+
const res = await client.post<{ data: unknown }>(
|
|
1706
|
+
`/editor/${args.slug}/renders/${String(args.render_id)}/retry`,
|
|
1707
|
+
);
|
|
1708
|
+
return ok(asRecord(res).data ?? res);
|
|
1709
|
+
}),
|
|
1710
|
+
);
|
|
1711
|
+
|
|
1712
|
+
// ── Uploads & local assets (R1) ───────────────────────────────────────────────
|
|
1713
|
+
//
|
|
1714
|
+
// Bring the agent's OWN media into a project: upload local video clips onto the
|
|
1715
|
+
// timeline, and attach a product image / closing card / brand logo. All local
|
|
1716
|
+
// paths are confined to HUBFLUENCER_INPUT_DIR (or cwd) with an extension
|
|
1717
|
+
// allowlist (see uploads.ts) so a prompt-injected file_path can't exfiltrate
|
|
1718
|
+
// arbitrary files. Uploads are 0 credits.
|
|
1719
|
+
|
|
1720
|
+
/**
|
|
1721
|
+
* Polls ONE editor upload until it finishes async processing ("ready") or
|
|
1722
|
+
* "failed", or the budget runs out. Processing extracts metadata/frames after
|
|
1723
|
+
* confirm, so a just-confirmed upload is "pending"/"processing" — it must reach
|
|
1724
|
+
* "ready" before it can be placed on the timeline.
|
|
1725
|
+
*/
|
|
1726
|
+
async function pollUploadToReady(
|
|
1727
|
+
client: HubfluencerClient,
|
|
1728
|
+
slug: string,
|
|
1729
|
+
uploadId: string | number,
|
|
1730
|
+
extra: ToolExtra,
|
|
1731
|
+
budgetMs: number,
|
|
1732
|
+
intervalMs: number,
|
|
1733
|
+
): Promise<{ status: string; error: string | null; timed_out: boolean }> {
|
|
1734
|
+
const deadline = Date.now() + budgetMs;
|
|
1735
|
+
const uid = String(uploadId);
|
|
1736
|
+
const read = async (): Promise<{ status: string; error: string | null }> => {
|
|
1737
|
+
const res = await client.get<{ data: unknown }>(`/editor/${slug}/uploads`);
|
|
1738
|
+
const data = asRecord(res).data;
|
|
1739
|
+
const list = Array.isArray(data) ? (data as Record<string, unknown>[]) : [];
|
|
1740
|
+
const row = list.find((u) => String(u.id) === uid);
|
|
1741
|
+
return {
|
|
1742
|
+
status: row ? ((row.status as string) ?? "unknown") : "unknown",
|
|
1743
|
+
error: row ? ((row.error_message as string) ?? null) : null,
|
|
1744
|
+
};
|
|
1745
|
+
};
|
|
1746
|
+
let n = 0;
|
|
1747
|
+
let { status, error } = await read();
|
|
1748
|
+
while (
|
|
1749
|
+
status !== "ready" &&
|
|
1750
|
+
status !== "failed" &&
|
|
1751
|
+
Date.now() + intervalMs <= deadline
|
|
1752
|
+
) {
|
|
1753
|
+
await reportProgress(extra, ++n, `upload ${uid}: ${status}`);
|
|
1754
|
+
await sleep(intervalMs);
|
|
1755
|
+
({ status, error } = await read());
|
|
1756
|
+
}
|
|
1757
|
+
return {
|
|
1758
|
+
status,
|
|
1759
|
+
error,
|
|
1760
|
+
timed_out: status !== "ready" && status !== "failed",
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
registerTool(
|
|
1765
|
+
"upload_video",
|
|
1766
|
+
{
|
|
1767
|
+
title: "Upload a local video clip into an editor project",
|
|
1768
|
+
description:
|
|
1769
|
+
"Uploads a local video FILE (your own footage) into an editor project and, by default, adds it to the timeline " +
|
|
1770
|
+
"as a finished scene. Unlike an AI-generated scene (a fixed 8 seconds), an uploaded clip keeps its own native " +
|
|
1771
|
+
"duration. Runs presign → PUT (resumable multipart for large files) → confirm, then polls until the " +
|
|
1772
|
+
"upload is processed (ready). file_path is a LOCAL path confined to HUBFLUENCER_INPUT_DIR (or cwd). Accepted: " +
|
|
1773
|
+
VIDEO_EXTS.map((e) => `.${e}`).join(", ") +
|
|
1774
|
+
" (≤500 MB, ≤5 min). Costs 0 credits. If add_to_timeline is false (default true) the clip is uploaded but not " +
|
|
1775
|
+
"placed — call add_segment_from_upload later.",
|
|
1776
|
+
inputSchema: {
|
|
1777
|
+
slug: z.string().describe("Editor project slug"),
|
|
1778
|
+
file_path: z
|
|
1779
|
+
.string()
|
|
1780
|
+
.describe(
|
|
1781
|
+
"Local video file path (inside HUBFLUENCER_INPUT_DIR or cwd)",
|
|
1782
|
+
),
|
|
1783
|
+
add_to_timeline: z
|
|
1784
|
+
.boolean()
|
|
1785
|
+
.optional()
|
|
1786
|
+
.describe("Append as a new scene once ready (default true)"),
|
|
1787
|
+
fit_mode: z
|
|
1788
|
+
.string()
|
|
1789
|
+
.optional()
|
|
1790
|
+
.describe(
|
|
1791
|
+
'"cover" or "blur" (default blur) — how the clip fills the frame',
|
|
1792
|
+
),
|
|
1793
|
+
max_wait_seconds: z
|
|
1794
|
+
.number()
|
|
1795
|
+
.optional()
|
|
1796
|
+
.describe("Processing wait budget (default 240, capped 10–280)"),
|
|
1797
|
+
},
|
|
1798
|
+
annotations: { title: "Upload video", ...WRITE, idempotentHint: false },
|
|
1799
|
+
},
|
|
1800
|
+
tool(
|
|
1801
|
+
async (
|
|
1802
|
+
args: {
|
|
1803
|
+
slug: string;
|
|
1804
|
+
file_path: string;
|
|
1805
|
+
add_to_timeline?: boolean;
|
|
1806
|
+
fit_mode?: string;
|
|
1807
|
+
max_wait_seconds?: number;
|
|
1808
|
+
},
|
|
1809
|
+
client,
|
|
1810
|
+
extra,
|
|
1811
|
+
) => {
|
|
1812
|
+
const uploaded = await uploadVideoFile(
|
|
1813
|
+
client,
|
|
1814
|
+
args.slug,
|
|
1815
|
+
args.file_path,
|
|
1816
|
+
{
|
|
1817
|
+
fitMode: args.fit_mode,
|
|
1818
|
+
},
|
|
1819
|
+
);
|
|
1820
|
+
await reportProgress(
|
|
1821
|
+
extra,
|
|
1822
|
+
0,
|
|
1823
|
+
`uploaded ${uploaded.upload_id}, processing…`,
|
|
1824
|
+
);
|
|
1825
|
+
const waitSeconds = Math.min(
|
|
1826
|
+
280,
|
|
1827
|
+
Math.max(10, args.max_wait_seconds ?? 240),
|
|
1828
|
+
);
|
|
1829
|
+
const poll = await pollUploadToReady(
|
|
1830
|
+
client,
|
|
1831
|
+
args.slug,
|
|
1832
|
+
uploaded.upload_id,
|
|
1833
|
+
extra,
|
|
1834
|
+
waitSeconds * 1000,
|
|
1835
|
+
5000,
|
|
1836
|
+
);
|
|
1837
|
+
if (poll.status === "failed") {
|
|
1838
|
+
return fail(
|
|
1839
|
+
`Upload ${uploaded.upload_id} failed to process: ${poll.error ?? "unknown error"}.`,
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
if (poll.timed_out) {
|
|
1843
|
+
return ok({
|
|
1844
|
+
upload_id: uploaded.upload_id,
|
|
1845
|
+
status: poll.status,
|
|
1846
|
+
timed_out: true,
|
|
1847
|
+
note: "Still processing. Re-check with get_editor (uploads[]); once status is 'ready' call add_segment_from_upload to place it.",
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
if (args.add_to_timeline === false) {
|
|
1851
|
+
return ok({
|
|
1852
|
+
upload_id: uploaded.upload_id,
|
|
1853
|
+
status: "ready",
|
|
1854
|
+
note: "Uploaded and ready. Call add_segment_from_upload to place it on the timeline.",
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
try {
|
|
1858
|
+
const seg = await client.post<{ data: unknown }>(
|
|
1859
|
+
`/editor/${args.slug}/segments/from-upload`,
|
|
1860
|
+
{ upload_id: uploaded.upload_id },
|
|
1861
|
+
);
|
|
1862
|
+
return ok({
|
|
1863
|
+
upload_id: uploaded.upload_id,
|
|
1864
|
+
status: "ready",
|
|
1865
|
+
segment: asRecord(seg).data ?? null,
|
|
1866
|
+
note: "Uploaded and added to the timeline as a new scene.",
|
|
1867
|
+
});
|
|
1868
|
+
} catch (e) {
|
|
1869
|
+
const he = e as HubfluencerError;
|
|
1870
|
+
if (he?.code === "batch_generation_active") {
|
|
1871
|
+
return ok({
|
|
1872
|
+
upload_id: uploaded.upload_id,
|
|
1873
|
+
status: "ready",
|
|
1874
|
+
note: "Uploaded and ready, but a generation/render is running so it wasn't placed. Retry add_segment_from_upload({ slug, upload_id }) once that finishes.",
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
throw e;
|
|
1878
|
+
}
|
|
1879
|
+
},
|
|
1880
|
+
),
|
|
1881
|
+
);
|
|
1882
|
+
|
|
1883
|
+
registerTool(
|
|
1884
|
+
"add_segment_from_upload",
|
|
1885
|
+
{
|
|
1886
|
+
title: "Add a ready upload to the timeline as a scene",
|
|
1887
|
+
description:
|
|
1888
|
+
"Appends a READY upload (from upload_video / get_editor uploads[]) to the editor timeline as a new finished " +
|
|
1889
|
+
"scene. 0 credits. Fails with editor_upload_not_ready if still processing, batch_generation_active if a " +
|
|
1890
|
+
"generation/render is running, or editor_segment_limit at 20 scenes.",
|
|
1891
|
+
inputSchema: {
|
|
1892
|
+
slug: z.string().describe("Editor project slug"),
|
|
1893
|
+
upload_id: z
|
|
1894
|
+
.union([z.number(), z.string()])
|
|
1895
|
+
.describe("A ready upload id"),
|
|
1896
|
+
},
|
|
1897
|
+
annotations: {
|
|
1898
|
+
title: "Add segment from upload",
|
|
1899
|
+
...WRITE,
|
|
1900
|
+
idempotentHint: false,
|
|
1901
|
+
},
|
|
1902
|
+
},
|
|
1903
|
+
tool(async (args: { slug: string; upload_id: number | string }, client) => {
|
|
1904
|
+
const res = await client.post<{ data: unknown }>(
|
|
1905
|
+
`/editor/${args.slug}/segments/from-upload`,
|
|
1906
|
+
{ upload_id: args.upload_id },
|
|
1907
|
+
);
|
|
1908
|
+
return ok(asRecord(res).data ?? res);
|
|
1909
|
+
}),
|
|
1910
|
+
);
|
|
1911
|
+
|
|
1912
|
+
registerTool(
|
|
1913
|
+
"use_asset_for_segment",
|
|
1914
|
+
{
|
|
1915
|
+
title: "Reuse a clip/asset for a specific scene (0 credits)",
|
|
1916
|
+
description:
|
|
1917
|
+
"Sets a specific scene's video from an existing asset — either a ready upload (upload_id) OR a completed scene " +
|
|
1918
|
+
"in the same project (source_segment_id). Provide EXACTLY ONE. 0 credits. Use it to reuse one uploaded clip " +
|
|
1919
|
+
"across scenes, or to swap a generated scene for your own footage.",
|
|
1920
|
+
inputSchema: {
|
|
1921
|
+
slug: z.string().describe("Editor project slug"),
|
|
1922
|
+
segment_id: z
|
|
1923
|
+
.union([z.number(), z.string()])
|
|
1924
|
+
.describe("Target scene id (from get_editor)"),
|
|
1925
|
+
upload_id: z
|
|
1926
|
+
.union([z.number(), z.string()])
|
|
1927
|
+
.optional()
|
|
1928
|
+
.describe("Source: a ready upload id"),
|
|
1929
|
+
source_segment_id: z
|
|
1930
|
+
.union([z.number(), z.string()])
|
|
1931
|
+
.optional()
|
|
1932
|
+
.describe("Source: a completed scene id"),
|
|
1933
|
+
},
|
|
1934
|
+
annotations: {
|
|
1935
|
+
title: "Use asset for segment",
|
|
1936
|
+
...WRITE,
|
|
1937
|
+
idempotentHint: false,
|
|
1938
|
+
},
|
|
1939
|
+
},
|
|
1940
|
+
tool(
|
|
1941
|
+
async (
|
|
1942
|
+
args: {
|
|
1943
|
+
slug: string;
|
|
1944
|
+
segment_id: number | string;
|
|
1945
|
+
upload_id?: number | string;
|
|
1946
|
+
source_segment_id?: number | string;
|
|
1947
|
+
},
|
|
1948
|
+
client,
|
|
1949
|
+
) => {
|
|
1950
|
+
const hasUpload = args.upload_id !== undefined;
|
|
1951
|
+
const hasSource = args.source_segment_id !== undefined;
|
|
1952
|
+
if (hasUpload === hasSource) {
|
|
1953
|
+
return fail("Provide exactly one of upload_id or source_segment_id.");
|
|
1954
|
+
}
|
|
1955
|
+
const body: Record<string, unknown> = hasUpload
|
|
1956
|
+
? { upload_id: args.upload_id }
|
|
1957
|
+
: { source_segment_id: args.source_segment_id };
|
|
1958
|
+
const res = await client.post<{ data: unknown }>(
|
|
1959
|
+
`/editor/${args.slug}/segments/${String(args.segment_id)}/use-asset`,
|
|
1960
|
+
body,
|
|
1961
|
+
);
|
|
1962
|
+
return ok(asRecord(res).data ?? res);
|
|
1963
|
+
},
|
|
1964
|
+
),
|
|
1965
|
+
);
|
|
1966
|
+
|
|
1967
|
+
registerTool(
|
|
1968
|
+
"set_product",
|
|
1969
|
+
{
|
|
1970
|
+
title: "Attach a product image (from a local file)",
|
|
1971
|
+
description:
|
|
1972
|
+
"Uploads a local product image and attaches it to the project as the product. Accepted: " +
|
|
1973
|
+
IMAGE_EXTS.map((e) => `.${e}`).join(", ") +
|
|
1974
|
+
" (≤20 MB), local path confined to HUBFLUENCER_INPUT_DIR (or cwd). 0 credits. The FIRST product added " +
|
|
1975
|
+
"defaults every pending AI scene to feature it 'throughout' (change with set_product_placement). Optional " +
|
|
1976
|
+
"description (≤500 chars) guides how it's woven into scenes.",
|
|
1977
|
+
inputSchema: {
|
|
1978
|
+
slug: z.string().describe("Editor project slug"),
|
|
1979
|
+
file_path: z
|
|
1980
|
+
.string()
|
|
1981
|
+
.describe("Local product image path (.jpg/.jpeg/.png)"),
|
|
1982
|
+
description: z
|
|
1983
|
+
.string()
|
|
1984
|
+
.max(500)
|
|
1985
|
+
.optional()
|
|
1986
|
+
.describe("Product description (≤500 chars)"),
|
|
1987
|
+
},
|
|
1988
|
+
annotations: { title: "Set product", ...WRITE, idempotentHint: false },
|
|
1989
|
+
},
|
|
1990
|
+
tool(
|
|
1991
|
+
async (
|
|
1992
|
+
args: { slug: string; file_path: string; description?: string },
|
|
1993
|
+
client,
|
|
1994
|
+
) => {
|
|
1995
|
+
const { s3_key } = await uploadImageFile(
|
|
1996
|
+
client,
|
|
1997
|
+
`/editor/${args.slug}/product/presign`,
|
|
1998
|
+
args.file_path,
|
|
1999
|
+
);
|
|
2000
|
+
const res = await client.post<{ data: unknown }>(
|
|
2001
|
+
`/editor/${args.slug}/product/confirm`,
|
|
2002
|
+
{ s3_key, product_description: args.description },
|
|
2003
|
+
);
|
|
2004
|
+
return ok(asRecord(res).data ?? res);
|
|
2005
|
+
},
|
|
2006
|
+
),
|
|
2007
|
+
);
|
|
2008
|
+
|
|
2009
|
+
registerTool(
|
|
2010
|
+
"set_product_placement",
|
|
2011
|
+
{
|
|
2012
|
+
title: "Set default product placement on pending scenes (0 credits)",
|
|
2013
|
+
description:
|
|
2014
|
+
"Sets where the product appears across pending (never-generated) AI scenes. mode is 'throughout' (every scene) " +
|
|
2015
|
+
"or 'end' (closing scene only; requires a first+last-frame capable model). 0 credits. Add a product first with " +
|
|
2016
|
+
"set_product. (There is no 'none' — to remove the product, that's a separate action.)",
|
|
2017
|
+
inputSchema: {
|
|
2018
|
+
slug: z.string().describe("Editor project slug"),
|
|
2019
|
+
mode: z.enum(["throughout", "end"]).describe("'throughout' or 'end'"),
|
|
2020
|
+
},
|
|
2021
|
+
annotations: {
|
|
2022
|
+
title: "Set product placement",
|
|
2023
|
+
...WRITE,
|
|
2024
|
+
idempotentHint: true,
|
|
2025
|
+
},
|
|
2026
|
+
},
|
|
2027
|
+
tool(async (args: { slug: string; mode: "throughout" | "end" }, client) => {
|
|
2028
|
+
const res = await client.post<{ data: unknown }>(
|
|
2029
|
+
`/editor/${args.slug}/product/apply-default-placement`,
|
|
2030
|
+
{ placement: args.mode },
|
|
2031
|
+
);
|
|
2032
|
+
return ok(asRecord(res).data ?? res);
|
|
2033
|
+
}),
|
|
2034
|
+
);
|
|
2035
|
+
|
|
2036
|
+
registerTool(
|
|
2037
|
+
"set_closing_image",
|
|
2038
|
+
{
|
|
2039
|
+
title: "Set the closing-card image (0 credits)",
|
|
2040
|
+
description:
|
|
2041
|
+
"Sets the optional ~2s end-card image. Either upload a local file (file_path; .jpg/.jpeg/.png ≤20 MB, confined " +
|
|
2042
|
+
"to HUBFLUENCER_INPUT_DIR/cwd) OR reuse the project's product image (from_product:true — a 0-credit server-side " +
|
|
2043
|
+
"copy, no upload). If a closing image already exists, pass overwrite:true to replace it (else " +
|
|
2044
|
+
"editor_closing_image_exists).",
|
|
2045
|
+
inputSchema: {
|
|
2046
|
+
slug: z.string().describe("Editor project slug"),
|
|
2047
|
+
file_path: z
|
|
2048
|
+
.string()
|
|
2049
|
+
.optional()
|
|
2050
|
+
.describe("Local image to upload as the end card"),
|
|
2051
|
+
from_product: z
|
|
2052
|
+
.boolean()
|
|
2053
|
+
.optional()
|
|
2054
|
+
.describe(
|
|
2055
|
+
"Reuse the product image (0-credit copy) instead of uploading",
|
|
2056
|
+
),
|
|
2057
|
+
overwrite: z
|
|
2058
|
+
.boolean()
|
|
2059
|
+
.optional()
|
|
2060
|
+
.describe("Replace an existing closing image"),
|
|
2061
|
+
},
|
|
2062
|
+
annotations: {
|
|
2063
|
+
title: "Set closing image",
|
|
2064
|
+
...WRITE,
|
|
2065
|
+
idempotentHint: false,
|
|
2066
|
+
},
|
|
2067
|
+
},
|
|
2068
|
+
tool(
|
|
2069
|
+
async (
|
|
2070
|
+
args: {
|
|
2071
|
+
slug: string;
|
|
2072
|
+
file_path?: string;
|
|
2073
|
+
from_product?: boolean;
|
|
2074
|
+
overwrite?: boolean;
|
|
2075
|
+
},
|
|
2076
|
+
client,
|
|
2077
|
+
) => {
|
|
2078
|
+
if (args.from_product) {
|
|
2079
|
+
const res = await client.post<{ data: unknown }>(
|
|
2080
|
+
`/editor/${args.slug}/closing-image/from-product`,
|
|
2081
|
+
{ overwrite: args.overwrite === true },
|
|
2082
|
+
);
|
|
2083
|
+
return ok(asRecord(res).data ?? res);
|
|
2084
|
+
}
|
|
2085
|
+
if (!args.file_path) {
|
|
2086
|
+
return fail("Provide file_path (a local image) or from_product:true.");
|
|
2087
|
+
}
|
|
2088
|
+
const { s3_key } = await uploadImageFile(
|
|
2089
|
+
client,
|
|
2090
|
+
`/editor/${args.slug}/closing-image/presign`,
|
|
2091
|
+
args.file_path,
|
|
2092
|
+
);
|
|
2093
|
+
const res = await client.post<{ data: unknown }>(
|
|
2094
|
+
`/editor/${args.slug}/closing-image/confirm`,
|
|
2095
|
+
{ s3_key },
|
|
2096
|
+
);
|
|
2097
|
+
return ok(asRecord(res).data ?? res);
|
|
2098
|
+
},
|
|
2099
|
+
),
|
|
2100
|
+
);
|
|
2101
|
+
|
|
2102
|
+
registerTool(
|
|
2103
|
+
"set_logo",
|
|
2104
|
+
{
|
|
2105
|
+
title: "Set a brand logo overlay (0 credits)",
|
|
2106
|
+
description:
|
|
2107
|
+
"Uploads a local brand logo and overlays it on the video. Accepted: .png/.jpg/.jpeg (≤20 MB; PNG keeps " +
|
|
2108
|
+
"transparency), confined to HUBFLUENCER_INPUT_DIR/cwd. 0 credits. Optional placement controls: treatment, " +
|
|
2109
|
+
"position, duration_seconds.",
|
|
2110
|
+
inputSchema: {
|
|
2111
|
+
slug: z.string().describe("Editor project slug"),
|
|
2112
|
+
file_path: z.string().describe("Local logo image (.png/.jpg/.jpeg)"),
|
|
2113
|
+
treatment: z.string().optional().describe("Optional logo treatment"),
|
|
2114
|
+
position: z.string().optional().describe("Optional logo position"),
|
|
2115
|
+
duration_seconds: z
|
|
2116
|
+
.number()
|
|
2117
|
+
.optional()
|
|
2118
|
+
.describe("Optional on-screen duration (seconds)"),
|
|
2119
|
+
},
|
|
2120
|
+
annotations: { title: "Set logo", ...WRITE, idempotentHint: false },
|
|
2121
|
+
},
|
|
2122
|
+
tool(
|
|
2123
|
+
async (
|
|
2124
|
+
args: {
|
|
2125
|
+
slug: string;
|
|
2126
|
+
file_path: string;
|
|
2127
|
+
treatment?: string;
|
|
2128
|
+
position?: string;
|
|
2129
|
+
duration_seconds?: number;
|
|
2130
|
+
},
|
|
2131
|
+
client,
|
|
2132
|
+
) => {
|
|
2133
|
+
const { s3_key } = await uploadImageFile(
|
|
2134
|
+
client,
|
|
2135
|
+
`/editor/${args.slug}/logo/presign`,
|
|
2136
|
+
args.file_path,
|
|
2137
|
+
);
|
|
2138
|
+
const confirmed = await client.post<{ data: unknown }>(
|
|
2139
|
+
`/editor/${args.slug}/logo/confirm`,
|
|
2140
|
+
{ s3_key },
|
|
2141
|
+
);
|
|
2142
|
+
let result = asRecord(confirmed).data ?? confirmed;
|
|
2143
|
+
const settings: Record<string, unknown> = {};
|
|
2144
|
+
if (args.treatment !== undefined)
|
|
2145
|
+
settings.logo_treatment = args.treatment;
|
|
2146
|
+
if (args.position !== undefined) settings.logo_position = args.position;
|
|
2147
|
+
if (args.duration_seconds !== undefined)
|
|
2148
|
+
settings.logo_duration_seconds = args.duration_seconds;
|
|
2149
|
+
if (Object.keys(settings).length > 0) {
|
|
2150
|
+
const updated = await client.patch<{ data: unknown }>(
|
|
2151
|
+
`/editor/${args.slug}/logo`,
|
|
2152
|
+
settings,
|
|
2153
|
+
);
|
|
2154
|
+
result = asRecord(updated).data ?? updated;
|
|
2155
|
+
}
|
|
2156
|
+
return ok(result);
|
|
2157
|
+
},
|
|
2158
|
+
),
|
|
2159
|
+
);
|
|
2160
|
+
|
|
2161
|
+
// ── Status / waiting ──────────────────────────────────────────────────────────
|
|
2162
|
+
|
|
2163
|
+
registerTool(
|
|
2164
|
+
"get_status",
|
|
2165
|
+
{
|
|
2166
|
+
title: "Get generation status",
|
|
2167
|
+
description:
|
|
2168
|
+
"Returns a normalized status {stage, terminal, ready, video_url, error} for a short or editor " +
|
|
2169
|
+
"project. ready:true means the post-ready MP4 is at video_url.",
|
|
2170
|
+
inputSchema: { slug: z.string(), kind: kindSchema },
|
|
2171
|
+
annotations: { title: "Get status", ...RO },
|
|
2172
|
+
},
|
|
2173
|
+
tool(async (args: { slug: string; kind: Kind }, client) => {
|
|
2174
|
+
const status = await fetchStatus(client, args.kind, args.slug);
|
|
2175
|
+
return ok(status);
|
|
2176
|
+
}),
|
|
2177
|
+
);
|
|
2178
|
+
|
|
2179
|
+
registerTool(
|
|
2180
|
+
"wait_for_completion",
|
|
2181
|
+
{
|
|
2182
|
+
title: "Wait for a render to finish",
|
|
2183
|
+
description:
|
|
2184
|
+
"Polls status (emitting progress) until terminal (ready/failed) or the wait budget is exhausted. " +
|
|
2185
|
+
"Generation can take several minutes; if it returns terminal=false, call again to keep waiting. " +
|
|
2186
|
+
"Pass save_path to download the finished MP4 when it's ready (e.g. to resume a make_video that timed out mid-render).",
|
|
2187
|
+
inputSchema: {
|
|
2188
|
+
slug: z.string(),
|
|
2189
|
+
kind: kindSchema,
|
|
2190
|
+
max_wait_seconds: z
|
|
2191
|
+
.number()
|
|
2192
|
+
.int()
|
|
2193
|
+
.min(10)
|
|
2194
|
+
.max(280)
|
|
2195
|
+
.optional()
|
|
2196
|
+
.describe("Default 240, capped at 280"),
|
|
2197
|
+
poll_interval_seconds: z
|
|
2198
|
+
.number()
|
|
2199
|
+
.int()
|
|
2200
|
+
.min(5)
|
|
2201
|
+
.max(60)
|
|
2202
|
+
.optional()
|
|
2203
|
+
.describe("Default 15"),
|
|
2204
|
+
save_path: z
|
|
2205
|
+
.string()
|
|
2206
|
+
.optional()
|
|
2207
|
+
.describe(
|
|
2208
|
+
"Optional .mp4 path to download to when ready (confined to HUBFLUENCER_OUTPUT_DIR or cwd)",
|
|
2209
|
+
),
|
|
2210
|
+
},
|
|
2211
|
+
annotations: { title: "Wait for completion", ...RO },
|
|
2212
|
+
},
|
|
2213
|
+
tool(
|
|
2214
|
+
async (
|
|
2215
|
+
args: {
|
|
2216
|
+
slug: string;
|
|
2217
|
+
kind: Kind;
|
|
2218
|
+
max_wait_seconds?: number;
|
|
2219
|
+
poll_interval_seconds?: number;
|
|
2220
|
+
save_path?: string;
|
|
2221
|
+
},
|
|
2222
|
+
client,
|
|
2223
|
+
extra,
|
|
2224
|
+
) => {
|
|
2225
|
+
// Validate the save_path up front so a bad path fails before the wait.
|
|
2226
|
+
if (args.save_path) resolveSavePath(args.save_path);
|
|
2227
|
+
const budgetMs = (args.max_wait_seconds ?? 240) * 1000;
|
|
2228
|
+
const intervalMs = (args.poll_interval_seconds ?? 15) * 1000;
|
|
2229
|
+
const status = await pollToTerminal(
|
|
2230
|
+
client,
|
|
2231
|
+
args.kind,
|
|
2232
|
+
args.slug,
|
|
2233
|
+
extra,
|
|
2234
|
+
budgetMs,
|
|
2235
|
+
intervalMs,
|
|
2236
|
+
);
|
|
2237
|
+
let saved_to: string | null = null;
|
|
2238
|
+
if (status.ready && status.video_url && args.save_path) {
|
|
2239
|
+
saved_to = (await downloadTo(status.video_url, args.save_path))
|
|
2240
|
+
.saved_to;
|
|
2241
|
+
}
|
|
2242
|
+
const out = { ...status, saved_to, timed_out: !status.terminal };
|
|
2243
|
+
const links =
|
|
2244
|
+
status.ready && status.video_url
|
|
2245
|
+
? [mp4Link(status.video_url, args.slug)]
|
|
2246
|
+
: [];
|
|
2247
|
+
return ok(out, links);
|
|
2248
|
+
},
|
|
2249
|
+
),
|
|
2250
|
+
);
|
|
2251
|
+
|
|
2252
|
+
// ── Download ──────────────────────────────────────────────────────────────────
|
|
2253
|
+
|
|
2254
|
+
registerTool(
|
|
2255
|
+
"download_result",
|
|
2256
|
+
{
|
|
2257
|
+
title: "Get / download the finished video",
|
|
2258
|
+
description:
|
|
2259
|
+
"Returns the post-ready MP4 URL for a completed project (presigned, ~24h TTL — use promptly). " +
|
|
2260
|
+
"If save_path is given (confined to HUBFLUENCER_OUTPUT_DIR or cwd), the file is downloaded there.",
|
|
2261
|
+
inputSchema: {
|
|
2262
|
+
slug: z.string(),
|
|
2263
|
+
kind: kindSchema,
|
|
2264
|
+
save_path: z
|
|
2265
|
+
.string()
|
|
2266
|
+
.optional()
|
|
2267
|
+
.describe("Optional .mp4 path inside the output base"),
|
|
2268
|
+
},
|
|
2269
|
+
annotations: { title: "Download result", ...RO },
|
|
2270
|
+
},
|
|
2271
|
+
tool(
|
|
2272
|
+
async (args: { slug: string; kind: Kind; save_path?: string }, client) => {
|
|
2273
|
+
const status = await fetchStatus(client, args.kind, args.slug);
|
|
2274
|
+
if (!status.ready || !status.video_url) {
|
|
2275
|
+
return fail(
|
|
2276
|
+
`Not ready to download (stage=${status.stage}, ready=${status.ready}). Wait for completion first.`,
|
|
2277
|
+
);
|
|
2278
|
+
}
|
|
2279
|
+
const link = [mp4Link(status.video_url, args.slug)];
|
|
2280
|
+
if (args.save_path) {
|
|
2281
|
+
const { saved_to, bytes } = await downloadTo(
|
|
2282
|
+
status.video_url,
|
|
2283
|
+
args.save_path,
|
|
2284
|
+
);
|
|
2285
|
+
return ok({ video_url: status.video_url, saved_to, bytes }, link);
|
|
2286
|
+
}
|
|
2287
|
+
return ok(
|
|
2288
|
+
{
|
|
2289
|
+
video_url: status.video_url,
|
|
2290
|
+
note: "Presigned ~24h URL — download promptly.",
|
|
2291
|
+
},
|
|
2292
|
+
link,
|
|
2293
|
+
);
|
|
2294
|
+
},
|
|
2295
|
+
),
|
|
2296
|
+
);
|
|
2297
|
+
|
|
2298
|
+
// ── Boot ──────────────────────────────────────────────────────────────────────
|
|
2299
|
+
|
|
2300
|
+
async function main() {
|
|
2301
|
+
const transport = new StdioServerTransport();
|
|
2302
|
+
await server.connect(transport);
|
|
2303
|
+
// stderr is safe; stdout is reserved for the MCP protocol.
|
|
2304
|
+
console.error("hubfluencer-mcp running on stdio");
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
main().catch((e) => {
|
|
2308
|
+
console.error("Fatal:", e);
|
|
2309
|
+
process.exit(1);
|
|
2310
|
+
});
|