diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 5b1f14613..4b98fa4e6 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -8856,11 +8856,12 @@ fn sanitize_editor_payload_inline_media(mut value: Value) -> Value { /// 返回 `true` 表示这个值本身就是被抹掉的内联媒体,调用方必须把承载它的键或数组元素一并删除。 /// -/// 中文注释:删除范围必须严格限定在「本次抹掉的值」。早期实现在每层对象上无差别执行 +/// 中文注释:放宽只针对**对象字段**。早期实现在每层对象上无差别执行 /// `retain(|_, child| !child.is_null())`,把调用方原本就写着 `null` 的合法契约字段一起删了: /// SFX 自动时长的 `generationInputs.soundEffect.requestedDurationSeconds` 正是这样在读边界 -/// 消失,客户端据此判定整份 soundEffect 元数据非法,音频信息页整块退化成空。脱敏强度不变 -/// ——内联媒体仍然先被置空再连键删除,只是不再殃及本来就是 `null` 的字段。 +/// 消失,客户端据此判定整份 soundEffect 元数据非法,音频信息页整块退化成空。对象字段的 `null` +/// 是「有意义的空值」,必须原样下发;**数组元素的 `null` 是无效元素,照旧过滤**(见 Array 分支)。 +/// 脱敏强度不变——内联媒体仍然先被置空再连键/连元素删除。 fn sanitize_editor_payload_media_value( value: &mut Value, inherited_media: Option<&EditorPayloadMediaReference>, @@ -8936,8 +8937,16 @@ fn sanitize_editor_payload_media_value( false } Value::Array(items) => { + // 中文注释:数组元素继续沿用「连 null 一起丢掉」的旧行为,放宽只发生在对象字段上。 + // 布局数组是唯一没有元素级 shape 校验的写入口(legacy layout_storage_version=0 是 + // 新画布的默认状态),一个 null 元素读回前端后会让 canvasLayoutItemId / + // isCanvasSettingsLayoutItem 抛 TypeError,整次项目套用静默失败;generationInputs + // 的 fields / references 里混进 null 元素也会让整份元数据被判非法丢弃。两者都是 + // 「无效元素」而不是「有意义的空值」,读边界照旧过滤掉。 items.retain_mut(|item| { - !sanitize_editor_payload_media_value(item, inherited_media, resource_media) + let sanitized = + sanitize_editor_payload_media_value(item, inherited_media, resource_media); + !sanitized && !item.is_null() }); false } @@ -13991,6 +14000,58 @@ mod tests { } } + #[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/ImageCanvasMetadataModalView.test.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx index 2ca6814df..c108b87d8 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx @@ -380,9 +380,10 @@ describe('ImageCanvasMetadataModalView', () => { expect(within(dialog).queryByText('1234')).toBeNull(); }); - it('keeps the task id display tied to the resource kind across asset tag overrides', () => { - // 中文注释:素材标签只改 assetKindOverride,资源原始类型才是展示口径的依据。背景音乐被 - // 改标成「音效」不该换用完整 id,音效被改标成「背景音乐」更不能退回截断假值。 + it('keeps the task id display tied to the generating model across asset tag overrides', () => { + // 中文注释:展示口径由「谁生成了这个 taskId」决定,也就是 model。素材标签只改 + // assetKindOverride,不该影响它:背景音乐被改标成「音效」不能换用完整 id,音效被改标成 + // 「背景音乐」更不能退回截断假值。 const { unmount } = render( { 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 c3385dda8..6d9a25e89 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.tsx @@ -1,5 +1,5 @@ +import { EDITOR_SOUND_EFFECT_MODEL } from '../../../packages/shared/src/contracts/editorAudio'; import { UnifiedModal } from '../common/UnifiedModal'; -import { resolveLayerResourceAssetKind } from './ImageCanvasEditorModel'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { formatTaskIdForDisplay } from './ImageCanvasExportModel'; import { @@ -107,12 +107,17 @@ export function ImageCanvasMetadataModalView({
Task
{/* 中文注释:音效的 taskId 是 operation id 形态,`formatTaskIdForDisplay` 的 - 「取最后一段数字」会把它截成 `0` 这类误导值。判据必须落在资源原始类型上: - `layer.assetKind` 是 `assetKindOverride ?? resourceAssetKind`,用户在画布上 - 改一次「素材标签」就能让音效层显示成截断假值、让背景音乐层改用完整 id。 - soundEffect 那一支只是补充——它和 action 一样长在 generationInputs 上,元数据 - 整块缺失时会一并消失,而那正是这里要兜住的场景。 */} - {resolveLayerResourceAssetKind(layer) === 'sound-effect' || + 「取最后一段数字」会把它截成 `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)}