WIP: UI编辑器自动分图层切图标 #304
@@ -93,12 +93,12 @@ raw 操作使用独立的 operation / ledger 命名空间,例如 `raw-image-ed
|
||||
|
||||
`platform-image` 保留 VectorEngine 协议细节。raw handler 只负责:认证、JSON DTO、base64 解码、预检查、计费编排和响应映射。provider 请求仍由 `platform-image` 统一构造,并携带 `model`、`n`、`quality`、`background`、`output_format`、尺寸及图片参考字节。
|
||||
|
||||
provider 结果统一解码为图片字节;每项结果的 MIME 与扩展名以 VectorEngine 响应中的真实 `output_format` 为准,不得从请求参数反推;entry 级格式为空或非字符串时回退 payload 级格式。发送、响应读取、上游状态、响应解析和缺图失败必须生成 `PlatformImageFailureAudit`,由 api-server 写入现有外部 API 失败审计链;成功结果同时写入统一的 `external_generation_run` 追踪事件。raw handler 只将结果字节编码到 `data[].b64_json`。
|
||||
provider 响应只提取并透传 `data[].b64_json` 字符串,不在服务端解码图片 base64,也不读取或回传 provider 的 `output_format`(该字段只是请求参数回显)。发送、响应读取、上游状态、响应解析和缺图失败必须生成 `PlatformImageFailureAudit`,由 api-server 写入现有外部 API 失败审计链;成功结果同时写入统一的 `external_generation_run` 追踪事件。raw handler 只将上游 `b64_json` 原样写入 `data[].b64_json`。
|
||||
|
||||
## 代码拆分
|
||||
|
||||
- `server-rs/crates/api-server/src/raw_image.rs`:独立路由 handler、请求/响应 DTO、base64 输入校验、预检查和 raw billing 编排。
|
||||
- `server-rs/crates/platform-image/src/vector_engine/raw_edit.rs`:raw 编辑选项、严格尺寸校验、独立 provider 请求映射和原始响应解码;不复用现有 editor 图片编辑 client 或其 multipart transport。
|
||||
- `server-rs/crates/platform-image/src/vector_engine/raw_edit.rs`:raw 编辑选项、严格尺寸校验、独立 provider 请求映射和 `b64_json` 响应透传;不复用现有 editor 图片编辑 client 或其 multipart transport。
|
||||
- `server-rs/crates/api-server/src/modules/raw.rs`:只注册 `/api/raw/v1/images/edit` 并挂载 Bearer middleware。
|
||||
|
||||
不修改 External v1 OpenAPI;不在 `external_editor_api.rs`、编辑器项目模块或外部生成 worker 中增加 raw 分支。
|
||||
|
||||
@@ -109,11 +109,9 @@ pub(crate) async fn edit_raw_image(
|
||||
};
|
||||
let task_id = generated.task_id.clone();
|
||||
let data: Vec<RawImageEditItem> = generated
|
||||
.images
|
||||
.b64_images
|
||||
.into_iter()
|
||||
.map(|image| RawImageEditItem {
|
||||
b64_json: BASE64_STANDARD.encode(image.bytes),
|
||||
})
|
||||
.map(|b64_json| RawImageEditItem { b64_json })
|
||||
.collect();
|
||||
if let Some(state) = tracking_state.as_ref() {
|
||||
record_external_generation_run_after_success(
|
||||
|
||||
@@ -12,7 +12,7 @@ pub use vector_engine::{
|
||||
DownloadedImage, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, GeneratedImages, NANOBANANA_2_MODEL,
|
||||
PlatformImageError, PlatformImageFailureAudit, PlatformImageStatusHint,
|
||||
RAW_IMAGE_DIMENSION_ALIGNMENT, RAW_IMAGE_MAX_EDGE, RAW_IMAGE_MAX_PIXELS, RAW_IMAGE_MIN_PIXELS,
|
||||
RawImageEditDimensionError, RawImageEditOptions, ReferenceImage,
|
||||
RawImageEditDimensionError, RawImageEditOptions, RawImageEditResult, ReferenceImage,
|
||||
VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings,
|
||||
build_vector_engine_image_http_client, build_vector_engine_image_request_body,
|
||||
build_vector_engine_nanobanana_generate_content_request_body, create_vector_engine_image_edit,
|
||||
|
||||
@@ -38,4 +38,6 @@ pub use request::{
|
||||
vector_engine_nanobanana_generate_content_url,
|
||||
};
|
||||
pub use transport::build_vector_engine_image_http_client;
|
||||
pub use types::{DownloadedImage, GeneratedImages, ReferenceImage, VectorEngineImageSettings};
|
||||
pub use types::{
|
||||
DownloadedImage, GeneratedImages, RawImageEditResult, ReferenceImage, VectorEngineImageSettings,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -13,7 +12,7 @@ use super::{
|
||||
},
|
||||
error::PlatformImageError,
|
||||
request::vector_engine_images_edit_url,
|
||||
types::{DownloadedImage, GeneratedImages, ReferenceImage, VectorEngineImageSettings},
|
||||
types::{RawImageEditResult, ReferenceImage, VectorEngineImageSettings},
|
||||
util::truncate_raw,
|
||||
};
|
||||
|
||||
@@ -98,7 +97,7 @@ pub async fn create_vector_engine_raw_image_edit(
|
||||
image: &ReferenceImage,
|
||||
options: RawImageEditOptions,
|
||||
failure_context: &str,
|
||||
) -> Result<GeneratedImages, PlatformImageError> {
|
||||
) -> Result<RawImageEditResult, PlatformImageError> {
|
||||
validate_raw_image_edit_dimensions(options.width, options.height)
|
||||
.map_err(|error| invalid_input(failure_context, error.to_string()))?;
|
||||
let url = vector_engine_images_edit_url(settings);
|
||||
@@ -250,61 +249,8 @@ pub async fn create_vector_engine_raw_image_edit(
|
||||
});
|
||||
}
|
||||
};
|
||||
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 = match BASE64_STANDARD.decode(value) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
let message = format!("{failure_context}:上游 b64_json 解码失败:{error}");
|
||||
let audit = build_failure_audit(
|
||||
url.as_str(),
|
||||
failure_context,
|
||||
"response_parse",
|
||||
Some(status.as_u16()),
|
||||
Some(status_class(status.as_u16())),
|
||||
false,
|
||||
false,
|
||||
message.as_str(),
|
||||
Some(error.to_string()),
|
||||
Some(truncate_raw(body.as_str())),
|
||||
Some(started_at.elapsed().as_millis() as u64),
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
Some(GPT_IMAGE_2_MODEL),
|
||||
);
|
||||
return Err(PlatformImageError::ResponseParse {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message,
|
||||
raw_excerpt: truncate_raw(body.as_str()),
|
||||
audit: Some(audit),
|
||||
});
|
||||
}
|
||||
};
|
||||
let (mime_type, extension) =
|
||||
response_image_format(&payload, entry).map_err(|message| {
|
||||
response_parse_error(
|
||||
&url,
|
||||
failure_context,
|
||||
message,
|
||||
status.as_u16(),
|
||||
started_at,
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
&body,
|
||||
)
|
||||
})?;
|
||||
images.push(DownloadedImage {
|
||||
bytes,
|
||||
mime_type: mime_type.to_string(),
|
||||
extension: extension.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if images.is_empty() {
|
||||
let b64_images = extract_b64_images(&payload);
|
||||
if b64_images.is_empty() {
|
||||
let message = format!("{failure_context}:上游未返回 b64_json 图片");
|
||||
let audit = build_failure_audit(
|
||||
url.as_str(),
|
||||
@@ -328,18 +274,28 @@ pub async fn create_vector_engine_raw_image_edit(
|
||||
audit: Some(audit),
|
||||
});
|
||||
}
|
||||
Ok(GeneratedImages {
|
||||
Ok(RawImageEditResult {
|
||||
task_id: payload
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("raw-image-edit")
|
||||
.to_string(),
|
||||
actual_prompt: None,
|
||||
images,
|
||||
b64_images,
|
||||
recovered_failure_audits: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_b64_images(payload: &Value) -> Vec<String> {
|
||||
payload
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|entry| entry.get("b64_json").and_then(Value::as_str))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn invalid_request(context: &str, message: String) -> PlatformImageError {
|
||||
PlatformImageError::InvalidRequest {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
@@ -361,68 +317,6 @@ fn invalid_config(context: &str, message: String) -> PlatformImageError {
|
||||
}
|
||||
}
|
||||
|
||||
fn response_parse_error(
|
||||
url: &str,
|
||||
context: &str,
|
||||
message: &str,
|
||||
status: u16,
|
||||
started_at: Instant,
|
||||
prompt_chars: Option<usize>,
|
||||
reference_image_count: Option<usize>,
|
||||
body: &str,
|
||||
) -> PlatformImageError {
|
||||
let audit = build_failure_audit(
|
||||
url,
|
||||
context,
|
||||
"response_parse",
|
||||
Some(status),
|
||||
Some(status_class(status)),
|
||||
false,
|
||||
false,
|
||||
message,
|
||||
None,
|
||||
Some(truncate_raw(body)),
|
||||
Some(started_at.elapsed().as_millis() as u64),
|
||||
prompt_chars,
|
||||
reference_image_count,
|
||||
Some(GPT_IMAGE_2_MODEL),
|
||||
);
|
||||
PlatformImageError::ResponseParse {
|
||||
provider: VECTOR_ENGINE_PROVIDER,
|
||||
message: format!("{context}:{message}"),
|
||||
raw_excerpt: truncate_raw(body),
|
||||
audit: Some(audit),
|
||||
}
|
||||
}
|
||||
|
||||
fn response_image_format(
|
||||
payload: &Value,
|
||||
entry: &Value,
|
||||
) -> Result<(&'static str, &'static str), &'static str> {
|
||||
let Some(value) = entry
|
||||
.get("output_format")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| {
|
||||
payload
|
||||
.get("output_format")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
else {
|
||||
return Err("上游响应缺少 output_format");
|
||||
};
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"png" => Ok(("image/png", "png")),
|
||||
"jpeg" | "jpg" => Ok(("image/jpeg", "jpg")),
|
||||
"webp" => Ok(("image/webp", "webp")),
|
||||
"gif" => Ok(("image/gif", "gif")),
|
||||
_ => Err("上游响应包含不支持的 output_format"),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_error(
|
||||
url: &str,
|
||||
context: &str,
|
||||
@@ -495,57 +389,16 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn response_format_uses_vector_engine_output_format() {
|
||||
let payload = json!({"output_format": "png"});
|
||||
assert_eq!(
|
||||
response_image_format(&payload, &json!({"output_format": "webp"})),
|
||||
Ok(("image/webp", "webp"))
|
||||
);
|
||||
assert_eq!(
|
||||
response_image_format(&payload, &json!({})),
|
||||
Ok(("image/png", "png"))
|
||||
);
|
||||
assert_eq!(
|
||||
response_image_format(&json!({}), &json!({"output_format": "jpeg"})),
|
||||
Ok(("image/jpeg", "jpg"))
|
||||
);
|
||||
assert_eq!(
|
||||
response_image_format(
|
||||
&json!({"output_format": "png"}),
|
||||
&json!({"output_format": ""})
|
||||
),
|
||||
Ok(("image/png", "png"))
|
||||
);
|
||||
assert_eq!(
|
||||
response_image_format(
|
||||
&json!({"output_format": "webp"}),
|
||||
&json!({"output_format": null})
|
||||
),
|
||||
Ok(("image/webp", "webp"))
|
||||
);
|
||||
assert!(response_image_format(&json!({}), &json!({})).is_err());
|
||||
assert!(response_image_format(&json!({}), &json!({"output_format": "bmp"})).is_err());
|
||||
}
|
||||
fn raw_result_forwards_b64_without_decoding_or_using_output_format() {
|
||||
let payload = json!({
|
||||
"output_format": "png",
|
||||
"data": [{"b64_json": "not-base64-but-forwarded", "output_format": "jpeg"}]
|
||||
});
|
||||
|
||||
#[test]
|
||||
fn response_parse_error_contains_structured_audit() {
|
||||
let error = response_parse_error(
|
||||
"https://vector.example/v1/images/edits",
|
||||
"raw_image_edit",
|
||||
"上游响应缺少 output_format",
|
||||
200,
|
||||
Instant::now(),
|
||||
Some(12),
|
||||
Some(2),
|
||||
"{\"data\":[]}",
|
||||
assert_eq!(
|
||||
extract_b64_images(&payload),
|
||||
vec!["not-base64-but-forwarded".to_string()]
|
||||
);
|
||||
let audit = error.audit().expect("response error should carry audit");
|
||||
assert_eq!(audit.failure_stage, "response_parse");
|
||||
assert_eq!(audit.status_code, Some(200));
|
||||
assert_eq!(audit.status_class, Some("2xx"));
|
||||
assert_eq!(audit.prompt_chars, Some(12));
|
||||
assert_eq!(audit.reference_image_count, Some(2));
|
||||
assert_eq!(audit.image_model, Some(GPT_IMAGE_2_MODEL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -16,6 +16,13 @@ pub struct GeneratedImages {
|
||||
pub recovered_failure_audits: Vec<PlatformImageFailureAudit>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RawImageEditResult {
|
||||
pub task_id: String,
|
||||
pub b64_images: Vec<String>,
|
||||
pub recovered_failure_audits: Vec<PlatformImageFailureAudit>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DownloadedImage {
|
||||
pub bytes: Vec<u8>,
|
||||
|
||||
Reference in New Issue
Block a user