Files
ttstd-infinite-canvas/web/src/lib/model-capabilities.ts
huiku 7466119867 feat(image): 以插件声明统一图片参数、尺寸选择与定价合同 (#760)
* feat(image): 图片参数 - 统一插件声明配置与生成合同

* docs(image): 图片参数 - 说明声明合同与验证边界

* fix(plugins): 图片声明 - 保留内联素材支持并同步制品文档

* fix(image): 参数合同 - 对齐审批意图和尺寸预检调用方

* fix(image): 调用链 - 保留公开意图并接通独立分辨率

* fix(image): 图片参数 - 补齐分辨率接线与目录价格投影
2026-10-13 19:18:21 +08:00

682 lines
34 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 type { ModelProtocol, ModelProtocolWorkflow } from "@/lib/model-protocols";
import { projectImageParameters, normalizeImageParameters, imageParameterRequest } from "./image-parameters";
import type { ImageResolutionOption, ImageResolutionTier } from "@/lib/image-resolution-tiers";
import { IMAGE_RATIOS, IMAGE_RESOLUTIONS, imagePresetForRatio } from "@/lib/image-size-presets";
import { kemeiSeedreamPresets } from "./kemei-image-presets";
import { type WorkflowVideoFieldLike, workflowImageCapabilityConfig, workflowVideoCapabilityConfig } from "./model-capabilities-workflow";
import { resolveWorkflowVideoScreenSpec } from "./video-screen-specs";
export {
workflowFieldChoiceValues,
workflowFieldConfigurationError,
workflowFieldCurrentValue,
workflowFieldHasStoredValue,
workflowFieldIsRatioChoices,
workflowFieldKey,
workflowFieldNumberBounds,
workflowFieldOptionValue,
workflowFieldPresetOptions,
workflowFieldRandomKey,
workflowFieldRole,
workflowFieldSafeToOverride,
workflowFieldSource,
workflowFieldSubmissionValue,
workflowFieldValueError,
workflowImageCapabilityConfig,
workflowOutputSizeValue,
workflowParameterFields,
workflowVideoCapabilityConfig,
workflowVideoDefaultSize,
workflowVideoFieldsFromJson,
type WorkflowFieldNumberBounds,
type WorkflowVideoFieldLike,
} from "./model-capabilities-workflow";
export type ModelCapabilityConfig = {
version: number;
text?: TextCapabilityConfig;
image?: ImageCapabilityConfig;
video?: VideoCapabilityConfig;
};
export type TextCapabilityConfig = {
/** Whether the upstream text endpoint accepts SSE streaming responses. */
streaming?: boolean;
/** Whether the model exposes a user-selectable reasoning/thinking mode. */
thinking?: boolean;
/** Total provider input plus output context window, in tokens. */
contextWindowTokens: number;
/** Provider completion/reasoning output ceiling, in tokens. */
maxOutputTokens: number;
references: {
promptMaxChars: number;
maxImages: number;
maxImageBytes: number;
maxVideos: number;
maxVideoBytes: number;
};
};
export type ImageSizeParameter = "none" | "size" | "aspect_ratio";
/**
* 普通视频模型提示词字符数的默认上限。
*
* 视频提示词由「输入框文本 + 连线内容 + 技能上下文」合成,远长于用户手输内容,
* 因此不能沿用偏小的默认值把画布工作流拦在本地。必须与后端
* `DefaultVideoPromptMaxChars` 保持一致,否则前端放行后端拒绝(或反之)。
*/
export const DEFAULT_VIDEO_PROMPT_MAX_CHARS = 8000;
export type ImageCapabilityConfig = {
parameters?: import("./image-parameters").ImageParameter[];
references: {
promptMaxChars: number;
maxImages: number;
maxImageBytes: number;
maskSupported: boolean;
};
size: {
parameter: ImageSizeParameter;
values: string[];
default: string;
allowCustom: boolean;
presets?: ImageResolutionOption[];
defaultTier?: string;
};
resolution: { supported: boolean; values: string[]; default: string };
quality: {
supported: boolean;
values: string[];
default: string;
};
/** Protocol-declared mapping where quality values select image resolution. */
resolutionUsesQuality?: boolean;
transparentBackground: { supported: boolean; default: boolean };
responseFormat: { supported: boolean };
outputFormat: { supported: boolean };
maxOutputs: number;
};
export type VideoCapabilityConfig = {
references: {
promptMaxChars: number;
minImages: number;
maxImages: number;
maxImageBytes: number;
maxVideos: number;
maxVideoBytes: number;
maxVideoDurationSeconds: number;
maxAudios: number;
maxAudioBytes: number;
maxAudioDurationSeconds: number;
};
duration: {
selection: "range" | "enum";
min?: number;
max?: number;
step?: number;
values?: number[];
default: number;
};
durationSupported?: boolean;
ratios: string[];
defaultRatio: string;
resolutions: string[];
defaultResolution: string;
fixedScreenSpec?: VideoScreenSpecConfig;
generateAudio: { supported: boolean; default: boolean };
watermark: { supported: boolean; default: boolean };
operations: string[];
defaultOperation: string;
};
export type VideoScreenSpecConfig = Pick<VideoCapabilityConfig, "ratios" | "defaultRatio" | "resolutions" | "defaultResolution">;
// 旧版本的“允许自定义”可能只保存了 `*`,前台需要用这组标准值恢复可选项。
export const STANDARD_IMAGE_SIZE_VALUES = ["1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "21:9", "9:16", "1024x1024", "1536x1024", "1024x1536"] as const;
export function normalizeCapabilityString(value: string) {
const normalized = value.trim();
return normalized.startsWith("string:") ? normalized.slice("string:".length) : normalized;
}
function normalizeCapabilityStrings(values: string[]) {
return Array.from(new Set(values.map(normalizeCapabilityString)));
}
export function normalizeModelCapabilityConfig(config: ModelCapabilityConfig): ModelCapabilityConfig {
return {
...config,
text: config.text
? {
...config.text,
streaming: config.text.streaming !== false,
contextWindowTokens: config.text.contextWindowTokens || 128_000,
maxOutputTokens: config.text.maxOutputTokens || 16_384,
}
: config.text,
image: config.image
? {
...config.image,
size: {
...config.image.size,
values: normalizeCapabilityStrings(config.image.size.values),
default: normalizeCapabilityString(config.image.size.default),
},
quality: {
...config.image.quality,
values: normalizeCapabilityStrings(config.image.quality.values),
default: normalizeCapabilityString(config.image.quality.default),
},
}
: undefined,
video: config.video
? resolveWorkflowVideoScreenSpec({
...config.video,
ratios: normalizeCapabilityStrings(config.video.ratios),
defaultRatio: normalizeCapabilityString(config.video.defaultRatio),
resolutions: normalizeCapabilityStrings(config.video.resolutions),
defaultResolution: normalizeCapabilityString(config.video.defaultResolution),
operations: normalizeCapabilityStrings(config.video.operations),
defaultOperation: normalizeCapabilityString(config.video.defaultOperation),
})
: undefined,
};
}
// Only these shipped protocol contracts prove that legacy quality values were resolution tiers.
// This is a read projection; persisted capability JSON is not migrated.
export function normalizeLegacyImageResolutionConfig(protocol: ModelProtocol | undefined, image: ImageCapabilityConfig): ImageCapabilityConfig {
if (image.parameters) return projectImageParameters(image, image.parameters);
image = { ...image, resolutionUsesQuality: protocol === "gemini-image" };
if (image.resolution.supported || !image.quality.supported) return image;
const allowed = protocol === "grok-image" ? new Set(["1k", "2k"]) : protocol === "agnes-image" ? new Set(["1k", "2k", "4k"]) : undefined;
if (!allowed) return image;
const values = image.quality.values.map((value) => value.trim().toLowerCase());
const defaultValue = image.quality.default.trim().toLowerCase();
if (!values.length || values.some((value) => !allowed.has(value)) || !allowed.has(defaultValue) || !values.includes(defaultValue)) return image;
return { ...image, resolution: { supported: true, values, default: defaultValue }, quality: { supported: false, values: [], default: "auto" } };
}
// Keep explicit pixel presets for each resolution tier so the settings panel can
// switch between 1K, 2K and 4K without silently converting the requested ratio.
const defaultImageSizes = [
"auto",
"1:1",
"3:2",
"2:3",
"4:3",
"3:4",
"16:9",
"21:9",
"9:16",
"1024x1024",
"1360x1024",
"1024x1360",
"1536x1024",
"1024x1536",
"1024x1280",
"1280x1024",
"2048x878",
"1824x1024",
"1024x1824",
"2048x2048",
"2304x1728",
"1728x2304",
"2496x1664",
"1664x2496",
"1792x2240",
"2240x1792",
"3136x1344",
"2752x1536",
"1536x2752",
"2880x2880",
"3264x2448",
"2448x3264",
"3504x2336",
"2336x3504",
"2560x3200",
"3200x2560",
"3808x1632",
"3840x2160",
"2160x3840",
];
export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = ""): ImageCapabilityConfig {
const image: ImageCapabilityConfig = {
references: { promptMaxChars: 32000, maxImages: 16, maxImageBytes: 30 * 1024 * 1024, maskSupported: true },
size: { parameter: "size", values: [...defaultImageSizes], default: "1:1", allowCustom: true },
resolution: { supported: false, values: [], default: "auto" },
quality: { supported: true, values: ["auto", "low", "medium", "high"], default: "auto" },
transparentBackground: { supported: true, default: false },
responseFormat: { supported: true },
outputFormat: { supported: true },
maxOutputs: 15,
};
if (protocol === "km-kemei-seedream") {
const presets = kemeiSeedreamPresets();
image.references = { ...image.references, maxImages: 10, maskSupported: false };
image.size = { parameter: "size", values: presets.map((preset) => preset.size), default: "2048x2048", allowCustom: true, presets };
image.quality = { supported: false, values: [], default: "auto" };
// 透明输出要求一张含 alpha 的参考图,宿主通用开关不能保证此条件。
// 此能力通过插件命名空间的 background + output_format 显式配置。
image.transparentBackground = { supported: false, default: false };
image.responseFormat = { supported: true };
image.outputFormat = { supported: true };
image.maxOutputs = 1;
return image;
}
if (protocol === "kacang-midjourney-special" || protocol === "kacang-midjourney-v7" || protocol === "kacang-midjourney") {
const stable = protocol === "kacang-midjourney";
const extendedRatios = stable || protocol === "kacang-midjourney-v7";
const ratios = ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "21:9", ...(extendedRatios ? ["9:21"] : [])];
// 卡藏未声明分辨率档。1K 预设只用于宿主比例选择,插件不发送 resolution。
image.references = { ...image.references, maxImages: stable ? 5 : 1, maskSupported: false };
image.size = { parameter: "aspect_ratio", values: [...(extendedRatios ? ["auto"] : []), ...ratios], default: stable ? "9:16" : "16:9", allowCustom: false, presets: ratios.map((ratio) => ({ ...imagePresetForRatio("1k", ratio), ratio })) };
// 稳定版的数值 quality 通过插件命名空间设置,避免与宿主分辨率档混用。
image.quality = { supported: false, values: [], default: "auto" };
image.transparentBackground = { supported: false, default: false };
image.responseFormat = { supported: false };
image.outputFormat = { supported: false };
image.maxOutputs = 1;
return image;
}
if (protocol === "cangyuan-midjourney-v7" || protocol === "cangyuan-midjourney-v82") {
const v7 = protocol === "cangyuan-midjourney-v7";
const tier =
!v7 &&
model
.trim()
.toLowerCase()
.replace(/^models\//, "") === "midjourney-2k"
? "2k"
: "1k";
const ratios = v7 ? ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "21:9"] : ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "1:2", "6:11", "5:6", "2:1", "11:6", "6:5"];
image.references = { ...image.references, promptMaxChars: v7 ? 4000 : 32000, maxImages: v7 ? 5 : 1, maskSupported: !v7 };
image.size = { parameter: "aspect_ratio", values: ["auto", ...ratios], default: "auto", allowCustom: false, presets: ratios.map((ratio) => imagePresetForRatio(tier, ratio)) };
image.quality = { supported: false, values: [], default: "auto" };
image.transparentBackground = { supported: false, default: false };
image.responseFormat = { supported: false };
image.outputFormat = { supported: false };
// n=1 是一次提交;供应商返回的四张图片仍全部消费。
image.maxOutputs = 1;
return image;
}
if (protocol === "grok-image") {
image.references.maxImages = 1;
image.references.maskSupported = false;
// grok2api / xAI Imagine:size→aspect_ratio,quality→resolution(1k/2k)。
image.size = {
parameter: "aspect_ratio",
values: ["1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2"],
default: "1:1",
allowCustom: false,
};
image.resolution = { supported: true, values: ["1k", "2k"], default: "2k" };
image.quality = { supported: false, values: [], default: "auto" };
image.transparentBackground = { supported: false, default: false };
image.responseFormat = { supported: true };
image.outputFormat = { supported: false };
image.maxOutputs = 1;
} else if (protocol === "volcengine-ark-image" || protocol === "volcengine-ark-agent-plan-image") {
image.references.maskSupported = false;
image.quality.supported = false;
image.transparentBackground.supported = false;
image.responseFormat.supported = false;
image.outputFormat.supported = false;
}
if (protocol === "openai-image") {
// OpenAI Images 只收 WIDTHxHEIGHT:默认配置声明了 1K/2K/4K,就必须自带档位换算表,
// 否则运行时换算会被静默跳过,实际出图档位与所选档位不一致。取值来自本地唯一那张比例表。
image.size.presets = IMAGE_RESOLUTIONS.flatMap((tier) => IMAGE_RATIOS.map((ratio) => imagePresetForRatio(tier, ratio)));
}
if (protocol === "volcengine-jimeng-image") {
image.references.maxImages = 14;
image.references.maskSupported = false;
image.quality.supported = false;
image.transparentBackground.supported = false;
image.responseFormat.supported = false;
image.outputFormat.supported = false;
}
if (protocol === "gemini-image") {
image.references.maskSupported = false;
// Gemini Images uses imageConfig.aspectRatio, not the OpenAI-style pixel size field.
image.size = {
parameter: "aspect_ratio",
values: ["auto", "1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"],
default: "1:1",
allowCustom: false,
};
image.transparentBackground = { supported: false, default: false };
image.responseFormat = { supported: false };
image.outputFormat = { supported: false };
image.maxOutputs = 4;
image.resolutionUsesQuality = true;
}
if (protocol === "agnes-image") {
// Agnes 图像:size 必填,取 1K/2K/3K/4K 档位或 WxH 精确尺寸,画面比例走独立的 ratio 字段;
// 参考图放 extra_body.image,支持多图合成,但没有蒙版端点。
image.references.maxImages = 9;
image.references.maskSupported = false;
image.size = {
parameter: "aspect_ratio",
values: ["1:1", "3:4", "4:3", "16:9", "9:16", "2:3", "3:2", "21:9"],
default: "1:1",
allowCustom: false,
};
// 官方档位是 1K/2K/3K/4K,统一层只提供 1k/2k/4k 三档;3K 保留在协议映射里但不在界面露出。
image.resolution = { supported: true, values: ["1k", "2k", "4k"], default: "2k" };
image.quality = { supported: false, values: [], default: "auto" };
image.transparentBackground = { supported: false, default: false };
// 顶层 response_format 是官方明确的错误写法,输出格式只能在 extra_body 内声明。
image.responseFormat = { supported: false };
image.outputFormat = { supported: false };
// 该端点不接受 n,单次请求固定返回一张图片。
image.maxOutputs = 1;
}
if (protocol !== "grok-image" && model.trim().toLowerCase().startsWith("grok-imagine-image")) {
image.references.maxImages = 0;
image.references.maskSupported = false;
image.size = {
parameter: "aspect_ratio",
values: ["1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2"],
default: "1:1",
allowCustom: false,
};
image.resolution = { supported: true, values: ["1k", "2k"], default: "2k" };
image.quality = { supported: false, values: [], default: "auto" };
image.transparentBackground = { supported: false, default: false };
image.responseFormat = { supported: true };
image.outputFormat = { supported: false };
image.maxOutputs = 1;
}
return image;
}
export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = ""): ModelCapabilityConfig {
const text: TextCapabilityConfig = {
streaming: true,
contextWindowTokens: 128_000,
maxOutputTokens: 16_384,
// 文本模型的视觉能力必须由管理员明确开启,不能根据模型名猜测。
references: { promptMaxChars: 32000, maxImages: 0, maxImageBytes: 0, maxVideos: 0, maxVideoBytes: 0 },
};
const video: VideoCapabilityConfig = {
references: {
promptMaxChars: DEFAULT_VIDEO_PROMPT_MAX_CHARS,
minImages: 0,
maxImages: 9,
maxImageBytes: 30 * 1024 * 1024,
maxVideos: 0,
maxVideoBytes: 0,
maxVideoDurationSeconds: 0,
maxAudios: 0,
maxAudioBytes: 0,
maxAudioDurationSeconds: 0,
},
duration: { selection: "range", min: 1, max: 15, step: 1, default: 6 },
ratios: ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"],
defaultRatio: "16:9",
resolutions: ["480p", "720p", "1080p", "1440p", "2160p"],
defaultResolution: "720p",
generateAudio: { supported: false, default: false },
watermark: { supported: false, default: false },
operations: ["text_to_video", "image_to_video"],
defaultOperation: "text_to_video",
};
if (protocol === "volcengine-jimeng-video") {
video.duration = { selection: "enum", values: [5, 10], default: 5 };
video.resolutions = ["720p"];
}
if (protocol === "gemini-veo") {
video.duration = { selection: "enum", values: [4, 6, 8], default: 6 };
video.resolutions = ["720p", "1080p"];
}
if (protocol === "volcengine-ark-video" || protocol === "volcengine-ark-agent-plan-video" || protocol === "newapi-channel-1" || protocol === "newapi-channel-2") {
video.references.maxVideos = 3;
video.references.maxAudios = 3;
video.references.maxVideoBytes = 200 * 1024 * 1024;
video.references.maxAudioBytes = 15 * 1024 * 1024;
video.references.maxVideoDurationSeconds = 15;
video.references.maxAudioDurationSeconds = 15;
video.generateAudio = { supported: true, default: true };
}
if (protocol === "volcengine-ark-video" || protocol === "volcengine-ark-agent-plan-video" || protocol === "newapi-channel-1") video.resolutions = ["480p", "720p", "1080p"];
if (protocol === "volcengine-ark-video" || protocol === "volcengine-ark-agent-plan-video") {
video.watermark = { supported: true, default: false };
video.operations.push("reference_to_video", "audio_to_video");
}
if (protocol === "novita-video") {
video.references.maxImages = 1;
video.references.maxImageBytes = 10 * 1024 * 1024;
video.duration = { selection: "enum", values: [5, 10], default: 5 };
video.ratios = ["16:9", "9:16", "1:1"];
video.resolutions = ["1080p"];
video.defaultResolution = "1080p";
}
if (protocol === "minimax-video") {
video.references.maxImages = 9;
video.references.maxImageBytes = 30 * 1024 * 1024;
video.references.maxVideos = 3;
video.references.maxVideoBytes = 50 * 1024 * 1024;
video.references.maxVideoDurationSeconds = 15;
video.references.maxAudios = 3;
video.references.maxAudioBytes = 15 * 1024 * 1024;
video.references.maxAudioDurationSeconds = 15;
video.duration = { selection: "enum", values: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], default: 5 };
video.ratios = ["adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"];
video.resolutions = ["768P", "2K"];
video.defaultResolution = "768P";
video.watermark = { supported: true, default: false };
video.operations.push("reference_to_video");
}
if (protocol === "agnes-video" && ["agnes-video-2.5", "agnes-video-2.5-flash"].includes(model.trim().toLowerCase())) {
const flash = model.trim().toLowerCase() === "agnes-video-2.5-flash";
video.references.maxImages = flash ? 5 : 9;
video.references.maxVideos = flash ? 0 : 3;
video.references.maxAudios = 3;
video.duration = { selection: "range", min: 4, max: 12, step: 1, default: 5 };
video.ratios = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"];
video.defaultRatio = "16:9";
video.resolutions = flash ? ["720P"] : ["720P", "960P", "2K"];
video.defaultResolution = "720P";
video.operations.push("reference_to_video", "audio_to_video");
}
return { version: 1, text, image: defaultImageCapabilityConfig(protocol, model), video };
}
export function pluginWorkflowCapabilityConfig(protocol: ModelProtocol, workflow: ModelProtocolWorkflow): ModelCapabilityConfig | undefined {
if (workflow.capability !== "image" && workflow.capability !== "video") return undefined;
const fallback = defaultModelCapabilityConfig(protocol, workflow.id);
const fields: WorkflowVideoFieldLike[] = workflow.parameters.map((parameter) => ({
fieldName: parameter.name,
source: parameter.mapping,
fieldType: parameter.type,
options: parameter.values,
defaultValue: workflow.defaults?.[parameter.name],
}));
if (workflow.capability === "image") {
return { ...fallback, image: workflowImageCapabilityConfig(fields, fallback.image!) };
}
return { ...fallback, video: workflowVideoCapabilityConfig(fields, fallback.video!) };
}
export function modelCapabilityConfigFor(config: { channels: Array<{ id: string; models: string[]; modelCosts?: Array<{ model: string; capabilityConfig?: ModelCapabilityConfig; protocol?: ModelProtocol }> }> }, model: string) {
const separator = model.indexOf("::");
const channelId = separator >= 0 ? model.slice(0, separator) : "";
const modelName = separator >= 0 ? model.slice(separator + 2) : model;
const channel = config.channels.find((item) => item.id === channelId) || config.channels.find((item) => item.models.includes(modelName));
const cost = channel?.modelCosts?.find((item) => item.model === modelName);
const fallback = defaultModelCapabilityConfig(cost?.protocol, modelName);
if (!cost?.capabilityConfig) return fallback;
const capabilityConfig = normalizeModelCapabilityConfig(cost.capabilityConfig);
const text = capabilityConfig.text ? { ...fallback.text!, ...capabilityConfig.text, references: { ...fallback.text!.references, ...capabilityConfig.text.references } } : fallback.text;
const video = capabilityConfig.video ? { ...fallback.video!, ...capabilityConfig.video, references: { ...fallback.video!.references, ...capabilityConfig.video.references } } : fallback.video;
const configuredImage = capabilityConfig.image;
const configuredImageProfile = configuredImage
? (() => {
const configuredSize = configuredImage.size;
const configuredValues = configuredSize?.values?.map(normalizeCapabilityString);
const allowCustom = Boolean(configuredSize?.allowCustom || configuredValues?.includes("*"));
const concreteValues = configuredValues?.filter((value) => value !== "*") || [];
const values = !configuredValues ? fallback.image!.size.values : concreteValues.length || !allowCustom ? concreteValues : [...STANDARD_IMAGE_SIZE_VALUES];
const configuredDefault = configuredSize?.default ? normalizeCapabilityString(configuredSize.default) : undefined;
const defaultValue = configuredDefault && configuredDefault !== "*" && values.includes(configuredDefault) ? configuredDefault : values.find((value) => value !== "*") || fallback.image!.size.default;
return {
...fallback.image!,
...configuredImage,
size: {
...fallback.image!.size,
...configuredSize,
values,
default: defaultValue,
allowCustom,
},
resolution: {
...fallback.image!.resolution,
...configuredImage.resolution,
values: normalizeCapabilityStrings(configuredImage.resolution?.values || []),
default: normalizeCapabilityString(configuredImage.resolution?.default || fallback.image!.resolution.default),
},
};
})()
: fallback.image;
const image = configuredImageProfile ? normalizeLegacyImageResolutionConfig(cost?.protocol, configuredImageProfile) : fallback.image;
return { ...fallback, ...capabilityConfig, text, image, video };
}
// 工作流字段是供应商参数的唯一事实来源;不能用普通视频模型的固定清晰度列表覆盖它。
export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; imageResolution?: string; imageParameters?: string; count?: string; transparentBackground?: string }, protocol?: ModelProtocol) {
if (profile.parameters) {
const values = imageParameterRequest(profile, value) || {};
return {
size: String(values.size ?? ""),
quality: String(values.quality ?? ""),
imageResolution: String(values.imageResolution ?? ""),
count: String(values.count ?? 1),
transparentBackground: String(values.transparentBackground ?? false),
imageParameters: JSON.stringify(normalizeImageParameters(profile.parameters, value.imageParameters)),
};
}
const size = normalizeImageSizeSetting(profile, value.size);
const requestedQuality = String(value.quality || "")
.trim()
.toLowerCase();
// 比例协议的固定分辨率预设没有独立 quality 字段时,UI 仍需把当前比例对应的
// 预设档位带入请求。仅在 quality 未声明支持时启用,避免与 auto/low/medium/high
// 这组真实图片质量语义混用。
const presetTier = !profile.quality.supported && !profile.resolution.supported ? imagePresetTierForSelection(profile, size) : undefined;
const quality = profile.quality.supported
? requestedQuality === "auto" || requestedQuality === "any"
? "auto"
: value.quality && profile.quality.values.includes(value.quality)
? value.quality
: profile.quality.default || "auto"
: profile.resolution.supported
? profile.quality.default || "auto"
: requestedQuality === "1k" || requestedQuality === "1.5k" || requestedQuality === "2k" || requestedQuality === "4k"
? requestedQuality
: presetTier || profile.quality.default || "auto";
const count = String(Math.max(1, Math.min(profile.maxOutputs, Math.floor(Math.abs(Number(value.count)) || 1))));
const transparentBackground = profile.transparentBackground.supported && value.transparentBackground === "true" ? "true" : "false";
const requestedResolution = String(value.imageResolution || "")
.trim()
.toLowerCase();
const configuredResolution = profile.resolution.values.find((item) => item.trim().toLowerCase() === requestedResolution);
const legacyResolutionProtocols = protocol === "grok-image" || protocol === "agnes-image";
const legacyResolution = legacyResolutionProtocols && !profile.quality.supported ? profile.resolution.values.find((item) => item.trim().toLowerCase() === requestedQuality) : undefined;
const imageResolution = profile.resolution.supported ? configuredResolution || legacyResolution || profile.resolution.default || "auto" : "auto";
return {
size,
quality: profile.parameters && !profile.quality.supported ? "auto" : quality,
...(profile.resolution.supported ? { imageResolution } : {}),
count,
transparentBackground,
...(profile.parameters ? { imageParameters: JSON.stringify(normalizeImageParameters(profile.parameters, value.imageParameters)) } : {}),
};
}
function imagePresetTierForSelection(profile: ImageCapabilityConfig, size: string): ImageResolutionTier | undefined {
if (profile.size.parameter !== "aspect_ratio" || !size || size === "auto") return undefined;
return profile.size.presets?.find((preset) => preset.ratio === size)?.tier;
}
export function normalizeImageSizeSetting(profile: ImageCapabilityConfig, value?: string) {
if (profile.size.parameter === "none") return "auto";
const candidate = value?.trim() || profile.size.default;
if (profile.size.allowCustom || profile.size.values.includes(candidate)) return candidate;
return profile.size.default || profile.size.values[0] || "auto";
}
export function imageSizeRequest(profile: ImageCapabilityConfig, value?: string) {
const parameter = profile.size.parameter;
if (parameter === "none") return undefined;
const normalized = normalizeImageSizeSetting(profile, value);
if (!normalized || normalized === "auto") return undefined;
return { parameter, value: normalized };
}
export function normalizeVideoValue(profile: VideoCapabilityConfig, value: { seconds?: string; ratio?: string; resolution?: string }) {
const duration = profile.duration.selection === "enum" ? ((profile.duration.values || []).includes(Number(value.seconds)) ? Number(value.seconds) : profile.duration.default) : normalizeRangeDuration(profile, Number(value.seconds));
const ratio = resolveVideoRatioValue(profile, value.ratio);
// 前端状态历史上保存过 `720`,而能力配置和供应商通常使用 `720p`;统一按能力中的原始值返回,避免被误判为不支持。
const resolution = resolveVideoResolutionValue(profile, value.resolution);
return { seconds: String(duration), ratio, resolution };
}
export function resolveVideoRatioValue(profile: VideoCapabilityConfig, value: string | undefined) {
return profile.ratios.includes(value || "") ? value! : profile.defaultRatio || profile.ratios[0] || "";
}
export function resolveVideoResolutionValue(profile: VideoCapabilityConfig, value: string | undefined) {
return videoResolutionRequest(profile, value) || profile.defaultResolution || profile.resolutions[0] || "";
}
export function videoResolutionRequest(profile: VideoCapabilityConfig, value: string | undefined) {
const requested = String(value || "")
.trim()
.toLowerCase();
if (!requested || requested === "auto" || requested === "default" || requested === "medium" || requested === "high") return undefined;
const candidates = [requested];
if (/^\d+$/.test(requested)) candidates.push(`${requested}p`);
if (requested === "low") candidates.push("480p");
if (requested === "2k") candidates.push("1440p");
if (requested === "1440" || requested === "1440p") candidates.push("2k");
if (requested === "4k") candidates.push("2160p");
if (requested === "2160" || requested === "2160p") candidates.push("4k");
const supported = new Map(profile.resolutions.map((resolution) => [resolution.trim().toLowerCase(), resolution.trim()]));
for (const candidate of candidates) {
const match = supported.get(candidate);
if (match) return match;
}
return undefined;
}
function normalizeRangeDuration(profile: VideoCapabilityConfig, value: number) {
const min = profile.duration.min || 1;
const max = profile.duration.max || min;
const step = profile.duration.step || 1;
const candidate = Number.isFinite(value) ? Math.floor(value) : profile.duration.default;
const clamped = Math.min(max, Math.max(min, candidate));
const maxStep = Math.max(0, Math.floor((max - min) / step));
return min + Math.min(maxStep, Math.max(0, Math.round((clamped - min) / step))) * step;
}
export function videoDurationOptions(profile: VideoCapabilityConfig) {
if (profile.duration.selection === "enum") return profile.duration.values || [];
const min = profile.duration.min || 1;
const max = profile.duration.max || min;
const step = profile.duration.step || 1;
return Array.from({ length: Math.floor((max - min) / step) + 1 }, (_, index) => min + index * step);
}
export function videoDurationAllowed(profile: VideoCapabilityConfig, value: number) {
if (profile.duration.selection === "enum") return (profile.duration.values || []).includes(value);
const min = profile.duration.min || 1;
const max = profile.duration.max || min;
const step = profile.duration.step || 1;
return value >= min && value <= max && (value - min) % step === 0;
}