fix: 提升拼消消素材生成质量门禁

This commit is contained in:
2026-06-04 22:32:46 +08:00
parent b9de2f2a43
commit 0c7fc0b26f
6 changed files with 724 additions and 49 deletions

View File

@@ -1832,6 +1832,17 @@
- 处理:只要移动后棋盘存在空位,就立即走补位和可解性修复;这样源位会从顶部准备区补卡,不会留下不可交互空洞。 - 处理:只要移动后棋盘存在空位,就立即走补位和可解性修复;这样源位会从顶部准备区补卡,不会留下不可交互空洞。
- 验证:`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts``cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml player_move_can_drop_card_into_empty_target_cell -- --nocapture` - 验证:`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts``cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml player_move_can_drop_card_into_empty_target_cell -- --nocapture`
- 关联:`src/services/puzzle-clear/puzzleClearLocalRuntime.ts``server-rs/crates/module-puzzle-clear/src/application.rs` - 关联:`src/services/puzzle-clear/puzzleClearLocalRuntime.ts``server-rs/crates/module-puzzle-clear/src/application.rs`
## 拼消消素材错位先查 sheet 质量门禁
- 现象:一张卡牌切片里同时出现两个或多个错位图案,或空白格、相邻编号区域里混入其他图案碎片。
- 原因provider 生成的 `1024x1536 / 4x6` 工作表可能违反视觉契约;旧流程只校验布局元数据和切片数量,无法发现图像内容已经主体缺失或污染空白格。边界贴边检测容易把正常铺满主体误判成跨格污染,不能作为高可靠硬门禁。
- 处理:先强化 atlas prompt要求每个 `256x256` 单元独立查看时只能包含一个主体或同一主体单一局部;服务端在 sheet 切片前做像素级质量门禁,硬拦截非空格前景占比过低和空白格污染,严重多边非同组边界贴边只记录 warning 供排查,不直接让创作失败。硬门禁失败的 sheet 最多尝试 4 次,仍失败则拒绝持久化脏 atlas。
- 追加处理:照片式微场景素材必须把每个 `256x256` 单元收束为一张完整的单场景照片裁片;同编号连续格只表示玩法分组,不要暗示 provider 生成横跨多格的大图或照片拼贴。禁止单格内部出现两张照片、两个不同场景、拼接线、内部竖切、内部横切或左右 / 上下两块不同背景;质量门禁需要扫描单格内部贯穿大部分高度或宽度的强色差直线,命中时按“单格内部疑似拼接线”硬失败并重试 sheet。
- 追加处理sheet 生成时如果 VectorEngine 返回 `retryable=true``502``504``429` 或请求超时,例如 nginx HTML `502 Bad Gateway`,不要立刻把草稿置为 failed应消耗同一 sheet 的下一次 attempt仍失败再回写失败状态。
- 验证:`cargo test -p api-server puzzle_clear --manifest-path server-rs/Cargo.toml -- --nocapture``cargo check -p api-server --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/api-server/src/puzzle_clear.rs``docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`
## 拼消消锁定组覆盖层必须锚定在棋盘本身 ## 拼消消锁定组覆盖层必须锚定在棋盘本身
- 现象:消除或补牌过程中,局部完成的组图偶尔会看起来从格子里“飘出去”,并且大小会随着窗口和外层面板变化而异常拉伸。 - 现象:消除或补牌过程中,局部完成的组图偶尔会看起来从格子里“飘出去”,并且大小会随着窗口和外层面板变化而异常拉伸。

View File

@@ -46,9 +46,16 @@
验证策略: 验证策略:
- 生图 prompt 明确禁止文字、水印、UI、边框标签、切分线、网格线、裁切参考线和跨格主体 - 生图 prompt 明确要求照片式构图 / 绘本式渲染的主题微场景拼图卡,每个 256x256 单元本身就是一张完整的单场景照片裁片,单元内部只能有一个连续画面,禁止出现两张照片、两个不同场景、拼接线、分割线、内部竖切、内部横切、左右 / 上下两块不同背景,场景变化只能发生在 256 单元边界上
- 复合图案组本身不画任何可见分割辅助线,但 prompt 必须说明每个 `1x2``1x3``2x2``2x3` 图案都能被服务端按等大的 1x1 方形单元切分;纵向 `1x2` 按横向切线分成两个 1x1小图案内不显示切线横向 `1x2` 按纵向切线分成两个 1x1小图案内不显示切线其他形状同理 - 同编号连续格只表示玩法上的同组关系,不再暗示 provider 把连续区域画成一张横跨多格的大图;同组格子用色调、道具和背景线索呼应,但每个 256x256 单元独立查看时都必须完整成图
- 服务端保留 `PuzzleClearPatternGroup` 坐标清单,切片前校验每个 sheet 编号出现次数等于领域图案组 `width * height`,并要求同编号区域是完整连续矩形;切片后还应对尺寸、非空像素比例和重复 hash 做校验 - 同一张 sheet 内不同编号必须发散成不同视觉概念;以水果为例,应扩展为果园、集市摊位、野餐布、果汁杯、厨房案板、甜品盘、篮筐、玻璃罐、窗边餐桌、花园背景等微场景,禁止同品种主体换角度、换大小或换姿势后重复出现
- 每个 256x256 小卡切片独立查看时也要有可辨识的背景纹理、桌面、草地、天空、建筑、布料、器皿、叶片、阴影或装饰元素,避免“孤立主体 + 纯色背景”导致运行态难区分。
- 生图 prompt 明确禁止文字、水印、UI、边框标签、切分线、网格线、裁切参考线、纯色背景、白底商品图、孤立主体、同品种重复和同一物体多角度。
- 复合图案组本身不画任何可见分割辅助线,但 prompt 必须说明每个 `1x2``1x3``2x2``2x3` 图案都能被服务端按等大的 1x1 方形单元切分;纵向 `1x2` 按横向切线分成两个 1x1横向 `1x2` 按纵向切线分成两个 1x1其他形状同理。图案组可以在语义上成组但不能把一张大图的照片边界或拼贴边界落在单个 1x1 单元内部。
- 服务端保留 `PuzzleClearPatternGroup` 坐标清单,切片前校验每个 sheet 编号出现次数等于领域图案组 `width * height`,并要求同编号区域是完整连续矩形。
- 每张 sheet 生成后、正式切片前执行像素级质量门禁:非空格必须达到最低前景占比,空白格前景占比不得超阈值,单格内部不得出现贯穿大部分高度或宽度的强色差拼接线;非同组边界前景贴边仅记录为质量提示,不作为硬失败,避免把模型正常铺满主体的图集误杀。
- 每张 sheet 生成最多尝试 4 次除质量门禁失败外VectorEngine 返回 `retryable=true``502``504``429` 或请求超时也应消耗下一次 sheet attempt避免上游 nginx 偶发 502 或单次拼贴式坏图直接把草稿置为 failed。
- sheet 多次生成仍未通过硬质量门禁时,生成任务进入 `failed` 并写入错误原因;不得把明显空白格污染或主体缺失的工作表切成正式卡牌资产。
- 首版若当前 provider 无法稳定产出可切 atlas生成任务进入 `failed`,错误写入审计;不得退回前端假素材或绕过平台资产底座。 - 首版若当前 provider 无法稳定产出可切 atlas生成任务进入 `failed`,错误写入审计;不得退回前端假素材或绕过平台资产底座。
- 草稿编译和作品发布都必须拒绝缺失 atlas、缺失卡牌切片、空 `assetObjectId` / `imageObjectKey``placeholder` 占位资产;`spacetime-client` 不再为编译请求合成默认 atlas / card assets。 - 草稿编译和作品发布都必须拒绝缺失 atlas、缺失卡牌切片、空 `assetObjectId` / `imageObjectKey``placeholder` 占位资产;`spacetime-client` 不再为编译请求合成默认 atlas / card assets。
- 技术回退需要用户确认后才能改成更多 sheet、降低切片规格或改为逐图生成当前需求固定为 4 张 `1024x1536` sheet 与最终 `2560x2560` atlas。 - 技术回退需要用户确认后才能改成更多 sheet、降低切片规格或改为逐图生成当前需求固定为 4 张 `1024x1536` sheet 与最终 `2560x2560` atlas。

View File

@@ -4080,8 +4080,7 @@ mod tests {
.await .await
.expect("banners body should collect") .expect("banners body should collect")
.to_bytes(); .to_bytes();
let payload: Value = let payload: Value = serde_json::from_slice(&body).expect("banners payload should be json");
serde_json::from_slice(&body).expect("banners payload should be json");
assert_eq!(payload["eventBanners"][0]["title"], "后台表单公告"); assert_eq!(payload["eventBanners"][0]["title"], "后台表单公告");
assert_eq!(payload["eventBanners"][0]["renderMode"], "html"); assert_eq!(payload["eventBanners"][0]["renderMode"], "html");

View File

@@ -77,17 +77,13 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
{ {
return Some("puzzle"); return Some("puzzle");
} }
if normalized.starts_with("/api/runtime/puzzle/gallery/") if normalized.starts_with("/api/runtime/puzzle/gallery/") && normalized.ends_with("/remix") {
&& normalized.ends_with("/remix")
{
return Some("puzzle"); return Some("puzzle");
} }
if normalized == "/api/runtime/big-fish/agent/sessions" { if normalized == "/api/runtime/big-fish/agent/sessions" {
return Some("big-fish"); return Some("big-fish");
} }
if normalized.starts_with("/api/runtime/big-fish/gallery/") if normalized.starts_with("/api/runtime/big-fish/gallery/") && normalized.ends_with("/remix") {
&& normalized.ends_with("/remix")
{
return Some("big-fish"); return Some("big-fish");
} }
if normalized == "/api/runtime/custom-world/agent/sessions" if normalized == "/api/runtime/custom-world/agent/sessions"

View File

@@ -42,6 +42,10 @@ impl AppError {
&self.message &self.message
} }
pub fn details(&self) -> Option<&Value> {
self.details.as_ref()
}
pub fn body_text(&self) -> String { pub fn body_text(&self) -> String {
// 批处理任务不能只读 HTTP 状态文案,否则 DashScope 返回的真实失败原因会被压成“上游服务请求失败”。 // 批处理任务不能只读 HTTP 状态文案,否则 DashScope 返回的真实失败原因会被压成“上游服务请求失败”。
self.details self.details

View File

@@ -5,6 +5,7 @@ use axum::{
http::{HeaderName, StatusCode, header}, http::{HeaderName, StatusCode, header},
response::Response, response::Response,
}; };
use image::GenericImageView;
use module_assets::{ use module_assets::{
AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input, AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input,
generate_asset_binding_id, generate_asset_object_id, generate_asset_binding_id, generate_asset_object_id,
@@ -61,6 +62,15 @@ const PUZZLE_CLEAR_FINAL_ATLAS_ROWS: u32 = 10;
const PUZZLE_CLEAR_ATLAS_GENERATION_SIZE: &str = "1024x1536"; const PUZZLE_CLEAR_ATLAS_GENERATION_SIZE: &str = "1024x1536";
const PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE: &str = "1024x1024"; 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_CARD_BACK_IMAGE_SRC: &str = "/creation-type-references/puzzle.webp";
const PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS: usize = 4;
const PUZZLE_CLEAR_SHEET_FOREGROUND_DIFF_THRESHOLD: i32 = 58;
const PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO: f32 = 0.018;
const PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO: f32 = 0.045;
const PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD: f32 = 0.34;
const PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD: f32 = 0.66;
const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD: i32 = 155;
const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD: f32 = 0.86;
const PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT: &str = "文字、Logo、水印、按钮、UI 字、网格线、编号、标签、边框、外轮廓框、白色描边、白色贴纸边、圆角框、阴影框、分隔线、裁切参考线、单格内部拼接线、内部竖切、内部横切、照片拼贴、相册拼贴、多场景拼贴、双联图、三联图、画中画、单格双图、单格多图、低清晰度、纯色背景、空白背景、白底商品图、孤立主体、单体素材、素材表、图标、贴纸、同品种重复、同一物体多角度、重复同款小图、主体跨格、主体贴边、拼贴、重影、不同图案互相穿插";
pub async fn create_puzzle_clear_session( pub async fn create_puzzle_clear_session(
State(state): State<AppState>, State(state): State<AppState>,
@@ -689,18 +699,47 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
payload.theme_prompt.as_deref().unwrap_or_default(), payload.theme_prompt.as_deref().unwrap_or_default(),
&sheet_spec, &sheet_spec,
); );
let failure_context = format!("拼消消素材 {} 生成失败", sheet_spec.sheet_id); let mut accepted_sheet = None;
let generated = create_openai_image_generation( for attempt_index in 0..PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS {
let failure_context = format!(
"拼消消素材 {} 生成失败,第 {}",
sheet_spec.sheet_id,
attempt_index + 1
);
let generated = match create_openai_image_generation(
&http_client, &http_client,
&settings, &settings,
sheet_prompt.as_str(), sheet_prompt.as_str(),
Some("文字、Logo、水印、按钮、UI 字、网格线、编号、标签、边框、外轮廓框、白色描边、白色贴纸边、圆角框、阴影框、分隔线、裁切参考线、低清晰度、主体跨格、主体贴边、重复同款小图"), Some(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT),
PUZZLE_CLEAR_ATLAS_GENERATION_SIZE, PUZZLE_CLEAR_ATLAS_GENERATION_SIZE,
1, 1,
&[], &[],
failure_context.as_str(), failure_context.as_str(),
) )
.await?; .await
{
Ok(generated) => generated,
Err(error)
if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS
&& is_retryable_puzzle_clear_sheet_generation_error(&error) =>
{
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
sheet_id = sheet_spec.sheet_id,
attempt = attempt_index + 1,
generation_error = %error.body_text(),
"拼消消素材 sheet 生成遇到可重试上游错误,准备重试"
);
continue;
}
Err(error) => {
return Err(puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
error,
));
}
};
let task_id = generated.task_id; let task_id = generated.task_id;
let image = generated.images.into_iter().next().ok_or_else(|| { let image = generated.images.into_iter().next().ok_or_else(|| {
puzzle_clear_error_response( puzzle_clear_error_response(
@@ -712,12 +751,52 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
})), })),
) )
})?; })?;
generated_sheets.push(PuzzleClearGeneratedSheet { match validate_puzzle_clear_sheet_quality(&image, &sheet_spec) {
Ok(()) => {
accepted_sheet = Some(PuzzleClearGeneratedSheet {
spec: sheet_spec, spec: sheet_spec,
prompt: sheet_prompt, prompt: sheet_prompt.clone(),
task_id, task_id,
image, image,
}); });
break;
}
Err(error) if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS => {
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
sheet_id = sheet_spec.sheet_id,
attempt = attempt_index + 1,
quality_error = %error.body_text(),
"拼消消素材 sheet 质量校验未通过,准备重试"
);
}
Err(error) => {
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
sheet_id = sheet_spec.sheet_id,
attempt = attempt_index + 1,
quality_error = %error.body_text(),
"拼消消素材 sheet 质量校验最终未通过"
);
return Err(puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
error,
));
}
}
}
let Some(accepted_sheet) = accepted_sheet else {
return Err(puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 多次生成后仍未得到可切图集。", sheet_spec.sheet_id),
})),
));
};
generated_sheets.push(accepted_sheet);
} }
let mut slices = Vec::new(); let mut slices = Vec::new();
@@ -858,7 +937,7 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec<PuzzleClearAtlasSheetSpec> {
"第4行D02 D02 C01 C01\n", "第4行D02 D02 C01 C01\n",
"第5行D02 D02 C02 C02\n", "第5行D02 D02 C02 C02\n",
"第6行A01 A01 C02 C02\n\n", "第6行A01 A01 C02 C02\n\n",
"A 表示 1x2 复合图案C 表示 2x2 复合图案D 表示 2x3 或 3x2 复合图案。请按相同编号连续区域成一幅完整连续的小插画" "A 表示 1x2 复合图案C 表示 2x2 复合图案D 表示 2x3 或 3x2 复合图案。相同编号只表示玩法分组:请生成一组主题一致的小照片裁片,每个 256 单元都要独立成图,不要把连续区域成一张横跨多格的大图或照片拼贴"
), ),
}, },
PuzzleClearAtlasSheetSpec { PuzzleClearAtlasSheetSpec {
@@ -879,7 +958,7 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec<PuzzleClearAtlasSheetSpec> {
"第4行C03 C03 C04 C04\n", "第4行C03 C03 C04 C04\n",
"第5行B01 B01 B01 A06\n", "第5行B01 B01 B01 A06\n",
"第6行B03 B03 B03 A06\n\n", "第6行B03 B03 B03 A06\n\n",
"A 表示 1x2 复合图案B 表示 1x3 或 3x1 复合图案C 表示 2x2 复合图案D 表示 2x3 或 3x2 复合图案。请按相同编号连续区域成一幅完整连续的小插画" "A 表示 1x2 复合图案B 表示 1x3 或 3x1 复合图案C 表示 2x2 复合图案D 表示 2x3 或 3x2 复合图案。相同编号只表示玩法分组:请生成一组主题一致的小照片裁片,每个 256 单元都要独立成图,不要把连续区域成一张横跨多格的大图或照片拼贴"
), ),
}, },
PuzzleClearAtlasSheetSpec { PuzzleClearAtlasSheetSpec {
@@ -900,7 +979,7 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec<PuzzleClearAtlasSheetSpec> {
"第4行B05 B05 B05 A08\n", "第4行B05 B05 B05 A08\n",
"第5行A09 A09 A10 A08\n", "第5行A09 A09 A10 A08\n",
"第6行A11 A11 A10 空白\n\n", "第6行A11 A11 A10 空白\n\n",
"A 表示 1x2 复合图案B 表示 1x3 或 3x1 复合图案。空白格保持干净浅色背景,不要生成图案。请按相同编号连续区域成一幅完整连续的小插画" "A 表示 1x2 复合图案B 表示 1x3 或 3x1 复合图案。空白格保持干净浅色背景,不要生成图案。相同编号只表示玩法分组:请生成一组主题一致的小照片裁片,每个 256 单元都要独立成图,不要把连续区域成一张横跨多格的大图或照片拼贴"
), ),
}, },
PuzzleClearAtlasSheetSpec { PuzzleClearAtlasSheetSpec {
@@ -921,7 +1000,7 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec<PuzzleClearAtlasSheetSpec> {
"第4行A16 A19 A19 A18\n", "第4行A16 A19 A19 A18\n",
"第5行A20 A21 A21 A22\n", "第5行A20 A21 A21 A22\n",
"第6行A20 A23 A23 A22\n\n", "第6行A20 A23 A23 A22\n\n",
"A 表示 1x2 复合图案。请按相同编号连续区域生成一幅完整连续的小插画,横向 1x2 和纵向 1x2 都要自然可拼接" "A 表示 1x2 复合图案。相同编号只表示玩法分组:横向 1x2 和纵向 1x2 用色调、道具和背景线索互相呼应,每个 256 单元都要独立成图,不要把连续区域画成一张横跨两格的大图或照片拼贴"
), ),
}, },
] ]
@@ -936,9 +1015,14 @@ fn build_puzzle_clear_atlas_prompt(
concat!( concat!(
"生成一张拼消消素材工作表,主题是「{subject}」,竖版 1024x1536。\n\n", "生成一张拼消消素材工作表,主题是「{subject}」,竖版 1024x1536。\n\n",
"这张图供程序后台按 4 列 x 6 行裁切,每个裁切单元为 256x256 的正方形。4x6 网格只用于后台理解,画面中绝对不要画出网格线、切分线、边框、编号或坐标。\n\n", "这张图供程序后台按 4 列 x 6 行裁切,每个裁切单元为 256x256 的正方形。4x6 网格只用于后台理解,画面中绝对不要画出网格线、切分线、边框、编号或坐标。\n\n",
"相同编号连续占据的格子是一幅复合小插画,必须形成同一个完整主题物件或小场景;不同编号之间是不同图案,不要重复主体。复合图案可以横向或纵向跨格,但跨格处必须自然连续,切成 1x1 后每一格仍然有清晰可识别的局部图案\n\n", "这不是单个物体素材表,而是一组照片式构图、绘本式渲染的主题微场景拼图卡。每个编号区域必须有明确背景、环境、道具、光影和构图线索,像从一张丰富照片或插画中裁出的局部。\n\n",
"图案不要做成卡牌、贴纸、图标格子或带框小卡片。每个图案外沿自然融入干净浅色背景,但不能有过多留白,外轮廓框、白色描边、圆角框、阴影框、分线、参考线或贴纸边\n\n", "每个 256x256 单元本身就是一张完整的单场景照片裁片,单元内部只能有一个连续画面;禁止在一个单元内部出现两张照片、两个不同场景、拼接线、分线、内部竖切、内部横切、左右/上下两块不同背景。场景变化只能发生在 256 单元边界上\n\n",
"画风为高清、清爽、适合休闲消除游戏的小插画主体饱满颜色鲜明边缘干净不能出现文字、Logo、水印、按钮、UI 或教程元素\n\n", "相同编号连续占据的格子只表示玩法上的同组关系,不是要求把一张大图横跨多个格子。请把同编号区域画成色调、道具、背景线索互相呼应的一组小照片裁片;每个格子独立查看时都必须完整成图,不能在单格内部再切出第二张图或第二个场景\n\n",
"同一张 sheet 内,不同编号必须使用不同视觉概念,不要把同一种主体换角度、换大小、换姿势后重复使用。比如主题是水果时,不要重复生成不同角度的葡萄、菠萝、西瓜、橙子;应扩展为果园、集市摊位、野餐布、果汁杯、厨房案板、甜品盘、篮筐、玻璃罐、窗边餐桌、花园背景等不同场景。\n\n",
"每个 256x256 单元独立查看时,都应该有可辨识的局部信息:可以包含主体局部、背景纹理、桌面、草地、天空、建筑、布料、器皿、叶片、阴影或装饰元素。不要让小卡只有一个孤立主体加纯色背景。\n\n",
"不同编号区域之间保持干净边界,主体不能越界或挤入相邻编号区域;空白格必须保持干净浅色背景,不要出现任何图案碎片。\n\n",
"图案不要做成商品素材表、卡牌、贴纸、图标格子或带框小卡片。不能有外轮廓框、白色描边、圆角框、阴影框、分隔线、参考线或贴纸边。\n\n",
"画风为高清、清爽、适合休闲消除游戏的丰富主题插画颜色鲜明边缘干净不能出现文字、Logo、水印、按钮、UI 或教程元素。\n\n",
"{layout_prompt}" "{layout_prompt}"
), ),
subject = subject, subject = subject,
@@ -1063,6 +1147,376 @@ async fn persist_puzzle_clear_data_url_asset(
.await .await
} }
#[derive(Clone, Copy, Debug)]
struct PuzzleClearSheetCellBounds {
x0: u32,
y0: u32,
x1: u32,
y1: u32,
}
impl PuzzleClearSheetCellBounds {
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()).max(1)
}
}
#[derive(Clone, Copy, Debug)]
struct PuzzleClearSheetCellQuality {
foreground_ratio: f32,
exposed_edge_count: usize,
strongest_edge_ratio: f32,
strongest_internal_seam_ratio: f32,
}
fn validate_puzzle_clear_sheet_quality(
image: &DownloadedOpenAiImage,
sheet_spec: &PuzzleClearAtlasSheetSpec,
) -> Result<(), AppError> {
// 中文注释:生成图进入正式切片前先做像素级门禁,避免把明显错位的 sheet 持久化成卡牌资产。
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!("拼消消素材 {} 解码失败:{error}", sheet_spec.sheet_id),
}))
})?;
let source_width = source.width();
let source_height = source.height();
if source_width < PUZZLE_CLEAR_SHEET_COLUMNS || source_height < PUZZLE_CLEAR_SHEET_ROWS {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 尺寸过小,无法做切片质量校验。", sheet_spec.sheet_id),
})),
);
}
let mut findings = Vec::new();
let mut advisory_findings = Vec::new();
for row in 0..PUZZLE_CLEAR_SHEET_ROWS {
for col in 0..PUZZLE_CLEAR_SHEET_COLUMNS {
let group_id = sheet_spec.layout[row as usize][col as usize];
let bounds = puzzle_clear_sheet_cell_bounds(row, col, source_width, source_height);
let quality =
analyze_puzzle_clear_sheet_cell_quality(&source, sheet_spec, row, col, bounds);
let cell_label = format!("{}行第{}", row + 1, col + 1);
if group_id == "." {
if quality.foreground_ratio > PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO {
findings.push(format!("{cell_label} 空白格有主体"));
}
continue;
}
if quality.foreground_ratio < PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO {
findings.push(format!("{cell_label} 主体过少"));
}
if quality.strongest_internal_seam_ratio
> PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD
{
findings.push(format!("{cell_label} 单格内部疑似拼接线"));
}
if quality.exposed_edge_count >= 2
&& quality.strongest_edge_ratio > PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD
{
advisory_findings.push(format!("{cell_label} 主体贴到不同图案边界"));
}
}
}
if !advisory_findings.is_empty() {
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
sheet_id = sheet_spec.sheet_id,
quality_warning = %advisory_findings.join(""),
"拼消消素材 sheet 检测到边界接触,已作为提示继续切片"
);
}
if findings.is_empty() {
return Ok(());
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"reason": "invalid_puzzle_clear_sheet_quality",
"message": format!(
"拼消消素材 {} 不满足切片质量:{}。请重新生成图集。",
sheet_spec.sheet_id,
findings.join(""),
),
"findings": findings,
})),
)
}
fn is_retryable_puzzle_clear_sheet_generation_error(error: &AppError) -> bool {
if !matches!(
error.status_code(),
StatusCode::BAD_GATEWAY | StatusCode::GATEWAY_TIMEOUT | StatusCode::TOO_MANY_REQUESTS
) {
return false;
}
error
.details()
.and_then(|details| details.get("retryable"))
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn analyze_puzzle_clear_sheet_cell_quality(
source: &image::DynamicImage,
sheet_spec: &PuzzleClearAtlasSheetSpec,
row: u32,
col: u32,
bounds: PuzzleClearSheetCellBounds,
) -> PuzzleClearSheetCellQuality {
let background = sample_puzzle_clear_sheet_cell_background(source, bounds);
let width = bounds.width() as usize;
let height = bounds.height() as usize;
let mut mask = vec![0u8; width.saturating_mul(height)];
let mut foreground_pixels = 0u32;
for local_y in 0..height {
let y = bounds.y0 + local_y as u32;
for local_x in 0..width {
let x = bounds.x0 + local_x as u32;
if is_puzzle_clear_sheet_foreground_pixel(source.get_pixel(x, y).0, background) {
mask[local_y * width + local_x] = 1;
foreground_pixels = foreground_pixels.saturating_add(1);
}
}
}
let (exposed_edge_count, strongest_edge_ratio) =
measure_puzzle_clear_sheet_exposed_edges(&mask, width, height, sheet_spec, row, col);
let strongest_internal_seam_ratio = measure_puzzle_clear_sheet_internal_seam(source, bounds);
PuzzleClearSheetCellQuality {
foreground_ratio: foreground_pixels as f32 / bounds.area() as f32,
exposed_edge_count,
strongest_edge_ratio,
strongest_internal_seam_ratio,
}
}
fn puzzle_clear_sheet_cell_bounds(
row: u32,
col: u32,
source_width: u32,
source_height: u32,
) -> PuzzleClearSheetCellBounds {
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);
PuzzleClearSheetCellBounds { x0, y0, x1, y1 }
}
fn is_puzzle_clear_sheet_foreground_pixel(pixel: [u8; 4], background: [u8; 4]) -> bool {
if pixel[3] <= 24 {
return false;
}
let alpha_diff = (pixel[3] as i32 - background[3] as i32).abs();
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();
alpha_diff >= 48 || color_diff >= PUZZLE_CLEAR_SHEET_FOREGROUND_DIFF_THRESHOLD
}
fn sample_puzzle_clear_sheet_cell_background(
source: &image::DynamicImage,
bounds: PuzzleClearSheetCellBounds,
) -> [u8; 4] {
let sample_size = (bounds.width().min(bounds.height()) / 12).clamp(2, 8);
let points = [
(bounds.x0, bounds.y0),
(bounds.x1.saturating_sub(sample_size), bounds.y0),
(bounds.x0, bounds.y1.saturating_sub(sample_size)),
(
bounds.x1.saturating_sub(sample_size),
bounds.y1.saturating_sub(sample_size),
),
];
let mut samples = Vec::new();
for (start_x, start_y) in points {
let mut totals = [0u32; 4];
let mut count = 0u32;
for y in start_y..start_y.saturating_add(sample_size).min(bounds.y1) {
for x in start_x..start_x.saturating_add(sample_size).min(bounds.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()
.max_by_key(|sample| {
let max_channel = sample[0].max(sample[1]).max(sample[2]) as u16;
let min_channel = sample[0].min(sample[1]).min(sample[2]) as u16;
let luminance = sample[0] as u16 + sample[1] as u16 + sample[2] as u16;
let saturation = max_channel.saturating_sub(min_channel);
(luminance, u16::MAX.saturating_sub(saturation))
})
.unwrap_or([255, 255, 255, 255])
}
fn measure_puzzle_clear_sheet_exposed_edges(
mask: &[u8],
width: usize,
height: usize,
sheet_spec: &PuzzleClearAtlasSheetSpec,
row: u32,
col: u32,
) -> (usize, f32) {
if width == 0 || height == 0 || mask.len() < width.saturating_mul(height) {
return (0, 0.0);
}
let band = (width.min(height) / 24).clamp(6, 12);
let mut exposed_edges = 0usize;
let mut strongest_ratio = 0.0f32;
let edge_specs = [
((-1i32, 0i32), 0usize, 0usize, width, band),
((1, 0), 0, height.saturating_sub(band), width, band),
((0, -1), 0, 0, band, height),
((0, 1), width.saturating_sub(band), 0, band, height),
];
for ((row_delta, col_delta), start_x, start_y, edge_width, edge_height) in edge_specs {
if puzzle_clear_sheet_neighbor_is_same_group(sheet_spec, row, col, row_delta, col_delta) {
continue;
}
let mut foreground = 0usize;
let mut total = 0usize;
for local_y in start_y..start_y.saturating_add(edge_height).min(height) {
for local_x in start_x..start_x.saturating_add(edge_width).min(width) {
total = total.saturating_add(1);
if mask[local_y * width + local_x] != 0 {
foreground = foreground.saturating_add(1);
}
}
}
if total == 0 {
continue;
}
let ratio = foreground as f32 / total as f32;
strongest_ratio = strongest_ratio.max(ratio);
if ratio > PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD {
exposed_edges = exposed_edges.saturating_add(1);
}
}
(exposed_edges, strongest_ratio)
}
fn measure_puzzle_clear_sheet_internal_seam(
source: &image::DynamicImage,
bounds: PuzzleClearSheetCellBounds,
) -> f32 {
let width = bounds.width();
let height = bounds.height();
if width < 48 || height < 48 {
return 0.0;
}
let margin_x = (width / 8).clamp(18, 36);
let margin_y = (height / 8).clamp(18, 36);
let x_start = bounds.x0.saturating_add(margin_x).max(bounds.x0 + 1);
let x_end = bounds.x1.saturating_sub(margin_x).max(x_start + 1);
let y_start = bounds.y0.saturating_add(margin_y).max(bounds.y0 + 1);
let y_end = bounds.y1.saturating_sub(margin_y).max(y_start + 1);
let mut strongest = 0.0f32;
for x in x_start..x_end {
let mut strong = 0u32;
let mut total = 0u32;
for y in y_start..y_end {
let left = source.get_pixel(x.saturating_sub(1), y).0;
let right = source.get_pixel(x, y).0;
if puzzle_clear_rgb_distance(left, right)
>= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD
{
strong = strong.saturating_add(1);
}
total = total.saturating_add(1);
}
if total > 0 {
strongest = strongest.max(strong as f32 / total as f32);
}
}
for y in y_start..y_end {
let mut strong = 0u32;
let mut total = 0u32;
for x in x_start..x_end {
let top = source.get_pixel(x, y.saturating_sub(1)).0;
let bottom = source.get_pixel(x, y).0;
if puzzle_clear_rgb_distance(top, bottom)
>= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD
{
strong = strong.saturating_add(1);
}
total = total.saturating_add(1);
}
if total > 0 {
strongest = strongest.max(strong as f32 / total as f32);
}
}
strongest
}
fn puzzle_clear_rgb_distance(left: [u8; 4], right: [u8; 4]) -> i32 {
(left[0] as i32 - right[0] as i32).abs()
+ (left[1] as i32 - right[1] as i32).abs()
+ (left[2] as i32 - right[2] as i32).abs()
}
fn puzzle_clear_sheet_neighbor_is_same_group(
sheet_spec: &PuzzleClearAtlasSheetSpec,
row: u32,
col: u32,
row_delta: i32,
col_delta: i32,
) -> bool {
let current = sheet_spec.layout[row as usize][col as usize];
if current == "." {
return false;
}
let neighbor_row = row as i32 + row_delta;
let neighbor_col = col as i32 + col_delta;
if neighbor_row < 0
|| neighbor_col < 0
|| neighbor_row >= PUZZLE_CLEAR_SHEET_ROWS as i32
|| neighbor_col >= PUZZLE_CLEAR_SHEET_COLUMNS as i32
{
return false;
}
sheet_spec.layout[neighbor_row as usize][neighbor_col as usize] == current
}
fn slice_puzzle_clear_sheet( fn slice_puzzle_clear_sheet(
image: &DownloadedOpenAiImage, image: &DownloadedOpenAiImage,
sheet_spec: &PuzzleClearAtlasSheetSpec, sheet_spec: &PuzzleClearAtlasSheetSpec,
@@ -1617,12 +2071,19 @@ fn build_puzzle_clear_public_work_code(profile_id: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
PUZZLE_CLEAR_ATLAS_CELL_SIZE, PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, PUZZLE_CLEAR_ATLAS_CELL_SIZE, PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT,
build_puzzle_clear_atlas_prompt, build_puzzle_clear_board_background_prompt, PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, build_puzzle_clear_atlas_prompt,
build_puzzle_clear_draft, planned_puzzle_clear_pattern_groups, build_puzzle_clear_board_background_prompt, build_puzzle_clear_draft,
puzzle_clear_atlas_sheet_specs, is_retryable_puzzle_clear_sheet_generation_error, planned_puzzle_clear_pattern_groups,
puzzle_clear_atlas_sheet_specs, validate_puzzle_clear_sheet_quality,
}; };
use crate::http_error::AppError;
use crate::openai_image_generation::DownloadedOpenAiImage;
use axum::http::StatusCode;
use image::{ImageFormat, Rgba, RgbaImage};
use serde_json::json;
use shared_contracts::puzzle_clear::PuzzleClearWorkspaceCreateRequest; use shared_contracts::puzzle_clear::PuzzleClearWorkspaceCreateRequest;
use std::io::Cursor;
#[test] #[test]
fn puzzle_clear_atlas_prompt_uses_sheet_cells_and_subject() { fn puzzle_clear_atlas_prompt_uses_sheet_cells_and_subject() {
@@ -1635,12 +2096,38 @@ mod tests {
assert!(prompt.contains("竖版 1024x1536")); assert!(prompt.contains("竖版 1024x1536"));
assert!(prompt.contains("4 列 x 6 行裁切")); assert!(prompt.contains("4 列 x 6 行裁切"));
assert!(prompt.contains("256x256 的正方形")); assert!(prompt.contains("256x256 的正方形"));
assert!(prompt.contains("切成 1x1 后每一格")); assert!(prompt.contains("完整的单场景照片裁片"));
assert!(prompt.contains("案不要做成卡牌、贴纸、图标格子或带框小卡片")); assert!(prompt.contains("照片式构"));
assert!(prompt.contains("主题微场景拼图卡"));
assert!(prompt.contains("明确背景、环境、道具、光影和构图线索"));
assert!(prompt.contains("每个 256x256 单元本身就是一张完整的单场景照片裁片"));
assert!(prompt.contains("禁止在一个单元内部出现两张照片"));
assert!(prompt.contains("内部竖切"));
assert!(prompt.contains("内部横切"));
assert!(prompt.contains("场景变化只能发生在 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("外轮廓框")); assert!(prompt.contains("外轮廓框"));
assert!(prompt.contains("贴纸边")); assert!(prompt.contains("贴纸边"));
assert!(prompt.contains("圆角框")); assert!(prompt.contains("圆角框"));
assert!(prompt.contains("阴影框")); assert!(prompt.contains("阴影框"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("纯色背景"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("白底商品图"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("孤立主体"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("同品种重复"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("同一物体多角度"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("单格内部拼接线"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("单格双图"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("照片拼贴"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("相册拼贴"));
assert!(!prompt.contains("135 幅")); assert!(!prompt.contains("135 幅"));
assert!(!prompt.contains("24 列 x 38 行")); assert!(!prompt.contains("24 列 x 38 行"));
assert!(!prompt.contains("卡牌小格")); assert!(!prompt.contains("卡牌小格"));
@@ -1684,6 +2171,177 @@ mod tests {
} }
} }
#[test]
fn puzzle_clear_sheet_quality_allows_edge_contact_as_advisory_warning() {
let sheet = puzzle_clear_atlas_sheet_specs()
.into_iter()
.find(|sheet| sheet.sheet_id == "sheet-04")
.expect("sheet exists");
let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255]));
for row in 0..6u32 {
for col in 0..4u32 {
let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let color = Rgba([
70u8.saturating_add((row * 23) as u8),
80u8.saturating_add((col * 31) as u8),
160,
255,
]);
for y in base_y + 80..base_y + 176 {
for x in base_x + 80..base_x + 176 {
source.put_pixel(x, y, color);
}
}
}
}
for y in 0..180u32 {
for x in 0..180u32 {
source.put_pixel(x, y, Rgba([215, 48, 62, 255]));
}
}
let mut encoded = Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(source)
.write_to(&mut encoded, ImageFormat::Png)
.expect("test image should encode");
let image = DownloadedOpenAiImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: encoded.into_inner(),
};
validate_puzzle_clear_sheet_quality(&image, &sheet)
.expect("edge contact is advisory because generated sheets often touch borders");
}
#[test]
fn puzzle_clear_sheet_quality_rejects_blank_cell_pollution() {
let sheet = puzzle_clear_atlas_sheet_specs()
.into_iter()
.find(|sheet| sheet.sheet_id == "sheet-03")
.expect("sheet exists");
let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255]));
for row in 0..6u32 {
for col in 0..4u32 {
let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let color = Rgba([
70u8.saturating_add((row * 23) as u8),
80u8.saturating_add((col * 31) as u8),
160,
255,
]);
for y in base_y + 80..base_y + 176 {
for x in base_x + 80..base_x + 176 {
source.put_pixel(x, y, color);
}
}
}
}
for y in 5 * PUZZLE_CLEAR_ATLAS_CELL_SIZE + 40..6 * PUZZLE_CLEAR_ATLAS_CELL_SIZE - 40 {
for x in 3 * PUZZLE_CLEAR_ATLAS_CELL_SIZE + 40..4 * PUZZLE_CLEAR_ATLAS_CELL_SIZE - 40 {
source.put_pixel(x, y, Rgba([215, 48, 62, 255]));
}
}
let mut encoded = Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(source)
.write_to(&mut encoded, ImageFormat::Png)
.expect("test image should encode");
let image = DownloadedOpenAiImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: encoded.into_inner(),
};
let error = validate_puzzle_clear_sheet_quality(&image, &sheet)
.expect_err("blank cell pollution should be rejected");
assert!(error.body_text().contains("空白格有主体"));
}
#[test]
fn puzzle_clear_sheet_quality_rejects_internal_photo_seam() {
let sheet = puzzle_clear_atlas_sheet_specs()
.into_iter()
.find(|sheet| sheet.sheet_id == "sheet-04")
.expect("sheet exists");
let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255]));
for row in 0..6u32 {
for col in 0..4u32 {
let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let color = Rgba([
70u8.saturating_add((row * 23) as u8),
80u8.saturating_add((col * 31) as u8),
160,
255,
]);
for y in base_y + 80..base_y + 176 {
for x in base_x + 80..base_x + 176 {
source.put_pixel(x, y, color);
}
}
}
}
for y in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE {
for x in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2 {
source.put_pixel(x, y, Rgba([206, 46, 62, 255]));
}
for x in PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2..PUZZLE_CLEAR_ATLAS_CELL_SIZE {
source.put_pixel(x, y, Rgba([38, 112, 218, 255]));
}
}
let mut encoded = Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(source)
.write_to(&mut encoded, ImageFormat::Png)
.expect("test image should encode");
let image = DownloadedOpenAiImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: encoded.into_inner(),
};
let error = validate_puzzle_clear_sheet_quality(&image, &sheet)
.expect_err("internal photo seam should be rejected");
assert!(error.body_text().contains("单格内部疑似拼接线"));
}
#[test]
fn puzzle_clear_sheet_generation_retries_only_retryable_upstream_errors() {
let retryable_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "上游服务请求失败",
"retryable": true,
}));
assert!(is_retryable_puzzle_clear_sheet_generation_error(
&retryable_error
));
let non_retryable_gateway =
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "上游服务请求失败",
"retryable": false,
}));
assert!(!is_retryable_puzzle_clear_sheet_generation_error(
&non_retryable_gateway
));
let bad_request = AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "vector-engine",
"message": "请求参数不合法",
"retryable": true,
}));
assert!(!is_retryable_puzzle_clear_sheet_generation_error(
&bad_request
));
}
#[test] #[test]
fn puzzle_clear_board_background_prompt_reveals_theme_goal() { fn puzzle_clear_board_background_prompt_reveals_theme_goal() {
let prompt = build_puzzle_clear_board_background_prompt("星港花园"); let prompt = build_puzzle_clear_board_background_prompt("星港花园");