refactor: extract platform media crates

This commit is contained in:
kdletters
2026-05-26 13:18:13 +08:00
parent 50f44489cd
commit 44c65df5c9
92 changed files with 7381 additions and 5848 deletions

View File

@@ -0,0 +1,429 @@
use super::color::{
GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE, GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE,
GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE, clamp_generated_asset_sheet_unit,
compute_generated_asset_sheet_green_screen_score,
compute_generated_asset_sheet_white_screen_score,
is_generated_asset_sheet_soft_green_matte_pixel, lerp_generated_asset_sheet_channel,
touches_generated_asset_sheet_background_mask,
};
pub 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 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,
))
}

View File

@@ -0,0 +1,162 @@
pub(super) const GENERATED_ASSET_SHEET_FOREGROUND_DIFF_THRESHOLD: i32 = 36;
pub(super) const GENERATED_ASSET_SHEET_FOREGROUND_ALPHA_THRESHOLD: i32 = 36;
pub(super) const GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE: f32 = 0.34;
pub(super) const GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE: f32 = 0.18;
pub(super) const GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE: f32 = 0.82;
pub(super) fn clamp_generated_asset_sheet_unit(value: f32) -> f32 {
value.clamp(0.0, 1.0)
}
pub(super) fn lerp_generated_asset_sheet_channel(from: f32, to: f32, t: f32) -> f32 {
from + (to - from) * clamp_generated_asset_sheet_unit(t)
}
pub(super) 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
}
pub(super) 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
}
pub(super) fn is_generated_asset_sheet_visible_pixel(pixel: [u8; 4]) -> bool {
pixel[3] > 0 && (pixel[0] > 8 || pixel[1] > 8 || pixel[2] > 8)
}
pub(super) 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)
}
pub(super) 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
}
pub(super) 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
}
pub(super) 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
}
pub(super) 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)
}
pub(super) 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)
}
pub(super) 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))
}

View File

@@ -0,0 +1,62 @@
use std::{error::Error, fmt};
use platform_oss::OssError;
pub const GENERATED_ASSET_SHEET_PROVIDER: &str = "generated-asset-sheets";
#[derive(Debug)]
pub enum GeneratedAssetSheetError {
InvalidRequest { message: String },
DecodeImage { message: String },
EncodeImage { message: String },
BuildHttpClient { message: String },
Oss(OssError),
}
impl GeneratedAssetSheetError {
pub fn provider(&self) -> &'static str {
GENERATED_ASSET_SHEET_PROVIDER
}
pub fn message(&self) -> String {
match self {
Self::InvalidRequest { message }
| Self::DecodeImage { message }
| Self::EncodeImage { message }
| Self::BuildHttpClient { message } => message.clone(),
Self::Oss(error) => error.to_string(),
}
}
pub fn invalid_request(message: impl Into<String>) -> Self {
Self::InvalidRequest {
message: message.into(),
}
}
pub fn decode_image(message: impl Into<String>) -> Self {
Self::DecodeImage {
message: message.into(),
}
}
pub fn encode_image(message: impl Into<String>) -> Self {
Self::EncodeImage {
message: message.into(),
}
}
pub fn build_http_client(message: impl Into<String>) -> Self {
Self::BuildHttpClient {
message: message.into(),
}
}
}
impl fmt::Display for GeneratedAssetSheetError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.message().as_str())
}
}
impl Error for GeneratedAssetSheetError {}

View File

@@ -0,0 +1,18 @@
pub mod alpha;
mod color;
pub mod error;
pub mod persist;
pub mod prompt;
pub mod sheet;
pub use alpha::apply_generated_asset_sheet_green_screen_alpha;
pub use error::GeneratedAssetSheetError;
pub use persist::{
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetUpload,
persist_generated_asset_sheet_bytes, prepare_generated_asset_sheet_put_request,
};
pub use prompt::{GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt};
pub use sheet::{
GeneratedAssetSheetSliceImage, crop_generated_asset_sheet_view_edge_matte,
slice_generated_asset_sheet, slice_generated_asset_sheet_two_items_per_row,
};

View File

@@ -0,0 +1,203 @@
use std::{collections::BTreeMap, time::Duration};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use platform_oss::{LegacyAssetPrefix, OssClient, OssObjectAccess, OssPutObjectRequest};
use super::error::GeneratedAssetSheetError;
const GENERATED_ASSET_SHEET_OSS_PUT_TIMEOUT_MS: u64 = 3 * 60_000;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedAssetSheetUpload {
pub src: String,
pub object_key: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GeneratedAssetSheetPersistPrompt {
pub sheet_prompt: Option<String>,
pub item_name_prompt: Option<String>,
pub special_prompt: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedAssetSheetPersistInput {
pub prefix: LegacyAssetPrefix,
pub owner_user_id: String,
pub session_id: String,
pub profile_id: String,
pub path_segments: Vec<String>,
pub file_name: String,
pub content_type: String,
pub bytes: Vec<u8>,
pub asset_kind: String,
pub source_job_id: Option<String>,
pub generated_at_micros: i64,
pub grid_size: usize,
pub row_index: usize,
pub view_index: usize,
pub prompt: GeneratedAssetSheetPersistPrompt,
}
pub fn prepare_generated_asset_sheet_put_request(
input: GeneratedAssetSheetPersistInput,
) -> Result<OssPutObjectRequest, GeneratedAssetSheetError> {
if input.grid_size == 0 {
return Err(GeneratedAssetSheetError::invalid_request(
"系列素材图集的 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(GeneratedAssetSheetError::invalid_request(format!(
"系列素材图集持久化的行列索引必须落在 n*n 范围内。gridSize={}, rowIndex={}, viewIndex={}",
input.grid_size, input.row_index, 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 async fn persist_generated_asset_sheet_bytes(
oss_client: &OssClient,
input: GeneratedAssetSheetPersistInput,
) -> Result<GeneratedAssetSheetUpload, GeneratedAssetSheetError> {
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| {
GeneratedAssetSheetError::build_http_client(format!(
"构造系列素材图集 OSS 上传客户端失败:{error}"
))
})?;
let put_result = oss_client
.put_object(&oss_http_client, put_request)
.await
.map_err(GeneratedAssetSheetError::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()),
);
}
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()
}
}

View File

@@ -0,0 +1,65 @@
use super::error::GeneratedAssetSheetError;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedAssetSheetPromptInput<'a> {
pub subject_text: &'a str,
pub item_names: &'a [String],
pub grid_size: usize,
pub item_name_prompt_template: Option<&'a str>,
pub special_prompt: Option<&'a str>,
}
pub fn build_generated_asset_sheet_prompt(
input: &GeneratedAssetSheetPromptInput<'_>,
) -> Result<String, GeneratedAssetSheetError> {
let grid_size = input.grid_size;
if grid_size == 0 {
return Err(GeneratedAssetSheetError::invalid_request(
"系列素材图集的 n 必须大于 0。",
));
}
if input.item_names.len() > grid_size {
return Err(GeneratedAssetSheetError::invalid_request(format!(
"系列素材图集的物品行数不能超过 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、边框、网格线、标签、底座、场景或其他物体。"
))
}

View File

@@ -0,0 +1,672 @@
use super::alpha::apply_generated_asset_sheet_green_screen_alpha;
use super::color::{
is_generated_asset_sheet_foreground_pixel,
is_generated_asset_sheet_green_contaminated_edge_pixel,
is_generated_asset_sheet_soft_edge_pixel, is_generated_asset_sheet_strong_green_contamination,
is_generated_asset_sheet_view_background_pixel, is_generated_asset_sheet_visible_pixel,
touches_generated_asset_sheet_background_mask,
};
use super::error::GeneratedAssetSheetError;
use image::{GenericImageView, ImageFormat};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedAssetSheetSliceImage {
pub bytes: Vec<u8>,
}
pub fn slice_generated_asset_sheet(
image: &crate::DownloadedImage,
item_names: &[String],
grid_size: usize,
) -> Result<Vec<Vec<GeneratedAssetSheetSliceImage>>, GeneratedAssetSheetError> {
if grid_size == 0 {
return Err(GeneratedAssetSheetError::invalid_request(
"系列素材图集的 n 必须大于 0。",
));
}
let grid_size_u32 = u32::try_from(grid_size).map_err(|_| {
GeneratedAssetSheetError::invalid_request("系列素材图集的 n 超出可支持范围。")
})?;
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
GeneratedAssetSheetError::decode_image(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(GeneratedAssetSheetError::invalid_request(
"系列素材图集尺寸过小,无法切割。",
));
}
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| {
GeneratedAssetSheetError::encode_image(format!("系列素材图集切割失败:{error}"))
})?;
views.push(GeneratedAssetSheetSliceImage {
bytes: cursor.into_inner(),
});
}
slices.push(views);
}
Ok(slices)
}
pub fn slice_generated_asset_sheet_two_items_per_row(
image: &crate::DownloadedImage,
item_names: &[String],
grid_size: usize,
views_per_item: usize,
) -> Result<Vec<Vec<GeneratedAssetSheetSliceImage>>, GeneratedAssetSheetError> {
if grid_size == 0 || views_per_item == 0 {
return Err(GeneratedAssetSheetError::invalid_request(
"系列素材图集的 n 和每物品视图数必须大于 0。",
));
}
if !grid_size.is_multiple_of(views_per_item) {
return Err(GeneratedAssetSheetError::invalid_request(format!(
"系列素材图集每行必须能均分为若干物品。gridSize={}, viewsPerItem={}",
grid_size, views_per_item
)));
}
let grid_size_u32 = u32::try_from(grid_size).map_err(|_| {
GeneratedAssetSheetError::invalid_request("系列素材图集的 n 超出可支持范围。")
})?;
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
GeneratedAssetSheetError::decode_image(format!("系列素材图集解码失败:{error}"))
})?;
let source = apply_generated_asset_sheet_green_screen_alpha(source);
let (width, height) = source.dimensions();
if width / grid_size_u32 == 0 || height / grid_size_u32 == 0 {
return Err(GeneratedAssetSheetError::invalid_request(
"系列素材图集尺寸过小,无法切割。",
));
}
let items_per_row = grid_size / views_per_item;
let max_item_count = grid_size.saturating_mul(items_per_row);
let mut slices = Vec::with_capacity(item_names.len().min(max_item_count));
for item_index in 0..item_names.len().min(max_item_count) {
let row = (item_index / items_per_row) as u32;
let start_col = ((item_index % items_per_row) * views_per_item) as u32;
let mut views = Vec::with_capacity(views_per_item);
for view_offset in 0..views_per_item {
let col = start_col + view_offset 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| {
GeneratedAssetSheetError::encode_image(format!("系列素材图集切割失败:{error}"))
})?;
views.push(GeneratedAssetSheetSliceImage {
bytes: cursor.into_inner(),
});
}
slices.push(views);
}
Ok(slices)
}
pub 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(),
)
}
#[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 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 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,
))
}