From a43ce0c3a7897b7d6bf374839820e336f5547cf7 Mon Sep 17 00:00:00 2001 From: Chris Dumas Date: Fri, 31 Jul 2026 10:46:48 +0000 Subject: [PATCH] Add Save Image Dumas node --- README.md | 8 + dumas_image_nodes.py | 265 ++++++++++++++++++++++++++++++++ tests/test_dumas_image_nodes.py | 71 ++++++++- 3 files changed, 341 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9d7c8cd..7ba69ed 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,12 @@ - Saves preview images for the built-in compare UI and passes through the second image when present, otherwise the first. - Bundles a `js/` frontend extension so the compare viewer renders inside both classic ComfyUI and Nodes 2.0. +- `Save Image Dumas` + - Inputs: `images`, `folder`, `pattern`, `format`, `quality`, `embed_workflow`, `save_on_run`, optional `name`, optional `name_2` + - Output: none + - Saves images to any folder, not just ComfyUI's output directory. + - Supports filename tokens including `%input%`, `%input2%`, `%date:yyyy-MM-dd%`, `%counter%`, `%width%`, `%height%`, and `%batch_num%`. + - `Dumas JSON String to Object` - Input: `json_string` - Output: parsed `JSON` @@ -152,6 +158,8 @@ decr -> use index - 1 `Dumas Image Compare` accepts one or two images. The `new image` socket forwards `image2` when connected so you can keep the "after" image moving through the workflow, and falls back to `image1` if only one input is present. +`Save Image Dumas` leaves `folder` empty to use ComfyUI's output directory. `name` feeds `%input%` and `name_2` feeds `%input2%`, so a pattern like `project/%input%_%input2%_%counter%` can combine two upstream strings into the saved filename. + ## Roadmap This repo is intended to grow into a broader set of Dumas-branded generic utility nodes, including JSON helpers and adjacent data-manipulation tools. diff --git a/dumas_image_nodes.py b/dumas_image_nodes.py index 6b57646..cf94380 100644 --- a/dumas_image_nodes.py +++ b/dumas_image_nodes.py @@ -1,5 +1,8 @@ +import json import os import random +import re +import time import numpy as np from PIL import Image @@ -7,6 +10,124 @@ from PIL import Image import folder_paths +_MEDIA_EXT_RE = re.compile( + r"\.(png|jpe?g|webp|gif|bmp|tiff?|avif|mp4|mov|webm|mkv|m4v)$", re.IGNORECASE +) +_DATE_TOKEN_RE = re.compile(r"%date:([^%]+)%") + + +def _clean_input_token_value(value): + cleaned = "" + if value is not None: + cleaned = value if isinstance(value, str) else str(value) + cleaned = _MEDIA_EXT_RE.sub("", cleaned.strip()) + cleaned = cleaned.replace("\\", "_").replace("/", "_") + return cleaned + + +def _expand_date_tokens(value): + if not isinstance(value, str) or "%date:" not in value: + return value + + now = time.localtime() + + def pad(number, width): + return str(number).zfill(width) + + def repl(match): + fmt = match.group(1) + + def swap(token_match): + token = token_match.group(0) + if token == "yyyy": + return pad(now.tm_year, 4) + if token == "yy": + return str(now.tm_year)[-2:] + if token == "MM": + return pad(now.tm_mon, 2) + if token == "M": + return str(now.tm_mon) + if token == "dd": + return pad(now.tm_mday, 2) + if token == "d": + return str(now.tm_mday) + if token in ("hh", "HH"): + return pad(now.tm_hour, 2) + if token in ("h", "H"): + return str(now.tm_hour) + if token == "mm": + return pad(now.tm_min, 2) + if token == "m": + return str(now.tm_min) + if token == "ss": + return pad(now.tm_sec, 2) + if token == "s": + return str(now.tm_sec) + return token + + return re.sub(r"yyyy|yy|MM|M|dd|d|hh|h|HH|H|mm|m|ss|s", swap, fmt) + + return _DATE_TOKEN_RE.sub(repl, value) + + +def _expand_native_tokens(value): + if not isinstance(value, str) or "%" not in value: + return value + + now = time.localtime() + replacements = ( + ("%year%", f"{now.tm_year:04}"), + ("%month%", f"{now.tm_mon:02}"), + ("%day%", f"{now.tm_mday:02}"), + ("%hour%", f"{now.tm_hour:02}"), + ("%minute%", f"{now.tm_min:02}"), + ("%second%", f"{now.tm_sec:02}"), + ) + for token, replacement in replacements: + value = value.replace(token, replacement) + return value + + +def _safe_pattern(value): + value = str(value or "").replace("\\", "/") + value = re.sub(r'[<>:"|?*]', "_", value) + value = re.sub(r"/{2,}", "/", value).strip(" /.") or "image_%counter%" + return value + + +def _next_counter(directory, filename_template): + os.makedirs(directory, exist_ok=True) + if "%counter%" not in filename_template: + return 1 + + parts = filename_template.split("%counter%") + highest = 0 + for entry in os.listdir(directory): + if not entry.startswith(parts[0]) or not entry.endswith(parts[-1]): + continue + middle = entry[len(parts[0]):] + if parts[-1]: + middle = middle[: -len(parts[-1])] + if middle.isdigit(): + highest = max(highest, int(middle)) + return highest + 1 + + +def _build_pnginfo(prompt=None, extra_pnginfo=None): + try: + pnginfo = Image.PngImagePlugin.PngInfo() + except AttributeError: + from PIL.PngImagePlugin import PngInfo + + pnginfo = PngInfo() + if prompt is not None: + pnginfo.add_text("prompt", json.dumps(prompt)) + if isinstance(extra_pnginfo, dict): + for key, value in extra_pnginfo.items(): + pnginfo.add_text(str(key), json.dumps(value)) + return pnginfo + + def _tensor_image_to_pil_image(tensor): image_tensor = tensor[0] if hasattr(image_tensor, "mul") and hasattr(image_tensor, "clamp"): @@ -106,10 +227,154 @@ class DumasImageCompareNode: return {"ui": {"images": results}, "result": (new_image,)} +class DumasSaveImageNode: + DESCRIPTION = ( + "Dumas Save Image writes images to any folder, with filename tokens " + "such as %input%, %input2%, %date:yyyy-MM-dd%, %counter%, %width%, " + "%height%, and %batch_num%." + ) + RETURN_TYPES = () + FUNCTION = "save_images" + OUTPUT_NODE = True + CATEGORY = "Dumas/Image" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE", {"tooltip": "Image batch to save."}), + "folder": ( + "STRING", + { + "default": "", + "multiline": False, + "tooltip": ( + "Destination folder. Leave empty to save in ComfyUI's " + "output directory." + ), + }, + ), + "pattern": ( + "STRING", + { + "default": "image_%date:yyyy-MM-dd%_%counter%", + "multiline": False, + "tooltip": ( + "Filename pattern with optional subfolders. Tokens: " + "%input%, %input2%, %date:yyyy-MM-dd%, %counter%, " + "%width%, %height%, %batch_num%." + ), + }, + ), + "format": (["png", "jpg"], {"default": "png"}), + "quality": ( + "INT", + {"default": 100, "min": 1, "max": 100, "step": 1}, + ), + "embed_workflow": ("BOOLEAN", {"default": True}), + "save_on_run": ("BOOLEAN", {"default": True}), + }, + "optional": { + "name": ( + "STRING", + { + "forceInput": True, + "tooltip": "Optional text inserted by the %input% token.", + }, + ), + "name_2": ( + "STRING", + { + "forceInput": True, + "tooltip": "Optional text inserted by the %input2% token.", + }, + ), + }, + "hidden": { + "prompt": "PROMPT", + "extra_pnginfo": "EXTRA_PNGINFO", + }, + } + + @classmethod + def IS_CHANGED(cls, **_kwargs): + return float("nan") + + def save_images( + self, + images, + folder, + pattern, + format, + quality, + embed_workflow, + save_on_run, + name=None, + name_2=None, + prompt=None, + extra_pnginfo=None, + ): + if not save_on_run: + return {"ui": {"images": []}} + + width = int(images.shape[2]) + height = int(images.shape[1]) + output_dir = folder_paths.get_output_directory() + target_dir = os.path.abspath(folder.strip()) if str(folder or "").strip() else output_dir + os.makedirs(target_dir, exist_ok=True) + + resolved_pattern = str(pattern or "image_%date:yyyy-MM-dd%_%counter%") + resolved_pattern = resolved_pattern.replace("%input%", _clean_input_token_value(name)) + resolved_pattern = resolved_pattern.replace("%input2%", _clean_input_token_value(name_2)) + resolved_pattern = _expand_date_tokens(resolved_pattern) + resolved_pattern = _expand_native_tokens(resolved_pattern) + resolved_pattern = resolved_pattern.replace("%width%", str(width)) + resolved_pattern = resolved_pattern.replace("%height%", str(height)) + resolved_pattern = _safe_pattern(resolved_pattern) + + extension = ".jpg" if format == "jpg" else ".png" + quality = max(1, min(100, int(quality))) + ui_images = [] + + for batch_index in range(images.shape[0]): + frame_pattern = resolved_pattern.replace("%batch_num%", str(batch_index)) + frame_parts = [part for part in frame_pattern.split("/") if part] + sub_dirs = frame_parts[:-1] + filename_template = (frame_parts[-1] if frame_parts else "image_%counter%") + extension + frame_dir = os.path.join(target_dir, *sub_dirs) + counter = _next_counter(frame_dir, filename_template) + filename = filename_template.replace("%counter%", str(counter).zfill(3)) + image = _tensor_image_to_pil_image(images[batch_index : batch_index + 1]) + full_path = os.path.join(frame_dir, filename) + + if format == "jpg": + image = image.convert("RGB") + image.save(full_path, "JPEG", quality=quality) + else: + pnginfo = None + if embed_workflow: + pnginfo = _build_pnginfo(prompt=prompt, extra_pnginfo=extra_pnginfo) + image.save(full_path, "PNG", pnginfo=pnginfo) + + if os.path.commonpath([output_dir, full_path]) == output_dir: + subfolder = os.path.relpath(frame_dir, output_dir) + ui_images.append( + { + "filename": filename, + "subfolder": "" if subfolder == "." else subfolder.replace("\\", "/"), + "type": "output", + } + ) + + return {"ui": {"images": ui_images}} + + NODE_CLASS_MAPPINGS = { "DumasImageCompare": DumasImageCompareNode, + "DumasSaveImage": DumasSaveImageNode, } NODE_DISPLAY_NAME_MAPPINGS = { "DumasImageCompare": "Dumas Image Compare", + "DumasSaveImage": "Save Image Dumas", } diff --git a/tests/test_dumas_image_nodes.py b/tests/test_dumas_image_nodes.py index d7b1855..070e5ea 100644 --- a/tests/test_dumas_image_nodes.py +++ b/tests/test_dumas_image_nodes.py @@ -24,10 +24,18 @@ class FakeImageArray: class FakeTensorBatch: - def __init__(self, width=8, height=6): + def __init__(self, width=8, height=6, count=1): self.image = FakeImageArray(width=width, height=height) + self.shape = (count, height, width, 3) + self.count = count def __getitem__(self, index): + if isinstance(index, slice): + return FakeTensorBatch( + width=self.shape[2], + height=self.shape[1], + count=len(range(*index.indices(self.count))), + ) if index != 0: raise IndexError(index) return self.image @@ -36,8 +44,11 @@ class FakeTensorBatch: class FakePILImage: saved_paths = [] - def save(self, path, compress_level=0): - self.saved_paths.append((path, compress_level)) + def save(self, path, *args, **kwargs): + self.saved_paths.append((path, args, kwargs)) + + def convert(self, _mode): + return self class DumasImageNodeTests(unittest.TestCase): @@ -52,6 +63,7 @@ class DumasImageNodeTests(unittest.TestCase): fake_pil_module = types.SimpleNamespace(Image=fake_pil_image_module) fake_folder_paths = types.SimpleNamespace( get_temp_directory=lambda: cls.temp_dir, + get_output_directory=lambda: cls.temp_dir, get_save_image_path=lambda prefix, _out, _width, _height: ( cls.temp_dir, prefix, @@ -119,6 +131,59 @@ class DumasImageNodeTests(unittest.TestCase): self.assertTrue(result["ui"]["images"][0]["filename"].startswith("dumas_compare")) self.assertTrue(os.path.basename(FakePILImage.saved_paths[0][0]).startswith("dumas_compare")) + def test_save_image_uses_second_input_token(self): + node = self.image_nodes.DumasSaveImageNode() + image = FakeTensorBatch(width=10, height=12) + + node.save_images( + images=image, + folder=self.temp_dir, + pattern="shot_%input%_%input2%_%counter%", + format="png", + quality=100, + embed_workflow=False, + save_on_run=True, + name="alpha.png", + name_2="beta/final", + ) + + saved_name = os.path.basename(FakePILImage.saved_paths[0][0]) + self.assertEqual(saved_name, "shot_alpha_beta_final_001.png") + + def test_save_image_returns_ui_entries_for_output_folder(self): + node = self.image_nodes.DumasSaveImageNode() + image = FakeTensorBatch() + + result = node.save_images( + images=image, + folder="", + pattern="result_%counter%", + format="jpg", + quality=90, + embed_workflow=False, + save_on_run=True, + ) + + self.assertEqual(result["ui"]["images"][0]["type"], "output") + self.assertTrue(result["ui"]["images"][0]["filename"].endswith(".jpg")) + + def test_save_image_skips_when_save_is_disabled(self): + node = self.image_nodes.DumasSaveImageNode() + image = FakeTensorBatch() + + result = node.save_images( + images=image, + folder=self.temp_dir, + pattern="ignored_%counter%", + format="png", + quality=100, + embed_workflow=False, + save_on_run=False, + ) + + self.assertEqual(result["ui"]["images"], []) + self.assertEqual(FakePILImage.saved_paths, []) + if __name__ == "__main__": unittest.main()