Files

196 lines
6.7 KiB
Python

import json
import logging
import os
import random
import string
from typing import Any, Dict, Optional
import numpy as np
import torch
from PIL import Image
from PIL.PngImagePlugin import PngInfo
import folder_paths
from comfy.cli_args import args
from .core.export import ExportEnvironment, PhotoExportEngine
JPEG_COMMENT_MAX_BYTES = 65500 # Conservative buffer below 64 KiB JPEG comment cap.
INVALID_FILENAME_CHARS = '<>:"/\\|?*'
def _coerce_str(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
return str(value)
def _sanitize_name_component(name: str, allow_empty: bool = False) -> str:
cleaned = _coerce_str(name).strip()
if not cleaned:
return "" if allow_empty else "_"
safe = cleaned.translate({ord(ch): "_" for ch in INVALID_FILENAME_CHARS})
# rstrip(".") already collapses "." and ".." to empty — no separate check needed.
safe = safe.strip().rstrip(".")
if not safe:
if allow_empty:
return ""
raise ValueError("Filename component resolves to an empty string after sanitization.")
return safe
def _resolve_output_directory(path_value: str) -> str:
base_dir = os.path.abspath(folder_paths.get_output_directory())
requested = _coerce_str(path_value).strip()
if not requested:
return base_dir
if os.path.isabs(requested):
return os.path.abspath(requested)
normalized = os.path.normpath(requested)
if normalized.startswith(".."):
raise ValueError("Relative output path cannot traverse above the base output directory.")
resolved = os.path.abspath(os.path.join(base_dir, normalized))
common = os.path.commonpath([base_dir, resolved])
if common != base_dir:
raise ValueError("Resolved output directory escapes the base output directory.")
return resolved
def _encode_png_metadata(prompt: Optional[Any], extra_pnginfo: Optional[Any]) -> Optional[PngInfo]:
if args.disable_metadata:
return None
has_prompt = prompt is not None
has_extra = isinstance(extra_pnginfo, dict) and bool(extra_pnginfo)
if not has_prompt and not has_extra:
return None
metadata = PngInfo()
if has_prompt:
metadata.add_text("prompt", json.dumps(prompt))
if has_extra:
for key, value in extra_pnginfo.items():
metadata.add_text(key, json.dumps(value))
return metadata
def _encode_jpeg_comment(
prompt: Optional[Any],
extra_pnginfo: Optional[Any],
) -> Optional[bytes]:
if args.disable_metadata:
return None
payload: Dict[str, Any] = {}
if prompt is not None:
payload["prompt"] = prompt
if isinstance(extra_pnginfo, dict) and extra_pnginfo:
payload["extra_pnginfo"] = extra_pnginfo
if not payload:
return None
try:
encoded = json.dumps(payload, default=str).encode("utf-8")
except (TypeError, ValueError):
logging.warning("SimpleImageSaver: failed to serialize metadata to JSON; skipping metadata for JPEG.")
return None
if len(encoded) > JPEG_COMMENT_MAX_BYTES:
logging.warning(
"SimpleImageSaver: metadata payload (%d bytes) exceeds JPEG comment limit; metadata skipped.",
len(encoded),
)
return None
return encoded
def _save_jpeg(image: Image.Image, file_path: str, quality: int, comment: Optional[bytes]) -> None:
"""Save *image* as JPEG to *file_path* with consistent quality/subsampling settings.
Extracted to a shared helper so that the primary save and the preview proxy copy
both use identical settings — preventing the proxy from silently downgrading quality.
JPEG does not support alpha or palette modes. Any non-RGB image is converted
directly to RGB, discarding transparency without compositing.
"""
if image.mode != "RGB":
image = image.convert("RGB")
save_kwargs: Dict[str, Any] = {"quality": quality}
if quality >= 90:
# Disable chroma subsampling at high quality to preserve colour fidelity.
save_kwargs["subsampling"] = 0
if comment:
save_kwargs["comment"] = comment
image.save(file_path, format="JPEG", **save_kwargs)
class SimpleImageSaver:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"output_path": ("STRING", {"default": "", "multiline": False, "tooltip": "Directory path (absolute or relative to ComfyUI/output)."}),
"filename": ("STRING", {"default": "ComfyUI", "multiline": False, "tooltip": "Base filename without extension."}),
"suffix": ("STRING", {"default": "", "multiline": False, "tooltip": "Optional suffix appended with a dash when provided."}),
# Renamed from "format" to "file_format" to avoid shadowing the Python built-in.
# Existing saved workflows will need to reconnect this input after reloading.
"file_format": (["PNG", "JPG"], {"default": "PNG"}),
"jpeg_quality": ("INT", {"default": 95, "min": 0, "max": 100, "tooltip": "JPEG quality (0-100)."}),
"include_metadata": ("BOOLEAN", {"default": True, "tooltip": "Include workflow metadata (prompt + extras)."}),
"unique_filenames": ("BOOLEAN", {"default": True, "tooltip": "Append a counter to avoid overwriting existing files."}),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
RETURN_TYPES = ()
FUNCTION = "save"
OUTPUT_NODE = True
CATEGORY = "PortraitUtils/IO"
def save(
self,
images: torch.Tensor,
output_path: str,
filename: str,
suffix: str,
file_format: str,
jpeg_quality: int,
include_metadata: bool,
unique_filenames: bool = True,
prompt: Optional[Dict[str, Any]] = None,
extra_pnginfo: Optional[Dict[str, Any]] = None,
):
environment = ExportEnvironment(
folder_paths.get_output_directory(),
folder_paths.get_temp_directory(),
args.disable_metadata,
)
return PhotoExportEngine(environment).save(
images, output_path, filename, suffix, file_format, jpeg_quality,
include_metadata, unique_filenames, prompt, extra_pnginfo,
)
@staticmethod
def _relative_subfolder(target_dir: str, base_dir: str) -> str:
target_abs = os.path.abspath(target_dir)
try:
common = os.path.commonpath([base_dir, target_abs])
except ValueError:
return ""
if common != base_dir:
return ""
rel = os.path.relpath(target_abs, base_dir)
return "" if rel == "." else rel.replace("\\", "/")