diff --git a/__init__.py b/__init__.py index 494238a..5492acc 100644 --- a/__init__.py +++ b/__init__.py @@ -1,45 +1,20 @@ -# 从各节点文件导入映射 -from .node.ollama_node import ( - NODE_CLASS_MAPPINGS as OLLAMA_NODE_MAPPINGS, - NODE_DISPLAY_NAME_MAPPINGS as OLLAMA_DISPLAY_NAMES -) +import importlib +import os +import pkgutil -from .node.online_api import ( - NODE_CLASS_MAPPINGS as LLM_ONLINE_API, - NODE_DISPLAY_NAME_MAPPINGS as LLM_ONLINE_API_DISPLAY_NAMES -) +# 动态加载 node 目录下所有节点模块 +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} -from .node.string_utils import ( - NODE_CLASS_MAPPINGS as STRING_UTILS_MAPPINGS, - NODE_DISPLAY_NAME_MAPPINGS as STRING_UTILS_DISPLAY_NAMES -) +package = __name__ +node_pkg = f"{package}.node" -from .node.qiniu_uploader import ( - NODE_CLASS_MAPPINGS as QINIU_MAPPINGS, - NODE_DISPLAY_NAME_MAPPINGS as QINIU_DISPLAY_NAMES -) +for _, modname, ispkg in pkgutil.iter_modules([os.path.join(os.path.dirname(__file__), 'node')]): + if not ispkg: + module = importlib.import_module(f"{node_pkg}.{modname}") + if hasattr(module, "NODE_CLASS_MAPPINGS"): + NODE_CLASS_MAPPINGS.update(getattr(module, "NODE_CLASS_MAPPINGS")) + if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS"): + NODE_DISPLAY_NAME_MAPPINGS.update(getattr(module, "NODE_DISPLAY_NAME_MAPPINGS")) -from .node.img_utils import ( - NODE_CLASS_MAPPINGS as IMG_UTIL_MAPPINGS, - NODE_DISPLAY_NAME_MAPPINGS as IMG_UTIL_DISPLAY_NAMES -) - -# 合并所有映射 -NODE_CLASS_MAPPINGS = { - **OLLAMA_NODE_MAPPINGS, - **LLM_ONLINE_API, - **STRING_UTILS_MAPPINGS, - **QINIU_MAPPINGS, - **IMG_UTIL_MAPPINGS -} - -NODE_DISPLAY_NAME_MAPPINGS = { - **OLLAMA_DISPLAY_NAMES, - **LLM_ONLINE_API_DISPLAY_NAMES, - **STRING_UTILS_DISPLAY_NAMES, - **QINIU_DISPLAY_NAMES, - **IMG_UTIL_DISPLAY_NAMES -} - -# 定义公开接口 __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] \ No newline at end of file diff --git a/qiniu_config.json.example b/cloud_config.json.example similarity index 100% rename from qiniu_config.json.example rename to cloud_config.json.example diff --git a/cloud_utils.py b/cloud_utils.py new file mode 100644 index 0000000..e932d64 --- /dev/null +++ b/cloud_utils.py @@ -0,0 +1,36 @@ +import os +import json + + +def load_cloud_config(config_path=None): + """ + 通用多云配置读取工具方法,返回 (cloud_type, config) 元组。 + config_path 可选,默认自动定位到 cloud_config.json。 + """ + if config_path is None: + # 优先本目录下 cloud_config.json + local_path = os.path.join(os.path.dirname(__file__), "cloud_config.json") + parent_path = os.path.join(os.path.dirname(__file__), "..", "cloud_config.json") + if os.path.exists(local_path): + config_path = local_path + elif os.path.exists(parent_path): + config_path = parent_path + else: + # 返回空配置,避免节点加载时报错 + return "qiniu", {"access_key": "", "secret_key": "", "bucket_name": "", "domain": ""} + if not os.path.exists(config_path): + return "qiniu", {"access_key": "", "secret_key": "", "bucket_name": "", "domain": ""} + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + cloud_type = config.get("cloud_type", "qiniu") + cloud_config = config.get(cloud_type, {}) + return cloud_type, cloud_config + + +class CloudUploader: + """ + 云存储上传抽象基类,所有云厂商需实现 upload_binary 方法 + """ + + def upload_binary(self, data: bytes, key: str = None) -> str: + raise NotImplementedError("upload_binary 必须由子类实现") \ No newline at end of file diff --git a/node/cloud_node.py b/node/cloud_node.py new file mode 100644 index 0000000..e9cae9f --- /dev/null +++ b/node/cloud_node.py @@ -0,0 +1,260 @@ +import os +from qiniu import Auth, put_data +import io +from PIL import Image +import numpy as np +import uuid +import os +import uuid +import tempfile +import subprocess +import imageio_ffmpeg +from ..cloud_utils import load_cloud_config, CloudUploader + + +class QiniuUploader: + def __init__(self, access_key: str, secret_key: str, bucket_name: str, domain: str,): + self.access_key = access_key + self.secret_key = secret_key + self.bucket_name = bucket_name + self.domain = domain + self.q = Auth(self.access_key, self.secret_key) + + def upload_binary(self, data: bytes, key: str = None) -> str: + """ + 上传二进制数据到七牛云 + :param data: 二进制内容 + :param key: 文件名(可选),不传则由七牛自动生成 + :return: 文件外链URL + """ + token = self.q.upload_token(self.bucket_name, key, 3600) + ret, info = put_data(token, key, data) + print(f"七牛 put_data 返回 ret: {ret}, info: {info}") # 打印上传结果 + # 七牛 put_data 返回 ret 可能为 None,info.key 才是真实 key + real_key = None + if ret is not None and 'key' in ret: + real_key = ret['key'] + elif hasattr(info, 'key') and info.key: + real_key = info.key + if real_key: + url = f"{self.domain}/{real_key}" + if not url.startswith("http://") and not url.startswith("https://"): + url = "http://" + url.lstrip("/") + return url + else: + raise Exception(f"上传失败: {info}") + +# class JDCloudUploader(CloudUploader): +# """ +# 京东云对象存储上传实现(需安装 jdcloud-sdk-python) +# """ +# def __init__(self, access_key: str, secret_key: str, bucket_name: str, domain: str, region: str = "cn-north-1"): +# from jdcloud_sdk.services.oss.client.OssClient import OssClient +# from jdcloud_sdk.core.credential import Credential +# self.access_key = access_key +# self.secret_key = secret_key +# self.bucket_name = bucket_name +# self.domain = domain +# self.region = region +# self.credential = Credential(self.access_key, self.secret_key) +# self.client = OssClient(self.credential) + +# def upload_binary(self, data: bytes, key: str = None) -> str: +# if not key: +# import uuid +# key = uuid.uuid4().hex +# resp = self.client.put_object(self.bucket_name, key, body=data) +# if hasattr(resp, "error") and resp.error: +# raise Exception(f"京东云上传失败: {resp.error}") +# url = f"{self.domain}/{key}" +# if not url.startswith("http://") and not url.startswith("https://"): +# url = "http://" + url.lstrip("/") +# return url + +class CloudImageUploadNode: + """ + ComfyUI 图片张量直接上传云节点 + """ + @classmethod + def INPUT_TYPES(cls): + cloud_type, config = load_cloud_config() + return { + "required": { + "access_key": ("STRING", {"default": config.get("access_key", "")}), + "secret_key": ("STRING", {"default": config.get("secret_key", "")}), + "bucket_name": ("STRING", {"default": config.get("bucket_name", "")}), + "domain": ("STRING", {"default": config.get("domain", "")}), + "images": ("IMAGE", ), + "folder": ("STRING", {"default": "output"}), + "key_prefix": ("STRING", {"default": "comfyui_"}), + "format": (["PNG", "JPEG", "GIF"], {"default": "PNG"}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("urls",) + FUNCTION = "upload_images" + CATEGORY = "云服务" + OUTPUT_NODE = True + + def upload_images(self, access_key, secret_key, bucket_name, domain, images, folder, key_prefix, format): + cloud_type, _ = load_cloud_config() + if cloud_type == "jdcloud": + # uploader = JDCloudUploader(access_key, secret_key, bucket_name, domain) + raise NotImplementedError(f"暂不支持的云类型: {cloud_type}") + else: + uploader = QiniuUploader(access_key, secret_key, bucket_name, domain) + urls = [] + arr = images.cpu().numpy() if hasattr(images, 'cpu') else images + for idx, image in enumerate(arr): + img = Image.fromarray(np.clip(255. * image, 0, 255).astype(np.uint8)) + buf = io.BytesIO() + if format == "GIF": + img = img.convert("P", palette=Image.ADAPTIVE) + img.save(buf, format="GIF") + else: + img.save(buf, format=format) + buf.seek(0) + random_name = uuid.uuid4().hex + # 拼接文件夹路径 + folder_path = folder.strip().strip('/') + key = f"{folder_path}/{key_prefix}{random_name}.{format.lower()}" + url = uploader.upload_binary(buf.getvalue(), key) + print(f"上传图片返回 url: {url}, 类型: {type(url)}") + urls.append(str(url)) + return (urls,) + +class CloudVideoUploadNode: + """ + ComfyUI 视频文件上传云节点 + """ + @classmethod + def INPUT_TYPES(cls): + cloud_type, config = load_cloud_config() + return { + "required": { + "access_key": ("STRING", {"default": config.get("access_key", "")}), + "secret_key": ("STRING", {"default": config.get("secret_key", "")}), + "bucket_name": ("STRING", {"default": config.get("bucket_name", "")}), + "domain": ("STRING", {"default": config.get("domain", "")}), + "video_path": ("STRING", {"default": ""}), + "folder": ("STRING", {"default": "video"}), + "key_prefix": ("STRING", {"default": "comfyui_"}), + "ext": (["mp4", "mov", "avi", "mkv"], {"default": "mp4"}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("url",) + FUNCTION = "upload_video" + CATEGORY = "云服务" + OUTPUT_NODE = True + + def upload_video(self, access_key, secret_key, bucket_name, domain, video_path, folder, key_prefix, ext): + cloud_type, _ = load_cloud_config() + if cloud_type == "jdcloud": + # uploader = JDCloudUploader(access_key, secret_key, bucket_name, domain) + raise NotImplementedError(f"暂不支持的云类型: {cloud_type}") + else: + uploader = QiniuUploader(access_key, secret_key, bucket_name, domain) + if not os.path.isfile(video_path): + raise FileNotFoundError(f"视频文件不存在: {video_path}") + with open(video_path, "rb") as f: + data = f.read() + random_name = uuid.uuid4().hex + folder_path = folder.strip().strip('/') + key = f"{folder_path}/{key_prefix}{random_name}.{ext}" + url = uploader.upload_binary(data, key) + print(f"上传视频返回 url: {url}, 类型: {type(url)}") + return (url,) + +class CloudImagesToVideoAndUpload: + """ + 将图片张量序列合成为视频并上传到云存储(支持多云扩展) + 输入: 图片张量 (N, H, W, C) + 输出: 云存储视频URL + """ + @classmethod + def INPUT_TYPES(cls): + cloud_type, config = load_cloud_config() + return { + "required": { + "images": ("IMAGE",), + "fps": ("INT", {"default": 12, "min": 1, "max": 60}), + "cloud_type": ("STRING", {"default": cloud_type}), + "access_key": ("STRING", {"default": config["access_key"]}), + "secret_key": ("STRING", {"default": config["secret_key"]}), + "bucket_name": ("STRING", {"default": config["bucket_name"]}), + "domain": ("STRING", {"default": config["domain"]}), + "folder": ("STRING", {"default": "video"}), + "key_prefix": ("STRING", {"default": "comfyui_"}), + "ext": (["mp4", "mov", "avi", "mkv"], {"default": "mp4"}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("url",) + FUNCTION = "images_to_video_and_upload" + CATEGORY = "云服务" + OUTPUT_NODE = True + + def images_to_video_and_upload(self, images, fps, cloud_type, access_key, secret_key, bucket_name, domain, folder, key_prefix, ext): + config = load_cloud_config()[1] + access_key = access_key or config.get("access_key", "") + secret_key = secret_key or config.get("secret_key", "") + bucket_name = bucket_name or config.get("bucket_name", "") + domain = domain or config.get("domain", "") + # 1. 张量转图片序列 + arr = images.cpu().numpy() if hasattr(images, 'cpu') else images + img_list = [Image.fromarray(np.clip(255. * img, 0, 255).astype(np.uint8)) for img in arr] + # 2. 合成视频 + height, width = img_list[0].height, img_list[0].width + pix_fmt = 'rgb24' + with tempfile.NamedTemporaryFile(suffix=f'.{ext}', delete=False) as tmpfile: + tmp_path = tmpfile.name + cmd = [ + imageio_ffmpeg.get_ffmpeg_exe(), + '-y', + '-f', 'rawvideo', + '-vcodec', 'rawvideo', + '-s', f'{width}x{height}', + '-pix_fmt', pix_fmt, + '-r', str(fps), + '-i', '-', + '-an', + '-vcodec', 'libx264', + '-pix_fmt', 'yuv420p', + tmp_path + ] + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE) + for im in img_list: + proc.stdin.write(im.convert('RGB').tobytes()) + proc.stdin.close() + proc.wait() + # 3. 上传到云存储 + if cloud_type == "qiniu": + uploader = QiniuUploader(access_key, secret_key, bucket_name, domain) + elif cloud_type == "jdcloud": + # uploader = JDCloudUploader(access_key, secret_key, bucket_name, domain) + # else: + raise NotImplementedError(f"暂不支持的云类型: {cloud_type}") + random_name = uuid.uuid4().hex + folder_path = folder.strip().strip('/') + key = f"{folder_path}/{key_prefix}{random_name}.{ext}" + with open(tmp_path, "rb") as f: + data = f.read() + url = uploader.upload_binary(data, key) + os.remove(tmp_path) + return (url,) + +# 注册到节点映射 +NODE_CLASS_MAPPINGS = { + "CloudImageUploadNode": CloudImageUploadNode, + "CloudVideoUploadNode": CloudVideoUploadNode, + "CloudImagesToVideoAndUpload": CloudImagesToVideoAndUpload +} +NODE_DISPLAY_NAME_MAPPINGS = { + "CloudImageUploadNode": "☁️ 图片上传到云 (IMAGE)", + "CloudVideoUploadNode": "☁️ 视频上传到云 (VIDEO)", + "CloudImagesToVideoAndUpload": "🖼️图片合成视频并上传到云" +} diff --git a/node/img_node.py b/node/img_node.py new file mode 100644 index 0000000..da122fd --- /dev/null +++ b/node/img_node.py @@ -0,0 +1,85 @@ +from PIL import Image, ImageOps +from PIL.PngImagePlugin import PngInfo +import requests +import torch +import numpy as np + +class LoadImgFromUrl: + """Load an image from the given URL""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "url": ( + "STRING", + { + "default": "http://swqqsa5wv.hb-bkt.clouddn.com/admin/comfyui_b46dcb7133234c549218bf957a6334ed.png" + }, + ), + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + FUNCTION = "load" + CATEGORY = "云服务" + + def load(self, url): + response = requests.get(url, stream=True, timeout=10) + response.raise_for_status() + image = Image.open(response.raw) + image = ImageOps.exif_transpose(image) + np_image = np.array(image).astype(np.float32) / 255.0 + if np_image.ndim == 2: # 灰度图 + np_image = np.expand_dims(np_image, axis=-1) + # 保证输出为 (1, H, W, C) + tensor = torch.from_numpy(np_image).unsqueeze(0) + return (tensor,) + + +class LoadGifFromLocal: + """从本地路径加载GIF图片(返回所有帧的张量)""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "path": ( + "STRING", + { + "default": "your_local_gif_path.gif" + }, + ), + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("images",) + FUNCTION = "load" + CATEGORY = "本地文件" + + def load(self, path): + images = [] + with Image.open(path) as im: + for frame in range(im.n_frames): + im.seek(frame) + frame_img = ImageOps.exif_transpose(im.convert("RGBA")) + np_image = np.array(frame_img).astype(np.float32) / 255.0 + if np_image.ndim == 2: # 灰度图 + np_image = np.expand_dims(np_image, axis=-1) + images.append(torch.from_numpy(np_image)) + # 输出为 (帧数, H, W, C) + tensor = torch.stack(images, dim=0) + return (tensor,) + + +NODE_CLASS_MAPPINGS = { + "LoadImgFromUrl": LoadImgFromUrl, + "LoadGifFromLocal": LoadGifFromLocal +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "LoadImgFromUrl": "☁️ 加载图片", + "LoadGifFromLocal": "📂 加载本地GIF" +} \ No newline at end of file diff --git a/node/img_utils.py b/node/img_utils.py deleted file mode 100644 index fc2366e..0000000 --- a/node/img_utils.py +++ /dev/null @@ -1,168 +0,0 @@ -from PIL import Image, ImageOps -from PIL.PngImagePlugin import PngInfo -import requests -import torch -import numpy as np -import os -import uuid -import tempfile -import subprocess -import imageio_ffmpeg - - -class LoadImgFromUrl: - """Load an image from the given URL""" - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "url": ( - "STRING", - { - "default": "http://swqqsa5wv.hb-bkt.clouddn.com/admin/comfyui_b46dcb7133234c549218bf957a6334ed.png" - }, - ), - } - } - - RETURN_TYPES = ("IMAGE",) - RETURN_NAMES = ("image",) - FUNCTION = "load" - CATEGORY = "云服务" - - def load(self, url): - response = requests.get(url, stream=True, timeout=10) - response.raise_for_status() - image = Image.open(response.raw) - image = ImageOps.exif_transpose(image) - np_image = np.array(image).astype(np.float32) / 255.0 - if np_image.ndim == 2: # 灰度图 - np_image = np.expand_dims(np_image, axis=-1) - # 保证输出为 (1, H, W, C) - tensor = torch.from_numpy(np_image).unsqueeze(0) - return (tensor,) - - -class LoadGifFromLocal: - """从本地路径加载GIF图片(返回所有帧的张量)""" - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "path": ( - "STRING", - { - "default": "your_local_gif_path.gif" - }, - ), - } - } - - RETURN_TYPES = ("IMAGE",) - RETURN_NAMES = ("images",) - FUNCTION = "load" - CATEGORY = "本地文件" - - def load(self, path): - images = [] - with Image.open(path) as im: - for frame in range(im.n_frames): - im.seek(frame) - frame_img = ImageOps.exif_transpose(im.convert("RGBA")) - np_image = np.array(frame_img).astype(np.float32) / 255.0 - if np_image.ndim == 2: # 灰度图 - np_image = np.expand_dims(np_image, axis=-1) - images.append(torch.from_numpy(np_image)) - # 输出为 (帧数, H, W, C) - tensor = torch.stack(images, dim=0) - return (tensor,) - - -class ImagesToVideoAndUpload: - """ - 将图片张量序列合成为视频并上传到七牛云 - 输入: 图片张量 (N, H, W, C) - 输出: 七牛云视频URL - """ - @classmethod - def INPUT_TYPES(cls): - from .qiniu_uploader import load_qiniu_config - config = load_qiniu_config() or { - "access_key": "", - "secret_key": "", - "bucket_name": "", - "domain": "" - } - return { - "required": { - "images": ("IMAGE",), - "fps": ("INT", {"default": 12, "min": 1, "max": 60}), - "access_key": ("STRING", {"default": config["access_key"]}), - "secret_key": ("STRING", {"default": config["secret_key"]}), - "bucket_name": ("STRING", {"default": config["bucket_name"]}), - "domain": ("STRING", {"default": config["domain"]}), - "folder": ("STRING", {"default": "video"}), - "key_prefix": ("STRING", {"default": "comfyui_"}), - "ext": (["mp4", "mov", "avi", "mkv"], {"default": "mp4"}), - } - } - - RETURN_TYPES = ("STRING",) - RETURN_NAMES = ("url",) - FUNCTION = "images_to_video_and_upload" - CATEGORY = "云服务/七牛云" - OUTPUT_NODE = True - - def images_to_video_and_upload(self, images, fps, access_key, secret_key, bucket_name, domain, folder, key_prefix, ext): - from .qiniu_uploader import QiniuUploader - # 1. 张量转图片序列(兼容 torch.Tensor 和 numpy) - arr = images.cpu().numpy() if hasattr(images, 'cpu') else images - img_list = [Image.fromarray(np.clip(255. * img, 0, 255).astype(np.uint8)) for img in arr] - # 2. 只用 ffmpeg/imageio-ffmpeg 合成视频 - height, width = img_list[0].height, img_list[0].width - pix_fmt = 'rgb24' - with tempfile.NamedTemporaryFile(suffix=f'.{ext}', delete=False) as tmpfile: - tmp_path = tmpfile.name - cmd = [ - imageio_ffmpeg.get_ffmpeg_exe(), - '-y', - '-f', 'rawvideo', - '-vcodec', 'rawvideo', - '-s', f'{width}x{height}', - '-pix_fmt', pix_fmt, - '-r', str(fps), - '-i', '-', - '-an', - '-vcodec', 'libx264', - '-pix_fmt', 'yuv420p', - tmp_path - ] - proc = subprocess.Popen(cmd, stdin=subprocess.PIPE) - for im in img_list: - proc.stdin.write(im.convert('RGB').tobytes()) - proc.stdin.close() - proc.wait() - # 3. 上传到七牛云 - uploader = QiniuUploader(access_key, secret_key, bucket_name, domain) - random_name = uuid.uuid4().hex - folder_path = folder.strip().strip('/') - key = f"{folder_path}/{key_prefix}{random_name}.{ext}" - with open(tmp_path, "rb") as f: - data = f.read() - url = uploader.upload_binary(data, key) - os.remove(tmp_path) - return (url,) - -NODE_CLASS_MAPPINGS = { - "LoadImgFromUrl": LoadImgFromUrl, - "LoadGifFromLocal": LoadGifFromLocal, - "ImagesToVideoAndUpload": ImagesToVideoAndUpload -} - -NODE_DISPLAY_NAME_MAPPINGS = { - "LoadImgFromUrl": "☁️ 加载图片", - "LoadGifFromLocal": "📂 加载本地GIF", - "ImagesToVideoAndUpload": "🖼️图片合成视频并上传七牛云" -} \ No newline at end of file diff --git a/node/qiniu_uploader.py b/node/qiniu_uploader.py deleted file mode 100644 index d6c3e70..0000000 --- a/node/qiniu_uploader.py +++ /dev/null @@ -1,160 +0,0 @@ -import os -from qiniu import Auth, put_data -import io -from PIL import Image -import numpy as np -import uuid -import json - - -QINIU_CONFIG_PATH = os.path.join(os.path.dirname(__file__), "qiniu_config.json") - -def load_qiniu_config(): - if os.path.exists(QINIU_CONFIG_PATH): - with open(QINIU_CONFIG_PATH, "r", encoding="utf-8") as f: - return json.load(f) - return None - -# 初始化全局缓存 -QINIU_CONFIG = load_qiniu_config() - -class QiniuUploader: - def __init__(self, access_key: str, secret_key: str, bucket_name: str, domain: str,): - self.access_key = access_key - self.secret_key = secret_key - self.bucket_name = bucket_name - self.domain = domain - self.q = Auth(self.access_key, self.secret_key) - - def upload_binary(self, data: bytes, key: str = None) -> str: - """ - 上传二进制数据到七牛云 - :param data: 二进制内容 - :param key: 文件名(可选),不传则由七牛自动生成 - :return: 文件外链URL - """ - token = self.q.upload_token(self.bucket_name, key, 3600) - ret, info = put_data(token, key, data) - print(f"七牛 put_data 返回 ret: {ret}, info: {info}") # 打印上传结果 - # 七牛 put_data 返回 ret 可能为 None,info.key 才是真实 key - real_key = None - if ret is not None and 'key' in ret: - real_key = ret['key'] - elif hasattr(info, 'key') and info.key: - real_key = info.key - if real_key: - url = f"{self.domain}/{real_key}" - if not url.startswith("http://") and not url.startswith("https://"): - url = "http://" + url.lstrip("/") - return url - else: - raise Exception(f"上传失败: {info}") - -class QiniuImageUploadNode: - """ - ComfyUI 图片张量直接上传七牛云节点 - """ - @classmethod - def INPUT_TYPES(cls): - # 自动填充默认值,确保 config 始终有值 - config = load_qiniu_config() or { - "access_key": "", - "secret_key": "", - "bucket_name": "", - "domain": "" - } - - return { - "required": { - "access_key": ("STRING", {"default": config["access_key"]}), - "secret_key": ("STRING", {"default": config["secret_key"]}), - "bucket_name": ("STRING", {"default": config["bucket_name"]}), - "domain": ("STRING", {"default": config["domain"]}), - "images": ("IMAGE", ), - "folder": ("STRING", {"default": "output"}), - "key_prefix": ("STRING", {"default": "comfyui_"}), - "format": (["PNG", "JPEG", "GIF"], {"default": "PNG"}), - } - } - - RETURN_TYPES = ("STRING",) - RETURN_NAMES = ("urls",) - FUNCTION = "upload_images" - CATEGORY = "云服务/七牛云" - OUTPUT_NODE = True - - def upload_images(self, access_key, secret_key, bucket_name, domain, images, folder, key_prefix, format): - uploader = QiniuUploader(access_key, secret_key, bucket_name, domain) - urls = [] - arr = images.cpu().numpy() if hasattr(images, 'cpu') else images - for idx, image in enumerate(arr): - img = Image.fromarray(np.clip(255. * image, 0, 255).astype(np.uint8)) - buf = io.BytesIO() - if format == "GIF": - img = img.convert("P", palette=Image.ADAPTIVE) - img.save(buf, format="GIF") - else: - img.save(buf, format=format) - buf.seek(0) - random_name = uuid.uuid4().hex - # 拼接文件夹路径 - folder_path = folder.strip().strip('/') - key = f"{folder_path}/{key_prefix}{random_name}.{format.lower()}" - url = uploader.upload_binary(buf.getvalue(), key) - print(f"上传图片返回 url: {url}, 类型: {type(url)}") - urls.append(str(url)) - return (urls,) - -class QiniuVideoUploadNode: - """ - ComfyUI 视频文件上传七牛云节点 - """ - @classmethod - def INPUT_TYPES(cls): - config = load_qiniu_config() or { - "access_key": "", - "secret_key": "", - "bucket_name": "", - "domain": "" - } - return { - "required": { - "access_key": ("STRING", {"default": config["access_key"]}), - "secret_key": ("STRING", {"default": config["secret_key"]}), - "bucket_name": ("STRING", {"default": config["bucket_name"]}), - "domain": ("STRING", {"default": config["domain"]}), - "video_path": ("STRING", {"default": ""}), - "folder": ("STRING", {"default": "video"}), - "key_prefix": ("STRING", {"default": "comfyui_"}), - "ext": (["mp4", "mov", "avi", "mkv"], {"default": "mp4"}), - } - } - - RETURN_TYPES = ("STRING",) - RETURN_NAMES = ("url",) - FUNCTION = "upload_video" - CATEGORY = "云服务/七牛云" - OUTPUT_NODE = True - - def upload_video(self, access_key, secret_key, bucket_name, domain, video_path, folder, key_prefix, ext): - uploader = QiniuUploader(access_key, secret_key, bucket_name, domain) - if not os.path.isfile(video_path): - raise FileNotFoundError(f"视频文件不存在: {video_path}") - with open(video_path, "rb") as f: - data = f.read() - random_name = uuid.uuid4().hex - folder_path = folder.strip().strip('/') - key = f"{folder_path}/{key_prefix}{random_name}.{ext}" - url = uploader.upload_binary(data, key) - print(f"上传视频返回 url: {url}, 类型: {type(url)}") - return (url,) - -# 注册到节点映射 -NODE_CLASS_MAPPINGS = { - "QiniuImageUploadNode": QiniuImageUploadNode, - "QiniuVideoUploadNode": QiniuVideoUploadNode -} -NODE_DISPLAY_NAME_MAPPINGS = { -"QiniuImageUploadNode": "☁️ 七牛云图片上传 (IMAGE)", -"QiniuVideoUploadNode": "☁️ 七牛云视频上传 (VIDEO)" -} diff --git a/node/string_utils.py b/node/string_node.py similarity index 100% rename from node/string_utils.py rename to node/string_node.py