fix: 完善拼消消模板运行规则
This commit is contained in:
@@ -25,6 +25,8 @@ use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
env, fs,
|
||||
path::{Path as FsPath, PathBuf},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
@@ -38,8 +40,8 @@ use crate::{
|
||||
},
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
|
||||
require_openai_image_settings,
|
||||
DownloadedOpenAiImage, build_openai_image_http_client, build_openai_image_request_body,
|
||||
create_openai_image_generation, require_openai_image_settings,
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
@@ -599,6 +601,721 @@ struct PuzzleClearGeneratedSheet {
|
||||
image: DownloadedOpenAiImage,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PuzzleClearImageDebugRun {
|
||||
root: PathBuf,
|
||||
run_id: String,
|
||||
}
|
||||
|
||||
impl PuzzleClearImageDebugRun {
|
||||
fn record_spec(
|
||||
&self,
|
||||
sheet_specs: &[PuzzleClearAtlasSheetSpec],
|
||||
groups: &[PuzzleClearPatternGroup],
|
||||
) {
|
||||
let sheets = sheet_specs
|
||||
.iter()
|
||||
.map(|spec| {
|
||||
json!({
|
||||
"sheetId": spec.sheet_id,
|
||||
"layout": spec.layout.iter().map(|row| row.to_vec()).collect::<Vec<_>>(),
|
||||
"layoutPrompt": spec.layout_prompt,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let groups = groups
|
||||
.iter()
|
||||
.map(|group| {
|
||||
json!({
|
||||
"groupId": group.group_id.as_str(),
|
||||
"shape": group.shape.as_str(),
|
||||
"width": group.width,
|
||||
"height": group.height,
|
||||
"atlasX": group.atlas_x,
|
||||
"atlasY": group.atlas_y,
|
||||
"atlasWidth": group.atlas_width,
|
||||
"atlasHeight": group.atlas_height,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
self.write_json(
|
||||
"specs/puzzle-clear-debug-spec.json",
|
||||
&json!({
|
||||
"cellSize": PUZZLE_CLEAR_ATLAS_CELL_SIZE,
|
||||
"sheetColumns": PUZZLE_CLEAR_SHEET_COLUMNS,
|
||||
"sheetRows": PUZZLE_CLEAR_SHEET_ROWS,
|
||||
"finalAtlasColumns": PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS,
|
||||
"finalAtlasRows": PUZZLE_CLEAR_FINAL_ATLAS_ROWS,
|
||||
"sheets": sheets,
|
||||
"groups": groups,
|
||||
}),
|
||||
"记录拼消消调试规格失败",
|
||||
);
|
||||
}
|
||||
|
||||
fn record_board_background_request(&self, prompt: &str) {
|
||||
self.write_text(
|
||||
"prompts/board-background.txt",
|
||||
prompt,
|
||||
"记录拼消消底图 prompt 失败",
|
||||
);
|
||||
self.write_json(
|
||||
"requests/board-background.json",
|
||||
&json!({
|
||||
"endpoint": "/v1/images/generations",
|
||||
"body": build_openai_image_request_body(
|
||||
prompt,
|
||||
Some("文字、水印、按钮、教程浮层、明显网格"),
|
||||
PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE,
|
||||
1,
|
||||
&[],
|
||||
),
|
||||
}),
|
||||
"记录拼消消底图 request 失败",
|
||||
);
|
||||
}
|
||||
|
||||
fn record_board_background_image(
|
||||
&self,
|
||||
task_id: &str,
|
||||
actual_prompt: Option<&str>,
|
||||
image: &DownloadedOpenAiImage,
|
||||
) {
|
||||
let extension = puzzle_clear_debug_image_extension(image);
|
||||
self.write_bytes(
|
||||
format!("background/board-background.{extension}"),
|
||||
image.bytes.as_slice(),
|
||||
"记录拼消消底图图片失败",
|
||||
);
|
||||
self.write_json(
|
||||
"responses/board-background.json",
|
||||
&json!({
|
||||
"taskId": task_id,
|
||||
"actualPrompt": actual_prompt,
|
||||
"image": {
|
||||
"mimeType": image.mime_type.as_str(),
|
||||
"extension": image.extension.as_str(),
|
||||
"byteLength": image.bytes.len(),
|
||||
},
|
||||
}),
|
||||
"记录拼消消底图 response 摘要失败",
|
||||
);
|
||||
}
|
||||
|
||||
fn record_board_background_error(&self, error: &AppError) {
|
||||
self.write_json(
|
||||
"responses/board-background.error.json",
|
||||
&puzzle_clear_debug_error_json(error),
|
||||
"记录拼消消底图错误失败",
|
||||
);
|
||||
}
|
||||
|
||||
fn record_sheet_request(
|
||||
&self,
|
||||
sheet_spec: &PuzzleClearAtlasSheetSpec,
|
||||
attempt_index: usize,
|
||||
prompt: &str,
|
||||
) {
|
||||
let attempt_id = puzzle_clear_debug_attempt_id(attempt_index);
|
||||
self.write_text(
|
||||
format!("prompts/{}.txt", sheet_spec.sheet_id),
|
||||
prompt,
|
||||
"记录拼消消 sheet prompt 失败",
|
||||
);
|
||||
self.write_json(
|
||||
format!("requests/{}-{attempt_id}.json", sheet_spec.sheet_id),
|
||||
&json!({
|
||||
"endpoint": "/v1/images/generations",
|
||||
"sheetId": sheet_spec.sheet_id,
|
||||
"attempt": attempt_index + 1,
|
||||
"body": build_openai_image_request_body(
|
||||
prompt,
|
||||
Some(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT),
|
||||
PUZZLE_CLEAR_ATLAS_GENERATION_SIZE,
|
||||
1,
|
||||
&[],
|
||||
),
|
||||
}),
|
||||
"记录拼消消 sheet request 失败",
|
||||
);
|
||||
}
|
||||
|
||||
fn record_sheet_generation_error(
|
||||
&self,
|
||||
sheet_spec: &PuzzleClearAtlasSheetSpec,
|
||||
attempt_index: usize,
|
||||
error: &AppError,
|
||||
) {
|
||||
let attempt_id = puzzle_clear_debug_attempt_id(attempt_index);
|
||||
self.write_json(
|
||||
format!("responses/{}-{attempt_id}.error.json", sheet_spec.sheet_id),
|
||||
&json!({
|
||||
"sheetId": sheet_spec.sheet_id,
|
||||
"attempt": attempt_index + 1,
|
||||
"stage": "generation",
|
||||
"error": puzzle_clear_debug_error_json(error),
|
||||
}),
|
||||
"记录拼消消 sheet 生成错误失败",
|
||||
);
|
||||
}
|
||||
|
||||
fn record_sheet_attempt_image(
|
||||
&self,
|
||||
sheet_spec: &PuzzleClearAtlasSheetSpec,
|
||||
attempt_index: usize,
|
||||
task_id: &str,
|
||||
actual_prompt: Option<&str>,
|
||||
image: &DownloadedOpenAiImage,
|
||||
) {
|
||||
let attempt_id = puzzle_clear_debug_attempt_id(attempt_index);
|
||||
let extension = puzzle_clear_debug_image_extension(image);
|
||||
self.write_bytes(
|
||||
format!("sheets/{}-{attempt_id}.{extension}", sheet_spec.sheet_id),
|
||||
image.bytes.as_slice(),
|
||||
"记录拼消消 sheet 原图失败",
|
||||
);
|
||||
self.write_json(
|
||||
format!("responses/{}-{attempt_id}.json", sheet_spec.sheet_id),
|
||||
&json!({
|
||||
"sheetId": sheet_spec.sheet_id,
|
||||
"attempt": attempt_index + 1,
|
||||
"taskId": task_id,
|
||||
"actualPrompt": actual_prompt,
|
||||
"image": {
|
||||
"mimeType": image.mime_type.as_str(),
|
||||
"extension": image.extension.as_str(),
|
||||
"byteLength": image.bytes.len(),
|
||||
},
|
||||
}),
|
||||
"记录拼消消 sheet response 摘要失败",
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
task_id: &str,
|
||||
image: &DownloadedOpenAiImage,
|
||||
) {
|
||||
let extension = puzzle_clear_debug_image_extension(image);
|
||||
self.write_bytes(
|
||||
format!("accepted/{}.{extension}", sheet_spec.sheet_id),
|
||||
image.bytes.as_slice(),
|
||||
"记录拼消消 accepted sheet 失败",
|
||||
);
|
||||
self.write_json(
|
||||
format!("accepted/{}.json", sheet_spec.sheet_id),
|
||||
&json!({
|
||||
"sheetId": sheet_spec.sheet_id,
|
||||
"taskId": task_id,
|
||||
"accepted": true,
|
||||
"image": {
|
||||
"mimeType": image.mime_type.as_str(),
|
||||
"extension": image.extension.as_str(),
|
||||
"byteLength": image.bytes.len(),
|
||||
},
|
||||
}),
|
||||
"记录拼消消 accepted sheet 摘要失败",
|
||||
);
|
||||
}
|
||||
|
||||
fn record_atlas_image(
|
||||
&self,
|
||||
image: &DownloadedOpenAiImage,
|
||||
generated_sheets: &[PuzzleClearGeneratedSheet],
|
||||
) {
|
||||
self.write_bytes(
|
||||
"atlas/puzzle-clear-atlas.png",
|
||||
image.bytes.as_slice(),
|
||||
"记录拼消消最终 atlas 失败",
|
||||
);
|
||||
self.write_json(
|
||||
"atlas/puzzle-clear-atlas.json",
|
||||
&json!({
|
||||
"image": {
|
||||
"mimeType": image.mime_type.as_str(),
|
||||
"extension": image.extension.as_str(),
|
||||
"byteLength": image.bytes.len(),
|
||||
},
|
||||
"acceptedSheets": generated_sheets
|
||||
.iter()
|
||||
.map(|sheet| json!({
|
||||
"sheetId": sheet.spec.sheet_id,
|
||||
"taskId": sheet.task_id.as_str(),
|
||||
}))
|
||||
.collect::<Vec<_>>(),
|
||||
}),
|
||||
"记录拼消消最终 atlas 摘要失败",
|
||||
);
|
||||
}
|
||||
|
||||
fn record_summary_success(
|
||||
&self,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
sheet_count: usize,
|
||||
card_count: usize,
|
||||
) {
|
||||
self.write_text(
|
||||
"summary.md",
|
||||
format!(
|
||||
concat!(
|
||||
"# 拼消消 runtime 生图调试\n\n",
|
||||
"- runId: `{}`\n",
|
||||
"- sessionId: `{}`\n",
|
||||
"- profileId: `{}`\n",
|
||||
"- status: `ready`\n",
|
||||
"- acceptedSheets: `{}`\n",
|
||||
"- cardCount: `{}`\n\n",
|
||||
"关键查看顺序:\n\n",
|
||||
"1. `prompts/` 和 `requests/`:真实请求内容。\n",
|
||||
"2. `sheets/`:每次 attempt 的原始 sheet。\n",
|
||||
"3. `reports/*.quality.json`:每格质量指标和 hard/advisory findings。\n",
|
||||
"4. `cells/<sheet-attempt>/contact-sheet.png`:4x6 裁切总览。\n",
|
||||
"5. `accepted/`:最终通过门禁的 sheet。\n",
|
||||
"6. `atlas/puzzle-clear-atlas.png`:最终 atlas 预览。\n"
|
||||
),
|
||||
self.run_id, session_id, profile_id, sheet_count, card_count
|
||||
),
|
||||
"记录拼消消调试 summary 失败",
|
||||
);
|
||||
}
|
||||
|
||||
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<FsPath>,
|
||||
content: impl AsRef<str>,
|
||||
action: &'static str,
|
||||
) {
|
||||
self.write_result(action, || {
|
||||
let target = self.root.join(relative_path.as_ref());
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(target, content.as_ref().as_bytes())
|
||||
});
|
||||
}
|
||||
|
||||
fn write_json(&self, relative_path: impl AsRef<FsPath>, value: &Value, action: &'static str) {
|
||||
self.write_result(action, || {
|
||||
let target = self.root.join(relative_path.as_ref());
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut bytes = serde_json::to_vec_pretty(value)
|
||||
.map_err(|error| std::io::Error::new(std::io::ErrorKind::Other, error))?;
|
||||
bytes.push(b'\n');
|
||||
fs::write(target, bytes)
|
||||
});
|
||||
}
|
||||
|
||||
fn write_bytes(
|
||||
&self,
|
||||
relative_path: impl AsRef<FsPath>,
|
||||
bytes: &[u8],
|
||||
action: &'static str,
|
||||
) {
|
||||
self.write_result(action, || {
|
||||
let target = self.root.join(relative_path.as_ref());
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(target, bytes)
|
||||
});
|
||||
}
|
||||
|
||||
fn write_result(
|
||||
&self,
|
||||
action: &'static str,
|
||||
operation: impl FnOnce() -> std::io::Result<()>,
|
||||
) {
|
||||
if let Err(error) = operation() {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
|
||||
run_id = self.run_id,
|
||||
debug_path = %self.root.display(),
|
||||
error = %error,
|
||||
action,
|
||||
"拼消消本地生图调试包写入失败,已忽略"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_create_puzzle_clear_image_debug_run(
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
theme_prompt: &str,
|
||||
) -> Option<PuzzleClearImageDebugRun> {
|
||||
if !puzzle_clear_image_debug_enabled() {
|
||||
return None;
|
||||
}
|
||||
let base_dir = puzzle_clear_image_debug_runs_dir();
|
||||
let run_id = env::var("PUZZLE_CLEAR_IMAGE_DEBUG_RUN_ID")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"runtime-{}-{}-{}",
|
||||
sanitize_puzzle_clear_debug_segment(session_id, "session"),
|
||||
sanitize_puzzle_clear_debug_segment(profile_id, "profile"),
|
||||
current_utc_micros()
|
||||
)
|
||||
});
|
||||
let run_id = sanitize_puzzle_clear_debug_segment(run_id.as_str(), "runtime");
|
||||
let root = base_dir.join(run_id.as_str());
|
||||
if let Err(error) = fs::create_dir_all(&root) {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
|
||||
debug_path = %root.display(),
|
||||
error = %error,
|
||||
"拼消消本地生图调试包初始化失败,已关闭本次记录"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let debug_run = PuzzleClearImageDebugRun { root, run_id };
|
||||
debug_run.write_json(
|
||||
"manifest.json",
|
||||
&json!({
|
||||
"runId": debug_run.run_id.as_str(),
|
||||
"source": "api-server-runtime",
|
||||
"playType": "puzzle-clear",
|
||||
"sessionId": session_id,
|
||||
"profileId": profile_id,
|
||||
"themePrompt": theme_prompt,
|
||||
"createdAt": format_timestamp_micros(current_utc_micros()),
|
||||
"env": {
|
||||
"PUZZLE_CLEAR_IMAGE_DEBUG_ENABLED": "1",
|
||||
"PUZZLE_CLEAR_IMAGE_DEBUG_DIR": puzzle_clear_image_debug_runs_dir().display().to_string(),
|
||||
},
|
||||
}),
|
||||
"记录拼消消调试 manifest 失败",
|
||||
);
|
||||
if let Err(error) = fs::write(
|
||||
puzzle_clear_image_debug_runs_dir().join("latest-runtime.txt"),
|
||||
debug_run.root.to_string_lossy().as_bytes(),
|
||||
) {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
|
||||
error = %error,
|
||||
"拼消消本地生图调试包 latest-runtime.txt 写入失败,已忽略"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
|
||||
run_id = debug_run.run_id.as_str(),
|
||||
debug_path = %debug_run.root.display(),
|
||||
"拼消消本地生图调试包已开启"
|
||||
);
|
||||
Some(debug_run)
|
||||
}
|
||||
|
||||
fn puzzle_clear_image_debug_enabled() -> bool {
|
||||
env::var("PUZZLE_CLEAR_IMAGE_DEBUG_ENABLED")
|
||||
.ok()
|
||||
.map(|value| {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn puzzle_clear_image_debug_runs_dir() -> PathBuf {
|
||||
env::var("PUZZLE_CLEAR_IMAGE_DEBUG_DIR")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
puzzle_clear_repo_root()
|
||||
.join(".app")
|
||||
.join("puzzle-clear-image-debug")
|
||||
.join("runs")
|
||||
})
|
||||
}
|
||||
|
||||
fn puzzle_clear_repo_root() -> PathBuf {
|
||||
let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
for candidate in current_dir.ancestors() {
|
||||
if candidate.join("server-rs").is_dir() && candidate.join("package.json").is_file() {
|
||||
return candidate.to_path_buf();
|
||||
}
|
||||
}
|
||||
current_dir
|
||||
}
|
||||
|
||||
fn puzzle_clear_debug_attempt_id(attempt_index: usize) -> String {
|
||||
format!("attempt-{:02}", attempt_index + 1)
|
||||
}
|
||||
|
||||
fn puzzle_clear_debug_image_extension(image: &DownloadedOpenAiImage) -> String {
|
||||
sanitize_puzzle_clear_debug_segment(
|
||||
image
|
||||
.extension
|
||||
.trim()
|
||||
.trim_start_matches('.')
|
||||
.to_ascii_lowercase()
|
||||
.as_str(),
|
||||
"png",
|
||||
)
|
||||
}
|
||||
|
||||
fn sanitize_puzzle_clear_debug_segment(raw: &str, fallback: &str) -> String {
|
||||
let mut value = String::new();
|
||||
for character in raw.chars() {
|
||||
if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') {
|
||||
value.push(character);
|
||||
} else {
|
||||
value.push('-');
|
||||
}
|
||||
}
|
||||
let value = value.trim_matches('-').trim_matches('.').to_string();
|
||||
if value.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
fn puzzle_clear_debug_error_json(error: &AppError) -> Value {
|
||||
json!({
|
||||
"statusCode": error.status_code().as_u16(),
|
||||
"code": error.code(),
|
||||
"message": error.body_text(),
|
||||
"details": error.details(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_puzzle_clear_sheet_quality_debug_report(
|
||||
sheet_spec: &PuzzleClearAtlasSheetSpec,
|
||||
attempt_index: usize,
|
||||
task_id: &str,
|
||||
image: &DownloadedOpenAiImage,
|
||||
quality_error: Option<&AppError>,
|
||||
) -> Result<Value, 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 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,
|
||||
@@ -633,8 +1350,13 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
build_prefixed_uuid_id(module_puzzle_clear::PUZZLE_CLEAR_PROFILE_ID_PREFIX)
|
||||
});
|
||||
});
|
||||
payload.profile_id = Some(profile_id.clone());
|
||||
let image_debug_run = maybe_create_puzzle_clear_image_debug_run(
|
||||
session_id,
|
||||
profile_id.as_str(),
|
||||
payload.theme_prompt.as_deref().unwrap_or_default(),
|
||||
);
|
||||
|
||||
if payload.generate_board_background.unwrap_or(false)
|
||||
&& payload
|
||||
@@ -654,6 +1376,7 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
owner_user_id,
|
||||
profile_id.as_str(),
|
||||
board_background_prompt.unwrap_or(theme_prompt),
|
||||
image_debug_run.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
payload.board_background_asset = Some(background_asset);
|
||||
@@ -683,6 +1406,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
.map(|group| (group.group_id.clone(), group))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let sheet_specs = puzzle_clear_atlas_sheet_specs();
|
||||
if let Some(debug_run) = image_debug_run.as_ref() {
|
||||
debug_run.record_spec(&sheet_specs, &groups);
|
||||
}
|
||||
let settings = require_openai_image_settings(state)
|
||||
.map(|settings| {
|
||||
settings.with_external_api_audit_context(
|
||||
@@ -710,6 +1436,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
sheet_spec.sheet_id,
|
||||
attempt_index + 1
|
||||
);
|
||||
if let Some(debug_run) = image_debug_run.as_ref() {
|
||||
debug_run.record_sheet_request(&sheet_spec, attempt_index, sheet_prompt.as_str());
|
||||
}
|
||||
let generated = match create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
@@ -727,6 +1456,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS
|
||||
&& is_retryable_puzzle_clear_sheet_generation_error(&error) =>
|
||||
{
|
||||
if let Some(debug_run) = image_debug_run.as_ref() {
|
||||
debug_run.record_sheet_generation_error(&sheet_spec, attempt_index, &error);
|
||||
}
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
|
||||
sheet_id = sheet_spec.sheet_id,
|
||||
@@ -737,6 +1469,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
if let Some(debug_run) = image_debug_run.as_ref() {
|
||||
debug_run.record_sheet_generation_error(&sheet_spec, attempt_index, &error);
|
||||
}
|
||||
return Err(puzzle_clear_error_response(
|
||||
request_context,
|
||||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||||
@@ -745,6 +1480,7 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
}
|
||||
};
|
||||
let task_id = generated.task_id;
|
||||
let actual_prompt = generated.actual_prompt;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
puzzle_clear_error_response(
|
||||
request_context,
|
||||
@@ -755,8 +1491,30 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
match validate_puzzle_clear_sheet_quality(&image, &sheet_spec) {
|
||||
if let Some(debug_run) = image_debug_run.as_ref() {
|
||||
debug_run.record_sheet_attempt_image(
|
||||
&sheet_spec,
|
||||
attempt_index,
|
||||
task_id.as_str(),
|
||||
actual_prompt.as_deref(),
|
||||
&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(),
|
||||
@@ -821,6 +1579,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
compose_puzzle_clear_final_atlas(&slices, &groups_by_id).map_err(|error| {
|
||||
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
|
||||
})?;
|
||||
if let Some(debug_run) = image_debug_run.as_ref() {
|
||||
debug_run.record_atlas_image(&atlas_image, &generated_sheets);
|
||||
}
|
||||
let atlas_prompt = generated_sheets
|
||||
.iter()
|
||||
.map(|sheet| format!("{}:\n{}", sheet.spec.sheet_id, sheet.prompt))
|
||||
@@ -864,6 +1625,14 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
payload.atlas_asset = Some(atlas_asset);
|
||||
payload.pattern_groups = Some(groups);
|
||||
payload.card_assets = Some(card_assets);
|
||||
if let Some(debug_run) = image_debug_run.as_ref() {
|
||||
debug_run.record_summary_success(
|
||||
session_id,
|
||||
profile_id.as_str(),
|
||||
generated_sheets.len(),
|
||||
payload.card_assets.as_ref().map_or(0, Vec::len),
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
|
||||
session_id,
|
||||
@@ -1054,8 +1823,12 @@ async fn generate_and_persist_puzzle_clear_board_background(
|
||||
owner_user_id: &str,
|
||||
profile_id: &str,
|
||||
theme_prompt: &str,
|
||||
image_debug_run: Option<&PuzzleClearImageDebugRun>,
|
||||
) -> Result<PuzzleClearImageAsset, Response> {
|
||||
let prompt = build_puzzle_clear_board_background_prompt(theme_prompt);
|
||||
if let Some(debug_run) = image_debug_run {
|
||||
debug_run.record_board_background_request(prompt.as_str());
|
||||
}
|
||||
let settings = require_openai_image_settings(state)
|
||||
.map(|settings| {
|
||||
settings.with_external_api_audit_context(
|
||||
@@ -1082,9 +1855,13 @@ async fn generate_and_persist_puzzle_clear_board_background(
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if let Some(debug_run) = image_debug_run {
|
||||
debug_run.record_board_background_error(&error);
|
||||
}
|
||||
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
|
||||
})?;
|
||||
let task_id = generated.task_id;
|
||||
let actual_prompt = generated.actual_prompt;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
puzzle_clear_error_response(
|
||||
request_context,
|
||||
@@ -1095,6 +1872,9 @@ async fn generate_and_persist_puzzle_clear_board_background(
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
if let Some(debug_run) = image_debug_run {
|
||||
debug_run.record_board_background_image(task_id.as_str(), actual_prompt.as_deref(), &image);
|
||||
}
|
||||
persist_puzzle_clear_generated_image_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
@@ -2140,7 +2920,11 @@ fn map_puzzle_clear_client_error(error: SpacetimeClientError) -> AppError {
|
||||
if value.contains("发布需要")
|
||||
|| value.contains("不能为空")
|
||||
|| value.contains("必须")
|
||||
|| value.contains("无权") =>
|
||||
|| value.contains("无权")
|
||||
|| value.contains("puzzle-clear 坐标无效")
|
||||
|| value.contains("puzzle-clear 目标格子没有卡牌")
|
||||
|| value.contains("puzzle-clear 当前 run 不在 playing 状态")
|
||||
|| value.contains("puzzle-clear 当前关卡已经超时") =>
|
||||
{
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user