Update Match3D/image-generation docs & code
Adds/updates documentation, assets and implementation for Match3D and puzzle image generation workflows. Key changes: decision logs and pitfalls updated to prefer VectorEngine Gemini for Match3D material sheets and to require edits (multipart) for 1:1 container reference images; guidance added for when to use APIMart vs VectorEngine. .env.example clarified APIMart/Responses config. Many new public assets and PPT visuals added. Code changes across frontend and backend: updated shared contracts, server-rs match3d/puzzle/image-generation handlers, VectorEngine/OpenAI image generation clients, and multiple React components/tests to handle UI/background/container image signing, edits workflow, and puzzle UI background resolution. Added src/services/puzzle-runtime/puzzleUiBackgroundSource.ts and related test updates. Includes notes about multipart HTTP/1.1 requirement and test/verification commands in docs.
This commit is contained in:
@@ -90,12 +90,13 @@ use crate::{
|
||||
match3d::{
|
||||
click_match3d_item, compile_match3d_agent_draft, create_match3d_agent_session,
|
||||
delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up,
|
||||
generate_match3d_background_image_for_work, generate_match3d_cover_image,
|
||||
generate_match3d_item_assets_for_work, generate_match3d_work_tags,
|
||||
get_match3d_agent_session, get_match3d_run, get_match3d_work_detail, get_match3d_works,
|
||||
list_match3d_gallery, persist_match3d_generated_model, publish_match3d_work,
|
||||
put_match3d_audio_assets, put_match3d_work, restart_match3d_run, start_match3d_run,
|
||||
stop_match3d_run, stream_match3d_agent_message, submit_match3d_agent_message,
|
||||
generate_match3d_background_image_for_work, generate_match3d_container_image_for_work,
|
||||
generate_match3d_cover_image, generate_match3d_item_assets_for_work,
|
||||
generate_match3d_work_tags, get_match3d_agent_session, get_match3d_run,
|
||||
get_match3d_work_detail, get_match3d_works, list_match3d_gallery,
|
||||
persist_match3d_generated_model, publish_match3d_work, put_match3d_audio_assets,
|
||||
put_match3d_work, restart_match3d_run, start_match3d_run, stop_match3d_run,
|
||||
stream_match3d_agent_message, submit_match3d_agent_message,
|
||||
},
|
||||
password_entry::password_entry,
|
||||
password_management::{change_password, reset_password},
|
||||
@@ -969,10 +970,15 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.route(
|
||||
"/api/creation/match3d/works/{profile_id}/cover-image",
|
||||
post(generate_match3d_cover_image).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
post(generate_match3d_cover_image)
|
||||
// 中文注释:抓大鹅封面支持上传主图与多张参考图,沿用拼图参考图入口上限。
|
||||
.layer(DefaultBodyLimit::max(
|
||||
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/match3d/works/{profile_id}/background-image",
|
||||
@@ -980,6 +986,12 @@ pub fn build_router(state: AppState) -> Router {
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/match3d/works/{profile_id}/container-image",
|
||||
post(generate_match3d_container_image_for_work).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/match3d/works/{profile_id}/item-assets",
|
||||
post(generate_match3d_item_assets_for_work).route_layer(
|
||||
|
||||
@@ -165,6 +165,7 @@ pub(crate) fn should_skip_asset_operation_billing_for_connectivity(
|
||||
|| message.contains("Service Unavailable")
|
||||
|| message.contains("Failed to connect")
|
||||
|| message.contains("WebSocket")
|
||||
|| message.contains("No such procedure")
|
||||
|| message.contains("连接已断开")
|
||||
|| message.contains("连接在返回结果前已断开")
|
||||
}
|
||||
@@ -190,6 +191,11 @@ mod tests {
|
||||
"Failed to connect: HTTP error: 503 Service Unavailable".to_string(),
|
||||
),
|
||||
));
|
||||
assert!(should_skip_asset_operation_billing_for_connectivity(
|
||||
&SpacetimeClientError::Procedure(
|
||||
"No such procedure: consume_profile_wallet_points_and_return".to_string(),
|
||||
),
|
||||
));
|
||||
assert!(!should_skip_asset_operation_billing_for_connectivity(
|
||||
&SpacetimeClientError::Procedure("泥点余额不足".to_string()),
|
||||
));
|
||||
|
||||
@@ -118,7 +118,7 @@ pub(crate) fn test_creation_entry_config_response()
|
||||
test_creation_type("puzzle", true, true, 30),
|
||||
test_creation_type("match3d", true, true, 40),
|
||||
test_creation_type("square-hole", false, true, 50),
|
||||
test_creation_type("visual-novel", true, false, 60),
|
||||
test_creation_type("visual-novel", false, false, 60),
|
||||
test_creation_type("airp", true, false, 70),
|
||||
test_creation_type("creative-agent", false, true, 80),
|
||||
],
|
||||
|
||||
@@ -109,6 +109,7 @@ fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) {
|
||||
StatusCode::UNAUTHORIZED => ("UNAUTHORIZED", "未授权访问"),
|
||||
StatusCode::FORBIDDEN => ("FORBIDDEN", "禁止访问"),
|
||||
StatusCode::NOT_FOUND => ("NOT_FOUND", "资源不存在"),
|
||||
StatusCode::GONE => ("GONE", "资源已失效"),
|
||||
StatusCode::NOT_IMPLEMENTED => ("NOT_IMPLEMENTED", "功能暂未实现"),
|
||||
StatusCode::CONFLICT => ("CONFLICT", "请求冲突"),
|
||||
StatusCode::TOO_MANY_REQUESTS => ("TOO_MANY_REQUESTS", "请求过于频繁"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,13 @@ pub(crate) struct DownloadedOpenAiImage {
|
||||
pub extension: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct OpenAiReferenceImage {
|
||||
pub bytes: Vec<u8>,
|
||||
pub mime_type: String,
|
||||
pub file_name: String,
|
||||
}
|
||||
|
||||
// 中文注释:RPG、方洞等图片资产统一走 VectorEngine GPT-image-2-all,避免把密钥或供应商协议暴露到前端。
|
||||
pub(crate) fn require_openai_image_settings(
|
||||
state: &AppState,
|
||||
@@ -75,6 +82,8 @@ pub(crate) fn build_openai_image_http_client(
|
||||
) -> Result<reqwest::Client, AppError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||||
// 中文注释:同一客户端也会承载 `/v1/images/edits` multipart 图生图请求,强制 HTTP/1.1 可避开部分网关对 HTTP/2 multipart 流的兼容问题。
|
||||
.http1_only()
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
@@ -157,6 +166,82 @@ pub(crate) async fn create_openai_image_generation(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn create_openai_image_edit(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &OpenAiImageSettings,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
size: &str,
|
||||
reference_image: &OpenAiReferenceImage,
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let task_id = format!("vector-engine-edit-{}", current_utc_micros());
|
||||
let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone())
|
||||
.file_name(reference_image.file_name.clone())
|
||||
.mime_str(reference_image.mime_type.as_str())
|
||||
.map_err(|error| {
|
||||
map_openai_image_request_error(format!("{failure_context}:构造参考图失败:{error}"))
|
||||
})?;
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.part("image", image_part)
|
||||
.text("model", GPT_IMAGE_2_MODEL.to_string())
|
||||
.text(
|
||||
"prompt",
|
||||
build_prompt_with_negative(prompt, negative_prompt),
|
||||
)
|
||||
.text("n", "1")
|
||||
.text("size", normalize_image_size(size));
|
||||
let response = http_client
|
||||
.post(vector_engine_images_edit_url(settings).as_str())
|
||||
.header(
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_openai_image_request_error(format!(
|
||||
"{failure_context}:创建图片编辑任务失败:{error}"
|
||||
))
|
||||
})?;
|
||||
let response_status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
map_openai_image_request_error(format!("{failure_context}:读取图片编辑响应失败:{error}"))
|
||||
})?;
|
||||
if !response_status.is_success() {
|
||||
return Err(map_openai_image_upstream_error(
|
||||
response_status.as_u16(),
|
||||
response_text.as_str(),
|
||||
failure_context,
|
||||
));
|
||||
}
|
||||
|
||||
let response_json = parse_json_payload(response_text.as_str(), failure_context)?;
|
||||
let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt")
|
||||
.or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt"));
|
||||
let image_urls = extract_image_urls(&response_json.payload);
|
||||
if !image_urls.is_empty() {
|
||||
let mut generated = download_images_from_urls(http_client, task_id, image_urls, 1).await?;
|
||||
generated.actual_prompt = actual_prompt;
|
||||
return Ok(generated);
|
||||
}
|
||||
let b64_images = extract_b64_images(&response_json.payload);
|
||||
if !b64_images.is_empty() {
|
||||
let mut generated = images_from_base64(task_id, b64_images, 1);
|
||||
generated.actual_prompt = actual_prompt;
|
||||
return Ok(generated);
|
||||
}
|
||||
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": format!("{failure_context}:VectorEngine 未返回编辑图片"),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_openai_image_request_body(
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
@@ -453,6 +538,14 @@ fn vector_engine_images_generation_url(settings: &OpenAiImageSettings) -> String
|
||||
}
|
||||
}
|
||||
|
||||
fn vector_engine_images_edit_url(settings: &OpenAiImageSettings) -> String {
|
||||
if settings.base_url.ends_with("/v1") {
|
||||
format!("{}/images/edits", settings.base_url)
|
||||
} else {
|
||||
format!("{}/v1/images/edits", settings.base_url)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_downloaded_image_mime_type(content_type: &str) -> String {
|
||||
let mime_type = content_type
|
||||
.split(';')
|
||||
@@ -530,6 +623,29 @@ mod tests {
|
||||
assert!(body["prompt"].as_str().unwrap_or_default().contains("避免"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_engine_edit_url_uses_images_edits_endpoint() {
|
||||
let root_settings = OpenAiImageSettings {
|
||||
base_url: "https://vector.example".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 180_000,
|
||||
};
|
||||
let v1_settings = OpenAiImageSettings {
|
||||
base_url: "https://vector.example/v1".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 180_000,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
vector_engine_images_edit_url(&root_settings),
|
||||
"https://vector.example/v1/images/edits"
|
||||
);
|
||||
assert_eq!(
|
||||
vector_engine_images_edit_url(&v1_settings),
|
||||
"https://vector.example/v1/images/edits"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b64_json_response_decodes_png_image() {
|
||||
let images = images_from_base64(
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
/// 拼图首关关卡名生成提示词。
|
||||
/// 拼图首关关卡名与 UI 背景提示词生成提示词。
|
||||
///
|
||||
/// 模型只负责把画面描述压缩成可直接展示的中文关卡名;写回草稿和作品卡由业务路由处理。
|
||||
/// 模型只负责把画面描述压缩成可直接展示的中文关卡名,并产出运行态 UI 背景的正向视觉提示词;
|
||||
/// 写回草稿和作品卡由业务路由处理。
|
||||
pub(crate) const PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT: &str = r#"你是一个中文拼图关卡命名编辑。
|
||||
|
||||
你会收到拼图第一关的画面描述,部分请求还会附带已经生成完成的正式图片。请综合图片内容和画面描述,生成 1 个适合直接展示在游戏关卡卡片上的中文关卡名。
|
||||
你会收到拼图第一关的画面描述,部分请求还会附带已经生成完成的正式图片。请综合图片内容和画面描述,同时生成:
|
||||
- 1 个适合直接展示在游戏关卡卡片上的中文关卡名。
|
||||
- 1 段用于生成 9:16 拼图运行态 UI 纯背景图的中文正向视觉提示词。
|
||||
|
||||
硬约束:
|
||||
1. 只输出 JSON,不要输出 Markdown、解释或代码块。
|
||||
2. JSON 格式必须是 {"levelName":"关卡名"}。
|
||||
2. JSON 格式必须是 {"levelName":"关卡名","uiBackgroundPrompt":"提示词"}。
|
||||
3. levelName 必须是 2 到 8 个中文字符为主。
|
||||
4. 不要输出“第一关”“画面”“拼图”“作品”等泛词。
|
||||
5. 不要输出标点、引号、编号、英文、emoji 或空白。
|
||||
6. 关卡名要抓住画面主体、场景和氛围,读起来像一个具体可玩的关卡。
|
||||
7. uiBackgroundPrompt 必须是 30 到 160 个中文字符,描述题材氛围、环境、色彩、光影和空间层次。
|
||||
8. uiBackgroundPrompt 只写正向画面描述,不要写规则说明,不要出现拼图槽、棋盘、HUD、按钮、文字、水印、数字、拼图碎片、完整拼图图像或教程浮层。
|
||||
"#;
|
||||
|
||||
pub(crate) fn build_puzzle_first_level_name_user_prompt(picture_description: &str) -> String {
|
||||
format!(
|
||||
"画面描述:{picture_description}\n\n请生成第一关关卡名。",
|
||||
"画面描述:{picture_description}\n\n请生成第一关关卡名和 UI 背景提示词。",
|
||||
picture_description = picture_description.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_puzzle_first_level_name_vision_user_text(picture_description: &str) -> String {
|
||||
format!(
|
||||
"画面描述:{picture_description}\n\n请观察随消息附带的正式拼图图片,生成第一关关卡名。",
|
||||
"画面描述:{picture_description}\n\n请观察随消息附带的正式拼图图片,生成第一关关卡名和 UI 背景提示词。",
|
||||
picture_description = picture_description.trim(),
|
||||
)
|
||||
}
|
||||
@@ -38,6 +43,7 @@ mod tests {
|
||||
|
||||
assert!(prompt.contains("画面描述:一只猫在雨夜灯牌下回头。"));
|
||||
assert!(prompt.contains("第一关关卡名"));
|
||||
assert!(prompt.contains("UI 背景提示词"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -46,5 +52,6 @@ mod tests {
|
||||
|
||||
assert!(prompt.contains("画面描述:一只猫在雨夜灯牌下回头。"));
|
||||
assert!(prompt.contains("正式拼图图片"));
|
||||
assert!(prompt.contains("UI 背景提示词"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,9 +105,6 @@ use crate::{
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
vector_engine_audio_generation::{
|
||||
GeneratedCreationAudioTarget, generate_background_music_asset_for_creation,
|
||||
},
|
||||
work_author::resolve_work_author_by_user_id,
|
||||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||||
};
|
||||
@@ -127,9 +124,8 @@ const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
||||
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
|
||||
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;
|
||||
const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2";
|
||||
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND: &str = "puzzle_background_music";
|
||||
const PUZZLE_BACKGROUND_MUSIC_SLOT: &str = "background_music";
|
||||
|
||||
const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
|
||||
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素";
|
||||
pub async fn create_puzzle_agent_session(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -201,13 +197,14 @@ pub async fn generate_puzzle_onboarding_work(
|
||||
|
||||
let now = current_utc_micros();
|
||||
let session_id = build_prefixed_uuid_id("puzzle-onboarding-");
|
||||
let level_name = generate_puzzle_first_level_name(&state, prompt_text.as_str()).await;
|
||||
let tags = generate_puzzle_work_tags(&state, level_name.as_str(), prompt_text.as_str()).await;
|
||||
let naming = generate_puzzle_first_level_name(&state, prompt_text.as_str()).await;
|
||||
let tags =
|
||||
generate_puzzle_work_tags(&state, naming.level_name.as_str(), prompt_text.as_str()).await;
|
||||
let candidates = generate_puzzle_image_candidates(
|
||||
&state,
|
||||
"onboarding-guest",
|
||||
session_id.as_str(),
|
||||
level_name.as_str(),
|
||||
naming.level_name.as_str(),
|
||||
prompt_text.as_str(),
|
||||
None,
|
||||
false,
|
||||
@@ -236,10 +233,10 @@ pub async fn generate_puzzle_onboarding_work(
|
||||
})?;
|
||||
let level = PuzzleDraftLevelRecord {
|
||||
level_id: "onboarding-level-1".to_string(),
|
||||
level_name: level_name.clone(),
|
||||
level_name: naming.level_name.clone(),
|
||||
picture_description: prompt_text.clone(),
|
||||
picture_reference: None,
|
||||
ui_background_prompt: None,
|
||||
ui_background_prompt: naming.ui_background_prompt.clone(),
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
background_music: None,
|
||||
@@ -250,7 +247,7 @@ pub async fn generate_puzzle_onboarding_work(
|
||||
generation_status: "ready".to_string(),
|
||||
};
|
||||
let anchor_pack = map_puzzle_domain_anchor_pack(module_puzzle::build_form_anchor_pack(
|
||||
level_name.as_str(),
|
||||
naming.level_name.as_str(),
|
||||
level.picture_description.as_str(),
|
||||
));
|
||||
let item = PuzzleWorkProfileRecord {
|
||||
@@ -259,9 +256,9 @@ pub async fn generate_puzzle_onboarding_work(
|
||||
owner_user_id: "onboarding-guest".to_string(),
|
||||
source_session_id: None,
|
||||
author_display_name: "陶泥儿主".to_string(),
|
||||
work_title: level_name.clone(),
|
||||
work_title: naming.level_name.clone(),
|
||||
work_description: prompt_text.clone(),
|
||||
level_name,
|
||||
level_name: naming.level_name,
|
||||
summary: prompt_text,
|
||||
theme_tags: tags,
|
||||
cover_image_src: level.cover_image_src.clone(),
|
||||
@@ -919,14 +916,17 @@ pub async fn execute_puzzle_agent_action(
|
||||
}),
|
||||
));
|
||||
}
|
||||
if let Some(refined_level_name) = generate_puzzle_first_level_name_from_image(
|
||||
if let Some(refined_naming) = generate_puzzle_first_level_name_from_image(
|
||||
&state,
|
||||
target_level.picture_description.as_str(),
|
||||
&candidates[0].downloaded_image,
|
||||
)
|
||||
.await
|
||||
{
|
||||
target_level.level_name = refined_level_name;
|
||||
target_level.level_name = refined_naming.level_name;
|
||||
if refined_naming.ui_background_prompt.is_some() {
|
||||
target_level.ui_background_prompt = refined_naming.ui_background_prompt;
|
||||
}
|
||||
}
|
||||
let generated_level_name = target_level.level_name.clone();
|
||||
let levels_json_with_generated_name =
|
||||
@@ -2396,7 +2396,11 @@ fn map_puzzle_work_summary_response(
|
||||
.saturating_div(2)
|
||||
.saturating_sub(item.point_incentive_claimed_points),
|
||||
publish_ready: item.publish_ready,
|
||||
levels: Vec::new(),
|
||||
levels: item
|
||||
.levels
|
||||
.into_iter()
|
||||
.map(map_puzzle_draft_level_response)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2404,15 +2408,8 @@ fn map_puzzle_work_profile_response(
|
||||
state: &AppState,
|
||||
item: PuzzleWorkProfileRecord,
|
||||
) -> PuzzleWorkProfileResponse {
|
||||
let mut summary = map_puzzle_work_summary_response(state, item.clone());
|
||||
summary.levels = item
|
||||
.levels
|
||||
.into_iter()
|
||||
.map(map_puzzle_draft_level_response)
|
||||
.collect();
|
||||
|
||||
PuzzleWorkProfileResponse {
|
||||
summary,
|
||||
summary: map_puzzle_work_summary_response(state, item.clone()),
|
||||
anchor_pack: map_puzzle_anchor_pack_response(item.anchor_pack),
|
||||
}
|
||||
}
|
||||
@@ -2507,6 +2504,7 @@ fn map_puzzle_runtime_level_response(
|
||||
theme_tags: level.theme_tags,
|
||||
cover_image_src: level.cover_image_src,
|
||||
ui_background_image_src: level.ui_background_image_src,
|
||||
ui_background_image_object_key: level.ui_background_image_object_key,
|
||||
background_music: level
|
||||
.background_music
|
||||
.map(map_puzzle_audio_asset_record_response),
|
||||
@@ -3066,7 +3064,25 @@ fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) {
|
||||
)
|
||||
}
|
||||
|
||||
async fn generate_puzzle_first_level_name(state: &AppState, picture_description: &str) -> String {
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct PuzzleLevelNaming {
|
||||
level_name: String,
|
||||
ui_background_prompt: Option<String>,
|
||||
}
|
||||
|
||||
impl PuzzleLevelNaming {
|
||||
fn fallback(picture_description: &str) -> Self {
|
||||
Self {
|
||||
level_name: build_fallback_puzzle_first_level_name(picture_description),
|
||||
ui_background_prompt: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn generate_puzzle_first_level_name(
|
||||
state: &AppState,
|
||||
picture_description: &str,
|
||||
) -> PuzzleLevelNaming {
|
||||
if let Some(llm_client) = state.llm_client() {
|
||||
let user_prompt = build_puzzle_first_level_name_user_prompt(picture_description);
|
||||
let response = llm_client
|
||||
@@ -3081,10 +3097,9 @@ async fn generate_puzzle_first_level_name(state: &AppState, picture_description:
|
||||
.await;
|
||||
match response {
|
||||
Ok(response) => {
|
||||
if let Some(level_name) =
|
||||
parse_puzzle_first_level_name_from_text(response.content.as_str())
|
||||
if let Some(naming) = parse_puzzle_level_naming_from_text(response.content.as_str())
|
||||
{
|
||||
return level_name;
|
||||
return naming;
|
||||
}
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
@@ -3103,14 +3118,14 @@ async fn generate_puzzle_first_level_name(state: &AppState, picture_description:
|
||||
}
|
||||
}
|
||||
|
||||
build_fallback_puzzle_first_level_name(picture_description)
|
||||
PuzzleLevelNaming::fallback(picture_description)
|
||||
}
|
||||
|
||||
async fn generate_puzzle_first_level_name_from_image(
|
||||
state: &AppState,
|
||||
picture_description: &str,
|
||||
image: &PuzzleDownloadedImage,
|
||||
) -> Option<String> {
|
||||
) -> Option<PuzzleLevelNaming> {
|
||||
let Some(llm_client) = state.creative_agent_gpt5_client() else {
|
||||
return None;
|
||||
};
|
||||
@@ -3141,7 +3156,7 @@ async fn generate_puzzle_first_level_name_from_image(
|
||||
|
||||
match response {
|
||||
Ok(response) => {
|
||||
parse_puzzle_first_level_name_from_text(response.content.as_str()).or_else(|| {
|
||||
parse_puzzle_level_naming_from_text(response.content.as_str()).or_else(|| {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
model = PUZZLE_LEVEL_NAME_VISION_LLM_MODEL,
|
||||
@@ -3191,7 +3206,7 @@ fn resize_puzzle_level_name_image_bytes(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
Some(cursor.into_inner())
|
||||
}
|
||||
|
||||
fn parse_puzzle_first_level_name_from_text(text: &str) -> Option<String> {
|
||||
fn parse_puzzle_level_naming_from_text(text: &str) -> Option<PuzzleLevelNaming> {
|
||||
let trimmed = text.trim();
|
||||
let json_text = if let Some(start) = trimmed.find('{')
|
||||
&& let Some(end) = trimmed.rfind('}')
|
||||
@@ -3211,7 +3226,66 @@ fn parse_puzzle_first_level_name_from_text(text: &str) -> Option<String> {
|
||||
.and_then(|value| value.get("level_name").and_then(Value::as_str))
|
||||
})
|
||||
.unwrap_or(trimmed);
|
||||
normalize_puzzle_first_level_name(raw_name)
|
||||
let level_name = normalize_puzzle_first_level_name(raw_name)?;
|
||||
let ui_background_prompt = parsed
|
||||
.as_ref()
|
||||
.and_then(parse_puzzle_ui_background_prompt_field);
|
||||
|
||||
Some(PuzzleLevelNaming {
|
||||
level_name,
|
||||
ui_background_prompt,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn parse_puzzle_first_level_name_from_text(text: &str) -> Option<String> {
|
||||
parse_puzzle_level_naming_from_text(text).map(|naming| naming.level_name)
|
||||
}
|
||||
|
||||
fn parse_puzzle_ui_background_prompt_field(value: &Value) -> Option<String> {
|
||||
value
|
||||
.get("uiBackgroundPrompt")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| value.get("ui_background_prompt").and_then(Value::as_str))
|
||||
.and_then(normalize_puzzle_generated_ui_background_prompt)
|
||||
}
|
||||
|
||||
fn normalize_puzzle_generated_ui_background_prompt(value: &str) -> Option<String> {
|
||||
let normalized = value
|
||||
.trim()
|
||||
.trim_matches(|ch: char| {
|
||||
ch.is_ascii_punctuation()
|
||||
|| matches!(
|
||||
ch,
|
||||
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
||||
)
|
||||
})
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
let filtered = normalized
|
||||
.replace("拼图槽", "")
|
||||
.replace("棋盘", "")
|
||||
.replace("HUD", "")
|
||||
.replace("按钮", "")
|
||||
.replace("文字", "")
|
||||
.replace("水印", "")
|
||||
.replace("数字", "")
|
||||
.replace("拼图碎片", "")
|
||||
.replace("完整拼图图像", "")
|
||||
.replace("教程浮层", "");
|
||||
let prompt = filtered
|
||||
.chars()
|
||||
.take(160)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.trim_matches(|ch: char| matches!(ch, ',' | '。' | '、' | ';' | ':'))
|
||||
.to_string();
|
||||
if prompt.chars().count() >= 12 {
|
||||
Some(prompt)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_puzzle_first_level_name(value: &str) -> Option<String> {
|
||||
@@ -3332,15 +3406,15 @@ fn build_puzzle_levels_with_primary_update(
|
||||
levels
|
||||
}
|
||||
|
||||
fn resolve_puzzle_background_music_title(
|
||||
fn resolve_puzzle_initial_ui_background_prompt(
|
||||
draft: &PuzzleResultDraftRecord,
|
||||
target_level: &PuzzleDraftLevelRecord,
|
||||
) -> String {
|
||||
let work_title = draft.work_title.trim();
|
||||
if !work_title.is_empty() {
|
||||
return work_title.to_string();
|
||||
}
|
||||
target_level.level_name.trim().to_string()
|
||||
target_level
|
||||
.ui_background_prompt
|
||||
.as_deref()
|
||||
.and_then(normalize_puzzle_generated_ui_background_prompt)
|
||||
.unwrap_or_else(|| normalize_puzzle_ui_background_prompt("", draft, target_level))
|
||||
}
|
||||
|
||||
fn normalize_puzzle_ui_background_prompt(
|
||||
@@ -3371,7 +3445,7 @@ fn normalize_puzzle_ui_background_prompt(
|
||||
draft.work_description.trim(),
|
||||
target_level.picture_description.trim(),
|
||||
tags.as_str(),
|
||||
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素",
|
||||
PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.is_empty())
|
||||
@@ -3394,30 +3468,6 @@ fn build_puzzle_ui_background_generation_prompt(level_name: &str, prompt: &str)
|
||||
)
|
||||
}
|
||||
|
||||
fn attach_puzzle_level_background_music(
|
||||
levels: &mut [PuzzleDraftLevelRecord],
|
||||
level_id: &str,
|
||||
music: CreationAudioAsset,
|
||||
) {
|
||||
let Some(index) = levels
|
||||
.iter()
|
||||
.position(|level| level.level_id == level_id)
|
||||
.or_else(|| (!levels.is_empty()).then_some(0))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
levels[index].background_music = Some(PuzzleAudioAssetRecord {
|
||||
task_id: music.task_id,
|
||||
provider: music.provider,
|
||||
asset_object_id: music.asset_object_id,
|
||||
asset_kind: music.asset_kind,
|
||||
audio_src: music.audio_src,
|
||||
prompt: music.prompt,
|
||||
title: music.title,
|
||||
updated_at: music.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
fn attach_puzzle_level_ui_background(
|
||||
levels: &mut [PuzzleDraftLevelRecord],
|
||||
level_id: &str,
|
||||
@@ -3436,38 +3486,6 @@ fn attach_puzzle_level_ui_background(
|
||||
levels[index].ui_background_image_object_key = Some(generated.object_key);
|
||||
}
|
||||
|
||||
async fn generate_puzzle_background_music_required(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
profile_id: &str,
|
||||
title: &str,
|
||||
) -> Result<CreationAudioAsset, AppError> {
|
||||
let normalized_title = title.trim();
|
||||
if normalized_title.is_empty() {
|
||||
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图草稿背景音乐名称为空,无法完成背景音乐生成",
|
||||
})));
|
||||
}
|
||||
generate_background_music_asset_for_creation(
|
||||
state,
|
||||
owner_user_id,
|
||||
String::new(),
|
||||
normalized_title.to_string(),
|
||||
Some("轻快, 拼图, 循环, instrumental".to_string()),
|
||||
None,
|
||||
GeneratedCreationAudioTarget {
|
||||
entity_kind: PUZZLE_ENTITY_KIND.to_string(),
|
||||
entity_id: profile_id.to_string(),
|
||||
slot: PUZZLE_BACKGROUND_MUSIC_SLOT.to_string(),
|
||||
asset_kind: PUZZLE_BACKGROUND_MUSIC_ASSET_KIND.to_string(),
|
||||
profile_id: Some(profile_id.to_string()),
|
||||
storage_prefix: LegacyAssetPrefix::PuzzleAssets,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn generate_puzzle_initial_ui_background_required(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
@@ -3475,7 +3493,7 @@ async fn generate_puzzle_initial_ui_background_required(
|
||||
draft: &PuzzleResultDraftRecord,
|
||||
target_level: &PuzzleDraftLevelRecord,
|
||||
) -> Result<(String, GeneratedPuzzleUiBackgroundResponse), AppError> {
|
||||
let prompt = normalize_puzzle_ui_background_prompt("", draft, target_level);
|
||||
let prompt = resolve_puzzle_initial_ui_background_prompt(draft, target_level);
|
||||
let generated = generate_puzzle_ui_background_image(
|
||||
state,
|
||||
owner_user_id,
|
||||
@@ -3490,11 +3508,6 @@ async fn generate_puzzle_initial_ui_background_required(
|
||||
fn ensure_puzzle_initial_level_assets_ready(
|
||||
level: &PuzzleDraftLevelRecord,
|
||||
) -> Result<(), AppError> {
|
||||
let has_background_music = level
|
||||
.background_music
|
||||
.as_ref()
|
||||
.map(|music| music.audio_src.trim())
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
let has_ui_background = level
|
||||
.ui_background_image_src
|
||||
.as_deref()
|
||||
@@ -3505,23 +3518,22 @@ fn ensure_puzzle_initial_level_assets_ready(
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
if has_background_music && has_ui_background {
|
||||
if has_ui_background {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut missing = Vec::new();
|
||||
if !has_background_music {
|
||||
missing.push("背景音乐");
|
||||
}
|
||||
if !has_ui_background {
|
||||
missing.push("UI背景图");
|
||||
}
|
||||
|
||||
Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图草稿资源生成未完成:缺少{}", missing.join("、")),
|
||||
"missingAssets": missing,
|
||||
})))
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图草稿资源生成未完成:缺少{}", missing.join("、")),
|
||||
"missingAssets": missing,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn find_puzzle_level_for_initial_asset_check<'a>(
|
||||
@@ -3582,9 +3594,9 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
1,
|
||||
target_level.candidates.len(),
|
||||
);
|
||||
let (generated_level_name, candidates_result) =
|
||||
tokio::join!(level_name_future, candidates_future);
|
||||
target_level.level_name = generated_level_name.clone();
|
||||
let (generated_naming, candidates_result) = tokio::join!(level_name_future, candidates_future);
|
||||
target_level.level_name = generated_naming.level_name.clone();
|
||||
target_level.ui_background_prompt = generated_naming.ui_background_prompt;
|
||||
let candidates = candidates_result?;
|
||||
let selected_candidate_id = candidates
|
||||
.iter()
|
||||
@@ -3597,21 +3609,22 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
"message": "拼图候选图生成结果为空",
|
||||
}))
|
||||
})?;
|
||||
if let Some(refined_level_name) = generate_puzzle_first_level_name_from_image(
|
||||
if let Some(refined_naming) = generate_puzzle_first_level_name_from_image(
|
||||
state,
|
||||
target_level.picture_description.as_str(),
|
||||
&candidates[0].downloaded_image,
|
||||
)
|
||||
.await
|
||||
{
|
||||
target_level.level_name = refined_level_name;
|
||||
target_level.level_name = refined_naming.level_name;
|
||||
if refined_naming.ui_background_prompt.is_some() {
|
||||
target_level.ui_background_prompt = refined_naming.ui_background_prompt;
|
||||
}
|
||||
}
|
||||
let generated_level_name = target_level.level_name.clone();
|
||||
let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str());
|
||||
let mut updated_levels =
|
||||
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
|
||||
let music_title = resolve_puzzle_background_music_title(&draft, &target_level);
|
||||
// 中文注释:UI 背景先生成,避免其失败后留下已经扣费但未写入草稿的音乐资产。
|
||||
// 中文注释:拼图草稿音频生成临时关闭,首版生成只补首图与 UI 背景。
|
||||
let (ui_prompt, ui_background) = generate_puzzle_initial_ui_background_required(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
@@ -3626,17 +3639,6 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
ui_prompt,
|
||||
ui_background,
|
||||
);
|
||||
attach_puzzle_level_background_music(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
generate_puzzle_background_music_required(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
music_title.as_str(),
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
let ready_level =
|
||||
find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str())
|
||||
.ok_or_else(|| {
|
||||
@@ -3809,19 +3811,24 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
uploaded_downloaded_image.clone(),
|
||||
current_utc_micros(),
|
||||
);
|
||||
let (generated_level_name, refined_level_name, persisted_upload_result) = tokio::join!(
|
||||
let (mut generated_naming, refined_naming, persisted_upload_result) = tokio::join!(
|
||||
level_name_future,
|
||||
image_level_name_future,
|
||||
persist_upload_future
|
||||
);
|
||||
target_level.level_name = refined_level_name.unwrap_or(generated_level_name.clone());
|
||||
if let Some(refined_naming) = refined_naming {
|
||||
generated_naming.level_name = refined_naming.level_name;
|
||||
if refined_naming.ui_background_prompt.is_some() {
|
||||
generated_naming.ui_background_prompt = refined_naming.ui_background_prompt;
|
||||
}
|
||||
}
|
||||
target_level.level_name = generated_naming.level_name;
|
||||
target_level.ui_background_prompt = generated_naming.ui_background_prompt;
|
||||
let generated_level_name = target_level.level_name.clone();
|
||||
let persisted_upload = persisted_upload_result?;
|
||||
let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str());
|
||||
let mut updated_levels =
|
||||
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
|
||||
let music_title = resolve_puzzle_background_music_title(&draft, &target_level);
|
||||
// 中文注释:直用上传图时同样先补 UI 背景,再生成会单独扣费的音乐资产。
|
||||
// 中文注释:直用上传图时同样只补 UI 背景;音频生成入口临时关闭。
|
||||
let (ui_prompt, ui_background) = generate_puzzle_initial_ui_background_required(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
@@ -3836,17 +3843,6 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
ui_prompt,
|
||||
ui_background,
|
||||
);
|
||||
attach_puzzle_level_background_music(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
generate_puzzle_background_music_required(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
music_title.as_str(),
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
let ready_level =
|
||||
find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str())
|
||||
.ok_or_else(|| {
|
||||
@@ -5061,6 +5057,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_level_naming_parser_accepts_ui_background_prompt() {
|
||||
let naming = parse_puzzle_level_naming_from_text(
|
||||
r#"{"levelName":"雨夜猫街","uiBackgroundPrompt":"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"}"#,
|
||||
)
|
||||
.expect("naming should parse");
|
||||
|
||||
assert_eq!(naming.level_name, "雨夜猫街");
|
||||
assert_eq!(
|
||||
naming.ui_background_prompt.as_deref(),
|
||||
Some("雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_level_naming_parser_filters_forbidden_ui_prompt_words() {
|
||||
let naming = parse_puzzle_level_naming_from_text(
|
||||
r#"{"levelName":"雨夜猫街","uiBackgroundPrompt":"雨夜老街背景,中央不要出现拼图槽、棋盘、HUD、按钮、文字或水印,保留暖色灯光"}"#,
|
||||
)
|
||||
.expect("naming should parse");
|
||||
let prompt = naming
|
||||
.ui_background_prompt
|
||||
.as_deref()
|
||||
.expect("prompt should parse");
|
||||
|
||||
assert!(!prompt.contains("拼图槽"));
|
||||
assert!(!prompt.contains("棋盘"));
|
||||
assert!(!prompt.contains("HUD"));
|
||||
assert!(!prompt.contains("按钮"));
|
||||
assert!(!prompt.contains("文字"));
|
||||
assert!(!prompt.contains("水印"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_first_level_name_fallback_uses_picture_keywords() {
|
||||
assert_eq!(
|
||||
@@ -5256,6 +5285,74 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
|
||||
let state = AppState::new(crate::config::AppConfig::default()).expect("state should build");
|
||||
let level = PuzzleDraftLevelRecord {
|
||||
level_id: "puzzle-level-1".to_string(),
|
||||
level_name: "雨夜猫街".to_string(),
|
||||
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||
picture_reference: None,
|
||||
ui_background_prompt: None,
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
background_music: None,
|
||||
candidates: vec![PuzzleGeneratedImageCandidateRecord {
|
||||
candidate_id: "candidate-1".to_string(),
|
||||
image_src: "/generated-puzzle-assets/session/candidate-1.png".to_string(),
|
||||
asset_id: "asset-1".to_string(),
|
||||
prompt: "雨夜猫街".to_string(),
|
||||
actual_prompt: None,
|
||||
source_type: "generated".to_string(),
|
||||
selected: true,
|
||||
}],
|
||||
selected_candidate_id: Some("candidate-1".to_string()),
|
||||
cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()),
|
||||
cover_asset_id: Some("asset-1".to_string()),
|
||||
generation_status: "ready".to_string(),
|
||||
};
|
||||
|
||||
let response = map_puzzle_work_summary_response(
|
||||
&state,
|
||||
PuzzleWorkProfileRecord {
|
||||
work_id: "puzzle-work-1".to_string(),
|
||||
profile_id: "puzzle-profile-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
source_session_id: Some("puzzle-session-1".to_string()),
|
||||
author_display_name: "玩家".to_string(),
|
||||
work_title: "雨夜猫街".to_string(),
|
||||
work_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||
level_name: "雨夜猫街".to_string(),
|
||||
summary: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||
theme_tags: vec!["猫".to_string()],
|
||||
cover_image_src: None,
|
||||
cover_asset_id: None,
|
||||
publication_status: "draft".to_string(),
|
||||
updated_at: "2026-05-08T00:00:00.000Z".to_string(),
|
||||
published_at: None,
|
||||
play_count: 0,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
recent_play_count_7d: 0,
|
||||
point_incentive_total_half_points: 0,
|
||||
point_incentive_claimed_points: 0,
|
||||
publish_ready: false,
|
||||
anchor_pack: test_puzzle_anchor_pack_record(),
|
||||
levels: vec![level],
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(response.levels.len(), 1);
|
||||
assert_eq!(
|
||||
response.levels[0].cover_image_src.as_deref(),
|
||||
Some("/generated-puzzle-assets/session/cover.png")
|
||||
);
|
||||
assert_eq!(
|
||||
response.levels[0].candidates[0].image_src,
|
||||
"/generated-puzzle-assets/session/candidate-1.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() {
|
||||
let prompt =
|
||||
@@ -5268,6 +5365,34 @@ mod tests {
|
||||
assert!(prompt.contains("文字"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_initial_ui_background_prompt_prefers_ai_generated_prompt() {
|
||||
let mut draft = test_puzzle_draft_record();
|
||||
draft.work_title = "模板作品名".to_string();
|
||||
draft.work_description = "模板作品描述".to_string();
|
||||
let mut target_level = draft.levels[0].clone();
|
||||
target_level.level_name = "雨夜猫街".to_string();
|
||||
let ai_prompt =
|
||||
"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次";
|
||||
target_level.ui_background_prompt = Some(ai_prompt.to_string());
|
||||
|
||||
let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level);
|
||||
|
||||
assert_eq!(prompt, ai_prompt);
|
||||
assert!(!prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_initial_ui_background_prompt_falls_back_to_context_template() {
|
||||
let draft = test_puzzle_draft_record();
|
||||
let target_level = draft.levels[0].clone();
|
||||
|
||||
let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level);
|
||||
|
||||
assert!(prompt.contains("雨夜猫街"));
|
||||
assert!(prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_ui_background_initial_attach_updates_first_level_fields() {
|
||||
let draft = test_puzzle_draft_record();
|
||||
@@ -5299,33 +5424,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_initial_draft_assets_must_include_music_and_ui_background() {
|
||||
fn puzzle_initial_draft_assets_must_include_ui_background() {
|
||||
let mut draft = test_puzzle_draft_record();
|
||||
let missing_all = ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
|
||||
.expect_err("缺少自动生成资产时不能把草稿标记为完成");
|
||||
assert_eq!(missing_all.status_code(), StatusCode::BAD_GATEWAY);
|
||||
assert!(missing_all.body_text().contains("背景音乐"));
|
||||
assert!(missing_all.body_text().contains("UI背景图"));
|
||||
|
||||
draft.levels[0].ui_background_image_src =
|
||||
Some("/generated-puzzle-assets/session/ui/background.png".to_string());
|
||||
let missing_music = ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
|
||||
.expect_err("只有 UI 背景时仍不能完成草稿");
|
||||
assert!(missing_music.body_text().contains("背景音乐"));
|
||||
|
||||
draft.levels[0].background_music = Some(PuzzleAudioAssetRecord {
|
||||
task_id: "suno-task-1".to_string(),
|
||||
provider: "vector-engine-suno".to_string(),
|
||||
asset_object_id: Some("assetobj_1".to_string()),
|
||||
asset_kind: Some("puzzle_background_music".to_string()),
|
||||
audio_src: "/generated-puzzle-assets/session/music.mp3".to_string(),
|
||||
prompt: Some(String::new()),
|
||||
title: Some("雨夜猫街".to_string()),
|
||||
updated_at: Some("2026-05-14T00:00:00Z".to_string()),
|
||||
});
|
||||
|
||||
ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
|
||||
.expect("音乐和 UI 背景都存在时才能完成自动草稿");
|
||||
.expect("UI 背景存在时即可完成自动草稿资源检查");
|
||||
}
|
||||
|
||||
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {
|
||||
|
||||
@@ -65,16 +65,6 @@ struct AudioAssetBindingTarget {
|
||||
storage_scope: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct GeneratedCreationAudioTarget {
|
||||
pub entity_kind: String,
|
||||
pub entity_id: String,
|
||||
pub slot: String,
|
||||
pub asset_kind: String,
|
||||
pub profile_id: Option<String>,
|
||||
pub storage_prefix: LegacyAssetPrefix,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum AudioAssetSlot {
|
||||
BackgroundMusic,
|
||||
@@ -173,21 +163,13 @@ pub async fn create_visual_novel_background_music_task(
|
||||
}
|
||||
|
||||
pub async fn create_background_music_task(
|
||||
State(state): State<AppState>,
|
||||
State(_state): State<AppState>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
payload: Result<Json<creation_audio::CreateBackgroundMusicRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
create_background_music_task_response(
|
||||
&state,
|
||||
payload.prompt,
|
||||
payload.title,
|
||||
payload.tags,
|
||||
payload.model,
|
||||
)
|
||||
.await
|
||||
.map(|payload| json_success_body(Some(&request_context), payload))
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))
|
||||
let _ = parse_json_payload(&request_context, payload)?;
|
||||
Err(creation_audio_generation_disabled_error()
|
||||
.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
pub async fn create_visual_novel_sound_effect_task(
|
||||
@@ -241,210 +223,13 @@ pub async fn create_visual_novel_sound_effect_task(
|
||||
}
|
||||
|
||||
pub async fn create_sound_effect_task(
|
||||
State(state): State<AppState>,
|
||||
State(_state): State<AppState>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
payload: Result<Json<creation_audio::CreateSoundEffectRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
create_sound_effect_task_response(&state, payload.prompt, payload.duration, payload.seed)
|
||||
.await
|
||||
.map(|payload| json_success_body(Some(&request_context), payload))
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_sound_effect_asset_for_creation(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
prompt: String,
|
||||
duration: Option<u8>,
|
||||
seed: Option<u64>,
|
||||
target: GeneratedCreationAudioTarget,
|
||||
) -> Result<creation_audio::CreationAudioAsset, AppError> {
|
||||
let normalized_prompt = normalize_limited_text(&prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?;
|
||||
let task =
|
||||
create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed).await?;
|
||||
let target = AudioAssetBindingTarget {
|
||||
storage_scope: target.entity_kind.clone(),
|
||||
entity_kind: target.entity_kind,
|
||||
entity_id: target.entity_id,
|
||||
slot: target.slot,
|
||||
asset_kind: target.asset_kind,
|
||||
profile_id: target.profile_id,
|
||||
storage_prefix: target.storage_prefix,
|
||||
};
|
||||
let generated = wait_for_generated_audio_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
task.task_id.clone(),
|
||||
AudioAssetSlot::SoundEffect,
|
||||
target,
|
||||
)
|
||||
.await?;
|
||||
let audio_src = generated
|
||||
.audio_src
|
||||
.ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?;
|
||||
|
||||
Ok(creation_audio::CreationAudioAsset {
|
||||
task_id: generated.task_id,
|
||||
provider: generated.provider,
|
||||
asset_object_id: generated.asset_object_id,
|
||||
asset_kind: generated.asset_kind,
|
||||
audio_src,
|
||||
prompt: Some(normalized_prompt),
|
||||
title: None,
|
||||
updated_at: Some(current_utc_iso_text()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_background_music_asset_for_creation(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
prompt: String,
|
||||
title: String,
|
||||
tags: Option<String>,
|
||||
model: Option<String>,
|
||||
target: GeneratedCreationAudioTarget,
|
||||
) -> Result<creation_audio::CreationAudioAsset, AppError> {
|
||||
let normalized_prompt =
|
||||
normalize_limited_text_allow_empty(&prompt, "prompt", SUNO_PROMPT_MAX_CHARS)?;
|
||||
let normalized_title = normalize_limited_text(&title, "title", SUNO_TITLE_MAX_CHARS)?;
|
||||
let task = create_background_music_task_response(
|
||||
state,
|
||||
normalized_prompt.clone(),
|
||||
normalized_title.clone(),
|
||||
tags,
|
||||
model,
|
||||
)
|
||||
.await?;
|
||||
let target = AudioAssetBindingTarget {
|
||||
storage_scope: target.entity_kind.clone(),
|
||||
entity_kind: target.entity_kind,
|
||||
entity_id: target.entity_id,
|
||||
slot: target.slot,
|
||||
asset_kind: target.asset_kind,
|
||||
profile_id: target.profile_id,
|
||||
storage_prefix: target.storage_prefix,
|
||||
};
|
||||
let generated = wait_for_generated_audio_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
task.task_id.clone(),
|
||||
AudioAssetSlot::BackgroundMusic,
|
||||
target,
|
||||
)
|
||||
.await?;
|
||||
let audio_src = generated
|
||||
.audio_src
|
||||
.ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?;
|
||||
|
||||
Ok(creation_audio::CreationAudioAsset {
|
||||
task_id: generated.task_id,
|
||||
provider: generated.provider,
|
||||
asset_object_id: generated.asset_object_id,
|
||||
asset_kind: generated.asset_kind,
|
||||
audio_src,
|
||||
prompt: Some(normalized_prompt),
|
||||
title: Some(normalized_title),
|
||||
updated_at: Some(current_utc_iso_text()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_background_music_task_response(
|
||||
state: &AppState,
|
||||
prompt: String,
|
||||
title: String,
|
||||
tags: Option<String>,
|
||||
model: Option<String>,
|
||||
) -> Result<creation_audio::AudioGenerationTaskResponse, AppError> {
|
||||
let settings = require_vector_engine_audio_settings(state)?;
|
||||
let http_client = build_vector_engine_audio_http_client(&settings)?;
|
||||
let prompt = normalize_limited_text_allow_empty(&prompt, "prompt", SUNO_PROMPT_MAX_CHARS)?;
|
||||
let title = normalize_limited_text(&title, "title", SUNO_TITLE_MAX_CHARS)?;
|
||||
let tags = tags
|
||||
.as_deref()
|
||||
.map(|value| normalize_limited_text(value, "tags", SUNO_TAGS_MAX_CHARS))
|
||||
.transpose()?;
|
||||
let model =
|
||||
normalize_optional_text(model.as_deref()).unwrap_or_else(|| SUNO_DEFAULT_MODEL.to_string());
|
||||
|
||||
let mut body = Map::from_iter([
|
||||
("prompt".to_string(), Value::String(prompt)),
|
||||
("mv".to_string(), Value::String(model)),
|
||||
("title".to_string(), Value::String(title)),
|
||||
("task".to_string(), Value::String("generate".to_string())),
|
||||
("make_instrumental".to_string(), Value::Bool(true)),
|
||||
]);
|
||||
if let Some(tags) = tags {
|
||||
body.insert("tags".to_string(), Value::String(tags));
|
||||
}
|
||||
|
||||
let response = post_vector_engine_json(
|
||||
&http_client,
|
||||
&settings,
|
||||
"/suno/submit/music",
|
||||
Value::Object(body),
|
||||
"提交 Suno 背景音乐任务失败",
|
||||
)
|
||||
.await?;
|
||||
let task_id = extract_string_by_path(&response, &["data"])
|
||||
.or_else(|| find_first_string_by_key(&response, "task_id"))
|
||||
.or_else(|| find_first_string_by_key(&response, "taskId"))
|
||||
.ok_or_else(|| {
|
||||
vector_engine_bad_gateway("提交 Suno 背景音乐任务失败:上游未返回任务 ID")
|
||||
})?;
|
||||
|
||||
Ok(creation_audio::AudioGenerationTaskResponse {
|
||||
kind: creation_audio::CreationAudioGenerationKind::BackgroundMusic,
|
||||
task_id,
|
||||
provider: VECTOR_ENGINE_SUNO_PROVIDER.to_string(),
|
||||
status: "submitted".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_sound_effect_task_response(
|
||||
state: &AppState,
|
||||
prompt: String,
|
||||
duration: Option<u8>,
|
||||
seed: Option<u64>,
|
||||
) -> Result<creation_audio::AudioGenerationTaskResponse, AppError> {
|
||||
let settings = require_vector_engine_audio_settings(state)?;
|
||||
let http_client = build_vector_engine_audio_http_client(&settings)?;
|
||||
let prompt = normalize_limited_text(&prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?;
|
||||
let duration = duration
|
||||
.unwrap_or(DEFAULT_SOUND_EFFECT_DURATION_SECONDS)
|
||||
.clamp(2, 10);
|
||||
|
||||
let mut body = Map::from_iter([
|
||||
(
|
||||
"model".to_string(),
|
||||
Value::String(VIDU_AUDIO_MODEL.to_string()),
|
||||
),
|
||||
("prompt".to_string(), Value::String(prompt)),
|
||||
("duration".to_string(), json!(duration)),
|
||||
]);
|
||||
if let Some(seed) = seed {
|
||||
body.insert("seed".to_string(), json!(seed));
|
||||
}
|
||||
|
||||
let response = post_vector_engine_json(
|
||||
&http_client,
|
||||
&settings,
|
||||
"/ent/v2/text2audio",
|
||||
Value::Object(body),
|
||||
"提交 Vidu 音效任务失败",
|
||||
)
|
||||
.await?;
|
||||
let task_id = find_first_string_by_key(&response, "task_id")
|
||||
.or_else(|| find_first_string_by_key(&response, "taskId"))
|
||||
.ok_or_else(|| vector_engine_bad_gateway("提交 Vidu 音效任务失败:上游未返回任务 ID"))?;
|
||||
let status = find_first_string_by_key(&response, "state").unwrap_or_else(|| "created".into());
|
||||
|
||||
Ok(creation_audio::AudioGenerationTaskResponse {
|
||||
kind: creation_audio::CreationAudioGenerationKind::SoundEffect,
|
||||
task_id,
|
||||
provider: VECTOR_ENGINE_VIDU_PROVIDER.to_string(),
|
||||
status,
|
||||
})
|
||||
let _ = parse_json_payload(&request_context, payload)?;
|
||||
Err(creation_audio_generation_disabled_error()
|
||||
.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
pub async fn publish_visual_novel_background_music_asset(
|
||||
@@ -516,45 +301,27 @@ pub async fn publish_visual_novel_sound_effect_asset(
|
||||
}
|
||||
|
||||
pub async fn publish_background_music_asset(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
State(_state): State<AppState>,
|
||||
Path(_task_id): Path<String>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
axum::extract::Extension(_authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<creation_audio::PublishGeneratedAudioAssetRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let payload = parse_json_payload(&request_context, payload)?.0;
|
||||
let target = build_creation_audio_target(payload)?;
|
||||
publish_generated_audio_asset(
|
||||
&state,
|
||||
authenticated.claims().user_id(),
|
||||
task_id,
|
||||
AudioAssetSlot::BackgroundMusic,
|
||||
target,
|
||||
)
|
||||
.await
|
||||
.map(|payload| json_success_body(Some(&request_context), payload))
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))
|
||||
Err(creation_audio_generation_disabled_error_for_target(payload)
|
||||
.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
pub async fn publish_sound_effect_asset(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
State(_state): State<AppState>,
|
||||
Path(_task_id): Path<String>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
axum::extract::Extension(_authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<creation_audio::PublishGeneratedAudioAssetRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let payload = parse_json_payload(&request_context, payload)?.0;
|
||||
let target = build_creation_audio_target(payload)?;
|
||||
publish_generated_audio_asset(
|
||||
&state,
|
||||
authenticated.claims().user_id(),
|
||||
task_id,
|
||||
AudioAssetSlot::SoundEffect,
|
||||
target,
|
||||
)
|
||||
.await
|
||||
.map(|payload| json_success_body(Some(&request_context), payload))
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))
|
||||
Err(creation_audio_generation_disabled_error_for_target(payload)
|
||||
.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
async fn publish_generated_audio_asset(
|
||||
@@ -650,45 +417,6 @@ async fn publish_generated_audio_asset(
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_for_generated_audio_asset(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
task_id: String,
|
||||
slot: AudioAssetSlot,
|
||||
target: AudioAssetBindingTarget,
|
||||
) -> Result<creation_audio::GeneratedAudioAssetResponse, AppError> {
|
||||
let mut latest_status = String::new();
|
||||
for _ in 0..40 {
|
||||
let response = publish_generated_audio_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
task_id.clone(),
|
||||
slot,
|
||||
target.clone(),
|
||||
)
|
||||
.await?;
|
||||
if response
|
||||
.audio_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
return Ok(response);
|
||||
}
|
||||
latest_status = response.status;
|
||||
tokio::time::sleep(Duration::from_millis(3_000)).await;
|
||||
}
|
||||
|
||||
Err(vector_engine_bad_gateway(format!(
|
||||
"音频生成超时:{}",
|
||||
if latest_status.trim().is_empty() {
|
||||
task_id
|
||||
} else {
|
||||
latest_status
|
||||
}
|
||||
)))
|
||||
}
|
||||
|
||||
fn build_audio_billing_asset_id(
|
||||
task_id: &str,
|
||||
slot: AudioAssetSlot,
|
||||
@@ -888,33 +616,21 @@ fn build_visual_novel_audio_target(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_creation_audio_target(
|
||||
fn creation_audio_generation_disabled_error() -> AppError {
|
||||
AppError::from_status(StatusCode::GONE).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": "拼图与抓大鹅音频生成入口已临时关闭",
|
||||
}))
|
||||
}
|
||||
|
||||
fn creation_audio_generation_disabled_error_for_target(
|
||||
payload: creation_audio::PublishGeneratedAudioAssetRequest,
|
||||
) -> Result<AudioAssetBindingTarget, AppError> {
|
||||
let entity_kind = normalize_limited_text(&payload.entity_kind, "entityKind", 80)?;
|
||||
let entity_id = normalize_limited_text(&payload.entity_id, "entityId", 160)?;
|
||||
let slot = normalize_limited_text(&payload.slot, "slot", 80)?;
|
||||
let asset_kind = normalize_limited_text(&payload.asset_kind, "assetKind", 80)?;
|
||||
let storage_prefix = match payload.storage_prefix {
|
||||
Some(creation_audio::CreationAudioStoragePrefix::PuzzleAssets) => {
|
||||
LegacyAssetPrefix::PuzzleAssets
|
||||
}
|
||||
Some(creation_audio::CreationAudioStoragePrefix::Match3DAssets) => {
|
||||
LegacyAssetPrefix::Match3DAssets
|
||||
}
|
||||
Some(creation_audio::CreationAudioStoragePrefix::CustomWorldScenes) | None => {
|
||||
LegacyAssetPrefix::CustomWorldScenes
|
||||
}
|
||||
};
|
||||
Ok(AudioAssetBindingTarget {
|
||||
storage_scope: entity_kind.clone(),
|
||||
entity_kind,
|
||||
entity_id,
|
||||
slot,
|
||||
asset_kind,
|
||||
profile_id: normalize_optional_text(payload.profile_id.as_deref()),
|
||||
storage_prefix,
|
||||
})
|
||||
) -> AppError {
|
||||
creation_audio_generation_disabled_error().with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": "拼图与抓大鹅音频生成入口已临时关闭",
|
||||
"entityKind": payload.entity_kind.trim(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn require_vector_engine_audio_settings(
|
||||
@@ -1253,24 +969,6 @@ fn normalize_limited_text(
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn normalize_limited_text_allow_empty(
|
||||
value: &str,
|
||||
field: &'static str,
|
||||
max_chars: usize,
|
||||
) -> Result<String, AppError> {
|
||||
let normalized = value.trim().to_string();
|
||||
if normalized.chars().count() > max_chars {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"field": field,
|
||||
"message": format!("{field} 超过 {} 字符", max_chars),
|
||||
})),
|
||||
);
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn normalize_optional_text(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
@@ -1369,11 +1067,6 @@ fn current_utc_micros() -> i64 {
|
||||
shared_kernel::offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc())
|
||||
}
|
||||
|
||||
fn current_utc_iso_text() -> String {
|
||||
shared_kernel::format_rfc3339(time::OffsetDateTime::now_utc())
|
||||
.unwrap_or_else(|_| shared_kernel::format_timestamp_micros(current_utc_micros()))
|
||||
}
|
||||
|
||||
fn map_asset_field_error(error: module_assets::AssetObjectFieldError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
@@ -1473,6 +1166,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_creation_audio_targets_return_gone() {
|
||||
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
|
||||
entity_kind: "puzzle_work".to_string(),
|
||||
entity_id: "puzzle-profile-1".to_string(),
|
||||
slot: "background_music".to_string(),
|
||||
asset_kind: "puzzle_background_music".to_string(),
|
||||
profile_id: Some("puzzle-profile-1".to_string()),
|
||||
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::PuzzleAssets),
|
||||
};
|
||||
let error = creation_audio_generation_disabled_error_for_target(payload);
|
||||
assert_eq!(error.status_code(), StatusCode::GONE);
|
||||
|
||||
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
|
||||
entity_kind: "match3d_work".to_string(),
|
||||
entity_id: "match3d-profile-1".to_string(),
|
||||
slot: "background_music".to_string(),
|
||||
asset_kind: "match3d_background_music".to_string(),
|
||||
profile_id: Some("match3d-profile-1".to_string()),
|
||||
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::Match3DAssets),
|
||||
};
|
||||
let error = creation_audio_generation_disabled_error_for_target(payload);
|
||||
assert_eq!(error.status_code(), StatusCode::GONE);
|
||||
|
||||
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
|
||||
entity_kind: "match3d_item".to_string(),
|
||||
entity_id: "match3d-item-1".to_string(),
|
||||
slot: "click_sound".to_string(),
|
||||
asset_kind: "match3d_click_sound".to_string(),
|
||||
profile_id: Some("match3d-profile-1".to_string()),
|
||||
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::Match3DAssets),
|
||||
};
|
||||
let error = creation_audio_generation_disabled_error_for_target(payload);
|
||||
assert_eq!(error.status_code(), StatusCode::GONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_prompt_length() {
|
||||
let prompt = "声".repeat(VIDU_PROMPT_MAX_CHARS + 1);
|
||||
|
||||
@@ -1070,6 +1070,9 @@ pub fn start_run_with_shuffle_seed_at(
|
||||
ui_background_image_src: current_profile_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.ui_background_image_src.clone()),
|
||||
ui_background_image_object_key: current_profile_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.ui_background_image_object_key.clone()),
|
||||
background_music: current_profile_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.background_music.clone()),
|
||||
@@ -1347,6 +1350,9 @@ pub fn advance_next_level_at(
|
||||
ui_background_image_src: current_profile_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.ui_background_image_src.clone()),
|
||||
ui_background_image_object_key: current_profile_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.ui_background_image_object_key.clone()),
|
||||
background_music: current_profile_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.background_music.clone()),
|
||||
@@ -1426,6 +1432,9 @@ pub fn advance_to_new_work_first_level_at(
|
||||
ui_background_image_src: current_profile_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.ui_background_image_src.clone()),
|
||||
ui_background_image_object_key: current_profile_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.ui_background_image_object_key.clone()),
|
||||
background_music: current_profile_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.background_music.clone()),
|
||||
@@ -3131,14 +3140,50 @@ mod tests {
|
||||
title: Some("奇境初见".to_string()),
|
||||
updated_at: Some("2026-05-12T00:00:00Z".to_string()),
|
||||
});
|
||||
profile.levels[0].ui_background_image_object_key =
|
||||
Some("generated-puzzle-assets/background-ui.png".to_string());
|
||||
|
||||
let run = start_run("run-music".to_string(), &profile, 0).expect("run");
|
||||
let current_level = run.current_level.as_ref().expect("level");
|
||||
|
||||
assert_eq!(
|
||||
run.current_level
|
||||
.and_then(|level| level.background_music)
|
||||
.map(|music| music.audio_src),
|
||||
current_level
|
||||
.background_music
|
||||
.as_ref()
|
||||
.map(|music| music.audio_src.as_str()),
|
||||
Some("/generated-puzzle-assets/background.mp3".to_string())
|
||||
.as_deref()
|
||||
);
|
||||
assert_eq!(
|
||||
current_level.ui_background_image_object_key.as_deref(),
|
||||
Some("generated-puzzle-assets/background-ui.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_to_new_work_first_level_carries_ui_background_object_key() {
|
||||
let first_profile = build_published_profile("entry", "owner-a", vec!["奇幻"]);
|
||||
let mut next_profile = build_published_profile("next", "owner-b", vec!["奇幻"]);
|
||||
next_profile.levels[0].ui_background_image_object_key =
|
||||
Some("generated-puzzle-assets/next-ui.png".to_string());
|
||||
|
||||
let run = start_run("run-ui".to_string(), &first_profile, 2).expect("run");
|
||||
let mut cleared_run = run.clone();
|
||||
cleared_run.cleared_level_count = cleared_run.current_level_index;
|
||||
let current_level = cleared_run.current_level.as_mut().expect("level");
|
||||
current_level.status = PuzzleRuntimeLevelStatus::Cleared;
|
||||
current_level.cleared_at_ms = Some(2_000);
|
||||
current_level.elapsed_ms = Some(1_000);
|
||||
|
||||
let next_run =
|
||||
advance_to_new_work_first_level_at(&cleared_run, &next_profile, 3_000).expect("next run");
|
||||
|
||||
assert_eq!(
|
||||
next_run
|
||||
.current_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.ui_background_image_object_key.as_deref()),
|
||||
Some("generated-puzzle-assets/next-ui.png")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -365,6 +365,8 @@ pub struct PuzzleRuntimeLevelSnapshot {
|
||||
#[serde(default)]
|
||||
pub ui_background_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ui_background_image_object_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub background_music: Option<PuzzleAudioAsset>,
|
||||
pub board: PuzzleBoardSnapshot,
|
||||
pub status: PuzzleRuntimeLevelStatus,
|
||||
|
||||
@@ -64,7 +64,11 @@ pub struct PersistMatch3DGeneratedModelResponse {
|
||||
pub struct GenerateMatch3DCoverImageRequest {
|
||||
pub prompt: String,
|
||||
#[serde(default)]
|
||||
pub uploaded_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_srcs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
@@ -92,10 +96,28 @@ pub struct GenerateMatch3DBackgroundImageResponse {
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GenerateMatch3DContainerImageRequest {
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GenerateMatch3DContainerImageResponse {
|
||||
pub item: Match3DWorkProfileResponse,
|
||||
pub container_image_src: String,
|
||||
pub container_image_object_key: String,
|
||||
pub generated_background_asset: Match3DGeneratedBackgroundAssetResponse,
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GenerateMatch3DItemAssetsRequest {
|
||||
pub item_names: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
|
||||
@@ -120,6 +120,8 @@ pub struct PuzzleRuntimeLevelSnapshotResponse {
|
||||
#[serde(default)]
|
||||
pub ui_background_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ui_background_image_object_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub background_music: Option<CreationAudioAsset>,
|
||||
pub board: PuzzleBoardSnapshotResponse,
|
||||
pub status: String,
|
||||
|
||||
@@ -3817,6 +3817,7 @@ pub(crate) fn map_puzzle_runtime_level_snapshot(
|
||||
theme_tags: snapshot.theme_tags,
|
||||
cover_image_src: snapshot.cover_image_src,
|
||||
ui_background_image_src: snapshot.ui_background_image_src,
|
||||
ui_background_image_object_key: snapshot.ui_background_image_object_key,
|
||||
background_music: snapshot.background_music.map(map_puzzle_audio_asset),
|
||||
board: map_puzzle_board_snapshot(snapshot.board),
|
||||
status: snapshot.status.as_str().to_string(),
|
||||
@@ -7510,6 +7511,7 @@ pub struct PuzzleRuntimeLevelRecord {
|
||||
pub theme_tags: Vec<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub ui_background_image_src: Option<String>,
|
||||
pub ui_background_image_object_key: Option<String>,
|
||||
pub background_music: Option<PuzzleAudioAssetRecord>,
|
||||
pub board: PuzzleBoardRecord,
|
||||
pub status: String,
|
||||
|
||||
@@ -179,24 +179,24 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
|
||||
}
|
||||
}
|
||||
|
||||
migrate_visual_novel_entry_from_old_open_default(ctx, now);
|
||||
migrate_visual_novel_entry_from_old_visible_default(ctx, now);
|
||||
}
|
||||
|
||||
fn migrate_visual_novel_entry_from_old_open_default(ctx: &ReducerContext, now: Timestamp) {
|
||||
fn migrate_visual_novel_entry_from_old_visible_default(ctx: &ReducerContext, now: Timestamp) {
|
||||
let id = "visual-novel".to_string();
|
||||
let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// 中文注释:只纠偏旧默认种子,不覆盖后台入口开关里后续手动调整的视觉小说配置。
|
||||
let still_old_default = row.title == "视觉小说"
|
||||
// 中文注释:只纠偏历史默认种子,不覆盖后台入口开关里后续手动调整过的视觉小说配置。
|
||||
let still_old_visible_default = row.title == "视觉小说"
|
||||
&& row.subtitle == "分支叙事体验"
|
||||
&& row.badge == "可创建"
|
||||
&& row.image_src == "/creation-type-references/visual-novel.webp"
|
||||
&& row.visible
|
||||
&& row.open
|
||||
&& ((row.badge == "可创建" && row.open)
|
||||
|| (row.badge == "敬请期待" && !row.open))
|
||||
&& row.sort_order == 60;
|
||||
if !still_old_default {
|
||||
if !still_old_visible_default {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -205,6 +205,7 @@ fn migrate_visual_novel_entry_from_old_open_default(ctx: &ReducerContext, now: T
|
||||
.id()
|
||||
.update(CreationEntryTypeConfig {
|
||||
badge: "敬请期待".to_string(),
|
||||
visible: false,
|
||||
open: false,
|
||||
updated_at: now,
|
||||
..row
|
||||
@@ -274,7 +275,7 @@ fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeC
|
||||
"分支叙事体验",
|
||||
"敬请期待",
|
||||
"/creation-type-references/visual-novel.webp",
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
60,
|
||||
now,
|
||||
|
||||
Reference in New Issue
Block a user