Files
るみなー先生 09e717ea00 仕様: 出力ディレクトリ処理とファイル連番の仕様書を追加し、関連するドキュメントを更新
機能: Windows形式のドライブ相対パスを拒否し、連番の予約処理を原子的に実装
テスト: 出力ディレクトリとファイル連番に関するユニットテストを追加
バージョン: 0.1.3に更新
2026-08-24 19:37:41 +09:00

537 lines
16 KiB
Python

import os
import re
import shutil
import subprocess
import tempfile
from datetime import datetime
from pathlib import PurePath
from typing import Any, BinaryIO, List, Optional, Tuple
import numpy as np
try:
import folder_paths
except Exception: # pragma: no cover - ComfyUI provides this module at runtime
folder_paths = None
try:
from comfy.utils import ProgressBar
except Exception: # pragma: no cover - ComfyUI provides this module at runtime
ProgressBar = None
DIRECTORY_PATTERNS = [
"none",
"date",
"prefix",
"prefix_date",
"prefix/date",
"label",
"label_date",
"label/date",
"prefix_label",
"prefix/label",
"prefix_label_date",
"prefix/label/date",
"prefix_date_label",
"prefix/date/label",
]
FILENAME_DATE_FORMATS = [
"none",
"yyyyMMdd",
"yyyyMMdd_HHmm",
]
SUBSAMPLING_OPTIONS = ["4:4:4", "4:2:2", "4:2:0"]
SUBSAMPLING_TO_GM = {
"4:4:4": "1x1,1x1,1x1",
"4:2:2": "2x1,1x1,1x1",
"4:2:0": "2x2,1x1,1x1",
}
INVALID_NAME_CHARS_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]+')
WHITESPACE_RE = re.compile(r"\s+")
WINDOWS_DRIVE_RELATIVE_RE = re.compile(r"^[A-Za-z]:(?![\\/])")
UNSAFE_COMMENT_CHARS_RE = re.compile(
r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\ud800-\udfff]"
)
MAX_NAME_COMPONENT_LENGTH = 120
MAX_FILENAME_STEM_LENGTH = 220
MAX_JPEG_COMMENT_BYTES = 65000
JPEG_EXTENSION = ".jpg"
GM_TIMEOUT_SECONDS = 300
WINDOWS_RESERVED_NAMES = {
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
}
class _NoopProgressBar:
def update(self, amount: int = 1):
return None
def _make_progress_bar(total: int):
if ProgressBar is None:
return _NoopProgressBar()
try:
return ProgressBar(total)
except Exception:
return _NoopProgressBar()
def _get_comfy_output_directory() -> str:
if folder_paths is None:
raise RuntimeError("ComfyUI folder_paths module is not available.")
return folder_paths.get_output_directory()
def _resolve_gm_command() -> str:
gm_exec = shutil.which("gm")
if gm_exec is None:
raise RuntimeError(
"GraphicsMagick executable 'gm' was not found. "
"Please install GraphicsMagick and restart ComfyUI."
)
return gm_exec
def _sanitize_path_part(value: Any, fallback: str = "unnamed") -> str:
text = str(value if value is not None else "").strip()
text = INVALID_NAME_CHARS_RE.sub("_", text)
text = WHITESPACE_RE.sub("_", text)
text = text.strip("._ ")
while "__" in text:
text = text.replace("__", "_")
if not text:
text = fallback
if text in (".", ".."):
text = fallback
stem = text.split(".", 1)[0].upper()
if stem in WINDOWS_RESERVED_NAMES:
text = f"_{text}"
if len(text) > MAX_NAME_COMPONENT_LENGTH:
text = text[:MAX_NAME_COMPONENT_LENGTH].rstrip("._ ") or fallback
return text
def _limit_filename_stem(stem: str) -> str:
stem = stem.strip("._ ") or "image"
if len(stem) > MAX_FILENAME_STEM_LENGTH:
stem = stem[:MAX_FILENAME_STEM_LENGTH].rstrip("._ ") or "image"
return stem
def _build_file_date_text(filename_date_format: str, now: datetime) -> str:
if filename_date_format == "none":
return ""
if filename_date_format == "yyyyMMdd":
return now.strftime("%Y%m%d")
if filename_date_format == "yyyyMMdd_HHmm":
return now.strftime("%Y%m%d_%H%M")
raise ValueError(f"Unsupported filename_date_format: {filename_date_format}")
def _build_dir_date_text(now: datetime) -> str:
return now.strftime("%Y%m%d")
def _is_relative_traversal(path_text: str) -> bool:
parts = PurePath(path_text.replace("\\", "/")).parts
return any(part == ".." for part in parts)
def _resolve_base_output_dir(output_dir: Optional[str]) -> str:
if output_dir is None:
return _get_comfy_output_directory()
raw = str(output_dir).strip()
if not raw:
return _get_comfy_output_directory()
if WINDOWS_DRIVE_RELATIVE_RE.match(raw):
raise RuntimeError(
"Windows-style drive-relative output_dir is not supported: "
f"'{raw}'. Use a normal relative path to save under the ComfyUI "
"output directory, or use a fully qualified absolute path such as "
r"'D:\foo'."
)
if os.path.isabs(raw):
return os.path.normpath(raw)
if _is_relative_traversal(raw):
raise RuntimeError(
"Relative output_dir must not contain parent-directory traversal."
)
return os.path.normpath(os.path.join(_get_comfy_output_directory(), raw))
def _requires_label(directory_pattern: str) -> bool:
return "label" in directory_pattern
def _build_directory_parts(
directory_pattern: str,
prefix_safe: str,
label_safe: Optional[str],
dir_date_text: str,
) -> List[str]:
if directory_pattern not in DIRECTORY_PATTERNS:
raise ValueError(f"Unsupported directory_pattern: {directory_pattern}")
if _requires_label(directory_pattern) and not label_safe:
raise RuntimeError(
"directory_pattern uses label, but no label string was provided. "
"Connect a STRING node to the label input, or choose a pattern without label."
)
if directory_pattern == "none":
return []
if directory_pattern == "date":
return [dir_date_text]
if directory_pattern == "prefix":
return [prefix_safe]
if directory_pattern == "prefix_date":
return [f"{prefix_safe}_{dir_date_text}"]
if directory_pattern == "prefix/date":
return [prefix_safe, dir_date_text]
if directory_pattern == "label":
return [label_safe]
if directory_pattern == "label_date":
return [f"{label_safe}_{dir_date_text}"]
if directory_pattern == "label/date":
return [label_safe, dir_date_text]
if directory_pattern == "prefix_label":
return [f"{prefix_safe}_{label_safe}"]
if directory_pattern == "prefix/label":
return [prefix_safe, label_safe]
if directory_pattern == "prefix_label_date":
return [f"{prefix_safe}_{label_safe}_{dir_date_text}"]
if directory_pattern == "prefix/label/date":
return [prefix_safe, label_safe, dir_date_text]
if directory_pattern == "prefix_date_label":
return [f"{prefix_safe}_{dir_date_text}_{label_safe}"]
if directory_pattern == "prefix/date/label":
return [prefix_safe, dir_date_text, label_safe]
raise ValueError(f"Unsupported directory_pattern: {directory_pattern}")
def _build_save_dir(
output_dir: Optional[str],
directory_pattern: str,
prefix_safe: str,
label_safe: Optional[str],
dir_date_text: str,
) -> str:
base_dir = _resolve_base_output_dir(output_dir)
parts = _build_directory_parts(directory_pattern, prefix_safe, label_safe, dir_date_text)
save_dir = os.path.join(base_dir, *parts) if parts else base_dir
os.makedirs(save_dir, exist_ok=True)
return save_dir
def _build_filename_stem(
prefix_safe: str,
label_safe: Optional[str],
file_date_text: str,
) -> str:
parts = [prefix_safe]
if label_safe:
parts.append(label_safe)
if file_date_text:
parts.append(file_date_text)
return _limit_filename_stem("_".join(parts))
def _next_counter(save_dir: str, stem: str) -> int:
prefix = f"{stem}_"
max_counter = 0
try:
names = os.listdir(save_dir)
except FileNotFoundError:
return 1
for name in names:
lower_name = name.lower()
if not lower_name.endswith(JPEG_EXTENSION):
continue
if not name.startswith(prefix):
continue
tail = name[len(prefix):-len(JPEG_EXTENSION)]
if len(tail) >= 4 and tail.isdigit():
try:
existing_counter = int(tail)
except ValueError:
continue
max_counter = max(max_counter, existing_counter)
return max_counter + 1
def _reserve_output_file(
save_dir: str,
stem: str,
start_counter: int,
) -> Tuple[str, int, BinaryIO]:
counter = max(1, start_counter)
while True:
filename = f"{stem}_{counter:04}{JPEG_EXTENSION}"
out_path = os.path.join(save_dir, filename)
try:
output_file = open(out_path, "xb")
except FileExistsError:
counter += 1
continue
return out_path, counter, output_file
def _remove_incomplete_output_file(out_path: str) -> None:
try:
os.unlink(out_path)
except FileNotFoundError:
pass
except OSError as exc:
print(
"GM Image Saver warning: failed to remove incomplete output file "
f"'{out_path}': {exc}"
)
def _tensor_to_rgb_bytes_and_size(image_tensor) -> Tuple[bytes, int, int]:
img = image_tensor.detach().cpu().numpy()
img = np.clip(img * 255.0, 0, 255).astype(np.uint8)
if img.ndim == 2:
img = np.repeat(img[:, :, None], 3, axis=2)
if img.ndim != 3:
raise RuntimeError(
f"Expected image tensor with 2 or 3 dimensions, got shape {img.shape}."
)
height, width, channels = img.shape
if channels == 1:
img = np.repeat(img, 3, axis=2)
elif channels == 3:
pass
elif channels == 4:
img = img[:, :, :3]
else:
raise RuntimeError(
f"Expected 1, 3, or 4 channels, got shape {img.shape}."
)
img = np.ascontiguousarray(img)
return img.tobytes(), width, height
def _normalize_comment_bytes(comment: Optional[str]) -> Optional[bytes]:
if comment is None:
return None
text = str(comment).replace("\ufeff", "")
text = UNSAFE_COMMENT_CHARS_RE.sub("", text)
if not text:
return None
encoded = text.encode("utf-8")
if len(encoded) > MAX_JPEG_COMMENT_BYTES:
encoded = encoded[:MAX_JPEG_COMMENT_BYTES]
encoded = encoded.decode("utf-8", errors="ignore").encode("utf-8")
return encoded or None
def _write_temp_comment_file(comment_bytes: bytes) -> str:
temp_path = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
prefix="gm_image_saver_comment_",
suffix=".txt",
delete=False,
) as temp_file:
temp_path = temp_file.name
temp_file.write(comment_bytes)
return temp_path
except BaseException:
if temp_path:
try:
os.unlink(temp_path)
except OSError:
pass
raise
def _remove_temp_comment_file(temp_path: Optional[str]) -> None:
if not temp_path:
return
try:
os.unlink(temp_path)
except FileNotFoundError:
pass
except OSError as exc:
print(
"GM Image Saver warning: failed to remove temporary comment file "
f"'{temp_path}': {exc}"
)
class GMImageJpegSave:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"filename_prefix": ("STRING", {"default": "image"}),
"directory_pattern": (DIRECTORY_PATTERNS, {"default": "prefix/date"}),
"filename_date_format": (FILENAME_DATE_FORMATS, {"default": "none"}),
"quality": ("INT", {"default": 80, "min": 1, "max": 100, "step": 1}),
"subsampling": (SUBSAMPLING_OPTIONS, {"default": "4:2:2"}),
"progressive": ("BOOLEAN", {"default": False}),
},
"optional": {
"output_dir": ("STRING", {"forceInput": True}),
"label": ("STRING", {"forceInput": True}),
"comment": ("STRING", {"forceInput": True}),
},
}
RETURN_TYPES = ()
FUNCTION = "save_images"
OUTPUT_NODE = True
CATEGORY = "image/GMImageSaver"
def save_images(
self,
images,
filename_prefix,
directory_pattern,
filename_date_format,
quality,
subsampling,
progressive,
output_dir=None,
label=None,
comment=None,
):
now = datetime.now()
prefix_safe = _sanitize_path_part(filename_prefix, fallback="image")
label_safe = None
if label is not None and str(label).strip():
label_safe = _sanitize_path_part(label, fallback="label")
dir_date_text = _build_dir_date_text(now)
file_date_text = _build_file_date_text(filename_date_format, now)
save_dir = _build_save_dir(
output_dir=output_dir,
directory_pattern=directory_pattern,
prefix_safe=prefix_safe,
label_safe=label_safe,
dir_date_text=dir_date_text,
)
stem = _build_filename_stem(prefix_safe, label_safe, file_date_text)
counter = _next_counter(save_dir, stem)
gm_cmd = _resolve_gm_command()
sampling_factor = SUBSAMPLING_TO_GM[subsampling]
comment_bytes = _normalize_comment_bytes(comment)
pbar = _make_progress_bar(len(images))
comment_file_path = None
try:
if comment_bytes:
comment_file_path = _write_temp_comment_file(comment_bytes)
for image in images:
rgb_bytes, width, height = _tensor_to_rgb_bytes_and_size(image)
out_path, counter, output_file = _reserve_output_file(
save_dir,
stem,
counter,
)
save_succeeded = False
try:
with output_file:
cmd = [
gm_cmd,
"convert",
"-size",
f"{width}x{height}",
"-depth",
"8",
"rgb:-",
"-sampling-factor",
sampling_factor,
"-quality",
str(int(quality)),
]
if progressive:
cmd += ["-interlace", "Plane"]
if comment_file_path:
cmd += ["-comment", f"@{comment_file_path}"]
cmd.append("jpg:-")
try:
result = subprocess.run(
cmd,
input=rgb_bytes,
stdout=output_file,
stderr=subprocess.PIPE,
timeout=GM_TIMEOUT_SECONDS,
shell=False,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
"GraphicsMagick timed out after "
f"{GM_TIMEOUT_SECONDS} seconds while saving "
f"'{out_path}'."
) from exc
except OSError as exc:
raise RuntimeError(
"Failed to execute GraphicsMagick while saving "
f"'{out_path}'. OS error: {exc}"
) from exc
if result.returncode != 0:
stderr_text = result.stderr.decode(errors="ignore").strip()
raise RuntimeError(
"GraphicsMagick failed while saving "
f"'{out_path}'.\nstderr: {stderr_text}"
)
save_succeeded = True
finally:
if not output_file.closed:
output_file.close()
if not save_succeeded:
_remove_incomplete_output_file(out_path)
counter += 1
pbar.update(1)
finally:
_remove_temp_comment_file(comment_file_path)
return {}