440 lines
25 KiB
TypeScript
440 lines
25 KiB
TypeScript
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 "当前生成模式";
|
||
}
|