diff --git a/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md b/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md index 397defd40..0783cbc83 100644 --- a/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md +++ b/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md @@ -1,6 +1,6 @@ # Raw GPT Image 2 图片编辑代理 -更新时间:`2026-09-07` +更新时间:`2026-09-08` ## 目标 @@ -35,7 +35,18 @@ POST /api/raw/v1/images/edit } ``` -`image` 是必填的单图结构 `{ data, mimeType }`;`mask` 可选并使用相同结构。输入格式由 MIME 类型和解码后的图片字节共同确定,服务端不把输入格式另建成请求参数。`prompt` 必填。`quality`、`background` 和 `output_format` 采用 GPT Image 模型支持的值。`width`、`height` 为整数,组成发送给 provider 的输出尺寸;不把尺寸改写成业务字符串字段。 +`image` 是必填的单图结构 `{ data, mimeType }`;`mask` 可选并使用相同结构。`image` 和 `mask` 的 `mimeType` 必须为 `image/png`,base64 解码后必须是可完整解码的有效 PNG 文件;空数据、非 PNG 字节或 MIME 不匹配均在扣费前返回 400。服务端不把输入格式另建成请求参数。`prompt` 必填。`quality`、`background` 和 `output_format` 采用 GPT Image 模型支持的值。 + +`width`、`height` 使用严格输出尺寸规则,均在扣费前校验: + +1. 单边最大值为 `3840px`; +2. 宽、高均为 `16px` 的倍数; +3. 长边 / 短边不超过 `3:1`; +4. 总像素范围为 `655360` 至 `8294400`(含边界)。 + +校验通过后按整数尺寸发送给 provider,不静默 clamp 或改写调用者尺寸。 + +Raw 路由的 JSON body limit 为 `64 MiB`,为 base64 编码膨胀和可选 mask 留出空间;同时必须在 base64 解码后拒绝空 PNG,并保留图片格式校验,避免仅依赖 HTTP body limit。 服务端发送给 `platform-image` 时固定注入: @@ -87,7 +98,7 @@ provider 结果统一解码为图片字节;每项结果的 MIME 与扩展名 ## 代码拆分 - `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 请求映射和原始响应解码;现有编辑器调用通过默认选项复用,不在业务 handler 复制 provider 协议。 +- `server-rs/crates/platform-image/src/vector_engine/raw_edit.rs`:raw 编辑选项、严格尺寸校验、独立 provider 请求映射和原始响应解码;不复用现有 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 分支。 diff --git a/server-rs/crates/api-server/src/modules/raw.rs b/server-rs/crates/api-server/src/modules/raw.rs index 905c16584..8307da70c 100644 --- a/server-rs/crates/api-server/src/modules/raw.rs +++ b/server-rs/crates/api-server/src/modules/raw.rs @@ -1,11 +1,14 @@ -use axum::{Router, middleware, routing::post}; +use axum::{Router, extract::DefaultBodyLimit, middleware, routing::post}; use crate::{auth::require_bearer_auth, raw_image::edit_raw_image, state::AppState}; +const RAW_IMAGE_EDIT_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024; + pub fn router(state: AppState) -> Router { Router::new().route( "/api/raw/v1/images/edit", post(edit_raw_image) - .route_layer(middleware::from_fn_with_state(state, require_bearer_auth)), + .route_layer(middleware::from_fn_with_state(state, require_bearer_auth)) + .layer(DefaultBodyLimit::max(RAW_IMAGE_EDIT_BODY_LIMIT_BYTES)), ) } diff --git a/server-rs/crates/api-server/src/raw_image.rs b/server-rs/crates/api-server/src/raw_image.rs index ef4a22b33..455ed49b8 100644 --- a/server-rs/crates/api-server/src/raw_image.rs +++ b/server-rs/crates/api-server/src/raw_image.rs @@ -4,9 +4,14 @@ use axum::{ http::StatusCode, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use platform_image::{RawImageEditOptions, ReferenceImage, create_vector_engine_raw_image_edit}; +use image::{ImageFormat, ImageReader}; +use platform_image::{ + RawImageEditOptions, ReferenceImage, create_vector_engine_raw_image_edit, + validate_raw_image_edit_dimensions, +}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use std::io::Cursor; use crate::{ asset_billing::{ @@ -122,9 +127,8 @@ fn prepare_request(payload: RawImageEditRequest) -> Result( fn decode_image(value: RawImageData, field: &str) -> Result { let mime_type = value.mime_type.trim().to_string(); - if mime_type.is_empty() { - return Err(bad_request(format!("{field}.mimeType 不能为空"))); + if !mime_type.eq_ignore_ascii_case("image/png") { + return Err(bad_request(format!("{field}.mimeType 必须为 image/png"))); } let bytes = BASE64_STANDARD .decode(value.data.trim()) .map_err(|_| bad_request(format!("{field}.data 必须是有效 base64")))?; + if bytes.is_empty() { + return Err(bad_request(format!("{field}.data 不能为空"))); + } + let reader = ImageReader::new(Cursor::new(bytes.as_slice())) + .with_guessed_format() + .map_err(|_| bad_request(format!("{field}.data 必须是有效 PNG 文件")))?; + if reader.format() != Some(ImageFormat::Png) { + return Err(bad_request(format!("{field}.data 必须是有效 PNG 文件"))); + } + reader + .decode() + .map_err(|_| bad_request(format!("{field}.data 必须是有效 PNG 文件")))?; Ok(ReferenceImage { bytes, file_name: format!("{field}.png"), - mime_type, + mime_type: "image/png".to_string(), }) } @@ -225,18 +241,29 @@ fn bad_request(message: impl Into) -> AppError { #[cfg(test)] mod tests { use super::*; + use image::{ImageFormat, Rgba, RgbaImage}; + use std::io::Cursor; + + fn encoded_png(width: u32, height: u32) -> String { + let image = RgbaImage::from_pixel(width, height, Rgba([255, 0, 0, 255])); + let mut bytes = Vec::new(); + image + .write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png) + .expect("test PNG should encode"); + BASE64_STANDARD.encode(bytes) + } #[test] fn request_uses_one_image_object_and_rejects_images_array() { let payload = serde_json::json!({ - "image": {"data": "aGVsbG8=", "mimeType": "image/png"}, + "image": {"data": encoded_png(1, 1), "mimeType": "image/png"}, "prompt": "edit", "width": 1024, "height": 1024 }); let parsed: RawImageEditRequest = serde_json::from_value(payload).expect("image object"); let prepared = prepare_request(parsed).expect("request should prepare"); - assert_eq!(prepared.image.bytes, b"hello"); + assert!(prepared.image.bytes.starts_with(b"\x89PNG\r\n\x1a\n")); let array_payload = serde_json::json!({ "images": [{"data": "aGVsbG8=", "mimeType": "image/png"}], @@ -278,4 +305,36 @@ mod tests { assert!(rendered.contains("image.data 必须是有效 base64")); assert!(!rendered.contains("InvalidByte")); } + + #[test] + fn dimensions_follow_strict_raw_image_contract() { + assert!(validate_raw_image_edit_dimensions(1024, 1024).is_ok()); + assert!(validate_raw_image_edit_dimensions(3840, 1280).is_ok()); + assert!(validate_raw_image_edit_dimensions(3839, 1280).is_err()); + assert!(validate_raw_image_edit_dimensions(3840, 1264).is_err()); + assert!(validate_raw_image_edit_dimensions(1024, 1000).is_err()); + assert!(validate_raw_image_edit_dimensions(16, 16).is_err()); + assert!(validate_raw_image_edit_dimensions(3840, 3840).is_err()); + } + + #[test] + fn input_requires_decodable_png_and_png_mime() { + let valid = serde_json::json!({ + "image": {"data": encoded_png(1, 1), "mimeType": "IMAGE/PNG"}, + "prompt": "edit", + "width": 1024, + "height": 1024 + }); + assert!(prepare_request(serde_json::from_value(valid).expect("valid request")).is_ok()); + + for (data, mime_type) in [("aGVsbG8=", "image/png"), ("aGVsbG8=", "image/jpeg")] { + let payload = serde_json::json!({ + "image": {"data": data, "mimeType": mime_type}, + "prompt": "edit", + "width": 1024, + "height": 1024 + }); + assert!(prepare_request(serde_json::from_value(payload).expect("request")).is_err()); + } + } } diff --git a/server-rs/crates/platform-image/src/lib.rs b/server-rs/crates/platform-image/src/lib.rs index 1e6e757bd..7140aec48 100644 --- a/server-rs/crates/platform-image/src/lib.rs +++ b/server-rs/crates/platform-image/src/lib.rs @@ -10,15 +10,16 @@ pub use pixel_art_snapper::{ }; pub use vector_engine::{ DownloadedImage, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, GeneratedImages, NANOBANANA_2_MODEL, - PlatformImageError, PlatformImageFailureAudit, PlatformImageStatusHint, RawImageEditOptions, - ReferenceImage, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER, - VectorEngineImageSettings, build_vector_engine_image_http_client, - build_vector_engine_image_request_body, + PlatformImageError, PlatformImageFailureAudit, PlatformImageStatusHint, + RAW_IMAGE_DIMENSION_ALIGNMENT, RAW_IMAGE_MAX_EDGE, RAW_IMAGE_MAX_PIXELS, RAW_IMAGE_MIN_PIXELS, + RawImageEditDimensionError, RawImageEditOptions, 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, create_vector_engine_image_edit_with_references, create_vector_engine_image_edit_with_references_and_model, create_vector_engine_image_generation, create_vector_engine_image_generation_with_model, create_vector_engine_nanobanana_generate_content, create_vector_engine_raw_image_edit, - download_remote_image, vector_engine_images_edit_url, vector_engine_images_generation_url, - vector_engine_nanobanana_generate_content_url, + download_remote_image, validate_raw_image_edit_dimensions, vector_engine_images_edit_url, + vector_engine_images_generation_url, vector_engine_nanobanana_generate_content_url, }; diff --git a/server-rs/crates/platform-image/src/vector_engine/mod.rs b/server-rs/crates/platform-image/src/vector_engine/mod.rs index aacb3256d..b94d22221 100644 --- a/server-rs/crates/platform-image/src/vector_engine/mod.rs +++ b/server-rs/crates/platform-image/src/vector_engine/mod.rs @@ -26,7 +26,11 @@ pub use constants::{ }; pub use error::{PlatformImageError, PlatformImageStatusHint}; pub use image_source::download_remote_image; -pub use raw_edit::{RawImageEditOptions, create_vector_engine_raw_image_edit}; +pub use raw_edit::{ + RAW_IMAGE_DIMENSION_ALIGNMENT, RAW_IMAGE_MAX_EDGE, RAW_IMAGE_MAX_PIXELS, RAW_IMAGE_MIN_PIXELS, + RawImageEditDimensionError, RawImageEditOptions, create_vector_engine_raw_image_edit, + validate_raw_image_edit_dimensions, +}; 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, diff --git a/server-rs/crates/platform-image/src/vector_engine/raw_edit.rs b/server-rs/crates/platform-image/src/vector_engine/raw_edit.rs index a6c53ff0d..c46d46037 100644 --- a/server-rs/crates/platform-image/src/vector_engine/raw_edit.rs +++ b/server-rs/crates/platform-image/src/vector_engine/raw_edit.rs @@ -13,7 +13,7 @@ use super::{ util::truncate_raw, }; -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug)] pub struct RawImageEditOptions { pub quality: Option, pub background: Option, @@ -23,6 +23,68 @@ pub struct RawImageEditOptions { pub mask: Option, } +pub const RAW_IMAGE_MAX_EDGE: u32 = 3_840; +pub const RAW_IMAGE_DIMENSION_ALIGNMENT: u32 = 16; +pub const RAW_IMAGE_MIN_PIXELS: u64 = 655_360; +pub const RAW_IMAGE_MAX_PIXELS: u64 = 8_294_400; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RawImageEditDimensionError { + Zero, + MaxEdge, + Alignment, + AspectRatio, + PixelCount, +} + +impl std::fmt::Display for RawImageEditDimensionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Zero => formatter.write_str("width 和 height 必须为正整数"), + Self::MaxEdge => write!( + formatter, + "width 和 height 的单边最大值为 {RAW_IMAGE_MAX_EDGE}px" + ), + Self::Alignment => write!( + formatter, + "width 和 height 必须是 {RAW_IMAGE_DIMENSION_ALIGNMENT}px 的倍数" + ), + Self::AspectRatio => formatter.write_str("长边与短边的比例不能超过 3:1"), + Self::PixelCount => write!( + formatter, + "总像素必须在 {RAW_IMAGE_MIN_PIXELS} 至 {RAW_IMAGE_MAX_PIXELS} 之间" + ), + } + } +} + +pub fn validate_raw_image_edit_dimensions( + width: u32, + height: u32, +) -> Result<(), RawImageEditDimensionError> { + if width == 0 || height == 0 { + return Err(RawImageEditDimensionError::Zero); + } + if width > RAW_IMAGE_MAX_EDGE || height > RAW_IMAGE_MAX_EDGE { + return Err(RawImageEditDimensionError::MaxEdge); + } + if !width.is_multiple_of(RAW_IMAGE_DIMENSION_ALIGNMENT) + || !height.is_multiple_of(RAW_IMAGE_DIMENSION_ALIGNMENT) + { + return Err(RawImageEditDimensionError::Alignment); + } + let long_edge = u64::from(width.max(height)); + let short_edge = u64::from(width.min(height)); + if long_edge > short_edge.saturating_mul(3) { + return Err(RawImageEditDimensionError::AspectRatio); + } + let pixels = u64::from(width) * u64::from(height); + if !(RAW_IMAGE_MIN_PIXELS..=RAW_IMAGE_MAX_PIXELS).contains(&pixels) { + return Err(RawImageEditDimensionError::PixelCount); + } + Ok(()) +} + /// Independent raw GPT Image 2 proxy; it does not call the editor image-edit client. pub async fn create_vector_engine_raw_image_edit( settings: &VectorEngineImageSettings, @@ -31,6 +93,8 @@ pub async fn create_vector_engine_raw_image_edit( options: RawImageEditOptions, failure_context: &str, ) -> Result { + validate_raw_image_edit_dimensions(options.width, options.height) + .map_err(|error| invalid_request(failure_context, error.to_string()))?; let url = vector_engine_images_edit_url(settings); let started_at = Instant::now(); let prompt_chars = Some(prompt.chars().count()); @@ -392,9 +456,7 @@ mod tests { Ok(("image/jpeg", "jpg")) ); assert!(response_image_format(&json!({}), &json!({})).is_err()); - assert!( - response_image_format(&json!({}), &json!({"output_format": "bmp"})).is_err() - ); + assert!(response_image_format(&json!({}), &json!({"output_format": "bmp"})).is_err()); } #[test] @@ -417,4 +479,30 @@ mod tests { assert_eq!(audit.reference_image_count, Some(2)); assert_eq!(audit.image_model, Some(GPT_IMAGE_2_MODEL)); } + + #[test] + fn raw_image_edit_dimensions_enforce_strict_contract() { + assert!(validate_raw_image_edit_dimensions(1024, 640).is_ok()); + assert!(validate_raw_image_edit_dimensions(3840, 2160).is_ok()); + assert_eq!( + validate_raw_image_edit_dimensions(3856, 2160), + Err(RawImageEditDimensionError::MaxEdge) + ); + assert_eq!( + validate_raw_image_edit_dimensions(1024, 1000), + Err(RawImageEditDimensionError::Alignment) + ); + assert_eq!( + validate_raw_image_edit_dimensions(1936, 640), + Err(RawImageEditDimensionError::AspectRatio) + ); + assert_eq!( + validate_raw_image_edit_dimensions(1024, 624), + Err(RawImageEditDimensionError::PixelCount) + ); + assert_eq!( + validate_raw_image_edit_dimensions(3840, 2176), + Err(RawImageEditDimensionError::PixelCount) + ); + } }