Compare commits

...

29 Commits

Author SHA1 Message Date
k88936 f7edc9d0ee 区分 Raw 图片 PNG 资源限制错误
Project CI / Frontend tests (pull_request) Successful in 4m43s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Native shell tests (pull_request) Failing after 5m14s
将有效但超出解码限制的 PNG 映射为明确的 400 提示

补充超大合法 PNG 的回归测试
2026-09-09 11:04:44 +08:00
k88936 f60771dd00 提前检查 Raw 图片请求预算
在构造 multipart 表单前校验请求 deadline

避免预算耗尽时复制输入图片并缩短耗时统计范围
2026-09-09 10:58:48 +08:00
k88936 f7cccd8592 补充 Raw 图片客户端错误上下文
为 HTTP 客户端构造失败统一保留操作上下文

补充配置错误文案回归测试
2026-09-09 10:57:04 +08:00
k88936 5ee2d940c1 修正 Raw 图片尺寸校验错误语义
尺寸校验失败改用请求参数无效语义

补充错误文案回归测试
2026-09-09 10:55:36 +08:00
k88936 c75a7725ce Merge remote-tracking branch 'origin/master' into feat/expose-image2edit
Project CI / Repository checks (pull_request) Successful in 2m51s
Project CI / Native shell tests (pull_request) Failing after 4m9s
Project CI / Backend tests (pull_request) Failing after 4m58s
Project CI / Frontend tests (pull_request) Successful in 3m46s
2026-09-09 10:42:22 +08:00
k88936 3a95715990 同步 Raw 图片编辑技术方案
Project CI / Native shell tests (pull_request) Failing after 6m36s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 4m22s
记录解码资源上限和阻塞线程策略
补充输出格式回退与成功追踪约定
2026-09-08 23:41:27 +08:00
k88936 ed86963de2 修正 Raw 图片追踪结果类型
明确输出数据集合类型
保持 API 编译检查通过
2026-09-08 23:38:25 +08:00
k88936 65e73eee02 补充 Raw 图片生成成功追踪
记录成功任务与输出数量
完善上游响应和失败阶段日志
2026-09-08 23:25:47 +08:00
k88936 6068eeda83 补充 Raw 图片编辑请求日志
记录上游响应状态与耗时
记录传输失败阶段和结构化上下文
2026-09-08 23:22:56 +08:00
k88936 0e244c64ea 统一 GPT Image 2 尺寸常量
集中维护像素边界和对齐规则
供生成与编辑路径共同引用
2026-09-08 22:11:35 +08:00
k88936 ad7c059603 遵守 Raw 图片请求截止时间
发送前裁剪有效超时预算
预算耗尽时提前返回结构化错误
2026-09-08 21:54:22 +08:00
k88936 55d2a393d1 完善 Raw 图片尺寸错误类型
为公开错误实现 Error trait
支持通用错误链处理
2026-09-08 21:52:15 +08:00
k88936 e7f66ce950 修正 Raw 图片传输失败阶段标记
区分请求发送和响应读取失败
让错误详情准确反映故障阶段
2026-09-08 20:03:13 +08:00
k88936 35dcfea3ef 修正 Raw 图片输出格式回退
忽略空值后再使用载荷级格式
补充空字符串和 null 回归测试
2026-09-08 19:56:41 +08:00
k88936 cea8bf77fa 减少 Raw 图片编辑请求拷贝
移动 multipart 选项字段而非克隆
降低掩码和字符串的峰值内存
2026-09-08 19:52:13 +08:00
k88936 0136bfe564 移出 Raw 图片同步解码
将请求预校验放入阻塞线程
避免占用 Tokio 异步工作线程
2026-09-08 19:48:58 +08:00
k88936 22785b4d7b 限制 Raw 图片解码资源
为 PNG 解码设置边长和分配上限
避免压缩图片膨胀导致内存耗尽
2026-09-08 19:45:02 +08:00
k88936 a86a2ee4d2 简化 Raw 图片编辑响应类型
移除无必要的 JSON Value 转换
保持响应结构由类型系统校验
2026-09-08 19:32:55 +08:00
k88936 b22eae2726 收紧 Raw 图片编辑输入契约
Project CI / Frontend tests (pull_request) Successful in 3m51s
Project CI / Native shell tests (pull_request) Successful in 20m20s
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Backend tests (pull_request) Failing after 14s
新增 3840 边长、16 像素对齐、3:1 比例和总像素范围校验

要求 image 与 mask 为可完整解码的 PNG 并在扣费前拒绝非法输入

将 Raw 路由 JSON body limit 提升至 64 MiB

同步 Raw 图片编辑技术方案和边界测试
2026-09-08 18:18:58 +08:00
k88936 35d0db6377 修复 Raw 图片失败审计与响应格式
Project CI / Frontend tests (pull_request) Successful in 6m7s
Project CI / Native shell tests (pull_request) Successful in 19m41s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 9s
为 Raw provider 失败分支补齐结构化 failure audit 并接入 api-server 记录链

使用 VectorEngine 响应 output_format 生成返回图片 MIME 与扩展名

补充响应格式和审计字段定向测试并同步技术方案
2026-09-08 16:19:29 +08:00
k88936 f087ba3e2b 隔离 Raw 图片代理实现
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Frontend tests (pull_request) Successful in 36m40s
Project CI / Native shell tests (pull_request) Successful in 54m1s
移除对现有图片编辑 client 与 curl transport 的复用

将 Raw GPT Image 2 请求、multipart 构造和响应解析收敛到独立文件

保持现有 editor 图片链路代码不变
2026-09-08 13:54:31 +08:00
k88936 7fc1b841be 收口 Raw 图片 base64 校验错误
客户端仅返回稳定的 base64 格式错误提示

补充测试避免泄露解码器内部诊断细节
2026-09-08 12:30:55 +08:00
k88936 a576fdcc37 移除未使用的 multipart 请求包装函数
无选项测试直接调用统一传输入口

避免生产构建保留未使用函数告警
2026-09-08 12:29:59 +08:00
k88936 4dcf38715b 补充图片编辑选项 multipart 测试
覆盖质量、背景、输出格式与 mask 文件字段

断言文件名、MIME 类型和字节内容均正确发送
2026-09-08 12:21:11 +08:00
k88936 d1f9f4e1ef 减少图片编辑 multipart 字节复制
将已克隆的选项按值传入阻塞传输函数

构造 mask 与参考图表单时直接移动字节缓冲
2026-09-08 12:16:00 +08:00
k88936 5836aaec26 简化图片编辑 multipart 请求分支
统一通过带选项的 multipart 传输函数发送请求

移除冗余包装函数调用与未使用导入
2026-09-08 12:10:56 +08:00
k88936 18d60feb04 补充 Raw 图片参数预检查
在计费前校验质量、背景和输出格式

规范化可选参数并保持单图请求契约
2026-09-08 10:40:09 +08:00
k88936 4e1316e272 实现 Raw GPT Image 2 图片编辑代理
新增受 Bearer 保护的 /api/raw/v1/images/edit JSON 路由

按单图 image 与可选 mask 转发 GPT Image 2 参数

固定 model 与 n 并返回仅含 data[].b64_json 的响应

复用钱包计费与退款边界并补充单元测试
2026-09-08 10:36:48 +08:00
k88936 302b4addae 补充 Raw GPT Image 2 代理方案
Project CI / Native shell tests (pull_request) Successful in 55m40s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Frontend tests (pull_request) Successful in 23m43s
明确 JSON 请求与 data 数组响应

记录预检查和钱包事务边界

拆分 api-server 与 platform-image 文件职责
2026-09-07 16:33:34 +08:00
12 changed files with 1155 additions and 10 deletions
@@ -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`
+1
View File
@@ -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",
+1
View File
@@ -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 尺寸或解码资源上限"));
}
}
+6 -4
View File
@@ -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 的显式像素尺寸。