feat: polish jump hop themed runtime assets
This commit is contained in:
@@ -61,6 +61,9 @@ const JUMP_HOP_TILE_ATLAS_KEY_HEX: &str = "#FF00FF";
|
||||
const JUMP_HOP_BACKGROUND_IMAGE_SIZE: &str = "1024*1536";
|
||||
const JUMP_HOP_BACKGROUND_IMAGE_WIDTH: u32 = 1024;
|
||||
const JUMP_HOP_BACKGROUND_IMAGE_HEIGHT: u32 = 1536;
|
||||
const JUMP_HOP_BACK_BUTTON_IMAGE_SIZE: &str = "1024*1024";
|
||||
const JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH: u32 = 1024;
|
||||
const JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT: u32 = 1024;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct JumpHopTileAtlasSlice {
|
||||
@@ -444,8 +447,12 @@ async fn maybe_generate_jump_hop_assets(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some_and(|value| !is_jump_hop_legacy_cover_composite_placeholder(value));
|
||||
let has_back_button_asset = payload
|
||||
.back_button_asset
|
||||
.as_ref()
|
||||
.is_some_and(is_jump_hop_image_asset_usable);
|
||||
|
||||
if has_complete_tile_assets && has_real_background {
|
||||
if has_complete_tile_assets && has_real_background && has_back_button_asset {
|
||||
return Ok(());
|
||||
}
|
||||
let profile_id = payload
|
||||
@@ -529,6 +536,58 @@ async fn maybe_generate_jump_hop_assets(
|
||||
payload.cover_composite = Some(background_asset.image_src);
|
||||
}
|
||||
|
||||
if !has_back_button_asset {
|
||||
let back_button_prompt = build_jump_hop_back_button_prompt(theme_text.as_str());
|
||||
let back_button_generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
back_button_prompt.as_str(),
|
||||
Some(build_jump_hop_back_button_negative_prompt()),
|
||||
JUMP_HOP_BACK_BUTTON_IMAGE_SIZE,
|
||||
1,
|
||||
&[],
|
||||
"跳一跳返回按钮图生成失败",
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||||
})?;
|
||||
let back_button_image =
|
||||
back_button_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 back_button_image =
|
||||
prepare_jump_hop_green_screen_image_for_persist(back_button_image, "跳一跳返回按钮图")
|
||||
.map_err(|error| {
|
||||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||||
})?;
|
||||
let back_button_asset = persist_jump_hop_generated_image_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
profile_id.as_str(),
|
||||
"back-button",
|
||||
back_button_prompt.as_str(),
|
||||
back_button_image,
|
||||
LegacyAssetPrefix::JumpHopAssets,
|
||||
JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH,
|
||||
JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT,
|
||||
request_context,
|
||||
)
|
||||
.await?;
|
||||
payload.back_button_asset = Some(back_button_asset);
|
||||
}
|
||||
|
||||
if !has_complete_tile_assets {
|
||||
let sheet_prompt =
|
||||
build_jump_hop_tile_atlas_prompt(theme_text.as_str(), tile_prompt.as_str());
|
||||
@@ -604,33 +663,110 @@ fn is_jump_hop_legacy_cover_composite_placeholder(value: &str) -> bool {
|
||||
&& (value.ends_with("/cover-composite.png") || value.contains("/cover-composite-"))
|
||||
}
|
||||
|
||||
fn build_jump_hop_background_prompt(theme_text: &str) -> String {
|
||||
fn is_jump_hop_image_asset_usable(asset: &JumpHopCharacterAsset) -> bool {
|
||||
!asset.image_src.trim().is_empty()
|
||||
&& !asset.image_object_key.trim().is_empty()
|
||||
&& !asset.asset_object_id.trim().is_empty()
|
||||
&& !asset.generation_provider.trim().is_empty()
|
||||
}
|
||||
|
||||
fn prepare_jump_hop_green_screen_image_for_persist(
|
||||
image: crate::openai_image_generation::DownloadedOpenAiImage,
|
||||
failure_label: &str,
|
||||
) -> Result<crate::openai_image_generation::DownloadedOpenAiImage, 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!("{failure_label}解码失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||||
crate::generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha(source)
|
||||
.write_to(&mut encoded, image::ImageFormat::Png)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||||
"message": format!("{failure_label}绿幕去背失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(crate::openai_image_generation::DownloadedOpenAiImage {
|
||||
bytes: encoded.into_inner(),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_jump_hop_generation_theme_text(theme_text: &str) -> String {
|
||||
let theme_text = theme_text.trim();
|
||||
let theme_text = if theme_text.is_empty() {
|
||||
"跳一跳"
|
||||
} else {
|
||||
theme_text
|
||||
};
|
||||
if theme_text.is_empty() {
|
||||
return "跳一跳".to_string();
|
||||
}
|
||||
|
||||
replace_jump_hop_pokemon_prompt_terms(theme_text)
|
||||
}
|
||||
|
||||
fn replace_jump_hop_pokemon_prompt_terms(value: &str) -> String {
|
||||
let mut value = value.trim().to_string();
|
||||
if value.is_empty() {
|
||||
return value;
|
||||
}
|
||||
|
||||
// 中文注释:仅对宝可梦相关词做生成侧脱敏,避免地块图集触发上游安全拦截。
|
||||
const POKEMON_REPLACEMENTS: [(&str, &str); 15] = [
|
||||
("宝可梦", "原创幻想萌宠冒险道具"),
|
||||
("神奇宝贝", "原创幻想萌宠冒险道具"),
|
||||
("口袋妖怪", "原创幻想萌宠冒险道具"),
|
||||
("精灵球", "彩色冒险能量球"),
|
||||
("皮卡丘", "黄色闪电萌宠符号"),
|
||||
("Pokémon", "原创幻想萌宠冒险道具"),
|
||||
("Pokemon", "原创幻想萌宠冒险道具"),
|
||||
("POKEMON", "原创幻想萌宠冒险道具"),
|
||||
("pokemon", "原创幻想萌宠冒险道具"),
|
||||
("Pikachu", "黄色闪电萌宠符号"),
|
||||
("PIKACHU", "黄色闪电萌宠符号"),
|
||||
("pikachu", "黄色闪电萌宠符号"),
|
||||
("Poké Ball", "彩色冒险能量球"),
|
||||
("Poke Ball", "彩色冒险能量球"),
|
||||
("pokeball", "彩色冒险能量球"),
|
||||
];
|
||||
|
||||
for (from, to) in POKEMON_REPLACEMENTS {
|
||||
value = value.replace(from, to);
|
||||
}
|
||||
|
||||
value
|
||||
}
|
||||
|
||||
fn build_jump_hop_background_prompt(theme_text: &str) -> String {
|
||||
let theme_text = normalize_jump_hop_generation_theme_text(theme_text);
|
||||
|
||||
format!(
|
||||
"生成一张9:16竖版跳一跳游戏背景底图,主题关键词严格只使用“{theme_text}”,不要额外改换主题;整体风格需要和同一主题的跳一跳游戏元素一致。\n画面结构必须以左右两侧氛围为主:左侧和右侧可以使用符合主题的环境元素、装饰层次、前中后景遮挡、透视节奏和行进感,让玩家感到从画面下方向上方前进。\n中央纵轴1/2区域必须是清爽的跳跃路径视觉走廊,从画面底部延伸到上方;该区域只能使用少量低对比度纹理、柔和光影、空气透视和纵深引导线,禁止堆放大型主体。\n中央纵轴1/2区域要有明显纵深感,但元素数量必须少,不能抢跳板、角色和交互层的视觉;两侧可以更有立体感、空间层次和主题氛围。\n背景只作为底图,不画任何跳板、地块、落脚物、角色、UI按钮、标题、文字、路径箭头、分数、边框、海报排版、Logo或水印。\n视角保持正面约30度的2D/2.5D休闲手游视角,相机位于场景正前方略高位置,画面有轻微向上行进的纵深,不要画成纯俯视地图、平铺俯拍、扁平壁纸或真实摄影。\n色彩清爽自然,哑光手绘质感,柔和光照,主体背景不油亮、不厚重CG、不暗黑;中央区域需要给运行态地块和陶泥儿角色留出干净可读空间。\nEnglish guardrail: vertical 9:16 mobile game background only, theme keywords strictly from \"{theme_text}\", left and right sides carry the atmosphere, the central vertical half-width corridor stays simple with sparse low-contrast details and clear depth, no platforms, no landing objects, no character, no UI, no text, consistent 2D/2.5D front-facing 30-degree game perspective."
|
||||
"生成一张9:16竖版跳一跳游戏背景底图,主题关键词严格只使用“{theme_text}”,不要额外改换主题;整体风格需要和同一主题的跳一跳游戏元素一致。\n画面结构必须以左右两侧氛围为主:左侧和右侧可以使用符合主题的环境元素、装饰层次、前中后景遮挡、透视节奏和行进感,让玩家感到从画面下方向上方前进。\n中央纵轴1/2区域必须是清爽的跳跃路径视觉走廊,从画面底部延伸到上方;该区域只能使用少量低对比度纹理、柔和光影、空气透视和纵深引导线,禁止堆放大型主体。\n中央纵轴1/2区域要有明显纵深感,但元素数量必须少,不能抢跳板、角色和交互层的视觉;两侧可以更有立体感、空间层次和主题氛围。\n背景只作为底图,不画任何跳板、地块、落脚物、角色、UI按钮、标题、文字、路径箭头、分数、边框、海报排版、Logo或水印;左上角也不要画返回按钮或任何固定图标,运行态会叠加独立可点击按钮资产。\n视角保持正面约30度的2D/2.5D休闲手游视角,相机位于场景正前方略高位置,画面有轻微向上行进的纵深,不要画成纯俯视地图、平铺俯拍、扁平壁纸或真实摄影。\n色彩清爽自然,哑光手绘质感,柔和光照,主体背景不油亮、不厚重CG、不暗黑;中央区域需要给运行态地块和陶泥儿角色留出干净可读空间。\nEnglish guardrail: vertical 9:16 mobile game background only, theme keywords strictly from \"{theme_text}\", left and right sides carry the atmosphere, the central vertical half-width corridor stays simple with sparse low-contrast details and clear depth, no platforms, no landing objects, no character, no UI, no top-left back button, no score UI, no other UI panels, consistent 2D/2.5D front-facing 30-degree game perspective."
|
||||
)
|
||||
}
|
||||
|
||||
fn build_jump_hop_background_negative_prompt() -> &'static str {
|
||||
"文字、Logo、水印、UI按钮、标题、说明文字、分数、边框、海报排版、角色、人物、跳板、地块、落脚物、平台、道路箭头、棋盘、格子、中心大型主体、中央堆满元素、中央遮挡、中央高对比装饰、中央复杂花纹、纯俯视地图、平铺俯拍、扁平壁纸、真实摄影、暗黑幻想风、厚重CG渲染、油亮高光、塑料质感"
|
||||
"文字、Logo、水印、UI按钮、返回按钮、左上角图标、右上角按钮、底部按钮、UI面板、标题、说明文字、分数、边框、海报排版、角色、人物、跳板、地块、落脚物、平台、道路箭头、棋盘、格子、中心大型主体、中央堆满元素、中央遮挡、中央高对比装饰、中央复杂花纹、纯俯视地图、平铺俯拍、扁平壁纸、真实摄影、暗黑幻想风、厚重CG渲染、油亮高光、塑料质感"
|
||||
}
|
||||
|
||||
fn build_jump_hop_back_button_prompt(theme_text: &str) -> String {
|
||||
let theme_text = normalize_jump_hop_generation_theme_text(theme_text);
|
||||
|
||||
format!(
|
||||
"生成跳一跳运行态左上角返回按钮的独立透明素材。主题关键词严格只使用“{theme_text}”,按钮的底色、材质、描边和轻微装饰跟随该主题,但必须仍然是清晰可识别的游戏 UI 返回按钮。\n按钮必须是单个标准圆形图标,圆心居中,主体视觉尺寸占画布约72%-82%,外沿有一圈干净描边,内部只有一个居中的向左箭头;不要写“返回”文字,不要数字、Logo、水印、按钮外标签或额外 UI 面板。\n允许在圆形底色里做很轻的主题材质包装,例如水果主题可用果皮色和果肉色、森林主题可用叶片色和木质描边、未来主题可用金属边和发光内环;但不要把按钮画成主题物体本身,不要继承复杂花纹、浮雕边、异形外框、贴纸堆叠或徽章装饰。\n尺寸1:1,输出绿色背景主体图,背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影;按钮主体边缘干净,后续由服务端扣除绿色背景。按钮底色不要使用与绿幕接近的纯绿色,若主题天然包含绿色,请使用偏深、偏黄或偏蓝的主题绿色,并用高对比箭头颜色区分。\nEnglish guardrail: one standalone circular mobile game back button asset only, theme-styled colors/materials from \"{theme_text}\", centered left arrow only, no text, no logo, no extra UI, no complex badge, no object silhouette, solid #00FF00 green-screen background for later alpha removal."
|
||||
)
|
||||
}
|
||||
|
||||
fn build_jump_hop_back_button_negative_prompt() -> &'static str {
|
||||
"文字、返回文字、Logo、水印、数字、多个按钮、UI面板、海报排版、复杂徽章、花盘、浮雕边、异形外框、主题物体主体、木槌、角色、跳板、地块、落脚物、平台、透明棋盘格、白底、黑底、灰底、真实摄影、厚重CG、暗黑幻想风、油亮塑料、纯绿色按钮主体、与绿幕混在一起"
|
||||
}
|
||||
|
||||
fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> String {
|
||||
let theme_text = theme_text.trim();
|
||||
let theme_text = if theme_text.is_empty() {
|
||||
"跳一跳"
|
||||
} else {
|
||||
theme_text
|
||||
};
|
||||
let theme_text = normalize_jump_hop_generation_theme_text(theme_text);
|
||||
let sanitized_tile_prompt = sanitize_jump_hop_tile_prompt(tile_prompt);
|
||||
let subject_text = if sanitized_tile_prompt.is_empty() {
|
||||
theme_text
|
||||
theme_text.as_str()
|
||||
} else {
|
||||
sanitized_tile_prompt.as_str()
|
||||
};
|
||||
@@ -649,6 +785,7 @@ fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String {
|
||||
if value.is_empty() {
|
||||
return value;
|
||||
}
|
||||
value = replace_jump_hop_pokemon_prompt_terms(value.as_str());
|
||||
|
||||
const REPLACEMENTS: [(&str, &str); 18] = [
|
||||
("俯视角", "正面30度视角"),
|
||||
@@ -1134,6 +1271,7 @@ fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraft
|
||||
tile_assets: Vec::new(),
|
||||
path: None,
|
||||
cover_composite: None,
|
||||
back_button_asset: None,
|
||||
generation_status: JumpHopGenerationStatus::Draft,
|
||||
}
|
||||
}
|
||||
@@ -1376,13 +1514,32 @@ mod tests {
|
||||
assert!(prompt.contains("中央纵轴1/2区域要有明显纵深感"));
|
||||
assert!(prompt.contains("两侧可以更有立体感、空间层次和主题氛围"));
|
||||
assert!(prompt.contains("不画任何跳板、地块、落脚物、角色、UI按钮"));
|
||||
assert!(prompt.contains("左上角也不要画返回按钮或任何固定图标"));
|
||||
assert!(prompt.contains("运行态会叠加独立可点击按钮资产"));
|
||||
assert!(prompt.contains("视角保持正面约30度"));
|
||||
assert!(prompt.contains("中央区域需要给运行态地块和陶泥儿角色留出干净可读空间"));
|
||||
assert!(prompt.contains("English guardrail"));
|
||||
assert!(prompt.contains("left and right sides carry the atmosphere"));
|
||||
assert!(prompt.contains("central vertical half-width corridor stays simple"));
|
||||
assert!(prompt.contains("no top-left back button"));
|
||||
assert!(prompt.contains("no platforms"));
|
||||
assert!(prompt.contains("no landing objects"));
|
||||
assert!(prompt.contains("no other UI panels"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_hop_back_button_prompt_builds_standalone_green_screen_asset() {
|
||||
let prompt = build_jump_hop_back_button_prompt("水果");
|
||||
|
||||
assert!(prompt.contains("独立透明素材"));
|
||||
assert!(prompt.contains("主题关键词严格只使用“水果”"));
|
||||
assert!(prompt.contains("单个标准圆形图标"));
|
||||
assert!(prompt.contains("内部只有一个居中的向左箭头"));
|
||||
assert!(prompt.contains("不要写“返回”文字"));
|
||||
assert!(prompt.contains("背景必须是单一纯绿色 #00FF00"));
|
||||
assert!(prompt.contains("后续由服务端扣除绿色背景"));
|
||||
assert!(prompt.contains("one standalone circular mobile game back button asset only"));
|
||||
assert!(prompt.contains("solid #00FF00 green-screen background"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1394,6 +1551,11 @@ mod tests {
|
||||
assert!(negative_prompt.contains("落脚物"));
|
||||
assert!(negative_prompt.contains("角色"));
|
||||
assert!(negative_prompt.contains("UI按钮"));
|
||||
assert!(negative_prompt.contains("返回按钮"));
|
||||
assert!(negative_prompt.contains("左上角图标"));
|
||||
assert!(negative_prompt.contains("右上角按钮"));
|
||||
assert!(negative_prompt.contains("底部按钮"));
|
||||
assert!(negative_prompt.contains("UI面板"));
|
||||
assert!(negative_prompt.contains("中央堆满元素"));
|
||||
assert!(negative_prompt.contains("中央遮挡"));
|
||||
assert!(negative_prompt.contains("纯俯视地图"));
|
||||
@@ -1416,6 +1578,34 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_hop_generation_prompt_only_rewrites_pokemon_terms() {
|
||||
let background_prompt = build_jump_hop_background_prompt("宝可梦");
|
||||
assert!(background_prompt.contains("主题关键词严格只使用“原创幻想萌宠冒险道具”"));
|
||||
assert!(!background_prompt.contains("宝可梦"));
|
||||
|
||||
let back_button_prompt = build_jump_hop_back_button_prompt("Pokemon");
|
||||
assert!(back_button_prompt.contains("主题关键词严格只使用“原创幻想萌宠冒险道具”"));
|
||||
assert!(!back_button_prompt.contains("Pokemon"));
|
||||
|
||||
let tile_prompt = build_jump_hop_tile_atlas_prompt(
|
||||
"宝可梦",
|
||||
"宝可梦主题的正面30度视角主题物体图集,包含皮卡丘和精灵球装饰",
|
||||
);
|
||||
assert!(tile_prompt.contains("主题为“原创幻想萌宠冒险道具”"));
|
||||
assert!(tile_prompt.contains("画面内容是原创幻想萌宠冒险道具主题"));
|
||||
assert!(tile_prompt.contains("黄色闪电萌宠符号"));
|
||||
assert!(tile_prompt.contains("彩色冒险能量球"));
|
||||
assert!(!tile_prompt.contains("宝可梦"));
|
||||
assert!(!tile_prompt.contains("皮卡丘"));
|
||||
assert!(!tile_prompt.contains("精灵球"));
|
||||
|
||||
let normal_prompt =
|
||||
build_jump_hop_tile_atlas_prompt("水果", "水果主题的正面30度视角主题物体图集");
|
||||
assert!(normal_prompt.contains("主题为“水果”"));
|
||||
assert!(normal_prompt.contains("画面内容是水果主题的正面30度视角主题物体图集"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_hop_tile_prompt_sanitizes_legacy_platform_words() {
|
||||
let prompt = build_jump_hop_tile_atlas_prompt(
|
||||
|
||||
Reference in New Issue
Block a user