实现 SFX V2 T1 契约与提示词基础

统一前后端 SFX Prompt Unicode canonicalization、字符计数和 2048 边界
固化 52 个音效预设、追加规则及跨语言测试向量
演进共享音频请求响应、duration、Loop 和 V2 metadata 契约
增加 External model 输入矩阵纯函数并保持正式接线归属 T5
补齐前端提交、读取、深拷贝与请求边界回归测试
更新共享计划并标记 T1 完成且当前不可发布
This commit is contained in:
2026-08-06 13:05:48 +00:00
parent 6fd731f56c
commit cd80c085ff
24 changed files with 1559 additions and 97 deletions
@@ -1028,7 +1028,8 @@ impl EditorAgentTool for GenerateSoundEffectTool {
let payload = EditorSoundEffectGenerateRequest {
prompt: args.prompt,
model: Some(args.model),
duration: args.duration,
duration: Some(f64::from(args.duration)),
loop_enabled: false,
project_id: Some(context.conversation.project_id.clone()),
canvas_completion: Some(build_editor_agent_canvas_completion(
context.project,
@@ -768,6 +768,32 @@ pub async fn generate_external_editor_video(
Ok(external_generation_accepted_response(&request_context, job))
}
/// T1 先冻结 External v1 的 model 输入矩阵;实际在定价、预扣和入队前接线属于 T5。
#[allow(dead_code)]
fn canonicalize_external_editor_sound_effect_model(
value: Option<&str>,
) -> Result<&'static str, AppError> {
let normalized = value
.map(|value| value.trim_matches(char::is_whitespace))
.filter(|value| !value.is_empty());
match normalized {
None => Ok(shared_contracts::assets::EDITOR_SOUND_EFFECT_MODEL),
Some(value) if value == shared_contracts::assets::EDITOR_SOUND_EFFECT_MODEL => {
Ok(shared_contracts::assets::EDITOR_SOUND_EFFECT_MODEL)
}
Some(_) => Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": EXTERNAL_EDITOR_PROVIDER,
"field": "model",
"message": format!(
"model 只支持 {}",
shared_contracts::assets::EDITOR_SOUND_EFFECT_MODEL
),
})),
),
}
}
pub async fn generate_external_editor_sound_effect(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -1025,6 +1051,39 @@ fn serialize_external_editor_image_sequence_frames(
mod tests {
use super::*;
#[test]
fn external_sound_effect_model_canonicalizer_freezes_the_t1_matrix() {
for accepted in [
None,
Some(""),
Some(" \t\r\n"),
Some("\u{0085}\u{2003}\u{00a0}"),
Some("eleven_text_to_sound_v2"),
Some("\u{0085} eleven_text_to_sound_v2 \u{2003}"),
] {
assert_eq!(
canonicalize_external_editor_sound_effect_model(accepted)
.expect("accepted model form should canonicalize"),
shared_contracts::assets::EDITOR_SOUND_EFFECT_MODEL,
"accepted={accepted:?}"
);
}
for rejected in [
"audio1.0",
"AUDIO1.0",
"Eleven_text_to_sound_v2",
"eleven_text_to_sound_v3",
"\u{200b}",
"\u{feff}",
] {
let error = canonicalize_external_editor_sound_effect_model(Some(rejected))
.expect_err("legacy and unknown non-empty models should be rejected");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert!(error.body_text().contains("model"));
}
}
const EXTERNAL_MEDIA_CREATE_REQUEST_SCHEMAS: [&str; 2] = [
"ExternalEditorAssetCreateRequest",
"ExternalEditorProjectResourceCreateRequest",
@@ -100,13 +100,10 @@ pub(super) fn normalize_editor_sound_effect_request_with_pricing(
) -> Result<NormalizedEditorSoundEffectRequest, AppError> {
let model = normalize_editor_sound_model(payload.model.as_deref())?;
let expected_price_mud_points = pricing.sound_effect_model_mud_points(Some(model.as_str()));
let prompt = platform_audio::validate_sound_effect_prompt(&payload.prompt)
.map_err(map_platform_audio_error)?;
Ok(NormalizedEditorSoundEffectRequest {
prompt: platform_audio::normalize_limited_text(
&payload.prompt,
"prompt",
platform_audio::VIDU_PROMPT_MAX_CHARS,
)
.map_err(map_platform_audio_error)?,
prompt: prompt.prompt.to_string(),
model,
duration: normalize_editor_sound_duration(payload.duration)?,
price_mud_points: expected_price_mud_points,
@@ -124,9 +121,12 @@ fn normalize_editor_sound_model(value: Option<&str>) -> Result<String, AppError>
))
}
fn normalize_editor_sound_duration(value: u8) -> Result<u8, AppError> {
if (2..=10).contains(&value) {
return Ok(value);
fn normalize_editor_sound_duration(value: Option<f64>) -> Result<u8, AppError> {
let value = value.unwrap_or(f64::from(
platform_audio::DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
));
if value.is_finite() && value.fract() == 0.0 && (2.0..=10.0).contains(&value) {
return Ok(value as u8);
}
Err(editor_audio_bad_request("音效 duration 必须在 2-10 秒之间"))
}
@@ -406,9 +406,12 @@ pub(crate) async fn generate_editor_sound_effect_for_owner(
prompt: normalized.prompt.clone(),
actual_prompt: Some(normalized.prompt),
model: normalized.model,
provider: generated.provider,
task_id: generated.task_id,
price_mud_points: normalized.price_mud_points,
audio_kind: "sound-effect".to_string(),
duration_seconds: None,
loop_enabled: None,
project: completed_project,
resource,
asset,
@@ -550,9 +553,12 @@ fn build_editor_background_music_generate_response(
prompt: normalized.gpt_description_prompt.clone(),
actual_prompt: Some(normalized.gpt_description_prompt),
model: platform_audio::SUNO_DEFAULT_MODEL.to_string(),
provider: generated.provider,
task_id: generated.task_id,
price_mud_points: normalized.price_mud_points,
audio_kind: "background-music".to_string(),
duration_seconds: None,
loop_enabled: None,
project,
resource,
asset,
@@ -801,7 +807,8 @@ mod tests {
assets::EditorSoundEffectGenerateRequest {
prompt: prompt.into(),
model: model.map(str::to_string),
duration,
duration: Some(f64::from(duration)),
loop_enabled: false,
project_id: Some("project-1".to_string()),
canvas_completion: None,
generation_inputs: None,
@@ -964,13 +971,13 @@ mod tests {
#[test]
fn editor_sound_effect_normalizer_enforces_prompt_and_duration_boundaries() {
let max_prompt = "声".repeat(platform_audio::VIDU_PROMPT_MAX_CHARS);
let max_prompt = "声".repeat(platform_audio::SOUND_EFFECT_PROMPT_MAX_CODE_POINTS);
let normalized = normalize_editor_sound_effect_request(sound_effect_payload(
format!(" {max_prompt} "),
None,
2,
))
.expect("1500 canonical code points and the 2-second boundary should pass");
.expect("2048 canonical code points and the 2-second boundary should pass");
assert_eq!(normalized.prompt, max_prompt);
assert_eq!(normalized.duration, 2);
@@ -979,10 +986,10 @@ mod tests {
.expect("the 10-second boundary should pass");
assert_eq!(normalized.duration, 10);
let overlong_prompt = "声".repeat(platform_audio::VIDU_PROMPT_MAX_CHARS + 1);
let overlong_prompt = "声".repeat(platform_audio::SOUND_EFFECT_PROMPT_MAX_CODE_POINTS + 1);
let error =
normalize_editor_sound_effect_request(sound_effect_payload(overlong_prompt, None, 5))
.expect_err("1501 canonical code points should fail");
.expect_err("2049 canonical code points should fail");
assert_eq!(error.status_code(), axum::http::StatusCode::BAD_REQUEST);
for duration in [1, 11] {
@@ -5,6 +5,7 @@ mod error;
mod persist;
mod request;
mod response;
mod sound_effect_prompt;
mod types;
pub use background_music_prompt::{
@@ -30,6 +31,12 @@ pub use request::{
pub use response::{
extract_audio_urls, is_failed_task_status, is_pending_task_status, normalize_task_status,
};
pub use sound_effect_prompt::{
SOUND_EFFECT_DURATION_MAX_SECONDS, SOUND_EFFECT_DURATION_MIN_SECONDS,
SOUND_EFFECT_PROMPT_MAX_CODE_POINTS, ValidatedSoundEffectPrompt,
canonicalize_sound_effect_prompt, sound_effect_prompt_code_point_count,
validate_sound_effect_duration_seconds, validate_sound_effect_prompt,
};
pub use types::{
AudioTaskKind, AudioTaskResponse, BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CHARS,
BackgroundMusicTaskRequest, DEFAULT_SOUND_EFFECT_DURATION_SECONDS, DownloadedAudio,
@@ -0,0 +1,56 @@
use crate::AudioError;
pub const SOUND_EFFECT_PROMPT_MAX_CODE_POINTS: usize = 2048;
pub const SOUND_EFFECT_DURATION_MIN_SECONDS: f64 = 0.5;
pub const SOUND_EFFECT_DURATION_MAX_SECONDS: f64 = 30.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ValidatedSoundEffectPrompt<'a> {
pub prompt: &'a str,
pub char_count: usize,
}
/// 只删除首尾 Unicode `White_Space`,与前端 SFX canonicalizer 保持一致。
///
/// Rust `char::is_whitespace` 覆盖 U+0085 等 White_Space,同时不会误删
/// U+200B 与 U+FEFF。内部空白和其它 Unicode scalar value 均保持原样。
pub fn canonicalize_sound_effect_prompt(prompt: &str) -> &str {
prompt.trim_matches(char::is_whitespace)
}
pub fn sound_effect_prompt_code_point_count(prompt: &str) -> usize {
canonicalize_sound_effect_prompt(prompt).chars().count()
}
pub fn validate_sound_effect_prompt(
prompt: &str,
) -> Result<ValidatedSoundEffectPrompt<'_>, AudioError> {
let prompt = canonicalize_sound_effect_prompt(prompt);
let char_count = prompt.chars().count();
if char_count == 0 {
return Err(AudioError::invalid_request("prompt 不能为空"));
}
if char_count > SOUND_EFFECT_PROMPT_MAX_CODE_POINTS {
return Err(AudioError::invalid_request(format!(
"prompt 超过 {SOUND_EFFECT_PROMPT_MAX_CODE_POINTS} 字符"
)));
}
Ok(ValidatedSoundEffectPrompt { prompt, char_count })
}
pub fn validate_sound_effect_duration_seconds(
duration_seconds: Option<f64>,
) -> Result<Option<f64>, AudioError> {
let Some(duration_seconds) = duration_seconds else {
return Ok(None);
};
if duration_seconds.is_finite()
&& (SOUND_EFFECT_DURATION_MIN_SECONDS..=SOUND_EFFECT_DURATION_MAX_SECONDS)
.contains(&duration_seconds)
{
return Ok(Some(duration_seconds));
}
Err(AudioError::invalid_request(format!(
"duration 必须在 {SOUND_EFFECT_DURATION_MIN_SECONDS}-{SOUND_EFFECT_DURATION_MAX_SECONDS} 秒之间"
)))
}
@@ -0,0 +1,110 @@
use platform_audio::{
SOUND_EFFECT_DURATION_MAX_SECONDS, SOUND_EFFECT_DURATION_MIN_SECONDS,
SOUND_EFFECT_PROMPT_MAX_CODE_POINTS, canonicalize_sound_effect_prompt,
sound_effect_prompt_code_point_count, validate_sound_effect_duration_seconds,
validate_sound_effect_prompt,
};
use serde_json::Value;
fn shared_canonicalization_fixture() -> Value {
serde_json::from_str(include_str!(
"../../../../packages/shared/test-fixtures/sound-effect-prompt-canonicalization.json"
))
.expect("shared SFX prompt canonicalization fixture should be valid JSON")
}
#[test]
fn sound_effect_prompt_matches_shared_unicode_canonicalization_fixtures() {
for case in shared_canonicalization_fixture()
.as_array()
.expect("fixture should be an array")
{
let name = case["name"]
.as_str()
.expect("fixture name should be a string");
let input = case["input"]
.as_str()
.expect("fixture input should be a string");
let expected_prompt = case["prompt"]
.as_str()
.expect("fixture prompt should be a string");
let expected_char_count = case["charCount"]
.as_u64()
.expect("fixture charCount should be an unsigned integer")
as usize;
let canonical_prompt = canonicalize_sound_effect_prompt(input);
assert_eq!(canonical_prompt, expected_prompt, "fixture={name}");
assert_eq!(
sound_effect_prompt_code_point_count(input),
expected_char_count,
"fixture={name}"
);
assert_eq!(
canonicalize_sound_effect_prompt(canonical_prompt),
canonical_prompt,
"canonicalization should be idempotent for fixture={name}"
);
}
}
#[test]
fn sound_effect_prompt_uses_the_2048_code_point_boundary_without_truncation() {
let maximum = format!(
"\u{0085}{}\u{2003}",
"😀".repeat(SOUND_EFFECT_PROMPT_MAX_CODE_POINTS)
);
let accepted = validate_sound_effect_prompt(&maximum)
.expect("2048 canonical code points should be accepted");
assert_eq!(accepted.char_count, SOUND_EFFECT_PROMPT_MAX_CODE_POINTS);
assert_eq!(
accepted.prompt,
"😀".repeat(SOUND_EFFECT_PROMPT_MAX_CODE_POINTS)
);
let overlong = "声".repeat(SOUND_EFFECT_PROMPT_MAX_CODE_POINTS + 1);
let error = validate_sound_effect_prompt(&overlong)
.expect_err("2049 canonical code points should be rejected");
assert!(error.message().contains("超过 2048 字符"));
assert_eq!(
overlong.chars().count(),
SOUND_EFFECT_PROMPT_MAX_CODE_POINTS + 1
);
}
#[test]
fn sound_effect_prompt_rejects_empty_canonical_values_without_a_default() {
for prompt in ["", " \t\r\n", "\u{0085}\u{2003}\u{00a0}"] {
let error = validate_sound_effect_prompt(prompt)
.expect_err("empty canonical SFX prompt should be rejected");
assert_eq!(error.message(), "prompt 不能为空");
}
}
#[test]
fn sound_effect_duration_accepts_auto_and_frozen_boundaries() {
assert_eq!(
validate_sound_effect_duration_seconds(None).expect("auto duration should pass"),
None
);
for duration_seconds in [
SOUND_EFFECT_DURATION_MIN_SECONDS,
7.5,
SOUND_EFFECT_DURATION_MAX_SECONDS,
] {
assert_eq!(
validate_sound_effect_duration_seconds(Some(duration_seconds))
.expect("manual duration boundary should pass"),
Some(duration_seconds)
);
}
}
#[test]
fn sound_effect_duration_rejects_non_finite_and_out_of_range_values() {
for duration_seconds in [0.49, 30.01, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let error = validate_sound_effect_duration_seconds(Some(duration_seconds))
.expect_err("invalid manual duration should fail");
assert!(error.message().contains("duration 必须在 0.5-30 秒之间"));
}
}
+159 -11
View File
@@ -513,13 +513,39 @@ pub struct EditorIconSpritesheetGenerateResponse {
pub price_mud_points: u32,
}
pub const EDITOR_SOUND_EFFECT_MODEL: &str = "eleven_text_to_sound_v2";
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum EditorSoundEffectDurationMode {
Auto,
Manual,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorSoundEffectGenerationMetadataV2 {
pub schema_version: u8,
pub user_prompt: String,
pub actual_prompt: String,
pub model: String,
pub duration_mode: EditorSoundEffectDurationMode,
pub requested_duration_seconds: Option<f64>,
pub actual_duration_seconds: f64,
#[serde(rename = "loop")]
pub loop_enabled: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorSoundEffectGenerateRequest {
pub prompt: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub duration: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration: Option<f64>,
#[serde(default, rename = "loop")]
pub loop_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -562,6 +588,19 @@ pub struct BackgroundMusicPromptAssistResponse {
pub char_count: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SoundEffectPromptOptimizeRequest {
pub current_prompt: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SoundEffectPromptOptimizeResponse {
pub prompt: String,
pub char_count: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorAudioGenerateResponse {
@@ -578,10 +617,15 @@ pub struct EditorAudioGenerateResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub actual_prompt: Option<String>,
pub model: String,
pub provider: String,
pub task_id: String,
pub price_mud_points: u32,
pub audio_kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_seconds: Option<f64>,
#[serde(default, rename = "loop", skip_serializing_if = "Option::is_none")]
pub loop_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource: Option<Value>,
@@ -1387,8 +1431,9 @@ mod tests {
fn editor_audio_requests_and_response_use_canvas_audio_shape() {
let sound_payload = serde_json::to_value(EditorSoundEffectGenerateRequest {
prompt: "金币掉落叮当声".to_string(),
model: Some("audio1.0".to_string()),
duration: 7,
model: Some(EDITOR_SOUND_EFFECT_MODEL.to_string()),
duration: Some(7.5),
loop_enabled: true,
project_id: None,
canvas_completion: None,
generation_inputs: None,
@@ -1397,8 +1442,9 @@ mod tests {
})
.expect("sound request should serialize");
assert_eq!(sound_payload["prompt"], json!("金币掉落叮当声"));
assert_eq!(sound_payload["model"], json!("audio1.0"));
assert_eq!(sound_payload["duration"], json!(7));
assert_eq!(sound_payload["model"], json!(EDITOR_SOUND_EFFECT_MODEL));
assert_eq!(sound_payload["duration"], json!(7.5));
assert_eq!(sound_payload["loop"], json!(true));
assert_eq!(sound_payload["assetFolderId"], json!("project"));
assert_eq!(sound_payload["assetLabel"], json!("游戏音效 1"));
assert!(sound_payload.get("priceMudPoints").is_none());
@@ -1410,12 +1456,14 @@ mod tests {
serde_json::from_value(sound_payload)
.expect("sound request with duration should deserialize");
assert_eq!(parsed_sound_payload.prompt, "金币掉落叮当声");
assert_eq!(parsed_sound_payload.duration, 7);
assert_eq!(parsed_sound_payload.duration, Some(7.5));
assert!(parsed_sound_payload.loop_enabled);
let unset_model_payload = serde_json::to_value(EditorSoundEffectGenerateRequest {
prompt: "按钮确认短促音".to_string(),
model: None,
duration: 5,
duration: None,
loop_enabled: false,
project_id: None,
canvas_completion: None,
generation_inputs: None,
@@ -1424,7 +1472,23 @@ mod tests {
})
.expect("sound request with unset model should serialize");
assert!(unset_model_payload.get("model").is_none());
assert_eq!(unset_model_payload["duration"], json!(5));
assert!(unset_model_payload.get("duration").is_none());
assert_eq!(unset_model_payload["loop"], json!(false));
for nullable_payload in [
json!({ "prompt": "按钮确认短促音" }),
json!({
"prompt": "按钮确认短促音",
"model": null,
"duration": null,
}),
] {
let parsed: EditorSoundEffectGenerateRequest = serde_json::from_value(nullable_payload)
.expect("omitted and explicit null fields should deserialize");
assert_eq!(parsed.model, None);
assert_eq!(parsed.duration, None);
assert!(!parsed.loop_enabled);
}
let music_payload = serde_json::to_value(EditorBackgroundMusicGenerateRequest {
gpt_description_prompt: "森林冒险背景音乐".to_string(),
@@ -1452,10 +1516,13 @@ mod tests {
source_type: "generated".to_string(),
prompt: "金币掉落叮当声".to_string(),
actual_prompt: Some("金币掉落叮当声".to_string()),
model: "audio1.0".to_string(),
model: EDITOR_SOUND_EFFECT_MODEL.to_string(),
provider: "elevenlabs".to_string(),
task_id: "sound-task-1".to_string(),
price_mud_points: 10,
audio_kind: "sound-effect".to_string(),
duration_seconds: Some(7.42),
loop_enabled: Some(true),
project: None,
resource: None,
asset: Some(json!({
@@ -1481,14 +1548,95 @@ mod tests {
);
assert_eq!(response_payload["assetObjectId"], json!("assetobj_audio_1"));
assert_eq!(response_payload["audioKind"], json!("sound-effect"));
assert!(response_payload.get("durationSeconds").is_none());
assert!(response_payload.get("provider").is_none());
assert_eq!(response_payload["durationSeconds"], json!(7.42));
assert_eq!(response_payload["loop"], json!(true));
assert_eq!(response_payload["provider"], json!("elevenlabs"));
assert_eq!(
response_payload["asset"]["assetKind"],
json!("sound-effect")
);
}
#[test]
fn editor_sound_effect_v2_metadata_uses_the_frozen_camel_case_shape() {
let metadata = EditorSoundEffectGenerationMetadataV2 {
schema_version: 2,
user_prompt: "金币落地,轻快可爱".to_string(),
actual_prompt: "A bright, cute coin landing chime".to_string(),
model: EDITOR_SOUND_EFFECT_MODEL.to_string(),
duration_mode: EditorSoundEffectDurationMode::Manual,
requested_duration_seconds: Some(5.0),
actual_duration_seconds: 5.12,
loop_enabled: false,
};
let payload = serde_json::to_value(&metadata).expect("metadata should serialize");
assert_eq!(
payload,
json!({
"schemaVersion": 2,
"userPrompt": "金币落地,轻快可爱",
"actualPrompt": "A bright, cute coin landing chime",
"model": "eleven_text_to_sound_v2",
"durationMode": "manual",
"requestedDurationSeconds": 5.0,
"actualDurationSeconds": 5.12,
"loop": false,
})
);
assert_eq!(
serde_json::from_value::<EditorSoundEffectGenerationMetadataV2>(payload)
.expect("metadata should deserialize"),
metadata
);
}
#[test]
fn sound_effect_prompt_optimize_contract_uses_the_minimal_camel_case_shape() {
let request = SoundEffectPromptOptimizeRequest {
current_prompt: "\n 金币落地,轻快可爱 ".to_string(),
};
let request_payload = serde_json::to_value(&request)
.expect("sound effect prompt optimize request should serialize");
assert_eq!(
request_payload,
json!({
"currentPrompt": "\n 金币落地,轻快可爱 ",
})
);
assert_eq!(
serde_json::from_value::<SoundEffectPromptOptimizeRequest>(request_payload)
.expect("sound effect prompt optimize request should deserialize"),
request
);
assert!(
serde_json::from_value::<SoundEffectPromptOptimizeRequest>(json!({
"currentPrompt": "金币落地",
"targetChars": 2048,
}))
.is_err(),
"clients must not control sound effect optimization targets"
);
let response = SoundEffectPromptOptimizeResponse {
prompt: "金币落地时清脆明亮、轻快可爱的金属叮当声".to_string(),
char_count: 23,
};
let response_payload = serde_json::to_value(&response)
.expect("sound effect prompt optimize response should serialize");
assert_eq!(
response_payload,
json!({
"prompt": "金币落地时清脆明亮、轻快可爱的金属叮当声",
"charCount": 23,
})
);
assert_eq!(
serde_json::from_value::<SoundEffectPromptOptimizeResponse>(response_payload)
.expect("sound effect prompt optimize response should deserialize"),
response
);
}
#[test]
fn background_music_prompt_assist_contract_uses_camel_case_shape() {
let request = BackgroundMusicPromptAssistRequest {