Files
Genarrative/server-rs/crates/api-server/src/generated_asset_sheets.rs
高物 3931442249 Enforce Genarrative play-type SOP and update docs
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.
2026-05-20 12:12:00 +08:00

1666 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![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"))
);
}
}