Files
Genarrative/server-rs/crates/api-server/src/match3d/item_assets.rs

2632 lines
90 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::*;
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,
))
}