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 ae006ffa7..759fe959d 100644 --- a/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md +++ b/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md @@ -18,12 +18,10 @@ POST /api/raw/v1/images/edit ```json { - "images": [ - { - "data": "", - "mimeType": "image/png" - } - ], + "image": { + "data": "", + "mimeType": "image/png" + }, "mask": { "data": "", "mimeType": "image/png" @@ -37,7 +35,7 @@ POST /api/raw/v1/images/edit } ``` -`images` 是必填数组,数组成员结构固定为 `{ data, mimeType }`;`mask` 可选并使用相同结构。输入格式由成员的 MIME 类型和解码后的图片字节共同确定,服务端不把输入格式另建成请求参数。`prompt` 必填。`quality`、`background` 和 `output_format` 采用 GPT Image 模型支持的值。`width`、`height` 为整数,组成发送给 provider 的输出尺寸;不把尺寸改写成业务字符串字段。 +`image` 是必填的单图结构 `{ data, mimeType }`;`mask` 可选并使用相同结构。输入格式由 MIME 类型和解码后的图片字节共同确定,服务端不把输入格式另建成请求参数。`prompt` 必填。`quality`、`background` 和 `output_format` 采用 GPT Image 模型支持的值。`width`、`height` 为整数,组成发送给 provider 的输出尺寸;不把尺寸改写成业务字符串字段。 服务端发送给 `platform-image` 时固定注入: @@ -68,14 +66,15 @@ n = 1 所有请求、JSON、base64、图片结构和 provider 参数检查必须在扣费前完成。预检查失败直接返回 4xx,不产生钱包流水,也不调用 provider。 -检查通过后,api-server 调用一个 raw 图片操作的 SpacetimeDB 事务 procedure,在同一事务内完成: +检查通过后,api-server 进入现有资产操作计费边界,通过 SpacetimeDB 钱包事务 procedure 原子完成: -1. 以认证后的用户和请求 ID 建立 raw 操作幂等事实; -2. 按现有图片编辑算法解析价格:GPT Image 2 长边不超过 1536 使用 1K 价格,否则使用 2K 价格;当前默认价格为 3 / 5 泥点; -3. 原子扣除用户泥点并写入 `asset_operation_consume` 流水; -4. 持久化操作状态,供 provider 返回后成功或失败收口。 +1. 按现有图片编辑算法解析价格:GPT Image 2 长边不超过 1536 使用 1K 价格,否则使用 2K 价格;当前默认价格为 3 / 5 泥点; +2. 以认证后的用户、`raw-image-edit` 命名空间和请求 ID 组成幂等扣费流水 ID; +3. 原子扣除用户泥点并写入 `asset_operation_consume` 流水。 -provider 调用在 SpacetimeDB 事务之外执行。成功后调用同一 raw 操作的完成 procedure;失败后调用失败 procedure,由数据库事务写入退款 outbox / settlement 事实。进程崩溃时不依赖 Rust future `Drop` 才能发现需要退款;恢复处理根据持久化的 raw 操作状态完成退款。 +provider 调用在 SpacetimeDB 事务之外执行。失败时由现有计费边界把幂等退款事实写入 SpacetimeDB refund outbox,再由 worker 完成退款。 + +TODO:新增 raw 操作持久化状态,将“创建 raw 操作事实 + 扣费”收入同一事务,并由恢复 worker 对“已扣费但未收口”状态自动退款,填补进程在扣费后、写入 refund outbox 前崩溃的窗口。 raw 操作使用独立的 operation / ledger 命名空间,例如 `raw-image-edit`,不能复用编辑器资源 ID、编辑器任务 ID 或 `external_generation_job`。 @@ -83,7 +82,7 @@ raw 操作使用独立的 operation / ledger 命名空间,例如 `raw-image-ed `platform-image` 保留 VectorEngine 协议细节。raw handler 只负责:认证、JSON DTO、base64 解码、预检查、计费编排和响应映射。provider 请求仍由 `platform-image` 统一构造,并携带 `model`、`n`、`quality`、`background`、`output_format`、尺寸及图片参考字节。 -provider 返回的原始 `size` 必须沿 `GeneratedImages` 结果传回 api-server;raw handler 从该字段解析响应尺寸,再编码 `data[].b64_json`。 +provider 结果统一解码为图片字节;raw handler 只将这些字节编码到 `data[].b64_json`。 ## 代码拆分 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index d898bdbf9..a3a761b3f 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -50,6 +50,7 @@ pub fn build_router(state: AppState) -> Router { .merge(modules::platform::router(state.clone())) .merge(modules::external_generation::router(state.clone())) .merge(modules::platform_support::router(state.clone())) + .merge(modules::raw::router(state.clone())) .merge(crate::error_reports::router(state.clone())) .route( "/api/profile/recharge/wechat/notify", diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index f40e6309e..25e42cd28 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -67,6 +67,7 @@ mod profile_identity; mod profile_recharge_expiration_listener; mod profile_recharge_refund_reconciliation; mod prompt; +mod raw_image; mod refresh_session; mod registration_reward; mod request_context; diff --git a/server-rs/crates/api-server/src/modules.rs b/server-rs/crates/api-server/src/modules.rs index 558c37c2b..76f5958b7 100644 --- a/server-rs/crates/api-server/src/modules.rs +++ b/server-rs/crates/api-server/src/modules.rs @@ -10,3 +10,4 @@ pub mod internal; pub mod platform; pub mod platform_support; pub mod profile; +pub mod raw; diff --git a/server-rs/crates/api-server/src/modules/raw.rs b/server-rs/crates/api-server/src/modules/raw.rs new file mode 100644 index 000000000..905c16584 --- /dev/null +++ b/server-rs/crates/api-server/src/modules/raw.rs @@ -0,0 +1,11 @@ +use axum::{Router, middleware, routing::post}; + +use crate::{auth::require_bearer_auth, raw_image::edit_raw_image, state::AppState}; + +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)), + ) +} diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index e3e381eb5..f0fa7591c 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -414,7 +414,7 @@ impl OpenAiImageSettings { self } - fn provider_settings(&self) -> VectorEngineImageSettings { + pub(crate) fn provider_settings(&self) -> VectorEngineImageSettings { VectorEngineImageSettings { base_url: self.base_url.clone(), api_key: self.api_key.clone(), diff --git a/server-rs/crates/api-server/src/raw_image.rs b/server-rs/crates/api-server/src/raw_image.rs new file mode 100644 index 000000000..15ab2c765 --- /dev/null +++ b/server-rs/crates/api-server/src/raw_image.rs @@ -0,0 +1,219 @@ +use axum::{ + Json, + extract::{Extension, State}, + 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 serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::{ + asset_billing::{ + execute_billable_asset_operation_with_cost, with_editor_generation_durable_billing_boundary, + }, + auth::AuthenticatedAccessToken, + http_error::AppError, + openai_image_generation::{ + build_openai_image_http_client, map_platform_image_error, require_openai_image_settings, + }, + request_context::RequestContext, + state::AppState, +}; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct RawImageData { + pub(crate) data: String, + pub(crate) mime_type: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub(crate) struct RawImageEditRequest { + pub(crate) image: RawImageData, + pub(crate) mask: Option, + pub(crate) prompt: String, + pub(crate) quality: Option, + pub(crate) background: Option, + pub(crate) output_format: Option, + pub(crate) width: u32, + pub(crate) height: u32, +} + +#[derive(Debug, Serialize)] +pub(crate) struct RawImageEditItem { + pub(crate) b64_json: String, +} + +#[derive(Debug, Serialize)] +pub(crate) struct RawImageEditResponse { + pub(crate) data: Vec, +} + +pub(crate) async fn edit_raw_image( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, AppError> { + let prepared = prepare_request(payload)?; + let settings = require_openai_image_settings(&state)?.with_external_api_audit_context( + &request_context, + 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, + prepared.options, + "raw_image_edit", + ) + .await + .map_err(map_platform_image_error)?; + let data = generated + .images + .into_iter() + .map(|image| RawImageEditItem { + b64_json: BASE64_STANDARD.encode(image.bytes), + }) + .collect(); + Ok::<_, AppError>(RawImageEditResponse { data }) + }; + let result = with_editor_generation_durable_billing_boundary( + execute_billable_asset_operation_with_cost( + &state, + user_id.as_str(), + "raw-image-edit", + request_id.as_str(), + u64::from(points_cost), + operation, + ), + ) + .await?; + Ok(Json(serde_json::to_value(result).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) + })?)) +} + +struct PreparedRawImageEdit { + image: ReferenceImage, + prompt: String, + options: RawImageEditOptions, + width: u32, + height: u32, +} + +fn prepare_request(payload: RawImageEditRequest) -> Result { + if payload.prompt.trim().is_empty() { + return Err(bad_request("prompt 不能为空")); + } + if payload.width == 0 || payload.height == 0 { + return Err(bad_request("width 和 height 必须为正整数")); + } + let image = decode_image(payload.image, "image")?; + let mask = payload + .mask + .map(|value| decode_image(value, "mask")) + .transpose()?; + Ok(PreparedRawImageEdit { + image, + prompt: payload.prompt, + options: RawImageEditOptions { + quality: payload.quality, + background: payload.background, + output_format: payload.output_format, + width: payload.width, + height: payload.height, + mask, + }, + width: payload.width, + height: payload.height, + }) +} + +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 不能为空"))); + } + let bytes = BASE64_STANDARD + .decode(value.data.trim()) + .map_err(|error| bad_request(format!("{field}.data 必须是有效 base64:{error}")))?; + Ok(ReferenceImage { + bytes, + file_name: format!("{field}.png"), + mime_type, + }) +} + +async fn raw_image_edit_price(state: &AppState, width: u32, height: u32) -> Result { + let tier = if width.max(height) > 1536 { "2K" } else { "1K" }; + state + .editor_generation_pricing() + .await + .map(|pricing| { + pricing.image_generation_mud_points(Some("quick-edit"), Some("gpt-image-2"), Some(tier)) + }) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })) + }) +} + +fn bad_request(message: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "raw-image-edit", + "message": message.into(), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_uses_one_image_object_and_rejects_images_array() { + let payload = serde_json::json!({ + "image": {"data": "aGVsbG8=", "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"); + + 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()); + } + + #[test] + fn response_contains_only_data_b64_json() { + let response = serde_json::to_value(RawImageEditResponse { + data: vec![RawImageEditItem { + b64_json: "aGVsbG8=".to_string(), + }], + }) + .expect("response should serialize"); + assert_eq!( + response, + serde_json::json!({"data": [{"b64_json": "aGVsbG8="}]}) + ); + } +} diff --git a/server-rs/crates/platform-image/src/lib.rs b/server-rs/crates/platform-image/src/lib.rs index 8db6a9c97..1e6e757bd 100644 --- a/server-rs/crates/platform-image/src/lib.rs +++ b/server-rs/crates/platform-image/src/lib.rs @@ -10,14 +10,15 @@ 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, 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, 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, download_remote_image, - vector_engine_images_edit_url, vector_engine_images_generation_url, + 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, }; diff --git a/server-rs/crates/platform-image/src/vector_engine/client.rs b/server-rs/crates/platform-image/src/vector_engine/client.rs index 24b10196a..5e1abfe1c 100644 --- a/server-rs/crates/platform-image/src/vector_engine/client.rs +++ b/server-rs/crates/platform-image/src/vector_engine/client.rs @@ -16,6 +16,7 @@ use super::{ curl_transport::{ map_curl_error, send_vector_engine_json_request_with_curl, send_vector_engine_multipart_edit_request_with_curl, + send_vector_engine_multipart_edit_request_with_curl_options, }, error::PlatformImageError, image_source::resolve_reference_images, @@ -28,7 +29,7 @@ use super::{ vector_engine_nanobanana_generate_content_url, }, response::handle_vector_engine_response, - types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings}, + types::{GeneratedImages, RawImageEditOptions, ReferenceImage, VectorEngineImageSettings}, util::truncate_raw, }; @@ -537,6 +538,34 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( candidate_count: u32, reference_images: &[ReferenceImage], failure_context: &str, +) -> Result { + 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 { let requested_model = normalize_vector_engine_image_model(model); if reference_images.is_empty() { @@ -611,19 +640,37 @@ pub async fn create_vector_engine_image_edit_with_references_and_model( &mut recovered_failure_audits, )); }; - let response = match send_vector_engine_multipart_edit_request_with_curl( - request_url.as_str(), - settings.api_key.as_str(), - upstream_model, - prompt, - negative_prompt, - normalized_size.as_str(), - candidate_count, - reference_images, - attempt_timeout_ms, - ) - .await - { + let response = match match options { + Some(options) => { + send_vector_engine_multipart_edit_request_with_curl_options( + request_url.as_str(), + settings.api_key.as_str(), + upstream_model, + prompt, + negative_prompt, + normalized_size.as_str(), + candidate_count, + reference_images, + Some(options), + attempt_timeout_ms, + ) + .await + } + None => { + send_vector_engine_multipart_edit_request_with_curl( + request_url.as_str(), + settings.api_key.as_str(), + upstream_model, + prompt, + negative_prompt, + normalized_size.as_str(), + candidate_count, + reference_images, + attempt_timeout_ms, + ) + .await + } + } { Ok(response) => { if should_retry_vector_engine_upstream_response( response.status, diff --git a/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs b/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs index fbe94e1b1..6e653356c 100644 --- a/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs +++ b/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs @@ -7,8 +7,11 @@ use curl::{ use serde_json::Value; use super::{ - audit::build_failure_audit, constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, - request::build_prompt_with_negative, types::ReferenceImage, + audit::build_failure_audit, + constants::VECTOR_ENGINE_PROVIDER, + error::PlatformImageError, + request::build_prompt_with_negative, + types::{RawImageEditOptions, ReferenceImage}, }; #[derive(Debug)] @@ -119,6 +122,34 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl( candidate_count: u32, reference_images: &[ReferenceImage], timeout_ms: u64, +) -> Result { + send_vector_engine_multipart_edit_request_with_curl_options( + request_url, + api_key, + model, + prompt, + negative_prompt, + normalized_size, + candidate_count, + reference_images, + None, + timeout_ms, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl_options( + request_url: &str, + api_key: &str, + model: &str, + prompt: &str, + negative_prompt: Option<&str>, + normalized_size: &str, + candidate_count: u32, + reference_images: &[ReferenceImage], + options: Option<&RawImageEditOptions>, + timeout_ms: u64, ) -> Result { let request_url = request_url.to_string(); let api_key = api_key.to_string(); @@ -127,6 +158,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl( 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(), @@ -137,6 +169,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl( normalized_size.as_str(), candidate_count, reference_images.as_slice(), + options.as_ref(), timeout_ms, ) }) @@ -239,6 +272,7 @@ fn send_multipart_edit_request_with_curl_blocking( normalized_size: &str, candidate_count: u32, reference_images: &[ReferenceImage], + options: Option<&RawImageEditOptions>, timeout_ms: u64, ) -> Result { let mut form = Form::new(); @@ -253,6 +287,28 @@ fn send_multipart_edit_request_with_curl_blocking( .contents(normalized_size.as_bytes()) .add()?; + if let Some(options) = options { + if let Some(quality) = options.quality.as_deref() { + form.part("quality").contents(quality.as_bytes()).add()?; + } + if let Some(background) = options.background.as_deref() { + form.part("background") + .contents(background.as_bytes()) + .add()?; + } + if let Some(output_format) = options.output_format.as_deref() { + form.part("output_format") + .contents(output_format.as_bytes()) + .add()?; + } + if let Some(mask) = options.mask.as_ref() { + form.part("mask") + .buffer(mask.file_name.as_str(), mask.bytes.clone()) + .content_type(mask.mime_type.as_str()) + .add()?; + } + } + for reference_image in reference_images { form.part("image") .buffer( 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 f64cba54a..a20f76189 100644 --- a/server-rs/crates/platform-image/src/vector_engine/mod.rs +++ b/server-rs/crates/platform-image/src/vector_engine/mod.rs @@ -6,6 +6,7 @@ mod curl_transport; mod error; mod image_source; mod payload; +mod raw_edit; mod request; mod response; mod transport; @@ -25,6 +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 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, @@ -32,4 +34,7 @@ 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, RawImageEditOptions, ReferenceImage, + VectorEngineImageSettings, +}; 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 new file mode 100644 index 000000000..b7d6f49dd --- /dev/null +++ b/server-rs/crates/platform-image/src/vector_engine/raw_edit.rs @@ -0,0 +1,33 @@ +use super::{ + client::create_vector_engine_image_edit_with_references_and_model_and_options, + constants::GPT_IMAGE_2_MODEL, + error::PlatformImageError, + types::{GeneratedImages, RawImageEditOptions, ReferenceImage, VectorEngineImageSettings}, +}; + +/// Sends the raw GPT Image 2 edit contract while keeping VectorEngine's +/// multipart transport inside this crate. +#[allow(clippy::too_many_arguments)] +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 { + 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 +} diff --git a/server-rs/crates/platform-image/src/vector_engine/types.rs b/server-rs/crates/platform-image/src/vector_engine/types.rs index 77fbd19f9..818cafe60 100644 --- a/server-rs/crates/platform-image/src/vector_engine/types.rs +++ b/server-rs/crates/platform-image/src/vector_engine/types.rs @@ -29,3 +29,15 @@ 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, + pub background: Option, + pub output_format: Option, + pub width: u32, + pub height: u32, + pub mask: Option, +}