"""gpt-image-2 的尺寸与参数约束。 与火山那边不同,OpenAI 的图片接口不用「分辨率 + 宽高比」两个字段,而是一个 `WIDTHxHEIGHT` 字符串,并对它施加几条**算术约束**。逐条取自官方文档: - 宽高**都必须能被 16 整除** - 宽高比必须在 `1:3` 到 `3:1` 之间 - 最大支持 `3840x2160` - 超过 `2560x1440` 属**实验性** - 另有「模型当前的像素与边长限制」——文档未给具体数值,故无法在本地校验 在本地先校验这些,是因为违反约束的请求会被 OpenAI 以 400 拒绝,而那要花一次 往返、计入配额,并表现为一个失败的任务。错误信息还能直接指出违反了哪一条。 **标准尺寸**(`1024x1024` / `1536x1024` / `1024x1536`)与 `auto` 一律放行, 不做算术校验:它们是文档明示支持的取值,其中 `auto` 由模型自行决定。 """ from __future__ import annotations import logging import re from dataclasses import dataclass log = logging.getLogger(__name__) #: 文档明示的标准尺寸,直接放行。 STANDARD_SIZES = frozenset({"1024x1024", "1536x1024", "1024x1536"}) #: 交给模型自选尺寸。 AUTO_SIZE = "auto" #: `WIDTHxHEIGHT`。 _SIZE_PATTERN = re.compile(r"^(\d+)x(\d+)$") #: 宽高必须同时是它的倍数。 EDGE_MULTIPLE = 16 #: 宽高比的允许区间(宽/高),即 1:3 ~ 3:1。 MIN_ASPECT = 1 / 3 MAX_ASPECT = 3.0 #: 最大支持尺寸,文档原文是「the maximum supported resolution is 3840x2160」。 #: #: ⚠️ **这里有一处文档未明确、由我们推断的语义**:那句话没说清限制是「逐边」 #: (宽≤3840 且 高≤2160)还是「按总像素」。按逐边理解会**禁掉所有高于 2160 的 #: 竖图**,而 `1024x1536` 是文档明示的标准竖图尺寸、竖版显然受支持——故逐边理解 #: 必然过严。 #: #: 采用的读法:**最长边 ≤ 3840,且总像素 ≤ 3840×2160**。它允许 `2160x3840` #: 这类竖图(与横图同像素量),又拦住明显越界的尺寸。若日后拿到更明确的文档或 #: 实测结果,回来校正本处并更新取证页。 MAX_EDGE = 3840 MAX_PIXELS = 3840 * 2160 #: 超过此面积属实验性——放行但记一条日志,便于把偶发的异常表现与它关联起来。 #: 同样按面积而非逐边理解,理由同上。 EXPERIMENTAL_ABOVE_PIXELS = 2560 * 1440 #: 可选的画质档。xhigh 与 max 仅 2.5 系列支持,gpt-image-2 不含。 QUALITIES_GPT_IMAGE_2 = frozenset({"low", "medium", "high", "auto"}) #: 输出格式。注意 transparent 背景要求 png 或 webp。 OUTPUT_FORMATS = frozenset({"png", "jpeg", "webp"}) @dataclass(frozen=True) class Size: width: int height: int def __str__(self) -> str: return f"{self.width}x{self.height}" def parse_size(value: str) -> Size | None: """把 `WIDTHxHEIGHT` 解析成尺寸;标准尺寸与 `auto` 之外的非法串返回 None。""" match = _SIZE_PATTERN.match(value.strip()) if not match: return None return Size(int(match.group(1)), int(match.group(2))) def validate_size(value: str | None) -> None: """校验尺寸,不合法则抛 `ValueError`(消息指明违反了哪一条约束)。 `None`、`auto`、标准尺寸一律放行。 """ if not value or value == AUTO_SIZE or value in STANDARD_SIZES: return size = parse_size(value) if size is None: raise ValueError( f"size 形态非法:{value!r},应为 'WIDTHxHEIGHT'、'auto' " f"或标准尺寸 {sorted(STANDARD_SIZES)}" ) if size.width % EDGE_MULTIPLE or size.height % EDGE_MULTIPLE: raise ValueError(f"size {size} 的宽高必须都能被 {EDGE_MULTIPLE} 整除") # 宽高比先判:它是文档最明确的一条,且比尺寸上限更常被违反(比例写错比 # 尺寸写大更容易发生),先报它的错误信息更有用。 aspect = size.width / size.height if not MIN_ASPECT <= aspect <= MAX_ASPECT: raise ValueError(f"size {size} 的宽高比 {aspect:.2f} 超出允许区间 1:3 ~ 3:1") if max(size.width, size.height) > MAX_EDGE: raise ValueError(f"size {size} 的最长边超出上限 {MAX_EDGE}") pixels = size.width * size.height if pixels > MAX_PIXELS: raise ValueError( f"size {size} 的总像素 {pixels} 超出上限 {MAX_PIXELS}" f"(即 3840x2160 的像素量)" ) if pixels > EXPERIMENTAL_ABOVE_PIXELS: # 放行但记一条:文档称实验性,出问题时这条日志能把现象与它关联起来。 log.info("size %s 超过 2560x1440,属服务商标注的实验性范围", size) def validate_quality(value: str | None) -> None: """校验画质档。 只覆盖 gpt-image-2:`xhigh`/`max` 是 2.5 系列才支持的档,传给 gpt-image-2 会被拒。不按模型名分表是因为目前只接入一个图片模型——接入第二个时再分。 """ if value and value not in QUALITIES_GPT_IMAGE_2: raise ValueError( f"quality {value!r} 不被 gpt-image-2 支持," f"可选:{sorted(QUALITIES_GPT_IMAGE_2)}" f"(xhigh / max 仅 gpt-image-2.5 系列支持)" ) def validate_output_format(value: str | None, *, transparent: bool = False) -> None: """校验输出格式。 :param transparent: 背景为透明时**只能** png 或 webp——jpeg 没有 alpha 通道, 指定它会得到一张背景被填成不透明的图,而那不会报错、只会静默不符合预期。 """ if value and value not in OUTPUT_FORMATS: raise ValueError( f"output_format {value!r} 非法,可选:{sorted(OUTPUT_FORMATS)}" ) if transparent and value == "jpeg": raise ValueError( "背景为 transparent 时 output_format 不能是 jpeg(无 alpha 通道)," "请用 png 或 webp" )