Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54e9183ecd | ||
|
|
669e7a9d03 | ||
|
|
ab2410ba95 | ||
|
|
6369fb315f | ||
|
|
fc67d2b6b0 | ||
|
|
906d2b9669 | ||
|
|
53b9270e12 | ||
|
|
61f89b803f | ||
|
|
a2f4b00fa1 | ||
|
|
aabd212f33 | ||
|
|
c2fd1f396d | ||
|
|
6646775342 | ||
|
|
9b6c9df2a0 | ||
|
|
983f5316f6 | ||
|
|
4ab2249c02 |
@@ -2,6 +2,7 @@ backup/
|
||||
*.7z
|
||||
*.zip
|
||||
gitsync*
|
||||
commit.*
|
||||
*.url
|
||||
*.lnk
|
||||
desktop.ini
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Save Image Extended for ComfyUI
|
||||
version = 2.43 - **AVIF** support!
|
||||
# 💾 Save Image Extended for ComfyUI
|
||||
Supports those extensions: **AVIF WebP JXL jpg jpeg png gif tiff bmp**
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/save-image-extended-comfyui-example.png" />
|
||||
@@ -7,8 +7,9 @@ version = 2.43 - **AVIF** support!
|
||||
|
||||
* Customize the folder, sub-folders, and filenames of your images!
|
||||
* Save data about the generated job (sampler, prompts, models) as entries in a `json` (text) file, in each folder.
|
||||
* Use the values of ANY node's widget, by simply adding its badge number in the form _id.widget_name_:
|
||||
* Oh btw... also saves your output as **WebP** or **JPEG**... And yes the prompt is included :) ComfyUI can load it but a PR approval is needed.
|
||||
* Use the values of ANY node's widget, by simply adding its badge number in the form _id.widget_name_
|
||||
* Oh btw... also saves your output as **AVIF** / **WebP** / **JPEG** / **JXL** ... And yes the prompt is included :)
|
||||
* ComfyUI can only load PNG and WebP atm
|
||||
|
||||
|
||||
<br>
|
||||
@@ -22,6 +23,8 @@ version = 2.43 - **AVIF** support!
|
||||
|
||||
## Installation
|
||||
### Requirements:
|
||||
There is a requirements.txt that will take care of that, but just in case:
|
||||
|
||||
- python 10.6
|
||||
- piexif
|
||||
- pillow
|
||||
@@ -31,15 +34,21 @@ version = 2.43 - **AVIF** support!
|
||||
pip install piexif pillow pillow-avif-plugin
|
||||
```
|
||||
|
||||
For Jpeg XL / jxl it's more complicated. You need to install and compile the wheel jxlpy, and therefore, need a valid and functional MSVC installation.
|
||||
|
||||
### Installation
|
||||
```
|
||||
pip install jxlpy
|
||||
```
|
||||
|
||||
### Manual Download
|
||||
1. Open a terminal inside the 'custom_nodes' folder located in your ComfyUI installation dir
|
||||
2. Use the `git clone` command to clone the [save-image-extended-comfyui](https://github.com/audioscavenger/save-image-extended-comfyui) repo.
|
||||
2. Use the `git clone` command to clone the [save-image-extended-comfyui](https://github.com/audioscavenger/save-image-extended-comfyui) repo under ComfyUI\custom_nodes\
|
||||
```
|
||||
git clone https://github.com/audioscavenger/save-image-extended-comfyui
|
||||
```
|
||||
|
||||
## Parameters / Usage
|
||||
|
||||
| Attribute | Description |
|
||||
| --- | --- |
|
||||
| `filename_prefix` | String prefix added to files. |
|
||||
@@ -57,7 +66,9 @@ git clone https://github.com/audioscavenger/save-image-extended-comfyui
|
||||
| `image_preview` | Turns the image preview on and off. |
|
||||
| `output_ext` | File extension: PNG by default, or WEBP (coming soon). |
|
||||
|
||||
Unknown key names in `filename_keys` and `foldername_keys` are treated as custom strings.
|
||||
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
|
||||
|
||||
@@ -67,41 +78,61 @@ 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.
|
||||
|
||||
#
|
||||
* WebP and JPEG quality are fixed at 91.
|
||||
* PNG is maxed compressed (9)
|
||||
Quality and compression settings: default is 75, 100 will activate **lossless** for AVIF and WEBP only.
|
||||
|
||||
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: ComfyUI cannot load it atm... Feel free to ask ComfyUI team to add support for AVIF WebP Jpeg!
|
||||
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.
|
||||
|
||||
#
|
||||
You asked for it... Now you can select which node to get the widget values from! Formerly, this custom node would simply return the last value found: useles if you have many Ksamplers...
|
||||
Make sure you enable the badge IDs to benefit from this:
|
||||
You asked for it... Now you can select which node to get the widget values from! Formerly, this custom node would simply return the last value found: useless if you have multiple same nodes...
|
||||
To see node numbers in the UI, **enable the badge IDs**:
|
||||
<br>
|
||||
<p align="center">
|
||||
<img src="assets/ComfyUI-enable-badge-ids.png" />
|
||||
</p>
|
||||
|
||||
#
|
||||
jobs.json sample:
|
||||
jobs.json sample: always generated and appended, not sure what it can be used for.
|
||||
<br>
|
||||
<p align="center">
|
||||
<img src="assets/save-image-extended-comfyui-jobs-example.png" />
|
||||
@@ -112,19 +143,50 @@ jobs.json sample:
|
||||
|
||||
## RoadMap
|
||||
|
||||
I won't promise anything, just like @thedyze did not promise anything when they released this custom node. Then disappeared for good 3 months later. That's fine, I do that too.
|
||||
I won't promise you the moon, but since I use this node myself, I will maintain it as much as I can. I do provide a way to contact me, and will accept PR and collabs.
|
||||
Once I feel like I don't have time to work on it, I will gladly transfer ownership or let collabs maintain it.
|
||||
|
||||
However, I do provide a way to contact me, and will accept PR and collabs. Once I feel like I don't have time to work on it, I will gladly transfer ownership or let collabs maintain it.
|
||||
TODO:
|
||||
|
||||
- [ ] save_job_to_json is pretty much useless actually, since it only saves the last value found for each node
|
||||
- [ ] bugfix: when using /name in foldername_keys, Comfy thinks you want to save outside the output folder
|
||||
- [ ] what is job_custom_text?
|
||||
- [ ] what is jobs.json? History, alraight. What do you do with that? Can we disable it?
|
||||
- [ ] ComfyRoll CR XY Save Grid Image: it offers jpeg webp tif - check how it embeds prompt and see if that works better with Comfy!
|
||||
- [ ] offer quality setting in the node?
|
||||
- [ ] remove save_job_to_json? thisis pretty much useless actually, since it only saves the last value found for each node.
|
||||
- [ ] remove job_custom_text? what is this for?
|
||||
- [ ] remove jobs.json? jobs history, alright. What do you do with that? images contain the prompt, what is this for?
|
||||
- [ ] improve get_latest_counter: fails when user renames files: appends text after counter
|
||||
- [ ] 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`!
|
||||
- uncommented `__all__` in init.py
|
||||
- potential bugfix in splitKey, `len(splitKey) = 2` to identify actual "node.widget" format
|
||||
|
||||
### release 2.45 💾
|
||||
- added 💾 in the name
|
||||
|
||||
### release 2.44
|
||||
- so many bugfixes
|
||||
- complete rework of generate_custom_name to handle ALL the possible scenarios
|
||||
- [x] bugfix: when using `/name` in foldername_keys, Comfy thinks you want to save outside the output folder
|
||||
|
||||
### release 2.43
|
||||
- [x] support for AVIF
|
||||
- [x] support for **AVIF**
|
||||
- [x] added requirements.txt
|
||||
|
||||
### release 2.42
|
||||
@@ -135,8 +197,8 @@ However, I do provide a way to contact me, and will accept PR and collabs. Once
|
||||
- [x] WebP is indeed loaded properly like a PNG, if you apply the patch above to `pnginfo.js and `app.js`
|
||||
|
||||
### release 2.4
|
||||
- [x] integrate webp
|
||||
- [x] integrate jpg
|
||||
- [x] integrate **webp**
|
||||
- [x] integrate **jpeg**
|
||||
|
||||
### release 2.3
|
||||
- [x] for each keys, we return only the last value found in the prompt. Not the last Ksampler. Impossible to know which one is the last. Therefore, simply use this syntax: number.widget_name
|
||||
|
||||
+1
-1
@@ -13,4 +13,4 @@
|
||||
|
||||
from .save_image_extended import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
# __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -1 +0,0 @@
|
||||
WIP = =.txt
|
||||
+237
-118
@@ -11,10 +11,28 @@ import pprint
|
||||
import piexif
|
||||
import piexif.helper
|
||||
|
||||
avif_supported = False
|
||||
jxl_supported = False
|
||||
|
||||
# Avif is included in requirements.txt
|
||||
try:
|
||||
import pillow_avif
|
||||
except ModuleNotFoundError:
|
||||
except:
|
||||
print(f"\033[92m[save_image_extended]\033[0m AVIF is not supported. To add it: pip install pillow pillow-avif-plugin\033[0m")
|
||||
pass
|
||||
else:
|
||||
print(f"\033[92m[save_image_extended] AVIF is supported! Woohoo!\033[0m")
|
||||
avif_supported = True
|
||||
|
||||
# Jxl requires jxlpy wheel to be compiled, and a valid MSVC environment, which is complex task
|
||||
try:
|
||||
from jxlpy import JXLImagePlugin
|
||||
except:
|
||||
print(f"\033[92m[save_image_extended]\033[0m JXL is not supported. To add it: pip install jxlpy\033[0m")
|
||||
print(f"\033[92m[save_image_extended]\033[0m You will need a valid MSVC env to build the wheel\033[0m")
|
||||
pass
|
||||
else:
|
||||
jxl_supported = True
|
||||
|
||||
# PIL must be loaded after pillow_avif
|
||||
from PIL import Image, ExifTags
|
||||
@@ -26,20 +44,24 @@ original_locale = locale.setlocale(locale.LC_TIME, '')
|
||||
|
||||
# class SaveImageExtended -------------------------------------------------------------------------------
|
||||
class SaveImageExtended:
|
||||
#Version: 2.43
|
||||
version = 2.43
|
||||
version = 2.61
|
||||
type = 'output'
|
||||
|
||||
png_compress_level = 9
|
||||
avif_quality = 100
|
||||
webp_quality = 91
|
||||
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 = 'myFile'
|
||||
filename_keys = 'sampler_name, scheduler, cfg, steps'
|
||||
foldername_prefix = 'myFolder'
|
||||
foldername_keys = 'ckpt_name, ./exampleSubfolder'
|
||||
delimiter = '_'
|
||||
filename_prefix = 'ComfyUI'
|
||||
filename_keys = 'sampler_name, cfg, steps, %F %H-%M-%S'
|
||||
foldername_prefix = ''
|
||||
foldername_keys = 'ckpt_name'
|
||||
delimiter = '-'
|
||||
save_job_data = 'basic, models, sampler, prompt'
|
||||
job_data_per_image = False
|
||||
job_custom_text = ''
|
||||
@@ -49,16 +71,17 @@ class SaveImageExtended:
|
||||
counter_positions = ['last', 'first']
|
||||
one_counter_per_folder = True
|
||||
image_preview = True
|
||||
extToRemove = ['.safetensors', '.ckpt', '.pt']
|
||||
extToRemove = ['.safetensors', '.ckpt', '.pt', '.bin', '.pth']
|
||||
output_ext = '.webp'
|
||||
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 pillow_avif not in sys.modules:
|
||||
output_ext = '.avif'
|
||||
output_exts = ['.avif', '.png', '.webp', '.jpg']
|
||||
print(f"\033[92m[save_image_extended] AVIF is supported! Woohoo!\033[0m")
|
||||
else:
|
||||
output_ext = '.png'
|
||||
output_exts = ['.png', '.webp', '.jpg']
|
||||
if jxl_supported:
|
||||
output_exts.insert(0, '.jxl')
|
||||
# if pillow_avif not in sys.modules:
|
||||
if avif_supported:
|
||||
output_exts.insert(0, '.avif')
|
||||
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
@@ -95,10 +118,10 @@ class SaveImageExtended:
|
||||
'basic, prompt',
|
||||
'basic, sampler, prompt',
|
||||
'basic, models, sampler, prompt'
|
||||
],{'default': self.save_job_data}),
|
||||
'job_data_per_image': ([False, True], {'default': self.job_data_per_image}),
|
||||
], {'default': self.save_job_data}),
|
||||
'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,
|
||||
@@ -107,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}),
|
||||
@@ -137,7 +167,7 @@ class SaveImageExtended:
|
||||
def get_latest_counter(self, one_counter_per_folder, folder_path, filename_prefix, counter_digits=counter_digits, counter_position=counter_position, output_ext=output_ext):
|
||||
counter = 1
|
||||
if not os.path.exists(folder_path):
|
||||
print(f"SaveImageExtended error: Folder {folder_path} does not exist, starting counter at 1.")
|
||||
print(f"SaveImageExtended {version} error: Folder {folder_path} does not exist, starting counter at 1.")
|
||||
return counter
|
||||
|
||||
try:
|
||||
@@ -147,20 +177,21 @@ class SaveImageExtended:
|
||||
if counter_position not in self.counter_positions: counter_position = self.counter_position
|
||||
if counter_position == 'last':
|
||||
# BUG: this works only if extension is 3 letters like png, this will break with webp and avif:
|
||||
counters = [int(file[-(extLen + counter_digits):-extLen]) if file[-(extLen + counter_digits):-extLen].isdigit() else 0 for file in files if one_counter_per_folder or file.startswith(filename_prefix)]
|
||||
counters = [int(file[-(extLen + counter_digits):-extLen]) if file[-(extLen + counter_digits):-extLen].isdecimal() else 0 for file in files if one_counter_per_folder or file.startswith(filename_prefix)]
|
||||
else:
|
||||
counters = [int(file[:counter_digits]) if file[:counter_digits].isdigit() else 0 for file in files if one_counter_per_folder or file[counter_digits +1:].startswith(filename_prefix)]
|
||||
counters = [int(file[:counter_digits]) if file[:counter_digits].isdecimal() else 0 for file in files if one_counter_per_folder or file[counter_digits +1:].startswith(filename_prefix)]
|
||||
|
||||
if counters:
|
||||
counter = max(counters) + 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"SaveImageExtended error: An error occurred while finding the latest counter: {e}")
|
||||
print(f"SaveImageExtended {version} error: An error occurred while finding the latest counter: {e}")
|
||||
|
||||
return counter
|
||||
|
||||
|
||||
def find_keys_recursively(self, prompt, keys_to_find, found_values):
|
||||
# find_keys_recursively is a self-updating recursive method, that will update the dict found_values
|
||||
def find_keys_recursively(self, prompt={}, keys_to_find=[], found_values={}):
|
||||
for key, value in prompt.items():
|
||||
if key in keys_to_find:
|
||||
found_values[key] = value
|
||||
@@ -168,15 +199,17 @@ class SaveImageExtended:
|
||||
self.find_keys_recursively(value, keys_to_find, found_values)
|
||||
|
||||
|
||||
def cleanup_fileName(self, value):
|
||||
if isinstance(value, str):
|
||||
def cleanup_fileName(self, file='', extToRemove=extToRemove):
|
||||
if isinstance(file, str):
|
||||
# takes care of all the possible safetensor extensions under the sun
|
||||
value = os.path.splitext(os.path.basename(value))[0]
|
||||
return value
|
||||
# cannot do that... maybe the user want a string.string fixed value to use, that does not end with extToRemove
|
||||
# file = os.path.splitext(os.path.basename(file))[0]
|
||||
for ext in extToRemove: file = file.removesuffix(ext)
|
||||
return file
|
||||
|
||||
|
||||
# this method pretty much does the same as find_keys_recursively, except it's for job.json export
|
||||
def find_parameter_values(self, target_keys, prompt, found_values={}):
|
||||
def find_parameter_values(self, target_keys, prompt={}, found_values={}):
|
||||
loras_string = ''
|
||||
for key, value in prompt.items():
|
||||
# print(f"debug find_parameter_values: key={key} value={value}")
|
||||
@@ -204,14 +237,23 @@ class SaveImageExtended:
|
||||
return found_values
|
||||
|
||||
|
||||
def generate_custom_name(self, keys_to_extract, prefix, delimiter, resolution, prompt):
|
||||
custom_name = prefix
|
||||
if prompt is not None and len(keys_to_extract) > 0:
|
||||
# String Type Example isdecimal() isdigit() isnumeric()
|
||||
# --------------------------- --------- ----------- --------- -----------
|
||||
# Base 10 Numbers '0123' True True True
|
||||
# Fractions and Superscripts '⅔','2²' False True True
|
||||
# Roman Numerals 'ↁ' False False True
|
||||
# --------------------------- --------- ----------- --------- -----------
|
||||
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}")
|
||||
# print(f"debug generate_custom_name: --keys_to_extract: {keys_to_extract}")
|
||||
# print(f"debug generate_custom_name: --prompt:")
|
||||
# pprint.pprint(prompt)
|
||||
|
||||
# now separating numbered keys from non-numbered keys:
|
||||
# 37.ckpt_name = i want the ckpt_name from node #37
|
||||
@@ -221,54 +263,93 @@ class SaveImageExtended:
|
||||
# 'inputs': {'cfg': 1.6,
|
||||
# 'denoise': 1.0, ...
|
||||
for key in keys_to_extract:
|
||||
node, nodeKey = None, None
|
||||
splitKey = key.split('.')
|
||||
if len(splitKey) > 1:
|
||||
node, nodeKey = splitKey[0], splitKey[1]
|
||||
if node in prompt:
|
||||
# print(f"debug generate_custom_name: --node.nodeKey = {node}.{nodeKey}")
|
||||
# splitKey[0] = #node number found in prompt, we will recurse only in that node:
|
||||
value = self.find_keys_recursively(prompt[node], [nodeKey], found_values)
|
||||
else:
|
||||
# if splitKey[0] = #node number not found in prompt, we will just inform the user
|
||||
print(f"SaveImageExtended info: node #{node} not found")
|
||||
else:
|
||||
# from now on we will wotk with nodeKey, that will save us tons of if then else
|
||||
nodeKey = key
|
||||
# we just try and find the last value for that key, whichever node it's in:
|
||||
value = self.find_keys_recursively(prompt, [nodeKey], found_values)
|
||||
if not key: continue
|
||||
|
||||
# at this point we have a nodeKey but maybe no value
|
||||
# now we analyze each value found and format them accordingly:
|
||||
value = found_values[nodeKey]
|
||||
# print(f"debug generate_custom_name: ----key: {nodeKey}")
|
||||
value = None
|
||||
node, nodeKey = None, None
|
||||
|
||||
# check if this is a subfolder: starts with ./ or /, can also end with /
|
||||
# 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
|
||||
# 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
|
||||
if splitKey[0].isdecimal():
|
||||
# key has the form num.widget_name like 123.widget_name, we will then look for widget_name value in node #123
|
||||
node, nodeKey = splitKey[0], splitKey[1]
|
||||
if node in prompt:
|
||||
# print(f"debug generate_custom_name: --node.nodeKey = {node}.{nodeKey}")
|
||||
# splitKey[0] = #node number found in prompt, we will recurse only in that node:
|
||||
self.find_keys_recursively(prompt[node], [nodeKey], found_values)
|
||||
else:
|
||||
# if splitKey[0] = #num node not found in prompt; #num could have changed or user made a typo
|
||||
print(f"SaveImageExtended info: node #{node} not found")
|
||||
self.find_keys_recursively(prompt, [nodeKey], found_values)
|
||||
else:
|
||||
# key is in the form string.string = fixed string
|
||||
value = self.cleanup_fileName(key)
|
||||
else:
|
||||
# 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:
|
||||
# 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
|
||||
|
||||
# at this point we have a nodeKey, or a value, or both
|
||||
# print(f"debug generate_custom_name: ----key: {nodeKey}")
|
||||
# print(f"debug generate_custom_name: ----value: {value}")
|
||||
|
||||
if value is None:
|
||||
# key not found = it's a fixed string
|
||||
# print(f"debug generate_custom_name: ------value=key: {nodeKey}")
|
||||
value = nodeKey
|
||||
if nodeKey is not None:
|
||||
if nodeKey in found_values: value = found_values[nodeKey]
|
||||
if value is None:
|
||||
value = nodeKey
|
||||
else:
|
||||
value = self.cleanup_fileName(value)
|
||||
|
||||
# at this point, value is not None anymore
|
||||
# now we analyze each value found and format them accordingly:
|
||||
# print(f"debug generate_custom_name: ----value: {value}")
|
||||
delim = delimiter
|
||||
|
||||
# now we build the custom_name:
|
||||
if isinstance(value, str):
|
||||
# prefix and keys can very well be subfolders ending or starting with a /
|
||||
if custom_name.endswith('/'):
|
||||
# for subfolders, do not start filename with a delimiter...
|
||||
delim = ''
|
||||
else:
|
||||
# for subfolders in keys, do not clean the filename...
|
||||
if '/' in value and not value.endswith('/'):
|
||||
# print(f"debug generate_custom_name: ---------: folder")
|
||||
delim= ''
|
||||
# ".string" case
|
||||
if value.startswith('.'):
|
||||
delim = ''
|
||||
|
||||
if isinstance(value, float):
|
||||
value = round(float(value), 1)
|
||||
|
||||
# now we build the custom_name:
|
||||
if isinstance(value, str):
|
||||
value = self.cleanup_fileName(value)
|
||||
# prefix and keys can very well be subfolders ending or starting with a /
|
||||
if (value.startswith('./') or value.startswith('/') or custom_name.endswith('/')):
|
||||
# for subfolders, do not start filename with a delimiter...
|
||||
custom_name += f"{value}"
|
||||
else:
|
||||
custom_name += f"{delimiter}{value}"
|
||||
else:
|
||||
# could be int or float, can't be anything else
|
||||
custom_name += f"{delimiter}{value}"
|
||||
custom_name += f"{delim}{value}"
|
||||
# print(f"debug generate_custom_name: ------custom_name: {custom_name}")
|
||||
return custom_name.strip(delimiter)
|
||||
# for each key
|
||||
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:
|
||||
@@ -350,10 +431,10 @@ class SaveImageExtended:
|
||||
with open(json_file_path, 'r') as f:
|
||||
existing_data = json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
print(f"SaveImageExtended error: The file {json_file_path} is empty or malformed. Initializing with empty data.")
|
||||
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)
|
||||
@@ -394,7 +475,15 @@ class SaveImageExtended:
|
||||
exif = img.getexif()
|
||||
dump = json.dumps(metadata)
|
||||
# print(f"dump={dump}") {"prompt": { .. }, "workflow": { .. }}
|
||||
exif[ExifTags.Base.UserComment] = 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
|
||||
# exif[ExifTags.Base.UserComment] = piexif.helper.UserComment.dump(json.dumps(metadata), encoding="ascii") # type 1
|
||||
@@ -429,29 +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["quality"] = self.avif_quality
|
||||
case '.webp':
|
||||
if save_metadata: kwargs["exif"] = self.get_metadata_exif(img, prompt, extra_pnginfo)
|
||||
kwargs["lossless"] = False
|
||||
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 _:
|
||||
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)
|
||||
@@ -459,39 +559,58 @@ class SaveImageExtended:
|
||||
# class SaveImageExtended -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
# node will never return None values, except for optional input. Impossible.
|
||||
def save_images(self,
|
||||
counter_digits,
|
||||
counter_position,
|
||||
one_counter_per_folder,
|
||||
delimiter,
|
||||
filename_keys,
|
||||
foldername_keys,
|
||||
images,
|
||||
image_preview,
|
||||
filename_prefix,
|
||||
filename_keys,
|
||||
foldername_prefix,
|
||||
foldername_keys,
|
||||
delimiter,
|
||||
save_job_data,
|
||||
job_data_per_image,
|
||||
job_custom_text,
|
||||
save_metadata,
|
||||
filename_prefix=filename_prefix,
|
||||
foldername_prefix=foldername_prefix,
|
||||
extra_pnginfo=None,
|
||||
counter_digits,
|
||||
counter_position,
|
||||
one_counter_per_folder,
|
||||
image_preview,
|
||||
output_ext,
|
||||
negative_text_opt=None,
|
||||
positive_text_opt=None,
|
||||
extra_pnginfo=None,
|
||||
prompt=None,
|
||||
output_ext=output_ext
|
||||
quality=75,
|
||||
):
|
||||
|
||||
# print(f"filename_prefix = x{filename_prefix}x")
|
||||
# print(f"filename_keys = x{filename_keys}x")
|
||||
# print(f"foldername_prefix = x{foldername_prefix}x")
|
||||
# print(f"foldername_keys = x{foldername_keys}x")
|
||||
# print(f"delimiter = x{delimiter}x")
|
||||
# print(f"save_job_data = x{save_job_data}x")
|
||||
# print(f"job_data_per_image = x{job_data_per_image}x")
|
||||
# print(f"output_ext = x{output_ext}x")
|
||||
|
||||
# apply default values: we replicate the default save image box
|
||||
if not filename_prefix and not filename_keys: filename_prefix=self.filename_prefix
|
||||
if delimiter: delimiter = delimiter[0]
|
||||
|
||||
filename_keys_to_extract = [item.strip() for item in filename_keys.split(',')]
|
||||
foldername_keys_to_extract = [item.strip() for item in foldername_keys.split(',')]
|
||||
|
||||
################################## UNCOMMENT HERE TO SEE THE ENTIRE PROMPT
|
||||
# pprint.pprint(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()
|
||||
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
|
||||
resolution = f'{img.width}x{img.height}'
|
||||
|
||||
delimiter = delimiter[0]
|
||||
filename_keys_to_extract = [item.strip() for item in filename_keys.split(',')]
|
||||
foldername_keys_to_extract = [item.strip() for item in foldername_keys.split(',')]
|
||||
custom_filename = self.generate_custom_name(filename_keys_to_extract, filename_prefix, delimiter, resolution, prompt)
|
||||
custom_foldername = self.generate_custom_name(foldername_keys_to_extract, foldername_prefix, delimiter, resolution, prompt)
|
||||
|
||||
# Create folders, count images, save images
|
||||
try:
|
||||
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])
|
||||
@@ -514,20 +633,20 @@ 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 error: An error occurred while creating the subfolder or saving the image: {e}")
|
||||
print(f"SaveImageExtended {version} error: An error occurred while creating the subfolder or saving the image: {e}")
|
||||
else:
|
||||
if not image_preview:
|
||||
results = list()
|
||||
@@ -540,7 +659,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
'SaveImageExtended': 'Save Image Extended',
|
||||
'SaveImageExtended': '💾 Save Image Extended',
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user