Compare commits

...
25 Commits
Author SHA1 Message Date
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
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
audioscavenger 906d2b9669 release 2.50 JXL
modified:   README.md
	modified:   assets/save-image-extended-comfyui-example.png
	modified:   save_image_extended.py
2024-05-23 09:20:58 -07:00
audioscavenger 53b9270e12 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
	modified:   README.md
	modified:   save_image_extended.py
2024-05-21 07:29:10 -07:00
audioscavenger 61f89b803f 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
	modified:   README.md
	modified:   __init__.py
	new file:   assets/bug-rgthree-ksampler_config-steps_total.png
	modified:   save_image_extended.py
2024-05-21 07:07:13 -07:00
audioscavenger a2f4b00fa1 WIP 2.45 💾
- new title
	modified:   README.md
	modified:   save_image_extended.py
2024-05-11 11:44:40 -07:00
audioscavenger aabd212f33 release 2.44 worky
- final bugfixes and working loop for filename building, tested for all case scenarios
	modified:   save_image_extended.py
	modified:   .gitignore
	deleted:    commit.gitsync
    	modified:   README.md
2024-05-08 11:22:12 -07:00
19 changed files with 927 additions and 130 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 }}
+155 -58
View File
@@ -1,5 +1,7 @@
# Save Image Extended for ComfyUI
**AVIF** support!
# 💾 Save Image Extended for ComfyUI
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" />
@@ -7,41 +9,18 @@
* 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_
* 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
```
### Installation
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.
```
git clone https://github.com/audioscavenger/save-image-extended-comfyui
```
## Parameters / Usage
| Attribute | Description |
| --- | --- |
| `filename_prefix` | String prefix added to files. |
@@ -59,7 +38,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
@@ -69,45 +50,111 @@ 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>
## 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?
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 90, 100 will activate **lossless** for AVIF and WEBP only.
Quick comparison of size per extension, for the same 512x512 picture, with similar visual quality:
| Ext | Compression | Maker | Size |
| --- | --- | --- | --- |
| png | max 9 | PIL | 413111 |
| j2k | n/a | PIL | 395028 |
| jxl | lossless | PIL | 301310 |
| jxl | 90 | PIL | 179210 |
| jpeg | 90 | PIL | 88554 |
| avif | 90 | Imagick | 67272 |
| webp | 90 | Imagick | 64416 |
| webp | 90 | PIL | 64356 |
| avif | 60 | PIL | 47353 |
| avif | 60 | Imagick | 33691 |
* AVIF quality is fixed at 50.
* WebP quality is fixed at 75.
* JPEG quality is fixed at 91.
* PNG is maxed compressed (9)
#
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 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.
#
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" />
@@ -117,28 +164,69 @@ jobs.json sample:
## RoadMap
*Reboot by AudioscavengeR since 2024-05-05, original idea from [@thedyze](https://github.com/thedyze/save-image-extended-comfyui)*
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.
However, I do provide a way to contact me, and will accept PR and collabs.
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.
- [ ] 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
TODO:
- [ ] 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
### 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
- [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
@@ -149,8 +237,8 @@ Once I feel like I don't have time to work on it, I will gladly transfer ownersh
- [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
@@ -172,3 +260,12 @@ Once I feel like I don't have time to work on it, I will gladly transfer ownersh
### 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())]
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+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: 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

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
+163 -71
View File
@@ -11,12 +11,36 @@ import pprint
import piexif
import piexif.helper
version = 2.70
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
# PIL must be loaded after pillow_avif
# Jxl requires jxlpy wheel to be compiled, and a valid MSVC environment, which is complex task
try:
# 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 plugins
from PIL import Image, ExifTags
from PIL.PngImagePlugin import PngInfo
@@ -26,20 +50,41 @@ original_locale = locale.setlocale(locale.LC_TIME, '')
# class SaveImageExtended -------------------------------------------------------------------------------
class SaveImageExtended:
version = 2.44
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
avif_quality = 91
webp_quality = 91
jpeg_quality = 91
avif_quality = 60
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
@@ -49,15 +94,17 @@ class SaveImageExtended:
one_counter_per_folder = True
image_preview = True
extToRemove = ['.safetensors', '.ckpt', '.pt', '.bin', '.pth']
output_ext = '.webp'
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 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\n")
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:
# 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):
self.output_dir = folder_paths.get_output_directory()
@@ -95,9 +142,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,
@@ -106,9 +153,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}),
@@ -117,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()
@@ -136,7 +185,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:
@@ -154,7 +203,7 @@ class SaveImageExtended:
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
@@ -212,14 +261,17 @@ 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}")
# 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
@@ -229,17 +281,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:
# key is a fsubfolder
# 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 ".string" or "string.string"
if len(splitKey) > 1:
# 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
@@ -258,12 +314,16 @@ class SaveImageExtended:
# 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
# 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, 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
@@ -307,7 +367,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:
@@ -379,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)
@@ -389,10 +449,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)
@@ -420,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
@@ -433,7 +494,18 @@ 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
# 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
# exif[ExifTags.Base.UserComment] = piexif.helper.UserComment.dump(json.dumps(metadata), encoding="ascii") # type 1
@@ -468,29 +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["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
# 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)
@@ -519,6 +606,7 @@ class SaveImageExtended:
positive_text_opt=None,
extra_pnginfo=None,
prompt=None,
quality=75,
):
# print(f"filename_prefix = x{filename_prefix}x")
@@ -537,8 +625,12 @@ class SaveImageExtended:
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, prompt)
custom_foldername = self.generate_custom_name(foldername_keys_to_extract, foldername_prefix, delimiter, prompt)
################################## 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()
@@ -567,20 +659,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()
@@ -593,7 +685,7 @@ NODE_CLASS_MAPPINGS = {
NODE_DISPLAY_NAME_MAPPINGS = {
'SaveImageExtended': 'Save Image Extended',
'SaveImageExtended': '💾 Save Image Extended',
}
+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);
});
}