diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 285d0885e..4b98fa4e6 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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, -) { +) -> 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 抛 TypeError,generationInputs 的 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 { diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 2c2845b7c..978138cba 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -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 = { + ...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' }, diff --git a/src/components/image-editor/ImageCanvasGenerationInputsModel.ts b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts index 0684ece44..463308b1e 100644 --- a/src/components/image-editor/ImageCanvasGenerationInputsModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts @@ -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; diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx index b4036ca15..c108b87d8 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx @@ -352,6 +352,125 @@ describe('ImageCanvasMetadataModalView', () => { } }); + it('keeps the full sound effect task id when SFX metadata is unavailable', () => { + render( + , + ); + + // 中文注释:音效 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( + , + ); + + const musicDialog = screen.getByRole('dialog', { name: '音频信息' }); + expect(within(musicDialog).getByText('75')).toBeTruthy(); + expect(within(musicDialog).queryByText('audio-task-75')).toBeNull(); + unmount(); + + render( + , + ); + + 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( + , + ); + + 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(); diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.tsx index 134f2bc40..6d9a25e89 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.tsx @@ -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}
Task
- {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)}