feat: add edutainment drawing and visual package flows
This commit is contained in:
642
server-rs/crates/api-server/src/edutainment_baby_object.rs
Normal file
642
server-rs/crates/api-server/src/edutainment_baby_object.rs
Normal 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 {
|
||||
"背景,场景,草地,天空,房间,光效氛围,多个物品,组合套装,人物,手,篮子,礼物盒,包装文字,标签文字,水印,Logo,UI,按钮,边框,真实照片风,复杂投影"
|
||||
}
|
||||
|
||||
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,"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user