Compare commits

...

9 Commits

Author SHA1 Message Date
Vinci
d2af2283e9
Merge f334f2db3d into 97f58baaaf 2026-05-01 13:53:02 +08:00
Jedrzej Kosinski
97f58baaaf
Add alexisrolland and rattus128 as code owners (#13648)
Some checks are pending
Python Linting / Run Ruff (push) Waiting to run
Python Linting / Run Pylint (push) Waiting to run
Build package / Build Test (3.10) (push) Waiting to run
Build package / Build Test (3.11) (push) Waiting to run
Build package / Build Test (3.12) (push) Waiting to run
Build package / Build Test (3.13) (push) Waiting to run
Build package / Build Test (3.14) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Waiting to run
Execution Tests / test (macos-latest) (push) Waiting to run
Execution Tests / test (ubuntu-latest) (push) Waiting to run
Execution Tests / test (windows-latest) (push) Waiting to run
Test server launches without errors / test (push) Waiting to run
Unit Tests / test (macos-latest) (push) Waiting to run
Unit Tests / test (ubuntu-latest) (push) Waiting to run
Unit Tests / test (windows-2022) (push) Waiting to run
2026-04-30 21:49:31 -04:00
Daxiong (Lin)
e8e8fee224
chore: update workflow templates to v0.9.65 (#13644) 2026-04-30 18:14:28 -07:00
Rainer
e9c311b245
OneTainer ERNIE LoRA support (#13640) 2026-04-30 19:33:41 -04:00
comfyanonymous
e6e0936128
Load other jpeg formats without taking so much memory. (#13642) 2026-04-30 19:33:09 -04:00
Alexander Piskun
b633244635
[Partner Nodes] ByteDance: virtual portrait library for regular images (#13638)
Some checks are pending
Python Linting / Run Ruff (push) Waiting to run
Python Linting / Run Pylint (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Waiting to run
Execution Tests / test (macos-latest) (push) Waiting to run
Execution Tests / test (ubuntu-latest) (push) Waiting to run
Execution Tests / test (windows-latest) (push) Waiting to run
Test server launches without errors / test (push) Waiting to run
Unit Tests / test (macos-latest) (push) Waiting to run
Unit Tests / test (ubuntu-latest) (push) Waiting to run
Unit Tests / test (windows-2022) (push) Waiting to run
Build package / Build Test (3.10) (push) Waiting to run
Build package / Build Test (3.11) (push) Waiting to run
Build package / Build Test (3.12) (push) Waiting to run
Build package / Build Test (3.13) (push) Waiting to run
Build package / Build Test (3.14) (push) Waiting to run
* feat(api-nodes-bytedance): use the virtual portrait library for regular images

Signed-off-by: bigcat88 <bigcat88@icloud.com>

* fix: include shape in image dedup hash

Signed-off-by: bigcat88 <bigcat88@icloud.com>

---------

Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-04-30 11:49:08 -07:00
cest-la-v
f334f2db3d fix: nested models: block inherits outer block_base and block_is_default
When models: has no base_path or is_default of its own, fall back to the
enclosing block's values instead of None/False. This ensures relative
category paths resolve against the outer base_path as users would expect.
2026-04-26 20:44:41 +08:00
cest-la-v
e488f8bbc4 fix: address review feedback on extra_paths.yaml loader
- extra_model_paths.yaml is now ignored (not merged) when extra_paths.yaml
  exists; warning message clarified to instruct deletion/migration
- System directory keys (output/input/temp/user) now gated behind
  allow_system_dirs=True; only extra_paths.yaml sets this flag, so
  legacy extra_model_paths.yaml files cannot accidentally call
  set_*_directory() if a block happens to be named 'output' etc.
- Add test_system_dir_keys_not_applied_for_legacy to cover the guard
2026-04-26 20:34:42 +08:00
cest-la-v
feee1c7a85 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
2026-04-26 14:54:10 +08:00
12 changed files with 563 additions and 33 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,2 +1,2 @@
# Admins
* @comfyanonymous @kosinkadink @guill
* @comfyanonymous @kosinkadink @guill @alexisrolland @rattus128

View File

@ -342,6 +342,12 @@ def model_lora_keys_unet(model, key_map={}):
key_map["base_model.model.{}".format(key_lora)] = k # Official base model loras
key_map["lycoris_{}".format(key_lora.replace(".", "_"))] = k # LyCORIS/LoKR format
if isinstance(model, comfy.model_base.ErnieImage):
for k in sdk:
if k.startswith("diffusion_model.") and k.endswith(".weight"):
key_lora = k[len("diffusion_model."):-len(".weight")]
key_map["transformer.{}".format(key_lora)] = k
return key_map

View File

@ -290,7 +290,7 @@ class VideoFromFile(VideoInput):
alphas = []
alpha_channel = True
break
if frame.format.name in ("yuvj420p", "rgb24", "rgba", "pal8"):
if frame.format.name in ("yuvj420p", "yuvj422p", "yuvj444p", "rgb24", "rgba", "pal8"):
process_image_format = lambda a: a.float() / 255.0
if alpha_channel:
image_format = 'rgba'

View File

@ -157,6 +157,11 @@ class SeedanceCreateAssetResponse(BaseModel):
asset_id: str = Field(...)
class SeedanceVirtualLibraryCreateAssetRequest(BaseModel):
url: str = Field(..., description="Publicly accessible URL of the image asset to upload.")
hash: str = Field(..., description="Dedup key. Re-submitting the same hash returns the existing asset id.")
# Dollars per 1K tokens, keyed by (model_id, has_video_input).
SEEDANCE2_PRICE_PER_1K_TOKENS = {
("dreamina-seedance-2-0-260128", False): 0.007,

View File

@ -1,3 +1,4 @@
import hashlib
import logging
import math
import re
@ -20,6 +21,7 @@ from comfy_api_nodes.apis.bytedance import (
SeedanceCreateAssetResponse,
SeedanceCreateVisualValidateSessionResponse,
SeedanceGetVisualValidateSessionResponse,
SeedanceVirtualLibraryCreateAssetRequest,
Seedream4Options,
Seedream4TaskCreationRequest,
TaskAudioContent,
@ -271,6 +273,30 @@ async def _wait_for_asset_active(cls: type[IO.ComfyNode], asset_id: str, group_i
)
async def _seedance_virtual_library_upload_image_asset(
cls: type[IO.ComfyNode],
image: torch.Tensor,
*,
wait_label: str = "Uploading image",
) -> str:
"""Upload an image into the caller's per-customer Seedance virtual library."""
public_url = await upload_image_to_comfyapi(cls, image, wait_label=wait_label)
normalized = image.detach().cpu().contiguous().to(torch.float32)
digest = hashlib.sha256()
digest.update(str(tuple(normalized.shape)).encode("utf-8"))
digest.update(b"\0")
digest.update(normalized.numpy().tobytes())
image_hash = digest.hexdigest()
create_resp = await sync_op(
cls,
ApiEndpoint(path="/proxy/seedance/virtual-library/assets", method="POST"),
response_model=SeedanceCreateAssetResponse,
data=SeedanceVirtualLibraryCreateAssetRequest(url=public_url, hash=image_hash),
)
await _wait_for_asset_active(cls, create_resp.asset_id, group_id="virtual-library")
return f"asset://{create_resp.asset_id}"
def _seedance2_price_extractor(model_id: str, has_video_input: bool):
"""Returns a price_extractor closure for Seedance 2.0 poll_op."""
rate = SEEDANCE2_PRICE_PER_1K_TOKENS.get((model_id, has_video_input))
@ -1507,7 +1533,9 @@ class ByteDance2FirstLastFrameNode(IO.ComfyNode):
if first_frame_asset_id:
first_frame_url = image_assets[first_frame_asset_id]
else:
first_frame_url = await upload_image_to_comfyapi(cls, first_frame, wait_label="Uploading first frame.")
first_frame_url = await _seedance_virtual_library_upload_image_asset(
cls, first_frame, wait_label="Uploading first frame."
)
content: list[TaskTextContent | TaskImageContent] = [
TaskTextContent(text=model["prompt"]),
@ -1527,7 +1555,9 @@ class ByteDance2FirstLastFrameNode(IO.ComfyNode):
content.append(
TaskImageContent(
image_url=TaskImageContentUrl(
url=await upload_image_to_comfyapi(cls, last_frame, wait_label="Uploading last frame.")
url=await _seedance_virtual_library_upload_image_asset(
cls, last_frame, wait_label="Uploading last frame."
)
),
role="last_frame",
),
@ -1805,9 +1835,9 @@ class ByteDance2ReferenceNode(IO.ComfyNode):
content.append(
TaskImageContent(
image_url=TaskImageContentUrl(
url=await upload_image_to_comfyapi(
url=await _seedance_virtual_library_upload_image_asset(
cls,
image=reference_images[key],
reference_images[key],
wait_label=f"Uploading image {i}",
),
),

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

18
main.py
View File

@ -93,9 +93,21 @@ 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")
if os.path.isfile(extra_model_paths_config_path):
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, allow_system_dirs=True)
if os.path.isfile(extra_model_paths_config_path):
logging.warning(
"Both extra_paths.yaml and extra_model_paths.yaml found; "
"ignoring the deprecated extra_model_paths.yaml. "
"Please remove or migrate its entries to extra_paths.yaml."
)
elif os.path.isfile(extra_model_paths_config_path):
utils.extra_config.load_extra_path_config(extra_model_paths_config_path)
if args.extra_model_paths_config:

View File

@ -1,5 +1,5 @@
comfyui-frontend-package==1.42.15
comfyui-workflow-templates==0.9.63
comfyui-workflow-templates==0.9.65
comfyui-embedded-docs==0.4.4
torch
torchsde

View File

@ -301,3 +301,320 @@ 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, allow_system_dirs=True)
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_system_dir_keys_not_applied_for_legacy(mock_yaml_load, save_restore_system_dirs, tmp_path):
"""System directory keys are ignored when allow_system_dirs=False (legacy extra_model_paths.yaml)."""
original_output = folder_paths.get_output_directory()
config_data = {
"comfyui": {
"base_path": str(tmp_path),
"output": "my_output/",
}
}
mock_yaml_load.return_value = config_data
yaml_path = str(tmp_path / "extra_model_paths.yaml")
with open(yaml_path, "w") as f:
f.write("")
load_extra_path_config(yaml_path) # allow_system_dirs defaults to False
# output should be unchanged — treated as a model category, not a system dir
assert folder_paths.get_output_directory() == original_output
@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]
@patch("yaml.safe_load")
def test_nested_models_inherits_block_base(mock_yaml_load, clear_folder_paths, tmp_path):
"""models: block without its own base_path inherits the outer block's base_path."""
config_data = {
"comfyui": {
"base_path": str(tmp_path),
"is_default": True,
"models": {
"checkpoints": "models/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("")
load_extra_path_config(yaml_path)
expected = os.path.normpath(str(tmp_path / "models" / "checkpoints"))
paths = folder_paths.folder_names_and_paths["checkpoints"][0]
assert expected in paths
# is_default inherited: path should be at index 0
assert paths[0] == expected

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, allow_system_dirs: bool = False) -> 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 allow_system_dirs and 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 = block_base
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", block_is_default))
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)