From 4ec1ad1fea1ab7695e9e48e3fc6e2fff730b728f Mon Sep 17 00:00:00 2001 From: rui40000 Date: Wed, 1 Apr 2026 13:24:54 +0800 Subject: [PATCH] Update OpenAI node docs for multi-image input and remove Xmiles-nanobanana node --- README.md | 21 +- __init__.py | 4 - __pycache__/__init__.cpython-312.pyc | Bin 0 -> 3213 bytes __pycache__/openai_node.cpython-312.pyc | Bin 0 -> 5793 bytes openai_node.py | 131 ++++--- requirements.txt | 1 - xmiles_nanobanana_node.py | 452 ------------------------ 7 files changed, 102 insertions(+), 507 deletions(-) create mode 100644 __pycache__/__init__.cpython-312.pyc create mode 100644 __pycache__/openai_node.cpython-312.pyc delete mode 100644 xmiles_nanobanana_node.py diff --git a/README.md b/README.md index 48c02be..2f7eef5 100644 --- a/README.md +++ b/README.md @@ -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 +- 对多张参考图做综合分析、比对与总结 --- diff --git a/__init__.py b/__init__.py index 2ab34de..4342828 100644 --- a/__init__.py +++ b/__init__.py @@ -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'] diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d20ba263bd99e51138dda63c6f6579f79f6e0dfe GIT binary patch literal 3213 zcmbW3U2ob}7{?z&LS86I18qqN;VrMUS})peOWUlQ2ZlJ|Z9tX=skJQZq+ShX#R&afmuL@`TAca z6$E&17vsZ=2Op!80Dpl55~D(kX?PfW>_KC1V`4aF)JepBm`@@;h50n%Gnh{!K8v{@ z@hh175f5NKhxk0^bBJHXd;#$w<_n02Fu#WQbnct4a z%k6y2p4ZH%V&D?PM>S@s#uchDPc?#6<2uz?q#DaqBStlnRAY^5Y*LMzj)C^qD|?Ci zJKZCRXW48!D6n!S4c^NxYHr0%&VFx2}(;m zk#Sa`%x1}1PKTTo`2D5amLq;ml~3XA9;00Y{wX>iI4?JJ=Gt&xNk6%1&&o$kz62T!G7w zC%k^KPmW&h40lD;@tvc~E$zj4IaW$+waV7{GI@#DF0{#Km^09V2&&S>TNbTzkLv}# zMxOTS`7SwfVYnV0U4Eg+FGo8Uw=cI+<$mS4L*hbjd?AptLp|eoTWD%+vHr}H|rd%jji)=l!%TOe14L*45=r*mIS8 WChVX36aCbq{_Kx`PU&~h9r8bWZuL0; literal 0 HcmV?d00001 diff --git a/__pycache__/openai_node.cpython-312.pyc b/__pycache__/openai_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..31e587e04e7dd8ca0c2f71015a0d4bd31ad1d943 GIT binary patch literal 5793 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 NK)a5ZMFG{r{|m#y2_XOg literal 0 HcmV?d00001 diff --git a/openai_node.py b/openai_node.py index 9fcf45f..01c10f0 100644 --- a/openai_node.py +++ b/openai_node.py @@ -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,) diff --git a/requirements.txt b/requirements.txt index c6d8b56..c9d4273 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,3 @@ torch numpy Pillow requests -oss2 diff --git a/xmiles_nanobanana_node.py b/xmiles_nanobanana_node.py deleted file mode 100644 index 7589042..0000000 --- a/xmiles_nanobanana_node.py +++ /dev/null @@ -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 轮询查询", -}