Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a39e8b0f7 | ||
|
|
29971dc90f | ||
|
|
c87b890b32 | ||
|
|
a7a2dffaf9 | ||
|
|
37ee3483e1 | ||
|
|
1fc32e0ad3 | ||
|
|
8e9b750840 | ||
|
|
54b30b9c1a | ||
|
|
61aed6e7e1 | ||
|
|
8aa85b1c15 | ||
|
|
a2b9b4ed72 | ||
|
|
f7c06ba306 | ||
|
|
d1d0ddab43 | ||
|
|
4b1d081a2e | ||
|
|
449d3dab03 | ||
|
|
8255b57e9c | ||
|
|
ef8870d03e | ||
|
|
66b3877c11 | ||
|
|
43d6b8b622 | ||
|
|
8a82a5784a | ||
|
|
0ca2a77ea7 | ||
|
|
6ae161115c | ||
|
|
a75e901318 | ||
|
|
01b16c7056 | ||
|
|
13e7ccf849 | ||
|
|
3ca7d02f3d | ||
|
|
e32bb26b51 | ||
|
|
ff4e4f9ba8 | ||
|
|
75c8fdd886 | ||
|
|
bf91779271 | ||
|
|
189bb9997c | ||
|
|
b0d5dd8aa6 | ||
|
|
9ec40cbebf | ||
|
|
8c91af7243 | ||
|
|
a25b5091c3 | ||
|
|
c4aeb168b6 | ||
|
|
266cef3800 | ||
|
|
18bc84204e | ||
|
|
569f4c1ec2 | ||
|
|
40aeed0e3b | ||
|
|
d72b8e5428 | ||
|
|
d38ac58316 | ||
|
|
8c9326eb65 | ||
|
|
dba9e539f9 | ||
|
|
a9504f9e63 | ||
|
|
d53328c8ac | ||
|
|
85967a9a4b | ||
|
|
bfabb38bea | ||
|
|
2a2e38397a | ||
|
|
54e9183ecd | ||
|
|
669e7a9d03 | ||
|
|
ab2410ba95 |
@@ -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
|
||||
**AVIF** / **WebP** / **JXL** support!
|
||||
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,79 +10,82 @@
|
||||
* Customize the folder, sub-folders, and filenames of your images!
|
||||
* Save data about the generated job (sampler, prompts, models) as entries in a `json` (text) file, in each folder.
|
||||
* Use the values of ANY node's widget, by simply adding its badge number in the form _id.widget_name_
|
||||
* Oh btw... also saves your output as **AVIF** / **WebP** / **JPEG** / **JXL** ... And yes the prompt is included :)
|
||||
* ComfyUI can only load PNG and WebP atm
|
||||
* Warning: ComfyUI can only load prompts from PNG and WebP atm
|
||||
|
||||
|
||||
<br>
|
||||
<p align="center">
|
||||
<img src="assets/save-image-extended-comfyui-named_nodes_widgets-example.png" />
|
||||
<br><br>
|
||||
Happy saving!
|
||||
</p>
|
||||
|
||||
*Reboot by AudioscavengeR since 2024-05-05, original idea from [@thedyze](https://github.com/thedyze/save-image-extended-comfyui)*
|
||||
|
||||
## Installation
|
||||
### Requirements:
|
||||
There is a requirements.txt that will take care of that, but just in case:
|
||||
|
||||
- python 10.6
|
||||
- piexif
|
||||
- pillow
|
||||
- pillow-avif-plugin
|
||||
|
||||
```
|
||||
pip install piexif pillow pillow-avif-plugin
|
||||
```
|
||||
|
||||
For Jpeg XL / jxl it's more complicated. You need to install and compile the wheel jxlpy, and therefore, need a valid and functional MSVC installation.
|
||||
|
||||
```
|
||||
pip install jxlpy
|
||||
```
|
||||
|
||||
### Manual Download
|
||||
1. Open a terminal inside the 'custom_nodes' folder located in your ComfyUI installation dir
|
||||
2. Use the `git clone` command to clone the [save-image-extended-comfyui](https://github.com/audioscavenger/save-image-extended-comfyui) repo under ComfyUI\custom_nodes\
|
||||
```
|
||||
git clone https://github.com/audioscavenger/save-image-extended-comfyui
|
||||
```
|
||||
|
||||
## Parameters / Usage
|
||||
## Parameters / Reserved Keywords
|
||||
|
||||
| Attribute | Description |
|
||||
| --- | --- |
|
||||
| `filename_prefix` | String prefix added to files. |
|
||||
| `filename_keys` | Comma separated string with sampler parameters to add to filename. E.g: `sampler_name, scheduler, cfg, denoise` Added to filename in written order. `resolution` also works. `vae_name` `model_name` (upscale model), `ckpt_name` (checkpoint) are others that should work. Here you can try any parameter name of any node. As long as the parameter has the same variable name defined in the `prompt` object they should work. The same applies to `foldername_keys`. |
|
||||
| `foldername_prefix` | String prefix added to folders. |
|
||||
| `foldername_keys` | Comma separated string with _sampler_ parameters to add to foldername. Add more subfolders by prepending "./" to the key name. |
|
||||
| `delimiter` | **now a free field** Delimiter = 1 character, can be anything your file system supports. Windows users should still use "/" for subfolders. |
|
||||
| `save_job_data` | If enabled, saves information about each job as entries in a `jobs.json` text file, inside the generated folder. Mulitple options for saving `prompt`, `basic data`, `sampler settings`, `loaded models`. |
|
||||
| `job_data_per_image` | When enabled, saves individual job data files for each image. |
|
||||
| `job_custom_text` | Custom string to save along with the job data. Right click the node and convert to input to connect with another node. |
|
||||
| `filename_prefix` | **Static string** prefixed to file name. |
|
||||
| `filename_keys` | Comma separated string with sampler parameters to add to filename. E.g: `sampler_name, scheduler, cfg, denoise` Added to filename in written order. `vae_name` `model_name` (upscale model), `ckpt_name` (checkpoint) are others that should work. Here you can try any parameter name of any node. As long as the parameter has the same variable name defined in the `prompt` object, they should work. The same applies to `foldername_keys`. |
|
||||
| `foldername_prefix` | Fixed string prefixed to subfolders. |
|
||||
| `foldername_keys` | Same rules as for `filename_keys`. Create subfolders by using `/` or `../` etc. |
|
||||
| `delimiter` | **now a free field** Delimiter = any string you like. You can also use `/` to create subfolders. |
|
||||
| `save_job_data` | Saves information about each job as entries in a `jobs.json` text file, inside the generated subfolder. Multiple options for its content: `prompt`, `basic data`, `sampler settings`, `loaded models`. |
|
||||
| `job_data_per_image` | Saves individual job data file per image. |
|
||||
| `job_custom_text` | Custom string to save along with the job data. |
|
||||
| `save_metadata` | Saves metadata into the image. |
|
||||
| `counter_digits` | Number of digits used for the image counter. `3` = image_001.png. Will adjust the counter if files are deleted. Looks for the highest number in the folder, does not fill gaps. |
|
||||
| `counter_position` | Image counter first or last in the filename. |
|
||||
| `one_counter_per_folder` | Toggles the counter. Either one counter per folder, or resets when a parameter/prompt changes. |
|
||||
| `counter_digits` | Number of digits used for the image counter. `3` = image_001.png, based on highest number in the subfolder, ignores gaps. **Can be disabled** when == 0. |
|
||||
| `counter_position` | Image counter postition: image_001.png or 001_image.png. |
|
||||
| `one_counter_per_folder` | Toggles one counter per subfolder, or resets when a parameter/prompt changes. |
|
||||
| `image_preview` | Turns the image preview on and off. |
|
||||
| `output_ext` | File extension: PNG by default, or WEBP (coming soon). |
|
||||
| `output_ext` | File extension: WEBP by default, AVIF, PNG, JXL, JPG, etc. |
|
||||
| `quality` | Quality for JPEG/JXL/WebP/AVIF/J2K formats; Quality is relative to each format. Example: AVIF 60 is same quality as WebP 90. PNG compression is fixed at 4 and not affected by this. PNG compression times skyrocket above level 4 for zero benefits on filesize. |
|
||||
| `resolution` | Saved image reolution in format `000x000` (pixels).
|
||||
| `named_keys` | Prefix each value by its key name. Example: prefix-seed=123456-width=1024-cfg=5.0-0001.avif |
|
||||
|
||||
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.
|
||||
- ANY other keyword that exist as a field name in a widget will be resolved.
|
||||
- That is why I do not list all common keywords like `ckpt_name` etc, as they are virtually infinite and exist only within your workflow.
|
||||
- Unknown key names not found in any widget's fields are treated as fixed strings
|
||||
- if you enter `wrongNumber.attribute`, you will also get `attribute` as a fixed string.
|
||||
- Datetime UNIX format is now included! `%Y-%m-%d` or `%F` etc: see [unix datetime formats](https://www.man7.org/linux/man-pages/man1/date.1.html)
|
||||
- default it output only the **name** for `ckpt_name` / `control_net_name` / `lora_name` not the **path**
|
||||
- use `ckpt_path` / `control_net_path` / `lora_path` to get the same subfolders as your models
|
||||
- using `.custom_string` will prevent appending delimiter, the dot will be the delimiter
|
||||
|
||||
Datetime UNIX format is now included! `%Y-%m-%d` or `%F` etc
|
||||
### FAQ
|
||||
* How do I format the date/timestamps?
|
||||
* see [unix datetime formats](https://www.man7.org/linux/man-pages/man1/date.1.html)
|
||||
* How do I reference the prompt (aka CLIP Text Encode) in the filename (given it's not too long)?
|
||||
* Like this: use `#num.text` 
|
||||
* How do I show the nodes ID badges?
|
||||
* legacy: 
|
||||
* modern: 
|
||||
|
||||
### Examples
|
||||
- prefix = `PREFIX`
|
||||
- filename = `cfg, sampler_name, /steps, %F %H-%M-%S`
|
||||
- foldername = `ckpt_name/, .cfg`
|
||||
- > `v1-5-pruned.cfg / PREFIX-8.0-euler / 20-2024-12-18 23-17-17-0001.png`
|
||||
|
||||
- prefix = `PREFIX`
|
||||
- filename = `cfg, sampler_name, steps, %F %H-%M-%S`
|
||||
- foldername = `ckpt_name, /, cfg`
|
||||
- > `v1-5-pruned / 8.0 / PREFIX-cfg-8.0-euler-20-2024-12-18 23-21-41-0001.png`
|
||||
|
||||
- prefix = `PREFIX`
|
||||
- filename = `"cfg", cfg`
|
||||
- foldername = `ckpt_name, /, cfg`
|
||||
- > `v1-5-pruned / 8.0 / PREFIX-cfg-8.0-0002.png`
|
||||
|
||||
## Node inputs
|
||||
|
||||
- `images` - The generated images.
|
||||
|
||||
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.
|
||||
- `positive_text_opt` - Optional string saved as `positive_text_opt` in job.json when `save_job_data`=True.
|
||||
- `negative_text_opt` - Optional string saved as `negative_text_opt` in job.json when `save_job_data`=True.
|
||||
|
||||
## Automatic date/time conversion in file/folder names
|
||||
## How To Date/Time conversion in file/folder names:
|
||||
|
||||
Converts [unix datetime formats](https://www.man7.org/linux/man-pages/man1/date.1.html):
|
||||
Converts [unix datetime formats](https://www.man7.org/linux/man-pages/man1/date.1.html) following POSIX format. Examples:
|
||||
|
||||
| Unix datetime | Example | Comment |
|
||||
| --- | --- | --- |
|
||||
@@ -89,47 +94,108 @@ Converts [unix datetime formats](https://www.man7.org/linux/man-pages/man1/date.
|
||||
| %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+
|
||||
- numpy
|
||||
- pillow
|
||||
- pillow-avif-plugin
|
||||
- pillow-jxl-plugin
|
||||
|
||||
```
|
||||
pip install numpy pillow pillow-avif-plugin pillow-jxl-plugin
|
||||
```
|
||||
|
||||
### Tested on / up to:
|
||||
* python 3.10.6
|
||||
* pytorch 2.5.1+cu124
|
||||
* ComfyUI v0.3.12-1-g7fc3ccdc | Released on '2025-01-16'
|
||||
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
|
||||
## Known Bugs
|
||||
* ComfyUI cannot load lossless WebP atm. Feel free to try and fix `pnginfo.js`.
|
||||
* Pillow cannot save Exif data in JPEG2000, nor can it compress it in any way. Who the heck is using JPEG2000 in 2024 anyway?
|
||||
* Incompatible with *extended-saveimage-comfyui* - This node can be safely discarded, as it only offers WebP output. My node adds all major and latest image formats.
|
||||
* WebP cannot handle images larger than 12,000 x 12,0000 pixels and that's by design. Ask a Google developer to fix the cwebp library.
|
||||
* JXL (pillow-jxl-plugin) cannot even save images 12,000 x 12,0000 pixels and will crash Comfy completely.
|
||||
* [Load Image //Inspire](https://github.com/ltdrdata/ComfyUI-Inspire-Pack) will keep loaded images as base64 inside the prompt... exploding your output filesize. JPEG has a metadata size limit and will likely fail with error `EXIF data is too long`.
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
#
|
||||
Quality and compression settings:
|
||||
Quality and compression settings: min = 1, default is **90**, max = 100 will activate **lossless** for AVIF and WEBP only.
|
||||
|
||||
* AVIF quality is fixed at 60.
|
||||
* WebP quality is fixed at 75.
|
||||
* JPEG quality is fixed at 91.
|
||||
* JXL quality is fixed at 91.
|
||||
* PNG is maxed compressed (9)
|
||||
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% |
|
||||
|
||||
Start a discussion or a poll if you want to change output quality, and add another attribute in the node. There's so many already, some complain about the node being too tall.
|
||||
#
|
||||
PNG compression 0-9 is fixed at level 4 for the following reason: ***zero** compression benefits above level 4*, while saving time skyrockets. Test results below for a 2560x1440 image on an Intel i7-9700:
|
||||
| Compression | time | Size |
|
||||
| --- | --- | --- |
|
||||
| 0 | 0.5s | 10.6 MB |
|
||||
| 1 | 0.6s | 4.6 MB |
|
||||
| 2 | 0.6s | 4.5 MB |
|
||||
| 3 | 0.6s | 4.4 MB |
|
||||
| **4** | **0.7s** | **4.0 MB** |
|
||||
| 5 | 1.1s | 4.0 MB |
|
||||
| 6 | 1.9s | 4.0 MB |
|
||||
| 7 | 2.6s | 4.0 MB |
|
||||
| 9 | 10.5s | 3.9 MB |
|
||||
|
||||
|
||||
#
|
||||
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}`
|
||||
|
||||
#
|
||||
[JPEG XL is a heated debate on chromium forum](https://issues.chromium.org/issues/40168998#comment85) and if true, 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 compared to WebP, with a caveat. The compression offered by pillow is 3x lower then Image Magick for the same level. No idea why.
|
||||
|
||||
|
||||
#
|
||||
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: 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: always generated and appended, not sure what it can be used for.
|
||||
jobs.json sample:
|
||||
<br>
|
||||
<p align="center">
|
||||
<img src="assets/save-image-extended-comfyui-jobs-example.png" />
|
||||
@@ -139,20 +205,111 @@ jobs.json sample: always generated and appended, not sure what it can be used fo
|
||||
|
||||
|
||||
## RoadMap
|
||||
*Reboot by AudioscavengeR since 2024-05-05, original idea from [@thedyze](https://github.com/thedyze/save-image-extended-comfyui)*
|
||||
|
||||
I won't promise you the moon, but since I use this node myself, I will maintain it as much as I can. I do provide a way to contact me, and will accept PR and collabs.
|
||||
Once I feel like I don't have time to work on it, I will gladly transfer ownership or let collabs maintain it.
|
||||
|
||||
TODO:
|
||||
|
||||
- [ ] ComfyRoll CR XY Save Grid Image: it offers jpeg webp tif - check how it embeds prompt and see if that works better with Comfy!
|
||||
- [ ] offer quality setting in the node?
|
||||
- [ ] remove save_job_to_json? thisis pretty much useless actually, since it only saves the last value found for each node.
|
||||
- [ ] remove job_custom_text? what is this for?
|
||||
- [ ] remove jobs.json? jobs history, alright. What do you do with that? images contain the prompt, what is this for?
|
||||
- [ ] improve get_latest_counter: fails when user renames files: appends text after counter
|
||||
- [ ] WebP: explore method values 0-6 and test filesize vs save time
|
||||
- [ ] WebP: quality has an impact on both lossy and lossless, more testing needed
|
||||
- [ ] review accepted parameters at https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html to improve TIFF and other formats
|
||||
- [ ] 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?
|
||||
- [x] PIL.Image doesn't respect compress_level value and always output max 9 compressed images -> when optimize_image = True! So we turn that off for PNG
|
||||
- [x] TODO: test import cv2 / OpenCV: https://github.com/python-pillow/Pillow/issues/5986 -> not faster then PIL
|
||||
|
||||
|
||||
### release 2.87 💾
|
||||
- finally! right-click any node with IMAGE output adds "Add SaveImageExtended" option
|
||||
- upgraded purify.min.js
|
||||
|
||||
### release 2.86 💾
|
||||
- bugfix in counter_digits which does not accept 0 to disable counter
|
||||
|
||||
### release 2.85 💾
|
||||
- issue/51: #31 #49 API mode KeyError - fix by adding empty {} to the metadata for the API call
|
||||
|
||||
### release 2.84 💾
|
||||
- issue/48: allow no prefix and no filename_keys at all to generate 0001.jpg files
|
||||
- one_counter_per_folder is depracated as it was in fact, not implemented at all. Cannot remove it or all previous saves will crash the widget
|
||||
|
||||
### release 2.83 💾
|
||||
- quality is now also used for PNG compression: 0-90 translates to 0-9 levels of compression
|
||||
- requirements.txt and import cleanups, updated wiki
|
||||
|
||||
### release 2.82 💾
|
||||
- bugfix: removed extra debug lines
|
||||
- improvement: now can extract CLIP text
|
||||
- improvement: added tooltips
|
||||
|
||||
### release 2.81 💾
|
||||
- fix for pythongosssss/ComfyUI-Custom-Scripts Checkpoint Loader bug and other nodes that store values in a dict
|
||||
|
||||
### release 2.80 💾
|
||||
- tested OpenCV vs PIL for saving images: same speed
|
||||
- various typos fixed
|
||||
- counter can now be disabled: 0 digits
|
||||
|
||||
### release 2.79 💾
|
||||
- enhancement 28-request-handle-lora-names-like-checkpoint-names
|
||||
|
||||
### release 2.78 💾
|
||||
- fix 40-subdirectory-creation
|
||||
|
||||
### release 2.77 💾
|
||||
- fix in ckpt_path: will only build subfolders if ckpt_name is also used: https://github.com/audioscavenger/save-image-extended-comfyui/pull/38
|
||||
- fix for too much rounding of floats (did not affect actual value in prompt saved inside the image)
|
||||
|
||||
### release 2.76 💾
|
||||
- brought back resolution
|
||||
|
||||
### release 2.75 💾
|
||||
- Prefix each value by its key name: prefix-seed=123456-width=1024-etc-0001.avif
|
||||
|
||||
### 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
|
||||
- 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
|
||||
@@ -206,3 +363,12 @@ TODO:
|
||||
### release 2.0
|
||||
- [x] Reboot on 2024-05-05
|
||||
|
||||
## :ribbon: Licence
|
||||
[GPL 3.0](https://choosealicense.com/licenses/gpl-3.0/)
|
||||
|
||||
## :beer: Buy me a beer
|
||||
Like my work? This tool helped you? Want to sponsor more awesomeness like this?
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.paypal.com/donate/?hosted_button_id=CD7P7PK3WP8WU"><img src="/assets/paypal-Donate-QR-Code.png" /></a>
|
||||
</p>
|
||||
|
||||
@@ -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())]
|
||||
)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
@@ -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.87"
|
||||
license = { file = "LICENSE" }
|
||||
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,8 @@
|
||||
piexif
|
||||
# piexif # for testing
|
||||
# jpegxl support: imagecodecs will bring libjxl 0.9.0
|
||||
# imagecodecs # pillow-jxl-plugin is simpler
|
||||
|
||||
numpy
|
||||
pillow
|
||||
pillow-avif-plugin
|
||||
pillow-jxl-plugin
|
||||
|
||||
@@ -2,39 +2,52 @@ import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
import numpy as np
|
||||
import locale
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import folder_paths
|
||||
|
||||
import pprint
|
||||
import piexif
|
||||
import piexif.helper
|
||||
import numpy
|
||||
# import piexif
|
||||
# import piexif.helper
|
||||
|
||||
# import cv2 # not faster then PIL
|
||||
|
||||
version = 2.87
|
||||
|
||||
avif_supported = False
|
||||
jxl_supported = False
|
||||
|
||||
debug = False
|
||||
# debug = True
|
||||
|
||||
# Avif is included in requirements.txt
|
||||
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
|
||||
# Therefore we use pillow_jxl which makes more sense
|
||||
try:
|
||||
from jxlpy import JXLImagePlugin
|
||||
# jxlpy is in early stages of development. No one has ever compiled it on Windows in 2025 AFAIK, please prove me wrong
|
||||
# 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,48 +57,87 @@ original_locale = locale.setlocale(locale.LC_TIME, '')
|
||||
|
||||
# class SaveImageExtended -------------------------------------------------------------------------------
|
||||
class SaveImageExtended:
|
||||
version = 2.51
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = 'save_images'
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = 'image'
|
||||
DESCRIPTION = """
|
||||
## Advice
|
||||
You must enable Badge numbers in the Manager if you have duplicate nodes: _#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
|
||||
- get name from CheckPoint/LorA/ControlNet: use _ckpt_name_ / _control_net_name_ / _lora_name_
|
||||
- get subfolders from CheckPoint/LorA/ControlNet: use use _ckpt_path_ / _control_net_path_ / _lora_path_
|
||||
|
||||
### subfolders
|
||||
- you can use `/` or `./` or even `../`
|
||||
- you can prepend `/` to your key such as `/sampler_name` or `/12.sampler_name`
|
||||
- you can use subfolders in _filename\_keys_ as well
|
||||
- trailing `/` will be removed
|
||||
|
||||
### 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.
|
||||
Prompt and Workflows are saved in Exif tags _Make_ [0x010f] and _ImageDescription_ [0x010e].
|
||||
ComfyUI can only load PNG and WebP at the moment, AVIF is a PR that was sadly dropped when they implemented audio files in Summer 2024.
|
||||
|
||||
"""
|
||||
|
||||
type = 'output'
|
||||
|
||||
png_compress_level = 9
|
||||
lossless = False
|
||||
avif_quality = 60
|
||||
webp_quality = 75
|
||||
jpeg_quality = 91
|
||||
jxl_quality = 91
|
||||
webp_quality = 90
|
||||
jpeg_quality = 90
|
||||
jxl_quality = 90
|
||||
j2k_quality = 90
|
||||
# tiff_quality has no impact unless you start meddling with the compressions algorithms
|
||||
tiff_quality = 90
|
||||
# optimize_image only works for jpeg, png anf TIFF, with like just 2% reduction in size; not used for PNG as it forces a level 9 compression.
|
||||
optimize_image = True
|
||||
|
||||
filename_prefix = 'ComfyUI'
|
||||
filename_keys = 'sampler_name, scheduler, cfg, steps'
|
||||
filename_keys = 'sampler_name, cfg, steps, %F %H-%M-%S'
|
||||
foldername_prefix = ''
|
||||
foldername_keys = 'ckpt_name, ./subfolder'
|
||||
delimiter = '_'
|
||||
save_job_data = 'basic, models, sampler, prompt'
|
||||
foldername_keys = 'ckpt_name'
|
||||
delimiter = '-'
|
||||
save_job_data = 'disabled'
|
||||
job_data_per_image = False
|
||||
job_custom_text = ''
|
||||
save_metadata = True
|
||||
counter_digits = 4
|
||||
counter_position = 'last'
|
||||
counter_positions = ['last', 'first']
|
||||
one_counter_per_folder = True
|
||||
one_counter_per_folder = True # deprecated but cannot remove it or all values loaded after it will be shifted
|
||||
image_preview = True
|
||||
extToRemove = ['.safetensors', '.ckpt', '.pt', '.bin', '.pth']
|
||||
modelExtensions = ['.safetensors', '.ckpt', '.pt', '.bin', '.pth']
|
||||
output_ext = '.webp'
|
||||
output_exts = ['.webp', '.png', '.jpg']
|
||||
output_exts = ['.webp', '.png', '.jpg', '.jpeg', '.j2k', '.jp2', '.gif', '.tiff', '.bmp']
|
||||
# quality is a lossy compression unused by PNG/tiff/gif but also translated to integers 0-9 for PNG compression level
|
||||
quality = 90
|
||||
named_keys = False
|
||||
|
||||
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.
|
||||
@@ -100,48 +152,74 @@ 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', ),
|
||||
'filename_prefix': ('STRING', {'default': self.filename_prefix, 'multiline': False}),
|
||||
'filename_keys': ('STRING', {'default': self.filename_keys, 'multiline': True}),
|
||||
'foldername_prefix': ('STRING', {'default': self.foldername_prefix, 'multiline': False}),
|
||||
'foldername_keys': ('STRING', {'default': self.foldername_keys, 'multiline': True}),
|
||||
'delimiter': ('STRING', {'default': self.delimiter, 'multiline': False}),
|
||||
'filename_prefix': ('STRING', {'default': self.filename_prefix, 'multiline': False, 'tooltip': "Fixed string prefixed to file name"}),
|
||||
'filename_keys': ('STRING', {'default': self.filename_keys, 'multiline': True, 'tooltip': "Comma separated string with sampler parameters to add to filename. \n* Example: `sampler_name, scheduler, cfg, denoise` Added to filename in written order. \n* Example: also accepts `vae_name` `model_name` (upscale model), `ckpt_name` (checkpoint). \n* `resolution` also works. \n\n* ANY parameter name of any node will work. The same applies to `foldername_keys`"}),
|
||||
'foldername_prefix': ('STRING', {'default': self.foldername_prefix, 'multiline': False, 'tooltip': "Fixed string prefixed to subfolders"}),
|
||||
'foldername_keys': ('STRING', {'default': self.foldername_keys, 'multiline': True, 'tooltip': "Same rules as for `filename_keys`. Create subfolders by using `/` or `../` etc"}),
|
||||
'delimiter': ('STRING', {'default': self.delimiter, 'multiline': False, 'tooltip': "Any string you like. You can also use `/` to create subfolders"}),
|
||||
'save_job_data': ([
|
||||
'disabled',
|
||||
'prompt',
|
||||
'basic, prompt',
|
||||
'basic, sampler, prompt',
|
||||
'basic, models, sampler, prompt'
|
||||
], {'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': ('BOOLEAN', {'default': self.save_metadata}),
|
||||
], {'default': self.save_job_data, 'tooltip': "Saves information about each job as entries in a `jobs.json` text file, under the generated subfolder. \nMultiple options for its content: `prompt`, `basic data`, `sampler settings`, `loaded models`"}),
|
||||
'job_data_per_image': ('BOOLEAN', {"default": self.job_data_per_image, 'tooltip': "Saves individual job data file per image"}),
|
||||
'job_custom_text': ('STRING', {'default': self.job_custom_text, 'multiline': False, 'tooltip': "Custom string to save along with the job data"}),
|
||||
'save_metadata': ('BOOLEAN', {'default': self.save_metadata, 'tooltip': "Saves metadata into the image"}),
|
||||
'counter_digits': ('INT', {
|
||||
"default": self.counter_digits,
|
||||
"min": 1,
|
||||
"min": 0,
|
||||
"max": 8,
|
||||
"step": 1,
|
||||
"display": "silder"
|
||||
"display": "silder",
|
||||
'tooltip': "Number of digits used for the image counter. `3` = image_001.png, based on highest number in the subfolder, ignores gaps. **Can be disabled** when == 0"
|
||||
}),
|
||||
'counter_position': (self.counter_positions, {'default': self.counter_position}),
|
||||
'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}),
|
||||
'counter_position': (self.counter_positions, {'default': self.counter_position, 'tooltip': "Image counter postition: image_001.png or 001_image.png"}),
|
||||
'one_counter_per_folder': ('BOOLEAN', {'default': self.one_counter_per_folder, 'tooltip': "deprecated but I cannot remove it of all your saved prompts will break"}),
|
||||
'image_preview': ('BOOLEAN', {'default': self.image_preview, 'tooltip': "Turns the image preview on and off"}),
|
||||
'output_ext': (self.output_exts, {'default': self.output_ext, 'tooltip': "File extension: WEBP by default, AVIF, PNG, JXL, JPG, etc"}),
|
||||
'quality': ('INT', {
|
||||
"default": self.quality,
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"step": 1,
|
||||
"display": "silder",
|
||||
'tooltip': "Quality for JPEG/JXL/WebP/AVIF/J2K formats; Quality is relative to each format. \n* Example: AVIF 60 is same quality as WebP 90. \n* PNG compression is fixed at 4 and not affected by this. PNG compression times skyrocket above level 4 for zero benefits on filesize."
|
||||
}),
|
||||
'named_keys': ('BOOLEAN', {'default': self.named_keys, 'tooltip': "Prefix each value by its key name. Example: prefix-seed=123456-width=1024-cfg=5.0-0001.avif"}),
|
||||
},
|
||||
'optional': {
|
||||
'positive_text_opt': ('STRING', {'forceInput': True}),
|
||||
'negative_text_opt': ('STRING', {'forceInput': True}),
|
||||
'positive_text_opt': ('STRING', {'forceInput': True, 'tooltip': "Optional string saved as `positive_text_opt` in job.json when `save_job_data`=True"}),
|
||||
'negative_text_opt': ('STRING', {'forceInput': True, 'tooltip': "Optional string saved as `negative_text_opt` in job.json when `save_job_data`=True"}),
|
||||
},
|
||||
'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()
|
||||
@@ -152,23 +230,46 @@ class SaveImageExtended:
|
||||
return str(subfolder_path)
|
||||
|
||||
|
||||
|
||||
|
||||
# ██████ ██████ ██ ██ ███ ██ ████████ ███████ ██████
|
||||
# ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██
|
||||
# ██ ██ ██ ██ ██ ██ ██ ██ ██ █████ ██████
|
||||
# ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
|
||||
# ██████ ██████ ██████ ██ ████ ██ ███████ ██ ██
|
||||
|
||||
# Get current counter number from file names
|
||||
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):
|
||||
def get_latest_counter(self, folder_path, filename, counter_digits=counter_digits, counter_position=counter_position, output_ext=output_ext):
|
||||
counter = 1
|
||||
if not os.path.exists(folder_path):
|
||||
print(f"SaveImageExtended {version} error: Folder {folder_path} does not exist, starting counter at 1.")
|
||||
return counter
|
||||
|
||||
try:
|
||||
# grab all files with output_ext
|
||||
files = [file for file in os.listdir(folder_path) if file.endswith(output_ext)]
|
||||
# ['0001.webp', '0003-a.webp', 'AnythingV5_inkBase-0001.webp', 'AnythingV5_inkBase-0002.webp']
|
||||
|
||||
extLen = len(output_ext)
|
||||
# '.webp' = 5
|
||||
|
||||
if files:
|
||||
# get default counter position if passed value is somehow not in the allowed values list
|
||||
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].isdecimal() else 0 for file in files if one_counter_per_folder or file.startswith(filename_prefix)]
|
||||
|
||||
# issue/48 needs a special case for files, counter_position does not matter here, but the filename length does
|
||||
if not filename:
|
||||
counters = [int(file[:counter_digits]) if (file[:counter_digits].isdecimal() and len(file)==(counter_digits+extLen)) else 0 for file in files]
|
||||
# [1, 0, 0, 0]
|
||||
else:
|
||||
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 counter_position == 'last':
|
||||
counters = [int(file[-(extLen + counter_digits):-extLen]) if file[-(extLen + counter_digits):-extLen].isdecimal() else 0 for file in files if file.startswith(filename)]
|
||||
# [1, 0, 1, 2]
|
||||
else:
|
||||
# file[:counter_digits] = '0001'
|
||||
# file[counter_digits +1:] = 'webp'
|
||||
counters = [int(file[:counter_digits]) if file[:counter_digits].isdecimal() else 0 for file in files if file[counter_digits +1:].startswith(filename)]
|
||||
# [1, 3, 0, 0]
|
||||
|
||||
if counters:
|
||||
counter = max(counters) + 1
|
||||
@@ -176,19 +277,50 @@ class SaveImageExtended:
|
||||
except Exception as e:
|
||||
print(f"SaveImageExtended {version} error: An error occurred while finding the latest counter: {e}")
|
||||
|
||||
if debug: print(f"debug get_latest_counter: counter={counter}")
|
||||
return counter
|
||||
|
||||
|
||||
# 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={}):
|
||||
if debug: 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
|
||||
if debug: print(f"debug find_keys_recursively: found key={key}")
|
||||
if debug: print(f"debug find_keys_recursively: value={value}")
|
||||
# pythongosssss/ComfyUI-Custom-Scripts stores the value as a dict: value={'content': 'v1-5-pruned-emaonly.safetensors', 'image': 'checkpoints/v1-5-pruned-emaonly.jpg'}
|
||||
if isinstance(value, dict):
|
||||
if 'content' in value:
|
||||
value = value['content']
|
||||
else:
|
||||
value = ''
|
||||
|
||||
if key in ['ckpt_path','ckpt_name']:
|
||||
value_path = Path(value)
|
||||
if 'ckpt_path' in keys_to_find:
|
||||
found_values['ckpt_path'] = self.cleanup_fileName(str(value_path.parent))
|
||||
elif 'ckpt_name' in keys_to_find:
|
||||
found_values['ckpt_name'] = self.cleanup_fileName(str(value_path.name))
|
||||
if debug: print(f"debug find_keys_recursively: ckpt_name={value_path.name} ckpt_path={str(value_path.parent)}")
|
||||
elif key in ['control_net_path','control_net_name']:
|
||||
value_path = Path(value)
|
||||
if 'control_net_path' in keys_to_find:
|
||||
found_values['control_net_path'] = self.cleanup_fileName(str(value_path.parent))
|
||||
elif 'control_net_name' in keys_to_find:
|
||||
found_values['control_net_name'] = self.cleanup_fileName(str(value_path.name))
|
||||
elif key in ['lora_path','lora_name']:
|
||||
value_path = Path(value)
|
||||
if 'lora_path' in keys_to_find:
|
||||
found_values['lora_path'] = self.cleanup_fileName(str(value_path.parent))
|
||||
elif 'lora_name' in keys_to_find:
|
||||
found_values['lora_name'] = self.cleanup_fileName(str(value_path.name))
|
||||
else:
|
||||
found_values[key] = self.cleanup_fileName(value)
|
||||
elif isinstance(value, dict):
|
||||
self.find_keys_recursively(value, keys_to_find, found_values)
|
||||
|
||||
|
||||
def cleanup_fileName(self, file='', extToRemove=extToRemove):
|
||||
def cleanup_fileName(self, file='', extToRemove=modelExtensions):
|
||||
if isinstance(file, str):
|
||||
# takes care of all the possible safetensor extensions under the sun
|
||||
# cannot do that... maybe the user want a string.string fixed value to use, that does not end with extToRemove
|
||||
@@ -201,7 +333,7 @@ class SaveImageExtended:
|
||||
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}")
|
||||
if debug: print(f"debug find_parameter_values: key={key} value={value}")
|
||||
if 'loras' in target_keys:
|
||||
# Match both formats: lora_xx and lora_name_x
|
||||
if re.match(r'lora(_name)?(_\d+)?', key):
|
||||
@@ -226,19 +358,36 @@ class SaveImageExtended:
|
||||
return found_values
|
||||
|
||||
|
||||
|
||||
|
||||
# ███ ██ █████ ███ ███ ███████ ██ ██████ ██████ ██████
|
||||
# ████ ██ ██ ██ ████ ████ ██ ██ ██ ██ ██ ██ ██ ██
|
||||
# ██ ██ ██ ███████ ██ ████ ██ █████ ██ ██ ██ ██ ██ ██████
|
||||
# ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
|
||||
# ██ ████ ██ ██ ██ ██ ███████ ███████ ██████ ██████ ██
|
||||
|
||||
# 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()):
|
||||
custom_name = prefix
|
||||
# def generate_custom_name: (self, keys_to_extract, prefix, delimiter, prompt, resolution, timestamp=datetime.now(), named_keys=False):
|
||||
def generate_custom_name(self, keys_to_extract, prefix, delimiter, prompt, resolution, timestamp=datetime.now(), named_keys=False):
|
||||
custom_name = []
|
||||
|
||||
# only filename has prefix
|
||||
if prefix:
|
||||
if '%' in prefix:
|
||||
custom_name.append(timestamp.strftime(prefix))
|
||||
else:
|
||||
custom_name.append(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:")
|
||||
if debug: print(f"debug generate_custom_name: --prefix: {prefix}")
|
||||
if debug: print(f"debug generate_custom_name: --keys_to_extract: {keys_to_extract}")
|
||||
if debug: print(f"debug generate_custom_name: --prompt:")
|
||||
|
||||
# now separating numbered keys from non-numbered keys:
|
||||
# 37.ckpt_name = i want the ckpt_name from node #37
|
||||
@@ -248,19 +397,38 @@ class SaveImageExtended:
|
||||
# 'inputs': {'cfg': 1.6,
|
||||
# 'denoise': 1.0, ...
|
||||
for key in keys_to_extract:
|
||||
# empty comma
|
||||
if not key: continue
|
||||
|
||||
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
|
||||
# datetime format
|
||||
if '%' in key:
|
||||
value = timestamp.strftime(key)
|
||||
|
||||
# check if there is an os.sep involved, cleanup the key
|
||||
if '/' in key:
|
||||
# key is a subfolder: ./key or ../key or /key ==> last / is removed
|
||||
values = re.split('/+', key)
|
||||
# we will strip the last / in all cases
|
||||
if values[0] in ['','.','..']:
|
||||
custom_name.append(values[0]+'/')
|
||||
key = values[1]
|
||||
# was it just a folder separator?
|
||||
if not key: continue
|
||||
else:
|
||||
key = values[0]
|
||||
|
||||
# fixed string:
|
||||
if (key.startswith("'") and key.endswith("'")) or (key.startswith('"') and key.endswith('"')):
|
||||
value = key
|
||||
|
||||
# is it num.key ?
|
||||
if value is None:
|
||||
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:
|
||||
if len(splitKey) == 2:
|
||||
# key has the form string.string
|
||||
if '' not in splitKey:
|
||||
# key has the form string.string
|
||||
@@ -268,69 +436,111 @@ class SaveImageExtended:
|
||||
# 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}")
|
||||
if debug: print(f"debug generate_custom_name: --node.nodeKey = {node}.{nodeKey}")
|
||||
# splitKey[0] = #node number found in prompt, we will recurse only in that node:
|
||||
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)
|
||||
elif nodeKey == 'lora_path':
|
||||
self.find_keys_recursively(prompt[node], ['lora_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")
|
||||
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)
|
||||
elif nodeKey == 'lora_path':
|
||||
self.find_keys_recursively(prompt, ['lora_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
|
||||
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
|
||||
|
||||
# key 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
|
||||
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)
|
||||
elif nodeKey == 'lora_path':
|
||||
self.find_keys_recursively(prompt, ['lora_name', nodeKey], found_values)
|
||||
elif nodeKey == 'resolution':
|
||||
value = resolution
|
||||
else:
|
||||
self.find_keys_recursively(prompt, [nodeKey], found_values)
|
||||
# is key num.widget_name
|
||||
# is key subfolder
|
||||
# value was None
|
||||
|
||||
# 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 debug: print(f"debug generate_custom_name: ----nodeKey: {nodeKey}")
|
||||
if debug: print(f"debug generate_custom_name: ----value: {value}")
|
||||
if debug: 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
|
||||
if nodeKey in found_values:
|
||||
if named_keys:
|
||||
value = f"{nodeKey}={found_values[nodeKey]}"
|
||||
else:
|
||||
value = self.cleanup_fileName(value)
|
||||
value = found_values[nodeKey]
|
||||
if value is None: value = nodeKey
|
||||
|
||||
|
||||
# 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
|
||||
if debug: print(f"debug generate_custom_name: ----value: {value}")
|
||||
|
||||
# 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 debug: print(f"debug generate_custom_name: ---------: / in value? {nodeKey}={value}")
|
||||
|
||||
if isinstance(value, float):
|
||||
value = round(float(value), 1)
|
||||
# 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', 'lora_path']:
|
||||
# smth_path is most likely placed before smth_name, therefore value cannot be resolved during the previous check
|
||||
value = found_values[nodeKey]
|
||||
else:
|
||||
value = self.cleanup_fileName(value)
|
||||
elif isinstance(value, float):
|
||||
# value = round(float(value), 1) # too much rounding
|
||||
value = float(f'{value:.10g}')
|
||||
|
||||
custom_name += f"{delim}{value}"
|
||||
# print(f"debug generate_custom_name: ------custom_name: {custom_name}")
|
||||
custom_name.append(str(value))
|
||||
if debug: print(f"debug generate_custom_name: ------custom_name: {custom_name}")
|
||||
# for each key
|
||||
return custom_name.strip(delimiter).strip('.').strip('/').strip(delimiter)
|
||||
|
||||
# remove empty values
|
||||
custom_name = list(filter(None, custom_name))
|
||||
# strip each item
|
||||
custom_name = list(map(str.strip, custom_name))
|
||||
# join
|
||||
stringName = delimiter.join(custom_name).replace('/'+delimiter, '/').replace(delimiter+'/', '/').replace(delimiter+'.', '.')
|
||||
# clean and remove line feeds
|
||||
stringName = re.sub('\s+',' ',stringName).strip(delimiter).strip('/').strip(delimiter).strip('.')
|
||||
|
||||
if debug: print(f"debug generate_custom_name: ------custom_name: {custom_name}")
|
||||
if debug: print(f"debug generate_custom_name: ------stringName: {stringName}")
|
||||
return re.sub(r'[*?:"<>|]','',stringName).replace('/', os.sep)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ██ ███████ ██████ ███ ██
|
||||
# ██ ██ ██ ██ ████ ██
|
||||
# ██ ███████ ██ ██ ██ ██ ██
|
||||
# ██ ██ ██ ██ ██ ██ ██ ██
|
||||
# █████ ███████ ██████ ██ ████
|
||||
|
||||
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 = {}
|
||||
@@ -404,7 +614,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)
|
||||
@@ -426,7 +636,15 @@ class SaveImageExtended:
|
||||
json.dump(existing_data, f, indent=4)
|
||||
|
||||
|
||||
def get_metadata_png(self, img, prompt, extra_pnginfo=None):
|
||||
|
||||
|
||||
# ██████ ███ ██ ██████
|
||||
# ██ ██ ████ ██ ██
|
||||
# ██████ ██ ██ ██ ██ ███
|
||||
# ██ ██ ██ ██ ██ ██
|
||||
# ██ ██ ████ ██████
|
||||
|
||||
def genMetadataPng(self, img, prompt, extra_pnginfo=None):
|
||||
metadata = PngInfo()
|
||||
if prompt is not None:
|
||||
metadata.add_text('prompt', json.dumps(prompt))
|
||||
@@ -436,8 +654,13 @@ class SaveImageExtended:
|
||||
|
||||
return metadata
|
||||
|
||||
# ███████ ██ ██ ██ ███████
|
||||
# ██ ██ ██ ██ ██
|
||||
# █████ ███ ██ █████
|
||||
# ██ ██ ██ ██ ██
|
||||
# ███████ ██ ██ ██ ██
|
||||
|
||||
def get_metadata_exif(self, img, prompt, extra_pnginfo=None):
|
||||
def genMetadataEXIF(self, img, prompt, extra_pnginfo=None):
|
||||
metadata = {}
|
||||
if prompt is not None:
|
||||
metadata["prompt"] = prompt
|
||||
@@ -445,9 +668,10 @@ class SaveImageExtended:
|
||||
metadata.update(extra_pnginfo)
|
||||
|
||||
## For Comfy to load an image, it need a PNG tag at the root: Prompt={prompt}
|
||||
## For AVIF WebP Jpeg, it's only Exif that's available... and UserComment is the best choice.
|
||||
## For AVIF WebP Jpeg JXL, it's only Exif that's available... and UserComment is the best choice.
|
||||
|
||||
## This method gives wrong results, as [ExifTool] will issue Warning: Invalid EXIF text encoding for UserComment
|
||||
## This method gives good results, as long as you save the prompt/workflow in 2 separate Exif tags
|
||||
## Otherwise, [ExifTool] will issue Warning: Invalid EXIF text encoding for UserComment
|
||||
# entryOffset 10
|
||||
# tag 37510
|
||||
# type 2
|
||||
@@ -458,9 +682,18 @@ class SaveImageExtended:
|
||||
exif = img.getexif()
|
||||
dump = json.dumps(metadata)
|
||||
# print(f"dump={dump}") {"prompt": { .. }, "workflow": { .. }}
|
||||
exif[ExifTags.Base.UserComment] = json.dumps(metadata)
|
||||
# This should work the same:
|
||||
# exif[0x9286] = json.dumps(metadata)
|
||||
# exif[ExifTags.Base.UserComment] = dump
|
||||
|
||||
## It seems better to separate the two
|
||||
# 0x010d: DocumentName Parameters (SD)
|
||||
# 0x010e: ImageDescription Workflow
|
||||
# 0x010f: Make Prompt
|
||||
# 0x9286: UserComment cancelled
|
||||
# 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.get('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
|
||||
@@ -496,42 +729,84 @@ class SaveImageExtended:
|
||||
return exif_dat
|
||||
|
||||
|
||||
def save_image(self, image_path, img, prompt, save_metadata=save_metadata, extra_pnginfo=None):
|
||||
# print(f"debug save_images: image_path={image_path}")
|
||||
output_ext = os.path.splitext(os.path.basename(image_path))[1]
|
||||
# ██ ██ ██████ ██ ████████ ███████
|
||||
# ██ ██ ██ ██ ██ ██ ██
|
||||
# ██ █ ██ ██████ ██ ██ █████
|
||||
# ██ ███ ██ ██ ██ ██ ██ ██
|
||||
# ███ ███ ██ ██ ██ ██ ███████
|
||||
|
||||
def writeImage(self, image_path, img, prompt, save_metadata=save_metadata, extra_pnginfo=None, quality=quality):
|
||||
if debug: print(f"debug writeImage: image_path={image_path}")
|
||||
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()
|
||||
|
||||
# match is python 3.10+
|
||||
match output_ext:
|
||||
case '.avif':
|
||||
if save_metadata: kwargs["exif"] = self.get_metadata_exif(img, prompt, extra_pnginfo)
|
||||
kwargs["lossless"] = self.lossless
|
||||
kwargs["quality"] = self.avif_quality
|
||||
case '.webp':
|
||||
if save_metadata: kwargs["exif"] = self.get_metadata_exif(img, prompt, extra_pnginfo)
|
||||
kwargs["lossless"] = self.lossless
|
||||
kwargs["quality"] = self.webp_quality
|
||||
if self.webp_quality == 100: kwargs["lossless"] = True
|
||||
case '.jpg':
|
||||
if save_metadata: kwargs["exif"] = self.get_metadata_exif(img, prompt, extra_pnginfo)
|
||||
kwargs["quality"] = self.jpeg_quality
|
||||
case '.jxl':
|
||||
kwargs["lossless"] = self.lossless
|
||||
if save_metadata: kwargs["exif"] = self.get_metadata_exif(img, prompt, extra_pnginfo)
|
||||
kwargs["quality"] = self.jxl_quality
|
||||
case _:
|
||||
# png: no quality
|
||||
if save_metadata: kwargs["pnginfo"] = self.get_metadata_png(img, prompt, extra_pnginfo)
|
||||
kwargs["compress_level"] = self.png_compress_level
|
||||
# png
|
||||
# TODO: see if convert_hdr_to_8bit=False make a change
|
||||
# https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html
|
||||
|
||||
# img.save(image_path, pnginfo=metadata, compress_level=self.png_compress_level)
|
||||
if output_ext in ['.avif', '.webp', '.jxl']:
|
||||
if save_metadata: kwargs["exif"] = self.genMetadataEXIF(img, prompt, extra_pnginfo)
|
||||
if quality == 100:
|
||||
kwargs["lossless"] = True
|
||||
else:
|
||||
kwargs["quality"] = quality
|
||||
kwargs["optimize"] = self.optimize_image
|
||||
if output_ext in ['.j2k', '.jp2', '.jpc', '.jpf', '.jpx', '.j2c']:
|
||||
if save_metadata: kwargs["exif"] = self.genMetadataEXIF(img, prompt, extra_pnginfo)
|
||||
if quality < 100:
|
||||
kwargs["irreversible"] = True
|
||||
# there is no such thing as compression level in JPEG2000. Read https://comprimato.com/blog/2017/06/22/bitrate-control-quality-layers-jpeg2000/
|
||||
# kwargs["quality_mode"] = 'rates' or 'dB'
|
||||
# kwargs["quality_layers"] = [0,1,2] no refence online. i tried all values from 0 to 100 and no change in filesize
|
||||
else:
|
||||
kwargs["quality"] = quality
|
||||
elif output_ext in ['.jpg', '.jpeg']:
|
||||
if save_metadata: kwargs["exif"] = self.genMetadataEXIF(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 ['.tiff']:
|
||||
# tiff: no quality
|
||||
kwargs["optimize"] = self.optimize_image
|
||||
elif output_ext in ['.png', '.gif']:
|
||||
if save_metadata: kwargs["pnginfo"] = self.genMetadataPng(img, prompt, extra_pnginfo)
|
||||
|
||||
# png/gif: no quality, rather we convert quality to compression level in the 0-9 range
|
||||
old_min = 0
|
||||
old_max = 90
|
||||
new_min = 0
|
||||
new_max = 9
|
||||
if quality >= 91: quality = 90
|
||||
png_compress_level = round( ( (quality - old_min) / (old_max - old_min) ) * (new_max - new_min) + new_min )
|
||||
|
||||
kwargs["compress_level"] = png_compress_level
|
||||
# BUG: PIL will compress at level 9 when PNG optimize_image = True
|
||||
# kwargs["optimize"] = self.optimize_image
|
||||
# elif output_ext in ['.bmp']:
|
||||
# nothing to add
|
||||
|
||||
# BUG: PIL.Image doesn't respect compress_level value and always output max 9 compressed images when optimize_image = True
|
||||
# img.save(image_path, pnginfo=metadata, compress_level=png_compress_level)
|
||||
img.save(image_path, **kwargs)
|
||||
|
||||
# class SaveImageExtended -------------------------------------------------------------------------------
|
||||
# Is saving image with OpenCV really faster then PIL? https://github.com/python-pillow/Pillow/issues/5986
|
||||
# I found that it does not matter for anything smaller then 8k*8k, which Comfy cannot produce anyways.
|
||||
# compression_level = [cv2.IMWRITE_PNG_COMPRESSION, png_compress_level]
|
||||
# image_array = numpy.array(img)
|
||||
# image_array = cv2.cvtColor(image_array, cv2.COLOR_RGB2BGR)
|
||||
# cv2.imwrite(image_path.replace('000','cv2'), image_array)
|
||||
|
||||
|
||||
# ███████ █████ ██ ██ ███████
|
||||
# ██ ██ ██ ██ ██ ██
|
||||
# ███████ ███████ ██ ██ █████
|
||||
# ██ ██ ██ ██ ██ ██
|
||||
# ███████ ██ ██ ████ ███████
|
||||
|
||||
# node will never return None values, except for optional input. Impossible.
|
||||
def save_images(self,
|
||||
images,
|
||||
@@ -553,19 +828,27 @@ class SaveImageExtended:
|
||||
positive_text_opt=None,
|
||||
extra_pnginfo=None,
|
||||
prompt=None,
|
||||
quality=quality,
|
||||
named_keys=named_keys,
|
||||
):
|
||||
|
||||
# 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")
|
||||
if debug:
|
||||
print(f"debug save_images filename_prefix = x{filename_prefix}x")
|
||||
print(f"debug save_images filename_keys = x{filename_keys}x")
|
||||
print(f"debug save_images foldername_prefix = x{foldername_prefix}x")
|
||||
print(f"debug save_images foldername_keys = x{foldername_keys}x")
|
||||
print(f"debug save_images delimiter = x{delimiter}x")
|
||||
print(f"debug save_images save_job_data = x{save_job_data}x")
|
||||
print(f"debug save_images job_data_per_image = x{job_data_per_image}x")
|
||||
print(f"debug save_images output_ext = x{output_ext}x")
|
||||
print(f"debug 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
|
||||
# Nope: as per issues/48 request, we could want 0001.jpg etc with no name
|
||||
# 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(',')]
|
||||
@@ -574,44 +857,65 @@ class SaveImageExtended:
|
||||
################################## 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
|
||||
# Get set resolution value - that's a secret keyword
|
||||
i = 255. * images[0].cpu().numpy()
|
||||
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
|
||||
img = Image.fromarray(numpy.clip(i, 0, 255).astype(numpy.uint8))
|
||||
resolution = f'{img.width}x{img.height}'
|
||||
|
||||
timestamp = datetime.now()
|
||||
custom_foldername = self.generate_custom_name(foldername_keys_to_extract, foldername_prefix, delimiter, prompt, resolution, timestamp, named_keys)
|
||||
custom_filename = self.generate_custom_name(filename_keys_to_extract, filename_prefix, delimiter, prompt, resolution, timestamp, named_keys)
|
||||
|
||||
# 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)
|
||||
|
||||
# issue/48
|
||||
if custom_filename:
|
||||
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
|
||||
else:
|
||||
output_path = Path(os.path.join(self.output_dir, custom_foldername))
|
||||
filename = ''
|
||||
if debug: print(f"debug save_images: custom_foldername= {custom_foldername}")
|
||||
if debug: print(f"debug save_images: custom_filename= {custom_filename}")
|
||||
if debug: print(f"debug save_images: output_path= {output_path}")
|
||||
if debug: 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}")
|
||||
counter = self.get_latest_counter(output_path, filename, counter_digits, counter_position, output_ext)
|
||||
|
||||
results = list()
|
||||
for image in images:
|
||||
i = 255. * image.cpu().numpy()
|
||||
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
|
||||
img = Image.fromarray(numpy.clip(i, 0, 255).astype(numpy.uint8))
|
||||
|
||||
if counter_digits > 0:
|
||||
# issue/48
|
||||
if filename:
|
||||
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}'
|
||||
else:
|
||||
image_name = f'{counter:0{counter_digits}}{output_ext}'
|
||||
else:
|
||||
# issue/48
|
||||
if filename:
|
||||
image_name = f'{filename}{output_ext}'
|
||||
else:
|
||||
image_name = f'{filename_prefix}{output_ext}'
|
||||
|
||||
image_path = os.path.join(output_path, file)
|
||||
self.save_image(image_path, img, prompt, save_metadata, extra_pnginfo)
|
||||
image_path = os.path.join(output_path, image_name)
|
||||
self.writeImage(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:
|
||||
@@ -624,6 +928,8 @@ class SaveImageExtended:
|
||||
results = list()
|
||||
return { 'ui': { 'images': results } }
|
||||
|
||||
# class SaveImageExtended -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
'SaveImageExtended': SaveImageExtended,
|
||||
@@ -631,7 +937,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
'SaveImageExtended': '💾 Save Image Extended',
|
||||
'SaveImageExtended': f'💾 Save Image Extended {version}',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
// Adds context menu entries, code partly from pyssssscustom-scripts
|
||||
|
||||
function addMenuHandler(nodeType, cb) {
|
||||
// console.debug("SIE addMenuHandler nodeType:", nodeType);
|
||||
// console.debug("SIE addMenuHandler cb:", cb);
|
||||
const getOpts = nodeType.prototype.getExtraMenuOptions;
|
||||
|
||||
// the below is never called, i suspect app.js has changed a lot
|
||||
nodeType.prototype.getExtraMenuOptions = function () {
|
||||
const r = getOpts.apply(this, arguments);
|
||||
cb.apply(this, arguments);
|
||||
// console.debug("SIE addMenuHandler arguments:", arguments);
|
||||
return r;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function addNode(name, nextTo, options) {
|
||||
// console.debug("SIE addNode name:", name);
|
||||
// console.debug("SIE addNode nextTo:", nextTo);
|
||||
options = { select: true, shiftY: 0, before: false, ...(options || {}) };
|
||||
const node = LiteGraph.createNode(name);
|
||||
app.graph.add(node);
|
||||
node.pos = [
|
||||
options.before ? nextTo.pos[0] - node.size[0] - 30 : nextTo.pos[0] + nextTo.size[0] + 30,
|
||||
nextTo.pos[1] + options.shiftY,
|
||||
];
|
||||
if (options.select) {
|
||||
app.canvas.selectNode(node, false);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// https://docs.comfy.org/custom-nodes/js/javascript_overview
|
||||
app.registerExtension({
|
||||
name: "SIE.QuickNodes",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (
|
||||
// this could go forever if we want to list all nodes that can output IMAGE
|
||||
// nodeData.name === "VAEDecode" ||
|
||||
// nodeData.name === "LoadImage" ||
|
||||
|
||||
// fortunately, output is an array where "IMAGE" is always the first item.
|
||||
nodeData.output[0] == "IMAGE"
|
||||
) {
|
||||
// console.debug("SIE.QuickNodes VAEDecode nodeType:", nodeType);
|
||||
// console.debug("SIE.QuickNodes VAEDecode nodeData:", nodeData);
|
||||
// console.debug("SIE.QuickNodes VAEDecode app:", app);
|
||||
|
||||
// https://docs.comfy.org/custom-nodes/js/javascript_examples#node-menu
|
||||
// Code below adds SaveImageExtended option on right-click menu for VAEDecode
|
||||
addMenuHandler(nodeType, function (_, options) {
|
||||
options.unshift(
|
||||
{
|
||||
content: "💾 Add SaveImageExtended",
|
||||
callback: () => {
|
||||
// const saveNode = addNode("SaveImageExtended", this, { before: true });
|
||||
const saveNode = addNode("SaveImageExtended", this);
|
||||
this.connect(0, saveNode, 0);
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// https://docs.comfy.org/custom-nodes/walkthrough#write-a-client-extension
|
||||
// I forgot why I added this one, probably not needed after all
|
||||
{
|
||||
name: "SIE.Contextmenu",
|
||||
async setup(app) {
|
||||
// console.debug("SIEContextmenu app:", 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||