6 Commits
Author SHA1 Message Date
jetthuangai f541a2fc8c Bump version 1.10.0 2026-11-12 15:14:47 +07:00
jetthuangai be1ffd431c test: a backslash path is a Windows-only way out of the output folder 2026-11-12 14:52:00 +07:00
jetthuangai 5b3eb0d2c0 docs: folders outside ComfyUI, nh_image_folders and the 1.10.0 changelog 2026-11-12 14:52:00 +07:00
jetthuangai ce7d20df86 chore: English for the Qwen-Image note and for the comments in the node files 2026-11-12 14:52:00 +07:00
jetthuangai a2bcc41bcf fix: image nodes and the count route stay inside ComfyUI's folders and the listed ones
Save Image Path, Load Images Folder, Load images matching and
GET /nh-nodes/load-images-folder/count used any absolute path. They now read
only in ComfyUI's input, output and temp folders and write only in its output
folder, plus the folders listed under nh_image_folders in
extra_model_paths.yaml. A path is refused before the disk is touched, links
that lead out are refused or skipped, and the source path of Load images
matching is read as text only.

Image Label opens only fonts of its own list and reads the font folder when
the node list is built, not at import. The bundled DWPose code no longer sets
KMP_DUPLICATE_LIB_OK for the whole process.
2026-11-12 14:52:00 +07:00
jetthuangai 7b60ad5bea chore: leave tests and docs build tools out of the Registry package
The Comfy Registry's scanner reads the test suite's stubs and helper processes as risky
code; installed users need neither the tests nor the scripts that build the docs' images,
cards and PDFs. Both stay in the GitHub repository.
2026-10-19 10:06:34 +07:00
31 changed files with 947 additions and 163 deletions
+5
View File
@@ -0,0 +1,5 @@
# Left out of the package published to the Comfy Registry; they stay in the GitHub repository.
# The test suite (fixtures, stubs and helper processes that the Registry's scanner reads as risky code)
tests/
# The scripts that build the docs' images, node cards and PDF guides
docs/_tools/
+11
View File
@@ -92,6 +92,17 @@ Restart ComfyUI afterwards. Requirements: a recent ComfyUI (tested with 0.41) an
vton_mask: ComfyUI-Vton-Mask/ # VTON pose + parsing ONNX models
sam3: sam3/ # SAM 3 checkpoint (you download sam3.pt yourself)
```
- **Image folders outside ComfyUI.** Load Images Folder, Load images matching and Save Image Path work inside
ComfyUI's `input`, `output` and `temp` folders (saving: `output` only). To use a photo drive or a project folder,
list it in the same file and restart ComfyUI
([details](docs/en/image-io.md#folders-outside-comfyui)):
```yaml
my_images:
nh_image_folders: |
D:/Photos/
E:/Exports/
```
- **Requirements for SAM 3** (SAM3 Segment only): an NVIDIA GPU with CUDA, Meta's `sam3` package and the `sam3.pt`
checkpoint. NH-Nodes installs and downloads none of them: you install the package yourself and request the weights
from Meta. The steps, and what to do if another SAM 3 pack is already installed, are in
+118
View File
@@ -0,0 +1,118 @@
"""Which folders the image nodes and routes of NH-Nodes may read and write.
A workflow is data: it can come from anyone, and ComfyUI can be reachable from the network. So the folder text of a
node (or of a request) only works inside
* reading: ComfyUI's ``input``, ``output`` and ``temp`` folders,
* writing: ComfyUI's ``output`` folder,
and the folders the owner of the machine listed on disk under ``nh_image_folders`` in ``extra_model_paths.yaml``
(both reading and writing)::
nh_nodes_images:
nh_image_folders: |
D:/Photos/
E:/Exports/
A path is checked twice. First as written (``..`` resolved, no disk access), so a path outside every allowed folder
is refused before anything touches it: a ``\\\\server\\share`` path never makes Windows contact that server. Then
with links and junctions resolved, so a link inside an allowed folder cannot lead out of it.
"""
import os
import folder_paths
FOLDER_KEY = "nh_image_folders"
_declared = []
class FolderNotAllowed(PermissionError):
"""The path lies outside every folder NH-Nodes may use. An OSError, so callers that skip unreadable paths skip it."""
def _absorb_declared():
# ComfyUI turns every key of extra_model_paths.yaml into a model folder name, and its /models routes list the
# files of every such name. These folders hold photos, not models: take the locations and drop the name.
entry = folder_paths.folder_names_and_paths.pop(FOLDER_KEY, None)
if entry:
for path in entry[0]:
if path and path not in _declared:
_declared.append(path)
def declared_folders():
"""The folders listed under ``nh_image_folders``; [] when the key is not used."""
_absorb_declared()
return list(_declared)
def read_roots():
return [
folder_paths.get_input_directory(),
folder_paths.get_output_directory(),
folder_paths.get_temp_directory(),
*declared_folders(),
]
def write_roots():
return [folder_paths.get_output_directory(), *declared_folders()]
def _contains(root, path):
try:
return os.path.commonpath([root, path]) == root
except ValueError: # another drive, or a drive path against a network share
return False
def _written(path):
return os.path.normcase(os.path.abspath(path))
def _resolved(path):
return os.path.normcase(os.path.realpath(path))
def is_inside(path, roots):
"""True when ``path`` is one of ``roots`` or below one, both as written and with links resolved."""
roots = [root for root in roots if root]
resolved_roots = [_resolved(root) for root in roots] # the roots are the owner's: resolving them is safe
written = _written(path)
# As written, the path must lie in a root under either of the root's spellings (a root that is itself a link
# may be typed by its real location).
if not any(_contains(root, written) for root in [*map(_written, roots), *resolved_roots]):
return False
resolved = _resolved(path)
return any(_contains(root, resolved) for root in resolved_roots)
def _refusal(path, what, action, defaults):
return FolderNotAllowed(
f"NH-Nodes: {what} is outside the folders NH-Nodes may {action}: {str(path)!r}. Allowed are {defaults} and "
f"the folders listed under '{FOLDER_KEY}' in extra_model_paths.yaml. To allow another folder, add it there, "
f"for example\n nh_nodes_images:\n {FOLDER_KEY}: D:/Photos/\nand restart ComfyUI."
)
def require_readable(path, what="The folder"):
"""``path`` as an absolute path; FolderNotAllowed (naming ``what``) when NH-Nodes may not read there."""
if not is_inside(path, read_roots()):
raise _refusal(path, what, "read", "ComfyUI's input, output and temp folders")
return os.path.abspath(path)
def require_writable(path, what="The folder"):
"""``path`` as an absolute path; FolderNotAllowed (naming ``what``) when NH-Nodes may not write there."""
if not is_inside(path, write_roots()):
raise _refusal(path, what, "write to", "ComfyUI's output folder")
return os.path.abspath(path)
def can_read(path):
return is_inside(path, read_roots())
_absorb_declared()
+4
View File
@@ -12,6 +12,7 @@ import os
from aiohttp import web
from .allowed_folders import FolderNotAllowed
from .image_folders import collect_image_files, resolve_load_dir
COUNT_PATH = "/nh-nodes/load-images-folder/count"
@@ -51,6 +52,9 @@ async def nh_load_images_folder_count(request):
count = await asyncio.to_thread(_count_images, folder_path, recursive)
except FileNotFoundError:
return _error(404, "folder not found")
except FolderNotAllowed:
# Outside ComfyUI's own folders and the folders listed under nh_image_folders: refused before the disk is read.
return _error(403, "folder is not allowed")
except PermissionError:
return _error(403, "folder is not readable")
except Exception as exc:
+12 -3
View File
@@ -10,6 +10,8 @@ from pathlib import Path
import folder_paths
from .allowed_folders import can_read, require_readable
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff"}
@@ -22,13 +24,15 @@ 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.
else the folder of the uploaded ``image`` / ``upload_probe``, else the input folder. FolderNotAllowed for a
``folder_path`` outside the folders NH-Nodes may read, before the disk is touched.
"""
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)
resolved = require_readable(resolved, "folder_path")
if os.path.isfile(resolved):
resolved = os.path.dirname(resolved)
return resolved
@@ -59,9 +63,14 @@ def collect_image_files(folder_path, recursive=False):
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).
# Natural order, never into the ComfyUI models folder (a walk from one of its parents would crawl it), and
# never through a link or junction that leads out of the folders NH-Nodes may read.
dir_names[:] = sorted(
(name for name in dir_names if os.path.normcase(os.path.abspath(os.path.join(current_root, name))) != models_dir),
(
name for name in dir_names
if os.path.normcase(os.path.abspath(os.path.join(current_root, name))) != models_dir
and can_read(os.path.join(current_root, name))
),
key=natural_key,
)
for file_name in sorted(file_names, key=natural_key):
+1 -1
View File
@@ -43,7 +43,7 @@ MODEL_SPECS = {
"min_res": (512, 512),
"max_res": (2560, 2560),
"latent_channels": 16,
"note": "Qwen tự match aspect input image",
"note": "Qwen-Image matches the aspect ratio of the input image",
},
"z_image": {
"name": "Z-Image",
+26
View File
@@ -3,6 +3,32 @@
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.10.0 — Image folders are limited to ComfyUI's folders and the ones you list (2026-11-12)
No node is added (76 in total) and no input or output changes. The first entry is a behaviour change that stops workflows which read or save outside ComfyUI until you list their folders once, so read it before you update.
### Changed
- (behaviour change) `Save Image Path (NH)`, `Load Images Folder (NH)` and `Load images matching` only work inside ComfyUI's own folders and the folders you list on disk. Reading is allowed in ComfyUI's `input`, `output` and `temp` folders, writing in its `output` folder. To use any other folder (a photo drive, a project folder), list it under the new key `nh_image_folders` in `extra_model_paths.yaml` and restart ComfyUI; every folder below a listed one is allowed, for reading and for writing:
```yaml
my_images:
nh_image_folders: |
D:/Photos/
E:/Exports/
```
A path anywhere else stops the run with an error that names the input and shows these lines. Nothing is read, listed or created first, and a network path (`\\server\share`) outside the list is not even contacted. A relative path with `..` that leaves `input` or `output` is refused in the same way. So is a link or junction that leads out of the allowed folders: as the folder you type, or as part of `new_folder_name` or `filename_prefix`, it stops the run; as a sub-folder it is skipped when sub-folders are read or searched; an image file that is such a link is not opened. If a sub-folder of `output` or of a listed folder is a link to another disk, list the place it leads to as well. Before 1.10.0 an absolute path was used as it was, anywhere the ComfyUI process could reach, so a workflow from someone else could read or write there. Only the file on disk can widen the list: a workflow cannot. `NH Save Large Image` and `Load Image Info (NH)` are unchanged: they leave the choice of folder to ComfyUI's own save and input-file handling.
- (behaviour change) The image count that `Load Images Folder (NH)` asks the server for (`GET /nh-nodes/load-images-folder/count`) follows the same rule and answers `403` for a folder outside the list, without touching it. On the node, `increment` and `decrement` then move the index without wrapping it, as they do for a folder that does not exist; the run itself stops with the error above.
- (behaviour change) `Load images matching`: `source_folder_path` is read as text only and the node no longer looks at the disk for it. A source that ends in an image file name (`…/subject_01/portrait.jpg`) stands for the folder of that file; a source file with another extension is no longer replaced by its folder. With a root inside a context folder (`…/Nam`), the sibling context folder (`…/Nu`) is only searched when the folder that holds both is allowed.
- `Image Label (NH)`: `font_name` only accepts a name from its list. A text wired into it used to be opened as a path below the Windows font folder, so `..` or a network path reached other places; any other value now falls back to the default font, as a font that is not installed always did.
- `Image Label (NH)` reads the list of installed fonts when ComfyUI first asks for the node list, no longer while the pack is imported. The list and the result are the same.
- `VTON Ultimate Processor (NH)`: the bundled DWPose code no longer sets the environment variable `KMP_DUPLICATE_LIB_OK` for the whole ComfyUI process.
- The note that `NH Smart Resolution Picker` shows for Qwen-Image is in English ("Qwen-Image matches the aspect ratio of the input image"), and the comments in the code are in English throughout.
### Notes
- Folders listed under `nh_image_folders` do not show up in ComfyUI's model lists: NH-Nodes takes them out of the model folder names when it loads.
- Updating from 1.9.0 or older: if a workflow saves to or loads from a folder outside ComfyUI, add that folder (or a folder above it) to `nh_image_folders` first. The guide has a section on it: [Folders outside ComfyUI](en/image-io.md#folders-outside-comfyui).
## 1.9.0 — Vision: body parts by name, Garment Segment on FASHN, parts in segs (2026-10-18)
One node is added, `Body Segment (NH)` (76 nodes in total). `Garment Segment (NH)`, `Portrait Segment (NH)`, `Segs Compose (NH)` and `Segs Select (NH)` change. Entries marked "behaviour change" can change what a saved workflow produces, so read them before you update. Workflows saved with 1.8.x still load and run: every new input is optional and every new output comes last. The licence notes at the end matter: `Garment Segment (NH)` is now documented as non-commercial, and `Body Segment (NH)` comes with use restrictions.
+34 -9
View File
@@ -11,7 +11,7 @@ These nodes move images in and out of a workflow. Load one image together with i
| [Load Image Info (NH)](#load-image-info-nh) | Load one image and get its file name and path as text |
| [Load Images Folder (NH)](#load-images-folder-nh) | Load images from a folder by position, and move to the next one on every run |
| [Load images matching](#load-images-matching) | Find the folder that matches a subject name and load its images |
| [Save Image Path (NH)](#save-image-path-nh) | Save to any folder with your own prefix, numbering, format and DPI |
| [Save Image Path (NH)](#save-image-path-nh) | Save to a folder you choose with your own prefix, numbering, format and DPI |
| [NH Save Large Image](#nh-save-large-image) | Save very large images without a heavy preview on the canvas |
## Paths and file types
@@ -24,6 +24,28 @@ These nodes move images in and out of a workflow. Load one image together with i
The folder loaders read `.png`, `.jpg`, `.jpeg`, `.webp`, `.avif`, `.bmp`, `.tif` and `.tiff` files. They sort names in natural order, so `img2` comes before `img10`, and they return RGB images.
### Folders outside ComfyUI
A workflow can come from anyone, and ComfyUI can be reachable from the network. So the folder you type into a node only works in these places:
| | Allowed folders |
|---|---|
| Reading (Load Images Folder, Load images matching, the image count on the node) | ComfyUI's `input`, `output` and `temp` folders, and the folders you list |
| Writing (Save Image Path) | ComfyUI's `output` folder, and the folders you list |
To use a folder anywhere else (a photo drive, a project folder), list it once under the key `nh_image_folders` in `extra_model_paths.yaml`, ComfyUI's file for extra model paths (in the ComfyUI folder; ComfyUI Desktop keeps its own copy with its settings), and restart ComfyUI:
```yaml
my_images:
nh_image_folders: |
D:/Photos/
E:/Exports/
```
Every folder below a listed folder is allowed too, for reading and for writing. A path anywhere else stops the run with an error that names the input and shows these lines; nothing is read or created. `..` parts are refused in the same way. So is a link or junction that leads out of the allowed folders: as the folder you type (or part of `new_folder_name` or `filename_prefix`) it stops the run, as a sub-folder it is skipped when sub-folders are read or searched, and an image file that is such a link is not opened. If a sub-folder of `output` or of a listed folder is a link to another disk, list the place it leads to as well. Only the file on disk can widen the list: a workflow cannot.
`source_folder_path` of Load images matching is the exception: the node only reads the names in it and never opens that folder, so it can be any path.
---
## Load Image Info (NH)
@@ -76,7 +98,7 @@ Loads one or more images from a folder by position, and can move to the next ima
| Input | Type | Default | Description |
|---|---|---|---|
| `folder_path` | STRING | empty | The folder to read. An absolute path, or a path relative to the `input` folder. Empty means the `input` folder. A path to a file uses the folder of that file. |
| `folder_path` | STRING | empty | The folder to read. A path relative to the `input` folder, or an absolute path inside an [allowed folder](#folders-outside-comfyui). Empty means the `input` folder. A path to a file uses the folder of that file. |
| `index` | INT | `0` | Position of the first image to load, from `0` to `999999`. It wraps around: with 10 images, `12` loads image `2`. |
| `step` | INT | `1` | How many images to load per run (`1` to `9999`), one after the other from `index`. It is also the distance `index` moves between runs in `increment` and `decrement` modes. |
| `seed_mode` | COMBO | `fixed` | What happens to `index` each time you queue: `fixed` (stays), `increment` (adds `step`), `decrement` (subtracts `step`; the images are also read backwards), `random` (a new random position). |
@@ -95,7 +117,7 @@ Loads one or more images from a folder by position, and can move to the next ima
- If the folder holds no images of its own, its sub-folders are searched even when `recursive` is off. A search of sub-folders skips the ComfyUI `models` folder.
- The browser moves `index` when you queue the workflow from the ComfyUI interface. It counts the images with the node's own `recursive` setting, so `increment`, `decrement` and `random` wrap and pick over the same images that the node loads. If you send the workflow through the API, set `index` yourself.
- The node runs again when one of the selected files changes on disk (name, size or date). If `folder_path` is wired from another node, the node cannot check the files in advance: it then runs again when the wired path or another setting changes, but not when a file inside the same folder changes.
- A folder that does not exist, or has no images, stops the run with an error.
- A folder that does not exist, or has no images, stops the run with an error. So does a folder outside the [allowed folders](#folders-outside-comfyui).
**Example — upscale a whole folder**
@@ -129,8 +151,8 @@ matching_root_path
| Input | Type | Default | Description |
|---|---|---|---|
| `source_folder_path` | STRING | empty | The source folder of the subject. Only the folder **names** in the path are used, so the folder does not have to exist. The last name is the match text, unless you fill in `match_text`. A context folder in the path (see `context_folder_names`) tells the node which branch to prefer. |
| `matching_root_path` | STRING | empty | The root of the folder tree to search. An absolute path, or a path relative to the `input` folder. Required. |
| `source_folder_path` | STRING | empty | The source folder of the subject. Only the folder **names** in the path are used, so the folder does not have to exist and can be anywhere. A path that ends in an image file name (such as `…/subject_01/portrait.jpg`) stands for the folder of that file. The last name is the match text, unless you fill in `match_text`. A context folder in the path (see `context_folder_names`) tells the node which branch to prefer. |
| `matching_root_path` | STRING | empty | The root of the folder tree to search. A path relative to the `input` folder, or an absolute path inside an [allowed folder](#folders-outside-comfyui). Required. |
| `match_text` | STRING | empty | The folder name to look for. Empty means: use the name of the source folder. |
| `match_mode` | COMBO | `contains` | `contains`: the folder name contains the text. `exact`: the folder name equals the text. Both ignore case and accents, and treat `_` and `-` as spaces. |
| `recursive` | BOOLEAN | `true` | `true`: search every folder below the root. `false`: check only the root and its direct sub-folders. |
@@ -154,7 +176,7 @@ matching_root_path
- **Match text:** `match_text`, or else the name of the folder that holds each `source_file_names` line, or else the name of the source folder. Fill in at least one of `source_folder_path`, `source_file_names` and `match_text`. With none of them, the node looks for the name of ComfyUI's `input` folder, which rarely matches.
- **Context:** if the source path contains one of the `context_folder_names`, matches under the same context rank first. A source in `…/Nu/subject_01` picks `Nu/subject_01` before `Nam/subject_01`. If your root sits inside one context folder (`…/Nam`) and the source belongs to another (`Nu`), the same place under the other context folder is searched too.
- **Ties:** among equal matches, the folder whose path is most like the source path wins, then natural name order.
- **Limits:** the search stays inside the root. It does not scan parent folders, apart from the sibling context folders described above, and it never enters the ComfyUI `models` folder. With `recursive` on, a root that is a drive or a network share (such as `D:\` or `\\server\share`) is refused: it cannot be searched recursively. The `models` folder is refused as a root whatever `recursive` says.
- **Limits:** the search stays inside the root. It does not scan parent folders, apart from the sibling context folders described above, and it never enters the ComfyUI `models` folder. With `recursive` on, a root that is a drive or a network share (such as `D:\` or `\\server\share`) is refused: it cannot be searched recursively. The `models` folder is refused as a root whatever `recursive` says. A root outside the [allowed folders](#folders-outside-comfyui) is refused, and a sibling context folder is only searched when the folder that holds it is allowed.
- **Files:** the images of the matched folder are sorted in natural order. `index` and `step` choose which ones to load.
- **Caching:** the result is reused until the matched folder or one of the selected files changes (name, size or date). A search that finds nothing is never remembered, so a folder you add later is found on the next run. If a setting is wired from another node, the node cannot know the folder in advance. It then watches the whole tree below the root, and in a very large tree it runs on every queue.
@@ -188,12 +210,12 @@ On Load Images Folder, set `path_output` to `folder_path`, so `path` is the fold
Saves an image batch to a folder you choose, with your own file name prefix and automatic numbering.
**Use it when** the results belong in a project folder outside the default `output` folder, or you need a format or a DPI value that the built-in Save Image does not offer.
**Use it when** the results belong in a sub-folder of `output` or in a project folder you listed under `nh_image_folders`, or you need a format or a DPI value that the built-in Save Image does not offer.
| Input | Type | Default | Description |
|---|---|---|---|
| `images` | IMAGE | — | The images to save. Every image of the batch becomes a file. |
| `folder_path` | STRING | empty | Where to save. Empty means the `output` folder. A relative path is placed inside `output`. An absolute path is used as it is, even outside ComfyUI. Missing folders are created. A path to an existing file uses the folder of that file. |
| `folder_path` | STRING | empty | Where to save. Empty means the `output` folder. A relative path is placed inside `output`. An absolute path must lie inside `output` or a folder listed under `nh_image_folders` (see [Folders outside ComfyUI](#folders-outside-comfyui)). Missing folders are created. A path to an existing file uses the folder of that file. |
| `create_new_folder` | BOOLEAN | `false` | Save into a sub-folder of `folder_path`. The `new_folder_name` field only shows while this is on. |
| `new_folder_name` | STRING | empty | The sub-folder, such as `day1` or `batch/day1`. It must stay inside `folder_path`. |
| `filename_prefix` | STRING | `NH` | The start of every file name. An empty value becomes `NH`. A `/` puts the files in a sub-folder, as in `renders/img`. |
@@ -217,6 +239,8 @@ Saves an image batch to a folder you choose, with your own file name prefix and
| `D:\Projects\ClientA` | on, `day1` | `portrait` | `D:\Projects\ClientA\day1\portrait0001.png` |
| `D:\Projects\ClientA` | off | `final/portrait` | `D:\Projects\ClientA\final\portrait0001.png` |
The last two rows need `D:/Projects/` (or `D:/Projects/ClientA/`) under `nh_image_folders`.
**How numbering works**
The node never overwrites a file. It looks at the files that already use the prefix in the target folder (any of `png`, `jpg`, `webp`, `avif`) and takes the next free number.
@@ -228,7 +252,8 @@ The node never overwrites a file. It looks at the files that already use the pre
**Rules for names**
- `filename_prefix` and `new_folder_name` must stay inside the target folder. A `..` part or an absolute path stops the run with an error before anything is written. An absolute `folder_path` is allowed.
- `filename_prefix` and `new_folder_name` must stay inside the target folder. A `..` part or an absolute path stops the run with an error before anything is written.
- `folder_path` must lie inside the `output` folder or a folder listed under `nh_image_folders`. Anything else, a relative path with `..` that leaves `output` included, stops the run before a folder is created.
- On Windows, `filename_prefix` cannot contain `:`. It would select a drive instead of naming a file.
**How it works**
+34 -9
View File
@@ -11,7 +11,7 @@ Các node này đưa ảnh vào và ra khỏi workflow. Bạn có thể tải m
| [Load Image Info (NH)](#load-image-info-nh) | Tải một ảnh và lấy tên tệp, đường dẫn của nó dưới dạng văn bản |
| [Load Images Folder (NH)](#load-images-folder-nh) | Tải ảnh từ thư mục theo vị trí, và chuyển sang ảnh kế tiếp sau mỗi lần chạy |
| [Load images matching](#load-images-matching) | Tìm thư mục khớp với tên một đối tượng và tải ảnh của nó |
| [Save Image Path (NH)](#save-image-path-nh) | Lưu vào thư mục bất kỳ với tiền tố, cách đánh số, định dạng và DPI do bạn chọn |
| [Save Image Path (NH)](#save-image-path-nh) | Lưu vào thư mục bạn chọn với tiền tố, cách đánh số, định dạng và DPI riêng |
| [NH Save Large Image](#nh-save-large-image) | Lưu ảnh rất lớn mà không có bản xem trước nặng trên canvas |
## Đường dẫn và loại tệp
@@ -24,6 +24,28 @@ Các node này đưa ảnh vào và ra khỏi workflow. Bạn có thể tải m
Các loader thư mục đọc tệp `.png`, `.jpg`, `.jpeg`, `.webp`, `.avif`, `.bmp`, `.tif` và `.tiff`. Chúng sắp xếp tên theo thứ tự tự nhiên, nên `img2` đứng trước `img10`, và trả về ảnh RGB.
### Thư mục bên ngoài ComfyUI
Workflow có thể đến từ bất kỳ ai, và ComfyUI có thể được truy cập qua mạng. Vì vậy thư mục bạn gõ vào node chỉ dùng được ở những nơi sau:
| | Thư mục được phép |
|---|---|
| Đọc (Load Images Folder, Load images matching, số ảnh hiện trên node) | thư mục `input`, `output` và `temp` của ComfyUI, cùng các thư mục bạn khai báo |
| Ghi (Save Image Path) | thư mục `output` của ComfyUI, cùng các thư mục bạn khai báo |
Muốn dùng thư mục ở nơi khác (ổ ảnh, thư mục dự án), hãy khai báo một lần dưới khóa `nh_image_folders` trong `extra_model_paths.yaml`, tệp khai báo đường dẫn model bổ sung của ComfyUI (nằm trong thư mục ComfyUI; ComfyUI Desktop giữ bản riêng cùng phần thiết lập của nó), rồi khởi động lại ComfyUI:
```yaml
my_images:
nh_image_folders: |
D:/Photos/
E:/Exports/
```
Mọi thư mục nằm dưới một thư mục đã khai báo cũng được phép, cả đọc lẫn ghi. Đường dẫn ở nơi khác làm lần chạy dừng với thông báo lỗi nêu tên đầu vào và hiện các dòng khai báo này; không có gì được đọc hay tạo ra. Thành phần `..` cũng bị từ chối như vậy. Liên kết (symlink hoặc junction) dẫn ra ngoài các thư mục được phép cũng thế: nếu nó là thư mục bạn gõ (hoặc một phần của `new_folder_name` hay `filename_prefix`) thì lần chạy dừng lại, nếu nó là thư mục con thì bị bỏ qua khi đọc hoặc tìm trong các thư mục con, và tệp ảnh là liên kết như vậy sẽ không được mở. Nếu một thư mục con của `output` hoặc của thư mục đã khai báo là liên kết sang ổ khác, hãy khai báo cả nơi nó dẫn tới. Chỉ tệp trên ổ đĩa mới mở rộng được danh sách: workflow thì không.
`source_folder_path` của Load images matching là ngoại lệ: node chỉ đọc các tên trong đường dẫn và không bao giờ mở thư mục đó, nên nó có thể là đường dẫn bất kỳ.
---
## Load Image Info (NH)
@@ -76,7 +98,7 @@ Tải một hoặc nhiều ảnh từ một thư mục theo vị trí, và có t
| Đầu vào | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `folder_path` | `STRING` | trống | Thư mục cần đọc. Có thể là đường dẫn tuyệt đối, hoặc tương đối so với thư mục `input`. Để trống nghĩa là thư mục `input`. Nếu đường dẫn trỏ tới một tệp, node dùng thư mục chứa tệp đó. |
| `folder_path` | `STRING` | trống | Thư mục cần đọc. Đường dẫn tương đối so với thư mục `input`, hoặc đường dẫn tuyệt đối nằm trong một [thư mục được phép](#thư-mục-bên-ngoài-comfyui). Để trống nghĩa là thư mục `input`. Nếu đường dẫn trỏ tới một tệp, node dùng thư mục chứa tệp đó. |
| `index` | `INT` | `0` | Vị trí của ảnh đầu tiên cần tải, từ `0` đến `999999`. Giá trị này quay vòng: với 10 ảnh, `12` tải ảnh số `2`. |
| `step` | `INT` | `1` | Số ảnh tải mỗi lần chạy (`1` đến `9999`), lấy nối tiếp nhau từ `index`. Đây cũng là khoảng `index` di chuyển giữa các lần chạy ở chế độ `increment` và `decrement`. |
| `seed_mode` | `COMBO` | `fixed` | Điều gì xảy ra với `index` mỗi lần bạn queue: `fixed` (giữ nguyên), `increment` (cộng thêm `step`), `decrement` (trừ đi `step`; các ảnh cũng được đọc lùi), `random` (một vị trí ngẫu nhiên mới). |
@@ -95,7 +117,7 @@ Tải một hoặc nhiều ảnh từ một thư mục theo vị trí, và có t
- Nếu thư mục không có ảnh nào ở cấp của nó, các thư mục con vẫn được tìm kể cả khi `recursive` tắt. Việc tìm trong thư mục con bỏ qua thư mục `models` của ComfyUI.
- Trình duyệt di chuyển `index` khi bạn queue workflow từ giao diện ComfyUI. Trình duyệt đếm ảnh theo đúng thiết lập `recursive` của node, nên `increment`, `decrement` và `random` quay vòng và chọn trên đúng những ảnh mà node tải. Nếu bạn gửi workflow qua API, hãy tự đặt `index`.
- Node chạy lại khi một trong các tệp được chọn thay đổi trên ổ đĩa (tên, dung lượng hoặc ngày giờ). Nếu `folder_path` được nối từ một node khác, node không thể kiểm tra các tệp từ trước: khi đó nó chạy lại khi đường dẫn được nối hoặc một thiết lập khác thay đổi, nhưng không chạy lại khi một tệp trong cùng thư mục thay đổi.
- Thư mục không tồn tại, hoặc không có ảnh nào, sẽ làm lần chạy dừng với một thông báo lỗi.
- Thư mục không tồn tại, hoặc không có ảnh nào, sẽ làm lần chạy dừng với một thông báo lỗi. Thư mục nằm ngoài các [thư mục được phép](#thư-mục-bên-ngoài-comfyui) cũng vậy.
**Ví dụ — upscale cả thư mục**
@@ -129,8 +151,8 @@ matching_root_path
| Đầu vào | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `source_folder_path` | `STRING` | trống | Thư mục nguồn của đối tượng. Chỉ **tên** các thư mục trong đường dẫn được dùng, nên thư mục không cần tồn tại. Tên cuối cùng là văn bản cần khớp, trừ khi bạn điền `match_text`. Một thư mục ngữ cảnh trong đường dẫn (xem `context_folder_names`) cho node biết nên ưu tiên nhánh nào. |
| `matching_root_path` | `STRING` | trống | Thư mục gốc của cây thư mục cần tìm. Đường dẫn tuyệt đối, hoặc tương đối so với thư mục `input`. Bắt buộc. |
| `source_folder_path` | `STRING` | trống | Thư mục nguồn của đối tượng. Chỉ **tên** các thư mục trong đường dẫn được dùng, nên thư mục không cần tồn tại và có thể ở bất kỳ đâu. Đường dẫn kết thúc bằng tên một tệp ảnh (như `…/subject_01/portrait.jpg`) được hiểu là thư mục chứa tệp đó. Tên cuối cùng là văn bản cần khớp, trừ khi bạn điền `match_text`. Một thư mục ngữ cảnh trong đường dẫn (xem `context_folder_names`) cho node biết nên ưu tiên nhánh nào. |
| `matching_root_path` | `STRING` | trống | Thư mục gốc của cây thư mục cần tìm. Đường dẫn tương đối so với thư mục `input`, hoặc đường dẫn tuyệt đối nằm trong một [thư mục được phép](#thư-mục-bên-ngoài-comfyui). Bắt buộc. |
| `match_text` | `STRING` | trống | Tên thư mục cần tìm. Để trống nghĩa là dùng tên của thư mục nguồn. |
| `match_mode` | `COMBO` | `contains` | `contains`: tên thư mục chứa văn bản. `exact`: tên thư mục bằng đúng văn bản. Cả hai đều bỏ qua chữ hoa/thường và dấu, và coi `_` và `-` như dấu cách. |
| `recursive` | `BOOLEAN` | `true` | `true`: tìm trong mọi thư mục dưới thư mục gốc. `false`: chỉ xem thư mục gốc và các thư mục con trực tiếp của nó. |
@@ -154,7 +176,7 @@ matching_root_path
- **Văn bản cần khớp:** `match_text`; nếu trống thì lấy tên thư mục chứa từng dòng của `source_file_names`; nếu cũng trống thì lấy tên thư mục nguồn. Hãy điền ít nhất một trong ba: `source_folder_path`, `source_file_names`, `match_text`. Nếu bỏ trống cả ba, node tìm theo tên thư mục `input` của ComfyUI, tên này hiếm khi khớp.
- **Ngữ cảnh:** nếu đường dẫn nguồn chứa một trong các `context_folder_names`, kết quả cùng ngữ cảnh được xếp lên đầu. Nguồn ở `…/Nu/subject_01` sẽ chọn `Nu/subject_01` trước `Nam/subject_01`. Nếu thư mục gốc của bạn nằm trong một thư mục ngữ cảnh (`…/Nam`) còn nguồn thuộc ngữ cảnh khác (`Nu`), node cũng tìm đúng vị trí đó dưới thư mục ngữ cảnh còn lại.
- **Khi ngang nhau:** thư mục có đường dẫn giống đường dẫn nguồn nhất thắng, rồi đến thứ tự tên tự nhiên.
- **Giới hạn:** việc tìm kiếm nằm trong thư mục gốc. Node không quét các thư mục cha, ngoại trừ các thư mục ngữ cảnh anh em đã nêu ở trên, và không bao giờ vào thư mục `models` của ComfyUI. Khi `recursive` bật, node từ chối thư mục gốc là một ổ đĩa hoặc một chia sẻ mạng (như `D:\` hay `\\server\share`): không thể tìm đệ quy ở đó. Thư mục `models` cũng bị từ chối làm thư mục gốc, dù `recursive` đặt thế nào.
- **Giới hạn:** việc tìm kiếm nằm trong thư mục gốc. Node không quét các thư mục cha, ngoại trừ các thư mục ngữ cảnh anh em đã nêu ở trên, và không bao giờ vào thư mục `models` của ComfyUI. Khi `recursive` bật, node từ chối thư mục gốc là một ổ đĩa hoặc một chia sẻ mạng (như `D:\` hay `\\server\share`): không thể tìm đệ quy ở đó. Thư mục `models` cũng bị từ chối làm thư mục gốc, dù `recursive` đặt thế nào. Thư mục gốc nằm ngoài các [thư mục được phép](#thư-mục-bên-ngoài-comfyui) bị từ chối, và thư mục ngữ cảnh anh em chỉ được tìm khi thư mục chứa nó được phép.
- **Tệp:** ảnh trong thư mục khớp được sắp xếp theo thứ tự tự nhiên. `index` và `step` chọn ảnh nào được tải.
- **Bộ nhớ đệm:** kết quả được dùng lại cho đến khi thư mục khớp hoặc một trong các tệp được chọn thay đổi (tên, dung lượng hoặc ngày giờ). Lần tìm không thấy gì không bao giờ được ghi nhớ, nên thư mục bạn thêm sau đó sẽ được tìm thấy ở lần chạy kế tiếp. Nếu một thiết lập được nối từ node khác, node không biết trước thư mục nào. Khi đó nó theo dõi cả cây thư mục dưới thư mục gốc, và với cây rất lớn thì chạy lại ở mọi lần queue.
@@ -188,12 +210,12 @@ Trên Load Images Folder, đặt `path_output` là `folder_path` để `path` l
Lưu một batch ảnh vào thư mục bạn chọn, với tiền tố tên tệp riêng và đánh số tự động.
**Dùng khi** kết quả cần nằm trong thư mục dự án bên ngoài thư mục `output` mặc định, hoặc bạn cần định dạng hay giá trị DPI mà Save Image có sẵn không có.
**Dùng khi** kết quả cần nằm trong một thư mục con của `output` hoặc trong thư mục dự án bạn đã khai báo dưới `nh_image_folders`, hoặc bạn cần định dạng hay giá trị DPI mà Save Image có sẵn không có.
| Đầu vào | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `images` | `IMAGE` | — | Các ảnh cần lưu. Mỗi ảnh trong batch thành một tệp. |
| `folder_path` | `STRING` | trống | Nơi lưu. Để trống nghĩa là thư mục `output`. Đường dẫn tương đối được đặt bên trong `output`. Đường dẫn tuyệt đối được dùng nguyên như vậy, kể cả bên ngoài ComfyUI. Thư mục chưa có sẽ được tạo. Đường dẫn trỏ tới một tệp có sẵn sẽ dùng thư mục chứa tệp đó. |
| `folder_path` | `STRING` | trống | Nơi lưu. Để trống nghĩa là thư mục `output`. Đường dẫn tương đối được đặt bên trong `output`. Đường dẫn tuyệt đối phải nằm trong `output` hoặc trong một thư mục khai báo dưới `nh_image_folders` (xem [Thư mục bên ngoài ComfyUI](#thư-mục-bên-ngoài-comfyui)). Thư mục chưa có sẽ được tạo. Đường dẫn trỏ tới một tệp có sẵn sẽ dùng thư mục chứa tệp đó. |
| `create_new_folder` | `BOOLEAN` | `false` | Lưu vào một thư mục con của `folder_path`. Ô `new_folder_name` chỉ hiện khi tùy chọn này bật. |
| `new_folder_name` | `STRING` | trống | Thư mục con, ví dụ `day1` hoặc `batch/day1`. Phải nằm bên trong `folder_path`. |
| `filename_prefix` | `STRING` | `NH` | Phần đầu của mọi tên tệp. Giá trị trống sẽ thành `NH`. Dấu `/` đưa tệp vào thư mục con, như `renders/img`. |
@@ -217,6 +239,8 @@ Lưu một batch ảnh vào thư mục bạn chọn, với tiền tố tên tệ
| `D:\Projects\ClientA` | bật, `day1` | `portrait` | `D:\Projects\ClientA\day1\portrait0001.png` |
| `D:\Projects\ClientA` | tắt | `final/portrait` | `D:\Projects\ClientA\final\portrait0001.png` |
Hai dòng cuối cần `D:/Projects/` (hoặc `D:/Projects/ClientA/`) dưới `nh_image_folders`.
**Cách đánh số**
Node không bao giờ ghi đè tệp. Nó xem các tệp đã dùng tiền tố đó trong thư mục đích (bất kỳ định dạng nào trong `png`, `jpg`, `webp`, `avif`) và lấy số trống kế tiếp.
@@ -228,7 +252,8 @@ Node không bao giờ ghi đè tệp. Nó xem các tệp đã dùng tiền tố
**Quy tắc đặt tên**
- `filename_prefix` và `new_folder_name` phải nằm bên trong thư mục đích. Thành phần `..` hoặc đường dẫn tuyệt đối làm lần chạy dừng với lỗi trước khi ghi bất cứ thứ gì. `folder_path` tuyệt đối thì được phép.
- `filename_prefix` và `new_folder_name` phải nằm bên trong thư mục đích. Thành phần `..` hoặc đường dẫn tuyệt đối làm lần chạy dừng với lỗi trước khi ghi bất cứ thứ gì.
- `folder_path` phải nằm trong thư mục `output` hoặc trong một thư mục khai báo dưới `nh_image_folders`. Mọi đường dẫn khác, kể cả đường dẫn tương đối có `..` đi ra khỏi `output`, làm lần chạy dừng trước khi thư mục nào được tạo.
- Trên Windows, `filename_prefix` không được chứa `:`. Ký tự này sẽ chọn ổ đĩa thay vì đặt tên tệp.
**Cách hoạt động**
+26 -24
View File
@@ -5,16 +5,17 @@ import cv2
class NH_AgnosticImageGenerator:
"""Xóa vùng mask trên ảnh và fill bằng gray, noise, hoặc blur.
"""Erase the mask region of the image and fill it with gray, noise or blur.
Output:
- agnostic_img: ảnh đã fill vùng mask (dùng làm input cho inpaint/VTON)
- masked_img: ảnh gốc với vùng mask bị xóa trắng (transparent)
- composite: ảnh gốc với viền mask overlay để preview
- agnostic_img: the image with the mask region filled (used as input for inpaint/VTON)
- masked_img: the original image with the mask region blanked to white (transparent)
- composite: the original image with the mask outline overlaid, for preview
Mask là alpha mềm: giá trị trung gian được blend một phần, feathering làm mềm thêm từ chính các giá trị đó.
Mask 2-D [H,W] coi như [1,H,W]; mask khác kích thước ảnh được resize về kích thước ảnh.
Fill "noise" tất định: ảnh thứ i trong batch dùng seed = i.
The mask is a soft alpha: intermediate values are blended partially, and feathering softens further starting
from those same values.
A 2-D mask [H,W] is treated as [1,H,W]; a mask whose size differs from the image is resized to the image size.
The "noise" fill is deterministic: the i-th image of the batch uses seed = i.
"""
@classmethod
@@ -41,7 +42,7 @@ class NH_AgnosticImageGenerator:
results_masked = []
results_composite = []
# Chuẩn hoá mask về [B,H,W] đúng kích thước ảnh (batch 1 được dùng lại cho mọi ảnh ở vòng lặp dưới).
# Normalise the mask to [B,H,W] at the image size (a batch of 1 is reused for every image in the loop below).
if mask.dim() == 2:
mask = mask.unsqueeze(0)
if mask.shape[-2:] != image.shape[1:3]:
@@ -55,12 +56,13 @@ class NH_AgnosticImageGenerator:
h, w, c = img_np.shape
# Alpha giữ nguyên giá trị mềm của mask (không ngưỡng hoá), kẹp về [0,1]
# Alpha keeps the soft values of the mask (no thresholding), clamped to [0,1]
alpha = np.clip(m_np, 0.0, 1.0).astype(np.float32)
# Viền preview cần hình dạng rõ nét: lấy ở mức 0.5 của mask gốc (như trước đây), không dùng alpha mềm
# The preview outline needs a crisp shape: taken at the 0.5 level of the original mask (as before),
# not from the soft alpha
outline_mask = (m_np > 0.5).astype(np.float32)
# Feathering: làm mềm biên mask
# Feathering: soften the mask edge
if feathering > 0:
kernel_size = feathering * 2 + 1
alpha = cv2.GaussianBlur(alpha, (kernel_size, kernel_size), 0)
@@ -73,25 +75,25 @@ class NH_AgnosticImageGenerator:
fill = np.full_like(img_np, gray_value)
elif fill_mode == "noise":
# Seed cố định theo index trong batch: cùng input luôn ra cùng noise
# The seed is fixed by the index in the batch: the same input always gives the same noise
noise = np.random.default_rng(i).random((h, w, c), dtype=np.float32)
# Blend noise với gray trung tâm để bớt harsh
# Blend the noise with the centre gray to make it less harsh
fill = noise * noise_strength + gray_value * (1.0 - noise_strength)
elif fill_mode == "blur":
# Blur radius phải lẻ
# The blur radius must be odd
kr = blur_radius if blur_radius % 2 == 1 else blur_radius + 1
fill = cv2.GaussianBlur(img_np, (kr, kr), 0)
# --- agnostic_img: blend fill vào vùng mask ---
# --- agnostic_img: blend the fill into the mask region ---
agnostic = img_np * (1.0 - alpha_3ch) + fill * alpha_3ch
agnostic = np.clip(agnostic, 0.0, 1.0)
# --- masked_img: vùng mask = trắng ---
# --- masked_img: mask region = white ---
masked = img_np * (1.0 - alpha_3ch) + np.ones_like(img_np) * alpha_3ch
masked = np.clip(masked, 0.0, 1.0)
# --- composite: overlay viền mask lên ảnh gốc ---
# --- composite: overlay the mask outline on the original image ---
composite = self._create_composite(img_np, outline_mask, agnostic)
results_agnostic.append(agnostic)
@@ -105,31 +107,31 @@ class NH_AgnosticImageGenerator:
@staticmethod
def _create_composite(img_np, outline_mask, agnostic):
"""Tạo preview: nửa trái = ảnh gốc với viền mask, nửa phải = agnostic."""
"""Build the preview: left half = original image with the mask outline, right half = agnostic."""
h, w = img_np.shape[:2]
# Tìm contour của mask để vẽ viền
# Find the mask contours to draw the outline
mask_uint8 = np.ascontiguousarray((outline_mask * 255).astype(np.uint8))
contours, _ = cv2.findContours(mask_uint8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# Nửa trái: ảnh gốc + viền xanh
# Left half: original image + green outline
left = np.ascontiguousarray((np.clip(img_np, 0.0, 1.0) * 255).astype(np.uint8))
cv2.drawContours(left, contours, -1, (0, 255, 0), 2)
# Tô nhẹ vùng mask bằng màu xanh semi-transparent
# Lightly tint the mask region with semi-transparent green
overlay = np.ascontiguousarray(left.copy())
cv2.drawContours(overlay, contours, -1, (0, 255, 0), -1)
left = cv2.addWeighted(left, 0.7, overlay, 0.3, 0)
# Nửa phải: agnostic
# Right half: agnostic
right = np.ascontiguousarray((np.clip(agnostic, 0.0, 1.0) * 255).astype(np.uint8))
# Ghép 2 nửa
# Join the 2 halves
mid = w // 2
composite = np.ascontiguousarray(left.copy())
composite[:, mid:] = right[:, mid:]
# Vẽ đường chia giữa
# Draw the dividing line in the middle
cv2.line(composite, (mid, 0), (mid, h), (255, 255, 255), 1)
return composite.astype(np.float32) / 255.0
+9 -9
View File
@@ -1,15 +1,15 @@
import torch
import numpy as np
import cv2 # Cần thư viện OpenCV
import cv2 # Requires the OpenCV library
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.
# Clip before casting to uint8: values outside [0,1] (e.g. from resampling) would wrap to near 0 without it.
return (np.clip(image.cpu().numpy(), 0.0, 1.0) * 255).astype(np.uint8)
def _as_mask_batch(mask):
# MASK 2-D [H,W] không có chiều batch: coi là [1,H,W].
# A 2-D MASK [H,W] has no batch dimension: treat it as [1,H,W].
return mask.unsqueeze(0) if mask.dim() == 2 else mask
class NH_SimpleFacePaste:
@@ -32,7 +32,7 @@ class NH_SimpleFacePaste:
def paste_face(self, dest_image, dest_mask, source_image, source_mask, feathering):
dest_mask = _as_mask_batch(dest_mask)
source_mask = _as_mask_batch(source_mask)
# Output theo batch của dest_image; input khác có batch 1 (hoặc ngắn hơn) được lặp lại theo vòng.
# The output follows the batch of dest_image; other inputs with batch 1 (or a shorter one) repeat cyclically.
output = dest_image.clone()
for index in range(dest_image.shape[0]):
pasted = self._paste_single(
@@ -40,13 +40,14 @@ class NH_SimpleFacePaste:
source_image[index % source_image.shape[0]], source_mask[index % source_mask.shape[0]],
feathering,
)
if pasted is not None: # mask rỗng: giữ nguyên ảnh đích của item này
if pasted is not None: # empty mask: keep this item's destination image as is
output[index] = pasted
return (output,)
@staticmethod
def _paste_single(dest_image, dest_mask, source_image, source_mask, feathering):
"""Dán mặt của một ảnh nguồn [H,W,C] lên một ảnh đích [H,W,C]; trả None nếu một trong hai mask rỗng."""
"""Paste the face of one source image [H,W,C] onto one destination image [H,W,C]; returns None when either
mask is empty."""
src_img_np = _to_uint8(source_image)
src_mask_np = source_mask.cpu().numpy()
@@ -85,13 +86,12 @@ class NH_SimpleFacePaste:
return torch.from_numpy(output_img_np.astype(np.float32) / 255.0)
# --- PHẦN BỊ THIẾU TRƯỚC ĐÂY ĐÃ ĐƯỢC THÊM VÀO ĐÂY ---
# Ánh xạ để ComfyUI nhận diện class
# Mapping that lets ComfyUI find the class
NODE_CLASS_MAPPINGS = {
"NH_SimpleFacePaste": NH_SimpleFacePaste
}
# Tên sẽ hiển thị trong menu của ComfyUI
# Name shown in the ComfyUI menu
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_SimpleFacePaste": "NH Simple Face Paste"
}
+36 -6
View File
@@ -13,6 +13,7 @@ import folder_paths
import node_helpers
from comfy_execution.graph_utils import ExecutionBlocker
from ...core.allowed_folders import can_read, require_readable
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
@@ -179,11 +180,32 @@ def _entry_is_dir(entry, follow_symlinks=True):
def _absolute_root(root_path):
"""The matching root as an absolute path; a relative one is taken relative to ComfyUI's input folder."""
"""The matching root as an absolute path; a relative one is taken relative to ComfyUI's input folder.
FolderNotAllowed for a root outside the folders NH-Nodes may read, before the disk is touched.
"""
root_path = (root_path or "").strip()
if root_path and not os.path.isabs(root_path):
if not root_path:
return root_path
if not os.path.isabs(root_path):
root_path = os.path.join(folder_paths.get_input_directory(), root_path)
return root_path
return require_readable(root_path, "matching_root_path")
def _source_folder(source_path):
"""The folder a source path names, worked out from the text alone: the source is only read for its folder names.
A path that ends in an image file name stands for the folder of that file. An empty path is ComfyUI's input folder,
a relative one lies below it.
"""
source_path = (source_path or "").strip()
if not source_path:
return folder_paths.get_input_directory()
if not os.path.isabs(source_path):
source_path = os.path.join(folder_paths.get_input_directory(), source_path)
if Path(source_path).suffix.lower() in IMAGE_EXTENSIONS:
source_path = os.path.dirname(source_path)
return source_path
def _check_search_root(root_path, recursive):
@@ -205,9 +227,13 @@ def _list_folder(folder_path):
except OSError:
return []
models_dir = os.path.normcase(os.path.abspath(folder_paths.models_dir))
# A sub-folder that is a link or junction out of the folders NH-Nodes may read is left out, like the models folder.
return [
entry for entry in entries
if not (_entry_is_dir(entry) and os.path.normcase(os.path.normpath(entry.path)) == models_dir)
if not (
_entry_is_dir(entry)
and (os.path.normcase(os.path.normpath(entry.path)) == models_dir or not can_read(entry.path))
)
]
@@ -250,7 +276,10 @@ def _context_twins(root_path, context_names, wanted=None):
if position == 0 or own_name not in context:
continue
rest = parts[position + 1:]
for sibling_path in _child_folders(str(Path(*parts[:position]))):
parent = str(Path(*parts[:position]))
if not can_read(parent):
continue # a parent NH-Nodes may not read is never listed, so its other context folders are no twins
for sibling_path in _child_folders(parent):
sibling_name = _normalize_match_value(os.path.basename(sibling_path))
if sibling_name == own_name or sibling_name not in context:
continue
@@ -407,7 +436,7 @@ def _matching_selection(
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)
resolved_source_folder = _source_folder(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)
@@ -499,6 +528,7 @@ def _load_image_batch(paths):
images = []
target_size = None
for path in paths:
require_readable(path, "The image file") # a file that is a link out of the allowed folders is not opened
img = node_helpers.pillow(Image.open, path)
img = node_helpers.pillow(ImageOps.exif_transpose, img).convert("RGB")
if target_size is None:
+5 -5
View File
@@ -14,7 +14,9 @@ from ...core.image_ops import RESAMPLING, parse_color, pil_to_image_tensor
_FONT_EXTENSIONS = {".ttf", ".otf", ".ttc"}
@functools.lru_cache(maxsize=1)
def _font_choices():
""""default" and the fonts of the Windows font folder. Read when the node list is first asked for, not at import."""
fonts = ["default"]
seen = set(fonts)
font_dir = Path(os.environ.get("WINDIR", "C:\\Windows")) / "Fonts"
@@ -26,9 +28,6 @@ def _font_choices():
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."""
@@ -44,7 +43,8 @@ def _default_font():
def _load_font(font_name, font_size):
if font_name and font_name != "default":
# Only a name from the list: a wired text could otherwise be any path, a network path included.
if font_name and font_name != "default" and font_name in _font_choices():
font_path = Path(os.environ.get("WINDIR", "C:\\Windows")) / "Fonts" / font_name
if font_path.exists():
font = _cached_truetype(str(font_path), font_size)
@@ -96,7 +96,7 @@ class NH_ImageLabel:
"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_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}),
+5 -4
View File
@@ -9,8 +9,8 @@ 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).
# Longest side (px) each format can write; PNG/TIFF have no limit in practice.
# JPEG: the spec allows up to 65535 but libjpeg (Pillow) rejects > 65500 (JPEG_MAX_DIMENSION).
_MAX_SIDE_BY_EXTENSION = {"webp": 16383, "jpg": 65500}
@@ -91,9 +91,10 @@ class NH_SaveLargeImage:
def save(self, image, filename_prefix="NH_Large", file_format="png", quality=95, embed_metadata=True, prompt=None, extra_pnginfo=None):
extension = _extension_for_save_format(file_format)
# Kiểm tra giới hạn trước khi ghi bất kỳ file nào (mọi ảnh trong batch cùng kích thước), kể cả tạo thư mục.
# Check the limit before writing any file, creating the folder included (every image of the batch has the
# same size).
_check_size_limit(extension, int(image.shape[2]), int(image.shape[1]))
# --disable-metadata của ComfyUI thắng ô embed_metadata: không nhúng prompt/workflow.
# ComfyUI's --disable-metadata overrides the embed_metadata toggle: no prompt/workflow is embedded.
embed_metadata = bool(embed_metadata) and not args.disable_metadata
first = image[0]
full_output_folder, filename, counter, _subfolder, _filename_prefix = folder_paths.get_save_image_path(
+2 -2
View File
@@ -26,8 +26,8 @@ class NH_LargePreviewCacheManager:
@classmethod
def IS_CHANGED(cls, **kwargs):
# Info/cleanup đọc và xoá file trên đĩa, input không đổi vẫn phải chạy lại mỗi lần queue;
# NaN không bằng chính nó nên ComfyUI không bao giờ dùng lại output cache.
# Info/cleanup read and delete files on disk, so the node must run again on every queue even with unchanged
# inputs; NaN never equals itself, so ComfyUI never reuses the cached output.
return float("nan")
def run(self, action="info", max_age_hours=24.0, max_total_mb=2048.0, cache_id=""):
+27 -24
View File
@@ -6,11 +6,13 @@ 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.
"""Resize the image to the target size while making sure the mask region is not lost.
- Crop: scale để mask vừa khít target, crop phần ảnh thừa (pad nếu ảnh nhỏ hơn target).
- Pad: scale vừa đủ để mask nằm trong target, pad phần thừa bằng màu tùy chọn.
- Mask rỗng: coi như mask phủ cả ảnh (crop: lấp đầy target rồi cắt giữa; pad: cả ảnh nằm gọn trong target).
- Crop: scale so the mask fits the target exactly, crop the surplus image (pad when the image is smaller than
the target).
- Pad: scale just enough for the mask to lie inside the target, pad the rest with the chosen colour.
- Empty mask: treated as a mask covering the whole image (crop: fill the target, then centre-crop; pad: the
whole image fits inside the target).
"""
@classmethod
@@ -47,7 +49,7 @@ class NH_MaskResizeImage:
results_img.append(out_img)
results_mask.append(out_mask)
# Lanczos có thể vọt ra ngoài [0,1] (ringing) quanh cạnh tương phản cao -> kẹp lại
# Lanczos can overshoot [0,1] (ringing) around high-contrast edges -> clamp
out_images = torch.from_numpy(np.clip(np.stack(results_img, axis=0), 0.0, 1.0)).float()
out_masks = torch.from_numpy(np.stack(results_mask, axis=0)).float()
return (out_images, out_masks)
@@ -55,34 +57,34 @@ class NH_MaskResizeImage:
def _process_single(self, img_np, mask_np, tw, th, mode, pad_color):
sh, sw = img_np.shape[:2]
# Tìm bounding box của mask
# Find the bounding box of the mask
bbox = mask_bbox(mask_np)
if bbox is None:
# Mask rỗng: resize bình thường
# Empty mask: plain resize
return self._simple_resize(img_np, mask_np, tw, th, mode, pad_color)
mx1, my1, mx2, my2 = bbox
mw = mx2 - mx1 + 1
mh = my2 - my1 + 1
# Tính scale cần thiết để vùng mask vừa trong target
# Compute the scale needed for the mask region to fit the target
scale_for_mask_w = tw / mw
scale_for_mask_h = th / mh
# Cả hai mode: scale lớn nhất mà toàn bộ mask vẫn nằm trong target.
# (Dùng max ở đây sẽ làm mask tràn target ở một chiều và bị crop mất.)
# Both modes: the largest scale at which the whole mask still lies inside the target.
# (Using max here would make the mask overflow the target in one dimension and get cropped away.)
scale = min(scale_for_mask_w, scale_for_mask_h)
# Kích thước ảnh sau khi scale
# Image size after scaling
new_sw = max(1, round(sw * scale))
new_sh = max(1, round(sh * scale))
# Resize ảnh và mask
# Resize the image and the mask
img_scaled = cv2.resize(img_np, (new_sw, new_sh), interpolation=cv2.INTER_LANCZOS4)
mask_scaled = cv2.resize(mask_np, (new_sw, new_sh), interpolation=cv2.INTER_NEAREST)
# Vị trí mask center sau khi scale
# Pixel mask chiếm [mx1, mx2 + 1) -> tâm là (mx1 + mx2 + 1) / 2
# Position of the mask centre after scaling
# The mask pixels span [mx1, mx2 + 1) -> the centre is (mx1 + mx2 + 1) / 2
mask_cx = round((mx1 + mx2 + 1) / 2.0 * scale)
mask_cy = round((my1 + my2 + 1) / 2.0 * scale)
@@ -94,15 +96,15 @@ class NH_MaskResizeImage:
def _crop_around_center(self, img, mask, tw, th, cx, cy, pad_color):
h, w = img.shape[:2]
# Ảnh scaled nhỏ hơn target: không thể crop mà giữ đủ mask -> pad bằng màu user chọn
# Scaled image smaller than the target: cropping cannot keep the whole mask -> pad with the user's colour
if w < tw or h < th:
return self._pad_around_center(img, mask, tw, th, cx, cy, pad_color)
# Tính vùng crop, giữ mask center ở giữa target
# Compute the crop region, keeping the mask centre in the middle of the target
x1 = cx - tw // 2
y1 = cy - th // 2
# Clamp để không vượt ra ngoài ảnh
# Clamp so the crop does not leave the image
x1 = max(0, min(x1, w - tw))
y1 = max(0, min(y1, h - th))
x2 = x1 + tw
@@ -113,15 +115,15 @@ class NH_MaskResizeImage:
def _pad_around_center(self, img, mask, tw, th, cx, cy, pad_color):
h, w = img.shape[:2]
# Vị trí đặt ảnh trên canvas target, giữ mask center ở giữa target
# Where the image goes on the target canvas, keeping the mask centre in the middle of the target
paste_x = tw // 2 - cx
paste_y = th // 2 - cy
# Clamp: đảm bảo ảnh không bị đặt quá xa khiến vùng mask bị cắt
# Clamp: make sure the image is not placed so far off that the mask region gets cut
paste_x = max(min(paste_x, tw - 1), -(w - 1))
paste_y = max(min(paste_y, th - 1), -(h - 1))
# Tạo canvas
# Create the canvas
canvas_img = np.zeros((th, tw, img.shape[2]), dtype=img.dtype)
r, g, b = pad_color
canvas_img[:, :, 0] = r / 255.0
@@ -130,7 +132,7 @@ class NH_MaskResizeImage:
canvas_mask = np.zeros((th, tw), dtype=mask.dtype)
# Tính vùng overlap
# Compute the overlap region
src_x1 = max(0, -paste_x)
src_y1 = max(0, -paste_y)
dst_x1 = max(0, paste_x)
@@ -147,9 +149,10 @@ class NH_MaskResizeImage:
return canvas_img, canvas_mask
def _simple_resize(self, img_np, mask_np, tw, th, mode, pad_color):
"""Fallback khi mask rỗng: coi như mask phủ cả ảnh, không méo ảnh.
"""Fallback for an empty mask: treat it as a mask covering the whole image, without distorting the image.
crop = scale lớn nhất (lấp đầy target) rồi cắt giữa; pad = scale nhỏ nhất (cả ảnh nằm trong target), phần thừa pad_color.
crop = the larger scale (fills the target), then centre-crop; pad = the smaller scale (the whole image fits
inside the target), the rest is pad_color.
"""
sh, sw = img_np.shape[:2]
scale = max(tw / sw, th / sh) if mode == "crop" else min(tw / sw, th / sh)
@@ -158,7 +161,7 @@ class NH_MaskResizeImage:
img_scaled = cv2.resize(img_np, (new_sw, new_sh), interpolation=cv2.INTER_LANCZOS4)
mask_scaled = cv2.resize(mask_np, (new_sw, new_sh), interpolation=cv2.INTER_NEAREST)
# Tâm ảnh sau khi scale
# Image centre after scaling
cx = round(sw / 2.0 * scale)
cy = round(sh / 2.0 * scale)
if mode == "crop":
+9 -4
View File
@@ -1,4 +1,4 @@
"""Save Image Path (NH): numbered image files in any folder, with an optional preview."""
"""Save Image Path (NH): numbered image files in a folder NH-Nodes may write to, with an optional preview."""
import os
import re
@@ -6,6 +6,7 @@ import uuid
import folder_paths
from ...core.allowed_folders import require_writable
from ...core.utils import tensor_to_pil
_WINDOWS = os.name == "nt"
@@ -15,11 +16,13 @@ def _save_dir_from_text(folder_path):
folder_path = (folder_path or "").strip()
if not folder_path:
return folder_paths.get_output_directory()
if not os.path.isabs(folder_path):
folder_path = os.path.join(folder_paths.get_output_directory(), folder_path)
# Checked before the disk is touched: ComfyUI's output folder and the folders the machine's owner listed.
folder_path = require_writable(folder_path, "folder_path")
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)
return folder_path
def _contained_path(base_dir, relative, what):
@@ -130,6 +133,8 @@ class NH_SaveImagePath:
# 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)
# Again for the final folder: new_folder_name or the prefix may pass through a link that leads elsewhere.
require_writable(target_dir, "The target folder")
os.makedirs(target_dir, exist_ok=True)
numbered_pattern = re.compile(rf"^{re.escape(prefix_name)}(\d+)\.(?:png|jpe?g|webp|avif)$", re.IGNORECASE)
+16 -17
View File
@@ -5,14 +5,14 @@ from ...core.mask_ops import mask_bbox
def _fit_window(start, length, limit):
"""Cửa sổ [start, start + length) trượt vào trong [0, limit); lớn hơn limit thì cắt ở mép cuối."""
"""Slide the window [start, start + length) inside [0, limit); one longer than limit is cut at the far edge."""
start = max(0, min(start, limit - length))
return start, min(limit, start + length)
def _adjust_mask(mask_np, bbox, target_size, mode):
"""Chỉnh hộp (bbox) của một mask [H, W] về đúng tỷ lệ target_size = (rộng, cao).
Kích thước hộp mới tính bằng số nguyên nên không bị lệch 1px do số thực. Ngoài hộp luôn là 0."""
"""Adjust the box (bbox) of one mask [H, W] to the aspect ratio of target_size = (width, height).
The new box size is computed in integers, so there is no 1px drift from floats. Outside the box is always 0."""
height, width = mask_np.shape
xmin, ymin, xmax, ymax = (int(v) for v in bbox)
cur_w, cur_h = xmax - xmin + 1, ymax - ymin + 1
@@ -21,31 +21,31 @@ def _adjust_mask(mask_np, bbox, target_size, mode):
output = np.zeros_like(mask_np)
if mode in ("stretch", "pad"):
# Nới một chiều: target rộng hơn thì nới ngang, ngược lại nới dọc
# Grow one dimension: horizontally when the target is wider, vertically otherwise
new_w, new_h = cur_w, cur_h
if target_is_wider:
new_w = max(1, cur_h * target_w // target_h)
else:
new_h = max(1, cur_w * target_h // target_w)
# Hộp mới nới đối xứng quanh hộp cũ. Chạm mép ảnh thì trượt vào trong để vẫn đủ kích thước.
# The new box grows symmetrically around the old one. At an image edge it slides inside to keep its full size.
x0, x1 = _fit_window(xmin - (new_w - cur_w) // 2, new_w, width)
y0, y1 = _fit_window(ymin - (new_h - cur_h) // 2, new_h, height)
if mode == "stretch":
# Kéo dãn nội dung (nearest) cho vừa hộp mới. Hộp lớn hơn cả ảnh thì chỉ hiện phần giữa.
# Stretch the content (nearest) to fit the new box. A box larger than the image shows only its middle part.
off_x = (new_w - (x1 - x0)) // 2
off_y = (new_h - (y1 - y0)) // 2
cols = (np.arange(x1 - x0) + off_x) * cur_w // new_w
rows = (np.arange(y1 - y0) + off_y) * cur_h // new_h
output[y0:y1, x0:x1] = mask_np[ymin:ymax + 1, xmin:xmax + 1][np.ix_(rows, cols)]
else:
# Pad: phần đệm thuộc vùng mask (1.0), nội dung gốc giữ nguyên vị trí cũ
# Pad: the padding belongs to the mask (1.0), the original content keeps its position
output[y0:y1, x0:x1] = 1.0
output[ymin:ymax + 1, xmin:xmax + 1] = mask_np[ymin:ymax + 1, xmin:xmax + 1]
elif mode == "crop":
# Cắt bớt một chiều quanh tâm; phần giữ lại không bị dịch chỗ
# Trim one dimension around the centre; the part that is kept does not move
new_w, new_h = cur_w, cur_h
if target_is_wider:
new_h = max(1, cur_w * target_h // target_w)
@@ -66,9 +66,8 @@ class NH_MaskAspectRatioMatch:
def INPUT_TYPES(cls):
return {
"required": {
"target_ratio_mask": ("MASK",), # Mask A (Cái áo)
"mask_to_adjust": ("MASK",), # Mask B (Hộp vuông)
# THÊM LỰA CHỌN MỚI Ở ĐÂY
"target_ratio_mask": ("MASK",), # Mask A (the garment)
"mask_to_adjust": ("MASK",), # Mask B (the square box)
"mode": (["stretch", "pad", "crop"],),
}
}
@@ -86,7 +85,7 @@ class NH_MaskAspectRatioMatch:
)
def match_aspect_ratio(self, target_ratio_mask, mask_to_adjust, mode):
# Chấp nhận mask [H, W] hoặc [B, H, W]; xử lý từng mask trong batch
# Accepts a mask [H, W] or [B, H, W]; every mask of the batch is processed
targets = target_ratio_mask.reshape(-1, *target_ratio_mask.shape[-2:])
masks = mask_to_adjust.reshape(-1, *mask_to_adjust.shape[-2:])
if targets.shape[0] == 0 or masks.shape[0] == 0:
@@ -97,12 +96,12 @@ class NH_MaskAspectRatioMatch:
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
adjusted.append(masks[index]) # Empty mask B: keep it as is
continue
# Batch của mask A ngắn hơn thì lặp lại vòng tròn
# A shorter mask A batch repeats cyclically
bbox_a = mask_bbox(targets[index % targets.shape[0]].cpu().numpy())
# Mask A rỗng thì coi như tỷ lệ 1:1
# An empty mask A counts as a 1:1 ratio
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(mask_np, bbox_b, target_size, mode)
adjusted.append(torch.from_numpy(output_np).to(masks.device))
@@ -110,12 +109,12 @@ class NH_MaskAspectRatioMatch:
return (torch.stack(adjusted),)
# Ánh xạ để ComfyUI nhận diện class
# Mapping that lets ComfyUI find the class
NODE_CLASS_MAPPINGS = {
"NH_MaskAspectRatioMatch": NH_MaskAspectRatioMatch
}
# Tên sẽ hiển thị trong menu của ComfyUI
# Name shown in the ComfyUI menu
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_MaskAspectRatioMatch": "NH Mask Aspect Ratio Match"
}
+7 -7
View File
@@ -1,6 +1,6 @@
from ...core.mask_ops import mask_bbox
# File này chứa logic cho node Mask Properties (phiên bản gốc).
# This file holds the logic of the Mask Properties node (original version).
class NH_MaskProperties:
@classmethod
@@ -17,28 +17,28 @@ class NH_MaskProperties:
CATEGORY = "NH-Nodes/Mask"
def get_properties(self, mask):
# Chỉ xử lý mask đầu tiên trong batch; hộp bao quanh các pixel > 0.5
# Only the first mask of the batch is processed; the box encloses the pixels > 0.5
bbox = mask_bbox(mask[0].cpu().numpy())
# Xử lý trường hợp mask rỗng (toàn màu đen)
# Handle an empty mask (all black)
if bbox is None:
return (0, 0, 0, 0, 0, 0, (0, 0, 0, 0))
x1, y1, x2, y2 = bbox
# Tính toán chiều rộng và chiều cao
# Compute the width and height
width = x2 - x1 + 1
height = y2 - y1 + 1
# Trả về tất cả các giá trị theo thứ tự đã định nghĩa
# Return all values in the declared order
return (width, height, x1, y1, x2, y2, bbox)
# Ánh xạ để ComfyUI nhận diện class
# Mapping that lets ComfyUI find the class
NODE_CLASS_MAPPINGS = {
"NH_MaskProperties": NH_MaskProperties
}
# Tên sẽ hiển thị trong menu của ComfyUI
# Name shown in the ComfyUI menu
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_MaskProperties": "Mask Properties (NH)"
}
+1 -3
View File
@@ -13,7 +13,6 @@ class NH_MaskToBBox:
}
RETURN_TYPES = ("MASK",)
# THAY ĐỔI Ở ĐÂY: Thêm tên cho đầu ra
RETURN_NAMES = ("box_mask",)
FUNCTION = "create_bbox_mask"
CATEGORY = "NH-Nodes/Mask"
@@ -39,12 +38,11 @@ class NH_MaskToBBox:
return (final_output,)
# Ánh xạ class không đổi
# The class mapping key stays unchanged
NODE_CLASS_MAPPINGS = {
"NH_MaskToBBox": NH_MaskToBBox
}
# THAY ĐỔI Ở ĐÂY: Đổi tên hiển thị của node
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_MaskToBBox": "NH Create Box Mask"
}
+1 -1
View File
@@ -78,7 +78,7 @@ def _needs_warning(entry, extra_warnings):
if entry is None:
return False
text = f"{entry.get('tier', '')} {entry.get('reliability', '')} {entry.get('warning', '')}".casefold()
return any(marker in text for marker in ("community", "extrapolation", "rủi ro", "risk"))
return any(marker in text for marker in ("community", "extrapolation", "risk"))
def _build_info(model_spec, tier, ratio, width, height, entry=None, extra_warnings=None, warnings=None):
+6 -6
View File
@@ -1,14 +1,14 @@
#
# --- HƯỚNG DẪN TÙY CHỈNH ---
# Để thay đổi tên của một thanh trượt, hãy chỉnh sửa 2 nơi:
# 1. Trong `INPUT_TYPES`: Thay đổi giá trị của "label". Đây là tên sẽ hiển thị trên node.
# 2. Trong `RETURN_NAMES`: Thay đổi tên của đầu ra (output) tương ứng.
# --- CUSTOMISATION GUIDE ---
# To rename a slider, edit 2 places:
# 1. In `INPUT_TYPES`: change the value of "label". This is the name shown on the node.
# 2. In `RETURN_NAMES`: change the name of the matching output.
#
# Ví dụ, để đổi "Float 1" thành "Strength":
# For example, to rename "Float 1" to "Strength":
# - "float_1": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01, "label": "Strength"}),
# - RETURN_NAMES = ("Strength", "Float 2", ...)
#
# Sau khi chỉnh sửa, hãy lưu file và khởi động lại ComfyUI.
# After editing, save the file and restart ComfyUI.
#
class NH_MultiSliderFloat:
+9 -9
View File
@@ -1,7 +1,7 @@
# Kiểu dữ liệu tùy chỉnh cho đường ống đa năng
# Custom data type for the universal pipe
NH_UNIVERSAL_PIPE = ("NH_UNIVERSAL_PIPE",)
# Số ô input của Pack (input_0 .. input_9) cũng là độ dài của package
# The number of Pack input slots (input_0 .. input_9) is also the length of the package
_PIPE_SLOTS = 10
class NH_PackUniversal:
@@ -9,7 +9,7 @@ class NH_PackUniversal:
def INPUT_TYPES(cls):
return {
"required": {},
# Cung cấp sẵn 10 đầu vào tùy chọn
# Provides 10 optional inputs
"optional": {
"input_0": ("*",), "input_1": ("*",), "input_2": ("*",), "input_3": ("*",), "input_4": ("*",),
"input_5": ("*",), "input_6": ("*",), "input_7": ("*",), "input_8": ("*",), "input_9": ("*",),
@@ -22,8 +22,8 @@ class NH_PackUniversal:
CATEGORY = "NH-Nodes/Utils/Pipe"
def pack(self, **kwargs):
# Đóng gói theo số ô: package[i] là input_i, ô chưa nối là None.
# (list(kwargs.values()) chỉ giữ ô đã nối nên index bị lệch khi bỏ trống input_0.)
# Pack by slot number: package[i] is input_i, an unconnected slot is None.
# (list(kwargs.values()) keeps only connected slots, so the indices shift when input_0 is left empty.)
pipe = [kwargs.get(f"input_{i}") for i in range(_PIPE_SLOTS)]
return (pipe,)
@@ -40,7 +40,7 @@ class NH_UnpackUniversal:
},
}
# Đầu ra là kiểu dữ liệu bất kỳ
# The output can be any data type
RETURN_TYPES = ("*",)
RETURN_NAMES = ("item",)
FUNCTION = "unpack"
@@ -51,14 +51,14 @@ class NH_UnpackUniversal:
if pipe_data is None:
return (None,)
# index = số ô input của Pack; ngoài phạm vi (kể cả âm) thì trả None
# index = the Pack input slot number; out of range (negative included) returns None
if 0 <= index < len(pipe_data):
return (pipe_data[index],)
else:
# Trả về None nếu index nằm ngoài phạm vi
# Return None when the index is out of range
return (None,)
# --- Đăng ký các node ---
# --- Node registration ---
NODE_CLASS_MAPPINGS = {
"NH_PackUniversal": NH_PackUniversal,
"NH_UnpackUniversal": NH_UnpackUniversal,
+8 -8
View File
@@ -16,14 +16,14 @@ class NH_UniversalSliderBuilder:
}
}
# Node này sẽ tự động tạo ra các đầu ra
# This node creates its outputs automatically
RETURN_TYPES = ()
RETURN_NAMES = ()
FUNCTION = "build_sliders"
CATEGORY = "NH-Nodes/Utils"
OUTPUT_NODE = True # Đánh dấu đây là node có đầu ra động
# Node này không có input/output thật nên không dùng được. DEPRECATED ẩn nó khỏi search,
# còn workflow cũ đã lưu vẫn load được. Dùng Multi-Slider (FLOAT/INT) thay thế.
OUTPUT_NODE = True # Marks this as a node with dynamic outputs
# This node has no real inputs/outputs, so it cannot be used. DEPRECATED hides it from search,
# while saved old workflows still load. Use Multi-Slider (FLOAT/INT) instead.
DEPRECATED = True
def build_sliders(self, config, **kwargs):
@@ -31,11 +31,11 @@ class NH_UniversalSliderBuilder:
cls.RETURN_TYPES = ()
cls.RETURN_NAMES = ()
# Phân tích config để xác định các widgets và outputs
# Parse the config to determine the widgets and outputs
lines = [line.strip() for line in config.split('\n') if line.strip() and not line.strip().startswith('#')]
output_values = []
# Cần một dictionary riêng cho UI để tránh lỗi
# The UI needs its own dictionary to avoid errors
ui_widgets = {}
for i, line in enumerate(lines):
@@ -55,7 +55,7 @@ class NH_UniversalSliderBuilder:
cls.RETURN_TYPES += (type_str,)
cls.RETURN_NAMES += (name,)
# Lấy giá trị thực tế từ kwargs nếu có, nếu không dùng giá trị mặc định
# Take the actual value from kwargs when present, otherwise use the default
cast_fn = float if type_str == "FLOAT" else int
output_values.append(kwargs.get(widget_name, cast_fn(default)))
@@ -75,7 +75,7 @@ class NH_BooleanSwitch:
def get_value(self, boolean_switch):
return (boolean_switch,)
# --- Đăng ký các node ---
# --- Node registration ---
NODE_CLASS_MAPPINGS = {
"NH_UniversalSliderBuilder": NH_UniversalSliderBuilder,
"NH_BooleanSwitch": NH_BooleanSwitch
+2 -3
View File
@@ -3,9 +3,8 @@
# 2nd Edited by https://github.com/Hzzone/pytorch-openpose
# 3rd Edited by ControlNet
# 4th Edited by ControlNet (added face and correct hands)
import os
os.environ["KMP_DUPLICATE_LIB_OK"]="TRUE"
# NH-Nodes: the upstream file set KMP_DUPLICATE_LIB_OK here; a custom node must not change the environment of the
# whole ComfyUI process, so that line is gone.
import torch
import numpy as np
+10 -7
View File
@@ -163,7 +163,7 @@ class NH_VTonUltimateProcessor:
def process(self, human_image, category, mask_feathering, cover_shoes, refine_hands, refine_hair, device,
offset_top=0, offset_bottom=0, offset_left=0, offset_right=0, keep_model_loaded=False):
# Import trước load_model: thiếu scikit-image thì báo lỗi ngay, không phải tải model xong mới lỗi.
# Import before load_model: a missing scikit-image fails right away, not after the models have been loaded.
from ...core.utils_mask import get_mask_location
if human_image.shape[0] == 0:
@@ -190,7 +190,8 @@ class NH_VTonUltimateProcessor:
refine_hair, device, offset_top, offset_bottom, offset_left, offset_right):
# The models are only referenced from this frame and _process_image's, so release_vton_models() can free them.
vton_model = self.load_model(device)
# Mỗi ảnh trong batch chạy độc lập với cùng setting, rồi nối các output theo chiều batch.
# Each image of the batch runs independently with the same settings; the outputs are then joined along the
# batch dimension.
results = [
self._process_image(
vton_model, get_mask_location, human_image[index], index, category, mask_feathering, cover_shoes,
@@ -202,13 +203,15 @@ class NH_VTonUltimateProcessor:
def _process_image(self, vton_model, get_mask_location, human_image, index, category, mask_feathering, cover_shoes,
refine_hands, refine_hair, offset_top, offset_bottom, offset_left, offset_right):
"""Xử lý đúng một ảnh [H,W,C]; `index` là vị trí trong batch (chỉ dùng cho thông báo lỗi)."""
# DWPose và parsing cần RGB 3 kênh: ảnh xám làm hỏng bước đảo BGR, RGBA đưa 4 kênh vào DWPose (alpha bị bỏ).
"""Process exactly one image [H,W,C]; `index` is its position in the batch (used only for error messages)."""
# DWPose and parsing need 3-channel RGB: a grayscale image breaks the BGR flip, and RGBA would feed 4 channels
# to DWPose (the alpha is dropped).
human_img_pil = tensor_to_pil(human_image).convert("RGB")
human_img_resized = resize_image(human_img_pil)
pose_img_np, _, _, candidate = vton_model['dwprocessor'](np.array(human_img_resized)[:,:,::-1])
# DWPose không trả mảng rỗng khi không thấy người: nó vẫn chạy pose trên cả ảnh và đánh dấu mọi keypoint là -1.
# DWPose does not return an empty array when it sees no person: it still runs pose on the whole image and marks
# every keypoint as -1.
if len(candidate) == 0 or not np.any(candidate[0] != -1):
raise ValueError(
f"VTON Ultimate Processor: DWPose found no person in the image at batch index {index}. "
@@ -237,7 +240,7 @@ class NH_VTonUltimateProcessor:
if mask_np.ndim == 3:
mask_np = mask_np[:,:,0]
# --- CÁC LOGIC TINH CHỈNH ---
# --- REFINEMENT LOGIC ---
if category in ["Lower-body", "Lower-body (Shorts/Skirt)"]:
body_cols = np.any((parsing_map_full_np > 0), axis=0)
@@ -289,7 +292,7 @@ class NH_VTonUltimateProcessor:
feathered_pil = feathered_pil.filter(ImageFilter.GaussianBlur(radius=mask_feathering))
mask_np = np.array(feathered_pil).astype(np.float32) / 255.0
# --- Tạo các output cuối cùng ---
# --- Build the final outputs ---
person_silhouette_np = (parsing_map_full_np > 0).astype(np.float32)
agnostic_mask_np = np.clip(person_silhouette_np - mask_np, 0, 1)
agnostic_mask_pil = Image.fromarray((agnostic_mask_np * 255).astype(np.uint8))
+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, SAM 3 object segmentation from mask formulas (Vietnamese words too), instance selection, close-up garment/portrait parsing, body-part masks by name (Sapiens2), and VTON preprocessing."
version = "1.9.0"
version = "1.10.0"
license = { file = "LICENSE" }
dependencies = [
"numpy",
+15
View File
@@ -19,3 +19,18 @@ if not torch.cuda.is_available():
from comfy.cli_args import args # noqa: E402
args.cpu = True
import importlib # noqa: E402
import pytest # noqa: E402
@pytest.fixture(autouse=True)
def _pytest_folders_are_declared(tmp_path_factory, monkeypatch):
"""Every test runs as if pytest's temp folder were listed under nh_image_folders.
The image nodes only read and write inside ComfyUI's folders and the declared ones. A test of that limit
replaces ``_declared`` itself.
"""
allowed_folders = importlib.import_module("NH-Nodes.core.allowed_folders")
monkeypatch.setattr(allowed_folders, "_declared", [str(tmp_path_factory.getbasetemp())])
+504
View File
@@ -0,0 +1,504 @@
"""The image nodes and the folder-count route only read and write inside ComfyUI's folders and the declared ones.
core/allowed_folders.py decides; Save Image Path, Load Images Folder, Load images matching and
GET /nh-nodes/load-images-folder/count ask it before they touch the disk.
"""
import asyncio
import importlib
import json
import os
import pytest
import torch
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from PIL import Image
import folder_paths
allowed_folders = importlib.import_module("NH-Nodes.core.allowed_folders")
image_folders = importlib.import_module("NH-Nodes.core.image_folders")
folder_routes = importlib.import_module("NH-Nodes.core.folder_routes")
folder_loaders = importlib.import_module("NH-Nodes.nodes.image.folder_loaders")
save_image_path = importlib.import_module("NH-Nodes.nodes.image.save_image_path")
FolderNotAllowed = allowed_folders.FolderNotAllowed
@pytest.fixture
def machine(tmp_path, monkeypatch):
"""A ComfyUI with input/output/temp folders, one declared folder (photos) and one that is not (private)."""
for name in ("input", "output", "temp", "photos", "private"):
(tmp_path / name).mkdir()
monkeypatch.setattr(folder_paths, "get_input_directory", lambda: str(tmp_path / "input"))
monkeypatch.setattr(folder_paths, "get_output_directory", lambda: str(tmp_path / "output"))
monkeypatch.setattr(folder_paths, "get_temp_directory", lambda: str(tmp_path / "temp"))
monkeypatch.setattr(allowed_folders, "_declared", [str(tmp_path / "photos")])
return tmp_path
def _png(path):
path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (4, 4), (10, 20, 30)).save(path)
return path
def _link_or_skip(link, target):
"""A directory link ``link`` -> ``target``: a symlink, or a junction where Windows refuses symlinks."""
try:
os.symlink(target, link, target_is_directory=True)
return
except OSError:
pass
if os.name != "nt":
pytest.skip("cannot create a directory link here")
import subprocess
done = subprocess.run(["cmd", "/c", "mklink", "/J", str(link), str(target)], capture_output=True)
if done.returncode != 0:
pytest.skip("cannot create a directory link here")
# --- the rule ----------------------------------------------------------------------------------------------------
@pytest.mark.parametrize("name", ["input", "output", "temp", "photos"])
def test_comfyui_folders_and_declared_folders_are_readable(machine, name):
assert allowed_folders.can_read(str(machine / name))
assert allowed_folders.can_read(str(machine / name / "sub" / "deeper")) # need not exist
@pytest.mark.parametrize("name, writable", [("output", True), ("photos", True), ("input", False), ("temp", False)])
def test_only_output_and_declared_folders_are_writable(machine, name, writable):
target = str(machine / name / "sub")
if writable:
assert allowed_folders.require_writable(target) == os.path.abspath(target)
else:
with pytest.raises(FolderNotAllowed):
allowed_folders.require_writable(target)
@pytest.mark.parametrize("relative", ["private", "output/../private", "photos/../../x", "outputs", "photos2"])
def test_other_folders_are_refused(machine, relative):
path = os.path.join(str(machine), *relative.split("/"))
assert not allowed_folders.can_read(path)
with pytest.raises(FolderNotAllowed):
allowed_folders.require_readable(path)
with pytest.raises(FolderNotAllowed):
allowed_folders.require_writable(path)
def test_a_refusal_is_a_permission_error_that_says_how_to_allow_the_folder(machine):
with pytest.raises(PermissionError) as error:
allowed_folders.require_writable(str(machine / "private"), "folder_path")
message = str(error.value)
assert "folder_path" in message and "nh_image_folders" in message and "extra_model_paths.yaml" in message
assert "restart ComfyUI" in message
@pytest.mark.skipif(os.name != "nt", reason="Windows paths")
def test_windows_spellings_of_an_allowed_folder_are_the_same_folder(machine):
assert allowed_folders.can_read(str(machine / "PHOTOS" / "a").upper())
assert allowed_folders.can_read(str(machine / "photos").replace("\\", "/") + "/a")
@pytest.mark.skipif(os.name != "nt", reason="Windows paths")
@pytest.mark.parametrize("path", ["\\\\server\\share\\x", "//server/share/x", "\\\\?\\UNC\\server\\share\\x"])
def test_a_network_path_is_refused_without_resolving_it(machine, monkeypatch, path):
# Resolving a \\server\share path makes Windows contact that server, so a refused path is never resolved.
# (The allowed folders themselves are resolved: they are the owner's.)
real_realpath = os.path.realpath
def no_network_resolving(value, *args, **kwargs):
if "server" in str(value).lower():
raise AssertionError(f"resolved {value!r}")
return real_realpath(value, *args, **kwargs)
monkeypatch.setattr(os.path, "realpath", no_network_resolving)
assert not allowed_folders.can_read(path)
def test_another_drive_is_refused(machine):
other = "Q:\\x" if os.name == "nt" else "/nh-nodes-test-other-root/x"
assert not allowed_folders.can_read(other)
def test_a_link_inside_an_allowed_folder_cannot_lead_out_of_it(machine):
_link_or_skip(machine / "photos" / "way_out", machine / "private")
assert not allowed_folders.can_read(str(machine / "photos" / "way_out"))
assert not allowed_folders.can_read(str(machine / "photos" / "way_out" / "deeper"))
with pytest.raises(FolderNotAllowed):
allowed_folders.require_writable(str(machine / "photos" / "way_out"))
def test_an_allowed_folder_reached_through_a_link_still_works(machine, monkeypatch):
# ComfyUI's own folders are often links (a models or output folder on another disk).
_link_or_skip(machine / "linked_output", machine / "output")
monkeypatch.setattr(folder_paths, "get_output_directory", lambda: str(machine / "linked_output"))
assert allowed_folders.require_writable(str(machine / "linked_output" / "day1"))
def test_nothing_is_declared_by_default(machine, monkeypatch):
monkeypatch.setattr(allowed_folders, "_declared", [])
assert allowed_folders.declared_folders() == []
assert not allowed_folders.can_read(str(machine / "photos"))
assert allowed_folders.can_read(str(machine / "input"))
def test_folders_of_extra_model_paths_are_taken_and_not_left_as_a_model_folder(machine, monkeypatch):
# ComfyUI registers every key of extra_model_paths.yaml as a model folder and lists such folders at /models.
registry = dict(folder_paths.folder_names_and_paths)
registry[allowed_folders.FOLDER_KEY] = ([str(machine / "private"), str(machine / "photos")], set())
monkeypatch.setattr(folder_paths, "folder_names_and_paths", registry)
monkeypatch.setattr(allowed_folders, "_declared", [])
assert allowed_folders.declared_folders() == [str(machine / "private"), str(machine / "photos")]
assert allowed_folders.FOLDER_KEY not in folder_paths.folder_names_and_paths
assert allowed_folders.can_read(str(machine / "private"))
assert allowed_folders.declared_folders() == [str(machine / "private"), str(machine / "photos")] # kept
# --- Save Image Path ---------------------------------------------------------------------------------------------
def _save(folder_path, **kwargs):
settings = dict(filename_prefix="img", create_new_folder=False, new_folder_name="")
settings.update(kwargs)
return save_image_path.NH_SaveImagePath().save_images(torch.rand((1, 4, 4, 3)), folder_path=folder_path, **settings)
def test_save_refuses_a_folder_that_is_not_allowed_and_creates_nothing(machine):
target = machine / "private" / "new" / "deeper"
with pytest.raises(FolderNotAllowed, match="folder_path"):
_save(str(target))
assert not (machine / "private" / "new").exists()
assert list(machine.rglob("*.png")) == []
@pytest.mark.parametrize("folder_path", [
"../private",
"a/../../private",
# A backslash separates folders on Windows only; elsewhere this is one odd folder name inside output.
pytest.param("..\\private", marks=pytest.mark.skipif(os.name != "nt", reason="Windows separator")),
])
def test_save_refuses_a_relative_path_that_leaves_the_output_folder(machine, folder_path):
with pytest.raises(FolderNotAllowed):
_save(folder_path)
assert list(machine.rglob("*.png")) == []
def test_save_refuses_the_input_folder(machine):
with pytest.raises(FolderNotAllowed):
_save(str(machine / "input"))
def test_save_works_in_the_output_folder_and_in_a_declared_folder(machine):
assert _save("")[1] == str(machine / "output")
assert _save("day1")[1] == str(machine / "output" / "day1")
assert _save(str(machine / "photos" / "exports"))[1] == str(machine / "photos" / "exports")
saved = sorted(str(path.relative_to(machine)).replace("\\", "/") for path in machine.rglob("*.png"))
assert saved == ["output/day1/img0001.png", "output/img0001.png", "photos/exports/img0001.png"]
def test_save_next_to_an_existing_file_of_an_allowed_folder(machine):
source = _png(machine / "photos" / "shoot" / "a.png")
assert _save(str(source))[1] == str(machine / "photos" / "shoot")
def test_save_never_looks_at_a_file_outside_the_allowed_folders(machine, monkeypatch):
secret = _png(machine / "private" / "a.png")
looked = []
real_isfile = os.path.isfile
monkeypatch.setattr(os.path, "isfile", lambda path: looked.append(str(path)) or real_isfile(path))
with pytest.raises(FolderNotAllowed):
_save(str(secret))
assert looked == []
# --- Load Images Folder ------------------------------------------------------------------------------------------
def _load_folder(folder_path, **kwargs):
settings = dict(index=0, step=1, seed_mode="fixed")
settings.update(kwargs)
return folder_loaders.NH_LoadImagesFromFolder().load_images(folder_path, **settings)
def test_load_folder_refuses_a_folder_that_is_not_allowed(machine, monkeypatch):
_png(machine / "private" / "a.png")
listed = []
real_listdir = os.listdir
monkeypatch.setattr(os, "listdir", lambda path: listed.append(str(path)) or real_listdir(path))
with pytest.raises(FolderNotAllowed, match="folder_path"):
_load_folder(str(machine / "private"))
with pytest.raises(FolderNotAllowed):
_load_folder("../private")
assert listed == []
def test_load_folder_change_check_does_not_raise_for_a_refused_folder(machine):
_png(machine / "private" / "a.png")
result = folder_loaders.NH_LoadImagesFromFolder.IS_CHANGED(str(machine / "private"), 0, 1, "fixed")
assert isinstance(result, str) and result.startswith("error:FolderNotAllowed")
@pytest.mark.parametrize("name", ["input", "output", "temp", "photos"])
def test_load_folder_reads_comfyui_folders_and_declared_folders(machine, name):
_png(machine / name / "set" / "a.png")
images, filename, path = _load_folder(str(machine / name / "set"))
assert images.shape == (1, 4, 4, 3) and filename == "a.png"
assert path == str(machine / name / "set" / "a.png")
def test_load_folder_relative_path_stays_below_the_input_folder(machine):
_png(machine / "input" / "set" / "a.png")
assert _load_folder("set")[1] == "a.png"
# --- Load images matching ----------------------------------------------------------------------------------------
def _matching_args(**overrides):
settings = dict(
source_folder_path="", matching_root_path="", match_text="", match_mode="contains", recursive=True,
index=0, step=1, repeat_count=1, context_folder_names="Nam\nNu", source_file_names="",
)
settings.update(overrides)
return settings
def test_matching_refuses_a_root_that_is_not_allowed(machine):
_png(machine / "private" / "Anh" / "a.png")
node = folder_loaders.NH_LoadImagesMatching()
args = _matching_args(matching_root_path=str(machine / "private"), match_text="Anh")
with pytest.raises(FolderNotAllowed, match="matching_root_path"):
node.load_matching(**args)
assert folder_loaders.NH_LoadImagesMatching.IS_CHANGED(**args) != folder_loaders.NH_LoadImagesMatching.IS_CHANGED(**args) # NaN
assert node.check_lazy_status(fallback_images=None, **args) == []
def test_matching_reads_a_declared_root_and_the_source_is_names_only(machine, monkeypatch):
_png(machine / "photos" / "library" / "Anh" / "a.png")
probed = []
real_isfile = os.path.isfile
monkeypatch.setattr(os.path, "isfile", lambda path: probed.append(str(path)) or real_isfile(path))
source = machine / "private" / "shoot" / "Anh" # not allowed, and never touched: only its names count
_, filename, path, matched_text, found = folder_loaders.NH_LoadImagesMatching().load_matching(
**_matching_args(source_folder_path=str(source), matching_root_path=str(machine / "photos" / "library"))
)
assert found and filename == "a.png" and matched_text == "Anh"
assert path == str(machine / "photos" / "library" / "Anh")
assert [item for item in probed if "private" in item] == []
def test_matching_takes_the_folder_of_a_source_image_path_from_the_text(machine):
_png(machine / "photos" / "library" / "Anh" / "a.png")
source_file = machine / "private" / "shoot" / "Anh" / "portrait.JPG" # does not exist
_, _, _, matched_text, found = folder_loaders.NH_LoadImagesMatching().load_matching(
**_matching_args(source_folder_path=str(source_file), matching_root_path=str(machine / "photos" / "library"))
)
assert found and matched_text == "Anh"
def test_matching_does_not_list_a_parent_that_is_not_allowed_for_twins(machine, monkeypatch):
# The root sits in a context folder (Nam) whose parent is not allowed: the sibling Nu is not looked for.
monkeypatch.setattr(allowed_folders, "_declared", [str(machine / "private" / "Nam")])
_png(machine / "private" / "Nam" / "Anh" / "a.png")
_png(machine / "private" / "Nu" / "Anh" / "b.png")
scanned = []
real_scandir = os.scandir
monkeypatch.setattr(os, "scandir", lambda path: scanned.append(os.path.normcase(str(path))) or real_scandir(path))
_, filename, _, _, found = folder_loaders.NH_LoadImagesMatching().load_matching(
**_matching_args(source_folder_path="x/Nu/Anh", matching_root_path=str(machine / "private" / "Nam"))
)
assert found and filename == "a.png"
assert os.path.normcase(str(machine / "private")) not in scanned
assert not any(os.path.normcase(str(machine / "private" / "Nu")) in item for item in scanned)
# --- the count route ---------------------------------------------------------------------------------------------
def _count(**params):
async def run():
routes = web.RouteTableDef()
folder_routes.register_routes(routes)
app = web.Application()
app.add_routes(routes)
async with TestClient(TestServer(app)) as client:
response = await client.get(folder_routes.COUNT_PATH, params=params)
return response.status, await response.read()
status, body = asyncio.run(run())
return status, json.loads(body), body.decode("utf-8")
def test_count_route_refuses_a_folder_that_is_not_allowed(machine):
_png(machine / "private" / "a.png")
for folder_path in (str(machine / "private"), "../private", str(machine / "private" / "missing")):
status, body, raw = _count(folder_path=folder_path)
assert (status, body) == (403, {"error": "folder is not allowed"})
assert "private" not in raw and str(machine) not in raw
def test_count_route_counts_in_allowed_folders(machine):
_png(machine / "photos" / "a.png")
_png(machine / "input" / "b.png")
assert _count(folder_path=str(machine / "photos"))[:2] == (200, {"count": 1})
assert _count(folder_path="")[:2] == (200, {"count": 1})
# --- nothing outside the pack is touched at import ----------------------------------------------------------------
PACK_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def test_image_label_reads_the_font_folder_only_when_the_node_list_is_built(monkeypatch):
import importlib.util
from pathlib import Path
def no_listing(self):
raise AssertionError(f"listed {self} at import")
# A second copy of the module, so the registered node class is left alone.
spec = importlib.util.spec_from_file_location(
"NH-Nodes.nodes.image.image_label_import_probe", os.path.join(PACK_DIR, "nodes", "image", "image_label.py")
)
probe = importlib.util.module_from_spec(spec)
with monkeypatch.context() as patch:
patch.setattr(Path, "iterdir", no_listing)
spec.loader.exec_module(probe) # importing the module lists no folder
fonts = probe.NH_ImageLabel.INPUT_TYPES()["required"]["font_name"][0]
assert fonts[0] == "default" and len(fonts) == len(set(fonts))
def test_the_pack_does_not_change_the_environment_of_the_process():
import re
changes = re.compile(r"os\.environ\[[^\]]*\]\s*=(?!=)|os\.environ\.(setdefault|update|pop)\(|os\.putenv\(")
offenders = []
for folder in ("core", "nodes"):
for root, _dirs, files in os.walk(os.path.join(PACK_DIR, folder)):
for name in files:
if name.endswith(".py"):
with open(os.path.join(root, name), encoding="utf-8") as handle:
if changes.search(handle.read()):
offenders.append(os.path.relpath(os.path.join(root, name), PACK_DIR))
assert offenders == []
def test_resolution_notes_shown_in_the_ui_are_english():
resolution_data = importlib.import_module("NH-Nodes.core.resolution_data")
notes = [spec.get("note", "") for spec in resolution_data.MODEL_SPECS.values()]
assert any(notes) and all(note.isascii() for note in notes)
# --- links below an allowed folder ---------------------------------------------------------------------------------
@pytest.fixture
def way_out(machine):
"""photos/way_out is a link to private/, which holds a picture in a folder named like a match."""
_png(machine / "private" / "secret.png")
_png(machine / "private" / "Anh" / "secret.png")
_png(machine / "photos" / "own.png")
_link_or_skip(machine / "photos" / "way_out", machine / "private")
return machine
def test_reading_sub_folders_skips_a_link_that_leads_out(way_out):
files = image_folders.collect_image_files(str(way_out / "photos"), recursive=True)
assert files == ["own.png"]
assert _count(folder_path=str(way_out / "photos"), recursive="true")[:2] == (200, {"count": 1})
def test_matching_does_not_search_through_a_link_that_leads_out(way_out):
node = folder_loaders.NH_LoadImagesMatching()
for match_text in ("way", "Anh"):
result = node.load_matching(**_matching_args(matching_root_path=str(way_out / "photos"), match_text=match_text))
assert result[4] is False # found
scanned = []
real_scandir = os.scandir
with pytest.MonkeyPatch.context() as patch:
patch.setattr(os, "scandir", lambda path: scanned.append(os.path.normcase(str(path))) or real_scandir(path))
assert folder_loaders._tree_signature([str(way_out / "photos")]) is not None
assert scanned == [os.path.normcase(str(way_out / "photos"))] # the wired-input change check stays out too
def test_a_twin_context_folder_that_is_a_link_out_is_skipped_not_an_error(machine):
_png(machine / "photos" / "Nam" / "Anh" / "a.png")
_png(machine / "private" / "Anh" / "b.png")
_link_or_skip(machine / "photos" / "Nu", machine / "private")
_, filename, _, _, found = folder_loaders.NH_LoadImagesMatching().load_matching(
**_matching_args(source_folder_path="x/Nu/Anh", matching_root_path=str(machine / "photos" / "Nam"))
)
assert found and filename == "a.png"
def test_an_image_file_that_is_a_link_out_is_not_opened(machine):
secret = _png(machine / "private" / "secret.png")
try:
os.symlink(secret, machine / "photos" / "linked.png")
except OSError:
pytest.skip("cannot create a file link here")
with pytest.raises(FolderNotAllowed):
_load_folder(str(machine / "photos"))
@pytest.mark.parametrize("settings", [
dict(create_new_folder=True, new_folder_name="way_out"),
dict(filename_prefix="way_out/c"),
dict(filename_prefix="way_out\\deeper\\c"),
])
def test_save_does_not_write_through_a_link_in_the_new_folder_or_the_prefix(way_out, settings):
before = sorted(way_out.joinpath("private").rglob("*"))
with pytest.raises(FolderNotAllowed):
_save(str(way_out / "photos"), **settings)
assert sorted(way_out.joinpath("private").rglob("*")) == before
# --- more spellings ------------------------------------------------------------------------------------------------
def test_a_declared_folder_may_end_in_a_slash_or_differ_in_case(machine, monkeypatch):
spelled = str(machine / "photos") + "/"
if os.name == "nt":
spelled = spelled.upper()
monkeypatch.setattr(allowed_folders, "_declared", [spelled])
assert allowed_folders.can_read(str(machine / "photos" / "set"))
assert _save(str(machine / "photos" / "exports"))[2] == 1
@pytest.mark.skipif(os.name != "nt", reason="Windows paths")
@pytest.mark.parametrize("path", ["Q:foo", "\\\\?\\C:\\Windows", "\\\\.\\C:\\Windows", "\\\\?\\GLOBALROOT\\Device\\x"])
def test_windows_device_and_drive_relative_paths_are_refused(machine, path):
assert not allowed_folders.can_read(path)
with pytest.raises(FolderNotAllowed):
_save(path)
@pytest.mark.skipif(os.name != "nt", reason="Windows paths")
def test_a_drive_relative_path_lands_in_the_output_folder_or_is_refused(machine):
# "C:foo" is no absolute path: on the drive of the output folder it is the sub-folder foo of it, on another
# drive it is refused. Nothing is written anywhere else either way.
drive = os.path.splitdrive(str(machine))[0]
assert _save(f"{drive}foo")[1] == str(machine / "output" / "foo")
assert [path.relative_to(machine).parts[0] for path in machine.rglob("*.png")] == ["output"]
@pytest.mark.skipif(os.name != "nt", reason="Windows paths")
def test_a_device_prefix_cannot_hide_dots_that_leave_an_allowed_folder(machine):
path = "\\\\?\\" + str(machine / "photos") + "\\..\\private"
assert not allowed_folders.can_read(path)
def test_matching_change_check_with_wired_inputs_does_not_raise_for_a_refused_root(machine):
args = _matching_args(matching_root_path=str(machine / "private"), match_text=None) # match_text is wired
result = folder_loaders.NH_LoadImagesMatching.IS_CHANGED(**args)
assert result != result # NaN: unknown, run again
# --- Image Label fonts ---------------------------------------------------------------------------------------------
@pytest.mark.parametrize("font_name", ["..\\..\\secret.ttf", "C:\\secret.ttf", "\\\\server\\share\\x.ttf", "missing.ttf"])
def test_image_label_only_opens_fonts_of_its_list(monkeypatch, font_name):
image_label = importlib.import_module("NH-Nodes.nodes.image.image_label")
opened = []
real = image_label._cached_truetype
monkeypatch.setattr(image_label, "_cached_truetype", lambda path, size: opened.append(path) or real(path, size))
assert image_label._load_font(font_name, 20) is not None
assert opened == ["arial.ttf"]
+2
View File
@@ -245,6 +245,8 @@ def test_search_stays_inside_the_matching_root_and_its_context_siblings(tmp_path
def test_a_drive_root_is_never_walked(studio, monkeypatch):
drive = os.path.abspath(os.sep)
# Even when the owner of the machine declared a whole drive as an image folder.
monkeypatch.setattr(importlib.import_module("NH-Nodes.core.allowed_folders"), "_declared", [drive])
fs = _Fs(monkeypatch)
with pytest.raises(ValueError, match="drive"):
_load(source_folder_path=str(studio.source), matching_root_path=drive, match_text="Anh")
+1 -1
View File
@@ -283,7 +283,7 @@ def test_new_subfolder_inside_the_target_stays_allowed(tmp_path):
assert (tmp_path / "out" / "batch" / "day1" / "img0001.png").is_file()
def test_absolute_folder_path_is_still_allowed(tmp_path):
def test_absolute_folder_path_in_a_declared_folder_is_allowed(tmp_path):
target = tmp_path / "somewhere" / "else"
_, path, _ = _save(tmp_path, "img", folder_path=str(target))
assert path == str(target) and (target / "img0001.png").is_file()