@spinabot/brigade 1.11.2 → 1.13.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.
Files changed (79) hide show
  1. package/README.md +56 -0
  2. package/dist/agents/tools/edge-tts.d.ts +44 -0
  3. package/dist/agents/tools/edge-tts.d.ts.map +1 -0
  4. package/dist/agents/tools/edge-tts.js +142 -0
  5. package/dist/agents/tools/edge-tts.js.map +1 -0
  6. package/dist/agents/tools/generate-music-tool.d.ts +61 -0
  7. package/dist/agents/tools/generate-music-tool.d.ts.map +1 -0
  8. package/dist/agents/tools/generate-music-tool.js +286 -0
  9. package/dist/agents/tools/generate-music-tool.js.map +1 -0
  10. package/dist/agents/tools/generate-speech-tool.d.ts +69 -0
  11. package/dist/agents/tools/generate-speech-tool.d.ts.map +1 -0
  12. package/dist/agents/tools/generate-speech-tool.js +331 -0
  13. package/dist/agents/tools/generate-speech-tool.js.map +1 -0
  14. package/dist/agents/tools/generate-video-tool.d.ts +111 -0
  15. package/dist/agents/tools/generate-video-tool.d.ts.map +1 -0
  16. package/dist/agents/tools/generate-video-tool.js +1028 -0
  17. package/dist/agents/tools/generate-video-tool.js.map +1 -0
  18. package/dist/agents/tools/media-command.d.ts +47 -0
  19. package/dist/agents/tools/media-command.d.ts.map +1 -0
  20. package/dist/agents/tools/media-command.js +93 -0
  21. package/dist/agents/tools/media-command.js.map +1 -0
  22. package/dist/agents/tools/registry.d.ts.map +1 -1
  23. package/dist/agents/tools/registry.js +27 -0
  24. package/dist/agents/tools/registry.js.map +1 -1
  25. package/dist/agents/tools/transcribe-audio-tool.d.ts +96 -0
  26. package/dist/agents/tools/transcribe-audio-tool.d.ts.map +1 -0
  27. package/dist/agents/tools/transcribe-audio-tool.js +577 -0
  28. package/dist/agents/tools/transcribe-audio-tool.js.map +1 -0
  29. package/dist/buildstamp.json +1 -1
  30. package/dist/cli/commands/connect.d.ts +6 -0
  31. package/dist/cli/commands/connect.d.ts.map +1 -1
  32. package/dist/cli/commands/connect.js +7 -0
  33. package/dist/cli/commands/connect.js.map +1 -1
  34. package/dist/cli/commands/doctor.d.ts.map +1 -1
  35. package/dist/cli/commands/doctor.js +2 -1
  36. package/dist/cli/commands/doctor.js.map +1 -1
  37. package/dist/cli/commands/expose.d.ts.map +1 -1
  38. package/dist/cli/commands/expose.js +22 -3
  39. package/dist/cli/commands/expose.js.map +1 -1
  40. package/dist/cli/commands/gateway.d.ts +12 -0
  41. package/dist/cli/commands/gateway.d.ts.map +1 -1
  42. package/dist/cli/commands/gateway.js +114 -2
  43. package/dist/cli/commands/gateway.js.map +1 -1
  44. package/dist/cli/commands/status.d.ts.map +1 -1
  45. package/dist/cli/commands/status.js +2 -1
  46. package/dist/cli/commands/status.js.map +1 -1
  47. package/dist/cli/program/build-program.d.ts.map +1 -1
  48. package/dist/cli/program/build-program.js +36 -0
  49. package/dist/cli/program/build-program.js.map +1 -1
  50. package/dist/config/io.d.ts +13 -0
  51. package/dist/config/io.d.ts.map +1 -1
  52. package/dist/config/io.js.map +1 -1
  53. package/dist/core/gateway-auth.d.ts +86 -0
  54. package/dist/core/gateway-auth.d.ts.map +1 -0
  55. package/dist/core/gateway-auth.js +156 -0
  56. package/dist/core/gateway-auth.js.map +1 -0
  57. package/dist/core/gateway-probe.d.ts +5 -0
  58. package/dist/core/gateway-probe.d.ts.map +1 -1
  59. package/dist/core/gateway-probe.js +2 -1
  60. package/dist/core/gateway-probe.js.map +1 -1
  61. package/dist/core/gateway-spawn.d.ts.map +1 -1
  62. package/dist/core/gateway-spawn.js +5 -2
  63. package/dist/core/gateway-spawn.js.map +1 -1
  64. package/dist/core/server.d.ts.map +1 -1
  65. package/dist/core/server.js +21 -1
  66. package/dist/core/server.js.map +1 -1
  67. package/dist/core/tunnel/auth-proxy.d.ts +3 -2
  68. package/dist/core/tunnel/auth-proxy.d.ts.map +1 -1
  69. package/dist/core/tunnel/auth-proxy.js +8 -34
  70. package/dist/core/tunnel/auth-proxy.js.map +1 -1
  71. package/dist/core/tunnel/manager.d.ts +4 -2
  72. package/dist/core/tunnel/manager.d.ts.map +1 -1
  73. package/dist/core/tunnel/manager.js +3 -2
  74. package/dist/core/tunnel/manager.js.map +1 -1
  75. package/dist/tui/client.d.ts +8 -0
  76. package/dist/tui/client.d.ts.map +1 -1
  77. package/dist/tui/client.js +5 -1
  78. package/dist/tui/client.js.map +1 -1
  79. package/package.json +1 -1
@@ -0,0 +1,1028 @@
1
+ /**
2
+ * `generate_video` tool — text-to-video / image-to-video generation, modeled
3
+ * on the proven self-contained `generate_speech` / `generate_image` pattern.
4
+ *
5
+ * Why this tool exists
6
+ * --------------------
7
+ * Same reasoning as `generate_image` / `generate_speech`: without a first-class
8
+ * tool, "make me a video" / "animate this image" sends the model to raw `curl`
9
+ * against a video API — the key flows through a shell, the async submit→poll→
10
+ * download dance is hand-rolled (and dropped half-way), and a BILLED render is
11
+ * lost. This tool owns the call in-process: stored auth, validated params, the
12
+ * provider-specific async flow (submit a job → poll its status → download the
13
+ * finished mp4), and a saved file the model hands to `send_media`.
14
+ *
15
+ * Most video providers are ASYNC: a POST submits a job and returns an id; a
16
+ * status GET is polled on an interval until the job is done/failed; the final
17
+ * video URL is then downloaded (GET) and the bytes saved. We poll inline (the
18
+ * tool's `execute` is async) via `pollUntil(...)`, treating any unknown /
19
+ * transient status as "keep polling".
20
+ *
21
+ * Providers (auto-selected by which key is configured, preference order):
22
+ * • openrouter — POST /api/v1/videos (Veo by default); may return the video
23
+ * inline (url / data-url) OR an {id} to poll. NEWER API —
24
+ * implemented flexibly (deep-searches the body for the first
25
+ * mp4 / data:video URL). [per-spec; treat as unverified]
26
+ * • fal — queue.fal.run/{model} submit → status_url poll → response_url
27
+ * • openai — Sora: POST /v1/videos → poll → /v1/videos/{id}/content
28
+ * • xai — grok-imagine-video: POST /v1/videos/generations → poll
29
+ * • minimax — Hailuo: POST /v1/video_generation → query task → file fetch
30
+ * • runway — gen4_turbo: POST /v1/text_to_video (or image_to_video) → poll
31
+ * Keys resolve through `resolveMediaProviderKey` (the same credential-store +
32
+ * env path the media-understanding subsystem uses), so video works for whichever
33
+ * provider the operator already configured — no bespoke auth.
34
+ *
35
+ * Flow: generate → bytes saved under `<cache>/video/` → result text carries a
36
+ * `MEDIA:<saved-path>` line → the model delivers with `send_media({path})`.
37
+ */
38
+ import fs from "node:fs";
39
+ import path from "node:path";
40
+ import { Type } from "typebox";
41
+ import { resolveCacheDir, DEFAULT_AGENT_ID } from "../../config/paths.js";
42
+ import { loadConfig } from "../../core/config.js";
43
+ import { resolveMediaProviderKey } from "../media-understanding/config.js";
44
+ import { jsonResult } from "./common.js";
45
+ /** Each individual HTTP call (submit / poll tick / download) is bounded. */
46
+ const REQUEST_TIMEOUT_MS = 120_000;
47
+ /** Hard cap on prompt length — providers reject very long prompts; fail clearly. */
48
+ const MAX_PROMPT = 4_000;
49
+ /**
50
+ * Absolute ceiling on total time spent polling a single job, regardless of the
51
+ * per-provider attempt counts below. Renders can legitimately take minutes;
52
+ * this is the "give up and tell the operator" backstop so a wedged job can't
53
+ * hang the agent loop forever.
54
+ */
55
+ const POLL_TIMEOUT_MS = 12 * 60_000;
56
+ /** Preference order when no provider is pinned: first keyed one wins. */
57
+ const PROVIDER_PREFERENCE = [
58
+ "openrouter",
59
+ "fal",
60
+ "openai",
61
+ "xai",
62
+ "minimax",
63
+ "runway",
64
+ "google",
65
+ "byteplus",
66
+ "alibaba",
67
+ "together",
68
+ ];
69
+ /** Default model per provider (overridable via the `model` param / config). */
70
+ const DEFAULT_MODELS = {
71
+ openrouter: "kwaivgi/kling-v3.0-std",
72
+ fal: "fal-ai/wan/v2.2-a14b/text-to-video",
73
+ openai: "sora-2",
74
+ xai: "grok-imagine-video",
75
+ minimax: "MiniMax-Hailuo-2.3",
76
+ runway: "gen4_turbo",
77
+ google: "veo-3.0-fast-generate-001",
78
+ byteplus: "seedance-1-0-pro-250528",
79
+ alibaba: "wan2.6-t2v",
80
+ together: "Wan-AI/Wan2.2-T2V-A14B",
81
+ };
82
+ const GenerateVideoParams = Type.Object({
83
+ action: Type.Optional(Type.Union([Type.Literal("generate"), Type.Literal("list")], {
84
+ description: 'Optional: "generate" (default) or "list" to see which video providers are configured.',
85
+ })),
86
+ prompt: Type.Optional(Type.String({ description: "The text prompt describing the video to generate." })),
87
+ image: Type.Optional(Type.String({
88
+ description: "Optional source image for image-to-video: a LOCAL file path or an http(s) URL. Its bytes are read and sent as the first/seed frame to providers that accept image input.",
89
+ })),
90
+ provider: Type.Optional(Type.Union([
91
+ Type.Literal("openrouter"),
92
+ Type.Literal("fal"),
93
+ Type.Literal("openai"),
94
+ Type.Literal("xai"),
95
+ Type.Literal("minimax"),
96
+ Type.Literal("runway"),
97
+ Type.Literal("google"),
98
+ Type.Literal("byteplus"),
99
+ Type.Literal("alibaba"),
100
+ Type.Literal("together"),
101
+ ], { description: "Optional provider override. Default: the first one with a configured key." })),
102
+ model: Type.Optional(Type.String({ description: "Optional model override for the chosen provider." })),
103
+ durationSeconds: Type.Optional(Type.Integer({ description: "Optional clip length in seconds (provider-dependent; commonly 5-10)." })),
104
+ aspectRatio: Type.Optional(Type.String({ description: 'Optional aspect ratio, e.g. "16:9", "9:16", "1:1".' })),
105
+ resolution: Type.Optional(Type.String({ description: 'Optional resolution, e.g. "720p", "1080p".' })),
106
+ filename: Type.Optional(Type.String({ description: "Optional output filename hint (basename preserved, saved under the managed video dir)." })),
107
+ });
108
+ export function makeGenerateVideoTool(opts = {}) {
109
+ const agentId = opts.agentId ?? DEFAULT_AGENT_ID;
110
+ const fetchFn = opts.fetchFn ?? fetch;
111
+ const resolveKey = opts.resolveKey ?? ((p) => resolveMediaProviderKey(p, agentId));
112
+ // When a tiny test interval is supplied, honour it for EVERY provider so the
113
+ // submit→poll→download flow runs without real waits.
114
+ const intervalOverride = opts.pollIntervalMs;
115
+ return {
116
+ name: "generate_video",
117
+ label: "Generate Video",
118
+ displaySummary: "generating video",
119
+ // Billed per call (cloud video generation) — owner-gated like generate_image.
120
+ ownerOnly: true,
121
+ description: [
122
+ "Turn a text prompt (optionally + a source image) into a generated video. USE THIS — never call a video API with bash/curl: the key must not flow through a shell, and the async submit→poll→download flow + binary mp4 are handled here.",
123
+ 'action="generate" (default): requires `prompt`. Optionally pass `image` (a local path or http(s) URL) for image-to-video. Saves an mp4 and returns its REAL path as a `MEDIA:<path>` line — reference that path exactly; never invent one.',
124
+ "Auto-selects the first configured provider (OpenRouter → fal → OpenAI → xAI → MiniMax → Runway); override with `provider`/`model`. Generation is async and can take a few minutes; the tool polls until the render finishes.",
125
+ "To play it for the operator on a chat surface, follow up with `send_media({path})` — generation does NOT auto-send.",
126
+ 'action="list": show which video providers have a configured key.',
127
+ ].join(" "),
128
+ parameters: GenerateVideoParams,
129
+ execute: async (_id, args, signal) => {
130
+ const action = args.action ?? "generate";
131
+ if (action === "list") {
132
+ const providers = PROVIDER_PREFERENCE.filter((p) => resolveKey(p).length > 0);
133
+ return jsonResult({
134
+ action,
135
+ providers,
136
+ ok: true,
137
+ message: providers.length > 0
138
+ ? `${providers.length} video provider(s) configured: ${providers.join(", ")}.`
139
+ : "No video provider configured. Add an OpenRouter, fal, OpenAI, xAI, MiniMax, or Runway key with `brigade onboard`.",
140
+ });
141
+ }
142
+ const prompt = (args.prompt ?? "").trim();
143
+ if (!prompt) {
144
+ return fail(action, "`prompt` is required for action=generate.");
145
+ }
146
+ if (prompt.length > MAX_PROMPT) {
147
+ return fail(action, `\`prompt\` is too long (${prompt.length} chars; max ${MAX_PROMPT}). Shorten it.`);
148
+ }
149
+ // Resolve the provider: explicit override (must be keyed) else first keyed.
150
+ let provider;
151
+ if (args.provider) {
152
+ if (resolveKey(args.provider).length === 0) {
153
+ return fail(action, `Provider "${args.provider}" has no configured key. Add one with \`brigade onboard\`, or omit \`provider\` to auto-select.`);
154
+ }
155
+ provider = args.provider;
156
+ }
157
+ else {
158
+ provider = PROVIDER_PREFERENCE.find((p) => resolveKey(p).length > 0);
159
+ }
160
+ if (!provider) {
161
+ return fail(action, "No video provider is configured. Add an OpenRouter, fal, OpenAI, xAI, MiniMax, or Runway API key with `brigade onboard` (then this tool auto-selects it).");
162
+ }
163
+ const apiKey = resolveKey(provider);
164
+ const model = args.model?.trim() || resolveConfiguredModel(provider) || DEFAULT_MODELS[provider];
165
+ // Resolve the optional source image into a data URL (path → read bytes;
166
+ // http(s) → fetch bytes). Failure here is a clear user-facing error.
167
+ let imageDataUrl;
168
+ if (args.image && args.image.trim()) {
169
+ try {
170
+ imageDataUrl = await loadImageAsDataUrl(args.image.trim(), fetchFn, signal);
171
+ }
172
+ catch (err) {
173
+ return fail(action, `Could not read \`image\`: ${err instanceof Error ? err.message : String(err)}`, {
174
+ provider,
175
+ model,
176
+ });
177
+ }
178
+ }
179
+ let bytes;
180
+ try {
181
+ bytes = await generate({
182
+ provider,
183
+ fetchFn,
184
+ apiKey,
185
+ model,
186
+ prompt,
187
+ imageDataUrl,
188
+ durationSeconds: args.durationSeconds,
189
+ aspectRatio: args.aspectRatio?.trim() || undefined,
190
+ resolution: args.resolution?.trim() || undefined,
191
+ signal,
192
+ intervalOverride,
193
+ });
194
+ }
195
+ catch (err) {
196
+ return fail(action, `Video via ${provider} failed: ${err instanceof Error ? err.message : String(err)}`, {
197
+ provider,
198
+ model,
199
+ });
200
+ }
201
+ const outDir = opts.outDirOverride ?? path.join(resolveCacheDir(), "video");
202
+ fs.mkdirSync(outDir, { recursive: true });
203
+ const outPath = path.join(outDir, buildFileName(args.filename));
204
+ fs.writeFileSync(outPath, bytes);
205
+ return {
206
+ content: [
207
+ {
208
+ type: "text",
209
+ text: [
210
+ `Generated video with ${provider}/${model}${imageDataUrl ? " (image-to-video)" : ""}.`,
211
+ `MEDIA:${outPath}`,
212
+ "Deliver with send_media({path}) — generation does not auto-send.",
213
+ ].join("\n"),
214
+ },
215
+ ],
216
+ details: { action, provider, model, path: outPath, ok: true },
217
+ };
218
+ },
219
+ };
220
+ }
221
+ async function generate(p) {
222
+ switch (p.provider) {
223
+ case "openrouter":
224
+ return generateOpenRouter(p);
225
+ case "fal":
226
+ return generateFal(p);
227
+ case "openai":
228
+ return generateOpenAI(p);
229
+ case "xai":
230
+ return generateXai(p);
231
+ case "minimax":
232
+ return generateMiniMax(p);
233
+ case "runway":
234
+ return generateRunway(p);
235
+ case "google":
236
+ return generateGoogle(p);
237
+ case "byteplus":
238
+ return generateBytePlus(p);
239
+ case "alibaba":
240
+ return generateAlibaba(p);
241
+ case "together":
242
+ return generateTogether(p);
243
+ }
244
+ }
245
+ /**
246
+ * OpenRouter video. NEWER API — implemented flexibly per spec. Submit returns
247
+ * (verified shape, per memory reference-openrouter-video-generation): the submit
248
+ * is ASYNC — `POST /api/v1/videos` returns `{ id, polling_url, status:"pending" }`;
249
+ * poll `GET /api/v1/videos/{id}` until `status:"completed"`; then download the
250
+ * rendered bytes from `GET /api/v1/videos/{id}/content?index=0`. Image-to-video
251
+ * passes a typed `frame_images:[{type:"image_url", image_url:{url}, frame_type}]`
252
+ * (inline base64 data URLs are accepted — no public host upload needed).
253
+ */
254
+ async function generateOpenRouter(p) {
255
+ const body = { model: p.model, prompt: p.prompt };
256
+ if (p.imageDataUrl) {
257
+ body.frame_images = [{ type: "image_url", image_url: { url: p.imageDataUrl }, frame_type: "first_frame" }];
258
+ }
259
+ const res = await p.fetchFn("https://openrouter.ai/api/v1/videos", {
260
+ method: "POST",
261
+ headers: { Authorization: `Bearer ${p.apiKey}`, "Content-Type": "application/json" },
262
+ body: JSON.stringify(body),
263
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
264
+ });
265
+ if (!res.ok)
266
+ throw new Error(`HTTP ${res.status} ${(await safeText(res)).slice(0, 200)}`);
267
+ const submitBody = (await safeJson(res));
268
+ const id = readString(submitBody, "id") ?? readString(submitBody, "request_id");
269
+ if (!id)
270
+ throw new Error("OpenRouter video submit returned no job id.");
271
+ // Poll until completed (failed/canceled throw; any other status keeps polling).
272
+ await pollUntil(async () => {
273
+ const r = await p.fetchFn(`https://openrouter.ai/api/v1/videos/${encodeURIComponent(id)}`, {
274
+ headers: { Authorization: `Bearer ${p.apiKey}` },
275
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
276
+ });
277
+ if (!r.ok)
278
+ throw new Error(`HTTP ${r.status} ${(await safeText(r)).slice(0, 200)}`);
279
+ const pollBody = (await safeJson(r));
280
+ const status = (readString(pollBody, "status") ?? "").toLowerCase();
281
+ if (status === "failed" || status === "error" || status === "canceled" || status === "cancelled") {
282
+ throw new Error(`OpenRouter reported status "${status}".`);
283
+ }
284
+ return status === "completed" ? "done" : POLL_AGAIN;
285
+ }, { intervalMs: scaleInterval(5_000, p.intervalOverride), maxAttempts: 120 });
286
+ // Download the rendered bytes from the documented content endpoint.
287
+ const dl = await p.fetchFn(`https://openrouter.ai/api/v1/videos/${encodeURIComponent(id)}/content?index=0`, {
288
+ headers: { Authorization: `Bearer ${p.apiKey}` },
289
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
290
+ });
291
+ if (!dl.ok)
292
+ throw new Error(`OpenRouter content download failed: HTTP ${dl.status} ${(await safeText(dl)).slice(0, 120)}`);
293
+ return Buffer.from(await dl.arrayBuffer());
294
+ }
295
+ /**
296
+ * fal queue. Submit to `queue.fal.run/{model}` (header `Authorization: Key …`,
297
+ * NOT Bearer); poll the returned `status_url`; on COMPLETED fetch `response_url`
298
+ * for the video url; download it.
299
+ */
300
+ async function generateFal(p) {
301
+ const body = { prompt: p.prompt };
302
+ if (p.imageDataUrl)
303
+ body.image_url = p.imageDataUrl;
304
+ if (p.aspectRatio)
305
+ body.aspect_ratio = p.aspectRatio;
306
+ if (p.durationSeconds !== undefined)
307
+ body.duration = String(p.durationSeconds); // fal wants a STRING
308
+ if (p.resolution)
309
+ body.resolution = p.resolution;
310
+ const res = await p.fetchFn(`https://queue.fal.run/${p.model}`, {
311
+ method: "POST",
312
+ headers: { Authorization: `Key ${p.apiKey}`, "Content-Type": "application/json" },
313
+ body: JSON.stringify(body),
314
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
315
+ });
316
+ if (!res.ok)
317
+ throw new Error(`HTTP ${res.status} ${(await safeText(res)).slice(0, 200)}`);
318
+ const submit = (await safeJson(res));
319
+ const statusUrl = submit.status_url;
320
+ const responseUrl = submit.response_url;
321
+ if (!statusUrl || !responseUrl)
322
+ throw new Error("fal submit returned no status_url/response_url.");
323
+ await pollUntil(async () => {
324
+ const r = await p.fetchFn(statusUrl, {
325
+ headers: { Authorization: `Key ${p.apiKey}` },
326
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
327
+ });
328
+ if (!r.ok)
329
+ throw new Error(`HTTP ${r.status} ${(await safeText(r)).slice(0, 200)}`);
330
+ const status = (readString((await safeJson(r)), "status") ?? "").toUpperCase();
331
+ if (status === "FAILED" || status === "ERROR")
332
+ throw new Error(`fal reported status "${status}".`);
333
+ return status === "COMPLETED" ? true : POLL_AGAIN;
334
+ }, { intervalMs: scaleInterval(5_000, p.intervalOverride), maxAttempts: 120 });
335
+ const finalRes = await p.fetchFn(responseUrl, {
336
+ headers: { Authorization: `Key ${p.apiKey}` },
337
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
338
+ });
339
+ if (!finalRes.ok)
340
+ throw new Error(`HTTP ${finalRes.status} ${(await safeText(finalRes)).slice(0, 200)}`);
341
+ const out = (await safeJson(finalRes));
342
+ const url = out.video?.url ?? out.videos?.[0]?.url;
343
+ if (!url)
344
+ throw new Error("fal response carried no video url.");
345
+ return downloadVideo(url, p.fetchFn, p.apiKey, p.signal);
346
+ }
347
+ /**
348
+ * OpenAI Sora. Submit `POST /v1/videos` → `{id,status}`; poll
349
+ * `GET /v1/videos/{id}` until `completed`; download bytes from
350
+ * `GET /v1/videos/{id}/content?variant=video`.
351
+ */
352
+ async function generateOpenAI(p) {
353
+ const body = { prompt: p.prompt, model: p.model };
354
+ if (p.durationSeconds !== undefined)
355
+ body.seconds = String(p.durationSeconds);
356
+ if (p.resolution)
357
+ body.size = p.resolution;
358
+ if (p.imageDataUrl)
359
+ body.input_reference = { image_url: p.imageDataUrl };
360
+ const res = await p.fetchFn("https://api.openai.com/v1/videos", {
361
+ method: "POST",
362
+ headers: { Authorization: `Bearer ${p.apiKey}`, "Content-Type": "application/json" },
363
+ body: JSON.stringify(body),
364
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
365
+ });
366
+ if (!res.ok)
367
+ throw new Error(`HTTP ${res.status} ${(await safeText(res)).slice(0, 200)}`);
368
+ const submit = (await safeJson(res));
369
+ const id = submit.id;
370
+ if (!id)
371
+ throw new Error("OpenAI video submit returned no id.");
372
+ await pollUntil(async () => {
373
+ const r = await p.fetchFn(`https://api.openai.com/v1/videos/${encodeURIComponent(id)}`, {
374
+ headers: { Authorization: `Bearer ${p.apiKey}` },
375
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
376
+ });
377
+ if (!r.ok)
378
+ throw new Error(`HTTP ${r.status} ${(await safeText(r)).slice(0, 200)}`);
379
+ const status = (readString((await safeJson(r)), "status") ?? "").toLowerCase();
380
+ if (status === "failed" || status === "error")
381
+ throw new Error(`OpenAI reported status "${status}".`);
382
+ return status === "completed" ? true : POLL_AGAIN;
383
+ }, { intervalMs: scaleInterval(2_500, p.intervalOverride), maxAttempts: 120 });
384
+ const contentRes = await p.fetchFn(`https://api.openai.com/v1/videos/${encodeURIComponent(id)}/content?variant=video`, {
385
+ headers: { Authorization: `Bearer ${p.apiKey}`, Accept: "application/binary" },
386
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
387
+ });
388
+ if (!contentRes.ok)
389
+ throw new Error(`HTTP ${contentRes.status} ${(await safeText(contentRes)).slice(0, 200)}`);
390
+ return Buffer.from(await contentRes.arrayBuffer());
391
+ }
392
+ /**
393
+ * xAI grok-imagine-video. Submit `POST /v1/videos/generations` → `{request_id}`;
394
+ * poll `GET /v1/videos/{request_id}` until `done`; download `video.url`.
395
+ */
396
+ async function generateXai(p) {
397
+ const body = { model: p.model, prompt: p.prompt };
398
+ if (p.imageDataUrl)
399
+ body.image = { url: p.imageDataUrl };
400
+ if (p.durationSeconds !== undefined)
401
+ body.duration = p.durationSeconds;
402
+ if (p.aspectRatio)
403
+ body.aspect_ratio = p.aspectRatio;
404
+ if (p.resolution)
405
+ body.resolution = p.resolution;
406
+ const res = await p.fetchFn("https://api.x.ai/v1/videos/generations", {
407
+ method: "POST",
408
+ headers: { Authorization: `Bearer ${p.apiKey}`, "Content-Type": "application/json" },
409
+ body: JSON.stringify(body),
410
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
411
+ });
412
+ if (!res.ok)
413
+ throw new Error(`HTTP ${res.status} ${(await safeText(res)).slice(0, 200)}`);
414
+ const submit = (await safeJson(res));
415
+ const id = submit.request_id;
416
+ if (!id)
417
+ throw new Error("xAI video submit returned no request_id.");
418
+ const url = await pollUntil(async () => {
419
+ const r = await p.fetchFn(`https://api.x.ai/v1/videos/${encodeURIComponent(id)}`, {
420
+ headers: { Authorization: `Bearer ${p.apiKey}` },
421
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
422
+ });
423
+ if (!r.ok)
424
+ throw new Error(`HTTP ${r.status} ${(await safeText(r)).slice(0, 200)}`);
425
+ const pollBody = (await safeJson(r));
426
+ const status = (readString(pollBody, "status") ?? "").toLowerCase();
427
+ if (status === "failed" || status === "expired" || status === "error") {
428
+ throw new Error(`xAI reported status "${status}".`);
429
+ }
430
+ if (status === "done") {
431
+ const videoUrl = pollBody.video?.url ?? findVideoUrl(pollBody);
432
+ if (!videoUrl)
433
+ throw new Error("xAI reported done but carried no video url.");
434
+ return videoUrl;
435
+ }
436
+ return POLL_AGAIN;
437
+ }, { intervalMs: scaleInterval(5_000, p.intervalOverride), maxAttempts: 120 });
438
+ return downloadVideo(url, p.fetchFn, p.apiKey, p.signal);
439
+ }
440
+ /**
441
+ * MiniMax Hailuo. Submit `POST /v1/video_generation` → `{task_id}`; poll
442
+ * `GET /v1/query/video_generation?task_id=` until `Success`; on success take
443
+ * `video_url` if present, else resolve `file_id` → `/v1/files/retrieve` →
444
+ * `file.download_url`; download.
445
+ */
446
+ async function generateMiniMax(p) {
447
+ const body = { model: p.model, prompt: p.prompt };
448
+ if (p.imageDataUrl)
449
+ body.first_frame_image = p.imageDataUrl;
450
+ const res = await p.fetchFn("https://api.minimax.io/v1/video_generation", {
451
+ method: "POST",
452
+ headers: { Authorization: `Bearer ${p.apiKey}`, "Content-Type": "application/json" },
453
+ body: JSON.stringify(body),
454
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
455
+ });
456
+ if (!res.ok)
457
+ throw new Error(`HTTP ${res.status} ${(await safeText(res)).slice(0, 200)}`);
458
+ const submit = (await safeJson(res));
459
+ const taskId = submit.task_id;
460
+ if (!taskId)
461
+ throw new Error("MiniMax video submit returned no task_id.");
462
+ const result = await pollUntil(async () => {
463
+ const r = await p.fetchFn(`https://api.minimax.io/v1/query/video_generation?task_id=${encodeURIComponent(taskId)}`, { headers: { Authorization: `Bearer ${p.apiKey}` }, signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS) });
464
+ if (!r.ok)
465
+ throw new Error(`HTTP ${r.status} ${(await safeText(r)).slice(0, 200)}`);
466
+ const pollBody = (await safeJson(r));
467
+ const status = (pollBody.status ?? "").toLowerCase();
468
+ if (status === "fail" || status === "failed" || status === "error") {
469
+ throw new Error(`MiniMax reported status "${pollBody.status}".`);
470
+ }
471
+ if (status === "success") {
472
+ return { videoUrl: pollBody.video_url, fileId: pollBody.file_id };
473
+ }
474
+ return POLL_AGAIN;
475
+ }, { intervalMs: scaleInterval(10_000, p.intervalOverride), maxAttempts: 90 });
476
+ if (result.videoUrl)
477
+ return downloadVideo(result.videoUrl, p.fetchFn, p.apiKey, p.signal);
478
+ if (!result.fileId)
479
+ throw new Error("MiniMax success carried neither video_url nor file_id.");
480
+ const fileRes = await p.fetchFn(`https://api.minimax.io/v1/files/retrieve?file_id=${encodeURIComponent(result.fileId)}`, { headers: { Authorization: `Bearer ${p.apiKey}` }, signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS) });
481
+ if (!fileRes.ok)
482
+ throw new Error(`HTTP ${fileRes.status} ${(await safeText(fileRes)).slice(0, 200)}`);
483
+ const fileBody = (await safeJson(fileRes));
484
+ const downloadUrl = fileBody.file?.download_url;
485
+ if (!downloadUrl)
486
+ throw new Error("MiniMax file retrieve carried no download_url.");
487
+ return downloadVideo(downloadUrl, p.fetchFn, p.apiKey, p.signal);
488
+ }
489
+ /**
490
+ * Runway. Submit `POST /v1/text_to_video` (or `/v1/image_to_video` when an
491
+ * image is given), header `X-Runway-Version`; poll `GET /v1/tasks/{id}` until
492
+ * `SUCCEEDED`; `output[0]` is the video URL → download.
493
+ */
494
+ async function generateRunway(p) {
495
+ const headers = {
496
+ Authorization: `Bearer ${p.apiKey}`,
497
+ "Content-Type": "application/json",
498
+ "X-Runway-Version": "2024-11-06",
499
+ };
500
+ const ratio = p.aspectRatio && p.aspectRatio.includes(":") && /\d/.test(p.aspectRatio) ? p.aspectRatio : "1280:720";
501
+ const duration = p.durationSeconds ?? 5;
502
+ let submitUrl;
503
+ let body;
504
+ if (p.imageDataUrl) {
505
+ submitUrl = "https://api.dev.runwayml.com/v1/image_to_video";
506
+ body = { model: p.model, promptText: p.prompt, promptImage: p.imageDataUrl, ratio, duration };
507
+ }
508
+ else {
509
+ submitUrl = "https://api.dev.runwayml.com/v1/text_to_video";
510
+ body = { model: p.model, promptText: p.prompt, ratio, duration };
511
+ }
512
+ const res = await p.fetchFn(submitUrl, {
513
+ method: "POST",
514
+ headers,
515
+ body: JSON.stringify(body),
516
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
517
+ });
518
+ if (!res.ok)
519
+ throw new Error(`HTTP ${res.status} ${(await safeText(res)).slice(0, 200)}`);
520
+ const submit = (await safeJson(res));
521
+ const id = submit.id;
522
+ if (!id)
523
+ throw new Error("Runway submit returned no id.");
524
+ const url = await pollUntil(async () => {
525
+ const r = await p.fetchFn(`https://api.dev.runwayml.com/v1/tasks/${encodeURIComponent(id)}`, {
526
+ headers,
527
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
528
+ });
529
+ if (!r.ok)
530
+ throw new Error(`HTTP ${r.status} ${(await safeText(r)).slice(0, 200)}`);
531
+ const pollBody = (await safeJson(r));
532
+ const status = (pollBody.status ?? "").toUpperCase();
533
+ if (status === "FAILED" || status === "CANCELLED" || status === "ERROR") {
534
+ throw new Error(`Runway reported status "${pollBody.status}".`);
535
+ }
536
+ if (status === "SUCCEEDED") {
537
+ const out = Array.isArray(pollBody.output) ? pollBody.output : [];
538
+ const first = out.find((x) => typeof x === "string" && x.length > 0);
539
+ if (!first)
540
+ throw new Error("Runway succeeded but output[0] was empty.");
541
+ return first;
542
+ }
543
+ return POLL_AGAIN;
544
+ }, { intervalMs: scaleInterval(5_000, p.intervalOverride), maxAttempts: 120 });
545
+ return downloadVideo(url, p.fetchFn, p.apiKey, p.signal);
546
+ }
547
+ /**
548
+ * Google Veo (Gemini API, REST — not the SDK). Submit
549
+ * `POST /v1beta/models/{model}:predictLongRunning?key=` with
550
+ * `{ instances:[{ prompt, image? }], parameters:{ aspectRatio?, durationSeconds? } }`
551
+ * → returns `{ name:"<operation>" }`. Poll the long-running operation at
552
+ * `GET /v1beta/{operation}?key=` until `done:true`; the finished sample is at
553
+ * `response.generateVideoResponse.generatedSamples[0].video` — either a `.uri`
554
+ * (a download URL that itself needs `?key=` appended) or inline
555
+ * `.bytesBase64Encoded`. Image-to-video passes the seed frame as raw base64
556
+ * (the `data:` prefix stripped) plus its mime type.
557
+ */
558
+ async function generateGoogle(p) {
559
+ const base = "https://generativelanguage.googleapis.com/v1beta";
560
+ const keyQuery = `key=${encodeURIComponent(p.apiKey)}`;
561
+ const instance = { prompt: p.prompt };
562
+ if (p.imageDataUrl) {
563
+ const { base64, mimeType } = splitDataUrl(p.imageDataUrl);
564
+ instance.image = { bytesBase64Encoded: base64, mimeType };
565
+ }
566
+ const parameters = {};
567
+ if (p.aspectRatio)
568
+ parameters.aspectRatio = p.aspectRatio;
569
+ if (p.durationSeconds !== undefined)
570
+ parameters.durationSeconds = p.durationSeconds;
571
+ const body = { instances: [instance], parameters };
572
+ const res = await p.fetchFn(`${base}/models/${encodeURIComponent(p.model)}:predictLongRunning?${keyQuery}`, {
573
+ method: "POST",
574
+ headers: { "Content-Type": "application/json" },
575
+ body: JSON.stringify(body),
576
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
577
+ });
578
+ if (!res.ok)
579
+ throw new Error(`HTTP ${res.status} ${(await safeText(res)).slice(0, 200)}`);
580
+ const submit = (await safeJson(res));
581
+ const operation = submit.name;
582
+ if (!operation)
583
+ throw new Error("Google Veo submit returned no operation name.");
584
+ // Poll the long-running operation; on done, return either the sample's URI
585
+ // (sentinel + download below) or its inline base64 as a data-url.
586
+ const finalUrl = await pollUntil(async () => {
587
+ const r = await p.fetchFn(`${base}/${operation}?${keyQuery}`, {
588
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
589
+ });
590
+ if (!r.ok)
591
+ throw new Error(`HTTP ${r.status} ${(await safeText(r)).slice(0, 200)}`);
592
+ const pollBody = (await safeJson(r));
593
+ if (pollBody.error) {
594
+ throw new Error(`Google reported error: ${pollBody.error.message ?? "unknown"}`);
595
+ }
596
+ if (pollBody.done !== true)
597
+ return POLL_AGAIN;
598
+ const sample = findVeoSample(pollBody.response);
599
+ if (sample?.uri)
600
+ return sample.uri;
601
+ if (sample?.bytesBase64Encoded) {
602
+ return `data:video/mp4;base64,${sample.bytesBase64Encoded}`;
603
+ }
604
+ throw new Error("Google Veo done but carried no video uri/bytes.");
605
+ }, { intervalMs: scaleInterval(10_000, p.intervalOverride), maxAttempts: 90 });
606
+ // Inline base64 → decode directly. A `uri` is a signed Google URL that still
607
+ // needs the API key appended as a query param to download.
608
+ if (finalUrl.startsWith("data:"))
609
+ return downloadVideo(finalUrl, p.fetchFn, p.apiKey, p.signal);
610
+ const withKey = finalUrl.includes("?") ? `${finalUrl}&${keyQuery}` : `${finalUrl}?${keyQuery}`;
611
+ return downloadVideo(withKey, p.fetchFn, p.apiKey, p.signal);
612
+ }
613
+ /**
614
+ * BytePlus Seedance (Ark). Submit
615
+ * `POST /api/v3/contents/generations/tasks` (Bearer) with a multimodal
616
+ * `content` array (text + optional first-frame image_url) → `{id}`. Poll
617
+ * `GET /api/v3/contents/generations/tasks/{id}` until `succeeded`; the finished
618
+ * url is `content.video_url`. `failed`/`cancelled` throw; queued/running keep
619
+ * polling.
620
+ */
621
+ async function generateBytePlus(p) {
622
+ const base = "https://ark.ap-southeast.bytepluses.com/api/v3/contents/generations/tasks";
623
+ const content = [{ type: "text", text: p.prompt }];
624
+ if (p.imageDataUrl) {
625
+ content.push({ type: "image_url", image_url: { url: p.imageDataUrl }, role: "first_frame" });
626
+ }
627
+ const body = { model: p.model, content };
628
+ const res = await p.fetchFn(base, {
629
+ method: "POST",
630
+ headers: { Authorization: `Bearer ${p.apiKey}`, "Content-Type": "application/json" },
631
+ body: JSON.stringify(body),
632
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
633
+ });
634
+ if (!res.ok)
635
+ throw new Error(`HTTP ${res.status} ${(await safeText(res)).slice(0, 200)}`);
636
+ const submit = (await safeJson(res));
637
+ const id = submit.id;
638
+ if (!id)
639
+ throw new Error("BytePlus video submit returned no id.");
640
+ const url = await pollUntil(async () => {
641
+ const r = await p.fetchFn(`${base}/${encodeURIComponent(id)}`, {
642
+ headers: { Authorization: `Bearer ${p.apiKey}` },
643
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
644
+ });
645
+ if (!r.ok)
646
+ throw new Error(`HTTP ${r.status} ${(await safeText(r)).slice(0, 200)}`);
647
+ const pollBody = (await safeJson(r));
648
+ const status = (pollBody.status ?? "").toLowerCase();
649
+ if (status === "failed" || status === "cancelled" || status === "canceled" || status === "error") {
650
+ throw new Error(`BytePlus reported status "${pollBody.status}".`);
651
+ }
652
+ if (status === "succeeded") {
653
+ const videoUrl = pollBody.content?.video_url ?? findVideoUrl(pollBody);
654
+ if (!videoUrl)
655
+ throw new Error("BytePlus succeeded but carried no video_url.");
656
+ return videoUrl;
657
+ }
658
+ return POLL_AGAIN;
659
+ }, { intervalMs: scaleInterval(5_000, p.intervalOverride), maxAttempts: 120 });
660
+ return downloadVideo(url, p.fetchFn, p.apiKey, p.signal);
661
+ }
662
+ /**
663
+ * Alibaba Wan (DashScope, international endpoint). Submit
664
+ * `POST /api/v1/services/aigc/video-generation/video-synthesis` with the async
665
+ * header `X-DashScope-Async: enable` → `{ output:{ task_id } }`. Poll
666
+ * `GET /api/v1/tasks/{task_id}` until `output.task_status` is `SUCCEEDED`; the
667
+ * final url is `output.results[0].video_url ?? output.video_url`.
668
+ * `FAILED`/`CANCELED` throw; PENDING/RUNNING keep polling.
669
+ */
670
+ async function generateAlibaba(p) {
671
+ const input = { prompt: p.prompt };
672
+ if (p.imageDataUrl)
673
+ input.img_url = p.imageDataUrl;
674
+ const parameters = {};
675
+ if (p.resolution)
676
+ parameters.size = p.resolution;
677
+ if (p.durationSeconds !== undefined)
678
+ parameters.duration = p.durationSeconds;
679
+ const body = { model: p.model, input, parameters };
680
+ const res = await p.fetchFn("https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis", {
681
+ method: "POST",
682
+ headers: {
683
+ Authorization: `Bearer ${p.apiKey}`,
684
+ "Content-Type": "application/json",
685
+ "X-DashScope-Async": "enable",
686
+ },
687
+ body: JSON.stringify(body),
688
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
689
+ });
690
+ if (!res.ok)
691
+ throw new Error(`HTTP ${res.status} ${(await safeText(res)).slice(0, 200)}`);
692
+ const submit = (await safeJson(res));
693
+ const taskId = submit.output?.task_id;
694
+ if (!taskId)
695
+ throw new Error("Alibaba video submit returned no task_id.");
696
+ const url = await pollUntil(async () => {
697
+ const r = await p.fetchFn(`https://dashscope-intl.aliyuncs.com/api/v1/tasks/${encodeURIComponent(taskId)}`, {
698
+ headers: { Authorization: `Bearer ${p.apiKey}` },
699
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
700
+ });
701
+ if (!r.ok)
702
+ throw new Error(`HTTP ${r.status} ${(await safeText(r)).slice(0, 200)}`);
703
+ const pollBody = (await safeJson(r));
704
+ const status = (pollBody.output?.task_status ?? "").toUpperCase();
705
+ if (status === "FAILED" || status === "CANCELED" || status === "CANCELLED" || status === "ERROR") {
706
+ throw new Error(`Alibaba reported status "${pollBody.output?.task_status}".`);
707
+ }
708
+ if (status === "SUCCEEDED") {
709
+ const videoUrl = pollBody.output?.results?.[0]?.video_url ?? pollBody.output?.video_url;
710
+ if (!videoUrl)
711
+ throw new Error("Alibaba succeeded but carried no video_url.");
712
+ return videoUrl;
713
+ }
714
+ return POLL_AGAIN;
715
+ }, { intervalMs: scaleInterval(2_500, p.intervalOverride), maxAttempts: 120 });
716
+ return downloadVideo(url, p.fetchFn, p.apiKey, p.signal);
717
+ }
718
+ /**
719
+ * Together AI. Submit `POST /v1/videos` (Bearer) with
720
+ * `{ model, prompt, seconds?, width?, height? }` → `{id}`. Poll
721
+ * `GET /v1/videos/{id}` until `completed`; the final url is
722
+ * `outputs[0].video_url ?? outputs[0].url` (outputs may be an array or a single
723
+ * object). `failed` throws; in_progress/queued keep polling.
724
+ */
725
+ async function generateTogether(p) {
726
+ const body = { model: p.model, prompt: p.prompt };
727
+ if (p.durationSeconds !== undefined)
728
+ body.seconds = String(p.durationSeconds); // Together wants a STRING
729
+ const dims = parseResolution(p.resolution);
730
+ if (dims) {
731
+ body.width = dims.width;
732
+ body.height = dims.height;
733
+ }
734
+ const res = await p.fetchFn("https://api.together.xyz/v1/videos", {
735
+ method: "POST",
736
+ headers: { Authorization: `Bearer ${p.apiKey}`, "Content-Type": "application/json" },
737
+ body: JSON.stringify(body),
738
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
739
+ });
740
+ if (!res.ok)
741
+ throw new Error(`HTTP ${res.status} ${(await safeText(res)).slice(0, 200)}`);
742
+ const submit = (await safeJson(res));
743
+ const id = submit.id;
744
+ if (!id)
745
+ throw new Error("Together video submit returned no id.");
746
+ const url = await pollUntil(async () => {
747
+ const r = await p.fetchFn(`https://api.together.xyz/v1/videos/${encodeURIComponent(id)}`, {
748
+ headers: { Authorization: `Bearer ${p.apiKey}` },
749
+ signal: withTimeout(p.signal, REQUEST_TIMEOUT_MS),
750
+ });
751
+ if (!r.ok)
752
+ throw new Error(`HTTP ${r.status} ${(await safeText(r)).slice(0, 200)}`);
753
+ const pollBody = (await safeJson(r));
754
+ const status = (pollBody.status ?? "").toLowerCase();
755
+ if (status === "failed" || status === "error" || status === "canceled" || status === "cancelled") {
756
+ throw new Error(`Together reported status "${pollBody.status}".`);
757
+ }
758
+ if (status === "completed") {
759
+ const first = Array.isArray(pollBody.outputs) ? pollBody.outputs[0] : pollBody.outputs;
760
+ const out = (first ?? {});
761
+ const videoUrl = out.video_url ?? out.url ?? findVideoUrl(pollBody);
762
+ if (!videoUrl)
763
+ throw new Error("Together completed but carried no video url.");
764
+ return videoUrl;
765
+ }
766
+ return POLL_AGAIN;
767
+ }, { intervalMs: scaleInterval(5_000, p.intervalOverride), maxAttempts: 120 });
768
+ return downloadVideo(url, p.fetchFn, p.apiKey, p.signal);
769
+ }
770
+ /* ───────────────────────── shared helpers ───────────────────────── */
771
+ /**
772
+ * Sentinel a poll callback returns to mean "not finished yet — keep polling".
773
+ * Using a unique symbol (rather than `undefined`) lets the callback's resolved
774
+ * value type infer `T` cleanly while keeping the keep-polling signal
775
+ * unambiguous and impossible to collide with a real result value.
776
+ */
777
+ export const POLL_AGAIN = Symbol("poll-again");
778
+ /**
779
+ * Generic inline poller. Calls `fn` every `intervalMs` until it returns a real
780
+ * value (returned) or throws (propagated as a hard failure). Returning
781
+ * `POLL_AGAIN` (an unknown / transient / queued status) just waits and retries.
782
+ * Bounded by BOTH `maxAttempts` AND the absolute `POLL_TIMEOUT_MS` wall-clock
783
+ * ceiling so a wedged job can never hang the agent loop forever.
784
+ */
785
+ export async function pollUntil(fn, options) {
786
+ const started = Date.now();
787
+ for (let attempt = 0; attempt < options.maxAttempts; attempt++) {
788
+ const result = await fn();
789
+ if (result !== POLL_AGAIN)
790
+ return result;
791
+ if (Date.now() - started > POLL_TIMEOUT_MS) {
792
+ throw new Error(`Timed out polling for the finished video after ${Math.round(POLL_TIMEOUT_MS / 1000)}s.`);
793
+ }
794
+ await sleep(options.intervalMs);
795
+ }
796
+ throw new Error(`Timed out polling for the finished video after ${options.maxAttempts} attempts.`);
797
+ }
798
+ /**
799
+ * Download the final video. If the URL is a `data:` URL, decode its base64
800
+ * payload directly; otherwise GET the URL and read the body as bytes. The
801
+ * provider key is sent as a Bearer header for same-origin signed URLs that
802
+ * still require auth; cross-origin CDN URLs simply ignore it.
803
+ */
804
+ export async function downloadVideo(url, fetchFn, apiKey, signal) {
805
+ if (url.startsWith("data:")) {
806
+ const comma = url.indexOf(",");
807
+ const meta = url.slice(0, comma);
808
+ const payload = url.slice(comma + 1);
809
+ if (meta.includes(";base64"))
810
+ return Buffer.from(payload, "base64");
811
+ return Buffer.from(decodeURIComponent(payload), "utf8");
812
+ }
813
+ const res = await fetchFn(url, {
814
+ headers: { Authorization: `Bearer ${apiKey}` },
815
+ signal: withTimeout(signal, REQUEST_TIMEOUT_MS),
816
+ });
817
+ if (!res.ok)
818
+ throw new Error(`download HTTP ${res.status} ${(await safeText(res)).slice(0, 200)}`);
819
+ return Buffer.from(await res.arrayBuffer());
820
+ }
821
+ /**
822
+ * Read an `image` argument (a local path OR an http(s) URL) into a
823
+ * `data:<mime>;base64,…` data URL suitable for the image-to-video providers.
824
+ */
825
+ async function loadImageAsDataUrl(image, fetchFn, signal) {
826
+ if (/^https?:\/\//i.test(image)) {
827
+ const res = await fetchFn(image, { signal: withTimeout(signal, REQUEST_TIMEOUT_MS) });
828
+ if (!res.ok)
829
+ throw new Error(`HTTP ${res.status} fetching image URL`);
830
+ const bytes = Buffer.from(await res.arrayBuffer());
831
+ const mime = res.headers.get("content-type")?.split(";")[0]?.trim() || guessImageMime(image);
832
+ return `data:${mime};base64,${bytes.toString("base64")}`;
833
+ }
834
+ // Treat as a local filesystem path.
835
+ const bytes = fs.readFileSync(image);
836
+ return `data:${guessImageMime(image)};base64,${bytes.toString("base64")}`;
837
+ }
838
+ function guessImageMime(p) {
839
+ const ext = path.extname(p).toLowerCase();
840
+ switch (ext) {
841
+ case ".png":
842
+ return "image/png";
843
+ case ".webp":
844
+ return "image/webp";
845
+ case ".gif":
846
+ return "image/gif";
847
+ case ".heic":
848
+ return "image/heic";
849
+ case ".jpg":
850
+ case ".jpeg":
851
+ default:
852
+ return "image/jpeg";
853
+ }
854
+ }
855
+ /**
856
+ * Split a `data:<mime>;base64,<payload>` URL into its raw base64 payload and
857
+ * mime type — Google Veo's `image` field wants the bytes WITHOUT the `data:`
858
+ * prefix plus the mime type as a sibling. Non-data inputs are returned verbatim
859
+ * as the payload with a sensible default mime.
860
+ */
861
+ function splitDataUrl(dataUrl) {
862
+ const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(dataUrl);
863
+ if (!match)
864
+ return { base64: dataUrl, mimeType: "image/jpeg" };
865
+ return { base64: match[3] ?? "", mimeType: match[1] || "image/jpeg" };
866
+ }
867
+ /**
868
+ * Pull the first Veo sample (`{ uri?, bytesBase64Encoded? }`) out of a finished
869
+ * long-running operation's `response`. The documented path is
870
+ * `response.generateVideoResponse.generatedSamples[0].video`; we fall back to a
871
+ * deep walk for any sibling shape so a slightly different field name still
872
+ * yields the bytes.
873
+ */
874
+ function findVeoSample(response) {
875
+ const direct = response?.generateVideoResponse?.generatedSamples?.[0]?.video;
876
+ if (direct && (direct.uri || direct.bytesBase64Encoded))
877
+ return direct;
878
+ // Fallback: deep-walk for the first object carrying a uri or base64 video.
879
+ const seen = new Set();
880
+ const stack = [response];
881
+ while (stack.length > 0) {
882
+ const node = stack.pop();
883
+ if (node === null || typeof node !== "object")
884
+ continue;
885
+ if (seen.has(node))
886
+ continue;
887
+ seen.add(node);
888
+ const obj = node;
889
+ if (typeof obj.bytesBase64Encoded === "string" && obj.bytesBase64Encoded.length > 0) {
890
+ return { bytesBase64Encoded: obj.bytesBase64Encoded };
891
+ }
892
+ if (typeof obj.uri === "string" && /^https?:\/\//i.test(obj.uri)) {
893
+ return { uri: obj.uri };
894
+ }
895
+ for (const value of Object.values(obj))
896
+ stack.push(value);
897
+ }
898
+ return undefined;
899
+ }
900
+ /**
901
+ * Parse a `"WIDTHxHEIGHT"` (or `"WIDTH*HEIGHT"`) resolution string into numeric
902
+ * width/height. `"720p"`-style shorthands (no explicit pair) yield undefined so
903
+ * the caller simply omits the dimensions.
904
+ */
905
+ function parseResolution(resolution) {
906
+ if (!resolution)
907
+ return undefined;
908
+ const match = /^(\d+)\s*[x*×]\s*(\d+)$/i.exec(resolution.trim());
909
+ if (!match)
910
+ return undefined;
911
+ const width = Number(match[1]);
912
+ const height = Number(match[2]);
913
+ if (!Number.isFinite(width) || !Number.isFinite(height))
914
+ return undefined;
915
+ return { width, height };
916
+ }
917
+ /**
918
+ * Deep-search an arbitrary JSON body for the first usable video URL — an
919
+ * `https://…mp4` (or any https URL on a key that smells like a video field) or
920
+ * a `data:video…` data URL. Used by the providers (chiefly OpenRouter) whose
921
+ * exact response field for the finished video isn't pinned down.
922
+ */
923
+ export function findVideoUrl(body) {
924
+ const seen = new Set();
925
+ const stack = [body];
926
+ let httpsFallback;
927
+ while (stack.length > 0) {
928
+ const node = stack.pop();
929
+ if (node === null || node === undefined)
930
+ continue;
931
+ if (typeof node === "string") {
932
+ if (node.startsWith("data:video"))
933
+ return node;
934
+ if (/^https?:\/\/\S+\.mp4(\?\S*)?$/i.test(node))
935
+ return node;
936
+ if (!httpsFallback && /^https?:\/\//i.test(node) && /\.(mp4|webm|mov|m4v)(\?|$)/i.test(node)) {
937
+ httpsFallback = node;
938
+ }
939
+ continue;
940
+ }
941
+ if (typeof node !== "object")
942
+ continue;
943
+ if (seen.has(node))
944
+ continue;
945
+ seen.add(node);
946
+ if (Array.isArray(node)) {
947
+ for (const item of node)
948
+ stack.push(item);
949
+ continue;
950
+ }
951
+ for (const value of Object.values(node))
952
+ stack.push(value);
953
+ }
954
+ return httpsFallback;
955
+ }
956
+ /** Read a string property from a loose object (undefined when absent/non-string). */
957
+ function readString(obj, key) {
958
+ const v = obj[key];
959
+ return typeof v === "string" && v.length > 0 ? v : undefined;
960
+ }
961
+ /**
962
+ * Scale a provider's real poll interval by the test override. When no override
963
+ * is set, the real cadence is used; when a tiny override is supplied (tests),
964
+ * every provider collapses to it so the flow runs without real waits.
965
+ */
966
+ function scaleInterval(realMs, override) {
967
+ if (override === undefined)
968
+ return realMs;
969
+ return Math.max(0, override);
970
+ }
971
+ function sleep(ms) {
972
+ return new Promise((resolve) => {
973
+ const t = setTimeout(resolve, ms);
974
+ if (typeof t.unref === "function")
975
+ t.unref();
976
+ });
977
+ }
978
+ function resolveConfiguredModel(provider) {
979
+ try {
980
+ const cfg = loadConfig();
981
+ const m = cfg.tools?.video?.models?.[provider];
982
+ if (typeof m === "string" && m.trim())
983
+ return m.trim();
984
+ }
985
+ catch {
986
+ /* default below */
987
+ }
988
+ return undefined;
989
+ }
990
+ function buildFileName(hint) {
991
+ const stamp = Date.now().toString(36);
992
+ const base = hint
993
+ ? path.basename(hint).replace(/\.[a-z0-9]+$/i, "").replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 48)
994
+ : `video-${stamp}`;
995
+ return `${base}.mp4`;
996
+ }
997
+ function fail(action, message, extra = {}) {
998
+ return jsonResult({
999
+ action,
1000
+ ok: false,
1001
+ message,
1002
+ ...extra,
1003
+ });
1004
+ }
1005
+ async function safeText(res) {
1006
+ try {
1007
+ return await res.text();
1008
+ }
1009
+ catch {
1010
+ return "";
1011
+ }
1012
+ }
1013
+ async function safeJson(res) {
1014
+ try {
1015
+ return await res.json();
1016
+ }
1017
+ catch {
1018
+ return {};
1019
+ }
1020
+ }
1021
+ /** Compose the caller's signal with a hard per-request timeout. */
1022
+ function withTimeout(signal, ms) {
1023
+ const timeoutSignal = AbortSignal.timeout(ms);
1024
+ if (!signal)
1025
+ return timeoutSignal;
1026
+ return AbortSignal.any([signal, timeoutSignal]);
1027
+ }
1028
+ //# sourceMappingURL=generate-video-tool.js.map