修复 SFX 音效信息页元数据整块丢失
读边界的内联媒体脱敏在每层对象上无差别删除 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>
This commit is contained in:
@@ -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<String, EditorPayloadMediaReference>,
|
||||
) {
|
||||
) -> 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 {
|
||||
|
||||
@@ -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,34 @@ 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('does not render a dialog when no layer is selected', () => {
|
||||
render(<ImageCanvasMetadataModalView layer={null} onClose={vi.fn()} />);
|
||||
|
||||
|
||||
@@ -105,7 +105,12 @@ export function ImageCanvasMetadataModalView({
|
||||
) : null}
|
||||
<dt>Task</dt>
|
||||
<dd>
|
||||
{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)}
|
||||
</dd>
|
||||
|
||||
Reference in New Issue
Block a user