From 7e91c8c7e22cba83ee8a5e32bfa6225fdfbfbe37 Mon Sep 17 00:00:00 2001 From: Francesco Date: Mon, 3 Nov 2025 17:24:04 +0100 Subject: [PATCH] initial release --- .gitignore | 163 ++++++++++ LICENSE.txt | 21 ++ README.md | 108 +++++++ __init__.py | 26 ++ example/config.json | 50 ++++ example/workflow_example.json | 539 ++++++++++++++++++++++++++++++++++ nodes.py | 454 ++++++++++++++++++++++++++++ requirements.txt | 1 + 8 files changed, 1362 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 __init__.py create mode 100644 example/config.json create mode 100644 example/workflow_example.json create mode 100644 nodes.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df6c6da --- /dev/null +++ b/.gitignore @@ -0,0 +1,163 @@ +# Added +.vscode/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..61ccca9 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [2005] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6fef961 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# ComfyUI OnDemand Loaders + +This is a suite of custom nodes for [ComfyUI](https://github.com/comfyanonymous/ComfyUI) that allows you to download and apply various models directly from Civitai or HuggingFace. It simplifies your workflow by managing model downloads automatically, so you don't have to manually download files and place them in the correct folders. + +## Features + +* **Automatic Downloading**: Downloads models from Civitai/HuggingFace URLs specified in a configuration file. +* **On-Demand Loading**: Models are downloaded only when they are needed for a workflow. +* **Centralized Configuration**: Manage your list of LoRAs, Checkpoints, VAEs, and more from a single `config.json` file. +* **No Re-downloads**: Checks if a model file already exists before attempting to download it. +* **Console Progress Bar**: Displays a tqdm progress bar in the console during download. +* **Private Model Support**: Access private or early-access models using your Civitai or HuggingFace API key. Environment variables (`CIVITAI_TOKEN`, `HUGGINGFACE_TOKEN`) are also supported. +* **Seamless Integration**: Functions as standard loader nodes within the ComfyUI interface. + +## Available Nodes + +This package includes the following nodes: +* `OnDemand Lora Loader` +* `OnDemand Checkpoint Loader` +* `OnDemand VAE Loader` +* `OnDemand UNET Loader` +* `OnDemand CLIP Loader` +* `OnDemand GGUF Loader` (Note: Requires [ComfyUI-GGUF](https://github.com/jquesnelle/ComfyUI-GGUF) to be installed.) + +## Installation + +1. Navigate to your ComfyUI `custom_nodes` directory: + ```bash + cd ComfyUI/custom_nodes/ + ``` +2. Clone this repository: + ```bash + git clone https://github.com/francarl/ComfyUI-OnDemand-Loaders.git + ``` +3. Install the required dependencies: + ```bash + cd ComfyUI-OnDemand-Loaders + pip install -r requirements.txt + ``` +4. Restart ComfyUI. + +## Usage + +### 1. Configure Your Models + +After installation, create and configure a `config.json` file to list the models you want to access. + +1. In the `ComfyUI/custom_nodes/ComfyUI-OnDemand-Loaders/` directory, create a file named `config.json`. You can use the `example/config.json` file as a template. If `config.json` does not exist, the nodes will fall back to a default example configuration. +2. Add your models to `config.json`, organized by type (`loras`, `checkpoints`, `vae_models`, etc.). Each entry requires a `name` (which will appear in the node's dropdown menu) and a `url` (the Civitai/HuggingFace **download** link). + +You can get the download link from a model's page on Civitai/HuggingFace by right-clicking the download button and copying the link address. + +**Example `config.json`:** +```json +{ + "loras": [ + { + "name": "epi_noiseoffset", + "url": "https://civitai.com/api/download/models/16576" + }, + { + "name": "Studio Ghibli Style LoRA", + "url": "https://civitai.com/api/download/models/7657" + } + ], + "checkpoints": [ + { + "name": "v1-5-pruned-emaonly-fp16", + "url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors" + } + ], + "vae_models": [ + { + "name": "SDXL VAE", + "url": "https://huggingface.co/stabilityai/sdxl-vae/resolve/main/sdxl_vae.safetensors" + } + ], + "clip_models": [ + { + "name": "OpenCLIP ViT-H-14", + "url": "https://huggingface.co/openai/clip-vit-large-patch14/resolve/main/pytorch_model.bin" + } + ], + "gguf_models": [ + { + "name": "Wan2.2-T2V-A14B-HighNoise-Q2_K", + "url": "https://huggingface.co/QuantStack/Wan2.2-T2V-A14B-GGUF/resolve/main/HighNoise/Wan2.2-T2V-A14B-HighNoise-Q2_K.gguf" + } + ] +} +``` + +### 2. Add the Nodes in ComfyUI + +1. In ComfyUI, double-click on the canvas to open the search menu. +2. Search for and add one of the **`OnDemand ... Loader`** nodes (e.g., `OnDemand Checkpoint Loader`). +3. Select the desired model from the dropdown. The first time you queue a workflow with a new model, it will be automatically downloaded to the corresponding directory inside `ComfyUI/models/`. +4. Connect the node in your workflow just as you would with a standard loader. An example workflow can be found in `example/workflow_example.json`. + +### 3. Common Node Options + +* `..._name`: A dropdown menu to select the model you configured in `config.json`. +* `api_key` (optional): Your API key for Civitai or HuggingFace. Use this to download private or early-access models. Alternatively, you can set the `CIVITAI_TOKEN` or `HUGGINGFACE_TOKEN` environment variables. +* `download_chunks` (optional): The chunk size (in KB) for downloading files. The default is 4KB. + +## License + +This project is licensed under the MIT License. See the [LICENSE.txt](LICENSE.txt) file for details. \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..a62c762 --- /dev/null +++ b/__init__.py @@ -0,0 +1,26 @@ +from .nodes import OnDemandLoraLoader, OnDemandUNETLoader, OnDemandCheckpointLoader, OnDemandVAELoader, OnDemandCLIPLoader, OnDemandGGUFLoader + +NODE_CLASS_MAPPINGS = { + "OnDemandLoraLoader": OnDemandLoraLoader, + "OnDemandUNETLoader": OnDemandUNETLoader, + "OnDemandCheckpointLoader": OnDemandCheckpointLoader, + "OnDemandVAELoader": OnDemandVAELoader, + "OnDemandCLIPLoader": OnDemandCLIPLoader, + "OnDemandGGUFLoader": OnDemandGGUFLoader +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "OnDemandLoraLoader": "OnDemand Lora Loader", + "OnDemandUNETLoader": "OnDemand UNET Loader", + "OnDemandCheckpointLoader": "OnDemand Checkpoint Loader", + "OnDemandVAELoader": "OnDemand VAE Loader", + "OnDemandCLIPLoader": "OnDemand CLIP Loader", + "OnDemandGGUFLoader": "OnDemand GGUF Loader" +} + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] + +# Module metadata +__version__ = "1.0.0" +__author__ = "francarl" +__description__ = "A suite of nodes for on-demand loading of Diffusion Models, VAE, Clip and Loras in ComfyUI." diff --git a/example/config.json b/example/config.json new file mode 100644 index 0000000..8de8f07 --- /dev/null +++ b/example/config.json @@ -0,0 +1,50 @@ +{ + "loras": [ + { + "name": "epi_noiseoffset", + "url": "https://civitai.com/api/download/models/16576?type=Model&format=SafeTensor&size=full&fp=fp16" + }, + { + "name": "Studio Ghibli Style LoRA", + "url": "https://civitai.com/api/download/models/7657?type=Model&format=SafeTensor&size=full&fp=fp16" + } + ], + "diffusion_models": [ + { + "name": "SDXL 0.9 Base", + "url": "https://huggingface.co/stabilityai/sdxl-0.9-base/resolve/main/sdxl_0.9_base_1.0.safetensors" + }, + { + "name": "SDXL 0.9 Refiner", + "url": "https://huggingface.co/stabilityai/sdxl-0.9-refiner/resolve/main/sdxl_0.9_refiner_1.0.safetensors" + } + ], + "checkpoints": [ + { + "name": "chilloutmix_NiPrunedFp32Fix", + "url": "https://civitai.com/api/download/models/15545?type=Model&format=Checkpoint&size=full&fp=fp32" + }, + { + "name": "v1-5-pruned-emaonly-fp16", + "url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors" + } + ], + "vae_models": [ + { + "name": "SDXL VAE", + "url": "https://huggingface.co/stabilityai/sdxl-vae/resolve/main/sdxl_vae.safetensors" + } + ], + "clip_models": [ + { + "name": "OpenCLIP ViT-H-14", + "url": "https://huggingface.co/openai/clip-vit-large-patch14/resolve/main/pytorch_model.bin" + } + ], + "gguf_models": [ + { + "name": "Wan2.2-T2V-A14B-HighNoise-Q2_K", + "url": "https://huggingface.co/QuantStack/Wan2.2-T2V-A14B-GGUF/resolve/main/HighNoise/Wan2.2-T2V-A14B-HighNoise-Q2_K.gguf" + } + ] +} \ No newline at end of file diff --git a/example/workflow_example.json b/example/workflow_example.json new file mode 100644 index 0000000..9cc1482 --- /dev/null +++ b/example/workflow_example.json @@ -0,0 +1,539 @@ +{ + "id": "f6f66a45-b5d6-4446-87b9-037380aaf60b", + "revision": 0, + "last_node_id": 23, + "last_link_id": 35, + "nodes": [ + { + "id": 7, + "type": "CLIPTextEncode", + "pos": [ + 413, + 389 + ], + "size": [ + 425.27801513671875, + 180.6060791015625 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 33 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 6 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.67", + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "text, watermark" + ] + }, + { + "id": 5, + "type": "EmptyLatentImage", + "pos": [ + 473, + 609 + ], + "size": [ + 315, + 106 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [ + 2 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.67", + "Node name for S&R": "EmptyLatentImage" + }, + "widgets_values": [ + 512, + 512, + 1 + ] + }, + { + "id": 9, + "type": "SaveImage", + "pos": [ + 1560.502833668053, + 205.42542863066734 + ], + "size": [ + 210, + 270 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 9 + } + ], + "outputs": [], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.67" + }, + "widgets_values": [ + "ComfyUI" + ] + }, + { + "id": 8, + "type": "VAEDecode", + "pos": [ + 1289.5628404868114, + 216.94004688813172 + ], + "size": [ + 210, + 46 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 7 + }, + { + "name": "vae", + "type": "VAE", + "link": 32 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 9 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.67", + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 16, + "type": "OnDemandLoraLoader", + "pos": [ + -400.87904821982886, + -210.26314722923422 + ], + "size": [ + 270, + 174 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 30 + }, + { + "name": "clip", + "shape": 7, + "type": "CLIP", + "link": 31 + } + ], + "outputs": [ + { + "name": "model", + "type": "MODEL", + "links": [ + 27 + ] + }, + { + "name": "clip", + "type": "CLIP", + "links": [ + 34 + ] + } + ], + "properties": { + "aux_id": "francarl/ComfyUI-OnDemand-Lora-Loader", + "ver": "7692e7bb5a528ca5f9d5988d1526a5646b3f9851", + "Node name for S&R": "OnDemandLoraLoader" + }, + "widgets_values": [ + "epi_noiseoffset", + 1, + 1, + "", + 4 + ] + }, + { + "id": 22, + "type": "OnDemandCheckpointLoader", + "pos": [ + -888.1669015139005, + -11.59362938551542 + ], + "size": [ + 292.905859375, + 146 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 30 + ] + }, + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 31, + 33 + ] + }, + { + "name": "VAE", + "type": "VAE", + "links": [ + 32 + ] + } + ], + "properties": { + "aux_id": "francarl/ComfyUI-OnDemand-Loaders", + "ver": "6bad65955f69db0b7cba0df967b7d74b98e5cc98", + "Node name for S&R": "OnDemandCheckpointLoader" + }, + "widgets_values": [ + "v1-5-pruned-emaonly-fp16", + "", + 4 + ] + }, + { + "id": 6, + "type": "CLIPTextEncode", + "pos": [ + 415, + 186 + ], + "size": [ + 422.84503173828125, + 164.31304931640625 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 35 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 4 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.67", + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "beautiful scenery nature glass bottle landscape, , purple galaxy bottle," + ] + }, + { + "id": 17, + "type": "OnDemandLoraLoader", + "pos": [ + 194.3472465642872, + -191.4912466964826 + ], + "size": [ + 270, + 174 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 27 + }, + { + "name": "clip", + "shape": 7, + "type": "CLIP", + "link": 34 + } + ], + "outputs": [ + { + "name": "model", + "type": "MODEL", + "links": [ + 29 + ] + }, + { + "name": "clip", + "type": "CLIP", + "links": [ + 35 + ] + } + ], + "properties": { + "aux_id": "francarl/ComfyUI-OnDemand-Lora-Loader", + "ver": "7692e7bb5a528ca5f9d5988d1526a5646b3f9851", + "Node name for S&R": "OnDemandLoraLoader" + }, + "widgets_values": [ + "Studio Ghibli Style LoRA", + 1, + 1, + "", + 1 + ] + }, + { + "id": 3, + "type": "KSampler", + "pos": [ + 891.7722308900037, + 118.53420614605602 + ], + "size": [ + 315, + 262 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 29 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 4 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 6 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 2 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [ + 7 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.3.67", + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 338922263340174, + "randomize", + 20, + 8, + "euler", + "normal", + 1 + ] + } + ], + "links": [ + [ + 2, + 5, + 0, + 3, + 3, + "LATENT" + ], + [ + 4, + 6, + 0, + 3, + 1, + "CONDITIONING" + ], + [ + 6, + 7, + 0, + 3, + 2, + "CONDITIONING" + ], + [ + 7, + 3, + 0, + 8, + 0, + "LATENT" + ], + [ + 9, + 8, + 0, + 9, + 0, + "IMAGE" + ], + [ + 27, + 16, + 0, + 17, + 0, + "MODEL" + ], + [ + 29, + 17, + 0, + 3, + 0, + "MODEL" + ], + [ + 30, + 22, + 0, + 16, + 0, + "MODEL" + ], + [ + 31, + 22, + 1, + 16, + 1, + "CLIP" + ], + [ + 32, + 22, + 2, + 8, + 1, + "VAE" + ], + [ + 33, + 22, + 1, + 7, + 0, + "CLIP" + ], + [ + 34, + 16, + 1, + 17, + 1, + "CLIP" + ], + [ + 35, + 17, + 1, + 6, + 0, + "CLIP" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.8523369657374481, + "offset": [ + 975.7693546322258, + 370.21540427770117 + ] + }, + "frontendVersion": "1.28.8" + }, + "version": 0.4 +} \ No newline at end of file diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..73430fd --- /dev/null +++ b/nodes.py @@ -0,0 +1,454 @@ +import json +import os +import requests +import sys +import logging +from tqdm import tqdm +import re +import folder_paths +from nodes import LoraLoader, UNETLoader, CheckpointLoaderSimple, VAELoader, CLIPLoader + +# Logger initialization +MSG_PREFIX = "[OnDemand Loaders]" + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) +if not logger.handlers: + handler = logging.StreamHandler(sys.stdout) + formatter = logging.Formatter(MSG_PREFIX + ' %(levelname)s: %(message)s') + handler.setFormatter(formatter) + logger.addHandler(handler) + + +logger.info("Starting dynamic import of nodes.py from ComfyUI-GGUF...") +UnetLoaderGGUF = None + +from pathlib import Path +import importlib.util + +parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +nodes_file_path = Path(parent_dir) / 'ComfyUI-GGUF' / 'nodes.py' + +if nodes_file_path.exists(): + module_name = 'nodes' + package_path = nodes_file_path.parent + spec = importlib.util.spec_from_file_location( + module_name, + nodes_file_path, + submodule_search_locations=[str(package_path)], + loader=importlib.machinery.SourceFileLoader(module_name, str(nodes_file_path)) + ) + try: + if spec is not None: + module = importlib.util.module_from_spec(spec) + module.__package__ = 'ComfyUI_GGUF' + sys.modules[module_name] = module + sys.modules['ComfyUI_GGUF'] = module + spec.loader.exec_module(module) + UnetLoaderGGUF = getattr(module, 'UnetLoaderGGUF') + + logger.info("Successfully found and imported UnetLoaderGGUF dynamically.") + + except AttributeError: + logger.error("'UnetLoaderGGUF' not found in nodes.py. Check the class name.") + except Exception as e: + logger.error(f"Error during module execution (nodes.py content error): {e}") + +else: + # Handle the missing file case gracefully + logger.warning(f"Error: ComfyUI-GGUF installation not found! Expected location: {nodes_file_path.parent}") + logger.warning("OnDemand GGUF Loaders will not be available") + + +# Function to load configuration +def load_config(config_filename="config.json"): + + current_dir = os.path.dirname(__file__) + config_path = os.path.join(current_dir, config_filename) + + default_config = { + "loras": [ + { + "name": "Lora n1", + "url": "not_valid_url", + }, + { + "name": "Lora n2", + "url": "not_valid_url" + } + ] + } + + try: + with open(config_path, 'r') as f: + config = json.load(f) + logger.info(f"Successfully loaded configuration from {config_filename}") + return config + except FileNotFoundError: + logger.warning(f"Configuration file '{config_path}' not found. Using default fallback configuration.") + return default_config + except json.JSONDecodeError: + logger.error(f"Error decoding JSON from '{config_path}'. Using default fallback configuration.") + return default_config + except Exception as e: + logger.error(f"An unexpected error occurred while loading '{config_path}': {e}. Using default fallback.") + return default_config + +def _get_api_key_for_url(model_url, api_key_param): + """ + Determines the API key to use based on the model_url. + It checks for a provided api_key_param first, then environment variables. + """ + if model_url.startswith("https://civitai.com"): + return api_key_param or os.environ.get('CIVITAI_TOKEN') + elif model_url.startswith("https://huggingface.co"): + return api_key_param or os.environ.get('HUGGINGFACE_TOKEN') + else: + return api_key_param # Return provided key if URL doesn't match known platforms + + +def _download_model(model_url, model_name, destination_dir, api_key, download_chunks): + """ + Handles the download of a model from a given URL to a specified directory. + + Args: + model_url (str): The URL of the model to download. + model_name (str): The name of the model (for logging purposes). + destination_dir (str): The directory where the model should be saved. + api_key (str): API key for authentication, if required. + download_chunks (int): The size of download chunks in KB. + + Returns: + str: The full path to the downloaded model file, or None if an error occurred. + """ + os.makedirs(destination_dir, exist_ok=True) + + headers = None + if api_key: + logger.info("Using provided API key") + headers = { + "Authorization": f"Bearer {api_key}" + } + + try: + response = requests.get(model_url, stream=True, allow_redirects=True, headers=headers) + response.raise_for_status() # Raise an exception for bad status codes + except requests.exceptions.RequestException as e: + logger.error(f"Error making request for '{model_name}' from '{model_url}': {e}") + return None + + model_filename = None + content_disposition = response.headers.get('Content-Disposition') + if content_disposition: + filename_match = re.search(r'filename="?([^"]+)"?', content_disposition) + if filename_match: + model_filename = filename_match.group(1).strip() + + if not model_filename: + # Fallback to extracting filename from URL if Content-Disposition is missing or malformed + model_filename = os.path.basename(model_url) + + model_filepath = os.path.join(destination_dir, model_filename) + + if os.path.exists(model_filepath): + logger.info(f"File '{model_filename}' already exists at '{model_filepath}'. Skipping download.") + return model_filepath + else: + logger.info(f"Downloading '{model_name}' from '{model_url}' to '{model_filepath}'") + try: + total_size = int(response.headers.get('content-length', 0)) + block_size = download_chunks * 1024 + with tqdm(total=total_size, unit='iB', unit_scale=True, desc=f"Downloading {model_name}") as progress_bar: + with open(model_filepath, 'wb') as f: + for data in response.iter_content(block_size): + progress_bar.update(len(data)) + f.write(data) + logger.info(f"Successfully downloaded '{model_name}'.") + return model_filepath + except Exception as e: + logger.error(f"An unexpected error occurred during download of '{model_name}': {e}") + return None + +def _get_model_url_from_config(model_name, model_type_key): + """ + Retrieves the URL for a given model name from the NODE_CONFIG. + """ + model_url = None + for model in NODE_CONFIG.get(model_type_key, []): + if model["name"] == model_name: + model_url = model["url"] + break + if not model_url: + logger.error(f"Model URL not found for name: {model_name} in {model_type_key}") + return model_url + +NODE_CONFIG = load_config() + +class OnDemandLoraLoader: + + @classmethod + def INPUT_TYPES(cls): + + loras = [lora["name"] for lora in NODE_CONFIG.get("loras", []) ] + + return { + "required": { + "model": ("MODEL",), + "lora_name": (loras,), + "strength_model": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}), + "strength_clip": ("FLOAT", {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01}), + }, + "optional": { + "clip": ("CLIP", ), + "api_key": ("STRING", {"default": None, "multiline": False}), + "download_chunks": ("INT", {"default": 4, "min": 1, "max": 12, "step": 1}) + } + } + + RETURN_TYPES = ("MODEL", "CLIP") + RETURN_NAMES = ("model", "clip") + FUNCTION = "download_lora" + DESCRIPTION = "Load loras models from CivitAI/HuggingFace, they will be downloaded automatically if not found.\nPut a valid CivitAI/HuggingFace API key in form field 'api_key' or in CIVITAI_TOKEN/HUGGINGFACE_TOKEN environment variable to access private models" + + CATEGORY = "loaders" + + def download_lora(self, model, lora_name, strength_model, strength_clip, clip=None, api_key=None, download_chunks=None): + self.lora_loader = LoraLoader() + + destination_dir = os.path.join(folder_paths.models_dir, "loras") + + lora_url = _get_model_url_from_config(lora_name, "loras") + if not lora_url: + return model, clip # Return original model/clip if URL not found + + api_key = _get_api_key_for_url(lora_url, api_key) + + lora_filepath = _download_model(lora_url, lora_name, destination_dir, api_key, download_chunks) + if not lora_filepath: + return model, clip # Return original model/clip if download fails + + lora_filename = os.path.basename(lora_filepath) + + # Load the LORA using the existing LoraLoader + model_lora, clip_lora = self.lora_loader.load_lora(model, clip, lora_filename, strength_model, strength_clip) + return model_lora, clip_lora + + +class OnDemandUNETLoader: + + @classmethod + def INPUT_TYPES(cls): + + models = [model["name"] for model in NODE_CONFIG.get("diffusion_models", []) ] + + return { + "required": { + "unet_name": (models,), + "weight_dtype": (["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],) + }, + "optional": { + "api_key": ("STRING", {"default": None, "multiline": False}), + "download_chunks": ("INT", {"default": 4, "min": 1, "max": 12, "step": 1}) + } + } + + RETURN_TYPES = ("MODEL",) + FUNCTION = "download_unet" + DESCRIPTION = "Load diffusion models from CivitAI/HuggingFace, they will be downloaded automatically if not found.\nPut a valid CivitAI/HuggingFace API key in form field 'api_key' or in CIVITAI_TOKEN/HUGGINGFACE_TOKEN environment variable to access private models" + + CATEGORY = "loaders" + + def download_unet(self, unet_name, weight_dtype, api_key=None, download_chunks=None): + self.unet_loader = UNETLoader() + + destination_dir = os.path.join(folder_paths.models_dir, "diffusion_models") + + model_url = _get_model_url_from_config(unet_name, "diffusion_models") + if not model_url: + return None + + api_key = _get_api_key_for_url(model_url, api_key) + + model_filepath = _download_model(model_url, unet_name, destination_dir, api_key, download_chunks) + if not model_filepath: + return None + + model_filename = os.path.basename(model_filepath) + + # Load the Model using the existing UNETLoader + model_output = self.unet_loader.load_unet(model_filename, weight_dtype) + return model_output + + +class OnDemandCheckpointLoader: + + @classmethod + def INPUT_TYPES(cls): + + models = [model["name"] for model in NODE_CONFIG.get("checkpoints", []) ] + + return { + "required": { + "ckpt_name": (models,) + }, + "optional": { + "api_key": ("STRING", {"default": None, "multiline": False}), + "download_chunks": ("INT", {"default": 4, "min": 1, "max": 12, "step": 1}) + } + } + + RETURN_TYPES = ("MODEL", "CLIP", "VAE") + OUTPUT_TOOLTIPS = ("The model used for denoising latents.", + "The CLIP model used for encoding text prompts.", + "The VAE model used for encoding and decoding images to and from latent space.") + FUNCTION = "download_checkpoint" + DESCRIPTION = "Load checkpoint models from CivitAI/HuggingFace, they will be downloaded automatically if not found.\nPut a valid CivitAI/HuggingFace API key in form field 'api_key' or in CIVITAI_TOKEN/HUGGINGFACE_TOKEN environment variable to access private models" + CATEGORY = "loaders" + + def download_checkpoint(self, ckpt_name, api_key=None, download_chunks=None): + self.checkpoint_loader = CheckpointLoaderSimple() + + destination_dir = os.path.join(folder_paths.models_dir, "checkpoints") + + model_url = _get_model_url_from_config(ckpt_name, "checkpoints") + if not model_url: + return None + + api_key = _get_api_key_for_url(model_url, api_key) + + model_filepath = _download_model(model_url, ckpt_name, destination_dir, api_key, download_chunks) + if not model_filepath: + return None, None, None # Return None for all outputs if download fails + + model_filename = os.path.basename(model_filepath) + + # Load the checkpoint using the existing CheckpointLoaderSimple + return self.checkpoint_loader.load_checkpoint(model_filename) + +class OnDemandVAELoader: + + @classmethod + def INPUT_TYPES(cls): + + models = [model["name"] for model in NODE_CONFIG.get("vae_models", []) ] + + return { + "required": { + "vae_name": (models,) + }, + "optional": { + "api_key": ("STRING", {"default": None, "multiline": False}), + "download_chunks": ("INT", {"default": 4, "min": 1, "max": 12, "step": 1}) + } + } + + RETURN_TYPES = ("VAE",) + FUNCTION = "download_vae" + DESCRIPTION = "Load vae models from CivitAI/HuggingFace, they will be downloaded automatically if not found.\nPut a valid CivitAI/HuggingFace API key in form field 'api_key' or in CIVITAI_TOKEN/HUGGINGFACE_TOKEN environment variable to access private models" + CATEGORY = "loaders" + + def download_vae(self, vae_name, api_key=None, download_chunks=None): + self.vae_loader = VAELoader() + + destination_dir = os.path.join(folder_paths.models_dir, "vae") + + model_url = _get_model_url_from_config(vae_name, "vae_models") + if not model_url: + return None + + api_key = _get_api_key_for_url(model_url, api_key) + + model_filepath = _download_model(model_url, vae_name, destination_dir, api_key, download_chunks) + if not model_filepath: + return None, None, None # Return None for all outputs if download fails + + model_filename = os.path.basename(model_filepath) + + # Load vae using the existing VAELoader + return self.vae_loader.load_vae(model_filename) + +class OnDemandCLIPLoader: + + @classmethod + def INPUT_TYPES(s): + + models = [model["name"] for model in NODE_CONFIG.get("clip_models", []) ] + + return {"required": { + "clip_name": (models,), + "type": (["stable_diffusion", "stable_cascade", "sd3", "stable_audio", "mochi", "ltxv", "pixart", "cosmos", "lumina2", "wan", "hidream", "chroma", "ace", "omnigen2", "qwen_image", "hunyuan_image"], ), + }, + "optional": { + "device": (["default", "cpu"], {"advanced": True}), + "api_key": ("STRING", {"default": None, "multiline": False}), + "download_chunks": ("INT", {"default": 4, "min": 1, "max": 12, "step": 1}) + }} + RETURN_TYPES = ("CLIP",) + FUNCTION = "download_clip" + CATEGORY = "loaders" + DESCRIPTION = "Load clip models from CivitAI/HuggingFace, they will be downloaded automatically if not found.\nPut a valid CivitAI/HuggingFace API key in form field 'api_key' or in CIVITAI_TOKEN/HUGGINGFACE_TOKEN environment variable to access private models" + + def download_clip(self, clip_name, type="stable_diffusion", device="default", api_key=None, download_chunks=None): + self.clip_loader = CLIPLoader() + + destination_dir = os.path.join(folder_paths.models_dir, "text_encoders") + + model_url = _get_model_url_from_config(clip_name, "clip_models") + if not model_url: + return None + + api_key = _get_api_key_for_url(model_url, api_key) + + model_filepath = _download_model(model_url, clip_name, destination_dir, api_key, download_chunks) + if not model_filepath: + return None + + model_filename = os.path.basename(model_filepath) + + # Load the checkpoint using the existing CheckpointLoaderSimple + return self.clip_loader.load_clip(model_filename, type, device) + + + +class OnDemandGGUFLoader: + + @classmethod + def INPUT_TYPES(s): + + models = [model["name"] for model in NODE_CONFIG.get("gguf_models", []) ] + + return {"required": { + "unet_name": (models,) + }, + "optional": { + "api_key": ("STRING", {"default": None, "multiline": False}), + "download_chunks": ("INT", {"default": 4, "min": 1, "max": 12, "step": 1}) + }} + RETURN_TYPES = ("MODEL",) + FUNCTION = "download_unet" + CATEGORY = "loaders" + DESCRIPTION = "Load gguf models from CivitAI/HuggingFace, they will be downloaded automatically if not found.\nPut a valid CivitAI/HuggingFace API key in form field 'api_key' or in CIVITAI_TOKEN/HUGGINGFACE_TOKEN environment variable to access private models" + + def download_unet(self, unet_name, api_key=None, download_chunks=None): + if UnetLoaderGGUF is None: + logger.error("UnetLoaderGGUF class not available. Ensure ComfyUI-GGUF is installed correctly.") + return None + + self.gguf_loader = UnetLoaderGGUF(); + + destination_dir = os.path.join(folder_paths.models_dir, "unet") + + model_url = _get_model_url_from_config(unet_name, "gguf_models") + if not model_url: + return None + + api_key = _get_api_key_for_url(model_url, api_key) + + model_filepath = _download_model(model_url, unet_name, destination_dir, api_key, download_chunks) + if not model_filepath: + return None + + model_filename = os.path.basename(model_filepath) + + # Load the gguf using the existing UnetLoaderGGUF + return self.gguf_loader.load_unet(model_filename) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f229360 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests