Compare commits

..
18 Commits
Author SHA1 Message Date
audioscavenger 8c91af7243 release 2.70 JXL
modified:   README.md
        	modified:   pyproject.toml
        	modified:   requirements.txt
        	modified:   save_image_extended.py
        	modified:   web/js/help_popup.js
    	modified:   README.md
    	modified:   save_image_extended.py
	modified:   README.md
2024-06-01 13:35:08 -07:00
audioscavenger a25b5091c3 release 2.70 JXL
modified:   README.md
    	modified:   pyproject.toml
    	modified:   requirements.txt
    	modified:   save_image_extended.py
    	modified:   web/js/help_popup.js
	modified:   README.md
	modified:   save_image_extended.py
2024-06-01 12:47:39 -07:00
audioscavenger c4aeb168b6 release 2.70 JXL
modified:   README.md
	modified:   pyproject.toml
	modified:   requirements.txt
	modified:   save_image_extended.py
	modified:   web/js/help_popup.js
2024-06-01 11:09:06 -07:00
audioscavenger 266cef3800 release 2.65 comfy-registry
modified:   README.md
    	modified:   save_image_extended.py
	modified:   README.md
	modified:   pyproject.toml
	modified:   save_image_extended.py
2024-05-30 08:14:36 -07:00
audioscavenger 18bc84204e release 2.6.5 comfy-registry
modified:   README.md
    	modified:   pyproject.toml
    	modified:   save_image_extended.py
	modified:   README.md
2024-05-29 21:32:48 -07:00
audioscavenger 569f4c1ec2 release 2.6.5 comfy-registry
modified:   README.md
	modified:   pyproject.toml
	modified:   save_image_extended.py
2024-05-29 18:12:37 -07:00
audioscavenger 40aeed0e3b release 2.65 comfy-registry
modified:   README.md
	modified:   save_image_extended.py
2024-05-29 18:09:21 -07:00
audioscavenger d72b8e5428 release 2.64 helpPopup
modified:   README.md
            	modified:   __init__.py
            	modified:   save_image_extended.py
        	modified:   web/js/help_popup.js
    	modified:   __init__.py
    	modified:   web/js/help_popup.js
2024-05-29 18:03:20 -07:00
audioscavenger d38ac58316 release 2.64 helpPopup
modified:   README.md
        	modified:   __init__.py
        	modified:   save_image_extended.py
    	modified:   web/js/help_popup.js
	modified:   __init__.py
	modified:   web/js/help_popup.js
2024-05-29 09:31:57 -07:00
audioscavenger 8c9326eb65 release 2.64 helpPopup
modified:   README.md
    	modified:   __init__.py
    	modified:   save_image_extended.py
	modified:   web/js/help_popup.js
2024-05-29 08:48:47 -07:00
audioscavenger dba9e539f9 release 2.64 helpPopup
modified:   README.md
	modified:   __init__.py
	modified:   save_image_extended.py
2024-05-27 18:53:06 -07:00
audioscavenger a9504f9e63 release 2.63 bugfix
modified:   README.md
	modified:   save_image_extended.py
2024-05-27 17:27:52 -07:00
audioscavenger d53328c8ac release 2.62 IFD-270-271
modified:   save_image_extended.py
	modified:   README.md
2024-05-27 17:14:54 -07:00
audioscavenger 85967a9a4b release 2.62 IFD-270-271
modified:   README.md
    	modified:   save_image_extended.py
	modified:   README.md
	modified:   save_image_extended.py
2024-05-27 17:11:48 -07:00
audioscavenger bfabb38bea release 2.62 IFD-270-271
modified:   README.md
	modified:   save_image_extended.py
2024-05-27 09:11:20 -07:00
audioscavenger 2a2e38397a release 2.61 quality
modified:   README.md
    	modified:   save_image_extended.py
	modified:   README.md
2024-05-23 20:42:30 -07:00
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
16 changed files with 814 additions and 101 deletions
+25
View File
@@ -0,0 +1,25 @@
name: "Publish To Registry"
description: "Publish Custom Nodes to Registry."
inputs:
personal_access_token:
description: "Personal Access Token for authenticating to the Registry"
required: true
runs:
using: "composite"
steps:
- name: Checkout Custom Node Repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install comfy-cli
shell: bash
run: |
pip install comfy-cli
- name: Publish Node
shell: bash
run: |
comfy --skip-prompt --no-enable-telemetry env
comfy node publish --token ${{ inputs.personal_access_token }}
+108 -51
View File
@@ -1,5 +1,7 @@
# 💾 Save Image Extended for ComfyUI
**AVIF** / **WebP** / **JXL** support!
Save as JXL, AVIF, WebP, JPEG, JPEG2000, customize the folder, sub-folders, and filenames of your images!
Supports those extensions: **JXL AVIF WebP jpg jpeg j2k jp2 png gif tiff bmp**
<p align="center">
<img src="assets/save-image-extended-comfyui-example.png" />
@@ -8,45 +10,15 @@
* 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 **AVIF** / **WebP** / **JPEG** / **JXL** ... And yes the prompt is included :)
* ComfyUI can only load PNG and WebP atm
* Warning: ComfyUI can only load prompts from PNG and WebP atm
<br>
<p align="center">
<img src="assets/save-image-extended-comfyui-named_nodes_widgets-example.png" />
<br><br>
Happy saving!
</p>
*Reboot by AudioscavengeR since 2024-05-05, original idea from [@thedyze](https://github.com/thedyze/save-image-extended-comfyui)*
## Installation
### Requirements:
There is a requirements.txt that will take care of that, but just in case:
- python 10.6
- piexif
- pillow
- pillow-avif-plugin
```
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.
```
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 under ComfyUI\custom_nodes\
```
git clone https://github.com/audioscavenger/save-image-extended-comfyui
```
## Parameters / Usage
| Attribute | Description |
@@ -93,32 +65,82 @@ Converts [unix datetime formats](https://www.man7.org/linux/man-pages/man1/date.
<img src="assets/save-image-extended-comfyui-example-2.png" />
</p>
## Installation
### Requirements:
There is a requirements.txt that will take care of that, but just in case:
- python 10.6
- piexif
- imagecodecs
- pillow
- pillow-avif-plugin
- pillow-jxl-plugin
```
pip install piexif pillow pillow-avif-plugin
```
For Jpeg XL / jxl it's more complicated. You cannot compile the wheel jxlpy on Windows. Therefore, we use an alternative: [imagecodecs](https://pypi.org/project/imagecodecs/)
```
pip install -U imagecodecs
```
### 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 under ComfyUI\custom_nodes\
```
git clone https://github.com/audioscavenger/save-image-extended-comfyui
```
## Miscelaneous
#
[JPEG XL is a heated debate on chromium forum](https://issues.chromium.org/issues/40168998#comment85) and if true indeed that Google is working on WebP2, jxl is unlikely to take off any day soon. Proponents arguably declare with no proof, that jxl is better and faster than the current best codec: AVIF. But again, without support from the industry, it's going nowhere.
I tested with compression 90 and it's good, with a caveat. The compression offered by pillow is 3x lower then Image Magick for the same level. No idea why.
#
Pillow cannot save Exif data in JPEG2000, nor can it compress it in any way. Who the heck is using JPEG2000 in 2024 anyway?
#
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 90, 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 512x512 picture, with similar visual quality:
| Ext | Compression | Maker | Size | Compression |
| --- | --- | --- | --- | --- |
| png | max 9 | PIL | 413111 | 0% |
| j2k | n/a | PIL | 395028 | 4% |
| jxl | lossless | PIL | 301310 | 27% |
| jxl | 90 | PIL | 179210 | 57% |
| jpeg | 90 | PIL | 88554 | 79% |
| avif | 90 | Imagick | 67272 | 84% |
| webp | 90 | Imagick | 64416 | 84% |
| webp | 90 | PIL | 64356 | 84% |
| avif | 60 | PIL | 47353 | 89% |
| avif | 60 | Imagick | 33691 | 92% |
#
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 IFD below, as defined [here](https://exiftool.org/TagNames/EXIF.html)
WAS Node Suite also use those tags. They must be next to each other in order to Comfy to be able to load them with drag and drop.
| Data | EXIF | Name | String looks like |
| --- | --- | --- | --- |
| prompt | 0x010f | Make | 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}`
#
ComfyUI cannot load lossless WebP atm. Feel free to try and fix `pnginfo.js`
#
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,20 +164,46 @@ jobs.json sample: always generated and appended, not sure what it can be used fo
## RoadMap
*Reboot by AudioscavengeR since 2024-05-05, original idea from [@thedyze](https://github.com/thedyze/save-image-extended-comfyui)*
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.
TODO:
- [ ] 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
- [ ] improve get_latest_counter: restarts when user renames files after the counter part.
- [ ] 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
- [ ] keep same counter if extension changes?
- [ ] files will be out of order if prefixes change... that is expected, but is this what we want?
### release 2.70 💾
- added JPEG-XL
- added JPEG2000
- fixed help popup text color in dark mode
### release 2.65 💾
- published in comfy-registry
### release 2.64 💾
- added Help at top-right corner, based off KJNodes
### release 2.63 💾
- fixed negative_prompt job save overwritten by positive_prompt
- [x] remove job_custom_text? no, some ppl use it apparently
- [x] remove jobs.json? no, some ppl use it apparently
- [x] ComfyRoll CR XY Save Grid Image: it offers jpeg webp tif - check how it embeds prompt - nope it does not
### release 2.62 💾
- prompt and workflow are saved in IFD 270 and 271 for better load compatibility
- disabled jobs.json by default, this thingy seems useless to me
### 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
@@ -212,3 +260,12 @@ TODO:
### release 2.0
- [x] Reboot on 2024-05-05
## :ribbon: Licence
[GPL 3.0](https://choosealicense.com/licenses/gpl-3.0/)
## :beer: Buy me a beer
Like my work? This tool helped you? Want to sponsor more awesomeness like this?
<p align="center">
<a href="https://www.paypal.com/donate/?hosted_button_id=CD7P7PK3WP8WU"><img src="/assets/paypal-Donate-QR-Code.png" /></a>
</p>
+15 -1
View File
@@ -13,4 +13,18 @@
from .save_image_extended import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
WEB_DIRECTORY = "./web"
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', "WEB_DIRECTORY"]
from aiohttp import web
from server import PromptServer
from pathlib import Path
if hasattr(PromptServer, "instance"):
# NOTE: we add an extra static path to avoid comfy mechanism
# that loads every script in web.
PromptServer.instance.app.add_routes(
[web.static("/save_image_extended", (Path(__file__).parent.absolute() / "assets").as_posix())]
)
+6
View File
File diff suppressed because one or more lines are too long
+3
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
View File
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
[project]
name = "save-image-extended-comfyui"
description = "Save as AVIF, WebP, JPEG, customize the folder, sub-folders, and filenames of your images!"
version = "2.70"
license = "LICENCE"
dependencies = ["piexif", "imagecodecs", "pillow", "pillow-avif-plugin", "pillow-jxl-plugin"]
[project.urls]
Repository = "https://github.com/audioscavenger/save-image-extended-comfyui"
# Used by Comfy Registry https://comfyregistry.org
[tool.comfy]
PublisherId = "audioscavenger"
DisplayName = "save-image-extended-comfyui"
Icon = ""
+4
View File
@@ -1,3 +1,7 @@
piexif
# jpegxl support: imagecodecs will bring libjxl 0.9.0
imagecodecs
pillow
pillow-avif-plugin
pillow-jxl-plugin
+97 -49
View File
@@ -11,6 +11,8 @@ import pprint
import piexif
import piexif.helper
version = 2.70
avif_supported = False
jxl_supported = False
@@ -21,20 +23,24 @@ 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")
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
# jxlpy is in early stages of development. None one has ever compiled it on Windows AFAIK
# from jxlpy import JXLImagePlugin
# from imagecodecs import (jpegxl_encode, jpegxl_decode, jpegxl_check, jpegxl_version, JPEGXL)
import pillow_jxl
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:
print(f"\033[92m[save_image_extended] JPEGXL is supported! YeePee!\033[0m")
jxl_supported = True
# PIL must be loaded after pillow_avif
# PIL must be loaded after pillow plugins
from PIL import Image, ExifTags
from PIL.PngImagePlugin import PngInfo
@@ -44,22 +50,41 @@ original_locale = locale.setlocale(locale.LC_TIME, '')
# class SaveImageExtended -------------------------------------------------------------------------------
class SaveImageExtended:
version = 2.51
RETURN_TYPES = ()
FUNCTION = 'save_images'
OUTPUT_NODE = True
CATEGORY = 'image'
DESCRIPTION = """
### subfolders:
- you can use / or ./ or even ../ to start from parent
- you can also use / as the separator
- if your widget name has subfolders like SDXL/whatnot, you will have subfolders too
### Sample datetime formats: see [man datetime](https://www.man7.org/linux/man-pages/man1/date.1.html)
- %F = %Y-%m-%d = 2024-05-22
- %H-%M-%S = 09-13-58
- %D = 05/22/24 (subfolders)
"""
type = 'output'
png_compress_level = 9
lossless = False
avif_quality = 60
webp_quality = 75
jpeg_quality = 91
jxl_quality = 91
webp_quality = 90
jpeg_quality = 90
jxl_quality = 90
j2k_quality = 90
# tiff_quality has no impact unless you start meddling with the compressions algorithms
tiff_quality = 90
# 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 = '_'
save_job_data = 'basic, models, sampler, prompt'
foldername_keys = 'ckpt_name'
delimiter = '-'
save_job_data = 'disabled'
job_data_per_image = False
job_custom_text = ''
save_metadata = True
@@ -70,13 +95,15 @@ 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', '.j2k', '.jp2', '.gif', '.tiff', '.bmp']
quality = 90
print(f"\033[92m[save_image_extended]\033[0m version: {version}\033[0m")
if jxl_supported:
output_exts.insert(0, '.jxl')
# if pillow_avif not in sys.modules:
if avif_supported:
# no matter what people say, jxl is far away from being integrated in browsers. I bet on AVIF.
output_exts.insert(0, '.avif')
def __init__(self):
@@ -129,6 +156,13 @@ class SaveImageExtended:
'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,11 +171,6 @@ class SaveImageExtended:
'hidden': {'prompt': 'PROMPT', 'extra_pnginfo': 'EXTRA_PNGINFO'},
}
RETURN_TYPES = ()
FUNCTION = 'save_images'
OUTPUT_NODE = True
CATEGORY = 'image'
def get_subfolder_path(self, image_path, output_path):
image_path = Path(image_path).resolve()
@@ -410,7 +439,7 @@ class SaveImageExtended:
if isinstance(negative_text[0], str) and len(negative_text[0]) < 6:
if isinstance(negative_text[1], (int, float)):
continue
prompt_keys_to_save['positive_prompt'] = negative_text
prompt_keys_to_save['negative_prompt'] = negative_text
# Append data and save
json_file_path = os.path.join(output_path, filename)
@@ -451,9 +480,10 @@ class SaveImageExtended:
metadata.update(extra_pnginfo)
## For Comfy to load an image, it need a PNG tag at the root: Prompt={prompt}
## For AVIF WebP Jpeg, it's only Exif that's available... and UserComment is the best choice.
## For AVIF WebP Jpeg JXL, it's only Exif that's available... and UserComment is the best choice.
## This method gives wrong results, as [ExifTool] will issue Warning: Invalid EXIF text encoding for UserComment
## This method gives good results, as long as you save the prompt/workflow in 2 separate Exif tags
## Otherwise, [ExifTool] will issue Warning: Invalid EXIF text encoding for UserComment
# entryOffset 10
# tag 37510
# type 2
@@ -464,9 +494,17 @@ 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
# both prompt and workflow must be in IFD close together of that can cause problems for the parseIFD function on import
# https://exiftool.org/TagNames/EXIF.html
# exif[0x9286] = "Prompt: " + json.dumps(metadata['prompt']) # UserComment
exif[0x010f] = "Prompt: " + json.dumps(metadata['prompt']) # Make
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
@@ -502,35 +540,44 @@ 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=90):
# 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
# TODO: see if convert_hdr_to_8bit=False make a change
# https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html
if output_ext in ['.avif', '.webp', '.jxl']:
if save_metadata: kwargs["exif"] = self.get_metadata_exif(img, prompt, extra_pnginfo)
if quality == 100:
kwargs["lossless"] = True
else:
kwargs["quality"] = quality
if output_ext in ['.j2k', '.jp2', '.jpc', '.jpf', '.jpx', '.j2c']:
if save_metadata: kwargs["exif"] = self.get_metadata_exif(img, prompt, extra_pnginfo)
if quality < 100:
kwargs["irreversible"] = True
# there is no such thing as compression level in JPEG2000. Read https://comprimato.com/blog/2017/06/22/bitrate-control-quality-layers-jpeg2000/
# kwargs["quality_mode"] = 'rates' or 'dB'
# kwargs["quality_layers"] = [0,1,2] no refence online. i tried all values from 0 to 100 and no change in filesize
else:
kwargs["quality"] = quality
elif output_ext in ['.jpg', '.jpeg']:
if save_metadata: kwargs["exif"] = self.get_metadata_exif(img, prompt, extra_pnginfo)
# https://stackoverflow.com/questions/19303621/why-is-the-quality-of-jpeg-images-produced-by-pil-so-poor
kwargs["subsampling"] = 0
kwargs["quality"] = quality
elif output_ext in ['.tiff']:
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)
@@ -559,6 +606,7 @@ class SaveImageExtended:
positive_text_opt=None,
extra_pnginfo=None,
prompt=None,
quality=75,
):
# print(f"filename_prefix = x{filename_prefix}x")
@@ -611,7 +659,7 @@ 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', timestamp)
+46
View File
@@ -0,0 +1,46 @@
import { app } from "../../../scripts/app.js";
// Adds context menu entries, code partly from pyssssscustom-scripts
function addMenuHandler(nodeType, cb) {
const getOpts = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function () {
const r = getOpts.apply(this, arguments);
cb.apply(this, arguments);
return r;
};
}
function addNode(name, nextTo, options) {
console.log("name:", name);
console.log("nextTo:", nextTo);
options = { side: "left", select: true, shiftY: 0, shiftX: 0, ...(options || {}) };
const node = LiteGraph.createNode(name);
app.graph.add(node);
node.pos = [
options.side === "left" ? nextTo.pos[0] - (node.size[0] + options.offset): nextTo.pos[0] + nextTo.size[0] + options.offset,
nextTo.pos[1] + options.shiftY,
];
if (options.select) {
app.canvas.selectNode(node, false);
}
return node;
}
app.registerExtension({
name: "SIEContextmenu",
async setup(app) {
app.ui.settings.addSetting({
id: "SIE.helpPopup",
name: "🦛 SIE: Help popups",
defaultValue: true,
type: "boolean",
options: (value) => [
{ value: true, text: "On", selected: value === true },
{ value: false, text: "Off", selected: value === false },
],
});
}
});
+329
View File
@@ -0,0 +1,329 @@
import { app } from "../../../scripts/app.js";
// code based on mtb nodes by Mel Massadian https://github.com/melMass/comfy_mtb/ and sieNodes
export const loadScript = (
FILE_URL,
async = true,
type = 'text/javascript',
) => {
return new Promise((resolve, reject) => {
try {
// Check if the script already exists
const existingScript = document.querySelector(`script[src="${FILE_URL}"]`)
if (existingScript) {
resolve({ status: true, message: 'Script already loaded' })
return
}
const scriptEle = document.createElement('script')
scriptEle.type = type
scriptEle.async = async
scriptEle.src = FILE_URL
scriptEle.addEventListener('load', (ev) => {
resolve({ status: true })
})
scriptEle.addEventListener('error', (ev) => {
reject({
status: false,
message: `Failed to load the script ${FILE_URL}`,
})
})
document.body.appendChild(scriptEle)
} catch (error) {
reject(error)
}
})
}
loadScript('/save_image_extended/js/marked.min.js').catch((e) => {
console.log(e)
})
loadScript('/save_image_extended/js/purify.min.js').catch((e) => {
console.log(e)
})
const categories = ["image"];
app.registerExtension({
name: "SIE.HelpPopup",
async beforeRegisterNodeDef(nodeType, nodeData) {
// console.log(`nodeType: ${nodeType}`)
// console.log(`nodeType: ${nodeData}`)
if (app.ui.settings.getSettingValue("SIE.helpPopup") === false) {
return;
}
try {
categories.forEach(category => {
if (nodeData?.category?.startsWith(category)) {
addDocumentation(nodeData, nodeType);
}
else return
});
} catch (error) {
console.error("Error in registering SIE.HelpPopup", error);
}
},
});
const create_documentation_stylesheet = () => {
const tag = 'sie-documentation-stylesheet'
let styleTag = document.head.querySelector(tag)
// color: var(--fg-color); will be black even in dark mode... bug but i can't tell where it comes from
if (!styleTag) {
styleTag = document.createElement('style')
styleTag.type = 'text/css'
styleTag.id = tag
styleTag.innerHTML = `
.sie-documentation-popup {
background: var(--comfy-menu-bg);
position: absolute;
color: white;
font: 12px monospace;
line-height: 1.5em;
padding: 10px;
border-radius: 10px;
border-style: solid;
border-width: medium;
border-color: var(--border-color);
z-index: 5;
overflow: hidden;
}
.content-wrapper {
overflow: auto;
max-height: 100%;
/* Scrollbar styling for Chrome */
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: var(--bg-color);
}
&::-webkit-scrollbar-thumb {
background-color: var(--fg-color);
border-radius: 6px;
border: 3px solid var(--bg-color);
}
/* Scrollbar styling for Firefox */
scrollbar-width: thin;
scrollbar-color: var(--fg-color) var(--bg-color);
a {
color: yellow;
}
a:visited {
color: orange;
}
a:hover {
color: red;
}
}
`
document.head.appendChild(styleTag)
}
}
/** Add documentation widget to the selected node */
export const addDocumentation = (
nodeData,
nodeType,
opts = { icon_size: 14, icon_margin: 4 },) => {
opts = opts || {}
const iconSize = opts.icon_size ? opts.icon_size : 14
const iconMargin = opts.icon_margin ? opts.icon_margin : 4
let docElement = null
let contentWrapper = null
//if no description in the node python code, don't do anything
if (!nodeData.description) {
return
}
const drawFg = nodeType.prototype.onDrawForeground
nodeType.prototype.onDrawForeground = function (ctx) {
const r = drawFg ? drawFg.apply(this, arguments) : undefined
if (this.flags.collapsed) return r
// icon position
const x = this.size[0] - iconSize - iconMargin
// create the popup
if (this.show_doc && docElement === null) {
docElement = document.createElement('div')
contentWrapper = document.createElement('div');
docElement.appendChild(contentWrapper);
create_documentation_stylesheet()
contentWrapper.classList.add('content-wrapper');
docElement.classList.add('sie-documentation-popup')
//parse the string from the python node code to html with marked, and sanitize the html with DOMPurify
contentWrapper.innerHTML = DOMPurify.sanitize(marked.parse(nodeData.description,))
// resize handle
const resizeHandle = document.createElement('div');
resizeHandle.style.width = '0';
resizeHandle.style.height = '0';
resizeHandle.style.position = 'absolute';
resizeHandle.style.bottom = '0';
resizeHandle.style.right = '0';
resizeHandle.style.cursor = 'se-resize';
// Add pseudo-elements to create a triangle shape
const borderColor = getComputedStyle(document.documentElement).getPropertyValue('--border-color').trim();
resizeHandle.style.borderTop = '10px solid transparent';
resizeHandle.style.borderLeft = '10px solid transparent';
resizeHandle.style.borderBottom = `10px solid ${borderColor}`;
resizeHandle.style.borderRight = `10px solid ${borderColor}`;
docElement.appendChild(resizeHandle)
let isResizing = false
let startX, startY, startWidth, startHeight
resizeHandle.addEventListener('mousedown', function (e) {
e.preventDefault();
e.stopPropagation();
isResizing = true;
startX = e.clientX;
startY = e.clientY;
startWidth = parseInt(document.defaultView.getComputedStyle(docElement).width, 10);
startHeight = parseInt(document.defaultView.getComputedStyle(docElement).height, 10);
},
{ signal: this.docCtrl.signal },
);
// close button
const closeButton = document.createElement('div');
closeButton.textContent = '❌';
closeButton.style.position = 'absolute';
closeButton.style.top = '0';
closeButton.style.right = '0';
closeButton.style.cursor = 'pointer';
closeButton.style.padding = '5px';
closeButton.style.color = 'red';
closeButton.style.fontSize = '12px';
docElement.appendChild(closeButton)
closeButton.addEventListener('mousedown', (e) => {
e.stopPropagation();
this.show_doc = !this.show_doc
docElement.parentNode.removeChild(docElement)
docElement = null
if (contentWrapper) {
contentWrapper.remove()
contentWrapper = null
}
},
{ signal: this.docCtrl.signal },
);
document.addEventListener('mousemove', function (e) {
if (!isResizing) return;
const scale = app.canvas.ds.scale;
const newWidth = startWidth + (e.clientX - startX) / scale;
const newHeight = startHeight + (e.clientY - startY) / scale;;
docElement.style.width = `${newWidth}px`;
docElement.style.height = `${newHeight}px`;
},
{ signal: this.docCtrl.signal },
);
document.addEventListener('mouseup', function () {
isResizing = false
},
{ signal: this.docCtrl.signal },
)
document.body.appendChild(docElement)
}
// close the popup
else if (!this.show_doc && docElement !== null) {
docElement.parentNode.removeChild(docElement)
docElement = null
}
// update position of the popup
if (this.show_doc && docElement !== null) {
const rect = ctx.canvas.getBoundingClientRect()
const scaleX = rect.width / ctx.canvas.width
const scaleY = rect.height / ctx.canvas.height
const transform = new DOMMatrix()
.scaleSelf(scaleX, scaleY)
.multiplySelf(ctx.getTransform())
.translateSelf(this.size[0] * scaleX * Math.max(1.0,window.devicePixelRatio) , 0)
.translateSelf(10, -32)
const scale = new DOMMatrix()
.scaleSelf(transform.a, transform.d);
const styleObject = {
transformOrigin: '0 0',
transform: scale,
left: `${transform.a + transform.e}px`,
top: `${transform.d + transform.f}px`,
};
Object.assign(docElement.style, styleObject);
}
ctx.save()
ctx.translate(x - 2, iconSize - 34)
ctx.scale(iconSize / 32, iconSize / 32)
ctx.strokeStyle = 'rgba(255,255,255,0.3)'
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.lineWidth = 2.4
ctx.font = 'bold 36px monospace'
ctx.fillStyle = 'orange';
ctx.fillText('?', 0, 24)
ctx.restore()
return r
}
// handle clicking of the icon
const mouseDown = nodeType.prototype.onMouseDown
nodeType.prototype.onMouseDown = function (e, localPos, canvas) {
const r = mouseDown ? mouseDown.apply(this, arguments) : undefined
const iconX = this.size[0] - iconSize - iconMargin
const iconY = iconSize - 34
if (
localPos[0] > iconX &&
localPos[0] < iconX + iconSize &&
localPos[1] > iconY &&
localPos[1] < iconY + iconSize
) {
if (this.show_doc === undefined) {
this.show_doc = true
} else {
this.show_doc = !this.show_doc
}
if (this.show_doc) {
this.docCtrl = new AbortController()
} else {
this.docCtrl.abort()
}
return true;
}
return r;
}
const onRem = nodeType.prototype.onRemoved
nodeType.prototype.onRemoved = function () {
const r = onRem ? onRem.apply(this, []) : undefined
if (docElement) {
docElement.remove()
docElement = null
}
if (contentWrapper) {
contentWrapper.remove()
contentWrapper = null
}
return r
}
}
+6
View File
File diff suppressed because one or more lines are too long
+3
View File
File diff suppressed because one or more lines are too long
+157
View File
@@ -0,0 +1,157 @@
// code based on mtb nodes by Mel Massadian https://github.com/melMass/comfy_mtb/ and KJNodes
export function loadScript(
FILE_URL,
async = true,
type = 'text/javascript',
) {
return new Promise((resolve, reject) => {
try {
// Check if the script already exists
const existingScript = document.querySelector(`script[src="${FILE_URL}"]`)
if (existingScript) {
resolve({ status: true, message: 'Script already loaded' })
return
}
const scriptEle = document.createElement('script')
scriptEle.type = type
scriptEle.async = async
scriptEle.src = FILE_URL
scriptEle.addEventListener('load', (ev) => {
resolve({ status: true })
})
scriptEle.addEventListener('error', (ev) => {
reject({
status: false,
message: `Failed to load the script ${FILE_URL}`,
})
})
document.body.appendChild(scriptEle)
} catch (error) {
reject(error)
}
})
}
export function getResolver(timeout = 5000) {
const resolver = {};
resolver.id = generateId(8);
resolver.completed = false;
resolver.resolved = false;
resolver.rejected = false;
resolver.promise = new Promise((resolve, reject) => {
resolver.reject = () => {
resolver.completed = true;
resolver.rejected = true;
reject();
};
resolver.resolve = (data) => {
resolver.completed = true;
resolver.resolved = true;
resolve(data);
};
});
resolver.timeout = setTimeout(() => {
if (!resolver.completed) {
resolver.reject();
}
}, timeout);
return resolver;
}
export function wait(ms = 16, value) {
if (ms === 16) {
return new Promise((resolve) => {
requestAnimationFrame(resolve);
});
}
return new Promise((resolve) => {
setTimeout(() => {
resolve(value);
}, ms);
});
}
function dec2hex(dec) {
return dec.toString(16).padStart(2, "0");
}
export function generateId(length) {
const arr = new Uint8Array(length / 2);
crypto.getRandomValues(arr);
return Array.from(arr, dec2hex).join("");
}
export function getObjectValue(obj, objKey, def) {
if (!obj || !objKey)
return def;
const keys = objKey.split(".");
const key = keys.shift();
const found = obj[key];
if (keys.length) {
return getObjectValue(found, keys.join("."), def);
}
return found;
}
export function setObjectValue(obj, objKey, value, createMissingObjects = true) {
if (!obj || !objKey)
return obj;
const keys = objKey.split(".");
const key = keys.shift();
if (obj[key] === undefined) {
if (!createMissingObjects) {
return;
}
obj[key] = {};
}
if (!keys.length) {
obj[key] = value;
}
else {
if (typeof obj[key] != "object") {
obj[key] = {};
}
setObjectValue(obj[key], keys.join("."), value, createMissingObjects);
}
return obj;
}
export function moveArrayItem(arr, itemOrFrom, to) {
const from = typeof itemOrFrom === "number" ? itemOrFrom : arr.indexOf(itemOrFrom);
arr.splice(to, 0, arr.splice(from, 1)[0]);
}
export function removeArrayItem(arr, itemOrIndex) {
const index = typeof itemOrIndex === "number" ? itemOrIndex : arr.indexOf(itemOrIndex);
arr.splice(index, 1);
}
export function injectCss(href) {
if (document.querySelector(`link[href^="${href}"]`)) {
return Promise.resolve();
}
return new Promise((resolve) => {
const link = document.createElement('link');
link.setAttribute('rel', "stylesheet");
link.setAttribute('type', "text/css");
const timeout = setTimeout(resolve, 1000);
link.addEventListener("load", (e) => {
clearInterval(timeout);
resolve();
});
link.href = href;
document.head.appendChild(link);
});
}
export function injectJs(href) {
if (document.querySelector(`script[src^="${href}"]`)) {
return Promise.resolve();
}
return new Promise((resolve) => {
const script = document.createElement('script');
script.setAttribute('type', "text/javascript");
const timeout = setTimeout(resolve, 1000);
script.addEventListener("load", (e) => {
clearInterval(timeout);
resolve();
});
script.src = href;
document.head.appendChild(script);
});
}