Switch to VectorEngine gpt-image-2 and edits

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

View File

@@ -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!({