Merge remote-tracking branch 'origin/codex/wooden-fish-template'
This commit is contained in:
@@ -60,6 +60,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(modules::match3d::router(state.clone()))
|
||||
.merge(modules::square_hole::router(state.clone()))
|
||||
.merge(modules::jump_hop::router(state.clone()))
|
||||
.merge(modules::wooden_fish::router(state.clone()))
|
||||
.merge(modules::puzzle::router(state.clone()))
|
||||
.merge(visual_novel_router(state.clone()))
|
||||
.route(
|
||||
|
||||
@@ -8,8 +8,8 @@ use axum::{
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[cfg(test)]
|
||||
use module_runtime::build_creation_entry_config_response;
|
||||
use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
|
||||
@@ -84,6 +84,12 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
|
||||
if normalized.starts_with("/api/creation/bark-battle") {
|
||||
return Some("bark-battle");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/wooden-fish") {
|
||||
return Some("wooden-fish");
|
||||
}
|
||||
if normalized.starts_with("/api/creation/wooden-fish") {
|
||||
return Some("wooden-fish");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/square-hole") {
|
||||
return Some("square-hole");
|
||||
}
|
||||
@@ -123,9 +129,8 @@ fn creation_entry_error_response(request_context: &RequestContext, error: AppErr
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_creation_entry_config_response()
|
||||
-> shared_contracts::creation_entry_config::CreationEntryConfigResponse {
|
||||
/// 中文注释:本地 debug 兜底也来自后端领域默认种子,避免前端恢复硬编码入口配置。
|
||||
pub(crate) fn default_creation_entry_config_response() -> CreationEntryConfigResponse {
|
||||
build_creation_entry_config_response(module_runtime::CreationEntryConfigSnapshot {
|
||||
config_id: module_runtime::CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(),
|
||||
start_card: module_runtime::CreationEntryStartCardSnapshot {
|
||||
@@ -143,6 +148,11 @@ pub(crate) fn test_creation_entry_config_response()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_creation_entry_config_response() -> CreationEntryConfigResponse {
|
||||
default_creation_entry_config_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -197,6 +207,14 @@ mod tests {
|
||||
resolve_creation_entry_route_id("/api/creation/bark-battle/drafts"),
|
||||
Some("bark-battle"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/wooden-fish/runs/run-1"),
|
||||
Some("wooden-fish"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/creation/wooden-fish/sessions"),
|
||||
Some("wooden-fish"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/creation/edutainment/baby-object-match/assets"),
|
||||
Some("baby-object-match"),
|
||||
@@ -217,10 +235,10 @@ mod tests {
|
||||
.find(|item| item.id == "bark-battle")
|
||||
.expect("test creation entry config should include bark-battle");
|
||||
|
||||
assert_eq!(bark_battle.title, "汪汪声浪");
|
||||
assert_eq!(bark_battle.title, "\u{6c6a}\u{6c6a}\u{58f0}\u{6d6a}");
|
||||
assert!(bark_battle.visible);
|
||||
assert!(bark_battle.open);
|
||||
assert_eq!(bark_battle.badge, "可创建");
|
||||
assert_eq!(bark_battle.badge, "\u{53ef}\u{521b}\u{5efa}");
|
||||
assert_eq!(
|
||||
bark_battle.image_src,
|
||||
"/creation-type-references/bark-battle.webp"
|
||||
@@ -228,7 +246,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_creation_entry_config_response_keeps_baby_object_match_coming_soon() {
|
||||
fn test_creation_entry_config_response_keeps_baby_object_match_visible() {
|
||||
let config = test_creation_entry_config_response();
|
||||
let baby_object_match = config
|
||||
.creation_types
|
||||
@@ -236,10 +254,10 @@ mod tests {
|
||||
.find(|item| item.id == "baby-object-match")
|
||||
.expect("test creation entry config should include baby-object-match");
|
||||
|
||||
assert_eq!(baby_object_match.title, "宝贝识物");
|
||||
assert_eq!(baby_object_match.title, "\u{5b9d}\u{8d1d}\u{8bc6}\u{7269}");
|
||||
assert!(baby_object_match.visible);
|
||||
assert!(!baby_object_match.open);
|
||||
assert_eq!(baby_object_match.badge, "敬请期待");
|
||||
assert!(baby_object_match.open);
|
||||
assert_eq!(baby_object_match.badge, "\u{53ef}\u{521b}\u{5efa}");
|
||||
assert_eq!(baby_object_match.sort_order, 90);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ mod volcengine_speech;
|
||||
mod wechat_auth;
|
||||
mod wechat_pay;
|
||||
mod wechat_provider;
|
||||
mod wooden_fish;
|
||||
mod work_author;
|
||||
mod work_play_tracking;
|
||||
|
||||
|
||||
@@ -1539,111 +1539,11 @@ pub(super) fn slice_match3d_material_sheet(
|
||||
image: &DownloadedOpenAiImage,
|
||||
item_names: &[String],
|
||||
) -> Result<Vec<Vec<Match3DSlicedItemImage>>, AppError> {
|
||||
// 中文注释:素材图提示词固定要求 5*5 均匀排布;切图也固定按 5 行 5 列定位格子。
|
||||
// 每个格子内再基于前景像素二次校准,避免固定内缩裁断物品边缘。
|
||||
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "match3d-assets",
|
||||
"message": format!("抓大鹅素材图解码失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
// 中文注释:素材图按绿幕背景生成;先把整张 sheet 的绿幕转成 alpha,再进入格子裁切。
|
||||
let source = apply_match3d_material_green_screen_alpha(source);
|
||||
let (width, height) = source.dimensions();
|
||||
let row_count = MATCH3D_MATERIAL_GRID_SIZE;
|
||||
let cell_width = width / MATCH3D_MATERIAL_GRID_SIZE;
|
||||
let cell_height = height / row_count;
|
||||
if cell_width == 0 || cell_height == 0 {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "match3d-assets",
|
||||
"message": "抓大鹅素材图尺寸过小,无法切割",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let mut slices = Vec::with_capacity(item_names.len());
|
||||
for item_index in 0..item_names.len().min(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) {
|
||||
let row = item_index as u32;
|
||||
let mut views = Vec::with_capacity(MATCH3D_ITEM_VIEW_COUNT);
|
||||
for view_index in 0..MATCH3D_ITEM_VIEW_COUNT {
|
||||
let col = view_index as u32;
|
||||
let (crop_x, crop_y, crop_width, crop_height) =
|
||||
resolve_match3d_material_cell_crop(&source, row_count, row, col);
|
||||
let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height);
|
||||
let cleaned = crop_match3d_material_view_edge_matte(cropped);
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
cleaned
|
||||
.write_to(&mut cursor, ImageFormat::Png)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "match3d-assets",
|
||||
"message": format!("抓大鹅素材图切割失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
views.push(Match3DSlicedItemImage {
|
||||
bytes: cursor.into_inner(),
|
||||
});
|
||||
}
|
||||
slices.push(views);
|
||||
}
|
||||
|
||||
Ok(slices)
|
||||
}
|
||||
|
||||
fn resolve_match3d_material_cell_crop(
|
||||
source: &image::DynamicImage,
|
||||
row_count: u32,
|
||||
row: u32,
|
||||
col: u32,
|
||||
) -> (u32, u32, u32, u32) {
|
||||
let (image_width, image_height) = source.dimensions();
|
||||
let cell = resolve_match3d_material_cell_bounds(image_width, image_height, row_count, row, col);
|
||||
let Some(foreground) = detect_match3d_material_foreground_bounds(source, cell) else {
|
||||
return cell.to_crop_tuple();
|
||||
};
|
||||
|
||||
let cell_width = cell.width();
|
||||
let cell_height = cell.height();
|
||||
let pad_x = (cell_width / 16).clamp(4, 16);
|
||||
let pad_y = (cell_height / 16).clamp(4, 16);
|
||||
let crop = Match3DMaterialCellBounds {
|
||||
x0: foreground.x0.saturating_sub(pad_x).max(cell.x0),
|
||||
y0: foreground.y0.saturating_sub(pad_y).max(cell.y0),
|
||||
x1: foreground.x1.saturating_add(pad_x).min(cell.x1),
|
||||
y1: foreground.y1.saturating_add(pad_y).min(cell.y1),
|
||||
};
|
||||
|
||||
crop.to_crop_tuple()
|
||||
}
|
||||
|
||||
pub(super) fn crop_match3d_material_view_edge_matte(
|
||||
image: image::DynamicImage,
|
||||
) -> image::DynamicImage {
|
||||
let mut image = image.to_rgba8();
|
||||
let (width, height) = image.dimensions();
|
||||
remove_match3d_material_view_edge_matte(image.as_mut(), width as usize, height as usize);
|
||||
let bounds = detect_match3d_material_visible_bounds(&image).unwrap_or_else(|| {
|
||||
Match3DMaterialCellBounds {
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
x1: width,
|
||||
y1: height,
|
||||
}
|
||||
});
|
||||
if bounds.x0 == 0 && bounds.y0 == 0 && bounds.x1 == width && bounds.y1 == height {
|
||||
return image::DynamicImage::ImageRgba8(image);
|
||||
}
|
||||
|
||||
image::DynamicImage::ImageRgba8(
|
||||
image::imageops::crop_imm(
|
||||
&image,
|
||||
bounds.x0,
|
||||
bounds.y0,
|
||||
bounds.width(),
|
||||
bounds.height(),
|
||||
)
|
||||
.to_image(),
|
||||
slice_generated_asset_sheet_two_items_per_row(
|
||||
image,
|
||||
item_names,
|
||||
MATCH3D_MATERIAL_GRID_SIZE as usize,
|
||||
MATCH3D_ITEM_VIEW_COUNT,
|
||||
)
|
||||
.map(|rows| {
|
||||
rows.into_iter()
|
||||
|
||||
@@ -716,6 +716,40 @@ pub(super) fn load_match3d_container_reference_image() -> Result<OpenAiReference
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_level_scene_generation_prompt(config: &Match3DConfigJson) -> String {
|
||||
let theme = config.theme_text.trim();
|
||||
let theme = if theme.is_empty() {
|
||||
MATCH3D_DEFAULT_THEME
|
||||
} else {
|
||||
theme
|
||||
};
|
||||
let style_clause = resolve_match3d_asset_style_prompt(config)
|
||||
.map(|style| format!("\n整体美术风格要求:{style}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
format!(
|
||||
concat!(
|
||||
"生成抓大鹅游戏关卡画面,要求画面中所有元素精致且风格高度一致,画面中所有UI细节饱满精致、完成度高、顶级游戏品质\n\n",
|
||||
"抓大鹅主题描述:\n",
|
||||
"{theme}{style_clause}\n\n",
|
||||
"画面元素:\n",
|
||||
"返回按钮位于顶部左上角,顶部中间显示关卡标题“第1关 重庆火锅”和倒计时时间,右上角显示设置按钮\n",
|
||||
"画面中间是一个和主题匹配的容器,宽度与画面宽度同宽,紧贴画面横向边缘\n",
|
||||
"底部还有三个道具按钮分别为“移出”、“凑齐”、“打乱”"
|
||||
),
|
||||
theme = theme,
|
||||
style_clause = style_clause,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_ui_spritesheet_prompt() -> String {
|
||||
"提取画面中的UI元素,将返回按钮、设置按钮、方格素材(不含边框,仅保留一个)、移出按钮、凑齐按钮、打乱按钮的顺序从上到下从左到右整理成纯绿色绿幕背景spritesheet。背景必须是统一纯绿色绿幕(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。UI 素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。".to_string()
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_background_from_scene_prompt() -> String {
|
||||
"移除画面中的所有UI组件和容器中的内含物,完整保留容器和背景,补全被UI覆盖的背景内容".to_string()
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_background_generation_prompt(
|
||||
config: &Match3DConfigJson,
|
||||
prompt: &str,
|
||||
|
||||
@@ -14,3 +14,4 @@ pub mod profile;
|
||||
pub mod puzzle;
|
||||
pub mod square_hole;
|
||||
pub mod story;
|
||||
pub mod wooden_fish;
|
||||
|
||||
80
server-rs/crates/api-server/src/modules/wooden_fish.rs
Normal file
80
server-rs/crates/api-server/src/modules/wooden_fish.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::require_bearer_auth,
|
||||
state::AppState,
|
||||
wooden_fish::{
|
||||
checkpoint_wooden_fish_run, create_wooden_fish_session, execute_wooden_fish_action,
|
||||
finish_wooden_fish_run, get_wooden_fish_gallery_detail, get_wooden_fish_runtime_work,
|
||||
get_wooden_fish_session, list_wooden_fish_gallery, publish_wooden_fish_work,
|
||||
start_wooden_fish_run,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/creation/wooden-fish/sessions",
|
||||
post(create_wooden_fish_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/wooden-fish/sessions/{session_id}",
|
||||
get(get_wooden_fish_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/wooden-fish/sessions/{session_id}/actions",
|
||||
post(execute_wooden_fish_action).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/wooden-fish/works/{profile_id}/publish",
|
||||
post(publish_wooden_fish_work).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/wooden-fish/works/{profile_id}",
|
||||
get(get_wooden_fish_runtime_work),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/wooden-fish/runs",
|
||||
post(start_wooden_fish_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/wooden-fish/runs/{run_id}/checkpoint",
|
||||
post(checkpoint_wooden_fish_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/wooden-fish/runs/{run_id}/finish",
|
||||
post(finish_wooden_fish_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/wooden-fish/gallery",
|
||||
get(list_wooden_fish_gallery),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/wooden-fish/gallery/{public_work_code}",
|
||||
get(get_wooden_fish_gallery_detail),
|
||||
)
|
||||
}
|
||||
@@ -423,13 +423,14 @@ pub(crate) async fn create_openai_image_edit_with_references(
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": format!("{failure_context}:图片编辑需要至少一张参考图。"),
|
||||
"message": format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let request_url = vector_engine_images_edit_url(settings);
|
||||
let normalized_size = normalize_image_size(size);
|
||||
|
||||
let mut form = reqwest::multipart::Form::new()
|
||||
.text("model", GPT_IMAGE_2_MODEL.to_string())
|
||||
.text(
|
||||
@@ -1277,6 +1278,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vector_engine_multi_reference_edit_rejects_empty_references() {
|
||||
let settings = OpenAiImageSettings {
|
||||
base_url: "https://vector.example".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 1_000_000,
|
||||
external_api_audit_state: None,
|
||||
};
|
||||
let http_client = reqwest::Client::new();
|
||||
|
||||
let result = create_openai_image_edit_with_references(
|
||||
&http_client,
|
||||
&settings,
|
||||
"提示词",
|
||||
None,
|
||||
"1:1",
|
||||
1,
|
||||
&[],
|
||||
"测试图片编辑失败",
|
||||
)
|
||||
.await;
|
||||
|
||||
let error = result.expect_err("empty references should be rejected locally");
|
||||
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
||||
assert!(error.body_text().contains("缺少参考图"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_data_url_resolves_to_edit_image_part() {
|
||||
let source = format!(
|
||||
|
||||
@@ -463,6 +463,14 @@ impl AppState {
|
||||
self.cache_test_creation_entry_config(config.clone());
|
||||
Ok(config)
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
Err(error) if is_missing_creation_entry_config_procedure(&error) => {
|
||||
warn!(
|
||||
error = %error,
|
||||
"本地 SpacetimeDB 缺少创作入口配置 procedure,使用后端默认入口配置兜底"
|
||||
);
|
||||
Ok(crate::creation_entry_config::default_creation_entry_config_response())
|
||||
}
|
||||
#[cfg(test)]
|
||||
Err(_) => Ok(self.read_test_creation_entry_config()),
|
||||
#[cfg(not(test))]
|
||||
@@ -1327,12 +1335,35 @@ fn build_admin_runtime(
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
fn is_missing_creation_entry_config_procedure(error: &SpacetimeClientError) -> bool {
|
||||
match error {
|
||||
SpacetimeClientError::Procedure(message) => message.contains("No such procedure"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use module_ai::{AiTaskKind, generate_ai_task_id};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_missing_creation_entry_config_procedure_for_debug_fallback() {
|
||||
assert!(is_missing_creation_entry_config_procedure(
|
||||
&SpacetimeClientError::Procedure(
|
||||
"No such procedure: get_creation_entry_config".to_string(),
|
||||
),
|
||||
));
|
||||
assert!(is_missing_creation_entry_config_procedure(
|
||||
&SpacetimeClientError::Procedure("No such procedure".to_string()),
|
||||
));
|
||||
assert!(!is_missing_creation_entry_config_procedure(
|
||||
&SpacetimeClientError::Timeout,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_state_exposes_usable_ai_task_service() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
|
||||
@@ -233,13 +233,15 @@ pub async fn create_visual_novel_sound_effect_task(
|
||||
}
|
||||
|
||||
pub async fn create_sound_effect_task(
|
||||
State(_state): State<AppState>,
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
payload: Result<Json<creation_audio::CreateSoundEffectRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let _ = parse_json_payload(&request_context, payload)?;
|
||||
Err(creation_audio_generation_disabled_error()
|
||||
.into_response_with_context(Some(&request_context)))
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
create_sound_effect_task_response(&state, payload.prompt, payload.duration, payload.seed)
|
||||
.await
|
||||
.map(|task| json_success_body(Some(&request_context), task))
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_sound_effect_asset_for_creation(
|
||||
@@ -518,15 +520,25 @@ pub async fn publish_background_music_asset(
|
||||
}
|
||||
|
||||
pub async fn publish_sound_effect_asset(
|
||||
State(_state): State<AppState>,
|
||||
Path(_task_id): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
axum::extract::Extension(_authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<creation_audio::PublishGeneratedAudioAssetRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let payload = parse_json_payload(&request_context, payload)?.0;
|
||||
Err(creation_audio_generation_disabled_error_for_target(payload)
|
||||
.into_response_with_context(Some(&request_context)))
|
||||
let target = build_creation_audio_target(payload, AudioAssetSlot::SoundEffect)
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
|
||||
publish_generated_audio_asset(
|
||||
&state,
|
||||
authenticated.claims().user_id(),
|
||||
task_id,
|
||||
AudioAssetSlot::SoundEffect,
|
||||
target,
|
||||
)
|
||||
.await
|
||||
.map(|payload| json_success_body(Some(&request_context), payload))
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
async fn publish_generated_audio_asset(
|
||||
@@ -860,10 +872,36 @@ fn build_visual_novel_audio_target(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_creation_audio_target(
|
||||
payload: creation_audio::PublishGeneratedAudioAssetRequest,
|
||||
slot: AudioAssetSlot,
|
||||
) -> Result<AudioAssetBindingTarget, AppError> {
|
||||
if matches!(slot, AudioAssetSlot::SoundEffect)
|
||||
&& payload.entity_kind.trim() == "wooden_fish_work"
|
||||
&& payload.slot.trim() == "hit_sound"
|
||||
&& payload.asset_kind.trim() == "wooden_fish_hit_sound"
|
||||
&& payload.storage_prefix
|
||||
== Some(creation_audio::CreationAudioStoragePrefix::WoodenFishAssets)
|
||||
{
|
||||
let entity_id = normalize_limited_text(&payload.entity_id, "entityId", 160)?;
|
||||
return Ok(AudioAssetBindingTarget {
|
||||
storage_scope: payload.entity_kind.trim().to_string(),
|
||||
entity_kind: payload.entity_kind.trim().to_string(),
|
||||
entity_id,
|
||||
slot: payload.slot.trim().to_string(),
|
||||
asset_kind: payload.asset_kind.trim().to_string(),
|
||||
profile_id: normalize_optional_text(payload.profile_id.as_deref()),
|
||||
storage_prefix: LegacyAssetPrefix::WoodenFishAssets,
|
||||
});
|
||||
}
|
||||
|
||||
Err(creation_audio_generation_disabled_error_for_target(payload))
|
||||
}
|
||||
|
||||
fn creation_audio_generation_disabled_error() -> AppError {
|
||||
AppError::from_status(StatusCode::GONE).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": "拼图与抓大鹅音频生成入口已临时关闭",
|
||||
"message": "当前创作音频目标未开放",
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -872,8 +910,9 @@ fn creation_audio_generation_disabled_error_for_target(
|
||||
) -> AppError {
|
||||
creation_audio_generation_disabled_error().with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": "拼图与抓大鹅音频生成入口已临时关闭",
|
||||
"message": "当前创作音频目标未开放",
|
||||
"entityKind": payload.entity_kind.trim(),
|
||||
"slot": payload.slot.trim(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1434,7 +1473,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_creation_audio_targets_return_gone() {
|
||||
fn disabled_creation_audio_targets_return_gone_except_wooden_fish_sound_effects() {
|
||||
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
|
||||
entity_kind: "puzzle_work".to_string(),
|
||||
entity_id: "puzzle-profile-1".to_string(),
|
||||
@@ -1467,6 +1506,22 @@ mod tests {
|
||||
};
|
||||
let error = creation_audio_generation_disabled_error_for_target(payload);
|
||||
assert_eq!(error.status_code(), StatusCode::GONE);
|
||||
|
||||
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
|
||||
entity_kind: "wooden_fish_work".to_string(),
|
||||
entity_id: "wooden-fish-profile-1".to_string(),
|
||||
slot: "hit_sound".to_string(),
|
||||
asset_kind: "wooden_fish_hit_sound".to_string(),
|
||||
profile_id: Some("wooden-fish-profile-1".to_string()),
|
||||
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::WoodenFishAssets),
|
||||
};
|
||||
let target = build_creation_audio_target(payload, AudioAssetSlot::SoundEffect)
|
||||
.expect("wooden fish hit sound target should be enabled");
|
||||
|
||||
assert_eq!(target.entity_kind, "wooden_fish_work");
|
||||
assert_eq!(target.slot, "hit_sound");
|
||||
assert_eq!(target.storage_prefix, LegacyAssetPrefix::WoodenFishAssets);
|
||||
assert_eq!(target.storage_scope, "wooden_fish_work");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
1309
server-rs/crates/api-server/src/wooden_fish.rs
Normal file
1309
server-rs/crates/api-server/src/wooden_fish.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user