diff --git a/server-rs/crates/platform-audio/src/background_music_prompt.rs b/server-rs/crates/platform-audio/src/background_music_prompt.rs new file mode 100644 index 000000000..ba5d43e73 --- /dev/null +++ b/server-rs/crates/platform-audio/src/background_music_prompt.rs @@ -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, 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, 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, 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, + }) +} diff --git a/server-rs/crates/platform-audio/src/lib.rs b/server-rs/crates/platform-audio/src/lib.rs index a686612a7..cb6a3b5dc 100644 --- a/server-rs/crates/platform-audio/src/lib.rs +++ b/server-rs/crates/platform-audio/src/lib.rs @@ -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, diff --git a/server-rs/crates/platform-audio/tests/vector_engine_audio.rs b/server-rs/crates/platform-audio/tests/vector_engine_audio.rs index bcdee944d..0aabc1c75 100644 --- a/server-rs/crates/platform-audio/tests/vector_engine_audio.rs +++ b/server-rs/crates/platform-audio/tests/vector_engine_audio.rs @@ -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!( diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index 6186d6253..02c8a6b29 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -542,6 +542,19 @@ pub struct EditorBackgroundMusicGenerateRequest { pub asset_label: Option, } +#[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::(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::(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 { diff --git a/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.test.ts b/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.test.ts new file mode 100644 index 000000000..0cd600dbd --- /dev/null +++ b/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.test.ts @@ -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); + }); +}); diff --git a/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.ts b/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.ts new file mode 100644 index 000000000..05d3075a5 --- /dev/null +++ b/src/components/image-editor/ImageCanvasBackgroundMusicPromptModel.ts @@ -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 + ); +}