feat: 完善敲木鱼玩法模板链路
This commit is contained in:
@@ -233,15 +233,13 @@ 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 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)))
|
||||
let _ = parse_json_payload(&request_context, payload)?;
|
||||
Err(creation_audio_generation_disabled_error()
|
||||
.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_sound_effect_asset_for_creation(
|
||||
@@ -874,27 +872,8 @@ fn build_visual_novel_audio_target(
|
||||
|
||||
fn build_creation_audio_target(
|
||||
payload: creation_audio::PublishGeneratedAudioAssetRequest,
|
||||
slot: AudioAssetSlot,
|
||||
_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))
|
||||
}
|
||||
|
||||
@@ -1473,7 +1452,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_creation_audio_targets_return_gone_except_wooden_fish_sound_effects() {
|
||||
fn disabled_creation_audio_targets_return_gone_including_wooden_fish_sound_effects() {
|
||||
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
|
||||
entity_kind: "puzzle_work".to_string(),
|
||||
entity_id: "puzzle-profile-1".to_string(),
|
||||
@@ -1515,13 +1494,9 @@ mod tests {
|
||||
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");
|
||||
let error = build_creation_audio_target(payload, AudioAssetSlot::SoundEffect)
|
||||
.expect_err("wooden fish hit sound target should be disabled");
|
||||
assert_eq!(error.status_code(), StatusCode::GONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -42,9 +42,6 @@ use crate::{
|
||||
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";
|
||||
@@ -53,16 +50,17 @@ 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 DEFAULT_HIT_SOUND_ASSET_ID: &str = "wooden-fish-default-hit-sound";
|
||||
const DEFAULT_HIT_SOUND_AUDIO_SRC: &str = "/wooden-fish/default-hit-sound.mp3";
|
||||
const WOODEN_FISH_ENTITY_KIND: &str = "wooden_fish_work";
|
||||
const WOODEN_FISH_HIT_OBJECT_SLOT: &str = "hit_object";
|
||||
const WOODEN_FISH_HIT_OBJECT_ASSET_KIND: &str = "wooden_fish_hit_object";
|
||||
const WOODEN_FISH_BACKGROUND_SLOT: &str = "background";
|
||||
const WOODEN_FISH_BACKGROUND_ASSET_KIND: &str = "wooden_fish_background";
|
||||
const WOODEN_FISH_HIT_SOUND_SLOT: &str = "hit_sound";
|
||||
const WOODEN_FISH_HIT_SOUND_ASSET_KIND: &str = "wooden_fish_hit_sound";
|
||||
const WOODEN_FISH_BACK_BUTTON_SLOT: &str = "back_button";
|
||||
const WOODEN_FISH_BACK_BUTTON_ASSET_KIND: &str = "wooden_fish_back_button";
|
||||
const WOODEN_FISH_HIT_SOUND_DURATION_SECONDS: u8 = 3;
|
||||
const DEFAULT_HIT_OBJECT_REFERENCE_BYTES: &[u8] = include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
@@ -154,14 +152,7 @@ pub async fn execute_wooden_fish_action(
|
||||
&mut payload,
|
||||
)
|
||||
.await?;
|
||||
maybe_generate_hit_sound_asset(
|
||||
&state,
|
||||
&request_context,
|
||||
&session_id,
|
||||
owner_user_id.as_str(),
|
||||
&mut payload,
|
||||
)
|
||||
.await?;
|
||||
maybe_generate_hit_sound_asset(&mut payload);
|
||||
let response = state
|
||||
.spacetime_client()
|
||||
.execute_wooden_fish_action(session_id, owner_user_id, payload)
|
||||
@@ -371,16 +362,15 @@ fn build_wooden_fish_draft(payload: &WoodenFishWorkspaceCreateRequest) -> Wooden
|
||||
.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())),
|
||||
hit_sound_prompt: None,
|
||||
floating_words: normalize_floating_words(payload.floating_words.clone()),
|
||||
hit_object_asset: None,
|
||||
background_asset: None,
|
||||
hit_sound_asset: payload.hit_sound_asset.clone(),
|
||||
back_button_asset: None,
|
||||
hit_sound_asset: payload
|
||||
.hit_sound_asset
|
||||
.clone()
|
||||
.or_else(|| Some(default_wooden_fish_hit_sound_asset())),
|
||||
cover_image_src: None,
|
||||
generation_status: WoodenFishGenerationStatus::Draft,
|
||||
}
|
||||
@@ -418,7 +408,10 @@ async fn maybe_generate_hit_object_asset(
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
if payload.hit_object_asset.is_some() && payload.background_asset.is_some() {
|
||||
if payload.hit_object_asset.is_some()
|
||||
&& payload.background_asset.is_some()
|
||||
&& payload.back_button_asset.is_some()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -447,6 +440,7 @@ async fn maybe_generate_hit_object_asset(
|
||||
})?;
|
||||
payload.hit_object_asset = Some(generated.hit_object_asset);
|
||||
payload.background_asset = Some(generated.background_asset);
|
||||
payload.back_button_asset = Some(generated.back_button_asset);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -463,6 +457,18 @@ fn default_wooden_fish_hit_object_asset() -> WoodenFishImageAsset {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_wooden_fish_hit_sound_asset() -> WoodenFishAudioAsset {
|
||||
WoodenFishAudioAsset {
|
||||
asset_id: DEFAULT_HIT_SOUND_ASSET_ID.to_string(),
|
||||
audio_src: DEFAULT_HIT_SOUND_AUDIO_SRC.to_string(),
|
||||
audio_object_key: "public/wooden-fish/default-hit-sound.mp3".to_string(),
|
||||
asset_object_id: DEFAULT_HIT_SOUND_ASSET_ID.to_string(),
|
||||
source: "bundled-default".to_string(),
|
||||
prompt: Some("默认木鱼音".to_string()),
|
||||
duration_ms: Some(u32::from(WOODEN_FISH_HIT_SOUND_DURATION_SECONDS) * 1_000),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_default_hit_object_prompt(prompt: &str) -> bool {
|
||||
let normalized = normalize_hit_object_prompt_for_default_match(prompt);
|
||||
normalized.is_empty()
|
||||
@@ -530,130 +536,27 @@ async fn resolve_hit_object_profile_id(
|
||||
})
|
||||
}
|
||||
|
||||
async fn maybe_generate_hit_sound_asset(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
payload: &mut WoodenFishActionRequest,
|
||||
) -> Result<(), Response> {
|
||||
fn maybe_generate_hit_sound_asset(payload: &mut WoodenFishActionRequest) {
|
||||
if !matches!(
|
||||
payload.action_type,
|
||||
shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft
|
||||
| shared_contracts::wooden_fish::WoodenFishActionType::GenerateHitSound
|
||||
| shared_contracts::wooden_fish::WoodenFishActionType::ReplaceHitSound
|
||||
) {
|
||||
return Ok(());
|
||||
return;
|
||||
}
|
||||
if matches!(
|
||||
payload.action_type,
|
||||
shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft
|
||||
) && payload.hit_sound_asset.is_some()
|
||||
{
|
||||
return Ok(());
|
||||
payload.hit_sound_prompt = None;
|
||||
if payload.hit_sound_asset.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
payload.hit_sound_asset = Some(default_wooden_fish_hit_sound_asset());
|
||||
}
|
||||
|
||||
struct WoodenFishGeneratedImageAssets {
|
||||
hit_object_asset: WoodenFishImageAsset,
|
||||
background_asset: WoodenFishImageAsset,
|
||||
back_button_asset: WoodenFishImageAsset,
|
||||
}
|
||||
|
||||
async fn generate_wooden_fish_image_assets(
|
||||
@@ -674,7 +577,7 @@ async fn generate_wooden_fish_image_assets(
|
||||
let theme_reference_image =
|
||||
resolve_wooden_fish_theme_reference_image(clean_reference_image_src)?;
|
||||
|
||||
let (hit_object_asset, background_reference_image) =
|
||||
let (hit_object_asset, hit_object_reference_image) =
|
||||
if should_generate_wooden_fish_hit_object(prompt, clean_reference_image_src) {
|
||||
let hit_object_prompt = build_wooden_fish_hit_object_prompt(theme.as_str());
|
||||
let mut reference_images = vec![default_reference_image.clone()];
|
||||
@@ -699,8 +602,11 @@ async fn generate_wooden_fish_image_assets(
|
||||
"message": "生成敲木鱼敲击物图案失败:上游未返回图片",
|
||||
}))
|
||||
})?;
|
||||
let background_reference_image =
|
||||
downloaded_wooden_fish_reference_image(&image, "wooden-fish-generated-hit-object");
|
||||
let image = prepare_wooden_fish_hit_object_image_for_persist(image)?;
|
||||
let hit_object_reference_image = downloaded_wooden_fish_reference_image(
|
||||
&image,
|
||||
"wooden-fish-generated-hit-object-transparent",
|
||||
);
|
||||
let hit_object_asset = persist_wooden_fish_image_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
@@ -719,7 +625,7 @@ async fn generate_wooden_fish_image_assets(
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
(hit_object_asset, background_reference_image)
|
||||
(hit_object_asset, hit_object_reference_image)
|
||||
} else {
|
||||
(
|
||||
default_wooden_fish_hit_object_asset(),
|
||||
@@ -734,7 +640,7 @@ async fn generate_wooden_fish_image_assets(
|
||||
background_prompt.as_str(),
|
||||
None,
|
||||
"9:16",
|
||||
&background_reference_image,
|
||||
&hit_object_reference_image,
|
||||
"生成敲木鱼背景环境图失败",
|
||||
)
|
||||
.await?;
|
||||
@@ -749,6 +655,8 @@ async fn generate_wooden_fish_image_assets(
|
||||
"message": "生成敲木鱼背景环境图失败:上游未返回图片",
|
||||
}))
|
||||
})?;
|
||||
let background_reference_image =
|
||||
downloaded_wooden_fish_reference_image(&background_image, "wooden-fish-generated-background");
|
||||
let background_asset = persist_wooden_fish_image_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
@@ -767,23 +675,79 @@ async fn generate_wooden_fish_image_assets(
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let back_button_prompt = build_wooden_fish_back_button_prompt(theme.as_str());
|
||||
let back_button_generated = create_openai_image_edit_with_references(
|
||||
&http_client,
|
||||
&settings,
|
||||
back_button_prompt.as_str(),
|
||||
None,
|
||||
"1:1",
|
||||
1,
|
||||
&[
|
||||
hit_object_reference_image.clone(),
|
||||
background_reference_image,
|
||||
],
|
||||
"生成敲木鱼返回按钮图失败",
|
||||
)
|
||||
.await?;
|
||||
let back_button_task_id = back_button_generated.task_id.clone();
|
||||
let back_button_image = back_button_generated
|
||||
.images
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": "生成敲木鱼返回按钮图失败:上游未返回图片",
|
||||
}))
|
||||
})?;
|
||||
let back_button_image = prepare_wooden_fish_green_screen_image_for_persist(
|
||||
back_button_image,
|
||||
"敲木鱼返回按钮图",
|
||||
)?;
|
||||
let back_button_asset = persist_wooden_fish_image_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
profile_id,
|
||||
back_button_task_id.as_str(),
|
||||
back_button_prompt.as_str(),
|
||||
back_button_image,
|
||||
current_utc_micros(),
|
||||
WoodenFishImageSlotPersistSpec {
|
||||
slot: WOODEN_FISH_BACK_BUTTON_SLOT,
|
||||
asset_kind: WOODEN_FISH_BACK_BUTTON_ASSET_KIND,
|
||||
asset_id_part: "back-button",
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(WoodenFishGeneratedImageAssets {
|
||||
hit_object_asset,
|
||||
background_asset,
|
||||
back_button_asset,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_wooden_fish_hit_object_prompt(prompt: &str) -> String {
|
||||
format!(
|
||||
"生成敲木鱼新样式,要求结构,画风与参考图保持高度一致,新样式颜色搭配使用新主题对应的颜色。\n新主题为:{}",
|
||||
"生成敲木鱼新样式,要求结构,画风与参考图保持高度一致,新样式颜色搭配使用新主题对应的颜色。尺寸1:1,先输出绿色背景主体图(纯绿色绿幕),背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影、无道具,主体完整居中,主体边缘必须干净,不要直接输出透明底。随后由服务端对绿色背景主体图做抠图去除绿色背景。最终结果只保留单个敲击物图案,禁止黑底、白底、棋盘格、纸板底或任何实底背景;主体本身不要使用与绿幕接近的纯绿色,若新主题天然包含绿色,请改用偏深、偏黄或偏蓝的绿色并与绿幕清晰区分。\n新主题为:{}",
|
||||
clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT)
|
||||
)
|
||||
}
|
||||
|
||||
fn build_wooden_fish_background_prompt(prompt: &str) -> String {
|
||||
format!(
|
||||
"生成敲木鱼背景,要求主题,画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。\n主题为:{}",
|
||||
"生成敲木鱼背景,要求主题,画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。尺寸竖屏9:16。参考图必须是第一步敲击物抠图完成后的透明图,不继承任何绿色底色、绿幕底色或纯绿色画布,并要求最终输出完整不透明的背景环境图。中央主体预留区必须保持干净,画面中央 40% 区域禁止出现主题主体、主体局部特写、主体轮廓影子、重复元素或主题主体的局部碎片;主题元素只允许出现在外围氛围,不得把主题物品画在画面中央,也不要把主题物品作为背景中心装饰。\n主题为:{}",
|
||||
clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT)
|
||||
)
|
||||
}
|
||||
|
||||
fn build_wooden_fish_back_button_prompt(prompt: &str) -> String {
|
||||
format!(
|
||||
"生成敲木鱼左上角返回按钮图。要求以参考图-去除绿色背景后的敲击物主体和背景环境图为主题、画风、材质和配色参考,但参考图只用来约束圆形底色和中央左箭头的颜色搭配,不要继承复杂造型、花纹、浮雕边、异形外框或装饰图案。按钮必须始终是标准圆形,整体像单个圆形图标,圆心居中,圆形内部只保留一个清晰、简洁、居中的向左返回箭头,不要出现文字、数字、水印、按钮外标签、额外 UI 面板、木槌或敲击道具。尺寸1:1,输出绿色背景主体图(纯绿色绿幕),背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影。按钮主体边缘干净,后续由服务端扣除绿色背景;按钮底色不要使用与绿幕接近的纯绿色,若主题天然包含绿色,请仅在圆形底色上使用偏深、偏黄或偏蓝的主题绿色,并用更高对比的箭头颜色区分。\n主题为:{}",
|
||||
clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT)
|
||||
)
|
||||
}
|
||||
@@ -866,6 +830,39 @@ fn downloaded_wooden_fish_reference_image(
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_wooden_fish_hit_object_image_for_persist(
|
||||
image: DownloadedOpenAiImage,
|
||||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||
prepare_wooden_fish_green_screen_image_for_persist(image, "敲木鱼敲击物图案")
|
||||
}
|
||||
|
||||
fn prepare_wooden_fish_green_screen_image_for_persist(
|
||||
image: DownloadedOpenAiImage,
|
||||
failure_label: &str,
|
||||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": WOODEN_FISH_CREATION_PROVIDER,
|
||||
"message": format!("{failure_label}解码失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||||
crate::generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha(source)
|
||||
.write_to(&mut encoded, image::ImageFormat::Png)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": WOODEN_FISH_CREATION_PROVIDER,
|
||||
"message": format!("{failure_label}绿幕去背失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(DownloadedOpenAiImage {
|
||||
bytes: encoded.into_inner(),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
struct WoodenFishImageSlotPersistSpec {
|
||||
slot: &'static str,
|
||||
asset_kind: &'static str,
|
||||
@@ -1194,23 +1191,93 @@ mod tests {
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
|
||||
#[test]
|
||||
fn wooden_fish_hit_object_prompt_uses_hidden_image2_flow() {
|
||||
fn wooden_fish_hit_object_prompt_uses_hidden_green_screen_flow() {
|
||||
let prompt = build_wooden_fish_hit_object_prompt("赛博莲花木鱼");
|
||||
|
||||
assert_eq!(
|
||||
prompt,
|
||||
"生成敲木鱼新样式,要求结构,画风与参考图保持高度一致,新样式颜色搭配使用新主题对应的颜色。\n新主题为:赛博莲花木鱼"
|
||||
);
|
||||
assert!(prompt.contains(
|
||||
"生成敲木鱼新样式,要求结构,画风与参考图保持高度一致,新样式颜色搭配使用新主题对应的颜色。"
|
||||
));
|
||||
assert!(prompt.contains("尺寸1:1"));
|
||||
assert!(prompt.contains("绿色背景主体图"));
|
||||
assert!(prompt.contains("纯绿色绿幕"));
|
||||
assert!(prompt.contains("#00FF00"));
|
||||
assert!(prompt.contains("不要直接输出透明底"));
|
||||
assert!(prompt.contains("主体本身不要使用与绿幕接近的纯绿色"));
|
||||
assert!(prompt.contains("新主题为:赛博莲花木鱼"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wooden_fish_background_prompt_uses_hidden_image2_flow() {
|
||||
let prompt = build_wooden_fish_background_prompt("赛博莲花木鱼");
|
||||
let prompt = build_wooden_fish_background_prompt("苹果");
|
||||
|
||||
assert!(prompt.contains(
|
||||
"生成敲木鱼背景,要求主题,画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。"
|
||||
));
|
||||
assert!(prompt.contains("尺寸竖屏9:16"));
|
||||
assert!(prompt.contains("抠图完成后的透明图"));
|
||||
assert!(prompt.contains("不继承任何绿色底色"));
|
||||
assert!(prompt.contains("完整不透明的背景环境图"));
|
||||
assert!(prompt.contains("中央主体预留区"));
|
||||
assert!(prompt.contains("禁止出现主题主体"));
|
||||
assert!(prompt.contains("苹果"));
|
||||
assert!(prompt.contains("不得把主题物品画在画面中央"));
|
||||
assert!(prompt.contains("主题为:苹果"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wooden_fish_back_button_prompt_forces_plain_circular_icon() {
|
||||
let prompt = build_wooden_fish_back_button_prompt("玉米");
|
||||
|
||||
assert!(prompt.contains("参考图只用来约束圆形底色和中央左箭头的颜色搭配"));
|
||||
assert!(prompt.contains("按钮必须始终是标准圆形"));
|
||||
assert!(prompt.contains("圆形内部只保留一个清晰、简洁、居中的向左返回箭头"));
|
||||
assert!(prompt.contains("不要继承复杂造型、花纹、浮雕边、异形外框或装饰图案"));
|
||||
assert!(prompt.contains("不要出现文字、数字、水印、按钮外标签、额外 UI 面板、木槌或敲击道具"));
|
||||
assert!(prompt.contains("按钮底色不要使用与绿幕接近的纯绿色"));
|
||||
assert!(prompt.contains("主题为:玉米"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wooden_fish_hit_object_prepare_removes_green_screen_background() {
|
||||
let width = 12;
|
||||
let height = 12;
|
||||
let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255]));
|
||||
for y in 4..8 {
|
||||
for x in 4..8 {
|
||||
image.put_pixel(x, y, image::Rgba([190, 70, 42, 255]));
|
||||
}
|
||||
}
|
||||
image.put_pixel(6, 6, image::Rgba([18, 14, 12, 255]));
|
||||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(image)
|
||||
.write_to(&mut encoded, image::ImageFormat::Png)
|
||||
.expect("test image should encode");
|
||||
|
||||
let original_bytes = encoded.into_inner();
|
||||
let processed = prepare_wooden_fish_hit_object_image_for_persist(DownloadedOpenAiImage {
|
||||
bytes: original_bytes.clone(),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
});
|
||||
let processed = processed.expect("processed image should succeed");
|
||||
let decoded = image::load_from_memory(processed.bytes.as_slice())
|
||||
.expect("processed image should decode")
|
||||
.to_rgba8();
|
||||
|
||||
assert_eq!(processed.mime_type, "image/png");
|
||||
assert_eq!(processed.extension, "png");
|
||||
assert_eq!(
|
||||
prompt,
|
||||
"生成敲木鱼背景,要求主题,画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。\n主题为:赛博莲花木鱼"
|
||||
decoded.get_pixel(0, 0).0[3],
|
||||
0,
|
||||
"绿幕背景必须在入库前去除"
|
||||
);
|
||||
assert_eq!(decoded.get_pixel(4, 4).0[3], 255);
|
||||
assert_eq!(
|
||||
decoded.get_pixel(6, 6).0[3],
|
||||
255,
|
||||
"敲击物内部深色细节不能被当成背景抠除"
|
||||
);
|
||||
assert_ne!(processed.bytes, original_bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1273,37 +1340,41 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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,
|
||||
fn wooden_fish_default_hit_sound_asset_uses_bundled_mp3() {
|
||||
let asset = default_wooden_fish_hit_sound_asset();
|
||||
|
||||
assert_eq!(asset.asset_id, "wooden-fish-default-hit-sound");
|
||||
assert_eq!(asset.audio_src, "/wooden-fish/default-hit-sound.mp3");
|
||||
assert_eq!(
|
||||
asset.audio_object_key,
|
||||
"public/wooden-fish/default-hit-sound.mp3"
|
||||
);
|
||||
assert_eq!(asset.asset_object_id, "wooden-fish-default-hit-sound");
|
||||
assert_eq!(asset.source, "bundled-default");
|
||||
assert_eq!(asset.prompt.as_deref(), Some("默认木鱼音"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wooden_fish_draft_uses_default_hit_sound_asset_and_ignores_prompt() {
|
||||
let payload = WoodenFishWorkspaceCreateRequest {
|
||||
template_id: WOODEN_FISH_TEMPLATE_ID.to_string(),
|
||||
work_title: "今日敲木鱼".to_string(),
|
||||
work_description: String::new(),
|
||||
theme_tags: vec!["敲木鱼".to_string()],
|
||||
hit_object_prompt: "金色木鱼".to_string(),
|
||||
hit_object_reference_image_src: None,
|
||||
hit_sound_prompt: Some("清脆木鱼声".to_string()),
|
||||
hit_sound_asset: None,
|
||||
floating_words: vec![],
|
||||
};
|
||||
|
||||
let 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");
|
||||
let draft = build_wooden_fish_draft(&payload);
|
||||
|
||||
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));
|
||||
assert!(draft.hit_sound_prompt.is_none());
|
||||
let asset = draft
|
||||
.hit_sound_asset
|
||||
.expect("default hit sound asset should be attached");
|
||||
assert_eq!(asset.audio_src, "/wooden-fish/default-hit-sound.mp3");
|
||||
assert_eq!(asset.prompt.as_deref(), Some("默认木鱼音"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,9 @@ pub struct WoodenFishActionRequest {
|
||||
#[serde(skip_deserializing)]
|
||||
pub background_asset: Option<WoodenFishImageAsset>,
|
||||
#[serde(default)]
|
||||
#[serde(skip_deserializing)]
|
||||
pub back_button_asset: Option<WoodenFishImageAsset>,
|
||||
#[serde(default)]
|
||||
pub hit_sound_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hit_sound_asset: Option<WoodenFishAudioAsset>,
|
||||
@@ -128,6 +131,8 @@ pub struct WoodenFishDraftResponse {
|
||||
#[serde(default)]
|
||||
pub background_asset: Option<WoodenFishImageAsset>,
|
||||
#[serde(default)]
|
||||
pub back_button_asset: Option<WoodenFishImageAsset>,
|
||||
#[serde(default)]
|
||||
pub hit_sound_asset: Option<WoodenFishAudioAsset>,
|
||||
#[serde(default)]
|
||||
pub cover_image_src: Option<String>,
|
||||
@@ -192,6 +197,8 @@ pub struct WoodenFishWorkProfileResponse {
|
||||
pub hit_object_asset: WoodenFishImageAsset,
|
||||
#[serde(default)]
|
||||
pub background_asset: Option<WoodenFishImageAsset>,
|
||||
#[serde(default)]
|
||||
pub back_button_asset: Option<WoodenFishImageAsset>,
|
||||
pub hit_sound_asset: WoodenFishAudioAsset,
|
||||
pub floating_words: Vec<String>,
|
||||
}
|
||||
@@ -384,6 +391,18 @@ mod tests {
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
}),
|
||||
back_button_asset: Some(WoodenFishImageAsset {
|
||||
asset_id: "back-button-1".to_string(),
|
||||
image_src: "/generated-wooden-fish-assets/profile/back-button/image.png"
|
||||
.to_string(),
|
||||
image_object_key: "generated-wooden-fish-assets/profile/back-button/image.png"
|
||||
.to_string(),
|
||||
asset_object_id: "back-button-object-1".to_string(),
|
||||
generation_provider: "image2".to_string(),
|
||||
prompt: "赛博莲花返回按钮".to_string(),
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}),
|
||||
hit_sound_prompt: Some("短促木鱼声".to_string()),
|
||||
hit_sound_asset: Some(WoodenFishAudioAsset {
|
||||
asset_id: "sound-1".to_string(),
|
||||
@@ -406,6 +425,7 @@ mod tests {
|
||||
json!("generated-wooden-fish-assets/profile/hit-object/image.png")
|
||||
);
|
||||
assert_eq!(payload["backgroundAsset"]["height"], json!(1536));
|
||||
assert_eq!(payload["backButtonAsset"]["width"], json!(1024));
|
||||
assert_eq!(payload["hitSoundAsset"]["source"], json!("upload"));
|
||||
assert_eq!(payload["hitSoundAsset"]["durationMs"], json!(800));
|
||||
}
|
||||
@@ -454,6 +474,16 @@ mod tests {
|
||||
prompt: Some("清脆木鱼".to_string()),
|
||||
duration_ms: Some(600),
|
||||
};
|
||||
let back_button = WoodenFishImageAsset {
|
||||
asset_id: "back-button-1".to_string(),
|
||||
image_src: "/generated/wooden-fish-back-button.png".to_string(),
|
||||
image_object_key: "generated/wooden-fish-back-button.png".to_string(),
|
||||
asset_object_id: "back-button-object-1".to_string(),
|
||||
generation_provider: "image2".to_string(),
|
||||
prompt: "主题返回按钮".to_string(),
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
};
|
||||
let profile = WoodenFishWorkProfileResponse {
|
||||
summary: WoodenFishWorkSummaryResponse {
|
||||
runtime_kind: "wooden-fish".to_string(),
|
||||
@@ -485,12 +515,14 @@ mod tests {
|
||||
floating_words: vec!["功德".to_string()],
|
||||
hit_object_asset: Some(image.clone()),
|
||||
background_asset: None,
|
||||
back_button_asset: Some(back_button.clone()),
|
||||
hit_sound_asset: Some(audio.clone()),
|
||||
cover_image_src: Some(image.image_src.clone()),
|
||||
generation_status: WoodenFishGenerationStatus::Ready,
|
||||
},
|
||||
hit_object_asset: image,
|
||||
background_asset: None,
|
||||
back_button_asset: Some(back_button),
|
||||
hit_sound_asset: audio,
|
||||
floating_words: vec!["功德".to_string()],
|
||||
};
|
||||
@@ -503,5 +535,9 @@ mod tests {
|
||||
json!("image2")
|
||||
);
|
||||
assert_eq!(payload["hitSoundAsset"]["source"], json!("generated"));
|
||||
assert_eq!(
|
||||
payload["backButtonAsset"]["imageSrc"],
|
||||
json!("/generated/wooden-fish-back-button.png")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,6 +450,10 @@ mod tests {
|
||||
cover_image_src: None,
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
level_background_image_src: None,
|
||||
level_background_image_object_key: None,
|
||||
ui_spritesheet_image_src: None,
|
||||
ui_spritesheet_image_object_key: None,
|
||||
background_music: None,
|
||||
board: PuzzleBoardSnapshot {
|
||||
rows: 3,
|
||||
|
||||
@@ -113,6 +113,7 @@ fn map_wooden_fish_work_snapshot(
|
||||
floating_words: snapshot.floating_words.clone(),
|
||||
hit_object_asset: snapshot.hit_object_asset.clone().map(map_image_asset),
|
||||
background_asset: snapshot.background_asset.clone().map(map_image_asset),
|
||||
back_button_asset: snapshot.back_button_asset.clone().map(map_image_asset),
|
||||
hit_sound_asset: snapshot.hit_sound_asset.clone().map(map_audio_asset),
|
||||
cover_image_src: empty_string_to_none(snapshot.cover_image_src.clone()),
|
||||
generation_status: parse_generation_status(&snapshot.generation_status),
|
||||
@@ -147,6 +148,7 @@ fn map_wooden_fish_work_snapshot(
|
||||
draft,
|
||||
hit_object_asset,
|
||||
background_asset: snapshot.background_asset.map(map_image_asset),
|
||||
back_button_asset: snapshot.back_button_asset.map(map_image_asset),
|
||||
hit_sound_asset,
|
||||
floating_words: snapshot.floating_words,
|
||||
})
|
||||
@@ -166,6 +168,7 @@ fn map_wooden_fish_draft_snapshot(snapshot: WoodenFishDraftSnapshot) -> WoodenFi
|
||||
floating_words: snapshot.floating_words,
|
||||
hit_object_asset: snapshot.hit_object_asset.map(map_image_asset),
|
||||
background_asset: snapshot.background_asset.map(map_image_asset),
|
||||
back_button_asset: snapshot.back_button_asset.map(map_image_asset),
|
||||
hit_sound_asset: snapshot.hit_sound_asset.map(map_audio_asset),
|
||||
cover_image_src: snapshot.cover_image_src,
|
||||
generation_status: parse_generation_status(&snapshot.generation_status),
|
||||
|
||||
@@ -20,6 +20,7 @@ pub struct WoodenFishDraftCompileInput {
|
||||
pub hit_object_asset_json: Option<String>,
|
||||
pub background_asset_json: Option<String>,
|
||||
pub hit_sound_asset_json: Option<String>,
|
||||
pub back_button_asset_json: Option<String>,
|
||||
pub floating_words_json: Option<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub generation_status: Option<String>,
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct WoodenFishDraftSnapshot {
|
||||
pub floating_words: Vec<String>,
|
||||
pub hit_object_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub background_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub back_button_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub hit_sound_asset: Option<WoodenFishAudioAssetSnapshot>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub generation_status: String,
|
||||
|
||||
@@ -24,6 +24,7 @@ pub struct WoodenFishGalleryViewRow {
|
||||
pub hit_sound_prompt: Option<String>,
|
||||
pub hit_object_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub background_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub back_button_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub hit_sound_asset: Option<WoodenFishAudioAssetSnapshot>,
|
||||
pub floating_words: Vec<String>,
|
||||
pub cover_image_src: String,
|
||||
@@ -60,6 +61,8 @@ pub struct WoodenFishGalleryViewRowCols {
|
||||
__sdk::__query_builder::Col<WoodenFishGalleryViewRow, Option<WoodenFishImageAssetSnapshot>>,
|
||||
pub background_asset:
|
||||
__sdk::__query_builder::Col<WoodenFishGalleryViewRow, Option<WoodenFishImageAssetSnapshot>>,
|
||||
pub back_button_asset:
|
||||
__sdk::__query_builder::Col<WoodenFishGalleryViewRow, Option<WoodenFishImageAssetSnapshot>>,
|
||||
pub hit_sound_asset:
|
||||
__sdk::__query_builder::Col<WoodenFishGalleryViewRow, Option<WoodenFishAudioAssetSnapshot>>,
|
||||
pub floating_words: __sdk::__query_builder::Col<WoodenFishGalleryViewRow, Vec<String>>,
|
||||
@@ -96,6 +99,7 @@ impl __sdk::__query_builder::HasCols for WoodenFishGalleryViewRow {
|
||||
hit_sound_prompt: __sdk::__query_builder::Col::new(table_name, "hit_sound_prompt"),
|
||||
hit_object_asset: __sdk::__query_builder::Col::new(table_name, "hit_object_asset"),
|
||||
background_asset: __sdk::__query_builder::Col::new(table_name, "background_asset"),
|
||||
back_button_asset: __sdk::__query_builder::Col::new(table_name, "back_button_asset"),
|
||||
hit_sound_asset: __sdk::__query_builder::Col::new(table_name, "hit_sound_asset"),
|
||||
floating_words: __sdk::__query_builder::Col::new(table_name, "floating_words"),
|
||||
cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"),
|
||||
|
||||
@@ -28,6 +28,7 @@ pub struct WoodenFishWorkProfileRow {
|
||||
pub updated_at: __sdk::Timestamp,
|
||||
pub published_at: Option<__sdk::Timestamp>,
|
||||
pub background_asset_json: Option<String>,
|
||||
pub back_button_asset_json: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for WoodenFishWorkProfileRow {
|
||||
@@ -62,6 +63,8 @@ pub struct WoodenFishWorkProfileRowCols {
|
||||
__sdk::__query_builder::Col<WoodenFishWorkProfileRow, Option<__sdk::Timestamp>>,
|
||||
pub background_asset_json:
|
||||
__sdk::__query_builder::Col<WoodenFishWorkProfileRow, Option<String>>,
|
||||
pub back_button_asset_json:
|
||||
__sdk::__query_builder::Col<WoodenFishWorkProfileRow, Option<String>>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for WoodenFishWorkProfileRow {
|
||||
@@ -107,6 +110,10 @@ impl __sdk::__query_builder::HasCols for WoodenFishWorkProfileRow {
|
||||
table_name,
|
||||
"background_asset_json",
|
||||
),
|
||||
back_button_asset_json: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"back_button_asset_json",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ pub struct WoodenFishWorkSnapshot {
|
||||
pub hit_sound_prompt: Option<String>,
|
||||
pub hit_object_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub background_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub back_button_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub hit_sound_asset: Option<WoodenFishAudioAssetSnapshot>,
|
||||
pub floating_words: Vec<String>,
|
||||
pub cover_image_src: String,
|
||||
|
||||
@@ -18,6 +18,7 @@ pub struct WoodenFishWorkUpdateInput {
|
||||
pub hit_object_asset_json: Option<String>,
|
||||
pub background_asset_json: Option<String>,
|
||||
pub hit_sound_asset_json: Option<String>,
|
||||
pub back_button_asset_json: Option<String>,
|
||||
pub floating_words_json: Option<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub generation_status: Option<String>,
|
||||
|
||||
@@ -15,7 +15,6 @@ use shared_kernel::build_prefixed_uuid_id;
|
||||
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 = "清脆短促的木鱼敲击声";
|
||||
|
||||
impl SpacetimeClient {
|
||||
pub async fn create_wooden_fish_session(
|
||||
@@ -532,21 +531,11 @@ fn merge_action_into_draft(
|
||||
if let Some(asset) = payload.background_asset.clone() {
|
||||
draft.background_asset = Some(asset);
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
scope,
|
||||
WoodenFishDraftMergeScope::CompileDraft
|
||||
| WoodenFishDraftMergeScope::GenerateHitSound
|
||||
| WoodenFishDraftMergeScope::ReplaceHitSound
|
||||
) {
|
||||
if let Some(value) = payload
|
||||
.hit_sound_prompt
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
draft.hit_sound_prompt = Some(value.trim().to_string());
|
||||
if let Some(asset) = payload.back_button_asset.clone() {
|
||||
draft.back_button_asset = Some(asset);
|
||||
}
|
||||
}
|
||||
draft.hit_sound_prompt = None;
|
||||
if matches!(scope, WoodenFishDraftMergeScope::GenerateHitSound) {
|
||||
draft.hit_sound_asset = payload.hit_sound_asset.clone();
|
||||
} else if matches!(
|
||||
@@ -577,6 +566,7 @@ fn merge_action_into_draft(
|
||||
{
|
||||
draft.hit_object_asset = None;
|
||||
draft.background_asset = None;
|
||||
draft.back_button_asset = None;
|
||||
}
|
||||
if draft.floating_words.is_empty() {
|
||||
draft.floating_words = default_floating_words();
|
||||
@@ -613,6 +603,9 @@ fn build_compile_input(
|
||||
let background_asset = draft.background_asset.clone().ok_or_else(|| {
|
||||
SpacetimeClientError::validation_failed("wooden fish background asset 缺少真实生成资产")
|
||||
})?;
|
||||
let back_button_asset = draft.back_button_asset.clone().ok_or_else(|| {
|
||||
SpacetimeClientError::validation_failed("wooden fish back button asset 缺少真实生成资产")
|
||||
})?;
|
||||
|
||||
Ok(WoodenFishDraftCompileInput {
|
||||
session_id: current.session_id.clone(),
|
||||
@@ -628,6 +621,7 @@ fn build_compile_input(
|
||||
hit_object_asset_json: Some(json_string(&hit_object_asset)?),
|
||||
background_asset_json: Some(json_string(&background_asset)?),
|
||||
hit_sound_asset_json: Some(json_string(&hit_sound_asset)?),
|
||||
back_button_asset_json: Some(json_string(&back_button_asset)?),
|
||||
floating_words_json: Some(json_string(&draft.floating_words)?),
|
||||
cover_image_src: draft.cover_image_src.clone(),
|
||||
generation_status: Some("ready".to_string()),
|
||||
@@ -662,6 +656,7 @@ fn build_update_input(
|
||||
} else {
|
||||
None
|
||||
},
|
||||
back_button_asset_json: None,
|
||||
floating_words_json: Some(json_string(&draft.floating_words)?),
|
||||
cover_image_src: draft.cover_image_src.clone(),
|
||||
generation_status: None,
|
||||
@@ -716,10 +711,11 @@ fn default_draft() -> WoodenFishDraftResponse {
|
||||
theme_tags: vec!["休闲".to_string()],
|
||||
hit_object_prompt: DEFAULT_HIT_OBJECT_PROMPT.to_string(),
|
||||
hit_object_reference_image_src: None,
|
||||
hit_sound_prompt: Some(DEFAULT_HIT_SOUND_PROMPT.to_string()),
|
||||
hit_sound_prompt: None,
|
||||
floating_words: default_floating_words(),
|
||||
hit_object_asset: None,
|
||||
background_asset: None,
|
||||
back_button_asset: None,
|
||||
hit_sound_asset: None,
|
||||
cover_image_src: None,
|
||||
generation_status: WoodenFishGenerationStatus::Draft,
|
||||
@@ -807,6 +803,7 @@ mod tests {
|
||||
let mut payload = action(WoodenFishActionType::CompileDraft);
|
||||
payload.hit_object_asset = Some(generated_hit_object_asset("generated-compile-object"));
|
||||
payload.background_asset = Some(generated_background_asset("generated-compile-background"));
|
||||
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
|
||||
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
|
||||
|
||||
let (plan, draft) =
|
||||
@@ -840,6 +837,13 @@ mod tests {
|
||||
.unwrap_or("")
|
||||
.contains("generated-compile-background")
|
||||
);
|
||||
assert!(
|
||||
input
|
||||
.back_button_asset_json
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("generated-compile-back")
|
||||
);
|
||||
assert_eq!(draft.generation_status, WoodenFishGenerationStatus::Ready);
|
||||
}
|
||||
|
||||
@@ -849,6 +853,7 @@ mod tests {
|
||||
let mut payload = action(WoodenFishActionType::CompileDraft);
|
||||
payload.hit_object_asset = Some(generated_hit_object_asset("generated-compile-object"));
|
||||
payload.background_asset = Some(generated_background_asset("generated-compile-background"));
|
||||
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
|
||||
|
||||
let error =
|
||||
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) {
|
||||
@@ -869,6 +874,7 @@ mod tests {
|
||||
let mut payload = action(WoodenFishActionType::CompileDraft);
|
||||
payload.hit_object_asset = Some(generated_hit_object_asset("generated-compile-object"));
|
||||
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
|
||||
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
|
||||
|
||||
let error =
|
||||
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) {
|
||||
@@ -883,6 +889,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wooden_fish_compile_requires_real_back_button_asset_from_api_server() {
|
||||
let session = session_with_draft(draft_without_assets());
|
||||
let mut payload = action(WoodenFishActionType::CompileDraft);
|
||||
payload.hit_object_asset = Some(generated_hit_object_asset("generated-compile-object"));
|
||||
payload.background_asset = Some(generated_background_asset("generated-compile-background"));
|
||||
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
|
||||
|
||||
let error =
|
||||
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) {
|
||||
Ok(_) => panic!("compile-draft should not publish without back button asset"),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("back button asset 缺少真实生成资产")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wooden_fish_action_regenerate_hit_object_replaces_only_object_asset() {
|
||||
let session = session_with_draft(draft_with_assets());
|
||||
@@ -890,6 +917,7 @@ mod tests {
|
||||
payload.hit_object_prompt = Some("新的敲击物".to_string());
|
||||
payload.hit_object_asset = Some(generated_hit_object_asset("generated-object"));
|
||||
payload.background_asset = Some(generated_background_asset("generated-background"));
|
||||
payload.back_button_asset = Some(generated_back_button_asset("generated-back"));
|
||||
|
||||
let (plan, _draft) =
|
||||
build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
|
||||
@@ -933,6 +961,13 @@ mod tests {
|
||||
.unwrap_or("")
|
||||
.contains("generated-background")
|
||||
);
|
||||
assert!(
|
||||
input
|
||||
.back_button_asset_json
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("generated-back")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -967,6 +1002,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wooden_fish_default_draft_has_no_hit_sound_prompt() {
|
||||
let draft = default_draft();
|
||||
|
||||
assert!(draft.hit_sound_prompt.is_none());
|
||||
}
|
||||
|
||||
fn action(action_type: WoodenFishActionType) -> WoodenFishActionRequest {
|
||||
WoodenFishActionRequest {
|
||||
action_type,
|
||||
@@ -978,6 +1020,7 @@ mod tests {
|
||||
hit_object_reference_image_src: None,
|
||||
hit_object_asset: None,
|
||||
background_asset: None,
|
||||
back_button_asset: None,
|
||||
hit_sound_prompt: None,
|
||||
hit_sound_asset: None,
|
||||
floating_words: None,
|
||||
@@ -1032,6 +1075,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn generated_back_button_asset(asset_id: &str) -> WoodenFishImageAsset {
|
||||
WoodenFishImageAsset {
|
||||
asset_id: asset_id.to_string(),
|
||||
image_src: "/generated-wooden-fish-assets/real-profile/back-button/image.png"
|
||||
.to_string(),
|
||||
image_object_key: "generated-wooden-fish-assets/real-profile/back-button/image.png"
|
||||
.to_string(),
|
||||
asset_object_id: format!("{asset_id}-asset"),
|
||||
generation_provider: "image2".to_string(),
|
||||
prompt: "新的返回按钮".to_string(),
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}
|
||||
}
|
||||
|
||||
fn generated_hit_sound_asset(asset_id: &str) -> WoodenFishAudioAsset {
|
||||
WoodenFishAudioAsset {
|
||||
asset_id: asset_id.to_string(),
|
||||
@@ -1068,6 +1126,16 @@ mod tests {
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
}),
|
||||
back_button_asset: Some(WoodenFishImageAsset {
|
||||
asset_id: "old-back".to_string(),
|
||||
image_src: "/generated-wooden-fish-assets/old-back.png".to_string(),
|
||||
image_object_key: "generated-wooden-fish-assets/old-back.png".to_string(),
|
||||
asset_object_id: "old-back-asset".to_string(),
|
||||
generation_provider: "image2".to_string(),
|
||||
prompt: "旧返回按钮".to_string(),
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}),
|
||||
hit_sound_asset: Some(WoodenFishAudioAsset {
|
||||
asset_id: "old-sound".to_string(),
|
||||
audio_src: "/generated-wooden-fish-assets/old-sound.mp3".to_string(),
|
||||
@@ -1093,10 +1161,11 @@ mod tests {
|
||||
theme_tags: vec!["旧标签".to_string()],
|
||||
hit_object_prompt: "旧敲击物".to_string(),
|
||||
hit_object_reference_image_src: None,
|
||||
hit_sound_prompt: Some("旧音效".to_string()),
|
||||
hit_sound_prompt: None,
|
||||
floating_words: default_floating_words(),
|
||||
hit_object_asset: None,
|
||||
background_asset: None,
|
||||
back_button_asset: None,
|
||||
hit_sound_asset: None,
|
||||
cover_image_src: None,
|
||||
generation_status: WoodenFishGenerationStatus::Draft,
|
||||
|
||||
@@ -1271,6 +1271,10 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
|
||||
object
|
||||
.entry("background_asset_json".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
// 中文注释:敲木鱼返回按钮图晚于首版作品表加入,旧迁移包按未生成返回按钮兼容。
|
||||
object
|
||||
.entry("back_button_asset_json".to_string())
|
||||
.or_insert(serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
next_value
|
||||
|
||||
@@ -82,6 +82,7 @@ pub struct WoodenFishGalleryViewRow {
|
||||
pub hit_sound_prompt: Option<String>,
|
||||
pub hit_object_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub background_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub back_button_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub hit_sound_asset: Option<WoodenFishAudioAssetSnapshot>,
|
||||
pub floating_words: Vec<String>,
|
||||
pub cover_image_src: String,
|
||||
@@ -333,6 +334,11 @@ fn compile_wooden_fish_draft_tx(
|
||||
.as_deref()
|
||||
.map(parse_json)
|
||||
.transpose()?;
|
||||
let back_button_asset = input
|
||||
.back_button_asset_json
|
||||
.as_deref()
|
||||
.map(parse_json)
|
||||
.transpose()?;
|
||||
let cover_image_src = input
|
||||
.cover_image_src
|
||||
.as_deref()
|
||||
@@ -361,6 +367,7 @@ fn compile_wooden_fish_draft_tx(
|
||||
floating_words: floating_words.clone(),
|
||||
hit_object_asset: hit_object_asset.clone(),
|
||||
background_asset: background_asset.clone(),
|
||||
back_button_asset: back_button_asset.clone(),
|
||||
hit_sound_asset: hit_sound_asset.clone(),
|
||||
cover_image_src: cover_image_src.clone(),
|
||||
generation_status: input
|
||||
@@ -400,6 +407,7 @@ fn compile_wooden_fish_draft_tx(
|
||||
updated_at: compiled_at,
|
||||
published_at: None,
|
||||
background_asset_json: background_asset.as_ref().map(to_json_string),
|
||||
back_button_asset_json: back_button_asset.as_ref().map(to_json_string),
|
||||
};
|
||||
upsert_work(ctx, row);
|
||||
let config = config_from_draft(&draft);
|
||||
@@ -485,6 +493,14 @@ fn update_wooden_fish_work_tx(
|
||||
let asset = parse_json::<WoodenFishImageAssetSnapshot>(&value)?;
|
||||
next.background_asset_json = Some(to_json_string(&asset));
|
||||
}
|
||||
if let Some(value) = input
|
||||
.back_button_asset_json
|
||||
.as_deref()
|
||||
.and_then(clean_optional)
|
||||
{
|
||||
let asset = parse_json::<WoodenFishImageAssetSnapshot>(&value)?;
|
||||
next.back_button_asset_json = Some(to_json_string(&asset));
|
||||
}
|
||||
if let Some(value) = input
|
||||
.floating_words_json
|
||||
.as_deref()
|
||||
@@ -512,7 +528,7 @@ fn publish_wooden_fish_work_tx(
|
||||
) -> Result<WoodenFishWorkSnapshot, String> {
|
||||
let row = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
|
||||
if !is_publish_ready(&row) {
|
||||
return Err("发布需要完整的敲击物图案、敲击音效和飘字配置".to_string());
|
||||
return Err("发布需要完整的敲击物图案、背景、返回按钮、敲击音效和飘字配置".to_string());
|
||||
}
|
||||
let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros);
|
||||
replace_work(
|
||||
@@ -691,6 +707,7 @@ fn build_gallery_view_row(
|
||||
hit_sound_prompt: work.hit_sound_prompt,
|
||||
hit_object_asset: work.hit_object_asset,
|
||||
background_asset: work.background_asset,
|
||||
back_button_asset: work.back_button_asset,
|
||||
hit_sound_asset: work.hit_sound_asset,
|
||||
floating_words: work.floating_words,
|
||||
cover_image_src: work.cover_image_src,
|
||||
@@ -744,6 +761,12 @@ fn build_work_snapshot(row: &WoodenFishWorkProfileRow) -> Result<WoodenFishWorkS
|
||||
.and_then(clean_optional)
|
||||
.map(|value| parse_json(&value))
|
||||
.transpose()?,
|
||||
back_button_asset: row
|
||||
.back_button_asset_json
|
||||
.as_deref()
|
||||
.and_then(clean_optional)
|
||||
.map(|value| parse_json(&value))
|
||||
.transpose()?,
|
||||
hit_sound_asset: clean_optional(&row.hit_sound_asset_json)
|
||||
.map(|value| parse_json(&value))
|
||||
.transpose()?,
|
||||
@@ -993,6 +1016,11 @@ fn is_publish_ready(row: &WoodenFishWorkProfileRow) -> bool {
|
||||
.as_deref()
|
||||
.and_then(clean_optional)
|
||||
.is_some()
|
||||
&& row
|
||||
.back_button_asset_json
|
||||
.as_deref()
|
||||
.and_then(clean_optional)
|
||||
.is_some()
|
||||
&& !row.hit_sound_asset_json.trim().is_empty()
|
||||
&& !row.floating_words_json.trim().is_empty()
|
||||
&& row.generation_status == WOODEN_FISH_GENERATION_READY
|
||||
@@ -1031,6 +1059,7 @@ fn draft_from_config(
|
||||
floating_words: normalize_floating_words(&config.floating_words),
|
||||
hit_object_asset: None,
|
||||
background_asset: None,
|
||||
back_button_asset: None,
|
||||
hit_sound_asset: None,
|
||||
cover_image_src: None,
|
||||
generation_status: generation_status.to_string(),
|
||||
@@ -1051,6 +1080,7 @@ fn draft_from_work_snapshot(work: &WoodenFishWorkSnapshot) -> WoodenFishDraftSna
|
||||
floating_words: work.floating_words.clone(),
|
||||
hit_object_asset: work.hit_object_asset.clone(),
|
||||
background_asset: work.background_asset.clone(),
|
||||
back_button_asset: work.back_button_asset.clone(),
|
||||
hit_sound_asset: work.hit_sound_asset.clone(),
|
||||
cover_image_src: clean_optional(&work.cover_image_src),
|
||||
generation_status: work.generation_status.clone(),
|
||||
@@ -1231,6 +1261,7 @@ fn clone_work(row: &WoodenFishWorkProfileRow) -> WoodenFishWorkProfileRow {
|
||||
hit_object_asset_json: row.hit_object_asset_json.clone(),
|
||||
background_asset_json: row.background_asset_json.clone(),
|
||||
hit_sound_asset_json: row.hit_sound_asset_json.clone(),
|
||||
back_button_asset_json: row.back_button_asset_json.clone(),
|
||||
floating_words_json: row.floating_words_json.clone(),
|
||||
cover_image_src: row.cover_image_src.clone(),
|
||||
generation_status: row.generation_status.clone(),
|
||||
|
||||
@@ -47,6 +47,8 @@ pub struct WoodenFishWorkProfileRow {
|
||||
pub(crate) published_at: Option<Timestamp>,
|
||||
#[default(None::<String>)]
|
||||
pub(crate) background_asset_json: Option<String>,
|
||||
#[default(None::<String>)]
|
||||
pub(crate) back_button_asset_json: Option<String>,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
|
||||
@@ -47,6 +47,7 @@ pub struct WoodenFishDraftCompileInput {
|
||||
pub hit_object_asset_json: Option<String>,
|
||||
pub background_asset_json: Option<String>,
|
||||
pub hit_sound_asset_json: Option<String>,
|
||||
pub back_button_asset_json: Option<String>,
|
||||
pub floating_words_json: Option<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub generation_status: Option<String>,
|
||||
@@ -66,6 +67,7 @@ pub struct WoodenFishWorkUpdateInput {
|
||||
pub hit_object_asset_json: Option<String>,
|
||||
pub background_asset_json: Option<String>,
|
||||
pub hit_sound_asset_json: Option<String>,
|
||||
pub back_button_asset_json: Option<String>,
|
||||
pub floating_words_json: Option<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub generation_status: Option<String>,
|
||||
@@ -210,6 +212,7 @@ pub struct WoodenFishDraftSnapshot {
|
||||
pub floating_words: Vec<String>,
|
||||
pub hit_object_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub background_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub back_button_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub hit_sound_asset: Option<WoodenFishAudioAssetSnapshot>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub generation_status: String,
|
||||
@@ -246,6 +249,7 @@ pub struct WoodenFishWorkSnapshot {
|
||||
pub hit_sound_prompt: Option<String>,
|
||||
pub hit_object_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub background_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub back_button_asset: Option<WoodenFishImageAssetSnapshot>,
|
||||
pub hit_sound_asset: Option<WoodenFishAudioAssetSnapshot>,
|
||||
pub floating_words: Vec<String>,
|
||||
pub cover_image_src: String,
|
||||
|
||||
Reference in New Issue
Block a user