Switch to VectorEngine gpt-image-2 and edits
Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::openai_image_generation::GPT_IMAGE_2_MODEL;
|
||||
|
||||
#[test]
|
||||
fn puzzle_generated_image_size_is_square_1_1() {
|
||||
@@ -7,7 +8,7 @@ fn puzzle_generated_image_size_is_square_1_1() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() {
|
||||
fn puzzle_vector_engine_create_request_uses_gpt_image_2_without_reference_images() {
|
||||
let body = build_puzzle_vector_engine_image_request_body(
|
||||
PuzzleImageModel::Gemini31FlashPreview,
|
||||
"一只猫在雨夜灯牌下回头。",
|
||||
@@ -17,7 +18,7 @@ fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() {
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL);
|
||||
assert_eq!(body["model"], GPT_IMAGE_2_MODEL);
|
||||
assert_eq!(body["size"], PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE);
|
||||
assert_eq!(body["n"], 1);
|
||||
assert!(body.get("official_fallback").is_none());
|
||||
@@ -31,7 +32,7 @@ fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
|
||||
fn puzzle_vector_engine_create_request_never_embeds_reference_image() {
|
||||
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
image
|
||||
@@ -53,20 +54,148 @@ fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
|
||||
Some(&reference_image),
|
||||
);
|
||||
|
||||
let images = body["image"]
|
||||
.as_array()
|
||||
.expect("fallback generation should include reference image array");
|
||||
assert_eq!(images.len(), 1);
|
||||
assert!(body.get("image").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_level_scene_spritesheet_and_background_requests_use_references() {
|
||||
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 = PuzzleDownloadedImage {
|
||||
extension: "png".to_string(),
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes: cursor.into_inner(),
|
||||
};
|
||||
|
||||
let scene_body = build_puzzle_level_scene_image_request_body_for_test(&reference_image)
|
||||
.expect("scene request should build");
|
||||
assert_eq!(scene_body["model"], GPT_IMAGE_2_MODEL);
|
||||
assert_eq!(scene_body["size"], "1024x1536");
|
||||
assert!(scene_body.get("image").is_none());
|
||||
assert!(
|
||||
images[0]
|
||||
scene_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("data:image/png;base64,")
|
||||
.contains("参考图作为拼图画面")
|
||||
);
|
||||
assert!(
|
||||
scene_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("道具按钮上不要显示次数标注")
|
||||
);
|
||||
assert!(
|
||||
scene_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("返回按钮和设置按钮旁禁止标注文字")
|
||||
);
|
||||
|
||||
let spritesheet_body = build_puzzle_ui_spritesheet_request_body_for_test(&reference_image)
|
||||
.expect("spritesheet request should build");
|
||||
assert_eq!(spritesheet_body["model"], GPT_IMAGE_2_MODEL);
|
||||
assert_eq!(spritesheet_body["size"], "1024x1024");
|
||||
assert!(
|
||||
spritesheet_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("返回按钮、设置按钮、下一关按钮、提示按钮、原图按钮、冻结按钮")
|
||||
);
|
||||
assert!(
|
||||
spritesheet_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("纯绿色绿幕背景")
|
||||
);
|
||||
assert!(
|
||||
spritesheet_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("绿幕扣成透明")
|
||||
);
|
||||
assert!(
|
||||
spritesheet_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("自动边界检测")
|
||||
);
|
||||
assert!(
|
||||
spritesheet_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("按钮素材内必须保留对应中文文字")
|
||||
);
|
||||
assert!(
|
||||
spritesheet_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("不要额外画白色外圈")
|
||||
);
|
||||
assert!(
|
||||
spritesheet_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("白底圆环")
|
||||
);
|
||||
assert!(
|
||||
!spritesheet_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("禁止文字")
|
||||
);
|
||||
|
||||
let background_body = build_puzzle_level_background_request_body_for_test(&reference_image)
|
||||
.expect("background request should build");
|
||||
assert_eq!(background_body["model"], GPT_IMAGE_2_MODEL);
|
||||
assert_eq!(background_body["size"], "1024x1536");
|
||||
assert!(
|
||||
background_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("移除参考图中所有UI元素")
|
||||
);
|
||||
assert!(
|
||||
background_body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("禁止在背景中出现人像或和拼图画面中主体一致的内容")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_generation_prefers_signed_reference_url() {
|
||||
fn puzzle_ui_spritesheet_postprocess_turns_green_screen_transparent() {
|
||||
let mut source = image::RgbaImage::from_pixel(8, 8, image::Rgba([0, 255, 0, 255]));
|
||||
for y in 2..6 {
|
||||
for x in 2..6 {
|
||||
source.put_pixel(x, y, image::Rgba([190, 78, 42, 255]));
|
||||
}
|
||||
}
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(source)
|
||||
.write_to(&mut cursor, ImageFormat::Png)
|
||||
.expect("test image should encode");
|
||||
|
||||
let processed = make_puzzle_ui_spritesheet_image_transparent_for_test(PuzzleDownloadedImage {
|
||||
extension: "png".to_string(),
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes: cursor.into_inner(),
|
||||
})
|
||||
.expect("green screen postprocess should succeed");
|
||||
|
||||
assert_eq!(processed.extension, "png");
|
||||
assert_eq!(processed.mime_type, "image/png");
|
||||
let decoded = image::load_from_memory(processed.bytes.as_slice())
|
||||
.expect("processed image should decode")
|
||||
.to_rgba8();
|
||||
assert_eq!(decoded.get_pixel(0, 0).0[3], 0);
|
||||
assert_eq!(decoded.get_pixel(3, 3).0[3], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_create_request_never_embeds_signed_reference_url() {
|
||||
let reference_image = PuzzleResolvedReferenceImage {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: 4,
|
||||
@@ -86,14 +215,24 @@ fn puzzle_vector_engine_generation_prefers_signed_reference_url() {
|
||||
Some(&reference_image),
|
||||
);
|
||||
|
||||
assert!(body.get("image").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_generation_url_normalizes_base_url() {
|
||||
let settings = PuzzleVectorEngineSettings {
|
||||
base_url: "https://vector.example/v1".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
body["image"][0],
|
||||
"https://oss.example/generated-puzzle-assets/reference.png?x-oss-signature=abc"
|
||||
puzzle_vector_engine_images_generation_url(&settings),
|
||||
"https://vector.example/v1/images/generations"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() {
|
||||
fn puzzle_vector_engine_edit_url_normalizes_base_url() {
|
||||
let settings = PuzzleVectorEngineSettings {
|
||||
base_url: "https://vector.example/v1".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
@@ -135,18 +274,31 @@ fn puzzle_vector_engine_prompt_keeps_text_only_prompt_unchanged() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_reference_image_edit_requires_ai_redraw() {
|
||||
assert!(!should_use_puzzle_reference_image_edit(None, true));
|
||||
assert!(!should_use_puzzle_reference_image_edit(
|
||||
fn puzzle_reference_image_generation_requires_ai_redraw() {
|
||||
assert!(!should_use_puzzle_reference_image_generation(None, true));
|
||||
assert!(!should_use_puzzle_reference_image_generation(
|
||||
Some("data:image/png;base64,abcd"),
|
||||
false
|
||||
));
|
||||
assert!(should_use_puzzle_reference_image_edit(
|
||||
assert!(should_use_puzzle_reference_image_generation(
|
||||
Some("data:image/png;base64,abcd"),
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_result_level_direct_upload_skips_cover_image_generation() {
|
||||
assert!(should_use_uploaded_puzzle_image_directly(
|
||||
Some("data:image/png;base64,abcd"),
|
||||
false
|
||||
));
|
||||
assert!(!should_use_uploaded_puzzle_image_directly(
|
||||
Some("data:image/png;base64,abcd"),
|
||||
true
|
||||
));
|
||||
assert!(!should_use_uploaded_puzzle_image_directly(None, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_reference_image_sources_are_deduped_and_limited() {
|
||||
let sources = collect_puzzle_reference_image_sources(
|
||||
@@ -239,51 +391,14 @@ fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
|
||||
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 图片编辑任务失败",
|
||||
r#"{"error":{"message":"VectorEngine generation 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() {
|
||||
Ok(_) => panic!("invalid url should fail request build"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let app_error = map_puzzle_vector_engine_reqwest_error(
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
"https://api.vectorengine.ai/v1/images/edits",
|
||||
error,
|
||||
);
|
||||
|
||||
let response = app_error.into_response();
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_compile_error_preserves_vector_engine_unavailable_status() {
|
||||
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
|
||||
@@ -601,6 +716,12 @@ fn puzzle_level_audio_asset_roundtrips_between_response_and_module_json() {
|
||||
ui_background_prompt: None,
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
level_scene_image_src: None,
|
||||
level_scene_image_object_key: None,
|
||||
ui_spritesheet_image_src: None,
|
||||
ui_spritesheet_image_object_key: None,
|
||||
level_background_image_src: None,
|
||||
level_background_image_object_key: None,
|
||||
background_music: Some(CreationAudioAsset {
|
||||
task_id: "suno-task-1".to_string(),
|
||||
provider: "vector-engine-suno".to_string(),
|
||||
@@ -666,6 +787,12 @@ fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() {
|
||||
ui_background_image_object_key: Some(
|
||||
"generated-puzzle-assets/session/ui/background.png".to_string(),
|
||||
),
|
||||
level_scene_image_src: None,
|
||||
level_scene_image_object_key: None,
|
||||
ui_spritesheet_image_src: None,
|
||||
ui_spritesheet_image_object_key: None,
|
||||
level_background_image_src: None,
|
||||
level_background_image_object_key: None,
|
||||
background_music: None,
|
||||
candidates: vec![],
|
||||
selected_candidate_id: None,
|
||||
@@ -703,6 +830,81 @@ fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_level_asset_bundle_fields_roundtrip_between_response_and_module_json() {
|
||||
let level = PuzzleDraftLevelResponse {
|
||||
level_id: "puzzle-level-1".to_string(),
|
||||
level_name: "雨夜猫街".to_string(),
|
||||
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||
picture_reference: None,
|
||||
ui_background_prompt: Some("雨夜猫街竖屏拼图UI背景".to_string()),
|
||||
ui_background_image_src: Some(
|
||||
"/generated-puzzle-assets/session/legacy-ui/background.png".to_string(),
|
||||
),
|
||||
ui_background_image_object_key: Some(
|
||||
"generated-puzzle-assets/session/legacy-ui/background.png".to_string(),
|
||||
),
|
||||
level_scene_image_src: Some(
|
||||
"/generated-puzzle-assets/session/level-scene/scene.png".to_string(),
|
||||
),
|
||||
level_scene_image_object_key: Some(
|
||||
"generated-puzzle-assets/session/level-scene/scene.png".to_string(),
|
||||
),
|
||||
ui_spritesheet_image_src: Some(
|
||||
"/generated-puzzle-assets/session/ui-spritesheet/sheet.png".to_string(),
|
||||
),
|
||||
ui_spritesheet_image_object_key: Some(
|
||||
"generated-puzzle-assets/session/ui-spritesheet/sheet.png".to_string(),
|
||||
),
|
||||
level_background_image_src: Some(
|
||||
"/generated-puzzle-assets/session/level-background/background.png".to_string(),
|
||||
),
|
||||
level_background_image_object_key: Some(
|
||||
"generated-puzzle-assets/session/level-background/background.png".to_string(),
|
||||
),
|
||||
background_music: None,
|
||||
candidates: vec![],
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()),
|
||||
cover_asset_id: Some("asset-1".to_string()),
|
||||
generation_status: "ready".to_string(),
|
||||
};
|
||||
let request_context = RequestContext::new(
|
||||
"test-request".to_string(),
|
||||
"PUT /api/runtime/puzzle/works/test".to_string(),
|
||||
Duration::ZERO,
|
||||
false,
|
||||
);
|
||||
|
||||
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
|
||||
.expect("levels should serialize");
|
||||
let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse");
|
||||
assert_eq!(
|
||||
payload[0]["level_background_image_object_key"],
|
||||
Value::String(
|
||||
"generated-puzzle-assets/session/level-background/background.png".to_string()
|
||||
)
|
||||
);
|
||||
assert!(payload[0].get("levelBackgroundImageObjectKey").is_none());
|
||||
|
||||
let records = parse_puzzle_level_records_from_module_json(&levels_json)
|
||||
.expect("levels should map back into records");
|
||||
assert_eq!(
|
||||
records[0].level_scene_image_src.as_deref(),
|
||||
Some("/generated-puzzle-assets/session/level-scene/scene.png")
|
||||
);
|
||||
assert_eq!(
|
||||
records[0].ui_spritesheet_image_object_key.as_deref(),
|
||||
Some("generated-puzzle-assets/session/ui-spritesheet/sheet.png")
|
||||
);
|
||||
|
||||
let response = map_puzzle_draft_level_response(records[0].clone());
|
||||
assert_eq!(
|
||||
response.level_background_image_src.as_deref(),
|
||||
Some("/generated-puzzle-assets/session/level-background/background.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
|
||||
let app_state = crate::state::AppState::new(crate::config::AppConfig::default())
|
||||
@@ -716,6 +918,12 @@ fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
|
||||
ui_background_prompt: None,
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
level_scene_image_src: None,
|
||||
level_scene_image_object_key: None,
|
||||
ui_spritesheet_image_src: None,
|
||||
ui_spritesheet_image_object_key: None,
|
||||
level_background_image_src: None,
|
||||
level_background_image_object_key: None,
|
||||
background_music: None,
|
||||
candidates: vec![PuzzleGeneratedImageCandidateRecord {
|
||||
candidate_id: "candidate-1".to_string(),
|
||||
@@ -849,12 +1057,15 @@ fn puzzle_initial_draft_assets_must_include_ui_background() {
|
||||
let missing_all = ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
|
||||
.expect_err("缺少自动生成资产时不能把草稿标记为完成");
|
||||
assert_eq!(missing_all.status_code(), StatusCode::BAD_GATEWAY);
|
||||
assert!(missing_all.body_text().contains("UI背景图"));
|
||||
assert!(missing_all.body_text().contains("关卡背景图"));
|
||||
assert!(missing_all.body_text().contains("UI spritesheet"));
|
||||
|
||||
draft.levels[0].ui_background_image_src =
|
||||
Some("/generated-puzzle-assets/session/ui/background.png".to_string());
|
||||
draft.levels[0].level_background_image_src =
|
||||
Some("/generated-puzzle-assets/session/background/background.png".to_string());
|
||||
draft.levels[0].ui_spritesheet_image_src =
|
||||
Some("/generated-puzzle-assets/session/spritesheet/sheet.png".to_string());
|
||||
ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
|
||||
.expect("UI 背景存在时即可完成自动草稿资源检查");
|
||||
.expect("关卡背景和 UI spritesheet 存在时即可完成自动草稿资源检查");
|
||||
}
|
||||
|
||||
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {
|
||||
@@ -898,6 +1109,12 @@ fn test_puzzle_draft_record() -> PuzzleResultDraftRecord {
|
||||
ui_background_prompt: None,
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
level_scene_image_src: None,
|
||||
level_scene_image_object_key: None,
|
||||
ui_spritesheet_image_src: None,
|
||||
ui_spritesheet_image_object_key: None,
|
||||
level_background_image_src: None,
|
||||
level_background_image_object_key: None,
|
||||
background_music: None,
|
||||
candidates: vec![],
|
||||
selected_candidate_id: None,
|
||||
|
||||
Reference in New Issue
Block a user