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