Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7edc9d0ee | |||
| f60771dd00 | |||
| f7cccd8592 | |||
| 5ee2d940c1 | |||
| c75a7725ce | |||
| 3a95715990 | |||
| ed86963de2 | |||
| 65e73eee02 | |||
| 6068eeda83 | |||
| 0e244c64ea | |||
| ad7c059603 | |||
| 55d2a393d1 | |||
| e7f66ce950 | |||
| 35dcfea3ef | |||
| cea8bf77fa | |||
| 0136bfe564 | |||
| 22785b4d7b | |||
| a86a2ee4d2 | |||
| b22eae2726 | |||
| 35d0db6377 | |||
| f087ba3e2b | |||
| 7fc1b841be | |||
| a576fdcc37 | |||
| 4dcf38715b | |||
| d1f9f4e1ef | |||
| 5836aaec26 | |||
| 18d60feb04 | |||
| 4e1316e272 | |||
| 302b4addae |
@@ -0,0 +1,113 @@
|
||||
# Raw GPT Image 2 图片编辑代理
|
||||
|
||||
更新时间:`2026-09-08`
|
||||
|
||||
## 目标
|
||||
|
||||
提供一个由主站客户端调用的独立同步图片编辑代理:
|
||||
|
||||
```text
|
||||
POST /api/raw/v1/images/edit
|
||||
```
|
||||
|
||||
该入口使用登录态 Bearer access token,不进入 External v1 / MCP OpenAPI,不读取或写入画布、项目资源、素材库、OSS 结果或 `external_generation_job`。
|
||||
|
||||
## 请求合同
|
||||
|
||||
请求使用 `application/json`。图片字段只使用原始图片的 base64 数据和 MIME 类型,不接受 object key、URL、Data URL 或 Blob URL。
|
||||
|
||||
```json
|
||||
{
|
||||
"image": {
|
||||
"data": "<base64>",
|
||||
"mimeType": "image/png"
|
||||
},
|
||||
"mask": {
|
||||
"data": "<base64>",
|
||||
"mimeType": "image/png"
|
||||
},
|
||||
"prompt": "修改图片",
|
||||
"quality": "auto",
|
||||
"background": "auto",
|
||||
"output_format": "png",
|
||||
"width": 1536,
|
||||
"height": 1024
|
||||
}
|
||||
```
|
||||
|
||||
`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。PNG 解码使用与输出合同一致的资源上限:宽高各不超过 `3840`,解码分配不超过 `8294400 × 4` 字节;base64 与 PNG 解码在阻塞线程执行,不占用 Tokio 异步 worker。
|
||||
|
||||
服务端发送给 `platform-image` 时固定注入:
|
||||
|
||||
```text
|
||||
model = gpt-image-2
|
||||
n = 1
|
||||
```
|
||||
|
||||
请求不暴露 `model`、`n`、`response_format`、`style`、`user` 或 `output_compression`。
|
||||
|
||||
## 成功响应合同
|
||||
|
||||
响应始终为 JSON,响应只保留 `data` 字段,图片内容只以 base64 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"b64_json": "<base64>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`data` 保持数组形状,即使服务端固定 `n=1`。响应不重复返回请求参数,不返回 URL、资源 ID、任务 ID、provider 原始 JSON 或 editor 字段。
|
||||
|
||||
## 预检查与计费事务
|
||||
|
||||
所有请求、JSON、base64、图片结构和 provider 参数检查必须在扣费前完成。预检查失败直接返回 4xx,不产生钱包流水,也不调用 provider。
|
||||
|
||||
检查通过后,api-server 进入现有资产操作计费边界,通过 SpacetimeDB 钱包事务 procedure 原子完成:
|
||||
|
||||
1. 按现有图片编辑算法解析价格:GPT Image 2 长边不超过 1536 使用 1K 价格,否则使用 2K 价格;当前默认价格为 3 / 5 泥点;
|
||||
2. 以认证后的用户、`raw-image-edit` 命名空间和请求 ID 组成幂等扣费流水 ID;
|
||||
3. 原子扣除用户泥点并写入 `asset_operation_consume` 流水。
|
||||
|
||||
provider 调用在 SpacetimeDB 事务之外执行。失败时由现有计费边界把幂等退款事实写入 SpacetimeDB refund outbox,再由 worker 完成退款。
|
||||
|
||||
TODO:新增 raw 操作持久化状态,将“创建 raw 操作事实 + 扣费”收入同一事务,并由恢复 worker 对“已扣费但未收口”状态自动退款,填补进程在扣费后、写入 refund outbox 前崩溃的窗口。
|
||||
|
||||
raw 操作使用独立的 operation / ledger 命名空间,例如 `raw-image-edit`,不能复用编辑器资源 ID、编辑器任务 ID 或 `external_generation_job`。
|
||||
|
||||
## Provider 边界
|
||||
|
||||
`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`。
|
||||
|
||||
## 代码拆分
|
||||
|
||||
- `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/api-server/src/modules/raw.rs`:只注册 `/api/raw/v1/images/edit` 并挂载 Bearer middleware。
|
||||
|
||||
不修改 External v1 OpenAPI;不在 `external_editor_api.rs`、编辑器项目模块或外部生成 worker 中增加 raw 分支。
|
||||
|
||||
## 验收
|
||||
|
||||
- 未认证请求被 Bearer middleware 拒绝。
|
||||
- 预检查失败时钱包无扣费、provider 无请求。
|
||||
- 成功响应严格只包含 `data[].b64_json`。
|
||||
- provider 失败时 raw 操作失败事务产生可恢复退款事实。
|
||||
- raw 请求不创建 `external_generation_job`,不写 editor project/resource/asset/OSS。
|
||||
- 运行 api-server 与 platform-image 定向测试、`npm run check:encoding` 和 `git diff --check`。
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,3 +10,4 @@ pub mod internal;
|
||||
pub mod platform;
|
||||
pub mod platform_support;
|
||||
pub mod profile;
|
||||
pub mod raw;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
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))
|
||||
.layer(DefaultBodyLimit::max(RAW_IMAGE_EDIT_BODY_LIMIT_BYTES)),
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use image::{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_json::json;
|
||||
use std::io::Cursor;
|
||||
|
||||
use crate::{
|
||||
asset_billing::{
|
||||
execute_billable_asset_operation_with_cost, with_editor_generation_durable_billing_boundary,
|
||||
},
|
||||
auth::AuthenticatedAccessToken,
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
map_platform_image_error, record_openai_image_failure_if_configured,
|
||||
require_openai_image_settings,
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
tracking::record_external_generation_run_after_success,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
#[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<RawImageData>,
|
||||
pub(crate) prompt: String,
|
||||
pub(crate) quality: Option<String>,
|
||||
pub(crate) background: Option<String>,
|
||||
pub(crate) output_format: Option<String>,
|
||||
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<RawImageEditItem>,
|
||||
}
|
||||
|
||||
pub(crate) async fn edit_raw_image(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<RawImageEditRequest>,
|
||||
) -> Result<Json<RawImageEditResponse>, AppError> {
|
||||
let prepared = tokio::task::spawn_blocking(move || prepare_request(payload))
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
})??;
|
||||
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
|
||||
&request_context,
|
||||
Some(authenticated.claims().user_id().to_string()),
|
||||
None,
|
||||
);
|
||||
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 audit_settings = settings.clone();
|
||||
let tracking_state = audit_settings.external_api_audit_state.clone();
|
||||
let tracking_payload = json!({
|
||||
"width": prepared.width,
|
||||
"height": prepared.height,
|
||||
"promptChars": prepared.prompt.chars().count(),
|
||||
"hasMask": prepared.options.mask.is_some(),
|
||||
"quality": prepared.options.quality.as_deref(),
|
||||
"background": prepared.options.background.as_deref(),
|
||||
"outputFormat": prepared.options.output_format.as_deref(),
|
||||
});
|
||||
let started_at_micros = (OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64;
|
||||
let operation = async move {
|
||||
let generated = match create_vector_engine_raw_image_edit(
|
||||
&provider_settings,
|
||||
prepared.prompt.as_str(),
|
||||
&prepared.image,
|
||||
prepared.options,
|
||||
"raw_image_edit",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(generated) => generated,
|
||||
Err(error) => {
|
||||
record_openai_image_failure_if_configured(&audit_settings, &error).await;
|
||||
return Err(map_platform_image_error(error));
|
||||
}
|
||||
};
|
||||
let task_id = generated.task_id.clone();
|
||||
let data: Vec<RawImageEditItem> = generated
|
||||
.images
|
||||
.into_iter()
|
||||
.map(|image| RawImageEditItem {
|
||||
b64_json: BASE64_STANDARD.encode(image.bytes),
|
||||
})
|
||||
.collect();
|
||||
if let Some(state) = tracking_state.as_ref() {
|
||||
record_external_generation_run_after_success(
|
||||
state,
|
||||
platform_image::VECTOR_ENGINE_PROVIDER,
|
||||
"raw_image_edit",
|
||||
"raw_image_edit",
|
||||
tracking_payload,
|
||||
started_at_micros,
|
||||
true,
|
||||
None,
|
||||
Some(task_id),
|
||||
Some(json!({ "imageCount": data.len() })),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
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(result))
|
||||
}
|
||||
|
||||
struct PreparedRawImageEdit {
|
||||
image: ReferenceImage,
|
||||
prompt: String,
|
||||
options: RawImageEditOptions,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
fn prepare_request(payload: RawImageEditRequest) -> Result<PreparedRawImageEdit, AppError> {
|
||||
if payload.prompt.trim().is_empty() {
|
||||
return Err(bad_request("prompt 不能为空"));
|
||||
}
|
||||
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",
|
||||
["low", "medium", "high", "auto"],
|
||||
)?;
|
||||
validate_optional_value(
|
||||
payload.background.as_deref(),
|
||||
"background",
|
||||
["transparent", "opaque", "auto"],
|
||||
)?;
|
||||
validate_optional_value(
|
||||
payload.output_format.as_deref(),
|
||||
"output_format",
|
||||
["png", "webp", "jpeg"],
|
||||
)?;
|
||||
let quality = normalize_optional(payload.quality);
|
||||
let background = normalize_optional(payload.background);
|
||||
let output_format = normalize_optional(payload.output_format);
|
||||
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,
|
||||
background,
|
||||
output_format,
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
mask,
|
||||
},
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_optional(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn validate_optional_value<const N: usize>(
|
||||
value: Option<&str>,
|
||||
field: &str,
|
||||
allowed: [&str; N],
|
||||
) -> Result<(), AppError> {
|
||||
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(());
|
||||
};
|
||||
if allowed.contains(&value) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(bad_request(format!("{field} 值无效")))
|
||||
}
|
||||
|
||||
fn decode_image(value: RawImageData, field: &str) -> Result<ReferenceImage, 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")));
|
||||
}
|
||||
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 mut reader = ImageReader::new(Cursor::new(bytes.as_slice()))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| bad_request(format!("{field}.data 必须是有效 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 文件")));
|
||||
}
|
||||
reader
|
||||
.decode()
|
||||
.map_err(|error| map_decode_image_error(field, error))?;
|
||||
Ok(ReferenceImage {
|
||||
bytes,
|
||||
file_name: format!("{field}.png"),
|
||||
mime_type: "image/png".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
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}.data 必须是有效 PNG 文件"),
|
||||
};
|
||||
bad_request(message)
|
||||
}
|
||||
|
||||
async fn raw_image_edit_price(state: &AppState, width: u32, height: u32) -> Result<u32, AppError> {
|
||||
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<String>) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "raw-image-edit",
|
||||
"message": message.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[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": 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"));
|
||||
|
||||
let array_payload = serde_json::json!({
|
||||
"images": [{"data": "aGVsbG8=", "mimeType": "image/png"}],
|
||||
"prompt": "edit",
|
||||
"width": 1024,
|
||||
"height": 1024
|
||||
});
|
||||
assert!(serde_json::from_value::<RawImageEditRequest>(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="}]})
|
||||
);
|
||||
}
|
||||
|
||||
#[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());
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
#[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) {
|
||||
Ok(_) => panic!("oversized PNG should fail"),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
assert!(format!("{error:?}").contains("超出 PNG 尺寸或解码资源上限"));
|
||||
}
|
||||
}
|
||||
@@ -10,14 +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, ReferenceImage,
|
||||
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, download_remote_image,
|
||||
vector_engine_images_edit_url, vector_engine_images_generation_url,
|
||||
vector_engine_nanobanana_generate_content_url,
|
||||
create_vector_engine_nanobanana_generate_content, create_vector_engine_raw_image_edit,
|
||||
download_remote_image, validate_raw_image_edit_dimensions, vector_engine_images_edit_url,
|
||||
vector_engine_images_generation_url, vector_engine_nanobanana_generate_content_url,
|
||||
};
|
||||
|
||||
@@ -5,3 +5,8 @@ pub const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL;
|
||||
pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
||||
pub const VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES: usize = 5;
|
||||
pub const VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES: usize = 14;
|
||||
|
||||
pub(crate) const GPT_IMAGE_2_MIN_PIXELS: u64 = 655_360;
|
||||
pub(crate) const GPT_IMAGE_2_MAX_PIXELS: u64 = 8_294_400;
|
||||
pub(crate) const GPT_IMAGE_2_MAX_EDGE: u32 = 3_840;
|
||||
pub(crate) const GPT_IMAGE_2_DIMENSION_ALIGNMENT: u32 = 16;
|
||||
|
||||
@@ -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,11 @@ pub use constants::{
|
||||
};
|
||||
pub use error::{PlatformImageError, PlatformImageStatusHint};
|
||||
pub use image_source::download_remote_image;
|
||||
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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,10 @@
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::{
|
||||
constants::{GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL},
|
||||
constants::{
|
||||
GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_DIMENSION_ALIGNMENT, GPT_IMAGE_2_MAX_EDGE,
|
||||
GPT_IMAGE_2_MAX_PIXELS, GPT_IMAGE_2_MIN_PIXELS, GPT_IMAGE_2_MODEL,
|
||||
},
|
||||
types::{ReferenceImage, VectorEngineImageSettings},
|
||||
};
|
||||
|
||||
@@ -129,10 +132,10 @@ fn normalize_explicit_pixel_size(value: &str) -> String {
|
||||
}
|
||||
|
||||
fn clamp_gpt_image_2_pixel_size(size: &str) -> String {
|
||||
const MIN_PIXELS: u64 = 655_360;
|
||||
const MAX_PIXELS: u64 = 8_294_400;
|
||||
const MAX_EDGE: u32 = 3_840;
|
||||
const DIMENSION_ALIGNMENT: u32 = 16;
|
||||
const MIN_PIXELS: u64 = GPT_IMAGE_2_MIN_PIXELS;
|
||||
const MAX_PIXELS: u64 = GPT_IMAGE_2_MAX_PIXELS;
|
||||
const MAX_EDGE: u32 = GPT_IMAGE_2_MAX_EDGE;
|
||||
const DIMENSION_ALIGNMENT: u32 = GPT_IMAGE_2_DIMENSION_ALIGNMENT;
|
||||
const MAX_ASPECT_RATIO: f64 = 3.0;
|
||||
|
||||
// 中文注释:这里是 VectorEngine 的共享发送边界,只处理 gpt-image-2 的显式像素尺寸。
|
||||
|
||||
Reference in New Issue
Block a user