Sync local updates with origin/master
This commit is contained in:
@@ -29,8 +29,7 @@ use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
||||
generated_asset_sheets::{
|
||||
GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt,
|
||||
slice_generated_asset_sheet,
|
||||
apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte,
|
||||
},
|
||||
generated_image_assets::{
|
||||
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
||||
@@ -56,6 +55,15 @@ 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>,
|
||||
@@ -379,7 +387,7 @@ pub async fn get_jump_hop_gallery_detail(
|
||||
async fn maybe_generate_jump_hop_assets(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
session_id: &str,
|
||||
_session_id: &str,
|
||||
owner_user_id: &str,
|
||||
payload: &mut JumpHopActionRequest,
|
||||
) -> Result<(), Response> {
|
||||
@@ -457,21 +465,7 @@ async fn maybe_generate_jump_hop_assets(
|
||||
)
|
||||
.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 sheet_prompt = build_jump_hop_tile_atlas_prompt(tile_prompt);
|
||||
let tile_generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
@@ -494,19 +488,9 @@ async fn maybe_generate_jump_hop_assets(
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
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_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,
|
||||
@@ -520,28 +504,20 @@ async fn maybe_generate_jump_hop_assets(
|
||||
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<_>>();
|
||||
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);
|
||||
@@ -553,6 +529,153 @@ async fn maybe_generate_jump_hop_assets(
|
||||
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,
|
||||
@@ -882,3 +1005,71 @@ fn current_utc_micros() -> i64 {
|
||||
.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} 个地块切片应保留对应格子的主体颜色"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ use crate::{
|
||||
wooden_fish::{
|
||||
checkpoint_wooden_fish_run, create_wooden_fish_session, execute_wooden_fish_action,
|
||||
finish_wooden_fish_run, get_wooden_fish_gallery_detail, get_wooden_fish_runtime_work,
|
||||
get_wooden_fish_session, list_wooden_fish_gallery, publish_wooden_fish_work,
|
||||
start_wooden_fish_run,
|
||||
get_wooden_fish_session, list_wooden_fish_gallery, list_wooden_fish_works,
|
||||
publish_wooden_fish_work, start_wooden_fish_run,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -37,6 +37,13 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/wooden-fish/works",
|
||||
get(list_wooden_fish_works).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/wooden-fish/works/{profile_id}/publish",
|
||||
post(publish_wooden_fish_work).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -21,6 +21,7 @@ use shared_contracts::wooden_fish::{
|
||||
WoodenFishGenerationStatus, WoodenFishImageAsset, WoodenFishRunResponse,
|
||||
WoodenFishSessionResponse, WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest,
|
||||
WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkspaceCreateRequest,
|
||||
WoodenFishWorksResponse,
|
||||
};
|
||||
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
@@ -193,6 +194,31 @@ pub async fn publish_wooden_fish_work(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_wooden_fish_works(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let works = state
|
||||
.spacetime_client()
|
||||
.list_wooden_fish_works(authenticated.claims().user_id().to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
wooden_fish_error_response(
|
||||
&request_context,
|
||||
WOODEN_FISH_CREATION_PROVIDER,
|
||||
map_wooden_fish_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
WoodenFishWorksResponse {
|
||||
items: works.into_iter().map(|work| work.summary).collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_wooden_fish_runtime_work(
|
||||
State(state): State<AppState>,
|
||||
Path(profile_id): Path<String>,
|
||||
|
||||
Reference in New Issue
Block a user