Files
2025-11-11 01:53:55 +08:00

2504 lines
111 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import time
import urllib.error
import urllib.request
import urllib.parse
import io
import re
import hashlib
import hmac
import threading
import socket
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from PIL import Image
import numpy as np
import torch
DEFAULT_SERVER_URL = "https://api.nanobananaapi.ai"
ENDPOINT_PATH = "/api/v1/nanobanana/generate"
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
)
# 豆包API相关常量
# API端点路径常量
# DOUBAO_ENDPOINT_PATH = "/images/generations" # 不再使用,用户填写完整接口地址
# 火山引擎API签名相关
VOLCENGINE_HOST = "visual.volcengineapi.com"
VOLCENGINE_REGION = "cn-north-1"
VOLCENGINE_SERVICE = "cv"
def _create_volcengine_signature(
access_key_id: str,
secret_access_key: str,
method: str,
uri: str,
query_string: str,
headers: Dict[str, str],
body: str,
timestamp: str
) -> str:
"""创建火山引擎API签名 - 基于官方示例"""
# 计算请求体哈希
body_hash = hashlib.sha256(body.encode('utf-8')).hexdigest()
# 规范化头部 - 按照官方示例的顺序
signed_headers_list = ["content-type", "host", "x-content-sha256", "x-date"]
signed_headers_str = ";".join(signed_headers_list)
# 构建规范头部字符串
canonical_headers_list = [
f"content-type:{headers.get('Content-Type', '')}",
f"host:{headers.get('Host', '')}",
f"x-content-sha256:{body_hash}",
f"x-date:{timestamp}"
]
canonical_headers_str = "\n".join(canonical_headers_list)
# 创建规范请求
canonical_request = "\n".join([
method.upper(),
uri,
query_string,
canonical_headers_str,
"", # 空行
signed_headers_str,
body_hash
])
# 计算规范请求的哈希
canonical_request_hash = hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()
# 创建待签名字符串
date_part = timestamp[:8] # YYYYMMDD
credential_scope = f"{date_part}/{VOLCENGINE_REGION}/{VOLCENGINE_SERVICE}/request"
string_to_sign = "\n".join([
"HMAC-SHA256",
timestamp,
credential_scope,
canonical_request_hash
])
# 派生签名密钥
k_date = hmac.new(secret_access_key.encode('utf-8'), date_part.encode('utf-8'), hashlib.sha256).digest()
k_region = hmac.new(k_date, VOLCENGINE_REGION.encode('utf-8'), hashlib.sha256).digest()
k_service = hmac.new(k_region, VOLCENGINE_SERVICE.encode('utf-8'), hashlib.sha256).digest()
k_signing = hmac.new(k_service, "request".encode('utf-8'), hashlib.sha256).digest()
# 计算签名
signature = hmac.new(k_signing, string_to_sign.encode('utf-8'), hashlib.sha256).hexdigest()
# 创建Authorization头
authorization = f"HMAC-SHA256 Credential={access_key_id}/{credential_scope}, SignedHeaders={signed_headers_str}, Signature={signature}"
return authorization
def _calculate_aspect_ratio(width: int, height: int) -> str:
"""计算图片的宽高比,返回最接近的NanoBanana支持的比例"""
# 验证输入参数
if width is None or height is None or width <= 0 or height <= 0:
print(f"[警告] 无效的图片尺寸 width={width}, height={height},使用默认比例 1:1")
return "1:1"
ratio = width / height
print(f"[调试] 计算宽高比: {width}/{height} = {ratio:.6f}")
# 定义NanoBanana支持的比例及其阈值
supported_ratios = {
"1:1": 1.0, # 正方形
"9:16": 9/16, # 0.5625 (竖屏手机)
"16:9": 16/9, # 1.777... (横屏宽屏)
"3:4": 3/4, # 0.75 (竖屏)
"4:3": 4/3, # 1.333... (横屏)
"3:2": 3/2, # 1.5 (横屏)
"2:3": 2/3, # 0.666... (竖屏)
"5:4": 5/4, # 1.25 (横屏)
"4:5": 4/5, # 0.8 (竖屏)
"21:9": 21/9, # 2.333... (超宽屏)
}
# 找到差值最小的比例
min_diff = float('inf')
best_ratio = "1:1" # 默认值
for ratio_name, target_ratio in supported_ratios.items():
diff = abs(ratio - target_ratio)
print(f"[调试] 比例 {ratio_name} ({target_ratio:.6f}): 差值 = {diff:.6f}")
if diff < min_diff:
min_diff = diff
best_ratio = ratio_name
print(f"[调试] 最终匹配比例: {best_ratio} (差值 = {min_diff:.6f})")
return best_ratio
def _get_image_size_with_exif(image: Image.Image) -> Tuple[int, int]:
"""获取图片的实际尺寸,考虑EXIF方向信息
当图片有EXIF方向信息(orientation)时,需要根据方向信息调整宽高。
例如:如果orientation=6(顺时针旋转90度),则实际显示时需要交换宽高。
如果没有EXIF信息或EXIF中没有orientation信息,则返回图片的默认尺寸。
Args:
image: PIL Image对象
Returns:
(width, height): 实际显示的尺寸
"""
width, height = image.size
# 检查EXIF方向信息
try:
exif = image.getexif()
orientation = exif.get(274) # EXIF标签274是Orientation
if orientation:
# orientation值说明:
# 1 = 正常(0度)- 不需要交换
# 3 = 旋转180度 - 不需要交换(尺寸不变)
# 6 = 顺时针旋转90度(需要交换宽高)
# 8 = 逆时针旋转90度(需要交换宽高)
if orientation in [6, 8]: # 需要旋转90度或270度
# 交换宽高
width, height = height, width
# 如果没有orientation信息或orientation为None/1/3,使用原始尺寸(已赋值,无需修改)
except Exception:
# 如果获取EXIF失败或图片没有EXIF信息,使用原始尺寸(已赋值,无需修改)
pass
return width, height
def _encode_url_for_request(url: str) -> str:
"""对URL进行编码处理,处理中文字符
智能判断是否需要编码:
1. 如果路径已经编码(包含%XX格式),不再次编码,避免破坏签名
2. 如果路径包含中文字符但未编码,进行编码
3. 对于签名URL(通常已编码),直接返回原URL
Args:
url: 原始URL
Returns:
编码后的URL(如果需要编码)或原URL(如果已编码或无需编码)
"""
try:
parsed = urllib.parse.urlparse(url)
# 检查路径是否已经编码(如果包含%XX格式,说明已经编码)
# 对于已经编码的URL,直接返回原URL,避免双重编码破坏签名
if '%' in parsed.path:
# 路径已经编码,直接返回原URL(特别是OSS签名URL)
return url
# 检查路径是否包含中文字符(需要编码)
# 判断是否包含非ASCII字符(包括中文)
try:
# 尝试解码路径,如果包含非ASCII字符,quote会进行编码
decoded_path = urllib.parse.unquote(parsed.path)
# 检查是否包含中文字符或其他非ASCII字符
has_non_ascii = any(ord(c) > 127 for c in decoded_path)
if has_non_ascii:
# 包含中文字符或其他非ASCII字符,需要进行编码
encoded_path = urllib.parse.quote(parsed.path, safe='/:')
# 重新组装URL
safe_url = urllib.parse.urlunparse((
parsed.scheme,
parsed.netloc,
encoded_path,
parsed.params,
parsed.query,
parsed.fragment
))
return safe_url
else:
# 不包含非ASCII字符,无需编码
return url
except Exception:
# 如果解码失败,尝试直接检查原始路径
has_non_ascii = any(ord(c) > 127 for c in parsed.path)
if has_non_ascii:
# 包含非ASCII字符,进行编码
encoded_path = urllib.parse.quote(parsed.path, safe='/:')
safe_url = urllib.parse.urlunparse((
parsed.scheme,
parsed.netloc,
encoded_path,
parsed.params,
parsed.query,
parsed.fragment
))
return safe_url
else:
return url
except Exception as e:
# 如果编码失败,返回原URL(避免破坏签名URL)
return url
def _get_image_dimensions_from_url(url: str) -> Tuple[int, int]:
"""从图片URL获取图片尺寸
采用多级备选方案:
1. 优先使用GET请求(适用于大多数情况,包括签名URL)
2. 如果GET请求失败,尝试HEAD请求作为备选方案
3. 支持中文路径的URL编码
Args:
url: 图片URL
Returns:
(width, height): 图片尺寸,如果获取失败返回 (None, None)
"""
# 对URL进行编码处理,避免中文路径导致的问题
safe_url = _encode_url_for_request(url)
# 方案1: 优先尝试GET请求(适用于大多数情况,包括签名URL)
try:
request = urllib.request.Request(
url=safe_url,
method="GET",
headers={"User-Agent": DEFAULT_USER_AGENT}
)
with urllib.request.urlopen(request, timeout=30) as resp:
# 检查是否为图片
content_type = resp.headers.get('Content-Type', '')
if content_type and 'image' not in content_type.lower():
print(f"非图片类型: {content_type}")
return None, None
# 尝试多种方法获取图片尺寸
# 方法1: 尝试读取部分数据(8KB)
try:
img_data = resp.read(8192) # 读取前8KB
with Image.open(io.BytesIO(img_data)) as im:
return _get_image_size_with_exif(im) # (width, height)
except Exception as e1:
print(f"[GET请求] 方法1失败: {str(e1)}")
# 方法2: 重新请求,读取完整图片
try:
request2 = urllib.request.Request(
url=safe_url,
method="GET",
headers={"User-Agent": DEFAULT_USER_AGENT}
)
with urllib.request.urlopen(request2, timeout=30) as resp2:
img_data = resp2.read() # 读取完整图片
with Image.open(io.BytesIO(img_data)) as im:
return _get_image_size_with_exif(im) # (width, height)
except Exception as e2:
print(f"[GET请求] 方法2失败: {str(e2)}")
# 方法3: 尝试从HTTP头获取尺寸信息
try:
content_length = resp.headers.get('Content-Length')
if content_length:
print(f"图片大小: {content_length} bytes")
# 某些服务器可能在头信息中包含尺寸
return None, None
except Exception as e3:
print(f"[GET请求] 方法3失败: {str(e3)}")
except urllib.error.HTTPError as e:
# HTTP错误(如403、404等),尝试HEAD请求作为备选方案
error_code = e.code
error_msg = str(e)
print(f"[GET请求] HTTP错误 {error_code}: {error_msg},尝试HEAD请求作为备选方案")
# 方案2: HEAD请求备选方案
try:
head_request = urllib.request.Request(
url=safe_url,
method="HEAD",
headers={"User-Agent": DEFAULT_USER_AGENT}
)
with urllib.request.urlopen(head_request, timeout=30) as head_resp:
# 检查是否为图片
content_type = head_resp.headers.get('Content-Type', '')
if content_type and 'image' not in content_type.lower():
print(f"[HEAD请求] 非图片类型: {content_type}")
return None, None
# HEAD请求通常不返回响应体,但某些服务器可能在头信息中包含尺寸
# 如果HEAD请求成功,尝试使用GET请求获取实际数据
print(f"[HEAD请求] 请求成功,尝试使用GET请求获取图片数据")
# 重新尝试GET请求(可能HEAD请求验证了URL有效性)
try:
get_request = urllib.request.Request(
url=safe_url,
method="GET",
headers={"User-Agent": DEFAULT_USER_AGENT}
)
with urllib.request.urlopen(get_request, timeout=30) as get_resp:
img_data = get_resp.read(8192) # 读取前8KB
with Image.open(io.BytesIO(img_data)) as im:
return _get_image_size_with_exif(im)
except Exception as get_e:
print(f"[HEAD备选方案] GET请求仍然失败: {str(get_e)}")
return None, None
except urllib.error.HTTPError as head_e:
# HEAD请求也失败
print(f"[HEAD请求] HTTP错误 {head_e.code}: {str(head_e)}")
return None, None
except Exception as head_e:
# HEAD请求其他错误
print(f"[HEAD请求] 错误: {str(head_e)}")
return None, None
except Exception as e:
# 其他错误(网络错误、超时等)
try:
error_msg = str(e)
print(f"获取图片尺寸失败: {error_msg}")
except UnicodeEncodeError:
print(f"获取图片尺寸失败: {repr(e)}")
return None, None
def _determine_image_size(image_size_mode: str, image_urls: Optional[List[str]]) -> str:
"""确定最终的image_size参数"""
# 如果用户选择了特定比例,直接使用
if image_size_mode != "auto":
print(f"用户选择固定比例: {image_size_mode}")
return image_size_mode
# AUTO模式:需要从输入图片计算比例
if not image_urls or len(image_urls) == 0:
print("AUTO模式但未提供输入图片,使用默认比例 1:1")
return "1:1"
# 获取第一张输入图片的尺寸
first_image_url = image_urls[0]
print(f"[AUTO模式] 分析输入图片: {first_image_url}")
width, height = _get_image_dimensions_from_url(first_image_url)
if width is None or height is None:
print(f"[AUTO模式] 无法获取图片尺寸,使用默认比例 1:1")
return "1:1"
# 验证尺寸有效性
if width <= 0 or height <= 0:
print(f"[AUTO模式] 警告: 获取到无效的图片尺寸 {width}×{height},使用默认比例 1:1")
return "1:1"
# 输出获取到的尺寸信息
print(f"[AUTO模式] 成功获取图片尺寸: {width}×{height}")
print(f"[AUTO模式] 图片宽高比: {width/height:.6f} ({width}:{height})")
# 计算最接近的比例
calculated_ratio = _calculate_aspect_ratio(width, height)
print(f"[AUTO模式] 最终选择比例: {calculated_ratio}")
return calculated_ratio
def _build_request_body(
prompt: str,
generation_type: str,
num_images: int,
image_urls: Optional[List[str]],
seed: Optional[int],
watermark: Optional[str],
image_size_mode: str = "auto",
) -> Dict[str, Any]:
if not prompt:
raise ValueError("prompt 不能为空")
if generation_type not in {"IMAGETOIAMGE", "TEXTTOIAMGE"}:
raise ValueError("type 只能是 TEXTTOIAMGE 或 IMAGETOIAMGE")
if not (1 <= num_images <= 4):
raise ValueError("numImages 必须在 1 到 4 之间")
body: Dict[str, Any] = {
"prompt": prompt,
"type": generation_type,
"numImages": num_images,
"callBackUrl": "https://api.nanobananaapi.ai/callback", # 必填的回调URL,使用官方默认值
}
print(f"banana请求api参数: {json.dumps(body, ensure_ascii=False)}")
# 处理图片尺寸
final_image_size = _determine_image_size(image_size_mode, image_urls)
body["image_size"] = final_image_size
if watermark:
body["watermark"] = watermark
if seed is not None:
try:
body["seed"] = int(seed)
except Exception:
pass
if generation_type == "IMAGETOIAMGE":
if not image_urls:
raise ValueError("IMAGETOIAMGE 模式下必须提供 image_urls")
body["imageUrls"] = image_urls
return body
def _send_request(
server_url: str,
api_key: str,
body: Dict[str, Any],
timeout_seconds: int = 60,
user_agent: str = DEFAULT_USER_AGENT,
) -> Dict[str, Any]:
# 如果server_url已经包含完整路径,则不添加ENDPOINT_PATH
if "/api/v1/nanobanana/generate" in server_url:
url = server_url.rstrip("/")
else:
url = server_url.rstrip("/") + ENDPOINT_PATH
data_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8")
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer {api_key}",
"x-api-key": api_key,
"User-Agent": user_agent or DEFAULT_USER_AGENT,
"Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7",
"Connection": "keep-alive",
}
request = urllib.request.Request(url=url, data=data_bytes, headers=headers, method="POST")
try:
with urllib.request.urlopen(request, timeout=timeout_seconds) as resp:
resp_text = resp.read().decode("utf-8", errors="replace")
print(f"banana api响应: {resp_text}")
try:
return json.loads(resp_text)
except json.JSONDecodeError:
return {"raw": resp_text}
except urllib.error.HTTPError as e:
err_text = e.read().decode("utf-8", errors="replace")
try:
payload = json.loads(err_text)
except json.JSONDecodeError:
payload = {"error": err_text}
payload["http_status"] = e.code
return payload
except urllib.error.URLError as e:
return {"error": str(e)}
def _clean_url_token(token: str) -> Optional[str]:
s = (token or "").strip()
if not s:
return None
# 支持以 @ 开头的输入格式
if s.startswith("@"):
s = s.lstrip("@").strip()
# 去除可能的包裹符 <...>
if s.startswith("<") and s.endswith(">"):
s = s[1:-1].strip()
# 仅接受 http/https
if not (s.startswith("http://") or s.startswith("https://")):
return None
return s
def _parse_image_urls_field(text: str) -> Optional[List[str]]:
"""解析任意输入(JSON 数组/单字符串/按换行或逗号或空白分隔,支持 @ 前缀)为 URL 列表。"""
txt = (text or "").strip()
if not txt:
return None
urls: List[str] = []
# 优先尝试 JSON
try:
parsed = json.loads(txt)
if isinstance(parsed, list):
for u in parsed:
if isinstance(u, str):
cu = _clean_url_token(u)
if cu:
urls.append(cu)
seen = set()
return [u for u in urls if not (u in seen or seen.add(u))] or None
elif isinstance(parsed, str):
cu = _clean_url_token(parsed)
return ([cu] if cu else None)
except Exception:
pass
# 自由格式:按换行/逗号/空白分割
tokens = [t for t in re.split(r"[\r\n,\s]+", txt) if t.strip()]
for t in tokens:
cu = _clean_url_token(t)
if cu:
urls.append(cu)
if not urls:
return None
seen = set()
return [u for u in urls if not (u in seen or seen.add(u))]
class NanoBananaGenerate:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
"type": (["TEXTTOIAMGE", "IMAGETOIAMGE"], {"default": "TEXTTOIAMGE"}),
"num_images": ("INT", {"default": 1, "min": 1, "max": 4}),
"api_key": ("STRING", {"default": "", "multiline": False}),
"image_size_mode": (["auto", "1:1", "9:16", "16:9", "3:4", "4:3", "3:2", "2:3", "5:4", "4:5", "21:9"], {"default": "auto"}),
},
"optional": {
"image_urls_json": ("STRING", {"default": "[]", "multiline": True}),
"seed": ("INT", {"default": -1, "min": -1, "max": 99999999}),
"watermark": ("STRING", {"default": ""}),
"server": ("STRING", {"default": DEFAULT_SERVER_URL}),
"timeout_seconds": ("INT", {"default": 60, "min": 5, "max": 300}),
"wait_for_result": ("BOOLEAN", {"default": False}),
"poll_interval_seconds": ("INT", {"default": 3, "min": 1, "max": 60}),
"poll_timeout_seconds": ("INT", {"default": 240, "min": 5, "max": 3600}),
},
}
RETURN_TYPES = ("STRING", "STRING", "IMAGE")
RETURN_NAMES = ("task_id", "response_json", "result_image")
FUNCTION = "generate"
CATEGORY = "NanoBanana"
def generate(
self,
prompt: str,
type: str,
num_images: int,
api_key: str,
image_urls_json: str = "[]",
seed: int = -1,
watermark: str = "",
image_size_mode: str = "auto",
server: str = DEFAULT_SERVER_URL,
timeout_seconds: int = 60,
wait_for_result: bool = False,
poll_interval_seconds: int = 3,
poll_timeout_seconds: int = 240, # 与 INPUT_TYPES 保持一致
) -> Tuple[str, str, Any]:
if not api_key:
raise ValueError("未提供 API Key。请在节点中填写 api_key。")
image_urls: Optional[List[str]] = _parse_image_urls_field(image_urls_json)
# 限制种子值范围
processed_seed = None
if seed is not None and seed >= 0:
# 如果种子值超出范围,取模限制在合理范围内
processed_seed = int(seed) % 100000000 if seed > 99999999 else int(seed)
body = _build_request_body(
prompt=prompt,
generation_type=type,
num_images=num_images,
image_urls=image_urls,
seed=processed_seed,
watermark=watermark or None,
image_size_mode=image_size_mode,
)
response = _send_request(
server_url=server,
api_key=api_key,
body=body,
timeout_seconds=timeout_seconds,
user_agent=DEFAULT_USER_AGENT,
)
task_id: str = ""
try:
task_id = str(response.get("data", {}).get("taskId", ""))
print(f"NanoBanana: API响应解析 - taskId: {task_id}")
if not task_id:
print(f"NanoBanana: 完整响应内容: {json.dumps(response, ensure_ascii=False, indent=2)}")
except Exception as e:
task_id = ""
print(f"NanoBanana: 解析taskId时出错: {e}")
print(f"NanoBanana: 原始响应: {json.dumps(response, ensure_ascii=False, indent=2)}")
# 如果用户选择等待结果且拿到了 task_id,则进入轮询
if wait_for_result and task_id:
print(f"NanoBanana: 开始轮询任务 {task_id}")
final_info = _poll_task(
server_url=server,
api_key=api_key,
task_id=task_id,
interval_seconds=poll_interval_seconds,
timeout_seconds=poll_timeout_seconds,
user_agent=DEFAULT_USER_AGENT,
)
print(f"NanoBanana: 轮询完成,结果: {json.dumps(final_info, ensure_ascii=False, indent=2)}")
# 提取结果图片地址(支持多张)
result_image_urls: List[str] = []
try:
data_obj = final_info.get("data") or {}
if isinstance(data_obj, dict):
# 1) data.response.*
resp_obj = data_obj.get("response") or {}
if isinstance(resp_obj, dict):
candidates: List[str] = []
# 优先收集数组字段
for key in ("resultImageUrls", "resultImages", "images", "imageUrls", "image_urls", "result_image_urls", "data"):
v = resp_obj.get(key)
if isinstance(v, list):
for item in v:
if isinstance(item, str) and item.strip():
candidates.append(item)
elif isinstance(item, dict) and "url" in item:
# 处理字典格式的图片项
url = item.get("url")
if isinstance(url, str) and url.strip():
candidates.append(url)
# 兼容单字段
for key in ("resultImageUrl", "imageUrl", "image_url", "result_image_url"):
v = resp_obj.get(key)
if isinstance(v, str) and v.strip():
candidates.append(v)
# 去重并保序
seen: set = set()
result_image_urls = [u for u in candidates if not (u in seen or seen.add(u))]
# 2) data.* 顶层数组字段
if not result_image_urls:
for key in ("images", "resultImages", "imageUrls", "image_urls", "result_image_urls", "data"):
v = data_obj.get(key)
if isinstance(v, list):
for item in v:
if isinstance(item, str) and item.strip():
result_image_urls.append(item)
elif isinstance(item, dict) and "url" in item:
# 处理字典格式的图片项
url = item.get("url")
if isinstance(url, str) and url.strip():
result_image_urls.append(url)
# 兼容处理 - 处理所有图片,不只是第一张
if not result_image_urls:
images_list = data_obj.get("images") or data_obj.get("resultImages")
if isinstance(images_list, list) and images_list:
# 处理所有图片项,不只是第一张
for item in images_list:
if isinstance(item, str) and item.strip():
result_image_urls.append(item)
elif isinstance(item, dict) and "url" in item:
# 如果item是字典且包含url字段
url = item.get("url")
if isinstance(url, str) and url.strip():
result_image_urls.append(url)
except Exception as e:
print(f"NanoBanana: 解析图片URL失败: {str(e)}")
pass
print(f"NanoBanana: 解析到图片URLs: {result_image_urls}")
# 下载并转换为 ComfyUI IMAGE 批次张量
result_image = None
if result_image_urls:
images_tensors: List[torch.Tensor] = []
for idx, url in enumerate(result_image_urls):
try:
print(f"NanoBanana: 下载图片 {idx+1}/{len(result_image_urls)}: {url}")
# 保持每张图片的原始尺寸,不强制调整
tensor_img = _download_image_to_tensor_with_size(url, target_size=None, timeout_seconds=min(timeout_seconds, 60))
images_tensors.append(tensor_img)
# 打印每张图片的实际尺寸(使用编码后的URL)
try:
safe_url = _encode_url_for_request(url)
with urllib.request.urlopen(urllib.request.Request(safe_url, headers={"User-Agent": DEFAULT_USER_AGENT}), timeout=min(timeout_seconds, 60)) as resp:
img_bytes = resp.read()
with Image.open(io.BytesIO(img_bytes)) as im:
width, height = _get_image_size_with_exif(im)
print(f"NanoBanana: 图片{idx+1}实际尺寸: {width}x{height}")
except Exception as size_e:
print(f"NanoBanana: 无法读取图片{idx+1}尺寸: {size_e}")
except Exception as e:
print(f"NanoBanana: 图片下载失败 {url}: {str(e)}")
continue
if images_tensors:
try:
result_image = torch.cat(images_tensors, dim=0)
print(f"NanoBanana: 成功下载 {len(images_tensors)} 张图片")
except Exception as e:
print(f"NanoBanana: 图片拼接失败: {str(e)}")
# 退化到第一张
result_image = images_tensors[0]
else:
print("NanoBanana: 所有图片下载失败")
else:
print("NanoBanana: 未找到图片URL")
response_json = json.dumps(final_info, ensure_ascii=False)
return (task_id, response_json, result_image)
else:
# 如果不等待结果,返回任务ID和响应,但图片为None
# 提示用户需要等待结果才能获取图片
response_json = json.dumps(response, ensure_ascii=False)
if not wait_for_result:
print("NanoBanana: 未启用等待结果,请勾选 wait_for_result 选项以获取图片")
elif not task_id:
print("NanoBanana: 未获取到任务ID,可能API调用失败")
return (task_id, response_json, None)
def _get_task_info(
server_url: str,
api_key: str,
task_id: str,
timeout_seconds: int = 30,
user_agent: str = DEFAULT_USER_AGENT,
) -> Dict[str, Any]:
# 处理server_url,如果包含完整路径则提取基础URL
base = server_url.rstrip("/")
if "/api/v1/nanobanana/generate" in base:
base = base.replace("/api/v1/nanobanana/generate", "")
query = urllib.parse.urlencode({"taskId": task_id})
url = f"{base}/api/v1/nanobanana/record-info?{query}"
print(f"NanoBanana: 轮询URL: {url}")
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {api_key}",
"x-api-key": api_key,
"User-Agent": user_agent or DEFAULT_USER_AGENT,
"Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7",
"Connection": "keep-alive",
}
request = urllib.request.Request(url=url, headers=headers, method="GET")
try:
with urllib.request.urlopen(request, timeout=timeout_seconds) as resp:
resp_text = resp.read().decode("utf-8", errors="replace")
print(f"banana 轮询api响应: {resp_text}")
try:
result = json.loads(resp_text)
print(f"NanoBanana: 解析后的响应: {json.dumps(result, ensure_ascii=False)}")
return result
except json.JSONDecodeError:
return {"raw": resp_text}
except urllib.error.HTTPError as e:
err_text = e.read().decode("utf-8", errors="replace")
try:
payload = json.loads(err_text)
except json.JSONDecodeError:
payload = {"error": err_text}
payload["http_status"] = e.code
return payload
except urllib.error.URLError as e:
return {"error": str(e)}
def _poll_task(
server_url: str,
api_key: str,
task_id: str,
interval_seconds: int,
timeout_seconds: int,
user_agent: str = DEFAULT_USER_AGENT,
) -> Dict[str, Any]:
start_ts = time.time()
last_payload: Dict[str, Any] = {}
consecutive_timeouts = 0 # 连续超时计数
max_consecutive_timeouts = 3 # 最多允许连续3次超时
while True:
try:
# 使用传入的 timeout_seconds 参数作为单个请求的超时时间
payload = _get_task_info(
server_url=server_url,
api_key=api_key,
task_id=task_id,
timeout_seconds=timeout_seconds,
user_agent=user_agent,
)
last_payload = payload
# 检查是否是错误响应(包括超时错误)
if "error" in payload:
error_msg = payload.get("error", "")
if "timeout" in error_msg.lower() or "timed out" in error_msg.lower():
consecutive_timeouts += 1
print(f"NanoBanana: 轮询请求超时 ({consecutive_timeouts}/{max_consecutive_timeouts}): {error_msg}")
if consecutive_timeouts >= max_consecutive_timeouts:
print(f"NanoBanana: 连续{consecutive_timeouts}次轮询请求超时,停止轮询")
return {
"error": f"连续轮询请求超时({consecutive_timeouts}次)",
"last": payload,
"consecutive_timeouts": consecutive_timeouts
}
else:
# 其他错误,也记录但允许重试
print(f"NanoBanana: 轮询请求错误: {error_msg}")
consecutive_timeouts += 1
if consecutive_timeouts >= max_consecutive_timeouts:
return {
"error": f"连续轮询错误",
"last": payload,
"consecutive_timeouts": consecutive_timeouts
}
else:
consecutive_timeouts = 0 # 重置超时计数(成功响应)
# HTTP错误检查
if payload.get("http_status") and payload["http_status"] >= 500:
consecutive_timeouts += 1
print(f"NanoBanana: HTTP {payload['http_status']} 错误 ({consecutive_timeouts}/{max_consecutive_timeouts})")
if consecutive_timeouts >= max_consecutive_timeouts:
return {
"error": f"HTTP {payload['http_status']} 错误",
"last": payload,
"consecutive_timeouts": consecutive_timeouts
}
except Exception as e:
# 捕获所有未预期的异常(包括socket.timeout等)
consecutive_timeouts += 1
print(f"NanoBanana: 轮询请求异常 ({consecutive_timeouts}/{max_consecutive_timeouts}): {str(e)}")
if consecutive_timeouts >= max_consecutive_timeouts:
return {
"error": f"轮询异常: {str(e)}",
"last": last_payload,
"consecutive_timeouts": consecutive_timeouts
}
# 等待后继续尝试
time.sleep(max(1, interval_seconds))
continue
# 尝试解析状态
status = 0
data = {}
try:
data = payload.get("data") or {}
if not isinstance(data, dict):
data = payload if isinstance(payload, dict) else {}
status = int(data.get("successFlag", 0))
print(f"NanoBanana: 轮询状态 {status}, 数据: {json.dumps(data, ensure_ascii=False)}")
except Exception as e:
status = 0
print(f"NanoBanana: 解析状态失败: {str(e)}, 原始响应: {json.dumps(payload, ensure_ascii=False)}")
# 检查任务是否完成
if status in (1, 2, 3):
print(f"NanoBanana: 任务完成,状态: {status}")
return payload
# 检查总超时
elapsed = time.time() - start_ts
if elapsed >= timeout_seconds:
print(f"NanoBanana: 轮询超时 ({timeout_seconds}秒),最后状态: {status}, 已耗时: {elapsed:.1f}秒")
return {
"error": "poll_timeout",
"data": data,
"last": last_payload,
"timeout_seconds": timeout_seconds,
"elapsed": elapsed
}
# 等待下一次轮询
time.sleep(max(1, interval_seconds))
def _download_image_to_tensor(url: str, timeout_seconds: int = 30):
"""下载图片并转换为 ComfyUI IMAGE(torch.Tensor,形状 [1, H, W, 3],值域 0-1)。"""
# 对URL进行编码处理
safe_url = _encode_url_for_request(url)
request = urllib.request.Request(
url=safe_url,
method="GET",
headers={
"User-Agent": DEFAULT_USER_AGENT,
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7",
"Connection": "keep-alive",
},
)
with urllib.request.urlopen(request, timeout=timeout_seconds) as resp:
img_bytes = resp.read()
with Image.open(io.BytesIO(img_bytes)) as im:
im = im.convert("RGB")
arr = np.array(im, dtype=np.float32) / 255.0
tensor = torch.from_numpy(arr)
if tensor.ndim == 2:
tensor = tensor.unsqueeze(-1).repeat(1, 1, 3)
# [H, W, C] -> add batch dim -> [1, H, W, C]
tensor = tensor.unsqueeze(0).contiguous()
return tensor
def _download_image_to_tensor_with_size(
url: str,
target_size: Optional[Tuple[int, int]] = None, # (W, H)
return_size: bool = False,
timeout_seconds: int = 30,
):
"""下载图片并转换为 ComfyUI IMAGE;可选对齐到 target_size;
return_size=True 时返回 (tensor, (W, H))
"""
# 对URL进行编码处理
safe_url = _encode_url_for_request(url)
request = urllib.request.Request(
url=safe_url,
method="GET",
headers={
"User-Agent": DEFAULT_USER_AGENT,
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7",
"Connection": "keep-alive",
},
)
with urllib.request.urlopen(request, timeout=timeout_seconds) as resp:
img_bytes = resp.read()
with Image.open(io.BytesIO(img_bytes)) as im:
im = im.convert("RGB")
if target_size is not None:
# target_size 为 (W, H)
im = im.resize((int(target_size[0]), int(target_size[1])), Image.BICUBIC)
width, height = _get_image_size_with_exif(im)
arr = np.array(im, dtype=np.float32) / 255.0
tensor = torch.from_numpy(arr)
if tensor.ndim == 2:
tensor = tensor.unsqueeze(-1).repeat(1, 1, 3)
tensor = tensor.unsqueeze(0).contiguous()
if return_size:
return tensor, (width, height)
return tensor
def _create_white_image_tensor(width: int = 512, height: int = 512) -> torch.Tensor:
"""创建一个白色图片张量,用于错误情况下的返回。"""
# 创建白色图片 [1, H, W, 3],值域 0-1
white_array = np.ones((height, width, 3), dtype=np.float32)
tensor = torch.from_numpy(white_array).unsqueeze(0).contiguous()
return tensor
def _create_fallback_or_white_image_tensor(
fallback_image_url: str,
timeout_seconds: int = 60,
) -> torch.Tensor:
"""
优先返回前端提供的链接图片;若无或下载失败,则返回白色底图。
"""
try:
if fallback_image_url and isinstance(fallback_image_url, str) and fallback_image_url.strip():
return _download_image_to_tensor_with_size(
fallback_image_url.strip(),
timeout_seconds=timeout_seconds,
)
except Exception:
pass
return _create_white_image_tensor()
def _extract_image_urls_from_response(response_data: Dict[str, Any]) -> List[str]:
"""从API响应中提取图片URLs"""
urls: List[str] = []
try:
# 处理NanoBanana响应格式
if "data" in response_data and isinstance(response_data["data"], dict):
data_obj = response_data["data"]
# 1) data.response.*
resp_obj = data_obj.get("response") or {}
if isinstance(resp_obj, dict):
candidates: List[str] = []
# 优先收集数组字段
for key in ("resultImageUrls", "resultImages", "images", "imageUrls", "image_urls", "result_image_urls", "data"):
v = resp_obj.get(key)
if isinstance(v, list):
for item in v:
if isinstance(item, str) and item.strip():
candidates.append(item)
elif isinstance(item, dict) and "url" in item:
url = item.get("url")
if isinstance(url, str) and url.strip():
candidates.append(url)
# 兼容单字段
for key in ("resultImageUrl", "imageUrl", "image_url", "result_image_url"):
v = resp_obj.get(key)
if isinstance(v, str) and v.strip():
candidates.append(v)
# 去重并保序
seen: set = set()
urls.extend([u for u in candidates if not (u in seen or seen.add(u))])
# 2) data.* 顶层数组字段
if not urls:
for key in ("images", "resultImages", "imageUrls", "image_urls", "result_image_urls", "data"):
v = data_obj.get(key)
if isinstance(v, list):
for item in v:
if isinstance(item, str) and item.strip():
urls.append(item)
elif isinstance(item, dict) and "url" in item:
url = item.get("url")
if isinstance(url, str) and url.strip():
urls.append(url)
# 兼容处理 - 处理所有图片,不只是第一张
if not urls:
images_list = data_obj.get("images") or data_obj.get("resultImages")
if isinstance(images_list, list) and images_list:
for item in images_list:
if isinstance(item, str) and item.strip():
urls.append(item)
elif isinstance(item, dict) and "url" in item:
url = item.get("url")
if isinstance(url, str) and url.strip():
urls.append(url)
# 处理豆包响应格式
elif "data" in response_data and isinstance(response_data["data"], list):
for item in response_data["data"]:
if isinstance(item, dict) and "url" in item:
url = item.get("url")
if isinstance(url, str) and url.strip():
urls.append(url)
# 处理即梦响应格式
elif "data" in response_data and isinstance(response_data["data"], dict):
data_obj = response_data["data"]
if "image_urls" in data_obj and isinstance(data_obj["image_urls"], list):
urls.extend([url for url in data_obj["image_urls"] if isinstance(url, str) and url.strip()])
elif "image_url" in data_obj and isinstance(data_obj["image_url"], str):
urls.append(data_obj["image_url"])
except Exception as e:
print(f"提取图片URLs失败: {str(e)}")
return urls
def _send_doubao_request(
api_key: str,
prompt: str,
size: str = "1K",
model: str = "doubao-seedream-4-0-250828",
watermark: bool = True,
seed: Optional[int] = None,
image_urls: Optional[List[str]] = None,
sequential_generation: str = "disabled",
num_images: int = 1,
timeout_seconds: int = 60,
server_url: str = "https://ark.cn-beijing.volces.com/api/v3/images/generations",
) -> Dict[str, Any]:
"""发送豆包 API 请求 - 使用HTTP请求方式,支持转发站"""
try:
# 构建API URL - 用户填写的是完整的接口地址
url = server_url.rstrip('/')
# 构建请求体
body = {
"model": model,
"prompt": prompt,
"size": size,
"response_format": "url",
"watermark": watermark,
"stream": False
}
# 添加图片输入(如果提供)
if image_urls and len(image_urls) > 0:
if len(image_urls) == 1:
body["image"] = image_urls[0] # 单图模式
else:
body["image"] = image_urls # 多图模式
# 添加组图生成控制
if sequential_generation == "auto":
body["sequential_image_generation"] = "auto"
# 计算实际可生成的最大图片数量
input_image_count = len(image_urls) if image_urls else 0
max_allowed_images = 15 - input_image_count
if max_allowed_images <= 0:
print(f"TripleAPI: 豆包API错误 - 输入图片数量({input_image_count})已达到15张限制")
return {"error": f"输入图片数量({input_image_count})已达到15张限制"}
body["sequential_image_generation_options"] = {
"max_images": min(num_images, max_allowed_images)
}
print(f"TripleAPI: 豆包组图生成 - 输入图片:{input_image_count}张, 请求生成:{num_images}张, 实际生成:{min(num_images, max_allowed_images)}张")
else:
body["sequential_image_generation"] = sequential_generation
# 添加 seed 参数(如果提供)
if seed is not None and seed >= 0:
limited_seed = seed % 100000000 if seed > 99999999 else seed
body["seed"] = limited_seed
print(f"豆包请求API参数: {json.dumps(body, ensure_ascii=False, indent=2)}")
# 构建请求头
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": DEFAULT_USER_AGENT,
}
# 发送HTTP请求
data_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(url=url, data=data_bytes, headers=headers, method="POST")
with urllib.request.urlopen(request, timeout=timeout_seconds) as resp:
resp_text = resp.read().decode("utf-8", errors="replace")
try:
response_data = json.loads(resp_text)
print(f"豆包API响应: {response_data}")
return response_data
except json.JSONDecodeError:
return {"raw": resp_text}
except urllib.error.HTTPError as e:
err_text = e.read().decode("utf-8", errors="replace")
try:
payload = json.loads(err_text)
except json.JSONDecodeError:
payload = {"error": err_text}
payload["http_status"] = e.code
print(f"豆包API HTTP错误: {payload}")
return payload
except urllib.error.URLError as e:
error_msg = str(e)
print(f"豆包API URL错误: {error_msg}")
return {"error": error_msg}
except Exception as e:
error_msg = str(e)
print(f"豆包API未知错误: {error_msg}")
return {"error": error_msg}
def _send_hidream_request_multi(
req_key: str,
prompt: str,
hidream_type: str = "txt2img",
image_urls: Optional[List[str]] = None,
seed: Optional[int] = None,
scale: float = 0.5,
timeout_seconds: int = 60,
server_url: str = "https://visual.volcengineapi.com",
) -> Dict[str, Any]:
"""发送即梦 API 4.0 请求 - 支持多图输入(最多10张)"""
# 从req_key解析access_key_id和secret_access_key
if ":" not in req_key:
return {"error": "req_key格式错误,应为 'access_key_id:secret_access_key'"}
access_key_id, secret_access_key = req_key.split(":", 1)
# API基本信息
method = "POST"
uri = "/"
query_params = {
"Action": "CVSync2AsyncSubmitTask",
"Version": "2022-08-31"
}
query_string = "&".join([f"{k}={v}" for k, v in sorted(query_params.items())])
# 构建请求体
body_data = {
"req_key": "jimeng_t2i_v40",
"prompt": prompt,
"scale": scale,
}
# 添加 seed 参数(如果提供)
if seed is not None and seed >= 0:
limited_seed = seed % 100000000 if seed > 99999999 else seed
body_data["seed"] = limited_seed
# 如果是图生图类型,添加图片URLs(支持多图,最多10张)
if hidream_type in ["img2img", "shopImg2img", "vton"] and image_urls:
# 限制最多10张图片
limited_urls = image_urls[:10] if len(image_urls) > 10 else image_urls
body_data["image_urls"] = limited_urls
if len(image_urls) > 10:
print(f"即梦: 输入图片超过10张,已截取前10张: {len(limited_urls)}张")
body = json.dumps(body_data)
print(f"即梦请求api参数: {body}")
# 创建时间戳
now = datetime.utcnow()
timestamp = now.strftime("%Y%m%dT%H%M%SZ")
# 计算请求体哈希
body_hash = hashlib.sha256(body.encode('utf-8')).hexdigest()
# 从server_url中提取host
from urllib.parse import urlparse
parsed_url = urlparse(server_url)
host = parsed_url.hostname or VOLCENGINE_HOST
# 构建请求头
headers = {
"Host": host,
"Content-Type": "application/json",
"X-Date": timestamp,
"X-Content-Sha256": body_hash,
"User-Agent": DEFAULT_USER_AGENT,
}
# 创建签名
authorization = _create_volcengine_signature(
access_key_id=access_key_id,
secret_access_key=secret_access_key,
method=method,
uri=uri,
query_string=query_string,
headers=headers,
body=body,
timestamp=timestamp
)
headers["Authorization"] = authorization
# 构建完整URL
url = f"{server_url.rstrip('/')}{uri}?{query_string}"
# 发送请求
request = urllib.request.Request(
url=url,
data=body.encode("utf-8"),
headers=headers,
method=method
)
try:
with urllib.request.urlopen(request, timeout=timeout_seconds) as resp:
resp_text = resp.read().decode("utf-8", errors="replace")
try:
return json.loads(resp_text)
except json.JSONDecodeError:
return {"raw": resp_text}
except urllib.error.HTTPError as e:
err_text = e.read().decode("utf-8", errors="replace")
try:
payload = json.loads(err_text)
except json.JSONDecodeError:
payload = {"error": err_text}
payload["http_status"] = e.code
return payload
except urllib.error.URLError as e:
return {"error": str(e)}
def _send_hidream_request(
req_key: str,
prompt: str,
hidream_type: str = "txt2img",
image_url: Optional[str] = None,
seed: Optional[int] = None,
scale: float = 0.5,
timeout_seconds: int = 60,
server_url: str = "https://visual.volcengineapi.com",
) -> Dict[str, Any]:
"""发送即梦 API 4.0 请求 - 提交任务"""
# 从req_key解析access_key_id和secret_access_key
# 格式应该是: "access_key_id:secret_access_key"
if ":" not in req_key:
return {"error": "req_key格式错误,应为 'access_key_id:secret_access_key'"}
access_key_id, secret_access_key = req_key.split(":", 1)
# API基本信息
method = "POST"
uri = "/"
query_params = {
"Action": "CVSync2AsyncSubmitTask",
"Version": "2022-08-31"
}
query_string = "&".join([f"{k}={v}" for k, v in sorted(query_params.items())])
# 构建请求体
body_data = {
"req_key": "jimeng_t2i_v40",
"prompt": prompt,
"scale": scale,
}
# 添加 seed 参数(如果提供)
if seed is not None and seed >= 0:
# 限制种子值范围
limited_seed = seed % 100000000 if seed > 99999999 else seed
body_data["seed"] = limited_seed
# 如果是图生图类型,添加图片URL
if hidream_type in ["img2img", "shopImg2img", "vton"] and image_url:
body_data["image_urls"] = [image_url]
body = json.dumps(body_data)
# 创建时间戳
now = datetime.utcnow()
timestamp = now.strftime("%Y%m%dT%H%M%SZ")
# 计算请求体哈希
body_hash = hashlib.sha256(body.encode('utf-8')).hexdigest()
# 从server_url中提取host
from urllib.parse import urlparse
parsed_url = urlparse(server_url)
host = parsed_url.hostname or VOLCENGINE_HOST
# 构建请求头
headers = {
"Host": host,
"Content-Type": "application/json",
"X-Date": timestamp,
"X-Content-Sha256": body_hash,
"User-Agent": DEFAULT_USER_AGENT,
}
# 创建签名
authorization = _create_volcengine_signature(
access_key_id=access_key_id,
secret_access_key=secret_access_key,
method=method,
uri=uri,
query_string=query_string,
headers=headers,
body=body,
timestamp=timestamp
)
headers["Authorization"] = authorization
# 构建完整URL
url = f"{server_url.rstrip('/')}{uri}?{query_string}"
# 发送请求
request = urllib.request.Request(
url=url,
data=body.encode("utf-8"),
headers=headers,
method=method
)
try:
with urllib.request.urlopen(request, timeout=timeout_seconds) as resp:
resp_text = resp.read().decode("utf-8", errors="replace")
try:
return json.loads(resp_text)
except json.JSONDecodeError:
return {"raw": resp_text}
except urllib.error.HTTPError as e:
err_text = e.read().decode("utf-8", errors="replace")
try:
payload = json.loads(err_text)
except json.JSONDecodeError:
payload = {"error": err_text}
payload["http_status"] = e.code
return payload
except urllib.error.URLError as e:
return {"error": str(e)}
def _check_hidream_status(
req_key: str,
task_id: str,
timeout_seconds: int = 30,
server_url: str = "https://visual.volcengineapi.com",
) -> Dict[str, Any]:
"""查询即梦 API 4.0 任务状态"""
# 从req_key解析access_key_id和secret_access_key
# 格式应该是: "access_key_id:secret_access_key"
if ":" not in req_key:
return {"error": "req_key格式错误,应为 'access_key_id:secret_access_key'"}
access_key_id, secret_access_key = req_key.split(":", 1)
# API基本信息
method = "POST"
uri = "/"
query_params = {
"Action": "CVSync2AsyncGetResult",
"Version": "2022-08-31"
}
query_string = "&".join([f"{k}={v}" for k, v in sorted(query_params.items())])
# 构建请求体
body_data = {
"req_key": "jimeng_t2i_v40",
"task_id": task_id,
"req_json": json.dumps({
"return_url": True,
"logo_info": {
"add_logo": False
}
}, ensure_ascii=False)
}
body = json.dumps(body_data)
# 创建时间戳
now = datetime.utcnow()
timestamp = now.strftime("%Y%m%dT%H%M%SZ")
# 计算请求体哈希
body_hash = hashlib.sha256(body.encode('utf-8')).hexdigest()
# 从server_url中提取host
from urllib.parse import urlparse
parsed_url = urlparse(server_url)
host = parsed_url.hostname or VOLCENGINE_HOST
# 构建请求头
headers = {
"Host": host,
"Content-Type": "application/json",
"X-Date": timestamp,
"X-Content-Sha256": body_hash,
"User-Agent": DEFAULT_USER_AGENT,
}
# 创建签名
authorization = _create_volcengine_signature(
access_key_id=access_key_id,
secret_access_key=secret_access_key,
method=method,
uri=uri,
query_string=query_string,
headers=headers,
body=body,
timestamp=timestamp
)
headers["Authorization"] = authorization
# 构建完整URL
url = f"{server_url.rstrip('/')}{uri}?{query_string}"
# 发送请求
request = urllib.request.Request(
url=url,
data=body.encode("utf-8"),
headers=headers,
method=method
)
try:
with urllib.request.urlopen(request, timeout=timeout_seconds) as resp:
resp_text = resp.read().decode("utf-8", errors="replace")
try:
return json.loads(resp_text)
except json.JSONDecodeError:
return {"raw": resp_text}
except urllib.error.HTTPError as e:
err_text = e.read().decode("utf-8", errors="replace")
try:
payload = json.loads(err_text)
except json.JSONDecodeError:
payload = {"error": err_text}
payload["http_status"] = e.code
return payload
except urllib.error.URLError as e:
return {"error": str(e)}
def _call_nanobanana_api(
prompt: str,
nanobanana_type: str,
nanobanana_num_images: int,
nanobanana_image_size_mode: str,
nanobanana_api_key: str,
image_urls_input: str,
seed: int,
nanobanana_watermark: str,
nanobanana_server_url: str,
fallback_image_url: str,
poll_interval: int,
poll_timeout: int,
max_retries: int = 3,
retry_interval: int = 2,
) -> Tuple[Any, str, str, str]:
"""调用NanoBanana API的并行执行函数(支持重试)"""
last_error = None
for attempt in range(max_retries):
try:
if attempt > 0:
print(f"TripleAPI: NanoBanana第{attempt + 1}次重试...")
else:
print(f"TripleAPI: 开始调用NanoBanana API,类型: {nanobanana_type}")
nanobanana_node = NanoBananaGenerate()
task_id, response_json, result_image = nanobanana_node.generate(
prompt=prompt,
type=nanobanana_type,
num_images=nanobanana_num_images,
api_key=nanobanana_api_key,
image_urls_json=image_urls_input or "[]",
seed=seed,
watermark=nanobanana_watermark,
image_size_mode=nanobanana_image_size_mode,
server=nanobanana_server_url,
timeout_seconds=poll_timeout,
wait_for_result=True,
poll_interval_seconds=poll_interval,
poll_timeout_seconds=poll_timeout,
)
# 检查响应是否包含错误(API调用失败的情况)
has_error = False
error_msg = None
try:
if response_json:
response_data = json.loads(response_json) if isinstance(response_json, str) else response_json
# 检查是否有错误字段或HTTP错误状态
if isinstance(response_data, dict):
if "error" in response_data or "http_status" in response_data:
has_error = True
error_msg = response_data.get("error", f"HTTP错误: {response_data.get('http_status', 'unknown')}")
except Exception:
pass # 如果解析失败,继续检查其他条件
# 如果result_image为None且task_id为空,说明API调用失败,应该重试
if result_image is None and (not task_id or has_error):
if has_error:
error_msg = error_msg or "API返回错误响应"
else:
error_msg = "未获取到任务ID,API调用可能失败"
# 抛出异常以触发重试逻辑
raise Exception(error_msg)
if result_image is not None:
status_info = f"NanoBanana: 成功(第{attempt + 1}次尝试); "
print(f"TripleAPI: NanoBanana成功,图片形状: {result_image.shape}")
else:
# 这种情况应该是正常情况(比如用户选择不等待结果)
status_info = f"NanoBanana: 成功但无图片(第{attempt + 1}次尝试); "
print("TripleAPI: NanoBanana成功但未返回图片")
return result_image, task_id, response_json, status_info
except Exception as e:
last_error = e
print(f"TripleAPI: NanoBanana第{attempt + 1}次尝试失败: {str(e)}")
if attempt < max_retries - 1:
print(f"TripleAPI: NanoBanana将在{retry_interval}秒后重试...")
time.sleep(retry_interval) # 固定重试间隔
# 所有重试都失败了
status_info = f"NanoBanana: 失败(已重试{max_retries}次) - {str(last_error)}; "
print(f"TripleAPI: NanoBanana最终失败: {str(last_error)}")
result_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
response_json = json.dumps({"error": str(last_error), "retries": max_retries}, ensure_ascii=False)
return result_image, "", response_json, status_info
def _call_doubao_api(
prompt: str,
doubao_type: str,
doubao_api_key: str,
image_urls_input: str,
seed: int,
doubao_model: str,
doubao_size: str,
doubao_watermark: bool,
doubao_sequential_generation: str,
doubao_num_images: int,
fallback_image_url: str,
poll_timeout: int,
max_retries: int = 3,
retry_interval: int = 2,
doubao_server_url: str = "https://ark.cn-beijing.volces.com",
) -> Tuple[Any, str, str]:
"""调用豆包API的并行执行函数(支持重试)"""
last_error = None
for attempt in range(max_retries):
try:
if attempt > 0:
print(f"TripleAPI: 豆包第{attempt + 1}次重试...")
else:
print(f"TripleAPI: 开始调用豆包 API,类型: {doubao_type}")
# 从统一的图片输入中解析URL用于豆包
doubao_image_urls = []
if doubao_type in ["img2img", "multi_img_fusion"]:
if image_urls_input:
parsed_urls = _parse_image_urls_field(image_urls_input)
if parsed_urls:
if doubao_type == "img2img":
# 图生图模式:只使用第一张图片
doubao_image_urls = [parsed_urls[0]]
print(f"TripleAPI: 豆包图生图模式,使用第一张图片: {doubao_image_urls[0]}")
elif doubao_type == "multi_img_fusion":
# 多图融合模式:使用所有图片
doubao_image_urls = parsed_urls
print(f"TripleAPI: 豆包多图融合模式,使用所有图片: {len(doubao_image_urls)}张")
if not doubao_image_urls:
status_info = "豆包: 图生图模式需要提供图片URL; "
print("TripleAPI: 豆包图生图模式需要提供图片URL")
doubao_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
doubao_response = json.dumps({"error": "图生图模式需要提供图片URL"}, ensure_ascii=False)
return doubao_image, doubao_response, status_info
else:
# 有图片URL,继续API调用
print(f"TripleAPI: 豆包使用图片URLs: {doubao_image_urls}")
doubao_result = _send_doubao_request(
api_key=doubao_api_key,
prompt=prompt,
size=doubao_size,
model=doubao_model,
watermark=doubao_watermark,
seed=(None if seed < 0 else seed),
image_urls=doubao_image_urls,
sequential_generation=doubao_sequential_generation,
num_images=doubao_num_images,
timeout_seconds=poll_timeout,
server_url=doubao_server_url,
)
doubao_response = json.dumps(doubao_result, ensure_ascii=False)
print(f"TripleAPI: 豆包API响应: {doubao_result}")
# 检查响应是否包含错误(API调用失败的情况)
has_error = False
error_msg = None
if isinstance(doubao_result, dict):
if "error" in doubao_result or "http_status" in doubao_result:
has_error = True
error_msg = doubao_result.get("error", f"HTTP错误: {doubao_result.get('http_status', 'unknown')}")
# 如果API返回错误,抛出异常以触发重试逻辑
if has_error:
raise Exception(error_msg or "API返回错误响应")
# 提取图片URL并下载(支持多张组图)
if "data" in doubao_result and isinstance(doubao_result["data"], list) and doubao_result["data"]:
image_urls = []
for item in doubao_result["data"]:
if isinstance(item, dict) and "url" in item:
image_urls.append(item["url"])
if image_urls:
try:
print(f"TripleAPI: 豆包下载{len(image_urls)}张图片")
images_tensors = []
target_size = None
for idx, image_url in enumerate(image_urls):
print(f"TripleAPI: 豆包下载图片 {idx+1}/{len(image_urls)}: {image_url}")
if target_size is None:
tensor, wh = _download_image_to_tensor_with_size(image_url, return_size=True, timeout_seconds=min(poll_timeout, 60))
target_size = wh
images_tensors.append(tensor)
else:
tensor = _download_image_to_tensor_with_size(image_url, target_size=target_size, timeout_seconds=min(poll_timeout, 60))
images_tensors.append(tensor)
if images_tensors:
doubao_image = torch.cat(images_tensors, dim=0)
status_info = f"豆包: 成功({doubao_type}, {len(images_tensors)}张图片, 第{attempt + 1}次尝试); "
print(f"TripleAPI: 豆包成功,图片形状: {doubao_image.shape}")
else:
status_info = "豆包: 所有图片下载失败; "
doubao_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
except Exception as e:
status_info = f"豆包: 图片下载失败 - {str(e)}; "
print(f"TripleAPI: 豆包图片下载失败: {str(e)}")
doubao_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
else:
status_info = "豆包: 响应中未找到图片URL; "
print("TripleAPI: 豆包响应中未找到图片URL")
doubao_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
else:
status_info = "豆包: API响应格式错误; "
print("TripleAPI: 豆包API响应格式错误")
doubao_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
else:
# 文生图模式,不需要图片
print("TripleAPI: 豆包文生图模式")
doubao_result = _send_doubao_request(
api_key=doubao_api_key,
prompt=prompt,
size=doubao_size,
model=doubao_model,
watermark=doubao_watermark,
seed=(None if seed < 0 else seed),
image_urls=None,
sequential_generation=doubao_sequential_generation,
num_images=doubao_num_images,
timeout_seconds=poll_timeout,
server_url=doubao_server_url,
)
doubao_response = json.dumps(doubao_result, ensure_ascii=False)
print(f"TripleAPI: 豆包API响应: {doubao_result}")
# 检查响应是否包含错误(API调用失败的情况)
has_error = False
error_msg = None
if isinstance(doubao_result, dict):
if "error" in doubao_result or "http_status" in doubao_result:
has_error = True
error_msg = doubao_result.get("error", f"HTTP错误: {doubao_result.get('http_status', 'unknown')}")
# 如果API返回错误,抛出异常以触发重试逻辑
if has_error:
raise Exception(error_msg or "API返回错误响应")
# 提取图片URL并下载(支持多张组图)
if "data" in doubao_result and isinstance(doubao_result["data"], list) and doubao_result["data"]:
image_urls = []
for item in doubao_result["data"]:
if isinstance(item, dict) and "url" in item:
image_urls.append(item["url"])
if image_urls:
try:
print(f"TripleAPI: 豆包下载{len(image_urls)}张图片")
images_tensors = []
target_size = None
for idx, image_url in enumerate(image_urls):
print(f"TripleAPI: 豆包下载图片 {idx+1}/{len(image_urls)}: {image_url}")
if target_size is None:
tensor, wh = _download_image_to_tensor_with_size(image_url, return_size=True, timeout_seconds=min(poll_timeout, 60))
target_size = wh
images_tensors.append(tensor)
else:
tensor = _download_image_to_tensor_with_size(image_url, target_size=target_size, timeout_seconds=min(poll_timeout, 60))
images_tensors.append(tensor)
if images_tensors:
doubao_image = torch.cat(images_tensors, dim=0)
status_info = f"豆包: 成功(txt2img, {len(images_tensors)}张图片, 第{attempt + 1}次尝试); "
print(f"TripleAPI: 豆包成功,图片形状: {doubao_image.shape}")
else:
status_info = "豆包: 所有图片下载失败; "
doubao_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
except Exception as e:
status_info = f"豆包: 图片下载失败 - {str(e)}; "
print(f"TripleAPI: 豆包图片下载失败: {str(e)}")
doubao_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
else:
status_info = "豆包: 响应中未找到图片URL; "
print("TripleAPI: 豆包响应中未找到图片URL")
doubao_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
else:
status_info = "豆包: API响应格式错误; "
print("TripleAPI: 豆包API响应格式错误")
doubao_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
return doubao_image, doubao_response, status_info
except Exception as e:
last_error = e
print(f"TripleAPI: 豆包第{attempt + 1}次尝试失败: {str(e)}")
if attempt < max_retries - 1:
print(f"TripleAPI: 豆包将在{retry_interval}秒后重试...")
time.sleep(retry_interval) # 固定重试间隔
# 所有重试都失败了
status_info = f"豆包: 失败(已重试{max_retries}次) - {str(last_error)}; "
print(f"TripleAPI: 豆包最终失败: {str(last_error)}")
doubao_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
doubao_response = json.dumps({"error": str(last_error), "retries": max_retries}, ensure_ascii=False)
return doubao_image, doubao_response, status_info
def _call_hidream_api(
prompt: str,
hidream_type: str,
hidream_req_key: str,
image_urls_input: str,
seed: int,
hidream_scale: float,
fallback_image_url: str,
poll_interval: int,
poll_timeout: int,
max_retries: int = 3,
retry_interval: int = 2,
hidream_server_url: str = "https://visual.volcengineapi.com",
) -> Tuple[Any, str, str, str]:
"""调用即梦API的并行执行函数(支持重试)"""
last_error = None
for attempt in range(max_retries):
try:
if attempt > 0:
print(f"TripleAPI: 即梦第{attempt + 1}次重试...")
else:
print(f"TripleAPI: 开始调用即梦 API,类型: {hidream_type}")
# 从统一的图片输入中解析URLs用于即梦(支持多图,最多10张)
hidream_image_urls = []
if image_urls_input:
parsed_urls = _parse_image_urls_field(image_urls_input)
if parsed_urls:
# 限制最多10张图片
hidream_image_urls = parsed_urls[:10] if len(parsed_urls) > 10 else parsed_urls
if len(parsed_urls) > 10:
print(f"TripleAPI: 即梦输入图片超过10张,已截取前10张: {len(hidream_image_urls)}张")
print(f"TripleAPI: 即梦使用图片URLs: {hidream_image_urls}")
hidream_result = _send_hidream_request_multi(
req_key=hidream_req_key,
prompt=prompt,
hidream_type=hidream_type,
image_urls=hidream_image_urls if hidream_image_urls else None,
seed=(None if seed < 0 else seed),
scale=hidream_scale,
timeout_seconds=poll_timeout,
server_url=hidream_server_url,
)
hidream_response = json.dumps(hidream_result, ensure_ascii=False)
print(f"TripleAPI: 即梦API响应: {hidream_result}")
# 检查响应是否包含错误(API调用失败的情况)
has_error = False
error_msg = None
if isinstance(hidream_result, dict):
# 检查HTTP错误状态
if "http_status" in hidream_result:
has_error = True
error_msg = f"HTTP错误: {hidream_result.get('http_status', 'unknown')}"
# 检查即梦API错误码(非10000表示错误)
elif "code" in hidream_result and hidream_result["code"] != 10000:
has_error = True
error_msg = hidream_result.get("message", f"API错误码: {hidream_result.get('code', 'unknown')}")
# 检查是否有error字段
elif "error" in hidream_result:
has_error = True
error_msg = hidream_result.get("error", "API返回错误")
# 如果API返回错误,抛出异常以触发重试逻辑
if has_error:
raise Exception(error_msg or "API返回错误响应")
# 根据API响应处理结果 (即梦API 4.0使用10000作为成功码)
if "code" in hidream_result and hidream_result["code"] == 10000:
# 成功响应,检查是否有task_id需要轮询
if "data" in hidream_result and "task_id" in hidream_result["data"]:
hidream_task_id = str(hidream_result["data"]["task_id"])
# 轮询任务状态
print(f"即梦: 开始轮询任务 {hidream_task_id}")
start_time = time.time()
poll_count = 0
while time.time() - start_time < poll_timeout:
poll_count += 1
try:
print(f"即梦: 第{poll_count}次查询状态...")
status_result = _check_hidream_status(
req_key=hidream_req_key,
task_id=hidream_task_id,
timeout_seconds=poll_timeout,
server_url=hidream_server_url,
)
print(f"即梦: 状态查询响应: {json.dumps(status_result, ensure_ascii=False)}")
# 检查状态查询响应是否包含HTTP错误(需要重试整个API调用)
if isinstance(status_result, dict) and "http_status" in status_result:
http_status = status_result.get("http_status", 0)
if http_status >= 500: # 5xx错误表示服务器错误,应该重试
error_msg = f"状态查询HTTP错误: {http_status}"
raise Exception(error_msg)
if "code" in status_result and status_result["code"] == 10000:
if "data" in status_result and "status" in status_result["data"]:
task_status = status_result["data"]["status"]
print(f"即梦: 任务状态: {task_status}")
if task_status == "done":
# 任务完成,提取图片URLs(支持多张图片)
image_urls = []
if "image_urls" in status_result["data"] and status_result["data"]["image_urls"]:
image_urls = status_result["data"]["image_urls"]
elif "image_url" in status_result["data"]:
image_urls = [status_result["data"]["image_url"]]
print(f"即梦: 提取到{len(image_urls)}个图片URL")
if image_urls:
try:
# 下载所有图片并合并(类似豆包的处理方式)
print(f"TripleAPI: 即梦下载{len(image_urls)}张图片")
images_tensors = []
target_size = None
for idx, image_url in enumerate(image_urls):
print(f"TripleAPI: 即梦下载图片 {idx+1}/{len(image_urls)}: {image_url}")
if target_size is None:
tensor, wh = _download_image_to_tensor_with_size(image_url, return_size=True, timeout_seconds=min(poll_timeout, 60))
target_size = wh
images_tensors.append(tensor)
else:
tensor = _download_image_to_tensor_with_size(image_url, target_size=target_size, timeout_seconds=min(poll_timeout, 60))
images_tensors.append(tensor)
# 合并所有图片为一张
if images_tensors:
hidream_image = torch.cat(images_tensors, dim=0)
print(f"TripleAPI: 即梦成功合并{len(images_tensors)}张图片")
else:
hidream_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
status_info = f"即梦: 成功(第{attempt + 1}次尝试); "
hidream_response = json.dumps(status_result, ensure_ascii=False)
print("即梦: 图片下载成功!")
return hidream_image, hidream_response, hidream_task_id, status_info
except Exception as e:
status_info = f"即梦: 图片下载失败 - {str(e)}; "
hidream_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
print(f"即梦: 图片下载失败 - {str(e)}")
return hidream_image, hidream_response, hidream_task_id, status_info
else:
status_info = "即梦: 响应中未找到图片URL; "
hidream_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
print("即梦: 响应中未找到图片URL")
return hidream_image, hidream_response, hidream_task_id, status_info
elif task_status in ["not_found", "expired", "failed"]:
error_msg = status_result.get("message", f"任务{task_status}")
status_info = f"即梦: {error_msg}; "
hidream_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
print(f"即梦: 任务失败 - {error_msg}")
return hidream_image, hidream_response, hidream_task_id, status_info
# 如果是 in_queue, generating 或其他状态,继续等待
else:
print(f"即梦: 任务进行中,状态: {task_status},继续等待...")
else:
print(f"即梦: 状态响应格式异常: {status_result}")
status_info = f"即梦: 状态响应格式异常; "
hidream_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
return hidream_image, hidream_response, hidream_task_id, status_info
else:
# API返回错误
error_msg = status_result.get("message", "API查询失败")
status_info = f"即梦: 查询错误 - {error_msg}; "
hidream_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
print(f"即梦: API查询错误 - {error_msg}")
return hidream_image, hidream_response, hidream_task_id, status_info
time.sleep(poll_interval)
except Exception as e:
status_info = f"即梦: 状态查询错误 - {str(e)}; "
hidream_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
print(f"即梦: 状态查询异常 - {str(e)}")
return hidream_image, hidream_response, hidream_task_id, status_info
else:
# 超时
status_info = "即梦: 轮询超时; "
hidream_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
return hidream_image, hidream_response, hidream_task_id, status_info
# 检查是否直接返回了图片URLs(某些接口可能直接返回)
elif "data" in hidream_result and ("image_url" in hidream_result["data"] or "image_urls" in hidream_result["data"]):
image_urls = []
if "image_urls" in hidream_result["data"] and hidream_result["data"]["image_urls"]:
image_urls = hidream_result["data"]["image_urls"]
elif "image_url" in hidream_result["data"]:
image_urls = [hidream_result["data"]["image_url"]]
if image_urls:
try:
# 下载所有图片并合并(类似豆包的处理方式)
print(f"TripleAPI: 即梦直接返回{len(image_urls)}张图片")
images_tensors = []
target_size = None
for idx, image_url in enumerate(image_urls):
print(f"TripleAPI: 即梦下载图片 {idx+1}/{len(image_urls)}: {image_url}")
if target_size is None:
tensor, wh = _download_image_to_tensor_with_size(image_url, return_size=True, timeout_seconds=min(poll_timeout, 60))
target_size = wh
images_tensors.append(tensor)
else:
tensor = _download_image_to_tensor_with_size(image_url, target_size=target_size, timeout_seconds=min(poll_timeout, 60))
images_tensors.append(tensor)
# 合并所有图片为一张
if images_tensors:
hidream_image = torch.cat(images_tensors, dim=0)
print(f"TripleAPI: 即梦成功合并{len(images_tensors)}张图片")
else:
hidream_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
status_info = f"即梦: 成功(第{attempt + 1}次尝试); "
return hidream_image, hidream_response, "", status_info
except Exception as e:
status_info = f"即梦: 图片下载失败 - {str(e)}; "
hidream_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
return hidream_image, hidream_response, "", status_info
else:
status_info = "即梦: API响应格式错误; "
hidream_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
return hidream_image, hidream_response, "", status_info
else:
# 这种情况不应该发生,因为已经在前面检查过了,但为了安全起见保留
error_msg = hidream_result.get("message", "未知错误")
status_info = f"即梦: API错误 - {error_msg}; "
hidream_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
# 如果code不是10000,抛出异常以触发重试
raise Exception(f"API错误: {error_msg}")
except Exception as e:
last_error = e
print(f"TripleAPI: 即梦第{attempt + 1}次尝试失败: {str(e)}")
if attempt < max_retries - 1:
print(f"TripleAPI: 即梦将在{retry_interval}秒后重试...")
time.sleep(retry_interval) # 固定重试间隔
# 所有重试都失败了
status_info = f"即梦: 失败(已重试{max_retries}次) - {str(last_error)}; "
print(f"TripleAPI: 即梦最终失败: {str(last_error)}")
hidream_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
hidream_response = json.dumps({"error": str(last_error), "retries": max_retries}, ensure_ascii=False)
return hidream_image, hidream_response, "", status_info
class TripleAPIGenerate:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": ""}),
# API网址配置(按顺序:网址在前,KEY在后)
"nanobanana_server_url": ("STRING", {"default": DEFAULT_SERVER_URL, "multiline": False}),
"nanobanana_api_key": ("STRING", {"default": "", "multiline": False}),
"doubao_server_url": ("STRING", {"default": "https://ark.cn-beijing.volces.com/api/v3/images/generations", "multiline": False}),
"doubao_api_key": ("STRING", {"default": "", "multiline": False}),
"hidream_server_url": ("STRING", {"default": "https://visual.volcengineapi.com", "multiline": False}),
"hidream_req_key": ("STRING", {"default": "", "multiline": False}),
# 平台开关
"enable_nanobanana": ("BOOLEAN", {"default": True}),
"enable_doubao": ("BOOLEAN", {"default": True}),
"enable_hidream": ("BOOLEAN", {"default": True}),
"seed": ("INT", {"default": -1, "min": -1, "max": 99999999}),
},
"optional": {
# 通用图片输入(统一所有API使用)
"image_urls_input": ("STRING", {"default": "", "multiline": True}),
# 通用轮询和超时设置
"poll_interval": ("INT", {"default": 3, "min": 1, "max": 60}),
"poll_timeout": ("INT", {"default": 240, "min": 5, "max": 3600}),
# 错误回退图片(错误时优先返回该URL图片,否则返白图)
"fallback_image_url": ("STRING", {"default": "", "multiline": False}),
# NanoBanana 参数
"nanobanana_type": (["TEXTTOIAMGE", "IMAGETOIAMGE"], {"default": "TEXTTOIAMGE"}),
"nanobanana_num_images": ("INT", {"default": 1, "min": 1, "max": 4}),
"nanobanana_image_size_mode": (["auto", "1:1", "9:16", "16:9", "3:4", "4:3", "3:2", "2:3", "5:4", "4:5", "21:9"], {"default": "auto"}),
"nanobanana_watermark": ("STRING", {"default": ""}),
# 豆包参数
"doubao_model": ("STRING", {"default": "doubao-seedream-4-0-250828"}),
"doubao_size": (["1K", "2K", "4K"], {"default": "1K"}),
"doubao_watermark": ("BOOLEAN", {"default": True}),
"doubao_type": (["txt2img", "img2img", "multi_img_fusion"], {"default": "txt2img"}),
"doubao_sequential_generation": (["disabled", "auto"], {"default": "disabled"}),
"doubao_num_images": ("INT", {"default": 1, "min": 1, "max": 15}),
# 即梦参数
"hidream_type": (["txt2img", "img2img", "shopImg2img", "vton"], {"default": "txt2img"}),
"hidream_scale": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 2.0, "step": 0.1}),
# 重试参数
"max_retries": ("INT", {"default": 3, "min": 1, "max": 5}),
"retry_interval": ("INT", {"default": 2, "min": 1, "max": 60}),
},
}
RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("nanobanana_image", "doubao_image", "hidream_image", "nanobanana_task_id", "nanobanana_response", "doubao_response", "hidream_response", "hidream_task_id", "status_info", "nanobanana_image_urls", "doubao_image_urls", "hidream_image_urls")
FUNCTION = "triple_generate"
CATEGORY = "AIYang_TripleAPI"
def triple_generate(
self,
prompt: str,
# API网址配置(按顺序:网址在前,KEY在后)
nanobanana_server_url: str = DEFAULT_SERVER_URL,
nanobanana_api_key: str = "",
doubao_server_url: str = "https://ark.cn-beijing.volces.com/api/v3/images/generations",
doubao_api_key: str = "",
hidream_server_url: str = "https://visual.volcengineapi.com",
hidream_req_key: str = "",
# 平台开关
enable_nanobanana: bool = True,
enable_doubao: bool = True,
enable_hidream: bool = True,
seed: int = -1,
# 通用图片输入
image_urls_input: str = "",
# 错误回退图片
fallback_image_url: str = "",
# 通用轮询和超时设置
poll_interval: int = 3,
poll_timeout: int = 240,
# NanoBanana 参数
nanobanana_type: str = "TEXTTOIAMGE",
nanobanana_num_images: int = 1,
nanobanana_image_size_mode: str = "auto",
nanobanana_watermark: str = "",
# 豆包参数
doubao_model: str = "doubao-seedream-4-0-250828",
doubao_size: str = "1K",
doubao_watermark: bool = True,
doubao_type: str = "txt2img",
doubao_sequential_generation: str = "disabled",
doubao_num_images: int = 1,
# 即梦参数
hidream_type: str = "txt2img",
hidream_scale: float = 0.5,
# 重试参数
max_retries: int = 3,
retry_interval: int = 2,
) -> Tuple[Any, Any, Any, str, str, str, str, str, str, str, str, str]:
if not prompt:
raise ValueError("提示词不能为空")
# 限制种子值范围
if seed is not None and seed > 99999999:
seed = seed % 100000000
# 初始化结果变量
nanobanana_image = None
doubao_image = None
hidream_image = None
nanobanana_task_id = ""
nanobanana_response = ""
doubao_response = ""
hidream_response = ""
hidream_task_id = ""
status_info = ""
# 初始化图片URL数组变量
nanobanana_image_urls = "[]"
doubao_image_urls = "[]"
hidream_image_urls = "[]"
# 并行调用三个API
print("TripleAPI: 开始并行调用三个API...")
# 准备并行任务
tasks = []
# NanoBanana任务
if enable_nanobanana and nanobanana_api_key:
tasks.append(('nanobanana', _call_nanobanana_api, {
'prompt': prompt,
'nanobanana_type': nanobanana_type,
'nanobanana_num_images': nanobanana_num_images,
'nanobanana_image_size_mode': nanobanana_image_size_mode,
'nanobanana_api_key': nanobanana_api_key,
'image_urls_input': image_urls_input,
'seed': seed,
'nanobanana_watermark': nanobanana_watermark,
'nanobanana_server_url': nanobanana_server_url,
'fallback_image_url': fallback_image_url,
'poll_interval': poll_interval,
'poll_timeout': poll_timeout,
'max_retries': max_retries, # 使用用户配置的重试次数
'retry_interval': retry_interval, # 使用用户配置的重试间隔
}))
else:
status_info += "NanoBanana: 已禁用或未提供API密钥; "
print("TripleAPI: NanoBanana已禁用或未提供API密钥")
nanobanana_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
nanobanana_task_id = ""
nanobanana_response = json.dumps({"error": "API已禁用或未提供密钥"}, ensure_ascii=False)
nanobanana_image_urls = json.dumps(["api_disabled"], ensure_ascii=False)
# 豆包任务
if enable_doubao and doubao_api_key:
tasks.append(('doubao', _call_doubao_api, {
'prompt': prompt,
'doubao_type': doubao_type,
'doubao_api_key': doubao_api_key,
'image_urls_input': image_urls_input,
'seed': seed,
'doubao_model': doubao_model,
'doubao_size': doubao_size,
'doubao_watermark': doubao_watermark,
'doubao_sequential_generation': doubao_sequential_generation,
'doubao_num_images': doubao_num_images,
'fallback_image_url': fallback_image_url,
'poll_timeout': poll_timeout,
'max_retries': max_retries, # 使用用户配置的重试次数
'retry_interval': retry_interval, # 使用用户配置的重试间隔
'doubao_server_url': doubao_server_url,
}))
else:
status_info += "豆包: 已禁用或未提供API密钥; "
print("TripleAPI: 豆包已禁用或未提供API密钥")
doubao_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
doubao_response = json.dumps({"error": "API已禁用或未提供密钥"}, ensure_ascii=False)
doubao_image_urls = json.dumps(["api_disabled"], ensure_ascii=False)
# 即梦任务
if enable_hidream and hidream_req_key:
tasks.append(('hidream', _call_hidream_api, {
'prompt': prompt,
'hidream_type': hidream_type,
'hidream_req_key': hidream_req_key,
'image_urls_input': image_urls_input,
'seed': seed,
'hidream_scale': hidream_scale,
'fallback_image_url': fallback_image_url,
'poll_interval': poll_interval,
'poll_timeout': poll_timeout,
'max_retries': max_retries, # 使用用户配置的重试次数
'retry_interval': retry_interval, # 使用用户配置的重试间隔
'hidream_server_url': hidream_server_url,
}))
else:
status_info += "即梦: 已禁用或未提供API密钥; "
print("TripleAPI: 即梦已禁用或未提供API密钥")
hidream_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
hidream_response = json.dumps({"error": "API已禁用或未提供密钥"}, ensure_ascii=False)
hidream_task_id = ""
hidream_image_urls = json.dumps(["api_disabled"], ensure_ascii=False)
# 使用线程池并行执行
with ThreadPoolExecutor(max_workers=3) as executor:
# 提交所有任务
future_to_api = {
executor.submit(func, **kwargs): api_name
for api_name, func, kwargs in tasks
}
# 等待所有任务完成并收集结果
for future in as_completed(future_to_api):
api_name = future_to_api[future]
try:
result = future.result()
if api_name == 'nanobanana':
nanobanana_image, nanobanana_task_id, nanobanana_response, api_status = result
status_info += api_status
# 提取NanoBanana图片URLs
try:
response_data = json.loads(nanobanana_response)
urls = _extract_image_urls_from_response(response_data)
if urls:
nanobanana_image_urls = json.dumps(urls, ensure_ascii=False)
print(f"TripleAPI: NanoBanana提取到{len(urls)}个URL")
else:
nanobanana_image_urls = json.dumps(["api_error"], ensure_ascii=False)
except Exception as e:
print(f"TripleAPI: NanoBanana URL提取失败: {str(e)}")
nanobanana_image_urls = json.dumps(["api_error"], ensure_ascii=False)
elif api_name == 'doubao':
doubao_image, doubao_response, api_status = result
status_info += api_status
# 提取豆包图片URLs
try:
response_data = json.loads(doubao_response)
urls = _extract_image_urls_from_response(response_data)
if urls:
doubao_image_urls = json.dumps(urls, ensure_ascii=False)
print(f"TripleAPI: 豆包提取到{len(urls)}个URL")
else:
doubao_image_urls = json.dumps(["api_error"], ensure_ascii=False)
except Exception as e:
print(f"TripleAPI: 豆包URL提取失败: {str(e)}")
doubao_image_urls = json.dumps(["api_error"], ensure_ascii=False)
elif api_name == 'hidream':
hidream_image, hidream_response, hidream_task_id, api_status = result
status_info += api_status
# 提取即梦图片URLs
try:
response_data = json.loads(hidream_response)
urls = _extract_image_urls_from_response(response_data)
if urls:
hidream_image_urls = json.dumps(urls, ensure_ascii=False)
print(f"TripleAPI: 即梦提取到{len(urls)}个URL")
else:
hidream_image_urls = json.dumps(["api_error"], ensure_ascii=False)
except Exception as e:
print(f"TripleAPI: 即梦URL提取失败: {str(e)}")
hidream_image_urls = json.dumps(["api_error"], ensure_ascii=False)
except Exception as e:
print(f"TripleAPI: {api_name} 并行执行异常: {str(e)}")
if api_name == 'nanobanana':
nanobanana_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
nanobanana_task_id = ""
nanobanana_response = json.dumps({"error": str(e)}, ensure_ascii=False)
status_info += f"NanoBanana: 并行执行异常 - {str(e)}; "
nanobanana_image_urls = json.dumps(["api_error"], ensure_ascii=False)
elif api_name == 'doubao':
doubao_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
doubao_response = json.dumps({"error": str(e)}, ensure_ascii=False)
status_info += f"豆包: 并行执行异常 - {str(e)}; "
doubao_image_urls = json.dumps(["api_error"], ensure_ascii=False)
elif api_name == 'hidream':
hidream_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
hidream_task_id = ""
hidream_response = json.dumps({"error": str(e)}, ensure_ascii=False)
status_info += f"即梦: 并行执行异常 - {str(e)}; "
hidream_image_urls = json.dumps(["api_error"], ensure_ascii=False)
print("TripleAPI: 所有API并行执行完成")
# 确保所有图片都不为空
if nanobanana_image is None:
print("TripleAPI: NanoBanana图片为空,使用白图占位")
nanobanana_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
if doubao_image is None:
print("TripleAPI: 豆包图片为空,使用白图占位")
doubao_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
if hidream_image is None:
print("TripleAPI: 即梦图片为空,使用白图占位")
hidream_image = _create_fallback_or_white_image_tensor(
fallback_image_url=fallback_image_url,
timeout_seconds=min(poll_timeout, 60),
)
print(f"TripleAPI: 最终状态信息: {status_info.strip()}")
return (
nanobanana_image,
doubao_image,
hidream_image,
nanobanana_task_id,
nanobanana_response,
doubao_response,
hidream_response,
hidream_task_id,
status_info.strip(),
nanobanana_image_urls,
doubao_image_urls,
hidream_image_urls
)
NODE_CLASS_MAPPINGS = {
"NanoBananaGenerate": NanoBananaGenerate,
"TripleAPIGenerate": TripleAPIGenerate,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"NanoBananaGenerate": "NanoBanana Generate",
"TripleAPIGenerate": "Triple API Generate (NanoBanana + Doubao + HiDream)",
}