import { createLogger } from "@/lib/observability/logger"; import { after } from "next/server"; import { getAiModelById, getEnabledAiModelsWithProvider, updateMessageById, } from "@/lib/db/queries"; import { isImageGenerationModelDescriptor, isVideoModelDescriptor, } from "@/lib/ai/model-classification"; import type { StoryboardModelCandidate } from "@/lib/video-agent/types"; const chatApiLogger = createLogger("chat.videoModels"); export type VideoModeType = | "text-to-video" | "first-frame" | "first-last-frame" | "reference-images"; export function isVideoProviderType( providerType?: string | null, model?: { modelId?: string | null; capabilities?: unknown; supportsImageGen?: boolean | null; }, ): boolean { return isVideoModelDescriptor({ providerType, modelId: model?.modelId, capabilities: model?.capabilities, supportsImageGen: model?.supportsImageGen, }); } export function isImageGenerationModelType( providerType?: string | null, model?: { modelId?: string | null; capabilities?: unknown; supportsImageGen?: boolean | null; }, ): boolean { return isImageGenerationModelDescriptor({ providerType, modelId: model?.modelId, capabilities: model?.capabilities, supportsImageGen: model?.supportsImageGen, }); } export function inferSupportedVideoModesByModelId(modelId: string): VideoModeType[] { const normalized = modelId.toLowerCase(); if ( normalized === "gen4.5" || normalized === "gen4_turbo" || normalized === "gen4_aleph" || normalized === "gen3a_turbo" || normalized === "veo3" || normalized === "veo3.1" || normalized === "veo3.1_fast" ) { return ["text-to-video", "first-frame"]; } 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"]; } export 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); } export 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; } } export 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]; } export async function resolveStoryboardModelCandidates( selectedModelRef: string, ): Promise { const selectedModel = await getAiModelById({ id: selectedModelRef }).catch(() => undefined); if (!selectedModel) return []; const configuredFallbackIds = [ selectedModel.fallbackModelId1, selectedModel.fallbackModelId2, ].filter((item): item is string => Boolean(String(item || "").trim())); const allModels = await getEnabledAiModelsWithProvider(true, true).catch(() => []); const sameProvider = allModels .filter( ({ model, provider }: any) => provider?.id === selectedModel.providerId && model?.id !== selectedModel.id && model?.isEnabled, ) .map(({ model }: any) => String(model?.id || "").trim()) .filter(Boolean); const candidateIds = Array.from(new Set([selectedModel.id, ...configuredFallbackIds, ...sameProvider])); const byModelRef = new Map(); allModels .map(({ model }: any) => model) .filter(Boolean) .forEach((model: any) => { byModelRef.set(String(model.id), model); if (model.modelId) { byModelRef.set(String(model.modelId), model); } }); return candidateIds .map((id) => byModelRef.get(id)) .filter(Boolean) .map((model: any) => ({ id: String(model.id), modelId: String(model.modelId || "").trim(), displayName: String(model.displayName || model.modelId || "").trim(), })) .filter((item) => item.modelId); } export function extractVideoUrlFromText(text: string): string | null { const normalizedText = String(text || ""); if (!normalizedText) return null; const htmlMatch = normalizedText.match(/]+src="([^"]+)"/i); if (htmlMatch?.[1]) return htmlMatch[1]; const markdownMatch = normalizedText.match( /\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/i, ); if (markdownMatch?.[1]) return markdownMatch[1]; const plainMatch = normalizedText.match(/(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/i); return plainMatch?.[1] || null; } export function isAlreadyArchivedVideoUrl(url: string): boolean { return /\/uploads\//i.test(url); } export function scheduleVideoArchive(params: { chatId: string; messageId: string; text: string; temporaryVideoUrl: string; taskId?: string; metadata?: Record | null; }) { const { chatId, messageId, temporaryVideoUrl, taskId, metadata } = params; if (!temporaryVideoUrl || isAlreadyArchivedVideoUrl(temporaryVideoUrl)) { return; } const delayHoursRaw = Number(process.env.VIDEO_ARCHIVE_DELAY_HOURS || 48); const delayHours = Number.isFinite(delayHoursRaw) ? Math.max(1, Math.min(24 * 30, Math.round(delayHoursRaw))) : 48; const archiveAfterAtMs = Date.now() + delayHours * 60 * 60 * 1000; after(async () => { try { const nextMetadata = { ...(metadata || {}), temporaryVideoUrl, storageStatus: "temporary", archiveMode: "delayed_job", archiveAfterAt: archiveAfterAtMs, archiveDelayHours: delayHours, archiveTaskId: taskId || messageId, updatedAt: Date.now(), }; await updateMessageById({ messageId, chatId, videoStorageStatus: "temporary", temporaryVideoUrl, permanentVideoUrl: null, archivedAt: null, videoMetadata: nextMetadata as any, }); chatApiLogger.info("[Chat API] 🕒 已登记延迟归档视频:", { messageId, archiveAfterAt: new Date(archiveAfterAtMs).toISOString(), }); } catch (error) { chatApiLogger.error("[Chat API] ❌ 登记视频延迟归档失败(保留临时地址):", { messageId, temporaryVideoUrl, error, }); } }); }