Add Save Image node, caption output on Load Image, and .gitignore

Brekel Save Image: PNG/JPG output with quality control, a save/preview
toggle, start_number offset and fill_gaps numbering shared between both
formats, and optional caption metadata instead of the workflow/prompt.

Brekel Load Image: extra caption output reading PNG text chunks and the
JPG/WEBP EXIF fields (UserComment/ImageDescription).

Also adds a .gitignore so __pycache__ and other build artifacts stay out
of the repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brekel
2026-08-23 15:20:21 +02:00
co-authored by Claude Opus 5
parent 2114bd3996
commit cc88f06425
5 changed files with 380 additions and 9 deletions
+31
View File
@@ -0,0 +1,31 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
# Packaging / distribution
build/
dist/
*.egg-info/
.eggs/
# Virtual environments
.venv/
venv/
env/
# Testing / tooling caches
.pytest_cache/
.mypy_cache/
.ruff_cache/
# Editors
.vscode/
.idea/
*.swp
# OS
.DS_Store
Thumbs.db
desktop.ini
+5
View File
@@ -13,6 +13,9 @@ from .brekel_resolution_selector import NODE_CLASS_MAPPINGS as RESOLUTION_CLASS_
# Import the mappings from the new load image node
from .brekel_load_image import NODE_CLASS_MAPPINGS as LOAD_IMAGE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as LOAD_IMAGE_NAME_MAPPINGS
# Import the mappings from the new save image node
from .brekel_save_image import NODE_CLASS_MAPPINGS as SAVE_IMAGE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as SAVE_IMAGE_NAME_MAPPINGS
# Import the mappings from the directory-based lora loader node
from .brekel_lora_loader import NODE_CLASS_MAPPINGS as LORA_LOADER_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as LORA_LOADER_NAME_MAPPINGS
@@ -23,6 +26,7 @@ NODE_CLASS_MAPPINGS = {
**ENHANCE_PROMPT_CLASS_MAPPINGS,
**RESOLUTION_CLASS_MAPPINGS,
**LOAD_IMAGE_CLASS_MAPPINGS,
**SAVE_IMAGE_CLASS_MAPPINGS,
**LORA_LOADER_CLASS_MAPPINGS,
}
@@ -33,6 +37,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
**ENHANCE_PROMPT_NAME_MAPPINGS,
**RESOLUTION_NAME_MAPPINGS,
**LOAD_IMAGE_NAME_MAPPINGS,
**SAVE_IMAGE_NAME_MAPPINGS,
**LORA_LOADER_NAME_MAPPINGS,
}
+64 -7
View File
@@ -4,8 +4,11 @@
#
# Author: Brekel - https://brekel.com
#
# This node is the same as the standard Load Image node except it adds the image name (without extension) as an output
# so it can for example be passed to the filename_prefix of an output node or used elsewhere in your workflow
# This node is the same as the standard Load Image node except it adds two extra outputs:
# - filename: the image name without extension, so it can for example be passed to the filename_prefix
# of an output node or used elsewhere in your workflow
# - caption: the text embedded in the image (as written by the Brekel Save Image node or by other tools),
# empty when the image has none
import os
import torch
@@ -18,8 +21,8 @@ import node_helpers
class BrekelLoadImage:
# Returns IMAGE, MASK, and STRING (filename)
RETURN_TYPES = ("IMAGE", "MASK", "STRING")
RETURN_TYPES = ("IMAGE", "MASK", "STRING", "STRING")
RETURN_NAMES = ("image", "mask", "filename", "caption")
FUNCTION = "load_image"
CATEGORY = "image"
@@ -84,8 +87,7 @@ class BrekelLoadImage:
output_image = output_images[0]
output_mask = output_masks[0]
# Return the base_name (without extension) as the third output
return (output_image, output_mask, base_name)
return (output_image, output_mask, base_name, extract_caption(img))
@classmethod
def IS_CHANGED(s, image):
@@ -103,11 +105,66 @@ class BrekelLoadImage:
return True
# PNG text chunks and EXIF tags that can hold a caption
CAPTION_TEXT_KEYS = ("parameters", "Description", "caption")
EXIF_IMAGE_DESCRIPTION = 0x010E
EXIF_IFD = 0x8769
EXIF_USER_COMMENT = 0x9286
def extract_caption(img):
"""Read the caption embedded in an image, returns an empty string when there is none.
Reads the PNG text chunks and the JPG/WEBP EXIF fields written by the Brekel Save Image node,
which are the same ones used by most other tools. The workflow/prompt metadata of a regular
ComfyUI image is deliberately ignored, that is not a caption.
"""
text = getattr(img, "text", None) or {}
for key in CAPTION_TEXT_KEYS:
value = text.get(key)
if isinstance(value, str) and value.strip():
return value
try:
exif = img.getexif()
except Exception:
return ""
# UserComment holds the full unicode text, ImageDescription is limited to ASCII
comment = decode_user_comment(exif.get_ifd(EXIF_IFD).get(EXIF_USER_COMMENT))
if comment:
return comment
description = exif.get(EXIF_IMAGE_DESCRIPTION)
if isinstance(description, str) and description.strip() and not description.startswith(("workflow:", "prompt:")):
return description
return ""
def decode_user_comment(value):
"""Decode the EXIF UserComment field, which is prefixed by an 8 byte character code."""
if isinstance(value, str):
return value.strip()
if not isinstance(value, bytes):
return ""
prefix, payload = value[:8], value[8:]
try:
if prefix == b"UNICODE\x00":
return payload.decode("utf-16-le").rstrip("\x00").strip()
if prefix == b"ASCII\x00\x00\x00":
return payload.decode("ascii", "ignore").rstrip("\x00").strip()
return value.decode("utf-8", "ignore").rstrip("\x00").strip()
except Exception:
return ""
# ComfyUI mappings (These need to be at the end of the file)
NODE_CLASS_MAPPINGS = {
"BrekelLoadImage": BrekelLoadImage
}
NODE_DISPLAY_NAME_MAPPINGS = {
"BrekelLoadImage": "Brekel Load Image (with Filename)"
"BrekelLoadImage": "Brekel Load Image (with Filename & Caption)"
}
+278
View File
@@ -0,0 +1,278 @@
#
# Brekel Save Image Node for ComfyUI
# Version: 1.0.0
#
# Author: Brekel - https://brekel.com
#
# Like the standard Save Image node, but with control over the file numbering and the embedded metadata.
#
# Key Features:
# - Save as PNG or JPG (with quality control).
# - A save toggle that turns the node into a preview node, leaving the output folder untouched.
# - start_number sets the first number used, so a sequence can start at any offset.
# - fill_gaps scans the output folder and re-uses missing numbers, so deleting image_00003 while
# image_00004 exists makes the next save land on _00003 again, keeping the range continuous.
# - Numbering is shared between PNG and JPG so the two formats never claim the same number.
# - By default the workflow/prompt is embedded (drag & drop the PNG onto the canvas to restore it),
# but connecting the optional caption input embeds that text instead.
import json
import os
import random
import re
import numpy as np
from PIL import Image
from PIL.PngImagePlugin import PngInfo
import folder_paths
from comfy.cli_args import args
# EXIF tags used for the JPG metadata
EXIF_IMAGE_DESCRIPTION = 0x010E
EXIF_MAKE = 0x010F
EXIF_IFD = 0x8769
EXIF_USER_COMMENT = 0x9286
# A single EXIF APP1 segment cannot exceed 64KB, leave a little headroom
MAX_EXIF_BYTES = 60000
# Extensions taken into account when scanning for already used numbers
KNOWN_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp")
class BrekelSaveImage:
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
self.type = "output"
self.compress_level = 4
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE", {"tooltip": "The images to save."}),
"save": ("BOOLEAN", {
"default": True,
"label_on": "save", "label_off": "preview only",
"tooltip": "Turn off to act like a Preview Image node, the images go to the temp folder and the output folder and its numbering are left untouched."
}),
"filename_prefix": ("STRING", {
"default": "ComfyUI",
"tooltip": "The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes."
}),
"file_format": (["png", "jpg"], {
"default": "png",
"tooltip": "PNG is lossless and can be dragged onto the canvas to restore the workflow, JPG is smaller but stores its metadata in EXIF."
}),
"quality": ("INT", {
"default": 95, "min": 1, "max": 100,
"tooltip": "JPG quality, ignored when saving PNG."
}),
"start_number": ("INT", {
"default": 1, "min": 0, "max": 99999,
"tooltip": "The first number of the sequence, numbers below this are never used."
}),
"fill_gaps": ("BOOLEAN", {
"default": True,
"tooltip": "Re-use missing numbers in the sequence on disk instead of always appending after the highest one."
}),
},
"optional": {
"caption": ("STRING", {
"forceInput": True,
"tooltip": "When connected this text is embedded into the image instead of the workflow/prompt."
}),
},
"hidden": {
"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"
},
}
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("images", "filename")
FUNCTION = "save_images"
OUTPUT_NODE = True
CATEGORY = "image"
DESCRIPTION = "Saves the input images as PNG or JPG with control over the numbering and the embedded metadata."
def save_images(self, images, save=True, filename_prefix="ComfyUI", file_format="png", quality=95,
start_number=1, fill_gaps=True, caption=None, prompt=None, extra_pnginfo=None):
if save:
output_dir = self.output_dir
output_type = self.type
compress_level = self.compress_level
else:
# Preview only, a random suffix keeps the temp files of separate runs apart and
# makes the numbering below start at 1 without ever touching the output folder
output_dir = folder_paths.get_temp_directory()
output_type = "temp"
compress_level = 1
filename_prefix += "_temp_" + "".join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5))
start_number = 1
fill_gaps = True
full_output_folder, filename, _, subfolder, filename_prefix = folder_paths.get_save_image_path(
filename_prefix, output_dir, images[0].shape[1], images[0].shape[0])
# An empty string (an unconnected reroute, an empty prompt box) falls back to the workflow metadata
if caption is not None and caption.strip() == "":
caption = None
extension = "jpg" if file_format == "jpg" else "png"
# Numbers already on disk, per resolved base name (%batch_num% can make them differ within one batch)
used_numbers = {}
results = list()
last_filename = ""
for (batch_number, image) in enumerate(images):
i = 255. * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
if filename_with_batch_num not in used_numbers:
used_numbers[filename_with_batch_num] = scan_used_numbers(full_output_folder, filename_with_batch_num)
used = used_numbers[filename_with_batch_num]
counter = next_number(used, start_number, fill_gaps)
used.add(counter)
file = f"{filename_with_batch_num}_{counter:05}_.{extension}"
path = os.path.join(full_output_folder, file)
if extension == "jpg":
img = img.convert("RGB")
exif_bytes = build_jpeg_exif(img, caption, prompt, extra_pnginfo)
if exif_bytes is None:
img.save(path, quality=quality, subsampling=0, optimize=True)
else:
img.save(path, quality=quality, subsampling=0, optimize=True, exif=exif_bytes)
else:
img.save(path, pnginfo=build_png_metadata(caption, prompt, extra_pnginfo),
compress_level=compress_level)
results.append({
"filename": file,
"subfolder": subfolder,
"type": output_type
})
last_filename = f"{filename_with_batch_num}_{counter:05}_"
return {"ui": {"images": results}, "result": (images, last_filename)}
def scan_used_numbers(folder, base_name):
"""Collect the numbers already used by files named <base_name>_<number>_.<ext> in folder."""
pattern = re.compile(re.escape(base_name) + r"_(\d+)_", re.IGNORECASE)
used = set()
try:
entries = os.listdir(folder)
except FileNotFoundError:
os.makedirs(folder, exist_ok=True)
return used
for entry in entries:
name, ext = os.path.splitext(entry)
if ext.lower() not in KNOWN_EXTENSIONS:
continue
match = pattern.fullmatch(name)
if match:
used.add(int(match.group(1)))
return used
def next_number(used, start_number, fill_gaps):
"""Pick the number to save as, either the first free one or the one after the highest used."""
if fill_gaps:
counter = start_number
while counter in used:
counter += 1
return counter
highest = max((n for n in used if n >= start_number), default=None)
return start_number if highest is None else highest + 1
def build_png_metadata(caption, prompt, extra_pnginfo):
"""Embed the caption as text chunks, or the workflow/prompt like the standard Save Image node does."""
if args.disable_metadata:
return None
metadata = PngInfo()
if caption is not None:
# "parameters" is what most external tools read, "Description" is the generic PNG equivalent
metadata.add_text("parameters", caption)
metadata.add_text("Description", caption)
return metadata
if prompt is not None:
metadata.add_text("prompt", json.dumps(prompt))
if extra_pnginfo is not None:
for x in extra_pnginfo:
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
return metadata
def build_jpeg_exif(img, caption, prompt, extra_pnginfo):
"""Build the EXIF block for a JPG, returns None when there is nothing to embed.
The workflow/prompt use the same "key:value" strings ComfyUI writes into WEBP files.
JSON of a large workflow can outgrow the 64KB EXIF segment, in that case it is dropped
rather than failing the save.
"""
if args.disable_metadata:
return None
exif = img.getexif()
if caption is not None:
exif[EXIF_IMAGE_DESCRIPTION] = caption
exif.get_ifd(EXIF_IFD)[EXIF_USER_COMMENT] = b"UNICODE\x00" + caption.encode("utf-16-le")
return fit_exif(exif, [EXIF_USER_COMMENT], [EXIF_IMAGE_DESCRIPTION])
if extra_pnginfo is not None and "workflow" in extra_pnginfo:
exif[EXIF_IMAGE_DESCRIPTION] = "workflow:" + json.dumps(extra_pnginfo["workflow"])
if prompt is not None:
exif[EXIF_MAKE] = "prompt:" + json.dumps(prompt)
if len(exif) == 0:
return None
# Drop the workflow first, it is the largest and the least useful of the two in a JPG
return fit_exif(exif, [], [EXIF_IMAGE_DESCRIPTION, EXIF_MAKE])
def fit_exif(exif, sub_ifd_tags, tags):
"""Serialize the EXIF block, dropping tags in the given order until it fits into one APP1 segment."""
while True:
try:
data = exif.tobytes()
if len(data) <= MAX_EXIF_BYTES:
return data
except Exception as e:
print(f"[Brekel Save Image] could not encode EXIF metadata: {e}")
if sub_ifd_tags:
exif.get_ifd(EXIF_IFD).pop(sub_ifd_tags.pop(0), None)
elif tags:
exif.pop(tags.pop(0), None)
else:
print("[Brekel Save Image] metadata too large for JPG EXIF, saved without it")
return None
# ComfyUI mappings (These need to be at the end of the file)
NODE_CLASS_MAPPINGS = {
"BrekelSaveImage": BrekelSaveImage
}
NODE_DISPLAY_NAME_MAPPINGS = {
"BrekelSaveImage": "Brekel Save Image (PNG/JPG)"
}
+2 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "brekel"
description = "Nodes to enhance & streamline prompts. Enhance using local LLM within ComfyUI, generate using random lines from text files, or randomly select text file prompts. Resolution selector with common presets, Load Image node with filename output."
version = "1.2.0"
description = "Nodes to enhance & streamline prompts. Enhance using local LLM within ComfyUI, generate using random lines from text files, or randomly select text file prompts. Resolution selector with common presets, Load Image node with filename output, Save Image node with PNG/JPG, gap-filling numbering and caption metadata."
version = "1.3.0"
license = {file = "LICENSE"}
dependencies = ["bitsandbytes", "accelerate"]