From 8efa2b7d6d1e245bbaa2e959695f0b016dc520fb Mon Sep 17 00:00:00 2001 From: Cyber Dick Lang <286878701@qq.com> Date: Mon, 16 Mar 2026 13:56:48 +0800 Subject: [PATCH] =?UTF-8?q?v1.4.11:=20=E6=96=B0=E5=A2=9E=20Save=20Image=20?= =?UTF-8?q?Plus=20(UTK)=20=E8=8A=82=E7=82=B9=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=AB=98=E8=B4=A8=E9=87=8F=E8=87=AA=E5=AE=9A=E4=B9=89=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E5=9B=BE=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SaveImagePlus_UTK 节点,参考 LayerStyle SaveImagePlusV2 实现 - 支持 PNG、JPEG、TIFF、WebP 四种输出格式 - 支持自定义 DPI 元数据嵌入(默认 300 DPI,适用于印刷质量输出) - PNG:pHYs 块嵌入 DPI + PngInfo 工作流 JSON + 文本元数据 - JPEG:JFIF DPI + 可选 EXIF 作者/描述(需 piexif) - TIFF:LZW 压缩 + XResolution/YResolution 标签 + Artist/ImageDescription + 工作流 JSON - WebP:质量控制 + 可选 EXIF DPI(需 piexif,不可用则跳过) - 支持可选 author、description 元数据字段 - 支持子目录命名(filename_prefix 使用 '/' 分隔) - 更新 pyproject.toml 版本至 1.4.11 以触发 Comfy Registry 版本抓取 Co-Authored-By: Claude Sonnet 4.6 --- __init__.py | 23 ++- nodes/image/save_image_plus.py | 304 +++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 3 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 nodes/image/save_image_plus.py diff --git a/__init__.py b/__init__.py index 89b909e..208d12d 100644 --- a/__init__.py +++ b/__init__.py @@ -8,13 +8,25 @@ A comprehensive toolkit for ComfyUI that provides various utility nodes for imag :license: MIT, see LICENSE for more details. """ -__version__ = "1.4.10" +__version__ = "1.4.11" __author__ = "CyberDickLang" __email__ = "286878701@qq.com" __url__ = "https://github.com/whmc76" # 更新日志 CHANGELOG = { + "1.4.11": [ + "新增 Save Image Plus (UTK) 节点:", + "- 支持 PNG、JPEG、TIFF、WebP 四种格式输出", + "- 支持自定义 DPI 元数据嵌入(默认 300 DPI,用于印刷质量输出)", + "- PNG:嵌入 pHYs 分辨率块 + ComfyUI 工作流 + 文本元数据", + "- JPEG:支持 DPI 与可选 EXIF(需 piexif)", + "- TIFF:LZW 压缩、DPI + Artist/ImageDescription 标签 + 工作流 JSON", + "- WebP:支持质量控制与可选 EXIF DPI(需 piexif)", + "- 支持 author、description 可选元数据字段", + "- 支持子目录命名(filename_prefix 使用 '/' 分隔)", + "- 分类:UniversalToolkit/Image", + ], "1.4.10": [ "新增 Image Batch Extend With Overlap (UTK) 节点:", "- 自 comfyui-kjnodes 移植,用于视频/图像序列扩展与重叠混合", @@ -817,6 +829,10 @@ try: NODE_CLASS_MAPPINGS as IMAGE_BATCH_EXTEND_MAPPINGS from .nodes.image.image_batch_extend_with_overlap import \ NODE_DISPLAY_NAME_MAPPINGS as IMAGE_BATCH_EXTEND_DISPLAY + from .nodes.image.save_image_plus import \ + NODE_CLASS_MAPPINGS as SAVE_IMAGE_PLUS_MAPPINGS + from .nodes.image.save_image_plus import \ + NODE_DISPLAY_NAME_MAPPINGS as SAVE_IMAGE_PLUS_DISPLAY except ImportError as e: print(f"[UniversalToolkit] 导入错误: {e}") GET_IMAGE_RANGE_MAPPINGS = {} @@ -833,6 +849,8 @@ except ImportError as e: RESIZE_VER_KJ_DISPLAY = {} IMAGE_BATCH_EXTEND_MAPPINGS = {} IMAGE_BATCH_EXTEND_DISPLAY = {} + SAVE_IMAGE_PLUS_MAPPINGS = {} + SAVE_IMAGE_PLUS_DISPLAY = {} # 合并所有节点映射 @@ -880,6 +898,7 @@ NODE_CLASS_MAPPINGS.update(BEST_CONTEXT_WINDOW_MAPPINGS) NODE_CLASS_MAPPINGS.update(BLOCKIFY_MASK_MAPPINGS) NODE_CLASS_MAPPINGS.update(RESIZE_VER_KJ_MAPPINGS) NODE_CLASS_MAPPINGS.update(IMAGE_BATCH_EXTEND_MAPPINGS) +NODE_CLASS_MAPPINGS.update(SAVE_IMAGE_PLUS_MAPPINGS) # 合并显示名称映射 NODE_DISPLAY_NAME_MAPPINGS = {} @@ -926,6 +945,7 @@ NODE_DISPLAY_NAME_MAPPINGS.update(BEST_CONTEXT_WINDOW_DISPLAY) NODE_DISPLAY_NAME_MAPPINGS.update(BLOCKIFY_MASK_DISPLAY) NODE_DISPLAY_NAME_MAPPINGS.update(RESIZE_VER_KJ_DISPLAY) NODE_DISPLAY_NAME_MAPPINGS.update(IMAGE_BATCH_EXTEND_DISPLAY) +NODE_DISPLAY_NAME_MAPPINGS.update(SAVE_IMAGE_PLUS_DISPLAY) NODE_CATEGORIES = { "UniversalToolkit": [ @@ -973,6 +993,7 @@ NODE_CATEGORIES = { "BlockifyMask_UTK", "ResizeImageVerKJ_UTK", "ImageBatchExtendWithOverlap_UTK", + "SaveImagePlus_UTK", ] } diff --git a/nodes/image/save_image_plus.py b/nodes/image/save_image_plus.py new file mode 100644 index 0000000..c9e3e1b --- /dev/null +++ b/nodes/image/save_image_plus.py @@ -0,0 +1,304 @@ +""" +Save Image Plus Node +~~~~~~~~~~~~~~~~~~~~ + +Save images with custom DPI, format, quality and metadata for print-ready output. +Supports PNG, JPEG, TIFF, WebP formats with configurable DPI (default 300 DPI for printing). + +:copyright: (c) 2024 by May +:license: MIT, see LICENSE for more details. +""" + +import os +import json + +import numpy as np +import folder_paths +from PIL import Image +from PIL.PngImagePlugin import PngInfo + + +class SaveImagePlus_UTK: + """Save images with custom DPI, format and metadata for print-ready output.""" + + CATEGORY = "UniversalToolkit/Image" + OUTPUT_NODE = True + DESCRIPTION = ( + "Save images with custom DPI metadata for professional print output. " + "Supports PNG, JPEG, TIFF, WebP. Default 300 DPI is standard for print quality. " + "Optionally embeds ComfyUI workflow, author and description metadata." + ) + + def __init__(self): + self.output_dir = folder_paths.get_output_directory() + self.type = "output" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + "filename_prefix": ( + "STRING", + { + "default": "ComfyUI/PrintReady", + "tooltip": "Filename prefix. Use '/' for subfolders, e.g. 'prints/my_image'.", + }, + ), + "file_format": ( + ["PNG", "JPEG", "TIFF", "WEBP"], + { + "default": "PNG", + "tooltip": "Output file format. TIFF is best for professional print workflows.", + }, + ), + "dpi": ( + "INT", + { + "default": 300, + "min": 72, + "max": 1200, + "step": 1, + "tooltip": "DPI (dots per inch) embedded in file metadata. 300 = standard print, 72 = screen.", + }, + ), + "quality": ( + "INT", + { + "default": 95, + "min": 1, + "max": 100, + "step": 1, + "tooltip": "Compression quality for JPEG/WEBP (1-100). Has no effect on PNG/TIFF.", + }, + ), + "png_compress_level": ( + "INT", + { + "default": 6, + "min": 0, + "max": 9, + "step": 1, + "tooltip": "PNG ZLIB compression level (0=none/fastest, 9=max/slowest). Only affects PNG.", + }, + ), + "save_workflow": ( + "BOOLEAN", + { + "default": True, + "tooltip": "Embed ComfyUI workflow JSON into the image metadata (PNG/TIFF only).", + }, + ), + }, + "optional": { + "author": ( + "STRING", + { + "default": "", + "multiline": False, + "tooltip": "Author name to embed in image metadata.", + }, + ), + "description": ( + "STRING", + { + "default": "", + "multiline": True, + "tooltip": "Image description to embed in metadata.", + }, + ), + }, + "hidden": { + "prompt": "PROMPT", + "extra_pnginfo": "EXTRA_PNGINFO", + }, + } + + RETURN_TYPES = () + FUNCTION = "save_image_plus" + + def save_image_plus( + self, + images, + filename_prefix, + file_format, + dpi, + quality, + png_compress_level, + save_workflow, + author="", + description="", + prompt=None, + extra_pnginfo=None, + ): + full_output_folder, filename, counter, subfolder, filename_prefix = ( + folder_paths.get_save_image_path( + filename_prefix, + self.output_dir, + images[0].shape[1], + images[0].shape[0], + ) + ) + + ext_map = {"PNG": "png", "JPEG": "jpg", "TIFF": "tif", "WEBP": "webp"} + ext = ext_map[file_format] + dpi_tuple = (dpi, dpi) + results = [] + + for batch_number, image in enumerate(images): + # Tensor → uint8 numpy → PIL + i = 255.0 * image.cpu().numpy() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + + filename_with_batch = filename.replace("%batch_num%", str(batch_number)) + file = f"{filename_with_batch}_{counter:05}_.{ext}" + file_path = os.path.join(full_output_folder, file) + + if file_format == "PNG": + self._save_png( + img, file_path, dpi_tuple, png_compress_level, + save_workflow, author, description, prompt, extra_pnginfo, + ) + + elif file_format == "JPEG": + self._save_jpeg(img, file_path, dpi_tuple, quality, author, description) + + elif file_format == "TIFF": + self._save_tiff( + img, file_path, dpi_tuple, save_workflow, + author, description, prompt, extra_pnginfo, + ) + + elif file_format == "WEBP": + self._save_webp(img, file_path, dpi_tuple, quality, author, description) + + results.append({"filename": file, "subfolder": subfolder, "type": self.type}) + counter += 1 + + print(f"✅ SaveImagePlus (UTK): Saved '{file}' [{file_format}, {dpi} DPI]") + + return {"ui": {"images": results}} + + # ------------------------------------------------------------------ # + # Format-specific save helpers # + # ------------------------------------------------------------------ # + + def _save_png( + self, img, path, dpi_tuple, compress_level, + save_workflow, author, description, prompt, extra_pnginfo + ): + metadata = PngInfo() + if save_workflow: + if prompt is not None: + metadata.add_text("prompt", json.dumps(prompt)) + if extra_pnginfo is not None: + for key, value in extra_pnginfo.items(): + metadata.add_text(key, json.dumps(value)) + if author: + metadata.add_text("Author", author) + if description: + metadata.add_text("Description", description) + + img.save( + path, + format="PNG", + pnginfo=metadata, + compress_level=compress_level, + dpi=dpi_tuple, + ) + + def _save_jpeg(self, img, path, dpi_tuple, quality, author, description): + # JPEG requires RGB + if img.mode != "RGB": + img = img.convert("RGB") + + save_kwargs = {"format": "JPEG", "quality": quality, "dpi": dpi_tuple} + + # Optionally embed author/description via EXIF using piexif + exif_bytes = self._build_jpeg_exif(dpi_tuple[0], author, description) + if exif_bytes is not None: + save_kwargs["exif"] = exif_bytes + + img.save(path, **save_kwargs) + + def _save_tiff( + self, img, path, dpi_tuple, save_workflow, + author, description, prompt, extra_pnginfo + ): + from PIL import TiffImagePlugin + + tiffinfo = TiffImagePlugin.ImageFileDirectory_v2() + + # Standard TIFF tags + # 270 = ImageDescription, 305 = Software, 315 = Artist + desc_parts = [] + if description: + desc_parts.append(description) + if save_workflow and prompt is not None: + desc_parts.append("ComfyUI workflow: " + json.dumps(prompt)) + if save_workflow and extra_pnginfo is not None: + for key, value in extra_pnginfo.items(): + desc_parts.append(f"{key}: {json.dumps(value)}") + + if desc_parts: + tiffinfo[270] = "\n\n".join(desc_parts) + + if author: + tiffinfo[315] = author + + tiffinfo[305] = "ComfyUI SaveImagePlus (UTK)" + + img.save( + path, + format="TIFF", + dpi=dpi_tuple, + compression="tiff_lzw", + tiffinfo=tiffinfo, + ) + + def _save_webp(self, img, path, dpi_tuple, quality, author, description): + if img.mode not in ("RGB", "RGBA"): + img = img.convert("RGB") + + save_kwargs = {"format": "WEBP", "quality": quality} + + # WebP supports EXIF for DPI metadata + exif_bytes = self._build_jpeg_exif(dpi_tuple[0], author, description) + if exif_bytes is not None: + save_kwargs["exif"] = exif_bytes + + img.save(path, **save_kwargs) + + # ------------------------------------------------------------------ # + # EXIF helper (piexif optional) # + # ------------------------------------------------------------------ # + + @staticmethod + def _build_jpeg_exif(dpi: int, author: str, description: str): + """Build EXIF bytes with DPI, author, description using piexif if available.""" + try: + import piexif # optional dependency + + ifd = { + piexif.ImageIFD.XResolution: (dpi, 1), + piexif.ImageIFD.YResolution: (dpi, 1), + piexif.ImageIFD.ResolutionUnit: 2, # 2 = inch (DPI) + } + if author: + ifd[piexif.ImageIFD.Artist] = author.encode("utf-8") + if description: + ifd[piexif.ImageIFD.ImageDescription] = description.encode("utf-8") + + exif_dict = {"0th": ifd} + return piexif.dump(exif_dict) + except ImportError: + return None + + +NODE_CLASS_MAPPINGS = { + "SaveImagePlus_UTK": SaveImagePlus_UTK, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SaveImagePlus_UTK": "Save Image Plus (UTK)", +} diff --git a/pyproject.toml b/pyproject.toml index 214f9d4..5201930 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "universaltoolkit" description = "A comprehensive toolkit based on ComfyUI, providing image, mask, audio, and tools nodes, fully modular and v3 compatible." -version = "1.4.10" +version = "1.4.11" license = {file = "LICENSE"} dependencies = [ "torch>=1.9.0",