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:
@@ -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("禁止文字"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user