ai-chatbot/lib/ai/providers/ruxa-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

356 lines
11 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 RuxaVideoConfig {
apiKey: string;
baseURL: string;
}
interface RuxaVideoParams {
mode?: "text-to-video" | "first-frame" | "first-last-frame" | "reference-images";
ratio?: string;
duration?: number;
enhancePrompt?: boolean;
enableUpsample?: boolean;
}
const POLL_INTERVAL_MS = 2500;
const POLL_MAX_ATTEMPTS = 90;
function joinApiPath(baseURL: string, path: string) {
const cleanBase = String(baseURL || "").replace(/\/+$/, "");
const cleanPath = path.startsWith("/") ? path : `/${path}`;
return `${cleanBase}${cleanPath}`;
}
function normalizeRuxaTextInput(value: string) {
return String(value || "")
.replace(/\u00a0/g, " ")
.trim()
.normalize("NFKC");
}
function normalizeRuxaCredentials(apiKey: string) {
const normalized = normalizeRuxaTextInput(apiKey);
const stripped = normalized
.replace(/^bearer\s+/i, "")
.replace(/^key\s+/i, "");
const kvMatch = stripped.match(/^(?:api[_-]?key|token)\s*[::]\s*(.+)$/i);
const candidate = kvMatch?.[1] ? kvMatch[1] : stripped;
return candidate.trim().replace(/[^\x20-\x7E]/g, "");
}
function normalizeRuxaRatio(ratio?: string) {
const normalized = String(ratio || "").trim();
if (normalized === "16:9" || normalized === "9:16" || normalized === "1:1") {
return normalized;
}
return "16:9";
}
function normalizeRuxaDuration(duration?: number) {
if (!Number.isFinite(duration)) return 10;
return Math.max(5, Math.min(10, Math.round(Number(duration))));
}
function normalizeRuxaModelId(modelId: string) {
return normalizeRuxaTextInput(modelId);
}
function extractTaskId(payload: any): string | undefined {
if (typeof payload === "string" && payload.trim()) return payload.trim();
if (typeof payload?.data === "string" && payload.data.trim()) return payload.data.trim();
return (
payload?.taskId ||
payload?.task_id ||
payload?.id ||
payload?.data?.taskId ||
payload?.data?.task_id ||
payload?.data?.id
);
}
function extractStatus(payload: any): string | undefined {
const raw =
payload?.status ||
payload?.taskStatus ||
payload?.task_status ||
payload?.state ||
payload?.data?.status ||
payload?.data?.taskStatus ||
payload?.data?.task_status ||
payload?.data?.state;
if (typeof raw !== "string") return undefined;
return raw.toLowerCase();
}
function extractErrorMessage(payload: any): string {
return (
payload?.message ||
payload?.error ||
payload?.errorMessage ||
payload?.data?.message ||
payload?.data?.error ||
"RUXA 视频任务执行失败"
);
}
function collectVideoUrls(payload: any): string[] {
const urls = new Set<string>();
const isVideoLikeKey = (key: string) => /video|url|output|result|file/i.test(key);
const isLikelyVideoUrl = (value: string) =>
/^https?:\/\//i.test(value) &&
(/\.(mp4|webm|mov|mkv)(\?|$)/i.test(value) ||
value.includes("/video") ||
value.includes("video"));
const walk = (value: any, parentKey = "") => {
if (typeof value === "string") {
if (isLikelyVideoUrl(value) || (isVideoLikeKey(parentKey) && /^https?:\/\//i.test(value))) {
urls.add(value);
}
return;
}
if (Array.isArray(value)) {
value.forEach((item) => walk(item, parentKey));
return;
}
if (value && typeof value === "object") {
Object.entries(value).forEach(([key, nested]) => walk(nested, key));
}
};
walk(payload);
return Array.from(urls);
}
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 = normalizeRuxaTextInput(String(url));
if (normalized.startsWith("data:")) {
throw new Error("RUXA 视频模型不支持 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 };
}
async function waitForVideoUrl(baseURL: string, apiKey: string, taskId: string) {
const endpointCandidates = [
(id: string) => joinApiPath(baseURL, `/api/v1/tasks/query/${id}`),
(id: string) => joinApiPath(baseURL, `/api/v1/tasks/${id}`),
(id: string) => `${joinApiPath(baseURL, "/api/v1/tasks/query")}?taskId=${encodeURIComponent(id)}`,
];
let endpointIndex = 0;
for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
const endpoint = endpointCandidates[endpointIndex]!(taskId);
const response = await fetch(endpoint, {
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
if (response.status === 404 && endpointIndex < endpointCandidates.length - 1) {
endpointIndex += 1;
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
continue;
}
const payload = await response.json();
const videoUrls = collectVideoUrls(payload);
if (videoUrls.length > 0) {
return videoUrls[0]!;
}
const status = extractStatus(payload);
if (status && ["failed", "error", "cancelled", "canceled"].includes(status)) {
throw new Error(extractErrorMessage(payload));
}
if (status && ["success", "succeeded", "completed", "done", "finished"].includes(status)) {
throw new Error("任务已完成但未返回视频地址");
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
throw new Error("RUXA 视频任务超时,请稍后重试");
}
function formatVideoMarkdown(videoUrl: string) {
return `<video src="${videoUrl}" controls style="max-width: 100%; border-radius: 8px; margin-bottom: 12px;"></video>\n\n[下载视频](${videoUrl})`;
}
function buildVideoInput(params: {
imageUrls: string[];
prompt: string;
videoParams?: RuxaVideoParams;
}) {
const { imageUrls, prompt, videoParams } = params;
if (!imageUrls[0]) {
throw new Error("RUXA Veo 3.1 需要至少 1 张起始图");
}
const duration = normalizeRuxaDuration(videoParams?.duration);
const ratio = normalizeRuxaRatio(videoParams?.ratio);
const enhancePrompt =
typeof videoParams?.enhancePrompt === "boolean" ? videoParams.enhancePrompt : true;
const enableUpsample =
typeof videoParams?.enableUpsample === "boolean"
? videoParams.enableUpsample
: false;
return {
prompt: normalizeRuxaTextInput(prompt),
image_url: imageUrls[0],
seconds: String(duration),
aspect_ratio: ratio,
enhance_prompt: enhancePrompt,
enable_upsample: enableUpsample,
};
}
export function createRuxaVideo({ apiKey, baseURL }: RuxaVideoConfig) {
return {
provider: "ruxa-video",
chat: (modelId: string) => ({
provider: "ruxa-video",
doGenerate: async (options: {
messages: Array<{ role: string; content: any }>;
videoParams?: RuxaVideoParams;
overrideModelId?: string;
}) => {
try {
const credentials = normalizeRuxaCredentials(apiKey);
if (!credentials) {
throw new Error("RUXA API Key 未配置或格式无效");
}
const activeModelId = normalizeRuxaModelId(options.overrideModelId || modelId);
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
const input = buildVideoInput({
imageUrls,
prompt,
videoParams: options.videoParams,
});
const callbackUrl =
process.env.RUXA_VIDEO_CALLBACK_URL?.trim() || process.env.RUXA_CALLBACK_URL?.trim();
const requestBody = {
...(callbackUrl ? { callBackUrl: callbackUrl } : {}),
input,
model: activeModelId,
};
const createEndpoint = joinApiPath(baseURL, "/api/v1/tasks/create");
const createResponse = await fetch(createEndpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${credentials}`,
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
const createRawText = await createResponse.text();
let createPayload: any = null;
try {
createPayload = JSON.parse(createRawText);
} catch {
createPayload = createRawText;
}
if (!createResponse.ok) {
throw new Error(`${extractErrorMessage(createPayload)} (HTTP ${createResponse.status})`);
}
let remoteVideoUrl = collectVideoUrls(createPayload)[0];
if (!remoteVideoUrl) {
const taskId = extractTaskId(createPayload);
if (!taskId) {
throw new Error("RUXA 创建任务成功,但未返回任务ID");
}
remoteVideoUrl = await waitForVideoUrl(baseURL, credentials, taskId);
}
if (!remoteVideoUrl) {
throw new Error("RUXA 未返回可用视频地址");
}
const text = formatVideoMarkdown(remoteVideoUrl);
return {
text,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: text.length,
},
};
} catch (error) {
console.error("[Ruxa 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?: RuxaVideoParams;
overrideModelId?: string;
}) {
const result = await this.doGenerate(options);
yield {
type: "text-delta",
textDelta: result.text,
};
},
modelId,
specificationVersion: "v1" as const,
defaultObjectGenerationMode: "json" as const,
}),
};
}