ai-chatbot/lib/video-canvas/model-matching.ts

124 lines
7.1 KiB
TypeScript

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 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<string, any> {
return value && typeof value === "object" ? value as Record<string, any> : {};
}
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<string, VideoGenerationMode> = {
"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<T extends CanvasModelDescriptor>(
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 function chooseStoryVideoSettings(models: CanvasModelDescriptor[], hasImage: boolean, seconds: number, preferredRef?: string) {
const mode:VideoGenerationMode = hasImage ? "first-frame" : "text-to-video";
const choices = filterCanvasModels(models,"video",mode);
const model = choices.find(item=>item.id===preferredRef||item.modelId===preferredRef) || choices[0];
if (!model) throw Error(hasImage ? "没有可用的首帧视频模型" : "镜头缺少首帧,且没有可用的文生视频模型");
const options = getCanvasVideoParameters(model,mode);
if (!options.durations.length || !options.ratios.length || !options.resolutions.length) throw Error("视频模型缺少已确认的时长、比例或清晰度规格,请先完善模型配置");
const duration = options.durations.reduce((best,value)=>Math.abs(value-seconds)<Math.abs(best-seconds)?value:best,options.durations[0]);
return {mode,model:model.id||model.modelId||"",duration,
ratio:options.ratios.includes("16:9")?"16:9":options.ratios[0],
resolution:options.resolutions.includes("720p")?"720p":options.resolutions[0],generateAudio:options.supportsAudio};
}