This commit is contained in:
2026-05-14 01:11:58 +08:00
parent b13870f71b
commit 5a55180b78
61 changed files with 5050 additions and 1057 deletions

View File

@@ -1,5 +1,6 @@
use std::{
collections::BTreeMap,
error::Error as StdError,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
@@ -126,8 +127,6 @@ 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_UI_BACKGROUND_REFERENCE_IMAGE_PATH: &str =
"public/ui-previews/puzzle-image-compact-ui-2026-05-08.png";
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND: &str = "puzzle_background_music";
const PUZZLE_BACKGROUND_MUSIC_SLOT: &str = "background_music";
@@ -3372,7 +3371,7 @@ fn normalize_puzzle_ui_background_prompt(
draft.work_description.trim(),
target_level.picture_description.trim(),
tags.as_str(),
"移动端拼图游戏 UI 背景,中央正方形拼图区边界清晰",
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素",
]
.into_iter()
.filter(|value| !value.is_empty())
@@ -3391,7 +3390,7 @@ fn build_puzzle_ui_background_generation_prompt(level_name: &str, prompt: &str)
format!("当前拼图关卡名称:{level_name}")
};
format!(
"{title_clause}{prompt}\n严格参考输入图的构图关系:生成一张 9:16 竖屏拼图游戏 UI 背景图,中央必须预留清晰正方形拼图区,拼图区与外部 UI 背景必须有明确边界、描边或容器层次;拼图区之外可以生成与作品名称相关的氛围背景、顶部安全区和底部工具区背景,但不要画文字、按钮文字、数字、拼图碎片、完整拼图图像、教程浮层或水印"
"{title_clause}{prompt}\n生成一张 9:16 竖屏拼图游戏纯背景图只表现题材氛围、色彩层次和环境空间。画面不得出现拼图槽、棋盘、拼图区边框、物品槽、HUD、按钮、按钮文字、数字、文字、水印、拼图碎片、完整拼图图像、教程浮层或角色手指。中央区域保持干净通透,方便运行态后续叠加默认拼图槽和正式拼图图块"
)
}
@@ -3533,28 +3532,36 @@ async fn compile_puzzle_draft_with_initial_cover(
})?;
let mut target_level = select_puzzle_level_for_api(&draft, None)?;
let fallback_level_name = target_level.level_name.clone();
let generated_level_name =
generate_puzzle_first_level_name(state, &target_level.picture_description).await;
target_level.level_name = generated_level_name.clone();
let image_prompt = resolve_puzzle_draft_cover_prompt(
prompt_text,
&target_level.picture_description,
&draft.summary,
);
let image_level_name = if target_level.level_name.trim().is_empty() {
build_fallback_puzzle_first_level_name(&target_level.picture_description)
} else {
target_level.level_name.clone()
};
// 中文注释:首图 prompt 只依赖画面描述关卡名分支可以和生图分支并行OSS 临时路径使用已有名或确定性兜底名。
let level_name_future =
generate_puzzle_first_level_name(state, &target_level.picture_description);
// 点击生成草稿时一次性完成首图生成与正式图选定,前端只展示进度,不再承担业务编排。
let candidates = generate_puzzle_image_candidates(
let candidates_future = generate_puzzle_image_candidates(
state,
owner_user_id.as_str(),
&compiled_session.session_id,
&target_level.level_name,
&image_level_name,
&image_prompt,
reference_image_src,
true,
image_model,
1,
target_level.candidates.len(),
)
.await?;
);
let (generated_level_name, candidates_result) =
tokio::join!(level_name_future, candidates_future);
target_level.level_name = generated_level_name.clone();
let candidates = candidates_result?;
let selected_candidate_id = candidates
.iter()
.find(|candidate| candidate.record.selected)
@@ -3580,30 +3587,31 @@ async fn compile_puzzle_draft_with_initial_cover(
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);
if let Some(music) = try_generate_puzzle_background_music(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
profile_id.as_str(),
music_title.as_str(),
)
.await
{
// 中文注释:音乐和 UI 背景都只依赖最终关卡名与草稿快照,名称确定后即可并行生成。
let (music_result, ui_background_result) = tokio::join!(
try_generate_puzzle_background_music(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
profile_id.as_str(),
music_title.as_str(),
),
try_generate_puzzle_initial_ui_background(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
&draft,
&target_level,
),
);
if let Some(music) = music_result {
attach_puzzle_level_background_music(
&mut updated_levels,
target_level.level_id.as_str(),
music,
);
}
if let Some((ui_prompt, ui_background)) = try_generate_puzzle_initial_ui_background(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
&draft,
&target_level,
)
.await
{
if let Some((ui_prompt, ui_background)) = ui_background_result {
attach_puzzle_level_ui_background(
&mut updated_levels,
target_level.level_id.as_str(),
@@ -3736,14 +3744,16 @@ async fn compile_puzzle_draft_with_uploaded_cover(
})?;
let mut target_level = select_puzzle_level_for_api(&draft, None)?;
let fallback_level_name = target_level.level_name.clone();
let generated_level_name =
generate_puzzle_first_level_name(state, &target_level.picture_description).await;
target_level.level_name = generated_level_name.clone();
let image_prompt = resolve_puzzle_draft_cover_prompt(
prompt_text,
&target_level.picture_description,
&draft.summary,
);
let image_level_name = if target_level.level_name.trim().is_empty() {
build_fallback_puzzle_first_level_name(&target_level.picture_description)
} else {
target_level.level_name.clone()
};
// 中文注释:关闭 AI 重绘时首关图不请求 VectorEngine上传图直接成为首关正式图候选。
let candidate_id = format!(
"{}-candidate-{}",
@@ -3755,44 +3765,60 @@ async fn compile_puzzle_draft_with_uploaded_cover(
mime_type: normalize_puzzle_downloaded_image_mime_type(uploaded_image.mime_type.as_str()),
bytes: uploaded_image.bytes,
};
if let Some(refined_level_name) = generate_puzzle_first_level_name_from_image(
let level_name_future =
generate_puzzle_first_level_name(state, &target_level.picture_description);
let image_level_name_future = generate_puzzle_first_level_name_from_image(
state,
target_level.picture_description.as_str(),
&uploaded_downloaded_image,
)
.await
{
target_level.level_name = refined_level_name;
}
);
let persist_upload_future = persist_puzzle_generated_asset(
state,
owner_user_id.as_str(),
&compiled_session.session_id,
image_level_name.as_str(),
candidate_id.as_str(),
"uploaded-direct",
uploaded_downloaded_image.clone(),
current_utc_micros(),
);
let (generated_level_name, refined_level_name, 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());
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);
if let Some(music) = try_generate_puzzle_background_music(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
profile_id.as_str(),
music_title.as_str(),
)
.await
{
// 中文注释:直用上传图时,名称分支和上传图落库完成后,再并行补齐音乐与 UI 背景。
let (music_result, ui_background_result) = tokio::join!(
try_generate_puzzle_background_music(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
profile_id.as_str(),
music_title.as_str(),
),
try_generate_puzzle_initial_ui_background(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
&draft,
&target_level,
),
);
if let Some(music) = music_result {
attach_puzzle_level_background_music(
&mut updated_levels,
target_level.level_id.as_str(),
music,
);
}
if let Some((ui_prompt, ui_background)) = try_generate_puzzle_initial_ui_background(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
&draft,
&target_level,
)
.await
{
if let Some((ui_prompt, ui_background)) = ui_background_result {
attach_puzzle_level_ui_background(
&mut updated_levels,
target_level.level_id.as_str(),
@@ -3802,17 +3828,6 @@ async fn compile_puzzle_draft_with_uploaded_cover(
}
let levels_json_with_generated_name =
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
let persisted_upload = persist_puzzle_generated_asset(
state,
owner_user_id.as_str(),
&compiled_session.session_id,
&target_level.level_name,
candidate_id.as_str(),
"uploaded-direct",
uploaded_downloaded_image,
current_utc_micros(),
)
.await?;
let candidate = PuzzleGeneratedImageCandidateRecord {
candidate_id: candidate_id.clone(),
image_src: persisted_upload.image_src,
@@ -4769,15 +4784,14 @@ async fn generate_puzzle_ui_background_image(
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let reference_image = load_puzzle_ui_background_reference_data_url().await?;
let generated = create_openai_image_generation(
&http_client,
&settings,
build_puzzle_ui_background_generation_prompt(level_name, prompt).as_str(),
Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、角色手指、模糊边界"),
Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、拼图槽、棋盘、拼图区边框、物品槽、HUD、角色手指"),
"9:16",
1,
&[reference_image],
&[],
"拼图 UI 背景图生成失败",
)
.await?;
@@ -4803,29 +4817,6 @@ fn build_puzzle_ui_background_request_prompt_for_test(level_name: &str, prompt:
build_puzzle_ui_background_generation_prompt(level_name, prompt)
}
async fn load_puzzle_ui_background_reference_data_url() -> Result<String, AppError> {
let bytes = tokio::fs::read(PUZZLE_UI_BACKGROUND_REFERENCE_IMAGE_PATH)
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("读取拼图 UI 背景参考图失败:{error}"),
}))
})?;
if bytes.is_empty() {
return Err(
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图 UI 背景参考图为空",
})),
);
}
Ok(format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(bytes)
))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -4914,6 +4905,32 @@ mod tests {
));
}
#[test]
fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
let error = map_puzzle_vector_engine_request_error(
"创建拼图 VectorEngine 图片生成任务失败operation timed out".to_string(),
);
let response = error.into_response();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
}
#[test]
fn puzzle_vector_engine_reqwest_error_maps_to_bad_gateway() {
let error = match reqwest::Client::new().get("http://[::1").build() {
Ok(_) => panic!("invalid url should fail request build"),
Err(error) => error,
};
let app_error = map_puzzle_vector_engine_reqwest_error(
"创建拼图 VectorEngine 图片编辑任务失败",
"https://api.vectorengine.ai/v1/images/edits",
error,
);
let response = app_error.into_response();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
}
#[test]
fn puzzle_compile_error_preserves_vector_engine_unavailable_status() {
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
@@ -5210,14 +5227,15 @@ mod tests {
}
#[test]
fn puzzle_ui_background_prompt_keeps_square_boundary_constraint() {
fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() {
let prompt =
build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景");
assert!(prompt.contains("9:16"));
assert!(prompt.contains("中央必须预留清晰正方形拼图区"));
assert!(prompt.contains("明确边界"));
assert!(prompt.contains("不要画文字"));
assert!(prompt.contains("纯背景图"));
assert!(prompt.contains("不得出现拼图槽"));
assert!(prompt.contains("默认拼图槽"));
assert!(prompt.contains("文字"));
}
#[test]
@@ -5530,6 +5548,8 @@ fn build_puzzle_image_http_client(
reqwest::Client::builder()
.timeout(Duration::from_millis(request_timeout_ms.max(1)))
// 中文注释VectorEngine 的图片编辑接口是 multipart 请求;强制 HTTP/1.1 可避开部分网关对 HTTP/2 multipart 流的中断兼容问题。
.http1_only()
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
@@ -5690,9 +5710,11 @@ async fn create_puzzle_vector_engine_image_edit(
.send()
.await
.map_err(|error| {
map_puzzle_vector_engine_request_error(format!(
"创建拼图 VectorEngine 图片编辑任务失败{error}"
))
map_puzzle_vector_engine_reqwest_error(
"创建拼图 VectorEngine 图片编辑任务失败",
&request_url,
error,
)
})?;
let status = response.status();
tracing::info!(
@@ -6361,19 +6383,107 @@ fn puzzle_mime_to_extension(mime_type: &str) -> &str {
}
fn map_puzzle_image_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
let is_timeout = is_puzzle_request_timeout_message(message.as_str());
let status = if is_timeout {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
AppError::from_status(status).with_details(json!({
"provider": "puzzle-image",
"message": message,
"timeout": is_timeout,
}))
}
fn map_puzzle_vector_engine_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
let is_timeout = is_puzzle_request_timeout_message(message.as_str());
let status = if is_timeout {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
AppError::from_status(status).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": message,
"timeout": is_timeout,
}))
}
fn map_puzzle_vector_engine_reqwest_error(
context: &str,
request_url: &str,
error: reqwest::Error,
) -> AppError {
let message = format!(
"{context}{}",
normalize_puzzle_reqwest_error_message(&error)
);
let is_timeout = error.is_timeout() || is_puzzle_request_timeout_message(message.as_str());
let is_connect = error.is_connect();
let status = if is_timeout {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
let source = error.source().map(ToString::to_string).unwrap_or_default();
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
timeout = is_timeout,
connect = is_connect,
request = error.is_request(),
body = error.is_body(),
source = %source,
message = %message,
"拼图 VectorEngine 请求发送失败"
);
AppError::from_status(status).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": message,
"reason": resolve_puzzle_vector_engine_request_failure_reason(&error),
"endpoint": request_url,
"timeout": is_timeout,
"connect": is_connect,
"request": error.is_request(),
"body": error.is_body(),
"source": source,
}))
}
fn normalize_puzzle_reqwest_error_message(error: &reqwest::Error) -> String {
error
.to_string()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn resolve_puzzle_vector_engine_request_failure_reason(error: &reqwest::Error) -> &'static str {
if error.is_timeout() {
return "VectorEngine 图片编辑请求超时,请稍后重试或调大 VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS";
}
if error.is_connect() {
return "无法连接 VectorEngine 图片编辑接口请检查服务器网络、DNS、防火墙或代理配置";
}
if error.is_body() {
return "发送 VectorEngine 图片编辑 multipart 请求体失败,请重试并检查参考图大小";
}
"VectorEngine 图片编辑请求发送失败,请查看 source 字段中的底层网络错误"
}
fn is_puzzle_request_timeout_message(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("timed out")
|| lower.contains("timeout")
|| lower.contains("operation timed out")
|| lower.contains("deadline has elapsed")
}
fn map_puzzle_vector_engine_upstream_error(
upstream_status: reqwest::StatusCode,
raw_text: &str,