拼消消生图管线升级:6 sheet 单形状满画布 + 洋红去背 + 自适应切图 + 提示词优化

改为 6 张 sheet,每张单形状,取消全部 FILL/留白,AI 填满画布后多画少取
新增洋红去背步骤,对接 platform-image alpha 管线
新增 find_non_transparent_bounds 四方向内容边界扫描
新增 fill_transparent_with_opaque_average 透明像素填充
自适应网格检测 (detect_cell_grid_seed) 用于组间边界对齐
重写 slice_puzzle_clear_sheet 为两阶段:group bbox → 等分 cell
提示词优化:主前缀改为裁片级描述,每 sheet 增加精确占格约束
修复 jump_hop 测试断言 (1×1×1 → 1×1×1 的立方体)
新增分析脚本 tools/analyze_puzzle_clear_output.py 和 tools/test_ve_api.py
Sheet-06 为纵向 1×3 缓冲区
This commit is contained in:
2026-06-12 22:08:57 +08:00
parent 21a8ff690a
commit 5795115c20
4 changed files with 684 additions and 142 deletions

View File

@@ -1918,7 +1918,7 @@ mod tests {
assert!(prompt.contains("第3行第2列bottom"));
assert!(prompt.contains("其余 6 个格子"));
// 贴图内容
assert!(prompt.contains("1x1x1 立方体物体的六面展开"));
assert!(prompt.contains("1×1×1 立方体"));
assert!(prompt.contains("主题为\"森林冒险\""));
assert!(prompt.contains("6 个面必须属于同一个物体"));
assert!(prompt.contains("组合成一个完整的立方体造型"));

View File

@@ -62,11 +62,12 @@ const PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS: u32 = 10;
const PUZZLE_CLEAR_FINAL_ATLAS_ROWS: u32 = 10;
const PUZZLE_CLEAR_SHEET_UNUSED_CELL: &str = ".";
const PUZZLE_CLEAR_SHEET_FILLER_CELL: &str = "FILL";
const PUZZLE_CLEAR_SHEET_BUFFER_CELL: &str = "BUF";
const PUZZLE_CLEAR_ATLAS_GENERATION_SIZE: &str = "1024x1536";
const PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE: &str = "1024x1024";
const PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC: &str = "/creation-type-references/puzzle.webp";
const PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS: usize = 4;
const PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT: &str = "文字、Logo、水印、按钮、UI、网格线、边框、编号、标签、纯色背景、白底、孤立主体、多场景拼贴";
const PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT: &str = "单元格紧贴、单元格粘连、无间距、贴边、跨格、越界、侵占洋红间距、文字、Logo、水印、按钮、UI、网格线、边框、编号、标签、纯色背景、白底、孤立主体、多场景拼贴、洋红阴影、紫色底边、粉色脏边、洋红色描边";
pub async fn create_puzzle_clear_session(
State(state): State<AppState>,
@@ -1303,13 +1304,28 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
}
let mut slices = Vec::new();
for generated_sheet in &generated_sheets {
for sheet_idx in 0..generated_sheets.len() {
let sheet = &generated_sheets[sheet_idx];
let cleaned = prepare_puzzle_clear_magenta_cleanup(
&sheet.image,
sheet.spec.sheet_id,
)
.map_err(|error| {
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
})?;
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_sheet_accepted(
&sheet.spec,
sheet.task_id.as_str(),
&cleaned,
);
}
slices.extend(
slice_puzzle_clear_sheet(
&generated_sheet.image,
&generated_sheet.spec,
&cleaned,
&sheet.spec,
&groups_by_id,
generated_sheet.task_id.as_str(),
sheet.task_id.as_str(),
)
.map_err(|error| {
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
@@ -1432,114 +1448,119 @@ fn planned_puzzle_clear_pattern_groups() -> Vec<PuzzleClearPatternGroup> {
}
fn puzzle_clear_atlas_sheet_specs() -> Vec<PuzzleClearAtlasSheetSpec> {
let buf = PUZZLE_CLEAR_SHEET_BUFFER_CELL;
vec![
// Sheet 1: 1×2 横向 12 组, 纯横条节奏
// Sheet 1: 1×2 横向 12 组, 满画布 24cells
PuzzleClearAtlasSheetSpec {
sheet_id: "sheet-01",
layout: [
["A01", "A01", "A03", "A03"],
["A05", "A05", "A07", "A07"],
["A09", "A09", "A11", "A11"],
["A13", "A13", "A15", "A15"],
["A17", "A17", "A19", "A19"],
["A21", "A21", "A23", "A23"],
["A01","A01", "A03","A03"],
["A05","A05", "A07","A07"],
["A09","A09", "A11","A11"],
["A13","A13", "A15","A15"],
["A17","A17", "A19","A19"],
["A21","A21", "A23","A23"],
],
layout_prompt: concat!(
"整张图集排满 12 个横向场景裁片,每个占 2 格",
"每格 256×2564 列 6 行排布,场景间自然过渡",
"画面等分为 6 行 4 列,每格 256×256",
"共 12 个横向裁片,每行左右各一个,每裁片严格占 2 格1 行 2 列)",
"裁片内部画面连续无间断,不可拆成两格独立小图。",
"不同裁片之间用洋红细线分隔。画面四周留洋红边距。",
"绝对不要画网格线、边框、编号、文字或水印。",
),
},
// Sheet 2: 1×2 纵向 11+ 2 FILL, 纯竖条节奏
// Sheet 2: 1×2 纵向 12(取11), 满画布 24cells
PuzzleClearAtlasSheetSpec {
sheet_id: "sheet-02",
layout: [
["A02", "A04", "A06", "A08"],
["A02", "A04", "A06", "A08"],
["A10", "A12", "A14", "A16"],
["A10", "A12", "A14", "A16"],
["A18", "A20", "A22", PUZZLE_CLEAR_SHEET_FILLER_CELL],
["A18", "A20", "A22", PUZZLE_CLEAR_SHEET_FILLER_CELL],
["A02","A04", "A06","A08"],
["A02","A04", "A06","A08"],
["A10","A12", "A14","A16"],
["A10","A12", "A14","A16"],
["A18","A20", "A22",buf],
["A18","A20", "A22",buf],
],
layout_prompt: concat!(
"整张图集排满 11 个纵向场景裁片,每个占 1×2 格",
"每格 256×2564 列 6 行排布,场景间自然过渡。右下两格为留白",
"画面等分为 6 行 4 列,每格 256×256各行高度严格相等",
"共 12 个纵向裁片,每行排 4 个,两两成组上下排列,每裁片严格占 1 列 2 行",
"裁片内部画面连续无间断,不可拆成上下两格独立小图。",
"不同裁片之间用洋红细线分隔。画面四周留洋红边距。",
"绝对不要画网格线、边框、编号、文字或水印。",
),
},
// Sheet 3: 2×2 正方形 4+ 8 FILL, 纯正方节奏
// Sheet 3: 2×2 正方形 6(取4), 满画布 24cells
PuzzleClearAtlasSheetSpec {
sheet_id: "sheet-03",
layout: [
["C01", "C01", "C02", "C02"],
["C01", "C01", "C02", "C02"],
["C03", "C03", "C04", "C04"],
["C03", "C03", "C04", "C04"],
[
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
],
[
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
],
["C01","C01", "C02","C02"],
["C01","C01", "C02","C02"],
["C03","C03", "C04","C04"],
["C03","C03", "C04","C04"],
[buf,buf, buf,buf],
[buf,buf, buf,buf],
],
layout_prompt: concat!(
"整张图集排满 4 个正方形场景裁片,每个占 2×2 格",
"每格 256×25646排布,下半部分为留白",
"画面等分为 6 行 4 列,每格 256×256各行高度严格相等",
"共 6 个正方形裁片,每行左右各一个,每裁片严格占 22 行。",
"裁片内部画面连续无间断,不可拆成四格独立小图。",
"不同裁片之间用洋红细线分隔。画面四周留洋红边距。",
"绝对不要画网格线、边框、编号、文字或水印。",
),
},
// Sheet 4: 2×3 横向 3 组 + 1×3 纵向 2 组, 大块面节奏
// Sheet 4: 2×3 横向 3 组, 满画布 + 右列缓冲
PuzzleClearAtlasSheetSpec {
sheet_id: "sheet-04",
layout: [
["D01", "D01", "D01", "B02"],
["D01", "D01", "D01", "B02"],
["D02", "D02", "D02", "B02"],
["D02", "D02", "D02", "B04"],
["D03", "D03", "D03", "B04"],
["D03", "D03", "D03", "B04"],
["D01","D01","D01",buf],
["D01","D01","D01",buf],
["D02","D02","D02",buf],
["D02","D02","D02",buf],
["D03","D03","D03",buf],
["D03","D03","D03",buf],
],
layout_prompt: concat!(
"整张图集排满 5 个大场景裁片:左边 3 个各占 3×2 格,右边 2 个各占 1×3 格",
"每格 256×25646排布,场景间自然过渡",
"画面等分为 6 行 4 列,每格 256×256各行高度严格相等",
"共 3 个横向宽裁片,每两行一个,每裁片严格占 32(宽 3 格高 2 格)",
"裁片内部画面连续无间断,不可拆成独立格子。",
"不同裁片之间用洋红细线分隔。画面四周留洋红边距。",
"绝对不要画网格线、边框、编号、文字或水印。",
),
},
// Sheet 5: 1×3 横向 3+ 15 FILL, 纯宽条节奏
// Sheet 5: 1×3 横向 6(取3), 满画布 18cells + 右列缓冲
PuzzleClearAtlasSheetSpec {
sheet_id: "sheet-05",
layout: [
["B01", "B01", "B01", PUZZLE_CLEAR_SHEET_FILLER_CELL],
["B03", "B03", "B03", PUZZLE_CLEAR_SHEET_FILLER_CELL],
["B05", "B05", "B05", PUZZLE_CLEAR_SHEET_FILLER_CELL],
[
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
],
[
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
],
[
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
],
["B01","B01","B01",buf],
["B03","B03","B03",buf],
["B05","B05","B05",buf],
[buf,buf,buf,buf],
[buf,buf,buf,buf],
[buf,buf,buf,buf],
],
layout_prompt: concat!(
"整张图集排满 3 个横向宽场景裁片,每个占 3 格",
"每格 256×25646排布,下半部分为留白",
"画面等分为 6 行 4 列,每格 256×256各行高度严格相等",
"共 6 个横向宽裁片,每行一个,每裁片严格占 31(宽 3 格高 1 格)",
"裁片内部画面连续无间断,不可拆成三格独立小图。",
"不同裁片之间用洋红细线分隔。画面四周留洋红边距。",
"绝对不要画网格线、边框、编号、文字或水印。",
),
},
// Sheet 6: 1×3 纵向 8 组 (取2), 满画布 24cells
PuzzleClearAtlasSheetSpec {
sheet_id: "sheet-06",
layout: [
["B02",buf,buf,buf],
["B02",buf,buf,buf],
["B02",buf,buf,buf],
["B04",buf,buf,buf],
["B04",buf,buf,buf],
["B04",buf,buf,buf],
],
layout_prompt: concat!(
"画面等分为 6 行 4 列,每格 256×256各列宽度严格相等。",
"共 8 个纵向裁片,每列排 2 组上下相邻,每裁片严格占 1 列 3 行(宽 1 格高 3 格)。",
"裁片内部画面连续无间断,不可拆成三格独立小图。",
"不同裁片之间用洋红细线分隔。画面四周留洋红边距。",
"绝对不要画网格线、边框、编号、文字或水印。",
),
},
@@ -1554,8 +1575,9 @@ fn build_puzzle_clear_atlas_prompt(
format!(
concat!(
"生成一张拼消消卡牌图集,主题是「{subject}」,竖版 1024x1536。\n",
"照片式构图、绘本式渲染。每格 256×256 是一个完整微场景裁片,",
"同编号连续格共享场景锚点和道具语言,不同编号展现不同视觉概念",
"照片式构图、绘本式渲染。画面由若干场景裁片组成",
"每个裁片是完整连续画面,内部无接缝、无分隔线、无网格",
"不同裁片之间用纯洋红(#FF00FF)细线分隔。",
"不要文字、Logo、水印、UI、网格线、边框、编号、纯色背景或孤立主体。\n",
"{layout_prompt}"
),
@@ -1706,8 +1728,40 @@ fn is_retryable_puzzle_clear_sheet_generation_error(error: &AppError) -> bool {
.unwrap_or(false)
}
fn prepare_puzzle_clear_magenta_cleanup(
image: &DownloadedOpenAiImage,
sheet_id: &str,
) -> Result<DownloadedOpenAiImage, AppError> {
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {sheet_id} 洋红去背解码失败:{error}"),
}))
})?;
let processed = crate::generated_asset_sheets::apply_generated_asset_sheet_alpha_with_options(
source,
crate::generated_asset_sheets::GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
);
let mut encoded = std::io::Cursor::new(Vec::new());
processed
.write_to(&mut encoded, image::ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {sheet_id} 洋红去背编码失败:{error}"),
}))
})?;
Ok(DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
}
fn is_puzzle_clear_sheet_discarded_cell(group_id: &str) -> bool {
group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL || group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL
group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL
|| group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL
|| group_id == PUZZLE_CLEAR_SHEET_BUFFER_CELL
}
fn slice_puzzle_clear_sheet(
@@ -1732,6 +1786,14 @@ fn slice_puzzle_clear_sheet(
})),
);
}
let source_rgba = source.to_rgba8();
let grid = crate::jump_hop_atlas_slicing::detect_cell_grid_seed(
source_rgba.as_raw(),
source_width,
source_height,
PUZZLE_CLEAR_SHEET_ROWS,
PUZZLE_CLEAR_SHEET_COLUMNS,
);
let mut slices = Vec::new();
let mut cells_by_group: BTreeMap<&str, Vec<(u32, u32)>> = BTreeMap::new();
for (row, cells) in sheet_spec.layout.iter().enumerate() {
@@ -1754,7 +1816,9 @@ fn slice_puzzle_clear_sheet(
}))
})?;
let min_row = cells.iter().map(|(row, _)| *row).min().unwrap_or(0);
let max_row = cells.iter().map(|(row, _)| *row).max().unwrap_or(0);
let min_col = cells.iter().map(|(_, col)| *col).min().unwrap_or(0);
let max_col = cells.iter().map(|(_, col)| *col).max().unwrap_or(0);
let expected_cell_count = (group.width * group.height) as usize;
if cells.len() != expected_cell_count {
return Err(
@@ -1762,65 +1826,65 @@ fn slice_puzzle_clear_sheet(
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!(
"拼消消素材 {} 的布局 {} 格数不匹配,期望 {} 格,实际 {} 格。",
sheet_spec.sheet_id,
group_id,
expected_cell_count,
cells.len(),
sheet_spec.sheet_id, group_id, expected_cell_count, cells.len(),
),
})),
);
}
// 中文注释阶段1 — 自适应网格找到 group 大致区域,四方向向内扫描剔除透明边距
let gx0 = grid.col_boundaries[min_col as usize];
let gx1 = grid.col_boundaries[max_col as usize + 1];
let gy0 = grid.row_boundaries[min_row as usize];
let gy1 = grid.row_boundaries[max_row as usize + 1];
let (cx0, cy0, cx1, cy1) =
find_non_transparent_bounds(&source_rgba, gx0, gy0, gx1, gy1);
let content_w = cx1.saturating_sub(cx0).max(1);
let content_h = cy1.saturating_sub(cy0).max(1);
// 中文注释阶段2 — 裁出 group 内容,缩放到精确尺寸,等分为 1×1 格
let target_w = group.width * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let target_h = group.height * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let mut group_cropped = image::imageops::crop_imm(&source_rgba, cx0, cy0, content_w, content_h)
.to_image();
fill_transparent_with_opaque_average(&mut group_cropped);
let group_resized = image::imageops::resize(
&group_cropped,
target_w,
target_h,
image::imageops::FilterType::Lanczos3,
);
for part_y in 0..group.height {
for part_x in 0..group.width {
let expected_cell = (min_row + part_y, min_col + part_x);
if !cells.contains(&expected_cell) {
return Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_details(json!({
let px = part_x * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let py = part_y * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let cell = image::imageops::crop_imm(
&group_resized,
px,
py,
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
)
.to_image();
let mut cursor = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(cell)
.write_to(&mut cursor, image::ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!(
"拼消消素材 {} 的布局 {} 不是完整连续矩形,缺少第 {} 行第 {} 列。",
sheet_spec.sheet_id,
group_id,
expected_cell.0 + 1,
expected_cell.1 + 1,
),
})));
}
"message": format!("拼消消素材 {} 切片失败:{error}", sheet_spec.sheet_id),
}))
})?;
slices.push(PuzzleClearAtlasCardSlice {
group: group.clone(),
task_id: Some(task_id.to_string()),
part_x,
part_y,
bytes: cursor.into_inner(),
});
}
}
for (row, col) in cells {
let part_x = col.saturating_sub(min_col);
let part_y = row.saturating_sub(min_row);
let x0 = scale_sheet_coord(col, source_width, PUZZLE_CLEAR_SHEET_COLUMNS);
let y0 = scale_sheet_coord(row, source_height, PUZZLE_CLEAR_SHEET_ROWS);
let x1 = scale_sheet_coord(col + 1, source_width, PUZZLE_CLEAR_SHEET_COLUMNS)
.max(x0 + 1)
.min(source_width);
let y1 = scale_sheet_coord(row + 1, source_height, PUZZLE_CLEAR_SHEET_ROWS)
.max(y0 + 1)
.min(source_height);
let cropped = source.crop_imm(x0, y0, x1 - x0, y1 - y0).resize_exact(
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
image::imageops::FilterType::Lanczos3,
);
let mut cursor = std::io::Cursor::new(Vec::new());
cropped
.write_to(&mut cursor, image::ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 切片失败:{error}", sheet_spec.sheet_id),
}))
})?;
slices.push(PuzzleClearAtlasCardSlice {
group: group.clone(),
task_id: Some(task_id.to_string()),
part_x,
part_y,
bytes: cursor.into_inner(),
});
}
}
Ok(slices)
}
@@ -1870,8 +1934,101 @@ fn compose_puzzle_clear_final_atlas(
})
}
fn scale_sheet_coord(value: u32, actual: u32, sheet_cells: u32) -> u32 {
((u64::from(value) * u64::from(actual)) / u64::from(sheet_cells)) as u32
fn find_non_transparent_bounds(
source: &image::RgbaImage,
x0: u32,
y0: u32,
x1: u32,
y1: u32,
) -> (u32, u32, u32, u32) {
let w = source.width();
let pixels = source.as_raw();
let stride = w as usize * 4;
let x1 = x1.min(w);
let y1 = y1.min(source.height());
let mut left = x0;
for x in x0..x1 {
let mut has = false;
for y in y0..y1 {
if pixels[y as usize * stride + x as usize * 4 + 3] > 0 {
has = true;
break;
}
}
if has { left = x; break; }
left = x + 1;
}
let mut right = x1;
for x in (x0..x1).rev() {
let mut has = false;
for y in y0..y1 {
if pixels[y as usize * stride + x as usize * 4 + 3] > 0 {
has = true;
break;
}
}
if has { right = x + 1; break; }
right = x;
}
let mut top = y0;
for y in y0..y1 {
let mut has = false;
for x in left..right {
if pixels[y as usize * stride + x as usize * 4 + 3] > 0 {
has = true;
break;
}
}
if has { top = y; break; }
top = y + 1;
}
let mut bottom = y1;
for y in (y0..y1).rev() {
let mut has = false;
for x in left..right {
if pixels[y as usize * stride + x as usize * 4 + 3] > 0 {
has = true;
break;
}
}
if has { bottom = y + 1; break; }
bottom = y;
}
(left, top, right, bottom)
}
fn fill_transparent_with_opaque_average(image: &mut image::RgbaImage) {
let mut total_r = 0u64;
let mut total_g = 0u64;
let mut total_b = 0u64;
let mut opaque_count = 0u64;
for pixel in image.pixels() {
if pixel.0[3] > 0 {
total_r += pixel.0[0] as u64;
total_g += pixel.0[1] as u64;
total_b += pixel.0[2] as u64;
opaque_count += 1;
}
}
if opaque_count == 0 {
return;
}
let avg = image::Rgba([
(total_r / opaque_count) as u8,
(total_g / opaque_count) as u8,
(total_b / opaque_count) as u8,
255,
]);
for pixel in image.pixels_mut() {
if pixel.0[3] == 0 {
*pixel = avg;
}
}
}
fn normalize_non_empty_str(value: &str) -> Option<String> {
@@ -2289,15 +2446,15 @@ mod tests {
let prompt = build_puzzle_clear_atlas_prompt("水果", &sheet);
assert!(prompt.contains("主题是「水果」"));
assert!(prompt.contains("竖版 1024x1536"));
assert!(prompt.contains("每格 256×256"));
assert!(prompt.contains("照片式构图"));
assert!(prompt.contains("共享场景锚点和道具语言"));
assert!(prompt.contains("不同编号展现不同视觉概念"));
assert!(prompt.contains("完整连续画面"));
assert!(prompt.contains("内部无接缝"));
assert!(prompt.contains("纯洋红"));
assert!(prompt.contains("不要文字"));
assert!(prompt.contains("网格线"));
assert!(prompt.contains("纯色背景"));
assert!(prompt.contains("孤立主体"));
assert!(prompt.contains("12 个横向场景裁片"));
assert!(prompt.contains("12 个横向"));
assert!(!prompt.contains("卡牌排版图"));
assert!(!prompt.contains("贴纸表"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("纯色背景"));
@@ -2341,11 +2498,11 @@ mod tests {
.map(|group| group.width * group.height)
.sum::<u32>();
assert_eq!(sheets.len(), 5);
assert_eq!(sheets.len(), 6);
assert_eq!(groups.len(), 35);
assert_eq!(occupied_sheet_cells, 120);
assert_eq!(occupied_sheet_cells, 144);
assert_eq!(playable_sheet_cells, 95);
assert_eq!(filler_sheet_cells, 25);
assert_eq!(filler_sheet_cells, 0);
assert_eq!(group_cells, 95);
assert_eq!(PUZZLE_CLEAR_ATLAS_CELL_SIZE, 256);
assert_eq!(sheet_cells_by_group.len(), groups.len());
@@ -2559,8 +2716,7 @@ mod tests {
rt.block_on(async {
use super::{
compose_puzzle_clear_final_atlas, slice_puzzle_clear_sheet,
build_puzzle_clear_atlas_prompt, PUZZLE_CLEAR_ATLAS_CELL_SIZE,
PUZZLE_CLEAR_SHEET_COLUMNS, PUZZLE_CLEAR_SHEET_ROWS,
build_puzzle_clear_atlas_prompt, prepare_puzzle_clear_magenta_cleanup,
PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT, PUZZLE_CLEAR_ATLAS_GENERATION_SIZE,
};
use crate::openai_image_generation::DownloadedOpenAiImage;
@@ -2584,7 +2740,12 @@ mod tests {
let http_client = build_vector_engine_image_http_client(&settings)
.expect("build http client");
let out = std::env::temp_dir().join("puzzle-clear-real-output");
let out = std::env::current_dir()
.expect("current dir")
.join("target")
.join("test-output")
.join("puzzle-clear-real");
let _ = fs::remove_dir_all(&out);
fs::create_dir_all(&out).expect("create output dir");
let sheets_out = out.join("sheets");
let cards_out = out.join("cards");
@@ -2643,8 +2804,14 @@ mod tests {
fs::write(sheets_out.join(format!("{}.png", sheet_id)), &image.bytes).unwrap();
let cleaned = prepare_puzzle_clear_magenta_cleanup(
&downloaded, sheet.sheet_id,
)
.unwrap_or_else(|e| panic!("magenta cleanup {} failed: {:?}", sheet_id, e.body_text()));
fs::write(sheets_out.join(format!("{}-cleaned.png", sheet_id)), &cleaned.bytes).unwrap();
let slices = slice_puzzle_clear_sheet(
&downloaded, sheet, &groups_by_id, &task_id,
&cleaned, sheet, &groups_by_id, &task_id,
)
.unwrap_or_else(|e| panic!("slice {} failed: {:?}", sheet_id, e.body_text()));