feat: refine wooden fish runtime generation

This commit is contained in:
2026-05-22 03:49:35 +08:00
parent d81cc49549
commit 5f1128540e
30 changed files with 804 additions and 126 deletions

View File

@@ -380,17 +380,41 @@ pub(crate) async fn create_openai_image_edit(
reference_image: &OpenAiReferenceImage,
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
create_openai_image_edit_with_references(
http_client,
settings,
prompt,
negative_prompt,
size,
std::slice::from_ref(reference_image),
failure_context,
)
.await
}
pub(crate) async fn create_openai_image_edit_with_references(
http_client: &reqwest::Client,
settings: &OpenAiImageSettings,
prompt: &str,
negative_prompt: Option<&str>,
size: &str,
reference_images: &[OpenAiReferenceImage],
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
if reference_images.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("{failure_context}:缺少参考图"),
})),
);
}
let task_id = format!("vector-engine-edit-{}", current_utc_micros());
let request_url = vector_engine_images_edit_url(settings);
let normalized_size = normalize_image_size(size);
let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone())
.file_name(reference_image.file_name.clone())
.mime_str(reference_image.mime_type.as_str())
.map_err(|error| {
map_openai_image_request_error(format!("{failure_context}:构造参考图失败:{error}"))
})?;
let form = reqwest::multipart::Form::new()
.part("image", image_part)
let mut form = reqwest::multipart::Form::new()
.text("model", GPT_IMAGE_2_MODEL.to_string())
.text(
"prompt",
@@ -398,7 +422,20 @@ pub(crate) async fn create_openai_image_edit(
)
.text("n", "1")
.text("size", normalized_size.clone());
for reference_image in reference_images {
let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone())
.file_name(reference_image.file_name.clone())
.mime_str(reference_image.mime_type.as_str())
.map_err(|error| {
map_openai_image_request_error(format!(
"{failure_context}:构造参考图失败:{error}"
))
})?;
form = form.part("image", image_part);
}
let started_at = std::time::Instant::now();
let reference_image_count = reference_images.len();
let response = match http_client
.post(request_url.as_str())
.header(
@@ -432,7 +469,7 @@ pub(crate) async fn create_openai_image_edit(
None,
Some(latency_ms),
Some(prompt.chars().count()),
Some(1),
Some(reference_image_count),
),
)
.await;
@@ -450,7 +487,7 @@ pub(crate) async fn create_openai_image_edit(
status = response_status.as_u16(),
prompt_chars = prompt.chars().count(),
size = %normalized_size,
reference_image_count = 1usize,
reference_image_count,
elapsed_ms = started_at.elapsed().as_millis() as u64,
failure_context,
"VectorEngine 图片编辑 HTTP 返回"
@@ -478,7 +515,7 @@ pub(crate) async fn create_openai_image_edit(
None,
Some(latency_ms),
Some(prompt.chars().count()),
Some(1),
Some(reference_image_count),
),
)
.await;
@@ -505,7 +542,7 @@ pub(crate) async fn create_openai_image_edit(
Some(truncate_raw(response_text.as_str())),
Some(started_at.elapsed().as_millis() as u64),
Some(prompt.chars().count()),
Some(1),
Some(reference_image_count),
),
)
.await;
@@ -534,7 +571,7 @@ pub(crate) async fn create_openai_image_edit(
Some(truncate_raw(response_text.as_str())),
Some(started_at.elapsed().as_millis() as u64),
Some(prompt.chars().count()),
Some(1),
Some(reference_image_count),
),
)
.await;
@@ -565,7 +602,7 @@ pub(crate) async fn create_openai_image_edit(
None,
Some(download_started_at.elapsed().as_millis() as u64),
Some(prompt.chars().count()),
Some(1),
Some(reference_image_count),
),
)
.await;
@@ -597,7 +634,7 @@ pub(crate) async fn create_openai_image_edit(
Some(truncate_raw(response_text.as_str())),
Some(started_at.elapsed().as_millis() as u64),
Some(prompt.chars().count()),
Some(1),
Some(reference_image_count),
),
)
.await;
@@ -1100,6 +1137,32 @@ mod tests {
);
}
#[tokio::test]
async fn vector_engine_multi_reference_edit_rejects_empty_references() {
let settings = OpenAiImageSettings {
base_url: "https://vector.example".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 1_000_000,
external_api_audit_state: None,
};
let http_client = reqwest::Client::new();
let result = create_openai_image_edit_with_references(
&http_client,
&settings,
"提示词",
None,
"1:1",
&[],
"测试图片编辑失败",
)
.await;
let error = result.expect_err("empty references should be rejected locally");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert!(error.body_text().contains("缺少参考图"));
}
#[test]
fn b64_json_response_decodes_png_image() {
let images = images_from_base64(

View File

@@ -28,14 +28,15 @@ use spacetime_client::SpacetimeClientError;
use crate::generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
adapter::GeneratedImageAssetAdapterMetadata, adapter::GeneratedImageAssetPersistInput,
normalize_generated_image_asset_mime,
decode_generated_image_asset_data_url, normalize_generated_image_asset_mime,
};
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
DownloadedOpenAiImage, OpenAiReferenceImage, build_openai_image_http_client,
create_openai_image_edit, create_openai_image_edit_with_references,
require_openai_image_settings,
},
platform_errors::map_oss_error,
@@ -58,9 +59,15 @@ const DEFAULT_HIT_OBJECT_IMAGE_SRC: &str = "/wooden-fish/default-hit-object.png"
const WOODEN_FISH_ENTITY_KIND: &str = "wooden_fish_work";
const WOODEN_FISH_HIT_OBJECT_SLOT: &str = "hit_object";
const WOODEN_FISH_HIT_OBJECT_ASSET_KIND: &str = "wooden_fish_hit_object";
const WOODEN_FISH_BACKGROUND_SLOT: &str = "background";
const WOODEN_FISH_BACKGROUND_ASSET_KIND: &str = "wooden_fish_background";
const WOODEN_FISH_HIT_SOUND_SLOT: &str = "hit_sound";
const WOODEN_FISH_HIT_SOUND_ASSET_KIND: &str = "wooden_fish_hit_sound";
const WOODEN_FISH_HIT_SOUND_DURATION_SECONDS: u8 = 3;
const DEFAULT_HIT_OBJECT_REFERENCE_BYTES: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../../public/wooden-fish/default-hit-object.png"
));
pub async fn create_wooden_fish_session(
State(state): State<AppState>,
@@ -372,6 +379,7 @@ fn build_wooden_fish_draft(payload: &WoodenFishWorkspaceCreateRequest) -> Wooden
.or_else(|| Some(DEFAULT_HIT_SOUND_PROMPT.to_string())),
floating_words: normalize_floating_words(payload.floating_words.clone()),
hit_object_asset: None,
background_asset: None,
hit_sound_asset: payload.hit_sound_asset.clone(),
cover_image_src: None,
generation_status: WoodenFishGenerationStatus::Draft,
@@ -410,7 +418,7 @@ async fn maybe_generate_hit_object_asset(
) {
return Ok(());
}
if payload.hit_object_asset.is_some() {
if payload.hit_object_asset.is_some() && payload.background_asset.is_some() {
return Ok(());
}
@@ -424,32 +432,21 @@ async fn maybe_generate_hit_object_asset(
.map(|value| clean_string(value, DEFAULT_HIT_OBJECT_PROMPT))
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_HIT_OBJECT_PROMPT.to_string());
let reference_images = payload
.hit_object_reference_image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| vec![value.to_string()])
.unwrap_or_default();
if reference_images.is_empty() && is_default_hit_object_prompt(prompt.as_str()) {
payload.hit_object_asset = Some(default_wooden_fish_hit_object_asset());
return Ok(());
}
let asset = generate_wooden_fish_hit_object_asset(
let generated = generate_wooden_fish_image_assets(
state,
owner_user_id,
session_id,
profile_id.as_str(),
prompt.as_str(),
reference_images.as_slice(),
payload.hit_object_reference_image_src.as_deref(),
)
.await
.map_err(|error| {
wooden_fish_error_response(request_context, WOODEN_FISH_CREATION_PROVIDER, error)
})?;
payload.hit_object_asset = Some(asset);
payload.hit_object_asset = Some(generated.hit_object_asset);
payload.background_asset = Some(generated.background_asset);
Ok(())
}
@@ -469,8 +466,7 @@ fn default_wooden_fish_hit_object_asset() -> WoodenFishImageAsset {
fn is_default_hit_object_prompt(prompt: &str) -> bool {
let normalized = normalize_hit_object_prompt_for_default_match(prompt);
normalized.is_empty()
|| normalized
== normalize_hit_object_prompt_for_default_match(DEFAULT_HIT_OBJECT_PROMPT)
|| normalized == normalize_hit_object_prompt_for_default_match(DEFAULT_HIT_OBJECT_PROMPT)
|| normalized
== normalize_hit_object_prompt_for_default_match("卡通木鱼,圆润可爱,透明背景")
|| normalized
@@ -655,64 +651,229 @@ fn map_generated_creation_audio_to_wooden_fish_asset(
})
}
async fn generate_wooden_fish_hit_object_asset(
struct WoodenFishGeneratedImageAssets {
hit_object_asset: WoodenFishImageAsset,
background_asset: WoodenFishImageAsset,
}
async fn generate_wooden_fish_image_assets(
state: &AppState,
owner_user_id: &str,
session_id: &str,
profile_id: &str,
prompt: &str,
reference_images: &[String],
) -> Result<WoodenFishImageAsset, AppError> {
hit_object_reference_image_src: Option<&str>,
) -> Result<WoodenFishGeneratedImageAssets, AppError> {
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let final_prompt = build_wooden_fish_hit_object_prompt(prompt);
let generated = create_openai_image_generation(
let clean_reference_image_src = hit_object_reference_image_src
.map(str::trim)
.filter(|value| !value.is_empty());
let theme = resolve_wooden_fish_generation_theme(prompt, clean_reference_image_src);
let default_reference_image = default_wooden_fish_reference_image()?;
let theme_reference_image =
resolve_wooden_fish_theme_reference_image(clean_reference_image_src)?;
let (hit_object_asset, background_reference_image) =
if should_generate_wooden_fish_hit_object(prompt, clean_reference_image_src) {
let hit_object_prompt = build_wooden_fish_hit_object_prompt(theme.as_str());
let mut reference_images = vec![default_reference_image.clone()];
if let Some(reference_image) = theme_reference_image {
reference_images.push(reference_image);
}
let generated = create_openai_image_edit_with_references(
&http_client,
&settings,
hit_object_prompt.as_str(),
None,
"1:1",
reference_images.as_slice(),
"生成敲木鱼敲击物图案失败",
)
.await?;
let task_id = generated.task_id.clone();
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "生成敲木鱼敲击物图案失败:上游未返回图片",
}))
})?;
let background_reference_image =
downloaded_wooden_fish_reference_image(&image, "wooden-fish-generated-hit-object");
let hit_object_asset = persist_wooden_fish_image_asset(
state,
owner_user_id,
session_id,
profile_id,
task_id.as_str(),
hit_object_prompt.as_str(),
image,
current_utc_micros(),
WoodenFishImageSlotPersistSpec {
slot: WOODEN_FISH_HIT_OBJECT_SLOT,
asset_kind: WOODEN_FISH_HIT_OBJECT_ASSET_KIND,
asset_id_part: "hit-object",
width: 1024,
height: 1024,
},
)
.await?;
(hit_object_asset, background_reference_image)
} else {
(
default_wooden_fish_hit_object_asset(),
default_reference_image,
)
};
let background_prompt = build_wooden_fish_background_prompt(theme.as_str());
let background_generated = create_openai_image_edit(
&http_client,
&settings,
final_prompt.as_str(),
Some(build_wooden_fish_hit_object_negative_prompt().as_str()),
"1024x1024",
1,
reference_images,
"生成敲木鱼敲击物图案失败",
background_prompt.as_str(),
None,
"9:16",
&background_reference_image,
"生成敲木鱼背景环境图失败",
)
.await?;
let task_id = generated.task_id.clone();
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "生成敲木鱼敲击物图案失败:上游未返回图片",
}))
})?;
let generated_at_micros = current_utc_micros();
let persisted = persist_wooden_fish_hit_object_asset(
let background_task_id = background_generated.task_id.clone();
let background_image = background_generated
.images
.into_iter()
.next()
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "生成敲木鱼背景环境图失败:上游未返回图片",
}))
})?;
let background_asset = persist_wooden_fish_image_asset(
state,
owner_user_id,
session_id,
profile_id,
task_id.as_str(),
&final_prompt,
image,
generated_at_micros,
background_task_id.as_str(),
background_prompt.as_str(),
background_image,
current_utc_micros(),
WoodenFishImageSlotPersistSpec {
slot: WOODEN_FISH_BACKGROUND_SLOT,
asset_kind: WOODEN_FISH_BACKGROUND_ASSET_KIND,
asset_id_part: "background",
width: 1024,
height: 1536,
},
)
.await?;
Ok(persisted)
Ok(WoodenFishGeneratedImageAssets {
hit_object_asset,
background_asset,
})
}
fn build_wooden_fish_hit_object_prompt(prompt: &str) -> String {
format!(
"请使用 gpt-image-2 生成一个适合点击敲击玩法的单个物品图案:{}。画面要求单个主体卡通插画风格透明或纯净浅色背景居中构图圆润可爱边缘清晰适合移动端屏幕中央展示和点击动画缩放。不要包含文字、按钮、UI、边框、水印、品牌标识或人物手部。",
"生成敲木鱼新样式,要求结构,画风与参考图保持高度一致,新样式颜色搭配使用新主题对应的颜色。\n新主题为:{}",
clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT)
)
}
fn build_wooden_fish_hit_object_negative_prompt() -> String {
"不要生成文字、Logo、水印、按钮、界面截图、复杂背景、多个主体、真实摄影质感、恐怖或血腥元素。"
.to_string()
fn build_wooden_fish_background_prompt(prompt: &str) -> String {
format!(
"生成敲木鱼背景,要求主题,画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。\n主题为:{}",
clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT)
)
}
async fn persist_wooden_fish_hit_object_asset(
fn should_generate_wooden_fish_hit_object(
prompt: &str,
hit_object_reference_image_src: Option<&str>,
) -> bool {
hit_object_reference_image_src.is_some() || !is_default_hit_object_prompt(prompt)
}
fn resolve_wooden_fish_generation_theme(
prompt: &str,
hit_object_reference_image_src: Option<&str>,
) -> String {
let prompt = clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT);
if !is_default_hit_object_prompt(prompt.as_str()) {
return prompt;
}
if hit_object_reference_image_src.is_some() {
return "用户提供参考图".to_string();
}
prompt
}
fn default_wooden_fish_reference_image() -> Result<OpenAiReferenceImage, AppError> {
let bytes = DEFAULT_HIT_OBJECT_REFERENCE_BYTES.to_vec();
if bytes.is_empty() {
return Err(
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": WOODEN_FISH_CREATION_PROVIDER,
"message": "敲木鱼默认参考图为空",
})),
);
}
Ok(OpenAiReferenceImage {
bytes,
mime_type: "image/png".to_string(),
file_name: "wooden-fish-default-hit-object-reference.png".to_string(),
})
}
fn resolve_wooden_fish_theme_reference_image(
source: Option<&str>,
) -> Result<Option<OpenAiReferenceImage>, AppError> {
let Some(source) = source.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
if !source.to_ascii_lowercase().starts_with("data:image/") {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": WOODEN_FISH_CREATION_PROVIDER,
"field": "hitObjectReferenceImageSrc",
"message": "敲木鱼参考图必须是 base64 图片 Data URL。",
})),
);
}
let decoded = decode_generated_image_asset_data_url(source).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": WOODEN_FISH_CREATION_PROVIDER,
"field": "hitObjectReferenceImageSrc",
"message": "敲木鱼参考图必须是 base64 图片 Data URL。",
}))
})?;
Ok(Some(OpenAiReferenceImage {
file_name: format!("wooden-fish-theme-reference.{}", decoded.format.extension),
mime_type: decoded.format.mime_type,
bytes: decoded.bytes,
}))
}
fn downloaded_wooden_fish_reference_image(
image: &DownloadedOpenAiImage,
file_name_stem: &str,
) -> OpenAiReferenceImage {
OpenAiReferenceImage {
bytes: image.bytes.clone(),
mime_type: image.mime_type.clone(),
file_name: format!("{file_name_stem}.{}", image.extension),
}
}
struct WoodenFishImageSlotPersistSpec {
slot: &'static str,
asset_kind: &'static str,
asset_id_part: &'static str,
width: u32,
height: u32,
}
async fn persist_wooden_fish_image_asset(
state: &AppState,
owner_user_id: &str,
session_id: &str,
@@ -721,6 +882,7 @@ async fn persist_wooden_fish_hit_object_asset(
prompt: &str,
image: DownloadedOpenAiImage,
generated_at_micros: i64,
spec: WoodenFishImageSlotPersistSpec,
) -> Result<WoodenFishImageAsset, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
@@ -735,7 +897,7 @@ async fn persist_wooden_fish_hit_object_asset(
path_segments: vec![
sanitize_wooden_fish_asset_segment(session_id, "session"),
sanitize_wooden_fish_asset_segment(profile_id, "profile"),
WOODEN_FISH_HIT_OBJECT_SLOT.to_string(),
spec.slot.to_string(),
format!("asset-{generated_at_micros}"),
],
file_stem: "image".to_string(),
@@ -745,11 +907,11 @@ async fn persist_wooden_fish_hit_object_asset(
},
access: OssObjectAccess::Private,
metadata: GeneratedImageAssetAdapterMetadata {
asset_kind: Some(WOODEN_FISH_HIT_OBJECT_ASSET_KIND.to_string()),
asset_kind: Some(spec.asset_kind.to_string()),
owner_user_id: Some(owner_user_id.to_string()),
entity_kind: Some(WOODEN_FISH_ENTITY_KIND.to_string()),
entity_id: Some(profile_id.to_string()),
slot: Some(WOODEN_FISH_HIT_OBJECT_SLOT.to_string()),
slot: Some(spec.slot.to_string()),
provider: Some("image2".to_string()),
task_id: Some(task_id.to_string()),
},
@@ -784,7 +946,7 @@ async fn persist_wooden_fish_hit_object_asset(
head.content_type.or(Some(persisted_mime_type)),
head.content_length,
head.etag,
WOODEN_FISH_HIT_OBJECT_ASSET_KIND.to_string(),
spec.asset_kind.to_string(),
Some(task_id.to_string()),
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
@@ -808,8 +970,8 @@ async fn persist_wooden_fish_hit_object_asset(
asset_object.asset_object_id.clone(),
WOODEN_FISH_ENTITY_KIND.to_string(),
profile_id.to_string(),
WOODEN_FISH_HIT_OBJECT_SLOT.to_string(),
WOODEN_FISH_HIT_OBJECT_ASSET_KIND.to_string(),
spec.slot.to_string(),
spec.asset_kind.to_string(),
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
generated_at_micros,
@@ -823,20 +985,21 @@ async fn persist_wooden_fish_hit_object_asset(
owner_user_id,
session_id,
profile_id,
slot = spec.slot,
error = %error,
"敲木鱼图片资产绑定失败,历史素材索引可能缺少绑定记录"
);
}
Ok(WoodenFishImageAsset {
asset_id: format!("{profile_id}-hit-object-{generated_at_micros}"),
asset_id: format!("{profile_id}-{}-{generated_at_micros}", spec.asset_id_part),
image_src: put_result.legacy_public_path,
image_object_key: head.object_key,
asset_object_id: asset_object.asset_object_id,
generation_provider: "image2".to_string(),
prompt: prompt.to_string(),
width: 1024,
height: 1024,
width: spec.width,
height: spec.height,
})
}
@@ -1027,15 +1190,51 @@ fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use super::*;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
#[test]
fn wooden_fish_hit_object_prompt_keeps_user_object_and_image2_constraints() {
fn wooden_fish_hit_object_prompt_uses_hidden_image2_flow() {
let prompt = build_wooden_fish_hit_object_prompt("赛博莲花木鱼");
assert!(prompt.contains("赛博莲花木鱼"));
assert!(prompt.contains("gpt-image-2"));
assert!(prompt.contains("单个主体"));
assert!(prompt.contains("不要包含文字"));
assert_eq!(
prompt,
"生成敲木鱼新样式,要求结构,画风与参考图保持高度一致,新样式颜色搭配使用新主题对应的颜色。\n新主题为:赛博莲花木鱼"
);
}
#[test]
fn wooden_fish_background_prompt_uses_hidden_image2_flow() {
let prompt = build_wooden_fish_background_prompt("赛博莲花木鱼");
assert_eq!(
prompt,
"生成敲木鱼背景,要求主题,画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。\n主题为:赛博莲花木鱼"
);
}
#[test]
fn wooden_fish_theme_reference_image_decodes_data_url_for_image2() {
let source = format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nreference")
);
let image = resolve_wooden_fish_theme_reference_image(Some(source.as_str()))
.expect("data url should parse")
.expect("reference image should exist");
assert_eq!(image.mime_type, "image/png");
assert_eq!(image.file_name, "wooden-fish-theme-reference.png");
assert!(image.bytes.starts_with(b"\x89PNG\r\n\x1A\n"));
}
#[test]
fn wooden_fish_theme_reference_image_rejects_non_data_url() {
let error = resolve_wooden_fish_theme_reference_image(Some("/generated/example.png"))
.expect_err("legacy path should not be accepted as direct image2 reference");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert!(error.body_text().contains("Data URL"));
}
#[test]
@@ -1053,7 +1252,9 @@ mod tests {
fn wooden_fish_default_prompt_matches_legacy_defaults() {
assert!(is_default_hit_object_prompt(DEFAULT_HIT_OBJECT_PROMPT));
assert!(is_default_hit_object_prompt("卡通木鱼,圆润可爱,透明背景"));
assert!(is_default_hit_object_prompt("卡通木鱼,透明背景,居中,圆润可爱"));
assert!(is_default_hit_object_prompt(
"卡通木鱼,透明背景,居中,圆润可爱"
));
assert!(is_default_hit_object_prompt("卡通木鱼"));
assert!(!is_default_hit_object_prompt("赛博莲花木鱼"));
}

View File

@@ -90,6 +90,9 @@ pub struct WoodenFishActionRequest {
#[serde(default, skip_deserializing)]
pub hit_object_asset: Option<WoodenFishImageAsset>,
#[serde(default)]
#[serde(skip_deserializing)]
pub background_asset: Option<WoodenFishImageAsset>,
#[serde(default)]
pub hit_sound_prompt: Option<String>,
#[serde(default)]
pub hit_sound_asset: Option<WoodenFishAudioAsset>,
@@ -123,6 +126,8 @@ pub struct WoodenFishDraftResponse {
#[serde(default)]
pub hit_object_asset: Option<WoodenFishImageAsset>,
#[serde(default)]
pub background_asset: Option<WoodenFishImageAsset>,
#[serde(default)]
pub hit_sound_asset: Option<WoodenFishAudioAsset>,
#[serde(default)]
pub cover_image_src: Option<String>,
@@ -185,6 +190,8 @@ pub struct WoodenFishWorkProfileResponse {
pub summary: WoodenFishWorkSummaryResponse,
pub draft: WoodenFishDraftResponse,
pub hit_object_asset: WoodenFishImageAsset,
#[serde(default)]
pub background_asset: Option<WoodenFishImageAsset>,
pub hit_sound_asset: WoodenFishAudioAsset,
pub floating_words: Vec<String>,
}
@@ -365,6 +372,18 @@ mod tests {
width: 1024,
height: 1024,
}),
background_asset: Some(WoodenFishImageAsset {
asset_id: "background-1".to_string(),
image_src: "/generated-wooden-fish-assets/profile/background/image.png"
.to_string(),
image_object_key: "generated-wooden-fish-assets/profile/background/image.png"
.to_string(),
asset_object_id: "background-object-1".to_string(),
generation_provider: "image2".to_string(),
prompt: "赛博莲花背景".to_string(),
width: 1024,
height: 1536,
}),
hit_sound_prompt: Some("短促木鱼声".to_string()),
hit_sound_asset: Some(WoodenFishAudioAsset {
asset_id: "sound-1".to_string(),
@@ -386,6 +405,7 @@ mod tests {
payload["hitObjectAsset"]["imageObjectKey"],
json!("generated-wooden-fish-assets/profile/hit-object/image.png")
);
assert_eq!(payload["backgroundAsset"]["height"], json!(1536));
assert_eq!(payload["hitSoundAsset"]["source"], json!("upload"));
assert_eq!(payload["hitSoundAsset"]["durationMs"], json!(800));
}
@@ -464,11 +484,13 @@ mod tests {
hit_sound_prompt: Some("清脆木鱼".to_string()),
floating_words: vec!["功德".to_string()],
hit_object_asset: Some(image.clone()),
background_asset: None,
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,
hit_sound_asset: audio,
floating_words: vec!["功德".to_string()],
};

View File

@@ -112,6 +112,7 @@ fn map_wooden_fish_work_snapshot(
hit_sound_prompt: snapshot.hit_sound_prompt.clone(),
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),
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),
@@ -145,6 +146,7 @@ fn map_wooden_fish_work_snapshot(
},
draft,
hit_object_asset,
background_asset: snapshot.background_asset.map(map_image_asset),
hit_sound_asset,
floating_words: snapshot.floating_words,
})
@@ -163,6 +165,7 @@ fn map_wooden_fish_draft_snapshot(snapshot: WoodenFishDraftSnapshot) -> WoodenFi
hit_sound_prompt: snapshot.hit_sound_prompt,
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),
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

@@ -18,6 +18,7 @@ pub struct WoodenFishDraftCompileInput {
pub hit_object_reference_image_src: Option<String>,
pub hit_sound_prompt: Option<String>,
pub hit_object_asset_json: Option<String>,
pub background_asset_json: Option<String>,
pub hit_sound_asset_json: Option<String>,
pub floating_words_json: Option<String>,
pub cover_image_src: Option<String>,

View File

@@ -21,6 +21,7 @@ pub struct WoodenFishDraftSnapshot {
pub hit_sound_prompt: Option<String>,
pub floating_words: Vec<String>,
pub hit_object_asset: Option<WoodenFishImageAssetSnapshot>,
pub background_asset: Option<WoodenFishImageAssetSnapshot>,
pub hit_sound_asset: Option<WoodenFishAudioAssetSnapshot>,
pub cover_image_src: Option<String>,
pub generation_status: String,

View File

@@ -23,6 +23,7 @@ pub struct WoodenFishGalleryViewRow {
pub hit_object_reference_image_src: Option<String>,
pub hit_sound_prompt: Option<String>,
pub hit_object_asset: Option<WoodenFishImageAssetSnapshot>,
pub background_asset: Option<WoodenFishImageAssetSnapshot>,
pub hit_sound_asset: Option<WoodenFishAudioAssetSnapshot>,
pub floating_words: Vec<String>,
pub cover_image_src: String,
@@ -57,6 +58,8 @@ pub struct WoodenFishGalleryViewRowCols {
pub hit_sound_prompt: __sdk::__query_builder::Col<WoodenFishGalleryViewRow, Option<String>>,
pub hit_object_asset:
__sdk::__query_builder::Col<WoodenFishGalleryViewRow, Option<WoodenFishImageAssetSnapshot>>,
pub background_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>>,
@@ -92,6 +95,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"),
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

@@ -27,6 +27,7 @@ pub struct WoodenFishWorkProfileRow {
pub play_count: u32,
pub updated_at: __sdk::Timestamp,
pub published_at: Option<__sdk::Timestamp>,
pub background_asset_json: Option<String>,
}
impl __sdk::InModule for WoodenFishWorkProfileRow {
@@ -59,6 +60,8 @@ pub struct WoodenFishWorkProfileRowCols {
pub updated_at: __sdk::__query_builder::Col<WoodenFishWorkProfileRow, __sdk::Timestamp>,
pub published_at:
__sdk::__query_builder::Col<WoodenFishWorkProfileRow, Option<__sdk::Timestamp>>,
pub background_asset_json:
__sdk::__query_builder::Col<WoodenFishWorkProfileRow, Option<String>>,
}
impl __sdk::__query_builder::HasCols for WoodenFishWorkProfileRow {
@@ -100,6 +103,10 @@ impl __sdk::__query_builder::HasCols for WoodenFishWorkProfileRow {
play_count: __sdk::__query_builder::Col::new(table_name, "play_count"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
published_at: __sdk::__query_builder::Col::new(table_name, "published_at"),
background_asset_json: __sdk::__query_builder::Col::new(
table_name,
"background_asset_json",
),
}
}
}

View File

@@ -22,6 +22,7 @@ pub struct WoodenFishWorkSnapshot {
pub hit_object_reference_image_src: Option<String>,
pub hit_sound_prompt: Option<String>,
pub hit_object_asset: Option<WoodenFishImageAssetSnapshot>,
pub background_asset: Option<WoodenFishImageAssetSnapshot>,
pub hit_sound_asset: Option<WoodenFishAudioAssetSnapshot>,
pub floating_words: Vec<String>,
pub cover_image_src: String,

View File

@@ -16,6 +16,7 @@ pub struct WoodenFishWorkUpdateInput {
pub hit_object_reference_image_src: Option<String>,
pub hit_sound_prompt: Option<String>,
pub hit_object_asset_json: Option<String>,
pub background_asset_json: Option<String>,
pub hit_sound_asset_json: Option<String>,
pub floating_words_json: Option<String>,
pub cover_image_src: Option<String>,

View File

@@ -529,6 +529,9 @@ fn merge_action_into_draft(
if let Some(asset) = payload.hit_object_asset.clone() {
draft.hit_object_asset = Some(asset);
}
if let Some(asset) = payload.background_asset.clone() {
draft.background_asset = Some(asset);
}
}
if matches!(
scope,
@@ -573,6 +576,7 @@ fn merge_action_into_draft(
&& payload.hit_object_asset.is_none()
{
draft.hit_object_asset = None;
draft.background_asset = None;
}
if draft.floating_words.is_empty() {
draft.floating_words = default_floating_words();
@@ -606,6 +610,9 @@ fn build_compile_input(
let hit_sound_asset = draft.hit_sound_asset.clone().ok_or_else(|| {
SpacetimeClientError::validation_failed("wooden fish hit sound asset 缺少真实生成资产")
})?;
let background_asset = draft.background_asset.clone().ok_or_else(|| {
SpacetimeClientError::validation_failed("wooden fish background asset 缺少真实生成资产")
})?;
Ok(WoodenFishDraftCompileInput {
session_id: current.session_id.clone(),
@@ -619,6 +626,7 @@ fn build_compile_input(
hit_object_reference_image_src: draft.hit_object_reference_image_src.clone(),
hit_sound_prompt: draft.hit_sound_prompt.clone(),
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)?),
floating_words_json: Some(json_string(&draft.floating_words)?),
cover_image_src: draft.cover_image_src.clone(),
@@ -644,6 +652,7 @@ fn build_update_input(
hit_object_reference_image_src: draft.hit_object_reference_image_src.clone(),
hit_sound_prompt: draft.hit_sound_prompt.clone(),
hit_object_asset_json: None,
background_asset_json: None,
hit_sound_asset_json: if include_hit_sound_asset {
draft
.hit_sound_asset
@@ -710,6 +719,7 @@ fn default_draft() -> WoodenFishDraftResponse {
hit_sound_prompt: Some(DEFAULT_HIT_SOUND_PROMPT.to_string()),
floating_words: default_floating_words(),
hit_object_asset: None,
background_asset: None,
hit_sound_asset: None,
cover_image_src: None,
generation_status: WoodenFishGenerationStatus::Draft,
@@ -796,6 +806,7 @@ mod tests {
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 (plan, draft) =
@@ -822,6 +833,13 @@ mod tests {
.unwrap_or("")
.contains("generated-compile-sound")
);
assert!(
input
.background_asset_json
.as_deref()
.unwrap_or("")
.contains("generated-compile-background")
);
assert_eq!(draft.generation_status, WoodenFishGenerationStatus::Ready);
}
@@ -830,6 +848,7 @@ mod tests {
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"));
let error =
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) {
@@ -844,12 +863,33 @@ mod tests {
);
}
#[test]
fn wooden_fish_compile_requires_real_background_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.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 background asset"),
Err(error) => error,
};
assert!(
error
.to_string()
.contains("background asset 缺少真实生成资产")
);
}
#[test]
fn wooden_fish_action_regenerate_hit_object_replaces_only_object_asset() {
let session = session_with_draft(draft_with_assets());
let mut payload = action(WoodenFishActionType::RegenerateHitObject);
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"));
let (plan, _draft) =
build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
@@ -886,6 +926,13 @@ mod tests {
.unwrap_or("")
.contains("old-sound")
);
assert!(
input
.background_asset_json
.as_deref()
.unwrap_or("")
.contains("generated-background")
);
}
#[test]
@@ -930,6 +977,7 @@ mod tests {
hit_object_prompt: None,
hit_object_reference_image_src: None,
hit_object_asset: None,
background_asset: None,
hit_sound_prompt: None,
hit_sound_asset: None,
floating_words: None,
@@ -969,6 +1017,21 @@ mod tests {
}
}
fn generated_background_asset(asset_id: &str) -> WoodenFishImageAsset {
WoodenFishImageAsset {
asset_id: asset_id.to_string(),
image_src: "/generated-wooden-fish-assets/real-profile/background/image.png"
.to_string(),
image_object_key: "generated-wooden-fish-assets/real-profile/background/image.png"
.to_string(),
asset_object_id: format!("{asset_id}-asset"),
generation_provider: "image2".to_string(),
prompt: "新的敲击背景".to_string(),
width: 1024,
height: 1536,
}
}
fn generated_hit_sound_asset(asset_id: &str) -> WoodenFishAudioAsset {
WoodenFishAudioAsset {
asset_id: asset_id.to_string(),
@@ -995,6 +1058,16 @@ mod tests {
width: 1024,
height: 1024,
}),
background_asset: Some(WoodenFishImageAsset {
asset_id: "old-background".to_string(),
image_src: "/generated-wooden-fish-assets/old-background.png".to_string(),
image_object_key: "generated-wooden-fish-assets/old-background.png".to_string(),
asset_object_id: "old-background-asset".to_string(),
generation_provider: "image2".to_string(),
prompt: "旧背景".to_string(),
width: 1024,
height: 1536,
}),
hit_sound_asset: Some(WoodenFishAudioAsset {
asset_id: "old-sound".to_string(),
audio_src: "/generated-wooden-fish-assets/old-sound.mp3".to_string(),
@@ -1023,6 +1096,7 @@ mod tests {
hit_sound_prompt: Some("旧音效".to_string()),
floating_words: default_floating_words(),
hit_object_asset: None,
background_asset: None,
hit_sound_asset: None,
cover_image_src: None,
generation_status: WoodenFishGenerationStatus::Draft,

View File

@@ -1265,6 +1265,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
.or_insert(serde_json::Value::Null);
}
}
if table_name == "wooden_fish_work_profile" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:敲木鱼背景环境图晚于首版作品表加入,旧迁移包按未生成背景兼容。
object
.entry("background_asset_json".to_string())
.or_insert(serde_json::Value::Null);
}
}
next_value
}

View File

@@ -81,6 +81,7 @@ pub struct WoodenFishGalleryViewRow {
pub hit_object_reference_image_src: Option<String>,
pub hit_sound_prompt: Option<String>,
pub hit_object_asset: Option<WoodenFishImageAssetSnapshot>,
pub background_asset: Option<WoodenFishImageAssetSnapshot>,
pub hit_sound_asset: Option<WoodenFishAudioAssetSnapshot>,
pub floating_words: Vec<String>,
pub cover_image_src: String,
@@ -327,6 +328,11 @@ fn compile_wooden_fish_draft_tx(
.as_deref()
.map(parse_json)
.transpose()?;
let background_asset = input
.background_asset_json
.as_deref()
.map(parse_json)
.transpose()?;
let cover_image_src = input
.cover_image_src
.as_deref()
@@ -354,6 +360,7 @@ fn compile_wooden_fish_draft_tx(
hit_sound_prompt: input.hit_sound_prompt.as_deref().and_then(clean_optional),
floating_words: floating_words.clone(),
hit_object_asset: hit_object_asset.clone(),
background_asset: background_asset.clone(),
hit_sound_asset: hit_sound_asset.clone(),
cover_image_src: cover_image_src.clone(),
generation_status: input
@@ -392,6 +399,7 @@ fn compile_wooden_fish_draft_tx(
play_count: 0,
updated_at: compiled_at,
published_at: None,
background_asset_json: background_asset.as_ref().map(to_json_string),
};
upsert_work(ctx, row);
let config = config_from_draft(&draft);
@@ -469,6 +477,14 @@ fn update_wooden_fish_work_tx(
let asset = parse_json::<WoodenFishAudioAssetSnapshot>(&value)?;
next.hit_sound_asset_json = to_json_string(&asset);
}
if let Some(value) = input
.background_asset_json
.as_deref()
.and_then(clean_optional)
{
let asset = parse_json::<WoodenFishImageAssetSnapshot>(&value)?;
next.background_asset_json = Some(to_json_string(&asset));
}
if let Some(value) = input
.floating_words_json
.as_deref()
@@ -674,6 +690,7 @@ fn build_gallery_view_row(
hit_object_reference_image_src: work.hit_object_reference_image_src,
hit_sound_prompt: work.hit_sound_prompt,
hit_object_asset: work.hit_object_asset,
background_asset: work.background_asset,
hit_sound_asset: work.hit_sound_asset,
floating_words: work.floating_words,
cover_image_src: work.cover_image_src,
@@ -721,6 +738,12 @@ fn build_work_snapshot(row: &WoodenFishWorkProfileRow) -> Result<WoodenFishWorkS
hit_object_asset: clean_optional(&row.hit_object_asset_json)
.map(|value| parse_json(&value))
.transpose()?,
background_asset: row
.background_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()?,
@@ -965,6 +988,11 @@ fn insert_event(
fn is_publish_ready(row: &WoodenFishWorkProfileRow) -> bool {
!row.work_title.trim().is_empty()
&& !row.hit_object_asset_json.trim().is_empty()
&& row
.background_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
@@ -1002,6 +1030,7 @@ fn draft_from_config(
hit_sound_prompt: config.hit_sound_prompt.clone(),
floating_words: normalize_floating_words(&config.floating_words),
hit_object_asset: None,
background_asset: None,
hit_sound_asset: None,
cover_image_src: None,
generation_status: generation_status.to_string(),
@@ -1021,6 +1050,7 @@ fn draft_from_work_snapshot(work: &WoodenFishWorkSnapshot) -> WoodenFishDraftSna
hit_sound_prompt: work.hit_sound_prompt.clone(),
floating_words: work.floating_words.clone(),
hit_object_asset: work.hit_object_asset.clone(),
background_asset: work.background_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(),
@@ -1199,6 +1229,7 @@ fn clone_work(row: &WoodenFishWorkProfileRow) -> WoodenFishWorkProfileRow {
hit_object_reference_image_src: row.hit_object_reference_image_src.clone(),
hit_sound_prompt: row.hit_sound_prompt.clone(),
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(),
floating_words_json: row.floating_words_json.clone(),
cover_image_src: row.cover_image_src.clone(),

View File

@@ -45,6 +45,8 @@ pub struct WoodenFishWorkProfileRow {
pub(crate) play_count: u32,
pub(crate) updated_at: Timestamp,
pub(crate) published_at: Option<Timestamp>,
#[default(None::<String>)]
pub(crate) background_asset_json: Option<String>,
}
#[spacetimedb::table(

View File

@@ -45,6 +45,7 @@ pub struct WoodenFishDraftCompileInput {
pub hit_object_reference_image_src: Option<String>,
pub hit_sound_prompt: Option<String>,
pub hit_object_asset_json: Option<String>,
pub background_asset_json: Option<String>,
pub hit_sound_asset_json: Option<String>,
pub floating_words_json: Option<String>,
pub cover_image_src: Option<String>,
@@ -63,6 +64,7 @@ pub struct WoodenFishWorkUpdateInput {
pub hit_object_reference_image_src: Option<String>,
pub hit_sound_prompt: Option<String>,
pub hit_object_asset_json: Option<String>,
pub background_asset_json: Option<String>,
pub hit_sound_asset_json: Option<String>,
pub floating_words_json: Option<String>,
pub cover_image_src: Option<String>,
@@ -207,6 +209,7 @@ pub struct WoodenFishDraftSnapshot {
pub hit_sound_prompt: Option<String>,
pub floating_words: Vec<String>,
pub hit_object_asset: Option<WoodenFishImageAssetSnapshot>,
pub background_asset: Option<WoodenFishImageAssetSnapshot>,
pub hit_sound_asset: Option<WoodenFishAudioAssetSnapshot>,
pub cover_image_src: Option<String>,
pub generation_status: String,
@@ -242,6 +245,7 @@ pub struct WoodenFishWorkSnapshot {
pub hit_object_reference_image_src: Option<String>,
pub hit_sound_prompt: Option<String>,
pub hit_object_asset: Option<WoodenFishImageAssetSnapshot>,
pub background_asset: Option<WoodenFishImageAssetSnapshot>,
pub hit_sound_asset: Option<WoodenFishAudioAssetSnapshot>,
pub floating_words: Vec<String>,
pub cover_image_src: String,