feat(*): 生成工作台 - 新增项目画风配置与图片模型能力裁剪
This commit is contained in:
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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 }) {
|
||||
|
||||
Reference in new issue
Block a user