@tanstack/ai-fal 0.6.17 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/adapters/audio.d.ts +32 -0
- package/dist/esm/adapters/audio.js +80 -0
- package/dist/esm/adapters/audio.js.map +1 -0
- package/dist/esm/adapters/image.js +18 -7
- package/dist/esm/adapters/image.js.map +1 -1
- package/dist/esm/adapters/speech.d.ts +32 -0
- package/dist/esm/adapters/speech.js +84 -0
- package/dist/esm/adapters/speech.js.map +1 -0
- package/dist/esm/adapters/transcription.d.ts +32 -0
- package/dist/esm/adapters/transcription.js +90 -0
- package/dist/esm/adapters/transcription.js.map +1 -0
- package/dist/esm/image/image-provider-options.js +10 -6
- package/dist/esm/image/image-provider-options.js.map +1 -1
- package/dist/esm/index.d.ts +4 -1
- package/dist/esm/index.js +9 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/model-meta.d.ts +15 -0
- package/dist/esm/utils/client.d.ts +31 -0
- package/dist/esm/utils/client.js +79 -13
- package/dist/esm/utils/client.js.map +1 -1
- package/dist/esm/utils/index.d.ts +1 -1
- package/package.json +9 -4
- package/src/adapters/audio.ts +148 -0
- package/src/adapters/image.ts +29 -9
- package/src/adapters/speech.ts +147 -0
- package/src/adapters/transcription.ts +169 -0
- package/src/image/image-provider-options.ts +10 -6
- package/src/index.ts +24 -0
- package/src/model-meta.ts +27 -0
- package/src/utils/client.ts +121 -13
- package/src/utils/index.ts +4 -0
package/dist/esm/utils/client.js
CHANGED
|
@@ -22,25 +22,91 @@ function getFalApiKeyFromEnv() {
|
|
|
22
22
|
return key;
|
|
23
23
|
}
|
|
24
24
|
function configureFalClient(config) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
const apiKey = config?.apiKey ?? getFalApiKeyFromEnv();
|
|
26
|
+
fal.config({
|
|
27
|
+
credentials: apiKey,
|
|
28
|
+
...config?.proxyUrl ? { proxyUrl: config.proxyUrl } : {}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function generateId(prefix) {
|
|
32
|
+
return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2)}`;
|
|
33
|
+
}
|
|
34
|
+
function extractUrlExtension(url) {
|
|
35
|
+
let pathname;
|
|
36
|
+
try {
|
|
37
|
+
const parsed = new URL(url);
|
|
38
|
+
pathname = parsed.pathname;
|
|
39
|
+
} catch {
|
|
40
|
+
pathname = url.split("?")[0].split("#")[0];
|
|
41
|
+
}
|
|
42
|
+
const normalized = pathname.replace(/\/+$/, "");
|
|
43
|
+
if (!normalized.includes("/")) return void 0;
|
|
44
|
+
const lastSegment = normalized.split("/").pop();
|
|
45
|
+
if (!lastSegment) return void 0;
|
|
46
|
+
const extension = lastSegment.split(".").pop();
|
|
47
|
+
if (!extension || extension === lastSegment) return void 0;
|
|
48
|
+
return /^[a-z0-9]{2,5}$/i.test(extension) ? extension : void 0;
|
|
49
|
+
}
|
|
50
|
+
function deriveAudioContentType(explicitContentType, url) {
|
|
51
|
+
const stripped = explicitContentType?.split(";")[0]?.trim();
|
|
52
|
+
if (stripped) return stripped;
|
|
53
|
+
const ext = extractUrlExtension(url)?.toLowerCase();
|
|
54
|
+
switch (ext) {
|
|
55
|
+
case "mp3":
|
|
56
|
+
return "audio/mpeg";
|
|
57
|
+
case "wav":
|
|
58
|
+
return "audio/wav";
|
|
59
|
+
case "ogg":
|
|
60
|
+
case "oga":
|
|
61
|
+
return "audio/ogg";
|
|
62
|
+
case "flac":
|
|
63
|
+
return "audio/flac";
|
|
64
|
+
case "aac":
|
|
65
|
+
return "audio/aac";
|
|
66
|
+
case "m4a":
|
|
67
|
+
case "mp4":
|
|
68
|
+
return "audio/mp4";
|
|
69
|
+
case "webm":
|
|
70
|
+
return "audio/webm";
|
|
71
|
+
default:
|
|
72
|
+
return "audio/mpeg";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function dataUrlToBlob(value) {
|
|
76
|
+
if (!value.startsWith("data:")) return void 0;
|
|
77
|
+
const commaIndex = value.indexOf(",");
|
|
78
|
+
if (commaIndex === -1) return void 0;
|
|
79
|
+
const header = value.slice(5, commaIndex);
|
|
80
|
+
const payload = value.slice(commaIndex + 1);
|
|
81
|
+
const isBase64 = /;base64$/i.test(header);
|
|
82
|
+
const mimeType = header.split(";")[0] || "application/octet-stream";
|
|
83
|
+
if (isBase64) {
|
|
84
|
+
const binary = atob(payload);
|
|
85
|
+
const bytes = new Uint8Array(binary.length);
|
|
86
|
+
for (let i = 0; i < binary.length; i += 1) {
|
|
87
|
+
bytes[i] = binary.charCodeAt(i);
|
|
33
88
|
}
|
|
34
|
-
|
|
35
|
-
credentials: apiKey
|
|
36
|
-
});
|
|
89
|
+
return new Blob([bytes], { type: mimeType });
|
|
37
90
|
}
|
|
91
|
+
return new Blob([decodeURIComponent(payload)], { type: mimeType });
|
|
38
92
|
}
|
|
39
|
-
function
|
|
40
|
-
|
|
93
|
+
function arrayBufferToBase64(bytes) {
|
|
94
|
+
if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") {
|
|
95
|
+
return Buffer.from(bytes).toString("base64");
|
|
96
|
+
}
|
|
97
|
+
const view = new Uint8Array(bytes);
|
|
98
|
+
let binary = "";
|
|
99
|
+
for (let i = 0; i < view.byteLength; i += 1) {
|
|
100
|
+
binary += String.fromCharCode(view[i]);
|
|
101
|
+
}
|
|
102
|
+
return btoa(binary);
|
|
41
103
|
}
|
|
42
104
|
export {
|
|
105
|
+
arrayBufferToBase64,
|
|
43
106
|
configureFalClient,
|
|
107
|
+
dataUrlToBlob,
|
|
108
|
+
deriveAudioContentType,
|
|
109
|
+
extractUrlExtension,
|
|
44
110
|
generateId,
|
|
45
111
|
getFalApiKeyFromEnv
|
|
46
112
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","sources":["../../../src/utils/client.ts"],"sourcesContent":["import { fal } from '@fal-ai/client'\n\nexport interface FalClientConfig {\n apiKey: string\n proxyUrl?: string\n}\n\ninterface EnvObject {\n FAL_KEY?: string\n}\n\ninterface WindowWithEnv {\n env?: EnvObject\n}\n\nfunction getEnvironment(): EnvObject | undefined {\n if (typeof globalThis !== 'undefined') {\n const win = (globalThis as { window?: WindowWithEnv }).window\n if (win?.env) {\n return win.env\n }\n }\n if (typeof process !== 'undefined') {\n return process.env as EnvObject\n }\n return undefined\n}\n\nexport function getFalApiKeyFromEnv(): string {\n const env = getEnvironment()\n const key = env?.FAL_KEY\n\n if (!key) {\n throw new Error(\n 'FAL_KEY is required. Please set it in your environment variables or use the factory function with an explicit API key.',\n )\n }\n\n return key\n}\n\nexport function configureFalClient(config?: FalClientConfig): void {\n
|
|
1
|
+
{"version":3,"file":"client.js","sources":["../../../src/utils/client.ts"],"sourcesContent":["import { fal } from '@fal-ai/client'\n\nexport interface FalClientConfig {\n apiKey: string\n proxyUrl?: string\n}\n\ninterface EnvObject {\n FAL_KEY?: string\n}\n\ninterface WindowWithEnv {\n env?: EnvObject\n}\n\nfunction getEnvironment(): EnvObject | undefined {\n if (typeof globalThis !== 'undefined') {\n const win = (globalThis as { window?: WindowWithEnv }).window\n if (win?.env) {\n return win.env\n }\n }\n if (typeof process !== 'undefined') {\n return process.env as EnvObject\n }\n return undefined\n}\n\nexport function getFalApiKeyFromEnv(): string {\n const env = getEnvironment()\n const key = env?.FAL_KEY\n\n if (!key) {\n throw new Error(\n 'FAL_KEY is required. Please set it in your environment variables or use the factory function with an explicit API key.',\n )\n }\n\n return key\n}\n\nexport function configureFalClient(config?: FalClientConfig): void {\n const apiKey = config?.apiKey ?? getFalApiKeyFromEnv()\n fal.config({\n credentials: apiKey,\n ...(config?.proxyUrl ? { proxyUrl: config.proxyUrl } : {}),\n })\n}\n\nexport function generateId(prefix: string): string {\n return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2)}`\n}\n\n/**\n * Extract a safe file extension from a URL. Strips query strings, URL\n * fragments, and any trailing slashes, and only returns the extension when\n * it looks like a real one (2-5 alphanumeric chars). Returns undefined\n * otherwise so callers can fall back to a default.\n */\nexport function extractUrlExtension(url: string): string | undefined {\n // Parse via URL when possible so we only look at the pathname and never\n // mistake a TLD (e.g. the `.com` in `https://x.com/`) for a file extension.\n let pathname: string\n try {\n const parsed = new URL(url)\n pathname = parsed.pathname\n } catch {\n // Fall back to treating the input as a raw path when URL parsing fails\n // (e.g. the caller passed a bare path). Still strip ?query and #fragment.\n pathname = url.split('?')[0]!.split('#')[0]!\n }\n // Drop trailing slashes so `/path/audio.mp3/` still yields `mp3`.\n const normalized = pathname.replace(/\\/+$/, '')\n // Require at least one `/` — otherwise we're looking at an empty pathname\n // (bare-host URLs like `https://x.com/` land here after stripping the\n // trailing slash).\n if (!normalized.includes('/')) return undefined\n const lastSegment = normalized.split('/').pop()\n if (!lastSegment) return undefined\n const extension = lastSegment.split('.').pop()\n if (!extension || extension === lastSegment) return undefined\n return /^[a-z0-9]{2,5}$/i.test(extension) ? extension : undefined\n}\n\n/**\n * Derive a reasonable audio content-type. Prefers the explicit MIME (stripped\n * of parameters), then an extension-based lookup for common audio formats,\n * otherwise falls back to audio/mpeg — fal URLs virtually always serve mp3.\n */\nexport function deriveAudioContentType(\n explicitContentType: string | undefined,\n url: string,\n): string {\n const stripped = explicitContentType?.split(';')[0]?.trim()\n if (stripped) return stripped\n\n const ext = extractUrlExtension(url)?.toLowerCase()\n switch (ext) {\n case 'mp3':\n return 'audio/mpeg'\n case 'wav':\n return 'audio/wav'\n case 'ogg':\n case 'oga':\n return 'audio/ogg'\n case 'flac':\n return 'audio/flac'\n case 'aac':\n return 'audio/aac'\n case 'm4a':\n case 'mp4':\n return 'audio/mp4'\n case 'webm':\n return 'audio/webm'\n default:\n return 'audio/mpeg'\n }\n}\n\n/**\n * Decode a `data:` URL into a Blob so fal-client can auto-upload it via\n * `fal.storage.upload`. fal's inference API rejects data URLs with a 422\n * \"Unsupported data URL\", so we convert them before handing them off.\n *\n * Supports both base64 and URL-encoded data URLs. Returns `undefined` for\n * anything that isn't a data URL, so callers can fall through to other\n * handling (http URLs are passed to fal as-is).\n */\nexport function dataUrlToBlob(value: string): Blob | undefined {\n if (!value.startsWith('data:')) return undefined\n const commaIndex = value.indexOf(',')\n if (commaIndex === -1) return undefined\n\n const header = value.slice(5, commaIndex)\n const payload = value.slice(commaIndex + 1)\n const isBase64 = /;base64$/i.test(header)\n const mimeType = header.split(';')[0] || 'application/octet-stream'\n\n if (isBase64) {\n const binary = atob(payload)\n const bytes = new Uint8Array(binary.length)\n for (let i = 0; i < binary.length; i += 1) {\n bytes[i] = binary.charCodeAt(i)\n }\n return new Blob([bytes], { type: mimeType })\n }\n\n return new Blob([decodeURIComponent(payload)], { type: mimeType })\n}\n\n/**\n * Convert an ArrayBuffer to base64 in a cross-runtime way.\n *\n * The naive `btoa(String.fromCharCode(...bytes))` form blows up V8's argument\n * limit (~65k) on realistic audio payloads, so we either use `Buffer` (Node /\n * Bun) or walk the byte array in a single loop (browser).\n */\nexport function arrayBufferToBase64(bytes: ArrayBuffer): string {\n if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') {\n return Buffer.from(bytes).toString('base64')\n }\n const view = new Uint8Array(bytes)\n let binary = ''\n for (let i = 0; i < view.byteLength; i += 1) {\n binary += String.fromCharCode(view[i]!)\n }\n return btoa(binary)\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { getFalApiKeyFromEnv, configureFalClient, generateId, type FalClientConfig, } from './client.js';
|
|
1
|
+
export { getFalApiKeyFromEnv, configureFalClient, generateId, arrayBufferToBase64, dataUrlToBlob, extractUrlExtension, deriveAudioContentType, type FalClientConfig, } from './client.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/ai-fal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "fal.ai adapter for TanStack AI",
|
|
5
5
|
"author": "",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,12 @@
|
|
|
28
28
|
"tanstack",
|
|
29
29
|
"adapter",
|
|
30
30
|
"image-generation",
|
|
31
|
-
"video-generation"
|
|
31
|
+
"video-generation",
|
|
32
|
+
"audio-generation",
|
|
33
|
+
"text-to-speech",
|
|
34
|
+
"speech-to-text",
|
|
35
|
+
"tts",
|
|
36
|
+
"transcription"
|
|
32
37
|
],
|
|
33
38
|
"dependencies": {
|
|
34
39
|
"@fal-ai/client": "^1.9.4"
|
|
@@ -36,10 +41,10 @@
|
|
|
36
41
|
"devDependencies": {
|
|
37
42
|
"@vitest/coverage-v8": "4.0.14",
|
|
38
43
|
"vite": "^7.2.7",
|
|
39
|
-
"@tanstack/ai": "0.
|
|
44
|
+
"@tanstack/ai": "0.14.0"
|
|
40
45
|
},
|
|
41
46
|
"peerDependencies": {
|
|
42
|
-
"@tanstack/ai": "0.
|
|
47
|
+
"@tanstack/ai": "0.14.0"
|
|
43
48
|
},
|
|
44
49
|
"scripts": {
|
|
45
50
|
"build": "vite build",
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { fal } from '@fal-ai/client'
|
|
2
|
+
import { BaseAudioAdapter } from '@tanstack/ai/adapters'
|
|
3
|
+
import {
|
|
4
|
+
configureFalClient,
|
|
5
|
+
deriveAudioContentType,
|
|
6
|
+
generateId as utilGenerateId,
|
|
7
|
+
} from '../utils'
|
|
8
|
+
import type { OutputType, Result } from '@fal-ai/client'
|
|
9
|
+
import type {
|
|
10
|
+
AudioGenerationOptions,
|
|
11
|
+
AudioGenerationResult,
|
|
12
|
+
} from '@tanstack/ai'
|
|
13
|
+
import type { FalClientConfig } from '../utils'
|
|
14
|
+
import type { FalModel, FalModelInput } from '../model-meta'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Provider options for audio generation, excluding fields TanStack AI handles.
|
|
18
|
+
*/
|
|
19
|
+
export type FalAudioProviderOptions<TModel extends string> = Omit<
|
|
20
|
+
FalModelInput<TModel>,
|
|
21
|
+
'prompt'
|
|
22
|
+
>
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fal audio models don't agree on the name for "how long should this be."
|
|
26
|
+
* Most accept seconds via `duration`, but a few use model-specific fields —
|
|
27
|
+
* e.g. ElevenLabs wants milliseconds via `music_length_ms`, Stable Audio wants
|
|
28
|
+
* `seconds_total`. Explicit per-model entries override the default; user
|
|
29
|
+
* `modelOptions` still win over either.
|
|
30
|
+
*/
|
|
31
|
+
const DURATION_FRAGMENT_BUILDERS: Record<
|
|
32
|
+
string,
|
|
33
|
+
(seconds: number) => Record<string, unknown>
|
|
34
|
+
> = {
|
|
35
|
+
'fal-ai/elevenlabs/music': (seconds) => ({ music_length_ms: seconds * 1000 }),
|
|
36
|
+
'fal-ai/stable-audio-25/text-to-audio': (seconds) => ({
|
|
37
|
+
seconds_total: seconds,
|
|
38
|
+
}),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildDurationFragment(
|
|
42
|
+
model: string,
|
|
43
|
+
duration: number | undefined,
|
|
44
|
+
): Record<string, unknown> {
|
|
45
|
+
if (duration == null) return {}
|
|
46
|
+
const builder = DURATION_FRAGMENT_BUILDERS[model]
|
|
47
|
+
return builder ? builder(duration) : { duration }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* fal.ai audio generation adapter.
|
|
52
|
+
*
|
|
53
|
+
* Supports fal.ai audio models like diffrhythm (music), sound effects, etc.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```typescript
|
|
57
|
+
* const adapter = falAudio('fal-ai/diffrhythm')
|
|
58
|
+
* const result = await generateAudio({
|
|
59
|
+
* adapter,
|
|
60
|
+
* prompt: 'An upbeat electronic track with synths',
|
|
61
|
+
* duration: 10,
|
|
62
|
+
* })
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export class FalAudioAdapter<TModel extends FalModel> extends BaseAudioAdapter<
|
|
66
|
+
TModel,
|
|
67
|
+
FalAudioProviderOptions<TModel>
|
|
68
|
+
> {
|
|
69
|
+
readonly name = 'fal' as const
|
|
70
|
+
|
|
71
|
+
constructor(model: TModel, config?: FalClientConfig) {
|
|
72
|
+
super(model, {})
|
|
73
|
+
configureFalClient(config)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async generateAudio(
|
|
77
|
+
options: AudioGenerationOptions<FalAudioProviderOptions<TModel>>,
|
|
78
|
+
): Promise<AudioGenerationResult> {
|
|
79
|
+
const { logger } = options
|
|
80
|
+
logger.request(`activity=generateAudio provider=fal model=${this.model}`, {
|
|
81
|
+
provider: 'fal',
|
|
82
|
+
model: this.model,
|
|
83
|
+
})
|
|
84
|
+
try {
|
|
85
|
+
const input = this.buildInput(options)
|
|
86
|
+
const result = await fal.subscribe(this.model, { input })
|
|
87
|
+
return this.transformResponse(result)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
logger.errors('fal.generateAudio fatal', {
|
|
90
|
+
error,
|
|
91
|
+
source: 'fal.generateAudio',
|
|
92
|
+
})
|
|
93
|
+
throw error
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private buildInput(
|
|
98
|
+
options: AudioGenerationOptions<FalAudioProviderOptions<TModel>>,
|
|
99
|
+
): FalModelInput<TModel> {
|
|
100
|
+
return {
|
|
101
|
+
...buildDurationFragment(this.model, options.duration),
|
|
102
|
+
...options.modelOptions,
|
|
103
|
+
prompt: options.prompt,
|
|
104
|
+
} as FalModelInput<TModel>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
protected override generateId(): string {
|
|
108
|
+
return utilGenerateId(this.name)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private transformResponse(
|
|
112
|
+
response: Result<OutputType<TModel>>,
|
|
113
|
+
): AudioGenerationResult {
|
|
114
|
+
const data = response.data as Record<string, unknown>
|
|
115
|
+
|
|
116
|
+
// fal returns { audio: { url, content_type } } or { audio_url: string }
|
|
117
|
+
let audioUrl: string | undefined
|
|
118
|
+
let contentType: string | undefined
|
|
119
|
+
|
|
120
|
+
if (data.audio && typeof data.audio === 'object' && 'url' in data.audio) {
|
|
121
|
+
const audioObj = data.audio as { url: string; content_type?: string }
|
|
122
|
+
audioUrl = audioObj.url
|
|
123
|
+
contentType = audioObj.content_type
|
|
124
|
+
} else if (typeof data.audio_url === 'string') {
|
|
125
|
+
audioUrl = data.audio_url
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!audioUrl) {
|
|
129
|
+
throw new Error('Audio URL not found in fal audio generation response')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
id: response.requestId || this.generateId(),
|
|
134
|
+
model: this.model,
|
|
135
|
+
audio: {
|
|
136
|
+
url: audioUrl,
|
|
137
|
+
contentType: deriveAudioContentType(contentType, audioUrl),
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function falAudio<TModel extends FalModel>(
|
|
144
|
+
model: TModel,
|
|
145
|
+
config?: FalClientConfig,
|
|
146
|
+
): FalAudioAdapter<TModel> {
|
|
147
|
+
return new FalAudioAdapter(model, config)
|
|
148
|
+
}
|
package/src/adapters/image.ts
CHANGED
|
@@ -46,7 +46,7 @@ export class FalImageAdapter<TModel extends FalModel> extends BaseImageAdapter<
|
|
|
46
46
|
readonly name = 'fal' as const
|
|
47
47
|
|
|
48
48
|
constructor(model: TModel, config?: FalClientConfig) {
|
|
49
|
-
super({}
|
|
49
|
+
super(model, {})
|
|
50
50
|
configureFalClient(config)
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -110,6 +110,13 @@ export class FalImageAdapter<TModel extends FalModel> extends BaseImageAdapter<
|
|
|
110
110
|
images.push(this.parseImage(data.image))
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
if (images.length === 0) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
'Unexpected fal image response shape. Expected images[] or image{}. Got keys: ' +
|
|
116
|
+
Object.keys(data).join(','),
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
113
120
|
return {
|
|
114
121
|
id: response.requestId || this.generateId(),
|
|
115
122
|
model: this.model,
|
|
@@ -117,16 +124,29 @@ export class FalImageAdapter<TModel extends FalModel> extends BaseImageAdapter<
|
|
|
117
124
|
}
|
|
118
125
|
}
|
|
119
126
|
|
|
120
|
-
private parseImage(img:
|
|
121
|
-
|
|
122
|
-
|
|
127
|
+
private parseImage(img: unknown): GeneratedImage {
|
|
128
|
+
let url: string
|
|
129
|
+
if (typeof img === 'string') {
|
|
130
|
+
url = img
|
|
131
|
+
} else if (
|
|
132
|
+
img &&
|
|
133
|
+
typeof img === 'object' &&
|
|
134
|
+
'url' in img &&
|
|
135
|
+
typeof (img as { url: unknown }).url === 'string'
|
|
136
|
+
) {
|
|
137
|
+
url = (img as { url: string }).url
|
|
138
|
+
} else {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Invalid image payload from fal response: expected string or { url: string }, received ${
|
|
141
|
+
img === null ? 'null' : typeof img
|
|
142
|
+
}`,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
123
146
|
if (url.startsWith('data:')) {
|
|
124
147
|
const base64Match = url.match(/^data:image\/[^;]+;base64,(.+)$/)
|
|
125
|
-
if (base64Match) {
|
|
126
|
-
return {
|
|
127
|
-
b64Json: base64Match[1],
|
|
128
|
-
url,
|
|
129
|
-
}
|
|
148
|
+
if (base64Match && base64Match[1]) {
|
|
149
|
+
return { b64Json: base64Match[1] }
|
|
130
150
|
}
|
|
131
151
|
}
|
|
132
152
|
return { url }
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { fal } from '@fal-ai/client'
|
|
2
|
+
import { BaseTTSAdapter } from '@tanstack/ai/adapters'
|
|
3
|
+
import {
|
|
4
|
+
arrayBufferToBase64,
|
|
5
|
+
configureFalClient,
|
|
6
|
+
extractUrlExtension,
|
|
7
|
+
generateId as utilGenerateId,
|
|
8
|
+
} from '../utils'
|
|
9
|
+
import type { OutputType, Result } from '@fal-ai/client'
|
|
10
|
+
import type { TTSOptions, TTSResult } from '@tanstack/ai'
|
|
11
|
+
import type { FalClientConfig } from '../utils'
|
|
12
|
+
import type { FalModel, FalModelInput } from '../model-meta'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Provider options for TTS, excluding fields TanStack AI handles.
|
|
16
|
+
*/
|
|
17
|
+
export type FalSpeechProviderOptions<TModel extends string> = Omit<
|
|
18
|
+
FalModelInput<TModel>,
|
|
19
|
+
'prompt' | 'text'
|
|
20
|
+
>
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* fal.ai text-to-speech adapter.
|
|
24
|
+
*
|
|
25
|
+
* Supports fal.ai TTS models like kokoro, elevenlabs, etc.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* const adapter = falSpeech('fal-ai/kokoro/american-english')
|
|
30
|
+
* const result = await generateSpeech({
|
|
31
|
+
* adapter,
|
|
32
|
+
* text: 'Hello, world!',
|
|
33
|
+
* voice: 'af_heart',
|
|
34
|
+
* })
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export class FalSpeechAdapter<TModel extends FalModel> extends BaseTTSAdapter<
|
|
38
|
+
TModel,
|
|
39
|
+
FalSpeechProviderOptions<TModel>
|
|
40
|
+
> {
|
|
41
|
+
readonly name = 'fal' as const
|
|
42
|
+
|
|
43
|
+
constructor(model: TModel, config?: FalClientConfig) {
|
|
44
|
+
super(model, {})
|
|
45
|
+
configureFalClient(config)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async generateSpeech(
|
|
49
|
+
options: TTSOptions<FalSpeechProviderOptions<TModel>>,
|
|
50
|
+
): Promise<TTSResult> {
|
|
51
|
+
const { logger } = options
|
|
52
|
+
logger.request(`activity=generateSpeech provider=fal model=${this.model}`, {
|
|
53
|
+
provider: 'fal',
|
|
54
|
+
model: this.model,
|
|
55
|
+
})
|
|
56
|
+
try {
|
|
57
|
+
const input = this.buildInput(options)
|
|
58
|
+
const result = await fal.subscribe(this.model, { input })
|
|
59
|
+
return await this.transformResponse(result)
|
|
60
|
+
} catch (error) {
|
|
61
|
+
logger.errors('fal.generateSpeech fatal', {
|
|
62
|
+
error,
|
|
63
|
+
source: 'fal.generateSpeech',
|
|
64
|
+
})
|
|
65
|
+
throw error
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private buildInput(
|
|
70
|
+
options: TTSOptions<FalSpeechProviderOptions<TModel>>,
|
|
71
|
+
): FalModelInput<TModel> {
|
|
72
|
+
const input = {
|
|
73
|
+
...options.modelOptions,
|
|
74
|
+
// Map text to both prompt and text fields (different models use different fields)
|
|
75
|
+
prompt: options.text,
|
|
76
|
+
text: options.text,
|
|
77
|
+
...(options.voice ? { voice: options.voice } : {}),
|
|
78
|
+
...(options.speed ? { speed: options.speed } : {}),
|
|
79
|
+
} as FalModelInput<TModel>
|
|
80
|
+
return input
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
protected override generateId(): string {
|
|
84
|
+
return utilGenerateId(this.name)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async transformResponse(
|
|
88
|
+
response: Result<OutputType<TModel>>,
|
|
89
|
+
): Promise<TTSResult> {
|
|
90
|
+
const data = response.data as Record<string, unknown>
|
|
91
|
+
|
|
92
|
+
// fal returns { audio: { url, content_type } } or { audio_url: string }
|
|
93
|
+
let audioUrl: string | undefined
|
|
94
|
+
let contentType: string | undefined
|
|
95
|
+
|
|
96
|
+
if (data.audio && typeof data.audio === 'object' && 'url' in data.audio) {
|
|
97
|
+
const audioObj = data.audio as { url: string; content_type?: string }
|
|
98
|
+
audioUrl = audioObj.url
|
|
99
|
+
contentType = audioObj.content_type
|
|
100
|
+
} else if (typeof data.audio_url === 'string') {
|
|
101
|
+
audioUrl = data.audio_url
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!audioUrl) {
|
|
105
|
+
throw new Error('Audio URL not found in fal TTS response')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Fetch the audio and convert to base64 to match TTSResult contract.
|
|
109
|
+
// Using a chunked helper here — spreading Uint8Array into btoa exceeds
|
|
110
|
+
// V8's argument limit (~65k) for any realistic TTS clip.
|
|
111
|
+
const audioResponse = await fetch(audioUrl)
|
|
112
|
+
if (!audioResponse.ok) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Failed to fetch audio from ${audioUrl}: ${audioResponse.status} ${audioResponse.statusText}`,
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
const arrayBuffer = await audioResponse.arrayBuffer()
|
|
118
|
+
const base64 = arrayBufferToBase64(arrayBuffer)
|
|
119
|
+
|
|
120
|
+
// Strip parameters like `; charset=...` from contentType, and only use
|
|
121
|
+
// the URL extension as a fallback when it looks like a real extension.
|
|
122
|
+
const contentTypeMime = contentType?.split(';')[0]?.trim()
|
|
123
|
+
const safeUrlExtension = extractUrlExtension(audioUrl)
|
|
124
|
+
// Prefer URL-derived extension when available (more canonical for file
|
|
125
|
+
// consumers), otherwise derive from the content-type mime subtype, then
|
|
126
|
+
// fall back to `wav`. Normalize `mpeg` → `mp3` so the format field is a
|
|
127
|
+
// usable file extension rather than the IANA subtype.
|
|
128
|
+
const rawFormat =
|
|
129
|
+
safeUrlExtension || contentTypeMime?.split('/')[1] || 'wav'
|
|
130
|
+
const format = rawFormat === 'mpeg' ? 'mp3' : rawFormat
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
id: response.requestId || this.generateId(),
|
|
134
|
+
model: this.model,
|
|
135
|
+
audio: base64,
|
|
136
|
+
format,
|
|
137
|
+
contentType: contentTypeMime || `audio/${format}`,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function falSpeech<TModel extends FalModel>(
|
|
143
|
+
model: TModel,
|
|
144
|
+
config?: FalClientConfig,
|
|
145
|
+
): FalSpeechAdapter<TModel> {
|
|
146
|
+
return new FalSpeechAdapter(model, config)
|
|
147
|
+
}
|