1084 lines
38 KiB
Rust
1084 lines
38 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,
|
||
JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopSessionResponse,
|
||
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopTileAsset, 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_asset_sheets::{
|
||
apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte,
|
||
},
|
||
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,
|
||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||
};
|
||
|
||
const JUMP_HOP_TILE_ITEM_NAMES: [&str; 6] =
|
||
["start", "normal", "target", "finish", "bonus", "accent"];
|
||
|
||
const JUMP_HOP_PROVIDER: &str = "jump-hop";
|
||
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";
|
||
const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 2;
|
||
const JUMP_HOP_TILE_ATLAS_COLS: u32 = 3;
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
struct JumpHopTileAtlasSlice {
|
||
tile_type: JumpHopTileType,
|
||
source_atlas_cell: String,
|
||
bytes: Vec<u8>,
|
||
}
|
||
|
||
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;
|
||
maybe_generate_jump_hop_assets(
|
||
&state,
|
||
&request_context,
|
||
session_id.as_str(),
|
||
owner_user_id.as_str(),
|
||
&mut payload,
|
||
)
|
||
.await?;
|
||
let response = state
|
||
.spacetime_client()
|
||
.execute_jump_hop_action(session_id, owner_user_id, payload)
|
||
.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), response))
|
||
}
|
||
|
||
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_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 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 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),
|
||
)
|
||
})?;
|
||
|
||
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) {
|
||
return Ok(());
|
||
}
|
||
if payload.character_asset.is_some()
|
||
&& payload.tile_atlas_asset.is_some()
|
||
&& payload
|
||
.tile_assets
|
||
.as_ref()
|
||
.is_some_and(|assets| !assets.is_empty())
|
||
{
|
||
return Ok(());
|
||
}
|
||
let profile_id = payload
|
||
.profile_id
|
||
.as_ref()
|
||
.map(|value| value.trim())
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToString::to_string)
|
||
.unwrap_or_else(|| build_prefixed_uuid_id("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 character_prompt = payload
|
||
.character_prompt
|
||
.as_deref()
|
||
.unwrap_or("俯视角可爱主角,透明背景");
|
||
let tile_prompt = payload.tile_prompt.as_deref().unwrap_or("等距立体地块图集");
|
||
|
||
let character_generated = create_openai_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
character_prompt,
|
||
Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"),
|
||
"1024*1024",
|
||
1,
|
||
&[],
|
||
"跳一跳角色资产生成失败",
|
||
)
|
||
.await
|
||
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
||
let character_image = character_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 character_asset = persist_jump_hop_generated_image_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id.as_str(),
|
||
"character",
|
||
character_prompt,
|
||
character_image,
|
||
LegacyAssetPrefix::JumpHopAssets,
|
||
768,
|
||
768,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
|
||
let sheet_prompt = build_jump_hop_tile_atlas_prompt(tile_prompt);
|
||
let tile_generated = create_openai_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
sheet_prompt.as_str(),
|
||
Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"),
|
||
"1024*1024",
|
||
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": "跳一跳地块图集生成成功但未返回图片。",
|
||
})),
|
||
)
|
||
})?;
|
||
let tile_slices = slice_jump_hop_tile_atlas(&tile_image).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,
|
||
tile_image,
|
||
LegacyAssetPrefix::JumpHopAssets,
|
||
1024,
|
||
1024,
|
||
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.character_asset = Some(character_asset);
|
||
payload.tile_atlas_asset = Some(tile_atlas_asset);
|
||
payload.tile_assets = Some(tile_assets);
|
||
payload.cover_composite = payload.cover_composite.clone().or_else(|| {
|
||
Some(format!(
|
||
"/generated-jump-hop-assets/{profile_id}/cover-composite.png"
|
||
))
|
||
});
|
||
Ok(())
|
||
}
|
||
|
||
fn build_jump_hop_tile_atlas_prompt(tile_prompt: &str) -> String {
|
||
let subject_text = tile_prompt.trim();
|
||
let subject_text = if subject_text.is_empty() {
|
||
"等距立体地块图集"
|
||
} else {
|
||
subject_text
|
||
};
|
||
let cell_plan = [
|
||
"第1行第1列:start 起点地块",
|
||
"第1行第2列:normal 普通地块",
|
||
"第1行第3列:target 目标地块",
|
||
"第2行第1列:finish 终点地块",
|
||
"第2行第2列:bonus 奖励地块",
|
||
"第2行第3列:accent 视觉强调地块",
|
||
]
|
||
.join(";");
|
||
|
||
format!(
|
||
"生成一张1:1图片。固定生成2行*3列的跳一跳地块素材图集,画面是{subject_text}。严格按六个单元格排布:{cell_plan}。每个单元格只放一个完整等距/俯视角 2D 地块,必须表现顶面、侧面厚度和统一投影,光向一致,地块主体居中且四周保留留白。每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。素材本身不得使用与绿幕相同的纯绿色;若材质天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。禁止主体跨格、贴边或越界,禁止任何内容进入相邻格子。不要出现文字、水印、UI、边框、网格线、标签、角色或场景。"
|
||
)
|
||
}
|
||
|
||
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}"),
|
||
}))
|
||
})?;
|
||
let source = apply_generated_asset_sheet_green_screen_alpha(source);
|
||
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_NAMES.len());
|
||
for index in 0..JUMP_HOP_TILE_ITEM_NAMES.len() {
|
||
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 cropped = source.crop_imm(
|
||
x0,
|
||
y0,
|
||
x1.saturating_sub(x0).max(1),
|
||
y1.saturating_sub(y0).max(1),
|
||
);
|
||
let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped);
|
||
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!("跳一跳地块图集切割失败:{error}"),
|
||
}))
|
||
})?;
|
||
slices.push(JumpHopTileAtlasSlice {
|
||
tile_type: jump_hop_tile_type_by_index(index),
|
||
source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1),
|
||
bytes: cursor.into_inner(),
|
||
});
|
||
}
|
||
|
||
Ok(slices)
|
||
}
|
||
|
||
fn jump_hop_tile_type_by_index(index: usize) -> JumpHopTileType {
|
||
match index {
|
||
0 => JumpHopTileType::Start,
|
||
1 => JumpHopTileType::Normal,
|
||
2 => JumpHopTileType::Target,
|
||
3 => JumpHopTileType::Finish,
|
||
4 => JumpHopTileType::Bonus,
|
||
_ => JumpHopTileType::Accent,
|
||
}
|
||
}
|
||
|
||
fn jump_hop_tile_asset_slot_name(tile_type: &JumpHopTileType) -> &'static str {
|
||
match tile_type {
|
||
JumpHopTileType::Start => "tile-start",
|
||
JumpHopTileType::Normal => "tile-normal",
|
||
JumpHopTileType::Target => "tile-target",
|
||
JumpHopTileType::Finish => "tile-finish",
|
||
JumpHopTileType::Bonus => "tile-bonus",
|
||
JumpHopTileType::Accent => "tile-accent",
|
||
}
|
||
}
|
||
|
||
#[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_slice.tile_type);
|
||
let image = crate::openai_image_generation::DownloadedOpenAiImage {
|
||
bytes: tile_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,
|
||
&format!(
|
||
"跳一跳地块切片 {}:{}",
|
||
tile_index + 1,
|
||
tile_slice.source_atlas_cell
|
||
),
|
||
image,
|
||
LegacyAssetPrefix::JumpHopAssets,
|
||
256,
|
||
192,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
|
||
Ok(JumpHopTileAsset {
|
||
tile_type: tile_slice.tile_type,
|
||
image_src: persisted.image_src,
|
||
image_object_key: persisted.image_object_key,
|
||
asset_object_id: persisted.asset_object_id,
|
||
source_atlas_cell: tile_slice.source_atlas_cell,
|
||
visual_width: 256,
|
||
visual_height: 192,
|
||
top_surface_radius: 42.0,
|
||
landing_radius: 34.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 build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse {
|
||
JumpHopDraftResponse {
|
||
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
|
||
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
|
||
profile_id: None,
|
||
work_title: payload.work_title.trim().to_string(),
|
||
work_description: payload.work_description.trim().to_string(),
|
||
theme_tags: normalize_tags(payload.theme_tags.clone()),
|
||
difficulty: payload.difficulty.clone(),
|
||
style_preset: payload.style_preset.clone(),
|
||
character_prompt: payload.character_prompt.trim().to_string(),
|
||
tile_prompt: payload.tile_prompt.trim().to_string(),
|
||
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,
|
||
generation_status: JumpHopGenerationStatus::Draft,
|
||
}
|
||
}
|
||
|
||
fn validate_workspace_request(
|
||
request_context: &RequestContext,
|
||
payload: &JumpHopWorkspaceCreateRequest,
|
||
) -> Result<(), Response> {
|
||
ensure_non_empty(request_context, &payload.work_title, "workTitle")?;
|
||
ensure_non_empty(
|
||
request_context,
|
||
&payload.character_prompt,
|
||
"characterPrompt",
|
||
)?;
|
||
ensure_non_empty(request_context, &payload.tile_prompt, "tilePrompt")?;
|
||
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 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_tile_atlas_prompt_uses_dedicated_two_by_three_layout() {
|
||
let prompt = build_jump_hop_tile_atlas_prompt("森林石块风格等距地块");
|
||
|
||
assert!(prompt.contains("2行*3列"));
|
||
assert!(prompt.contains("第1行第1列:start 起点地块"));
|
||
assert!(prompt.contains("第2行第3列:accent 视觉强调地块"));
|
||
assert!(!prompt.contains("每个物品生成"));
|
||
assert!(!prompt.contains("不同视图"));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_tile_atlas_slices_one_png_per_tile_type() {
|
||
let width = 300;
|
||
let height = 200;
|
||
let colors = [
|
||
[220, 24, 24, 255],
|
||
[240, 150, 32, 255],
|
||
[248, 220, 72, 255],
|
||
[52, 168, 84, 255],
|
||
[38, 132, 255, 255],
|
||
[156, 92, 220, 255],
|
||
];
|
||
let mut atlas = image::RgbaImage::new(width, height);
|
||
for row in 0..2 {
|
||
for col in 0..3 {
|
||
let color = image::Rgba(colors[row * 3 + col]);
|
||
for y in row as u32 * 100..(row as u32 + 1) * 100 {
|
||
for x in col as u32 * 100..(col as u32 + 1) * 100 {
|
||
atlas.put_pixel(x, y, color);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||
image::DynamicImage::ImageRgba8(atlas)
|
||
.write_to(&mut encoded, image::ImageFormat::Png)
|
||
.expect("atlas should encode");
|
||
let image = crate::openai_image_generation::DownloadedOpenAiImage {
|
||
bytes: encoded.into_inner(),
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
};
|
||
|
||
let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice");
|
||
|
||
assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_NAMES.len());
|
||
for (index, slice) in slices.iter().enumerate() {
|
||
assert_eq!(slice.tile_type, jump_hop_tile_type_by_index(index));
|
||
assert_eq!(
|
||
slice.source_atlas_cell,
|
||
format!("row-{}-col-{}", index / 3 + 1, index % 3 + 1)
|
||
);
|
||
let decoded = image::load_from_memory(slice.bytes.as_slice())
|
||
.expect("tile slice should decode")
|
||
.to_rgba8();
|
||
assert!(
|
||
decoded.pixels().any(|pixel| pixel.0 == colors[index]),
|
||
"第 {index} 个地块切片应保留对应格子的主体颜色"
|
||
);
|
||
}
|
||
}
|
||
}
|