fix(video): harden volcano model routing and request retries
This commit is contained in:
parent
b1ac60a629
commit
46b3f4b081
@ -238,6 +238,103 @@ function startStreamKeepalive(writer: StreamWriter, label: string, intervalMs =
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
type VideoModeType = "text-to-video" | "first-frame" | "first-last-frame" | "reference-images";
|
||||
|
||||
function inferSupportedVideoModesByModelId(modelId: string): VideoModeType[] {
|
||||
const normalized = modelId.toLowerCase();
|
||||
|
||||
if (normalized.includes("lite-t2v")) {
|
||||
return ["text-to-video"];
|
||||
}
|
||||
if (normalized.includes("lite-i2v")) {
|
||||
return ["first-frame", "first-last-frame", "reference-images"];
|
||||
}
|
||||
if (normalized.includes("pro-fast")) {
|
||||
return ["text-to-video", "first-frame"];
|
||||
}
|
||||
if (normalized.includes("-pro-") || normalized.endsWith("-pro")) {
|
||||
return ["text-to-video", "first-frame", "first-last-frame"];
|
||||
}
|
||||
|
||||
return ["text-to-video", "first-frame", "first-last-frame", "reference-images"];
|
||||
}
|
||||
|
||||
function getSupportedVideoModesForModel(model: any): VideoModeType[] {
|
||||
const capabilityModes = (((model?.capabilities as any)?.supportedModes ?? []) as string[])
|
||||
.filter((mode): mode is VideoModeType =>
|
||||
mode === "text-to-video" ||
|
||||
mode === "first-frame" ||
|
||||
mode === "first-last-frame" ||
|
||||
mode === "reference-images"
|
||||
);
|
||||
|
||||
if (capabilityModes.length > 0) {
|
||||
return capabilityModes;
|
||||
}
|
||||
|
||||
const modelId = String(model?.modelId || "");
|
||||
return inferSupportedVideoModesByModelId(modelId);
|
||||
}
|
||||
|
||||
function scoreVideoSubModelForMode(modelId: string, mode: VideoModeType): number {
|
||||
const normalized = modelId.toLowerCase();
|
||||
const isProFast = normalized.includes("pro-fast");
|
||||
const isLiteT2V = normalized.includes("lite-t2v");
|
||||
const isLiteI2V = normalized.includes("lite-i2v");
|
||||
const isPro = (normalized.includes("-pro-") || normalized.endsWith("-pro")) && !isProFast;
|
||||
const isV15Pro = normalized.includes("seedance-1-5-pro");
|
||||
|
||||
switch (mode) {
|
||||
case "first-last-frame":
|
||||
if (isV15Pro) return 150;
|
||||
if (isPro) return 120;
|
||||
if (isLiteI2V) return 90;
|
||||
if (isProFast) return -1000;
|
||||
if (isLiteT2V) return -1000;
|
||||
return 10;
|
||||
case "first-frame":
|
||||
if (isV15Pro) return 140;
|
||||
if (isProFast) return 130;
|
||||
if (isPro) return 120;
|
||||
if (isLiteI2V) return 100;
|
||||
if (isLiteT2V) return 20;
|
||||
return 10;
|
||||
case "reference-images":
|
||||
if (isV15Pro) return 150;
|
||||
if (isLiteI2V) return 140;
|
||||
if (isPro) return 110;
|
||||
if (isProFast || isLiteT2V) return -1000;
|
||||
return 10;
|
||||
case "text-to-video":
|
||||
default:
|
||||
if (isV15Pro) return 140;
|
||||
if (isLiteT2V) return 130;
|
||||
if (isProFast) return 120;
|
||||
if (isPro) return 100;
|
||||
if (isLiteI2V) return 80;
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
|
||||
function selectBestVideoSubModelForMode(
|
||||
subModels: Array<{ model: any }>,
|
||||
mode: VideoModeType,
|
||||
): { model: any } | null {
|
||||
const compatible = subModels.filter(({ model }) => {
|
||||
const supportedModes = getSupportedVideoModesForModel(model);
|
||||
return supportedModes.includes(mode);
|
||||
});
|
||||
|
||||
const candidates = compatible.length > 0 ? compatible : subModels;
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
return [...candidates].sort((a, b) => {
|
||||
const scoreA = scoreVideoSubModelForMode(String(a.model?.modelId || ""), mode);
|
||||
const scoreB = scoreVideoSubModelForMode(String(b.model?.modelId || ""), mode);
|
||||
return scoreB - scoreA;
|
||||
})[0];
|
||||
}
|
||||
|
||||
async function replayAssistantMessagesForDuplicateRequest({
|
||||
chatId,
|
||||
userMessageId,
|
||||
@ -2118,41 +2215,18 @@ export async function POST(request: Request) {
|
||||
console.log("[Chat API] Video params:", videoParams);
|
||||
|
||||
// 如果没有 videoParams 或没有 mode,使用默认模式
|
||||
const mode = videoParams?.mode || "text-to-video";
|
||||
const mode = (videoParams?.mode || "text-to-video") as VideoModeType;
|
||||
console.log("[Chat API] Using mode:", mode);
|
||||
|
||||
// 根据模式选择对应的子模型
|
||||
const modeToModelMap: Record<string, string> = {
|
||||
"text-to-video": "lite-t2v", // 文生视频 → Lite T2V
|
||||
"first-last-frame": "pro", // 首尾帧 → Pro
|
||||
"first-frame": "pro-fast", // 智能多帧 → Pro Fast
|
||||
"reference-images": "lite-i2v", // 主体参考 → Lite I2V
|
||||
};
|
||||
|
||||
const targetSubModelKey = modeToModelMap[mode];
|
||||
if (targetSubModelKey) {
|
||||
// 查找匹配的子模型(通过 modelId 中包含的关键字)
|
||||
const compatibleSubModels = subModels.filter(({ model }) => {
|
||||
const supportedModes = ((model.capabilities as any)?.supportedModes ?? []) as string[];
|
||||
return supportedModes.length === 0 || supportedModes.includes(mode);
|
||||
});
|
||||
const matchedSubModel = compatibleSubModels.find(({ model }) =>
|
||||
model.modelId.toLowerCase().includes(targetSubModelKey)
|
||||
|
||||
const matchedSubModel = selectBestVideoSubModelForMode(subModels as any, mode);
|
||||
|
||||
if (matchedSubModel) {
|
||||
console.log(
|
||||
`[Chat API] 视频模式 ${mode} → 选择子模型: ${matchedSubModel.model.displayName} (${matchedSubModel.model.modelId})`
|
||||
);
|
||||
|
||||
if (matchedSubModel) {
|
||||
console.log(`[Chat API] 视频模式 ${mode} → 选择子模型: ${matchedSubModel.model.displayName}`);
|
||||
targetModelId = matchedSubModel.model.id;
|
||||
} else if (compatibleSubModels.length > 0) {
|
||||
console.warn(
|
||||
`[Chat API] 未找到匹配关键字子模型: ${targetSubModelKey},使用兼容子模型 ${compatibleSubModels[0].model.displayName}`
|
||||
);
|
||||
targetModelId = compatibleSubModels[0].model.id;
|
||||
} else {
|
||||
console.warn(`[Chat API] 未找到匹配的子模型: ${targetSubModelKey},将使用第一个可用子模型`);
|
||||
// 如果没找到匹配的,使用第一个子模型作为后备
|
||||
targetModelId = subModels[0].model.id;
|
||||
}
|
||||
targetModelId = matchedSubModel.model.id;
|
||||
} else {
|
||||
console.warn(`[Chat API] 未找到可用子模型,继续使用主模型`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3724,6 +3798,8 @@ export async function POST(request: Request) {
|
||||
videoParams: effectiveVideoParams,
|
||||
});
|
||||
|
||||
const videoMode = (effectiveVideoParams?.mode || "text-to-video") as VideoModeType;
|
||||
|
||||
// 🆕 重新执行子模型选择逻辑,确定要使用的模型 UUID
|
||||
let videoModelId = finalModelId; // 默认使用选择的模型 ID
|
||||
|
||||
@ -3741,37 +3817,16 @@ export async function POST(request: Request) {
|
||||
console.log(`[Chat API] Video: Found ${videoSubModels.length} sub-models for master model`);
|
||||
|
||||
if (videoSubModels.length > 0) {
|
||||
const videoMode = effectiveVideoParams?.mode || "text-to-video";
|
||||
const modeToModelMap: Record<string, string> = {
|
||||
"text-to-video": "lite-t2v",
|
||||
"first-last-frame": "pro",
|
||||
"first-frame": "pro-fast",
|
||||
"reference-images": "lite-i2v",
|
||||
};
|
||||
|
||||
const targetKey = modeToModelMap[videoMode];
|
||||
const compatibleModels = videoSubModels.filter(({ model }) => {
|
||||
const supportedModes = ((model.capabilities as any)?.supportedModes ?? []) as string[];
|
||||
return supportedModes.length === 0 || supportedModes.includes(videoMode);
|
||||
});
|
||||
const matchedModel = compatibleModels.find(({ model }) =>
|
||||
model.modelId.toLowerCase().includes(targetKey)
|
||||
);
|
||||
const matchedModel = selectBestVideoSubModelForMode(videoSubModels as any, videoMode);
|
||||
|
||||
if (matchedModel) {
|
||||
videoModelId = matchedModel.model.id;
|
||||
console.log(
|
||||
`[Chat API] Video: Selected sub-model ${matchedModel.model.displayName} for mode ${videoMode}`
|
||||
);
|
||||
} else if (compatibleModels.length > 0) {
|
||||
videoModelId = compatibleModels[0].model.id;
|
||||
console.log(
|
||||
`[Chat API] Video: Fallback to compatible sub-model ${compatibleModels[0].model.displayName} for mode ${videoMode}`
|
||||
`[Chat API] Video: Selected sub-model ${matchedModel.model.displayName} (${matchedModel.model.modelId}) for mode ${videoMode}`
|
||||
);
|
||||
} else {
|
||||
videoModelId = videoSubModels[0].model.id;
|
||||
console.log(
|
||||
`[Chat API] Video: No compatible sub-model found for mode ${videoMode}, using ${videoSubModels[0].model.displayName}`
|
||||
`[Chat API] Video: No compatible sub-model found for mode ${videoMode}, keep current model`
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -3783,14 +3838,50 @@ export async function POST(request: Request) {
|
||||
throw new Error("视频模型配置未找到");
|
||||
}
|
||||
|
||||
// 若用户手动选了不兼容模式的子模型,自动回退到同父模型兼容子模型
|
||||
const selectedSupportsMode = getSupportedVideoModesForModel(aiModelData as any).includes(videoMode);
|
||||
if (!selectedSupportsMode) {
|
||||
let fallbackModel: { model: any } | null = null;
|
||||
|
||||
if (aiModelData.parentModelId) {
|
||||
const siblingSubModels = await getSubModelsByParentModelId(aiModelData.parentModelId);
|
||||
fallbackModel = selectBestVideoSubModelForMode(siblingSubModels as any, videoMode);
|
||||
} else if (selectedModelProvider?.type === "volcano-video") {
|
||||
const enabledModels = await getEnabledAiModelsWithProvider();
|
||||
const sameProviderModels = enabledModels
|
||||
.filter(
|
||||
({ model, provider }) =>
|
||||
provider.type === "volcano-video" &&
|
||||
model.isEnabled &&
|
||||
model.id !== aiModelData.id,
|
||||
)
|
||||
.map(({ model }) => ({ model }));
|
||||
fallbackModel = selectBestVideoSubModelForMode(sameProviderModels as any, videoMode);
|
||||
}
|
||||
|
||||
if (fallbackModel) {
|
||||
console.warn(
|
||||
`[Chat API] Video: Model ${aiModelData.modelId} does not support ${videoMode}, fallback to ${fallbackModel.model.modelId}`
|
||||
);
|
||||
videoModelId = fallbackModel.model.id;
|
||||
}
|
||||
}
|
||||
|
||||
const finalVideoModelData = videoModelId === aiModelData.id
|
||||
? aiModelData
|
||||
: await getAiModelById({ id: videoModelId });
|
||||
if (!finalVideoModelData) {
|
||||
throw new Error("视频模型配置未找到");
|
||||
}
|
||||
|
||||
// 如果是子模型,获取父模型的 Provider 配置
|
||||
let providerModelData = aiModelData;
|
||||
if (aiModelData.isSubModel && aiModelData.parentModelId) {
|
||||
let providerModelData = finalVideoModelData;
|
||||
if (finalVideoModelData.isSubModel && finalVideoModelData.parentModelId) {
|
||||
console.log(
|
||||
"[Chat API] This is a sub-model, getting parent model config:",
|
||||
aiModelData.parentModelId
|
||||
finalVideoModelData.parentModelId
|
||||
);
|
||||
const parentModel = await getAiModelById({ id: aiModelData.parentModelId });
|
||||
const parentModel = await getAiModelById({ id: finalVideoModelData.parentModelId });
|
||||
if (parentModel) {
|
||||
providerModelData = parentModel;
|
||||
}
|
||||
@ -3802,13 +3893,13 @@ export async function POST(request: Request) {
|
||||
throw new Error("Provider 配置未找到");
|
||||
}
|
||||
|
||||
const actualVideoModelId = aiModelData.modelId;
|
||||
const actualVideoModelId = finalVideoModelData.modelId;
|
||||
|
||||
// 构建视频生成选项
|
||||
const videoOptions: any = {
|
||||
model: actualVideoModelId,
|
||||
prompt: prompt || "生成视频",
|
||||
mode: effectiveVideoParams?.mode || "text-to-video",
|
||||
mode: videoMode,
|
||||
resolution: effectiveVideoParams?.resolution || "720p",
|
||||
ratio: effectiveVideoParams?.ratio || "16:9",
|
||||
duration: effectiveVideoParams?.duration || 5,
|
||||
|
||||
@ -11,33 +11,138 @@ import type {
|
||||
} from "@/types/ai";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
|
||||
const RETRYABLE_HTTP_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
||||
const RETRYABLE_FETCH_CODES = new Set([
|
||||
"UND_ERR_HEADERS_TIMEOUT",
|
||||
"UND_ERR_CONNECT_TIMEOUT",
|
||||
"UND_ERR_BODY_TIMEOUT",
|
||||
"UND_ERR_SOCKET",
|
||||
"ETIMEDOUT",
|
||||
"ECONNRESET",
|
||||
"ECONNREFUSED",
|
||||
"EAI_AGAIN",
|
||||
"ENOTFOUND",
|
||||
]);
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isRetryableFetchError(error: any): boolean {
|
||||
const code = error?.code || error?.cause?.code;
|
||||
if (typeof code === "string" && RETRYABLE_FETCH_CODES.has(code)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (error?.name === "AbortError") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const message = String(error?.message || "").toLowerCase();
|
||||
return (
|
||||
message.includes("timeout") ||
|
||||
message.includes("timed out") ||
|
||||
message.includes("fetch failed")
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
options: {
|
||||
label: string;
|
||||
timeoutMs: number;
|
||||
retries: number;
|
||||
retryDelayMs?: number;
|
||||
}
|
||||
): Promise<Response> {
|
||||
const { label, timeoutMs, retries, retryDelayMs = 1500 } = options;
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt <= retries) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (
|
||||
RETRYABLE_HTTP_STATUS.has(response.status) &&
|
||||
attempt < retries
|
||||
) {
|
||||
const bodyText = await response.text().catch(() => "");
|
||||
console.warn(
|
||||
`[Volcano Video] ${label} transient status ${response.status}, retry ${attempt + 1}/${retries}`,
|
||||
bodyText?.slice(0, 300)
|
||||
);
|
||||
await sleep(retryDelayMs * (attempt + 1));
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
clearTimeout(timer);
|
||||
|
||||
if (isRetryableFetchError(error) && attempt < retries) {
|
||||
console.warn(
|
||||
`[Volcano Video] ${label} request failed, retry ${attempt + 1}/${retries}`,
|
||||
error?.cause?.code || error?.code || error?.message
|
||||
);
|
||||
await sleep(retryDelayMs * (attempt + 1));
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`[Volcano Video] ${label} request failed after retries`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将图片 URL 转换为 Base64(火山引擎 API 要求)
|
||||
* 规范化图片地址为可公开访问 URL(优先直传 URL,避免大体积 base64)
|
||||
*/
|
||||
async function convertImageUrlToBase64(imageUrl: string): Promise<{
|
||||
permanentUrl: string;
|
||||
base64: string;
|
||||
}> {
|
||||
async function normalizeImageUrlForVolcano(imageUrl: string): Promise<string> {
|
||||
try {
|
||||
// 如果已经是 Base64,直接返回
|
||||
// Base64 输入:上传到 S3,返回永久 URL
|
||||
if (imageUrl.startsWith("data:image/")) {
|
||||
const matches = imageUrl.match(/^data:image\/(\w+);base64,(.+)$/);
|
||||
const matches = imageUrl.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
throw new Error("Invalid base64 image format");
|
||||
}
|
||||
|
||||
// 上传到 S3 获取永久 URL
|
||||
|
||||
const mimeType = matches[1];
|
||||
const ext = mimeType.split("/")[1]?.toLowerCase() || "jpg";
|
||||
const buffer = Buffer.from(matches[2], "base64");
|
||||
const filename = `volcano-video-input-${Date.now()}.${matches[1]}`;
|
||||
const filename = `volcano-video-input-${Date.now()}.${ext}`;
|
||||
const { url: permanentUrl } = await uploadFile(filename, buffer as any);
|
||||
|
||||
return { permanentUrl, base64: matches[2] };
|
||||
|
||||
return permanentUrl;
|
||||
}
|
||||
|
||||
// 下载图片
|
||||
// 已经是公网 URL,直接使用
|
||||
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
// 其他格式:下载后上传到 S3,返回永久 URL
|
||||
console.log(`[Volcano Video] Downloading image: ${imageUrl}`);
|
||||
const response = await fetch(imageUrl);
|
||||
|
||||
const response = await fetchWithRetry(
|
||||
imageUrl,
|
||||
{ method: "GET" },
|
||||
{
|
||||
label: "download-image",
|
||||
timeoutMs: 30000,
|
||||
retries: 2,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download image: ${response.statusText}`);
|
||||
}
|
||||
@ -51,10 +156,7 @@ async function convertImageUrlToBase64(imageUrl: string): Promise<{
|
||||
|
||||
console.log(`[Volcano Video] Image uploaded to S3: ${permanentUrl}`);
|
||||
|
||||
// 转换为 Base64
|
||||
const base64 = bufferNode.toString("base64");
|
||||
|
||||
return { permanentUrl, base64 };
|
||||
return permanentUrl;
|
||||
} catch (error) {
|
||||
console.error("[Volcano Video] Image processing failed:", error);
|
||||
throw new Error("图片处理失败,请重试");
|
||||
@ -175,15 +277,13 @@ async function createVideoTask(
|
||||
// 2. 处理图片输入(根据模式)
|
||||
if (options.mode === "first-frame" && options.firstFrameImage) {
|
||||
// 首帧模式
|
||||
const { permanentUrl, base64 } = await convertImageUrlToBase64(
|
||||
options.firstFrameImage
|
||||
);
|
||||
inputImageUrls.firstFrame = permanentUrl;
|
||||
const firstFrameUrl = await normalizeImageUrlForVolcano(options.firstFrameImage);
|
||||
inputImageUrls.firstFrame = firstFrameUrl;
|
||||
|
||||
content.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:image/jpeg;base64,${base64}`,
|
||||
url: firstFrameUrl,
|
||||
},
|
||||
role: "first_frame",
|
||||
});
|
||||
@ -193,24 +293,24 @@ async function createVideoTask(
|
||||
options.lastFrameImage
|
||||
) {
|
||||
// 首尾帧模式
|
||||
const firstFrame = await convertImageUrlToBase64(options.firstFrameImage);
|
||||
const lastFrame = await convertImageUrlToBase64(options.lastFrameImage);
|
||||
const firstFrameUrl = await normalizeImageUrlForVolcano(options.firstFrameImage);
|
||||
const lastFrameUrl = await normalizeImageUrlForVolcano(options.lastFrameImage);
|
||||
|
||||
inputImageUrls.firstFrame = firstFrame.permanentUrl;
|
||||
inputImageUrls.lastFrame = lastFrame.permanentUrl;
|
||||
inputImageUrls.firstFrame = firstFrameUrl;
|
||||
inputImageUrls.lastFrame = lastFrameUrl;
|
||||
|
||||
content.push(
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:image/jpeg;base64,${firstFrame.base64}`,
|
||||
url: firstFrameUrl,
|
||||
},
|
||||
role: "first_frame",
|
||||
},
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:image/jpeg;base64,${lastFrame.base64}`,
|
||||
url: lastFrameUrl,
|
||||
},
|
||||
role: "last_frame",
|
||||
}
|
||||
@ -220,13 +320,13 @@ async function createVideoTask(
|
||||
inputImageUrls.references = [];
|
||||
|
||||
for (const imageUrl of options.referenceImages) {
|
||||
const { permanentUrl, base64 } = await convertImageUrlToBase64(imageUrl);
|
||||
inputImageUrls.references.push(permanentUrl);
|
||||
const normalizedUrl = await normalizeImageUrlForVolcano(imageUrl);
|
||||
inputImageUrls.references.push(normalizedUrl);
|
||||
|
||||
content.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:image/jpeg;base64,${base64}`,
|
||||
url: normalizedUrl,
|
||||
},
|
||||
role: "reference_image",
|
||||
});
|
||||
@ -238,6 +338,15 @@ async function createVideoTask(
|
||||
model: options.model,
|
||||
content: content, // 直接传content数组,不使用messages包装
|
||||
};
|
||||
|
||||
// 显式指定 task_type,避免服务端推断错误
|
||||
const taskTypeMap: Record<string, string> = {
|
||||
"text-to-video": "t2v",
|
||||
"first-frame": "i2v",
|
||||
"first-last-frame": "flf2v",
|
||||
"reference-images": "i2v",
|
||||
};
|
||||
requestBody.task_type = taskTypeMap[options.mode] || "t2v";
|
||||
|
||||
// 可选参数:返回尾帧(用于连续生成视频)
|
||||
if (options.returnLastFrame) {
|
||||
@ -257,14 +366,22 @@ async function createVideoTask(
|
||||
console.log("[Volcano Video] 🔍 Request method: POST");
|
||||
console.log("[Volcano Video] 🔍 Request body:", JSON.stringify(requestBody, null, 2));
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
const response = await fetchWithRetry(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
{
|
||||
label: "create-task",
|
||||
timeoutMs: 90000,
|
||||
retries: 2,
|
||||
}
|
||||
);
|
||||
|
||||
console.log("[Volcano Video] 🔍 Response status:", response.status);
|
||||
console.log("[Volcano Video] 🔍 Response headers:", JSON.stringify(Object.fromEntries(response.headers.entries())));
|
||||
@ -302,12 +419,20 @@ async function fetchTaskStatus(
|
||||
): Promise<any> {
|
||||
const url = `${baseURL}/contents/generations/tasks/${taskId}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
const response = await fetchWithRetry(
|
||||
url,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
{
|
||||
label: `task-status:${taskId}`,
|
||||
timeoutMs: 45000,
|
||||
retries: 2,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
@ -330,7 +455,20 @@ async function pollTaskStatus(
|
||||
console.log("[Volcano Video] Polling task:", taskId);
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
const status = await fetchTaskStatus(baseURL, apiKey, taskId);
|
||||
let status: any;
|
||||
try {
|
||||
status = await fetchTaskStatus(baseURL, apiKey, taskId);
|
||||
} catch (error: any) {
|
||||
if (isRetryableFetchError(error)) {
|
||||
console.warn(
|
||||
`[Volcano Video] Poll ${i + 1}/${maxRetries} transient error, continue waiting:`,
|
||||
error?.cause?.code || error?.code || error?.message
|
||||
);
|
||||
await sleep(pollInterval);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Volcano Video] Poll ${i + 1}/${maxRetries}, status: ${status.status}`
|
||||
@ -371,7 +509,7 @@ async function pollTaskStatus(
|
||||
}
|
||||
|
||||
// 继续等待
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
await sleep(pollInterval);
|
||||
}
|
||||
|
||||
throw new Error("视频生成超时,请稍后重试");
|
||||
@ -454,7 +592,20 @@ export async function* doGenerateStream(
|
||||
const pollInterval = 10000;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
const status = await fetchTaskStatus(baseURL, apiKey, taskId);
|
||||
let status: any;
|
||||
try {
|
||||
status = await fetchTaskStatus(baseURL, apiKey, taskId);
|
||||
} catch (error: any) {
|
||||
if (isRetryableFetchError(error)) {
|
||||
console.warn(
|
||||
`[Volcano Video] Stream poll ${i + 1}/${maxRetries} transient error, continue waiting:`,
|
||||
error?.cause?.code || error?.code || error?.message
|
||||
);
|
||||
await sleep(pollInterval);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (status.status === "succeeded") {
|
||||
// 火山引擎API返回格式:status.content.video_url
|
||||
@ -504,7 +655,7 @@ export async function* doGenerateStream(
|
||||
}
|
||||
|
||||
// 静默等待,不输出进度文字
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
await sleep(pollInterval);
|
||||
}
|
||||
|
||||
throw new Error("视频生成超时,请稍后重试");
|
||||
@ -514,4 +665,3 @@ export async function* doGenerateStream(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -16,6 +16,27 @@ import type {
|
||||
VideoGenerationOptions,
|
||||
} from "@/types/ai";
|
||||
|
||||
function inferSupportedModesFromModelId(modelId: string): VideoGenerationMode[] {
|
||||
const normalized = modelId.toLowerCase();
|
||||
|
||||
if (normalized.includes("lite-t2v")) return ["text-to-video"];
|
||||
if (normalized.includes("lite-i2v")) return ["first-frame", "first-last-frame", "reference-images"];
|
||||
if (normalized.includes("pro-fast")) return ["text-to-video", "first-frame"];
|
||||
if (normalized.includes("-pro-") || normalized.endsWith("-pro")) {
|
||||
return ["text-to-video", "first-frame", "first-last-frame"];
|
||||
}
|
||||
|
||||
return ["text-to-video", "first-frame", "first-last-frame", "reference-images"];
|
||||
}
|
||||
|
||||
function getSupportedModes(caps: Partial<VideoModelCapabilities> | undefined, modelId: string): VideoGenerationMode[] {
|
||||
const modes = (caps?.supportedModes || []) as VideoGenerationMode[];
|
||||
if (Array.isArray(modes) && modes.length > 0) {
|
||||
return modes;
|
||||
}
|
||||
return inferSupportedModesFromModelId(modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 智能选择最佳子模型
|
||||
* 根据用户选择的模式和参数,从子模型列表中选择最合适的模型
|
||||
@ -37,11 +58,11 @@ async function selectBestSubModel(
|
||||
// 筛选支持指定模式的子模型
|
||||
const compatibleModels = subModels.filter((m) => {
|
||||
const caps = m.model.capabilities as VideoModelCapabilities;
|
||||
const supportsMode = caps.supportedModes?.includes(mode);
|
||||
const supportsMode = getSupportedModes(caps, m.model.modelId).includes(mode);
|
||||
|
||||
// 检查分辨率支持(如果用户指定了分辨率)
|
||||
const supportsResolution = !params.resolution ||
|
||||
caps.supportedResolutions?.includes(params.resolution);
|
||||
const supportedResolutions = caps.supportedResolutions || ["480p", "720p", "1080p"];
|
||||
const supportsResolution = !params.resolution || supportedResolutions.includes(params.resolution);
|
||||
|
||||
return supportsMode && supportsResolution;
|
||||
});
|
||||
@ -104,7 +125,7 @@ async function getCompatibleModel(
|
||||
|
||||
const compatibleModels = volcanoVideoModels.filter((m) => {
|
||||
const caps = m.model.capabilities as VideoModelCapabilities;
|
||||
return caps.supportedModes?.includes(mode);
|
||||
return getSupportedModes(caps, m.model.modelId).includes(mode);
|
||||
});
|
||||
|
||||
if (compatibleModels.length === 0) {
|
||||
@ -357,4 +378,3 @@ export function generateVideo({ dataStream }: { dataStream: any }) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user