This commit is contained in:
2026-04-28 19:36:39 +08:00
parent a9febe7678
commit f0471a4f8d
206 changed files with 18456 additions and 10133 deletions

View File

@@ -31,9 +31,10 @@ use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::{
BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord,
BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord,
BigFishBackgroundBlueprintRecord, BigFishGameDraftRecord, BigFishLevelBlueprintRecord,
BigFishMessageSubmitRecordInput, BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput,
BigFishSessionRecord, BigFishWorkSummaryRecord, SpacetimeClientError,
BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, BigFishGameDraftRecord,
BigFishLevelBlueprintRecord, BigFishMessageSubmitRecordInput, BigFishRuntimeParamsRecord,
BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishWorkSummaryRecord,
SpacetimeClientError,
};
use tokio::time::sleep;
@@ -41,6 +42,12 @@ use crate::big_fish_agent_turn::{
BigFishAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
run_big_fish_agent_turn,
};
use crate::big_fish_draft_compiler::compile_big_fish_draft_with_fallback;
use crate::prompt::big_fish::{
BIG_FISH_DEFAULT_NEGATIVE_PROMPT, BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT,
build_big_fish_level_main_image_prompt, build_big_fish_level_motion_prompt,
build_big_fish_stage_background_prompt,
};
use crate::{
ai_generation_drafts::{
AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter,
@@ -48,6 +55,7 @@ use crate::{
api_response::json_success_body,
asset_billing::{consume_asset_operation_points, refund_asset_operation_points},
auth::AuthenticatedAccessToken,
character_visual_assets::try_apply_background_alpha_to_png,
http_error::AppError,
request_context::RequestContext,
state::AppState,
@@ -101,13 +109,13 @@ pub async fn get_big_fish_session(
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &session_id, "sessionId")?;
let session = state
.spacetime_client()
.get_big_fish_session(session_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
let session = load_big_fish_session_with_retry(
&state,
session_id,
authenticated.claims().user_id().to_string(),
)
.await
.map_err(|error| big_fish_error_response(&request_context, map_big_fish_client_error(error)))?;
Ok(json_success_body(
Some(&request_context),
@@ -145,13 +153,22 @@ pub async fn list_big_fish_gallery(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
let items = state
.spacetime_client()
.list_big_fish_gallery()
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
let items = match state.spacetime_client().list_big_fish_gallery().await {
Ok(items) => items,
Err(error) if should_soft_fallback_big_fish_gallery(&error) => {
tracing::warn!(
error = %error,
"大鱼吃小鱼公开广场读取失败,已按空广场降级返回"
);
Vec::new()
}
Err(error) => {
return Err(big_fish_error_response(
&request_context,
map_big_fish_client_error(error),
));
}
};
Ok(json_success_body(
Some(&request_context),
@@ -489,13 +506,8 @@ pub async fn execute_big_fish_action(
let session_result = match action.as_str() {
"big_fish_compile_draft" => {
compile_big_fish_draft_with_all_assets(
&state,
session_id.clone(),
owner_user_id.clone(),
now,
)
.await
compile_big_fish_draft_only(&state, session_id.clone(), owner_user_id.clone(), now)
.await
}
"big_fish_generate_level_main_image" => {
let asset_url = generate_big_fish_formal_asset(
@@ -734,9 +746,13 @@ fn map_big_fish_level_response(
level: level.level,
name: level.name,
one_line_fantasy: level.one_line_fantasy,
text_description: level.text_description,
silhouette_direction: level.silhouette_direction,
size_ratio: level.size_ratio,
visual_description: level.visual_description,
visual_prompt_seed: level.visual_prompt_seed,
idle_motion_description: level.idle_motion_description,
move_motion_description: level.move_motion_description,
motion_prompt_seed: level.motion_prompt_seed,
merge_source_level: level.merge_source_level,
prey_window: level.prey_window,
@@ -802,98 +818,88 @@ fn map_big_fish_asset_coverage_response(
}
}
async fn compile_big_fish_draft_with_all_assets(
async fn compile_big_fish_draft_only(
state: &AppState,
session_id: String,
owner_user_id: String,
now: i64,
) -> Result<BigFishSessionRecord, SpacetimeClientError> {
let session = state
.spacetime_client()
.compile_big_fish_draft(session_id.clone(), owner_user_id.clone(), now)
.await?;
let draft = session
.draft
.clone()
.ok_or_else(|| SpacetimeClientError::Runtime("大鱼吃小鱼玩法草稿尚未生成".to_string()))?;
// 点击生成草稿时一次性生成所有首版玩法资产,前端只负责展示进度和最终 session。
for level in &draft.levels {
let asset_url = generate_big_fish_formal_asset(
state,
&owner_user_id,
&session_id,
"level_main_image",
Some(level.level),
None,
current_utc_micros(),
)
.await
.map_err(|error| SpacetimeClientError::Runtime(error.message().to_string()))?;
state
.spacetime_client()
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
asset_kind: "level_main_image".to_string(),
level: Some(level.level),
motion_key: None,
asset_url: Some(asset_url),
generated_at_micros: current_utc_micros(),
})
.await?;
}
for level in &draft.levels {
for motion_key in ["idle_float", "move_swim"] {
let asset_url = generate_big_fish_formal_asset(
state,
&owner_user_id,
&session_id,
"level_motion",
Some(level.level),
Some(motion_key),
current_utc_micros(),
)
.await
.map_err(|error| SpacetimeClientError::Runtime(error.message().to_string()))?;
state
.spacetime_client()
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
asset_kind: "level_motion".to_string(),
level: Some(level.level),
motion_key: Some(motion_key.to_string()),
asset_url: Some(asset_url),
generated_at_micros: current_utc_micros(),
})
.await?;
}
}
let asset_url = generate_big_fish_formal_asset(
state,
&owner_user_id,
&session_id,
"stage_background",
None,
None,
current_utc_micros(),
)
.await
.map_err(|error| SpacetimeClientError::Runtime(error.message().to_string()))?;
// 中文注释:大鱼吃小鱼草稿阶段只负责编译结果页草稿,不在这一步串行生成主图、动作或背景。
// 这些资产统一留在结果页工坊按需触发,避免 compile action 因长耗时资产任务卡在首步等待态。
let session =
load_big_fish_session_with_retry(state, session_id.clone(), owner_user_id.clone()).await?;
let anchor_pack = map_record_anchor_pack_to_domain(&session.anchor_pack);
let compiled_draft =
compile_big_fish_draft_with_fallback(state.llm_client(), &anchor_pack).await;
let draft_json = serde_json::to_string(&compiled_draft).ok();
state
.spacetime_client()
.generate_big_fish_asset(BigFishAssetGenerateRecordInput {
.compile_big_fish_draft(BigFishDraftCompileRecordInput {
session_id,
owner_user_id,
asset_kind: "stage_background".to_string(),
level: None,
motion_key: None,
asset_url: Some(asset_url),
generated_at_micros: current_utc_micros(),
draft_json,
compiled_at_micros: now,
})
.await
}
async fn load_big_fish_session_with_retry(
state: &AppState,
session_id: String,
owner_user_id: String,
) -> Result<BigFishSessionRecord, SpacetimeClientError> {
let mut last_retryable_error = None;
for attempt in 0..2 {
match state
.spacetime_client()
.get_big_fish_session(session_id.clone(), owner_user_id.clone())
.await
{
Ok(session) => return Ok(session),
Err(error @ SpacetimeClientError::Timeout)
| Err(error @ SpacetimeClientError::ConnectDropped) => {
last_retryable_error = Some(error);
if attempt == 0 {
sleep(Duration::from_millis(250)).await;
continue;
}
}
Err(error) => return Err(error),
}
}
Err(last_retryable_error.unwrap_or(SpacetimeClientError::Timeout))
}
fn map_record_anchor_pack_to_domain(
anchor_pack: &BigFishAnchorPackRecord,
) -> module_big_fish::BigFishAnchorPack {
module_big_fish::BigFishAnchorPack {
gameplay_promise: map_record_anchor_item_to_domain(&anchor_pack.gameplay_promise),
ecology_visual_theme: map_record_anchor_item_to_domain(&anchor_pack.ecology_visual_theme),
growth_ladder: map_record_anchor_item_to_domain(&anchor_pack.growth_ladder),
risk_tempo: map_record_anchor_item_to_domain(&anchor_pack.risk_tempo),
}
}
fn map_record_anchor_item_to_domain(
anchor_item: &BigFishAnchorItemRecord,
) -> module_big_fish::BigFishAnchorItem {
module_big_fish::BigFishAnchorItem {
key: anchor_item.key.clone(),
label: anchor_item.label.clone(),
value: anchor_item.value.clone(),
status: match anchor_item.status.as_str() {
"confirmed" => module_big_fish::BigFishAnchorStatus::Confirmed,
"locked" => module_big_fish::BigFishAnchorStatus::Locked,
"inferred" => module_big_fish::BigFishAnchorStatus::Inferred,
_ => module_big_fish::BigFishAnchorStatus::Missing,
},
}
}
fn map_big_fish_agent_message_response(
message: BigFishAgentMessageRecord,
) -> BigFishAgentMessageResponse {
@@ -960,12 +966,11 @@ struct BigFishFormalAssetContext {
asset_object_kind: String,
binding_slot: String,
path_segments: Vec<String>,
apply_transparent_background_post_process: bool,
}
const BIG_FISH_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash";
const BIG_FISH_ENTITY_KIND: &str = "big_fish_session";
const BIG_FISH_DEFAULT_NEGATIVE_PROMPT: &str = "文字水印logoUI界面对话框边框多余肢体畸形鱼体低清晰度模糊压缩噪点现代摄影棚写实照片背景复杂背景";
const BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT: &str = "文字水印logoUI界面对话框边框多余肢体畸形鱼体低清晰度模糊压缩噪点现代摄影棚写实照片背景场景背景水草背景气泡背景多只主体阴影地面";
async fn generate_big_fish_formal_asset(
state: &AppState,
@@ -1009,6 +1014,7 @@ async fn generate_big_fish_formal_asset(
&http_client,
generated.image_url.as_str(),
"下载 Big Fish 正式图片失败",
context.apply_transparent_background_post_process,
)
.await?;
@@ -1049,6 +1055,7 @@ fn build_big_fish_formal_asset_context(
level_part,
asset_id,
],
apply_transparent_background_post_process: true,
})
}
"level_motion" => {
@@ -1077,6 +1084,7 @@ fn build_big_fish_formal_asset_context(
sanitize_big_fish_path_segment(motion_key, "motion"),
asset_id,
],
apply_transparent_background_post_process: true,
})
}
"stage_background" => Ok(BigFishFormalAssetContext {
@@ -1091,6 +1099,7 @@ fn build_big_fish_formal_asset_context(
"stage-background".to_string(),
asset_id,
],
apply_transparent_background_post_process: false,
}),
_ => Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
@@ -1123,79 +1132,6 @@ fn find_big_fish_level_blueprint(
})
}
fn build_big_fish_level_main_image_prompt(
draft: &BigFishGameDraftRecord,
level: &BigFishLevelBlueprintRecord,
) -> String {
vec![
format!(
"为竖屏移动游戏《{}》生成一张等级生物主图。",
draft.title
),
format!(
"生态主题:{}。核心乐趣:{}",
draft.ecology_theme, draft.core_fun
),
format!(
"等级Lv.{},名称:{},幻想描述:{}",
level.level, level.name, level.one_line_fantasy
),
format!("轮廓方向:{}", level.silhouette_direction),
format!("视觉提示词种子:{}", level.visual_prompt_seed),
"画面要求:按 RPG 角色资产口径生成单体鱼形游戏生物完整入镜轮廓清晰中心构图2D 高完成度游戏插画,深海发光质感。".to_string(),
"背景要求:透明背景 PNG 风格不出现任何场景、水草、气泡、阴影地面、UI、文字、logo、水印、对话框或边框不要出现多只主体。".to_string(),
]
.join("")
}
fn build_big_fish_level_motion_prompt(
draft: &BigFishGameDraftRecord,
level: &BigFishLevelBlueprintRecord,
motion_key: &str,
) -> String {
let motion_text = match motion_key {
"move_swim" => "向右游动的关键帧预览,身体与尾鳍有清晰推进姿态,带轻微水流拖尾。",
_ => "待机漂浮的关键帧预览,身体轻微摆动,姿态稳定,适合作为 idle 状态。",
};
vec![
format!(
"为竖屏移动游戏《{}》生成一张等级生物动作关键帧静态预览图。",
draft.title
),
format!("生态主题:{}", draft.ecology_theme),
format!(
"等级Lv.{},名称:{},幻想描述:{}",
level.level, level.name, level.one_line_fantasy
),
format!("动作提示词种子:{}", level.motion_prompt_seed),
format!("动作要求:{motion_text}"),
"画面要求:按 RPG 角色动画资产口径生成单体鱼形生物完整入镜轮廓清晰动作方向明确2D 高完成度游戏插画,适合作为 Big Fish 动作槽位的静态 keyframe。".to_string(),
"背景要求:透明背景 PNG 风格不出现任何场景、水草、气泡、阴影地面、UI、文字、logo、水印、对话框或边框不要生成序列帧拼图不要出现多只主体。".to_string(),
]
.join("")
}
fn build_big_fish_stage_background_prompt(draft: &BigFishGameDraftRecord) -> String {
let background = &draft.background;
vec![
format!(
"为竖屏移动游戏《{}》生成一张 9:16 全屏活动区域背景。",
draft.title
),
format!("生态主题:{}", draft.ecology_theme),
format!("背景主题:{}。色彩氛围:{}", background.theme, background.color_mood),
format!("前景提示:{}", background.foreground_hints),
format!("中景构图:{}", background.midground_composition),
format!("背景纵深:{}", background.background_depth),
format!("安全操作区:{}", background.safe_play_area_hint),
format!("出生边缘:{}", background.spawn_edge_hint),
format!("背景提示词种子:{}", background.background_prompt_seed),
"画面要求:竖屏 9:16大场地全屏运行态背景中央 80% 保持开阔清爽,边缘只保留少量出生区环境提示。".to_string(),
"元素要求整体元素少不出现大型主体、密集装饰、鱼群主角、UI、文字、logo、水印、对话框或边框不要把中央操作区画得过暗或过复杂。".to_string(),
]
.join("")
}
fn require_big_fish_dashscope_settings(
state: &AppState,
) -> Result<BigFishDashScopeSettings, AppError> {
@@ -1372,6 +1308,7 @@ async fn download_big_fish_remote_image(
http_client: &reqwest::Client,
image_url: &str,
fallback_message: &str,
apply_transparent_background_post_process: bool,
) -> Result<BigFishDownloadedImage, AppError> {
let response = http_client.get(image_url).send().await.map_err(|error| {
map_big_fish_dashscope_request_error(format!("{fallback_message}{error}"))
@@ -1397,10 +1334,25 @@ async fn download_big_fish_remote_image(
}
let mime_type = normalize_big_fish_downloaded_image_mime_type(content_type.as_str());
let mut normalized_bytes = bytes.to_vec();
let mut normalized_mime_type = mime_type;
let mut extension = big_fish_mime_to_extension(normalized_mime_type.as_str()).to_string();
// 中文注释Big Fish 的等级主图与动作关键帧要和 RPG 角色主图保持同一后处理口径。
// 因此在上游已经输出 PNG 时,统一补一层透明背景 alpha 清理,避免只靠 prompt 约束导致残留底色。
if apply_transparent_background_post_process
&& normalized_mime_type == "image/png"
&& let Some(optimized) = try_apply_background_alpha_to_png(normalized_bytes.as_slice())
{
normalized_bytes = optimized;
normalized_mime_type = "image/png".to_string();
extension = "png".to_string();
}
Ok(BigFishDownloadedImage {
extension: big_fish_mime_to_extension(mime_type.as_str()).to_string(),
mime_type,
bytes: bytes.to_vec(),
extension,
mime_type: normalized_mime_type,
bytes: normalized_bytes,
})
}
@@ -1735,15 +1687,37 @@ fn map_big_fish_client_error(error: SpacetimeClientError) -> AppError {
StatusCode::BAD_REQUEST
}
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT,
_ => StatusCode::BAD_GATEWAY,
};
let message = match &error {
SpacetimeClientError::Timeout => "SpacetimeDB 会话读取超时,请稍后重试。".to_string(),
SpacetimeClientError::ConnectDropped => {
"SpacetimeDB 会话连接已断开,请稍后重试。".to_string()
}
_ => error.to_string(),
};
AppError::from_status(status).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
"message": message,
}))
}
fn should_soft_fallback_big_fish_gallery(error: &SpacetimeClientError) -> bool {
match error {
// 公开广场是首页可选数据SpacetimeDB procedure 运行态短暂失败时不应阻断平台首屏。
SpacetimeClientError::Runtime(_) | SpacetimeClientError::ConnectDropped => true,
SpacetimeClientError::Procedure(message) => {
message.contains("list_big_fish_works")
|| message.contains("procedure")
|| message.contains("No such procedure")
}
_ => false,
}
}
fn big_fish_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
@@ -1758,3 +1732,28 @@ fn current_utc_micros() -> i64 {
fn current_timestamp_micros_to_string(value: i64) -> String {
format_timestamp_micros(value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn big_fish_gallery_soft_fallbacks_for_runtime_errors() {
assert!(should_soft_fallback_big_fish_gallery(
&SpacetimeClientError::Runtime("procedure runtime error".to_string())
));
assert!(should_soft_fallback_big_fish_gallery(
&SpacetimeClientError::ConnectDropped
));
assert!(should_soft_fallback_big_fish_gallery(
&SpacetimeClientError::Procedure("No such procedure: list_big_fish_works".to_string(),)
));
}
#[test]
fn big_fish_gallery_keeps_timeout_errors_visible() {
assert!(!should_soft_fallback_big_fish_gallery(
&SpacetimeClientError::Timeout
));
}
}