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("默认木鱼音"));
}
}