vargai 0.4.0-alpha4 → 0.4.0-alpha40
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/.env.example +6 -0
- package/README.md +483 -61
- package/assets/fonts/TikTokSans-Bold.ttf +0 -0
- package/examples/grok-imagine-test.tsx +155 -0
- package/launch-videos/06-kawaii-fruits.tsx +93 -0
- package/launch-videos/07-ugc-weight-loss.tsx +132 -0
- package/launch-videos/08-talking-head-varg.tsx +107 -0
- package/launch-videos/09-girl.tsx +160 -0
- package/launch-videos/README.md +42 -0
- package/package.json +10 -4
- package/pipeline/cookbooks/round-video-character.md +1 -1
- package/skills/varg-video-generation/SKILL.md +224 -0
- package/skills/varg-video-generation/references/templates.md +380 -0
- package/skills/varg-video-generation/scripts/setup.ts +265 -0
- package/src/ai-sdk/cache.ts +1 -3
- package/src/ai-sdk/examples/google-image.ts +62 -0
- package/src/ai-sdk/index.ts +10 -0
- package/src/ai-sdk/middleware/wrap-image-model.ts +4 -21
- package/src/ai-sdk/middleware/wrap-music-model.ts +4 -16
- package/src/ai-sdk/middleware/wrap-video-model.ts +5 -17
- package/src/ai-sdk/providers/CONTRIBUTING.md +457 -0
- package/src/ai-sdk/providers/editly/backends/index.ts +8 -0
- package/src/ai-sdk/providers/editly/backends/local.ts +94 -0
- package/src/ai-sdk/providers/editly/backends/types.ts +74 -0
- package/src/ai-sdk/providers/editly/editly.test.ts +49 -1
- package/src/ai-sdk/providers/editly/index.ts +164 -80
- package/src/ai-sdk/providers/editly/layers.ts +58 -6
- package/src/ai-sdk/providers/editly/rendi/editly-with-rendi-backend.test.ts +335 -0
- package/src/ai-sdk/providers/editly/rendi/index.ts +289 -0
- package/src/ai-sdk/providers/editly/rendi/rendi.test.ts +35 -0
- package/src/ai-sdk/providers/editly/types.ts +30 -0
- package/src/ai-sdk/providers/elevenlabs.ts +10 -2
- package/src/ai-sdk/providers/fal.test.ts +214 -0
- package/src/ai-sdk/providers/fal.ts +435 -40
- package/src/ai-sdk/providers/google.ts +423 -0
- package/src/ai-sdk/providers/together.ts +191 -0
- package/src/cli/commands/find.tsx +1 -0
- package/src/cli/commands/frame.tsx +616 -0
- package/src/cli/commands/hello.ts +85 -0
- package/src/cli/commands/help.tsx +18 -30
- package/src/cli/commands/index.ts +11 -2
- package/src/cli/commands/init.tsx +570 -0
- package/src/cli/commands/list.tsx +1 -0
- package/src/cli/commands/render.tsx +322 -76
- package/src/cli/commands/run.tsx +1 -0
- package/src/cli/commands/storyboard.tsx +1714 -0
- package/src/cli/commands/which.tsx +1 -0
- package/src/cli/index.ts +23 -4
- package/src/cli/ui/components/Badge.tsx +1 -0
- package/src/cli/ui/components/DataTable.tsx +1 -0
- package/src/cli/ui/components/Header.tsx +1 -0
- package/src/cli/ui/components/HelpBlock.tsx +1 -0
- package/src/cli/ui/components/KeyValue.tsx +1 -0
- package/src/cli/ui/components/OptionRow.tsx +1 -0
- package/src/cli/ui/components/Separator.tsx +1 -0
- package/src/cli/ui/components/StatusBox.tsx +1 -0
- package/src/cli/ui/components/VargBox.tsx +1 -0
- package/src/cli/ui/components/VargProgress.tsx +1 -0
- package/src/cli/ui/components/VargSpinner.tsx +1 -0
- package/src/cli/ui/components/VargText.tsx +1 -0
- package/src/definitions/actions/grok-edit.ts +133 -0
- package/src/definitions/actions/index.ts +16 -0
- package/src/definitions/actions/qwen-angles.ts +218 -0
- package/src/index.ts +1 -0
- package/src/providers/fal.ts +196 -0
- package/src/react/assets.ts +9 -0
- package/src/react/elements.ts +0 -5
- package/src/react/examples/branching.tsx +6 -4
- package/src/react/examples/character-video.tsx +13 -10
- package/src/react/examples/local-files-test.tsx +19 -0
- package/src/react/examples/ltx2-test.tsx +25 -0
- package/src/react/examples/madi.tsx +13 -10
- package/src/react/examples/mcmeows.tsx +40 -0
- package/src/react/examples/music-defaults.tsx +24 -0
- package/src/react/examples/quickstart-test.tsx +101 -0
- package/src/react/examples/qwen-angles-test.tsx +72 -0
- package/src/react/index.ts +3 -3
- package/src/react/layouts/grid.tsx +1 -1
- package/src/react/layouts/index.ts +2 -1
- package/src/react/layouts/slot.tsx +85 -0
- package/src/react/layouts/split.tsx +18 -0
- package/src/react/react.test.ts +60 -11
- package/src/react/renderers/burn-captions.ts +95 -0
- package/src/react/renderers/cache.test.ts +182 -0
- package/src/react/renderers/captions.ts +25 -6
- package/src/react/renderers/clip.ts +56 -25
- package/src/react/renderers/context.ts +5 -2
- package/src/react/renderers/image.ts +5 -2
- package/src/react/renderers/index.ts +0 -1
- package/src/react/renderers/music.ts +8 -3
- package/src/react/renderers/packshot/blinking-button.ts +413 -0
- package/src/react/renderers/packshot.ts +170 -8
- package/src/react/renderers/progress.ts +4 -3
- package/src/react/renderers/render.ts +127 -71
- package/src/react/renderers/speech.ts +2 -2
- package/src/react/renderers/split.ts +34 -13
- package/src/react/renderers/utils.test.ts +80 -0
- package/src/react/renderers/utils.ts +37 -1
- package/src/react/renderers/video.ts +47 -9
- package/src/react/types.ts +70 -17
- package/src/studio/stages.ts +40 -39
- package/src/studio/step-renderer.ts +14 -24
- package/src/studio/ui/index.html +2 -2
- package/src/tests/all.test.ts +4 -4
- package/src/tests/index.ts +1 -1
- package/test-slot-grid.tsx +19 -0
- package/test-slot-userland.tsx +30 -0
- package/test-sync-v2.ts +30 -0
- package/test-sync-v2.tsx +29 -0
- package/tsconfig.json +1 -1
- package/video.tsx +7 -0
- package/src/ai-sdk/providers/editly/ffmpeg.ts +0 -60
- package/src/react/renderers/animate.ts +0 -59
- /package/src/cli/commands/{studio.tsx → studio.ts} +0 -0
|
@@ -11,9 +11,28 @@ import {
|
|
|
11
11
|
type TranscriptionModelV3CallOptions,
|
|
12
12
|
} from "@ai-sdk/provider";
|
|
13
13
|
import { fal } from "@fal-ai/client";
|
|
14
|
+
import { fileCache } from "../file-cache";
|
|
14
15
|
import type { VideoModelV3, VideoModelV3CallOptions } from "../video-model";
|
|
15
16
|
|
|
17
|
+
interface PendingRequest {
|
|
18
|
+
request_id: string;
|
|
19
|
+
endpoint: string;
|
|
20
|
+
submitted_at: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const pendingStorage = fileCache({ dir: ".cache/fal-pending" });
|
|
24
|
+
|
|
25
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
26
|
+
const FAL_TIMEOUT_MS = process.env.FAL_TIMEOUT_MS
|
|
27
|
+
? Number.parseInt(process.env.FAL_TIMEOUT_MS, 10)
|
|
28
|
+
: DEFAULT_TIMEOUT_MS;
|
|
29
|
+
|
|
16
30
|
const VIDEO_MODELS: Record<string, { t2v: string; i2v: string }> = {
|
|
31
|
+
// Kling v2.6 - latest with native audio generation
|
|
32
|
+
"kling-v2.6": {
|
|
33
|
+
t2v: "fal-ai/kling-video/v2.6/pro/text-to-video",
|
|
34
|
+
i2v: "fal-ai/kling-video/v2.6/pro/image-to-video",
|
|
35
|
+
},
|
|
17
36
|
"kling-v2.5": {
|
|
18
37
|
t2v: "fal-ai/kling-video/v2.5-turbo/pro/text-to-video",
|
|
19
38
|
i2v: "fal-ai/kling-video/v2.5-turbo/pro/image-to-video",
|
|
@@ -38,6 +57,28 @@ const VIDEO_MODELS: Record<string, { t2v: string; i2v: string }> = {
|
|
|
38
57
|
t2v: "fal-ai/minimax-video/text-to-video",
|
|
39
58
|
i2v: "fal-ai/minimax-video/image-to-video",
|
|
40
59
|
},
|
|
60
|
+
// LTX-2 19B Distilled - video with native audio generation
|
|
61
|
+
"ltx-2-19b-distilled": {
|
|
62
|
+
t2v: "fal-ai/ltx-2-19b/distilled/text-to-video",
|
|
63
|
+
i2v: "fal-ai/ltx-2-19b/distilled/image-to-video",
|
|
64
|
+
},
|
|
65
|
+
// Grok Imagine Video - xAI's video generation with audio
|
|
66
|
+
"grok-imagine": {
|
|
67
|
+
t2v: "xai/grok-imagine-video/text-to-video",
|
|
68
|
+
i2v: "xai/grok-imagine-video/image-to-video",
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Video edit models - video-to-video editing
|
|
73
|
+
const VIDEO_EDIT_MODELS: Record<string, string> = {
|
|
74
|
+
"grok-imagine-edit": "xai/grok-imagine-video/edit-video",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Motion control models - video-to-video with motion transfer
|
|
78
|
+
const MOTION_CONTROL_MODELS: Record<string, string> = {
|
|
79
|
+
"kling-v2.6-motion": "fal-ai/kling-video/v2.6/pro/motion-control",
|
|
80
|
+
"kling-v2.6-motion-standard":
|
|
81
|
+
"fal-ai/kling-video/v2.6/standard/motion-control",
|
|
41
82
|
};
|
|
42
83
|
|
|
43
84
|
// lipsync models - video + audio input
|
|
@@ -55,10 +96,34 @@ const IMAGE_MODELS: Record<string, string> = {
|
|
|
55
96
|
"nano-banana-pro": "fal-ai/nano-banana-pro",
|
|
56
97
|
"nano-banana-pro/edit": "fal-ai/nano-banana-pro/edit",
|
|
57
98
|
"seedream-v4.5/edit": "fal-ai/bytedance/seedream/v4.5/edit",
|
|
99
|
+
// Qwen Image Edit 2511 Multiple Angles - camera angle adjustment
|
|
100
|
+
"qwen-angles": "fal-ai/qwen-image-edit-2511-multiple-angles",
|
|
58
101
|
};
|
|
59
102
|
|
|
60
103
|
// Models that use image_size instead of aspect_ratio
|
|
61
|
-
const IMAGE_SIZE_MODELS = new Set([
|
|
104
|
+
const IMAGE_SIZE_MODELS = new Set([
|
|
105
|
+
"flux-schnell",
|
|
106
|
+
"flux-dev",
|
|
107
|
+
"flux-pro",
|
|
108
|
+
"seedream-v4.5/edit",
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
// Qwen Angles model - image-to-image with camera angle adjustment
|
|
112
|
+
const QWEN_ANGLES_MODEL = "qwen-angles";
|
|
113
|
+
|
|
114
|
+
// Map aspect ratio to image_size for Qwen Angles (base dimension 1024)
|
|
115
|
+
const ASPECT_RATIO_TO_QWEN_SIZE: Record<
|
|
116
|
+
string,
|
|
117
|
+
{ width: number; height: number }
|
|
118
|
+
> = {
|
|
119
|
+
"1:1": { width: 1024, height: 1024 },
|
|
120
|
+
"4:3": { width: 1024, height: 768 },
|
|
121
|
+
"3:4": { width: 768, height: 1024 },
|
|
122
|
+
"16:9": { width: 1024, height: 576 },
|
|
123
|
+
"9:16": { width: 576, height: 1024 },
|
|
124
|
+
"3:2": { width: 1024, height: 683 },
|
|
125
|
+
"2:3": { width: 683, height: 1024 },
|
|
126
|
+
};
|
|
62
127
|
|
|
63
128
|
// Map aspect ratio strings to image_size enum values
|
|
64
129
|
const ASPECT_RATIO_TO_IMAGE_SIZE: Record<string, string> = {
|
|
@@ -131,6 +196,143 @@ async function uploadBuffer(buffer: ArrayBuffer): Promise<string> {
|
|
|
131
196
|
return fal.storage.upload(new Blob([buffer]));
|
|
132
197
|
}
|
|
133
198
|
|
|
199
|
+
export function computePendingKey(
|
|
200
|
+
endpoint: string,
|
|
201
|
+
input: Record<string, unknown>,
|
|
202
|
+
stableKey?: string,
|
|
203
|
+
): string {
|
|
204
|
+
const keyData = stableKey ?? JSON.stringify({ endpoint, input });
|
|
205
|
+
const hash = Bun.hash(keyData).toString(16);
|
|
206
|
+
return `pending_${hash}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function computeFileHashes(
|
|
210
|
+
files: ImageModelV3File[] | undefined,
|
|
211
|
+
): Promise<string[]> {
|
|
212
|
+
if (!files || files.length === 0) return [];
|
|
213
|
+
return Promise.all(
|
|
214
|
+
files.map(async (f) => {
|
|
215
|
+
if (f.type === "url") return f.url;
|
|
216
|
+
const bytes =
|
|
217
|
+
typeof f.data === "string"
|
|
218
|
+
? Uint8Array.from(atob(f.data), (c) => c.charCodeAt(0))
|
|
219
|
+
: f.data;
|
|
220
|
+
return Bun.hash(bytes).toString(16);
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function executeWithQueueRecovery<T>(
|
|
226
|
+
endpoint: string,
|
|
227
|
+
input: Record<string, unknown>,
|
|
228
|
+
options: {
|
|
229
|
+
logs?: boolean;
|
|
230
|
+
onQueueUpdate?: (status: { status: string }) => void;
|
|
231
|
+
stableKey?: string;
|
|
232
|
+
} = {},
|
|
233
|
+
): Promise<T> {
|
|
234
|
+
const { logs = true, onQueueUpdate, stableKey } = options;
|
|
235
|
+
const pendingKey = computePendingKey(endpoint, input, stableKey);
|
|
236
|
+
|
|
237
|
+
const pending = (await pendingStorage.get(pendingKey)) as
|
|
238
|
+
| PendingRequest
|
|
239
|
+
| undefined;
|
|
240
|
+
|
|
241
|
+
if (pending) {
|
|
242
|
+
try {
|
|
243
|
+
const status = await fal.queue.status(pending.endpoint, {
|
|
244
|
+
requestId: pending.request_id,
|
|
245
|
+
logs,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
if (status.status === "COMPLETED") {
|
|
249
|
+
console.log(
|
|
250
|
+
`\x1b[32m⚡ recovered completed job from queue (${pending.request_id.slice(0, 8)}...)\x1b[0m`,
|
|
251
|
+
);
|
|
252
|
+
const result = await fal.queue.result(pending.endpoint, {
|
|
253
|
+
requestId: pending.request_id,
|
|
254
|
+
});
|
|
255
|
+
await pendingStorage.delete(pendingKey);
|
|
256
|
+
return result as T;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (status.status === "IN_QUEUE" || status.status === "IN_PROGRESS") {
|
|
260
|
+
console.log(
|
|
261
|
+
`\x1b[33m⏳ resuming pending job (${pending.request_id.slice(0, 8)}...) - status: ${status.status}\x1b[0m`,
|
|
262
|
+
);
|
|
263
|
+
await fal.queue.subscribeToStatus(pending.endpoint, {
|
|
264
|
+
requestId: pending.request_id,
|
|
265
|
+
logs,
|
|
266
|
+
timeout: FAL_TIMEOUT_MS,
|
|
267
|
+
onQueueUpdate,
|
|
268
|
+
});
|
|
269
|
+
const result = await fal.queue.result(pending.endpoint, {
|
|
270
|
+
requestId: pending.request_id,
|
|
271
|
+
});
|
|
272
|
+
await pendingStorage.delete(pendingKey);
|
|
273
|
+
return result as T;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
await pendingStorage.delete(pendingKey);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
const isNotFound =
|
|
279
|
+
error instanceof Error &&
|
|
280
|
+
(error.message.includes("not found") ||
|
|
281
|
+
error.message.includes("404") ||
|
|
282
|
+
error.message.includes("does not exist"));
|
|
283
|
+
|
|
284
|
+
if (isNotFound) {
|
|
285
|
+
console.log(
|
|
286
|
+
`\x1b[33m⚠ pending job expired or not found, submitting new request\x1b[0m`,
|
|
287
|
+
);
|
|
288
|
+
await pendingStorage.delete(pendingKey);
|
|
289
|
+
} else {
|
|
290
|
+
console.log(
|
|
291
|
+
`\x1b[33m⚠ pending job check failed (${error instanceof Error ? error.message : "unknown"}), keeping for retry\x1b[0m`,
|
|
292
|
+
);
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const { request_id } = await fal.queue.submit(endpoint, { input });
|
|
299
|
+
|
|
300
|
+
await pendingStorage.set(
|
|
301
|
+
pendingKey,
|
|
302
|
+
{
|
|
303
|
+
request_id,
|
|
304
|
+
endpoint,
|
|
305
|
+
submitted_at: Date.now(),
|
|
306
|
+
} satisfies PendingRequest,
|
|
307
|
+
24 * 60 * 60 * 1000,
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
console.log(
|
|
311
|
+
`\x1b[36m📋 queued job ${request_id.slice(0, 8)}... (recoverable on timeout)\x1b[0m`,
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
await fal.queue.subscribeToStatus(endpoint, {
|
|
316
|
+
requestId: request_id,
|
|
317
|
+
logs,
|
|
318
|
+
timeout: FAL_TIMEOUT_MS,
|
|
319
|
+
onQueueUpdate,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const result = await fal.queue.result(endpoint, {
|
|
323
|
+
requestId: request_id,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
await pendingStorage.delete(pendingKey);
|
|
327
|
+
return result as T;
|
|
328
|
+
} catch (error) {
|
|
329
|
+
console.log(
|
|
330
|
+
`\x1b[33m⚠ job ${request_id.slice(0, 8)}... saved for recovery on next run\x1b[0m`,
|
|
331
|
+
);
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
134
336
|
class FalVideoModel implements VideoModelV3 {
|
|
135
337
|
readonly specificationVersion = "v3" as const;
|
|
136
338
|
readonly provider = "fal";
|
|
@@ -152,7 +354,7 @@ class FalVideoModel implements VideoModelV3 {
|
|
|
152
354
|
} = options;
|
|
153
355
|
const warnings: SharedV3Warning[] = [];
|
|
154
356
|
|
|
155
|
-
const
|
|
357
|
+
const hasVideoInput = files?.some((f) =>
|
|
156
358
|
getMediaType(f)?.startsWith("video/"),
|
|
157
359
|
);
|
|
158
360
|
const hasImageInput = files?.some((f) =>
|
|
@@ -163,15 +365,28 @@ class FalVideoModel implements VideoModelV3 {
|
|
|
163
365
|
);
|
|
164
366
|
|
|
165
367
|
const isLipsync = LIPSYNC_MODELS[this.modelId] !== undefined;
|
|
368
|
+
const isMotionControl = MOTION_CONTROL_MODELS[this.modelId] !== undefined;
|
|
369
|
+
const isVideoEdit = VIDEO_EDIT_MODELS[this.modelId] !== undefined;
|
|
370
|
+
const isKlingV26 = this.modelId === "kling-v2.6";
|
|
371
|
+
const isLtx2 = this.modelId === "ltx-2-19b-distilled";
|
|
372
|
+
const isGrokImagine = this.modelId === "grok-imagine";
|
|
373
|
+
|
|
374
|
+
const fileHashes = await computeFileHashes(files as ImageModelV3File[]);
|
|
375
|
+
|
|
166
376
|
const endpoint = isLipsync
|
|
167
377
|
? this.resolveLipsyncEndpoint()
|
|
168
|
-
:
|
|
378
|
+
: isMotionControl
|
|
379
|
+
? this.resolveMotionControlEndpoint()
|
|
380
|
+
: isVideoEdit
|
|
381
|
+
? this.resolveVideoEditEndpoint()
|
|
382
|
+
: this.resolveEndpoint(hasImageInput ?? false);
|
|
169
383
|
|
|
170
384
|
const input: Record<string, unknown> = {
|
|
171
385
|
...(providerOptions?.fal ?? {}),
|
|
172
386
|
};
|
|
173
387
|
|
|
174
388
|
if (isLipsync) {
|
|
389
|
+
// Lipsync: video + audio input
|
|
175
390
|
const videoFile = files?.find((f) =>
|
|
176
391
|
getMediaType(f)?.startsWith("video/"),
|
|
177
392
|
);
|
|
@@ -185,21 +400,119 @@ class FalVideoModel implements VideoModelV3 {
|
|
|
185
400
|
if (audioFile) {
|
|
186
401
|
input.audio_url = await fileToUrl(audioFile);
|
|
187
402
|
}
|
|
403
|
+
} else if (isMotionControl) {
|
|
404
|
+
// Motion control: image + reference video input
|
|
405
|
+
if (prompt) {
|
|
406
|
+
input.prompt = prompt;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const imageFile = files?.find((f) =>
|
|
410
|
+
getMediaType(f)?.startsWith("image/"),
|
|
411
|
+
);
|
|
412
|
+
const videoFile = files?.find((f) =>
|
|
413
|
+
getMediaType(f)?.startsWith("video/"),
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
if (imageFile) {
|
|
417
|
+
input.image_url = await fileToUrl(imageFile);
|
|
418
|
+
}
|
|
419
|
+
if (videoFile) {
|
|
420
|
+
input.video_url = await fileToUrl(videoFile);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Default character orientation to 'video' for better motion matching
|
|
424
|
+
if (!input.character_orientation) {
|
|
425
|
+
input.character_orientation = "video";
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Default to keeping original sound
|
|
429
|
+
if (input.keep_original_sound === undefined) {
|
|
430
|
+
input.keep_original_sound = true;
|
|
431
|
+
}
|
|
432
|
+
} else if (isVideoEdit) {
|
|
433
|
+
// Video edit: video input + prompt for editing instruction
|
|
434
|
+
input.prompt = prompt;
|
|
435
|
+
|
|
436
|
+
const videoFile = files?.find((f) =>
|
|
437
|
+
getMediaType(f)?.startsWith("video/"),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
if (videoFile) {
|
|
441
|
+
input.video_url = await fileToUrl(videoFile);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Grok Imagine Edit supports resolution: "auto", "480p", "720p"
|
|
445
|
+
if (!input.resolution) {
|
|
446
|
+
input.resolution = "auto";
|
|
447
|
+
}
|
|
188
448
|
} else {
|
|
449
|
+
// Standard video generation
|
|
189
450
|
input.prompt = prompt;
|
|
190
|
-
|
|
451
|
+
|
|
452
|
+
// LTX-2 uses num_frames instead of duration, and has different defaults
|
|
453
|
+
if (isLtx2) {
|
|
454
|
+
// LTX-2: convert duration to num_frames (25fps default)
|
|
455
|
+
// Always set num_frames from duration unless explicitly provided via providerOptions
|
|
456
|
+
if (input.num_frames === undefined) {
|
|
457
|
+
const fps = (input.fps as number) ?? 25;
|
|
458
|
+
const durationSec = duration ?? 5; // default 5 seconds
|
|
459
|
+
input.num_frames = Math.round(durationSec * fps);
|
|
460
|
+
}
|
|
461
|
+
// LTX-2 uses video_size instead of aspect_ratio
|
|
462
|
+
if (input.video_size === undefined) {
|
|
463
|
+
input.video_size = "auto";
|
|
464
|
+
}
|
|
465
|
+
} else if (isKlingV26) {
|
|
466
|
+
// Duration must be string "5" or "10" for Kling v2.6
|
|
467
|
+
input.duration = String(duration ?? 5);
|
|
468
|
+
} else if (isGrokImagine) {
|
|
469
|
+
// Grok Imagine: duration 1-15 seconds (default 6)
|
|
470
|
+
input.duration = duration ?? 6;
|
|
471
|
+
// Grok Imagine supports resolution: "480p", "720p" (default "720p")
|
|
472
|
+
if (!input.resolution) {
|
|
473
|
+
input.resolution = "720p";
|
|
474
|
+
}
|
|
475
|
+
} else {
|
|
476
|
+
input.duration = duration ?? 5;
|
|
477
|
+
}
|
|
191
478
|
|
|
192
479
|
if (hasImageInput && files) {
|
|
193
|
-
const
|
|
480
|
+
const imageFiles = files.filter((f) =>
|
|
194
481
|
getMediaType(f)?.startsWith("image/"),
|
|
195
482
|
);
|
|
196
|
-
if (
|
|
197
|
-
|
|
483
|
+
if (imageFiles.length > 0) {
|
|
484
|
+
// First image is start image
|
|
485
|
+
input.image_url = await fileToUrl(imageFiles[0]!);
|
|
486
|
+
// Second image (if provided) is end image for Kling v2.6 and LTX-2
|
|
487
|
+
if ((isKlingV26 || isLtx2) && imageFiles.length > 1) {
|
|
488
|
+
input.end_image_url = await fileToUrl(imageFiles[1]!);
|
|
489
|
+
}
|
|
198
490
|
}
|
|
199
|
-
} else {
|
|
491
|
+
} else if (!isLtx2) {
|
|
492
|
+
// LTX-2 uses video_size, not aspect_ratio
|
|
200
493
|
input.aspect_ratio = aspectRatio ?? "16:9";
|
|
201
494
|
}
|
|
202
495
|
|
|
496
|
+
// Kling v2.6 and LTX-2 support native audio generation
|
|
497
|
+
if (isKlingV26 || isLtx2) {
|
|
498
|
+
// Default to generating audio unless explicitly disabled
|
|
499
|
+
if (input.generate_audio === undefined) {
|
|
500
|
+
input.generate_audio = true;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// LTX-2 specific defaults
|
|
505
|
+
if (isLtx2) {
|
|
506
|
+
// Enable multiscale for better coherence (default: true)
|
|
507
|
+
if (input.use_multiscale === undefined) {
|
|
508
|
+
input.use_multiscale = true;
|
|
509
|
+
}
|
|
510
|
+
// Enable prompt expansion for better results (default: true)
|
|
511
|
+
if (input.enable_prompt_expansion === undefined) {
|
|
512
|
+
input.enable_prompt_expansion = true;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
203
516
|
const audioFile = files?.find((f) =>
|
|
204
517
|
getMediaType(f)?.startsWith("audio/"),
|
|
205
518
|
);
|
|
@@ -208,12 +521,17 @@ class FalVideoModel implements VideoModelV3 {
|
|
|
208
521
|
}
|
|
209
522
|
}
|
|
210
523
|
|
|
524
|
+
// LTX-2 supports seed, other models don't
|
|
211
525
|
if (options.seed !== undefined) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
526
|
+
if (isLtx2) {
|
|
527
|
+
input.seed = options.seed;
|
|
528
|
+
} else {
|
|
529
|
+
warnings.push({
|
|
530
|
+
type: "unsupported",
|
|
531
|
+
feature: "seed",
|
|
532
|
+
details: "Seed is not supported by this model",
|
|
533
|
+
});
|
|
534
|
+
}
|
|
217
535
|
}
|
|
218
536
|
|
|
219
537
|
if (options.resolution !== undefined) {
|
|
@@ -224,18 +542,37 @@ class FalVideoModel implements VideoModelV3 {
|
|
|
224
542
|
});
|
|
225
543
|
}
|
|
226
544
|
|
|
545
|
+
// LTX-2 supports fps configuration
|
|
227
546
|
if (options.fps !== undefined) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
547
|
+
if (isLtx2) {
|
|
548
|
+
input.fps = options.fps;
|
|
549
|
+
} else {
|
|
550
|
+
warnings.push({
|
|
551
|
+
type: "unsupported",
|
|
552
|
+
feature: "fps",
|
|
553
|
+
details: "FPS is not configurable for this model",
|
|
554
|
+
});
|
|
555
|
+
}
|
|
233
556
|
}
|
|
234
557
|
|
|
235
|
-
const
|
|
558
|
+
const stableKey =
|
|
559
|
+
fileHashes.length > 0
|
|
560
|
+
? JSON.stringify({
|
|
561
|
+
endpoint,
|
|
562
|
+
prompt,
|
|
563
|
+
duration,
|
|
564
|
+
aspectRatio,
|
|
565
|
+
providerOptions,
|
|
566
|
+
modelId: this.modelId,
|
|
567
|
+
fileHashes,
|
|
568
|
+
})
|
|
569
|
+
: undefined;
|
|
570
|
+
|
|
571
|
+
const result = await executeWithQueueRecovery<{ data: unknown }>(
|
|
572
|
+
endpoint,
|
|
236
573
|
input,
|
|
237
|
-
logs: true,
|
|
238
|
-
|
|
574
|
+
{ logs: true, stableKey },
|
|
575
|
+
);
|
|
239
576
|
|
|
240
577
|
const data = result.data as { video?: { url?: string } };
|
|
241
578
|
const videoUrl = data?.video?.url;
|
|
@@ -278,6 +615,22 @@ class FalVideoModel implements VideoModelV3 {
|
|
|
278
615
|
|
|
279
616
|
return LIPSYNC_MODELS[this.modelId] ?? this.modelId;
|
|
280
617
|
}
|
|
618
|
+
|
|
619
|
+
private resolveMotionControlEndpoint(): string {
|
|
620
|
+
if (this.modelId.startsWith("raw:")) {
|
|
621
|
+
return this.modelId.slice(4);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return MOTION_CONTROL_MODELS[this.modelId] ?? this.modelId;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private resolveVideoEditEndpoint(): string {
|
|
628
|
+
if (this.modelId.startsWith("raw:")) {
|
|
629
|
+
return this.modelId.slice(4);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return VIDEO_EDIT_MODELS[this.modelId] ?? this.modelId;
|
|
633
|
+
}
|
|
281
634
|
}
|
|
282
635
|
|
|
283
636
|
class FalImageModel implements ImageModelV3 {
|
|
@@ -303,12 +656,28 @@ class FalImageModel implements ImageModelV3 {
|
|
|
303
656
|
} = options;
|
|
304
657
|
const warnings: SharedV3Warning[] = [];
|
|
305
658
|
|
|
659
|
+
const isQwenAngles = this.modelId === QWEN_ANGLES_MODEL;
|
|
660
|
+
|
|
306
661
|
const input: Record<string, unknown> = {
|
|
307
|
-
prompt,
|
|
308
662
|
num_images: n ?? 1,
|
|
309
663
|
...(providerOptions?.fal ?? {}),
|
|
310
664
|
};
|
|
311
665
|
|
|
666
|
+
// Qwen Angles uses additional_prompt instead of prompt
|
|
667
|
+
if (isQwenAngles) {
|
|
668
|
+
if (prompt) {
|
|
669
|
+
input.additional_prompt = prompt;
|
|
670
|
+
}
|
|
671
|
+
// Qwen Angles supports "regular" or "none" acceleration, not "high"
|
|
672
|
+
if (!input.acceleration) {
|
|
673
|
+
input.acceleration = "regular";
|
|
674
|
+
}
|
|
675
|
+
} else {
|
|
676
|
+
input.prompt = prompt;
|
|
677
|
+
// Use high acceleration for faster queue processing on supported models (flux-schnell)
|
|
678
|
+
input.acceleration = "high";
|
|
679
|
+
}
|
|
680
|
+
|
|
312
681
|
const usesImageSize = IMAGE_SIZE_MODELS.has(this.modelId);
|
|
313
682
|
|
|
314
683
|
if (size) {
|
|
@@ -322,7 +691,21 @@ class FalImageModel implements ImageModelV3 {
|
|
|
322
691
|
}
|
|
323
692
|
|
|
324
693
|
if (aspectRatio) {
|
|
325
|
-
if (
|
|
694
|
+
if (isQwenAngles) {
|
|
695
|
+
// Convert aspect ratio to image_size dimensions for Qwen Angles
|
|
696
|
+
if (!input.image_size) {
|
|
697
|
+
const qwenSize = ASPECT_RATIO_TO_QWEN_SIZE[aspectRatio];
|
|
698
|
+
if (qwenSize) {
|
|
699
|
+
input.image_size = qwenSize;
|
|
700
|
+
} else {
|
|
701
|
+
warnings.push({
|
|
702
|
+
type: "unsupported",
|
|
703
|
+
feature: "aspectRatio",
|
|
704
|
+
details: `Aspect ratio "${aspectRatio}" not supported for qwen-angles, use one of: ${Object.keys(ASPECT_RATIO_TO_QWEN_SIZE).join(", ")}`,
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
} else if (usesImageSize) {
|
|
326
709
|
// Convert aspect ratio to image_size enum for models that require it
|
|
327
710
|
// Only set if size wasn't already provided
|
|
328
711
|
if (!input.image_size) {
|
|
@@ -347,33 +730,43 @@ class FalImageModel implements ImageModelV3 {
|
|
|
347
730
|
}
|
|
348
731
|
|
|
349
732
|
const hasFiles = files && files.length > 0;
|
|
350
|
-
|
|
733
|
+
const finalEndpoint = this.resolveEndpoint();
|
|
734
|
+
|
|
735
|
+
let stableKey: string | undefined;
|
|
736
|
+
if (hasFiles && files) {
|
|
737
|
+
const fileHashes = await computeFileHashes(files);
|
|
738
|
+
stableKey = JSON.stringify({
|
|
739
|
+
endpoint: finalEndpoint,
|
|
740
|
+
prompt,
|
|
741
|
+
n,
|
|
742
|
+
size,
|
|
743
|
+
aspectRatio,
|
|
744
|
+
seed,
|
|
745
|
+
providerOptions,
|
|
746
|
+
modelId: this.modelId,
|
|
747
|
+
fileHashes,
|
|
748
|
+
});
|
|
351
749
|
input.image_urls = await Promise.all(files.map((f) => fileToUrl(f)));
|
|
352
750
|
}
|
|
353
751
|
|
|
752
|
+
if (isQwenAngles && !input.image_urls) {
|
|
753
|
+
throw new Error("qwen-angles requires at least one image file");
|
|
754
|
+
}
|
|
755
|
+
|
|
354
756
|
const hasImageUrls =
|
|
355
757
|
hasFiles ||
|
|
356
758
|
!!(providerOptions?.fal as Record<string, unknown>)?.image_urls;
|
|
357
759
|
if (hasImageUrls) {
|
|
358
|
-
if (!files) {
|
|
760
|
+
if (!files && !isQwenAngles) {
|
|
359
761
|
throw new Error("No files provided");
|
|
360
762
|
}
|
|
361
763
|
}
|
|
362
764
|
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
// Debug: log the input being sent
|
|
366
|
-
if (IMAGE_SIZE_MODELS.has(this.modelId)) {
|
|
367
|
-
console.log(
|
|
368
|
-
"[fal-provider] seedream input:",
|
|
369
|
-
JSON.stringify(input, null, 2),
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const result = await fal.subscribe(finalEndpoint, {
|
|
765
|
+
const result = await executeWithQueueRecovery<{ data: unknown }>(
|
|
766
|
+
finalEndpoint,
|
|
374
767
|
input,
|
|
375
|
-
logs: true,
|
|
376
|
-
|
|
768
|
+
{ logs: true, stableKey },
|
|
769
|
+
);
|
|
377
770
|
|
|
378
771
|
const data = result.data as { images?: Array<{ url?: string }> };
|
|
379
772
|
const images = data?.images ?? [];
|
|
@@ -478,8 +871,10 @@ export interface FalProvider extends ProviderV3 {
|
|
|
478
871
|
}
|
|
479
872
|
|
|
480
873
|
export function createFal(settings: FalProviderSettings = {}): FalProvider {
|
|
481
|
-
|
|
482
|
-
|
|
874
|
+
const apiKey =
|
|
875
|
+
settings.apiKey ?? process.env.FAL_API_KEY ?? process.env.FAL_KEY;
|
|
876
|
+
if (apiKey) {
|
|
877
|
+
fal.config({ credentials: apiKey });
|
|
483
878
|
}
|
|
484
879
|
|
|
485
880
|
return {
|