实现 SFX V2 T1 契约与提示词基础
统一前后端 SFX Prompt Unicode canonicalization、字符计数和 2048 边界 固化 52 个音效预设、追加规则及跨语言测试向量 演进共享音频请求响应、duration、Loop 和 V2 metadata 契约 增加 External model 输入矩阵纯函数并保持正式接线归属 T5 补齐前端提交、读取、深拷贝与请求边界回归测试 更新共享计划并标记 T1 完成且当前不可发布
This commit is contained in:
@@ -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 音频 DTO:fixed 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.5–30s`,拒绝非有限值和越界值。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 秒之间"));
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user