import "server-only";
interface FalVideoConfig {
apiKey: string;
baseURL: string;
}
interface FalVideoParams {
mode?: "text-to-video" | "first-frame" | "first-last-frame" | "reference-images";
ratio?: string;
duration?: number;
generateAudio?: boolean;
negativePrompt?: string;
cfgScale?: number;
shotType?: "customize" | "intelligent";
}
const DEFAULT_DURATION = 5;
const DEFAULT_RATIO = "16:9";
const DEFAULT_SHOT_TYPE = "customize";
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
function normalizeFalTextInput(value: string) {
return String(value || "")
.replace(/\u00a0/g, " ")
.trim()
.normalize("NFKC");
}
function normalizeFalCredentials(apiKey: string) {
const normalized = normalizeFalTextInput(apiKey);
const stripped = normalized.replace(/^bearer\s+/i, "").replace(/^key\s+/i, "");
const kvMatch = stripped.match(/^(?:fal[_-]?key|api[_-]?key|token)\s*[::]\s*(.+)$/i);
if (kvMatch?.[1]) {
return kvMatch[1].trim().replace(/[^\x20-\x7E]/g, "");
}
return stripped.replace(/[^\x20-\x7E]/g, "");
}
function normalizeFalModelId(modelId: string) {
return normalizeFalTextInput(modelId);
}
function normalizeFalImageUrl(url: string) {
const normalized = normalizeFalTextInput(url);
try {
return new URL(normalized).toString();
} catch {
return normalized;
}
}
function normalizeDuration(value?: number) {
if (!Number.isFinite(value)) return DEFAULT_DURATION;
return Math.max(3, Math.min(15, Math.round(value as number)));
}
function normalizeRatio(value?: string) {
if (!value) return DEFAULT_RATIO;
const ratio = value.trim();
if (["16:9", "9:16", "1:1"].includes(ratio)) return ratio;
return DEFAULT_RATIO;
}
function normalizeShotType(value?: string) {
if (value === "intelligent") return "intelligent";
return DEFAULT_SHOT_TYPE;
}
function extractPromptAndImages(messages: Array<{ role: string; content: any }>) {
const lastUserMessage = messages.filter((message) => message.role === "user").pop();
if (!lastUserMessage) {
throw new Error("No user message found");
}
let prompt = "";
const imageUrls: string[] = [];
const pushImageUrl = (url?: string) => {
if (!url) return;
const normalized = normalizeFalImageUrl(String(url));
if (normalized.startsWith("data:")) {
throw new Error("FAL 视频模型不支持 Base64 图片,请先上传图片后再生成视频");
}
if (!normalized.startsWith("http")) return;
if (!imageUrls.includes(normalized)) {
imageUrls.push(normalized);
}
};
if (typeof lastUserMessage.content === "string") {
prompt = lastUserMessage.content.trim();
} else if (Array.isArray(lastUserMessage.content)) {
for (const part of lastUserMessage.content) {
if (part?.type === "text" && typeof part.text === "string") {
prompt = `${prompt} ${part.text}`.trim();
} else if (part?.type === "image_url" && part.image_url?.url) {
pushImageUrl(part.image_url.url);
} else if (part?.type === "file") {
pushImageUrl(part.url || part.data);
} else if (part?.type === "image") {
pushImageUrl(part.image || part.url);
}
}
}
if (!prompt) {
prompt = "生成视频";
}
return { prompt, imageUrls };
}
function extractVideoUrl(payload: any): string | undefined {
if (!payload) return undefined;
if (typeof payload === "string" && payload.startsWith("http")) {
return payload;
}
if (typeof payload !== "object") return undefined;
const direct =
payload?.video?.url ||
payload?.output?.video?.url ||
payload?.data?.video?.url ||
payload?.video_url ||
payload?.output?.video_url ||
payload?.data?.video_url;
if (typeof direct === "string" && direct.startsWith("http")) {
return direct;
}
if (Array.isArray(payload)) {
for (const item of payload) {
const nested = extractVideoUrl(item);
if (nested) return nested;
}
return undefined;
}
for (const value of Object.values(payload)) {
const nested = extractVideoUrl(value);
if (nested) return nested;
}
return undefined;
}
function formatVideoResponse(videoUrl: string): string {
return `\n\n[下载视频](${videoUrl})`;
}
function buildFalInput(params: {
prompt: string;
imageUrls: string[];
videoParams?: FalVideoParams;
}) {
const { prompt, imageUrls, videoParams } = params;
const mode = videoParams?.mode || "first-frame";
const input: Record = {
prompt,
duration: String(normalizeDuration(videoParams?.duration)),
aspect_ratio: normalizeRatio(videoParams?.ratio),
shot_type: normalizeShotType(videoParams?.shotType),
};
if (typeof videoParams?.generateAudio === "boolean") {
input.generate_audio = videoParams.generateAudio;
}
if (videoParams?.negativePrompt?.trim()) {
input.negative_prompt = videoParams.negativePrompt.trim();
}
if (typeof videoParams?.cfgScale === "number" && Number.isFinite(videoParams.cfgScale)) {
input.cfg_scale = videoParams.cfgScale;
}
if (mode === "first-last-frame") {
if (!imageUrls[0] || !imageUrls[1]) {
throw new Error("请至少上传 2 张图片(首帧 + 尾帧)");
}
input.image_url = imageUrls[0];
input.end_image_url = imageUrls[1];
return input;
}
if (!imageUrls[0]) {
throw new Error("FAL O3 图生视频需要至少 1 张起始图片");
}
input.image_url = imageUrls[0];
if (imageUrls[1]) {
input.end_image_url = imageUrls[1];
}
return input;
}
export function createFalVideo({ apiKey, baseURL }: FalVideoConfig) {
return {
provider: "fal-video",
chat: (modelId: string) => ({
provider: "fal-video",
doGenerate: async (options: {
messages: Array<{ role: string; content: any }>;
videoParams?: FalVideoParams;
overrideModelId?: string;
}) => {
try {
const activeModelId = normalizeFalModelId(options.overrideModelId || modelId);
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
const input = buildFalInput({
prompt,
imageUrls,
videoParams: options.videoParams,
});
console.log("[Fal Video] Submitting task", {
model: activeModelId,
baseURL,
duration: input.duration,
aspect_ratio: input.aspect_ratio,
hasEndFrame: Boolean(input.end_image_url),
});
const fal = await import("@fal-ai/serverless-client");
const credentials = normalizeFalCredentials(apiKey);
if (!credentials) {
throw new Error("FAL API Key 未配置或格式无效");
}
fal.config({
credentials,
});
const result: any = await fal.subscribe(activeModelId, {
input,
logs: true,
timeout: DEFAULT_TIMEOUT_MS,
onQueueUpdate: (update: any) => {
if (update?.status === "IN_PROGRESS" && Array.isArray(update.logs)) {
for (const log of update.logs) {
if (log?.message) {
console.log(`[Fal Video][${activeModelId}]`, log.message);
}
}
}
},
});
const remoteVideoUrl = extractVideoUrl(result) || extractVideoUrl(result?.data);
if (!remoteVideoUrl) {
throw new Error("FAL 未返回视频地址");
}
const text = formatVideoResponse(remoteVideoUrl);
return {
text,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: text.length,
},
};
} catch (error) {
console.error("[Fal Video] Generation failed", error);
const rawErrorText = error instanceof Error ? error.message : "";
const errorText = /ByteString/i.test(rawErrorText)
? "视频生成失败: 检测到配置中包含非法字符(常见为中文冒号),请检查 FAL API Key 与模型ID"
: error instanceof Error
? `视频生成失败: ${error.message}`
: "视频生成失败,请稍后重试";
return {
text: errorText,
finishReason: "error" as const,
usage: {
promptTokens: 0,
completionTokens: 0,
},
};
}
},
doStream: async function* (options: {
messages: Array<{ role: string; content: any }>;
videoParams?: FalVideoParams;
overrideModelId?: string;
}) {
const result = await this.doGenerate(options);
yield {
type: "text-delta",
textDelta: result.text,
};
},
modelId,
specificationVersion: "v1" as const,
defaultObjectGenerationMode: "json" as const,
}),
};
}