2632 lines
90 KiB
Rust
2632 lines
90 KiB
Rust
use super::*;
|
||
|
||
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>,
|
||
) -> 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,
|
||
)
|
||
.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>,
|
||
) -> 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 {
|
||
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,
|
||
generated_at_micros: i64,
|
||
items: Vec<(Match3DItemImageGenerationSeed, Vec<Match3DSlicedItemImage>)>,
|
||
}
|
||
|
||
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 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 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_match3d_material_sheet(&material_sheet.image, &persisted_item_names)?;
|
||
Ok::<_, AppError>(Match3DMaterialBatchOutput {
|
||
task_id: material_sheet.task_id,
|
||
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 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 view_upload = persist_match3d_generated_bytes(
|
||
state,
|
||
owner_user_id,
|
||
session_id,
|
||
profile_id,
|
||
&["items", item_slug.as_str(), "views"],
|
||
format!("view-{view_number:02}.png").as_str(),
|
||
"image/png",
|
||
item_image.bytes,
|
||
"match3d_item_image_view",
|
||
Some(sheet_task_id.as_str()),
|
||
generated_at_micros.saturating_add(
|
||
(item_index * MATCH3D_ITEM_VIEW_COUNT + view_index) as i64 + 1,
|
||
),
|
||
)
|
||
.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: seed.background_asset,
|
||
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 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: 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) image: DownloadedOpenAiImage,
|
||
}
|
||
|
||
pub(super) struct Match3DVectorEngineGeminiImageSettings {
|
||
pub(super) base_url: String,
|
||
pub(super) api_key: String,
|
||
pub(super) request_timeout_ms: u64,
|
||
}
|
||
|
||
pub(super) struct Match3DSlicedItemImage {
|
||
pub(super) bytes: Vec<u8>,
|
||
}
|
||
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 => 21,
|
||
_ => match config.difficulty {
|
||
0..=2 => 3,
|
||
3..=4 => 9,
|
||
5..=6 => 15,
|
||
_ => 21,
|
||
},
|
||
}
|
||
.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)
|
||
}
|
||
|
||
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 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
|
||
.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 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 item_rows = item_names
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(index, name)| format!("第{}行:{name} 的 5 个不同视角", index + 1))
|
||
.collect::<Vec<_>>()
|
||
.join(";");
|
||
format!(
|
||
"生成一张1024x1024的1:1图片。固定生成5行*5列网格素材图,画面是{theme}题材的抓大鹅游戏2D物品素材。{style_clause}严格5*5均匀排布,严格按行组织:{item_rows}。同一行五格必须是同一物品的五个不同视角,依次为正面、左前、右前、俯视、背面;每个格子一个独立居中的完整物体,每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。物体本身不得使用与绿幕相同的纯绿色;若物品天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。统一柔和光照,清晰轮廓,适合直接切割成游戏2D图标。请让每个物体完整落在自己的格子中央,四周保留留白,相邻物体主体之间必须至少保留单个素材格宽度的1/4空白间距(约25%单格宽度),包含左右相邻格和上下相邻行,物体主体不得占满格子。禁止主体跨格、贴边或越界,禁止任何内容进入相邻格子影响裁剪后的效果。不要出现文字、水印、UI、边框、网格线、标签、底座、场景或其他物体。",
|
||
theme = config.theme_text,
|
||
style_clause = style_clause,
|
||
item_rows = item_rows,
|
||
)
|
||
}
|
||
|
||
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("像素复古"))
|
||
}
|
||
|
||
pub(super) fn slice_match3d_material_sheet(
|
||
image: &DownloadedOpenAiImage,
|
||
item_names: &[String],
|
||
) -> Result<Vec<Vec<Match3DSlicedItemImage>>, AppError> {
|
||
// 中文注释:素材图提示词固定要求 5*5 均匀排布;切图也固定按 5 行 5 列定位格子。
|
||
// 每个格子内再基于前景像素二次校准,避免固定内缩裁断物品边缘。
|
||
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!("抓大鹅素材图解码失败:{error}"),
|
||
}))
|
||
})?;
|
||
// 中文注释:素材图按绿幕背景生成;先把整张 sheet 的绿幕转成 alpha,再进入格子裁切。
|
||
let source = apply_match3d_material_green_screen_alpha(source);
|
||
let (width, height) = source.dimensions();
|
||
let row_count = MATCH3D_MATERIAL_GRID_SIZE;
|
||
let cell_width = width / MATCH3D_MATERIAL_GRID_SIZE;
|
||
let cell_height = height / row_count;
|
||
if cell_width == 0 || cell_height == 0 {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "match3d-assets",
|
||
"message": "抓大鹅素材图尺寸过小,无法切割",
|
||
})),
|
||
);
|
||
}
|
||
|
||
let mut slices = Vec::with_capacity(item_names.len());
|
||
for item_index in 0..item_names.len().min(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) {
|
||
let row = item_index as u32;
|
||
let mut views = Vec::with_capacity(MATCH3D_ITEM_VIEW_COUNT);
|
||
for view_index in 0..MATCH3D_ITEM_VIEW_COUNT {
|
||
let col = view_index as u32;
|
||
let (crop_x, crop_y, crop_width, crop_height) =
|
||
resolve_match3d_material_cell_crop(&source, row_count, row, col);
|
||
let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height);
|
||
let cleaned = crop_match3d_material_view_edge_matte(cropped);
|
||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||
cleaned
|
||
.write_to(&mut cursor, ImageFormat::Png)
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "match3d-assets",
|
||
"message": format!("抓大鹅素材图切割失败:{error}"),
|
||
}))
|
||
})?;
|
||
views.push(Match3DSlicedItemImage {
|
||
bytes: cursor.into_inner(),
|
||
});
|
||
}
|
||
slices.push(views);
|
||
}
|
||
|
||
Ok(slices)
|
||
}
|
||
|
||
fn resolve_match3d_material_cell_crop(
|
||
source: &image::DynamicImage,
|
||
row_count: u32,
|
||
row: u32,
|
||
col: u32,
|
||
) -> (u32, u32, u32, u32) {
|
||
let (image_width, image_height) = source.dimensions();
|
||
let cell = resolve_match3d_material_cell_bounds(image_width, image_height, row_count, row, col);
|
||
let Some(foreground) = detect_match3d_material_foreground_bounds(source, cell) else {
|
||
return cell.to_crop_tuple();
|
||
};
|
||
|
||
let cell_width = cell.width();
|
||
let cell_height = cell.height();
|
||
let pad_x = (cell_width / 16).clamp(4, 16);
|
||
let pad_y = (cell_height / 16).clamp(4, 16);
|
||
let crop = Match3DMaterialCellBounds {
|
||
x0: foreground.x0.saturating_sub(pad_x).max(cell.x0),
|
||
y0: foreground.y0.saturating_sub(pad_y).max(cell.y0),
|
||
x1: foreground.x1.saturating_add(pad_x).min(cell.x1),
|
||
y1: foreground.y1.saturating_add(pad_y).min(cell.y1),
|
||
};
|
||
|
||
crop.to_crop_tuple()
|
||
}
|
||
|
||
pub(super) fn crop_match3d_material_view_edge_matte(image: image::DynamicImage) -> image::DynamicImage {
|
||
let mut image = image.to_rgba8();
|
||
let (width, height) = image.dimensions();
|
||
remove_match3d_material_view_edge_matte(image.as_mut(), width as usize, height as usize);
|
||
let bounds = detect_match3d_material_visible_bounds(&image).unwrap_or_else(|| {
|
||
Match3DMaterialCellBounds {
|
||
x0: 0,
|
||
y0: 0,
|
||
x1: width,
|
||
y1: height,
|
||
}
|
||
});
|
||
if bounds.x0 == 0 && bounds.y0 == 0 && bounds.x1 == width && bounds.y1 == height {
|
||
return image::DynamicImage::ImageRgba8(image);
|
||
}
|
||
|
||
image::DynamicImage::ImageRgba8(
|
||
image::imageops::crop_imm(
|
||
&image,
|
||
bounds.x0,
|
||
bounds.y0,
|
||
bounds.width(),
|
||
bounds.height(),
|
||
)
|
||
.to_image(),
|
||
)
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
struct Match3DMaterialCellBounds {
|
||
x0: u32,
|
||
y0: u32,
|
||
x1: u32,
|
||
y1: u32,
|
||
}
|
||
|
||
impl Match3DMaterialCellBounds {
|
||
fn width(self) -> u32 {
|
||
self.x1.saturating_sub(self.x0).max(1)
|
||
}
|
||
|
||
fn height(self) -> u32 {
|
||
self.y1.saturating_sub(self.y0).max(1)
|
||
}
|
||
|
||
fn area(self) -> u32 {
|
||
self.width().saturating_mul(self.height())
|
||
}
|
||
|
||
fn to_crop_tuple(self) -> (u32, u32, u32, u32) {
|
||
(self.x0, self.y0, self.width(), self.height())
|
||
}
|
||
}
|
||
|
||
fn resolve_match3d_material_cell_bounds(
|
||
image_width: u32,
|
||
image_height: u32,
|
||
row_count: u32,
|
||
row: u32,
|
||
col: u32,
|
||
) -> Match3DMaterialCellBounds {
|
||
let normalized_rows = row_count.clamp(1, MATCH3D_MATERIAL_GRID_SIZE);
|
||
let cell_x0 = col.saturating_mul(image_width) / MATCH3D_MATERIAL_GRID_SIZE;
|
||
let cell_x1 = (col.saturating_add(1)).saturating_mul(image_width) / MATCH3D_MATERIAL_GRID_SIZE;
|
||
let cell_y0 = row.saturating_mul(image_height) / normalized_rows;
|
||
let cell_y1 = (row.saturating_add(1)).saturating_mul(image_height) / normalized_rows;
|
||
|
||
Match3DMaterialCellBounds {
|
||
x0: cell_x0.min(image_width.saturating_sub(1)),
|
||
y0: cell_y0.min(image_height.saturating_sub(1)),
|
||
x1: cell_x1.clamp(cell_x0.saturating_add(1), image_width),
|
||
y1: cell_y1.clamp(cell_y0.saturating_add(1), image_height),
|
||
}
|
||
}
|
||
|
||
fn detect_match3d_material_foreground_bounds(
|
||
source: &image::DynamicImage,
|
||
cell: Match3DMaterialCellBounds,
|
||
) -> Option<Match3DMaterialCellBounds> {
|
||
let background = sample_match3d_material_cell_background(source, cell);
|
||
let mut foreground: Option<Match3DMaterialCellBounds> = None;
|
||
let mut foreground_pixels = 0u32;
|
||
|
||
for y in cell.y0..cell.y1 {
|
||
for x in cell.x0..cell.x1 {
|
||
if !is_match3d_material_foreground_pixel(source.get_pixel(x, y).0, background) {
|
||
continue;
|
||
}
|
||
foreground_pixels = foreground_pixels.saturating_add(1);
|
||
foreground = Some(match foreground {
|
||
Some(bounds) => Match3DMaterialCellBounds {
|
||
x0: bounds.x0.min(x),
|
||
y0: bounds.y0.min(y),
|
||
x1: bounds.x1.max(x.saturating_add(1)),
|
||
y1: bounds.y1.max(y.saturating_add(1)),
|
||
},
|
||
None => Match3DMaterialCellBounds {
|
||
x0: x,
|
||
y0: y,
|
||
x1: x.saturating_add(1),
|
||
y1: y.saturating_add(1),
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
let min_foreground_pixels = (cell.area() / 320).clamp(12, 220);
|
||
foreground.filter(|bounds| {
|
||
foreground_pixels >= min_foreground_pixels && bounds.width() > 2 && bounds.height() > 2
|
||
})
|
||
}
|
||
|
||
fn detect_match3d_material_visible_bounds(
|
||
image: &image::RgbaImage,
|
||
) -> Option<Match3DMaterialCellBounds> {
|
||
let (width, height) = image.dimensions();
|
||
let mut bounds: Option<Match3DMaterialCellBounds> = None;
|
||
let mut visible_pixels = 0u32;
|
||
|
||
for y in 0..height {
|
||
for x in 0..width {
|
||
let pixel = image.get_pixel(x, y).0;
|
||
if !is_match3d_material_visible_pixel(pixel) {
|
||
continue;
|
||
}
|
||
visible_pixels = visible_pixels.saturating_add(1);
|
||
bounds = Some(match bounds {
|
||
Some(current) => Match3DMaterialCellBounds {
|
||
x0: current.x0.min(x),
|
||
y0: current.y0.min(y),
|
||
x1: current.x1.max(x.saturating_add(1)),
|
||
y1: current.y1.max(y.saturating_add(1)),
|
||
},
|
||
None => Match3DMaterialCellBounds {
|
||
x0: x,
|
||
y0: y,
|
||
x1: x.saturating_add(1),
|
||
y1: y.saturating_add(1),
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
let min_visible_pixels = ((width.saturating_mul(height)) / 540).clamp(10, 120);
|
||
bounds.filter(|visible_bounds| {
|
||
visible_pixels >= min_visible_pixels
|
||
&& visible_bounds.width() > 2
|
||
&& visible_bounds.height() > 2
|
||
})
|
||
}
|
||
|
||
fn sample_match3d_material_cell_background(
|
||
source: &image::DynamicImage,
|
||
cell: Match3DMaterialCellBounds,
|
||
) -> [u8; 4] {
|
||
let sample_size = (cell.width().min(cell.height()) / 12).clamp(2, 8);
|
||
let sample_points = [
|
||
(cell.x0, cell.y0),
|
||
(cell.x1.saturating_sub(sample_size), cell.y0),
|
||
(cell.x0, cell.y1.saturating_sub(sample_size)),
|
||
(
|
||
cell.x1.saturating_sub(sample_size),
|
||
cell.y1.saturating_sub(sample_size),
|
||
),
|
||
];
|
||
let mut samples = Vec::new();
|
||
for (start_x, start_y) in sample_points {
|
||
let mut totals = [0u32; 4];
|
||
let mut count = 0u32;
|
||
for y in start_y..start_y.saturating_add(sample_size).min(cell.y1) {
|
||
for x in start_x..start_x.saturating_add(sample_size).min(cell.x1) {
|
||
let pixel = source.get_pixel(x, y).0;
|
||
totals[0] = totals[0].saturating_add(pixel[0] as u32);
|
||
totals[1] = totals[1].saturating_add(pixel[1] as u32);
|
||
totals[2] = totals[2].saturating_add(pixel[2] as u32);
|
||
totals[3] = totals[3].saturating_add(pixel[3] as u32);
|
||
count = count.saturating_add(1);
|
||
}
|
||
}
|
||
if count > 0 {
|
||
samples.push([
|
||
(totals[0] / count) as u8,
|
||
(totals[1] / count) as u8,
|
||
(totals[2] / count) as u8,
|
||
(totals[3] / count) as u8,
|
||
]);
|
||
}
|
||
}
|
||
|
||
samples
|
||
.into_iter()
|
||
.min_by_key(|sample| {
|
||
let luminance = sample[0] as u16 + sample[1] as u16 + sample[2] as u16;
|
||
(sample[3] as u16, u16::MAX.saturating_sub(luminance))
|
||
})
|
||
.unwrap_or([255, 255, 255, 255])
|
||
}
|
||
|
||
fn clamp_match3d_material_unit(value: f32) -> f32 {
|
||
value.clamp(0.0, 1.0)
|
||
}
|
||
|
||
fn lerp_match3d_material_channel(from: f32, to: f32, t: f32) -> f32 {
|
||
from + (to - from) * clamp_match3d_material_unit(t)
|
||
}
|
||
|
||
fn is_match3d_material_foreground_pixel(pixel: [u8; 4], background: [u8; 4]) -> bool {
|
||
let alpha_diff = pixel[3] as i32 - background[3] as i32;
|
||
if alpha_diff.abs() >= MATCH3D_MATERIAL_FOREGROUND_ALPHA_THRESHOLD && pixel[3] > 24 {
|
||
return true;
|
||
}
|
||
if pixel[3] <= 24 {
|
||
return false;
|
||
}
|
||
|
||
let color_diff = (pixel[0] as i32 - background[0] as i32).abs()
|
||
+ (pixel[1] as i32 - background[1] as i32).abs()
|
||
+ (pixel[2] as i32 - background[2] as i32).abs();
|
||
color_diff >= MATCH3D_MATERIAL_FOREGROUND_DIFF_THRESHOLD
|
||
}
|
||
|
||
fn remove_match3d_material_view_edge_matte(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 changed = false;
|
||
let mut background_mask = vec![0u8; pixel_count];
|
||
let mut queue = Vec::<usize>::new();
|
||
let mut queue_index = 0usize;
|
||
let mut transparent_pixel_count = 0usize;
|
||
for pixel_index in 0..pixel_count {
|
||
let offset = pixel_index * 4;
|
||
if pixels[offset + 3] == 0 {
|
||
background_mask[pixel_index] = 1;
|
||
queue.push(pixel_index);
|
||
transparent_pixel_count = transparent_pixel_count.saturating_add(1);
|
||
}
|
||
}
|
||
let has_transparent_background = transparent_pixel_count > pixel_count / 200;
|
||
|
||
// 中文注释:单图被前景边界收紧后,浅绿框可能正好贴在 PNG 外缘;
|
||
// 把外缘一段宽度作为去背种子,但只清理绿幕 / 近白 matte,避免误伤贴边主体。
|
||
let edge_width = resolve_match3d_material_view_edge_cleanup_width(width, height);
|
||
for y in 0..height {
|
||
for x in 0..width {
|
||
if x >= edge_width
|
||
&& y >= edge_width
|
||
&& x.saturating_add(edge_width) < width
|
||
&& y.saturating_add(edge_width) < height
|
||
{
|
||
continue;
|
||
}
|
||
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_material_view_background_pixel(pixel) {
|
||
continue;
|
||
}
|
||
background_mask[pixel_index] = 1;
|
||
queue.push(pixel_index);
|
||
}
|
||
}
|
||
|
||
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;
|
||
let pixel = [
|
||
pixels[offset],
|
||
pixels[offset + 1],
|
||
pixels[offset + 2],
|
||
pixels[offset + 3],
|
||
];
|
||
if !is_match3d_material_view_background_pixel(pixel) {
|
||
continue;
|
||
}
|
||
background_mask[next_pixel_index] = 1;
|
||
queue.push(next_pixel_index);
|
||
}
|
||
}
|
||
|
||
for _ in 0..edge_width {
|
||
let mut expanded_mask = background_mask.clone();
|
||
let mut changed_this_round = false;
|
||
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;
|
||
if !is_match3d_material_view_background_pixel([
|
||
pixels[offset],
|
||
pixels[offset + 1],
|
||
pixels[offset + 2],
|
||
pixels[offset + 3],
|
||
]) {
|
||
continue;
|
||
}
|
||
|
||
if touches_match3d_material_background_mask(x, y, width, height, &background_mask) {
|
||
expanded_mask[pixel_index] = 1;
|
||
changed_this_round = true;
|
||
}
|
||
}
|
||
}
|
||
background_mask = expanded_mask;
|
||
if !changed_this_round {
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 中文注释:边缘抗锯齿圈要直接从可见像素里剔除,再按剩余主体重新收紧裁边。
|
||
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] != 0
|
||
|| pixels[offset + 1] != 0
|
||
|| pixels[offset + 2] != 0
|
||
{
|
||
pixels[offset] = 0;
|
||
pixels[offset + 1] = 0;
|
||
pixels[offset + 2] = 0;
|
||
pixels[offset + 3] = 0;
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
if has_transparent_background {
|
||
let mut visible_mask = vec![0u8; pixel_count];
|
||
for pixel_index in 0..pixel_count {
|
||
let offset = pixel_index * 4;
|
||
if is_match3d_material_visible_pixel([
|
||
pixels[offset],
|
||
pixels[offset + 1],
|
||
pixels[offset + 2],
|
||
pixels[offset + 3],
|
||
]) {
|
||
visible_mask[pixel_index] = 1;
|
||
}
|
||
}
|
||
|
||
for _ in 0..2 {
|
||
let mut changed_this_round = false;
|
||
for y in 0..height {
|
||
for x in 0..width {
|
||
let pixel_index = y * width + x;
|
||
if visible_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_material_green_contaminated_edge_pixel(pixel) {
|
||
continue;
|
||
}
|
||
if !touches_match3d_material_background_mask(
|
||
x,
|
||
y,
|
||
width,
|
||
height,
|
||
&background_mask,
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
if is_match3d_material_strong_green_contamination(pixel) {
|
||
pixels[offset] = 0;
|
||
pixels[offset + 1] = 0;
|
||
pixels[offset + 2] = 0;
|
||
pixels[offset + 3] = 0;
|
||
visible_mask[pixel_index] = 0;
|
||
background_mask[pixel_index] = 1;
|
||
changed = true;
|
||
changed_this_round = true;
|
||
continue;
|
||
}
|
||
|
||
let replacement = collect_match3d_material_visible_neighbor_color(
|
||
pixels,
|
||
width,
|
||
height,
|
||
x,
|
||
y,
|
||
&background_mask,
|
||
&visible_mask,
|
||
)
|
||
.unwrap_or((
|
||
pixels[offset],
|
||
pixels[offset + 1],
|
||
pixels[offset + 2],
|
||
));
|
||
let next_red = replacement.0.max(pixels[offset]);
|
||
let next_blue = replacement.2.max(pixels[offset + 2]);
|
||
let next_green = replacement
|
||
.1
|
||
.min(next_red.max(next_blue).saturating_add(12));
|
||
if next_red != pixels[offset]
|
||
|| next_green != pixels[offset + 1]
|
||
|| next_blue != pixels[offset + 2]
|
||
{
|
||
pixels[offset] = next_red;
|
||
pixels[offset + 1] = next_green;
|
||
pixels[offset + 2] = next_blue;
|
||
changed = true;
|
||
changed_this_round = true;
|
||
}
|
||
background_mask[pixel_index] = 1;
|
||
}
|
||
}
|
||
if !changed_this_round {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
changed
|
||
}
|
||
|
||
fn resolve_match3d_material_view_edge_cleanup_width(width: usize, height: usize) -> usize {
|
||
let min_side = width.min(height).max(1);
|
||
(min_side / 24).clamp(4, 12).min(min_side)
|
||
}
|
||
|
||
fn is_match3d_material_view_background_pixel(pixel: [u8; 4]) -> bool {
|
||
pixel[3] < 16
|
||
|| is_match3d_material_soft_edge_pixel(pixel)
|
||
|| compute_match3d_material_white_screen_score(pixel) > 0.18
|
||
}
|
||
|
||
fn is_match3d_material_visible_pixel(pixel: [u8; 4]) -> bool {
|
||
pixel[3] > 0 && (pixel[0] > 8 || pixel[1] > 8 || pixel[2] > 8)
|
||
}
|
||
|
||
fn is_match3d_material_soft_edge_pixel(pixel: [u8; 4]) -> bool {
|
||
if pixel[3] == 0 {
|
||
return false;
|
||
}
|
||
|
||
let red = pixel[0];
|
||
let green = pixel[1];
|
||
let blue = pixel[2];
|
||
green >= 188
|
||
&& green.saturating_sub(red.max(blue)) >= 42
|
||
&& (red >= 48 || blue >= 96 || pixel[3] < 236)
|
||
}
|
||
|
||
fn is_match3d_material_green_contaminated_edge_pixel(pixel: [u8; 4]) -> bool {
|
||
if pixel[3] == 0 {
|
||
return false;
|
||
}
|
||
|
||
let red = pixel[0];
|
||
let green = pixel[1];
|
||
let blue = pixel[2];
|
||
green >= 72 && green.saturating_sub(red.max(blue)) >= 18
|
||
}
|
||
|
||
fn is_match3d_material_strong_green_contamination(pixel: [u8; 4]) -> bool {
|
||
let red = pixel[0];
|
||
let green = pixel[1];
|
||
let blue = pixel[2];
|
||
green >= 148 && green.saturating_sub(red.max(blue)) >= 34
|
||
}
|
||
|
||
fn collect_match3d_material_visible_neighbor_color(
|
||
pixels: &[u8],
|
||
width: usize,
|
||
height: usize,
|
||
x: usize,
|
||
y: usize,
|
||
background_mask: &[u8],
|
||
visible_mask: &[u8],
|
||
) -> Option<(u8, u8, u8)> {
|
||
let mut total_weight = 0.0f32;
|
||
let mut total_red = 0.0f32;
|
||
let mut total_green = 0.0f32;
|
||
let mut total_blue = 0.0f32;
|
||
|
||
for offset_y in -3i32..=3 {
|
||
for offset_x in -3i32..=3 {
|
||
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 {
|
||
continue;
|
||
}
|
||
|
||
let next_pixel_index = next_y as usize * width + next_x as usize;
|
||
if background_mask[next_pixel_index] != 0 || visible_mask[next_pixel_index] == 0 {
|
||
continue;
|
||
}
|
||
|
||
let next_offset = next_pixel_index * 4;
|
||
let next_alpha = pixels[next_offset + 3];
|
||
if next_alpha < 96 {
|
||
continue;
|
||
}
|
||
let pixel = [
|
||
pixels[next_offset],
|
||
pixels[next_offset + 1],
|
||
pixels[next_offset + 2],
|
||
next_alpha,
|
||
];
|
||
if is_match3d_material_green_contaminated_edge_pixel(pixel)
|
||
|| is_match3d_material_soft_edge_pixel(pixel)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs();
|
||
let weight = (next_alpha as f32 / 255.0)
|
||
* if distance <= 1 {
|
||
2.0
|
||
} else if distance <= 3 {
|
||
1.2
|
||
} else {
|
||
0.7
|
||
};
|
||
total_weight += weight;
|
||
total_red += pixels[next_offset] as f32 * weight;
|
||
total_green += pixels[next_offset + 1] as f32 * weight;
|
||
total_blue += pixels[next_offset + 2] as f32 * weight;
|
||
}
|
||
}
|
||
|
||
if total_weight <= 0.0 {
|
||
return None;
|
||
}
|
||
|
||
Some((
|
||
(total_red / total_weight).round() as u8,
|
||
(total_green / total_weight).round() as u8,
|
||
(total_blue / total_weight).round() as u8,
|
||
))
|
||
}
|
||
|
||
fn apply_match3d_material_green_screen_alpha(source: image::DynamicImage) -> image::DynamicImage {
|
||
let mut image = source.to_rgba8();
|
||
let (width, height) = image.dimensions();
|
||
remove_match3d_material_green_screen_background(
|
||
image.as_mut(),
|
||
width as usize,
|
||
height as usize,
|
||
);
|
||
image::DynamicImage::ImageRgba8(image)
|
||
}
|
||
|
||
fn remove_match3d_material_green_screen_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 green_scores = vec![0.0f32; pixel_count];
|
||
let mut white_scores = vec![0.0f32; pixel_count];
|
||
let mut background_hints = vec![0.0f32; pixel_count];
|
||
let mut background_mask = vec![0u8; pixel_count];
|
||
let mut queue = Vec::<usize>::new();
|
||
let mut queue_index = 0usize;
|
||
|
||
for pixel_index in 0..pixel_count {
|
||
let offset = pixel_index * 4;
|
||
let red = pixels[offset];
|
||
let green = pixels[offset + 1];
|
||
let blue = pixels[offset + 2];
|
||
let alpha = pixels[offset + 3];
|
||
let green_score = compute_match3d_material_green_screen_score([red, green, blue, alpha]);
|
||
let white_score = compute_match3d_material_white_screen_score([red, green, blue, alpha]);
|
||
let transparency_hint = clamp_match3d_material_unit((56.0 - alpha as f32) / 56.0) * 0.75;
|
||
|
||
green_scores[pixel_index] = green_score;
|
||
white_scores[pixel_index] = white_score;
|
||
background_hints[pixel_index] = green_score.max(white_score).max(transparency_hint);
|
||
}
|
||
|
||
let seed_background_pixel = |pixel_index: usize,
|
||
background_mask: &mut [u8],
|
||
queue: &mut Vec<usize>| {
|
||
if background_mask[pixel_index] != 0 {
|
||
return;
|
||
}
|
||
let alpha = pixels[pixel_index * 4 + 3];
|
||
let strong_candidate = alpha < 40
|
||
|| green_scores[pixel_index] >= MATCH3D_MATERIAL_HARD_GREEN_SCREEN_SCORE
|
||
|| (alpha < 224 && green_scores[pixel_index] > MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE)
|
||
|| white_scores[pixel_index] > 0.32;
|
||
if !strong_candidate {
|
||
return;
|
||
}
|
||
background_mask[pixel_index] = 1;
|
||
queue.push(pixel_index);
|
||
};
|
||
|
||
for x in 0..width {
|
||
seed_background_pixel(x, &mut background_mask, &mut queue);
|
||
seed_background_pixel((height - 1) * width + x, &mut background_mask, &mut queue);
|
||
}
|
||
for y in 1..height.saturating_sub(1) {
|
||
seed_background_pixel(y * width, &mut background_mask, &mut queue);
|
||
seed_background_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 neighbor_indexes = [
|
||
if x > 0 { Some(pixel_index - 1) } else { None },
|
||
if x + 1 < width {
|
||
Some(pixel_index + 1)
|
||
} else {
|
||
None
|
||
},
|
||
if y > 0 {
|
||
Some(pixel_index - width)
|
||
} else {
|
||
None
|
||
},
|
||
if y + 1 < height {
|
||
Some(pixel_index + width)
|
||
} else {
|
||
None
|
||
},
|
||
];
|
||
|
||
for next_pixel_index in neighbor_indexes.into_iter().flatten() {
|
||
if background_mask[next_pixel_index] != 0 {
|
||
continue;
|
||
}
|
||
let next_offset = next_pixel_index * 4;
|
||
let alpha = pixels[next_offset + 3];
|
||
let green_score = green_scores[next_pixel_index];
|
||
let white_score = white_scores[next_pixel_index];
|
||
let hint = background_hints[next_pixel_index];
|
||
let reachable_soft_edge = hint > 0.08
|
||
&& alpha < 224
|
||
&& (green_score > 0.04 || white_score > 0.08 || alpha < 180);
|
||
let green_background = green_score >= MATCH3D_MATERIAL_HARD_GREEN_SCREEN_SCORE
|
||
|| (alpha < 224 && green_score > MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE);
|
||
if alpha < 40 || green_background || white_score > 0.32 || reachable_soft_edge {
|
||
background_mask[next_pixel_index] = 1;
|
||
queue.push(next_pixel_index);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 中文注释:Gemini 有时把每个素材格生成成独立绿幕块,块外又是近白背景;
|
||
// 这类绿幕不一定和整张 sheet 外边缘连通,必须用高置信绿幕直接补进背景层。
|
||
for pixel_index in 0..pixel_count {
|
||
if background_mask[pixel_index] == 0
|
||
&& green_scores[pixel_index] >= MATCH3D_MATERIAL_HARD_GREEN_SCREEN_SCORE
|
||
{
|
||
background_mask[pixel_index] = 1;
|
||
}
|
||
}
|
||
|
||
// 中文注释:较厚的抗锯齿绿边可能低于 hard 阈值;先沿整张 sheet 的透明背景向内吃掉
|
||
// 软绿边,再进入格子裁剪,避免每张切图自带绿色描边。
|
||
let soft_green_cleanup_rounds = (width.min(height) / 40).clamp(4, 14);
|
||
for _ in 0..soft_green_cleanup_rounds {
|
||
let mut expanded_mask = background_mask.clone();
|
||
let mut changed_this_round = false;
|
||
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],
|
||
];
|
||
let green_score = green_scores[pixel_index];
|
||
let white_score = white_scores[pixel_index];
|
||
if !is_match3d_material_soft_green_matte_pixel(pixel, green_score, white_score) {
|
||
continue;
|
||
}
|
||
if !touches_match3d_material_background_mask(x, y, width, height, &background_mask)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
expanded_mask[pixel_index] = 1;
|
||
changed_this_round = true;
|
||
}
|
||
}
|
||
background_mask = expanded_mask;
|
||
if !changed_this_round {
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 中文注释:主体边缘常带一圈绿幕或白底抗锯齿,扩一层软边,避免切割后残留毛边。
|
||
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 alpha = pixels[pixel_index * 4 + 3];
|
||
let green_score = green_scores[pixel_index];
|
||
let white_score = white_scores[pixel_index];
|
||
let hint = background_hints[pixel_index];
|
||
let soft_matte_candidate = alpha < 224
|
||
|| white_score > 0.10
|
||
|| green_score >= MATCH3D_MATERIAL_HARD_GREEN_SCREEN_SCORE;
|
||
if hint < MATCH3D_MATERIAL_GREEN_SCREEN_SOFT_SCORE || !soft_matte_candidate {
|
||
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 >= 2
|
||
|| (adjacent_background_count >= 1
|
||
&& hint >= MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE)
|
||
{
|
||
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 alpha_offset = pixel_index * 4 + 3;
|
||
if pixels[alpha_offset] != 0 {
|
||
pixels[alpha_offset] = 0;
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
for y in 0..height {
|
||
for x in 0..width {
|
||
let pixel_index = y * width + x;
|
||
let offset = pixel_index * 4;
|
||
let alpha = pixels[offset + 3];
|
||
if alpha == 0 {
|
||
continue;
|
||
}
|
||
|
||
let mut touches_transparent_edge = false;
|
||
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
|
||
{
|
||
touches_transparent_edge = true;
|
||
continue;
|
||
}
|
||
let next_pixel_index = next_y as usize * width + next_x as usize;
|
||
if background_mask[next_pixel_index] != 0
|
||
|| pixels[next_pixel_index * 4 + 3] < 16
|
||
{
|
||
touches_transparent_edge = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
if !touches_transparent_edge {
|
||
continue;
|
||
}
|
||
|
||
let green_score = green_scores[pixel_index];
|
||
let white_score = white_scores[pixel_index];
|
||
let contamination = green_score.max(white_score).max(if alpha < 220 {
|
||
((220 - alpha) as f32 / 220.0) * 0.25
|
||
} else {
|
||
0.0
|
||
});
|
||
if contamination < 0.06 {
|
||
continue;
|
||
}
|
||
|
||
let sample = collect_match3d_material_foreground_neighbor_color(
|
||
pixels,
|
||
width,
|
||
height,
|
||
x,
|
||
y,
|
||
&background_mask,
|
||
&background_hints,
|
||
);
|
||
let mut red = pixels[offset] as f32;
|
||
let mut green = pixels[offset + 1] as f32;
|
||
let mut blue = pixels[offset + 2] as f32;
|
||
let blend = clamp_match3d_material_unit(contamination.max(0.22));
|
||
|
||
if let Some((sample_red, sample_green, sample_blue)) = sample {
|
||
red = lerp_match3d_material_channel(red, sample_red as f32, blend);
|
||
green = lerp_match3d_material_channel(green, sample_green as f32, blend);
|
||
blue = lerp_match3d_material_channel(blue, sample_blue as f32, blend);
|
||
|
||
if green_score > 0.04 {
|
||
green = green.min(sample_green as f32 + 18.0);
|
||
}
|
||
if white_score > 0.1 {
|
||
red = red.min(sample_red as f32 + 26.0);
|
||
green = green.min(sample_green as f32 + 26.0);
|
||
blue = blue.min(sample_blue as f32 + 26.0);
|
||
}
|
||
} else {
|
||
if green_score > 0.04 {
|
||
let toned_green = (green - (green - red.max(blue)) * 0.78)
|
||
.round()
|
||
.max(red.max(blue));
|
||
green = green.min(toned_green).min(red.max(blue) + 18.0);
|
||
}
|
||
|
||
if white_score > 0.12 {
|
||
let spread = red.max(green).max(blue) - red.min(green).min(blue);
|
||
if spread < 20.0 {
|
||
let toned_value = ((red + green + blue) / 3.0 * 0.88).round();
|
||
red = red.min(toned_value);
|
||
green = green.min(toned_value);
|
||
blue = blue.min(toned_value);
|
||
}
|
||
}
|
||
}
|
||
|
||
let mut next_alpha = alpha;
|
||
let edge_fade = (green_score * 0.35).max(white_score * 0.28);
|
||
if edge_fade > 0.08 {
|
||
next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8;
|
||
if next_alpha < 10 {
|
||
next_alpha = 0;
|
||
}
|
||
}
|
||
|
||
let next_red = red.round().clamp(0.0, 255.0) as u8;
|
||
let next_green = green.round().clamp(0.0, 255.0) as u8;
|
||
let next_blue = blue.round().clamp(0.0, 255.0) as u8;
|
||
if next_red != pixels[offset]
|
||
|| next_green != pixels[offset + 1]
|
||
|| next_blue != pixels[offset + 2]
|
||
|| next_alpha != alpha
|
||
{
|
||
pixels[offset] = next_red;
|
||
pixels[offset + 1] = next_green;
|
||
pixels[offset + 2] = next_blue;
|
||
pixels[offset + 3] = next_alpha;
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
changed
|
||
}
|
||
|
||
fn touches_match3d_material_background_mask(
|
||
x: usize,
|
||
y: usize,
|
||
width: usize,
|
||
height: usize,
|
||
background_mask: &[u8],
|
||
) -> bool {
|
||
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 {
|
||
return true;
|
||
}
|
||
if background_mask[next_y as usize * width + next_x as usize] != 0 {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
fn is_match3d_material_soft_green_matte_pixel(
|
||
pixel: [u8; 4],
|
||
green_score: f32,
|
||
white_score: f32,
|
||
) -> bool {
|
||
if pixel[3] == 0 || green_score < MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE {
|
||
return false;
|
||
}
|
||
|
||
let red = pixel[0];
|
||
let green = pixel[1];
|
||
let blue = pixel[2];
|
||
let foreground_mix = red.max(blue);
|
||
green >= 188
|
||
&& white_score < 0.34
|
||
&& green.saturating_sub(foreground_mix) >= 42
|
||
&& (red >= 48 || blue >= 96 || pixel[3] < 236)
|
||
}
|
||
|
||
fn compute_match3d_material_green_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 green_lead = green - red.max(blue);
|
||
if green < 96.0 || green_lead <= 18.0 {
|
||
return 0.0;
|
||
}
|
||
|
||
let green_ratio = green / (red + blue).max(1.0);
|
||
if green_ratio <= 0.9 {
|
||
return 0.0;
|
||
}
|
||
|
||
(((green - 96.0) / 128.0).clamp(0.0, 1.0) * 0.34
|
||
+ ((green_lead - 18.0) / 120.0).clamp(0.0, 1.0) * 0.46
|
||
+ ((green_ratio - 0.9) / 2.4).clamp(0.0, 1.0) * 0.20)
|
||
.clamp(0.0, 1.0)
|
||
}
|
||
|
||
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 - clamp_match3d_material_unit((spread - 6.0) / 34.0);
|
||
let brightness = clamp_match3d_material_unit((average - 188.0) / 55.0);
|
||
let floor = clamp_match3d_material_unit((min_channel - 168.0) / 60.0);
|
||
clamp_match3d_material_unit(neutrality * (brightness * 0.85 + floor * 0.15))
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
fn collect_match3d_material_foreground_neighbor_color(
|
||
pixels: &[u8],
|
||
width: usize,
|
||
height: usize,
|
||
x: usize,
|
||
y: usize,
|
||
background_mask: &[u8],
|
||
background_hints: &[f32],
|
||
) -> Option<(u8, u8, u8)> {
|
||
let mut total_weight = 0.0f32;
|
||
let mut total_red = 0.0f32;
|
||
let mut total_green = 0.0f32;
|
||
let mut total_blue = 0.0f32;
|
||
|
||
for offset_y in -2i32..=2 {
|
||
for offset_x in -2i32..=2 {
|
||
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 {
|
||
continue;
|
||
}
|
||
|
||
let next_pixel_index = next_y as usize * width + next_x as usize;
|
||
if background_mask[next_pixel_index] != 0 || background_hints[next_pixel_index] >= 0.18
|
||
{
|
||
continue;
|
||
}
|
||
|
||
let next_offset = next_pixel_index * 4;
|
||
let next_alpha = pixels[next_offset + 3];
|
||
if next_alpha < 96 {
|
||
continue;
|
||
}
|
||
let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs();
|
||
let weight = (next_alpha as f32 / 255.0)
|
||
* if distance <= 1 {
|
||
1.8
|
||
} else if distance == 2 {
|
||
1.2
|
||
} else {
|
||
0.7
|
||
};
|
||
|
||
total_weight += weight;
|
||
total_red += pixels[next_offset] as f32 * weight;
|
||
total_green += pixels[next_offset + 1] as f32 * weight;
|
||
total_blue += pixels[next_offset + 2] as f32 * weight;
|
||
}
|
||
}
|
||
|
||
if total_weight <= 0.0 {
|
||
return None;
|
||
}
|
||
|
||
Some((
|
||
(total_red / total_weight).round() as u8,
|
||
(total_green / total_weight).round() as u8,
|
||
(total_blue / total_weight).round() as u8,
|
||
))
|
||
}
|