ai-chatbot/lib/ai/providers/fal-video.ts
Your Name 1967992de7
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat(video): storyboard task tables and delayed archive flow
2026-03-05 15:58:43 +08:00

306 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 `<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 = 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,
}),
};
}