new save image node
This commit is contained in:
@@ -51,6 +51,16 @@ It includes:
|
||||
|
||||
---
|
||||
|
||||
### 💾 Save Image w/Metadata
|
||||
|
||||
- **Single path+filename input:** Provide a single required `path_and_filename` string (examples: `comfy`, `Test/comfy`, `SDXL/Test/comfy`). The last path segment is used as the filename; preceding segments become folders under the ComfyUI output directory.
|
||||
- **Supported formats:** PNG, JPEG (saved as .jpg), WEBP (choose with the `extension` dropdown).
|
||||
- **Automatic folder creation:** Any missing folders in the provided path are created automatically.
|
||||
- **Metadata embedding:** PNG files receive tEXt entries (parameters/prompt). JPEG/WEBP receive EXIF UserComment where possible.
|
||||
- **Placeholder resolution:** Filenames and paths accept `%...%` placeholders which are resolved from the node's `prompt` dictionary (best-effort). Example: if `prompt` contains `{"title": "MyTitle"}`, using `%title%/img` will save under `MyTitle/img.png`.
|
||||
- **Collision-safe saving:** If a file already exists the node will append `_0001`, `_0002`, ... to the base filename to avoid overwriting.
|
||||
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
1. Clone or download this repository into your ComfyUI `custom_nodes` directory:
|
||||
@@ -130,6 +140,31 @@ It includes:
|
||||
|
||||
---
|
||||
|
||||
### 💾 Save Image w/Metadata — Inputs & Outputs
|
||||
|
||||
| Name | Type | Description |
|
||||
|--------------------|---------|-------------|
|
||||
| `path_and_filename`| STRING | Single required path + filename string. Examples: `comfy`, `Test/comfy`, `SDXL/Test/comfy`. The last segment is used as filename (extension ignored); preceding segments are folder path under ComfyUI output directory. |
|
||||
| `extension` | STRING | File format dropdown: `png`, `jpeg`, `webp`. |
|
||||
| `custom_string` | STRING | Optional custom text to embed in metadata. |
|
||||
| `quality_jpeg_or_webp` | INT | JPEG/WEBP quality (1-100). |
|
||||
| `lossless_webp` | BOOLEAN | If true, WEBP will be saved lossless (when supported). |
|
||||
|
||||
**Hidden inputs:**
|
||||
|
||||
- `prompt` — a dict-like object the node will consult to resolve `%...%` placeholders used in the `path_and_filename` string (best-effort lookup).
|
||||
|
||||
**Outputs:**
|
||||
|
||||
The node returns no tensor outputs but updates the UI with the list of saved filenames and their subfolders (so ComfyUI will display the saved files in the results panel).
|
||||
|
||||
**Behavior notes & examples:**
|
||||
|
||||
- If `path_and_filename` is `comfy` and `extension` is `png`, the node saves `OUTPUT_DIR/comfy.png` (or `comfy_0001.png` if it already exists).
|
||||
- If `path_and_filename` is `SDXL/Test/comfy`, the node creates `OUTPUT_DIR/SDXL/Test/` if missing and saves `comfy.<ext>` inside it.
|
||||
- You may use placeholders in `path_and_filename`, for example `%title%/img`, which will be resolved from the `prompt` dict when possible.
|
||||
|
||||
|
||||
## 📝 License
|
||||
|
||||
MIT License
|
||||
@@ -138,7 +173,4 @@ MIT License
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- [ComfyUI](https://github.com/comfyanonymous/ComfyUI)
|
||||
- [SATA Utility Node GitHub](https://github.com/SatadalAI/SATA_UtilityNode)
|
||||
- [ComfyUI](https://github.com/comfyanonymous/ComfyUI)
|
||||
- [SATA Utility Node GitHub](https://github.com/SatadalAI/SATA_UtilityNode)
|
||||
- [ComfyUI](https://github.com/comfyanonymous/ComfyUI)
|
||||
@@ -1,17 +1,20 @@
|
||||
from .nodes.upscale_machine import Upscale_Machine
|
||||
from .nodes.prompt_machine import Prompt_Machine
|
||||
from .nodes.resolution_machine import Resolution_Machine
|
||||
from .nodes.save_image_metadata import ImageSaveWithMetadata
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"Upscale_Machine": Upscale_Machine,
|
||||
"Prompt_Machine": Prompt_Machine,
|
||||
"Resolution_Machine": Resolution_Machine,
|
||||
"Save_Image_Metadata": ImageSaveWithMetadata,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"Upscale_Machine": "Upscale Machine",
|
||||
"Prompt_Machine": "Prompt Machine",
|
||||
"Resolution_Machine": "Resolution Machine",
|
||||
"Save_Image_Metadata": "Save Image Metadata",
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import os
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
import json
|
||||
import piexif
|
||||
import piexif.helper
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
import numpy as np
|
||||
import folder_paths
|
||||
import re
|
||||
|
||||
|
||||
def parse_name(ckpt_name):
|
||||
path = ckpt_name
|
||||
filename = path.split("/")[-1]
|
||||
filename = filename.split(".")[:-1]
|
||||
filename = ".".join(filename)
|
||||
return filename
|
||||
|
||||
|
||||
def calculate_sha256(file_path):
|
||||
sha256_hash = hashlib.sha256()
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
for byte_block in iter(lambda: f.read(4096), b""):
|
||||
sha256_hash.update(byte_block)
|
||||
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
|
||||
def handle_whitespace(string: str):
|
||||
return string.strip().replace("\n", " ").replace("\r", " ").replace("\t", " ")
|
||||
|
||||
|
||||
def get_timestamp(time_format):
|
||||
now = datetime.now()
|
||||
try:
|
||||
timestamp = now.strftime(time_format)
|
||||
except:
|
||||
timestamp = now.strftime("%Y-%m-%d-%H%M%S")
|
||||
|
||||
return timestamp
|
||||
|
||||
|
||||
def make_pathname(template: str):
|
||||
"""Simple pathname templating: supports %date and %time."""
|
||||
template = template.replace("%date", get_timestamp("%Y-%m-%d"))
|
||||
template = template.replace("%time", get_timestamp("%Y-%m-%d-%H%M%S"))
|
||||
return template
|
||||
|
||||
|
||||
def make_filename(template: str):
|
||||
"""Return a resolved filename from template; if empty, return a timestamp."""
|
||||
filename = make_pathname(template)
|
||||
return get_timestamp("%Y-%m-%d-%H%M%S") if filename == "" else filename
|
||||
|
||||
|
||||
def resolve_placeholders(template: str, prompt=None) -> str:
|
||||
"""Resolve placeholders of the form %node_name/field% by looking into extra_pnginfo and prompt.
|
||||
|
||||
This is a best-effort resolver: it attempts several lookup strategies and falls back to leaving
|
||||
the placeholder unchanged when a value can't be found.
|
||||
"""
|
||||
if not template or '%' not in template:
|
||||
return template
|
||||
|
||||
def lookup(key: str):
|
||||
# key is like 'node_name/field' or 'field'
|
||||
if not key:
|
||||
return None
|
||||
# Try prompt mapping
|
||||
if prompt and isinstance(prompt, dict):
|
||||
if key in prompt:
|
||||
return prompt[key]
|
||||
if '/' in key:
|
||||
_, field = key.split('/', 1)
|
||||
if field in prompt:
|
||||
return prompt[field]
|
||||
|
||||
return None
|
||||
|
||||
pattern = re.compile(r"%([^%]+)%")
|
||||
|
||||
def repl(m):
|
||||
key = m.group(1)
|
||||
val = lookup(key)
|
||||
if val is None:
|
||||
return m.group(0)
|
||||
# If value is a list/dict, JSON-serialize; otherwise string
|
||||
try:
|
||||
if isinstance(val, (dict, list)):
|
||||
return json.dumps(val)
|
||||
return str(val)
|
||||
except Exception:
|
||||
return str(val)
|
||||
|
||||
return pattern.sub(repl, template)
|
||||
|
||||
|
||||
class ImageSaveWithMetadata:
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.output_directory
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE", ),
|
||||
"path_and_filename": ("STRING", {"default": "%time", "multiline": False}),
|
||||
"extension": ((['png', 'jpeg', 'webp']),),
|
||||
},
|
||||
"optional": {
|
||||
"custom_string": ("STRING", {"default": '', "multiline": True}),
|
||||
"lossless_webp": ("BOOLEAN", {"default": True}),
|
||||
"quality_jpeg_or_webp": ("INT", {"default": 100, "min": 1, "max": 100}),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "save_files"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "SATA_UtilityNode"
|
||||
|
||||
def save_files(self, images, path_and_filename, extension, custom_string=None,
|
||||
quality_jpeg_or_webp=100, lossless_webp=True, prompt=None):
|
||||
# Resolve placeholders first (uses prompt)
|
||||
try:
|
||||
resolved = resolve_placeholders(str(path_and_filename), prompt)
|
||||
except Exception:
|
||||
resolved = str(path_and_filename)
|
||||
|
||||
# Normalize separators and split into directory + basename
|
||||
pa = resolved.replace('\\', '/').strip()
|
||||
pa_dir = os.path.dirname(pa)
|
||||
pa_base = os.path.basename(pa)
|
||||
# Remove extension from provided base if present; we'll use the extension input instead
|
||||
pa_base_noext = os.path.splitext(pa_base)[0]
|
||||
|
||||
# Final filename and path
|
||||
filename = make_filename(pa_base_noext if pa_base_noext != '' else "%time")
|
||||
path = make_pathname(pa_dir)
|
||||
|
||||
output_path = os.path.join(self.output_dir, path)
|
||||
|
||||
if output_path.strip() != '':
|
||||
if not os.path.exists(output_path.strip()):
|
||||
print(f'The path `{output_path.strip()}` specified doesn\'t exist! Creating directory.')
|
||||
os.makedirs(output_path, exist_ok=True)
|
||||
|
||||
comment_parts = []
|
||||
if custom_string:
|
||||
comment_parts.append(handle_whitespace(custom_string))
|
||||
if prompt is not None:
|
||||
try:
|
||||
prompt_text = json.dumps(prompt)
|
||||
except Exception:
|
||||
prompt_text = str(prompt)
|
||||
comment_parts.append(f"Prompt: {handle_whitespace(prompt_text)}")
|
||||
|
||||
comment = "\n".join(comment_parts).strip()
|
||||
|
||||
# Resolve any %node/field% placeholders in filename and path using prompt
|
||||
try:
|
||||
filename = resolve_placeholders(filename, prompt)
|
||||
path = resolve_placeholders(path, prompt)
|
||||
except Exception:
|
||||
# Best-effort: if resolution fails, continue with original templates
|
||||
pass
|
||||
|
||||
filenames = self.save_images(images, output_path, filename, comment, extension,
|
||||
quality_jpeg_or_webp, lossless_webp, prompt)
|
||||
|
||||
subfolder = os.path.normpath(path)
|
||||
ui_images = [ {"filename": fn, "subfolder": subfolder if subfolder != '.' else '', "type": 'output'} for fn in filenames ]
|
||||
return {"ui": {"images": ui_images}}
|
||||
|
||||
def save_images(self, images, output_path, filename_prefix, comment, extension, quality_jpeg_or_webp, lossless_webp, prompt=None) -> list:
|
||||
img_count = 1
|
||||
paths = []
|
||||
|
||||
# images is a torch tensor (B,H,W,C) or a list/iterable of such tensors
|
||||
try:
|
||||
import torch
|
||||
is_torch = True
|
||||
except Exception:
|
||||
is_torch = False
|
||||
|
||||
# Normalize input to list of images
|
||||
imgs = None
|
||||
if is_torch and hasattr(images, 'ndim'):
|
||||
imgs = [images[i] for i in range(images.shape[0])] if images.ndim == 4 else [images]
|
||||
elif isinstance(images, (list, tuple)):
|
||||
imgs = images
|
||||
else:
|
||||
imgs = [images]
|
||||
|
||||
def find_unique_name(base_name: str, ext: str) -> str:
|
||||
"""Return a file name (with extension) that does not collide on disk. Uses _0001 style suffixes."""
|
||||
candidate = f"{base_name}.{ext}"
|
||||
candidate_path = os.path.join(output_path, candidate)
|
||||
if not os.path.exists(candidate_path):
|
||||
return candidate
|
||||
idx = 1
|
||||
while True:
|
||||
candidate = f"{base_name}_{idx:04d}.{ext}"
|
||||
candidate_path = os.path.join(output_path, candidate)
|
||||
if not os.path.exists(candidate_path):
|
||||
return candidate
|
||||
idx += 1
|
||||
|
||||
for image in imgs:
|
||||
# Convert to PIL Image
|
||||
if is_torch and hasattr(image, 'cpu'):
|
||||
i = 255. * image.cpu().numpy()
|
||||
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
|
||||
else:
|
||||
# assume numpy array
|
||||
arr = np.array(image)
|
||||
if arr.dtype != np.uint8:
|
||||
arr = np.clip(arr * 255.0, 0, 255).astype(np.uint8)
|
||||
img = Image.fromarray(arr)
|
||||
|
||||
# Base filename (without extension)
|
||||
base_name = filename_prefix
|
||||
|
||||
if extension == 'png':
|
||||
metadata = PngInfo()
|
||||
if comment:
|
||||
metadata.add_text("parameters", comment)
|
||||
|
||||
if prompt is not None:
|
||||
try:
|
||||
metadata.add_text("prompt", json.dumps(prompt))
|
||||
except Exception:
|
||||
metadata.add_text("prompt", str(prompt))
|
||||
|
||||
# No extra_pnginfo support: only include the prompt (handled above)
|
||||
|
||||
outname = find_unique_name(base_name, 'png')
|
||||
img.save(os.path.join(output_path, outname), pnginfo=metadata, optimize=True)
|
||||
else:
|
||||
ext = extension
|
||||
if ext == 'jpeg':
|
||||
ext_name = 'jpg'
|
||||
else:
|
||||
ext_name = ext
|
||||
|
||||
outname = find_unique_name(base_name, ext_name)
|
||||
file_path = os.path.join(output_path, outname)
|
||||
img.save(file_path, optimize=True, quality=quality_jpeg_or_webp, lossless=lossless_webp if ext_name=='webp' else False)
|
||||
|
||||
# Insert EXIF user comment with our metadata
|
||||
if comment:
|
||||
try:
|
||||
exif_bytes = piexif.dump({
|
||||
"Exif": {
|
||||
piexif.ExifIFD.UserComment: piexif.helper.UserComment.dump(comment, encoding="unicode")
|
||||
},
|
||||
})
|
||||
piexif.insert(exif_bytes, file_path)
|
||||
except Exception:
|
||||
# piexif may fail for some formats; ignore silently
|
||||
pass
|
||||
|
||||
paths.append(outname)
|
||||
img_count += 1
|
||||
|
||||
return paths
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui_sata_utilitynode"
|
||||
description = "ComfyUI utility nodes collection providing features like prompt machine, resolution machine, and upscale machine to enhance workflow efficiency."
|
||||
version = "1.0.0"
|
||||
version = "1.2.0"
|
||||
license = "MIT"
|
||||
|
||||
[project.urls]
|
||||
|
||||
Reference in New Issue
Block a user