429 lines
14 KiB
Python
429 lines
14 KiB
Python
import json
|
|
import os
|
|
import random
|
|
import re
|
|
import time
|
|
import uuid
|
|
from collections import OrderedDict
|
|
|
|
import numpy as np
|
|
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:([^%]+)%")
|
|
_SERVE_TOKENS = OrderedDict()
|
|
_SERVE_CAP = 256
|
|
|
|
|
|
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 _register_serve_token(path):
|
|
token = uuid.uuid4().hex
|
|
_SERVE_TOKENS[token] = path
|
|
while len(_SERVE_TOKENS) > _SERVE_CAP:
|
|
_SERVE_TOKENS.popitem(last=False)
|
|
return token
|
|
|
|
|
|
def resolve_serve_token(token):
|
|
return _SERVE_TOKENS.get(str(token or ""))
|
|
|
|
|
|
def _is_within_directory(parent_path, child_path):
|
|
try:
|
|
return os.path.commonpath([parent_path, child_path]) == parent_path
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
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 _next_counter_for_relative_path(base_directory, relative_template):
|
|
os.makedirs(base_directory, exist_ok=True)
|
|
if "%counter%" not in relative_template:
|
|
return 1
|
|
|
|
counter = 1
|
|
while True:
|
|
candidate = relative_template.replace("%counter%", str(counter).zfill(3))
|
|
full_path = os.path.join(base_directory, *[part for part in candidate.split("/") if part])
|
|
if not os.path.exists(full_path):
|
|
return counter
|
|
counter += 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"):
|
|
image_array = image_tensor.mul(255).clamp(0, 255)
|
|
if hasattr(image_array, "byte"):
|
|
image_array = image_array.byte()
|
|
image_array = image_array.cpu().numpy()
|
|
return Image.fromarray(image_array)
|
|
|
|
image_array = 255.0 * image_tensor.cpu().numpy()
|
|
return Image.fromarray(np.clip(image_array, 0, 255).astype(np.uint8))
|
|
|
|
|
|
class DumasImageCompareNode:
|
|
DESCRIPTION = (
|
|
"Dumas Image Compare shows the difference between two images directly on "
|
|
"the node. Connect one or two IMAGE inputs to compare before/after "
|
|
"results, model variants, or processing stages without breaking a "
|
|
"workflow when one branch is bypassed."
|
|
)
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("new image",)
|
|
FUNCTION = "compare_images"
|
|
OUTPUT_NODE = True
|
|
CATEGORY = "Dumas/Image"
|
|
|
|
def __init__(self):
|
|
self.output_dir = folder_paths.get_temp_directory()
|
|
self.type = "temp"
|
|
self.prefix_append = "_dumascmp_" + "".join(
|
|
random.choice("abcdefghijklmnopqrstuvwxyz") for _ in range(5)
|
|
)
|
|
self.compress_level = 4
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"optional": {
|
|
"image1": (
|
|
"IMAGE",
|
|
{
|
|
"tooltip": (
|
|
"First image to compare. Optional so muted or bypassed "
|
|
"branches do not trigger a missing-input error."
|
|
)
|
|
},
|
|
),
|
|
"image2": (
|
|
"IMAGE",
|
|
{
|
|
"tooltip": (
|
|
"Second image to compare. Optional so the node can still "
|
|
"display a single available image."
|
|
)
|
|
},
|
|
),
|
|
}
|
|
}
|
|
|
|
def compare_images(self, image1=None, image2=None):
|
|
present = []
|
|
if image1 is not None:
|
|
present.append((1, image1))
|
|
if image2 is not None:
|
|
present.append((2, image2))
|
|
results = []
|
|
|
|
if present:
|
|
first_tensor = present[0][1]
|
|
prefix = "dumas_compare" + self.prefix_append
|
|
first_image = first_tensor[0]
|
|
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
|
|
prefix,
|
|
self.output_dir,
|
|
first_image.shape[1],
|
|
first_image.shape[0],
|
|
)
|
|
join_path = os.path.join
|
|
for slot, tensor in present:
|
|
image = _tensor_image_to_pil_image(tensor)
|
|
file_name = f"{filename}_{counter:05}_.png"
|
|
image.save(
|
|
join_path(full_output_folder, file_name),
|
|
compress_level=self.compress_level,
|
|
)
|
|
results.append(
|
|
{
|
|
"filename": file_name,
|
|
"subfolder": subfolder,
|
|
"type": self.type,
|
|
"slot": slot,
|
|
}
|
|
)
|
|
counter += 1
|
|
|
|
new_image = image2 if image2 is not None else image1
|
|
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]
|
|
relative_template = "/".join(frame_parts[:-1] + [((frame_parts[-1] if frame_parts else "image_%counter%") + extension)])
|
|
counter = _next_counter_for_relative_path(target_dir, relative_template)
|
|
resolved_relative = relative_template.replace("%counter%", str(counter).zfill(3))
|
|
resolved_parts = [part for part in resolved_relative.split("/") if part]
|
|
sub_dirs = resolved_parts[:-1]
|
|
filename = resolved_parts[-1] if resolved_parts else f"image_{str(counter).zfill(3)}{extension}"
|
|
frame_dir = os.path.join(target_dir, *sub_dirs)
|
|
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 _is_within_directory(output_dir, full_path):
|
|
subfolder = os.path.relpath(frame_dir, output_dir)
|
|
ui_images.append(
|
|
{
|
|
"filename": filename,
|
|
"subfolder": "" if subfolder == "." else subfolder.replace("\\", "/"),
|
|
"type": "output",
|
|
}
|
|
)
|
|
else:
|
|
ui_images.append(
|
|
{
|
|
"filename": filename,
|
|
"subfolder": frame_dir.replace("\\", "/"),
|
|
"type": "external",
|
|
"token": _register_serve_token(full_path),
|
|
}
|
|
)
|
|
|
|
return {"ui": {"images": ui_images}}
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"DumasImageCompare": DumasImageCompareNode,
|
|
"DumasSaveImage": DumasSaveImageNode,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"DumasImageCompare": "Dumas Image Compare",
|
|
"DumasSaveImage": "Save Image Dumas",
|
|
}
|