@tanstack/ai-fal 0.10.1 → 0.12.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.
@@ -1,6 +1,6 @@
1
- import { BaseVideoAdapter } from '@tanstack/ai/adapters';
1
+ import { BaseVideoAdapter, DurationOptions } from '@tanstack/ai/adapters';
2
2
  import { VideoGenerationOptions, VideoJobResult, VideoStatusResult, VideoUrlResult } from '@tanstack/ai';
3
- import { FalModel, FalModelVideoSize, FalVideoPromptModalitiesFor, FalVideoProviderOptions } from '../model-meta.js';
3
+ import { FalModel, FalModelVideoDuration, FalModelVideoSize, FalVideoPromptModalitiesFor, FalVideoProviderOptions } from '../model-meta.js';
4
4
  import { FalClientConfig } from '../utils/client.js';
5
5
  /**
6
6
  * fal.ai video generation adapter.
@@ -11,11 +11,13 @@ import { FalClientConfig } from '../utils/client.js';
11
11
  *
12
12
  * @experimental Video generation is an experimental feature and may change.
13
13
  */
14
- export declare class FalVideoAdapter<TModel extends FalModel> extends BaseVideoAdapter<TModel, FalVideoProviderOptions<TModel>, Record<TModel, FalVideoProviderOptions<TModel>>, Record<TModel, FalModelVideoSize<TModel>>, Record<TModel, FalVideoPromptModalitiesFor<TModel>>> {
14
+ export declare class FalVideoAdapter<TModel extends FalModel> extends BaseVideoAdapter<TModel, FalVideoProviderOptions<TModel>, Record<TModel, FalVideoProviderOptions<TModel>>, Record<TModel, FalModelVideoSize<TModel>>, Record<TModel, FalVideoPromptModalitiesFor<TModel>>, Record<TModel, FalModelVideoDuration<TModel>>> {
15
15
  readonly kind: "video";
16
16
  readonly name: "fal";
17
17
  constructor(model: TModel, config?: FalClientConfig);
18
- createVideoJob(options: VideoGenerationOptions<FalVideoProviderOptions<TModel>, FalModelVideoSize<TModel>>): Promise<VideoJobResult>;
18
+ createVideoJob(options: VideoGenerationOptions<FalVideoProviderOptions<TModel>, FalModelVideoSize<TModel>, FalModelVideoDuration<TModel>>): Promise<VideoJobResult>;
19
+ availableDurations(): DurationOptions<FalModelVideoDuration<TModel>>;
20
+ snapDuration(seconds: number): FalModelVideoDuration<TModel> | undefined;
19
21
  getVideoStatus(jobId: string): Promise<VideoStatusResult>;
20
22
  getVideoUrl(jobId: string): Promise<VideoUrlResult>;
21
23
  protected generateId(): string;
@@ -1,10 +1,10 @@
1
1
  import { buildFalUsage, takeBillableUnits } from "../utils/billing.js";
2
2
  import { configureFalClient, generateId } from "../utils/client.js";
3
3
  import { mapImageInputsToFalVideoFields } from "../image/image-inputs.js";
4
- import { mapVideoSizeToFalFormat } from "../video/video-provider-options.js";
4
+ import { getFalVideoDurationOptions, mapVideoSizeToFalFormat } from "../video/video-provider-options.js";
5
5
  import { fal } from "@fal-ai/client";
6
6
  import { resolveMediaPrompt } from "@tanstack/ai";
7
- import { BaseVideoAdapter } from "@tanstack/ai/adapters";
7
+ import { BaseVideoAdapter, snapToDurationOption } from "@tanstack/ai/adapters";
8
8
  //#region src/adapters/video.ts
9
9
  /**
10
10
  * Map video conditioning inputs onto fal field names.
@@ -86,7 +86,7 @@ var FalVideoAdapter = class extends BaseVideoAdapter {
86
86
  ...audioFields,
87
87
  ...modelOptions,
88
88
  ...resolved.text ? { prompt: resolved.text } : {},
89
- ...duration ? { duration } : {}
89
+ ...duration !== void 0 ? { duration } : {}
90
90
  };
91
91
  const { request_id } = await fal.queue.submit(this.model, {
92
92
  input,
@@ -104,6 +104,12 @@ var FalVideoAdapter = class extends BaseVideoAdapter {
104
104
  throw error;
105
105
  }
106
106
  }
107
+ availableDurations() {
108
+ return getFalVideoDurationOptions(this.model);
109
+ }
110
+ snapDuration(seconds) {
111
+ return snapToDurationOption(seconds, this.availableDurations());
112
+ }
107
113
  async getVideoStatus(jobId) {
108
114
  const statusResponse = await fal.queue.status(this.model, {
109
115
  requestId: jobId,
@@ -1 +1 @@
1
- {"version":3,"file":"video.js","names":[],"sources":["../../../src/adapters/video.ts"],"sourcesContent":["import { fal } from '@fal-ai/client'\nimport { resolveMediaPrompt } from '@tanstack/ai'\nimport { BaseVideoAdapter } from '@tanstack/ai/adapters'\nimport {\n configureFalClient,\n generateId as utilGenerateId,\n} from '../utils/client'\nimport { buildFalUsage, takeBillableUnits } from '../utils/billing'\nimport { mapVideoSizeToFalFormat } from '../video/video-provider-options'\nimport { mapImageInputsToFalVideoFields } from '../image/image-inputs'\nimport type {\n AudioPart,\n MediaInputMetadata,\n VideoGenerationOptions,\n VideoJobResult,\n VideoPart,\n VideoStatusResult,\n VideoUrlResult,\n} from '@tanstack/ai'\nimport type {\n FalModel,\n FalModelInput,\n FalModelVideoSize,\n FalVideoPromptModalitiesFor,\n FalVideoProviderOptions,\n} from '../model-meta'\nimport type { FalClientConfig } from '../utils/client'\n\n/**\n * Map video conditioning inputs onto fal field names.\n * Video-to-video endpoints on fal almost universally use `video_url`; the\n * occasional model takes `video_urls` (rare). Mirror the image-input logic\n * positionally with a `reference` role escape hatch via `reference_video_urls`.\n */\nfunction mapVideoInputsToFalFields(\n videoInputs?: ReadonlyArray<VideoPart<MediaInputMetadata>>,\n): Record<string, unknown> {\n if (!videoInputs || videoInputs.length === 0) return {}\n const references: Array<string> = []\n const sources: Array<string> = []\n for (const part of videoInputs) {\n const url = videoPartToUrl(part)\n if (\n part.metadata?.role === 'reference' ||\n part.metadata?.role === 'character'\n ) {\n references.push(url)\n } else {\n sources.push(url)\n }\n }\n const out: Record<string, unknown> = {}\n if (references.length > 0) out.reference_video_urls = references\n if (sources.length === 1) {\n out.video_url = sources[0]\n } else if (sources.length > 1) {\n out.video_urls = sources\n }\n return out\n}\n\nfunction mapAudioInputsToFalFields(\n audioInputs?: ReadonlyArray<AudioPart<MediaInputMetadata>>,\n): Record<string, unknown> {\n if (!audioInputs || audioInputs.length === 0) return {}\n const [part, ...rest] = audioInputs\n if (!part || rest.length > 0) {\n throw new Error(\n `fal: exactly one audio prompt part is supported (received ${audioInputs.length}).`,\n )\n }\n return {\n audio_url:\n part.source.type === 'url'\n ? part.source.value\n : `data:${part.source.mimeType};base64,${part.source.value}`,\n }\n}\n\nfunction videoPartToUrl(part: VideoPart<MediaInputMetadata>): string {\n return part.source.type === 'url'\n ? part.source.value\n : `data:${part.source.mimeType};base64,${part.source.value}`\n}\n\ntype FalQueueStatus = 'IN_QUEUE' | 'IN_PROGRESS' | 'COMPLETED'\n\ninterface FalStatusResponse {\n status: FalQueueStatus\n queue_position?: number\n logs?: Array<{ message: string }>\n}\n\ninterface FalVideoResultData {\n video?: { url: string }\n video_url?: string\n}\n\n/**\n * Maps fal.ai queue status to TanStack AI video status.\n *\n * Note: fal.ai does not return a FAILED queue status. Errors surface\n * as exceptions when fetching results from a COMPLETED job (e.g. 422\n * validation errors). Those are handled in getVideoUrl().\n */\nfunction mapFalStatusToVideoStatus(\n falStatus: FalQueueStatus,\n): VideoStatusResult['status'] {\n switch (falStatus) {\n case 'IN_QUEUE':\n return 'pending'\n case 'IN_PROGRESS':\n return 'processing'\n case 'COMPLETED':\n return 'completed'\n default:\n return 'processing'\n }\n}\n\n/**\n * fal.ai video generation adapter.\n * Supports MiniMax, Luma, Kling, Hunyuan, and other fal.ai video models.\n *\n * Uses fal.ai's comprehensive type system to provide autocomplete\n * and type safety for all supported video models.\n *\n * @experimental Video generation is an experimental feature and may change.\n */\nexport class FalVideoAdapter<TModel extends FalModel> extends BaseVideoAdapter<\n TModel,\n FalVideoProviderOptions<TModel>,\n Record<TModel, FalVideoProviderOptions<TModel>>,\n Record<TModel, FalModelVideoSize<TModel>>,\n Record<TModel, FalVideoPromptModalitiesFor<TModel>>\n> {\n override readonly kind = 'video' as const\n readonly name = 'fal' as const\n\n constructor(model: TModel, config?: FalClientConfig) {\n super({}, model)\n configureFalClient(config)\n }\n\n async createVideoJob(\n options: VideoGenerationOptions<\n FalVideoProviderOptions<TModel>,\n FalModelVideoSize<TModel>\n >,\n ): Promise<VideoJobResult> {\n const { size, duration, modelOptions, logger } = options\n\n logger.request(`activity=generateVideo provider=fal model=${this.model}`, {\n provider: 'fal',\n model: this.model,\n })\n\n try {\n const resolved = resolveMediaPrompt(options.prompt)\n const sizeParams = mapVideoSizeToFalFormat(size)\n const inputImageFields = mapImageInputsToFalVideoFields(\n this.model,\n resolved.images,\n )\n const videoFields = mapVideoInputsToFalFields(resolved.videos)\n const audioFields = mapAudioInputsToFalFields(resolved.audios)\n\n const input = {\n ...sizeParams,\n ...inputImageFields,\n ...videoFields,\n ...audioFields,\n // modelOptions applied after derived media fields so explicit user\n // overrides (video_url, reference_video_urls, audio_url, ...) win.\n ...modelOptions,\n // Media-only prompts omit the prompt field rather than sending an\n // empty string (e.g. pure image-to-video endpoints).\n ...(resolved.text ? { prompt: resolved.text } : {}),\n ...(duration ? { duration } : {}),\n } as FalModelInput<TModel>\n\n // Submit to queue and get request ID. Request-specific abortSignal only —\n // never via fal.config() (global; would cancel concurrent jobs).\n const { request_id } = await fal.queue.submit(this.model, {\n input,\n ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),\n })\n\n return {\n jobId: request_id,\n model: this.model,\n }\n } catch (error) {\n logger.errors('fal.createVideoJob fatal', {\n error,\n source: 'fal.createVideoJob',\n })\n throw error\n }\n }\n\n async getVideoStatus(jobId: string): Promise<VideoStatusResult> {\n const statusResponse = (await fal.queue.status(this.model, {\n requestId: jobId,\n logs: true,\n })) as FalStatusResponse\n\n return {\n jobId,\n status: mapFalStatusToVideoStatus(statusResponse.status),\n ...(statusResponse.queue_position != null\n ? {\n progress: Math.max(0, 100 - statusResponse.queue_position * 10),\n }\n : {}),\n }\n }\n\n async getVideoUrl(jobId: string): Promise<VideoUrlResult> {\n let result\n try {\n result = await fal.queue.result(this.model, {\n requestId: jobId,\n })\n } catch (error: unknown) {\n // fal.ai may report COMPLETED status but throw on result fetch\n // (e.g. 422 validation errors). Extract the detailed error info.\n const err = error as { body?: { detail?: unknown }; message?: string }\n const detail = err.body?.detail\n if (Array.isArray(detail)) {\n const messages = detail.map(\n (d: { msg?: string; loc?: Array<string> }) =>\n d.loc ? `${d.loc.join('.')}: ${d.msg}` : d.msg,\n )\n throw new Error(`Video generation failed: ${messages.join('; ')}`)\n }\n throw new Error(\n `Failed to retrieve video result: ${err.message || String(error)}`,\n )\n }\n\n const data = result.data as FalVideoResultData\n\n // Different models return video URL in different formats\n const url = data.video?.url || data.video_url\n if (!url) {\n throw new Error('Video URL not found in response')\n }\n\n const usage = buildFalUsage(takeBillableUnits(result.requestId))\n\n return {\n jobId,\n url,\n ...(usage ? { usage } : {}),\n }\n }\n\n protected override generateId(): string {\n return utilGenerateId(this.name)\n }\n}\n\n/**\n * Create a fal.ai video adapter with an explicit API key.\n *\n * @experimental Video generation is an experimental feature and may change.\n */\nexport function createFalVideo<TModel extends FalModel>(\n model: TModel,\n config?: FalClientConfig,\n): FalVideoAdapter<TModel> {\n return new FalVideoAdapter(model, config)\n}\n\n/**\n * Create a fal.ai video adapter using config.apiKey or the FAL_KEY environment variable.\n *\n * @experimental Video generation is an experimental feature and may change.\n */\nexport function falVideo<TModel extends FalModel>(\n model: TModel,\n config?: FalClientConfig,\n): FalVideoAdapter<TModel> {\n return createFalVideo(model, config)\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"video.js","names":[],"sources":["../../../src/adapters/video.ts"],"sourcesContent":["import { fal } from '@fal-ai/client'\nimport { resolveMediaPrompt } from '@tanstack/ai'\nimport { BaseVideoAdapter, snapToDurationOption } from '@tanstack/ai/adapters'\nimport {\n configureFalClient,\n generateId as utilGenerateId,\n} from '../utils/client'\nimport { buildFalUsage, takeBillableUnits } from '../utils/billing'\nimport {\n getFalVideoDurationOptions,\n mapVideoSizeToFalFormat,\n} from '../video/video-provider-options'\nimport { mapImageInputsToFalVideoFields } from '../image/image-inputs'\nimport type { DurationOptions } from '@tanstack/ai/adapters'\nimport type {\n AudioPart,\n MediaInputMetadata,\n VideoGenerationOptions,\n VideoJobResult,\n VideoPart,\n VideoStatusResult,\n VideoUrlResult,\n} from '@tanstack/ai'\nimport type {\n FalModel,\n FalModelInput,\n FalModelVideoDuration,\n FalModelVideoSize,\n FalVideoPromptModalitiesFor,\n FalVideoProviderOptions,\n} from '../model-meta'\nimport type { FalClientConfig } from '../utils/client'\n\n/**\n * Map video conditioning inputs onto fal field names.\n * Video-to-video endpoints on fal almost universally use `video_url`; the\n * occasional model takes `video_urls` (rare). Mirror the image-input logic\n * positionally with a `reference` role escape hatch via `reference_video_urls`.\n */\nfunction mapVideoInputsToFalFields(\n videoInputs?: ReadonlyArray<VideoPart<MediaInputMetadata>>,\n): Record<string, unknown> {\n if (!videoInputs || videoInputs.length === 0) return {}\n const references: Array<string> = []\n const sources: Array<string> = []\n for (const part of videoInputs) {\n const url = videoPartToUrl(part)\n if (\n part.metadata?.role === 'reference' ||\n part.metadata?.role === 'character'\n ) {\n references.push(url)\n } else {\n sources.push(url)\n }\n }\n const out: Record<string, unknown> = {}\n if (references.length > 0) out.reference_video_urls = references\n if (sources.length === 1) {\n out.video_url = sources[0]\n } else if (sources.length > 1) {\n out.video_urls = sources\n }\n return out\n}\n\nfunction mapAudioInputsToFalFields(\n audioInputs?: ReadonlyArray<AudioPart<MediaInputMetadata>>,\n): Record<string, unknown> {\n if (!audioInputs || audioInputs.length === 0) return {}\n const [part, ...rest] = audioInputs\n if (!part || rest.length > 0) {\n throw new Error(\n `fal: exactly one audio prompt part is supported (received ${audioInputs.length}).`,\n )\n }\n return {\n audio_url:\n part.source.type === 'url'\n ? part.source.value\n : `data:${part.source.mimeType};base64,${part.source.value}`,\n }\n}\n\nfunction videoPartToUrl(part: VideoPart<MediaInputMetadata>): string {\n return part.source.type === 'url'\n ? part.source.value\n : `data:${part.source.mimeType};base64,${part.source.value}`\n}\n\ntype FalQueueStatus = 'IN_QUEUE' | 'IN_PROGRESS' | 'COMPLETED'\n\ninterface FalStatusResponse {\n status: FalQueueStatus\n queue_position?: number\n logs?: Array<{ message: string }>\n}\n\ninterface FalVideoResultData {\n video?: { url: string }\n video_url?: string\n}\n\n/**\n * Maps fal.ai queue status to TanStack AI video status.\n *\n * Note: fal.ai does not return a FAILED queue status. Errors surface\n * as exceptions when fetching results from a COMPLETED job (e.g. 422\n * validation errors). Those are handled in getVideoUrl().\n */\nfunction mapFalStatusToVideoStatus(\n falStatus: FalQueueStatus,\n): VideoStatusResult['status'] {\n switch (falStatus) {\n case 'IN_QUEUE':\n return 'pending'\n case 'IN_PROGRESS':\n return 'processing'\n case 'COMPLETED':\n return 'completed'\n default:\n return 'processing'\n }\n}\n\n/**\n * fal.ai video generation adapter.\n * Supports MiniMax, Luma, Kling, Hunyuan, and other fal.ai video models.\n *\n * Uses fal.ai's comprehensive type system to provide autocomplete\n * and type safety for all supported video models.\n *\n * @experimental Video generation is an experimental feature and may change.\n */\nexport class FalVideoAdapter<TModel extends FalModel> extends BaseVideoAdapter<\n TModel,\n FalVideoProviderOptions<TModel>,\n Record<TModel, FalVideoProviderOptions<TModel>>,\n Record<TModel, FalModelVideoSize<TModel>>,\n Record<TModel, FalVideoPromptModalitiesFor<TModel>>,\n Record<TModel, FalModelVideoDuration<TModel>>\n> {\n override readonly kind = 'video' as const\n readonly name = 'fal' as const\n\n constructor(model: TModel, config?: FalClientConfig) {\n super({}, model)\n configureFalClient(config)\n }\n\n async createVideoJob(\n options: VideoGenerationOptions<\n FalVideoProviderOptions<TModel>,\n FalModelVideoSize<TModel>,\n FalModelVideoDuration<TModel>\n >,\n ): Promise<VideoJobResult> {\n const { size, duration, modelOptions, logger } = options\n\n logger.request(`activity=generateVideo provider=fal model=${this.model}`, {\n provider: 'fal',\n model: this.model,\n })\n\n try {\n const resolved = resolveMediaPrompt(options.prompt)\n const sizeParams = mapVideoSizeToFalFormat(size)\n const inputImageFields = mapImageInputsToFalVideoFields(\n this.model,\n resolved.images,\n )\n const videoFields = mapVideoInputsToFalFields(resolved.videos)\n const audioFields = mapAudioInputsToFalFields(resolved.audios)\n\n const input = {\n ...sizeParams,\n ...inputImageFields,\n ...videoFields,\n ...audioFields,\n // modelOptions applied after derived media fields so explicit user\n // overrides (video_url, reference_video_urls, audio_url, ...) win.\n ...modelOptions,\n // Media-only prompts omit the prompt field rather than sending an\n // empty string (e.g. pure image-to-video endpoints).\n ...(resolved.text ? { prompt: resolved.text } : {}),\n ...(duration !== undefined ? { duration } : {}),\n } as FalModelInput<TModel>\n\n // Submit to queue and get request ID. Request-specific abortSignal only —\n // never via fal.config() (global; would cancel concurrent jobs).\n const { request_id } = await fal.queue.submit(this.model, {\n input,\n ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),\n })\n\n return {\n jobId: request_id,\n model: this.model,\n }\n } catch (error) {\n logger.errors('fal.createVideoJob fatal', {\n error,\n source: 'fal.createVideoJob',\n })\n throw error\n }\n }\n\n override availableDurations(): DurationOptions<\n FalModelVideoDuration<TModel>\n > {\n return getFalVideoDurationOptions(this.model)\n }\n\n override snapDuration(\n seconds: number,\n ): FalModelVideoDuration<TModel> | undefined {\n return snapToDurationOption(seconds, this.availableDurations())\n }\n\n async getVideoStatus(jobId: string): Promise<VideoStatusResult> {\n const statusResponse = (await fal.queue.status(this.model, {\n requestId: jobId,\n logs: true,\n })) as FalStatusResponse\n\n return {\n jobId,\n status: mapFalStatusToVideoStatus(statusResponse.status),\n ...(statusResponse.queue_position != null\n ? {\n progress: Math.max(0, 100 - statusResponse.queue_position * 10),\n }\n : {}),\n }\n }\n\n async getVideoUrl(jobId: string): Promise<VideoUrlResult> {\n let result\n try {\n result = await fal.queue.result(this.model, {\n requestId: jobId,\n })\n } catch (error: unknown) {\n // fal.ai may report COMPLETED status but throw on result fetch\n // (e.g. 422 validation errors). Extract the detailed error info.\n const err = error as { body?: { detail?: unknown }; message?: string }\n const detail = err.body?.detail\n if (Array.isArray(detail)) {\n const messages = detail.map(\n (d: { msg?: string; loc?: Array<string> }) =>\n d.loc ? `${d.loc.join('.')}: ${d.msg}` : d.msg,\n )\n throw new Error(`Video generation failed: ${messages.join('; ')}`)\n }\n throw new Error(\n `Failed to retrieve video result: ${err.message || String(error)}`,\n )\n }\n\n const data = result.data as FalVideoResultData\n\n // Different models return video URL in different formats\n const url = data.video?.url || data.video_url\n if (!url) {\n throw new Error('Video URL not found in response')\n }\n\n const usage = buildFalUsage(takeBillableUnits(result.requestId))\n\n return {\n jobId,\n url,\n ...(usage ? { usage } : {}),\n }\n }\n\n protected override generateId(): string {\n return utilGenerateId(this.name)\n }\n}\n\n/**\n * Create a fal.ai video adapter with an explicit API key.\n *\n * @experimental Video generation is an experimental feature and may change.\n */\nexport function createFalVideo<TModel extends FalModel>(\n model: TModel,\n config?: FalClientConfig,\n): FalVideoAdapter<TModel> {\n return new FalVideoAdapter(model, config)\n}\n\n/**\n * Create a fal.ai video adapter using config.apiKey or the FAL_KEY environment variable.\n *\n * @experimental Video generation is an experimental feature and may change.\n */\nexport function falVideo<TModel extends FalModel>(\n model: TModel,\n config?: FalClientConfig,\n): FalVideoAdapter<TModel> {\n return createFalVideo(model, config)\n}\n"],"mappings":";;;;;;;;;;;;;;AAuCA,SAAS,0BACP,aACyB;CACzB,IAAI,CAAC,eAAe,YAAY,WAAW,GAAG,OAAO,CAAC;CACtD,MAAM,aAA4B,CAAC;CACnC,MAAM,UAAyB,CAAC;CAChC,KAAK,MAAM,QAAQ,aAAa;EAC9B,MAAM,MAAM,eAAe,IAAI;EAC/B,IACE,KAAK,UAAU,SAAS,eACxB,KAAK,UAAU,SAAS,aAExB,WAAW,KAAK,GAAG;OAEnB,QAAQ,KAAK,GAAG;CAEpB;CACA,MAAM,MAA+B,CAAC;CACtC,IAAI,WAAW,SAAS,GAAG,IAAI,uBAAuB;CACtD,IAAI,QAAQ,WAAW,GACrB,IAAI,YAAY,QAAQ;MACnB,IAAI,QAAQ,SAAS,GAC1B,IAAI,aAAa;CAEnB,OAAO;AACT;AAEA,SAAS,0BACP,aACyB;CACzB,IAAI,CAAC,eAAe,YAAY,WAAW,GAAG,OAAO,CAAC;CACtD,MAAM,CAAC,MAAM,GAAG,QAAQ;CACxB,IAAI,CAAC,QAAQ,KAAK,SAAS,GACzB,MAAM,IAAI,MACR,6DAA6D,YAAY,OAAO,GAClF;CAEF,OAAO,EACL,WACE,KAAK,OAAO,SAAS,QACjB,KAAK,OAAO,QACZ,QAAQ,KAAK,OAAO,SAAS,UAAU,KAAK,OAAO,QAC3D;AACF;AAEA,SAAS,eAAe,MAA6C;CACnE,OAAO,KAAK,OAAO,SAAS,QACxB,KAAK,OAAO,QACZ,QAAQ,KAAK,OAAO,SAAS,UAAU,KAAK,OAAO;AACzD;;;;;;;;AAsBA,SAAS,0BACP,WAC6B;CAC7B,QAAQ,WAAR;EACE,KAAK,YACH,OAAO;EACT,KAAK,eACH,OAAO;EACT,KAAK,aACH,OAAO;EACT,SACE,OAAO;CACX;AACF;;;;;;;;;;AAWA,IAAa,kBAAb,cAA8D,iBAO5D;CACA,OAAyB;CACzB,OAAgB;CAEhB,YAAY,OAAe,QAA0B;EACnD,MAAM,CAAC,GAAG,KAAK;EACf,mBAAmB,MAAM;CAC3B;CAEA,MAAM,eACJ,SAKyB;EACzB,MAAM,EAAE,MAAM,UAAU,cAAc,WAAW;EAEjD,OAAO,QAAQ,6CAA6C,KAAK,SAAS;GACxE,UAAU;GACV,OAAO,KAAK;EACd,CAAC;EAED,IAAI;GACF,MAAM,WAAW,mBAAmB,QAAQ,MAAM;GAClD,MAAM,aAAa,wBAAwB,IAAI;GAC/C,MAAM,mBAAmB,+BACvB,KAAK,OACL,SAAS,MACX;GACA,MAAM,cAAc,0BAA0B,SAAS,MAAM;GAC7D,MAAM,cAAc,0BAA0B,SAAS,MAAM;GAE7D,MAAM,QAAQ;IACZ,GAAG;IACH,GAAG;IACH,GAAG;IACH,GAAG;IAGH,GAAG;IAGH,GAAI,SAAS,OAAO,EAAE,QAAQ,SAAS,KAAK,IAAI,CAAC;IACjD,GAAI,aAAa,KAAA,IAAY,EAAE,SAAS,IAAI,CAAC;GAC/C;GAIA,MAAM,EAAE,eAAe,MAAM,IAAI,MAAM,OAAO,KAAK,OAAO;IACxD;IACA,GAAI,QAAQ,cAAc,EAAE,aAAa,QAAQ,YAAY,IAAI,CAAC;GACpE,CAAC;GAED,OAAO;IACL,OAAO;IACP,OAAO,KAAK;GACd;EACF,SAAS,OAAO;GACd,OAAO,OAAO,4BAA4B;IACxC;IACA,QAAQ;GACV,CAAC;GACD,MAAM;EACR;CACF;CAEA,qBAEE;EACA,OAAO,2BAA2B,KAAK,KAAK;CAC9C;CAEA,aACE,SAC2C;EAC3C,OAAO,qBAAqB,SAAS,KAAK,mBAAmB,CAAC;CAChE;CAEA,MAAM,eAAe,OAA2C;EAC9D,MAAM,iBAAkB,MAAM,IAAI,MAAM,OAAO,KAAK,OAAO;GACzD,WAAW;GACX,MAAM;EACR,CAAC;EAED,OAAO;GACL;GACA,QAAQ,0BAA0B,eAAe,MAAM;GACvD,GAAI,eAAe,kBAAkB,OACjC,EACE,UAAU,KAAK,IAAI,GAAG,MAAM,eAAe,iBAAiB,EAAE,EAChE,IACA,CAAC;EACP;CACF;CAEA,MAAM,YAAY,OAAwC;EACxD,IAAI;EACJ,IAAI;GACF,SAAS,MAAM,IAAI,MAAM,OAAO,KAAK,OAAO,EAC1C,WAAW,MACb,CAAC;EACH,SAAS,OAAgB;GAGvB,MAAM,MAAM;GACZ,MAAM,SAAS,IAAI,MAAM;GACzB,IAAI,MAAM,QAAQ,MAAM,GAAG;IACzB,MAAM,WAAW,OAAO,KACrB,MACC,EAAE,MAAM,GAAG,EAAE,IAAI,KAAK,GAAG,EAAE,IAAI,EAAE,QAAQ,EAAE,GAC/C;IACA,MAAM,IAAI,MAAM,4BAA4B,SAAS,KAAK,IAAI,GAAG;GACnE;GACA,MAAM,IAAI,MACR,oCAAoC,IAAI,WAAW,OAAO,KAAK,GACjE;EACF;EAEA,MAAM,OAAO,OAAO;EAGpB,MAAM,MAAM,KAAK,OAAO,OAAO,KAAK;EACpC,IAAI,CAAC,KACH,MAAM,IAAI,MAAM,iCAAiC;EAGnD,MAAM,QAAQ,cAAc,kBAAkB,OAAO,SAAS,CAAC;EAE/D,OAAO;GACL;GACA;GACA,GAAI,QAAQ,EAAE,MAAM,IAAI,CAAC;EAC3B;CACF;CAEA,aAAwC;EACtC,OAAO,WAAe,KAAK,IAAI;CACjC;AACF;;;;;;AAOA,SAAgB,eACd,OACA,QACyB;CACzB,OAAO,IAAI,gBAAgB,OAAO,MAAM;AAC1C;;;;;;AAOA,SAAgB,SACd,OACA,QACyB;CACzB,OAAO,eAAe,OAAO,MAAM;AACrC"}
@@ -3,5 +3,5 @@ export { FalVideoAdapter, falVideo } from './adapters/video.js';
3
3
  export { FalSpeechAdapter, falSpeech } from './adapters/speech.js';
4
4
  export { FalTranscriptionAdapter, falTranscription, } from './adapters/transcription.js';
5
5
  export { FalAudioAdapter, falAudio } from './adapters/audio.js';
6
- export { type FalImageProviderOptions, type FalVideoProviderOptions, type FalSpeechProviderOptions, type FalTranscriptionProviderOptions, type FalAudioProviderOptions, type FalModel, type FalModelInput, type FalModelOutput, type FalModelImageSize, type FalModelVideoSize, } from './model-meta.js';
6
+ export { type FalImageProviderOptions, type FalVideoProviderOptions, type FalSpeechProviderOptions, type FalTranscriptionProviderOptions, type FalAudioProviderOptions, type FalModel, type FalModelInput, type FalModelOutput, type FalModelImageSize, type FalModelVideoSize, type FalModelVideoDuration, } from './model-meta.js';
7
7
  export { getFalApiKeyFromEnv, configureFalClient, generateId, type FalClientConfig, } from './utils/client.js';
@@ -88,6 +88,21 @@ export type FalModelVideoSizeInput<TModel extends string> = TModel extends keyof
88
88
  aspect_ratio?: string;
89
89
  resolution?: string;
90
90
  };
91
+ /**
92
+ * Extract the `duration` field type from a fal video model's input.
93
+ * Falls back to `string | number | undefined` for models not in the SDK's
94
+ * `EndpointTypeMap`.
95
+ *
96
+ * Shapes seen in the wild:
97
+ * - `'5' | '10'` (Kling, Pika): discrete numeric strings
98
+ * - `'5s' | '9s'` (Luma): keyword strings with unit
99
+ * - `'4s' | '6s' | '8s'` (Veo3 via FAL): keyword strings
100
+ * - `'2' | … | '15'` (WAN-25): discrete-range numeric strings
101
+ * - undefined (Minimax, Hunyuan): no duration field; passing one is a type error
102
+ */
103
+ export type FalModelVideoDuration<TModel extends string> = TModel extends keyof EndpointTypeMap ? 'duration' extends keyof EndpointTypeMap[TModel]['input'] ? Extract<NonNullable<EndpointTypeMap[TModel]['input'] extends {
104
+ duration?: infer D;
105
+ } ? D : never>, string | number> : undefined : string | number | undefined;
91
106
  /**
92
107
  * Prompt input modalities for a fal image endpoint, derived from the SDK's
93
108
  * endpoint input type: an endpoint accepts image prompt parts exactly when
@@ -111,7 +126,7 @@ export type FalVideoPromptModalitiesFor<TModel extends string> = TModel extends
111
126
  * video/audio) are optional here even when the endpoint requires them —
112
127
  * they're usually supplied as prompt parts instead.
113
128
  */
114
- export type FalVideoProviderOptions<TModel extends string> = TModel extends keyof EndpointTypeMap ? WithOptionalMediaInputFields<Omit<FalModelInput<TModel>, 'prompt'>> : Record<string, unknown>;
129
+ export type FalVideoProviderOptions<TModel extends string> = TModel extends keyof EndpointTypeMap ? WithOptionalMediaInputFields<Omit<FalModelInput<TModel>, 'prompt' | 'duration'>> : Record<string, unknown>;
115
130
  /**
116
131
  * Provider options for TTS, excluding fields TanStack AI handles.
117
132
  * Use this for the `modelOptions` parameter in speech generation.
@@ -19,9 +19,10 @@ export declare function takeBillableUnits(requestId: string | undefined): number
19
19
  /**
20
20
  * Build a {@link TokenUsage} carrying fal's billed quantity. Media generation has
21
21
  * no tokens, so the token fields are zero and the real billing signal rides on
22
- * `unitsBilled` — mirroring how the duration-billed transcription adapters
23
- * surface `durationSeconds`. Returns `undefined` when no units were captured so
24
- * callers can omit `usage` entirely.
22
+ * `billed`, denominated in `'units'` — fal's priced unit is endpoint-defined
23
+ * (its pricing page maps each endpoint to a unit price), so the count is opaque
24
+ * by design. Returns `undefined` when no units were captured so callers can
25
+ * omit `usage` entirely.
25
26
  */
26
27
  export declare function buildFalUsage(unitsBilled: number | undefined): TokenUsage | undefined;
27
28
  /**
@@ -59,9 +59,10 @@ function takeBillableUnits(requestId) {
59
59
  /**
60
60
  * Build a {@link TokenUsage} carrying fal's billed quantity. Media generation has
61
61
  * no tokens, so the token fields are zero and the real billing signal rides on
62
- * `unitsBilled` — mirroring how the duration-billed transcription adapters
63
- * surface `durationSeconds`. Returns `undefined` when no units were captured so
64
- * callers can omit `usage` entirely.
62
+ * `billed`, denominated in `'units'` — fal's priced unit is endpoint-defined
63
+ * (its pricing page maps each endpoint to a unit price), so the count is opaque
64
+ * by design. Returns `undefined` when no units were captured so callers can
65
+ * omit `usage` entirely.
65
66
  */
66
67
  function buildFalUsage(unitsBilled) {
67
68
  if (unitsBilled == null) return void 0;
@@ -69,6 +70,10 @@ function buildFalUsage(unitsBilled) {
69
70
  promptTokens: 0,
70
71
  completionTokens: 0,
71
72
  totalTokens: 0,
73
+ billed: {
74
+ quantity: unitsBilled,
75
+ unit: "units"
76
+ },
72
77
  unitsBilled
73
78
  };
74
79
  }
@@ -1 +1 @@
1
- {"version":3,"file":"billing.js","names":[],"sources":["../../../src/utils/billing.ts"],"sourcesContent":["import type { TokenUsage } from '@tanstack/ai'\n\n/**\n * Response header fal sets on a queue *result* fetch carrying the real billed\n * quantity for the generation, denominated in the endpoint's priced unit.\n */\nconst FAL_BILLABLE_UNITS_HEADER = 'x-fal-billable-units'\n\n/**\n * Response header fal sets carrying the request id. The fal client surfaces this\n * same value as `Result.requestId`, so keying captured billable units by it\n * guarantees the adapter's lookup matches the fetch the units came from — no URL\n * parsing or global correlation registry of our own design needed.\n */\nconst FAL_REQUEST_ID_HEADER = 'x-fal-request-id'\n\n/**\n * Upper bound on retained, not-yet-consumed billable-unit entries. Each\n * successful generation reads-and-deletes its entry (see {@link takeBillableUnits}),\n * so this only guards against an unbounded leak when a result fetch records units\n * but the adapter never resolves (e.g. it throws before reading). When the cap is\n * exceeded the oldest entry is evicted (Map preserves insertion order).\n */\nconst MAX_PENDING_ENTRIES = 256\n\nconst billableUnitsByRequestId = new Map<string, number>()\n\n/**\n * Parse the `x-fal-billable-units` header value into a finite number. Returns\n * `undefined` for a missing or non-numeric value so callers can skip attaching\n * usage rather than surfacing `NaN`.\n */\nexport function parseBillableUnits(value: string | null): number | undefined {\n if (value == null || value === '') return undefined\n const parsed = Number(value)\n return Number.isFinite(parsed) ? parsed : undefined\n}\n\n/**\n * Record the billable units carried by a fal result response, keyed by the\n * request id from the same response. Reading headers does not consume the body,\n * so the response can be returned to the fal client untouched.\n */\nexport function recordBillableUnitsFromResponse(response: Response): void {\n const units = parseBillableUnits(\n response.headers.get(FAL_BILLABLE_UNITS_HEADER),\n )\n if (units == null) return\n const requestId = response.headers.get(FAL_REQUEST_ID_HEADER)\n if (!requestId) return\n if (\n billableUnitsByRequestId.size >= MAX_PENDING_ENTRIES &&\n !billableUnitsByRequestId.has(requestId)\n ) {\n const oldest = billableUnitsByRequestId.keys().next().value\n if (oldest !== undefined) billableUnitsByRequestId.delete(oldest)\n }\n billableUnitsByRequestId.set(requestId, units)\n}\n\n/**\n * Read and remove the billable units recorded for a request id. Removing on read\n * keeps the registry from growing across the lifetime of the process.\n */\nexport function takeBillableUnits(\n requestId: string | undefined,\n): number | undefined {\n if (!requestId) return undefined\n const units = billableUnitsByRequestId.get(requestId)\n if (units !== undefined) billableUnitsByRequestId.delete(requestId)\n return units\n}\n\n/**\n * Build a {@link TokenUsage} carrying fal's billed quantity. Media generation has\n * no tokens, so the token fields are zero and the real billing signal rides on\n * `unitsBilled` — mirroring how the duration-billed transcription adapters\n * surface `durationSeconds`. Returns `undefined` when no units were captured so\n * callers can omit `usage` entirely.\n */\nexport function buildFalUsage(\n unitsBilled: number | undefined,\n): TokenUsage | undefined {\n if (unitsBilled == null) return undefined\n return {\n promptTokens: 0,\n completionTokens: 0,\n totalTokens: 0,\n unitsBilled,\n }\n}\n\n/**\n * Wrap a fetch so every fal request's response is inspected for the\n * `x-fal-billable-units` header before being returned untouched. Installed as\n * fal's `config.fetch`, which (unlike a global `responseHandler`) is honoured for\n * every request — the fal client forces `resultResponseHandler` per queue\n * operation, clobbering any configured response handler.\n *\n * `baseFetch` is the underlying implementation to delegate to (defaults to the\n * global `fetch`). Injecting it keeps usage capture working when a caller\n * supplies a custom fetch — a proxy, instrumentation, or a test mock — without\n * mutating any global.\n */\nexport function createBillingFetch(\n baseFetch: typeof fetch = globalThis.fetch,\n): typeof fetch {\n return async (input, init) => {\n const response = await baseFetch(input, init)\n try {\n recordBillableUnitsFromResponse(response)\n } catch {\n // Capturing usage must never break the underlying request.\n }\n return response\n }\n}\n"],"mappings":";;;;;AAMA,IAAM,4BAA4B;;;;;;;AAQlC,IAAM,wBAAwB;;;;;;;;AAS9B,IAAM,sBAAsB;AAE5B,IAAM,2CAA2B,IAAI,IAAoB;;;;;;AAOzD,SAAgB,mBAAmB,OAA0C;CAC3E,IAAI,SAAS,QAAQ,UAAU,IAAI,OAAO,KAAA;CAC1C,MAAM,SAAS,OAAO,KAAK;CAC3B,OAAO,OAAO,SAAS,MAAM,IAAI,SAAS,KAAA;AAC5C;;;;;;AAOA,SAAgB,gCAAgC,UAA0B;CACxE,MAAM,QAAQ,mBACZ,SAAS,QAAQ,IAAI,yBAAyB,CAChD;CACA,IAAI,SAAS,MAAM;CACnB,MAAM,YAAY,SAAS,QAAQ,IAAI,qBAAqB;CAC5D,IAAI,CAAC,WAAW;CAChB,IACE,yBAAyB,QAAQ,uBACjC,CAAC,yBAAyB,IAAI,SAAS,GACvC;EACA,MAAM,SAAS,yBAAyB,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC;EACtD,IAAI,WAAW,KAAA,GAAW,yBAAyB,OAAO,MAAM;CAClE;CACA,yBAAyB,IAAI,WAAW,KAAK;AAC/C;;;;;AAMA,SAAgB,kBACd,WACoB;CACpB,IAAI,CAAC,WAAW,OAAO,KAAA;CACvB,MAAM,QAAQ,yBAAyB,IAAI,SAAS;CACpD,IAAI,UAAU,KAAA,GAAW,yBAAyB,OAAO,SAAS;CAClE,OAAO;AACT;;;;;;;;AASA,SAAgB,cACd,aACwB;CACxB,IAAI,eAAe,MAAM,OAAO,KAAA;CAChC,OAAO;EACL,cAAc;EACd,kBAAkB;EAClB,aAAa;EACb;CACF;AACF;;;;;;;;;;;;;AAcA,SAAgB,mBACd,YAA0B,WAAW,OACvB;CACd,OAAO,OAAO,OAAO,SAAS;EAC5B,MAAM,WAAW,MAAM,UAAU,OAAO,IAAI;EAC5C,IAAI;GACF,gCAAgC,QAAQ;EAC1C,QAAQ,CAER;EACA,OAAO;CACT;AACF"}
1
+ {"version":3,"file":"billing.js","names":[],"sources":["../../../src/utils/billing.ts"],"sourcesContent":["import type { TokenUsage } from '@tanstack/ai'\n\n/**\n * Response header fal sets on a queue *result* fetch carrying the real billed\n * quantity for the generation, denominated in the endpoint's priced unit.\n */\nconst FAL_BILLABLE_UNITS_HEADER = 'x-fal-billable-units'\n\n/**\n * Response header fal sets carrying the request id. The fal client surfaces this\n * same value as `Result.requestId`, so keying captured billable units by it\n * guarantees the adapter's lookup matches the fetch the units came from — no URL\n * parsing or global correlation registry of our own design needed.\n */\nconst FAL_REQUEST_ID_HEADER = 'x-fal-request-id'\n\n/**\n * Upper bound on retained, not-yet-consumed billable-unit entries. Each\n * successful generation reads-and-deletes its entry (see {@link takeBillableUnits}),\n * so this only guards against an unbounded leak when a result fetch records units\n * but the adapter never resolves (e.g. it throws before reading). When the cap is\n * exceeded the oldest entry is evicted (Map preserves insertion order).\n */\nconst MAX_PENDING_ENTRIES = 256\n\nconst billableUnitsByRequestId = new Map<string, number>()\n\n/**\n * Parse the `x-fal-billable-units` header value into a finite number. Returns\n * `undefined` for a missing or non-numeric value so callers can skip attaching\n * usage rather than surfacing `NaN`.\n */\nexport function parseBillableUnits(value: string | null): number | undefined {\n if (value == null || value === '') return undefined\n const parsed = Number(value)\n return Number.isFinite(parsed) ? parsed : undefined\n}\n\n/**\n * Record the billable units carried by a fal result response, keyed by the\n * request id from the same response. Reading headers does not consume the body,\n * so the response can be returned to the fal client untouched.\n */\nexport function recordBillableUnitsFromResponse(response: Response): void {\n const units = parseBillableUnits(\n response.headers.get(FAL_BILLABLE_UNITS_HEADER),\n )\n if (units == null) return\n const requestId = response.headers.get(FAL_REQUEST_ID_HEADER)\n if (!requestId) return\n if (\n billableUnitsByRequestId.size >= MAX_PENDING_ENTRIES &&\n !billableUnitsByRequestId.has(requestId)\n ) {\n const oldest = billableUnitsByRequestId.keys().next().value\n if (oldest !== undefined) billableUnitsByRequestId.delete(oldest)\n }\n billableUnitsByRequestId.set(requestId, units)\n}\n\n/**\n * Read and remove the billable units recorded for a request id. Removing on read\n * keeps the registry from growing across the lifetime of the process.\n */\nexport function takeBillableUnits(\n requestId: string | undefined,\n): number | undefined {\n if (!requestId) return undefined\n const units = billableUnitsByRequestId.get(requestId)\n if (units !== undefined) billableUnitsByRequestId.delete(requestId)\n return units\n}\n\n/**\n * Build a {@link TokenUsage} carrying fal's billed quantity. Media generation has\n * no tokens, so the token fields are zero and the real billing signal rides on\n * `billed`, denominated in `'units'` — fal's priced unit is endpoint-defined\n * (its pricing page maps each endpoint to a unit price), so the count is opaque\n * by design. Returns `undefined` when no units were captured so callers can\n * omit `usage` entirely.\n */\nexport function buildFalUsage(\n unitsBilled: number | undefined,\n): TokenUsage | undefined {\n if (unitsBilled == null) return undefined\n return {\n promptTokens: 0,\n completionTokens: 0,\n totalTokens: 0,\n billed: { quantity: unitsBilled, unit: 'units' },\n unitsBilled,\n }\n}\n\n/**\n * Wrap a fetch so every fal request's response is inspected for the\n * `x-fal-billable-units` header before being returned untouched. Installed as\n * fal's `config.fetch`, which (unlike a global `responseHandler`) is honoured for\n * every request — the fal client forces `resultResponseHandler` per queue\n * operation, clobbering any configured response handler.\n *\n * `baseFetch` is the underlying implementation to delegate to (defaults to the\n * global `fetch`). Injecting it keeps usage capture working when a caller\n * supplies a custom fetch — a proxy, instrumentation, or a test mock — without\n * mutating any global.\n */\nexport function createBillingFetch(\n baseFetch: typeof fetch = globalThis.fetch,\n): typeof fetch {\n return async (input, init) => {\n const response = await baseFetch(input, init)\n try {\n recordBillableUnitsFromResponse(response)\n } catch {\n // Capturing usage must never break the underlying request.\n }\n return response\n }\n}\n"],"mappings":";;;;;AAMA,IAAM,4BAA4B;;;;;;;AAQlC,IAAM,wBAAwB;;;;;;;;AAS9B,IAAM,sBAAsB;AAE5B,IAAM,2CAA2B,IAAI,IAAoB;;;;;;AAOzD,SAAgB,mBAAmB,OAA0C;CAC3E,IAAI,SAAS,QAAQ,UAAU,IAAI,OAAO,KAAA;CAC1C,MAAM,SAAS,OAAO,KAAK;CAC3B,OAAO,OAAO,SAAS,MAAM,IAAI,SAAS,KAAA;AAC5C;;;;;;AAOA,SAAgB,gCAAgC,UAA0B;CACxE,MAAM,QAAQ,mBACZ,SAAS,QAAQ,IAAI,yBAAyB,CAChD;CACA,IAAI,SAAS,MAAM;CACnB,MAAM,YAAY,SAAS,QAAQ,IAAI,qBAAqB;CAC5D,IAAI,CAAC,WAAW;CAChB,IACE,yBAAyB,QAAQ,uBACjC,CAAC,yBAAyB,IAAI,SAAS,GACvC;EACA,MAAM,SAAS,yBAAyB,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC;EACtD,IAAI,WAAW,KAAA,GAAW,yBAAyB,OAAO,MAAM;CAClE;CACA,yBAAyB,IAAI,WAAW,KAAK;AAC/C;;;;;AAMA,SAAgB,kBACd,WACoB;CACpB,IAAI,CAAC,WAAW,OAAO,KAAA;CACvB,MAAM,QAAQ,yBAAyB,IAAI,SAAS;CACpD,IAAI,UAAU,KAAA,GAAW,yBAAyB,OAAO,SAAS;CAClE,OAAO;AACT;;;;;;;;;AAUA,SAAgB,cACd,aACwB;CACxB,IAAI,eAAe,MAAM,OAAO,KAAA;CAChC,OAAO;EACL,cAAc;EACd,kBAAkB;EAClB,aAAa;EACb,QAAQ;GAAE,UAAU;GAAa,MAAM;EAAQ;EAC/C;CACF;AACF;;;;;;;;;;;;;AAcA,SAAgB,mBACd,YAA0B,WAAW,OACvB;CACd,OAAO,OAAO,OAAO,SAAS;EAC5B,MAAM,WAAW,MAAM,UAAU,OAAO,IAAI;EAC5C,IAAI;GACF,gCAAgC,QAAQ;EAC1C,QAAQ,CAER;EACA,OAAO;CACT;AACF"}
@@ -1,2 +1,4 @@
1
- import { FalModelVideoSize, FalModelVideoSizeInput } from '../model-meta.js';
2
- export declare function mapVideoSizeToFalFormat<TModel extends string>(size: FalModelVideoSize<TModel> | undefined): FalModelVideoSizeInput<TModel> | undefined;
1
+ import { DurationOptions } from '@tanstack/ai/adapters';
2
+ import { FalModelVideoDuration, FalModelVideoSize, FalModelVideoSizeInput } from '../model-meta.js';
3
+ export declare function mapVideoSizeToFalFormat<TModel extends string>(size?: FalModelVideoSize<TModel>): FalModelVideoSizeInput<TModel>;
4
+ export declare function getFalVideoDurationOptions<TModel extends string>(model: TModel): DurationOptions<FalModelVideoDuration<TModel>>;
@@ -1,17 +1,119 @@
1
1
  //#region src/video/video-provider-options.ts
2
2
  function mapVideoSizeToFalFormat(size) {
3
- if (!size) return void 0;
3
+ if (!size) return {};
4
4
  if (size.includes("_")) {
5
- const [aspect_ratio, resolution] = size.split("_");
5
+ const [aspectRatio, resolution] = size.split("_");
6
6
  return {
7
- aspect_ratio,
7
+ aspect_ratio: aspectRatio,
8
8
  resolution
9
9
  };
10
10
  }
11
11
  if (size.includes(":")) return { aspect_ratio: size };
12
12
  return { resolution: size };
13
13
  }
14
+ /**
15
+ * Identity helper that checks each entry against the SDK's `duration` union
16
+ * for its own key, so a typo'd endpoint id or a stale union fails to compile.
17
+ */
18
+ function durationMap(map) {
19
+ return map;
20
+ }
21
+ var KLING_5_10 = {
22
+ kind: "discrete",
23
+ values: ["5", "10"]
24
+ };
25
+ var VEO_4_6_8 = {
26
+ kind: "discrete",
27
+ values: [
28
+ "4s",
29
+ "6s",
30
+ "8s"
31
+ ]
32
+ };
33
+ var LTX_6_8_10 = {
34
+ kind: "discrete",
35
+ values: [
36
+ "6",
37
+ "8",
38
+ "10"
39
+ ]
40
+ };
41
+ var KLING_3_15 = {
42
+ kind: "discrete",
43
+ values: [
44
+ "3",
45
+ "4",
46
+ "5",
47
+ "6",
48
+ "7",
49
+ "8",
50
+ "9",
51
+ "10",
52
+ "11",
53
+ "12",
54
+ "13",
55
+ "14",
56
+ "15"
57
+ ]
58
+ };
59
+ /**
60
+ * Curated map of per-model duration options for popular fal.ai video models.
61
+ * Values are checked at compile time against `@fal-ai/client`'s
62
+ * `EndpointTypeMap` input types via `durationMap`.
63
+ *
64
+ * Models not listed here fall back to `{ kind: 'none' }` — honest "we don't
65
+ * know" rather than guessing. The type-level `FalModelVideoDuration<TModel>`
66
+ * still derives from the SDK types, so autocomplete works for SDK-known
67
+ * models that aren't curated here.
68
+ */
69
+ var FAL_VIDEO_DURATIONS = durationMap({
70
+ "fal-ai/kling-video/v1.6/standard/text-to-video": KLING_5_10,
71
+ "fal-ai/kling-video/v1.6/pro/text-to-video": KLING_5_10,
72
+ "fal-ai/kling-video/v2.6/pro/text-to-video": KLING_5_10,
73
+ "fal-ai/kling-video/v2.6/pro/image-to-video": KLING_5_10,
74
+ "fal-ai/kling-video/v3/pro/text-to-video": KLING_3_15,
75
+ "fal-ai/kling-video/v3/pro/image-to-video": KLING_3_15,
76
+ "fal-ai/pika/v2.2/text-to-video": KLING_5_10,
77
+ "fal-ai/luma-dream-machine/ray-2": {
78
+ kind: "discrete",
79
+ values: ["5s", "9s"]
80
+ },
81
+ "fal-ai/veo3": VEO_4_6_8,
82
+ "fal-ai/veo3/image-to-video": VEO_4_6_8,
83
+ "fal-ai/veo3.1": VEO_4_6_8,
84
+ "fal-ai/veo3.1/image-to-video": VEO_4_6_8,
85
+ "fal-ai/veo3.1/fast": VEO_4_6_8,
86
+ "fal-ai/veo3.1/fast/image-to-video": VEO_4_6_8,
87
+ "fal-ai/ltx-2.3/text-to-video": LTX_6_8_10,
88
+ "fal-ai/ltx-2.3/text-to-video/fast": LTX_6_8_10,
89
+ "fal-ai/ltx-2.3/image-to-video": LTX_6_8_10,
90
+ "fal-ai/ltx-2.3/image-to-video/fast": LTX_6_8_10,
91
+ "fal-ai/wan-25-preview/text-to-video": {
92
+ kind: "discrete",
93
+ values: [
94
+ "2",
95
+ "3",
96
+ "4",
97
+ "5",
98
+ "6",
99
+ "7",
100
+ "8",
101
+ "9",
102
+ "10",
103
+ "11",
104
+ "12",
105
+ "13",
106
+ "14",
107
+ "15"
108
+ ]
109
+ },
110
+ "fal-ai/minimax/video-01": { kind: "none" },
111
+ "fal-ai/hunyuan-video-v1.5/text-to-video": { kind: "none" }
112
+ });
113
+ function getFalVideoDurationOptions(model) {
114
+ return FAL_VIDEO_DURATIONS[model] ?? { kind: "none" };
115
+ }
14
116
  //#endregion
15
- export { mapVideoSizeToFalFormat };
117
+ export { getFalVideoDurationOptions, mapVideoSizeToFalFormat };
16
118
 
17
119
  //# sourceMappingURL=video-provider-options.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"video-provider-options.js","names":[],"sources":["../../../src/video/video-provider-options.ts"],"sourcesContent":["import type { FalModelVideoSize, FalModelVideoSizeInput } from '../model-meta'\n\nexport function mapVideoSizeToFalFormat<TModel extends string>(\n size: FalModelVideoSize<TModel> | undefined,\n): FalModelVideoSizeInput<TModel> | undefined {\n if (!size) return undefined\n\n // \"16:9_720p\" → { aspect_ratio, resolution }\n // \"16:9\" → { aspect_ratio }\n // \"720p\" → { resolution }\n if (size.includes('_')) {\n const [aspect_ratio, resolution] = size.split('_')\n return {\n aspect_ratio,\n resolution,\n } as FalModelVideoSizeInput<TModel>\n }\n\n if (size.includes(':')) {\n return { aspect_ratio: size } as FalModelVideoSizeInput<TModel>\n }\n\n return { resolution: size } as FalModelVideoSizeInput<TModel>\n}\n"],"mappings":";AAEA,SAAgB,wBACd,MAC4C;CAC5C,IAAI,CAAC,MAAM,OAAO,KAAA;CAKlB,IAAI,KAAK,SAAS,GAAG,GAAG;EACtB,MAAM,CAAC,cAAc,cAAc,KAAK,MAAM,GAAG;EACjD,OAAO;GACL;GACA;EACF;CACF;CAEA,IAAI,KAAK,SAAS,GAAG,GACnB,OAAO,EAAE,cAAc,KAAK;CAG9B,OAAO,EAAE,YAAY,KAAK;AAC5B"}
1
+ {"version":3,"file":"video-provider-options.js","names":[],"sources":["../../../src/video/video-provider-options.ts"],"sourcesContent":["import type { DurationOptions } from '@tanstack/ai/adapters'\nimport type {\n FalModelVideoDuration,\n FalModelVideoSize,\n FalModelVideoSizeInput,\n} from '../model-meta'\n\nexport function mapVideoSizeToFalFormat<TModel extends string>(\n size?: FalModelVideoSize<TModel>,\n): FalModelVideoSizeInput<TModel> {\n if (!size) {\n return {} as FalModelVideoSizeInput<TModel>\n }\n\n if (size.includes('_')) {\n const [aspectRatio, resolution] = size.split('_')\n return {\n aspect_ratio: aspectRatio,\n resolution,\n } as FalModelVideoSizeInput<TModel>\n }\n\n if (size.includes(':')) {\n return { aspect_ratio: size } as FalModelVideoSizeInput<TModel>\n }\n\n return { resolution: size } as FalModelVideoSizeInput<TModel>\n}\n\n/**\n * Identity helper that checks each entry against the SDK's `duration` union\n * for its own key, so a typo'd endpoint id or a stale union fails to compile.\n */\nfunction durationMap<\n const T extends {\n [K in keyof T & string]: DurationOptions<FalModelVideoDuration<K>>\n },\n>(map: T): T {\n return map\n}\n\nconst KLING_5_10 = { kind: 'discrete', values: ['5', '10'] } as const\nconst VEO_4_6_8 = { kind: 'discrete', values: ['4s', '6s', '8s'] } as const\nconst LTX_6_8_10 = { kind: 'discrete', values: ['6', '8', '10'] } as const\nconst KLING_3_15 = {\n kind: 'discrete',\n values: [\n '3',\n '4',\n '5',\n '6',\n '7',\n '8',\n '9',\n '10',\n '11',\n '12',\n '13',\n '14',\n '15',\n ],\n} as const\n\n/**\n * Curated map of per-model duration options for popular fal.ai video models.\n * Values are checked at compile time against `@fal-ai/client`'s\n * `EndpointTypeMap` input types via `durationMap`.\n *\n * Models not listed here fall back to `{ kind: 'none' }` — honest \"we don't\n * know\" rather than guessing. The type-level `FalModelVideoDuration<TModel>`\n * still derives from the SDK types, so autocomplete works for SDK-known\n * models that aren't curated here.\n */\nconst FAL_VIDEO_DURATIONS = durationMap({\n 'fal-ai/kling-video/v1.6/standard/text-to-video': KLING_5_10,\n 'fal-ai/kling-video/v1.6/pro/text-to-video': KLING_5_10,\n 'fal-ai/kling-video/v2.6/pro/text-to-video': KLING_5_10,\n 'fal-ai/kling-video/v2.6/pro/image-to-video': KLING_5_10,\n 'fal-ai/kling-video/v3/pro/text-to-video': KLING_3_15,\n 'fal-ai/kling-video/v3/pro/image-to-video': KLING_3_15,\n 'fal-ai/pika/v2.2/text-to-video': KLING_5_10,\n 'fal-ai/luma-dream-machine/ray-2': {\n kind: 'discrete',\n values: ['5s', '9s'],\n },\n 'fal-ai/veo3': VEO_4_6_8,\n 'fal-ai/veo3/image-to-video': VEO_4_6_8,\n 'fal-ai/veo3.1': VEO_4_6_8,\n 'fal-ai/veo3.1/image-to-video': VEO_4_6_8,\n 'fal-ai/veo3.1/fast': VEO_4_6_8,\n 'fal-ai/veo3.1/fast/image-to-video': VEO_4_6_8,\n 'fal-ai/ltx-2.3/text-to-video': LTX_6_8_10,\n 'fal-ai/ltx-2.3/text-to-video/fast': LTX_6_8_10,\n 'fal-ai/ltx-2.3/image-to-video': LTX_6_8_10,\n 'fal-ai/ltx-2.3/image-to-video/fast': LTX_6_8_10,\n 'fal-ai/wan-25-preview/text-to-video': {\n kind: 'discrete',\n values: [\n '2',\n '3',\n '4',\n '5',\n '6',\n '7',\n '8',\n '9',\n '10',\n '11',\n '12',\n '13',\n '14',\n '15',\n ],\n },\n 'fal-ai/minimax/video-01': { kind: 'none' },\n 'fal-ai/hunyuan-video-v1.5/text-to-video': { kind: 'none' },\n})\n\nexport function getFalVideoDurationOptions<TModel extends string>(\n model: TModel,\n): DurationOptions<FalModelVideoDuration<TModel>> {\n const entry = (FAL_VIDEO_DURATIONS as Record<string, unknown>)[model]\n // The map is keyed by literal ids but the adapter's TModel is an open string;\n // the lookup result can't be correlated by TS, hence the one cast here.\n return (entry ?? { kind: 'none' }) as DurationOptions<\n FalModelVideoDuration<TModel>\n >\n}\n"],"mappings":";AAOA,SAAgB,wBACd,MACgC;CAChC,IAAI,CAAC,MACH,OAAO,CAAC;CAGV,IAAI,KAAK,SAAS,GAAG,GAAG;EACtB,MAAM,CAAC,aAAa,cAAc,KAAK,MAAM,GAAG;EAChD,OAAO;GACL,cAAc;GACd;EACF;CACF;CAEA,IAAI,KAAK,SAAS,GAAG,GACnB,OAAO,EAAE,cAAc,KAAK;CAG9B,OAAO,EAAE,YAAY,KAAK;AAC5B;;;;;AAMA,SAAS,YAIP,KAAW;CACX,OAAO;AACT;AAEA,IAAM,aAAa;CAAE,MAAM;CAAY,QAAQ,CAAC,KAAK,IAAI;AAAE;AAC3D,IAAM,YAAY;CAAE,MAAM;CAAY,QAAQ;EAAC;EAAM;EAAM;CAAI;AAAE;AACjE,IAAM,aAAa;CAAE,MAAM;CAAY,QAAQ;EAAC;EAAK;EAAK;CAAI;AAAE;AAChE,IAAM,aAAa;CACjB,MAAM;CACN,QAAQ;EACN;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CACF;AACF;;;;;;;;;;;AAYA,IAAM,sBAAsB,YAAY;CACtC,kDAAkD;CAClD,6CAA6C;CAC7C,6CAA6C;CAC7C,8CAA8C;CAC9C,2CAA2C;CAC3C,4CAA4C;CAC5C,kCAAkC;CAClC,mCAAmC;EACjC,MAAM;EACN,QAAQ,CAAC,MAAM,IAAI;CACrB;CACA,eAAe;CACf,8BAA8B;CAC9B,iBAAiB;CACjB,gCAAgC;CAChC,sBAAsB;CACtB,qCAAqC;CACrC,gCAAgC;CAChC,qCAAqC;CACrC,iCAAiC;CACjC,sCAAsC;CACtC,uCAAuC;EACrC,MAAM;EACN,QAAQ;GACN;GACA;GACA;GACA;GACA;GACA;GACA;GACA;GACA;GACA;GACA;GACA;GACA;GACA;EACF;CACF;CACA,2BAA2B,EAAE,MAAM,OAAO;CAC1C,2CAA2C,EAAE,MAAM,OAAO;AAC5D,CAAC;AAED,SAAgB,2BACd,OACgD;CAIhD,OAHe,oBAAgD,UAG9C,EAAE,MAAM,OAAO;AAGlC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-fal",
3
- "version": "0.10.1",
3
+ "version": "0.12.0",
4
4
  "description": "fal.ai adapter for TanStack AI image, video, audio, speech, and transcription generation.",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -54,10 +54,10 @@
54
54
  "devDependencies": {
55
55
  "@vitest/coverage-v8": "4.1.10",
56
56
  "vite": "^8.2.1",
57
- "@tanstack/ai": "0.45.0"
57
+ "@tanstack/ai": "0.47.1"
58
58
  },
59
59
  "peerDependencies": {
60
- "@tanstack/ai": "^0.45.0"
60
+ "@tanstack/ai": "^0.47.1"
61
61
  },
62
62
  "scripts": {
63
63
  "build": "vite build",
@@ -1,13 +1,17 @@
1
1
  import { fal } from '@fal-ai/client'
2
2
  import { resolveMediaPrompt } from '@tanstack/ai'
3
- import { BaseVideoAdapter } from '@tanstack/ai/adapters'
3
+ import { BaseVideoAdapter, snapToDurationOption } from '@tanstack/ai/adapters'
4
4
  import {
5
5
  configureFalClient,
6
6
  generateId as utilGenerateId,
7
7
  } from '../utils/client'
8
8
  import { buildFalUsage, takeBillableUnits } from '../utils/billing'
9
- import { mapVideoSizeToFalFormat } from '../video/video-provider-options'
9
+ import {
10
+ getFalVideoDurationOptions,
11
+ mapVideoSizeToFalFormat,
12
+ } from '../video/video-provider-options'
10
13
  import { mapImageInputsToFalVideoFields } from '../image/image-inputs'
14
+ import type { DurationOptions } from '@tanstack/ai/adapters'
11
15
  import type {
12
16
  AudioPart,
13
17
  MediaInputMetadata,
@@ -20,6 +24,7 @@ import type {
20
24
  import type {
21
25
  FalModel,
22
26
  FalModelInput,
27
+ FalModelVideoDuration,
23
28
  FalModelVideoSize,
24
29
  FalVideoPromptModalitiesFor,
25
30
  FalVideoProviderOptions,
@@ -132,7 +137,8 @@ export class FalVideoAdapter<TModel extends FalModel> extends BaseVideoAdapter<
132
137
  FalVideoProviderOptions<TModel>,
133
138
  Record<TModel, FalVideoProviderOptions<TModel>>,
134
139
  Record<TModel, FalModelVideoSize<TModel>>,
135
- Record<TModel, FalVideoPromptModalitiesFor<TModel>>
140
+ Record<TModel, FalVideoPromptModalitiesFor<TModel>>,
141
+ Record<TModel, FalModelVideoDuration<TModel>>
136
142
  > {
137
143
  override readonly kind = 'video' as const
138
144
  readonly name = 'fal' as const
@@ -145,7 +151,8 @@ export class FalVideoAdapter<TModel extends FalModel> extends BaseVideoAdapter<
145
151
  async createVideoJob(
146
152
  options: VideoGenerationOptions<
147
153
  FalVideoProviderOptions<TModel>,
148
- FalModelVideoSize<TModel>
154
+ FalModelVideoSize<TModel>,
155
+ FalModelVideoDuration<TModel>
149
156
  >,
150
157
  ): Promise<VideoJobResult> {
151
158
  const { size, duration, modelOptions, logger } = options
@@ -176,7 +183,7 @@ export class FalVideoAdapter<TModel extends FalModel> extends BaseVideoAdapter<
176
183
  // Media-only prompts omit the prompt field rather than sending an
177
184
  // empty string (e.g. pure image-to-video endpoints).
178
185
  ...(resolved.text ? { prompt: resolved.text } : {}),
179
- ...(duration ? { duration } : {}),
186
+ ...(duration !== undefined ? { duration } : {}),
180
187
  } as FalModelInput<TModel>
181
188
 
182
189
  // Submit to queue and get request ID. Request-specific abortSignal only —
@@ -199,6 +206,18 @@ export class FalVideoAdapter<TModel extends FalModel> extends BaseVideoAdapter<
199
206
  }
200
207
  }
201
208
 
209
+ override availableDurations(): DurationOptions<
210
+ FalModelVideoDuration<TModel>
211
+ > {
212
+ return getFalVideoDurationOptions(this.model)
213
+ }
214
+
215
+ override snapDuration(
216
+ seconds: number,
217
+ ): FalModelVideoDuration<TModel> | undefined {
218
+ return snapToDurationOption(seconds, this.availableDurations())
219
+ }
220
+
202
221
  async getVideoStatus(jobId: string): Promise<VideoStatusResult> {
203
222
  const statusResponse = (await fal.queue.status(this.model, {
204
223
  requestId: jobId,
package/src/index.ts CHANGED
@@ -46,6 +46,7 @@ export {
46
46
  type FalModelOutput,
47
47
  type FalModelImageSize,
48
48
  type FalModelVideoSize,
49
+ type FalModelVideoDuration,
49
50
  } from './model-meta'
50
51
  // ============================================================================
51
52
  // Utils
package/src/model-meta.ts CHANGED
@@ -144,6 +144,32 @@ export type FalModelVideoSizeInput<TModel extends string> =
144
144
  : never
145
145
  : { aspect_ratio?: string; resolution?: string }
146
146
 
147
+ /**
148
+ * Extract the `duration` field type from a fal video model's input.
149
+ * Falls back to `string | number | undefined` for models not in the SDK's
150
+ * `EndpointTypeMap`.
151
+ *
152
+ * Shapes seen in the wild:
153
+ * - `'5' | '10'` (Kling, Pika): discrete numeric strings
154
+ * - `'5s' | '9s'` (Luma): keyword strings with unit
155
+ * - `'4s' | '6s' | '8s'` (Veo3 via FAL): keyword strings
156
+ * - `'2' | … | '15'` (WAN-25): discrete-range numeric strings
157
+ * - undefined (Minimax, Hunyuan): no duration field; passing one is a type error
158
+ */
159
+ export type FalModelVideoDuration<TModel extends string> =
160
+ TModel extends keyof EndpointTypeMap
161
+ ? 'duration' extends keyof EndpointTypeMap[TModel]['input']
162
+ ? Extract<
163
+ NonNullable<
164
+ EndpointTypeMap[TModel]['input'] extends { duration?: infer D }
165
+ ? D
166
+ : never
167
+ >,
168
+ string | number
169
+ >
170
+ : undefined
171
+ : string | number | undefined
172
+
147
173
  /**
148
174
  * Prompt input modalities for a fal image endpoint, derived from the SDK's
149
175
  * endpoint input type: an endpoint accepts image prompt parts exactly when
@@ -194,7 +220,9 @@ export type FalVideoPromptModalitiesFor<TModel extends string> =
194
220
  */
195
221
  export type FalVideoProviderOptions<TModel extends string> =
196
222
  TModel extends keyof EndpointTypeMap
197
- ? WithOptionalMediaInputFields<Omit<FalModelInput<TModel>, 'prompt'>>
223
+ ? WithOptionalMediaInputFields<
224
+ Omit<FalModelInput<TModel>, 'prompt' | 'duration'>
225
+ >
198
226
  : Record<string, unknown>
199
227
 
200
228
  /**
@@ -74,9 +74,10 @@ export function takeBillableUnits(
74
74
  /**
75
75
  * Build a {@link TokenUsage} carrying fal's billed quantity. Media generation has
76
76
  * no tokens, so the token fields are zero and the real billing signal rides on
77
- * `unitsBilled` — mirroring how the duration-billed transcription adapters
78
- * surface `durationSeconds`. Returns `undefined` when no units were captured so
79
- * callers can omit `usage` entirely.
77
+ * `billed`, denominated in `'units'` — fal's priced unit is endpoint-defined
78
+ * (its pricing page maps each endpoint to a unit price), so the count is opaque
79
+ * by design. Returns `undefined` when no units were captured so callers can
80
+ * omit `usage` entirely.
80
81
  */
81
82
  export function buildFalUsage(
82
83
  unitsBilled: number | undefined,
@@ -86,6 +87,7 @@ export function buildFalUsage(
86
87
  promptTokens: 0,
87
88
  completionTokens: 0,
88
89
  totalTokens: 0,
90
+ billed: { quantity: unitsBilled, unit: 'units' },
89
91
  unitsBilled,
90
92
  }
91
93
  }
@@ -1,17 +1,21 @@
1
- import type { FalModelVideoSize, FalModelVideoSizeInput } from '../model-meta'
1
+ import type { DurationOptions } from '@tanstack/ai/adapters'
2
+ import type {
3
+ FalModelVideoDuration,
4
+ FalModelVideoSize,
5
+ FalModelVideoSizeInput,
6
+ } from '../model-meta'
2
7
 
3
8
  export function mapVideoSizeToFalFormat<TModel extends string>(
4
- size: FalModelVideoSize<TModel> | undefined,
5
- ): FalModelVideoSizeInput<TModel> | undefined {
6
- if (!size) return undefined
9
+ size?: FalModelVideoSize<TModel>,
10
+ ): FalModelVideoSizeInput<TModel> {
11
+ if (!size) {
12
+ return {} as FalModelVideoSizeInput<TModel>
13
+ }
7
14
 
8
- // "16:9_720p" → { aspect_ratio, resolution }
9
- // "16:9" → { aspect_ratio }
10
- // "720p" → { resolution }
11
15
  if (size.includes('_')) {
12
- const [aspect_ratio, resolution] = size.split('_')
16
+ const [aspectRatio, resolution] = size.split('_')
13
17
  return {
14
- aspect_ratio,
18
+ aspect_ratio: aspectRatio,
15
19
  resolution,
16
20
  } as FalModelVideoSizeInput<TModel>
17
21
  }
@@ -22,3 +26,103 @@ export function mapVideoSizeToFalFormat<TModel extends string>(
22
26
 
23
27
  return { resolution: size } as FalModelVideoSizeInput<TModel>
24
28
  }
29
+
30
+ /**
31
+ * Identity helper that checks each entry against the SDK's `duration` union
32
+ * for its own key, so a typo'd endpoint id or a stale union fails to compile.
33
+ */
34
+ function durationMap<
35
+ const T extends {
36
+ [K in keyof T & string]: DurationOptions<FalModelVideoDuration<K>>
37
+ },
38
+ >(map: T): T {
39
+ return map
40
+ }
41
+
42
+ const KLING_5_10 = { kind: 'discrete', values: ['5', '10'] } as const
43
+ const VEO_4_6_8 = { kind: 'discrete', values: ['4s', '6s', '8s'] } as const
44
+ const LTX_6_8_10 = { kind: 'discrete', values: ['6', '8', '10'] } as const
45
+ const KLING_3_15 = {
46
+ kind: 'discrete',
47
+ values: [
48
+ '3',
49
+ '4',
50
+ '5',
51
+ '6',
52
+ '7',
53
+ '8',
54
+ '9',
55
+ '10',
56
+ '11',
57
+ '12',
58
+ '13',
59
+ '14',
60
+ '15',
61
+ ],
62
+ } as const
63
+
64
+ /**
65
+ * Curated map of per-model duration options for popular fal.ai video models.
66
+ * Values are checked at compile time against `@fal-ai/client`'s
67
+ * `EndpointTypeMap` input types via `durationMap`.
68
+ *
69
+ * Models not listed here fall back to `{ kind: 'none' }` — honest "we don't
70
+ * know" rather than guessing. The type-level `FalModelVideoDuration<TModel>`
71
+ * still derives from the SDK types, so autocomplete works for SDK-known
72
+ * models that aren't curated here.
73
+ */
74
+ const FAL_VIDEO_DURATIONS = durationMap({
75
+ 'fal-ai/kling-video/v1.6/standard/text-to-video': KLING_5_10,
76
+ 'fal-ai/kling-video/v1.6/pro/text-to-video': KLING_5_10,
77
+ 'fal-ai/kling-video/v2.6/pro/text-to-video': KLING_5_10,
78
+ 'fal-ai/kling-video/v2.6/pro/image-to-video': KLING_5_10,
79
+ 'fal-ai/kling-video/v3/pro/text-to-video': KLING_3_15,
80
+ 'fal-ai/kling-video/v3/pro/image-to-video': KLING_3_15,
81
+ 'fal-ai/pika/v2.2/text-to-video': KLING_5_10,
82
+ 'fal-ai/luma-dream-machine/ray-2': {
83
+ kind: 'discrete',
84
+ values: ['5s', '9s'],
85
+ },
86
+ 'fal-ai/veo3': VEO_4_6_8,
87
+ 'fal-ai/veo3/image-to-video': VEO_4_6_8,
88
+ 'fal-ai/veo3.1': VEO_4_6_8,
89
+ 'fal-ai/veo3.1/image-to-video': VEO_4_6_8,
90
+ 'fal-ai/veo3.1/fast': VEO_4_6_8,
91
+ 'fal-ai/veo3.1/fast/image-to-video': VEO_4_6_8,
92
+ 'fal-ai/ltx-2.3/text-to-video': LTX_6_8_10,
93
+ 'fal-ai/ltx-2.3/text-to-video/fast': LTX_6_8_10,
94
+ 'fal-ai/ltx-2.3/image-to-video': LTX_6_8_10,
95
+ 'fal-ai/ltx-2.3/image-to-video/fast': LTX_6_8_10,
96
+ 'fal-ai/wan-25-preview/text-to-video': {
97
+ kind: 'discrete',
98
+ values: [
99
+ '2',
100
+ '3',
101
+ '4',
102
+ '5',
103
+ '6',
104
+ '7',
105
+ '8',
106
+ '9',
107
+ '10',
108
+ '11',
109
+ '12',
110
+ '13',
111
+ '14',
112
+ '15',
113
+ ],
114
+ },
115
+ 'fal-ai/minimax/video-01': { kind: 'none' },
116
+ 'fal-ai/hunyuan-video-v1.5/text-to-video': { kind: 'none' },
117
+ })
118
+
119
+ export function getFalVideoDurationOptions<TModel extends string>(
120
+ model: TModel,
121
+ ): DurationOptions<FalModelVideoDuration<TModel>> {
122
+ const entry = (FAL_VIDEO_DURATIONS as Record<string, unknown>)[model]
123
+ // The map is keyed by literal ids but the adapter's TModel is an open string;
124
+ // the lookup result can't be correlated by TS, hence the one cast here.
125
+ return (entry ?? { kind: 'none' }) as DurationOptions<
126
+ FalModelVideoDuration<TModel>
127
+ >
128
+ }