收紧 Raw 图片编辑输入契约
新增 3840 边长、16 像素对齐、3:1 比例和总像素范围校验 要求 image 与 mask 为可完整解码的 PNG 并在扣费前拒绝非法输入 将 Raw 路由 JSON body limit 提升至 64 MiB 同步 Raw 图片编辑技术方案和边界测试
This commit is contained in:
@@ -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 分支。
|
||||
|
||||
@@ -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<AppState> {
|
||||
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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<PreparedRawImageEdit,
|
||||
if payload.prompt.trim().is_empty() {
|
||||
return Err(bad_request("prompt 不能为空"));
|
||||
}
|
||||
if payload.width == 0 || payload.height == 0 {
|
||||
return Err(bad_request("width 和 height 必须为正整数"));
|
||||
}
|
||||
validate_raw_image_edit_dimensions(payload.width, payload.height)
|
||||
.map_err(|error| bad_request(error.to_string()))?;
|
||||
validate_optional_value(
|
||||
payload.quality.as_deref(),
|
||||
"quality",
|
||||
@@ -186,16 +190,28 @@ fn validate_optional_value<const N: usize>(
|
||||
|
||||
fn decode_image(value: RawImageData, field: &str) -> Result<ReferenceImage, AppError> {
|
||||
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<String>) -> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,7 +13,7 @@ use super::{
|
||||
util::truncate_raw,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RawImageEditOptions {
|
||||
pub quality: Option<String>,
|
||||
pub background: Option<String>,
|
||||
@@ -23,6 +23,68 @@ pub struct RawImageEditOptions {
|
||||
pub mask: Option<ReferenceImage>,
|
||||
}
|
||||
|
||||
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<GeneratedImages, PlatformImageError> {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user