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:
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user