import { is3DModelDescriptor, isImageGenerationModelDescriptor, isVideoModelDescriptor, } from "@/lib/ai/model-classification"; import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-capability"; import { isAiModelMultimodal } from "@/lib/db/ai-model-flags"; import { vip98kVideoDurationBounds } from "@/lib/ai/vip98k-video-parameters"; import type { VideoGenerationMode } from "@/types/ai"; export type CanvasModelKind = "image" | "image-edit" | "video" | "audio-video" | "multimodal" | "text" | "any"; export type CanvasModelDescriptor = { id?: string; modelId?: string | null; displayName?: string | null; providerType?: string | null; supportsVision?: boolean | null; supportsImageGen?: boolean | null; isMultimodal?: boolean | null; isEnabled?: boolean; selectable?: boolean; capabilities?: unknown; defaultParams?: unknown; imageGenConfig?: unknown; }; function record(value: unknown): Record { return value && typeof value === "object" ? value as Record : {}; } export function isCanvasLanguageModel(model: CanvasModelDescriptor) { const caps = record(model.capabilities); return !is3DModelDescriptor(model) && !isImageGenerationModelDescriptor(model) && !isVideoModelDescriptor(model) && !/^(audio|speech|tts|asr|embedding|rerank|image-tool|video-tool|world|model-3d)$/i.test(String(caps.kind || caps.mediaType || "")) && !/world[-_]generation|text-to-3d|image-to-3d/i.test(model.modelId || ""); } export function isCanvasVisionModel(model: CanvasModelDescriptor) { return isCanvasLanguageModel(model) && Boolean( model.supportsVision || model.isMultimodal || isAiModelMultimodal(model) ); } export function getCanvasVideoModes(model: CanvasModelDescriptor): VideoGenerationMode[] { if (!isVideoModelDescriptor(model)) return []; const raw = record(model.capabilities).supportedModes; if (!Array.isArray(raw)) return []; const aliases: Record = { "text-to-video": "text-to-video", "text2video": "text-to-video", "image-to-video": "first-frame", "image2video": "first-frame", "first-frame": "first-frame", "first-last-frame": "first-last-frame", "frames2video": "first-last-frame", "reference-images": "reference-images", "reference-to-video": "reference-images", }; return [...new Set(raw.map(value => aliases[String(value)]).filter(Boolean))]; } // Only adapters actually handled by the canvas video endpoint are selectable here. export function isExecutableCanvasVideoModel(model: CanvasModelDescriptor) { if (!isVideoModelDescriptor(model)) return false; if (model.providerType === "qianwen-video" && model.modelId === "wan2.6-i2v-flash") return true; if (["volcano-video", "doubao-video", "atlascloud", "vip98k"].includes(model.providerType || "")) return true; const caps = record(model.capabilities); return model.providerType === "custom" && caps.kind === "video" && (caps.provider === "agnes-ai" || /^agnes-video/i.test(model.modelId || "")); } export function supportsCanvasAudioVideo(model: CanvasModelDescriptor) { const runtime = record(record(model.imageGenConfig).atlasCloud); return isExecutableCanvasVideoModel(model) && model.providerType === "atlascloud" && Array.isArray(runtime.propertyNames) && runtime.propertyNames.includes("reference_audios"); } export function filterCanvasModels( models: T[], kind: CanvasModelKind, mode?: VideoGenerationMode, ) { return models.filter(model => { if (model.isEnabled === false || model.selectable === false) return false; if (kind === "audio-video") return supportsCanvasAudioVideo(model); if (kind === "video") return isExecutableCanvasVideoModel(model) && getCanvasVideoModes(model).length > 0 && (!mode || getCanvasVideoModes(model).includes(mode)); if (kind === "image" || kind === "image-edit") return isImageGenerationModelDescriptor(model) && (kind !== "image-edit" || !modelLacksReferenceImageInputSupport(model, {type: model.providerType})); if (kind === "multimodal") return isCanvasVisionModel(model); if (kind === "text") return isCanvasLanguageModel(model); return true; }); } export function getCanvasVideoParameters(model: CanvasModelDescriptor | undefined, mode?: VideoGenerationMode) { const caps = record(model?.capabilities); const scoped = record(record(caps.modeConstraints || caps.constraints)[mode || ""]); const strings = (value: unknown) => Array.isArray(value) ? [...new Set(value.map(String).filter(Boolean))] : []; const durationRange = record(caps.durationRange); const min = Number(durationRange.min), max = Number(durationRange.max); const configured = record((model as CanvasModelDescriptor & {imageGenConfig?: unknown})?.imageGenConfig).videoDurationOptions; const durations = Array.isArray(scoped.durationOptions) ? scoped.durationOptions : Array.isArray(configured) && configured.length ? configured : caps.durationOptions; return { ratios: strings(scoped.ratioOptions || caps.supportedRatios), resolutions: strings(scoped.resolutionOptions || caps.supportedResolutions), durations: Array.isArray(durations) ? [...new Set(durations.map(Number).filter(n => Number.isFinite(n) && n > 0))].sort((a,b) => a-b) : min > 0 && max >= min && max-min <= 120 ? Array.from({length: max-min+1}, (_,i) => min+i) : [], supportsAudio: caps.supportGenerateAudio === true || caps.supportsAudio === true || caps.generateAudio === true || strings(record(record(model?.imageGenConfig).atlasCloud).propertyNames).includes("generate_audio"), }; } export function validateCanvasVideoInputs(mode: VideoGenerationMode, images: string[] = []) { if (mode === "text-to-video" && images.length !== 0) throw new Error("文生视频不能包含参考图片,请切换生成模式"); if (mode === "first-frame" && images.length !== 1) throw new Error("首帧生视频需要恰好一张参考图片"); if (mode === "first-last-frame" && images.length !== 2) throw new Error("首尾帧生视频需要恰好两张参考图片"); if (mode === "reference-images" && images.length === 0) throw new Error("参考生视频需要至少一张参考图片"); } export type StoryVideoRequirements = { ratio?: string; resolution?: string; generateAudio?: boolean; minimumDuration?: boolean; minimumResolution?: boolean; strictModel?: boolean; }; export function chooseStoryVideoSettings(models: CanvasModelDescriptor[], hasImage: boolean, seconds: number, preferredRef?: string, requirements: StoryVideoRequirements = {}) { const mode:VideoGenerationMode = hasImage ? "first-frame" : "text-to-video"; const choices = filterCanvasModels(models,"video",mode); const preferred = choices.find(item=>item.id===preferredRef||item.modelId===preferredRef); const selectResolution = (values: string[]) => requirements.minimumResolution && requirements.resolution ? values.filter(value => [480, 720, 768, 1080].includes(Number.parseInt(value)) && Number.parseInt(value) >= Number.parseInt(requirements.resolution!)) .sort((a, b) => Number.parseInt(a) - Number.parseInt(b))[0] : requirements.resolution; const durationsFor = (candidate: CanvasModelDescriptor) => { const values = getCanvasVideoParameters(candidate, mode); const resolution = selectResolution(values.resolutions) || (values.resolutions.includes("720p") ? "720p" : values.resolutions[0]); const bounds = candidate.providerType === "vip98k" ? vip98kVideoDurationBounds(record(record(candidate.capabilities).vip98k).invocation?.inputSchema, resolution) : { min: 0, max: Infinity, durations: undefined }; return values.durations.filter(value => value >= bounds.min && value <= bounds.max && (!bounds.durations || bounds.durations.includes(value))); }; if (requirements.strictModel && preferredRef && !preferred) throw Error("所选视频模型不支持当前生成模式,请重新选择"); const meetsRequirements = (candidate: CanvasModelDescriptor) => { const values = getCanvasVideoParameters(candidate, mode); return (!requirements.minimumDuration || durationsFor(candidate).some(value => value >= seconds)) && (!requirements.ratio || values.ratios.some(value => value === requirements.ratio || value === "adaptive" || value === "auto")) && (!requirements.resolution || (requirements.minimumResolution ? Boolean(selectResolution(values.resolutions)) : values.resolutions.some(value => value.toLowerCase() === requirements.resolution!.toLowerCase()))) && (requirements.generateAudio !== true || values.supportsAudio); }; if (requirements.strictModel && preferred && !meetsRequirements(preferred)) throw Error("所选视频模型无法满足镜头时长、画幅、清晰度或声音要求,请调整要求或选择其他模型"); const model = (preferred && meetsRequirements(preferred) ? preferred : undefined) || choices.find(meetsRequirements); if (!model) throw new Error(hasImage ? "没有可用的首帧视频模型" : "镜头缺少首帧,且没有可用的文生视频模型"); const options = getCanvasVideoParameters(model,mode); const executableResolutions = options.resolutions.filter(value => ["480p", "720p", "768p", "1080p"].includes(value.toLowerCase())); if (!options.durations.length || !options.ratios.length || !executableResolutions.length) throw new Error("视频模型缺少已确认且可执行的时长、比例或清晰度规格,请先完善模型配置"); const durations = durationsFor(model); if (!durations.length) throw Error("视频模型当前清晰度没有可执行的时长规格"); const duration = requirements.minimumDuration ? durations.find(value => value >= seconds)! : durations.reduce((best,value)=>Math.abs(value-seconds) value === "adaptive" || value === "auto")!) : options.ratios.includes("16:9")?"16:9":options.ratios[0], resolution:requirements.resolution ? selectResolution(options.resolutions)!.toLowerCase() : executableResolutions.includes("720p")?"720p":executableResolutions[0],generateAudio:requirements.generateAudio ?? options.supportsAudio}; }