@tanstack/ai-fal 0.8.0 → 0.9.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/image.d.ts +2 -2
- package/dist/esm/adapters/image.js +21 -4
- package/dist/esm/adapters/image.js.map +1 -1
- package/dist/esm/adapters/video.d.ts +2 -2
- package/dist/esm/adapters/video.js +55 -3
- package/dist/esm/adapters/video.js.map +1 -1
- package/dist/esm/image/generated/image-field-overrides.d.ts +1279 -0
- package/dist/esm/image/generated/image-field-overrides.js +368 -0
- package/dist/esm/image/generated/image-field-overrides.js.map +1 -0
- package/dist/esm/image/image-inputs.d.ts +50 -0
- package/dist/esm/image/image-inputs.js +119 -0
- package/dist/esm/image/image-inputs.js.map +1 -0
- package/dist/esm/model-meta.d.ts +38 -2
- package/package.json +13 -5
- package/src/adapters/image.ts +31 -4
- package/src/adapters/video.ts +82 -4
- package/src/image/generated/image-field-overrides.ts +430 -0
- package/src/image/image-inputs.ts +246 -0
- package/src/model-meta.ts +75 -5
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { FAL_IMAGE_FIELD_OVERRIDES } from "./generated/image-field-overrides.js";
|
|
2
|
+
const DEFAULT_FIELDS = {
|
|
3
|
+
single: "image_url",
|
|
4
|
+
multi: "image_urls",
|
|
5
|
+
mask: "mask_url",
|
|
6
|
+
control: "control_image_url",
|
|
7
|
+
reference: "reference_image_urls",
|
|
8
|
+
start: "start_image_url",
|
|
9
|
+
end: "end_image_url"
|
|
10
|
+
};
|
|
11
|
+
const LIST_FIELDS = /* @__PURE__ */ new Set([
|
|
12
|
+
"image_urls",
|
|
13
|
+
"input_image_urls",
|
|
14
|
+
"ref_image_urls",
|
|
15
|
+
"reference_image_urls"
|
|
16
|
+
]);
|
|
17
|
+
function fieldSpecFor(model) {
|
|
18
|
+
const overrides = FAL_IMAGE_FIELD_OVERRIDES[model];
|
|
19
|
+
return { ...DEFAULT_FIELDS, ...overrides };
|
|
20
|
+
}
|
|
21
|
+
function assignField(fields, field, urls, model, what) {
|
|
22
|
+
if (urls.length === 0) return;
|
|
23
|
+
const existing = fields[field];
|
|
24
|
+
if (LIST_FIELDS.has(field)) {
|
|
25
|
+
fields[field] = Array.isArray(existing) ? [...existing, ...urls] : urls;
|
|
26
|
+
} else if (existing !== void 0) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`fal: multiple inputs map to '${field}' on model ${model}. Drop one of the conflicting inputs or pass the field explicitly via modelOptions.`
|
|
29
|
+
);
|
|
30
|
+
} else if (urls.length === 1) {
|
|
31
|
+
fields[field] = urls[0];
|
|
32
|
+
} else {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`fal: model ${model} accepts a single ${what} image via '${field}' (received ${urls.length}).`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function bucketByRole(imageInputs) {
|
|
39
|
+
const buckets = {
|
|
40
|
+
sources: [],
|
|
41
|
+
masks: [],
|
|
42
|
+
controls: [],
|
|
43
|
+
references: [],
|
|
44
|
+
starts: [],
|
|
45
|
+
ends: []
|
|
46
|
+
};
|
|
47
|
+
for (const part of imageInputs) {
|
|
48
|
+
const url = imagePartToUrl(part);
|
|
49
|
+
const role = part.metadata?.role;
|
|
50
|
+
if (role === "mask") buckets.masks.push(url);
|
|
51
|
+
else if (role === "control") buckets.controls.push(url);
|
|
52
|
+
else if (role === "reference" || role === "character")
|
|
53
|
+
buckets.references.push(url);
|
|
54
|
+
else if (role === "start_frame") buckets.starts.push(url);
|
|
55
|
+
else if (role === "end_frame") buckets.ends.push(url);
|
|
56
|
+
else buckets.sources.push(url);
|
|
57
|
+
}
|
|
58
|
+
return buckets;
|
|
59
|
+
}
|
|
60
|
+
function mapImageInputsToFalFields(model, imageInputs) {
|
|
61
|
+
if (!imageInputs || imageInputs.length === 0) return {};
|
|
62
|
+
const spec = fieldSpecFor(model);
|
|
63
|
+
const { sources, masks, controls, references, starts, ends } = bucketByRole(imageInputs);
|
|
64
|
+
const allSources = [...sources, ...starts, ...ends];
|
|
65
|
+
if (masks.length > 1) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`fal: only one input with metadata.role === 'mask' is supported per request (received ${masks.length}).`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (controls.length > 1) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`fal: only one input with metadata.role === 'control' is supported per request (received ${controls.length}).`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const fields = {};
|
|
76
|
+
const sourceField = allSources.length > 1 ? spec.multi : spec.single;
|
|
77
|
+
assignField(fields, sourceField, allSources, model, "source");
|
|
78
|
+
assignField(fields, spec.reference, references, model, "reference");
|
|
79
|
+
assignField(fields, spec.mask, masks, model, "mask");
|
|
80
|
+
assignField(fields, spec.control, controls, model, "control");
|
|
81
|
+
return fields;
|
|
82
|
+
}
|
|
83
|
+
function mapImageInputsToFalVideoFields(model, imageInputs) {
|
|
84
|
+
if (!imageInputs || imageInputs.length === 0) return {};
|
|
85
|
+
const spec = fieldSpecFor(model);
|
|
86
|
+
const { sources, masks, controls, references, starts, ends } = bucketByRole(imageInputs);
|
|
87
|
+
if (masks.length > 0 || controls.length > 0) {
|
|
88
|
+
const role = masks.length > 0 ? "mask" : "control";
|
|
89
|
+
throw new Error(
|
|
90
|
+
`fal: metadata.role === '${role}' is not supported for video generation on model ${model}. Remove the role or pass the field explicitly via modelOptions.`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (starts.length > 1) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`fal: only one input with metadata.role === 'start_frame' is supported (received ${starts.length}).`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (ends.length > 1) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`fal: only one input with metadata.role === 'end_frame' is supported (received ${ends.length}).`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const fields = {};
|
|
104
|
+
const sourceField = sources.length > 1 ? spec.multi : spec.single;
|
|
105
|
+
assignField(fields, sourceField, sources, model, "source");
|
|
106
|
+
assignField(fields, spec.reference, references, model, "reference");
|
|
107
|
+
assignField(fields, spec.start, starts, model, "start frame");
|
|
108
|
+
assignField(fields, spec.end, ends, model, "end frame");
|
|
109
|
+
return fields;
|
|
110
|
+
}
|
|
111
|
+
function imagePartToUrl(part) {
|
|
112
|
+
if (part.source.type === "url") return part.source.value;
|
|
113
|
+
return `data:${part.source.mimeType};base64,${part.source.value}`;
|
|
114
|
+
}
|
|
115
|
+
export {
|
|
116
|
+
mapImageInputsToFalFields,
|
|
117
|
+
mapImageInputsToFalVideoFields
|
|
118
|
+
};
|
|
119
|
+
//# sourceMappingURL=image-inputs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"image-inputs.js","sources":["../../../src/image/image-inputs.ts"],"sourcesContent":["import { FAL_IMAGE_FIELD_OVERRIDES } from './generated/image-field-overrides'\nimport type {\n FalImageFieldName,\n FalImageFieldOverride,\n} from './generated/image-field-overrides'\nimport type { ImagePart, MediaInputMetadata } from '@tanstack/ai'\nimport type { FalModel, FalModelInput } from '../model-meta'\n\n/**\n * The image-conditioning fields the mappers may set, narrowed to the ones\n * that actually exist on the given endpoint's input type. For endpoints\n * unknown to the installed `@fal-ai/client` this widens to all known field\n * names.\n */\nexport type FalImageInputFields<TModel extends string> = Partial<\n Pick<\n FalModelInput<TModel>,\n Extract<keyof FalModelInput<TModel>, FalImageFieldName>\n >\n>\n\n/**\n * Default field per routing role. Endpoint-specific deviations live in the\n * generated `FAL_IMAGE_FIELD_OVERRIDES` map (regenerate with\n * `pnpm generate:fal-image-fields`); these defaults must stay in sync with\n * `DEFAULTS` in scripts/generate-fal-image-field-map.ts.\n */\nconst DEFAULT_FIELDS = {\n single: 'image_url',\n multi: 'image_urls',\n mask: 'mask_url',\n control: 'control_image_url',\n reference: 'reference_image_urls',\n start: 'start_image_url',\n end: 'end_image_url',\n} satisfies Required<FalImageFieldOverride>\n\n/**\n * Field names that accept an array of images. The generator asserts the\n * SDK types agree with this set, so wrap-vs-scalar decisions stay correct.\n */\nconst LIST_FIELDS = new Set<string>([\n 'image_urls',\n 'input_image_urls',\n 'ref_image_urls',\n 'reference_image_urls',\n])\n\n/** Resolve the per-role field names for a model: defaults + generated overrides. */\nfunction fieldSpecFor(model: string): Required<FalImageFieldOverride> {\n const overrides = (\n FAL_IMAGE_FIELD_OVERRIDES as Record<string, FalImageFieldOverride>\n )[model]\n return { ...DEFAULT_FIELDS, ...overrides }\n}\n\n/**\n * Assign URLs to a field, wrapping or unwrapping based on whether the field\n * takes an array. When two roles resolve to the same list field (e.g.\n * sources and references both land on `image_urls` for nano-banana edit)\n * the values are merged in assignment order; two roles resolving to the\n * same scalar field is ambiguous and throws. Throws when multiple images\n * target a scalar field.\n */\nfunction assignField(\n fields: Record<string, unknown>,\n field: string,\n urls: Array<string>,\n model: string,\n what: string,\n): void {\n if (urls.length === 0) return\n const existing = fields[field]\n if (LIST_FIELDS.has(field)) {\n fields[field] = Array.isArray(existing) ? [...existing, ...urls] : urls\n } else if (existing !== undefined) {\n throw new Error(\n `fal: multiple inputs map to '${field}' on model ${model}. Drop one of the conflicting inputs or pass the field explicitly via modelOptions.`,\n )\n } else if (urls.length === 1) {\n fields[field] = urls[0]\n } else {\n throw new Error(\n `fal: model ${model} accepts a single ${what} image via '${field}' (received ${urls.length}).`,\n )\n }\n}\n\ninterface RoleBuckets {\n sources: Array<string>\n masks: Array<string>\n controls: Array<string>\n references: Array<string>\n starts: Array<string>\n ends: Array<string>\n}\n\nfunction bucketByRole(\n imageInputs: ReadonlyArray<ImagePart<MediaInputMetadata>>,\n): RoleBuckets {\n const buckets: RoleBuckets = {\n sources: [],\n masks: [],\n controls: [],\n references: [],\n starts: [],\n ends: [],\n }\n for (const part of imageInputs) {\n const url = imagePartToUrl(part)\n const role = part.metadata?.role\n if (role === 'mask') buckets.masks.push(url)\n else if (role === 'control') buckets.controls.push(url)\n else if (role === 'reference' || role === 'character')\n buckets.references.push(url)\n else if (role === 'start_frame') buckets.starts.push(url)\n else if (role === 'end_frame') buckets.ends.push(url)\n else buckets.sources.push(url)\n }\n return buckets\n}\n\n/**\n * Map the prompt's image parts onto fal.ai image-endpoint fields.\n *\n * fal endpoints use different field names for image-conditioned generation\n * (~80% use `image_url` for single; the rest use `image_urls`,\n * `reference_image_urls`, `mask_url`, `control_image_url`, etc.). Field\n * names are resolved per endpoint from the generated\n * `FAL_IMAGE_FIELD_OVERRIDES` map (derived from the fal SDK's endpoint\n * types), falling back to the defaults above for endpoints the installed\n * SDK doesn't know:\n *\n * - parts with `metadata.role === 'mask'` → spec.mask (single)\n * - parts with `metadata.role === 'control'` → spec.control (single)\n * - `role === 'reference' | 'character'` → spec.reference\n * - `role === 'start_frame' | 'end_frame'` → treated as sources (frame\n * roles only apply to video generation)\n * - remaining parts → spec.single / spec.multi\n *\n * Users can always override the resulting field shape via `modelOptions`\n * (spread before these fields), or pass everything through `modelOptions`\n * directly when the mapping doesn't match an obscure endpoint.\n */\nexport function mapImageInputsToFalFields<TModel extends FalModel>(\n model: TModel,\n imageInputs?: ReadonlyArray<ImagePart<MediaInputMetadata>>,\n): FalImageInputFields<TModel> {\n if (!imageInputs || imageInputs.length === 0) return {}\n\n const spec = fieldSpecFor(model)\n const { sources, masks, controls, references, starts, ends } =\n bucketByRole(imageInputs)\n // Frame roles aren't meaningful for image generation; treat as the\n // primary source. The video mapper handles start/end framing.\n const allSources = [...sources, ...starts, ...ends]\n\n if (masks.length > 1) {\n throw new Error(\n `fal: only one input with metadata.role === 'mask' is supported per request (received ${masks.length}).`,\n )\n }\n if (controls.length > 1) {\n throw new Error(\n `fal: only one input with metadata.role === 'control' is supported per request (received ${controls.length}).`,\n )\n }\n\n const fields: Record<string, unknown> = {}\n const sourceField = allSources.length > 1 ? spec.multi : spec.single\n assignField(fields, sourceField, allSources, model, 'source')\n assignField(fields, spec.reference, references, model, 'reference')\n assignField(fields, spec.mask, masks, model, 'mask')\n assignField(fields, spec.control, controls, model, 'control')\n\n return fields as FalImageInputFields<TModel>\n}\n\n/**\n * Map the prompt's image parts onto fal.ai video-endpoint fields.\n *\n * Video endpoints often expose a start frame as `image_url` (76% of i2v\n * models) plus an optional `end_image_url`. Multi-reference video models\n * (Kling O3, Seedance reference-to-video) use `reference_image_urls` or\n * `image_urls`. Field names resolve through the same generated override\n * map as the image mapper — e.g. `role: 'start_frame'` lands on `image_url`\n * for Kling/Veo image-to-video and `first_frame_url` for Pixverse. Mapping:\n *\n * - `metadata.role === 'start_frame'` → spec.start\n * - `metadata.role === 'end_frame'` → spec.end\n * - `metadata.role === 'reference' | 'character'` → spec.reference\n * - `metadata.role === 'mask' | 'control'` → throws (no video routing)\n * - remaining parts (no role) → spec.single / spec.multi\n */\nexport function mapImageInputsToFalVideoFields<TModel extends FalModel>(\n model: TModel,\n imageInputs?: ReadonlyArray<ImagePart<MediaInputMetadata>>,\n): FalImageInputFields<TModel> {\n if (!imageInputs || imageInputs.length === 0) return {}\n\n const spec = fieldSpecFor(model)\n const { sources, masks, controls, references, starts, ends } =\n bucketByRole(imageInputs)\n // Mask / control roles have no video-specific routing; silently repurposing\n // them as source frames would hide the problem, so reject them instead.\n if (masks.length > 0 || controls.length > 0) {\n const role = masks.length > 0 ? 'mask' : 'control'\n throw new Error(\n `fal: metadata.role === '${role}' is not supported for video generation on model ${model}. ` +\n `Remove the role or pass the field explicitly via modelOptions.`,\n )\n }\n\n if (starts.length > 1) {\n throw new Error(\n `fal: only one input with metadata.role === 'start_frame' is supported (received ${starts.length}).`,\n )\n }\n if (ends.length > 1) {\n throw new Error(\n `fal: only one input with metadata.role === 'end_frame' is supported (received ${ends.length}).`,\n )\n }\n\n const fields: Record<string, unknown> = {}\n const sourceField = sources.length > 1 ? spec.multi : spec.single\n assignField(fields, sourceField, sources, model, 'source')\n assignField(fields, spec.reference, references, model, 'reference')\n // Frame roles assign last: when an endpoint routes the start frame to its\n // generic source field (e.g. Kling image-to-video) and an unroled source\n // was also provided, assignField rejects the ambiguous combination.\n assignField(fields, spec.start, starts, model, 'start frame')\n assignField(fields, spec.end, ends, model, 'end frame')\n\n return fields as FalImageInputFields<TModel>\n}\n\n/**\n * Convert a TanStack ImagePart into a string suitable for fal's URL-based\n * input fields. URL sources pass through; data sources are emitted as a\n * `data:<mime>;base64,<value>` URI which fal endpoints accept on the wire.\n */\nfunction imagePartToUrl(part: ImagePart<MediaInputMetadata>): string {\n if (part.source.type === 'url') return part.source.value\n return `data:${part.source.mimeType};base64,${part.source.value}`\n}\n"],"names":[],"mappings":"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"}
|
package/dist/esm/model-meta.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { EndpointTypeMap } from '@fal-ai/client/endpoints';
|
|
2
|
+
import { MediaPromptModality } from '@tanstack/ai';
|
|
3
|
+
import { FalImageFieldName } from './image/generated/image-field-overrides.js';
|
|
2
4
|
export type { EndpointTypeMap } from '@fal-ai/client/endpoints';
|
|
3
5
|
/**
|
|
4
6
|
* All known fal.ai model IDs with autocomplete support.
|
|
@@ -40,6 +42,21 @@ export type FalModelImageSizeInput<TModel extends string> = TModel extends keyof
|
|
|
40
42
|
} : never : {
|
|
41
43
|
image_size: string;
|
|
42
44
|
};
|
|
45
|
+
/**
|
|
46
|
+
* Input fields the prompt-part mappers can populate: image conditioning via
|
|
47
|
+
* the generated `FalImageFieldName` set, video conditioning via
|
|
48
|
+
* `video_url` / `video_urls` / `reference_video_urls`, audio via `audio_url`.
|
|
49
|
+
*/
|
|
50
|
+
type FalMediaInputFieldName = FalImageFieldName | 'video_url' | 'video_urls' | 'reference_video_urls' | 'audio_url';
|
|
51
|
+
/**
|
|
52
|
+
* Demote an endpoint input's media-conditioning fields from required to
|
|
53
|
+
* optional. Image-to-video endpoints declare e.g. `image_url` as a required
|
|
54
|
+
* input, but with a multimodal `prompt` the start frame usually arrives as a
|
|
55
|
+
* prompt part — requiring it in `modelOptions` too would force redundancy.
|
|
56
|
+
* The fields stay passable via `modelOptions` as the documented escape hatch
|
|
57
|
+
* (and override-wise the mapped prompt-part fields win on conflict).
|
|
58
|
+
*/
|
|
59
|
+
type WithOptionalMediaInputFields<TInput> = Omit<TInput, Extract<keyof TInput, FalMediaInputFieldName>> & Partial<Pick<TInput, Extract<keyof TInput, FalMediaInputFieldName>>>;
|
|
43
60
|
/**
|
|
44
61
|
* Provider options for image generation, excluding fields TanStack AI handles.
|
|
45
62
|
* Use this for the `modelOptions` parameter in image generation.
|
|
@@ -48,7 +65,7 @@ export type FalModelImageSizeInput<TModel extends string> = TModel extends keyof
|
|
|
48
65
|
* type FluxOptions = FalImageProviderOptions<'fal-ai/flux/dev'>
|
|
49
66
|
* // { num_inference_steps?: number; guidance_scale?: number; seed?: number; ... }
|
|
50
67
|
*/
|
|
51
|
-
export type FalImageProviderOptions<TModel extends string> = Omit<FalModelInput<TModel>, 'prompt'
|
|
68
|
+
export type FalImageProviderOptions<TModel extends string> = WithOptionalMediaInputFields<Omit<FalModelInput<TModel>, 'prompt'>>;
|
|
52
69
|
/**
|
|
53
70
|
* Extract the video size type supported by a specific fal model.
|
|
54
71
|
* Video models typically use aspect_ratio and/or resolution fields.
|
|
@@ -71,11 +88,30 @@ export type FalModelVideoSizeInput<TModel extends string> = TModel extends keyof
|
|
|
71
88
|
aspect_ratio?: string;
|
|
72
89
|
resolution?: string;
|
|
73
90
|
};
|
|
91
|
+
/**
|
|
92
|
+
* Prompt input modalities for a fal image endpoint, derived from the SDK's
|
|
93
|
+
* endpoint input type: an endpoint accepts image prompt parts exactly when
|
|
94
|
+
* its input declares one of the known image-conditioning fields
|
|
95
|
+
* (`image_url`, `image_urls`, `mask_url`, …). Endpoints unknown to the
|
|
96
|
+
* installed SDK are unconstrained.
|
|
97
|
+
*/
|
|
98
|
+
export type FalImagePromptModalitiesFor<TModel extends string> = TModel extends keyof EndpointTypeMap ? ReadonlyArray<Extract<keyof FalModelInput<TModel>, FalImageFieldName> extends never ? never : 'image'> : ReadonlyArray<MediaPromptModality>;
|
|
99
|
+
/**
|
|
100
|
+
* Prompt input modalities for a fal video endpoint. Image conditioning is
|
|
101
|
+
* detected via the same field set as image endpoints; video conditioning via
|
|
102
|
+
* `video_url` / `video_urls` / `reference_video_urls`; audio conditioning
|
|
103
|
+
* via `audio_url`. Endpoints unknown to the installed SDK are unconstrained.
|
|
104
|
+
*/
|
|
105
|
+
export type FalVideoPromptModalitiesFor<TModel extends string> = TModel extends keyof EndpointTypeMap ? ReadonlyArray<(Extract<keyof FalModelInput<TModel>, FalImageFieldName> extends never ? never : 'image') | (Extract<keyof FalModelInput<TModel>, 'video_url' | 'video_urls' | 'reference_video_urls'> extends never ? never : 'video') | (Extract<keyof FalModelInput<TModel>, 'audio_url'> extends never ? never : 'audio')> : ReadonlyArray<MediaPromptModality>;
|
|
74
106
|
/**
|
|
75
107
|
* Provider options for video generation, excluding fields TanStack AI handles.
|
|
76
108
|
* Use this for the `modelOptions` parameter in video generation.
|
|
109
|
+
*
|
|
110
|
+
* Media-conditioning fields (start/end frame, reference images, source
|
|
111
|
+
* video/audio) are optional here even when the endpoint requires them —
|
|
112
|
+
* they're usually supplied as prompt parts instead.
|
|
77
113
|
*/
|
|
78
|
-
export type FalVideoProviderOptions<TModel extends string> = TModel extends keyof EndpointTypeMap ? Omit<FalModelInput<TModel>, 'prompt'
|
|
114
|
+
export type FalVideoProviderOptions<TModel extends string> = TModel extends keyof EndpointTypeMap ? WithOptionalMediaInputFields<Omit<FalModelInput<TModel>, 'prompt'>> : Record<string, unknown>;
|
|
79
115
|
/**
|
|
80
116
|
* Provider options for TTS, excluding fields TanStack AI handles.
|
|
81
117
|
* Use this for the `modelOptions` parameter in speech generation.
|
package/package.json
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/ai-fal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "fal.ai adapter for TanStack AI image, video, audio, speech, and transcription generation.",
|
|
5
|
-
"author": "",
|
|
5
|
+
"author": "Tanner Linsley",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"homepage": "https://tanstack.com/ai",
|
|
7
8
|
"repository": {
|
|
8
9
|
"type": "git",
|
|
9
10
|
"url": "git+https://github.com/TanStack/ai.git",
|
|
10
11
|
"directory": "packages/ai-fal"
|
|
11
12
|
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/TanStack/ai/issues"
|
|
15
|
+
},
|
|
16
|
+
"funding": {
|
|
17
|
+
"type": "github",
|
|
18
|
+
"url": "https://github.com/sponsors/tannerlinsley"
|
|
19
|
+
},
|
|
12
20
|
"type": "module",
|
|
13
21
|
"module": "./dist/esm/index.js",
|
|
14
22
|
"types": "./dist/esm/index.d.ts",
|
|
@@ -41,15 +49,15 @@
|
|
|
41
49
|
],
|
|
42
50
|
"dependencies": {
|
|
43
51
|
"@fal-ai/client": "^1.10.1",
|
|
44
|
-
"@tanstack/ai-utils": "0.2.
|
|
52
|
+
"@tanstack/ai-utils": "0.2.2"
|
|
45
53
|
},
|
|
46
54
|
"devDependencies": {
|
|
47
55
|
"@vitest/coverage-v8": "4.0.14",
|
|
48
56
|
"vite": "^7.3.3",
|
|
49
|
-
"@tanstack/ai": "0.
|
|
57
|
+
"@tanstack/ai": "0.32.0"
|
|
50
58
|
},
|
|
51
59
|
"peerDependencies": {
|
|
52
|
-
"@tanstack/ai": "0.
|
|
60
|
+
"@tanstack/ai": "0.32.0"
|
|
53
61
|
},
|
|
54
62
|
"scripts": {
|
|
55
63
|
"build": "vite build",
|
package/src/adapters/image.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fal } from '@fal-ai/client'
|
|
2
|
+
import { resolveMediaPrompt } from '@tanstack/ai'
|
|
2
3
|
import { BaseImageAdapter } from '@tanstack/ai/adapters'
|
|
3
4
|
import {
|
|
4
5
|
buildFalUsage,
|
|
@@ -7,14 +8,17 @@ import {
|
|
|
7
8
|
generateId as utilGenerateId,
|
|
8
9
|
} from '../utils'
|
|
9
10
|
import { mapSizeToFalFormat } from '../image/image-provider-options'
|
|
11
|
+
import { mapImageInputsToFalFields } from '../image/image-inputs'
|
|
10
12
|
import type { OutputType, Result } from '@fal-ai/client'
|
|
11
13
|
import type { FalClientConfig } from '../utils'
|
|
12
14
|
import type {
|
|
13
15
|
GeneratedImage,
|
|
14
16
|
ImageGenerationOptions,
|
|
15
17
|
ImageGenerationResult,
|
|
18
|
+
ResolvedMediaPrompt,
|
|
16
19
|
} from '@tanstack/ai'
|
|
17
20
|
import type {
|
|
21
|
+
FalImagePromptModalitiesFor,
|
|
18
22
|
FalImageProviderOptions,
|
|
19
23
|
FalModel,
|
|
20
24
|
FalModelImageSize,
|
|
@@ -45,7 +49,8 @@ export class FalImageAdapter<TModel extends FalModel> extends BaseImageAdapter<
|
|
|
45
49
|
TModel,
|
|
46
50
|
FalImageProviderOptions<TModel>,
|
|
47
51
|
Record<TModel, FalImageProviderOptions<TModel>>,
|
|
48
|
-
Record<TModel, FalModelImageSize<TModel
|
|
52
|
+
Record<TModel, FalModelImageSize<TModel>>,
|
|
53
|
+
Record<TModel, FalImagePromptModalitiesFor<TModel>>
|
|
49
54
|
> {
|
|
50
55
|
override readonly kind = 'image' as const
|
|
51
56
|
readonly name = 'fal' as const
|
|
@@ -68,8 +73,21 @@ export class FalImageAdapter<TModel extends FalModel> extends BaseImageAdapter<
|
|
|
68
73
|
model: this.model,
|
|
69
74
|
})
|
|
70
75
|
|
|
76
|
+
const resolved = resolveMediaPrompt(options.prompt)
|
|
77
|
+
|
|
78
|
+
if (resolved.videos.length > 0) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`fal.generateImages does not support video prompt parts on model ${this.model}.`,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
if (resolved.audios.length > 0) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`fal.generateImages does not support audio prompt parts on model ${this.model}.`,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
71
89
|
try {
|
|
72
|
-
const input = this.buildInput(options)
|
|
90
|
+
const input = this.buildInput(options, resolved)
|
|
73
91
|
const result = await fal.subscribe(this.model, { input })
|
|
74
92
|
return this.transformResponse(result)
|
|
75
93
|
} catch (error) {
|
|
@@ -86,12 +104,21 @@ export class FalImageAdapter<TModel extends FalModel> extends BaseImageAdapter<
|
|
|
86
104
|
FalImageProviderOptions<TModel>,
|
|
87
105
|
FalModelImageSize<TModel>
|
|
88
106
|
>,
|
|
107
|
+
resolved: ResolvedMediaPrompt,
|
|
89
108
|
): FalModelInput<TModel> {
|
|
90
109
|
const sizeParams = mapSizeToFalFormat(options.size)
|
|
110
|
+
// Order matters: size and derived image-input fields first, then
|
|
111
|
+
// modelOptions (so explicit user overrides win for mask_url /
|
|
112
|
+
// control_image_url / reference_image_urls), then the call-controlled
|
|
113
|
+
// prompt / num_images, which always take precedence.
|
|
114
|
+
const inputFields = mapImageInputsToFalFields(this.model, resolved.images)
|
|
91
115
|
const input = {
|
|
92
|
-
...options.modelOptions,
|
|
93
116
|
...sizeParams,
|
|
94
|
-
|
|
117
|
+
...inputFields,
|
|
118
|
+
...options.modelOptions,
|
|
119
|
+
// Media-only prompts (e.g. upscalers, background removal) omit the
|
|
120
|
+
// prompt field entirely rather than sending an empty string.
|
|
121
|
+
...(resolved.text ? { prompt: resolved.text } : {}),
|
|
95
122
|
num_images: options.numberOfImages,
|
|
96
123
|
} as FalModelInput<TModel>
|
|
97
124
|
return input
|
package/src/adapters/video.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fal } from '@fal-ai/client'
|
|
2
|
+
import { resolveMediaPrompt } from '@tanstack/ai'
|
|
2
3
|
import { BaseVideoAdapter } from '@tanstack/ai/adapters'
|
|
3
4
|
import {
|
|
4
5
|
buildFalUsage,
|
|
@@ -7,9 +8,13 @@ import {
|
|
|
7
8
|
generateId as utilGenerateId,
|
|
8
9
|
} from '../utils'
|
|
9
10
|
import { mapVideoSizeToFalFormat } from '../video/video-provider-options'
|
|
11
|
+
import { mapImageInputsToFalVideoFields } from '../image/image-inputs'
|
|
10
12
|
import type {
|
|
13
|
+
AudioPart,
|
|
14
|
+
MediaInputMetadata,
|
|
11
15
|
VideoGenerationOptions,
|
|
12
16
|
VideoJobResult,
|
|
17
|
+
VideoPart,
|
|
13
18
|
VideoStatusResult,
|
|
14
19
|
VideoUrlResult,
|
|
15
20
|
} from '@tanstack/ai'
|
|
@@ -17,10 +22,68 @@ import type {
|
|
|
17
22
|
FalModel,
|
|
18
23
|
FalModelInput,
|
|
19
24
|
FalModelVideoSize,
|
|
25
|
+
FalVideoPromptModalitiesFor,
|
|
20
26
|
FalVideoProviderOptions,
|
|
21
27
|
} from '../model-meta'
|
|
22
28
|
import type { FalClientConfig } from '../utils'
|
|
23
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Map video conditioning inputs onto fal field names.
|
|
32
|
+
* Video-to-video endpoints on fal almost universally use `video_url`; the
|
|
33
|
+
* occasional model takes `video_urls` (rare). Mirror the image-input logic
|
|
34
|
+
* positionally with a `reference` role escape hatch via `reference_video_urls`.
|
|
35
|
+
*/
|
|
36
|
+
function mapVideoInputsToFalFields(
|
|
37
|
+
videoInputs?: ReadonlyArray<VideoPart<MediaInputMetadata>>,
|
|
38
|
+
): Record<string, unknown> {
|
|
39
|
+
if (!videoInputs || videoInputs.length === 0) return {}
|
|
40
|
+
const references: Array<string> = []
|
|
41
|
+
const sources: Array<string> = []
|
|
42
|
+
for (const part of videoInputs) {
|
|
43
|
+
const url = videoPartToUrl(part)
|
|
44
|
+
if (
|
|
45
|
+
part.metadata?.role === 'reference' ||
|
|
46
|
+
part.metadata?.role === 'character'
|
|
47
|
+
) {
|
|
48
|
+
references.push(url)
|
|
49
|
+
} else {
|
|
50
|
+
sources.push(url)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const out: Record<string, unknown> = {}
|
|
54
|
+
if (references.length > 0) out.reference_video_urls = references
|
|
55
|
+
if (sources.length === 1) {
|
|
56
|
+
out.video_url = sources[0]
|
|
57
|
+
} else if (sources.length > 1) {
|
|
58
|
+
out.video_urls = sources
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mapAudioInputsToFalFields(
|
|
64
|
+
audioInputs?: ReadonlyArray<AudioPart<MediaInputMetadata>>,
|
|
65
|
+
): Record<string, unknown> {
|
|
66
|
+
if (!audioInputs || audioInputs.length === 0) return {}
|
|
67
|
+
const [part, ...rest] = audioInputs
|
|
68
|
+
if (!part || rest.length > 0) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`fal: exactly one audio prompt part is supported (received ${audioInputs.length}).`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
audio_url:
|
|
75
|
+
part.source.type === 'url'
|
|
76
|
+
? part.source.value
|
|
77
|
+
: `data:${part.source.mimeType};base64,${part.source.value}`,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function videoPartToUrl(part: VideoPart<MediaInputMetadata>): string {
|
|
82
|
+
return part.source.type === 'url'
|
|
83
|
+
? part.source.value
|
|
84
|
+
: `data:${part.source.mimeType};base64,${part.source.value}`
|
|
85
|
+
}
|
|
86
|
+
|
|
24
87
|
type FalQueueStatus = 'IN_QUEUE' | 'IN_PROGRESS' | 'COMPLETED'
|
|
25
88
|
|
|
26
89
|
interface FalStatusResponse {
|
|
@@ -69,7 +132,8 @@ export class FalVideoAdapter<TModel extends FalModel> extends BaseVideoAdapter<
|
|
|
69
132
|
TModel,
|
|
70
133
|
FalVideoProviderOptions<TModel>,
|
|
71
134
|
Record<TModel, FalVideoProviderOptions<TModel>>,
|
|
72
|
-
Record<TModel, FalModelVideoSize<TModel
|
|
135
|
+
Record<TModel, FalModelVideoSize<TModel>>,
|
|
136
|
+
Record<TModel, FalVideoPromptModalitiesFor<TModel>>
|
|
73
137
|
> {
|
|
74
138
|
override readonly kind = 'video' as const
|
|
75
139
|
readonly name = 'fal' as const
|
|
@@ -85,7 +149,7 @@ export class FalVideoAdapter<TModel extends FalModel> extends BaseVideoAdapter<
|
|
|
85
149
|
FalModelVideoSize<TModel>
|
|
86
150
|
>,
|
|
87
151
|
): Promise<VideoJobResult> {
|
|
88
|
-
const {
|
|
152
|
+
const { size, duration, modelOptions, logger } = options
|
|
89
153
|
|
|
90
154
|
logger.request(`activity=generateVideo provider=fal model=${this.model}`, {
|
|
91
155
|
provider: 'fal',
|
|
@@ -93,12 +157,26 @@ export class FalVideoAdapter<TModel extends FalModel> extends BaseVideoAdapter<
|
|
|
93
157
|
})
|
|
94
158
|
|
|
95
159
|
try {
|
|
160
|
+
const resolved = resolveMediaPrompt(options.prompt)
|
|
96
161
|
const sizeParams = mapVideoSizeToFalFormat(size)
|
|
162
|
+
const inputImageFields = mapImageInputsToFalVideoFields(
|
|
163
|
+
this.model,
|
|
164
|
+
resolved.images,
|
|
165
|
+
)
|
|
166
|
+
const videoFields = mapVideoInputsToFalFields(resolved.videos)
|
|
167
|
+
const audioFields = mapAudioInputsToFalFields(resolved.audios)
|
|
97
168
|
|
|
98
169
|
const input = {
|
|
99
|
-
...modelOptions,
|
|
100
170
|
...sizeParams,
|
|
101
|
-
|
|
171
|
+
...inputImageFields,
|
|
172
|
+
...videoFields,
|
|
173
|
+
...audioFields,
|
|
174
|
+
// modelOptions applied after derived media fields so explicit user
|
|
175
|
+
// overrides (video_url, reference_video_urls, audio_url, ...) win.
|
|
176
|
+
...modelOptions,
|
|
177
|
+
// Media-only prompts omit the prompt field rather than sending an
|
|
178
|
+
// empty string (e.g. pure image-to-video endpoints).
|
|
179
|
+
...(resolved.text ? { prompt: resolved.text } : {}),
|
|
102
180
|
...(duration ? { duration } : {}),
|
|
103
181
|
} as FalModelInput<TModel>
|
|
104
182
|
|