use std::{ collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}, }; use axum::{ Json, extract::{Extension, Path, State, rejection::JsonRejection}, http::{HeaderName, StatusCode, header}, response::Response, }; use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, }; use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; use serde_json::{Value, json}; use shared_contracts::wooden_fish::{ WoodenFishActionRequest, WoodenFishAudioAsset, WoodenFishCheckpointRunRequest, WoodenFishDraftResponse, WoodenFishFinishRunRequest, WoodenFishGalleryDetailResponse, WoodenFishGenerationStatus, WoodenFishImageAsset, WoodenFishRunResponse, WoodenFishSessionResponse, WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest, WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkspaceCreateRequest, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::SpacetimeClientError; use crate::generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, adapter::GeneratedImageAssetAdapterMetadata, adapter::GeneratedImageAssetPersistInput, decode_generated_image_asset_data_url, normalize_generated_image_asset_mime, }; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, openai_image_generation::{ DownloadedOpenAiImage, OpenAiReferenceImage, build_openai_image_http_client, create_openai_image_edit, create_openai_image_edit_with_references, require_openai_image_settings, }, platform_errors::map_oss_error, request_context::RequestContext, state::AppState, }; const WOODEN_FISH_PROVIDER: &str = "wooden-fish"; const WOODEN_FISH_CREATION_PROVIDER: &str = "wooden-fish-creation"; const WOODEN_FISH_RUNTIME_PROVIDER: &str = "wooden-fish-runtime"; const WOODEN_FISH_TEMPLATE_ID: &str = "wooden-fish"; const WOODEN_FISH_TEMPLATE_NAME: &str = "敲木鱼"; const DEFAULT_HIT_OBJECT_PROMPT: &str = "默认敲击物图案,圆润木质质感,透明背景"; const DEFAULT_HIT_OBJECT_ASSET_ID: &str = "wooden-fish-default-hit-object"; const DEFAULT_HIT_OBJECT_IMAGE_SRC: &str = "/wooden-fish/default-hit-object.png"; const DEFAULT_HIT_SOUND_ASSET_ID: &str = "wooden-fish-default-hit-sound"; const DEFAULT_HIT_SOUND_AUDIO_SRC: &str = "/wooden-fish/default-hit-sound.mp3"; const WOODEN_FISH_ENTITY_KIND: &str = "wooden_fish_work"; const WOODEN_FISH_HIT_OBJECT_SLOT: &str = "hit_object"; const WOODEN_FISH_HIT_OBJECT_ASSET_KIND: &str = "wooden_fish_hit_object"; const WOODEN_FISH_BACKGROUND_SLOT: &str = "background"; const WOODEN_FISH_BACKGROUND_ASSET_KIND: &str = "wooden_fish_background"; const WOODEN_FISH_BACK_BUTTON_SLOT: &str = "back_button"; const WOODEN_FISH_BACK_BUTTON_ASSET_KIND: &str = "wooden_fish_back_button"; const WOODEN_FISH_HIT_SOUND_DURATION_SECONDS: u8 = 3; const DEFAULT_HIT_OBJECT_REFERENCE_BYTES: &[u8] = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../../public/wooden-fish/default-hit-object.png" )); pub async fn create_wooden_fish_session( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_CREATION_PROVIDER)?; validate_workspace_request(&request_context, &payload)?; let owner_user_id = authenticated.claims().user_id().to_string(); let session_id = build_prefixed_uuid_id("wooden-fish-session-"); let now = current_utc_micros(); let draft = build_wooden_fish_draft(&payload); let session = WoodenFishSessionSnapshotResponse { session_id, owner_user_id, status: WoodenFishGenerationStatus::Draft, draft: Some(draft), created_at: format_timestamp_micros(now), updated_at: format_timestamp_micros(now), }; Ok(json_success_body( Some(&request_context), WoodenFishSessionResponse { session: state .spacetime_client() .create_wooden_fish_session(session) .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_CREATION_PROVIDER, map_wooden_fish_client_error(error), ) })?, }, )) } pub async fn get_wooden_fish_session( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &session_id, "sessionId")?; let owner_user_id = authenticated.claims().user_id().to_string(); let session = state .spacetime_client() .get_wooden_fish_session(session_id, owner_user_id) .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_CREATION_PROVIDER, map_wooden_fish_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), WoodenFishSessionResponse { session }, )) } pub async fn execute_wooden_fish_action( State(state): State, Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, &session_id, "sessionId")?; let Json(mut payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_CREATION_PROVIDER)?; let owner_user_id = authenticated.claims().user_id().to_string(); maybe_generate_hit_object_asset( &state, &request_context, &session_id, owner_user_id.as_str(), &mut payload, ) .await?; maybe_generate_hit_sound_asset(&mut payload); let response = state .spacetime_client() .execute_wooden_fish_action(session_id, owner_user_id, payload) .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_CREATION_PROVIDER, map_wooden_fish_client_error(error), ) })?; Ok(json_success_body(Some(&request_context), response)) } pub async fn publish_wooden_fish_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &profile_id, "profileId")?; let work = state .spacetime_client() .publish_wooden_fish_work(profile_id, authenticated.claims().user_id().to_string()) .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_CREATION_PROVIDER, map_wooden_fish_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), WoodenFishWorkMutationResponse { item: work }, )) } pub async fn get_wooden_fish_runtime_work( State(state): State, Path(profile_id): Path, Extension(request_context): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &profile_id, "profileId")?; let work = state .spacetime_client() .get_wooden_fish_runtime_work(profile_id) .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_RUNTIME_PROVIDER, map_wooden_fish_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), WoodenFishWorkDetailResponse { item: work }, )) } pub async fn start_wooden_fish_run( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_RUNTIME_PROVIDER)?; ensure_non_empty(&request_context, &payload.profile_id, "profileId")?; let run = state .spacetime_client() .start_wooden_fish_run(payload, authenticated.claims().user_id().to_string()) .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_RUNTIME_PROVIDER, map_wooden_fish_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), WoodenFishRunResponse { run }, )) } pub async fn checkpoint_wooden_fish_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, &run_id, "runId")?; let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_RUNTIME_PROVIDER)?; let run = state .spacetime_client() .checkpoint_wooden_fish_run( run_id, authenticated.claims().user_id().to_string(), payload, ) .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_RUNTIME_PROVIDER, map_wooden_fish_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), WoodenFishRunResponse { run }, )) } pub async fn finish_wooden_fish_run( State(state): State, Path(run_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { ensure_non_empty(&request_context, &run_id, "runId")?; let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_RUNTIME_PROVIDER)?; let run = state .spacetime_client() .finish_wooden_fish_run( run_id, authenticated.claims().user_id().to_string(), payload, ) .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_RUNTIME_PROVIDER, map_wooden_fish_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), WoodenFishRunResponse { run }, )) } pub async fn list_wooden_fish_gallery( State(state): State, Extension(request_context): Extension, ) -> Result, Response> { let gallery = state .spacetime_client() .list_wooden_fish_gallery() .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_RUNTIME_PROVIDER, map_wooden_fish_client_error(error), ) })?; Ok(json_success_body(Some(&request_context), gallery)) } pub async fn get_wooden_fish_gallery_detail( State(state): State, Path(public_work_code): Path, Extension(request_context): Extension, ) -> Result, Response> { ensure_non_empty(&request_context, &public_work_code, "publicWorkCode")?; let work = state .spacetime_client() .get_wooden_fish_gallery_detail(public_work_code) .await .map_err(|error| { wooden_fish_error_response( &request_context, WOODEN_FISH_RUNTIME_PROVIDER, map_wooden_fish_client_error(error), ) })?; Ok(json_success_body( Some(&request_context), WoodenFishGalleryDetailResponse { item: work }, )) } fn build_wooden_fish_draft(payload: &WoodenFishWorkspaceCreateRequest) -> WoodenFishDraftResponse { WoodenFishDraftResponse { template_id: WOODEN_FISH_TEMPLATE_ID.to_string(), template_name: WOODEN_FISH_TEMPLATE_NAME.to_string(), profile_id: None, work_title: payload.work_title.trim().to_string(), work_description: payload.work_description.trim().to_string(), theme_tags: normalize_tags(payload.theme_tags.clone()), hit_object_prompt: clean_string(&payload.hit_object_prompt, DEFAULT_HIT_OBJECT_PROMPT), hit_object_reference_image_src: payload .hit_object_reference_image_src .as_ref() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()), hit_sound_prompt: None, floating_words: normalize_floating_words(payload.floating_words.clone()), hit_object_asset: None, background_asset: None, back_button_asset: None, hit_sound_asset: payload .hit_sound_asset .clone() .or_else(|| Some(default_wooden_fish_hit_sound_asset())), cover_image_src: None, generation_status: WoodenFishGenerationStatus::Draft, } } fn validate_workspace_request( request_context: &RequestContext, payload: &WoodenFishWorkspaceCreateRequest, ) -> Result<(), Response> { ensure_non_empty(request_context, &payload.work_title, "workTitle")?; if payload.template_id.trim() != WOODEN_FISH_TEMPLATE_ID { return Err(wooden_fish_error_response( request_context, WOODEN_FISH_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": WOODEN_FISH_PROVIDER, "message": "templateId 必须为 wooden-fish", })), )); } Ok(()) } async fn maybe_generate_hit_object_asset( state: &AppState, request_context: &RequestContext, session_id: &str, owner_user_id: &str, payload: &mut WoodenFishActionRequest, ) -> Result<(), Response> { if !matches!( payload.action_type, shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft | shared_contracts::wooden_fish::WoodenFishActionType::RegenerateHitObject ) { return Ok(()); } if payload.hit_object_asset.is_some() && payload.background_asset.is_some() && payload.back_button_asset.is_some() { return Ok(()); } let profile_id = resolve_hit_object_profile_id(state, request_context, session_id, owner_user_id, payload) .await?; payload.profile_id = Some(profile_id.clone()); let prompt = payload .hit_object_prompt .as_deref() .map(|value| clean_string(value, DEFAULT_HIT_OBJECT_PROMPT)) .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| DEFAULT_HIT_OBJECT_PROMPT.to_string()); let generated = generate_wooden_fish_image_assets( state, owner_user_id, session_id, profile_id.as_str(), prompt.as_str(), payload.hit_object_reference_image_src.as_deref(), ) .await .map_err(|error| { wooden_fish_error_response(request_context, WOODEN_FISH_CREATION_PROVIDER, error) })?; payload.hit_object_asset = Some(generated.hit_object_asset); payload.background_asset = Some(generated.background_asset); payload.back_button_asset = Some(generated.back_button_asset); Ok(()) } fn default_wooden_fish_hit_object_asset() -> WoodenFishImageAsset { WoodenFishImageAsset { asset_id: DEFAULT_HIT_OBJECT_ASSET_ID.to_string(), image_src: DEFAULT_HIT_OBJECT_IMAGE_SRC.to_string(), image_object_key: "public/wooden-fish/default-hit-object.png".to_string(), asset_object_id: DEFAULT_HIT_OBJECT_ASSET_ID.to_string(), generation_provider: "bundled-default".to_string(), prompt: DEFAULT_HIT_OBJECT_PROMPT.to_string(), width: 1024, height: 1024, } } fn default_wooden_fish_hit_sound_asset() -> WoodenFishAudioAsset { WoodenFishAudioAsset { asset_id: DEFAULT_HIT_SOUND_ASSET_ID.to_string(), audio_src: DEFAULT_HIT_SOUND_AUDIO_SRC.to_string(), audio_object_key: "public/wooden-fish/default-hit-sound.mp3".to_string(), asset_object_id: DEFAULT_HIT_SOUND_ASSET_ID.to_string(), source: "bundled-default".to_string(), prompt: Some("默认木鱼音".to_string()), duration_ms: Some(u32::from(WOODEN_FISH_HIT_SOUND_DURATION_SECONDS) * 1_000), } } fn is_default_hit_object_prompt(prompt: &str) -> bool { let normalized = normalize_hit_object_prompt_for_default_match(prompt); normalized.is_empty() || normalized == normalize_hit_object_prompt_for_default_match(DEFAULT_HIT_OBJECT_PROMPT) || normalized == normalize_hit_object_prompt_for_default_match("卡通木鱼,圆润可爱,透明背景") || normalized == normalize_hit_object_prompt_for_default_match("卡通木鱼,透明背景,居中,圆润可爱") || normalized == normalize_hit_object_prompt_for_default_match("卡通木鱼") } fn normalize_hit_object_prompt_for_default_match(prompt: &str) -> String { prompt .chars() .filter(|ch| !ch.is_whitespace() && !matches!(ch, ',' | ',' | '。' | '.')) .collect::() } async fn resolve_hit_object_profile_id( state: &AppState, request_context: &RequestContext, session_id: &str, owner_user_id: &str, payload: &WoodenFishActionRequest, ) -> Result { if let Some(profile_id) = payload .profile_id .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()) { return Ok(profile_id.to_string()); } if matches!( payload.action_type, shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft ) { return Ok(build_prefixed_uuid_id("wooden-fish-profile-")); } let session = state .spacetime_client() .get_wooden_fish_session(session_id.to_string(), owner_user_id.to_string()) .await .map_err(|error| { wooden_fish_error_response( request_context, WOODEN_FISH_CREATION_PROVIDER, map_wooden_fish_client_error(error), ) })?; session .draft .and_then(|draft| draft.profile_id) .filter(|value| !value.trim().is_empty()) .ok_or_else(|| { wooden_fish_error_response( request_context, WOODEN_FISH_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": WOODEN_FISH_PROVIDER, "message": "wooden-fish action 需要先完成 compile-draft", })), ) }) } fn maybe_generate_hit_sound_asset(payload: &mut WoodenFishActionRequest) { if !matches!( payload.action_type, shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft | shared_contracts::wooden_fish::WoodenFishActionType::GenerateHitSound | shared_contracts::wooden_fish::WoodenFishActionType::ReplaceHitSound ) { return; } payload.hit_sound_prompt = None; if payload.hit_sound_asset.is_some() { return; } payload.hit_sound_asset = Some(default_wooden_fish_hit_sound_asset()); } struct WoodenFishGeneratedImageAssets { hit_object_asset: WoodenFishImageAsset, background_asset: WoodenFishImageAsset, back_button_asset: WoodenFishImageAsset, } async fn generate_wooden_fish_image_assets( state: &AppState, owner_user_id: &str, session_id: &str, profile_id: &str, prompt: &str, hit_object_reference_image_src: Option<&str>, ) -> Result { let settings = require_openai_image_settings(state)?; let http_client = build_openai_image_http_client(&settings)?; let clean_reference_image_src = hit_object_reference_image_src .map(str::trim) .filter(|value| !value.is_empty()); let theme = resolve_wooden_fish_generation_theme(prompt, clean_reference_image_src); let default_reference_image = default_wooden_fish_reference_image()?; let theme_reference_image = resolve_wooden_fish_theme_reference_image(clean_reference_image_src)?; let (hit_object_asset, hit_object_reference_image) = if should_generate_wooden_fish_hit_object(prompt, clean_reference_image_src) { let hit_object_prompt = build_wooden_fish_hit_object_prompt(theme.as_str()); let mut reference_images = vec![default_reference_image.clone()]; if let Some(reference_image) = theme_reference_image { reference_images.push(reference_image); } let generated = create_openai_image_edit_with_references( &http_client, &settings, hit_object_prompt.as_str(), None, "1:1", 1, reference_images.as_slice(), "生成敲木鱼敲击物图案失败", ) .await?; let task_id = generated.task_id.clone(); let image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "生成敲木鱼敲击物图案失败:上游未返回图片", })) })?; let image = prepare_wooden_fish_hit_object_image_for_persist(image)?; let hit_object_reference_image = downloaded_wooden_fish_reference_image( &image, "wooden-fish-generated-hit-object-transparent", ); let hit_object_asset = persist_wooden_fish_image_asset( state, owner_user_id, session_id, profile_id, task_id.as_str(), hit_object_prompt.as_str(), image, current_utc_micros(), WoodenFishImageSlotPersistSpec { slot: WOODEN_FISH_HIT_OBJECT_SLOT, asset_kind: WOODEN_FISH_HIT_OBJECT_ASSET_KIND, asset_id_part: "hit-object", width: 1024, height: 1024, }, ) .await?; (hit_object_asset, hit_object_reference_image) } else { ( default_wooden_fish_hit_object_asset(), default_reference_image, ) }; let background_prompt = build_wooden_fish_background_prompt(theme.as_str()); let background_generated = create_openai_image_edit( &http_client, &settings, background_prompt.as_str(), None, "9:16", &hit_object_reference_image, "生成敲木鱼背景环境图失败", ) .await?; let background_task_id = background_generated.task_id.clone(); let background_image = background_generated .images .into_iter() .next() .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "生成敲木鱼背景环境图失败:上游未返回图片", })) })?; let background_reference_image = downloaded_wooden_fish_reference_image(&background_image, "wooden-fish-generated-background"); let background_asset = persist_wooden_fish_image_asset( state, owner_user_id, session_id, profile_id, background_task_id.as_str(), background_prompt.as_str(), background_image, current_utc_micros(), WoodenFishImageSlotPersistSpec { slot: WOODEN_FISH_BACKGROUND_SLOT, asset_kind: WOODEN_FISH_BACKGROUND_ASSET_KIND, asset_id_part: "background", width: 1024, height: 1536, }, ) .await?; let back_button_prompt = build_wooden_fish_back_button_prompt(theme.as_str()); let back_button_generated = create_openai_image_edit_with_references( &http_client, &settings, back_button_prompt.as_str(), None, "1:1", 1, &[ hit_object_reference_image.clone(), background_reference_image, ], "生成敲木鱼返回按钮图失败", ) .await?; let back_button_task_id = back_button_generated.task_id.clone(); let back_button_image = back_button_generated .images .into_iter() .next() .ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "生成敲木鱼返回按钮图失败:上游未返回图片", })) })?; let back_button_image = prepare_wooden_fish_green_screen_image_for_persist( back_button_image, "敲木鱼返回按钮图", )?; let back_button_asset = persist_wooden_fish_image_asset( state, owner_user_id, session_id, profile_id, back_button_task_id.as_str(), back_button_prompt.as_str(), back_button_image, current_utc_micros(), WoodenFishImageSlotPersistSpec { slot: WOODEN_FISH_BACK_BUTTON_SLOT, asset_kind: WOODEN_FISH_BACK_BUTTON_ASSET_KIND, asset_id_part: "back-button", width: 1024, height: 1024, }, ) .await?; Ok(WoodenFishGeneratedImageAssets { hit_object_asset, background_asset, back_button_asset, }) } fn build_wooden_fish_hit_object_prompt(prompt: &str) -> String { format!( "生成敲木鱼新样式,要求结构,画风与参考图保持高度一致,新样式颜色搭配使用新主题对应的颜色。尺寸1:1,先输出绿色背景主体图(纯绿色绿幕),背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影、无道具,主体完整居中,主体边缘必须干净,不要直接输出透明底。随后由服务端对绿色背景主体图做抠图去除绿色背景。最终结果只保留单个敲击物图案,禁止黑底、白底、棋盘格、纸板底或任何实底背景;主体本身不要使用与绿幕接近的纯绿色,若新主题天然包含绿色,请改用偏深、偏黄或偏蓝的绿色并与绿幕清晰区分。\n新主题为:{}", clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT) ) } fn build_wooden_fish_background_prompt(prompt: &str) -> String { format!( "生成敲木鱼背景,要求主题,画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。尺寸竖屏9:16。参考图必须是第一步敲击物抠图完成后的透明图,不继承任何绿色底色、绿幕底色或纯绿色画布,并要求最终输出完整不透明的背景环境图。中央主体预留区必须保持干净,画面中央 40% 区域禁止出现主题主体、主体局部特写、主体轮廓影子、重复元素或主题主体的局部碎片;主题元素只允许出现在外围氛围,不得把主题物品画在画面中央,也不要把主题物品作为背景中心装饰。\n主题为:{}", clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT) ) } fn build_wooden_fish_back_button_prompt(prompt: &str) -> String { format!( "生成敲木鱼左上角返回按钮图。要求以参考图-去除绿色背景后的敲击物主体和背景环境图为主题、画风、材质和配色参考,但参考图只用来约束圆形底色和中央左箭头的颜色搭配,不要继承复杂造型、花纹、浮雕边、异形外框或装饰图案。按钮必须始终是标准圆形,整体像单个圆形图标,圆心居中,圆形内部只保留一个清晰、简洁、居中的向左返回箭头,不要出现文字、数字、水印、按钮外标签、额外 UI 面板、木槌或敲击道具。尺寸1:1,输出绿色背景主体图(纯绿色绿幕),背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影。按钮主体边缘干净,后续由服务端扣除绿色背景;按钮底色不要使用与绿幕接近的纯绿色,若主题天然包含绿色,请仅在圆形底色上使用偏深、偏黄或偏蓝的主题绿色,并用更高对比的箭头颜色区分。\n主题为:{}", clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT) ) } fn should_generate_wooden_fish_hit_object( prompt: &str, hit_object_reference_image_src: Option<&str>, ) -> bool { hit_object_reference_image_src.is_some() || !is_default_hit_object_prompt(prompt) } fn resolve_wooden_fish_generation_theme( prompt: &str, hit_object_reference_image_src: Option<&str>, ) -> String { let prompt = clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT); if !is_default_hit_object_prompt(prompt.as_str()) { return prompt; } if hit_object_reference_image_src.is_some() { return "用户提供参考图".to_string(); } prompt } fn default_wooden_fish_reference_image() -> Result { let bytes = DEFAULT_HIT_OBJECT_REFERENCE_BYTES.to_vec(); if bytes.is_empty() { return Err( AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": WOODEN_FISH_CREATION_PROVIDER, "message": "敲木鱼默认参考图为空", })), ); } Ok(OpenAiReferenceImage { bytes, mime_type: "image/png".to_string(), file_name: "wooden-fish-default-hit-object-reference.png".to_string(), }) } fn resolve_wooden_fish_theme_reference_image( source: Option<&str>, ) -> Result, AppError> { let Some(source) = source.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(None); }; if !source.to_ascii_lowercase().starts_with("data:image/") { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": WOODEN_FISH_CREATION_PROVIDER, "field": "hitObjectReferenceImageSrc", "message": "敲木鱼参考图必须是 base64 图片 Data URL。", })), ); } let decoded = decode_generated_image_asset_data_url(source).map_err(|_| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": WOODEN_FISH_CREATION_PROVIDER, "field": "hitObjectReferenceImageSrc", "message": "敲木鱼参考图必须是 base64 图片 Data URL。", })) })?; Ok(Some(OpenAiReferenceImage { file_name: format!("wooden-fish-theme-reference.{}", decoded.format.extension), mime_type: decoded.format.mime_type, bytes: decoded.bytes, })) } fn downloaded_wooden_fish_reference_image( image: &DownloadedOpenAiImage, file_name_stem: &str, ) -> OpenAiReferenceImage { OpenAiReferenceImage { bytes: image.bytes.clone(), mime_type: image.mime_type.clone(), file_name: format!("{file_name_stem}.{}", image.extension), } } fn prepare_wooden_fish_hit_object_image_for_persist( image: DownloadedOpenAiImage, ) -> Result { prepare_wooden_fish_green_screen_image_for_persist(image, "敲木鱼敲击物图案") } fn prepare_wooden_fish_green_screen_image_for_persist( image: DownloadedOpenAiImage, failure_label: &str, ) -> Result { let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": WOODEN_FISH_CREATION_PROVIDER, "message": format!("{failure_label}解码失败:{error}"), })) })?; let mut encoded = std::io::Cursor::new(Vec::new()); crate::generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha(source) .write_to(&mut encoded, image::ImageFormat::Png) .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": WOODEN_FISH_CREATION_PROVIDER, "message": format!("{failure_label}绿幕去背失败:{error}"), })) })?; Ok(DownloadedOpenAiImage { bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }) } struct WoodenFishImageSlotPersistSpec { slot: &'static str, asset_kind: &'static str, asset_id_part: &'static str, width: u32, height: u32, } async fn persist_wooden_fish_image_asset( state: &AppState, owner_user_id: &str, session_id: &str, profile_id: &str, task_id: &str, prompt: &str, image: DownloadedOpenAiImage, generated_at_micros: i64, spec: WoodenFishImageSlotPersistSpec, ) -> Result { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": "OSS 未完成环境变量配置", })) })?; let http_client = reqwest::Client::new(); let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput { prefix: LegacyAssetPrefix::WoodenFishAssets, path_segments: vec![ sanitize_wooden_fish_asset_segment(session_id, "session"), sanitize_wooden_fish_asset_segment(profile_id, "profile"), spec.slot.to_string(), format!("asset-{generated_at_micros}"), ], file_stem: "image".to_string(), image: GeneratedImageAssetDataUrl { format: normalize_generated_image_asset_mime(image.mime_type.as_str()), bytes: image.bytes, }, access: OssObjectAccess::Private, metadata: GeneratedImageAssetAdapterMetadata { asset_kind: Some(spec.asset_kind.to_string()), owner_user_id: Some(owner_user_id.to_string()), entity_kind: Some(WOODEN_FISH_ENTITY_KIND.to_string()), entity_id: Some(profile_id.to_string()), slot: Some(spec.slot.to_string()), provider: Some("image2".to_string()), task_id: Some(task_id.to_string()), }, extra_metadata: BTreeMap::from([ ("profile_id".to_string(), profile_id.to_string()), ("session_id".to_string(), session_id.to_string()), ]), }) .map_err(map_wooden_fish_generated_image_asset_error)?; let persisted_mime_type = prepared.format.mime_type.clone(); let put_result = oss_client .put_object(&http_client, prepared.request) .await .map_err(map_wooden_fish_asset_oss_error)?; let head = oss_client .head_object( &http_client, OssHeadObjectRequest { object_key: put_result.object_key.clone(), }, ) .await .map_err(map_wooden_fish_asset_oss_error)?; let asset_object = state .spacetime_client() .confirm_asset_object( build_asset_object_upsert_input( generate_asset_object_id(generated_at_micros), head.bucket, head.object_key.clone(), AssetObjectAccessPolicy::Private, head.content_type.or(Some(persisted_mime_type)), head.content_length, head.etag, spec.asset_kind.to_string(), Some(task_id.to_string()), Some(owner_user_id.to_string()), Some(profile_id.to_string()), Some(profile_id.to_string()), generated_at_micros, ) .map_err(map_wooden_fish_asset_field_error)?, ) .await .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) })?; if let Err(error) = state .spacetime_client() .bind_asset_object_to_entity( build_asset_entity_binding_input( generate_asset_binding_id(generated_at_micros), asset_object.asset_object_id.clone(), WOODEN_FISH_ENTITY_KIND.to_string(), profile_id.to_string(), spec.slot.to_string(), spec.asset_kind.to_string(), Some(owner_user_id.to_string()), Some(profile_id.to_string()), generated_at_micros, ) .map_err(map_wooden_fish_asset_field_error)?, ) .await { tracing::warn!( provider = "spacetimedb", owner_user_id, session_id, profile_id, slot = spec.slot, error = %error, "敲木鱼图片资产绑定失败,历史素材索引可能缺少绑定记录" ); } Ok(WoodenFishImageAsset { asset_id: format!("{profile_id}-{}-{generated_at_micros}", spec.asset_id_part), image_src: put_result.legacy_public_path, image_object_key: head.object_key, asset_object_id: asset_object.asset_object_id, generation_provider: "image2".to_string(), prompt: prompt.to_string(), width: spec.width, height: spec.height, }) } fn map_wooden_fish_generated_image_asset_error( error: crate::generated_image_assets::helpers::GeneratedImageAssetHelperError, ) -> AppError { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "generated-image-assets", "message": format!("准备敲木鱼图片资产上传请求失败:{error:?}"), })) } fn map_wooden_fish_asset_oss_error(error: platform_oss::OssError) -> AppError { map_oss_error(error, "aliyun-oss") } fn map_wooden_fish_asset_field_error(error: AssetObjectFieldError) -> AppError { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "wooden-fish-assets", "message": error.to_string(), })) } fn sanitize_wooden_fish_asset_segment(value: &str, fallback: &str) -> String { let sanitized = value .trim() .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) { ch } else { '-' } }) .collect::() .trim_matches('-') .to_string(); if sanitized.is_empty() { fallback.to_string() } else { sanitized } } fn ensure_non_empty( request_context: &RequestContext, value: &str, field: &str, ) -> Result<(), Response> { if value.trim().is_empty() { return Err(wooden_fish_error_response( request_context, WOODEN_FISH_PROVIDER, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": WOODEN_FISH_PROVIDER, "field": field, "message": format!("{field} 不能为空"), })), )); } Ok(()) } fn clean_string(value: &str, fallback: &str) -> String { let value = value.trim(); if value.is_empty() { fallback.to_string() } else { value.to_string() } } fn normalize_tags(tags: Vec) -> Vec { let mut normalized = Vec::new(); for tag in tags { let tag = tag.trim(); if tag.is_empty() || normalized.iter().any(|item| item == tag) { continue; } normalized.push(tag.to_string()); if normalized.len() >= 6 { break; } } normalized } fn normalize_floating_words(words: Vec) -> Vec { let mut normalized = Vec::new(); for word in words { let word = normalize_floating_word(&word); if word.is_empty() || normalized.iter().any(|item| item == &word) { continue; } normalized.push(word); if normalized.len() >= 8 { break; } } if normalized.is_empty() { vec![ "幸运".to_string(), "健康".to_string(), "财富".to_string(), "姻缘".to_string(), "幸福".to_string(), "事业".to_string(), "成功".to_string(), "功德".to_string(), ] } else { normalized } } fn normalize_floating_word(word: &str) -> String { word.trim() .trim_end_matches(|ch: char| ch == '1' || ch.is_whitespace()) .trim_end_matches(['+', '+']) .trim() .to_string() } fn wooden_fish_json( payload: Result, JsonRejection>, request_context: &RequestContext, provider: &str, ) -> Result, Response> { payload.map_err(|error| { wooden_fish_error_response( request_context, provider, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": provider, "message": error.to_string(), })), ) }) } fn map_wooden_fish_client_error(error: SpacetimeClientError) -> AppError { let status = match &error { SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, SpacetimeClientError::Procedure(message) if message.contains("不存在") || message.contains("not found") || message.contains("does not exist") => { StatusCode::NOT_FOUND } SpacetimeClientError::Procedure(message) if message.contains("发布需要") || message.contains("不能为空") || message.contains("必须") => { StatusCode::BAD_REQUEST } _ => StatusCode::BAD_GATEWAY, }; AppError::from_status(status).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })) } fn wooden_fish_error_response( request_context: &RequestContext, provider: &str, error: AppError, ) -> Response { let mut response = error.into_response_with_context(Some(request_context)); response.headers_mut().insert( HeaderName::from_static("x-genarrative-provider"), header::HeaderValue::from_str(provider) .unwrap_or_else(|_| header::HeaderValue::from_static("wooden-fish")), ); response } fn current_utc_micros() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_micros().min(i64::MAX as u128) as i64) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; #[test] fn wooden_fish_hit_object_prompt_uses_hidden_green_screen_flow() { let prompt = build_wooden_fish_hit_object_prompt("赛博莲花木鱼"); assert!(prompt.contains( "生成敲木鱼新样式,要求结构,画风与参考图保持高度一致,新样式颜色搭配使用新主题对应的颜色。" )); assert!(prompt.contains("尺寸1:1")); assert!(prompt.contains("绿色背景主体图")); assert!(prompt.contains("纯绿色绿幕")); assert!(prompt.contains("#00FF00")); assert!(prompt.contains("不要直接输出透明底")); assert!(prompt.contains("主体本身不要使用与绿幕接近的纯绿色")); assert!(prompt.contains("新主题为:赛博莲花木鱼")); } #[test] fn wooden_fish_background_prompt_uses_hidden_image2_flow() { let prompt = build_wooden_fish_background_prompt("苹果"); assert!(prompt.contains( "生成敲木鱼背景,要求主题,画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。" )); assert!(prompt.contains("尺寸竖屏9:16")); assert!(prompt.contains("抠图完成后的透明图")); assert!(prompt.contains("不继承任何绿色底色")); assert!(prompt.contains("完整不透明的背景环境图")); assert!(prompt.contains("中央主体预留区")); assert!(prompt.contains("禁止出现主题主体")); assert!(prompt.contains("苹果")); assert!(prompt.contains("不得把主题物品画在画面中央")); assert!(prompt.contains("主题为:苹果")); } #[test] fn wooden_fish_back_button_prompt_forces_plain_circular_icon() { let prompt = build_wooden_fish_back_button_prompt("玉米"); assert!(prompt.contains("参考图只用来约束圆形底色和中央左箭头的颜色搭配")); assert!(prompt.contains("按钮必须始终是标准圆形")); assert!(prompt.contains("圆形内部只保留一个清晰、简洁、居中的向左返回箭头")); assert!(prompt.contains("不要继承复杂造型、花纹、浮雕边、异形外框或装饰图案")); assert!(prompt.contains("不要出现文字、数字、水印、按钮外标签、额外 UI 面板、木槌或敲击道具")); assert!(prompt.contains("按钮底色不要使用与绿幕接近的纯绿色")); assert!(prompt.contains("主题为:玉米")); } #[test] fn wooden_fish_hit_object_prepare_removes_green_screen_background() { let width = 12; let height = 12; let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); for y in 4..8 { for x in 4..8 { image.put_pixel(x, y, image::Rgba([190, 70, 42, 255])); } } image.put_pixel(6, 6, image::Rgba([18, 14, 12, 255])); let mut encoded = std::io::Cursor::new(Vec::new()); image::DynamicImage::ImageRgba8(image) .write_to(&mut encoded, image::ImageFormat::Png) .expect("test image should encode"); let original_bytes = encoded.into_inner(); let processed = prepare_wooden_fish_hit_object_image_for_persist(DownloadedOpenAiImage { bytes: original_bytes.clone(), mime_type: "image/png".to_string(), extension: "png".to_string(), }); let processed = processed.expect("processed image should succeed"); let decoded = image::load_from_memory(processed.bytes.as_slice()) .expect("processed image should decode") .to_rgba8(); assert_eq!(processed.mime_type, "image/png"); assert_eq!(processed.extension, "png"); assert_eq!( decoded.get_pixel(0, 0).0[3], 0, "绿幕背景必须在入库前去除" ); assert_eq!(decoded.get_pixel(4, 4).0[3], 255); assert_eq!( decoded.get_pixel(6, 6).0[3], 255, "敲击物内部深色细节不能被当成背景抠除" ); assert_ne!(processed.bytes, original_bytes); } #[test] fn wooden_fish_theme_reference_image_decodes_data_url_for_image2() { let source = format!( "data:image/png;base64,{}", BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nreference") ); let image = resolve_wooden_fish_theme_reference_image(Some(source.as_str())) .expect("data url should parse") .expect("reference image should exist"); assert_eq!(image.mime_type, "image/png"); assert_eq!(image.file_name, "wooden-fish-theme-reference.png"); assert!(image.bytes.starts_with(b"\x89PNG\r\n\x1A\n")); } #[test] fn wooden_fish_theme_reference_image_rejects_non_data_url() { let error = resolve_wooden_fish_theme_reference_image(Some("/generated/example.png")) .expect_err("legacy path should not be accepted as direct image2 reference"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); assert!(error.body_text().contains("Data URL")); } #[test] fn wooden_fish_default_hit_object_uses_bundled_asset() { let asset = default_wooden_fish_hit_object_asset(); assert_eq!(asset.asset_id, DEFAULT_HIT_OBJECT_ASSET_ID); assert_eq!(asset.image_src, DEFAULT_HIT_OBJECT_IMAGE_SRC); assert_eq!(asset.generation_provider, "bundled-default"); assert_eq!(asset.width, 1024); assert_eq!(asset.height, 1024); } #[test] fn wooden_fish_default_prompt_matches_legacy_defaults() { assert!(is_default_hit_object_prompt(DEFAULT_HIT_OBJECT_PROMPT)); assert!(is_default_hit_object_prompt("卡通木鱼,圆润可爱,透明背景")); assert!(is_default_hit_object_prompt( "卡通木鱼,透明背景,居中,圆润可爱" )); assert!(is_default_hit_object_prompt("卡通木鱼")); assert!(!is_default_hit_object_prompt("赛博莲花木鱼")); } #[test] fn wooden_fish_asset_segment_sanitizes_for_oss_object_key() { assert_eq!( sanitize_wooden_fish_asset_segment("wooden-fish/profile:1", "fallback"), "wooden-fish-profile-1" ); assert_eq!( sanitize_wooden_fish_asset_segment(" ", "fallback"), "fallback" ); } #[test] fn wooden_fish_default_hit_sound_asset_uses_bundled_mp3() { let asset = default_wooden_fish_hit_sound_asset(); assert_eq!(asset.asset_id, "wooden-fish-default-hit-sound"); assert_eq!(asset.audio_src, "/wooden-fish/default-hit-sound.mp3"); assert_eq!( asset.audio_object_key, "public/wooden-fish/default-hit-sound.mp3" ); assert_eq!(asset.asset_object_id, "wooden-fish-default-hit-sound"); assert_eq!(asset.source, "bundled-default"); assert_eq!(asset.prompt.as_deref(), Some("默认木鱼音")); } #[test] fn wooden_fish_draft_uses_default_hit_sound_asset_and_ignores_prompt() { let payload = WoodenFishWorkspaceCreateRequest { template_id: WOODEN_FISH_TEMPLATE_ID.to_string(), work_title: "今日敲木鱼".to_string(), work_description: String::new(), theme_tags: vec!["敲木鱼".to_string()], hit_object_prompt: "金色木鱼".to_string(), hit_object_reference_image_src: None, hit_sound_prompt: Some("清脆木鱼声".to_string()), hit_sound_asset: None, floating_words: vec![], }; let draft = build_wooden_fish_draft(&payload); assert!(draft.hit_sound_prompt.is_none()); let asset = draft .hit_sound_asset .expect("default hit sound asset should be attached"); assert_eq!(asset.audio_src, "/wooden-fish/default-hit-sound.mp3"); assert_eq!(asset.prompt.as_deref(), Some("默认木鱼音")); } }