diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 285d0885e..5b1f14613 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,22 @@ 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` 的字段。 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 +8920,32 @@ 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()); + items.retain_mut(|item| { + !sanitize_editor_payload_media_value(item, inherited_media, resource_media) + }); + false } Value::String(item) if is_forbidden_editor_persisted_media_src(item) => { *value = Value::Null; + true } - _ => {} + _ => false, } } @@ -13913,6 +13931,66 @@ 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_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..cd1aaf265 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx @@ -352,6 +352,34 @@ 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('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..7a9237bd9 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.tsx @@ -105,7 +105,12 @@ export function ImageCanvasMetadataModalView({ ) : null}
Task
- {layer.generationInputs?.soundEffect?.schemaVersion === 2 + {/* 中文注释:音效的 taskId 是 operation id 形态,`formatTaskIdForDisplay` 的 + 「取最后一段数字」会把它截成 `0` 这类误导值。判据取图层自身的 assetKind, + 不再依赖 soundEffect 元数据是否解析成功——元数据一旦缺失,Task 这一栏不该 + 跟着降级成假值。 */} + {layer.assetKind === 'sound-effect' || + layer.generationInputs?.soundEffect?.schemaVersion === 2 ? layer.taskId || '-' : formatTaskIdForDisplay(layer.taskId)}