diff --git a/__init__.py b/__init__.py index ed1771a..cb0c6b6 100644 --- a/__init__.py +++ b/__init__.py @@ -1,17 +1,31 @@ -from .nodes.image import SaveImagePath -from .nodes.heygem import HeyGemF2F, HeyGemF2FFromFile -from .nodes.s3 import S3Download, S3Upload, S3UploadURL -from .nodes.text import * -from .nodes.traverse_folder import TraverseFolder -from .nodes.unload_all_models import UnloadAllModels -from .nodes.string_empty_judgement import StringEmptyJudgement from .nodes.compute_video_point import VideoStartPointDurationCompute from .nodes.cos import COSUpload, COSDownload from .nodes.face_detect import FaceDetect from .nodes.face_extract import FaceExtract +from .nodes.heygem import HeyGemF2F, HeyGemF2FFromFile +from .nodes.image import SaveImagePath +from .nodes.load_image_pro import LoadNetImg from .nodes.log2db import LogToDB +from .nodes.nodes_bfl import ( + FluxProUltraImageNode, + FluxKontextProImageNode, + FluxKontextMaxImageNode, + FluxProExpandNode, + FluxProFillNode, + FluxProCannyNode, + FluxProDepthNode + +) +from .nodes.s3 import S3Download, S3Upload, S3UploadURL +from .nodes.string_empty_judgement import StringEmptyJudgement +from .nodes.text import * +from .nodes.traverse_folder import TraverseFolder +from .nodes.unload_all_models import UnloadAllModels from .nodes.video import VideoCut, VideoCutByFramePoint, VideoChangeFPS from .nodes.vod2local import VodToLocalNode +from .nodes.prompt_id_generator import TaskIdGenerate +from .nodes.random_line import RandomLineSelector +from .nodes.webhook_forward import PlugAndPlayWebhook, SaveImageWithOutput # A dictionary that contains all nodes you want to export with their names # NOTE: names should be globally unique @@ -37,6 +51,19 @@ NODE_CLASS_MAPPINGS = { "HeyGemF2F": HeyGemF2F, "HeyGemF2FFromFile": HeyGemF2FFromFile, "SaveImagePath": SaveImagePath, + "LoadNetImg": LoadNetImg, + "FluxProUltraImageNode": FluxProUltraImageNode, + # "FluxProImageNode": FluxProImageNode, + "FluxKontextProImageNode": FluxKontextProImageNode, + "FluxKontextMaxImageNode": FluxKontextMaxImageNode, + "FluxProExpandNode": FluxProExpandNode, + "FluxProFillNode": FluxProFillNode, + "FluxProCannyNode": FluxProCannyNode, + "FluxProDepthNode": FluxProDepthNode, + "TaskIdGenerate": TaskIdGenerate, + "RandomLineSelector": RandomLineSelector, + "PlugAndPlayWebhook": PlugAndPlayWebhook, + "SaveImageWithOutput": SaveImageWithOutput } # A dictionary that contains the friendly/humanly readable titles for the nodes @@ -61,5 +88,19 @@ NODE_DISPLAY_NAME_MAPPINGS = { "LoadTextCustomOnline": "读取文本文件(线上)", "HeyGemF2F": "HeyGem口型同步(API, 传入文件Tensor)", "HeyGemF2FFromFile": "HeyGem口型同步(API, 传入文件路径)", - "SaveImagePath": "保存图片" + "SaveImagePath": "保存图片", + "LoadNetImg": "load_net_image", + "FluxProUltraImageNode": "Flux 1.1 [pro] Ultra Image", + # "FluxProImageNode": "Flux 1.1 [pro] Image", + "FluxKontextProImageNode": "Flux.1 Kontext [pro] Image", + "FluxKontextMaxImageNode": "Flux.1 Kontext [max] Image", + "FluxProExpandNode": "Flux.1 Expand Image", + "FluxProFillNode": "Flux.1 Fill Image", + "FluxProCannyNode": "Flux.1 Canny Control Image", + "FluxProDepthNode": "Flux.1 Depth Control Image", + "TaskIdGenerate": "TaskID生成器", + "RandomLineSelector": "随机选择一行内容", + "PlugAndPlayWebhook": "Webhook转发器", + "SaveImageWithOutput": "保存图片(带输出)" + } diff --git a/comfyui_v2.py b/comfyui_v2.py new file mode 100644 index 0000000..9e9836e --- /dev/null +++ b/comfyui_v2.py @@ -0,0 +1,47 @@ +# 文件名 comfyui_v2.py +import subprocess + +import modal + +image = ( # build up a Modal Image to run ComfyUI, step by step + modal.Image.debian_slim( # start from basic Linux with Python + python_version="3.10" + ) + .apt_install("git", "gcc", "libportaudio2", "ffmpeg") + .pip_install("fastapi[standard]==0.115.4") # install web dependencies + .pip_install("comfy_cli==0.0.0", + index_url="https://packages-1747622887395:0ee15474ccd7b27b57ca63a9306327678e6c2631@g-ldyi2063-pypi.pkg.coding.net/dev/packages/simple") + .run_commands( # use comfy-cli to install ComfyUI and its dependencies + "comfy --skip-prompt install --fast-deps --nvidia --version 0.3.40" + ) + .pip_install_from_pyproject("./pyproject_comfyui.toml") + .run_commands("comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI-CustomNode.git", force_build=True) + .run_commands("comfy node install https://github.com/yolain/ComfyUI-Easy-Use.git", force_build=True) + # .add_local_file("ext/webhook_forward.py", "/root/comfy/ComfyUI/custom_nodes/webhook_forward.py", copy=True) + # .add_local_file("ext/prompt_id_generator.py", "/root/comfy/ComfyUI/custom_nodes/prompt_id_generator.py", copy=True) + # .add_local_file("ext/load_image_pro.py", "/root/comfy/ComfyUI/custom_nodes/load_image_pro.py", copy=True) + # .add_local_file('ext/image.py', '/root/comfy/ComfyUI/custom_nodes/image.py', copy=True) + # .add_local_file('ext/nodes_bfl.py', '/root/comfy/ComfyUI/comfy_api_nodes/nodes_bfl.py', copy=True) + # # .add_local_file('ext/s3_utils.py', '/root/comfy/ComfyUI/custom_nodes/s3_utils.py', copy=True) + # .add_local_file('ext/cos_utils.py', '/root/comfy/ComfyUI/custom_nodes/cos_utils.py', copy=True) + # .add_local_file('ext/random_line.py', '/root/comfy/ComfyUI/custom_nodes/random_line.py', copy=True) +) +app = modal.App(name="test", image=image) +vol = modal.Volume.from_name("test", environment_name="dev", create_if_missing=True) +custom_secret = modal.Secret.from_name("comfyui-custom-secret", environment_name="dev") + + +# modal deploy .\comfyui_v2.py --name dev +@app.cls( + max_containers=1, # limit interactive session to 1 container + # gpu="L40S", # good starter GPU for inference + volumes={"/cache": vol}, # mounts our cached models + secrets=[custom_secret] +) +@modal.concurrent( + max_inputs=10 +) # required for UI startup process which runs several API calls concurrently +@modal.web_server(8000, startup_timeout=60) +class ModalComfy(): + + subprocess.Popen("comfy launch -- --cpu --listen 0.0.0.0 --port 8000", shell=True) diff --git a/ext/cos_utils.py b/ext/cos_utils.py new file mode 100644 index 0000000..e7e0caa --- /dev/null +++ b/ext/cos_utils.py @@ -0,0 +1,98 @@ +# -*- coding:utf-8 -*- +""" +File cos_utils.py +Author silence +Date 2025/6/11 20:37 +""" +import mimetypes +import os +from loguru import logger +from qcloud_cos import CosConfig +from qcloud_cos import CosS3Client + +# COS配置 +COS_BUCKET_NAME = 'sucai-1324682537' +COS_SECRET_ID = 'AKIDsrihIyjZOBsjimt8TsN8yvv1AMh5dB44' +COS_SECRET_KEY = 'CPZcxdk6W39Jd4cGY95wvupoyMd0YFqW' +COS_REGION = 'ap-shanghai' + + +class CosUploadNode: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "file_path": ("STRING", {"forceInput": True}), + }, + "optional": { + "remove_source": ("BOOLEAN", {"default": False}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("upload_url",) + FUNCTION = "upload_to_cos" + CATEGORY = "不忘科技-自定义节点🚩" + + def upload_to_cos(self, file_path, remove_source=False): + try: + # 检查文件是否存在 + if not os.path.isfile(file_path): + raise Exception(f"文件不存在: {file_path}") + + # 获取文件MIME类型和分类 + mime_type, _ = mimetypes.guess_type(file_path) + if mime_type: + category = mime_type.split('/')[0] + else: + category = 'unknown' + + file_name = os.path.basename(file_path) + object_key = f'tk/{category}/{file_name}' + + # 配置COS客户端 + config = CosConfig( + Region=COS_REGION, + SecretId=COS_SECRET_ID, + SecretKey=COS_SECRET_KEY + ) + client = CosS3Client(config) + + # 上传文件 + client.upload_file( + Bucket=COS_BUCKET_NAME, + Key=object_key, + LocalFilePath=file_path, + EnableMD5=False + ) + + # 生成访问URL + upload_url = f'https://{COS_BUCKET_NAME}.cos.{COS_REGION}.myqcloud.com/{object_key}' + + logger.info(f'文件上传成功: {upload_url}') + + # 如果需要删除源文件 + if remove_source and os.path.exists(file_path): + try: + os.remove(file_path) + logger.info(f'源文件已删除: {file_path}') + except Exception as e: + logger.warning(f'删除源文件失败: {e}') + + return (upload_url,) + + except Exception as e: + error_msg = f'上传失败: {str(e)}' + logger.error(error_msg) + # 返回错误信息而不是抛出异常,这样ComfyUI不会中断流程 + return (f"ERROR: {error_msg}",) + + +# 节点注册 +NODE_CLASS_MAPPINGS = { + "CosUploadNode": CosUploadNode +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "CosUploadNode": "腾讯COS上传" +} \ No newline at end of file diff --git a/ext/image.py b/ext/image.py new file mode 100644 index 0000000..2acdd5d --- /dev/null +++ b/ext/image.py @@ -0,0 +1,71 @@ +import os +import uuid +from PIL import Image +import numpy as np +import torch + + +class SaveImagePath: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image_path": ("IMAGE", {"forceInput": True}), + } + } + RETURN_TYPES = ("STRING",) + FUNCTION = "load" + CATEGORY = "不忘科技-自定义节点🚩" + + def load(self, image_path): + # 如果是torch.Tensor类型,转换为numpy数组 + if isinstance(image_path, torch.Tensor): + image_path = image_path.cpu().numpy() + + # 去除多余的维度,如果形状是(1, 1, height, width, channels)或(1, height, width, channels)等情况 + while len(image_path.shape) > 3: + image_path = image_path.squeeze(0) + + # 如果是通道优先格式 (C, H, W),转换为通道最后格式 (H, W, C) + if len(image_path.shape) == 3 and image_path.shape[0] <= 4: + image_path = np.transpose(image_path, (1, 2, 0)) + + # 如果是单通道图像,转换为3通道 + if len(image_path.shape) == 2: + image_path = np.stack([image_path] * 3, axis=-1) + + # 数据范围和类型转换 - 这是关键修复 + if image_path.dtype == np.float32 or image_path.dtype == np.float64: + # ComfyUI图像数据通常是0-1范围的浮点数 + if image_path.max() <= 1.0: + # 从0-1范围转换到0-255范围 + image_path = (image_path * 255.0).astype(np.uint8) + else: + # 如果已经是0-255范围,直接转换类型 + image_path = np.clip(image_path, 0, 255).astype(np.uint8) + elif image_path.dtype != np.uint8: + # 其他数据类型,确保在0-255范围内 + image_path = np.clip(image_path, 0, 255).astype(np.uint8) + + pil_image = Image.fromarray(image_path) + + output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output") + # base_dir = os.path.dirname(os.path.abspath(__file__)) + # output_dir = '/root/comfy/ComfyUI/output' + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + file_name = "%s.png" % str(uuid.uuid4()) + p = os.path.join(output_dir, file_name) + pil_image.save(p) + + return (p,) + + +# 节点类定义结束,以下是用于注册节点的字典结构(通常在实际使用中由ComfyUI等框架来解析和注册) +NODE_CLASS_MAPPINGS = { + "SaveImagePath": SaveImagePath +} +NODE_DISPLAY_NAME_MAPPINGS = { + "SaveImagePath": "保存图片路径" +} diff --git a/ext/load_image_pro.py b/ext/load_image_pro.py new file mode 100644 index 0000000..9d2e4d8 --- /dev/null +++ b/ext/load_image_pro.py @@ -0,0 +1,62 @@ +from io import BytesIO + +import numpy as np +import requests +import torch +from PIL import Image + + +# 定义节点类 +class LoadNetImg: + # 定义节点输入类型 + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image_url": ("STRING", { + "default": "https://example.com/sample.jpg", + "multiline": False + }), + } + } + + # 定义节点输出类型 + RETURN_TYPES = ("IMAGE",) # 返回图像数据 + RETURN_NAMES = ("image",) # 命名返回值 + FUNCTION = "load_image_task" # 函数标识符,方便注册多个节点功能 + OUTPUT_NODE = False # 不允许该节点直接作为最终输出节点 + CATEGORY = "image" # 节点所属类别(在 ComfyUI 界面中分类) + + def load_image_task(self, image_url): + try: + if not image_url or not image_url.strip(): + raise ValueError("需要提供图片URL") + + # 下载网络图片 + response = requests.get(image_url) + response.raise_for_status() # 请求失败时抛出异常 + image = Image.open(BytesIO(response.content)).convert("RGB") + + # 按照官方格式转换图像数据 + # Convert to numpy array and normalize to 0-1 + image_array = np.array(image).astype(np.float32) / 255.0 + # Convert to torch tensor and add batch dimension + image_tensor = torch.from_numpy(image_array)[None,] + + return (image_tensor,) # 返回torch张量 + except Exception as e: + print(f"Error loading image: {e}") + # 返回一个空的黑色图片作为错误处理 + empty_image = torch.zeros((1, 512, 512, 3), dtype=torch.float32) + return (empty_image,) + + + + +# 映射节点类和名称 +NODE_CLASS_MAPPINGS = { + "LoadNetImg": LoadNetImg, # 将类映射到节点名称 +} +NODE_DISPLAY_NAME_MAPPINGS = { + "LoadNetImg": "load_net_image", # 节点显示名称 +} \ No newline at end of file diff --git a/ext/nodes_bfl.py b/ext/nodes_bfl.py new file mode 100644 index 0000000..d971677 --- /dev/null +++ b/ext/nodes_bfl.py @@ -0,0 +1,1093 @@ +import io +from inspect import cleandoc +from typing import Union, Optional +from comfy.comfy_types.node_typing import IO, ComfyNodeABC +from comfy_api_nodes.apis.bfl_api import ( + BFLStatus, + BFLFluxExpandImageRequest, + BFLFluxFillImageRequest, + BFLFluxCannyImageRequest, + BFLFluxDepthImageRequest, + BFLFluxProGenerateRequest, + BFLFluxKontextProGenerateRequest, + BFLFluxProUltraGenerateRequest, + BFLFluxProGenerateResponse, +) +from comfy_api_nodes.apis.client import ( + ApiEndpoint, + HttpMethod, + SynchronousOperation, +) +from comfy_api_nodes.apinode_utils import ( + downscale_image_tensor, + validate_aspect_ratio, + process_image_response, + resize_mask_to_image, + validate_string, +) + +import numpy as np +from PIL import Image +import requests +import torch +import base64 +import time +from server import PromptServer + + +def convert_mask_to_image(mask: torch.Tensor): + """ + Make mask have the expected amount of dims (4) and channels (3) to be recognized as an image. + """ + mask = mask.unsqueeze(-1) + mask = torch.cat([mask]*3, dim=-1) + return mask + + +def handle_bfl_synchronous_operation( + operation: SynchronousOperation, + timeout_bfl_calls=360, + node_id: Union[str, None] = None, +): + response_api: BFLFluxProGenerateResponse = operation.execute() + return _poll_until_generated( + response_api.polling_url, timeout=timeout_bfl_calls, node_id=node_id + ) + + +def _poll_until_generated( + polling_url: str, timeout=360, node_id: Union[str, None] = None +): + # used bfl-comfy-nodes to verify code implementation: + # https://github.com/black-forest-labs/bfl-comfy-nodes/tree/main + start_time = time.time() + retries_404 = 0 + max_retries_404 = 5 + retry_404_seconds = 2 + retry_202_seconds = 2 + retry_pending_seconds = 1 + request = requests.Request(method=HttpMethod.GET, url=polling_url) + # NOTE: should True loop be replaced with checking if workflow has been interrupted? + while True: + if node_id: + time_elapsed = time.time() - start_time + PromptServer.instance.send_progress_text( + f"Generating ({time_elapsed:.0f}s)", node_id + ) + + response = requests.Session().send(request.prepare()) + if response.status_code == 200: + result = response.json() + if result["status"] == BFLStatus.ready: + img_url = result["result"]["sample"] + if node_id: + PromptServer.instance.send_progress_text( + f"Result URL: {img_url}", node_id + ) + img_response = requests.get(img_url) + return process_image_response(img_response), img_url + elif result["status"] in [ + BFLStatus.request_moderated, + BFLStatus.content_moderated, + ]: + status = result["status"] + raise Exception( + f"BFL API did not return an image due to: {status}." + ) + elif result["status"] == BFLStatus.error: + raise Exception(f"BFL API encountered an error: {result}.") + elif result["status"] == BFLStatus.pending: + time.sleep(retry_pending_seconds) + continue + elif response.status_code == 404: + if retries_404 < max_retries_404: + retries_404 += 1 + time.sleep(retry_404_seconds) + continue + raise Exception( + f"BFL API could not find task after {max_retries_404} tries." + ) + elif response.status_code == 202: + time.sleep(retry_202_seconds) + elif time.time() - start_time > timeout: + raise Exception( + f"BFL API experienced a timeout; could not return request under {timeout} seconds." + ) + else: + raise Exception(f"BFL API encountered an error: {response.json()}") + +def convert_image_to_base64(image: torch.Tensor): + scaled_image = downscale_image_tensor(image, total_pixels=2048 * 2048) + # remove batch dimension if present + if len(scaled_image.shape) > 3: + scaled_image = scaled_image[0] + image_np = (scaled_image.numpy() * 255).astype(np.uint8) + img = Image.fromarray(image_np) + img_byte_arr = io.BytesIO() + img.save(img_byte_arr, format="PNG") + return base64.b64encode(img_byte_arr.getvalue()).decode() + + +class FluxProUltraImageNode(ComfyNodeABC): + """ + Generates images using Flux Pro 1.1 Ultra via api based on prompt and resolution. + """ + + MINIMUM_RATIO = 1 / 4 + MAXIMUM_RATIO = 4 / 1 + MINIMUM_RATIO_STR = "1:4" + MAXIMUM_RATIO_STR = "4:1" + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "prompt": ( + IO.STRING, + { + "multiline": True, + "default": "", + "tooltip": "Prompt for the image generation", + }, + ), + "prompt_upsampling": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).", + }, + ), + "seed": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": "The random seed used for creating the noise.", + }, + ), + "aspect_ratio": ( + IO.STRING, + { + "default": "16:9", + "tooltip": "Aspect ratio of image; must be between 1:4 and 4:1.", + }, + ), + "raw": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "When True, generate less processed, more natural-looking images.", + }, + ), + }, + "optional": { + "image_prompt": (IO.IMAGE,), + "image_prompt_strength": ( + IO.FLOAT, + { + "default": 0.1, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "tooltip": "Blend between the prompt and the image prompt.", + }, + ), + }, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + @classmethod + def VALIDATE_INPUTS(cls, aspect_ratio: str): + try: + validate_aspect_ratio( + aspect_ratio, + minimum_ratio=cls.MINIMUM_RATIO, + maximum_ratio=cls.MAXIMUM_RATIO, + minimum_ratio_str=cls.MINIMUM_RATIO_STR, + maximum_ratio_str=cls.MAXIMUM_RATIO_STR, + ) + except Exception as e: + return str(e) + return True + + RETURN_TYPES = (IO.IMAGE, IO.STRING) + RETURN_NAMES = ("image", "url") + DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value + FUNCTION = "api_call" + API_NODE = True + CATEGORY = "api node/image/BFL" + + def api_call( + self, + prompt: str, + aspect_ratio: str, + prompt_upsampling=False, + raw=False, + seed=0, + image_prompt=None, + image_prompt_strength=0.1, + unique_id: Union[str, None] = None, + **kwargs, + ): + if image_prompt is None: + validate_string(prompt, strip_whitespace=False) + operation = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/bfl/flux-pro-1.1-ultra/generate", + method=HttpMethod.POST, + request_model=BFLFluxProUltraGenerateRequest, + response_model=BFLFluxProGenerateResponse, + ), + request=BFLFluxProUltraGenerateRequest( + prompt=prompt, + prompt_upsampling=prompt_upsampling, + seed=seed, + aspect_ratio=validate_aspect_ratio( + aspect_ratio, + minimum_ratio=self.MINIMUM_RATIO, + maximum_ratio=self.MAXIMUM_RATIO, + minimum_ratio_str=self.MINIMUM_RATIO_STR, + maximum_ratio_str=self.MAXIMUM_RATIO_STR, + ), + raw=raw, + image_prompt=( + image_prompt + if image_prompt is None + else convert_image_to_base64(image_prompt) + ), + image_prompt_strength=( + None if image_prompt is None else round(image_prompt_strength, 2) + ), + ), + auth_kwargs=kwargs, + ) + output_image, img_url = handle_bfl_synchronous_operation(operation, node_id=unique_id) + return (output_image, img_url) + + +class FluxKontextProImageNode(ComfyNodeABC): + """ + Edits images using Flux.1 Kontext [pro] via api based on prompt and aspect ratio. + """ + + MINIMUM_RATIO = 1 / 4 + MAXIMUM_RATIO = 4 / 1 + MINIMUM_RATIO_STR = "1:4" + MAXIMUM_RATIO_STR = "4:1" + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "prompt": ( + IO.STRING, + { + "multiline": True, + "default": "", + "tooltip": "Prompt for the image generation - specify what and how to edit.", + }, + ), + "aspect_ratio": ( + IO.STRING, + { + "default": "16:9", + "tooltip": "Aspect ratio of image; must be between 1:4 and 4:1.", + }, + ), + "guidance": ( + IO.FLOAT, + { + "default": 3.0, + "min": 0.1, + "max": 99.0, + "step": 0.1, + "tooltip": "Guidance strength for the image generation process" + }, + ), + "steps": ( + IO.INT, + { + "default": 50, + "min": 1, + "max": 150, + "tooltip": "Number of steps for the image generation process" + }, + ), + "seed": ( + IO.INT, + { + "default": 1234, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": "The random seed used for creating the noise.", + }, + ), + "prompt_upsampling": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).", + }, + ), + }, + "optional": { + "input_image": (IO.IMAGE,), + }, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + @classmethod + def VALIDATE_INPUTS(cls, aspect_ratio: str): + try: + validate_aspect_ratio( + aspect_ratio, + minimum_ratio=cls.MINIMUM_RATIO, + maximum_ratio=cls.MAXIMUM_RATIO, + minimum_ratio_str=cls.MINIMUM_RATIO_STR, + maximum_ratio_str=cls.MAXIMUM_RATIO_STR, + ) + except Exception as e: + return str(e) + return True + + RETURN_TYPES = (IO.IMAGE, IO.STRING) + RETURN_NAMES = ("image", "url") + DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value + FUNCTION = "api_call" + API_NODE = True + CATEGORY = "api node/image/BFL" + + BFL_PATH = "/proxy/bfl/flux-kontext-pro/generate" + + def api_call( + self, + prompt: str, + aspect_ratio: str, + guidance: float, + steps: int, + input_image: Optional[torch.Tensor]=None, + seed=0, + prompt_upsampling=False, + unique_id: Union[str, None] = None, + **kwargs, + ): + if input_image is None: + validate_string(prompt, strip_whitespace=False) + operation = SynchronousOperation( + endpoint=ApiEndpoint( + path=self.BFL_PATH, + method=HttpMethod.POST, + request_model=BFLFluxKontextProGenerateRequest, + response_model=BFLFluxProGenerateResponse, + ), + request=BFLFluxKontextProGenerateRequest( + prompt=prompt, + prompt_upsampling=prompt_upsampling, + guidance=round(guidance, 1), + steps=steps, + seed=seed, + aspect_ratio=validate_aspect_ratio( + aspect_ratio, + minimum_ratio=self.MINIMUM_RATIO, + maximum_ratio=self.MAXIMUM_RATIO, + minimum_ratio_str=self.MINIMUM_RATIO_STR, + maximum_ratio_str=self.MAXIMUM_RATIO_STR, + ), + input_image=( + input_image + if input_image is None + else convert_image_to_base64(input_image) + ) + ), + auth_kwargs=kwargs, + ) + output_image, img_url = handle_bfl_synchronous_operation(operation, node_id=unique_id) + return (output_image, img_url) + + +class FluxKontextMaxImageNode(FluxKontextProImageNode): + """ + Edits images using Flux.1 Kontext [max] via api based on prompt and aspect ratio. + """ + + DESCRIPTION = cleandoc(__doc__ or "") + BFL_PATH = "/proxy/bfl/flux-kontext-max/generate" + + +class FluxProImageNode(ComfyNodeABC): + """ + Generates images synchronously based on prompt and resolution. + """ + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "prompt": ( + IO.STRING, + { + "multiline": True, + "default": "", + "tooltip": "Prompt for the image generation", + }, + ), + "prompt_upsampling": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).", + }, + ), + "width": ( + IO.INT, + { + "default": 1024, + "min": 256, + "max": 1440, + "step": 32, + }, + ), + "height": ( + IO.INT, + { + "default": 768, + "min": 256, + "max": 1440, + "step": 32, + }, + ), + "seed": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": "The random seed used for creating the noise.", + }, + ), + }, + "optional": { + "image_prompt": (IO.IMAGE,), + # "image_prompt_strength": ( + # IO.FLOAT, + # { + # "default": 0.1, + # "min": 0.0, + # "max": 1.0, + # "step": 0.01, + # "tooltip": "Blend between the prompt and the image prompt.", + # }, + # ), + }, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = (IO.IMAGE,) + DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value + FUNCTION = "api_call" + API_NODE = True + CATEGORY = "api node/image/BFL" + + def api_call( + self, + prompt: str, + prompt_upsampling, + width: int, + height: int, + seed=0, + image_prompt=None, + # image_prompt_strength=0.1, + unique_id: Union[str, None] = None, + **kwargs, + ): + image_prompt = ( + image_prompt + if image_prompt is None + else convert_image_to_base64(image_prompt) + ) + + operation = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/bfl/flux-pro-1.1/generate", + method=HttpMethod.POST, + request_model=BFLFluxProGenerateRequest, + response_model=BFLFluxProGenerateResponse, + ), + request=BFLFluxProGenerateRequest( + prompt=prompt, + prompt_upsampling=prompt_upsampling, + width=width, + height=height, + seed=seed, + image_prompt=image_prompt, + ), + auth_kwargs=kwargs, + ) + output_image, img_url = handle_bfl_synchronous_operation(operation, node_id=unique_id) + return (output_image,) + + +class FluxProExpandNode(ComfyNodeABC): + """ + Outpaints image based on prompt. + """ + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image": (IO.IMAGE,), + "prompt": ( + IO.STRING, + { + "multiline": True, + "default": "", + "tooltip": "Prompt for the image generation", + }, + ), + "prompt_upsampling": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).", + }, + ), + "top": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 2048, + "tooltip": "Number of pixels to expand at the top of the image" + }, + ), + "bottom": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 2048, + "tooltip": "Number of pixels to expand at the bottom of the image" + }, + ), + "left": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 2048, + "tooltip": "Number of pixels to expand at the left side of the image" + }, + ), + "right": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 2048, + "tooltip": "Number of pixels to expand at the right side of the image" + }, + ), + "guidance": ( + IO.FLOAT, + { + "default": 60, + "min": 1.5, + "max": 100, + "tooltip": "Guidance strength for the image generation process" + }, + ), + "steps": ( + IO.INT, + { + "default": 50, + "min": 15, + "max": 50, + "tooltip": "Number of steps for the image generation process" + }, + ), + "seed": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": "The random seed used for creating the noise.", + }, + ), + }, + "optional": {}, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = (IO.IMAGE, IO.STRING) + RETURN_NAMES = ("image", "url") + DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value + FUNCTION = "api_call" + API_NODE = True + CATEGORY = "api node/image/BFL" + + def api_call( + self, + image: torch.Tensor, + prompt: str, + prompt_upsampling: bool, + top: int, + bottom: int, + left: int, + right: int, + steps: int, + guidance: float, + seed=0, + unique_id: Union[str, None] = None, + **kwargs, + ): + image = convert_image_to_base64(image) + + operation = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/bfl/flux-pro-1.0-expand/generate", + method=HttpMethod.POST, + request_model=BFLFluxExpandImageRequest, + response_model=BFLFluxProGenerateResponse, + ), + request=BFLFluxExpandImageRequest( + prompt=prompt, + prompt_upsampling=prompt_upsampling, + top=top, + bottom=bottom, + left=left, + right=right, + steps=steps, + guidance=guidance, + seed=seed, + image=image, + ), + auth_kwargs=kwargs, + ) + output_image, img_url = handle_bfl_synchronous_operation(operation, node_id=unique_id) + return (output_image, img_url) + + +class FluxProFillNode(ComfyNodeABC): + """ + Inpaints image based on mask and prompt. + """ + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image": (IO.IMAGE,), + "mask": (IO.MASK,), + "prompt": ( + IO.STRING, + { + "multiline": True, + "default": "", + "tooltip": "Prompt for the image generation", + }, + ), + "prompt_upsampling": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).", + }, + ), + "guidance": ( + IO.FLOAT, + { + "default": 60, + "min": 1.5, + "max": 100, + "tooltip": "Guidance strength for the image generation process" + }, + ), + "steps": ( + IO.INT, + { + "default": 50, + "min": 15, + "max": 50, + "tooltip": "Number of steps for the image generation process" + }, + ), + "seed": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": "The random seed used for creating the noise.", + }, + ), + }, + "optional": {}, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = (IO.IMAGE, IO.STRING) + RETURN_NAMES = ("image", "url") + DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value + FUNCTION = "api_call" + API_NODE = True + CATEGORY = "api node/image/BFL" + + def api_call( + self, + image: torch.Tensor, + mask: torch.Tensor, + prompt: str, + prompt_upsampling: bool, + steps: int, + guidance: float, + seed=0, + unique_id: Union[str, None] = None, + **kwargs, + ): + # prepare mask + mask = resize_mask_to_image(mask, image) + mask = convert_image_to_base64(convert_mask_to_image(mask)) + # make sure image will have alpha channel removed + image = convert_image_to_base64(image[:, :, :, :3]) + + operation = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/bfl/flux-pro-1.0-fill/generate", + method=HttpMethod.POST, + request_model=BFLFluxFillImageRequest, + response_model=BFLFluxProGenerateResponse, + ), + request=BFLFluxFillImageRequest( + prompt=prompt, + prompt_upsampling=prompt_upsampling, + steps=steps, + guidance=guidance, + seed=seed, + image=image, + mask=mask, + ), + auth_kwargs=kwargs, + ) + output_image, img_url = handle_bfl_synchronous_operation(operation, node_id=unique_id) + return (output_image, img_url) + + +class FluxProCannyNode(ComfyNodeABC): + """ + Generate image using a control image (canny). + """ + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "control_image": (IO.IMAGE,), + "prompt": ( + IO.STRING, + { + "multiline": True, + "default": "", + "tooltip": "Prompt for the image generation", + }, + ), + "prompt_upsampling": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).", + }, + ), + "canny_low_threshold": ( + IO.FLOAT, + { + "default": 0.1, + "min": 0.01, + "max": 0.99, + "step": 0.01, + "tooltip": "Low threshold for Canny edge detection; ignored if skip_processing is True" + }, + ), + "canny_high_threshold": ( + IO.FLOAT, + { + "default": 0.4, + "min": 0.01, + "max": 0.99, + "step": 0.01, + "tooltip": "High threshold for Canny edge detection; ignored if skip_processing is True" + }, + ), + "skip_preprocessing": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "Whether to skip preprocessing; set to True if control_image already is canny-fied, False if it is a raw image.", + }, + ), + "guidance": ( + IO.FLOAT, + { + "default": 30, + "min": 1, + "max": 100, + "tooltip": "Guidance strength for the image generation process" + }, + ), + "steps": ( + IO.INT, + { + "default": 50, + "min": 15, + "max": 50, + "tooltip": "Number of steps for the image generation process" + }, + ), + "seed": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": "The random seed used for creating the noise.", + }, + ), + }, + "optional": {}, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = (IO.IMAGE, IO.STRING) + RETURN_NAMES = ("image", "url") + DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value + FUNCTION = "api_call" + API_NODE = True + CATEGORY = "api node/image/BFL" + + def api_call( + self, + control_image: torch.Tensor, + prompt: str, + prompt_upsampling: bool, + canny_low_threshold: float, + canny_high_threshold: float, + skip_preprocessing: bool, + steps: int, + guidance: float, + seed=0, + unique_id: Union[str, None] = None, + **kwargs, + ): + control_image = convert_image_to_base64(control_image[:, :, :, :3]) + preprocessed_image = None + + # scale canny threshold between 0-500, to match BFL's API + def scale_value(value: float, min_val=0, max_val=500): + return min_val + value * (max_val - min_val) + canny_low_threshold = int(round(scale_value(canny_low_threshold))) + canny_high_threshold = int(round(scale_value(canny_high_threshold))) + + + if skip_preprocessing: + preprocessed_image = control_image + control_image = None + canny_low_threshold = None + canny_high_threshold = None + + operation = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/bfl/flux-pro-1.0-canny/generate", + method=HttpMethod.POST, + request_model=BFLFluxCannyImageRequest, + response_model=BFLFluxProGenerateResponse, + ), + request=BFLFluxCannyImageRequest( + prompt=prompt, + prompt_upsampling=prompt_upsampling, + steps=steps, + guidance=guidance, + seed=seed, + control_image=control_image, + canny_low_threshold=canny_low_threshold, + canny_high_threshold=canny_high_threshold, + preprocessed_image=preprocessed_image, + ), + auth_kwargs=kwargs, + ) + output_image, img_url = handle_bfl_synchronous_operation(operation, node_id=unique_id) + return (output_image, img_url) + + +class FluxProDepthNode(ComfyNodeABC): + """ + Generate image using a control image (depth). + """ + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "control_image": (IO.IMAGE,), + "prompt": ( + IO.STRING, + { + "multiline": True, + "default": "", + "tooltip": "Prompt for the image generation", + }, + ), + "prompt_upsampling": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).", + }, + ), + "skip_preprocessing": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "Whether to skip preprocessing; set to True if control_image already is depth-ified, False if it is a raw image.", + }, + ), + "guidance": ( + IO.FLOAT, + { + "default": 15, + "min": 1, + "max": 100, + "tooltip": "Guidance strength for the image generation process" + }, + ), + "steps": ( + IO.INT, + { + "default": 50, + "min": 15, + "max": 50, + "tooltip": "Number of steps for the image generation process" + }, + ), + "seed": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": "The random seed used for creating the noise.", + }, + ), + }, + "optional": {}, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = (IO.IMAGE, IO.STRING) + RETURN_NAMES = ("image", "url") + DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value + FUNCTION = "api_call" + API_NODE = True + CATEGORY = "api node/image/BFL" + + def api_call( + self, + control_image: torch.Tensor, + prompt: str, + prompt_upsampling: bool, + skip_preprocessing: bool, + steps: int, + guidance: float, + seed=0, + unique_id: Union[str, None] = None, + **kwargs, + ): + control_image = convert_image_to_base64(control_image[:,:,:,:3]) + preprocessed_image = None + + if skip_preprocessing: + preprocessed_image = control_image + control_image = None + + operation = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/bfl/flux-pro-1.0-depth/generate", + method=HttpMethod.POST, + request_model=BFLFluxDepthImageRequest, + response_model=BFLFluxProGenerateResponse, + ), + request=BFLFluxDepthImageRequest( + prompt=prompt, + prompt_upsampling=prompt_upsampling, + steps=steps, + guidance=guidance, + seed=seed, + control_image=control_image, + preprocessed_image=preprocessed_image, + ), + auth_kwargs=kwargs, + ) + output_image, img_url = handle_bfl_synchronous_operation(operation, node_id=unique_id) + return (output_image, img_url) + + +# A dictionary that contains all nodes you want to export with their names +# NOTE: names should be globally unique +NODE_CLASS_MAPPINGS = { + "FluxProUltraImageNode": FluxProUltraImageNode, + # "FluxProImageNode": FluxProImageNode, + "FluxKontextProImageNode": FluxKontextProImageNode, + "FluxKontextMaxImageNode": FluxKontextMaxImageNode, + "FluxProExpandNode": FluxProExpandNode, + "FluxProFillNode": FluxProFillNode, + "FluxProCannyNode": FluxProCannyNode, + "FluxProDepthNode": FluxProDepthNode, +} + +# A dictionary that contains the friendly/humanly readable titles for the nodes +NODE_DISPLAY_NAME_MAPPINGS = { + "FluxProUltraImageNode": "Flux 1.1 [pro] Ultra Image", + # "FluxProImageNode": "Flux 1.1 [pro] Image", + "FluxKontextProImageNode": "Flux.1 Kontext [pro] Image", + "FluxKontextMaxImageNode": "Flux.1 Kontext [max] Image", + "FluxProExpandNode": "Flux.1 Expand Image", + "FluxProFillNode": "Flux.1 Fill Image", + "FluxProCannyNode": "Flux.1 Canny Control Image", + "FluxProDepthNode": "Flux.1 Depth Control Image", +} diff --git a/ext/prompt_id_generator.py b/ext/prompt_id_generator.py new file mode 100644 index 0000000..95972ed --- /dev/null +++ b/ext/prompt_id_generator.py @@ -0,0 +1,40 @@ +import uuid + + +class TaskIdGenerate: + """TaskID生成器:用户可传入或自动生成TaskID""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": {}, + "optional": { + "custom_task_id": ("STRING", {"default": "", "placeholder": "留空则自动生成"}), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("task_id",) + FUNCTION = "generate_task_id" + OUTPUT_NODE = False + CATEGORY = "utils" + + def generate_task_id(self, custom_task_id=""): + if custom_task_id and custom_task_id.strip(): + # 用户输入了自定义ID + task_id = custom_task_id.strip() + print(f"📝 使用自定义TaskID: {task_id}") + else: + # 自动生成UUID + task_id = str(uuid.uuid4()) + print(f"🎲 自动生成TaskID: {task_id}") + + return (task_id,) + + +NODE_CLASS_MAPPINGS = { + "TaskIdGenerate": TaskIdGenerate +} +NODE_DISPLAY_NAME_MAPPINGS = { + "TaskIdGenerate": "TaskID生成器" +} \ No newline at end of file diff --git a/ext/random_line.py b/ext/random_line.py new file mode 100644 index 0000000..b8c9ec8 --- /dev/null +++ b/ext/random_line.py @@ -0,0 +1,77 @@ +import random +import time + + +class RandomLineSelector: + """ + ComfyUI自定义节点:随机行选择器 + 从输入的多行文本中随机选择一行输出 + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("STRING", { + "multiline": True, + "default": """ +保持人物样貌和衣服不变,改变人物的光影效果。更换背景: 图像呈现出一个温馨的维多利亚复古风格房间,墙面被多样化的艺术画框与照片装饰,展现出浓郁的怀旧氛围。中心的金框全身镜是视觉焦点,上面缠绕着精致花卉装饰,增强了空间的艺术感。左侧靠墙摆放花瓶与烛台,白色地板映衬着跳动的暖黄色光线,营造出柔和而梦幻的氛围。右侧床铺整洁柔软,与一旁的小抽屉柜和台灯形成和谐的组合。整体画面色调以暖黄色为主,搭配田园风格装饰元素,构图均衡,注重细节,体现了舒适浪漫的生活场景。人物重新打光融合到场景中。 +保持人物形象和衣服不变,改变人物的光影效果。更换背景: 衣帽间的开放式悬挂区搭配浅色木地板,衣杆上挂满中性色长裙,旁边搁板整齐叠放针织毛衣与围巾。 +保持人物形象和衣服不变,改变人物的光影效果。更换背景: 白色衣柜门打开后,分层挂满长裙和连体裤,底部抽屉收纳着色彩鲜艳的运动服与家居裤。 +保持人物形象和衣服不变,改变人物的光影效果。更换背景: 玻璃门的衣柜设计体现精致生活,内部悬挂精致衬衫和镂空裙摆,饰品抽屉随时为配件搭配提供轻便选择。 +""" + }), + "seed": ("INT", { + "default": 0, + "min": -1, + "max": 0xffffffffffffffff + }), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("selected_line",) + FUNCTION = "select_random_line" + CATEGORY = "text" + + def select_random_line(self, text, seed): + """ + 从输入文本中随机选择一行 + + Args: + text (str): 输入的多行文本 + seed (int): 随机种子 + + Returns: + tuple: 包含选中行的元组 + """ + # 设置随机种子(-1表示使用当前时间戳) + if seed == -1: + random.seed(int(time.time() * 1000000)) # 使用微秒级时间戳 + else: + random.seed(seed) + + # 过滤掉空行 + non_empty_lines = [line.strip() for line in text.split('\n') if line.strip()] + + # 如果没有非空行,返回空字符串 + if not non_empty_lines: + return ("",) + + # 随机选择一行 + selected_line = random.choice(non_empty_lines) + + return (selected_line,) + + +# ComfyUI节点映射 +NODE_CLASS_MAPPINGS = { + "RandomLineSelector": RandomLineSelector +} + +# 节点显示名称映射 +NODE_DISPLAY_NAME_MAPPINGS = { + "RandomLineSelector": "随机选择一行内容" +} +# https://bowongai--dev-ui.modal.run/ +# https://bowongai--dev-ui.modal.run diff --git a/ext/webhook_forward.py b/ext/webhook_forward.py new file mode 100644 index 0000000..afcaca8 --- /dev/null +++ b/ext/webhook_forward.py @@ -0,0 +1,187 @@ +# -*- coding:utf-8 -*- +""" +File webhook_forward.py +Author silence +Date 2025/6/13 13:42 +""" +import base64 +import io +import os +import random +import string +import time + +import numpy as np +import requests +from PIL import Image + + +class PromptIDGenerator: + """PromptID生成器:用户可传入或自动生成PromptID""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": {}, + "optional": { + "custom_prompt_id": ("STRING", {"default": "", "placeholder": "留空则自动生成"}), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("prompt_id",) + FUNCTION = "generate_prompt_id" + OUTPUT_NODE = False + CATEGORY = "utils" + + def generate_prompt_id(self, custom_prompt_id=""): + if custom_prompt_id and custom_prompt_id.strip(): + # 用户输入了自定义ID + prompt_id = custom_prompt_id.strip() + print(f"📝 使用自定义PromptID: {prompt_id}") + else: + # 自动生成ID:时间戳 + 随机字符 + timestamp = str(int(time.time())) + random_str = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6)) + prompt_id = f"prompt_{timestamp}_{random_str}" + print(f"🎲 自动生成PromptID: {prompt_id}") + + return (prompt_id,) + + +class PlugAndPlayWebhook: + """即插即用Webhook节点:连上线就能转发数据""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "webhook_url": ("STRING", {"default": "http://127.0.0.1:8010/handler/webhook", + "placeholder": "https://your-api.com/webhook"}), + "image_url": ("STRING", {"default": "", + "placeholder": "图片的url"}), + }, + "optional": { + "prompt_id": ("STRING", {"default": ""}), + }, + "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO", "unique_id": "UNIQUE_ID"}, + } + + RETURN_TYPES = () + FUNCTION = "send" + OUTPUT_NODE = True + CATEGORY = "utils" + + def send(self, webhook_url, image_url, prompt_id="", prompt=None, extra_pnginfo=None, unique_id=None): + if not webhook_url: + raise ValueError("❌ 请填写Webhook URL!") + + # 使用传入的prompt_id,如果没有则用unique_id + final_prompt_id = prompt_id or unique_id or "unknown" + + # 准备发送的数据 + data = { + "img_base64": image_url, + "format": "png", + "image_url": image_url, + "prompt_id": final_prompt_id, + "timestamp": time.time() + } + + # 发送Webhook + try: + response = requests.post(webhook_url, json=data) + response.raise_for_status() + print(f'发送的数据:{data}') + except Exception as e: + print(f"❌ 发送失败: {str(e)}") + + # 终端节点,无需返回 + return () + + +class SaveImageWithOutput: + """保存图片并输出的节点:既保存图片又能继续传递数据""" + + def __init__(self): + self.output_dir = "output" + self.type = "output" + self.prefix_append = "" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + "filename_prefix": ("STRING", {"default": "ComfyUI"}), + }, + } + + RETURN_TYPES = ("IMAGE", "STRING") + RETURN_NAMES = ("images", "file_path") + FUNCTION = "save_image" + OUTPUT_NODE = False + CATEGORY = "image" + + def save_image(self, images, filename_prefix="ComfyUI"): + filename_prefix += self.prefix_append + full_output_folder, filename, counter, subfolder, filename_prefix = self.get_save_image_path(filename_prefix, + self.output_dir) + + file_paths = [] + + for i, image in enumerate(images): + # 转换图片数据 + img_array = 255. * image.cpu().numpy() + img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8)) + + # 生成文件名 + file = f"{filename_prefix}_{counter + i:05}_.png" + file_path = os.path.join(full_output_folder, file) + + # 保存图片 + img.save(file_path, compress_level=4) + file_paths.append(file_path) + + print(f"✅ 图片已保存到: {file_paths[0] if file_paths else '未知路径'}") + + # 直接返回元组:(原始图片数据, 第一个文件路径) + return (images, file_paths[0] if file_paths else "") + + def get_save_image_path(self, filename_prefix, output_dir): + def map_filename(filename): + prefix_len = len(os.path.basename(filename_prefix)) + prefix = filename[:prefix_len + 1] + try: + digits = int(filename[prefix_len + 1:].split('_')[0]) + except: + digits = 0 + return (digits, prefix) + + subfolder = "" + full_output_folder = os.path.join(output_dir, subfolder) + + if os.path.commonpath((output_dir, os.path.abspath(full_output_folder))) != output_dir: + print("Saving image outside the output folder is not allowed.") + return {} + + try: + counter = max(filter(lambda a: a[1][:-1] == filename_prefix and a[1][-1] == "_", + map(map_filename, os.listdir(full_output_folder))))[0] + 1 + except ValueError: + counter = 1 + except FileNotFoundError: + os.makedirs(full_output_folder, exist_ok=True) + counter = 1 + + return full_output_folder, filename_prefix, counter, subfolder, filename_prefix + + +NODE_CLASS_MAPPINGS = { + "PlugAndPlayWebhook": PlugAndPlayWebhook, + "SaveImageWithOutput": SaveImageWithOutput +} +NODE_DISPLAY_NAME_MAPPINGS = { + "PlugAndPlayWebhook": "Webhook转发器", + "SaveImageWithOutput": "保存图片(带输出)" +} diff --git a/nodes/cos_utils.py b/nodes/cos_utils.py new file mode 100644 index 0000000..e7e0caa --- /dev/null +++ b/nodes/cos_utils.py @@ -0,0 +1,98 @@ +# -*- coding:utf-8 -*- +""" +File cos_utils.py +Author silence +Date 2025/6/11 20:37 +""" +import mimetypes +import os +from loguru import logger +from qcloud_cos import CosConfig +from qcloud_cos import CosS3Client + +# COS配置 +COS_BUCKET_NAME = 'sucai-1324682537' +COS_SECRET_ID = 'AKIDsrihIyjZOBsjimt8TsN8yvv1AMh5dB44' +COS_SECRET_KEY = 'CPZcxdk6W39Jd4cGY95wvupoyMd0YFqW' +COS_REGION = 'ap-shanghai' + + +class CosUploadNode: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "file_path": ("STRING", {"forceInput": True}), + }, + "optional": { + "remove_source": ("BOOLEAN", {"default": False}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("upload_url",) + FUNCTION = "upload_to_cos" + CATEGORY = "不忘科技-自定义节点🚩" + + def upload_to_cos(self, file_path, remove_source=False): + try: + # 检查文件是否存在 + if not os.path.isfile(file_path): + raise Exception(f"文件不存在: {file_path}") + + # 获取文件MIME类型和分类 + mime_type, _ = mimetypes.guess_type(file_path) + if mime_type: + category = mime_type.split('/')[0] + else: + category = 'unknown' + + file_name = os.path.basename(file_path) + object_key = f'tk/{category}/{file_name}' + + # 配置COS客户端 + config = CosConfig( + Region=COS_REGION, + SecretId=COS_SECRET_ID, + SecretKey=COS_SECRET_KEY + ) + client = CosS3Client(config) + + # 上传文件 + client.upload_file( + Bucket=COS_BUCKET_NAME, + Key=object_key, + LocalFilePath=file_path, + EnableMD5=False + ) + + # 生成访问URL + upload_url = f'https://{COS_BUCKET_NAME}.cos.{COS_REGION}.myqcloud.com/{object_key}' + + logger.info(f'文件上传成功: {upload_url}') + + # 如果需要删除源文件 + if remove_source and os.path.exists(file_path): + try: + os.remove(file_path) + logger.info(f'源文件已删除: {file_path}') + except Exception as e: + logger.warning(f'删除源文件失败: {e}') + + return (upload_url,) + + except Exception as e: + error_msg = f'上传失败: {str(e)}' + logger.error(error_msg) + # 返回错误信息而不是抛出异常,这样ComfyUI不会中断流程 + return (f"ERROR: {error_msg}",) + + +# 节点注册 +NODE_CLASS_MAPPINGS = { + "CosUploadNode": CosUploadNode +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "CosUploadNode": "腾讯COS上传" +} \ No newline at end of file diff --git a/nodes/image.py b/nodes/image.py index ed54e12..2acdd5d 100644 --- a/nodes/image.py +++ b/nodes/image.py @@ -13,7 +13,6 @@ class SaveImagePath: "image_path": ("IMAGE", {"forceInput": True}), } } - RETURN_TYPES = ("STRING",) FUNCTION = "load" CATEGORY = "不忘科技-自定义节点🚩" @@ -50,13 +49,13 @@ class SaveImagePath: pil_image = Image.fromarray(image_path) - # output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output") + output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output") # base_dir = os.path.dirname(os.path.abspath(__file__)) - output_dir = '/root/comfy/ComfyUI/output' + # output_dir = '/root/comfy/ComfyUI/output' if not os.path.exists(output_dir): os.makedirs(output_dir) - file_name = "%s.jpg" % str(uuid.uuid4()) + file_name = "%s.png" % str(uuid.uuid4()) p = os.path.join(output_dir, file_name) pil_image.save(p) diff --git a/nodes/load_image_pro.py b/nodes/load_image_pro.py new file mode 100644 index 0000000..9d2e4d8 --- /dev/null +++ b/nodes/load_image_pro.py @@ -0,0 +1,62 @@ +from io import BytesIO + +import numpy as np +import requests +import torch +from PIL import Image + + +# 定义节点类 +class LoadNetImg: + # 定义节点输入类型 + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image_url": ("STRING", { + "default": "https://example.com/sample.jpg", + "multiline": False + }), + } + } + + # 定义节点输出类型 + RETURN_TYPES = ("IMAGE",) # 返回图像数据 + RETURN_NAMES = ("image",) # 命名返回值 + FUNCTION = "load_image_task" # 函数标识符,方便注册多个节点功能 + OUTPUT_NODE = False # 不允许该节点直接作为最终输出节点 + CATEGORY = "image" # 节点所属类别(在 ComfyUI 界面中分类) + + def load_image_task(self, image_url): + try: + if not image_url or not image_url.strip(): + raise ValueError("需要提供图片URL") + + # 下载网络图片 + response = requests.get(image_url) + response.raise_for_status() # 请求失败时抛出异常 + image = Image.open(BytesIO(response.content)).convert("RGB") + + # 按照官方格式转换图像数据 + # Convert to numpy array and normalize to 0-1 + image_array = np.array(image).astype(np.float32) / 255.0 + # Convert to torch tensor and add batch dimension + image_tensor = torch.from_numpy(image_array)[None,] + + return (image_tensor,) # 返回torch张量 + except Exception as e: + print(f"Error loading image: {e}") + # 返回一个空的黑色图片作为错误处理 + empty_image = torch.zeros((1, 512, 512, 3), dtype=torch.float32) + return (empty_image,) + + + + +# 映射节点类和名称 +NODE_CLASS_MAPPINGS = { + "LoadNetImg": LoadNetImg, # 将类映射到节点名称 +} +NODE_DISPLAY_NAME_MAPPINGS = { + "LoadNetImg": "load_net_image", # 节点显示名称 +} \ No newline at end of file diff --git a/nodes/nodes_bfl.py b/nodes/nodes_bfl.py new file mode 100644 index 0000000..d971677 --- /dev/null +++ b/nodes/nodes_bfl.py @@ -0,0 +1,1093 @@ +import io +from inspect import cleandoc +from typing import Union, Optional +from comfy.comfy_types.node_typing import IO, ComfyNodeABC +from comfy_api_nodes.apis.bfl_api import ( + BFLStatus, + BFLFluxExpandImageRequest, + BFLFluxFillImageRequest, + BFLFluxCannyImageRequest, + BFLFluxDepthImageRequest, + BFLFluxProGenerateRequest, + BFLFluxKontextProGenerateRequest, + BFLFluxProUltraGenerateRequest, + BFLFluxProGenerateResponse, +) +from comfy_api_nodes.apis.client import ( + ApiEndpoint, + HttpMethod, + SynchronousOperation, +) +from comfy_api_nodes.apinode_utils import ( + downscale_image_tensor, + validate_aspect_ratio, + process_image_response, + resize_mask_to_image, + validate_string, +) + +import numpy as np +from PIL import Image +import requests +import torch +import base64 +import time +from server import PromptServer + + +def convert_mask_to_image(mask: torch.Tensor): + """ + Make mask have the expected amount of dims (4) and channels (3) to be recognized as an image. + """ + mask = mask.unsqueeze(-1) + mask = torch.cat([mask]*3, dim=-1) + return mask + + +def handle_bfl_synchronous_operation( + operation: SynchronousOperation, + timeout_bfl_calls=360, + node_id: Union[str, None] = None, +): + response_api: BFLFluxProGenerateResponse = operation.execute() + return _poll_until_generated( + response_api.polling_url, timeout=timeout_bfl_calls, node_id=node_id + ) + + +def _poll_until_generated( + polling_url: str, timeout=360, node_id: Union[str, None] = None +): + # used bfl-comfy-nodes to verify code implementation: + # https://github.com/black-forest-labs/bfl-comfy-nodes/tree/main + start_time = time.time() + retries_404 = 0 + max_retries_404 = 5 + retry_404_seconds = 2 + retry_202_seconds = 2 + retry_pending_seconds = 1 + request = requests.Request(method=HttpMethod.GET, url=polling_url) + # NOTE: should True loop be replaced with checking if workflow has been interrupted? + while True: + if node_id: + time_elapsed = time.time() - start_time + PromptServer.instance.send_progress_text( + f"Generating ({time_elapsed:.0f}s)", node_id + ) + + response = requests.Session().send(request.prepare()) + if response.status_code == 200: + result = response.json() + if result["status"] == BFLStatus.ready: + img_url = result["result"]["sample"] + if node_id: + PromptServer.instance.send_progress_text( + f"Result URL: {img_url}", node_id + ) + img_response = requests.get(img_url) + return process_image_response(img_response), img_url + elif result["status"] in [ + BFLStatus.request_moderated, + BFLStatus.content_moderated, + ]: + status = result["status"] + raise Exception( + f"BFL API did not return an image due to: {status}." + ) + elif result["status"] == BFLStatus.error: + raise Exception(f"BFL API encountered an error: {result}.") + elif result["status"] == BFLStatus.pending: + time.sleep(retry_pending_seconds) + continue + elif response.status_code == 404: + if retries_404 < max_retries_404: + retries_404 += 1 + time.sleep(retry_404_seconds) + continue + raise Exception( + f"BFL API could not find task after {max_retries_404} tries." + ) + elif response.status_code == 202: + time.sleep(retry_202_seconds) + elif time.time() - start_time > timeout: + raise Exception( + f"BFL API experienced a timeout; could not return request under {timeout} seconds." + ) + else: + raise Exception(f"BFL API encountered an error: {response.json()}") + +def convert_image_to_base64(image: torch.Tensor): + scaled_image = downscale_image_tensor(image, total_pixels=2048 * 2048) + # remove batch dimension if present + if len(scaled_image.shape) > 3: + scaled_image = scaled_image[0] + image_np = (scaled_image.numpy() * 255).astype(np.uint8) + img = Image.fromarray(image_np) + img_byte_arr = io.BytesIO() + img.save(img_byte_arr, format="PNG") + return base64.b64encode(img_byte_arr.getvalue()).decode() + + +class FluxProUltraImageNode(ComfyNodeABC): + """ + Generates images using Flux Pro 1.1 Ultra via api based on prompt and resolution. + """ + + MINIMUM_RATIO = 1 / 4 + MAXIMUM_RATIO = 4 / 1 + MINIMUM_RATIO_STR = "1:4" + MAXIMUM_RATIO_STR = "4:1" + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "prompt": ( + IO.STRING, + { + "multiline": True, + "default": "", + "tooltip": "Prompt for the image generation", + }, + ), + "prompt_upsampling": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).", + }, + ), + "seed": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": "The random seed used for creating the noise.", + }, + ), + "aspect_ratio": ( + IO.STRING, + { + "default": "16:9", + "tooltip": "Aspect ratio of image; must be between 1:4 and 4:1.", + }, + ), + "raw": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "When True, generate less processed, more natural-looking images.", + }, + ), + }, + "optional": { + "image_prompt": (IO.IMAGE,), + "image_prompt_strength": ( + IO.FLOAT, + { + "default": 0.1, + "min": 0.0, + "max": 1.0, + "step": 0.01, + "tooltip": "Blend between the prompt and the image prompt.", + }, + ), + }, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + @classmethod + def VALIDATE_INPUTS(cls, aspect_ratio: str): + try: + validate_aspect_ratio( + aspect_ratio, + minimum_ratio=cls.MINIMUM_RATIO, + maximum_ratio=cls.MAXIMUM_RATIO, + minimum_ratio_str=cls.MINIMUM_RATIO_STR, + maximum_ratio_str=cls.MAXIMUM_RATIO_STR, + ) + except Exception as e: + return str(e) + return True + + RETURN_TYPES = (IO.IMAGE, IO.STRING) + RETURN_NAMES = ("image", "url") + DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value + FUNCTION = "api_call" + API_NODE = True + CATEGORY = "api node/image/BFL" + + def api_call( + self, + prompt: str, + aspect_ratio: str, + prompt_upsampling=False, + raw=False, + seed=0, + image_prompt=None, + image_prompt_strength=0.1, + unique_id: Union[str, None] = None, + **kwargs, + ): + if image_prompt is None: + validate_string(prompt, strip_whitespace=False) + operation = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/bfl/flux-pro-1.1-ultra/generate", + method=HttpMethod.POST, + request_model=BFLFluxProUltraGenerateRequest, + response_model=BFLFluxProGenerateResponse, + ), + request=BFLFluxProUltraGenerateRequest( + prompt=prompt, + prompt_upsampling=prompt_upsampling, + seed=seed, + aspect_ratio=validate_aspect_ratio( + aspect_ratio, + minimum_ratio=self.MINIMUM_RATIO, + maximum_ratio=self.MAXIMUM_RATIO, + minimum_ratio_str=self.MINIMUM_RATIO_STR, + maximum_ratio_str=self.MAXIMUM_RATIO_STR, + ), + raw=raw, + image_prompt=( + image_prompt + if image_prompt is None + else convert_image_to_base64(image_prompt) + ), + image_prompt_strength=( + None if image_prompt is None else round(image_prompt_strength, 2) + ), + ), + auth_kwargs=kwargs, + ) + output_image, img_url = handle_bfl_synchronous_operation(operation, node_id=unique_id) + return (output_image, img_url) + + +class FluxKontextProImageNode(ComfyNodeABC): + """ + Edits images using Flux.1 Kontext [pro] via api based on prompt and aspect ratio. + """ + + MINIMUM_RATIO = 1 / 4 + MAXIMUM_RATIO = 4 / 1 + MINIMUM_RATIO_STR = "1:4" + MAXIMUM_RATIO_STR = "4:1" + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "prompt": ( + IO.STRING, + { + "multiline": True, + "default": "", + "tooltip": "Prompt for the image generation - specify what and how to edit.", + }, + ), + "aspect_ratio": ( + IO.STRING, + { + "default": "16:9", + "tooltip": "Aspect ratio of image; must be between 1:4 and 4:1.", + }, + ), + "guidance": ( + IO.FLOAT, + { + "default": 3.0, + "min": 0.1, + "max": 99.0, + "step": 0.1, + "tooltip": "Guidance strength for the image generation process" + }, + ), + "steps": ( + IO.INT, + { + "default": 50, + "min": 1, + "max": 150, + "tooltip": "Number of steps for the image generation process" + }, + ), + "seed": ( + IO.INT, + { + "default": 1234, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": "The random seed used for creating the noise.", + }, + ), + "prompt_upsampling": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).", + }, + ), + }, + "optional": { + "input_image": (IO.IMAGE,), + }, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + @classmethod + def VALIDATE_INPUTS(cls, aspect_ratio: str): + try: + validate_aspect_ratio( + aspect_ratio, + minimum_ratio=cls.MINIMUM_RATIO, + maximum_ratio=cls.MAXIMUM_RATIO, + minimum_ratio_str=cls.MINIMUM_RATIO_STR, + maximum_ratio_str=cls.MAXIMUM_RATIO_STR, + ) + except Exception as e: + return str(e) + return True + + RETURN_TYPES = (IO.IMAGE, IO.STRING) + RETURN_NAMES = ("image", "url") + DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value + FUNCTION = "api_call" + API_NODE = True + CATEGORY = "api node/image/BFL" + + BFL_PATH = "/proxy/bfl/flux-kontext-pro/generate" + + def api_call( + self, + prompt: str, + aspect_ratio: str, + guidance: float, + steps: int, + input_image: Optional[torch.Tensor]=None, + seed=0, + prompt_upsampling=False, + unique_id: Union[str, None] = None, + **kwargs, + ): + if input_image is None: + validate_string(prompt, strip_whitespace=False) + operation = SynchronousOperation( + endpoint=ApiEndpoint( + path=self.BFL_PATH, + method=HttpMethod.POST, + request_model=BFLFluxKontextProGenerateRequest, + response_model=BFLFluxProGenerateResponse, + ), + request=BFLFluxKontextProGenerateRequest( + prompt=prompt, + prompt_upsampling=prompt_upsampling, + guidance=round(guidance, 1), + steps=steps, + seed=seed, + aspect_ratio=validate_aspect_ratio( + aspect_ratio, + minimum_ratio=self.MINIMUM_RATIO, + maximum_ratio=self.MAXIMUM_RATIO, + minimum_ratio_str=self.MINIMUM_RATIO_STR, + maximum_ratio_str=self.MAXIMUM_RATIO_STR, + ), + input_image=( + input_image + if input_image is None + else convert_image_to_base64(input_image) + ) + ), + auth_kwargs=kwargs, + ) + output_image, img_url = handle_bfl_synchronous_operation(operation, node_id=unique_id) + return (output_image, img_url) + + +class FluxKontextMaxImageNode(FluxKontextProImageNode): + """ + Edits images using Flux.1 Kontext [max] via api based on prompt and aspect ratio. + """ + + DESCRIPTION = cleandoc(__doc__ or "") + BFL_PATH = "/proxy/bfl/flux-kontext-max/generate" + + +class FluxProImageNode(ComfyNodeABC): + """ + Generates images synchronously based on prompt and resolution. + """ + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "prompt": ( + IO.STRING, + { + "multiline": True, + "default": "", + "tooltip": "Prompt for the image generation", + }, + ), + "prompt_upsampling": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).", + }, + ), + "width": ( + IO.INT, + { + "default": 1024, + "min": 256, + "max": 1440, + "step": 32, + }, + ), + "height": ( + IO.INT, + { + "default": 768, + "min": 256, + "max": 1440, + "step": 32, + }, + ), + "seed": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": "The random seed used for creating the noise.", + }, + ), + }, + "optional": { + "image_prompt": (IO.IMAGE,), + # "image_prompt_strength": ( + # IO.FLOAT, + # { + # "default": 0.1, + # "min": 0.0, + # "max": 1.0, + # "step": 0.01, + # "tooltip": "Blend between the prompt and the image prompt.", + # }, + # ), + }, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = (IO.IMAGE,) + DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value + FUNCTION = "api_call" + API_NODE = True + CATEGORY = "api node/image/BFL" + + def api_call( + self, + prompt: str, + prompt_upsampling, + width: int, + height: int, + seed=0, + image_prompt=None, + # image_prompt_strength=0.1, + unique_id: Union[str, None] = None, + **kwargs, + ): + image_prompt = ( + image_prompt + if image_prompt is None + else convert_image_to_base64(image_prompt) + ) + + operation = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/bfl/flux-pro-1.1/generate", + method=HttpMethod.POST, + request_model=BFLFluxProGenerateRequest, + response_model=BFLFluxProGenerateResponse, + ), + request=BFLFluxProGenerateRequest( + prompt=prompt, + prompt_upsampling=prompt_upsampling, + width=width, + height=height, + seed=seed, + image_prompt=image_prompt, + ), + auth_kwargs=kwargs, + ) + output_image, img_url = handle_bfl_synchronous_operation(operation, node_id=unique_id) + return (output_image,) + + +class FluxProExpandNode(ComfyNodeABC): + """ + Outpaints image based on prompt. + """ + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image": (IO.IMAGE,), + "prompt": ( + IO.STRING, + { + "multiline": True, + "default": "", + "tooltip": "Prompt for the image generation", + }, + ), + "prompt_upsampling": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).", + }, + ), + "top": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 2048, + "tooltip": "Number of pixels to expand at the top of the image" + }, + ), + "bottom": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 2048, + "tooltip": "Number of pixels to expand at the bottom of the image" + }, + ), + "left": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 2048, + "tooltip": "Number of pixels to expand at the left side of the image" + }, + ), + "right": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 2048, + "tooltip": "Number of pixels to expand at the right side of the image" + }, + ), + "guidance": ( + IO.FLOAT, + { + "default": 60, + "min": 1.5, + "max": 100, + "tooltip": "Guidance strength for the image generation process" + }, + ), + "steps": ( + IO.INT, + { + "default": 50, + "min": 15, + "max": 50, + "tooltip": "Number of steps for the image generation process" + }, + ), + "seed": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": "The random seed used for creating the noise.", + }, + ), + }, + "optional": {}, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = (IO.IMAGE, IO.STRING) + RETURN_NAMES = ("image", "url") + DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value + FUNCTION = "api_call" + API_NODE = True + CATEGORY = "api node/image/BFL" + + def api_call( + self, + image: torch.Tensor, + prompt: str, + prompt_upsampling: bool, + top: int, + bottom: int, + left: int, + right: int, + steps: int, + guidance: float, + seed=0, + unique_id: Union[str, None] = None, + **kwargs, + ): + image = convert_image_to_base64(image) + + operation = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/bfl/flux-pro-1.0-expand/generate", + method=HttpMethod.POST, + request_model=BFLFluxExpandImageRequest, + response_model=BFLFluxProGenerateResponse, + ), + request=BFLFluxExpandImageRequest( + prompt=prompt, + prompt_upsampling=prompt_upsampling, + top=top, + bottom=bottom, + left=left, + right=right, + steps=steps, + guidance=guidance, + seed=seed, + image=image, + ), + auth_kwargs=kwargs, + ) + output_image, img_url = handle_bfl_synchronous_operation(operation, node_id=unique_id) + return (output_image, img_url) + + +class FluxProFillNode(ComfyNodeABC): + """ + Inpaints image based on mask and prompt. + """ + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image": (IO.IMAGE,), + "mask": (IO.MASK,), + "prompt": ( + IO.STRING, + { + "multiline": True, + "default": "", + "tooltip": "Prompt for the image generation", + }, + ), + "prompt_upsampling": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).", + }, + ), + "guidance": ( + IO.FLOAT, + { + "default": 60, + "min": 1.5, + "max": 100, + "tooltip": "Guidance strength for the image generation process" + }, + ), + "steps": ( + IO.INT, + { + "default": 50, + "min": 15, + "max": 50, + "tooltip": "Number of steps for the image generation process" + }, + ), + "seed": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": "The random seed used for creating the noise.", + }, + ), + }, + "optional": {}, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = (IO.IMAGE, IO.STRING) + RETURN_NAMES = ("image", "url") + DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value + FUNCTION = "api_call" + API_NODE = True + CATEGORY = "api node/image/BFL" + + def api_call( + self, + image: torch.Tensor, + mask: torch.Tensor, + prompt: str, + prompt_upsampling: bool, + steps: int, + guidance: float, + seed=0, + unique_id: Union[str, None] = None, + **kwargs, + ): + # prepare mask + mask = resize_mask_to_image(mask, image) + mask = convert_image_to_base64(convert_mask_to_image(mask)) + # make sure image will have alpha channel removed + image = convert_image_to_base64(image[:, :, :, :3]) + + operation = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/bfl/flux-pro-1.0-fill/generate", + method=HttpMethod.POST, + request_model=BFLFluxFillImageRequest, + response_model=BFLFluxProGenerateResponse, + ), + request=BFLFluxFillImageRequest( + prompt=prompt, + prompt_upsampling=prompt_upsampling, + steps=steps, + guidance=guidance, + seed=seed, + image=image, + mask=mask, + ), + auth_kwargs=kwargs, + ) + output_image, img_url = handle_bfl_synchronous_operation(operation, node_id=unique_id) + return (output_image, img_url) + + +class FluxProCannyNode(ComfyNodeABC): + """ + Generate image using a control image (canny). + """ + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "control_image": (IO.IMAGE,), + "prompt": ( + IO.STRING, + { + "multiline": True, + "default": "", + "tooltip": "Prompt for the image generation", + }, + ), + "prompt_upsampling": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).", + }, + ), + "canny_low_threshold": ( + IO.FLOAT, + { + "default": 0.1, + "min": 0.01, + "max": 0.99, + "step": 0.01, + "tooltip": "Low threshold for Canny edge detection; ignored if skip_processing is True" + }, + ), + "canny_high_threshold": ( + IO.FLOAT, + { + "default": 0.4, + "min": 0.01, + "max": 0.99, + "step": 0.01, + "tooltip": "High threshold for Canny edge detection; ignored if skip_processing is True" + }, + ), + "skip_preprocessing": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "Whether to skip preprocessing; set to True if control_image already is canny-fied, False if it is a raw image.", + }, + ), + "guidance": ( + IO.FLOAT, + { + "default": 30, + "min": 1, + "max": 100, + "tooltip": "Guidance strength for the image generation process" + }, + ), + "steps": ( + IO.INT, + { + "default": 50, + "min": 15, + "max": 50, + "tooltip": "Number of steps for the image generation process" + }, + ), + "seed": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": "The random seed used for creating the noise.", + }, + ), + }, + "optional": {}, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = (IO.IMAGE, IO.STRING) + RETURN_NAMES = ("image", "url") + DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value + FUNCTION = "api_call" + API_NODE = True + CATEGORY = "api node/image/BFL" + + def api_call( + self, + control_image: torch.Tensor, + prompt: str, + prompt_upsampling: bool, + canny_low_threshold: float, + canny_high_threshold: float, + skip_preprocessing: bool, + steps: int, + guidance: float, + seed=0, + unique_id: Union[str, None] = None, + **kwargs, + ): + control_image = convert_image_to_base64(control_image[:, :, :, :3]) + preprocessed_image = None + + # scale canny threshold between 0-500, to match BFL's API + def scale_value(value: float, min_val=0, max_val=500): + return min_val + value * (max_val - min_val) + canny_low_threshold = int(round(scale_value(canny_low_threshold))) + canny_high_threshold = int(round(scale_value(canny_high_threshold))) + + + if skip_preprocessing: + preprocessed_image = control_image + control_image = None + canny_low_threshold = None + canny_high_threshold = None + + operation = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/bfl/flux-pro-1.0-canny/generate", + method=HttpMethod.POST, + request_model=BFLFluxCannyImageRequest, + response_model=BFLFluxProGenerateResponse, + ), + request=BFLFluxCannyImageRequest( + prompt=prompt, + prompt_upsampling=prompt_upsampling, + steps=steps, + guidance=guidance, + seed=seed, + control_image=control_image, + canny_low_threshold=canny_low_threshold, + canny_high_threshold=canny_high_threshold, + preprocessed_image=preprocessed_image, + ), + auth_kwargs=kwargs, + ) + output_image, img_url = handle_bfl_synchronous_operation(operation, node_id=unique_id) + return (output_image, img_url) + + +class FluxProDepthNode(ComfyNodeABC): + """ + Generate image using a control image (depth). + """ + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "control_image": (IO.IMAGE,), + "prompt": ( + IO.STRING, + { + "multiline": True, + "default": "", + "tooltip": "Prompt for the image generation", + }, + ), + "prompt_upsampling": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).", + }, + ), + "skip_preprocessing": ( + IO.BOOLEAN, + { + "default": False, + "tooltip": "Whether to skip preprocessing; set to True if control_image already is depth-ified, False if it is a raw image.", + }, + ), + "guidance": ( + IO.FLOAT, + { + "default": 15, + "min": 1, + "max": 100, + "tooltip": "Guidance strength for the image generation process" + }, + ), + "steps": ( + IO.INT, + { + "default": 50, + "min": 15, + "max": 50, + "tooltip": "Number of steps for the image generation process" + }, + ), + "seed": ( + IO.INT, + { + "default": 0, + "min": 0, + "max": 0xFFFFFFFFFFFFFFFF, + "control_after_generate": True, + "tooltip": "The random seed used for creating the noise.", + }, + ), + }, + "optional": {}, + "hidden": { + "auth_token": "AUTH_TOKEN_COMFY_ORG", + "comfy_api_key": "API_KEY_COMFY_ORG", + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = (IO.IMAGE, IO.STRING) + RETURN_NAMES = ("image", "url") + DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value + FUNCTION = "api_call" + API_NODE = True + CATEGORY = "api node/image/BFL" + + def api_call( + self, + control_image: torch.Tensor, + prompt: str, + prompt_upsampling: bool, + skip_preprocessing: bool, + steps: int, + guidance: float, + seed=0, + unique_id: Union[str, None] = None, + **kwargs, + ): + control_image = convert_image_to_base64(control_image[:,:,:,:3]) + preprocessed_image = None + + if skip_preprocessing: + preprocessed_image = control_image + control_image = None + + operation = SynchronousOperation( + endpoint=ApiEndpoint( + path="/proxy/bfl/flux-pro-1.0-depth/generate", + method=HttpMethod.POST, + request_model=BFLFluxDepthImageRequest, + response_model=BFLFluxProGenerateResponse, + ), + request=BFLFluxDepthImageRequest( + prompt=prompt, + prompt_upsampling=prompt_upsampling, + steps=steps, + guidance=guidance, + seed=seed, + control_image=control_image, + preprocessed_image=preprocessed_image, + ), + auth_kwargs=kwargs, + ) + output_image, img_url = handle_bfl_synchronous_operation(operation, node_id=unique_id) + return (output_image, img_url) + + +# A dictionary that contains all nodes you want to export with their names +# NOTE: names should be globally unique +NODE_CLASS_MAPPINGS = { + "FluxProUltraImageNode": FluxProUltraImageNode, + # "FluxProImageNode": FluxProImageNode, + "FluxKontextProImageNode": FluxKontextProImageNode, + "FluxKontextMaxImageNode": FluxKontextMaxImageNode, + "FluxProExpandNode": FluxProExpandNode, + "FluxProFillNode": FluxProFillNode, + "FluxProCannyNode": FluxProCannyNode, + "FluxProDepthNode": FluxProDepthNode, +} + +# A dictionary that contains the friendly/humanly readable titles for the nodes +NODE_DISPLAY_NAME_MAPPINGS = { + "FluxProUltraImageNode": "Flux 1.1 [pro] Ultra Image", + # "FluxProImageNode": "Flux 1.1 [pro] Image", + "FluxKontextProImageNode": "Flux.1 Kontext [pro] Image", + "FluxKontextMaxImageNode": "Flux.1 Kontext [max] Image", + "FluxProExpandNode": "Flux.1 Expand Image", + "FluxProFillNode": "Flux.1 Fill Image", + "FluxProCannyNode": "Flux.1 Canny Control Image", + "FluxProDepthNode": "Flux.1 Depth Control Image", +} diff --git a/nodes/prompt_id_generator.py b/nodes/prompt_id_generator.py new file mode 100644 index 0000000..95972ed --- /dev/null +++ b/nodes/prompt_id_generator.py @@ -0,0 +1,40 @@ +import uuid + + +class TaskIdGenerate: + """TaskID生成器:用户可传入或自动生成TaskID""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": {}, + "optional": { + "custom_task_id": ("STRING", {"default": "", "placeholder": "留空则自动生成"}), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("task_id",) + FUNCTION = "generate_task_id" + OUTPUT_NODE = False + CATEGORY = "utils" + + def generate_task_id(self, custom_task_id=""): + if custom_task_id and custom_task_id.strip(): + # 用户输入了自定义ID + task_id = custom_task_id.strip() + print(f"📝 使用自定义TaskID: {task_id}") + else: + # 自动生成UUID + task_id = str(uuid.uuid4()) + print(f"🎲 自动生成TaskID: {task_id}") + + return (task_id,) + + +NODE_CLASS_MAPPINGS = { + "TaskIdGenerate": TaskIdGenerate +} +NODE_DISPLAY_NAME_MAPPINGS = { + "TaskIdGenerate": "TaskID生成器" +} \ No newline at end of file diff --git a/nodes/random_line.py b/nodes/random_line.py new file mode 100644 index 0000000..b8c9ec8 --- /dev/null +++ b/nodes/random_line.py @@ -0,0 +1,77 @@ +import random +import time + + +class RandomLineSelector: + """ + ComfyUI自定义节点:随机行选择器 + 从输入的多行文本中随机选择一行输出 + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("STRING", { + "multiline": True, + "default": """ +保持人物样貌和衣服不变,改变人物的光影效果。更换背景: 图像呈现出一个温馨的维多利亚复古风格房间,墙面被多样化的艺术画框与照片装饰,展现出浓郁的怀旧氛围。中心的金框全身镜是视觉焦点,上面缠绕着精致花卉装饰,增强了空间的艺术感。左侧靠墙摆放花瓶与烛台,白色地板映衬着跳动的暖黄色光线,营造出柔和而梦幻的氛围。右侧床铺整洁柔软,与一旁的小抽屉柜和台灯形成和谐的组合。整体画面色调以暖黄色为主,搭配田园风格装饰元素,构图均衡,注重细节,体现了舒适浪漫的生活场景。人物重新打光融合到场景中。 +保持人物形象和衣服不变,改变人物的光影效果。更换背景: 衣帽间的开放式悬挂区搭配浅色木地板,衣杆上挂满中性色长裙,旁边搁板整齐叠放针织毛衣与围巾。 +保持人物形象和衣服不变,改变人物的光影效果。更换背景: 白色衣柜门打开后,分层挂满长裙和连体裤,底部抽屉收纳着色彩鲜艳的运动服与家居裤。 +保持人物形象和衣服不变,改变人物的光影效果。更换背景: 玻璃门的衣柜设计体现精致生活,内部悬挂精致衬衫和镂空裙摆,饰品抽屉随时为配件搭配提供轻便选择。 +""" + }), + "seed": ("INT", { + "default": 0, + "min": -1, + "max": 0xffffffffffffffff + }), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("selected_line",) + FUNCTION = "select_random_line" + CATEGORY = "text" + + def select_random_line(self, text, seed): + """ + 从输入文本中随机选择一行 + + Args: + text (str): 输入的多行文本 + seed (int): 随机种子 + + Returns: + tuple: 包含选中行的元组 + """ + # 设置随机种子(-1表示使用当前时间戳) + if seed == -1: + random.seed(int(time.time() * 1000000)) # 使用微秒级时间戳 + else: + random.seed(seed) + + # 过滤掉空行 + non_empty_lines = [line.strip() for line in text.split('\n') if line.strip()] + + # 如果没有非空行,返回空字符串 + if not non_empty_lines: + return ("",) + + # 随机选择一行 + selected_line = random.choice(non_empty_lines) + + return (selected_line,) + + +# ComfyUI节点映射 +NODE_CLASS_MAPPINGS = { + "RandomLineSelector": RandomLineSelector +} + +# 节点显示名称映射 +NODE_DISPLAY_NAME_MAPPINGS = { + "RandomLineSelector": "随机选择一行内容" +} +# https://bowongai--dev-ui.modal.run/ +# https://bowongai--dev-ui.modal.run diff --git a/nodes/webhook_forward.py b/nodes/webhook_forward.py new file mode 100644 index 0000000..afcaca8 --- /dev/null +++ b/nodes/webhook_forward.py @@ -0,0 +1,187 @@ +# -*- coding:utf-8 -*- +""" +File webhook_forward.py +Author silence +Date 2025/6/13 13:42 +""" +import base64 +import io +import os +import random +import string +import time + +import numpy as np +import requests +from PIL import Image + + +class PromptIDGenerator: + """PromptID生成器:用户可传入或自动生成PromptID""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": {}, + "optional": { + "custom_prompt_id": ("STRING", {"default": "", "placeholder": "留空则自动生成"}), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("prompt_id",) + FUNCTION = "generate_prompt_id" + OUTPUT_NODE = False + CATEGORY = "utils" + + def generate_prompt_id(self, custom_prompt_id=""): + if custom_prompt_id and custom_prompt_id.strip(): + # 用户输入了自定义ID + prompt_id = custom_prompt_id.strip() + print(f"📝 使用自定义PromptID: {prompt_id}") + else: + # 自动生成ID:时间戳 + 随机字符 + timestamp = str(int(time.time())) + random_str = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6)) + prompt_id = f"prompt_{timestamp}_{random_str}" + print(f"🎲 自动生成PromptID: {prompt_id}") + + return (prompt_id,) + + +class PlugAndPlayWebhook: + """即插即用Webhook节点:连上线就能转发数据""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "webhook_url": ("STRING", {"default": "http://127.0.0.1:8010/handler/webhook", + "placeholder": "https://your-api.com/webhook"}), + "image_url": ("STRING", {"default": "", + "placeholder": "图片的url"}), + }, + "optional": { + "prompt_id": ("STRING", {"default": ""}), + }, + "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO", "unique_id": "UNIQUE_ID"}, + } + + RETURN_TYPES = () + FUNCTION = "send" + OUTPUT_NODE = True + CATEGORY = "utils" + + def send(self, webhook_url, image_url, prompt_id="", prompt=None, extra_pnginfo=None, unique_id=None): + if not webhook_url: + raise ValueError("❌ 请填写Webhook URL!") + + # 使用传入的prompt_id,如果没有则用unique_id + final_prompt_id = prompt_id or unique_id or "unknown" + + # 准备发送的数据 + data = { + "img_base64": image_url, + "format": "png", + "image_url": image_url, + "prompt_id": final_prompt_id, + "timestamp": time.time() + } + + # 发送Webhook + try: + response = requests.post(webhook_url, json=data) + response.raise_for_status() + print(f'发送的数据:{data}') + except Exception as e: + print(f"❌ 发送失败: {str(e)}") + + # 终端节点,无需返回 + return () + + +class SaveImageWithOutput: + """保存图片并输出的节点:既保存图片又能继续传递数据""" + + def __init__(self): + self.output_dir = "output" + self.type = "output" + self.prefix_append = "" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + "filename_prefix": ("STRING", {"default": "ComfyUI"}), + }, + } + + RETURN_TYPES = ("IMAGE", "STRING") + RETURN_NAMES = ("images", "file_path") + FUNCTION = "save_image" + OUTPUT_NODE = False + CATEGORY = "image" + + def save_image(self, images, filename_prefix="ComfyUI"): + filename_prefix += self.prefix_append + full_output_folder, filename, counter, subfolder, filename_prefix = self.get_save_image_path(filename_prefix, + self.output_dir) + + file_paths = [] + + for i, image in enumerate(images): + # 转换图片数据 + img_array = 255. * image.cpu().numpy() + img = Image.fromarray(np.clip(img_array, 0, 255).astype(np.uint8)) + + # 生成文件名 + file = f"{filename_prefix}_{counter + i:05}_.png" + file_path = os.path.join(full_output_folder, file) + + # 保存图片 + img.save(file_path, compress_level=4) + file_paths.append(file_path) + + print(f"✅ 图片已保存到: {file_paths[0] if file_paths else '未知路径'}") + + # 直接返回元组:(原始图片数据, 第一个文件路径) + return (images, file_paths[0] if file_paths else "") + + def get_save_image_path(self, filename_prefix, output_dir): + def map_filename(filename): + prefix_len = len(os.path.basename(filename_prefix)) + prefix = filename[:prefix_len + 1] + try: + digits = int(filename[prefix_len + 1:].split('_')[0]) + except: + digits = 0 + return (digits, prefix) + + subfolder = "" + full_output_folder = os.path.join(output_dir, subfolder) + + if os.path.commonpath((output_dir, os.path.abspath(full_output_folder))) != output_dir: + print("Saving image outside the output folder is not allowed.") + return {} + + try: + counter = max(filter(lambda a: a[1][:-1] == filename_prefix and a[1][-1] == "_", + map(map_filename, os.listdir(full_output_folder))))[0] + 1 + except ValueError: + counter = 1 + except FileNotFoundError: + os.makedirs(full_output_folder, exist_ok=True) + counter = 1 + + return full_output_folder, filename_prefix, counter, subfolder, filename_prefix + + +NODE_CLASS_MAPPINGS = { + "PlugAndPlayWebhook": PlugAndPlayWebhook, + "SaveImageWithOutput": SaveImageWithOutput +} +NODE_DISPLAY_NAME_MAPPINGS = { + "PlugAndPlayWebhook": "Webhook转发器", + "SaveImageWithOutput": "保存图片(带输出)" +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5bec768 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "modal_comfyui" +version = "1.0.0" +requires-python = ">=3.10" +dependencies = [ + "fastapi[standard]==0.115.4", + "cos-python-sdk-v5", + "sqlalchemy", + "ultralytics", + "tencentcloud-sdk-python", + "pymysql", + "Pillow", + "ffmpy", + "opencv-python", + "av", + "imageio", + "loguru", + "openai-whisper", + "sentry-sdk", + "pydantic", + "pydantic_settings", + "conformer==0.3.2", + "einops>0.6.1" +] \ No newline at end of file diff --git a/pyproject_comfyui.toml b/pyproject_comfyui.toml new file mode 100644 index 0000000..5bec768 --- /dev/null +++ b/pyproject_comfyui.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "modal_comfyui" +version = "1.0.0" +requires-python = ">=3.10" +dependencies = [ + "fastapi[standard]==0.115.4", + "cos-python-sdk-v5", + "sqlalchemy", + "ultralytics", + "tencentcloud-sdk-python", + "pymysql", + "Pillow", + "ffmpy", + "opencv-python", + "av", + "imageio", + "loguru", + "openai-whisper", + "sentry-sdk", + "pydantic", + "pydantic_settings", + "conformer==0.3.2", + "einops>0.6.1" +] \ No newline at end of file