Merge remote-tracking branch 'origin/master' into codex/wooden-fish-template

This commit is contained in:
2026-05-22 04:00:52 +08:00
121 changed files with 10876 additions and 3477 deletions

View File

@@ -620,6 +620,31 @@ mod tests {
assert_eq!(body["error"]["details"]["creationTypeId"], "visual-novel");
}
#[tokio::test]
async fn disabled_rpg_route_returns_service_unavailable() {
let state = AppState::new(AppConfig::default()).expect("state should build");
state.set_test_creation_entry_route_enabled("rpg", false);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/api/runtime/custom-world/agent/sessions")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = read_json_response(response).await;
assert_eq!(
body["error"]["details"]["reason"],
"creation_entry_disabled"
);
assert_eq!(body["error"]["details"]["creationTypeId"], "rpg");
}
#[tokio::test]
async fn healthz_returns_standard_envelope_when_requested() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));

View File

@@ -260,8 +260,11 @@ impl Default for AppConfig {
llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS,
llm_max_retries: DEFAULT_MAX_RETRIES,
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
rpg_llm_web_search_enabled: true,
creation_agent_llm_web_search_enabled: true,
// 中文注释:创作/RPG 的结构化 JSON 链路默认不启用 Responses web_search。
// 未开通工具的上游会先吐自然语言再返回 ToolNotOpen容易污染严格 JSON 结果;
// 需要联网增强时由部署环境显式打开对应开关。
rpg_llm_web_search_enabled: false,
creation_agent_llm_web_search_enabled: false,
dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(),
dashscope_api_key: None,
dashscope_scene_image_model: String::new(),
@@ -1467,6 +1470,14 @@ mod tests {
}
}
#[test]
fn default_keeps_structured_llm_web_search_disabled() {
let config = AppConfig::default();
assert!(!config.rpg_llm_web_search_enabled);
assert!(!config.creation_agent_llm_web_search_enabled);
}
#[test]
fn from_env_reads_rpg_llm_web_search_switch() {
let _guard = ENV_LOCK
@@ -1476,11 +1487,11 @@ mod tests {
unsafe {
std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED");
std::env::set_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED", "false");
std::env::set_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED", "true");
}
let config = AppConfig::from_env();
assert!(!config.rpg_llm_web_search_enabled);
assert!(config.rpg_llm_web_search_enabled);
unsafe {
std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED");
@@ -1496,11 +1507,11 @@ mod tests {
unsafe {
std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED");
std::env::set_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", "false");
std::env::set_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", "true");
}
let config = AppConfig::from_env();
assert!(!config.creation_agent_llm_web_search_enabled);
assert!(config.creation_agent_llm_web_search_enabled);
unsafe {
std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED");

View File

@@ -93,15 +93,74 @@ where
F: FnMut(&str),
{
let mut latest_reply_text = String::new();
let turn_output = match request_stream_creation_agent_json_turn_once(
llm_client,
system_prompt.clone(),
user_prompt.clone(),
enable_web_search,
on_reply_update,
&mut latest_reply_text,
!enable_web_search,
)
.await
{
Ok(turn_output) => turn_output,
Err(CreationAgentJsonTurnFailure::Stream(error))
if enable_web_search && is_web_search_tool_unavailable(&error) =>
{
tracing::warn!(
error = %error,
"创作 Agent 流式联网搜索插件不可用,自动降级为无联网搜索重试"
);
latest_reply_text.clear();
request_stream_creation_agent_json_turn_once(
llm_client,
system_prompt,
user_prompt,
false,
on_reply_update,
&mut latest_reply_text,
true,
)
.await?
}
Err(error) => return Err(error),
};
let reply_text = read_reply_text(&turn_output.parsed);
if let Some(reply_text) = reply_text.as_deref()
&& reply_text != latest_reply_text
{
on_reply_update(reply_text);
}
Ok(turn_output)
}
async fn request_stream_creation_agent_json_turn_once<F>(
llm_client: &LlmClient,
system_prompt: String,
user_prompt: String,
enable_web_search: bool,
on_reply_update: &mut F,
latest_reply_text: &mut String,
emit_reply_updates: bool,
) -> Result<CreationAgentJsonTurnOutput, CreationAgentJsonTurnFailure>
where
F: FnMut(&str),
{
let response = llm_client
.stream_text(
build_creation_agent_llm_request(system_prompt, user_prompt, enable_web_search),
|delta: &LlmStreamDelta| {
if !emit_reply_updates {
return;
}
if let Some(reply_progress) =
extract_reply_text_from_partial_json(delta.accumulated_text.as_str())
&& reply_progress != latest_reply_text
&& reply_progress != *latest_reply_text
{
latest_reply_text = reply_progress.clone();
*latest_reply_text = reply_progress.clone();
on_reply_update(reply_progress.as_str());
}
},
@@ -110,12 +169,6 @@ where
.map_err(CreationAgentJsonTurnFailure::Stream)?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|_| CreationAgentJsonTurnFailure::Parse)?;
let reply_text = read_reply_text(&parsed);
if let Some(reply_text) = reply_text.as_deref()
&& reply_text != latest_reply_text
{
on_reply_update(reply_text);
}
Ok(CreationAgentJsonTurnOutput { parsed })
}
@@ -327,6 +380,7 @@ mod tests {
let server = spawn_capturing_mock_server(vec![
MockResponse {
body: concat!(
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"我需要先搜索玩具王国资料。\"}\n\n",
"data: {\"type\":\"error\",\"code\":\"ToolNotOpen\",\"message\":\"Your account has not activated web search.\"}\n\n",
"data: [DONE]\n\n"
)
@@ -391,6 +445,55 @@ mod tests {
}
}
#[tokio::test]
async fn stream_turn_keeps_partial_updates_when_web_search_is_disabled() {
let server = spawn_capturing_mock_server(vec![MockResponse {
body: concat!(
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"{\\\"replyText\\\":\\\"我先\"}\n\n",
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"把玩具王国定住。\\\",\\\"progressPercent\\\":12}\"}\n\n",
"data: {\"type\":\"response.completed\"}\n\n",
)
.to_string(),
}]);
let config = LlmConfig::new(
LlmProvider::Ark,
server.base_url,
"test-key".to_string(),
"test-model".to_string(),
30_000,
0,
1,
)
.expect("LLM config should build");
let llm_client = platform_llm::LlmClient::new(config).expect("LLM client should build");
let mut visible_replies = Vec::new();
let output = stream_creation_agent_json_turn(
Some(&llm_client),
"系统提示".to_string(),
"用户提示",
false,
CreationAgentLlmTurnErrorMessages {
model_unavailable: "模型不可用",
generation_failed: "生成失败",
parse_failed: "解析失败",
},
|text| visible_replies.push(text.to_string()),
|message| message,
)
.await
.expect("stream without web search should succeed");
assert_eq!(
output.parsed["replyText"].as_str(),
Some("我先把玩具王国定住。")
);
assert_eq!(
visible_replies,
vec!["我先".to_string(), "我先把玩具王国定住。".to_string()]
);
}
struct MockResponse {
body: String,
}

View File

@@ -96,6 +96,14 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
if normalized.starts_with("/api/runtime/big-fish") {
return Some("big-fish");
}
if normalized.starts_with("/api/runtime/custom-world")
|| normalized.starts_with("/api/runtime/custom-world-library")
|| normalized.starts_with("/api/runtime/custom-world-gallery")
|| normalized.starts_with("/api/runtime/chat")
|| normalized.starts_with("/api/story")
{
return Some("rpg");
}
if normalized.starts_with("/api/runtime/visual-novel") {
return Some("visual-novel");
}
@@ -165,6 +173,26 @@ mod tests {
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
Some("visual-novel"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/custom-world/agent/sessions"),
Some("rpg"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/custom-world-library/profile-1"),
Some("rpg"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/custom-world-gallery/user-1/profile-1"),
Some("rpg"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/story/sessions/runtime"),
Some("rpg"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/chat/npc/turn/stream"),
Some("rpg"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"),
Some("bark-battle"),

View File

@@ -1626,39 +1626,20 @@ pub async fn execute_custom_world_agent_action(
)
})?
} else if action == "publish_world" {
let mut publish_payload = serde_json::to_value(&payload).map_err(|error| {
let publish_payload = serialize_publish_world_action_payload(
resolve_author_public_user_code(&state, &authenticated, &request_context)?,
resolve_author_display_name(&state, &authenticated),
)
.map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": format!("action payload JSON 序列化失败:{error}"),
"message": error,
})),
)
})?;
if let Some(object) = publish_payload.as_object_mut() {
// 发布到广场时必须写入真实作者公开信息,避免 gallery 投影落成匿名兜底数据。
object.insert(
"authorPublicUserCode".to_string(),
Value::String(resolve_author_public_user_code(
&state,
&authenticated,
&request_context,
)?),
);
object.insert(
"authorDisplayName".to_string(),
Value::String(resolve_author_display_name(&state, &authenticated)),
);
}
serde_json::to_string(&publish_payload).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": format!("action payload JSON 序列化失败:{error}"),
})),
)
})?
publish_payload
} else {
serde_json::to_string(&payload).map_err(|error| {
custom_world_error_response(
@@ -1734,6 +1715,23 @@ fn serialize_sync_result_profile_action_payload(
.map_err(|error| format!("action payload JSON 序列化失败:{error}"))
}
fn serialize_publish_world_action_payload(
author_public_user_code: String,
author_display_name: String,
) -> Result<String, String> {
// 中文注释:发布动作只提交动作名和作者公开信息。
// 结果页当前 profile 必须先通过 sync_result_profile 写入 session
// SpacetimeDB 发布时再从 session.draft_profile_json 读取草稿真相,避免前端
// draftProfile / legacyResultProfile / profile 旧载荷覆盖刚保存的内容。
let payload_value = json!({
"action": "publish_world",
"authorPublicUserCode": author_public_user_code,
"authorDisplayName": author_display_name,
});
serde_json::to_string(&payload_value)
.map_err(|error| format!("action payload JSON 序列化失败:{error}"))
}
fn canonicalize_custom_world_library_profile_payload(
mut profile: Value,
) -> Result<(Value, CustomWorldProfileMetadata), String> {
@@ -3414,6 +3412,36 @@ mod tests {
);
}
#[test]
fn publish_world_payload_only_contains_action_and_author_identity() {
let payload_json =
serialize_publish_world_action_payload("TN-0001".to_string(), "潮汐作者".to_string())
.expect("publish payload serializes");
let payload_value: Value =
serde_json::from_str(&payload_json).expect("payload should be valid JSON");
let object = payload_value
.as_object()
.expect("publish payload should be object");
assert_eq!(object.len(), 3);
assert_eq!(
object.get("action").and_then(Value::as_str),
Some("publish_world")
);
assert_eq!(
object.get("authorPublicUserCode").and_then(Value::as_str),
Some("TN-0001")
);
assert_eq!(
object.get("authorDisplayName").and_then(Value::as_str),
Some("潮汐作者")
);
assert!(!object.contains_key("profile"));
assert!(!object.contains_key("draftProfile"));
assert!(!object.contains_key("legacyResultProfile"));
assert!(!object.contains_key("settingText"));
}
#[test]
fn custom_world_library_profile_payload_is_canonicalized_on_server() {
let (profile, metadata) = canonicalize_custom_world_library_profile_payload(json!({

View File

@@ -10,7 +10,9 @@ use axum::{
response::Response,
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use image::{DynamicImage, GenericImageView, imageops::FilterType};
use image::{
DynamicImage, GenericImageView, ImageFormat, codecs::jpeg::JpegEncoder, imageops::FilterType,
};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
@@ -375,6 +377,8 @@ const OPENING_CG_ENTITY_KIND: &str = "custom_world_profile";
const OPENING_CG_STORYBOARD_SLOT: &str = "opening_cg_storyboard";
const OPENING_CG_VIDEO_SLOT: &str = "opening_cg_video";
const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000;
const OPENING_CG_REFERENCE_MAX_EDGE: u32 = 768;
const OPENING_CG_REFERENCE_JPEG_QUALITY: u8 = 82;
struct CoverPromptContext {
opening_act_title: String,
@@ -1025,6 +1029,16 @@ pub async fn generate_custom_world_opening_cg(
"openingSceneImageSrc",
)
.await?;
let player_role_reference = resize_image_reference_data_url(
player_role_reference,
OPENING_CG_REFERENCE_MAX_EDGE,
OPENING_CG_REFERENCE_JPEG_QUALITY,
)?;
let opening_scene_reference = resize_image_reference_data_url(
opening_scene_reference,
OPENING_CG_REFERENCE_MAX_EDGE,
OPENING_CG_REFERENCE_JPEG_QUALITY,
)?;
let storyboard = generate_opening_cg_storyboard(
&state,
&owner_user_id,
@@ -1617,6 +1631,52 @@ async fn resolve_reference_image_as_data_url(
))
}
fn resize_image_reference_data_url(
data_url: String,
max_edge: u32,
jpeg_quality: u8,
) -> Result<String, AppError> {
if max_edge == 0 {
return Ok(data_url);
}
let Some(parsed) = parse_image_data_url(data_url.as_str()) else {
return Ok(data_url);
};
let image = image::load_from_memory(parsed.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-ai",
"message": format!("无法解析参考图:{error}"),
}))
})?;
let (width, height) = image.dimensions();
let already_within_budget = width <= max_edge && height <= max_edge;
if already_within_budget && parsed.mime_type == "image/jpeg" {
return Ok(data_url);
}
// 中文注释:开局 CG 故事板会同时带角色和场景两张参考图;先压到较小 JPEG避免大图 PNG Data URL 让 VectorEngine 网关在请求发送阶段中断。
let resized = if already_within_budget {
image
} else {
image.resize(max_edge, max_edge, FilterType::Triangle)
};
let encoded_image = DynamicImage::ImageRgb8(resized.to_rgb8());
let mut encoded = Vec::new();
JpegEncoder::new_with_quality(&mut encoded, jpeg_quality)
.encode_image(&encoded_image)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "custom-world-ai",
"message": format!("压缩参考图失败:{error}"),
}))
})?;
Ok(format!(
"data:image/jpeg;base64,{}",
BASE64_STANDARD.encode(encoded)
))
}
async fn create_text_to_image_generation(
http_client: &reqwest::Client,
settings: &DashScopeSettings,
@@ -3065,6 +3125,34 @@ mod tests {
assert_eq!(parsed.bytes, b"hello".to_vec());
}
#[test]
fn opening_cg_reference_data_url_is_resized_to_request_budget() {
let image = DynamicImage::ImageRgb8(image::RgbImage::new(2048, 1152));
let mut cursor = std::io::Cursor::new(Vec::new());
image
.write_to(&mut cursor, ImageFormat::Png)
.expect("test image should encode");
let data_url = format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(cursor.into_inner())
);
let resized = resize_image_reference_data_url(
data_url,
OPENING_CG_REFERENCE_MAX_EDGE,
OPENING_CG_REFERENCE_JPEG_QUALITY,
)
.expect("reference should resize");
let parsed = parse_image_data_url(resized.as_str()).expect("resized data url should parse");
let resized_image =
image::load_from_memory(parsed.bytes.as_slice()).expect("resized image should decode");
let (width, height) = resized_image.dimensions();
assert!(width <= OPENING_CG_REFERENCE_MAX_EDGE);
assert!(height <= OPENING_CG_REFERENCE_MAX_EDGE);
assert_eq!(parsed.mime_type, "image/jpeg");
}
#[test]
fn push_cover_reference_source_keeps_full_data_url() {
let mut sources = Vec::new();

View File

@@ -197,6 +197,86 @@ pub(crate) fn slice_generated_asset_sheet(
Ok(slices)
}
pub(crate) fn slice_generated_asset_sheet_two_items_per_row(
image: &DownloadedOpenAiImage,
item_names: &[String],
grid_size: usize,
views_per_item: usize,
) -> Result<Vec<Vec<GeneratedAssetSheetSliceImage>>, AppError> {
if grid_size == 0 || views_per_item == 0 {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": GENERATED_ASSET_SHEET_PROVIDER,
"message": "系列素材图集的 n 和每物品视图数必须大于 0。",
})),
);
}
if !grid_size.is_multiple_of(views_per_item) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": GENERATED_ASSET_SHEET_PROVIDER,
"message": "系列素材图集每行必须能均分为若干物品。",
"gridSize": grid_size,
"viewsPerItem": views_per_item,
})),
);
}
let grid_size_u32 = u32::try_from(grid_size).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": GENERATED_ASSET_SHEET_PROVIDER,
"message": "系列素材图集的 n 超出可支持范围。",
}))
})?;
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": GENERATED_ASSET_SHEET_PROVIDER,
"message": format!("系列素材图集解码失败:{error}"),
}))
})?;
let source = apply_generated_asset_sheet_green_screen_alpha(source);
let (width, height) = source.dimensions();
if width / grid_size_u32 == 0 || height / grid_size_u32 == 0 {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": GENERATED_ASSET_SHEET_PROVIDER,
"message": "系列素材图集尺寸过小,无法切割。",
})),
);
}
let items_per_row = grid_size / views_per_item;
let max_item_count = grid_size.saturating_mul(items_per_row);
let mut slices = Vec::with_capacity(item_names.len().min(max_item_count));
for item_index in 0..item_names.len().min(max_item_count) {
let row = (item_index / items_per_row) as u32;
let start_col = ((item_index % items_per_row) * views_per_item) as u32;
let mut views = Vec::with_capacity(views_per_item);
for view_offset in 0..views_per_item {
let col = start_col + view_offset as u32;
let (crop_x, crop_y, crop_width, crop_height) =
resolve_generated_asset_sheet_cell_crop(&source, grid_size_u32, row, col);
let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height);
let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped);
let mut cursor = std::io::Cursor::new(Vec::new());
cleaned
.write_to(&mut cursor, ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": GENERATED_ASSET_SHEET_PROVIDER,
"message": format!("系列素材图集切割失败:{error}"),
}))
})?;
views.push(GeneratedAssetSheetSliceImage {
bytes: cursor.into_inner(),
});
}
slices.push(views);
}
Ok(slices)
}
pub(crate) fn crop_generated_asset_sheet_view_edge_matte(
image: image::DynamicImage,
) -> image::DynamicImage {
@@ -958,7 +1038,7 @@ fn collect_generated_asset_sheet_visible_neighbor_color(
))
}
fn apply_generated_asset_sheet_green_screen_alpha(
pub(crate) fn apply_generated_asset_sheet_green_screen_alpha(
source: image::DynamicImage,
) -> image::DynamicImage {
let mut image = source.to_rgba8();

View File

@@ -71,10 +71,12 @@ use crate::{
},
auth::AuthenticatedAccessToken,
config::AppConfig,
generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha,
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, OpenAiGeneratedImages, OpenAiReferenceImage,
build_openai_image_http_client, create_openai_image_edit, create_openai_image_generation,
build_openai_image_http_client, create_openai_image_edit,
create_openai_image_edit_with_references, create_openai_image_generation,
require_openai_image_settings,
},
platform_errors::map_oss_error,
@@ -95,10 +97,10 @@ const MATCH3D_DEFAULT_DIFFICULTY: u32 = 4;
const MATCH3D_DRAFT_GENERATION_POINTS_COST: u64 = 10;
const MATCH3D_BACKGROUND_IMAGE_POINTS_COST: u64 = 2;
const MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH: u64 = 2;
const MATCH3D_MATERIAL_ITEM_BATCH_SIZE: usize = 5;
const MATCH3D_MATERIAL_ITEM_BATCH_SIZE: usize = 20;
const MATCH3D_ITEM_VIEW_COUNT: usize = 5;
const MATCH3D_MATERIAL_GRID_SIZE: u32 = 5;
const MATCH3D_MAX_GENERATED_ITEM_COUNT: usize = 25;
const MATCH3D_MATERIAL_GRID_SIZE: u32 = 10;
const MATCH3D_MAX_GENERATED_ITEM_COUNT: usize = 20;
const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL: &str = "gemini-3-pro-image-preview";
const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO: &str = "1:1";
const MATCH3D_LEGACY_MODEL_DOWNLOAD_TIMEOUT_MS: u64 = 3 * 60_000;
@@ -118,7 +120,7 @@ const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10你要创作
const MATCH3D_CLICK_SOUND_ASSET_KIND: &str = "match3d_click_sound";
const MATCH3D_PIXEL_RETRO_STYLE_PROMPT: &str = "真正复古像素 2D 游戏道具 sprite 风格,先以约 64x64 低分辨率像素块绘制再按整数倍放大,硬边方块像素清晰可见,有限色板 12-24 色,禁止抗锯齿、柔焦、平滑渐变、真实 3D 渲染、PBR 材质和摄影光照。";
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DConfigJson {
theme_text: String,
@@ -170,15 +172,33 @@ struct Match3DGeneratedItemImageView {
image_object_key: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DGeneratedBackgroundAsset {
prompt: String,
#[serde(default)]
level_scene_prompt: Option<String>,
#[serde(default)]
level_scene_image_src: Option<String>,
#[serde(default)]
level_scene_image_object_key: Option<String>,
#[serde(default)]
image_src: Option<String>,
#[serde(default)]
image_object_key: Option<String>,
#[serde(default)]
ui_spritesheet_prompt: Option<String>,
#[serde(default)]
ui_spritesheet_image_src: Option<String>,
#[serde(default)]
ui_spritesheet_image_object_key: Option<String>,
#[serde(default)]
item_spritesheet_prompt: Option<String>,
#[serde(default)]
item_spritesheet_image_src: Option<String>,
#[serde(default)]
item_spritesheet_image_object_key: Option<String>,
#[serde(default)]
container_prompt: Option<String>,
#[serde(default)]
container_image_src: Option<String>,
@@ -445,8 +465,17 @@ impl From<shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse>
.background_asset
.map(|asset| Match3DGeneratedBackgroundAsset {
prompt: asset.prompt,
level_scene_prompt: asset.level_scene_prompt,
level_scene_image_src: asset.level_scene_image_src,
level_scene_image_object_key: asset.level_scene_image_object_key,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
ui_spritesheet_prompt: asset.ui_spritesheet_prompt,
ui_spritesheet_image_src: asset.ui_spritesheet_image_src,
ui_spritesheet_image_object_key: asset.ui_spritesheet_image_object_key,
item_spritesheet_prompt: asset.item_spritesheet_prompt,
item_spritesheet_image_src: asset.item_spritesheet_image_src,
item_spritesheet_image_object_key: asset.item_spritesheet_image_object_key,
container_prompt: asset.container_prompt,
container_image_src: asset.container_image_src,
container_image_object_key: asset.container_image_object_key,

View File

@@ -229,12 +229,27 @@ pub(super) async fn compile_match3d_draft_for_session(
)
.await?;
let existing_assets = get_match3d_existing_generated_item_assets(
let mut existing_assets = get_match3d_existing_generated_item_assets(
state,
owner_user_id.as_str(),
profile_id.as_str(),
)
.await;
let generated_background_asset = resolve_or_generate_match3d_level_asset_bundle(
state,
request_context,
owner_user_id.as_str(),
session.session_id.as_str(),
profile_id.as_str(),
&config,
generated_work_metadata.background_prompt.as_str(),
&existing_assets,
)
.await?;
attach_match3d_background_asset_to_assets(
&mut existing_assets,
generated_background_asset.clone(),
);
let generated_item_assets = generate_match3d_item_assets(
state,
request_context,
@@ -245,18 +260,22 @@ pub(super) async fn compile_match3d_draft_for_session(
&config,
generated_work_metadata.items,
existing_assets,
Some(generated_background_asset.clone()),
)
.await?;
let generated_item_assets = ensure_match3d_background_asset(
let mut generated_item_assets = generated_item_assets;
attach_match3d_background_asset_to_assets(
&mut generated_item_assets,
generated_background_asset,
);
persist_match3d_generated_item_assets_snapshot(
state,
request_context,
authenticated,
owner_user_id.as_str(),
session.session_id.as_str(),
owner_user_id.as_str(),
profile_id.as_str(),
&config,
generated_work_metadata.background_prompt.as_str(),
generated_item_assets,
&generated_item_assets,
)
.await?;
let existing_cover_image_src = get_match3d_existing_cover_image_src(

View File

@@ -3,9 +3,8 @@ use super::*;
use crate::generated_asset_sheets::crop_generated_asset_sheet_view_edge_matte;
use crate::generated_asset_sheets::{
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage,
build_generated_asset_sheet_prompt, persist_generated_asset_sheet_bytes,
slice_generated_asset_sheet,
GeneratedAssetSheetSliceImage, persist_generated_asset_sheet_bytes,
slice_generated_asset_sheet_two_items_per_row,
};
pub(super) async fn generate_match3d_item_assets(
@@ -18,6 +17,7 @@ pub(super) async fn generate_match3d_item_assets(
config: &Match3DConfigJson,
item_plan: Vec<Match3DGeneratedItemPlan>,
existing_assets: Vec<Match3DGeneratedItemAsset>,
generated_background_asset: Option<Match3DGeneratedBackgroundAsset>,
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
// 中文注释:抓大鹅音频生成当前关闭;自动草稿只补齐 2D 物品图片和可选点击音效。
let target_item_count = resolve_match3d_generated_item_count(config);
@@ -37,6 +37,7 @@ pub(super) async fn generate_match3d_item_assets(
config,
item_plan,
assets,
generated_background_asset,
)
.await?;
}
@@ -76,6 +77,7 @@ async fn ensure_match3d_item_image_assets(
config: &Match3DConfigJson,
item_plan: Vec<Match3DGeneratedItemPlan>,
existing_assets: Vec<Match3DGeneratedItemAsset>,
generated_background_asset: Option<Match3DGeneratedBackgroundAsset>,
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
let mut assets = normalize_match3d_generated_item_assets_for_resume(existing_assets);
let target_item_count = resolve_match3d_generated_item_count(config);
@@ -101,9 +103,11 @@ async fn ensure_match3d_item_image_assets(
background_music_style: None,
background_music_prompt: None,
background_asset: if index == 0 {
assets
.first()
.and_then(|asset| asset.background_asset.clone())
generated_background_asset.clone().or_else(|| {
assets
.first()
.and_then(|asset| asset.background_asset.clone())
})
} else {
None
},
@@ -160,6 +164,8 @@ struct Match3DItemImageGenerationSeed {
struct Match3DMaterialBatchOutput {
task_id: String,
prompt: String,
image_src: Option<String>,
image_object_key: Option<String>,
generated_at_micros: i64,
items: Vec<(
Match3DItemImageGenerationSeed,
@@ -194,12 +200,17 @@ async fn generate_match3d_item_image_assets_in_batches(
.map(|chunk| {
let chunk_seeds = chunk.to_vec();
async move {
let item_names = chunk_seeds
.iter()
.map(|item| item.item_name.clone())
.collect::<Vec<_>>();
let material_sheet =
generate_match3d_material_sheet(state, config, &item_names).await?;
let material_sheet = generate_match3d_material_sheet_from_level_scene(
state,
owner_user_id,
session_id,
profile_id,
config,
chunk_seeds
.iter()
.find_map(|seed| seed.background_asset.as_ref()),
)
.await?;
let generated_at_micros = current_utc_micros();
let persisted_seed_count = chunk_seeds
.iter()
@@ -218,14 +229,17 @@ async fn generate_match3d_item_image_assets_in_batches(
.iter()
.map(|item| item.item_name.clone())
.collect::<Vec<_>>();
let item_images = slice_generated_asset_sheet(
let item_images = slice_generated_asset_sheet_two_items_per_row(
&material_sheet.image,
&persisted_item_names,
MATCH3D_MATERIAL_GRID_SIZE as usize,
MATCH3D_ITEM_VIEW_COUNT,
)?;
Ok::<_, AppError>(Match3DMaterialBatchOutput {
task_id: material_sheet.task_id,
prompt: material_sheet.prompt,
image_src: material_sheet.image_src,
image_object_key: material_sheet.image_object_key,
generated_at_micros,
items: persisted_seeds
.into_iter()
@@ -248,14 +262,22 @@ async fn generate_match3d_item_image_assets_in_batches(
for batch in batches {
let sheet_task_id = batch.task_id;
let sheet_prompt = batch.prompt;
let sheet_image_src = batch.image_src;
let sheet_image_object_key = batch.image_object_key;
let generated_at_micros = batch.generated_at_micros;
for (item_index, (seed, item_images)) in batch.items.into_iter().enumerate() {
let item_slug = build_match3d_item_slug(seed.item_id.as_str(), seed.item_name.as_str());
let mut image_views = Vec::with_capacity(item_images.len());
for (view_index, item_image) in item_images.into_iter().enumerate() {
let view_number = view_index + 1;
let item_name_prompt =
format!("{}行:{} 的 5 个不同视角", item_index + 1, seed.item_name);
let (sheet_row_index, sheet_col_index) =
resolve_match3d_material_sheet_cell_indices(item_index, view_index);
let item_name_prompt = format!(
"{}行第{}种:{} 的 5 个不同形态",
item_index / 2 + 1,
item_index % 2 + 1,
seed.item_name
);
let view_upload = persist_generated_asset_sheet_bytes(
state,
GeneratedAssetSheetPersistInput {
@@ -277,8 +299,8 @@ async fn generate_match3d_item_image_assets_in_batches(
(item_index * MATCH3D_ITEM_VIEW_COUNT + view_index) as i64 + 1,
),
grid_size: MATCH3D_MATERIAL_GRID_SIZE as usize,
row_index: item_index + 1,
view_index: view_number,
row_index: sheet_row_index,
view_index: sheet_col_index,
prompt: GeneratedAssetSheetPersistPrompt {
sheet_prompt: Some(sheet_prompt.clone()),
item_name_prompt: Some(item_name_prompt),
@@ -322,7 +344,12 @@ async fn generate_match3d_item_image_assets_in_batches(
background_music_prompt: seed.background_music_prompt,
background_music: None,
click_sound: None,
background_asset: seed.background_asset,
background_asset: merge_match3d_item_spritesheet_asset_metadata(
seed.background_asset,
sheet_prompt.clone(),
sheet_image_src.clone(),
sheet_image_object_key.clone(),
),
status: "image_ready".to_string(),
error: None,
},
@@ -512,6 +539,7 @@ async fn append_match3d_new_item_assets(
return Ok(assets);
}
let mut next_item_index = next_match3d_generated_item_index(&assets);
let background_asset = find_match3d_generated_background_asset(&assets);
let item_seeds = append_plan
.padded_item_names
.into_iter()
@@ -527,7 +555,11 @@ async fn append_match3d_new_item_assets(
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_asset: None,
background_asset: if index == 0 {
background_asset.clone()
} else {
None
},
}
})
.collect::<Vec<_>>();
@@ -697,6 +729,8 @@ async fn replace_match3d_item_assets(
pub(super) struct Match3DMaterialSheet {
pub(super) task_id: String,
pub(super) prompt: String,
pub(super) image_src: Option<String>,
pub(super) image_object_key: Option<String>,
pub(super) image: DownloadedOpenAiImage,
}
@@ -710,6 +744,118 @@ pub(super) struct Match3DVectorEngineGeminiImageSettings {
pub(super) struct Match3DSlicedItemImage {
pub(super) bytes: Vec<u8>,
}
async fn generate_match3d_material_sheet_from_level_scene(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
) -> Result<Match3DMaterialSheet, AppError> {
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let prompt = build_match3d_item_spritesheet_prompt();
let reference = load_match3d_level_scene_reference_image(state, background_asset).await?;
let generated = create_openai_image_edit(
&http_client,
&settings,
prompt.as_str(),
Some(build_match3d_material_sheet_negative_prompt(config).as_str()),
"2k",
&reference,
"抓大鹅物品 spritesheet 生成失败",
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "抓大鹅物品 spritesheet 生成失败:未返回图片",
}))
})?;
let image = make_match3d_spritesheet_image_transparent(image)?;
let upload = persist_match3d_generated_bytes(
state,
owner_user_id,
session_id,
profile_id,
&["item-spritesheet", generated.task_id.as_str()],
"item-spritesheet.png",
image.mime_type.as_str(),
image.bytes.clone(),
"match3d_item_spritesheet_image",
Some(generated.task_id.as_str()),
current_utc_micros(),
)
.await?;
Ok(Match3DMaterialSheet {
task_id: generated.task_id,
prompt,
image_src: Some(upload.src),
image_object_key: Some(upload.object_key),
image,
})
}
fn merge_match3d_item_spritesheet_asset_metadata(
background_asset: Option<Match3DGeneratedBackgroundAsset>,
prompt: String,
image_src: Option<String>,
image_object_key: Option<String>,
) -> Option<Match3DGeneratedBackgroundAsset> {
background_asset.map(|mut asset| {
asset.item_spritesheet_prompt = Some(prompt);
asset.item_spritesheet_image_src = image_src;
asset.item_spritesheet_image_object_key = image_object_key;
asset
})
}
async fn load_match3d_level_scene_reference_image(
state: &AppState,
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
) -> Result<OpenAiReferenceImage, AppError> {
let Some(source) = background_asset
.and_then(|asset| {
asset
.level_scene_image_object_key
.as_deref()
.or(asset.level_scene_image_src.as_deref())
})
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": MATCH3D_AGENT_PROVIDER,
"message": "抓大鹅物品 spritesheet 生成缺少关卡画面参考图",
})),
);
};
let bytes = if source.starts_with("data:image/") {
decode_match3d_data_url_bytes(source)?
} else if source.trim_start_matches('/').starts_with("generated-") {
read_match3d_generated_object_bytes(
state,
source,
"读取抓大鹅关卡画面参考图失败",
MATCH3D_ITEM_IMAGE_MAX_BYTES,
)
.await?
} else {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": MATCH3D_AGENT_PROVIDER,
"message": "抓大鹅关卡画面参考图必须是图片 Data URL 或 /generated-* 路径",
})),
);
};
Ok(OpenAiReferenceImage {
bytes,
mime_type: "image/png".to_string(),
file_name: "match3d-level-scene.png".to_string(),
})
}
pub(super) fn normalize_match3d_item_name(raw: &str) -> String {
raw.trim()
.trim_matches(['"', '\'', '“', '”', '。', '', ',', '、'])
@@ -1115,20 +1261,20 @@ pub(super) fn resolve_match3d_gameplay_item_count(config: &Match3DConfigJson) ->
8 => 3,
12 => 9,
16 => 15,
20 | 21 => 21,
20 | 21 => 20,
_ => match config.difficulty {
0..=2 => 3,
3..=4 => 9,
5..=6 => 15,
_ => 21,
_ => 20,
},
}
.min(MATCH3D_MAX_GENERATED_ITEM_COUNT)
}
pub(super) fn resolve_match3d_generated_item_count(config: &Match3DConfigJson) -> usize {
round_match3d_item_count_to_full_sheet(resolve_match3d_gameplay_item_count(config))
.min(MATCH3D_MAX_GENERATED_ITEM_COUNT)
let _ = config;
MATCH3D_MAX_GENERATED_ITEM_COUNT
}
fn round_match3d_item_count_to_full_sheet(item_count: usize) -> usize {
@@ -1138,6 +1284,16 @@ fn round_match3d_item_count_to_full_sheet(item_count: usize) -> usize {
item_count.div_ceil(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) * MATCH3D_MATERIAL_ITEM_BATCH_SIZE
}
pub(super) fn resolve_match3d_material_sheet_cell_indices(
item_index: usize,
view_index: usize,
) -> (usize, usize) {
let items_per_row = (MATCH3D_MATERIAL_GRID_SIZE as usize / MATCH3D_ITEM_VIEW_COUNT).max(1);
let row_index = item_index / items_per_row + 1;
let col_index = (item_index % items_per_row) * MATCH3D_ITEM_VIEW_COUNT + view_index + 1;
(row_index, col_index)
}
pub(super) fn sort_match3d_generated_assets(
mut assets: Vec<Match3DGeneratedItemAsset>,
) -> Vec<Match3DGeneratedItemAsset> {
@@ -1295,11 +1451,23 @@ pub(super) fn is_match3d_background_asset_ready(asset: &Match3DGeneratedBackgrou
.filter(|value| !value.is_empty())
.is_some())
&& (asset
.container_image_object_key
.ui_spritesheet_image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
|| asset
.ui_spritesheet_image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
|| asset
.container_image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
|| asset
.container_image_src
.as_deref()
@@ -1312,34 +1480,16 @@ pub(super) fn build_match3d_material_sheet_prompt(
config: &Match3DConfigJson,
item_names: &[String],
) -> String {
let asset_style_prompt = resolve_match3d_asset_style_prompt(config);
let style_clause = asset_style_prompt
.as_ref()
.map(|prompt| format!("整体画风遵循:{prompt}"))
.unwrap_or_default();
let subject_text = format!(
"{}题材的抓大鹅游戏2D物品素材。{style_clause}",
config.theme_text
);
let special_prompt = match3d_material_sheet_special_prompt();
build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput {
subject_text: subject_text.as_str(),
item_names,
grid_size: MATCH3D_MATERIAL_GRID_SIZE as usize,
item_name_prompt_template: Some("第{row_index}行:{item_name} 的 {view_count} 个不同视角"),
special_prompt: Some(special_prompt.as_str()),
})
.unwrap_or_else(|_| {
format!(
"生成一张1:1图片。固定生成5行*5列网格素材图画面是{}题材的抓大鹅游戏2D物品素材。{}",
config.theme_text,
match3d_material_sheet_special_prompt(),
)
})
let _ = (config, item_names);
build_match3d_item_spritesheet_prompt()
}
pub(super) fn build_match3d_item_spritesheet_prompt() -> String {
"固定生成10行*10列spritesheet图统一纯绿色绿幕背景高饱和亮绿色接近 #00FF00背景平整无纹理、无渐变、无阴影、无场景内容后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。素材间距严格均匀分布任意两个素材间距相同物品来自参考图中画面中心容器中的2D素材。每一行包含两种物品每种物品的五个不同形态。物品素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色物品只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。严禁出现两种高相似度的物品".to_string()
}
fn match3d_material_sheet_special_prompt() -> String {
"一行五格必须是同一物品的五个不同视角,依次为正面、左前、右前、俯视、背面;".to_string()
"一行包含两种物品,每种物品的五个不同形态。".to_string()
}
pub(super) fn build_match3d_material_sheet_negative_prompt(config: &Match3DConfigJson) -> String {
@@ -1389,18 +1539,22 @@ pub(super) fn slice_match3d_material_sheet(
image: &DownloadedOpenAiImage,
item_names: &[String],
) -> Result<Vec<Vec<Match3DSlicedItemImage>>, AppError> {
slice_generated_asset_sheet(image, item_names, MATCH3D_MATERIAL_GRID_SIZE as usize).map(
|rows| {
rows.into_iter()
.map(|views| {
views
.into_iter()
.map(|view| Match3DSlicedItemImage { bytes: view.bytes })
.collect()
})
.collect()
},
slice_generated_asset_sheet_two_items_per_row(
image,
item_names,
MATCH3D_MATERIAL_GRID_SIZE as usize,
MATCH3D_ITEM_VIEW_COUNT,
)
.map(|rows| {
rows.into_iter()
.map(|views| {
views
.into_iter()
.map(|view| Match3DSlicedItemImage { bytes: view.bytes })
.collect()
})
.collect()
})
}
#[cfg(test)]

View File

@@ -282,13 +282,25 @@ pub(super) fn map_match3d_image_view_from_work(
pub(super) fn map_match3d_background_asset_for_agent(
asset: Match3DGeneratedBackgroundAsset,
) -> shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
let ui_spritesheet_image_src = asset.ui_spritesheet_image_src.clone();
let ui_spritesheet_image_object_key = asset.ui_spritesheet_image_object_key.clone();
shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
prompt: asset.prompt,
level_scene_prompt: asset.level_scene_prompt,
level_scene_image_src: asset.level_scene_image_src,
level_scene_image_object_key: asset.level_scene_image_object_key,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
ui_spritesheet_prompt: asset.ui_spritesheet_prompt,
ui_spritesheet_image_src: ui_spritesheet_image_src.clone(),
ui_spritesheet_image_object_key: ui_spritesheet_image_object_key.clone(),
item_spritesheet_prompt: asset.item_spritesheet_prompt,
item_spritesheet_image_src: asset.item_spritesheet_image_src,
item_spritesheet_image_object_key: asset.item_spritesheet_image_object_key,
container_prompt: asset.container_prompt,
container_image_src: asset.container_image_src,
container_image_object_key: asset.container_image_object_key,
container_image_src: ui_spritesheet_image_src.or(asset.container_image_src),
container_image_object_key: ui_spritesheet_image_object_key
.or(asset.container_image_object_key),
status: asset.status,
error: asset.error,
}
@@ -299,8 +311,17 @@ pub(super) fn map_match3d_background_asset_for_work(
) -> shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
prompt: asset.prompt,
level_scene_prompt: asset.level_scene_prompt,
level_scene_image_src: asset.level_scene_image_src,
level_scene_image_object_key: asset.level_scene_image_object_key,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
ui_spritesheet_prompt: asset.ui_spritesheet_prompt,
ui_spritesheet_image_src: asset.ui_spritesheet_image_src,
ui_spritesheet_image_object_key: asset.ui_spritesheet_image_object_key,
item_spritesheet_prompt: asset.item_spritesheet_prompt,
item_spritesheet_image_src: asset.item_spritesheet_image_src,
item_spritesheet_image_object_key: asset.item_spritesheet_image_object_key,
container_prompt: asset.container_prompt,
container_image_src: asset.container_image_src,
container_image_object_key: asset.container_image_object_key,
@@ -327,6 +348,14 @@ pub(super) fn resolve_match3d_default_cover_image_src(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| {
asset
.ui_spritesheet_image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
.or_else(|| {
asset
.container_image_object_key
@@ -335,6 +364,14 @@ pub(super) fn resolve_match3d_default_cover_image_src(
.filter(|value| !value.is_empty())
.map(str::to_string)
})
.or_else(|| {
asset
.ui_spritesheet_image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
.or_else(|| {
asset
.image_src
@@ -408,6 +445,10 @@ fn match3d_item_asset_has_image(asset: &Match3DGeneratedItemAssetJson) -> bool {
fn match3d_background_asset_has_image(asset: &Match3DGeneratedBackgroundAsset) -> bool {
match3d_text_present(asset.image_src.as_ref())
|| match3d_text_present(asset.image_object_key.as_ref())
|| match3d_text_present(asset.ui_spritesheet_image_src.as_ref())
|| match3d_text_present(asset.ui_spritesheet_image_object_key.as_ref())
|| match3d_text_present(asset.item_spritesheet_image_src.as_ref())
|| match3d_text_present(asset.item_spritesheet_image_object_key.as_ref())
|| match3d_text_present(asset.container_image_src.as_ref())
|| match3d_text_present(asset.container_image_object_key.as_ref())
}

View File

@@ -147,17 +147,17 @@ fn match3d_item_image_path_segments_stay_unique_for_chinese_names() {
}
#[test]
fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
let width = 500;
let height = 500;
fn match3d_material_sheet_slicing_uses_fixed_ten_by_ten_two_items_per_row() {
let width = 1000;
let height = 1000;
let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()];
let mut sheet = image::RgbaImage::new(width, height);
for row in 0..5 {
for col in 0..5 {
for row in 0..10 {
for col in 0..10 {
let color = image::Rgba([
32 + row as u8 * 40,
24 + col as u8 * 36,
210 - row as u8 * 30,
32 + row as u8 * 16,
24 + col as u8 * 18,
210 - row as u8 * 12,
255,
]);
for y in row * 100..(row + 1) * 100 {
@@ -180,9 +180,12 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice");
assert_eq!(slices.len(), 3);
for (row, views) in slices.iter().enumerate() {
for (item_index, views) in slices.iter().enumerate() {
let row = item_index / 2;
let start_col = (item_index % 2) * MATCH3D_ITEM_VIEW_COUNT;
assert_eq!(views.len(), MATCH3D_ITEM_VIEW_COUNT);
for (col, view) in views.iter().enumerate() {
for (view_index, view) in views.iter().enumerate() {
let col = start_col + view_index;
let decoded = image::load_from_memory(view.bytes.as_slice())
.expect("view should decode")
.to_rgba8();
@@ -190,12 +193,12 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
assert_eq!(
pixel.0,
[
32 + row as u8 * 40,
24 + col as u8 * 36,
210 - row as u8 * 30,
32 + row as u8 * 16,
24 + col as u8 * 18,
210 - row as u8 * 12,
255,
],
"row {row} col {col} should be cut from the fixed 5*5 grid row"
"item {item_index} view {view_index} should be cut from the fixed 10*10 grid"
);
}
}
@@ -203,8 +206,8 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
#[test]
fn match3d_material_sheet_slicing_keeps_near_edge_foreground_pixels() {
let width = 500;
let height = 500;
let width = 1000;
let height = 1000;
let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()];
let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255]));
for y in 1..5 {
@@ -616,6 +619,52 @@ fn match3d_background_image_postprocess_removes_transparent_pixels() {
);
}
#[test]
fn match3d_level_scene_prompt_uses_requested_theme_and_full_ui_layout() {
let prompt = build_match3d_level_scene_generation_prompt(&config("重庆火锅", 12, 4));
assert!(prompt.contains("重庆火锅"));
assert!(prompt.contains("第1关 重庆火锅"));
assert!(prompt.contains("返回按钮位于顶部左上角"));
assert!(prompt.contains("设置按钮"));
assert!(prompt.contains("和主题匹配的容器"));
assert!(prompt.contains("移出"));
assert!(prompt.contains("凑齐"));
assert!(prompt.contains("打乱"));
}
#[test]
fn match3d_derived_asset_prompts_match_three_sheet_pipeline() {
let config = config("水果", 12, 4);
let ui_prompt = build_match3d_ui_spritesheet_prompt();
let background_prompt = build_match3d_background_from_scene_prompt();
let item_prompt = build_match3d_material_sheet_prompt(
&config,
&["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()],
);
assert!(ui_prompt.contains("返回按钮"));
assert!(ui_prompt.contains("设置按钮"));
assert!(ui_prompt.contains("方格素材"));
assert!(ui_prompt.contains("纯绿色绿幕背景spritesheet"));
assert!(ui_prompt.contains("绿幕扣成透明"));
assert!(background_prompt.contains("移除画面中的所有UI组件"));
assert!(background_prompt.contains("完整保留容器和背景"));
assert!(item_prompt.contains("10行*10列"));
assert!(item_prompt.contains("纯绿色绿幕背景"));
assert!(item_prompt.contains("扣成透明"));
assert!(item_prompt.contains("每一行包含两种物品"));
assert!(item_prompt.contains("五个不同形态"));
}
#[test]
fn match3d_hardcore_generated_item_count_is_capped_by_ten_by_ten_sheet() {
assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 21, 8)),
20
);
}
#[test]
fn match3d_work_metadata_parses_gpt4o_json() {
let metadata = parse_match3d_work_metadata(
@@ -687,38 +736,69 @@ fn match3d_legacy_item_asset_without_size_defaults_to_large() {
}
#[test]
fn match3d_draft_item_plan_rounds_up_to_full_five_item_sheets() {
fn match3d_draft_item_plan_rounds_up_to_full_ten_by_ten_sheet() {
let plan = parse_match3d_draft_plan(
r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"},{"name":"葡萄","soundPrompt":"葡萄点击音效"},{"name":"西瓜","soundPrompt":"西瓜点击音效"},{"name":"梨子","soundPrompt":"梨子点击音效"},{"name":"桃子","soundPrompt":"桃子点击音效"},{"name":"橙子","soundPrompt":"橙子点击音效"},{"name":"蓝莓","soundPrompt":"蓝莓点击音效"}]}"#,
&config("水果", 12, 4),
)
.expect("draft plan should parse");
assert_eq!(plan.items.len(), 10);
assert_eq!(plan.items.len(), 20);
assert_eq!(plan.items[8].name, "蓝莓");
assert_ne!(plan.items[9].name, "蓝莓");
}
#[test]
fn match3d_generated_item_count_rounds_up_to_five_multiples() {
fn match3d_generated_item_count_uses_full_ten_by_ten_sheet_capacity() {
assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 8, 2)),
5
20
);
assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 12, 4)),
10
20
);
assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 16, 6)),
15
20
);
assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 21, 8)),
25
20
);
}
#[test]
fn match3d_gameplay_item_count_uses_difficulty_loading_limit() {
assert_eq!(
resolve_match3d_gameplay_item_count(&config("水果", 8, 2)),
3
);
assert_eq!(
resolve_match3d_gameplay_item_count(&config("水果", 12, 4)),
9
);
assert_eq!(
resolve_match3d_gameplay_item_count(&config("水果", 16, 6)),
15
);
assert_eq!(
resolve_match3d_gameplay_item_count(&config("水果", 21, 8)),
20
);
}
#[test]
fn match3d_material_sheet_cell_indices_stay_inside_ten_by_ten_grid() {
let first = resolve_match3d_material_sheet_cell_indices(0, 0);
let second = resolve_match3d_material_sheet_cell_indices(1, 0);
let twentieth_last_view = resolve_match3d_material_sheet_cell_indices(19, 4);
assert_eq!(first, (1, 1));
assert_eq!(second, (1, 6));
assert_eq!(twentieth_last_view, (10, 10));
}
#[test]
fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() {
let assets = vec![test_match3d_generated_item_asset(1, "草莓")];
@@ -731,12 +811,11 @@ fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() {
}
#[test]
fn match3d_item_asset_points_cost_counts_five_item_batches() {
fn match3d_item_asset_points_cost_counts_ten_by_ten_sheet_batches() {
assert_eq!(calculate_match3d_item_assets_points_cost(0), 0);
assert_eq!(calculate_match3d_item_assets_points_cost(1), 2);
assert_eq!(calculate_match3d_item_assets_points_cost(5), 2);
assert_eq!(calculate_match3d_item_assets_points_cost(6), 4);
assert_eq!(calculate_match3d_item_assets_points_cost(10), 4);
assert_eq!(calculate_match3d_item_assets_points_cost(20), 2);
assert_eq!(calculate_match3d_item_assets_points_cost(21), 4);
}
#[test]
@@ -775,7 +854,7 @@ fn match3d_item_asset_append_plan_pads_generation_without_persisting_padding() {
);
assert_eq!(plan.requested_item_names, vec!["苹果", "香蕉", "梨子"]);
assert_eq!(plan.padded_item_names.len(), 5);
assert_eq!(plan.padded_item_names.len(), 20);
assert_eq!(&plan.padded_item_names[..3], ["苹果", "香蕉", "梨子"]);
assert_eq!(
calculate_match3d_item_assets_points_cost(plan.requested_item_names.len()),
@@ -872,6 +951,7 @@ fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() {
container_image_object_key: None,
status: "image_ready".to_string(),
error: None,
..Default::default()
});
let mut generated_asset = test_match3d_generated_item_asset(99, "新草莓");
generated_asset.image_src =
@@ -897,20 +977,19 @@ fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() {
}
#[test]
fn match3d_material_sheet_prompt_requires_uniform_five_by_five_layout() {
fn match3d_material_sheet_prompt_requires_uniform_ten_by_ten_transparent_layout() {
let prompt = build_match3d_material_sheet_prompt(
&config("水果", 12, 4),
&["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()],
);
assert!(prompt.contains("5行*5列"));
assert!(prompt.contains("严格5*5均匀排布"));
assert!(prompt.contains("绿幕背景"));
assert!(prompt.contains("10行*10列spritesheet图"));
assert!(prompt.contains("纯绿色绿幕背景"));
assert!(prompt.contains("#00FF00"));
assert!(prompt.contains("单个素材格宽度的1/4空白间距"));
assert!(prompt.contains("约25%单格宽度"));
assert!(prompt.contains("禁止主体跨格"));
assert!(prompt.contains("贴边或越界"));
assert!(prompt.contains("素材间距严格均匀分布"));
assert!(prompt.contains("每一行包含两种物品"));
assert!(prompt.contains("每种物品的五个不同形态"));
assert!(prompt.contains("严禁出现两种高相似度的物品"));
}
#[test]
@@ -921,16 +1000,53 @@ fn match3d_material_sheet_prompt_hardens_pixel_retro_style() {
let prompt = build_match3d_material_sheet_prompt(&config, &["草莓".to_string()]);
let negative_prompt = build_match3d_material_sheet_negative_prompt(&config);
assert!(prompt.contains("64x64"));
assert!(prompt.contains("整数倍放大"));
assert!(prompt.contains("禁止抗锯齿"));
assert!(prompt.contains("真实 3D 渲染"));
assert!(prompt.contains("PBR 材质"));
assert!(prompt.contains("10行*10列spritesheet图"));
assert!(prompt.contains("纯绿色绿幕背景"));
assert!(negative_prompt.contains("抗锯齿"));
assert!(negative_prompt.contains("平滑插画"));
assert!(negative_prompt.contains("真实 3D 渲染"));
}
#[test]
fn match3d_spritesheet_green_screen_postprocess_turns_background_transparent() {
let width = 100;
let height = 100;
let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255]));
for y in 32..68 {
for x in 32..68 {
image.put_pixel(x, y, image::Rgba([220, 32, 48, 255]));
}
}
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(image)
.write_to(&mut encoded, ImageFormat::Png)
.expect("spritesheet should encode");
let processed = make_match3d_spritesheet_image_transparent(DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
.expect("spritesheet should postprocess");
let decoded = image::load_from_memory(processed.bytes.as_slice())
.expect("processed spritesheet should decode")
.to_rgba8();
assert_eq!(processed.mime_type, "image/png");
assert_eq!(processed.extension, "png");
assert_eq!(
decoded.get_pixel(0, 0).0[3],
0,
"绿幕背景必须在上传 OSS 前扣成透明 alpha"
);
assert_eq!(
decoded.get_pixel(width / 2, height / 2).0,
[220, 32, 48, 255],
"物品主体不能被绿幕去背误删"
);
}
#[test]
fn match3d_material_sheet_request_uses_vector_engine_gemini_contract() {
let body = build_match3d_vector_engine_gemini_image_request_body(
@@ -1060,6 +1176,7 @@ fn match3d_background_asset_requires_background_and_container_images() {
container_image_object_key: None,
status: "image_ready".to_string(),
error: None,
..Default::default()
};
let with_container = Match3DGeneratedBackgroundAsset {
container_prompt: Some("果园容器".to_string()),
@@ -1106,6 +1223,7 @@ fn match3d_default_cover_prefers_generated_container_ui_image() {
container_image_object_key: None,
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
error: None,
@@ -1181,8 +1299,8 @@ fn match3d_cover_reference_prompt_marks_reference_images() {
}
#[test]
fn match3d_cover_edit_prompt_preserves_uploaded_image() {
let prompt = build_match3d_cover_edit_prompt("水果封面");
fn match3d_cover_reference_generation_prompt_preserves_uploaded_image() {
let prompt = build_match3d_cover_uploaded_reference_prompt("水果封面");
assert!(prompt.contains("上传的封面图作为第一优先级"));
assert!(prompt.contains("保留主图的主体、构图、视角和主要配色"));
@@ -1225,6 +1343,7 @@ fn match3d_fallback_work_profile_keeps_generated_background_asset() {
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
error: None,
@@ -1362,6 +1481,7 @@ fn match3d_agent_session_response_hydrates_persisted_ui_assets() {
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
error: None,
@@ -1437,6 +1557,7 @@ fn match3d_agent_session_response_keeps_draft_ui_assets_without_work_detail_hydr
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
error: None,
@@ -1820,6 +1941,7 @@ fn match3d_work_summary_marks_complete_generated_assets_ready() {
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
..test_match3d_generated_item_asset(1, "草莓")
}];

View File

@@ -27,6 +27,8 @@ pub(super) async fn generate_match3d_material_sheet(
Ok(Match3DMaterialSheet {
task_id: generated.task_id,
prompt,
image_src: None,
image_object_key: None,
image,
})
}

View File

@@ -212,7 +212,7 @@ pub(super) async fn ensure_match3d_background_asset(
}
}
let generated_background = generate_match3d_background_image(
let generated_background = generate_match3d_level_asset_bundle(
state,
owner_user_id,
session_id,
@@ -236,6 +236,40 @@ pub(super) async fn ensure_match3d_background_asset(
Ok(assets)
}
pub(super) async fn resolve_or_generate_match3d_level_asset_bundle(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
background_prompt: &str,
assets: &[Match3DGeneratedItemAsset],
) -> Result<Match3DGeneratedBackgroundAsset, Response> {
if let Some(existing_background) = find_match3d_generated_background_asset(assets) {
if is_match3d_background_asset_ready(&existing_background) {
return Ok(existing_background);
}
}
let normalized_prompt = normalize_match3d_background_prompt(background_prompt);
let resolved_prompt = if normalized_prompt.is_empty() {
build_fallback_match3d_background_prompt(config)
} else {
normalized_prompt
};
generate_match3d_level_asset_bundle(
state,
owner_user_id,
session_id,
profile_id,
config,
resolved_prompt.as_str(),
)
.await
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))
}
pub(super) fn attach_match3d_background_asset_to_assets(
assets: &mut Vec<Match3DGeneratedItemAsset>,
background_asset: Match3DGeneratedBackgroundAsset,
@@ -281,7 +315,7 @@ pub(super) async fn generate_match3d_cover_image_asset(
create_openai_image_edit(
&http_client,
&settings,
build_match3d_cover_edit_prompt(cover_prompt.as_str()).as_str(),
build_match3d_cover_uploaded_reference_prompt(cover_prompt.as_str()).as_str(),
Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"),
"1:1",
&uploaded_image,
@@ -289,27 +323,38 @@ pub(super) async fn generate_match3d_cover_image_asset(
)
.await?
} else {
let reference_images = resolve_match3d_cover_reference_image_data_urls(
let reference_images = resolve_match3d_cover_reference_images_for_edit(
state,
reference_image_srcs,
MATCH3D_ITEM_IMAGE_MAX_BYTES,
)
.await?;
create_openai_image_generation(
&http_client,
&settings,
build_match3d_cover_reference_generation_prompt(
if reference_images.is_empty() {
create_openai_image_generation(
&http_client,
&settings,
cover_prompt.as_str(),
!reference_images.is_empty(),
Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"),
"1:1",
1,
&[],
"抓大鹅封面图生成失败",
)
.as_str(),
Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"),
"1:1",
1,
reference_images.as_slice(),
"抓大鹅封面图生成失败",
)
.await?
.await?
} else {
create_openai_image_edit_with_references(
&http_client,
&settings,
build_match3d_cover_reference_generation_prompt(cover_prompt.as_str(), true)
.as_str(),
Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"),
"1:1",
1,
reference_images.as_slice(),
"抓大鹅封面图生成失败",
)
.await?
}
};
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
@@ -347,7 +392,7 @@ fn build_match3d_cover_generation_prompt(config: &Match3DConfigJson, prompt: &st
)
}
pub(super) fn build_match3d_cover_edit_prompt(prompt: &str) -> String {
pub(super) fn build_match3d_cover_uploaded_reference_prompt(prompt: &str) -> String {
format!(
concat!(
"请以随请求上传的封面图作为第一优先级重绘依据,保留主图的主体、构图、视角和主要配色;",
@@ -382,24 +427,113 @@ pub(super) async fn generate_match3d_background_image(
profile_id: &str,
config: &Match3DConfigJson,
prompt: &str,
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
generate_match3d_level_asset_bundle(
state,
owner_user_id,
session_id,
profile_id,
config,
prompt,
)
.await
}
pub(super) async fn generate_match3d_level_asset_bundle(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
prompt: &str,
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
require_match3d_oss_client(state)?;
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let reference_image = load_match3d_container_reference_image()?;
let generated_background = create_openai_image_generation(
let level_scene_prompt = build_match3d_level_scene_generation_prompt(config);
let generated_scene = create_openai_image_generation(
&http_client,
&settings,
build_match3d_background_generation_prompt(config, prompt).as_str(),
Some(
"文字、水印、UI、按钮、倒计时、分数、物品、角色、手、边框、教程浮层、菜单、透明区域、透明 alpha、镂空、棋盘格透明底",
),
level_scene_prompt.as_str(),
Some("水印、教程浮层、菜单、广告、真实手机外框、浏览器 UI"),
"9:16",
1,
&[],
"抓大鹅背景图生成失败",
"抓大鹅关卡画面生成失败",
)
.await?;
let level_scene_image = generated_scene.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "抓大鹅关卡画面生成失败:未返回图片",
}))
})?;
let level_scene_reference = OpenAiReferenceImage {
bytes: level_scene_image.bytes.clone(),
mime_type: level_scene_image.mime_type.clone(),
file_name: "match3d-level-scene.png".to_string(),
};
let level_scene_upload = persist_match3d_generated_bytes(
state,
owner_user_id,
session_id,
profile_id,
&["level-scene", generated_scene.task_id.as_str()],
"scene.png",
level_scene_image.mime_type.as_str(),
level_scene_image.bytes,
"match3d_level_scene_image",
Some(generated_scene.task_id.as_str()),
current_utc_micros(),
)
.await?;
let ui_prompt = build_match3d_ui_spritesheet_prompt();
let background_extract_prompt = build_match3d_background_from_scene_prompt();
let generated_ui_future = create_openai_image_edit(
&http_client,
&settings,
ui_prompt.as_str(),
Some("整页背景、中心物品、容器内物品、重复按钮、文字说明、白底、纯色底、网格线"),
"1:1",
&level_scene_reference,
"抓大鹅 UI spritesheet 生成失败",
);
let generated_background_future = create_openai_image_edit(
&http_client,
&settings,
background_extract_prompt.as_str(),
Some("返回按钮、设置按钮、倒计时、标题文字、道具按钮、物品、容器内含物、菜单、教程浮层"),
"9:16",
&level_scene_reference,
"抓大鹅背景图生成失败",
);
let (generated_ui, generated_background) =
tokio::try_join!(generated_ui_future, generated_background_future)?;
let ui_image = generated_ui.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "抓大鹅 UI spritesheet 生成失败:未返回图片",
}))
})?;
let ui_image = make_match3d_spritesheet_image_transparent(ui_image)?;
let ui_upload = persist_match3d_generated_bytes(
state,
owner_user_id,
session_id,
profile_id,
&["ui-spritesheet", generated_ui.task_id.as_str()],
"ui-spritesheet.png",
ui_image.mime_type.as_str(),
ui_image.bytes,
"match3d_ui_spritesheet_image",
Some(generated_ui.task_id.as_str()),
current_utc_micros(),
)
.await?;
let background_image = generated_background
.images
.into_iter()
@@ -426,50 +560,22 @@ pub(super) async fn generate_match3d_background_image(
)
.await?;
let container_prompt = build_match3d_container_generation_prompt(config, prompt);
let generated_container = create_openai_image_edit(
&http_client,
&settings,
container_prompt.as_str(),
Some("文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层、菜单、整页背景、小容器、正俯视圆盘、侧视碗、餐盘、托盘、画布大留白"),
"1:1",
&reference_image,
"抓大鹅容器 UI 图生成失败",
)
.await?;
let container_image = generated_container
.images
.into_iter()
.next()
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "抓大鹅容器 UI 图生成失败:未返回图片",
}))
})?;
let container_image = make_match3d_container_image_transparent(container_image)?;
let container_upload = persist_match3d_generated_bytes(
state,
owner_user_id,
session_id,
profile_id,
&["ui-container", generated_container.task_id.as_str()],
"container.png",
container_image.mime_type.as_str(),
container_image.bytes,
"match3d_ui_container_image",
Some(generated_container.task_id.as_str()),
current_utc_micros(),
)
.await?;
Ok(Match3DGeneratedBackgroundAsset {
prompt: prompt.to_string(),
level_scene_prompt: Some(level_scene_prompt),
level_scene_image_src: Some(level_scene_upload.src),
level_scene_image_object_key: Some(level_scene_upload.object_key),
image_src: Some(background_upload.src),
image_object_key: Some(background_upload.object_key),
container_prompt: Some(container_prompt),
container_image_src: Some(container_upload.src),
container_image_object_key: Some(container_upload.object_key),
ui_spritesheet_prompt: Some(ui_prompt.clone()),
ui_spritesheet_image_src: Some(ui_upload.src.clone()),
ui_spritesheet_image_object_key: Some(ui_upload.object_key.clone()),
item_spritesheet_prompt: None,
item_spritesheet_image_src: None,
item_spritesheet_image_object_key: None,
container_prompt: Some(ui_prompt),
container_image_src: Some(ui_upload.src),
container_image_object_key: Some(ui_upload.object_key),
status: "image_ready".to_string(),
error: None,
})
@@ -533,6 +639,7 @@ pub(super) async fn generate_match3d_container_image(
container_image_object_key: Some(container_upload.object_key),
status: "image_ready".to_string(),
error: None,
..Default::default()
})
}
@@ -549,12 +656,39 @@ pub(super) fn merge_match3d_container_image_into_background_asset(
.unwrap_or_else(|| container_asset.prompt.clone());
Match3DGeneratedBackgroundAsset {
prompt,
level_scene_prompt: existing_background
.as_ref()
.and_then(|asset| asset.level_scene_prompt.clone()),
level_scene_image_src: existing_background
.as_ref()
.and_then(|asset| asset.level_scene_image_src.clone()),
level_scene_image_object_key: existing_background
.as_ref()
.and_then(|asset| asset.level_scene_image_object_key.clone()),
image_src: existing_background
.as_ref()
.and_then(|asset| asset.image_src.clone()),
image_object_key: existing_background
.as_ref()
.and_then(|asset| asset.image_object_key.clone()),
ui_spritesheet_prompt: existing_background
.as_ref()
.and_then(|asset| asset.ui_spritesheet_prompt.clone()),
ui_spritesheet_image_src: existing_background
.as_ref()
.and_then(|asset| asset.ui_spritesheet_image_src.clone()),
ui_spritesheet_image_object_key: existing_background
.as_ref()
.and_then(|asset| asset.ui_spritesheet_image_object_key.clone()),
item_spritesheet_prompt: existing_background
.as_ref()
.and_then(|asset| asset.item_spritesheet_prompt.clone()),
item_spritesheet_image_src: existing_background
.as_ref()
.and_then(|asset| asset.item_spritesheet_image_src.clone()),
item_spritesheet_image_object_key: existing_background
.as_ref()
.and_then(|asset| asset.item_spritesheet_image_object_key.clone()),
container_prompt: container_asset.container_prompt,
container_image_src: container_asset.container_image_src,
container_image_object_key: container_asset.container_image_object_key,
@@ -582,6 +716,44 @@ pub(super) fn load_match3d_container_reference_image() -> Result<OpenAiReference
})
}
pub(super) fn build_match3d_level_scene_generation_prompt(config: &Match3DConfigJson) -> String {
let theme = config.theme_text.trim();
let theme = if theme.is_empty() {
MATCH3D_DEFAULT_THEME
} else {
theme
};
let style_clause = resolve_match3d_asset_style_prompt(config)
.map(|style| format!("\n整体美术风格要求:{style}"))
.unwrap_or_default();
format!(
concat!(
"生成抓大鹅游戏关卡画面要求画面中所有元素精致且风格高度一致画面中所有UI细节饱满精致、完成度高、顶级游戏品质\n\n",
"抓大鹅主题描述:\n",
"{theme}{style_clause}\n\n",
"画面元素:\n",
"返回按钮位于顶部左上角顶部中间显示关卡标题“第1关 重庆火锅”和倒计时时间,右上角显示设置按钮\n",
"画面中间是一个和主题匹配的容器,宽度与画面宽度同宽,紧贴画面横向边缘\n",
"底部还有三个道具按钮分别为“移出”、“凑齐”、“打乱”"
),
theme = theme,
style_clause = style_clause,
)
}
pub(super) fn build_match3d_ui_spritesheet_prompt() -> String {
"提取画面中的UI元素将返回按钮、设置按钮、方格素材不含边框仅保留一个、移出按钮、凑齐按钮、打乱按钮的顺序从上到下从左到右整理成纯绿色绿幕背景spritesheet。背景必须是统一纯绿色绿幕高饱和亮绿色接近 #00FF00背景平整无纹理、无渐变、无阴影、无场景内容后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。UI 素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。".to_string()
}
pub(super) fn build_match3d_background_from_scene_prompt() -> String {
"移除画面中的所有UI组件和容器中的内含物完整保留容器和背景补全被UI覆盖的背景内容".to_string()
}
pub(super) fn build_match3d_item_spritesheet_prompt() -> String {
"固定生成10行*10列spritesheet图统一纯绿色绿幕背景高饱和亮绿色接近 #00FF00背景平整无纹理、无渐变、无阴影、无场景内容后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。素材间距严格均匀分布任意两个素材间距相同物品来自参考图中画面中心容器中的2D素材。每一行包含两种物品每种物品的五个不同形态。物品素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色物品只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。严禁出现两种高相似度的物品".to_string()
}
pub(super) fn build_match3d_background_generation_prompt(
config: &Match3DConfigJson,
prompt: &str,
@@ -761,6 +933,32 @@ pub(super) fn make_match3d_container_image_transparent(
extension: "png".to_string(),
})
}
pub(super) fn make_match3d_spritesheet_image_transparent(
image: DownloadedOpenAiImage,
) -> Result<DownloadedOpenAiImage, AppError> {
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "match3d-assets",
"message": format!("抓大鹅 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": "match3d-assets",
"message": format!("抓大鹅 spritesheet 图透明化失败:{error}"),
}))
})?;
Ok(DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
}
pub(super) async fn download_match3d_legacy_model(
file: &hyper3d_contract::Hyper3dDownloadFilePayload,
) -> Result<Match3DDownloadedModel, AppError> {
@@ -864,7 +1062,7 @@ pub(super) fn is_match3d_glb_binary_payload(bytes: &[u8]) -> bool {
magic == 0x4654_6c67 && version == 2 && declared_length == bytes.len()
}
async fn read_match3d_generated_object_bytes(
pub(super) async fn read_match3d_generated_object_bytes(
state: &AppState,
object_key: &str,
message_prefix: &str,
@@ -915,57 +1113,6 @@ async fn read_match3d_generated_object_bytes(
Ok(bytes.to_vec())
}
async fn resolve_match3d_reference_image_data_url(
state: &AppState,
source: Option<&str>,
max_size_bytes: usize,
) -> Result<Option<String>, AppError> {
let Some(source) = source.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
if source.starts_with("data:image/") {
return Ok(Some(source.to_string()));
}
if let Some(public_path) = normalize_match3d_public_reference_image_path(source) {
let bytes = tokio::fs::read(public_path.as_str())
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": MATCH3D_WORKS_PROVIDER,
"message": format!("读取抓大鹅本地参考图失败:{error}"),
"path": public_path,
}))
})?;
if bytes.is_empty() || bytes.len() > max_size_bytes {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": MATCH3D_WORKS_PROVIDER,
"field": "referenceImageSrcs",
"message": "封面参考图过大,请压缩后重试。",
"maxBytes": max_size_bytes,
"actualBytes": bytes.len(),
})),
);
}
return Ok(Some(format!(
"data:{};base64,{}",
infer_match3d_image_mime_type(bytes.as_slice()),
BASE64_STANDARD.encode(bytes)
)));
}
if !source.trim_start_matches('/').starts_with("generated-") {
return Ok(Some(source.to_string()));
}
let bytes =
read_match3d_generated_object_bytes(state, source, "读取抓大鹅参考图失败", max_size_bytes)
.await?;
Ok(Some(format!(
"data:{};base64,{}",
infer_match3d_image_mime_type(bytes.as_slice()),
BASE64_STANDARD.encode(bytes)
)))
}
pub(super) fn normalize_match3d_public_reference_image_path(source: &str) -> Option<String> {
let source = source
.trim()
@@ -1018,18 +1165,22 @@ pub(super) fn collect_match3d_cover_reference_image_sources(
sources
}
async fn resolve_match3d_cover_reference_image_data_urls(
async fn resolve_match3d_cover_reference_images_for_edit(
state: &AppState,
sources: Vec<String>,
max_size_bytes: usize,
) -> Result<Vec<String>, AppError> {
) -> Result<Vec<OpenAiReferenceImage>, AppError> {
let mut resolved = Vec::new();
for source in sources {
if let Some(data_url) =
resolve_match3d_reference_image_data_url(state, Some(source.as_str()), max_size_bytes)
.await?
for (index, source) in sources.into_iter().enumerate() {
if let Some(image) = resolve_match3d_reference_image_for_edit(
state,
Some(source.as_str()),
max_size_bytes,
format!("match3d-cover-reference-{index}").as_str(),
)
.await?
{
resolved.push(data_url);
resolved.push(image);
}
}
Ok(resolved)
@@ -1046,6 +1197,16 @@ async fn resolve_match3d_reference_image_for_edit(
};
let bytes = if source.starts_with("data:image/") {
decode_match3d_data_url_bytes(source)?
} else if let Some(public_path) = normalize_match3d_public_reference_image_path(source) {
tokio::fs::read(public_path.as_str())
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": MATCH3D_WORKS_PROVIDER,
"message": format!("读取抓大鹅本地参考图失败:{error}"),
"path": public_path,
}))
})?
} else if source.trim_start_matches('/').starts_with("generated-") {
read_match3d_generated_object_bytes(
state,
@@ -1059,7 +1220,7 @@ async fn resolve_match3d_reference_image_for_edit(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": MATCH3D_WORKS_PROVIDER,
"field": "uploadedImageSrc",
"message": "封面上传图必须是图片 Data URL 或 /generated-* 路径。",
"message": "封面参考图必须是图片 Data URL、本地 public 参考图或 /generated-* 路径。",
})),
);
};
@@ -1086,7 +1247,7 @@ async fn resolve_match3d_reference_image_for_edit(
}))
}
fn decode_match3d_data_url_bytes(source: &str) -> Result<Vec<u8>, AppError> {
pub(super) fn decode_match3d_data_url_bytes(source: &str) -> Result<Vec<u8>, AppError> {
let Some((header, data)) = source.split_once(',') else {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({

View File

@@ -15,7 +15,7 @@ use crate::{
};
pub(crate) const GPT_IMAGE_2_MODEL: &str = "gpt-image-2";
pub(crate) const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = "gpt-image-2-all";
pub(crate) const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL;
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
#[derive(Clone)]
@@ -62,7 +62,7 @@ pub(crate) struct OpenAiReferenceImage {
pub file_name: String,
}
// 中文注释RPG、方洞等图片资产统一走 VectorEngine GPT-image-2-all,避免把密钥或供应商协议暴露到前端。
// 中文注释RPG、方洞等图片资产统一走后端 VectorEngine GPT-image-2避免把密钥或供应商协议暴露到前端。
pub(crate) fn require_openai_image_settings(
state: &AppState,
) -> Result<OpenAiImageSettings, AppError> {
@@ -106,7 +106,7 @@ 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 流的兼容问题。
// 中文注释:参考图会走 multipart edits强制 HTTP/1.1 可避开部分网关对长耗时上传流的兼容问题。
.http1_only()
.build()
.map_err(|error| {
@@ -127,6 +127,22 @@ pub(crate) async fn create_openai_image_generation(
reference_images: &[String],
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
if !reference_images.is_empty() {
let resolved_references =
resolve_openai_reference_images(http_client, reference_images, failure_context).await?;
return create_openai_image_edit_with_references(
http_client,
settings,
prompt,
negative_prompt,
size,
candidate_count,
resolved_references.as_slice(),
failure_context,
)
.await;
}
let request_url = vector_engine_images_generation_url(settings);
let normalized_size = normalize_image_size(size);
let request_body = build_openai_image_request_body(
@@ -386,6 +402,7 @@ pub(crate) async fn create_openai_image_edit(
prompt,
negative_prompt,
size,
1,
std::slice::from_ref(reference_image),
failure_context,
)
@@ -398,6 +415,7 @@ pub(crate) async fn create_openai_image_edit_with_references(
prompt: &str,
negative_prompt: Option<&str>,
size: &str,
candidate_count: u32,
reference_images: &[OpenAiReferenceImage],
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
@@ -405,12 +423,11 @@ pub(crate) async fn create_openai_image_edit_with_references(
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("{failure_context}:缺少参考图"),
"message": format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"),
})),
);
}
let task_id = format!("vector-engine-edit-{}", current_utc_micros());
let request_url = vector_engine_images_edit_url(settings);
let normalized_size = normalize_image_size(size);
@@ -420,9 +437,10 @@ pub(crate) async fn create_openai_image_edit_with_references(
"prompt",
build_prompt_with_negative(prompt, negative_prompt),
)
.text("n", "1")
.text("n", candidate_count.clamp(1, 4).to_string())
.text("size", normalized_size.clone());
for reference_image in reference_images {
for reference_image in reference_images.iter().take(5) {
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())
@@ -434,8 +452,8 @@ pub(crate) async fn create_openai_image_edit_with_references(
form = form.part("image", image_part);
}
let reference_image_count = reference_images.iter().take(5).count();
let started_at = std::time::Instant::now();
let reference_image_count = reference_images.len();
let response = match http_client
.post(request_url.as_str())
.header(
@@ -578,43 +596,51 @@ pub(crate) async fn create_openai_image_edit_with_references(
return Err(error);
}
};
let task_id = extract_generation_id(&response_json.payload)
.unwrap_or_else(|| format!("vector-engine-edit-{}", current_utc_micros()));
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 download_started_at = std::time::Instant::now();
let mut generated =
match download_images_from_urls(http_client, task_id, image_urls, 1).await {
Ok(generated) => generated,
Err(error) => {
record_openai_image_failure_if_configured(
settings,
build_openai_image_failure_audit_draft(
request_url.as_str(),
failure_context,
"image_download",
Some(response_status.as_u16()),
Some(app_error_status_class(error.status_code())),
false,
false,
error.body_text().as_str(),
None,
None,
Some(download_started_at.elapsed().as_millis() as u64),
Some(prompt.chars().count()),
Some(reference_image_count),
),
)
.await;
return Err(error);
}
};
let mut generated = match download_images_from_urls(
http_client,
task_id,
image_urls,
candidate_count,
)
.await
{
Ok(generated) => generated,
Err(error) => {
record_openai_image_failure_if_configured(
settings,
build_openai_image_failure_audit_draft(
request_url.as_str(),
failure_context,
"image_download",
Some(response_status.as_u16()),
Some(app_error_status_class(error.status_code())),
false,
false,
error.body_text().as_str(),
None,
None,
Some(download_started_at.elapsed().as_millis() as u64),
Some(prompt.chars().count()),
Some(reference_image_count),
),
)
.await;
return Err(error);
}
};
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);
let mut generated = images_from_base64(task_id, b64_images, candidate_count);
generated.actual_prompt = actual_prompt;
return Ok(generated);
}
@@ -641,7 +667,7 @@ pub(crate) async fn create_openai_image_edit_with_references(
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("{failure_context}VectorEngine 未返回编辑图片"),
"message": format!("{failure_context}VectorEngine 未返回图片"),
})),
)
}
@@ -651,12 +677,12 @@ pub(crate) fn build_openai_image_request_body(
negative_prompt: Option<&str>,
size: &str,
candidate_count: u32,
reference_images: &[String],
_reference_images: &[String],
) -> Value {
let mut body = Map::from_iter([
let body = Map::from_iter([
(
"model".to_string(),
Value::String(VECTOR_ENGINE_GPT_IMAGE_2_MODEL.to_string()),
Value::String(GPT_IMAGE_2_MODEL.to_string()),
),
(
"prompt".to_string(),
@@ -669,10 +695,6 @@ pub(crate) fn build_openai_image_request_body(
),
]);
if !reference_images.is_empty() {
body.insert("image".to_string(), json!(reference_images));
}
Value::Object(body)
}
@@ -784,6 +806,100 @@ pub(crate) async fn download_remote_image(
})
}
async fn resolve_openai_reference_images(
http_client: &reqwest::Client,
reference_images: &[String],
failure_context: &str,
) -> Result<Vec<OpenAiReferenceImage>, AppError> {
let mut resolved = Vec::new();
for (index, source) in reference_images.iter().take(5).enumerate() {
let source = source.trim();
if source.is_empty() {
continue;
}
if let Some(reference_image) = parse_openai_reference_image_data_url(source, index)? {
resolved.push(reference_image);
continue;
}
if source.starts_with("http://") || source.starts_with("https://") {
let downloaded = download_remote_image(http_client, source)
.await
.map_err(|error| {
map_openai_image_request_error(format!(
"{failure_context}:下载参考图失败:{}",
error.body_text()
))
})?;
resolved.push(OpenAiReferenceImage {
bytes: downloaded.bytes,
mime_type: downloaded.mime_type.clone(),
file_name: format!(
"reference-{index}.{}",
mime_to_extension(downloaded.mime_type.as_str())
),
});
continue;
}
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("{failure_context}:参考图必须是图片 Data URL 或 HTTP(S) URL。"),
})),
);
}
if resolved.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("{failure_context}:图片编辑需要至少一张参考图。"),
})),
);
}
Ok(resolved)
}
fn parse_openai_reference_image_data_url(
source: &str,
index: usize,
) -> Result<Option<OpenAiReferenceImage>, AppError> {
let Some(body) = source.strip_prefix("data:") else {
return Ok(None);
};
let Some((mime_type, data)) = body.split_once(";base64,") else {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": "参考图 Data URL 必须是 base64 图片。",
})),
);
};
if !mime_type.starts_with("image/") {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": "参考图 Data URL 必须是图片类型。",
})),
);
}
let bytes = BASE64_STANDARD.decode(data.trim()).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("参考图 Data URL 解码失败:{error}"),
}))
})?;
let mime_type = normalize_downloaded_image_mime_type(mime_type);
Ok(Some(OpenAiReferenceImage {
bytes,
file_name: format!(
"reference-{index}.{}",
mime_to_extension(mime_type.as_str())
),
mime_type,
}))
}
fn parse_json_payload(
raw_text: &str,
failure_context: &str,
@@ -1095,7 +1211,7 @@ mod tests {
use super::*;
#[test]
fn gpt_image_2_request_uses_vector_engine_contract() {
fn gpt_image_2_generation_request_uses_create_model_without_reference_images() {
let body = build_openai_image_request_body(
"雾海神殿",
Some("文字,水印"),
@@ -1104,16 +1220,41 @@ mod tests {
&["data:image/png;base64,abcd".to_string()],
);
assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL);
assert_eq!(body["model"], GPT_IMAGE_2_MODEL);
assert_eq!(body["size"], "1536x1024");
assert_eq!(body["n"], 2);
assert!(body.get("official_fallback").is_none());
assert_eq!(body["image"][0], "data:image/png;base64,abcd");
assert!(body.get("image").is_none());
assert!(body["prompt"].as_str().unwrap_or_default().contains("避免"));
}
#[test]
fn vector_engine_edit_url_uses_images_edits_endpoint() {
fn vector_engine_generation_url_normalizes_base_url() {
let root_settings = OpenAiImageSettings {
base_url: "https://vector.example".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 1_000_000,
external_api_audit_state: None,
};
let v1_settings = OpenAiImageSettings {
base_url: "https://vector.example/v1".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 1_000_000,
external_api_audit_state: None,
};
assert_eq!(
vector_engine_images_generation_url(&root_settings),
"https://vector.example/v1/images/generations"
);
assert_eq!(
vector_engine_images_generation_url(&v1_settings),
"https://vector.example/v1/images/generations"
);
}
#[test]
fn vector_engine_edit_url_normalizes_base_url() {
let root_settings = OpenAiImageSettings {
base_url: "https://vector.example".to_string(),
api_key: "test-key".to_string(),
@@ -1153,6 +1294,7 @@ mod tests {
"提示词",
None,
"1:1",
1,
&[],
"测试图片编辑失败",
)
@@ -1163,6 +1305,21 @@ mod tests {
assert!(error.body_text().contains("缺少参考图"));
}
#[test]
fn reference_data_url_resolves_to_edit_image_part() {
let source = format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(b"pngbytes")
);
let image = parse_openai_reference_image_data_url(source.as_str(), 2)
.expect("data url should parse")
.expect("data url should resolve image");
assert_eq!(image.bytes, b"pngbytes");
assert_eq!(image.mime_type, "image/png");
assert_eq!(image.file_name, "reference-2.png");
}
#[test]
fn b64_json_response_decodes_png_image() {
let images = images_from_base64(

View File

@@ -40,9 +40,11 @@ pub(crate) fn resolve_puzzle_draft_cover_prompt(
pub(crate) fn resolve_puzzle_level_image_prompt(
explicit_prompt: Option<&str>,
level_picture_description: &str,
draft_summary: &str,
) -> String {
normalize_prompt_part(explicit_prompt)
.or_else(|| normalize_prompt_part(Some(level_picture_description)))
.or_else(|| normalize_prompt_part(Some(draft_summary)))
.unwrap_or_default()
.to_string()
}
@@ -76,8 +78,15 @@ mod tests {
#[test]
fn level_image_prompt_falls_back_to_level_description() {
let prompt = resolve_puzzle_level_image_prompt(Some(" "), "关卡画面描述");
let prompt = resolve_puzzle_level_image_prompt(Some(" "), "关卡画面描述", "作品简介");
assert_eq!(prompt, "关卡画面描述");
}
#[test]
fn level_image_prompt_falls_back_to_draft_summary_like_initial_cover() {
let prompt = resolve_puzzle_level_image_prompt(Some(" "), " ", "作品简介");
assert_eq!(prompt, "作品简介");
}
}

View File

@@ -43,7 +43,7 @@ fn build_puzzle_image_prompt_text(_level_name: &str, prompt: &str) -> String {
concat!(
"请生成一张高清插画。",
"画面主体:{prompt}。",
"画面要求:1:1",
"画面要求:输出画面比例为11",
"主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,",
"避免文字、水印、边框和 UI 元素。"
),
@@ -77,7 +77,7 @@ mod tests {
let prompt = build_puzzle_image_prompt("雨夜神庙", "猫咪在发光遗迹前寻找线索");
assert!(prompt.contains("猫咪在发光遗迹前寻找线索"));
assert!(prompt.contains("1:1"));
assert!(prompt.contains("输出画面比例为11"));
assert!(prompt.contains("主体要清晰集中"));
assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
}
@@ -90,7 +90,7 @@ mod tests {
let prompt = build_puzzle_image_prompt(long_level_name.as_str(), long_description.as_str());
assert!(prompt.chars().count() <= PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS);
assert!(prompt.contains("1:1"));
assert!(prompt.contains("输出画面比例为11"));
assert!(prompt.contains("主体要清晰集中"));
assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
}

View File

@@ -1,6 +1,5 @@
use std::{
collections::BTreeMap,
error::Error as StdError,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
@@ -78,10 +77,11 @@ use crate::{
should_skip_asset_operation_billing_for_connectivity,
},
auth::AuthenticatedAccessToken,
generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha,
http_error::AppError,
llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL},
openai_image_generation::{
DownloadedOpenAiImage, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, build_openai_image_http_client,
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, build_openai_image_http_client,
create_openai_image_generation, require_openai_image_settings,
},
platform_errors::map_oss_error,
@@ -124,9 +124,13 @@ const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
const PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS: u32 = 512;
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;
const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5;
const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2";
const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素";
const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024";
const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536";
const PUZZLE_LEVEL_SCENE_IMAGE_PROMPT: &str = "参考图作为拼图画面生成对应的拼图游戏关卡画面要求画面中所有元素精致且风格高度一致画面中所有UI细节饱满精致、完成度高、顶级游戏品质\n\n画面元素:\n返回按钮位于顶部左上角顶部中间显示关卡标题“第1关 影”和倒计时时间,右上角显示设置按钮\n画面中间是一个正方形的3*3拼图拼图区域宽度与画面宽度同宽紧贴画面横向边缘拼图区域边界带有边框装饰\n拼图区域下方包含一个下一关按钮,仅在关卡完成时显示\n底部是三个贴合画面主题的道具按钮分别为“提示”、“原图”、“冻结”\n道具按钮上不要显示次数标注,返回按钮和设置按钮旁禁止标注文字";
const PUZZLE_UI_SPRITESHEET_IMAGE_PROMPT: &str = "提取画面中的UI元素将返回按钮、设置按钮、下一关按钮、提示按钮、原图按钮、冻结按钮整理成纯绿色绿幕背景的spritesheet。背景必须是统一纯绿色绿幕高饱和亮绿色接近 #00FF00背景平整无纹理、无渐变、无阴影、无场景内容后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。按钮顺序必须按原图位置从左到右、从上到下排列返回、设置、下一关、提示、原图、冻结。按钮素材内必须保留对应中文文字每个按钮必须是独立完整图形按钮之间保留足够纯绿色绿幕空白不能相互接触、重叠或连成一片方便运行态按自动边界检测识别矩形素材。返回按钮和设置按钮不要额外画白色外圈、白底圆环或浮雕外框直接画扁平图标本体。按钮自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。禁止水印、数字、次数标注、透明背景、背景图、拼图块、棋盘、网格线、按钮外标签和额外按钮。";
const PUZZLE_LEVEL_BACKGROUND_IMAGE_PROMPT: &str = "移除参考图中所有UI元素、移除拼图画面仅保留背景图补全被覆盖的背景图内容。禁止在背景中出现人像或和拼图画面中主体一致的内容";
mod handlers;
pub(crate) use self::handlers::*;

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")

View File

@@ -687,6 +687,7 @@ async fn generate_wooden_fish_image_assets(
hit_object_prompt.as_str(),
None,
"1:1",
1,
reference_images.as_slice(),
"生成敲木鱼敲击物图案失败",
)