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, existing_assets: Vec, ) -> Result, 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, existing_assets: Vec, ) -> Result, 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::>(); 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, background_music_style: Option, background_music_prompt: Option, background_asset: Option, } struct Match3DMaterialBatchOutput { task_id: String, generated_at_micros: i64, items: Vec<(Match3DItemImageGenerationSeed, Vec)>, } 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, ) -> Result, 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::>(); 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::>(); let persisted_item_names = persisted_seeds .iter() .map(|item| item.item_name.clone()) .collect::>(); 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::>(), }) } }) .collect::>(); 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, ) -> Result, 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, ) -> Result, 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::>(); 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::>(); 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 { 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, ) -> Result, 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::>(); 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, ) -> Result, 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::>(); 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::>(); 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, } pub(super) fn normalize_match3d_item_name(raw: &str) -> String { raw.trim() .trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、']) .chars() .filter(|character| !character.is_control()) .take(12) .collect::() .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 { 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, ) -> Vec { 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, 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) -> Vec { let mut normalized: Vec = 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, 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, 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::>(); 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 { let mut padded = item_names .iter() .take(available_capacity) .cloned() .collect::>(); 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, 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::() .trim() .to_string() } pub(super) fn normalize_match3d_audio_prompt(raw: &str) -> String { raw.trim() .chars() .filter(|character| !character.is_control()) .take(500) .collect::() .trim() .to_string() } pub(super) fn normalize_match3d_background_prompt(raw: &str) -> String { raw.trim() .chars() .filter(|character| !character.is_control()) .take(900) .collect::() .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, ) -> Vec { 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, ) -> Vec { 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::().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, 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, 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::>() .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 { 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>, 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 { let background = sample_match3d_material_cell_background(source, cell); let mut foreground: Option = 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 { let (width, height) = image.dimensions(); let mut bounds: Option = 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::::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::::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| { 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::::new(); let mut queue_index = 0usize; let seed_pixel = |pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec| { 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, )) }