From 46b3f4b081b88b7380fc162cfae6d310cac728aa Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 22:28:08 +0800 Subject: [PATCH] fix(video): harden volcano model routing and request retries --- app/(chat)/api/chat/route.ts | 217 ++++++++++++++++++-------- lib/ai/providers/volcano-video.ts | 250 ++++++++++++++++++++++++------ lib/ai/tools/generate-video.ts | 30 +++- 3 files changed, 379 insertions(+), 118 deletions(-) diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 7b7b7d44f..c630b2b56 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -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 = { - "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 = { - "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, diff --git a/lib/ai/providers/volcano-video.ts b/lib/ai/providers/volcano-video.ts index 0351ca85f..91b9c01b6 100644 --- a/lib/ai/providers/volcano-video.ts +++ b/lib/ai/providers/volcano-video.ts @@ -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 { + 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 { 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 = { + "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 { 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; } } - diff --git a/lib/ai/tools/generate-video.ts b/lib/ai/tools/generate-video.ts index 655927b9a..ff264b9e8 100644 --- a/lib/ai/tools/generate-video.ts +++ b/lib/ai/tools/generate-video.ts @@ -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 | 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 }) { }, }); } -