Update OpenAI node docs for multi-image input and remove Xmiles-nanobanana node
This commit is contained in:
@@ -368,7 +368,7 @@ Rui-Node🐶 是一个功能丰富的 ComfyUI 节点集合,提供图像处理
|
||||
**分类**: `Rui-Node🐶/AI模型🤖`
|
||||
|
||||
**功能描述**:
|
||||
连接 OpenAI 或兼容 API(如 DeepSeek、Moonshot 等),进行文本生成或多模态图像理解。
|
||||
连接 OpenAI 或兼容 API(如 DeepSeek、Moonshot 等),进行文本生成或多模态图像理解,支持最多 6 张图像同时输入。
|
||||
|
||||
**输入参数**:
|
||||
- `api_url` (STRING): API 接口地址
|
||||
@@ -379,15 +379,30 @@ Rui-Node🐶 是一个功能丰富的 ComfyUI 节点集合,提供图像处理
|
||||
- `system_prompt` (STRING): 系统提示词
|
||||
- `user_prompt` (STRING): 用户提示词
|
||||
- `seed` (INT): 随机种子,用于控制生成的随机性
|
||||
- `image` (IMAGE, 可选): 输入图像(用于多模态模型)
|
||||
- `image_1` ~ `image_6` (IMAGE, 可选): 最多 6 张输入图像
|
||||
- 说明: 用户有几张图就连接几个输入口,无需手动 Batch
|
||||
- 规则: 节点内部会自动收集所有已连接图像,并使用 `torch.cat(..., dim=0)` 合并后逐张编码发送到 API
|
||||
- 注意: 多张图像尺寸需一致,否则会返回尺寸不一致错误
|
||||
- `temperature` (FLOAT, 可选): 采样温度
|
||||
- 默认值: 0.3
|
||||
- 范围: 0.0 ~ 2.0
|
||||
- `max_tokens` (INT, 可选): 最大输出 token 数
|
||||
- 默认值: 500
|
||||
- 范围: 1 ~ 8192
|
||||
- `detail` (选择, 可选): 图像分析细节等级
|
||||
- 选项: low, high, auto
|
||||
- 默认值: auto
|
||||
- `proxy_url` (STRING, 可选): HTTP/HTTPS 代理地址
|
||||
- 示例: `http://127.0.0.1:7890`
|
||||
|
||||
**输出**:
|
||||
- `text` (STRING): 模型生成的文本内容
|
||||
|
||||
**使用场景**:
|
||||
- 调用 LLM 进行文本生成
|
||||
- 使用 Vision 模型进行图像理解
|
||||
- 使用 Vision 模型进行单图或多图联合理解
|
||||
- 连接本地或第三方兼容 OpenAI 协议的 API
|
||||
- 对多张参考图做综合分析、比对与总结
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -30,8 +30,6 @@ from .color_matcher_node import NODE_DISPLAY_NAME_MAPPINGS as COLORMATCHER_NODE_
|
||||
# 新增:素材拆分节点
|
||||
from .image_splitter_node import NODE_CLASS_MAPPINGS as IMAGESPLITTER_NODE_CLASS_MAPPINGS
|
||||
from .image_splitter_node import NODE_DISPLAY_NAME_MAPPINGS as IMAGESPLITTER_NODE_DISPLAY_NAME_MAPPINGS
|
||||
from .xmiles_nanobanana_node import NODE_CLASS_MAPPINGS as XMILES_NODE_CLASS_MAPPINGS
|
||||
from .xmiles_nanobanana_node import NODE_DISPLAY_NAME_MAPPINGS as XMILES_NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
# 合并节点映射字典
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
@@ -50,7 +48,6 @@ NODE_CLASS_MAPPINGS.update(UTF8_NODE_CLASS_MAPPINGS)
|
||||
NODE_CLASS_MAPPINGS.update(OPENAI_NODE_CLASS_MAPPINGS)
|
||||
NODE_CLASS_MAPPINGS.update(COLORMATCHER_NODE_CLASS_MAPPINGS)
|
||||
NODE_CLASS_MAPPINGS.update(IMAGESPLITTER_NODE_CLASS_MAPPINGS)
|
||||
NODE_CLASS_MAPPINGS.update(XMILES_NODE_CLASS_MAPPINGS)
|
||||
|
||||
# 合并节点显示名称映射
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
@@ -69,6 +66,5 @@ NODE_DISPLAY_NAME_MAPPINGS.update(UTF8_NODE_DISPLAY_NAME_MAPPINGS)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(OPENAI_NODE_DISPLAY_NAME_MAPPINGS)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(COLORMATCHER_NODE_DISPLAY_NAME_MAPPINGS)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(IMAGESPLITTER_NODE_DISPLAY_NAME_MAPPINGS)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(XMILES_NODE_DISPLAY_NAME_MAPPINGS)
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+84
-47
@@ -54,10 +54,28 @@ class OpenAINode:
|
||||
"min": 0,
|
||||
"max": 0xffffffffffffffff
|
||||
}),
|
||||
# max_tokens, temperature 等常用参数可以根据需要添加,这里保持精简
|
||||
},
|
||||
"optional": {
|
||||
"image": ("IMAGE",),
|
||||
"image_1": ("IMAGE",),
|
||||
"image_2": ("IMAGE",),
|
||||
"image_3": ("IMAGE",),
|
||||
"image_4": ("IMAGE",),
|
||||
"image_5": ("IMAGE",),
|
||||
"image_6": ("IMAGE",),
|
||||
"temperature": ("FLOAT", {
|
||||
"default": 0.3,
|
||||
"min": 0.0,
|
||||
"max": 2.0,
|
||||
"step": 0.1
|
||||
}),
|
||||
"max_tokens": ("INT", {
|
||||
"default": 500,
|
||||
"min": 1,
|
||||
"max": 8192
|
||||
}),
|
||||
"detail": (["low", "high", "auto"], {
|
||||
"default": "auto"
|
||||
}),
|
||||
"proxy_url": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
@@ -71,80 +89,103 @@ class OpenAINode:
|
||||
FUNCTION = "generate_content"
|
||||
CATEGORY = "Rui-Node🐶/AI模型🤖"
|
||||
|
||||
def generate_content(self, api_url, api_key, model, system_prompt, user_prompt, seed, image=None, proxy_url=""):
|
||||
def _encode_image_tensor(self, img_tensor):
|
||||
img_np = img_tensor.cpu().numpy()
|
||||
img_np = np.clip(img_np, 0, 1)
|
||||
img_pil = Image.fromarray((img_np * 255).astype(np.uint8), 'RGB')
|
||||
buffered = io.BytesIO()
|
||||
img_pil.save(buffered, format="JPEG")
|
||||
return base64.b64encode(buffered.getvalue()).decode('utf-8')
|
||||
|
||||
def generate_content(
|
||||
self,
|
||||
api_url,
|
||||
api_key,
|
||||
model,
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
seed,
|
||||
image_1=None,
|
||||
image_2=None,
|
||||
image_3=None,
|
||||
image_4=None,
|
||||
image_5=None,
|
||||
image_6=None,
|
||||
temperature=0.3,
|
||||
max_tokens=500,
|
||||
detail="auto",
|
||||
proxy_url=""
|
||||
):
|
||||
"""
|
||||
调用 OpenAI API 生成内容
|
||||
"""
|
||||
|
||||
# 准备消息列表
|
||||
|
||||
all_images = [
|
||||
img for img in [
|
||||
image_1,
|
||||
image_2,
|
||||
image_3,
|
||||
image_4,
|
||||
image_5,
|
||||
image_6,
|
||||
] if img is not None
|
||||
]
|
||||
|
||||
if not all_images:
|
||||
return ("Error: 至少需要连接一张图像到 image_1 ~ image_6。",)
|
||||
|
||||
try:
|
||||
images = torch.cat(all_images, dim=0)
|
||||
except Exception as e:
|
||||
return (f"Error: 无法合并多张图像,请确保所有输入图像尺寸一致。详细信息: {str(e)}",)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt}
|
||||
]
|
||||
|
||||
|
||||
user_content = []
|
||||
|
||||
# 添加用户文本提示词
|
||||
|
||||
if user_prompt:
|
||||
user_content.append({
|
||||
"type": "text",
|
||||
"text": user_prompt
|
||||
})
|
||||
|
||||
# 处理图像输入
|
||||
if image is not None:
|
||||
# 获取批次中的第一张图像
|
||||
img_tensor = image[0]
|
||||
|
||||
# 将 Tensor 转换为 PIL Image
|
||||
img_np = img_tensor.cpu().numpy()
|
||||
img_np = np.clip(img_np, 0, 1)
|
||||
img_pil = Image.fromarray((img_np * 255).astype(np.uint8), 'RGB')
|
||||
|
||||
# 将图像转换为 base64
|
||||
buffered = io.BytesIO()
|
||||
img_pil.save(buffered, format="JPEG")
|
||||
img_base64 = base64.b64encode(buffered.getvalue()).decode('utf-8')
|
||||
|
||||
# 添加图像内容
|
||||
|
||||
for idx in range(images.shape[0]):
|
||||
img_tensor = images[idx]
|
||||
img_base64 = self._encode_image_tensor(img_tensor)
|
||||
user_content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{img_base64}"
|
||||
"url": f"data:image/jpeg;base64,{img_base64}",
|
||||
"detail": detail
|
||||
}
|
||||
})
|
||||
|
||||
# 如果 user_content 为空,且没有图像,至少添加一个空文本以防 API 报错
|
||||
|
||||
if not user_content:
|
||||
user_content.append({
|
||||
user_content.append({
|
||||
"type": "text",
|
||||
"text": " "
|
||||
"text": " "
|
||||
})
|
||||
|
||||
# 构造用户消息
|
||||
# 注意:对于不支持多模态的模型(如 gpt-3.5-turbo),发送 image_url 可能会报错
|
||||
# 但遵循“符合最新规范”的要求,我们默认使用 content list 结构
|
||||
# 如果模型不支持 list content,可以尝试回退到纯字符串(但这会丢失图片)
|
||||
# 这里为了保持代码简洁,我们始终使用 list 结构,依赖用户选择支持 vision 的模型或仅输入文本
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": user_content
|
||||
})
|
||||
|
||||
# 构造请求头
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}"
|
||||
}
|
||||
|
||||
# 构造请求体
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"seed": seed,
|
||||
# 可以添加 temperature 等参数
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
|
||||
# 处理代理设置
|
||||
|
||||
proxies = None
|
||||
if proxy_url and proxy_url.strip():
|
||||
proxies = {
|
||||
@@ -153,14 +194,10 @@ class OpenAINode:
|
||||
}
|
||||
|
||||
try:
|
||||
# 发送请求
|
||||
response = requests.post(api_url, headers=headers, json=payload, proxies=proxies, timeout=60)
|
||||
response.raise_for_status()
|
||||
|
||||
# 解析响应
|
||||
result = response.json()
|
||||
|
||||
# 提取生成的文本
|
||||
|
||||
if "choices" in result and len(result["choices"]) > 0:
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
return (content,)
|
||||
|
||||
@@ -2,4 +2,3 @@ torch
|
||||
numpy
|
||||
Pillow
|
||||
requests
|
||||
oss2
|
||||
|
||||
@@ -1,452 +0,0 @@
|
||||
import torch
|
||||
import numpy as np
|
||||
import requests
|
||||
import json
|
||||
import uuid
|
||||
import io
|
||||
import base64
|
||||
import time
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
import oss2 # type: ignore
|
||||
except Exception:
|
||||
oss2 = None
|
||||
|
||||
class XmilesNanobananaNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"multiline": True, "default": ""}),
|
||||
"resolution": (["1K", "2K", "4K"], {"default": "4K"}),
|
||||
"aspect_ratio": (["1:1","2:3","3:2","3:4","4:3","4:5","5:4","9:16","16:9","21:9"], {"default": "9:16"}),
|
||||
},
|
||||
"optional": {
|
||||
"images": ("IMAGE",),
|
||||
"proxy_url": ("STRING", {"default": "", "multiline": False}),
|
||||
"verbose": ("BOOLEAN", {"default": True}),
|
||||
"use_oss": ("BOOLEAN", {"default": False}),
|
||||
"on_queued": (["passthrough", "empty"], {"default": "passthrough"}),
|
||||
"oss_access_key": ("STRING", {"default": "", "multiline": False}),
|
||||
"oss_secret_key": ("STRING", {"default": "", "multiline": False}),
|
||||
"oss_endpoint": ("STRING", {"default": "", "multiline": False, "placeholder": "oss-cn-shanghai.aliyuncs.com"}),
|
||||
"oss_bucket_name": ("STRING", {"default": "", "multiline": False}),
|
||||
"oss_url_prefix": ("STRING", {"default": "", "multiline": False, "placeholder": "https://genai.holopix.cn"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "STRING")
|
||||
RETURN_NAMES = ("images", "log")
|
||||
OUTPUT_IS_LIST = (True, False)
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "Rui-Node🐶/AI模型🤖"
|
||||
|
||||
def _make_client_id(self):
|
||||
return f"{uuid.uuid4()}-test"
|
||||
|
||||
def _tensor_to_png_base64(self, tensor):
|
||||
arr = tensor.cpu().numpy()
|
||||
arr = np.clip(arr, 0, 1)
|
||||
img = Image.fromarray((arr * 255).astype(np.uint8), 'RGB')
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||
|
||||
def _pil_to_tensor(self, pil_img):
|
||||
if pil_img.mode != "RGB":
|
||||
pil_img = pil_img.convert("RGB")
|
||||
np_img = np.array(pil_img).astype(np.float32) / 255.0
|
||||
t = torch.from_numpy(np_img).unsqueeze(0)
|
||||
return t
|
||||
|
||||
def _upload_to_oss_and_get_url(self, tensor, ak, sk, endpoint, bucket_name, url_prefix, verbose=False, proxy_url=""):
|
||||
if oss2 is None:
|
||||
raise RuntimeError("oss2 is not installed. Please `pip install oss2` and try again.")
|
||||
arr = tensor.cpu().numpy()
|
||||
arr = np.clip(arr, 0, 1)
|
||||
img = Image.fromarray((arr * 255).astype(np.uint8), 'RGB')
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
data = buf.getvalue()
|
||||
key = f"comfyui/{uuid.uuid4().hex}.png"
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:oss_put_begin", {"key": key, "size": len(data)}, flush=True)
|
||||
auth = oss2.Auth(ak, sk)
|
||||
endp = endpoint if endpoint.startswith("http") else ("https://" + endpoint) if endpoint else ""
|
||||
old_http = None
|
||||
old_https = None
|
||||
try:
|
||||
if proxy_url and proxy_url.strip():
|
||||
old_http = os.environ.get("HTTP_PROXY")
|
||||
old_https = os.environ.get("HTTPS_PROXY")
|
||||
os.environ["HTTP_PROXY"] = proxy_url
|
||||
os.environ["HTTPS_PROXY"] = proxy_url
|
||||
bucket = oss2.Bucket(auth, endp, bucket_name)
|
||||
result = bucket.put_object(key, data)
|
||||
if result.status not in (200, 204):
|
||||
raise RuntimeError(f"OSS put_object failed, status={result.status}")
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:oss_put_error_primary", str(e), flush=True)
|
||||
if url_prefix:
|
||||
cname_ep = url_prefix if url_prefix.startswith("http") else ("https://" + url_prefix)
|
||||
try:
|
||||
bucket = oss2.Bucket(auth, cname_ep, bucket_name, is_cname=True)
|
||||
result = bucket.put_object(key, data)
|
||||
if result.status not in (200, 204):
|
||||
raise RuntimeError(f"CNAME put_object failed, status={result.status}")
|
||||
except Exception as e2:
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:oss_put_error_cname", str(e2), flush=True)
|
||||
raise
|
||||
else:
|
||||
raise
|
||||
finally:
|
||||
if proxy_url and proxy_url.strip():
|
||||
if old_http is None:
|
||||
os.environ.pop("HTTP_PROXY", None)
|
||||
else:
|
||||
os.environ["HTTP_PROXY"] = old_http
|
||||
if old_https is None:
|
||||
os.environ.pop("HTTPS_PROXY", None)
|
||||
else:
|
||||
os.environ["HTTPS_PROXY"] = old_https
|
||||
url = url_prefix.rstrip("/") + "/" + key
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:oss_put_done", {"url": url}, flush=True)
|
||||
return url
|
||||
|
||||
def _download_image_tensor(self, url, proxies=None):
|
||||
r = requests.get(url, proxies=proxies, timeout=60)
|
||||
r.raise_for_status()
|
||||
img = Image.open(io.BytesIO(r.content))
|
||||
return self._pil_to_tensor(img)
|
||||
|
||||
def generate(self, text, resolution, aspect_ratio, images=None, proxy_url="", verbose=True, use_oss=False, on_queued="passthrough",
|
||||
oss_access_key="", oss_secret_key="", oss_endpoint="", oss_bucket_name="", oss_url_prefix=""):
|
||||
t0 = time.perf_counter()
|
||||
logs = []
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:start", {"ts": t0, "resolution": resolution, "aspect_ratio": aspect_ratio}, flush=True)
|
||||
logs.append(f"start_ts={t0}")
|
||||
client_id = self._make_client_id()
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:client_id", client_id, flush=True)
|
||||
logs.append(f"client_id={client_id}")
|
||||
proxies = None
|
||||
if proxy_url and proxy_url.strip():
|
||||
proxies = {"http": proxy_url, "https": proxy_url}
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:proxies", proxies, flush=True)
|
||||
logs.append(f"proxies={proxy_url}")
|
||||
else:
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:proxies=none", flush=True)
|
||||
logs.append("proxies=none")
|
||||
|
||||
parts = []
|
||||
if text and text.strip():
|
||||
parts.append({"text": text})
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:text_len", len(text), flush=True)
|
||||
logs.append(f"text_len={len(text)}")
|
||||
|
||||
image_parts = []
|
||||
if images is not None:
|
||||
if isinstance(images, list):
|
||||
tensors = [img[0] for img in images]
|
||||
else:
|
||||
tensors = [images[0]]
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:image_count", len(tensors), flush=True)
|
||||
logs.append(f"image_count={len(tensors)}")
|
||||
for t in tensors:
|
||||
if use_oss:
|
||||
if not (oss_access_key and oss_secret_key and oss_endpoint and oss_bucket_name and oss_url_prefix):
|
||||
raise RuntimeError("use_oss=True but OSS credentials/config are missing.")
|
||||
url_uploaded = self._upload_to_oss_and_get_url(
|
||||
t, oss_access_key, oss_secret_key, oss_endpoint, oss_bucket_name, oss_url_prefix, verbose=verbose, proxy_url=proxy_url
|
||||
)
|
||||
image_parts.append({"inlineData": {"data": url_uploaded, "mimeType": "image/png"}})
|
||||
else:
|
||||
b64 = self._tensor_to_png_base64(t)
|
||||
image_parts.append({"inlineData": {"data": "data:image/png;base64," + b64, "mimeType": "image/png"}})
|
||||
|
||||
for p in image_parts:
|
||||
parts.append(p)
|
||||
|
||||
t1 = time.perf_counter()
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:parts_ready_ms", int((t1 - t0) * 1000), flush=True)
|
||||
logs.append(f"parts_ready_ms={(t1-t0)*1000:.2f}")
|
||||
|
||||
unique_id = str(uuid.uuid4().int)[:19]
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:unique_id", unique_id, flush=True)
|
||||
logs.append(f"unique_id={unique_id}")
|
||||
|
||||
body_obj = {
|
||||
"contents": [
|
||||
{
|
||||
"parts": parts,
|
||||
"role": "user"
|
||||
}
|
||||
],
|
||||
"filePath": {
|
||||
"inputs": {
|
||||
"filePath": ""
|
||||
}
|
||||
},
|
||||
"generationConfig": {
|
||||
"candidateCount": 1,
|
||||
"imageConfig": {
|
||||
"aspectRatio": aspect_ratio,
|
||||
"imageSize": resolution
|
||||
},
|
||||
"responseModalities": ["TEXT", "IMAGE"],
|
||||
"temperature": 1.0,
|
||||
"topP": 0.95
|
||||
},
|
||||
"model": "gemini-3.1-flash-image-preview"
|
||||
}
|
||||
|
||||
t2 = time.perf_counter()
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:body_size", len(json.dumps(body_obj, ensure_ascii=False)), flush=True)
|
||||
logs.append(f"body_size={len(json.dumps(body_obj, ensure_ascii=False))}")
|
||||
|
||||
payload = {
|
||||
"taskType": "ZENMUX",
|
||||
"clientId": client_id,
|
||||
"clientType": "image",
|
||||
"callBackService": "remoteApi",
|
||||
"extraData": {
|
||||
"faceDetailer": 0,
|
||||
"filePath": "",
|
||||
"loraNum": 0,
|
||||
"memberType": "PLUS",
|
||||
"moduleName": "全能编辑 V2",
|
||||
"resolution": "",
|
||||
"taskType": "ZENMUX",
|
||||
"uniqueId": unique_id,
|
||||
"workflowName": ""
|
||||
},
|
||||
"imgIdList": [],
|
||||
"memberType": "PLUS",
|
||||
"body": json.dumps(body_obj, ensure_ascii=False)
|
||||
}
|
||||
|
||||
url = "https://test.holopix.cn/ai-holopix-queue/api/prompt"
|
||||
try:
|
||||
t3 = time.perf_counter()
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:post_begin", {"ts": t3, "url": url}, flush=True)
|
||||
logs.append(f"post_begin_ts={t3}")
|
||||
resp = requests.post(url, headers={"Content-Type": "application/json"}, json=payload, proxies=proxies, timeout=60)
|
||||
t4 = time.perf_counter()
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:post_done", {"status_code": resp.status_code, "elapsed_ms": int((t4 - t3) * 1000)}, flush=True)
|
||||
logs.append(f"post_elapsed_ms={(t4-t3)*1000:.2f}")
|
||||
status = data.get("status")
|
||||
gen_status = data.get("generateStatus")
|
||||
# 补充本地发送的 clientId / uniqueId,便于轮询节点使用
|
||||
logs = {
|
||||
"status": status,
|
||||
"generateStatus": gen_status,
|
||||
"clientId": data.get("clientId"),
|
||||
"timestamp": data.get("timestamp"),
|
||||
"clientId_sent": client_id,
|
||||
"uniqueId_sent": unique_id,
|
||||
"raw": data
|
||||
}
|
||||
# 队列受理态识别(部分服务返回 code/success)
|
||||
if status is None and gen_status is None and isinstance(data, dict) and data.get("success") is True:
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:queued_accept", {"clientId": client_id, "uniqueId": unique_id}, flush=True)
|
||||
tensors = []
|
||||
if status == 0 and gen_status == 1:
|
||||
items = data.get("data") or []
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:result_items", len(items), flush=True)
|
||||
for item in items:
|
||||
url_item = item.get("url")
|
||||
if url_item:
|
||||
d0 = time.perf_counter()
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:download_begin", url_item, flush=True)
|
||||
t = self._download_image_tensor(url_item, proxies=proxies)
|
||||
tensors.append(t)
|
||||
d1 = time.perf_counter()
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:download_done_ms", int((d1 - d0) * 1000), flush=True)
|
||||
return (tensors if tensors else [], json.dumps(logs, ensure_ascii=False))
|
||||
else:
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:task_failed", {"status": status, "generateStatus": gen_status}, flush=True)
|
||||
if on_queued == "passthrough" and images is not None:
|
||||
passthrough = []
|
||||
if isinstance(images, list):
|
||||
passthrough = [img[0] for img in images]
|
||||
else:
|
||||
passthrough = [images[0]]
|
||||
return (passthrough, json.dumps(logs, ensure_ascii=False))
|
||||
return ([], json.dumps(logs, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:error", str(e), flush=True)
|
||||
return ([], str(e))
|
||||
|
||||
class XmilesNanobananaResultParser:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"json_text": ("STRING", {"multiline": True, "default": ""}),
|
||||
"proxy_url": ("STRING", {"default": "", "multiline": False}),
|
||||
"verbose": ("BOOLEAN", {"default": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "STRING")
|
||||
RETURN_NAMES = ("images", "log")
|
||||
OUTPUT_IS_LIST = (True, False)
|
||||
FUNCTION = "parse"
|
||||
CATEGORY = "Rui-Node🐶/AI模型🤖"
|
||||
|
||||
def _download_image_tensor(self, url, proxies=None):
|
||||
r = requests.get(url, proxies=proxies, timeout=60)
|
||||
r.raise_for_status()
|
||||
img = Image.open(io.BytesIO(r.content))
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
np_img = np.array(img).astype(np.float32) / 255.0
|
||||
return torch.from_numpy(np_img).unsqueeze(0)
|
||||
|
||||
def parse(self, json_text, proxy_url="", verbose=True):
|
||||
p0 = time.perf_counter()
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:parse_begin", {"ts": p0}, flush=True)
|
||||
proxies = None
|
||||
if proxy_url and proxy_url.strip():
|
||||
proxies = {"http": proxy_url, "https": proxy_url}
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:parse_proxies", proxies, flush=True)
|
||||
try:
|
||||
obj = json.loads(json_text)
|
||||
status = obj.get("status")
|
||||
gen_status = obj.get("generateStatus")
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:parse_status", {"status": status, "generateStatus": gen_status}, flush=True)
|
||||
tensors = []
|
||||
if status == 0 and gen_status == 1:
|
||||
items = obj.get("data") or []
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:parse_items", len(items), flush=True)
|
||||
for item in items:
|
||||
url_item = item.get("url")
|
||||
if url_item:
|
||||
z0 = time.perf_counter()
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:parse_download_begin", url_item, flush=True)
|
||||
tensors.append(self._download_image_tensor(url_item, proxies=proxies))
|
||||
z1 = time.perf_counter()
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:parse_download_ms", int((z1 - z0) * 1000), flush=True)
|
||||
return (tensors, json.dumps({"status": status, "generateStatus": gen_status}, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:parse_error", str(e), flush=True)
|
||||
return ([], str(e))
|
||||
|
||||
class XmilesNanobananaPoller:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"query_url": ("STRING", {"default": "", "multiline": False}),
|
||||
"method": (["GET", "POST"], {"default": "GET"}),
|
||||
"client_id": ("STRING", {"default": "", "multiline": False}),
|
||||
"unique_id": ("STRING", {"default": "", "multiline": False}),
|
||||
"poll_interval_ms": ("INT", {"default": 2000, "min": 200, "max": 60000, "step": 100}),
|
||||
"max_wait_ms": ("INT", {"default": 60000, "min": 2000, "max": 600000, "step": 1000}),
|
||||
},
|
||||
"optional": {
|
||||
"payload_template": ("STRING", {"multiline": True, "default": ""}),
|
||||
"proxy_url": ("STRING", {"default": "", "multiline": False}),
|
||||
"verbose": ("BOOLEAN", {"default": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "STRING")
|
||||
RETURN_NAMES = ("images", "log")
|
||||
OUTPUT_IS_LIST = (True, False)
|
||||
FUNCTION = "poll"
|
||||
CATEGORY = "Rui-Node🐶/AI模型🤖"
|
||||
|
||||
def _download_image_tensor(self, url, proxies=None):
|
||||
r = requests.get(url, proxies=proxies, timeout=60)
|
||||
r.raise_for_status()
|
||||
img = Image.open(io.BytesIO(r.content))
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
np_img = np.array(img).astype(np.float32) / 255.0
|
||||
return torch.from_numpy(np_img).unsqueeze(0)
|
||||
|
||||
def poll(self, query_url, method, client_id, unique_id, poll_interval_ms, max_wait_ms, payload_template="", proxy_url="", verbose=True):
|
||||
t_start = time.perf_counter()
|
||||
proxies = None
|
||||
if proxy_url and proxy_url.strip():
|
||||
proxies = {"http": proxy_url, "https": proxy_url}
|
||||
last_obj = None
|
||||
while (time.perf_counter() - t_start) * 1000 < max_wait_ms:
|
||||
try:
|
||||
body = None
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if payload_template and payload_template.strip():
|
||||
body_str = payload_template.replace("{clientId}", client_id).replace("{uniqueId}", unique_id)
|
||||
try:
|
||||
body = json.loads(body_str)
|
||||
except:
|
||||
body = body_str
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:poll_tick", {"url": query_url}, flush=True)
|
||||
if method == "POST":
|
||||
r = requests.post(query_url, headers=headers, json=body if isinstance(body, dict) else None, data=None if isinstance(body, dict) else body, proxies=proxies, timeout=30)
|
||||
else:
|
||||
params = {"clientId": client_id, "uniqueId": unique_id}
|
||||
r = requests.get(query_url, headers=headers, params=params, proxies=proxies, timeout=30)
|
||||
r.raise_for_status()
|
||||
obj = r.json()
|
||||
last_obj = obj
|
||||
status = obj.get("status")
|
||||
gen_status = obj.get("generateStatus")
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:poll_status", {"status": status, "generateStatus": gen_status}, flush=True)
|
||||
if status == 0 and gen_status == 1:
|
||||
tensors = []
|
||||
items = obj.get("data") or []
|
||||
for item in items:
|
||||
url_item = item.get("url")
|
||||
if url_item:
|
||||
tensors.append(self._download_image_tensor(url_item, proxies=proxies))
|
||||
return (tensors, json.dumps(obj, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print("Xmiles-nanobanana:poll_error", str(e), flush=True)
|
||||
time.sleep(poll_interval_ms / 1000.0)
|
||||
return ([], json.dumps(last_obj if last_obj is not None else {"error": "timeout"}, ensure_ascii=False))
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"XmilesNanobanana": XmilesNanobananaNode,
|
||||
"XmilesNanobananaResultParser": XmilesNanobananaResultParser,
|
||||
"XmilesNanobananaPoller": XmilesNanobananaPoller,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"XmilesNanobanana": "Xmiles-nanobanana",
|
||||
"XmilesNanobananaResultParser": "Xmiles-nanobanana 结果解析",
|
||||
"XmilesNanobananaPoller": "Xmiles-nanobanana 轮询查询",
|
||||
}
|
||||
Reference in New Issue
Block a user