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:
@@ -12,7 +12,7 @@ impl PuzzleImageModel {
|
||||
}
|
||||
|
||||
pub(crate) fn request_model_name(self) -> &'static str {
|
||||
VECTOR_ENGINE_GPT_IMAGE_2_MODEL
|
||||
GPT_IMAGE_2_MODEL
|
||||
}
|
||||
|
||||
pub(crate) fn candidate_source_type(self) -> &'static str {
|
||||
@@ -95,13 +95,26 @@ pub(crate) struct GeneratedPuzzleUiBackgroundResponse {
|
||||
pub(crate) object_key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct GeneratedPuzzleLevelAssetResponse {
|
||||
pub(crate) image_src: String,
|
||||
pub(crate) object_key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct GeneratedPuzzleLevelAssetBundle {
|
||||
pub(crate) level_scene: GeneratedPuzzleLevelAssetResponse,
|
||||
pub(crate) ui_spritesheet: GeneratedPuzzleLevelAssetResponse,
|
||||
pub(crate) level_background: GeneratedPuzzleLevelAssetResponse,
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageModel {
|
||||
match value.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
Some(PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW) => {
|
||||
tracing::warn!(
|
||||
requested_model = PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW,
|
||||
effective_model = VECTOR_ENGINE_GPT_IMAGE_2_MODEL,
|
||||
"拼图 nanobanana2 历史选项已回落到 VectorEngine GPT-image-2-all"
|
||||
effective_model = GPT_IMAGE_2_MODEL,
|
||||
"拼图 nanobanana2 历史选项已回落到 VectorEngine GPT-image-2"
|
||||
);
|
||||
PuzzleImageModel::Gemini31FlashPreview
|
||||
}
|
||||
@@ -150,7 +163,7 @@ pub(crate) fn build_puzzle_image_http_client(
|
||||
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(request_timeout_ms.max(1)))
|
||||
// 中文注释:VectorEngine 的图片编辑接口是 multipart 请求;强制 HTTP/1.1 可避开部分网关对 HTTP/2 multipart 流的中断兼容问题。
|
||||
// 中文注释:参考图走 multipart edits;强制 HTTP/1.1 可降低部分网关对长耗时上传流的兼容风险。
|
||||
.http1_only()
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
@@ -186,6 +199,20 @@ pub(crate) async fn create_puzzle_vector_engine_image_generation(
|
||||
candidate_count: u32,
|
||||
reference_image: Option<&PuzzleResolvedReferenceImage>,
|
||||
) -> Result<PuzzleGeneratedImages, AppError> {
|
||||
if let Some(reference_image) = reference_image {
|
||||
return create_puzzle_vector_engine_image_edit(
|
||||
http_client,
|
||||
settings,
|
||||
image_model,
|
||||
prompt,
|
||||
negative_prompt,
|
||||
size,
|
||||
candidate_count,
|
||||
reference_image,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let request_body = build_puzzle_vector_engine_image_request_body(
|
||||
image_model,
|
||||
prompt,
|
||||
@@ -262,6 +289,15 @@ pub(crate) async fn create_puzzle_vector_engine_image_generation(
|
||||
return Ok(images);
|
||||
}
|
||||
|
||||
let b64_images = extract_puzzle_b64_images(&payload);
|
||||
if !b64_images.is_empty() {
|
||||
return Ok(puzzle_images_from_base64(
|
||||
format!("vector-engine-{}", current_utc_micros()),
|
||||
b64_images,
|
||||
candidate_count,
|
||||
));
|
||||
}
|
||||
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
@@ -273,6 +309,7 @@ pub(crate) async fn create_puzzle_vector_engine_image_generation(
|
||||
pub(crate) async fn create_puzzle_vector_engine_image_edit(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &PuzzleVectorEngineSettings,
|
||||
image_model: PuzzleImageModel,
|
||||
prompt: &str,
|
||||
negative_prompt: &str,
|
||||
size: &str,
|
||||
@@ -295,7 +332,7 @@ pub(crate) async fn create_puzzle_vector_engine_image_edit(
|
||||
})?;
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.part("image", image_part)
|
||||
.text("model", PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL.to_string())
|
||||
.text("model", image_model.request_model_name().to_string())
|
||||
.text(
|
||||
"prompt",
|
||||
build_puzzle_vector_engine_prompt(prompt, negative_prompt),
|
||||
@@ -314,16 +351,14 @@ pub(crate) async fn create_puzzle_vector_engine_image_edit(
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_puzzle_vector_engine_reqwest_error(
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
&request_url,
|
||||
error,
|
||||
)
|
||||
map_puzzle_vector_engine_request_error(format!(
|
||||
"创建拼图 VectorEngine 图片编辑任务失败:{error}"
|
||||
))
|
||||
})?;
|
||||
let status = response.status();
|
||||
tracing::info!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
image_model = PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL,
|
||||
image_model = image_model.request_model_name(),
|
||||
endpoint = %request_url,
|
||||
status = status.as_u16(),
|
||||
prompt_chars = prompt.chars().count(),
|
||||
@@ -372,6 +407,17 @@ pub(crate) async fn create_puzzle_vector_engine_image_edit(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_puzzle_downloaded_image_reference(
|
||||
image: &PuzzleDownloadedImage,
|
||||
) -> PuzzleResolvedReferenceImage {
|
||||
PuzzleResolvedReferenceImage {
|
||||
mime_type: image.mime_type.clone(),
|
||||
bytes_len: image.bytes.len(),
|
||||
bytes: image.bytes.clone(),
|
||||
signed_read_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_puzzle_vector_engine_image_request_body(
|
||||
image_model: PuzzleImageModel,
|
||||
prompt: &str,
|
||||
@@ -380,7 +426,7 @@ pub(crate) fn build_puzzle_vector_engine_image_request_body(
|
||||
candidate_count: u32,
|
||||
reference_image: Option<&PuzzleResolvedReferenceImage>,
|
||||
) -> Value {
|
||||
let mut body = Map::from_iter([
|
||||
let body = Map::from_iter([
|
||||
(
|
||||
"model".to_string(),
|
||||
Value::String(image_model.request_model_name().to_string()),
|
||||
@@ -392,20 +438,7 @@ pub(crate) 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 {
|
||||
if let Some(signed_read_url) = reference_image
|
||||
.signed_read_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
body.insert("image".to_string(), json!([signed_read_url]));
|
||||
} else if let Some(reference_data_url) =
|
||||
build_puzzle_generation_reference_image_data_url(reference_image)
|
||||
{
|
||||
body.insert("image".to_string(), json!([reference_data_url]));
|
||||
}
|
||||
}
|
||||
let _ = reference_image;
|
||||
|
||||
Value::Object(body)
|
||||
}
|
||||
@@ -429,32 +462,6 @@ pub(crate) fn build_puzzle_vector_engine_generation_prompt(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) 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)
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) 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())
|
||||
}
|
||||
|
||||
pub(crate) fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> bool {
|
||||
reference_image_src
|
||||
.map(str::trim)
|
||||
@@ -545,11 +552,11 @@ pub(crate) fn has_puzzle_reference_images(
|
||||
.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn should_use_puzzle_reference_image_edit(
|
||||
pub(crate) fn should_use_puzzle_reference_image_generation(
|
||||
reference_image_src: Option<&str>,
|
||||
use_reference_image_edit: bool,
|
||||
use_reference_image_generation: bool,
|
||||
) -> bool {
|
||||
use_reference_image_edit && has_puzzle_reference_image(reference_image_src)
|
||||
use_reference_image_generation && has_puzzle_reference_image(reference_image_src)
|
||||
}
|
||||
|
||||
pub(crate) fn build_puzzle_vector_engine_prompt(prompt: &str, negative_prompt: &str) -> String {
|
||||
@@ -1072,6 +1079,57 @@ pub(crate) async fn persist_puzzle_ui_background_image(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn persist_puzzle_level_asset_image(
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
task_id: &str,
|
||||
path_segment: &str,
|
||||
asset_kind: &str,
|
||||
slot: &str,
|
||||
file_stem: &str,
|
||||
image: PuzzleDownloadedImage,
|
||||
) -> Result<GeneratedPuzzleLevelAssetResponse, AppError> {
|
||||
let oss_client = state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"reason": "OSS 未完成环境变量配置",
|
||||
}))
|
||||
})?;
|
||||
let http_client = reqwest::Client::new();
|
||||
let put_result = oss_client
|
||||
.put_object(
|
||||
&http_client,
|
||||
OssPutObjectRequest {
|
||||
prefix: LegacyAssetPrefix::PuzzleAssets,
|
||||
path_segments: vec![
|
||||
sanitize_path_segment(session_id, "session"),
|
||||
sanitize_path_segment(level_name, "puzzle"),
|
||||
sanitize_path_segment(path_segment, "level-asset"),
|
||||
sanitize_path_segment(task_id, "task"),
|
||||
],
|
||||
file_name: format!("{file_stem}.{}", image.extension),
|
||||
content_type: Some(image.mime_type.clone()),
|
||||
access: OssObjectAccess::Private,
|
||||
metadata: build_puzzle_level_asset_metadata(
|
||||
owner_user_id,
|
||||
session_id,
|
||||
asset_kind,
|
||||
slot,
|
||||
),
|
||||
body: image.bytes,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_asset_oss_error)?;
|
||||
|
||||
Ok(GeneratedPuzzleLevelAssetResponse {
|
||||
image_src: put_result.legacy_public_path,
|
||||
object_key: put_result.object_key,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn handle_puzzle_asset_spacetime_index_error(
|
||||
error: SpacetimeClientError,
|
||||
owner_user_id: &str,
|
||||
@@ -1126,6 +1184,21 @@ pub(crate) fn build_puzzle_ui_background_asset_metadata(
|
||||
])
|
||||
}
|
||||
|
||||
pub(crate) fn build_puzzle_level_asset_metadata(
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
asset_kind: &str,
|
||||
slot: &str,
|
||||
) -> BTreeMap<String, String> {
|
||||
BTreeMap::from([
|
||||
("asset_kind".to_string(), asset_kind.to_string()),
|
||||
("owner_user_id".to_string(), owner_user_id.to_string()),
|
||||
("entity_kind".to_string(), PUZZLE_ENTITY_KIND.to_string()),
|
||||
("entity_id".to_string(), session_id.to_string()),
|
||||
("slot".to_string(), slot.to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
pub(crate) fn parse_puzzle_json_payload(
|
||||
raw_text: &str,
|
||||
fallback_message: &str,
|
||||
@@ -1331,72 +1404,6 @@ pub(crate) fn map_puzzle_vector_engine_request_error(message: String) -> AppErro
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn map_puzzle_vector_engine_reqwest_error(
|
||||
context: &str,
|
||||
request_url: &str,
|
||||
error: reqwest::Error,
|
||||
) -> AppError {
|
||||
let message = format!(
|
||||
"{context}:{}",
|
||||
normalize_puzzle_reqwest_error_message(&error)
|
||||
);
|
||||
let is_timeout = error.is_timeout() || is_puzzle_request_timeout_message(message.as_str());
|
||||
let is_connect = error.is_connect();
|
||||
let status = if is_timeout {
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
} else {
|
||||
StatusCode::BAD_GATEWAY
|
||||
};
|
||||
let source = error.source().map(ToString::to_string).unwrap_or_default();
|
||||
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
endpoint = %request_url,
|
||||
timeout = is_timeout,
|
||||
connect = is_connect,
|
||||
request = error.is_request(),
|
||||
body = error.is_body(),
|
||||
source = %source,
|
||||
message = %message,
|
||||
"拼图 VectorEngine 请求发送失败"
|
||||
);
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": message,
|
||||
"reason": resolve_puzzle_vector_engine_request_failure_reason(&error),
|
||||
"endpoint": request_url,
|
||||
"timeout": is_timeout,
|
||||
"connect": is_connect,
|
||||
"request": error.is_request(),
|
||||
"body": error.is_body(),
|
||||
"source": source,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_puzzle_reqwest_error_message(error: &reqwest::Error) -> String {
|
||||
error
|
||||
.to_string()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_puzzle_vector_engine_request_failure_reason(
|
||||
error: &reqwest::Error,
|
||||
) -> &'static str {
|
||||
if error.is_timeout() {
|
||||
return "VectorEngine 图片编辑请求超时,请稍后重试或调大 VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS";
|
||||
}
|
||||
if error.is_connect() {
|
||||
return "无法连接 VectorEngine 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置";
|
||||
}
|
||||
if error.is_body() {
|
||||
return "发送 VectorEngine 图片编辑 multipart 请求体失败,请重试并检查参考图大小";
|
||||
}
|
||||
"VectorEngine 图片编辑请求发送失败,请查看 source 字段中的底层网络错误"
|
||||
}
|
||||
|
||||
pub(crate) fn is_puzzle_request_timeout_message(message: &str) -> bool {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
lower.contains("timed out")
|
||||
|
||||
Reference in New Issue
Block a user