Files
Genarrative/server-rs/crates/api-server/src/match3d/works.rs

1417 lines
51 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 super::*;
pub(super) async fn update_match3d_work_cover_only(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
profile: Match3DWorkProfileRecord,
cover_image_src: &str,
) -> Result<Match3DWorkProfileRecord, Response> {
// 中文注释:封面生成是定向图片槽位更新,不能复用草稿编译路径重算题材、难度或素材 JSON。
state
.spacetime_client()
.update_match3d_work(Match3DWorkUpdateRecordInput {
profile_id: profile.profile_id,
owner_user_id: owner_user_id.to_string(),
game_name: profile.game_name,
theme_text: profile.theme_text,
summary_text: profile.summary,
tags_json: serde_json::to_string(&normalize_tags(profile.tags)).unwrap_or_default(),
cover_image_src: cover_image_src.to_string(),
cover_asset_id: profile.cover_asset_id.unwrap_or_default(),
clear_count: profile.clear_count,
difficulty: profile.difficulty,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
match3d_error_response(
request_context,
MATCH3D_WORKS_PROVIDER,
map_match3d_client_error(error),
)
})
}
pub(super) async fn get_match3d_existing_generated_item_assets(
state: &AppState,
owner_user_id: &str,
profile_id: &str,
) -> Vec<Match3DGeneratedItemAsset> {
match state
.spacetime_client()
.get_match3d_work_detail(profile_id.to_string(), owner_user_id.to_string())
.await
{
Ok(profile) => {
parse_match3d_generated_item_assets(profile.generated_item_assets_json.as_deref())
.into_iter()
.map(Match3DGeneratedItemAsset::from)
.collect()
}
Err(error) => {
tracing::debug!(
provider = MATCH3D_AGENT_PROVIDER,
profile_id,
error = %error,
"读取抓大鹅已有素材失败,按空素材继续生成"
);
Vec::new()
}
}
}
pub(super) async fn get_match3d_existing_cover_image_src(
state: &AppState,
owner_user_id: &str,
profile_id: &str,
) -> Option<String> {
state
.spacetime_client()
.get_match3d_work_detail(profile_id.to_string(), owner_user_id.to_string())
.await
.ok()
.and_then(|profile| profile.cover_image_src)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub(super) async fn load_match3d_work_asset_context(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
profile_id: &str,
) -> Result<Match3DWorkAssetContext, Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
let profile = state
.spacetime_client()
.get_match3d_work_detail(profile_id.to_string(), owner_user_id.clone())
.await
.map_err(|error| {
match3d_error_response(
request_context,
MATCH3D_WORKS_PROVIDER,
map_match3d_client_error(error),
)
})?;
let session_id = profile.source_session_id.clone().ok_or_else(|| {
match3d_error_response(
request_context,
MATCH3D_WORKS_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": MATCH3D_WORKS_PROVIDER,
"message": "抓大鹅作品缺少来源 session无法生成素材",
})),
)
})?;
let config = match state
.spacetime_client()
.get_match3d_agent_session(session_id.clone(), owner_user_id.clone())
.await
{
Ok(session) => {
let mut config = resolve_config_or_default(session.config.as_ref());
if config.theme_text.trim().is_empty() {
config.theme_text = profile.theme_text.clone();
}
config
}
Err(error) => {
tracing::debug!(
provider = MATCH3D_WORKS_PROVIDER,
profile_id,
session_id = session_id.as_str(),
error = %error,
"读取抓大鹅 session 配置失败,使用作品 profile 派生素材配置"
);
Match3DConfigJson {
theme_text: profile.theme_text.clone(),
reference_image_src: profile.reference_image_src.clone(),
clear_count: profile.clear_count,
difficulty: profile.difficulty,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}
}
};
let assets = parse_match3d_generated_item_assets(profile.generated_item_assets_json.as_deref())
.into_iter()
.map(Match3DGeneratedItemAsset::from)
.collect::<Vec<_>>();
Ok(Match3DWorkAssetContext {
owner_user_id,
session_id,
profile,
config,
assets,
})
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn persist_match3d_generated_item_assets_snapshot(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
session_id: &str,
owner_user_id: &str,
profile_id: &str,
assets: &[Match3DGeneratedItemAsset],
) -> Result<(), Response> {
upsert_match3d_draft_snapshot(
state,
request_context,
authenticated,
session_id.to_string(),
owner_user_id.to_string(),
profile_id.to_string(),
None,
None,
None,
None,
None,
serialize_match3d_generated_item_assets(assets),
)
.await
.map(|_| ())
}
pub(super) fn resolve_author_display_name(
state: &AppState,
authenticated: &AuthenticatedAccessToken,
) -> String {
state
.auth_user_service()
.get_user_by_id(authenticated.claims().user_id())
.ok()
.flatten()
.map(|user| user.display_name)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "玩家".to_string())
}
pub(super) async fn ensure_match3d_background_asset(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
background_prompt: &str,
mut assets: Vec<Match3DGeneratedItemAsset>,
) -> Result<Vec<Match3DGeneratedItemAsset>, Response> {
let normalized_prompt = normalize_match3d_background_prompt(background_prompt);
let resolved_prompt = if normalized_prompt.is_empty() {
build_fallback_match3d_background_prompt(config)
} else {
normalized_prompt
};
if let Some(existing_background) = find_match3d_generated_background_asset(&assets) {
if is_match3d_background_asset_ready(&existing_background) {
return Ok(assets);
}
}
let generated_background = generate_match3d_level_asset_bundle(
state,
owner_user_id,
session_id,
profile_id,
config,
&resolved_prompt,
)
.await
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
attach_match3d_background_asset_to_assets(&mut assets, generated_background);
persist_match3d_generated_item_assets_snapshot(
state,
request_context,
authenticated,
session_id,
owner_user_id,
profile_id,
&assets,
)
.await?;
Ok(assets)
}
pub(super) async fn resolve_or_generate_match3d_level_asset_bundle(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
background_prompt: &str,
assets: &[Match3DGeneratedItemAsset],
) -> Result<Match3DGeneratedBackgroundAsset, Response> {
if let Some(existing_background) = find_match3d_generated_background_asset(assets) {
if is_match3d_background_asset_ready(&existing_background) {
return Ok(existing_background);
}
}
let normalized_prompt = normalize_match3d_background_prompt(background_prompt);
let resolved_prompt = if normalized_prompt.is_empty() {
build_fallback_match3d_background_prompt(config)
} else {
normalized_prompt
};
generate_match3d_level_asset_bundle(
state,
owner_user_id,
session_id,
profile_id,
config,
resolved_prompt.as_str(),
)
.await
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))
}
pub(super) fn attach_match3d_background_asset_to_assets(
assets: &mut Vec<Match3DGeneratedItemAsset>,
background_asset: Match3DGeneratedBackgroundAsset,
) {
if let Some(first_asset) = assets
.iter_mut()
.min_by_key(|asset| match3d_item_sort_index(asset.item_id.as_str()))
{
first_asset.background_asset = Some(background_asset);
}
}
pub(super) fn build_match3d_item_slug(item_id: &str, item_name: &str) -> String {
format!(
"{}-{}",
sanitize_match3d_asset_segment(item_id, "match3d-item"),
sanitize_match3d_asset_segment(item_name, "item")
)
}
pub(super) async fn generate_match3d_cover_image_asset(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
prompt: &str,
uploaded_image_src: Option<String>,
reference_image_srcs: Vec<String>,
) -> Result<Match3DAssetUpload, AppError> {
require_match3d_oss_client(state)?;
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let cover_prompt = build_match3d_cover_generation_prompt(config, prompt);
let generated = if let Some(uploaded_image) = resolve_match3d_reference_image_for_edit(
state,
uploaded_image_src.as_deref(),
MATCH3D_ITEM_IMAGE_MAX_BYTES,
"match3d-cover-upload",
)
.await?
{
create_openai_image_edit(
&http_client,
&settings,
build_match3d_cover_uploaded_reference_prompt(cover_prompt.as_str()).as_str(),
Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"),
"1:1",
&uploaded_image,
"抓大鹅封面图重绘失败",
)
.await?
} else {
let reference_images = resolve_match3d_cover_reference_images_for_edit(
state,
reference_image_srcs,
MATCH3D_ITEM_IMAGE_MAX_BYTES,
)
.await?;
if reference_images.is_empty() {
create_openai_image_generation(
&http_client,
&settings,
cover_prompt.as_str(),
Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"),
"1:1",
1,
&[],
"抓大鹅封面图生成失败",
)
.await?
} else {
create_openai_image_edit_with_references(
&http_client,
&settings,
build_match3d_cover_reference_generation_prompt(cover_prompt.as_str(), true)
.as_str(),
Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"),
"1:1",
1,
reference_images.as_slice(),
"抓大鹅封面图生成失败",
)
.await?
}
};
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "抓大鹅封面图生成失败:未返回图片",
}))
})?;
let file_name = format!("cover.{}", image.extension);
persist_match3d_generated_bytes(
state,
owner_user_id,
session_id,
profile_id,
&["cover", generated.task_id.as_str()],
file_name.as_str(),
image.mime_type.as_str(),
image.bytes,
"match3d_cover_image",
Some(generated.task_id.as_str()),
current_utc_micros(),
)
.await
}
fn build_match3d_cover_generation_prompt(config: &Match3DConfigJson, prompt: &str) -> String {
let style_clause = resolve_match3d_asset_style_prompt(config)
.map(|style| format!("整体美术风格遵循:{style}"))
.unwrap_or_default();
format!(
"{theme}题材抓大鹅作品封面图。{style_clause}{prompt}。画面为1:1封面主体清晰、色彩明亮、适合移动端作品卡片展示可以包含生成物品或主题元素但不要出现任何文字、按钮、倒计时、分数或 UI。",
theme = config.theme_text,
style_clause = style_clause,
prompt = prompt,
)
}
pub(super) fn build_match3d_cover_uploaded_reference_prompt(prompt: &str) -> String {
format!(
concat!(
"请以随请求上传的封面图作为第一优先级重绘依据,保留主图的主体、构图、视角和主要配色;",
"允许按文字要求提升美术质量、统一风格和补充细节,但不要改成与主图无关的新画面。\n",
"{prompt}"
),
prompt = prompt.trim()
)
}
pub(super) fn build_match3d_cover_reference_generation_prompt(
prompt: &str,
has_reference_images: bool,
) -> String {
if !has_reference_images {
return prompt.trim().to_string();
}
format!(
concat!(
"请参考随请求提供的一张或多张图片作为题材、物体和美术风格参考,融合为一张新的抓大鹅作品封面;",
"参考图只用于主体元素、材质、配色和风格启发,不要拼贴成素材墙或多图排版。\n",
"{prompt}"
),
prompt = prompt.trim()
)
}
pub(super) async fn generate_match3d_background_image(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
prompt: &str,
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
generate_match3d_level_asset_bundle(
state,
owner_user_id,
session_id,
profile_id,
config,
prompt,
)
.await
}
pub(super) async fn generate_match3d_level_asset_bundle(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
prompt: &str,
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
require_match3d_oss_client(state)?;
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let level_scene_prompt = build_match3d_level_scene_generation_prompt(config);
let generated_scene = create_openai_image_generation(
&http_client,
&settings,
level_scene_prompt.as_str(),
Some("水印、教程浮层、菜单、广告、真实手机外框、浏览器 UI"),
"9:16",
1,
&[],
"抓大鹅关卡画面生成失败",
)
.await?;
let level_scene_image = generated_scene.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "抓大鹅关卡画面生成失败:未返回图片",
}))
})?;
let level_scene_reference = OpenAiReferenceImage {
bytes: level_scene_image.bytes.clone(),
mime_type: level_scene_image.mime_type.clone(),
file_name: "match3d-level-scene.png".to_string(),
};
let level_scene_upload = persist_match3d_generated_bytes(
state,
owner_user_id,
session_id,
profile_id,
&["level-scene", generated_scene.task_id.as_str()],
"scene.png",
level_scene_image.mime_type.as_str(),
level_scene_image.bytes,
"match3d_level_scene_image",
Some(generated_scene.task_id.as_str()),
current_utc_micros(),
)
.await?;
let ui_prompt = build_match3d_ui_spritesheet_prompt();
let background_extract_prompt = build_match3d_background_from_scene_prompt();
let generated_ui_future = create_openai_image_edit(
&http_client,
&settings,
ui_prompt.as_str(),
Some("整页背景、中心物品、容器内物品、重复按钮、文字说明、白底、纯色底、网格线"),
"1:1",
&level_scene_reference,
"抓大鹅 UI spritesheet 生成失败",
);
let generated_background_future = create_openai_image_edit(
&http_client,
&settings,
background_extract_prompt.as_str(),
Some("返回按钮、设置按钮、倒计时、标题文字、道具按钮、物品、容器内含物、菜单、教程浮层"),
"9:16",
&level_scene_reference,
"抓大鹅背景图生成失败",
);
let (generated_ui, generated_background) =
tokio::try_join!(generated_ui_future, generated_background_future)?;
let ui_image = generated_ui.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "抓大鹅 UI spritesheet 生成失败:未返回图片",
}))
})?;
let ui_image = make_match3d_spritesheet_image_transparent(ui_image)?;
let ui_upload = persist_match3d_generated_bytes(
state,
owner_user_id,
session_id,
profile_id,
&["ui-spritesheet", generated_ui.task_id.as_str()],
"ui-spritesheet.png",
ui_image.mime_type.as_str(),
ui_image.bytes,
"match3d_ui_spritesheet_image",
Some(generated_ui.task_id.as_str()),
current_utc_micros(),
)
.await?;
let background_image = generated_background
.images
.into_iter()
.next()
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "抓大鹅背景图生成失败:未返回图片",
}))
})?;
let background_image = make_match3d_background_image_opaque(background_image)?;
let background_upload = persist_match3d_generated_bytes(
state,
owner_user_id,
session_id,
profile_id,
&["background", generated_background.task_id.as_str()],
"background.png",
background_image.mime_type.as_str(),
background_image.bytes,
"match3d_background_image",
Some(generated_background.task_id.as_str()),
current_utc_micros(),
)
.await?;
Ok(Match3DGeneratedBackgroundAsset {
prompt: prompt.to_string(),
level_scene_prompt: Some(level_scene_prompt),
level_scene_image_src: Some(level_scene_upload.src),
level_scene_image_object_key: Some(level_scene_upload.object_key),
image_src: Some(background_upload.src),
image_object_key: Some(background_upload.object_key),
ui_spritesheet_prompt: Some(ui_prompt.clone()),
ui_spritesheet_image_src: Some(ui_upload.src.clone()),
ui_spritesheet_image_object_key: Some(ui_upload.object_key.clone()),
item_spritesheet_prompt: None,
item_spritesheet_image_src: None,
item_spritesheet_image_object_key: None,
container_prompt: Some(ui_prompt),
container_image_src: Some(ui_upload.src),
container_image_object_key: Some(ui_upload.object_key),
status: "image_ready".to_string(),
error: None,
})
}
pub(super) async fn generate_match3d_container_image(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
config: &Match3DConfigJson,
prompt: &str,
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
require_match3d_oss_client(state)?;
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let reference_image = load_match3d_container_reference_image()?;
let container_prompt = build_match3d_container_generation_prompt(config, prompt);
let generated_container = create_openai_image_edit(
&http_client,
&settings,
container_prompt.as_str(),
Some("文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层、菜单、整页背景、小容器、正俯视圆盘、侧视碗、餐盘、托盘、画布大留白"),
"1:1",
&reference_image,
"抓大鹅容器 UI 图生成失败",
)
.await?;
let container_image = generated_container
.images
.into_iter()
.next()
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "抓大鹅容器 UI 图生成失败:未返回图片",
}))
})?;
let container_image = make_match3d_container_image_transparent(container_image)?;
let container_upload = persist_match3d_generated_bytes(
state,
owner_user_id,
session_id,
profile_id,
&["ui-container", generated_container.task_id.as_str()],
"container.png",
container_image.mime_type.as_str(),
container_image.bytes,
"match3d_ui_container_image",
Some(generated_container.task_id.as_str()),
current_utc_micros(),
)
.await?;
Ok(Match3DGeneratedBackgroundAsset {
prompt: prompt.to_string(),
image_src: None,
image_object_key: None,
container_prompt: Some(container_prompt),
container_image_src: Some(container_upload.src),
container_image_object_key: Some(container_upload.object_key),
status: "image_ready".to_string(),
error: None,
..Default::default()
})
}
pub(super) fn merge_match3d_container_image_into_background_asset(
assets: &[Match3DGeneratedItemAsset],
container_asset: Match3DGeneratedBackgroundAsset,
) -> Match3DGeneratedBackgroundAsset {
let existing_background = find_match3d_generated_background_asset(assets);
let prompt = existing_background
.as_ref()
.map(|asset| asset.prompt.trim())
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| container_asset.prompt.clone());
Match3DGeneratedBackgroundAsset {
prompt,
level_scene_prompt: existing_background
.as_ref()
.and_then(|asset| asset.level_scene_prompt.clone()),
level_scene_image_src: existing_background
.as_ref()
.and_then(|asset| asset.level_scene_image_src.clone()),
level_scene_image_object_key: existing_background
.as_ref()
.and_then(|asset| asset.level_scene_image_object_key.clone()),
image_src: existing_background
.as_ref()
.and_then(|asset| asset.image_src.clone()),
image_object_key: existing_background
.as_ref()
.and_then(|asset| asset.image_object_key.clone()),
ui_spritesheet_prompt: existing_background
.as_ref()
.and_then(|asset| asset.ui_spritesheet_prompt.clone()),
ui_spritesheet_image_src: existing_background
.as_ref()
.and_then(|asset| asset.ui_spritesheet_image_src.clone()),
ui_spritesheet_image_object_key: existing_background
.as_ref()
.and_then(|asset| asset.ui_spritesheet_image_object_key.clone()),
item_spritesheet_prompt: existing_background
.as_ref()
.and_then(|asset| asset.item_spritesheet_prompt.clone()),
item_spritesheet_image_src: existing_background
.as_ref()
.and_then(|asset| asset.item_spritesheet_image_src.clone()),
item_spritesheet_image_object_key: existing_background
.as_ref()
.and_then(|asset| asset.item_spritesheet_image_object_key.clone()),
container_prompt: container_asset.container_prompt,
container_image_src: container_asset.container_image_src,
container_image_object_key: container_asset.container_image_object_key,
status: "image_ready".to_string(),
error: container_asset.error,
}
}
pub(super) fn load_match3d_container_reference_image() -> Result<OpenAiReferenceImage, AppError> {
// 中文注释:生产 API 单独发布二进制Web 静态资源可能在另一轮流水线发布。
// 容器参考图属于后端生图协议输入,必须随 api-server 编译进二进制,不能依赖运行时 cwd 下存在 public/。
let bytes = MATCH3D_CONTAINER_REFERENCE_IMAGE_BYTES.to_vec();
if bytes.is_empty() {
return Err(
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": MATCH3D_AGENT_PROVIDER,
"message": "抓大鹅容器参考图为空",
})),
);
}
Ok(OpenAiReferenceImage {
bytes,
mime_type: "image/png".to_string(),
file_name: "match3d-container-reference.png".to_string(),
})
}
pub(super) fn build_match3d_level_scene_generation_prompt(config: &Match3DConfigJson) -> String {
let theme = config.theme_text.trim();
let theme = if theme.is_empty() {
MATCH3D_DEFAULT_THEME
} else {
theme
};
let style_clause = resolve_match3d_asset_style_prompt(config)
.map(|style| format!("\n整体美术风格要求:{style}"))
.unwrap_or_default();
format!(
concat!(
"生成抓大鹅游戏关卡画面要求画面中所有元素精致且风格高度一致画面中所有UI细节饱满精致、完成度高、顶级游戏品质\n\n",
"抓大鹅主题描述:\n",
"{theme}{style_clause}\n\n",
"画面元素:\n",
"返回按钮位于顶部左上角顶部中间显示关卡标题“第1关 重庆火锅”和倒计时时间,右上角显示设置按钮\n",
"画面中间是一个和主题匹配的容器,宽度与画面宽度同宽,紧贴画面横向边缘\n",
"底部还有三个道具按钮分别为“移出”、“凑齐”、“打乱”"
),
theme = theme,
style_clause = style_clause,
)
}
pub(super) fn build_match3d_ui_spritesheet_prompt() -> String {
"提取画面中的UI元素将返回按钮、设置按钮、方格素材不含边框仅保留一个、移出按钮、凑齐按钮、打乱按钮的顺序从上到下从左到右整理成纯绿色绿幕背景spritesheet。背景必须是统一纯绿色绿幕高饱和亮绿色接近 #00FF00背景平整无纹理、无渐变、无阴影、无场景内容后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。UI 素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。".to_string()
}
pub(super) fn build_match3d_background_from_scene_prompt() -> String {
"移除画面中的所有UI组件和容器中的内含物完整保留容器和背景补全被UI覆盖的背景内容".to_string()
}
pub(super) fn build_match3d_background_generation_prompt(
config: &Match3DConfigJson,
prompt: &str,
) -> String {
let style_clause = resolve_match3d_asset_style_prompt(config)
.map(|style| format!("整体美术风格参考:{style}"))
.unwrap_or_default();
format!(
"{prompt}\n{style_clause}生成一张 9:16 竖屏抓大鹅游戏纯背景图,只表现题材氛围、色彩层次和场景环境。必须全画幅不透明,四边和角落都要有完整环境像素,不得出现透明 alpha、透明底、镂空或棋盘格透明区域。画面不得出现锅、圆盘、托盘、拼图槽、物品槽、棋盘、容器边框、HUD、文字、按钮、倒计时、分数、物品、角色或手。中央区域保持干净通透方便运行态后续叠加默认交互容器和物品素材。"
)
}
pub(super) fn build_match3d_container_generation_prompt(
config: &Match3DConfigJson,
prompt: &str,
) -> String {
let style_clause = resolve_match3d_asset_style_prompt(config)
.map(|style| format!("整体美术风格参考:{style}"))
.unwrap_or_default();
format!(
"{prompt}\n{style_clause}生成一张 1:1 抓大鹅中心容器 UI 图,只绘制一个贴合题材设定的圆形或浅盘状竞技容器。严格参考输入参考图的容器范围和视图角度:容器外轮廓必须接近画布四边,占画布宽度约 86%-92%、高度约 82%-90%,中心在画布中心略偏下,只保留少量透明留白;视角为轻俯视 3/4 上方视角,能看到圆形碗体外壁、厚实前沿和横向椭圆形内口,不能画成正俯视扁圆盘、侧视碗、小托盘或居中的小容器。容器需要有清晰外沿、内侧可放置 2D 物品的干净空间、轻微阴影和高辨识边界;背景必须是透明 alpha不得出现白底、纯色底、渐变底、场景底或整页背景。禁止文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层和菜单。"
)
}
// 中文注释9:16 运行背景是整屏底图,必须和中心容器透明素材分层处理,避免局内露出透明底。
pub(super) fn make_match3d_background_image_opaque(
image: DownloadedOpenAiImage,
) -> Result<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": "match3d-assets",
"message": format!("抓大鹅背景图解码失败:{error}"),
}))
})?;
let mut rgba = source.to_rgba8();
let matte = sample_match3d_background_opaque_matte(&rgba).unwrap_or([246, 243, 236]);
let mut changed = false;
for pixel in rgba.pixels_mut() {
let alpha = pixel.0[3];
if alpha == 255 {
continue;
}
pixel.0 = blend_match3d_background_pixel_over_matte(pixel.0, matte);
changed = true;
}
if !changed {
return Ok(image);
}
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(rgba)
.write_to(&mut encoded, ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "match3d-assets",
"message": format!("抓大鹅背景图不透明化失败:{error}"),
}))
})?;
Ok(DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
}
fn sample_match3d_background_opaque_matte(image: &image::RgbaImage) -> Option<[u8; 3]> {
sample_match3d_background_matte_from_edges(image)
.or_else(|| sample_match3d_background_matte_from_pixels(image))
}
fn sample_match3d_background_matte_from_edges(image: &image::RgbaImage) -> Option<[u8; 3]> {
let (width, height) = image.dimensions();
if width == 0 || height == 0 {
return None;
}
let mut sampler = Match3DBackgroundMatteSampler::default();
for x in 0..width {
sampler.push(image.get_pixel(x, 0).0);
sampler.push(image.get_pixel(x, height - 1).0);
}
for y in 1..height.saturating_sub(1) {
sampler.push(image.get_pixel(0, y).0);
sampler.push(image.get_pixel(width - 1, y).0);
}
sampler.finish()
}
fn sample_match3d_background_matte_from_pixels(image: &image::RgbaImage) -> Option<[u8; 3]> {
let mut sampler = Match3DBackgroundMatteSampler::default();
for pixel in image.pixels() {
sampler.push(pixel.0);
}
sampler.finish()
}
#[derive(Default)]
struct Match3DBackgroundMatteSampler {
red: u64,
green: u64,
blue: u64,
weight: u64,
}
impl Match3DBackgroundMatteSampler {
fn push(&mut self, pixel: [u8; 4]) {
let alpha = pixel[3] as u64;
if alpha < 32 {
return;
}
self.red = self.red.saturating_add(pixel[0] as u64 * alpha);
self.green = self.green.saturating_add(pixel[1] as u64 * alpha);
self.blue = self.blue.saturating_add(pixel[2] as u64 * alpha);
self.weight = self.weight.saturating_add(alpha);
}
fn finish(self) -> Option<[u8; 3]> {
(self.weight > 0).then(|| {
[
(self.red / self.weight) as u8,
(self.green / self.weight) as u8,
(self.blue / self.weight) as u8,
]
})
}
}
fn blend_match3d_background_pixel_over_matte(pixel: [u8; 4], matte: [u8; 3]) -> [u8; 4] {
let alpha = pixel[3] as u16;
let inverse_alpha = 255u16.saturating_sub(alpha);
[
blend_match3d_background_channel(pixel[0], matte[0], alpha, inverse_alpha),
blend_match3d_background_channel(pixel[1], matte[1], alpha, inverse_alpha),
blend_match3d_background_channel(pixel[2], matte[2], alpha, inverse_alpha),
255,
]
}
fn blend_match3d_background_channel(
foreground: u8,
matte: u8,
alpha: u16,
inverse_alpha: u16,
) -> u8 {
((foreground as u16 * alpha + matte as u16 * inverse_alpha + 127) / 255) as u8
}
pub(super) fn make_match3d_container_image_transparent(
image: DownloadedOpenAiImage,
) -> Result<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": "match3d-assets",
"message": format!("抓大鹅容器图解码失败:{error}"),
}))
})?;
let mut rgba = source.to_rgba8();
let (width, height) = rgba.dimensions();
remove_match3d_container_plain_background(rgba.as_mut(), width as usize, height as usize);
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(rgba)
.write_to(&mut encoded, ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "match3d-assets",
"message": format!("抓大鹅容器图透明化失败:{error}"),
}))
})?;
Ok(DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
}
pub(super) fn make_match3d_spritesheet_image_transparent(
image: DownloadedOpenAiImage,
) -> Result<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": "match3d-assets",
"message": format!("抓大鹅 spritesheet 图解码失败:{error}"),
}))
})?;
let mut encoded = std::io::Cursor::new(Vec::new());
apply_generated_asset_sheet_green_screen_alpha(source)
.write_to(&mut encoded, ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "match3d-assets",
"message": format!("抓大鹅 spritesheet 图透明化失败:{error}"),
}))
})?;
Ok(DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
}
pub(super) async fn download_match3d_legacy_model(
file: &hyper3d_contract::Hyper3dDownloadFilePayload,
) -> Result<Match3DDownloadedModel, AppError> {
let http_client = reqwest::Client::builder()
.timeout(Duration::from_millis(
MATCH3D_LEGACY_MODEL_DOWNLOAD_TIMEOUT_MS,
))
.build()
.map_err(|error| match3d_bad_gateway(format!("构造历史模型下载客户端失败:{error}")))?;
tracing::info!(
provider = MATCH3D_AGENT_PROVIDER,
file_name = file.name.as_str(),
"抓大鹅历史 GLB 下载开始"
);
let response = http_client
.get(file.url.as_str())
.send()
.await
.map_err(|error| match3d_bad_gateway(format!("下载历史模型失败:{error}")))?;
let status = response.status();
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("model/gltf-binary")
.to_string();
let bytes = response
.bytes()
.await
.map_err(|error| match3d_bad_gateway(format!("读取历史模型内容失败:{error}")))?;
if !status.is_success() {
return Err(match3d_bad_gateway(format!(
"下载历史模型失败HTTP {}",
status.as_u16()
)));
}
if !is_match3d_downloaded_model_payload(file.name.as_str(), content_type.as_str()) {
return Err(match3d_bad_gateway("历史模型下载结果不是 GLB 模型文件"));
}
if bytes.is_empty() || bytes.len() > MATCH3D_LEGACY_MODEL_MAX_BYTES {
return Err(match3d_bad_gateway("历史模型内容为空或超过大小上限"));
}
if !is_match3d_glb_binary_payload(&bytes) {
return Err(match3d_bad_gateway("历史模型下载结果不是有效 GLB 模型文件"));
}
Ok(Match3DDownloadedModel {
bytes: bytes.to_vec(),
file_name: normalize_match3d_model_file_name(file.name.as_str()),
content_type: normalize_match3d_model_content_type(content_type.as_str()),
})
}
fn is_match3d_downloaded_model_payload(file_name: &str, content_type: &str) -> bool {
let normalized_file_name = file_name.to_ascii_lowercase();
let normalized_content_type = content_type
.split(';')
.next()
.unwrap_or(content_type)
.trim()
.to_ascii_lowercase();
normalized_file_name.ends_with(".glb")
|| matches!(
normalized_content_type.as_str(),
"model/gltf-binary" | "application/octet-stream"
)
}
pub(super) fn normalize_match3d_model_file_name(raw: &str) -> String {
let trimmed = raw.trim().rsplit('/').next().unwrap_or(raw).trim();
let without_query = trimmed.split('?').next().unwrap_or(trimmed).trim();
let normalized = without_query.to_ascii_lowercase();
let stem = without_query
.strip_suffix(".glb")
.or_else(|| {
normalized
.strip_suffix(".glb")
.map(|_| &without_query[..without_query.len().saturating_sub(4)])
})
.unwrap_or(without_query);
let sanitized_stem = sanitize_match3d_asset_segment(stem, "model");
format!("{sanitized_stem}.glb")
}
pub(super) fn normalize_match3d_model_content_type(raw: &str) -> String {
let normalized = raw.split(';').next().unwrap_or(raw).trim().to_lowercase();
if normalized == "model/gltf-binary" {
return normalized;
}
"model/gltf-binary".to_string()
}
pub(super) fn is_match3d_glb_binary_payload(bytes: &[u8]) -> bool {
if bytes.len() < 12 {
return false;
}
let magic = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
let version = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
let declared_length = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize;
magic == 0x4654_6c67 && version == 2 && declared_length == bytes.len()
}
pub(super) async fn read_match3d_generated_object_bytes(
state: &AppState,
object_key: &str,
message_prefix: &str,
max_size_bytes: usize,
) -> Result<Vec<u8>, AppError> {
let object_key = object_key.trim().trim_start_matches('/');
if object_key.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "match3d-assets",
"message": format!("{message_prefix}objectKey 不能为空"),
})),
);
}
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let signed = oss_client
.sign_get_object_url(platform_oss::OssSignedGetObjectUrlRequest {
object_key: object_key.to_string(),
expire_seconds: Some(300),
})
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
let response = reqwest::Client::new()
.get(signed.signed_url.as_str())
.send()
.await
.map_err(|error| match3d_bad_gateway(format!("{message_prefix}{error}")))?;
let status = response.status();
if !status.is_success() {
return Err(match3d_bad_gateway(format!(
"{message_prefix}HTTP {}",
status.as_u16()
)));
}
let bytes = response
.bytes()
.await
.map_err(|error| match3d_bad_gateway(format!("{message_prefix}{error}")))?;
if bytes.is_empty() || bytes.len() > max_size_bytes {
return Err(match3d_bad_gateway(format!(
"{message_prefix}:内容为空或超过大小上限"
)));
}
Ok(bytes.to_vec())
}
pub(super) fn normalize_match3d_public_reference_image_path(source: &str) -> Option<String> {
let source = source
.trim()
.split('?')
.next()
.unwrap_or_default()
.trim()
.trim_start_matches('/');
if !source.starts_with("match3d-background-references/") {
return None;
}
if source.contains("..") || source.contains('\\') {
return None;
}
let lower = source.to_ascii_lowercase();
if !matches!(
lower.rsplit('.').next(),
Some("png" | "jpg" | "jpeg" | "webp")
) {
return None;
}
Some(format!(
"{MATCH3D_PUBLIC_REFERENCE_IMAGE_PATH_PREFIX}{source}"
))
}
pub(super) fn collect_match3d_cover_reference_image_sources(
legacy_reference_image_src: Option<String>,
reference_image_srcs: Vec<String>,
) -> Vec<String> {
let mut sources = Vec::new();
for source in legacy_reference_image_src
.into_iter()
.chain(reference_image_srcs)
{
let normalized = source.trim();
if normalized.is_empty() {
continue;
}
if !sources
.iter()
.any(|existing: &String| existing == normalized)
{
sources.push(normalized.to_string());
}
if sources.len() >= 6 {
break;
}
}
sources
}
async fn resolve_match3d_cover_reference_images_for_edit(
state: &AppState,
sources: Vec<String>,
max_size_bytes: usize,
) -> Result<Vec<OpenAiReferenceImage>, AppError> {
let mut resolved = Vec::new();
for (index, source) in sources.into_iter().enumerate() {
if let Some(image) = resolve_match3d_reference_image_for_edit(
state,
Some(source.as_str()),
max_size_bytes,
format!("match3d-cover-reference-{index}").as_str(),
)
.await?
{
resolved.push(image);
}
}
Ok(resolved)
}
async fn resolve_match3d_reference_image_for_edit(
state: &AppState,
source: Option<&str>,
max_size_bytes: usize,
file_name_prefix: &str,
) -> Result<Option<OpenAiReferenceImage>, AppError> {
let Some(source) = source.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
let bytes = if source.starts_with("data:image/") {
decode_match3d_data_url_bytes(source)?
} else if let Some(public_path) = normalize_match3d_public_reference_image_path(source) {
tokio::fs::read(public_path.as_str())
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": MATCH3D_WORKS_PROVIDER,
"message": format!("读取抓大鹅本地参考图失败:{error}"),
"path": public_path,
}))
})?
} else if source.trim_start_matches('/').starts_with("generated-") {
read_match3d_generated_object_bytes(
state,
source,
"读取抓大鹅封面上传图失败",
max_size_bytes,
)
.await?
} else {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": MATCH3D_WORKS_PROVIDER,
"field": "uploadedImageSrc",
"message": "封面参考图必须是图片 Data URL、本地 public 参考图或 /generated-* 路径。",
})),
);
};
if bytes.is_empty() || bytes.len() > max_size_bytes {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": MATCH3D_WORKS_PROVIDER,
"field": "uploadedImageSrc",
"message": "封面上传图过大,请压缩后重试。",
"maxBytes": max_size_bytes,
"actualBytes": bytes.len(),
})),
);
}
let mime_type = infer_match3d_image_mime_type(bytes.as_slice()).to_string();
Ok(Some(OpenAiReferenceImage {
file_name: format!(
"{}.{}",
file_name_prefix,
match3d_mime_to_extension(mime_type.as_str())
),
mime_type,
bytes,
}))
}
pub(super) fn decode_match3d_data_url_bytes(source: &str) -> Result<Vec<u8>, AppError> {
let Some((header, data)) = source.split_once(',') else {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": MATCH3D_WORKS_PROVIDER,
"field": "uploadedImageSrc",
"message": "图片 Data URL 格式不正确。",
})),
);
};
if !header.starts_with("data:image/") || !header.contains(";base64") {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": MATCH3D_WORKS_PROVIDER,
"field": "uploadedImageSrc",
"message": "图片 Data URL 必须是 base64 图片。",
})),
);
}
BASE64_STANDARD.decode(data.trim()).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": MATCH3D_WORKS_PROVIDER,
"field": "uploadedImageSrc",
"message": format!("图片 Data URL 解码失败:{error}"),
}))
})
}
pub(super) fn infer_match3d_image_mime_type(bytes: &[u8]) -> &'static str {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
return "image/png";
}
if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
return "image/jpeg";
}
if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
return "image/webp";
}
"image/png"
}
pub(super) async fn persist_match3d_generated_bytes(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
path_segments: &[&str],
file_name: &str,
content_type: &str,
bytes: Vec<u8>,
asset_kind: &str,
source_job_id: Option<&str>,
generated_at_micros: i64,
) -> Result<Match3DAssetUpload, AppError> {
let oss_client = require_match3d_oss_client(state)?;
let mut metadata = BTreeMap::new();
metadata.insert("x-oss-meta-asset-kind".to_string(), asset_kind.to_string());
metadata.insert(
"x-oss-meta-owner-user-id".to_string(),
owner_user_id.to_string(),
);
metadata.insert("x-oss-meta-profile-id".to_string(), profile_id.to_string());
if let Some(source_job_id) = source_job_id.filter(|value| !value.trim().is_empty()) {
metadata.insert(
"x-oss-meta-source-job-id".to_string(),
source_job_id.to_string(),
);
}
let oss_http_client = reqwest::Client::builder()
.timeout(Duration::from_millis(MATCH3D_OSS_PUT_TIMEOUT_MS))
.build()
.map_err(|error| match3d_bad_gateway(format!("构造抓大鹅 OSS 上传客户端失败:{error}")))?;
let put_result = oss_client
.put_object(
&oss_http_client,
OssPutObjectRequest {
prefix: LegacyAssetPrefix::Match3DAssets,
path_segments: std::iter::once(session_id)
.chain(std::iter::once(profile_id))
.chain(path_segments.iter().copied())
.map(|segment| sanitize_match3d_asset_segment(segment, "asset"))
.collect(),
file_name: file_name.to_string(),
content_type: Some(content_type.to_string()),
access: OssObjectAccess::Private,
metadata,
body: bytes,
},
)
.await
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
let _ = generated_at_micros;
Ok(Match3DAssetUpload {
src: put_result.legacy_public_path,
object_key: put_result.object_key,
})
}
pub(super) fn require_match3d_oss_client(
state: &AppState,
) -> Result<&platform_oss::OssClient, AppError> {
state
.oss_client()
.ok_or_else(|| match3d_oss_config_error(&state.config))
}
fn match3d_oss_config_error(config: &AppConfig) -> AppError {
let missing = missing_match3d_oss_env_keys(config);
let reason = match3d_oss_missing_reason(&missing);
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": reason,
"missingEnv": missing,
}))
}
pub(super) fn match3d_oss_missing_reason(missing: &[&str]) -> String {
if missing.is_empty() {
"OSS 未完成环境变量配置".to_string()
} else {
format!("OSS 未完成环境变量配置,缺少:{}", missing.join(", "))
}
}
pub(super) fn missing_match3d_oss_env_keys(config: &AppConfig) -> Vec<&'static str> {
[
("ALIYUN_OSS_BUCKET", config.oss_bucket.as_deref()),
("ALIYUN_OSS_ENDPOINT", config.oss_endpoint.as_deref()),
(
"ALIYUN_OSS_ACCESS_KEY_ID",
config.oss_access_key_id.as_deref(),
),
(
"ALIYUN_OSS_ACCESS_KEY_SECRET",
config.oss_access_key_secret.as_deref(),
),
]
.into_iter()
.filter_map(|(name, value)| match value {
Some(value) if !value.trim().is_empty() => None,
_ => Some(name),
})
.collect()
}
pub(super) fn sanitize_match3d_asset_segment(raw: &str, fallback: &str) -> String {
let normalized = raw
.trim()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
let collapsed = normalized
.split('-')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-");
if collapsed.is_empty() {
fallback.to_string()
} else {
collapsed.chars().take(64).collect()
}
}