Compare commits

...
3 Commits
Author SHA1 Message Date
audioscavenger fd8388e9d8 release 2.87 right-click
- finally! right-click any node with IMAGE output adds "Add SaveImageExtended" option
    - upgraded purify.min.js
    	modified:   README.md
    	modified:   pyproject.toml
    	modified:   save_image_extended.py
    	modified:   web/js/contextmenu.js
    	modified:   web/js/purify.min.js
	modified:   pyproject.toml
	modified:   save_image_extended.py
2025-09-01 18:36:17 -07:00
audioscavenger 1a39e8b0f7 release 2.87 right-click
- finally! right-click any node with IMAGE output adds "Add SaveImageExtended" option
- upgraded purify.min.js
	modified:   README.md
	modified:   pyproject.toml
	modified:   save_image_extended.py
	modified:   web/js/contextmenu.js
	modified:   web/js/purify.min.js
2025-09-01 18:19:40 -07:00
audioscavenger 29971dc90f release 2.86 bugfix in counter_digits which does not accept 0 to disable counter
modified:   README.md
    	modified:   pyproject.toml
    	modified:   save_image_extended.py
2025-08-02 10:26:40 -07:00
7 changed files with 147 additions and 84 deletions
+19 -6
View File
@@ -19,12 +19,12 @@ Supports those extensions: **JXL AVIF WebP jpg jpeg j2k jp2 png gif tiff bmp**
<br><br>
</p>
## Parameters / Usage
## Parameters / Reserved Keywords
| Attribute | Description |
| --- | --- |
| `filename_prefix` | Fixed 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. `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`. |
| `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. |
@@ -38,11 +38,13 @@ Supports those extensions: **JXL AVIF WebP jpg jpeg j2k jp2 png gif tiff bmp**
| `image_preview` | Turns the image preview on and off. |
| `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 fixed 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
@@ -110,6 +112,12 @@ There is a requirements.txt that will take care of that, but just in case:
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\
@@ -215,6 +223,11 @@ TODO:
- [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
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+2 -2
View File
@@ -1,7 +1,7 @@
[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.86"
version = "2.87"
license = { file = "LICENSE" }
dependencies = ["piexif", "imagecodecs", "pillow", "pillow-avif-plugin", "pillow-jxl-plugin"]
@@ -12,4 +12,4 @@ Repository = "https://github.com/audioscavenger/save-image-extended-comfyui"
[tool.comfy]
PublisherId = "audioscavenger"
DisplayName = "save-image-extended-comfyui"
Icon = ""
Icon = "💾"
+68 -66
View File
@@ -14,7 +14,7 @@ import numpy
# import cv2 # not faster then PIL
version = 2.86
version = 2.87
avif_supported = False
jxl_supported = False
@@ -33,8 +33,9 @@ else:
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:
# jxlpy is in early stages of development. None one has ever compiled it on Windows AFAIK
# 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
@@ -56,6 +57,71 @@ original_locale = locale.setlocale(locale.LC_TIME, '')
# class SaveImageExtended -------------------------------------------------------------------------------
class SaveImageExtended:
"""
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.
Returns: `dict`:
- Key input_fields_group (`string`): Can be either required, hidden or optional. A node class must have property `required`
- Value input_fields (`dict`): Contains input fields config:
* Key field_name (`string`): Name of a entry-point method's argument
* Value field_config (`tuple`):
+ First value is a string indicate the type of field or a list for selection.
+ Secound value is a config for type "INT", "STRING" or "FLOAT".
"""
@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, '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, '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": 0,
"max": 8,
"step": 1,
"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, '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, '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
@@ -135,70 +201,6 @@ ComfyUI can only load PNG and WebP at the moment, AVIF is a PR that was sadly dr
self.output_dir = folder_paths.get_output_directory()
self.prefix_append = ''
"""
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.
Returns: `dict`:
- Key input_fields_group (`string`): Can be either required, hidden or optional. A node class must have property `required`
- Value input_fields (`dict`): Contains input fields config:
* Key field_name (`string`): Name of a entry-point method's argument
* Value field_config (`tuple`):
+ First value is a string indicate the type of field or a list for selection.
+ Secound value is a config for type "INT", "STRING" or "FLOAT".
"""
@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, '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, '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": 0,
"max": 8,
"step": 1,
"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, '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, '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'},
}
# This class serves no purpose, it can only test 1 element. you always get all errors even if only one element is bad
# @classmethod
+55 -8
View File
@@ -3,24 +3,28 @@ 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.log("name:", name);
console.log("nextTo:", nextTo);
options = { side: "left", select: true, shiftY: 0, shiftX: 0, ...(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.side === "left" ? nextTo.pos[0] - (node.size[0] + options.offset): nextTo.pos[0] + nextTo.size[0] + options.offset,
options.before ? nextTo.pos[0] - node.size[0] - 30 : nextTo.pos[0] + nextTo.size[0] + 30,
nextTo.pos[1] + options.shiftY,
];
if (options.select) {
@@ -29,12 +33,49 @@ function addNode(name, nextTo, options) {
return node;
}
// https://docs.comfy.org/custom-nodes/js/javascript_overview
app.registerExtension({
name: "SIEContextmenu",
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",
name: "💾 SIE: Help popups",
defaultValue: true,
type: "boolean",
options: (value) => [
@@ -42,5 +83,11 @@ app.registerExtension({
{ value: false, text: "Off", selected: value === false },
],
});
}
});
+2 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long