@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.
- package/README.md +56 -0
- package/dist/agents/tools/edge-tts.d.ts +44 -0
- package/dist/agents/tools/edge-tts.d.ts.map +1 -0
- package/dist/agents/tools/edge-tts.js +142 -0
- package/dist/agents/tools/edge-tts.js.map +1 -0
- package/dist/agents/tools/generate-music-tool.d.ts +61 -0
- package/dist/agents/tools/generate-music-tool.d.ts.map +1 -0
- package/dist/agents/tools/generate-music-tool.js +286 -0
- package/dist/agents/tools/generate-music-tool.js.map +1 -0
- package/dist/agents/tools/generate-speech-tool.d.ts +69 -0
- package/dist/agents/tools/generate-speech-tool.d.ts.map +1 -0
- package/dist/agents/tools/generate-speech-tool.js +331 -0
- package/dist/agents/tools/generate-speech-tool.js.map +1 -0
- package/dist/agents/tools/generate-video-tool.d.ts +111 -0
- package/dist/agents/tools/generate-video-tool.d.ts.map +1 -0
- package/dist/agents/tools/generate-video-tool.js +1028 -0
- package/dist/agents/tools/generate-video-tool.js.map +1 -0
- package/dist/agents/tools/media-command.d.ts +47 -0
- package/dist/agents/tools/media-command.d.ts.map +1 -0
- package/dist/agents/tools/media-command.js +93 -0
- package/dist/agents/tools/media-command.js.map +1 -0
- package/dist/agents/tools/registry.d.ts.map +1 -1
- package/dist/agents/tools/registry.js +27 -0
- package/dist/agents/tools/registry.js.map +1 -1
- package/dist/agents/tools/transcribe-audio-tool.d.ts +96 -0
- package/dist/agents/tools/transcribe-audio-tool.d.ts.map +1 -0
- package/dist/agents/tools/transcribe-audio-tool.js +577 -0
- package/dist/agents/tools/transcribe-audio-tool.js.map +1 -0
- package/dist/buildstamp.json +1 -1
- package/dist/cli/commands/connect.d.ts +6 -0
- package/dist/cli/commands/connect.d.ts.map +1 -1
- package/dist/cli/commands/connect.js +7 -0
- package/dist/cli/commands/connect.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +2 -1
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/expose.d.ts.map +1 -1
- package/dist/cli/commands/expose.js +22 -3
- package/dist/cli/commands/expose.js.map +1 -1
- package/dist/cli/commands/gateway.d.ts +12 -0
- package/dist/cli/commands/gateway.d.ts.map +1 -1
- package/dist/cli/commands/gateway.js +114 -2
- package/dist/cli/commands/gateway.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +2 -1
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/program/build-program.d.ts.map +1 -1
- package/dist/cli/program/build-program.js +36 -0
- package/dist/cli/program/build-program.js.map +1 -1
- package/dist/config/io.d.ts +13 -0
- package/dist/config/io.d.ts.map +1 -1
- package/dist/config/io.js.map +1 -1
- package/dist/core/gateway-auth.d.ts +86 -0
- package/dist/core/gateway-auth.d.ts.map +1 -0
- package/dist/core/gateway-auth.js +156 -0
- package/dist/core/gateway-auth.js.map +1 -0
- package/dist/core/gateway-probe.d.ts +5 -0
- package/dist/core/gateway-probe.d.ts.map +1 -1
- package/dist/core/gateway-probe.js +2 -1
- package/dist/core/gateway-probe.js.map +1 -1
- package/dist/core/gateway-spawn.d.ts.map +1 -1
- package/dist/core/gateway-spawn.js +5 -2
- package/dist/core/gateway-spawn.js.map +1 -1
- package/dist/core/server.d.ts.map +1 -1
- package/dist/core/server.js +21 -1
- package/dist/core/server.js.map +1 -1
- package/dist/core/tunnel/auth-proxy.d.ts +3 -2
- package/dist/core/tunnel/auth-proxy.d.ts.map +1 -1
- package/dist/core/tunnel/auth-proxy.js +8 -34
- package/dist/core/tunnel/auth-proxy.js.map +1 -1
- package/dist/core/tunnel/manager.d.ts +4 -2
- package/dist/core/tunnel/manager.d.ts.map +1 -1
- package/dist/core/tunnel/manager.js +3 -2
- package/dist/core/tunnel/manager.js.map +1 -1
- package/dist/tui/client.d.ts +8 -0
- package/dist/tui/client.d.ts.map +1 -1
- package/dist/tui/client.js +5 -1
- package/dist/tui/client.js.map +1 -1
- 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
|