3157 lines
132 KiB
Rust
3157 lines
132 KiB
Rust
use axum::{
|
||
Json,
|
||
extract::{Extension, Path, State, rejection::JsonRejection},
|
||
http::{HeaderName, StatusCode, header},
|
||
response::Response,
|
||
};
|
||
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_json::{Value, json};
|
||
use shared_contracts::jump_hop::{
|
||
JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse,
|
||
JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse,
|
||
JumpHopLeaderboardEntry, JumpHopLeaderboardResponse, JumpHopRestartRunRequest,
|
||
JumpHopRunResponse, JumpHopSessionResponse, JumpHopSessionSnapshotResponse,
|
||
JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileFaceAsset, JumpHopTileFaceAssets,
|
||
JumpHopTileFaceKey, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
|
||
JumpHopWorksResponse, JumpHopWorkspaceCreateRequest,
|
||
};
|
||
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||
use spacetime_client::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},
|
||
normalize_generated_image_asset_mime,
|
||
},
|
||
http_error::AppError,
|
||
openai_image_generation::{
|
||
build_openai_image_http_client, create_openai_image_generation,
|
||
require_openai_image_settings,
|
||
},
|
||
request_context::RequestContext,
|
||
state::AppState,
|
||
wechat::subscribe_message::{
|
||
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
|
||
send_generation_result_subscribe_message_after_completion,
|
||
},
|
||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||
};
|
||
|
||
const JUMP_HOP_TILE_ITEM_COUNT: usize = 18;
|
||
|
||
const JUMP_HOP_PROVIDER: &str = "jump-hop";
|
||
pub(crate) const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation";
|
||
const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime";
|
||
const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop";
|
||
const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳";
|
||
const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs";
|
||
#[allow(dead_code)]
|
||
pub(crate) const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 6;
|
||
pub(crate) const JUMP_HOP_TILE_ATLAS_COLS: u32 = 3;
|
||
pub(crate) const JUMP_HOP_TILE_UV_FACE_ROWS: u32 = 3;
|
||
pub(crate) const JUMP_HOP_TILE_UV_FACE_COLS: u32 = 4;
|
||
pub(crate) const JUMP_HOP_TILE_ATLAS_KEY_HEX: &str = "#FF00FF";
|
||
pub(crate) const JUMP_HOP_TILE_ATLAS_IMAGE_SIZE: &str = "1024*1536";
|
||
pub(crate) const JUMP_HOP_TILE_ATLAS_IMAGE_WIDTH: u32 = 1024;
|
||
pub(crate) const JUMP_HOP_TILE_ATLAS_IMAGE_HEIGHT: u32 = 1536;
|
||
pub(crate) const JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE: u32 = 256;
|
||
const JUMP_HOP_BACKGROUND_IMAGE_SIZE: &str = "1024*1536";
|
||
const JUMP_HOP_BACKGROUND_IMAGE_WIDTH: u32 = 1024;
|
||
const JUMP_HOP_BACKGROUND_IMAGE_HEIGHT: u32 = 1536;
|
||
const JUMP_HOP_BACK_BUTTON_IMAGE_SIZE: &str = "1024*1024";
|
||
const JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH: u32 = 1024;
|
||
const JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT: u32 = 1024;
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub(crate) struct JumpHopTileAtlasSlice {
|
||
pub(crate) tile_type: JumpHopTileType,
|
||
pub(crate) source_atlas_cell: String,
|
||
pub(crate) faces: JumpHopTileFaceSlices,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub(crate) struct JumpHopTileFaceSlice {
|
||
pub(crate) face: JumpHopTileFaceKey,
|
||
pub(crate) source_atlas_cell: String,
|
||
pub(crate) bytes: Vec<u8>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub(crate) struct JumpHopTileFaceSlices {
|
||
pub(crate) top: JumpHopTileFaceSlice,
|
||
pub(crate) front: JumpHopTileFaceSlice,
|
||
pub(crate) right: JumpHopTileFaceSlice,
|
||
pub(crate) back: JumpHopTileFaceSlice,
|
||
pub(crate) left: JumpHopTileFaceSlice,
|
||
pub(crate) bottom: JumpHopTileFaceSlice,
|
||
}
|
||
|
||
pub async fn create_jump_hop_session(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<JumpHopWorkspaceCreateRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_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("jump-hop-session-");
|
||
let now = current_utc_micros();
|
||
let draft = build_jump_hop_draft(&payload);
|
||
let session = JumpHopSessionSnapshotResponse {
|
||
session_id,
|
||
owner_user_id,
|
||
status: JumpHopGenerationStatus::Draft,
|
||
draft: Some(draft),
|
||
created_at: format_timestamp_micros(now),
|
||
updated_at: format_timestamp_micros(now),
|
||
};
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopSessionResponse {
|
||
session: state
|
||
.spacetime_client()
|
||
.create_jump_hop_session(session)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?,
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_jump_hop_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 owner_user_id = authenticated.claims().user_id().to_string();
|
||
let session = state
|
||
.spacetime_client()
|
||
.get_jump_hop_session(session_id, owner_user_id)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopSessionResponse { session },
|
||
))
|
||
}
|
||
|
||
pub async fn execute_jump_hop_action(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<JumpHopActionRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
||
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?;
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let mut payload = payload;
|
||
let is_compile_draft = matches!(payload.action_type, JumpHopActionType::CompileDraft);
|
||
let generation_points_cost = if is_compile_draft {
|
||
resolve_jump_hop_generation_points_cost(&state).await
|
||
} else {
|
||
0
|
||
};
|
||
let result = async {
|
||
maybe_generate_jump_hop_assets(
|
||
&state,
|
||
&request_context,
|
||
session_id.as_str(),
|
||
owner_user_id.as_str(),
|
||
&mut payload,
|
||
)
|
||
.await?;
|
||
state
|
||
.spacetime_client()
|
||
.execute_jump_hop_action(session_id, owner_user_id.clone(), payload)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})
|
||
}
|
||
.await;
|
||
|
||
match result {
|
||
Ok(response) => {
|
||
if is_compile_draft && response.session.status == JumpHopGenerationStatus::Ready {
|
||
send_generation_result_subscribe_message_after_completion(
|
||
&state,
|
||
GenerationResultSubscribeMessage {
|
||
owner_user_id,
|
||
task_name: Some(JUMP_HOP_TEMPLATE_NAME.to_string()),
|
||
work_name: response
|
||
.session
|
||
.draft
|
||
.as_ref()
|
||
.map(|draft| draft.work_title.clone()),
|
||
status: GenerationResultSubscribeMessageStatus::Succeeded,
|
||
consumed_points: generation_points_cost,
|
||
completed_at_micros: current_utc_micros(),
|
||
page: Some("/pages/web-view/index".to_string()),
|
||
},
|
||
)
|
||
.await;
|
||
}
|
||
Ok(json_success_body(Some(&request_context), response))
|
||
}
|
||
Err(response) => {
|
||
if is_compile_draft && response.status().is_server_error() {
|
||
send_generation_result_subscribe_message_after_completion(
|
||
&state,
|
||
GenerationResultSubscribeMessage {
|
||
owner_user_id,
|
||
task_name: Some(JUMP_HOP_TEMPLATE_NAME.to_string()),
|
||
work_name: None,
|
||
status: GenerationResultSubscribeMessageStatus::Failed,
|
||
consumed_points: 0,
|
||
completed_at_micros: current_utc_micros(),
|
||
page: Some("/pages/web-view/index".to_string()),
|
||
},
|
||
)
|
||
.await;
|
||
}
|
||
Err(response)
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn resolve_jump_hop_generation_points_cost(state: &AppState) -> u64 {
|
||
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
|
||
state,
|
||
JUMP_HOP_TEMPLATE_ID,
|
||
u64::from(shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
|
||
)
|
||
.await
|
||
}
|
||
|
||
pub async fn publish_jump_hop_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 work = state
|
||
.spacetime_client()
|
||
.publish_jump_hop_work(profile_id, authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopWorkMutationResponse { item: work },
|
||
))
|
||
}
|
||
|
||
pub async fn list_jump_hop_works(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let works = state
|
||
.spacetime_client()
|
||
.list_jump_hop_works(authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopWorksResponse {
|
||
items: works.into_iter().map(|work| work.summary).collect(),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_jump_hop_work_detail(
|
||
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 work = state
|
||
.spacetime_client()
|
||
.get_jump_hop_work_profile(profile_id, authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopWorkDetailResponse { item: work },
|
||
))
|
||
}
|
||
|
||
pub async fn delete_jump_hop_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 works = state
|
||
.spacetime_client()
|
||
.delete_jump_hop_work(profile_id, authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopWorksResponse {
|
||
items: works.into_iter().map(|work| work.summary).collect(),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_jump_hop_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 work = state
|
||
.spacetime_client()
|
||
.get_jump_hop_runtime_work(profile_id)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_RUNTIME_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopWorkDetailResponse { item: work },
|
||
))
|
||
}
|
||
|
||
pub async fn get_jump_hop_leaderboard(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(principal): Extension<RuntimePrincipal>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &profile_id, "profileId")?;
|
||
let leaderboard = state
|
||
.spacetime_client()
|
||
.get_jump_hop_leaderboard(profile_id, principal.subject().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_RUNTIME_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopLeaderboardResponse {
|
||
profile_id: leaderboard.profile_id,
|
||
items: leaderboard
|
||
.items
|
||
.into_iter()
|
||
.map(|entry| resolve_jump_hop_leaderboard_entry_display_name(&state, entry))
|
||
.collect(),
|
||
viewer_best: leaderboard
|
||
.viewer_best
|
||
.map(|entry| resolve_jump_hop_leaderboard_entry_display_name(&state, entry)),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn start_jump_hop_run(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(principal): Extension<RuntimePrincipal>,
|
||
payload: Result<Json<JumpHopStartRunRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
|
||
ensure_non_empty(&request_context, &payload.profile_id, "profileId")?;
|
||
let is_draft_runtime = payload
|
||
.runtime_mode
|
||
.as_deref()
|
||
.is_some_and(is_jump_hop_draft_runtime_mode);
|
||
let owner_user_id = principal.subject().to_string();
|
||
let principal_kind = principal.kind().as_str();
|
||
let run = state
|
||
.spacetime_client()
|
||
.start_jump_hop_run(payload, owner_user_id.clone())
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_RUNTIME_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
if !is_draft_runtime {
|
||
record_work_play_start_after_success(
|
||
&state,
|
||
&request_context,
|
||
build_jump_hop_work_play_tracking_draft(
|
||
&principal,
|
||
run.profile_id.clone(),
|
||
JUMP_HOP_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,
|
||
})),
|
||
)
|
||
.await;
|
||
}
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopRunResponse { run },
|
||
))
|
||
}
|
||
|
||
pub async fn jump_hop_run_jump(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(principal): Extension<RuntimePrincipal>,
|
||
payload: Result<Json<JumpHopJumpRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
|
||
let owner_user_id = principal.subject().to_string();
|
||
let run = state
|
||
.spacetime_client()
|
||
.jump_hop_run_jump(run_id, owner_user_id, payload)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_RUNTIME_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopJumpResponse { run },
|
||
))
|
||
}
|
||
|
||
pub async fn restart_jump_hop_run(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(principal): Extension<RuntimePrincipal>,
|
||
payload: Result<Json<JumpHopRestartRunRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
|
||
let owner_user_id = principal.subject().to_string();
|
||
let run = state
|
||
.spacetime_client()
|
||
.restart_jump_hop_run(run_id, owner_user_id, payload)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_RUNTIME_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopRunResponse { run },
|
||
))
|
||
}
|
||
|
||
pub async fn list_jump_hop_gallery(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let gallery = state
|
||
.spacetime_client()
|
||
.list_jump_hop_gallery()
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_RUNTIME_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(Some(&request_context), gallery))
|
||
}
|
||
|
||
pub async fn get_jump_hop_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 work = state
|
||
.spacetime_client()
|
||
.get_jump_hop_gallery_detail(public_work_code)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_RUNTIME_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopGalleryDetailResponse { item: work },
|
||
))
|
||
}
|
||
|
||
async fn maybe_generate_jump_hop_assets(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
_session_id: &str,
|
||
owner_user_id: &str,
|
||
payload: &mut JumpHopActionRequest,
|
||
) -> Result<(), Response> {
|
||
if !matches!(
|
||
payload.action_type,
|
||
JumpHopActionType::CompileDraft | JumpHopActionType::RegenerateTiles
|
||
) {
|
||
return Ok(());
|
||
}
|
||
let has_complete_tile_assets = payload.tile_atlas_asset.is_some()
|
||
&& payload
|
||
.tile_assets
|
||
.as_ref()
|
||
.is_some_and(|assets| assets.len() >= JUMP_HOP_TILE_ITEM_COUNT);
|
||
let has_real_background = payload
|
||
.cover_composite
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.is_some_and(|value| !is_jump_hop_legacy_cover_composite_placeholder(value));
|
||
let has_back_button_asset = payload
|
||
.back_button_asset
|
||
.as_ref()
|
||
.is_some_and(is_jump_hop_image_asset_usable);
|
||
|
||
if has_complete_tile_assets && has_real_background && has_back_button_asset {
|
||
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("jump-hop-profile-"));
|
||
payload.profile_id = Some(profile_id.clone());
|
||
|
||
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| {
|
||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||
})?;
|
||
let http_client = build_openai_image_http_client(&settings).map_err(|error| {
|
||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||
})?;
|
||
|
||
let theme_text = payload
|
||
.theme_text
|
||
.as_deref()
|
||
.or(payload.work_title.as_deref())
|
||
.unwrap_or("跳一跳")
|
||
.to_string();
|
||
let tile_prompt = payload
|
||
.tile_prompt
|
||
.clone()
|
||
.unwrap_or_else(|| theme_text.clone());
|
||
|
||
if !has_real_background {
|
||
let background_prompt = build_jump_hop_background_prompt(theme_text.as_str());
|
||
let background_generated = create_openai_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
background_prompt.as_str(),
|
||
Some(build_jump_hop_background_negative_prompt()),
|
||
JUMP_HOP_BACKGROUND_IMAGE_SIZE,
|
||
1,
|
||
&[],
|
||
"跳一跳背景底图生成失败",
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||
})?;
|
||
let background_image = background_generated
|
||
.images
|
||
.into_iter()
|
||
.next()
|
||
.ok_or_else(|| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": "跳一跳背景底图生成成功但未返回图片。",
|
||
})),
|
||
)
|
||
})?;
|
||
let background_asset = persist_jump_hop_generated_image_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id.as_str(),
|
||
"background",
|
||
background_prompt.as_str(),
|
||
background_image,
|
||
LegacyAssetPrefix::JumpHopAssets,
|
||
JUMP_HOP_BACKGROUND_IMAGE_WIDTH,
|
||
JUMP_HOP_BACKGROUND_IMAGE_HEIGHT,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
payload.cover_composite = Some(background_asset.image_src);
|
||
}
|
||
|
||
if !has_back_button_asset {
|
||
let back_button_prompt = build_jump_hop_back_button_prompt(theme_text.as_str());
|
||
let back_button_generated = create_openai_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
back_button_prompt.as_str(),
|
||
Some(build_jump_hop_back_button_negative_prompt()),
|
||
JUMP_HOP_BACK_BUTTON_IMAGE_SIZE,
|
||
1,
|
||
&[],
|
||
"跳一跳返回按钮图生成失败",
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||
})?;
|
||
let back_button_image =
|
||
back_button_generated
|
||
.images
|
||
.into_iter()
|
||
.next()
|
||
.ok_or_else(|| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": "跳一跳返回按钮图生成成功但未返回图片。",
|
||
})),
|
||
)
|
||
})?;
|
||
let back_button_image =
|
||
prepare_jump_hop_green_screen_image_for_persist(back_button_image, "跳一跳返回按钮图")
|
||
.map_err(|error| {
|
||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||
})?;
|
||
let back_button_asset = persist_jump_hop_generated_image_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id.as_str(),
|
||
"back-button",
|
||
back_button_prompt.as_str(),
|
||
back_button_image,
|
||
LegacyAssetPrefix::JumpHopAssets,
|
||
JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH,
|
||
JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
payload.back_button_asset = Some(back_button_asset);
|
||
}
|
||
|
||
if !has_complete_tile_assets {
|
||
let sheet_prompt =
|
||
build_jump_hop_tile_atlas_prompt(theme_text.as_str(), tile_prompt.as_str());
|
||
let tile_generated = create_openai_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
sheet_prompt.as_str(),
|
||
Some(build_jump_hop_tile_atlas_negative_prompt()),
|
||
JUMP_HOP_TILE_ATLAS_IMAGE_SIZE,
|
||
1,
|
||
&[],
|
||
"跳一跳地板贴图图集生成失败",
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||
})?;
|
||
let tile_image = tile_generated.images.into_iter().next().ok_or_else(|| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "vector-engine",
|
||
"message": "跳一跳地板贴图图集生成成功但未返回图片。",
|
||
})),
|
||
)
|
||
})?;
|
||
// 去除 AI 生图中可能混入主体区域或边缘的洋红(#FF00FF)背景,避免污染后续切片与贴图。
|
||
let tile_image = prepare_jump_hop_magenta_screen_image_for_slicing(
|
||
tile_image,
|
||
"跳一跳地板贴图图集洋红去背失败",
|
||
)
|
||
.map_err(|error| {
|
||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||
})?;
|
||
let tile_slices = crate::jump_hop_atlas_slicing::slice_tile_atlas_adaptive(
|
||
&tile_image,
|
||
crate::jump_hop_atlas_slicing::DEFAULT_TILE_ROWS,
|
||
crate::jump_hop_atlas_slicing::DEFAULT_TILE_COLS,
|
||
crate::jump_hop_atlas_slicing::AtlasSliceAlgorithm::default(),
|
||
)
|
||
.map_err(|error| {
|
||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||
})?;
|
||
let tile_atlas_asset = persist_jump_hop_generated_image_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id.as_str(),
|
||
"tile-atlas",
|
||
tile_prompt.as_str(),
|
||
tile_image,
|
||
LegacyAssetPrefix::JumpHopAssets,
|
||
JUMP_HOP_TILE_ATLAS_IMAGE_WIDTH,
|
||
JUMP_HOP_TILE_ATLAS_IMAGE_HEIGHT,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
let mut tile_assets = Vec::with_capacity(tile_slices.len());
|
||
for (index, tile_slice) in tile_slices.into_iter().enumerate() {
|
||
tile_assets.push(
|
||
persist_jump_hop_tile_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id.as_str(),
|
||
index,
|
||
tile_slice,
|
||
request_context,
|
||
)
|
||
.await?,
|
||
);
|
||
}
|
||
payload.tile_atlas_asset = Some(tile_atlas_asset);
|
||
payload.tile_assets = Some(tile_assets);
|
||
}
|
||
if payload.character_asset.is_none() {
|
||
payload.character_asset = Some(build_jump_hop_default_character_asset(
|
||
profile_id.as_str(),
|
||
theme_text.as_str(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn is_jump_hop_legacy_cover_composite_placeholder(value: &str) -> bool {
|
||
let value = value.trim();
|
||
value.starts_with("/generated-jump-hop-assets/")
|
||
&& (value.ends_with("/cover-composite.png") || value.contains("/cover-composite-"))
|
||
}
|
||
|
||
fn is_jump_hop_image_asset_usable(asset: &JumpHopCharacterAsset) -> bool {
|
||
!asset.image_src.trim().is_empty()
|
||
&& !asset.image_object_key.trim().is_empty()
|
||
&& !asset.asset_object_id.trim().is_empty()
|
||
&& !asset.generation_provider.trim().is_empty()
|
||
}
|
||
|
||
fn prepare_jump_hop_green_screen_image_for_persist(
|
||
image: crate::openai_image_generation::DownloadedOpenAiImage,
|
||
failure_label: &str,
|
||
) -> Result<crate::openai_image_generation::DownloadedOpenAiImage, AppError> {
|
||
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||
"message": format!("{failure_label}解码失败:{error}"),
|
||
}))
|
||
})?;
|
||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||
crate::generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha(source)
|
||
.write_to(&mut encoded, image::ImageFormat::Png)
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||
"message": format!("{failure_label}绿幕去背失败:{error}"),
|
||
}))
|
||
})?;
|
||
|
||
Ok(crate::openai_image_generation::DownloadedOpenAiImage {
|
||
bytes: encoded.into_inner(),
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
})
|
||
}
|
||
|
||
/// 在图集切片之前,用 BFS 漫水 + 软 matte 扩展 + 去彩边算法去除洋红(#FF00FF)背景。
|
||
/// 算法与绿幕去背共享同一套 `remove_generated_asset_sheet_green_screen_background` 核心,
|
||
/// 通过 `jump_hop_magenta_screen()` 预设切换 key 色为洋红,并启用内部镂空洞检测。
|
||
fn prepare_jump_hop_magenta_screen_image_for_slicing(
|
||
image: crate::openai_image_generation::DownloadedOpenAiImage,
|
||
failure_label: &str,
|
||
) -> Result<crate::openai_image_generation::DownloadedOpenAiImage, AppError> {
|
||
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||
"message": format!("{failure_label}解码失败:{error}"),
|
||
}))
|
||
})?;
|
||
let processed = crate::generated_asset_sheets::apply_generated_asset_sheet_alpha_with_options(
|
||
source,
|
||
crate::generated_asset_sheets::GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
|
||
);
|
||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||
processed
|
||
.write_to(&mut encoded, image::ImageFormat::Png)
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||
"message": format!("{failure_label}洋红去背编码失败:{error}"),
|
||
}))
|
||
})?;
|
||
|
||
Ok(crate::openai_image_generation::DownloadedOpenAiImage {
|
||
bytes: encoded.into_inner(),
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
})
|
||
}
|
||
|
||
fn normalize_jump_hop_generation_theme_text(theme_text: &str) -> String {
|
||
let theme_text = theme_text.trim();
|
||
if theme_text.is_empty() {
|
||
return "跳一跳".to_string();
|
||
}
|
||
|
||
replace_jump_hop_pokemon_prompt_terms(theme_text)
|
||
}
|
||
|
||
fn replace_jump_hop_pokemon_prompt_terms(value: &str) -> String {
|
||
let mut value = value.trim().to_string();
|
||
if value.is_empty() {
|
||
return value;
|
||
}
|
||
|
||
// 中文注释:仅对宝可梦相关词做生成侧脱敏,避免贴图图集触发上游安全拦截。
|
||
const POKEMON_REPLACEMENTS: [(&str, &str); 15] = [
|
||
("宝可梦", "原创幻想萌宠冒险道具"),
|
||
("神奇宝贝", "原创幻想萌宠冒险道具"),
|
||
("口袋妖怪", "原创幻想萌宠冒险道具"),
|
||
("精灵球", "彩色冒险能量球"),
|
||
("皮卡丘", "黄色闪电萌宠符号"),
|
||
("Pokémon", "原创幻想萌宠冒险道具"),
|
||
("Pokemon", "原创幻想萌宠冒险道具"),
|
||
("POKEMON", "原创幻想萌宠冒险道具"),
|
||
("pokemon", "原创幻想萌宠冒险道具"),
|
||
("Pikachu", "黄色闪电萌宠符号"),
|
||
("PIKACHU", "黄色闪电萌宠符号"),
|
||
("pikachu", "黄色闪电萌宠符号"),
|
||
("Poké Ball", "彩色冒险能量球"),
|
||
("Poke Ball", "彩色冒险能量球"),
|
||
("pokeball", "彩色冒险能量球"),
|
||
];
|
||
|
||
for (from, to) in POKEMON_REPLACEMENTS {
|
||
value = value.replace(from, to);
|
||
}
|
||
|
||
value
|
||
}
|
||
|
||
fn build_jump_hop_background_prompt(theme_text: &str) -> String {
|
||
let theme_text = normalize_jump_hop_generation_theme_text(theme_text);
|
||
|
||
format!(
|
||
"生成一张9:16竖版跳一跳游戏背景底图,主题关键词严格只使用“{theme_text}”,不要额外改换主题;整体风格需要和同一主题的跳一跳游戏元素一致。\n画面结构必须以左右两侧氛围为主:左侧和右侧可以使用符合主题的环境元素、装饰层次、前中后景遮挡、透视节奏和行进感,让玩家感到从画面下方向上方前进。\n中央纵轴1/2区域必须是清爽的跳跃路径视觉走廊,从画面底部延伸到上方;该区域只能使用少量低对比度纹理、柔和光影、空气透视和纵深引导线,禁止堆放大型主体。\n中央纵轴1/2区域要有明显纵深感,但元素数量必须少,不能抢跳板、角色和交互层的视觉;两侧可以更有立体感、空间层次和主题氛围。\n背景只作为底图,不画任何跳板、地块、落脚物、角色、UI按钮、标题、文字、路径箭头、分数、边框、海报排版、Logo或水印;左上角也不要画返回按钮或任何固定图标,运行态会叠加独立可点击按钮资产。\n视角保持正面约30度的2D/2.5D休闲手游视角,相机位于场景正前方略高位置,画面有轻微向上行进的纵深,不要画成纯俯视地图、平铺俯拍、扁平壁纸或真实摄影。\n色彩清爽自然,哑光手绘质感,柔和光照,主体背景不油亮、不厚重CG、不暗黑;中央区域需要给运行态地块和陶泥儿角色留出干净可读空间。\nEnglish guardrail: vertical 9:16 mobile game background only, theme keywords strictly from \"{theme_text}\", left and right sides carry the atmosphere, the central vertical half-width corridor stays simple with sparse low-contrast details and clear depth, no platforms, no landing objects, no character, no UI, no top-left back button, no score UI, no other UI panels, consistent 2D/2.5D front-facing 30-degree game perspective."
|
||
)
|
||
}
|
||
|
||
fn build_jump_hop_background_negative_prompt() -> &'static str {
|
||
"文字、Logo、水印、UI按钮、返回按钮、左上角图标、右上角按钮、底部按钮、UI面板、标题、说明文字、分数、边框、海报排版、角色、人物、跳板、地块、落脚物、平台、道路箭头、棋盘、格子、中心大型主体、中央堆满元素、中央遮挡、中央高对比装饰、中央复杂花纹、纯俯视地图、平铺俯拍、扁平壁纸、真实摄影、暗黑幻想风、厚重CG渲染、油亮高光、塑料质感"
|
||
}
|
||
|
||
fn build_jump_hop_back_button_prompt(theme_text: &str) -> String {
|
||
let theme_text = normalize_jump_hop_generation_theme_text(theme_text);
|
||
|
||
format!(
|
||
"生成跳一跳运行态左上角返回按钮的独立透明素材。主题关键词严格只使用“{theme_text}”,按钮的底色、材质、描边和轻微装饰跟随该主题,但必须仍然是清晰可识别的游戏 UI 返回按钮。\n按钮必须是单个标准圆形图标,圆心居中,主体视觉尺寸占画布约72%-82%,外沿有一圈干净描边,内部只有一个居中的向左箭头;不要写“返回”文字,不要数字、Logo、水印、按钮外标签或额外 UI 面板。\n允许在圆形底色里做很轻的主题材质包装,例如水果主题可用果皮色和果肉色、森林主题可用叶片色和木质描边、未来主题可用金属边和发光内环;但不要把按钮画成主题物体本身,不要继承复杂花纹、浮雕边、异形外框、贴纸堆叠或徽章装饰。\n尺寸1:1,输出绿色背景主体图,背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影;按钮主体边缘干净,后续由服务端扣除绿色背景。按钮底色不要使用与绿幕接近的纯绿色,若主题天然包含绿色,请使用偏深、偏黄或偏蓝的主题绿色,并用高对比箭头颜色区分。\nEnglish guardrail: one standalone circular mobile game back button asset only, theme-styled colors/materials from \"{theme_text}\", centered left arrow only, no text, no logo, no extra UI, no complex badge, no object silhouette, solid #00FF00 green-screen background for later alpha removal."
|
||
)
|
||
}
|
||
|
||
fn build_jump_hop_back_button_negative_prompt() -> &'static str {
|
||
"文字、返回文字、Logo、水印、数字、多个按钮、UI面板、海报排版、复杂徽章、花盘、浮雕边、异形外框、主题物体主体、木槌、角色、跳板、地块、落脚物、平台、透明棋盘格、白底、黑底、灰底、真实摄影、厚重CG、暗黑幻想风、油亮塑料、纯绿色按钮主体、与绿幕混在一起"
|
||
}
|
||
|
||
fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> String {
|
||
let theme_text = normalize_jump_hop_generation_theme_text(theme_text);
|
||
let sanitized_tile_prompt = sanitize_jump_hop_tile_prompt(tile_prompt);
|
||
let subject_text = if sanitized_tile_prompt.is_empty() {
|
||
theme_text.as_str()
|
||
} else {
|
||
sanitized_tile_prompt.as_str()
|
||
};
|
||
|
||
// 旧版 prompt(保留供参考)
|
||
// format!(
|
||
// "生成一张1024x1536竖版图片,主题为"{theme_text}"。\n画面只包含18个用于跳一跳地板的立方体主题物体 UV 展开包装图,按三列六行均匀排布;每个大单元格代表一个完整的 1x1x1 立方体方块物体,运行态会把该单元内的六张面贴图精确贴到 Three.js 标准极小倒角立方体的六个面上。\n画面内容是{subject_text}。这是一张 cube object UV unwrap atlas / 立方体主题物体六面展开图集,不是单纯平铺材质、不是抽象纹理、不是只把主题颜色铺满,也不是已经渲染好的 3D 方块成品、游戏界面或图标集页面。\n每个大单元格内部必须使用固定 4列x3行 UV 展开结构,只有以下六个位置有贴图,其它位置保持纯洋红安全色:第1行第2列是 top;第2行第1列是 left;第2行第2列是 front;第2行第3列是 right;第2行第4列是 back;第3行第2列是 bottom。不要改变顺序,不要旋转面,不要把六个面画成一张连续透视图。\n每个方块都必须表现为"一个完整主题物体被塑造成 1x1x1 立方体后的六面包装",六个面要属于同一个物体并能组合成完整方块造型;top/front/right/back/left/bottom 之间的颜色、边缘纹理、切面、果皮、籽点、条纹、果柄和叶片必须连续一致,不能六面各画互不相关的图案,也不能把同一张纹理重复六次。\n水果主题要生成18种可一眼辨认的方块水果 UV:方块苹果、方块香蕉、方块橙子、方块西瓜、方块草莓、方块葡萄、方块奇异果、方块菠萝、方块柠檬、方块桃子、方块梨、方块蓝莓、方块芒果、方块椰子、方块火龙果、方块樱桃、方块哈密瓜、方块石榴等;苹果需要果柄叶片跨 top/front,香蕉需要剥皮条带跨 front/right,橙子需要放射切面跨 top/front,西瓜需要红瓤黑籽和绿皮条纹在各面连续。不要只画重复果皮纹理、随机斑点、叶脉纹理或抽象材质。\n每个面都是满版不透明正方形贴图 / full-bleed opaque square face texture:四角、边缘和中心都要有可识别内容,不留透明、不留空白、不留实底背景;允许大面积水果切面、果柄叶片、剥皮条带、籽点、条纹和轮廓图案作为包装身份锚点,但不要把一个小水果、小叶片、小石头或小物体放在面中央,也不要画小贴纸、小图标、徽章或孤立主体。\n这不是透视渲染图:不要画摄像机视角、透视块、已烘焙侧壁、已烘焙厚度、自身投影、接触阴影或高光光斑;真实透视、倒角、侧壁和阴影会由运行态 Three.js 统一生成。每个面贴图在运行态会以约45度下压视角和较小尺寸显示,所以必须使用大色块、高对比、粗线条和简单图形,保证在64x64缩略图里仍能分辨主题物体身份。\n排布必须安全:18个大单元格之间应留有纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 填充的区域作为自然间距,相邻单元格的面贴图不得互相紧贴或交叠;大单元之间、UV 空位、六面之间和画布最外圈只能使用单一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 大面积填充,作为切图安全色。洋红区域以块状面视觉呈现,不要画网格线、描边、边框、单元格编号、face label 或裁切标记。\n贴图内部可以使用绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题颜色,但不得使用接近 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 的纯洋红色;贴图边缘不得有洋红描边、紫色底边、粉色脏边、彩色光晕或发光边。\n禁止文字、Logo、水印、UI按钮、标题、角标、装饰边框、face label、top/front/right/back/left/bottom文字、背包、装备栏、菜单、角色、完整场景、自然圆形水果、自然长条香蕉、非方块化完整水果、孤立水果照片、小型果切贴纸、小型橙片贴纸、小贴纸图标、小物体居中、纯果皮材质、纯果肉纹理、纯叶脉纹理、纯颜色块、透明背景、留白、3D平台、圆台、底座、托盘、物体摆在平台上、透视地块、正面30度物体图、鸟瞰地图块、落地投影、接触阴影、方形阴影、白底、灰底、黑底。\nEnglish guardrail: one vertical 1024x1536 image, exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas; each large cell is one complete cube object skin with a fixed 4x3 UV net: row1 col2 top, row2 col1 left, row2 co... (line truncated to 2000 chars)
|
||
// )
|
||
|
||
// 新版 prompt:先说明布局与元素,最后说明背景填充
|
||
format!(
|
||
"生成一张1024x1536竖版图片。以下按顺序说明画面中需要包含的内容:\n\
|
||
\n\
|
||
【整体布局】\n\
|
||
画面中排列 18 个大单元格,以 3 列 6 行的网格形式均匀分布。大单元格之间彼此绝不接触——行与行之间由至少 40 像素宽的纯洋红水平间距隔开,列与列之间由至少 30 像素宽的纯洋红垂直间距隔开。画面四周同样保留至少 30 像素宽的纯洋红边距。\n\
|
||
\n\
|
||
【每个大单元格的内部结构】\n\
|
||
每个大单元格内部是一个 4列x3行 的子网格。在这 12 个格子中,只有以下 6 个位置包含贴图内容:\n\
|
||
- 第1行第2列:top(顶面)\n\
|
||
- 第2行第1列:left(左侧面)\n\
|
||
- 第2行第2列:front(正面)\n\
|
||
- 第2行第3列:right(右侧面)\n\
|
||
- 第2行第4列:back(背面)\n\
|
||
- 第3行第2列:bottom(底面)\n\
|
||
其余 6 个格子和子网格之间的区域留空。每个面的贴图是正方形,应填满所在格子,不留边距。\n\
|
||
\n\
|
||
【贴图内容要求】\n\
|
||
18 个大单元格各自代表一个独立的 1x1x1 立方体物体的六面展开。主题为\"{theme_text}\",具体内容为{subject_text}。\n\
|
||
每个方块物体的 6 个面必须属于同一个物体,各面的纹理、颜色、图案要能连续接合,组合成一个完整的立方体造型。不要 6 个面彼此无关,也不要 6 个面重复同一张纹理。\n\
|
||
水果主题需生成 18 种可辨认的不同方块水果,例如果柄叶片跨越 top/front,剥皮条带跨越 front/right,放射切面跨越 top/front 等。\n\
|
||
每个面是满版不透明贴图,使用大色块、高对比、粗线条和简单图形,保证缩小到 64x64 时仍可辨认。不做透视渲染,不画投影、高光、倒角、侧壁厚度。\n\
|
||
\n\
|
||
【背景填充】\n\
|
||
大单元格之间的间距、每个大单元格内 6 个贴图之外的子网格区域、子网格之间的空隙、以及画面四周边距,全部使用单一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 填充。洋红区域内不画任何文字、编号、标记或装饰。\n\
|
||
\n\
|
||
禁止:文字、数字、Logo、图标、编号、标记线、箭头、UI 元素、透视渲染、投影、高光、底座、托盘、透明背景、留白。"
|
||
)
|
||
}
|
||
|
||
fn build_jump_hop_tile_atlas_negative_prompt() -> &'static str {
|
||
"单元格紧贴、单元格粘连、无间距、无缝排列、贴边、跨格、越界、边界溢出、侵占洋红间距、吞没洋红间隔、贴图伸入间距区域、文字、Logo、水印、UI按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、海报、UI图标卡、标题、说明文字、装饰边框、单纯平铺材质、抽象纹理、随机斑点、只铺主题颜色、纯果皮材质、纯果肉纹理、纯叶脉纹理、无法分辨具体物体、自然圆形水果、自然长条香蕉、非方块化完整水果、孤立水果照片、果切小贴纸、橙片小贴纸、小水果居中、苹果小贴纸、香蕉小贴纸、小贴纸图标、小物体居中、透明背景、留白、3D平台、跳板成品、地块成品、物体剪影、正面30度物体图、纯俯视地图块、鸟瞰地图块、透视地块、已经画好的侧壁、已经画好的厚度、烘焙高光、烘焙阴影、落地投影、接触阴影、方形阴影、洋红阴影、紫色底边、粉色脏边、洋红色描边、彩色光晕、发光底边、方形底板、额外底座、承托底座、台座、石台、土墩、木板底座、圆台、底盘、托盘、岛屿底座、花盆底座、地面块、脚下地板、物体摆在平台上、物体下方垫地板、白底、灰底、黑底、暗色背景、背景色块、可见网格线、编号、裁切标记"
|
||
}
|
||
|
||
fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String {
|
||
let mut value = tile_prompt.trim().to_string();
|
||
if value.is_empty() {
|
||
return value;
|
||
}
|
||
value = replace_jump_hop_pokemon_prompt_terms(value.as_str());
|
||
|
||
const REPLACEMENTS: [(&str, &str); 24] = [
|
||
("正面30度视角主题物体图集", "3D立方体主题身份方块包装图集"),
|
||
("物体本身作为跳跃落点", "主题物体方块化后作为立方体包装"),
|
||
("3D立方体主题方块包装图集", "3D立方体主题身份方块包装图集"),
|
||
("立方体主题方块包装图集", "立方体主题身份方块包装图集"),
|
||
("俯视角", "正交平面"),
|
||
("正上方视角", "正交平面"),
|
||
("鸟瞰视角", "正交平面"),
|
||
("平铺俯拍", "正交平面"),
|
||
("可落脚平台素材", "立方体主题身份方块包装贴图"),
|
||
(
|
||
"清爽游戏化立体感平台素材",
|
||
"清爽游戏化立方体主题身份方块包装贴图",
|
||
),
|
||
("平台裸素材", "立方体主题身份方块包装贴图"),
|
||
("每格一个完整平台", "每格一张完整身份方块包装贴图"),
|
||
("主题物体图集", "立方体主题身份方块包装图集"),
|
||
("主题物体", "主题身份方块包装"),
|
||
("平台素材", "立方体身份方块包装贴图"),
|
||
("可落脚平台", "立方体主题身份方块包装"),
|
||
("可落脚", "可贴图"),
|
||
("平台", "立方体地板"),
|
||
("跳台", "立方体地板"),
|
||
("地块", "身份方块包装贴图"),
|
||
("地砖", "身份方块包装贴图"),
|
||
("底座", "承托物"),
|
||
("底盘", "承托物"),
|
||
("地板", "立方体地板"),
|
||
];
|
||
|
||
for (from, to) in REPLACEMENTS {
|
||
value = value.replace(from, to);
|
||
}
|
||
while value.contains("立方体立方体") {
|
||
value = value.replace("立方体立方体", "立方体");
|
||
}
|
||
|
||
value
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
pub(crate) fn slice_jump_hop_tile_atlas(
|
||
image: &crate::openai_image_generation::DownloadedOpenAiImage,
|
||
) -> Result<Vec<JumpHopTileAtlasSlice>, AppError> {
|
||
let source = image::load_from_memory(image.bytes.as_slice())
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||
"message": format!("跳一跳地板贴图图集解码失败:{error}"),
|
||
}))
|
||
})?
|
||
.to_rgba8();
|
||
let width = source.width();
|
||
let height = source.height();
|
||
let cell_width = width / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let cell_height = height / JUMP_HOP_TILE_ATLAS_ROWS;
|
||
if cell_width == 0 || cell_height == 0 {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||
"message": "跳一跳地板贴图图集尺寸过小,无法切割。",
|
||
})),
|
||
);
|
||
}
|
||
|
||
let mut slices = Vec::with_capacity(JUMP_HOP_TILE_ITEM_COUNT);
|
||
for index in 0..JUMP_HOP_TILE_ITEM_COUNT {
|
||
let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let col = index as u32 % JUMP_HOP_TILE_ATLAS_COLS;
|
||
let x0 = col.saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let x1 = (col.saturating_add(1)).saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let y0 = row.saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS;
|
||
let y1 = (row.saturating_add(1)).saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS;
|
||
let tile_width = x1.saturating_sub(x0).max(1);
|
||
let tile_height = y1.saturating_sub(y0).max(1);
|
||
let faces = slice_jump_hop_tile_uv_faces(
|
||
&source,
|
||
x0,
|
||
y0,
|
||
tile_width,
|
||
tile_height,
|
||
row,
|
||
col,
|
||
)?;
|
||
slices.push(JumpHopTileAtlasSlice {
|
||
tile_type: jump_hop_tile_type_by_index(index),
|
||
source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1),
|
||
faces,
|
||
});
|
||
}
|
||
|
||
Ok(slices)
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
pub(crate) fn slice_jump_hop_tile_uv_faces(
|
||
source: &image::RgbaImage,
|
||
tile_x: u32,
|
||
tile_y: u32,
|
||
tile_width: u32,
|
||
tile_height: u32,
|
||
atlas_row: u32,
|
||
atlas_col: u32,
|
||
) -> Result<JumpHopTileFaceSlices, AppError> {
|
||
let face_side = (tile_width / JUMP_HOP_TILE_UV_FACE_COLS)
|
||
.min(tile_height / JUMP_HOP_TILE_UV_FACE_ROWS)
|
||
.max(1);
|
||
let uv_width = face_side.saturating_mul(JUMP_HOP_TILE_UV_FACE_COLS);
|
||
let uv_height = face_side.saturating_mul(JUMP_HOP_TILE_UV_FACE_ROWS);
|
||
let uv_x = tile_x.saturating_add(tile_width.saturating_sub(uv_width) / 2);
|
||
let uv_y = tile_y.saturating_add(tile_height.saturating_sub(uv_height) / 2);
|
||
|
||
Ok(JumpHopTileFaceSlices {
|
||
top: slice_jump_hop_tile_uv_face(
|
||
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Top, 1, 0,
|
||
)?,
|
||
front: slice_jump_hop_tile_uv_face(
|
||
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Front, 1, 1,
|
||
)?,
|
||
right: slice_jump_hop_tile_uv_face(
|
||
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Right, 2, 1,
|
||
)?,
|
||
back: slice_jump_hop_tile_uv_face(
|
||
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Back, 3, 1,
|
||
)?,
|
||
left: slice_jump_hop_tile_uv_face(
|
||
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Left, 0, 1,
|
||
)?,
|
||
bottom: slice_jump_hop_tile_uv_face(
|
||
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Bottom, 1, 2,
|
||
)?,
|
||
})
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
#[allow(dead_code)]
|
||
pub(crate) fn slice_jump_hop_tile_uv_face(
|
||
source: &image::RgbaImage,
|
||
uv_x: u32,
|
||
uv_y: u32,
|
||
face_side: u32,
|
||
atlas_row: u32,
|
||
atlas_col: u32,
|
||
face: JumpHopTileFaceKey,
|
||
face_col: u32,
|
||
face_row: u32,
|
||
) -> Result<JumpHopTileFaceSlice, AppError> {
|
||
let cleaned = crop_jump_hop_tile_texture_cell(
|
||
source,
|
||
uv_x.saturating_add(face_col.saturating_mul(face_side)),
|
||
uv_y.saturating_add(face_row.saturating_mul(face_side)),
|
||
face_side,
|
||
face_side,
|
||
);
|
||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||
cleaned
|
||
.write_to(&mut cursor, image::ImageFormat::Png)
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||
"message": format!("跳一跳地板 UV 面贴图切割失败:{error}"),
|
||
}))
|
||
})?;
|
||
let face_label = jump_hop_tile_face_key_label(&face);
|
||
|
||
Ok(JumpHopTileFaceSlice {
|
||
face,
|
||
source_atlas_cell: format!(
|
||
"row-{}-col-{}/{}",
|
||
atlas_row + 1,
|
||
atlas_col + 1,
|
||
face_label
|
||
),
|
||
bytes: cursor.into_inner(),
|
||
})
|
||
}
|
||
|
||
pub(crate) fn crop_jump_hop_tile_texture_cell(
|
||
source: &image::RgbaImage,
|
||
x0: u32,
|
||
y0: u32,
|
||
width: u32,
|
||
height: u32,
|
||
) -> image::DynamicImage {
|
||
let min_side = width.min(height).max(1);
|
||
// 洋红去背已在切片前完成,内缩只需微调去掉可能的极边缘残留,不再承担主要去洋红职责。
|
||
let safe_inset = (min_side / 64).clamp(1, 4);
|
||
let inset_x = safe_inset.min(width.saturating_sub(1) / 2);
|
||
let inset_y = safe_inset.min(height.saturating_sub(1) / 2);
|
||
let crop_width = width.saturating_sub(inset_x.saturating_mul(2)).max(1);
|
||
let crop_height = height.saturating_sub(inset_y.saturating_mul(2)).max(1);
|
||
let cropped = image::imageops::crop_imm(
|
||
source,
|
||
x0.saturating_add(inset_x),
|
||
y0.saturating_add(inset_y),
|
||
crop_width,
|
||
crop_height,
|
||
)
|
||
.to_image();
|
||
let resized = image::imageops::resize(
|
||
&cropped,
|
||
JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE,
|
||
JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE,
|
||
image::imageops::FilterType::Lanczos3,
|
||
);
|
||
image::DynamicImage::ImageRgba8(resized)
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
fn average_jump_hop_tile_texture_color(image: &image::RgbaImage) -> image::Rgba<u8> {
|
||
let mut total_r = 0u64;
|
||
let mut total_g = 0u64;
|
||
let mut total_b = 0u64;
|
||
let mut count = 0u64;
|
||
|
||
for pixel in image.pixels() {
|
||
if is_jump_hop_tile_texture_key_pixel(*pixel) {
|
||
continue;
|
||
}
|
||
total_r += pixel.0[0] as u64;
|
||
total_g += pixel.0[1] as u64;
|
||
total_b += pixel.0[2] as u64;
|
||
count += 1;
|
||
}
|
||
|
||
if count == 0 {
|
||
return image::Rgba([148, 163, 184, 255]);
|
||
}
|
||
|
||
image::Rgba([
|
||
(total_r / count) as u8,
|
||
(total_g / count) as u8,
|
||
(total_b / count) as u8,
|
||
255,
|
||
])
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
fn is_jump_hop_tile_texture_key_pixel(pixel: image::Rgba<u8>) -> bool {
|
||
let [red, green, blue, _] = pixel.0;
|
||
let red_delta = red.abs_diff(255) as u32;
|
||
let green_delta = green as u32;
|
||
let blue_delta = blue.abs_diff(255) as u32;
|
||
|
||
red_delta.saturating_mul(red_delta)
|
||
+ green_delta.saturating_mul(green_delta)
|
||
+ blue_delta.saturating_mul(blue_delta)
|
||
<= 24u32.saturating_mul(24)
|
||
}
|
||
|
||
pub(crate) fn jump_hop_tile_type_by_index(index: usize) -> JumpHopTileType {
|
||
match index {
|
||
0 => JumpHopTileType::Start,
|
||
value if value % 11 == 0 => JumpHopTileType::Bonus,
|
||
value if value % 7 == 0 => JumpHopTileType::Accent,
|
||
value if value % 3 == 0 => JumpHopTileType::Target,
|
||
_ => JumpHopTileType::Normal,
|
||
}
|
||
}
|
||
|
||
fn jump_hop_tile_asset_slot_name(tile_index: usize) -> String {
|
||
format!("tile-{:02}", tile_index + 1)
|
||
}
|
||
|
||
pub(crate) fn jump_hop_tile_face_key_label(face: &JumpHopTileFaceKey) -> &'static str {
|
||
match face {
|
||
JumpHopTileFaceKey::Top => "top",
|
||
JumpHopTileFaceKey::Front => "front",
|
||
JumpHopTileFaceKey::Right => "right",
|
||
JumpHopTileFaceKey::Back => "back",
|
||
JumpHopTileFaceKey::Left => "left",
|
||
JumpHopTileFaceKey::Bottom => "bottom",
|
||
}
|
||
}
|
||
|
||
fn jump_hop_tile_face_asset_slot_name(tile_index: usize, face: &JumpHopTileFaceKey) -> String {
|
||
format!(
|
||
"{}-{}",
|
||
jump_hop_tile_asset_slot_name(tile_index),
|
||
jump_hop_tile_face_key_label(face)
|
||
)
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn persist_jump_hop_tile_asset(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
profile_id: &str,
|
||
tile_index: usize,
|
||
tile_slice: JumpHopTileAtlasSlice,
|
||
request_context: &RequestContext,
|
||
) -> Result<JumpHopTileAsset, Response> {
|
||
let slot = jump_hop_tile_asset_slot_name(tile_index);
|
||
let top = persist_jump_hop_tile_face_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id,
|
||
tile_index,
|
||
tile_slice.faces.top,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
let front = persist_jump_hop_tile_face_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id,
|
||
tile_index,
|
||
tile_slice.faces.front,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
let right = persist_jump_hop_tile_face_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id,
|
||
tile_index,
|
||
tile_slice.faces.right,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
let back = persist_jump_hop_tile_face_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id,
|
||
tile_index,
|
||
tile_slice.faces.back,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
let left = persist_jump_hop_tile_face_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id,
|
||
tile_index,
|
||
tile_slice.faces.left,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
let bottom = persist_jump_hop_tile_face_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id,
|
||
tile_index,
|
||
tile_slice.faces.bottom,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
let primary = top.clone();
|
||
|
||
Ok(JumpHopTileAsset {
|
||
tile_type: tile_slice.tile_type,
|
||
tile_id: Some(slot),
|
||
image_src: primary.image_src.clone(),
|
||
image_object_key: primary.image_object_key.clone(),
|
||
asset_object_id: primary.asset_object_id.clone(),
|
||
source_atlas_cell: tile_slice.source_atlas_cell,
|
||
atlas_row: Some((tile_index as u32 / JUMP_HOP_TILE_ATLAS_COLS) + 1),
|
||
atlas_col: Some((tile_index as u32 % JUMP_HOP_TILE_ATLAS_COLS) + 1),
|
||
visual_width: JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE,
|
||
visual_height: JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE,
|
||
top_surface_radius: 42.0,
|
||
landing_radius: 34.0,
|
||
face_assets: Some(JumpHopTileFaceAssets {
|
||
top,
|
||
front,
|
||
right,
|
||
back,
|
||
left,
|
||
bottom,
|
||
}),
|
||
})
|
||
}
|
||
|
||
async fn persist_jump_hop_tile_face_asset(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
profile_id: &str,
|
||
tile_index: usize,
|
||
face_slice: JumpHopTileFaceSlice,
|
||
request_context: &RequestContext,
|
||
) -> Result<JumpHopTileFaceAsset, Response> {
|
||
let slot = jump_hop_tile_face_asset_slot_name(tile_index, &face_slice.face);
|
||
let image = crate::openai_image_generation::DownloadedOpenAiImage {
|
||
bytes: face_slice.bytes,
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
};
|
||
let persisted = persist_jump_hop_generated_image_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id,
|
||
slot.as_str(),
|
||
&format!(
|
||
"跳一跳地板 UV 面贴图 {}:{}",
|
||
tile_index + 1,
|
||
face_slice.source_atlas_cell
|
||
),
|
||
image,
|
||
LegacyAssetPrefix::JumpHopAssets,
|
||
JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE,
|
||
JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
|
||
Ok(JumpHopTileFaceAsset {
|
||
face: face_slice.face,
|
||
asset_id: persisted.asset_id,
|
||
image_src: persisted.image_src,
|
||
image_object_key: persisted.image_object_key,
|
||
asset_object_id: persisted.asset_object_id,
|
||
generation_provider: persisted.generation_provider,
|
||
prompt: persisted.prompt,
|
||
width: persisted.width,
|
||
height: persisted.height,
|
||
source_atlas_cell: face_slice.source_atlas_cell,
|
||
})
|
||
}
|
||
|
||
fn build_jump_hop_default_character_asset(
|
||
profile_id: &str,
|
||
theme_text: &str,
|
||
) -> JumpHopCharacterAsset {
|
||
JumpHopCharacterAsset {
|
||
asset_id: format!("{profile_id}-builtin-character"),
|
||
image_src: "builtin://jump-hop/default-character".to_string(),
|
||
image_object_key: String::new(),
|
||
asset_object_id: format!("{profile_id}-builtin-character"),
|
||
generation_provider: "builtin-three".to_string(),
|
||
prompt: format!("内置默认 3D 角色:{}", theme_text.trim()),
|
||
width: 0,
|
||
height: 0,
|
||
}
|
||
}
|
||
|
||
async fn persist_jump_hop_generated_image_asset(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
profile_id: &str,
|
||
slot: &str,
|
||
prompt: &str,
|
||
image: crate::openai_image_generation::DownloadedOpenAiImage,
|
||
prefix: LegacyAssetPrefix,
|
||
width: u32,
|
||
height: u32,
|
||
request_context: &RequestContext,
|
||
) -> Result<JumpHopCharacterAsset, Response> {
|
||
let image_format = normalize_generated_image_asset_mime(image.mime_type.as_str());
|
||
let prepared =
|
||
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||
prefix,
|
||
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!("jump-hop-{slot}")),
|
||
owner_user_id: Some(owner_user_id.to_string()),
|
||
entity_kind: Some("jump_hop_work".to_string()),
|
||
entity_id: Some(profile_id.to_string()),
|
||
slot: Some(slot.to_string()),
|
||
provider: Some("vector-engine".to_string()),
|
||
task_id: None,
|
||
},
|
||
extra_metadata: BTreeMap::new(),
|
||
})
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_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(|| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_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| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_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| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_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!("jump-hop-{slot}"),
|
||
None,
|
||
Some(owner_user_id.to_string()),
|
||
Some(profile_id.to_string()),
|
||
Some(profile_id.to_string()),
|
||
now_micros,
|
||
)
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_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| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_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(),
|
||
"jump_hop_work".to_string(),
|
||
profile_id.to_string(),
|
||
slot.to_string(),
|
||
format!("jump-hop-{slot}"),
|
||
Some(owner_user_id.to_string()),
|
||
Some(profile_id.to_string()),
|
||
now_micros,
|
||
)
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_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| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "spacetimedb",
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
Ok(JumpHopCharacterAsset {
|
||
asset_id: format!("{profile_id}-{slot}-{now_micros}"),
|
||
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_jump_hop_work_play_tracking_draft(
|
||
principal: &RuntimePrincipal,
|
||
work_id: impl Into<String>,
|
||
source_route: &'static str,
|
||
) -> WorkPlayTrackingDraft {
|
||
WorkPlayTrackingDraft::runtime_principal("jump-hop", work_id, principal, source_route)
|
||
}
|
||
|
||
fn resolve_jump_hop_leaderboard_entry_display_name(
|
||
state: &AppState,
|
||
mut entry: JumpHopLeaderboardEntry,
|
||
) -> JumpHopLeaderboardEntry {
|
||
entry.display_name = resolve_jump_hop_leaderboard_display_name(state, &entry.player_id);
|
||
entry
|
||
}
|
||
|
||
fn resolve_jump_hop_leaderboard_display_name(state: &AppState, player_id: &str) -> String {
|
||
resolve_jump_hop_leaderboard_display_name_with_lookup(player_id, |user_id| {
|
||
state
|
||
.auth_user_service()
|
||
.get_user_by_id(user_id)
|
||
.ok()
|
||
.flatten()
|
||
.and_then(|user| normalize_non_empty_text(user.display_name.as_str()))
|
||
})
|
||
}
|
||
|
||
fn resolve_jump_hop_leaderboard_display_name_with_lookup(
|
||
player_id: &str,
|
||
lookup_display_name: impl FnOnce(&str) -> Option<String>,
|
||
) -> String {
|
||
let player_id = player_id.trim();
|
||
if player_id.is_empty() {
|
||
return "玩家".to_string();
|
||
}
|
||
if player_id.starts_with("guest-runtime-") {
|
||
return "游客玩家".to_string();
|
||
}
|
||
|
||
lookup_display_name(player_id)
|
||
.and_then(|display_name| normalize_non_empty_text(display_name.as_str()))
|
||
.unwrap_or_else(|| "失效玩家".to_string())
|
||
}
|
||
|
||
fn normalize_non_empty_text(value: &str) -> Option<String> {
|
||
let value = value.trim();
|
||
if value.is_empty() {
|
||
None
|
||
} else {
|
||
Some(value.to_string())
|
||
}
|
||
}
|
||
|
||
fn is_jump_hop_draft_runtime_mode(runtime_mode: &str) -> bool {
|
||
runtime_mode.trim().eq_ignore_ascii_case("draft")
|
||
}
|
||
|
||
fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse {
|
||
let theme_text = normalize_theme_text(&payload.theme_text, &payload.work_title);
|
||
JumpHopDraftResponse {
|
||
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
|
||
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
|
||
profile_id: None,
|
||
theme_text: theme_text.clone(),
|
||
work_title: clean_or_default(&payload.work_title, &theme_text),
|
||
work_description: clean_or_default(
|
||
&payload.work_description,
|
||
&format!("{theme_text}主题的俯视角跳跃作品"),
|
||
),
|
||
theme_tags: normalize_tags(payload.theme_tags.clone()),
|
||
difficulty: payload.difficulty.clone(),
|
||
style_preset: payload.style_preset.clone(),
|
||
default_character: Some(default_jump_hop_character()),
|
||
character_prompt: clean_or_default(&payload.character_prompt, "内置默认 3D 角色"),
|
||
tile_prompt: clean_or_default(
|
||
&payload.tile_prompt,
|
||
&format!("{theme_text}主题的3D立方体主题身份方块包装图集"),
|
||
),
|
||
end_mood_prompt: payload
|
||
.end_mood_prompt
|
||
.as_ref()
|
||
.map(|value| value.trim().to_string())
|
||
.filter(|value| !value.is_empty()),
|
||
character_asset: None,
|
||
tile_atlas_asset: None,
|
||
tile_assets: Vec::new(),
|
||
path: None,
|
||
cover_composite: None,
|
||
back_button_asset: None,
|
||
generation_status: JumpHopGenerationStatus::Draft,
|
||
}
|
||
}
|
||
|
||
fn validate_workspace_request(
|
||
request_context: &RequestContext,
|
||
payload: &JumpHopWorkspaceCreateRequest,
|
||
) -> Result<(), Response> {
|
||
ensure_non_empty(request_context, &payload.theme_text, "themeText")?;
|
||
if payload.template_id.trim() != JUMP_HOP_TEMPLATE_ID {
|
||
return Err(jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": JUMP_HOP_PROVIDER,
|
||
"message": "templateId 必须为 jump-hop",
|
||
})),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn normalize_theme_text(theme_text: &str, fallback: &str) -> String {
|
||
clean_or_default(theme_text, fallback)
|
||
.chars()
|
||
.take(60)
|
||
.collect::<String>()
|
||
}
|
||
|
||
fn clean_or_default(value: &str, fallback: &str) -> String {
|
||
let value = value.trim();
|
||
if value.is_empty() {
|
||
fallback.trim().to_string()
|
||
} else {
|
||
value.to_string()
|
||
}
|
||
}
|
||
|
||
fn default_jump_hop_character() -> shared_contracts::jump_hop::JumpHopDefaultCharacter {
|
||
shared_contracts::jump_hop::JumpHopDefaultCharacter {
|
||
character_id: "jump-hop-default-runner".to_string(),
|
||
display_name: "默认角色".to_string(),
|
||
model_kind: "builtin-three".to_string(),
|
||
body_color: "#f59e0b".to_string(),
|
||
accent_color: "#2563eb".to_string(),
|
||
}
|
||
}
|
||
|
||
fn ensure_non_empty(
|
||
request_context: &RequestContext,
|
||
value: &str,
|
||
field: &str,
|
||
) -> Result<(), Response> {
|
||
if value.trim().is_empty() {
|
||
return Err(jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": JUMP_HOP_PROVIDER,
|
||
"field": field,
|
||
"message": format!("{field} 不能为空"),
|
||
})),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn normalize_tags(tags: Vec<String>) -> Vec<String> {
|
||
let mut normalized = Vec::new();
|
||
for tag in tags {
|
||
let tag = tag.trim();
|
||
if tag.is_empty() || normalized.iter().any(|item| item == tag) {
|
||
continue;
|
||
}
|
||
normalized.push(tag.to_string());
|
||
if normalized.len() >= 6 {
|
||
break;
|
||
}
|
||
}
|
||
normalized
|
||
}
|
||
|
||
fn jump_hop_json<T>(
|
||
payload: Result<Json<T>, JsonRejection>,
|
||
request_context: &RequestContext,
|
||
provider: &str,
|
||
) -> Result<Json<T>, Response> {
|
||
payload.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
provider,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": provider,
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})
|
||
}
|
||
|
||
fn map_jump_hop_client_error(error: SpacetimeClientError) -> AppError {
|
||
let status = match &error {
|
||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||
SpacetimeClientError::Procedure(message)
|
||
if message.contains("不存在")
|
||
|| message.contains("not found")
|
||
|| message.contains("does not exist") =>
|
||
{
|
||
StatusCode::NOT_FOUND
|
||
}
|
||
SpacetimeClientError::Procedure(message)
|
||
if message.contains("发布需要")
|
||
|| message.contains("不能为空")
|
||
|| message.contains("必须") =>
|
||
{
|
||
StatusCode::BAD_REQUEST
|
||
}
|
||
_ => StatusCode::BAD_GATEWAY,
|
||
};
|
||
|
||
AppError::from_status(status).with_details(json!({
|
||
"provider": "spacetimedb",
|
||
"message": error.to_string(),
|
||
}))
|
||
}
|
||
|
||
fn jump_hop_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("jump-hop")),
|
||
);
|
||
response
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn jump_hop_draft_runtime_mode_detection_matches_client_normalization() {
|
||
assert!(is_jump_hop_draft_runtime_mode("draft"));
|
||
assert!(is_jump_hop_draft_runtime_mode(" DRAFT "));
|
||
assert!(!is_jump_hop_draft_runtime_mode("published"));
|
||
assert!(!is_jump_hop_draft_runtime_mode(""));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_leaderboard_display_name_never_falls_back_to_player_id() {
|
||
assert_eq!(
|
||
resolve_jump_hop_leaderboard_display_name_with_lookup(" user-secret-1 ", |user_id| {
|
||
assert_eq!(user_id, "user-secret-1");
|
||
Some(" 陶泥儿玩家 ".to_string())
|
||
}),
|
||
"陶泥儿玩家"
|
||
);
|
||
assert_eq!(
|
||
resolve_jump_hop_leaderboard_display_name_with_lookup("guest-runtime-1", |_| {
|
||
panic!("guest player should not query account display name")
|
||
}),
|
||
"游客玩家"
|
||
);
|
||
assert_eq!(
|
||
resolve_jump_hop_leaderboard_display_name_with_lookup("user-missing", |_| None),
|
||
"失效玩家"
|
||
);
|
||
assert_eq!(
|
||
resolve_jump_hop_leaderboard_display_name_with_lookup("", |_| {
|
||
panic!("empty player id should not query account display name")
|
||
}),
|
||
"玩家"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_tile_atlas_prompt_uses_dedicated_uv_unwrap_floor_layout() {
|
||
let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台");
|
||
|
||
// 基础信息
|
||
assert!(prompt.contains("生成一张1024x1536竖版图片"));
|
||
// 布局
|
||
assert!(prompt.contains("18 个大单元格"));
|
||
assert!(prompt.contains("3 列 6 行"));
|
||
assert!(prompt.contains("大单元格之间彼此绝不接触"));
|
||
// 内部 UV 结构
|
||
assert!(prompt.contains("4列x3行"));
|
||
assert!(prompt.contains("第1行第2列:top"));
|
||
assert!(prompt.contains("第2行第1列:left"));
|
||
assert!(prompt.contains("第2行第2列:front"));
|
||
assert!(prompt.contains("第2行第3列:right"));
|
||
assert!(prompt.contains("第2行第4列:back"));
|
||
assert!(prompt.contains("第3行第2列:bottom"));
|
||
assert!(prompt.contains("其余 6 个格子"));
|
||
// 贴图内容
|
||
assert!(prompt.contains("1x1x1 立方体物体的六面展开"));
|
||
assert!(prompt.contains("主题为\"森林冒险\""));
|
||
assert!(prompt.contains("6 个面必须属于同一个物体"));
|
||
assert!(prompt.contains("组合成一个完整的立方体造型"));
|
||
assert!(prompt.contains("不要 6 个面彼此无关"));
|
||
assert!(prompt.contains("不要 6 个面重复同一张纹理"));
|
||
// 水果主题
|
||
assert!(prompt.contains("水果主题需生成 18 种可辨认的不同方块水果"));
|
||
assert!(prompt.contains("果柄叶片跨越 top/front"));
|
||
assert!(prompt.contains("剥皮条带跨越 front/right"));
|
||
assert!(prompt.contains("放射切面跨越 top/front"));
|
||
// 面贴图要求
|
||
assert!(prompt.contains("满版不透明贴图"));
|
||
assert!(prompt.contains("缩小到 64x64 时仍可辨认"));
|
||
assert!(prompt.contains("不做透视渲染"));
|
||
assert!(prompt.contains("不画投影、高光、倒角、侧壁厚度"));
|
||
assert!(prompt.contains("大色块、高对比、粗线条和简单图形"));
|
||
// 背景填充(最后一段)
|
||
assert!(prompt.contains("【背景填充】"));
|
||
assert!(prompt.contains("大单元格之间的间距"));
|
||
assert!(prompt.contains("全部使用单一纯洋红"));
|
||
assert!(prompt.contains(JUMP_HOP_TILE_ATLAS_KEY_HEX));
|
||
assert!(prompt.contains("不画任何文字、编号、标记或装饰"));
|
||
// 禁止项
|
||
assert!(prompt.contains("透视渲染"));
|
||
assert!(prompt.contains("底座"));
|
||
assert!(prompt.contains("托盘"));
|
||
assert!(prompt.contains("透明背景"));
|
||
assert!(prompt.contains("留白"));
|
||
// 负向检查:旧 prompt 残余
|
||
assert!(!prompt.contains("可落脚平台素材"));
|
||
assert!(!prompt.contains("平台裸素材"));
|
||
assert!(!prompt.contains("正面30度视角"));
|
||
assert!(!prompt.contains("五行五列"));
|
||
assert!(!prompt.contains("25个"));
|
||
assert!(!prompt.contains("允许极细"));
|
||
assert!(!prompt.contains("one square 5x5"));
|
||
assert!(!prompt.contains("基础轮廓优先做不规则主题剪影"));
|
||
assert!(!prompt.contains("25格造型要混排"));
|
||
assert!(!prompt.contains("no simple circles"));
|
||
assert!(!prompt.contains("no simple squares"));
|
||
assert!(!prompt.contains("纯绿色绿幕"));
|
||
assert!(!prompt.contains("#00FF00"));
|
||
assert!(!prompt.contains("isolated top-down"));
|
||
assert!(!prompt.contains("按5行*5列"));
|
||
assert!(!prompt.contains("2D地板图标"));
|
||
assert!(!prompt.contains("清爽自然的游戏图标"));
|
||
assert!(!prompt.contains("边缘厚度暗示"));
|
||
assert!(!prompt.contains("统一投影"));
|
||
assert!(!prompt.contains("每个物品生成"));
|
||
assert!(!prompt.contains("不同视图"));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_background_prompt_keeps_center_corridor_and_side_atmosphere() {
|
||
let prompt = build_jump_hop_background_prompt("水果");
|
||
|
||
assert!(prompt.contains("9:16竖版跳一跳游戏背景底图"));
|
||
assert!(prompt.contains("主题关键词严格只使用“水果”"));
|
||
assert!(prompt.contains("整体风格需要和同一主题的跳一跳游戏元素一致"));
|
||
assert!(prompt.contains("左右两侧氛围为主"));
|
||
assert!(prompt.contains("中央纵轴1/2区域必须是清爽的跳跃路径视觉走廊"));
|
||
assert!(prompt.contains("该区域只能使用少量低对比度纹理"));
|
||
assert!(prompt.contains("中央纵轴1/2区域要有明显纵深感"));
|
||
assert!(prompt.contains("两侧可以更有立体感、空间层次和主题氛围"));
|
||
assert!(prompt.contains("不画任何跳板、地块、落脚物、角色、UI按钮"));
|
||
assert!(prompt.contains("左上角也不要画返回按钮或任何固定图标"));
|
||
assert!(prompt.contains("运行态会叠加独立可点击按钮资产"));
|
||
assert!(prompt.contains("视角保持正面约30度"));
|
||
assert!(prompt.contains("中央区域需要给运行态地块和陶泥儿角色留出干净可读空间"));
|
||
assert!(prompt.contains("English guardrail"));
|
||
assert!(prompt.contains("left and right sides carry the atmosphere"));
|
||
assert!(prompt.contains("central vertical half-width corridor stays simple"));
|
||
assert!(prompt.contains("no top-left back button"));
|
||
assert!(prompt.contains("no platforms"));
|
||
assert!(prompt.contains("no landing objects"));
|
||
assert!(prompt.contains("no other UI panels"));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_back_button_prompt_builds_standalone_green_screen_asset() {
|
||
let prompt = build_jump_hop_back_button_prompt("水果");
|
||
|
||
assert!(prompt.contains("独立透明素材"));
|
||
assert!(prompt.contains("主题关键词严格只使用“水果”"));
|
||
assert!(prompt.contains("单个标准圆形图标"));
|
||
assert!(prompt.contains("内部只有一个居中的向左箭头"));
|
||
assert!(prompt.contains("不要写“返回”文字"));
|
||
assert!(prompt.contains("背景必须是单一纯绿色 #00FF00"));
|
||
assert!(prompt.contains("后续由服务端扣除绿色背景"));
|
||
assert!(prompt.contains("one standalone circular mobile game back button asset only"));
|
||
assert!(prompt.contains("solid #00FF00 green-screen background"));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_background_negative_prompt_blocks_runtime_layer_conflicts() {
|
||
let negative_prompt = build_jump_hop_background_negative_prompt();
|
||
|
||
assert!(negative_prompt.contains("跳板"));
|
||
assert!(negative_prompt.contains("地块"));
|
||
assert!(negative_prompt.contains("落脚物"));
|
||
assert!(negative_prompt.contains("角色"));
|
||
assert!(negative_prompt.contains("UI按钮"));
|
||
assert!(negative_prompt.contains("返回按钮"));
|
||
assert!(negative_prompt.contains("左上角图标"));
|
||
assert!(negative_prompt.contains("右上角按钮"));
|
||
assert!(negative_prompt.contains("底部按钮"));
|
||
assert!(negative_prompt.contains("UI面板"));
|
||
assert!(negative_prompt.contains("中央堆满元素"));
|
||
assert!(negative_prompt.contains("中央遮挡"));
|
||
assert!(negative_prompt.contains("纯俯视地图"));
|
||
assert!(negative_prompt.contains("平铺俯拍"));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_legacy_cover_placeholder_is_not_treated_as_background() {
|
||
assert!(is_jump_hop_legacy_cover_composite_placeholder(
|
||
"/generated-jump-hop-assets/jump-hop-profile-test/cover-composite.png",
|
||
));
|
||
assert!(is_jump_hop_legacy_cover_composite_placeholder(
|
||
"/generated-jump-hop-assets/jump-hop-profile-test/cover-composite-123.png",
|
||
));
|
||
assert!(!is_jump_hop_legacy_cover_composite_placeholder(
|
||
"/generated-jump-hop-assets/jump-hop-profile-test/background/image.png",
|
||
));
|
||
assert!(!is_jump_hop_legacy_cover_composite_placeholder(
|
||
"/uploads/custom-cover.png",
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_generation_prompt_only_rewrites_pokemon_terms() {
|
||
let background_prompt = build_jump_hop_background_prompt("宝可梦");
|
||
assert!(background_prompt.contains("主题关键词严格只使用“原创幻想萌宠冒险道具”"));
|
||
assert!(!background_prompt.contains("宝可梦"));
|
||
|
||
let back_button_prompt = build_jump_hop_back_button_prompt("Pokemon");
|
||
assert!(back_button_prompt.contains("主题关键词严格只使用“原创幻想萌宠冒险道具”"));
|
||
assert!(!back_button_prompt.contains("Pokemon"));
|
||
|
||
let tile_prompt = build_jump_hop_tile_atlas_prompt(
|
||
"宝可梦",
|
||
"宝可梦主题的正面30度视角主题物体图集,包含皮卡丘和精灵球装饰",
|
||
);
|
||
assert!(tile_prompt.contains("黄色闪电萌宠符号"));
|
||
assert!(tile_prompt.contains("彩色冒险能量球"));
|
||
assert!(!tile_prompt.contains("宝可梦"));
|
||
assert!(!tile_prompt.contains("皮卡丘"));
|
||
assert!(!tile_prompt.contains("精灵球"));
|
||
|
||
let normal_prompt =
|
||
build_jump_hop_tile_atlas_prompt("水果", "水果主题的正面30度视角主题物体图集");
|
||
assert!(normal_prompt.contains("3D立方体主题身份方块包装图集"));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_tile_prompt_sanitizes_legacy_platform_words() {
|
||
let prompt = build_jump_hop_tile_atlas_prompt(
|
||
"科幻芯片",
|
||
"科幻芯片主题的俯视角清爽游戏化立体感平台素材",
|
||
);
|
||
|
||
assert!(prompt.contains("具体内容为科幻芯片主题的正交平面清爽游戏化立方体主题身份方块包装贴图"));
|
||
assert!(!prompt.contains("俯视角清爽游戏化立体感平台素材"));
|
||
assert!(!prompt.contains("俯视角"));
|
||
|
||
let top_down_prompt =
|
||
build_jump_hop_tile_atlas_prompt("水果", "水果主题鸟瞰视角平铺俯拍圆形平台");
|
||
|
||
assert!(top_down_prompt.contains("正交平面"));
|
||
assert!(top_down_prompt.contains("圆形立方体地板"));
|
||
assert!(!top_down_prompt.contains("鸟瞰视角"));
|
||
assert!(!top_down_prompt.contains("平铺俯拍"));
|
||
|
||
let legacy_prompt = build_jump_hop_tile_atlas_prompt(
|
||
"雪花",
|
||
"雪花主题可落脚平台素材,每格一个完整平台,不要底座",
|
||
);
|
||
|
||
assert!(legacy_prompt.contains("雪花主题立方体主题身份方块包装贴图"));
|
||
assert!(legacy_prompt.contains("每格一张完整身份方块包装贴图"));
|
||
assert!(legacy_prompt.contains("不要承托物"));
|
||
assert!(!legacy_prompt.contains("画面内容是雪花主题可落脚平台素材"));
|
||
assert!(!legacy_prompt.contains("画面内容是雪花主题可落脚"));
|
||
assert!(!legacy_prompt.contains("画面内容是雪花主题平台"));
|
||
assert!(!legacy_prompt.contains("画面内容是雪花主题地块"));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_tile_atlas_negative_prompt_blocks_oily_and_square_shadow_artifacts() {
|
||
let negative_prompt = build_jump_hop_tile_atlas_negative_prompt();
|
||
|
||
assert!(negative_prompt.contains("油亮高光"));
|
||
assert!(negative_prompt.contains("厚重CG渲染"));
|
||
assert!(negative_prompt.contains("游戏界面"));
|
||
assert!(negative_prompt.contains("图标集页面"));
|
||
assert!(negative_prompt.contains("完整水果"));
|
||
assert!(negative_prompt.contains("孤立水果"));
|
||
assert!(negative_prompt.contains("果切"));
|
||
assert!(negative_prompt.contains("橙片"));
|
||
assert!(negative_prompt.contains("苹果小贴纸"));
|
||
assert!(negative_prompt.contains("香蕉小贴纸"));
|
||
assert!(negative_prompt.contains("小贴纸图标"));
|
||
assert!(negative_prompt.contains("纯果皮材质"));
|
||
assert!(negative_prompt.contains("无法分辨具体物体"));
|
||
assert!(negative_prompt.contains("小物体居中"));
|
||
assert!(negative_prompt.contains("透明背景"));
|
||
assert!(negative_prompt.contains("留白"));
|
||
assert!(negative_prompt.contains("3D平台"));
|
||
assert!(negative_prompt.contains("跳板成品"));
|
||
assert!(negative_prompt.contains("地块成品"));
|
||
assert!(negative_prompt.contains("物体剪影"));
|
||
assert!(negative_prompt.contains("正面30度物体图"));
|
||
assert!(negative_prompt.contains("透视地块"));
|
||
assert!(negative_prompt.contains("已经画好的侧壁"));
|
||
assert!(negative_prompt.contains("已经画好的厚度"));
|
||
assert!(negative_prompt.contains("烘焙高光"));
|
||
assert!(negative_prompt.contains("烘焙阴影"));
|
||
assert!(negative_prompt.contains("方形阴影"));
|
||
assert!(negative_prompt.contains("洋红阴影"));
|
||
assert!(negative_prompt.contains("紫色底边"));
|
||
assert!(negative_prompt.contains("粉色脏边"));
|
||
assert!(negative_prompt.contains("洋红色描边"));
|
||
assert!(negative_prompt.contains("彩色光晕"));
|
||
assert!(negative_prompt.contains("发光底边"));
|
||
assert!(negative_prompt.contains("方形底板"));
|
||
assert!(negative_prompt.contains("额外底座"));
|
||
assert!(negative_prompt.contains("承托底座"));
|
||
assert!(negative_prompt.contains("台座"));
|
||
assert!(negative_prompt.contains("物体摆在平台上"));
|
||
assert!(negative_prompt.contains("物体下方垫地板"));
|
||
assert!(negative_prompt.contains("可见网格线"));
|
||
assert!(negative_prompt.contains("裁切标记"));
|
||
assert!(!negative_prompt.contains("规则圆盘"));
|
||
assert!(!negative_prompt.contains("正圆平台"));
|
||
assert!(!negative_prompt.contains("规则方块"));
|
||
assert!(!negative_prompt.contains("圆角矩形"));
|
||
assert!(!negative_prompt.contains("杯垫"));
|
||
assert!(!negative_prompt.contains("重复圆形"));
|
||
assert!(!negative_prompt.contains("建筑"));
|
||
assert!(!negative_prompt.contains("楼房"));
|
||
}
|
||
|
||
fn paint_test_uv_face(
|
||
atlas: &mut image::RgbaImage,
|
||
atlas_col: u32,
|
||
atlas_row: u32,
|
||
face_col: u32,
|
||
face_row: u32,
|
||
color: image::Rgba<u8>,
|
||
) {
|
||
let cell_width = atlas.width() / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let cell_height = atlas.height() / JUMP_HOP_TILE_ATLAS_ROWS;
|
||
let face_side = (cell_width / JUMP_HOP_TILE_UV_FACE_COLS)
|
||
.min(cell_height / JUMP_HOP_TILE_UV_FACE_ROWS)
|
||
.max(1);
|
||
let tile_x = atlas_col.saturating_mul(cell_width);
|
||
let tile_y = atlas_row.saturating_mul(cell_height);
|
||
let uv_x = tile_x.saturating_add(
|
||
cell_width.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_COLS) / 2,
|
||
);
|
||
let uv_y = tile_y.saturating_add(
|
||
cell_height.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_ROWS) / 2,
|
||
);
|
||
for y in uv_y + face_row * face_side..uv_y + (face_row + 1) * face_side {
|
||
for x in uv_x + face_col * face_side..uv_x + (face_col + 1) * face_side {
|
||
atlas.put_pixel(x, y, color);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn load_test_png(bytes: Vec<u8>) -> crate::openai_image_generation::DownloadedOpenAiImage {
|
||
crate::openai_image_generation::DownloadedOpenAiImage {
|
||
bytes,
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
}
|
||
}
|
||
|
||
fn encode_test_atlas(atlas: image::RgbaImage) -> Vec<u8> {
|
||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||
image::DynamicImage::ImageRgba8(atlas)
|
||
.write_to(&mut encoded, image::ImageFormat::Png)
|
||
.expect("atlas should encode");
|
||
encoded.into_inner()
|
||
}
|
||
|
||
fn assert_png_contains_color(bytes: &[u8], color: [u8; 4], message: &str) {
|
||
let decoded = image::load_from_memory(bytes)
|
||
.expect("tile face slice should decode")
|
||
.to_rgba8();
|
||
assert_eq!(
|
||
decoded.dimensions(),
|
||
(
|
||
JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE,
|
||
JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE
|
||
),
|
||
"{message}"
|
||
);
|
||
assert!(
|
||
decoded.pixels().any(|pixel| pixel.0 == color),
|
||
"{message}"
|
||
);
|
||
assert!(
|
||
decoded.pixels().all(|pixel| pixel.0[3] == 255),
|
||
"{message}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_tile_atlas_slices_eighteen_cube_uv_unwrap_tiles() {
|
||
let width = 384;
|
||
let height = 576;
|
||
let mut atlas =
|
||
image::RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 255, 255]));
|
||
for row in 0..JUMP_HOP_TILE_ATLAS_ROWS {
|
||
for col in 0..JUMP_HOP_TILE_ATLAS_COLS {
|
||
let index = row * JUMP_HOP_TILE_ATLAS_COLS + col;
|
||
let base = index as u8;
|
||
paint_test_uv_face(
|
||
&mut atlas,
|
||
col,
|
||
row,
|
||
1,
|
||
0,
|
||
image::Rgba([40 + base * 3, 24 + base * 2, 100, 255]),
|
||
);
|
||
paint_test_uv_face(
|
||
&mut atlas,
|
||
col,
|
||
row,
|
||
1,
|
||
1,
|
||
image::Rgba([50 + base * 3, 34 + base * 2, 110, 255]),
|
||
);
|
||
paint_test_uv_face(
|
||
&mut atlas,
|
||
col,
|
||
row,
|
||
2,
|
||
1,
|
||
image::Rgba([60 + base * 3, 44 + base * 2, 120, 255]),
|
||
);
|
||
paint_test_uv_face(
|
||
&mut atlas,
|
||
col,
|
||
row,
|
||
3,
|
||
1,
|
||
image::Rgba([70 + base * 3, 54 + base * 2, 130, 255]),
|
||
);
|
||
paint_test_uv_face(
|
||
&mut atlas,
|
||
col,
|
||
row,
|
||
0,
|
||
1,
|
||
image::Rgba([80 + base * 3, 64 + base * 2, 140, 255]),
|
||
);
|
||
paint_test_uv_face(
|
||
&mut atlas,
|
||
col,
|
||
row,
|
||
1,
|
||
2,
|
||
image::Rgba([90 + base * 3, 74 + base * 2, 150, 255]),
|
||
);
|
||
}
|
||
}
|
||
let image = load_test_png(encode_test_atlas(atlas));
|
||
|
||
let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice");
|
||
|
||
assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_COUNT);
|
||
for (index, slice) in slices.iter().enumerate() {
|
||
let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let col = index as u32 % JUMP_HOP_TILE_ATLAS_COLS;
|
||
let base = index as u8;
|
||
assert_eq!(slice.tile_type, jump_hop_tile_type_by_index(index));
|
||
assert_eq!(
|
||
slice.source_atlas_cell,
|
||
format!("row-{}-col-{}", row + 1, col + 1)
|
||
);
|
||
assert_eq!(
|
||
slice.faces.top.source_atlas_cell,
|
||
format!("row-{}-col-{}/top", row + 1, col + 1)
|
||
);
|
||
assert_eq!(
|
||
slice.faces.front.source_atlas_cell,
|
||
format!("row-{}-col-{}/front", row + 1, col + 1)
|
||
);
|
||
assert_png_contains_color(
|
||
slice.faces.top.bytes.as_slice(),
|
||
[40 + base * 3, 24 + base * 2, 100, 255],
|
||
"top 面应从每格第1行第2列切出",
|
||
);
|
||
assert_png_contains_color(
|
||
slice.faces.front.bytes.as_slice(),
|
||
[50 + base * 3, 34 + base * 2, 110, 255],
|
||
"front 面应从每格第2行第2列切出",
|
||
);
|
||
assert_png_contains_color(
|
||
slice.faces.right.bytes.as_slice(),
|
||
[60 + base * 3, 44 + base * 2, 120, 255],
|
||
"right 面应从每格第2行第3列切出",
|
||
);
|
||
assert_png_contains_color(
|
||
slice.faces.back.bytes.as_slice(),
|
||
[70 + base * 3, 54 + base * 2, 130, 255],
|
||
"back 面应从每格第2行第4列切出",
|
||
);
|
||
assert_png_contains_color(
|
||
slice.faces.left.bytes.as_slice(),
|
||
[80 + base * 3, 64 + base * 2, 140, 255],
|
||
"left 面应从每格第2行第1列切出",
|
||
);
|
||
assert_png_contains_color(
|
||
slice.faces.bottom.bytes.as_slice(),
|
||
[90 + base * 3, 74 + base * 2, 150, 255],
|
||
"bottom 面应从每格第3行第2列切出",
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_tile_atlas_slicing_preserves_green_and_white_tile_materials() {
|
||
let width = 384;
|
||
let height = 576;
|
||
let mut atlas =
|
||
image::RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 255, 255]));
|
||
for row in 0..JUMP_HOP_TILE_ATLAS_ROWS {
|
||
for col in 0..JUMP_HOP_TILE_ATLAS_COLS {
|
||
let color = if row == 0 && col == 0 {
|
||
image::Rgba([62, 188, 74, 255])
|
||
} else if row == 0 && col == 1 {
|
||
image::Rgba([246, 246, 238, 255])
|
||
} else {
|
||
image::Rgba([120, 96, 72, 255])
|
||
};
|
||
paint_test_uv_face(&mut atlas, col, row, 1, 0, color);
|
||
}
|
||
}
|
||
let image = load_test_png(encode_test_atlas(atlas));
|
||
|
||
let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice");
|
||
let green_tile = image::load_from_memory(slices[0].faces.top.bytes.as_slice())
|
||
.expect("green tile should decode")
|
||
.to_rgba8();
|
||
let white_tile = image::load_from_memory(slices[1].faces.top.bytes.as_slice())
|
||
.expect("white tile should decode")
|
||
.to_rgba8();
|
||
|
||
assert!(
|
||
green_tile
|
||
.pixels()
|
||
.any(|pixel| pixel.0 == [62, 188, 74, 255])
|
||
);
|
||
assert!(
|
||
white_tile
|
||
.pixels()
|
||
.any(|pixel| pixel.0 == [246, 246, 238, 255])
|
||
);
|
||
assert_eq!(green_tile.get_pixel(0, 0).0[3], 255);
|
||
assert_eq!(white_tile.get_pixel(0, 0).0[3], 255);
|
||
assert!(
|
||
green_tile.pixels().all(|pixel| pixel.0[3] == 255),
|
||
"绿色主题材质不能被透明化扣掉"
|
||
);
|
||
assert!(
|
||
white_tile.pixels().all(|pixel| pixel.0[3] == 255),
|
||
"白色主题材质不能被透明化扣掉"
|
||
);
|
||
assert!(
|
||
green_tile
|
||
.pixels()
|
||
.all(|pixel| pixel.0 != [255, 0, 255, 255]),
|
||
"残留洋红 key 色应被转成不透明材质底色,不能留成可见边"
|
||
);
|
||
assert!(
|
||
white_tile
|
||
.pixels()
|
||
.all(|pixel| pixel.0 != [255, 0, 255, 255]),
|
||
"残留洋红 key 色应被转成不透明材质底色,不能留成可见边"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_tile_asset_slots_are_unique_for_eighteen_slices() {
|
||
let slots = (0..JUMP_HOP_TILE_ITEM_COUNT)
|
||
.map(jump_hop_tile_asset_slot_name)
|
||
.collect::<Vec<_>>();
|
||
let unique_slots = slots
|
||
.iter()
|
||
.cloned()
|
||
.collect::<std::collections::BTreeSet<_>>();
|
||
|
||
assert_eq!(
|
||
unique_slots.len(),
|
||
JUMP_HOP_TILE_ITEM_COUNT,
|
||
"18 个地板 UV 大单元必须写入 18 个独立 slot/path,不能按重复的 tile_type 互相覆盖"
|
||
);
|
||
|
||
let face_slots = (0..JUMP_HOP_TILE_ITEM_COUNT)
|
||
.flat_map(|index| {
|
||
[
|
||
JumpHopTileFaceKey::Top,
|
||
JumpHopTileFaceKey::Front,
|
||
JumpHopTileFaceKey::Right,
|
||
JumpHopTileFaceKey::Back,
|
||
JumpHopTileFaceKey::Left,
|
||
JumpHopTileFaceKey::Bottom,
|
||
]
|
||
.into_iter()
|
||
.map(move |face| jump_hop_tile_face_asset_slot_name(index, &face))
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let unique_face_slots = face_slots
|
||
.iter()
|
||
.cloned()
|
||
.collect::<std::collections::BTreeSet<_>>();
|
||
assert_eq!(
|
||
unique_face_slots.len(),
|
||
JUMP_HOP_TILE_ITEM_COUNT * 6,
|
||
"18 个地板 UV 大单元的 108 张面贴图必须写入独立 slot/path"
|
||
);
|
||
}
|
||
|
||
/// 跳一跳图集切片全流程测试:生成合成图集 → 切片 → 关键色清除 → 缩放到256×256
|
||
/// 保留所有中间结果到 target/test-output/jump-hop-tile-atlas/
|
||
#[test]
|
||
fn jump_hop_tile_atlas_full_pipeline_with_intermediate_output() {
|
||
let output_root = std::path::PathBuf::from("target")
|
||
.join("test-output")
|
||
.join("jump-hop-tile-atlas");
|
||
let _ = std::fs::remove_dir_all(&output_root);
|
||
std::fs::create_dir_all(&output_root).expect("应该能创建输出目录");
|
||
|
||
let width = JUMP_HOP_TILE_ATLAS_IMAGE_WIDTH;
|
||
let height = JUMP_HOP_TILE_ATLAS_IMAGE_HEIGHT;
|
||
let cell_width = width / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let cell_height = height / JUMP_HOP_TILE_ATLAS_ROWS;
|
||
let face_side = (cell_width / JUMP_HOP_TILE_UV_FACE_COLS)
|
||
.min(cell_height / JUMP_HOP_TILE_UV_FACE_ROWS)
|
||
.max(1);
|
||
|
||
// 生成合成图集:洋红安全色背景 + 18 个大单元格,每格 6 个不同颜色的 UV 面
|
||
let mut atlas =
|
||
image::RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 255, 255]));
|
||
for row in 0..JUMP_HOP_TILE_ATLAS_ROWS {
|
||
for col in 0..JUMP_HOP_TILE_ATLAS_COLS {
|
||
let index = (row * JUMP_HOP_TILE_ATLAS_COLS + col) as u8;
|
||
paint_test_uv_face(
|
||
&mut atlas,
|
||
col,
|
||
row,
|
||
1,
|
||
0,
|
||
image::Rgba([40 + index * 3, 24 + index * 2, 100, 255]),
|
||
);
|
||
paint_test_uv_face(
|
||
&mut atlas,
|
||
col,
|
||
row,
|
||
1,
|
||
1,
|
||
image::Rgba([50 + index * 3, 34 + index * 2, 110, 255]),
|
||
);
|
||
paint_test_uv_face(
|
||
&mut atlas,
|
||
col,
|
||
row,
|
||
2,
|
||
1,
|
||
image::Rgba([60 + index * 3, 44 + index * 2, 120, 255]),
|
||
);
|
||
paint_test_uv_face(
|
||
&mut atlas,
|
||
col,
|
||
row,
|
||
3,
|
||
1,
|
||
image::Rgba([70 + index * 3, 54 + index * 2, 130, 255]),
|
||
);
|
||
paint_test_uv_face(
|
||
&mut atlas,
|
||
col,
|
||
row,
|
||
0,
|
||
1,
|
||
image::Rgba([80 + index * 3, 64 + index * 2, 140, 255]),
|
||
);
|
||
paint_test_uv_face(
|
||
&mut atlas,
|
||
col,
|
||
row,
|
||
1,
|
||
2,
|
||
image::Rgba([90 + index * 3, 74 + index * 2, 150, 255]),
|
||
);
|
||
}
|
||
}
|
||
|
||
// 在边缘区域故意填充一些洋红色像素,验证后续内缩裁切和关键色清除
|
||
let atlas_bytes = encode_test_atlas(atlas);
|
||
std::fs::write(output_root.join("00-raw-atlas.png"), &atlas_bytes)
|
||
.expect("保存原始图集");
|
||
|
||
let atlas_image = load_test_png(atlas_bytes);
|
||
|
||
// === 阶段1: 图集切片(18 个 tile cell) ===
|
||
let slices =
|
||
slice_jump_hop_tile_atlas(&atlas_image).expect("图集切片应该成功");
|
||
assert_eq!(
|
||
slices.len(),
|
||
JUMP_HOP_TILE_ITEM_COUNT,
|
||
"应该切出 18 个 tile"
|
||
);
|
||
|
||
let mut total_faces = 0usize;
|
||
let mut no_key_color_residue = true;
|
||
let expected_output_size =
|
||
JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE as u32;
|
||
|
||
for (index, slice) in slices.iter().enumerate() {
|
||
let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let col = index as u32 % JUMP_HOP_TILE_ATLAS_COLS;
|
||
let tile_type = format!("{:?}", slice.tile_type);
|
||
|
||
let tile_dir = output_root.join(format!(
|
||
"tile-{:02}-{}-row{}-col{}",
|
||
index + 1,
|
||
tile_type,
|
||
row + 1,
|
||
col + 1
|
||
));
|
||
std::fs::create_dir_all(&tile_dir).expect("应该能创建 tile 输出目录");
|
||
|
||
// 验证 tile type 分配
|
||
assert_eq!(
|
||
slice.tile_type,
|
||
jump_hop_tile_type_by_index(index),
|
||
"tile {index} 的类型分配应该一致"
|
||
);
|
||
assert_eq!(
|
||
slice.source_atlas_cell,
|
||
format!("row-{}-col-{}", row + 1, col + 1),
|
||
"tile {index} 的 source_atlas_cell 应该正确"
|
||
);
|
||
|
||
let faces: [(&str, &JumpHopTileFaceSlice); 6] = [
|
||
("top", &slice.faces.top),
|
||
("front", &slice.faces.front),
|
||
("right", &slice.faces.right),
|
||
("back", &slice.faces.back),
|
||
("left", &slice.faces.left),
|
||
("bottom", &slice.faces.bottom),
|
||
];
|
||
|
||
for (face_name, face) in &faces {
|
||
total_faces += 1;
|
||
|
||
let face_filename = format!(
|
||
"{}-{}.png",
|
||
face_name,
|
||
face.source_atlas_cell.replace('/', "-")
|
||
);
|
||
let face_path = tile_dir.join(&face_filename);
|
||
std::fs::write(&face_path, &face.bytes)
|
||
.expect("保存面贴图");
|
||
|
||
// 解码并验证
|
||
let decoded = image::load_from_memory(&face.bytes)
|
||
.expect("面贴图应该能解码")
|
||
.to_rgba8();
|
||
|
||
// 验证输出尺寸为 256×256
|
||
assert_eq!(
|
||
decoded.dimensions(),
|
||
(expected_output_size, expected_output_size),
|
||
"tile {index} face {face_name} 应该输出 {}×{}",
|
||
expected_output_size, expected_output_size
|
||
);
|
||
|
||
// 验证没有残余洋红关键色
|
||
for pixel in decoded.pixels() {
|
||
if is_jump_hop_tile_texture_key_pixel(*pixel) {
|
||
no_key_color_residue = false;
|
||
}
|
||
}
|
||
|
||
// 验证面贴图不是纯洋红色(洋红背景已由去背步骤移除)
|
||
assert!(
|
||
decoded.pixels().any(|p| p.0 != [255, 0, 255, 255]),
|
||
"tile {index} face {face_name} 不应是纯洋红"
|
||
);
|
||
}
|
||
}
|
||
|
||
assert_eq!(total_faces, JUMP_HOP_TILE_ITEM_COUNT * 6);
|
||
assert!(
|
||
no_key_color_residue,
|
||
"所有输出面贴图不应残留洋红关键色像素"
|
||
);
|
||
|
||
// === 阶段2: 关键色检测算法验证 ===
|
||
// 纯洋红应被检测为关键色
|
||
assert!(is_jump_hop_tile_texture_key_pixel(image::Rgba([
|
||
255, 0, 255, 255
|
||
])));
|
||
assert!(is_jump_hop_tile_texture_key_pixel(image::Rgba([
|
||
255, 0, 255, 128
|
||
])));
|
||
// 接近洋红(距离 ≤ 24)应被检测为关键色
|
||
assert!(is_jump_hop_tile_texture_key_pixel(image::Rgba([
|
||
245, 10, 245, 255
|
||
])));
|
||
// 远离洋红的不应被检测为关键色
|
||
assert!(!is_jump_hop_tile_texture_key_pixel(image::Rgba([
|
||
62, 188, 74, 255
|
||
])));
|
||
assert!(!is_jump_hop_tile_texture_key_pixel(image::Rgba([
|
||
246, 246, 238, 255
|
||
])));
|
||
assert!(!is_jump_hop_tile_texture_key_pixel(image::Rgba([
|
||
0, 0, 0, 255
|
||
])));
|
||
|
||
// === 阶段3: 平均色计算验证 ===
|
||
let mut test_image = image::RgbaImage::new(4, 4);
|
||
for pixel in test_image.pixels_mut() {
|
||
*pixel = image::Rgba([100, 150, 200, 255]);
|
||
}
|
||
test_image.put_pixel(0, 0, image::Rgba([255, 0, 255, 255])); // 关键色(会被跳过)
|
||
let avg = average_jump_hop_tile_texture_color(&test_image);
|
||
// 15 个非关键色像素,均值应为 (100, 150, 200)
|
||
assert_eq!(avg.0[0], 100);
|
||
assert_eq!(avg.0[1], 150);
|
||
assert_eq!(avg.0[2], 200);
|
||
assert_eq!(avg.0[3], 255);
|
||
|
||
// 全部是关键色时回退到 fallback 颜色
|
||
let mut all_key = image::RgbaImage::new(4, 4);
|
||
for pixel in all_key.pixels_mut() {
|
||
*pixel = image::Rgba([255, 0, 255, 255]);
|
||
}
|
||
let fallback_avg = average_jump_hop_tile_texture_color(&all_key);
|
||
assert_eq!(fallback_avg.0[0], 148);
|
||
assert_eq!(fallback_avg.0[1], 163);
|
||
assert_eq!(fallback_avg.0[2], 184);
|
||
|
||
// === 阶段4: prompt 构建验证 ===
|
||
let prompt = build_jump_hop_tile_atlas_prompt("水果", "水果方块 UV 展开图集");
|
||
assert!(prompt.contains("1024x1536"));
|
||
assert!(prompt.contains("18 个大单元格"));
|
||
assert!(prompt.contains("3 列 6 行"));
|
||
assert!(prompt.contains("4列x3行"));
|
||
assert!(prompt.contains("【背景填充】"));
|
||
|
||
let negative_prompt = build_jump_hop_tile_atlas_negative_prompt();
|
||
assert!(negative_prompt.contains("文字"));
|
||
assert!(negative_prompt.contains("Logo"));
|
||
assert!(negative_prompt.contains("塑料质感"));
|
||
assert!(!negative_prompt.contains("规则圆盘"));
|
||
|
||
// === 阶段5: prompt 清洗验证 ===
|
||
let sanitized = sanitize_jump_hop_tile_prompt(
|
||
"宝可梦主题方块,皮卡丘风格可落脚平台素材",
|
||
);
|
||
assert!(!sanitized.contains("宝可梦"));
|
||
assert!(!sanitized.contains("皮卡丘"));
|
||
assert!(sanitized.contains("原创幻想萌宠冒险道具"));
|
||
assert!(sanitized.contains("黄色闪电萌宠符号"));
|
||
assert!(sanitized.contains("立方体主题身份方块包装"));
|
||
|
||
let sanitized2 = sanitize_jump_hop_tile_prompt(
|
||
"水果主题,跳台和地板",
|
||
);
|
||
assert!(sanitized2.contains("立方体地板"));
|
||
assert!(!sanitized2.contains("跳台"));
|
||
|
||
// 打印摘要
|
||
println!(
|
||
"\n====== 跳一跳图集切片测试完成 ======"
|
||
);
|
||
println!("原始图集尺寸: {}×{}", width, height);
|
||
println!("大单元格数: {} ({}行×{}列)", JUMP_HOP_TILE_ITEM_COUNT, JUMP_HOP_TILE_ATLAS_ROWS, JUMP_HOP_TILE_ATLAS_COLS);
|
||
println!("每格 UV 面网格: {}×{}", JUMP_HOP_TILE_UV_FACE_COLS, JUMP_HOP_TILE_UV_FACE_ROWS);
|
||
println!("每格 face_side: {}px", face_side);
|
||
println!("输出面贴图数量: {}", total_faces);
|
||
println!("输出面贴图尺寸: {}×{}", expected_output_size, expected_output_size);
|
||
println!("无关键色残留: {}", no_key_color_residue);
|
||
for (index, slice) in slices.iter().enumerate() {
|
||
println!(
|
||
" tile-{:02} [{:?}] row-{}-col-{}",
|
||
index + 1,
|
||
slice.tile_type,
|
||
index as u32 / JUMP_HOP_TILE_ATLAS_COLS + 1,
|
||
index as u32 % JUMP_HOP_TILE_ATLAS_COLS + 1
|
||
);
|
||
}
|
||
println!("中间结果保存在: {}", output_root.display());
|
||
println!("=====================================\n");
|
||
}
|
||
|
||
/// 跳一跳图集全流程测试(AI 生图模式)—— 对比种子点精修 vs 谷检测。
|
||
/// 使用 VectorEngine 真实生成图集 → 洋红去背 → 两种算法分别切片 → 保存所有中间结果。
|
||
/// 需要配置 VECTOR_ENGINE_BASE_URL 和 VECTOR_ENGINE_API_KEY 环境变量。
|
||
/// 运行:cargo test -p api-server jump_hop_tile_atlas_ai_generation_pipeline -- --nocapture --ignored
|
||
#[test]
|
||
#[ignore]
|
||
fn jump_hop_tile_atlas_ai_generation_pipeline() {
|
||
use crate::jump_hop_atlas_slicing::{
|
||
AtlasSliceAlgorithm, DEFAULT_TILE_COLS, DEFAULT_TILE_ROWS,
|
||
compute_col_density, compute_row_density, refine_boundaries_seed,
|
||
};
|
||
|
||
let base_url = std::env::var("VECTOR_ENGINE_BASE_URL")
|
||
.unwrap_or_default()
|
||
.trim()
|
||
.trim_end_matches('/')
|
||
.to_string();
|
||
let api_key = std::env::var("VECTOR_ENGINE_API_KEY")
|
||
.unwrap_or_default()
|
||
.trim()
|
||
.to_string();
|
||
|
||
if base_url.is_empty() || api_key.is_empty() {
|
||
println!("\n跳过:未配置 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY\n");
|
||
return;
|
||
}
|
||
|
||
let output_root = std::path::PathBuf::from("target")
|
||
.join("test-output")
|
||
.join("jump-hop-tile-atlas-ai");
|
||
let _ = std::fs::remove_dir_all(&output_root);
|
||
std::fs::create_dir_all(&output_root).expect("应该能创建输出目录");
|
||
|
||
let theme_text = "水果";
|
||
let tile_prompt = "水果方块 UV 展开图集";
|
||
let atlas_prompt =
|
||
build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt);
|
||
let negative_prompt = build_jump_hop_tile_atlas_negative_prompt();
|
||
|
||
println!("\n====== 跳一跳 AI 图集生成与自适应切片对比测试 ======");
|
||
println!("主题: {theme_text}");
|
||
println!("网格: {}×{}", DEFAULT_TILE_ROWS, DEFAULT_TILE_COLS);
|
||
println!("输出目录: {}", output_root.display());
|
||
|
||
std::fs::write(
|
||
output_root.join("00-generation-prompt.txt"),
|
||
format!(
|
||
"=== POSITIVE PROMPT ===\n{atlas_prompt}\n\n=== NEGATIVE PROMPT ===\n{negative_prompt}\n"
|
||
),
|
||
).expect("保存 prompt");
|
||
|
||
// ── AI 生图 + 洋红去背(一次性,两种算法共享输入) ──
|
||
println!("\n--- 阶段1: 调用 VectorEngine 生成图集 ---");
|
||
let rt = tokio::runtime::Runtime::new().expect("创建 tokio runtime");
|
||
let settings = platform_image::VectorEngineImageSettings {
|
||
base_url: base_url.clone(),
|
||
api_key: api_key.clone(),
|
||
request_timeout_ms: 180_000,
|
||
};
|
||
let http_client =
|
||
platform_image::build_vector_engine_image_http_client(&settings)
|
||
.expect("构建 HTTP 客户端");
|
||
|
||
let generation_result = rt.block_on(
|
||
platform_image::create_vector_engine_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
&atlas_prompt,
|
||
Some(negative_prompt),
|
||
JUMP_HOP_TILE_ATLAS_IMAGE_SIZE,
|
||
1,
|
||
&[],
|
||
"跳一跳图集测试",
|
||
),
|
||
);
|
||
|
||
let generated = match generation_result {
|
||
Ok(images) => {
|
||
println!("生成成功,task_id: {}", images.task_id);
|
||
images
|
||
}
|
||
Err(error) => panic!("VectorEngine 生图失败:{error}"),
|
||
};
|
||
|
||
let tile_image = generated.images.into_iter().next().expect("应该有生成的图片");
|
||
std::fs::write(output_root.join("01-ai-generated-atlas.png"), &tile_image.bytes)
|
||
.expect("保存 AI 生成图集");
|
||
|
||
let download_image = crate::openai_image_generation::DownloadedOpenAiImage {
|
||
bytes: tile_image.bytes,
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
};
|
||
|
||
println!("\n--- 洋红去背 ---");
|
||
let cleaned = prepare_jump_hop_magenta_screen_image_for_slicing(
|
||
download_image,
|
||
"跳一跳图集洋红去背测试失败",
|
||
).expect("洋红去背应该成功");
|
||
std::fs::write(
|
||
output_root.join("01b-after-magenta-cleanup.png"),
|
||
&cleaned.bytes,
|
||
).expect("保存去背后图集");
|
||
|
||
// 解码并计算 density,打印供对比
|
||
let source = image::load_from_memory(&cleaned.bytes)
|
||
.expect("去背后图集应可解码")
|
||
.to_rgba8();
|
||
let width = source.width();
|
||
let height = source.height();
|
||
let pixels = source.as_raw();
|
||
println!("去背后图集尺寸: {}×{}", width, height);
|
||
|
||
let row_density = compute_row_density(pixels, width, height);
|
||
let col_density = compute_col_density(pixels, width, height);
|
||
|
||
// ── 算法对比 ──
|
||
let algorithms = [
|
||
("seed", AtlasSliceAlgorithm::SeedRefinement),
|
||
("valley", AtlasSliceAlgorithm::ValleyDetection),
|
||
];
|
||
|
||
for &(algo_name, algo) in &algorithms {
|
||
println!("\n========================================");
|
||
println!(" 算法: {algo_name}");
|
||
println!("========================================");
|
||
|
||
let algo_dir = output_root.join(format!("02-slices-{algo_name}"));
|
||
let _ = std::fs::remove_dir_all(&algo_dir);
|
||
std::fs::create_dir_all(&algo_dir).expect("创建算法输出目录");
|
||
|
||
let slices = crate::jump_hop_atlas_slicing::slice_tile_atlas_adaptive(
|
||
&cleaned,
|
||
DEFAULT_TILE_ROWS,
|
||
DEFAULT_TILE_COLS,
|
||
algo,
|
||
).expect(&format!("{algo_name} 切片应该成功"));
|
||
|
||
assert_eq!(
|
||
slices.len(),
|
||
(DEFAULT_TILE_ROWS * DEFAULT_TILE_COLS) as usize,
|
||
"{algo_name} 应该切出正确数量的 tile"
|
||
);
|
||
|
||
// 保存 density 数据供离线分析
|
||
std::fs::write(
|
||
algo_dir.join("row_density.csv"),
|
||
row_density.iter().enumerate()
|
||
.map(|(i, v)| format!("{i},{v:.6}"))
|
||
.collect::<Vec<_>>()
|
||
.join("\n"),
|
||
).expect("保存行 density");
|
||
|
||
std::fs::write(
|
||
algo_dir.join("col_density.csv"),
|
||
col_density.iter().enumerate()
|
||
.map(|(i, v)| format!("{i},{v:.6}"))
|
||
.collect::<Vec<_>>()
|
||
.join("\n"),
|
||
).expect("保存列 density");
|
||
|
||
// 保存行投影可视化(ASCII 柱状图)
|
||
let mut row_viz = String::from("row_density:\n");
|
||
let max_d = row_density.iter().cloned().fold(0.0f32, f32::max).max(0.001);
|
||
for (y, &d) in row_density.iter().enumerate() {
|
||
let bar = (d / max_d * 60.0) as usize;
|
||
row_viz.push_str(&format!("{:4} |{}\n", y, "#".repeat(bar)));
|
||
}
|
||
std::fs::write(algo_dir.join("row_density_viz.txt"), &row_viz).ok();
|
||
|
||
let mut col_viz = String::from("col_density:\n");
|
||
let max_c = col_density.iter().cloned().fold(0.0f32, f32::max).max(0.001);
|
||
for (x, &d) in col_density.iter().enumerate() {
|
||
let bar = (d / max_c * 60.0) as usize;
|
||
col_viz.push_str(&format!("{:4} |{}\n", x, "#".repeat(bar)));
|
||
}
|
||
std::fs::write(algo_dir.join("col_density_viz.txt"), &col_viz).ok();
|
||
|
||
// 打印 cell 边界
|
||
println!("\n固定网格种子位置:");
|
||
let cell_h = (height / DEFAULT_TILE_ROWS).max(1);
|
||
let cell_w = (width / DEFAULT_TILE_COLS).max(1);
|
||
let row_seeds: Vec<_> = (1..DEFAULT_TILE_ROWS).map(|i| i * height / DEFAULT_TILE_ROWS).collect();
|
||
let col_seeds: Vec<_> = (1..DEFAULT_TILE_COLS).map(|i| i * width / DEFAULT_TILE_COLS).collect();
|
||
println!(" 行种子: {:?}", row_seeds);
|
||
println!(" 列种子: {:?}", col_seeds);
|
||
|
||
// 精修后的位置(用种子点精修展示偏移量)
|
||
let refined_rows = refine_boundaries_seed(&row_density, &row_seeds, (cell_h / 3).max(1));
|
||
let refined_cols = refine_boundaries_seed(&col_density, &col_seeds, (cell_w / 3).max(1));
|
||
println!("\n种子点精修偏移:");
|
||
for (i, (&seed, &refined)) in row_seeds.iter().zip(refined_rows.iter()).enumerate() {
|
||
println!(" 行边界 {}: seed={seed} → refined={refined} (偏移 {})", i + 1, refined as i32 - seed as i32);
|
||
}
|
||
for (i, (&seed, &refined)) in col_seeds.iter().zip(refined_cols.iter()).enumerate() {
|
||
println!(" 列边界 {}: seed={seed} → refined={refined} (偏移 {})", i + 1, refined as i32 - seed as i32);
|
||
}
|
||
|
||
// 保存面贴图
|
||
let mut total_faces = 0usize;
|
||
for (index, slice) in slices.iter().enumerate() {
|
||
let r = index as u32 / DEFAULT_TILE_COLS;
|
||
let c = index as u32 % DEFAULT_TILE_COLS;
|
||
let tile_dir = algo_dir.join(format!(
|
||
"tile-{:02}-{:?}-row{}-col{}",
|
||
index + 1, slice.tile_type, r + 1, c + 1
|
||
));
|
||
std::fs::create_dir_all(&tile_dir).expect("创建 tile 目录");
|
||
|
||
let faces: [(&str, &JumpHopTileFaceSlice); 6] = [
|
||
("top", &slice.faces.top),
|
||
("front", &slice.faces.front),
|
||
("right", &slice.faces.right),
|
||
("back", &slice.faces.back),
|
||
("left", &slice.faces.left),
|
||
("bottom", &slice.faces.bottom),
|
||
];
|
||
for (face_name, face) in &faces {
|
||
total_faces += 1;
|
||
let filename = format!(
|
||
"{}-{}.png",
|
||
face_name,
|
||
face.source_atlas_cell.replace('/', "-")
|
||
);
|
||
std::fs::write(tile_dir.join(&filename), &face.bytes)
|
||
.expect("保存面贴图");
|
||
}
|
||
}
|
||
println!("{algo_name}: 输出 {total_faces} 张面贴图");
|
||
|
||
// 保存 3×6 cell 网格中间切图
|
||
let cells_dir = algo_dir.join("cells");
|
||
std::fs::create_dir_all(&cells_dir).expect("创建 cells 目录");
|
||
let grid = match algo {
|
||
AtlasSliceAlgorithm::SeedRefinement => {
|
||
crate::jump_hop_atlas_slicing::detect_cell_grid_seed(
|
||
pixels, width, height, DEFAULT_TILE_ROWS, DEFAULT_TILE_COLS)
|
||
}
|
||
AtlasSliceAlgorithm::ValleyDetection => {
|
||
crate::jump_hop_atlas_slicing::detect_cell_grid_valley(
|
||
pixels, width, height, DEFAULT_TILE_ROWS, DEFAULT_TILE_COLS)
|
||
.unwrap_or_else(|_| crate::jump_hop_atlas_slicing::detect_cell_grid_seed(
|
||
pixels, width, height, DEFAULT_TILE_ROWS, DEFAULT_TILE_COLS))
|
||
}
|
||
};
|
||
for row in 0..DEFAULT_TILE_ROWS {
|
||
for col in 0..DEFAULT_TILE_COLS {
|
||
let x0 = grid.col_boundaries[col as usize];
|
||
let x1 = grid.col_boundaries[col as usize + 1];
|
||
let y0 = grid.row_boundaries[row as usize];
|
||
let y1 = grid.row_boundaries[row as usize + 1];
|
||
let cell_img = image::imageops::crop_imm(
|
||
&source, x0, y0, x1 - x0, y1 - y0);
|
||
cell_img.to_image().save(
|
||
cells_dir.join(format!("cell-row{}-col{}.png", row + 1, col + 1))
|
||
).expect("保存 cell 切图");
|
||
}
|
||
}
|
||
println!("{algo_name}: 保存 {DEFAULT_TILE_ROWS}×{DEFAULT_TILE_COLS} cell 网格切图");
|
||
}
|
||
|
||
println!("\n====== 对比测试完成 ======");
|
||
println!("输出目录: {}", output_root.display());
|
||
println!(" seed 算法结果: {}/02-slices-seed/", output_root.display());
|
||
println!(" valley 算法结果: {}/02-slices-valley/", output_root.display());
|
||
println!(" density 数据: 各算法目录下的 row_density.csv / col_density.csv");
|
||
println!("==============================\n");
|
||
}
|
||
|
||
/// 修改前固定网格管线测试(AI 生图模式)
|
||
/// 使用 VectorEngine 真实生成图集 → 洋红去背 → 旧固定网格切片 → 保存中间结果。
|
||
/// 需要配置 VECTOR_ENGINE_BASE_URL 和 VECTOR_ENGINE_API_KEY 环境变量。
|
||
/// 运行:cargo test -p api-server jump_hop_tile_atlas_ai_fixed_grid -- --nocapture --ignored
|
||
#[test]
|
||
#[ignore]
|
||
fn jump_hop_tile_atlas_ai_fixed_grid() {
|
||
let base_url = std::env::var("VECTOR_ENGINE_BASE_URL")
|
||
.unwrap_or_default()
|
||
.trim()
|
||
.trim_end_matches('/')
|
||
.to_string();
|
||
let api_key = std::env::var("VECTOR_ENGINE_API_KEY")
|
||
.unwrap_or_default()
|
||
.trim()
|
||
.to_string();
|
||
|
||
if base_url.is_empty() || api_key.is_empty() {
|
||
println!("\n跳过:未配置 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY\n");
|
||
return;
|
||
}
|
||
|
||
let output_root = std::path::PathBuf::from("target")
|
||
.join("test-output")
|
||
.join("jump-hop-tile-atlas-ai-fixed");
|
||
let _ = std::fs::remove_dir_all(&output_root);
|
||
std::fs::create_dir_all(&output_root).expect("应该能创建输出目录");
|
||
|
||
let theme_text = "水果";
|
||
let tile_prompt = "水果方块 UV 展开图集";
|
||
let atlas_prompt =
|
||
build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt);
|
||
let negative_prompt = build_jump_hop_tile_atlas_negative_prompt();
|
||
|
||
println!("\n====== 跳一跳固定网格 AI 生图测试 ======");
|
||
println!("主题: {theme_text}");
|
||
println!("输出目录: {}", output_root.display());
|
||
|
||
std::fs::write(
|
||
output_root.join("00-generation-prompt.txt"),
|
||
format!(
|
||
"=== POSITIVE PROMPT ===\n{atlas_prompt}\n\n=== NEGATIVE PROMPT ===\n{negative_prompt}\n"
|
||
),
|
||
).expect("保存 prompt");
|
||
|
||
// AI 生图
|
||
println!("\n--- 阶段1: VectorEngine 生图 ---");
|
||
let rt = tokio::runtime::Runtime::new().expect("创建 tokio runtime");
|
||
let settings = platform_image::VectorEngineImageSettings {
|
||
base_url: base_url.clone(),
|
||
api_key: api_key.clone(),
|
||
request_timeout_ms: 180_000,
|
||
};
|
||
let http_client =
|
||
platform_image::build_vector_engine_image_http_client(&settings)
|
||
.expect("构建 HTTP 客户端");
|
||
|
||
let generation_result = rt.block_on(
|
||
platform_image::create_vector_engine_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
&atlas_prompt,
|
||
Some(negative_prompt),
|
||
JUMP_HOP_TILE_ATLAS_IMAGE_SIZE,
|
||
1,
|
||
&[],
|
||
"跳一跳图集测试(固定网格)",
|
||
),
|
||
);
|
||
|
||
let generated = match generation_result {
|
||
Ok(images) => {
|
||
println!("生成成功,task_id: {}", images.task_id);
|
||
images
|
||
}
|
||
Err(error) => panic!("VectorEngine 生图失败:{error}"),
|
||
};
|
||
|
||
let tile_image = generated.images.into_iter().next().expect("应该有生成的图片");
|
||
std::fs::write(output_root.join("01-ai-generated-atlas.png"), &tile_image.bytes)
|
||
.expect("保存 AI 生成图集");
|
||
|
||
let download_image = crate::openai_image_generation::DownloadedOpenAiImage {
|
||
bytes: tile_image.bytes,
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
};
|
||
|
||
println!("\n--- 阶段2: 洋红去背 ---");
|
||
let cleaned = prepare_jump_hop_magenta_screen_image_for_slicing(
|
||
download_image,
|
||
"跳一跳图集洋红去背测试失败",
|
||
).expect("洋红去背应该成功");
|
||
std::fs::write(
|
||
output_root.join("02-after-magenta-cleanup.png"),
|
||
&cleaned.bytes,
|
||
).expect("保存去背后图集");
|
||
|
||
let source = image::load_from_memory(&cleaned.bytes)
|
||
.expect("解码")
|
||
.to_rgba8();
|
||
println!("去背后尺寸: {}×{}", source.width(), source.height());
|
||
|
||
// 旧固定网格切片
|
||
println!("\n--- 阶段3: 固定网格切片 ---");
|
||
let cell_w = source.width() / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let cell_h = source.height() / JUMP_HOP_TILE_ATLAS_ROWS;
|
||
println!("固定 cell: {}×{} px", cell_w, cell_h);
|
||
|
||
let slices = slice_jump_hop_tile_atlas(&cleaned)
|
||
.expect("固定网格切片应该成功");
|
||
assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_COUNT);
|
||
|
||
let mut total_faces = 0usize;
|
||
for (index, slice) in slices.iter().enumerate() {
|
||
let r = index as u32 / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let c = index as u32 % JUMP_HOP_TILE_ATLAS_COLS;
|
||
let tile_dir = output_root.join(format!(
|
||
"tile-{:02}-{:?}-row{}-col{}",
|
||
index + 1, slice.tile_type, r + 1, c + 1
|
||
));
|
||
std::fs::create_dir_all(&tile_dir).expect("创建 tile 目录");
|
||
|
||
let faces: [(&str, &JumpHopTileFaceSlice); 6] = [
|
||
("top", &slice.faces.top),
|
||
("front", &slice.faces.front),
|
||
("right", &slice.faces.right),
|
||
("back", &slice.faces.back),
|
||
("left", &slice.faces.left),
|
||
("bottom", &slice.faces.bottom),
|
||
];
|
||
for (face_name, face) in &faces {
|
||
total_faces += 1;
|
||
let filename = format!(
|
||
"{}-{}.png",
|
||
face_name,
|
||
face.source_atlas_cell.replace('/', "-")
|
||
);
|
||
std::fs::write(tile_dir.join(&filename), &face.bytes)
|
||
.expect("保存面贴图");
|
||
}
|
||
}
|
||
|
||
assert_eq!(total_faces, JUMP_HOP_TILE_ITEM_COUNT * 6);
|
||
println!("输出: {} 张面贴图", total_faces);
|
||
|
||
// 保存 3×6 cell 网格中间切图
|
||
let cells_dir = output_root.join("cells");
|
||
std::fs::create_dir_all(&cells_dir).expect("创建 cells 目录");
|
||
for row in 0..JUMP_HOP_TILE_ATLAS_ROWS {
|
||
for col in 0..JUMP_HOP_TILE_ATLAS_COLS {
|
||
let x0 = col * source.width() / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let x1 = (col + 1) * source.width() / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let y0 = row * source.height() / JUMP_HOP_TILE_ATLAS_ROWS;
|
||
let y1 = (row + 1) * source.height() / JUMP_HOP_TILE_ATLAS_ROWS;
|
||
let cell_img = image::imageops::crop_imm(&source, x0, y0, x1 - x0, y1 - y0);
|
||
cell_img.to_image().save(
|
||
cells_dir.join(format!("cell-row{}-col{}.png", row + 1, col + 1))
|
||
).expect("保存 cell 切图");
|
||
}
|
||
}
|
||
println!("保存 {}×{} cell 网格切图", JUMP_HOP_TILE_ATLAS_ROWS, JUMP_HOP_TILE_ATLAS_COLS);
|
||
|
||
println!("\n====== 固定网格测试完成 ======");
|
||
println!("输出目录: {}", output_root.display());
|
||
println!("==============================\n");
|
||
}
|
||
}
|