Files
Genarrative/src/components/image-editor/ImageCanvasGenerationInputsModel.ts
T
lhk229 bcff8f1957
Project CI / Native shell tests (push) Successful in 13m23s
Project CI / Repository checks (push) Successful in 1m12s
Project CI / Frontend tests (push) Successful in 2m55s
Project CI / Backend tests (push) Successful in 4m0s
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
修复 SFX 音效信息页元数据整块丢失 (#153)
读边界的内联媒体脱敏在每层对象上无差别删除 null 值,把 SFX 自动时长 metadata 里
合法的 requestedDurationSeconds: null 一并删掉;客户端据此判定整份 soundEffect 非法,
音频信息页的「生成输入」退化成空,Task 也被截成 0 这类误导值。手动指定时长的音效不受
影响,图片路径的 generationInputs 没有可为 null 的字段,所以只有部分 SFX 可见。

- sanitize_editor_payload_media_value 改为返回「是否被抹除」,只删除本次抹掉的内联
  媒体,调用方原有的显式 null 保留;脱敏强度不变,内联媒体与保留字段仍连键消失
- SFX 自动时长的 requestedDurationSeconds 接受显式 null 与整个键缺失两种同义形态
- 音频信息页 Task 按图层 assetKind 判定,不再随 soundEffect 元数据缺失降级成截断值

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/153
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-08-09 13:47:16 +08:00

389 lines
12 KiB
TypeScript

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<CanvasGenerationAction>([
'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<string>(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<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'
);
}
function soundEffectGenerationMetadataOrNull(
value: unknown,
): NonNullable<CanvasGenerationInputs['soundEffect']> | 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<string, unknown>,
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<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 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;
}