7048c05ebf
更新外部编辑器 API skill 的画板、素材库和美术规范引导。 扩展 Python helper,自动准备画板会话、长超时生成并补齐角色动画素材入库。 将新建项目和生成任务 ID 前缀调整为 proj/task,并保留旧 ID 兼容。 更新 OpenAPI 说明和项目记忆。
2810 lines
108 KiB
Rust
2810 lines
108 KiB
Rust
use axum::{
|
||
Json,
|
||
body::{Body, to_bytes},
|
||
extract::{Extension, Path, State, rejection::JsonRejection},
|
||
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,
|
||
};
|
||
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Value, json};
|
||
use shared_contracts::external_generation::{
|
||
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
|
||
};
|
||
use shared_contracts::puzzle_clear::{
|
||
PuzzleClearActionRequest, PuzzleClearActionType, PuzzleClearCardAsset,
|
||
PuzzleClearDraftResponse, PuzzleClearGenerationStatus, PuzzleClearImageAsset,
|
||
PuzzleClearNextLevelRequest, PuzzleClearPatternGroup, PuzzleClearRetryLevelRequest,
|
||
PuzzleClearRunResponse, PuzzleClearSessionResponse, PuzzleClearSessionSnapshotResponse,
|
||
PuzzleClearStartRunRequest, PuzzleClearSwapRequest, PuzzleClearTimeUpRequest,
|
||
PuzzleClearWorkDetailResponse, PuzzleClearWorkMutationResponse, PuzzleClearWorksResponse,
|
||
PuzzleClearWorkspaceCreateRequest,
|
||
};
|
||
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||
use spacetime_client::{
|
||
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, SpacetimeClientError,
|
||
};
|
||
use std::{
|
||
collections::BTreeMap,
|
||
time::{SystemTime, UNIX_EPOCH},
|
||
};
|
||
|
||
use crate::{
|
||
api_response::json_success_body,
|
||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
||
generated_image_assets::{
|
||
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
||
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
|
||
decode_generated_image_asset_data_url, normalize_generated_image_asset_mime,
|
||
},
|
||
http_error::AppError,
|
||
openai_image_generation::{
|
||
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
|
||
require_openai_image_settings,
|
||
},
|
||
request_context::RequestContext,
|
||
state::AppState,
|
||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||
};
|
||
|
||
const PUZZLE_CLEAR_PROVIDER: &str = "puzzle-clear";
|
||
const PUZZLE_CLEAR_CREATION_PROVIDER: &str = "puzzle-clear-creation";
|
||
const PUZZLE_CLEAR_RUNTIME_PROVIDER: &str = "puzzle-clear-runtime";
|
||
const PUZZLE_CLEAR_TEMPLATE_ID: &str = "puzzle-clear";
|
||
const PUZZLE_CLEAR_TEMPLATE_NAME: &str = "拼消消";
|
||
pub(crate) const PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_clear_compile_draft";
|
||
const PUZZLE_CLEAR_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/puzzle-clear/runs";
|
||
const PUZZLE_CLEAR_ATLAS_CELL_SIZE: u32 = 256;
|
||
const PUZZLE_CLEAR_SHEET_COLUMNS: u32 = 4;
|
||
const PUZZLE_CLEAR_SHEET_ROWS: u32 = 6;
|
||
const PUZZLE_CLEAR_SHEET_COLUMNS_USIZE: usize = 4;
|
||
const PUZZLE_CLEAR_SHEET_ROWS_USIZE: usize = 6;
|
||
const PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS: u32 = 10;
|
||
const PUZZLE_CLEAR_FINAL_ATLAS_ROWS: u32 = 10;
|
||
const PUZZLE_CLEAR_SHEET_UNUSED_CELL: &str = ".";
|
||
const PUZZLE_CLEAR_SHEET_FILLER_CELL: &str = "FILL";
|
||
const PUZZLE_CLEAR_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 字、网格线、编号、标签、边框、外轮廓框、白色描边、白色贴纸边、圆角框、阴影框、分隔线、裁切参考线、单格内部拼接线、内部竖切、内部横切、照片拼贴、相册拼贴、多场景拼贴、双联图、三联图、画中画、单格双图、单格多图、低清晰度、纯色背景、空白背景、白底商品图、孤立主体、单体素材、素材表、图标、贴纸、同品种重复、同一物体多角度、重复同款小图、主体跨格、主体贴边、拼贴、重影、不同图案互相穿插";
|
||
|
||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct PuzzleClearCompileDraftWorkerPayload {
|
||
pub session_id: String,
|
||
pub owner_user_id: String,
|
||
pub author_display_name: String,
|
||
pub payload: PuzzleClearActionRequest,
|
||
}
|
||
|
||
pub async fn create_puzzle_clear_session(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<PuzzleClearWorkspaceCreateRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) =
|
||
puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_CREATION_PROVIDER)?;
|
||
validate_workspace_request(&request_context, &payload)?;
|
||
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let session_id = build_prefixed_uuid_id(module_puzzle_clear::PUZZLE_CLEAR_SESSION_ID_PREFIX);
|
||
let now = current_utc_micros();
|
||
let draft = build_puzzle_clear_draft(&payload);
|
||
let session = PuzzleClearSessionSnapshotResponse {
|
||
session_id,
|
||
owner_user_id,
|
||
status: PuzzleClearGenerationStatus::Draft,
|
||
draft: Some(draft),
|
||
created_at: format_timestamp_micros(now),
|
||
updated_at: format_timestamp_micros(now),
|
||
};
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
PuzzleClearSessionResponse {
|
||
session: state
|
||
.spacetime_client()
|
||
.create_puzzle_clear_session(session)
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
&request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
map_puzzle_clear_client_error(error),
|
||
)
|
||
})?,
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_puzzle_clear_session(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
||
let session = state
|
||
.spacetime_client()
|
||
.get_puzzle_clear_session(session_id, authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
&request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
map_puzzle_clear_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
PuzzleClearSessionResponse { session },
|
||
))
|
||
}
|
||
|
||
pub async fn execute_puzzle_clear_action(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<PuzzleClearActionRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
||
let Json(payload) =
|
||
puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_CREATION_PROVIDER)?;
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let author_display_name = authenticated
|
||
.claims()
|
||
.display_name
|
||
.as_deref()
|
||
.unwrap_or("拼消消玩家")
|
||
.to_string();
|
||
let mut payload = payload;
|
||
let should_queue_generation = matches!(
|
||
payload.action_type,
|
||
PuzzleClearActionType::CompileDraft | PuzzleClearActionType::RegenerateAtlas
|
||
) && !state.config.external_generation_mode.is_inline();
|
||
if should_queue_generation {
|
||
let mut queued_response = state
|
||
.spacetime_client()
|
||
.mark_puzzle_clear_generation_queued(
|
||
session_id.clone(),
|
||
owner_user_id.clone(),
|
||
author_display_name.clone(),
|
||
payload.clone(),
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
&request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
map_puzzle_clear_client_error(error),
|
||
)
|
||
})?;
|
||
let queue_job = enqueue_puzzle_clear_compile_draft_job(
|
||
&state,
|
||
&request_context,
|
||
&session_id,
|
||
owner_user_id.as_str(),
|
||
author_display_name.as_str(),
|
||
payload,
|
||
)
|
||
.await?;
|
||
queued_response.queue_state = Some(map_puzzle_clear_queue_job_status(queue_job));
|
||
return Ok(json_success_body(Some(&request_context), queued_response));
|
||
}
|
||
if let Err(response) = maybe_prepare_puzzle_clear_assets_inner(
|
||
&state,
|
||
&request_context,
|
||
session_id.as_str(),
|
||
owner_user_id.as_str(),
|
||
&mut payload,
|
||
)
|
||
.await
|
||
{
|
||
let (error_message, response) = extract_puzzle_clear_response_error_message(response).await;
|
||
tracing::warn!(
|
||
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
session_id,
|
||
error = %error_message,
|
||
"拼消消素材生成失败,准备回写 failed 状态"
|
||
);
|
||
if let Err(writeback_error) = state
|
||
.spacetime_client()
|
||
.mark_puzzle_clear_generation_failed(
|
||
session_id.clone(),
|
||
owner_user_id.clone(),
|
||
author_display_name.clone(),
|
||
payload.clone(),
|
||
)
|
||
.await
|
||
{
|
||
tracing::warn!(
|
||
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
session_id,
|
||
error = %writeback_error,
|
||
"拼消消素材生成失败状态回写失败"
|
||
);
|
||
}
|
||
return Err(response);
|
||
}
|
||
let response = state
|
||
.spacetime_client()
|
||
.execute_puzzle_clear_action(session_id, owner_user_id, author_display_name, payload)
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
&request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
map_puzzle_clear_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(Some(&request_context), response))
|
||
}
|
||
|
||
async fn enqueue_puzzle_clear_compile_draft_job(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
session_id: &str,
|
||
owner_user_id: &str,
|
||
author_display_name: &str,
|
||
payload: PuzzleClearActionRequest,
|
||
) -> Result<ExternalGenerationJobRecord, Response> {
|
||
let job_id = build_prefixed_uuid_id("task-");
|
||
let now_micros = current_utc_micros();
|
||
let request_payload_json = serde_json::to_string(&PuzzleClearCompileDraftWorkerPayload {
|
||
session_id: session_id.to_string(),
|
||
owner_user_id: owner_user_id.to_string(),
|
||
author_display_name: author_display_name.to_string(),
|
||
payload,
|
||
})
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||
"message": format!("拼消消 worker 任务参数序列化失败:{error}"),
|
||
})),
|
||
)
|
||
})?;
|
||
let price_mud_points = resolve_puzzle_clear_generation_points_cost(state).await;
|
||
state
|
||
.spacetime_client()
|
||
.enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput {
|
||
dedupe_key: format!("puzzle-clear:compile-draft:{session_id}:{job_id}"),
|
||
job_id,
|
||
job_kind: PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND.to_string(),
|
||
owner_user_id: owner_user_id.to_string(),
|
||
source_module: "puzzle-clear".to_string(),
|
||
source_entity_id: session_id.to_string(),
|
||
request_label: "拼消消草稿生成".to_string(),
|
||
request_payload_json,
|
||
max_attempts: 1,
|
||
available_at_micros: now_micros,
|
||
created_at_micros: now_micros,
|
||
price_mud_points,
|
||
})
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
map_puzzle_clear_client_error(error),
|
||
)
|
||
})
|
||
}
|
||
|
||
async fn resolve_puzzle_clear_generation_points_cost(state: &AppState) -> u64 {
|
||
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
|
||
state,
|
||
PUZZLE_CLEAR_TEMPLATE_ID,
|
||
u64::from(shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
|
||
)
|
||
.await
|
||
}
|
||
|
||
fn map_puzzle_clear_queue_job_status(
|
||
job: ExternalGenerationJobRecord,
|
||
) -> ExternalGenerationJobStatusRecord {
|
||
ExternalGenerationJobStatusRecord {
|
||
operation_id: job.job_id,
|
||
status: ExternalGenerationJobStatus::Queued,
|
||
phase_label: job.request_label,
|
||
phase_detail: "排队中。".to_string(),
|
||
progress: 8,
|
||
error: job.last_error_message,
|
||
updated_at_micros: job.updated_at_micros,
|
||
}
|
||
}
|
||
|
||
pub(crate) async fn execute_puzzle_clear_compile_draft_worker_job(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
mut worker_payload: PuzzleClearCompileDraftWorkerPayload,
|
||
) -> Result<PuzzleClearSessionSnapshotResponse, Response> {
|
||
if let Err(response) = maybe_prepare_puzzle_clear_assets_inner(
|
||
state,
|
||
request_context,
|
||
worker_payload.session_id.as_str(),
|
||
worker_payload.owner_user_id.as_str(),
|
||
&mut worker_payload.payload,
|
||
)
|
||
.await
|
||
{
|
||
let (error_message, response) = extract_puzzle_clear_response_error_message(response).await;
|
||
tracing::warn!(
|
||
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
session_id = worker_payload.session_id,
|
||
error = %error_message,
|
||
"拼消消 worker 素材生成失败,准备回写 failed 状态"
|
||
);
|
||
if let Err(writeback_error) = state
|
||
.spacetime_client()
|
||
.mark_puzzle_clear_generation_failed(
|
||
worker_payload.session_id.clone(),
|
||
worker_payload.owner_user_id.clone(),
|
||
worker_payload.author_display_name.clone(),
|
||
worker_payload.payload.clone(),
|
||
)
|
||
.await
|
||
{
|
||
tracing::warn!(
|
||
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
session_id = worker_payload.session_id,
|
||
error = %writeback_error,
|
||
"拼消消 worker 失败状态回写失败"
|
||
);
|
||
}
|
||
return Err(response);
|
||
}
|
||
let response = state
|
||
.spacetime_client()
|
||
.execute_puzzle_clear_action(
|
||
worker_payload.session_id,
|
||
worker_payload.owner_user_id,
|
||
worker_payload.author_display_name,
|
||
worker_payload.payload,
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
map_puzzle_clear_client_error(error),
|
||
)
|
||
})?;
|
||
Ok(response.session)
|
||
}
|
||
|
||
pub async fn list_puzzle_clear_works(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let works = state
|
||
.spacetime_client()
|
||
.list_puzzle_clear_works(authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
&request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
map_puzzle_clear_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
PuzzleClearWorksResponse {
|
||
items: works.into_iter().map(|work| work.summary).collect(),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_puzzle_clear_work(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &profile_id, "profileId")?;
|
||
let item = state
|
||
.spacetime_client()
|
||
.get_puzzle_clear_work_profile(profile_id, authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
&request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
map_puzzle_clear_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
PuzzleClearWorkDetailResponse { item },
|
||
))
|
||
}
|
||
|
||
pub async fn publish_puzzle_clear_work(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &profile_id, "profileId")?;
|
||
let item = state
|
||
.spacetime_client()
|
||
.publish_puzzle_clear_work(profile_id, authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
&request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
map_puzzle_clear_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
PuzzleClearWorkMutationResponse { item },
|
||
))
|
||
}
|
||
|
||
pub async fn get_puzzle_clear_runtime_work(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &profile_id, "profileId")?;
|
||
let item = state
|
||
.spacetime_client()
|
||
.get_puzzle_clear_runtime_work(profile_id)
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
&request_context,
|
||
PUZZLE_CLEAR_RUNTIME_PROVIDER,
|
||
map_puzzle_clear_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
PuzzleClearWorkDetailResponse { item },
|
||
))
|
||
}
|
||
|
||
pub async fn start_puzzle_clear_run(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(principal): Extension<RuntimePrincipal>,
|
||
payload: Result<Json<PuzzleClearStartRunRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) =
|
||
puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?;
|
||
ensure_non_empty(&request_context, &payload.profile_id, "profileId")?;
|
||
let owner_user_id = principal.subject().to_string();
|
||
let principal_kind = principal.kind().as_str();
|
||
let run = state
|
||
.spacetime_client()
|
||
.start_puzzle_clear_run(payload, owner_user_id)
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
&request_context,
|
||
PUZZLE_CLEAR_RUNTIME_PROVIDER,
|
||
map_puzzle_clear_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
record_work_play_start_after_success(
|
||
&state,
|
||
&request_context,
|
||
build_puzzle_clear_work_play_tracking_draft(
|
||
&principal,
|
||
run.profile_id.clone(),
|
||
PUZZLE_CLEAR_RUNTIME_RUNS_ROUTE,
|
||
)
|
||
.owner_user_id(run.owner_user_id.clone())
|
||
.run_id(run.run_id.clone())
|
||
.profile_id(run.profile_id.clone())
|
||
.extra(json!({
|
||
"runStatus": run.status,
|
||
"principalKind": principal_kind,
|
||
"levelIndex": run.level_index,
|
||
})),
|
||
)
|
||
.await;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
PuzzleClearRunResponse { run },
|
||
))
|
||
}
|
||
|
||
pub async fn get_puzzle_clear_run(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(principal): Extension<RuntimePrincipal>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||
let run = state
|
||
.spacetime_client()
|
||
.get_puzzle_clear_run(run_id, principal.subject().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
&request_context,
|
||
PUZZLE_CLEAR_RUNTIME_PROVIDER,
|
||
map_puzzle_clear_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
PuzzleClearRunResponse { run },
|
||
))
|
||
}
|
||
|
||
pub async fn swap_puzzle_clear_cards(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(principal): Extension<RuntimePrincipal>,
|
||
payload: Result<Json<PuzzleClearSwapRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||
let Json(payload) =
|
||
puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?;
|
||
let run = state
|
||
.spacetime_client()
|
||
.swap_puzzle_clear_cards(run_id, principal.subject().to_string(), payload)
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
&request_context,
|
||
PUZZLE_CLEAR_RUNTIME_PROVIDER,
|
||
map_puzzle_clear_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
PuzzleClearRunResponse { run },
|
||
))
|
||
}
|
||
|
||
pub async fn retry_puzzle_clear_level(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(principal): Extension<RuntimePrincipal>,
|
||
payload: Result<Json<PuzzleClearRetryLevelRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||
let Json(payload) =
|
||
puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?;
|
||
let run = state
|
||
.spacetime_client()
|
||
.retry_puzzle_clear_level(run_id, principal.subject().to_string(), payload)
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
&request_context,
|
||
PUZZLE_CLEAR_RUNTIME_PROVIDER,
|
||
map_puzzle_clear_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
PuzzleClearRunResponse { run },
|
||
))
|
||
}
|
||
|
||
pub async fn advance_puzzle_clear_next_level(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(principal): Extension<RuntimePrincipal>,
|
||
payload: Result<Json<PuzzleClearNextLevelRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||
let Json(payload) =
|
||
puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?;
|
||
let run = state
|
||
.spacetime_client()
|
||
.advance_puzzle_clear_next_level(run_id, principal.subject().to_string(), payload)
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
&request_context,
|
||
PUZZLE_CLEAR_RUNTIME_PROVIDER,
|
||
map_puzzle_clear_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
PuzzleClearRunResponse { run },
|
||
))
|
||
}
|
||
|
||
pub async fn mark_puzzle_clear_level_time_up(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(principal): Extension<RuntimePrincipal>,
|
||
payload: Result<Json<PuzzleClearTimeUpRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||
let Json(payload) =
|
||
puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?;
|
||
let run = state
|
||
.spacetime_client()
|
||
.mark_puzzle_clear_level_time_up(run_id, principal.subject().to_string(), payload)
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
&request_context,
|
||
PUZZLE_CLEAR_RUNTIME_PROVIDER,
|
||
map_puzzle_clear_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
PuzzleClearRunResponse { run },
|
||
))
|
||
}
|
||
|
||
pub async fn list_puzzle_clear_gallery(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let items = state
|
||
.spacetime_client()
|
||
.list_puzzle_clear_gallery()
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
&request_context,
|
||
PUZZLE_CLEAR_RUNTIME_PROVIDER,
|
||
map_puzzle_clear_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
json!({
|
||
"items": items,
|
||
"hasMore": false,
|
||
"nextCursor": null,
|
||
}),
|
||
))
|
||
}
|
||
|
||
pub async fn get_puzzle_clear_gallery_detail(
|
||
State(state): State<AppState>,
|
||
Path(public_work_code): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &public_work_code, "publicWorkCode")?;
|
||
let normalized_code = normalize_public_work_code(public_work_code.as_str());
|
||
let items = state
|
||
.spacetime_client()
|
||
.list_puzzle_clear_gallery()
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
&request_context,
|
||
PUZZLE_CLEAR_RUNTIME_PROVIDER,
|
||
map_puzzle_clear_client_error(error),
|
||
)
|
||
})?;
|
||
let profile_id = items
|
||
.into_iter()
|
||
.find(|item| {
|
||
normalize_public_work_code(
|
||
build_puzzle_clear_public_work_code(&item.profile_id).as_str(),
|
||
) == normalized_code
|
||
|| normalize_public_work_code(item.profile_id.as_str()) == normalized_code
|
||
})
|
||
.map(|item| item.profile_id)
|
||
.ok_or_else(|| {
|
||
puzzle_clear_error_response(
|
||
&request_context,
|
||
PUZZLE_CLEAR_RUNTIME_PROVIDER,
|
||
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
|
||
"provider": PUZZLE_CLEAR_PROVIDER,
|
||
"message": "拼消消公开作品不存在",
|
||
})),
|
||
)
|
||
})?;
|
||
let item = state
|
||
.spacetime_client()
|
||
.get_puzzle_clear_work_profile(profile_id, String::new())
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
&request_context,
|
||
PUZZLE_CLEAR_RUNTIME_PROVIDER,
|
||
map_puzzle_clear_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
PuzzleClearWorkDetailResponse { item },
|
||
))
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
struct PuzzleClearAtlasCardSlice {
|
||
group: PuzzleClearPatternGroup,
|
||
task_id: Option<String>,
|
||
part_x: u32,
|
||
part_y: u32,
|
||
bytes: Vec<u8>,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
struct PuzzleClearAtlasSheetSpec {
|
||
sheet_id: &'static str,
|
||
layout: [[&'static str; PUZZLE_CLEAR_SHEET_COLUMNS_USIZE]; PUZZLE_CLEAR_SHEET_ROWS_USIZE],
|
||
layout_prompt: &'static str,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct PuzzleClearGeneratedSheet {
|
||
spec: PuzzleClearAtlasSheetSpec,
|
||
prompt: String,
|
||
task_id: String,
|
||
image: DownloadedOpenAiImage,
|
||
}
|
||
|
||
async fn maybe_prepare_puzzle_clear_assets_inner(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
session_id: &str,
|
||
owner_user_id: &str,
|
||
payload: &mut PuzzleClearActionRequest,
|
||
) -> Result<(), Response> {
|
||
if !matches!(
|
||
payload.action_type,
|
||
PuzzleClearActionType::CompileDraft | PuzzleClearActionType::RegenerateAtlas
|
||
) {
|
||
return Ok(());
|
||
}
|
||
if payload.atlas_asset.is_some()
|
||
&& payload
|
||
.pattern_groups
|
||
.as_ref()
|
||
.is_some_and(|groups| !groups.is_empty())
|
||
&& payload
|
||
.card_assets
|
||
.as_ref()
|
||
.is_some_and(|cards| !cards.is_empty())
|
||
{
|
||
return Ok(());
|
||
}
|
||
|
||
let profile_id = payload
|
||
.profile_id
|
||
.as_ref()
|
||
.map(|value| value.trim())
|
||
.filter(|value| !value.is_empty())
|
||
.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());
|
||
|
||
if payload.generate_board_background.unwrap_or(false)
|
||
&& payload
|
||
.board_background_asset
|
||
.as_ref()
|
||
.is_none_or(|asset| asset.image_src.trim().is_empty())
|
||
{
|
||
let board_background_prompt = payload
|
||
.board_background_prompt
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty());
|
||
let theme_prompt = payload.theme_prompt.as_deref().unwrap_or_default();
|
||
let background_asset = generate_and_persist_puzzle_clear_board_background(
|
||
state,
|
||
request_context,
|
||
owner_user_id,
|
||
profile_id.as_str(),
|
||
board_background_prompt.unwrap_or(theme_prompt),
|
||
)
|
||
.await?;
|
||
payload.board_background_asset = Some(background_asset);
|
||
} else if let Some(asset) = payload.board_background_asset.clone() {
|
||
if asset.image_src.trim_start().starts_with("data:image/") {
|
||
payload.board_background_asset = Some(
|
||
persist_puzzle_clear_data_url_asset(
|
||
state,
|
||
request_context,
|
||
owner_user_id,
|
||
profile_id.as_str(),
|
||
"board-background-upload",
|
||
asset.prompt.as_str(),
|
||
asset.image_src.as_str(),
|
||
1024,
|
||
1536,
|
||
)
|
||
.await?,
|
||
);
|
||
}
|
||
}
|
||
|
||
let groups = planned_puzzle_clear_pattern_groups();
|
||
let groups_by_id = groups
|
||
.iter()
|
||
.cloned()
|
||
.map(|group| (group.group_id.clone(), group))
|
||
.collect::<BTreeMap<_, _>>();
|
||
let sheet_specs = puzzle_clear_atlas_sheet_specs();
|
||
let settings = require_openai_image_settings(state)
|
||
.map(|settings| {
|
||
settings.with_external_api_audit_context(
|
||
request_context,
|
||
Some(owner_user_id.to_string()),
|
||
Some(profile_id.clone()),
|
||
)
|
||
})
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
|
||
})?;
|
||
let http_client = build_openai_image_http_client(&settings).map_err(|error| {
|
||
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
|
||
})?;
|
||
let mut generated_sheets = Vec::with_capacity(sheet_specs.len());
|
||
for sheet_spec in sheet_specs {
|
||
let sheet_prompt = build_puzzle_clear_atlas_prompt(
|
||
payload.theme_prompt.as_deref().unwrap_or_default(),
|
||
&sheet_spec,
|
||
);
|
||
let mut accepted_sheet = None;
|
||
for attempt_index in 0..PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS {
|
||
let failure_context = format!(
|
||
"拼消消素材 {} 生成失败,第 {} 次",
|
||
sheet_spec.sheet_id,
|
||
attempt_index + 1
|
||
);
|
||
let generated = match create_openai_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
sheet_prompt.as_str(),
|
||
Some(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT),
|
||
PUZZLE_CLEAR_ATLAS_GENERATION_SIZE,
|
||
1,
|
||
&[],
|
||
failure_context.as_str(),
|
||
)
|
||
.await
|
||
{
|
||
Ok(generated) => generated,
|
||
Err(error)
|
||
if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS
|
||
&& is_retryable_puzzle_clear_sheet_generation_error(&error) =>
|
||
{
|
||
tracing::warn!(
|
||
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
sheet_id = sheet_spec.sheet_id,
|
||
attempt = attempt_index + 1,
|
||
generation_error = %error.body_text(),
|
||
"拼消消素材 sheet 生成遇到可重试上游错误,准备重试"
|
||
);
|
||
continue;
|
||
}
|
||
Err(error) => {
|
||
return Err(puzzle_clear_error_response(
|
||
request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
error,
|
||
));
|
||
}
|
||
};
|
||
let task_id = generated.task_id;
|
||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||
puzzle_clear_error_response(
|
||
request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": format!("拼消消素材 {} 生成成功但未返回图片。", sheet_spec.sheet_id),
|
||
})),
|
||
)
|
||
})?;
|
||
match validate_puzzle_clear_sheet_quality(&image, &sheet_spec) {
|
||
Ok(()) => {
|
||
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,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
let Some(accepted_sheet) = accepted_sheet else {
|
||
return Err(puzzle_clear_error_response(
|
||
request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
"message": format!("拼消消素材 {} 多次生成后仍未得到可切图集。", sheet_spec.sheet_id),
|
||
})),
|
||
));
|
||
};
|
||
generated_sheets.push(accepted_sheet);
|
||
}
|
||
|
||
let mut slices = Vec::new();
|
||
for generated_sheet in &generated_sheets {
|
||
slices.extend(
|
||
slice_puzzle_clear_sheet(
|
||
&generated_sheet.image,
|
||
&generated_sheet.spec,
|
||
&groups_by_id,
|
||
generated_sheet.task_id.as_str(),
|
||
)
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
|
||
})?,
|
||
);
|
||
}
|
||
let atlas_image =
|
||
compose_puzzle_clear_final_atlas(&slices, &groups_by_id).map_err(|error| {
|
||
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
|
||
})?;
|
||
let atlas_prompt = generated_sheets
|
||
.iter()
|
||
.map(|sheet| format!("{}:\n{}", sheet.spec.sheet_id, sheet.prompt))
|
||
.collect::<Vec<_>>()
|
||
.join("\n\n---\n\n");
|
||
let atlas_task_ids = generated_sheets
|
||
.iter()
|
||
.map(|sheet| sheet.task_id.as_str())
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
let atlas_asset = persist_puzzle_clear_generated_image_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id.as_str(),
|
||
"atlas",
|
||
atlas_prompt.as_str(),
|
||
Some(atlas_task_ids.as_str()),
|
||
atlas_image,
|
||
PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS,
|
||
PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_FINAL_ATLAS_ROWS,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
|
||
let mut card_assets = Vec::with_capacity(slices.len());
|
||
for slice in slices {
|
||
let task_id = slice.task_id.clone();
|
||
card_assets.push(
|
||
persist_puzzle_clear_card_slice(
|
||
state,
|
||
owner_user_id,
|
||
profile_id.as_str(),
|
||
task_id.as_deref(),
|
||
slice,
|
||
request_context,
|
||
)
|
||
.await?,
|
||
);
|
||
}
|
||
|
||
payload.atlas_asset = Some(atlas_asset);
|
||
payload.pattern_groups = Some(groups);
|
||
payload.card_assets = Some(card_assets);
|
||
tracing::info!(
|
||
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
session_id,
|
||
profile_id,
|
||
group_count = payload.pattern_groups.as_ref().map_or(0, Vec::len),
|
||
card_count = payload.card_assets.as_ref().map_or(0, Vec::len),
|
||
"拼消消素材 atlas 已生成、切片并写入资产索引"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
async fn extract_puzzle_clear_response_error_message(response: Response) -> (String, Response) {
|
||
let status = response.status();
|
||
let (parts, body) = response.into_parts();
|
||
let body_bytes = match to_bytes(body, 64 * 1024).await {
|
||
Ok(bytes) => bytes,
|
||
Err(_) => {
|
||
let rebuilt = Response::from_parts(parts, Body::empty());
|
||
return (format!("拼消消素材生成失败:{status}"), rebuilt);
|
||
}
|
||
};
|
||
let body_text = String::from_utf8_lossy(&body_bytes).trim().to_string();
|
||
let message = if body_text.is_empty() {
|
||
format!("拼消消素材生成失败:{status}")
|
||
} else if let Ok(body_json) = serde_json::from_str::<Value>(&body_text) {
|
||
body_json
|
||
.get("error")
|
||
.and_then(|error| error.get("message"))
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|message| !message.is_empty())
|
||
.unwrap_or(body_text.as_str())
|
||
.to_string()
|
||
} else {
|
||
body_text
|
||
};
|
||
let rebuilt = Response::from_parts(parts, Body::from(body_bytes));
|
||
(message, rebuilt)
|
||
}
|
||
|
||
fn planned_puzzle_clear_pattern_groups() -> Vec<PuzzleClearPatternGroup> {
|
||
module_puzzle_clear::plan_puzzle_clear_pattern_groups(PUZZLE_CLEAR_ATLAS_CELL_SIZE)
|
||
.unwrap_or_default()
|
||
.into_iter()
|
||
.map(|group| PuzzleClearPatternGroup {
|
||
group_id: group.group_id,
|
||
shape: group.shape.as_str().to_string(),
|
||
width: group.width,
|
||
height: group.height,
|
||
atlas_x: group.atlas_x,
|
||
atlas_y: group.atlas_y,
|
||
atlas_width: group.atlas_width,
|
||
atlas_height: group.atlas_height,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn puzzle_clear_atlas_sheet_specs() -> Vec<PuzzleClearAtlasSheetSpec> {
|
||
vec![
|
||
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"],
|
||
],
|
||
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 单元仍需完整可读,不要做成彼此无关的随机独立小图。"
|
||
),
|
||
},
|
||
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"],
|
||
],
|
||
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 单元仍需完整可读,不要做成彼此无关的随机独立小图。"
|
||
),
|
||
},
|
||
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],
|
||
],
|
||
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 单元仍需完整可读,不要做成彼此无关的随机独立小图。"
|
||
),
|
||
},
|
||
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"],
|
||
],
|
||
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 单元仍需完整可读,但不要做成彼此无关的随机独立小图。"
|
||
),
|
||
},
|
||
]
|
||
}
|
||
|
||
fn build_puzzle_clear_atlas_prompt(
|
||
theme_prompt: &str,
|
||
sheet_spec: &PuzzleClearAtlasSheetSpec,
|
||
) -> String {
|
||
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",
|
||
"{layout_prompt}"
|
||
),
|
||
subject = subject,
|
||
layout_prompt = sheet_spec.layout_prompt
|
||
)
|
||
}
|
||
|
||
fn build_puzzle_clear_board_background_prompt(theme_prompt: &str) -> String {
|
||
let subject = normalize_non_empty_str(theme_prompt).unwrap_or_else(|| "拼消消".to_string());
|
||
format!(
|
||
concat!(
|
||
"生成拼消消中央背景底图,1:1 正方形,尺寸与中央棋盘一致,主题是「{subject}」。",
|
||
"这张图不是棋盘装饰,而是玩家逐渐消除卡片后慢慢看见的目标画面;",
|
||
"画面需要让玩家有探索、揭开底图全貌和追求目标完成的感受。",
|
||
"请以主题为核心设计精致完整的主题场景或主题主视觉,主体清晰、细节丰富、色彩和氛围强绑定主题,强表现力,必须一眼体现主题。",
|
||
"不要文字、水印、按钮、教程浮层或明显网格。"
|
||
),
|
||
subject = subject
|
||
)
|
||
}
|
||
|
||
async fn generate_and_persist_puzzle_clear_board_background(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
owner_user_id: &str,
|
||
profile_id: &str,
|
||
theme_prompt: &str,
|
||
) -> Result<PuzzleClearImageAsset, Response> {
|
||
let prompt = build_puzzle_clear_board_background_prompt(theme_prompt);
|
||
let settings = require_openai_image_settings(state)
|
||
.map(|settings| {
|
||
settings.with_external_api_audit_context(
|
||
request_context,
|
||
Some(owner_user_id.to_string()),
|
||
Some(profile_id.to_string()),
|
||
)
|
||
})
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
|
||
})?;
|
||
let http_client = build_openai_image_http_client(&settings).map_err(|error| {
|
||
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
|
||
})?;
|
||
let generated = create_openai_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
prompt.as_str(),
|
||
Some("文字、水印、按钮、教程浮层、明显网格"),
|
||
PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE,
|
||
1,
|
||
&[],
|
||
"拼消消场地底图生成失败",
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
|
||
})?;
|
||
let task_id = generated.task_id;
|
||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||
puzzle_clear_error_response(
|
||
request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": "拼消消场地底图生成成功但未返回图片。",
|
||
})),
|
||
)
|
||
})?;
|
||
persist_puzzle_clear_generated_image_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id,
|
||
"board-background",
|
||
prompt.as_str(),
|
||
Some(task_id.as_str()),
|
||
image,
|
||
1024,
|
||
1024,
|
||
request_context,
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn persist_puzzle_clear_data_url_asset(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
owner_user_id: &str,
|
||
profile_id: &str,
|
||
slot: &str,
|
||
prompt: &str,
|
||
data_url: &str,
|
||
width: u32,
|
||
height: u32,
|
||
) -> Result<PuzzleClearImageAsset, Response> {
|
||
let parsed = decode_generated_image_asset_data_url(data_url).map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "generated-image-assets",
|
||
"message": format!("拼消消图片 Data URL 解析失败:{error:?}"),
|
||
})),
|
||
)
|
||
})?;
|
||
let image = DownloadedOpenAiImage {
|
||
extension: parsed.format.extension,
|
||
mime_type: parsed.format.mime_type,
|
||
bytes: parsed.bytes,
|
||
};
|
||
persist_puzzle_clear_generated_image_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id,
|
||
slot,
|
||
prompt,
|
||
None,
|
||
image,
|
||
width,
|
||
height,
|
||
request_context,
|
||
)
|
||
.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(),
|
||
StatusCode::BAD_GATEWAY | StatusCode::GATEWAY_TIMEOUT | StatusCode::TOO_MANY_REQUESTS
|
||
) {
|
||
return false;
|
||
}
|
||
error
|
||
.details()
|
||
.and_then(|details| details.get("retryable"))
|
||
.and_then(Value::as_bool)
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
fn analyze_puzzle_clear_sheet_cell_quality(
|
||
source: &image::DynamicImage,
|
||
sheet_spec: &PuzzleClearAtlasSheetSpec,
|
||
row: u32,
|
||
col: u32,
|
||
bounds: PuzzleClearSheetCellBounds,
|
||
) -> PuzzleClearSheetCellQuality {
|
||
let background = sample_puzzle_clear_sheet_cell_background(source, bounds);
|
||
let width = bounds.width() as usize;
|
||
let height = bounds.height() as usize;
|
||
let mut mask = vec![0u8; width.saturating_mul(height)];
|
||
let mut foreground_pixels = 0u32;
|
||
for local_y in 0..height {
|
||
let y = bounds.y0 + local_y as u32;
|
||
for local_x in 0..width {
|
||
let x = bounds.x0 + local_x as u32;
|
||
if is_puzzle_clear_sheet_foreground_pixel(source.get_pixel(x, y).0, background) {
|
||
mask[local_y * width + local_x] = 1;
|
||
foreground_pixels = foreground_pixels.saturating_add(1);
|
||
}
|
||
}
|
||
}
|
||
|
||
let (exposed_edge_count, strongest_edge_ratio) =
|
||
measure_puzzle_clear_sheet_exposed_edges(&mask, width, height, sheet_spec, row, col);
|
||
let strongest_internal_seam_ratio = measure_puzzle_clear_sheet_internal_seam(source, bounds);
|
||
PuzzleClearSheetCellQuality {
|
||
foreground_ratio: foreground_pixels as f32 / bounds.area() as f32,
|
||
exposed_edge_count,
|
||
strongest_edge_ratio,
|
||
strongest_internal_seam_ratio,
|
||
}
|
||
}
|
||
|
||
fn puzzle_clear_sheet_cell_bounds(
|
||
row: u32,
|
||
col: u32,
|
||
source_width: u32,
|
||
source_height: u32,
|
||
) -> PuzzleClearSheetCellBounds {
|
||
let x0 = scale_sheet_coord(col, source_width, PUZZLE_CLEAR_SHEET_COLUMNS);
|
||
let y0 = scale_sheet_coord(row, source_height, PUZZLE_CLEAR_SHEET_ROWS);
|
||
let x1 = scale_sheet_coord(col + 1, source_width, PUZZLE_CLEAR_SHEET_COLUMNS)
|
||
.max(x0 + 1)
|
||
.min(source_width);
|
||
let y1 = scale_sheet_coord(row + 1, source_height, PUZZLE_CLEAR_SHEET_ROWS)
|
||
.max(y0 + 1)
|
||
.min(source_height);
|
||
PuzzleClearSheetCellBounds { x0, y0, x1, y1 }
|
||
}
|
||
|
||
fn is_puzzle_clear_sheet_foreground_pixel(pixel: [u8; 4], background: [u8; 4]) -> bool {
|
||
if pixel[3] <= 24 {
|
||
return false;
|
||
}
|
||
let alpha_diff = (pixel[3] as i32 - background[3] as i32).abs();
|
||
let color_diff = (pixel[0] as i32 - background[0] as i32).abs()
|
||
+ (pixel[1] as i32 - background[1] as i32).abs()
|
||
+ (pixel[2] as i32 - background[2] as i32).abs();
|
||
alpha_diff >= 48 || color_diff >= PUZZLE_CLEAR_SHEET_FOREGROUND_DIFF_THRESHOLD
|
||
}
|
||
|
||
fn sample_puzzle_clear_sheet_cell_background(
|
||
source: &image::DynamicImage,
|
||
bounds: PuzzleClearSheetCellBounds,
|
||
) -> [u8; 4] {
|
||
let sample_size = (bounds.width().min(bounds.height()) / 12).clamp(2, 8);
|
||
let points = [
|
||
(bounds.x0, bounds.y0),
|
||
(bounds.x1.saturating_sub(sample_size), bounds.y0),
|
||
(bounds.x0, bounds.y1.saturating_sub(sample_size)),
|
||
(
|
||
bounds.x1.saturating_sub(sample_size),
|
||
bounds.y1.saturating_sub(sample_size),
|
||
),
|
||
];
|
||
let mut samples = Vec::new();
|
||
for (start_x, start_y) in points {
|
||
let mut totals = [0u32; 4];
|
||
let mut count = 0u32;
|
||
for y in start_y..start_y.saturating_add(sample_size).min(bounds.y1) {
|
||
for x in start_x..start_x.saturating_add(sample_size).min(bounds.x1) {
|
||
let pixel = source.get_pixel(x, y).0;
|
||
totals[0] = totals[0].saturating_add(pixel[0] as u32);
|
||
totals[1] = totals[1].saturating_add(pixel[1] as u32);
|
||
totals[2] = totals[2].saturating_add(pixel[2] as u32);
|
||
totals[3] = totals[3].saturating_add(pixel[3] as u32);
|
||
count = count.saturating_add(1);
|
||
}
|
||
}
|
||
if count > 0 {
|
||
samples.push([
|
||
(totals[0] / count) as u8,
|
||
(totals[1] / count) as u8,
|
||
(totals[2] / count) as u8,
|
||
(totals[3] / count) as u8,
|
||
]);
|
||
}
|
||
}
|
||
|
||
samples
|
||
.into_iter()
|
||
.max_by_key(|sample| {
|
||
let max_channel = sample[0].max(sample[1]).max(sample[2]) as u16;
|
||
let min_channel = sample[0].min(sample[1]).min(sample[2]) as u16;
|
||
let luminance = sample[0] as u16 + sample[1] as u16 + sample[2] as u16;
|
||
let saturation = max_channel.saturating_sub(min_channel);
|
||
(luminance, u16::MAX.saturating_sub(saturation))
|
||
})
|
||
.unwrap_or([255, 255, 255, 255])
|
||
}
|
||
|
||
fn measure_puzzle_clear_sheet_exposed_edges(
|
||
mask: &[u8],
|
||
width: usize,
|
||
height: usize,
|
||
sheet_spec: &PuzzleClearAtlasSheetSpec,
|
||
row: u32,
|
||
col: u32,
|
||
) -> (usize, f32) {
|
||
if width == 0 || height == 0 || mask.len() < width.saturating_mul(height) {
|
||
return (0, 0.0);
|
||
}
|
||
let band = (width.min(height) / 24).clamp(6, 12);
|
||
let mut exposed_edges = 0usize;
|
||
let mut strongest_ratio = 0.0f32;
|
||
let edge_specs = [
|
||
((-1i32, 0i32), 0usize, 0usize, width, band),
|
||
((1, 0), 0, height.saturating_sub(band), width, band),
|
||
((0, -1), 0, 0, band, height),
|
||
((0, 1), width.saturating_sub(band), 0, band, height),
|
||
];
|
||
|
||
for ((row_delta, col_delta), start_x, start_y, edge_width, edge_height) in edge_specs {
|
||
if puzzle_clear_sheet_neighbor_is_same_group(sheet_spec, row, col, row_delta, col_delta) {
|
||
continue;
|
||
}
|
||
let mut foreground = 0usize;
|
||
let mut total = 0usize;
|
||
for local_y in start_y..start_y.saturating_add(edge_height).min(height) {
|
||
for local_x in start_x..start_x.saturating_add(edge_width).min(width) {
|
||
total = total.saturating_add(1);
|
||
if mask[local_y * width + local_x] != 0 {
|
||
foreground = foreground.saturating_add(1);
|
||
}
|
||
}
|
||
}
|
||
if total == 0 {
|
||
continue;
|
||
}
|
||
let ratio = foreground as f32 / total as f32;
|
||
strongest_ratio = strongest_ratio.max(ratio);
|
||
if ratio > PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD {
|
||
exposed_edges = exposed_edges.saturating_add(1);
|
||
}
|
||
}
|
||
|
||
(exposed_edges, strongest_ratio)
|
||
}
|
||
|
||
fn measure_puzzle_clear_sheet_internal_seam(
|
||
source: &image::DynamicImage,
|
||
bounds: PuzzleClearSheetCellBounds,
|
||
) -> f32 {
|
||
let width = bounds.width();
|
||
let height = bounds.height();
|
||
if width < 48 || height < 48 {
|
||
return 0.0;
|
||
}
|
||
let margin_x = (width / 8).clamp(18, 36);
|
||
let margin_y = (height / 8).clamp(18, 36);
|
||
let x_start = bounds.x0.saturating_add(margin_x).max(bounds.x0 + 1);
|
||
let x_end = bounds.x1.saturating_sub(margin_x).max(x_start + 1);
|
||
let y_start = bounds.y0.saturating_add(margin_y).max(bounds.y0 + 1);
|
||
let y_end = bounds.y1.saturating_sub(margin_y).max(y_start + 1);
|
||
let mut strongest = 0.0f32;
|
||
|
||
for x in x_start..x_end {
|
||
let mut strong = 0u32;
|
||
let mut total = 0u32;
|
||
for y in y_start..y_end {
|
||
let left = source.get_pixel(x.saturating_sub(1), y).0;
|
||
let right = source.get_pixel(x, y).0;
|
||
if puzzle_clear_rgb_distance(left, right)
|
||
>= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD
|
||
{
|
||
strong = strong.saturating_add(1);
|
||
}
|
||
total = total.saturating_add(1);
|
||
}
|
||
if total > 0 {
|
||
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,
|
||
groups_by_id: &BTreeMap<String, PuzzleClearPatternGroup>,
|
||
task_id: &str,
|
||
) -> Result<Vec<PuzzleClearAtlasCardSlice>, 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();
|
||
if source_width < 16 || source_height < 16 {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
"message": format!("拼消消素材 {} 尺寸过小,无法切割。", sheet_spec.sheet_id),
|
||
})),
|
||
);
|
||
}
|
||
let mut slices = Vec::new();
|
||
let mut cells_by_group: BTreeMap<&str, Vec<(u32, u32)>> = BTreeMap::new();
|
||
for (row, cells) in sheet_spec.layout.iter().enumerate() {
|
||
for (col, group_id) in cells.iter().enumerate() {
|
||
if is_puzzle_clear_sheet_discarded_cell(group_id) {
|
||
continue;
|
||
}
|
||
cells_by_group
|
||
.entry(*group_id)
|
||
.or_default()
|
||
.push((row as u32, col as u32));
|
||
}
|
||
}
|
||
|
||
for (group_id, cells) in cells_by_group {
|
||
let group = groups_by_id.get(group_id).ok_or_else(|| {
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
"message": format!("拼消消素材布局引用了未知图案组:{group_id}"),
|
||
}))
|
||
})?;
|
||
let min_row = cells.iter().map(|(row, _)| *row).min().unwrap_or(0);
|
||
let min_col = cells.iter().map(|(_, col)| *col).min().unwrap_or(0);
|
||
let expected_cell_count = (group.width * group.height) as usize;
|
||
if cells.len() != expected_cell_count {
|
||
return Err(
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
"message": format!(
|
||
"拼消消素材 {} 的布局 {} 格数不匹配,期望 {} 格,实际 {} 格。",
|
||
sheet_spec.sheet_id,
|
||
group_id,
|
||
expected_cell_count,
|
||
cells.len(),
|
||
),
|
||
})),
|
||
);
|
||
}
|
||
for part_y in 0..group.height {
|
||
for part_x in 0..group.width {
|
||
let expected_cell = (min_row + part_y, min_col + part_x);
|
||
if !cells.contains(&expected_cell) {
|
||
return Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||
.with_details(json!({
|
||
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
"message": format!(
|
||
"拼消消素材 {} 的布局 {} 不是完整连续矩形,缺少第 {} 行第 {} 列。",
|
||
sheet_spec.sheet_id,
|
||
group_id,
|
||
expected_cell.0 + 1,
|
||
expected_cell.1 + 1,
|
||
),
|
||
})));
|
||
}
|
||
}
|
||
}
|
||
for (row, col) in cells {
|
||
let part_x = col.saturating_sub(min_col);
|
||
let part_y = row.saturating_sub(min_row);
|
||
let x0 = scale_sheet_coord(col, source_width, PUZZLE_CLEAR_SHEET_COLUMNS);
|
||
let y0 = scale_sheet_coord(row, source_height, PUZZLE_CLEAR_SHEET_ROWS);
|
||
let x1 = scale_sheet_coord(col + 1, source_width, PUZZLE_CLEAR_SHEET_COLUMNS)
|
||
.max(x0 + 1)
|
||
.min(source_width);
|
||
let y1 = scale_sheet_coord(row + 1, source_height, PUZZLE_CLEAR_SHEET_ROWS)
|
||
.max(y0 + 1)
|
||
.min(source_height);
|
||
let cropped = source.crop_imm(x0, y0, x1 - x0, y1 - y0).resize_exact(
|
||
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
|
||
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
|
||
image::imageops::FilterType::Lanczos3,
|
||
);
|
||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||
cropped
|
||
.write_to(&mut cursor, image::ImageFormat::Png)
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
"message": format!("拼消消素材 {} 切片失败:{error}", sheet_spec.sheet_id),
|
||
}))
|
||
})?;
|
||
slices.push(PuzzleClearAtlasCardSlice {
|
||
group: group.clone(),
|
||
task_id: Some(task_id.to_string()),
|
||
part_x,
|
||
part_y,
|
||
bytes: cursor.into_inner(),
|
||
});
|
||
}
|
||
}
|
||
Ok(slices)
|
||
}
|
||
|
||
fn compose_puzzle_clear_final_atlas(
|
||
slices: &[PuzzleClearAtlasCardSlice],
|
||
groups_by_id: &BTreeMap<String, PuzzleClearPatternGroup>,
|
||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||
let width = PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS;
|
||
let height = PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_FINAL_ATLAS_ROWS;
|
||
let mut atlas = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 248, 234, 255]));
|
||
for slice in slices {
|
||
let group = groups_by_id.get(&slice.group.group_id).ok_or_else(|| {
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
"message": format!("拼消消最终 atlas 缺少图案组:{}", slice.group.group_id),
|
||
}))
|
||
})?;
|
||
let piece = image::load_from_memory(slice.bytes.as_slice())
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
"message": format!("拼消消最终 atlas 切片解码失败:{error}"),
|
||
}))
|
||
})?
|
||
.to_rgba8();
|
||
image::imageops::overlay(
|
||
&mut atlas,
|
||
&piece,
|
||
i64::from(group.atlas_x + slice.part_x * PUZZLE_CLEAR_ATLAS_CELL_SIZE),
|
||
i64::from(group.atlas_y + slice.part_y * PUZZLE_CLEAR_ATLAS_CELL_SIZE),
|
||
);
|
||
}
|
||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||
image::DynamicImage::ImageRgba8(atlas)
|
||
.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!("拼消消最终 atlas 合成失败:{error}"),
|
||
}))
|
||
})?;
|
||
Ok(DownloadedOpenAiImage {
|
||
extension: "png".to_string(),
|
||
mime_type: "image/png".to_string(),
|
||
bytes: cursor.into_inner(),
|
||
})
|
||
}
|
||
|
||
fn scale_sheet_coord(value: u32, actual: u32, sheet_cells: u32) -> u32 {
|
||
((u64::from(value) * u64::from(actual)) / u64::from(sheet_cells)) as u32
|
||
}
|
||
|
||
fn normalize_non_empty_str(value: &str) -> Option<String> {
|
||
let value = value.trim();
|
||
if value.is_empty() {
|
||
None
|
||
} else {
|
||
Some(value.to_string())
|
||
}
|
||
}
|
||
|
||
async fn persist_puzzle_clear_card_slice(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
profile_id: &str,
|
||
task_id: Option<&str>,
|
||
slice: PuzzleClearAtlasCardSlice,
|
||
request_context: &RequestContext,
|
||
) -> Result<PuzzleClearCardAsset, Response> {
|
||
let card_id = format!(
|
||
"{}-part-{}-{}",
|
||
slice.group.group_id, slice.part_x, slice.part_y
|
||
);
|
||
let prompt = format!(
|
||
"拼消消素材切片 {} {}:{}",
|
||
slice.group.group_id, slice.part_x, slice.part_y
|
||
);
|
||
let image = DownloadedOpenAiImage {
|
||
extension: "png".to_string(),
|
||
mime_type: "image/png".to_string(),
|
||
bytes: slice.bytes,
|
||
};
|
||
let persisted = persist_puzzle_clear_generated_image_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id,
|
||
format!("cards/{card_id}").as_str(),
|
||
prompt.as_str(),
|
||
task_id,
|
||
image,
|
||
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
|
||
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
|
||
Ok(PuzzleClearCardAsset {
|
||
card_id,
|
||
group_id: slice.group.group_id,
|
||
shape: slice.group.shape,
|
||
orientation: if slice.group.width >= slice.group.height {
|
||
"horizontal".to_string()
|
||
} else {
|
||
"vertical".to_string()
|
||
},
|
||
part_x: slice.part_x,
|
||
part_y: slice.part_y,
|
||
image_src: persisted.image_src,
|
||
image_object_key: persisted.image_object_key,
|
||
asset_object_id: persisted.asset_object_id,
|
||
source_atlas_cell: format!("{}:{}:{}", persisted.asset_id, slice.part_x, slice.part_y),
|
||
})
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn persist_puzzle_clear_generated_image_asset(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
profile_id: &str,
|
||
slot: &str,
|
||
prompt: &str,
|
||
task_id: Option<&str>,
|
||
image: DownloadedOpenAiImage,
|
||
width: u32,
|
||
height: u32,
|
||
request_context: &RequestContext,
|
||
) -> Result<PuzzleClearImageAsset, Response> {
|
||
let image_format = normalize_generated_image_asset_mime(image.mime_type.as_str());
|
||
let prepared =
|
||
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||
prefix: LegacyAssetPrefix::PuzzleClearAssets,
|
||
path_segments: vec![profile_id.to_string(), slot.to_string()],
|
||
file_stem: "image".to_string(),
|
||
image: GeneratedImageAssetDataUrl {
|
||
format: image_format,
|
||
bytes: image.bytes,
|
||
},
|
||
access: OssObjectAccess::Private,
|
||
metadata: GeneratedImageAssetAdapterMetadata {
|
||
asset_kind: Some(format!("puzzle-clear-{slot}")),
|
||
owner_user_id: Some(owner_user_id.to_string()),
|
||
entity_kind: Some("puzzle_clear_work".to_string()),
|
||
entity_id: Some(profile_id.to_string()),
|
||
slot: Some(slot.to_string()),
|
||
provider: Some("vector-engine".to_string()),
|
||
task_id: task_id.map(ToString::to_string),
|
||
},
|
||
extra_metadata: BTreeMap::new(),
|
||
})
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||
"provider": "generated-image-assets",
|
||
"message": format!("准备拼消消图片资产上传请求失败:{error:?}"),
|
||
})),
|
||
)
|
||
})?;
|
||
let persisted_mime_type = prepared.format.mime_type.clone();
|
||
let oss_client = state.oss_client().ok_or_else(|| {
|
||
puzzle_clear_error_response(
|
||
request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"reason": "OSS 未完成环境变量配置",
|
||
})),
|
||
)
|
||
})?;
|
||
let http_client = reqwest::Client::new();
|
||
let put_result = oss_client
|
||
.put_object(&http_client, prepared.request)
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
let head = oss_client
|
||
.head_object(
|
||
&http_client,
|
||
OssHeadObjectRequest {
|
||
object_key: put_result.object_key.clone(),
|
||
},
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
let now_micros = current_utc_micros();
|
||
let asset_object_input = build_asset_object_upsert_input(
|
||
generate_asset_object_id(now_micros),
|
||
head.bucket,
|
||
head.object_key.clone(),
|
||
AssetObjectAccessPolicy::Private,
|
||
head.content_type.or(Some(persisted_mime_type)),
|
||
head.content_length,
|
||
head.etag,
|
||
format!("puzzle-clear-{slot}"),
|
||
task_id.map(ToString::to_string),
|
||
Some(owner_user_id.to_string()),
|
||
Some(profile_id.to_string()),
|
||
Some(profile_id.to_string()),
|
||
now_micros,
|
||
)
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "asset-object",
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
let asset_object = state
|
||
.spacetime_client()
|
||
.confirm_asset_object(asset_object_input)
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "spacetimedb",
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
let binding_input = build_asset_entity_binding_input(
|
||
generate_asset_binding_id(now_micros),
|
||
asset_object.asset_object_id.clone(),
|
||
"puzzle_clear_work".to_string(),
|
||
profile_id.to_string(),
|
||
slot.to_string(),
|
||
format!("puzzle-clear-{slot}"),
|
||
Some(owner_user_id.to_string()),
|
||
Some(profile_id.to_string()),
|
||
now_micros,
|
||
)
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "asset-entity-binding",
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
state
|
||
.spacetime_client()
|
||
.bind_asset_object_to_entity(binding_input)
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "spacetimedb",
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
|
||
Ok(PuzzleClearImageAsset {
|
||
asset_id: format!("{profile_id}-{}-{now_micros}", slot.replace('/', "-")),
|
||
image_src: put_result.legacy_public_path,
|
||
image_object_key: head.object_key,
|
||
asset_object_id: asset_object.asset_object_id,
|
||
generation_provider: "vector-engine".to_string(),
|
||
prompt: prompt.to_string(),
|
||
width,
|
||
height,
|
||
})
|
||
}
|
||
|
||
fn build_puzzle_clear_draft(
|
||
payload: &PuzzleClearWorkspaceCreateRequest,
|
||
) -> PuzzleClearDraftResponse {
|
||
PuzzleClearDraftResponse {
|
||
template_id: PUZZLE_CLEAR_TEMPLATE_ID.to_string(),
|
||
template_name: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(),
|
||
profile_id: None,
|
||
work_title: payload.work_title.trim().to_string(),
|
||
work_description: payload.work_description.trim().to_string(),
|
||
theme_prompt: payload.theme_prompt.trim().to_string(),
|
||
board_background_prompt: payload.board_background_prompt.trim().to_string(),
|
||
generate_board_background: payload.generate_board_background,
|
||
board_background_asset: payload.board_background_asset.clone(),
|
||
card_back_image_src: Some(PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC.to_string()),
|
||
atlas_asset: None,
|
||
pattern_groups: Vec::new(),
|
||
card_assets: Vec::new(),
|
||
generation_status: PuzzleClearGenerationStatus::Draft,
|
||
}
|
||
}
|
||
|
||
fn validate_workspace_request(
|
||
request_context: &RequestContext,
|
||
payload: &PuzzleClearWorkspaceCreateRequest,
|
||
) -> Result<(), Response> {
|
||
if payload.template_id.trim() != PUZZLE_CLEAR_TEMPLATE_ID {
|
||
return Err(puzzle_clear_error_response(
|
||
request_context,
|
||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": PUZZLE_CLEAR_PROVIDER,
|
||
"message": "templateId 必须为 puzzle-clear",
|
||
})),
|
||
));
|
||
}
|
||
ensure_non_empty(request_context, &payload.work_title, "workTitle")?;
|
||
ensure_non_empty(request_context, &payload.theme_prompt, "themePrompt")?;
|
||
Ok(())
|
||
}
|
||
|
||
fn ensure_non_empty(
|
||
request_context: &RequestContext,
|
||
value: &str,
|
||
field: &str,
|
||
) -> Result<(), Response> {
|
||
if value.trim().is_empty() {
|
||
return Err(puzzle_clear_error_response(
|
||
request_context,
|
||
PUZZLE_CLEAR_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": PUZZLE_CLEAR_PROVIDER,
|
||
"field": field,
|
||
"message": format!("{field} 不能为空"),
|
||
})),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn puzzle_clear_json<T>(
|
||
payload: Result<Json<T>, JsonRejection>,
|
||
request_context: &RequestContext,
|
||
provider: &str,
|
||
) -> Result<Json<T>, Response> {
|
||
payload.map_err(|error| {
|
||
puzzle_clear_error_response(
|
||
request_context,
|
||
provider,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": provider,
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})
|
||
}
|
||
|
||
fn map_puzzle_clear_client_error(error: SpacetimeClientError) -> AppError {
|
||
let message = error.to_string();
|
||
let status = match &error {
|
||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||
SpacetimeClientError::Procedure(value)
|
||
if value.contains("不存在")
|
||
|| value.contains("not found")
|
||
|| value.contains("does not exist") =>
|
||
{
|
||
StatusCode::NOT_FOUND
|
||
}
|
||
SpacetimeClientError::Procedure(value)
|
||
if value.contains("发布需要")
|
||
|| value.contains("不能为空")
|
||
|| value.contains("必须")
|
||
|| value.contains("无权") =>
|
||
{
|
||
StatusCode::BAD_REQUEST
|
||
}
|
||
_ => StatusCode::BAD_GATEWAY,
|
||
};
|
||
|
||
AppError::from_status(status).with_details(json!({
|
||
"provider": "spacetimedb",
|
||
"message": message,
|
||
}))
|
||
}
|
||
|
||
fn puzzle_clear_error_response(
|
||
request_context: &RequestContext,
|
||
provider: &str,
|
||
error: AppError,
|
||
) -> Response {
|
||
let mut response = error.into_response_with_context(Some(request_context));
|
||
response.headers_mut().insert(
|
||
HeaderName::from_static("x-genarrative-provider"),
|
||
header::HeaderValue::from_str(provider)
|
||
.unwrap_or_else(|_| header::HeaderValue::from_static(PUZZLE_CLEAR_PROVIDER)),
|
||
);
|
||
response
|
||
}
|
||
|
||
fn build_puzzle_clear_work_play_tracking_draft(
|
||
principal: &RuntimePrincipal,
|
||
work_id: impl Into<String>,
|
||
source_route: &'static str,
|
||
) -> WorkPlayTrackingDraft {
|
||
WorkPlayTrackingDraft::runtime_principal("puzzle-clear", work_id, principal, source_route)
|
||
}
|
||
|
||
fn normalize_public_work_code(value: &str) -> String {
|
||
value
|
||
.chars()
|
||
.filter(|character| character.is_ascii_alphanumeric())
|
||
.map(|character| character.to_ascii_uppercase())
|
||
.collect()
|
||
}
|
||
|
||
fn build_puzzle_clear_public_work_code(profile_id: &str) -> String {
|
||
let normalized = normalize_public_work_code(profile_id);
|
||
let fallback = if normalized.is_empty() {
|
||
"00000000".to_string()
|
||
} else {
|
||
normalized
|
||
};
|
||
let suffix = if fallback.len() >= 8 {
|
||
fallback[fallback.len() - 8..].to_string()
|
||
} else {
|
||
format!("{fallback:0>8}")
|
||
};
|
||
format!("PC-{suffix}")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
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,
|
||
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() {
|
||
let sheet = puzzle_clear_atlas_sheet_specs()
|
||
.into_iter()
|
||
.next()
|
||
.expect("sheet exists");
|
||
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("照片式构图"));
|
||
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!(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("卡牌小格"));
|
||
assert!(!prompt.contains("卡牌排版图"));
|
||
assert!(!prompt.contains("贴纸表"));
|
||
}
|
||
|
||
#[test]
|
||
fn puzzle_clear_sheet_plan_matches_reduced_asset_strategy() {
|
||
let sheets = puzzle_clear_atlas_sheet_specs();
|
||
let groups = planned_puzzle_clear_pattern_groups();
|
||
let occupied_sheet_cells = sheets
|
||
.iter()
|
||
.flat_map(|sheet| sheet.layout.iter().flatten())
|
||
.filter(|group_id| **group_id != PUZZLE_CLEAR_SHEET_UNUSED_CELL)
|
||
.count();
|
||
let playable_sheet_cells = sheets
|
||
.iter()
|
||
.flat_map(|sheet| sheet.layout.iter().flatten())
|
||
.filter(|group_id| !is_puzzle_clear_sheet_discarded_cell(group_id))
|
||
.count();
|
||
let filler_sheet_cells = sheets
|
||
.iter()
|
||
.flat_map(|sheet| sheet.layout.iter().flatten())
|
||
.filter(|group_id| **group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL)
|
||
.count();
|
||
let mut sheet_cells_by_group = std::collections::BTreeMap::<&str, u32>::new();
|
||
for group_id in sheets
|
||
.iter()
|
||
.flat_map(|sheet| sheet.layout.iter().flatten())
|
||
.filter(|group_id| !is_puzzle_clear_sheet_discarded_cell(group_id))
|
||
{
|
||
*sheet_cells_by_group.entry(*group_id).or_default() += 1;
|
||
}
|
||
let group_cells = groups
|
||
.iter()
|
||
.map(|group| group.width * group.height)
|
||
.sum::<u32>();
|
||
|
||
assert_eq!(sheets.len(), 4);
|
||
assert_eq!(groups.len(), 35);
|
||
assert_eq!(occupied_sheet_cells, 96);
|
||
assert_eq!(playable_sheet_cells, 95);
|
||
assert_eq!(filler_sheet_cells, 1);
|
||
assert_eq!(group_cells, 95);
|
||
assert_eq!(PUZZLE_CLEAR_ATLAS_CELL_SIZE, 256);
|
||
assert_eq!(sheet_cells_by_group.len(), groups.len());
|
||
for group in &groups {
|
||
assert_eq!(
|
||
sheet_cells_by_group.get(group.group_id.as_str()).copied(),
|
||
Some(group.width * group.height),
|
||
);
|
||
}
|
||
}
|
||
|
||
#[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!({
|
||
"provider": "vector-engine",
|
||
"message": "上游服务请求失败",
|
||
"retryable": true,
|
||
}));
|
||
assert!(is_retryable_puzzle_clear_sheet_generation_error(
|
||
&retryable_error
|
||
));
|
||
|
||
let non_retryable_gateway =
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": "上游服务请求失败",
|
||
"retryable": false,
|
||
}));
|
||
assert!(!is_retryable_puzzle_clear_sheet_generation_error(
|
||
&non_retryable_gateway
|
||
));
|
||
|
||
let bad_request = AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": "请求参数不合法",
|
||
"retryable": true,
|
||
}));
|
||
assert!(!is_retryable_puzzle_clear_sheet_generation_error(
|
||
&bad_request
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn puzzle_clear_board_background_prompt_reveals_theme_goal() {
|
||
let prompt = build_puzzle_clear_board_background_prompt("星港花园");
|
||
|
||
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_eq!(PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, "1024x1024");
|
||
assert!(!prompt.contains("画面干净"));
|
||
assert!(!prompt.contains("适合作为卡牌棋盘底图"));
|
||
assert!(!prompt.contains("9:16"));
|
||
}
|
||
|
||
#[test]
|
||
fn puzzle_clear_draft_uses_existing_card_back_placeholder() {
|
||
let draft = build_puzzle_clear_draft(&PuzzleClearWorkspaceCreateRequest {
|
||
template_id: "puzzle-clear".to_string(),
|
||
work_title: "星港拼消消".to_string(),
|
||
work_description: String::new(),
|
||
theme_prompt: "星港".to_string(),
|
||
board_background_prompt: String::new(),
|
||
generate_board_background: true,
|
||
board_background_asset: None,
|
||
});
|
||
|
||
assert_eq!(
|
||
draft.card_back_image_src.as_deref(),
|
||
Some("/creation-type-references/puzzle.webp"),
|
||
);
|
||
}
|
||
}
|
||
|
||
fn current_utc_micros() -> i64 {
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.map(|duration| duration.as_micros().min(i64::MAX as u128) as i64)
|
||
.unwrap_or(0)
|
||
}
|