Files
SmartSaveImage/__init__.py
T
2025-11-15 07:42:57 +08:00

443 lines
21 KiB
Python

"""Top-level package for SmartSaveImage."""
__all__ = [
"NODE_CLASS_MAPPINGS",
"NODE_DISPLAY_NAME_MAPPINGS",
]
__author__ = """kj"""
__email__ = "2990346238@qq.com"
__version__ = "0.0.1"
from .src.SmartSaveImage.nodes import NODE_CLASS_MAPPINGS
from .src.SmartSaveImage.nodes import NODE_DISPLAY_NAME_MAPPINGS
import os
import re
import json
import hashlib
import numpy as np
from PIL import Image, PngImagePlugin
import piexif
import folder_paths
import nodes
class SmartSaveImage:
CATEGORY = "IO/Output"
OUTPUT_NODE = True
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("images",)
FUNCTION = "process"
token_pattern = re.compile(r"(%[^%]+%)")
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"folder_plan": ("STRING", {"default": "", "multiline": True}),
"file_format": (["png", "jpeg", "webp"],),
"preview_only": ("BOOLEAN", {"default": False}),
},
"optional": {
"quality": ("INT", {"default": 100, "min": 1, "max": 100}),
"lossless_webp": ("BOOLEAN", {"default": False}),
"embed_workflow": ("BOOLEAN", {"default": False}),
"add_counter": ("BOOLEAN", {"default": True}),
"root_dir": ("STRING", {"default": "output", "multiline": False}),
},
"hidden": {
"id": "UNIQUE_ID",
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
def sanitize(self, s):
return re.sub(r"[:*?\"<>|]", "_", s)
def sanitize_segment(self, s):
s = str(s)
s = re.sub(r"[:*?\"<>|]", "_", s)
s = re.sub(r"\s+", "_", s)
s = re.sub(r"[^A-Za-z0-9_\-]", "_", s)
return s.strip("_")
def build_metadata(self, prompt, extra_pnginfo, steps, sampler_name, scheduler, cfg, seed, width, height, modelname):
parts = []
ptxt = (json.dumps(prompt) if isinstance(prompt, dict) else (prompt or ""))
parts.append(str(ptxt).replace("\n", " ").strip())
neg = ""
if isinstance(extra_pnginfo, dict):
neg = extra_pnginfo.get("neg_prompt", "")
if neg:
parts.append(f"Negative prompt: {str(neg).replace('\n',' ').strip()}")
params = []
if steps is not None:
params.append(f"Steps: {steps}")
if sampler_name:
if scheduler and scheduler != "normal":
params.append(f"Sampler: {sampler_name} {scheduler}")
else:
params.append(f"Sampler: {sampler_name}")
if cfg is not None:
params.append(f"CFG Scale: {cfg}")
if seed is not None:
params.append(f"Seed: {seed}")
params.append(f"Size: {width}x{height}")
modelhash = None
modellabel = None
if modelname:
try:
ckpt_path = folder_paths.get_full_path("checkpoints", modelname)
h = hashlib.sha256()
with open(ckpt_path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
modelhash = h.hexdigest()[:10]
except Exception:
modelhash = None
modellabel = os.path.splitext(os.path.basename(modelname))[0]
if modellabel:
if modelhash:
params.append(f"Model hash: {modelhash}, Model: {modellabel}")
else:
params.append(f"Model: {modellabel}")
parts.append(", ".join(params))
return "\n".join(parts)
def extract_from_workflow(self, extra_pnginfo):
out = {"seed": None, "steps": None, "cfg": None, "sampler_name": None, "scheduler": None, "model": None}
wf = None
if isinstance(extra_pnginfo, dict):
wf = extra_pnginfo.get("workflow")
if isinstance(wf, dict):
nodes_list = wf.get("nodes") or []
for n in nodes_list:
if not isinstance(n, dict):
continue
ct = n.get("class_type") or n.get("type")
inputs = n.get("inputs") or {}
if ct in ("CheckpointLoaderSimple", "CheckpointLoader", "CheckpointLoaderV2"):
m = inputs.get("ckpt_name") or inputs.get("model") or inputs.get("ckpt")
if m and not out["model"]:
out["model"] = m
if ct in ("KSampler", "KSamplerAdvanced"):
if inputs.get("seed") is not None:
out["seed"] = inputs.get("seed")
if inputs.get("steps") is not None:
out["steps"] = inputs.get("steps")
if inputs.get("cfg") is not None:
out["cfg"] = inputs.get("cfg")
if inputs.get("sampler_name") is not None:
out["sampler_name"] = inputs.get("sampler_name")
if inputs.get("scheduler") is not None:
out["scheduler"] = inputs.get("scheduler")
return out
def extract_from_prompt(self, prompt):
out = {"seed": None, "steps": None, "cfg": None, "sampler_name": None, "scheduler": None, "model": None}
if isinstance(prompt, dict):
nodes_list = prompt.get("nodes") or []
for n in nodes_list:
if not isinstance(n, dict):
continue
ct = n.get("class_type") or n.get("type")
inputs = n.get("inputs") or {}
if ct in ("CheckpointLoaderSimple", "CheckpointLoader", "CheckpointLoaderV2"):
m = inputs.get("ckpt_name") or inputs.get("model") or inputs.get("ckpt")
if m and not out["model"]:
out["model"] = m
if ct in ("KSampler", "KSamplerAdvanced"):
if inputs.get("seed") is not None:
out["seed"] = inputs.get("seed")
if inputs.get("steps") is not None:
out["steps"] = inputs.get("steps")
if inputs.get("cfg") is not None:
out["cfg"] = inputs.get("cfg")
if inputs.get("sampler_name") is not None:
out["sampler_name"] = inputs.get("sampler_name")
if inputs.get("scheduler") is not None:
out["scheduler"] = inputs.get("scheduler")
return out
def format_template(self, template, metadata_dict):
result = template
matches = re.findall(self.token_pattern, template)
for seg in matches:
inner = seg.strip("%")
parts = inner.split(":")
key = parts[0]
if key == "seed":
val = metadata_dict.get("seed")
if isinstance(val, (int, float)):
if isinstance(val, int) and val < 0:
rep = "rand"
else:
rep = str(val)
else:
rep = "seed"
result = result.replace(seg, self.sanitize_segment(rep))
elif key == "width":
result = result.replace(seg, self.sanitize_segment(metadata_dict.get("width", "")))
elif key == "height":
result = result.replace(seg, self.sanitize_segment(metadata_dict.get("height", "")))
elif key == "pprompt":
raw = metadata_dict.get("prompt", "untitled")
txt = str(raw).replace("\n", " ").strip()
if len(parts) >= 2:
try:
n = int(parts[1])
txt = txt[:n]
except Exception:
pass
result = result.replace(seg, self.sanitize_segment(txt))
elif key == "nprompt":
raw = metadata_dict.get("negative_prompt", "")
txt = str(raw).replace("\n", " ").strip()
if len(parts) >= 2:
try:
n = int(parts[1])
txt = txt[:n]
except Exception:
pass
result = result.replace(seg, self.sanitize_segment(txt))
elif key == "model":
m = str(metadata_dict.get("model", "model"))
m = os.path.splitext(os.path.basename(m))[0]
if len(parts) >= 2:
try:
n = int(parts[1])
m = m[:n]
except Exception:
pass
result = result.replace(seg, self.sanitize_segment(m))
elif key == "date":
from datetime import datetime
now = datetime.now()
table = {"yyyy": f"{now.year:04d}", "yy": f"{now.year % 100:02d}", "MM": f"{now.month:02d}", "dd": f"{now.day:02d}", "hh": f"{now.hour:02d}", "mm": f"{now.minute:02d}", "ss": f"{now.second:02d}"}
fmt = "yyyyMMddhhmmss"
if len(parts) >= 2:
fmt = parts[1]
for k, v in table.items():
fmt = fmt.replace(k, v)
result = result.replace(seg, fmt)
parts = [self.sanitize_segment(p) for p in result.split("/") if p and p.strip()]
if not parts:
return "ComfyUI"
return "/".join(parts)
def save_batch(self, images, full_output_folder, base_filename, file_format, quality, lossless_webp, embed_workflow, png_parameters_text, extra_pnginfo, add_counter, counter_start):
results = []
if not os.path.exists(full_output_folder):
os.makedirs(full_output_folder, exist_ok=True)
for i, image in enumerate(images):
arr = 255.0 * image.cpu().numpy()
pil = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8))
fname = base_filename
if add_counter:
fname += f"_{counter_start + i:05}_"
if file_format == "png":
file = fname + ".png"
pnginfo = PngImagePlugin.PngInfo()
if png_parameters_text:
pnginfo.add_text("parameters", png_parameters_text)
if embed_workflow and extra_pnginfo is not None and isinstance(extra_pnginfo, dict) and "workflow" in extra_pnginfo:
pnginfo.add_text("workflow", json.dumps(extra_pnginfo["workflow"]))
pil.save(os.path.join(full_output_folder, file), format="PNG", compress_level=4, pnginfo=pnginfo)
elif file_format == "jpeg":
file = fname + ".jpg"
save_kwargs = {"quality": quality, "optimize": True}
if png_parameters_text:
try:
exif_dict = {"Exif": {piexif.ExifIFD.UserComment: b"UNICODE\0" + png_parameters_text.encode("utf-16be")}}
exif_bytes = piexif.dump(exif_dict)
save_kwargs["exif"] = exif_bytes
except Exception:
pass
pil.save(os.path.join(full_output_folder, file), format="JPEG", **save_kwargs)
else:
file = fname + ".webp"
save_kwargs = {"quality": quality, "lossless": lossless_webp, "method": 0}
try:
exif_dict = {}
if png_parameters_text:
exif_dict["Exif"] = {piexif.ExifIFD.UserComment: b"UNICODE\0" + png_parameters_text.encode("utf-16be")}
if embed_workflow and extra_pnginfo is not None and isinstance(extra_pnginfo, dict) and "workflow" in extra_pnginfo:
exif_dict["0th"] = {piexif.ImageIFD.ImageDescription: "Workflow:" + json.dumps(extra_pnginfo["workflow"])}
exif_bytes = piexif.dump(exif_dict)
save_kwargs["exif"] = exif_bytes
except Exception:
pass
pil.save(os.path.join(full_output_folder, file), format="WEBP", **save_kwargs)
results.append({"filename": file, "subfolder": os.path.basename(os.path.normpath(full_output_folder)), "type": "output"})
return results
def process(self, images, folder_plan, file_format, preview_only, quality=100, lossless_webp=False, embed_workflow=False, add_counter=True, root_dir="", id=None, prompt=None, extra_pnginfo=None):
rd = (root_dir or "").strip()
base = folder_paths.get_output_directory()
if rd.lower() in ("", "output", ".", "./", "/"):
output_dir = base
elif os.path.isabs(rd):
output_dir = rd
else:
output_dir = os.path.join(base, rd)
if not isinstance(images, (list, tuple, np.ndarray)):
if len(images.shape) == 3:
images = [images]
else:
images = [img for img in images]
h = images[0].shape[0]
w = images[0].shape[1]
plan = {}
try:
plan = json.loads(folder_plan) if isinstance(folder_plan, str) and folder_plan.strip() else {}
except Exception:
plan = {}
segments = plan.get("segments") if isinstance(plan, dict) else None
meta = plan.get("metadata") if isinstance(plan, dict) else None
if not isinstance(meta, dict):
ex = self.extract_from_prompt(prompt)
if not any(v is not None for v in ex.values()):
ex = self.extract_from_workflow(extra_pnginfo)
pos_text = None
neg_text = None
wf = extra_pnginfo.get("workflow") if isinstance(extra_pnginfo, dict) else None
if isinstance(wf, dict):
for n in wf.get("nodes", []) or []:
if not isinstance(n, dict):
continue
ct = n.get("class_type") or n.get("type")
inputs = n.get("inputs") or {}
if ct in ("CLIPTextEncode", "CLIPTextEncodeSDXL", "T5TextEncode") and isinstance(inputs.get("text"), str):
if pos_text is None:
pos_text = inputs.get("text")
elif neg_text is None:
neg_text = inputs.get("text")
if pos_text is None and isinstance(prompt, dict):
for n in prompt.get("nodes", []) or []:
if not isinstance(n, dict):
continue
ct = n.get("class_type") or n.get("type")
inputs = n.get("inputs") or {}
if ct in ("CLIPTextEncode", "CLIPTextEncodeSDXL", "T5TextEncode") and isinstance(inputs.get("text"), str):
if pos_text is None:
pos_text = inputs.get("text")
elif neg_text is None:
neg_text = inputs.get("text")
meta = {"seed": ex.get("seed"), "steps": ex.get("steps"), "cfg": ex.get("cfg"), "sampler_name": ex.get("sampler_name"), "scheduler": ex.get("scheduler"), "model": ex.get("model"), "width": w, "height": h, "prompt": (pos_text if isinstance(pos_text, str) and pos_text.strip() else (prompt if isinstance(prompt, str) else "untitled")), "negative_prompt": (neg_text if isinstance(neg_text, str) else (extra_pnginfo.get("neg_prompt", "") if isinstance(extra_pnginfo, dict) else ""))}
if not isinstance(segments, list) or not segments:
template = "ComfyUI/%date:yyyy-MM-dd%/%model%/%seed%/%pprompt:64%"
processed_prefix = self.format_template(template, meta)
else:
cleaned = [self.sanitize_segment(s) for s in segments if isinstance(s, str) and s.strip()]
if not cleaned:
processed_prefix = "ComfyUI"
else:
processed_prefix = "/".join(cleaned)
if preview_only:
res = nodes.PreviewImage().save_images(images, filename_prefix=processed_prefix, prompt=prompt, extra_pnginfo=extra_pnginfo)
return {"ui": res.get("ui", {}), "result": (images,)}
full_output_folder, base_filename, counter, subfolder, processed_prefix2 = folder_paths.get_save_image_path(processed_prefix, output_dir, w, h)
metadata_text = self.build_metadata(meta.get("prompt"), extra_pnginfo, meta.get("steps"), meta.get("sampler_name"), meta.get("scheduler"), meta.get("cfg"), meta.get("seed"), w, h, meta.get("model"))
results = self.save_batch(images, full_output_folder, base_filename, file_format, quality, lossless_webp, embed_workflow, metadata_text, extra_pnginfo, add_counter, counter)
return {"ui": {"images": results}, "result": (images,)}
class SmartMetaCollector:
CATEGORY = "IO/Output"
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("folder_plan",)
FUNCTION = "collect"
@classmethod
def INPUT_TYPES(cls):
import comfy
return {
"required": {
"mode": (["workflow", "custom"],),
"enable_date": ("BOOLEAN", {"default": True}),
"date_format": ("STRING", {"default": "yyyy-MM-dd", "multiline": False}),
"enable_model": ("BOOLEAN", {"default": True}),
"enable_seed": ("BOOLEAN", {"default": True}),
"enable_prompt": ("BOOLEAN", {"default": True}),
"prompt_len": ("INT", {"default": 64, "min": 1, "max": 512}),
},
"optional": {
"modelname": (folder_paths.get_filename_list("checkpoints"),),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
"positive": ("STRING", {"default": "", "multiline": True}),
"negative": ("STRING", {"default": "", "multiline": True}),
"sampler_name": (comfy.samplers.KSampler.SAMPLERS,),
"scheduler": (comfy.samplers.KSampler.SCHEDULERS,),
"width": ("INT", {"default": 0, "min": 0, "max": 16384}),
"height": ("INT", {"default": 0, "min": 0, "max": 16384}),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
def collect(self, mode, enable_date, date_format, enable_model, enable_seed, enable_prompt, prompt_len, modelname=None, seed=0, positive="", negative="", sampler_name=None, scheduler=None, width=0, height=0, prompt=None, extra_pnginfo=None):
segs = ["ComfyUI"]
meta = {"seed": None, "steps": None, "cfg": None, "sampler_name": None, "scheduler": None, "model": None, "width": width if width else None, "height": height if height else None, "prompt": None, "negative_prompt": None}
if mode == "workflow":
wf = extra_pnginfo.get("workflow") if isinstance(extra_pnginfo, dict) else None
ex = SmartSaveImage().extract_from_workflow(extra_pnginfo)
meta.update(ex)
pos_text = None
neg_text = None
if isinstance(wf, dict):
for n in wf.get("nodes", []) or []:
if not isinstance(n, dict):
continue
ct = n.get("class_type") or n.get("type")
inputs = n.get("inputs") or {}
if ct in ("CLIPTextEncode", "CLIPTextEncodeSDXL", "T5TextEncode") and isinstance(inputs.get("text"), str):
if pos_text is None:
pos_text = inputs.get("text")
elif neg_text is None:
neg_text = inputs.get("text")
meta["prompt"] = pos_text if isinstance(pos_text, str) and pos_text.strip() else "untitled"
meta["negative_prompt"] = neg_text if isinstance(neg_text, str) else ""
else:
meta["model"] = modelname or meta["model"]
meta["seed"] = seed
meta["sampler_name"] = sampler_name
meta["scheduler"] = scheduler
meta["prompt"] = positive if isinstance(positive, str) and positive.strip() else "untitled"
meta["negative_prompt"] = negative if isinstance(negative, str) else ""
from datetime import datetime
if enable_date:
now = datetime.now()
table = {"yyyy": f"{now.year:04d}", "yy": f"{now.year % 100:02d}", "MM": f"{now.month:02d}", "dd": f"{now.day:02d}", "hh": f"{now.hour:02d}", "mm": f"{now.minute:02d}", "ss": f"{now.second:02d}"}
fmt = date_format or "yyyy-MM-dd"
for k, v in table.items():
fmt = fmt.replace(k, v)
segs.append(SmartSaveImage().sanitize_segment(fmt))
if enable_model:
m = os.path.splitext(os.path.basename(meta.get("model") or "model"))[0]
segs.append(SmartSaveImage().sanitize_segment(m))
if enable_seed:
s = meta.get("seed")
segs.append(SmartSaveImage().sanitize_segment((str(s) if s is not None else "seed")))
if enable_prompt:
p = str(meta.get("prompt") or "untitled").replace("\n", " ")
p = p[:prompt_len] if isinstance(prompt_len, int) and prompt_len > 0 else p
segs.append(SmartSaveImage().sanitize_segment(p))
plan = {"segments": segs, "metadata": meta}
return (json.dumps(plan),)
NODE_CLASS_MAPPINGS = {
"Smart Save Image": SmartSaveImage,
"Smart Meta Collector": SmartMetaCollector,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"Smart Save Image": "Smart Save Image",
"Smart Meta Collector": "Smart Meta Collector",
}