Switch to VectorEngine gpt-image-2 and edits

Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
This commit is contained in:
2026-05-22 03:06:41 +08:00
parent 321e1ea33a
commit ae014ac881
90 changed files with 7078 additions and 3389 deletions

View File

@@ -184,6 +184,12 @@ pub(crate) fn parse_puzzle_level_records_from_module_json(
ui_background_prompt: level.ui_background_prompt,
ui_background_image_src: level.ui_background_image_src,
ui_background_image_object_key: level.ui_background_image_object_key,
level_scene_image_src: level.level_scene_image_src,
level_scene_image_object_key: level.level_scene_image_object_key,
ui_spritesheet_image_src: level.ui_spritesheet_image_src,
ui_spritesheet_image_object_key: level.ui_spritesheet_image_object_key,
level_background_image_src: level.level_background_image_src,
level_background_image_object_key: level.level_background_image_object_key,
background_music: level
.background_music
.map(map_puzzle_audio_asset_domain_record),
@@ -357,6 +363,12 @@ pub(crate) fn serialize_puzzle_levels_response(
"ui_background_prompt": level.ui_background_prompt,
"ui_background_image_src": level.ui_background_image_src,
"ui_background_image_object_key": level.ui_background_image_object_key,
"level_scene_image_src": level.level_scene_image_src,
"level_scene_image_object_key": level.level_scene_image_object_key,
"ui_spritesheet_image_src": level.ui_spritesheet_image_src,
"ui_spritesheet_image_object_key": level.ui_spritesheet_image_object_key,
"level_background_image_src": level.level_background_image_src,
"level_background_image_object_key": level.level_background_image_object_key,
"background_music": puzzle_audio_asset_response_module_json(&level.background_music),
"candidates": level
.candidates
@@ -411,6 +423,12 @@ pub(crate) fn normalize_puzzle_levels_json_for_module(
"ui_background_prompt": level.ui_background_prompt,
"ui_background_image_src": level.ui_background_image_src,
"ui_background_image_object_key": level.ui_background_image_object_key,
"level_scene_image_src": level.level_scene_image_src,
"level_scene_image_object_key": level.level_scene_image_object_key,
"ui_spritesheet_image_src": level.ui_spritesheet_image_src,
"ui_spritesheet_image_object_key": level.ui_spritesheet_image_object_key,
"level_background_image_src": level.level_background_image_src,
"level_background_image_object_key": level.level_background_image_object_key,
"background_music": puzzle_audio_asset_response_module_json(&level.background_music),
"candidates": level
.candidates
@@ -918,6 +936,15 @@ pub(crate) fn build_puzzle_levels_with_primary_update(
levels[index].ui_background_image_src = target_level.ui_background_image_src.clone();
levels[index].ui_background_image_object_key =
target_level.ui_background_image_object_key.clone();
levels[index].level_scene_image_src = target_level.level_scene_image_src.clone();
levels[index].level_scene_image_object_key =
target_level.level_scene_image_object_key.clone();
levels[index].ui_spritesheet_image_src = target_level.ui_spritesheet_image_src.clone();
levels[index].ui_spritesheet_image_object_key =
target_level.ui_spritesheet_image_object_key.clone();
levels[index].level_background_image_src = target_level.level_background_image_src.clone();
levels[index].level_background_image_object_key =
target_level.level_background_image_object_key.clone();
if let Some(picture_reference) = picture_reference
.map(str::trim)
.filter(|value| !value.is_empty())
@@ -1033,6 +1060,29 @@ pub(crate) fn attach_puzzle_level_ui_background(
levels[index].ui_background_image_object_key = Some(generated.object_key);
}
pub(crate) fn attach_puzzle_level_asset_bundle(
levels: &mut [PuzzleDraftLevelRecord],
level_id: &str,
generated: GeneratedPuzzleLevelAssetBundle,
) {
let Some(index) = levels
.iter()
.position(|level| level.level_id == level_id)
.or_else(|| (!levels.is_empty()).then_some(0))
else {
return;
};
let level = &mut levels[index];
level.level_scene_image_src = Some(generated.level_scene.image_src);
level.level_scene_image_object_key = Some(generated.level_scene.object_key);
level.ui_spritesheet_image_src = Some(generated.ui_spritesheet.image_src);
level.ui_spritesheet_image_object_key = Some(generated.ui_spritesheet.object_key);
level.level_background_image_src = Some(generated.level_background.image_src.clone());
level.level_background_image_object_key = Some(generated.level_background.object_key.clone());
level.ui_background_image_src = Some(generated.level_background.image_src);
level.ui_background_image_object_key = Some(generated.level_background.object_key);
}
pub(crate) async fn generate_puzzle_initial_ui_background_required(
state: &PuzzleApiState,
owner_user_id: &str,
@@ -1052,26 +1102,56 @@ pub(crate) async fn generate_puzzle_initial_ui_background_required(
Ok((prompt, generated))
}
pub(crate) async fn generate_puzzle_level_asset_bundle_required(
state: &PuzzleApiState,
owner_user_id: &str,
session_id: &str,
target_level: &PuzzleDraftLevelRecord,
puzzle_image: &PuzzleDownloadedImage,
) -> Result<GeneratedPuzzleLevelAssetBundle, AppError> {
generate_puzzle_level_asset_bundle(
state,
owner_user_id,
session_id,
target_level.level_name.as_str(),
puzzle_image,
)
.await
}
pub(crate) fn ensure_puzzle_initial_level_assets_ready(
level: &PuzzleDraftLevelRecord,
) -> Result<(), AppError> {
let has_ui_background = level
.ui_background_image_src
let has_level_background = level
.level_background_image_src
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
|| level
.ui_background_image_object_key
.level_background_image_object_key
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty());
if has_ui_background {
let has_ui_spritesheet = level
.ui_spritesheet_image_src
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
|| level
.ui_spritesheet_image_object_key
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty());
if has_level_background && has_ui_spritesheet {
return Ok(());
}
let mut missing = Vec::new();
if !has_ui_background {
missing.push("UI背景图");
if !has_level_background {
missing.push("关卡背景图");
}
if !has_ui_spritesheet {
missing.push("UI spritesheet");
}
Err(
@@ -1125,8 +1205,8 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
target_level.level_name = generated_naming.level_name.clone();
target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone();
let mut generated_metadata = generated_naming;
// 点击生成草稿时一次性完成首图生成、UI 背景生成与正式图选定,前端只展示进度,不再承担业务编排。
let candidates_future = generate_puzzle_image_candidates(
// 点击生成草稿时一次性完成拼图主图和运行态资产包,前端只展示进度,不再承担业务编排。
let mut candidates = generate_puzzle_image_candidates(
state,
owner_user_id.as_str(),
&compiled_session.session_id,
@@ -1137,18 +1217,8 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
image_model,
1,
target_level.candidates.len(),
);
let ui_background_future = generate_puzzle_initial_ui_background_required(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
&draft,
&target_level,
);
// 中文注释:命名稳定后并行发起首关图与 UI 背景,避免两次外部生图串行等待。
let (candidates_result, ui_background_result) =
tokio::join!(candidates_future, ui_background_future);
let mut candidates = candidates_result?;
)
.await?;
if let Some(first_candidate) = candidates.first()
&& let Some(refined_naming) = generate_puzzle_first_level_name_from_image(
state,
@@ -1184,19 +1254,25 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
"message": "拼图候选图生成结果为空",
}))
})?;
// 中文注释:拼图草稿音频生成临时关闭,首版生成只补首图与 UI 背景
let (ui_prompt, ui_background) = ui_background_result?;
attach_puzzle_level_ui_background(
&mut updated_levels,
target_level.level_id.as_str(),
ui_prompt,
ui_background,
);
// 中文注释:拼图草稿音频生成临时关闭,首版生成只补首图、关卡背景和 UI spritesheet
if let Some(selected_candidate) = candidates
.iter()
.find(|candidate| candidate.record.selected)
.or_else(|| candidates.first())
{
let asset_bundle = generate_puzzle_level_asset_bundle_required(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
&target_level,
&selected_candidate.downloaded_image,
)
.await?;
attach_puzzle_level_asset_bundle(
&mut updated_levels,
target_level.level_id.as_str(),
asset_bundle,
);
attach_selected_puzzle_candidate_to_levels(
&mut updated_levels,
target_level.level_id.as_str(),
@@ -1455,7 +1531,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
let generated_level_name = target_level.level_name.clone();
let mut updated_levels =
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
let persist_upload_future = persist_puzzle_generated_asset(
let persisted_upload = persist_puzzle_generated_asset(
state,
owner_user_id.as_str(),
&compiled_session.session_id,
@@ -1464,24 +1540,20 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
"uploaded-direct",
uploaded_downloaded_image.clone(),
current_utc_micros(),
);
let ui_background_future = generate_puzzle_initial_ui_background_required(
)
.await?;
let asset_bundle = generate_puzzle_level_asset_bundle_required(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
&draft,
&target_level,
);
// 中文注释:直用上传图时并行完成上传图持久化与 UI 背景生成;音频生成入口临时关闭。
let (persisted_upload_result, ui_background_result) =
tokio::join!(persist_upload_future, ui_background_future);
let persisted_upload = persisted_upload_result?;
let (ui_prompt, ui_background) = ui_background_result?;
attach_puzzle_level_ui_background(
&uploaded_downloaded_image,
)
.await?;
attach_puzzle_level_asset_bundle(
&mut updated_levels,
target_level.level_id.as_str(),
ui_prompt,
ui_background,
asset_bundle,
);
attach_selected_puzzle_candidate_to_levels(
&mut updated_levels,

View File

@@ -12,9 +12,67 @@ pub(crate) fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError
error
}
pub(crate) fn should_fallback_puzzle_reference_edit_to_generation(error: &AppError) -> bool {
error.status_code() == StatusCode::GATEWAY_TIMEOUT
|| is_puzzle_request_timeout_message(error.body_text().as_str())
pub(crate) fn should_use_uploaded_puzzle_image_directly(
reference_image_src: Option<&str>,
ai_redraw: bool,
) -> bool {
!ai_redraw
&& reference_image_src
.map(str::trim)
.is_some_and(|value| !value.is_empty())
}
pub(crate) async fn create_uploaded_puzzle_image_candidate(
state: &PuzzleApiState,
owner_user_id: &str,
session_id: &str,
level_name: &str,
prompt: &str,
reference_image_src: &str,
candidate_start_index: usize,
) -> Result<GeneratedPuzzleImageCandidate, AppError> {
let http_client = reqwest::Client::new();
let downloaded_image =
resolve_puzzle_reference_image_as_data_url(state, &http_client, reference_image_src)
.await
.map(PuzzleDownloadedImage::from_resolved_reference_image)
.map_err(|error| {
if error.status_code() == StatusCode::BAD_REQUEST {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"field": "referenceImageSrc",
"message": "关闭 AI 重绘时上传图必须是图片 Data URL 或历史生成图片路径。",
}))
} else {
error
}
})?;
let candidate_id = format!("{session_id}-candidate-{}", candidate_start_index + 1);
let asset = persist_puzzle_generated_asset(
state,
owner_user_id,
session_id,
level_name,
candidate_id.as_str(),
"uploaded-direct",
downloaded_image.clone(),
current_utc_micros(),
)
.await
.map_err(map_puzzle_generation_endpoint_error)?;
Ok(GeneratedPuzzleImageCandidate {
record: PuzzleGeneratedImageCandidateRecord {
candidate_id,
image_src: asset.image_src,
asset_id: asset.asset_id,
prompt: prompt.to_string(),
actual_prompt: None,
source_type: "uploaded".to_string(),
selected: true,
},
downloaded_image,
})
}
pub(crate) async fn generate_puzzle_image_candidates(
@@ -24,7 +82,7 @@ pub(crate) async fn generate_puzzle_image_candidates(
level_name: &str,
prompt: &str,
reference_image_src: Option<&str>,
use_reference_image_edit: bool,
use_reference_image_generation: bool,
image_model: Option<&str>,
candidate_count: u32,
candidate_start_index: usize,
@@ -34,11 +92,13 @@ pub(crate) async fn generate_puzzle_image_candidates(
let resolved_model = resolve_puzzle_image_model(image_model);
let http_client = build_puzzle_image_http_client(state, resolved_model)?;
let has_reference_image = has_puzzle_reference_image(reference_image_src);
let should_use_reference_image_edit =
should_use_puzzle_reference_image_edit(reference_image_src, use_reference_image_edit);
let should_use_reference_image_generation = should_use_puzzle_reference_image_generation(
reference_image_src,
use_reference_image_generation,
);
let actual_prompt = build_puzzle_vector_engine_generation_prompt(
build_puzzle_image_prompt(level_name, prompt).as_str(),
should_use_reference_image_edit,
should_use_reference_image_generation,
);
tracing::info!(
provider = resolved_model.provider_name(),
@@ -48,23 +108,19 @@ pub(crate) async fn generate_puzzle_image_candidates(
prompt_chars = prompt.chars().count(),
actual_prompt_chars = actual_prompt.chars().count(),
has_reference_image,
use_reference_image_edit = should_use_reference_image_edit,
use_reference_image_generation = should_use_reference_image_generation,
"拼图图片生成请求已准备"
);
let reference_image_started_at = Instant::now();
let reference_image = match reference_image_src
.map(str::trim)
.filter(|value| !value.is_empty())
.filter(|_| should_use_reference_image_edit)
.filter(|_| should_use_reference_image_generation)
{
Some(source) => {
let resolved = resolve_puzzle_reference_image(
state,
&http_client,
source,
Some(owner_user_id),
)
.await?;
let resolved =
resolve_puzzle_reference_image(state, &http_client, source, Some(owner_user_id))
.await?;
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
@@ -79,14 +135,14 @@ pub(crate) async fn generate_puzzle_image_candidates(
}
None => None,
};
if !should_use_reference_image_edit {
if !should_use_reference_image_generation {
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
has_reference_image,
use_reference_image_edit = should_use_reference_image_edit,
use_reference_image_generation = should_use_reference_image_generation,
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
"拼图参考图解析跳过"
);
@@ -95,7 +151,7 @@ pub(crate) async fn generate_puzzle_image_candidates(
// 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。
let settings = require_puzzle_vector_engine_settings(state)?;
let vector_engine_started_at = Instant::now();
let generated = if should_use_reference_image_edit {
let generated = if should_use_reference_image_generation {
let reference_image = reference_image.as_ref().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "puzzle",
@@ -103,43 +159,17 @@ pub(crate) async fn generate_puzzle_image_candidates(
"message": "AI 重绘需要提供参考图。",
}))
})?;
let edit_result = create_puzzle_vector_engine_image_edit(
create_puzzle_vector_engine_image_generation(
&http_client,
&settings,
resolved_model,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
count,
reference_image,
Some(reference_image),
)
.await;
match edit_result {
Ok(generated) => Ok(generated),
Err(error) if should_fallback_puzzle_reference_edit_to_generation(&error) => {
tracing::warn!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
reference_mime = %reference_image.mime_type,
reference_bytes = reference_image.bytes_len,
error = %error,
"拼图参考图编辑接口超时,降级为带参考图的生成接口"
);
create_puzzle_vector_engine_image_generation(
&http_client,
&settings,
resolved_model,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
count,
Some(reference_image),
)
.await
}
Err(error) => Err(error),
}
.await
} else {
create_puzzle_vector_engine_image_generation(
&http_client,
@@ -260,6 +290,175 @@ pub(crate) async fn generate_puzzle_ui_background_image(
.await
}
pub(crate) async fn generate_puzzle_level_asset_bundle(
state: &PuzzleApiState,
owner_user_id: &str,
session_id: &str,
level_name: &str,
puzzle_image: &PuzzleDownloadedImage,
) -> Result<GeneratedPuzzleLevelAssetBundle, AppError> {
let settings = require_puzzle_vector_engine_settings(state)?;
let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?;
let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image);
let scene_generated = create_puzzle_vector_engine_image_generation(
&http_client,
&settings,
PuzzleImageModel::GptImage2,
PUZZLE_LEVEL_SCENE_IMAGE_PROMPT,
"",
PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE,
1,
Some(&puzzle_reference),
)
.await
.map_err(map_puzzle_generation_endpoint_error)?;
let scene_image = scene_generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": "拼图关卡画面图生成失败:未返回图片",
}))
})?;
let scene_reference = build_puzzle_downloaded_image_reference(&scene_image);
let scene_persist_future = persist_puzzle_level_asset_image(
state,
owner_user_id,
session_id,
level_name,
scene_generated.task_id.as_str(),
"level-scene",
"puzzle_level_scene_image",
"level_scene",
"scene",
scene_image,
);
let spritesheet_future = generate_and_persist_puzzle_level_asset(
state,
&http_client,
&settings,
owner_user_id,
session_id,
level_name,
PUZZLE_UI_SPRITESHEET_IMAGE_PROMPT,
PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE,
&scene_reference,
"ui-spritesheet",
"puzzle_ui_spritesheet_image",
"ui_spritesheet",
"spritesheet",
);
let background_future = generate_and_persist_puzzle_level_asset(
state,
&http_client,
&settings,
owner_user_id,
session_id,
level_name,
PUZZLE_LEVEL_BACKGROUND_IMAGE_PROMPT,
PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE,
&scene_reference,
"level-background",
"puzzle_level_background_image",
"level_background",
"background",
);
let (level_scene, ui_spritesheet, level_background) =
tokio::join!(scene_persist_future, spritesheet_future, background_future);
Ok(GeneratedPuzzleLevelAssetBundle {
level_scene: level_scene?,
ui_spritesheet: ui_spritesheet?,
level_background: level_background?,
})
}
async fn generate_and_persist_puzzle_level_asset(
state: &PuzzleApiState,
http_client: &reqwest::Client,
settings: &PuzzleVectorEngineSettings,
owner_user_id: &str,
session_id: &str,
level_name: &str,
prompt: &str,
size: &str,
reference_image: &PuzzleResolvedReferenceImage,
path_segment: &str,
asset_kind: &str,
slot: &str,
file_stem: &str,
) -> Result<GeneratedPuzzleLevelAssetResponse, AppError> {
let generated = create_puzzle_vector_engine_image_generation(
http_client,
settings,
PuzzleImageModel::GptImage2,
prompt,
"",
size,
1,
Some(reference_image),
)
.await
.map_err(map_puzzle_generation_endpoint_error)?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("拼图关卡资产生成失败:{asset_kind} 未返回图片"),
}))
})?;
let image = if slot == "ui_spritesheet" {
make_puzzle_ui_spritesheet_image_transparent(image)?
} else {
image
};
persist_puzzle_level_asset_image(
state,
owner_user_id,
session_id,
level_name,
generated.task_id.as_str(),
path_segment,
asset_kind,
slot,
file_stem,
image,
)
.await
}
pub(crate) fn make_puzzle_ui_spritesheet_image_transparent(
image: PuzzleDownloadedImage,
) -> Result<PuzzleDownloadedImage, AppError> {
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("拼图 UI spritesheet 图解码失败:{error}"),
}))
})?;
let mut encoded = std::io::Cursor::new(Vec::new());
apply_generated_asset_sheet_green_screen_alpha(source)
.write_to(&mut encoded, ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("拼图 UI spritesheet 图透明化失败:{error}"),
}))
})?;
Ok(PuzzleDownloadedImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: encoded.into_inner(),
})
}
#[cfg(test)]
pub(crate) fn make_puzzle_ui_spritesheet_image_transparent_for_test(
image: PuzzleDownloadedImage,
) -> Result<PuzzleDownloadedImage, AppError> {
make_puzzle_ui_spritesheet_image_transparent(image)
}
#[cfg(test)]
pub(crate) fn build_puzzle_ui_background_request_prompt_for_test(
level_name: &str,
@@ -267,3 +466,45 @@ pub(crate) fn build_puzzle_ui_background_request_prompt_for_test(
) -> String {
build_puzzle_ui_background_generation_prompt(level_name, prompt)
}
#[cfg(test)]
pub(crate) fn build_puzzle_level_scene_image_request_body_for_test(
reference_image: &PuzzleDownloadedImage,
) -> Result<Value, AppError> {
Ok(build_puzzle_vector_engine_image_request_body(
PuzzleImageModel::GptImage2,
PUZZLE_LEVEL_SCENE_IMAGE_PROMPT,
"",
PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE,
1,
Some(&build_puzzle_downloaded_image_reference(reference_image)),
))
}
#[cfg(test)]
pub(crate) fn build_puzzle_ui_spritesheet_request_body_for_test(
reference_image: &PuzzleDownloadedImage,
) -> Result<Value, AppError> {
Ok(build_puzzle_vector_engine_image_request_body(
PuzzleImageModel::GptImage2,
PUZZLE_UI_SPRITESHEET_IMAGE_PROMPT,
"",
PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE,
1,
Some(&build_puzzle_downloaded_image_reference(reference_image)),
))
}
#[cfg(test)]
pub(crate) fn build_puzzle_level_background_request_body_for_test(
reference_image: &PuzzleDownloadedImage,
) -> Result<Value, AppError> {
Ok(build_puzzle_vector_engine_image_request_body(
PuzzleImageModel::GptImage2,
PUZZLE_LEVEL_BACKGROUND_IMAGE_PROMPT,
"",
PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE,
1,
Some(&build_puzzle_downloaded_image_reference(reference_image)),
))
}

View File

@@ -113,6 +113,12 @@ pub async fn generate_puzzle_onboarding_work(
ui_background_prompt: naming.ui_background_prompt.clone(),
ui_background_image_src: None,
ui_background_image_object_key: None,
level_scene_image_src: None,
level_scene_image_object_key: None,
ui_spritesheet_image_src: None,
ui_spritesheet_image_object_key: None,
level_background_image_src: None,
level_background_image_object_key: None,
background_music: None,
candidates,
selected_candidate_id: Some(selected.candidate_id.clone()),
@@ -772,6 +778,7 @@ pub async fn execute_puzzle_agent_action(
let prompt = resolve_puzzle_level_image_prompt(
payload.prompt_text.as_deref(),
&target_level.picture_description,
&draft.summary,
);
let should_auto_name_level = payload
.should_auto_name_level
@@ -797,22 +804,40 @@ pub async fn execute_puzzle_agent_action(
let primary_reference_image_src =
reference_image_sources.first().map(String::as_str);
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
let candidate_count = 1;
let candidate_start_index = target_level.candidates.len();
let candidates = generate_puzzle_image_candidates(
&state,
owner_user_id.as_str(),
&session.session_id,
&target_level.level_name,
&prompt,
let ai_redraw = payload.ai_redraw.unwrap_or(true);
let mut candidates = if should_use_uploaded_puzzle_image_directly(
primary_reference_image_src,
payload.ai_redraw.unwrap_or(true),
payload.image_model.as_deref(),
candidate_count,
candidate_start_index,
)
.await
.map_err(map_puzzle_generation_endpoint_error)?;
ai_redraw,
) {
vec![
create_uploaded_puzzle_image_candidate(
&state,
owner_user_id.as_str(),
&session.session_id,
&target_level.level_name,
&prompt,
primary_reference_image_src.expect("checked reference image"),
candidate_start_index,
)
.await?,
]
} else {
generate_puzzle_image_candidates(
&state,
owner_user_id.as_str(),
&session.session_id,
&target_level.level_name,
&prompt,
primary_reference_image_src,
ai_redraw,
payload.image_model.as_deref(),
1,
candidate_start_index,
)
.await
.map_err(map_puzzle_generation_endpoint_error)?
};
if candidates.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(
json!({
@@ -837,14 +862,44 @@ pub async fn execute_puzzle_agent_action(
generated_naming = Some(refined_naming);
}
let generated_level_name = target_level.level_name.clone();
let mut updated_levels = build_puzzle_levels_with_primary_update(
&draft,
&target_level,
primary_reference_image_src,
);
for candidate in &mut candidates {
candidate.record.prompt = prompt.clone();
}
let selected_candidate = candidates
.iter()
.find(|candidate| candidate.record.selected)
.or_else(|| candidates.first())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图候选图生成结果为空",
}))
})?;
let asset_bundle = generate_puzzle_level_asset_bundle_required(
&state,
owner_user_id.as_str(),
&session.session_id,
&target_level,
&selected_candidate.downloaded_image,
)
.await?;
attach_puzzle_level_asset_bundle(
&mut updated_levels,
target_level.level_id.as_str(),
asset_bundle,
);
attach_selected_puzzle_candidate_to_levels(
&mut updated_levels,
target_level.level_id.as_str(),
&selected_candidate.record,
);
let levels_json_with_generated_name =
Some(serialize_puzzle_level_records_for_module(
&build_puzzle_levels_with_primary_update(
&draft,
&target_level,
primary_reference_image_src,
),
)?);
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
let candidates_json = serde_json::to_string(
&candidates
.iter()
@@ -896,7 +951,11 @@ pub async fn execute_puzzle_agent_action(
};
let mut fallback_session =
apply_generated_puzzle_candidates_to_session_snapshot(
fallback_session,
apply_generated_puzzle_levels_to_session_snapshot(
fallback_session,
updated_levels,
now,
),
target_level.level_id.as_str(),
candidates.into_records(),
primary_reference_image_src,

View File

@@ -105,6 +105,12 @@ pub(super) fn map_puzzle_draft_level_response(
ui_background_prompt: level.ui_background_prompt,
ui_background_image_src: level.ui_background_image_src,
ui_background_image_object_key: level.ui_background_image_object_key,
level_scene_image_src: level.level_scene_image_src,
level_scene_image_object_key: level.level_scene_image_object_key,
ui_spritesheet_image_src: level.ui_spritesheet_image_src,
ui_spritesheet_image_object_key: level.ui_spritesheet_image_object_key,
level_background_image_src: level.level_background_image_src,
level_background_image_object_key: level.level_background_image_object_key,
background_music: level
.background_music
.map(map_puzzle_audio_asset_record_response),
@@ -541,6 +547,10 @@ pub(super) fn map_puzzle_runtime_level_response(
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,
level_background_image_src: level.level_background_image_src,
level_background_image_object_key: level.level_background_image_object_key,
ui_spritesheet_image_src: level.ui_spritesheet_image_src,
ui_spritesheet_image_object_key: level.ui_spritesheet_image_object_key,
background_music: level
.background_music
.map(map_puzzle_audio_asset_record_response),

View File

@@ -278,6 +278,12 @@ pub(super) fn serialize_puzzle_level_records_for_module(
"ui_background_prompt": level.ui_background_prompt,
"ui_background_image_src": level.ui_background_image_src,
"ui_background_image_object_key": level.ui_background_image_object_key,
"level_scene_image_src": level.level_scene_image_src,
"level_scene_image_object_key": level.level_scene_image_object_key,
"ui_spritesheet_image_src": level.ui_spritesheet_image_src,
"ui_spritesheet_image_object_key": level.ui_spritesheet_image_object_key,
"level_background_image_src": level.level_background_image_src,
"level_background_image_object_key": level.level_background_image_object_key,
"background_music": puzzle_audio_asset_record_module_json(&level.background_music),
"candidates": level
.candidates

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::openai_image_generation::GPT_IMAGE_2_MODEL;
#[test]
fn puzzle_generated_image_size_is_square_1_1() {
@@ -7,7 +8,7 @@ fn puzzle_generated_image_size_is_square_1_1() {
}
#[test]
fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() {
fn puzzle_vector_engine_create_request_uses_gpt_image_2_without_reference_images() {
let body = build_puzzle_vector_engine_image_request_body(
PuzzleImageModel::Gemini31FlashPreview,
"一只猫在雨夜灯牌下回头。",
@@ -17,7 +18,7 @@ fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() {
None,
);
assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL);
assert_eq!(body["model"], GPT_IMAGE_2_MODEL);
assert_eq!(body["size"], PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE);
assert_eq!(body["n"], 1);
assert!(body.get("official_fallback").is_none());
@@ -31,7 +32,7 @@ fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() {
}
#[test]
fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
fn puzzle_vector_engine_create_request_never_embeds_reference_image() {
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
let mut cursor = std::io::Cursor::new(Vec::new());
image
@@ -53,20 +54,148 @@ fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
Some(&reference_image),
);
let images = body["image"]
.as_array()
.expect("fallback generation should include reference image array");
assert_eq!(images.len(), 1);
assert!(body.get("image").is_none());
}
#[test]
fn puzzle_level_scene_spritesheet_and_background_requests_use_references() {
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
let mut cursor = std::io::Cursor::new(Vec::new());
image
.write_to(&mut cursor, ImageFormat::Png)
.expect("test image should encode");
let reference_image = PuzzleDownloadedImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: cursor.into_inner(),
};
let scene_body = build_puzzle_level_scene_image_request_body_for_test(&reference_image)
.expect("scene request should build");
assert_eq!(scene_body["model"], GPT_IMAGE_2_MODEL);
assert_eq!(scene_body["size"], "1024x1536");
assert!(scene_body.get("image").is_none());
assert!(
images[0]
scene_body["prompt"]
.as_str()
.unwrap_or_default()
.starts_with("data:image/png;base64,")
.contains("参考图作为拼图画面")
);
assert!(
scene_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("道具按钮上不要显示次数标注")
);
assert!(
scene_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("返回按钮和设置按钮旁禁止标注文字")
);
let spritesheet_body = build_puzzle_ui_spritesheet_request_body_for_test(&reference_image)
.expect("spritesheet request should build");
assert_eq!(spritesheet_body["model"], GPT_IMAGE_2_MODEL);
assert_eq!(spritesheet_body["size"], "1024x1024");
assert!(
spritesheet_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("返回按钮、设置按钮、下一关按钮、提示按钮、原图按钮、冻结按钮")
);
assert!(
spritesheet_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("纯绿色绿幕背景")
);
assert!(
spritesheet_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("绿幕扣成透明")
);
assert!(
spritesheet_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("自动边界检测")
);
assert!(
spritesheet_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("按钮素材内必须保留对应中文文字")
);
assert!(
spritesheet_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("不要额外画白色外圈")
);
assert!(
spritesheet_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("白底圆环")
);
assert!(
!spritesheet_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("禁止文字")
);
let background_body = build_puzzle_level_background_request_body_for_test(&reference_image)
.expect("background request should build");
assert_eq!(background_body["model"], GPT_IMAGE_2_MODEL);
assert_eq!(background_body["size"], "1024x1536");
assert!(
background_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("移除参考图中所有UI元素")
);
assert!(
background_body["prompt"]
.as_str()
.unwrap_or_default()
.contains("禁止在背景中出现人像或和拼图画面中主体一致的内容")
);
}
#[test]
fn puzzle_vector_engine_generation_prefers_signed_reference_url() {
fn puzzle_ui_spritesheet_postprocess_turns_green_screen_transparent() {
let mut source = image::RgbaImage::from_pixel(8, 8, image::Rgba([0, 255, 0, 255]));
for y in 2..6 {
for x in 2..6 {
source.put_pixel(x, y, image::Rgba([190, 78, 42, 255]));
}
}
let mut cursor = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(source)
.write_to(&mut cursor, ImageFormat::Png)
.expect("test image should encode");
let processed = make_puzzle_ui_spritesheet_image_transparent_for_test(PuzzleDownloadedImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: cursor.into_inner(),
})
.expect("green screen postprocess should succeed");
assert_eq!(processed.extension, "png");
assert_eq!(processed.mime_type, "image/png");
let decoded = image::load_from_memory(processed.bytes.as_slice())
.expect("processed image should decode")
.to_rgba8();
assert_eq!(decoded.get_pixel(0, 0).0[3], 0);
assert_eq!(decoded.get_pixel(3, 3).0[3], 255);
}
#[test]
fn puzzle_vector_engine_create_request_never_embeds_signed_reference_url() {
let reference_image = PuzzleResolvedReferenceImage {
mime_type: "image/png".to_string(),
bytes_len: 4,
@@ -86,14 +215,24 @@ fn puzzle_vector_engine_generation_prefers_signed_reference_url() {
Some(&reference_image),
);
assert!(body.get("image").is_none());
}
#[test]
fn puzzle_vector_engine_generation_url_normalizes_base_url() {
let settings = PuzzleVectorEngineSettings {
base_url: "https://vector.example/v1".to_string(),
api_key: "test-key".to_string(),
};
assert_eq!(
body["image"][0],
"https://oss.example/generated-puzzle-assets/reference.png?x-oss-signature=abc"
puzzle_vector_engine_images_generation_url(&settings),
"https://vector.example/v1/images/generations"
);
}
#[test]
fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() {
fn puzzle_vector_engine_edit_url_normalizes_base_url() {
let settings = PuzzleVectorEngineSettings {
base_url: "https://vector.example/v1".to_string(),
api_key: "test-key".to_string(),
@@ -135,18 +274,31 @@ fn puzzle_vector_engine_prompt_keeps_text_only_prompt_unchanged() {
}
#[test]
fn puzzle_reference_image_edit_requires_ai_redraw() {
assert!(!should_use_puzzle_reference_image_edit(None, true));
assert!(!should_use_puzzle_reference_image_edit(
fn puzzle_reference_image_generation_requires_ai_redraw() {
assert!(!should_use_puzzle_reference_image_generation(None, true));
assert!(!should_use_puzzle_reference_image_generation(
Some("data:image/png;base64,abcd"),
false
));
assert!(should_use_puzzle_reference_image_edit(
assert!(should_use_puzzle_reference_image_generation(
Some("data:image/png;base64,abcd"),
true
));
}
#[test]
fn puzzle_result_level_direct_upload_skips_cover_image_generation() {
assert!(should_use_uploaded_puzzle_image_directly(
Some("data:image/png;base64,abcd"),
false
));
assert!(!should_use_uploaded_puzzle_image_directly(
Some("data:image/png;base64,abcd"),
true
));
assert!(!should_use_uploaded_puzzle_image_directly(None, false));
}
#[test]
fn puzzle_reference_image_sources_are_deduped_and_limited() {
let sources = collect_puzzle_reference_image_sources(
@@ -239,51 +391,14 @@ fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() {
let error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::GATEWAY_TIMEOUT,
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
"创建拼图 VectorEngine 图片编辑任务失败",
r#"{"error":{"message":"VectorEngine generation endpoint timeout"}}"#,
"创建拼图 VectorEngine 图片生成任务失败",
);
let response = error.into_response();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
}
#[test]
fn puzzle_reference_edit_fallback_only_accepts_timeout_errors() {
let timeout_error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::GATEWAY_TIMEOUT,
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
"创建拼图 VectorEngine 图片编辑任务失败",
);
assert!(should_fallback_puzzle_reference_edit_to_generation(
&timeout_error
));
let auth_error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::UNAUTHORIZED,
r#"{"error":{"message":"invalid api key"}}"#,
"创建拼图 VectorEngine 图片编辑任务失败",
);
assert!(!should_fallback_puzzle_reference_edit_to_generation(
&auth_error
));
}
#[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(
@@ -601,6 +716,12 @@ fn puzzle_level_audio_asset_roundtrips_between_response_and_module_json() {
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
level_scene_image_src: None,
level_scene_image_object_key: None,
ui_spritesheet_image_src: None,
ui_spritesheet_image_object_key: None,
level_background_image_src: None,
level_background_image_object_key: None,
background_music: Some(CreationAudioAsset {
task_id: "suno-task-1".to_string(),
provider: "vector-engine-suno".to_string(),
@@ -666,6 +787,12 @@ fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() {
ui_background_image_object_key: Some(
"generated-puzzle-assets/session/ui/background.png".to_string(),
),
level_scene_image_src: None,
level_scene_image_object_key: None,
ui_spritesheet_image_src: None,
ui_spritesheet_image_object_key: None,
level_background_image_src: None,
level_background_image_object_key: None,
background_music: None,
candidates: vec![],
selected_candidate_id: None,
@@ -703,6 +830,81 @@ fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() {
);
}
#[test]
fn puzzle_level_asset_bundle_fields_roundtrip_between_response_and_module_json() {
let level = PuzzleDraftLevelResponse {
level_id: "puzzle-level-1".to_string(),
level_name: "雨夜猫街".to_string(),
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
picture_reference: None,
ui_background_prompt: Some("雨夜猫街竖屏拼图UI背景".to_string()),
ui_background_image_src: Some(
"/generated-puzzle-assets/session/legacy-ui/background.png".to_string(),
),
ui_background_image_object_key: Some(
"generated-puzzle-assets/session/legacy-ui/background.png".to_string(),
),
level_scene_image_src: Some(
"/generated-puzzle-assets/session/level-scene/scene.png".to_string(),
),
level_scene_image_object_key: Some(
"generated-puzzle-assets/session/level-scene/scene.png".to_string(),
),
ui_spritesheet_image_src: Some(
"/generated-puzzle-assets/session/ui-spritesheet/sheet.png".to_string(),
),
ui_spritesheet_image_object_key: Some(
"generated-puzzle-assets/session/ui-spritesheet/sheet.png".to_string(),
),
level_background_image_src: Some(
"/generated-puzzle-assets/session/level-background/background.png".to_string(),
),
level_background_image_object_key: Some(
"generated-puzzle-assets/session/level-background/background.png".to_string(),
),
background_music: None,
candidates: vec![],
selected_candidate_id: None,
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 request_context = RequestContext::new(
"test-request".to_string(),
"PUT /api/runtime/puzzle/works/test".to_string(),
Duration::ZERO,
false,
);
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
.expect("levels should serialize");
let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse");
assert_eq!(
payload[0]["level_background_image_object_key"],
Value::String(
"generated-puzzle-assets/session/level-background/background.png".to_string()
)
);
assert!(payload[0].get("levelBackgroundImageObjectKey").is_none());
let records = parse_puzzle_level_records_from_module_json(&levels_json)
.expect("levels should map back into records");
assert_eq!(
records[0].level_scene_image_src.as_deref(),
Some("/generated-puzzle-assets/session/level-scene/scene.png")
);
assert_eq!(
records[0].ui_spritesheet_image_object_key.as_deref(),
Some("generated-puzzle-assets/session/ui-spritesheet/sheet.png")
);
let response = map_puzzle_draft_level_response(records[0].clone());
assert_eq!(
response.level_background_image_src.as_deref(),
Some("/generated-puzzle-assets/session/level-background/background.png")
);
}
#[test]
fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
let app_state = crate::state::AppState::new(crate::config::AppConfig::default())
@@ -716,6 +918,12 @@ fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
level_scene_image_src: None,
level_scene_image_object_key: None,
ui_spritesheet_image_src: None,
ui_spritesheet_image_object_key: None,
level_background_image_src: None,
level_background_image_object_key: None,
background_music: None,
candidates: vec![PuzzleGeneratedImageCandidateRecord {
candidate_id: "candidate-1".to_string(),
@@ -849,12 +1057,15 @@ fn puzzle_initial_draft_assets_must_include_ui_background() {
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("UI背景图"));
assert!(missing_all.body_text().contains("关卡背景图"));
assert!(missing_all.body_text().contains("UI spritesheet"));
draft.levels[0].ui_background_image_src =
Some("/generated-puzzle-assets/session/ui/background.png".to_string());
draft.levels[0].level_background_image_src =
Some("/generated-puzzle-assets/session/background/background.png".to_string());
draft.levels[0].ui_spritesheet_image_src =
Some("/generated-puzzle-assets/session/spritesheet/sheet.png".to_string());
ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
.expect("UI 背景存在时即可完成自动草稿资源检查");
.expect("关卡背景和 UI spritesheet 存在时即可完成自动草稿资源检查");
}
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {
@@ -898,6 +1109,12 @@ fn test_puzzle_draft_record() -> PuzzleResultDraftRecord {
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
level_scene_image_src: None,
level_scene_image_object_key: None,
ui_spritesheet_image_src: None,
ui_spritesheet_image_object_key: None,
level_background_image_src: None,
level_background_image_object_key: None,
background_music: None,
candidates: vec![],
selected_candidate_id: None,

View File

@@ -12,7 +12,7 @@ impl PuzzleImageModel {
}
pub(crate) fn request_model_name(self) -> &'static str {
VECTOR_ENGINE_GPT_IMAGE_2_MODEL
GPT_IMAGE_2_MODEL
}
pub(crate) fn candidate_source_type(self) -> &'static str {
@@ -95,13 +95,26 @@ pub(crate) struct GeneratedPuzzleUiBackgroundResponse {
pub(crate) object_key: String,
}
#[derive(Clone, Debug)]
pub(crate) struct GeneratedPuzzleLevelAssetResponse {
pub(crate) image_src: String,
pub(crate) object_key: String,
}
#[derive(Clone, Debug)]
pub(crate) struct GeneratedPuzzleLevelAssetBundle {
pub(crate) level_scene: GeneratedPuzzleLevelAssetResponse,
pub(crate) ui_spritesheet: GeneratedPuzzleLevelAssetResponse,
pub(crate) level_background: GeneratedPuzzleLevelAssetResponse,
}
pub(crate) fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageModel {
match value.map(str::trim).filter(|value| !value.is_empty()) {
Some(PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW) => {
tracing::warn!(
requested_model = PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW,
effective_model = VECTOR_ENGINE_GPT_IMAGE_2_MODEL,
"拼图 nanobanana2 历史选项已回落到 VectorEngine GPT-image-2-all"
effective_model = GPT_IMAGE_2_MODEL,
"拼图 nanobanana2 历史选项已回落到 VectorEngine GPT-image-2"
);
PuzzleImageModel::Gemini31FlashPreview
}
@@ -150,7 +163,7 @@ pub(crate) 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 流的中断兼容问题
// 中文注释:参考图走 multipart edits;强制 HTTP/1.1 可降低部分网关对长耗时上传流的兼容风险
.http1_only()
.build()
.map_err(|error| {
@@ -186,6 +199,20 @@ pub(crate) async fn create_puzzle_vector_engine_image_generation(
candidate_count: u32,
reference_image: Option<&PuzzleResolvedReferenceImage>,
) -> Result<PuzzleGeneratedImages, AppError> {
if let Some(reference_image) = reference_image {
return create_puzzle_vector_engine_image_edit(
http_client,
settings,
image_model,
prompt,
negative_prompt,
size,
candidate_count,
reference_image,
)
.await;
}
let request_body = build_puzzle_vector_engine_image_request_body(
image_model,
prompt,
@@ -262,6 +289,15 @@ pub(crate) async fn create_puzzle_vector_engine_image_generation(
return Ok(images);
}
let b64_images = extract_puzzle_b64_images(&payload);
if !b64_images.is_empty() {
return Ok(puzzle_images_from_base64(
format!("vector-engine-{}", current_utc_micros()),
b64_images,
candidate_count,
));
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
@@ -273,6 +309,7 @@ pub(crate) async fn create_puzzle_vector_engine_image_generation(
pub(crate) async fn create_puzzle_vector_engine_image_edit(
http_client: &reqwest::Client,
settings: &PuzzleVectorEngineSettings,
image_model: PuzzleImageModel,
prompt: &str,
negative_prompt: &str,
size: &str,
@@ -295,7 +332,7 @@ pub(crate) async fn create_puzzle_vector_engine_image_edit(
})?;
let form = reqwest::multipart::Form::new()
.part("image", image_part)
.text("model", PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL.to_string())
.text("model", image_model.request_model_name().to_string())
.text(
"prompt",
build_puzzle_vector_engine_prompt(prompt, negative_prompt),
@@ -314,16 +351,14 @@ pub(crate) async fn create_puzzle_vector_engine_image_edit(
.send()
.await
.map_err(|error| {
map_puzzle_vector_engine_reqwest_error(
"创建拼图 VectorEngine 图片编辑任务失败",
&request_url,
error,
)
map_puzzle_vector_engine_request_error(format!(
"创建拼图 VectorEngine 图片编辑任务失败{error}"
))
})?;
let status = response.status();
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
image_model = PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL,
image_model = image_model.request_model_name(),
endpoint = %request_url,
status = status.as_u16(),
prompt_chars = prompt.chars().count(),
@@ -372,6 +407,17 @@ pub(crate) async fn create_puzzle_vector_engine_image_edit(
)
}
pub(crate) fn build_puzzle_downloaded_image_reference(
image: &PuzzleDownloadedImage,
) -> PuzzleResolvedReferenceImage {
PuzzleResolvedReferenceImage {
mime_type: image.mime_type.clone(),
bytes_len: image.bytes.len(),
bytes: image.bytes.clone(),
signed_read_url: None,
}
}
pub(crate) fn build_puzzle_vector_engine_image_request_body(
image_model: PuzzleImageModel,
prompt: &str,
@@ -380,7 +426,7 @@ pub(crate) fn build_puzzle_vector_engine_image_request_body(
candidate_count: u32,
reference_image: Option<&PuzzleResolvedReferenceImage>,
) -> Value {
let mut body = Map::from_iter([
let body = Map::from_iter([
(
"model".to_string(),
Value::String(image_model.request_model_name().to_string()),
@@ -392,20 +438,7 @@ pub(crate) fn build_puzzle_vector_engine_image_request_body(
("n".to_string(), json!(candidate_count.clamp(1, 1))),
("size".to_string(), Value::String(size.to_string())),
]);
if let Some(reference_image) = reference_image {
if let Some(signed_read_url) = reference_image
.signed_read_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
body.insert("image".to_string(), json!([signed_read_url]));
} else if let Some(reference_data_url) =
build_puzzle_generation_reference_image_data_url(reference_image)
{
body.insert("image".to_string(), json!([reference_data_url]));
}
}
let _ = reference_image;
Value::Object(body)
}
@@ -429,32 +462,6 @@ pub(crate) fn build_puzzle_vector_engine_generation_prompt(
)
}
pub(crate) fn build_puzzle_generation_reference_image_data_url(
image: &PuzzleResolvedReferenceImage,
) -> Option<String> {
let bytes = resize_puzzle_generation_reference_image_bytes(image.bytes.as_slice())
.unwrap_or_else(|| image.bytes.clone());
let mime_type = if bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
"image/png"
} else {
image.mime_type.as_str()
};
Some(format!(
"data:{};base64,{}",
normalize_puzzle_downloaded_image_mime_type(mime_type),
BASE64_STANDARD.encode(bytes)
))
}
pub(crate) fn resize_puzzle_generation_reference_image_bytes(bytes: &[u8]) -> Option<Vec<u8>> {
let image = image::load_from_memory(bytes).ok()?;
let resized = image.resize(1024, 1024, image::imageops::FilterType::Triangle);
let mut cursor = std::io::Cursor::new(Vec::new());
resized.write_to(&mut cursor, ImageFormat::Png).ok()?;
Some(cursor.into_inner())
}
pub(crate) fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> bool {
reference_image_src
.map(str::trim)
@@ -545,11 +552,11 @@ pub(crate) fn has_puzzle_reference_images(
.is_empty()
}
pub(crate) fn should_use_puzzle_reference_image_edit(
pub(crate) fn should_use_puzzle_reference_image_generation(
reference_image_src: Option<&str>,
use_reference_image_edit: bool,
use_reference_image_generation: bool,
) -> bool {
use_reference_image_edit && has_puzzle_reference_image(reference_image_src)
use_reference_image_generation && has_puzzle_reference_image(reference_image_src)
}
pub(crate) fn build_puzzle_vector_engine_prompt(prompt: &str, negative_prompt: &str) -> String {
@@ -1072,6 +1079,57 @@ pub(crate) async fn persist_puzzle_ui_background_image(
})
}
pub(crate) async fn persist_puzzle_level_asset_image(
state: &PuzzleApiState,
owner_user_id: &str,
session_id: &str,
level_name: &str,
task_id: &str,
path_segment: &str,
asset_kind: &str,
slot: &str,
file_stem: &str,
image: PuzzleDownloadedImage,
) -> Result<GeneratedPuzzleLevelAssetResponse, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let http_client = reqwest::Client::new();
let put_result = oss_client
.put_object(
&http_client,
OssPutObjectRequest {
prefix: LegacyAssetPrefix::PuzzleAssets,
path_segments: vec![
sanitize_path_segment(session_id, "session"),
sanitize_path_segment(level_name, "puzzle"),
sanitize_path_segment(path_segment, "level-asset"),
sanitize_path_segment(task_id, "task"),
],
file_name: format!("{file_stem}.{}", image.extension),
content_type: Some(image.mime_type.clone()),
access: OssObjectAccess::Private,
metadata: build_puzzle_level_asset_metadata(
owner_user_id,
session_id,
asset_kind,
slot,
),
body: image.bytes,
},
)
.await
.map_err(map_puzzle_asset_oss_error)?;
Ok(GeneratedPuzzleLevelAssetResponse {
image_src: put_result.legacy_public_path,
object_key: put_result.object_key,
})
}
pub(crate) fn handle_puzzle_asset_spacetime_index_error(
error: SpacetimeClientError,
owner_user_id: &str,
@@ -1126,6 +1184,21 @@ pub(crate) fn build_puzzle_ui_background_asset_metadata(
])
}
pub(crate) fn build_puzzle_level_asset_metadata(
owner_user_id: &str,
session_id: &str,
asset_kind: &str,
slot: &str,
) -> BTreeMap<String, String> {
BTreeMap::from([
("asset_kind".to_string(), asset_kind.to_string()),
("owner_user_id".to_string(), owner_user_id.to_string()),
("entity_kind".to_string(), PUZZLE_ENTITY_KIND.to_string()),
("entity_id".to_string(), session_id.to_string()),
("slot".to_string(), slot.to_string()),
])
}
pub(crate) fn parse_puzzle_json_payload(
raw_text: &str,
fallback_message: &str,
@@ -1331,72 +1404,6 @@ pub(crate) fn map_puzzle_vector_engine_request_error(message: String) -> AppErro
}))
}
pub(crate) 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,
}))
}
pub(crate) fn normalize_puzzle_reqwest_error_message(error: &reqwest::Error) -> String {
error
.to_string()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
pub(crate) 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 字段中的底层网络错误"
}
pub(crate) fn is_puzzle_request_timeout_message(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("timed out")