1417 lines
51 KiB
Rust
1417 lines
51 KiB
Rust
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()
|
||
}
|
||
}
|