From 0400103a1985f0333dd353585d680521aeb8c71f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 9 Sep 2026 13:06:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=86=20Raw=20=E5=9B=BE=E7=89=87=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=85=A5=E7=AB=99=E6=94=B9=E4=B8=BA=20multipart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 JSON/base64 入站兼容,仅接受 multipart 图片与参数字段 复用扣费前 PNG、尺寸、mask 和 prompt 校验并补充 multipart 测试 优化上游图片字节转发,避免再次克隆 image bytes 同步 Raw 图片编辑技术方案与 Axum multipart 依赖 --- ...案】Raw GPT Image 2图片编辑代理-2026-09-07.md | 38 +- server-rs/Cargo.lock | 24 ++ server-rs/crates/api-server/Cargo.toml | 2 +- server-rs/crates/api-server/src/raw_image.rs | 325 ++++++++++++------ .../src/vector_engine/raw_edit.rs | 13 +- 5 files changed, 270 insertions(+), 132 deletions(-) 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 f8696987b..ed130ed2a 100644 --- a/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md +++ b/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md @@ -14,28 +14,20 @@ POST /api/raw/v1/images/edit ## 请求合同 -请求使用 `application/json`。图片字段只使用原始图片的 base64 数据和 MIME 类型,不接受 object key、URL、Data URL 或 Blob URL。 +请求使用 `multipart/form-data`,不再接受 JSON/base64 入站格式。图片直接作为文件字段上传,避免 base64 膨胀和入站解码;服务端仍在扣费前完成 PNG 完整解码与资源限制校验。 -```json -{ - "image": { - "data": "", - "mimeType": "image/png" - }, - "mask": { - "data": "", - "mimeType": "image/png" - }, - "prompt": "修改图片", - "quality": "auto", - "background": "auto", - "output_format": "png", - "width": 1536, - "height": 1024 -} +```text +image: +mask: +prompt: 修改图片 +quality: auto +background: auto +output_format: png +width: 1536 +height: 1024 ``` -`image` 是必填的单图结构 `{ data, mimeType }`;`mask` 可选并使用相同结构。`image` 和 `mask` 的 `mimeType` 必须为 `image/png`,base64 解码后必须是可完整解码的有效 PNG 文件,提供 mask 时其宽高必须与 image 完全一致;空数据、非 PNG 字节、MIME 不匹配或尺寸不一致均在扣费前返回 400。服务端不把输入格式另建成请求参数。`prompt` 必填,UTF-8 原始字节长度不得超过 `4 KiB`;超限在扣费前返回 400。`quality`、`background` 和 `output_format` 采用 GPT Image 模型支持的值。 +`image` 和 `mask` 必须是 `image/png` 文件字段;服务端不信任客户端文件名,转发时使用固定文件名。空文件、非 PNG 字节、MIME 不匹配或 mask 与 image 尺寸不一致均在扣费前返回 400。`prompt` 必填,UTF-8 原始字节长度不得超过 `4 KiB`;超限在扣费前返回 400。`quality`、`background` 和 `output_format` 采用 GPT Image 模型支持的值。字段不能重复,未知字段拒绝;缺失的必填字段拒绝。 `width`、`height` 使用严格输出尺寸规则,均在扣费前校验: @@ -46,7 +38,7 @@ POST /api/raw/v1/images/edit 校验通过后按整数尺寸发送给 provider,不静默 clamp 或改写调用者尺寸。 -Raw 路由的 JSON body limit 为 `64 MiB`,为 base64 编码膨胀和可选 mask 留出空间;同时必须在 base64 解码后拒绝空 PNG,并保留图片格式校验,避免仅依赖 HTTP body limit。PNG 解码使用与输出合同一致的资源上限:宽高各不超过 `3840`,解码分配不超过 `8294400 × 4` 字节;base64 与 PNG 解码在阻塞线程执行,不占用 Tokio 异步 worker。 +Raw 路由的 multipart body limit 为 `64 MiB`,覆盖图片和文本字段;文件字段由 multipart 解析器直接收集为字节,随后在阻塞线程中完成 PNG 解码。PNG 解码使用与输出合同一致的资源上限:宽高各不超过 `3840`,解码分配不超过 `8294400 × 4` 字节;不再执行 base64 入站解码。 服务端发送给 `platform-image` 时固定注入: @@ -75,7 +67,7 @@ n = 1 ## 预检查与计费事务 -所有请求、JSON、base64、图片结构和 provider 参数检查必须在扣费前完成。预检查失败直接返回 4xx,不产生钱包流水,也不调用 provider。 +所有 multipart 字段、图片结构和 provider 参数检查必须在扣费前完成。预检查失败直接返回 4xx,不产生钱包流水,也不调用 provider。 检查通过后,api-server 进入现有资产操作计费边界,通过 SpacetimeDB 钱包事务 procedure 原子完成: @@ -91,13 +83,13 @@ raw 操作使用独立的 operation / ledger 命名空间,例如 `raw-image-ed ## Provider 边界 -`platform-image` 保留 VectorEngine 协议细节。raw handler 只负责:认证、JSON DTO、base64 解码、预检查、计费编排和响应映射。provider 请求仍由 `platform-image` 统一构造,并携带 `model`、`n`、`quality`、`background`、`output_format`、尺寸及图片参考字节。 +`platform-image` 保留 VectorEngine 协议细节。raw handler 只负责:认证、multipart 字段解析、PNG 预检查、计费编排和响应映射。provider 请求仍由 `platform-image` 统一构造,并携带 `model`、`n`、`quality`、`background`、`output_format`、尺寸及图片参考字节。 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/api-server/src/raw_image.rs`:独立路由 handler、multipart 字段解析、请求/响应 DTO、PNG 输入校验、预检查和 raw billing 编排。 - `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。 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 6581b2ef0..b77676b63 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -470,6 +470,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -2924,6 +2925,23 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "naga" version = "27.0.3" @@ -5629,6 +5647,12 @@ dependencies = [ "tokio-tungstenite 0.27.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "sse-stream" version = "0.2.5" diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 78cfb8bab..0a166cd5f 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true [dependencies] aes = { workspace = true } async-stream = { workspace = true } -axum = { workspace = true, features = ["ws"] } +axum = { workspace = true, features = ["ws", "multipart"] } base64 = { workspace = true } cbc = { workspace = true } bytes = { workspace = true } diff --git a/server-rs/crates/api-server/src/raw_image.rs b/server-rs/crates/api-server/src/raw_image.rs index 9dc066ae1..fc0d33a6d 100644 --- a/server-rs/crates/api-server/src/raw_image.rs +++ b/server-rs/crates/api-server/src/raw_image.rs @@ -1,15 +1,14 @@ use axum::{ Json, - extract::{Extension, State}, + extract::{Extension, Multipart, State}, http::StatusCode, }; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use image::{GenericImageView, ImageFormat, ImageReader}; use platform_image::{ RAW_IMAGE_MAX_EDGE, RAW_IMAGE_MAX_PIXELS, RawImageEditOptions, ReferenceImage, create_vector_engine_raw_image_edit, validate_raw_image_edit_dimensions, }; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use serde_json::json; use std::io::Cursor; @@ -29,16 +28,15 @@ use crate::{ }; use time::OffsetDateTime; -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct RawImageData { - pub(crate) data: String, +#[derive(Debug)] +struct RawImageData { + pub(crate) bytes: Vec, pub(crate) mime_type: String, + pub(crate) file_name: String, } -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "snake_case", deny_unknown_fields)] -pub(crate) struct RawImageEditRequest { +#[derive(Debug)] +struct RawImageEditRequest { pub(crate) image: RawImageData, pub(crate) mask: Option, pub(crate) prompt: String, @@ -65,8 +63,9 @@ pub(crate) async fn edit_raw_image( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, - Json(payload): Json, + multipart: Multipart, ) -> Result, AppError> { + let payload = parse_multipart_request(multipart).await?; let prepared = tokio::task::spawn_blocking(move || prepare_request(payload)) .await .map_err(|error| { @@ -97,7 +96,7 @@ pub(crate) async fn edit_raw_image( let generated = match create_vector_engine_raw_image_edit( &provider_settings, prepared.prompt.as_str(), - &prepared.image, + prepared.image, prepared.options, "raw_image_edit", ) @@ -153,6 +152,114 @@ struct PreparedRawImageEdit { height: u32, } +async fn parse_multipart_request( + mut multipart: Multipart, +) -> Result { + let mut image = None; + let mut mask = None; + let mut prompt = None; + let mut quality = None; + let mut background = None; + let mut output_format = None; + let mut width = None; + let mut height = None; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|error| bad_request(format!("multipart 字段读取失败:{error}")))? + { + let name = field + .name() + .ok_or_else(|| bad_request("multipart 字段缺少名称"))? + .to_string(); + match name.as_str() { + "image" => { + if image.is_some() { + return Err(bad_request("image 字段不能重复")); + } + image = Some(read_multipart_image(field, "image").await?); + } + "mask" => { + if mask.is_some() { + return Err(bad_request("mask 字段不能重复")); + } + mask = Some(read_multipart_image(field, "mask").await?); + } + "prompt" => set_text_field(&mut prompt, field, "prompt").await?, + "quality" => set_text_field(&mut quality, field, "quality").await?, + "background" => set_text_field(&mut background, field, "background").await?, + "output_format" => set_text_field(&mut output_format, field, "output_format").await?, + "width" => set_text_field(&mut width, field, "width").await?, + "height" => set_text_field(&mut height, field, "height").await?, + _ => return Err(bad_request(format!("不支持的 multipart 字段:{name}"))), + } + } + + let image = image.ok_or_else(|| bad_request("image 字段不能为空"))?; + let prompt = prompt.ok_or_else(|| bad_request("prompt 字段不能为空"))?; + let width = parse_multipart_u32(width, "width")?; + let height = parse_multipart_u32(height, "height")?; + + Ok(RawImageEditRequest { + image, + mask, + prompt, + quality, + background, + output_format, + width, + height, + }) +} + +async fn read_multipart_image( + field: axum::extract::multipart::Field<'_>, + name: &str, +) -> Result { + let mime_type = field.content_type().unwrap_or_default().to_string(); + if !mime_type.eq_ignore_ascii_case("image/png") { + return Err(bad_request(format!("{name} 必须为 image/png"))); + } + let bytes = field + .bytes() + .await + .map_err(|error| bad_request(format!("{name} 文件读取失败:{error}")))?; + if bytes.is_empty() { + return Err(bad_request(format!("{name} 文件不能为空"))); + } + Ok(RawImageData { + bytes: bytes.to_vec(), + mime_type: "image/png".to_string(), + file_name: format!("{name}.png"), + }) +} + +async fn set_text_field( + target: &mut Option, + field: axum::extract::multipart::Field<'_>, + name: &str, +) -> Result<(), AppError> { + if target.is_some() { + return Err(bad_request(format!("{name} 字段不能重复"))); + } + *target = Some( + field + .text() + .await + .map_err(|error| bad_request(format!("{name} 字段读取失败:{error}")))?, + ); + Ok(()) +} + +fn parse_multipart_u32(value: Option, field: &str) -> Result { + let value = value.ok_or_else(|| bad_request(format!("{field} 字段不能为空")))?; + value + .trim() + .parse::() + .map_err(|_| bad_request(format!("{field} 必须为有效整数"))) +} + fn prepare_request(payload: RawImageEditRequest) -> Result { if payload.prompt.trim().is_empty() { return Err(bad_request("prompt 不能为空")); @@ -232,24 +339,24 @@ fn validate_optional_value( fn decode_image(value: RawImageData, field: &str) -> Result<(ReferenceImage, u32, u32), AppError> { let mime_type = value.mime_type.trim().to_string(); if !mime_type.eq_ignore_ascii_case("image/png") { - return Err(bad_request(format!("{field}.mimeType 必须为 image/png"))); + return Err(bad_request(format!( + "{field} Content-Type 必须为 image/png" + ))); } - let bytes = BASE64_STANDARD - .decode(value.data.trim()) - .map_err(|_| bad_request(format!("{field}.data 必须是有效 base64")))?; + let bytes = value.bytes; if bytes.is_empty() { - return Err(bad_request(format!("{field}.data 不能为空"))); + return Err(bad_request(format!("{field} 文件不能为空"))); } let mut reader = ImageReader::new(Cursor::new(bytes.as_slice())) .with_guessed_format() - .map_err(|_| bad_request(format!("{field}.data 必须是有效 PNG 文件")))?; + .map_err(|_| bad_request(format!("{field} 文件必须是有效 PNG 文件")))?; let mut limits = image::Limits::default(); limits.max_image_width = Some(RAW_IMAGE_MAX_EDGE); limits.max_image_height = Some(RAW_IMAGE_MAX_EDGE); limits.max_alloc = Some(RAW_IMAGE_MAX_PIXELS.saturating_mul(4)); reader.limits(limits); if reader.format() != Some(ImageFormat::Png) { - return Err(bad_request(format!("{field}.data 必须是有效 PNG 文件"))); + return Err(bad_request(format!("{field} 文件必须是有效 PNG 文件"))); } let decoded = reader .decode() @@ -258,8 +365,8 @@ fn decode_image(value: RawImageData, field: &str) -> Result<(ReferenceImage, u32 Ok(( ReferenceImage { bytes, - file_name: format!("{field}.png"), - mime_type: "image/png".to_string(), + file_name: value.file_name, + mime_type, }, width, height, @@ -269,9 +376,9 @@ fn decode_image(value: RawImageData, field: &str) -> Result<(ReferenceImage, u32 fn map_decode_image_error(field: &str, error: image::ImageError) -> AppError { let message = match error { image::ImageError::Limits(_) => { - format!("{field}.data 超出 PNG 尺寸或解码资源上限(单边不超过 {RAW_IMAGE_MAX_EDGE}px)") + format!("{field} 文件超出 PNG 尺寸或解码资源上限(单边不超过 {RAW_IMAGE_MAX_EDGE}px)") } - _ => format!("{field}.data 必须是有效 PNG 文件"), + _ => format!("{field} 文件必须是有效 PNG 文件"), }; bad_request(message) } @@ -302,37 +409,84 @@ fn bad_request(message: impl Into) -> AppError { #[cfg(test)] mod tests { use super::*; + use axum::{body::Body, extract::FromRequest, http::Request}; use image::{ImageFormat, Rgba, RgbaImage}; use std::io::Cursor; - fn encoded_png(width: u32, height: u32) -> String { + fn png_bytes(width: u32, height: u32) -> Vec { 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) + bytes } - #[test] - fn request_uses_one_image_object_and_rejects_images_array() { - let payload = serde_json::json!({ - "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!(prepared.image.bytes.starts_with(b"\x89PNG\r\n\x1a\n")); + fn request(image: Vec, mask: Option>) -> RawImageEditRequest { + RawImageEditRequest { + image: RawImageData { + bytes: image, + mime_type: "image/png".to_string(), + file_name: "image.png".to_string(), + }, + mask: mask.map(|bytes| RawImageData { + bytes, + mime_type: "image/png".to_string(), + file_name: "mask.png".to_string(), + }), + prompt: "edit".to_string(), + width: 1024, + height: 1024, + quality: None, + background: None, + output_format: None, + } + } - let array_payload = serde_json::json!({ - "images": [{"data": "aGVsbG8=", "mimeType": "image/png"}], - "prompt": "edit", - "width": 1024, - "height": 1024 - }); - assert!(serde_json::from_value::(array_payload).is_err()); + fn multipart_body(boundary: &str, image: &[u8]) -> Vec { + let mut body = Vec::new(); + let add_text = |body: &mut Vec, name: &str, value: &str| { + body.extend_from_slice(format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n" + ).as_bytes()); + }; + add_text(&mut body, "prompt", "edit"); + add_text(&mut body, "width", "1024"); + add_text(&mut body, "height", "1024"); + body.extend_from_slice( + format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"image\"; filename=\"ignored.png\"\r\nContent-Type: image/png\r\n\r\n" + ) + .as_bytes(), + ); + body.extend_from_slice(image); + body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + body + } + + #[tokio::test] + async fn multipart_parser_accepts_binary_image_and_text_fields() { + let boundary = "raw-test-boundary"; + let image = png_bytes(1, 1); + let body = multipart_body(boundary, &image); + let request = Request::builder() + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .expect("multipart request"); + let multipart = Multipart::from_request(request, &()) + .await + .expect("multipart"); + let parsed = parse_multipart_request(multipart) + .await + .expect("multipart fields should parse"); + + assert_eq!(parsed.prompt, "edit"); + assert_eq!(parsed.width, 1024); + assert_eq!(parsed.height, 1024); + assert!(parsed.image.bytes.starts_with(b"\x89PNG\r\n\x1a\n")); } #[test] @@ -349,24 +503,6 @@ mod tests { ); } - #[test] - fn invalid_base64_uses_generic_client_message() { - let payload = serde_json::json!({ - "image": {"data": "not base64!", "mimeType": "image/png"}, - "prompt": "edit", - "width": 1024, - "height": 1024 - }); - let parsed: RawImageEditRequest = serde_json::from_value(payload).expect("request"); - let error = match prepare_request(parsed) { - Ok(_) => panic!("invalid base64 should fail"), - Err(error) => error, - }; - let rendered = format!("{error:?}"); - 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()); @@ -380,38 +516,32 @@ mod tests { #[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()); + assert!(prepare_request(request(png_bytes(1, 1), None)).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()); - } + let invalid_bytes = RawImageEditRequest { + image: RawImageData { + bytes: b"hello".to_vec(), + mime_type: "image/png".to_string(), + file_name: "image.png".to_string(), + }, + ..request(png_bytes(1, 1), None) + }; + assert!(prepare_request(invalid_bytes).is_err()); + + let invalid_mime = RawImageEditRequest { + image: RawImageData { + bytes: png_bytes(1, 1), + mime_type: "image/jpeg".to_string(), + file_name: "image.jpg".to_string(), + }, + ..request(png_bytes(1, 1), None) + }; + assert!(prepare_request(invalid_mime).is_err()); } #[test] fn oversized_valid_png_reports_resource_limit() { - let payload = serde_json::json!({ - "image": { - "data": encoded_png(RAW_IMAGE_MAX_EDGE + 1, 1), - "mimeType": "image/png" - }, - "prompt": "edit", - "width": 1024, - "height": 1024 - }); - let parsed: RawImageEditRequest = serde_json::from_value(payload).expect("request"); - let error = match prepare_request(parsed) { + let error = match prepare_request(request(png_bytes(RAW_IMAGE_MAX_EDGE + 1, 1), None)) { Ok(_) => panic!("oversized PNG should fail"), Err(error) => error, }; @@ -421,15 +551,7 @@ mod tests { #[test] fn mask_must_match_source_image_dimensions() { - let payload = serde_json::json!({ - "image": {"data": encoded_png(2, 1), "mimeType": "image/png"}, - "mask": {"data": encoded_png(1, 1), "mimeType": "image/png"}, - "prompt": "edit", - "width": 1024, - "height": 1024 - }); - let parsed: RawImageEditRequest = serde_json::from_value(payload).expect("request"); - let error = match prepare_request(parsed) { + let error = match prepare_request(request(png_bytes(2, 1), Some(png_bytes(1, 1)))) { Ok(_) => panic!("mismatched mask should fail before billing"), Err(error) => error, }; @@ -439,13 +561,8 @@ mod tests { #[test] fn prompt_uses_raw_utf8_byte_limit() { - let payload = serde_json::json!({ - "image": {"data": encoded_png(1, 1), "mimeType": "image/png"}, - "prompt": "a".repeat(RAW_IMAGE_MAX_PROMPT_BYTES + 1), - "width": 1024, - "height": 1024 - }); - let parsed: RawImageEditRequest = serde_json::from_value(payload).expect("request"); + let mut parsed = request(png_bytes(1, 1), None); + parsed.prompt = "a".repeat(RAW_IMAGE_MAX_PROMPT_BYTES + 1); let error = match prepare_request(parsed) { Ok(_) => panic!("oversized prompt should fail before image decode and billing"), Err(error) => error, 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 77c386322..d215adbbc 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 @@ -104,7 +104,7 @@ pub fn validate_raw_image_edit_dimensions( pub async fn create_vector_engine_raw_image_edit( settings: &VectorEngineImageSettings, prompt: &str, - image: &ReferenceImage, + image: ReferenceImage, options: RawImageEditOptions, failure_context: &str, ) -> Result { @@ -126,6 +126,11 @@ pub async fn create_vector_engine_raw_image_edit( reference_image_count, )); }; + let ReferenceImage { + bytes: image_bytes, + file_name: image_file_name, + mime_type: image_mime_type, + } = image; let mut form = Form::new() .text("model", GPT_IMAGE_2_MODEL.to_string()) .text("n", "1".to_string()) @@ -133,9 +138,9 @@ pub async fn create_vector_engine_raw_image_edit( .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()) + Part::bytes(image_bytes) + .file_name(image_file_name) + .mime_str(image_mime_type.as_str()) .map_err(|error| invalid_request(failure_context, error.to_string()))?, ); if let Some(value) = options.quality {