Add generationStatus and match3d/runtime fixes

Introduce persistent generationStatus to work summaries (puzzle & match3d) and propagate generation recovery rules across docs and frontend/backends so "generating" is restored from server-side work summary rather than ephemeral front-end notices. Update API server image/asset handling (improve match3d material sheet green/alpha decontamination and promote generatedItemAssets background fields) and add runtime improvements: alpha-based hotspot hit-testing, tray insertion/three-match animation behavior, and session re-read on client-side VectorEngine timeouts/lock-screen interruptions. Many docs, tests and related frontend modules updated/added to reflect these contract and behavior changes.
This commit is contained in:
2026-05-16 22:59:02 +08:00
parent bb60ca91ef
commit a45e358e83
42 changed files with 3872 additions and 443 deletions

View File

@@ -6016,13 +6016,16 @@ fn remove_match3d_material_view_edge_matte(pixels: &mut [u8], width: usize, heig
let mut background_mask = vec![0u8; pixel_count];
let mut queue = Vec::<usize>::new();
let mut queue_index = 0usize;
let mut transparent_pixel_count = 0usize;
for pixel_index in 0..pixel_count {
let offset = pixel_index * 4;
if pixels[offset + 3] == 0 {
background_mask[pixel_index] = 1;
queue.push(pixel_index);
transparent_pixel_count = transparent_pixel_count.saturating_add(1);
}
}
let has_transparent_background = transparent_pixel_count > pixel_count / 200;
// 中文注释:单图被前景边界收紧后,浅绿框可能正好贴在 PNG 外缘;
// 把外缘一段宽度作为去背种子,但只清理绿幕 / 近白 matte避免误伤贴边主体。
@@ -6136,6 +6139,98 @@ fn remove_match3d_material_view_edge_matte(pixels: &mut [u8], width: usize, heig
}
}
if has_transparent_background {
let mut visible_mask = vec![0u8; pixel_count];
for pixel_index in 0..pixel_count {
let offset = pixel_index * 4;
if is_match3d_material_visible_pixel([
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
pixels[offset + 3],
]) {
visible_mask[pixel_index] = 1;
}
}
for _ in 0..2 {
let mut changed_this_round = false;
for y in 0..height {
for x in 0..width {
let pixel_index = y * width + x;
if visible_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_material_green_contaminated_edge_pixel(pixel) {
continue;
}
if !touches_match3d_material_background_mask(
x,
y,
width,
height,
&background_mask,
) {
continue;
}
if is_match3d_material_strong_green_contamination(pixel) {
pixels[offset] = 0;
pixels[offset + 1] = 0;
pixels[offset + 2] = 0;
pixels[offset + 3] = 0;
visible_mask[pixel_index] = 0;
background_mask[pixel_index] = 1;
changed = true;
changed_this_round = true;
continue;
}
let replacement = collect_match3d_material_visible_neighbor_color(
pixels,
width,
height,
x,
y,
&background_mask,
&visible_mask,
)
.unwrap_or((
pixels[offset],
pixels[offset + 1],
pixels[offset + 2],
));
let next_red = replacement.0.max(pixels[offset]);
let next_blue = replacement.2.max(pixels[offset + 2]);
let next_green = replacement
.1
.min(next_red.max(next_blue).saturating_add(12));
if next_red != pixels[offset]
|| next_green != pixels[offset + 1]
|| next_blue != pixels[offset + 2]
{
pixels[offset] = next_red;
pixels[offset + 1] = next_green;
pixels[offset + 2] = next_blue;
changed = true;
changed_this_round = true;
}
background_mask[pixel_index] = 1;
}
}
if !changed_this_round {
break;
}
}
}
changed
}
@@ -6167,6 +6262,98 @@ fn is_match3d_material_soft_edge_pixel(pixel: [u8; 4]) -> bool {
&& (red >= 48 || blue >= 96 || pixel[3] < 236)
}
fn is_match3d_material_green_contaminated_edge_pixel(pixel: [u8; 4]) -> bool {
if pixel[3] == 0 {
return false;
}
let red = pixel[0];
let green = pixel[1];
let blue = pixel[2];
green >= 72 && green.saturating_sub(red.max(blue)) >= 18
}
fn is_match3d_material_strong_green_contamination(pixel: [u8; 4]) -> bool {
let red = pixel[0];
let green = pixel[1];
let blue = pixel[2];
green >= 148 && green.saturating_sub(red.max(blue)) >= 34
}
fn collect_match3d_material_visible_neighbor_color(
pixels: &[u8],
width: usize,
height: usize,
x: usize,
y: usize,
background_mask: &[u8],
visible_mask: &[u8],
) -> Option<(u8, u8, u8)> {
let mut total_weight = 0.0f32;
let mut total_red = 0.0f32;
let mut total_green = 0.0f32;
let mut total_blue = 0.0f32;
for offset_y in -3i32..=3 {
for offset_x in -3i32..=3 {
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 {
continue;
}
let next_pixel_index = next_y as usize * width + next_x as usize;
if background_mask[next_pixel_index] != 0 || visible_mask[next_pixel_index] == 0 {
continue;
}
let next_offset = next_pixel_index * 4;
let next_alpha = pixels[next_offset + 3];
if next_alpha < 96 {
continue;
}
let pixel = [
pixels[next_offset],
pixels[next_offset + 1],
pixels[next_offset + 2],
next_alpha,
];
if is_match3d_material_green_contaminated_edge_pixel(pixel)
|| is_match3d_material_soft_edge_pixel(pixel)
{
continue;
}
let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs();
let weight = (next_alpha as f32 / 255.0)
* if distance <= 1 {
2.0
} else if distance <= 3 {
1.2
} else {
0.7
};
total_weight += weight;
total_red += pixels[next_offset] as f32 * weight;
total_green += pixels[next_offset + 1] as f32 * weight;
total_blue += pixels[next_offset + 2] as f32 * weight;
}
}
if total_weight <= 0.0 {
return None;
}
Some((
(total_red / total_weight).round() as u8,
(total_green / total_weight).round() as u8,
(total_blue / total_weight).round() as u8,
))
}
fn apply_match3d_material_green_screen_alpha(source: image::DynamicImage) -> image::DynamicImage {
let mut image = source.to_rgba8();
let (width, height) = image.dimensions();
@@ -7571,6 +7758,37 @@ mod tests {
);
}
#[test]
fn match3d_material_view_edge_matte_neutralizes_dark_green_contour_pixels() {
let width = 64;
let height = 64;
let mut view = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 0]));
for y in 16..48 {
for x in 16..48 {
if x <= 18 || x >= 45 || y <= 18 || y >= 45 {
view.put_pixel(x, y, image::Rgba([42, 118, 36, 255]));
} else {
view.put_pixel(x, y, image::Rgba([174, 92, 72, 255]));
}
}
}
let cleaned =
crop_match3d_material_view_edge_matte(image::DynamicImage::ImageRgba8(view)).to_rgba8();
assert!(
cleaned.pixels().all(|pixel| {
let [red, green, blue, alpha] = pixel.0;
alpha == 0 || green <= red.max(blue).saturating_add(18)
}),
"暗绿轮廓污染也必须被透明化或去绿,不能残留可见绿边"
);
assert!(
cleaned.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]),
"暗绿轮廓清理不能误删物品主体"
);
}
#[test]
fn match3d_material_sheet_slicing_cleans_white_matte_edge() {
let width = 500;
@@ -8388,6 +8606,7 @@ mod tests {
total_item_count: 36,
publish_ready: false,
blockers: Vec::new(),
generated_item_assets_json: None,
}),
messages: Vec::new(),
last_assistant_reply: None,
@@ -8472,6 +8691,131 @@ mod tests {
);
}
#[test]
fn match3d_agent_session_response_keeps_draft_ui_assets_without_work_detail_hydration() {
let assets = vec![Match3DGeneratedItemAsset {
item_id: "match3d-item-1".to_string(),
item_name: "草莓".to_string(),
item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()),
image_src: Some(
"/generated-match3d-assets/session/profile/items/strawberry/view-01.png"
.to_string(),
),
image_object_key: Some(
"generated-match3d-assets/session/profile/items/strawberry/view-01.png".to_string(),
),
image_views: Vec::new(),
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: None,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_music: None,
click_sound: None,
background_asset: Some(Match3DGeneratedBackgroundAsset {
prompt: "果园背景".to_string(),
image_src: Some(
"/generated-match3d-assets/session/profile/background/background.png"
.to_string(),
),
image_object_key: Some(
"generated-match3d-assets/session/profile/background/background.png"
.to_string(),
),
container_prompt: Some("果园容器".to_string()),
container_image_src: Some(
"/generated-match3d-assets/session/profile/ui-container/container.png"
.to_string(),
),
container_image_object_key: Some(
"generated-match3d-assets/session/profile/ui-container/container.png"
.to_string(),
),
status: "image_ready".to_string(),
error: None,
}),
status: "image_ready".to_string(),
error: None,
}];
let session = Match3DAgentSessionRecord {
session_id: "match3d-session-1".to_string(),
current_turn: 3,
progress_percent: 100,
stage: "DraftCompiled".to_string(),
anchor_pack: Match3DAnchorPackRecord {
theme: Match3DAnchorItemRecord {
key: "theme".to_string(),
label: "题材主题".to_string(),
value: "水果".to_string(),
status: "confirmed".to_string(),
},
clear_count: Match3DAnchorItemRecord {
key: "clearCount".to_string(),
label: "消除次数".to_string(),
value: "12".to_string(),
status: "confirmed".to_string(),
},
difficulty: Match3DAnchorItemRecord {
key: "difficulty".to_string(),
label: "难度".to_string(),
value: "4".to_string(),
status: "confirmed".to_string(),
},
},
config: None,
draft: Some(Match3DResultDraftRecord {
profile_id: "match3d-profile-1".to_string(),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary_text: "水果主题".to_string(),
tags: vec!["水果".to_string(), "抓大鹅".to_string()],
cover_image_src: None,
reference_image_src: None,
clear_count: 12,
difficulty: 4,
total_item_count: 36,
publish_ready: false,
blockers: Vec::new(),
generated_item_assets_json: serialize_match3d_generated_item_assets(&assets),
}),
messages: Vec::new(),
last_assistant_reply: None,
published_profile_id: None,
updated_at: "2026-05-15T00:00:00.000Z".to_string(),
};
let response = map_match3d_agent_session_response_with_assets(session, &[]);
let draft = response.draft.expect("session draft should exist");
assert_eq!(draft.generated_item_assets.len(), 1);
assert_eq!(
draft.background_image_src.as_deref(),
Some("/generated-match3d-assets/session/profile/background/background.png")
);
assert_eq!(
draft.background_image_object_key.as_deref(),
Some("generated-match3d-assets/session/profile/background/background.png")
);
assert_eq!(
draft
.generated_background_asset
.as_ref()
.and_then(|asset| asset.container_image_src.as_deref()),
Some("/generated-match3d-assets/session/profile/ui-container/container.png")
);
assert_eq!(
draft.generated_item_assets[0]
.background_asset
.as_ref()
.and_then(|asset| asset.image_src.as_deref()),
Some("/generated-match3d-assets/session/profile/background/background.png")
);
}
#[test]
fn match3d_tag_normalization_only_strips_numbered_list_prefix() {
assert_eq!(normalize_match3d_tag("3D素材"), "3D素材");
@@ -8761,9 +9105,59 @@ mod tests {
assert_eq!(response.generated_item_assets.len(), 1);
assert_eq!(response.generated_item_assets[0].item_name, "草莓");
assert_eq!(response.generated_item_assets[0].status, "image_ready");
assert_eq!(response.generation_status.as_deref(), Some("generating"));
assert_eq!(
response.generated_item_assets[0].image_src.as_deref(),
Some("/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png")
);
}
#[test]
fn match3d_work_summary_marks_complete_generated_assets_ready() {
let assets = vec![Match3DGeneratedItemAsset {
background_asset: Some(Match3DGeneratedBackgroundAsset {
prompt: "水果厨房背景".to_string(),
image_src: Some(
"/generated-match3d-assets/session/profile/background.png".to_string(),
),
image_object_key: Some(
"generated-match3d-assets/session/profile/background.png".to_string(),
),
container_prompt: None,
container_image_src: Some(
"/generated-match3d-assets/session/profile/container.png".to_string(),
),
container_image_object_key: Some(
"generated-match3d-assets/session/profile/container.png".to_string(),
),
status: "image_ready".to_string(),
error: None,
}),
..test_match3d_generated_item_asset(1, "草莓")
}];
let response = map_match3d_work_summary_response(Match3DWorkProfileRecord {
work_id: "match3d-profile-1".to_string(),
profile_id: "match3d-profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: Some("match3d-session-1".to_string()),
author_display_name: "玩家".to_string(),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary: "水果主题".to_string(),
tags: vec!["水果".to_string()],
cover_image_src: None,
cover_asset_id: None,
reference_image_src: None,
clear_count: 3,
difficulty: 3,
publication_status: "draft".to_string(),
play_count: 0,
updated_at: "2026-05-10T00:00:00.000Z".to_string(),
published_at: None,
publish_ready: false,
generated_item_assets_json: serialize_match3d_generated_item_assets(&assets),
});
assert_eq!(response.generation_status.as_deref(), Some("ready"));
}
}