修复画布生成配方恢复与快照失真

保持背景音乐规范提示词与生成输入快照一致。

统一 V2 generationInputs 的项目、素材与完美像素账本水合,并保留 legacy 兼容。

为完美像素、裁扩、去背景和图集切片保存确定性配方并禁止改造。

由服务端按当前账号资源重建安全来源引用,修复布尔元数据显示。

补齐刷新恢复、数值布尔、无标签引用、来源溯源与确定性操作回归测试。
This commit is contained in:
2026-08-06 19:44:57 +08:00
parent 706fa3ca8c
commit e2fbef79e1
18 changed files with 968 additions and 256 deletions
@@ -0,0 +1,225 @@
import type {
CanvasGenerationAction,
CanvasGenerationInputField,
CanvasGenerationInputReference,
CanvasGenerationInputs,
} from './ImageCanvasEditorTypes';
export const CANVAS_GENERATION_ACTIONS = [
'image.generate',
'spec.generate',
'character.generate',
'icon.generate',
'ui-design.generate',
'publication.generate',
'video.generate',
'audio.sound-effect.generate',
'audio.background-music.generate',
'character-animation.generate',
'image.edit',
'ui-design.extract-assets',
'image.perfect-pixel',
'spritesheet.split',
'image.remove-background',
'image.crop-expand',
] as const satisfies readonly CanvasGenerationAction[];
export const REMIXABLE_CANVAS_GENERATION_ACTIONS =
new Set<CanvasGenerationAction>([
'image.generate',
'spec.generate',
'character.generate',
'icon.generate',
'ui-design.generate',
'publication.generate',
'video.generate',
'audio.sound-effect.generate',
'audio.background-music.generate',
'character-animation.generate',
'image.edit',
'ui-design.extract-assets',
]);
const CANVAS_GENERATION_ACTION_SET = new Set<string>(CANVAS_GENERATION_ACTIONS);
const V2_GENERATION_INPUT_KEYS = new Set([
'version',
'action',
'fields',
'references',
]);
const V2_GENERATION_FIELD_KEYS = new Set(['id', 'title', 'value']);
const V2_GENERATION_REFERENCE_KEYS = new Set([
'id',
'title',
'label',
'refType',
'refId',
]);
const LEGACY_GENERATION_INPUT_KEYS = new Set(['fields', 'references']);
const LEGACY_GENERATION_FIELD_KEYS = new Set(['title', 'value']);
const LEGACY_GENERATION_REFERENCE_KEYS = new Set([
'title',
'label',
'refType',
'refId',
]);
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function hasOnlyKeys(
value: Record<string, unknown>,
allowedKeys: ReadonlySet<string>,
) {
return Object.keys(value).every((key) => allowedKeys.has(key));
}
function isPresentString(value: unknown): value is string {
return typeof value === 'string' && Boolean(value.trim());
}
function isGenerationInputValue(
value: unknown,
): value is CanvasGenerationInputField['value'] {
return (
typeof value === 'string' ||
(typeof value === 'number' && Number.isFinite(value)) ||
typeof value === 'boolean'
);
}
export function isCanvasGenerationAction(
value: unknown,
): value is CanvasGenerationAction {
return typeof value === 'string' && CANVAS_GENERATION_ACTION_SET.has(value);
}
export function isNormalizedCanvasGenerationInputsStructure(
value: unknown,
): value is CanvasGenerationInputs & {
version: 2;
action: CanvasGenerationAction;
} {
if (
!isRecord(value) ||
value.version !== 2 ||
!isCanvasGenerationAction(value.action) ||
!Array.isArray(value.fields) ||
!Array.isArray(value.references)
) {
return false;
}
return (
value.fields.every(
(field) =>
isRecord(field) &&
hasOnlyKeys(field, V2_GENERATION_FIELD_KEYS) &&
isPresentString(field.id) &&
typeof field.title === 'string' &&
isGenerationInputValue(field.value),
) &&
value.references.every(
(reference) =>
isRecord(reference) &&
hasOnlyKeys(reference, V2_GENERATION_REFERENCE_KEYS) &&
isPresentString(reference.id) &&
typeof reference.title === 'string' &&
(reference.label === undefined ||
typeof reference.label === 'string') &&
(reference.refType === 'project-resource' ||
reference.refType === 'asset') &&
isPresentString(reference.refId),
)
);
}
function cloneV2GenerationInputs(
value: Record<string, unknown>,
strictWhitelist: boolean,
): CanvasGenerationInputs | null {
if (
(strictWhitelist && !hasOnlyKeys(value, V2_GENERATION_INPUT_KEYS)) ||
!isNormalizedCanvasGenerationInputsStructure(value)
) {
return null;
}
return {
version: 2,
action: value.action,
fields: value.fields.map((field) => ({ ...field })),
references: value.references.map((reference) => ({ ...reference })),
};
}
function hydrateLegacyGenerationInputs(
value: Record<string, unknown>,
strictWhitelist: boolean,
): CanvasGenerationInputs | null {
if (
strictWhitelist &&
(!hasOnlyKeys(value, LEGACY_GENERATION_INPUT_KEYS) ||
!Array.isArray(value.fields) ||
!Array.isArray(value.references))
) {
return null;
}
const rawFields = Array.isArray(value.fields) ? value.fields : [];
const rawReferences = Array.isArray(value.references) ? value.references : [];
const fields = rawFields.flatMap((field) => {
if (
!isRecord(field) ||
(strictWhitelist && !hasOnlyKeys(field, LEGACY_GENERATION_FIELD_KEYS)) ||
!isPresentString(field.title) ||
!isPresentString(field.value)
) {
return [];
}
return [{ title: field.title, value: field.value }];
});
const references = rawReferences.flatMap((reference) => {
if (
!isRecord(reference) ||
(strictWhitelist &&
!hasOnlyKeys(reference, LEGACY_GENERATION_REFERENCE_KEYS)) ||
!isPresentString(reference.title) ||
!isPresentString(reference.label) ||
(reference.refType !== 'project-resource' &&
reference.refType !== 'asset') ||
!isPresentString(reference.refId)
) {
return [];
}
return [
{
title: reference.title,
label: reference.label,
refType: reference.refType,
refId: reference.refId,
} satisfies CanvasGenerationInputReference,
];
});
if (
strictWhitelist &&
(fields.length !== rawFields.length ||
references.length !== rawReferences.length)
) {
return null;
}
return strictWhitelist || fields.length || references.length
? { fields, references }
: null;
}
export function hydrateCanvasGenerationInputs(
value: unknown,
options: { strictWhitelist?: boolean } = {},
): CanvasGenerationInputs | null {
if (!isRecord(value)) {
return null;
}
if ('version' in value || 'action' in value) {
return cloneV2GenerationInputs(value, options.strictWhitelist === true);
}
return hydrateLegacyGenerationInputs(value, options.strictWhitelist === true);
}