统一 GPT 图片尺寸计费阈值

提取共享的 1536 长边 tier 常量并复用到 raw 与编辑器计费。

同步 Raw 图片代理技术合同中的阈值来源。
This commit is contained in:
2026-09-11 17:47:31 +08:00
parent 0cebc39b6a
commit 99f4961c5f
5 changed files with 49 additions and 9 deletions
@@ -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` 流水。
@@ -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::<u32>() 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)> {
+32 -2
View File
@@ -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<u32, AppError> {
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)) {
@@ -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;
@@ -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;