diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index f69e35d47..e4f67a56b 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -4818,6 +4818,152 @@ } } }, + "ExternalEditorGenerationCompactResourceReference": { + "type": "object", + "required": [ + "resourceId" + ], + "properties": { + "resourceId": { + "type": "string", + "minLength": 1 + }, + "projectId": { + "type": "string", + "minLength": 1 + }, + "objectKey": { + "type": "string", + "minLength": 1 + }, + "assetObjectId": { + "type": "string", + "minLength": 1 + }, + "sourceResourceId": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": true, + "description": "完成态 compact result 中的稳定画布资源引用;不是完整 resource 快照。" + }, + "ExternalEditorGenerationCompactAssetReference": { + "type": "object", + "required": [ + "assetId" + ], + "properties": { + "assetId": { + "type": "string", + "minLength": 1 + }, + "folderId": { + "type": "string", + "minLength": 1 + }, + "objectKey": { + "type": "string", + "minLength": 1 + }, + "assetObjectId": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": true, + "description": "完成态 compact result 中的稳定素材库引用;不是完整 asset 快照。" + }, + "ExternalEditorSoundEffectCompactResult": { + "type": "object", + "required": [ + "audioKind" + ], + "properties": { + "audioKind": { + "type": "string", + "const": "sound-effect" + }, + "durationSeconds": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 600, + "description": "SFX V2 保存 MP3 后探测得到的实际时长;不是请求时长。历史完成结果可缺少此字段。" + }, + "loop": { + "type": "boolean", + "description": "SFX V2 冻结并发送给 provider 的 Loop;历史完成结果可缺少此字段。" + }, + "taskId": { + "type": "string", + "minLength": 1 + }, + "objectKey": { + "type": "string", + "minLength": 1, + "description": "持久化音频对象的稳定引用;需要临时下载或预览 URL 时使用 assets/read-url。" + }, + "assetObjectId": { + "type": "string", + "minLength": 1 + }, + "resource": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalEditorGenerationCompactResourceReference" + }, + { + "type": "null" + } + ] + }, + "asset": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalEditorGenerationCompactAssetReference" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true, + "description": "External SFX 完成态的 compact result。仅包含稳定产物引用和可安全消费的 SFX 元数据;不包含完整 project/canvas/asset 快照或脱敏的 Prompt、模型与 provider 字段。" + }, + "ExternalEditorGenerationGenericCompactResult": { + "type": "object", + "not": { + "type": "object", + "required": [ + "audioKind" + ], + "properties": { + "audioKind": { + "const": "sound-effect" + } + } + }, + "additionalProperties": true, + "description": "其它生成类型或历史结果的兼容 compact result。该 fallback 明确排除 audioKind=sound-effect,避免吞掉 SFX 专用分支。" + }, + "ExternalEditorGenerationCompletedResult": { + "oneOf": [ + { + "$ref": "#/components/schemas/ExternalEditorSoundEffectCompactResult" + }, + { + "$ref": "#/components/schemas/ExternalEditorGenerationGenericCompactResult" + } + ], + "discriminator": { + "propertyName": "audioKind", + "mapping": { + "sound-effect": "#/components/schemas/ExternalEditorSoundEffectCompactResult" + } + }, + "description": "External v1 轮询 completed 状态的命名 compact result 联合。" + }, "ExternalEditorGenerationSubmissionResponse": { "type": "object", "required": [ @@ -4902,9 +5048,8 @@ "type": "string" }, "result": { - "type": "object", - "description": "completed 时返回的 compact 稳定结果引用;不包含完整 project/canvas、Data URL、Blob URL 或临时签名 URL。", - "additionalProperties": true + "$ref": "#/components/schemas/ExternalEditorGenerationCompletedResult", + "description": "仅在 completed 时返回的 compact 稳定结果引用;queued、running 和 failed 不返回该字段。为兼容历史完成结果,此字段不作为无版本迁移的必填约束。" }, "pollAfterMs": { "type": "integer", diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 89284000d..83c43bf39 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -2157,6 +2157,79 @@ mod tests { audio_response["properties"]["loop"]["type"], json!(["boolean", "null"]) ); + let generation_job = + &parsed["components"]["schemas"]["ExternalEditorGenerationJobResponse"]; + assert_eq!( + generation_job["properties"]["result"]["$ref"], + "#/components/schemas/ExternalEditorGenerationCompletedResult" + ); + assert!( + generation_job["required"] + .as_array() + .is_some_and(|required| !required.contains(&json!("result"))), + "历史 completed 轮询结果可能没有 result,不能在无版本迁移时改为必填" + ); + let completed_result = + &parsed["components"]["schemas"]["ExternalEditorGenerationCompletedResult"]; + assert_eq!( + completed_result["oneOf"][0]["$ref"], + "#/components/schemas/ExternalEditorSoundEffectCompactResult" + ); + assert_eq!( + completed_result["oneOf"][1]["$ref"], + "#/components/schemas/ExternalEditorGenerationGenericCompactResult" + ); + assert_eq!( + completed_result["discriminator"]["propertyName"], + "audioKind" + ); + assert_eq!( + completed_result["discriminator"]["mapping"]["sound-effect"], + "#/components/schemas/ExternalEditorSoundEffectCompactResult" + ); + let sound_effect_result = + &parsed["components"]["schemas"]["ExternalEditorSoundEffectCompactResult"]; + assert_eq!(sound_effect_result["required"], json!(["audioKind"])); + assert_eq!( + sound_effect_result["properties"]["audioKind"]["const"], + "sound-effect" + ); + assert_eq!( + sound_effect_result["properties"]["durationSeconds"]["type"], + "number" + ); + assert_eq!( + sound_effect_result["properties"]["durationSeconds"]["exclusiveMinimum"], + json!(0) + ); + assert_eq!( + sound_effect_result["properties"]["durationSeconds"]["maximum"], + json!(600) + ); + assert_eq!(sound_effect_result["properties"]["loop"]["type"], "boolean"); + for stable_reference in ["taskId", "objectKey", "assetObjectId", "resource", "asset"] { + assert!( + sound_effect_result["properties"] + .get(stable_reference) + .is_some(), + "SFX compact result must expose {stable_reference} when it is present" + ); + } + for redacted_field in ["prompt", "actualPrompt", "model", "provider"] { + assert!( + sound_effect_result["properties"] + .get(redacted_field) + .is_none(), + "SFX compact schema must not declare redacted {redacted_field}" + ); + } + let generic_result = + &parsed["components"]["schemas"]["ExternalEditorGenerationGenericCompactResult"]; + assert_eq!(generic_result["not"]["required"], json!(["audioKind"])); + assert_eq!( + generic_result["not"]["properties"]["audioKind"]["const"], + "sound-effect" + ); assert!( parsed["paths"] .get("/api/external/v1/editor/audios/background-music/generations") diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs index 2acf8ef3f..873c6de88 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs @@ -1582,12 +1582,14 @@ fn editor_audio_bad_request(message: impl Into) -> AppError { #[cfg(test)] mod tests { use serde::Deserialize; - use serde_json::Value; + use serde_json::{Value, json}; use shared_contracts::assets; + use super::super::sound_effect_worker::{PersistedSoundEffectAudio, SoundEffectWritebackInput}; use super::{ CanonicalEditorBackgroundMusicSubmissionPayload, NormalizedEditorBackgroundMusicRequest, - build_editor_background_music_generate_response, build_inline_editor_audio_caller, + NormalizedEditorSoundEffectRequest, build_editor_background_music_generate_response, + build_inline_editor_audio_caller, build_sound_effect_job_result_payload, normalize_editor_background_music_request, normalize_editor_sound_effect_request, prepare_editor_background_music_queue_job, }; @@ -1596,6 +1598,7 @@ mod tests { EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, serialize_editor_generation_job_payload_for_test, }, + editor_project::{EditorGenerationCaller, EditorGenerationOperationContext}, request_context::RequestContext, }; @@ -1703,6 +1706,63 @@ mod tests { assert!(body.contains("\"actualPrompt\": input.prompt")); } + #[test] + fn sound_effect_compact_result_uses_persisted_metadata_for_duration_and_loop() { + let caller = EditorGenerationCaller { + owner_user_id: "user-1".to_string(), + audit_subject_user_id: Some("user-1".to_string()), + audit_project_id: Some("project-1".to_string()), + phase_reporter: None, + operation: Some(EditorGenerationOperationContext { + operation_kind: EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND.to_string(), + operation_id: "operation-sfx-1".to_string(), + operation_fingerprint: "fingerprint-sfx-1".to_string(), + worker_id: Some("worker-sfx-1".to_string()), + lease_token: Some("lease-sfx-1".to_string()), + queue_result_context: None, + }), + }; + let normalized = NormalizedEditorSoundEffectRequest { + prompt: "请求的音效描述".to_string(), + model: assets::EDITOR_SOUND_EFFECT_MODEL.to_string(), + duration: Some(1.0), + loop_enabled: false, + price_mud_points: 7, + }; + let input = SoundEffectWritebackInput { + actual_prompt: "provider prompt".to_string(), + generation_inputs: json!({ + "soundEffect": { + "requestedDurationSeconds": 1.0, + "actualDurationSeconds": 7.42, + "loop": true + } + }), + persisted_audio: PersistedSoundEffectAudio { + asset_object_id: "asset-object-sfx-1".to_string(), + object_key: "generated/editor/sfx-1.mp3".to_string(), + audio_src: "/generated-editor-audios/sfx-1.mp3".to_string(), + asset_object: None, + binding: None, + }, + }; + + let payload: Value = serde_json::from_str( + build_sound_effect_job_result_payload(&caller, &normalized, &input, None, None) + .expect("queued SFX worker result should be serialized") + .as_str(), + ) + .expect("queued SFX worker result should be JSON"); + + assert_eq!(payload["result"]["durationSeconds"], 7.42); + assert_eq!(payload["result"]["loop"], true); + assert_ne!( + payload["result"]["durationSeconds"], + json!(normalized.duration) + ); + assert_ne!(payload["result"]["loop"], json!(normalized.loop_enabled)); + } + #[test] fn editor_audio_targets_preflight_before_provider_or_queue() { let source = include_str!("generation.rs");