diff --git a/comfy_api/v3/io.py b/comfy_api/v3/io.py index c270fd625..f85b59253 100644 --- a/comfy_api/v3/io.py +++ b/comfy_api/v3/io.py @@ -202,13 +202,13 @@ class WidgetInputV3(InputV3): return super().as_dict_V1() | prune_dict({ "default": self.default, "socketless": self.socketless, - "widgetType": self.widgetType, "forceInput": self.force_input, }) def get_io_type_V1(self): 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 +372,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 +389,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 +402,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,6 +413,7 @@ 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, }) @@ -443,6 +445,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 @@ -969,7 +985,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/comfy_extras/v3/nodes_images.py b/comfy_extras/v3/nodes_images.py new file mode 100644 index 000000000..94891c1c4 --- /dev/null +++ b/comfy_extras/v3/nodes_images.py @@ -0,0 +1,290 @@ +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_V3(io.ComfyNodeV3): + @classmethod + def DEFINE_SCHEMA(cls): + return io.SchemaV3( + node_id="SaveImage_V3", + display_name="Save Image _V3", + 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( + filename_prefix, 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_V3(io.ComfyNodeV3): + @classmethod + def DEFINE_SCHEMA(cls): + return io.SchemaV3( + node_id="PreviewImage_V3", + display_name="Preview Image _V3", + 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_V3(io.ComfyNodeV3): + @classmethod + def DEFINE_SCHEMA(cls): + return io.SchemaV3( + node_id="LoadImage_V3", + display_name="Load Image _V3", + category="image", + inputs=[ + io.Combo.Input( + "image", + display_name="image", + image_upload=True, + image_folder=io.FolderType.input, + content_types=["image"], + options=cls.get_files_options(), + ), + ], + outputs=[ + io.Image.Output( + "IMAGE", + ), + io.Mask.Output( + "MASK", + ), + ], + ) + + @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)) + + 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 fingerprint_inputs(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_V3(io.ComfyNodeV3): + @classmethod + def DEFINE_SCHEMA(cls): + return io.SchemaV3( + 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.", + 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 fingerprint_inputs(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_V3, + PreviewImage_V3, + LoadImage_V3, + LoadImageOutput_V3, +] diff --git a/comfy_extras/v3/nodes_mask.py b/comfy_extras/v3/nodes_mask.py new file mode 100644 index 000000000..a3ff64e72 --- /dev/null +++ b/comfy_extras/v3/nodes_mask.py @@ -0,0 +1,32 @@ +from comfy_api.v3 import io, ui + + +class MaskPreview_V3(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_V3", + display_name="Convert Mask to Image _V3", + 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_V3] diff --git a/comfy_extras/v3/nodes_webcam.py b/comfy_extras/v3/nodes_webcam.py new file mode 100644 index 000000000..2624cca1b --- /dev/null +++ b/comfy_extras/v3/nodes_webcam.py @@ -0,0 +1,117 @@ +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_V3(io.ComfyNodeV3): + @classmethod + def DEFINE_SCHEMA(cls): + return io.SchemaV3( + node_id="WebcamCapture_V3", + display_name="Webcam Capture _V3", + 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 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): + if not folder_paths.exists_annotated_filepath(image): + return "Invalid image file: {}".format(image) + return True + + +NODES_LIST: list[type[io.ComfyNodeV3]] = [WebcamCapture_V3] 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 60308011b..4e4dae917 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 @@ -2162,7 +2162,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 @@ -2299,6 +2299,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 = []