1
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user