feat(*): 生成工作台 - 新增项目画风配置与图片模型能力裁剪

This commit is contained in:
ddCat committed 2026-08-07 11:46:23 +08:00
1 parent 2b0d56f8e3
commit 1973afc356
52 files changed
+3309 -361

No files matched your search

@@ -7,6 +7,7 @@ import { resourceIdFromStorageKey } from "@/services/api/resources";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
import { isSeedanceVideoConfig } from "@/lib/seedance-video";
import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities";
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
@@ -279,12 +280,14 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const storedModel = node?.metadata?.model;
const model = storedModel && configuredModelMatchesCapability(config, storedModel, mode) ? storedModel : defaultModel && configuredModelMatchesCapability(config, defaultModel, mode) ? defaultModel : fallbackModel;
const imageProfile = mode === "image" ? modelCapabilityConfigFor(config, model).image! : undefined;
const normalizedImage = imageProfile ? normalizeImageValue(imageProfile, { quality: node?.metadata?.quality || config.quality || defaultConfig.quality, size: node?.metadata?.size || config.size || defaultConfig.size, transparentBackground: node?.metadata?.transparentBackground || config.transparentBackground, count: String(node?.metadata?.count || config.canvasImageCount || config.count || defaultConfig.count) }) : undefined;
return {
...config,
model,
quality: node?.metadata?.quality || config.quality || defaultConfig.quality,
size: node?.metadata?.size || config.size || defaultConfig.size,
transparentBackground: (node?.metadata?.transparentBackground || config.transparentBackground) === "true" ? "true" : "false",
quality: normalizedImage?.quality || node?.metadata?.quality || config.quality || defaultConfig.quality,
size: normalizedImage?.size || node?.metadata?.size || config.size || defaultConfig.size,
transparentBackground: normalizedImage?.transparentBackground || ((node?.metadata?.transparentBackground || config.transparentBackground) === "true" ? "true" : "false"),
videoSeconds: normalizeVideoDuration(node?.metadata?.seconds || config.videoSeconds || defaultConfig.videoSeconds),
vquality: normalizeVideoResolution(node?.metadata?.vquality || config.vquality || defaultConfig.vquality),
videoGenerateAudio: node?.metadata?.generateAudio || config.videoGenerateAudio || defaultConfig.videoGenerateAudio,
@@ -293,7 +296,7 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
audioFormat: node?.metadata?.audioFormat || config.audioFormat || defaultConfig.audioFormat,
audioSpeed: node?.metadata?.audioSpeed || config.audioSpeed || defaultConfig.audioSpeed,
audioInstructions: node?.metadata?.audioInstructions || config.audioInstructions || defaultConfig.audioInstructions,
count: String(node?.metadata?.count || (mode === "image" ? config.canvasImageCount || config.count : config.count) || defaultConfig.count),
count: normalizedImage?.count || String(node?.metadata?.count || (mode === "image" ? config.canvasImageCount || config.count : config.count) || defaultConfig.count),
};
}
@@ -5,6 +5,7 @@ export type StoryboardGenerationContext = {
presetId: string;
title: string;
prompt: string;
profileJson?: string;
};
characters: Array<{
assetId: string;
@@ -30,6 +31,7 @@ export function resolveStoryboardGenerationContext(nodes: CanvasNodeData[]): Sto
presetId: stylePresetId,
title: styleNode.title.replace(/^(?:项目)?画风\s*[·::]?\s*/, "").trim() || styleNode.title,
prompt: stylePrompt,
profileJson: styleNode.metadata?.styleProfileJson,
},
characters: characterNodes.map((node) => ({
assetId: node.metadata!.characterAssetId!.trim(),
@@ -0,0 +1,37 @@
import { applyStyleExecutionPlan, createStyleProfileSnapshot, parseStyleProfile, resolveStyleExecutionPlan, serializeStyleProfile, type StyleExecutionPlan, type StyleProfileSnapshot } from "@/lib/canvas/style-profile";
import { resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import type { CanvasNodeData } from "@/types/canvas";
export type CanvasStyleExecutionRuntime = {
profile: StyleProfileSnapshot;
profileJson: string;
plan: StyleExecutionPlan;
prompt: string;
};
export function resolveCanvasStyleExecution(nodes: CanvasNodeData[], sourceNode: CanvasNodeData | undefined, prompt: string, config: AiConfig, mode: "image" | "video"): CanvasStyleExecutionRuntime | null {
const styleNode = nodes.find((node) => node.metadata?.workflowKind === "styleboard");
if (!styleNode || sourceNode?.metadata?.workflowKind === "styleboard") return null;
const profile = parseStyleProfile(styleNode.metadata?.styleProfileJson) || legacyStyleNodeProfile(styleNode);
if (!profile) return null;
const requestConfig = resolveModelRequestConfig(config, config.model);
const plan = resolveStyleExecutionPlan(profile, { mode, model: requestConfig.model, interfaceType: requestConfig.interfaceType || requestConfig.apiFormat });
if (plan.status === "blocked") throw new Error(`项目画风与当前模型不兼容:${plan.warnings.join(";")}`);
return { profile, profileJson: serializeStyleProfile(profile), plan, prompt: applyStyleExecutionPlan(prompt, plan) };
}
function legacyStyleNodeProfile(node: CanvasNodeData) {
const prompt = String(node.metadata?.content || node.metadata?.prompt || "").trim();
const presetId = String(node.metadata?.stylePresetId || "").trim();
if (!prompt || !presetId) return null;
return createStyleProfileSnapshot({
presetId,
title: node.title.replace(/^(?:项目)?画风\s*[·::]?\s*/, "").trim() || node.title,
description: node.metadata?.workflowDescription || "历史项目画风规范",
tags: [],
prompt,
assets: [],
source: "user",
revision: 1,
});
}
+21 -1
View File
@@ -1,3 +1,5 @@
import type { StyleProfileSnapshot } from "@/lib/canvas/style-profile";
export type ProjectStyleWorldId = "xianxia" | "urban" | "historical" | "suspense" | "science-fiction" | "pastoral";
export type ProjectStyleToneId = "epic" | "dark" | "light-comedy" | "romantic" | "healing";
export type ProjectStyleMediumId = "live-action" | "3d-anime" | "3d-cartoon" | "2d-guoman" | "ink";
@@ -19,6 +21,7 @@ export type CanvasStylePreset = {
prompt: string;
imageUrl: string;
selection?: ProjectStyleSelection;
profile?: StyleProfileSnapshot;
};
type StyleOption<T extends string> = {
@@ -251,7 +254,7 @@ export function compileCanvasStylePreset(selection: ProjectStyleSelection): Canv
const character = compatibleCharacters.find((item) => item.id === selection.character) || compatibleCharacters[0];
const resolvedSelection = { ...selection, character: character.id };
const title = `${world.label} · ${tone.label} · ${medium.label}`;
return {
const preset = {
id: styleSelectionId(resolvedSelection),
title,
category: `${world.label} / ${medium.label}`,
@@ -273,6 +276,23 @@ export function compileCanvasStylePreset(selection: ProjectStyleSelection): Canv
`【全局禁用】${world.forbidden};${tone.forbidden};${medium.forbidden};${character.forbidden}。`,
STYLE_SCOPE,
].join("\n"),
} satisfies CanvasStylePreset;
return { ...preset, profile: createBuiltinStyleProfile(preset) };
}
function createBuiltinStyleProfile(preset: Omit<CanvasStylePreset, "profile">): StyleProfileSnapshot {
return {
schemaVersion: 1,
presetId: preset.id,
title: preset.title,
description: preset.description,
tags: [...preset.tags],
prompt: preset.prompt,
selection: preset.selection ? { ...preset.selection } : undefined,
assets: [],
executionPolicy: "compatible-fallback",
source: "builtin",
revision: 1,
};
}
+263
View File
@@ -0,0 +1,263 @@
export type StyleAssetKind = "lora" | "template" | "reference" | "prompt";
export type StyleAssetStatus = "draft" | "validated" | "unavailable";
export type StyleAssetBinding = {
id: string;
kind: StyleAssetKind;
provider: string;
title: string;
enabled?: boolean;
sourceId?: string;
sourceUrl?: string;
model?: string;
version?: string;
baseModels?: string[];
triggerWords?: string[];
promptFragment?: string;
weight?: number;
parameters?: {
sampler?: string;
steps?: number;
cfg?: number;
size?: string;
};
referenceUrls?: string[];
referenceResourceIds?: string[];
status: StyleAssetStatus;
license?: {
commercial?: boolean;
note?: string;
source?: string;
capturedAt?: string;
};
};
export type StyleProfileSnapshot = {
schemaVersion: 1;
presetId: string;
title: string;
description: string;
tags: string[];
prompt: string;
negativePrompt?: string;
coverUrl?: string;
sourceProfileId?: string;
selection?: Record<string, string>;
assets: StyleAssetBinding[];
executionPolicy?: "compatible-fallback" | "strict-assets";
source: "builtin" | "user" | "external";
revision: number;
};
export type StyleProfileSource = Pick<StyleProfileSnapshot, "presetId" | "title" | "description" | "tags" | "prompt"> & {
negativePrompt?: string;
coverUrl?: string;
sourceProfileId?: string;
selection?: Record<string, string>;
assets?: StyleAssetBinding[];
executionPolicy?: StyleProfileSnapshot["executionPolicy"];
source?: StyleProfileSnapshot["source"];
revision?: number;
};
const MAX_PROFILE_BYTES = 256 * 1024;
export const MAX_STYLE_ASSETS = 20;
export type StyleExecutionContext = {
mode: "image" | "video";
model: string;
interfaceType?: string;
};
export type StyleExecutionAsset = {
assetId: string;
title: string;
kind: StyleAssetKind;
action: "prompt" | "native" | "reference" | "skip" | "block";
reason: string;
};
export type StyleExecutionPlan = {
schemaVersion: 1;
profilePresetId: string;
profileRevision: number;
mode: StyleExecutionContext["mode"];
model: string;
interfaceType?: string;
status: "ready" | "degraded" | "blocked";
prompt: string;
assets: StyleExecutionAsset[];
warnings: string[];
};
export function createStyleProfileSnapshot(source: StyleProfileSource): StyleProfileSnapshot {
return {
schemaVersion: 1,
presetId: source.presetId.trim(),
title: source.title.trim(),
description: source.description.trim(),
tags: source.tags.map((tag) => tag.trim()).filter(Boolean),
prompt: source.prompt.trim(),
negativePrompt: source.negativePrompt?.trim() || undefined,
coverUrl: source.coverUrl?.trim() || undefined,
sourceProfileId: source.sourceProfileId?.trim() || undefined,
selection: source.selection ? { ...source.selection } : undefined,
assets: source.assets ? source.assets.map((asset) => ({
...asset,
parameters: asset.parameters ? { ...asset.parameters } : undefined,
license: asset.license ? { ...asset.license } : undefined,
baseModels: asset.baseModels ? [...asset.baseModels] : undefined,
triggerWords: asset.triggerWords ? [...asset.triggerWords] : undefined,
referenceUrls: asset.referenceUrls ? [...asset.referenceUrls] : undefined,
referenceResourceIds: asset.referenceResourceIds ? [...asset.referenceResourceIds] : undefined,
})) : [],
executionPolicy: source.executionPolicy || "compatible-fallback",
source: source.source || "builtin",
revision: source.revision || 1,
};
}
export function serializeStyleProfile(profile: StyleProfileSnapshot): string {
const normalized = createStyleProfileSnapshot(profile);
const validationMessage = styleProfileValidationMessage(normalized);
if (validationMessage) throw new Error(validationMessage);
const serialized = JSON.stringify(normalized);
if (new TextEncoder().encode(serialized).length > MAX_PROFILE_BYTES) throw new Error("项目画风资产配置过大");
return serialized;
}
export function parseStyleProfile(value?: string | null): StyleProfileSnapshot | null {
const raw = String(value || "").trim();
if (!raw || new TextEncoder().encode(raw).length > MAX_PROFILE_BYTES) return null;
try {
const parsed = JSON.parse(raw) as Partial<StyleProfileSnapshot>;
if (parsed.schemaVersion !== 1 || typeof parsed.presetId !== "string" || typeof parsed.title !== "string" || typeof parsed.description !== "string" || typeof parsed.prompt !== "string" || !Array.isArray(parsed.tags) || !Array.isArray(parsed.assets)) return null;
if (!parsed.tags.every((tag) => typeof tag === "string")) return null;
if ([parsed.negativePrompt, parsed.coverUrl, parsed.sourceProfileId].some((value) => value !== undefined && typeof value !== "string")) return null;
if (parsed.selection !== undefined && (!parsed.selection || typeof parsed.selection !== "object" || Array.isArray(parsed.selection) || !Object.values(parsed.selection).every((value) => typeof value === "string"))) return null;
if (!parsed.assets.every(isStyleAssetBinding)) return null;
if (parsed.source !== "builtin" && parsed.source !== "user" && parsed.source !== "external") return null;
if (parsed.executionPolicy !== undefined && parsed.executionPolicy !== "compatible-fallback" && parsed.executionPolicy !== "strict-assets") return null;
if (!Number.isInteger(parsed.revision) || Number(parsed.revision) < 1) return null;
const profile = createStyleProfileSnapshot(parsed as StyleProfileSource);
return styleProfileValidationMessage(profile) ? null : profile;
} catch {
return null;
}
}
export function resolveStyleProfile(presetId: string | undefined, profileJson?: string | null, fallback?: StyleProfileSource): StyleProfileSnapshot | null {
return parseStyleProfile(profileJson) || (fallback ? createStyleProfileSnapshot({ ...fallback, presetId: fallback.presetId || String(presetId || "") }) : null);
}
export function resolveStyleExecutionPlan(profile: StyleProfileSnapshot, context: StyleExecutionContext): StyleExecutionPlan {
const assets = profile.assets.filter((asset) => asset.enabled !== false).map((asset) => resolveAssetExecution(asset, context));
const blockedAssets = assets.filter((asset) => asset.action === "block");
const skippedAssets = assets.filter((asset) => asset.action === "skip");
const promptAssetIds = new Set(assets.filter((asset) => asset.action === "prompt").map((asset) => asset.assetId));
const promptFragments = profile.assets
.filter((asset) => promptAssetIds.has(asset.id))
.flatMap((asset) => [asset.promptFragment, ...(asset.triggerWords || [])])
.map((value) => String(value || "").trim())
.filter(Boolean);
const strictBlocked = profile.executionPolicy === "strict-assets" && (blockedAssets.length > 0 || skippedAssets.length > 0);
const warnings = [...blockedAssets, ...skippedAssets].map((asset) => `${asset.title}:${asset.reason}`);
return {
schemaVersion: 1,
profilePresetId: profile.presetId,
profileRevision: profile.revision,
mode: context.mode,
model: context.model,
interfaceType: context.interfaceType,
status: strictBlocked ? "blocked" : warnings.length ? "degraded" : "ready",
prompt: [profile.prompt, profile.negativePrompt ? `【全局负面 Prompt】\n${profile.negativePrompt}` : "", ...promptFragments].filter(Boolean).join("\n"),
assets,
warnings,
};
}
export function applyStyleExecutionPlan(prompt: string, plan: StyleExecutionPlan) {
const content = prompt.trim();
const stylePrompt = plan.prompt.trim();
if (!stylePrompt || content.includes(stylePrompt)) return content;
return [content, "【项目画风执行规范】", stylePrompt].filter(Boolean).join("\n\n");
}
export function styleAssetValidationMessage(asset: StyleAssetBinding) {
if (!asset.id.trim()) return "资产 ID 不能为空";
if (!asset.title.trim()) return "请填写资产名称";
if (!asset.provider.trim()) return "请填写来源平台或适配器";
if (asset.weight !== undefined && (!Number.isFinite(asset.weight) || asset.weight < 0 || asset.weight > 2)) return "LoRA 权重必须在 0 到 2 之间";
if ((asset.baseModels?.length || 0) > 20) return "单个资产最多声明 20 个兼容模型";
if ((asset.triggerWords?.length || 0) > 50) return "单个资产最多声明 50 个触发词";
if ((asset.referenceUrls?.length || 0) > 20 || (asset.referenceResourceIds?.length || 0) > 20) return "单个参考图组最多绑定 20 个 URL 和 20 个资源";
if (asset.parameters?.steps !== undefined && (!Number.isInteger(asset.parameters.steps) || asset.parameters.steps < 1 || asset.parameters.steps > 200)) return "Steps 必须在 1 到 200 之间";
if (asset.parameters?.cfg !== undefined && (!Number.isFinite(asset.parameters.cfg) || asset.parameters.cfg < 0 || asset.parameters.cfg > 50)) return "CFG 必须在 0 到 50 之间";
if (asset.status !== "validated") return "";
if (asset.kind === "lora" && !asset.sourceId?.trim() && !asset.sourceUrl?.trim()) return "已验证的 LoRA 必须填写来源资产 ID 或 URL";
if ((asset.kind === "prompt" || asset.kind === "template") && !asset.promptFragment?.trim() && !asset.triggerWords?.some((word) => word.trim())) return "已验证的模板或提示词模块必须填写提示词内容";
if (asset.kind === "reference" && !asset.referenceUrls?.some((url) => url.trim()) && !asset.referenceResourceIds?.some((id) => id.trim())) return "已验证的参考图组必须绑定参考图";
return "";
}
export function styleProfileValidationMessage(profile: StyleProfileSnapshot) {
if (!profile.presetId.trim() || profile.revision < 1) return "项目画风快照缺少必要字段";
if (!profile.title.trim()) return "请填写风格名称";
if (!profile.prompt.trim()) return "请填写完整风格 Prompt";
if (profile.title.length > 80) return "风格名称最多 80 个字";
if (profile.description.length > 500) return "风格简介最多 500 个字";
if (profile.tags.length > 20) return "风格标签最多 20 个";
if (profile.selection && Object.keys(profile.selection).length > 20) return "项目画风辅助标注最多 20 项";
if (profile.selection && Object.entries(profile.selection).some(([key, value]) => !key.trim() || key.length > 64 || value.length > 200)) return "项目画风辅助标注格式无效";
if ((profile.coverUrl?.length || 0) > 4096) return "风格封面地址过长";
if (profile.coverUrl && !isStyleCoverUrl(profile.coverUrl)) return "风格封面只支持 http(s)、站内路径或图片 Data URL";
if ((profile.negativePrompt?.length || 0) > 64 * 1024) return "负面 Prompt 过大";
if (profile.assets.length > MAX_STYLE_ASSETS) return `项目画风最多绑定 ${MAX_STYLE_ASSETS} 个执行资产`;
const ids = new Set<string>();
for (const asset of profile.assets) {
const assetMessage = styleAssetValidationMessage(asset);
if (assetMessage) return `${asset.title || "未命名资产"}:${assetMessage}`;
if (ids.has(asset.id)) return "项目画风执行资产 ID 不能重复";
ids.add(asset.id);
}
return "";
}
function isStyleCoverUrl(value: string) {
const normalized = value.trim().toLowerCase();
return normalized.startsWith("http://") || normalized.startsWith("https://") || normalized.startsWith("/") || normalized.startsWith("data:image/");
}
function resolveAssetExecution(asset: StyleAssetBinding, context: StyleExecutionContext): StyleExecutionAsset {
if (asset.status !== "validated") return { assetId: asset.id, title: asset.title, kind: asset.kind, action: "skip", reason: asset.status === "unavailable" ? "资产当前不可用" : "资产尚未验证" };
if (asset.baseModels?.length && !asset.baseModels.some((model) => model.trim().toLowerCase() === context.model.trim().toLowerCase())) {
return { assetId: asset.id, title: asset.title, kind: asset.kind, action: "block", reason: `仅兼容 ${asset.baseModels.join("、")}` };
}
if (asset.kind === "prompt") return { assetId: asset.id, title: asset.title, kind: asset.kind, action: "prompt", reason: "追加到项目画风提示词" };
if (asset.kind === "template" && (asset.promptFragment?.trim() || asset.triggerWords?.length)) return { assetId: asset.id, title: asset.title, kind: asset.kind, action: "prompt", reason: "当前以模板触发词执行" };
if (asset.kind === "reference") return { assetId: asset.id, title: asset.title, kind: asset.kind, action: "block", reason: "项目参考图自动注入适配器尚未启用" };
if (asset.kind === "lora") return { assetId: asset.id, title: asset.title, kind: asset.kind, action: "block", reason: `当前 ${context.interfaceType || "图片"} 协议未启用 LoRA 适配器` };
return { assetId: asset.id, title: asset.title, kind: asset.kind, action: "block", reason: "当前协议不支持该执行资产" };
}
function isStyleAssetBinding(value: unknown): value is StyleAssetBinding {
if (!value || typeof value !== "object") return false;
const asset = value as Partial<StyleAssetBinding>;
if (typeof asset.id !== "string" || typeof asset.kind !== "string" || !["lora", "template", "reference", "prompt"].includes(asset.kind) || typeof asset.provider !== "string" || typeof asset.title !== "string" || !["draft", "validated", "unavailable"].includes(String(asset.status))) return false;
if (asset.enabled !== undefined && typeof asset.enabled !== "boolean") return false;
if (asset.weight !== undefined && (!Number.isFinite(asset.weight) || asset.weight < 0 || asset.weight > 2)) return false;
if (![asset.sourceId, asset.sourceUrl, asset.model, asset.version, asset.promptFragment].every(isOptionalString)) return false;
if (![asset.baseModels, asset.triggerWords, asset.referenceUrls, asset.referenceResourceIds].every((values) => values === undefined || (Array.isArray(values) && values.every((item) => typeof item === "string")))) return false;
if (asset.parameters !== undefined && (!asset.parameters || typeof asset.parameters !== "object"
|| !isOptionalString(asset.parameters.sampler) || !isOptionalString(asset.parameters.size)
|| (asset.parameters.steps !== undefined && !Number.isInteger(asset.parameters.steps))
|| (asset.parameters.cfg !== undefined && !Number.isFinite(asset.parameters.cfg)))) return false;
if (asset.license !== undefined && (!asset.license || typeof asset.license !== "object"
|| (asset.license.commercial !== undefined && typeof asset.license.commercial !== "boolean")
|| ![asset.license.note, asset.license.source, asset.license.capturedAt].every(isOptionalString))) return false;
return !styleAssetValidationMessage(asset as StyleAssetBinding);
}
function isOptionalString(value: unknown) {
return value === undefined || typeof value === "string";
}
+96 -3
View File
@@ -2,9 +2,36 @@ import type { ModelProtocol } from "@/lib/model-protocols";
export type ModelCapabilityConfig = {
version: number;
image?: ImageCapabilityConfig;
video?: VideoCapabilityConfig;
};
export type ImageSizeParameter = "none" | "size" | "aspect_ratio";
export type ImageCapabilityConfig = {
references: {
promptMaxChars: number;
maxImages: number;
maxImageBytes: number;
maskSupported: boolean;
};
size: {
parameter: ImageSizeParameter;
values: string[];
default: string;
allowCustom: boolean;
};
quality: {
supported: boolean;
values: string[];
default: string;
};
transparentBackground: { supported: boolean; default: boolean };
responseFormat: { supported: boolean };
outputFormat: { supported: boolean };
maxOutputs: number;
};
export type VideoCapabilityConfig = {
references: {
promptMaxChars: number;
@@ -35,7 +62,47 @@ export type VideoCapabilityConfig = {
defaultOperation: string;
};
export function defaultModelCapabilityConfig(protocol?: ModelProtocol): ModelCapabilityConfig {
const defaultImageSizes = ["1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "21:9", "9:16", "2048x2048", "2048x1152", "1152x2048", "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 },
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 === "volcengine-ark-image") {
image.references.maskSupported = false;
image.quality.supported = false;
image.transparentBackground.supported = false;
image.responseFormat.supported = false;
image.outputFormat.supported = false;
}
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 (model.trim().toLowerCase().startsWith("grok-imagine-image")) {
image.references.maxImages = 0;
image.references.maskSupported = false;
image.size = { parameter: "none", values: [], default: "auto", allowCustom: false };
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 video: VideoCapabilityConfig = {
references: {
promptMaxChars: 1000,
@@ -73,7 +140,7 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol): ModelCap
video.generateAudio = { supported: true, default: true };
}
if (protocol === "volcengine-ark-video") video.watermark = { supported: true, default: false };
return { version: 1, video };
return { version: 1, image: defaultImageCapabilityConfig(protocol, model), video };
}
export function modelCapabilityConfigFor(config: { channels: Array<{ id: string; models: string[]; modelCosts?: Array<{ model: string; capabilityConfig?: ModelCapabilityConfig; protocol?: ModelProtocol }> }> }, model: string) {
@@ -82,7 +149,33 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string;
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);
return cost?.capabilityConfig || defaultModelCapabilityConfig(cost?.protocol);
const fallback = defaultModelCapabilityConfig(cost?.protocol, modelName);
return cost?.capabilityConfig
? { ...fallback, ...cost.capabilityConfig, image: cost.capabilityConfig.image || fallback.image, video: cost.capabilityConfig.video || fallback.video }
: fallback;
}
export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; count?: string; transparentBackground?: string }) {
const size = normalizeImageSizeSetting(profile, value.size);
const quality = profile.quality.supported && profile.quality.values.includes(value.quality || "") ? value.quality! : 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";
return { size, quality, count, transparentBackground };
}
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 }) {