ai-chatbot/lib/video-canvas/aliyun-video-analysis.ts

111 lines
3.5 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.

type AliyunVideoModel = "qwen3.8-max" | "qwen3.8-flash" | "qwen3.8-omni-flash";
export function isAliyunVideoModel(modelId: string, providerType: string) {
return providerType === "alibaba" &&
["qwen3.8-max", "qwen3.8-flash", "qwen3.8-omni-flash"].includes(modelId);
}
export function supportsVideoSound(modelId: string) {
return modelId === "qwen3.8-omni-flash";
}
function officialEndpoint(baseUrl: string) {
const url = new URL(baseUrl);
if (url.protocol !== "https:" ||
!(url.hostname === "dashscope.aliyuncs.com" ||
/^[-a-z0-9]+\.[-a-z0-9]+\.maas\.aliyuncs\.com$/.test(url.hostname))) {
throw new Error("视频解析仅允许阿里云百炼官方地址");
}
return `${url.href.replace(/\/$/, "")}/chat/completions`;
}
function sseText(body: string) {
let result = "";
let finishReason: string | undefined;
for (const line of body.split(/\r?\n/)) {
if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
try {
const event = JSON.parse(line.slice(6));
result += event.choices?.[0]?.delta?.content || "";
finishReason = event.choices?.[0]?.finish_reason || finishReason;
} catch {
// Ignore keep-alive and incomplete events; a missing result is rejected below.
}
}
if (finishReason === "length") throw new Error("阿里云视频分析结果被截断,未提交成片合成");
return result;
}
export async function analyzeAliyunVideo({
apiKey,
baseUrl,
modelId,
prompt,
videoUrl,
contextImages = [],
fps = 1,
jsonOutput = false,
signal,
}: {
apiKey: string;
baseUrl: string;
modelId: AliyunVideoModel;
prompt: string;
videoUrl: string;
contextImages?: string[];
fps?: number;
jsonOutput?: boolean;
signal?: AbortSignal;
}) {
const video = new URL(videoUrl);
if (video.protocol !== "https:") {
throw new Error("阿里云视频解析需要可公网访问的 HTTPS 视频地址");
}
if (!Number.isFinite(fps) || fps < 0.1 || fps > 10 || contextImages.length > 2)
throw new Error("视频分析采样或参考图片参数无效");
const omni = supportsVideoSound(modelId);
const response = await fetch(officialEndpoint(baseUrl), {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: modelId,
messages: [{
role: "user",
content: [
{ type: "video_url", video_url: { url: video.href }, fps },
...contextImages.map(url => ({ type: "image_url", image_url: { url } })),
{ type: "text", text: prompt },
],
}],
stream: omni,
...(jsonOutput ? { response_format: { type: "json_object" } } : {}),
...(omni ? { modalities: ["text"], reasoning_effort: "none" } : {
temperature: 0.2,
max_tokens: 6000,
}),
}),
signal,
});
if (!response.ok) {
let code = "";
try {
const body = await response.json();
code = String(body.error?.code || body.code || "").slice(0, 80);
} catch {}
throw new Error(`阿里云视频解析失败(HTTP ${response.status}${code ? `,${code}` : ""})`);
}
let text: string;
if (omni) text = sseText(await response.text());
else {
const choice = (await response.json()).choices?.[0];
if (choice?.finish_reason === "length")
throw new Error("阿里云视频分析结果被截断,未提交成片合成");
text = String(choice?.message?.content || "");
}
if (!text.trim()) throw new Error("阿里云视频解析返回空结果");
return text;
}