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:
2026-05-15 02:40:59 +08:00
parent 4642855fd0
commit 74fd9a33ac
87 changed files with 5508 additions and 1261 deletions

View File

@@ -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("禁止文字"));
}