@ai-sdk/fal 2.0.17 → 2.0.19

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.
@@ -0,0 +1,324 @@
1
+ import {
2
+ AISDKError,
3
+ type Experimental_VideoModelV3,
4
+ type SharedV3Warning,
5
+ } from '@ai-sdk/provider';
6
+ import {
7
+ combineHeaders,
8
+ convertImageModelFileToDataUri,
9
+ createJsonErrorResponseHandler,
10
+ createJsonResponseHandler,
11
+ delay,
12
+ getFromApi,
13
+ lazySchema,
14
+ parseProviderOptions,
15
+ postJsonToApi,
16
+ zodSchema,
17
+ } from '@ai-sdk/provider-utils';
18
+ import { z } from 'zod/v4';
19
+ import type { FalConfig } from './fal-config';
20
+ import { falErrorDataSchema, falFailedResponseHandler } from './fal-error';
21
+ import type { FalVideoModelId } from './fal-video-settings';
22
+
23
+ export type FalVideoProviderOptions = {
24
+ loop?: boolean | null;
25
+ motionStrength?: number | null;
26
+ pollIntervalMs?: number | null;
27
+ pollTimeoutMs?: number | null;
28
+ resolution?: string | null;
29
+ negativePrompt?: string | null;
30
+ promptOptimizer?: boolean | null;
31
+ [key: string]: unknown; // For passthrough
32
+ };
33
+
34
+ // Provider options schema for FAL video generation
35
+ export const falVideoProviderOptionsSchema = lazySchema(() =>
36
+ zodSchema(
37
+ z
38
+ .object({
39
+ // Video loop - only for Luma models
40
+ loop: z.boolean().nullish(),
41
+
42
+ // Motion strength (provider-specific)
43
+ motionStrength: z.number().min(0).max(1).nullish(),
44
+
45
+ // Polling configuration
46
+ pollIntervalMs: z.number().positive().nullish(),
47
+ pollTimeoutMs: z.number().positive().nullish(),
48
+
49
+ // Resolution (model-specific, e.g., '480p', '720p', '1080p')
50
+ resolution: z.string().nullish(),
51
+
52
+ // Model-specific parameters
53
+ negativePrompt: z.string().nullish(),
54
+ promptOptimizer: z.boolean().nullish(),
55
+ })
56
+ .passthrough(),
57
+ ),
58
+ );
59
+
60
+ interface FalVideoModelConfig extends FalConfig {
61
+ _internal?: {
62
+ currentDate?: () => Date;
63
+ };
64
+ }
65
+
66
+ export class FalVideoModel implements Experimental_VideoModelV3 {
67
+ readonly specificationVersion = 'v3';
68
+ readonly maxVideosPerCall = 1; // FAL video models support 1 video at a time
69
+
70
+ get provider(): string {
71
+ return this.config.provider;
72
+ }
73
+
74
+ private get normalizedModelId(): string {
75
+ return this.modelId.replace(/^fal-ai\//, '').replace(/^fal\//, '');
76
+ }
77
+
78
+ constructor(
79
+ readonly modelId: FalVideoModelId,
80
+ private readonly config: FalVideoModelConfig,
81
+ ) {}
82
+
83
+ async doGenerate(
84
+ options: Parameters<Experimental_VideoModelV3['doGenerate']>[0],
85
+ ): Promise<Awaited<ReturnType<Experimental_VideoModelV3['doGenerate']>>> {
86
+ const currentDate = this.config._internal?.currentDate?.() ?? new Date();
87
+ const warnings: SharedV3Warning[] = [];
88
+
89
+ const falOptions = (await parseProviderOptions({
90
+ provider: 'fal',
91
+ providerOptions: options.providerOptions,
92
+ schema: falVideoProviderOptionsSchema,
93
+ })) as FalVideoProviderOptions | undefined;
94
+
95
+ const body: Record<string, unknown> = {};
96
+
97
+ if (options.prompt != null) {
98
+ body.prompt = options.prompt;
99
+ }
100
+
101
+ if (options.image != null) {
102
+ if (options.image.type === 'url') {
103
+ body.image_url = options.image.url;
104
+ } else {
105
+ body.image_url = convertImageModelFileToDataUri(options.image);
106
+ }
107
+ }
108
+
109
+ if (options.aspectRatio) {
110
+ body.aspect_ratio = options.aspectRatio;
111
+ }
112
+
113
+ if (options.duration) {
114
+ body.duration = `${options.duration}s`;
115
+ }
116
+
117
+ if (options.seed) {
118
+ body.seed = options.seed;
119
+ }
120
+
121
+ if (falOptions != null) {
122
+ const opts = falOptions;
123
+ if (opts.loop !== undefined && opts.loop !== null) {
124
+ body.loop = opts.loop;
125
+ }
126
+ if (opts.motionStrength !== undefined && opts.motionStrength !== null) {
127
+ body.motion_strength = opts.motionStrength;
128
+ }
129
+ if (opts.resolution !== undefined && opts.resolution !== null) {
130
+ body.resolution = opts.resolution;
131
+ }
132
+ if (opts.negativePrompt !== undefined && opts.negativePrompt !== null) {
133
+ body.negative_prompt = opts.negativePrompt;
134
+ }
135
+ if (opts.promptOptimizer !== undefined && opts.promptOptimizer !== null) {
136
+ body.prompt_optimizer = opts.promptOptimizer;
137
+ }
138
+
139
+ for (const [key, value] of Object.entries(opts)) {
140
+ if (
141
+ ![
142
+ 'loop',
143
+ 'motionStrength',
144
+ 'pollIntervalMs',
145
+ 'pollTimeoutMs',
146
+ 'resolution',
147
+ 'negativePrompt',
148
+ 'promptOptimizer',
149
+ ].includes(key)
150
+ ) {
151
+ body[key] = value;
152
+ }
153
+ }
154
+ }
155
+
156
+ const { value: queueResponse } = await postJsonToApi({
157
+ url: this.config.url({
158
+ path: `https://queue.fal.run/fal-ai/${this.normalizedModelId}`,
159
+ modelId: this.modelId,
160
+ }),
161
+ headers: combineHeaders(this.config.headers(), options.headers),
162
+ body,
163
+ failedResponseHandler: falFailedResponseHandler,
164
+ successfulResponseHandler:
165
+ createJsonResponseHandler(falJobResponseSchema),
166
+ abortSignal: options.abortSignal,
167
+ fetch: this.config.fetch,
168
+ });
169
+
170
+ const responseUrl = queueResponse.response_url;
171
+ if (!responseUrl) {
172
+ throw new AISDKError({
173
+ name: 'FAL_VIDEO_GENERATION_ERROR',
174
+ message: 'No response URL returned from queue endpoint',
175
+ });
176
+ }
177
+
178
+ const pollIntervalMs = falOptions?.pollIntervalMs ?? 2000; // 2 seconds
179
+ const pollTimeoutMs = falOptions?.pollTimeoutMs ?? 300000; // 5 minutes
180
+ const startTime = Date.now();
181
+ let response: FalVideoResponse;
182
+ let responseHeaders: Record<string, string> | undefined;
183
+
184
+ while (true) {
185
+ try {
186
+ const { value: statusResponse, responseHeaders: statusHeaders } =
187
+ await getFromApi({
188
+ url: this.config.url({
189
+ path: responseUrl,
190
+ modelId: this.modelId,
191
+ }),
192
+ headers: combineHeaders(this.config.headers(), options.headers),
193
+ failedResponseHandler: async ({
194
+ response,
195
+ url,
196
+ requestBodyValues,
197
+ }) => {
198
+ const body = await response.clone().json();
199
+
200
+ if (body.detail === 'Request is still in progress') {
201
+ return {
202
+ value: new Error('Request is still in progress'),
203
+ rawValue: body,
204
+ responseHeaders: {},
205
+ };
206
+ }
207
+
208
+ return createJsonErrorResponseHandler({
209
+ errorSchema: falErrorDataSchema,
210
+ errorToMessage: data => data.error.message,
211
+ })({ response, url, requestBodyValues });
212
+ },
213
+ successfulResponseHandler: createJsonResponseHandler(
214
+ falVideoResponseSchema,
215
+ ),
216
+ abortSignal: options.abortSignal,
217
+ fetch: this.config.fetch,
218
+ });
219
+
220
+ response = statusResponse;
221
+ responseHeaders = statusHeaders;
222
+ break;
223
+ } catch (error) {
224
+ if (
225
+ error instanceof Error &&
226
+ error.message === 'Request is still in progress'
227
+ ) {
228
+ // Continue polling
229
+ } else {
230
+ throw error;
231
+ }
232
+ }
233
+
234
+ if (Date.now() - startTime > pollTimeoutMs) {
235
+ throw new AISDKError({
236
+ name: 'FAL_VIDEO_GENERATION_TIMEOUT',
237
+ message: `Video generation request timed out after ${pollTimeoutMs}ms`,
238
+ });
239
+ }
240
+
241
+ await delay(pollIntervalMs);
242
+
243
+ if (options.abortSignal?.aborted) {
244
+ throw new AISDKError({
245
+ name: 'FAL_VIDEO_GENERATION_ABORTED',
246
+ message: 'Video generation request was aborted',
247
+ });
248
+ }
249
+ }
250
+
251
+ const videoUrl = response.video?.url;
252
+
253
+ if (!videoUrl || !response.video) {
254
+ throw new AISDKError({
255
+ name: 'FAL_VIDEO_GENERATION_ERROR',
256
+ message: 'No video URL in response',
257
+ });
258
+ }
259
+
260
+ return {
261
+ videos: [
262
+ {
263
+ type: 'url',
264
+ url: videoUrl,
265
+ mediaType: response.video.content_type || 'video/mp4',
266
+ },
267
+ ],
268
+ warnings,
269
+ response: {
270
+ timestamp: currentDate,
271
+ modelId: this.modelId,
272
+ headers: responseHeaders,
273
+ },
274
+ providerMetadata: {
275
+ fal: {
276
+ videos: [
277
+ {
278
+ url: videoUrl,
279
+ width: response.video.width,
280
+ height: response.video.height,
281
+ duration: response.video.duration,
282
+ fps: response.video.fps,
283
+ contentType: response.video.content_type,
284
+ },
285
+ ],
286
+ ...(response.seed !== undefined ? { seed: response.seed } : {}),
287
+ ...(response.timings ? { timings: response.timings } : {}),
288
+ ...(response.has_nsfw_concepts !== undefined
289
+ ? { has_nsfw_concepts: response.has_nsfw_concepts }
290
+ : {}),
291
+ ...(response.prompt ? { prompt: response.prompt } : {}),
292
+ },
293
+ },
294
+ };
295
+ }
296
+ }
297
+
298
+ const falJobResponseSchema = z.object({
299
+ request_id: z.string().nullish(),
300
+ response_url: z.string().nullish(),
301
+ });
302
+
303
+ const falVideoResponseSchema = z.object({
304
+ video: z
305
+ .object({
306
+ url: z.string(),
307
+ width: z.number().nullish(),
308
+ height: z.number().nullish(),
309
+ duration: z.number().nullish(),
310
+ fps: z.number().nullish(),
311
+ content_type: z.string().nullish(),
312
+ })
313
+ .nullish(),
314
+ seed: z.number().nullish(),
315
+ timings: z
316
+ .object({
317
+ inference: z.number().nullish(),
318
+ })
319
+ .nullish(),
320
+ has_nsfw_concepts: z.array(z.boolean()).nullish(),
321
+ prompt: z.string().nullish(),
322
+ });
323
+
324
+ type FalVideoResponse = z.infer<typeof falVideoResponseSchema>;
@@ -0,0 +1,8 @@
1
+ export type FalVideoModelId =
2
+ | 'luma-dream-machine'
3
+ | 'luma-ray-2'
4
+ | 'luma-ray-2-flash'
5
+ | 'minimax-video'
6
+ | 'minimax-video-01'
7
+ | 'hunyuan-video'
8
+ | (string & {});
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
- export { createFal, fal } from './fal-provider';
2
1
  export type { FalProvider, FalProviderSettings } from './fal-provider';
2
+ export { createFal, fal } from './fal-provider';
3
3
  export type { FalImageProviderOptions } from './fal-image-options';
4
+ export type { FalVideoProviderOptions } from './fal-video-model';
5
+ export type { FalVideoModelId } from './fal-video-settings';
4
6
  export { VERSION } from './version';