ai-chatbot/lib/ai/providers/fal-video.ts
Your Name 10b44ddfcd
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
新增FAL视频服务商并优化模型选择弹窗与头像配置
2026-02-14 20:53:47 +08:00

292 lines
8.5 KiB
TypeScript

import "server-only";
import { uploadFile } from "@/lib/storage/service";
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 normalizeFalCredentials(apiKey: string) {
return apiKey.trim().replace(/^bearer\s+/i, "").replace(/^key\s+/i, "");
}
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 = String(url).trim();
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;
}
async function downloadAndUploadVideo(videoUrl: string): Promise<string> {
try {
const response = await fetch(videoUrl);
if (!response.ok) {
throw new Error(`Failed to download video: ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const filename = `fal-video-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`;
const uploadResult = await uploadFile(filename, buffer as any);
return uploadResult.url;
} catch (error) {
console.error("[Fal Video] Failed to upload video to storage, fallback original URL", error);
return videoUrl;
}
}
function formatVideoResponse(videoUrl: string): string {
return `<video src="${videoUrl}" controls style="max-width: 100%; border-radius: 8px; margin-bottom: 12px;"></video>\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<string, any> = {
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 = 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");
fal.config({
credentials: normalizeFalCredentials(apiKey),
});
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 hostedVideoUrl = await downloadAndUploadVideo(remoteVideoUrl);
const text = formatVideoResponse(hostedVideoUrl);
return {
text,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: text.length,
},
};
} catch (error) {
console.error("[Fal Video] Generation failed", error);
const errorText =
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,
}),
};
}