From 99f4961c5faef70e1895d1f49c54f982b1937694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 11 Sep 2026 17:47:31 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=20GPT=20=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=B0=BA=E5=AF=B8=E8=AE=A1=E8=B4=B9=E9=98=88=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提取共享的 1536 长边 tier 常量并复用到 raw 与编辑器计费。 同步 Raw 图片代理技术合同中的阈值来源。 --- ...案】Raw GPT Image 2图片编辑代理-2026-09-07.md | 4 +-- .../crates/api-server/src/editor_project.rs | 14 ++++++-- server-rs/crates/api-server/src/raw_image.rs | 34 +++++++++++++++++-- .../src/vector_engine/constants.rs | 2 ++ .../platform-image/src/vector_engine/mod.rs | 4 +-- 5 files changed, 49 insertions(+), 9 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 b37b8502c..ff244f984 100644 --- a/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md +++ b/docs/technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md @@ -38,7 +38,7 @@ height: 1024 校验通过后按整数尺寸发送给 provider,不静默 clamp 或改写调用者尺寸。 -Raw 路由的 multipart body limit 为 `64 MiB`,覆盖图片和文本字段;图片字段由 multipart 解析器收集为可共享字节缓冲,文本字段按 chunk 流式读取并在达到 `16 KiB` 时立即拒绝,随后在阻塞线程中完成 PNG 解码。PNG 解码使用与输出合同一致的资源上限:宽高各不超过 `3840`,解码分配不超过 `8294400 × 4` 字节;不再执行 base64 入站解码。 +Raw 路由的 multipart body limit 为 `64 MiB`,覆盖图片和文本字段;图片字段由 multipart 解析器收集为可共享字节缓冲,文本字段按 chunk 流式读取并在达到 `16 KiB` 时立即拒绝,随后在阻塞线程中完成 PNG 解码。PNG 仅接受 8-bit/channel(`png::BitDepth::Eight`),其它位深在解码前以 400 返回“`{field} 必须为 8-bit PNG(每通道 8 位)`”;PNG 解码使用与输出合同一致的资源上限:宽高各不超过 `3840`,解码分配不超过 `8294400 × 4` 字节;不再执行 base64 入站解码。 服务端发送给 `platform-image` 时固定注入: @@ -72,7 +72,7 @@ response_format = b64_json 检查通过后,api-server 进入现有资产操作计费边界,通过 SpacetimeDB 钱包事务 procedure 原子完成: -1. 按现有图片编辑算法解析价格:GPT Image 2 长边不超过 1536 使用 1K 价格,否则使用 2K 价格;当前默认价格为 3 / 5 泥点; +1. 按共享常量 `platform-image::GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD`(当前值 `1536`)解析图片价格:长边不超过阈值使用 1K 价格,否则使用 2K 价格;当前默认价格为 3 / 5 泥点; 2. 以认证后的用户、`raw-image-edit` 命名空间和请求 ID 组成幂等扣费流水 ID; 3. 原子扣除用户泥点并写入 `asset_operation_consume` 流水。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index b0663a4b1..0e89d6be9 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -19,7 +19,7 @@ use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, AssetObjectUpsertInput, build_asset_object_upsert_input, generate_asset_object_id, }; -use platform_image::DownloadedImage; +use platform_image::{DownloadedImage, GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD}; use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssSignedGetObjectUrlRequest, }; @@ -3841,7 +3841,11 @@ fn editor_image_price_size_from_pixels(size: &str) -> &'static str { let Ok(height) = height.parse::() else { return "1K"; }; - if width.max(height) > 1536 { "2K" } else { "1K" } + if width.max(height) > GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD { + "2K" + } else { + "1K" + } } fn editor_image_edit_uses_nanobanana_generate_content(model: &str) -> bool { @@ -3890,7 +3894,11 @@ fn infer_editor_image_edit_size_tier(model: &str, size: Option<&str>) -> Option< if model == EDITOR_IMAGE_MODEL_NANOBANANA2 && long_edge <= 768 { return Some("0.5K"); } - Some(if long_edge > 1536 { "2K" } else { "1K" }) + Some(if long_edge > GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD { + "2K" + } else { + "1K" + }) } fn parse_editor_image_edit_pixel_size(size: &str) -> Option<(u32, u32)> { diff --git a/server-rs/crates/api-server/src/raw_image.rs b/server-rs/crates/api-server/src/raw_image.rs index 7bde75405..29541924d 100644 --- a/server-rs/crates/api-server/src/raw_image.rs +++ b/server-rs/crates/api-server/src/raw_image.rs @@ -6,7 +6,8 @@ use axum::{ use bytes::Bytes; use image::{GenericImageView, ImageFormat, ImageReader}; use platform_image::{ - RAW_IMAGE_MAX_EDGE, RAW_IMAGE_MAX_PIXELS, RawImageEditOptions, ReferenceImage, + GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, RAW_IMAGE_MAX_EDGE, RAW_IMAGE_MAX_PIXELS, + RawImageEditOptions, ReferenceImage, create_vector_engine_raw_image_edit, validate_raw_image_edit_dimensions, }; use serde::Serialize; @@ -380,6 +381,15 @@ fn decode_image(value: RawImageData, field: &str) -> Result<(ReferenceImage, u32 if reader.format() != Some(ImageFormat::Png) { return Err(bad_request(format!("{field} 文件必须是有效 PNG 文件"))); } + let png_decoder = png::Decoder::new(Cursor::new(bytes.as_ref())); + let png_reader = png_decoder + .read_info() + .map_err(|_| bad_request(format!("{field} 文件必须是有效 PNG 文件")))?; + if png_reader.info().bit_depth != png::BitDepth::Eight { + return Err(bad_request(format!( + "{field} 必须为 8-bit PNG(每通道 8 位)" + ))); + } let decoded = reader .decode() .map_err(|error| map_decode_image_error(field, error))?; @@ -406,7 +416,11 @@ fn map_decode_image_error(field: &str, error: image::ImageError) -> AppError { } async fn raw_image_edit_price(state: &AppState, width: u32, height: u32) -> Result { - let tier = if width.max(height) > 1536 { "2K" } else { "1K" }; + let tier = if width.max(height) > GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD { + "2K" + } else { + "1K" + }; state .editor_generation_pricing() .await @@ -561,6 +575,22 @@ mod tests { assert!(prepare_request(invalid_mime).is_err()); } + #[test] + fn input_rejects_non_eight_bit_png() { + let mut bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut bytes, 1, 1); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Sixteen); + let mut writer = encoder.write_header().expect("PNG header"); + writer + .write_image_data(&[0, 0, 0, 0, 0, 0, 0, 0]) + .expect("PNG body"); + } + let error = prepare_request(request(bytes, None)).expect_err("16-bit PNG should fail"); + assert!(format!("{error:?}").contains("image 必须为 8-bit PNG(每通道 8 位)")); + } + #[test] fn oversized_valid_png_reports_resource_limit() { let error = match prepare_request(request(png_bytes(RAW_IMAGE_MAX_EDGE + 1, 1), None)) { diff --git a/server-rs/crates/platform-image/src/vector_engine/constants.rs b/server-rs/crates/platform-image/src/vector_engine/constants.rs index dfb70f0f5..87572ca73 100644 --- a/server-rs/crates/platform-image/src/vector_engine/constants.rs +++ b/server-rs/crates/platform-image/src/vector_engine/constants.rs @@ -5,6 +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; +/// GPT-Image-2 pricing uses 1K through this inclusive long-edge threshold. +pub const GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD: u32 = 1536; pub(crate) const GPT_IMAGE_2_MIN_PIXELS: u64 = 655_360; pub(crate) const GPT_IMAGE_2_MAX_PIXELS: u64 = 8_294_400; 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 351244650..55a8e572e 100644 --- a/server-rs/crates/platform-image/src/vector_engine/mod.rs +++ b/server-rs/crates/platform-image/src/vector_engine/mod.rs @@ -21,8 +21,8 @@ pub use client::{ create_vector_engine_nanobanana_generate_content, }; pub use constants::{ - GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, NANOBANANA_2_MODEL, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, - VECTOR_ENGINE_PROVIDER, + GPT_IMAGE_2_2K_LONG_EDGE_THRESHOLD, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, + NANOBANANA_2_MODEL, VECTOR_ENGINE_GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER, }; pub use error::{PlatformImageError, PlatformImageStatusHint}; pub use image_source::download_remote_image;