306 lines
8.9 KiB
TypeScript
306 lines
8.9 KiB
TypeScript
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,
|
||
}),
|
||
};
|
||
}
|