Merge pull request #8833 from bigcat88/v3-load-save-nodes-replacement

[v3] Migrate LoadImage and SaveImage nodes to v3 schema
This commit is contained in:
Jedrzej Kosinski 2025-07-09 22:20:17 -07:00 committed by GitHub
commit 19bb231fbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 469 additions and 8 deletions

View File

@ -202,13 +202,13 @@ class WidgetInputV3(InputV3):
return super().as_dict_V1() | prune_dict({ return super().as_dict_V1() | prune_dict({
"default": self.default, "default": self.default,
"socketless": self.socketless, "socketless": self.socketless,
"widgetType": self.widgetType,
"forceInput": self.force_input, "forceInput": self.force_input,
}) })
def get_io_type_V1(self): def get_io_type_V1(self):
return self.widgetType if self.widgetType is not None else super().get_io_type_V1() return self.widgetType if self.widgetType is not None else super().get_io_type_V1()
class OutputV3(IO_V3): class OutputV3(IO_V3):
def __init__(self, id: str, display_name: str=None, tooltip: str=None, def __init__(self, id: str, display_name: str=None, tooltip: str=None,
is_output_list=False): is_output_list=False):
@ -372,7 +372,7 @@ class String(ComfyTypeIO):
class Input(WidgetInputV3): class Input(WidgetInputV3):
'''String input.''' '''String input.'''
def __init__(self, id: str, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, 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): socketless: bool=None, force_input: bool=None):
super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, self.io_type, force_input) super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, self.io_type, force_input)
self.multiline = multiline self.multiline = multiline
@ -389,11 +389,11 @@ class String(ComfyTypeIO):
class Combo(ComfyType): class Combo(ComfyType):
Type = str Type = str
class Input(WidgetInputV3): class Input(WidgetInputV3):
'''Combo input (dropdown).''' """Combo input (dropdown)."""
Type = str Type = str
def __init__(self, id: str, options: list[str]=None, display_name: str=None, optional=False, tooltip: str=None, lazy: bool=None, 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, 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, remote: RemoteOptions=None,
socketless: bool=None): socketless: bool=None):
super().__init__(id, display_name, optional, tooltip, lazy, default, socketless, self.io_type) 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.control_after_generate = control_after_generate
self.image_upload = image_upload self.image_upload = image_upload
self.image_folder = image_folder self.image_folder = image_folder
self.content_types = content_types
self.remote = remote self.remote = remote
self.default: str self.default: str
@ -412,6 +413,7 @@ class Combo(ComfyType):
"control_after_generate": self.control_after_generate, "control_after_generate": self.control_after_generate,
"image_upload": self.image_upload, "image_upload": self.image_upload,
"image_folder": self.image_folder.value if self.image_folder else None, "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, "remote": self.remote.as_dict() if self.remote else None,
}) })
@ -443,6 +445,20 @@ class MultiCombo(ComfyType):
class Image(ComfyTypeIO): class Image(ComfyTypeIO):
Type = torch.Tensor 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") @comfytype(io_type="MASK")
class Mask(ComfyTypeIO): class Mask(ComfyTypeIO):
Type = torch.Tensor Type = torch.Tensor
@ -969,7 +985,7 @@ class SchemaV3:
issues.append(f"Ids must be unique between inputs and outputs, but {intersection} are not.") issues.append(f"Ids must be unique between inputs and outputs, but {intersection} are not.")
if len(issues) > 0: if len(issues) > 0:
raise ValueError("\n".join(issues)) raise ValueError("\n".join(issues))
def finalize(self): def finalize(self):
"""Add hidden based on selected schema options.""" """Add hidden based on selected schema options."""
# if is an api_node, will need key-related hidden # if is an api_node, will need key-related hidden

View File

@ -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,
]

View File

@ -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]

View File

@ -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]

View File

@ -321,7 +321,10 @@ def get_output_data(obj, input_data_all, execution_block_cb=None, pre_execute_cb
elif isinstance(r, NodeOutput): elif isinstance(r, NodeOutput):
# V3 # V3
if r.ui is not None: 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: if r.expand is not None:
has_subgraph = True has_subgraph = True
new_graph = r.expand new_graph = r.expand

View File

@ -26,7 +26,7 @@ import comfy.sd
import comfy.utils import comfy.utils
import comfy.controlnet import comfy.controlnet
from comfy.comfy_types import IO, ComfyNodeABC, InputTypeDict, FileLocator 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 import comfy.clip_vision
@ -2162,7 +2162,7 @@ def load_custom_node(module_path: str, ignore=set(), module_parent="custom_nodes
# V3 node definition # V3 node definition
elif getattr(module, "NODES_LIST", None) is not None: elif getattr(module, "NODES_LIST", None) is not None:
for node_cls in module.NODES_LIST: for node_cls in module.NODES_LIST:
node_cls: ComfyNodeV3 node_cls: io.ComfyNodeV3
schema = node_cls.GET_SCHEMA() schema = node_cls.GET_SCHEMA()
if schema.node_id not in ignore: if schema.node_id not in ignore:
NODE_CLASS_MAPPINGS[schema.node_id] = node_cls NODE_CLASS_MAPPINGS[schema.node_id] = node_cls
@ -2299,6 +2299,9 @@ def init_builtin_extra_nodes():
"nodes_tcfg.py", "nodes_tcfg.py",
"nodes_v3_test.py", "nodes_v3_test.py",
"nodes_v1_test.py", "nodes_v1_test.py",
"v3/nodes_images.py",
"v3/nodes_mask.py",
"v3/nodes_webcam.py",
] ]
import_failed = [] import_failed = []