#![allow(dead_code)] use std::{collections::BTreeMap, time::Duration}; use axum::http::StatusCode; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use image::{GenericImageView, ImageFormat}; use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest}; use serde_json::json; use crate::{ http_error::AppError, openai_image_generation::DownloadedOpenAiImage, platform_errors::map_oss_error, state::AppState, }; const GENERATED_ASSET_SHEET_PROVIDER: &str = "generated-asset-sheets"; const GENERATED_ASSET_SHEET_OSS_PUT_TIMEOUT_MS: u64 = 3 * 60_000; const GENERATED_ASSET_SHEET_FOREGROUND_DIFF_THRESHOLD: i32 = 36; const GENERATED_ASSET_SHEET_FOREGROUND_ALPHA_THRESHOLD: i32 = 36; const GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE: f32 = 0.34; const GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE: f32 = 0.18; const GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE: f32 = 0.82; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct GeneratedAssetSheetPromptInput<'a> { pub(crate) subject_text: &'a str, pub(crate) item_names: &'a [String], pub(crate) grid_size: usize, pub(crate) item_name_prompt_template: Option<&'a str>, pub(crate) special_prompt: Option<&'a str>, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct GeneratedAssetSheetSliceImage { pub(crate) bytes: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct GeneratedAssetSheetUpload { pub(crate) src: String, pub(crate) object_key: String, } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct GeneratedAssetSheetPersistPrompt { pub(crate) sheet_prompt: Option, pub(crate) item_name_prompt: Option, pub(crate) special_prompt: Option, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct GeneratedAssetSheetPersistInput { pub(crate) prefix: LegacyAssetPrefix, pub(crate) owner_user_id: String, pub(crate) session_id: String, pub(crate) profile_id: String, pub(crate) path_segments: Vec, pub(crate) file_name: String, pub(crate) content_type: String, pub(crate) bytes: Vec, pub(crate) asset_kind: String, pub(crate) source_job_id: Option, pub(crate) generated_at_micros: i64, pub(crate) grid_size: usize, pub(crate) row_index: usize, pub(crate) view_index: usize, pub(crate) prompt: GeneratedAssetSheetPersistPrompt, } pub(crate) fn build_generated_asset_sheet_prompt( input: &GeneratedAssetSheetPromptInput<'_>, ) -> Result { let grid_size = input.grid_size; if grid_size == 0 { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": GENERATED_ASSET_SHEET_PROVIDER, "message": "系列素材图集的 n 必须大于 0。", })), ); } if input.item_names.len() > grid_size { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": GENERATED_ASSET_SHEET_PROVIDER, "message": "系列素材图集的物品行数不能超过 n。", "gridSize": grid_size, "itemCount": input.item_names.len(), })), ); } let subject_text = input.subject_text.trim(); let subject_text = if subject_text.is_empty() { "系列素材" } else { subject_text }; let item_rows = input .item_names .iter() .enumerate() .map(|(index, item_name)| { let row_index = index + 1; let item_name = item_name.trim(); if let Some(template) = input .item_name_prompt_template .map(str::trim) .filter(|value| !value.is_empty()) { return template .replace("{row_index}", row_index.to_string().as_str()) .replace("{item_name}", item_name) .replace("{view_count}", grid_size.to_string().as_str()); } format!("第{row_index}行:{item_name} 的 {grid_size} 个不同视图") }) .collect::>() .join(";"); let special_prompt = input .special_prompt .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .unwrap_or_else(|| format!("每个物品生成 {grid_size} 个不同视图。")); Ok(format!( "生成一张1:1图片。固定生成{grid_size}行*{grid_size}列网格素材图,画面是{subject_text}。严格{grid_size}*{grid_size}均匀排布,严格按行组织:{item_rows}。{special_prompt}每个格子一个独立居中的完整素材,每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。素材本身不得使用与绿幕相同的纯绿色;若素材天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。统一柔和光照,清晰轮廓,适合直接切割成游戏2D素材。请让每个素材完整落在自己的格子中央,四周保留留白,相邻素材主体之间必须至少保留单个素材格宽度的1/4空白间距(约25%单格宽度),包含左右相邻格和上下相邻行,素材主体不得占满格子。禁止主体跨格、贴边或越界,禁止任何内容进入相邻格子影响裁剪后的效果。不要出现文字、水印、UI、边框、网格线、标签、底座、场景或其他物体。" )) } pub(crate) fn slice_generated_asset_sheet( image: &DownloadedOpenAiImage, item_names: &[String], grid_size: usize, ) -> Result>, AppError> { if grid_size == 0 { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": GENERATED_ASSET_SHEET_PROVIDER, "message": "系列素材图集的 n 必须大于 0。", })), ); } let grid_size_u32 = u32::try_from(grid_size).map_err(|_| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": GENERATED_ASSET_SHEET_PROVIDER, "message": "系列素材图集的 n 超出可支持范围。", })) })?; let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": GENERATED_ASSET_SHEET_PROVIDER, "message": format!("系列素材图集解码失败:{error}"), })) })?; let source = apply_generated_asset_sheet_green_screen_alpha(source); let (width, height) = source.dimensions(); let cell_width = width / grid_size_u32; let cell_height = height / grid_size_u32; if cell_width == 0 || cell_height == 0 { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": GENERATED_ASSET_SHEET_PROVIDER, "message": "系列素材图集尺寸过小,无法切割。", })), ); } let mut slices = Vec::with_capacity(item_names.len().min(grid_size)); for item_index in 0..item_names.len().min(grid_size) { let row = item_index as u32; let mut views = Vec::with_capacity(grid_size); for view_index in 0..grid_size { let col = view_index as u32; let (crop_x, crop_y, crop_width, crop_height) = resolve_generated_asset_sheet_cell_crop(&source, grid_size_u32, row, col); let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height); let cleaned = crop_generated_asset_sheet_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": GENERATED_ASSET_SHEET_PROVIDER, "message": format!("系列素材图集切割失败:{error}"), })) })?; views.push(GeneratedAssetSheetSliceImage { bytes: cursor.into_inner(), }); } slices.push(views); } Ok(slices) } pub(crate) fn crop_generated_asset_sheet_view_edge_matte( image: image::DynamicImage, ) -> image::DynamicImage { let mut image = image.to_rgba8(); let (width, height) = image.dimensions(); remove_generated_asset_sheet_view_edge_matte(image.as_mut(), width as usize, height as usize); let bounds = detect_generated_asset_sheet_visible_bounds(&image).unwrap_or_else(|| { GeneratedAssetSheetCellBounds { 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(), ) } pub(crate) fn prepare_generated_asset_sheet_put_request( input: GeneratedAssetSheetPersistInput, ) -> Result { if input.grid_size == 0 { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": GENERATED_ASSET_SHEET_PROVIDER, "message": "系列素材图集的 n 必须大于 0。", })), ); } if input.row_index == 0 || input.view_index == 0 || input.row_index > input.grid_size || input.view_index > input.grid_size { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": GENERATED_ASSET_SHEET_PROVIDER, "message": "系列素材图集持久化的行列索引必须落在 n*n 范围内。", "gridSize": input.grid_size, "rowIndex": input.row_index, "viewIndex": input.view_index, })), ); } let mut metadata = BTreeMap::new(); metadata.insert( "x-oss-meta-asset-kind".to_string(), input.asset_kind.clone(), ); metadata.insert( "x-oss-meta-owner-user-id".to_string(), input.owner_user_id.clone(), ); metadata.insert( "x-oss-meta-profile-id".to_string(), input.profile_id.clone(), ); metadata.insert( "x-oss-meta-generated-asset-sheet-grid-size".to_string(), input.grid_size.to_string(), ); metadata.insert( "x-oss-meta-generated-asset-sheet-row-index".to_string(), input.row_index.to_string(), ); metadata.insert( "x-oss-meta-generated-asset-sheet-view-index".to_string(), input.view_index.to_string(), ); metadata.insert( "x-oss-meta-generated-at-micros".to_string(), input.generated_at_micros.to_string(), ); if let Some(source_job_id) = input .source_job_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { metadata.insert( "x-oss-meta-source-job-id".to_string(), source_job_id.to_string(), ); } insert_generated_asset_sheet_prompt_metadata( &mut metadata, "generated-asset-sheet-prompt-b64", input.prompt.sheet_prompt.as_deref(), ); insert_generated_asset_sheet_prompt_metadata( &mut metadata, "generated-asset-sheet-item-name-prompt-b64", input.prompt.item_name_prompt.as_deref(), ); insert_generated_asset_sheet_prompt_metadata( &mut metadata, "generated-asset-sheet-special-prompt-b64", input.prompt.special_prompt.as_deref(), ); if input.prompt.sheet_prompt.is_some() || input.prompt.item_name_prompt.is_some() || input.prompt.special_prompt.is_some() { metadata.insert( "x-oss-meta-generated-asset-sheet-prompt-encoding".to_string(), "utf8-base64".to_string(), ); } Ok(OssPutObjectRequest { prefix: input.prefix, path_segments: std::iter::once(input.session_id.as_str()) .chain(std::iter::once(input.profile_id.as_str())) .chain(input.path_segments.iter().map(String::as_str)) .map(|segment| sanitize_generated_asset_sheet_path_segment(segment, "asset")) .collect(), file_name: input.file_name, content_type: Some(input.content_type), access: OssObjectAccess::Private, metadata, body: input.bytes, }) } pub(crate) async fn persist_generated_asset_sheet_bytes( state: &AppState, input: GeneratedAssetSheetPersistInput, ) -> Result { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": "OSS 未完成环境变量配置", })) })?; let put_request = prepare_generated_asset_sheet_put_request(input)?; let oss_http_client = reqwest::Client::builder() .timeout(Duration::from_millis( GENERATED_ASSET_SHEET_OSS_PUT_TIMEOUT_MS, )) .build() .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": GENERATED_ASSET_SHEET_PROVIDER, "message": format!("构造系列素材图集 OSS 上传客户端失败:{error}"), })) })?; let put_result = oss_client .put_object(&oss_http_client, put_request) .await .map_err(|error| map_oss_error(error, "aliyun-oss"))?; Ok(GeneratedAssetSheetUpload { src: put_result.legacy_public_path, object_key: put_result.object_key, }) } fn insert_generated_asset_sheet_prompt_metadata( metadata: &mut BTreeMap, key: &str, value: Option<&str>, ) { let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { return; }; metadata.insert( format!("x-oss-meta-{key}"), BASE64_STANDARD.encode(value.as_bytes()), ); } #[derive(Clone, Copy, Debug)] struct GeneratedAssetSheetCellBounds { x0: u32, y0: u32, x1: u32, y1: u32, } impl GeneratedAssetSheetCellBounds { 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_generated_asset_sheet_cell_crop( source: &image::DynamicImage, grid_size: u32, row: u32, col: u32, ) -> (u32, u32, u32, u32) { let (image_width, image_height) = source.dimensions(); let cell = resolve_generated_asset_sheet_cell_bounds(image_width, image_height, grid_size, row, col); let Some(foreground) = detect_generated_asset_sheet_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 = GeneratedAssetSheetCellBounds { 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() } fn resolve_generated_asset_sheet_cell_bounds( image_width: u32, image_height: u32, grid_size: u32, row: u32, col: u32, ) -> GeneratedAssetSheetCellBounds { let normalized_grid_size = grid_size.max(1); let cell_x0 = col.saturating_mul(image_width) / normalized_grid_size; let cell_x1 = (col.saturating_add(1)).saturating_mul(image_width) / normalized_grid_size; let cell_y0 = row.saturating_mul(image_height) / normalized_grid_size; let cell_y1 = (row.saturating_add(1)).saturating_mul(image_height) / normalized_grid_size; GeneratedAssetSheetCellBounds { 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_generated_asset_sheet_foreground_bounds( source: &image::DynamicImage, cell: GeneratedAssetSheetCellBounds, ) -> Option { let background = sample_generated_asset_sheet_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_generated_asset_sheet_foreground_pixel(source.get_pixel(x, y).0, background) { continue; } foreground_pixels = foreground_pixels.saturating_add(1); foreground = Some(match foreground { Some(bounds) => GeneratedAssetSheetCellBounds { 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 => GeneratedAssetSheetCellBounds { 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_generated_asset_sheet_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_generated_asset_sheet_visible_pixel(pixel) { continue; } visible_pixels = visible_pixels.saturating_add(1); bounds = Some(match bounds { Some(current) => GeneratedAssetSheetCellBounds { 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 => GeneratedAssetSheetCellBounds { 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_generated_asset_sheet_cell_background( source: &image::DynamicImage, cell: GeneratedAssetSheetCellBounds, ) -> [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_generated_asset_sheet_unit(value: f32) -> f32 { value.clamp(0.0, 1.0) } fn lerp_generated_asset_sheet_channel(from: f32, to: f32, t: f32) -> f32 { from + (to - from) * clamp_generated_asset_sheet_unit(t) } fn is_generated_asset_sheet_foreground_pixel(pixel: [u8; 4], background: [u8; 4]) -> bool { let alpha_diff = pixel[3] as i32 - background[3] as i32; if alpha_diff.abs() >= GENERATED_ASSET_SHEET_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 >= GENERATED_ASSET_SHEET_FOREGROUND_DIFF_THRESHOLD } fn remove_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_view_background_pixel([ pixels[offset], pixels[offset + 1], pixels[offset + 2], pixels[offset + 3], ]) { continue; } if touches_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_green_contaminated_edge_pixel(pixel) { continue; } if !touches_generated_asset_sheet_background_mask( x, y, width, height, &background_mask, ) { continue; } if is_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_view_background_pixel(pixel: [u8; 4]) -> bool { pixel[3] < 16 || is_generated_asset_sheet_soft_edge_pixel(pixel) || compute_generated_asset_sheet_white_screen_score(pixel) > 0.18 } fn is_generated_asset_sheet_visible_pixel(pixel: [u8; 4]) -> bool { pixel[3] > 0 && (pixel[0] > 8 || pixel[1] > 8 || pixel[2] > 8) } fn is_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_green_contaminated_edge_pixel(pixel) || is_generated_asset_sheet_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_generated_asset_sheet_green_screen_alpha( source: image::DynamicImage, ) -> image::DynamicImage { let mut image = source.to_rgba8(); let (width, height) = image.dimensions(); remove_generated_asset_sheet_green_screen_background( image.as_mut(), width as usize, height as usize, ); image::DynamicImage::ImageRgba8(image) } fn remove_generated_asset_sheet_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_generated_asset_sheet_green_screen_score([red, green, blue, alpha]); let white_score = compute_generated_asset_sheet_white_screen_score([red, green, blue, alpha]); let transparency_hint = clamp_generated_asset_sheet_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] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE || (alpha < 224 && green_scores[pixel_index] > GENERATED_ASSET_SHEET_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 >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE || (alpha < 224 && green_score > GENERATED_ASSET_SHEET_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); } } } for pixel_index in 0..pixel_count { if background_mask[pixel_index] == 0 && green_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE { background_mask[pixel_index] = 1; } } 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_generated_asset_sheet_soft_green_matte_pixel(pixel, green_score, white_score) { continue; } if !touches_generated_asset_sheet_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 >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE; if hint < GENERATED_ASSET_SHEET_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 >= GENERATED_ASSET_SHEET_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_generated_asset_sheet_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_generated_asset_sheet_unit(contamination.max(0.22)); if let Some((sample_red, sample_green, sample_blue)) = sample { red = lerp_generated_asset_sheet_channel(red, sample_red as f32, blend); green = lerp_generated_asset_sheet_channel(green, sample_green as f32, blend); blue = lerp_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_soft_green_matte_pixel( pixel: [u8; 4], green_score: f32, white_score: f32, ) -> bool { if pixel[3] == 0 || green_score < GENERATED_ASSET_SHEET_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_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_unit((spread - 6.0) / 34.0); let brightness = clamp_generated_asset_sheet_unit((average - 188.0) / 55.0); let floor = clamp_generated_asset_sheet_unit((min_channel - 168.0) / 60.0); clamp_generated_asset_sheet_unit(neutrality * (brightness * 0.85 + floor * 0.15)) } fn collect_generated_asset_sheet_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, )) } fn sanitize_generated_asset_sheet_path_segment(raw: &str, fallback: &str) -> String { let normalized = raw .trim() .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { ch.to_ascii_lowercase() } else { '-' } }) .collect::(); let collapsed = normalized .split('-') .filter(|part| !part.is_empty()) .collect::>() .join("-"); if collapsed.is_empty() { fallback.to_string() } else { collapsed.chars().take(64).collect() } } #[cfg(test)] mod tests { use super::*; fn build_test_image(width: u32, height: u32, color: [u8; 4]) -> image::RgbaImage { image::RgbaImage::from_pixel(width, height, image::Rgba(color)) } #[test] fn generated_asset_sheet_prompt_uses_default_rows_and_special_instruction() { let item_names = vec!["草莓".to_string(), "苹果".to_string()]; let prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput { subject_text: "水果题材的抓大鹅 2D 物品素材", item_names: &item_names, grid_size: 5, item_name_prompt_template: None, special_prompt: None, }) .expect("prompt should build"); assert!(prompt.contains("5行*5列")); assert!(prompt.contains("第1行:草莓 的 5 个不同视图")); assert!(prompt.contains("第2行:苹果 的 5 个不同视图")); assert!(prompt.contains("每个物品生成 5 个不同视图")); } #[test] fn generated_asset_sheet_prompt_allows_custom_row_template_and_special_prompt() { let item_names = vec!["草莓".to_string()]; let prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput { subject_text: "水果题材的抓大鹅 2D 物品素材", item_names: &item_names, grid_size: 5, item_name_prompt_template: Some( "第{row_index}行是 {item_name},共 {view_count} 个视图", ), special_prompt: Some("每个物品要生成五个不同视图:正面、左前、右前、俯视、背面。"), }) .expect("prompt should build"); assert!(prompt.contains("第1行是 草莓,共 5 个视图")); assert!(prompt.contains("每个物品要生成五个不同视图")); } #[test] fn generated_asset_sheet_prompt_rejects_zero_grid_size() { let item_names = vec!["草莓".to_string()]; let error = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput { subject_text: "水果题材的抓大鹅 2D 物品素材", item_names: &item_names, grid_size: 0, item_name_prompt_template: None, special_prompt: None, }) .expect_err("grid size 0 should be rejected"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); } #[test] fn generated_asset_sheet_slices_by_requested_grid_size() { let width = 500; let height = 500; let item_names = vec!["樱桃".to_string(), "苹果".to_string()]; let mut sheet = image::RgbaImage::new(width, height); for row in 0..5 { for col in 0..5 { let color = image::Rgba([ 32 + row as u8 * 40, 24 + col as u8 * 36, 210 - row as u8 * 30, 255, ]); for y in row * 100..(row + 1) * 100 { for x in col * 100..(col + 1) * 100 { sheet.put_pixel(x, y, color); } } } } let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(sheet) .write_to(&mut encoded, ImageFormat::Png) .expect("sheet should encode"); let image = DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let slices = slice_generated_asset_sheet(&image, &item_names, 5).expect("sheet should slice"); assert_eq!(slices.len(), 2); assert_eq!(slices[0].len(), 5); assert_eq!(slices[1].len(), 5); } #[test] fn generated_asset_sheet_prepare_put_request_packs_prompt_metadata() { let request = prepare_generated_asset_sheet_put_request(GeneratedAssetSheetPersistInput { prefix: LegacyAssetPrefix::Match3DAssets, owner_user_id: "user-1".to_string(), session_id: "session-1".to_string(), profile_id: "profile-1".to_string(), path_segments: vec!["items".to_string(), "view".to_string()], file_name: "view-01.png".to_string(), content_type: "image/png".to_string(), bytes: b"sheet-bytes".to_vec(), asset_kind: "match3d_item_image_view".to_string(), source_job_id: Some("task-1".to_string()), generated_at_micros: 123, grid_size: 5, row_index: 1, view_index: 2, prompt: GeneratedAssetSheetPersistPrompt { sheet_prompt: Some("sheet prompt".to_string()), item_name_prompt: Some("item prompt".to_string()), special_prompt: Some("special prompt".to_string()), }, }) .expect("request should prepare"); assert_eq!( request .metadata .get("x-oss-meta-generated-asset-sheet-prompt-encoding"), Some(&"utf8-base64".to_string()) ); assert_eq!( request .metadata .get("x-oss-meta-generated-asset-sheet-grid-size"), Some(&"5".to_string()) ); assert_eq!( request .metadata .get("x-oss-meta-generated-asset-sheet-row-index"), Some(&"1".to_string()) ); assert_eq!( request .metadata .get("x-oss-meta-generated-asset-sheet-view-index"), Some(&"2".to_string()) ); assert_eq!( request .metadata .get("x-oss-meta-generated-asset-sheet-prompt-b64"), Some(&BASE64_STANDARD.encode("sheet prompt")) ); assert_eq!( request .metadata .get("x-oss-meta-generated-asset-sheet-item-name-prompt-b64"), Some(&BASE64_STANDARD.encode("item prompt")) ); assert_eq!( request .metadata .get("x-oss-meta-generated-asset-sheet-special-prompt-b64"), Some(&BASE64_STANDARD.encode("special prompt")) ); } }