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

1309 lines
46 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,
vector_engine_audio_generation::{
GeneratedCreationAudioTarget, generate_sound_effect_asset_for_creation,
},
};
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_SOUND_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 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_HIT_SOUND_SLOT: &str = "hit_sound";
const WOODEN_FISH_HIT_SOUND_ASSET_KIND: &str = "wooden_fish_hit_sound";
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(
&state,
&request_context,
&session_id,
owner_user_id.as_str(),
&mut payload,
)
.await?;
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: payload
.hit_sound_prompt
.as_ref()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(|| Some(DEFAULT_HIT_SOUND_PROMPT.to_string())),
floating_words: normalize_floating_words(payload.floating_words.clone()),
hit_object_asset: None,
background_asset: None,
hit_sound_asset: payload.hit_sound_asset.clone(),
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() {
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);
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 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",
})),
)
})
}
async fn maybe_generate_hit_sound_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::GenerateHitSound
) {
return Ok(());
}
if matches!(
payload.action_type,
shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft
) && payload.hit_sound_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_sound_prompt
.as_deref()
.map(|value| clean_string(value, DEFAULT_HIT_SOUND_PROMPT))
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_HIT_SOUND_PROMPT.to_string());
let asset = generate_wooden_fish_hit_sound_asset(
state,
owner_user_id,
profile_id.as_str(),
prompt.as_str(),
)
.await
.map_err(|error| {
wooden_fish_error_response(request_context, WOODEN_FISH_CREATION_PROVIDER, error)
})?;
payload.hit_sound_asset = Some(asset);
Ok(())
}
async fn generate_wooden_fish_hit_sound_asset(
state: &AppState,
owner_user_id: &str,
profile_id: &str,
prompt: &str,
) -> Result<WoodenFishAudioAsset, AppError> {
let final_prompt = build_wooden_fish_hit_sound_prompt(prompt);
let generated = generate_sound_effect_asset_for_creation(
state,
owner_user_id,
final_prompt.clone(),
Some(WOODEN_FISH_HIT_SOUND_DURATION_SECONDS),
None,
GeneratedCreationAudioTarget {
entity_kind: WOODEN_FISH_ENTITY_KIND.to_string(),
entity_id: profile_id.to_string(),
slot: WOODEN_FISH_HIT_SOUND_SLOT.to_string(),
asset_kind: WOODEN_FISH_HIT_SOUND_ASSET_KIND.to_string(),
profile_id: Some(profile_id.to_string()),
storage_prefix: LegacyAssetPrefix::WoodenFishAssets,
},
)
.await?;
map_generated_creation_audio_to_wooden_fish_asset(
profile_id,
final_prompt.as_str(),
generated,
WOODEN_FISH_HIT_SOUND_DURATION_SECONDS,
)
}
fn build_wooden_fish_hit_sound_prompt(prompt: &str) -> String {
format!(
"为敲木鱼玩法生成一次点击触发的短促敲击音效:{}。要求:干净、清脆、无旋律、无环境噪声、无语音、无文字提示音,适合高频点击时叠加播放。",
clean_string(prompt, DEFAULT_HIT_SOUND_PROMPT)
)
}
fn map_generated_creation_audio_to_wooden_fish_asset(
profile_id: &str,
prompt: &str,
asset: shared_contracts::creation_audio::CreationAudioAsset,
duration_seconds: u8,
) -> Result<WoodenFishAudioAsset, AppError> {
let asset_object_id = asset
.asset_object_id
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "敲木鱼音效生成完成但缺少资产对象 ID",
}))
})?;
let audio_object_key = asset.audio_src.trim().trim_start_matches('/').to_string();
if audio_object_key.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "敲木鱼音效生成完成但缺少音频地址",
})),
);
}
Ok(WoodenFishAudioAsset {
asset_id: format!("{profile_id}-hit-sound-{}", asset.task_id),
audio_src: asset.audio_src,
audio_object_key,
asset_object_id,
source: "generated".to_string(),
prompt: asset.prompt.or_else(|| Some(prompt.to_string())),
duration_ms: Some(u32::from(duration_seconds) * 1_000),
})
}
struct WoodenFishGeneratedImageAssets {
hit_object_asset: WoodenFishImageAsset,
background_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, background_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",
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 background_reference_image =
downloaded_wooden_fish_reference_image(&image, "wooden-fish-generated-hit-object");
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, background_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",
&background_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_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?;
Ok(WoodenFishGeneratedImageAssets {
hit_object_asset,
background_asset,
})
}
fn build_wooden_fish_hit_object_prompt(prompt: &str) -> String {
format!(
"生成敲木鱼新样式,要求结构,画风与参考图保持高度一致,新样式颜色搭配使用新主题对应的颜色。\n新主题为:{}",
clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT)
)
}
fn build_wooden_fish_background_prompt(prompt: &str) -> String {
format!(
"生成敲木鱼背景,要求主题,画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。\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),
}
}
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_image2_flow() {
let prompt = build_wooden_fish_hit_object_prompt("赛博莲花木鱼");
assert_eq!(
prompt,
"生成敲木鱼新样式,要求结构,画风与参考图保持高度一致,新样式颜色搭配使用新主题对应的颜色。\n新主题为:赛博莲花木鱼"
);
}
#[test]
fn wooden_fish_background_prompt_uses_hidden_image2_flow() {
let prompt = build_wooden_fish_background_prompt("赛博莲花木鱼");
assert_eq!(
prompt,
"生成敲木鱼背景,要求主题,画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。\n主题为:赛博莲花木鱼"
);
}
#[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_audio_asset_maps_from_generated_sound_effect() {
let asset = shared_contracts::creation_audio::CreationAudioAsset {
task_id: "task-hit-sound-1".to_string(),
provider: "vector-engine-vidu".to_string(),
asset_object_id: Some("assetobj-hit-sound-1".to_string()),
asset_kind: Some(WOODEN_FISH_HIT_SOUND_ASSET_KIND.to_string()),
audio_src: "/generated-wooden-fish-assets/wooden-fish-profile-1/hit-sound.mp3"
.to_string(),
prompt: Some("清脆木鱼声".to_string()),
title: None,
updated_at: None,
};
let mapped = map_generated_creation_audio_to_wooden_fish_asset(
"wooden-fish-profile-1",
"清脆木鱼声",
asset,
WOODEN_FISH_HIT_SOUND_DURATION_SECONDS,
)
.expect("generated sound effect should map to wooden fish audio asset");
assert_eq!(
mapped.asset_id,
"wooden-fish-profile-1-hit-sound-task-hit-sound-1"
);
assert_eq!(
mapped.audio_object_key,
"generated-wooden-fish-assets/wooden-fish-profile-1/hit-sound.mp3"
);
assert_eq!(mapped.asset_object_id, "assetobj-hit-sound-1");
assert_eq!(mapped.source, "generated");
assert_eq!(mapped.duration_ms, Some(3_000));
}
}