153 lines
7.5 KiB
TypeScript
153 lines
7.5 KiB
TypeScript
import { modelCapabilityConfigFor, videoDurationAllowed } from "@/lib/model-capabilities";
|
|
import { modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
|
|
|
export type ModelInputSummary = {
|
|
textCount: number;
|
|
imageCount: number;
|
|
videoCount: number;
|
|
audioCount: number;
|
|
characterCount: number;
|
|
};
|
|
|
|
export type ModelRequirements = {
|
|
capability?: ModelCapability;
|
|
input?: ModelInputSummary;
|
|
videoOperation?: string;
|
|
videoSeconds?: string;
|
|
};
|
|
|
|
export type DisplayModelGroup = {
|
|
key: string;
|
|
label: string;
|
|
models: string[];
|
|
};
|
|
|
|
export type ModelReferenceLimits = {
|
|
maxImages: number;
|
|
maxVideos: number;
|
|
maxAudios: number;
|
|
};
|
|
|
|
export function groupModelsByDisplayName(config: AiConfig, models: string[]): DisplayModelGroup[] {
|
|
const groups = new Map<string, DisplayModelGroup>();
|
|
models.forEach((model) => {
|
|
const channel = resolveModelChannel(config, model);
|
|
const label = configuredModelDisplayName(config, model);
|
|
const key = `${channel.id}\u0000${label.toLocaleLowerCase()}`;
|
|
const current = groups.get(key);
|
|
if (current) current.models.push(model);
|
|
else groups.set(key, { key, label, models: [model] });
|
|
});
|
|
return Array.from(groups.values());
|
|
}
|
|
|
|
export function configuredModelDisplayName(config: AiConfig, value: string) {
|
|
const model = modelOptionName(value);
|
|
const channel = resolveModelChannel(config, value);
|
|
return channel.modelCosts?.find((item) => item.model === model)?.displayName?.trim() || model;
|
|
}
|
|
|
|
export function modelCompatibilityError(config: AiConfig, model: string, requirements?: ModelRequirements) {
|
|
const capability = requirements?.capability;
|
|
const input = requirements?.input;
|
|
if (!capability || !input) return "";
|
|
const visualInputCount = input.imageCount + input.characterCount;
|
|
|
|
if (capability === "image") {
|
|
if (input.videoCount > 0) return "图片模型不支持参考视频";
|
|
if (input.audioCount > 0) return "图片模型不支持参考音频";
|
|
const maxImages = modelCapabilityConfigFor(config, model).image!.references.maxImages;
|
|
if (visualInputCount > maxImages) return `最多支持 ${maxImages} 张参考图`;
|
|
return "";
|
|
}
|
|
|
|
if (capability === "video") {
|
|
const profile = modelCapabilityConfigFor(config, model).video!;
|
|
if (visualInputCount > profile.references.maxImages) return `最多支持 ${profile.references.maxImages} 张参考图`;
|
|
if (input.videoCount > profile.references.maxVideos) return `最多支持 ${profile.references.maxVideos} 个参考视频`;
|
|
if (input.audioCount > profile.references.maxAudios) return `最多支持 ${profile.references.maxAudios} 个参考音频`;
|
|
if (requirements.videoSeconds && !videoDurationAllowed(profile, Number(requirements.videoSeconds))) return "不支持当前视频时长";
|
|
const operation = resolveVideoOperation(input, requirements.videoOperation);
|
|
if (operation !== "concat" && !profile.operations.includes(operation)) return `不支持${videoOperationLabel(operation)}`;
|
|
return "";
|
|
}
|
|
|
|
if (capability === "text") {
|
|
return input.audioCount > 0 ? "文本模型不支持参考音频" : "";
|
|
}
|
|
|
|
if (input.characterCount > 1) return "角色配音一次只能引用一个角色卡";
|
|
return input.imageCount > 0 || input.videoCount > 0 || input.audioCount > 0 ? "音频模型只接受文本或单个角色卡输入" : "";
|
|
}
|
|
|
|
export function compatibleModelInGroup(config: AiConfig, models: string[], requirements?: ModelRequirements, preferred?: string) {
|
|
const ordered = preferred && models.includes(preferred) ? [preferred, ...models.filter((model) => model !== preferred)] : models;
|
|
return ordered.find((model) => !modelCompatibilityError(config, model, requirements)) || "";
|
|
}
|
|
|
|
export function resolveCompatibleModel(config: AiConfig, selected: string, requirements?: ModelRequirements) {
|
|
if (!requirements?.capability) return selected;
|
|
const options = selectableModelsByCapability(config, requirements.capability);
|
|
if (!options.length) return selected;
|
|
const selectedGroup = groupModelsByDisplayName(config, options).find((group) => group.models.includes(selected));
|
|
if (!selectedGroup) return selected;
|
|
return compatibleModelInGroup(config, selectedGroup.models, requirements, selected);
|
|
}
|
|
|
|
export function maxModelInputCapacity(config: AiConfig, capability: "image" | "video", kind: "image" | "video" | "audio") {
|
|
const options = selectableModelsByCapability(config, capability);
|
|
if (!options.length) return null;
|
|
return options.reduce((maximum, model) => {
|
|
const profile = modelCapabilityConfigFor(config, model);
|
|
const value =
|
|
capability === "image" ? (kind === "image" ? profile.image!.references.maxImages : 0) : kind === "image" ? profile.video!.references.maxImages : kind === "video" ? profile.video!.references.maxVideos : profile.video!.references.maxAudios;
|
|
return Math.max(maximum, value);
|
|
}, 0);
|
|
}
|
|
|
|
export function modelGroupReferenceLimits(config: AiConfig, selected: string, capability: ModelCapability, requirements?: ModelRequirements): ModelReferenceLimits | undefined {
|
|
if (capability !== "image" && capability !== "video") return undefined;
|
|
const options = selectableModelsByCapability(config, capability);
|
|
const selectedGroup = groupModelsByDisplayName(config, options).find((group) => group.models.includes(selected));
|
|
const groupModels = selectedGroup?.models || (selected ? [selected] : []);
|
|
if (!groupModels.length) return undefined;
|
|
const compatibleModels = requirements ? groupModels.filter((model) => !modelCompatibilityError(config, model, requirements)) : groupModels;
|
|
const models = compatibleModels.length ? compatibleModels : groupModels;
|
|
return models.reduce<ModelReferenceLimits>(
|
|
(limits, model) => {
|
|
const profile = modelCapabilityConfigFor(config, model);
|
|
if (capability === "image") {
|
|
return { ...limits, maxImages: Math.max(limits.maxImages, profile.image!.references.maxImages) };
|
|
}
|
|
const references = profile.video!.references;
|
|
return {
|
|
maxImages: Math.max(limits.maxImages, references.maxImages),
|
|
maxVideos: Math.max(limits.maxVideos, references.maxVideos),
|
|
maxAudios: Math.max(limits.maxAudios, references.maxAudios),
|
|
};
|
|
},
|
|
{ maxImages: 0, maxVideos: 0, maxAudios: 0 },
|
|
);
|
|
}
|
|
|
|
export function inferVideoOperation(input: ModelInputSummary) {
|
|
const visualInputCount = input.imageCount + input.characterCount;
|
|
if (input.audioCount > 0 && visualInputCount === 0 && input.videoCount === 0) return "audio_to_video";
|
|
if (input.videoCount > 0) return "extend";
|
|
if (visualInputCount > 0) return "image_to_video";
|
|
return "text_to_video";
|
|
}
|
|
|
|
export function resolveVideoOperation(input: ModelInputSummary, storedOperation?: string) {
|
|
if (storedOperation && !["text_to_video", "image_to_video", "audio_to_video", "extend"].includes(storedOperation)) return storedOperation;
|
|
return inferVideoOperation(input);
|
|
}
|
|
|
|
function videoOperationLabel(operation: string) {
|
|
if (operation === "text_to_video") return "文生视频";
|
|
if (operation === "image_to_video") return "图生视频";
|
|
if (operation === "audio_to_video") return "音频生视频";
|
|
if (operation === "extend") return "视频续写";
|
|
return "当前生成模式";
|
|
}
|