Files
Genarrative/server-rs/crates/api-server/src/wooden_fish.rs

1381 lines
52 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<WoodenFishWorkspaceCreateRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<WoodenFishActionRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, 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<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<WoodenFishStartRunRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<WoodenFishCheckpointRunRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<WoodenFishFinishRunRequest>, JsonRejection>,
) -> Result<Json<Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, 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<AppState>,
Path(public_work_code): Path<String>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, 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::<String>()
}
async fn resolve_hit_object_profile_id(
state: &AppState,
request_context: &RequestContext,
session_id: &str,
owner_user_id: &str,
payload: &WoodenFishActionRequest,
) -> Result<String, Response> {
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<WoodenFishGeneratedImageAssets, AppError> {
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<OpenAiReferenceImage, AppError> {
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<Option<OpenAiReferenceImage>, 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<DownloadedOpenAiImage, AppError> {
prepare_wooden_fish_green_screen_image_for_persist(image, "敲木鱼敲击物图案")
}
fn prepare_wooden_fish_green_screen_image_for_persist(
image: DownloadedOpenAiImage,
failure_label: &str,
) -> Result<DownloadedOpenAiImage, AppError> {
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": 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<WoodenFishImageAsset, AppError> {
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::<String>()
.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<String>) -> Vec<String> {
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<String>) -> Vec<String> {
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<T>(
payload: Result<Json<T>, JsonRejection>,
request_context: &RequestContext,
provider: &str,
) -> Result<Json<T>, 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("默认木鱼音"));
}
}