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.
Files changed (114) hide show
  1. package/.env.example +6 -0
  2. package/README.md +483 -61
  3. package/assets/fonts/TikTokSans-Bold.ttf +0 -0
  4. package/examples/grok-imagine-test.tsx +155 -0
  5. package/launch-videos/06-kawaii-fruits.tsx +93 -0
  6. package/launch-videos/07-ugc-weight-loss.tsx +132 -0
  7. package/launch-videos/08-talking-head-varg.tsx +107 -0
  8. package/launch-videos/09-girl.tsx +160 -0
  9. package/launch-videos/README.md +42 -0
  10. package/package.json +10 -4
  11. package/pipeline/cookbooks/round-video-character.md +1 -1
  12. package/skills/varg-video-generation/SKILL.md +224 -0
  13. package/skills/varg-video-generation/references/templates.md +380 -0
  14. package/skills/varg-video-generation/scripts/setup.ts +265 -0
  15. package/src/ai-sdk/cache.ts +1 -3
  16. package/src/ai-sdk/examples/google-image.ts +62 -0
  17. package/src/ai-sdk/index.ts +10 -0
  18. package/src/ai-sdk/middleware/wrap-image-model.ts +4 -21
  19. package/src/ai-sdk/middleware/wrap-music-model.ts +4 -16
  20. package/src/ai-sdk/middleware/wrap-video-model.ts +5 -17
  21. package/src/ai-sdk/providers/CONTRIBUTING.md +457 -0
  22. package/src/ai-sdk/providers/editly/backends/index.ts +8 -0
  23. package/src/ai-sdk/providers/editly/backends/local.ts +94 -0
  24. package/src/ai-sdk/providers/editly/backends/types.ts +74 -0
  25. package/src/ai-sdk/providers/editly/editly.test.ts +49 -1
  26. package/src/ai-sdk/providers/editly/index.ts +164 -80
  27. package/src/ai-sdk/providers/editly/layers.ts +58 -6
  28. package/src/ai-sdk/providers/editly/rendi/editly-with-rendi-backend.test.ts +335 -0
  29. package/src/ai-sdk/providers/editly/rendi/index.ts +289 -0
  30. package/src/ai-sdk/providers/editly/rendi/rendi.test.ts +35 -0
  31. package/src/ai-sdk/providers/editly/types.ts +30 -0
  32. package/src/ai-sdk/providers/elevenlabs.ts +10 -2
  33. package/src/ai-sdk/providers/fal.test.ts +214 -0
  34. package/src/ai-sdk/providers/fal.ts +435 -40
  35. package/src/ai-sdk/providers/google.ts +423 -0
  36. package/src/ai-sdk/providers/together.ts +191 -0
  37. package/src/cli/commands/find.tsx +1 -0
  38. package/src/cli/commands/frame.tsx +616 -0
  39. package/src/cli/commands/hello.ts +85 -0
  40. package/src/cli/commands/help.tsx +18 -30
  41. package/src/cli/commands/index.ts +11 -2
  42. package/src/cli/commands/init.tsx +570 -0
  43. package/src/cli/commands/list.tsx +1 -0
  44. package/src/cli/commands/render.tsx +322 -76
  45. package/src/cli/commands/run.tsx +1 -0
  46. package/src/cli/commands/storyboard.tsx +1714 -0
  47. package/src/cli/commands/which.tsx +1 -0
  48. package/src/cli/index.ts +23 -4
  49. package/src/cli/ui/components/Badge.tsx +1 -0
  50. package/src/cli/ui/components/DataTable.tsx +1 -0
  51. package/src/cli/ui/components/Header.tsx +1 -0
  52. package/src/cli/ui/components/HelpBlock.tsx +1 -0
  53. package/src/cli/ui/components/KeyValue.tsx +1 -0
  54. package/src/cli/ui/components/OptionRow.tsx +1 -0
  55. package/src/cli/ui/components/Separator.tsx +1 -0
  56. package/src/cli/ui/components/StatusBox.tsx +1 -0
  57. package/src/cli/ui/components/VargBox.tsx +1 -0
  58. package/src/cli/ui/components/VargProgress.tsx +1 -0
  59. package/src/cli/ui/components/VargSpinner.tsx +1 -0
  60. package/src/cli/ui/components/VargText.tsx +1 -0
  61. package/src/definitions/actions/grok-edit.ts +133 -0
  62. package/src/definitions/actions/index.ts +16 -0
  63. package/src/definitions/actions/qwen-angles.ts +218 -0
  64. package/src/index.ts +1 -0
  65. package/src/providers/fal.ts +196 -0
  66. package/src/react/assets.ts +9 -0
  67. package/src/react/elements.ts +0 -5
  68. package/src/react/examples/branching.tsx +6 -4
  69. package/src/react/examples/character-video.tsx +13 -10
  70. package/src/react/examples/local-files-test.tsx +19 -0
  71. package/src/react/examples/ltx2-test.tsx +25 -0
  72. package/src/react/examples/madi.tsx +13 -10
  73. package/src/react/examples/mcmeows.tsx +40 -0
  74. package/src/react/examples/music-defaults.tsx +24 -0
  75. package/src/react/examples/quickstart-test.tsx +101 -0
  76. package/src/react/examples/qwen-angles-test.tsx +72 -0
  77. package/src/react/index.ts +3 -3
  78. package/src/react/layouts/grid.tsx +1 -1
  79. package/src/react/layouts/index.ts +2 -1
  80. package/src/react/layouts/slot.tsx +85 -0
  81. package/src/react/layouts/split.tsx +18 -0
  82. package/src/react/react.test.ts +60 -11
  83. package/src/react/renderers/burn-captions.ts +95 -0
  84. package/src/react/renderers/cache.test.ts +182 -0
  85. package/src/react/renderers/captions.ts +25 -6
  86. package/src/react/renderers/clip.ts +56 -25
  87. package/src/react/renderers/context.ts +5 -2
  88. package/src/react/renderers/image.ts +5 -2
  89. package/src/react/renderers/index.ts +0 -1
  90. package/src/react/renderers/music.ts +8 -3
  91. package/src/react/renderers/packshot/blinking-button.ts +413 -0
  92. package/src/react/renderers/packshot.ts +170 -8
  93. package/src/react/renderers/progress.ts +4 -3
  94. package/src/react/renderers/render.ts +127 -71
  95. package/src/react/renderers/speech.ts +2 -2
  96. package/src/react/renderers/split.ts +34 -13
  97. package/src/react/renderers/utils.test.ts +80 -0
  98. package/src/react/renderers/utils.ts +37 -1
  99. package/src/react/renderers/video.ts +47 -9
  100. package/src/react/types.ts +70 -17
  101. package/src/studio/stages.ts +40 -39
  102. package/src/studio/step-renderer.ts +14 -24
  103. package/src/studio/ui/index.html +2 -2
  104. package/src/tests/all.test.ts +4 -4
  105. package/src/tests/index.ts +1 -1
  106. package/test-slot-grid.tsx +19 -0
  107. package/test-slot-userland.tsx +30 -0
  108. package/test-sync-v2.ts +30 -0
  109. package/test-sync-v2.tsx +29 -0
  110. package/tsconfig.json +1 -1
  111. package/video.tsx +7 -0
  112. package/src/ai-sdk/providers/editly/ffmpeg.ts +0 -60
  113. package/src/react/renderers/animate.ts +0 -59
  114. /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(["seedream-v4.5/edit"]);
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 _hasVideoInput = files?.some((f) =>
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
- : this.resolveEndpoint(hasImageInput ?? false);
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
- input.duration = duration ?? 5;
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 imageFile = files.find((f) =>
480
+ const imageFiles = files.filter((f) =>
194
481
  getMediaType(f)?.startsWith("image/"),
195
482
  );
196
- if (imageFile) {
197
- input.image_url = await fileToUrl(imageFile);
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
- warnings.push({
213
- type: "unsupported",
214
- feature: "seed",
215
- details: "Seed is not supported by this model",
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
- warnings.push({
229
- type: "unsupported",
230
- feature: "fps",
231
- details: "FPS is not configurable for this model",
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 result = await fal.subscribe(endpoint, {
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 (usesImageSize) {
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
- if (hasFiles) {
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 finalEndpoint = this.resolveEndpoint();
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
- if (settings.apiKey) {
482
- fal.config({ credentials: settings.apiKey });
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 {