import { EDITOR_SOUND_EFFECT_MODEL, SOUND_EFFECT_DURATION_MAX_SECONDS, SOUND_EFFECT_DURATION_MIN_SECONDS, } from '../../../packages/shared/src/contracts/editorAudio'; import type { CanvasGenerationAction, CanvasGenerationInputField, CanvasGenerationInputReference, CanvasGenerationInputs, CanvasGenerationInputsHydrationState, } from './ImageCanvasEditorTypes'; import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel'; export const CANVAS_GENERATION_ACTIONS = [ 'image.generate', 'scene.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[]; const REMIXABLE_CANVAS_GENERATION_ACTIONS = new Set([ 'image.generate', 'scene.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', 'ui-design.extract-assets', ]); // 中文注释:image.edit 是图片快速编辑的原位替换记录,结果层就是被覆盖后的原层; // 其 source 指向替换前的编辑目标,不能再作为“改造”新产物可靠恢复。继续修改应重新 // 使用“快速编辑”,所以 image.edit 只属于已知 action,不属于可改造 action。 const CANVAS_GENERATION_ACTION_SET = new Set(CANVAS_GENERATION_ACTIONS); const V2_GENERATION_INPUT_KEYS = new Set([ 'version', 'action', 'fields', 'references', 'soundEffect', ]); 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', 'soundEffect', ]); 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 { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function hasOnlyKeys( value: Record, allowedKeys: ReadonlySet, ) { 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' ); } function soundEffectGenerationMetadataOrNull( value: unknown, ): NonNullable | null { if (!isRecord(value)) { return null; } const userPrompt = typeof value.userPrompt === 'string' ? value.userPrompt : ''; const actualPrompt = typeof value.actualPrompt === 'string' ? value.actualPrompt : ''; const userPromptValidation = validateSoundEffectPrompt(userPrompt); const actualPromptValidation = validateSoundEffectPrompt(actualPrompt); if ( value.schemaVersion !== 2 || value.model !== EDITOR_SOUND_EFFECT_MODEL || (value.durationMode !== 'auto' && value.durationMode !== 'manual') || typeof value.loop !== 'boolean' || !userPromptValidation.ok || userPromptValidation.prompt !== userPrompt || !actualPromptValidation.ok || actualPromptValidation.prompt !== actualPrompt || typeof value.actualDurationSeconds !== 'number' || !Number.isFinite(value.actualDurationSeconds) || value.actualDurationSeconds <= 0 || value.actualDurationSeconds > 600 ) { return null; } let requestedDurationSeconds: number | null; if (value.durationMode === 'auto') { // 中文注释:自动时长的契约值是「没有请求时长」,显式 null 与整个键缺失同义,两种形态都要 // 接受。服务端读边界的内联媒体脱敏曾把这个 null 连键删掉,只认 null 会让整份 soundEffect // 元数据被判非法,音频信息页连带 fields 一起退化成空。 if ( value.requestedDurationSeconds !== null && value.requestedDurationSeconds !== undefined ) { return null; } requestedDurationSeconds = null; } else { if ( typeof value.requestedDurationSeconds !== 'number' || !Number.isFinite(value.requestedDurationSeconds) || value.requestedDurationSeconds < SOUND_EFFECT_DURATION_MIN_SECONDS || value.requestedDurationSeconds > SOUND_EFFECT_DURATION_MAX_SECONDS ) { return null; } requestedDurationSeconds = value.requestedDurationSeconds; } return { schemaVersion: 2, userPrompt, actualPrompt, model: EDITOR_SOUND_EFFECT_MODEL, durationMode: value.durationMode, requestedDurationSeconds, actualDurationSeconds: value.actualDurationSeconds, loop: value.loop, }; } export function isCanvasGenerationAction( value: unknown, ): value is CanvasGenerationAction { return typeof value === 'string' && CANVAS_GENERATION_ACTION_SET.has(value); } function hasNormalizedCanvasGenerationInputsStructure( value: unknown, strictWhitelist: boolean, ): 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 ( (!Object.prototype.hasOwnProperty.call(value, 'soundEffect') || soundEffectGenerationMetadataOrNull(value.soundEffect) !== null) && value.fields.every( (field) => isRecord(field) && (!strictWhitelist || hasOnlyKeys(field, V2_GENERATION_FIELD_KEYS)) && isPresentString(field.id) && typeof field.title === 'string' && isGenerationInputValue(field.value), ) && value.references.every( (reference) => isRecord(reference) && (!strictWhitelist || 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), ) ); } export function isNormalizedCanvasGenerationInputsStructure( value: unknown, ): value is CanvasGenerationInputs & { version: 2; action: CanvasGenerationAction; } { return hasNormalizedCanvasGenerationInputsStructure(value, true); } export function isRemixableCanvasGenerationAction( action: CanvasGenerationAction, ) { return REMIXABLE_CANVAS_GENERATION_ACTIONS.has(action); } function cloneV2GenerationInputs( value: Record, strictWhitelist: boolean, ): CanvasGenerationInputs | null { if ( (strictWhitelist && !hasOnlyKeys(value, V2_GENERATION_INPUT_KEYS)) || !hasNormalizedCanvasGenerationInputsStructure(value, strictWhitelist) ) { return null; } const hasSoundEffect = Object.prototype.hasOwnProperty.call( value, 'soundEffect', ); const soundEffect = hasSoundEffect ? soundEffectGenerationMetadataOrNull(value.soundEffect) : null; if (hasSoundEffect && !soundEffect) { return null; } return { version: 2, action: value.action, fields: value.fields.map((field) => ({ id: field.id, title: field.title, value: field.value, })), references: value.references.map((reference) => ({ id: reference.id, title: reference.title, ...(reference.label === undefined ? {} : { label: reference.label }), refType: reference.refType, refId: reference.refId, })), ...(soundEffect ? { soundEffect } : {}), }; } function hydrateLegacyGenerationInputs( value: Record, 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 hasSoundEffect = Object.prototype.hasOwnProperty.call( value, 'soundEffect', ); const soundEffect = hasSoundEffect ? soundEffectGenerationMetadataOrNull(value.soundEffect) : null; if (hasSoundEffect && !soundEffect) { return null; } const fields = rawFields.flatMap((field) => { if ( !isRecord(field) || (strictWhitelist && !hasOnlyKeys(field, LEGACY_GENERATION_FIELD_KEYS)) || (strictWhitelist ? typeof field.title !== 'string' : !isPresentString(field.title)) || (strictWhitelist ? typeof field.value !== 'string' : !isPresentString(field.value)) ) { return []; } return [{ title: field.title as string, value: field.value as string }]; }); const references = rawReferences.flatMap((reference) => { if ( !isRecord(reference) || (strictWhitelist && !hasOnlyKeys(reference, LEGACY_GENERATION_REFERENCE_KEYS)) || (strictWhitelist ? typeof reference.title !== 'string' : !isPresentString(reference.title)) || (strictWhitelist ? typeof reference.label !== 'string' : !isPresentString(reference.label)) || (reference.refType !== 'project-resource' && reference.refType !== 'asset') || (strictWhitelist ? typeof reference.refId !== 'string' : !isPresentString(reference.refId)) ) { return []; } return [ { title: reference.title as string, label: reference.label as string, refType: reference.refType, refId: reference.refId as string, } satisfies CanvasGenerationInputReference, ]; }); if ( strictWhitelist && (fields.length !== rawFields.length || references.length !== rawReferences.length) ) { return null; } return strictWhitelist || fields.length || references.length || soundEffect ? { fields, references, ...(soundEffect ? { soundEffect } : {}) } : null; } export type CanvasGenerationInputsHydrationResult = { state: CanvasGenerationInputsHydrationState; inputs: CanvasGenerationInputs | null; }; export function hydrateCanvasGenerationInputsResult( value: unknown, options: { strictWhitelist?: boolean } = {}, ): CanvasGenerationInputsHydrationResult { if (!isRecord(value)) { return { state: 'absent', inputs: null }; } if ('version' in value || 'action' in value) { const inputs = cloneV2GenerationInputs( value, options.strictWhitelist === true, ); return inputs ? { state: 'valid', inputs } : { state: 'invalid-versioned', inputs: null }; } const inputs = hydrateLegacyGenerationInputs( value, options.strictWhitelist === true, ); return inputs ? { state: 'valid', inputs } : { state: 'absent', inputs: null }; } export function hydrateCanvasGenerationInputs( value: unknown, options: { strictWhitelist?: boolean } = {}, ): CanvasGenerationInputs | null { return hydrateCanvasGenerationInputsResult(value, options).inputs; }