feat(extra-paths): introduce extra_paths.yaml with full path configuration

Replace the model-only extra_model_paths.yaml with a more generic
extra_paths.yaml that covers all ComfyUI path configuration in one file.

New schema (nested style):
  comfyui:
    base_path: /path/to/comfyui/    # install root
    output: output/                  # → set_output_directory()
    input: input/
    temp: temp/
    user: user/
    custom_nodes: custom_nodes/      # explicit only, never auto-scanned
    models:
      base_path: models/             # model root, relative to parent base_path
      is_default: true
      checkpoints: checkpoints/      # or omit all categories to auto-scan

Key changes:
- System directory keys (output/input/temp/user) call set_*_directory()
- models: sub-block separates model paths from install-root paths; base_path
  at block root = install root; models/base_path = model root
- custom_nodes never auto-registered by implicit scan (fixes CodeRabbit #13560)
- Flat style fully preserved for backward compat with extra_model_paths.yaml
- extra_paths.yaml loaded first; deprecation warning logged if both present
- extra_paths.yaml.example covers all 22 model categories with preset paths
- extra_model_paths.yaml.example gains a deprecation note
This commit is contained in:
cest-la-v 2026-04-26 14:24:22 +08:00
parent 5e3f15a830
commit feee1c7a85
6 changed files with 465 additions and 25 deletions

1
.gitignore vendored
View File

@ -8,6 +8,7 @@ __pycache__/
/custom_nodes/
!custom_nodes/example_node.py.example
extra_model_paths.yaml
extra_paths.yaml
/.vs
.vscode/
.idea/

View File

@ -1,8 +1,16 @@
#Rename this to extra_model_paths.yaml and ComfyUI will load it
#
# DEPRECATED: extra_model_paths.yaml is superseded by extra_paths.yaml, which supports
# all path configuration (system dirs, custom_nodes, and models) in a cleaner format.
# See extra_paths.yaml.example. This file continues to work for backward compatibility.
#config for comfyui
#your base path should be either an existing comfy install or a central folder where you store all of your models, loras, etc.
# When base_path is set, any standard subdirectory that exists on disk is automatically
# registered — the explicit paths below are optional and only needed to override a
# category or point it to a non-standard location.
#comfyui:
# base_path: path/to/comfyui/
# # You can use is_default to mark that these folders should be listed first, and used as the default dirs for eg downloads

84
extra_paths.yaml.example Normal file
View File

@ -0,0 +1,84 @@
#Rename this to extra_paths.yaml and ComfyUI will load it
#This is the successor to extra_model_paths.yaml and supports all path configuration in one file.
#config for comfyui
#Set base_path to your ComfyUI install root. System directories (output, input, temp, user)
#and custom_nodes are resolved relative to base_path.
#
#Model paths go under the 'models' block. If you only set models/base_path, all standard
#subdirectories that exist on disk are automatically registered — no need to list them.
#Explicit paths under models/ are optional and only needed to override a specific category
#or point it to a non-standard location.
#comfyui:
# base_path: path/to/comfyui/
# # System directories (relative to base_path, or absolute)
# output: output/
# input: input/
# temp: temp/
# user: user/
# # Custom nodes directory (not auto-scanned; explicit only)
# custom_nodes: custom_nodes/
# models:
# base_path: models/
# # You can use is_default to mark that these folders should be listed first,
# # and used as the default dirs for eg downloads
# #is_default: true
# checkpoints: checkpoints/
# text_encoders: |
# text_encoders/
# clip/ # legacy location still supported
# clip_vision: clip_vision/
# configs: configs/
# controlnet: |
# controlnet/
# t2i_adapter/
# diffusion_models: |
# diffusion_models/
# unet/
# diffusers: diffusers/
# embeddings: embeddings/
# frame_interpolation: frame_interpolation/
# gligen: gligen/
# hypernetworks: hypernetworks/
# latent_upscale_models: latent_upscale_models/
# loras: loras/
# model_patches: model_patches/
# photomaker: photomaker/
# style_models: style_models/
# upscale_models: upscale_models/
# vae: vae/
# vae_approx: vae_approx/
# audio_encoders: audio_encoders/
# classifiers: classifiers/
#config for a1111 ui
#all you have to do is uncomment this (remove the #) and change the base_path to where yours is installed
#a111:
# models:
# base_path: path/to/stable-diffusion-webui/
# checkpoints: models/Stable-diffusion
# configs: models/Stable-diffusion
# vae: models/VAE
# loras: |
# models/Lora
# models/LyCORIS
# upscale_models: |
# models/ESRGAN
# models/RealESRGAN
# models/SwinIR
# embeddings: embeddings
# hypernetworks: models/hypernetworks
# controlnet: models/ControlNet
# For a full list of supported model category keys (style_models, vae_approx, hypernetworks,
# photomaker, model_patches, audio_encoders, classifiers, etc.) see folder_paths.py.
#other_ui:
# models:
# base_path: path/to/ui
# checkpoints: models/checkpoints
# gligen: models/gligen

16
main.py
View File

@ -93,8 +93,20 @@ if args.enable_manager:
def apply_custom_paths():
# extra model paths
extra_model_paths_config_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "extra_model_paths.yaml")
install_dir = os.path.dirname(os.path.realpath(__file__))
# extra_paths.yaml — primary config (superset of extra_model_paths.yaml)
extra_paths_config_path = os.path.join(install_dir, "extra_paths.yaml")
extra_model_paths_config_path = os.path.join(install_dir, "extra_model_paths.yaml")
if os.path.isfile(extra_paths_config_path):
utils.extra_config.load_extra_path_config(extra_paths_config_path)
if os.path.isfile(extra_model_paths_config_path):
logging.warning(
"Both extra_paths.yaml and extra_model_paths.yaml found. "
"extra_model_paths.yaml is deprecated; please migrate to extra_paths.yaml."
)
if os.path.isfile(extra_model_paths_config_path):
utils.extra_config.load_extra_path_config(extra_model_paths_config_path)

View File

@ -301,3 +301,271 @@ def test_load_extra_path_config_no_base_path(
actual_diffusion = folder_paths.folder_names_and_paths["diffusion_models"][0]
assert len(actual_diffusion) == 1, "Should have one path for 'diffusion_models'."
assert actual_diffusion[0] == os.path.abspath(expected_unet)
@patch("yaml.safe_load")
def test_load_extra_path_config_implicit_subdirs(
mock_yaml_load, clear_folder_paths, tmp_path
):
"""
When base_path is set and no explicit sub-paths are declared, any subdir
whose name matches a known category is auto-registered.
"""
# Create real subdirs that match known categories
(tmp_path / "checkpoints").mkdir()
(tmp_path / "loras").mkdir()
(tmp_path / "unknown_dir").mkdir() # not a registered category — should be ignored
config_data = {
"comfyui": {
"base_path": str(tmp_path),
}
}
mock_yaml_load.return_value = config_data
# Pre-populate only the categories we're testing so clear_folder_paths doesn't hide them
folder_paths.folder_names_and_paths["checkpoints"] = ([], set())
folder_paths.folder_names_and_paths["loras"] = ([], set())
yaml_path = str(tmp_path / "extra_model_paths.yaml")
with open(yaml_path, "w") as f:
f.write("") # content ignored; yaml.safe_load is mocked
load_extra_path_config(yaml_path)
assert str(tmp_path / "checkpoints") in folder_paths.folder_names_and_paths["checkpoints"][0]
assert str(tmp_path / "loras") in folder_paths.folder_names_and_paths["loras"][0]
assert "unknown_dir" not in folder_paths.folder_names_and_paths
@patch("yaml.safe_load")
def test_implicit_scan_excludes_custom_nodes(
mock_yaml_load, clear_folder_paths, tmp_path
):
"""custom_nodes must never be auto-registered by the implicit scan."""
(tmp_path / "custom_nodes").mkdir()
(tmp_path / "checkpoints").mkdir()
config_data = {"comfyui": {"base_path": str(tmp_path)}}
mock_yaml_load.return_value = config_data
folder_paths.folder_names_and_paths["checkpoints"] = ([], set())
folder_paths.folder_names_and_paths["custom_nodes"] = ([], set())
yaml_path = str(tmp_path / "extra_paths.yaml")
with open(yaml_path, "w") as f:
f.write("")
load_extra_path_config(yaml_path)
assert str(tmp_path / "checkpoints") in folder_paths.folder_names_and_paths["checkpoints"][0]
assert str(tmp_path / "custom_nodes") not in folder_paths.folder_names_and_paths["custom_nodes"][0], \
"custom_nodes must not be auto-registered by the implicit scan"
@patch("yaml.safe_load")
def test_load_extra_path_config_explicit_overrides_implicit(
mock_yaml_load, clear_folder_paths, tmp_path
):
"""
Explicit sub-path declarations take precedence; the implicit scan must not
double-register a category that was already declared explicitly.
"""
(tmp_path / "loras").mkdir()
custom_loras = tmp_path / "my_custom_loras"
custom_loras.mkdir()
config_data = {
"comfyui": {
"base_path": str(tmp_path),
"loras": "my_custom_loras", # explicit override
}
}
mock_yaml_load.return_value = config_data
folder_paths.folder_names_and_paths["loras"] = ([], set())
yaml_path = str(tmp_path / "extra_model_paths.yaml")
with open(yaml_path, "w") as f:
f.write("")
load_extra_path_config(yaml_path)
registered = folder_paths.folder_names_and_paths["loras"][0]
assert str(custom_loras) in registered
assert str(tmp_path / "loras") not in registered, "Implicit path must not override explicit"
@pytest.fixture
def save_restore_system_dirs():
"""Save and restore folder_paths system directories around a test."""
saved = {
"output": folder_paths.get_output_directory(),
"input": folder_paths.get_input_directory(),
"temp": folder_paths.get_temp_directory(),
"user": folder_paths.get_user_directory(),
}
yield
folder_paths.set_output_directory(saved["output"])
folder_paths.set_input_directory(saved["input"])
folder_paths.set_temp_directory(saved["temp"])
folder_paths.set_user_directory(saved["user"])
@patch("yaml.safe_load")
def test_system_dir_keys(mock_yaml_load, save_restore_system_dirs, tmp_path):
"""System directory keys (output, input, temp, user) call set_*_directory()."""
config_data = {
"comfyui": {
"base_path": str(tmp_path),
"output": "my_output/",
"input": "my_input/",
"temp": "my_temp/",
"user": "my_user/",
}
}
mock_yaml_load.return_value = config_data
yaml_path = str(tmp_path / "extra_paths.yaml")
with open(yaml_path, "w") as f:
f.write("")
load_extra_path_config(yaml_path)
assert folder_paths.get_output_directory() == os.path.normpath(str(tmp_path / "my_output"))
assert folder_paths.get_input_directory() == os.path.normpath(str(tmp_path / "my_input"))
assert folder_paths.get_temp_directory() == os.path.normpath(str(tmp_path / "my_temp"))
assert folder_paths.get_user_directory() == os.path.normpath(str(tmp_path / "my_user"))
@patch("yaml.safe_load")
def test_nested_models_block(mock_yaml_load, clear_folder_paths, tmp_path):
"""Nested models: block registers model paths relative to models/base_path."""
config_data = {
"comfyui": {
"base_path": str(tmp_path),
"models": {
"base_path": "models/",
"checkpoints": "checkpoints/",
"loras": "loras/",
},
}
}
mock_yaml_load.return_value = config_data
folder_paths.folder_names_and_paths["checkpoints"] = ([], set())
folder_paths.folder_names_and_paths["loras"] = ([], set())
yaml_path = str(tmp_path / "extra_paths.yaml")
with open(yaml_path, "w") as f:
f.write("")
load_extra_path_config(yaml_path)
expected_ckpt = os.path.normpath(str(tmp_path / "models" / "checkpoints"))
expected_loras = os.path.normpath(str(tmp_path / "models" / "loras"))
assert expected_ckpt in folder_paths.folder_names_and_paths["checkpoints"][0]
assert expected_loras in folder_paths.folder_names_and_paths["loras"][0]
@patch("yaml.safe_load")
def test_nested_models_is_default(mock_yaml_load, clear_folder_paths, tmp_path):
"""is_default under models: applies to all model paths in that block."""
config_data = {
"comfyui": {
"models": {
"base_path": str(tmp_path),
"is_default": True,
"checkpoints": "checkpoints/",
},
}
}
mock_yaml_load.return_value = config_data
folder_paths.folder_names_and_paths["checkpoints"] = ([], set())
yaml_path = str(tmp_path / "extra_paths.yaml")
with open(yaml_path, "w") as f:
f.write("")
mock_add = Mock()
with patch.object(folder_paths, "add_model_folder_path", mock_add):
load_extra_path_config(yaml_path)
call = mock_add.call_args_list[0]
assert call.args[0] == "checkpoints"
assert call.args[2] is True, "is_default under models: must be passed as True"
@patch("yaml.safe_load")
def test_nested_models_multipath(mock_yaml_load, clear_folder_paths, tmp_path):
"""Multi-line path values inside models: register multiple paths per category."""
config_data = {
"comfyui": {
"models": {
"base_path": str(tmp_path),
"text_encoders": "text_encoders/\nclip/",
},
}
}
mock_yaml_load.return_value = config_data
folder_paths.folder_names_and_paths["text_encoders"] = ([], set())
yaml_path = str(tmp_path / "extra_paths.yaml")
with open(yaml_path, "w") as f:
f.write("")
load_extra_path_config(yaml_path)
registered = folder_paths.folder_names_and_paths["text_encoders"][0]
assert os.path.normpath(str(tmp_path / "text_encoders")) in registered
assert os.path.normpath(str(tmp_path / "clip")) in registered
@patch("yaml.safe_load")
def test_nested_models_auto_scan(mock_yaml_load, clear_folder_paths, tmp_path):
"""models: with only base_path auto-scans for known categories that exist on disk."""
(tmp_path / "models" / "checkpoints").mkdir(parents=True)
(tmp_path / "models" / "loras").mkdir()
config_data = {
"comfyui": {
"base_path": str(tmp_path),
"models": {"base_path": "models/"},
}
}
mock_yaml_load.return_value = config_data
folder_paths.folder_names_and_paths["checkpoints"] = ([], set())
folder_paths.folder_names_and_paths["loras"] = ([], set())
yaml_path = str(tmp_path / "extra_paths.yaml")
with open(yaml_path, "w") as f:
f.write("")
load_extra_path_config(yaml_path)
assert os.path.normpath(str(tmp_path / "models" / "checkpoints")) in \
folder_paths.folder_names_and_paths["checkpoints"][0]
assert os.path.normpath(str(tmp_path / "models" / "loras")) in \
folder_paths.folder_names_and_paths["loras"][0]
@patch("yaml.safe_load")
def test_explicit_custom_nodes_key(mock_yaml_load, clear_folder_paths, tmp_path):
"""Explicit custom_nodes key in a block registers the path via add_model_folder_path."""
config_data = {
"comfyui": {
"base_path": str(tmp_path),
"custom_nodes": "my_nodes/",
}
}
mock_yaml_load.return_value = config_data
folder_paths.folder_names_and_paths["custom_nodes"] = ([], set())
yaml_path = str(tmp_path / "extra_paths.yaml")
with open(yaml_path, "w") as f:
f.write("")
load_extra_path_config(yaml_path)
assert os.path.normpath(str(tmp_path / "my_nodes")) in \
folder_paths.folder_names_and_paths["custom_nodes"][0]

View File

@ -1,34 +1,101 @@
from __future__ import annotations
import os
import yaml
import folder_paths
import logging
def load_extra_path_config(yaml_path):
_SYSTEM_DIR_KEYS = frozenset({"output", "input", "temp", "user"})
def _resolve_base(raw: str, parent_base: str | None, yaml_dir: str) -> str:
"""Resolve a base_path value: expand vars/user, join onto parent_base or yaml_dir if relative."""
raw = os.path.expandvars(os.path.expanduser(raw))
if not os.path.isabs(raw):
anchor = parent_base if parent_base else yaml_dir
raw = os.path.abspath(os.path.join(anchor, raw))
return os.path.normpath(raw)
def _add_model_paths(category: str, raw_value: str, base: str | None, yaml_dir: str, is_default: bool) -> None:
"""Split a (possibly multi-line) path value and register each path as a model folder."""
for raw in str(raw_value).split("\n"):
raw = raw.strip()
if not raw:
continue
if base and not os.path.isabs(raw):
full_path = os.path.join(base, raw)
elif not os.path.isabs(raw):
full_path = os.path.abspath(os.path.join(yaml_dir, raw))
else:
full_path = raw
normalized = os.path.normpath(full_path)
logging.info("Adding extra search path %s %s", category, normalized)
folder_paths.add_model_folder_path(category, normalized, is_default)
def _implicit_scan(base: str, exclude: set[str], is_default: bool) -> None:
"""Auto-register base/<category>/ for known model categories that exist on disk.
custom_nodes and system directory keys are always excluded from the scan.
"""
skip = _SYSTEM_DIR_KEYS | {"custom_nodes"} | exclude
for category in folder_paths.folder_names_and_paths:
if category in skip:
continue
path = os.path.normpath(os.path.join(base, category))
if os.path.isdir(path):
logging.info("Adding extra search path %s %s", category, path)
folder_paths.add_model_folder_path(category, path, is_default)
def load_extra_path_config(yaml_path: str) -> None:
with open(yaml_path, 'r', encoding='utf-8') as stream:
config = yaml.safe_load(stream)
yaml_dir = os.path.dirname(os.path.abspath(yaml_path))
for c in config:
conf = config[c]
for _block_name, conf in config.items():
if conf is None:
continue
base_path = None
# Pop block-level meta keys (preserved for flat backward-compat style)
block_base = None
if "base_path" in conf:
base_path = conf.pop("base_path")
base_path = os.path.expandvars(os.path.expanduser(base_path))
if not os.path.isabs(base_path):
base_path = os.path.abspath(os.path.join(yaml_dir, base_path))
is_default = False
if "is_default" in conf:
is_default = conf.pop("is_default")
for x in conf:
for y in conf[x].split("\n"):
if len(y) == 0:
continue
full_path = y
if base_path:
full_path = os.path.join(base_path, full_path)
elif not os.path.isabs(full_path):
full_path = os.path.abspath(os.path.join(yaml_dir, y))
normalized_path = os.path.normpath(full_path)
logging.info("Adding extra search path {} {}".format(x, normalized_path))
folder_paths.add_model_folder_path(x, normalized_path, is_default)
block_base = _resolve_base(conf.pop("base_path"), None, yaml_dir)
block_is_default = bool(conf.pop("is_default", False))
has_models_block = False
flat_model_keys: set[str] = set()
for key, value in conf.items():
if key in _SYSTEM_DIR_KEYS:
# System directory override → set_*_directory()
path = _resolve_base(str(value).strip(), block_base, yaml_dir)
logging.info("Setting %s directory to %s", key, path)
getattr(folder_paths, f"set_{key}_directory")(path)
elif key == "custom_nodes":
_add_model_paths("custom_nodes", value, block_base, yaml_dir, block_is_default)
elif key == "models" and isinstance(value, dict):
# New nested style: models: { base_path, is_default, <categories> }
has_models_block = True
models_conf = dict(value)
models_base = None
if "base_path" in models_conf:
models_base = _resolve_base(models_conf.pop("base_path"), block_base, yaml_dir)
models_is_default = bool(models_conf.pop("is_default", False))
explicit: set[str] = set(models_conf.keys())
for cat, raw in models_conf.items():
_add_model_paths(cat, raw, models_base, yaml_dir, models_is_default)
if models_base:
_implicit_scan(models_base, explicit, models_is_default)
else:
# Flat model key — backward-compat style
_add_model_paths(key, value, block_base, yaml_dir, block_is_default)
flat_model_keys.add(key)
# Flat-style implicit scan (only when no nested models: block)
if block_base and not has_models_block:
_implicit_scan(block_base, flat_model_keys, block_is_default)