Files
ttstd-infinite-canvas/web/src/lib/model-selection.ts

193 lines
10 KiB
TypeScript

import { defaultImageCapabilityConfig, modelCapabilityConfigFor, videoDurationAllowed, type ImageCapabilityConfig } 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;
imageSize?: 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;
if (!capability) return "";
const input = requirements?.input;
const visualInputCount = input ? input.imageCount + input.characterCount : 0;
if (capability === "image") {
const image = modelCapabilityConfigFor(config, model).image!;
// 尺寸兼容不依赖输入摘要:无输入时(如画布重试/工具链)也要按尺寸过滤组内模型。
if (requirements.imageSize && !image.size.allowCustom && !image.size.values.includes(requirements.imageSize)) return "不支持当前尺寸";
if (!input) return "";
if (input.videoCount > 0) return "图片模型不支持参考视频";
if (input.audioCount > 0) return "图片模型不支持参考音频";
if (visualInputCount > image.references.maxImages) return `最多支持 ${image.references.maxImages} 张参考图`;
return "";
}
if (capability === "video") {
const profile = modelCapabilityConfigFor(config, model).video!;
if (requirements.videoSeconds && !videoDurationAllowed(profile, Number(requirements.videoSeconds))) return "不支持当前视频时长";
if (!input) return "";
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} 个参考音频`;
const operation = resolveVideoOperation(input, requirements.videoOperation);
if (operation !== "concat" && !profile.operations.includes(operation)) return `不支持${videoOperationLabel(operation)}`;
return "";
}
if (!input) 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 compatible = models.filter((model) => !modelCompatibilityError(config, model, requirements));
if (!compatible.length) return "";
if (compatible.length === 1) return compatible[0];
const priceOf = (model: string) => {
const channel = resolveModelChannel(config, model);
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
return cost && Number.isFinite(cost.unitPriceMicrocredits) ? cost.unitPriceMicrocredits : Number.POSITIVE_INFINITY;
};
return compatible.sort((left, right) => priceOf(left) - priceOf(right) || (left === preferred ? -1 : right === preferred ? 1 : 0))[0];
}
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 mergedImageCapabilityConfig(config: AiConfig, selected: string): ImageCapabilityConfig {
const options = selectableModelsByCapability(config, "image");
const group = options.length ? groupModelsByDisplayName(config, options).find((item) => item.models.includes(selected)) : undefined;
const models = group?.models.length ? group.models : [selected];
const selectedProfile = modelCapabilityConfigFor(config, selected).image;
const profiles = models.map((model) => modelCapabilityConfigFor(config, model).image).filter((profile): profile is ImageCapabilityConfig => Boolean(profile));
if (profiles.length <= 1) return selectedProfile || defaultImageCapabilityConfig();
const values = Array.from(new Set(profiles.flatMap((profile) => profile.size.values)));
const base = selectedProfile || profiles[0];
return { ...base, size: { ...base.size, values } };
}
// 切换模型后初始化图片参数为该模型能力默认值,避免旧参数在目标模型族不兼容导致无法切换。
export function defaultImageParamsForModel(config: AiConfig, model: string): Pick<AiConfig, "size" | "quality" | "transparentBackground"> {
const image = modelCapabilityConfigFor(config, model).image;
if (!image) return { size: "1:1", quality: "auto", transparentBackground: "false" };
return {
size: image.size.default || image.size.values[0] || "1:1",
quality: image.quality.default || "auto",
transparentBackground: String(image.transparentBackground.default ?? false),
};
}
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 "当前生成模式";
}