124 lines
7.1 KiB
TypeScript
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};
|
|
}
|