diff --git a/server-rs/crates/api-server/src/puzzle_clear.rs b/server-rs/crates/api-server/src/puzzle_clear.rs index 0aff7483..053db575 100644 --- a/server-rs/crates/api-server/src/puzzle_clear.rs +++ b/server-rs/crates/api-server/src/puzzle_clear.rs @@ -5,7 +5,6 @@ use axum::{ http::{HeaderName, StatusCode, header}, response::Response, }; -use image::GenericImageView; use module_assets::{ AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, @@ -67,16 +66,7 @@ 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_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 = 170; -const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD: f32 = 0.92; -const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD: f32 = 145.0; -const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX: f32 = 36.0; -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, @@ -791,38 +781,6 @@ impl PuzzleClearImageDebugRun { ); } - fn record_sheet_quality( - &self, - sheet_spec: &PuzzleClearAtlasSheetSpec, - attempt_index: usize, - task_id: &str, - image: &DownloadedOpenAiImage, - quality_error: Option<&AppError>, - ) { - let attempt_id = puzzle_clear_debug_attempt_id(attempt_index); - match build_puzzle_clear_sheet_quality_debug_report( - sheet_spec, - attempt_index, - task_id, - image, - quality_error, - ) { - Ok(report) => { - self.write_json( - format!("reports/{}-{attempt_id}.quality.json", sheet_spec.sheet_id), - &report, - "记录拼消消 sheet 质量报告失败", - ); - } - Err(error) => self.write_json( - format!("reports/{}-{attempt_id}.quality-error.json", sheet_spec.sheet_id), - &puzzle_clear_debug_error_json(&error), - "记录拼消消 sheet 质量报告错误失败", - ), - } - self.write_sheet_cells(sheet_spec, attempt_index, image); - } - fn record_sheet_accepted( &self, sheet_spec: &PuzzleClearAtlasSheetSpec, @@ -913,93 +871,6 @@ impl PuzzleClearImageDebugRun { ); } - fn write_sheet_cells( - &self, - sheet_spec: &PuzzleClearAtlasSheetSpec, - attempt_index: usize, - image: &DownloadedOpenAiImage, - ) { - let attempt_id = puzzle_clear_debug_attempt_id(attempt_index); - let result = (|| -> Result<(), 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!("拼消消素材 {} 调试裁切解码失败:{error}", sheet_spec.sheet_id), - })) - })?; - let source_width = source.width(); - let source_height = source.height(); - let mut contact = image::RgbaImage::from_pixel( - PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_SHEET_COLUMNS, - PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_SHEET_ROWS, - image::Rgba([255, 255, 255, 0]), - ); - 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 cropped = source - .crop_imm(bounds.x0, bounds.y0, bounds.width(), bounds.height()) - .resize_exact( - PUZZLE_CLEAR_ATLAS_CELL_SIZE, - PUZZLE_CLEAR_ATLAS_CELL_SIZE, - image::imageops::FilterType::Lanczos3, - ) - .to_rgba8(); - let mut cursor = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(cropped.clone()) - .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), - })) - })?; - self.write_bytes( - format!( - "cells/{}-{attempt_id}/r{:02}-c{:02}-{}.png", - sheet_spec.sheet_id, - row + 1, - col + 1, - sanitize_puzzle_clear_debug_segment(group_id, "cell"), - ), - cursor.into_inner().as_slice(), - "记录拼消消 sheet 裁切格失败", - ); - image::imageops::overlay( - &mut contact, - &cropped, - i64::from(col * PUZZLE_CLEAR_ATLAS_CELL_SIZE), - i64::from(row * PUZZLE_CLEAR_ATLAS_CELL_SIZE), - ); - } - } - let mut cursor = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(contact) - .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!("拼消消素材 {} 调试 contact sheet 写入失败:{error}", sheet_spec.sheet_id), - })) - })?; - self.write_bytes( - format!("cells/{}-{attempt_id}/contact-sheet.png", sheet_spec.sheet_id), - cursor.into_inner().as_slice(), - "记录拼消消 sheet contact sheet 失败", - ); - Ok(()) - })(); - if let Err(error) = result { - self.write_json( - format!("cells/{}-{attempt_id}/error.json", sheet_spec.sheet_id), - &puzzle_clear_debug_error_json(&error), - "记录拼消消 sheet 裁切错误失败", - ); - } - } - fn write_text( &self, relative_path: impl AsRef, @@ -1208,114 +1079,6 @@ fn puzzle_clear_debug_error_json(error: &AppError) -> Value { }) } -fn build_puzzle_clear_sheet_quality_debug_report( - sheet_spec: &PuzzleClearAtlasSheetSpec, - attempt_index: usize, - task_id: &str, - image: &DownloadedOpenAiImage, - quality_error: Option<&AppError>, -) -> Result { - 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(); - let mut hard_findings = Vec::new(); - let mut advisory_findings = Vec::new(); - let mut cells = 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); - let mut cell_findings = Vec::new(); - let mut cell_advisory_findings = Vec::new(); - - if group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL { - if quality.foreground_ratio > PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO { - let finding = format!("{cell_label} 空白格有主体"); - hard_findings.push(finding.clone()); - cell_findings.push(finding); - } - } else if group_id != PUZZLE_CLEAR_SHEET_FILLER_CELL { - if quality.foreground_ratio < PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO { - let finding = format!("{cell_label} 主体过少"); - hard_findings.push(finding.clone()); - cell_findings.push(finding); - } - if quality.strongest_internal_seam_ratio - > PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD - { - let finding = format!("{cell_label} 单格内部疑似拼接线"); - hard_findings.push(finding.clone()); - cell_findings.push(finding); - } - if quality.exposed_edge_count >= 2 - && quality.strongest_edge_ratio - > PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD - { - let finding = format!("{cell_label} 主体贴到不同图案边界"); - advisory_findings.push(finding.clone()); - cell_advisory_findings.push(finding); - } - } - - cells.push(json!({ - "row": row + 1, - "col": col + 1, - "groupId": group_id, - "discarded": is_puzzle_clear_sheet_discarded_cell(group_id), - "bounds": { - "x0": bounds.x0, - "y0": bounds.y0, - "x1": bounds.x1, - "y1": bounds.y1, - }, - "foregroundRatio": quality.foreground_ratio, - "exposedEdgeCount": quality.exposed_edge_count, - "strongestEdgeRatio": quality.strongest_edge_ratio, - "strongestInternalSeamRatio": quality.strongest_internal_seam_ratio, - "hardFindings": cell_findings, - "advisoryFindings": cell_advisory_findings, - })); - } - } - - Ok(json!({ - "sheetId": sheet_spec.sheet_id, - "attempt": attempt_index + 1, - "taskId": task_id, - "accepted": quality_error.is_none(), - "image": { - "width": source_width, - "height": source_height, - "mimeType": image.mime_type.as_str(), - "extension": image.extension.as_str(), - "byteLength": image.bytes.len(), - }, - "thresholds": { - "foregroundDiff": PUZZLE_CLEAR_SHEET_FOREGROUND_DIFF_THRESHOLD, - "minForegroundRatio": PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO, - "blankMaxForegroundRatio": PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO, - "edgeRatio": PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD, - "strongEdgeRatio": PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD, - "internalSeamDiff": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD, - "internalSeamRatio": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD, - "internalSeamSideContrast": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD, - "internalSeamSideTextureMax": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX, - }, - "hardFindings": hard_findings, - "advisoryFindings": advisory_findings, - "qualityError": quality_error.map(puzzle_clear_debug_error_json), - "cells": cells, - })) -} - async fn maybe_prepare_puzzle_clear_assets_inner( state: &AppState, request_context: &RequestContext, @@ -1499,54 +1262,15 @@ async fn maybe_prepare_puzzle_clear_assets_inner( actual_prompt.as_deref(), &image, ); + debug_run.record_sheet_accepted(&sheet_spec, task_id.as_str(), &image); } - let quality_result = validate_puzzle_clear_sheet_quality(&image, &sheet_spec); - if let Some(debug_run) = image_debug_run.as_ref() { - debug_run.record_sheet_quality( - &sheet_spec, - attempt_index, - task_id.as_str(), - &image, - quality_result.as_ref().err(), - ); - } - match quality_result { - Ok(()) => { - if let Some(debug_run) = image_debug_run.as_ref() { - debug_run.record_sheet_accepted(&sheet_spec, task_id.as_str(), &image); - } - accepted_sheet = Some(PuzzleClearGeneratedSheet { - spec: sheet_spec, - prompt: sheet_prompt.clone(), - task_id, - 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, - )); - } - } + accepted_sheet = Some(PuzzleClearGeneratedSheet { + spec: sheet_spec, + prompt: sheet_prompt.clone(), + task_id, + image, + }); + break; } let Some(accepted_sheet) = accepted_sheet else { return Err(puzzle_clear_error_response( @@ -1692,88 +1416,114 @@ fn planned_puzzle_clear_pattern_groups() -> Vec { fn puzzle_clear_atlas_sheet_specs() -> Vec { vec![ + // Sheet 1: 1×2 横向 12 组, 纯横条节奏 PuzzleClearAtlasSheetSpec { sheet_id: "sheet-01", layout: [ - ["D01", "D01", "D01", "A02"], - ["D01", "D01", "D01", "A02"], - ["D02", "D02", "C01", "C01"], - ["D02", "D02", "C01", "C01"], - ["D02", "D02", "C02", "C02"], - ["A01", "A01", "C02", "C02"], + ["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!( - "本张 sheet 的布局如下,编号只给后台理解,绝对不要画在图中:\n\n", - "第1行:D01 D01 D01 A02\n", - "第2行:D01 D01 D01 A02\n", - "第3行:D02 D02 C01 C01\n", - "第4行:D02 D02 C01 C01\n", - "第5行:D02 D02 C02 C02\n", - "第6行:A01 A01 C02 C02\n\n", - "A 表示 1x2 复合图案,C 表示 2x2 复合图案,D 表示 2x3 或 3x2 复合图案。相同编号表示同一视觉家族:请生成一组主题一致、共享同一场景锚点的小照片裁片;同组格子要像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族,但每个 256 单元仍需完整可读,不要做成彼此无关的随机独立小图。" + "整张图集排满 12 个横向场景裁片,每个占 2 格。", + "每格 256×256,4 列 6 行排布,场景间自然过渡。", + "绝对不要画网格线、边框、编号、文字或水印。", ), }, + // Sheet 2: 1×2 纵向 11 组 + 2 FILL, 纯竖条节奏 PuzzleClearAtlasSheetSpec { sheet_id: "sheet-02", layout: [ - ["D03", "D03", "D03", "A04"], - ["D03", "D03", "D03", "A04"], - ["C03", "C03", "C04", "C04"], - ["C03", "C03", "C04", "C04"], - ["B01", "B01", "B01", "A06"], - ["B03", "B03", "B03", "A06"], + ["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], ], layout_prompt: concat!( - "本张 sheet 的布局如下,编号只给后台理解,绝对不要画在图中:\n\n", - "第1行:D03 D03 D03 A04\n", - "第2行:D03 D03 D03 A04\n", - "第3行:C03 C03 C04 C04\n", - "第4行:C03 C03 C04 C04\n", - "第5行:B01 B01 B01 A06\n", - "第6行:B03 B03 B03 A06\n\n", - "A 表示 1x2 复合图案,B 表示 1x3 或 3x1 复合图案,C 表示 2x2 复合图案,D 表示 2x3 或 3x2 复合图案。相同编号表示同一视觉家族:请生成一组主题一致、共享同一场景锚点的小照片裁片;同组格子要像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族,但每个 256 单元仍需完整可读,不要做成彼此无关的随机独立小图。" + "整张图集排满 11 个纵向场景裁片,每个占 1×2 格。", + "每格 256×256,4 列 6 行排布,场景间自然过渡。右下两格为留白。", + "绝对不要画网格线、边框、编号、文字或水印。", ), }, + // Sheet 3: 2×2 正方形 4 组 + 8 FILL, 纯正方节奏 PuzzleClearAtlasSheetSpec { sheet_id: "sheet-03", layout: [ - ["B02", "B04", "A03", "A03"], - ["B02", "B04", "A05", "A05"], - ["B02", "B04", "A07", "A07"], - ["B05", "B05", "B05", "A08"], - ["A09", "A09", "A10", "A08"], - ["A11", "A11", "A10", PUZZLE_CLEAR_SHEET_FILLER_CELL], + ["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, + ], ], layout_prompt: concat!( - "本张 sheet 的布局如下,编号只给后台理解,绝对不要画在图中:\n\n", - "第1行:B02 B04 A03 A03\n", - "第2行:B02 B04 A05 A05\n", - "第3行:B02 B04 A07 A07\n", - "第4行:B05 B05 B05 A08\n", - "第5行:A09 A09 A10 A08\n", - "第6行:A11 A11 A10 FILL\n\n", - "A 表示 1x2 复合图案,B 表示 1x3 或 3x1 复合图案。FILL 是后台会丢弃的补位格,请画成主题一致但不参与玩法的小照片裁片,不要写字或编号。相同编号表示同一视觉家族:请生成一组主题一致、共享同一场景锚点的小照片裁片;同组格子要像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族,但每个 256 单元仍需完整可读,不要做成彼此无关的随机独立小图。" + "整张图集排满 4 个正方形场景裁片,每个占 2×2 格。", + "每格 256×256,4 列 6 行排布,下半部分为留白。", + "绝对不要画网格线、边框、编号、文字或水印。", ), }, + // Sheet 4: 2×3 横向 3 组 + 1×3 纵向 2 组, 大块面节奏 PuzzleClearAtlasSheetSpec { sheet_id: "sheet-04", layout: [ - ["A12", "A13", "A13", "A14"], - ["A12", "A15", "A15", "A14"], - ["A16", "A17", "A17", "A18"], - ["A16", "A19", "A19", "A18"], - ["A20", "A21", "A21", "A22"], - ["A20", "A23", "A23", "A22"], + ["D01", "D01", "D01", "B02"], + ["D01", "D01", "D01", "B02"], + ["D02", "D02", "D02", "B02"], + ["D02", "D02", "D02", "B04"], + ["D03", "D03", "D03", "B04"], + ["D03", "D03", "D03", "B04"], ], layout_prompt: concat!( - "本张 sheet 的布局如下,编号只给后台理解,绝对不要画在图中:\n\n", - "第1行:A12 A13 A13 A14\n", - "第2行:A12 A15 A15 A14\n", - "第3行:A16 A17 A17 A18\n", - "第4行:A16 A19 A19 A18\n", - "第5行:A20 A21 A21 A22\n", - "第6行:A20 A23 A23 A22\n\n", - "A 表示 1x2 复合图案。相同编号表示同一视觉家族:横向 1x2 和纵向 1x2 要共享同一场景锚点,用色调、道具和背景线索互相呼应;每个 256 单元仍需完整可读,但不要做成彼此无关的随机独立小图。" + "整张图集排满 5 个大场景裁片:左边 3 个各占 3×2 格,右边 2 个各占 1×3 格。", + "每格 256×256,4 列 6 行排布,场景间自然过渡。", + "绝对不要画网格线、边框、编号、文字或水印。", + ), + }, + // Sheet 5: 1×3 横向 3 组 + 15 FILL, 纯宽条节奏 + 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, + ], + ], + layout_prompt: concat!( + "整张图集排满 3 个横向宽场景裁片,每个占 3 格。", + "每格 256×256,4 列 6 行排布,下半部分为留白。", + "绝对不要画网格线、边框、编号、文字或水印。", ), }, ] @@ -1786,16 +1536,10 @@ fn build_puzzle_clear_atlas_prompt( let subject = normalize_non_empty_str(theme_prompt).unwrap_or_else(|| "梦幻物件".to_string()); format!( concat!( - "生成一张拼消消素材工作表,主题是「{subject}」,竖版 1024x1536。\n\n", - "这张图供程序后台按 4 列 x 6 行裁切,每个裁切单元为 256x256 的正方形。4x6 网格只用于后台理解,画面中绝对不要画出网格线、切分线、边框、编号或坐标。\n\n", - "这不是单个物体素材表,而是一组照片式构图、绘本式渲染的主题微场景拼图卡。每个编号区域都要属于同一视觉家族,必须有明确背景、环境、道具、光影和构图线索,像从同一组丰富照片或插画中裁出的局部。\n\n", - "每个 256x256 单元本身就是一张完整的单场景照片裁片,单元内部只能有一个连续画面;同组之间要共享同一场景锚点、主色和道具语言。禁止在一个单元内部出现两张照片、两个不同场景、拼接线、分割线、内部竖切、内部横切、左右/上下两块不同背景。场景变化只能发生在 256 单元边界上。\n\n", - "相同编号连续占据的格子表示同一视觉家族,不是随机独立小图。请把同编号区域画成一组可辨认的兄弟卡片,至少共享一个明显场景锚点(同一张桌子、同一窗景、同一庭院、同一篮子或同一器物系统);每个格子可以展示这个家族的不同局部、视角或连贯片段,但仍需完整可读,不能在单格内部再切出第二张图或第二个场景。\n\n", - "同一张 sheet 内,不同编号必须使用不同视觉概念,并且拉开主色、场景和道具,不要把同一种主体换角度、换大小、换姿势后重复使用。比如主题是水果时,不要重复生成不同角度的葡萄、菠萝、西瓜、橙子;应扩展为果园、集市摊位、野餐布、果汁杯、厨房案板、甜品盘、篮筐、玻璃罐、窗边餐桌、花园背景等不同场景。\n\n", - "每个 256x256 单元独立查看时,都应该有可辨识的局部信息:可以包含主体局部、背景纹理、桌面、草地、天空、建筑、布料、器皿、叶片、阴影或装饰元素。不要让小卡只有一个孤立主体加纯色背景。\n\n", - "不同编号区域之间保持干净边界,主体不能越界或挤入相邻编号区域;FILL 补位格可以生成主题一致的小照片裁片,但后台会丢弃它,不要在 FILL 中写字、编号或画规则说明。\n\n", - "图案不要做成商品素材表、卡牌、贴纸、图标格子或带框小卡片。不能有外轮廓框、白色描边、圆角框、阴影框、分隔线、参考线或贴纸边。\n\n", - "画风为高清、清爽、适合休闲消除游戏的丰富主题插画;颜色鲜明,边缘干净,不能出现文字、Logo、水印、按钮、UI 或教程元素。\n\n", + "生成一张拼消消卡牌图集,主题是「{subject}」,竖版 1024x1536。\n", + "照片式构图、绘本式渲染。每格 256×256 是一个完整微场景裁片,", + "同编号连续格共享场景锚点和道具语言,不同编号展现不同视觉概念。", + "不要文字、Logo、水印、UI、网格线、边框、编号、纯色背景或孤立主体。\n", "{layout_prompt}" ), subject = subject, @@ -1931,120 +1675,6 @@ async fn persist_puzzle_clear_data_url_asset( .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 == PUZZLE_CLEAR_SHEET_UNUSED_CELL { - if quality.foreground_ratio > PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO { - findings.push(format!("{cell_label} 空白格有主体")); - } - continue; - } - if group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL { - 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(), @@ -2059,374 +1689,10 @@ fn is_retryable_puzzle_clear_sheet_generation_error(error: &AppError) -> 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 { - let line_ratio = strong as f32 / total as f32; - if line_ratio > PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD - && puzzle_clear_sheet_internal_seam_has_flat_split( - source, bounds, x, true, x_start, x_end, y_start, y_end, - ) - { - strongest = strongest.max(line_ratio); - } - } - } - - 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 { - let line_ratio = strong as f32 / total as f32; - if line_ratio > PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD - && puzzle_clear_sheet_internal_seam_has_flat_split( - source, bounds, y, false, x_start, x_end, y_start, y_end, - ) - { - strongest = strongest.max(line_ratio); - } - } - } - - strongest -} - -fn puzzle_clear_sheet_internal_seam_has_flat_split( - source: &image::DynamicImage, - bounds: PuzzleClearSheetCellBounds, - split: u32, - vertical: bool, - x_start: u32, - x_end: u32, - y_start: u32, - y_end: u32, -) -> bool { - // 中文注释:富场景照片里常有窗框、桌沿、地平线等贯穿强边,只有两侧都近似人工平铺色块时才按拼贴硬失败。 - let band = (bounds.width().min(bounds.height()) / 10).clamp(14, 28); - let (first, second) = if vertical { - let left_start = split.saturating_sub(band).max(x_start); - let left_end = split.saturating_sub(2).max(left_start); - let right_start = split.saturating_add(2).min(x_end); - let right_end = split.saturating_add(band).min(x_end).max(right_start); - ( - puzzle_clear_rgb_stats_for_region(source, left_start, left_end, y_start, y_end), - puzzle_clear_rgb_stats_for_region(source, right_start, right_end, y_start, y_end), - ) - } else { - let top_start = split.saturating_sub(band).max(y_start); - let top_end = split.saturating_sub(2).max(top_start); - let bottom_start = split.saturating_add(2).min(y_end); - let bottom_end = split.saturating_add(band).min(y_end).max(bottom_start); - ( - puzzle_clear_rgb_stats_for_region(source, x_start, x_end, top_start, top_end), - puzzle_clear_rgb_stats_for_region(source, x_start, x_end, bottom_start, bottom_end), - ) - }; - - if first.count == 0 || second.count == 0 { - return false; - } - - let side_contrast = puzzle_clear_rgb_stats_distance(first, second); - let side_texture = first.texture().max(second.texture()); - side_contrast >= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD - && side_texture <= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX -} - -#[derive(Clone, Copy, Debug, Default)] -struct PuzzleClearRgbStats { - count: u64, - sum: [u64; 3], - sum_square: [u64; 3], -} - -impl PuzzleClearRgbStats { - fn push(&mut self, pixel: [u8; 4]) { - self.count = self.count.saturating_add(1); - for (index, channel) in pixel.iter().take(3).enumerate() { - let value = *channel as u64; - self.sum[index] = self.sum[index].saturating_add(value); - self.sum_square[index] = self.sum_square[index].saturating_add(value * value); - } - } - - fn mean_channel(self, index: usize) -> f32 { - if self.count == 0 { - return 0.0; - } - self.sum[index] as f32 / self.count as f32 - } - - fn texture(self) -> f32 { - if self.count == 0 { - return f32::MAX; - } - let mut variance_sum = 0.0f32; - for index in 0..3 { - let mean = self.mean_channel(index); - let mean_square = self.sum_square[index] as f32 / self.count as f32; - variance_sum += (mean_square - mean * mean).max(0.0); - } - variance_sum.sqrt() - } -} - -fn puzzle_clear_rgb_stats_for_region( - source: &image::DynamicImage, - x0: u32, - x1: u32, - y0: u32, - y1: u32, -) -> PuzzleClearRgbStats { - let mut stats = PuzzleClearRgbStats::default(); - if x0 >= x1 || y0 >= y1 { - return stats; - } - for y in (y0..y1).step_by(4) { - for x in (x0..x1).step_by(4) { - stats.push(source.get_pixel(x, y).0); - } - } - stats -} - -fn puzzle_clear_rgb_stats_distance(left: PuzzleClearRgbStats, right: PuzzleClearRgbStats) -> f32 { - (0..3) - .map(|index| (left.mean_channel(index) - right.mean_channel(index)).abs()) - .sum() -} - -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 is_puzzle_clear_sheet_discarded_cell(group_id: &str) -> bool { group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL || group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL } -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 is_puzzle_clear_sheet_discarded_cell(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( image: &DownloadedOpenAiImage, sheet_spec: &PuzzleClearAtlasSheetSpec, @@ -2987,19 +2253,15 @@ mod tests { use super::{ PUZZLE_CLEAR_ATLAS_CELL_SIZE, PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT, PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, PUZZLE_CLEAR_SHEET_FILLER_CELL, - PUZZLE_CLEAR_SHEET_UNUSED_CELL, PuzzleClearAtlasSheetSpec, build_puzzle_clear_atlas_prompt, + PUZZLE_CLEAR_SHEET_UNUSED_CELL, build_puzzle_clear_atlas_prompt, build_puzzle_clear_board_background_prompt, build_puzzle_clear_draft, is_puzzle_clear_sheet_discarded_cell, 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 std::io::Cursor; #[test] fn puzzle_clear_atlas_prompt_uses_sheet_cells_and_subject() { @@ -3010,43 +2272,19 @@ mod tests { let prompt = build_puzzle_clear_atlas_prompt("水果", &sheet); assert!(prompt.contains("主题是「水果」")); assert!(prompt.contains("竖版 1024x1536")); - assert!(prompt.contains("4 列 x 6 行裁切")); - assert!(prompt.contains("256x256 的正方形")); - assert!(prompt.contains("完整的单场景照片裁片")); + assert!(prompt.contains("每格 256×256")); 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("FILL 补位格可以生成主题一致的小照片裁片")); - 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("12 个横向场景裁片")); + 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("24 列 x 38 行")); assert!(!prompt.contains("卡牌小格")); @@ -3086,11 +2324,11 @@ mod tests { .map(|group| group.width * group.height) .sum::(); - assert_eq!(sheets.len(), 4); + assert_eq!(sheets.len(), 5); assert_eq!(groups.len(), 35); - assert_eq!(occupied_sheet_cells, 96); + assert_eq!(occupied_sheet_cells, 120); assert_eq!(playable_sheet_cells, 95); - assert_eq!(filler_sheet_cells, 1); + assert_eq!(filler_sheet_cells, 25); assert_eq!(group_cells, 95); assert_eq!(PUZZLE_CLEAR_ATLAS_CELL_SIZE, 256); assert_eq!(sheet_cells_by_group.len(), groups.len()); @@ -3102,238 +2340,6 @@ 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"); - } - - fn build_test_puzzle_clear_sheet_image_with_cell_pollution( - row: u32, - col: u32, - ) -> DownloadedOpenAiImage { - 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); - } - } - } - } - - let cell_y0 = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE; - let cell_x0 = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE; - for y in cell_y0 + 40..cell_y0 + PUZZLE_CLEAR_ATLAS_CELL_SIZE - 40 { - for x in cell_x0 + 40..cell_x0 + 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"); - DownloadedOpenAiImage { - extension: "png".to_string(), - mime_type: "image/png".to_string(), - bytes: encoded.into_inner(), - } - } - - #[test] - fn puzzle_clear_sheet_quality_allows_filler_cell_pollution() { - let sheet = puzzle_clear_atlas_sheet_specs() - .into_iter() - .find(|sheet| sheet.sheet_id == "sheet-03") - .expect("sheet exists"); - - let image = build_test_puzzle_clear_sheet_image_with_cell_pollution(5, 3); - - validate_puzzle_clear_sheet_quality(&image, &sheet) - .expect("filler cell is generated only to stabilize the sheet and is discarded later"); - } - - #[test] - fn puzzle_clear_sheet_quality_rejects_blank_cell_pollution() { - let sheet = PuzzleClearAtlasSheetSpec { - sheet_id: "blank-test", - layout: [ - [PUZZLE_CLEAR_SHEET_UNUSED_CELL, "A01", "A01", "A02"], - ["A03", "A03", "A04", "A04"], - ["A05", "A05", "A06", "A06"], - ["A07", "A07", "A08", "A08"], - ["A09", "A09", "A10", "A10"], - ["A11", "A11", "A12", "A12"], - ], - layout_prompt: "test", - }; - let image = build_test_puzzle_clear_sheet_image_with_cell_pollution(0, 0); - - 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_allows_textured_scene_divider() { - 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 { - let noise = ((x * 17 + y * 31) % 120) as u8; - let color = if x < PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2 { - Rgba([ - 72u8.saturating_add(noise), - 88u8.saturating_add(((x * 7 + y * 11) % 96) as u8), - 112u8.saturating_add(((x * 5 + y * 13) % 72) as u8), - 255, - ]) - } else { - Rgba([ - 104u8.saturating_add(((x * 19 + y * 3) % 92) as u8), - 76u8.saturating_add(noise), - 56u8.saturating_add(((x * 11 + y * 23) % 88) as u8), - 255, - ]) - }; - source.put_pixel(x, y, color); - } - source.put_pixel(PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2, y, Rgba([24, 24, 24, 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("textured photo-like scene divider should not be rejected as collage"); - } - - #[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!({ @@ -3400,6 +2406,338 @@ mod tests { Some("/creation-type-references/puzzle.webp"), ); } + + #[test] + fn puzzle_clear_full_pipeline_saves_intermediate_results() { + use super::{ + compose_puzzle_clear_final_atlas, slice_puzzle_clear_sheet, PUZZLE_CLEAR_ATLAS_CELL_SIZE, + PUZZLE_CLEAR_SHEET_COLUMNS, PUZZLE_CLEAR_SHEET_ROWS, + PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS, PUZZLE_CLEAR_FINAL_ATLAS_ROWS, + }; + use crate::openai_image_generation::DownloadedOpenAiImage; + use image::{ImageFormat, Rgba, RgbaImage}; + use std::collections::BTreeMap; + use std::io::Cursor; + use std::fs; + + // 中文注释:输出到系统临时目录 + let out = std::env::temp_dir().join("puzzle-clear-test-output"); + fs::create_dir_all(&out).expect("create output dir"); + let sheets_out = out.join("sheets"); + let cards_out = out.join("cards"); + fs::create_dir_all(&sheets_out).expect("create sheets dir"); + fs::create_dir_all(&cards_out).expect("create cards dir"); + + let sheets = puzzle_clear_atlas_sheet_specs(); + let groups = planned_puzzle_clear_pattern_groups(); + let groups_by_id: BTreeMap = groups + .into_iter() + .map(|g| (g.group_id.clone(), g)) + .collect(); + + let w = PUZZLE_CLEAR_SHEET_COLUMNS * PUZZLE_CLEAR_ATLAS_CELL_SIZE; + let h = PUZZLE_CLEAR_SHEET_ROWS * PUZZLE_CLEAR_ATLAS_CELL_SIZE; + + let mut all_slices = Vec::new(); + + for sheet in &sheets { + // 中文注释:为每张 sheet 生成合成测试图,按 group_id 分配颜色 + let mut img = RgbaImage::from_pixel(w, h, Rgba([248, 246, 240, 255])); + for row in 0..PUZZLE_CLEAR_SHEET_ROWS { + for col in 0..PUZZLE_CLEAR_SHEET_COLUMNS { + let gid = sheet.layout[row as usize][col as usize]; + let (r, g, b) = group_color(gid); + let x0 = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE; + let y0 = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE; + for y in y0 + 4..y0 + PUZZLE_CLEAR_ATLAS_CELL_SIZE - 4 { + for x in x0 + 4..x0 + PUZZLE_CLEAR_ATLAS_CELL_SIZE - 4 { + img.put_pixel(x, y, Rgba([r, g, b, 255])); + } + } + } + } + + // 中文注释:保存 sheet 原图 + let mut buf = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(img.clone()) + .write_to(&mut buf, ImageFormat::Png) + .unwrap(); + let downloaded = DownloadedOpenAiImage { + extension: "png".to_string(), + mime_type: "image/png".to_string(), + bytes: buf.into_inner(), + }; + let sheet_path = sheets_out.join(format!("{}.png", sheet.sheet_id)); + fs::write(&sheet_path, &downloaded.bytes).unwrap(); + + // 中文注释:切片 + let task_id = format!("test-{}", sheet.sheet_id); + let slices = slice_puzzle_clear_sheet( + &downloaded, + sheet, + &groups_by_id, + &task_id, + ) + .unwrap_or_else(|e| panic!("slice {} failed: {:?}", sheet.sheet_id, e.body_text())); + + // 中文注释:保存每张切片 + for s in &slices { + let card_name = format!( + "{}-part-{}-{}.png", + s.group.group_id, s.part_x, s.part_y + ); + fs::write(cards_out.join(&card_name), &s.bytes).unwrap(); + } + + all_slices.extend(slices); + } + + // 中文注释:合成最终 atlas + let atlas = compose_puzzle_clear_final_atlas(&all_slices, &groups_by_id) + .expect("compose atlas"); + fs::write(out.join("final-atlas.png"), &atlas.bytes).unwrap(); + + // 中文注释:保存 plan 摘要 + let summary: Vec<_> = sheets.iter().map(|s| { + let cell_count = s.layout.iter().flatten().count(); + let group_count = s.layout.iter().flatten().collect::>().len(); + format!("{}: {} cells, {} groups", s.sheet_id, cell_count, group_count) + }).collect(); + fs::write( + out.join("plan-summary.txt"), + format!( + "sheets={} slices={} atlas={}x{}\n{}", + sheets.len(), + all_slices.len(), + PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS, + PUZZLE_CLEAR_FINAL_ATLAS_ROWS, + summary.join("\n"), + ), + ) + .unwrap(); + + assert_eq!(all_slices.len(), 95, "total slices"); + } + + fn group_color(group_id: &str) -> (u8, u8, u8) { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut h = DefaultHasher::new(); + group_id.hash(&mut h); + let v = h.finish(); + ( + ((v >> 16) & 0xFF) as u8, + ((v >> 8) & 0xFF) as u8, + (v & 0xFF) as u8, + ) + } + + /// 使用真实 VectorEngine API 生图,保存全流程中间结果。 + /// 运行前需设置环境变量:VECTOR_ENGINE_BASE_URL, VECTOR_ENGINE_API_KEY + /// 运行命令:cargo test -p api-server -- puzzle_clear_real_generation --nocapture --ignored + #[test] + #[ignore] + fn puzzle_clear_real_generation() { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + 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, + PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT, PUZZLE_CLEAR_ATLAS_GENERATION_SIZE, + }; + use crate::openai_image_generation::DownloadedOpenAiImage; + use platform_image::{ + VectorEngineImageSettings, build_vector_engine_image_http_client, + create_vector_engine_image_generation, + }; + use std::collections::BTreeMap; + use std::fs; + + let base_url = std::env::var("VECTOR_ENGINE_BASE_URL") + .expect("VECTOR_ENGINE_BASE_URL not set"); + let api_key = std::env::var("VECTOR_ENGINE_API_KEY") + .expect("VECTOR_ENGINE_API_KEY not set"); + + let settings = VectorEngineImageSettings { + base_url, + api_key, + request_timeout_ms: 600_000, + }; + 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"); + fs::create_dir_all(&out).expect("create output dir"); + let sheets_out = out.join("sheets"); + let cards_out = out.join("cards"); + fs::create_dir_all(&sheets_out).expect("create sheets dir"); + fs::create_dir_all(&cards_out).expect("create cards dir"); + + let sheets = puzzle_clear_atlas_sheet_specs(); + let groups = planned_puzzle_clear_pattern_groups(); + let groups_by_id: BTreeMap = groups + .into_iter() + .map(|g| (g.group_id.clone(), g)) + .collect(); + + let theme = "梦幻幻想"; + + let neg = PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT; + let size = PUZZLE_CLEAR_ATLAS_GENERATION_SIZE; + + // 中文注释:5 张 sheet 并行生成 + println!("开始并行生成 {} 张 sheet...", sheets.len()); + let futures: Vec<_> = sheets + .iter() + .map(|sheet| { + let prompt = build_puzzle_clear_atlas_prompt(theme, sheet); + let failure = format!("拼消消测试 {} 生成", sheet.sheet_id); + let client = http_client.clone(); + let settings = settings.clone(); + println!(" -> {} prompt={}chars", sheet.sheet_id, prompt.len()); + async move { + let result = create_vector_engine_image_generation( + &client, &settings, &prompt, Some(neg), size, 1, &[], &failure, + ) + .await + .unwrap_or_else(|e| panic!("{} failed: {:?}", sheet.sheet_id, e)); + let img = result.images.into_iter().next().expect("empty"); + (sheet.sheet_id.to_string(), result.task_id, result.actual_prompt, img) + } + }) + .collect(); + + let results = futures_util::future::join_all(futures).await; + + let mut all_slices = Vec::new(); + for (sheet_id, task_id, actual_prompt, image) in results { + println!( + " {} done: task_id={} actual_prompt={:?} bytes={}", + sheet_id, task_id, actual_prompt, image.bytes.len(), + ); + + let sheet = sheets.iter().find(|s| s.sheet_id == sheet_id).expect("sheet"); + let downloaded = DownloadedOpenAiImage { + extension: image.extension, + mime_type: image.mime_type, + bytes: image.bytes.clone(), + }; + + fs::write(sheets_out.join(format!("{}.png", sheet_id)), &image.bytes).unwrap(); + + let slices = slice_puzzle_clear_sheet( + &downloaded, sheet, &groups_by_id, &task_id, + ) + .unwrap_or_else(|e| panic!("slice {} failed: {:?}", sheet_id, e.body_text())); + + for s in &slices { + let card_name = format!("{}-part-{}-{}.png", s.group.group_id, s.part_x, s.part_y); + fs::write(cards_out.join(&card_name), &s.bytes).unwrap(); + } + println!(" sliced: {} cards", slices.len()); + all_slices.extend(slices); + } + + // 中文注释:合成最终 atlas + let atlas = compose_puzzle_clear_final_atlas(&all_slices, &groups_by_id) + .expect("compose atlas"); + fs::write(out.join("final-atlas.png"), &atlas.bytes).unwrap(); + + println!( + "\nDone: {} sheets, {} slices, atlas {}x{}", + sheets.len(), + all_slices.len(), + super::PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS, + super::PUZZLE_CLEAR_FINAL_ATLAS_ROWS, + ); + println!("Output: {}", out.display()); + + assert_eq!(all_slices.len(), 95, "total slices"); + }); + } + + /// 对比 VectorEngine 与 OpenAI 原生 GPT-image-2 的生图耗时。 + /// 运行:cargo test -p api-server -- benchmark_image_generation --nocapture --ignored + #[test] + #[ignore] + fn benchmark_image_generation() { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + rt.block_on(async { + use platform_image::{ + VectorEngineImageSettings, build_vector_engine_image_http_client, + create_vector_engine_image_generation, + }; + use std::time::Instant; + + let prompt = "生成一张拼消消卡牌图集,主题是「花园」。照片式构图、绘本式渲染。不要文字、水印、网格线、边框。"; + let negative = "文字、Logo、水印、按钮、UI、网格线、边框、编号、标签、纯色背景、白底、孤立主体"; + let size = "1024x1536"; + let sample_count = 3; + + println!("\n===== Benchmark: VectorEngine vs OpenAI gpt-image-2 ====="); + println!("prompt: {}", prompt); + println!("size: {}, samples: {}", size, sample_count); + + let ve_url = std::env::var("VECTOR_ENGINE_BASE_URL") + .unwrap_or_else(|_| "https://api.vectorengine.cn".to_string()); + let ve_key = std::env::var("VECTOR_ENGINE_API_KEY") + .expect("VECTOR_ENGINE_API_KEY not set"); + let ve_settings = VectorEngineImageSettings { + base_url: ve_url, api_key: ve_key, request_timeout_ms: 600_000, + }; + let ve_client = build_vector_engine_image_http_client(&ve_settings).unwrap(); + + let mut ve_times = Vec::new(); + for i in 1..=sample_count { + let start = Instant::now(); + let result = create_vector_engine_image_generation( + &ve_client, &ve_settings, prompt, Some(negative), size, 1, &[], "bench-ve", + ).await.unwrap_or_else(|e| panic!("VE #{i}: {e:?}")); + let elapsed = start.elapsed(); + let bytes = result.images.first().map(|i| i.bytes.len()).unwrap_or(0); + ve_times.push(elapsed); + println!(" VectorEngine #{i}: {:.2}s, {bytes} bytes", elapsed.as_secs_f64()); + } + + let oai_url = "https://api.openai.com/v1/images/generations"; + let oai_key = std::env::var("OPENAI_API_KEY").expect("OPENAI_API_KEY not set"); + let oai_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(600)).build().unwrap(); + + let mut oai_times = Vec::new(); + for i in 1..=sample_count { + let body = serde_json::json!({ + "model": "gpt-image-2", "prompt": prompt, "n": 1, "size": size, + }); + let start = Instant::now(); + let resp = oai_client.post(oai_url) + .header("Authorization", format!("Bearer {}", oai_key)) + .header("Content-Type", "application/json") + .json(&body).send().await.unwrap_or_else(|e| panic!("OAI #{i} req: {e}")); + let status = resp.status(); + let elapsed = start.elapsed(); + let resp_body: serde_json::Value = resp.json().await.unwrap_or_else(|e| panic!("OAI #{i} parse: {e}")); + oai_times.push(elapsed); + let bytes = resp_body["data"][0]["b64_json"].as_str().map(|s| s.len()).unwrap_or(0); + println!(" OpenAI #{i}: {:.2}s, status={status}, {bytes} bytes", elapsed.as_secs_f64()); + } + + println!(); + let ve_avg = ve_times.iter().sum::().as_secs_f64() / ve_times.len() as f64; + let oai_avg = oai_times.iter().sum::().as_secs_f64() / oai_times.len() as f64; + println!("VectorEngine avg: {ve_avg:.2}s ({sample_count} samples)"); + println!("OpenAI avg: {oai_avg:.2}s ({sample_count} samples)"); + for i in 0..sample_count { + let idx = i + 1; + println!( + " #{idx}: VE={:.2}s OAI={:.2}s", + ve_times[i].as_secs_f64(), oai_times[i].as_secs_f64() + ); + } + println!("\nDone."); + }); + } } fn current_utc_micros() -> i64 { diff --git a/server-rs/crates/module-puzzle-clear/src/application.rs b/server-rs/crates/module-puzzle-clear/src/application.rs index e3f8c25c..3f52a5d0 100644 --- a/server-rs/crates/module-puzzle-clear/src/application.rs +++ b/server-rs/crates/module-puzzle-clear/src/application.rs @@ -916,286 +916,41 @@ fn puzzle_clear_pattern_group_specs() -> Vec { use PuzzleClearShapeKind::{OneByThree, OneByTwo, TwoByThree, TwoByTwo}; vec![ - PuzzleClearPatternGroupSpec { - group_id: "D01", - shape: TwoByThree, - width: 3, - height: 2, - atlas_col: 0, - atlas_row: 0, - }, - PuzzleClearPatternGroupSpec { - group_id: "D02", - shape: TwoByThree, - width: 2, - height: 3, - atlas_col: 3, - atlas_row: 0, - }, - PuzzleClearPatternGroupSpec { - group_id: "D03", - shape: TwoByThree, - width: 3, - height: 2, - atlas_col: 5, - atlas_row: 0, - }, - PuzzleClearPatternGroupSpec { - group_id: "C01", - shape: TwoByTwo, - width: 2, - height: 2, - atlas_col: 8, - atlas_row: 0, - }, - PuzzleClearPatternGroupSpec { - group_id: "C02", - shape: TwoByTwo, - width: 2, - height: 2, - atlas_col: 0, - atlas_row: 2, - }, - PuzzleClearPatternGroupSpec { - group_id: "B02", - shape: OneByThree, - width: 1, - height: 3, - atlas_col: 2, - atlas_row: 2, - }, - PuzzleClearPatternGroupSpec { - group_id: "C03", - shape: TwoByTwo, - width: 2, - height: 2, - atlas_col: 5, - atlas_row: 2, - }, - PuzzleClearPatternGroupSpec { - group_id: "C04", - shape: TwoByTwo, - width: 2, - height: 2, - atlas_col: 7, - atlas_row: 2, - }, - PuzzleClearPatternGroupSpec { - group_id: "B04", - shape: OneByThree, - width: 1, - height: 3, - atlas_col: 9, - atlas_row: 2, - }, - PuzzleClearPatternGroupSpec { - group_id: "A02", - shape: OneByTwo, - width: 1, - height: 2, - atlas_col: 3, - atlas_row: 3, - }, - PuzzleClearPatternGroupSpec { - group_id: "A04", - shape: OneByTwo, - width: 1, - height: 2, - atlas_col: 4, - atlas_row: 3, - }, - PuzzleClearPatternGroupSpec { - group_id: "A01", - shape: OneByTwo, - width: 2, - height: 1, - atlas_col: 0, - atlas_row: 4, - }, - PuzzleClearPatternGroupSpec { - group_id: "B01", - shape: OneByThree, - width: 3, - height: 1, - atlas_col: 5, - atlas_row: 4, - }, - PuzzleClearPatternGroupSpec { - group_id: "A06", - shape: OneByTwo, - width: 1, - height: 2, - atlas_col: 8, - atlas_row: 4, - }, - PuzzleClearPatternGroupSpec { - group_id: "B03", - shape: OneByThree, - width: 3, - height: 1, - atlas_col: 0, - atlas_row: 5, - }, - PuzzleClearPatternGroupSpec { - group_id: "B05", - shape: OneByThree, - width: 3, - height: 1, - atlas_col: 3, - atlas_row: 5, - }, - PuzzleClearPatternGroupSpec { - group_id: "A03", - shape: OneByTwo, - width: 2, - height: 1, - atlas_col: 6, - atlas_row: 5, - }, - PuzzleClearPatternGroupSpec { - group_id: "A08", - shape: OneByTwo, - width: 1, - height: 2, - atlas_col: 9, - atlas_row: 5, - }, - PuzzleClearPatternGroupSpec { - group_id: "A05", - shape: OneByTwo, - width: 2, - height: 1, - atlas_col: 0, - atlas_row: 6, - }, - PuzzleClearPatternGroupSpec { - group_id: "A07", - shape: OneByTwo, - width: 2, - height: 1, - atlas_col: 2, - atlas_row: 6, - }, - PuzzleClearPatternGroupSpec { - group_id: "A09", - shape: OneByTwo, - width: 2, - height: 1, - atlas_col: 4, - atlas_row: 6, - }, - PuzzleClearPatternGroupSpec { - group_id: "A10", - shape: OneByTwo, - width: 1, - height: 2, - atlas_col: 6, - atlas_row: 6, - }, - PuzzleClearPatternGroupSpec { - group_id: "A11", - shape: OneByTwo, - width: 2, - height: 1, - atlas_col: 7, - atlas_row: 6, - }, - PuzzleClearPatternGroupSpec { - group_id: "A12", - shape: OneByTwo, - width: 1, - height: 2, - atlas_col: 0, - atlas_row: 7, - }, - PuzzleClearPatternGroupSpec { - group_id: "A13", - shape: OneByTwo, - width: 2, - height: 1, - atlas_col: 1, - atlas_row: 7, - }, - PuzzleClearPatternGroupSpec { - group_id: "A14", - shape: OneByTwo, - width: 1, - height: 2, - atlas_col: 3, - atlas_row: 7, - }, - PuzzleClearPatternGroupSpec { - group_id: "A15", - shape: OneByTwo, - width: 2, - height: 1, - atlas_col: 4, - atlas_row: 7, - }, - PuzzleClearPatternGroupSpec { - group_id: "A16", - shape: OneByTwo, - width: 1, - height: 2, - atlas_col: 7, - atlas_row: 7, - }, - PuzzleClearPatternGroupSpec { - group_id: "A17", - shape: OneByTwo, - width: 2, - height: 1, - atlas_col: 8, - atlas_row: 7, - }, - PuzzleClearPatternGroupSpec { - group_id: "A19", - shape: OneByTwo, - width: 2, - height: 1, - atlas_col: 1, - atlas_row: 8, - }, - PuzzleClearPatternGroupSpec { - group_id: "A18", - shape: OneByTwo, - width: 1, - height: 2, - atlas_col: 4, - atlas_row: 8, - }, - PuzzleClearPatternGroupSpec { - group_id: "A20", - shape: OneByTwo, - width: 1, - height: 2, - atlas_col: 5, - atlas_row: 8, - }, - PuzzleClearPatternGroupSpec { - group_id: "A22", - shape: OneByTwo, - width: 1, - height: 2, - atlas_col: 6, - atlas_row: 8, - }, - PuzzleClearPatternGroupSpec { - group_id: "A21", - shape: OneByTwo, - width: 2, - height: 1, - atlas_col: 8, - atlas_row: 8, - }, - PuzzleClearPatternGroupSpec { - group_id: "A23", - shape: OneByTwo, - width: 2, - height: 1, - atlas_col: 0, - atlas_row: 9, - }, + PuzzleClearPatternGroupSpec { group_id: "B02", shape: OneByThree, width: 1, height: 3, atlas_col: 0, atlas_row: 0 }, + PuzzleClearPatternGroupSpec { group_id: "B04", shape: OneByThree, width: 1, height: 3, atlas_col: 1, atlas_row: 0 }, + PuzzleClearPatternGroupSpec { group_id: "D01", shape: TwoByThree, width: 3, height: 2, atlas_col: 2, atlas_row: 0 }, + PuzzleClearPatternGroupSpec { group_id: "D02", shape: TwoByThree, width: 3, height: 2, atlas_col: 5, atlas_row: 0 }, + PuzzleClearPatternGroupSpec { group_id: "C01", shape: TwoByTwo, width: 2, height: 2, atlas_col: 8, atlas_row: 0 }, + PuzzleClearPatternGroupSpec { group_id: "D03", shape: TwoByThree, width: 3, height: 2, atlas_col: 2, atlas_row: 2 }, + PuzzleClearPatternGroupSpec { group_id: "C02", shape: TwoByTwo, width: 2, height: 2, atlas_col: 5, atlas_row: 2 }, + PuzzleClearPatternGroupSpec { group_id: "C03", shape: TwoByTwo, width: 2, height: 2, atlas_col: 7, atlas_row: 2 }, + PuzzleClearPatternGroupSpec { group_id: "A02", shape: OneByTwo, width: 1, height: 2, atlas_col: 9, atlas_row: 2 }, + PuzzleClearPatternGroupSpec { group_id: "C04", shape: TwoByTwo, width: 2, height: 2, atlas_col: 0, atlas_row: 3 }, + PuzzleClearPatternGroupSpec { group_id: "A04", shape: OneByTwo, width: 1, height: 2, atlas_col: 2, atlas_row: 4 }, + PuzzleClearPatternGroupSpec { group_id: "A06", shape: OneByTwo, width: 1, height: 2, atlas_col: 3, atlas_row: 4 }, + PuzzleClearPatternGroupSpec { group_id: "A08", shape: OneByTwo, width: 1, height: 2, atlas_col: 4, atlas_row: 4 }, + PuzzleClearPatternGroupSpec { group_id: "A10", shape: OneByTwo, width: 1, height: 2, atlas_col: 5, atlas_row: 4 }, + PuzzleClearPatternGroupSpec { group_id: "A12", shape: OneByTwo, width: 1, height: 2, atlas_col: 6, atlas_row: 4 }, + PuzzleClearPatternGroupSpec { group_id: "A14", shape: OneByTwo, width: 1, height: 2, atlas_col: 7, atlas_row: 4 }, + PuzzleClearPatternGroupSpec { group_id: "A16", shape: OneByTwo, width: 1, height: 2, atlas_col: 8, atlas_row: 4 }, + PuzzleClearPatternGroupSpec { group_id: "A18", shape: OneByTwo, width: 1, height: 2, atlas_col: 9, atlas_row: 4 }, + PuzzleClearPatternGroupSpec { group_id: "A20", shape: OneByTwo, width: 1, height: 2, atlas_col: 0, atlas_row: 5 }, + PuzzleClearPatternGroupSpec { group_id: "A22", shape: OneByTwo, width: 1, height: 2, atlas_col: 1, atlas_row: 5 }, + PuzzleClearPatternGroupSpec { group_id: "B01", shape: OneByThree, width: 3, height: 1, atlas_col: 2, atlas_row: 6 }, + PuzzleClearPatternGroupSpec { group_id: "B03", shape: OneByThree, width: 3, height: 1, atlas_col: 5, atlas_row: 6 }, + PuzzleClearPatternGroupSpec { group_id: "A01", shape: OneByTwo, width: 2, height: 1, atlas_col: 8, atlas_row: 6 }, + PuzzleClearPatternGroupSpec { group_id: "B05", shape: OneByThree, width: 3, height: 1, atlas_col: 0, atlas_row: 7 }, + PuzzleClearPatternGroupSpec { group_id: "A03", shape: OneByTwo, width: 2, height: 1, atlas_col: 3, atlas_row: 7 }, + PuzzleClearPatternGroupSpec { group_id: "A05", shape: OneByTwo, width: 2, height: 1, atlas_col: 5, atlas_row: 7 }, + PuzzleClearPatternGroupSpec { group_id: "A07", shape: OneByTwo, width: 2, height: 1, atlas_col: 7, atlas_row: 7 }, + PuzzleClearPatternGroupSpec { group_id: "A09", shape: OneByTwo, width: 2, height: 1, atlas_col: 0, atlas_row: 8 }, + PuzzleClearPatternGroupSpec { group_id: "A11", shape: OneByTwo, width: 2, height: 1, atlas_col: 2, atlas_row: 8 }, + PuzzleClearPatternGroupSpec { group_id: "A13", shape: OneByTwo, width: 2, height: 1, atlas_col: 4, atlas_row: 8 }, + PuzzleClearPatternGroupSpec { group_id: "A15", shape: OneByTwo, width: 2, height: 1, atlas_col: 6, atlas_row: 8 }, + PuzzleClearPatternGroupSpec { group_id: "A17", shape: OneByTwo, width: 2, height: 1, atlas_col: 8, atlas_row: 8 }, + PuzzleClearPatternGroupSpec { group_id: "A19", shape: OneByTwo, width: 2, height: 1, atlas_col: 0, atlas_row: 9 }, + PuzzleClearPatternGroupSpec { group_id: "A21", shape: OneByTwo, width: 2, height: 1, atlas_col: 2, atlas_row: 9 }, + PuzzleClearPatternGroupSpec { group_id: "A23", shape: OneByTwo, width: 2, height: 1, atlas_col: 4, atlas_row: 9 }, ] } diff --git a/server-rs/crates/spacetime-client/src/puzzle_clear.rs b/server-rs/crates/spacetime-client/src/puzzle_clear.rs index 8d1dc0ed..2de54a01 100644 --- a/server-rs/crates/spacetime-client/src/puzzle_clear.rs +++ b/server-rs/crates/spacetime-client/src/puzzle_clear.rs @@ -16,7 +16,7 @@ use shared_kernel::build_prefixed_uuid_id; const PUZZLE_CLEAR_TEMPLATE_ID: &str = "puzzle-clear"; const PUZZLE_CLEAR_TEMPLATE_NAME: &str = "拼消消"; -const PUZZLE_CLEAR_ATLAS_CELL_SIZE: u32 = 128; +const PUZZLE_CLEAR_ATLAS_CELL_SIZE: u32 = 256; const PUZZLE_CLEAR_ASSET_OBJECT_ID_PREFIX: &str = "assetobj_"; impl SpacetimeClient { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 640330c6..88ac6578 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -3130,6 +3130,7 @@ export function PlatformEntryFlowShellImpl({ 'big-fish': bigFishGenerationState, 'square-hole': squareHoleGenerationState, 'jump-hop': jumpHopGenerationState, + 'puzzle-clear': puzzleClearGenerationState, 'wooden-fish': woodenFishGenerationState, 'baby-object-match': babyObjectMatchGenerationState, }, @@ -6202,6 +6203,9 @@ export function PlatformEntryFlowShellImpl({ puzzle: () => { void openPuzzleWorkspace(); }, + 'puzzle-clear': () => { + void openPuzzleClearWorkspace(); + }, 'bark-battle': () => { void openBarkBattleWorkspace(); }, diff --git a/src/components/platform-entry/platformCreationLaunchModel.ts b/src/components/platform-entry/platformCreationLaunchModel.ts index 0b2b210c..b9b1f171 100644 --- a/src/components/platform-entry/platformCreationLaunchModel.ts +++ b/src/components/platform-entry/platformCreationLaunchModel.ts @@ -9,6 +9,7 @@ export type PlatformCreationLaunchTarget = | 'jump-hop' | 'wooden-fish' | 'puzzle' + | 'puzzle-clear' | 'bark-battle' | 'visual-novel' | 'baby-object-match'; @@ -43,6 +44,7 @@ const PLATFORM_CREATION_LAUNCH_TARGETS = new Set([ 'jump-hop', 'wooden-fish', 'puzzle', + 'puzzle-clear', 'bark-battle', 'visual-novel', 'baby-object-match', diff --git a/src/components/platform-entry/platformGenerationProgressTickModel.ts b/src/components/platform-entry/platformGenerationProgressTickModel.ts index 67a29bdd..11ad151e 100644 --- a/src/components/platform-entry/platformGenerationProgressTickModel.ts +++ b/src/components/platform-entry/platformGenerationProgressTickModel.ts @@ -38,6 +38,7 @@ const MINI_GAME_GENERATION_STAGE_TO_KIND: Partial< 'square-hole-generating': 'square-hole', 'jump-hop-generating': 'jump-hop', 'wooden-fish-generating': 'wooden-fish', + 'puzzle-clear-generating': 'puzzle-clear', 'baby-object-match-generating': 'baby-object-match', };