├── .gitignore ├── README.md ├── __init__.py ├── assets ├── example.png └── prompts.png └── save_image_extended.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Save Image Extended for ComfyUI 2 | 3 |

4 | 5 |

6 | 7 | Customize the information saved in file- and folder names. Use the values of sampler parameters as part of file or folder names.
Save data about the generated job (sampler, prompts, models) as entries in a `json` (text) file, in each folder. 8 | 9 | ## Installation 10 | 1. Open a terminal inside the 'custom_nodes' folder located in your ComfyUI installation dir 11 | 2. Use the `git clone` command to clone the [save-image-extended-comfyui](https://github.com/thedyze/save-image-extended-comfyui) repo. 12 | ``` 13 | git clone https://github.com/thedyze/save-image-extended-comfyui 14 | ``` 15 | 16 | ## Parameters / Usage 17 | 18 | - `filename_prefix` - String prefix added to files. 19 | - `filename_keys` - Comma separated string with sampler parameters to add to filename. E.g: `sampler_name, scheduler, cfg, denoise` Added to filename in written order. `resolution` also works. `vae_name` `model_name` (upscale model), `ckpt_name` (checkpoint) are others that should work. Here you can try any parameter name of any node. As long as the parameter has the same variable name defined in the `prompt` object they should work. The same applies to `foldername_keys`. 20 | - `foldername_prefix` - String prefix added to folders. 21 | - `foldername_keys` - Comma separated string with sampler parameters to add to foldername. 22 | - `delimiter` - Delimiter character, either `underscore`, `dot`, or `comma`. 23 | - `save_job_data` - If enabled, saves information about each job as entries in a `jobs.json` text file, inside the generated folder. Mulitple options for saving `prompt`, `basic data`, `sampler settings`, `loaded models`. 24 | - `job_data_per_image` - When enabled, saves individual job data files for each image. 25 | - `job_custom_text` - Custom string to save along with the job data. Right click the node and convert to input to connect with another node. 26 | - `save_metadata` - Saves metadata into the image. 27 | - `counter_digits` - Number of digits used for the image counter. `3` = image_001.png. Will adjust the counter if files are deleted. Looks for the highest number in the folder, does not fill gaps. 28 | - `counter_position` - Image counter first or last in the filename. 29 | - `one_counter_per_folder` - Toggles the counter. Either one counter per folder, or resets when a parameter/prompt changes. 30 | - `image_preview` - Turns the image preview on and off. 31 | 32 | ## Node inputs 33 | 34 | - `images` - The generated images. 35 | - `positive_text_opt` - Optional string input for when using custom nodes for positive prompt text. 36 | - `negative_text_opt` - Optional string input for when using custom nodes for negative prompt text. 37 | 38 | ## Automatic folder names and date/time in names 39 | 40 | Convert the 'prefix' parameters to inputs (right click in the node and select e.g 'convert foldername_prefix to input'. Then attach the 'Get Date Time String' custom node from JPS to these inputs. This way a new folder name can be automatically generated each time generate is pressed. 41 | # 42 | Disclaimer: Does not check for illegal characters entered in file or folder names. May not be compatible with every other custom node, depending on changes in the `prompt` object. Tested and working with default samplers, Efficiency nodes and UltimateSDUpscale. 43 | # 44 |
45 | 46 |

47 | 48 |

49 | Happy saving! 50 |

51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from .save_image_extended import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS 2 | 3 | __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] 4 | -------------------------------------------------------------------------------- /assets/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedyze/save-image-extended-comfyui/5f104fddfc8281d9fd85d03e63c5f70454db6701/assets/example.png -------------------------------------------------------------------------------- /assets/prompts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedyze/save-image-extended-comfyui/5f104fddfc8281d9fd85d03e63c5f70454db6701/assets/prompts.png -------------------------------------------------------------------------------- /save_image_extended.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | import json 5 | from PIL import Image 6 | from PIL.PngImagePlugin import PngInfo 7 | import numpy as np 8 | import locale 9 | from datetime import datetime 10 | from pathlib import Path 11 | 12 | sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), 'comfy')) 13 | original_locale = locale.setlocale(locale.LC_TIME, '') 14 | 15 | import folder_paths 16 | 17 | class SaveImageExtended: 18 | def __init__(self): 19 | self.output_dir = folder_paths.get_output_directory() 20 | self.type = 'output' 21 | self.prefix_append = '' 22 | 23 | @classmethod 24 | def INPUT_TYPES(s): 25 | return { 26 | 'required': { 27 | 'images': ('IMAGE', ), 28 | 'filename_prefix': ('STRING', {'default': 'myFile'}), 29 | 'filename_keys': ('STRING', {'default': 'steps, cfg', 'multiline': False}), 30 | 'foldername_prefix': ('STRING', {'default': 'myPix'}), 31 | 'foldername_keys': ('STRING', {'default': 'sampler_name, scheduler', 'multiline': False}), 32 | 'delimiter': (['underscore','dot', 'comma'], {'default': 'underscore'}), 33 | 'save_job_data': (['disabled', 'prompt', 'basic, prompt', 'basic, sampler, prompt', 'basic, models, sampler, prompt'],{'default': 'basic, prompt'}), 34 | 'job_data_per_image': (['disabled', 'enabled'],{'default': 'disabled'}), 35 | 'job_custom_text': ('STRING', {'default': '', 'multiline': False}), 36 | 'save_metadata': (['disabled', 'enabled'], {'default': 'enabled'}), 37 | 'counter_digits': ([2, 3, 4, 5, 6], {'default': 3}), 38 | 'counter_position': (['first', 'last'], {'default': 'last'}), 39 | 'one_counter_per_folder': (['disabled', 'enabled'], {'default': 'disabled'}), 40 | 'image_preview': (['disabled', 'enabled'], {'default': 'enabled'}), 41 | }, 42 | "optional": { 43 | "positive_text_opt": ("STRING", {"forceInput": True}), 44 | "negative_text_opt": ("STRING", {"forceInput": True}), 45 | }, 46 | 'hidden': {'prompt': 'PROMPT', 'extra_pnginfo': 'EXTRA_PNGINFO'}, 47 | } 48 | 49 | RETURN_TYPES = () 50 | FUNCTION = 'save_images' 51 | OUTPUT_NODE = True 52 | CATEGORY = 'image' 53 | 54 | def get_subfolder_path(self, image_path, output_path): 55 | image_path = Path(image_path).resolve() 56 | output_path = Path(output_path).resolve() 57 | relative_path = image_path.relative_to(output_path) 58 | subfolder_path = relative_path.parent 59 | 60 | return str(subfolder_path) 61 | 62 | # Get current counter number from file names 63 | def get_latest_counter(self, one_counter_per_folder, folder_path, filename_prefix, counter_digits, counter_position='last'): 64 | counter = 1 65 | if not os.path.exists(folder_path): 66 | print(f"Folder {folder_path} does not exist, starting counter at 1.") 67 | return counter 68 | 69 | try: 70 | files = [f for f in os.listdir(folder_path) if f.endswith('.png')] 71 | if files: 72 | if counter_position == 'last': 73 | counters = [int(f[-(4 + counter_digits):-4]) if f[-(4 + counter_digits):-4].isdigit() else 0 for f in files if one_counter_per_folder == 'enabled' or f.startswith(filename_prefix)] 74 | elif counter_position == 'first': 75 | counters = [int(f[:counter_digits]) if f[:counter_digits].isdigit() else 0 for f in files if one_counter_per_folder == 'enabled' or f[counter_digits +1:].startswith(filename_prefix)] 76 | else: 77 | print("Invalid counter_position. Using 'last' as default.") 78 | counters = [int(f[-(4 + counter_digits):-4]) if f[-(4 + counter_digits):-4].isdigit() else 0 for f in files if one_counter_per_folder == 'enabled' or f.startswith(filename_prefix)] 79 | 80 | if counters: 81 | counter = max(counters) + 1 82 | 83 | except Exception as e: 84 | print(f"An error occurred while finding the latest counter: {e}") 85 | 86 | return counter 87 | 88 | @staticmethod 89 | def find_keys_recursively(d, keys_to_find, found_values): 90 | for key, value in d.items(): 91 | if key in keys_to_find: 92 | found_values[key] = value 93 | if isinstance(value, dict): 94 | SaveImageExtended.find_keys_recursively(value, keys_to_find, found_values) 95 | 96 | @staticmethod 97 | def remove_file_extension(value): 98 | if isinstance(value, str) and value.endswith('.safetensors'): 99 | base_value = os.path.basename(value) 100 | value = base_value[:-12] 101 | if isinstance(value, str) and value.endswith('.pt'): 102 | base_value = os.path.basename(value) 103 | value = base_value[:-3] 104 | 105 | return value 106 | 107 | @staticmethod 108 | def find_parameter_values(target_keys, obj, found_values=None): 109 | if found_values is None: 110 | found_values = {} 111 | 112 | if not isinstance(target_keys, list): 113 | target_keys = [target_keys] 114 | 115 | loras_string = '' 116 | for key, value in obj.items(): 117 | if 'loras' in target_keys: 118 | # Match both formats: lora_xx and lora_name_x 119 | if re.match(r'lora(_name)?(_\d+)?', key): 120 | if value.endswith('.safetensors'): 121 | value = SaveImageExtended.remove_file_extension(value) 122 | if value != 'None': 123 | loras_string += f'{value}, ' 124 | 125 | if key in target_keys: 126 | if (isinstance(value, str) and value.endswith('.safetensors')) or (isinstance(value, str) and value.endswith('.pt')): 127 | value = SaveImageExtended.remove_file_extension(value) 128 | found_values[key] = value 129 | 130 | if isinstance(value, dict): 131 | SaveImageExtended.find_parameter_values(target_keys, value, found_values) 132 | 133 | if 'loras' in target_keys and loras_string: 134 | found_values['loras'] = loras_string.strip(', ') 135 | 136 | if len(target_keys) == 1: 137 | return found_values.get(target_keys[0], None) 138 | 139 | return found_values 140 | 141 | @staticmethod 142 | def generate_custom_name(keys_to_extract, prefix, delimiter_char, resolution, prompt): 143 | custom_name = prefix 144 | 145 | if prompt is not None and len(keys_to_extract) > 0: 146 | found_values = {'resolution': resolution} 147 | SaveImageExtended.find_keys_recursively(prompt, keys_to_extract, found_values) 148 | for key in keys_to_extract: 149 | value = found_values.get(key) 150 | if value is not None: 151 | if key == 'cfg' or key =='denoise': 152 | try: 153 | value = round(float(value), 1) 154 | except ValueError: 155 | pass 156 | 157 | if (isinstance(value, str) and value.endswith('.safetensors')) or (isinstance(value, str) and value.endswith('.pt')): 158 | value = SaveImageExtended.remove_file_extension(value) 159 | 160 | custom_name += f'{delimiter_char}{value}' 161 | 162 | return custom_name.strip(delimiter_char) 163 | 164 | @staticmethod 165 | def save_job_to_json(save_job_data, prompt, filename_prefix, positive_text_opt, negative_text_opt, job_custom_text, resolution, output_path, filename): 166 | prompt_keys_to_save = {} 167 | if 'basic' in save_job_data: 168 | if len(filename_prefix) > 0: 169 | prompt_keys_to_save['filename_prefix'] = filename_prefix 170 | prompt_keys_to_save['resolution'] = resolution 171 | if len(job_custom_text) > 0: 172 | prompt_keys_to_save['custom_text'] = job_custom_text 173 | 174 | if 'models' in save_job_data: 175 | models = SaveImageExtended.find_parameter_values(['ckpt_name', 'loras', 'vae_name', 'model_name'], prompt) 176 | if models.get('ckpt_name'): 177 | prompt_keys_to_save['checkpoint'] = models['ckpt_name'] 178 | if models.get('loras'): 179 | prompt_keys_to_save['loras'] = models['loras'] 180 | if models.get('vae_name'): 181 | prompt_keys_to_save['vae'] = models['vae_name'] 182 | if models.get('model_name'): 183 | prompt_keys_to_save['upscale_model'] = models['model_name'] 184 | 185 | 186 | 187 | if 'sampler' in save_job_data: 188 | prompt_keys_to_save['sampler_parameters'] = SaveImageExtended.find_parameter_values(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler', 'denoise'], prompt) 189 | 190 | if 'prompt' in save_job_data: 191 | if positive_text_opt is not None: 192 | if not (isinstance(positive_text_opt, list) and 193 | len(positive_text_opt) == 2 and 194 | isinstance(positive_text_opt[0], str) and 195 | len(positive_text_opt[0]) < 6 and 196 | isinstance(positive_text_opt[1], (int, float))): 197 | prompt_keys_to_save['positive_prompt'] = positive_text_opt 198 | 199 | if negative_text_opt is not None: 200 | if not (isinstance(positive_text_opt, list) and len(negative_text_opt) == 2 and isinstance(negative_text_opt[0], str) and isinstance(negative_text_opt[1], (int, float))): 201 | prompt_keys_to_save['negative_prompt'] = negative_text_opt 202 | 203 | #If no user input for prompts 204 | if positive_text_opt is None and negative_text_opt is None: 205 | if prompt is not None: 206 | for key in prompt: 207 | class_type = prompt[key].get('class_type', None) 208 | inputs = prompt[key].get('inputs', {}) 209 | 210 | # Efficiency Loaders prompt structure 211 | if class_type == 'Efficient Loader' or class_type == 'Eff. Loader SDXL': 212 | if 'positive' in inputs and 'negative' in inputs: 213 | prompt_keys_to_save['positive_prompt'] = inputs.get('positive') 214 | prompt_keys_to_save['negative_prompt'] = inputs.get('negative') 215 | 216 | # KSampler/UltimateSDUpscale prompt structure 217 | elif class_type == 'KSampler' or class_type == 'KSamplerAdvanced' or class_type == 'UltimateSDUpscale': 218 | positive_ref = inputs.get('positive', [])[0] if 'positive' in inputs else None 219 | negative_ref = inputs.get('negative', [])[0] if 'negative' in inputs else None 220 | 221 | positive_text = prompt.get(str(positive_ref), {}).get('inputs', {}).get('text', None) 222 | negative_text = prompt.get(str(negative_ref), {}).get('inputs', {}).get('text', None) 223 | 224 | # If we get non text inputs 225 | if positive_text is not None: 226 | if isinstance(positive_text, list): 227 | if len(positive_text) == 2: 228 | if isinstance(positive_text[0], str) and len(positive_text[0]) < 6: 229 | if isinstance(positive_text[1], (int, float)): 230 | continue 231 | prompt_keys_to_save['positive_prompt'] = positive_text 232 | 233 | if negative_text is not None: 234 | if isinstance(negative_text, list): 235 | if len(negative_text) == 2: 236 | if isinstance(negative_text[0], str) and len(negative_text[0]) < 6: 237 | if isinstance(negative_text[1], (int, float)): 238 | continue 239 | prompt_keys_to_save['negative_prompt'] = negative_text 240 | 241 | # Append data and save 242 | json_file_path = os.path.join(output_path, filename) 243 | existing_data = {} 244 | if os.path.exists(json_file_path): 245 | try: 246 | with open(json_file_path, 'r') as f: 247 | existing_data = json.load(f) 248 | except json.JSONDecodeError: 249 | print(f"The file {json_file_path} is empty or malformed. Initializing with empty data.") 250 | existing_data = {} 251 | 252 | timestamp = datetime.now().strftime('%c') 253 | new_entry = {} 254 | new_entry[timestamp] = prompt_keys_to_save 255 | existing_data.update(new_entry) 256 | 257 | with open(json_file_path, 'w') as f: 258 | json.dump(existing_data, f, indent=4) 259 | 260 | 261 | def save_images(self, 262 | counter_digits, 263 | counter_position, 264 | one_counter_per_folder, 265 | delimiter, 266 | filename_keys, 267 | foldername_keys, 268 | images, 269 | image_preview, 270 | save_job_data, 271 | job_data_per_image, 272 | job_custom_text, 273 | save_metadata, 274 | filename_prefix='', 275 | foldername_prefix='', 276 | extra_pnginfo=None, 277 | negative_text_opt=None, 278 | positive_text_opt=None, 279 | prompt=None 280 | ): 281 | 282 | delimiter_char = "_" if delimiter =='underscore' else '.' if delimiter =='dot' else ',' 283 | 284 | # Get set resolution value 285 | i = 255. * images[0].cpu().numpy() 286 | img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) 287 | resolution = f'{img.width}x{img.height}' 288 | 289 | filename_keys_to_extract = [item.strip() for item in filename_keys.split(',')] 290 | foldername_keys_to_extract = [item.strip() for item in foldername_keys.split(',')] 291 | custom_filename = SaveImageExtended.generate_custom_name(filename_keys_to_extract, filename_prefix, delimiter_char, resolution, prompt) 292 | custom_foldername = SaveImageExtended.generate_custom_name(foldername_keys_to_extract, foldername_prefix, delimiter_char, resolution, prompt) 293 | 294 | # Create and save images 295 | try: 296 | full_output_folder, filename, _, _, custom_filename = folder_paths.get_save_image_path(custom_filename, self.output_dir, images[0].shape[1], images[0].shape[0]) 297 | output_path = os.path.join(full_output_folder, custom_foldername) 298 | os.makedirs(output_path, exist_ok=True) 299 | counter = self.get_latest_counter(one_counter_per_folder, output_path, filename, counter_digits, counter_position) 300 | 301 | results = list() 302 | for image in images: 303 | i = 255. * image.cpu().numpy() 304 | img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) 305 | metadata = None 306 | if save_metadata == 'enabled': 307 | metadata = PngInfo() 308 | if prompt is not None: 309 | metadata.add_text('prompt', json.dumps(prompt)) 310 | if extra_pnginfo is not None: 311 | for x in extra_pnginfo: 312 | metadata.add_text(x, json.dumps(extra_pnginfo[x])) 313 | 314 | if counter_position == 'last': 315 | file = f'{filename}{delimiter_char}{counter:0{counter_digits}}.png' 316 | else: 317 | file = f'{counter:0{counter_digits}}{delimiter_char}{filename}.png' 318 | 319 | image_path = os.path.join(output_path, file) 320 | img.save(image_path, pnginfo=metadata, compress_level=4) 321 | 322 | if save_job_data != 'disabled' and job_data_per_image =='enabled': 323 | SaveImageExtended.save_job_to_json(save_job_data, prompt, filename_prefix, positive_text_opt, negative_text_opt, job_custom_text, resolution, output_path, f'{file.strip(".png")}.json') 324 | 325 | subfolder = self.get_subfolder_path(image_path, self.output_dir) 326 | results.append({ 'filename': file, 'subfolder': subfolder, 'type': self.type}) 327 | counter += 1 328 | 329 | if save_job_data != 'disabled' and job_data_per_image =='disabled': 330 | SaveImageExtended.save_job_to_json(save_job_data, prompt, filename_prefix, positive_text_opt, negative_text_opt, job_custom_text, resolution, output_path, 'jobs.json') 331 | 332 | except OSError as e: 333 | print(f'An error occurred while creating the subfolder or saving the image: {e}') 334 | else: 335 | if image_preview == 'disabled': 336 | results = list() 337 | return { 'ui': { 'images': results } } 338 | 339 | NODE_CLASS_MAPPINGS = { 340 | 'SaveImageExtended': SaveImageExtended, 341 | } 342 | 343 | NODE_DISPLAY_NAME_MAPPINGS = { 344 | 'SaveImageExtended': 'Save Image Extended', 345 | } 346 | --------------------------------------------------------------------------------