Files
ttstd-infinite-canvas/web/src/lib/canvas/style-profile.ts

294 lines
16 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.
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, "title" | "description" | "tags" | "prompt"> & {
// 内置画风使用 id,持久化配置使用 presetId,两种来源在此统一归一化。
presetId?: string;
id?: string;
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 || source.id || "").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";
}