* feat(image): 图片参数 - 统一插件声明配置与生成合同 * docs(image): 图片参数 - 说明声明合同与验证边界 * fix(plugins): 图片声明 - 保留内联素材支持并同步制品文档 * fix(image): 参数合同 - 对齐审批意图和尺寸预检调用方 * fix(image): 调用链 - 保留公开意图并接通独立分辨率 * fix(image): 图片参数 - 补齐分辨率接线与目录价格投影
682 lines
34 KiB
TypeScript
682 lines
34 KiB
TypeScript
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;
|
||
}
|