Increase VectorEngine timeouts and add image UI
Add VectorEngine image generation config and raise request timeouts (env + scripts) from 180000 to 1000000ms. Introduce a reusable CreativeImageInputPanel component with tests and wire up mobile keyboard-focus helpers; update generation views and related tests (CustomWorldGenerationView, BarkBattle editor, Match3D, Puzzle flows). Improve API error handling / VectorEngine request guidance (packages/shared http.ts and docs), and apply multiple backend/frontend fixes for puzzle/match3d/prompt handling. Also include extensive docs and decision-log updates describing UI/UX decisions and verification steps.
This commit is contained in:
@@ -13,6 +13,7 @@ use platform_speech::{
|
||||
const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge";
|
||||
const DEFAULT_AUTH_STORE_PATH: &str = "server-rs/.data/auth-store.json";
|
||||
const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json";
|
||||
pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000;
|
||||
|
||||
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -248,7 +249,7 @@ impl Default for AppConfig {
|
||||
apimart_image_request_timeout_ms: 180_000,
|
||||
vector_engine_base_url: String::new(),
|
||||
vector_engine_api_key: None,
|
||||
vector_engine_image_request_timeout_ms: 180_000,
|
||||
vector_engine_image_request_timeout_ms: DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS,
|
||||
vector_engine_audio_request_timeout_ms: 180_000,
|
||||
hyper3d_base_url: "https://api.hyper3d.com/api/v2".to_string(),
|
||||
hyper3d_api_key: None,
|
||||
@@ -675,7 +676,9 @@ impl AppConfig {
|
||||
if let Some(vector_engine_image_request_timeout_ms) =
|
||||
read_first_positive_u64_env(&["VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"])
|
||||
{
|
||||
config.vector_engine_image_request_timeout_ms = vector_engine_image_request_timeout_ms;
|
||||
// 中文注释:VectorEngine image-2 实测可能超过 500 秒;旧环境文件中常见的 180 秒值不能再提前截断真实生图。
|
||||
config.vector_engine_image_request_timeout_ms = vector_engine_image_request_timeout_ms
|
||||
.max(DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
if let Some(vector_engine_audio_request_timeout_ms) =
|
||||
@@ -1009,7 +1012,7 @@ fn parse_positive_u16(raw: &str) -> Option<u16> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AppConfig, LlmProvider};
|
||||
use super::{AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
@@ -1094,7 +1097,10 @@ mod tests {
|
||||
config.vector_engine_base_url,
|
||||
"https://vector.internal.example"
|
||||
);
|
||||
assert_eq!(config.vector_engine_image_request_timeout_ms, 210_000);
|
||||
assert_eq!(
|
||||
config.vector_engine_image_request_timeout_ms,
|
||||
DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS
|
||||
);
|
||||
assert_eq!(
|
||||
config.hyper3d_base_url,
|
||||
"https://model.internal.example/api/v2"
|
||||
|
||||
@@ -15,6 +15,7 @@ use serde_json::{Value, json};
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
character_visual_assets::try_apply_background_alpha_to_png,
|
||||
config::DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS,
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
DownloadedOpenAiImage, OpenAiImageSettings, build_openai_image_http_client,
|
||||
@@ -27,7 +28,6 @@ use crate::{
|
||||
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")]
|
||||
@@ -207,7 +207,7 @@ fn build_baby_object_match_negative_prompt() -> &'static str {
|
||||
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);
|
||||
.max(DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS);
|
||||
settings
|
||||
}
|
||||
|
||||
@@ -617,7 +617,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
settings.request_timeout_ms,
|
||||
BABY_OBJECT_MATCH_VECTOR_ENGINE_REQUEST_TIMEOUT_MS
|
||||
DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1023,32 +1023,14 @@ pub async fn generate_match3d_cover_image(
|
||||
.await
|
||||
.map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?;
|
||||
|
||||
upsert_match3d_draft_snapshot(
|
||||
let item = update_match3d_work_cover_only(
|
||||
&state,
|
||||
&request_context,
|
||||
&authenticated,
|
||||
context.session_id.clone(),
|
||||
context.owner_user_id.clone(),
|
||||
profile_id.clone(),
|
||||
Some(context.profile.game_name),
|
||||
Some(context.profile.summary),
|
||||
Some(serde_json::to_string(&context.profile.tags).unwrap_or_default()),
|
||||
Some(generated_cover.src.clone()),
|
||||
None,
|
||||
None,
|
||||
context.owner_user_id.as_str(),
|
||||
context.profile,
|
||||
generated_cover.src.as_str(),
|
||||
)
|
||||
.await?;
|
||||
let item = state
|
||||
.spacetime_client()
|
||||
.get_match3d_work_detail(profile_id.clone(), context.owner_user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
match3d_error_response(
|
||||
&request_context,
|
||||
MATCH3D_WORKS_PROVIDER,
|
||||
map_match3d_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -1061,6 +1043,39 @@ pub async fn generate_match3d_cover_image(
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_match3d_work_cover_only(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
profile: Match3DWorkProfileRecord,
|
||||
cover_image_src: &str,
|
||||
) -> Result<Match3DWorkProfileRecord, Response> {
|
||||
// 中文注释:封面生成是定向图片槽位更新,不能复用草稿编译路径重算题材、难度或素材 JSON。
|
||||
state
|
||||
.spacetime_client()
|
||||
.update_match3d_work(Match3DWorkUpdateRecordInput {
|
||||
profile_id: profile.profile_id,
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
game_name: profile.game_name,
|
||||
theme_text: profile.theme_text,
|
||||
summary_text: profile.summary,
|
||||
tags_json: serde_json::to_string(&normalize_tags(profile.tags)).unwrap_or_default(),
|
||||
cover_image_src: cover_image_src.to_string(),
|
||||
cover_asset_id: profile.cover_asset_id.unwrap_or_default(),
|
||||
clear_count: profile.clear_count,
|
||||
difficulty: profile.difficulty,
|
||||
updated_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_WORKS_PROVIDER,
|
||||
map_match3d_client_error(error),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn generate_match3d_background_image_for_work(
|
||||
State(state): State<AppState>,
|
||||
Path(profile_id): Path<String>,
|
||||
@@ -4804,6 +4819,7 @@ async fn generate_match3d_background_image(
|
||||
"message": "抓大鹅容器 UI 图生成失败:未返回图片",
|
||||
}))
|
||||
})?;
|
||||
let container_image = make_match3d_container_image_transparent(container_image)?;
|
||||
let container_upload = persist_match3d_generated_bytes(
|
||||
state,
|
||||
owner_user_id,
|
||||
@@ -4864,6 +4880,7 @@ async fn generate_match3d_container_image(
|
||||
"message": "抓大鹅容器 UI 图生成失败:未返回图片",
|
||||
}))
|
||||
})?;
|
||||
let container_image = make_match3d_container_image_transparent(container_image)?;
|
||||
let container_upload = persist_match3d_generated_bytes(
|
||||
state,
|
||||
owner_user_id,
|
||||
@@ -4956,10 +4973,40 @@ fn build_match3d_container_generation_prompt(config: &Match3DConfigJson, prompt:
|
||||
.map(|style| format!("整体美术风格参考:{style}。"))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"{prompt}\n{style_clause}生成一张 1:1 抓大鹅中心容器 UI 图,只绘制一个贴合题材设定的圆形或浅盘状竞技容器。严格参考输入参考图的容器范围和视图角度:容器外轮廓必须接近画布四边,占画布宽度约 86%-92%、高度约 82%-90%,中心在画布中心略偏下,只保留少量透明或纯净留白;视角为轻俯视 3/4 上方视角,能看到圆形碗体外壁、厚实前沿和横向椭圆形内口,不能画成正俯视扁圆盘、侧视碗、小托盘或居中的小容器。容器需要有清晰外沿、内侧可放置 2D 物品的干净空间、轻微阴影和高辨识边界;背景必须透明感或纯净留白,不能做成整页背景。禁止文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层和菜单。"
|
||||
"{prompt}\n{style_clause}生成一张 1:1 抓大鹅中心容器 UI 图,只绘制一个贴合题材设定的圆形或浅盘状竞技容器。严格参考输入参考图的容器范围和视图角度:容器外轮廓必须接近画布四边,占画布宽度约 86%-92%、高度约 82%-90%,中心在画布中心略偏下,只保留少量透明留白;视角为轻俯视 3/4 上方视角,能看到圆形碗体外壁、厚实前沿和横向椭圆形内口,不能画成正俯视扁圆盘、侧视碗、小托盘或居中的小容器。容器需要有清晰外沿、内侧可放置 2D 物品的干净空间、轻微阴影和高辨识边界;背景必须是透明 alpha,不得出现白底、纯色底、渐变底、场景底或整页背景。禁止文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层和菜单。"
|
||||
)
|
||||
}
|
||||
|
||||
fn make_match3d_container_image_transparent(
|
||||
image: DownloadedOpenAiImage,
|
||||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "match3d-assets",
|
||||
"message": format!("抓大鹅容器图解码失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let mut rgba = source.to_rgba8();
|
||||
let (width, height) = rgba.dimensions();
|
||||
remove_match3d_container_plain_background(rgba.as_mut(), width as usize, height as usize);
|
||||
|
||||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(rgba)
|
||||
.write_to(&mut encoded, ImageFormat::Png)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "match3d-assets",
|
||||
"message": format!("抓大鹅容器图透明化失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(DownloadedOpenAiImage {
|
||||
bytes: encoded.into_inner(),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn generate_match3d_material_sheet(
|
||||
state: &AppState,
|
||||
config: &Match3DConfigJson,
|
||||
@@ -6232,6 +6279,45 @@ fn remove_match3d_material_green_screen_background(
|
||||
}
|
||||
}
|
||||
|
||||
// 中文注释:较厚的抗锯齿绿边可能低于 hard 阈值;先沿整张 sheet 的透明背景向内吃掉
|
||||
// 软绿边,再进入格子裁剪,避免每张切图自带绿色描边。
|
||||
let soft_green_cleanup_rounds = (width.min(height) / 40).clamp(4, 14);
|
||||
for _ in 0..soft_green_cleanup_rounds {
|
||||
let mut expanded_mask = background_mask.clone();
|
||||
let mut changed_this_round = false;
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let pixel_index = y * width + x;
|
||||
if background_mask[pixel_index] != 0 {
|
||||
continue;
|
||||
}
|
||||
let offset = pixel_index * 4;
|
||||
let pixel = [
|
||||
pixels[offset],
|
||||
pixels[offset + 1],
|
||||
pixels[offset + 2],
|
||||
pixels[offset + 3],
|
||||
];
|
||||
let green_score = green_scores[pixel_index];
|
||||
let white_score = white_scores[pixel_index];
|
||||
if !is_match3d_material_soft_green_matte_pixel(pixel, green_score, white_score) {
|
||||
continue;
|
||||
}
|
||||
if !touches_match3d_material_background_mask(x, y, width, height, &background_mask)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
expanded_mask[pixel_index] = 1;
|
||||
changed_this_round = true;
|
||||
}
|
||||
}
|
||||
background_mask = expanded_mask;
|
||||
if !changed_this_round {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 中文注释:主体边缘常带一圈绿幕或白底抗锯齿,扩一层软边,避免切割后残留毛边。
|
||||
for _ in 0..2 {
|
||||
let mut expanded_mask = background_mask.clone();
|
||||
@@ -6372,9 +6458,10 @@ fn remove_match3d_material_green_screen_background(
|
||||
}
|
||||
} else {
|
||||
if green_score > 0.04 {
|
||||
green = green
|
||||
.max(red.max(blue))
|
||||
.max((green - (green - red.max(blue)) * 0.78).round());
|
||||
let toned_green = (green - (green - red.max(blue)) * 0.78)
|
||||
.round()
|
||||
.max(red.max(blue));
|
||||
green = green.min(toned_green).min(red.max(blue) + 18.0);
|
||||
}
|
||||
|
||||
if white_score > 0.12 {
|
||||
@@ -6417,6 +6504,50 @@ fn remove_match3d_material_green_screen_background(
|
||||
changed
|
||||
}
|
||||
|
||||
fn touches_match3d_material_background_mask(
|
||||
x: usize,
|
||||
y: usize,
|
||||
width: usize,
|
||||
height: usize,
|
||||
background_mask: &[u8],
|
||||
) -> bool {
|
||||
for offset_y in -1i32..=1 {
|
||||
for offset_x in -1i32..=1 {
|
||||
if offset_x == 0 && offset_y == 0 {
|
||||
continue;
|
||||
}
|
||||
let next_x = x as i32 + offset_x;
|
||||
let next_y = y as i32 + offset_y;
|
||||
if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 {
|
||||
return true;
|
||||
}
|
||||
if background_mask[next_y as usize * width + next_x as usize] != 0 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_match3d_material_soft_green_matte_pixel(
|
||||
pixel: [u8; 4],
|
||||
green_score: f32,
|
||||
white_score: f32,
|
||||
) -> bool {
|
||||
if pixel[3] == 0 || green_score < MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE {
|
||||
return false;
|
||||
}
|
||||
|
||||
let red = pixel[0];
|
||||
let green = pixel[1];
|
||||
let blue = pixel[2];
|
||||
let foreground_mix = red.max(blue);
|
||||
green >= 188
|
||||
&& white_score < 0.34
|
||||
&& green.saturating_sub(foreground_mix) >= 42
|
||||
&& (red >= 48 || blue >= 96 || pixel[3] < 236)
|
||||
}
|
||||
|
||||
fn compute_match3d_material_green_screen_score(pixel: [u8; 4]) -> f32 {
|
||||
if pixel[3] == 0 {
|
||||
return 1.0;
|
||||
@@ -6463,6 +6594,146 @@ fn compute_match3d_material_white_screen_score(pixel: [u8; 4]) -> f32 {
|
||||
clamp_match3d_material_unit(neutrality * (brightness * 0.85 + floor * 0.15))
|
||||
}
|
||||
|
||||
fn remove_match3d_container_plain_background(
|
||||
pixels: &mut [u8],
|
||||
width: usize,
|
||||
height: usize,
|
||||
) -> bool {
|
||||
let pixel_count = width.saturating_mul(height);
|
||||
if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut background_mask = vec![0u8; pixel_count];
|
||||
let mut queue = Vec::<usize>::new();
|
||||
let mut queue_index = 0usize;
|
||||
|
||||
let seed_pixel = |pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec<usize>| {
|
||||
if background_mask[pixel_index] != 0 {
|
||||
return;
|
||||
}
|
||||
let offset = pixel_index * 4;
|
||||
if is_match3d_container_background_pixel([
|
||||
pixels[offset],
|
||||
pixels[offset + 1],
|
||||
pixels[offset + 2],
|
||||
pixels[offset + 3],
|
||||
]) {
|
||||
background_mask[pixel_index] = 1;
|
||||
queue.push(pixel_index);
|
||||
}
|
||||
};
|
||||
|
||||
for x in 0..width {
|
||||
seed_pixel(x, &mut background_mask, &mut queue);
|
||||
seed_pixel((height - 1) * width + x, &mut background_mask, &mut queue);
|
||||
}
|
||||
for y in 1..height.saturating_sub(1) {
|
||||
seed_pixel(y * width, &mut background_mask, &mut queue);
|
||||
seed_pixel(y * width + width - 1, &mut background_mask, &mut queue);
|
||||
}
|
||||
|
||||
while queue_index < queue.len() {
|
||||
let pixel_index = queue[queue_index];
|
||||
queue_index += 1;
|
||||
let x = pixel_index % width;
|
||||
let y = pixel_index / width;
|
||||
let neighbors = [
|
||||
(x > 0).then(|| pixel_index - 1),
|
||||
(x + 1 < width).then_some(pixel_index + 1),
|
||||
(y > 0).then(|| pixel_index - width),
|
||||
(y + 1 < height).then_some(pixel_index + width),
|
||||
];
|
||||
|
||||
for next_pixel_index in neighbors.into_iter().flatten() {
|
||||
if background_mask[next_pixel_index] != 0 {
|
||||
continue;
|
||||
}
|
||||
let offset = next_pixel_index * 4;
|
||||
if is_match3d_container_background_pixel([
|
||||
pixels[offset],
|
||||
pixels[offset + 1],
|
||||
pixels[offset + 2],
|
||||
pixels[offset + 3],
|
||||
]) {
|
||||
background_mask[next_pixel_index] = 1;
|
||||
queue.push(next_pixel_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 中文注释:图生图偶尔会在容器边缘留下白底抗锯齿,扩一层只清理连到背景的浅色边。
|
||||
for _ in 0..2 {
|
||||
let mut expanded_mask = background_mask.clone();
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let pixel_index = y * width + x;
|
||||
if background_mask[pixel_index] != 0 {
|
||||
continue;
|
||||
}
|
||||
let offset = pixel_index * 4;
|
||||
let pixel = [
|
||||
pixels[offset],
|
||||
pixels[offset + 1],
|
||||
pixels[offset + 2],
|
||||
pixels[offset + 3],
|
||||
];
|
||||
if !is_match3d_container_soft_background_pixel(pixel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut adjacent_background_count = 0usize;
|
||||
for offset_y in -1i32..=1 {
|
||||
for offset_x in -1i32..=1 {
|
||||
if offset_x == 0 && offset_y == 0 {
|
||||
continue;
|
||||
}
|
||||
let next_x = x as i32 + offset_x;
|
||||
let next_y = y as i32 + offset_y;
|
||||
if next_x < 0
|
||||
|| next_x >= width as i32
|
||||
|| next_y < 0
|
||||
|| next_y >= height as i32
|
||||
{
|
||||
adjacent_background_count += 1;
|
||||
continue;
|
||||
}
|
||||
if background_mask[next_y as usize * width + next_x as usize] != 0 {
|
||||
adjacent_background_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if adjacent_background_count >= 3 {
|
||||
expanded_mask[pixel_index] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
background_mask = expanded_mask;
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
for pixel_index in 0..pixel_count {
|
||||
if background_mask[pixel_index] == 0 {
|
||||
continue;
|
||||
}
|
||||
let offset = pixel_index * 4;
|
||||
if pixels[offset + 3] != 0 {
|
||||
pixels[offset + 3] = 0;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn is_match3d_container_background_pixel(pixel: [u8; 4]) -> bool {
|
||||
pixel[3] < 16 || compute_match3d_material_white_screen_score(pixel) > 0.34
|
||||
}
|
||||
|
||||
fn is_match3d_container_soft_background_pixel(pixel: [u8; 4]) -> bool {
|
||||
pixel[3] < 80 || compute_match3d_material_white_screen_score(pixel) > 0.18
|
||||
}
|
||||
|
||||
fn collect_match3d_material_foreground_neighbor_color(
|
||||
pixels: &[u8],
|
||||
width: usize,
|
||||
@@ -7148,6 +7419,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_slicing_removes_soft_green_matte_before_crop() {
|
||||
let width = 500;
|
||||
let height = 500;
|
||||
let item_names = vec!["草莓".to_string()];
|
||||
let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255]));
|
||||
for y in 28..72 {
|
||||
for x in 28..72 {
|
||||
sheet.put_pixel(x, y, image::Rgba([64, 198, 112, 255]));
|
||||
}
|
||||
}
|
||||
for y in 36..64 {
|
||||
for x in 36..64 {
|
||||
sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255]));
|
||||
}
|
||||
}
|
||||
|
||||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(sheet)
|
||||
.write_to(&mut encoded, ImageFormat::Png)
|
||||
.expect("sheet should encode");
|
||||
let image = DownloadedOpenAiImage {
|
||||
bytes: encoded.into_inner(),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
};
|
||||
|
||||
let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice");
|
||||
let decoded = image::load_from_memory(slices[0][0].bytes.as_slice())
|
||||
.expect("view should decode")
|
||||
.to_rgba8();
|
||||
|
||||
assert!(
|
||||
decoded.pixels().all(|pixel| {
|
||||
let [red, green, blue, alpha] = pixel.0;
|
||||
alpha == 0 || green <= red.max(blue).saturating_add(32)
|
||||
}),
|
||||
"整张 sheet 去绿后再裁剪,输出 PNG 不能保留可见软绿边"
|
||||
);
|
||||
assert!(
|
||||
decoded.pixels().any(|pixel| pixel.0 == [220, 32, 48, 255]),
|
||||
"软绿边清理不能误删物品主体"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_slicing_cleans_white_matte_edge() {
|
||||
let width = 500;
|
||||
@@ -7193,6 +7509,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_container_image_postprocess_removes_plain_background() {
|
||||
let width = 256;
|
||||
let height = 256;
|
||||
let mut image =
|
||||
image::RgbaImage::from_pixel(width, height, image::Rgba([248, 248, 246, 255]));
|
||||
for y in 68..190 {
|
||||
for x in 38..218 {
|
||||
image.put_pixel(x, y, image::Rgba([160, 104, 54, 255]));
|
||||
}
|
||||
}
|
||||
|
||||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(image)
|
||||
.write_to(&mut encoded, ImageFormat::Png)
|
||||
.expect("container should encode");
|
||||
let processed = make_match3d_container_image_transparent(DownloadedOpenAiImage {
|
||||
bytes: encoded.into_inner(),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
})
|
||||
.expect("container should postprocess");
|
||||
let decoded = image::load_from_memory(processed.bytes.as_slice())
|
||||
.expect("processed container should decode")
|
||||
.to_rgba8();
|
||||
|
||||
assert_eq!(processed.mime_type, "image/png");
|
||||
assert_eq!(processed.extension, "png");
|
||||
assert_eq!(
|
||||
decoded.get_pixel(0, 0).0[3],
|
||||
0,
|
||||
"容器图四周白底必须在入库前转成透明 alpha"
|
||||
);
|
||||
assert_eq!(
|
||||
decoded.get_pixel(width / 2, height / 2).0[3],
|
||||
255,
|
||||
"容器主体不能被透明化误删"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_work_metadata_parses_gpt4o_json() {
|
||||
let metadata = parse_match3d_work_metadata(
|
||||
@@ -7544,12 +7900,12 @@ mod tests {
|
||||
let root_settings = Match3DVectorEngineGeminiImageSettings {
|
||||
base_url: "https://api.vectorengine.cn".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 180_000,
|
||||
request_timeout_ms: 1_000_000,
|
||||
};
|
||||
let v1_settings = Match3DVectorEngineGeminiImageSettings {
|
||||
base_url: "https://api.vectorengine.cn/v1".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 180_000,
|
||||
request_timeout_ms: 1_000_000,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -7584,7 +7940,9 @@ mod tests {
|
||||
assert!(container_prompt.contains("轻俯视 3/4"));
|
||||
assert!(container_prompt.contains("横向椭圆形内口"));
|
||||
assert!(container_prompt.contains("不能画成正俯视扁圆盘"));
|
||||
assert!(container_prompt.contains("不能做成整页背景"));
|
||||
assert!(container_prompt.contains("透明 alpha"));
|
||||
assert!(container_prompt.contains("白底"));
|
||||
assert!(container_prompt.contains("整页背景"));
|
||||
assert!(container_prompt.contains("禁止文字"));
|
||||
}
|
||||
|
||||
|
||||
@@ -628,12 +628,12 @@ mod tests {
|
||||
let root_settings = OpenAiImageSettings {
|
||||
base_url: "https://vector.example".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 180_000,
|
||||
request_timeout_ms: 1_000_000,
|
||||
};
|
||||
let v1_settings = OpenAiImageSettings {
|
||||
base_url: "https://vector.example/v1".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 180_000,
|
||||
request_timeout_ms: 1_000_000,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
/// 拼图首关关卡名与 UI 背景提示词生成提示词。
|
||||
/// 拼图首关关卡名、作品元信息与 UI 背景提示词生成提示词。
|
||||
///
|
||||
/// 模型只负责把画面描述压缩成可直接展示的中文关卡名,并产出运行态 UI 背景的正向视觉提示词;
|
||||
/// 写回草稿和作品卡由业务路由处理。
|
||||
/// 模型只负责把画面描述压缩成可直接展示的中文关卡名、作品描述、作品标签,
|
||||
/// 并产出运行态 UI 背景的正向视觉提示词;写回草稿和作品卡由业务路由处理。
|
||||
pub(crate) const PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT: &str = r#"你是一个中文拼图关卡命名编辑。
|
||||
|
||||
你会收到拼图第一关的画面描述,部分请求还会附带已经生成完成的正式图片。请综合图片内容和画面描述,同时生成:
|
||||
- 1 个适合直接展示在游戏关卡卡片上的中文关卡名。
|
||||
- 1 段适合默认填入拼图草稿的中文作品描述。
|
||||
- 6 个适合作品广场检索和相似推荐的中文作品标签。
|
||||
- 1 段用于生成 9:16 拼图运行态 UI 纯背景图的中文正向视觉提示词。
|
||||
|
||||
硬约束:
|
||||
1. 只输出 JSON,不要输出 Markdown、解释或代码块。
|
||||
2. JSON 格式必须是 {"levelName":"关卡名","uiBackgroundPrompt":"提示词"}。
|
||||
2. JSON 格式必须是 {"levelName":"关卡名","workDescription":"作品描述","workTags":["标签1","标签2","标签3","标签4","标签5","标签6"],"uiBackgroundPrompt":"提示词"}。
|
||||
3. levelName 必须是 2 到 8 个中文字符为主。
|
||||
4. 不要输出“第一关”“画面”“拼图”“作品”等泛词。
|
||||
5. 不要输出标点、引号、编号、英文、emoji 或空白。
|
||||
6. 关卡名要抓住画面主体、场景和氛围,读起来像一个具体可玩的关卡。
|
||||
7. uiBackgroundPrompt 必须是 30 到 160 个中文字符,描述题材氛围、环境、色彩、光影和空间层次。
|
||||
8. uiBackgroundPrompt 只写正向画面描述,不要写规则说明,不要出现拼图槽、棋盘、HUD、按钮、文字、水印、数字、拼图碎片、完整拼图图像或教程浮层。
|
||||
7. workDescription 必须是 18 到 80 个中文字符,描述这套拼图的画面主题、氛围和游玩期待,不要复述字段名。
|
||||
8. workTags 必须正好 6 个,每个标签 2 到 6 个中文字符为主,覆盖题材、主体、氛围、场景、风格和拼图辨识点。
|
||||
9. uiBackgroundPrompt 必须是 30 到 160 个中文字符,描述题材氛围、环境、色彩、光影和空间层次。
|
||||
10. uiBackgroundPrompt 只写正向画面描述,不要写规则说明,不要出现拼图槽、棋盘、HUD、按钮、文字、水印、数字、拼图碎片、完整拼图图像或教程浮层。
|
||||
"#;
|
||||
|
||||
pub(crate) fn build_puzzle_first_level_name_user_prompt(picture_description: &str) -> String {
|
||||
format!(
|
||||
"画面描述:{picture_description}\n\n请生成第一关关卡名和 UI 背景提示词。",
|
||||
"画面描述:{picture_description}\n\n请生成第一关关卡名、作品描述、6 个作品标签和 UI 背景提示词。",
|
||||
picture_description = picture_description.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_puzzle_first_level_name_vision_user_text(picture_description: &str) -> String {
|
||||
format!(
|
||||
"画面描述:{picture_description}\n\n请观察随消息附带的正式拼图图片,生成第一关关卡名和 UI 背景提示词。",
|
||||
"画面描述:{picture_description}\n\n请观察随消息附带的正式拼图图片,生成第一关关卡名、作品描述、6 个作品标签和 UI 背景提示词。",
|
||||
picture_description = picture_description.trim(),
|
||||
)
|
||||
}
|
||||
@@ -43,6 +47,8 @@ mod tests {
|
||||
|
||||
assert!(prompt.contains("画面描述:一只猫在雨夜灯牌下回头。"));
|
||||
assert!(prompt.contains("第一关关卡名"));
|
||||
assert!(prompt.contains("作品描述"));
|
||||
assert!(prompt.contains("6 个作品标签"));
|
||||
assert!(prompt.contains("UI 背景提示词"));
|
||||
}
|
||||
|
||||
@@ -52,6 +58,8 @@ mod tests {
|
||||
|
||||
assert!(prompt.contains("画面描述:一只猫在雨夜灯牌下回头。"));
|
||||
assert!(prompt.contains("正式拼图图片"));
|
||||
assert!(prompt.contains("作品描述"));
|
||||
assert!(prompt.contains("6 个作品标签"));
|
||||
assert!(prompt.contains("UI 背景提示词"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,9 @@ const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024";
|
||||
const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024";
|
||||
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
||||
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
|
||||
const PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS: u32 = 512;
|
||||
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;
|
||||
const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5;
|
||||
const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2";
|
||||
const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
|
||||
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素";
|
||||
@@ -718,16 +720,20 @@ pub async fn execute_puzzle_agent_action(
|
||||
.as_deref()
|
||||
.map(|value| value.chars().count())
|
||||
.unwrap_or(0),
|
||||
has_reference_image = payload
|
||||
.reference_image_src
|
||||
.as_deref()
|
||||
.map(|value| !value.trim().is_empty())
|
||||
.unwrap_or(false),
|
||||
has_reference_image = has_puzzle_reference_images(
|
||||
payload.reference_image_src.as_deref(),
|
||||
payload.reference_image_srcs.as_slice(),
|
||||
),
|
||||
"拼图 Agent action 开始执行"
|
||||
);
|
||||
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
|
||||
"compile_puzzle_draft" => {
|
||||
let ai_redraw = payload.ai_redraw.unwrap_or(true);
|
||||
let reference_image_sources = collect_puzzle_reference_image_sources(
|
||||
payload.reference_image_src.as_deref(),
|
||||
payload.reference_image_srcs.as_slice(),
|
||||
);
|
||||
let primary_reference_image_src = reference_image_sources.first().map(String::as_str);
|
||||
let prompt_text = payload
|
||||
.picture_description
|
||||
.as_deref()
|
||||
@@ -760,7 +766,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
compile_session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
prompt_text,
|
||||
payload.reference_image_src.as_deref(),
|
||||
primary_reference_image_src,
|
||||
payload.image_model.as_deref(),
|
||||
now,
|
||||
)
|
||||
@@ -891,6 +897,12 @@ pub async fn execute_puzzle_agent_action(
|
||||
payload.prompt_text.as_deref(),
|
||||
&target_level.picture_description,
|
||||
);
|
||||
let reference_image_sources = collect_puzzle_reference_image_sources(
|
||||
payload.reference_image_src.as_deref(),
|
||||
payload.reference_image_srcs.as_slice(),
|
||||
);
|
||||
let primary_reference_image_src =
|
||||
reference_image_sources.first().map(String::as_str);
|
||||
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
|
||||
let candidate_count = 1;
|
||||
let candidate_start_index = target_level.candidates.len();
|
||||
@@ -900,7 +912,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
&session.session_id,
|
||||
&target_level.level_name,
|
||||
&prompt,
|
||||
payload.reference_image_src.as_deref(),
|
||||
primary_reference_image_src,
|
||||
payload.ai_redraw.unwrap_or(true),
|
||||
payload.image_model.as_deref(),
|
||||
candidate_count,
|
||||
@@ -934,7 +946,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
&build_puzzle_levels_with_primary_update(
|
||||
&draft,
|
||||
&target_level,
|
||||
payload.reference_image_src.as_deref(),
|
||||
primary_reference_image_src,
|
||||
),
|
||||
)?);
|
||||
let candidates_json = serde_json::to_string(
|
||||
@@ -985,7 +997,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
),
|
||||
target_level.level_id.as_str(),
|
||||
candidates.into_records(),
|
||||
payload.reference_image_src.as_deref(),
|
||||
primary_reference_image_src,
|
||||
now,
|
||||
))
|
||||
}
|
||||
@@ -3067,6 +3079,8 @@ fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) {
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct PuzzleLevelNaming {
|
||||
level_name: String,
|
||||
work_description: Option<String>,
|
||||
work_tags: Vec<String>,
|
||||
ui_background_prompt: Option<String>,
|
||||
}
|
||||
|
||||
@@ -3074,6 +3088,8 @@ impl PuzzleLevelNaming {
|
||||
fn fallback(picture_description: &str) -> Self {
|
||||
Self {
|
||||
level_name: build_fallback_puzzle_first_level_name(picture_description),
|
||||
work_description: None,
|
||||
work_tags: Vec::new(),
|
||||
ui_background_prompt: None,
|
||||
}
|
||||
}
|
||||
@@ -3150,7 +3166,7 @@ async fn generate_puzzle_first_level_name_from_image(
|
||||
]),
|
||||
])
|
||||
.with_model(PUZZLE_LEVEL_NAME_VISION_LLM_MODEL)
|
||||
.with_max_tokens(80),
|
||||
.with_max_tokens(PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -3217,6 +3233,9 @@ fn parse_puzzle_level_naming_from_text(text: &str) -> Option<PuzzleLevelNaming>
|
||||
trimmed
|
||||
};
|
||||
let parsed = serde_json::from_str::<Value>(json_text).ok();
|
||||
if parsed.is_none() && looks_like_puzzle_json_fragment(trimmed) {
|
||||
return None;
|
||||
}
|
||||
let raw_name = parsed
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("levelName").and_then(Value::as_str))
|
||||
@@ -3227,12 +3246,21 @@ fn parse_puzzle_level_naming_from_text(text: &str) -> Option<PuzzleLevelNaming>
|
||||
})
|
||||
.unwrap_or(trimmed);
|
||||
let level_name = normalize_puzzle_first_level_name(raw_name)?;
|
||||
let work_description = parsed
|
||||
.as_ref()
|
||||
.and_then(parse_puzzle_generated_work_description_field);
|
||||
let work_tags = parsed
|
||||
.as_ref()
|
||||
.and_then(parse_puzzle_generated_work_tags_field)
|
||||
.unwrap_or_default();
|
||||
let ui_background_prompt = parsed
|
||||
.as_ref()
|
||||
.and_then(parse_puzzle_ui_background_prompt_field);
|
||||
|
||||
Some(PuzzleLevelNaming {
|
||||
level_name,
|
||||
work_description,
|
||||
work_tags,
|
||||
ui_background_prompt,
|
||||
})
|
||||
}
|
||||
@@ -3250,6 +3278,55 @@ fn parse_puzzle_ui_background_prompt_field(value: &Value) -> Option<String> {
|
||||
.and_then(normalize_puzzle_generated_ui_background_prompt)
|
||||
}
|
||||
|
||||
fn parse_puzzle_generated_work_description_field(value: &Value) -> Option<String> {
|
||||
value
|
||||
.get("workDescription")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| value.get("work_description").and_then(Value::as_str))
|
||||
.and_then(normalize_puzzle_generated_work_description)
|
||||
}
|
||||
|
||||
fn normalize_puzzle_generated_work_description(value: &str) -> Option<String> {
|
||||
let normalized = value
|
||||
.trim()
|
||||
.trim_matches(|ch: char| {
|
||||
ch.is_ascii_punctuation()
|
||||
|| matches!(
|
||||
ch,
|
||||
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
||||
)
|
||||
})
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
let description = normalized.chars().take(80).collect::<String>();
|
||||
(description.chars().count() >= 8 && !looks_like_puzzle_json_field_name(&description))
|
||||
.then_some(description)
|
||||
}
|
||||
|
||||
fn parse_puzzle_generated_work_tags_field(value: &Value) -> Option<Vec<String>> {
|
||||
let tags_value = value
|
||||
.get("workTags")
|
||||
.or_else(|| value.get("work_tags"))
|
||||
.or_else(|| value.get("themeTags"))
|
||||
.or_else(|| value.get("theme_tags"))
|
||||
.or_else(|| value.get("tags"))?;
|
||||
let raw_tags = match tags_value {
|
||||
Value::Array(items) => items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>(),
|
||||
Value::String(text) => text
|
||||
.split([',', ',', '、', '\n', '|', '/'])
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let tags = normalize_puzzle_generated_work_tag_candidates(raw_tags);
|
||||
(tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT).then_some(tags)
|
||||
}
|
||||
|
||||
fn normalize_puzzle_generated_ui_background_prompt(value: &str) -> Option<String> {
|
||||
let normalized = value
|
||||
.trim()
|
||||
@@ -3331,6 +3408,7 @@ fn normalize_puzzle_first_level_name(value: &str) -> Option<String> {
|
||||
normalized.as_str(),
|
||||
"第一关" | "画面" | "拼图" | "作品" | "关卡"
|
||||
)
|
||||
&& !looks_like_puzzle_json_field_name(&normalized)
|
||||
{
|
||||
Some(normalized)
|
||||
} else {
|
||||
@@ -3338,6 +3416,52 @@ fn normalize_puzzle_first_level_name(value: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_puzzle_json_field_name(value: &str) -> bool {
|
||||
let normalized = value.trim().trim_matches(|ch: char| {
|
||||
ch.is_ascii_punctuation()
|
||||
|| matches!(
|
||||
ch,
|
||||
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
||||
)
|
||||
});
|
||||
let compact = normalized.to_ascii_lowercase().replace('_', "");
|
||||
matches!(compact.as_str(), "levelnam" | "levelname")
|
||||
|| [
|
||||
"levelname",
|
||||
"workdescription",
|
||||
"worktags",
|
||||
"themetags",
|
||||
"uibackgroundprompt",
|
||||
]
|
||||
.iter()
|
||||
.any(|field| {
|
||||
compact == *field
|
||||
|| (compact.len() >= 6 && field.starts_with(compact.as_str()))
|
||||
|| compact.starts_with(field)
|
||||
})
|
||||
}
|
||||
|
||||
fn looks_like_puzzle_json_fragment(value: &str) -> bool {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.starts_with('{') || trimmed.starts_with('[') {
|
||||
return true;
|
||||
}
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
[
|
||||
"\"levelnam",
|
||||
"\"levelname\"",
|
||||
"\"level_name\"",
|
||||
"\"workdescription\"",
|
||||
"\"work_description\"",
|
||||
"\"worktags\"",
|
||||
"\"work_tags\"",
|
||||
"\"uibackgroundprompt\"",
|
||||
"\"ui_background_prompt\"",
|
||||
]
|
||||
.iter()
|
||||
.any(|field| lower.contains(field))
|
||||
}
|
||||
|
||||
fn strip_puzzle_level_name_generic_words(mut value: String) -> String {
|
||||
for prefix in ["第一关", "关卡名", "关卡"] {
|
||||
value = value.trim_start_matches(prefix).to_string();
|
||||
@@ -3406,6 +3530,28 @@ fn build_puzzle_levels_with_primary_update(
|
||||
levels
|
||||
}
|
||||
|
||||
fn attach_selected_puzzle_candidate_to_levels(
|
||||
levels: &mut [PuzzleDraftLevelRecord],
|
||||
target_level_id: &str,
|
||||
candidate: &PuzzleGeneratedImageCandidateRecord,
|
||||
) {
|
||||
if let Some(index) = levels
|
||||
.iter()
|
||||
.position(|level| level.level_id == target_level_id)
|
||||
.or_else(|| (!levels.is_empty()).then_some(0))
|
||||
{
|
||||
let level = &mut levels[index];
|
||||
level.candidates.clear();
|
||||
let mut candidate = candidate.clone();
|
||||
candidate.selected = true;
|
||||
level.selected_candidate_id = Some(candidate.candidate_id.clone());
|
||||
level.cover_image_src = Some(candidate.image_src.clone());
|
||||
level.cover_asset_id = Some(candidate.asset_id.clone());
|
||||
level.candidates.push(candidate);
|
||||
level.generation_status = "ready".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_puzzle_initial_ui_background_prompt(
|
||||
draft: &PuzzleResultDraftRecord,
|
||||
target_level: &PuzzleDraftLevelRecord,
|
||||
@@ -3596,7 +3742,8 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
);
|
||||
let (generated_naming, candidates_result) = tokio::join!(level_name_future, candidates_future);
|
||||
target_level.level_name = generated_naming.level_name.clone();
|
||||
target_level.ui_background_prompt = generated_naming.ui_background_prompt;
|
||||
target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone();
|
||||
let mut generated_metadata = generated_naming;
|
||||
let candidates = candidates_result?;
|
||||
let selected_candidate_id = candidates
|
||||
.iter()
|
||||
@@ -3620,6 +3767,14 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
if refined_naming.ui_background_prompt.is_some() {
|
||||
target_level.ui_background_prompt = refined_naming.ui_background_prompt;
|
||||
}
|
||||
if refined_naming.work_description.is_some() {
|
||||
generated_metadata.work_description = refined_naming.work_description;
|
||||
}
|
||||
if refined_naming.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||||
generated_metadata.work_tags = refined_naming.work_tags;
|
||||
}
|
||||
generated_metadata.level_name = target_level.level_name.clone();
|
||||
generated_metadata.ui_background_prompt = target_level.ui_background_prompt.clone();
|
||||
}
|
||||
let generated_level_name = target_level.level_name.clone();
|
||||
let mut updated_levels =
|
||||
@@ -3639,6 +3794,17 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
ui_prompt,
|
||||
ui_background,
|
||||
);
|
||||
if let Some(selected_candidate) = candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.record.selected)
|
||||
.or_else(|| candidates.first())
|
||||
{
|
||||
attach_selected_puzzle_candidate_to_levels(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
&selected_candidate.record,
|
||||
);
|
||||
}
|
||||
let ready_level =
|
||||
find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str())
|
||||
.ok_or_else(|| {
|
||||
@@ -3650,6 +3816,28 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
ensure_puzzle_initial_level_assets_ready(ready_level)?;
|
||||
let levels_json_with_generated_name =
|
||||
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
|
||||
let work_title = if draft.work_title.trim().is_empty()
|
||||
|| draft.work_title.trim() == fallback_level_name.trim()
|
||||
{
|
||||
generated_level_name.clone()
|
||||
} else {
|
||||
draft.work_title.clone()
|
||||
};
|
||||
let work_description = if draft.work_description.trim().is_empty() {
|
||||
generated_metadata
|
||||
.work_description
|
||||
.clone()
|
||||
.unwrap_or_else(|| draft.work_description.clone())
|
||||
} else {
|
||||
draft.work_description.clone()
|
||||
};
|
||||
let theme_tags = if draft.theme_tags.is_empty()
|
||||
&& generated_metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT
|
||||
{
|
||||
generated_metadata.work_tags.clone()
|
||||
} else {
|
||||
draft.theme_tags.clone()
|
||||
};
|
||||
let candidates_json = serde_json::to_string(
|
||||
&candidates
|
||||
.iter()
|
||||
@@ -3707,6 +3895,43 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
Err(error)
|
||||
}
|
||||
})?;
|
||||
let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id);
|
||||
match state
|
||||
.spacetime_client()
|
||||
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
|
||||
profile_id,
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
work_title,
|
||||
work_description: work_description.clone(),
|
||||
level_name: generated_level_name.clone(),
|
||||
summary: work_description,
|
||||
theme_tags,
|
||||
cover_image_src: ready_level.cover_image_src.clone(),
|
||||
cover_asset_id: ready_level.cover_asset_id.clone(),
|
||||
levels_json: levels_json_with_generated_name.clone(),
|
||||
updated_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(error) if is_spacetimedb_connectivity_app_error(&error) => {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %compiled_session.session_id,
|
||||
owner_user_id = %owner_user_id,
|
||||
message = %error.body_text(),
|
||||
"拼图首图生成后作品元信息投影回写不可用,继续使用会话草稿快照"
|
||||
);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
let saved_session = apply_generated_puzzle_initial_metadata_to_session_snapshot(
|
||||
saved_session,
|
||||
&generated_metadata,
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
);
|
||||
if save_used_fallback {
|
||||
return Ok(saved_session);
|
||||
}
|
||||
@@ -3721,7 +3946,12 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(session) => Ok(session),
|
||||
Ok(session) => Ok(apply_generated_puzzle_initial_metadata_to_session_snapshot(
|
||||
session,
|
||||
&generated_metadata,
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
)),
|
||||
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
@@ -3821,9 +4051,18 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
if refined_naming.ui_background_prompt.is_some() {
|
||||
generated_naming.ui_background_prompt = refined_naming.ui_background_prompt;
|
||||
}
|
||||
if refined_naming.work_description.is_some() {
|
||||
generated_naming.work_description = refined_naming.work_description;
|
||||
}
|
||||
if refined_naming.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||||
generated_naming.work_tags = refined_naming.work_tags;
|
||||
}
|
||||
}
|
||||
target_level.level_name = generated_naming.level_name;
|
||||
target_level.ui_background_prompt = generated_naming.ui_background_prompt;
|
||||
target_level.level_name = generated_naming.level_name.clone();
|
||||
target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone();
|
||||
let mut generated_metadata = generated_naming;
|
||||
generated_metadata.level_name = target_level.level_name.clone();
|
||||
generated_metadata.ui_background_prompt = target_level.ui_background_prompt.clone();
|
||||
let generated_level_name = target_level.level_name.clone();
|
||||
let persisted_upload = persisted_upload_result?;
|
||||
let mut updated_levels =
|
||||
@@ -3843,6 +4082,19 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
ui_prompt,
|
||||
ui_background,
|
||||
);
|
||||
attach_selected_puzzle_candidate_to_levels(
|
||||
&mut updated_levels,
|
||||
target_level.level_id.as_str(),
|
||||
&PuzzleGeneratedImageCandidateRecord {
|
||||
candidate_id: candidate_id.clone(),
|
||||
image_src: persisted_upload.image_src.clone(),
|
||||
asset_id: persisted_upload.asset_id.clone(),
|
||||
prompt: image_prompt.clone(),
|
||||
actual_prompt: None,
|
||||
source_type: "uploaded".to_string(),
|
||||
selected: true,
|
||||
},
|
||||
);
|
||||
let ready_level =
|
||||
find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str())
|
||||
.ok_or_else(|| {
|
||||
@@ -3854,6 +4106,28 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
ensure_puzzle_initial_level_assets_ready(ready_level)?;
|
||||
let levels_json_with_generated_name =
|
||||
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
|
||||
let work_title = if draft.work_title.trim().is_empty()
|
||||
|| draft.work_title.trim() == fallback_level_name.trim()
|
||||
{
|
||||
generated_level_name.clone()
|
||||
} else {
|
||||
draft.work_title.clone()
|
||||
};
|
||||
let work_description = if draft.work_description.trim().is_empty() {
|
||||
generated_metadata
|
||||
.work_description
|
||||
.clone()
|
||||
.unwrap_or_else(|| draft.work_description.clone())
|
||||
} else {
|
||||
draft.work_description.clone()
|
||||
};
|
||||
let theme_tags = if draft.theme_tags.is_empty()
|
||||
&& generated_metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT
|
||||
{
|
||||
generated_metadata.work_tags.clone()
|
||||
} else {
|
||||
draft.theme_tags.clone()
|
||||
};
|
||||
let candidate = PuzzleGeneratedImageCandidateRecord {
|
||||
candidate_id: candidate_id.clone(),
|
||||
image_src: persisted_upload.image_src,
|
||||
@@ -3916,6 +4190,43 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
Err(error)
|
||||
}
|
||||
})?;
|
||||
let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id);
|
||||
match state
|
||||
.spacetime_client()
|
||||
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
|
||||
profile_id,
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
work_title,
|
||||
work_description: work_description.clone(),
|
||||
level_name: generated_level_name.clone(),
|
||||
summary: work_description,
|
||||
theme_tags,
|
||||
cover_image_src: ready_level.cover_image_src.clone(),
|
||||
cover_asset_id: ready_level.cover_asset_id.clone(),
|
||||
levels_json: levels_json_with_generated_name.clone(),
|
||||
updated_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(error) if is_spacetimedb_connectivity_app_error(&error) => {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %compiled_session.session_id,
|
||||
owner_user_id = %owner_user_id,
|
||||
message = %error.body_text(),
|
||||
"拼图上传图草稿作品元信息投影回写不可用,继续使用会话草稿快照"
|
||||
);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
let saved_session = apply_generated_puzzle_initial_metadata_to_session_snapshot(
|
||||
saved_session,
|
||||
&generated_metadata,
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
);
|
||||
if save_used_fallback {
|
||||
return Ok(saved_session);
|
||||
}
|
||||
@@ -3930,7 +4241,12 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(session) => Ok(session),
|
||||
Ok(session) => Ok(apply_generated_puzzle_initial_metadata_to_session_snapshot(
|
||||
session,
|
||||
&generated_metadata,
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
)),
|
||||
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
@@ -4046,6 +4362,53 @@ fn apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
session
|
||||
}
|
||||
|
||||
fn apply_generated_puzzle_initial_metadata_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
metadata: &PuzzleLevelNaming,
|
||||
previous_level_name: &str,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
let Some(draft) = session.draft.as_mut() else {
|
||||
return session;
|
||||
};
|
||||
apply_generated_puzzle_initial_metadata_to_draft(
|
||||
draft,
|
||||
metadata,
|
||||
previous_level_name,
|
||||
updated_at_micros,
|
||||
);
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
fn apply_generated_puzzle_initial_metadata_to_draft(
|
||||
draft: &mut PuzzleResultDraftRecord,
|
||||
metadata: &PuzzleLevelNaming,
|
||||
previous_level_name: &str,
|
||||
_updated_at_micros: i64,
|
||||
) {
|
||||
let should_default_work_title =
|
||||
draft.work_title.trim().is_empty() || draft.work_title.trim() == previous_level_name.trim();
|
||||
if should_default_work_title {
|
||||
draft.work_title = metadata.level_name.clone();
|
||||
}
|
||||
|
||||
if draft.work_description.trim().is_empty()
|
||||
&& let Some(description) = metadata.work_description.as_ref()
|
||||
{
|
||||
draft.work_description = description.clone();
|
||||
draft.summary = description.clone();
|
||||
}
|
||||
|
||||
if draft.theme_tags.is_empty()
|
||||
&& metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT
|
||||
{
|
||||
draft.theme_tags = metadata.work_tags.clone();
|
||||
}
|
||||
|
||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||
}
|
||||
|
||||
fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftRecord) {
|
||||
let Some(primary_level) = draft.levels.first() else {
|
||||
return;
|
||||
@@ -4056,6 +4419,16 @@ fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftReco
|
||||
draft.cover_image_src = primary_level.cover_image_src.clone();
|
||||
draft.cover_asset_id = primary_level.cover_asset_id.clone();
|
||||
draft.generation_status = primary_level.generation_status.clone();
|
||||
draft.summary = draft.work_description.clone();
|
||||
if draft.form_draft.is_some() {
|
||||
draft.form_draft = Some(PuzzleFormDraftRecord {
|
||||
work_title: (!draft.work_title.trim().is_empty()).then_some(draft.work_title.clone()),
|
||||
work_description: (!draft.work_description.trim().is_empty())
|
||||
.then_some(draft.work_description.clone()),
|
||||
picture_description: (!primary_level.picture_description.trim().is_empty())
|
||||
.then_some(primary_level.picture_description.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_puzzle_session_draft_snapshot(
|
||||
@@ -4170,6 +4543,9 @@ where
|
||||
{
|
||||
let mut tags = Vec::new();
|
||||
for candidate in candidates {
|
||||
if looks_like_puzzle_json_field_name(candidate.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
let normalized = normalize_puzzle_tag(candidate.as_ref());
|
||||
if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) {
|
||||
continue;
|
||||
@@ -4190,6 +4566,29 @@ where
|
||||
tags
|
||||
}
|
||||
|
||||
fn normalize_puzzle_generated_work_tag_candidates<S>(
|
||||
candidates: impl IntoIterator<Item = S>,
|
||||
) -> Vec<String>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut tags = Vec::new();
|
||||
for candidate in candidates {
|
||||
let normalized = normalize_puzzle_tag(candidate.as_ref());
|
||||
if normalized.is_empty()
|
||||
|| looks_like_puzzle_json_field_name(&normalized)
|
||||
|| tags.iter().any(|tag| tag == &normalized)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
tags.push(normalized);
|
||||
if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
tags
|
||||
}
|
||||
|
||||
fn normalize_puzzle_tag(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
@@ -4931,6 +5330,26 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_reference_image_sources_are_deduped_and_limited() {
|
||||
let sources = collect_puzzle_reference_image_sources(
|
||||
Some("data:image/png;base64,a"),
|
||||
&[
|
||||
"data:image/png;base64,a".to_string(),
|
||||
"data:image/png;base64,b".to_string(),
|
||||
"data:image/png;base64,c".to_string(),
|
||||
"data:image/png;base64,d".to_string(),
|
||||
"data:image/png;base64,e".to_string(),
|
||||
"data:image/png;base64,f".to_string(),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(sources.len(), 5);
|
||||
assert_eq!(sources[0], "data:image/png;base64,a");
|
||||
assert_eq!(sources[1], "data:image/png;base64,b");
|
||||
assert!(!sources.contains(&"data:image/png;base64,f".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
|
||||
let error = map_puzzle_vector_engine_request_error(
|
||||
@@ -5008,6 +5427,7 @@ mod tests {
|
||||
action: "generate_puzzle_images".to_string(),
|
||||
prompt_text: None,
|
||||
reference_image_src: None,
|
||||
reference_image_srcs: Vec::new(),
|
||||
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||
ai_redraw: None,
|
||||
candidate_count: Some(1),
|
||||
@@ -5055,16 +5475,28 @@ mod tests {
|
||||
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街画面"}"#),
|
||||
Some("雨夜猫街".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_puzzle_first_level_name_from_text(r#"{"levelNam"#),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_level_naming_parser_accepts_ui_background_prompt() {
|
||||
fn puzzle_level_naming_parser_accepts_metadata_and_ui_background_prompt() {
|
||||
let naming = parse_puzzle_level_naming_from_text(
|
||||
r#"{"levelName":"雨夜猫街","uiBackgroundPrompt":"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"}"#,
|
||||
r#"{"levelName":"雨夜猫街","workDescription":"在湿润灯牌与猫影之间完成一套雨夜街角拼图。","workTags":["雨夜","猫咪","灯牌","街角","暖色","插画"],"uiBackgroundPrompt":"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"}"#,
|
||||
)
|
||||
.expect("naming should parse");
|
||||
|
||||
assert_eq!(naming.level_name, "雨夜猫街");
|
||||
assert_eq!(
|
||||
naming.work_description.as_deref(),
|
||||
Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图")
|
||||
);
|
||||
assert_eq!(naming.work_tags.len(), module_puzzle::PUZZLE_MAX_TAG_COUNT);
|
||||
assert!(naming.work_tags.contains(&"雨夜".to_string()));
|
||||
assert!(naming.work_tags.contains(&"猫咪".to_string()));
|
||||
assert!(naming.work_tags.contains(&"灯牌".to_string()));
|
||||
assert_eq!(
|
||||
naming.ui_background_prompt.as_deref(),
|
||||
Some("雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次")
|
||||
@@ -5139,6 +5571,7 @@ mod tests {
|
||||
action: "generate_puzzle_images".to_string(),
|
||||
prompt_text: None,
|
||||
reference_image_src: None,
|
||||
reference_image_srcs: Vec::new(),
|
||||
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||
ai_redraw: None,
|
||||
candidate_count: Some(1),
|
||||
@@ -5173,6 +5606,61 @@ mod tests {
|
||||
assert_eq!(draft.levels[0].level_name, "雨夜猫街");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_initial_metadata_defaults_empty_work_description_and_tags() {
|
||||
let mut session = PuzzleAgentSessionRecord {
|
||||
session_id: "puzzle-session-1".to_string(),
|
||||
seed_text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(),
|
||||
current_turn: 1,
|
||||
progress_percent: 94,
|
||||
stage: "ready_to_publish".to_string(),
|
||||
anchor_pack: test_puzzle_anchor_pack_record(),
|
||||
draft: Some(test_puzzle_draft_record()),
|
||||
messages: Vec::new(),
|
||||
last_assistant_reply: None,
|
||||
published_profile_id: None,
|
||||
suggested_actions: Vec::new(),
|
||||
result_preview: None,
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
};
|
||||
{
|
||||
let draft = session.draft.as_mut().expect("draft");
|
||||
draft.work_title = "猫画面".to_string();
|
||||
draft.work_description = String::new();
|
||||
draft.summary = String::new();
|
||||
draft.theme_tags = Vec::new();
|
||||
}
|
||||
let metadata = PuzzleLevelNaming {
|
||||
level_name: "雨夜猫街".to_string(),
|
||||
work_description: Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图".to_string()),
|
||||
work_tags: vec![
|
||||
"插画".to_string(),
|
||||
"灯牌".to_string(),
|
||||
"街角".to_string(),
|
||||
"猫咪".to_string(),
|
||||
"暖色".to_string(),
|
||||
"雨夜".to_string(),
|
||||
],
|
||||
ui_background_prompt: None,
|
||||
};
|
||||
|
||||
let session = apply_generated_puzzle_initial_metadata_to_session_snapshot(
|
||||
session,
|
||||
&metadata,
|
||||
"猫画面",
|
||||
1_713_686_401_234_568,
|
||||
);
|
||||
|
||||
let draft = session.draft.expect("draft");
|
||||
assert_eq!(draft.work_title, "雨夜猫街");
|
||||
assert_eq!(
|
||||
draft.work_description,
|
||||
"在湿润灯牌与猫影之间完成一套雨夜街角拼图"
|
||||
);
|
||||
assert_eq!(draft.summary, draft.work_description);
|
||||
assert_eq!(draft.theme_tags, metadata.work_tags);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_level_audio_asset_roundtrips_between_response_and_module_json() {
|
||||
let level = PuzzleDraftLevelResponse {
|
||||
@@ -5981,6 +6469,40 @@ fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn collect_puzzle_reference_image_sources(
|
||||
legacy_reference_image_src: Option<&str>,
|
||||
reference_image_srcs: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut sources = Vec::new();
|
||||
for source in legacy_reference_image_src
|
||||
.into_iter()
|
||||
.chain(reference_image_srcs.iter().map(String::as_str))
|
||||
{
|
||||
let normalized = source.trim();
|
||||
if normalized.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !sources
|
||||
.iter()
|
||||
.any(|existing: &String| existing == normalized)
|
||||
{
|
||||
sources.push(normalized.to_string());
|
||||
}
|
||||
if sources.len() >= PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
sources
|
||||
}
|
||||
|
||||
fn has_puzzle_reference_images(
|
||||
legacy_reference_image_src: Option<&str>,
|
||||
reference_image_srcs: &[String],
|
||||
) -> bool {
|
||||
!collect_puzzle_reference_image_sources(legacy_reference_image_src, reference_image_srcs)
|
||||
.is_empty()
|
||||
}
|
||||
|
||||
fn should_use_puzzle_reference_image_edit(
|
||||
reference_image_src: Option<&str>,
|
||||
use_reference_image_edit: bool,
|
||||
|
||||
@@ -476,6 +476,10 @@ mod tests {
|
||||
RuntimeProfileWalletLedgerSourceType::SnapshotSync.as_str(),
|
||||
"snapshot_sync"
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward.as_str(),
|
||||
"new_user_registration_reward"
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimeProfileWalletLedgerSourceType::PointsRecharge.as_str(),
|
||||
"points_recharge"
|
||||
@@ -494,6 +498,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_user_registration_wallet_reward_starts_with_ten_points() {
|
||||
assert_eq!(PROFILE_NEW_USER_INITIAL_WALLET_POINTS, 10);
|
||||
assert_eq!(
|
||||
calculate_runtime_profile_wallet_balance(
|
||||
0,
|
||||
PROFILE_NEW_USER_INITIAL_WALLET_POINTS as i64,
|
||||
)
|
||||
.expect("new user registration reward should fit wallet balance"),
|
||||
10
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_profile_beijing_day_key_uses_business_day_boundary() {
|
||||
let before_beijing_midnight = 1_714_927_999_999_999;
|
||||
|
||||
@@ -16,6 +16,8 @@ pub struct CreatePuzzleAgentSessionRequest {
|
||||
#[serde(default)]
|
||||
pub reference_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_srcs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub image_model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ai_redraw: Option<bool>,
|
||||
@@ -39,6 +41,8 @@ pub struct ExecutePuzzleAgentActionRequest {
|
||||
#[serde(default)]
|
||||
pub reference_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_srcs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub image_model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ai_redraw: Option<bool>,
|
||||
|
||||
@@ -544,6 +544,16 @@ fn update_match3d_work_tx(
|
||||
input: Match3DWorkUpdateInput,
|
||||
) -> Result<Match3DWorkSnapshot, String> {
|
||||
let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
|
||||
let next = build_updated_match3d_work_row(¤t, &input)?;
|
||||
let snapshot = build_work_snapshot(&next)?;
|
||||
replace_work(ctx, ¤t, next);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn build_updated_match3d_work_row(
|
||||
current: &Match3DWorkProfileRow,
|
||||
input: &Match3DWorkUpdateInput,
|
||||
) -> Result<Match3DWorkProfileRow, String> {
|
||||
let tags = parse_tags(&input.tags_json)?;
|
||||
let config = Match3DCreatorConfigSnapshot {
|
||||
theme_text: clean_string(&input.theme_text, "经典消除"),
|
||||
@@ -563,7 +573,7 @@ fn update_match3d_work_tx(
|
||||
author_display_name: current.author_display_name.clone(),
|
||||
game_name: clean_string(&input.game_name, "未命名抓大鹅"),
|
||||
theme_text: config.theme_text.clone(),
|
||||
summary_text: clean_string(&input.summary_text, "经典消除玩法"),
|
||||
summary_text: input.summary_text.trim().to_string(),
|
||||
tags_json: to_json_string(&tags),
|
||||
cover_image_src: input.cover_image_src.trim().to_string(),
|
||||
cover_asset_id: input.cover_asset_id.trim().to_string(),
|
||||
@@ -576,9 +586,7 @@ fn update_match3d_work_tx(
|
||||
published_at: current.published_at,
|
||||
generated_item_assets_json: current.generated_item_assets_json.clone(),
|
||||
};
|
||||
let snapshot = build_work_snapshot(&next)?;
|
||||
replace_work(ctx, ¤t, next);
|
||||
Ok(snapshot)
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
fn publish_match3d_work_tx(
|
||||
@@ -1881,6 +1889,65 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_work_update_preserves_assets_and_allows_empty_summary() {
|
||||
let existing = Match3DWorkProfileRow {
|
||||
profile_id: "profile-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
source_session_id: "session-1".to_string(),
|
||||
author_display_name: "作者".to_string(),
|
||||
game_name: "水果抓大鹅".to_string(),
|
||||
theme_text: "水果".to_string(),
|
||||
summary_text: "保留描述".to_string(),
|
||||
tags_json: "[\"水果\"]".to_string(),
|
||||
cover_image_src: "/old-cover.png".to_string(),
|
||||
cover_asset_id: "cover-asset-1".to_string(),
|
||||
clear_count: 12,
|
||||
difficulty: 4,
|
||||
config_json: to_json_string(&Match3DCreatorConfigSnapshot {
|
||||
theme_text: "水果".to_string(),
|
||||
reference_image_src: None,
|
||||
clear_count: 12,
|
||||
difficulty: 4,
|
||||
asset_style_id: None,
|
||||
asset_style_label: None,
|
||||
asset_style_prompt: None,
|
||||
generate_click_sound: false,
|
||||
}),
|
||||
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
|
||||
play_count: 2,
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(1),
|
||||
published_at: None,
|
||||
generated_item_assets_json: Some(
|
||||
r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"#
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
let input = Match3DWorkUpdateInput {
|
||||
profile_id: existing.profile_id.clone(),
|
||||
owner_user_id: existing.owner_user_id.clone(),
|
||||
game_name: existing.game_name.clone(),
|
||||
theme_text: existing.theme_text.clone(),
|
||||
summary_text: " ".to_string(),
|
||||
tags_json: existing.tags_json.clone(),
|
||||
cover_image_src: "/new-cover.png".to_string(),
|
||||
cover_asset_id: existing.cover_asset_id.clone(),
|
||||
clear_count: existing.clear_count,
|
||||
difficulty: existing.difficulty,
|
||||
updated_at_micros: 2,
|
||||
};
|
||||
let next = build_updated_match3d_work_row(&existing, &input).unwrap();
|
||||
|
||||
assert_eq!(next.summary_text, "");
|
||||
assert_eq!(next.cover_image_src, "/new-cover.png");
|
||||
assert_eq!(next.clear_count, 12);
|
||||
assert_eq!(next.difficulty, 4);
|
||||
assert_eq!(
|
||||
next.generated_item_assets_json.as_deref(),
|
||||
existing.generated_item_assets_json.as_deref()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_publish_ready_requires_five_image_views_per_item() {
|
||||
let base_work = Match3DWorkProfileRow {
|
||||
|
||||
Reference in New Issue
Block a user