合并最新主分支

合并 origin/master 的 SFX 音效信息页元数据修复。
This commit is contained in:
2026-08-09 16:06:30 +08:00
5 changed files with 319 additions and 13 deletions
@@ -1218,6 +1218,35 @@ describe('ImageCanvasEditorModel', () => {
generationInputsOrNull({ fields: [], references: [], soundEffect }),
).toEqual({ fields: [], references: [], soundEffect });
// 中文注释:自动时长的 requestedDurationSeconds 是「没有请求时长」,显式 null 与整个键
// 缺失同义。服务端读边界的内联媒体脱敏曾把这个 null 连键删掉,只认 null 会让整份元数据
// 被判非法,音频信息页连 fields 一起空掉。
const autoSoundEffect = {
...soundEffect,
durationMode: 'auto' as const,
requestedDurationSeconds: null,
};
const autoSoundEffectWithoutRequestedDuration: Record<string, unknown> = {
...autoSoundEffect,
};
delete autoSoundEffectWithoutRequestedDuration.requestedDurationSeconds;
for (const validAutoSoundEffect of [
autoSoundEffect,
autoSoundEffectWithoutRequestedDuration,
]) {
expect(
generationInputsOrNull({
fields: [{ title: '用户描述', value: '金币落地' }],
references: [],
soundEffect: validAutoSoundEffect,
}),
).toEqual({
fields: [{ title: '用户描述', value: '金币落地' }],
references: [],
soundEffect: autoSoundEffect,
});
}
for (const invalidSoundEffect of [
{ ...soundEffect, schemaVersion: 1 },
{ ...soundEffect, model: 'audio1.0' },
@@ -135,7 +135,13 @@ function soundEffectGenerationMetadataOrNull(
}
let requestedDurationSeconds: number | null;
if (value.durationMode === 'auto') {
if (value.requestedDurationSeconds !== null) {
// 中文注释:自动时长的契约值是「没有请求时长」,显式 null 与整个键缺失同义,两种形态都要
// 接受。服务端读边界的内联媒体脱敏曾把这个 null 连键删掉,只认 null 会让整份 soundEffect
// 元数据被判非法,音频信息页连带 fields 一起退化成空。
if (
value.requestedDurationSeconds !== null &&
value.requestedDurationSeconds !== undefined
) {
return null;
}
requestedDurationSeconds = null;
@@ -352,6 +352,125 @@ describe('ImageCanvasMetadataModalView', () => {
}
});
it('keeps the full sound effect task id when SFX metadata is unavailable', () => {
render(
<ImageCanvasMetadataModalView
layer={createLayer({
title: '金币音效',
src: '/generated-character-drafts/editor-audios/coin.mp3',
mediaType: 'audio',
assetKind: 'sound-effect',
originalWidth: 420,
originalHeight: 120,
model: 'eleven_text_to_sound_v2',
taskId: 'task-sfx-v2-1234-abcd',
generationInputs: {
fields: [{ title: '用户描述', value: '金币落地' }],
references: [],
},
})}
onClose={vi.fn()}
/>,
);
// 中文注释:音效 taskId 是 operation id 形态,「取最后一段数字」会截出 1234 这种误导值。
// Task 这一栏不能因为 soundEffect 元数据缺失就降级成假值。
const dialog = screen.getByRole('dialog', { name: '音频信息' });
expect(within(dialog).getByText('task-sfx-v2-1234-abcd')).toBeTruthy();
expect(within(dialog).queryByText('1234')).toBeNull();
});
it('keeps the task id display tied to the generating model across asset tag overrides', () => {
// 中文注释:展示口径由「谁生成了这个 taskId」决定,也就是 model。素材标签只改
// assetKindOverride,不该影响它:背景音乐被改标成「音效」不能换用完整 id,音效被改标成
// 「背景音乐」更不能退回截断假值。
const { unmount } = render(
<ImageCanvasMetadataModalView
layer={createLayer({
title: '游戏背景音乐',
src: '/generated-character-drafts/editor-audios/music.wav',
mediaType: 'audio',
resourceAssetKind: 'background-music',
assetKindOverride: 'sound-effect',
assetKind: 'sound-effect',
originalWidth: 420,
originalHeight: 120,
model: 'suno',
taskId: 'audio-task-75',
generationInputs: {
fields: [{ title: '音乐描述', value: '紧张的地下城循环音乐' }],
references: [],
},
})}
onClose={vi.fn()}
/>,
);
const musicDialog = screen.getByRole('dialog', { name: '音频信息' });
expect(within(musicDialog).getByText('75')).toBeTruthy();
expect(within(musicDialog).queryByText('audio-task-75')).toBeNull();
unmount();
render(
<ImageCanvasMetadataModalView
layer={createLayer({
title: '金币音效',
src: '/generated-character-drafts/editor-audios/coin.mp3',
mediaType: 'audio',
resourceAssetKind: 'sound-effect',
assetKindOverride: 'background-music',
assetKind: 'background-music',
originalWidth: 420,
originalHeight: 120,
model: 'eleven_text_to_sound_v2',
taskId: 'task-sfx-v2-1234-abcd',
generationInputs: {
fields: [{ title: '用户描述', value: '金币落地' }],
references: [],
},
})}
onClose={vi.fn()}
/>,
);
const soundEffectDialog = screen.getByRole('dialog', { name: '音频信息' });
expect(
within(soundEffectDialog).getByText('task-sfx-v2-1234-abcd'),
).toBeTruthy();
expect(within(soundEffectDialog).queryByText('1234')).toBeNull();
});
it('keeps the full sound effect task id when the resource kind is unresolvable', () => {
// 中文注释:legacy 布局快照没有 assetKindOverride 键,水合时旧的 assetKind 会被当成
// override;资源行又缺 assetKind(或整个 resource 查不到)时 resourceAssetKind 是 null。
// 这正是元数据缺失最常伴随的形态,判据不能依赖资源分类,否则又退回截断假值。
render(
<ImageCanvasMetadataModalView
layer={createLayer({
title: '金币音效',
src: '/generated-character-drafts/editor-audios/coin.mp3',
mediaType: 'audio',
resourceAssetKind: null,
assetKindOverride: 'sound-effect',
assetKind: 'sound-effect',
originalWidth: 420,
originalHeight: 120,
model: 'eleven_text_to_sound_v2',
taskId: 'task-sfx-v2-1234-abcd',
generationInputs: {
fields: [{ title: '用户描述', value: '金币落地' }],
references: [],
},
})}
onClose={vi.fn()}
/>,
);
const dialog = screen.getByRole('dialog', { name: '音频信息' });
expect(within(dialog).getByText('task-sfx-v2-1234-abcd')).toBeTruthy();
expect(within(dialog).queryByText('1234')).toBeNull();
});
it('does not render a dialog when no layer is selected', () => {
render(<ImageCanvasMetadataModalView layer={null} onClose={vi.fn()} />);
@@ -1,3 +1,4 @@
import { EDITOR_SOUND_EFFECT_MODEL } from '../../../packages/shared/src/contracts/editorAudio';
import { UnifiedModal } from '../common/UnifiedModal';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { formatTaskIdForDisplay } from './ImageCanvasExportModel';
@@ -105,7 +106,19 @@ export function ImageCanvasMetadataModalView({
) : null}
<dt>Task</dt>
<dd>
{layer.generationInputs?.soundEffect?.schemaVersion === 2
{/* 中文注释:音效的 taskId 是 operation id 形态,`formatTaskIdForDisplay` 的
「取最后一段数字」会把它截成 `0`、`1234` 这类误导值。判据要回答的是「这个
taskId 由谁生成」,而不是「这个图层被归成哪一类」,所以落在 model 上:它是
资源行权威字段(registered 图层的 serializeLayer 不回写它),改「素材标签」
不影响它,水合时 resolveHydratedLayerModel 也优先取资源行的值;后端
canonicalize_editor_sound_effect_model 只接受 eleven_text_to_sound_v2 一个取值。
用 assetKind / resourceAssetKind 判都踩过坑:前者是 assetKindOverride ??
resourceAssetKind,用户改一次标签就翻转;后者在 legacy 布局(快照没有
assetKindOverride 键,旧 assetKind 被当成 override)和资源行查不到时都是 null。
soundEffect 那一支只是补充——它和生成 action 一样长在 generationInputs 上,
元数据整块缺失时会一并消失,而那正是这里要兜住的场景。 */}
{layer.model === EDITOR_SOUND_EFFECT_MODEL ||
layer.generationInputs?.soundEffect?.schemaVersion === 2
? layer.taskId || '-'
: formatTaskIdForDisplay(layer.taskId)}
</dd>