new save image node

This commit is contained in:
Satadal Dhara
2025-11-12 09:51:45 +05:30
parent d309a24c06
commit 6b358ac267
4 changed files with 312 additions and 5 deletions
+36 -4
View File
@@ -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)
+3
View File
@@ -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"
+272
View File
@@ -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
View File
@@ -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]