Sync local updates with origin/master

This commit is contained in:
2026-05-26 23:00:08 +08:00
parent 6b9c0fb3db
commit 927dcf5664
21 changed files with 655 additions and 73 deletions

View File

@@ -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} 个地块切片应保留对应格子的主体颜色"
);
}
}
}

View File

@@ -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(

View File

@@ -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>,