Compare commits

...
27 Commits
Author SHA1 Message Date
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
audioscavenger c2fd1f396d 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:20:46 -07:00
audioscavenger 6646775342 release 2.44 worky
- final bugfixes and working loop for filename building, tested for all case scenarios
	modified:   README.md
    	modified:   save_image_extended.py
	modified:   .gitignore
	deleted:    commit.gitsync
	deleted:    commit.gitsync.2.43.txt
	deleted:    commit.gitsync.2.44.txt
2024-05-08 11:12:17 -07:00
audioscavenger 9b6c9df2a0 release 2.44 worky
- final bugfixes and working loop for filename building, tested for all case scenarios
    	modified:   README.md
    	modified:   commit.gitsync.2.43.txt
    	modified:   save_image_extended.py
	modified:   save_image_extended.py
	modified:   commit.gitsync.2.44.txt
2024-05-08 11:11:18 -07:00
audioscavenger 983f5316f6 release 2.44 worky
- final bugfixes and working loop for filename building, tested for all case scenarios
	modified:   README.md
	modified:   commit.gitsync.2.43.txt
	modified:   save_image_extended.py
2024-05-08 10:14:49 -07:00
audioscavenger 4ab2249c02 release 2.43 AVIF
- bugfix: rework of generate_custom_name to cover all subfolders scenarios
- [x] support for AVIF
- [x] added requirements.txt
    	modified:   README.md
    	modified:   save_image_extended.py
	modified:   save_image_extended.py
	modified:   commit.gitsync.2.43.txt
2024-05-07 22:35:00 -07:00
20 changed files with 1003 additions and 176 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 }}
+1
View File
@@ -2,6 +2,7 @@ backup/
*.7z
*.zip
gitsync*
commit.*
*.url
*.lnk
desktop.ini
+136 -49
View File
@@ -1,5 +1,7 @@
# Save Image Extended for ComfyUI
version = 2.43 - **AVIF** support!
# 💾 Save Image Extended for ComfyUI
Save as AVIF, WebP, JPEG, customize the folder, sub-folders, and filenames of your images!
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,39 +9,18 @@ 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_
* Promt saved in jpeg and AVIF, but ComfyUI can only load 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:
- 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. |
@@ -57,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
@@ -67,41 +50,92 @@ 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
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.
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
- 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
```
## Miscelaneous
#
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 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" />
@@ -111,20 +145,64 @@ 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.
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?
- [ ] 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
- [ ] 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.6.5 💾
- 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
### release 2.43
- [x] support for AVIF
- [x] support for **AVIF**
- [x] added requirements.txt
### release 2.42
@@ -135,8 +213,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
@@ -158,3 +236,12 @@ However, I do provide a way to contact me, and will accept PR and collabs. Once
### 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

-1
View File
@@ -1 +0,0 @@
WIP = =.txt
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.64"
license = "LICENCE"
dependencies = ["piexif", "pillow", "pillow-avif-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 = ""
+262 -125
View File
@@ -11,10 +11,30 @@ import pprint
import piexif
import piexif.helper
version = 2.6.5
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,21 +46,40 @@ original_locale = locale.setlocale(locale.LC_TIME, '')
# class SaveImageExtended -------------------------------------------------------------------------------
class SaveImageExtended:
#Version: 2.43
version = 2.43
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 = 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 = '_'
save_job_data = 'basic, models, sampler, prompt'
filename_prefix = 'ComfyUI'
filename_keys = 'sampler_name, cfg, steps, %F %H-%M-%S'
foldername_prefix = ''
foldername_keys = 'ckpt_name'
delimiter = '-'
save_job_data = 'disabled'
job_data_per_image = False
job_custom_text = ''
save_metadata = True
@@ -49,16 +88,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 +135,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 +147,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}),
@@ -118,11 +165,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()
@@ -137,7 +179,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 +189,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 +211,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 +249,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 +275,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:
@@ -340,7 +433,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)
@@ -350,10 +443,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)
@@ -383,7 +476,8 @@ class SaveImageExtended:
## 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.
## 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
@@ -394,7 +488,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
@@ -429,29 +534,42 @@ 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()
# TODO: see if convert_hdr_to_8bit=False make a change
# 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 +577,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 +651,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 +677,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 },
],
});
}
});
+328
View File
@@ -0,0 +1,328 @@
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)
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: var(--fg-color);
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);
});
}