1309 lines
46 KiB
Rust
1309 lines
46 KiB
Rust
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));
|
||
}
|
||
}
|