From e4289a2d96c3efd767e7066efe4264c4bcdbf63d Mon Sep 17 00:00:00 2001 From: yolain Date: Sun, 28 Jan 2024 00:49:17 +0800 Subject: [PATCH] Upgrade to v1.0.4 --- ComfyUI-Easy-Use.json | 145 +++++++++- README.en.md | 8 + README.md | 20 +- __init__.py | 3 +- py/easyNodes.py | 119 +++++---- py/image.py | 221 ++++++++++++++++ py/logic.py | 424 ++++++++++++++++++++++++++++++ web/js/easy/easy.js | 4 +- web/js/easy/easyDynamicWidgets.js | 80 +++++- web/js/easy/easyQuick.js | 5 +- web/js/getset.js | 8 +- 11 files changed, 969 insertions(+), 68 deletions(-) create mode 100644 py/logic.py diff --git a/ComfyUI-Easy-Use.json b/ComfyUI-Easy-Use.json index bc150b2..6158f7f 100644 --- a/ComfyUI-Easy-Use.json +++ b/ComfyUI-Easy-Use.json @@ -127,6 +127,7 @@ "inputs": { "optional_lora_stack": "Lora堆(可选)", "model_override": "模型(可选)", + "vae_override": "VAE(可选)", "clip_override": "CLIP(可选)" }, "outputs": { @@ -265,7 +266,9 @@ "widgets": { "control_net_name": "ControlNet", "strength": "强度", - "start_percent": "开始时间" + "start_percent": "开始时间", + "end_percent": "结束时间", + "scale_soft_weights": "缩放柔和权重" }, "inputs": { "pipe": "节点束", @@ -1112,6 +1115,45 @@ "image": "图像" } }, + "easy imageScaleDown": { + "title": "图像缩小", + "inputs": { + "images": "图像" + }, + "widgets": { + "width": "宽度", + "height": "高度", + "crop": "裁切" + }, + "outputs": { + "IMAGE": "图像" + } + }, + "easy imageScaleDownBy": { + "title": "图像缩小(按比例)", + "inputs": { + "images": "图像" + }, + "widgets": { + "scale_by": "缩小比例" + }, + "outputs": { + "IMAGE": "图像" + } + }, + "easy imageScaleDownToSize": { + "title": "图像缩小(按边)", + "inputs": { + "images": "图像" + }, + "widgets": { + "size": "尺寸", + "mode": "模式" + }, + "outputs": { + "IMAGE": "图像" + } + }, "easy imagePixelPerfect": { "title": "图像完美像素", "widgets": { @@ -1124,7 +1166,28 @@ "image": "图像" }, "outputs": { - "resolution": "分辨率(整数)" + "resolution": "分辨率(整数)" + } + }, + "easy imageSave": { + "title": "图像保存", + "inputs":{ + "images": "图像" + }, + "widgets": { + "filename_prefix": "图片名前缀" + } + }, + "easy joinImageBatch": { + "title": "合并图像批次", + "inputs": { + "images": "图像" + }, + "widgets": { + "mode": "合并模式" + }, + "outputs": { + "image": "图像" } }, "easy poseEditor": { @@ -1143,5 +1206,83 @@ "inputs": { "pipe": "节点束" } + }, + "easy string": { + "title": "字符串", + "widgets": { + "value": "值" + } + }, + "easy int": { + "title": "整数", + "widgets": { + "value": "值" + } + }, + "easy rangeInt": { + "title": "整数(范围)", + "widgets": { + "range_mode": "范围模式", + "start": "开始", + "stop": "终止", + "step": "步进", + "end_mode": "结束模式", + "num_steps": "步进数量" + } + }, + "easy float": { + "title": "浮点数", + "widgets": { + "value": "值" + } + }, + "easy rangeFloat": { + "title": "浮点数(范围)", + "widgets": { + "range_mode": "范围模式", + "start": "开始", + "stop": "终止", + "step": "步进", + "end_mode": "结束模式", + "num_steps": "步进数量" + } + }, + "easy boolean": { + "title": "布尔", + "widgets": { + "value": "值" + } + }, + "easy compare": { + "title": "比较", + "widgets": { + "comparison": "比较方式" + } + }, + "easy if": { + "title": "If判断", + "inputs": { + "any": "输入任何" + } + }, + "easy xyAny": { + "widgets": { + "direction": "方向" + } + }, + "easy convertAnything": { + "title": "转换任何", + "inputs": { + "anything": "输入任何" + }, + "widgets": { + "output_type": "输出类型" + } + }, + "easy showAnything": { + "title": "展示任何", + "inputs": { + "anything": "输入任何" + } } } \ No newline at end of file diff --git a/README.en.md b/README.en.md index 0c2cd44..ffad2d6 100644 --- a/README.en.md +++ b/README.en.md @@ -29,6 +29,14 @@ After installing the node package, the UI interface will be automatically switch ## Changelog +**v1.0.4(2024-01-28)** + +- Added **Logic** nodes +- Added `easy imageSave` - Image saving node with date conversion and aspect and height formatting +- Added `easy joinImageBatch` +- Fixed `easy XYInputs: ControlNet` Error +- Fixed `easy loraStack` error when **toggle** is disabled + **2024-01-22** - Fixed `easy XYInputs: Sampler/Scheduler` Error diff --git a/README.md b/README.md index 319f876..8f4c9a3 100644 --- a/README.md +++ b/README.md @@ -37,17 +37,27 @@ ## 更新日志 +**v1.0.4(2024-01-28)** + +- 增加 **Logic** 逻辑类节点 +- 增加 `easy imageSave` - 带日期转换和宽高格式化的图像保存节点 +- 增加 `easy joinImageBatch` - 合并图像批次 +- 修复 `easy XYInputs: ControlNet` 报错 +- 修复 `easy loraStack` **toggle** 为 disabled 时报错 + **2024-01-22** - 修复 `easy XYInputs: Sampler/Scheduler` 报错 - 修复 右侧菜单 点击按钮时老是跑位的问题 - 调整UI主题,分为官方默认背景和深黑色背景两套样式,可在设置里调色板里切换(调整原因:深黑色背景对亮度较暗的用户和UP主不太友好) -**2024-01-21** +
+2024-01-21> - 修改 styles 路径以兼容其他环境 - 修复 `easy comfyLoader` 读取错误 - 修复 xyPlot 在连接 zero123 时报错 +
**v1.0.3(2024-01-19)** @@ -215,6 +225,7 @@ | dynamicThresholdingFull | [sd-dynamic-thresholding](https://github.com/mcmonkeyprojects/sd-dynamic-thresholding) | DynamicThresholdingFull | | easy imageInsetCrop | [rgthree-comfy](https://github.com/rgthree/rgthree-comfy) | ImageInsetCrop | | easy poseEditor | [ComfyUI_Custom_Nodes_AlekPet](https://github.com/AlekPet/ComfyUI_Custom_Nodes_AlekPet) | poseNode | +| easy if | [ComfyUI-Logic](https://github.com/theUpsider/ComfyUI-Logic) | IfExecute | ## 示例 @@ -240,9 +251,10 @@ [tinyterraNodes](https://github.com/TinyTerra/ComfyUI_tinyterraNodes) - 管道节点(节点束)让用户减少了不必要的连接 -[ComfyUI-extensions](https://github.com/diffus3/ComfyUI-extensions) - diffus3的获取与设置点让用户可以分离工作流构成 - +[ComfyUI-extensions](https://github.com/diffus3/ComfyUI-extensions) - diffus3的获取与设置点让用户可以分离工作流构成 [ComfyUI-Impact-Pack](https://github.com/ltdrdata/ComfyUI-Impact-Pack) - 常规整合包1 -[ComfyUI-Inspire-Pack](https://github.com/ltdrdata/ComfyUI-Inspire-Pack) - 常规整合包2 \ No newline at end of file +[ComfyUI-Inspire-Pack](https://github.com/ltdrdata/ComfyUI-Inspire-Pack) - 常规整合包2 + +[ComfyUI-Logic](https://github.com/theUpsider/ComfyUI-Logic) - ComfyUI逻辑运算 \ No newline at end of file diff --git a/__init__.py b/__init__.py index dc97992..085ba97 100644 --- a/__init__.py +++ b/__init__.py @@ -10,7 +10,8 @@ node_list = [ "api", "easyNodes", "image", - "lllite" + "lllite", + "logic" ] NODE_CLASS_MAPPINGS = {} diff --git a/py/easyNodes.py b/py/easyNodes.py index f87ea4d..fa76076 100644 --- a/py/easyNodes.py +++ b/py/easyNodes.py @@ -18,11 +18,6 @@ import latent_preview import comfy.model_base import comfy.model_management from comfy.sd import CLIP, VAE -import comfy.clip_vision -from comfy.clip_vision import ClipVisionModel -import comfy.clip_model -import comfy.sd1_clip -from comfy.sd1_clip import escape_important, token_weights, unescape_important from pathlib import Path from urllib.request import urlopen from collections import defaultdict @@ -31,9 +26,6 @@ from PIL import Image, ImageDraw, ImageFont from comfy.model_patcher import ModelPatcher from comfy_extras.chainner_models import model_loading from typing import Dict, List, Optional, Tuple, Union, Any -from transformers import CLIPImageProcessor -from transformers.image_utils import PILImageResampling -from itertools import zip_longest from .adv_encode import advanced_encode, advanced_encode_XL from server import PromptServer @@ -496,9 +488,7 @@ class easyXYPlot: value_label = f"{value_type}: {value}" if "ControlNet" in value_type: - if "," in value: - line = value.split(',') - value_label = f"{value_type}: {line[2]}" + value_label = f"ControlNet {index + 1}" if value_type in ["ModelMergeBlocks"]: if ":" in value: @@ -757,6 +747,9 @@ class easyXYPlot: w_max=1.0, apply_to_pooled="enable") positive = [[positive, {"pooled_output": positive_pooled}]] + if "positive_cond" in plot_image_vars: + positive = positive + plot_image_vars["positive_cond"] + if "Negative" in self.x_type or "Negative" in self.y_type: if self.x_type == 'Negative Prompt S/R' or self.y_type == 'Negative Prompt S/R': negative = x_value if self.x_type == "Negative Prompt S/R" else y_value @@ -779,6 +772,8 @@ class easyXYPlot: w_max=1.0, apply_to_pooled="enable") negative = [[negative, {"pooled_output": negative_pooled}]] + if "negative_cond" in plot_image_vars: + positive = positive + plot_image_vars["negative_cond"] # ControlNet if "ControlNet" in self.x_type or "ControlNet" in self.y_type: @@ -794,20 +789,15 @@ class easyXYPlot: } cnet = plot_image_vars["cnet"] if "cnet" in plot_image_vars else None if cnet: - strength, start_percent, end_percent = x_value.split(',') if "ControlNet" in self.x_type else y_value.split(',') - strength = float(strength) - start_percent = float(start_percent) - end_percent = float(end_percent) - for index, item in enumerate(cnet): - control_net_names = item[0] + index = x_value if "ControlNet" in self.x_type else y_value + controlnet = cnet[index] + for index, item in enumerate(controlnet): + control_net_name = item[0] image = item[1] - for idx, control_net_name in enumerate(control_net_names): - # print(control_net_name) - _pipe, = controlnetAdvanced().controlnetApply(_pipe, image, control_net_name, None, strength, start_percent, - end_percent) - - positive = _pipe['positive'] - negative = _pipe['negative'] + strength = item[2] + start_percent = item[3] + end_percent = item[4] + _pipe, positive, negative = controlnetAdvanced().controlnetApply(_pipe, image, control_net_name, None, strength, start_percent, end_percent, 1) del _pipe @@ -1382,7 +1372,7 @@ class stylesPromptSelector: CATEGORY = 'EasyUse/Prompt' FUNCTION = 'run' - OUTPUT_MODE = True + OUTPUT_NODE = True def replace_repeat(self, prompt): @@ -1727,7 +1717,7 @@ class latentCompositeMaskedWithCond: RETURN_TYPES = ("PIPE_LINE", "LATENT", "CONDITIONING") RETURN_NAMES = ("pipe", "latent", "conditioning",) FUNCTION = "run" - OUTPUT_MODE = True + OUTPUT_NODE = True CATEGORY = "EasyUse/Latent" @@ -2363,7 +2353,7 @@ class loraStackLoader: def stack(self, toggle, mode, num_loras, lora_stack=None, **kwargs): if (toggle in [False, None, "False"]) or not kwargs: - return None + return (None,) loras = [] @@ -2387,12 +2377,45 @@ class loraStackLoader: loras.append((lora_name, model_strength, clip_strength)) return (loras,) +class controlnetNameStack: + + def get_file_list(filenames): + return [file for file in filenames if file != "put_models_here.txt" and "lllite" not in file] + + controlnets = ["None"] + get_file_list(folder_paths.get_filename_list("controlnet")) + + @classmethod + def INPUT_TYPES(s): + return {"required": {}, + "optional": { + "switch_1": (["Off", "On"],), + "controlnet_1": (s.controlnets,), + "controlnet_strength_1": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}), + "start_percent_1": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.001}), + "end_percent_1": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.001}), + "switch_2": (["Off", "On"],), + "controlnet_2": (s.controlnets,), + "controlnet_strength_2": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}), + "start_percent_2": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.001}), + "end_percent_2": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.001}), + "switch_3": (["Off", "On"],), + "controlnet_3": (s.controlnets,), + "controlnet_strength_3": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}), + "start_percent_3": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.001}), + "end_percent_3": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.001}), + "image_1": ("IMAGE",), + "image_2": ("IMAGE",), + "image_3": ("IMAGE",), + "controlnet_stack": ("CONTROL_NET_STACK",) + }, + } # controlnet class controlnetSimple: @classmethod def INPUT_TYPES(s): def get_file_list(filenames): return [file for file in filenames if file != "put_models_here.txt" and "lllite" not in file] + return { "required": { "pipe": ("PIPE_LINE",), @@ -2516,7 +2539,7 @@ class controlnetAdvanced: CATEGORY = "EasyUse/Loaders" - def controlnetApply(self, pipe, image, control_net_name, control_net=None, strength=1, start_percent=0, end_percent=1, scale_soft_weights=0): + def controlnetApply(self, pipe, image, control_net_name, control_net=None, strength=1, start_percent=0, end_percent=1, scale_soft_weights=1): if control_net is None: if scale_soft_weights < 1: if "ScaledSoftControlNetWeights" in ALL_NODE_CLASS_MAPPINGS: @@ -4615,9 +4638,8 @@ class pipeXYPlotAdvanced: x_values = [] cnet = [] for index, value in enumerate(x_value): - cnet_stack, image, strength, start_percent, end_percent = value - cnet.append((cnet_stack, image,), ) - x_values.append((",".join((str(strength), str(start_percent), str(end_percent),))),) + cnet.append(value) + x_values.append(str(index)) x_values = "; ".join(x_values) new_pipe['loader_settings'] = { **pipe['loader_settings'], @@ -4629,9 +4651,8 @@ class pipeXYPlotAdvanced: y_values = [] cnet = [] for index, value in enumerate(y_value): - cnet_stack, image, strength, start_percent, end_percent = value - cnet.append((cnet_stack, image,),) - y_values.append((",".join((str(strength), str(start_percent), str(end_percent),))),) + cnet.append(value) + y_values.append(str(index)) y_values = "; ".join(y_values) new_pipe['loader_settings'] = { **pipe['loader_settings'], @@ -5021,49 +5042,43 @@ class XYplot_Control_Net: axis, = None, - # If cnet_stack is provided, extend each inner array with its content - # if cnet_stack: - # for inner_list in values: - # inner_list.extend(cnet_stack) - cnet_stack = [control_net_name] - values = [] if target_parameter == "strength": axis = "advanced: ControlNetStrength" - values.append((cnet_stack, image, first_strength, start_percent, end_percent)) + values.append([(control_net_name, image, first_strength, start_percent, end_percent)]) strength_increment = (last_strength - first_strength) / (batch_count - 1) if batch_count > 1 else 0 for i in range(1, batch_count - 1): - values.append((cnet_stack, image, first_strength + i * strength_increment, start_percent, - end_percent)) + values.append([(control_net_name, image, first_strength + i * strength_increment, start_percent, + end_percent)]) if batch_count > 1: - values.append((cnet_stack, image, last_strength, start_percent, end_percent)) + values.append([(control_net_name, image, last_strength, start_percent, end_percent)]) elif target_parameter == "start_percent": axis = "advanced: ControlNetStart%" percent_increment = (last_start_percent - first_start_percent) / (batch_count - 1) if batch_count > 1 else 0 - values.append((cnet_stack, image, strength, first_start_percent, end_percent)) + values.append([(control_net_name, image, strength, first_start_percent, end_percent)]) for i in range(1, batch_count - 1): - values.append((cnet_stack, image, strength, first_start_percent + i * percent_increment, - end_percent)) + values.append([(control_net_name, image, strength, first_start_percent + i * percent_increment, + end_percent)]) # Always add the last start_percent if batch_count is more than 1. if batch_count > 1: - values.append((cnet_stack, image, strength, last_start_percent, end_percent)) + values.append((control_net_name, image, strength, last_start_percent, end_percent)) elif target_parameter == "end_percent": axis = "advanced: ControlNetEnd%" percent_increment = (last_end_percent - first_end_percent) / (batch_count - 1) if batch_count > 1 else 0 - values.append((cnet_stack, image, strength, start_percent, first_end_percent)) + values.append([(control_net_name, image, image, strength, start_percent, first_end_percent)]) for i in range(1, batch_count - 1): - values.append((cnet_stack, image, strength, start_percent, - first_end_percent + i * percent_increment)) + values.append([(control_net_name, image, strength, start_percent, + first_end_percent + i * percent_increment)]) if batch_count > 1: - values.append((cnet_stack, image, strength, start_percent, last_end_percent)) + values.append([(control_net_name, image, strength, start_percent, last_end_percent)]) return ({"axis": axis, "values": values},) diff --git a/py/image.py b/py/image.py index 66b15c4..855ae5c 100644 --- a/py/image.py +++ b/py/image.py @@ -1,5 +1,9 @@ from PIL import Image +from PIL.PngImagePlugin import PngInfo from enum import Enum +import datetime +import re +import json import os import hashlib import folder_paths @@ -218,6 +222,109 @@ class imageSizeByLongerSide: result = (0,) return {"ui": {"text": str(result[0])}, "result": result} +# 图像缩放 +class imageScaleDown: + crop_methods = ["disabled", "center"] + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "images": ("IMAGE",), + "width": ( + "INT", + {"default": 512, "min": 1, "max": MAX_RESOLUTION, "step": 1}, + ), + "height": ( + "INT", + {"default": 512, "min": 1, "max": MAX_RESOLUTION, "step": 1}, + ), + "crop": (s.crop_methods,), + } + } + + RETURN_TYPES = ("IMAGE",) + CATEGORY = "EasyUse/Image" + FUNCTION = "image_scale_down" + + def image_scale_down(self, images, width, height, crop): + if crop == "center": + old_width = images.shape[2] + old_height = images.shape[1] + old_aspect = old_width / old_height + new_aspect = width / height + x = 0 + y = 0 + if old_aspect > new_aspect: + x = round((old_width - old_width * (new_aspect / old_aspect)) / 2) + elif old_aspect < new_aspect: + y = round((old_height - old_height * (old_aspect / new_aspect)) / 2) + s = images[:, y: old_height - y, x: old_width - x, :] + else: + s = images + + results = [] + for image in s: + img = tensor2pil(image).convert("RGB") + img = img.resize((width, height), Image.LANCZOS) + results.append(pil2tensor(img)) + + return (torch.cat(results, dim=0),) + +# 图像缩放比例 +class imageScaleDownBy(imageScaleDown): + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "images": ("IMAGE",), + "scale_by": ( + "FLOAT", + {"default": 0.5, "min": 0.01, "max": 1.0, "step": 0.01}, + ), + } + } + + RETURN_TYPES = ("IMAGE",) + CATEGORY = "EasyUse/Image" + FUNCTION = "image_scale_down_by" + + def image_scale_down_by(self, images, scale_by): + width = images.shape[2] + height = images.shape[1] + new_width = int(width * scale_by) + new_height = int(height * scale_by) + return self.image_scale_down(images, new_width, new_height, "center") + +# 图像缩放尺寸 +class imageScaleDownToSize(imageScaleDownBy): + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "images": ("IMAGE",), + "size": ("INT", {"default": 512, "min": 1, "max": MAX_RESOLUTION, "step": 1}), + "mode": ("BOOLEAN", {"default": True, "label_on": "max", "label_off": "min"}), + } + } + + RETURN_TYPES = ("IMAGE",) + CATEGORY = "EasyUse/Image" + FUNCTION = "image_scale_down_to_size" + + def image_scale_down_to_size(self, images, size, mode): + width = images.shape[2] + height = images.shape[1] + + if mode: + scale_by = size / max(width, height) + else: + scale_by = size / min(width, height) + + scale_by = min(scale_by, 1.0) + return self.image_scale_down_by(images, scale_by) + + # 图像完美像素 class imagePixelPerfect: @classmethod @@ -296,6 +403,110 @@ class imageToMask: image = pil2tensor(image) return (image.squeeze().mean(2),) +# 图像保存 (简易) +from comfy.cli_args import args +class imageSaveSimple: + + def __init__(self): + self.output_dir = folder_paths.get_output_directory() + self.type = "output" + + @classmethod + def INPUT_TYPES(s): + return {"required": + {"images": ("IMAGE",), + "filename_prefix": ("STRING", {"default": "ComfyUI"}),}, + "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, + } + + RETURN_TYPES = () + FUNCTION = "save" + OUTPUT_NODE = True + CATEGORY = "EasyUse/Image" + + @staticmethod + def _format_date(text: str, date: datetime.datetime) -> str: + """Format the date according to specific patterns.""" + date_formats = { + 'd': lambda d: d.day, + 'dd': lambda d: '{:02d}'.format(d.day), + 'M': lambda d: d.month, + 'MM': lambda d: '{:02d}'.format(d.month), + 'h': lambda d: d.hour, + 'hh': lambda d: '{:02d}'.format(d.hour), + 'm': lambda d: d.minute, + 'mm': lambda d: '{:02d}'.format(d.minute), + 's': lambda d: d.second, + 'ss': lambda d: '{:02d}'.format(d.second), + 'y': lambda d: d.year, + 'yy': lambda d: str(d.year)[2:], + 'yyy': lambda d: str(d.year)[1:], + 'yyyy': lambda d: d.year, + } + + # We need to sort the keys in reverse order to ensure we match the longest formats first + for format_str in sorted(date_formats.keys(), key=len, reverse=True): + if format_str in text: + text = text.replace(format_str, str(date_formats[format_str](date))) + return text + + def save(self, images, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): + + filename_prefix = re.sub(r'%date:(.*?)%', lambda m: self._format_date(m.group(1), datetime.datetime.now()), + filename_prefix) + full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]) + + results = list() + for image in images: + img = Image.fromarray(np.clip(255. * image.cpu().numpy(), 0, 255).astype(np.uint8)) + filename = filename.replace("%width%", str(img.size[0])).replace("%height%", str(img.size[1])) + + metadata = None + metadata = PngInfo() + if prompt is not None: + metadata.add_text("prompt", json.dumps(prompt)) + if extra_pnginfo is not None: + for x in extra_pnginfo: + metadata.add_text(x, json.dumps(extra_pnginfo[x])) + + file = f"{filename}_{counter:05}_.png" + img.save(os.path.join(full_output_folder, file), pnginfo=metadata) + results.append({ + "filename": file, + "subfolder": subfolder, + "type": self.type + }) + counter += 1 + + return { "ui": { "images": results } } +# 图像批次合并 +class JoinImageBatch: + """Turns an image batch into one big image.""" + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "images": ("IMAGE",), + "mode": (("horizontal", "vertical"), {"default": "horizontal"}), + }, + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + FUNCTION = "join" + CATEGORY = "EasyUse/Image" + + def join(self, images, mode): + n, h, w, c = images.shape + image = None + if mode == "vertical": + # for vertical we can just reshape + image = images.reshape(1, n * h, w, c) + elif mode == "horizontal": + # for horizontal we have to swap axes + image = torch.transpose(torch.transpose(images, 1, 2).reshape(1, n * w, h, c), 1, 2) + return (image,) # 姿势编辑器 class poseEditor: @@ -345,7 +556,12 @@ NODE_CLASS_MAPPINGS = { "easy imageSizeBySide": imageSizeBySide, "easy imageSizeByLongerSide": imageSizeByLongerSide, "easy imagePixelPerfect": imagePixelPerfect, + "easy imageScaleDown": imageScaleDown, + "easy imageScaleDownBy": imageScaleDownBy, + "easy imageScaleDownToSize": imageScaleDownToSize, "easy imageToMask": imageToMask, + "easy imageSave": imageSaveSimple, + "easy joinImageBatch": JoinImageBatch, "easy poseEditor": poseEditor } @@ -355,7 +571,12 @@ NODE_DISPLAY_NAME_MAPPINGS = { "easy imageSizeBySide": "ImageSize (Side)", "easy imageSizeByLongerSide": "ImageSize (LongerSide)", "easy imagePixelPerfect": "ImagePixelPerfect", + "easy imageScaleDown": "Image Scale Down", + "easy imageScaleDownBy": "Image Scale Down By", + "easy imageScaleDownToSize": "Image Scale Down To Size", "easy imageToMask": "ImageToMask", "easy imageHSVMask": "ImageHSVMask", + "easy imageSave": "SaveImage (Simple)", + "easy joinImageBatch": "JoinImageBatch", "easy poseEditor": "PoseEditor" } \ No newline at end of file diff --git a/py/logic.py b/py/logic.py new file mode 100644 index 0000000..8e4bbd5 --- /dev/null +++ b/py/logic.py @@ -0,0 +1,424 @@ +from typing import Iterator, List, Tuple, Dict, Any, Union, Optional +from _decimal import Context, getcontext +from decimal import Decimal +import numpy as np + +def validate_list_args(args: Dict[str, List[Any]]) -> Tuple[bool, Optional[str], Optional[str]]: + """ + Checks that if there are multiple arguments, they are all the same length or 1 + :param args: + :return: Tuple (Status, mismatched_key_1, mismatched_key_2) + """ + # Only have 1 arg + if len(args) == 1: + return True, None, None + + len_to_match = None + matched_arg_name = None + for arg_name, arg in args.items(): + if arg_name == 'self': + # self is in locals() + continue + + if len(arg) != 1: + if len_to_match is None: + len_to_match = len(arg) + matched_arg_name = arg_name + elif len(arg) != len_to_match: + return False, arg_name, matched_arg_name + + return True, None, None +def error_if_mismatched_list_args(args: Dict[str, List[Any]]) -> None: + is_valid, failed_key1, failed_key2 = validate_list_args(args) + if not is_valid: + assert failed_key1 is not None + assert failed_key2 is not None + raise ValueError( + f"Mismatched list inputs received. {failed_key1}({len(args[failed_key1])}) !== {failed_key2}({len(args[failed_key2])})" + ) + +def zip_with_fill(*lists: Union[List[Any], None]) -> Iterator[Tuple[Any, ...]]: + """ + Zips lists together, but if a list has 1 element, it will be repeated for each element in the other lists. + If a list is None, None will be used for that element. + (Not intended for use with lists of different lengths) + :param lists: + :return: Iterator of tuples of length len(lists) + """ + max_len = max(len(lst) if lst is not None else 0 for lst in lists) + for i in range(max_len): + yield tuple(None if lst is None else (lst[0] if len(lst) == 1 else lst[i]) for lst in lists) + +# ---------------------------------------------------------------类型 开始----------------------------------------------------------------------# + +# 字符串 +class String: + @classmethod + def INPUT_TYPES(s): + return { + "required": {"value": ("STRING", {"default": ""})}, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("string",) + FUNCTION = "execute" + CATEGORY = "EasyUse/Logic/Type" + + def execute(self, value): + return (value,) + +# 整数 +class Int: + @classmethod + def INPUT_TYPES(s): + return { + "required": {"value": ("INT", {"default": 0})}, + } + + RETURN_TYPES = ("INT",) + RETURN_NAMES = ("int",) + FUNCTION = "execute" + CATEGORY = "EasyUse/Logic/Type" + + def execute(self, value): + return (value,) + +# 整数范围 +class RangeInt: + def __init__(self) -> None: + pass + + @classmethod + def INPUT_TYPES(s) -> Dict[str, Dict[str, Any]]: + return { + "required": { + "range_mode": (["step", "num_steps"], {"default": "step"}), + "start": ("INT", {"default": 0, "min": -4096, "max": 4096, "step": 1}), + "stop": ("INT", {"default": 0, "min": -4096, "max": 4096, "step": 1}), + "step": ("INT", {"default": 0, "min": -4096, "max": 4096, "step": 1}), + "num_steps": ("INT", {"default": 0, "min": -4096, "max": 4096, "step": 1}), + "end_mode": (["Inclusive", "Exclusive"], {"default": "Inclusive"}), + }, + } + + RETURN_TYPES = ("INT", "INT") + RETURN_NAMES = ("range", "range_sizes") + INPUT_IS_LIST = True + OUTPUT_IS_LIST = (True, True) + FUNCTION = "build_range" + + CATEGORY = "EasyUse/Logic/Type" + + def build_range( + self, range_mode, start, stop, step, num_steps, end_mode + ) -> Tuple[List[int], List[int]]: + error_if_mismatched_list_args(locals()) + + ranges = [] + range_sizes = [] + for range_mode, e_start, e_stop, e_num_steps, e_step, e_end_mode in zip_with_fill( + range_mode, start, stop, num_steps, step, end_mode + ): + if range_mode == 'step': + if e_end_mode == "Inclusive": + e_stop += 1 + vals = list(range(e_start, e_stop, e_step)) + ranges.extend(vals) + range_sizes.append(len(vals)) + elif range_mode == 'num_steps': + direction = 1 if e_stop > e_start else -1 + if e_end_mode == "Exclusive": + e_stop -= direction + vals = (np.rint(np.linspace(e_start, e_stop, e_num_steps)).astype(int).tolist()) + ranges.extend(vals) + range_sizes.append(len(vals)) + return ranges, range_sizes + + + +# 浮点数 +class Float: + @classmethod + def INPUT_TYPES(s): + return { + "required": {"value": ("FLOAT", {"default": 0, "step": 0.01})}, + } + + RETURN_TYPES = ("FLOAT",) + RETURN_NAMES = ("float",) + FUNCTION = "execute" + CATEGORY = "EasyUse/Logic/Type" + + def execute(self, value): + return (value,) + + +# 浮点数范围 +class RangeFloat: + def __init__(self) -> None: + pass + + @classmethod + def INPUT_TYPES(s) -> Dict[str, Dict[str, Any]]: + return { + "required": { + "range_mode": (["step", "num_steps"], {"default": "step"}), + "start": ("FLOAT", {"default": 0, "min": -4096, "max": 4096, "step": 0.1}), + "stop": ("FLOAT", {"default": 0, "min": -4096, "max": 4096, "step": 0.1}), + "step": ("FLOAT", {"default": 0, "min": -4096, "max": 4096, "step": 0.1}), + "num_steps": ("INT", {"default": 0, "min": -4096, "max": 4096, "step": 1}), + "end_mode": (["Inclusive", "Exclusive"], {"default": "Inclusive"}), + }, + } + + RETURN_TYPES = ("FLOAT", "INT") + RETURN_NAMES = ("range", "range_sizes") + INPUT_IS_LIST = True + OUTPUT_IS_LIST = (True, True) + FUNCTION = "build_range" + + CATEGORY = "EasyUse/Logic/Type" + + @staticmethod + def _decimal_range( + range_mode: String, start: Decimal, stop: Decimal, step: Decimal, num_steps: Int, inclusive: bool + ) -> Iterator[float]: + if range_mode == 'step': + ret_val = start + if inclusive: + stop = stop + step + direction = 1 if step > 0 else -1 + while (ret_val - stop) * direction < 0: + yield float(ret_val) + ret_val += step + elif range_mode == 'num_steps': + step = (stop - start) / (num_steps - 1) + direction = 1 if step > 0 else -1 + + ret_val = start + for _ in range(num_steps): + if (ret_val - stop) * direction > 0: # Ensure we don't exceed the 'stop' value + break + yield float(ret_val) + ret_val += step + + def build_range( + self, + range_mode, + start, + stop, + step, + num_steps, + end_mode, + ) -> Tuple[List[float], List[int]]: + error_if_mismatched_list_args(locals()) + getcontext().prec = 12 + + start = [Decimal(s) for s in start] + stop = [Decimal(s) for s in stop] + step = [Decimal(s) for s in step] + + ranges = [] + range_sizes = [] + for range_mode, e_start, e_stop, e_step, e_num_steps, e_end_mode in zip_with_fill( + range_mode, start, stop, step, num_steps, end_mode + ): + vals = list( + self._decimal_range(range_mode, e_start, e_stop, e_step, e_num_steps, e_end_mode == 'Inclusive') + ) + ranges.extend(vals) + range_sizes.append(len(vals)) + + return ranges, range_sizes + + +# 布尔 +class Boolean: + @classmethod + def INPUT_TYPES(s): + return { + "required": {"value": ("BOOLEAN", {"default": False})}, + } + + RETURN_TYPES = ("BOOLEAN",) + RETURN_NAMES = ("boolean",) + FUNCTION = "execute" + CATEGORY = "EasyUse/Logic/Type" + + def execute(self, value): + return (value,) + +# ---------------------------------------------------------------运算 开始----------------------------------------------------------------------# + +COMPARE_FUNCTIONS = { + "a == b": lambda a, b: a == b, + "a != b": lambda a, b: a != b, + "a < b": lambda a, b: a < b, + "a > b": lambda a, b: a > b, + "a <= b": lambda a, b: a <= b, + "a >= b": lambda a, b: a >= b, +} +class AlwaysEqualProxy(str): + def __eq__(self, _): + return True + + def __ne__(self, _): + return False + +# 比较 +class Compare: + @classmethod + def INPUT_TYPES(s): + s.compare_functions = list(COMPARE_FUNCTIONS.keys()) + return { + "required": { + "a": (AlwaysEqualProxy("*"), {"default": 0}), + "b": (AlwaysEqualProxy("*"), {"default": 0}), + "comparison": (s.compare_functions, {"default": "a == b"}), + }, + } + + RETURN_TYPES = ("BOOLEAN",) + RETURN_NAMES = ("boolean",) + FUNCTION = "compare" + CATEGORY = "EasyUse/Logic/Math" + + def compare(self, a, b, comparison): + return (COMPARE_FUNCTIONS[comparison](a, b),) + +# 判断 +class If: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "any": (AlwaysEqualProxy("*"),), + "if": (AlwaysEqualProxy("*"),), + "else": (AlwaysEqualProxy("*"),), + }, + } + + RETURN_TYPES = (AlwaysEqualProxy("*"),) + RETURN_NAMES = ("?",) + FUNCTION = "execute" + CATEGORY = "EasyUse/Logic/Math" + + def execute(self, *args, **kwargs): + return (kwargs['if'] if kwargs['any'] else kwargs['else'],) + +#xy矩阵 +class xyAny: + + @classmethod + def INPUT_TYPES(s): + + return { + "required": { + "X": (AlwaysEqualProxy("*"), {}), + "Y": (AlwaysEqualProxy("*"), {}), + "direction": (["horizontal", "vertical"], {"default": "horizontal"}) + } + } + + RETURN_TYPES = (AlwaysEqualProxy("*"), AlwaysEqualProxy("*")) + RETURN_NAMES = ("X", "Y") + INPUT_IS_LIST = True + OUTPUT_IS_LIST = (True, True) + CATEGORY = "EasyUse/Logic" + FUNCTION = "to_xy" + + def to_xy(self, X, Y, direction): + new_x = list() + new_y = list() + if direction[0] == "horizontal": + for y in Y: + for x in X: + new_x.append(x) + new_y.append(y) + else: + for x in X: + for y in Y: + new_x.append(x) + new_y.append(y) + + return (new_x, new_y) + +# 转换所有类型 +class ConvertAnything: + @classmethod + def INPUT_TYPES(s): + return {"required": { + "anything": (AlwaysEqualProxy("*"),), + "output_type": (["string", "int", "float", "boolean"], {"default": "string"}), + }} + + RETURN_TYPES = (AlwaysEqualProxy("*"),), + RETURN_NAMES = ('*',) + OUTPUT_NODE = True + FUNCTION = "convert" + CATEGORY = "EasyUse/Logic" + + def convert(self, *args, **kwargs): + print(kwargs) + anything = kwargs['anything'] + output_type = kwargs['output_type'] + params = None + if output_type == 'string': + params = str(anything) + elif output_type == 'int': + params = int(anything) + elif output_type == 'float': + params = float(anything) + elif output_type == 'boolean': + params = bool(anything) + return (params,) + +# 将所有类型的内容都转成字符串输出 +class ShowAnything: + @classmethod + def INPUT_TYPES(s): + return {"required": {}, "optional": {"anything": (AlwaysEqualProxy("*"), {}), }, + "hidden": {"unique_id": "UNIQUE_ID", "extra_pnginfo": "EXTRA_PNGINFO", + }} + + RETURN_TYPES = () + INPUT_IS_LIST = True + OUTPUT_NODE = True + FUNCTION = "log_input" + CATEGORY = "EasyUse/Logic" + + def log_input(self, unique_id=None, extra_pnginfo=None, **kwargs): + print(kwargs['anything']) + if unique_id and extra_pnginfo and "workflow" in extra_pnginfo[0]: + workflow = extra_pnginfo[0]["workflow"] + node = next((x for x in workflow["nodes"] if str(x["id"]) == unique_id[0]), None) + if node: + node["widgets_values"] = [kwargs['anything']] + return {"ui": {"text": kwargs['anything']}} + +NODE_CLASS_MAPPINGS = { + "easy string": String, + "easy int": Int, + "easy rangeInt": RangeInt, + "easy float": Float, + "easy rangeFloat": RangeFloat, + "easy boolean": Boolean, + "easy compare": Compare, + "easy if": If, + "easy xyAny": xyAny, + "easy convertAnything": ConvertAnything, + "easy showAnything": ShowAnything, +} +NODE_DISPLAY_NAME_MAPPINGS = { + "easy string": "String", + "easy int": "Int", + "easy rangeInt": "Range(Int)", + "easy float": "Float", + "easy rangeFloat": "Range(Float)", + "easy boolean": "Boolean", + "easy compare": "Compare", + "easy if": "If", + "easy xyAny": "XYAny", + "easy convertAnything": "ConvertAnything", + "easy showAnything": "ShowAnything" +} \ No newline at end of file diff --git a/web/js/easy/easy.js b/web/js/easy/easy.js index 70e1700..9bdad01 100644 --- a/web/js/easy/easy.js +++ b/web/js/easy/easy.js @@ -384,7 +384,9 @@ app.registerExtension({ e.currentTarget.parentNode.draggable = 'true'; }) text_group_title.addEventListener('mouseleave',e=>{ - isGroupMapcanMove = true + setTimeout(_=>{ + isGroupMapcanMove = true + },150) }) group_item.append(text_group_title) // 按钮组 diff --git a/web/js/easy/easyDynamicWidgets.js b/web/js/easy/easyDynamicWidgets.js index 34a1dbb..68d6e19 100644 --- a/web/js/easy/easyDynamicWidgets.js +++ b/web/js/easy/easyDynamicWidgets.js @@ -1,5 +1,7 @@ import { app } from "/scripts/app.js"; import { api } from "/scripts/api.js"; +import { ComfyWidgets } from "/scripts/widgets.js"; + let origProps = {}; @@ -149,6 +151,17 @@ function widgetLogic(node, widget) { updateNodeHeight(node) } + if (widget.name == 'range_mode'){ + if(widget.value == 'step'){ + toggleWidget(node, findWidgetByName(node, 'step'), true) + toggleWidget(node, findWidgetByName(node, 'num_steps')) + }else if(widget.value == 'num_steps'){ + toggleWidget(node, findWidgetByName(node, 'step')) + toggleWidget(node, findWidgetByName(node, 'num_steps'), true) + } + updateNodeHeight(node) + } + if (widget.name === 'toggle') { widget.type = 'toggle' widget.options = {on: 'Enabled', off: 'Disabled'} @@ -329,6 +342,8 @@ app.registerExtension({ case "easy XYInputs: Sampler/Scheduler": case "easy XYInputs: PromptSR": case "easy XYInputs: ControlNet": + case "easy rangeInt": + case "easy rangeFloat": getSetters(node) break case "easy wildcards": @@ -509,7 +524,6 @@ app.registerExtension({ accept: "text/csv", style: "display: none", onchange: async (event) => { - if (fileInput.files.length) { await uploadFile(fileInput.files[0], true); event.target.value = '' @@ -646,6 +660,68 @@ app.registerExtension({ },1) } } + + if(nodeData.name == 'easy showAnything'){ + function populate(text) { + if (this.widgets) { + const pos = this.widgets.findIndex((w) => w.name === "text"); + if (pos !== -1) { + for (let i = pos; i < this.widgets.length; i++) { + this.widgets[i].onRemove?.(); + } + this.widgets.length = pos; + } + } + + for (const list of text) { + const w = ComfyWidgets["STRING"](this, "text", ["STRING", { multiline: true }], app).widget; + w.inputEl.readOnly = true; + w.inputEl.style.opacity = 0.6; + w.value = list; + } + + requestAnimationFrame(() => { + const sz = this.computeSize(); + if (sz[0] < this.size[0]) { + sz[0] = this.size[0]; + } + if (sz[1] < this.size[1]) { + sz[1] = this.size[1]; + } + this.onResize?.(sz); + app.graph.setDirtyCanvas(true, false); + }); + } + + // When the node is executed we will be sent the input text, display this in the widget + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments); + populate.call(this, message.text); + }; + + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + onConfigure?.apply(this, arguments); + if (this.widgets_values?.length) { + populate.call(this, this.widgets_values); + } + }; + } + + if(nodeData.name == 'easy convertAnything'){ + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = async function () { + onNodeCreated ? onNodeCreated.apply(this, []) : undefined; + const type_control = this.widgets[this.widgets.findIndex((w) => w.name === "output_type")] + let _this = this + type_control.callback = _ => { + _this.outputs[0].type = (type_control.value).toUpperCase() + _this.outputs[0].name = type_control.value + } + + } + } } }); @@ -655,7 +731,7 @@ const getSetWidgets = ['rescale_after_model', 'rescale', 'image_output', 'refiner_lora1_name', 'refiner_lora2_name', 'upscale_method', 'image_output', 'add_noise', 'info', 'sampler_name', 'ckpt_B_name', 'ckpt_C_name', 'save_model', 'refiner_ckpt_name', - 'num_loras', 'mode', 'toggle', 'resolution', 'target_parameter', 'input_count', 'replace_count', "downscale_mode"] + 'num_loras', 'mode', 'toggle', 'resolution', 'target_parameter', 'input_count', 'replace_count', 'downscale_mode', 'range_mode'] function getSetters(node) { if (node.widgets) diff --git a/web/js/easy/easyQuick.js b/web/js/easy/easyQuick.js index 70e1335..f1a2112 100644 --- a/web/js/easy/easyQuick.js +++ b/web/js/easy/easyQuick.js @@ -54,15 +54,16 @@ app.registerExtension({ name: 'comfy.easyUse.quick', init() { const keybindListener = async function (event) { - const modifierPressed = event.altKey; + let modifierPressed = event.altKey; const isEnabled = getEnableNodeTemplateShortcut() if(isEnabled){ const mac_alt_nums = ['¡','™','£','¢','∞','§','¶','•','ª'] const nums = ['1','2','3','4','5','6','7','8','9'] let key = event.key if(mac_alt_nums.includes(key)){ - const idx = mac_alt_nums.findIndex(key) + const idx = mac_alt_nums.findIndex(cate=> cate == key) key = nums[idx] + modifierPressed = true } if(['1','2','3','4','5','6','7','8','9'].includes(key) && modifierPressed) { const template = loadTemplate() diff --git a/web/js/getset.js b/web/js/getset.js index fd1c7b8..5fc87a4 100644 --- a/web/js/getset.js +++ b/web/js/getset.js @@ -210,7 +210,7 @@ app.registerExtension({ this.addOutput("*", '*'); - + this.onConnectionsChange = function( slotType, //0 = output, 1 = input slot, //self-explanatory @@ -224,13 +224,13 @@ app.registerExtension({ },1) } - + this.setName = function(name) { node.widgets[0].value = name; node.onRename(); node.serialize(); } - + this.onRename = function() { const setter = this.findSetter(node.graph); @@ -256,7 +256,7 @@ app.registerExtension({ node.graph.removeLink(linkId) } }) - } + } } this.setType = function(type) {