基础:统一BGM提示词规则与助手契约
新增前端 Unicode code point 与有效字符计数规则 新增 Rust BGM 原样校验并覆盖生成和补全边界 新增 Prompt 助手 camelCase 内部 DTO 与契约测试 补齐跨语言 Unicode、200 字和 SFX 回归测试
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user