Compare commits

...
5 Commits
Author SHA1 Message Date
audioscavenger 54e9183ecd release 2.61 quality
modified:   README.md
	modified:   save_image_extended.py
2024-05-23 14:47:05 -07:00
audioscavenger 669e7a9d03 release 2.60 jpeg gif tiff bmp
modified:   README.md
	modified:   save_image_extended.py
2024-05-23 12:59:37 -07:00
audioscavenger ab2410ba95 release 2.51 datetime
modified:   README.md
    	modified:   save_image_extended.py
	modified:   README.md
	modified:   save_image_extended.py
2024-05-23 11:53:33 -07:00
audioscavenger 6369fb315f release 2.51 datetime
modified:   README.md
	modified:   save_image_extended.py
2024-05-23 11:26:37 -07:00
audioscavenger fc67d2b6b0 release 2.50 JXL
modified:   README.md
    	modified:   assets/save-image-extended-comfyui-example.png
    	modified:   save_image_extended.py
	modified:   README.md
	modified:   save_image_extended.py
2024-05-23 09:41:38 -07:00
4 changed files with 129 additions and 67 deletions
+43 -16
View File
@@ -1,5 +1,5 @@
# 💾 Save Image Extended for ComfyUI
**AVIF** / **WebP** / **JXL** support!
Supports those extensions: **AVIF WebP JXL jpg jpeg png gif tiff bmp**
<p align="center">
<img src="assets/save-image-extended-comfyui-example.png" />
@@ -48,6 +48,7 @@ git clone https://github.com/audioscavenger/save-image-extended-comfyui
```
## Parameters / Usage
| Attribute | Description |
| --- | --- |
| `filename_prefix` | String prefix added to files. |
@@ -67,6 +68,8 @@ git clone https://github.com/audioscavenger/save-image-extended-comfyui
Unknown key names in `filename_keys` and `foldername_keys` are treated as custom strings: if you enter `wrongNumber.attribute`, you will get `attribute` in your filename.
Datetime UNIX format is now included! `%Y-%m-%d` or `%F` etc
## Node inputs
- `images` - The generated images.
@@ -75,35 +78,47 @@ Optional:
- `positive_text_opt` - Optional string input for when using custom nodes for positive prompt text.
- `negative_text_opt` - Optional string input for when using custom nodes for negative prompt text.
## Automatic folder names and date/time in names
## Automatic date/time conversion in file/folder names
Converts [unix datetime formats](https://www.man7.org/linux/man-pages/man1/date.1.html):
| Unix datetime | Example | Comment |
| --- | --- | --- |
| %F or %Y-%m-%d | 2024-05-22 | |
| %D | 05/22/24 | This effectively creates subfolders |
| %F %H-%M-%S | 2024-05-22 09-13-58 | |
| %Y/%V | 2024/21 | year subfolder / ISO week number subfolder |
<p align="center">
<img src="assets/save-image-extended-comfyui-example-2.png" />
</p>
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.
#
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, UltimateSDUpscale, ComfyRoll, composer, NegiTools, and 45 other nodes.
#
Quality and compression settings:
Quality and compression settings: default is 75, 100 will activate **lossless** for AVIF and WEBP only.
* AVIF quality is fixed at 60.
* WebP quality is fixed at 75.
* JPEG quality is fixed at 91.
* JXL quality is fixed at 91.
* PNG is maxed compressed (9)
Start a discussion or a poll if you want to change output quality, and add another attribute in the node. There's so many already, some complain about the node being too tall.
Quick comparison of size per extension, for the same picture, AVIF quality=60, WebP quality=75, jpeg quality=91 (and yes, AVIF still looks better then WebP):
<p align="center">
<img src="assets/save-image-extended-sizes-comparison.png" />
</p>
#
About extensions WebP AVIF JPEG JXL: ComfyUI can only load PNG and WebP atm... Feel free to ask ComfyUI team to add support for AVIF/jpeg/JXL!
The prompt is included under the **EXIF** tag `UserComment` (IFD0 / 0x9286) as defined [here](https://exiftool.org/TagNames/EXIF.html).
It is saved in this form: `UserComment = {"prompt": {"1": {"inputs": {...}}}}`.
The metadata Are included under the **EXIF** tags IFD0 as defined [here](https://exiftool.org/TagNames/EXIF.html)
| Data | EXIF | Name | String looks like |
| --- | --- | --- | --- |
| prompt | 0x9286 | UserComment | Prompt: {"5" ... } |
| workflow | 0x010e | ImageDescription | Workflow: {"5" ... } |
You can retrieve the prompt manually with [exiftool](https://exiftool.org/), here are some example commands:
- `exiftool -Parameters -Prompt -Workflow file.png`
- `exiftool -Parameters -UserComment -Prompt -Workflow file.{jpg|webp|avif}`
- `exiftool -Parameters -Prompt -Workflow image.png`
- `exiftool -Parameters -UserComment -ImageDescription image.{jpg|jpeg|webp|avif|jxl}`
#
Incompatible with *extended-saveimage-comfyui* - This node can be safely discarded, as it only offers WebP output. My node already adds JPEG and WebP.
@@ -142,8 +157,20 @@ TODO:
- [ ] offer to place the counter anywhere, as a key in filename_keys
- [ ] files can get out of order if prefixes change... that is expected, but is this what we want? another reason to have the counter place anywhere we want
### release 2.61 💾
- added quality input
### release 2.60 💾
- added extensions jpeg, gif, tiff, bmp
- added image_optimization (only for jpeg)
- now saves prompt and workflow separately into 0x9286/UserComment and 0x010e/ImageDescription
### release 2.51 💾
- added unix datetime formats
### release 2.50 💾
- added JXL support
- added 'BOOLEAN' support
### release 2.46 💾
- bug discovered with *rgthree's Ksampler Config*: using `steps_total` as an input to a Ksampler, will issue the index of the output, instead of the steps value ("\[nodeNum, 0]" instead of steps value). FIX: use `steps_total` instead of `steps`!
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+86 -51
View File
@@ -44,21 +44,24 @@ original_locale = locale.setlocale(locale.LC_TIME, '')
# class SaveImageExtended -------------------------------------------------------------------------------
class SaveImageExtended:
version = 2.50
version = 2.61
type = 'output'
png_compress_level = 9
lossless = False
avif_quality = 60
webp_quality = 75
jpeg_quality = 91
jxl_quality = 91
# tiff_quality has no impact unless you start meddling with the compressions algorithms
tiff_quality = 91
# optimize_image only works for jpeg, with like just 2% reduction in size
optimize_image = True
filename_prefix = 'ComfyUI'
filename_keys = 'sampler_name, scheduler, cfg, steps'
filename_keys = 'sampler_name, cfg, steps, %F %H-%M-%S'
foldername_prefix = ''
foldername_keys = 'ckpt_name, ./subfolder'
delimiter = '_'
foldername_keys = 'ckpt_name'
delimiter = '-'
save_job_data = 'basic, models, sampler, prompt'
job_data_per_image = False
job_custom_text = ''
@@ -70,7 +73,8 @@ class SaveImageExtended:
image_preview = True
extToRemove = ['.safetensors', '.ckpt', '.pt', '.bin', '.pth']
output_ext = '.webp'
output_exts = ['.webp', '.png', '.jpg']
output_exts = ['.webp', '.png', '.jpg', '.jpeg', '.gif', '.tiff', '.bmp']
quality = 75
print(f"\033[92m[save_image_extended]\033[0m version: {version}\033[0m")
if jxl_supported:
@@ -115,9 +119,9 @@ class SaveImageExtended:
'basic, sampler, prompt',
'basic, models, sampler, prompt'
], {'default': self.save_job_data}),
'job_data_per_image': ([False, True], {'default': self.job_data_per_image}),
'job_data_per_image': ('BOOLEAN', {"default": self.job_data_per_image}),
'job_custom_text': ('STRING', {'default': self.job_custom_text, 'multiline': False}),
'save_metadata': ([True, False], {'default': self.save_metadata}),
'save_metadata': ('BOOLEAN', {'default': self.save_metadata}),
'counter_digits': ('INT', {
"default": self.counter_digits,
"min": 1,
@@ -126,9 +130,16 @@ class SaveImageExtended:
"display": "silder"
}),
'counter_position': (self.counter_positions, {'default': self.counter_position}),
'one_counter_per_folder': ([True, False], {'default': self.one_counter_per_folder}),
'image_preview': ([True, False], {'default': self.image_preview}),
'one_counter_per_folder': ('BOOLEAN', {'default': self.one_counter_per_folder}),
'image_preview': ('BOOLEAN', {'default': self.image_preview}),
'output_ext': (self.output_exts, {'default': self.output_ext}),
'quality': ('INT', {
"default": self.quality,
"min": 1,
"max": 100,
"step": 1,
"display": "silder"
}),
},
'optional': {
'positive_text_opt': ('STRING', {'forceInput': True}),
@@ -232,8 +243,12 @@ class SaveImageExtended:
# Fractions and Superscripts '⅔','2²' False True True
# Roman Numerals 'ↁ' False False True
# --------------------------- --------- ----------- --------- -----------
def generate_custom_name(self, keys_to_extract, prefix, delimiter, prompt):
custom_name = prefix
def generate_custom_name(self, keys_to_extract, prefix, delimiter, prompt, timestamp=datetime.now()):
if '%' in prefix:
custom_name = timestamp.strftime(prefix)
else:
custom_name = prefix
if prompt is not None and keys_to_extract != ['']:
found_values = {}
# print(f"debug generate_custom_name: --prefix: {prefix}")
@@ -248,17 +263,21 @@ class SaveImageExtended:
# 'inputs': {'cfg': 1.6,
# 'denoise': 1.0, ...
for key in keys_to_extract:
if not key: continue
value = None
node, nodeKey = None, None
# check if this is a subfolder: starts with ./ or /, can also end with /
if '/' in key:
# we also exclude datetime formats like "%A %d. %B %Y"
if '/' in key and not '%' in key:
# key is a subfolder: ./subfolder or ../subfolder or /subfolder
value = key
else:
splitKey = key.split('.')
# we also exclude cases like "..string" or "sting.string.x" etc
if len(splitKey) == 2:
# we also exclude datetime formats like "%A %d. %B %Y"
if len(splitKey) == 2 and not '%' in key:
# key has the form string.string
if '' not in splitKey:
# key has the form string.string
@@ -280,9 +299,13 @@ class SaveImageExtended:
# key is in the form ".string" or "string." or "." - we won't clean that up and keep as is, maybe it's a separator
value = key
else:
# nodeKey is not a folder, has no dot, or multiple dots, could be a valid key to find, could be a fixed string - keep as is
nodeKey = key
self.find_keys_recursively(prompt, [nodeKey], found_values)
# could be a datetime format
if '%' in key:
value = timestamp.strftime(key)
# nodeKey is not a datetime, folder, has no dot, or multiple dots, could be a valid key to find, could be a fixed string - keep as is
else:
nodeKey = key
self.find_keys_recursively(prompt, [nodeKey], found_values)
# is key num.widget_name
# is key subfolder
@@ -326,7 +349,7 @@ class SaveImageExtended:
return custom_name.strip(delimiter).strip('.').strip('/').strip(delimiter)
def save_job_to_json(self, save_job_data, prompt, filename_prefix, positive_text_opt, negative_text_opt, job_custom_text, resolution, output_path, filename):
def save_job_to_json(self, save_job_data, prompt, filename_prefix, positive_text_opt, negative_text_opt, job_custom_text, resolution, output_path, filename, timestamp=datetime.now()):
prompt_keys_to_save = {}
if 'basic' in save_job_data:
if len(filename_prefix) > 0:
@@ -411,7 +434,7 @@ class SaveImageExtended:
print(f"SaveImageExtended {version} error: The file {json_file_path} is empty or malformed. Initializing with empty data.")
existing_data = {}
timestamp = datetime.now().strftime('%c')
timestamp = timestamp.strftime('%c')
new_entry = {}
new_entry[timestamp] = prompt_keys_to_save
existing_data.update(new_entry)
@@ -452,9 +475,14 @@ class SaveImageExtended:
exif = img.getexif()
dump = json.dumps(metadata)
# print(f"dump={dump}") {"prompt": { .. }, "workflow": { .. }}
exif[ExifTags.Base.UserComment] = json.dumps(metadata)
# This should work the same:
# exif[0x9286] = json.dumps(metadata)
# exif[ExifTags.Base.UserComment] = dump
## It seems better to separate the two
# 0x010e: ImageDescription
# 0x010f: Make
# 0x9286: UserComment
exif[0x9286] = "Prompt: " + json.dumps(metadata['prompt']) # UserComment
exif[0x010e] = "Workflow: " + json.dumps(metadata['workflow']) # ImageDescription
# exif[ExifTags.Base.UserComment] = piexif.helper.UserComment.dump(json.dumps(metadata), encoding="unicode") # type 4
# exif[ExifTags.Base.UserComment] = piexif.helper.UserComment.dump(json.dumps(metadata), encoding="jis") # type 1
@@ -490,35 +518,40 @@ class SaveImageExtended:
return exif_dat
def save_image(self, image_path, img, prompt, save_metadata=save_metadata, extra_pnginfo=None):
def save_image(self, image_path, img, prompt, save_metadata=save_metadata, extra_pnginfo=None, quality=75):
# print(f"debug save_images: image_path={image_path}")
output_ext = os.path.splitext(os.path.basename(image_path))[1]
metadata = None
kwargs = dict()
# match is python 3.10+
match output_ext:
case '.avif':
if save_metadata: kwargs["exif"] = self.get_metadata_exif(img, prompt, extra_pnginfo)
kwargs["lossless"] = self.lossless
kwargs["quality"] = self.avif_quality
case '.webp':
if save_metadata: kwargs["exif"] = self.get_metadata_exif(img, prompt, extra_pnginfo)
kwargs["lossless"] = self.lossless
kwargs["quality"] = self.webp_quality
if self.webp_quality == 100: kwargs["lossless"] = True
case '.jpg':
if save_metadata: kwargs["exif"] = self.get_metadata_exif(img, prompt, extra_pnginfo)
kwargs["quality"] = self.jpeg_quality
case '.jxl':
kwargs["lossless"] = self.lossless
if save_metadata: kwargs["exif"] = self.get_metadata_exif(img, prompt, extra_pnginfo)
kwargs["quality"] = self.jxl_quality
case _:
# png: no quality
if save_metadata: kwargs["pnginfo"] = self.get_metadata_png(img, prompt, extra_pnginfo)
kwargs["compress_level"] = self.png_compress_level
# png
if output_ext in ['.avif']:
if save_metadata: kwargs["exif"] = self.get_metadata_exif(img, prompt, extra_pnginfo)
kwargs["quality"] = quality
if quality == 100: kwargs["lossless"] = True
elif output_ext in ['.webp']:
if save_metadata: kwargs["exif"] = self.get_metadata_exif(img, prompt, extra_pnginfo)
kwargs["quality"] = quality
if quality == 100: kwargs["lossless"] = True
elif output_ext in ['.jpg', '.jpeg']:
if save_metadata: kwargs["exif"] = self.get_metadata_exif(img, prompt, extra_pnginfo)
kwargs["quality"] = quality
kwargs["optimize"] = self.optimize_image
elif output_ext in ['.jxl']:
if save_metadata: kwargs["exif"] = self.get_metadata_exif(img, prompt, extra_pnginfo)
kwargs["quality"] = quality
if quality == 100: kwargs["lossless"] = True
elif output_ext in ['.tiff']:
# tiff: i suspect no quality either
kwargs["quality"] = quality
kwargs["optimize"] = self.optimize_image
elif output_ext in ['.png', '.gif']:
# png/gif: no quality
if save_metadata: kwargs["pnginfo"] = self.get_metadata_png(img, prompt, extra_pnginfo)
kwargs["compress_level"] = self.png_compress_level
kwargs["optimize"] = self.optimize_image
# elif output_ext in ['.bmp']:
# nothing to add
# img.save(image_path, pnginfo=metadata, compress_level=self.png_compress_level)
img.save(image_path, **kwargs)
@@ -547,6 +580,7 @@ class SaveImageExtended:
positive_text_opt=None,
extra_pnginfo=None,
prompt=None,
quality=75,
):
# print(f"filename_prefix = x{filename_prefix}x")
@@ -568,8 +602,9 @@ class SaveImageExtended:
################################## UNCOMMENT HERE TO SEE THE ENTIRE PROMPT
# pprint.pprint(prompt)
##########################################################################
custom_filename = self.generate_custom_name(filename_keys_to_extract, filename_prefix, delimiter, prompt)
custom_foldername = self.generate_custom_name(foldername_keys_to_extract, foldername_prefix, delimiter, prompt)
timestamp = datetime.now()
custom_filename = self.generate_custom_name(filename_keys_to_extract, filename_prefix, delimiter, prompt, timestamp)
custom_foldername = self.generate_custom_name(foldername_keys_to_extract, foldername_prefix, delimiter, prompt, timestamp)
# Get set resolution value
i = 255. * images[0].cpu().numpy()
@@ -598,17 +633,17 @@ class SaveImageExtended:
file = f'{counter:0{counter_digits}}{delimiter}{filename}{output_ext}'
image_path = os.path.join(output_path, file)
self.save_image(image_path, img, prompt, save_metadata, extra_pnginfo)
self.save_image(image_path, img, prompt, save_metadata, extra_pnginfo, quality)
if save_job_data != 'disabled' and job_data_per_image:
self.save_job_to_json(save_job_data, prompt, filename_prefix, positive_text_opt, negative_text_opt, job_custom_text, resolution, output_path, f'{file.removesuffix(output_ext)}.json')
self.save_job_to_json(save_job_data, prompt, filename_prefix, positive_text_opt, negative_text_opt, job_custom_text, resolution, output_path, f'{file.removesuffix(output_ext)}.json', timestamp)
subfolder = self.get_subfolder_path(image_path, self.output_dir)
results.append({ 'filename': file, 'subfolder': subfolder, 'type': self.type})
counter += 1
if save_job_data != 'disabled' and not job_data_per_image:
self.save_job_to_json(save_job_data, prompt, filename_prefix, positive_text_opt, negative_text_opt, job_custom_text, resolution, output_path, 'jobs.json')
self.save_job_to_json(save_job_data, prompt, filename_prefix, positive_text_opt, negative_text_opt, job_custom_text, resolution, output_path, 'jobs.json', timestamp)
except OSError as e:
print(f"SaveImageExtended {version} error: An error occurred while creating the subfolder or saving the image: {e}")