Author SHA1 Message Date
jetthuangai 359a3b56dd Bump version 1.6.1 2026-10-18 08:33:37 +07:00
jetthuangai d23c54eda3 refactor: split image tools, share helpers, harden routes (no node changes)
- nodes/image/image_tools_nodes.py (1776 lines) split into save_image_path,
  folder_loaders, layer_composite, image_label, image_compare and
  image_resize_unit; folder helpers in core/image_folders.py (set-based
  dedupe), image helpers in core/image_ops.py
- One tensor -> PIL converter (core.utils.tensor_to_pil, rgb=/keep_alpha=)
  that keeps the tensor's dtype; bf16 supported; duplicates removed from grid,
  large save and preview cache
- core/mask_ops.mask_bbox replaces five bbox copies; model helpers moved to
  core/model_files.py; Pillow fromarray(mode=) uses removed
- Routes moved to core/folder_routes.py: no absolute paths in responses,
  recursive honoured, proper status codes, scans off the event loop; large
  preview routes reject malformed JSON and hide paths
- web/nh_widget_utils.js shared by all extensions; widgets hidden via the
  frontend's `hidden` property when available; folder image count honours
  recursive
- A wired Load Images Folder path no longer fingerprints ComfyUI's input folder
- Node ids, inputs and outputs unchanged (69 nodes); new tests for routes,
  web helpers, mask ops, channel handling and Pillow usage
2026-10-18 08:33:36 +07:00
46 changed files with 3825 additions and 2416 deletions
+3 -45
View File
@@ -45,58 +45,16 @@ for _category in pkgutil.iter_modules(_nodes_pkg.__path__):
NODE_DISPLAY_NAME_MAPPINGS.update(getattr(_module, "NODE_DISPLAY_NAME_MAPPINGS", {}))
_module_count += 1
# Route-only module: registers /nh-nodes/large-preview/* on import.
# Route-only modules: they register the /nh-nodes/* HTTP routes on import.
try:
from .core import large_preview_routes # noqa: F401
except Exception as e:
print(f"[NH-Nodes] Failed to import core.large_preview_routes: {e}")
try:
from aiohttp import web
from server import PromptServer
@PromptServer.instance.routes.get("/nh-nodes/load-images-folder/count")
async def nh_load_images_folder_count(request):
folder_path = request.query.get("folder_path", "")
recursive = request.query.get("recursive", "").lower() in {"1", "true", "yes", "on"}
try:
from .nodes.image.image_tools_nodes import _collect_image_files, _resolve_load_dir
resolved_folder = _resolve_load_dir(folder_path)
image_count = len(_collect_image_files(resolved_folder, recursive=recursive))
return web.json_response({"count": image_count, "path": resolved_folder})
except Exception as exc:
return web.json_response({"count": 0, "error": str(exc)})
@PromptServer.instance.routes.get("/nh-nodes/smart-resolution-picker/presets")
async def nh_smart_resolution_picker_presets(request):
try:
from .core.resolution_data import DEFAULT_MODEL_LABEL, DEFAULT_PRESET, MODEL_LABELS, PRESET_LABELS_BY_MODEL
from .nodes.resolution.smart_resolution_picker import TARGET_RESOLUTION_LEVELS, _resolution_candidates
ratios_by_model_level = {}
for model_label in MODEL_LABELS:
ratios_by_model_level[model_label] = {}
for resolution_level in TARGET_RESOLUTION_LEVELS:
candidates = _resolution_candidates(model_label, resolution_level)
ratios = []
for entry in candidates:
if entry["aspect"] not in ratios:
ratios.append(entry["aspect"])
ratios_by_model_level[model_label][resolution_level] = ratios
return web.json_response({
"models": MODEL_LABELS,
"default_model": DEFAULT_MODEL_LABEL,
"default_preset": DEFAULT_PRESET,
"presets_by_model": PRESET_LABELS_BY_MODEL,
"resolution_levels": TARGET_RESOLUTION_LEVELS,
"ratios_by_model_level": ratios_by_model_level,
})
except Exception as exc:
return web.json_response({"error": str(exc)}, status=500)
from .core import folder_routes # noqa: F401
except Exception as e:
print(f"[NH-Nodes] Failed to register web routes: {e}")
print(f"[NH-Nodes] Failed to import core.folder_routes: {e}")
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+103
View File
@@ -0,0 +1,103 @@
"""HTTP routes behind the NH-Nodes frontend extensions.
GET /nh-nodes/load-images-folder/count how many images a folder loader sees (auto increment / wrap)
GET /nh-nodes/smart-resolution-picker/presets the preset lists for the resolution pickers' dropdowns
Importing this module registers both on ComfyUI's PromptServer, like core.large_preview_routes. The handlers are plain
module-level functions, so a test can serve them from any aiohttp app through ``register_routes``.
"""
import asyncio
import os
from aiohttp import web
from .image_folders import collect_image_files, resolve_load_dir
COUNT_PATH = "/nh-nodes/load-images-folder/count"
PRESETS_PATH = "/nh-nodes/smart-resolution-picker/presets"
_MAX_FOLDER_PATH_CHARS = 4096
# Spellings of the ``recursive`` query parameter. Anything else is a bad request, not a silent "false".
_FLAGS = {"1": True, "true": True, "yes": True, "on": True, "": False, "0": False, "false": False, "no": False, "off": False}
def _error(status, message):
# Fixed short texts only: ComfyUI can be reachable from the network (--listen), so an answer never carries a
# resolved path or the text of an exception.
return web.json_response({"error": message}, status=status)
def _count_images(folder_path, recursive):
"""How many images a loader with these settings sees. Blocking (a recursive walk): run it in a worker thread."""
folder = resolve_load_dir(folder_path)
if not os.path.isdir(folder):
raise FileNotFoundError("folder not found")
try:
return len(collect_image_files(folder, recursive=recursive))
except ValueError:
return 0 # the folder exists but holds no image
async def nh_load_images_folder_count(request):
folder_path = request.query.get("folder_path", "")
recursive = _FLAGS.get(request.query.get("recursive", "").strip().lower())
if recursive is None:
return _error(400, "recursive must be true or false")
if len(folder_path) > _MAX_FOLDER_PATH_CHARS or "\0" in folder_path:
return _error(400, "invalid folder_path")
try:
count = await asyncio.to_thread(_count_images, folder_path, recursive)
except FileNotFoundError:
return _error(404, "folder not found")
except PermissionError:
return _error(403, "folder is not readable")
except Exception as exc:
print(f"[NH-Nodes] load-images-folder/count failed: {type(exc).__name__}: {exc}")
return _error(500, "could not read the folder")
return web.json_response({"count": count})
async def nh_smart_resolution_picker_presets(request):
try:
# Imported here: core must not depend on the node modules at import time.
from ..nodes.resolution.smart_resolution_picker import TARGET_RESOLUTION_LEVELS, _resolution_candidates
from .resolution_data import DEFAULT_MODEL_LABEL, DEFAULT_PRESET, MODEL_LABELS, PRESET_LABELS_BY_MODEL
ratios_by_model_level = {}
for model_label in MODEL_LABELS:
ratios_by_model_level[model_label] = {}
for resolution_level in TARGET_RESOLUTION_LEVELS:
candidates = _resolution_candidates(model_label, resolution_level)
ratios = []
for entry in candidates:
if entry["aspect"] not in ratios:
ratios.append(entry["aspect"])
ratios_by_model_level[model_label][resolution_level] = ratios
return web.json_response({
"models": MODEL_LABELS,
"default_model": DEFAULT_MODEL_LABEL,
"default_preset": DEFAULT_PRESET,
"presets_by_model": PRESET_LABELS_BY_MODEL,
"resolution_levels": TARGET_RESOLUTION_LEVELS,
"ratios_by_model_level": ratios_by_model_level,
})
except Exception as exc:
print(f"[NH-Nodes] smart-resolution-picker/presets failed: {type(exc).__name__}: {exc}")
return _error(500, "could not build the preset lists")
def register_routes(routes):
"""Attach the handlers to an aiohttp RouteTableDef (ComfyUI's ``PromptServer.instance.routes``)."""
routes.get(COUNT_PATH)(nh_load_images_folder_count)
routes.get(PRESETS_PATH)(nh_smart_resolution_picker_presets)
try:
from server import PromptServer
register_routes(PromptServer.instance.routes)
except Exception as exc:
print(f"[NH-Nodes] Failed to register folder routes: {exc}")
+78
View File
@@ -0,0 +1,78 @@
"""Image folder helpers shared by the folder loader nodes and the folder-count HTTP route.
``resolve_load_dir`` turns the folder text of a loader into a directory, ``collect_image_files`` lists the images in
it in natural order. Nothing here needs torch or Pillow.
"""
import os
import re
from pathlib import Path
import folder_paths
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff"}
def natural_key(value):
"""Sort key that puts 'img2' before 'img10' and ignores case."""
return [int(part) if part.isdigit() else part.lower() for part in re.split(r"(\d+)", value)]
def resolve_load_dir(folder_path, image="", upload_probe=""):
"""The folder a loader reads.
``folder_path`` when given (relative to ComfyUI's input folder unless absolute; a file stands for its folder),
else the folder of the uploaded ``image`` / ``upload_probe``, else the input folder.
"""
folder_path = (folder_path or "").strip()
if folder_path:
resolved = folder_path
if not os.path.isabs(resolved):
resolved = os.path.join(folder_paths.get_input_directory(), resolved)
if os.path.isfile(resolved):
resolved = os.path.dirname(resolved)
return resolved
upload_name = image or upload_probe
if upload_name:
return os.path.dirname(folder_paths.get_annotated_filepath(upload_name))
return folder_paths.get_input_directory()
def collect_image_files(folder_path, recursive=False):
"""The images of ``folder_path`` in natural order, as names (paths relative to the folder below it).
Sub-folders are read when ``recursive`` is set, and also when the folder holds no image itself. ValueError for a
folder that does not exist or has no images.
"""
if not os.path.isdir(folder_path):
raise ValueError(f"Folder does not exist: {folder_path}")
files = []
for entry in os.listdir(folder_path):
full_path = os.path.join(folder_path, entry)
if os.path.isfile(full_path) and Path(entry).suffix.lower() in IMAGE_EXTENSIONS:
files.append(entry)
if recursive or not files:
seen = set(files)
models_dir = os.path.normcase(os.path.abspath(folder_paths.models_dir))
for current_root, dir_names, file_names in os.walk(folder_path):
# Natural order, and never into the ComfyUI models folder (a walk from one of its parents would crawl it).
dir_names[:] = sorted(
(name for name in dir_names if os.path.normcase(os.path.abspath(os.path.join(current_root, name))) != models_dir),
key=natural_key,
)
for file_name in sorted(file_names, key=natural_key):
full_path = os.path.join(current_root, file_name)
if os.path.isfile(full_path) and Path(file_name).suffix.lower() in IMAGE_EXTENSIONS:
rel_path = os.path.relpath(full_path, folder_path)
if rel_path not in seen:
seen.add(rel_path)
files.append(rel_path)
files.sort(key=natural_key)
if not files:
raise ValueError(f"No images found in folder: {folder_path}")
return files
+47
View File
@@ -0,0 +1,47 @@
"""Pillow / tensor helpers shared by the image nodes (save, load, compare, label, resize, layer composite).
IMAGE tensors become PIL images with core.utils.tensor_to_pil (rgb=True, plus keep_alpha=True for layers);
``pil_to_image_tensor`` converts back in ComfyUI's intermediate dtype.
"""
from PIL import Image, ImageColor
import comfy.model_management
from .utils import parse_hex_color, pil_to_tensor
RESAMPLING = getattr(Image, "Resampling", Image)
def parse_color(value, default):
"""Parse a colour into an (r, g, b, a) tuple of 0-255 ints; ``default`` when the text is not a colour.
'#RGB', '#RRGGBB' and '#RRGGBBAA' go through core.utils.parse_hex_color (opaque unless AA is given). Other CSS
colours Pillow understands ('red', 'rgb(1, 2, 3)') keep working, as they did before.
"""
text = str(value or "").strip()
color = parse_hex_color(text, ())
if color:
return color
try:
return ImageColor.getcolor(text, "RGBA")
except ValueError:
return tuple(default) + (255,) * (4 - len(default))
def pil_to_image_tensor(image):
"""A PIL image as a [1, H, W, C] float IMAGE in ComfyUI's intermediate dtype."""
return pil_to_tensor(image).to(dtype=comfy.model_management.intermediate_dtype())
def resize_pil(image, size, method):
"""``image`` resized to ``size`` (width, height) with the 'lanczos', 'bicubic', 'bilinear' or 'nearest' filter."""
resample = {
"lanczos": RESAMPLING.LANCZOS,
"bicubic": RESAMPLING.BICUBIC,
"bilinear": RESAMPLING.BILINEAR,
"nearest": RESAMPLING.NEAREST,
}[method]
return image.resize(size, resample)
+5 -17
View File
@@ -10,10 +10,11 @@ import uuid
from pathlib import Path
import folder_paths
import numpy as np
import torch
from PIL import Image, ImageChops, ImageDraw
from .utils import tensor_to_pil
CACHE_SUBDIR = "nh_large_preview"
CACHE_ID_RE = re.compile(r"^[a-f0-9]{32}$")
@@ -139,21 +140,6 @@ def delete_cache(cache_id):
}
def _tensor_to_pil(image_tensor):
if not isinstance(image_tensor, torch.Tensor):
raise TypeError(f"Expected IMAGE tensor, got {type(image_tensor).__name__}")
array = image_tensor.detach().cpu().numpy()
array = np.clip(array * 255.0, 0, 255).astype(np.uint8)
if array.ndim == 2:
return Image.fromarray(array).convert("RGB")
if array.ndim == 3 and array.shape[-1] == 1:
return Image.fromarray(array[:, :, 0]).convert("RGB")
if array.ndim == 3 and array.shape[-1] == 4:
return Image.fromarray(array).convert("RGBA")
return Image.fromarray(array).convert("RGB")
def _preview_dimensions(width, height, preview_megapixels):
width = max(1, int(width))
height = max(1, int(height))
@@ -292,7 +278,9 @@ def create_preview_cache(
batch_index=0,
batch_count=1,
):
source = _tensor_to_pil(image_tensor)
if not isinstance(image_tensor, torch.Tensor):
raise TypeError(f"Expected IMAGE tensor, got {type(image_tensor).__name__}")
source = tensor_to_pil(image_tensor, rgb=True, keep_alpha=True)
source_width, source_height = source.size
preview_width, preview_height = _preview_dimensions(source_width, source_height, preview_megapixels)
preview = source.copy()
+95 -56
View File
@@ -1,67 +1,106 @@
"""HTTP routes for NH large image preview cache."""
"""HTTP routes for NH large image preview cache.
Importing this module registers the routes on ComfyUI's PromptServer. The handlers are plain module-level functions,
so a test can serve them from any aiohttp app through ``register_routes``.
"""
import asyncio
import json
from aiohttp import web
from .large_preview_cache import cache_info, cleanup_cache, delete_cache, get_thumb_path, get_tile_path, read_manifest
def _without_paths(value):
"""Answers go to the browser: drop absolute filesystem paths (the Cache Manager node still shows them)."""
if isinstance(value, dict):
return {key: _without_paths(item) for key, item in value.items() if key not in ("path", "root")}
if isinstance(value, list):
return [_without_paths(item) for item in value]
return value
async def _read_json_object(request):
"""The JSON object in the request body ({} when the body is empty). ValueError for anything else."""
try:
text = await request.text()
payload = json.loads(text) if text.strip() else {}
except ValueError: # not JSON, or not UTF-8 text: json.JSONDecodeError and UnicodeDecodeError both are ValueErrors
raise ValueError("request body is not valid JSON") from None
if not isinstance(payload, dict):
raise ValueError("request body must be a JSON object")
return payload
async def nh_large_preview_manifest(request):
try:
manifest = read_manifest(request.match_info["cache_id"])
return web.json_response(manifest)
except FileNotFoundError:
return web.json_response({"error": "manifest not found"}, status=404)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
async def nh_large_preview_thumb(request):
try:
return web.FileResponse(get_thumb_path(request.match_info["cache_id"]))
except FileNotFoundError:
return web.json_response({"error": "thumbnail not found"}, status=404)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
async def nh_large_preview_tile(request):
try:
return web.FileResponse(get_tile_path(
request.match_info["cache_id"],
request.match_info["level"],
request.match_info["tile"],
))
except FileNotFoundError:
return web.json_response({"error": "tile not available in thumbnail-only preview"}, status=404)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
async def nh_large_preview_cache_info(request):
try:
# Directory walks run in a worker thread so a large cache never stalls the server's event loop.
return web.json_response(_without_paths(await asyncio.to_thread(cache_info)))
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
async def nh_large_preview_cleanup(request):
try:
payload = await _read_json_object(request)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
try:
cache_id = payload.get("cache_id", "")
if cache_id:
result = await asyncio.to_thread(delete_cache, cache_id)
else:
result = await asyncio.to_thread(cleanup_cache, payload.get("max_age_hours", 24), payload.get("max_total_mb", 0))
return web.json_response(_without_paths(result))
except (ValueError, TypeError, OverflowError) as exc: # a bad id, or a limit that is not a number
return web.json_response({"error": str(exc)}, status=400)
def register_routes(routes):
"""Attach the handlers to an aiohttp RouteTableDef (ComfyUI's ``PromptServer.instance.routes``)."""
routes.get("/nh-nodes/large-preview/manifest/{cache_id}")(nh_large_preview_manifest)
routes.get("/nh-nodes/large-preview/thumb/{cache_id}")(nh_large_preview_thumb)
routes.get("/nh-nodes/large-preview/tile/{cache_id}/{level}/{tile}")(nh_large_preview_tile)
routes.get("/nh-nodes/large-preview/cache-info")(nh_large_preview_cache_info)
routes.post("/nh-nodes/large-preview/cleanup")(nh_large_preview_cleanup)
try:
from server import PromptServer
@PromptServer.instance.routes.get("/nh-nodes/large-preview/manifest/{cache_id}")
async def nh_large_preview_manifest(request):
try:
manifest = read_manifest(request.match_info["cache_id"])
return web.json_response(manifest)
except FileNotFoundError:
return web.json_response({"error": "manifest not found"}, status=404)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
@PromptServer.instance.routes.get("/nh-nodes/large-preview/thumb/{cache_id}")
async def nh_large_preview_thumb(request):
try:
return web.FileResponse(get_thumb_path(request.match_info["cache_id"]))
except FileNotFoundError:
return web.json_response({"error": "thumbnail not found"}, status=404)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
@PromptServer.instance.routes.get("/nh-nodes/large-preview/tile/{cache_id}/{level}/{tile}")
async def nh_large_preview_tile(request):
try:
return web.FileResponse(get_tile_path(
request.match_info["cache_id"],
request.match_info["level"],
request.match_info["tile"],
))
except FileNotFoundError:
return web.json_response({"error": "tile not available in thumbnail-only preview"}, status=404)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
@PromptServer.instance.routes.get("/nh-nodes/large-preview/cache-info")
async def nh_large_preview_cache_info(request):
try:
return web.json_response(cache_info())
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
@PromptServer.instance.routes.post("/nh-nodes/large-preview/cleanup")
async def nh_large_preview_cleanup(request):
try:
payload = await request.json()
except Exception:
payload = {}
try:
cache_id = payload.get("cache_id", "")
if cache_id:
result = delete_cache(cache_id)
else:
result = cleanup_cache(payload.get("max_age_hours", 24), payload.get("max_total_mb", 0))
return web.json_response(result)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
register_routes(PromptServer.instance.routes)
except Exception as exc:
print(f"[NH-Nodes] Failed to register large preview routes: {exc}")
+25
View File
@@ -0,0 +1,25 @@
"""Mask helpers shared by the NH mask and image nodes.
NH-Nodes' own code (MIT). It is deliberately not part of core/utils_mask.py: that file is third-party
FitDiT code under CC BY-NC-SA 4.0 (see THIRD_PARTY_NOTICES.md), and plain MIT nodes must not depend on it.
"""
import numpy as np
def mask_bbox(mask, threshold=0.5):
"""Inclusive bounding box ``(x1, y1, x2, y2)`` of the pixels of a 2-D mask that are above ``threshold``.
``mask`` is an [H, W] array (a torch mask goes through ``.cpu().numpy()`` first). A pixel counts only when it is
strictly greater than ``threshold``, so a value of exactly 0.5 is outside, as in every NH mask node. The result is
four Python ints; (x2, y2) is the last pixel inside, so width = x2 - x1 + 1 and height = y2 - y1 + 1.
Returns None when no pixel is above the threshold, i.e. for an empty mask. Anything that is not 2-D raises ValueError.
"""
inside = np.asarray(mask) > threshold
if inside.ndim != 2:
raise ValueError(f"mask_bbox expects a 2-D [H, W] mask, got shape {inside.shape}")
rows = np.flatnonzero(inside.any(axis=1))
if rows.size == 0:
return None
cols = np.flatnonzero(inside.any(axis=0))
return int(cols[0]), int(rows[0]), int(cols[-1]), int(rows[-1])
+131
View File
@@ -0,0 +1,131 @@
"""Model-folder, download and VRAM helpers shared by the SegFormer and VTON model loaders.
Both model families follow the same rules:
* Model folders are registered with ComfyUI's ``folder_paths`` under the names
``nh_nodes`` (SegFormer) and ``vton_mask`` (VTON). Users can add more
locations with those names in ``extra_model_paths.yaml``; every registered
location is searched before anything is downloaded. Each name is registered
with the extension of its model files (``.safetensors``, ``.onnx``), so
ComfyUI's model lists show those files and not the Hugging Face cache
metadata that downloads leave in the same folder.
* Downloads always go to the default folder under the models dir, pinned to a
fixed Hugging Face revision.
"""
import os
import torch
try:
import folder_paths
except Exception: # running outside ComfyUI (tests)
folder_paths = None
try:
import comfy.model_management as _mm
except Exception:
_mm = None
def models_dir():
"""ComfyUI's models directory, read on every call (--models-directory, tests)."""
if folder_paths is not None:
return folder_paths.models_dir
return os.path.join(os.path.dirname(__file__), "models")
def unique_dirs(paths):
"""Drop repeats (same folder spelled differently) but keep the first spelling and the order."""
seen, unique = set(), []
for path in paths:
key = os.path.normcase(os.path.abspath(path))
if key not in seen:
seen.add(key)
unique.append(path)
return unique
def _list_only(folder_name, extensions):
"""Add `extensions` to the file types ComfyUI lists for the registered `folder_name`.
add_model_folder_path registers a name with an empty extension set, which ComfyUI reads as "list
every file": /models and the Model Library then show the .cache/huggingface metadata of our
downloads and a repo's .gitattributes. The set is replaced, never changed in place (ComfyUI shares
one set between many folder names), and the locations list stays the very same object, so every
extra_model_paths.yaml entry survives.
"""
if not extensions:
return
registry = folder_paths.folder_names_and_paths
paths, current = registry[folder_name]
merged = {*current, *extensions}
if merged != set(current):
registry[folder_name] = (paths, merged)
def register_model_folder(folder_name, default_dir, extensions=()):
"""Register `folder_name` with ComfyUI and return every folder searched for it, best first.
ComfyUI applies extra_model_paths.yaml before custom nodes load, so locations the user
configured under `folder_name` are already registered; they come first. `default_dir`
is added too (a no-op when already present) and is always part of the result, so models
that live there are found whether or not the user configured anything. Safe to call repeatedly.
`extensions` (".onnx") are the file types of the models in these folders: ComfyUI's lists for
`folder_name` then show only those, also for the locations the user added.
"""
registered = []
if folder_paths is not None:
try:
folder_paths.add_model_folder_path(folder_name, default_dir)
registered = folder_paths.get_folder_paths(folder_name)
_list_only(folder_name, extensions) # last: if it fails, the locations above are still returned
except Exception as exc: # an unusual ComfyUI build: fall back to the default location alone
print(f"[NH-Nodes] Could not register model folder '{folder_name}' with ComfyUI: {exc}")
return unique_dirs([*registered, default_dir])
def download_pinned(repo_id, revision, target, allow_patterns):
"""snapshot_download of `allow_patterns` from `repo_id` at `revision` into `target`.
Finished files are kept and partial ones resume, so calling it again after an interrupted
download completes the folder. Failures are re-raised with where to get the files by hand.
"""
from huggingface_hub import snapshot_download
os.makedirs(target, exist_ok=True)
print(f"[NH-Nodes] Downloading {repo_id}@{revision[:8]} -> {target}")
try:
snapshot_download(repo_id=repo_id, revision=revision, local_dir=target, allow_patterns=list(allow_patterns))
except Exception as exc:
raise RuntimeError(
f"[NH-Nodes] Downloading {repo_id} (revision {revision[:8]}) into {target} failed: {exc}\n"
f"Run again once the connection works (finished files are kept), or download "
f"{', '.join(allow_patterns)} from https://huggingface.co/{repo_id}/tree/{revision} into that folder."
) from exc
def make_room_on_gpu(nbytes):
"""Ask ComfyUI to unload its own models until about `nbytes` of VRAM are free.
ComfyUI's own loaders ask for their size plus the VRAM it keeps in reserve (extra_reserved_memory(),
set by --reserve-vram or its built-in default), so that reserve is added here as well: asking for
`nbytes` alone would leave us that much short. Also hands PyTorch's cached blocks back to the
driver: libraries that allocate outside PyTorch (ONNX Runtime) cannot use them. Nothing happens
without a CUDA device, when ComfyUI itself runs on the CPU, or outside ComfyUI. A failure is
reported, not raised: the caller still tries its allocation.
"""
if _mm is None or not torch.cuda.is_available():
return
try:
# ComfyUI's loaded models carry an indexed device (cuda:0); an index-less
# torch.device("cuda") compares unequal to it and would match none of them.
device = _mm.get_torch_device()
if device.type != "cuda":
return
reserved = getattr(_mm, "extra_reserved_memory", None) # absent in older ComfyUI builds
_mm.free_memory(nbytes + (reserved() if reserved else 0), device)
_mm.soft_empty_cache()
except Exception as exc:
print(f"[NH-Nodes] Could not ask ComfyUI to free VRAM ({exc}); continuing")
+10 -126
View File
@@ -4,18 +4,13 @@ Handles model discovery (reuse dirs already on disk before downloading),
per-model caching with CPU offload between runs, and full-resolution label
map prediction.
It also holds the model-folder, download and VRAM helpers that the VTON node
reuses, so both model families follow the same rules:
* Model folders are registered with ComfyUI's ``folder_paths`` under the names
``nh_nodes`` (SegFormer) and ``vton_mask`` (VTON). Users can add more
locations with those names in ``extra_model_paths.yaml``; every registered
location is searched before anything is downloaded. Each name is registered
with the extension of its model files (``.safetensors``, ``.onnx``), so
ComfyUI's model lists show those files and not the Hugging Face cache
metadata that downloads leave in the same folder.
* Downloads always go to the default folder under the models dir, pinned to a
fixed Hugging Face revision.
The model-folder, download and VRAM helpers it shares with the VTON node live
in model_files.py, so both model families follow the same rules. SegFormer
models are registered with ComfyUI's ``folder_paths`` under the name
``nh_nodes``; users can add more locations with that name in
``extra_model_paths.yaml`` and every registered location is searched before
anything is downloaded. Downloads always go to the default folder under the
models dir, pinned to a fixed Hugging Face revision.
"""
import os
@@ -25,15 +20,7 @@ import numpy as np
import torch
import torch.nn.functional as F
try:
import folder_paths
except Exception: # running outside ComfyUI (tests)
folder_paths = None
try:
import comfy.model_management as _mm
except Exception:
_mm = None
from .model_files import download_pinned, make_room_on_gpu, models_dir, register_model_folder, unique_dirs
_REQUIRED_FILES = ("config.json", "model.safetensors", "preprocessor_config.json")
# Logits are interpolated at most to this size before argmax; the label map is
@@ -70,116 +57,13 @@ _warned_no_cuda = False
# ---------------------------------------------------------------------------
# Model folders and downloads (shared with the VTON node)
# Model folders and downloads (the shared helpers are in model_files.py)
# ---------------------------------------------------------------------------
def models_dir():
"""ComfyUI's models directory, read on every call (--models-directory, tests)."""
if folder_paths is not None:
return folder_paths.models_dir
return os.path.join(os.path.dirname(__file__), "models")
def default_folder():
"""Where SegFormer models are downloaded and searched first: <models dir>/NH-Nodes."""
return os.path.join(models_dir(), _DEFAULT_FOLDER)
def _unique_dirs(paths):
"""Drop repeats (same folder spelled differently) but keep the first spelling and the order."""
seen, unique = set(), []
for path in paths:
key = os.path.normcase(os.path.abspath(path))
if key not in seen:
seen.add(key)
unique.append(path)
return unique
def _list_only(folder_name, extensions):
"""Add `extensions` to the file types ComfyUI lists for the registered `folder_name`.
add_model_folder_path registers a name with an empty extension set, which ComfyUI reads as "list
every file": /models and the Model Library then show the .cache/huggingface metadata of our
downloads and a repo's .gitattributes. The set is replaced, never changed in place (ComfyUI shares
one set between many folder names), and the locations list stays the very same object, so every
extra_model_paths.yaml entry survives.
"""
if not extensions:
return
registry = folder_paths.folder_names_and_paths
paths, current = registry[folder_name]
merged = {*current, *extensions}
if merged != set(current):
registry[folder_name] = (paths, merged)
def register_model_folder(folder_name, default_dir, extensions=()):
"""Register `folder_name` with ComfyUI and return every folder searched for it, best first.
ComfyUI applies extra_model_paths.yaml before custom nodes load, so locations the user
configured under `folder_name` are already registered; they come first. `default_dir`
is added too (a no-op when already present) and is always part of the result, so models
that live there are found whether or not the user configured anything. Safe to call repeatedly.
`extensions` (".onnx") are the file types of the models in these folders: ComfyUI's lists for
`folder_name` then show only those, also for the locations the user added.
"""
registered = []
if folder_paths is not None:
try:
folder_paths.add_model_folder_path(folder_name, default_dir)
registered = folder_paths.get_folder_paths(folder_name)
_list_only(folder_name, extensions) # last: if it fails, the locations above are still returned
except Exception as exc: # an unusual ComfyUI build: fall back to the default location alone
print(f"[NH-Nodes] Could not register model folder '{folder_name}' with ComfyUI: {exc}")
return _unique_dirs([*registered, default_dir])
def download_pinned(repo_id, revision, target, allow_patterns):
"""snapshot_download of `allow_patterns` from `repo_id` at `revision` into `target`.
Finished files are kept and partial ones resume, so calling it again after an interrupted
download completes the folder. Failures are re-raised with where to get the files by hand.
"""
from huggingface_hub import snapshot_download
os.makedirs(target, exist_ok=True)
print(f"[NH-Nodes] Downloading {repo_id}@{revision[:8]} -> {target}")
try:
snapshot_download(repo_id=repo_id, revision=revision, local_dir=target, allow_patterns=list(allow_patterns))
except Exception as exc:
raise RuntimeError(
f"[NH-Nodes] Downloading {repo_id} (revision {revision[:8]}) into {target} failed: {exc}\n"
f"Run again once the connection works (finished files are kept), or download "
f"{', '.join(allow_patterns)} from https://huggingface.co/{repo_id}/tree/{revision} into that folder."
) from exc
def make_room_on_gpu(nbytes):
"""Ask ComfyUI to unload its own models until about `nbytes` of VRAM are free.
ComfyUI's own loaders ask for their size plus the VRAM it keeps in reserve (extra_reserved_memory(),
set by --reserve-vram or its built-in default), so that reserve is added here as well: asking for
`nbytes` alone would leave us that much short. Also hands PyTorch's cached blocks back to the
driver: libraries that allocate outside PyTorch (ONNX Runtime) cannot use them. Nothing happens
without a CUDA device, when ComfyUI itself runs on the CPU, or outside ComfyUI. A failure is
reported, not raised: the caller still tries its allocation.
"""
if _mm is None or not torch.cuda.is_available():
return
try:
# ComfyUI's loaded models carry an indexed device (cuda:0); an index-less
# torch.device("cuda") compares unequal to it and would match none of them.
device = _mm.get_torch_device()
if device.type != "cuda":
return
reserved = getattr(_mm, "extra_reserved_memory", None) # absent in older ComfyUI builds
_mm.free_memory(nbytes + (reserved() if reserved else 0), device)
_mm.soft_empty_cache()
except Exception as exc:
print(f"[NH-Nodes] Could not ask ComfyUI to free VRAM ({exc}); continuing")
def _has_required_files(path):
return all(os.path.isfile(os.path.join(path, name)) for name in _REQUIRED_FILES)
@@ -194,7 +78,7 @@ def model_search_dirs(model_name):
dirs = [os.path.join(base, model_name)
for base in register_model_folder(FOLDER_NAME, default_folder(), FOLDER_EXTENSIONS)]
dirs += [os.path.normpath(os.path.join(models_dir(), *rel.split("/"))) for rel in spec["legacy_dirs"]]
return _unique_dirs(dirs)
return unique_dirs(dirs)
def resolve_model_dir(model_name, download=True):
+14 -3
View File
@@ -11,11 +11,14 @@ def resize_image(image, target_size=768):
new_h, new_w = target_size, int(w * target_size / h)
return image.resize((new_w, new_h), Image.LANCZOS)
def tensor_to_pil(tensor_image):
def tensor_to_pil(tensor_image, *, rgb=False, keep_alpha=False):
"""Convert ONE ComfyUI image ([H,W,C] or [1,H,W,C]) to PIL.
Values are clipped to [0, 1] first, so overshoot from resampling cannot wrap around
in uint8. 1/3/4 channels become L/RGB/RGBA. Batches must be converted per image.
``rgb=True`` always gives an RGB picture: grey is expanded and alpha is dropped. With ``keep_alpha=True`` as
well, a 4-channel image stays RGBA instead (every other image is still RGB).
"""
if tensor_image.dim() == 4:
if tensor_image.shape[0] != 1:
@@ -24,10 +27,18 @@ def tensor_to_pil(tensor_image):
"convert each image of the batch separately"
)
tensor_image = tensor_image[0]
np_image = np.clip(tensor_image.detach().cpu().float().numpy() * 255.0, 0, 255).astype(np.uint8)
pixels = tensor_image.detach().cpu()
if pixels.dtype == torch.bfloat16: # numpy has no bfloat16; other dtypes keep their own rounding
pixels = pixels.float()
np_image = np.clip(pixels.numpy() * 255.0, 0, 255).astype(np.uint8)
if np_image.ndim == 3 and np_image.shape[2] == 1:
np_image = np_image[:, :, 0]
return Image.fromarray(np_image)
pil_image = Image.fromarray(np_image)
if rgb:
kept_modes = ("RGB", "RGBA") if keep_alpha else ("RGB",)
if pil_image.mode not in kept_modes:
pil_image = pil_image.convert("RGB")
return pil_image
def pil_to_tensor(pil_image):
"""Convert PIL image to tensor in ComfyUI format for IMAGE"""
+9 -8
View File
@@ -1,7 +1,8 @@
# Garment mask construction for VTON Ultimate Processor (NH).
# Third-party code: FitDiT src/utils_mask.py (https://github.com/BoyuanJiang/FitDiT),
# received via ComfyUI-Vton-Mask. License: CC BY-NC-SA 4.0 (non-commercial), not the
# pack's MIT license. NH-Nodes changes: lint cleanups and bug fixes. See THIRD_PARTY_NOTICES.md.
# pack's MIT license. NH-Nodes changes: lint cleanups, bug fixes and Image.fromarray calls without the mode
# argument that Pillow 11.3 deprecated. See THIRD_PARTY_NOTICES.md.
import numpy as np
import cv2
from PIL import Image, ImageDraw
@@ -125,8 +126,8 @@ def get_img_agnostic_upper_rectangle(im_parse, pose_data, offset_top, offset_bot
agnostic_draw.rectangle((max(0, x1-pad_x1+offset_left), max(0, y1-pad_y1+offset_top), min(x2+pad_x2+offset_right, parse_array.shape[1]), min(y2+pad_y2+offset_bottom, parse_array.shape[0])), 'gray', 'gray')
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_head * 255), 'L'))
# agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_fixed * 255), 'L'))
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_head * 255)))
# agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_fixed * 255)))
mask_gray = agnostic.copy()
mask = agnostic.point(lambda p: p == 128 and 255)
@@ -213,8 +214,8 @@ def get_img_agnostic_lower_rectangle(im_parse, pose_data, offset_top, offset_bot
agnostic_draw.rectangle((max(0, x1-pad_x1+offset_left), max(0, y1-pad_y1+offset_top), min(x2+pad_x2+offset_right, parse_array.shape[1]), min(y2+pad_y2+offset_bottom, parse_array.shape[0])), 'gray', 'gray')
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_head * 255), 'L'))
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_fixed * 255), 'L'))
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_head * 255)))
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_fixed * 255)))
mask_gray = agnostic.copy()
mask = agnostic.point(lambda p: p == 128 and 255)
@@ -305,9 +306,9 @@ def get_img_agnostic_dresses_rectangle(im_parse, pose_data, offset_top, offset_b
agnostic_draw.rectangle((max(0, x1-pad_x1+offset_left), max(0, y1-pad_y1+offset_top), min(x2+pad_x2+offset_right, parse_array.shape[1]), min(y2+pad_y2+offset_bottom, parse_array.shape[0])), 'gray', 'gray')
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_head * 255), 'L'))
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_shoes * 255), 'L'))
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_fixed * 255), 'L'))
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_head * 255)))
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_shoes * 255)))
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_fixed * 255)))
mask_gray = agnostic.copy()
mask = agnostic.point(lambda p: p == 128 and 255)
+14
View File
@@ -3,6 +3,20 @@
All notable changes to NH-Nodes. Versions follow the Comfy Registry releases.
"Behaviour change" marks fixes that can change the output of an existing workflow.
## 1.6.1 — Cleanup and hardening
- Internal restructuring with no change to node ids, inputs or outputs: the large image-tools module is split into one
module per node family, shared helpers live in `core/` (`image_folders`, `image_ops`, `mask_ops`, `model_files`), and
the frontend extensions share one widget helper module.
- Widgets are hidden with the frontend's own `hidden` property when available (older frontends keep the previous
method); values are still saved with the workflow.
- `Load Images Folder`: the image count used for auto increment now honours `recursive`; a wired `folder_path` no
longer fingerprints ComfyUI's input folder by mistake.
- HTTP routes: the folder-count and large-preview routes no longer return absolute paths, answer with proper status
codes (malformed JSON → 400) and do their file work off the server's event loop.
- `NH Save Large Image` accepts single-channel images; bfloat16 IMAGE tensors convert correctly; image conversions no
longer use Pillow's deprecated `fromarray(mode=…)`.
## 1.6.0 — Performance, caching and VRAM
### Faster graphs
+7 -11
View File
@@ -2,14 +2,7 @@ import torch
import numpy as np
import cv2 # Cần thư viện OpenCV
def get_bounding_box(mask_np):
rows = np.any(mask_np, axis=1)
cols = np.any(mask_np, axis=0)
if not np.any(rows) or not np.any(cols):
return None, None, None, None
ymin, ymax = np.where(rows)[0][[0, -1]]
xmin, xmax = np.where(cols)[0][[0, -1]]
return xmin, ymin, xmax, ymax
from ...core.mask_ops import mask_bbox
def _to_uint8(image):
# Clip trước khi ép uint8: giá trị lệch khỏi [0,1] (vd. do resample) sẽ bị wrap về gần 0 nếu không clip.
@@ -60,12 +53,15 @@ class NH_SimpleFacePaste:
dest_img_np = _to_uint8(dest_image)
dest_mask_np = dest_mask.cpu().numpy()
src_x1, src_y1, src_x2, src_y2 = get_bounding_box(src_mask_np > 0.5)
dest_x1, dest_y1, dest_x2, dest_y2 = get_bounding_box(dest_mask_np > 0.5)
src_box = mask_bbox(src_mask_np)
dest_box = mask_bbox(dest_mask_np)
if src_x1 is None or dest_x1 is None:
if src_box is None or dest_box is None:
return None
src_x1, src_y1, src_x2, src_y2 = src_box
dest_x1, dest_y1, dest_x2, dest_y2 = dest_box
src_face = src_img_np[src_y1:src_y2+1, src_x1:src_x2+1]
src_face_mask = (np.clip(src_mask_np[src_y1:src_y2+1, src_x1:src_x2+1], 0.0, 1.0) * 255).astype(np.uint8)
+681
View File
@@ -0,0 +1,681 @@
"""Load Images Folder (NH) and Load images matching: read the images of a folder, or of the folder that matches a name."""
import hashlib
import os
import re
import unicodedata
from pathlib import Path
import torch
from PIL import Image, ImageOps
import folder_paths
import node_helpers
from comfy_execution.graph_utils import ExecutionBlocker
from ...core.image_folders import IMAGE_EXTENSIONS, collect_image_files, natural_key, resolve_load_dir
from ...core.image_ops import pil_to_image_tensor, resize_pil
def _normalize_match_value(value):
value = unicodedata.normalize("NFD", str(value or ""))
value = "".join(char for char in value if unicodedata.category(char) != "Mn")
value = re.sub(r"[_\-]+", " ", value)
value = re.sub(r"\s+", " ", value)
return value.strip().casefold()
def _path_parts(value):
return [part.strip() for part in re.split(r"[\\/]+", str(value or "")) if part.strip()]
def _normalized_path_parts(value):
return [part for part in (_normalize_match_value(item) for item in _path_parts(value)) if part]
def _longest_common_path_run(candidate_parts, source_parts):
if not candidate_parts or not source_parts:
return 0
previous = [0] * (len(source_parts) + 1)
best = 0
for candidate_part in candidate_parts:
current = [0] * (len(source_parts) + 1)
for index, source_part in enumerate(source_parts, start=1):
if candidate_part == source_part:
current[index] = previous[index - 1] + 1
best = max(best, current[index])
previous = current
return best
def _ordered_path_overlap(candidate_parts, source_parts):
if not candidate_parts or not source_parts:
return 0
count = 0
cursor = 0
for candidate_part in candidate_parts:
while cursor < len(source_parts) and source_parts[cursor] != candidate_part:
cursor += 1
if cursor >= len(source_parts):
break
count += 1
cursor += 1
return count
def _candidate_context_score(candidate_path, source_context_paths):
if not source_context_paths:
return 0
candidate_parts = _normalized_path_parts(candidate_path)
best_score = 0
for source_path in source_context_paths:
source_parts = _normalized_path_parts(source_path)
run = _longest_common_path_run(candidate_parts, source_parts)
ordered = _ordered_path_overlap(candidate_parts, source_parts)
score = run * 100 + ordered
if candidate_parts and len(source_parts) >= 2 and candidate_parts[-1] == source_parts[-2]:
score += 10
elif candidate_parts and source_parts and candidate_parts[-1] == source_parts[-1]:
score += 5
best_score = max(best_score, score)
return best_score
def _source_segment_set(source_context_paths):
segments = set()
for path_value in source_context_paths or []:
segments.update(_normalized_path_parts(path_value))
return segments
def _add_unique_path(paths, path_value):
if not path_value:
return
key = os.path.normcase(os.path.normpath(path_value))
if key not in {os.path.normcase(os.path.normpath(path)) for path in paths}:
paths.append(path_value)
_SIGNATURE_ENTRY_LIMIT = 20000 # most folders + files Load Images Matching hashes for IS_CHANGED before it gives up
class _NoMatchError(ValueError):
"""The search itself came up empty: no matching folder, or no images in it. A bad setting is a plain ValueError."""
def __init__(self, message, match_text=""):
super().__init__(message)
self.match_text = match_text
def _hash_parts(hasher, *parts):
for part in parts:
hasher.update(str(part).encode("utf-8", errors="replace"))
hasher.update(b"\0")
def _same_path(first, second):
return os.path.normcase(os.path.abspath(first)) == os.path.normcase(os.path.abspath(second))
def _is_inside(path, folder):
"""True when ``path`` lies below ``folder`` (``folder`` itself does not count)."""
path = os.path.normcase(os.path.abspath(path))
folder = os.path.normcase(os.path.abspath(folder))
return path != folder and path.startswith(folder.rstrip(os.sep) + os.sep)
def _is_drive_root(path):
path = os.path.abspath(path)
return os.path.dirname(path) == path
def _entry_is_dir(entry, follow_symlinks=True):
try:
return entry.is_dir(follow_symlinks=follow_symlinks)
except OSError:
return False
def _absolute_root(root_path):
"""The matching root as an absolute path; a relative one is taken relative to ComfyUI's input folder."""
root_path = (root_path or "").strip()
if root_path and not os.path.isabs(root_path):
root_path = os.path.join(folder_paths.get_input_directory(), root_path)
return root_path
def _check_search_root(root_path, recursive):
"""Refuse the roots a search must not scan: a whole drive (when it would be walked) and the models folder."""
if recursive and _is_drive_root(root_path):
raise ValueError(
f"Matching root is a drive root ({root_path}); refusing to scan a whole drive. "
"Point matching_root_path at the folder that holds the images, or turn recursive off."
)
if _same_path(root_path, folder_paths.models_dir):
raise ValueError(f"Matching root is the ComfyUI models folder ({root_path}), which holds no images to match.")
def _list_folder(folder_path):
"""The os.DirEntry objects in ``folder_path`` in natural order, without the ComfyUI models folder; [] if unreadable."""
try:
with os.scandir(folder_path) as scan:
entries = sorted(scan, key=lambda entry: natural_key(entry.name))
except OSError:
return []
models_dir = os.path.normcase(os.path.abspath(folder_paths.models_dir))
return [
entry for entry in entries
if not (_entry_is_dir(entry) and os.path.normcase(os.path.normpath(entry.path)) == models_dir)
]
def _child_folders(folder_path):
"""Paths of the sub-folders directly inside ``folder_path``, in natural order."""
return [entry.path for entry in _list_folder(folder_path) if _entry_is_dir(entry)]
def _walk_folders(root_path, recursive=True):
"""Yield ``(folder, entries)`` for ``root_path`` and, when ``recursive``, for every folder below it.
The matching search's stand-in for os.walk, with limits: it refuses the roots ``_check_search_root`` names,
never enters the ComfyUI models folder and, like os.walk, does not follow symlinks into other trees.
"""
_check_search_root(root_path, recursive)
pending = [root_path]
while pending:
folder = pending.pop()
entries = _list_folder(folder)
yield folder, entries
if recursive:
pending.extend(entry.path for entry in reversed(entries) if _entry_is_dir(entry, follow_symlinks=False))
def _context_twins(root_path, context_names, wanted=None):
"""Folders that stand in for ``root_path`` under another context folder.
When the root sits inside a context folder (``.../Nam/...``), the same place under a sibling context folder
(``.../Nu/...``) is a twin, and so is the sibling context folder as a whole. ``wanted`` limits the siblings to
those names (normalized); all other configured context names count when it is None.
"""
context = {_normalize_match_value(name) for name in context_names} - {""}
if not context:
return []
parts = list(Path(os.path.normpath(root_path)).parts)
twins = []
for position, part in enumerate(parts):
own_name = _normalize_match_value(part)
if position == 0 or own_name not in context:
continue
rest = parts[position + 1:]
for sibling_path in _child_folders(str(Path(*parts[:position]))):
sibling_name = _normalize_match_value(os.path.basename(sibling_path))
if sibling_name == own_name or sibling_name not in context:
continue
if wanted is not None and sibling_name not in wanted:
continue
twins.append(sibling_path)
if rest and os.path.isdir(os.path.join(sibling_path, *rest)):
twins.append(os.path.join(sibling_path, *rest))
return twins
def _context_root_candidates(root_path, source_context_paths, context_names=()):
"""Folders to search for the match, best context first.
That is the matching root, the sub-folders of it that the source path names (``Nu`` when the source lives in a
``Nu`` folder) and the context twins of the root. Nothing else above the root is listed, let alone walked.
"""
root_path = _absolute_root(root_path)
if not root_path:
return []
candidates = []
_add_unique_path(candidates, root_path)
source_segments = _source_segment_set(source_context_paths)
if not source_segments:
return candidates
for child_path in _child_folders(root_path):
if _normalize_match_value(os.path.basename(child_path)) in source_segments:
_add_unique_path(candidates, child_path)
for twin_path in _context_twins(root_path, context_names, source_segments):
_add_unique_path(candidates, twin_path)
candidates.sort(key=lambda item: (-_candidate_context_score(item, source_context_paths), natural_key(item)))
return candidates
def _resolve_matching_folder(root_path, match_text, match_mode, recursive, source_context_paths=None):
root_path = _absolute_root(root_path)
if not root_path:
raise ValueError("Matching root path is empty")
if not os.path.isdir(root_path):
raise ValueError(f"Matching root folder does not exist: {root_path}")
normalized_match = _normalize_match_value(match_text)
if not normalized_match:
raise ValueError("Match text is empty")
candidates = [root_path]
for _folder, entries in _walk_folders(root_path, recursive):
candidates.extend(entry.path for entry in entries if _entry_is_dir(entry))
exact_matches = []
contains_matches = []
for candidate in candidates:
normalized_name = _normalize_match_value(os.path.basename(candidate))
if normalized_name == normalized_match:
exact_matches.append(candidate)
if normalized_match in normalized_name:
contains_matches.append(candidate)
matches = exact_matches if match_mode == "exact" else contains_matches or exact_matches
if not matches:
raise _NoMatchError(f"No matching folder found for '{match_text}' under: {root_path}", match_text)
if source_context_paths:
matches.sort(key=lambda item: (
-_candidate_context_score(item, source_context_paths),
natural_key(os.path.relpath(item, root_path)),
))
else:
matches.sort(key=lambda item: natural_key(os.path.relpath(item, root_path)))
return matches[0]
def _resolve_best_matching_folder(root_path, match_text, match_mode, recursive, source_context_paths=None, context_names=()):
root_path = _absolute_root(root_path)
if not root_path:
raise ValueError("Matching root path is empty")
_check_search_root(root_path, recursive)
search_roots = _context_root_candidates(root_path, source_context_paths, context_names)
if recursive:
# Walking a folder covers everything below it, so a search root inside another one is not walked twice.
search_roots = [path for path in search_roots if not any(_is_inside(path, other) for other in search_roots)]
matched_folders = []
errors = []
for search_root in search_roots:
try:
matched_folder = _resolve_matching_folder(
search_root,
match_text,
match_mode,
recursive,
source_context_paths=source_context_paths,
)
_add_unique_path(matched_folders, matched_folder)
except ValueError as exc:
errors.append(exc)
if not matched_folders:
# A setting that cannot work (missing root, empty text) is reported as such; otherwise nothing matched.
for exc in errors:
if not isinstance(exc, _NoMatchError):
raise exc
raise _NoMatchError(f"No matching folder found for '{match_text}' under: {root_path}", match_text)
matched_folders.sort(key=lambda item: (-_candidate_context_score(item, source_context_paths), natural_key(item)))
return matched_folders[0]
def _parse_context_folder_names(value):
names = [item.strip() for item in re.split(r"[\n,;|]+", str(value or "")) if item.strip()]
return names
def _detect_context_folder(paths, context_folder_names):
if not context_folder_names:
return ""
normalized_context = {_normalize_match_value(name): name for name in context_folder_names}
for path_value in paths:
for part in re.split(r"[\\/]+", str(path_value or "")):
match = normalized_context.get(_normalize_match_value(part))
if match:
return match
return ""
def _matching_selection(
source_folder_path,
matching_root_path,
match_text,
match_mode,
recursive,
index,
step,
context_folder_names,
source_file_names,
person_folder_path=None,
):
"""Work out what Load images matching reads: one ``(match_text, folder, [file names])`` per match item.
Raises _NoMatchError when an item finds no folder, or a folder without images, and ValueError for a setting
that cannot work (missing root, empty match text, a root that must not be scanned).
"""
if not source_folder_path and person_folder_path:
source_folder_path = person_folder_path
source_paths = [line.strip() for line in str(source_folder_path or "").splitlines() if line.strip()]
first_source_path = source_paths[0] if source_paths else ""
resolved_source_folder = resolve_load_dir(first_source_path)
selected_match_text = (match_text or "").strip()
source_names = [line.strip() for line in str(source_file_names or "").splitlines() if line.strip()]
context_names = _parse_context_folder_names(context_folder_names)
match_items = []
if selected_match_text:
item_count = max(len(source_paths), len(source_names), 1)
for item_index in range(item_count):
match_items.append((
selected_match_text,
source_paths[item_index] if item_index < len(source_paths) else "",
source_names[item_index] if item_index < len(source_names) else "",
))
elif source_names:
for item_index, source_name in enumerate(source_names):
source_dir = os.path.dirname(source_name)
source_match_text = os.path.basename(os.path.normpath(source_dir)) if source_dir else os.path.basename(os.path.normpath(resolved_source_folder))
match_items.append((
source_match_text,
source_paths[item_index] if item_index < len(source_paths) else "",
source_name,
))
else:
selected_match_text = os.path.basename(os.path.normpath(resolved_source_folder))
match_items.append((selected_match_text, first_source_path, ""))
plan = []
for current_match_text, current_source_path, current_source_name in match_items:
context_paths = [current_source_path, current_source_name, resolved_source_folder]
context_folder = _detect_context_folder(context_paths, context_names)
if context_folder:
context_paths.append(context_folder)
matched_folder = _resolve_best_matching_folder(
matching_root_path,
current_match_text,
match_mode,
recursive,
source_context_paths=context_paths,
context_names=context_names,
)
try:
image_files = collect_image_files(matched_folder)
except ValueError as exc: # no images in the folder that matched
raise _NoMatchError(str(exc), current_match_text) from exc
start_index = index % len(image_files)
selected_files = [image_files[(start_index + offset) % len(image_files)] for offset in range(step)]
plan.append((current_match_text, matched_folder, selected_files))
return plan
def _selection_signature(plan):
"""Digest of what a matching plan reads: the folders chosen, and the selected files with size and mtime."""
hasher = hashlib.sha256()
for current_match_text, matched_folder, selected_files in plan:
_hash_parts(hasher, current_match_text, os.path.abspath(matched_folder))
for file_name in selected_files:
stat = os.stat(os.path.join(matched_folder, file_name))
_hash_parts(hasher, file_name, stat.st_size, stat.st_mtime_ns)
return hasher.hexdigest()
def _tree_signature(roots, limit=None):
"""Digest of every folder and image below ``roots`` (names, sizes, mtimes); None beyond ``limit`` entries."""
limit = _SIGNATURE_ENTRY_LIMIT if limit is None else limit
hasher = hashlib.sha256()
seen = 0
for root in roots:
_hash_parts(hasher, "root", os.path.abspath(root))
for folder, entries in _walk_folders(root, recursive=True):
_hash_parts(hasher, "folder", os.path.relpath(folder, root))
for entry in entries:
seen += 1
if seen > limit:
return None
if _entry_is_dir(entry):
_hash_parts(hasher, "dir", entry.name)
elif Path(entry.name).suffix.lower() in IMAGE_EXTENSIONS:
try:
stat = entry.stat()
except OSError:
_hash_parts(hasher, "image", entry.name)
else:
_hash_parts(hasher, "image", entry.name, stat.st_size, stat.st_mtime_ns)
return hasher.hexdigest()
def _load_image_batch(paths):
"""The images at ``paths`` as one RGB IMAGE batch; every image after the first is scaled to the size of the first."""
images = []
target_size = None
for path in paths:
img = node_helpers.pillow(Image.open, path)
img = node_helpers.pillow(ImageOps.exif_transpose, img).convert("RGB")
if target_size is None:
target_size = img.size
elif img.size != target_size:
img = resize_pil(img, target_size, "lanczos")
images.append(pil_to_image_tensor(img))
return torch.cat(images, dim=0)
class NH_LoadImagesFromFolder:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"folder_path": ("STRING", {"default": "", "multiline": False}),
"index": ("INT", {"default": 0, "min": 0, "max": 999999}),
"step": ("INT", {"default": 1, "min": 1, "max": 9999}),
"seed_mode": (["fixed", "increment", "decrement", "random"],),
"recursive": ("BOOLEAN", {"default": False}),
"filename_extension": ("BOOLEAN", {"default": True}),
"path_output": (["file_path", "folder_path"], {"default": "file_path"}),
},
}
RETURN_TYPES = ("IMAGE", "STRING", "STRING")
RETURN_NAMES = ("images", "filename", "path")
FUNCTION = "load_images"
CATEGORY = "NH-Nodes/Image"
@classmethod
def IS_CHANGED(cls, folder_path, index, step, seed_mode, recursive=False, filename_extension=True, path_output="file_path", **kwargs):
# An input wired from another node arrives here as None, so the folder is unknown (resolving None would
# fingerprint ComfyUI's input folder instead). The upstream value is already part of ComfyUI's cache key, so a
# constant keeps an unchanged graph cached while a new path still runs the node again.
if folder_path is None:
return "wired-folder-path"
try:
resolved_folder = resolve_load_dir(folder_path)
image_files = collect_image_files(resolved_folder, recursive=recursive)
step = max(1, int(step))
start_index = int(index) % len(image_files)
if seed_mode == "decrement":
indices = [((start_index - offset) % len(image_files)) for offset in range(step)]
else:
indices = [((start_index + offset) % len(image_files)) for offset in range(step)]
hasher = hashlib.sha256()
_hash_parts(
hasher,
os.path.abspath(resolved_folder),
start_index,
step,
seed_mode,
bool(recursive),
bool(filename_extension),
path_output,
)
for selected_index in indices:
rel_path = image_files[selected_index]
stat = os.stat(os.path.join(resolved_folder, rel_path))
_hash_parts(hasher, rel_path, stat.st_size, stat.st_mtime_ns)
return hasher.hexdigest()
except Exception as exc:
return f"error:{type(exc).__name__}:{exc}"
def load_images(self, folder_path, index, step, seed_mode, recursive=False, filename_extension=True, path_output="file_path", **kwargs):
resolved_folder = resolve_load_dir(folder_path)
image_files = collect_image_files(resolved_folder, recursive=recursive)
start_index = index % len(image_files)
if seed_mode == "decrement":
indices = [((start_index - offset) % len(image_files)) for offset in range(step)]
else:
indices = [((start_index + offset) % len(image_files)) for offset in range(step)]
selected_files = [image_files[i] for i in indices]
full_paths = [os.path.join(resolved_folder, file_name) for file_name in selected_files]
if path_output == "folder_path":
output_paths = [os.path.dirname(full_path) for full_path in full_paths]
else:
output_paths = full_paths
display_filenames = []
for file_name in selected_files:
base_name = os.path.basename(file_name)
if not filename_extension:
base_name = os.path.splitext(base_name)[0]
display_filenames.append(base_name)
return (_load_image_batch(full_paths), "\n".join(display_filenames), "\n".join(output_paths))
class NH_LoadImagesMatching:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"source_folder_path": ("STRING", {"default": "", "multiline": False}),
"matching_root_path": ("STRING", {"default": "", "multiline": False}),
"match_text": ("STRING", {"default": "", "multiline": False}),
"match_mode": (["contains", "exact"],),
"recursive": ("BOOLEAN", {"default": True}),
"index": ("INT", {"default": 0, "min": 0, "max": 999999}),
"step": ("INT", {"default": 1, "min": 1, "max": 9999}),
"repeat_count": ("INT", {"default": 1, "min": 1, "max": 9999}),
"context_folder_names": ("STRING", {"default": "Nam\nNu", "multiline": True}),
},
"optional": {
"source_file_names": ("STRING", {"default": "", "multiline": True}),
"fallback_images": ("IMAGE", {
"lazy": True,
"tooltip": (
"Comes out as images when nothing matches, and is only evaluated then. Connect it here "
"instead of routing images through a switch or If/Else: a node that receives the held-back "
"images never runs."
),
}),
},
}
RETURN_TYPES = ("IMAGE", "STRING", "STRING", "STRING", "BOOLEAN")
RETURN_NAMES = ("images", "filename", "path", "matched_text", "found")
FUNCTION = "load_matching"
CATEGORY = "NH-Nodes/Image"
DESCRIPTION = (
"Load the images of the folder under matching_root_path whose name matches the source folder (or match_text). "
"When nothing matches, found is False and images are held back: every node that receives them is skipped, "
"a switch or If/Else included. To carry on with another image, connect it to fallback_images instead of "
"routing images through a switch or If/Else: it is only evaluated when nothing matches, and then comes out "
"as images (found stays False)."
)
@classmethod
def IS_CHANGED(cls, source_folder_path, matching_root_path, match_text, match_mode, recursive, index, step, repeat_count, context_folder_names="Nam\nNu", source_file_names="", **kwargs):
# ComfyUI's cache key already holds every setting and every upstream result; this adds what is on disk:
# the folder(s) the search picks and the selected files with their size and time, so an edit re-runs the node.
# An input wired from another node arrives here as None, which leaves the folder unknown: the whole matching
# tree is fingerprinted instead. A search that finds nothing, or a tree too big to hash cheaply, gives NaN:
# that result is recomputed every time (so a "not found" is never remembered).
unknown = float("nan")
try:
settings = (source_folder_path, matching_root_path, match_text, match_mode, recursive, index, step, context_folder_names, source_file_names)
if None not in settings:
plan = _matching_selection(
source_folder_path, matching_root_path, match_text, match_mode, recursive, index, step,
context_folder_names, source_file_names, kwargs.get("person_folder_path"),
)
return _selection_signature(plan)
root_path = _absolute_root(matching_root_path)
if not root_path or context_folder_names is None:
return unknown
roots = [root_path] + _context_twins(root_path, _parse_context_folder_names(context_folder_names))
signature = _tree_signature(roots)
return unknown if signature is None else signature
except (ValueError, OSError):
return unknown
def check_lazy_status(self, source_folder_path, matching_root_path, match_text, match_mode, recursive, index, step, repeat_count, context_folder_names="Nam\nNu", source_file_names="", **kwargs):
# fallback_images is asked for only when it is wired (ComfyUI sends its key, with None until the input has
# run) and the search finds nothing; on a match its upstream nodes never run. The search is the one
# load_matching makes. A setting that cannot work asks for nothing: load_matching raises it right after.
if "fallback_images" not in kwargs or kwargs["fallback_images"] is not None:
return []
try:
_matching_selection(
source_folder_path, matching_root_path, match_text, match_mode, recursive, index, step,
context_folder_names, source_file_names, kwargs.get("person_folder_path"),
)
except _NoMatchError:
return ["fallback_images"]
except (ValueError, OSError):
pass
return []
def load_matching(self, source_folder_path, matching_root_path, match_text, match_mode, recursive, index, step, repeat_count, context_folder_names="Nam\nNu", source_file_names="", fallback_images=None, **kwargs):
try:
plan = _matching_selection(
source_folder_path, matching_root_path, match_text, match_mode, recursive, index, step,
context_folder_names, source_file_names, kwargs.get("person_folder_path"),
)
except _NoMatchError as exc:
# Nothing to load for this request. `found` lets a workflow carry on with something else, and so does
# fallback_images, which comes out as the images. Without it the images are held back (whatever needs
# them is told why). A setting that cannot work still raises.
message = str(exc)
if fallback_images is not None:
print(f"[NH-Nodes] Load images matching: {message} (using fallback_images)")
return (fallback_images, "", "", exc.match_text, False)
print(f"[NH-Nodes] Load images matching: {message}")
return (ExecutionBlocker(message), "", "", exc.match_text, False)
selected_files = []
matched_folders = []
matched_texts = []
for current_match_text, matched_folder, file_names in plan:
for file_name in file_names:
selected_files.append(file_name)
matched_folders.append(matched_folder)
matched_texts.append(current_match_text)
images = _load_image_batch(
[os.path.join(folder, file_name) for folder, file_name in zip(matched_folders, selected_files)]
)
if repeat_count > 1:
images = images.repeat(repeat_count, 1, 1, 1)
return (images, "\n".join(selected_files), "\n".join(matched_folders), "\n".join(matched_texts), True)
NODE_CLASS_MAPPINGS = {
"NH_LoadImagesFromFolder": NH_LoadImagesFromFolder,
"NH_LoadImagesMatching": NH_LoadImagesMatching,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_LoadImagesFromFolder": "Load Images Folder (NH)",
"NH_LoadImagesMatching": "Load images matching",
}
+95
View File
@@ -0,0 +1,95 @@
"""Image Compare (NH): two images side by side, split, blended or as a difference."""
import torch
from PIL import Image, ImageChops
from ...core.utils import tensor_to_pil
from ...core.image_ops import pil_to_image_tensor, resize_pil
def _match_batch_sizes(batch_a, batch_b):
count_a = batch_a.shape[0]
count_b = batch_b.shape[0]
if count_a == count_b:
return batch_a, batch_b
if count_a == 1:
return batch_a.repeat(count_b, 1, 1, 1), batch_b
if count_b == 1:
return batch_a, batch_b.repeat(count_a, 1, 1, 1)
shared = min(count_a, count_b)
print(f"[NH-Nodes] Image Compare: batch mismatch ({count_a} vs {count_b}), truncating to {shared}")
return batch_a[:shared], batch_b[:shared]
class NH_ImageCompare:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image_a": ("IMAGE",),
"image_b": ("IMAGE",),
"compare_mode": (
[
"side_by_side",
"top_bottom",
"split_vertical",
"split_horizontal",
"difference",
"overlay",
],
),
"split_position": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
"overlay_opacity": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
"resize_method": (["lanczos", "bicubic", "bilinear", "nearest"],),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "compare"
CATEGORY = "NH-Nodes/Image"
def compare(self, image_a, image_b, compare_mode, split_position, overlay_opacity, resize_method):
batch_a, batch_b = _match_batch_sizes(image_a, image_b)
compared = []
for tensor_a, tensor_b in zip(batch_a, batch_b):
pil_a = tensor_to_pil(tensor_a, rgb=True)
pil_b = resize_pil(tensor_to_pil(tensor_b, rgb=True), pil_a.size, resize_method)
width, height = pil_a.size
if compare_mode == "side_by_side":
canvas = Image.new("RGB", (width * 2, height))
canvas.paste(pil_a, (0, 0))
canvas.paste(pil_b, (width, 0))
elif compare_mode == "top_bottom":
canvas = Image.new("RGB", (width, height * 2))
canvas.paste(pil_a, (0, 0))
canvas.paste(pil_b, (0, height))
elif compare_mode == "split_vertical":
split_x = max(0, min(width, int(round(width * split_position))))
canvas = pil_b.copy()
canvas.paste(pil_a.crop((0, 0, split_x, height)), (0, 0))
elif compare_mode == "split_horizontal":
split_y = max(0, min(height, int(round(height * split_position))))
canvas = pil_b.copy()
canvas.paste(pil_a.crop((0, 0, width, split_y)), (0, 0))
elif compare_mode == "difference":
canvas = ImageChops.difference(pil_a, pil_b)
else:
canvas = Image.blend(pil_a, pil_b, overlay_opacity)
compared.append(pil_to_image_tensor(canvas))
return (torch.cat(compared, dim=0),)
NODE_CLASS_MAPPINGS = {
"NH_ImageCompare": NH_ImageCompare,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_ImageCompare": "Image Compare (NH)",
}
+8 -19
View File
@@ -1,7 +1,8 @@
import numpy as np
import torch
from PIL import Image, ImageColor
from ...core.utils import pil_to_tensor, tensor_to_pil
try:
import psutil
except ImportError: # ComfyUI installs psutil; without it the memory guard below is simply skipped
@@ -14,7 +15,7 @@ _RESAMPLING = getattr(Image, "Resampling", Image)
# (Image.MAX_IMAGE_PIXELS): that limit is process-wide, other packs raise or disable it, and it only applies to
# Image.open, never to an IMAGE that stays in memory.
#
# Peak RAM per canvas pixel, reached inside _pil_to_tensor (measured 28.1-28.2 B/px on Windows, Pillow 11, NumPy 2):
# Peak RAM per canvas pixel, reached inside pil_to_tensor (measured 28.1-28.2 B/px on Windows, Pillow 11, NumPy 2):
# 4 B the PIL canvas (Pillow stores an RGB image as 4 bytes per pixel)
# 12 B the float32 array from astype(np.float32)
# 12 B the float32 result of "/ 255.0" (NumPy only reuses that temporary on Linux)
@@ -71,26 +72,14 @@ def _check_canvas_size(canvas_width, canvas_height, columns, rows, cell_width, c
def _parse_hex_color(value, default):
# Pillow's own parser, not core.utils.parse_hex_color: it also reads CSS names and rgb(...), and it needs the '#'.
# Kept so that every background colour the node accepted before still gives the same fill.
try:
return ImageColor.getrgb((value or "").strip())
except Exception:
return default
def _tensor_to_pil(image_tensor):
array = np.clip(255.0 * image_tensor.cpu().numpy(), 0, 255).astype(np.uint8)
if array.ndim == 2:
return Image.fromarray(array).convert("RGB")
if array.ndim == 3 and array.shape[-1] == 1:
return Image.fromarray(array[:, :, 0]).convert("RGB")
return Image.fromarray(array).convert("RGB")
def _pil_to_tensor(image):
array = np.array(image).astype(np.float32) / 255.0
return torch.from_numpy(array).unsqueeze(0)
def _first_value(value):
if isinstance(value, (list, tuple)):
if not value:
@@ -110,10 +99,10 @@ def _flatten_images(images):
raise TypeError(f"Expected IMAGE tensor input, got {type(image_item).__name__}")
if image_item.ndim == 3:
pil_images.append(_tensor_to_pil(image_item))
pil_images.append(tensor_to_pil(image_item, rgb=True))
elif image_item.ndim == 4:
for batch_index in range(int(image_item.shape[0])):
pil_images.append(_tensor_to_pil(image_item[batch_index]))
pil_images.append(tensor_to_pil(image_item[batch_index], rgb=True))
else:
raise ValueError(f"Unsupported IMAGE tensor shape: {tuple(image_item.shape)}")
@@ -244,7 +233,7 @@ class NH_ImageGridComposite:
f"{image_index}: x={x}, y={y}, w={cell_width}, h={cell_height}, source={source.width}x{source.height}"
)
return (_pil_to_tensor(canvas), slot_count, used_count, "\n".join(info_lines))
return (pil_to_tensor(canvas), slot_count, used_count, "\n".join(info_lines))
NODE_CLASS_MAPPINGS = {
+214
View File
@@ -0,0 +1,214 @@
"""Image Label (NH): a text band above, below or over an image."""
import functools
import os
from pathlib import Path
import torch
from PIL import Image, ImageDraw, ImageFont
from ...core.utils import tensor_to_pil
from ...core.image_ops import RESAMPLING, parse_color, pil_to_image_tensor
_FONT_EXTENSIONS = {".ttf", ".otf", ".ttc"}
def _font_choices():
fonts = ["default"]
seen = set(fonts)
font_dir = Path(os.environ.get("WINDIR", "C:\\Windows")) / "Fonts"
if font_dir.exists():
for path in sorted(font_dir.iterdir(), key=lambda item: item.name.lower()):
if path.is_file() and path.suffix.lower() in _FONT_EXTENSIONS and path.name not in seen:
fonts.append(path.name)
seen.add(path.name)
return fonts
_FONT_CHOICES = _font_choices()
@functools.lru_cache(maxsize=64)
def _cached_truetype(path, size):
"""Open a TrueType font once per (path, size), as the Label node asks for many sizes. None if it cannot be opened."""
try:
return ImageFont.truetype(path, size)
except Exception:
return None
@functools.lru_cache(maxsize=1)
def _default_font():
return ImageFont.load_default()
def _load_font(font_name, font_size):
if font_name and font_name != "default":
font_path = Path(os.environ.get("WINDIR", "C:\\Windows")) / "Fonts" / font_name
if font_path.exists():
font = _cached_truetype(str(font_path), font_size)
if font is not None:
return font
font = _cached_truetype("arial.ttf", font_size)
return font if font is not None else _default_font()
_MIN_FIT_FONT_SIZE = 8
def _fit_font_size(font_size, fits):
"""Largest size in [8, font_size] for which ``fits(size)`` is true; 8 when none does.
A label gets wider as the font grows, so a binary search lands on the same size as shrinking one point at a
time, after about log2(font_size) measurements instead of up to font_size. Hinting can make a text a pixel or
two narrower at a few isolated sizes; then the size returned still fits but may sit a point or two below what
the one-point-at-a-time loop would have found.
"""
if font_size <= _MIN_FIT_FONT_SIZE or fits(font_size):
return font_size
low, high = _MIN_FIT_FONT_SIZE, font_size # `low` fits (or is the floor), `high` does not
while high - low > 1:
middle = (low + high) // 2
if fits(middle):
low = middle
else:
high = middle
return low
def _label_text_width(draw, text, font_name, font_size, stroke_width):
left, _top, right, _bottom = draw.multiline_textbbox(
(0, 0), text, font=_load_font(font_name, font_size), stroke_width=stroke_width
)
return right - left
class NH_ImageLabel:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"label_text": ("STRING", {"default": "NH Label", "multiline": True}),
"position": (["header", "bottom"],),
"frame_height": ("INT", {"default": 80, "min": 1, "max": 4096, "step": 1}),
"fill_enabled": ("BOOLEAN", {"default": True}),
"frame_color_hex": ("STRING", {"default": "#111111", "multiline": False}),
"text_color_hex": ("STRING", {"default": "#FFFFFF", "multiline": False}),
"font_name": (_FONT_CHOICES,),
"font_size": ("INT", {"default": 32, "min": 6, "max": 512, "step": 1}),
"fit_text": ("BOOLEAN", {"default": True}),
"overlay_on_image": ("BOOLEAN", {"default": False}),
"align": (["left", "center", "right"],),
"padding_x": ("INT", {"default": 20, "min": 0, "max": 2048, "step": 1}),
"outline_width": ("INT", {"default": 0, "min": 0, "max": 32, "step": 1}),
"outline_color_hex": ("STRING", {"default": "#000000", "multiline": False}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "add_label"
CATEGORY = "NH-Nodes/Image"
def _band_image(self, image, position, frame_height, fill_enabled, frame_color):
width, height = image.size
if fill_enabled:
return Image.new("RGB", (width, frame_height), frame_color)
sample_box = (0, 0, width, 1) if position == "header" else (0, max(0, height - 1), width, height)
return image.crop(sample_box).resize((width, frame_height), RESAMPLING.BILINEAR).convert("RGB")
def add_label(
self,
image,
label_text,
position,
frame_height,
fill_enabled,
frame_color_hex,
text_color_hex,
font_name,
font_size,
fit_text,
overlay_on_image,
align,
padding_x,
outline_width,
outline_color_hex,
):
frame_color = parse_color(frame_color_hex, (17, 17, 17))
text_color = parse_color(text_color_hex, (255, 255, 255))
outline_color = parse_color(outline_color_hex, (0, 0, 0))
labeled = []
for item in image:
pil_image = tensor_to_pil(item, rgb=True)
width, height = pil_image.size
band = self._band_image(pil_image, position, frame_height, fill_enabled, frame_color)
if overlay_on_image:
canvas = pil_image.copy()
max_band_height = min(frame_height, height)
if band.height != max_band_height:
band = band.resize((width, max_band_height), RESAMPLING.BILINEAR)
if position == "header":
band_top = 0
else:
band_top = height - max_band_height
canvas.paste(band, (0, band_top))
else:
canvas = Image.new("RGB", (width, height + frame_height))
if position == "header":
canvas.paste(band, (0, 0))
canvas.paste(pil_image, (0, frame_height))
band_top = 0
else:
canvas.paste(pil_image, (0, 0))
canvas.paste(band, (0, height))
band_top = height
draw = ImageDraw.Draw(canvas)
current_size = font_size
if fit_text:
max_width = max(1, width - (padding_x * 2))
current_size = _fit_font_size(
font_size,
lambda size: _label_text_width(draw, label_text, font_name, size, outline_width) <= max_width,
)
font = _load_font(font_name, current_size)
bbox = draw.multiline_textbbox((0, 0), label_text, font=font, stroke_width=outline_width)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
if align == "left":
text_x = padding_x
elif align == "right":
text_x = max(padding_x, width - padding_x - text_width)
else:
text_x = max(0, (width - text_width) // 2)
text_y = band_top + max(0, (frame_height - text_height) // 2) - bbox[1]
draw.multiline_text(
(text_x, text_y),
label_text,
font=font,
fill=text_color,
align=align,
stroke_width=outline_width,
stroke_fill=outline_color,
)
labeled.append(pil_to_image_tensor(canvas))
return (torch.cat(labeled, dim=0),)
NODE_CLASS_MAPPINGS = {
"NH_ImageLabel": NH_ImageLabel,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_ImageLabel": "Image Label (NH)",
}
+123
View File
@@ -0,0 +1,123 @@
"""Image Resize Unit (NH): resize to a size given in pixels, millimetres or centimetres at a DPI."""
import torch
import comfy.utils
from ...core.image_ops import parse_color
def _image_resize_methods():
return ["nearest", "bilinear", "bicubic", "area", "nearest-exact", "lanczos"]
def _resize_value_to_pixels(value, unit, dpi):
if unit == "pixel":
return max(0, int(round(value)))
divisor = 25.4 if unit == "mm" else 2.54
return max(0, int(round((value / divisor) * dpi)))
def _resolve_target_size(orig_width, orig_height, width_value, height_value, unit, dpi):
width_px = _resize_value_to_pixels(width_value, unit, dpi)
height_px = _resize_value_to_pixels(height_value, unit, dpi)
if width_px == 0 and height_px == 0:
return orig_width, orig_height
if width_px == 0:
width_px = max(1, round(orig_width * height_px / orig_height))
elif height_px == 0:
height_px = max(1, round(orig_height * width_px / orig_width))
return width_px, height_px
def _common_upscale_method(resize_method):
return "nearest-exact" if resize_method == "nearest" else resize_method
def _pad_samples(samples, target_width, target_height, fill_rgb):
"""Centre ``samples`` ([B, C, H, W]) on a canvas of the padding colour.
``fill_rgb`` is (r, g, b) or (r, g, b, a) in 0-255. The fourth channel of RGBA images takes the alpha value, which
is opaque when the colour has none.
"""
batch, channels, resized_h, resized_w = samples.shape
canvas = torch.empty((batch, channels, target_height, target_width), dtype=samples.dtype, device=samples.device)
fill_values = [channel / 255.0 for channel in fill_rgb[:3]] + [fill_rgb[3] / 255.0 if len(fill_rgb) > 3 else 1.0]
for channel in range(channels):
canvas[:, channel, :, :] = fill_values[min(channel, 3)]
offset_x = max(0, (target_width - resized_w) // 2)
offset_y = max(0, (target_height - resized_h) // 2)
canvas[:, :, offset_y:offset_y + resized_h, offset_x:offset_x + resized_w] = samples
return canvas
class NH_ImageResizeByUnit:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"unit": (["pixel", "mm", "cm"],),
"width_value": ("FLOAT", {"default": 1024.0, "min": 0.0, "max": 100000.0, "step": 0.1}),
"height_value": ("FLOAT", {"default": 1024.0, "min": 0.0, "max": 100000.0, "step": 0.1}),
"dpi": ("INT", {"default": 300, "min": 1, "max": 2400, "step": 1}),
"resize_mode": (["stretch", "pad", "crop", "lock_ratio"],),
"resize_method": (_image_resize_methods(),),
"pad_color_hex": ("STRING", {"default": "#000000", "multiline": False}),
}
}
RETURN_TYPES = ("IMAGE", "INT", "INT")
RETURN_NAMES = ("image", "width_px", "height_px")
FUNCTION = "resize"
CATEGORY = "NH-Nodes/Image"
def resize(self, image, unit, width_value, height_value, dpi, resize_mode, resize_method, pad_color_hex):
samples = image.movedim(-1, 1)
orig_height = samples.shape[-2]
orig_width = samples.shape[-1]
target_width, target_height = _resolve_target_size(
orig_width, orig_height, width_value, height_value, unit, dpi
)
upscale_method = _common_upscale_method(resize_method)
if resize_mode == "lock_ratio":
if width_value <= 0 and height_value <= 0:
result = samples
actual_width, actual_height = orig_width, orig_height
elif width_value <= 0 or height_value <= 0:
actual_width, actual_height = target_width, target_height
result = comfy.utils.common_upscale(samples, actual_width, actual_height, upscale_method, "disabled")
else:
scale = min(target_width / orig_width, target_height / orig_height)
actual_width = max(1, round(orig_width * scale))
actual_height = max(1, round(orig_height * scale))
result = comfy.utils.common_upscale(samples, actual_width, actual_height, upscale_method, "disabled")
elif resize_mode == "crop":
actual_width, actual_height = target_width, target_height
result = comfy.utils.common_upscale(samples, actual_width, actual_height, upscale_method, "center")
elif resize_mode == "pad":
actual_width, actual_height = target_width, target_height
scale = min(target_width / orig_width, target_height / orig_height)
scaled_width = max(1, round(orig_width * scale))
scaled_height = max(1, round(orig_height * scale))
resized = comfy.utils.common_upscale(samples, scaled_width, scaled_height, upscale_method, "disabled")
pad_color = parse_color(pad_color_hex, (0, 0, 0))
result = _pad_samples(resized, target_width, target_height, pad_color)
else:
actual_width, actual_height = target_width, target_height
result = comfy.utils.common_upscale(samples, actual_width, actual_height, upscale_method, "disabled")
return (result.movedim(1, -1), actual_width, actual_height)
NODE_CLASS_MAPPINGS = {
"NH_ImageResizeByUnit": NH_ImageResizeByUnit,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_ImageResizeByUnit": "Image Resize Unit (NH)",
}
File diff suppressed because it is too large Load Diff
+3 -11
View File
@@ -4,11 +4,11 @@ import json
import os
import folder_paths
import numpy as np
from comfy.cli_args import args
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from ...core.utils import tensor_to_pil
# Cạnh dài nhất (px) mà từng định dạng ghi được; PNG/TIFF không bị giới hạn trong thực tế.
# JPEG: spec cho tới 65535 nhưng libjpeg (Pillow) từ chối > 65500 (JPEG_MAX_DIMENSION).
_MAX_SIDE_BY_EXTENSION = {"webp": 16383, "jpg": 65500}
@@ -34,14 +34,6 @@ def _extension_for_save_format(file_format):
return "png"
def _tensor_to_pil(image_tensor):
array = image_tensor.detach().cpu().numpy()
array = np.clip(array * 255.0, 0, 255).astype(np.uint8)
if array.ndim == 3 and array.shape[-1] == 4:
return Image.fromarray(array).convert("RGBA")
return Image.fromarray(array).convert("RGB")
def _metadata_pnginfo(prompt=None, extra_pnginfo=None):
metadata = PngInfo()
if prompt is not None:
@@ -116,7 +108,7 @@ class NH_SaveLargeImage:
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
file_name = f"{filename_with_batch_num}_{counter:05}_.{extension}"
file_path = os.path.join(full_output_folder, file_name)
pil_image = _tensor_to_pil(image_tensor)
pil_image = tensor_to_pil(image_tensor, rgb=True, keep_alpha=True)
_save_image(pil_image, file_path, extension, quality, embed_metadata, prompt, extra_pnginfo)
saved_paths.append(file_path)
counter += 1
+426
View File
@@ -0,0 +1,426 @@
"""Layer Layout Composite (NH) and Layer Stack Composite (NH): paste images onto a canvas."""
import re
from PIL import Image
from ...core.image_ops import parse_color, pil_to_image_tensor, resize_pil
from ...core.safe_eval import evaluate
from ...core.utils import tensor_to_pil
_MAX_LAYER_STACK_INPUTS = 64
def _resize_cover(image, target_size):
target_width, target_height = target_size
width, height = image.size
scale = max(target_width / max(width, 1), target_height / max(height, 1))
resized = resize_pil(image, (max(1, round(width * scale)), max(1, round(height * scale))), "lanczos")
left = max(0, (resized.width - target_width) // 2)
top = max(0, (resized.height - target_height) // 2)
return resized.crop((left, top, left + target_width, top + target_height))
def _resize_contain(image, target_size):
target_width, target_height = target_size
width, height = image.size
scale = min(target_width / max(width, 1), target_height / max(height, 1))
return resize_pil(image, (max(1, round(width * scale)), max(1, round(height * scale))), "lanczos")
def _paste_layer(canvas, layer, position):
"""Paste ``layer`` onto ``canvas`` through its own alpha, so transparent areas leave the canvas untouched."""
if layer.mode != "RGBA":
canvas.paste(layer, position) # opaque, whatever the canvas mode
elif canvas.mode == "RGBA":
# Both have alpha: a straight paste would blend the alpha channels as if they were colours.
box = (position[0], position[1], position[0] + layer.width, position[1] + layer.height)
canvas.paste(Image.alpha_composite(canvas.crop(box), layer), position)
else:
canvas.paste(layer, position, layer)
def _fit_layer_image(image, target_size, resize_mode):
target_width, target_height = target_size
if target_width <= 0 or target_height <= 0:
raise ValueError("Layer slot width and height must be greater than 0")
if resize_mode == "stretch":
return resize_pil(image, (target_width, target_height), "lanczos"), 0, 0
if resize_mode == "contain":
fitted = _resize_contain(image, (target_width, target_height))
return fitted, (target_width - fitted.width) // 2, (target_height - fitted.height) // 2
fitted = _resize_cover(image, (target_width, target_height))
return fitted, 0, 0
def _grid_slots(canvas_width, canvas_height, columns, rows, margin_x, margin_y, gap_x, gap_y):
columns = max(1, int(columns))
rows = max(1, int(rows))
content_width = max(1, canvas_width - (2 * margin_x) - (gap_x * (columns - 1)))
content_height = max(1, canvas_height - (2 * margin_y) - (gap_y * (rows - 1)))
cell_width = max(1, content_width // columns)
cell_height = max(1, content_height // rows)
slots = []
right_edge = canvas_width - margin_x
bottom_edge = canvas_height - margin_y
for row in range(rows):
for column in range(columns):
x = margin_x + column * (cell_width + gap_x)
y = margin_y + row * (cell_height + gap_y)
width = max(1, (right_edge - x) if column == columns - 1 else cell_width)
height = max(1, (bottom_edge - y) if row == rows - 1 else cell_height)
slots.append((x, y, width, height))
return slots
def _relative_slot(canvas_width, canvas_height, x, y, width, height):
return (
int(round(canvas_width * x)),
int(round(canvas_height * y)),
int(round(canvas_width * width)),
int(round(canvas_height * height)),
)
def _preset_layout_slots(preset, canvas_width, canvas_height, margin_x, margin_y, gap_x, gap_y):
content_x = margin_x
content_y = margin_y
content_width = max(1, canvas_width - (2 * margin_x))
content_height = max(1, canvas_height - (2 * margin_y))
if preset == "1_full":
return [(content_x, content_y, content_width, content_height)]
if preset == "2_vertical":
return _grid_slots(canvas_width, canvas_height, 1, 2, margin_x, margin_y, gap_x, gap_y)
if preset == "2_horizontal":
return _grid_slots(canvas_width, canvas_height, 2, 1, margin_x, margin_y, gap_x, gap_y)
if preset == "3_vertical":
return _grid_slots(canvas_width, canvas_height, 1, 3, margin_x, margin_y, gap_x, gap_y)
if preset == "4_grid":
return _grid_slots(canvas_width, canvas_height, 2, 2, margin_x, margin_y, gap_x, gap_y)
if preset == "2_top_1_bottom":
top_height = max(1, round((content_height - gap_y) * 0.42))
bottom_y = content_y + top_height + gap_y
bottom_height = max(1, content_y + content_height - bottom_y)
half_width = max(1, (content_width - gap_x) // 2)
return [
(content_x, content_y, half_width, top_height),
(content_x + half_width + gap_x, content_y, max(1, content_width - half_width - gap_x), top_height),
(content_x, bottom_y, content_width, bottom_height),
]
if preset == "2x3_mixed":
row_heights = [0.20, 0.20, 0.44]
usable_height = max(1, content_height - (2 * gap_y))
heights = [max(1, round(usable_height * ratio / sum(row_heights))) for ratio in row_heights]
heights[-1] = max(1, content_height - (2 * gap_y) - heights[0] - heights[1])
half_width = max(1, (content_width - gap_x) // 2)
slots = []
y = content_y
for height in heights:
slots.append((content_x, y, half_width, height))
slots.append((content_x + half_width + gap_x, y, max(1, content_width - half_width - gap_x), height))
y += height + gap_y
return slots
return _grid_slots(canvas_width, canvas_height, 2, 3, margin_x, margin_y, gap_x, gap_y)
def _parse_custom_layout(custom_layout, canvas_width, canvas_height, custom_units):
slots = []
for raw_line in str(custom_layout or "").splitlines():
line = raw_line.split("#", 1)[0].strip()
if not line:
continue
values = [float(value) for value in re.split(r"[\s,;]+", line) if value.strip()]
if len(values) < 4:
raise ValueError(f"Invalid custom layout line: {raw_line}")
if len(values) >= 5:
values = values[-4:]
x, y, width, height = values[:4]
units = custom_units
if units == "auto":
units = "relative" if max(abs(x), abs(y), abs(width), abs(height)) <= 1.0 else "pixels"
if units == "relative":
slot = _relative_slot(canvas_width, canvas_height, x, y, width, height)
elif units == "percent":
slot = _relative_slot(canvas_width, canvas_height, x / 100.0, y / 100.0, width / 100.0, height / 100.0)
else:
slot = (int(round(x)), int(round(y)), int(round(width)), int(round(height)))
if slot[2] <= 0 or slot[3] <= 0:
raise ValueError(f"Custom layout slot size must be greater than 0: {raw_line}")
slots.append(slot)
return slots
def _layout_slots(layout_mode, layout_preset, canvas_width, canvas_height, columns, rows, margin_x, margin_y, gap_x, gap_y, custom_layout, custom_units):
if layout_mode == "custom":
slots = _parse_custom_layout(custom_layout, canvas_width, canvas_height, custom_units)
if not slots:
raise ValueError("Custom layout is empty")
return slots
if layout_mode == "grid":
return _grid_slots(canvas_width, canvas_height, columns, rows, margin_x, margin_y, gap_x, gap_y)
return _preset_layout_slots(layout_preset, canvas_width, canvas_height, margin_x, margin_y, gap_x, gap_y)
def _eval_position_expr(expression, variables):
# Variable keys are already casefolded. core.safe_eval works in floating point, so 9**9**9 overflows into an
# error instead of building a huge integer and freezing the worker.
try:
return evaluate(expression, variables, zero_division="error", case_insensitive=True)
except ValueError as exc: # core.safe_eval.ExpressionError is a ValueError
raise ValueError(f"Invalid position expression '{expression}': {exc}") from exc
def _split_position_line(line):
parts = [part.strip() for part in re.split(r"[,;]+", line, maxsplit=1)]
if len(parts) < 2 or not parts[0] or not parts[1]:
raise ValueError(f"Invalid position line: {line}")
return parts[0], parts[1]
def _parse_layer_positions(positions, count, background_width=0, background_height=0, layer_sizes=None):
variables = {
"a": int(background_width),
"b": int(background_height),
"bg_w": int(background_width),
"bg_h": int(background_height),
"width": int(background_width),
"height": int(background_height),
}
for index, (layer_width, layer_height) in enumerate(layer_sizes or [], start=1):
variables[f"w{index}"] = int(layer_width)
variables[f"h{index}"] = int(layer_height)
parsed = []
for raw_line in str(positions or "").splitlines():
line = raw_line.split("#", 1)[0].strip()
if not line:
continue
x_expr, y_expr = _split_position_line(line)
x = int(round(_eval_position_expr(x_expr, variables)))
y = int(round(_eval_position_expr(y_expr, variables)))
parsed.append((x, y))
index = len(parsed)
variables[f"x{index}"] = x
variables[f"y{index}"] = y
while len(parsed) < count:
parsed.append((0, 0))
return parsed[:count]
class NH_LayerLayoutComposite:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"canvas_width": ("INT", {"default": 2480, "min": 1, "max": 20000, "step": 1}),
"canvas_height": ("INT", {"default": 3508, "min": 1, "max": 20000, "step": 1}),
"background_color": ("STRING", {"default": "#FFFFFF", "multiline": False}),
"layout_mode": (["preset", "grid", "custom"],),
"layout_preset": (["2x3", "2x3_mixed", "2_top_1_bottom", "1_full", "2_vertical", "2_horizontal", "3_vertical", "4_grid"],),
"resize_mode": (["cover", "contain", "stretch"],),
"columns": ("INT", {"default": 2, "min": 1, "max": 20, "step": 1}),
"rows": ("INT", {"default": 3, "min": 1, "max": 20, "step": 1}),
"margin_x": ("INT", {"default": 150, "min": 0, "max": 10000, "step": 1}),
"margin_y": ("INT", {"default": 150, "min": 0, "max": 10000, "step": 1}),
"gap_x": ("INT", {"default": 150, "min": 0, "max": 10000, "step": 1}),
"gap_y": ("INT", {"default": 150, "min": 0, "max": 10000, "step": 1}),
"custom_units": (["auto", "relative", "percent", "pixels"],),
"custom_layout": ("STRING", {
"default": "0.06,0.04,0.39,0.20\n0.55,0.04,0.39,0.20\n0.06,0.28,0.39,0.20\n0.55,0.28,0.39,0.20\n0.06,0.58,0.39,0.36\n0.55,0.58,0.39,0.36",
"multiline": True,
}),
"background_fit": (["stretch", "cover", "contain"],),
},
"optional": {
"background_image": ("IMAGE",),
},
}
RETURN_TYPES = ("IMAGE", "INT", "INT", "STRING")
RETURN_NAMES = ("image", "slot_count", "used_count", "layout_info")
FUNCTION = "composite"
CATEGORY = "NH-Nodes/Image"
@staticmethod
def _paste_clipped(canvas, layer, x, y):
left = max(0, int(x))
top = max(0, int(y))
right = min(canvas.width, int(x) + layer.width)
bottom = min(canvas.height, int(y) + layer.height)
if right <= left or bottom <= top:
return
crop_box = (left - int(x), top - int(y), right - int(x), bottom - int(y))
_paste_layer(canvas, layer.crop(crop_box), (left, top))
def _create_canvas(self, canvas_width, canvas_height, background_color, background_fit, background_image=None):
fill = parse_color(background_color, (255, 255, 255))
# An opaque colour gives the RGB canvas (and RGB output) this node always had; a colour with transparency
# ('#RRGGBBAA' with AA below FF) gives an RGBA canvas so that the transparency survives.
canvas = Image.new("RGBA" if fill[3] < 255 else "RGB", (canvas_width, canvas_height), fill)
if background_image is None:
return canvas
background = tensor_to_pil(background_image[0], rgb=True, keep_alpha=True)
fitted, offset_x, offset_y = _fit_layer_image(background, (canvas_width, canvas_height), background_fit)
self._paste_clipped(canvas, fitted, offset_x, offset_y)
return canvas
def composite(
self,
images,
canvas_width,
canvas_height,
background_color,
layout_mode,
layout_preset,
resize_mode,
columns,
rows,
margin_x,
margin_y,
gap_x,
gap_y,
custom_units,
custom_layout,
background_fit,
background_image=None,
):
canvas_width = int(canvas_width)
canvas_height = int(canvas_height)
slots = _layout_slots(
layout_mode,
layout_preset,
canvas_width,
canvas_height,
int(columns),
int(rows),
int(margin_x),
int(margin_y),
int(gap_x),
int(gap_y),
custom_layout,
custom_units,
)
canvas = self._create_canvas(canvas_width, canvas_height, background_color, background_fit, background_image)
used_count = min(int(images.shape[0]), len(slots))
info_lines = []
for image_index in range(used_count):
x, y, width, height = slots[image_index]
layer = tensor_to_pil(images[image_index], rgb=True, keep_alpha=True)
fitted, offset_x, offset_y = _fit_layer_image(layer, (int(width), int(height)), resize_mode)
paste_x = int(x) + offset_x
paste_y = int(y) + offset_y
self._paste_clipped(canvas, fitted, paste_x, paste_y)
info_lines.append(
f"{image_index}: x={int(x)}, y={int(y)}, w={int(width)}, h={int(height)}, mode={resize_mode}"
)
return (pil_to_image_tensor(canvas), len(slots), used_count, "\n".join(info_lines))
class NH_LayerStackComposite:
@classmethod
def INPUT_TYPES(cls):
optional_inputs = {
f"layer_{index}": ("IMAGE",)
for index in range(1, _MAX_LAYER_STACK_INPUTS + 1)
}
return {
"required": {
"background_image": ("IMAGE",),
"layer_count": ("INT", {"default": 1, "min": 0, "max": _MAX_LAYER_STACK_INPUTS, "step": 1}),
"positions": ("STRING", {
"default": "0,0",
"multiline": True,
}),
},
"optional": optional_inputs,
}
RETURN_TYPES = ("IMAGE", "INT", "STRING")
RETURN_NAMES = ("image", "used_count", "layer_info")
FUNCTION = "composite"
CATEGORY = "NH-Nodes/Image"
@staticmethod
def _paste_clipped(canvas, layer, x, y):
x = int(x)
y = int(y)
left = max(0, x)
top = max(0, y)
right = min(canvas.width, x + layer.width)
bottom = min(canvas.height, y + layer.height)
if right <= left or bottom <= top:
return False
crop_box = (left - x, top - y, right - x, bottom - y)
_paste_layer(canvas, layer.crop(crop_box), (left, top))
return True
def composite(self, background_image, layer_count, positions, **kwargs):
# The output has the channels of the background: RGB stays RGB, an RGBA background gives RGBA.
background = tensor_to_pil(background_image[0], rgb=True, keep_alpha=True)
canvas = background.copy()
requested_count = max(0, min(int(layer_count), _MAX_LAYER_STACK_INPUTS))
layer_images = {}
layer_sizes = []
for index in range(1, requested_count + 1):
layer = kwargs.get(f"layer_{index}")
if layer is None:
layer_sizes.append((0, 0))
continue
layer_image = tensor_to_pil(layer[0], rgb=True, keep_alpha=True)
layer_images[index] = layer_image
layer_sizes.append((layer_image.width, layer_image.height))
layer_positions = _parse_layer_positions(
positions,
requested_count,
background.width,
background.height,
layer_sizes=layer_sizes,
)
used_count = 0
info_lines = []
for index in range(1, requested_count + 1):
layer_image = layer_images.get(index)
if layer_image is None:
info_lines.append(f"layer_{index}: missing")
continue
x, y = layer_positions[index - 1]
pasted = self._paste_clipped(canvas, layer_image, x, y)
if pasted:
used_count += 1
info_lines.append(
f"layer_{index}: x={x}, y={y}, w={layer_image.width}, h={layer_image.height}"
)
else:
info_lines.append(
f"layer_{index}: outside canvas x={x}, y={y}, w={layer_image.width}, h={layer_image.height}"
)
return (pil_to_image_tensor(canvas), used_count, "\n".join(info_lines))
NODE_CLASS_MAPPINGS = {
"NH_LayerLayoutComposite": NH_LayerLayoutComposite,
"NH_LayerStackComposite": NH_LayerStackComposite,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_LayerLayoutComposite": "Layer Layout Composite (NH)",
"NH_LayerStackComposite": "Layer Stack Composite (NH)",
}
+5 -4
View File
@@ -2,6 +2,8 @@ import torch
import numpy as np
import cv2
from ...core.mask_ops import mask_bbox
class NH_MaskResizeImage:
"""Resize ảnh theo kích thước target, đảm bảo vùng mask không bị mất.
@@ -54,13 +56,12 @@ class NH_MaskResizeImage:
sh, sw = img_np.shape[:2]
# Tìm bounding box của mask
rows, cols = np.where(mask_np > 0.5)
if len(rows) == 0:
bbox = mask_bbox(mask_np)
if bbox is None:
# Mask rỗng: resize bình thường
return self._simple_resize(img_np, mask_np, tw, th, mode, pad_color)
my1, my2 = int(np.min(rows)), int(np.max(rows))
mx1, mx2 = int(np.min(cols)), int(np.max(cols))
mx1, my1, mx2, my2 = bbox
mw = mx2 - mx1 + 1
mh = my2 - my1 + 1
+197
View File
@@ -0,0 +1,197 @@
"""Save Image Path (NH): numbered image files in any folder, with an optional preview."""
import os
import re
import uuid
import folder_paths
from ...core.utils import tensor_to_pil
_WINDOWS = os.name == "nt"
def _save_dir_from_text(folder_path):
folder_path = (folder_path or "").strip()
if not folder_path:
return folder_paths.get_output_directory()
if os.path.isfile(folder_path):
return os.path.dirname(folder_path)
if os.path.isabs(folder_path):
return folder_path
return os.path.join(folder_paths.get_output_directory(), folder_path)
def _contained_path(base_dir, relative, what):
"""Absolute path of ``relative`` below ``base_dir``; ValueError (naming ``what``) if it would leave ``base_dir``.
Both slash styles count as separators on every OS, so a workflow written on Windows is judged the same elsewhere.
"""
base_dir = os.path.abspath(base_dir)
path = os.path.abspath(os.path.join(base_dir, str(relative).replace("\\", "/")))
try:
inside = os.path.commonpath([base_dir, path]) == base_dir
except ValueError: # another drive or a UNC share
inside = False
if not inside:
raise ValueError(f"{what} must stay inside the target folder: {relative!r}")
return path
def _save_child_dir(save_dir, folder_name):
folder_name = str(folder_name or "").strip()
if not folder_name:
return save_dir
return _contained_path(save_dir, folder_name, "new_folder_name")
def _split_filename_prefix(save_dir, prefix):
"""('sub/dir/', 'name', directory) for a filename_prefix such as 'sub/dir/name'.
Subfolders are allowed, but the folder part must stay inside ``save_dir`` (no '..', no absolute path) and the
file name must be a plain name. Without a separator the name is not looked at by the folder check, and on
Windows a colon turns it into a path: os.path.join(save_dir, 'D:x0001.png') is a file on drive D:, 'C:x' is
relative to the current folder of drive C: (join drops the 'C:', so the reported name is wrong and the
numbering never finds the file again) and 'name:stream' writes an NTFS alternate data stream. A Windows file
name cannot contain a colon anyway, so such a prefix is refused whole, before anything is created.
"""
if _WINDOWS and ":" in prefix:
raise ValueError(
"filename_prefix cannot contain ':' on Windows (it selects a drive or an NTFS stream, "
f"not a file name): {prefix!r}"
)
folder_part, name = re.match(r"(.*[\\/])?([^\\/]*)\Z", prefix, re.DOTALL).groups()
if not folder_part:
return "", name, save_dir
return folder_part, name, _contained_path(save_dir, folder_part, "filename_prefix")
def _save_pil_image(image, save_path, file_format, dpi):
image = image.convert("RGB")
save_kwargs = {"dpi": (dpi, dpi)}
if file_format == "png":
pil_format = "PNG"
save_kwargs["compress_level"] = 4
elif file_format == "jpg":
pil_format = "JPEG"
save_kwargs["quality"] = 95
save_kwargs["subsampling"] = 0
elif file_format == "webp":
pil_format = "WEBP"
save_kwargs["quality"] = 95
save_kwargs["method"] = 6
elif file_format == "avif":
pil_format = "AVIF"
save_kwargs["quality"] = 90
else:
raise ValueError(f"Unsupported format: {file_format}")
try:
image.save(save_path, format=pil_format, **save_kwargs)
except TypeError:
if file_format == "avif" and "dpi" in save_kwargs:
save_kwargs.pop("dpi", None)
image.save(save_path, format=pil_format, **save_kwargs)
print("[NH-Nodes] Save Image: AVIF DPI metadata is not supported by the current Pillow build.")
else:
raise
class NH_SaveImagePath:
OUTPUT_NODE = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"folder_path": ("STRING", {"default": "", "multiline": False}),
"create_new_folder": ("BOOLEAN", {"default": False}),
"new_folder_name": ("STRING", {"default": "", "multiline": False}),
"filename_prefix": ("STRING", {"default": "NH", "multiline": False}),
"number_suffix": ("BOOLEAN", {"default": True}),
"preview_image": ("BOOLEAN", {"default": False}),
"file_format": (["png", "jpg", "webp", "avif"],),
"dpi": ("INT", {"default": 300, "min": 1, "max": 2400, "step": 1}),
}
}
RETURN_TYPES = ("STRING", "STRING", "INT")
RETURN_NAMES = ("filenames", "path", "saved_count")
FUNCTION = "save_images"
CATEGORY = "NH-Nodes/Image"
def save_images(self, images, folder_path, filename_prefix, number_suffix=True, preview_image=False, file_format="png", dpi=300, create_new_folder=False, new_folder_name=""):
save_dir = _save_dir_from_text(folder_path)
if create_new_folder:
save_dir = _save_child_dir(save_dir, new_folder_name)
# A prefix may name subfolders ("renders/img"); they live below save_dir and are where files are counted.
prefix = (filename_prefix or "NH").strip() or "NH"
prefix_folder, prefix_name, target_dir = _split_filename_prefix(save_dir, prefix)
os.makedirs(target_dir, exist_ok=True)
numbered_pattern = re.compile(rf"^{re.escape(prefix_name)}(\d+)\.(?:png|jpe?g|webp|avif)$", re.IGNORECASE)
legacy_numbered_pattern = re.compile(rf"^(?:{re.escape(prefix_name)})?(\d+)\.(?:png|jpe?g|webp|avif)$", re.IGNORECASE)
bare_pattern = re.compile(rf"^{re.escape(prefix_name)}\.(?:png|jpe?g|webp|avif)$", re.IGNORECASE)
used_numbers = set()
bare_exists = False
for name in os.listdir(target_dir):
if bare_pattern.match(name):
bare_exists = True
continue
match = numbered_pattern.match(name)
if match is None and number_suffix:
match = legacy_numbered_pattern.match(name)
if match:
used_numbers.add(int(match.group(1)))
saved_files = []
preview_results = []
preview_dir = folder_paths.get_temp_directory()
next_number = 1
for batch_index, image in enumerate(images):
if number_suffix:
while next_number in used_numbers:
next_number += 1
filename = f"{prefix_name}{next_number:04d}.{file_format}"
used_numbers.add(next_number)
next_number += 1
elif not bare_exists and not used_numbers:
filename = f"{prefix_name}.{file_format}"
bare_exists = True
else:
while next_number in used_numbers:
next_number += 1
filename = f"{prefix_name}{next_number}.{file_format}"
used_numbers.add(next_number)
next_number += 1
pil_image = tensor_to_pil(image, rgb=True)
_save_pil_image(pil_image, os.path.join(target_dir, filename), file_format, int(dpi))
saved_files.append(prefix_folder + filename)
if preview_image:
preview_filename = f"NH_preview_{uuid.uuid4().hex}_{batch_index}.png"
_save_pil_image(pil_image, os.path.join(preview_dir, preview_filename), "png", int(dpi))
preview_results.append({
"filename": preview_filename,
"subfolder": "",
"type": "temp",
})
result = ("\n".join(saved_files), save_dir, len(saved_files))
if preview_image:
return {"ui": {"images": preview_results}, "result": result}
return result
NODE_CLASS_MAPPINGS = {
"NH_SaveImagePath": NH_SaveImagePath,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_SaveImagePath": "Save Image Path (NH)",
}
+6 -21
View File
@@ -1,23 +1,7 @@
import torch
import numpy as np
def get_bounding_box_and_ratio(mask_tensor):
"""Tính toán bounding box và tỷ lệ (width / height) từ một mask tensor."""
m_np = mask_tensor.cpu().numpy()
rows, cols = np.where(m_np > 0.5)
if len(rows) == 0:
return None, 1.0 # Trả về None và tỷ lệ 1:1 nếu mask rỗng
ymin, ymax = np.min(rows), np.max(rows)
xmin, xmax = np.min(cols), np.max(cols)
bbox = (xmin, ymin, xmax, ymax)
width = xmax - xmin + 1
height = ymax - ymin + 1
aspect_ratio = width / height if height > 0 else 1.0
return bbox, aspect_ratio
from ...core.mask_ops import mask_bbox
def _fit_window(start, length, limit):
@@ -110,16 +94,17 @@ class NH_MaskAspectRatioMatch:
adjusted = []
for index in range(masks.shape[0]):
# Batch của mask A ngắn hơn thì lặp lại vòng tròn
bbox_a, _ = get_bounding_box_and_ratio(targets[index % targets.shape[0]])
bbox_b, _ = get_bounding_box_and_ratio(masks[index])
mask_np = masks[index].cpu().numpy()
bbox_b = mask_bbox(mask_np)
if bbox_b is None:
adjusted.append(masks[index]) # Mask B rỗng: giữ nguyên
continue
# Batch của mask A ngắn hơn thì lặp lại vòng tròn
bbox_a = mask_bbox(targets[index % targets.shape[0]].cpu().numpy())
# Mask A rỗng thì coi như tỷ lệ 1:1
target_size = (1, 1) if bbox_a is None else (bbox_a[2] - bbox_a[0] + 1, bbox_a[3] - bbox_a[1] + 1)
output_np = _adjust_mask(masks[index].cpu().numpy(), bbox_b, target_size, mode)
output_np = _adjust_mask(mask_np, bbox_b, target_size, mode)
adjusted.append(torch.from_numpy(output_np).to(masks.device))
return (torch.stack(adjusted),)
+10 -23
View File
@@ -1,4 +1,4 @@
import numpy as np
from ...core.mask_ops import mask_bbox
# File này chứa logic cho node Mask Properties (phiên bản gốc).
@@ -17,32 +17,19 @@ class NH_MaskProperties:
CATEGORY = "NH-Nodes/Mask"
def get_properties(self, mask):
# Chỉ xử lý mask đầu tiên trong batch
m = mask[0]
# Chuyển tensor sang numpy array
m_np = m.cpu().numpy()
# Lấy tọa độ của tất cả các pixel không phải màu đen (giá trị > 0.5)
rows, cols = np.where(m_np > 0.5)
# Chỉ xử lý mask đầu tiên trong batch; hộp bao quanh các pixel > 0.5
bbox = mask_bbox(mask[0].cpu().numpy())
# Xử lý trường hợp mask rỗng (toàn màu đen)
if len(rows) == 0:
if bbox is None:
return (0, 0, 0, 0, 0, 0, (0, 0, 0, 0))
# Tìm các giá trị min và max để xác định hộp bao quanh
x1 = int(np.min(cols))
y1 = int(np.min(rows))
x2 = int(np.max(cols))
y2 = int(np.max(rows))
x1, y1, x2, y2 = bbox
# Tính toán chiều rộng và chiều cao
width = x2 - x1 + 1
height = y2 - y1 + 1
# Tạo bounding box tuple để trả về
bbox = (x1, y1, x2, y2)
# Trả về tất cả các giá trị theo thứ tự đã định nghĩa
return (width, height, x1, y1, x2, y2, bbox)
@@ -54,4 +41,4 @@ NODE_CLASS_MAPPINGS = {
# Tên sẽ hiển thị trong menu của ComfyUI
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_MaskProperties": "Mask Properties (NH)"
}
}
+9 -9
View File
@@ -1,6 +1,8 @@
import torch
import numpy as np
from ...core.mask_ops import mask_bbox
class NH_MaskToBBox:
@classmethod
def INPUT_TYPES(cls):
@@ -22,21 +24,19 @@ class NH_MaskToBBox:
for i in range(batch_size):
single_mask_np = mask[i].cpu().numpy()
rows, cols = np.where(single_mask_np > 0.5)
bbox_mask_np = np.zeros_like(single_mask_np)
if len(rows) > 0 and len(cols) > 0:
ymin, ymax = np.min(rows), np.max(rows)
xmin, xmax = np.min(cols), np.max(cols)
bbox = mask_bbox(single_mask_np)
if bbox is not None:
xmin, ymin, xmax, ymax = bbox
bbox_mask_np[ymin:ymax+1, xmin:xmax+1] = 1.0
output_masks.append(torch.from_numpy(bbox_mask_np))
final_output = torch.stack(output_masks)
return (final_output,)
# Ánh xạ class không đổi
@@ -47,4 +47,4 @@ NODE_CLASS_MAPPINGS = {
# THAY ĐỔI Ở ĐÂY: Đổi tên hiển thị của node
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_MaskToBBox": "NH Create Box Mask"
}
}
+3 -2
View File
@@ -7,7 +7,7 @@ import numpy as np
from PIL import Image, ImageFilter
import os
import folder_paths
from ...core.segformer_backend import download_pinned, make_room_on_gpu, register_model_folder
from ...core.model_files import download_pinned, make_room_on_gpu, register_model_folder
from ...core.utils import resize_image, tensor_to_pil, pil_to_tensor, pil_to_mask
# onnxruntime (via preprocess/) and scikit-image (via utils_mask) are imported
@@ -298,7 +298,8 @@ class NH_VTonUltimateProcessor:
masked_image = Image.composite(final_mask_for_composite.convert("L"), human_img_pil, final_mask_for_composite)
pose_image_final = pose_image_pil.resize(human_img_pil.size, Image.LANCZOS)
parsing_map_colored_pil = Image.fromarray(parsing_map_full_np.astype(np.uint8), 'P')
# No fromarray(mode="P") (deprecated in Pillow 11.3): the uint8 map is read as "L", and putpalette turns it into "P".
parsing_map_colored_pil = Image.fromarray(parsing_map_full_np.astype(np.uint8))
parsing_map_colored_pil.putpalette(LIP_PALETTE)
parsing_map_raw_tensor = torch.from_numpy(parsing_map_full_np.astype(np.float32) / 19.0).unsqueeze(0)
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "nh-nodes"
description = "ComfyUI custom nodes for image workflows, tiling, masks, smart resize, logic routing, prompt and text tools, batch automation, garment/portrait segmentation, and VTON preprocessing."
version = "1.6.0"
version = "1.6.1"
license = { file = "LICENSE" }
dependencies = [
"numpy",
+81 -20
View File
@@ -20,9 +20,12 @@ from PIL import Image
import comfy.cli_args
import comfy.sd
import comfy.utils
import folder_paths
from comfy_execution.graph_utils import ExecutionBlocker
tools = importlib.import_module("NH-Nodes.nodes.image.image_tools_nodes")
folder_loaders = importlib.import_module("NH-Nodes.nodes.image.folder_loaders")
image_folders = importlib.import_module("NH-Nodes.core.image_folders")
layer_composite = importlib.import_module("NH-Nodes.nodes.image.layer_composite")
loaders = importlib.import_module("NH-Nodes.nodes.loaders.loader_index_nodes")
PACK_DIR = Path(__file__).resolve().parent.parent
@@ -40,11 +43,11 @@ def _inside(path, folder):
# --- 1. Layer Stack Composite is a pure function: it must not opt out of ComfyUI's cache ---
def test_layer_stack_has_no_is_changed_so_it_can_be_cached():
assert not hasattr(tools.NH_LayerStackComposite, "IS_CHANGED")
assert not hasattr(layer_composite.NH_LayerStackComposite, "IS_CHANGED")
def test_layer_stack_depends_only_on_its_inputs():
node = tools.NH_LayerStackComposite()
node = layer_composite.NH_LayerStackComposite()
background = torch.zeros(1, 8, 8, 3)
layer = torch.ones(1, 4, 4, 3)
first = node.composite(background, 1, "2,2", layer_1=layer)
@@ -79,11 +82,11 @@ def _args(**overrides):
def _load(**overrides):
return tools.NH_LoadImagesMatching().load_matching(**_args(**overrides))
return folder_loaders.NH_LoadImagesMatching().load_matching(**_args(**overrides))
def _changed(**overrides):
return tools.NH_LoadImagesMatching.IS_CHANGED(**_args(**overrides))
return folder_loaders.NH_LoadImagesMatching.IS_CHANGED(**_args(**overrides))
# The node only reads the names in the source path, never the folder, so tests use one that does not exist. It also
@@ -179,7 +182,7 @@ def test_source_file_names_give_the_match_text_and_the_context(studio):
def test_legacy_person_folder_path_still_names_the_source(studio):
_, _, path, _, found = tools.NH_LoadImagesMatching().load_matching(
_, _, path, _, found = folder_loaders.NH_LoadImagesMatching().load_matching(
**_args(matching_root_path=str(studio.root)), person_folder_path=str(studio.source),
)
assert found and _norm(path) == _norm(studio.root / "Nu" / "Anh")
@@ -253,7 +256,7 @@ def test_the_models_folder_is_never_entered(tmp_path, monkeypatch):
_png(work / "Nam" / "Anh" / "01.png", 10)
models = work / "models"
_png(models / "previews" / "Anh" / "01.png", 99)
monkeypatch.setattr(tools.folder_paths, "models_dir", str(models))
monkeypatch.setattr(folder_paths, "models_dir", str(models))
fs = _Fs(monkeypatch)
source = _png(tmp_path / "src" / "Anh" / "p.png").parent
_, _, path, _, found = _load(source_folder_path=str(source), matching_root_path=str(work))
@@ -265,13 +268,13 @@ def test_collecting_images_skips_the_models_folder(tmp_path, monkeypatch):
_png(tmp_path / "work" / "Anh" / "01.png")
_png(tmp_path / "work" / "models" / "previews" / "02.png")
_png(tmp_path / "work" / "models" / "03.png")
monkeypatch.setattr(tools.folder_paths, "models_dir", str(tmp_path / "work" / "models"))
monkeypatch.setattr(folder_paths, "models_dir", str(tmp_path / "work" / "models"))
fs = _Fs(monkeypatch)
# No pictures directly in "work", so the walk goes down, as it always did - but not into the models folder.
assert tools._collect_image_files(str(tmp_path / "work")) == [os.path.join("Anh", "01.png")]
assert image_folders.collect_image_files(str(tmp_path / "work")) == [os.path.join("Anh", "01.png")]
assert fs.touched(tmp_path / "work" / "models") == []
# A folder inside the models folder, named on purpose, is still read.
assert tools._collect_image_files(str(tmp_path / "work" / "models"), recursive=True) == [
assert image_folders.collect_image_files(str(tmp_path / "work" / "models"), recursive=True) == [
"03.png", os.path.join("previews", "02.png"),
]
@@ -279,7 +282,7 @@ def test_collecting_images_skips_the_models_folder(tmp_path, monkeypatch):
def test_the_models_folder_is_refused_as_a_search_root(tmp_path, monkeypatch):
models = tmp_path / "models"
_png(models / "Anh" / "01.png")
monkeypatch.setattr(tools.folder_paths, "models_dir", str(models))
monkeypatch.setattr(folder_paths, "models_dir", str(models))
with pytest.raises(ValueError, match="models"):
_load(source_folder_path=str(tmp_path / "Anh"), matching_root_path=str(models), match_text="Anh")
@@ -379,7 +382,7 @@ def test_is_changed_with_a_wired_root_cannot_know_what_to_look_at(studio):
def test_is_changed_gives_up_on_a_tree_too_big_to_fingerprint(studio, monkeypatch):
wired = {"source_folder_path": None, "source_file_names": None, "matching_root_path": str(studio.root)}
assert isinstance(_changed(**wired), str)
monkeypatch.setattr(tools, "_SIGNATURE_ENTRY_LIMIT", 3)
monkeypatch.setattr(folder_loaders, "_SIGNATURE_ENTRY_LIMIT", 3)
assert math.isnan(_changed(**wired))
@@ -406,8 +409,9 @@ def _cache_key(monkeypatch, prompt, node_id):
from execution import IsChangedCache
for name, cls in (
("NH_LayerStackComposite", tools.NH_LayerStackComposite),
("NH_LoadImagesMatching", tools.NH_LoadImagesMatching),
("NH_LayerStackComposite", layer_composite.NH_LayerStackComposite),
("NH_LoadImagesMatching", folder_loaders.NH_LoadImagesMatching),
("NH_LoadImagesFromFolder", folder_loaders.NH_LoadImagesFromFolder),
("NH_Text", _Text),
):
monkeypatch.setitem(comfy_nodes.NODE_CLASS_MAPPINGS, name, cls)
@@ -459,7 +463,7 @@ def test_matching_cache_key_does_not_hold_a_miss(studio, monkeypatch):
def _lazy(**overrides):
"""check_lazy_status the way ComfyUI calls it: every setting, and fallback_images None until it has run."""
return tools.NH_LoadImagesMatching().check_lazy_status(**_args(**overrides))
return folder_loaders.NH_LoadImagesMatching().check_lazy_status(**_args(**overrides))
def _where(studio, **overrides):
@@ -467,7 +471,7 @@ def _where(studio, **overrides):
def test_fallback_images_is_the_only_new_input_and_it_is_optional_and_lazy():
spec = tools.NH_LoadImagesMatching.INPUT_TYPES()
spec = folder_loaders.NH_LoadImagesMatching.INPUT_TYPES()
assert list(spec["required"]) == [
"source_folder_path", "matching_root_path", "match_text", "match_mode", "recursive", "index", "step",
"repeat_count", "context_folder_names",
@@ -475,13 +479,13 @@ def test_fallback_images_is_the_only_new_input_and_it_is_optional_and_lazy():
assert list(spec["optional"]) == ["source_file_names", "fallback_images"]
kind, options = spec["optional"]["fallback_images"]
assert kind == "IMAGE" and options["lazy"] is True
assert tools.NH_LoadImagesMatching.RETURN_TYPES == ("IMAGE", "STRING", "STRING", "STRING", "BOOLEAN")
assert tools.NH_LoadImagesMatching.RETURN_NAMES == ("images", "filename", "path", "matched_text", "found")
assert folder_loaders.NH_LoadImagesMatching.RETURN_TYPES == ("IMAGE", "STRING", "STRING", "STRING", "BOOLEAN")
assert folder_loaders.NH_LoadImagesMatching.RETURN_NAMES == ("images", "filename", "path", "matched_text", "found")
def test_description_and_tooltip_send_users_to_fallback_images_instead_of_a_switch():
description = tools.NH_LoadImagesMatching.DESCRIPTION
tooltip = tools.NH_LoadImagesMatching.INPUT_TYPES()["optional"]["fallback_images"][1]["tooltip"]
description = folder_loaders.NH_LoadImagesMatching.DESCRIPTION
tooltip = folder_loaders.NH_LoadImagesMatching.INPUT_TYPES()["optional"]["fallback_images"][1]["tooltip"]
for text in (description, tooltip):
assert "instead of routing images through a switch" in text
assert "fallback_images" in description and "nothing matches" in description and "found" in description
@@ -575,6 +579,63 @@ def test_cache_key_with_a_fallback_wired_is_stable_on_a_hit_and_never_holds_a_mi
assert _cache_key(monkeypatch, miss, "2") != _cache_key(monkeypatch, miss, "2")
# 2g. Load Images Folder (a different node): IS_CHANGED fingerprints the folder it reads, and gives up on an unknown one
def _folder_changed(folder, **overrides):
args = {"folder_path": folder, "index": 0, "step": 1, "seed_mode": "fixed"}
args.update(overrides)
return folder_loaders.NH_LoadImagesFromFolder.IS_CHANGED(**args)
def test_folder_is_changed_follows_the_selected_files_only(tmp_path):
folder = tmp_path / "pics"
_png(folder / "01.png")
_png(folder / "02.png")
before = _folder_changed(str(folder))
assert isinstance(before, str) and before == _folder_changed(str(folder))
_touch(folder / "02.png") # not selected (index 0, step 1)
assert _folder_changed(str(folder)) == before
_touch(folder / "01.png") # selected
assert _folder_changed(str(folder)) != before
assert _folder_changed(str(folder), index=1) != before # another selection is another fingerprint
def test_folder_is_changed_with_a_wired_folder_is_a_constant_not_the_input_folder(tmp_path, monkeypatch):
# ComfyUI passes None for an input that comes from another node, so the folder that will be read is unknown. None
# used to resolve to ComfyUI's input folder: the node hashed that folder and never saw a change in the real one.
inputs = tmp_path / "input"
_png(inputs / "01.png")
monkeypatch.setattr(folder_paths, "get_input_directory", lambda: str(inputs))
input_signature = _folder_changed("") # no text at all still means the input folder
assert isinstance(input_signature, str)
wired = _folder_changed(None)
assert isinstance(wired, str) and wired == _folder_changed(None) and wired != input_signature
def test_folder_cache_key_is_stable_until_a_selected_file_or_the_wired_path_changes(tmp_path, monkeypatch):
folder = tmp_path / "pics"
_png(folder / "01.png")
settings = {
"folder_path": str(folder), "index": 0, "step": 1, "seed_mode": "fixed", "recursive": False,
"filename_extension": True, "path_output": "file_path",
}
typed = {"2": {"class_type": "NH_LoadImagesFromFolder", "inputs": settings}}
first = _cache_key(monkeypatch, typed, "2")
assert first == _cache_key(monkeypatch, typed, "2")
_touch(folder / "01.png")
assert first != _cache_key(monkeypatch, typed, "2")
def wired(path):
return {
"1": {"class_type": "NH_Text", "inputs": {"value": str(path)}},
"2": {"class_type": "NH_LoadImagesFromFolder", "inputs": {**settings, "folder_path": ["1", 0]}},
}
# Same upstream path: cached (nodes downstream are not re-run); another path: runs again.
assert _cache_key(monkeypatch, wired(folder), "2") == _cache_key(monkeypatch, wired(folder), "2")
assert _cache_key(monkeypatch, wired(folder), "2") != _cache_key(monkeypatch, wired(tmp_path / "other"), "2")
# --- 3. Indexed loaders keep what they loaded ---
# ComfyUI's output cache: it holds what a loader node returned until it evicts it. The helpers below file every result
+51
View File
@@ -126,6 +126,57 @@ def test_tensor_to_pil_rejects_batches_with_clear_message():
utils.tensor_to_pil(torch.rand((2, 4, 4, 3)))
def _flat(channels, level=0.5):
"""1x1 image whose channel k holds (10 * (k + 1) + level) / 255, i.e. the byte value 10 * (k + 1)."""
return torch.tensor([[[(10 * (k + 1) + level) / 255.0 for k in range(channels)]]])
@pytest.mark.parametrize("channels,expected", [(1, (10, 10, 10)), (3, (10, 20, 30)), (4, (10, 20, 30))])
def test_tensor_to_pil_rgb_always_gives_rgb(channels, expected):
pil = utils.tensor_to_pil(_flat(channels), rgb=True)
assert pil.mode == "RGB" and pil.getpixel((0, 0)) == expected # grey is expanded, alpha is dropped
@pytest.mark.parametrize(
"channels,mode,expected",
[(1, "RGB", (10, 10, 10)), (3, "RGB", (10, 20, 30)), (4, "RGBA", (10, 20, 30, 40))],
)
def test_tensor_to_pil_keep_alpha_keeps_a_fourth_channel_only(channels, mode, expected):
pil = utils.tensor_to_pil(_flat(channels), rgb=True, keep_alpha=True)
assert pil.mode == mode and pil.getpixel((0, 0)) == expected
def test_tensor_to_pil_two_channel_image_becomes_rgb_whatever_the_flags():
for keep_alpha in (False, True):
assert utils.tensor_to_pil(_flat(2), rgb=True, keep_alpha=keep_alpha).mode == "RGB"
def test_tensor_to_pil_keep_alpha_alone_changes_nothing():
for channels, mode in ((1, "L"), (3, "RGB"), (4, "RGBA")):
assert utils.tensor_to_pil(_flat(channels), keep_alpha=True).mode == mode
def test_tensor_to_pil_flags_are_keyword_only():
with pytest.raises(TypeError):
utils.tensor_to_pil(_flat(3), True)
def test_tensor_to_pil_accepts_a_single_image_batch_with_the_flags():
pil = utils.tensor_to_pil(torch.rand((1, 5, 4, 4)), rgb=True, keep_alpha=True)
assert pil.mode == "RGBA" and pil.size == (4, 5)
# --- pil_to_tensor ---
def test_pil_to_tensor_gives_a_batch_of_one_float32_image():
from PIL import Image
pil = Image.new("RGB", (4, 3), (255, 0, 51))
tensor = utils.pil_to_tensor(pil)
assert tensor.shape == (1, 3, 4, 3) and tensor.dtype == torch.float32
assert torch.allclose(tensor[0, 0, 0], torch.tensor([1.0, 0.0, 51 / 255]))
# --- parse_hex_color ---
@pytest.mark.parametrize(
+2 -2
View File
@@ -26,7 +26,7 @@ import nodes as comfy_nodes
logic_nodes = importlib.import_module("NH-Nodes.nodes.logic.logic_nodes")
math_nodes = importlib.import_module("NH-Nodes.nodes.logic.math_nodes")
tools = importlib.import_module("NH-Nodes.nodes.image.image_tools_nodes")
folder_loaders = importlib.import_module("NH-Nodes.nodes.image.folder_loaders")
loader_nodes = importlib.import_module("NH-Nodes.nodes.loaders.loader_index_nodes")
# The node only reads the names in the source path, never the folder, so the tests use one that does not exist.
@@ -117,7 +117,7 @@ STUBS = {
"NH_AnySwitchBoolean": logic_nodes.NH_AnySwitchBoolean,
"NH_RandomChoice": math_nodes.NH_RandomChoice,
"NH_MathEval": math_nodes.NH_MathEval,
"NH_LoadImagesMatching": tools.NH_LoadImagesMatching,
"NH_LoadImagesMatching": folder_loaders.NH_LoadImagesMatching,
"NH_DiffusionModelIndex": loader_nodes.NH_DiffusionModelIndex,
}
+7 -6
View File
@@ -12,7 +12,8 @@ import torch
from PIL import Image
grid = importlib.import_module("NH-Nodes.nodes.image.image_grid_nodes")
tools = importlib.import_module("NH-Nodes.nodes.image.image_tools_nodes")
save_image_path = importlib.import_module("NH-Nodes.nodes.image.save_image_path")
utils = importlib.import_module("NH-Nodes.core.utils")
GIB = 2**30
@@ -58,7 +59,7 @@ def test_grid_guard_accepts_big_canvases_whatever_pillow_limit_other_packs_set(
def test_grid_estimate_is_the_peak_of_the_canvas_to_tensor_conversion():
# PIL keeps an RGB canvas as 4 bytes per pixel; _pil_to_tensor then holds two float32 RGB arrays (astype, / 255.0).
# PIL keeps an RGB canvas as 4 bytes per pixel; pil_to_tensor then holds two float32 RGB arrays (astype, / 255.0).
pil_rgb, float32_rgb = 4, 3 * 4
peak_per_pixel = pil_rgb + 2 * float32_rgb
@@ -69,7 +70,7 @@ def test_grid_estimate_is_the_peak_of_the_canvas_to_tensor_conversion():
def test_grid_estimate_covers_what_the_node_keeps_alive():
canvas = Image.new("RGB", (64, 48))
tensor = grid._pil_to_tensor(canvas)
tensor = utils.pil_to_tensor(canvas)
held = tensor.numel() * tensor.element_size() + 64 * 48 * 4 # the IMAGE tensor and the PIL canvas
assert grid._estimate_canvas_bytes(64, 48) >= held
@@ -183,20 +184,20 @@ def written(monkeypatch):
"""Record the files Save Image Path would write instead of writing them, so that a prefix that escapes the
target folder cannot leave a stray file on the real disk."""
paths = []
monkeypatch.setattr(tools, "_save_pil_image", lambda image, path, file_format, dpi: paths.append(path))
monkeypatch.setattr(save_image_path, "_save_pil_image", lambda image, path, file_format, dpi: paths.append(path))
return paths
@pytest.fixture
def as_windows(monkeypatch):
"""Judge prefixes by the Windows rules even where the tests run on another OS (a no-op on Windows itself)."""
monkeypatch.setattr(tools, "_WINDOWS", True, raising=False)
monkeypatch.setattr(save_image_path, "_WINDOWS", True, raising=False)
def _save_image_path(tmp_path, prefix, **kwargs):
settings = dict(folder_path=str(tmp_path / "out"), create_new_folder=False, new_folder_name="")
settings.update(kwargs)
return tools.NH_SaveImagePath().save_images(torch.rand((1, 4, 4, 3)), filename_prefix=prefix, **settings)
return save_image_path.NH_SaveImagePath().save_images(torch.rand((1, 4, 4, 3)), filename_prefix=prefix, **settings)
def _png_files_outside_out(tmp_path):
+103
View File
@@ -0,0 +1,103 @@
"""Channel handling of the nodes that turn IMAGE tensors into PIL pictures through core.utils.tensor_to_pil.
NH Save Large Image and the large preview cache keep an alpha channel where the format can hold it and give RGB
otherwise (grey included); Image Grid Composite always builds RGB cells and drops alpha without compositing.
"""
import importlib
import os
import folder_paths
import numpy as np
import pytest
import torch
from PIL import Image
large_save = importlib.import_module("NH-Nodes.nodes.image.large_image_save")
preview_cache = importlib.import_module("NH-Nodes.core.large_preview_cache")
grid = importlib.import_module("NH-Nodes.nodes.image.image_grid_nodes")
def _flat(height, width, channels):
"""[1, H, W, C] image whose channel k holds the byte value 10 * (k + 1); the +0.5 keeps float rounding out of it."""
values = torch.tensor([(10 * (k + 1) + 0.5) / 255.0 for k in range(channels)])
return values.expand(1, height, width, channels).clone()
def _save(tmp_path, image, file_format):
node = large_save.NH_SaveLargeImage()
node.output_dir = str(tmp_path)
return node.save(image, "nh_channels", file_format)["result"][1].split("\n")[0]
# --- NH Save Large Image ---
def test_png_keeps_the_alpha_channel(tmp_path):
with Image.open(_save(tmp_path, _flat(4, 6, 4), "png")) as saved:
assert saved.mode == "RGBA" and saved.getpixel((2, 1)) == (10, 20, 30, 40)
@pytest.mark.parametrize("file_format", ["jpg", "webp"])
def test_formats_that_need_rgb_drop_the_alpha_channel(tmp_path, file_format):
with Image.open(_save(tmp_path, _flat(4, 6, 4), file_format)) as saved:
assert saved.mode == "RGB"
@pytest.mark.parametrize("channels,pixel", [(3, (10, 20, 30)), (1, (10, 10, 10))])
def test_rgb_and_grey_images_are_saved_as_rgb(tmp_path, channels, pixel):
with Image.open(_save(tmp_path, _flat(4, 6, channels), "png")) as saved:
assert saved.mode == "RGB" and saved.getpixel((2, 1)) == pixel
# --- large preview cache ---
@pytest.fixture
def temp_dir(tmp_path, monkeypatch):
monkeypatch.setattr(folder_paths, "temp_directory", str(tmp_path))
return tmp_path
def _thumbnail(manifest):
thumb = manifest["thumb"]
return Image.open(os.path.join(folder_paths.get_temp_directory(), thumb["subfolder"], thumb["filename"]))
@pytest.mark.parametrize("preview_format,mode", [("png", "RGBA"), ("jpg", "RGB"), ("webp", "RGB")])
def test_preview_thumbnail_keeps_alpha_only_in_png(temp_dir, preview_format, mode):
manifest = preview_cache.create_preview_cache(_flat(40, 30, 4)[0], preview_format=preview_format, tile_size=64)
with _thumbnail(manifest) as thumb:
assert thumb.mode == mode and thumb.size == (30, 40)
@pytest.mark.parametrize("shape", [(40, 30, 1), (40, 30, 3), (40, 30, 2), (40, 30)])
def test_preview_accepts_grey_rgb_and_two_dimensional_images(temp_dir, shape):
image = torch.rand(shape)
manifest = preview_cache.create_preview_cache(image, preview_format="png", tile_size=64)
assert (manifest["width"], manifest["height"]) == (30, 40)
with _thumbnail(manifest) as thumb:
assert thumb.mode == "RGB"
def test_preview_rejects_anything_but_a_tensor(temp_dir):
with pytest.raises(TypeError, match="Expected IMAGE tensor, got ndarray"):
preview_cache.create_preview_cache(np.zeros((8, 8, 3), np.float32))
# --- Image Grid Composite ---
def test_grid_cells_are_rgb_whatever_the_input_channels():
transparent = _flat(4, 4, 4)
transparent[..., 3] = 0.0 # fully transparent: the colour channels must survive, not be composited away
grey = _flat(4, 4, 1)
image, _slots, _used, _info = grid.NH_ImageGridComposite().composite(
[transparent, grey], columns=2, rows=1, cell_size_mode="manual", cell_width=4, cell_height=4,
resize_mode="stretch", gap_x=0, gap_y=0, background_color_hex="#000000",
)
assert image.shape == (1, 4, 8, 3)
assert torch.allclose(image[0, 0, 0], torch.tensor([10.0, 20.0, 30.0]) / 255.0)
assert torch.allclose(image[0, 0, 4], torch.tensor([10.0, 10.0, 10.0]) / 255.0)
@@ -20,7 +20,10 @@ import torch
import torch.nn.functional as F
from PIL import Image, ImageDraw, ImageFont
tools = importlib.import_module("NH-Nodes.nodes.image.image_tools_nodes")
image_label = importlib.import_module("NH-Nodes.nodes.image.image_label")
layer_composite = importlib.import_module("NH-Nodes.nodes.image.layer_composite")
resize_unit = importlib.import_module("NH-Nodes.nodes.image.image_resize_unit")
save_image_path = importlib.import_module("NH-Nodes.nodes.image.save_image_path")
picker = importlib.import_module("NH-Nodes.nodes.resolution.smart_resolution_picker")
restore = importlib.import_module("NH-Nodes.nodes.resolution.restore_image_geometry")
resolution_data = importlib.import_module("NH-Nodes.core.resolution_data")
@@ -62,7 +65,7 @@ def _abort_if_frozen(seconds):
[("#000000", 1.0), ("#FFFFFF", 1.0), ("#336699", 1.0), ("#336699FF", 1.0), ("#33669900", 0.0), ("#33669980", 128 / 255)],
)
def test_resize_unit_pad_alpha_follows_the_hex_alpha(color, alpha):
out, width, height = tools.NH_ImageResizeByUnit().resize(
out, width, height = resize_unit.NH_ImageResizeByUnit().resize(
_rgba(4, 4, (1.0, 1.0, 1.0, 1.0)), "pixel", 8, 4, 300, "pad", "nearest-exact", color
)
assert (width, height) == (8, 4) and out.shape == (1, 4, 8, 4)
@@ -75,7 +78,7 @@ def test_resize_unit_pad_alpha_follows_the_hex_alpha(color, alpha):
def test_resize_unit_pad_keeps_rgb_results():
image = torch.full((1, 4, 4, 3), 0.25)
out, _, _ = tools.NH_ImageResizeByUnit().resize(image, "pixel", 8, 4, 300, "pad", "nearest-exact", "#102030")
out, _, _ = resize_unit.NH_ImageResizeByUnit().resize(image, "pixel", 8, 4, 300, "pad", "nearest-exact", "#102030")
assert out.shape == (1, 4, 8, 3)
expected = torch.tensor([16 / 255.0, 32 / 255.0, 48 / 255.0]).expand(4, 2, 3)
assert torch.equal(out[0, :, :2], expected) and torch.equal(out[0, :, 6:], expected)
@@ -84,15 +87,15 @@ def test_resize_unit_pad_keeps_rgb_results():
def test_pad_samples_alpha_is_opaque_unless_given():
samples = torch.zeros((1, 4, 2, 2))
opaque = tools._pad_samples(samples, 4, 4, (0, 0, 0))
opaque = resize_unit._pad_samples(samples, 4, 4, (0, 0, 0))
assert opaque[0, :, 0, 0].tolist() == [0.0, 0.0, 0.0, 1.0]
translucent = tools._pad_samples(samples, 4, 4, (10, 20, 30, 64))
translucent = resize_unit._pad_samples(samples, 4, 4, (10, 20, 30, 64))
assert translucent[0, 3, 0, 0].item() == pytest.approx(64 / 255)
assert translucent[0, 0, 0, 0].item() == pytest.approx(10 / 255)
def test_pad_color_named_css_colours_still_work():
out, _, _ = tools.NH_ImageResizeByUnit().resize(
out, _, _ = resize_unit.NH_ImageResizeByUnit().resize(
torch.ones((1, 4, 4, 3)), "pixel", 8, 4, 300, "pad", "nearest-exact", "red"
)
assert out[0, 0, 0].tolist() == [1.0, 0.0, 0.0]
@@ -132,7 +135,7 @@ def _alpha_layer(size=4, opaque_rows=2):
def test_stack_composite_rgba_layer_keeps_transparent_areas():
background = torch.full((1, 8, 8, 3), _q(100))
out, used, _ = tools.NH_LayerStackComposite().composite(background, 1, "2,2", layer_1=_alpha_layer())
out, used, _ = layer_composite.NH_LayerStackComposite().composite(background, 1, "2,2", layer_1=_alpha_layer())
assert used == 1 and out.shape == (1, 8, 8, 3) # RGB background -> RGB output, as before
assert _px(out, 2, 2) == [255, 0, 0]
assert _px(out, 3, 5) == [255, 0, 0]
@@ -144,14 +147,14 @@ def test_stack_composite_rgba_layer_keeps_transparent_areas():
def test_stack_composite_blends_semi_transparent_layers():
background = torch.full((1, 4, 4, 3), _q(100))
layer = _rgba(2, 2, (_q(255), _q(0), _q(0), _q(128)))
out, _, _ = tools.NH_LayerStackComposite().composite(background, 1, "0,0", layer_1=layer)
out, _, _ = layer_composite.NH_LayerStackComposite().composite(background, 1, "0,0", layer_1=layer)
assert _close(_px(out, 0, 0), [178, 50, 50]) # 128/255 red over gray 100
def test_stack_composite_rgb_layers_are_unchanged():
background = torch.full((1, 6, 6, 3), _q(100))
layer = torch.full((1, 2, 3, 3), _q(200))
out, used, info = tools.NH_LayerStackComposite().composite(background, 1, "1,2", layer_1=layer)
out, used, info = layer_composite.NH_LayerStackComposite().composite(background, 1, "1,2", layer_1=layer)
assert used == 1 and "layer_1: x=1, y=2, w=3, h=2" in info
assert _px(out, 2, 1) == [200, 200, 200] and _px(out, 3, 3) == [200, 200, 200]
assert _px(out, 1, 1) == [100, 100, 100] and _px(out, 2, 4) == [100, 100, 100]
@@ -160,7 +163,7 @@ def test_stack_composite_rgb_layers_are_unchanged():
def test_stack_composite_rgba_background_gives_rgba_output():
background = _rgba(8, 8, (_q(0), _q(0), _q(0), _q(0))) # fully transparent canvas
layer = _rgba(2, 2, (_q(255), _q(0), _q(0), _q(128)))
out, _, _ = tools.NH_LayerStackComposite().composite(background, 1, "3,3", layer_1=layer)
out, _, _ = layer_composite.NH_LayerStackComposite().composite(background, 1, "3,3", layer_1=layer)
assert out.shape == (1, 8, 8, 4)
assert _close(_px(out, 3, 3), [255, 0, 0, 128]) # straight alpha over transparent keeps the colour
assert _px(out, 0, 0)[3] == 0
@@ -174,7 +177,7 @@ LAYOUT_ARGS = dict(
def _layout(images, **overrides):
return tools.NH_LayerLayoutComposite().composite(images, **{**LAYOUT_ARGS, **overrides})
return layer_composite.NH_LayerLayoutComposite().composite(images, **{**LAYOUT_ARGS, **overrides})
def test_layout_composite_rgba_layer_keeps_transparent_areas():
@@ -220,7 +223,7 @@ def test_layout_composite_rgb_layers_are_unchanged():
def _save(tmp_path, prefix, images=None, **kwargs):
settings = dict(folder_path=str(tmp_path / "out"), create_new_folder=False, new_folder_name="")
settings.update(kwargs)
return tools.NH_SaveImagePath().save_images(
return save_image_path.NH_SaveImagePath().save_images(
torch.rand((1, 4, 4, 3)) if images is None else images, filename_prefix=prefix, **settings
)
@@ -294,7 +297,7 @@ def test_dots_inside_a_name_are_not_an_escape(tmp_path):
# --- 4. Position expressions use the shared safe evaluator ---
def test_position_expressions_keep_their_values():
positions = tools._parse_layer_positions(
positions = layer_composite._parse_layer_positions(
"(bg_w - w1) // 2, BG_H / 2 - h1 / 2 # centred\nx1 + w1 + 10, y1\n\n2 ** 3, -(a - b) % 7",
3, background_width=100, background_height=60, layer_sizes=[(20, 10), (5, 5), (1, 1)],
)
@@ -302,7 +305,7 @@ def test_position_expressions_keep_their_values():
def test_position_positions_fill_missing_lines_with_zero():
assert tools._parse_layer_positions("5,6", 3) == [(5, 6), (0, 0), (0, 0)]
assert layer_composite._parse_layer_positions("5,6", 3) == [(5, 6), (0, 0), (0, 0)]
@pytest.mark.parametrize("expression", ["9**9**9", "10**10**6", "2**2**20", "(9**9)**(9**9)"])
@@ -310,7 +313,7 @@ def test_position_expression_power_bombs_fail_fast(expression):
started = time.perf_counter()
with _abort_if_frozen(60):
with pytest.raises(ValueError):
tools._parse_layer_positions(f"{expression}, 0", 1)
layer_composite._parse_layer_positions(f"{expression}, 0", 1)
assert time.perf_counter() - started < 5
@@ -319,16 +322,16 @@ def test_position_expression_power_bombs_fail_fast(expression):
)
def test_bad_position_expressions_raise_value_error(expression):
with pytest.raises(ValueError):
tools._parse_layer_positions(f"{expression}, 0", 1)
layer_composite._parse_layer_positions(f"{expression}, 0", 1)
def test_position_expression_error_names_the_expression():
with pytest.raises(ValueError, match="1 / 0"):
tools._eval_position_expr("1 / 0", {})
layer_composite._eval_position_expr("1 / 0", {})
def test_module_no_longer_uses_the_removed_ast_num():
assert "ast.Num" not in inspect.getsource(tools)
assert "ast.Num" not in inspect.getsource(layer_composite)
# --- 5. Image Label: cached fonts and a binary-searched fit ---
@@ -347,7 +350,7 @@ def test_fit_font_size_equals_the_linear_search_for_growing_widths(start, per_po
for box in list(range(1, 400, 7)) + [10_000, 100_000]:
def fits(size):
return math.ceil(per_point * size) <= box
assert tools._fit_font_size(start, fits) == _linear_fit(start, fits), (start, per_point, box)
assert image_label._fit_font_size(start, fits) == _linear_fit(start, fits), (start, per_point, box)
def test_fit_font_size_needs_only_a_logarithmic_number_of_probes():
@@ -357,7 +360,7 @@ def test_fit_font_size_needs_only_a_logarithmic_number_of_probes():
probes.append(size)
return size <= 100
assert tools._fit_font_size(512, fits) == 100
assert image_label._fit_font_size(512, fits) == 100
assert len(probes) <= 12 # the linear search needed 412
@@ -375,10 +378,10 @@ def scalable_font(monkeypatch):
loads.append((str(path), size))
return real_truetype(io.BytesIO(font_bytes), size)
cache = getattr(tools, "_cached_truetype", None)
cache = getattr(image_label, "_cached_truetype", None)
if cache is not None:
cache.cache_clear()
monkeypatch.setattr(tools.ImageFont, "truetype", fake_truetype)
monkeypatch.setattr(image_label.ImageFont, "truetype", fake_truetype)
yield loads
if cache is not None:
cache.cache_clear()
@@ -401,7 +404,7 @@ def test_fit_font_size_matches_the_linear_search_with_a_real_font(scalable_font,
for text in LABEL_TEXTS:
widths = {}
for size in range(6, 301):
box = draw.multiline_textbbox((0, 0), text, font=tools._load_font("default", size), stroke_width=stroke)
box = draw.multiline_textbbox((0, 0), text, font=image_label._load_font("default", size), stroke_width=stroke)
widths[size] = box[2] - box[0]
for start in (6, 8, 9, 24, 96, 300):
# Text width is not strictly increasing for every font (hinting makes it dip by a pixel or two now and
@@ -411,7 +414,7 @@ def test_fit_font_size_matches_the_linear_search_with_a_real_font(scalable_font,
def fits(size, box_width=box_width):
return widths[size] <= box_width
expected = _linear_fit(start, fits)
actual = tools._fit_font_size(start, fits)
actual = image_label._fit_font_size(start, fits)
checked += 1
if increasing:
assert actual == expected, (text[:20], start, box_width)
@@ -428,7 +431,7 @@ def test_label_node_fits_text_like_the_old_linear_search_and_loads_each_font_onc
font_name="default", overlay_on_image=False, align="left", padding_x=10, outline_width=1,
outline_color_hex="#000000",
)
node = tools.NH_ImageLabel()
node = image_label.NH_ImageLabel()
fitted, = node.add_label(image, text, font_size=120, fit_text=True, **settings)
loads_when_fitting = len(scalable_font)
@@ -436,7 +439,7 @@ def test_label_node_fits_text_like_the_old_linear_search_and_loads_each_font_onc
draw = ImageDraw.Draw(Image.new("RGB", (8, 8)))
def fits(size):
box = draw.multiline_textbbox((0, 0), text, font=tools._load_font("default", size), stroke_width=1)
box = draw.multiline_textbbox((0, 0), text, font=image_label._load_font("default", size), stroke_width=1)
return box[2] - box[0] <= 600 - 2 * 10
expected = _linear_fit(120, fits)
@@ -448,8 +451,8 @@ def test_label_node_fits_text_like_the_old_linear_search_and_loads_each_font_onc
def test_load_font_returns_the_cached_object(scalable_font):
assert tools._load_font("default", 24) is tools._load_font("default", 24)
assert tools._load_font("default", 24) is not tools._load_font("default", 25)
assert image_label._load_font("default", 24) is image_label._load_font("default", 24)
assert image_label._load_font("default", 24) is not image_label._load_font("default", 25)
assert len(scalable_font) == 2
@@ -463,16 +466,16 @@ def test_missing_fonts_fall_back_to_the_default_font_and_the_failure_is_remember
raise OSError("cannot open resource")
return real_truetype(font, *args, **kwargs)
tools._cached_truetype.cache_clear()
monkeypatch.setattr(tools.ImageFont, "truetype", truetype_without_system_fonts)
image_label._cached_truetype.cache_clear()
monkeypatch.setattr(image_label.ImageFont, "truetype", truetype_without_system_fonts)
try:
for name in ("no-such-font.ttf", "default", "default"):
font = tools._load_font(name, 20)
font = image_label._load_font(name, 20)
assert font.getbbox("Hello")[2] > 0
assert len(attempts) == 1 # arial.ttf is tried once per size, not once per label
finally:
monkeypatch.undo()
tools._cached_truetype.cache_clear()
image_label._cached_truetype.cache_clear()
# --- 6. Remove Resize Padding must not stretch cropped results ---
@@ -1016,6 +1019,7 @@ def layer_stack_js_result(tmp_path_factory):
assert JS_IMPORT in source, "the extension no longer imports app.js the way this test expects"
work = tmp_path_factory.mktemp("layer_stack_js")
(work / "extension.mjs").write_text(source.replace(JS_IMPORT, "const app = globalThis.__app;"), encoding="utf-8")
shutil.copy(PACK_DIR / "web" / "nh_widget_utils.js", work / "nh_widget_utils.js") # the extension imports it
(work / "harness.mjs").write_text(JS_HARNESS, encoding="utf-8")
run = subprocess.run(
[node, str(work / "harness.mjs"), str(work / "extension.mjs")],
+141
View File
@@ -0,0 +1,141 @@
"""core.mask_ops.mask_bbox and the mask nodes built on it (Mask Properties, Create Box Mask)."""
import importlib
import numpy as np
import pytest
import torch
mask_ops = importlib.import_module("NH-Nodes.core.mask_ops")
properties = importlib.import_module("NH-Nodes.nodes.mask.mask_properties")
box_mask = importlib.import_module("NH-Nodes.nodes.mask.mask_to_bbox")
mask_bbox = mask_ops.mask_bbox
def _mask(h, w, *boxes):
"""float32 [H, W] mask; each box is (x1, y1, x2, y2) with an exclusive end."""
mask = np.zeros((h, w), np.float32)
for x1, y1, x2, y2 in boxes:
mask[y1:y2, x1:x2] = 1.0
return mask
# --- mask_bbox ---
def test_bbox_is_inclusive_and_ordered_x1_y1_x2_y2():
assert mask_bbox(_mask(10, 12, (3, 2, 9, 5))) == (3, 2, 8, 4) # width 6, height 3
def test_bbox_covers_several_blobs():
assert mask_bbox(_mask(20, 30, (2, 3, 5, 6), (20, 15, 27, 18))) == (2, 3, 26, 17)
def test_bbox_of_an_empty_mask_is_none():
assert mask_bbox(np.zeros((6, 7), np.float32)) is None
@pytest.mark.parametrize("shape", [(0, 5), (5, 0), (0, 0)])
def test_bbox_of_a_zero_sized_mask_is_none(shape):
assert mask_bbox(np.zeros(shape, np.float32)) is None
def test_bbox_of_a_single_pixel_and_of_a_full_mask():
single = np.zeros((9, 11), np.float32)
single[7, 4] = 1.0
assert mask_bbox(single) == (4, 7, 4, 7)
assert mask_bbox(np.ones((9, 11), np.float32)) == (0, 0, 10, 8)
def test_threshold_is_strict_and_defaults_to_half():
half = np.full((4, 4), 0.5, np.float32)
assert mask_bbox(half) is None # exactly 0.5 is outside, as in every NH mask node
assert mask_bbox(np.full((4, 4), np.nextafter(np.float32(0.5), np.float32(1)), np.float32)) == (0, 0, 3, 3)
soft = _mask(8, 8, (2, 2, 6, 6)) * 0.4
assert mask_bbox(soft) is None
assert mask_bbox(soft, threshold=0.25) == (2, 2, 5, 5)
assert mask_bbox(soft, threshold=0.0) == (2, 2, 5, 5) # "any non-zero pixel"
def test_nan_pixels_are_outside():
mask = _mask(5, 5, (1, 1, 3, 3))
mask[0, 0] = np.nan
assert mask_bbox(mask) == (1, 1, 2, 2)
@pytest.mark.parametrize("shape", [(), (7,), (4, 5, 1), (2, 4, 5)])
def test_bbox_rejects_masks_that_are_not_2d(shape):
with pytest.raises(ValueError, match="2-D"):
mask_bbox(np.ones(shape, np.float32))
def test_bbox_is_four_python_ints():
bbox = mask_bbox(_mask(6, 6, (1, 2, 4, 5)))
assert all(type(value) is int for value in bbox)
@pytest.mark.parametrize("dtype", [np.float16, np.float64, np.uint8, np.bool_])
def test_bbox_accepts_other_array_types(dtype):
mask = _mask(8, 9, (2, 1, 7, 4)).astype(dtype)
if dtype is np.uint8:
mask *= 255
assert mask_bbox(mask) == (2, 1, 6, 3)
def test_bbox_matches_a_brute_force_search():
rng = np.random.default_rng(5)
for _ in range(200):
h, w = rng.integers(1, 30, 2)
mask = (rng.random((h, w)) * (rng.random((h, w)) < rng.choice([0.0, 0.03, 0.4]))).astype(np.float32)
points = [(x, y) for y in range(h) for x in range(w) if mask[y, x] > 0.5]
expected = None if not points else (
min(x for x, _ in points), min(y for _, y in points), max(x for x, _ in points), max(y for _, y in points)
)
assert mask_bbox(mask) == expected
# --- Mask Properties ---
def _properties(mask):
return properties.NH_MaskProperties().get_properties(torch.from_numpy(mask))
def test_properties_report_size_corners_and_bbox():
mask = torch.from_numpy(_mask(10, 12, (3, 2, 9, 5))).unsqueeze(0)
width, height, x1, y1, x2, y2, bbox = properties.NH_MaskProperties().get_properties(mask)
assert (width, height, x1, y1, x2, y2) == (6, 3, 3, 2, 8, 4)
assert bbox == (3, 2, 8, 4)
def test_properties_of_an_empty_mask_are_all_zero():
assert _properties(np.zeros((1, 6, 7), np.float32)) == (0, 0, 0, 0, 0, 0, (0, 0, 0, 0))
def test_properties_only_look_at_the_first_batch_item():
batch = torch.from_numpy(np.stack([_mask(8, 8, (1, 1, 3, 3)), _mask(8, 8, (4, 4, 8, 8))]))
assert _properties(batch.numpy())[:6] == (2, 2, 1, 1, 2, 2)
# --- Create Box Mask ---
def test_box_mask_fills_the_bounding_box_of_every_batch_item():
first = _mask(10, 10, (2, 2, 4, 3), (6, 5, 8, 8))
second = np.zeros((10, 10), np.float32) # empty: stays empty
third = _mask(10, 10, (0, 0, 1, 1))
masks = torch.from_numpy(np.stack([first, second, third]))
(result,) = box_mask.NH_MaskToBBox().create_bbox_mask(masks)
assert result.shape == masks.shape and result.dtype == masks.dtype
assert torch.equal(result[0], torch.from_numpy(_mask(10, 10, (2, 2, 8, 8))))
assert float(result[1].sum()) == 0.0
assert torch.equal(result[2], torch.from_numpy(_mask(10, 10, (0, 0, 1, 1))))
def test_box_mask_ignores_values_at_or_below_half():
soft = torch.from_numpy(_mask(6, 6, (1, 1, 5, 5)) * 0.5).unsqueeze(0)
(result,) = box_mask.NH_MaskToBBox().create_bbox_mask(soft)
assert float(result.sum()) == 0.0
+74
View File
@@ -0,0 +1,74 @@
"""Pillow deprecations on the garment-mask and VTON paths.
Pillow 11.3 deprecated Image.fromarray(array, mode) and warned at every call (12.0 took most of that back and 13.0
only rejects a mode that changes the data type). The code reads uint8 label maps with a plain Image.fromarray(array)
instead, which behaves the same on every Pillow version and stays quiet.
Both tests turn any "will be removed in Pillow N" DeprecationWarning into an error, so a `mode=` that comes back
fails here.
"""
import importlib
import warnings
import numpy as np
import pytest
import torch
from PIL import Image
pytest.importorskip("skimage") # core.utils_mask imports it at module level
utils_mask = importlib.import_module("NH-Nodes.core.utils_mask")
vton = importlib.import_module("NH-Nodes.nodes.vton.vton_preprocessor_nodes")
H, W = 48, 64
@pytest.fixture(autouse=True)
def pillow_deprecations_are_errors():
with warnings.catch_warnings():
warnings.filterwarnings("error", message=r".*removed in Pillow", category=DeprecationWarning)
yield
def _parsing_labels():
labels = np.zeros((H, W), np.uint8)
labels[5:12, 20:40] = 11 # head
labels[12:30, 15:45] = 4 # upper clothes
labels[30:44, 18:42] = 5 # trousers
labels[44:48, 18:42] = 9 # shoes
return labels
@pytest.mark.parametrize("category", ["Upper-body", "Lower-body", "Dresses"])
def test_garment_masks_do_not_trip_a_pillow_deprecation(category):
pose = np.full((134, 2), -1.0, np.float32) # nothing detected: the masks fall back to the garment contours
mask, mask_gray = utils_mask.get_mask_location(category, Image.fromarray(_parsing_labels()), pose, W, H, 0, 0, 0, 0)
assert mask.mode == mask_gray.mode == "L" and mask.size == (W, H)
class _Pose:
def __call__(self, bgr):
return np.zeros(bgr.shape, np.uint8), None, None, np.full((1, 134, 2), 0.5, np.float32)
class _Parsing:
def __call__(self, image):
labels = Image.fromarray(_parsing_labels())
return labels, labels
@pytest.mark.parametrize("category", ["Upper-body", "Lower-body", "Dresses"])
def test_vton_node_builds_its_coloured_parsing_image_without_a_pillow_deprecation(monkeypatch, category):
monkeypatch.setattr(vton, "LOADED_VTON_MODELS", {"dwprocessor": _Pose(), "parsing_model": _Parsing(), "device": "cpu"})
outputs = vton.NH_VTonUltimateProcessor().process(torch.full((1, H, W, 3), 0.5), category, 2, False, True, True, "cpu")
parsing_image = outputs[3]
assert parsing_image.shape == (1, H, W, 3)
# The palette is applied label by label: row 20, column 30 is label 4 (upper clothes), row 46, column 25 label 9 (shoes).
for (row, column), label in (((20, 30), 4), ((46, 25), 9), ((0, 0), 0)):
colour = torch.tensor(vton.LIP_PALETTE[3 * label:3 * label + 3], dtype=torch.float32) / 255.0
assert torch.allclose(parsing_image[0, row, column], colour), label
+843
View File
@@ -0,0 +1,843 @@
"""HTTP routes (core/folder_routes.py, core/large_preview_routes.py) and the frontend extensions (web/*.js).
The routes are served from a throw-away aiohttp app through each module's ``register_routes``; no ComfyUI server runs.
The extensions run in Node against a fake ComfyUI app and fake nodes (two kinds of widget: the one of the current
frontend, which has ``hidden``, and a plain one from an older frontend, which has not).
"""
import asyncio
import importlib
import json
import os
import re
import shutil
import subprocess
import threading
import time
from pathlib import Path
import pytest
import torch
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
import folder_paths
pack = importlib.import_module("NH-Nodes")
folder_routes = importlib.import_module("NH-Nodes.core.folder_routes")
preview_routes = importlib.import_module("NH-Nodes.core.large_preview_routes")
preview_cache = importlib.import_module("NH-Nodes.core.large_preview_cache")
resolution_data = importlib.import_module("NH-Nodes.core.resolution_data")
picker = importlib.import_module("NH-Nodes.nodes.resolution.smart_resolution_picker")
PACK_DIR = Path(__file__).resolve().parent.parent
WEB_FILES = sorted((PACK_DIR / "web").glob("*.js"))
SHARED_MODULE = "nh_widget_utils.js"
COUNT = folder_routes.COUNT_PATH
PRESETS = folder_routes.PRESETS_PATH
CLEANUP = "/nh-nodes/large-preview/cleanup"
NO_CACHE_ID = "0" * 32
def call(method, path, **kwargs):
"""One request to the pack's routes. Returns (status, parsed JSON) or (status, raw bytes) for other content."""
async def run():
routes = web.RouteTableDef()
folder_routes.register_routes(routes)
preview_routes.register_routes(routes)
app = web.Application()
app.add_routes(routes)
async with TestClient(TestServer(app)) as client:
response = await client.request(method, path, **kwargs)
return response.status, response.content_type, await response.read()
status, content_type, body = asyncio.run(run())
return status, json.loads(body) if content_type == "application/json" else body
# --- registration -----------------------------------------------------------------------------------------------
def test_every_route_is_registered_and_the_package_imports_both_route_modules():
routes = web.RouteTableDef()
folder_routes.register_routes(routes)
preview_routes.register_routes(routes)
assert {(route.method, route.path) for route in routes} == {
("GET", "/nh-nodes/load-images-folder/count"),
("GET", "/nh-nodes/smart-resolution-picker/presets"),
("GET", "/nh-nodes/large-preview/manifest/{cache_id}"),
("GET", "/nh-nodes/large-preview/thumb/{cache_id}"),
("GET", "/nh-nodes/large-preview/tile/{cache_id}/{level}/{tile}"),
("GET", "/nh-nodes/large-preview/cache-info"),
("POST", "/nh-nodes/large-preview/cleanup"),
}
assert pack.folder_routes is folder_routes and pack.large_preview_routes is preview_routes
# --- /nh-nodes/load-images-folder/count --------------------------------------------------------------------------
@pytest.fixture
def image_tree(tmp_path, monkeypatch):
"""input/ holds 2 images, input/sub/ 2 more, input/sub/deeper/ 1 more; empty/ holds none."""
root = tmp_path / "input"
(root / "sub" / "deeper").mkdir(parents=True)
for relative in ("top1.png", "top2.JPG", "notes.txt", "sub/deep1.png", "sub/deep2.jpg", "sub/deeper/deep3.webp"):
(root / relative).write_bytes(b"")
(tmp_path / "empty").mkdir()
monkeypatch.setattr(folder_paths, "get_input_directory", lambda: str(root))
return root
def test_count_answers_with_the_count_and_nothing_else(image_tree):
status, body = call("GET", COUNT, params={"folder_path": str(image_tree)})
assert status == 200
assert body == {"count": 2} # in particular no resolved "path": ComfyUI may be reachable from the network
@pytest.mark.parametrize("recursive,expected", [("true", 5), ("TRUE", 5), ("1", 5), ("yes", 5), ("on", 5),
("false", 2), ("0", 2), ("no", 2), ("off", 2), ("", 2)])
def test_count_honours_the_recursive_parameter(image_tree, recursive, expected):
status, body = call("GET", COUNT, params={"folder_path": str(image_tree), "recursive": recursive})
assert (status, body) == (200, {"count": expected})
def test_count_without_recursive_is_not_recursive(image_tree):
assert call("GET", COUNT, params={"folder_path": str(image_tree)}) == (200, {"count": 2})
def test_count_resolves_like_the_loader_node(image_tree):
assert call("GET", COUNT, params={"folder_path": "sub"}) == (200, {"count": 2}) # relative to the input folder
assert call("GET", COUNT, params={"folder_path": "sub", "recursive": "true"}) == (200, {"count": 3})
assert call("GET", COUNT) == (200, {"count": 2}) # no folder: the input folder
assert call("GET", COUNT, params={"folder_path": str(image_tree / "top1.png")}) == (200, {"count": 2}) # a file: its folder
assert call("GET", COUNT, params={"folder_path": " sub "}) == (200, {"count": 2}) # surrounding blanks are ignored
def test_count_of_a_folder_without_images_is_zero(image_tree):
assert call("GET", COUNT, params={"folder_path": str(image_tree.parent / "empty")}) == (200, {"count": 0})
def test_count_of_a_missing_folder_is_404_and_does_not_echo_the_path(image_tree):
missing = image_tree.parent / "does-not-exist"
status, body = call("GET", COUNT, params={"folder_path": str(missing)})
assert status == 404
assert body == {"error": "folder not found"}
assert str(missing) not in json.dumps(body)
@pytest.mark.parametrize("params", [
{"folder_path": "x", "recursive": "maybe"},
{"folder_path": "x", "recursive": "2"},
{"folder_path": "bad\0name"},
{"folder_path": "a" * 5000},
])
def test_count_rejects_bad_input_with_400(image_tree, params):
status, body = call("GET", COUNT, params=params)
assert status == 400 and list(body) == ["error"]
def test_count_errors_are_short_and_never_leak_internals(image_tree, monkeypatch):
def denied(folder, recursive=False):
raise PermissionError(f"[Errno 13] Permission denied: {folder!r}")
monkeypatch.setattr(folder_routes, "collect_image_files", denied)
status, body = call("GET", COUNT, params={"folder_path": str(image_tree)})
assert (status, body) == (403, {"error": "folder is not readable"})
def broken(folder, recursive=False):
raise RuntimeError(f"secret detail about {folder}")
monkeypatch.setattr(folder_routes, "collect_image_files", broken)
status, body = call("GET", COUNT, params={"folder_path": str(image_tree)})
assert status == 500
assert body == {"error": "could not read the folder"}
def test_count_scans_in_a_worker_thread_not_on_the_event_loop(image_tree, monkeypatch):
seen = []
real = folder_routes.collect_image_files
def spy(folder, recursive=False):
seen.append(threading.get_ident())
return real(folder, recursive=recursive)
monkeypatch.setattr(folder_routes, "collect_image_files", spy)
assert call("GET", COUNT, params={"folder_path": str(image_tree)})[0] == 200
assert seen and seen[0] != threading.get_ident()
# --- /nh-nodes/smart-resolution-picker/presets --------------------------------------------------------------------
def test_presets_payload_matches_the_resolution_data():
status, body = call("GET", PRESETS)
assert status == 200
assert set(body) == {"models", "default_model", "default_preset", "presets_by_model", "resolution_levels",
"ratios_by_model_level"}
assert body["models"] == resolution_data.MODEL_LABELS
assert body["default_model"] == resolution_data.DEFAULT_MODEL_LABEL
assert body["default_preset"] == resolution_data.DEFAULT_PRESET
assert body["presets_by_model"] == resolution_data.PRESET_LABELS_BY_MODEL
assert body["resolution_levels"] == picker.TARGET_RESOLUTION_LEVELS
for model in body["models"]:
for level in body["resolution_levels"]:
ratios = body["ratios_by_model_level"][model][level]
assert ratios and len(ratios) == len(set(ratios))
# --- /nh-nodes/large-preview/* -----------------------------------------------------------------------------------
@pytest.fixture
def preview_temp(tmp_path, monkeypatch):
temp = tmp_path / "temp"
monkeypatch.setattr(folder_paths, "get_temp_directory", lambda: str(temp))
return temp
@pytest.mark.parametrize("body", ["[]", "[1, 2]", '"text"', "5", "null", "true"])
def test_cleanup_rejects_a_json_body_that_is_not_an_object(preview_temp, body):
status, payload = call("POST", CLEANUP, data=body, headers={"Content-Type": "application/json"})
assert status == 400
assert payload == {"error": "request body must be a JSON object"}
def test_cleanup_rejects_a_body_that_is_not_json(preview_temp):
status, payload = call("POST", CLEANUP, data="{not json", headers={"Content-Type": "application/json"})
assert status == 400 and "JSON" in payload["error"]
status, _ = call("POST", CLEANUP, data=b"\xff\xfe\x00", headers={"Content-Type": "application/json"})
assert status == 400
@pytest.mark.parametrize("kwargs", [{}, {"data": ""}, {"data": " \n"}, {"json": {}}, {"json": {"max_age_hours": 24}}])
def test_cleanup_accepts_an_empty_body_or_an_object(preview_temp, kwargs):
status, payload = call("POST", CLEANUP, **kwargs)
assert status == 200 and payload["removed"] == 0
@pytest.mark.parametrize("payload", [
{"max_age_hours": [1]},
{"max_age_hours": "abc"},
{"max_total_mb": [1]},
{"max_age_hours": 10 ** 400},
{"cache_id": "../outside"},
{"cache_id": 5},
])
def test_cleanup_answers_400_for_values_it_cannot_use(preview_temp, payload):
status, body = call("POST", CLEANUP, json=payload)
assert status == 400 and list(body) == ["error"]
def test_cleanup_removes_old_entries_and_keeps_new_ones(preview_temp):
root = preview_temp / preview_cache.CACHE_SUBDIR
old, new = root / ("a" * 32), root / ("b" * 32)
for entry in (old, new):
entry.mkdir(parents=True)
(entry / "manifest.json").write_text("{}", encoding="utf-8")
long_ago = time.time() - 48 * 3600
os.utime(old, (long_ago, long_ago))
status, body = call("POST", CLEANUP, json={"max_age_hours": 24})
assert status == 200 and body["removed"] == 1
assert not old.exists() and new.exists()
def test_large_preview_routes_serve_and_delete_a_cache_entry(preview_temp):
manifest = preview_cache.create_preview_cache(torch.rand(48, 64, 3))
cache_id = manifest["cache_id"]
base = "/nh-nodes/large-preview"
status, body = call("GET", f"{base}/manifest/{cache_id}")
assert status == 200 and body["cache_id"] == cache_id
for path in (f"{base}/thumb/{cache_id}", f"{base}/tile/{cache_id}/0/0_0.jpg"):
status, data = call("GET", path)
assert status == 200 and data[:2] == b"\xff\xd8" # a JPEG
status, info = call("GET", f"{base}/cache-info")
assert status == 200 and info["cache_count"] == 1
assert "root" not in json.dumps(info) and str(preview_temp) not in json.dumps(info) # no absolute paths leak
status, body = call("POST", CLEANUP, json={"cache_id": cache_id})
assert status == 200 and body["removed"] == 1
assert call("GET", f"{base}/manifest/{cache_id}")[0] == 404
@pytest.mark.parametrize("path,status", [
(f"/nh-nodes/large-preview/manifest/{NO_CACHE_ID}", 404),
("/nh-nodes/large-preview/manifest/not-an-id", 400),
(f"/nh-nodes/large-preview/thumb/{NO_CACHE_ID}", 404),
("/nh-nodes/large-preview/thumb/zz", 400),
(f"/nh-nodes/large-preview/tile/{NO_CACHE_ID}/0/0_0.jpg", 404),
(f"/nh-nodes/large-preview/tile/{NO_CACHE_ID}/x/0_0.jpg", 400),
(f"/nh-nodes/large-preview/tile/{NO_CACHE_ID}/0/passwd", 400),
])
def test_large_preview_routes_answer_404_for_missing_and_400_for_malformed(preview_temp, path, status):
assert call("GET", path)[0] == status
def test_large_preview_tile_route_does_not_leave_the_cache_folder(preview_temp):
cache_id = preview_cache.create_preview_cache(torch.rand(8, 8, 3))["cache_id"]
status, _ = call("GET", f"/nh-nodes/large-preview/tile/{cache_id}/0/..%2F..%2Fmanifest.json")
assert status in (400, 404)
# --- web/*.js: syntax and structure ------------------------------------------------------------------------------
needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed")
@needs_node
@pytest.mark.parametrize("path", WEB_FILES, ids=lambda path: path.name)
def test_every_js_file_is_valid_syntax(path):
check = subprocess.run([shutil.which("node"), "--check", str(path)], capture_output=True, text=True,
encoding="utf-8", timeout=60, check=False)
assert check.returncode == 0, check.stderr
def test_the_shared_module_imports_nothing_and_registers_no_extension():
source = (PACK_DIR / "web" / SHARED_MODULE).read_text(encoding="utf-8")
assert "registerExtension" not in source
assert not re.search(r"^\s*import\b", source, re.MULTILINE)
COPIED_HELPER = re.compile(
r"^\s*(?:async\s+)?function\s+(findWidget|toInt|clampInt|hideWidget|showWidget|chainCallback|markDirty|refreshNode)\b",
re.MULTILINE,
)
@pytest.mark.parametrize("path", [path for path in WEB_FILES if path.name != SHARED_MODULE], ids=lambda path: path.name)
def test_extensions_import_the_shared_helpers_instead_of_copying_them(path):
source = path.read_text(encoding="utf-8")
assert not COPIED_HELPER.search(source)
if re.search(r"\b(?:findWidget|toInt|clampInt|hideWidget|showWidget|chainCallback|markDirty|refreshNode)\(", source):
assert f'from "./{SHARED_MODULE}"' in source
# --- web/*.js: behaviour in Node ----------------------------------------------------------------------------------
JS_HARNESS = r"""
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const webDir = process.argv[2];
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const toUrl = (file) => pathToFileURL(path.join(webDir, file)).href;
// --- a fake ComfyUI application: it records which extension each file registers ---
const registered = [];
let loading = null;
const appGraph = { dirty: 0, setDirtyCanvas() { this.dirty++; } };
globalThis.__nhApp = { graph: appGraph, registerExtension(extension) { registered.push({ file: loading, extension }); } };
globalThis.__nhApi = { apiURL: (url) => url };
const files = fs.readdirSync(webDir).filter((name) => name.endsWith(".js")).sort();
for (const file of files) {
loading = file;
await import(toUrl(file));
}
loading = null;
const utils = await import(toUrl("nh_widget_utils.js"));
// --- two kinds of widget ---
// "modern": ComfyUI frontend 1.55.17 (litegraph BaseWidget). `hidden` is an accessor on the prototype, and a type that
// contains "hidden" hides the widget as well. "legacy": a plain object of an older frontend, no `hidden` at all.
class ModernWidget {
constructor(name, type, value, options) {
this.name = name;
this._type = type;
this.value = value;
this.options = options ?? {};
this._hidden = false;
this._hiddenByType = false;
}
get type() { return this._type; }
set type(value) { this._type = value; this._hiddenByType = /hidden/i.test(value); }
get hidden() { return this._hidden || this._hiddenByType; }
set hidden(value) { this._hidden = Boolean(value); }
computeSize() { return [0, 20]; }
}
function makeLegacyWidget(name, type, value, options) {
return { name, type, value, options: options ?? {}, computeSize() { return [0, 20]; } };
}
// A node with the parts of LGraphNode the extensions use, plus what the frontend does with its widgets: the
// workflow keeps the value of every widget unless widget.serialize is false (litegraph serialiseWidgetValues), the
// prompt gets every widget unless options.serialize is false (graphToPrompt), and a hidden widget takes no row.
function makeNode(kind, specs) {
const make = kind === "modern" ? (...args) => new ModernWidget(...args) : makeLegacyWidget;
return {
id: 1, graph: appGraph, size: [200, 100], inputs: [], outputs: [], sizeCalls: 0, dirtyCalls: 0,
widgets: specs.map(([name, type, value, options]) => make(name, type, value, options)),
addWidget(type, name, value, callback) {
const widget = make(name, type, value, {});
widget.callback = callback;
this.widgets.push(widget);
return widget;
},
addOutput(name, type) { this.outputs.push({ name, type }); },
removeOutput(slot) { this.outputs.splice(slot, 1); },
disconnectOutput() {},
widget(name) { return this.widgets.find((widget) => widget.name === name); },
isShown(widget) { return kind === "modern" ? !widget.hidden : widget.type !== "nh-hidden"; },
shown() { return this.widgets.filter((widget) => this.isShown(widget)).map((widget) => widget.name); },
hiddenNames() { return this.widgets.filter((widget) => !this.isShown(widget)).map((widget) => widget.name); },
types() { return Object.fromEntries(this.widgets.map((widget) => [widget.name, widget.type])); },
computeSize() { return [200, 40 + 24 * this.widgets.filter((widget) => this.isShown(widget)).length]; },
setSize(size) { this.size = size; this.sizeCalls++; },
setDirtyCanvas() { this.dirtyCalls++; },
workflowValues() { return this.widgets.filter((widget) => widget.serialize !== false).map((widget) => widget.value); },
promptInputs() {
const inputs = {};
for (const widget of this.widgets) {
if (widget.name && widget.options?.serialize !== false) inputs[widget.name] = widget.value;
}
return inputs;
},
};
}
// What ComfyUI does for a node: beforeRegisterNodeDef hooks the class, the node is created, then configured.
async function mountNode(file, nodeTypeName, kind, specs) {
const { extension } = registered.find((entry) => entry.file === file);
const NodeType = function () {};
await extension.beforeRegisterNodeDef(NodeType, { name: nodeTypeName });
const node = makeNode(kind, specs);
NodeType.prototype.onNodeCreated.call(node);
NodeType.prototype.onConfigure?.call(node, {});
await sleep(30);
return node;
}
const results = {};
async function scenario(name, run) {
try {
results[name] = await run();
} catch (error) {
results[name] = { error: String(error?.stack ?? error) };
}
}
const numbered = (prefix, count) => Array.from({ length: count }, (_, index) => `${prefix}_${index + 1}`);
// --- the files ---
results.registration = registered.map(({ file, extension }) => [file, extension.name]);
results.utilsExports = Object.keys(utils).sort();
// --- the shared helpers ---
await scenario("utils_basic", async () => {
const node = { widgets: [{ name: "a", value: 1 }, { name: "b", value: 2 }] };
return {
found: utils.findWidget(node, "b")?.value,
missing: utils.findWidget(node, "zzz") === undefined,
noWidgets: utils.findWidget({}, "a") === undefined,
nullNode: utils.findWidget(null, "a") === undefined,
toInt: [["12", 0], [" 7", 0], [3.9, 0], ["12px", 0], ["abc", -1], [undefined, -1], [null, -1], [NaN, -1], ["", -1], [Infinity, -1]]
.map(([value, fallback]) => utils.toInt(value, fallback)),
clampInt: [[5, 0, 10, 1], [-3, 0, 10, 1], [99, 0, 10, 1], ["x", 2, 10, 5], ["x", 2, 10, 50], [NaN, 1, 64, 5], ["7", 1, 64, 5]]
.map((args) => utils.clampInt(...args)),
};
});
await scenario("hide_show_modern", async () => {
const widget = new ModernWidget("w", "text", "keep");
const out = {};
utils.hideWidget(widget);
out.hidden = widget.hidden;
out.type = widget.type;
out.ownComputeSize = Object.hasOwn(widget, "computeSize");
utils.hideWidget(widget); // twice
utils.showWidget(widget);
out.shownAgain = widget.hidden;
out.typeAfter = widget.type;
utils.showWidget(widget); // twice
out.value = widget.value;
out.serializeUntouched = widget.serialize === undefined;
const hiddenByDefinition = new ModernWidget("d", "text", 1);
hiddenByDefinition.hidden = true;
utils.showWidget(hiddenByDefinition);
out.hiddenByDefinitionStays = hiddenByDefinition.hidden;
utils.hideWidget(undefined);
utils.showWidget(undefined);
utils.showWidget(null);
return out;
});
await scenario("hide_show_legacy", async () => {
const widget = makeLegacyWidget("w", "text", "keep");
const original = widget.computeSize;
const out = {};
utils.hideWidget(widget);
out.hiddenType = widget.type;
out.hiddenSize = widget.computeSize();
out.gotHiddenProperty = "hidden" in widget;
utils.hideWidget(widget); // a second call must not save "nh-hidden" as the type to restore
utils.showWidget(widget);
out.type = widget.type;
out.sizeRestored = widget.computeSize === original;
out.leftovers = Object.keys(widget).filter((key) => key.startsWith("__nh"));
out.value = widget.value;
const inherited = Object.assign(Object.create({ computeSize() { return [0, 33]; } }), { name: "i", type: "combo", value: 1, options: {} });
utils.hideWidget(inherited);
out.inheritedHiddenSize = inherited.computeSize();
utils.showWidget(inherited);
out.inheritedOwnSize = Object.hasOwn(inherited, "computeSize");
out.inheritedSize = inherited.computeSize();
out.inheritedType = inherited.type;
return out;
});
await scenario("chain_callback", async () => {
const log = [];
const plain = { name: "plain" };
utils.chainCallback(plain, function (...args) { log.push(["after", this === plain, args]); });
plain.callback(1, 2);
const withOriginal = { name: "original", callback(...args) { log.push(["original", this === withOriginal, args]); } };
utils.chainCallback(withOriginal, function (...args) { log.push(["after", this === withOriginal, args]); });
withOriginal.callback("x", "y");
return log;
});
await scenario("refresh", async () => {
const calls = [];
const node = {
size: [1, 1],
computeSize() { calls.push("computeSize"); return [5, 6]; },
setSize(size) { calls.push(["setSize", size]); },
setDirtyCanvas(...args) { calls.push(["nodeDirty", ...args]); },
};
utils.refreshNode(node, { setDirtyCanvas(...args) { calls.push(["graphDirty", ...args]); } });
const sizeless = { size: [9, 9], computeSize() { return null; }, setSize(size) { calls.push(["setSize", size]); } };
utils.refreshNode(sizeless, null);
utils.refreshNode({});
utils.markDirty({}, undefined);
return calls;
});
// --- the extensions, on both kinds of frontend ---
for (const kind of ["modern", "legacy"]) {
await scenario(`value_match_index_${kind}`, async () => {
const specs = [["value_count", "number", 3], ...numbered("value", 5).map((name) => [name, "text", `text ${name}`])];
const node = await mountNode("nh_value_match_index.js", "NH_ValueMatchIndex", kind, specs);
const out = { shown: node.shown(), hidden: node.hiddenNames(), types: node.types() };
const count = node.widget("value_count");
count.value = 5;
count.callback();
out.five = node.shown();
out.typesAfterFive = node.types();
count.value = 1;
count.callback();
out.one = node.shown();
out.workflowValues = node.workflowValues();
out.promptInputs = node.promptInputs();
count.value = "abc";
count.callback();
out.notANumber = { count: count.value, shown: node.shown() };
count.value = 1000;
count.callback();
out.tooMany = count.value;
out.buttonSerialize = node.widget("update values").serialize;
out.sized = node.sizeCalls > 0 && node.dirtyCalls > 0 && appGraph.dirty > 0;
return out;
});
await scenario(`save_image_path_${kind}`, async () => {
const node = await mountNode("nh_save_image_path.js", "NH_SaveImagePath", kind,
[["create_new_folder", "toggle", false], ["new_folder_name", "text", "renders"]]);
const out = { off: node.shown() };
const create = node.widget("create_new_folder");
create.value = true;
create.callback();
out.on = node.shown();
create.value = false;
create.callback();
out.offAgain = node.shown();
out.workflowValues = node.workflowValues();
out.promptInputs = node.promptInputs();
return out;
});
await scenario(`index_nodes_${kind}`, async () => {
const specs = [
["select_mode", "combo", "index"], ["index", "number", 0], ["lora_count", "number", 2],
...numbered("lora", 3).map((name) => [name, "combo", name]),
...numbered("enable", 3).map((name) => [name, "toggle", false]),
];
const node = await mountNode("nh_index_nodes.js", "NH_LoraModelIndex", kind, specs);
const out = { indexMode: node.shown() };
const mode = node.widget("select_mode");
mode.value = "boolean";
mode.callback();
out.booleanMode = node.shown();
const first = node.widget("enable_1");
const second = node.widget("enable_2");
first.value = true;
second.value = true;
second.callback();
out.exclusive = [first.value, second.value];
const count = node.widget("lora_count");
count.value = 3;
count.callback();
out.threeLoras = node.shown();
out.workflowValues = node.workflowValues();
const concat = await mountNode("nh_index_nodes.js", "NH_TextConcatenate", kind,
[["string_count", "number", 1], ...numbered("string", 3).map((name) => [name, "text", ""])]);
out.concat = { count: concat.widget("string_count").value, shown: concat.shown() };
return out;
});
}
await scenario("any_list_split", async () => {
const node = await mountNode("nh_any_list_split.js", "NH_AnyListSplit", "modern", [["index_count", "number", 3]]);
const names = () => node.outputs.map((output) => output.name);
const out = { three: names() };
const count = node.widget("index_count");
count.value = 1;
count.callback();
out.one = names();
count.value = 100;
count.callback();
out.clamped = { count: count.value, names: names() };
out.buttonSerialize = node.widget("update outputs").serialize;
return out;
});
await scenario("load_images_folder", async () => {
globalThis.window = { location: { origin: "http://127.0.0.1:8188" } };
const calls = [];
let reply = (url) => ({ ok: true, json: async () => ({ count: url.searchParams.get("recursive") === "true" ? 5 : 2 }) });
globalThis.fetch = async (input) => {
const url = new URL(String(input));
calls.push({ path: url.pathname, folder: url.searchParams.get("folder_path"), recursive: url.searchParams.get("recursive") });
return reply(url);
};
const specs = [["folder_path", "text", "C:/imgs"], ["index", "number", 3], ["step", "number", 1],
["seed_mode", "combo", "increment"], ["recursive", "toggle", true]];
const node = await mountNode("nh_load_images_folder.js", "NH_LoadImagesFromFolder", "modern", specs);
const index = node.widget("index");
const recursive = node.widget("recursive");
const out = {};
const queue = async () => { const sent = await index.serializeValue(); await sleep(10); return [sent, index.value]; };
out.recursiveOn = await queue(); // 5 images: 3 is kept, next is 4
recursive.value = false;
out.recursiveOff = await queue(); // 2 images: 4 wraps to 0, next is 1
recursive.value = true;
out.recursiveOnAgain = await queue();
out.calls = calls.map((call) => call.recursive);
out.paths = [...new Set(calls.map((call) => call.path))];
out.folders = [...new Set(calls.map((call) => call.folder))];
node.widget("seed_mode").value = "decrement";
node.widget("step").value = 2;
index.value = 0;
out.decrement = await queue(); // 5 images: 0 stays, next is 3
node.widget("seed_mode").value = "random";
const random = Math.random;
Math.random = () => 0.5;
out.random = await queue(); // floor(0.5 * 5)
Math.random = random;
node.widget("seed_mode").value = "fixed";
index.value = 7;
out.fixed = await queue(); // 7 % 5 = 2
reply = () => ({ ok: false, status: 404 });
index.value = 7;
out.notFound = await queue(); // no count: the index is left alone
globalThis.fetch = async () => { throw new Error("offline"); };
out.offline = await queue();
globalThis.fetch = async (input) => { calls.push({ recursive: new URL(String(input)).searchParams.get("recursive") }); return { ok: true, json: async () => ({ count: 4 }) }; };
const without = await mountNode("nh_load_images_folder.js", "NH_LoadImagesFromFolder", "modern", specs.slice(0, 4));
await without.widget("index").serializeValue();
out.withoutRecursiveWidget = calls[calls.length - 1].recursive;
return out;
});
await scenario("smart_resolution_picker", async () => {
const presets = {
models: ["A", "B"], default_model: "A", default_preset: "A-1",
presets_by_model: { A: ["A-1", "A-2"], B: ["B-1"] },
resolution_levels: ["1 MP", "2 MP"],
ratios_by_model_level: { A: { "1 MP": ["1:1", "3:2"], "2 MP": ["1:1"] }, B: { "1 MP": ["16:9"], "2 MP": ["16:9", "4:3"] } },
};
let fetches = 0;
globalThis.fetch = async () => { fetches++; return { ok: true, status: 200, json: async () => presets }; };
const out = {};
const pickerNode = await mountNode("nh_smart_resolution_picker.js", "NH_SmartResolutionPicker", "modern",
[["model_family", "combo", "A"], ["preset", "combo", "stale"]]);
const preset = () => [pickerNode.widget("preset").options.values, pickerNode.widget("preset").value];
out.pickerStart = preset();
pickerNode.widget("model_family").value = "B";
pickerNode.widget("model_family").callback();
out.pickerModelB = preset();
const ratioNode = await mountNode("nh_smart_resolution_picker.js", "NH_RatioPresetImageResize", "modern",
[["model_family", "combo", "A"], ["resolution_level", "combo", "1 MP"], ["target_ratio", "combo", "9:9"]]);
const ratio = () => [ratioNode.widget("target_ratio").options.values, ratioNode.widget("target_ratio").value];
out.ratioStart = ratio();
ratioNode.widget("resolution_level").value = "2 MP";
ratioNode.widget("resolution_level").callback();
out.ratioTwoMp = ratio();
ratioNode.widget("model_family").value = "B";
ratioNode.widget("model_family").callback();
out.ratioModelB = ratio();
out.fetches = fetches;
return out;
});
console.log("RESULT:" + JSON.stringify(results));
"""
@pytest.fixture(scope="module")
def js(tmp_path_factory):
"""The harness results. The files are copied unchanged into a folder shaped like ComfyUI's: the extensions'
``../../../scripts/app.js`` finds the fake app, and ``./nh_widget_utils.js`` finds its sibling."""
node = shutil.which("node")
if node is None:
pytest.skip("node is not installed")
work = tmp_path_factory.mktemp("web_harness")
(work / "package.json").write_text('{"type": "module"}', encoding="utf-8") # the copied .js files are ES modules
(work / "scripts").mkdir()
(work / "scripts" / "app.js").write_text("export const app = globalThis.__nhApp;\n", encoding="utf-8")
(work / "scripts" / "api.js").write_text("export const api = globalThis.__nhApi;\n", encoding="utf-8")
web_dir = work / "server" / "extensions" / "NH-Nodes"
web_dir.mkdir(parents=True)
for source in WEB_FILES:
shutil.copy(source, web_dir / source.name)
(work / "harness.mjs").write_text(JS_HARNESS, encoding="utf-8")
run = subprocess.run([node, str(work / "harness.mjs"), str(web_dir)], capture_output=True, text=True,
encoding="utf-8", timeout=120, check=False)
assert run.returncode == 0, run.stderr
line = next(line for line in run.stdout.splitlines() if line.startswith("RESULT:"))
return json.loads(line[len("RESULT:"):])
def scenario(js, name):
result = js[name]
assert "error" not in result, result.get("error")
return result
def test_each_extension_file_registers_exactly_one_uniquely_named_extension(js):
registration = js["registration"]
assert sorted(file for file, _ in registration) == sorted(path.name for path in WEB_FILES if path.name != SHARED_MODULE)
names = [name for _, name in registration]
assert len(names) == len(set(names)) and all(name.startswith("NH.Nodes.") for name in names)
assert js["utilsExports"] == sorted(
["chainCallback", "clampInt", "findWidget", "hideWidget", "markDirty", "refreshNode", "showWidget", "toInt"]
)
def test_findwidget_toint_and_clampint(js):
out = scenario(js, "utils_basic")
assert out["found"] == 2 and out["missing"] and out["noWidgets"] and out["nullNode"]
assert out["toInt"] == [12, 7, 3, 12, -1, -1, -1, -1, -1, -1]
assert out["clampInt"] == [5, 0, 10, 5, 10, 5, 7]
def test_hidewidget_uses_the_hidden_flag_when_the_frontend_has_it(js):
out = scenario(js, "hide_show_modern")
assert out["hidden"] is True
assert out["type"] == "text" and out["typeAfter"] == "text" # the type is not touched
assert out["ownComputeSize"] is False
assert out["shownAgain"] is False
assert out["value"] == "keep" and out["serializeUntouched"] is True # still saved with the workflow
assert out["hiddenByDefinitionStays"] is True # showWidget only undoes hideWidget
def test_hidewidget_falls_back_to_the_type_swap_on_older_frontends(js):
out = scenario(js, "hide_show_legacy")
assert out["hiddenType"] == "nh-hidden" and out["hiddenSize"] == [0, -4] and out["gotHiddenProperty"] is False
assert out["type"] == "text" and out["sizeRestored"] is True and out["leftovers"] == []
assert out["value"] == "keep"
assert out["inheritedHiddenSize"] == [0, -4]
assert out["inheritedOwnSize"] is False and out["inheritedSize"] == [0, 33] and out["inheritedType"] == "combo"
def test_chaincallback_runs_the_existing_callback_first_with_the_same_this_and_arguments(js):
assert scenario(js, "chain_callback") == [
["after", True, [1, 2]],
["original", True, ["x", "y"]],
["after", True, ["x", "y"]],
]
def test_refreshnode_resizes_then_repaints_and_tolerates_missing_methods(js):
assert scenario(js, "refresh") == [
"computeSize", ["setSize", [5, 6]], ["nodeDirty", True, True], ["graphDirty", True, True],
["setSize", [9, 9]], # computeSize gave nothing: the current size is kept
]
@pytest.mark.parametrize("kind", ["modern", "legacy"])
def test_value_match_index_shows_only_the_counted_values_and_still_saves_the_rest(js, kind):
out = scenario(js, f"value_match_index_{kind}")
assert out["shown"] == ["value_count", "value_1", "value_2", "value_3", "update values"]
assert out["hidden"] == ["value_4", "value_5"]
assert out["five"] == ["value_count", *[f"value_{i}" for i in range(1, 6)], "update values"]
assert out["one"] == ["value_count", "value_1", "update values"]
hidden_type = "text" if kind == "modern" else "nh-hidden"
assert out["types"]["value_4"] == hidden_type and out["types"]["value_1"] == "text"
assert set(out["typesAfterFive"].values()) <= {"text", "number", "button"} # shown again: the real type is back
# the hidden widgets still travel with the workflow and the prompt
assert out["workflowValues"] == [1, *[f"text value_{i}" for i in range(1, 6)]]
assert {f"value_{i}" for i in range(1, 6)} <= set(out["promptInputs"])
assert out["notANumber"] == {"count": 5, "shown": ["value_count", *[f"value_{i}" for i in range(1, 6)], "update values"]}
assert out["tooMany"] == 64
assert out["buttonSerialize"] is False and out["sized"] is True
@pytest.mark.parametrize("kind", ["modern", "legacy"])
def test_save_image_path_shows_the_folder_name_only_when_creating_a_folder(js, kind):
out = scenario(js, f"save_image_path_{kind}")
assert out["off"] == ["create_new_folder"]
assert out["on"] == ["create_new_folder", "new_folder_name"]
assert out["offAgain"] == ["create_new_folder"]
assert out["workflowValues"] == [False, "renders"]
assert out["promptInputs"] == {"create_new_folder": False, "new_folder_name": "renders"}
@pytest.mark.parametrize("kind", ["modern", "legacy"])
def test_index_nodes_switch_between_index_and_boolean_mode(js, kind):
out = scenario(js, f"index_nodes_{kind}")
assert out["indexMode"] == ["select_mode", "index", "lora_count", "lora_1", "lora_2", "update loras"]
assert out["booleanMode"] == ["select_mode", "lora_count", "lora_1", "lora_2", "enable_1", "enable_2", "update loras"]
assert out["exclusive"] == [False, True] # turning enable_2 on turns enable_1 off
assert out["threeLoras"] == ["select_mode", "lora_count", "lora_1", "lora_2", "lora_3", "enable_1", "enable_2",
"enable_3", "update loras"]
assert len(out["workflowValues"]) == 9 # all 9 widgets, shown or not (the button is not saved)
assert out["concat"] == {"count": 2, "shown": ["string_count", "string_1", "string_2", "update strings"]}
def test_any_list_split_follows_index_count(js):
out = scenario(js, "any_list_split")
assert out["three"] == ["All", "index 1", "index 2", "index 3"]
assert out["one"] == ["All", "index 1"]
assert out["clamped"] == {"count": 10, "names": ["All", *[f"index {i}" for i in range(1, 11)]]}
assert out["buttonSerialize"] is False
def test_load_images_folder_counts_with_the_nodes_recursive_setting(js):
out = scenario(js, "load_images_folder")
assert out["calls"][:3] == ["true", "false", "true"] # follows the widget on every queue
assert out["paths"] == ["/nh-nodes/load-images-folder/count"] and out["folders"] == ["C:/imgs"]
assert out["recursiveOn"] == [3, 4] # 5 images: 3, then 4
assert out["recursiveOff"] == [0, 1] # 2 images: 4 wraps to 0, then 1
assert out["recursiveOnAgain"] == [1, 2]
assert out["decrement"] == [0, 3] # step 2 back from 0 wraps to 3 of 5
assert out["random"] == [2, 2]
assert out["fixed"] == [2, 7] # 7 % 5 is sent, the widget keeps what the user typed
assert out["notFound"] == [7, 7] and out["offline"][0] == 7 # unknown count: no wrapping
assert out["withoutRecursiveWidget"] == "false"
def test_smart_resolution_picker_fills_the_dropdowns_from_one_request(js):
out = scenario(js, "smart_resolution_picker")
assert out["pickerStart"] == [["A-1", "A-2"], "A-1"]
assert out["pickerModelB"] == [["B-1"], "B-1"]
assert out["ratioStart"] == [["1:1", "3:2"], "1:1"]
assert out["ratioTwoMp"] == [["1:1"], "1:1"]
assert out["ratioModelB"] == [["16:9", "4:3"], "16:9"]
assert out["fetches"] == 1
+38 -25
View File
@@ -26,6 +26,7 @@ for path in (COMFY_ROOT, os.path.dirname(PACK_DIR)):
folder_paths = importlib.import_module("folder_paths")
backend = importlib.import_module("NH-Nodes.core.segformer_backend")
model_files = importlib.import_module("NH-Nodes.core.model_files")
ort_session = importlib.import_module("NH-Nodes.nodes.vton.preprocess.ort_session")
vton = importlib.import_module("NH-Nodes.nodes.vton.vton_preprocessor_nodes")
@@ -224,7 +225,7 @@ def test_extension_filter_does_not_change_a_set_comfyui_shares_between_folders(m
def test_registering_the_extension_filter_twice_changes_nothing(models_root):
default = str(models_root / "NH-Nodes")
for _ in range(2):
backend.register_model_folder("nh_nodes", default, {".safetensors"})
model_files.register_model_folder("nh_nodes", default, {".safetensors"})
assert folder_paths.folder_names_and_paths["nh_nodes"] == ([default], {".safetensors"})
@@ -239,7 +240,7 @@ def test_a_failing_extension_filter_does_not_lose_the_registered_locations(model
monkeypatch.setattr(folder_paths, "folder_names_and_paths", registry)
default = str(models_root / "NH-Nodes")
assert backend.register_model_folder("nh_nodes", default, {".safetensors"}) == [user_dir, default]
assert model_files.register_model_folder("nh_nodes", default, {".safetensors"}) == [user_dir, default]
assert "read-only" in capsys.readouterr().out
@@ -278,11 +279,23 @@ def test_extra_model_paths_location_is_found_and_wins_over_the_default(models_ro
def test_registering_twice_does_not_duplicate_the_default(models_root):
default = str(models_root / "NH-Nodes")
assert backend.register_model_folder("nh_nodes", default) == [default]
assert backend.register_model_folder("nh_nodes", default) == [default]
assert model_files.register_model_folder("nh_nodes", default) == [default]
assert model_files.register_model_folder("nh_nodes", default) == [default]
assert folder_paths.get_folder_paths("nh_nodes") == [default]
def test_unique_dirs_keeps_the_first_spelling_and_the_order(tmp_path):
a, b = tmp_path / "a", tmp_path / "b"
spellings = [str(a), str(b), str(a / ".." / "a"), str(b) + os.sep, str(tmp_path / "c")]
assert model_files.unique_dirs(spellings) == [str(a), str(b), str(tmp_path / "c")]
assert model_files.unique_dirs([]) == []
def test_segformer_and_vton_use_the_same_model_helpers():
for name in ("register_model_folder", "download_pinned", "make_room_on_gpu"):
assert getattr(backend, name) is getattr(model_files, name) is getattr(vton, name)
def test_incomplete_model_folder_is_skipped_and_the_next_candidate_is_used(models_root, hub):
_touch(models_root / "NH-Nodes" / "segformer_fashion", ["config.json"]) # interrupted download
_touch(models_root / "RMBG" / "segformer_fashion", SEGFORMER_FILES)
@@ -658,7 +671,7 @@ def ready_loaders(stub_loaders):
def test_cuda_load_asks_comfyui_for_room_before_creating_sessions(ready_loaders, monkeypatch, cuda_spy):
comfy = _FakeComfyMemory(ready_loaders)
monkeypatch.setattr(backend, "_mm", comfy)
monkeypatch.setattr(model_files, "_mm", comfy)
vton.NH_VTonUltimateProcessor().load_model("cuda")
assert [event[0] for event in ready_loaders] == ["free_memory", "soft_empty_cache", "detector", "parsing"]
@@ -669,13 +682,13 @@ def test_cuda_load_asks_comfyui_for_room_before_creating_sessions(ready_loaders,
def test_cpu_load_never_touches_comfyui_memory(ready_loaders, monkeypatch, cuda_spy):
monkeypatch.setattr(backend, "_mm", _FakeComfyMemory(ready_loaders))
monkeypatch.setattr(model_files, "_mm", _FakeComfyMemory(ready_loaders))
vton.NH_VTonUltimateProcessor().load_model("cpu")
assert [event[0] for event in ready_loaders] == ["detector", "parsing"]
def test_cuda_request_without_the_cuda_provider_does_not_evict_comfyui_models(ready_loaders, fake_ort, monkeypatch, cuda_spy):
monkeypatch.setattr(backend, "_mm", _FakeComfyMemory(ready_loaders))
monkeypatch.setattr(model_files, "_mm", _FakeComfyMemory(ready_loaders))
fake_ort.available = ["CPUExecutionProvider"] # CPU-only onnxruntime wheel
fake_ort.working = {"CPUExecutionProvider"}
vton.NH_VTonUltimateProcessor().load_model("cuda")
@@ -683,7 +696,7 @@ def test_cuda_request_without_the_cuda_provider_does_not_evict_comfyui_models(re
def test_cuda_run_that_falls_back_asks_for_room_once_then_stops_evicting(ready_loaders, fake_ort, monkeypatch, cuda_spy):
monkeypatch.setattr(backend, "_mm", _FakeComfyMemory(ready_loaders))
monkeypatch.setattr(model_files, "_mm", _FakeComfyMemory(ready_loaders))
fake_ort.working = {"CPUExecutionProvider"} # onnxruntime-gpu is installed, but CUDA/cuDNN do not start
# First run: nothing says yet that the GPU is unusable, so ComfyUI is asked, once.
@@ -703,7 +716,7 @@ def test_cuda_run_that_falls_back_asks_for_room_once_then_stops_evicting(ready_l
def test_cuda_runs_on_the_gpu_keep_asking_for_room_and_release(ready_loaders, monkeypatch, cuda_spy):
monkeypatch.setattr(backend, "_mm", _FakeComfyMemory(ready_loaders))
monkeypatch.setattr(model_files, "_mm", _FakeComfyMemory(ready_loaders))
for _ in range(2):
ready_loaders.clear()
_process("cuda")
@@ -725,7 +738,7 @@ def test_load_model_uses_the_resolved_folder_and_caches_per_device(stub_loaders,
def test_switching_device_drops_the_old_sessions_first(ready_loaders, monkeypatch, cuda_spy):
monkeypatch.setattr(backend, "_mm", _FakeComfyMemory(ready_loaders))
monkeypatch.setattr(model_files, "_mm", _FakeComfyMemory(ready_loaders))
node = vton.NH_VTonUltimateProcessor()
cuda_ref = weakref.ref(node.load_model("cuda")["dwprocessor"])
ready_loaders.clear()
@@ -735,7 +748,7 @@ def test_switching_device_drops_the_old_sessions_first(ready_loaders, monkeypatc
def test_switching_device_away_from_cpu_fallback_sessions_does_not_flush_torchs_cache(ready_loaders, fake_ort, monkeypatch, cuda_spy):
monkeypatch.setattr(backend, "_mm", _FakeComfyMemory(ready_loaders))
monkeypatch.setattr(model_files, "_mm", _FakeComfyMemory(ready_loaders))
fake_ort.working = {"CPUExecutionProvider"}
node = vton.NH_VTonUltimateProcessor()
cuda_ref = weakref.ref(node.load_model("cuda")["dwprocessor"])
@@ -807,16 +820,16 @@ def test_cpu_requests_are_never_a_fallback(fake_ort):
# --- the shared GPU helper -------------------------------------------------------------------------
def test_make_room_is_a_noop_without_cuda_in_comfy_cpu_mode_or_outside_comfyui(monkeypatch):
events = []
monkeypatch.setattr(backend, "_mm", _FakeComfyMemory(events))
monkeypatch.setattr(model_files, "_mm", _FakeComfyMemory(events))
monkeypatch.setattr(torch.cuda, "is_available", lambda: False)
backend.make_room_on_gpu(GIB)
model_files.make_room_on_gpu(GIB)
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(backend, "_mm", _FakeComfyMemory(events, device=torch.device("cpu"))) # ComfyUI --cpu
backend.make_room_on_gpu(GIB)
monkeypatch.setattr(model_files, "_mm", _FakeComfyMemory(events, device=torch.device("cpu"))) # ComfyUI --cpu
model_files.make_room_on_gpu(GIB)
monkeypatch.setattr(backend, "_mm", None)
backend.make_room_on_gpu(GIB)
monkeypatch.setattr(model_files, "_mm", None)
model_files.make_room_on_gpu(GIB)
assert events == []
@@ -824,8 +837,8 @@ def test_make_room_asks_for_the_size_plus_the_reserve_comfyui_keeps_for_itself(m
events = []
comfy = _FakeComfyMemory(events)
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(backend, "_mm", comfy)
backend.make_room_on_gpu(GIB)
monkeypatch.setattr(model_files, "_mm", comfy)
model_files.make_room_on_gpu(GIB)
# ComfyUI's own loaders ask for their size plus extra_reserved_memory(); asking for less would leave us short.
assert events == [("free_memory", GIB + comfy.reserved, torch.device("cuda", 0)), ("soft_empty_cache",)]
@@ -837,8 +850,8 @@ def test_make_room_on_a_comfyui_without_extra_reserved_memory_asks_for_the_size_
extra_reserved_memory = None # the function does not exist in older ComfyUI builds
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(backend, "_mm", OlderComfy(events))
backend.make_room_on_gpu(GIB)
monkeypatch.setattr(model_files, "_mm", OlderComfy(events))
model_files.make_room_on_gpu(GIB)
assert events[0] == ("free_memory", GIB, torch.device("cuda", 0))
@@ -848,8 +861,8 @@ def test_make_room_reports_comfyui_errors_instead_of_failing_the_run(monkeypatch
raise RuntimeError("model list changed")
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(backend, "_mm", Broken([]))
backend.make_room_on_gpu(GIB) # must not raise
monkeypatch.setattr(model_files, "_mm", Broken([]))
model_files.make_room_on_gpu(GIB) # must not raise
assert "model list changed" in capsys.readouterr().out
@@ -871,7 +884,7 @@ def test_segformer_asks_comfyui_for_room_using_comfyuis_own_device(monkeypatch):
events = []
comfy = _FakeComfyMemory(events)
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(backend, "_mm", comfy)
monkeypatch.setattr(model_files, "_mm", comfy)
model = _FakeSegformer(torch.device("cpu"))
backend._ensure_on_device({"model": model, "device": torch.device("cuda")})
@@ -884,7 +897,7 @@ def test_segformer_asks_comfyui_for_room_using_comfyuis_own_device(monkeypatch):
def test_segformer_already_on_the_gpu_is_not_moved_or_evicted_again(monkeypatch):
events = []
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(backend, "_mm", _FakeComfyMemory(events))
monkeypatch.setattr(model_files, "_mm", _FakeComfyMemory(events))
model = _FakeSegformer(torch.device("cuda", 0)) # what .to("cuda") leaves behind
backend._ensure_on_device({"model": model, "device": torch.device("cuda")})
assert events == [] and model.moved_to == []
+4 -18
View File
@@ -1,19 +1,11 @@
import { app } from "../../../scripts/app.js";
import { chainCallback, clampInt, findWidget, refreshNode } from "./nh_widget_utils.js";
const NODE_NAME = "NH_AnyListSplit";
const MAX_INDEX_OUTPUTS = 10;
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function toInt(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function clampIndexCount(value) {
return Math.max(0, Math.min(MAX_INDEX_OUTPUTS, toInt(value, 4)));
return clampInt(value, 0, MAX_INDEX_OUTPUTS, 4);
}
function indexOutputNumber(output) {
@@ -51,9 +43,7 @@ function updateOutputs(node) {
}
}
node.setSize?.(node.computeSize?.() || node.size);
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
refreshNode(node, app.graph);
}
function installControls(node) {
@@ -69,11 +59,7 @@ function installControls(node) {
const countWidget = findWidget(node, "index_count");
if (countWidget) {
const oldCallback = countWidget.callback;
countWidget.callback = function () {
oldCallback?.apply(this, arguments);
updateOutputs(node);
};
chainCallback(countWidget, () => updateOutputs(node));
}
setTimeout(() => updateOutputs(node), 0);
+7 -52
View File
@@ -1,4 +1,5 @@
import { app } from "../../../scripts/app.js";
import { chainCallback, clampInt, findWidget, hideWidget, refreshNode, showWidget } from "./nh_widget_utils.js";
const MAX_ITEMS = 64;
@@ -63,42 +64,8 @@ const NODE_CONFIGS = {
},
};
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function toInt(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function clampCount(value, minCount = 1) {
return Math.max(minCount, Math.min(MAX_ITEMS, toInt(value, minCount)));
}
function hideWidget(widget) {
if (!widget || widget.__nhIndexHidden) {
return;
}
widget.__nhIndexHidden = true;
widget.__nhIndexType = widget.type;
widget.__nhIndexComputeSize = widget.computeSize;
widget.computeSize = () => [0, -4];
widget.type = "nh-hidden";
}
function showWidget(widget) {
if (!widget || !widget.__nhIndexHidden) {
return;
}
widget.type = widget.__nhIndexType;
widget.computeSize = widget.__nhIndexComputeSize;
delete widget.__nhIndexHidden;
delete widget.__nhIndexType;
delete widget.__nhIndexComputeSize;
return clampInt(value, minCount, MAX_ITEMS, minCount);
}
function updateIndexedWidgets(node, config) {
@@ -137,9 +104,7 @@ function updateIndexedWidgets(node, config) {
}
}
node.setSize?.(node.computeSize?.() || node.size);
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
refreshNode(node, app.graph);
}
function installIndexedControls(node, config) {
@@ -155,21 +120,13 @@ function installIndexedControls(node, config) {
const countWidget = findWidget(node, config.countWidget);
if (countWidget) {
const oldCallback = countWidget.callback;
countWidget.callback = function () {
oldCallback?.apply(this, arguments);
updateIndexedWidgets(node, config);
};
chainCallback(countWidget, () => updateIndexedWidgets(node, config));
}
if (config.selectModeWidget) {
const smWidget = findWidget(node, config.selectModeWidget);
if (smWidget) {
const smOld = smWidget.callback;
smWidget.callback = function () {
smOld?.apply(this, arguments);
updateIndexedWidgets(node, config);
};
chainCallback(smWidget, () => updateIndexedWidgets(node, config));
}
}
@@ -177,9 +134,7 @@ function installIndexedControls(node, config) {
for (let i = 1; i <= MAX_ITEMS; i++) {
const enableWidget = findWidget(node, `${config.enablePrefix}_${i}`);
if (enableWidget) {
const enOld = enableWidget.callback;
enableWidget.callback = function () {
enOld?.apply(this, arguments);
chainCallback(enableWidget, function () {
if (this.value) {
for (let j = 1; j <= MAX_ITEMS; j++) {
if (j !== i) {
@@ -191,7 +146,7 @@ function installIndexedControls(node, config) {
}
updateIndexedWidgets(node, config);
}
};
});
}
}
}
+4 -18
View File
@@ -1,19 +1,11 @@
import { app } from "../../../scripts/app.js";
import { chainCallback, clampInt, findWidget, refreshNode, toInt } from "./nh_widget_utils.js";
const NODE_NAME = "NH_LayerStackComposite";
const MAX_LAYERS = 64;
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function toInt(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function clampLayerCount(value) {
return Math.max(0, Math.min(MAX_LAYERS, toInt(value, 1)));
return clampInt(value, 0, MAX_LAYERS, 1);
}
function findInputIndex(node, name) {
@@ -120,9 +112,7 @@ function updateLayerInputs(node) {
addMissingLayerInputs(node, count);
sortLayerInputs(node);
node.setSize?.(node.computeSize?.() || node.size);
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
refreshNode(node, app.graph);
}
function installLayerStackControls(node) {
@@ -138,11 +128,7 @@ function installLayerStackControls(node) {
const layerCountWidget = findWidget(node, "layer_count");
if (layerCountWidget) {
const oldCallback = layerCountWidget.callback;
layerCountWidget.callback = function () {
oldCallback?.apply(this, arguments);
updateLayerInputs(node);
};
chainCallback(layerCountWidget, () => updateLayerInputs(node));
}
setTimeout(() => updateLayerInputs(node), 0);
+9 -13
View File
@@ -1,17 +1,9 @@
import { app } from "../../../scripts/app.js";
import { findWidget, markDirty, toInt } from "./nh_widget_utils.js";
const NODE_NAME = "NH_LoadImagesFromFolder";
const COUNT_ENDPOINT = "/nh-nodes/load-images-folder/count";
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function toInt(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function wrapIndex(value, count) {
const index = Math.trunc(value);
if (count > 0) {
@@ -20,9 +12,13 @@ function wrapIndex(value, count) {
return Math.max(0, index);
}
async function getImageCount(folderPath) {
// How many images the node sees in the folder. `recursive` has to be the node's own setting: a recursive node sees the
// images of the sub-folders too, so counting only the top level would wrap the index too early. The count is asked for
// on every queue, so a changed folder_path or recursive value is picked up without a cache to refresh.
async function getImageCount(folderPath, recursive) {
const url = new URL(COUNT_ENDPOINT, window.location.origin);
url.searchParams.set("folder_path", folderPath || "");
url.searchParams.set("recursive", recursive ? "true" : "false");
try {
const response = await fetch(url);
@@ -38,8 +34,7 @@ async function getImageCount(folderPath) {
function setWidgetValue(node, widget, value) {
widget.value = value;
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
markDirty(node, app.graph);
}
function installIndexControl(node) {
@@ -51,6 +46,7 @@ function installIndexControl(node) {
const indexWidget = findWidget(node, "index");
const stepWidget = findWidget(node, "step");
const modeWidget = findWidget(node, "seed_mode");
const recursiveWidget = findWidget(node, "recursive"); // optional: a node without it counts as not recursive
if (!folderWidget || !indexWidget || !stepWidget || !modeWidget) {
return;
@@ -61,7 +57,7 @@ function installIndexControl(node) {
indexWidget.serializeValue = async function () {
const mode = modeWidget.value || "fixed";
const step = Math.max(1, toInt(stepWidget.value, 1));
const imageCount = await getImageCount(folderWidget.value);
const imageCount = await getImageCount(folderWidget.value, Boolean(recursiveWidget?.value));
let currentIndex = wrapIndex(toInt(indexWidget.value, 0), imageCount);
if (mode === "random") {
+3 -37
View File
@@ -1,36 +1,8 @@
import { app } from "../../../scripts/app.js";
import { chainCallback, findWidget, hideWidget, refreshNode, showWidget } from "./nh_widget_utils.js";
const NODE_NAME = "NH_SaveImagePath";
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function hideWidget(widget) {
if (!widget || widget.__nhSaveImagePathHidden) {
return;
}
widget.__nhSaveImagePathHidden = true;
widget.__nhSaveImagePathType = widget.type;
widget.__nhSaveImagePathComputeSize = widget.computeSize;
widget.computeSize = () => [0, -4];
widget.type = "nh-hidden";
}
function showWidget(widget) {
if (!widget || !widget.__nhSaveImagePathHidden) {
return;
}
widget.type = widget.__nhSaveImagePathType;
widget.computeSize = widget.__nhSaveImagePathComputeSize;
delete widget.__nhSaveImagePathHidden;
delete widget.__nhSaveImagePathType;
delete widget.__nhSaveImagePathComputeSize;
}
function updateNewFolderWidget(node) {
const createWidget = findWidget(node, "create_new_folder");
const folderNameWidget = findWidget(node, "new_folder_name");
@@ -45,9 +17,7 @@ function updateNewFolderWidget(node) {
hideWidget(folderNameWidget);
}
node.setSize?.(node.computeSize?.() || node.size);
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
refreshNode(node, app.graph);
}
function installSaveImagePathControls(node) {
@@ -58,11 +28,7 @@ function installSaveImagePathControls(node) {
const createWidget = findWidget(node, "create_new_folder");
if (createWidget) {
const oldCallback = createWidget.callback;
createWidget.callback = function () {
oldCallback?.apply(this, arguments);
updateNewFolderWidget(node);
};
chainCallback(createWidget, () => updateNewFolderWidget(node));
}
setTimeout(() => updateNewFolderWidget(node), 0);
+5 -18
View File
@@ -1,4 +1,5 @@
import { app } from "../../../scripts/app.js";
import { chainCallback, findWidget, markDirty } from "./nh_widget_utils.js";
const NODE_NAME = "NH_SmartResolutionPicker";
const RATIO_RESIZE_NODE_NAME = "NH_RatioPresetImageResize";
@@ -6,10 +7,6 @@ const PRESETS_ENDPOINT = "/nh-nodes/smart-resolution-picker/presets";
let cachedPresetData = null;
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
async function getPresetData() {
if (cachedPresetData) {
return cachedPresetData;
@@ -45,8 +42,7 @@ function updatePresetDropdown(node, presetData, keepCurrent = true) {
presetWidget.value = model === presetData.default_model ? presetData.default_preset : presets[0];
}
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
markDirty(node, app.graph);
}
function updateRatioDropdown(node, presetData, keepCurrent = true) {
@@ -69,8 +65,7 @@ function updateRatioDropdown(node, presetData, keepCurrent = true) {
ratioWidget.value = ratios.includes("1:1") ? "1:1" : ratios[0];
}
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
markDirty(node, app.graph);
}
function installSmartResolutionPicker(node) {
@@ -87,11 +82,7 @@ function installSmartResolutionPicker(node) {
return;
}
const oldCallback = modelWidget.callback;
modelWidget.callback = function () {
oldCallback?.apply(this, arguments);
updatePresetDropdown(node, presetData, false);
};
chainCallback(modelWidget, () => updatePresetDropdown(node, presetData, false));
}).catch((error) => {
console.warn("[NH-Nodes] Smart Resolution Picker preset update failed:", error);
});
@@ -112,11 +103,7 @@ function installRatioPresetImageResize(node) {
continue;
}
const oldCallback = widget.callback;
widget.callback = function () {
oldCallback?.apply(this, arguments);
updateRatioDropdown(node, presetData, false);
};
chainCallback(widget, () => updateRatioDropdown(node, presetData, false));
}
}).catch((error) => {
console.warn("[NH-Nodes] Ratio Preset Image Resize ratio update failed:", error);
+4 -43
View File
@@ -1,44 +1,11 @@
import { app } from "../../../scripts/app.js";
import { chainCallback, clampInt, findWidget, hideWidget, refreshNode, showWidget } from "./nh_widget_utils.js";
const NODE_NAME = "NH_ValueMatchIndex";
const MAX_VALUES = 64;
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function toInt(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function clampValueCount(value) {
return Math.max(1, Math.min(MAX_VALUES, toInt(value, 5)));
}
function hideWidget(widget) {
if (!widget || widget.__nhValueMatchHidden) {
return;
}
widget.__nhValueMatchHidden = true;
widget.__nhValueMatchType = widget.type;
widget.__nhValueMatchComputeSize = widget.computeSize;
widget.computeSize = () => [0, -4];
widget.type = "nh-hidden";
}
function showWidget(widget) {
if (!widget || !widget.__nhValueMatchHidden) {
return;
}
widget.type = widget.__nhValueMatchType;
widget.computeSize = widget.__nhValueMatchComputeSize;
delete widget.__nhValueMatchHidden;
delete widget.__nhValueMatchType;
delete widget.__nhValueMatchComputeSize;
return clampInt(value, 1, MAX_VALUES, 5);
}
function updateValueWidgets(node) {
@@ -58,9 +25,7 @@ function updateValueWidgets(node) {
}
}
node.setSize?.(node.computeSize?.() || node.size);
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
refreshNode(node, app.graph);
}
function installValueMatchControls(node) {
@@ -76,11 +41,7 @@ function installValueMatchControls(node) {
const countWidget = findWidget(node, "value_count");
if (countWidget) {
const oldCallback = countWidget.callback;
countWidget.callback = function () {
oldCallback?.apply(this, arguments);
updateValueWidgets(node);
};
chainCallback(countWidget, () => updateValueWidgets(node));
}
setTimeout(() => updateValueWidgets(node), 0);
+91
View File
@@ -0,0 +1,91 @@
// Helpers shared by the NH-Nodes frontend extensions. This file registers no extension and imports nothing: ComfyUI
// loads every .js file of the web folder, the extensions import this one with a relative path, and the browser keeps a
// single copy of it.
export function findWidget(node, name) {
return node?.widgets?.find((widget) => widget.name === name);
}
export function toInt(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
// `value` as an integer between min and max; `fallback` (also clamped) when it is not a number.
export function clampInt(value, min, max, fallback) {
return Math.max(min, Math.min(max, toInt(value, fallback)));
}
// Hiding a widget.
//
// ComfyUI frontend 1.55.17 (litegraph BaseWidget) has `widget.hidden`: a hidden widget is not drawn, takes no row, cannot
// be clicked, and is still saved with the workflow and sent with the prompt. Older frontends have no such property, so
// there a widget is hidden the way this pack always did it: a type no renderer knows and a computeSize that takes no
// room. `serialize` is never touched, so the value of a hidden widget is saved either way.
//
// showWidget only undoes what hideWidget did: a widget the node definition hides stays hidden.
const HIDDEN_STATE = "__nhHiddenState";
const HIDDEN_TYPE = "nh-hidden";
export function hideWidget(widget) {
if (!widget || widget[HIDDEN_STATE]) {
return;
}
if ("hidden" in widget) {
widget[HIDDEN_STATE] = { native: true };
widget.hidden = true;
return;
}
widget[HIDDEN_STATE] = {
native: false,
type: widget.type,
computeSize: widget.computeSize,
ownComputeSize: Object.prototype.hasOwnProperty.call(widget, "computeSize"),
};
widget.computeSize = () => [0, -4];
widget.type = HIDDEN_TYPE;
}
export function showWidget(widget) {
const state = widget?.[HIDDEN_STATE];
if (!state) {
return;
}
delete widget[HIDDEN_STATE];
if (state.native) {
widget.hidden = false;
return;
}
widget.type = state.type;
if (state.ownComputeSize) {
widget.computeSize = state.computeSize;
} else {
delete widget.computeSize;
}
}
// Calls `after` each time the widget's callback fires, once the callback that was already there (the frontend's, or
// another extension's) has run. `after` gets the same `this` and arguments.
export function chainCallback(widget, after) {
const original = widget.callback;
widget.callback = function () {
original?.apply(this, arguments);
after.apply(this, arguments);
};
}
// Repaints the canvas. `graph` is the application's graph (app.graph), which can differ from node.graph.
export function markDirty(node, graph) {
node.setDirtyCanvas?.(true, true);
graph?.setDirtyCanvas?.(true, true);
}
// Fits the node to the widgets and slots it has now, then repaints.
export function refreshNode(node, graph) {
node.setSize?.(node.computeSize?.() || node.size);
markDirty(node, graph);
}