实现 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
@@ -2,7 +2,7 @@
日期:`2026-08-06`
状态:`T0通过,可以进入 T1`
状态:`T1完成,可以进入 T2、T3、T4;当前仍不是可发布切点`
开发分支:`feat/sound_opt`
@@ -135,6 +135,8 @@ External v1 `model` 先删除首尾 Unicode `White_Space`,再按大小写敏
- 原地演进现有 TypeScript / Rust 音频 DTOfixed model、nullable 小数 duration、Loop、实际时长和 V2 metadata;不得新增同义 DTO 或平行正式生成契约。
- 实现 External `model` canonicalizer 的纯函数与矩阵测试;实际 OpenAPI / handler 接线属于 T5。
实施记录(`2026-08-06`):T1 已完成。前后端共享 canonicalization fixture 已锁定 Unicode 边界与 `2048 / 2049` 行为;52 个预设、最小优化 DTO、固定模型、nullable duration、Loop 默认值、响应结果字段和强类型 V2 metadata 已落地。duration 纯校验接受自动 `null` 与手动 `0.530s`,拒绝非有限值和越界值。External `model` 当前只落地纯 canonicalizer 与输入矩阵测试,正式定价、预扣、enqueue、OpenAPI 和副作用测试仍严格归属 T5;在 T5 完成前不得发布当前中间态。
### T2:一键优化 BFF 和 Worker 翻译 service
- 增加登录态 SFX Prompt 优化 BFF,固定 Luna + Medium + `max_output_tokens = 8192` completion 总预算,32 KiB body limit,严格唯一 JSON envelope,不调用音频 provider 或正式计费。
@@ -6,3 +6,38 @@ export type BackgroundMusicPromptAssistResponse = {
prompt: string;
charCount: number;
};
export type SoundEffectPromptOptimizeRequest = {
currentPrompt: string;
};
export type SoundEffectPromptOptimizeResponse = {
prompt: string;
charCount: number;
};
export const EDITOR_SOUND_EFFECT_MODEL = 'eleven_text_to_sound_v2' as const;
export const SOUND_EFFECT_DURATION_MIN_SECONDS = 0.5;
export const SOUND_EFFECT_DURATION_MAX_SECONDS = 30;
export type EditorSoundEffectModel = typeof EDITOR_SOUND_EFFECT_MODEL;
export type EditorSoundEffectDurationMode = 'auto' | 'manual';
export type EditorSoundEffectGenerationRequest = {
prompt: string;
model: EditorSoundEffectModel;
duration?: number | null;
loop?: boolean;
};
export type EditorSoundEffectGenerationMetadataV2 = {
schemaVersion: 2;
userPrompt: string;
actualPrompt: string;
model: EditorSoundEffectModel;
durationMode: EditorSoundEffectDurationMode;
requestedDurationSeconds: number | null;
actualDurationSeconds: number;
loop: boolean;
};
@@ -0,0 +1,50 @@
[
{
"name": "plain-ascii-boundary-space",
"input": " thunder crack ",
"prompt": "thunder crack",
"charCount": 13
},
{
"name": "unicode-white-space-boundary",
"input": "\u0085\u2003金币叮当\u00a0\r\n",
"prompt": "金币叮当",
"charCount": 4
},
{
"name": "internal-white-space-preserved",
"input": "\u2003A \n B\u0085",
"prompt": "A \n B",
"charCount": 5
},
{
"name": "zero-width-and-bom-preserved",
"input": "\u0085\u200b\ufeff\u0085",
"prompt": "\u200b\ufeff",
"charCount": 2
},
{
"name": "combining-mark-preserved",
"input": "\u2003e\u0301\u00a0",
"prompt": "e\u0301",
"charCount": 2
},
{
"name": "zwj-emoji-counts-code-points",
"input": "\n👩‍💻\r",
"prompt": "👩‍💻",
"charCount": 3
},
{
"name": "unicode-white-space-only",
"input": "\u0085\u2003\u00a0\r\n",
"prompt": "",
"charCount": 0
},
{
"name": "empty",
"input": "",
"prompt": "",
"charCount": 0
}
]
@@ -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 {
@@ -13,6 +13,7 @@ import {
DEFAULT_CANVAS_BACKGROUND_COLOR,
dropDeadInlineGenerationPlaceholders,
formatCanvasDisplayScalePercent,
generationInputsOrNull,
hydrateCanvasGenerationDialog,
hydrateLayer,
INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS,
@@ -971,6 +972,47 @@ describe('ImageCanvasEditorModel', () => {
expect(hydrated).not.toHaveProperty('durationSeconds');
});
it('keeps only valid authoritative SFX V2 metadata in generation inputs', () => {
const soundEffect = {
schemaVersion: 2 as const,
userPrompt: '金币落地,轻快可爱',
actualPrompt: 'A bright, cute coin landing chime',
model: 'eleven_text_to_sound_v2' as const,
durationMode: 'manual' as const,
requestedDurationSeconds: 5,
actualDurationSeconds: 5.12,
loop: false,
};
expect(
generationInputsOrNull({ fields: [], references: [], soundEffect }),
).toEqual({ fields: [], references: [], soundEffect });
for (const invalidSoundEffect of [
{ ...soundEffect, schemaVersion: 1 },
{ ...soundEffect, model: 'audio1.0' },
{ ...soundEffect, userPrompt: ' 金币落地' },
{ ...soundEffect, actualDurationSeconds: 600.1 },
{
...soundEffect,
durationMode: 'auto',
requestedDurationSeconds: 5,
},
{
...soundEffect,
durationMode: 'manual',
requestedDurationSeconds: null,
},
]) {
expect(
generationInputsOrNull({
fields: [{ title: '用户描述', value: '金币落地' }],
references: [],
soundEffect: invalidSoundEffect,
}),
).toBeNull();
}
});
it('hydrates character animation sequence fields from the project resource', () => {
const layer: CanvasLayer = {
id: 'layer-action',
@@ -1,3 +1,8 @@
import {
EDITOR_SOUND_EFFECT_MODEL,
SOUND_EFFECT_DURATION_MAX_SECONDS,
SOUND_EFFECT_DURATION_MIN_SECONDS,
} from '../../../packages/shared/src/contracts/editorAudio';
import type {
EditorAssetGenerationInputs,
EditorAssetLibrarySnapshot,
@@ -21,6 +26,7 @@ import type {
PerfectPixelOperationSnapshot,
SnapCandidate,
} from './ImageCanvasEditorTypes';
import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel';
export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [
{
@@ -2170,6 +2176,7 @@ export function generationInputsOrNull(
const snapshot = value as {
fields?: unknown;
references?: unknown;
soundEffect?: unknown;
};
const fields = Array.isArray(snapshot.fields)
? snapshot.fields.flatMap((field) => {
@@ -2206,7 +2213,81 @@ export function generationInputsOrNull(
})
: [];
return fields.length || references.length ? { fields, references } : null;
const hasSoundEffect = Object.prototype.hasOwnProperty.call(
snapshot,
'soundEffect',
);
const soundEffect = hasSoundEffect
? soundEffectGenerationMetadataOrNull(snapshot.soundEffect)
: null;
if (hasSoundEffect && !soundEffect) {
return null;
}
return fields.length || references.length || soundEffect
? {
fields,
references,
...(soundEffect ? { soundEffect } : {}),
}
: null;
}
function soundEffectGenerationMetadataOrNull(
value: unknown,
): NonNullable<CanvasGenerationInputs['soundEffect']> | null {
if (!isSnapshotRecord(value)) {
return null;
}
const userPrompt =
typeof value.userPrompt === 'string' ? value.userPrompt : '';
const actualPrompt =
typeof value.actualPrompt === 'string' ? value.actualPrompt : '';
const userPromptValidation = validateSoundEffectPrompt(userPrompt);
const actualPromptValidation = validateSoundEffectPrompt(actualPrompt);
if (
value.schemaVersion !== 2 ||
value.model !== EDITOR_SOUND_EFFECT_MODEL ||
(value.durationMode !== 'auto' && value.durationMode !== 'manual') ||
typeof value.loop !== 'boolean' ||
!userPromptValidation.ok ||
userPromptValidation.prompt !== userPrompt ||
!actualPromptValidation.ok ||
actualPromptValidation.prompt !== actualPrompt ||
typeof value.actualDurationSeconds !== 'number' ||
!Number.isFinite(value.actualDurationSeconds) ||
value.actualDurationSeconds <= 0 ||
value.actualDurationSeconds > 600
) {
return null;
}
let requestedDurationSeconds: number | null;
if (value.durationMode === 'auto') {
if (value.requestedDurationSeconds !== null) {
return null;
}
requestedDurationSeconds = null;
} else {
if (
typeof value.requestedDurationSeconds !== 'number' ||
!Number.isFinite(value.requestedDurationSeconds) ||
value.requestedDurationSeconds < SOUND_EFFECT_DURATION_MIN_SECONDS ||
value.requestedDurationSeconds > SOUND_EFFECT_DURATION_MAX_SECONDS
) {
return null;
}
requestedDurationSeconds = value.requestedDurationSeconds;
}
return {
schemaVersion: 2,
userPrompt,
actualPrompt,
model: EDITOR_SOUND_EFFECT_MODEL,
durationMode: value.durationMode,
requestedDurationSeconds,
actualDurationSeconds: value.actualDurationSeconds,
loop: value.loop,
};
}
export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
@@ -1,3 +1,4 @@
import type { EditorSoundEffectGenerationMetadataV2 } from '../../../packages/shared/src/contracts/editorAudio';
import type {
EditorAssetSnapshot,
EditorCharacterAnimationFrameCount,
@@ -83,6 +84,7 @@ export type CanvasGenerationInputReference = {
export type CanvasGenerationInputs = {
fields: CanvasGenerationInputField[];
references: CanvasGenerationInputReference[];
soundEffect?: EditorSoundEffectGenerationMetadataV2;
};
export type CanvasLayer = {
@@ -462,6 +462,7 @@ describe('ImageCanvasGenerationLayerModel', () => {
prompt: '金币掉落叮当声',
actualPrompt: '金币掉落叮当声',
model: 'audio1.0',
provider: 'VectorEngine',
taskId: 'sound-task-1',
priceMudPoints: 10,
audioKind: 'sound-effect',
@@ -1026,15 +1026,16 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
normalizedPrompt: '金币掉落叮当声',
input: {
prompt: '金币掉落叮当声',
model: 'audio1.0',
model: 'eleven_text_to_sound_v2',
duration: 7,
loop: false,
},
result: {
title: '游戏音效 4',
generationInputs: {
fields: [
{ title: 'prompt', value: '金币掉落叮当声' },
{ title: 'model', value: 'audio1.0' },
{ title: 'model', value: 'eleven_text_to_sound_v2' },
{ title: '时长', value: '7秒' },
],
references: [],
@@ -1043,38 +1044,18 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('uses the game sound effect fallback for an all-whitespace prompt', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'audio-sound-effect',
prompt: ' \t\r\n ',
status: 'idle',
},
layers: [],
nextGeneratedIndex: 6,
});
expect(plan).toEqual({
kind: 'audio',
audioKind: 'sound-effect',
normalizedPrompt: '游戏音效',
input: {
prompt: '游戏音效',
model: 'audio1.0',
duration: 5,
},
result: {
title: '游戏音效 6',
generationInputs: {
fields: [
{ title: 'prompt', value: '游戏音效' },
{ title: 'model', value: 'audio1.0' },
{ title: '时长', value: '5秒' },
],
references: [],
it('rejects an all-whitespace sound effect prompt without a fallback', () => {
expect(() =>
buildImageGenerationSubmissionPlan({
dialog: {
mode: 'audio-sound-effect',
prompt: ' \t\r\n ',
status: 'idle',
},
},
});
layers: [],
nextGeneratedIndex: 6,
}),
).toThrow('音效描述不能为空');
});
it('builds game background music audio submission plans with instrumental fixed to true', () => {
@@ -1,3 +1,4 @@
import { EDITOR_SOUND_EFFECT_MODEL } from '../../../packages/shared/src/contracts/editorAudio';
import type {
EditorBackgroundMusicGenerationInput,
EditorCharacterAnimationGenerationInput,
@@ -32,7 +33,6 @@ import {
DEFAULT_EDITOR_BGFILTER_SEG_MODEL,
DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
DEFAULT_SOUND_EFFECT_MODEL,
DEFAULT_SPEC_FORM_VALUES,
DEFAULT_VIDEO_ASPECT_RATIO,
DEFAULT_VIDEO_DURATION_SECONDS,
@@ -50,6 +50,7 @@ import {
SPEC_TYPE_LABEL,
} from './ImageCanvasGenerationModel';
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel';
type ImageGenerationSubmissionOptions = {
dialog: GenerateDialogState;
@@ -135,9 +136,6 @@ function getDialogDefaultPrompt(mode: GenerateDialogState['mode']) {
if (mode === 'edit') {
return '修改当前图片';
}
if (mode === 'audio-sound-effect') {
return '游戏音效';
}
return 'AI 生成图片';
}
@@ -252,8 +250,20 @@ export function buildImageGenerationSubmissionPlan({
};
}
const normalizedPrompt =
dialog.prompt.trim() || getDialogDefaultPrompt(dialog.mode);
const soundEffectPrompt =
dialog.mode === 'audio-sound-effect'
? validateSoundEffectPrompt(dialog.prompt)
: null;
if (soundEffectPrompt && !soundEffectPrompt.ok) {
throw new Error(
soundEffectPrompt.reason === 'empty'
? '音效描述不能为空'
: '音效描述不能超过 2048 个字符',
);
}
const normalizedPrompt = soundEffectPrompt
? soundEffectPrompt.prompt
: dialog.prompt.trim() || getDialogDefaultPrompt(dialog.mode);
if (dialog.mode === 'edit') {
const sourceLayer = layers.find(
@@ -533,7 +543,7 @@ export function buildImageGenerationSubmissionPlan({
}
if (dialog.mode === 'audio-sound-effect') {
const soundModel = dialog.soundModel ?? DEFAULT_SOUND_EFFECT_MODEL;
const soundModel = EDITOR_SOUND_EFFECT_MODEL;
const durationSeconds =
typeof dialog.soundDurationSeconds === 'number'
? Math.min(10, Math.max(2, Math.round(dialog.soundDurationSeconds)))
@@ -546,6 +556,7 @@ export function buildImageGenerationSubmissionPlan({
prompt: normalizedPrompt,
model: soundModel,
duration: durationSeconds,
loop: false,
},
result: {
title: resolveGenerationAssetLabel(
@@ -190,6 +190,34 @@ describe('ImageCanvasLayerCommandModel', () => {
expect(removeCanvasLayers(layers, ['first'])).toEqual([layers[1]]);
});
it('deep-clones authoritative SFX metadata with generation inputs', () => {
const layer = createLayer({
id: 'sound-effect',
generationInputs: {
fields: [{ title: '用户描述', value: '金币落地' }],
references: [],
soundEffect: {
schemaVersion: 2,
userPrompt: '金币落地',
actualPrompt: 'A coin landing sound',
model: 'eleven_text_to_sound_v2',
durationMode: 'auto',
requestedDurationSeconds: null,
actualDurationSeconds: 4.8,
loop: false,
},
},
});
const clipboard = createCanvasLayerClipboard([layer], [layer.id], 'copy');
const cloned = clipboard?.layers[0];
expect(cloned?.generationInputs).toEqual(layer.generationInputs);
expect(cloned?.generationInputs).not.toBe(layer.generationInputs);
expect(cloned?.generationInputs?.soundEffect).not.toBe(
layer.generationInputs?.soundEffect,
);
});
it('moves layer z-indexes with the same commands as the context menu', () => {
const layers = [
createLayer({ id: 'bottom', zIndex: 1 }),
@@ -24,10 +24,14 @@ function cloneLayer(layer: CanvasLayer): CanvasLayer {
...layer,
generationInputs: layer.generationInputs
? {
...layer.generationInputs,
fields: layer.generationInputs.fields.map((field) => ({ ...field })),
references: layer.generationInputs.references.map((reference) => ({
...reference,
})),
...(layer.generationInputs.soundEffect
? { soundEffect: { ...layer.generationInputs.soundEffect } }
: {}),
}
: layer.generationInputs,
};
@@ -0,0 +1,222 @@
import { describe, expect, it } from 'vitest';
import {
appendSoundEffectPromptPreset,
SOUND_EFFECT_PROMPT_PRESET_GROUPS,
SOUND_EFFECT_PROMPT_PRESETS,
} from './ImageCanvasSoundEffectPresetModel';
describe('ImageCanvasSoundEffectPresetModel', () => {
it('freezes 40 event presets and 12 requirements with unique identities', () => {
expect(SOUND_EFFECT_PROMPT_PRESETS).toHaveLength(52);
expect(
SOUND_EFFECT_PROMPT_PRESETS.filter(
(preset) => preset.category === 'event',
),
).toHaveLength(40);
expect(
SOUND_EFFECT_PROMPT_PRESETS.filter(
(preset) => preset.category === 'requirement',
),
).toHaveLength(12);
expect(new Set(SOUND_EFFECT_PROMPT_PRESETS.map(({ id }) => id)).size).toBe(
52,
);
expect(
new Set(SOUND_EFFECT_PROMPT_PRESETS.map(({ label }) => label)).size,
).toBe(52);
expect(
new Set(SOUND_EFFECT_PROMPT_PRESETS.map(({ group }) => group)),
).toEqual(new Set(SOUND_EFFECT_PROMPT_PRESET_GROUPS));
});
it('locks the visible labels and prompt text in authoritative order', () => {
expect(
SOUND_EFFECT_PROMPT_PRESETS.map(({ category, group, label, prompt }) => [
category,
group,
label,
prompt,
]),
).toEqual([
['event', 'ui-operation', '轻触按钮', '柔和的按钮点击声'],
['event', 'ui-operation', '确认操作', '明亮的确认提示音'],
['event', 'ui-operation', '返回取消', '轻微下降的取消提示音'],
['event', 'ui-operation', '页面切换', '快速掠过的界面切换声'],
['event', 'ui-operation', '通知提醒', '清晰柔和的通知提示音'],
['event', 'ui-operation', '操作错误', '短促克制的错误提示音'],
['event', 'pickup-reward', '金币拾取', '金币拾取时清脆的金属叮当声'],
['event', 'pickup-reward', '道具拾取', '拾取道具时轻快的提示音'],
['event', 'pickup-reward', '获得奖励', '奖励出现时明亮的提示音'],
[
'event',
'pickup-reward',
'宝箱开启',
'金属锁扣弹开,随后响起明亮的奖励提示音',
],
[
'event',
'pickup-reward',
'解锁内容',
'锁定状态解除,随后响起解锁提示音',
],
['event', 'pickup-reward', '稀有掉落', '稀有物品出现时闪耀的奖励提示音'],
[
'event',
'growth-result',
'物品合成',
'两件物品融合,随后响起明亮的完成提示音',
],
[
'event',
'growth-result',
'角色升级',
'能量快速上升,随后响起明亮的升级提示音',
],
[
'event',
'growth-result',
'任务完成',
'任务完成提示音,随后响起简短的奖励音符',
],
[
'event',
'growth-result',
'成就达成',
'明亮的成就提示音,随后响起简短的庆祝音符',
],
[
'event',
'growth-result',
'挑战胜利',
'明亮的胜利提示音,随后响起短暂的庆祝音符',
],
['event', 'growth-result', '挑战失败', '低沉的失败提示音'],
['event', 'character-combat', '角色跳跃', '角色轻盈跳起的声音'],
['event', 'character-combat', '角色落地', '角色落地时轻微的撞击声'],
['event', 'character-combat', '轻度受击', '轻微撞击的受击声'],
['event', 'character-combat', '重度受击', '沉重有力的撞击声'],
['event', 'character-combat', '攻击挥动', '武器快速挥过空气的呼啸声'],
['event', 'character-combat', '攻击命中', '武器击中目标的清晰撞击声'],
['event', 'character-combat', '格挡成功', '武器碰撞,随后被挡开的金属声'],
['event', 'character-combat', '物体破碎', '物体撞击地面后快速破碎的声音'],
['event', 'skill-status', '技能蓄力', '能量逐渐聚集的低沉嗡鸣声'],
[
'event',
'skill-status',
'魔法释放',
'柔和的魔法能量扩散,带有圆润空灵的闪光声',
],
[
'event',
'skill-status',
'治疗恢复',
'柔和能量扩散,带有温暖圆润的提示音',
],
['event', 'skill-status', '护盾生成', '能量向外展开,形成稳定的护盾声'],
[
'event',
'skill-status',
'瞬间移动',
'能量快速收缩,随后以短促的空气抽离声消失',
],
[
'event',
'skill-status',
'冰冻技能',
'冰霜能量扩散,随后响起清脆的冻结声',
],
['event', 'skill-status', '火焰技能', '火焰迅速喷发,带有短促的燃烧声'],
[
'event',
'skill-status',
'状态强化',
'能量逐渐上升,形成稳定明亮的提示音',
],
[
'event',
'mechanism-scene-interaction',
'门开启',
'门锁解除,随后厚重的木门缓慢打开',
],
[
'event',
'mechanism-scene-interaction',
'机关启动',
'机关解锁,随后齿轮开始转动',
],
[
'event',
'mechanism-scene-interaction',
'拉杆触发',
'拉杆被扳动,随后远处机关启动',
],
[
'event',
'mechanism-scene-interaction',
'石块移动',
'大型石块缓慢移动时低沉的摩擦声',
],
[
'event',
'mechanism-scene-interaction',
'传送门开启',
'能量旋转聚集,随后响起持续、空灵的传送门展开声',
],
[
'event',
'mechanism-scene-interaction',
'倒计时警告',
'逐渐加快的倒计时提示音',
],
['requirement', 'style-direction', '休闲可爱', '轻快可爱的卡通风格'],
['requirement', 'style-direction', '复古街机', '复古街机风格'],
['requirement', 'style-direction', '科幻电子', '干净的科幻电子音色'],
['requirement', 'style-direction', '奇幻魔法', '柔和梦幻的魔法音色'],
['requirement', 'style-direction', '写实自然', '自然真实的声音质感'],
['requirement', 'style-direction', '卡通夸张', '夸张鲜明的卡通风格'],
['requirement', 'feedback-requirement', '轻柔反馈', '轻柔克制'],
['requirement', 'feedback-requirement', '有力反馈', '更有力的撞击感'],
['requirement', 'feedback-requirement', '短促反馈', '短促的单次声音'],
['requirement', 'feedback-requirement', '两段递进', '由弱到强的两段变化'],
[
'requirement',
'feedback-requirement',
'干净突出',
'主体声音清晰,减少杂音',
],
[
'requirement',
'feedback-requirement',
'柔和不刺耳',
'圆润柔和,避免尖锐高频',
],
]);
});
it('canonicalizes and appends with the frozen Unicode punctuation rule', () => {
const preset = SOUND_EFFECT_PROMPT_PRESETS[40];
expect(appendSoundEffectPromptPreset('\u0085\u2003', preset)).toBe(
preset.prompt,
);
expect(appendSoundEffectPromptPreset('金币落地', preset)).toBe(
`金币落地,${preset.prompt}`,
);
expect(appendSoundEffectPromptPreset('金币落地!\u0085', preset)).toBe(
`金币落地!${preset.prompt}`,
);
});
it('allows duplicates and preserves overlong text without truncation', () => {
const preset = SOUND_EFFECT_PROMPT_PRESETS[0];
const once = appendSoundEffectPromptPreset('按钮', preset);
expect(appendSoundEffectPromptPreset(once, preset)).toBe(
`按钮,${preset.prompt}${preset.prompt}`,
);
const overlong = '声'.repeat(2048);
const appended = appendSoundEffectPromptPreset(overlong, preset);
expect(appended).toBe(`${overlong}${preset.prompt}`);
expect(Array.from(appended).length).toBeGreaterThan(2048);
});
});
@@ -0,0 +1,423 @@
import { canonicalizeSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel';
export type SoundEffectPromptPresetCategory = 'event' | 'requirement';
export type SoundEffectPromptPresetGroup =
| 'ui-operation'
| 'pickup-reward'
| 'growth-result'
| 'character-combat'
| 'skill-status'
| 'mechanism-scene-interaction'
| 'style-direction'
| 'feedback-requirement';
export type SoundEffectPromptPreset = {
id: string;
category: SoundEffectPromptPresetCategory;
group: SoundEffectPromptPresetGroup;
label: string;
prompt: string;
};
const UNICODE_PUNCTUATION_CODE_POINT = /^\p{Punctuation}$/u;
const SOUND_EFFECT_PRESET_SEPARATOR = '';
export const SOUND_EFFECT_PROMPT_PRESET_GROUPS = [
'ui-operation',
'pickup-reward',
'growth-result',
'character-combat',
'skill-status',
'mechanism-scene-interaction',
'style-direction',
'feedback-requirement',
] as const satisfies readonly SoundEffectPromptPresetGroup[];
/** 权威设计「SFX 生成优化 V2.0」冻结的 40 个事件预设与 12 个补充要求。 */
export const SOUND_EFFECT_PROMPT_PRESETS = [
{
id: 'button-tap',
category: 'event',
group: 'ui-operation',
label: '轻触按钮',
prompt: '柔和的按钮点击声',
},
{
id: 'operation-confirm',
category: 'event',
group: 'ui-operation',
label: '确认操作',
prompt: '明亮的确认提示音',
},
{
id: 'back-cancel',
category: 'event',
group: 'ui-operation',
label: '返回取消',
prompt: '轻微下降的取消提示音',
},
{
id: 'page-transition',
category: 'event',
group: 'ui-operation',
label: '页面切换',
prompt: '快速掠过的界面切换声',
},
{
id: 'notification',
category: 'event',
group: 'ui-operation',
label: '通知提醒',
prompt: '清晰柔和的通知提示音',
},
{
id: 'operation-error',
category: 'event',
group: 'ui-operation',
label: '操作错误',
prompt: '短促克制的错误提示音',
},
{
id: 'coin-pickup',
category: 'event',
group: 'pickup-reward',
label: '金币拾取',
prompt: '金币拾取时清脆的金属叮当声',
},
{
id: 'item-pickup',
category: 'event',
group: 'pickup-reward',
label: '道具拾取',
prompt: '拾取道具时轻快的提示音',
},
{
id: 'reward-received',
category: 'event',
group: 'pickup-reward',
label: '获得奖励',
prompt: '奖励出现时明亮的提示音',
},
{
id: 'chest-open',
category: 'event',
group: 'pickup-reward',
label: '宝箱开启',
prompt: '金属锁扣弹开,随后响起明亮的奖励提示音',
},
{
id: 'content-unlock',
category: 'event',
group: 'pickup-reward',
label: '解锁内容',
prompt: '锁定状态解除,随后响起解锁提示音',
},
{
id: 'rare-drop',
category: 'event',
group: 'pickup-reward',
label: '稀有掉落',
prompt: '稀有物品出现时闪耀的奖励提示音',
},
{
id: 'item-craft',
category: 'event',
group: 'growth-result',
label: '物品合成',
prompt: '两件物品融合,随后响起明亮的完成提示音',
},
{
id: 'character-level-up',
category: 'event',
group: 'growth-result',
label: '角色升级',
prompt: '能量快速上升,随后响起明亮的升级提示音',
},
{
id: 'quest-complete',
category: 'event',
group: 'growth-result',
label: '任务完成',
prompt: '任务完成提示音,随后响起简短的奖励音符',
},
{
id: 'achievement-unlocked',
category: 'event',
group: 'growth-result',
label: '成就达成',
prompt: '明亮的成就提示音,随后响起简短的庆祝音符',
},
{
id: 'challenge-victory',
category: 'event',
group: 'growth-result',
label: '挑战胜利',
prompt: '明亮的胜利提示音,随后响起短暂的庆祝音符',
},
{
id: 'challenge-defeat',
category: 'event',
group: 'growth-result',
label: '挑战失败',
prompt: '低沉的失败提示音',
},
{
id: 'character-jump',
category: 'event',
group: 'character-combat',
label: '角色跳跃',
prompt: '角色轻盈跳起的声音',
},
{
id: 'character-land',
category: 'event',
group: 'character-combat',
label: '角色落地',
prompt: '角色落地时轻微的撞击声',
},
{
id: 'light-hit',
category: 'event',
group: 'character-combat',
label: '轻度受击',
prompt: '轻微撞击的受击声',
},
{
id: 'heavy-hit',
category: 'event',
group: 'character-combat',
label: '重度受击',
prompt: '沉重有力的撞击声',
},
{
id: 'attack-swing',
category: 'event',
group: 'character-combat',
label: '攻击挥动',
prompt: '武器快速挥过空气的呼啸声',
},
{
id: 'attack-hit',
category: 'event',
group: 'character-combat',
label: '攻击命中',
prompt: '武器击中目标的清晰撞击声',
},
{
id: 'block-success',
category: 'event',
group: 'character-combat',
label: '格挡成功',
prompt: '武器碰撞,随后被挡开的金属声',
},
{
id: 'object-break',
category: 'event',
group: 'character-combat',
label: '物体破碎',
prompt: '物体撞击地面后快速破碎的声音',
},
{
id: 'skill-charge',
category: 'event',
group: 'skill-status',
label: '技能蓄力',
prompt: '能量逐渐聚集的低沉嗡鸣声',
},
{
id: 'magic-cast',
category: 'event',
group: 'skill-status',
label: '魔法释放',
prompt: '柔和的魔法能量扩散,带有圆润空灵的闪光声',
},
{
id: 'healing',
category: 'event',
group: 'skill-status',
label: '治疗恢复',
prompt: '柔和能量扩散,带有温暖圆润的提示音',
},
{
id: 'shield-create',
category: 'event',
group: 'skill-status',
label: '护盾生成',
prompt: '能量向外展开,形成稳定的护盾声',
},
{
id: 'teleport',
category: 'event',
group: 'skill-status',
label: '瞬间移动',
prompt: '能量快速收缩,随后以短促的空气抽离声消失',
},
{
id: 'ice-skill',
category: 'event',
group: 'skill-status',
label: '冰冻技能',
prompt: '冰霜能量扩散,随后响起清脆的冻结声',
},
{
id: 'fire-skill',
category: 'event',
group: 'skill-status',
label: '火焰技能',
prompt: '火焰迅速喷发,带有短促的燃烧声',
},
{
id: 'status-buff',
category: 'event',
group: 'skill-status',
label: '状态强化',
prompt: '能量逐渐上升,形成稳定明亮的提示音',
},
{
id: 'door-open',
category: 'event',
group: 'mechanism-scene-interaction',
label: '门开启',
prompt: '门锁解除,随后厚重的木门缓慢打开',
},
{
id: 'mechanism-start',
category: 'event',
group: 'mechanism-scene-interaction',
label: '机关启动',
prompt: '机关解锁,随后齿轮开始转动',
},
{
id: 'lever-trigger',
category: 'event',
group: 'mechanism-scene-interaction',
label: '拉杆触发',
prompt: '拉杆被扳动,随后远处机关启动',
},
{
id: 'stone-move',
category: 'event',
group: 'mechanism-scene-interaction',
label: '石块移动',
prompt: '大型石块缓慢移动时低沉的摩擦声',
},
{
id: 'portal-open',
category: 'event',
group: 'mechanism-scene-interaction',
label: '传送门开启',
prompt: '能量旋转聚集,随后响起持续、空灵的传送门展开声',
},
{
id: 'countdown-warning',
category: 'event',
group: 'mechanism-scene-interaction',
label: '倒计时警告',
prompt: '逐渐加快的倒计时提示音',
},
{
id: 'casual-cute',
category: 'requirement',
group: 'style-direction',
label: '休闲可爱',
prompt: '轻快可爱的卡通风格',
},
{
id: 'retro-arcade',
category: 'requirement',
group: 'style-direction',
label: '复古街机',
prompt: '复古街机风格',
},
{
id: 'sci-fi-electronic',
category: 'requirement',
group: 'style-direction',
label: '科幻电子',
prompt: '干净的科幻电子音色',
},
{
id: 'fantasy-magic',
category: 'requirement',
group: 'style-direction',
label: '奇幻魔法',
prompt: '柔和梦幻的魔法音色',
},
{
id: 'realistic-natural',
category: 'requirement',
group: 'style-direction',
label: '写实自然',
prompt: '自然真实的声音质感',
},
{
id: 'cartoon-exaggerated',
category: 'requirement',
group: 'style-direction',
label: '卡通夸张',
prompt: '夸张鲜明的卡通风格',
},
{
id: 'gentle-feedback',
category: 'requirement',
group: 'feedback-requirement',
label: '轻柔反馈',
prompt: '轻柔克制',
},
{
id: 'strong-feedback',
category: 'requirement',
group: 'feedback-requirement',
label: '有力反馈',
prompt: '更有力的撞击感',
},
{
id: 'short-feedback',
category: 'requirement',
group: 'feedback-requirement',
label: '短促反馈',
prompt: '短促的单次声音',
},
{
id: 'two-stage-rise',
category: 'requirement',
group: 'feedback-requirement',
label: '两段递进',
prompt: '由弱到强的两段变化',
},
{
id: 'clean-prominent',
category: 'requirement',
group: 'feedback-requirement',
label: '干净突出',
prompt: '主体声音清晰,减少杂音',
},
{
id: 'soft-not-harsh',
category: 'requirement',
group: 'feedback-requirement',
label: '柔和不刺耳',
prompt: '圆润柔和,避免尖锐高频',
},
] as const satisfies readonly SoundEffectPromptPreset[];
function readLastCodePoint(value: string) {
const codePoints = Array.from(value);
return codePoints[codePoints.length - 1];
}
export function appendSoundEffectPromptPreset(
currentPrompt: string,
preset: SoundEffectPromptPreset,
) {
const canonicalPrompt = canonicalizeSoundEffectPrompt(currentPrompt);
if (!canonicalPrompt) {
return preset.prompt;
}
const lastCodePoint = readLastCodePoint(canonicalPrompt);
if (lastCodePoint && UNICODE_PUNCTUATION_CODE_POINT.test(lastCodePoint)) {
return `${canonicalPrompt}${preset.prompt}`;
}
return `${canonicalPrompt}${SOUND_EFFECT_PRESET_SEPARATOR}${preset.prompt}`;
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import soundEffectPromptCanonicalizationCases from '../../../packages/shared/test-fixtures/sound-effect-prompt-canonicalization.json';
import {
canonicalizeSoundEffectPrompt,
countSoundEffectPromptCodePoints,
SOUND_EFFECT_PROMPT_MAX_CODE_POINTS,
validateSoundEffectPrompt,
} from './ImageCanvasSoundEffectPromptModel';
describe('ImageCanvasSoundEffectPromptModel', () => {
it('matches the shared Unicode canonicalization fixtures', () => {
for (const fixture of soundEffectPromptCanonicalizationCases) {
expect(canonicalizeSoundEffectPrompt(fixture.input), fixture.name).toBe(
fixture.prompt,
);
expect(
countSoundEffectPromptCodePoints(fixture.input),
fixture.name,
).toBe(fixture.charCount);
expect(
canonicalizeSoundEffectPrompt(
canonicalizeSoundEffectPrompt(fixture.input),
),
fixture.name,
).toBe(fixture.prompt);
}
});
it('accepts 2048 code points and rejects 2049 without truncating', () => {
const maximum = `\u0085${'😀'.repeat(
SOUND_EFFECT_PROMPT_MAX_CODE_POINTS,
)}\u2003`;
const accepted = validateSoundEffectPrompt(maximum);
expect(accepted).toEqual({
ok: true,
prompt: '😀'.repeat(SOUND_EFFECT_PROMPT_MAX_CODE_POINTS),
charCount: SOUND_EFFECT_PROMPT_MAX_CODE_POINTS,
});
const overlong = '声'.repeat(SOUND_EFFECT_PROMPT_MAX_CODE_POINTS + 1);
expect(validateSoundEffectPrompt(overlong)).toEqual({
ok: false,
prompt: overlong,
charCount: SOUND_EFFECT_PROMPT_MAX_CODE_POINTS + 1,
reason: 'too-long',
});
});
it('rejects empty canonical prompts without adding a default description', () => {
for (const prompt of ['', ' \t\r\n', '\u0085\u2003\u00a0']) {
expect(validateSoundEffectPrompt(prompt)).toEqual({
ok: false,
prompt: '',
charCount: 0,
reason: 'empty',
});
}
});
});
@@ -0,0 +1,55 @@
export const SOUND_EFFECT_PROMPT_MAX_CODE_POINTS = 2048;
const LEADING_UNICODE_WHITE_SPACE = /^\p{White_Space}+/u;
const TRAILING_UNICODE_WHITE_SPACE = /\p{White_Space}+$/u;
export type SoundEffectPromptValidationResult =
| {
ok: true;
prompt: string;
charCount: number;
}
| {
ok: false;
prompt: string;
charCount: number;
reason: 'empty' | 'too-long';
};
/**
* 只删除首尾 Unicode `White_Space`。内部空白、组合字符、ZWJ、U+200B、U+FEFF
* 及其它 code point 均原样保留;不能替换为语义不同的原生 `trim()`。
*/
export function canonicalizeSoundEffectPrompt(prompt: string) {
return prompt
.replace(LEADING_UNICODE_WHITE_SPACE, '')
.replace(TRAILING_UNICODE_WHITE_SPACE, '');
}
export function countSoundEffectPromptCodePoints(prompt: string) {
return Array.from(canonicalizeSoundEffectPrompt(prompt)).length;
}
export function validateSoundEffectPrompt(
prompt: string,
): SoundEffectPromptValidationResult {
const canonicalPrompt = canonicalizeSoundEffectPrompt(prompt);
const charCount = Array.from(canonicalPrompt).length;
if (charCount === 0) {
return {
ok: false,
prompt: canonicalPrompt,
charCount,
reason: 'empty',
};
}
if (charCount > SOUND_EFFECT_PROMPT_MAX_CODE_POINTS) {
return {
ok: false,
prompt: canonicalPrompt,
charCount,
reason: 'too-long',
};
}
return { ok: true, prompt: canonicalPrompt, charCount };
}
@@ -1752,9 +1752,9 @@ describe('editorProjectClient', () => {
height: 120,
sourceType: 'generated',
prompt: '金币掉落叮当声',
actualPrompt: '金币掉落叮当声',
model: 'audio1.0',
provider: 'VectorEngine',
actualPrompt: 'A bright coin pickup chime',
model: 'eleven_text_to_sound_v2',
provider: 'elevenlabs',
taskId: 'sound-task-1',
audioKind: 'sound-effect',
asset: {
@@ -1771,8 +1771,9 @@ describe('editorProjectClient', () => {
const result = await generateEditorSoundEffect({
prompt: '金币掉落叮当声',
model: 'audio1.0',
duration: 7,
model: 'eleven_text_to_sound_v2',
duration: 7.5,
loop: true,
assetFolderId: 'project',
assetLabel: '游戏音效 1',
});
@@ -1786,8 +1787,9 @@ describe('editorProjectClient', () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: '金币掉落叮当声',
model: 'audio1.0',
duration: 7,
model: 'eleven_text_to_sound_v2',
duration: 7.5,
loop: true,
assetFolderId: 'project',
assetLabel: '游戏音效 1',
}),
@@ -1800,24 +1802,23 @@ describe('editorProjectClient', () => {
);
});
it('sends editor sound effect duration to Vidu instead of legacy type and tempo', async () => {
it('canonicalizes omitted duration to null and omitted loop to false', async () => {
requestJsonMock.mockResolvedValueOnce({
audioSrc: '/generated-character-drafts/editor-audios/sfx-null.mp3',
width: 420,
height: 120,
sourceType: 'generated',
prompt: '按钮确认短促音',
actualPrompt: '按钮确认短促音',
model: 'audio1.0',
provider: 'VectorEngine',
actualPrompt: 'A short confirmation click',
model: 'eleven_text_to_sound_v2',
provider: 'elevenlabs',
taskId: 'sound-task-null',
audioKind: 'sound-effect',
});
await generateEditorSoundEffect({
prompt: '按钮确认短促音',
model: 'audio1.0',
duration: 5,
model: 'eleven_text_to_sound_v2',
});
expect(requestJsonMock).toHaveBeenCalledWith(
@@ -1825,8 +1826,9 @@ describe('editorProjectClient', () => {
expect.objectContaining({
body: JSON.stringify({
prompt: '按钮确认短促音',
model: 'audio1.0',
duration: 5,
model: 'eleven_text_to_sound_v2',
duration: null,
loop: false,
}),
}),
'生成游戏音效失败',
@@ -1834,6 +1836,66 @@ describe('editorProjectClient', () => {
);
});
it('accepts the 0.5 and 30 second sound effect duration boundaries', async () => {
requestJsonMock.mockResolvedValue({});
for (const duration of [0.5, 30]) {
await generateEditorSoundEffect({
prompt: '按钮确认短促音',
model: 'eleven_text_to_sound_v2',
duration,
});
}
expect(requestJsonMock).toHaveBeenNthCalledWith(
1,
'/api/editor/audios/sound-effects/generations',
expect.objectContaining({
body: JSON.stringify({
prompt: '按钮确认短促音',
model: 'eleven_text_to_sound_v2',
duration: 0.5,
loop: false,
}),
}),
'生成游戏音效失败',
expect.any(Object),
);
expect(requestJsonMock).toHaveBeenNthCalledWith(
2,
'/api/editor/audios/sound-effects/generations',
expect.objectContaining({
body: JSON.stringify({
prompt: '按钮确认短促音',
model: 'eleven_text_to_sound_v2',
duration: 30,
loop: false,
}),
}),
'生成游戏音效失败',
expect.any(Object),
);
});
it('rejects invalid sound effect durations before the request', async () => {
for (const duration of [
0.49,
30.01,
Number.NaN,
Number.POSITIVE_INFINITY,
]) {
await expect(
generateEditorSoundEffect({
prompt: '按钮确认短促音',
model: 'eleven_text_to_sound_v2',
duration,
}),
).rejects.toThrow('游戏音效时长必须在 0.5-30 秒之间');
}
expect(requestJsonMock).not.toHaveBeenCalled();
});
it('generates editor background music through the backend BFF', async () => {
const representativeComplexPromptCase =
backgroundMusicPromptCanonicalizationCases.find(
@@ -1,6 +1,10 @@
import type {
BackgroundMusicPromptAssistRequest,
BackgroundMusicPromptAssistResponse,
import {
type BackgroundMusicPromptAssistRequest,
type BackgroundMusicPromptAssistResponse,
type EditorSoundEffectGenerationMetadataV2,
type EditorSoundEffectGenerationRequest,
SOUND_EFFECT_DURATION_MAX_SECONDS,
SOUND_EFFECT_DURATION_MIN_SECONDS,
} from '../../../packages/shared/src/contracts/editorAudio';
import type { ExternalGenerationJobStatusRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import { requestJson } from '../apiClient';
@@ -125,6 +129,7 @@ export type EditorAssetGenerationInputReference = {
export type EditorAssetGenerationInputs = {
fields: EditorAssetGenerationInputField[];
references: EditorAssetGenerationInputReference[];
soundEffect?: EditorSoundEffectGenerationMetadataV2;
[key: string]: unknown;
};
@@ -547,16 +552,14 @@ export type EditorVideoGenerationResult = {
queueState?: ExternalGenerationJobStatusRecord | null;
};
export type EditorSoundEffectGenerationInput = {
prompt: string;
model: 'audio1.0';
duration: number;
projectId?: string | null;
canvasCompletion?: EditorCanvasGenerationCompletionInput | null;
generationInputs?: EditorAssetGenerationInputs | null;
assetFolderId?: string | null;
assetLabel?: string | null;
};
export type EditorSoundEffectGenerationInput =
EditorSoundEffectGenerationRequest & {
projectId?: string | null;
canvasCompletion?: EditorCanvasGenerationCompletionInput | null;
generationInputs?: EditorAssetGenerationInputs | null;
assetFolderId?: string | null;
assetLabel?: string | null;
};
export type EditorBackgroundMusicGenerationInput = {
gptDescriptionPrompt: string;
@@ -582,10 +585,12 @@ export type EditorAudioGenerationResult = {
prompt: string;
actualPrompt?: string | null;
model: string;
provider: string;
taskId: string;
priceMudPoints: number;
audioKind: 'sound-effect' | 'background-music';
durationSeconds?: number | null;
loop?: boolean | null;
resource?: EditorProjectResourceSnapshot | null;
asset?: EditorAssetSnapshot | null;
project?: EditorProjectSnapshot | null;
@@ -1353,12 +1358,22 @@ export async function generateEditorVideo(input: EditorVideoGenerationInput) {
export async function generateEditorSoundEffect(
input: EditorSoundEffectGenerationInput,
) {
const duration = input.duration ?? null;
if (
duration !== null &&
(!Number.isFinite(duration) ||
duration < SOUND_EFFECT_DURATION_MIN_SECONDS ||
duration > SOUND_EFFECT_DURATION_MAX_SECONDS)
) {
throw new Error('游戏音效时长必须在 0.5-30 秒之间');
}
return requestJson<EditorAudioGenerationResponse>(
EDITOR_SOUND_EFFECT_GENERATION_API,
jsonRequest('POST', {
prompt: input.prompt,
model: input.model,
duration: input.duration,
duration,
loop: input.loop ?? false,
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.canvasCompletion
? { canvasCompletion: input.canvasCompletion }