Merge remote-tracking branch 'origin/master' into codex/yace
# Conflicts: # .hermes/shared-memory/pitfalls.md
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ pub(super) fn map_match3d_agent_session_response_with_assets(
|
||||
) -> Match3DAgentSessionSnapshotResponse {
|
||||
let mut response = map_match3d_agent_session_response(session);
|
||||
if let Some(draft) = response.draft.as_mut() {
|
||||
if generated_item_assets.is_empty() {
|
||||
return response;
|
||||
}
|
||||
|
||||
draft.generated_item_assets = generated_item_assets
|
||||
.iter()
|
||||
.cloned()
|
||||
@@ -129,7 +133,15 @@ pub(super) fn map_match3d_config_response(
|
||||
pub(super) fn map_match3d_draft_response(
|
||||
draft: Match3DResultDraftRecord,
|
||||
) -> Match3DResultDraftResponse {
|
||||
Match3DResultDraftResponse {
|
||||
// 中文注释:session draft 自身也可能携带生成素材快照,不能只依赖 work detail 回读补齐 UI 背景和容器图。
|
||||
let generated_item_assets = parse_match3d_generated_item_assets(
|
||||
draft.generated_item_assets_json.as_deref(),
|
||||
)
|
||||
.into_iter()
|
||||
.map(Match3DGeneratedItemAsset::from)
|
||||
.collect::<Vec<_>>();
|
||||
let background_asset = find_match3d_generated_background_asset(&generated_item_assets);
|
||||
let mut response = Match3DResultDraftResponse {
|
||||
profile_id: draft.profile_id,
|
||||
game_name: draft.game_name,
|
||||
theme_text: draft.theme_text,
|
||||
@@ -147,8 +159,24 @@ pub(super) fn map_match3d_draft_response(
|
||||
background_image_src: None,
|
||||
background_image_object_key: None,
|
||||
generated_background_asset: None,
|
||||
generated_item_assets: Vec::new(),
|
||||
generated_item_assets: generated_item_assets
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(map_match3d_generated_item_asset_for_agent)
|
||||
.collect(),
|
||||
};
|
||||
|
||||
if response
|
||||
.cover_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or_default()
|
||||
.is_empty()
|
||||
{
|
||||
response.cover_image_src = resolve_match3d_default_cover_image_src(&generated_item_assets);
|
||||
}
|
||||
apply_match3d_background_asset_to_agent_draft(&mut response, background_asset);
|
||||
response
|
||||
}
|
||||
|
||||
pub(super) fn map_match3d_generated_item_asset_for_agent(
|
||||
@@ -365,6 +393,45 @@ pub(super) fn build_match3d_work_profile_record_with_assets(
|
||||
item
|
||||
}
|
||||
|
||||
fn match3d_text_present(value: Option<&String>) -> bool {
|
||||
value.is_some_and(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
fn match3d_item_asset_has_image(asset: &Match3DGeneratedItemAssetJson) -> bool {
|
||||
match3d_text_present(asset.image_src.as_ref())
|
||||
|| match3d_text_present(asset.image_object_key.as_ref())
|
||||
|| asset.image_views.iter().any(|view| {
|
||||
match3d_text_present(view.image_src.as_ref())
|
||||
|| match3d_text_present(view.image_object_key.as_ref())
|
||||
})
|
||||
}
|
||||
|
||||
fn match3d_background_asset_has_image(asset: &Match3DGeneratedBackgroundAsset) -> bool {
|
||||
match3d_text_present(asset.image_src.as_ref())
|
||||
|| match3d_text_present(asset.image_object_key.as_ref())
|
||||
|| match3d_text_present(asset.container_image_src.as_ref())
|
||||
|| match3d_text_present(asset.container_image_object_key.as_ref())
|
||||
}
|
||||
|
||||
fn resolve_match3d_work_generation_status(
|
||||
item: &Match3DWorkProfileRecord,
|
||||
assets: &[Match3DGeneratedItemAssetJson],
|
||||
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
|
||||
) -> Option<String> {
|
||||
if item.publication_status.eq_ignore_ascii_case("published") {
|
||||
return Some("ready".to_string());
|
||||
}
|
||||
|
||||
if assets.is_empty()
|
||||
|| !assets.iter().any(match3d_item_asset_has_image)
|
||||
|| !background_asset.is_some_and(match3d_background_asset_has_image)
|
||||
{
|
||||
return Some("generating".to_string());
|
||||
}
|
||||
|
||||
Some("ready".to_string())
|
||||
}
|
||||
|
||||
pub(super) fn map_match3d_message_response(
|
||||
message: Match3DAgentMessageRecord,
|
||||
) -> Match3DAgentMessageResponse {
|
||||
@@ -383,6 +450,11 @@ pub(super) fn map_match3d_work_summary_response(
|
||||
let generated_item_asset_json =
|
||||
parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref());
|
||||
let background_asset = find_match3d_generated_background_asset_json(&generated_item_asset_json);
|
||||
let generation_status = resolve_match3d_work_generation_status(
|
||||
&item,
|
||||
&generated_item_asset_json,
|
||||
background_asset.as_ref(),
|
||||
);
|
||||
let generated_background_asset = background_asset
|
||||
.clone()
|
||||
.map(map_match3d_background_asset_for_work);
|
||||
@@ -408,6 +480,7 @@ pub(super) fn map_match3d_work_summary_response(
|
||||
updated_at: item.updated_at,
|
||||
published_at: item.published_at,
|
||||
publish_ready: item.publish_ready,
|
||||
generation_status,
|
||||
background_prompt: background_asset.as_ref().map(|asset| asset.prompt.clone()),
|
||||
background_image_src: background_asset
|
||||
.as_ref()
|
||||
|
||||
@@ -4030,6 +4030,11 @@ fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError {
|
||||
error
|
||||
}
|
||||
|
||||
fn should_fallback_puzzle_reference_edit_to_generation(error: &AppError) -> bool {
|
||||
error.status_code() == StatusCode::GATEWAY_TIMEOUT
|
||||
|| is_puzzle_request_timeout_message(error.body_text().as_str())
|
||||
}
|
||||
|
||||
async fn generate_puzzle_image_candidates(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
@@ -4111,7 +4116,7 @@ async fn generate_puzzle_image_candidates(
|
||||
"message": "AI 重绘需要提供参考图。",
|
||||
}))
|
||||
})?;
|
||||
create_puzzle_vector_engine_image_edit(
|
||||
let edit_result = create_puzzle_vector_engine_image_edit(
|
||||
&http_client,
|
||||
&settings,
|
||||
actual_prompt.as_str(),
|
||||
@@ -4120,7 +4125,34 @@ async fn generate_puzzle_image_candidates(
|
||||
count,
|
||||
reference_image,
|
||||
)
|
||||
.await
|
||||
.await;
|
||||
match edit_result {
|
||||
Ok(generated) => Ok(generated),
|
||||
Err(error) if should_fallback_puzzle_reference_edit_to_generation(&error) => {
|
||||
tracing::warn!(
|
||||
provider = resolved_model.provider_name(),
|
||||
image_model = resolved_model.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
reference_mime = %reference_image.mime_type,
|
||||
reference_bytes = reference_image.bytes_len,
|
||||
error = %error,
|
||||
"拼图参考图编辑接口超时,降级为带参考图的生成接口"
|
||||
);
|
||||
create_puzzle_vector_engine_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
resolved_model,
|
||||
actual_prompt.as_str(),
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||
count,
|
||||
Some(reference_image),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
} else {
|
||||
create_puzzle_vector_engine_image_generation(
|
||||
&http_client,
|
||||
@@ -4130,6 +4162,7 @@ async fn generate_puzzle_image_candidates(
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||
count,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -4263,6 +4296,7 @@ mod tests {
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||
4,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL);
|
||||
@@ -4278,6 +4312,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
|
||||
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
image
|
||||
.write_to(&mut cursor, ImageFormat::Png)
|
||||
.expect("test image should encode");
|
||||
let reference_image = PuzzleResolvedReferenceImage {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: cursor.get_ref().len(),
|
||||
bytes: cursor.into_inner(),
|
||||
};
|
||||
|
||||
let body = build_puzzle_vector_engine_image_request_body(
|
||||
PuzzleImageModel::GptImage2,
|
||||
"参考图里的小猫做成拼图主图。",
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||
1,
|
||||
Some(&reference_image),
|
||||
);
|
||||
|
||||
let images = body["image"]
|
||||
.as_array()
|
||||
.expect("fallback generation should include reference image array");
|
||||
assert_eq!(images.len(), 1);
|
||||
assert!(
|
||||
images[0]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("data:image/png;base64,")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() {
|
||||
let settings = PuzzleVectorEngineSettings {
|
||||
@@ -4363,6 +4431,39 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() {
|
||||
let error = map_puzzle_vector_engine_upstream_error(
|
||||
reqwest::StatusCode::GATEWAY_TIMEOUT,
|
||||
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
);
|
||||
|
||||
let response = error.into_response();
|
||||
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_reference_edit_fallback_only_accepts_timeout_errors() {
|
||||
let timeout_error = map_puzzle_vector_engine_upstream_error(
|
||||
reqwest::StatusCode::GATEWAY_TIMEOUT,
|
||||
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
);
|
||||
assert!(should_fallback_puzzle_reference_edit_to_generation(
|
||||
&timeout_error
|
||||
));
|
||||
|
||||
let auth_error = map_puzzle_vector_engine_upstream_error(
|
||||
reqwest::StatusCode::UNAUTHORIZED,
|
||||
r#"{"error":{"message":"invalid api key"}}"#,
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
);
|
||||
assert!(!should_fallback_puzzle_reference_edit_to_generation(
|
||||
&auth_error
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_reqwest_error_maps_to_bad_gateway() {
|
||||
let error = match reqwest::Client::new().get("http://[::1").build() {
|
||||
@@ -4834,6 +4935,7 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(response.levels.len(), 1);
|
||||
assert_eq!(response.generation_status.as_deref(), Some("ready"));
|
||||
assert_eq!(
|
||||
response.levels[0].cover_image_src.as_deref(),
|
||||
Some("/generated-puzzle-assets/session/cover.png")
|
||||
@@ -5242,6 +5344,7 @@ async fn create_puzzle_vector_engine_image_generation(
|
||||
negative_prompt: &str,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
reference_image: Option<&PuzzleResolvedReferenceImage>,
|
||||
) -> Result<PuzzleGeneratedImages, AppError> {
|
||||
let request_body = build_puzzle_vector_engine_image_request_body(
|
||||
image_model,
|
||||
@@ -5249,6 +5352,7 @@ async fn create_puzzle_vector_engine_image_generation(
|
||||
negative_prompt,
|
||||
size,
|
||||
candidate_count,
|
||||
reference_image,
|
||||
);
|
||||
let request_url = puzzle_vector_engine_images_generation_url(settings);
|
||||
let request_started_at = Instant::now();
|
||||
@@ -5277,7 +5381,7 @@ async fn create_puzzle_vector_engine_image_generation(
|
||||
status = status.as_u16(),
|
||||
prompt_chars = prompt.chars().count(),
|
||||
size,
|
||||
has_reference_image = false,
|
||||
has_reference_image = reference_image.is_some(),
|
||||
elapsed_ms = upstream_elapsed_ms,
|
||||
"拼图 VectorEngine 图片生成 HTTP 返回"
|
||||
);
|
||||
@@ -5434,8 +5538,9 @@ fn build_puzzle_vector_engine_image_request_body(
|
||||
negative_prompt: &str,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
reference_image: Option<&PuzzleResolvedReferenceImage>,
|
||||
) -> Value {
|
||||
Value::Object(Map::from_iter([
|
||||
let mut body = Map::from_iter([
|
||||
(
|
||||
"model".to_string(),
|
||||
Value::String(image_model.request_model_name().to_string()),
|
||||
@@ -5446,7 +5551,15 @@ fn build_puzzle_vector_engine_image_request_body(
|
||||
),
|
||||
("n".to_string(), json!(candidate_count.clamp(1, 1))),
|
||||
("size".to_string(), Value::String(size.to_string())),
|
||||
]))
|
||||
]);
|
||||
if let Some(reference_image) = reference_image
|
||||
&& let Some(reference_data_url) =
|
||||
build_puzzle_generation_reference_image_data_url(reference_image)
|
||||
{
|
||||
body.insert("image".to_string(), json!([reference_data_url]));
|
||||
}
|
||||
|
||||
Value::Object(body)
|
||||
}
|
||||
|
||||
fn build_puzzle_vector_engine_generation_prompt(prompt: &str, has_reference_image: bool) -> String {
|
||||
@@ -5465,6 +5578,32 @@ fn build_puzzle_vector_engine_generation_prompt(prompt: &str, has_reference_imag
|
||||
)
|
||||
}
|
||||
|
||||
fn build_puzzle_generation_reference_image_data_url(
|
||||
image: &PuzzleResolvedReferenceImage,
|
||||
) -> Option<String> {
|
||||
let bytes = resize_puzzle_generation_reference_image_bytes(image.bytes.as_slice())
|
||||
.unwrap_or_else(|| image.bytes.clone());
|
||||
let mime_type = if bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
|
||||
"image/png"
|
||||
} else {
|
||||
image.mime_type.as_str()
|
||||
};
|
||||
|
||||
Some(format!(
|
||||
"data:{};base64,{}",
|
||||
normalize_puzzle_downloaded_image_mime_type(mime_type),
|
||||
BASE64_STANDARD.encode(bytes)
|
||||
))
|
||||
}
|
||||
|
||||
fn resize_puzzle_generation_reference_image_bytes(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let image = image::load_from_memory(bytes).ok()?;
|
||||
let resized = image.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
resized.write_to(&mut cursor, ImageFormat::Png).ok()?;
|
||||
Some(cursor.into_inner())
|
||||
}
|
||||
|
||||
fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> bool {
|
||||
reference_image_src
|
||||
.map(str::trim)
|
||||
@@ -6185,19 +6324,28 @@ fn map_puzzle_vector_engine_upstream_error(
|
||||
) -> AppError {
|
||||
let message = parse_puzzle_api_error_message(raw_text, fallback_message);
|
||||
let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800);
|
||||
let is_timeout = is_puzzle_request_timeout_message(message.as_str())
|
||||
|| is_puzzle_request_timeout_message(raw_excerpt.as_str());
|
||||
let status = if is_timeout {
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
} else {
|
||||
StatusCode::BAD_GATEWAY
|
||||
};
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
upstream_status = upstream_status.as_u16(),
|
||||
timeout = is_timeout,
|
||||
message = %message,
|
||||
raw_excerpt = %raw_excerpt,
|
||||
"拼图 VectorEngine 上游请求失败"
|
||||
);
|
||||
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"upstreamStatus": upstream_status.as_u16(),
|
||||
"message": message,
|
||||
"rawExcerpt": raw_excerpt,
|
||||
"timeout": is_timeout,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -278,10 +278,31 @@ pub(super) fn map_puzzle_result_preview_finding_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_puzzle_work_generation_status(item: &PuzzleWorkProfileRecord) -> Option<String> {
|
||||
item.levels
|
||||
.iter()
|
||||
.map(|level| level.generation_status.trim())
|
||||
.find(|status| *status == "generating")
|
||||
.or_else(|| {
|
||||
item.levels
|
||||
.iter()
|
||||
.map(|level| level.generation_status.trim())
|
||||
.find(|status| *status == "ready")
|
||||
})
|
||||
.or_else(|| {
|
||||
item.levels
|
||||
.iter()
|
||||
.map(|level| level.generation_status.trim())
|
||||
.find(|status| !status.is_empty())
|
||||
})
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_work_summary_response(
|
||||
state: &AppState,
|
||||
item: PuzzleWorkProfileRecord,
|
||||
) -> PuzzleWorkSummaryResponse {
|
||||
let generation_status = resolve_puzzle_work_generation_status(&item);
|
||||
let author = resolve_work_author_by_user_id(
|
||||
state,
|
||||
&item.owner_user_id,
|
||||
@@ -316,6 +337,7 @@ pub(super) fn map_puzzle_work_summary_response(
|
||||
.saturating_div(2)
|
||||
.saturating_sub(item.point_incentive_claimed_points),
|
||||
publish_ready: item.publish_ready,
|
||||
generation_status,
|
||||
levels: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user