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
读边界的内联媒体脱敏在每层对象上无差别删除 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>
389 lines
12 KiB
TypeScript
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;
|
|
}
|