New nodes: - Isulion Censored Part: blur/pixelate/black bar/noise over a mask and/or a percent rectangle; accepts 2D/3D/4D masks and fails closed (blacks the item out) instead of passing an uncensored image through - Prompt Vault Save/Load: named prompt library stored in ComfyUI's user directory (outside the checkout, so updates never conflict), seeded from prompt_vault.example.json; Load re-runs when the vault changes Refactor: - SimpleSelectorNode base: ~20 selector nodes use an instance RNG instead of reseeding the global random module (same seed => same output) - Theme handlers declare DISPLAY_NAME; theme package auto-discovers modules - __init__ imports node classes directly Fixes: - net_utils: re-validate every redirect hop, unwrap IPv4-mapped IPv6, block NAT64 and all non-global addresses (SSRF) - Multiple Prompt Generator: no duplicate prompts for themes listed in two categories; 6 themes missing from every category are now reachable - Shutdown node: parameter renamed to match its "any" input (TypeError) - ConfigManager: deterministic (sorted) merge order, warns on key collisions; drop stale space_colony copy from tropical_paradise_config - Mega Prompt V3: seed honored again with randomize=disable Breaking: Civitai api_key widget removed (use CIVITAI_API_TOKEN), absolute paths refused by Load Images, Negative Prompt pools de-duplicated (shorter "strict" output), Dynamic Random theme order is now alphabetical. Tooling: pytest suite (51 tests), GitHub CI (compileall, pytest, ruff), ruff config, uv.lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
5.8 KiB
Python
154 lines
5.8 KiB
Python
import os
|
|
import hashlib
|
|
import torch
|
|
import numpy as np
|
|
from PIL import Image, ImageOps
|
|
import folder_paths
|
|
|
|
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.bmp', '.gif', '.webp'}
|
|
|
|
class IsulionLoadImagesNode:
|
|
"""
|
|
A flexible image loading node that preserves image characteristics
|
|
and prepares images for dynamic collage generation.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _resolve_directory(directory: str) -> str:
|
|
"""Resolve user input strictly inside ComfyUI's input directory sandbox."""
|
|
directory = directory.strip()
|
|
if os.path.isabs(directory):
|
|
raise ValueError(f"Directory escapes input sandbox: {directory} (move the folder into ComfyUI/input and give a relative path)")
|
|
base = os.path.realpath(folder_paths.get_input_directory())
|
|
candidate = os.path.realpath(os.path.join(base, directory.lstrip("/\\")))
|
|
if candidate != base and not candidate.startswith(base + os.sep):
|
|
raise ValueError(f"Directory escapes input sandbox: {directory} (move the folder into ComfyUI/input and give a relative path)")
|
|
return candidate
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"directory": ("STRING", {"default": "./input"}),
|
|
"target_row_height": ("INT", {"default": 300, "min": 100, "max": 1024, "step": 50}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("images",)
|
|
FUNCTION = "load_images"
|
|
CATEGORY = "Isulion/Image"
|
|
# Output a LIST of variable-size tensors so downstream nodes (e.g.
|
|
# IsuCollage) receive every image at its own native size instead of a
|
|
# uniform batch distorted by padding.
|
|
OUTPUT_IS_LIST = (True,)
|
|
|
|
def load_images(self, directory, target_row_height=300):
|
|
"""
|
|
Load images from a directory with intelligent processing.
|
|
|
|
:param directory: Path to directory containing images
|
|
:param target_row_height: Target height for image rows
|
|
:return: Tensor of processed images
|
|
"""
|
|
# Resolve the full path (relative paths are sandboxed to ComfyUI's input dir)
|
|
full_directory = self._resolve_directory(directory)
|
|
|
|
# Validate directory exists
|
|
if not os.path.isdir(full_directory):
|
|
raise ValueError(f"Directory does not exist: {full_directory}")
|
|
|
|
# Get all image files in the directory
|
|
image_files = [
|
|
os.path.join(full_directory, f) for f in os.listdir(full_directory)
|
|
if os.path.isfile(os.path.join(full_directory, f)) and
|
|
os.path.splitext(f)[1].lower() in IMAGE_EXTENSIONS
|
|
]
|
|
|
|
# Sort files for consistent order
|
|
image_files.sort()
|
|
|
|
# Check if any images were found
|
|
if not image_files:
|
|
raise ValueError(f"No images found in directory: {full_directory}")
|
|
|
|
# Process images
|
|
processed_images = []
|
|
|
|
for image_path in image_files:
|
|
with Image.open(image_path) as img:
|
|
# Correct orientation and convert to RGB
|
|
img = ImageOps.exif_transpose(img)
|
|
img = img.convert('RGB')
|
|
|
|
# Calculate original aspect ratio
|
|
original_width, original_height = img.size
|
|
aspect_ratio = original_width / original_height
|
|
|
|
# Intelligent scaling to target row height
|
|
new_height = target_row_height
|
|
new_width = int(new_height * aspect_ratio)
|
|
|
|
# Resize maintaining aspect ratio
|
|
resized_img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
|
|
# Convert to numpy array and normalize
|
|
img_array = np.array(resized_img).astype(np.float32) / 255.0
|
|
|
|
# Convert to tensor
|
|
img_tensor = torch.from_numpy(img_array)
|
|
|
|
processed_images.append(img_tensor)
|
|
|
|
# Return the list as-is: each image keeps its own size (no padding),
|
|
# preserving native aspect ratios for downstream collage layout.
|
|
return (processed_images,)
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, directory, **kwargs):
|
|
"""
|
|
Check if the directory contents have changed (deterministic across runs).
|
|
"""
|
|
try:
|
|
full_directory = cls._resolve_directory(directory)
|
|
entries = []
|
|
for f in sorted(os.listdir(full_directory)):
|
|
path = os.path.join(full_directory, f)
|
|
if os.path.isfile(path) and os.path.splitext(f)[1].lower() in IMAGE_EXTENSIONS:
|
|
stat = os.stat(path)
|
|
entries.append((f, stat.st_mtime_ns, stat.st_size))
|
|
return hashlib.sha1(repr(entries).encode()).hexdigest()
|
|
except (OSError, ValueError):
|
|
# Missing/unreadable directory: force re-execution so the node
|
|
# raises a proper validation error instead.
|
|
return float("NaN")
|
|
|
|
@classmethod
|
|
def VALIDATE_INPUTS(cls, directory, **kwargs):
|
|
"""
|
|
Validate the input directory.
|
|
|
|
:param directory: Directory to validate
|
|
:return: True if valid, error message if not
|
|
"""
|
|
try:
|
|
full_directory = cls._resolve_directory(directory)
|
|
|
|
# Check if directory exists
|
|
if not os.path.isdir(full_directory):
|
|
return f"Directory '{directory}' cannot be found."
|
|
|
|
# Check if directory has any image files
|
|
image_files = [
|
|
f for f in os.listdir(full_directory)
|
|
if os.path.isfile(os.path.join(full_directory, f)) and
|
|
os.path.splitext(f)[1].lower() in IMAGE_EXTENSIONS
|
|
]
|
|
|
|
if not image_files:
|
|
return f"No image files in directory '{directory}'."
|
|
|
|
return True
|
|
except Exception as e:
|
|
return f"Error validating directory: {str(e)}"
|