feat: integrate jump-hop shelf and asset flow
This commit is contained in:
@@ -4,23 +4,44 @@ use axum::{
|
||||
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, JumpHopDraftResponse, JumpHopGalleryDetailResponse,
|
||||
JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopRestartRunRequest,
|
||||
JumpHopRunResponse, JumpHopSessionResponse, JumpHopSessionSnapshotResponse,
|
||||
JumpHopStartRunRequest, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
|
||||
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::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::{collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
generated_asset_sheets::{
|
||||
GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt,
|
||||
slice_generated_asset_sheet,
|
||||
},
|
||||
generated_image_assets::{
|
||||
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
||||
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
|
||||
normalize_generated_image_asset_mime,
|
||||
},
|
||||
openai_image_generation::{
|
||||
build_openai_image_http_client, create_openai_image_generation,
|
||||
require_openai_image_settings,
|
||||
},
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
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";
|
||||
@@ -103,6 +124,15 @@ pub async fn execute_jump_hop_action(
|
||||
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)
|
||||
@@ -143,6 +173,31 @@ pub async fn publish_jump_hop_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>,
|
||||
@@ -298,6 +353,336 @@ pub async fn get_jump_hop_gallery_detail(
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
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_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_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput {
|
||||
subject_text: tile_prompt,
|
||||
item_names: &vec!["start".to_string(), "normal".to_string(), "target".to_string(), "finish".to_string(), "bonus".to_string(), "accent".to_string()],
|
||||
grid_size: 3,
|
||||
item_name_prompt_template: Some("第{row_index}行:{item_name} 的 {view_count} 个不同视图"),
|
||||
special_prompt: Some("每个格子对应一个 tile 类型,供跳一跳地块裁切使用。"),
|
||||
})
|
||||
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
||||
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_generated_asset_sheet(
|
||||
&tile_image,
|
||||
&vec!["start".to_string(), "normal".to_string(), "target".to_string(), "finish".to_string(), "bonus".to_string(), "accent".to_string()],
|
||||
3,
|
||||
)
|
||||
.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 tile_assets = tile_slices
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, row)| JumpHopTileAsset {
|
||||
tile_type: match index {
|
||||
0 => JumpHopTileType::Start,
|
||||
1 => JumpHopTileType::Normal,
|
||||
2 => JumpHopTileType::Target,
|
||||
3 => JumpHopTileType::Finish,
|
||||
4 => JumpHopTileType::Bonus,
|
||||
_ => JumpHopTileType::Accent,
|
||||
},
|
||||
image_src: format!("/generated-jump-hop-assets/{profile_id}/tiles/{index}.png"),
|
||||
image_object_key: format!("generated-jump-hop-assets/{profile_id}/tiles/{index}.png"),
|
||||
asset_object_id: format!("{profile_id}-tile-{index}-object"),
|
||||
source_atlas_cell: format!("cell-{index}"),
|
||||
visual_width: 256,
|
||||
visual_height: 192,
|
||||
top_surface_radius: 42.0,
|
||||
landing_radius: 34.0,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
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(())
|
||||
}
|
||||
|
||||
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_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse {
|
||||
JumpHopDraftResponse {
|
||||
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::{
|
||||
jump_hop::{
|
||||
create_jump_hop_session, execute_jump_hop_action, get_jump_hop_gallery_detail,
|
||||
get_jump_hop_runtime_work, get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery,
|
||||
publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
|
||||
list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -36,6 +36,13 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/jump-hop/works",
|
||||
get(list_jump_hop_works).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/jump-hop/works/{profile_id}/publish",
|
||||
post(publish_jump_hop_work).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -122,12 +122,24 @@ const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024";
|
||||
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
||||
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
|
||||
const PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS: u32 = 512;
|
||||
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;
|
||||
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 6 * 1024 * 1024;
|
||||
const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5;
|
||||
const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
|
||||
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素";
|
||||
const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024";
|
||||
const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536";
|
||||
|
||||
pub(crate) fn format_puzzle_reference_image_upload_bytes(bytes: usize) -> String {
|
||||
format!("{:.1}MB", bytes as f64 / 1024.0 / 1024.0)
|
||||
}
|
||||
|
||||
pub(crate) fn build_puzzle_reference_image_too_large_message(actual_bytes: usize) -> String {
|
||||
format!(
|
||||
"参考图过大,请压缩后再上传(当前 {},最多 6MB)。",
|
||||
format_puzzle_reference_image_upload_bytes(actual_bytes)
|
||||
)
|
||||
}
|
||||
|
||||
const PUZZLE_LEVEL_SCENE_IMAGE_PROMPT: &str = "参考图作为拼图画面,生成对应的拼图游戏关卡画面,要求画面中所有元素精致且风格高度一致,画面中所有UI细节饱满精致、完成度高、顶级游戏品质\n\n画面元素:\n返回按钮位于顶部左上角,顶部中间显示关卡标题“第1关 影”和倒计时时间,右上角显示设置按钮\n画面中间是一个正方形的3*3拼图,拼图区域宽度与画面宽度同宽,紧贴画面横向边缘,拼图区域边界带有边框装饰\n拼图区域下方包含一个下一关按钮,仅在关卡完成时显示\n底部是三个贴合画面主题的道具按钮分别为“提示”、“原图”、“冻结”\n道具按钮上不要显示次数标注,返回按钮和设置按钮旁禁止标注文字";
|
||||
const PUZZLE_UI_SPRITESHEET_IMAGE_PROMPT: &str = "提取画面中的UI元素,将返回按钮、设置按钮、下一关按钮、提示按钮、原图按钮、冻结按钮整理成纯绿色绿幕背景的spritesheet。背景必须是统一纯绿色绿幕(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。按钮顺序必须按原图位置从左到右、从上到下排列:返回、设置、下一关、提示、原图、冻结。按钮素材内必须保留对应中文文字,每个按钮必须是独立完整图形,按钮之间保留足够纯绿色绿幕空白,不能相互接触、重叠或连成一片,方便运行态按自动边界检测识别矩形素材。返回按钮和设置按钮不要额外画白色外圈、白底圆环或浮雕外框,直接画扁平图标本体。按钮自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。禁止水印、数字、次数标注、透明背景、背景图、拼图块、棋盘、网格线、按钮外标签和额外按钮。";
|
||||
const PUZZLE_LEVEL_BACKGROUND_IMAGE_PROMPT: &str = "移除参考图中所有UI元素、移除拼图画面,仅保留背景图,补全被覆盖的背景图内容。禁止在背景中出现人像或和拼图画面中主体一致的内容";
|
||||
|
||||
@@ -643,15 +643,13 @@ pub(crate) async fn resolve_puzzle_reference_image(
|
||||
if let Some(parsed) = parse_puzzle_image_data_url(trimmed) {
|
||||
let bytes_len = parsed.bytes.len();
|
||||
if bytes_len > PUZZLE_REFERENCE_IMAGE_MAX_BYTES {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": "参考图过大,请压缩后重试。",
|
||||
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
||||
"actualBytes": bytes_len,
|
||||
})),
|
||||
);
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": build_puzzle_reference_image_too_large_message(bytes_len),
|
||||
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
||||
"actualBytes": bytes_len,
|
||||
})));
|
||||
}
|
||||
return Ok(PuzzleResolvedReferenceImage {
|
||||
mime_type: parsed.mime_type,
|
||||
@@ -803,16 +801,16 @@ pub(crate) fn validate_puzzle_reference_asset_object(
|
||||
if asset_object.content_length == 0
|
||||
|| asset_object.content_length > PUZZLE_REFERENCE_IMAGE_MAX_BYTES as u64
|
||||
{
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": "参考图资产大小不符合拼图生成要求。",
|
||||
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
||||
"actualBytes": asset_object.content_length,
|
||||
})),
|
||||
);
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": build_puzzle_reference_image_too_large_message(
|
||||
asset_object.content_length as usize,
|
||||
),
|
||||
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
||||
"actualBytes": asset_object.content_length,
|
||||
})));
|
||||
}
|
||||
if let Some(expected_owner_user_id) = owner_user_id
|
||||
.map(str::trim)
|
||||
|
||||
@@ -20,7 +20,7 @@ const OSS_V4_REQUEST: &str = "aliyun_v4_request";
|
||||
const OSS_V4_SERVICE: &str = "oss";
|
||||
const OSS_UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
|
||||
|
||||
pub const LEGACY_PUBLIC_PREFIXES: [&str; 12] = [
|
||||
pub const LEGACY_PUBLIC_PREFIXES: [&str; 13] = [
|
||||
"generated-character-drafts",
|
||||
"generated-characters",
|
||||
"generated-animations",
|
||||
@@ -29,6 +29,7 @@ pub const LEGACY_PUBLIC_PREFIXES: [&str; 12] = [
|
||||
"generated-wooden-fish-assets",
|
||||
"generated-match3d-assets",
|
||||
"generated-puzzle-assets",
|
||||
"generated-jump-hop-assets",
|
||||
"generated-custom-world-scenes",
|
||||
"generated-custom-world-covers",
|
||||
"generated-bark-battle-assets",
|
||||
@@ -52,6 +53,7 @@ pub enum LegacyAssetPrefix {
|
||||
WoodenFishAssets,
|
||||
Match3DAssets,
|
||||
PuzzleAssets,
|
||||
JumpHopAssets,
|
||||
CustomWorldScenes,
|
||||
CustomWorldCovers,
|
||||
BarkBattleAssets,
|
||||
@@ -241,6 +243,7 @@ impl LegacyAssetPrefix {
|
||||
"generated-wooden-fish-assets" => Some(Self::WoodenFishAssets),
|
||||
"generated-match3d-assets" => Some(Self::Match3DAssets),
|
||||
"generated-puzzle-assets" => Some(Self::PuzzleAssets),
|
||||
"generated-jump-hop-assets" => Some(Self::JumpHopAssets),
|
||||
"generated-custom-world-scenes" => Some(Self::CustomWorldScenes),
|
||||
"generated-custom-world-covers" => Some(Self::CustomWorldCovers),
|
||||
"generated-bark-battle-assets" => Some(Self::BarkBattleAssets),
|
||||
@@ -259,6 +262,7 @@ impl LegacyAssetPrefix {
|
||||
Self::WoodenFishAssets => "generated-wooden-fish-assets",
|
||||
Self::Match3DAssets => "generated-match3d-assets",
|
||||
Self::PuzzleAssets => "generated-puzzle-assets",
|
||||
Self::JumpHopAssets => "generated-jump-hop-assets",
|
||||
Self::CustomWorldScenes => "generated-custom-world-scenes",
|
||||
Self::CustomWorldCovers => "generated-custom-world-covers",
|
||||
Self::BarkBattleAssets => "generated-bark-battle-assets",
|
||||
|
||||
@@ -87,6 +87,8 @@ pub struct JumpHopWorkspaceCreateRequest {
|
||||
pub struct JumpHopActionRequest {
|
||||
pub action_type: JumpHopActionType,
|
||||
#[serde(default)]
|
||||
pub profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub work_title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub work_description: Option<String>,
|
||||
@@ -102,6 +104,14 @@ pub struct JumpHopActionRequest {
|
||||
pub tile_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub end_mood_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub character_asset: Option<JumpHopCharacterAsset>,
|
||||
#[serde(default)]
|
||||
pub tile_atlas_asset: Option<JumpHopCharacterAsset>,
|
||||
#[serde(default)]
|
||||
pub tile_assets: Option<Vec<JumpHopTileAsset>>,
|
||||
#[serde(default)]
|
||||
pub cover_composite: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
|
||||
@@ -226,8 +226,11 @@ impl SpacetimeClient {
|
||||
&self,
|
||||
profile_id: String,
|
||||
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
|
||||
self.get_jump_hop_work_profile(profile_id, String::new())
|
||||
.await
|
||||
let work = self
|
||||
.get_jump_hop_work_profile(profile_id, String::new())
|
||||
.await?;
|
||||
validate_jump_hop_runtime_ready(&work)?;
|
||||
Ok(work)
|
||||
}
|
||||
|
||||
pub async fn start_jump_hop_run(
|
||||
@@ -235,12 +238,17 @@ impl SpacetimeClient {
|
||||
payload: JumpHopStartRunRequest,
|
||||
owner_user_id: String,
|
||||
) -> Result<JumpHopRuntimeRunSnapshotResponse, SpacetimeClientError> {
|
||||
let profile_id = payload.profile_id;
|
||||
let work = self
|
||||
.get_jump_hop_work_profile(profile_id.clone(), String::new())
|
||||
.await?;
|
||||
validate_jump_hop_runtime_ready(&work)?;
|
||||
let run_id = build_prefixed_uuid_id("jump-hop-run-");
|
||||
let procedure_input = JumpHopRunStartInput {
|
||||
client_event_id: format!("{run_id}:start"),
|
||||
run_id,
|
||||
owner_user_id,
|
||||
profile_id: payload.profile_id,
|
||||
profile_id,
|
||||
started_at_ms: current_unix_micros().div_euclid(1000),
|
||||
};
|
||||
self.start_jump_hop_run_with_input(procedure_input).await
|
||||
@@ -372,11 +380,91 @@ impl SpacetimeClient {
|
||||
&self,
|
||||
public_work_code: String,
|
||||
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
|
||||
self.get_jump_hop_work_profile(public_work_code, String::new())
|
||||
let gallery = self.list_jump_hop_gallery().await?;
|
||||
let requested_code = normalize_jump_hop_public_work_code(public_work_code.as_str());
|
||||
let card = gallery
|
||||
.items
|
||||
.into_iter()
|
||||
.find(|item| {
|
||||
normalize_jump_hop_public_work_code(item.public_work_code.as_str()) == requested_code
|
||||
})
|
||||
.ok_or_else(|| SpacetimeClientError::Procedure("jump_hop public work 不存在".to_string()))?;
|
||||
|
||||
self.get_jump_hop_work_profile(card.profile_id, String::new())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn validate_jump_hop_runtime_ready(
|
||||
work: &JumpHopWorkProfileResponse,
|
||||
) -> Result<(), SpacetimeClientError> {
|
||||
let status = work.summary.publication_status.trim().to_ascii_lowercase();
|
||||
if status != "published" {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop runtime 只能启动已发布作品",
|
||||
));
|
||||
}
|
||||
if work.summary.generation_status != JumpHopGenerationStatus::Ready {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop runtime 需要 ready 状态作品",
|
||||
));
|
||||
}
|
||||
validate_jump_hop_character_asset_ready(&work.character_asset, "character_asset")?;
|
||||
validate_jump_hop_character_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?;
|
||||
if work.tile_assets.is_empty() {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop runtime 缺少地块资产",
|
||||
));
|
||||
}
|
||||
for (index, asset) in work.tile_assets.iter().enumerate() {
|
||||
if asset.image_src.trim().is_empty()
|
||||
|| asset.image_object_key.trim().is_empty()
|
||||
|| asset.asset_object_id.trim().is_empty()
|
||||
{
|
||||
return Err(SpacetimeClientError::validation_failed(format!(
|
||||
"jump-hop runtime 地块资产 #{index} 不完整"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if work.path.platforms.is_empty() {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop runtime 缺少可玩路径",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_jump_hop_character_asset_ready(
|
||||
asset: &JumpHopCharacterAsset,
|
||||
field: &str,
|
||||
) -> Result<(), SpacetimeClientError> {
|
||||
if asset.image_src.trim().is_empty()
|
||||
|| asset.image_object_key.trim().is_empty()
|
||||
|| asset.asset_object_id.trim().is_empty()
|
||||
{
|
||||
return Err(SpacetimeClientError::validation_failed(format!(
|
||||
"jump-hop runtime {field} 不完整"
|
||||
)));
|
||||
}
|
||||
if asset.generation_provider.trim().is_empty()
|
||||
|| asset.generation_provider == "deterministic-placeholder"
|
||||
{
|
||||
return Err(SpacetimeClientError::validation_failed(format!(
|
||||
"jump-hop runtime {field} 不是可用真实生成资产"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_jump_hop_public_work_code(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.map(|character| character.to_ascii_uppercase())
|
||||
.collect()
|
||||
}
|
||||
|
||||
enum JumpHopActionProcedure {
|
||||
Compile(JumpHopDraftCompileInput),
|
||||
Update(JumpHopWorkUpdateInput),
|
||||
@@ -503,22 +591,61 @@ fn merge_action_into_draft(
|
||||
if matches!(
|
||||
scope,
|
||||
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter
|
||||
) && let Some(value) = payload
|
||||
.character_prompt
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
draft.character_prompt = value.trim().to_string();
|
||||
) {
|
||||
if let Some(value) = payload
|
||||
.character_prompt
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
draft.character_prompt = value.trim().to_string();
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
scope,
|
||||
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles
|
||||
) && let Some(value) = payload
|
||||
.tile_prompt
|
||||
) {
|
||||
if let Some(value) = payload
|
||||
.tile_prompt
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
draft.tile_prompt = value.trim().to_string();
|
||||
}
|
||||
}
|
||||
if let Some(profile_id) = payload
|
||||
.profile_id
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
draft.tile_prompt = value.trim().to_string();
|
||||
draft.profile_id = Some(profile_id.to_string());
|
||||
}
|
||||
if matches!(
|
||||
scope,
|
||||
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter
|
||||
) {
|
||||
if let Some(asset) = payload.character_asset.clone() {
|
||||
draft.character_asset = Some(asset);
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
scope,
|
||||
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles
|
||||
) {
|
||||
if let Some(asset) = payload.tile_atlas_asset.clone() {
|
||||
draft.tile_atlas_asset = Some(asset);
|
||||
}
|
||||
if let Some(assets) = payload.tile_assets.clone() {
|
||||
draft.tile_assets = assets;
|
||||
}
|
||||
}
|
||||
if let Some(value) = payload
|
||||
.cover_composite
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
draft.cover_composite = Some(value.to_string());
|
||||
}
|
||||
if draft.work_title.trim().is_empty() {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
@@ -545,31 +672,30 @@ fn build_compile_input(
|
||||
draft.tile_atlas_asset = None;
|
||||
draft.tile_assets.clear();
|
||||
}
|
||||
let character_asset = ensure_character_asset(
|
||||
draft.character_asset.clone(),
|
||||
let character_asset = draft.character_asset.clone().ok_or_else(|| {
|
||||
SpacetimeClientError::validation_failed(
|
||||
"jump-hop compile-draft 缺少真实角色资产,请先由 api-server 生成并持久化 asset_object",
|
||||
)
|
||||
})?;
|
||||
let tile_atlas_asset = draft.tile_atlas_asset.clone().ok_or_else(|| {
|
||||
SpacetimeClientError::validation_failed(
|
||||
"jump-hop compile-draft 缺少真实地块图集资产,请先由 api-server 生成并持久化 asset_object",
|
||||
)
|
||||
})?;
|
||||
let tile_assets = if draft.tile_assets.is_empty() {
|
||||
return Err(SpacetimeClientError::validation_failed(
|
||||
"jump-hop compile-draft 缺少真实地块资产,请先由 api-server 生成并持久化 asset_object",
|
||||
));
|
||||
} else {
|
||||
draft.tile_assets.clone()
|
||||
};
|
||||
let cover_composite = resolve_cover_composite(
|
||||
draft,
|
||||
profile_id,
|
||||
&draft.character_prompt,
|
||||
force_character,
|
||||
refresh,
|
||||
now_micros,
|
||||
);
|
||||
let tile_atlas_asset = ensure_tile_atlas_asset(
|
||||
draft.tile_atlas_asset.clone(),
|
||||
profile_id,
|
||||
&draft.tile_prompt,
|
||||
force_tiles,
|
||||
now_micros,
|
||||
);
|
||||
let tile_assets = ensure_tile_assets(
|
||||
draft.tile_assets.clone(),
|
||||
profile_id,
|
||||
force_tiles,
|
||||
now_micros,
|
||||
);
|
||||
let cover_composite = resolve_cover_composite(draft, profile_id, refresh, now_micros);
|
||||
|
||||
draft.character_asset = Some(character_asset.clone());
|
||||
draft.tile_atlas_asset = Some(tile_atlas_asset.clone());
|
||||
draft.tile_assets = tile_assets.clone();
|
||||
draft.cover_composite = cover_composite.clone();
|
||||
draft.generation_status = JumpHopGenerationStatus::Ready;
|
||||
|
||||
@@ -698,8 +824,10 @@ fn ensure_character_asset(
|
||||
force_new: bool,
|
||||
now_micros: i64,
|
||||
) -> JumpHopCharacterAsset {
|
||||
if !force_new && let Some(asset) = existing {
|
||||
return asset;
|
||||
if !force_new {
|
||||
if let Some(asset) = existing {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
let revision = force_new.then_some(now_micros);
|
||||
let suffix = asset_revision_suffix(revision);
|
||||
@@ -722,8 +850,10 @@ fn ensure_tile_atlas_asset(
|
||||
force_new: bool,
|
||||
now_micros: i64,
|
||||
) -> JumpHopCharacterAsset {
|
||||
if !force_new && let Some(asset) = existing {
|
||||
return asset;
|
||||
if !force_new {
|
||||
if let Some(asset) = existing {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
let revision = force_new.then_some(now_micros);
|
||||
let suffix = asset_revision_suffix(revision);
|
||||
@@ -781,14 +911,15 @@ fn resolve_cover_composite(
|
||||
refresh: JumpHopAssetRefresh,
|
||||
now_micros: i64,
|
||||
) -> Option<String> {
|
||||
if matches!(refresh, JumpHopAssetRefresh::Preserve)
|
||||
&& let Some(value) = draft
|
||||
if matches!(refresh, JumpHopAssetRefresh::Preserve) {
|
||||
if let Some(value) = draft
|
||||
.cover_composite
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(value.to_string());
|
||||
{
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
let suffix = asset_revision_suffix(
|
||||
(!matches!(refresh, JumpHopAssetRefresh::Preserve)).then_some(now_micros),
|
||||
|
||||
@@ -46,7 +46,7 @@ pub fn jump_hop_gallery_card_view(ctx: &AnonymousViewContext) -> Vec<JumpHopGall
|
||||
jump_hop_gallery_view(ctx)
|
||||
.into_iter()
|
||||
.map(|row| JumpHopGalleryCardViewRow {
|
||||
public_work_code: row.work_id.clone(),
|
||||
public_work_code: build_jump_hop_public_work_code(&row.profile_id),
|
||||
work_id: row.work_id,
|
||||
profile_id: row.profile_id,
|
||||
owner_user_id: row.owner_user_id,
|
||||
@@ -658,6 +658,25 @@ fn build_gallery_view_row(row: &JumpHopWorkProfileRow) -> Result<JumpHopGalleryV
|
||||
})
|
||||
}
|
||||
|
||||
fn build_jump_hop_public_work_code(profile_id: &str) -> String {
|
||||
let normalized = profile_id
|
||||
.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.flat_map(|character| character.to_uppercase())
|
||||
.collect::<String>();
|
||||
let fallback = if normalized.is_empty() {
|
||||
"00000000".to_string()
|
||||
} else {
|
||||
normalized
|
||||
};
|
||||
let suffix = if fallback.len() > 8 {
|
||||
fallback[fallback.len() - 8..].to_string()
|
||||
} else {
|
||||
format!("{fallback:0>8}")
|
||||
};
|
||||
format!("JH-{suffix}")
|
||||
}
|
||||
|
||||
fn build_session_snapshot(
|
||||
row: &JumpHopAgentSessionRow,
|
||||
) -> Result<JumpHopAgentSessionSnapshot, String> {
|
||||
|
||||
Reference in New Issue
Block a user