From 36770c165851e39b57a95cd992b747c3b97cc8fa Mon Sep 17 00:00:00 2001 From: bigcat88 Date: Tue, 8 Jul 2025 13:18:27 +0300 Subject: [PATCH 1/8] migrate load and save images nodes to v3 schema (rebased) --- comfy_api/v3/io.py | 28 ++++++++++-- nodes.py | 112 +++++++++++++++++++++++++-------------------- 2 files changed, 87 insertions(+), 53 deletions(-) diff --git a/comfy_api/v3/io.py b/comfy_api/v3/io.py index c270fd625..a97a3e8a0 100644 --- a/comfy_api/v3/io.py +++ b/comfy_api/v3/io.py @@ -20,6 +20,8 @@ from comfy.clip_vision import ClipVisionModel from comfy.clip_vision import Output as ClipVisionOutput_ from comfy_api.input import VideoInput from comfy.hooks import HookGroup, HookKeyframeGroup +import folder_paths +import os # from comfy_extras.nodes_images import SVG as SVG_ # NOTE: needs to be moved before can be imported due to circular reference @@ -207,8 +209,11 @@ class WidgetInputV3(InputV3): }) def get_io_type_V1(self): + if isinstance(self, Combo.Input): + return self.as_value_type_v1() return self.widgetType if self.widgetType is not None else super().get_io_type_V1() + class OutputV3(IO_V3): def __init__(self, id: str, display_name: str=None, tooltip: str=None, is_output_list=False): @@ -372,7 +377,7 @@ class String(ComfyTypeIO): class Input(WidgetInputV3): '''String input.''' def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, - multiline=False, placeholder: str=None, default: int=None, + multiline=False, placeholder: str=None, default: str=None, socketless: bool=None, force_input: bool=None): super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, self.io_type, force_input) self.multiline = multiline @@ -389,11 +394,11 @@ class String(ComfyTypeIO): class Combo(ComfyType): Type = str class Input(WidgetInputV3): - '''Combo input (dropdown).''' + """Combo input (dropdown).""" Type = str def __init__(self, id: str, options: list[str]=None, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, default: str=None, control_after_generate: bool=None, - image_upload: bool=None, image_folder: FolderType=None, + image_upload: bool=None, image_folder: FolderType=None, content_types: list[Literal["image", "video", "audio", "model"]]=None, remote: RemoteOptions=None, socketless: bool=None): super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, self.io_type) @@ -402,6 +407,7 @@ class Combo(ComfyType): self.control_after_generate = control_after_generate self.image_upload = image_upload self.image_folder = image_folder + self.content_types = content_types self.remote = remote self.default: str @@ -412,9 +418,23 @@ class Combo(ComfyType): "control_after_generate": self.control_after_generate, "image_upload": self.image_upload, "image_folder": self.image_folder.value if self.image_folder else None, + "content_types": self.content_types if self.content_types else None, "remote": self.remote.as_dict() if self.remote else None, }) + def as_value_type_v1(self): + if getattr(self, "image_folder"): + if self.image_folder == FolderType.input: + target_dir = folder_paths.get_input_directory() + elif self.image_folder == FolderType.output: + target_dir = folder_paths.get_output_directory() + else: + target_dir = folder_paths.get_temp_directory() + files = [f for f in os.listdir(target_dir) if os.path.isfile(os.path.join(target_dir, f))] + if self.content_types is None: + return files + return sorted(folder_paths.filter_files_content_types(files, self.content_types)) + @comfytype(io_type="COMBO") class MultiCombo(ComfyType): @@ -969,7 +989,7 @@ class SchemaV3: issues.append(f"Ids must be unique between inputs and outputs, but {intersection} are not.") if len(issues) > 0: raise ValueError("\n".join(issues)) - + def finalize(self): """Add hidden based on selected schema options.""" # if is an api_node, will need key-related hidden diff --git a/nodes.py b/nodes.py index 6b85f8be6..5d1f0dd6e 100644 --- a/nodes.py +++ b/nodes.py @@ -26,7 +26,7 @@ import comfy.sd import comfy.utils import comfy.controlnet from comfy.comfy_types import IO, ComfyNodeABC, InputTypeDict, FileLocator -from comfy_api.v3.io import ComfyNodeV3 +from comfy_api.v3 import io import comfy.clip_vision @@ -1550,36 +1550,36 @@ class KSamplerAdvanced: disable_noise = True return common_ksampler(model, noise_seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image, denoise=denoise, disable_noise=disable_noise, start_step=start_at_step, last_step=end_at_step, force_full_denoise=force_full_denoise) -class SaveImage: - def __init__(self): - self.output_dir = folder_paths.get_output_directory() - self.type = "output" - self.prefix_append = "" - self.compress_level = 4 + +class SaveImage(io.ComfyNodeV3): + @classmethod + def DEFINE_SCHEMA(cls): + return io.SchemaV3( + node_id="SaveImage", + display_name="Save Image", + description="Saves the input images to your ComfyUI output directory.", + category="image", + inputs=[ + io.Image.Input( + "images", + display_name="images", + tooltip="The images to save.", + ), + io.String.Input( + "filename_prefix", + default="ComfyUI", + tooltip="The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes.", + ), + ], + hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo], + is_output_node=True, + ) @classmethod - def INPUT_TYPES(s): - return { - "required": { - "images": ("IMAGE", {"tooltip": "The images to save."}), - "filename_prefix": ("STRING", {"default": "ComfyUI", "tooltip": "The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes."}) - }, - "hidden": { - "prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO" - }, - } - - RETURN_TYPES = () - FUNCTION = "save_images" - - OUTPUT_NODE = True - - CATEGORY = "image" - DESCRIPTION = "Saves the input images to your ComfyUI output directory." - - def save_images(self, images, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): - filename_prefix += self.prefix_append - 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]) + def execute(cls, images, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): + full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path( + filename_prefix, folder_paths.get_output_directory(), images[0].shape[1], images[0].shape[0] + ) results = list() for (batch_number, image) in enumerate(images): i = 255. * image.cpu().numpy() @@ -1595,16 +1595,17 @@ class SaveImage: filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) file = f"{filename_with_batch_num}_{counter:05}_.png" - img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=self.compress_level) + img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=4) results.append({ "filename": file, "subfolder": subfolder, - "type": self.type + "type": "output", }) counter += 1 return { "ui": { "images": results } } + class PreviewImage(SaveImage): def __init__(self): self.output_dir = folder_paths.get_temp_directory() @@ -1619,24 +1620,36 @@ class PreviewImage(SaveImage): "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, } -class LoadImage: + +class LoadImage(io.ComfyNodeV3): @classmethod - def INPUT_TYPES(s): - input_dir = folder_paths.get_input_directory() - files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] - files = folder_paths.filter_files_content_types(files, ["image"]) - return {"required": - {"image": (sorted(files), {"image_upload": True})}, - } + def DEFINE_SCHEMA(cls): + return io.SchemaV3( + node_id="LoadImage", + display_name="Load Image", + category="image", + inputs=[ + io.Combo.Input( + "image", + display_name="image", + image_upload=True, + image_folder=io.FolderType.input, + content_types=["image"], + ), + ], + outputs=[ + io.Image.Output( + "IMAGE", + ), + io.Mask.Output( + "MASK", + ), + ], + ) - CATEGORY = "image" - - RETURN_TYPES = ("IMAGE", "MASK") - FUNCTION = "load_image" - def load_image(self, image): - image_path = folder_paths.get_annotated_filepath(image) - - img = node_helpers.pillow(Image.open, image_path) + @classmethod + def execute(cls, image) -> io.NodeOutput: + img = node_helpers.pillow(Image.open, folder_paths.get_annotated_filepath(image)) output_images = [] output_masks = [] @@ -1678,7 +1691,7 @@ class LoadImage: output_image = output_images[0] output_mask = output_masks[0] - return (output_image, output_mask) + return io.NodeOutput(output_image, output_mask) @classmethod def IS_CHANGED(s, image): @@ -1695,6 +1708,7 @@ class LoadImage: return True + class LoadImageMask: _color_channels = ["alpha", "red", "green", "blue"] @classmethod @@ -2162,7 +2176,7 @@ def load_custom_node(module_path: str, ignore=set(), module_parent="custom_nodes # V3 node definition elif getattr(module, "NODES_LIST", None) is not None: for node_cls in module.NODES_LIST: - node_cls: ComfyNodeV3 + node_cls: io.ComfyNodeV3 schema = node_cls.GET_SCHEMA() if schema.node_id not in ignore: NODE_CLASS_MAPPINGS[schema.node_id] = node_cls From 1eb1a448831fcb9d0580d9199df67b24f1c1e0e8 Mon Sep 17 00:00:00 2001 From: bigcat88 Date: Tue, 8 Jul 2025 17:55:13 +0300 Subject: [PATCH 2/8] migrate PreviewImage node to V3 --- nodes.py | 45 +++++++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/nodes.py b/nodes.py index 5d1f0dd6e..62ce19057 100644 --- a/nodes.py +++ b/nodes.py @@ -1575,11 +1575,16 @@ class SaveImage(io.ComfyNodeV3): is_output_node=True, ) - @classmethod - def execute(cls, images, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): - full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path( - filename_prefix, folder_paths.get_output_directory(), images[0].shape[1], images[0].shape[0] - ) + def __init__(self): + super().__init__() + self.output_dir = folder_paths.get_output_directory() + self.type = "output" + self.prefix_append = "" + self.compress_level = 4 + + def execute(self, images, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): + filename_prefix += self.prefix_append + 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 (batch_number, image) in enumerate(images): i = 255. * image.cpu().numpy() @@ -1595,11 +1600,11 @@ class SaveImage(io.ComfyNodeV3): filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) file = f"{filename_with_batch_num}_{counter:05}_.png" - img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=4) + img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=self.compress_level) results.append({ "filename": file, "subfolder": subfolder, - "type": "output", + "type": self.type, }) counter += 1 @@ -1607,19 +1612,31 @@ class SaveImage(io.ComfyNodeV3): class PreviewImage(SaveImage): + @classmethod + def DEFINE_SCHEMA(cls): + return io.SchemaV3( + node_id="PreviewImage", + display_name="Preview Image", + description="Preview the input images.", + category="image", + inputs=[ + io.Image.Input( + "images", + display_name="images", + tooltip="The images to preview.", + ), + ], + hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo], + is_output_node=True, + ) + def __init__(self): + super().__init__() self.output_dir = folder_paths.get_temp_directory() self.type = "temp" self.prefix_append = "_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5)) self.compress_level = 1 - @classmethod - def INPUT_TYPES(s): - return {"required": - {"images": ("IMAGE", ), }, - "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, - } - class LoadImage(io.ComfyNodeV3): @classmethod From fefb24cc3341a12e302932f15af7f1b394aa86e1 Mon Sep 17 00:00:00 2001 From: bigcat88 Date: Wed, 9 Jul 2025 11:09:19 +0300 Subject: [PATCH 3/8] fixes, corrections; ported MaskPreview, WebcamCapture and LoadImageOutput nodes --- comfy_api/v3/io.py | 43 +++-- comfy_extras/nodes_mask.py | 29 ---- comfy_extras/nodes_webcam.py | 37 ----- comfy_extras/v3/nodes_images.py | 283 ++++++++++++++++++++++++++++++++ comfy_extras/v3/nodes_mask.py | 32 ++++ comfy_extras/v3/nodes_webcam.py | 118 +++++++++++++ execution.py | 5 +- nodes.py | 213 +----------------------- 8 files changed, 469 insertions(+), 291 deletions(-) delete mode 100644 comfy_extras/nodes_webcam.py create mode 100644 comfy_extras/v3/nodes_images.py create mode 100644 comfy_extras/v3/nodes_mask.py create mode 100644 comfy_extras/v3/nodes_webcam.py diff --git a/comfy_api/v3/io.py b/comfy_api/v3/io.py index a97a3e8a0..67509fb29 100644 --- a/comfy_api/v3/io.py +++ b/comfy_api/v3/io.py @@ -209,8 +209,6 @@ class WidgetInputV3(InputV3): }) def get_io_type_V1(self): - if isinstance(self, Combo.Input): - return self.as_value_type_v1() return self.widgetType if self.widgetType is not None else super().get_io_type_V1() @@ -411,18 +409,7 @@ class Combo(ComfyType): self.remote = remote self.default: str - def as_dict_V1(self): - return super().as_dict_V1() | prune_dict({ - "multiselect": self.multiselect, - "options": self.options, - "control_after_generate": self.control_after_generate, - "image_upload": self.image_upload, - "image_folder": self.image_folder.value if self.image_folder else None, - "content_types": self.content_types if self.content_types else None, - "remote": self.remote.as_dict() if self.remote else None, - }) - - def as_value_type_v1(self): + def get_io_type_V1(self): if getattr(self, "image_folder"): if self.image_folder == FolderType.input: target_dir = folder_paths.get_input_directory() @@ -434,6 +421,18 @@ class Combo(ComfyType): if self.content_types is None: return files return sorted(folder_paths.filter_files_content_types(files, self.content_types)) + return super().get_io_type_V1() + + def as_dict_V1(self): + return super().as_dict_V1() | prune_dict({ + "multiselect": self.multiselect, + "options": self.options, + "control_after_generate": self.control_after_generate, + "image_upload": self.image_upload, + "image_folder": self.image_folder.value if self.image_folder else None, + "content_types": self.content_types if self.content_types else None, + "remote": self.remote.as_dict() if self.remote else None, + }) @comfytype(io_type="COMBO") @@ -463,6 +462,20 @@ class MultiCombo(ComfyType): class Image(ComfyTypeIO): Type = torch.Tensor +@comfytype(io_type="WEBCAM") +class Webcam(ComfyTypeIO): + Type = str + + class Input(WidgetInputV3): + """Webcam input.""" + Type = str + def __init__( + self, id: str, display_name: str=None, optional=False, + tooltip: str=None, lazy: bool=None, default: str=None, socketless: bool=None + ): + super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, self.io_type) + + @comfytype(io_type="MASK") class Mask(ComfyTypeIO): Type = torch.Tensor @@ -1121,7 +1134,7 @@ class ComfyNodeV3: type_clone: type[ComfyNodeV3] = type(f"CLEAN_{c_type.__name__}", c_type.__bases__, {}) # TODO: what parameters should be carried over? type_clone.SCHEMA = c_type.SCHEMA - type_clone.hidden = HiddenHolder.from_dict(hidden_inputs) + type_clone.hidden = HiddenHolder.from_dict(hidden_inputs) if hidden_inputs is not None else None # TODO: add anything we would want to expose inside node's execute function return type_clone diff --git a/comfy_extras/nodes_mask.py b/comfy_extras/nodes_mask.py index ab387a2fc..d3ed7c68e 100644 --- a/comfy_extras/nodes_mask.py +++ b/comfy_extras/nodes_mask.py @@ -3,10 +3,7 @@ import scipy.ndimage import torch import comfy.utils import node_helpers -import folder_paths -import random -import nodes from nodes import MAX_RESOLUTION def composite(destination, source, x, y, mask = None, multiplier = 8, resize_source = False): @@ -365,30 +362,6 @@ class ThresholdMask: mask = (mask > value).float() return (mask,) -# Mask Preview - original implement from -# https://github.com/cubiq/ComfyUI_essentials/blob/9d9f4bedfc9f0321c19faf71855e228c93bd0dc9/mask.py#L81 -# upstream requested in https://github.com/Kosinkadink/rfcs/blob/main/rfcs/0000-corenodes.md#preview-nodes -class MaskPreview(nodes.SaveImage): - def __init__(self): - self.output_dir = folder_paths.get_temp_directory() - self.type = "temp" - self.prefix_append = "_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5)) - self.compress_level = 4 - - @classmethod - def INPUT_TYPES(s): - return { - "required": {"mask": ("MASK",), }, - "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, - } - - FUNCTION = "execute" - CATEGORY = "mask" - - def execute(self, mask, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): - preview = mask.reshape((-1, 1, mask.shape[-2], mask.shape[-1])).movedim(1, -1).expand(-1, -1, -1, 3) - return self.save_images(preview, filename_prefix, prompt, extra_pnginfo) - NODE_CLASS_MAPPINGS = { "LatentCompositeMasked": LatentCompositeMasked, @@ -403,10 +376,8 @@ NODE_CLASS_MAPPINGS = { "FeatherMask": FeatherMask, "GrowMask": GrowMask, "ThresholdMask": ThresholdMask, - "MaskPreview": MaskPreview } NODE_DISPLAY_NAME_MAPPINGS = { "ImageToMask": "Convert Image to Mask", - "MaskToImage": "Convert Mask to Image", } diff --git a/comfy_extras/nodes_webcam.py b/comfy_extras/nodes_webcam.py deleted file mode 100644 index 5bf80b4c6..000000000 --- a/comfy_extras/nodes_webcam.py +++ /dev/null @@ -1,37 +0,0 @@ -import nodes -import folder_paths - -MAX_RESOLUTION = nodes.MAX_RESOLUTION - - -class WebcamCapture(nodes.LoadImage): - @classmethod - def INPUT_TYPES(s): - return { - "required": { - "image": ("WEBCAM", {}), - "width": ("INT", {"default": 0, "min": 0, "max": MAX_RESOLUTION, "step": 1}), - "height": ("INT", {"default": 0, "min": 0, "max": MAX_RESOLUTION, "step": 1}), - "capture_on_queue": ("BOOLEAN", {"default": True}), - } - } - RETURN_TYPES = ("IMAGE",) - FUNCTION = "load_capture" - - CATEGORY = "image" - - def load_capture(self, image, **kwargs): - return super().load_image(folder_paths.get_annotated_filepath(image)) - - @classmethod - def IS_CHANGED(cls, image, width, height, capture_on_queue): - return super().IS_CHANGED(image) - - -NODE_CLASS_MAPPINGS = { - "WebcamCapture": WebcamCapture, -} - -NODE_DISPLAY_NAME_MAPPINGS = { - "WebcamCapture": "Webcam Capture", -} diff --git a/comfy_extras/v3/nodes_images.py b/comfy_extras/v3/nodes_images.py new file mode 100644 index 000000000..35666a444 --- /dev/null +++ b/comfy_extras/v3/nodes_images.py @@ -0,0 +1,283 @@ +import json +import os +import torch +import hashlib + +import numpy as np +from PIL import Image, ImageOps, ImageSequence +from PIL.PngImagePlugin import PngInfo + +from comfy_api.v3 import io, ui +from comfy.cli_args import args +import folder_paths +import node_helpers + + +class SaveImage(io.ComfyNodeV3): + @classmethod + def DEFINE_SCHEMA(cls): + return io.SchemaV3( + node_id="SaveImage", + display_name="Save Image", + description="Saves the input images to your ComfyUI output directory.", + category="image", + inputs=[ + io.Image.Input( + "images", + display_name="images", + tooltip="The images to save.", + ), + io.String.Input( + "filename_prefix", + default="ComfyUI", + tooltip="The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes.", + ), + ], + hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo], + is_output_node=True, + ) + + @classmethod + def execute(cls, images, filename_prefix="ComfyUI"): + full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path( + "", folder_paths.get_output_directory(), images[0].shape[1], images[0].shape[0] + ) + results = [] + for (batch_number, image) in enumerate(images): + i = 255. * image.cpu().numpy() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + metadata = None + if not args.disable_metadata: + metadata = PngInfo() + if cls.hidden.prompt is not None: + metadata.add_text("prompt", json.dumps(cls.hidden.prompt)) + if cls.hidden.extra_pnginfo is not None: + for x in cls.hidden.extra_pnginfo: + metadata.add_text(x, json.dumps(cls.hidden.extra_pnginfo[x])) + + filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) + file = f"{filename_with_batch_num}_{counter:05}_.png" + img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=4) + results.append({ + "filename": file, + "subfolder": subfolder, + "type": "output", + }) + counter += 1 + + return io.NodeOutput(ui={"images": results}) + + +class PreviewImage(io.ComfyNodeV3): + @classmethod + def DEFINE_SCHEMA(cls): + return io.SchemaV3( + node_id="PreviewImage", + display_name="Preview Image", + description="Preview the input images.", + category="image", + inputs=[ + io.Image.Input( + "images", + display_name="images", + tooltip="The images to preview.", + ), + ], + hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo], + is_output_node=True, + ) + + @classmethod + def execute(cls, images): + return io.NodeOutput(ui=ui.PreviewImage(images)) + + +class LoadImage(io.ComfyNodeV3): + @classmethod + def DEFINE_SCHEMA(cls): + return io.SchemaV3( + node_id="LoadImage", + display_name="Load Image", + category="image", + inputs=[ + io.Combo.Input( + "image", + display_name="image", + image_upload=True, + image_folder=io.FolderType.input, + content_types=["image"], + ), + ], + outputs=[ + io.Image.Output( + "IMAGE", + ), + io.Mask.Output( + "MASK", + ), + ], + ) + + @classmethod + def execute(cls, image) -> io.NodeOutput: + img = node_helpers.pillow(Image.open, folder_paths.get_annotated_filepath(image)) + + output_images = [] + output_masks = [] + w, h = None, None + + excluded_formats = ['MPO'] + + for i in ImageSequence.Iterator(img): + i = node_helpers.pillow(ImageOps.exif_transpose, i) + + if i.mode == 'I': + i = i.point(lambda i: i * (1 / 255)) + image = i.convert("RGB") + + if len(output_images) == 0: + w = image.size[0] + h = image.size[1] + + if image.size[0] != w or image.size[1] != h: + continue + + image = np.array(image).astype(np.float32) / 255.0 + image = torch.from_numpy(image)[None,] + if 'A' in i.getbands(): + mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0 + mask = 1. - torch.from_numpy(mask) + elif i.mode == 'P' and 'transparency' in i.info: + mask = np.array(i.convert('RGBA').getchannel('A')).astype(np.float32) / 255.0 + mask = 1. - torch.from_numpy(mask) + else: + mask = torch.zeros((64,64), dtype=torch.float32, device="cpu") + output_images.append(image) + output_masks.append(mask.unsqueeze(0)) + + if len(output_images) > 1 and img.format not in excluded_formats: + output_image = torch.cat(output_images, dim=0) + output_mask = torch.cat(output_masks, dim=0) + else: + output_image = output_images[0] + output_mask = output_masks[0] + + return io.NodeOutput(output_image, output_mask) + + @classmethod + def IS_CHANGED(s, image): + image_path = folder_paths.get_annotated_filepath(image) + m = hashlib.sha256() + with open(image_path, 'rb') as f: + m.update(f.read()) + return m.digest().hex() + + @classmethod + def VALIDATE_INPUTS(s, image): + if not folder_paths.exists_annotated_filepath(image): + return "Invalid image file: {}".format(image) + return True + + +class LoadImageOutput(io.ComfyNodeV3): + @classmethod + def DEFINE_SCHEMA(cls): + return io.SchemaV3( + node_id="LoadImageOutput", + display_name="Load Image (from Outputs)", + description="Load an image from the output folder. " + "When the refresh button is clicked, the node will update the image list " + "and automatically select the first image, allowing for easy iteration.", + category="image", + inputs=[ + io.Combo.Input( + "image", + display_name="image", + image_upload=True, + image_folder=io.FolderType.output, + content_types=["image"], + remote=io.RemoteOptions( + route="/internal/files/output", + refresh_button=True, + control_after_refresh="first", + ), + ), + ], + outputs=[ + io.Image.Output( + "IMAGE", + ), + io.Mask.Output( + "MASK", + ), + ], + ) + + @classmethod + def execute(cls, image) -> io.NodeOutput: + img = node_helpers.pillow(Image.open, folder_paths.get_annotated_filepath(image)) + + output_images = [] + output_masks = [] + w, h = None, None + + excluded_formats = ['MPO'] + + for i in ImageSequence.Iterator(img): + i = node_helpers.pillow(ImageOps.exif_transpose, i) + + if i.mode == 'I': + i = i.point(lambda i: i * (1 / 255)) + image = i.convert("RGB") + + if len(output_images) == 0: + w = image.size[0] + h = image.size[1] + + if image.size[0] != w or image.size[1] != h: + continue + + image = np.array(image).astype(np.float32) / 255.0 + image = torch.from_numpy(image)[None,] + if 'A' in i.getbands(): + mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0 + mask = 1. - torch.from_numpy(mask) + elif i.mode == 'P' and 'transparency' in i.info: + mask = np.array(i.convert('RGBA').getchannel('A')).astype(np.float32) / 255.0 + mask = 1. - torch.from_numpy(mask) + else: + mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu") + output_images.append(image) + output_masks.append(mask.unsqueeze(0)) + + if len(output_images) > 1 and img.format not in excluded_formats: + output_image = torch.cat(output_images, dim=0) + output_mask = torch.cat(output_masks, dim=0) + else: + output_image = output_images[0] + output_mask = output_masks[0] + + return io.NodeOutput(output_image, output_mask) + + @classmethod + def IS_CHANGED(s, image): + image_path = folder_paths.get_annotated_filepath(image) + m = hashlib.sha256() + with open(image_path, 'rb') as f: + m.update(f.read()) + return m.digest().hex() + + @classmethod + def VALIDATE_INPUTS(s, image): + if not folder_paths.exists_annotated_filepath(image): + return "Invalid image file: {}".format(image) + return True + + + +NODES_LIST: list[type[io.ComfyNodeV3]] = [ + SaveImage, + PreviewImage, + LoadImage, + LoadImageOutput, +] diff --git a/comfy_extras/v3/nodes_mask.py b/comfy_extras/v3/nodes_mask.py new file mode 100644 index 000000000..df34d5662 --- /dev/null +++ b/comfy_extras/v3/nodes_mask.py @@ -0,0 +1,32 @@ +from comfy_api.v3 import io, ui + + +class MaskPreview(io.ComfyNodeV3): + """Mask Preview - original implement in ComfyUI_essentials. + + https://github.com/cubiq/ComfyUI_essentials/blob/9d9f4bedfc9f0321c19faf71855e228c93bd0dc9/mask.py#L81 + Upstream requested in https://github.com/Kosinkadink/rfcs/blob/main/rfcs/0000-corenodes.md#preview-nodes + """ + + @classmethod + def DEFINE_SCHEMA(cls): + return io.SchemaV3( + node_id="MaskPreview", + display_name="Convert Mask to Image", + category="mask", + inputs=[ + io.Mask.Input( + "masks", + display_name="masks", + ), + ], + hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo], + is_output_node=True, + ) + + @classmethod + def execute(cls, masks): + return io.NodeOutput(ui=ui.PreviewMask(masks)) + + +NODES_LIST: list[type[io.ComfyNodeV3]] = [MaskPreview] diff --git a/comfy_extras/v3/nodes_webcam.py b/comfy_extras/v3/nodes_webcam.py new file mode 100644 index 000000000..6c0a96e15 --- /dev/null +++ b/comfy_extras/v3/nodes_webcam.py @@ -0,0 +1,118 @@ +import hashlib +import torch + +import numpy as np +from PIL import Image, ImageOps, ImageSequence + +from comfy_api.v3 import io +import nodes +import folder_paths +import node_helpers + + +MAX_RESOLUTION = nodes.MAX_RESOLUTION + + +class WebcamCapture(io.ComfyNodeV3): + @classmethod + def DEFINE_SCHEMA(cls): + return io.SchemaV3( + node_id="WebcamCapture", + display_name="Webcam Capture", + category="image", + inputs=[ + io.Webcam.Input( + "image", + display_name="image", + ), + io.Int.Input( + "width", + display_name="width", + default=0, + min=0, + max=MAX_RESOLUTION, + step=1, + ), + io.Int.Input( + "height", + display_name="height", + default=0, + min=0, + max=MAX_RESOLUTION, + step=1, + ), + io.Boolean.Input( + "capture_on_queue", + default=True, + ), + ], + outputs=[ + io.Image.Output( + "IMAGE", + ), + ], + ) + + @classmethod + def execute(cls, image, **kwargs) -> io.NodeOutput: + img = node_helpers.pillow(Image.open, folder_paths.get_annotated_filepath(image)) + + output_images = [] + output_masks = [] + w, h = None, None + + excluded_formats = ['MPO'] + + for i in ImageSequence.Iterator(img): + i = node_helpers.pillow(ImageOps.exif_transpose, i) + + if i.mode == 'I': + i = i.point(lambda i: i * (1 / 255)) + image = i.convert("RGB") + + if len(output_images) == 0: + w = image.size[0] + h = image.size[1] + + if image.size[0] != w or image.size[1] != h: + continue + + image = np.array(image).astype(np.float32) / 255.0 + image = torch.from_numpy(image)[None,] + if 'A' in i.getbands(): + mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0 + mask = 1. - torch.from_numpy(mask) + elif i.mode == 'P' and 'transparency' in i.info: + mask = np.array(i.convert('RGBA').getchannel('A')).astype(np.float32) / 255.0 + mask = 1. - torch.from_numpy(mask) + else: + mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu") + output_images.append(image) + output_masks.append(mask.unsqueeze(0)) + + if len(output_images) > 1 and img.format not in excluded_formats: + output_image = torch.cat(output_images, dim=0) + output_mask = torch.cat(output_masks, dim=0) + else: + output_image = output_images[0] + output_mask = output_masks[0] + + return io.NodeOutput(output_image, output_mask) + + @classmethod + def IS_CHANGED(s, image, width, height, capture_on_queue): + image_path = folder_paths.get_annotated_filepath(image) + m = hashlib.sha256() + with open(image_path, 'rb') as f: + m.update(f.read()) + return m.digest().hex() + + + @classmethod + def VALIDATE_INPUTS(s, image): + if not folder_paths.exists_annotated_filepath(image): + return "Invalid image file: {}".format(image) + return True + + +NODES_LIST: list[type[io.ComfyNodeV3]] = [WebcamCapture] diff --git a/execution.py b/execution.py index 896537980..8ca134525 100644 --- a/execution.py +++ b/execution.py @@ -321,7 +321,10 @@ def get_output_data(obj, input_data_all, execution_block_cb=None, pre_execute_cb elif isinstance(r, NodeOutput): # V3 if r.ui is not None: - uis.append(r.ui.as_dict()) + if isinstance(r.ui, dict): + uis.append(r.ui) + else: + uis.append(r.ui.as_dict()) if r.expand is not None: has_subgraph = True new_graph = r.expand diff --git a/nodes.py b/nodes.py index 62ce19057..49887ddc7 100644 --- a/nodes.py +++ b/nodes.py @@ -8,11 +8,9 @@ import hashlib import traceback import math import time -import random import logging -from PIL import Image, ImageOps, ImageSequence -from PIL.PngImagePlugin import PngInfo +from PIL import Image, ImageOps import numpy as np import safetensors.torch @@ -1551,181 +1549,6 @@ class KSamplerAdvanced: return common_ksampler(model, noise_seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image, denoise=denoise, disable_noise=disable_noise, start_step=start_at_step, last_step=end_at_step, force_full_denoise=force_full_denoise) -class SaveImage(io.ComfyNodeV3): - @classmethod - def DEFINE_SCHEMA(cls): - return io.SchemaV3( - node_id="SaveImage", - display_name="Save Image", - description="Saves the input images to your ComfyUI output directory.", - category="image", - inputs=[ - io.Image.Input( - "images", - display_name="images", - tooltip="The images to save.", - ), - io.String.Input( - "filename_prefix", - default="ComfyUI", - tooltip="The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes.", - ), - ], - hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo], - is_output_node=True, - ) - - def __init__(self): - super().__init__() - self.output_dir = folder_paths.get_output_directory() - self.type = "output" - self.prefix_append = "" - self.compress_level = 4 - - def execute(self, images, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): - filename_prefix += self.prefix_append - 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 (batch_number, image) in enumerate(images): - i = 255. * image.cpu().numpy() - img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) - metadata = None - if not args.disable_metadata: - 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])) - - filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) - file = f"{filename_with_batch_num}_{counter:05}_.png" - img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=self.compress_level) - results.append({ - "filename": file, - "subfolder": subfolder, - "type": self.type, - }) - counter += 1 - - return { "ui": { "images": results } } - - -class PreviewImage(SaveImage): - @classmethod - def DEFINE_SCHEMA(cls): - return io.SchemaV3( - node_id="PreviewImage", - display_name="Preview Image", - description="Preview the input images.", - category="image", - inputs=[ - io.Image.Input( - "images", - display_name="images", - tooltip="The images to preview.", - ), - ], - hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo], - is_output_node=True, - ) - - def __init__(self): - super().__init__() - self.output_dir = folder_paths.get_temp_directory() - self.type = "temp" - self.prefix_append = "_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5)) - self.compress_level = 1 - - -class LoadImage(io.ComfyNodeV3): - @classmethod - def DEFINE_SCHEMA(cls): - return io.SchemaV3( - node_id="LoadImage", - display_name="Load Image", - category="image", - inputs=[ - io.Combo.Input( - "image", - display_name="image", - image_upload=True, - image_folder=io.FolderType.input, - content_types=["image"], - ), - ], - outputs=[ - io.Image.Output( - "IMAGE", - ), - io.Mask.Output( - "MASK", - ), - ], - ) - - @classmethod - def execute(cls, image) -> io.NodeOutput: - img = node_helpers.pillow(Image.open, folder_paths.get_annotated_filepath(image)) - - output_images = [] - output_masks = [] - w, h = None, None - - excluded_formats = ['MPO'] - - for i in ImageSequence.Iterator(img): - i = node_helpers.pillow(ImageOps.exif_transpose, i) - - if i.mode == 'I': - i = i.point(lambda i: i * (1 / 255)) - image = i.convert("RGB") - - if len(output_images) == 0: - w = image.size[0] - h = image.size[1] - - if image.size[0] != w or image.size[1] != h: - continue - - image = np.array(image).astype(np.float32) / 255.0 - image = torch.from_numpy(image)[None,] - if 'A' in i.getbands(): - mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0 - mask = 1. - torch.from_numpy(mask) - elif i.mode == 'P' and 'transparency' in i.info: - mask = np.array(i.convert('RGBA').getchannel('A')).astype(np.float32) / 255.0 - mask = 1. - torch.from_numpy(mask) - else: - mask = torch.zeros((64,64), dtype=torch.float32, device="cpu") - output_images.append(image) - output_masks.append(mask.unsqueeze(0)) - - if len(output_images) > 1 and img.format not in excluded_formats: - output_image = torch.cat(output_images, dim=0) - output_mask = torch.cat(output_masks, dim=0) - else: - output_image = output_images[0] - output_mask = output_masks[0] - - return io.NodeOutput(output_image, output_mask) - - @classmethod - def IS_CHANGED(s, image): - image_path = folder_paths.get_annotated_filepath(image) - m = hashlib.sha256() - with open(image_path, 'rb') as f: - m.update(f.read()) - return m.digest().hex() - - @classmethod - def VALIDATE_INPUTS(s, image): - if not folder_paths.exists_annotated_filepath(image): - return "Invalid image file: {}".format(image) - - return True - - class LoadImageMask: _color_channels = ["alpha", "red", "green", "blue"] @classmethod @@ -1776,28 +1599,6 @@ class LoadImageMask: return True -class LoadImageOutput(LoadImage): - @classmethod - def INPUT_TYPES(s): - return { - "required": { - "image": ("COMBO", { - "image_upload": True, - "image_folder": "output", - "remote": { - "route": "/internal/files/output", - "refresh_button": True, - "control_after_refresh": "first", - }, - }), - } - } - - DESCRIPTION = "Load an image from the output folder. When the refresh button is clicked, the node will update the image list and automatically select the first image, allowing for easy iteration." - EXPERIMENTAL = True - FUNCTION = "load_image" - - class ImageScale: upscale_methods = ["nearest-exact", "bilinear", "area", "bicubic", "lanczos"] crop_methods = ["disabled", "center"] @@ -1980,11 +1781,7 @@ NODE_CLASS_MAPPINGS = { "LatentUpscaleBy": LatentUpscaleBy, "LatentFromBatch": LatentFromBatch, "RepeatLatentBatch": RepeatLatentBatch, - "SaveImage": SaveImage, - "PreviewImage": PreviewImage, - "LoadImage": LoadImage, "LoadImageMask": LoadImageMask, - "LoadImageOutput": LoadImageOutput, "ImageScale": ImageScale, "ImageScaleBy": ImageScaleBy, "ImageInvert": ImageInvert, @@ -2081,11 +1878,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "LatentFromBatch" : "Latent From Batch", "RepeatLatentBatch": "Repeat Latent Batch", # Image - "SaveImage": "Save Image", - "PreviewImage": "Preview Image", - "LoadImage": "Load Image", "LoadImageMask": "Load Image (as Mask)", - "LoadImageOutput": "Load Image (from Outputs)", "ImageScale": "Upscale Image", "ImageScaleBy": "Upscale Image By", "ImageUpscaleWithModel": "Upscale Image (using Model)", @@ -2295,7 +2088,6 @@ def init_builtin_extra_nodes(): "nodes_align_your_steps.py", "nodes_attention_multiply.py", "nodes_advanced_samplers.py", - "nodes_webcam.py", "nodes_audio.py", "nodes_sd3.py", "nodes_gits.py", @@ -2330,6 +2122,9 @@ def init_builtin_extra_nodes(): "nodes_tcfg.py" "nodes_v3_test.py", "nodes_v1_test.py", + "v3/nodes_images.py", + "v3/nodes_mask.py", + "v3/nodes_webcam.py", ] import_failed = [] From 8f0621ca7e79731f0625bc4de57bcbf68faff710 Mon Sep 17 00:00:00 2001 From: bigcat88 Date: Wed, 9 Jul 2025 14:02:28 +0300 Subject: [PATCH 4/8] IS_CHANGED->fingerprint_inputs , VALIDATE_INPUTS->validate_inputs --- comfy_extras/v3/nodes_images.py | 8 ++++---- comfy_extras/v3/nodes_webcam.py | 5 ++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/comfy_extras/v3/nodes_images.py b/comfy_extras/v3/nodes_images.py index 35666a444..839622d40 100644 --- a/comfy_extras/v3/nodes_images.py +++ b/comfy_extras/v3/nodes_images.py @@ -165,7 +165,7 @@ class LoadImage(io.ComfyNodeV3): return io.NodeOutput(output_image, output_mask) @classmethod - def IS_CHANGED(s, image): + def fingerprint_inputs(s, image): image_path = folder_paths.get_annotated_filepath(image) m = hashlib.sha256() with open(image_path, 'rb') as f: @@ -173,7 +173,7 @@ class LoadImage(io.ComfyNodeV3): return m.digest().hex() @classmethod - def VALIDATE_INPUTS(s, image): + def validate_inputs(s, image): if not folder_paths.exists_annotated_filepath(image): return "Invalid image file: {}".format(image) return True @@ -260,7 +260,7 @@ class LoadImageOutput(io.ComfyNodeV3): return io.NodeOutput(output_image, output_mask) @classmethod - def IS_CHANGED(s, image): + def fingerprint_inputs(s, image): image_path = folder_paths.get_annotated_filepath(image) m = hashlib.sha256() with open(image_path, 'rb') as f: @@ -268,7 +268,7 @@ class LoadImageOutput(io.ComfyNodeV3): return m.digest().hex() @classmethod - def VALIDATE_INPUTS(s, image): + def validate_inputs(s, image): if not folder_paths.exists_annotated_filepath(image): return "Invalid image file: {}".format(image) return True diff --git a/comfy_extras/v3/nodes_webcam.py b/comfy_extras/v3/nodes_webcam.py index 6c0a96e15..9c25aa633 100644 --- a/comfy_extras/v3/nodes_webcam.py +++ b/comfy_extras/v3/nodes_webcam.py @@ -100,16 +100,15 @@ class WebcamCapture(io.ComfyNodeV3): return io.NodeOutput(output_image, output_mask) @classmethod - def IS_CHANGED(s, image, width, height, capture_on_queue): + def fingerprint_inputs(s, image, width, height, capture_on_queue): image_path = folder_paths.get_annotated_filepath(image) m = hashlib.sha256() with open(image_path, 'rb') as f: m.update(f.read()) return m.digest().hex() - @classmethod - def VALIDATE_INPUTS(s, image): + def validate_inputs(s, image): if not folder_paths.exists_annotated_filepath(image): return "Invalid image file: {}".format(image) return True From 982f4d6f31eef4fadaa272fcd0f5b9f04c0ec6a9 Mon Sep 17 00:00:00 2001 From: bigcat88 Date: Thu, 10 Jul 2025 04:36:17 +0300 Subject: [PATCH 5/8] removed "prepare_class_clone" modification --- comfy_api/v3/io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_api/v3/io.py b/comfy_api/v3/io.py index 67509fb29..6a291b86a 100644 --- a/comfy_api/v3/io.py +++ b/comfy_api/v3/io.py @@ -1134,7 +1134,7 @@ class ComfyNodeV3: type_clone: type[ComfyNodeV3] = type(f"CLEAN_{c_type.__name__}", c_type.__bases__, {}) # TODO: what parameters should be carried over? type_clone.SCHEMA = c_type.SCHEMA - type_clone.hidden = HiddenHolder.from_dict(hidden_inputs) if hidden_inputs is not None else None + type_clone.hidden = HiddenHolder.from_dict(hidden_inputs) # TODO: add anything we would want to expose inside node's execute function return type_clone From e1975567a301712a6c3f06d9e1a8fce8a5132010 Mon Sep 17 00:00:00 2001 From: bigcat88 Date: Thu, 10 Jul 2025 06:38:49 +0300 Subject: [PATCH 6/8] removed widgetType from serialization --- comfy_api/v3/io.py | 1 - 1 file changed, 1 deletion(-) diff --git a/comfy_api/v3/io.py b/comfy_api/v3/io.py index 6a291b86a..ce6ac4f70 100644 --- a/comfy_api/v3/io.py +++ b/comfy_api/v3/io.py @@ -204,7 +204,6 @@ class WidgetInputV3(InputV3): return super().as_dict_V1() | prune_dict({ "default": self.default, "socketless": self.socketless, - "widgetType": self.widgetType, "forceInput": self.force_input, }) From 965d2f9b8f254d72a8838412a79428e6c1505af1 Mon Sep 17 00:00:00 2001 From: bigcat88 Date: Thu, 10 Jul 2025 06:46:07 +0300 Subject: [PATCH 7/8] use options key, remove get_io_type_V1 serialization --- comfy_api/v3/io.py | 16 ---------------- comfy_extras/v3/nodes_images.py | 9 ++++++++- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/comfy_api/v3/io.py b/comfy_api/v3/io.py index ce6ac4f70..f85b59253 100644 --- a/comfy_api/v3/io.py +++ b/comfy_api/v3/io.py @@ -20,8 +20,6 @@ from comfy.clip_vision import ClipVisionModel from comfy.clip_vision import Output as ClipVisionOutput_ from comfy_api.input import VideoInput from comfy.hooks import HookGroup, HookKeyframeGroup -import folder_paths -import os # from comfy_extras.nodes_images import SVG as SVG_ # NOTE: needs to be moved before can be imported due to circular reference @@ -408,20 +406,6 @@ class Combo(ComfyType): self.remote = remote self.default: str - def get_io_type_V1(self): - if getattr(self, "image_folder"): - if self.image_folder == FolderType.input: - target_dir = folder_paths.get_input_directory() - elif self.image_folder == FolderType.output: - target_dir = folder_paths.get_output_directory() - else: - target_dir = folder_paths.get_temp_directory() - files = [f for f in os.listdir(target_dir) if os.path.isfile(os.path.join(target_dir, f))] - if self.content_types is None: - return files - return sorted(folder_paths.filter_files_content_types(files, self.content_types)) - return super().get_io_type_V1() - def as_dict_V1(self): return super().as_dict_V1() | prune_dict({ "multiselect": self.multiselect, diff --git a/comfy_extras/v3/nodes_images.py b/comfy_extras/v3/nodes_images.py index 839622d40..bebabb967 100644 --- a/comfy_extras/v3/nodes_images.py +++ b/comfy_extras/v3/nodes_images.py @@ -40,7 +40,7 @@ class SaveImage(io.ComfyNodeV3): @classmethod def execute(cls, images, filename_prefix="ComfyUI"): full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path( - "", folder_paths.get_output_directory(), images[0].shape[1], images[0].shape[0] + filename_prefix, folder_paths.get_output_directory(), images[0].shape[1], images[0].shape[0] ) results = [] for (batch_number, image) in enumerate(images): @@ -106,6 +106,7 @@ class LoadImage(io.ComfyNodeV3): image_upload=True, image_folder=io.FolderType.input, content_types=["image"], + options=cls.get_files_options(), ), ], outputs=[ @@ -118,6 +119,12 @@ class LoadImage(io.ComfyNodeV3): ], ) + @classmethod + def get_files_options(cls) -> list[str]: + target_dir = folder_paths.get_input_directory() + files = [f for f in os.listdir(target_dir) if os.path.isfile(os.path.join(target_dir, f))] + return sorted(folder_paths.filter_files_content_types(files, ["image"])) + @classmethod def execute(cls, image) -> io.NodeOutput: img = node_helpers.pillow(Image.open, folder_paths.get_annotated_filepath(image)) From d8b91bb84ead1d2ef0bf1425333b562eefa23acf Mon Sep 17 00:00:00 2001 From: bigcat88 Date: Thu, 10 Jul 2025 07:48:45 +0300 Subject: [PATCH 8/8] put V1 nodes back --- comfy_extras/nodes_mask.py | 29 ++++++ comfy_extras/nodes_webcam.py | 37 +++++++ comfy_extras/v3/nodes_images.py | 32 +++--- comfy_extras/v3/nodes_mask.py | 8 +- comfy_extras/v3/nodes_webcam.py | 8 +- nodes.py | 179 +++++++++++++++++++++++++++++++- 6 files changed, 268 insertions(+), 25 deletions(-) create mode 100644 comfy_extras/nodes_webcam.py diff --git a/comfy_extras/nodes_mask.py b/comfy_extras/nodes_mask.py index d3ed7c68e..ab387a2fc 100644 --- a/comfy_extras/nodes_mask.py +++ b/comfy_extras/nodes_mask.py @@ -3,7 +3,10 @@ import scipy.ndimage import torch import comfy.utils import node_helpers +import folder_paths +import random +import nodes from nodes import MAX_RESOLUTION def composite(destination, source, x, y, mask = None, multiplier = 8, resize_source = False): @@ -362,6 +365,30 @@ class ThresholdMask: mask = (mask > value).float() return (mask,) +# Mask Preview - original implement from +# https://github.com/cubiq/ComfyUI_essentials/blob/9d9f4bedfc9f0321c19faf71855e228c93bd0dc9/mask.py#L81 +# upstream requested in https://github.com/Kosinkadink/rfcs/blob/main/rfcs/0000-corenodes.md#preview-nodes +class MaskPreview(nodes.SaveImage): + def __init__(self): + self.output_dir = folder_paths.get_temp_directory() + self.type = "temp" + self.prefix_append = "_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5)) + self.compress_level = 4 + + @classmethod + def INPUT_TYPES(s): + return { + "required": {"mask": ("MASK",), }, + "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, + } + + FUNCTION = "execute" + CATEGORY = "mask" + + def execute(self, mask, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): + preview = mask.reshape((-1, 1, mask.shape[-2], mask.shape[-1])).movedim(1, -1).expand(-1, -1, -1, 3) + return self.save_images(preview, filename_prefix, prompt, extra_pnginfo) + NODE_CLASS_MAPPINGS = { "LatentCompositeMasked": LatentCompositeMasked, @@ -376,8 +403,10 @@ NODE_CLASS_MAPPINGS = { "FeatherMask": FeatherMask, "GrowMask": GrowMask, "ThresholdMask": ThresholdMask, + "MaskPreview": MaskPreview } NODE_DISPLAY_NAME_MAPPINGS = { "ImageToMask": "Convert Image to Mask", + "MaskToImage": "Convert Mask to Image", } diff --git a/comfy_extras/nodes_webcam.py b/comfy_extras/nodes_webcam.py new file mode 100644 index 000000000..5bf80b4c6 --- /dev/null +++ b/comfy_extras/nodes_webcam.py @@ -0,0 +1,37 @@ +import nodes +import folder_paths + +MAX_RESOLUTION = nodes.MAX_RESOLUTION + + +class WebcamCapture(nodes.LoadImage): + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image": ("WEBCAM", {}), + "width": ("INT", {"default": 0, "min": 0, "max": MAX_RESOLUTION, "step": 1}), + "height": ("INT", {"default": 0, "min": 0, "max": MAX_RESOLUTION, "step": 1}), + "capture_on_queue": ("BOOLEAN", {"default": True}), + } + } + RETURN_TYPES = ("IMAGE",) + FUNCTION = "load_capture" + + CATEGORY = "image" + + def load_capture(self, image, **kwargs): + return super().load_image(folder_paths.get_annotated_filepath(image)) + + @classmethod + def IS_CHANGED(cls, image, width, height, capture_on_queue): + return super().IS_CHANGED(image) + + +NODE_CLASS_MAPPINGS = { + "WebcamCapture": WebcamCapture, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "WebcamCapture": "Webcam Capture", +} diff --git a/comfy_extras/v3/nodes_images.py b/comfy_extras/v3/nodes_images.py index bebabb967..94891c1c4 100644 --- a/comfy_extras/v3/nodes_images.py +++ b/comfy_extras/v3/nodes_images.py @@ -13,12 +13,12 @@ import folder_paths import node_helpers -class SaveImage(io.ComfyNodeV3): +class SaveImage_V3(io.ComfyNodeV3): @classmethod def DEFINE_SCHEMA(cls): return io.SchemaV3( - node_id="SaveImage", - display_name="Save Image", + node_id="SaveImage_V3", + display_name="Save Image _V3", description="Saves the input images to your ComfyUI output directory.", category="image", inputs=[ @@ -68,12 +68,12 @@ class SaveImage(io.ComfyNodeV3): return io.NodeOutput(ui={"images": results}) -class PreviewImage(io.ComfyNodeV3): +class PreviewImage_V3(io.ComfyNodeV3): @classmethod def DEFINE_SCHEMA(cls): return io.SchemaV3( - node_id="PreviewImage", - display_name="Preview Image", + node_id="PreviewImage_V3", + display_name="Preview Image _V3", description="Preview the input images.", category="image", inputs=[ @@ -92,12 +92,12 @@ class PreviewImage(io.ComfyNodeV3): return io.NodeOutput(ui=ui.PreviewImage(images)) -class LoadImage(io.ComfyNodeV3): +class LoadImage_V3(io.ComfyNodeV3): @classmethod def DEFINE_SCHEMA(cls): return io.SchemaV3( - node_id="LoadImage", - display_name="Load Image", + node_id="LoadImage_V3", + display_name="Load Image _V3", category="image", inputs=[ io.Combo.Input( @@ -186,12 +186,12 @@ class LoadImage(io.ComfyNodeV3): return True -class LoadImageOutput(io.ComfyNodeV3): +class LoadImageOutput_V3(io.ComfyNodeV3): @classmethod def DEFINE_SCHEMA(cls): return io.SchemaV3( - node_id="LoadImageOutput", - display_name="Load Image (from Outputs)", + node_id="LoadImageOutput_V3", + display_name="Load Image (from Outputs) _V3", description="Load an image from the output folder. " "When the refresh button is clicked, the node will update the image list " "and automatically select the first image, allowing for easy iteration.", @@ -283,8 +283,8 @@ class LoadImageOutput(io.ComfyNodeV3): NODES_LIST: list[type[io.ComfyNodeV3]] = [ - SaveImage, - PreviewImage, - LoadImage, - LoadImageOutput, + SaveImage_V3, + PreviewImage_V3, + LoadImage_V3, + LoadImageOutput_V3, ] diff --git a/comfy_extras/v3/nodes_mask.py b/comfy_extras/v3/nodes_mask.py index df34d5662..a3ff64e72 100644 --- a/comfy_extras/v3/nodes_mask.py +++ b/comfy_extras/v3/nodes_mask.py @@ -1,7 +1,7 @@ from comfy_api.v3 import io, ui -class MaskPreview(io.ComfyNodeV3): +class MaskPreview_V3(io.ComfyNodeV3): """Mask Preview - original implement in ComfyUI_essentials. https://github.com/cubiq/ComfyUI_essentials/blob/9d9f4bedfc9f0321c19faf71855e228c93bd0dc9/mask.py#L81 @@ -11,8 +11,8 @@ class MaskPreview(io.ComfyNodeV3): @classmethod def DEFINE_SCHEMA(cls): return io.SchemaV3( - node_id="MaskPreview", - display_name="Convert Mask to Image", + node_id="MaskPreview_V3", + display_name="Convert Mask to Image _V3", category="mask", inputs=[ io.Mask.Input( @@ -29,4 +29,4 @@ class MaskPreview(io.ComfyNodeV3): return io.NodeOutput(ui=ui.PreviewMask(masks)) -NODES_LIST: list[type[io.ComfyNodeV3]] = [MaskPreview] +NODES_LIST: list[type[io.ComfyNodeV3]] = [MaskPreview_V3] diff --git a/comfy_extras/v3/nodes_webcam.py b/comfy_extras/v3/nodes_webcam.py index 9c25aa633..2624cca1b 100644 --- a/comfy_extras/v3/nodes_webcam.py +++ b/comfy_extras/v3/nodes_webcam.py @@ -13,12 +13,12 @@ import node_helpers MAX_RESOLUTION = nodes.MAX_RESOLUTION -class WebcamCapture(io.ComfyNodeV3): +class WebcamCapture_V3(io.ComfyNodeV3): @classmethod def DEFINE_SCHEMA(cls): return io.SchemaV3( - node_id="WebcamCapture", - display_name="Webcam Capture", + node_id="WebcamCapture_V3", + display_name="Webcam Capture _V3", category="image", inputs=[ io.Webcam.Input( @@ -114,4 +114,4 @@ class WebcamCapture(io.ComfyNodeV3): return True -NODES_LIST: list[type[io.ComfyNodeV3]] = [WebcamCapture] +NODES_LIST: list[type[io.ComfyNodeV3]] = [WebcamCapture_V3] diff --git a/nodes.py b/nodes.py index 49887ddc7..75df9d070 100644 --- a/nodes.py +++ b/nodes.py @@ -8,9 +8,11 @@ import hashlib import traceback import math import time +import random import logging -from PIL import Image, ImageOps +from PIL import Image, ImageOps, ImageSequence +from PIL.PngImagePlugin import PngInfo import numpy as np import safetensors.torch @@ -1548,6 +1550,150 @@ class KSamplerAdvanced: disable_noise = True return common_ksampler(model, noise_seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image, denoise=denoise, disable_noise=disable_noise, start_step=start_at_step, last_step=end_at_step, force_full_denoise=force_full_denoise) +class SaveImage: + def __init__(self): + self.output_dir = folder_paths.get_output_directory() + self.type = "output" + self.prefix_append = "" + self.compress_level = 4 + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "images": ("IMAGE", {"tooltip": "The images to save."}), + "filename_prefix": ("STRING", {"default": "ComfyUI", "tooltip": "The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes."}) + }, + "hidden": { + "prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO" + }, + } + + RETURN_TYPES = () + FUNCTION = "save_images" + + OUTPUT_NODE = True + + CATEGORY = "image" + DESCRIPTION = "Saves the input images to your ComfyUI output directory." + + def save_images(self, images, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): + filename_prefix += self.prefix_append + 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 (batch_number, image) in enumerate(images): + i = 255. * image.cpu().numpy() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + metadata = None + if not args.disable_metadata: + 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])) + + filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) + file = f"{filename_with_batch_num}_{counter:05}_.png" + img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=self.compress_level) + results.append({ + "filename": file, + "subfolder": subfolder, + "type": self.type + }) + counter += 1 + + return { "ui": { "images": results } } + +class PreviewImage(SaveImage): + def __init__(self): + self.output_dir = folder_paths.get_temp_directory() + self.type = "temp" + self.prefix_append = "_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5)) + self.compress_level = 1 + + @classmethod + def INPUT_TYPES(s): + return {"required": + {"images": ("IMAGE", ), }, + "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, + } + +class LoadImage: + @classmethod + def INPUT_TYPES(s): + input_dir = folder_paths.get_input_directory() + files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] + files = folder_paths.filter_files_content_types(files, ["image"]) + return {"required": + {"image": (sorted(files), {"image_upload": True})}, + } + + CATEGORY = "image" + + RETURN_TYPES = ("IMAGE", "MASK") + FUNCTION = "load_image" + def load_image(self, image): + image_path = folder_paths.get_annotated_filepath(image) + + img = node_helpers.pillow(Image.open, image_path) + + output_images = [] + output_masks = [] + w, h = None, None + + excluded_formats = ['MPO'] + + for i in ImageSequence.Iterator(img): + i = node_helpers.pillow(ImageOps.exif_transpose, i) + + if i.mode == 'I': + i = i.point(lambda i: i * (1 / 255)) + image = i.convert("RGB") + + if len(output_images) == 0: + w = image.size[0] + h = image.size[1] + + if image.size[0] != w or image.size[1] != h: + continue + + image = np.array(image).astype(np.float32) / 255.0 + image = torch.from_numpy(image)[None,] + if 'A' in i.getbands(): + mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0 + mask = 1. - torch.from_numpy(mask) + elif i.mode == 'P' and 'transparency' in i.info: + mask = np.array(i.convert('RGBA').getchannel('A')).astype(np.float32) / 255.0 + mask = 1. - torch.from_numpy(mask) + else: + mask = torch.zeros((64,64), dtype=torch.float32, device="cpu") + output_images.append(image) + output_masks.append(mask.unsqueeze(0)) + + if len(output_images) > 1 and img.format not in excluded_formats: + output_image = torch.cat(output_images, dim=0) + output_mask = torch.cat(output_masks, dim=0) + else: + output_image = output_images[0] + output_mask = output_masks[0] + + return (output_image, output_mask) + + @classmethod + def IS_CHANGED(s, image): + image_path = folder_paths.get_annotated_filepath(image) + m = hashlib.sha256() + with open(image_path, 'rb') as f: + m.update(f.read()) + return m.digest().hex() + + @classmethod + def VALIDATE_INPUTS(s, image): + if not folder_paths.exists_annotated_filepath(image): + return "Invalid image file: {}".format(image) + + return True class LoadImageMask: _color_channels = ["alpha", "red", "green", "blue"] @@ -1599,6 +1745,28 @@ class LoadImageMask: return True +class LoadImageOutput(LoadImage): + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image": ("COMBO", { + "image_upload": True, + "image_folder": "output", + "remote": { + "route": "/internal/files/output", + "refresh_button": True, + "control_after_refresh": "first", + }, + }), + } + } + + DESCRIPTION = "Load an image from the output folder. When the refresh button is clicked, the node will update the image list and automatically select the first image, allowing for easy iteration." + EXPERIMENTAL = True + FUNCTION = "load_image" + + class ImageScale: upscale_methods = ["nearest-exact", "bilinear", "area", "bicubic", "lanczos"] crop_methods = ["disabled", "center"] @@ -1781,7 +1949,11 @@ NODE_CLASS_MAPPINGS = { "LatentUpscaleBy": LatentUpscaleBy, "LatentFromBatch": LatentFromBatch, "RepeatLatentBatch": RepeatLatentBatch, + "SaveImage": SaveImage, + "PreviewImage": PreviewImage, + "LoadImage": LoadImage, "LoadImageMask": LoadImageMask, + "LoadImageOutput": LoadImageOutput, "ImageScale": ImageScale, "ImageScaleBy": ImageScaleBy, "ImageInvert": ImageInvert, @@ -1878,7 +2050,11 @@ NODE_DISPLAY_NAME_MAPPINGS = { "LatentFromBatch" : "Latent From Batch", "RepeatLatentBatch": "Repeat Latent Batch", # Image + "SaveImage": "Save Image", + "PreviewImage": "Preview Image", + "LoadImage": "Load Image", "LoadImageMask": "Load Image (as Mask)", + "LoadImageOutput": "Load Image (from Outputs)", "ImageScale": "Upscale Image", "ImageScaleBy": "Upscale Image By", "ImageUpscaleWithModel": "Upscale Image (using Model)", @@ -2088,6 +2264,7 @@ def init_builtin_extra_nodes(): "nodes_align_your_steps.py", "nodes_attention_multiply.py", "nodes_advanced_samplers.py", + "nodes_webcam.py", "nodes_audio.py", "nodes_sd3.py", "nodes_gits.py",