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);
|
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({
|
async function replayAssistantMessagesForDuplicateRequest({
|
||||||
chatId,
|
chatId,
|
||||||
userMessageId,
|
userMessageId,
|
||||||
@ -2118,41 +2215,18 @@ export async function POST(request: Request) {
|
|||||||
console.log("[Chat API] Video params:", videoParams);
|
console.log("[Chat API] Video params:", videoParams);
|
||||||
|
|
||||||
// 如果没有 videoParams 或没有 mode,使用默认模式
|
// 如果没有 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);
|
console.log("[Chat API] Using mode:", mode);
|
||||||
|
|
||||||
// 根据模式选择对应的子模型
|
const matchedSubModel = selectBestVideoSubModelForMode(subModels as any, mode);
|
||||||
const modeToModelMap: Record<string, string> = {
|
|
||||||
"text-to-video": "lite-t2v", // 文生视频 → Lite T2V
|
if (matchedSubModel) {
|
||||||
"first-last-frame": "pro", // 首尾帧 → Pro
|
console.log(
|
||||||
"first-frame": "pro-fast", // 智能多帧 → Pro Fast
|
`[Chat API] 视频模式 ${mode} → 选择子模型: ${matchedSubModel.model.displayName} (${matchedSubModel.model.modelId})`
|
||||||
"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)
|
|
||||||
);
|
);
|
||||||
|
targetModelId = matchedSubModel.model.id;
|
||||||
if (matchedSubModel) {
|
} else {
|
||||||
console.log(`[Chat API] 视频模式 ${mode} → 选择子模型: ${matchedSubModel.model.displayName}`);
|
console.warn(`[Chat API] 未找到可用子模型,继续使用主模型`);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3724,6 +3798,8 @@ export async function POST(request: Request) {
|
|||||||
videoParams: effectiveVideoParams,
|
videoParams: effectiveVideoParams,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const videoMode = (effectiveVideoParams?.mode || "text-to-video") as VideoModeType;
|
||||||
|
|
||||||
// 🆕 重新执行子模型选择逻辑,确定要使用的模型 UUID
|
// 🆕 重新执行子模型选择逻辑,确定要使用的模型 UUID
|
||||||
let videoModelId = finalModelId; // 默认使用选择的模型 ID
|
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`);
|
console.log(`[Chat API] Video: Found ${videoSubModels.length} sub-models for master model`);
|
||||||
|
|
||||||
if (videoSubModels.length > 0) {
|
if (videoSubModels.length > 0) {
|
||||||
const videoMode = effectiveVideoParams?.mode || "text-to-video";
|
const matchedModel = selectBestVideoSubModelForMode(videoSubModels as any, videoMode);
|
||||||
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)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matchedModel) {
|
if (matchedModel) {
|
||||||
videoModelId = matchedModel.model.id;
|
videoModelId = matchedModel.model.id;
|
||||||
console.log(
|
console.log(
|
||||||
`[Chat API] Video: Selected sub-model ${matchedModel.model.displayName} for mode ${videoMode}`
|
`[Chat API] Video: Selected sub-model ${matchedModel.model.displayName} (${matchedModel.model.modelId}) 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}`
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
videoModelId = videoSubModels[0].model.id;
|
|
||||||
console.log(
|
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("视频模型配置未找到");
|
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 配置
|
// 如果是子模型,获取父模型的 Provider 配置
|
||||||
let providerModelData = aiModelData;
|
let providerModelData = finalVideoModelData;
|
||||||
if (aiModelData.isSubModel && aiModelData.parentModelId) {
|
if (finalVideoModelData.isSubModel && finalVideoModelData.parentModelId) {
|
||||||
console.log(
|
console.log(
|
||||||
"[Chat API] This is a sub-model, getting parent model config:",
|
"[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) {
|
if (parentModel) {
|
||||||
providerModelData = parentModel;
|
providerModelData = parentModel;
|
||||||
}
|
}
|
||||||
@ -3802,13 +3893,13 @@ export async function POST(request: Request) {
|
|||||||
throw new Error("Provider 配置未找到");
|
throw new Error("Provider 配置未找到");
|
||||||
}
|
}
|
||||||
|
|
||||||
const actualVideoModelId = aiModelData.modelId;
|
const actualVideoModelId = finalVideoModelData.modelId;
|
||||||
|
|
||||||
// 构建视频生成选项
|
// 构建视频生成选项
|
||||||
const videoOptions: any = {
|
const videoOptions: any = {
|
||||||
model: actualVideoModelId,
|
model: actualVideoModelId,
|
||||||
prompt: prompt || "生成视频",
|
prompt: prompt || "生成视频",
|
||||||
mode: effectiveVideoParams?.mode || "text-to-video",
|
mode: videoMode,
|
||||||
resolution: effectiveVideoParams?.resolution || "720p",
|
resolution: effectiveVideoParams?.resolution || "720p",
|
||||||
ratio: effectiveVideoParams?.ratio || "16:9",
|
ratio: effectiveVideoParams?.ratio || "16:9",
|
||||||
duration: effectiveVideoParams?.duration || 5,
|
duration: effectiveVideoParams?.duration || 5,
|
||||||
|
|||||||
@ -11,33 +11,138 @@ import type {
|
|||||||
} from "@/types/ai";
|
} from "@/types/ai";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
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<{
|
async function normalizeImageUrlForVolcano(imageUrl: string): Promise<string> {
|
||||||
permanentUrl: string;
|
|
||||||
base64: string;
|
|
||||||
}> {
|
|
||||||
try {
|
try {
|
||||||
// 如果已经是 Base64,直接返回
|
// Base64 输入:上传到 S3,返回永久 URL
|
||||||
if (imageUrl.startsWith("data:image/")) {
|
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) {
|
if (!matches) {
|
||||||
throw new Error("Invalid base64 image format");
|
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 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);
|
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}`);
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to download image: ${response.statusText}`);
|
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}`);
|
console.log(`[Volcano Video] Image uploaded to S3: ${permanentUrl}`);
|
||||||
|
|
||||||
// 转换为 Base64
|
return permanentUrl;
|
||||||
const base64 = bufferNode.toString("base64");
|
|
||||||
|
|
||||||
return { permanentUrl, base64 };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Volcano Video] Image processing failed:", error);
|
console.error("[Volcano Video] Image processing failed:", error);
|
||||||
throw new Error("图片处理失败,请重试");
|
throw new Error("图片处理失败,请重试");
|
||||||
@ -175,15 +277,13 @@ async function createVideoTask(
|
|||||||
// 2. 处理图片输入(根据模式)
|
// 2. 处理图片输入(根据模式)
|
||||||
if (options.mode === "first-frame" && options.firstFrameImage) {
|
if (options.mode === "first-frame" && options.firstFrameImage) {
|
||||||
// 首帧模式
|
// 首帧模式
|
||||||
const { permanentUrl, base64 } = await convertImageUrlToBase64(
|
const firstFrameUrl = await normalizeImageUrlForVolcano(options.firstFrameImage);
|
||||||
options.firstFrameImage
|
inputImageUrls.firstFrame = firstFrameUrl;
|
||||||
);
|
|
||||||
inputImageUrls.firstFrame = permanentUrl;
|
|
||||||
|
|
||||||
content.push({
|
content.push({
|
||||||
type: "image_url",
|
type: "image_url",
|
||||||
image_url: {
|
image_url: {
|
||||||
url: `data:image/jpeg;base64,${base64}`,
|
url: firstFrameUrl,
|
||||||
},
|
},
|
||||||
role: "first_frame",
|
role: "first_frame",
|
||||||
});
|
});
|
||||||
@ -193,24 +293,24 @@ async function createVideoTask(
|
|||||||
options.lastFrameImage
|
options.lastFrameImage
|
||||||
) {
|
) {
|
||||||
// 首尾帧模式
|
// 首尾帧模式
|
||||||
const firstFrame = await convertImageUrlToBase64(options.firstFrameImage);
|
const firstFrameUrl = await normalizeImageUrlForVolcano(options.firstFrameImage);
|
||||||
const lastFrame = await convertImageUrlToBase64(options.lastFrameImage);
|
const lastFrameUrl = await normalizeImageUrlForVolcano(options.lastFrameImage);
|
||||||
|
|
||||||
inputImageUrls.firstFrame = firstFrame.permanentUrl;
|
inputImageUrls.firstFrame = firstFrameUrl;
|
||||||
inputImageUrls.lastFrame = lastFrame.permanentUrl;
|
inputImageUrls.lastFrame = lastFrameUrl;
|
||||||
|
|
||||||
content.push(
|
content.push(
|
||||||
{
|
{
|
||||||
type: "image_url",
|
type: "image_url",
|
||||||
image_url: {
|
image_url: {
|
||||||
url: `data:image/jpeg;base64,${firstFrame.base64}`,
|
url: firstFrameUrl,
|
||||||
},
|
},
|
||||||
role: "first_frame",
|
role: "first_frame",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "image_url",
|
type: "image_url",
|
||||||
image_url: {
|
image_url: {
|
||||||
url: `data:image/jpeg;base64,${lastFrame.base64}`,
|
url: lastFrameUrl,
|
||||||
},
|
},
|
||||||
role: "last_frame",
|
role: "last_frame",
|
||||||
}
|
}
|
||||||
@ -220,13 +320,13 @@ async function createVideoTask(
|
|||||||
inputImageUrls.references = [];
|
inputImageUrls.references = [];
|
||||||
|
|
||||||
for (const imageUrl of options.referenceImages) {
|
for (const imageUrl of options.referenceImages) {
|
||||||
const { permanentUrl, base64 } = await convertImageUrlToBase64(imageUrl);
|
const normalizedUrl = await normalizeImageUrlForVolcano(imageUrl);
|
||||||
inputImageUrls.references.push(permanentUrl);
|
inputImageUrls.references.push(normalizedUrl);
|
||||||
|
|
||||||
content.push({
|
content.push({
|
||||||
type: "image_url",
|
type: "image_url",
|
||||||
image_url: {
|
image_url: {
|
||||||
url: `data:image/jpeg;base64,${base64}`,
|
url: normalizedUrl,
|
||||||
},
|
},
|
||||||
role: "reference_image",
|
role: "reference_image",
|
||||||
});
|
});
|
||||||
@ -238,6 +338,15 @@ async function createVideoTask(
|
|||||||
model: options.model,
|
model: options.model,
|
||||||
content: content, // 直接传content数组,不使用messages包装
|
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) {
|
if (options.returnLastFrame) {
|
||||||
@ -257,14 +366,22 @@ async function createVideoTask(
|
|||||||
console.log("[Volcano Video] 🔍 Request method: POST");
|
console.log("[Volcano Video] 🔍 Request method: POST");
|
||||||
console.log("[Volcano Video] 🔍 Request body:", JSON.stringify(requestBody, null, 2));
|
console.log("[Volcano Video] 🔍 Request body:", JSON.stringify(requestBody, null, 2));
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetchWithRetry(
|
||||||
method: "POST",
|
url,
|
||||||
headers: {
|
{
|
||||||
"Content-Type": "application/json",
|
method: "POST",
|
||||||
Authorization: `Bearer ${apiKey}`,
|
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 status:", response.status);
|
||||||
console.log("[Volcano Video] 🔍 Response headers:", JSON.stringify(Object.fromEntries(response.headers.entries())));
|
console.log("[Volcano Video] 🔍 Response headers:", JSON.stringify(Object.fromEntries(response.headers.entries())));
|
||||||
@ -302,12 +419,20 @@ async function fetchTaskStatus(
|
|||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const url = `${baseURL}/contents/generations/tasks/${taskId}`;
|
const url = `${baseURL}/contents/generations/tasks/${taskId}`;
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetchWithRetry(
|
||||||
method: "GET",
|
url,
|
||||||
headers: {
|
{
|
||||||
Authorization: `Bearer ${apiKey}`,
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
|
label: `task-status:${taskId}`,
|
||||||
|
timeoutMs: 45000,
|
||||||
|
retries: 2,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
@ -330,7 +455,20 @@ async function pollTaskStatus(
|
|||||||
console.log("[Volcano Video] Polling task:", taskId);
|
console.log("[Volcano Video] Polling task:", taskId);
|
||||||
|
|
||||||
for (let i = 0; i < maxRetries; i++) {
|
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(
|
console.log(
|
||||||
`[Volcano Video] Poll ${i + 1}/${maxRetries}, status: ${status.status}`
|
`[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("视频生成超时,请稍后重试");
|
throw new Error("视频生成超时,请稍后重试");
|
||||||
@ -454,7 +592,20 @@ export async function* doGenerateStream(
|
|||||||
const pollInterval = 10000;
|
const pollInterval = 10000;
|
||||||
|
|
||||||
for (let i = 0; i < maxRetries; i++) {
|
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") {
|
if (status.status === "succeeded") {
|
||||||
// 火山引擎API返回格式:status.content.video_url
|
// 火山引擎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("视频生成超时,请稍后重试");
|
throw new Error("视频生成超时,请稍后重试");
|
||||||
@ -514,4 +665,3 @@ export async function* doGenerateStream(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,27 @@ import type {
|
|||||||
VideoGenerationOptions,
|
VideoGenerationOptions,
|
||||||
} from "@/types/ai";
|
} 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 compatibleModels = subModels.filter((m) => {
|
||||||
const caps = m.model.capabilities as VideoModelCapabilities;
|
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 ||
|
const supportedResolutions = caps.supportedResolutions || ["480p", "720p", "1080p"];
|
||||||
caps.supportedResolutions?.includes(params.resolution);
|
const supportsResolution = !params.resolution || supportedResolutions.includes(params.resolution);
|
||||||
|
|
||||||
return supportsMode && supportsResolution;
|
return supportsMode && supportsResolution;
|
||||||
});
|
});
|
||||||
@ -104,7 +125,7 @@ async function getCompatibleModel(
|
|||||||
|
|
||||||
const compatibleModels = volcanoVideoModels.filter((m) => {
|
const compatibleModels = volcanoVideoModels.filter((m) => {
|
||||||
const caps = m.model.capabilities as VideoModelCapabilities;
|
const caps = m.model.capabilities as VideoModelCapabilities;
|
||||||
return caps.supportedModes?.includes(mode);
|
return getSupportedModes(caps, m.model.modelId).includes(mode);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (compatibleModels.length === 0) {
|
if (compatibleModels.length === 0) {
|
||||||
@ -357,4 +378,3 @@ export function generateVideo({ dataStream }: { dataStream: any }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user