Files
Genarrative/server-rs/crates/api-server/src/match3d/item_assets.rs
高物 ae014ac881 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).
2026-05-22 03:06:41 +08:00

1727 lines
59 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
#[cfg(test)]
use crate::generated_asset_sheets::crop_generated_asset_sheet_view_edge_matte;
use crate::generated_asset_sheets::{
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
GeneratedAssetSheetSliceImage, persist_generated_asset_sheet_bytes,
slice_generated_asset_sheet_two_items_per_row,
};
pub(super) async fn generate_match3d_item_assets(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
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);
let mut assets = normalize_match3d_generated_item_assets_for_resume(existing_assets);
if has_match3d_required_generated_assets(&assets, target_item_count, config) {
return Ok(assets.into_iter().take(target_item_count).collect());
}
if !has_match3d_required_item_images(&assets, target_item_count) {
assets = ensure_match3d_item_image_assets(
state,
request_context,
authenticated,
owner_user_id,
session_id,
profile_id,
config,
item_plan,
assets,
generated_background_asset,
)
.await?;
}
assets = ensure_match3d_click_sound_assets(
state,
request_context,
authenticated,
owner_user_id,
session_id,
profile_id,
config,
assets,
)
.await?;
persist_match3d_generated_item_assets_snapshot(
state,
request_context,
authenticated,
session_id,
owner_user_id,
profile_id,
&assets,
)
.await?;
Ok(assets.into_iter().take(target_item_count).collect())
}
#[allow(clippy::too_many_arguments)]
async fn ensure_match3d_item_image_assets(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
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);
let item_plan = normalize_match3d_item_plan(config, item_plan);
let missing_items = item_plan
.iter()
.take(target_item_count)
.enumerate()
.filter_map(|(index, item)| {
let item_id = format!("match3d-item-{}", index + 1);
if assets.iter().any(|asset| {
asset.item_id == item_id && is_match3d_generated_asset_image_ready(asset)
}) {
return None;
}
Some(Match3DItemImageGenerationSeed {
item_id,
item_name: item.name.clone(),
item_size: item.item_size.clone(),
sound_prompt: item.sound_prompt.clone(),
persist_asset: true,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_asset: if index == 0 {
generated_background_asset.clone().or_else(|| {
assets
.first()
.and_then(|asset| asset.background_asset.clone())
})
} else {
None
},
})
})
.collect::<Vec<_>>();
let generated_assets = generate_match3d_item_image_assets_in_batches(
state,
request_context,
MATCH3D_AGENT_PROVIDER,
owner_user_id,
session_id,
profile_id,
config,
missing_items,
)
.await?;
for generated_asset in generated_assets
.into_iter()
.filter(|generated| generated.persist_asset)
.map(|generated| generated.asset)
{
upsert_match3d_generated_item_asset(&mut assets, generated_asset);
persist_match3d_generated_item_assets_snapshot(
state,
request_context,
authenticated,
session_id,
owner_user_id,
profile_id,
&assets,
)
.await?;
}
Ok(assets)
}
#[derive(Clone)]
struct Match3DItemImageGenerationSeed {
item_id: String,
item_name: String,
item_size: String,
sound_prompt: String,
persist_asset: bool,
background_music_title: Option<String>,
background_music_style: Option<String>,
background_music_prompt: Option<String>,
background_asset: Option<Match3DGeneratedBackgroundAsset>,
}
struct Match3DMaterialBatchOutput {
task_id: String,
prompt: String,
image_src: Option<String>,
image_object_key: Option<String>,
generated_at_micros: i64,
items: Vec<(
Match3DItemImageGenerationSeed,
Vec<GeneratedAssetSheetSliceImage>,
)>,
}
struct Match3DGeneratedItemImageAssetOutput {
asset: Match3DGeneratedItemAsset,
persist_asset: bool,
}
#[allow(clippy::too_many_arguments)]
async fn generate_match3d_item_image_assets_in_batches(
state: &AppState,
request_context: &RequestContext,
provider: &str,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
item_seeds: Vec<Match3DItemImageGenerationSeed>,
) -> Result<Vec<Match3DGeneratedItemImageAssetOutput>, Response> {
if item_seeds.is_empty() {
return Ok(Vec::new());
}
require_match3d_oss_client(state)
.map_err(|error| match3d_error_response(request_context, provider, error))?;
let mut batch_tasks = item_seeds
.chunks(MATCH3D_MATERIAL_ITEM_BATCH_SIZE)
.map(|chunk| {
let chunk_seeds = chunk.to_vec();
async move {
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()
.position(|seed| !seed.persist_asset)
.unwrap_or(chunk_seeds.len());
debug_assert!(
chunk_seeds[persisted_seed_count..]
.iter()
.all(|seed| !seed.persist_asset)
);
let persisted_seeds = chunk_seeds
.into_iter()
.take(persisted_seed_count)
.collect::<Vec<_>>();
let persisted_item_names = persisted_seeds
.iter()
.map(|item| item.item_name.clone())
.collect::<Vec<_>>();
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()
.zip(item_images.into_iter())
.collect::<Vec<_>>(),
})
}
})
.collect::<FuturesUnordered<_>>();
let mut batches = Vec::new();
while let Some(batch_result) = batch_tasks.next().await {
batches.push(
batch_result
.map_err(|error| match3d_error_response(request_context, provider, error))?,
);
}
let mut generated_assets = Vec::new();
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 (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 {
prefix: LegacyAssetPrefix::Match3DAssets,
owner_user_id: owner_user_id.to_string(),
session_id: session_id.to_string(),
profile_id: profile_id.to_string(),
path_segments: vec![
"items".to_string(),
item_slug.clone(),
"views".to_string(),
],
file_name: format!("view-{view_number:02}.png"),
content_type: "image/png".to_string(),
bytes: item_image.bytes,
asset_kind: "match3d_item_image_view".to_string(),
source_job_id: Some(sheet_task_id.clone()),
generated_at_micros: generated_at_micros.saturating_add(
(item_index * MATCH3D_ITEM_VIEW_COUNT + view_index) as i64 + 1,
),
grid_size: MATCH3D_MATERIAL_GRID_SIZE as usize,
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),
special_prompt: Some(match3d_material_sheet_special_prompt()),
},
},
)
.await
.map_err(|error| match3d_error_response(request_context, provider, error))?;
image_views.push(Match3DGeneratedItemImageView {
view_id: format!("view-{view_number:02}"),
view_index: view_number as u32,
image_src: Some(view_upload.src),
image_object_key: Some(view_upload.object_key),
});
}
let primary_view = image_views.first().cloned();
generated_assets.push(Match3DGeneratedItemImageAssetOutput {
persist_asset: seed.persist_asset,
asset: Match3DGeneratedItemAsset {
item_id: seed.item_id,
item_name: seed.item_name,
item_size: Some(normalize_match3d_item_size(seed.item_size.as_str()))
.filter(|value| !value.is_empty())
.or_else(|| Some(MATCH3D_ITEM_SIZE_LARGE.to_string())),
image_src: primary_view
.as_ref()
.and_then(|view| view.image_src.clone()),
image_object_key: primary_view
.as_ref()
.and_then(|view| view.image_object_key.clone()),
image_views,
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: Some(seed.sound_prompt),
background_music_title: seed.background_music_title,
background_music_style: seed.background_music_style,
background_music_prompt: seed.background_music_prompt,
background_music: None,
click_sound: None,
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,
},
});
}
}
generated_assets.sort_by(|left, right| {
match3d_item_sort_index(left.asset.item_id.as_str())
.cmp(&match3d_item_sort_index(right.asset.item_id.as_str()))
.then_with(|| left.asset.item_id.cmp(&right.asset.item_id))
});
Ok(generated_assets)
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn append_match3d_item_assets(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
generation_plan: Match3DItemAssetsGenerationPlan,
existing_assets: Vec<Match3DGeneratedItemAsset>,
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
match generation_plan {
Match3DItemAssetsGenerationPlan::Append(append_plan) => {
append_match3d_new_item_assets(
state,
request_context,
authenticated,
owner_user_id,
session_id,
profile_id,
config,
append_plan,
existing_assets,
)
.await
}
Match3DItemAssetsGenerationPlan::Replace(replace_plan) => {
replace_match3d_item_assets(
state,
request_context,
authenticated,
owner_user_id,
session_id,
profile_id,
config,
replace_plan,
existing_assets,
)
.await
}
}
}
#[allow(clippy::too_many_arguments)]
async fn ensure_match3d_click_sound_assets(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
assets: Vec<Match3DGeneratedItemAsset>,
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
if !config.generate_click_sound {
return Ok(assets);
}
let mut assets = normalize_match3d_generated_item_assets_for_resume(assets);
let seeds = assets
.iter()
.filter(|asset| is_match3d_generated_asset_image_ready(asset))
.filter(|asset| asset.click_sound.is_none())
.cloned()
.collect::<Vec<_>>();
if seeds.is_empty() {
return Ok(assets);
}
let mut sound_tasks = seeds
.into_iter()
.map(|asset| async move {
let prompt = asset
.sound_prompt
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
build_fallback_match3d_item_sound_prompt(config, asset.item_name.as_str())
});
let result = generate_match3d_click_sound_asset(
state,
owner_user_id,
profile_id,
asset.item_id.as_str(),
asset.item_name.as_str(),
prompt.as_str(),
)
.await;
(asset, prompt, result)
})
.collect::<FuturesUnordered<_>>();
while let Some((mut asset, prompt, result)) = sound_tasks.next().await {
match result {
Ok(click_sound) => {
asset.sound_prompt = Some(prompt);
asset.click_sound = Some(click_sound);
asset.error = None;
}
Err(error) => {
tracing::warn!(
provider = MATCH3D_AGENT_PROVIDER,
session_id,
profile_id,
item_id = asset.item_id.as_str(),
error = %error,
"抓大鹅入口内联点击音效生成失败,保留草稿并允许结果页重试"
);
}
}
upsert_match3d_generated_item_asset(&mut assets, asset);
persist_match3d_generated_item_assets_snapshot(
state,
request_context,
authenticated,
session_id,
owner_user_id,
profile_id,
&assets,
)
.await?;
}
Ok(assets)
}
async fn generate_match3d_click_sound_asset(
state: &AppState,
owner_user_id: &str,
profile_id: &str,
item_id: &str,
item_name: &str,
prompt: &str,
) -> Result<CreationAudioAsset, AppError> {
let mut asset = generate_sound_effect_asset_for_creation(
state,
owner_user_id,
prompt.to_string(),
Some(3),
None,
GeneratedCreationAudioTarget {
entity_kind: "match3d_item".to_string(),
entity_id: item_id.to_string(),
slot: "click_sound".to_string(),
asset_kind: MATCH3D_CLICK_SOUND_ASSET_KIND.to_string(),
profile_id: Some(profile_id.to_string()),
storage_prefix: LegacyAssetPrefix::Match3DAssets,
},
)
.await?;
asset.title = Some(format!("{item_name}点击音效"));
Ok(asset)
}
#[allow(clippy::too_many_arguments)]
async fn append_match3d_new_item_assets(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
append_plan: Match3DItemAssetAppendPlan,
existing_assets: Vec<Match3DGeneratedItemAsset>,
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
let mut assets = sort_match3d_generated_assets(existing_assets);
let existing_item_count = assets.len();
let requested_item_count = append_plan.requested_item_names.len();
if requested_item_count == 0 {
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()
.enumerate()
.map(|(index, item_name)| {
let item_id = allocate_match3d_generated_item_id(&assets, &mut next_item_index);
Match3DItemImageGenerationSeed {
item_id,
item_size: infer_match3d_item_size(item_name.as_str()),
sound_prompt: build_fallback_match3d_item_sound_prompt(config, item_name.as_str()),
item_name,
persist_asset: index < requested_item_count,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_asset: if index == 0 {
background_asset.clone()
} else {
None
},
}
})
.collect::<Vec<_>>();
let generated_assets = generate_match3d_item_image_assets_in_batches(
state,
request_context,
MATCH3D_WORKS_PROVIDER,
owner_user_id,
session_id,
profile_id,
config,
item_seeds,
)
.await?;
for generated_asset in generated_assets
.into_iter()
.filter(|generated| generated.persist_asset)
.map(|generated| generated.asset)
{
upsert_match3d_generated_item_asset(&mut assets, generated_asset);
persist_match3d_generated_item_assets_snapshot(
state,
request_context,
authenticated,
session_id,
owner_user_id,
profile_id,
&assets,
)
.await?;
}
ensure_match3d_click_sound_assets(
state,
request_context,
authenticated,
owner_user_id,
session_id,
profile_id,
config,
assets,
)
.await
.map(|assets| {
sort_match3d_generated_assets(assets)
.into_iter()
.take(existing_item_count + requested_item_count)
.collect()
})
}
#[allow(clippy::too_many_arguments)]
async fn replace_match3d_item_assets(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
replace_plan: Match3DItemAssetReplacePlan,
existing_assets: Vec<Match3DGeneratedItemAsset>,
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
let mut assets = sort_match3d_generated_assets(existing_assets);
if replace_plan.target_assets.is_empty() {
return Ok(assets);
}
let target_by_name = replace_plan
.target_assets
.iter()
.map(|asset| (asset.item_name.trim().to_string(), asset.clone()))
.collect::<std::collections::HashMap<_, _>>();
let mut next_item_index = next_match3d_generated_item_index(&assets);
let requested_item_count = replace_plan.requested_item_names.len();
let item_seeds = replace_plan
.padded_item_names
.into_iter()
.enumerate()
.map(|(index, item_name)| {
let matched_asset = target_by_name.get(item_name.trim()).cloned();
let item_id = matched_asset
.as_ref()
.map(|asset| asset.item_id.clone())
.unwrap_or_else(|| {
allocate_match3d_generated_item_id(&assets, &mut next_item_index)
});
Match3DItemImageGenerationSeed {
item_id,
item_size: matched_asset
.as_ref()
.and_then(|asset| asset.item_size.clone())
.map(|value| normalize_match3d_item_size(value.as_str()))
.filter(|value| !value.is_empty())
.unwrap_or_else(|| infer_match3d_item_size(item_name.as_str())),
sound_prompt: matched_asset
.as_ref()
.and_then(|asset| asset.sound_prompt.clone())
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
build_fallback_match3d_item_sound_prompt(config, item_name.as_str())
}),
item_name,
persist_asset: index < requested_item_count,
background_music_title: matched_asset
.as_ref()
.and_then(|asset| asset.background_music_title.clone()),
background_music_style: matched_asset
.as_ref()
.and_then(|asset| asset.background_music_style.clone()),
background_music_prompt: matched_asset
.as_ref()
.and_then(|asset| asset.background_music_prompt.clone()),
background_asset: matched_asset
.as_ref()
.and_then(|asset| asset.background_asset.clone()),
}
})
.collect::<Vec<_>>();
let generated_assets = generate_match3d_item_image_assets_in_batches(
state,
request_context,
MATCH3D_WORKS_PROVIDER,
owner_user_id,
session_id,
profile_id,
config,
item_seeds,
)
.await?;
for generated_asset in generated_assets
.into_iter()
.filter(|generated| generated.persist_asset)
.map(|generated| generated.asset)
{
let current_asset = assets
.iter()
.find(|candidate| candidate.item_id == generated_asset.item_id)
.cloned();
upsert_match3d_generated_item_asset(
&mut assets,
merge_regenerated_match3d_item_asset(current_asset, generated_asset),
);
persist_match3d_generated_item_assets_snapshot(
state,
request_context,
authenticated,
session_id,
owner_user_id,
profile_id,
&assets,
)
.await?;
}
ensure_match3d_click_sound_assets(
state,
request_context,
authenticated,
owner_user_id,
session_id,
profile_id,
config,
assets,
)
.await
.map(sort_match3d_generated_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,
}
pub(super) struct Match3DVectorEngineGeminiImageSettings {
pub(super) base_url: String,
pub(super) api_key: String,
pub(super) request_timeout_ms: u64,
}
#[cfg(test)]
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(['"', '\'', '“', '”', '。', '', ',', '、'])
.chars()
.filter(|character| !character.is_control())
.take(12)
.collect::<String>()
.trim()
.to_string()
}
pub(super) fn normalize_match3d_item_size(raw: &str) -> String {
let normalized = raw
.trim()
.trim_matches(['"', '\'', '“', '”', '。', '', ',', '、']);
match normalized {
"" | "大型" | "偏大" | "large" | "Large" | "L" | "l" => {
MATCH3D_ITEM_SIZE_LARGE.to_string()
}
"" | "中型" | "中等" | "medium" | "Medium" | "M" | "m" => {
MATCH3D_ITEM_SIZE_MEDIUM.to_string()
}
"" | "小型" | "偏小" | "small" | "Small" | "S" | "s" => {
MATCH3D_ITEM_SIZE_SMALL.to_string()
}
_ => String::new(),
}
}
pub(super) fn infer_match3d_item_size(item_name: &str) -> String {
let name = item_name.trim();
let large_keywords = [
"西瓜", "南瓜", "椰子", "", "", "", "", "", "", "瓶子", "大瓶", "", "书包",
"", "抱枕", "玩偶", "", "圆球", "足球", "篮球", "",
];
if large_keywords.iter().any(|keyword| name.contains(keyword)) {
return MATCH3D_ITEM_SIZE_LARGE.to_string();
}
let small_keywords = [
"草莓", "蓝莓", "葡萄", "樱桃", "", "", "糖果", "钥匙", "硬币", "纽扣", "徽章", "戒指",
"耳环", "铃铛", "星星", "宝石", "叶片", "花瓣", "蘑菇", "贝壳", "印章", "彩蛋", "棋子",
"骰子", "挂件",
];
if small_keywords.iter().any(|keyword| name.contains(keyword)) {
return MATCH3D_ITEM_SIZE_SMALL.to_string();
}
MATCH3D_ITEM_SIZE_MEDIUM.to_string()
}
pub(super) fn fallback_match3d_item_names(theme_text: &str) -> Vec<String> {
let theme = theme_text.trim();
let normalized_theme = if theme.is_empty() { "主题" } else { theme };
[
"小物件",
"徽章",
"摆件",
"挂件",
"圆球",
"方块",
"钥匙",
"杯子",
"糖果",
"星星",
"宝石",
"铃铛",
"叶片",
"蘑菇",
"花朵",
"果冻",
"小瓶",
"帽子",
"贝壳",
"纽扣",
"积木",
"印章",
"彩蛋",
"小鼓",
"风车",
]
.into_iter()
.map(|suffix| format!("{normalized_theme}{suffix}"))
.take(MATCH3D_MAX_GENERATED_ITEM_COUNT)
.collect()
}
pub(super) fn normalize_match3d_item_plan(
config: &Match3DConfigJson,
items: Vec<Match3DGeneratedItemPlan>,
) -> Vec<Match3DGeneratedItemPlan> {
let target_item_count = resolve_match3d_generated_item_count(config);
let mut normalized = Vec::new();
for item in items {
let name = normalize_match3d_item_name(item.name.as_str());
if name.is_empty()
|| normalized
.iter()
.any(|candidate: &Match3DGeneratedItemPlan| candidate.name == name)
{
continue;
}
let sound_prompt = normalize_match3d_audio_prompt(item.sound_prompt.as_str());
let item_size = normalize_match3d_item_size(item.item_size.as_str());
normalized.push(Match3DGeneratedItemPlan {
item_size: if item_size.is_empty() {
infer_match3d_item_size(&name)
} else {
item_size
},
sound_prompt: if sound_prompt.is_empty() {
build_fallback_match3d_item_sound_prompt(config, &name)
} else {
sound_prompt
},
name,
});
if normalized.len() >= target_item_count {
break;
}
}
if normalized.len() < target_item_count {
for name in fallback_match3d_item_names(config.theme_text.as_str()) {
if normalized.iter().any(|candidate| candidate.name == name) {
continue;
}
normalized.push(Match3DGeneratedItemPlan {
item_size: infer_match3d_item_size(&name),
sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name),
name,
});
if normalized.len() >= target_item_count {
break;
}
}
}
if normalized.len() < target_item_count {
fill_match3d_item_plan_to_count(config, &mut normalized, target_item_count);
}
normalized
}
fn fill_match3d_item_plan_to_count(
config: &Match3DConfigJson,
normalized: &mut Vec<Match3DGeneratedItemPlan>,
target_item_count: usize,
) {
let normalized_theme = config.theme_text.trim();
let fallback_prefix = if normalized_theme.is_empty() {
"补充物品".to_string()
} else {
format!("{normalized_theme}补充")
};
let mut index = 1usize;
while normalized.len() < target_item_count {
let name = normalize_match3d_item_name(format!("{fallback_prefix}{index}").as_str());
if !name.is_empty()
&& !normalized
.iter()
.any(|candidate: &Match3DGeneratedItemPlan| candidate.name == name)
{
normalized.push(Match3DGeneratedItemPlan {
item_size: infer_match3d_item_size(&name),
sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name),
name,
});
}
index += 1;
}
}
pub(super) fn normalize_match3d_batch_item_names(items: Vec<String>) -> Vec<String> {
let mut normalized: Vec<String> = Vec::new();
for item in items {
let name = normalize_match3d_item_name(item.as_str());
if name.is_empty() || normalized.iter().any(|candidate| candidate == &name) {
continue;
}
normalized.push(name);
if normalized.len() >= MATCH3D_MAX_GENERATED_ITEM_COUNT {
break;
}
}
normalized
}
pub(super) fn normalize_match3d_item_assets_generation_mode(
mode: Option<&str>,
) -> Match3DItemAssetsGenerationMode {
match mode
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"replace" | "regenerate" => Match3DItemAssetsGenerationMode::Replace,
_ => Match3DItemAssetsGenerationMode::Append,
}
}
pub(super) fn build_match3d_item_assets_generation_plan(
mode: Match3DItemAssetsGenerationMode,
item_names: Vec<String>,
existing_assets: &[Match3DGeneratedItemAsset],
) -> Match3DItemAssetsGenerationPlan {
match mode {
Match3DItemAssetsGenerationMode::Append => Match3DItemAssetsGenerationPlan::Append(
build_match3d_item_asset_append_plan(item_names, existing_assets),
),
Match3DItemAssetsGenerationMode::Replace => Match3DItemAssetsGenerationPlan::Replace(
build_match3d_item_asset_replace_plan(item_names, existing_assets),
),
}
}
pub(super) fn build_match3d_item_asset_append_plan(
item_names: Vec<String>,
existing_assets: &[Match3DGeneratedItemAsset],
) -> Match3DItemAssetAppendPlan {
let available_capacity = MATCH3D_MAX_GENERATED_ITEM_COUNT.saturating_sub(existing_assets.len());
let mut requested_item_names = item_names
.into_iter()
.filter(|name| {
!existing_assets
.iter()
.any(|asset| asset.item_name.trim() == name.trim())
})
.take(available_capacity)
.collect::<Vec<_>>();
requested_item_names.truncate(available_capacity);
let padded_item_names = build_match3d_padded_item_names_for_generation(
&requested_item_names,
existing_assets,
available_capacity,
);
Match3DItemAssetAppendPlan {
requested_item_names,
padded_item_names,
}
}
fn build_match3d_padded_item_names_for_generation(
item_names: &[String],
existing_assets: &[Match3DGeneratedItemAsset],
available_capacity: usize,
) -> Vec<String> {
let mut padded = item_names
.iter()
.take(available_capacity)
.cloned()
.collect::<Vec<_>>();
let target_item_count = round_match3d_item_count_to_full_sheet(padded.len());
let mut fallback_index = 1usize;
while padded.len() < target_item_count {
let candidate = normalize_match3d_item_name(format!("追加物品{fallback_index}").as_str());
fallback_index += 1;
if candidate.is_empty()
|| padded.iter().any(|name| name == &candidate)
|| existing_assets
.iter()
.any(|asset| asset.item_name.trim() == candidate.as_str())
{
continue;
}
padded.push(candidate);
}
padded
}
pub(super) fn build_match3d_item_asset_replace_plan(
item_names: Vec<String>,
existing_assets: &[Match3DGeneratedItemAsset],
) -> Match3DItemAssetReplacePlan {
let mut requested_item_names = Vec::new();
let mut target_assets = Vec::new();
for item_name in item_names {
let Some(asset) = existing_assets
.iter()
.find(|asset| asset.item_name.trim() == item_name.trim())
else {
continue;
};
if target_assets
.iter()
.any(|candidate: &Match3DGeneratedItemAsset| candidate.item_id == asset.item_id)
{
continue;
}
requested_item_names.push(asset.item_name.clone());
target_assets.push(asset.clone());
if requested_item_names.len() >= MATCH3D_MAX_GENERATED_ITEM_COUNT {
break;
}
}
let padded_item_names = build_match3d_padded_item_names_for_generation(
&requested_item_names,
existing_assets,
MATCH3D_MAX_GENERATED_ITEM_COUNT,
);
Match3DItemAssetReplacePlan {
requested_item_names,
padded_item_names,
target_assets,
}
}
pub(super) fn calculate_match3d_item_assets_points_cost(item_count: usize) -> u64 {
if item_count == 0 {
return 0;
}
item_count.div_ceil(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) as u64
* MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH
}
pub(super) fn normalize_match3d_cover_prompt(raw: &str) -> String {
raw.trim()
.chars()
.filter(|character| !character.is_control())
.take(900)
.collect::<String>()
.trim()
.to_string()
}
pub(super) fn normalize_match3d_audio_prompt(raw: &str) -> String {
raw.trim()
.chars()
.filter(|character| !character.is_control())
.take(500)
.collect::<String>()
.trim()
.to_string()
}
pub(super) fn normalize_match3d_background_prompt(raw: &str) -> String {
raw.trim()
.chars()
.filter(|character| !character.is_control())
.take(900)
.collect::<String>()
.trim()
.to_string()
}
pub(super) fn build_match3d_prompt_fingerprint(value: &str) -> String {
let mut hash = 0u32;
for character in value.chars() {
hash = hash.wrapping_mul(31).wrapping_add(character as u32);
}
format!("{hash:08x}")
}
pub(super) fn build_fallback_match3d_background_prompt(config: &Match3DConfigJson) -> String {
let theme = config.theme_text.trim();
let normalized_theme = if theme.is_empty() { "抓大鹅" } else { theme };
normalize_match3d_background_prompt(
format!(
"{normalized_theme}题材抓大鹅游戏竖屏纯背景图,表现题材环境、绿色纵向渐变和轻快休闲氛围,中央区域保持干净通透,方便运行态叠加默认交互容器。无锅、无圆盘、无托盘、无拼图槽、无物品槽、无文字、无水印、无 UI、无按钮、无倒计时、无物品、无角色、无手。"
)
.as_str(),
)
}
pub(super) fn build_fallback_match3d_item_sound_prompt(
config: &Match3DConfigJson,
item_name: &str,
) -> String {
let theme = config.theme_text.trim();
let normalized_theme = if theme.is_empty() { "抓大鹅" } else { theme };
normalize_match3d_audio_prompt(
format!(
"{normalized_theme}题材抓大鹅中“{item_name}”被点击并消除时的短促反馈音效,清脆、可爱、有轻微弹跳感,适合移动端休闲游戏。"
)
.as_str(),
)
}
pub(super) fn normalize_match3d_generated_item_assets_for_resume(
assets: Vec<Match3DGeneratedItemAsset>,
) -> Vec<Match3DGeneratedItemAsset> {
let mut normalized = Vec::new();
for asset in sort_match3d_generated_assets(assets) {
if asset.item_id.trim().is_empty()
|| normalized
.iter()
.any(|candidate: &Match3DGeneratedItemAsset| candidate.item_id == asset.item_id)
{
continue;
}
normalized.push(asset);
if normalized.len() >= MATCH3D_MAX_GENERATED_ITEM_COUNT {
break;
}
}
normalized
}
pub(super) fn resolve_match3d_gameplay_item_count(config: &Match3DConfigJson) -> usize {
match config.clear_count {
8 => 3,
12 => 9,
16 => 15,
20 | 21 => 20,
_ => match config.difficulty {
0..=2 => 3,
3..=4 => 9,
5..=6 => 15,
_ => 20,
},
}
.min(MATCH3D_MAX_GENERATED_ITEM_COUNT)
}
pub(super) fn resolve_match3d_generated_item_count(config: &Match3DConfigJson) -> usize {
let _ = config;
MATCH3D_MAX_GENERATED_ITEM_COUNT
}
fn round_match3d_item_count_to_full_sheet(item_count: usize) -> usize {
if item_count == 0 {
return 0;
}
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> {
assets.sort_by(|left, right| {
match3d_item_sort_index(left.item_id.as_str())
.cmp(&match3d_item_sort_index(right.item_id.as_str()))
.then_with(|| left.item_id.cmp(&right.item_id))
});
assets
}
pub(super) fn match3d_item_sort_index(item_id: &str) -> u32 {
item_id
.rsplit('-')
.next()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(u32::MAX)
}
fn is_match3d_generated_asset_image_ready(asset: &Match3DGeneratedItemAsset) -> bool {
let view_count = asset
.image_views
.iter()
.filter(|view| {
view.image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
|| view
.image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
})
.count();
view_count >= MATCH3D_ITEM_VIEW_COUNT
}
pub(super) fn has_match3d_required_item_images(
assets: &[Match3DGeneratedItemAsset],
required_item_count: usize,
) -> bool {
assets.len() >= required_item_count
&& assets
.iter()
.take(required_item_count)
.all(is_match3d_generated_asset_image_ready)
}
pub(super) fn has_match3d_required_generated_assets(
assets: &[Match3DGeneratedItemAsset],
required_item_count: usize,
config: &Match3DConfigJson,
) -> bool {
has_match3d_required_item_images(assets, required_item_count)
&& (!config.generate_click_sound
|| assets
.iter()
.take(required_item_count)
.all(|asset| asset.click_sound.is_some()))
}
pub(super) fn upsert_match3d_generated_item_asset(
assets: &mut Vec<Match3DGeneratedItemAsset>,
asset: Match3DGeneratedItemAsset,
) {
if let Some(current) = assets
.iter_mut()
.find(|candidate| candidate.item_id == asset.item_id)
{
*current = asset;
*assets = sort_match3d_generated_assets(std::mem::take(assets));
return;
}
assets.push(asset);
*assets = sort_match3d_generated_assets(std::mem::take(assets));
}
pub(super) fn merge_regenerated_match3d_item_asset(
current_asset: Option<Match3DGeneratedItemAsset>,
generated_asset: Match3DGeneratedItemAsset,
) -> Match3DGeneratedItemAsset {
let Some(current_asset) = current_asset else {
return generated_asset;
};
Match3DGeneratedItemAsset {
item_id: current_asset.item_id,
item_name: current_asset.item_name,
item_size: current_asset
.item_size
.or(generated_asset.item_size)
.or_else(|| Some(MATCH3D_ITEM_SIZE_LARGE.to_string())),
image_src: generated_asset.image_src,
image_object_key: generated_asset.image_object_key,
image_views: generated_asset.image_views,
model_src: current_asset.model_src,
model_object_key: current_asset.model_object_key,
model_file_name: current_asset.model_file_name,
task_uuid: generated_asset.task_uuid.or(current_asset.task_uuid),
subscription_key: generated_asset
.subscription_key
.or(current_asset.subscription_key),
sound_prompt: generated_asset.sound_prompt.or(current_asset.sound_prompt),
background_music_title: current_asset.background_music_title,
background_music_style: current_asset.background_music_style,
background_music_prompt: current_asset.background_music_prompt,
background_music: current_asset.background_music,
click_sound: current_asset.click_sound,
background_asset: current_asset.background_asset,
status: generated_asset.status,
error: generated_asset.error,
}
}
fn next_match3d_generated_item_index(assets: &[Match3DGeneratedItemAsset]) -> u32 {
assets
.iter()
.filter_map(|asset| {
let value = match3d_item_sort_index(asset.item_id.as_str());
if value == u32::MAX { None } else { Some(value) }
})
.max()
.unwrap_or(0)
.saturating_add(1)
}
fn allocate_match3d_generated_item_id(
assets: &[Match3DGeneratedItemAsset],
next_item_index: &mut u32,
) -> String {
loop {
let candidate = format!("match3d-item-{}", *next_item_index);
*next_item_index = next_item_index.saturating_add(1);
if !assets.iter().any(|asset| asset.item_id == candidate) {
return candidate;
}
}
}
pub(super) fn is_match3d_background_asset_ready(asset: &Match3DGeneratedBackgroundAsset) -> bool {
asset.status == "image_ready"
&& (asset
.image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
|| asset
.image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some())
&& (asset
.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()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some())
}
pub(super) fn build_match3d_material_sheet_prompt(
config: &Match3DConfigJson,
item_names: &[String],
) -> String {
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()
}
pub(super) fn build_match3d_material_sheet_negative_prompt(config: &Match3DConfigJson) -> String {
let base = "文字、水印、UI、边框、网格线、标签、人物手部、复杂背景、非绿幕背景、白色背景、灰色背景、渐变背景、纹理背景";
if !is_match3d_pixel_retro_style(config) {
return base.to_string();
}
format!(
"{base}、抗锯齿、平滑插画、柔焦、软边渐变、矢量扁平插画、真实 3D 渲染、PBR 材质、摄影棚光照"
)
}
pub(super) fn resolve_match3d_asset_style_prompt(config: &Match3DConfigJson) -> Option<String> {
let prompt = config
.asset_style_prompt
.as_deref()
.or(config.asset_style_label.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
if !is_match3d_pixel_retro_style(config) {
return prompt;
}
Some(match prompt {
Some(prompt) if prompt.contains("禁止抗锯齿") && prompt.contains("64x64") => prompt,
Some(prompt) => format!("{prompt}{MATCH3D_PIXEL_RETRO_STYLE_PROMPT}"),
None => MATCH3D_PIXEL_RETRO_STYLE_PROMPT.to_string(),
})
}
fn is_match3d_pixel_retro_style(config: &Match3DConfigJson) -> bool {
config
.asset_style_id
.as_deref()
.map(str::trim)
.is_some_and(|value| value.eq_ignore_ascii_case("pixel-retro"))
|| config
.asset_style_label
.as_deref()
.map(str::trim)
.is_some_and(|value| value.contains("像素复古"))
}
#[cfg(test)]
pub(super) fn slice_match3d_material_sheet(
image: &DownloadedOpenAiImage,
item_names: &[String],
) -> Result<Vec<Vec<Match3DSlicedItemImage>>, AppError> {
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)]
pub(super) fn crop_match3d_material_view_edge_matte(
image: image::DynamicImage,
) -> image::DynamicImage {
crop_generated_asset_sheet_view_edge_matte(image)
}
fn compute_match3d_material_white_screen_score(pixel: [u8; 4]) -> f32 {
if pixel[3] == 0 {
return 1.0;
}
let red = pixel[0] as f32;
let green = pixel[1] as f32;
let blue = pixel[2] as f32;
let max_channel = red.max(green).max(blue);
let min_channel = red.min(green).min(blue);
let average = (red + green + blue) / 3.0;
if average < 188.0 || min_channel < 168.0 {
return 0.0;
}
let spread = max_channel - min_channel;
let neutrality = 1.0 - ((spread - 6.0) / 34.0).clamp(0.0, 1.0);
let brightness = ((average - 188.0) / 55.0).clamp(0.0, 1.0);
let floor = ((min_channel - 168.0) / 60.0).clamp(0.0, 1.0);
(neutrality * (brightness * 0.85 + floor * 0.15)).clamp(0.0, 1.0)
}
pub(super) fn remove_match3d_container_plain_background(
pixels: &mut [u8],
width: usize,
height: usize,
) -> bool {
let pixel_count = width.saturating_mul(height);
if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) {
return false;
}
let mut background_mask = vec![0u8; pixel_count];
let mut queue = Vec::<usize>::new();
let mut queue_index = 0usize;
let seed_pixel = |pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec<usize>| {
if background_mask[pixel_index] != 0 {
return;
}
let offset = pixel_index * 4;
if is_match3d_container_background_pixel([
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
]) {
background_mask[pixel_index] = 1;
queue.push(pixel_index);
}
};
for x in 0..width {
seed_pixel(x, &mut background_mask, &mut queue);
seed_pixel((height - 1) * width + x, &mut background_mask, &mut queue);
}
for y in 1..height.saturating_sub(1) {
seed_pixel(y * width, &mut background_mask, &mut queue);
seed_pixel(y * width + width - 1, &mut background_mask, &mut queue);
}
while queue_index < queue.len() {
let pixel_index = queue[queue_index];
queue_index += 1;
let x = pixel_index % width;
let y = pixel_index / width;
let neighbors = [
(x > 0).then(|| pixel_index - 1),
(x + 1 < width).then_some(pixel_index + 1),
(y > 0).then(|| pixel_index - width),
(y + 1 < height).then_some(pixel_index + width),
];
for next_pixel_index in neighbors.into_iter().flatten() {
if background_mask[next_pixel_index] != 0 {
continue;
}
let offset = next_pixel_index * 4;
if is_match3d_container_background_pixel([
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
]) {
background_mask[next_pixel_index] = 1;
queue.push(next_pixel_index);
}
}
}
// 中文注释:图生图偶尔会在容器边缘留下白底抗锯齿,扩一层只清理连到背景的浅色边。
for _ in 0..2 {
let mut expanded_mask = background_mask.clone();
for y in 0..height {
for x in 0..width {
let pixel_index = y * width + x;
if background_mask[pixel_index] != 0 {
continue;
}
let offset = pixel_index * 4;
let pixel = [
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
];
if !is_match3d_container_soft_background_pixel(pixel) {
continue;
}
let mut adjacent_background_count = 0usize;
for offset_y in -1i32..=1 {
for offset_x in -1i32..=1 {
if offset_x == 0 && offset_y == 0 {
continue;
}
let next_x = x as i32 + offset_x;
let next_y = y as i32 + offset_y;
if next_x < 0
|| next_x >= width as i32
|| next_y < 0
|| next_y >= height as i32
{
adjacent_background_count += 1;
continue;
}
if background_mask[next_y as usize * width + next_x as usize] != 0 {
adjacent_background_count += 1;
}
}
}
if adjacent_background_count >= 3 {
expanded_mask[pixel_index] = 1;
}
}
}
background_mask = expanded_mask;
}
let mut changed = false;
for pixel_index in 0..pixel_count {
if background_mask[pixel_index] == 0 {
continue;
}
let offset = pixel_index * 4;
if pixels[offset + 3] != 0 {
pixels[offset + 3] = 0;
changed = true;
}
}
changed
}
fn is_match3d_container_background_pixel(pixel: [u8; 4]) -> bool {
pixel[3] < 16 || compute_match3d_material_white_screen_score(pixel) > 0.34
}
fn is_match3d_container_soft_background_pixel(pixel: [u8; 4]) -> bool {
pixel[3] < 80 || compute_match3d_material_white_screen_score(pixel) > 0.18
}