|
4 | 4 | import os
|
5 | 5 | import random
|
6 | 6 | from io import BytesIO
|
| 7 | +from typing import Type |
7 | 8 |
|
8 | 9 | import av
|
9 | 10 | import numpy as np
|
| 11 | +import torch |
10 | 12 | import torchaudio
|
11 | 13 | from PIL import Image as PILImage
|
12 | 14 | from PIL.PngImagePlugin import PngInfo
|
@@ -35,32 +37,161 @@ def type(self) -> FolderType:
|
35 | 37 | return FolderType(self["type"])
|
36 | 38 |
|
37 | 39 |
|
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) |
57 | 121 | filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
|
58 | 122 | 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)) |
61 | 125 | 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) |
62 | 184 |
|
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 | + ) |
64 | 195 | self.animated = animated
|
65 | 196 |
|
66 | 197 | def as_dict(self):
|
|
0 commit comments