feat: add edutainment drawing and visual package flows

This commit is contained in:
2026-05-14 14:17:10 +08:00
parent 10e8beea80
commit e444266e1e
109 changed files with 8788 additions and 996 deletions

View File

@@ -77,6 +77,8 @@ use crate::{
generate_custom_world_opening_cg, generate_custom_world_scene_image,
generate_custom_world_scene_npc, upload_custom_world_cover_image,
},
edutainment_baby_drawing::create_baby_love_drawing_magic,
edutainment_baby_object::generate_baby_object_match_assets,
error_middleware::normalize_error_response,
health::health_check,
hyper3d_generation::{
@@ -184,6 +186,7 @@ use crate::{
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
const PROFILE_FEEDBACK_BODY_LIMIT_BYTES: usize = 6 * 1024 * 1024;
const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024;
const BABY_LOVE_DRAWING_MAGIC_BODY_LIMIT_BYTES: usize = 8 * 1024 * 1024;
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
pub fn build_router(state: AppState) -> Router {
@@ -647,6 +650,24 @@ pub fn build_router(state: AppState) -> Router {
"/api/creation-entry/config",
get(get_creation_entry_config_handler),
)
.route(
"/api/creation/edutainment/baby-object-match/assets",
post(generate_baby_object_match_assets).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/edutainment/baby-love-drawing/magic",
post(create_baby_love_drawing_magic)
.layer(DefaultBodyLimit::max(
BABY_LOVE_DRAWING_MAGIC_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/settings",
get(get_runtime_settings)

View File

@@ -90,6 +90,12 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
if normalized.starts_with("/api/creation/visual-novel") {
return Some("visual-novel");
}
if normalized.starts_with("/api/creation/edutainment/baby-object-match") {
return Some("baby-object-match");
}
if normalized.starts_with("/api/creation/edutainment/baby-love-drawing") {
return Some("baby-love-drawing");
}
None
}
@@ -112,40 +118,11 @@ pub(crate) fn test_creation_entry_config_response()
title: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(),
description: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(),
},
creation_types: vec![
test_creation_type("rpg", false, true, 10),
test_creation_type("big-fish", false, true, 20),
test_creation_type("puzzle", true, true, 30),
test_creation_type("match3d", true, true, 40),
test_creation_type("square-hole", false, true, 50),
test_creation_type("visual-novel", true, false, 60),
test_creation_type("airp", true, false, 70),
test_creation_type("creative-agent", false, true, 80),
],
creation_types: module_runtime::default_creation_entry_type_snapshots(0),
updated_at_micros: 0,
})
}
#[cfg(test)]
fn test_creation_type(
id: &str,
visible: bool,
open: bool,
sort_order: i32,
) -> module_runtime::CreationEntryTypeSnapshot {
module_runtime::CreationEntryTypeSnapshot {
id: id.to_string(),
title: id.to_string(),
subtitle: "测试入口".to_string(),
badge: "测试".to_string(),
image_src: format!("/creation-type-references/{id}.webp"),
visible,
open,
sort_order,
updated_at_micros: 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -172,6 +149,29 @@ mod tests {
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
Some("visual-novel"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/edutainment/baby-object-match/assets"),
Some("baby-object-match"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/edutainment/baby-love-drawing/magic"),
Some("baby-love-drawing"),
);
assert_eq!(resolve_creation_entry_route_id("/healthz"), None);
}
#[test]
fn test_creation_entry_config_response_keeps_baby_object_match_visible() {
let config = test_creation_entry_config_response();
let baby_object_match = config
.creation_types
.iter()
.find(|item| item.id == "baby-object-match")
.expect("test creation entry config should include baby-object-match");
assert_eq!(baby_object_match.title, "宝贝识物");
assert!(baby_object_match.visible);
assert!(baby_object_match.open);
assert_eq!(baby_object_match.sort_order, 90);
}
}

View File

@@ -0,0 +1,337 @@
use axum::{
Json,
extract::{Extension, State, rejection::JsonRejection},
http::StatusCode,
response::Response,
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use image::{ColorType, ImageEncoder, codecs::png::PngEncoder};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{
api_response::json_success_body,
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
require_openai_image_settings,
},
request_context::RequestContext,
state::AppState,
};
const BABY_LOVE_DRAWING_PROVIDER: &str = "vector-engine-gpt-image-2";
const BABY_LOVE_DRAWING_IMAGE_SIZE: &str = "1024x1024";
const BABY_LOVE_DRAWING_MAX_STROKES: usize = 600;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CreateBabyLoveDrawingMagicRequest {
original_image_src: String,
#[serde(default)]
stroke_trace: Vec<BabyLoveDrawingStrokePayload>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BabyLoveDrawingStrokePayload {
stroke_id: String,
tool: String,
color: String,
#[serde(default)]
points: Vec<BabyLoveDrawingPointPayload>,
}
#[derive(Debug, Deserialize)]
struct BabyLoveDrawingPointPayload {
x: f64,
y: f64,
t: f64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct CreateBabyLoveDrawingMagicResponse {
magic_image_src: String,
generation_provider: String,
prompt: String,
}
pub async fn create_baby_love_drawing_magic(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
payload: Result<Json<CreateBabyLoveDrawingMagicRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
baby_love_drawing_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "edutainment-baby-drawing",
"message": error.body_text(),
})),
)
})?;
validate_magic_request(&payload)
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
let settings = require_openai_image_settings(&state)
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
let http_client = build_openai_image_http_client(&settings)
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
let prompt = build_baby_love_drawing_magic_prompt(payload.stroke_trace.as_slice());
let reference_images = vec![payload.original_image_src.trim().to_string()];
let generated = create_openai_image_generation(
&http_client,
&settings,
prompt.as_str(),
Some(build_baby_love_drawing_negative_prompt()),
BABY_LOVE_DRAWING_IMAGE_SIZE,
1,
reference_images.as_slice(),
"宝贝爱画绘画魔法图片生成失败",
)
.await
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
let generated_image = generated.images.into_iter().next().ok_or_else(|| {
baby_love_drawing_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "宝贝爱画绘画魔法没有返回图片。",
})),
)
})?;
let magic_image_src = build_png_data_url(generated_image)
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
Ok(json_success_body(
Some(&request_context),
CreateBabyLoveDrawingMagicResponse {
magic_image_src,
generation_provider: BABY_LOVE_DRAWING_PROVIDER.to_string(),
prompt,
},
))
}
fn validate_magic_request(payload: &CreateBabyLoveDrawingMagicRequest) -> Result<(), AppError> {
let original_image_src = payload.original_image_src.trim();
if !original_image_src.starts_with("data:image/") || !original_image_src.contains(";base64,") {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "edutainment-baby-drawing",
"message": "绘画原图必须是图片 Data URL。",
})),
);
}
if payload.stroke_trace.len() > BABY_LOVE_DRAWING_MAX_STROKES {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "edutainment-baby-drawing",
"message": "绘画笔触数量过多,请重新完成绘画后再使用魔法。",
})),
);
}
Ok(())
}
fn build_baby_love_drawing_magic_prompt(stroke_trace: &[BabyLoveDrawingStrokePayload]) -> String {
let stroke_count = stroke_trace.len();
let brush_count = stroke_trace
.iter()
.filter(|stroke| stroke.tool.trim() == "brush")
.count();
let eraser_count = stroke_trace
.iter()
.filter(|stroke| stroke.tool.trim() == "eraser")
.count();
let color_summary = summarize_stroke_colors(stroke_trace);
let trace_bounds = summarize_trace_bounds(stroke_trace);
format!(
"根据参考图中的儿童绘画内容,为寓教于乐独立关卡“宝贝爱画”生成一张绘本风格图片。\n\
必须保留小朋友原始画面的主体构图、线条方向、颜色关系和童趣笔触,不要改成与原图无关的新内容。\n\
输出风格:明亮、温暖、柔和、卡通绘本风格,适合 4-8 岁儿童,画面干净,边缘柔和,有轻微纸面质感。\n\
笔触信息:总笔触 {stroke_count} 条,画笔 {brush_count} 条,橡皮 {eraser_count} 条,主要颜色 {color_summary},绘制范围 {trace_bounds}\n\
不要生成文字、水印、Logo、按钮、UI 面板、真实照片风、恐怖或成人化内容。"
)
}
fn summarize_stroke_colors(stroke_trace: &[BabyLoveDrawingStrokePayload]) -> String {
let mut colors = Vec::new();
for stroke in stroke_trace {
if stroke.stroke_id.trim().is_empty() {
continue;
}
let color = stroke.color.trim();
if color.is_empty() || colors.iter().any(|value| value == color) {
continue;
}
colors.push(color.to_string());
if colors.len() >= 5 {
break;
}
}
if colors.is_empty() {
"无明显颜色记录".to_string()
} else {
colors.join("")
}
}
fn summarize_trace_bounds(stroke_trace: &[BabyLoveDrawingStrokePayload]) -> String {
let mut min_x = 1.0_f64;
let mut min_y = 1.0_f64;
let mut max_x = 0.0_f64;
let mut max_y = 0.0_f64;
let mut has_point = false;
for point in stroke_trace.iter().flat_map(|stroke| stroke.points.iter()) {
if !(point.x.is_finite() && point.y.is_finite() && point.t.is_finite()) {
continue;
}
has_point = true;
min_x = min_x.min(point.x.clamp(0.0, 1.0));
min_y = min_y.min(point.y.clamp(0.0, 1.0));
max_x = max_x.max(point.x.clamp(0.0, 1.0));
max_y = max_y.max(point.y.clamp(0.0, 1.0));
}
if !has_point {
return "无可用坐标记录".to_string();
}
format!("x {:.2}-{:.2}, y {:.2}-{:.2}", min_x, max_x, min_y, max_y)
}
fn build_baby_love_drawing_negative_prompt() -> &'static str {
"文字水印Logo按钮UI面板复杂背景真实照片风恐怖元素成人化内容攻击性内容替换原图主体完全无关的新画面"
}
fn build_png_data_url(image: DownloadedOpenAiImage) -> Result<String, AppError> {
let png_bytes = normalize_generated_image_to_png(image.bytes.as_slice())?;
Ok(format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(png_bytes)
))
}
fn normalize_generated_image_to_png(source: &[u8]) -> Result<Vec<u8>, AppError> {
let rgba_image = image::load_from_memory(source)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("解析宝贝爱画魔法图片失败:{error}"),
}))
})?
.to_rgba8();
let (width, height) = rgba_image.dimensions();
let mut encoded = Vec::new();
let encoder = PngEncoder::new(&mut encoded);
encoder
.write_image(rgba_image.as_raw(), width, height, ColorType::Rgba8.into())
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("转换宝贝爱画魔法图片为 PNG 失败:{error}"),
}))
})?;
Ok(encoded)
}
fn baby_love_drawing_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_request() -> CreateBabyLoveDrawingMagicRequest {
CreateBabyLoveDrawingMagicRequest {
original_image_src: "data:image/png;base64,abcd".to_string(),
stroke_trace: vec![BabyLoveDrawingStrokePayload {
stroke_id: "stroke-1".to_string(),
tool: "brush".to_string(),
color: "#ef4444".to_string(),
points: vec![
BabyLoveDrawingPointPayload {
x: 0.2,
y: 0.3,
t: 1.0,
},
BabyLoveDrawingPointPayload {
x: 0.7,
y: 0.8,
t: 2.0,
},
],
}],
}
}
#[test]
fn magic_prompt_keeps_child_drawing_and_picture_book_style() {
let request = sample_request();
let prompt = build_baby_love_drawing_magic_prompt(request.stroke_trace.as_slice());
assert!(prompt.contains("宝贝爱画"));
assert!(prompt.contains("绘本风格"));
assert!(prompt.contains("保留小朋友原始画面"));
assert!(prompt.contains("#ef4444"));
assert!(prompt.contains("x 0.20-0.70"));
}
#[test]
fn magic_request_requires_image_data_url() {
let request = sample_request();
assert!(validate_magic_request(&request).is_ok());
let invalid = CreateBabyLoveDrawingMagicRequest {
original_image_src: "https://example.test/image.png".to_string(),
..sample_request()
};
assert!(validate_magic_request(&invalid).is_err());
}
#[test]
fn normalizes_png_to_png_data_url() {
let mut source = Vec::new();
let pixels = vec![255u8; 4 * 2 * 2];
let encoder = PngEncoder::new(&mut source);
encoder
.write_image(pixels.as_slice(), 2, 2, ColorType::Rgba8.into())
.expect("test png should encode");
let image_src = build_png_data_url(DownloadedOpenAiImage {
bytes: source,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
.expect("test png should normalize");
assert!(image_src.starts_with("data:image/png;base64,"));
}
#[test]
fn trace_summary_ignores_invalid_points() {
let mut request = sample_request();
request.stroke_trace[0]
.points
.push(BabyLoveDrawingPointPayload {
x: f64::NAN,
y: 0.1,
t: 3.0,
});
assert_eq!(
summarize_trace_bounds(request.stroke_trace.as_slice()),
"x 0.20-0.70, y 0.30-0.80",
);
}
}

View File

@@ -0,0 +1,642 @@
use std::time::Instant;
use axum::{
Json,
extract::{Extension, State, rejection::JsonRejection},
http::StatusCode,
response::Response,
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use futures_util::{StreamExt, stream::FuturesUnordered};
use image::{ColorType, ImageEncoder, codecs::png::PngEncoder};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{
api_response::json_success_body,
character_visual_assets::try_apply_background_alpha_to_png,
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, OpenAiImageSettings, build_openai_image_http_client,
create_openai_image_generation, require_openai_image_settings,
},
request_context::RequestContext,
state::AppState,
};
const BABY_OBJECT_MATCH_PROVIDER: &str = "vector-engine-gpt-image-2";
const BABY_OBJECT_MATCH_IMAGE_SIZE: &str = "1024x1024";
const BABY_OBJECT_MATCH_BACKGROUND_IMAGE_SIZE: &str = "1536x1024";
const BABY_OBJECT_MATCH_VECTOR_ENGINE_REQUEST_TIMEOUT_MS: u64 = 480_000;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GenerateBabyObjectMatchAssetsRequest {
item_names: Vec<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct GenerateBabyObjectMatchAssetsResponse {
assets: Vec<BabyObjectMatchItemAssetPayload>,
visual_package: BabyObjectMatchVisualPackagePayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct BabyObjectMatchItemAssetPayload {
item_id: String,
item_name: String,
image_src: String,
asset_object_id: Option<String>,
generation_provider: String,
prompt: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum BabyObjectMatchVisualAssetKind {
Background,
UiFrame,
GiftBox,
Basket,
SmokePuff,
}
impl BabyObjectMatchVisualAssetKind {
fn asset_id(self) -> &'static str {
match self {
Self::Background => "baby-object-visual-background",
Self::UiFrame => "baby-object-visual-ui-frame",
Self::GiftBox => "baby-object-visual-gift-box",
Self::Basket => "baby-object-visual-basket",
Self::SmokePuff => "baby-object-visual-smoke-puff",
}
}
fn contract_kind(self) -> &'static str {
match self {
Self::Background => "background",
Self::UiFrame => "ui-frame",
Self::GiftBox => "gift-box",
Self::Basket => "basket",
Self::SmokePuff => "smoke-puff",
}
}
fn requires_transparency(self) -> bool {
!matches!(self, Self::Background)
}
fn image_size(self) -> &'static str {
match self {
Self::Background => BABY_OBJECT_MATCH_BACKGROUND_IMAGE_SIZE,
Self::UiFrame | Self::GiftBox | Self::Basket | Self::SmokePuff => {
BABY_OBJECT_MATCH_IMAGE_SIZE
}
}
}
fn failure_context(self) -> &'static str {
match self {
Self::Background => "宝贝识物背景环境图片生成失败",
Self::UiFrame => "宝贝识物 UI 装饰图片生成失败",
Self::GiftBox => "宝贝识物礼物盒图片生成失败",
Self::Basket => "宝贝识物篮子图片生成失败",
Self::SmokePuff => "宝贝识物烟雾特效图片生成失败",
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct BabyObjectMatchVisualPackagePayload {
theme_prompt: String,
assets: Vec<BabyObjectMatchVisualAssetPayload>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct BabyObjectMatchVisualAssetPayload {
asset_id: String,
asset_kind: String,
image_src: String,
asset_object_id: Option<String>,
generation_provider: String,
prompt: String,
}
pub async fn generate_baby_object_match_assets(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
payload: Result<Json<GenerateBabyObjectMatchAssetsRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
baby_object_match_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "edutainment-baby-object",
"message": error.body_text(),
})),
)
})?;
let item_names = normalize_item_names(payload.item_names)
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
let settings = require_openai_image_settings(&state)
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
let settings = with_baby_object_match_image_timeout(settings);
let http_client = build_openai_image_http_client(&settings)
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
let request_started_at = Instant::now();
tracing::info!(
item_count = item_names.len(),
"宝贝识物 image-2 资源生成开始"
);
let (assets, visual_package) = tokio::try_join!(
build_baby_object_match_item_assets(&http_client, &settings, item_names.as_slice()),
build_baby_object_match_visual_package(&http_client, &settings, item_names.as_slice()),
)
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
tracing::info!(
elapsed_ms = request_started_at.elapsed().as_millis() as u64,
"宝贝识物 image-2 资源生成完成"
);
Ok(json_success_body(
Some(&request_context),
GenerateBabyObjectMatchAssetsResponse {
assets,
visual_package,
},
))
}
fn normalize_item_names(item_names: Vec<String>) -> Result<Vec<String>, AppError> {
let normalized = item_names
.into_iter()
.map(|value| value.trim().to_string())
.collect::<Vec<_>>();
if normalized.len() != 2 || normalized.iter().any(|value| value.is_empty()) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "edutainment-baby-object",
"message": "请填写两个物品名称。",
})),
);
}
Ok(normalized)
}
fn build_baby_object_match_item_prompt(item_name: &str) -> String {
format!(
"为儿童动作 Demo 玩法“宝贝识物”生成物品素材。关键词:{item_name}\n\
风格必须与寓教于乐板块统一:明亮、温暖、卡通绘本质感,适合 4-8 岁儿童,物体边缘清晰,色彩干净,能自然放在草地舞台插画中。\n\
画面只允许出现一个围绕关键词“{item_name}”的单一物品主体,不要生成组合物、多个物体、人物、手、篮子、礼物盒或玩法 UI。\n\
不要生成背景、场景、氛围渲染、阴影地面、文字、水印、边框或按钮。背景必须是纯白或直接透明,便于服务端做透明抠图。\n\
输出为居中完整物品,留少量透明安全边距,最终素材将作为透明 PNG 进入游戏。"
)
}
fn build_baby_object_match_negative_prompt() -> &'static str {
"背景场景草地天空房间光效氛围多个物品组合套装人物篮子礼物盒包装文字标签文字水印LogoUI按钮边框真实照片风复杂投影"
}
fn with_baby_object_match_image_timeout(mut settings: OpenAiImageSettings) -> OpenAiImageSettings {
settings.request_timeout_ms = settings
.request_timeout_ms
.max(BABY_OBJECT_MATCH_VECTOR_ENGINE_REQUEST_TIMEOUT_MS);
settings
}
async fn build_baby_object_match_item_assets(
http_client: &reqwest::Client,
settings: &OpenAiImageSettings,
item_names: &[String],
) -> Result<Vec<BabyObjectMatchItemAssetPayload>, AppError> {
let mut pending = FuturesUnordered::new();
// 中文注释:两个物品图互不依赖,并发生成可缩短创作等待时间。
for (index, item_name) in item_names.iter().cloned().enumerate() {
let prompt = build_baby_object_match_item_prompt(item_name.as_str());
pending.push(async move {
let asset_started_at = Instant::now();
tracing::info!(
asset_kind = "item",
item_index = index + 1,
item_name = %item_name,
"宝贝识物 image-2 物品资源生成开始"
);
let generated = create_openai_image_generation(
http_client,
settings,
prompt.as_str(),
Some(build_baby_object_match_negative_prompt()),
BABY_OBJECT_MATCH_IMAGE_SIZE,
1,
&[],
"宝贝识物物品图片生成失败",
)
.await?;
let generated_image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "宝贝识物物品图片生成没有返回图片。",
}))
})?;
let image_src = build_transparent_png_data_url(generated_image)?;
tracing::info!(
asset_kind = "item",
item_index = index + 1,
item_name = %item_name,
elapsed_ms = asset_started_at.elapsed().as_millis() as u64,
"宝贝识物 image-2 物品资源生成完成"
);
Ok::<_, AppError>(BabyObjectMatchItemAssetPayload {
item_id: format!("baby-object-item-{}", index + 1),
item_name,
image_src,
asset_object_id: None,
generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(),
prompt,
})
});
}
let mut assets = Vec::with_capacity(item_names.len());
while let Some(result) = pending.next().await {
assets.push(result?);
}
assets.sort_by_key(|asset| asset.item_id.clone());
Ok(assets)
}
async fn build_baby_object_match_visual_package(
http_client: &reqwest::Client,
settings: &OpenAiImageSettings,
item_names: &[String],
) -> Result<BabyObjectMatchVisualPackagePayload, AppError> {
let package_started_at = Instant::now();
let theme_prompt = build_baby_object_match_visual_theme_prompt(item_names);
let kinds = [
BabyObjectMatchVisualAssetKind::Background,
BabyObjectMatchVisualAssetKind::UiFrame,
BabyObjectMatchVisualAssetKind::GiftBox,
BabyObjectMatchVisualAssetKind::Basket,
BabyObjectMatchVisualAssetKind::SmokePuff,
];
let mut pending = FuturesUnordered::new();
tracing::info!(
asset_count = kinds.len(),
"宝贝识物 image-2 视觉主题包生成开始"
);
for kind in kinds.iter().copied() {
let prompt = build_baby_object_match_visual_asset_prompt(kind, item_names, &theme_prompt);
pending.push(async move {
let asset_started_at = Instant::now();
let asset_kind = kind.contract_kind();
tracing::info!(asset_kind, "宝贝识物 image-2 视觉资源生成开始");
let generated = create_openai_image_generation(
http_client,
settings,
prompt.as_str(),
Some(build_baby_object_match_visual_negative_prompt(kind)),
kind.image_size(),
1,
&[],
kind.failure_context(),
)
.await?;
let generated_image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("{}VectorEngine 没有返回图片。", kind.failure_context()),
}))
})?;
let image_src = if kind.requires_transparency() {
build_transparent_png_data_url(generated_image)?
} else {
build_png_data_url(generated_image)?
};
tracing::info!(
asset_kind,
elapsed_ms = asset_started_at.elapsed().as_millis() as u64,
"宝贝识物 image-2 视觉资源生成完成"
);
Ok::<_, AppError>(BabyObjectMatchVisualAssetPayload {
asset_id: kind.asset_id().to_string(),
asset_kind: asset_kind.to_string(),
image_src,
asset_object_id: None,
generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(),
prompt,
})
});
}
let mut assets = Vec::with_capacity(kinds.len());
while let Some(result) = pending.next().await {
assets.push(result?);
}
assets.sort_by_key(|asset| match asset.asset_kind.as_str() {
"background" => 0,
"ui-frame" => 1,
"gift-box" => 2,
"basket" => 3,
"smoke-puff" => 4,
_ => 5,
});
tracing::info!(
elapsed_ms = package_started_at.elapsed().as_millis() as u64,
"宝贝识物 image-2 视觉主题包生成完成"
);
Ok(BabyObjectMatchVisualPackagePayload {
theme_prompt,
assets,
})
}
fn build_baby_object_match_visual_theme_prompt(item_names: &[String]) -> String {
let item_a = item_names.first().map(String::as_str).unwrap_or_default();
let item_b = item_names.get(1).map(String::as_str).unwrap_or_default();
let theme_hint = resolve_baby_object_match_theme_hint(item_names);
format!(
"根据创作者填写的两个物品关键词“{item_a}”和“{item_b}”,为儿童动作 Demo 玩法“宝贝识物”生成一套完整游戏视觉包装。\n\
视觉必须保持寓教于乐板块统一的明亮、温暖、卡通绘本插画风,适合 4-8 岁儿童。\n\
主题匹配:{theme_hint}\n\
所有资源需要围绕这两个关键词形成统一主题,但不能改变物品识别和左右篮子固定规则。"
)
}
fn resolve_baby_object_match_theme_hint(item_names: &[String]) -> &'static str {
let joined = item_names.join(" ").to_lowercase();
let fruit_keywords = [
"苹果",
"橘子",
"桔子",
"香蕉",
"葡萄",
"草莓",
"西瓜",
"",
"",
"水果",
"apple",
"orange",
"banana",
"grape",
"strawberry",
"watermelon",
"fruit",
];
let character_keywords = [
"佩琪",
"小猪佩奇",
"小猪佩琪",
"奥特曼",
"动漫",
"动画",
"卡通",
"玩具",
"角色",
"公仔",
"peppa",
"ultraman",
"anime",
"cartoon",
"toy",
"doll",
"figure",
];
if fruit_keywords
.iter()
.any(|keyword| joined.contains(keyword))
{
return "若关键词属于水果,背景环境和 UI 元素匹配果园、自然、阳光、树叶等主题。";
}
if character_keywords
.iter()
.any(|keyword| joined.contains(keyword))
{
return "若关键词属于动漫角色、玩具或公仔,背景环境和 UI 元素匹配动漫、玩具房、儿童玩具等主题。";
}
"根据关键词语义自然匹配合适主题,保持儿童寓教于乐插画风。"
}
fn build_baby_object_match_visual_asset_prompt(
kind: BabyObjectMatchVisualAssetKind,
item_names: &[String],
theme_prompt: &str,
) -> String {
let item_a = item_names.first().map(String::as_str).unwrap_or_default();
let item_b = item_names.get(1).map(String::as_str).unwrap_or_default();
let base = format!(
"{theme_prompt}\n\
当前两个关键词:{item_a}{item_b}\n\
输出必须是无文字、无水印、无 Logo 的游戏美术资源。"
);
match kind {
BabyObjectMatchVisualAssetKind::Background => format!(
"{base}\n\
生成游戏背景环境图。背景需要根据关键词主题匹配环境,例如水果可偏果园自然,动漫角色或玩具可偏动漫玩具主题。\n\
保持中间、屏幕中下方和底部左右篮子区域清爽,给放大后的礼物盒、中央物品和左右大篮子预留足够空间,不能画入礼物盒、篮子、物品、人物、文字或操作 UI。"
),
BabyObjectMatchVisualAssetKind::UiFrame => format!(
"{base}\n\
生成透明 PNG 的 UI 装饰框资源,用于字幕条和计数器的风格化包装。\n\
只生成柔和装饰边框、贴纸感边缘和少量主题点缀,不生成任何文字、数字、按钮、图标说明或大面积背景。背景需要纯白或透明友好,便于抠图。"
),
BabyObjectMatchVisualAssetKind::GiftBox => format!(
"{base}\n\
生成透明 PNG 的大号礼物盒资源。礼物盒会在游戏中以约 2 倍视觉尺寸展示,需要主体饱满、轮廓清晰、中心构图、边缘安全留白少,打开动画时可被烟雾遮罩后移除。\n\
礼物盒要与关键词主题匹配,可以带主题贴纸感装饰,但不能出现任何文字、人物、手、篮子或待分类物品。背景需要纯白或透明友好,便于抠图。"
),
BabyObjectMatchVisualAssetKind::Basket => format!(
"{base}\n\
生成透明 PNG 的大号篮子资源,游戏左右两侧会复用同一个篮子造型并以约 1.5 倍视觉尺寸展示。篮子主体要饱满、开口清晰、可读性高、边缘安全留白少。\n\
篮子要与关键词主题匹配,可以有主题色和贴纸感边缘,但不能出现任何文字、礼物盒、人物、手或待分类物品。背景需要纯白或透明友好,便于抠图。"
),
BabyObjectMatchVisualAssetKind::SmokePuff => format!(
"{base}\n\
生成透明 PNG 的烟雾弹出特效资源,用于礼物盒打开瞬间。画面只允许出现一团柔和、圆润、儿童绘本风的云朵烟雾和少量主题色星点,不要生成礼物盒、篮子、物品、人物、手、文字或 UI。\n\
烟雾需要中心构图、边缘柔和、透明边界干净,适合覆盖礼物盒打开区域并衬托中央物品弹出。背景需要纯白或透明友好,便于抠图。"
),
}
}
fn build_baby_object_match_visual_negative_prompt(
kind: BabyObjectMatchVisualAssetKind,
) -> &'static str {
match kind {
BabyObjectMatchVisualAssetKind::Background => {
"文字数字水印Logo按钮说明面板人物礼物盒篮子中心物品复杂前景遮挡真实照片风暗黑风"
}
BabyObjectMatchVisualAssetKind::UiFrame => {
"文字数字水印Logo按钮复杂面板大面积实心背景人物礼物盒篮子物品主体真实照片风"
}
BabyObjectMatchVisualAssetKind::GiftBox => {
"文字数字水印Logo人物篮子待分类物品大面积背景场景真实照片风"
}
BabyObjectMatchVisualAssetKind::Basket => {
"文字数字水印Logo人物礼物盒待分类物品大面积背景场景真实照片风"
}
BabyObjectMatchVisualAssetKind::SmokePuff => {
"文字数字水印Logo人物礼物盒篮子待分类物品大面积背景场景真实照片风硬边爆炸火焰"
}
}
}
fn build_transparent_png_data_url(image: DownloadedOpenAiImage) -> Result<String, AppError> {
let png_bytes = normalize_generated_image_to_png(image.bytes.as_slice())?;
let transparent_png_bytes =
try_apply_background_alpha_to_png(png_bytes.as_slice()).unwrap_or(png_bytes);
Ok(format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(transparent_png_bytes)
))
}
fn build_png_data_url(image: DownloadedOpenAiImage) -> Result<String, AppError> {
let png_bytes = normalize_generated_image_to_png(image.bytes.as_slice())?;
Ok(format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(png_bytes)
))
}
fn normalize_generated_image_to_png(source: &[u8]) -> Result<Vec<u8>, AppError> {
let rgba_image = image::load_from_memory(source)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("解析宝贝识物物品图片失败:{error}"),
}))
})?
.to_rgba8();
let (width, height) = rgba_image.dimensions();
let mut encoded = Vec::new();
let encoder = PngEncoder::new(&mut encoded);
encoder
.write_image(rgba_image.as_raw(), width, height, ColorType::Rgba8.into())
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("转换宝贝识物物品图片为 PNG 失败:{error}"),
}))
})?;
Ok(encoded)
}
fn baby_object_match_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prompt_locks_single_transparent_object_constraints() {
let prompt = build_baby_object_match_item_prompt("苹果");
assert!(prompt.contains("苹果"));
assert!(prompt.contains("卡通绘本"));
assert!(prompt.contains("单一物品"));
assert!(prompt.contains("不要生成背景"));
assert!(prompt.contains("透明 PNG"));
assert!(prompt.contains("纯白或直接透明"));
}
#[test]
fn visual_theme_prompt_maps_fruit_keywords_to_nature_theme() {
let names = vec!["苹果".to_string(), "橘子".to_string()];
let prompt = build_baby_object_match_visual_theme_prompt(names.as_slice());
assert!(prompt.contains("寓教于乐"));
assert!(prompt.contains("卡通绘本"));
assert!(prompt.contains("果园"));
assert!(prompt.contains("自然"));
}
#[test]
fn visual_theme_prompt_maps_character_keywords_to_toy_theme() {
let names = vec!["小猪佩琪".to_string(), "奥特曼".to_string()];
let prompt = build_baby_object_match_visual_theme_prompt(names.as_slice());
assert!(prompt.contains("寓教于乐"));
assert!(prompt.contains("动漫"));
assert!(prompt.contains("玩具"));
}
#[test]
fn visual_asset_prompt_keeps_background_clear_for_playfield() {
let names = vec!["苹果".to_string(), "香蕉".to_string()];
let theme_prompt = build_baby_object_match_visual_theme_prompt(names.as_slice());
let prompt = build_baby_object_match_visual_asset_prompt(
BabyObjectMatchVisualAssetKind::Background,
names.as_slice(),
theme_prompt.as_str(),
);
assert!(prompt.contains("背景环境图"));
assert!(prompt.contains("中间"));
assert!(prompt.contains("屏幕中下方"));
assert!(prompt.contains("无文字"));
}
#[test]
fn normalize_item_names_requires_two_non_empty_names() {
let names = normalize_item_names(vec![" 苹果 ".to_string(), "香蕉".to_string()])
.expect("two names should be valid");
assert_eq!(names, vec!["苹果".to_string(), "香蕉".to_string()]);
assert!(normalize_item_names(vec!["苹果".to_string()]).is_err());
assert!(normalize_item_names(vec!["苹果".to_string(), " ".to_string()]).is_err());
}
#[test]
fn baby_object_match_image_timeout_keeps_long_generation_alive() {
let settings = with_baby_object_match_image_timeout(OpenAiImageSettings {
base_url: "https://vector.example".to_string(),
api_key: "secret".to_string(),
request_timeout_ms: 180_000,
});
assert_eq!(
settings.request_timeout_ms,
BABY_OBJECT_MATCH_VECTOR_ENGINE_REQUEST_TIMEOUT_MS
);
}
#[test]
fn normalizes_png_to_transparent_png_data_url() {
let mut source = Vec::new();
let pixels = vec![255u8; 4 * 2 * 2];
let encoder = PngEncoder::new(&mut source);
encoder
.write_image(pixels.as_slice(), 2, 2, ColorType::Rgba8.into())
.expect("test png should encode");
let image_src = build_transparent_png_data_url(DownloadedOpenAiImage {
bytes: source,
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
.expect("test png should normalize");
assert!(image_src.starts_with("data:image/png;base64,"));
}
}

View File

@@ -34,6 +34,8 @@ mod custom_world_asset_prompts;
mod custom_world_foundation_draft;
mod custom_world_result_prompts;
mod custom_world_rpg_draft_prompts;
mod edutainment_baby_drawing;
mod edutainment_baby_object;
mod error_middleware;
mod health;
mod http_error;

View File

@@ -46,6 +46,137 @@ pub fn build_creation_entry_config_response(
}
}
pub fn default_creation_entry_type_snapshots(
updated_at_micros: i64,
) -> Vec<CreationEntryTypeSnapshot> {
vec![
build_default_creation_entry_type_snapshot(
"rpg",
"文字冒险",
"经典 RPG 体验",
"内测",
"/creation-type-references/rpg.webp",
false,
true,
10,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
"big-fish",
"摸鱼",
"轻量闯关玩法",
"可创建",
"/creation-type-references/big-fish.webp",
false,
true,
20,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
"puzzle",
"拼图",
"拼图关卡创作",
"可创建",
"/creation-type-references/puzzle.webp",
true,
true,
30,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
"match3d",
"抓大鹅",
"3D 消除关卡",
"可创建",
"/creation-type-references/match3d.webp",
true,
true,
40,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
"square-hole",
"方洞",
"形状投放挑战",
"可创建",
"/creation-type-references/square-hole.webp",
false,
true,
50,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
"visual-novel",
"视觉小说",
"分支叙事体验",
"敬请期待",
"/creation-type-references/visual-novel.webp",
true,
false,
60,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
"airp",
"AI RPG",
"原生角色扮演",
"即将开放",
"/creation-type-references/airp.webp",
true,
false,
70,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
"creative-agent",
"智能体创作",
"对话式创作实验",
"内测",
"/creation-type-references/creative-agent.webp",
false,
true,
80,
updated_at_micros,
),
build_default_creation_entry_type_snapshot(
"baby-object-match",
"宝贝识物",
"亲子识物分类",
"可创建",
"/child-motion-demo/picture-book-grass-stage.png",
true,
true,
90,
updated_at_micros,
),
]
}
#[allow(clippy::too_many_arguments)]
fn build_default_creation_entry_type_snapshot(
id: &str,
title: &str,
subtitle: &str,
badge: &str,
image_src: &str,
visible: bool,
open: bool,
sort_order: i32,
updated_at_micros: i64,
) -> CreationEntryTypeSnapshot {
CreationEntryTypeSnapshot {
id: id.to_string(),
title: title.to_string(),
subtitle: subtitle.to_string(),
badge: badge.to_string(),
image_src: image_src.to_string(),
visible,
open,
sort_order,
updated_at_micros,
}
}
pub fn build_runtime_setting_record(snapshot: RuntimeSettingSnapshot) -> RuntimeSettingsRecord {
RuntimeSettingsRecord {
user_id: snapshot.user_id,

View File

@@ -209,6 +209,25 @@ mod tests {
assert_eq!(settings.platform_theme, RuntimePlatformTheme::Light);
}
#[test]
fn default_creation_entry_types_include_baby_object_match() {
let configs = default_creation_entry_type_snapshots(1);
let baby_object_match = configs
.iter()
.find(|item| item.id == "baby-object-match")
.expect("baby-object-match creation entry should be seeded");
assert_eq!(baby_object_match.title, "宝贝识物");
assert_eq!(baby_object_match.subtitle, "亲子识物分类");
assert!(baby_object_match.visible);
assert!(baby_object_match.open);
assert_eq!(baby_object_match.sort_order, 90);
assert_eq!(
baby_object_match.image_src,
"/child-motion-demo/picture-book-grass-stage.png"
);
}
#[test]
fn normalized_clamps_music_volume_into_valid_range() {
let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light);

View File

@@ -212,119 +212,18 @@ fn migrate_visual_novel_entry_from_old_open_default(ctx: &ReducerContext, now: T
}
fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeConfig> {
vec![
build_creation_entry_type_seed(
"rpg",
"文字冒险",
"经典 RPG 体验",
"内测",
"/creation-type-references/rpg.webp",
false,
true,
10,
now,
),
build_creation_entry_type_seed(
"big-fish",
"摸鱼",
"轻量闯关玩法",
"可创建",
"/creation-type-references/big-fish.webp",
false,
true,
20,
now,
),
build_creation_entry_type_seed(
"puzzle",
"拼图",
"拼图关卡创作",
"可创建",
"/creation-type-references/puzzle.webp",
true,
true,
30,
now,
),
build_creation_entry_type_seed(
"match3d",
"抓大鹅",
"3D 消除关卡",
"可创建",
"/creation-type-references/match3d.webp",
true,
true,
40,
now,
),
build_creation_entry_type_seed(
"square-hole",
"方洞",
"形状投放挑战",
"可创建",
"/creation-type-references/square-hole.webp",
false,
true,
50,
now,
),
build_creation_entry_type_seed(
"visual-novel",
"视觉小说",
"分支叙事体验",
"敬请期待",
"/creation-type-references/visual-novel.webp",
true,
false,
60,
now,
),
build_creation_entry_type_seed(
"airp",
"AI RPG",
"原生角色扮演",
"即将开放",
"/creation-type-references/airp.webp",
true,
false,
70,
now,
),
build_creation_entry_type_seed(
"creative-agent",
"智能体创作",
"对话式创作实验",
"内测",
"/creation-type-references/creative-agent.webp",
false,
true,
80,
now,
),
]
}
#[allow(clippy::too_many_arguments)]
fn build_creation_entry_type_seed(
id: &str,
title: &str,
subtitle: &str,
badge: &str,
image_src: &str,
visible: bool,
open: bool,
sort_order: i32,
now: Timestamp,
) -> CreationEntryTypeConfig {
CreationEntryTypeConfig {
id: id.to_string(),
title: title.to_string(),
subtitle: subtitle.to_string(),
badge: badge.to_string(),
image_src: image_src.to_string(),
visible,
open,
sort_order,
updated_at: now,
}
module_runtime::default_creation_entry_type_snapshots(now.to_micros_since_unix_epoch())
.into_iter()
.map(|snapshot| CreationEntryTypeConfig {
id: snapshot.id,
title: snapshot.title,
subtitle: snapshot.subtitle,
badge: snapshot.badge,
image_src: snapshot.image_src,
visible: snapshot.visible,
open: snapshot.open,
sort_order: snapshot.sort_order,
updated_at: now,
})
.collect()
}