v1.4.11: 新增 Save Image Plus (UTK) 节点,支持高质量自定义保存图像

- 新增 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 <noreply@anthropic.com>
This commit is contained in:
Cyber Dick Lang
2026-03-16 13:56:48 +08:00
co-authored by Claude Sonnet 4.6
parent 7a08c1558a
commit 8efa2b7d6d
3 changed files with 327 additions and 2 deletions
+22 -1
View File
@@ -8,13 +8,25 @@ A comprehensive toolkit for ComfyUI that provides various utility nodes for imag
:license: MIT, see LICENSE for more details. :license: MIT, see LICENSE for more details.
""" """
__version__ = "1.4.10" __version__ = "1.4.11"
__author__ = "CyberDickLang" __author__ = "CyberDickLang"
__email__ = "286878701@qq.com" __email__ = "286878701@qq.com"
__url__ = "https://github.com/whmc76" __url__ = "https://github.com/whmc76"
# 更新日志 # 更新日志
CHANGELOG = { 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": [ "1.4.10": [
"新增 Image Batch Extend With Overlap (UTK) 节点:", "新增 Image Batch Extend With Overlap (UTK) 节点:",
"- 自 comfyui-kjnodes 移植,用于视频/图像序列扩展与重叠混合", "- 自 comfyui-kjnodes 移植,用于视频/图像序列扩展与重叠混合",
@@ -817,6 +829,10 @@ try:
NODE_CLASS_MAPPINGS as IMAGE_BATCH_EXTEND_MAPPINGS NODE_CLASS_MAPPINGS as IMAGE_BATCH_EXTEND_MAPPINGS
from .nodes.image.image_batch_extend_with_overlap import \ from .nodes.image.image_batch_extend_with_overlap import \
NODE_DISPLAY_NAME_MAPPINGS as IMAGE_BATCH_EXTEND_DISPLAY 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: except ImportError as e:
print(f"[UniversalToolkit] 导入错误: {e}") print(f"[UniversalToolkit] 导入错误: {e}")
GET_IMAGE_RANGE_MAPPINGS = {} GET_IMAGE_RANGE_MAPPINGS = {}
@@ -833,6 +849,8 @@ except ImportError as e:
RESIZE_VER_KJ_DISPLAY = {} RESIZE_VER_KJ_DISPLAY = {}
IMAGE_BATCH_EXTEND_MAPPINGS = {} IMAGE_BATCH_EXTEND_MAPPINGS = {}
IMAGE_BATCH_EXTEND_DISPLAY = {} 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(BLOCKIFY_MASK_MAPPINGS)
NODE_CLASS_MAPPINGS.update(RESIZE_VER_KJ_MAPPINGS) NODE_CLASS_MAPPINGS.update(RESIZE_VER_KJ_MAPPINGS)
NODE_CLASS_MAPPINGS.update(IMAGE_BATCH_EXTEND_MAPPINGS) NODE_CLASS_MAPPINGS.update(IMAGE_BATCH_EXTEND_MAPPINGS)
NODE_CLASS_MAPPINGS.update(SAVE_IMAGE_PLUS_MAPPINGS)
# 合并显示名称映射 # 合并显示名称映射
NODE_DISPLAY_NAME_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(BLOCKIFY_MASK_DISPLAY)
NODE_DISPLAY_NAME_MAPPINGS.update(RESIZE_VER_KJ_DISPLAY) NODE_DISPLAY_NAME_MAPPINGS.update(RESIZE_VER_KJ_DISPLAY)
NODE_DISPLAY_NAME_MAPPINGS.update(IMAGE_BATCH_EXTEND_DISPLAY) NODE_DISPLAY_NAME_MAPPINGS.update(IMAGE_BATCH_EXTEND_DISPLAY)
NODE_DISPLAY_NAME_MAPPINGS.update(SAVE_IMAGE_PLUS_DISPLAY)
NODE_CATEGORIES = { NODE_CATEGORIES = {
"UniversalToolkit": [ "UniversalToolkit": [
@@ -973,6 +993,7 @@ NODE_CATEGORIES = {
"BlockifyMask_UTK", "BlockifyMask_UTK",
"ResizeImageVerKJ_UTK", "ResizeImageVerKJ_UTK",
"ImageBatchExtendWithOverlap_UTK", "ImageBatchExtendWithOverlap_UTK",
"SaveImagePlus_UTK",
] ]
} }
+304
View File
@@ -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)",
}
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "universaltoolkit" name = "universaltoolkit"
description = "A comprehensive toolkit based on ComfyUI, providing image, mask, audio, and tools nodes, fully modular and v3 compatible." 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"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"torch>=1.9.0", "torch>=1.9.0",