Rewrite Genarrative play-type integration guidance across .codex and .hermes to define a platform-level SOP: default to form/image workbench, unify single-image asset slots (CreativeImageInputPanel), standardize series-material sheet->cut->transparent->OSS pipeline, and forbid copying legacy chat/agent workflows as the default. Add decision-log entry freezing the SOP and a pitfalls note warning against direct reuse of old play tools. Update CONTEXT.md and docs/README.md, add a new PRD file, and apply related small server-side changes (module-auth, spacetime-client mappers and runtime) to align back-end code with the new contracts and flows.
1666 lines
58 KiB
Rust
1666 lines
58 KiB
Rust
#![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<u8>,
|
||
}
|
||
|
||
#[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<String>,
|
||
pub(crate) item_name_prompt: Option<String>,
|
||
pub(crate) special_prompt: Option<String>,
|
||
}
|
||
|
||
#[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<String>,
|
||
pub(crate) file_name: String,
|
||
pub(crate) content_type: String,
|
||
pub(crate) bytes: Vec<u8>,
|
||
pub(crate) asset_kind: String,
|
||
pub(crate) source_job_id: Option<String>,
|
||
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<String, AppError> {
|
||
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::<Vec<_>>()
|
||
.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<Vec<Vec<GeneratedAssetSheetSliceImage>>, 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<OssPutObjectRequest, AppError> {
|
||
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<GeneratedAssetSheetUpload, AppError> {
|
||
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<String, String>,
|
||
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<GeneratedAssetSheetCellBounds> {
|
||
let background = sample_generated_asset_sheet_cell_background(source, cell);
|
||
let mut foreground: Option<GeneratedAssetSheetCellBounds> = 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<GeneratedAssetSheetCellBounds> {
|
||
let (width, height) = image.dimensions();
|
||
let mut bounds: Option<GeneratedAssetSheetCellBounds> = 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::<usize>::new();
|
||
let mut queue_index = 0usize;
|
||
let mut transparent_pixel_count = 0usize;
|
||
for pixel_index in 0..pixel_count {
|
||
let offset = pixel_index * 4;
|
||
if pixels[offset + 3] == 0 {
|
||
background_mask[pixel_index] = 1;
|
||
queue.push(pixel_index);
|
||
transparent_pixel_count = transparent_pixel_count.saturating_add(1);
|
||
}
|
||
}
|
||
let has_transparent_background = transparent_pixel_count > pixel_count / 200;
|
||
|
||
// 中文注释:单图被前景边界收紧后,浅绿框可能正好贴在 PNG 外缘;
|
||
// 把外缘一段宽度作为去背种子,但只清理绿幕 / 近白 matte,避免误伤贴边主体。
|
||
let edge_width = resolve_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::<usize>::new();
|
||
let mut queue_index = 0usize;
|
||
|
||
for pixel_index in 0..pixel_count {
|
||
let offset = pixel_index * 4;
|
||
let red = pixels[offset];
|
||
let green = pixels[offset + 1];
|
||
let blue = pixels[offset + 2];
|
||
let alpha = pixels[offset + 3];
|
||
let green_score =
|
||
compute_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<usize>| {
|
||
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::<String>();
|
||
let collapsed = normalized
|
||
.split('-')
|
||
.filter(|part| !part.is_empty())
|
||
.collect::<Vec<_>>()
|
||
.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"))
|
||
);
|
||
}
|
||
}
|