Files
Genarrative/server-rs/crates/api-server/src/jump_hop.rs
2026-05-31 22:44:22 +08:00

1084 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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} 个地块切片应保留对应格子的主体颜色"
);
}
}
}