Merge remote-tracking branch 'origin/master' into feat/recommend-runtime-guest

# Conflicts:
#	docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
This commit is contained in:
kdletters
2026-05-25 14:12:39 +08:00
470 changed files with 8570 additions and 3058 deletions

View File

@@ -251,6 +251,9 @@ fn map_admin_creation_entry_type_config(
visible: entry.visible,
open: entry.open,
sort_order: entry.sort_order,
category_id: entry.category_id,
category_label: entry.category_label,
category_sort_order: entry.category_sort_order,
updated_at_micros: entry.updated_at_micros,
}
}
@@ -275,6 +278,9 @@ fn validate_admin_creation_entry_config(
visible: payload.visible,
open: payload.open,
sort_order: payload.sort_order,
category_id: payload.category_id.trim().to_string(),
category_label: payload.category_label.trim().to_string(),
category_sort_order: payload.category_sort_order,
})
}

View File

@@ -143,6 +143,15 @@ pub(crate) fn default_creation_entry_config_response() -> CreationEntryConfigRes
title: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(),
description: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(),
},
event_banner: module_runtime::CreationEntryEventBannerSnapshot {
title: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_TITLE.to_string(),
description: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION.to_string(),
cover_image_src: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC
.to_string(),
prize_pool_mud_points: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS,
starts_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string(),
ends_at_text: module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string(),
},
creation_types: module_runtime::default_creation_entry_type_snapshots(0),
updated_at_micros: 0,
})
@@ -259,5 +268,8 @@ mod tests {
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);
assert_eq!(baby_object_match.category_id, "character");
assert_eq!(baby_object_match.category_label, "\u{89d2}\u{8272}\u{521b}\u{4f5c}");
assert_eq!(baby_object_match.category_sort_order, 40);
}
}

View File

@@ -874,6 +874,7 @@ fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() {
container_image_object_key: None,
status: "image_ready".to_string(),
error: None,
..Default::default()
});
let mut generated_asset = test_match3d_generated_item_asset(99, "新草莓");
generated_asset.image_src =
@@ -1062,6 +1063,7 @@ fn match3d_background_asset_requires_background_and_container_images() {
container_image_object_key: None,
status: "image_ready".to_string(),
error: None,
..Default::default()
};
let with_container = Match3DGeneratedBackgroundAsset {
container_prompt: Some("果园容器".to_string()),
@@ -1108,6 +1110,7 @@ fn match3d_default_cover_prefers_generated_container_ui_image() {
container_image_object_key: None,
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
error: None,
@@ -1169,7 +1172,7 @@ fn match3d_cover_reference_prompt_marks_reference_images() {
#[test]
fn match3d_cover_edit_prompt_preserves_uploaded_image() {
let prompt = build_match3d_cover_edit_prompt("水果封面");
let prompt = build_match3d_cover_uploaded_reference_prompt("水果封面");
assert!(prompt.contains("上传的封面图作为第一优先级"));
assert!(prompt.contains("保留主图的主体、构图、视角和主要配色"));
@@ -1212,6 +1215,7 @@ fn match3d_fallback_work_profile_keeps_generated_background_asset() {
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
error: None,
@@ -1349,6 +1353,7 @@ fn match3d_agent_session_response_hydrates_persisted_ui_assets() {
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
error: None,
@@ -1424,6 +1429,7 @@ fn match3d_agent_session_response_keeps_draft_ui_assets_without_work_detail_hydr
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
error: None,
@@ -1807,6 +1813,7 @@ fn match3d_work_summary_marks_complete_generated_assets_ready() {
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
..test_match3d_generated_item_asset(1, "草莓")
}];

View File

@@ -516,6 +516,10 @@ impl AppState {
visible: enabled,
open: enabled,
sort_order: i32::try_from(config.creation_types.len()).unwrap_or(i32::MAX),
category_id: module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID.to_string(),
category_label: module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL
.to_string(),
category_sort_order: 0,
updated_at_micros: 0,
},
);

View File

@@ -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]

View File

@@ -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!(
"生成敲木鱼左上角返回按钮图。要求以参考图-去除绿色背景后的敲击物主体和背景环境图为主题、画风、材质和配色参考,但参考图只用来约束圆形底色和中央左箭头的颜色搭配,不要继承复杂造型、花纹、浮雕边、异形外框或装饰图案。按钮必须始终是标准圆形,整体像单个圆形图标,按钮主体在画布中的视觉尺寸比当前模板再放大约 50%,圆心居中,圆形外沿加一圈和主题色搭配的干净外描边,让它更像一个按钮,但仍然只保留一个清晰、简洁、居中的向左返回箭头,不要出现文字、数字、水印、按钮外标签、额外 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,95 @@ 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("按钮主体在画布中的视觉尺寸比当前模板再放大约 50%"));
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 +1342,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("默认木鱼音"));
}
}

View File

@@ -10,8 +10,8 @@ use crate::domain::*;
use crate::errors::RuntimeProfileFieldError;
use crate::format_utc_micros;
use shared_contracts::creation_entry_config::{
CreationEntryConfigResponse, CreationEntryStartCardResponse, CreationEntryTypeModalResponse,
CreationEntryTypeResponse,
CreationEntryConfigResponse, CreationEntryEventBannerResponse, CreationEntryStartCardResponse,
CreationEntryTypeModalResponse, CreationEntryTypeResponse,
};
pub fn build_creation_entry_config_response(
@@ -28,6 +28,14 @@ pub fn build_creation_entry_config_response(
title: snapshot.type_modal.title,
description: snapshot.type_modal.description,
},
event_banner: CreationEntryEventBannerResponse {
title: snapshot.event_banner.title,
description: snapshot.event_banner.description,
cover_image_src: snapshot.event_banner.cover_image_src,
prize_pool_mud_points: snapshot.event_banner.prize_pool_mud_points,
starts_at_text: snapshot.event_banner.starts_at_text,
ends_at_text: snapshot.event_banner.ends_at_text,
},
creation_types: snapshot
.creation_types
.into_iter()
@@ -40,6 +48,9 @@ pub fn build_creation_entry_config_response(
visible: item.visible,
open: item.open,
sort_order: item.sort_order,
category_id: item.category_id,
category_label: item.category_label,
category_sort_order: item.category_sort_order,
updated_at_micros: item.updated_at_micros,
})
.collect(),
@@ -59,6 +70,9 @@ pub fn default_creation_entry_type_snapshots(
true,
true,
10,
"recent",
"最近创作",
10,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
@@ -70,6 +84,9 @@ pub fn default_creation_entry_type_snapshots(
false,
true,
20,
"recommended",
"热门推荐",
20,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
@@ -81,6 +98,9 @@ pub fn default_creation_entry_type_snapshots(
true,
true,
30,
"recent",
"最近创作",
10,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
@@ -92,6 +112,9 @@ pub fn default_creation_entry_type_snapshots(
true,
true,
40,
"recent",
"最近创作",
10,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
@@ -103,6 +126,9 @@ pub fn default_creation_entry_type_snapshots(
true,
true,
45,
"recommended",
"热门推荐",
20,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
@@ -114,6 +140,9 @@ pub fn default_creation_entry_type_snapshots(
true,
true,
47,
"festival",
"节日主题",
30,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
@@ -125,6 +154,9 @@ pub fn default_creation_entry_type_snapshots(
false,
true,
50,
"material",
"材质工艺",
60,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
@@ -136,6 +168,9 @@ pub fn default_creation_entry_type_snapshots(
true,
false,
60,
"scene",
"生活场景",
50,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
@@ -147,6 +182,9 @@ pub fn default_creation_entry_type_snapshots(
true,
false,
70,
"character",
"角色创作",
40,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
@@ -158,6 +196,9 @@ pub fn default_creation_entry_type_snapshots(
false,
true,
80,
"recommended",
"热门推荐",
20,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
@@ -169,6 +210,9 @@ pub fn default_creation_entry_type_snapshots(
true,
true,
85,
"recommended",
"热门推荐",
20,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
@@ -180,6 +224,9 @@ pub fn default_creation_entry_type_snapshots(
true,
true,
90,
"character",
"角色创作",
40,
updated_at_micros,
),
]
@@ -195,6 +242,9 @@ fn build_default_creation_entry_type_snapshot(
visible: bool,
open: bool,
sort_order: i32,
category_id: &str,
category_label: &str,
category_sort_order: i32,
updated_at_micros: i64,
) -> CreationEntryTypeSnapshot {
CreationEntryTypeSnapshot {
@@ -206,6 +256,9 @@ fn build_default_creation_entry_type_snapshot(
visible,
open,
sort_order,
category_id: category_id.to_string(),
category_label: category_label.to_string(),
category_sort_order,
updated_at_micros,
}
}

View File

@@ -50,6 +50,15 @@ pub const DEFAULT_CREATION_ENTRY_START_IDLE_BADGE: &str = "模板 Tab";
pub const DEFAULT_CREATION_ENTRY_START_BUSY_BADGE: &str = "正在开启";
pub const DEFAULT_CREATION_ENTRY_MODAL_TITLE: &str = "选择创作类型";
pub const DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION: &str = "先选玩法类型,再进入对应创作工作台。";
pub const DEFAULT_CREATION_ENTRY_CATEGORY_ID: &str = "recent";
pub const DEFAULT_CREATION_ENTRY_CATEGORY_LABEL: &str = "最近创作";
pub const DEFAULT_CREATION_ENTRY_EVENT_TITLE: &str = "主题创作赛";
pub const DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION: &str = "用温暖的色彩,捏出秋天的故事。";
pub const DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC: &str =
"/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png";
pub const DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS: u64 = 58_000;
pub const DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT: &str = "2024.10.20 10:00";
pub const DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT: &str = "2024.11.20 23:59";
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -67,6 +76,17 @@ pub struct CreationEntryTypeModalSnapshot {
pub description: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreationEntryEventBannerSnapshot {
pub title: String,
pub description: String,
pub cover_image_src: String,
pub prize_pool_mud_points: u64,
pub starts_at_text: String,
pub ends_at_text: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreationEntryTypeSnapshot {
@@ -78,6 +98,9 @@ pub struct CreationEntryTypeSnapshot {
pub visible: bool,
pub open: bool,
pub sort_order: i32,
pub category_id: String,
pub category_label: String,
pub category_sort_order: i32,
pub updated_at_micros: i64,
}
@@ -87,6 +110,7 @@ pub struct CreationEntryConfigSnapshot {
pub config_id: String,
pub start_card: CreationEntryStartCardSnapshot,
pub type_modal: CreationEntryTypeModalSnapshot,
pub event_banner: CreationEntryEventBannerSnapshot,
pub creation_types: Vec<CreationEntryTypeSnapshot>,
pub updated_at_micros: i64,
}
@@ -102,6 +126,9 @@ pub struct CreationEntryTypeAdminUpsertInput {
pub visible: bool,
pub open: bool,
pub sort_order: i32,
pub category_id: String,
pub category_label: String,
pub category_sort_order: i32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]

View File

@@ -230,6 +230,9 @@ mod tests {
assert!(baby_object_match.open);
assert_eq!(baby_object_match.badge, "可创建");
assert_eq!(baby_object_match.sort_order, 90);
assert_eq!(baby_object_match.category_id, "character");
assert_eq!(baby_object_match.category_label, "角色创作");
assert_eq!(baby_object_match.category_sort_order, 40);
assert_eq!(
baby_object_match.image_src,
"/child-motion-demo/picture-book-grass-stage.png"
@@ -250,6 +253,8 @@ mod tests {
assert!(rpg.open);
assert_eq!(rpg.badge, "可创建");
assert_eq!(rpg.sort_order, 10);
assert_eq!(rpg.category_id, "recent");
assert_eq!(rpg.category_label, "最近创作");
assert_eq!(rpg.image_src, "/creation-type-references/rpg.webp");
}

View File

@@ -30,6 +30,9 @@ pub struct AdminCreationEntryTypeConfigPayload {
pub visible: bool,
pub open: bool,
pub sort_order: i32,
pub category_id: String,
pub category_label: String,
pub category_sort_order: i32,
pub updated_at_micros: i64,
}
@@ -45,6 +48,9 @@ pub struct AdminUpsertCreationEntryTypeConfigRequest {
pub visible: bool,
pub open: bool,
pub sort_order: i32,
pub category_id: String,
pub category_label: String,
pub category_sort_order: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]

View File

@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
pub struct CreationEntryConfigResponse {
pub start_card: CreationEntryStartCardResponse,
pub type_modal: CreationEntryTypeModalResponse,
pub event_banner: CreationEntryEventBannerResponse,
pub creation_types: Vec<CreationEntryTypeResponse>,
}
@@ -24,6 +25,17 @@ pub struct CreationEntryTypeModalResponse {
pub description: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CreationEntryEventBannerResponse {
pub title: String,
pub description: String,
pub cover_image_src: String,
pub prize_pool_mud_points: u64,
pub starts_at_text: String,
pub ends_at_text: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CreationEntryTypeResponse {
@@ -35,5 +47,8 @@ pub struct CreationEntryTypeResponse {
pub visible: bool,
pub open: bool,
pub sort_order: i32,
pub category_id: String,
pub category_label: String,
pub category_sort_order: i32,
pub updated_at_micros: i64,
}

View File

@@ -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")
);
}
}

View File

@@ -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,

View File

@@ -11,6 +11,9 @@ impl From<module_runtime::CreationEntryTypeAdminUpsertInput> for CreationEntryTy
visible: input.visible,
open: input.open,
sort_order: input.sort_order,
category_id: input.category_id,
category_label: input.category_label,
category_sort_order: input.category_sort_order,
}
}
}
@@ -151,6 +154,29 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
title: header.modal_title,
description: header.modal_description,
},
event_banner: module_runtime::CreationEntryEventBannerSnapshot {
title: creation_entry_text_or_default(
header.event_title,
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_TITLE,
),
description: creation_entry_text_or_default(
header.event_description,
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION,
),
cover_image_src: creation_entry_text_or_default(
header.event_cover_image_src,
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC,
),
prize_pool_mud_points: header.event_prize_pool_mud_points,
starts_at_text: creation_entry_text_or_default(
header.event_starts_at_text,
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT,
),
ends_at_text: creation_entry_text_or_default(
header.event_ends_at_text,
module_runtime::DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT,
),
},
creation_types: creation_types
.into_iter()
.map(|item| module_runtime::CreationEntryTypeSnapshot {
@@ -162,6 +188,15 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
visible: item.visible,
open: item.open,
sort_order: item.sort_order,
category_id: creation_entry_text_or_default(
item.category_id,
module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID,
),
category_label: creation_entry_text_or_default(
item.category_label,
module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL,
),
category_sort_order: item.category_sort_order,
updated_at_micros: item.updated_at.to_micros_since_unix_epoch(),
})
.collect(),
@@ -185,6 +220,14 @@ fn map_creation_entry_config_snapshot(
title: snapshot.type_modal.title,
description: snapshot.type_modal.description,
},
event_banner: module_runtime::CreationEntryEventBannerSnapshot {
title: snapshot.event_banner.title,
description: snapshot.event_banner.description,
cover_image_src: snapshot.event_banner.cover_image_src,
prize_pool_mud_points: snapshot.event_banner.prize_pool_mud_points,
starts_at_text: snapshot.event_banner.starts_at_text,
ends_at_text: snapshot.event_banner.ends_at_text,
},
creation_types: snapshot
.creation_types
.into_iter()
@@ -197,6 +240,9 @@ fn map_creation_entry_config_snapshot(
visible: item.visible,
open: item.open,
sort_order: item.sort_order,
category_id: item.category_id,
category_label: item.category_label,
category_sort_order: item.category_sort_order,
updated_at_micros: item.updated_at_micros,
})
.collect(),
@@ -204,6 +250,13 @@ fn map_creation_entry_config_snapshot(
}
}
fn creation_entry_text_or_default(value: Option<String>, default_value: &str) -> String {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| default_value.to_string())
}
pub(crate) fn map_runtime_setting_procedure_result(
result: RuntimeSettingProcedureResult,
) -> Result<RuntimeSettingsRecord, SpacetimeClientError> {

View File

@@ -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),

View File

@@ -234,6 +234,7 @@ pub mod creation_entry_config_procedure_result_type;
pub mod creation_entry_config_snapshot_type;
pub mod creation_entry_config_table;
pub mod creation_entry_config_type;
pub mod creation_entry_event_banner_snapshot_type;
pub mod creation_entry_start_card_snapshot_type;
pub mod creation_entry_type_admin_upsert_input_type;
pub mod creation_entry_type_config_table;
@@ -1262,6 +1263,7 @@ pub use creation_entry_config_procedure_result_type::CreationEntryConfigProcedur
pub use creation_entry_config_snapshot_type::CreationEntryConfigSnapshot;
pub use creation_entry_config_table::*;
pub use creation_entry_config_type::CreationEntryConfig;
pub use creation_entry_event_banner_snapshot_type::CreationEntryEventBannerSnapshot;
pub use creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot;
pub use creation_entry_type_admin_upsert_input_type::CreationEntryTypeAdminUpsertInput;
pub use creation_entry_type_config_table::*;
@@ -3285,10 +3287,6 @@ impl __sdk::DbUpdate for DbUpdate {
&self.visual_novel_work_profile,
)
.with_updates_by_pk(|row| &row.profile_id);
diff.bark_battle_gallery_view = cache.apply_diff_to_table::<BarkBattleGalleryViewRow>(
"bark_battle_gallery_view",
&self.bark_battle_gallery_view,
);
diff.wooden_fish_agent_session = cache
.apply_diff_to_table::<WoodenFishAgentSessionRow>(
"wooden_fish_agent_session",
@@ -3310,6 +3308,10 @@ impl __sdk::DbUpdate for DbUpdate {
&self.wooden_fish_work_profile,
)
.with_updates_by_pk(|row| &row.profile_id);
diff.bark_battle_gallery_view = cache.apply_diff_to_table::<BarkBattleGalleryViewRow>(
"bark_battle_gallery_view",
&self.bark_battle_gallery_view,
);
diff.big_fish_gallery_view = cache.apply_diff_to_table::<BigFishWorkSummarySnapshot>(
"big_fish_gallery_view",
&self.big_fish_gallery_view,

View File

@@ -4,6 +4,7 @@
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::creation_entry_event_banner_snapshot_type::CreationEntryEventBannerSnapshot;
use super::creation_entry_start_card_snapshot_type::CreationEntryStartCardSnapshot;
use super::creation_entry_type_modal_snapshot_type::CreationEntryTypeModalSnapshot;
use super::creation_entry_type_snapshot_type::CreationEntryTypeSnapshot;
@@ -14,6 +15,7 @@ pub struct CreationEntryConfigSnapshot {
pub config_id: String,
pub start_card: CreationEntryStartCardSnapshot,
pub type_modal: CreationEntryTypeModalSnapshot,
pub event_banner: CreationEntryEventBannerSnapshot,
pub creation_types: Vec<CreationEntryTypeSnapshot>,
pub updated_at_micros: i64,
}

View File

@@ -15,6 +15,12 @@ pub struct CreationEntryConfig {
pub modal_title: String,
pub modal_description: String,
pub updated_at: __sdk::Timestamp,
pub event_title: Option<String>,
pub event_description: Option<String>,
pub event_cover_image_src: Option<String>,
pub event_prize_pool_mud_points: u64,
pub event_starts_at_text: Option<String>,
pub event_ends_at_text: Option<String>,
}
impl __sdk::InModule for CreationEntryConfig {
@@ -33,6 +39,12 @@ pub struct CreationEntryConfigCols {
pub modal_title: __sdk::__query_builder::Col<CreationEntryConfig, String>,
pub modal_description: __sdk::__query_builder::Col<CreationEntryConfig, String>,
pub updated_at: __sdk::__query_builder::Col<CreationEntryConfig, __sdk::Timestamp>,
pub event_title: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
pub event_description: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
pub event_cover_image_src: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
pub event_prize_pool_mud_points: __sdk::__query_builder::Col<CreationEntryConfig, u64>,
pub event_starts_at_text: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
pub event_ends_at_text: __sdk::__query_builder::Col<CreationEntryConfig, Option<String>>,
}
impl __sdk::__query_builder::HasCols for CreationEntryConfig {
@@ -47,6 +59,21 @@ impl __sdk::__query_builder::HasCols for CreationEntryConfig {
modal_title: __sdk::__query_builder::Col::new(table_name, "modal_title"),
modal_description: __sdk::__query_builder::Col::new(table_name, "modal_description"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
event_title: __sdk::__query_builder::Col::new(table_name, "event_title"),
event_description: __sdk::__query_builder::Col::new(table_name, "event_description"),
event_cover_image_src: __sdk::__query_builder::Col::new(
table_name,
"event_cover_image_src",
),
event_prize_pool_mud_points: __sdk::__query_builder::Col::new(
table_name,
"event_prize_pool_mud_points",
),
event_starts_at_text: __sdk::__query_builder::Col::new(
table_name,
"event_starts_at_text",
),
event_ends_at_text: __sdk::__query_builder::Col::new(table_name, "event_ends_at_text"),
}
}
}

View File

@@ -0,0 +1,20 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct CreationEntryEventBannerSnapshot {
pub title: String,
pub description: String,
pub cover_image_src: String,
pub prize_pool_mud_points: u64,
pub starts_at_text: String,
pub ends_at_text: String,
}
impl __sdk::InModule for CreationEntryEventBannerSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -15,6 +15,9 @@ pub struct CreationEntryTypeAdminUpsertInput {
pub visible: bool,
pub open: bool,
pub sort_order: i32,
pub category_id: String,
pub category_label: String,
pub category_sort_order: i32,
}
impl __sdk::InModule for CreationEntryTypeAdminUpsertInput {

View File

@@ -16,6 +16,9 @@ pub struct CreationEntryTypeConfig {
pub open: bool,
pub sort_order: i32,
pub updated_at: __sdk::Timestamp,
pub category_id: Option<String>,
pub category_label: Option<String>,
pub category_sort_order: i32,
}
impl __sdk::InModule for CreationEntryTypeConfig {
@@ -35,6 +38,9 @@ pub struct CreationEntryTypeConfigCols {
pub open: __sdk::__query_builder::Col<CreationEntryTypeConfig, bool>,
pub sort_order: __sdk::__query_builder::Col<CreationEntryTypeConfig, i32>,
pub updated_at: __sdk::__query_builder::Col<CreationEntryTypeConfig, __sdk::Timestamp>,
pub category_id: __sdk::__query_builder::Col<CreationEntryTypeConfig, Option<String>>,
pub category_label: __sdk::__query_builder::Col<CreationEntryTypeConfig, Option<String>>,
pub category_sort_order: __sdk::__query_builder::Col<CreationEntryTypeConfig, i32>,
}
impl __sdk::__query_builder::HasCols for CreationEntryTypeConfig {
@@ -50,6 +56,12 @@ impl __sdk::__query_builder::HasCols for CreationEntryTypeConfig {
open: __sdk::__query_builder::Col::new(table_name, "open"),
sort_order: __sdk::__query_builder::Col::new(table_name, "sort_order"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
category_id: __sdk::__query_builder::Col::new(table_name, "category_id"),
category_label: __sdk::__query_builder::Col::new(table_name, "category_label"),
category_sort_order: __sdk::__query_builder::Col::new(
table_name,
"category_sort_order",
),
}
}
}

View File

@@ -15,6 +15,9 @@ pub struct CreationEntryTypeSnapshot {
pub visible: bool,
pub open: bool,
pub sort_order: i32,
pub category_id: String,
pub category_label: String,
pub category_sort_order: i32,
pub updated_at_micros: i64,
}

View File

@@ -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>,

View File

@@ -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,

View File

@@ -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"),

View File

@@ -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",
),
}
}
}

View File

@@ -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,

View File

@@ -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>,

View File

@@ -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,

View File

@@ -1159,6 +1159,43 @@ where
fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde_json::Value {
let mut next_value = value.clone();
if table_name == "creation_entry_config" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:入口活动横幅字段晚于创作入口配置表加入,旧迁移包按运行态默认横幅兼容。
object
.entry("event_title".to_string())
.or_insert(serde_json::Value::Null);
object
.entry("event_description".to_string())
.or_insert(serde_json::Value::Null);
object
.entry("event_cover_image_src".to_string())
.or_insert(serde_json::Value::Null);
object
.entry("event_prize_pool_mud_points".to_string())
.or_insert_with(|| serde_json::Value::from(58_000));
object
.entry("event_starts_at_text".to_string())
.or_insert(serde_json::Value::Null);
object
.entry("event_ends_at_text".to_string())
.or_insert(serde_json::Value::Null);
}
}
if table_name == "creation_entry_type_config" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:入口分类字段晚于入口类型配置表加入,旧迁移包按未分类兼容。
object
.entry("category_id".to_string())
.or_insert(serde_json::Value::Null);
object
.entry("category_label".to_string())
.or_insert(serde_json::Value::Null);
object
.entry("category_sort_order".to_string())
.or_insert_with(|| serde_json::Value::from(0));
}
}
if table_name == "user_account" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:头像字段晚于认证拆表加入,旧迁移包按未设置头像兼容。
@@ -1271,6 +1308,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

View File

@@ -11,6 +11,18 @@ pub struct CreationEntryConfig {
pub(crate) modal_title: String,
pub(crate) modal_description: String,
pub(crate) updated_at: Timestamp,
#[default(None::<String>)]
pub(crate) event_title: Option<String>,
#[default(None::<String>)]
pub(crate) event_description: Option<String>,
#[default(None::<String>)]
pub(crate) event_cover_image_src: Option<String>,
#[default(DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS)]
pub(crate) event_prize_pool_mud_points: u64,
#[default(None::<String>)]
pub(crate) event_starts_at_text: Option<String>,
#[default(None::<String>)]
pub(crate) event_ends_at_text: Option<String>,
}
#[spacetimedb::table(
@@ -28,6 +40,12 @@ pub struct CreationEntryTypeConfig {
pub(crate) open: bool,
pub(crate) sort_order: i32,
pub(crate) updated_at: Timestamp,
#[default(None::<String>)]
pub(crate) category_id: Option<String>,
#[default(None::<String>)]
pub(crate) category_label: Option<String>,
#[default(0)]
pub(crate) category_sort_order: i32,
}
#[spacetimedb::procedure]
@@ -88,6 +106,9 @@ fn upsert_creation_entry_type_config_in_tx(
open: input.open,
sort_order: input.sort_order,
updated_at: now,
category_id: Some(normalize_category_id(&input.category_id)),
category_label: Some(normalize_category_label(&input.category_label)),
category_sort_order: input.category_sort_order,
};
if ctx.db.creation_entry_type_config().id().find(&id).is_some() {
ctx.db.creation_entry_type_config().id().update(row);
@@ -120,6 +141,9 @@ fn get_or_seed_creation_entry_config_snapshot(
visible: row.visible,
open: row.open,
sort_order: row.sort_order,
category_id: normalize_optional_category_id(row.category_id.as_deref()),
category_label: normalize_optional_category_label(row.category_label.as_deref()),
category_sort_order: row.category_sort_order,
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
})
.collect::<Vec<_>>();
@@ -141,6 +165,29 @@ fn get_or_seed_creation_entry_config_snapshot(
title: header.modal_title,
description: header.modal_description,
},
event_banner: CreationEntryEventBannerSnapshot {
title: normalize_optional_text(
header.event_title.as_deref(),
DEFAULT_CREATION_ENTRY_EVENT_TITLE,
),
description: normalize_optional_text(
header.event_description.as_deref(),
DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION,
),
cover_image_src: normalize_optional_text(
header.event_cover_image_src.as_deref(),
DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC,
),
prize_pool_mud_points: header.event_prize_pool_mud_points,
starts_at_text: normalize_optional_text(
header.event_starts_at_text.as_deref(),
DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT,
),
ends_at_text: normalize_optional_text(
header.event_ends_at_text.as_deref(),
DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT,
),
},
creation_types,
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
})
@@ -164,6 +211,12 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
modal_title: DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(),
modal_description: DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(),
updated_at: now,
event_title: Some(DEFAULT_CREATION_ENTRY_EVENT_TITLE.to_string()),
event_description: Some(DEFAULT_CREATION_ENTRY_EVENT_DESCRIPTION.to_string()),
event_cover_image_src: Some(DEFAULT_CREATION_ENTRY_EVENT_COVER_IMAGE_SRC.to_string()),
event_prize_pool_mud_points: DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS,
event_starts_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string()),
event_ends_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string()),
});
}
@@ -348,6 +401,43 @@ fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeC
open: snapshot.open,
sort_order: snapshot.sort_order,
updated_at: now,
category_id: Some(snapshot.category_id),
category_label: Some(snapshot.category_label),
category_sort_order: snapshot.category_sort_order,
})
.collect()
}
fn normalize_category_id(value: &str) -> String {
let normalized = value.trim();
if normalized.is_empty() {
DEFAULT_CREATION_ENTRY_CATEGORY_ID.to_string()
} else {
normalized.to_string()
}
}
fn normalize_category_label(value: &str) -> String {
let normalized = value.trim();
if normalized.is_empty() {
DEFAULT_CREATION_ENTRY_CATEGORY_LABEL.to_string()
} else {
normalized.to_string()
}
}
fn normalize_optional_category_id(value: Option<&str>) -> String {
normalize_optional_text(value, DEFAULT_CREATION_ENTRY_CATEGORY_ID)
}
fn normalize_optional_category_label(value: Option<&str>) -> String {
normalize_optional_text(value, DEFAULT_CREATION_ENTRY_CATEGORY_LABEL)
}
fn normalize_optional_text(value: Option<&str>, fallback: &str) -> String {
value
.map(str::trim)
.filter(|normalized| !normalized.is_empty())
.unwrap_or(fallback)
.to_string()
}

View File

@@ -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(),

View File

@@ -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(

View File

@@ -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,