356 lines
11 KiB
TypeScript
356 lines
11 KiB
TypeScript
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,
|
||
}),
|
||
};
|
||
}
|