Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0d5dd8aa6 | ||
|
|
9ec40cbebf | ||
|
|
8c91af7243 | ||
|
|
a25b5091c3 | ||
|
|
c4aeb168b6 | ||
|
|
266cef3800 | ||
|
|
18bc84204e | ||
|
|
569f4c1ec2 | ||
|
|
40aeed0e3b | ||
|
|
d72b8e5428 | ||
|
|
d38ac58316 | ||
|
|
8c9326eb65 | ||
|
|
dba9e539f9 | ||
|
|
a9504f9e63 | ||
|
|
d53328c8ac |
@@ -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 }}
|
||||
|
||||
@@ -6,4 +6,6 @@ commit.*
|
||||
*.url
|
||||
*.lnk
|
||||
desktop.ini
|
||||
*.code-workspace
|
||||
|
||||
__pycache__/
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 💾 Save Image Extended for ComfyUI
|
||||
Supports those extensions: **AVIF WebP JXL jpg jpeg png gif tiff bmp**
|
||||
Save as JXL, AVIF, WebP, JPEG, JPEG2k, customize the folder, sub-folders, and filenames of your images!
|
||||
|
||||
Supports those extensions: **JXL AVIF WebP jpg jpeg j2k jp2 png gif tiff bmp**
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/save-image-extended-comfyui-example.png" />
|
||||
@@ -8,7 +10,7 @@ Supports those extensions: **AVIF WebP JXL jpg jpeg png gif tiff bmp**
|
||||
* 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_
|
||||
* Promt saved in jpeg and AVIF, but ComfyUI can only load PNG and WebP atm
|
||||
* Warning: ComfyUI can only load prompts from PNG and WebP atm
|
||||
|
||||
|
||||
<br>
|
||||
@@ -36,9 +38,14 @@ Supports those extensions: **AVIF WebP JXL jpg jpeg png gif tiff bmp**
|
||||
| `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: if you enter `wrongNumber.attribute`, you will get `attribute` in your filename.
|
||||
|
||||
Datetime UNIX format is now included! `%Y-%m-%d` or `%F` etc
|
||||
- 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
|
||||
- default it output only the **name** for `ckpt_name` and `control_net_name`
|
||||
- use `ckpt_path` or `control_net_path` in case you have subfolders for those and want to use them as subfolders
|
||||
- using `.custom_string` will prevent appending delimiter, the dot will be the delimiter
|
||||
|
||||
|
||||
## Node inputs
|
||||
|
||||
@@ -69,17 +76,19 @@ 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 need to install and compile the wheel jxlpy, and therefore, need a valid and functional MSVC installation.
|
||||
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 jxlpy
|
||||
pip install -U imagecodecs
|
||||
```
|
||||
|
||||
### Manual Download
|
||||
@@ -90,27 +99,45 @@ git clone https://github.com/audioscavenger/save-image-extended-comfyui
|
||||
```
|
||||
|
||||
## Miscelaneous
|
||||
#
|
||||
[JPEG XL is a heated debate on chromium forum](https://issues.chromium.org/issues/40168998#comment85) and if true indeed that Google is working on WebP2, jxl is unlikely to take off any day soon. Proponents arguably declare with no proof, that jxl is better and faster than the current best codec: AVIF. But again, without support from the industry, it's going nowhere.
|
||||
|
||||
I tested with compression 90 and it's good, with a caveat. The compression offered by pillow is 3x lower then Image Magick for the same level. No idea why.
|
||||
|
||||
#
|
||||
Pillow cannot save Exif data in JPEG2000, nor can it compress it in any way. Who the heck is using JPEG2000 in 2024 anyway?
|
||||
|
||||
#
|
||||
Disclaimer: Does not check for illegal characters entered in file or folder names. May not be compatible with every other custom node, depending on changes in the `prompt` object.
|
||||
Tested and working with default samplers, Efficiency nodes, UltimateSDUpscale, ComfyRoll, composer, NegiTools, and 45 other nodes.
|
||||
|
||||
#
|
||||
Quality and compression settings: default is 75, 100 will activate **lossless** for AVIF and WEBP only.
|
||||
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 picture, AVIF quality=60, WebP quality=75, jpeg quality=91 (and yes, AVIF still looks better then WebP):
|
||||
Quick comparison of size per extension, for the same 512x512 picture, with similar visual quality:
|
||||
| Ext | Compression | Maker | Size | Compression |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| png | max 9 | PIL | 413111 | 0% |
|
||||
| j2k | n/a | PIL | 395028 | 4% |
|
||||
| jxl | lossless | PIL | 301310 | 27% |
|
||||
| jxl | 90 | PIL | 179210 | 57% |
|
||||
| jpeg | 90 | PIL | 88554 | 79% |
|
||||
| avif | 90 | Imagick | 67272 | 84% |
|
||||
| webp | 90 | Imagick | 64416 | 84% |
|
||||
| webp | 90 | PIL | 64356 | 84% |
|
||||
| avif | 60 | PIL | 47353 | 89% |
|
||||
| avif | 60 | Imagick | 33691 | 92% |
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/save-image-extended-sizes-comparison.png" />
|
||||
</p>
|
||||
|
||||
#
|
||||
About extensions WebP AVIF JPEG JXL: ComfyUI can only load PNG and WebP atm... Feel free to ask ComfyUI team to add support for AVIF/jpeg/JXL!
|
||||
|
||||
The metadata Are included under the **EXIF** tags IFD0 as defined [here](https://exiftool.org/TagNames/EXIF.html)
|
||||
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 | 0x9286 | UserComment | Prompt: {"5" ... } |
|
||||
| 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:
|
||||
@@ -149,14 +176,36 @@ Once I feel like I don't have time to work on it, I will gladly transfer ownersh
|
||||
|
||||
TODO:
|
||||
|
||||
- [ ] ComfyRoll CR XY Save Grid Image: it offers jpeg webp tif - check how it embeds prompt and see if that works better with Comfy!
|
||||
- [ ] offer quality setting in the node?
|
||||
- [ ] remove save_job_to_json? thisis pretty much useless actually, since it only saves the last value found for each node.
|
||||
- [ ] remove job_custom_text? what is this for?
|
||||
- [ ] remove jobs.json? jobs history, alright. What do you do with that? images contain the prompt, what is this for?
|
||||
- [ ] improve get_latest_counter: fails when user renames files: appends text after counter
|
||||
- [ ] accept multiple images as input
|
||||
- [ ] 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.74 💾
|
||||
- updated help popup and minor cosmetic fixes
|
||||
|
||||
### release 2.73 💾
|
||||
- added ckpt_path and control_net_path in case you have subfolders for those: default it output only the name
|
||||
- complete fix of save_images() which allows for use of subfolders in filename_keys
|
||||
- tried VALIDATE_INPUTS() but all it does is checking 1 single value, no correction
|
||||
|
||||
### 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
|
||||
@@ -227,3 +276,10 @@ TODO:
|
||||
|
||||
## :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
@@ -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())]
|
||||
)
|
||||
|
||||
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
Vendored
+3
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
BIN
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
[project]
|
||||
name = "save-image-extended-comfyui"
|
||||
description = "Save as JXL, AVIF, WebP, JPEG, JPEG2k, customize the folder, sub-folders, and filenames of your images!"
|
||||
version = "2.74"
|
||||
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 = ""
|
||||
@@ -1,3 +1,7 @@
|
||||
piexif
|
||||
# jpegxl support: imagecodecs will bring libjxl 0.9.0
|
||||
imagecodecs
|
||||
|
||||
pillow
|
||||
pillow-avif-plugin
|
||||
pillow-jxl-plugin
|
||||
|
||||
+188
-75
@@ -11,6 +11,8 @@ import pprint
|
||||
import piexif
|
||||
import piexif.helper
|
||||
|
||||
version = 2.74
|
||||
|
||||
avif_supported = False
|
||||
jxl_supported = False
|
||||
|
||||
@@ -18,23 +20,27 @@ jxl_supported = False
|
||||
try:
|
||||
import pillow_avif
|
||||
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")
|
||||
print(f"\033[92m[💾 save_image_extended]\033[0m AVIF is not supported. To add it: pip install pillow pillow-avif-plugin\033[0m")
|
||||
pass
|
||||
else:
|
||||
print(f"\033[92m[save_image_extended] AVIF is supported! Woohoo!\033[0m")
|
||||
print(f"\033[92m[💾 save_image_extended] AVIF is supported! Woohoo!\033[0m")
|
||||
avif_supported = True
|
||||
|
||||
# Jxl requires jxlpy wheel to be compiled, and a valid MSVC environment, which is complex task
|
||||
try:
|
||||
from jxlpy import JXLImagePlugin
|
||||
# jxlpy is in early stages of development. None one has ever compiled it on Windows AFAIK
|
||||
# from jxlpy import JXLImagePlugin
|
||||
# from imagecodecs import (jpegxl_encode, jpegxl_decode, jpegxl_check, jpegxl_version, JPEGXL)
|
||||
import pillow_jxl
|
||||
except:
|
||||
print(f"\033[92m[save_image_extended]\033[0m JXL is not supported. To add it: pip install jxlpy\033[0m")
|
||||
print(f"\033[92m[save_image_extended]\033[0m You will need a valid MSVC env to build the wheel\033[0m")
|
||||
print(f"\033[92m[💾 save_image_extended]\033[0m JXL is not supported. To add it: pip install jxlpy\033[0m")
|
||||
print(f"\033[92m[💾 save_image_extended]\033[0m You will need a valid MSVC env to build the wheel\033[0m")
|
||||
pass
|
||||
else:
|
||||
print(f"\033[92m[💾 save_image_extended] JPEGXL is supported! YeePee!\033[0m")
|
||||
jxl_supported = True
|
||||
|
||||
# PIL must be loaded after pillow_avif
|
||||
# PIL must be loaded after pillow plugins
|
||||
from PIL import Image, ExifTags
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
@@ -44,16 +50,49 @@ original_locale = locale.setlocale(locale.LC_TIME, '')
|
||||
|
||||
# class SaveImageExtended -------------------------------------------------------------------------------
|
||||
class SaveImageExtended:
|
||||
version = 2.62
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = 'save_images'
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = 'image'
|
||||
DESCRIPTION = """
|
||||
## Advice
|
||||
It is strongly advised to enable Badge numbers in the Manager: _#ID Nickname_
|
||||
|
||||
### Default behavior
|
||||
- Workflows with multiple of the same node:
|
||||
- if you don't specify the node number in your keys like _13.sampler_name_,
|
||||
- then the highest node number value will be returned
|
||||
- if your checkpoint/controlnet is in subfolders like SDXL/whatnot, `ckpt_name` will **not** have subfolders
|
||||
- to get the subfolders from controlnets and checkpoints, use _ckpt_path_ and _control_net_path_
|
||||
|
||||
### subfolders
|
||||
- you can use `/` as the separator, or use keys such as `/name` or `name/` as a folder separator
|
||||
- you can use `/` or `./` or even `../` anywhere in your keys
|
||||
- there can be subfolders in _filename\_keys_ as well, altough it will mess the order of your keys
|
||||
|
||||
### Datetime formats
|
||||
See [man datetime](https://www.man7.org/linux/man-pages/man1/date.1.html) for all possible values
|
||||
- %F = %Y-%m-%d = 2024-05-22
|
||||
- %H-%M-%S = 09-13-58
|
||||
- %D = 05/22/24 (subfolders)
|
||||
|
||||
### 💾 Prompt Embedded
|
||||
Prompt and Workflows are embedded in every files except BMP.
|
||||
They are saved in Exif tags _Make_ [0x010f] and _ImageDescription_ [0x010e].
|
||||
ComfyUI can only load PNG and WebP at the moment, AVIF is a PR still not merged.
|
||||
|
||||
"""
|
||||
|
||||
type = 'output'
|
||||
|
||||
png_compress_level = 9
|
||||
avif_quality = 60
|
||||
webp_quality = 75
|
||||
jpeg_quality = 91
|
||||
jxl_quality = 91
|
||||
webp_quality = 90
|
||||
jpeg_quality = 90
|
||||
jxl_quality = 90
|
||||
j2k_quality = 90
|
||||
# tiff_quality has no impact unless you start meddling with the compressions algorithms
|
||||
tiff_quality = 91
|
||||
tiff_quality = 90
|
||||
# optimize_image only works for jpeg, with like just 2% reduction in size
|
||||
optimize_image = True
|
||||
|
||||
@@ -73,23 +112,23 @@ class SaveImageExtended:
|
||||
image_preview = True
|
||||
extToRemove = ['.safetensors', '.ckpt', '.pt', '.bin', '.pth']
|
||||
output_ext = '.webp'
|
||||
output_exts = ['.webp', '.png', '.jpg', '.jpeg', '.gif', '.tiff', '.bmp']
|
||||
quality = 75
|
||||
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")
|
||||
print(f"\033[92m[💾 save_image_extended]\033[0m version: {version}\033[0m")
|
||||
if jxl_supported:
|
||||
output_exts.insert(0, '.jxl')
|
||||
# if pillow_avif not in sys.modules:
|
||||
if avif_supported:
|
||||
# no matter what people say, jxl is far away from being integrated in browsers. I bet on AVIF.
|
||||
output_exts.insert(0, '.avif')
|
||||
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
self.prefix_append = ''
|
||||
|
||||
|
||||
"""
|
||||
Return a dictionary which contains config for all input fields.
|
||||
INPUT_TYPES Return a dictionary which contains config for all input fields.
|
||||
Some types (string): "MODEL", "VAE", "CLIP", "CONDITIONING", "LATENT", "IMAGE", "INT", "STRING", "FLOAT".
|
||||
Input types "INT", "STRING" or "FLOAT" are special values for fields on the node.
|
||||
The type can be a list for selection.
|
||||
@@ -104,6 +143,8 @@ class SaveImageExtended:
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(self):
|
||||
# this is checked by ComfyUI/execution.py: validate_inputs(prompt, item, validated):
|
||||
# must also define VALIDATE_INPUTS(self) so we can fill in missing inputs when new inputs are added by new versions
|
||||
return {
|
||||
'required': {
|
||||
'images': ('IMAGE', ),
|
||||
@@ -147,13 +188,27 @@ class SaveImageExtended:
|
||||
},
|
||||
'hidden': {'prompt': 'PROMPT', 'extra_pnginfo': 'EXTRA_PNGINFO'},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = 'save_images'
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = 'image'
|
||||
|
||||
|
||||
|
||||
# This class serves no purpose, it can only test 1 element. you always get all errors even if only one element is bad
|
||||
# @classmethod
|
||||
# def VALIDATE_INPUTS(self, output_ext, quality, **kwargs):
|
||||
# print(f"VALIDATE_INPUTS output_ext = x{output_ext}x")
|
||||
# print(f"VALIDATE_INPUTS quality = x{quality}x")
|
||||
# if output_ext == None or output_ext == '':
|
||||
# return "cannot be empty"
|
||||
# if output_ext not in self.output_exts:
|
||||
# return "extension invalid"
|
||||
# else:
|
||||
# return True
|
||||
# if quality == None or quality == '' or quality == 0:
|
||||
# return "cannot be empty or 0"
|
||||
# return True
|
||||
|
||||
# TODO: see how that works and how that can help
|
||||
# @classmethod
|
||||
# def IS_CHANGED(self, **kwargs):
|
||||
# return float("nan")
|
||||
|
||||
def get_subfolder_path(self, image_path, output_path):
|
||||
image_path = Path(image_path).resolve()
|
||||
output_path = Path(output_path).resolve()
|
||||
@@ -192,9 +247,19 @@ class SaveImageExtended:
|
||||
|
||||
# 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={}):
|
||||
# print(f"debug find_keys_recursively: keys_to_find={keys_to_find} found_values={found_values}")
|
||||
for key, value in prompt.items():
|
||||
if key in keys_to_find:
|
||||
found_values[key] = value
|
||||
# print(f"debug find_keys_recursively: found key={key}")
|
||||
if key == 'ckpt_name' and 'ckpt_path' in keys_to_find:
|
||||
value_path = Path(value)
|
||||
found_values['ckpt_path'] = self.cleanup_fileName(str(value_path.parent))
|
||||
# print(f"debug find_keys_recursively: ckpt_name={value_path.name} ckpt_path={str(value_path.parent)}")
|
||||
elif key == 'control_net_name' and 'control_net_path' in keys_to_find:
|
||||
value_path = Path(value)
|
||||
found_values['control_net_path'] = self.cleanup_fileName(str(value_path.parent))
|
||||
else:
|
||||
found_values[key] = self.cleanup_fileName(value)
|
||||
elif isinstance(value, dict):
|
||||
self.find_keys_recursively(value, keys_to_find, found_values)
|
||||
|
||||
@@ -263,6 +328,7 @@ class SaveImageExtended:
|
||||
# 'inputs': {'cfg': 1.6,
|
||||
# 'denoise': 1.0, ...
|
||||
for key in keys_to_extract:
|
||||
# empty comma
|
||||
if not key: continue
|
||||
|
||||
value = None
|
||||
@@ -270,7 +336,7 @@ class SaveImageExtended:
|
||||
|
||||
# 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:
|
||||
if os.sep in key and not '%' in key:
|
||||
# key is a subfolder: ./subfolder or ../subfolder or /subfolder
|
||||
value = key
|
||||
else:
|
||||
@@ -287,13 +353,23 @@ class SaveImageExtended:
|
||||
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)
|
||||
if nodeKey == 'ckpt_path':
|
||||
self.find_keys_recursively(prompt[node], ['ckpt_name', nodeKey], found_values)
|
||||
elif nodeKey == 'control_net_path':
|
||||
self.find_keys_recursively(prompt[node], ['control_net_name', nodeKey], found_values)
|
||||
else:
|
||||
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
|
||||
# if splitKey[0] = #num node not found in prompt; #num could have changed or user made a typo. Fallback to normal key search
|
||||
print(f"SaveImageExtended info: node #{node} not found")
|
||||
self.find_keys_recursively(prompt, [nodeKey], found_values)
|
||||
if nodeKey == 'ckpt_path':
|
||||
self.find_keys_recursively(prompt, ['ckpt_name', nodeKey], found_values)
|
||||
elif nodeKey == 'control_net_path':
|
||||
self.find_keys_recursively(prompt, ['control_net_name', nodeKey], found_values)
|
||||
else:
|
||||
self.find_keys_recursively(prompt, [nodeKey], found_values)
|
||||
else:
|
||||
# key is in the form string.string = fixed string
|
||||
# key is in the form string.string = fixed string; still, we remove extra stuff and known extensions
|
||||
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
|
||||
@@ -305,20 +381,24 @@ class SaveImageExtended:
|
||||
# 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)
|
||||
if nodeKey == 'ckpt_path':
|
||||
self.find_keys_recursively(prompt, ['ckpt_name', nodeKey], found_values)
|
||||
elif nodeKey == 'control_net_path':
|
||||
self.find_keys_recursively(prompt, ['control_net_name', nodeKey], found_values)
|
||||
else:
|
||||
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}")
|
||||
# print(f"debug generate_custom_name: ----nodeKey: {nodeKey}")
|
||||
# print(f"debug generate_custom_name: ----value: {value}")
|
||||
# print(f"debug generate_custom_name: ----found_values: {found_values}")
|
||||
if value is None:
|
||||
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:
|
||||
@@ -328,14 +408,32 @@ class SaveImageExtended:
|
||||
# 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('/'):
|
||||
if custom_name.endswith(os.sep):
|
||||
# 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: ---------: {os.sep} in value? {nodeKey}={value}")
|
||||
if os.sep in value and not value.endswith(os.sep):
|
||||
# print(f"debug generate_custom_name: ---------: folder")
|
||||
delim= ''
|
||||
|
||||
# now process the custom cases ckpt_path and control_net_path; maybe we should do that to `image` as well?
|
||||
# the recursive function creates ckpt_path and ckpt_name already; we just need to eliminate path if == '.'
|
||||
if nodeKey in ['ckpt_path', 'control_net_path']:
|
||||
# smth_path is most likely placed before smth_name, therefore value cannot be resolved during the previous check
|
||||
value = found_values[nodeKey]
|
||||
# and indeed we bypass anything that does not have a subfolder
|
||||
if value == '.':
|
||||
continue
|
||||
else:
|
||||
# terminate the subfolder with os.sep so it creates an actual subfolder.
|
||||
# Because find_keys_recursively will only return the path part: 'path' or 'path/subpath'
|
||||
# Therefore, enclosing this value with os.sep ensure we get a subfolder
|
||||
value = os.sep + value + os.sep
|
||||
else:
|
||||
value = self.cleanup_fileName(value)
|
||||
|
||||
# ".string" case
|
||||
if value.startswith('.'):
|
||||
delim = ''
|
||||
@@ -346,7 +444,7 @@ class SaveImageExtended:
|
||||
custom_name += f"{delim}{value}"
|
||||
# print(f"debug generate_custom_name: ------custom_name: {custom_name}")
|
||||
# for each key
|
||||
return custom_name.strip(delimiter).strip('.').strip('/').strip(delimiter)
|
||||
return custom_name.strip(delimiter).strip('.').strip(os.sep).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, timestamp=datetime.now()):
|
||||
@@ -421,7 +519,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)
|
||||
@@ -462,7 +560,7 @@ 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 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
|
||||
@@ -522,34 +620,39 @@ class SaveImageExtended:
|
||||
return exif_dat
|
||||
|
||||
|
||||
def save_image(self, image_path, img, prompt, save_metadata=save_metadata, extra_pnginfo=None, quality=75):
|
||||
def save_image(self, image_path, img, prompt, save_metadata=save_metadata, extra_pnginfo=None, quality=quality):
|
||||
# print(f"debug save_images: image_path={image_path}")
|
||||
output_ext = os.path.splitext(os.path.basename(image_path))[1]
|
||||
if quality == 0:
|
||||
quality = self.quality
|
||||
# output_ext = os.path.splitext(os.path.basename(image_path))[1]
|
||||
output_ext = Path(image_path).suffix
|
||||
metadata = None
|
||||
kwargs = dict()
|
||||
|
||||
# TODO: see if convert_hdr_to_8bit=False make a change
|
||||
# https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html
|
||||
|
||||
# match is python 3.10+
|
||||
if output_ext in ['.avif']:
|
||||
if output_ext in ['.avif', '.webp', '.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 ['.webp']:
|
||||
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)
|
||||
kwargs["quality"] = quality
|
||||
if quality == 100: kwargs["lossless"] = True
|
||||
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
|
||||
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
|
||||
@@ -586,18 +689,22 @@ class SaveImageExtended:
|
||||
positive_text_opt=None,
|
||||
extra_pnginfo=None,
|
||||
prompt=None,
|
||||
quality=75,
|
||||
quality=quality,
|
||||
):
|
||||
|
||||
# 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")
|
||||
# print(f"save_images filename_prefix = x{filename_prefix}x")
|
||||
# print(f"save_images filename_keys = x{filename_keys}x")
|
||||
# print(f"save_images foldername_prefix = x{foldername_prefix}x")
|
||||
# print(f"save_images foldername_keys = x{foldername_keys}x")
|
||||
# print(f"save_images delimiter = x{delimiter}x")
|
||||
# print(f"save_images save_job_data = x{save_job_data}x")
|
||||
# print(f"save_images job_data_per_image = x{job_data_per_image}x")
|
||||
# print(f"save_images output_ext = x{output_ext}x")
|
||||
# print(f"save_images quality = x{quality}x")
|
||||
|
||||
# bugfix: sometimes on load, quality == 0
|
||||
if quality == 0: quality = self.quality
|
||||
|
||||
# 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]
|
||||
@@ -609,8 +716,8 @@ class SaveImageExtended:
|
||||
# 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)
|
||||
custom_filename = self.generate_custom_name(filename_keys_to_extract, filename_prefix, delimiter, prompt, timestamp)
|
||||
|
||||
# Get set resolution value
|
||||
i = 255. * images[0].cpu().numpy()
|
||||
@@ -619,11 +726,17 @@ class SaveImageExtended:
|
||||
|
||||
# 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])
|
||||
output_path = os.path.join(full_output_folder, custom_foldername)
|
||||
# print(f"debug save_images: full_output_folder={full_output_folder}")
|
||||
# print(f"debug save_images: custom_foldername={custom_foldername}")
|
||||
# print(f"debug save_images: output_path={output_path}")
|
||||
# folder_paths.get_save_image_path() is kind of magic, I don't like that. Outpout is wrong anyway, when custom_filename contains folders
|
||||
# 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])
|
||||
# output_path = os.path.join(full_output_folder, custom_foldername)
|
||||
|
||||
output_path = Path(os.path.join(self.output_dir, custom_foldername, custom_filename)).parent
|
||||
filename = Path(os.path.join(self.output_dir, custom_foldername, custom_filename)).name
|
||||
# print(f"debug save_images: custom_foldername= {custom_foldername}")
|
||||
# print(f"debug save_images: custom_filename= {custom_filename}")
|
||||
# print(f"debug save_images: output_path= {output_path}")
|
||||
# print(f"debug save_images: filename= {filename}")
|
||||
|
||||
os.makedirs(output_path, exist_ok=True)
|
||||
counter = self.get_latest_counter(one_counter_per_folder, output_path, filename, counter_digits, counter_position, output_ext)
|
||||
# print(f"debug save_images: counter for {output_ext}: {counter}")
|
||||
@@ -634,18 +747,18 @@ class SaveImageExtended:
|
||||
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
|
||||
|
||||
if counter_position == 'last':
|
||||
file = f'{filename}{delimiter}{counter:0{counter_digits}}{output_ext}'
|
||||
image_name = f'{filename}{delimiter}{counter:0{counter_digits}}{output_ext}'
|
||||
else:
|
||||
file = f'{counter:0{counter_digits}}{delimiter}{filename}{output_ext}'
|
||||
image_name = f'{counter:0{counter_digits}}{delimiter}{filename}{output_ext}'
|
||||
|
||||
image_path = os.path.join(output_path, file)
|
||||
image_path = os.path.join(output_path, image_name)
|
||||
self.save_image(image_path, img, prompt, save_metadata, extra_pnginfo, quality)
|
||||
|
||||
if save_job_data != 'disabled' and job_data_per_image:
|
||||
self.save_job_to_json(save_job_data, prompt, filename_prefix, positive_text_opt, negative_text_opt, job_custom_text, resolution, output_path, f'{file.removesuffix(output_ext)}.json', timestamp)
|
||||
self.save_job_to_json(save_job_data, prompt, filename_prefix, positive_text_opt, negative_text_opt, job_custom_text, resolution, output_path, f'{image_name.removesuffix(output_ext)}.json', timestamp)
|
||||
|
||||
subfolder = self.get_subfolder_path(image_path, self.output_dir)
|
||||
results.append({ 'filename': file, 'subfolder': subfolder, 'type': self.type})
|
||||
results.append({ 'filename': image_name, 'subfolder': subfolder, 'type': self.type})
|
||||
counter += 1
|
||||
|
||||
if save_job_data != 'disabled' and not job_data_per_image:
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
Vendored
+3
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user