WIP: UI编辑器自动分图层切图标 #304

Draft
k88936 wants to merge 100 commits from feat/ui-editor-auto-seperation into master
6 changed files with 179 additions and 179 deletions
Showing only changes of commit f087ba3e2b - Show all commits
+1 -5
View File
@@ -14,9 +14,7 @@ use crate::{
},
auth::AuthenticatedAccessToken,
http_error::AppError,
openai_image_generation::{
build_openai_image_http_client, map_platform_image_error, require_openai_image_settings,
},
openai_image_generation::{map_platform_image_error, require_openai_image_settings},
request_context::RequestContext,
state::AppState,
};
@@ -63,14 +61,12 @@ pub(crate) async fn edit_raw_image(
Some(authenticated.claims().user_id().to_string()),
None,
);
let http_client = build_openai_image_http_client(&settings)?;
let provider_settings = settings.provider_settings();
let user_id = authenticated.claims().user_id().to_string();
let request_id = request_context.request_id().to_string();
let points_cost = raw_image_edit_price(&state, prepared.width, prepared.height).await?;
let operation = async move {
let generated = create_vector_engine_raw_image_edit(
&http_client,
&provider_settings,
prepared.prompt.as_str(),
&prepared.image,
@@ -15,7 +15,7 @@ use super::{
},
curl_transport::{
map_curl_error, send_vector_engine_json_request_with_curl,
send_vector_engine_multipart_edit_request_with_curl_options,
send_vector_engine_multipart_edit_request_with_curl,
},
error::PlatformImageError,
image_source::resolve_reference_images,
@@ -28,7 +28,7 @@ use super::{
vector_engine_nanobanana_generate_content_url,
},
response::handle_vector_engine_response,
types::{GeneratedImages, RawImageEditOptions, ReferenceImage, VectorEngineImageSettings},
types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings},
util::truncate_raw,
};
@@ -537,34 +537,6 @@ pub async fn create_vector_engine_image_edit_with_references_and_model(
candidate_count: u32,
reference_images: &[ReferenceImage],
failure_context: &str,
) -> Result<GeneratedImages, PlatformImageError> {
create_vector_engine_image_edit_with_references_and_model_and_options(
http_client,
settings,
model,
prompt,
negative_prompt,
size,
candidate_count,
reference_images,
None,
failure_context,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn create_vector_engine_image_edit_with_references_and_model_and_options(
http_client: &reqwest::Client,
settings: &VectorEngineImageSettings,
model: &str,
prompt: &str,
negative_prompt: Option<&str>,
size: &str,
candidate_count: u32,
reference_images: &[ReferenceImage],
options: Option<&RawImageEditOptions>,
failure_context: &str,
) -> Result<GeneratedImages, PlatformImageError> {
let requested_model = normalize_vector_engine_image_model(model);
if reference_images.is_empty() {
@@ -639,7 +611,7 @@ pub async fn create_vector_engine_image_edit_with_references_and_model_and_optio
&mut recovered_failure_audits,
));
};
let response = send_vector_engine_multipart_edit_request_with_curl_options(
let response = match send_vector_engine_multipart_edit_request_with_curl(
request_url.as_str(),
settings.api_key.as_str(),
upstream_model,
@@ -648,11 +620,10 @@ pub async fn create_vector_engine_image_edit_with_references_and_model_and_optio
normalized_size.as_str(),
candidate_count,
reference_images,
options,
attempt_timeout_ms,
)
.await;
let response = match response {
.await
{
Ok(response) => {
if should_retry_vector_engine_upstream_response(
response.status,
@@ -7,11 +7,8 @@ use curl::{
use serde_json::Value;
use super::{
audit::build_failure_audit,
constants::VECTOR_ENGINE_PROVIDER,
error::PlatformImageError,
request::build_prompt_with_negative,
types::{RawImageEditOptions, ReferenceImage},
audit::build_failure_audit, constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError,
request::build_prompt_with_negative, types::ReferenceImage,
};
#[derive(Debug)]
@@ -112,7 +109,7 @@ pub(crate) async fn send_vector_engine_json_request_with_curl(
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl_options(
pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl(
request_url: &str,
api_key: &str,
model: &str,
@@ -121,7 +118,6 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl_options(
normalized_size: &str,
candidate_count: u32,
reference_images: &[ReferenceImage],
options: Option<&RawImageEditOptions>,
timeout_ms: u64,
) -> Result<VectorEngineCurlResponse, VectorEngineCurlError> {
let request_url = request_url.to_string();
@@ -131,7 +127,6 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl_options(
let negative_prompt = negative_prompt.map(str::to_string);
let normalized_size = normalized_size.to_string();
let reference_images = reference_images.to_vec();
let options = options.cloned();
tokio::task::spawn_blocking(move || {
send_multipart_edit_request_with_curl_blocking(
request_url.as_str(),
@@ -141,8 +136,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl_options(
negative_prompt.as_deref(),
normalized_size.as_str(),
candidate_count,
reference_images,
options,
reference_images.as_slice(),
timeout_ms,
)
})
@@ -244,8 +238,7 @@ fn send_multipart_edit_request_with_curl_blocking(
negative_prompt: Option<&str>,
normalized_size: &str,
candidate_count: u32,
reference_images: Vec<ReferenceImage>,
options: Option<RawImageEditOptions>,
reference_images: &[ReferenceImage],
timeout_ms: u64,
) -> Result<VectorEngineCurlResponse, VectorEngineCurlError> {
let mut form = Form::new();
@@ -260,49 +253,13 @@ fn send_multipart_edit_request_with_curl_blocking(
.contents(normalized_size.as_bytes())
.add()?;
if let Some(options) = options {
let RawImageEditOptions {
quality,
background,
output_format,
mask,
..
} = options;
if let Some(quality) = quality {
form.part("quality").contents(quality.as_bytes()).add()?;
}
if let Some(background) = background {
form.part("background")
.contents(background.as_bytes())
.add()?;
}
if let Some(output_format) = output_format {
form.part("output_format")
.contents(output_format.as_bytes())
.add()?;
}
if let Some(mask) = mask {
let ReferenceImage {
bytes,
mime_type,
file_name,
} = mask;
form.part("mask")
.buffer(file_name.as_str(), bytes)
.content_type(mime_type.as_str())
.add()?;
}
}
for reference_image in reference_images {
let ReferenceImage {
bytes,
mime_type,
file_name,
} = reference_image;
form.part("image")
.buffer(file_name.as_str(), bytes)
.content_type(mime_type.as_str())
.buffer(
reference_image.file_name.as_str(),
reference_image.bytes.clone(),
)
.content_type(reference_image.mime_type.as_str())
.add()?;
}
@@ -372,7 +329,7 @@ mod tests {
#[tokio::test]
async fn vector_engine_curl_transport_posts_multipart_request() {
let (base_url, server, request_rx) = start_single_response_server().await;
let response = send_vector_engine_multipart_edit_request_with_curl_options(
let response = send_vector_engine_multipart_edit_request_with_curl(
format!("{base_url}/v1/images/edits").as_str(),
"test-key",
GPT_IMAGE_2_MODEL,
@@ -385,7 +342,6 @@ mod tests {
mime_type: "image/png".to_string(),
file_name: "reference.png".to_string(),
}],
None,
1_000,
)
.await
@@ -403,53 +359,6 @@ mod tests {
server.abort();
}
#[tokio::test]
async fn vector_engine_curl_transport_posts_raw_edit_options_and_mask() {
let (base_url, server, request_rx) = start_single_response_server().await;
let response = send_vector_engine_multipart_edit_request_with_curl_options(
format!("{base_url}/v1/images/edits").as_str(),
"test-key",
GPT_IMAGE_2_MODEL,
"测试提示词",
None,
"1536x1024",
1,
&[ReferenceImage {
bytes: b"reference".to_vec(),
mime_type: "image/webp".to_string(),
file_name: "reference.webp".to_string(),
}],
Some(&RawImageEditOptions {
quality: Some("high".to_string()),
background: Some("transparent".to_string()),
output_format: Some("png".to_string()),
width: 1536,
height: 1024,
mask: Some(ReferenceImage {
bytes: b"mask-bytes".to_vec(),
mime_type: "image/png".to_string(),
file_name: "mask.png".to_string(),
}),
}),
1_000,
)
.await
.expect("curl multipart raw edit request should succeed");
assert_eq!(response.status, 200);
let request = request_rx
.await
.expect("mock server should capture request");
let request_text = String::from_utf8_lossy(request.as_slice());
assert!(request_text.contains("name=\"quality\"\r\n\r\nhigh"));
assert!(request_text.contains("name=\"background\"\r\n\r\ntransparent"));
assert!(request_text.contains("name=\"output_format\"\r\n\r\npng"));
assert!(request_text.contains("name=\"mask\"; filename=\"mask.png\""));
assert!(request_text.contains("Content-Type: image/png"));
assert!(request_text.contains("mask-bytes"));
server.abort();
}
async fn start_single_response_server() -> (
String,
tokio::task::JoinHandle<()>,
@@ -26,7 +26,7 @@ pub use constants::{
};
pub use error::{PlatformImageError, PlatformImageStatusHint};
pub use image_source::download_remote_image;
pub use raw_edit::create_vector_engine_raw_image_edit;
pub use raw_edit::{RawImageEditOptions, create_vector_engine_raw_image_edit};
pub use request::{
build_vector_engine_image_request_body, build_vector_engine_image_request_body_with_model,
build_vector_engine_nanobanana_generate_content_request_body, normalize_image_size_for_model,
@@ -34,7 +34,4 @@ pub use request::{
vector_engine_nanobanana_generate_content_url,
};
pub use transport::build_vector_engine_image_http_client;
pub use types::{
DownloadedImage, GeneratedImages, RawImageEditOptions, ReferenceImage,
VectorEngineImageSettings,
};
pub use types::{DownloadedImage, GeneratedImages, ReferenceImage, VectorEngineImageSettings};
@@ -1,33 +1,172 @@
use std::time::Duration;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use reqwest::multipart::{Form, Part};
use serde_json::Value;
use super::{
client::create_vector_engine_image_edit_with_references_and_model_and_options,
constants::GPT_IMAGE_2_MODEL,
constants::{GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER},
error::PlatformImageError,
types::{GeneratedImages, RawImageEditOptions, ReferenceImage, VectorEngineImageSettings},
request::vector_engine_images_edit_url,
types::{DownloadedImage, GeneratedImages, ReferenceImage, VectorEngineImageSettings},
};
/// Sends the raw GPT Image 2 edit contract while keeping VectorEngine's
/// multipart transport inside this crate.
#[allow(clippy::too_many_arguments)]
#[derive(Clone, Debug, Default)]
pub struct RawImageEditOptions {
pub quality: Option<String>,
pub background: Option<String>,
pub output_format: Option<String>,
pub width: u32,
pub height: u32,
pub mask: Option<ReferenceImage>,
}
/// Independent raw GPT Image 2 proxy; it does not call the editor image-edit client.
pub async fn create_vector_engine_raw_image_edit(
http_client: &reqwest::Client,
settings: &VectorEngineImageSettings,
prompt: &str,
image: &ReferenceImage,
options: RawImageEditOptions,
failure_context: &str,
) -> Result<GeneratedImages, PlatformImageError> {
let size = format!("{}x{}", options.width, options.height);
create_vector_engine_image_edit_with_references_and_model_and_options(
http_client,
settings,
GPT_IMAGE_2_MODEL,
prompt,
None,
size.as_str(),
1,
std::slice::from_ref(image),
Some(&options),
failure_context,
)
.await
let url = vector_engine_images_edit_url(settings);
let mut form = Form::new()
.text("model", GPT_IMAGE_2_MODEL.to_string())
.text("n", "1".to_string())
.text("prompt", prompt.to_string())
.text("size", format!("{}x{}", options.width, options.height))
.part(
"image",
Part::bytes(image.bytes.clone())
.file_name(image.file_name.clone())
.mime_str(image.mime_type.as_str())
.map_err(|error| invalid_request(failure_context, error.to_string()))?,
);
if let Some(value) = options.quality.clone() {
form = form.text("quality", value);
}
if let Some(value) = options.background.clone() {
form = form.text("background", value);
}
if let Some(value) = options.output_format.clone() {
form = form.text("output_format", value);
}
if let Some(mask) = options.mask.clone() {
form = form.part(
"mask",
Part::bytes(mask.bytes)
.file_name(mask.file_name)
.mime_str(mask.mime_type.as_str())
.map_err(|error| invalid_request(failure_context, error.to_string()))?,
);
}
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms.max(1)))
.http1_only()
.build()
.map_err(|error| invalid_config(error.to_string()))?;
let response = client
.post(url.as_str())
.bearer_auth(settings.api_key.as_str())
.multipart(form)
.send()
.await
.map_err(|error| request_error(&url, failure_context, error))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|error| request_error(&url, failure_context, error))?;
if !status.is_success() {
return Err(PlatformImageError::Upstream {
provider: VECTOR_ENGINE_PROVIDER,
message: format!(
"{failure_context}:上游图片编辑失败(HTTP {})",
status.as_u16()
),
upstream_status: status.as_u16(),
raw_excerpt: body.chars().take(2_000).collect(),
audit: None,
});
}
let payload: Value =
serde_json::from_str(body.as_str()).map_err(|error| PlatformImageError::ResponseParse {
provider: VECTOR_ENGINE_PROVIDER,
message: format!("{failure_context}:上游响应不是 JSON{error}"),
raw_excerpt: body.chars().take(2_000).collect(),
audit: None,
})?;
let mut images = Vec::new();
if let Some(entries) = payload.get("data").and_then(Value::as_array) {
for entry in entries {
let Some(value) = entry.get("b64_json").and_then(Value::as_str) else {
continue;
};
let bytes = BASE64_STANDARD.decode(value).map_err(|error| {
PlatformImageError::ResponseParse {
provider: VECTOR_ENGINE_PROVIDER,
message: format!("{failure_context}:上游 b64_json 解码失败:{error}"),
raw_excerpt: body.chars().take(2_000).collect(),
audit: None,
}
})?;
let (mime_type, extension) = match options.output_format.as_deref() {
Some("jpeg") => ("image/jpeg", "jpg"),
Some("webp") => ("image/webp", "webp"),
_ => ("image/png", "png"),
};
images.push(DownloadedImage {
bytes,
mime_type: mime_type.to_string(),
extension: extension.to_string(),
});
}
}
if images.is_empty() {
return Err(PlatformImageError::MissingImage {
provider: VECTOR_ENGINE_PROVIDER,
message: format!("{failure_context}:上游未返回 b64_json 图片"),
audit: None,
});
}
Ok(GeneratedImages {
task_id: payload
.get("id")
.and_then(Value::as_str)
.unwrap_or("raw-image-edit")
.to_string(),
actual_prompt: None,
images,
recovered_failure_audits: Vec::new(),
})
}
fn invalid_request(context: &str, message: String) -> PlatformImageError {
PlatformImageError::InvalidRequest {
provider: VECTOR_ENGINE_PROVIDER,
message: format!("{context}:构造上游请求失败:{message}"),
}
}
fn invalid_config(message: String) -> PlatformImageError {
PlatformImageError::InvalidConfig {
provider: VECTOR_ENGINE_PROVIDER,
message,
}
}
fn request_error<E: std::fmt::Display>(url: &str, context: &str, error: E) -> PlatformImageError {
PlatformImageError::Request {
provider: VECTOR_ENGINE_PROVIDER,
message: format!("{context}:上游请求失败:{error}"),
endpoint: Some(url.to_string()),
timeout: false,
connect: false,
request: true,
body: false,
status_code: None,
source: Some(error.to_string()),
audit: None,
}
}
@@ -29,15 +29,3 @@ pub struct ReferenceImage {
pub mime_type: String,
pub file_name: String,
}
/// Raw GPT Image 2 edit options. The API layer owns validation; this type only
/// carries values that must be forwarded to VectorEngine.
#[derive(Clone, Debug, Default)]
pub struct RawImageEditOptions {
pub quality: Option<String>,
pub background: Option<String>,
pub output_format: Option<String>,
pub width: u32,
pub height: u32,
pub mask: Option<ReferenceImage>,
}