- 新增 BatchAsyncImageGenerator 节点(全并发+即时落盘,不怕中途失败丢图) - 原版 AsyncImageGenerator 移除批量提示词功能,单节点只处理单提示词 - 模型改名:限时特价→次卡,gemini→nano-banana-官方 - 异步节点过滤 官方计费 渠道,仅保留次卡和官方模型 - 单任务超时提升至900s,批量超时改为动态计算(批次数×900s) - No available channel for model 错误转化为中文友好提示 - 新增 base_async_provider / gemini_async_provider 异步客户端基类 Co-Authored-By: Claude Opus 4.6 <[email protected]>
500 lines
20 KiB
Python
500 lines
20 KiB
Python
"""
|
||
GPT Image API 客户端
|
||
支持两个接口:
|
||
- POST /v1/images/generations/ 文生图 / 图生图(gpt-image-1 / gpt-image-1.5)
|
||
- POST /v1/images/edits/ 图像编辑(带蒙版 inpainting)
|
||
|
||
设计原则:
|
||
- 与 doubao_image_client.py 保持相同的异步 + 同步双入口模式
|
||
- 图像以 multipart/form-data 方式上传(edits 接口)
|
||
- generations 接口使用 JSON 请求体,图像以 data URI base64 内联传递
|
||
- 响应支持 url 和 b64_json 两种格式,优先处理 b64_json(避免二次下载)
|
||
"""
|
||
|
||
import asyncio
|
||
import base64
|
||
import json
|
||
import time
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from io import BytesIO
|
||
from typing import List, Optional
|
||
|
||
import aiohttp
|
||
import numpy as np
|
||
import torch
|
||
from PIL import Image
|
||
|
||
from ..utils.config import get_api_key_or_raise, get_api_base_url
|
||
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64
|
||
|
||
# ── 接口端点 ──────────────────────────────────────────────────────────────────
|
||
_ENDPOINT_GENERATIONS = "/v1/images/generations/"
|
||
_ENDPOINT_EDITS = "/v1/images/edits/"
|
||
|
||
# ── 模型名映射(UI 显示名 → API 实际参数名)─────────────────────────────────
|
||
_MODEL_NAME_MAP = {
|
||
"gpt-image-2-次卡": "gpt-image-2-special",
|
||
}
|
||
|
||
# ── 超时 ──────────────────────────────────────────────────────────────────────
|
||
_REQUEST_TIMEOUT = 900 # 秒
|
||
|
||
|
||
class GptImageClient:
|
||
"""
|
||
GPT Image API 客户端
|
||
|
||
接口说明:
|
||
generations:JSON body,支持 quality / size / n / model
|
||
edits:multipart/form-data,必须包含 image(PNG),可选 mask(PNG)
|
||
|
||
两个接口的响应格式相同:
|
||
{ "data": [ {"url": "..."} | {"b64_json": "..."} ] }
|
||
"""
|
||
|
||
def __init__(self):
|
||
self.api_key = get_api_key_or_raise("O1KEY_API_KEY")
|
||
self.base_url = get_api_base_url()
|
||
|
||
# ── 认证头 ────────────────────────────────────────────────────────────────
|
||
|
||
def _auth_headers(self) -> dict:
|
||
return {"Authorization": f"Bearer {self.api_key}"}
|
||
|
||
def _json_headers(self) -> dict:
|
||
return {
|
||
"Authorization": f"Bearer {self.api_key}",
|
||
"Content-Type": "application/json",
|
||
}
|
||
|
||
# ── 图像转换工具 ──────────────────────────────────────────────────────────
|
||
|
||
# ── 请求体大小限制 ────────────────────────────────────────────────────────
|
||
_MAX_BODY_BYTES = 20 * 1024 * 1024 # 20 MB
|
||
|
||
@staticmethod
|
||
def _shrink_png_to_limit(png_bytes: bytes, max_bytes: int, label: str = "") -> bytes:
|
||
"""
|
||
若 PNG bytes 超过 max_bytes,按等比缩放反复压缩直到满足限制。
|
||
每次将面积缩小至约 80%(线性尺寸缩小至约 89.4%)。
|
||
"""
|
||
if len(png_bytes) <= max_bytes:
|
||
return png_bytes
|
||
|
||
img = Image.open(BytesIO(png_bytes))
|
||
w, h = img.size
|
||
original_size = len(png_bytes)
|
||
step = 0
|
||
|
||
while len(png_bytes) > max_bytes:
|
||
scale = 0.894 # sqrt(0.8),面积缩小 20%
|
||
w = max(1, int(w * scale))
|
||
h = max(1, int(h * scale))
|
||
img = img.resize((w, h), Image.LANCZOS)
|
||
buf = BytesIO()
|
||
img.save(buf, format="PNG")
|
||
png_bytes = buf.getvalue()
|
||
step += 1
|
||
|
||
tag = f" ({label})" if label else ""
|
||
print(
|
||
f"[o1key GPT Image] 图像{tag}超出 {max_bytes // (1024*1024)}MB 限制,"
|
||
f"已等比缩放 {step} 次:{original_size // 1024}KB → {len(png_bytes) // 1024}KB "
|
||
f"({w}×{h})"
|
||
)
|
||
return png_bytes
|
||
|
||
@staticmethod
|
||
def _tensor_to_png_bytes(tensor: torch.Tensor) -> bytes:
|
||
"""
|
||
单张 ComfyUI IMAGE tensor [1, H, W, C] 或 [H, W, C] → PNG bytes
|
||
"""
|
||
if tensor.dim() == 4:
|
||
tensor = tensor.squeeze(0) # [H, W, C]
|
||
arr = (tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
|
||
img = Image.fromarray(arr)
|
||
buf = BytesIO()
|
||
img.save(buf, format="PNG")
|
||
return buf.getvalue()
|
||
|
||
@staticmethod
|
||
def _mask_tensor_to_rgba_png_bytes(mask: torch.Tensor, image_size: tuple) -> bytes:
|
||
"""
|
||
ComfyUI MASK tensor [1, H, W] 或 [H, W] → RGBA PNG bytes
|
||
白色区域(mask=1)→ 透明(alpha=0),即 API 将在此处生成新内容。
|
||
"""
|
||
if mask.dim() == 3:
|
||
mask = mask.squeeze(0) # [H, W]
|
||
|
||
h, w = mask.shape
|
||
ih, iw = image_size
|
||
|
||
# 尺寸不一致时给出提示(API 侧也会报错)
|
||
if (h, w) != (ih, iw):
|
||
raise ValueError(
|
||
f"蒙版尺寸 ({h}×{w}) 与图像尺寸 ({ih}×{iw}) 不一致,请保持相同尺寸"
|
||
)
|
||
|
||
alpha = ((1.0 - mask.cpu().numpy()) * 255).clip(0, 255).astype(np.uint8)
|
||
rgba = np.zeros((h, w, 4), dtype=np.uint8)
|
||
rgba[:, :, 3] = alpha # 只设 alpha,RGB 全 0
|
||
|
||
buf = BytesIO()
|
||
Image.fromarray(rgba, mode="RGBA").save(buf, format="PNG")
|
||
return buf.getvalue()
|
||
|
||
@staticmethod
|
||
def _pil_list_to_tensor(images: List[Image.Image]) -> torch.Tensor:
|
||
"""
|
||
PIL Image 列表 → ComfyUI IMAGE tensor [B, H, W, C],值域 [0, 1]
|
||
RGBA 自动转换为 RGBA(保留透明通道)
|
||
"""
|
||
if not images:
|
||
placeholder = Image.new("RGBA", (512, 512), (128, 128, 128, 255))
|
||
images = [placeholder]
|
||
|
||
tensors = []
|
||
for img in images:
|
||
arr = np.array(img.convert("RGBA")).astype(np.float32) / 255.0
|
||
tensors.append(torch.from_numpy(arr))
|
||
|
||
return torch.stack(tensors, dim=0) # [B, H, W, 4]
|
||
|
||
# ── 响应解析(通用) ─────────────────────────────────────────────────────
|
||
|
||
async def _parse_response(
|
||
self,
|
||
resp_json: dict,
|
||
session: aiohttp.ClientSession,
|
||
) -> List[Image.Image]:
|
||
"""
|
||
解析 data 列表,优先取 b64_json,回退到 url 下载
|
||
"""
|
||
if "error" in resp_json:
|
||
err = resp_json["error"]
|
||
msg = (
|
||
err.get("message") or err.get("msg") or json.dumps(err, ensure_ascii=False)
|
||
if isinstance(err, dict)
|
||
else str(err)
|
||
)
|
||
raise RuntimeError(f"API 返回错误: {msg}")
|
||
|
||
data_list = resp_json.get("data")
|
||
if not data_list:
|
||
raise RuntimeError(
|
||
f"API 响应中未找到 data 字段,完整响应:\n"
|
||
f"{json.dumps(resp_json, ensure_ascii=False, indent=2)}"
|
||
)
|
||
|
||
images: List[Image.Image] = []
|
||
for idx, item in enumerate(data_list):
|
||
b64 = item.get("b64_json", "")
|
||
url = item.get("url", "")
|
||
|
||
if b64:
|
||
# 优先 base64(无需二次下载)
|
||
try:
|
||
img_bytes = base64.b64decode(b64)
|
||
img = Image.open(BytesIO(img_bytes))
|
||
images.append(img)
|
||
print(f"[o1key GPT Image] 第 {idx + 1} 张 base64 解码完成 "
|
||
f"({img.size[0]}×{img.size[1]})")
|
||
except Exception as e:
|
||
raise RuntimeError(f"第 {idx + 1} 张 base64 解码失败: {e}")
|
||
|
||
elif url and url.startswith("http"):
|
||
# 回退:下载 URL
|
||
async with session.get(url, allow_redirects=True) as r:
|
||
if r.status != 200:
|
||
raise RuntimeError(
|
||
f"图像下载失败 HTTP {r.status},URL: {url}"
|
||
)
|
||
img_bytes = await r.read()
|
||
img = Image.open(BytesIO(img_bytes))
|
||
images.append(img)
|
||
print(f"[o1key GPT Image] 第 {idx + 1} 张下载完成 "
|
||
f"({img.size[0]}×{img.size[1]})")
|
||
else:
|
||
print(f"[o1key GPT Image] 警告:第 {idx + 1} 条数据既无 b64_json 也无 url,已跳过")
|
||
|
||
return images
|
||
|
||
# ── 文生图 / 图生图(generations 接口)───────────────────────────────────
|
||
|
||
async def _generate_async(
|
||
self,
|
||
prompt: str,
|
||
model: str,
|
||
quality: str,
|
||
size: str,
|
||
n: int,
|
||
seed: int,
|
||
image_list: Optional[List[torch.Tensor]] = None,
|
||
) -> List[Image.Image]:
|
||
"""
|
||
调用 /v1/images/generations/ 接口。
|
||
当传入 image_list 时,以 data URI 格式内联图像(图生图)。
|
||
"""
|
||
# 模型名映射:UI 显示名 → API 参数名
|
||
api_model = _MODEL_NAME_MAP.get(model, model)
|
||
|
||
body: dict = {
|
||
"model": api_model,
|
||
"prompt": prompt,
|
||
"quality": quality,
|
||
"n": n,
|
||
"moderation": "low",
|
||
}
|
||
|
||
body["size"] = size if size else "auto"
|
||
|
||
# 图生图:将 tensor 列表转成 data URI 内联
|
||
if image_list is not None:
|
||
data_urls = []
|
||
for idx_img, img_tensor in enumerate(image_list):
|
||
pil_images = tensor_to_pil(img_tensor)
|
||
img = pil_images[0]
|
||
buf = BytesIO()
|
||
img.save(buf, format="PNG")
|
||
png_bytes = buf.getvalue()
|
||
# 单张图像预算:20MB 按图数平摊,至少保留 1MB 给其他字段
|
||
per_image_budget = max(
|
||
1024 * 1024,
|
||
(self._MAX_BODY_BYTES - 1024 * 1024) // len(image_list),
|
||
)
|
||
# base64 膨胀约 4/3,所以 PNG 目标上限 = budget * 3/4
|
||
png_budget = int(per_image_budget * 3 / 4)
|
||
label = f"第{idx_img + 1}张" if len(image_list) > 1 else ""
|
||
png_bytes = self._shrink_png_to_limit(png_bytes, png_budget, label)
|
||
b64 = base64.b64encode(png_bytes).decode("utf-8")
|
||
data_urls.append(f"data:image/png;base64,{b64}")
|
||
body["image"] = data_urls[0] if len(data_urls) == 1 else data_urls
|
||
mode = f"图生图(参考图 {len(data_urls)} 张)"
|
||
else:
|
||
mode = "文生图"
|
||
|
||
url = f"{self.base_url}{_ENDPOINT_GENERATIONS}"
|
||
print(f"[o1key GPT Image] {mode} | 模型={model} | quality={quality} | "
|
||
f"size={size} | n={n}")
|
||
|
||
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
|
||
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||
|
||
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
||
t0 = time.time()
|
||
async with session.post(url, json=body, headers=self._json_headers()) as resp:
|
||
elapsed = time.time() - t0
|
||
text = await resp.text()
|
||
|
||
if resp.status != 200:
|
||
try:
|
||
err_json = json.loads(text)
|
||
err_obj = err_json.get("error", {})
|
||
msg = (
|
||
err_obj.get("message") or err_obj.get("msg") or text
|
||
if isinstance(err_obj, dict)
|
||
else str(err_obj) or text
|
||
)
|
||
except Exception:
|
||
msg = text
|
||
raise RuntimeError(f"请求失败 HTTP {resp.status}: {msg}")
|
||
|
||
try:
|
||
resp_json = json.loads(text)
|
||
except Exception:
|
||
raise RuntimeError(f"响应 JSON 解析失败,原始内容:{text[:500]}")
|
||
|
||
print(f"[o1key GPT Image] API 响应耗时 {elapsed:.1f}s")
|
||
return await self._parse_response(resp_json, session)
|
||
|
||
# ── 图像编辑(edits 接口,multipart/form-data)──────────────────────────
|
||
|
||
async def _edit_async(
|
||
self,
|
||
prompt: str,
|
||
model: str,
|
||
quality: str,
|
||
size: str,
|
||
n: int,
|
||
seed: int,
|
||
image_list: List[torch.Tensor],
|
||
mask_tensor: Optional[torch.Tensor] = None,
|
||
) -> List[Image.Image]:
|
||
"""
|
||
调用 /v1/images/edits/ 接口(multipart/form-data)。
|
||
"""
|
||
# 模型名映射:UI 显示名 → API 参数名
|
||
api_model = _MODEL_NAME_MAP.get(model, model)
|
||
|
||
# 统一 tensors 为 [1,H,W,C] 格式,支持不同尺寸
|
||
normalized_tensors = []
|
||
for t in image_list:
|
||
if t.dim() == 3:
|
||
t = t.unsqueeze(0) # [H,W,C] → [1,H,W,C]
|
||
normalized_tensors.append(t)
|
||
num_images = len(normalized_tensors)
|
||
|
||
form = aiohttp.FormData()
|
||
form.add_field("model", api_model)
|
||
form.add_field("prompt", prompt)
|
||
form.add_field("n", str(n))
|
||
form.add_field("quality", quality)
|
||
|
||
form.add_field("size", size if size else "auto")
|
||
|
||
# 多图:用 image[] 数组字段逐张附加,支持 gpt-image-1.5 最多 16 张
|
||
# 预算:20MB 按图数平摊,蒙版预留 1MB
|
||
mask_reserve = 1024 * 1024 if mask_tensor is not None else 0
|
||
per_image_budget = max(
|
||
1024 * 1024,
|
||
(self._MAX_BODY_BYTES - mask_reserve) // num_images,
|
||
)
|
||
for i, frame in enumerate(normalized_tensors):
|
||
img_bytes = self._tensor_to_png_bytes(frame)
|
||
label = f"第{i + 1}张" if num_images > 1 else ""
|
||
img_bytes = self._shrink_png_to_limit(img_bytes, per_image_budget, label)
|
||
form.add_field(
|
||
"image[]",
|
||
img_bytes,
|
||
filename=f"image_{i}.png",
|
||
content_type="image/png",
|
||
)
|
||
|
||
# 蒙版尺寸校验以第一张图为基准
|
||
first_tensor = normalized_tensors[0]
|
||
ih, iw = first_tensor.shape[1], first_tensor.shape[2]
|
||
|
||
if mask_tensor is not None:
|
||
mask_png = self._mask_tensor_to_rgba_png_bytes(mask_tensor, (ih, iw))
|
||
form.add_field(
|
||
"mask",
|
||
mask_png,
|
||
filename="mask.png",
|
||
content_type="image/png",
|
||
)
|
||
mode = "图像编辑(带蒙版)"
|
||
else:
|
||
mode = "图像编辑(无蒙版)"
|
||
|
||
url = f"{self.base_url}{_ENDPOINT_EDITS}"
|
||
print(f"[o1key GPT Image] {mode} | 模型={model} | 参考图={num_images}张 | "
|
||
f"quality={quality} | size={size} | n={n}")
|
||
|
||
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
|
||
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||
|
||
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
||
t0 = time.time()
|
||
async with session.post(
|
||
url,
|
||
data=form,
|
||
headers=self._auth_headers(),
|
||
) as resp:
|
||
elapsed = time.time() - t0
|
||
text = await resp.text()
|
||
|
||
if resp.status != 200:
|
||
try:
|
||
err_json = json.loads(text)
|
||
err_obj = err_json.get("error", {})
|
||
msg = (
|
||
err_obj.get("message") or err_obj.get("msg") or text
|
||
if isinstance(err_obj, dict)
|
||
else str(err_obj) or text
|
||
)
|
||
except Exception:
|
||
msg = text
|
||
raise RuntimeError(f"请求失败 HTTP {resp.status}: {msg}")
|
||
|
||
try:
|
||
resp_json = json.loads(text)
|
||
except Exception:
|
||
raise RuntimeError(f"响应 JSON 解析失败,原始内容:{text[:500]}")
|
||
|
||
print(f"[o1key GPT Image] API 响应耗时 {elapsed:.1f}s")
|
||
return await self._parse_response(resp_json, session)
|
||
|
||
# ── 同步统一入口(供节点调用)────────────────────────────────────────────
|
||
|
||
def run_sync(
|
||
self,
|
||
prompt: str,
|
||
model: str,
|
||
quality: str,
|
||
size: str,
|
||
n: int,
|
||
seed: int,
|
||
image_tensor: Optional[List[torch.Tensor]] = None,
|
||
mask_tensor: Optional[torch.Tensor] = None,
|
||
) -> List[Image.Image]:
|
||
"""
|
||
同步入口,在独立线程中运行事件循环,避免与 ComfyUI 主循环冲突。
|
||
|
||
路由逻辑:
|
||
- 无 image_tensor → generations 接口(文生图,JSON body)
|
||
- 有 image_tensor → edits 接口(图生图/编辑,multipart/form-data)
|
||
"""
|
||
use_edits = (image_tensor is not None)
|
||
|
||
if use_edits:
|
||
coro = self._edit_async(
|
||
prompt=prompt, model=model, quality=quality,
|
||
size=size, n=n, seed=seed,
|
||
image_list=image_tensor, mask_tensor=mask_tensor,
|
||
)
|
||
else:
|
||
coro = self._generate_async(
|
||
prompt=prompt, model=model, quality=quality,
|
||
size=size, n=n, seed=seed,
|
||
image_list=image_tensor,
|
||
)
|
||
|
||
def _run():
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
try:
|
||
return loop.run_until_complete(coro)
|
||
finally:
|
||
loop.close()
|
||
|
||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||
future = executor.submit(_run)
|
||
try:
|
||
return future.result(timeout=_REQUEST_TIMEOUT + 30)
|
||
except TimeoutError:
|
||
raise RuntimeError(
|
||
f"o1key GPT Image 请求超时(>{_REQUEST_TIMEOUT}s),请检查网络或稍后重试"
|
||
)
|
||
|
||
# ── 余额查询 ──────────────────────────────────────────────────────────────
|
||
|
||
async def _query_balance_async(self) -> dict:
|
||
url = f"{self.base_url}/api/usage/token"
|
||
connector = aiohttp.TCPConnector(ssl=False, force_close=True)
|
||
timeout = aiohttp.ClientTimeout(total=10)
|
||
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
||
async with session.get(url, headers=self._auth_headers()) as resp:
|
||
if resp.status != 200:
|
||
raise RuntimeError(f"余额查询失败 HTTP {resp.status}")
|
||
return await resp.json()
|
||
|
||
def query_balance_sync(self) -> dict:
|
||
def _run():
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
try:
|
||
return loop.run_until_complete(self._query_balance_async())
|
||
finally:
|
||
loop.close()
|
||
|
||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||
return executor.submit(_run).result(timeout=15)
|
||
|
||
@staticmethod
|
||
def format_balance_info(balance_data: dict) -> str:
|
||
data = balance_data.get("data", {})
|
||
api_name = data.get("name", "未知")
|
||
total_available = data.get("total_available", 0)
|
||
balance_in_dollars = total_available / 500000
|
||
return f"当前余额:{balance_in_dollars:.2f} | API:{api_name}"
|