基础:统一BGM提示词规则与助手契约

新增前端 Unicode code point 与有效字符计数规则
新增 Rust BGM 原样校验并覆盖生成和补全边界
新增 Prompt 助手 camelCase 内部 DTO 与契约测试
补齐跨语言 Unicode、200 字和 SFX 回归测试
This commit is contained in:
2026-08-04 07:28:23 +00:00
parent 8daf85df6c
commit dafcfbe6b7
6 changed files with 313 additions and 1 deletions
@@ -0,0 +1,76 @@
use crate::{AudioError, SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS};
const BACKGROUND_MUSIC_GENERATION_PROMPT_FIELD: &str = "gpt_description_prompt";
const BACKGROUND_MUSIC_COMPLETION_PROMPT_FIELD: &str = "currentPrompt";
const BACKGROUND_MUSIC_GENERATION_MIN_EFFECTIVE_CHARS: usize = 1;
const BACKGROUND_MUSIC_COMPLETION_MIN_EFFECTIVE_CHARS: usize = 2;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ValidatedBackgroundMusicPrompt<'a> {
/// The exact caller-provided string. Validation never trims or normalizes it.
pub prompt: &'a str,
pub char_count: usize,
pub effective_char_count: usize,
}
/// Counts Unicode scalar values, matching TypeScript `Array.from(prompt).length`.
pub fn background_music_prompt_char_count(prompt: &str) -> usize {
prompt.chars().count()
}
/// Counts code points outside the Unicode `White_Space` property.
///
/// U+0085 is whitespace, while U+200B and U+FEFF remain effective characters.
pub fn background_music_prompt_effective_char_count(prompt: &str) -> usize {
prompt
.chars()
.filter(|character| !character.is_whitespace())
.count()
}
pub fn validate_background_music_generation_prompt(
prompt: &str,
) -> Result<ValidatedBackgroundMusicPrompt<'_>, AudioError> {
validate_background_music_prompt(
prompt,
BACKGROUND_MUSIC_GENERATION_PROMPT_FIELD,
BACKGROUND_MUSIC_GENERATION_MIN_EFFECTIVE_CHARS,
)
}
pub fn validate_background_music_completion_prompt(
prompt: &str,
) -> Result<ValidatedBackgroundMusicPrompt<'_>, AudioError> {
validate_background_music_prompt(
prompt,
BACKGROUND_MUSIC_COMPLETION_PROMPT_FIELD,
BACKGROUND_MUSIC_COMPLETION_MIN_EFFECTIVE_CHARS,
)
}
fn validate_background_music_prompt<'a>(
prompt: &'a str,
field: &'static str,
min_effective_chars: usize,
) -> Result<ValidatedBackgroundMusicPrompt<'a>, AudioError> {
let char_count = background_music_prompt_char_count(prompt);
let effective_char_count = background_music_prompt_effective_char_count(prompt);
if char_count > SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS {
return Err(AudioError::invalid_request(format!(
"{field} 超过 {} 字符",
SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS
)));
}
if effective_char_count < min_effective_chars {
return Err(AudioError::invalid_request(format!(
"{field} 至少需要 {min_effective_chars} 个有效字符"
)));
}
Ok(ValidatedBackgroundMusicPrompt {
prompt,
char_count,
effective_char_count,
})
}
@@ -1,3 +1,4 @@
mod background_music_prompt;
mod client;
mod download;
mod error;
@@ -6,6 +7,11 @@ mod request;
mod response;
mod types;
pub use background_music_prompt::{
ValidatedBackgroundMusicPrompt, background_music_prompt_char_count,
background_music_prompt_effective_char_count, validate_background_music_completion_prompt,
validate_background_music_generation_prompt,
};
pub use client::{
build_vector_engine_audio_http_client, resolve_audio_task_download_urls,
submit_background_music_task, submit_editor_background_music_task,
@@ -1,13 +1,96 @@
use platform_audio::{
AudioTaskKind, BackgroundMusicTaskRequest, EditorBackgroundMusicTaskRequest,
EditorSoundEffectTaskRequest, SUNO_DEFAULT_MODEL, VIDU_AUDIO_MODEL, VIDU_PROMPT_MAX_CHARS,
audio_mime_to_extension, build_background_music_task_body,
audio_mime_to_extension, background_music_prompt_char_count,
background_music_prompt_effective_char_count, build_background_music_task_body,
build_editor_background_music_task_body, build_editor_sound_effect_task_body,
build_sound_effect_task_body, extract_audio_urls, is_failed_task_status,
is_pending_task_status, normalize_audio_mime_type, normalize_task_status,
validate_background_music_completion_prompt, validate_background_music_generation_prompt,
};
use serde_json::json;
#[test]
fn background_music_prompt_counts_unicode_code_points_and_effective_characters() {
let prompt = " \t\n\u{00a0}\u{2003}\u{200b}😀";
assert_eq!(background_music_prompt_char_count(prompt), 7);
assert_eq!(background_music_prompt_effective_char_count(prompt), 2);
assert_eq!(background_music_prompt_char_count("\r\n"), 2);
assert_eq!(background_music_prompt_effective_char_count("\r\n"), 0);
assert_eq!(background_music_prompt_char_count("e\u{0301}"), 2);
assert_eq!(background_music_prompt_effective_char_count("e\u{0301}"), 2);
assert_eq!(background_music_prompt_char_count("👩‍💻"), 3);
assert_eq!(background_music_prompt_effective_char_count("👩‍💻"), 3);
assert_eq!(background_music_prompt_char_count("\u{0085}"), 1);
assert_eq!(background_music_prompt_effective_char_count("\u{0085}"), 0);
assert_eq!(background_music_prompt_char_count("\u{feff}"), 1);
assert_eq!(background_music_prompt_effective_char_count("\u{feff}"), 1);
}
#[test]
fn background_music_generation_prompt_requires_one_effective_character_without_cleaning() {
for prompt in ["", " \t\r\n", "\u{00a0}\u{2003}"] {
let error = validate_background_music_generation_prompt(prompt)
.expect_err("Unicode-whitespace-only prompts should be rejected");
assert!(error.message().contains("至少需要 1 个有效字符"));
}
for prompt in ["乐", "\u{200b}", "😀"] {
let validated = validate_background_music_generation_prompt(prompt)
.expect("one non-whitespace code point should be accepted for generation");
assert_eq!(validated.prompt, prompt);
assert_eq!(validated.char_count, 1);
assert_eq!(validated.effective_char_count, 1);
}
let prompt = " 音\n\u{200b}😀 ";
let validated = validate_background_music_generation_prompt(prompt)
.expect("valid prompt should retain every original code point");
assert_eq!(validated.prompt, prompt);
assert_eq!(validated.char_count, 8);
assert_eq!(validated.effective_char_count, 3);
}
#[test]
fn background_music_completion_prompt_requires_two_effective_characters() {
for prompt in ["", "乐", "乐 \n", "\u{200b}", "😀"] {
let error = validate_background_music_completion_prompt(prompt)
.expect_err("completion should require two effective characters");
assert!(error.message().starts_with("currentPrompt "));
assert!(error.message().contains("至少需要 2 个有效字符"));
}
for prompt in ["音乐", "\u{200b}乐", "😀🎵", "👩‍💻"] {
let validated = validate_background_music_completion_prompt(prompt)
.expect("completion should accept at least two effective code points");
assert_eq!(validated.prompt, prompt);
assert!(validated.effective_char_count >= 2);
}
}
#[test]
fn background_music_prompt_validation_accepts_200_and_rejects_201_code_points() {
let max_prompt = "😀".repeat(200);
let generation = validate_background_music_generation_prompt(&max_prompt)
.expect("200 code points should be accepted for generation");
let completion = validate_background_music_completion_prompt(&max_prompt)
.expect("200 code points should be accepted for completion");
assert_eq!(generation.prompt, max_prompt);
assert_eq!(generation.char_count, 200);
assert_eq!(completion.char_count, 200);
let overlong_prompt = "😀".repeat(201);
for error in [
validate_background_music_generation_prompt(&overlong_prompt)
.expect_err("201 code points should be rejected for generation"),
validate_background_music_completion_prompt(&overlong_prompt)
.expect_err("201 code points should be rejected for completion"),
] {
assert!(error.message().contains("超过 200 字符"));
}
}
#[test]
fn normalizes_audio_mime_type_from_content_type_and_url() {
assert_eq!(
@@ -542,6 +542,19 @@ pub struct EditorBackgroundMusicGenerateRequest {
pub asset_label: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct BackgroundMusicPromptAssistRequest {
pub current_prompt: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct BackgroundMusicPromptAssistResponse {
pub prompt: String,
pub char_count: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorAudioGenerateResponse {
@@ -1442,6 +1455,45 @@ mod tests {
);
}
#[test]
fn background_music_prompt_assist_contract_uses_camel_case_shape() {
let request = BackgroundMusicPromptAssistRequest {
current_prompt: "\n 森林冒险背景音乐 ".to_string(),
};
let request_payload = serde_json::to_value(&request)
.expect("background music prompt assist request should serialize");
assert_eq!(
request_payload,
json!({
"currentPrompt": "\n 森林冒险背景音乐 ",
})
);
assert_eq!(
serde_json::from_value::<BackgroundMusicPromptAssistRequest>(request_payload)
.expect("background music prompt assist request should deserialize"),
request
);
let response = BackgroundMusicPromptAssistResponse {
prompt: "森林冒险背景音乐,温和推进并适合自然循环".to_string(),
char_count: 20,
};
let response_payload = serde_json::to_value(&response)
.expect("background music prompt assist response should serialize");
assert_eq!(
response_payload,
json!({
"prompt": "森林冒险背景音乐,温和推进并适合自然循环",
"charCount": 20,
})
);
assert_eq!(
serde_json::from_value::<BackgroundMusicPromptAssistResponse>(response_payload)
.expect("background music prompt assist response should deserialize"),
response
);
}
#[test]
fn character_workflow_cache_response_keeps_legacy_shape() {
let payload = serde_json::to_value(CharacterWorkflowCacheSaveResponse {
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import {
BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS,
BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS,
BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS,
canCompleteBackgroundMusicPrompt,
canGenerateBackgroundMusicFromPrompt,
countEffectivePromptCodePoints,
countPromptCodePoints,
} from './ImageCanvasBackgroundMusicPromptModel';
describe('ImageCanvasBackgroundMusicPromptModel', () => {
it('counts raw Unicode code points without normalizing the prompt', () => {
const combiningText = 'e\u0301';
const zwjEmoji = '👨‍👩‍👧‍👦';
const prompt = ` \r\n${combiningText}${zwjEmoji}\u200B\uFEFF `;
expect(countPromptCodePoints('')).toBe(0);
expect(countPromptCodePoints('\r\n')).toBe(2);
expect(countPromptCodePoints(combiningText)).toBe(2);
expect(countPromptCodePoints(zwjEmoji)).toBe(7);
expect(countPromptCodePoints(prompt)).toBe(15);
expect(prompt).toBe(` \r\n${combiningText}${zwjEmoji}\u200B\uFEFF `);
});
it('counts only non-Unicode-White_Space code points as effective', () => {
expect(countEffectivePromptCodePoints(' \t\r\n\u0085\u00A0\u3000')).toBe(0);
expect(countEffectivePromptCodePoints('e\u0301')).toBe(2);
expect(countEffectivePromptCodePoints('👨‍👩‍👧‍👦')).toBe(7);
expect(countEffectivePromptCodePoints('\uFEFF')).toBe(1);
expect(countEffectivePromptCodePoints('\u200B')).toBe(1);
expect(countEffectivePromptCodePoints('。1A')).toBe(3);
});
it('allows formal generation for 1-200 code points with effective content', () => {
expect(BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS).toBe(200);
expect(BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS).toBe(
1,
);
expect(canGenerateBackgroundMusicFromPrompt('')).toBe(false);
expect(canGenerateBackgroundMusicFromPrompt(' \r\n\u0085')).toBe(false);
expect(canGenerateBackgroundMusicFromPrompt('乐')).toBe(true);
expect(canGenerateBackgroundMusicFromPrompt('😀'.repeat(199))).toBe(true);
expect(canGenerateBackgroundMusicFromPrompt('😀'.repeat(200))).toBe(true);
expect(canGenerateBackgroundMusicFromPrompt('😀'.repeat(201))).toBe(false);
});
it('requires 2 effective code points and at most 200 total for AI completion', () => {
expect(BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS).toBe(
2,
);
expect(canCompleteBackgroundMusicPrompt(' \r\n\u0085')).toBe(false);
expect(canCompleteBackgroundMusicPrompt('乐')).toBe(false);
expect(canCompleteBackgroundMusicPrompt('乐 ')).toBe(false);
expect(canCompleteBackgroundMusicPrompt('乐曲')).toBe(true);
expect(canCompleteBackgroundMusicPrompt('😀'.repeat(200))).toBe(true);
expect(canCompleteBackgroundMusicPrompt('😀'.repeat(201))).toBe(false);
});
});
@@ -0,0 +1,35 @@
export const BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS = 200;
export const BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS = 1;
export const BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS = 2;
const UNICODE_WHITE_SPACE_CODE_POINT = /^\p{White_Space}$/u;
export function countPromptCodePoints(prompt: string) {
return Array.from(prompt).length;
}
export function countEffectivePromptCodePoints(prompt: string) {
let effectiveCodePoints = 0;
for (const codePoint of prompt) {
if (!UNICODE_WHITE_SPACE_CODE_POINT.test(codePoint)) {
effectiveCodePoints += 1;
}
}
return effectiveCodePoints;
}
export function canGenerateBackgroundMusicFromPrompt(prompt: string) {
return (
countPromptCodePoints(prompt) <= BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS &&
countEffectivePromptCodePoints(prompt) >=
BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS
);
}
export function canCompleteBackgroundMusicPrompt(prompt: string) {
return (
countPromptCodePoints(prompt) <= BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS &&
countEffectivePromptCodePoints(prompt) >=
BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS
);
}