修复 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>
This commit is contained in:
2026-08-09 13:47:16 +08:00
committed by 段舒康
parent dfcd67bd05
commit 684ebdaaf3
5 changed files with 319 additions and 13 deletions
+150 -11
View File
@@ -8827,7 +8827,7 @@ fn sanitize_editor_payload_media(
})
.collect();
strip_legacy_canvas_layer_media_type(&mut value);
sanitize_editor_payload_media_value(&mut value, None, &resource_media);
let _ = sanitize_editor_payload_media_value(&mut value, None, &resource_media);
value
}
@@ -8850,15 +8850,23 @@ fn strip_legacy_canvas_layer_media_type(value: &mut Value) {
}
fn sanitize_editor_payload_inline_media(mut value: Value) -> Value {
sanitize_editor_payload_media_value(&mut value, None, &BTreeMap::new());
let _ = sanitize_editor_payload_media_value(&mut value, None, &BTreeMap::new());
value
}
/// 返回 `true` 表示这个值本身就是被抹掉的内联媒体,调用方必须把承载它的键或数组元素一并删除。
///
/// 中文注释:放宽只针对**对象字段**。早期实现在每层对象上无差别执行
/// `retain(|_, child| !child.is_null())`,把调用方原本就写着 `null` 的合法契约字段一起删了:
/// SFX 自动时长的 `generationInputs.soundEffect.requestedDurationSeconds` 正是这样在读边界
/// 消失,客户端据此判定整份 soundEffect 元数据非法,音频信息页整块退化成空。对象字段的 `null`
/// 是「有意义的空值」,必须原样下发;**数组元素的 `null` 是无效元素,照旧过滤**(见 Array 分支)。
/// 脱敏强度不变——内联媒体仍然先被置空再连键/连元素删除。
fn sanitize_editor_payload_media_value(
value: &mut Value,
inherited_media: Option<&EditorPayloadMediaReference>,
resource_media: &BTreeMap<String, EditorPayloadMediaReference>,
) {
) -> bool {
match value {
Value::Object(object) => {
let current_media = object
@@ -8913,21 +8921,40 @@ fn sanitize_editor_payload_media_value(
*generation_inputs = sanitized;
}
}
for child in object.values_mut() {
sanitize_editor_payload_media_value(child, current_media.as_ref(), resource_media);
let mut sanitized_keys = Vec::new();
for (key, child) in object.iter_mut() {
if sanitize_editor_payload_media_value(
child,
current_media.as_ref(),
resource_media,
) {
sanitized_keys.push(key.clone());
}
}
object.retain(|_, child| !child.is_null());
for key in &sanitized_keys {
object.remove(key);
}
false
}
Value::Array(items) => {
for item in items.iter_mut() {
sanitize_editor_payload_media_value(item, inherited_media, resource_media);
}
items.retain(|item| !item.is_null());
// 中文注释:数组元素继续沿用「连 null 一起丢掉」的旧行为,放宽只发生在对象字段上。
// 布局数组是唯一没有元素级 shape 校验的写入口(legacy layout_storage_version=0 是
// 新画布的默认状态),一个 null 元素读回前端后会让 canvasLayoutItemId /
// isCanvasSettingsLayoutItem 抛 TypeError,整次项目套用静默失败;generationInputs
// 的 fields / references 里混进 null 元素也会让整份元数据被判非法丢弃。两者都是
// 「无效元素」而不是「有意义的空值」,读边界照旧过滤掉。
items.retain_mut(|item| {
let sanitized =
sanitize_editor_payload_media_value(item, inherited_media, resource_media);
!sanitized && !item.is_null()
});
false
}
Value::String(item) if is_forbidden_editor_persisted_media_src(item) => {
*value = Value::Null;
true
}
_ => {}
_ => false,
}
}
@@ -13913,6 +13940,118 @@ mod tests {
assert!(frames_only.image_sequence_duration_ms.is_none());
}
#[test]
fn editor_user_generation_inputs_keep_sound_effect_auto_duration_null() {
let sanitized = sanitize_editor_user_generation_inputs(json!({
"fields": [
{ "title": "用户描述", "value": "金币落地" },
{ "title": "时长", "value": "7.42秒" }
],
"references": [],
"soundEffect": {
"schemaVersion": 2,
"userPrompt": "金币落地",
"actualPrompt": "A bright coin landing chime",
"model": "eleven_text_to_sound_v2",
"durationMode": "auto",
"requestedDurationSeconds": null,
"actualDurationSeconds": 7.42,
"loop": false
}
}));
// 自动时长的 requestedDurationSeconds 是「没有请求时长」的合法契约值。读边界脱敏不得
// 把它连键删掉,否则客户端判定整份 SFX 元数据非法,音频信息页会连 fields 一起空掉。
let sound_effect = sanitized
.get("soundEffect")
.and_then(Value::as_object)
.expect("soundEffect 必须完整穿过读边界脱敏");
assert!(sound_effect.contains_key("requestedDurationSeconds"));
assert_eq!(sound_effect["requestedDurationSeconds"], Value::Null);
assert_eq!(sound_effect["durationMode"], json!("auto"));
assert_eq!(sound_effect["actualDurationSeconds"], json!(7.42));
assert_eq!(sanitized["fields"][0]["value"], json!("金币落地"));
}
#[test]
fn editor_user_generation_inputs_still_drop_inline_media_and_reserved_fields() {
let sanitized = sanitize_editor_user_generation_inputs(json!({
"fields": [{ "title": "参考图", "value": "data:image/png;base64,forged" }],
"references": [
{
"title": "参考图",
"refId": "asset-1",
"src": "data:image/png;base64,forged"
}
],
"screenColorHex": "#00ff00",
"mattingProvider": "internal-provider",
"mattingModel": "internal-model"
}));
// 脱敏强度不变:内联媒体仍然连键消失,保留字段仍然被摘掉。
let payload = serde_json::to_string(&sanitized).expect("payload serializes");
assert!(!payload.contains("data:image"));
assert!(sanitized["fields"][0].get("value").is_none());
assert!(sanitized["references"][0].get("src").is_none());
assert_eq!(sanitized["references"][0]["refId"], json!("asset-1"));
for reserved in ["screenColorHex", "mattingProvider", "mattingModel"] {
assert!(sanitized.get(reserved).is_none());
}
}
#[test]
fn editor_payload_sanitizer_drops_null_array_items_but_keeps_null_object_fields() {
// 数组元素的 null 是无效元素:布局数组里留下一个就会让前端 canvasLayoutItemId /
// isCanvasSettingsLayoutItem 抛 TypeErrorgenerationInputs 的 fields 里留下一个会让
// 整份元数据被判非法。对象字段的 null 是有意义的空值,必须原样下发。
let sanitized = sanitize_editor_payload_media(
json!([
null,
{
"layerId": "layer-1",
"resourceId": "resource-1",
"sourceResourceId": null,
"generationInputs": {
"fields": [null, { "title": "用户描述", "value": "金币落地" }],
"references": [],
"soundEffect": {
"schemaVersion": 2,
"durationMode": "auto",
"requestedDurationSeconds": null
}
}
},
null
]),
&[],
);
let items = sanitized.as_array().expect("顶层仍是数组");
assert_eq!(items.len(), 1, "布局数组里的 null 元素必须被过滤掉");
let layer = &items[0];
assert_eq!(layer["layerId"], json!("layer-1"));
let fields = layer["generationInputs"]["fields"]
.as_array()
.expect("fields 仍是数组");
assert_eq!(fields.len(), 1, "fields 里的 null 元素必须被过滤掉");
assert_eq!(fields[0]["title"], json!("用户描述"));
let sound_effect = layer["generationInputs"]["soundEffect"]
.as_object()
.expect("soundEffect 保留");
assert!(sound_effect.contains_key("requestedDurationSeconds"));
assert_eq!(sound_effect["requestedDurationSeconds"], Value::Null);
assert!(
layer
.as_object()
.expect("图层是对象")
.contains_key("sourceResourceId"),
"对象字段的 null 要原样保留"
);
}
#[test]
fn editor_payload_media_sanitizer_replaces_inline_layer_src_from_resource() {
let resources = vec![EditorProjectResourcePayload {
@@ -1217,6 +1217,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>