Skip to content

Commit 5afcca1

Browse files
authored
Merge pull request #8974 from bigcat88/v3/nodes/refactor-image-save
[V3] refactoring of the images save nodes
2 parents 517be3d + aae6088 commit 5afcca1

File tree

6 files changed

+192
-141
lines changed

6 files changed

+192
-141
lines changed

comfy_api/v3/ui.py

Lines changed: 153 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
import os
55
import random
66
from io import BytesIO
7+
from typing import Type
78

89
import av
910
import numpy as np
11+
import torch
1012
import torchaudio
1113
from PIL import Image as PILImage
1214
from PIL.PngImagePlugin import PngInfo
@@ -35,32 +37,161 @@ def type(self) -> FolderType:
3537
return FolderType(self["type"])
3638

3739

38-
class PreviewImage(_UIOutput):
39-
def __init__(self, image: Image.Type, animated: bool=False, cls: ComfyNodeV3=None, **kwargs):
40-
output_dir = folder_paths.get_temp_directory()
41-
prefix_append = "_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5))
42-
filename_prefix = "ComfyUI" + prefix_append
43-
44-
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, output_dir, image[0].shape[1], image[0].shape[0])
45-
results = list()
46-
for (batch_number, image) in enumerate(image):
47-
img = PILImage.fromarray(np.clip(255. * image.cpu().numpy(), 0, 255).astype(np.uint8))
48-
metadata = None
49-
if not args.disable_metadata and cls is not None:
50-
metadata = PngInfo()
51-
if cls.hidden.prompt is not None:
52-
metadata.add_text("prompt", json.dumps(cls.hidden.prompt))
53-
if cls.hidden.extra_pnginfo is not None:
54-
for x in cls.hidden.extra_pnginfo:
55-
metadata.add_text(x, json.dumps(cls.hidden.extra_pnginfo[x]))
56-
40+
def _get_directory_by_folder_type(folder_type: FolderType) -> str:
41+
if folder_type == FolderType.input:
42+
return folder_paths.get_input_directory()
43+
if folder_type == FolderType.output:
44+
return folder_paths.get_output_directory()
45+
return folder_paths.get_temp_directory()
46+
47+
48+
class ImageSaveHelper:
49+
"""A helper class with static methods to handle image saving and metadata."""
50+
51+
@staticmethod
52+
def _convert_tensor_to_pil(image_tensor: torch.Tensor) -> PILImage.Image:
53+
"""Converts a single torch tensor to a PIL Image."""
54+
return PILImage.fromarray(np.clip(255.0 * image_tensor.cpu().numpy(), 0, 255).astype(np.uint8))
55+
56+
@staticmethod
57+
def _create_png_metadata(cls: Type[ComfyNodeV3] | None) -> PngInfo | None:
58+
"""Creates a PngInfo object with prompt and extra_pnginfo."""
59+
if args.disable_metadata or cls is None or not cls.hidden:
60+
return None
61+
metadata = PngInfo()
62+
if cls.hidden.prompt:
63+
metadata.add_text("prompt", json.dumps(cls.hidden.prompt))
64+
if cls.hidden.extra_pnginfo:
65+
for x in cls.hidden.extra_pnginfo:
66+
metadata.add_text(x, json.dumps(cls.hidden.extra_pnginfo[x]))
67+
return metadata
68+
69+
@staticmethod
70+
def _create_animated_png_metadata(cls: Type[ComfyNodeV3] | None) -> PngInfo | None:
71+
"""Creates a PngInfo object with prompt and extra_pnginfo for animated PNGs (APNG)."""
72+
if args.disable_metadata or cls is None or not cls.hidden:
73+
return None
74+
metadata = PngInfo()
75+
if cls.hidden.prompt:
76+
metadata.add(
77+
b"comf",
78+
"prompt".encode("latin-1", "strict")
79+
+ b"\0"
80+
+ json.dumps(cls.hidden.prompt).encode("latin-1", "strict"),
81+
after_idat=True,
82+
)
83+
if cls.hidden.extra_pnginfo:
84+
for x in cls.hidden.extra_pnginfo:
85+
metadata.add(
86+
b"comf",
87+
x.encode("latin-1", "strict")
88+
+ b"\0"
89+
+ json.dumps(cls.hidden.extra_pnginfo[x]).encode("latin-1", "strict"),
90+
after_idat=True,
91+
)
92+
return metadata
93+
94+
@staticmethod
95+
def _create_webp_metadata(pil_image: PILImage.Image, cls: Type[ComfyNodeV3] | None) -> PILImage.Exif:
96+
"""Creates EXIF metadata bytes for WebP images."""
97+
exif_data = pil_image.getexif()
98+
if args.disable_metadata or cls is None or cls.hidden is None:
99+
return exif_data
100+
if cls.hidden.prompt is not None:
101+
exif_data[0x0110] = "prompt:{}".format(json.dumps(cls.hidden.prompt)) # EXIF 0x0110 = Model
102+
if cls.hidden.extra_pnginfo is not None:
103+
inital_exif_tag = 0x010F # EXIF 0x010f = Make
104+
for key, value in cls.hidden.extra_pnginfo.items():
105+
exif_data[inital_exif_tag] = "{}:{}".format(key, json.dumps(value))
106+
inital_exif_tag -= 1
107+
return exif_data
108+
109+
@staticmethod
110+
def save_images(
111+
images, filename_prefix: str, folder_type: FolderType, cls: Type[ComfyNodeV3] | None, compress_level = 4,
112+
) -> list[SavedResult]:
113+
"""Saves a batch of images as individual PNG files."""
114+
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
115+
filename_prefix, _get_directory_by_folder_type(folder_type), images[0].shape[1], images[0].shape[0]
116+
)
117+
results = []
118+
metadata = ImageSaveHelper._create_png_metadata(cls)
119+
for batch_number, image_tensor in enumerate(images):
120+
img = ImageSaveHelper._convert_tensor_to_pil(image_tensor)
57121
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
58122
file = f"{filename_with_batch_num}_{counter:05}_.png"
59-
img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=1)
60-
results.append(SavedResult(file, subfolder, FolderType.temp))
123+
img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=compress_level)
124+
results.append(SavedResult(file, subfolder, folder_type))
61125
counter += 1
126+
return results
127+
128+
@staticmethod
129+
def save_animated_png(
130+
images,
131+
filename_prefix: str,
132+
folder_type: FolderType,
133+
cls: Type[ComfyNodeV3] | None,
134+
fps: float,
135+
compress_level: int
136+
) -> SavedResult:
137+
"""Saves a batch of images as a single animated PNG."""
138+
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
139+
filename_prefix, _get_directory_by_folder_type(folder_type), images[0].shape[1], images[0].shape[0]
140+
)
141+
pil_images = [ImageSaveHelper._convert_tensor_to_pil(img) for img in images]
142+
metadata = ImageSaveHelper._create_animated_png_metadata(cls)
143+
file = f"{filename}_{counter:05}_.png"
144+
save_path = os.path.join(full_output_folder, file)
145+
pil_images[0].save(
146+
save_path,
147+
pnginfo=metadata,
148+
compress_level=compress_level,
149+
save_all=True,
150+
duration=int(1000.0 / fps),
151+
append_images=pil_images[1:],
152+
)
153+
return SavedResult(file, subfolder, folder_type)
154+
155+
@staticmethod
156+
def save_animated_webp(
157+
images,
158+
filename_prefix: str,
159+
folder_type: FolderType,
160+
cls: Type[ComfyNodeV3] | None,
161+
fps: float,
162+
lossless: bool,
163+
quality: int,
164+
method: int,
165+
) -> SavedResult:
166+
"""Saves a batch of images as a single animated WebP."""
167+
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
168+
filename_prefix, _get_directory_by_folder_type(folder_type), images[0].shape[1], images[0].shape[0]
169+
)
170+
pil_images = [ImageSaveHelper._convert_tensor_to_pil(img) for img in images]
171+
pil_exif = ImageSaveHelper._create_webp_metadata(pil_images[0], cls)
172+
file = f"{filename}_{counter:05}_.webp"
173+
pil_images[0].save(
174+
os.path.join(full_output_folder, file),
175+
save_all=True,
176+
duration=int(1000.0 / fps),
177+
append_images=pil_images[1:],
178+
exif=pil_exif,
179+
lossless=lossless,
180+
quality=quality,
181+
method=method,
182+
)
183+
return SavedResult(file, subfolder, folder_type)
62184

63-
self.values = results
185+
186+
class PreviewImage(_UIOutput):
187+
def __init__(self, image: Image.Type, animated: bool=False, cls: ComfyNodeV3=None, **kwargs):
188+
self.values = ImageSaveHelper.save_images(
189+
image,
190+
filename_prefix="ComfyUI_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for _ in range(5)),
191+
folder_type=FolderType.temp,
192+
cls=cls,
193+
compress_level=1,
194+
)
64195
self.animated = animated
65196

66197
def as_dict(self):

comfy_extras/v3/nodes_cfg.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def define_schema(cls) -> io.SchemaV3:
3131
io.Model.Input("model"),
3232
io.Float.Input("strength", default=1.0, min=0.0, max=100.0, step=0.01),
3333
],
34-
outputs=[io.Model.Output("patched_model", display_name="patched_model")],
34+
outputs=[io.Model.Output(display_name="patched_model")],
3535
is_experimental=True,
3636
)
3737

@@ -61,7 +61,7 @@ def define_schema(cls) -> io.SchemaV3:
6161
inputs=[
6262
io.Model.Input("model"),
6363
],
64-
outputs=[io.Model.Output("patched_model", display_name="patched_model")],
64+
outputs=[io.Model.Output(display_name="patched_model")],
6565
)
6666

6767
@classmethod

comfy_extras/v3/nodes_controlnet.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ def define_schema(cls):
2121
io.Vae.Input("vae", optional=True),
2222
],
2323
outputs=[
24-
io.Conditioning.Output("positive_out", display_name="positive"),
25-
io.Conditioning.Output("negative_out", display_name="negative"),
24+
io.Conditioning.Output(display_name="positive"),
25+
io.Conditioning.Output(display_name="negative"),
2626
],
2727
)
2828

@@ -71,7 +71,7 @@ def define_schema(cls):
7171
io.Combo.Input("type", options=["auto"] + list(UNION_CONTROLNET_TYPES.keys())),
7272
],
7373
outputs=[
74-
io.ControlNet.Output("control_net_out"),
74+
io.ControlNet.Output(),
7575
],
7676
)
7777

@@ -105,8 +105,8 @@ def define_schema(cls):
105105
io.Float.Input("end_percent", default=1.0, min=0.0, max=1.0, step=0.001),
106106
],
107107
outputs=[
108-
io.Conditioning.Output("positive_out", display_name="positive"),
109-
io.Conditioning.Output("negative_out", display_name="negative"),
108+
io.Conditioning.Output(display_name="positive"),
109+
io.Conditioning.Output(display_name="negative"),
110110
],
111111
)
112112

0 commit comments

Comments
 (0)