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

440 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { defaultImageCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, STANDARD_IMAGE_SIZE_VALUES, videoDurationAllowed, type ImageCapabilityConfig } from "@/lib/model-capabilities";
import { videoResolutionComparisonKey } from "@/lib/video-generation-options";
import { imageSizePresets } from "@/lib/image-size-presets";
import { modelOptionName, PUBLIC_MODEL_CATALOG_ID, 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;
options?: Record<string, unknown>;
};
export type DisplayModelGroup = {
key: string;
label: string;
models: string[];
};
export type ModelReferenceLimits = {
maxImages: number;
maxVideos: number;
maxAudios: number;
};
/**
* 普通音频模型只接受文本或音色 ID。豆包音频生成可以连接最多 3 段参考音频,
* 或 1 张参考图片,两者不能同时使用。
*/
export function audioReferenceCapacity(config: AiConfig, model: string) {
return audioProtocol(config, model) === "doubao-streaming-tts" ? 3 : 0;
}
export function isDoubaoAudioModel(config: AiConfig, model: string) {
if (!String(model || "").trim()) return false;
if (audioProtocol(config, model) === "doubao-streaming-tts") return true;
// seed-audio-1.0 与 seed-audio-1.0-multilingual 共用同一套 references 合同。
return modelOptionName(model).toLowerCase().startsWith("seed-audio");
}
export function audioModelForConnection(config: AiConfig, explicit = "") {
const selected = String(explicit || "").trim();
if (selected) return selected;
if (String(config.audioModel || "").trim()) return config.audioModel;
return (config.audioModels || []).find((item) => isDoubaoAudioModel(config, item)) || "";
}
/**
* 从连线新建音频节点时选模型:默认音频模型接得住当前输入就用它,
* 否则改用第一个接得住的音频模型(例如图片连过来时改用豆包音频)。
*/
export function audioModelForConnectionInput(config: AiConfig, input: ModelInputSummary) {
const preferred = audioModelForConnection(config);
const requirements: ModelRequirements = { capability: "audio", input };
if (preferred && !modelCompatibilityError(config, preferred, requirements)) return preferred;
const candidates = config.audioModels?.length ? config.audioModels : selectableModelsByCapability(config, "audio");
return candidates.find((model) => !modelCompatibilityError(config, model, requirements)) || preferred;
}
export function doubaoAudioInputError(input: ModelInputSummary) {
if (input.videoCount > 0) return "音频生成不能连接参考视频";
if (input.imageCount > 0 && input.audioCount > 0) return "参考图片和参考音频不能同时连接";
if (input.imageCount > 1) return "最多连接 1 张参考图片";
if (input.audioCount > 3) return "最多连接 3 段参考音频";
return "";
}
function audioProtocol(config: AiConfig, model: string) {
const channel = resolveModelChannel(config, model);
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
return cost?.protocol || channel.interfaceType;
}
export function isDirectSystemModel(config: AiConfig, value: string) {
if (!value) return false;
const channel = resolveModelChannel(config, value);
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
return channel.scope === "system" && channel.id !== PUBLIC_MODEL_CATALOG_ID && !cost?.logicalModelId;
}
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);
// 平台直连模型是独立的渠道 SKU;同名只用于菜单展示,不能合并能力或自动改选。
const key = isDirectSystemModel(config, model) ? JSON.stringify([channel.id, modelOptionName(model)]) : `${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;
const channel = resolveModelChannel(config, model);
const logicalCost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
const logicalSpecs = logicalCost?.logicalCapabilityProfiles?.length ? logicalCost.logicalCapabilityProfiles : logicalCost?.logicalCapabilitySpec ? [logicalCost.logicalCapabilitySpec] : [];
if (logicalSpecs.length) {
const publicOptionNames = logicalCost?.logicalCapabilitySpec?.options || {};
const logicalRequirements = {
...requirements,
options: Object.fromEntries(Object.entries(requirements.options || {}).filter(([name]) => Boolean(publicOptionNames[name]))),
};
const errors = logicalSpecs.map((spec) => logicalModelCompatibilityError(spec, logicalRequirements, visualInputCount));
return errors.some((error) => !error) ? "" : errors[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") {
const text = modelCapabilityConfigFor(config, model).text;
if (input.imageCount > (text?.references.maxImages ?? 0)) return `最多支持 ${(text?.references.maxImages ?? 0)} 张参考图`;
if (input.videoCount > (text?.references.maxVideos ?? 0)) return `最多支持 ${(text?.references.maxVideos ?? 0)} 个参考视频`;
if (input.audioCount > 0) return "文本模型不支持参考音频";
return "";
}
if (isDoubaoAudioModel(config, model)) return doubaoAudioInputError(input);
if (input.imageCount > 0 || input.videoCount > 0) return "音频模型不能连接参考图片或参考视频";
if (input.characterCount > 1) return "角色配音一次只能引用一个角色卡";
const maxAudios = audioReferenceCapacity(config, model);
if (input.audioCount > maxAudios) {
return maxAudios > 0 ? `当前音频模型最多支持 ${maxAudios} 个参考音频` : "当前音频模型不支持参考音频";
}
return "";
}
export function modelPromptLengthError(config: AiConfig, model: string, capability: ModelCapability, prompt: string) {
const channel = resolveModelChannel(config, model);
const configuredProfile = channel.modelCosts?.find((item) => item.model === modelOptionName(model))?.capabilityConfig;
if (!configuredProfile) return "";
const profile = modelCapabilityConfigFor(config, model);
const maxChars = capability === "text" ? profile.text?.references.promptMaxChars : capability === "image" ? profile.image?.references.promptMaxChars : capability === "video" ? profile.video?.references.promptMaxChars : undefined;
if (!maxChars || maxChars <= 0) return "";
const actualChars = Array.from(prompt).length;
if (actualChars <= maxChars) return "";
const label = capability === "text" ? "文本" : capability === "image" ? "图片" : "视频";
return `当前${label}模型提示词最多 ${maxChars} 个字符,完整提示词为 ${actualChars} 个字符。系统不会自动截断,请精简当前输入、连线内容或技能上下文后重试`;
}
export function modelRequestOptions(config: AiConfig, capability: ModelCapability) {
switch (capability) {
case "image":
return { size: config.size, quality: config.quality, transparentBackground: config.transparentBackground === "true", count: Number(config.count) };
case "video":
return { size: config.size, videoSeconds: Number(config.videoSeconds), vquality: config.vquality, videoGenerateAudio: config.videoGenerateAudio === "true", videoWatermark: config.videoWatermark === "true" };
case "audio":
return { audioVoice: config.audioVoice, audioFormat: config.audioFormat, audioSpeed: Number(config.audioSpeed), audioEmotionControlMethod: config.audioEmotionControlMethod, audioEmotionRandom: config.audioEmotionRandom === "true", audioEmotionHappy: Number(config.audioEmotionHappy), audioEmotionAngry: Number(config.audioEmotionAngry), audioEmotionSad: Number(config.audioEmotionSad), audioEmotionAfraid: Number(config.audioEmotionAfraid), audioEmotionDisgusted: Number(config.audioEmotionDisgusted), audioEmotionMelancholic: Number(config.audioEmotionMelancholic), audioEmotionSurprised: Number(config.audioEmotionSurprised), audioEmotionCalm: Number(config.audioEmotionCalm) };
default:
return {};
}
}
function logicalModelCompatibilityError(spec: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalCapabilitySpec"]>, requirements: ModelRequirements, visualInputCount: number) {
if (requirements.capability && spec.capability !== requirements.capability) return "不支持当前生成类型";
const input = requirements.input;
const counts: Record<string, number> = {
text: 0,
image: visualInputCount,
video: input?.videoCount || 0,
audio: input?.audioCount || 0,
};
for (const [kind, count] of Object.entries(counts)) {
const constraint = spec.inputs?.[kind];
if (!constraint && count > 0) return `不支持${kind}输入`;
if (constraint && (count < constraint.min || count > constraint.max)) return `${kind}输入需为 ${constraint.min}-${constraint.max} 个`;
}
const operation = requirements.capability === "video" && input ? resolveVideoOperation(input, requirements.videoOperation) : requirements.videoOperation;
if (operation && spec.operations?.length && !spec.operations.includes(operation)) return "不支持当前生成模式";
// 图片创作状态也会携带全局默认视频时长;这个字段只对视频模型有意义,
// 不能把它拼进图片逻辑模型的能力匹配,否则图片模型会被误判为“不支持当前时长”。
const options = {
...requirements.options,
...(requirements.capability === "video" && requirements.videoSeconds ? { videoSeconds: requirements.videoSeconds } : {}),
...(requirements.capability === "image" && requirements.imageSize ? { size: requirements.imageSize } : {}),
};
for (const [name, value] of Object.entries(options)) {
if (value === undefined || value === null || value === "") continue;
const constraint = spec.options?.[name];
if (!constraint || !logicalOptionMatches(name, constraint, value)) return logicalOptionError(name);
}
return "";
}
function logicalOptionMatches(name: string, constraint: { values?: unknown[]; min?: number; max?: number; step?: number }, value: unknown) {
if (constraint.values?.length) {
const requested = normalizeLogicalOptionValue(name, value);
return constraint.values.some((candidate) => normalizeLogicalOptionValue(name, candidate) === requested);
}
const numeric = Number(value);
if (!Number.isFinite(numeric)) return false;
if (constraint.min !== undefined && numeric < constraint.min) return false;
if (constraint.max !== undefined && numeric > constraint.max) return false;
if (constraint.step !== undefined && constraint.min !== undefined) return Math.abs((numeric - constraint.min) / constraint.step - Math.round((numeric - constraint.min) / constraint.step)) < 1e-9;
return true;
}
function normalizeLogicalOptionValue(name: string, value: unknown) {
const normalized = String(value).trim().toLowerCase();
if (name !== "vquality" && name !== "resolution") return normalized;
return videoResolutionComparisonKey(normalized);
}
function logicalOptionError(name: string) {
const label: Record<string, string> = {
size: "尺寸",
quality: "质量",
transparentBackground: "透明背景",
count: "输出数量",
videoSeconds: "时长",
vquality: "分辨率",
videoGenerateAudio: "同步音频",
videoWatermark: "水印设置",
audioVoice: "音色",
audioFormat: "音频格式",
audioSpeed: "语速",
};
return `不支持当前${label[name] || name}`;
}
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 concreteValues = Array.from(new Set(profiles.flatMap((profile) => profile.size.values).filter((value) => value !== "*")));
const allowCustom = profiles.some((profile) => profile.size.allowCustom || profile.size.values.includes("*"));
const values = concreteValues.length ? concreteValues : allowCustom ? [...STANDARD_IMAGE_SIZE_VALUES] : [];
const base = selectedProfile || profiles[0];
const presets = profiles.some((profile) => profile.size.presets) ? [...new Map(profiles.flatMap((profile) => imageSizePresets(profile)).map((preset) => [`${preset.tier}:${preset.ratio}:${preset.size}`, preset])).values()] : undefined;
return { ...base, size: { ...base.size, values, allowCustom, presets } };
}
// 切换模型后初始化图片参数为该模型能力默认值,避免旧参数在目标模型族不兼容导致无法切换。
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" };
const sizeValues = image.size.values.filter((value) => value !== "*");
const sizeDefault = image.size.default !== "*" && image.size.default ? image.size.default : sizeValues[0] || "1:1";
return {
size: sizeDefault,
quality: image.quality.default || "auto",
transparentBackground: String(image.transparentBackground.default ?? false),
};
}
export type ModelGenerationDefaults = Pick<AiConfig, "size" | "quality" | "transparentBackground" | "count" | "videoSeconds" | "vquality" | "videoGenerateAudio" | "videoWatermark">;
type ModelVideoBooleanOptions = Pick<AiConfig, "videoGenerateAudio" | "videoWatermark">;
export function resolveModelGenerationDefaults(
config: AiConfig,
model: string,
capability: "image" | "video" | undefined,
explicit: Partial<ModelGenerationDefaults> = {},
fallback: Partial<ModelGenerationDefaults> = {},
): Partial<ModelGenerationDefaults> {
if (!capability) return {};
const channel = resolveModelChannel(config, model);
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
const isManagedModel = Boolean(cost?.logicalModelId || cost?.logicalCapabilitySpec);
// A channel model capability profile is an explicit per-model contract too.
// The persisted global values are legacy defaults and must not override a
// model's configured duration, ratio, or resolution on a new canvas node.
const hasModelCapabilityProfile = Boolean(cost?.capabilityConfig);
const source = (key: keyof ModelGenerationDefaults) => explicit[key] ?? (isManagedModel || hasModelCapabilityProfile ? undefined : fallback[key]);
const profile = modelCapabilityConfigFor(config, model);
if (capability === "image" && profile.image) {
const normalized = normalizeImageValue(profile.image, {
size: source("size"),
quality: source("quality"),
count: source("count"),
transparentBackground: source("transparentBackground"),
});
return normalized;
}
if (capability === "video" && profile.video) {
const normalized = normalizeVideoValue(profile.video, {
seconds: source("videoSeconds"),
ratio: source("size"),
resolution: source("vquality"),
});
return {
videoSeconds: normalized.seconds,
size: normalized.ratio,
vquality: normalized.resolution.replace(/p$/i, ""),
videoGenerateAudio: source("videoGenerateAudio") ?? String(profile.video.generateAudio.default),
videoWatermark: source("videoWatermark") ?? String(profile.video.watermark.default),
};
}
return {};
}
export function resolveModelVideoBooleanOptions(
config: AiConfig,
model: string,
explicit: Partial<ModelVideoBooleanOptions> = {},
fallback: Partial<ModelVideoBooleanOptions> = {},
): ModelVideoBooleanOptions {
const profile = modelCapabilityConfigFor(config, model).video!;
const defaults = resolveModelGenerationDefaults(config, model, "video", explicit, fallback);
return {
videoGenerateAudio: profile.generateAudio.supported ? defaults.videoGenerateAudio ?? String(profile.generateAudio.default) : "false",
videoWatermark: profile.watermark.supported ? defaults.videoWatermark ?? String(profile.watermark.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;
// 图片或角色决定图生视频主模式,音频只作为附加参考,不应把组合请求
// 提升为全模态参考;纯音频输入才使用独立的 audio_to_video 能力。
if (input.videoCount > 0 || visualInputCount > 2) return "reference_to_video";
if (visualInputCount > 0) return "image_to_video";
if (input.audioCount > 0) return "audio_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", "reference_to_video"].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 === "reference_to_video") return "全模态参考";
if (operation === "extend") return "视频续写";
return "当前生成模式";
}