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:
co-authored by
Claude Sonnet 4.6
parent
7a08c1558a
commit
8efa2b7d6d
+22
-1
@@ -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",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user