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 { // 中文注释:封面生成是定向图片槽位更新,不能复用草稿编译路径重算题材、难度或素材 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 { 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 { 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 { 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::>(); 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, ) -> Result, 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 { 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, 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, reference_image_srcs: Vec, ) -> Result { 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 { 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 { 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 { 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 { // 中文注释:生产 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 { 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 { 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 { 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 { 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, 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 { 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, reference_image_srcs: Vec, ) -> Vec { 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, max_size_bytes: usize, ) -> Result, 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, 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, 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, asset_kind: &str, source_job_id: Option<&str>, generated_at_micros: i64, ) -> Result { 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::(); let collapsed = normalized .split('-') .filter(|part| !part.is_empty()) .collect::>() .join("-"); if collapsed.is_empty() { fallback.to_string() } else { collapsed.chars().take(64).collect() } }