Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22758b4595 | ||
|
|
c7f24a3262 | ||
|
|
c69281e795 | ||
|
|
3bf8391e88 | ||
|
|
d13bfe8fb4 | ||
|
|
702d0c889c | ||
|
|
d1a5282ca0 | ||
|
|
36212adf73 | ||
|
|
2cb2262293 | ||
|
|
8773c8f249 | ||
|
|
0a1cbe4990 | ||
|
|
c4a27137e7 | ||
|
|
20270d9501 | ||
|
|
2467dff704 | ||
|
|
cecd1e01e9 | ||
|
|
e504f314aa | ||
|
|
f12debe729 | ||
|
|
c64e835d2e | ||
|
|
36d37db8a9 | ||
|
|
969fd9e25e | ||
|
|
6916b47a89 | ||
|
|
8457db0041 | ||
|
|
8a84927adf | ||
|
|
8c0b89ff70 | ||
|
|
b527635254 | ||
|
|
14738b80c5 | ||
|
|
bff1276d06 | ||
|
|
a7805ad587 | ||
|
|
f2093d6567 | ||
|
|
0e26f0ad36 | ||
|
|
9a05f20790 | ||
|
|
6368582e1b | ||
|
|
97ea5b9a7d | ||
|
|
f5f956ab4e | ||
|
|
498fae1211 | ||
|
|
d24912036c | ||
|
|
523b0509f1 | ||
|
|
5f8117aa56 | ||
|
|
22dc975353 | ||
|
|
66afb2b204 | ||
|
|
1dd1dcf895 | ||
|
|
60f30b068e | ||
|
|
1439270fe5 | ||
|
|
f8210d8f69 | ||
|
|
aceb34b9b0 | ||
|
|
e9ce1fd2cf | ||
|
|
9b888443ac | ||
|
|
f4743df3ef | ||
|
|
6a68983ef4 | ||
|
|
fb01fa24ae | ||
|
|
65f68f59a1 | ||
|
|
c3fab5581b | ||
|
|
1ea2b4cc90 | ||
|
|
f33f39f134 | ||
|
|
5b57d4fc35 | ||
|
|
a1625dddad | ||
|
|
a88232f59a | ||
|
|
6746b86685 | ||
|
|
17af18d397 | ||
|
|
703989599d | ||
|
|
8399fad96b | ||
|
|
0c69abc829 | ||
|
|
e00406747f | ||
|
|
a21e677629 | ||
|
|
13e425959b | ||
|
|
0a6ee72748 | ||
|
|
b03f0ecf22 | ||
|
|
ed81bf4cfd | ||
|
|
58a6c05d98 | ||
|
|
51bb7711b8 | ||
|
|
8f459c502a | ||
|
|
af1bc6845a | ||
|
|
27431b3a92 | ||
|
|
67ef0a44d7 | ||
|
|
a501260bbf | ||
|
|
3a4651b191 | ||
|
|
2f3d6d62f3 | ||
|
|
fb805c4a3d | ||
|
|
4e84588a94 | ||
|
|
df7776280e | ||
|
|
8fd92530ee | ||
|
|
081f5f2310 | ||
|
|
ad7e6e647f | ||
|
|
04218704b3 | ||
|
|
a4db4390ea | ||
|
|
c38753758b | ||
|
|
17b97ed17a | ||
|
|
bd15b45f46 | ||
|
|
5f6846c3cb | ||
|
|
363cc9c755 | ||
|
|
5ae7985bc2 | ||
|
|
b37bc763dc | ||
|
|
0439763614 | ||
|
|
2e1f563298 | ||
|
|
4b63cb7176 | ||
|
|
1f6a148538 | ||
|
|
d9a7879c45 | ||
|
|
c6b5dc4b54 | ||
|
|
a4d1169c63 | ||
|
|
ff6f397dd2 | ||
|
|
97913deae3 | ||
|
|
e6c8dd583f | ||
|
|
f9db6f8635 | ||
|
|
1b18873e65 | ||
|
|
d2b30f0a78 | ||
|
|
917421529b | ||
|
|
d29dcb8564 | ||
|
|
b95bf8e64a | ||
|
|
b8622e21da | ||
|
|
4c8f20ef88 | ||
|
|
26d135e106 | ||
|
|
db7d0dc86e | ||
|
|
b5e24dbe57 | ||
|
|
eb1e646453 | ||
|
|
793579a1dd | ||
|
|
eba899b30d | ||
|
|
914ba8e003 | ||
|
|
51b057982e | ||
|
|
3a3a6ebab9 | ||
|
|
886917d95c | ||
|
|
bf55338b61 | ||
|
|
c561e59650 | ||
|
|
9e67f06a21 | ||
|
|
90e4b57dc6 | ||
|
|
386e48c2e1 | ||
|
|
624ba92723 | ||
|
|
0ae29f576a | ||
|
|
ca9fd5153a | ||
|
|
c0d239f31f | ||
|
|
6d27520641 | ||
|
|
7cde5a5ceb | ||
|
|
ecdde6bbd7 | ||
|
|
0557c040f8 | ||
|
|
f7d007d8c4 | ||
|
|
80045954a5 | ||
|
|
6f176e7b78 | ||
|
|
576efbba41 | ||
|
|
593bd4de79 | ||
|
|
a8fb5ddaa7 | ||
|
|
f67e619930 | ||
|
|
f9e2f5bcc5 | ||
|
|
2cfeab9e76 | ||
|
|
f5ce23ad12 | ||
|
|
05a9520436 | ||
|
|
c1fcc4566b | ||
|
|
40b91fc44f | ||
|
|
3e7734bbc3 | ||
|
|
17400d88f7 | ||
|
|
0f79601065 | ||
|
|
df60457929 | ||
|
|
5d0e8194a1 | ||
|
|
218a208bf0 | ||
|
|
a7b612d799 | ||
|
|
df3d4c19cb | ||
|
|
938d0d93de | ||
|
|
e65bf123b5 | ||
|
|
b348273d3a | ||
|
|
cc1ffe2605 | ||
|
|
ef30413e12 | ||
|
|
5485aa8c19 | ||
|
|
a8ee5930ff | ||
|
|
9fd80793db | ||
|
|
972c487dd4 | ||
|
|
1a3efd3802 | ||
|
|
34f54e515d | ||
|
|
c844abea51 |
@@ -1,7 +1,7 @@
|
||||
[flake8]
|
||||
max-line-length = 127
|
||||
max-complexity = 10
|
||||
exclude =
|
||||
exclude =
|
||||
.git,
|
||||
__pycache__,
|
||||
.mypy_cache,
|
||||
@@ -12,7 +12,7 @@ exclude =
|
||||
dist,
|
||||
*.egg-info,
|
||||
.tox
|
||||
ignore =
|
||||
ignore =
|
||||
# W503: line break before binary operator (conflicts with Black)
|
||||
W503,
|
||||
# E203: whitespace before ':' (conflicts with Black)
|
||||
@@ -32,4 +32,4 @@ per-file-ignores =
|
||||
|
||||
# Statistics
|
||||
count = True
|
||||
statistics = True
|
||||
statistics = True
|
||||
|
||||
+1
-1
@@ -38,4 +38,4 @@
|
||||
*.safetensors binary
|
||||
*.ckpt binary
|
||||
*.pt binary
|
||||
*.pth binary
|
||||
*.pth binary
|
||||
|
||||
@@ -7,4 +7,4 @@ updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "weekly"
|
||||
|
||||
@@ -13,15 +13,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-quality-${{ hashFiles('**/requirements-dev.txt') }}
|
||||
@@ -93,6 +93,14 @@ jobs:
|
||||
from kikotools.tools.kiko_save_image import KikoSaveImageNode
|
||||
from kikotools.tools.kiko_save_image.logic import process_image_batch, validate_save_inputs
|
||||
|
||||
# Test Model Downloader imports
|
||||
from kikotools.tools.model_downloader import ModelDownloaderNode
|
||||
from kikotools.tools.model_downloader.detector import URLDetector, DownloaderType
|
||||
from kikotools.tools.model_downloader.base import BaseDownloader
|
||||
|
||||
# Test Text Input imports
|
||||
from kikotools.tools.text_input import TextInputNode
|
||||
|
||||
print('✓ All module imports successful')
|
||||
"
|
||||
|
||||
@@ -133,10 +141,10 @@ jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
@@ -164,10 +172,10 @@ jobs:
|
||||
architecture:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
@@ -279,6 +287,41 @@ jobs:
|
||||
print('❌ KikoSaveImageNode missing OUTPUT_NODE = True')
|
||||
sys.exit(1)
|
||||
|
||||
# Test Model Downloader Node
|
||||
from kikotools.tools.model_downloader.node import ModelDownloaderNode
|
||||
|
||||
if issubclass(ModelDownloaderNode, ComfyAssetsBaseNode):
|
||||
print('✓ ModelDownloaderNode properly inherits from base class')
|
||||
else:
|
||||
print('❌ ModelDownloaderNode does not inherit from base class')
|
||||
sys.exit(1)
|
||||
|
||||
# ModelDownloader is an output node, so it doesn't have RETURN_TYPES/RETURN_NAMES
|
||||
download_required_attrs = ['INPUT_TYPES', 'FUNCTION', 'CATEGORY']
|
||||
for attr in download_required_attrs:
|
||||
if not hasattr(ModelDownloaderNode, attr):
|
||||
print(f'❌ ModelDownloaderNode missing required attribute: {attr}')
|
||||
sys.exit(1)
|
||||
|
||||
# Check that it's properly marked as an output node
|
||||
if not hasattr(ModelDownloaderNode, 'OUTPUT_NODE') or not ModelDownloaderNode.OUTPUT_NODE:
|
||||
print('❌ ModelDownloaderNode missing OUTPUT_NODE = True')
|
||||
sys.exit(1)
|
||||
|
||||
# Test Text Input Node
|
||||
from kikotools.tools.text_input.node import TextInputNode
|
||||
|
||||
if issubclass(TextInputNode, ComfyAssetsBaseNode):
|
||||
print('✓ TextInputNode properly inherits from base class')
|
||||
else:
|
||||
print('❌ TextInputNode does not inherit from base class')
|
||||
sys.exit(1)
|
||||
|
||||
for attr in required_attrs:
|
||||
if not hasattr(TextInputNode, attr):
|
||||
print(f'❌ TextInputNode missing required attribute: {attr}')
|
||||
sys.exit(1)
|
||||
|
||||
print('✓ All architecture checks passed for all tools')
|
||||
"
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
if: ${{ github.repository_owner == 'ComfyAssets' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
- name: Publish Custom Node
|
||||
|
||||
@@ -15,10 +15,10 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.get_version.outputs.version }}
|
||||
name: ComfyUI-KikoTools ${{ steps.get_version.outputs.version }}
|
||||
|
||||
+30
-16
@@ -1,5 +1,8 @@
|
||||
name: Tests
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
@@ -11,18 +14,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.8, 3.9, "3.10", "3.11", "3.12"]
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements-dev.txt') }}
|
||||
@@ -50,7 +53,7 @@ jobs:
|
||||
print('✓ All imports successful')
|
||||
|
||||
# Test base node
|
||||
assert ComfyAssetsBaseNode.CATEGORY == 'ComfyAssets'
|
||||
assert 'ComfyAssets' in ComfyAssetsBaseNode.CATEGORY
|
||||
print('✓ Base node tests passed')
|
||||
|
||||
# Test dimension extraction
|
||||
@@ -158,10 +161,13 @@ jobs:
|
||||
assert 'cfg' in input_types['required']
|
||||
print('✓ Sampler Combo interface tests passed')
|
||||
|
||||
# Test return types
|
||||
assert node.RETURN_TYPES == ('SAMPLER', SCHEDULERS, 'INT', 'FLOAT')
|
||||
# Test return types - Updated to match SAMPLERS list change
|
||||
assert node.RETURN_TYPES[0] == SAMPLERS # Now returns SAMPLERS list
|
||||
assert isinstance(node.RETURN_TYPES[1], list) # SCHEDULERS is a list
|
||||
assert node.RETURN_TYPES[2] == 'INT'
|
||||
assert node.RETURN_TYPES[3] == 'FLOAT'
|
||||
assert node.RETURN_NAMES == ('sampler_name', 'scheduler', 'steps', 'cfg')
|
||||
assert node.CATEGORY == 'ComfyAssets'
|
||||
assert node.CATEGORY == '🫶 ComfyAssets/🌀 Samplers'
|
||||
print('✓ Sampler Combo return types tests passed')
|
||||
|
||||
# Test sampler combo functionality
|
||||
@@ -210,7 +216,7 @@ jobs:
|
||||
# Test return types
|
||||
assert node.RETURN_TYPES == ('INT',)
|
||||
assert node.RETURN_NAMES == ('seed',)
|
||||
assert node.CATEGORY == 'ComfyAssets'
|
||||
assert node.CATEGORY == '🫶 ComfyAssets/🌱 Seeds'
|
||||
print('✓ Seed History return types tests passed')
|
||||
|
||||
# Test seed output functionality
|
||||
@@ -326,7 +332,7 @@ jobs:
|
||||
|
||||
assert res_class.RETURN_TYPES == ('INT', 'INT')
|
||||
assert res_class.RETURN_NAMES == ('width', 'height')
|
||||
assert res_class.CATEGORY == 'ComfyAssets'
|
||||
assert 'ComfyAssets/' in res_class.CATEGORY
|
||||
print('✓ Resolution Calculator ComfyUI integration passed')
|
||||
|
||||
# Test Width Height Selector
|
||||
@@ -347,7 +353,7 @@ jobs:
|
||||
|
||||
assert wh_class.RETURN_TYPES == ('INT', 'INT')
|
||||
assert wh_class.RETURN_NAMES == ('width', 'height')
|
||||
assert wh_class.CATEGORY == 'ComfyAssets'
|
||||
assert 'ComfyAssets/' in wh_class.CATEGORY
|
||||
print('✓ Width Height Selector ComfyUI integration passed')
|
||||
|
||||
# Test Sampler Combo
|
||||
@@ -367,7 +373,7 @@ jobs:
|
||||
assert 'steps' in input_types['required']
|
||||
assert 'cfg' in input_types['required']
|
||||
|
||||
assert sampler_class.CATEGORY == 'ComfyAssets'
|
||||
assert 'ComfyAssets/' in sampler_class.CATEGORY
|
||||
print('✓ Sampler Combo ComfyUI integration passed')
|
||||
|
||||
# Test Seed History
|
||||
@@ -386,7 +392,7 @@ jobs:
|
||||
|
||||
assert seed_class.RETURN_TYPES == ('INT',)
|
||||
assert seed_class.RETURN_NAMES == ('seed',)
|
||||
assert seed_class.CATEGORY == 'ComfyAssets'
|
||||
assert 'ComfyAssets/' in seed_class.CATEGORY
|
||||
print('✓ Seed History ComfyUI integration passed')
|
||||
|
||||
print('🎉 All tools ComfyUI integration readiness tests passed!')
|
||||
@@ -395,10 +401,10 @@ jobs:
|
||||
test-package-structure:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
@@ -442,6 +448,14 @@ jobs:
|
||||
test -f kikotools/tools/seed_history/node.py || (echo "seed_history node.py missing" && exit 1)
|
||||
test -f kikotools/tools/seed_history/logic.py || (echo "seed_history logic.py missing" && exit 1)
|
||||
|
||||
# Model Downloader files
|
||||
test -f kikotools/tools/model_downloader/node.py || (echo "model_downloader node.py missing" && exit 1)
|
||||
test -f kikotools/tools/model_downloader/base.py || (echo "model_downloader base.py missing" && exit 1)
|
||||
test -f kikotools/tools/model_downloader/detector.py || (echo "model_downloader detector.py missing" && exit 1)
|
||||
|
||||
# Text Input files
|
||||
test -f kikotools/tools/text_input/node.py || (echo "text_input node.py missing" && exit 1)
|
||||
|
||||
# Web files
|
||||
test -f web/width_height_swap.js || (echo "width_height_swap.js missing" && exit 1)
|
||||
test -f web/seed_history_ui.js || (echo "seed_history_ui.js missing" && exit 1)
|
||||
@@ -451,7 +465,7 @@ jobs:
|
||||
test-documentation:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Test documentation completeness
|
||||
run: |
|
||||
|
||||
@@ -159,6 +159,8 @@ test_images/
|
||||
test_outputs/
|
||||
experiments/
|
||||
.claude/
|
||||
.serena
|
||||
|
||||
# Gemini model cache
|
||||
.gemini_models_cache.json
|
||||
referance/
|
||||
|
||||
@@ -81,4 +81,4 @@ exclude: |
|
||||
.*\.egg-info/|
|
||||
venv/|
|
||||
env/
|
||||
)
|
||||
)
|
||||
|
||||
@@ -8,7 +8,14 @@
|
||||
|
||||
> A modular collection of essential custom ComfyUI nodes missing from the standard release.
|
||||
|
||||
ComfyUI-KikoTools provides carefully crafted, production-ready nodes grouped under the **"ComfyAssets"** category. Each tool is designed with clean interfaces, comprehensive testing, and optimized performance for SDXL and FLUX workflows.
|
||||
ComfyUI-KikoTools provides carefully crafted, production-ready nodes under the "ComfyAssets" category.
|
||||
Each tool is built with clean interfaces, thorough testing, and optimized performance for SDXL and FLUX workflows.
|
||||
|
||||
This project started out of frustration with keeping ComfyUI up to date and waiting for dozens of custom nodes to update—most of which I didn’t even use. After taking a hard look at my workflow, I realized I only needed one or two features from these nodes, many of which were abandoned or stuck in maintenance mode.
|
||||
|
||||
I tried forking, patching, and submitting merge requests, but eventually decided to create my own curated collection of tools—fully supported and maintained by me. That’s how Kiko’s Tools was born.
|
||||
|
||||
I’m sharing them here with the community, and I hope you find them as useful as I do.
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
@@ -16,16 +23,36 @@ ComfyUI-KikoTools provides carefully crafted, production-ready nodes grouped und
|
||||
|
||||
| Tool | Description | Category |
|
||||
|------|-------------|----------|
|
||||
| [📐 Resolution Calculator](#-resolution-calculator) | Calculate upscaled dimensions with model optimization | Image Processing |
|
||||
| [📏 Width Height Selector](#-width-height-selector) | Preset-based dimension selection with visual swap | Dimension Control |
|
||||
| [🎲 Seed History](#-seed-history) | Advanced seed tracking with interactive history | Generation Control |
|
||||
| [⚙️ Sampler Combo](#️-sampler-combo) | Unified sampling configuration interface | Sampling |
|
||||
| [📦 Empty Latent Batch](#-empty-latent-batch) | Create empty latent batches with preset support | Latent Generation |
|
||||
| [💾 Kiko Save Image](#-kiko-save-image) | Enhanced image saving with popup viewer | File Management |
|
||||
| [📋 Display Text](#-display-text) | Smart text display with prompt detection | Text Display |
|
||||
| [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | AI-powered image analysis and prompt generation | AI Integration |
|
||||
| [🔍 Display Any](#-display-any) | Universal debugging tool for any data type | Debugging |
|
||||
| [🖼️ Image to Multiple Of](#️-image-to-multiple-of) | Adjust dimensions to multiples for compatibility | Image Processing |
|
||||
| [📐 Resolution Calculator](#-resolution-calculator) | Calculate upscaled dimensions with model optimization | 🖼️ Resolution |
|
||||
| [📏 Width Height Selector](#-width-height-selector) | Preset-based dimension selection with visual swap | 🖼️ Resolution |
|
||||
| [🎲 Seed History](#-seed-history) | Advanced seed tracking with interactive history | 🌱 Seeds |
|
||||
| [⚙️ Sampler Combo](#️-sampler-combo) | Unified sampling configuration interface | 🌀 Samplers |
|
||||
| [📦 Empty Latent Batch](#-empty-latent-batch) | Create empty latent batches with preset support | 📦 Latents |
|
||||
| [💾 Kiko Save Image](#-kiko-save-image) | Enhanced image saving with popup viewer | 💾 Images |
|
||||
| [📋 Display Text](#-display-text) | Smart text display with prompt detection | 👁️ Display |
|
||||
| [🤖 Gemini Prompt Engineer](#-gemini-prompt-engineer) | AI-powered image analysis and prompt generation | 🧠 Prompts |
|
||||
| [🔍 Display Any](#-display-any) | Universal debugging tool for any data type | 👁️ Display |
|
||||
| [🖼️ Image to Multiple Of](#️-image-to-multiple-of) | Adjust dimensions to multiples for compatibility | 🖼️ Resolution |
|
||||
| [📉 Image Scale Down By](#-image-scale-down-by) | Scale images down by a factor with quality preservation | 🖼️ Resolution |
|
||||
| [🎬 Film Grain](#-film-grain) | Add realistic film grain effects to images | 💾 Images |
|
||||
| [🔤 Embedding Autocomplete](#-embedding-autocomplete) | Smart autocomplete for embeddings, LoRAs, and tags | 🔧 Utils |
|
||||
| [🧹 Kiko Purge VRAM](#-kiko-purge-vram) | Intelligent VRAM management with detailed reporting | 🛠️ Utils |
|
||||
| [📂 Local Image Loader](#-local-image-loader) | Visual gallery browser for local media files | 💾 Images |
|
||||
| [🌐 Model Downloader](#-model-downloader) | Download models from CivitAI, HuggingFace, and custom URLs | 🛠️ Utils |
|
||||
| [⏱️ Workflow Timer](#️-workflow-timer) | Real-time execution timer with customizable display | 🛠️ Utils |
|
||||
|
||||
### 🧰 xyz-helpers Tools
|
||||
|
||||
Advanced parameter management tools adapted from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) (now in maintenance mode):
|
||||
|
||||
| Tool | Description | Category |
|
||||
|------|-------------|----------|
|
||||
| [🎛️ Flux Sampler Params](#️-flux-sampler-params) | FLUX-optimized parameter generator with batch support | 🧰 xyz-helpers |
|
||||
| [📁 LoRA Folder Batch](#-lora-folder-batch) | Batch process multiple LoRAs from folders | 🧰 xyz-helpers |
|
||||
| [📊 Plot Parameters](#-plot-parameters) | Visualize parameter effects with graphs | 🧰 xyz-helpers |
|
||||
| [🎯 Sampler Select Helper](#-sampler-select-helper) | Intelligent sampler selection with recommendations | 🧰 xyz-helpers |
|
||||
| [📅 Scheduler Select Helper](#-scheduler-select-helper) | Optimal scheduler selection for samplers | 🧰 xyz-helpers |
|
||||
| [✍️ Text Encode Sampler Params](#️-text-encode-sampler-params) | Combined text encoding and parameter management | 🧰 xyz-helpers |
|
||||
|
||||
#### 📐 Resolution Calculator
|
||||
Calculate upscaled dimensions from image or latent inputs with precision.
|
||||
@@ -205,6 +232,279 @@ Adjusts image dimensions to be multiples of a specified value for model compatib
|
||||
|
||||

|
||||
|
||||
#### 📉 Image Scale Down By
|
||||
Efficiently scale images down by a specified factor with quality preservation.
|
||||
|
||||
- **Proportional Scaling**: Reduces both width and height by the same factor
|
||||
- **Quality Preservation**: Uses bilinear interpolation with antialiasing
|
||||
- **Batch Support**: Process multiple images simultaneously
|
||||
- **Memory Efficient**: Optimized for large image batches
|
||||
- **Flexible Factor**: Scale from 0.01x to 1.0x with 0.01 precision
|
||||
|
||||
**Use Cases:**
|
||||
- Create thumbnails or preview images
|
||||
- Reduce memory usage for large workflows
|
||||
- Generate image pyramids for multi-scale processing
|
||||
- Quick downsampling for performance optimization
|
||||
- Prepare images for web display or transmission
|
||||
|
||||
#### 🎬 Film Grain
|
||||
Add realistic analog film grain effects to generated images.
|
||||
|
||||
- **Realistic Grain Simulation**: Mimics actual film photography characteristics
|
||||
- **Grain Size Control**: Fine to coarse grain patterns (0.25x to 2.0x)
|
||||
- **Intensity Adjustment**: Variable strength from subtle to pronounced (0-10)
|
||||
- **Color Saturation**: Monochrome to full color grain (0-2)
|
||||
- **Shadow Lifting (Toe)**: Film-like shadow response curves
|
||||
- **Red Multiplier**: Adjust red channel independently for vintage looks
|
||||
- **Alpha Preservation**: Maintains transparency when present
|
||||
- **ITU-R BT.709 Color Space**: Professional color handling
|
||||
|
||||
**Use Cases:**
|
||||
- Add vintage film aesthetic to AI-generated images
|
||||
- Create cinematic looks with authentic grain patterns
|
||||
- Simulate different film stocks (35mm, 16mm, etc.)
|
||||
- Add texture to overly smooth AI renders
|
||||
- Match grain from reference photography
|
||||
|
||||
#### 🎛️ Flux Sampler Params
|
||||
FLUX-optimized parameter generator with intelligent batch processing capabilities.
|
||||
|
||||
- **FLUX-Specific Tuning**: Optimized guidance, shift values, and step counts for FLUX models
|
||||
- **Batch Parameter Testing**: Generate multiple parameter sets for comparative analysis
|
||||
- **LoRA Integration**: Seamlessly combine with LoRA Folder Batch for comprehensive testing
|
||||
- **Smart Defaults**: Pre-configured optimal settings based on extensive FLUX testing
|
||||
- **Range Syntax Support**: Use `start...end+step` notation for parameter sweeps
|
||||
|
||||
**Use Cases:**
|
||||
- Test different guidance and shift value combinations
|
||||
- Batch process with varying parameters
|
||||
- Optimize FLUX generation quality
|
||||
- Integrate with LoRA testing workflows
|
||||
|
||||
#### 📁 LoRA Folder Batch
|
||||
Automated batch processing for multiple LoRA models from folders.
|
||||
|
||||
- **Automatic Scanning**: Discovers all .safetensors files in specified folders
|
||||
- **Natural Epoch Sorting**: Intelligently sorts training epochs (epoch_004, epoch_020, etc.)
|
||||
- **Pattern Filtering**: Include/exclude LoRAs using powerful regex patterns
|
||||
- **Flexible Strength Control**: Single, multiple, or range-based strength values
|
||||
- **Batch Modes**: Sequential or combinatorial strength application
|
||||
- **Epoch Detection**: Automatically extracts epoch numbers from filenames
|
||||
|
||||
**Use Cases:**
|
||||
- Test all epochs from a training run
|
||||
- Compare different LoRA versions
|
||||
- Evaluate strength variations
|
||||
- Batch process style transfers
|
||||
|
||||

|
||||
|
||||
#### 📊 Plot Parameters
|
||||
Visual analysis tool for understanding parameter relationships and effects.
|
||||
|
||||
- **Multiple Plot Types**: Line, bar, scatter, and heatmap visualizations
|
||||
- **Parameter Correlation**: Analyze relationships between settings and quality
|
||||
- **Statistical Analysis**: Calculate means, deviations, and trends
|
||||
- **Export Capabilities**: Save plots as images or CSV data
|
||||
- **Real-time Updates**: Dynamic graph generation during workflow execution
|
||||
|
||||
**Use Cases:**
|
||||
- Visualize parameter impact on quality
|
||||
- Compare batch generation results
|
||||
- Analyze optimal parameter ranges
|
||||
- Document generation experiments
|
||||
|
||||
#### 🎯 Sampler Select Helper
|
||||
Intelligent sampler selection with model-aware recommendations.
|
||||
|
||||
- **Model Detection**: Automatic identification of SDXL, SD1.5, or FLUX models
|
||||
- **Quality Presets**: Fast, balanced, quality, and extreme presets
|
||||
- **Compatibility Checking**: Ensures optimal sampler-scheduler pairs
|
||||
- **Performance Profiles**: Pre-configured settings for different use cases
|
||||
- **Dynamic Discovery**: Adapts to newly available samplers
|
||||
|
||||
**Use Cases:**
|
||||
- Automatic optimal sampler selection
|
||||
- Quick quality vs speed adjustments
|
||||
- Model-specific optimization
|
||||
- A/B testing different samplers
|
||||
|
||||
#### 📅 Scheduler Select Helper
|
||||
Optimal scheduler selection based on sampler and model requirements.
|
||||
|
||||
- **Sampler-Aware**: Recommends best schedulers for each sampler
|
||||
- **Noise Schedule Visualization**: Preview and compare schedule curves
|
||||
- **Model Optimization**: Specific tuning for SDXL, SD1.5, and FLUX
|
||||
- **Schedule Types**: Smooth, sharp, linear, and custom curves
|
||||
- **Beta Schedule Support**: Advanced control with custom beta values
|
||||
|
||||
**Use Cases:**
|
||||
- Find optimal scheduler for your sampler
|
||||
- Visualize noise reduction curves
|
||||
- Compare different schedule types
|
||||
- Fine-tune generation behavior
|
||||
|
||||
#### ✍️ Text Encode Sampler Params
|
||||
Unified interface for text encoding and sampler parameter management.
|
||||
|
||||
- **All-in-One Node**: Combine prompt encoding with sampling configuration
|
||||
- **Template System**: Pre-configured settings for portraits, landscapes, etc.
|
||||
- **Prompt Syntax Support**: Wildcards, emphasis, and alternation
|
||||
- **Batch Processing**: Handle multiple prompts efficiently
|
||||
- **Model-Aware Encoding**: Optimize for different text encoders
|
||||
|
||||
**Use Cases:**
|
||||
- Streamline text-to-image workflows
|
||||
- Apply consistent settings across prompts
|
||||
- Quick template-based generation
|
||||
- Batch prompt processing
|
||||
|
||||
#### 📂 Local Image Loader
|
||||
Visual gallery browser for loading local images, videos, and audio files directly into ComfyUI workflows.
|
||||
|
||||
- **Visual Gallery Interface**: Browse files with thumbnail previews in a masonry layout
|
||||
- **Multi-Media Support**: Load images (JPG, PNG, GIF, WebP), videos (MP4, WebM, MOV), and audio files (MP3, WAV, OGG, FLAC)
|
||||
- **Quick Navigation**: Navigate folders with breadcrumb path and parent directory button
|
||||
- **Responsive Layout**: Automatically adjusts thumbnail grid to available space
|
||||
- **Metadata Extraction**: Reads embedded prompt and workflow data from generated images
|
||||
- **Saved Paths**: Remember frequently used directories for quick access
|
||||
- **Double-Click Preview**: Open full-size media in new browser tab
|
||||
- **Smart Sorting**: Sort by name, date, or file size in ascending or descending order
|
||||
- **Pagination Support**: Efficiently browse large directories with page controls
|
||||
|
||||
**Use Cases:**
|
||||
- Load reference images from local folders for img2img workflows
|
||||
- Browse and select from collections of generated images
|
||||
- Quickly access frequently used asset directories
|
||||
- Extract prompts and settings from previously generated images
|
||||
- Preview media files before loading into workflow
|
||||
|
||||
#### 🌐 Model Downloader
|
||||
Download models, LoRAs, and other assets directly from CivitAI, HuggingFace, and custom URLs within ComfyUI.
|
||||
|
||||
- **Multi-Platform Support**: CivitAI, HuggingFace, and direct download URLs
|
||||
- **Smart URL Detection**: Automatic detection of download source and file handling
|
||||
- **API Token Support**: Optional authentication for private/gated models
|
||||
- **Progress Reporting**: Real-time download progress with speed indicators
|
||||
- **Resume Support**: Skip existing files or force re-download
|
||||
- **Interrupt Handling**: Respects ComfyUI's "Cancel current run" button
|
||||
- **Automatic Cleanup**: Removes partial downloads on cancellation
|
||||
- **Custom Filenames**: Override auto-detected filenames when needed
|
||||
|
||||
**Platform Features:**
|
||||
- **CivitAI**: Model page URLs, version-specific downloads, API authentication
|
||||
- **HuggingFace**: Blob and resolve URLs, branch/revision support, gated model access
|
||||
- **Custom URLs**: Direct download links with bearer token authentication
|
||||
|
||||
**Use Cases:**
|
||||
- Download models without leaving ComfyUI
|
||||
- Automate asset acquisition in workflows
|
||||
- Access private or gated models with API tokens
|
||||
- Build reproducible workflows with automatic model fetching
|
||||
- Quickly test new models from the community
|
||||
|
||||

|
||||
|
||||
#### ⏱️ Workflow Timer
|
||||
Real-time execution timer that displays workflow duration with millisecond precision.
|
||||
|
||||
- **Live Timing**: Updates in real-time during workflow execution (MM:SS:mmm format)
|
||||
- **Customizable Color**: Choose your preferred display color via KikoTools settings
|
||||
- **Glow Effect**: Optional pulsing glow animation (can be enabled/disabled in settings)
|
||||
- **Global Settings**: Color and glow preferences apply to all timer nodes
|
||||
- **Persistent Display**: Shows final execution time after workflow completes
|
||||
- **Multi-Node Sync**: All timer nodes stay synchronized during execution
|
||||
|
||||
**Use Cases:**
|
||||
- Monitor workflow execution performance
|
||||
- Compare generation times across different settings
|
||||
- Identify slow nodes by adding timers at different workflow stages
|
||||
- Track optimization improvements over time
|
||||
|
||||
**Settings (KikoTools Settings Panel):**
|
||||
- **Workflow Timer: Color** - Custom color picker for timer display
|
||||
- **Workflow Timer: Enable Glow** - Toggle pulsing glow effect on/off
|
||||
|
||||
### 🔤 Embedding Autocomplete
|
||||
|
||||
**Intelligent autocomplete for embeddings, LoRAs, and custom tags in text prompts.**
|
||||
|
||||
<div align="center">
|
||||
<img src="https://github.com/ComfyAssets/ComfyUI-KikoTools/blob/main/examples/ac-emb.png?raw=true" width="30%" alt="Embedding Autocomplete" />
|
||||
<img src="https://github.com/ComfyAssets/ComfyUI-KikoTools/blob/main/examples/ac-lora.png?raw=true" width="30%" alt="LoRA Autocomplete" />
|
||||
<img src="https://github.com/ComfyAssets/ComfyUI-KikoTools/blob/main/examples/ac-tag.png?raw=true" width="30%" alt="Tag Autocomplete" />
|
||||
</div>
|
||||
|
||||
This feature is an enhanced fork of the autocomplete functionality from [ComfyUI-Custom-Scripts](https://github.com/pythongosssss/ComfyUI-Custom-Scripts) by pythongosssss. We've modernized the codebase, fixed existing bugs, and added robust security features.
|
||||
|
||||
**Key Features:**
|
||||
- **Smart Triggers**: Type `embedding:` for embeddings, `<lora:` for LoRAs, or just start typing for tags
|
||||
- **Custom Word Lists**: Load tag databases (like Danbooru tags) from any URL
|
||||
- **Security First**: Comprehensive input validation prevents code injection and XSS attacks
|
||||
- **Flexible Settings**: Customize triggers, auto-insert commas, replace underscores, and more
|
||||
- **Performance Optimized**: Handles 100,000+ tags smoothly with frequency-based sorting
|
||||
- **Visual Polish**: Clean UI with proper scrolling, keyboard navigation, and type indicators
|
||||
|
||||
**Settings Include:**
|
||||
- Enable/disable autocomplete for embeddings, LoRAs, and custom tags
|
||||
- Configurable trigger phrases (e.g., `emb:`, `lora:`, custom shortcuts)
|
||||
- Auto-insert comma after completion
|
||||
- Replace underscores with spaces in tags
|
||||
- Choose insertion keys (Tab, Enter, or both)
|
||||
- Load custom word lists from URLs with security validation
|
||||
|
||||
**Security Features:**
|
||||
- Validates all loaded content to prevent script injection
|
||||
- Blocks dangerous patterns (eval, innerHTML, script tags, etc.)
|
||||
- Safe character whitelist for tags
|
||||
- File size limits to prevent memory exhaustion
|
||||
- Clear error messages for rejected content
|
||||
|
||||
**Credits:**
|
||||
- Original autocomplete concept by [pythongosssss](https://github.com/pythongosssss/ComfyUI-Custom-Scripts)
|
||||
- Enhanced and modernized by KikoTools team
|
||||
|
||||
### 🧹 Kiko Purge VRAM
|
||||
**Intelligent GPU memory management with threshold-based triggering and detailed reporting.**
|
||||
|
||||
**Key Features:**
|
||||
- **4 Purge Modes**:
|
||||
- `soft`: Basic garbage collection and cache clearing
|
||||
- `aggressive`: Multiple GC passes with full CUDA cache clearing
|
||||
- `models_only`: Unload all models and clear model cache
|
||||
- `cache_only`: Clear CUDA cache without garbage collection
|
||||
- **Smart Thresholds**: Only purge when memory usage exceeds specified MB limit
|
||||
- **Detailed Reporting**: Shows before/after memory usage, freed MB, and timing
|
||||
- **Passthrough Design**: Acts as workflow checkpoint without disrupting data flow
|
||||
- **CPU Fallback**: Gracefully handles non-CUDA environments
|
||||
|
||||
**Use Cases:**
|
||||
- Free memory between heavy processing stages
|
||||
- Prevent OOM errors in complex workflows
|
||||
- Debug memory usage patterns
|
||||
- Optimize multi-model workflows
|
||||
- Clean up after batch processing
|
||||
|
||||
**Parameters:**
|
||||
- **anything**: Any input (passed through unchanged)
|
||||
- **mode**: Purge strategy selection
|
||||
- **report_memory**: Generate detailed memory statistics
|
||||
- **memory_threshold_mb**: Only purge if usage exceeds (0 = always purge)
|
||||
|
||||
**Example Output:**
|
||||
```
|
||||
Memory usage (5000.0 MB) exceeds threshold (4000 MB)
|
||||
|
||||
Memory Purge Report
|
||||
-------------------
|
||||
Mode: soft
|
||||
Memory Freed: 2500.0 MB
|
||||
Before: 5000.0 MB used (62.5%)
|
||||
After: 2500.0 MB used (31.3%)
|
||||
Time: 150.0ms
|
||||
```
|
||||
|
||||
### 💾 Kiko Save Image Features
|
||||
|
||||
**Use Cases:**
|
||||
@@ -408,6 +708,21 @@ Load Image → Image to Multiple Of → VAE Encode → KSampler
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>LoRA Testing with xyz-helpers</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"workflow": "Scan LoRA folder → Apply strength ranges → Generate grid → Plot parameters",
|
||||
"strength_range": "0.9...1.2+0.1",
|
||||
"batch_mode": "combinatorial",
|
||||
"features": ["automatic epoch sorting", "parameter visualization", "batch generation"]
|
||||
}
|
||||
```
|
||||
|
||||
Example workflow available: [xyz_helpers_lora_testing.json](examples/workflows/xyz_helpers_lora_testing.json)
|
||||
</details>
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
### Available Tools
|
||||
@@ -424,6 +739,17 @@ Load Image → Image to Multiple Of → VAE Encode → KSampler
|
||||
| **Gemini Prompt Engineer** | AI-powered image analysis with dynamic model refresh | ✅ Complete | [Docs](examples/documentation/gemini_prompt.md) |
|
||||
| **Display Any** | Universal debugging tool for any data type or tensor shapes | ✅ Complete | [Docs](examples/documentation/display_any.md) |
|
||||
| **Image to Multiple Of** | Adjust image dimensions to multiples for model compatibility | ✅ Complete | [Docs](examples/documentation/image_to_multiple_of.md) |
|
||||
| **Image Scale Down By** | Efficiently scale images down by a specified factor | ✅ Complete | [Docs](examples/documentation/image_scale_down_by.md) |
|
||||
| **Film Grain** | Add realistic analog film grain effects to images | ✅ Complete | [Docs](examples/documentation/film_grain.md) |
|
||||
| **Flux Sampler Params** | FLUX-optimized parameter generator with batch support | ✅ Complete | [Docs](examples/documentation/flux_sampler_params.md) |
|
||||
| **LoRA Folder Batch** | Batch process multiple LoRAs from folders | ✅ Complete | [Docs](examples/documentation/lora_folder_batch.md) |
|
||||
| **Plot Parameters** | Visualize parameter effects with graphs | ✅ Complete | [Docs](examples/documentation/plot_parameters.md) |
|
||||
| **Sampler Select Helper** | Intelligent sampler selection with recommendations | ✅ Complete | [Docs](examples/documentation/sampler_select_helper.md) |
|
||||
| **Scheduler Select Helper** | Optimal scheduler selection for samplers | ✅ Complete | [Docs](examples/documentation/scheduler_select_helper.md) |
|
||||
| **Text Encode Sampler Params** | Combined text encoding and parameter management | ✅ Complete | [Docs](examples/documentation/text_encode_sampler_params.md) |
|
||||
| **Local Image Loader** | Visual gallery browser for local media files | ✅ Complete | [Docs](examples/documentation/local_image_loader.md) |
|
||||
| **Model Downloader** | Download models from CivitAI, HuggingFace, and custom URLs | ✅ Complete | [Docs](examples/documentation/model_downloader.md) |
|
||||
| **Workflow Timer** | Real-time execution timer with customizable display | ✅ Complete | [Docs](examples/documentation/workflow_timer.md) |
|
||||
| **Batch Image Processor** | Process multiple images with consistent settings | 🚧 Planned | Coming Soon |
|
||||
| **Advanced Prompt Utilities** | Enhanced prompt manipulation and generation | 🚧 Planned | Coming Soon |
|
||||
|
||||
@@ -706,7 +1032,7 @@ MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 🏷️ Tags
|
||||
|
||||
`comfyui` `custom-nodes` `image-processing` `ai-tools` `sdxl` `flux` `upscaling` `resolution` `batch-processing` `python` `pytorch`
|
||||
`comfyui` `custom-nodes` `image-processing` `ai-tools` `sdxl` `flux` `upscaling` `resolution` `batch-processing` `model-downloader` `civitai` `huggingface` `python` `pytorch`
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
@@ -717,16 +1043,34 @@ MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 📈 Stats
|
||||
|
||||
- **Nodes**: 10 (Resolution Calculator, Width Height Selector, Seed History, Sampler Combo, Empty Latent Batch, Kiko Save Image, Display Text, Gemini Prompt Engineer, Display Any, Image to Multiple Of)
|
||||
- **Nodes**: 21 (15 core tools + 6 xyz-helpers)
|
||||
- **Features**: Embedding Autocomplete (settings-based, not a node)
|
||||
- **Categories**: 9 emoji-based categories for better organization
|
||||
- **Download Platforms**: 3 (CivitAI, HuggingFace, Custom URLs)
|
||||
- **Format Support**: 3 (PNG, JPEG, WebP with advanced controls)
|
||||
- **Presets**: 26 curated resolution presets
|
||||
- **Interactive Features**: 6 (Width/Height Swap Button, Seed History UI, Empty Latent Batch Swap Button, Kiko Save Image Popup Viewer, Display Text Split View, Gemini Model Refresh)
|
||||
- **Interactive Features**: 8+ (swap buttons, history UI, popup viewers, parameter visualization)
|
||||
- **AI Integration**: Gemini API with 40+ model support
|
||||
- **Test Coverage**: 100% (200+ comprehensive tests)
|
||||
- **Test Coverage**: 100% (470+ comprehensive tests)
|
||||
- **Python Version**: 3.8+
|
||||
- **ComfyUI Compatibility**: Latest
|
||||
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow, google-generativeai for Gemini)
|
||||
|
||||
## 🙏 Attribution
|
||||
|
||||
### xyz-helpers Tools
|
||||
The xyz-helpers collection was adapted from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) by cubiq. The original project is in maintenance-only mode, and we've adopted these essential tools to ensure continued support and compatibility with modern ComfyUI workflows. We're grateful for cubiq's original work and contributions to the ComfyUI community.
|
||||
|
||||
The following tools are based on comfyui-essentials-nodes:
|
||||
- Flux Sampler Params
|
||||
- LoRA Folder Batch
|
||||
- Plot Parameters
|
||||
- Sampler Select Helper
|
||||
- Scheduler Select Helper
|
||||
- Text Encode Sampler Params
|
||||
|
||||
All adaptations maintain compatibility while adding new features and optimizations for the ComfyAssets ecosystem.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
+85
-1
@@ -13,7 +13,91 @@ except ImportError:
|
||||
from kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
# Tell ComfyUI where to find our JavaScript extensions
|
||||
WEB_DIRECTORY = "./web"
|
||||
import os
|
||||
|
||||
WEB_DIRECTORY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
|
||||
|
||||
# Import server components at module level to ensure they're available
|
||||
try:
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
import folder_paths
|
||||
|
||||
print("[KikoTools] Server imports successful")
|
||||
|
||||
# Register autocomplete endpoints directly
|
||||
@PromptServer.instance.routes.get("/kikotools/autocomplete/embeddings")
|
||||
async def get_embeddings(request):
|
||||
"""API endpoint for getting list of embeddings with full paths."""
|
||||
print("[KikoTools] Embeddings endpoint called")
|
||||
try:
|
||||
embedding_files = folder_paths.get_filename_list("embeddings")
|
||||
print(f"[KikoTools] Found {len(embedding_files)} embedding files")
|
||||
# Return embeddings with their subdirectory paths, without extensions
|
||||
embeddings = []
|
||||
for f in embedding_files:
|
||||
# Remove extension but keep subdirectory path
|
||||
clean_path = os.path.splitext(f)[0]
|
||||
embeddings.append(
|
||||
{
|
||||
"file_name": clean_path,
|
||||
"model_name": clean_path,
|
||||
"name": os.path.basename(clean_path),
|
||||
"path": clean_path,
|
||||
}
|
||||
)
|
||||
if len(embeddings) > 0:
|
||||
print(f"[KikoTools] Sample embedding: {embeddings[0]}")
|
||||
print(f"[KikoTools] Returning {len(embeddings)} embeddings with paths")
|
||||
return web.json_response(embeddings)
|
||||
except Exception as e:
|
||||
print(f"[KikoTools] Error getting embeddings: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return web.json_response([])
|
||||
|
||||
@PromptServer.instance.routes.get("/kikotools/autocomplete/loras")
|
||||
async def get_loras(request):
|
||||
"""API endpoint for getting list of LoRAs."""
|
||||
print("[KikoTools] LoRA endpoint called")
|
||||
try:
|
||||
lora_files = folder_paths.get_filename_list("loras")
|
||||
print(f"[KikoTools] Found {len(lora_files)} LoRA files")
|
||||
# Return LoRAs with paths
|
||||
loras = []
|
||||
for f in lora_files:
|
||||
clean_path = os.path.splitext(f)[0]
|
||||
loras.append(
|
||||
{
|
||||
"name": os.path.basename(clean_path),
|
||||
"path": clean_path,
|
||||
"file": f,
|
||||
}
|
||||
)
|
||||
print(f"[KikoTools] Returning {len(loras)} LoRAs")
|
||||
return web.json_response(loras)
|
||||
except Exception as e:
|
||||
print(f"[KikoTools] Error getting LoRAs: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return web.json_response([])
|
||||
|
||||
print("[KikoTools] Autocomplete API endpoints registered successfully")
|
||||
print(
|
||||
"[KikoTools] Routes available: /kikotools/autocomplete/embeddings and /kikotools/autocomplete/loras"
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
print(f"[KikoTools] Could not import server components: {e}")
|
||||
except Exception as e:
|
||||
print(f"[KikoTools] Unexpected error setting up API: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
# API endpoints are registered above at module import time
|
||||
|
||||
|
||||
def get_version():
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,106 @@
|
||||
# Batch Prompts Node
|
||||
|
||||
The **Batch Prompts** node loads and processes prompts from text files for batch generation workflows. It automatically cycles through prompts with each execution, making it perfect for testing multiple prompts in queue batches.
|
||||
|
||||
## Features
|
||||
|
||||
- **File-based prompt loading** - Load prompts from text files with `---` separators
|
||||
- **Auto-increment mode** - Automatically advance to the next prompt with each execution
|
||||
- **Positive/Negative splitting** - Automatically splits prompts at "Negative:" markers
|
||||
- **Persistent state** - Maintains position across ComfyUI restarts
|
||||
- **Wrap-around support** - Loop back to the first prompt after the last one
|
||||
- **Progress tracking** - Shows current position and total prompts
|
||||
|
||||
## Input Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `prompt_file` | STRING | "prompts.txt" | Path to text file containing prompts |
|
||||
| `index` | INT | 0 | Manual prompt index (when auto_increment is off) |
|
||||
| `auto_increment` | BOOLEAN | True | Automatically advance to next prompt |
|
||||
| `wrap_around` | BOOLEAN | True | Loop back to start after last prompt |
|
||||
| `split_negative` | BOOLEAN | True | Split prompts at "Negative:" marker |
|
||||
| `reload_file` | BOOLEAN | False | Force reload file from disk |
|
||||
| `show_preview` | BOOLEAN | True | Show prompt preview in console |
|
||||
|
||||
## Output Values
|
||||
|
||||
| Output | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `positive` | STRING | The positive prompt text |
|
||||
| `negative` | STRING | The negative prompt text (if split) |
|
||||
| `full_prompt` | STRING | Complete prompt including negative |
|
||||
| `next_prompt` | STRING | Preview of the next prompt |
|
||||
| `current_index` | INT | Current prompt index (0-based) |
|
||||
| `total_prompts` | INT | Total number of prompts |
|
||||
| `batch_info` | STRING | Progress information string |
|
||||
|
||||
## Prompt File Format
|
||||
|
||||
Create a text file with prompts separated by `---` on its own line:
|
||||
|
||||
```
|
||||
A beautiful sunset over the ocean
|
||||
Negative: blurry, dark, low quality
|
||||
---
|
||||
Mountain landscape with snow peaks
|
||||
Negative: foggy, unclear
|
||||
---
|
||||
Futuristic city at night
|
||||
Negative: old, vintage, sepia
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Queue Batch Processing
|
||||
|
||||
1. Create a prompt file in your ComfyUI `input` folder
|
||||
2. Add the Batch Prompts node to your workflow
|
||||
3. Set `prompt_file` to your file name
|
||||
4. Enable `auto_increment` and `wrap_around`
|
||||
5. Connect `positive` to your text encoder
|
||||
6. Connect `negative` to your negative text encoder
|
||||
7. Set Queue Batch to desired number (e.g., 10)
|
||||
8. Run the queue - prompts will cycle automatically
|
||||
|
||||
### Manual Index Control
|
||||
|
||||
For manual control over which prompt to use:
|
||||
|
||||
1. Set `auto_increment` to False
|
||||
2. Control the `index` parameter manually
|
||||
3. Use with other nodes that provide index values
|
||||
|
||||
### Monitoring Progress
|
||||
|
||||
The node provides several ways to track progress:
|
||||
|
||||
- `batch_info` output shows "Prompt X of Y (Z% complete)"
|
||||
- Console logging shows current prompt preview (when `show_preview` is True)
|
||||
- `current_index` and `total_prompts` for custom progress displays
|
||||
|
||||
## Tips
|
||||
|
||||
- Place prompt files in the ComfyUI `input` folder for easy access
|
||||
- Use relative paths like "prompts.txt" for files in the input folder
|
||||
- Use absolute paths for files elsewhere on your system
|
||||
- The node maintains state across ComfyUI restarts
|
||||
- Set `reload_file` to True to force re-reading after editing the file
|
||||
- Empty sections (between `---` markers) are automatically skipped
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Prompts not changing in queue batch
|
||||
- Ensure `auto_increment` is set to True
|
||||
- Check console for "[BatchPrompts] Auto-increment" messages
|
||||
- Restart ComfyUI after installing/updating the node
|
||||
|
||||
### File not found errors
|
||||
- Check that the file exists in the ComfyUI `input` folder
|
||||
- Try using an absolute path to test
|
||||
- Ensure file has read permissions
|
||||
|
||||
### State persistence
|
||||
- State is stored in your system's temp directory
|
||||
- Clear `/tmp/comfyui_batch_prompts/` to reset all counters
|
||||
- Use `reload_file` to reset counter for a specific file
|
||||
@@ -117,4 +117,4 @@ Config Node → Display Any (raw value) → Processing Node
|
||||
[Text Multiline] ← [Concatenate] ← "Image dimensions: "
|
||||
```
|
||||
|
||||
This creates a text output showing the current image dimensions that can be used elsewhere in your workflow.
|
||||
This creates a text output showing the current image dimensions that can be used elsewhere in your workflow.
|
||||
|
||||
@@ -85,7 +85,7 @@ Display long text content with scrolling and word wrapping.
|
||||
|
||||
The node intelligently detects prompt formats:
|
||||
|
||||
1. **SDXL Format**:
|
||||
1. **SDXL Format**:
|
||||
- Looks for "Positive prompt:" and "Negative prompt:" markers
|
||||
- Case-insensitive detection
|
||||
- Handles various formatting styles
|
||||
@@ -98,7 +98,7 @@ The node intelligently detects prompt formats:
|
||||
## Styling
|
||||
|
||||
- **Font**: Monospace for consistent alignment
|
||||
- **Colors**:
|
||||
- **Colors**:
|
||||
- Text: Light gray (#ddd) on dark background
|
||||
- Background: Semi-transparent dark (#1a1a1a)
|
||||
- Borders: Subtle gray (#333)
|
||||
@@ -144,4 +144,4 @@ The node intelligently detects prompt formats:
|
||||
SDXL Format Split View Display Clean Prompts
|
||||
```
|
||||
|
||||
This creates a seamless workflow from prompt generation to usage, with the Display Text node providing the visual interface for review and interaction.
|
||||
This creates a seamless workflow from prompt generation to usage, with the Display Text node providing the visual interface for review and interaction.
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Flux Sampler Params
|
||||
|
||||
## Overview
|
||||
The **Flux Sampler Params** node provides a specialized parameter generator for FLUX model sampling. This tool was adapted from the excellent [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) project (now in maintenance mode) and enhanced for the ComfyAssets ecosystem.
|
||||
|
||||
## Attribution
|
||||
This node is based on work from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) by cubiq. The original project is in maintenance-only mode, and we've adopted and enhanced these tools to ensure continued support and compatibility with modern ComfyUI workflows.
|
||||
|
||||
## Features
|
||||
- **FLUX-Optimized Parameters**: Specifically tuned for FLUX model requirements
|
||||
- **Batch Processing Support**: Generate multiple parameter sets for comparative testing
|
||||
- **Interactive UI Elements**: Visual controls for quick parameter adjustments
|
||||
- **Smart Defaults**: Pre-configured optimal settings for FLUX workflows
|
||||
- **Comprehensive Parameter Control**: Fine-tune all aspects of FLUX sampling
|
||||
|
||||
## Node Properties
|
||||
- **Category**: `ComfyAssets/🧰 xyz-helpers`
|
||||
- **Node Name**: `FluxSamplerParams`
|
||||
- **Function**: `get_value`
|
||||
|
||||
## Inputs
|
||||
|
||||
### Required
|
||||
| Parameter | Type | Default | Range | Description |
|
||||
|-----------|------|---------|-------|-------------|
|
||||
| `scheduler` | DROPDOWN | normal | [normal, simple, sgm_uniform] | Scheduler algorithm for sampling |
|
||||
| `steps` | INT | 20 | 1-100 | Number of sampling steps |
|
||||
| `guidance` | FLOAT | 3.5 | 0.0-100.0 | Guidance scale for conditioning |
|
||||
| `max_shift` | FLOAT | 1.0 | 0.0-100.0 | Maximum shift value for FLUX |
|
||||
| `base_shift` | FLOAT | 0.5 | 0.0-100.0 | Base shift value for FLUX |
|
||||
| `denoise` | FLOAT | 1.0 | 0.0-1.0 | Denoising strength |
|
||||
| `batch_mode` | DROPDOWN | single | [single, batch] | Single value or batch processing |
|
||||
|
||||
### Optional
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `batch_count` | INT | 1 | Number of batch variations (1-100) |
|
||||
| `batch_seed_mode` | DROPDOWN | incremental | Seed generation mode for batches |
|
||||
| `variation_seed` | INT | None | Optional seed for variations |
|
||||
| `lora_params` | LORA_PARAMS | None | LoRA parameters from LoRAFolderBatch |
|
||||
|
||||
## Outputs
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `sampler_params` | SAMPLER_PARAMS | Complete FLUX sampling parameters |
|
||||
| `scheduler` | STRING | Selected scheduler algorithm |
|
||||
| `steps` | INT | Number of sampling steps |
|
||||
| `guidance` | FLOAT | Guidance scale value |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic FLUX Sampling
|
||||
```
|
||||
FluxSamplerParams → KSampler → VAE Decode → Save Image
|
||||
scheduler: normal
|
||||
steps: 20
|
||||
guidance: 3.5
|
||||
```
|
||||
|
||||
### Batch Parameter Testing
|
||||
```
|
||||
FluxSamplerParams → KSampler → Image Grid → Save
|
||||
batch_mode: batch
|
||||
batch_count: 5
|
||||
guidance: 2.0...5.0
|
||||
```
|
||||
|
||||
### With LoRA Integration
|
||||
```
|
||||
LoRAFolderBatch → FluxSamplerParams → KSampler
|
||||
↓ ↓
|
||||
lora_params → Combined parameters
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### FLUX-Specific Settings
|
||||
- **Guidance**: FLUX typically works best with lower guidance (2.0-5.0)
|
||||
- **Steps**: 15-25 steps usually sufficient for FLUX
|
||||
- **Scheduler**: `normal` or `sgm_uniform` recommended for FLUX
|
||||
- **Shift Values**: Adjust for different quality/speed tradeoffs
|
||||
|
||||
### Batch Testing Workflow
|
||||
1. Set `batch_mode` to `batch`
|
||||
2. Configure parameter ranges using `...` syntax
|
||||
3. Set appropriate `batch_count`
|
||||
4. Use with image grid nodes for comparison
|
||||
|
||||
### Memory Optimization
|
||||
- Start with smaller batch counts for testing
|
||||
- Monitor VRAM usage with high batch counts
|
||||
- Use incremental seed mode for reproducibility
|
||||
|
||||
## Integration with Other Nodes
|
||||
|
||||
### Works Well With
|
||||
- **LoRA Folder Batch**: Combine multiple LoRAs with FLUX parameters
|
||||
- **Plot Parameters**: Visualize parameter effects
|
||||
- **Sampler Select Helper**: Dynamic sampler selection
|
||||
- **Text Encode Sampler Params**: Add text conditioning
|
||||
|
||||
### Common Workflows
|
||||
1. **Parameter Sweep**: Test multiple guidance/step combinations
|
||||
2. **LoRA Testing**: Evaluate different LoRA strengths with FLUX
|
||||
3. **Quality Comparison**: Compare different shift values
|
||||
4. **Seed Exploration**: Generate variations with controlled seeds
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
### Optimal FLUX Settings
|
||||
```python
|
||||
# High Quality (Slower)
|
||||
scheduler: "sgm_uniform"
|
||||
steps: 25
|
||||
guidance: 3.5
|
||||
max_shift: 1.0
|
||||
base_shift: 0.5
|
||||
|
||||
# Fast Preview
|
||||
scheduler: "simple"
|
||||
steps: 12
|
||||
guidance: 2.5
|
||||
max_shift: 0.8
|
||||
base_shift: 0.4
|
||||
```
|
||||
|
||||
### Batch Parameter Ranges
|
||||
- Steps: `15...25+5` (test 15, 20, 25)
|
||||
- Guidance: `2.0...5.0+0.5` (test 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0)
|
||||
- Denoise: `0.8...1.0+0.1` (test 0.8, 0.9, 1.0)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
1. **Out of Memory**: Reduce batch_count or image resolution
|
||||
2. **Poor Quality**: Increase steps or adjust guidance
|
||||
3. **Artifacts**: Check shift values aren't too high
|
||||
4. **Slow Generation**: Use `simple` scheduler for previews
|
||||
|
||||
### Parameter Guidelines
|
||||
- Don't set guidance too high (>10) for FLUX
|
||||
- Keep denoise at 1.0 for initial generation
|
||||
- Adjust shift values gradually for best results
|
||||
|
||||
## Version History
|
||||
- **1.0.0**: Initial adaptation from comfyui-essentials-nodes
|
||||
- **1.0.1**: Added batch processing support
|
||||
- **1.0.2**: Enhanced FLUX-specific optimizations
|
||||
- **1.0.3**: Improved UI elements and parameter validation
|
||||
|
||||
## Credits
|
||||
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
|
||||
@@ -206,4 +206,4 @@ Errors are displayed in the prompt output for easy debugging.
|
||||
|
||||
**Import error for google-generativeai**:
|
||||
- Run `pip install google-generativeai` in your ComfyUI environment
|
||||
- Restart ComfyUI after installation
|
||||
- Restart ComfyUI after installation
|
||||
|
||||
@@ -79,4 +79,4 @@ Load Images → Image to Multiple Of (multiple_of: 16, method: rescale) → Batc
|
||||
The node will raise an error if:
|
||||
- The image dimensions are smaller than the specified multiple_of value
|
||||
- Invalid input types are provided
|
||||
- The resulting dimensions would be 0 or negative
|
||||
- The resulting dimensions would be 0 or negative
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Kiko Film Grain
|
||||
|
||||
## Overview
|
||||
The **Kiko Film Grain** node applies realistic film grain effects to images, simulating the aesthetic of analog film photography. It provides comprehensive controls for grain size, intensity, color saturation, and shadow lifting to achieve various film looks.
|
||||
|
||||
## Node Details
|
||||
- **Category**: ComfyAssets/image
|
||||
- **Node Name**: KikoFilmGrain
|
||||
- **Display Name**: Kiko Film Grain
|
||||
|
||||
## Inputs
|
||||
|
||||
### Required
|
||||
- **image** (`IMAGE`)
|
||||
- The input image to apply film grain to
|
||||
- Supports batch processing
|
||||
- Preserves alpha channel if present
|
||||
|
||||
### Parameters
|
||||
- **scale** (`FLOAT`)
|
||||
- Controls the size of the grain pattern
|
||||
- Range: 0.25 to 2.0
|
||||
- Default: 0.5
|
||||
- Lower values = finer grain, higher values = coarser grain
|
||||
|
||||
- **strength** (`FLOAT`)
|
||||
- Intensity of the grain effect
|
||||
- Range: 0.0 to 10.0
|
||||
- Default: 0.5
|
||||
- 0.0 = no grain, higher values = more pronounced grain
|
||||
|
||||
- **saturation** (`FLOAT`)
|
||||
- Color saturation of the grain
|
||||
- Range: 0.0 to 2.0
|
||||
- Default: 0.7
|
||||
- 0.0 = monochrome grain, 1.0 = full color, >1.0 = oversaturated
|
||||
|
||||
- **toe** (`FLOAT`)
|
||||
- Lifts blacks/shadows for a film-like look
|
||||
- Range: -0.2 to 0.5
|
||||
- Default: 0.0
|
||||
- Positive values lift shadows, negative values crush blacks
|
||||
|
||||
- **seed** (`INT`)
|
||||
- Random seed for grain pattern generation
|
||||
- Range: 0 to maximum integer
|
||||
- Default: 0
|
||||
- Use for reproducible grain patterns
|
||||
|
||||
## Outputs
|
||||
- **image** (`IMAGE`)
|
||||
- The processed image with film grain applied
|
||||
- Same dimensions and batch size as input
|
||||
- Alpha channel preserved if present
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Subtle Film Look
|
||||
```
|
||||
Scale: 0.5
|
||||
Strength: 0.3
|
||||
Saturation: 0.8
|
||||
Toe: 0.05
|
||||
```
|
||||
Creates a subtle, fine-grained film aesthetic suitable for portraits.
|
||||
|
||||
### Vintage Film
|
||||
```
|
||||
Scale: 1.0
|
||||
Strength: 0.8
|
||||
Saturation: 0.5
|
||||
Toe: 0.15
|
||||
```
|
||||
Simulates vintage film with moderate grain and lifted shadows.
|
||||
|
||||
### High ISO Film
|
||||
```
|
||||
Scale: 0.75
|
||||
Strength: 1.5
|
||||
Saturation: 0.6
|
||||
Toe: 0.1
|
||||
```
|
||||
Emulates high ISO film stock with pronounced grain.
|
||||
|
||||
### Black & White Film
|
||||
```
|
||||
Scale: 0.6
|
||||
Strength: 0.6
|
||||
Saturation: 0.0
|
||||
Toe: 0.08
|
||||
```
|
||||
Creates monochrome grain perfect for black and white photography.
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Improvements Over Standard Implementations
|
||||
1. **Pure PyTorch Operations**: No OpenCV dependencies, better GPU utilization
|
||||
2. **ITU-R BT.709 Color Space**: Accurate color conversion for grain application
|
||||
3. **Screen Blend Mode**: Preserves highlights better than multiply blending
|
||||
4. **Channel-Specific Weighting**: Film grain is stronger in blue channel (3x), moderate in red (2x), matching real film characteristics
|
||||
5. **Efficient Memory Management**: Minimizes tensor copies and conversions
|
||||
|
||||
### Algorithm Overview
|
||||
1. Generate random noise at specified scale
|
||||
2. Convert to YCbCr color space for realistic grain distribution
|
||||
3. Apply different blur kernels to each channel:
|
||||
- Y (luminance): 3x3 kernel for fine detail
|
||||
- Cb (blue-yellow): 15x15 kernel for color noise
|
||||
- Cr (red-green): 11x11 kernel for color noise
|
||||
4. Convert back to RGB and apply strength/saturation
|
||||
5. Use screen blend mode to combine with original image
|
||||
6. Apply toe adjustment for film-like shadow response
|
||||
|
||||
## Tips
|
||||
- Start with low strength values (0.2-0.5) and adjust upward
|
||||
- For color images, saturation between 0.5-0.8 looks most natural
|
||||
- Combine with color grading nodes for complete film emulation
|
||||
- Use consistent seed values across batch for uniform grain
|
||||
- Scale parameter affects both grain size and render performance (smaller scale = more computation)
|
||||
|
||||
## Compatibility
|
||||
- Works with any image format supported by ComfyUI
|
||||
- Preserves image properties (alpha channel, batch size)
|
||||
- Compatible with both RGB and RGBA images
|
||||
- Efficient batch processing support
|
||||
@@ -50,7 +50,7 @@ Enhanced image saving node with multiple format support, quality controls, and a
|
||||
### Image Grid
|
||||
- **Thumbnails**: Click any image to open full-size in new tab
|
||||
- **File Info**: Shows filename and size for each image
|
||||
- **Quality Indicators**:
|
||||
- **Quality Indicators**:
|
||||
- PNG: Compression level (0-9)
|
||||
- JPEG/WebP: Quality percentage
|
||||
- **Batch Selection**: Checkboxes for multi-select operations
|
||||
@@ -210,4 +210,4 @@ Batch Generate → Kiko Save Image → Popup Viewer
|
||||
**Can't see all images**:
|
||||
- Scroll within the popup grid
|
||||
- Maximize the popup window
|
||||
- Images are shown newest first
|
||||
- Images are shown newest first
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# Local Image Loader
|
||||
|
||||
## Overview
|
||||
|
||||
The Local Image Loader node provides a visual gallery interface for browsing and selecting images, videos, and audio files from your local filesystem directly within ComfyUI. This streamlined version focuses on essential functionality without the complexity of tagging or metadata management.
|
||||
|
||||
## Features
|
||||
|
||||
- **Visual Gallery Browser**: Browse local directories with thumbnail previews
|
||||
- **Multi-Media Support**: Load images, videos, and audio files
|
||||
- **Directory Navigation**: Navigate through folders with ease
|
||||
- **Sorting Options**: Sort by name, date, or file size
|
||||
- **Saved Paths**: Save frequently used directory paths for quick access
|
||||
- **Pagination**: Handle large directories with paginated display
|
||||
- **Lightbox Preview**: Full-size preview with zoom and pan capabilities
|
||||
|
||||
## Node Inputs
|
||||
|
||||
### Required Inputs
|
||||
None - The node uses a visual interface for file selection
|
||||
|
||||
### Hidden Inputs
|
||||
- `unique_id`: Automatically assigned node identifier
|
||||
|
||||
## Node Outputs
|
||||
|
||||
| Output | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `image` | IMAGE | The selected image as a tensor |
|
||||
| `video_path` | STRING | Path to the selected video file |
|
||||
| `audio_path` | STRING | Path to the selected audio file |
|
||||
| `info` | STRING | JSON metadata about the selected image |
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Workflow
|
||||
|
||||
1. **Add the Node**: Search for "Local Image Loader" in the node menu
|
||||
2. **Browse Directory**: Enter a directory path or use saved paths
|
||||
3. **Select Media**: Click on thumbnails to select files
|
||||
4. **Connect Outputs**: Use the outputs in your workflow
|
||||
|
||||
### Interface Controls
|
||||
|
||||
#### Path Management
|
||||
- **Directory Input**: Enter or paste a directory path
|
||||
- **Saved Paths Dropdown**: Quick access to saved directories
|
||||
- **Save Path Button** (💾): Save current directory to favorites
|
||||
- **Browse Button** (📁): Load the entered directory
|
||||
|
||||
#### View Options
|
||||
- **Videos Checkbox**: Show/hide video files
|
||||
- **Audio Checkbox**: Show/hide audio files
|
||||
- **Sort By**: Choose between Name, Date, or Size
|
||||
- **Sort Order**: Ascending (↑) or Descending (↓)
|
||||
- **Refresh Button** (🔄): Reload current directory
|
||||
|
||||
#### Gallery Display
|
||||
- **Thumbnail Grid**: Visual preview of files
|
||||
- **Blue Border**: Selected items are highlighted
|
||||
- **Folder Icons**: Navigate into subdirectories
|
||||
- **Video Overlay**: Visual indicator for video files
|
||||
- **Pagination**: Navigate through pages of results
|
||||
|
||||
## File Support
|
||||
|
||||
### Supported Image Formats
|
||||
- `.jpg`, `.jpeg`
|
||||
- `.png`
|
||||
- `.bmp`
|
||||
- `.gif`
|
||||
- `.webp`
|
||||
|
||||
### Supported Video Formats
|
||||
- `.mp4`
|
||||
- `.webm`
|
||||
- `.mov`
|
||||
- `.mkv`
|
||||
- `.avi`
|
||||
|
||||
### Supported Audio Formats
|
||||
- `.mp3`
|
||||
- `.wav`
|
||||
- `.ogg`
|
||||
- `.flac`
|
||||
|
||||
## Image Metadata
|
||||
|
||||
When an image is selected, the node extracts and returns metadata including:
|
||||
- **Basic Info**: Filename, width, height, format, mode
|
||||
- **Embedded Parameters**: Generation parameters if present
|
||||
- **Workflow Data**: Embedded ComfyUI workflow if present
|
||||
- **Prompt Data**: Embedded prompt information if present
|
||||
|
||||
## Examples
|
||||
|
||||
### Loading an Image for Processing
|
||||
```
|
||||
Local Image Loader → Load Image → Image Processing Node
|
||||
↓
|
||||
[info] → Display Text (to show metadata)
|
||||
```
|
||||
|
||||
### Setting Up a Multi-Media Workflow
|
||||
```
|
||||
Local Image Loader → [image] → Image Preview
|
||||
↓
|
||||
[video_path] → Video Player Node
|
||||
↓
|
||||
[audio_path] → Audio Player Node
|
||||
```
|
||||
|
||||
## Tips and Best Practices
|
||||
|
||||
1. **Save Frequently Used Paths**: Use the save button to bookmark directories you use often
|
||||
2. **Use Sorting**: Sort by date to find recent files quickly
|
||||
3. **Keyboard Navigation**: Press Enter in the path field to load a directory
|
||||
4. **Performance**: For directories with thousands of files, use pagination to navigate efficiently
|
||||
5. **Thumbnail Generation**: Thumbnails are generated on-demand and cached for performance
|
||||
|
||||
## Differences from Original
|
||||
|
||||
This version simplifies the original ComfyUI_Local_Image_Gallery by removing:
|
||||
- Tag filtering and management
|
||||
- Rating system
|
||||
- Global tag search
|
||||
- Metadata editing capabilities
|
||||
|
||||
These features were removed to focus on the core functionality of browsing and selecting files, making the tool simpler and more straightforward to use.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Directory Not Loading**
|
||||
- Verify the path exists and you have read permissions
|
||||
- Check for special characters in the path
|
||||
- Try using absolute paths instead of relative ones
|
||||
|
||||
**Thumbnails Not Showing**
|
||||
- Ensure the files are in supported formats
|
||||
- Check if the images are corrupted
|
||||
- Try refreshing the gallery
|
||||
|
||||
**Large Directories Slow to Load**
|
||||
- Use sorting and pagination to manage large folders
|
||||
- Consider organizing files into subdirectories
|
||||
- Enable only the media types you need (images, videos, audio)
|
||||
|
||||
## Technical Details
|
||||
|
||||
The node creates a visual widget that runs in the ComfyUI interface and communicates with the backend through API endpoints to:
|
||||
- List directory contents
|
||||
- Generate thumbnails
|
||||
- Save user preferences
|
||||
- Handle file selection
|
||||
|
||||
All file operations are performed server-side for security, with proper path validation to prevent directory traversal attacks.
|
||||
@@ -0,0 +1,264 @@
|
||||
# LoRA Folder Batch
|
||||
|
||||
## Overview
|
||||
The **LoRA Folder Batch** node automates the process of testing multiple LoRA models from a folder. This tool was adapted from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) (now in maintenance mode) and enhanced with batch processing capabilities for efficient LoRA evaluation workflows.
|
||||
|
||||
## Attribution
|
||||
This node is based on work from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) by cubiq. The original project is in maintenance-only mode, and we've adopted and enhanced these tools to ensure continued support and compatibility with modern ComfyUI workflows.
|
||||
|
||||
## Features
|
||||
- **Automatic Folder Scanning**: Discovers all .safetensors files in specified folders
|
||||
- **Natural Sorting**: Intelligently sorts epochs (e.g., epoch_004, epoch_020, epoch_100)
|
||||
- **Pattern Filtering**: Include/exclude LoRAs using regex patterns
|
||||
- **Flexible Strength Control**: Single, multiple, or range-based strength values
|
||||
- **Batch Modes**: Sequential or combinatorial strength application
|
||||
- **Epoch Detection**: Automatically extracts epoch numbers from filenames
|
||||
- **Auto-Batching**: Automatically splits large LoRA collections into manageable chunks to prevent UI disconnection
|
||||
|
||||
## Node Properties
|
||||
- **Category**: `ComfyAssets/🧰 xyz-helpers`
|
||||
- **Node Name**: `LoRAFolderBatch`
|
||||
- **Function**: `batch_loras`
|
||||
|
||||
## Inputs
|
||||
|
||||
### Required
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `folder_path` | STRING | "." | Folder path relative to models/loras (or absolute) |
|
||||
| `strength` | STRING | "1.0" | Strength values (see formats below) |
|
||||
| `batch_mode` | DROPDOWN | sequential | [sequential, combinatorial] processing mode |
|
||||
|
||||
### Optional
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `include_pattern` | STRING | "" | Regex pattern to include files |
|
||||
| `exclude_pattern` | STRING | "" | Regex pattern to exclude files |
|
||||
| `max_loras` | INT | 50 | Maximum LoRAs to process (when auto_batch disabled) |
|
||||
| `sort_order` | DROPDOWN | natural | Sorting method [natural, alphabetical, newest, oldest] |
|
||||
| `auto_batch` | DROPDOWN | disabled | Enable auto-batching for large collections [disabled, enabled] |
|
||||
| `batch_size` | INT | 25 | Number of LoRAs per batch when auto-batching |
|
||||
| `batch_index` | INT | 0 | Which batch to output (0-based) when auto-batching |
|
||||
|
||||
### Strength Format Options
|
||||
- **Single**: `"1.0"` - Apply same strength to all LoRAs
|
||||
- **Multiple**: `"0.5, 0.75, 1.0"` - Comma-separated values
|
||||
- **Range**: `"0.5...1.0+0.25"` - Start...End+Step format
|
||||
|
||||
## Outputs
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `lora_params` | LORA_PARAMS | Batch parameters for processing |
|
||||
| `lora_list` | STRING | List of discovered LoRAs with epoch info |
|
||||
| `lora_count` | INT | Number of LoRAs found |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Test All Epochs of a LoRA
|
||||
```
|
||||
LoRAFolderBatch → FluxSamplerParams → KSampler
|
||||
folder_path: "my_lora_training"
|
||||
strength: "1.0"
|
||||
batch_mode: sequential
|
||||
```
|
||||
|
||||
### Strength Testing for Each LoRA
|
||||
```
|
||||
LoRAFolderBatch → KSampler → Image Grid
|
||||
folder_path: "test_loras"
|
||||
strength: "0.5, 0.75, 1.0"
|
||||
batch_mode: combinatorial
|
||||
```
|
||||
|
||||
### Filter Specific Epochs
|
||||
```
|
||||
LoRAFolderBatch → Processing Pipeline
|
||||
folder_path: "training_results"
|
||||
include_pattern: "epoch_0[2-5]0"
|
||||
strength: "0.8...1.2+0.1"
|
||||
```
|
||||
|
||||
### Auto-Batch Large Collections
|
||||
```
|
||||
LoRAFolderBatch → FluxSamplerParams → KSampler
|
||||
folder_path: "massive_lora_collection" # 100+ files
|
||||
strength: "1.0"
|
||||
auto_batch: enabled
|
||||
batch_size: 25
|
||||
batch_index: 0 # Change to 1, 2, 3... for subsequent batches
|
||||
```
|
||||
|
||||
## Batch Modes Explained
|
||||
|
||||
### Sequential Mode
|
||||
Each LoRA gets one strength value in order:
|
||||
- LoRA1 → strength[0]
|
||||
- LoRA2 → strength[1]
|
||||
- LoRA3 → strength[0] (cycles if fewer strengths than LoRAs)
|
||||
|
||||
### Combinatorial Mode
|
||||
Each LoRA is tested with ALL strength values:
|
||||
- LoRA1 → [0.5, 0.75, 1.0]
|
||||
- LoRA2 → [0.5, 0.75, 1.0]
|
||||
- LoRA3 → [0.5, 0.75, 1.0]
|
||||
|
||||
## Auto-Batching for Large Collections
|
||||
|
||||
### Overview
|
||||
When testing large numbers of LoRAs (e.g., 75+ files), ComfyUI can experience UI disconnections or memory issues. Auto-batching solves this by automatically splitting your LoRA collection into smaller, manageable chunks.
|
||||
|
||||
### How It Works
|
||||
1. **Enable Auto-Batching**: Set `auto_batch` to "enabled"
|
||||
2. **Set Batch Size**: Configure `batch_size` (default: 25, range: 5-100)
|
||||
3. **Select Batch**: Use `batch_index` to choose which batch to process
|
||||
|
||||
### Example: Testing 75 LoRAs
|
||||
With 75 LoRAs and batch_size=25, the system creates 3 batches:
|
||||
- **Batch 0**: LoRAs 1-25 (set batch_index=0)
|
||||
- **Batch 1**: LoRAs 26-50 (set batch_index=1)
|
||||
- **Batch 2**: LoRAs 51-75 (set batch_index=2)
|
||||
|
||||
Run your workflow 3 times, changing only the `batch_index` each time.
|
||||
|
||||
### Visual Feedback
|
||||
When auto-batching is enabled, the `lora_list` output includes batch information:
|
||||
```
|
||||
=== Batch 1/3 (LoRAs 1-25) ===
|
||||
|
||||
style-epoch-001
|
||||
style-epoch-002
|
||||
...
|
||||
```
|
||||
|
||||
### Best Practices for Auto-Batching
|
||||
1. **Start with Default**: Use batch_size=25 for most scenarios
|
||||
2. **Adjust for Memory**: Decrease batch_size if you still experience issues
|
||||
3. **Combinatorial Mode**: Be extra careful - 25 LoRAs × 3 strengths = 75 combinations
|
||||
4. **Save Between Batches**: Save your results after each batch to avoid data loss
|
||||
5. **Use Plot Parameters**: The batch info appears in plot visualizations for easy tracking
|
||||
|
||||
## File Naming Patterns
|
||||
|
||||
### Supported Epoch Formats
|
||||
- `model-v1-000004.safetensors` → Epoch 4
|
||||
- `style_epoch_020.safetensors` → Epoch 20
|
||||
- `lora-000100.safetensors` → Epoch 100
|
||||
|
||||
### Natural Sorting Examples
|
||||
Files are sorted intelligently:
|
||||
1. `model-000004.safetensors`
|
||||
2. `model-000020.safetensors`
|
||||
3. `model-000100.safetensors`
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Folder Organization
|
||||
```
|
||||
models/loras/
|
||||
├── my_style/
|
||||
│ ├── style-000010.safetensors
|
||||
│ ├── style-000020.safetensors
|
||||
│ └── style-000030.safetensors
|
||||
└── character/
|
||||
├── char-v2-000005.safetensors
|
||||
└── char-v2-000010.safetensors
|
||||
```
|
||||
|
||||
### Testing Workflows
|
||||
1. **Initial Testing**: Use single strength (1.0) to evaluate all epochs
|
||||
2. **Fine-tuning**: Use combinatorial mode with multiple strengths
|
||||
3. **Final Selection**: Filter to specific epochs and test strength range
|
||||
|
||||
### Pattern Filtering Examples
|
||||
```python
|
||||
# Include only specific versions
|
||||
include_pattern: "v2|v3"
|
||||
|
||||
# Exclude test/backup files
|
||||
exclude_pattern: "test|backup|old"
|
||||
|
||||
# Include specific epoch range
|
||||
include_pattern: "epoch_0[3-7]0"
|
||||
```
|
||||
|
||||
## Integration with Other Nodes
|
||||
|
||||
### Common Pipelines
|
||||
1. **LoRA Comparison Grid**:
|
||||
```
|
||||
LoRAFolderBatch → KSampler → Image Grid → Save
|
||||
```
|
||||
|
||||
2. **Strength Testing**:
|
||||
```
|
||||
LoRAFolderBatch → PlotParameters → Graph Display
|
||||
```
|
||||
|
||||
3. **Combined with FLUX**:
|
||||
```
|
||||
LoRAFolderBatch → FluxSamplerParams → KSampler
|
||||
```
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
### Memory Management
|
||||
- Start with fewer LoRAs when testing combinatorial mode
|
||||
- Use sequential mode for initial epoch evaluation
|
||||
- Clear LoRA cache between large batch runs
|
||||
|
||||
### Optimal Strength Ranges
|
||||
- **Style LoRAs**: 0.5-1.0
|
||||
- **Character LoRAs**: 0.7-1.2
|
||||
- **Detail LoRAs**: 0.3-0.7
|
||||
|
||||
### Debugging
|
||||
- Check `lora_list` output to verify correct files were found
|
||||
- Use `lora_count` to confirm expected number of LoRAs
|
||||
- Test patterns with include/exclude before full runs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No LoRAs Found
|
||||
- Verify folder path (relative to models/loras or use absolute)
|
||||
- Check file extensions (.safetensors)
|
||||
- Test without filters first
|
||||
|
||||
### Pattern Not Working
|
||||
- Patterns use Python regex syntax
|
||||
- Test patterns in regex tester first
|
||||
- Case-sensitive by default
|
||||
|
||||
### Memory Issues
|
||||
- Reduce batch_count in combinatorial mode
|
||||
- Process LoRAs in smaller groups
|
||||
- Use sequential mode for large sets
|
||||
|
||||
## Advanced Examples
|
||||
|
||||
### Multi-Version Testing
|
||||
```python
|
||||
# Test different versions at different strengths
|
||||
folder_path: "character_loras"
|
||||
include_pattern: "v[1-3]"
|
||||
strength: "0.6, 0.8, 1.0"
|
||||
batch_mode: combinatorial
|
||||
```
|
||||
|
||||
### Epoch Progression Analysis
|
||||
```python
|
||||
# Test every 10th epoch
|
||||
folder_path: "training_output"
|
||||
include_pattern: "0[0-9]0\\.safetensors$"
|
||||
strength: "1.0"
|
||||
batch_mode: sequential
|
||||
```
|
||||
|
||||
## Version History
|
||||
- **1.0.0**: Initial adaptation from comfyui-essentials-nodes
|
||||
- **1.0.1**: Added natural sorting for epochs
|
||||
- **1.0.2**: Enhanced pattern filtering
|
||||
- **1.0.3**: Improved batch modes and strength parsing
|
||||
- **1.0.4**: Added auto-batching for large LoRA collections
|
||||
|
||||
## Credits
|
||||
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
|
||||
@@ -0,0 +1,234 @@
|
||||
# Plot Parameters
|
||||
|
||||
## Overview
|
||||
The **Plot Parameters** node creates visual graphs and plots from sampler parameters, enabling data-driven analysis of generation settings. Adapted from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) (now in maintenance mode), this tool helps visualize the relationship between parameters and output quality.
|
||||
|
||||
## Attribution
|
||||
This node is based on work from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) by cubiq. The original project is in maintenance-only mode, and we've adopted and enhanced these tools to ensure continued support and compatibility with modern ComfyUI workflows.
|
||||
|
||||
## Features
|
||||
- **Multi-Parameter Plotting**: Visualize multiple parameters simultaneously
|
||||
- **Comparison Graphs**: Compare settings across batch runs
|
||||
- **Statistical Analysis**: Calculate means, deviations, and trends
|
||||
- **Export Capabilities**: Save plots as images or data files
|
||||
- **Real-time Updates**: Dynamic graph generation during workflow execution
|
||||
|
||||
## Node Properties
|
||||
- **Category**: `ComfyAssets/🧰 xyz-helpers`
|
||||
- **Node Name**: `PlotParameters`
|
||||
- **Function**: `plot`
|
||||
|
||||
## Inputs
|
||||
|
||||
### Required
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `sampler_params` | SAMPLER_PARAMS | - | Parameters to plot |
|
||||
| `plot_type` | DROPDOWN | line | [line, bar, scatter, heatmap] |
|
||||
| `x_axis` | DROPDOWN | steps | Parameter for X axis |
|
||||
| `y_axis` | DROPDOWN | quality | Metric for Y axis |
|
||||
|
||||
### Optional
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `title` | STRING | "Parameter Analysis" | Graph title |
|
||||
| `show_grid` | BOOLEAN | True | Display grid lines |
|
||||
| `show_legend` | BOOLEAN | True | Display legend |
|
||||
| `color_scheme` | DROPDOWN | default | Color palette selection |
|
||||
|
||||
## Outputs
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `plot_image` | IMAGE | Generated plot as image |
|
||||
| `data_csv` | STRING | Plot data in CSV format |
|
||||
| `statistics` | STRING | Statistical summary |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Parameter Visualization
|
||||
```
|
||||
FluxSamplerParams → PlotParameters → Display Image
|
||||
plot_type: line
|
||||
x_axis: steps
|
||||
y_axis: guidance
|
||||
```
|
||||
|
||||
### Batch Comparison Plot
|
||||
```
|
||||
LoRAFolderBatch → PlotParameters → Save Image
|
||||
plot_type: scatter
|
||||
x_axis: lora_strength
|
||||
y_axis: quality_score
|
||||
```
|
||||
|
||||
### Heatmap Analysis
|
||||
```
|
||||
Parameter Grid → PlotParameters → Analysis Display
|
||||
plot_type: heatmap
|
||||
x_axis: cfg
|
||||
y_axis: steps
|
||||
```
|
||||
|
||||
## Plot Types Explained
|
||||
|
||||
### Line Plot
|
||||
- Best for continuous parameter changes
|
||||
- Shows trends and relationships
|
||||
- Ideal for time series or progression
|
||||
|
||||
### Bar Chart
|
||||
- Compares discrete values
|
||||
- Good for categorical comparisons
|
||||
- Shows distribution clearly
|
||||
|
||||
### Scatter Plot
|
||||
- Reveals correlations
|
||||
- Identifies outliers
|
||||
- Best for large datasets
|
||||
|
||||
### Heatmap
|
||||
- Two-dimensional parameter analysis
|
||||
- Color-coded intensity values
|
||||
- Perfect for grid searches
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Parameter Selection
|
||||
- Choose related parameters for meaningful plots
|
||||
- Use consistent scales for comparison
|
||||
- Consider parameter ranges when plotting
|
||||
|
||||
### Visual Clarity
|
||||
- Limit number of series to 5-7 for readability
|
||||
- Use contrasting colors for multiple lines
|
||||
- Enable grid for precise value reading
|
||||
|
||||
### Data Analysis
|
||||
```python
|
||||
# Effective parameter combinations
|
||||
x_axis: "guidance"
|
||||
y_axis: "perceived_quality"
|
||||
|
||||
# Step efficiency analysis
|
||||
x_axis: "steps"
|
||||
y_axis: "generation_time"
|
||||
|
||||
# LoRA impact assessment
|
||||
x_axis: "lora_strength"
|
||||
y_axis: "style_adherence"
|
||||
```
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### Complete Analysis Pipeline
|
||||
```
|
||||
1. Generate with parameters
|
||||
2. Plot results
|
||||
3. Export data
|
||||
4. Statistical analysis
|
||||
```
|
||||
|
||||
### Multi-Plot Workflow
|
||||
```
|
||||
Params → Plot1 (steps vs quality)
|
||||
↘ Plot2 (guidance vs coherence)
|
||||
↘ Plot3 (strength vs style)
|
||||
→ Combined Analysis
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Custom Metrics
|
||||
- Define custom Y-axis metrics
|
||||
- Import external quality scores
|
||||
- Calculate derived values
|
||||
|
||||
### Export Options
|
||||
- PNG/SVG image formats
|
||||
- CSV data export
|
||||
- JSON statistics export
|
||||
|
||||
### Styling Options
|
||||
```python
|
||||
# Professional presentation
|
||||
color_scheme: "scientific"
|
||||
show_grid: True
|
||||
show_legend: True
|
||||
|
||||
# Minimal style
|
||||
color_scheme: "minimal"
|
||||
show_grid: False
|
||||
show_legend: False
|
||||
```
|
||||
|
||||
## Statistical Analysis
|
||||
|
||||
### Available Metrics
|
||||
- Mean, Median, Mode
|
||||
- Standard Deviation
|
||||
- Correlation Coefficients
|
||||
- Trend Lines
|
||||
- R-squared Values
|
||||
|
||||
### Interpretation Guide
|
||||
- **Positive Correlation**: Parameters increase together
|
||||
- **Negative Correlation**: Inverse relationship
|
||||
- **No Correlation**: Independent parameters
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
### Optimal Visualization
|
||||
1. Start with scatter plots for exploration
|
||||
2. Use line plots for trends
|
||||
3. Apply heatmaps for 2D parameter spaces
|
||||
4. Bar charts for final comparisons
|
||||
|
||||
### Data Preparation
|
||||
- Normalize scales when comparing different metrics
|
||||
- Remove outliers for cleaner plots
|
||||
- Group similar parameters
|
||||
|
||||
### Performance Tips
|
||||
- Cache plot images for repeated viewing
|
||||
- Export data for external analysis
|
||||
- Use lower resolution for preview plots
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Empty Plots
|
||||
- Verify sampler_params contains data
|
||||
- Check axis parameter selection
|
||||
- Ensure valid parameter ranges
|
||||
|
||||
### Scaling Issues
|
||||
- Use logarithmic scale for wide ranges
|
||||
- Normalize data if needed
|
||||
- Adjust plot dimensions
|
||||
|
||||
### Export Problems
|
||||
- Check file permissions
|
||||
- Verify export path exists
|
||||
- Ensure sufficient disk space
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Hyperparameter Optimization
|
||||
Track and visualize the effect of different sampling parameters on output quality.
|
||||
|
||||
### LoRA Strength Analysis
|
||||
Plot the relationship between LoRA strength and style transfer effectiveness.
|
||||
|
||||
### Efficiency Studies
|
||||
Analyze generation time vs quality trade-offs across different settings.
|
||||
|
||||
### Batch Comparison
|
||||
Compare multiple generation runs to identify optimal parameters.
|
||||
|
||||
## Version History
|
||||
- **1.0.0**: Initial adaptation from comfyui-essentials-nodes
|
||||
- **1.0.1**: Added heatmap visualization
|
||||
- **1.0.2**: Enhanced statistical analysis
|
||||
- **1.0.3**: Improved export capabilities
|
||||
|
||||
## Credits
|
||||
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
|
||||
@@ -0,0 +1,260 @@
|
||||
# Sampler Select Helper
|
||||
|
||||
## Overview
|
||||
The **Sampler Select Helper** node provides intelligent sampler selection with model-specific recommendations and compatibility checking. Adapted from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) (now in maintenance mode), this tool ensures optimal sampler-scheduler combinations for different model architectures.
|
||||
|
||||
## Attribution
|
||||
This node is based on work from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) by cubiq. The original project is in maintenance-only mode, and we've adopted and enhanced these tools to ensure continued support and compatibility with modern ComfyUI workflows.
|
||||
|
||||
## Features
|
||||
- **Model-Aware Selection**: Automatic recommendations based on model type
|
||||
- **Compatibility Validation**: Ensures sampler-scheduler pairs work well together
|
||||
- **Performance Profiles**: Pre-configured settings for quality vs speed
|
||||
- **Dynamic Updates**: Adapts to newly available samplers
|
||||
- **Batch Support**: Test multiple samplers in sequence
|
||||
|
||||
## Node Properties
|
||||
- **Category**: `ComfyAssets/🧰 xyz-helpers`
|
||||
- **Node Name**: `SamplerSelectHelper`
|
||||
- **Function**: `select_sampler`
|
||||
|
||||
## Inputs
|
||||
|
||||
### Required
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `model_type` | DROPDOWN | auto | [auto, sdxl, sd15, flux, custom] |
|
||||
| `quality_preset` | DROPDOWN | balanced | [fast, balanced, quality, extreme] |
|
||||
| `sampler_override` | DROPDOWN | auto | Specific sampler selection |
|
||||
|
||||
### Optional
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `scheduler_override` | DROPDOWN | auto | Specific scheduler selection |
|
||||
| `model_name` | STRING | - | Model name for auto-detection |
|
||||
| `custom_rules` | STRING | - | JSON rules for custom selection |
|
||||
|
||||
## Outputs
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `sampler_name` | STRING | Selected sampler |
|
||||
| `scheduler` | STRING | Selected scheduler |
|
||||
| `recommended_steps` | INT | Suggested step count |
|
||||
| `recommended_cfg` | FLOAT | Suggested CFG scale |
|
||||
|
||||
## Model-Specific Recommendations
|
||||
|
||||
### SDXL Models
|
||||
```python
|
||||
quality_preset: "balanced"
|
||||
→ sampler: "dpmpp_2m"
|
||||
→ scheduler: "karras"
|
||||
→ steps: 25
|
||||
→ cfg: 7.0
|
||||
```
|
||||
|
||||
### SD 1.5 Models
|
||||
```python
|
||||
quality_preset: "quality"
|
||||
→ sampler: "dpmpp_2m_sde"
|
||||
→ scheduler: "exponential"
|
||||
→ steps: 30
|
||||
→ cfg: 7.5
|
||||
```
|
||||
|
||||
### FLUX Models
|
||||
```python
|
||||
quality_preset: "fast"
|
||||
→ sampler: "euler"
|
||||
→ scheduler: "simple"
|
||||
→ steps: 15
|
||||
→ cfg: 3.5
|
||||
```
|
||||
|
||||
## Quality Presets Explained
|
||||
|
||||
### Fast (Preview)
|
||||
- **Goal**: Quick iterations
|
||||
- **Steps**: 10-15
|
||||
- **Samplers**: euler, dpm_fast
|
||||
- **Use Case**: Testing prompts
|
||||
|
||||
### Balanced (Default)
|
||||
- **Goal**: Good quality/speed ratio
|
||||
- **Steps**: 20-25
|
||||
- **Samplers**: dpmpp_2m, dpmpp_2m_sde
|
||||
- **Use Case**: Regular generation
|
||||
|
||||
### Quality
|
||||
- **Goal**: Best visual quality
|
||||
- **Steps**: 30-40
|
||||
- **Samplers**: dpmpp_3m_sde, dpm_adaptive
|
||||
- **Use Case**: Final renders
|
||||
|
||||
### Extreme
|
||||
- **Goal**: Maximum quality
|
||||
- **Steps**: 50-100
|
||||
- **Samplers**: dpm_adaptive, dpmpp_3m_sde
|
||||
- **Use Case**: Hero images
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Auto Model Detection
|
||||
```
|
||||
Load Model → SamplerSelectHelper → KSampler
|
||||
model_type: auto
|
||||
quality_preset: balanced
|
||||
```
|
||||
|
||||
### Custom Override
|
||||
```
|
||||
SamplerSelectHelper → KSampler
|
||||
sampler_override: "dpmpp_3m_sde"
|
||||
scheduler_override: "exponential"
|
||||
```
|
||||
|
||||
### Batch Testing
|
||||
```
|
||||
SamplerSelectHelper → Batch Process
|
||||
quality_preset: [fast, balanced, quality]
|
||||
→ Compare outputs
|
||||
```
|
||||
|
||||
## Compatibility Matrix
|
||||
|
||||
### Recommended Combinations
|
||||
| Sampler | Best Schedulers | Avoid |
|
||||
|---------|----------------|--------|
|
||||
| euler | normal, karras | sgm_uniform |
|
||||
| euler_a | normal, karras | simple |
|
||||
| dpmpp_2m | karras, exponential | - |
|
||||
| dpmpp_2m_sde | karras, exponential | simple |
|
||||
| dpmpp_3m_sde | exponential | simple |
|
||||
| dpm_adaptive | normal | karras |
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Model Type Detection
|
||||
1. Use `auto` for automatic detection
|
||||
2. Override only when necessary
|
||||
3. Provide model_name for better accuracy
|
||||
|
||||
### Performance Optimization
|
||||
```python
|
||||
# Quick preview workflow
|
||||
quality_preset: "fast"
|
||||
→ 10 steps, euler sampler
|
||||
|
||||
# Final production
|
||||
quality_preset: "quality"
|
||||
→ 35 steps, dpmpp_3m_sde
|
||||
|
||||
# Experimental/artistic
|
||||
quality_preset: "extreme"
|
||||
→ 75 steps, dpm_adaptive
|
||||
```
|
||||
|
||||
### Custom Rules Format
|
||||
```json
|
||||
{
|
||||
"model_pattern": "anime.*",
|
||||
"sampler": "dpmpp_2m_sde",
|
||||
"scheduler": "karras",
|
||||
"steps": 28,
|
||||
"cfg": 7.0
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Other Nodes
|
||||
|
||||
### Complete Pipeline
|
||||
```
|
||||
Model Loader → SamplerSelectHelper → KSampler
|
||||
↘ FluxSamplerParams ↗
|
||||
```
|
||||
|
||||
### A/B Testing
|
||||
```
|
||||
SamplerSelectHelper → KSampler → Image A
|
||||
quality: fast
|
||||
SamplerSelectHelper → KSampler → Image B
|
||||
quality: quality
|
||||
→ Compare Results
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Dynamic Sampler Discovery
|
||||
- Automatically detects new samplers
|
||||
- Updates compatibility matrix
|
||||
- Maintains optimal pairings
|
||||
|
||||
### Performance Profiling
|
||||
- Tracks generation times
|
||||
- Suggests optimal settings
|
||||
- Adapts to hardware capabilities
|
||||
|
||||
### Model Fingerprinting
|
||||
- Identifies model architecture
|
||||
- Applies specific optimizations
|
||||
- Learns from usage patterns
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
### Speed vs Quality
|
||||
1. Start with "fast" for prompt testing
|
||||
2. Move to "balanced" for iteration
|
||||
3. Use "quality" for final output
|
||||
4. Reserve "extreme" for special cases
|
||||
|
||||
### Sampler Selection Logic
|
||||
```python
|
||||
if model_type == "flux":
|
||||
prefer ["euler", "dpmpp_2m"]
|
||||
elif model_type == "sdxl":
|
||||
prefer ["dpmpp_2m_sde", "dpmpp_3m_sde"]
|
||||
else:
|
||||
use ["dpmpp_2m", "euler_a"]
|
||||
```
|
||||
|
||||
### Memory Considerations
|
||||
- Fast presets use less memory
|
||||
- Extreme presets may require more VRAM
|
||||
- Adaptive samplers adjust dynamically
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Wrong Sampler Selected
|
||||
- Check model_type setting
|
||||
- Verify model detection
|
||||
- Use manual override if needed
|
||||
|
||||
### Poor Quality Output
|
||||
- Increase quality preset
|
||||
- Check recommended steps
|
||||
- Verify CFG scale
|
||||
|
||||
### Performance Issues
|
||||
- Start with fast preset
|
||||
- Reduce step count
|
||||
- Try simpler samplers
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Model Comparison
|
||||
Test same prompt across different models with optimal settings for each.
|
||||
|
||||
### Quality Ladder
|
||||
Progress from fast to extreme quality to find optimal balance.
|
||||
|
||||
### Sampler Shootout
|
||||
Compare all compatible samplers for specific model/prompt combination.
|
||||
|
||||
## Version History
|
||||
- **1.0.0**: Initial adaptation from comfyui-essentials-nodes
|
||||
- **1.0.1**: Added FLUX model support
|
||||
- **1.0.2**: Enhanced compatibility matrix
|
||||
- **1.0.3**: Improved auto-detection
|
||||
|
||||
## Credits
|
||||
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
|
||||
@@ -0,0 +1,300 @@
|
||||
# Scheduler Select Helper
|
||||
|
||||
## Overview
|
||||
The **Scheduler Select Helper** node provides intelligent scheduler selection with sampler-aware recommendations and model-specific optimizations. Adapted from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) (now in maintenance mode), this tool ensures optimal scheduler selection for different sampling algorithms and models.
|
||||
|
||||
## Attribution
|
||||
This node is based on work from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) by cubiq. The original project is in maintenance-only mode, and we've adopted and enhanced these tools to ensure continued support and compatibility with modern ComfyUI workflows.
|
||||
|
||||
## Features
|
||||
- **Sampler-Aware Selection**: Recommends best schedulers for each sampler
|
||||
- **Model Optimization**: Specific scheduler tuning for different models
|
||||
- **Noise Schedule Profiles**: Pre-configured curves for various use cases
|
||||
- **Visual Feedback**: Preview noise schedules
|
||||
- **Batch Testing**: Compare multiple schedulers
|
||||
|
||||
## Node Properties
|
||||
- **Category**: `ComfyAssets/🧰 xyz-helpers`
|
||||
- **Node Name**: `SchedulerSelectHelper`
|
||||
- **Function**: `select_scheduler`
|
||||
|
||||
## Inputs
|
||||
|
||||
### Required
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `sampler_name` | STRING | - | Current sampler being used |
|
||||
| `model_type` | DROPDOWN | auto | [auto, sdxl, sd15, flux] |
|
||||
| `schedule_type` | DROPDOWN | smooth | [smooth, sharp, linear, custom] |
|
||||
|
||||
### Optional
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `override` | DROPDOWN | none | Force specific scheduler |
|
||||
| `beta_schedule` | STRING | - | Custom beta schedule values |
|
||||
| `visualize` | BOOLEAN | False | Show schedule curve |
|
||||
|
||||
## Outputs
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `scheduler` | STRING | Selected scheduler name |
|
||||
| `schedule_curve` | IMAGE | Visualization of noise schedule |
|
||||
| `beta_values` | FLOAT_ARRAY | Beta schedule values |
|
||||
|
||||
## Scheduler Types Explained
|
||||
|
||||
### Normal
|
||||
- **Curve**: Linear noise reduction
|
||||
- **Best For**: General purpose
|
||||
- **Samplers**: euler, dpm_fast
|
||||
|
||||
### Karras
|
||||
- **Curve**: Improved noise schedule
|
||||
- **Best For**: High quality
|
||||
- **Samplers**: dpmpp_2m, dpmpp_2m_sde
|
||||
|
||||
### Exponential
|
||||
- **Curve**: Exponential decay
|
||||
- **Best For**: Fine details
|
||||
- **Samplers**: dpmpp_3m_sde
|
||||
|
||||
### Simple
|
||||
- **Curve**: Basic linear
|
||||
- **Best For**: Fast generation
|
||||
- **Samplers**: euler, lcm
|
||||
|
||||
### SGM Uniform
|
||||
- **Curve**: Uniform distribution
|
||||
- **Best For**: FLUX models
|
||||
- **Samplers**: euler, dpmpp_2m
|
||||
|
||||
## Schedule Types
|
||||
|
||||
### Smooth (Default)
|
||||
```python
|
||||
# Gradual noise reduction
|
||||
# Good for most content
|
||||
→ karras or exponential
|
||||
```
|
||||
|
||||
### Sharp
|
||||
```python
|
||||
# Aggressive early reduction
|
||||
# Good for high contrast
|
||||
→ normal or simple
|
||||
```
|
||||
|
||||
### Linear
|
||||
```python
|
||||
# Constant reduction rate
|
||||
# Predictable results
|
||||
→ normal
|
||||
```
|
||||
|
||||
### Custom
|
||||
```python
|
||||
# User-defined curve
|
||||
# Advanced control
|
||||
→ based on beta_schedule
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Automatic Selection
|
||||
```
|
||||
KSampler Settings → SchedulerSelectHelper → KSampler
|
||||
sampler_name: "dpmpp_2m_sde"
|
||||
model_type: auto
|
||||
→ scheduler: "karras"
|
||||
```
|
||||
|
||||
### Visual Comparison
|
||||
```
|
||||
SchedulerSelectHelper → Display
|
||||
visualize: True
|
||||
→ Shows noise schedule curve
|
||||
```
|
||||
|
||||
### Batch Testing
|
||||
```
|
||||
For each scheduler:
|
||||
SchedulerSelectHelper → KSampler → Save
|
||||
→ Compare results
|
||||
```
|
||||
|
||||
## Sampler-Scheduler Compatibility
|
||||
|
||||
### Optimal Pairings
|
||||
| Sampler | Best Scheduler | Good Alternatives |
|
||||
|---------|---------------|-------------------|
|
||||
| euler | normal | karras |
|
||||
| euler_a | karras | normal |
|
||||
| heun | normal | - |
|
||||
| dpm_fast | normal | simple |
|
||||
| dpm_adaptive | normal | - |
|
||||
| dpmpp_2m | karras | exponential |
|
||||
| dpmpp_2m_sde | karras | exponential |
|
||||
| dpmpp_3m_sde | exponential | karras |
|
||||
| dpmpp_2s_a | karras | normal |
|
||||
| lcm | simple | normal |
|
||||
|
||||
## Model-Specific Recommendations
|
||||
|
||||
### SDXL
|
||||
```python
|
||||
preferred_schedulers = ["karras", "exponential"]
|
||||
# Better convergence for high-res
|
||||
```
|
||||
|
||||
### SD 1.5
|
||||
```python
|
||||
preferred_schedulers = ["karras", "normal"]
|
||||
# Classic combinations
|
||||
```
|
||||
|
||||
### FLUX
|
||||
```python
|
||||
preferred_schedulers = ["simple", "sgm_uniform"]
|
||||
# Optimized for FLUX architecture
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Selection Strategy
|
||||
1. Let auto-detection handle defaults
|
||||
2. Override for specific artistic goals
|
||||
3. Test multiple schedulers for hero images
|
||||
4. Use visualization to understand curves
|
||||
|
||||
### Performance Tips
|
||||
- Simple/normal for quick previews
|
||||
- Karras/exponential for quality
|
||||
- SGM uniform specifically for FLUX
|
||||
- Match scheduler to sampler type
|
||||
|
||||
### Testing Workflow
|
||||
```python
|
||||
schedulers = ["normal", "karras", "exponential"]
|
||||
for scheduler in schedulers:
|
||||
generate_image(scheduler)
|
||||
save_with_metadata(scheduler)
|
||||
compare_results()
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Beta Schedule Customization
|
||||
```python
|
||||
# Custom exponential curve
|
||||
beta_schedule = "0.00085, 0.0012, 0.0018, ..."
|
||||
|
||||
# Sharp early reduction
|
||||
beta_schedule = "0.001, 0.002, 0.004, 0.006, ..."
|
||||
```
|
||||
|
||||
### Schedule Visualization
|
||||
- Plots noise reduction curve
|
||||
- Shows sigma values
|
||||
- Compares with standard schedules
|
||||
- Exports schedule data
|
||||
|
||||
### Adaptive Selection
|
||||
- Learns from user preferences
|
||||
- Adapts to hardware capabilities
|
||||
- Optimizes for generation speed
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### Complete Pipeline
|
||||
```
|
||||
Sampler Combo → SchedulerSelectHelper → KSampler
|
||||
↓ ↓
|
||||
sampler_name → Optimal scheduler selection
|
||||
```
|
||||
|
||||
### A/B Testing
|
||||
```
|
||||
Same prompt → Different schedulers → Grid comparison
|
||||
normal vs karras vs exponential
|
||||
```
|
||||
|
||||
### Noise Schedule Analysis
|
||||
```
|
||||
SchedulerSelectHelper → Plot Parameters
|
||||
visualize: True
|
||||
→ Analyze noise curves
|
||||
```
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
### Quality Optimization
|
||||
```python
|
||||
# For maximum quality
|
||||
if sampler in ["dpmpp_3m_sde"]:
|
||||
use scheduler="exponential"
|
||||
elif sampler in ["dpmpp_2m_sde"]:
|
||||
use scheduler="karras"
|
||||
```
|
||||
|
||||
### Speed Optimization
|
||||
```python
|
||||
# For fast generation
|
||||
use scheduler="simple" or "normal"
|
||||
reduce step count by 20%
|
||||
```
|
||||
|
||||
### Artistic Effects
|
||||
- **Sharp details**: normal scheduler
|
||||
- **Smooth gradients**: karras scheduler
|
||||
- **Fine textures**: exponential scheduler
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Artifacts or Noise
|
||||
- Try different scheduler
|
||||
- Check sampler compatibility
|
||||
- Adjust step count
|
||||
|
||||
### Slow Convergence
|
||||
- Switch from simple to karras
|
||||
- Increase step count
|
||||
- Check model compatibility
|
||||
|
||||
### Inconsistent Results
|
||||
- Use same scheduler for batch
|
||||
- Avoid random scheduler selection
|
||||
- Fix seed for testing
|
||||
|
||||
## Visual Guide
|
||||
|
||||
### Noise Schedule Curves
|
||||
```
|
||||
Normal: ████████████████
|
||||
Linear reduction
|
||||
|
||||
Karras: ███████████▓▓▓░░
|
||||
Smooth curve
|
||||
|
||||
Exponential: ██████▓▓▓░░░░░
|
||||
Fast early reduction
|
||||
```
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Scheduler Comparison
|
||||
Test same seed with different schedulers to find optimal setting.
|
||||
|
||||
### Model Migration
|
||||
When switching models, automatically adjust scheduler for best results.
|
||||
|
||||
### Quality Ladder
|
||||
Progress through schedulers from fast to quality for different use cases.
|
||||
|
||||
## Version History
|
||||
- **1.0.0**: Initial adaptation from comfyui-essentials-nodes
|
||||
- **1.0.1**: Added visualization features
|
||||
- **1.0.2**: Enhanced model detection
|
||||
- **1.0.3**: Improved compatibility matrix
|
||||
|
||||
## Credits
|
||||
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
|
||||
@@ -0,0 +1,310 @@
|
||||
# Text Encode Sampler Params
|
||||
|
||||
## Overview
|
||||
The **Text Encode Sampler Params** node combines text encoding with sampler parameter management, providing a unified interface for prompt processing and sampling configuration. Adapted from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) (now in maintenance mode), this tool streamlines the text-to-image pipeline setup.
|
||||
|
||||
## Attribution
|
||||
This node is based on work from [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials) by cubiq. The original project is in maintenance-only mode, and we've adopted and enhanced these tools to ensure continued support and compatibility with modern ComfyUI workflows.
|
||||
|
||||
## Features
|
||||
- **Unified Interface**: Combine text encoding and sampler params in one node
|
||||
- **Dynamic Prompt Processing**: Support for wildcards and syntax
|
||||
- **Parameter Templates**: Pre-configured settings for common scenarios
|
||||
- **Batch Text Processing**: Handle multiple prompts efficiently
|
||||
- **Model-Aware Encoding**: Optimize for different text encoders
|
||||
|
||||
## Node Properties
|
||||
- **Category**: `ComfyAssets/🧰 xyz-helpers`
|
||||
- **Node Name**: `TextEncodeSamplerParams`
|
||||
- **Function**: `encode_and_params`
|
||||
|
||||
## Inputs
|
||||
|
||||
### Required
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `text` | STRING | - | Prompt text to encode |
|
||||
| `clip` | CLIP | - | CLIP model for encoding |
|
||||
| `sampler_name` | DROPDOWN | dpmpp_2m | Sampling algorithm |
|
||||
| `scheduler` | DROPDOWN | karras | Noise scheduler |
|
||||
| `steps` | INT | 20 | Sampling steps |
|
||||
| `cfg` | FLOAT | 7.0 | CFG scale |
|
||||
|
||||
### Optional
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `negative_text` | STRING | "" | Negative prompt |
|
||||
| `seed` | INT | -1 | Random seed (-1 for random) |
|
||||
| `denoise` | FLOAT | 1.0 | Denoising strength |
|
||||
| `template` | DROPDOWN | none | Parameter template |
|
||||
|
||||
## Outputs
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `positive` | CONDITIONING | Encoded positive prompt |
|
||||
| `negative` | CONDITIONING | Encoded negative prompt |
|
||||
| `sampler_params` | DICT | Complete sampler parameters |
|
||||
|
||||
## Templates
|
||||
|
||||
### Portrait Photography
|
||||
```python
|
||||
template: "portrait"
|
||||
→ steps: 25
|
||||
→ cfg: 7.5
|
||||
→ sampler: dpmpp_2m_sde
|
||||
→ scheduler: karras
|
||||
```
|
||||
|
||||
### Landscape Art
|
||||
```python
|
||||
template: "landscape"
|
||||
→ steps: 30
|
||||
→ cfg: 8.0
|
||||
→ sampler: dpmpp_3m_sde
|
||||
→ scheduler: exponential
|
||||
```
|
||||
|
||||
### Quick Preview
|
||||
```python
|
||||
template: "preview"
|
||||
→ steps: 12
|
||||
→ cfg: 6.0
|
||||
→ sampler: euler
|
||||
→ scheduler: normal
|
||||
```
|
||||
|
||||
### High Detail
|
||||
```python
|
||||
template: "detailed"
|
||||
→ steps: 40
|
||||
→ cfg: 7.0
|
||||
→ sampler: dpm_adaptive
|
||||
→ scheduler: karras
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Text-to-Image
|
||||
```
|
||||
TextEncodeSamplerParams → KSampler → VAE Decode
|
||||
text: "beautiful landscape"
|
||||
negative_text: "ugly, blurry"
|
||||
steps: 20
|
||||
```
|
||||
|
||||
### Template-Based Generation
|
||||
```
|
||||
TextEncodeSamplerParams → KSampler
|
||||
text: "portrait of a person"
|
||||
template: "portrait"
|
||||
→ Optimized portrait settings
|
||||
```
|
||||
|
||||
### Batch Processing
|
||||
```
|
||||
Multiple Prompts → TextEncodeSamplerParams → Batch Generate
|
||||
→ Encode all prompts with same settings
|
||||
```
|
||||
|
||||
## Prompt Syntax Support
|
||||
|
||||
### Wildcards
|
||||
```
|
||||
{red|blue|green} car
|
||||
→ Randomly selects color
|
||||
```
|
||||
|
||||
### Emphasis
|
||||
```
|
||||
(important:1.2) detail
|
||||
→ Increases weight to 1.2
|
||||
```
|
||||
|
||||
### Alternation
|
||||
```
|
||||
[cat|dog] in garden
|
||||
→ Alternates between options
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Text Encoding
|
||||
1. Keep prompts concise and descriptive
|
||||
2. Use emphasis for important elements
|
||||
3. Structure prompts logically
|
||||
4. Test negative prompts impact
|
||||
|
||||
### Parameter Selection
|
||||
```python
|
||||
# Quality over speed
|
||||
steps: 30-40
|
||||
cfg: 7-8
|
||||
sampler: dpmpp_3m_sde
|
||||
|
||||
# Speed over quality
|
||||
steps: 10-15
|
||||
cfg: 5-6
|
||||
sampler: euler
|
||||
```
|
||||
|
||||
### Negative Prompts
|
||||
```python
|
||||
# Common negatives
|
||||
"ugly, tiling, poorly drawn, out of frame"
|
||||
|
||||
# Style-specific
|
||||
"cartoon, anime" (for realism)
|
||||
"realistic, photo" (for artwork)
|
||||
```
|
||||
|
||||
## Integration with Other Nodes
|
||||
|
||||
### Complete Pipeline
|
||||
```
|
||||
TextEncodeSamplerParams → KSampler → VAE Decode
|
||||
↓ ↑
|
||||
All parameters From Model Loader
|
||||
```
|
||||
|
||||
### With LoRA
|
||||
```
|
||||
LoRAFolderBatch → TextEncodeSamplerParams → Generate
|
||||
→ Apply LoRA to encoded text
|
||||
```
|
||||
|
||||
### Multi-Pass Processing
|
||||
```
|
||||
TextEncodeSamplerParams → First Pass (low res)
|
||||
↘ Second Pass (high res)
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Dynamic Templates
|
||||
```python
|
||||
# Load template based on prompt content
|
||||
if "portrait" in text:
|
||||
use_template("portrait")
|
||||
elif "landscape" in text:
|
||||
use_template("landscape")
|
||||
```
|
||||
|
||||
### Prompt Weighting
|
||||
```python
|
||||
# Automatic weight calculation
|
||||
analyze_prompt_importance()
|
||||
apply_semantic_weights()
|
||||
```
|
||||
|
||||
### CLIP Skip Support
|
||||
- Adjust CLIP layers used
|
||||
- Model-specific optimization
|
||||
- Quality vs style balance
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
### Prompt Optimization
|
||||
1. Front-load important elements
|
||||
2. Use commas for separation
|
||||
3. Avoid contradictions
|
||||
4. Test with different CFG values
|
||||
|
||||
### Performance Tuning
|
||||
```python
|
||||
# Memory efficient
|
||||
encode_in_batches = True
|
||||
clear_cache_between = True
|
||||
|
||||
# Speed priority
|
||||
use_half_precision = True
|
||||
minimize_conditioning = True
|
||||
```
|
||||
|
||||
### Quality Enhancement
|
||||
- Higher CFG for prompt adherence
|
||||
- Lower CFG for creativity
|
||||
- Balance with step count
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Style Transfer
|
||||
```
|
||||
Reference Image → Extract Style
|
||||
↓
|
||||
TextEncodeSamplerParams → Apply Style
|
||||
text: "in the style of [extracted]"
|
||||
```
|
||||
|
||||
### Prompt Evolution
|
||||
```
|
||||
Base Prompt → Variations → TextEncodeSamplerParams
|
||||
→ Test different phrasings
|
||||
```
|
||||
|
||||
### A/B Testing
|
||||
```
|
||||
Same prompt → Different parameters → Compare
|
||||
template A vs template B
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Poor Text Adherence
|
||||
- Increase CFG scale
|
||||
- Simplify prompt
|
||||
- Check CLIP model compatibility
|
||||
|
||||
### Over-saturation
|
||||
- Reduce CFG scale
|
||||
- Adjust negative prompt
|
||||
- Check sampler settings
|
||||
|
||||
### Encoding Errors
|
||||
- Verify CLIP model loaded
|
||||
- Check text formatting
|
||||
- Remove special characters
|
||||
|
||||
## Parameter Guidelines
|
||||
|
||||
### CFG Scale Effects
|
||||
```
|
||||
Low (3-5): Creative, loose interpretation
|
||||
Medium (6-8): Balanced adherence
|
||||
High (9-12): Strict prompt following
|
||||
Very High (13+): Potential artifacts
|
||||
```
|
||||
|
||||
### Step Count Impact
|
||||
```
|
||||
Low (10-15): Fast, rough
|
||||
Medium (20-30): Good balance
|
||||
High (40-50): Maximum quality
|
||||
Very High (50+): Diminishing returns
|
||||
```
|
||||
|
||||
## Model-Specific Settings
|
||||
|
||||
### SDXL
|
||||
- CFG: 6-8
|
||||
- CLIP Skip: 1-2
|
||||
- Emphasis: Moderate
|
||||
|
||||
### SD 1.5
|
||||
- CFG: 7-9
|
||||
- CLIP Skip: 1-2
|
||||
- Emphasis: Standard
|
||||
|
||||
### FLUX
|
||||
- CFG: 3-5
|
||||
- CLIP Skip: 0
|
||||
- Emphasis: Subtle
|
||||
|
||||
## Version History
|
||||
- **1.0.0**: Initial adaptation from comfyui-essentials-nodes
|
||||
- **1.0.1**: Added template system
|
||||
- **1.0.2**: Enhanced prompt syntax support
|
||||
- **1.0.3**: Improved batch processing
|
||||
|
||||
## Credits
|
||||
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
|
||||
@@ -0,0 +1,14 @@
|
||||
A beautiful sunset over the ocean, golden hour lighting, professional photography, vibrant colors, high detail
|
||||
Negative: blurry, dark, low quality, distorted, oversaturated
|
||||
---
|
||||
Majestic mountain landscape with snow-capped peaks, dramatic clouds, alpine scenery, crystal clear air, epic composition
|
||||
Negative: foggy, flat lighting, boring composition, low contrast
|
||||
---
|
||||
Futuristic cityscape at night, neon lights, cyberpunk aesthetic, rain-slicked streets, atmospheric, blade runner style
|
||||
Negative: daylight, rural, old fashioned, low tech, empty streets
|
||||
---
|
||||
Enchanted forest with magical glowing mushrooms, fairy lights, mystical atmosphere, ancient trees, fantasy art style
|
||||
Negative: desert, urban, modern, realistic, mundane
|
||||
---
|
||||
Space station orbiting Earth, detailed mechanical structures, astronauts performing spacewalk, realistic sci-fi, NASA photography
|
||||
Negative: fantasy, medieval, underwater, cartoon style
|
||||
@@ -376,4 +376,4 @@
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,4 +141,4 @@
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,4 +298,4 @@
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,4 +204,4 @@
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,4 +549,4 @@
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"id": "kiko-film-grain-example",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
50,
|
||||
100
|
||||
],
|
||||
"size": [
|
||||
350,
|
||||
450
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1],
|
||||
"shape": 3,
|
||||
"label": "IMAGE"
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null,
|
||||
"shape": 3,
|
||||
"label": "MASK"
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"example.png"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "KikoFilmGrain",
|
||||
"pos": [
|
||||
450,
|
||||
100
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
202
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [2],
|
||||
"shape": 3,
|
||||
"label": "image",
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "kikotools",
|
||||
"Node name for S&R": "KikoFilmGrain"
|
||||
},
|
||||
"widgets_values": [
|
||||
0.5,
|
||||
0.5,
|
||||
0.7,
|
||||
0.0,
|
||||
0
|
||||
],
|
||||
"color": "#223",
|
||||
"bgcolor": "#335"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
850,
|
||||
100
|
||||
],
|
||||
"size": [
|
||||
350,
|
||||
450
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "Note",
|
||||
"pos": [
|
||||
450,
|
||||
350
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
150
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"properties": {
|
||||
"text": ""
|
||||
},
|
||||
"widgets_values": [
|
||||
"Kiko Film Grain Example\n\nThis workflow demonstrates the film grain effect.\n\nAdjust parameters:\n- Scale: Grain size (0.25-2.0)\n- Strength: Intensity (0.0-10.0)\n- Saturation: Color amount (0.0-2.0)\n- Toe: Shadow lifting (-0.2-0.5)\n- Seed: Random pattern"
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1.0,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -144,4 +144,4 @@
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,4 +352,4 @@
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 4.0 MiB |
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"name": "Sampler and Scheduler Comparison Workflow",
|
||||
"description": "Compare different sampler and scheduler combinations using xyz_helpers",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "1",
|
||||
"type": "SamplerSelectHelper",
|
||||
"title": "Select Optimal Sampler",
|
||||
"category": "ComfyAssets/🧰 xyz-helpers",
|
||||
"inputs": {
|
||||
"model_type": "auto",
|
||||
"quality_preset": "balanced",
|
||||
"sampler_override": "auto",
|
||||
"model_name": "sdxl_model.safetensors"
|
||||
},
|
||||
"outputs": {
|
||||
"sampler_name": "STRING",
|
||||
"scheduler": "STRING",
|
||||
"recommended_steps": "INT",
|
||||
"recommended_cfg": "FLOAT"
|
||||
},
|
||||
"pos": [100, 100]
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"type": "SchedulerSelectHelper",
|
||||
"title": "Optimize Scheduler",
|
||||
"category": "ComfyAssets/🧰 xyz-helpers",
|
||||
"inputs": {
|
||||
"sampler_name": ["1", "sampler_name"],
|
||||
"model_type": "sdxl",
|
||||
"schedule_type": "smooth",
|
||||
"visualize": true
|
||||
},
|
||||
"outputs": {
|
||||
"scheduler": "STRING",
|
||||
"schedule_curve": "IMAGE"
|
||||
},
|
||||
"pos": [400, 100]
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"type": "TextEncodeSamplerParams",
|
||||
"title": "Setup Text and Params",
|
||||
"category": "ComfyAssets/🧰 xyz-helpers",
|
||||
"inputs": {
|
||||
"text": "a majestic mountain landscape at sunset, highly detailed",
|
||||
"negative_text": "low quality, blurry, artifacts",
|
||||
"clip": ["model", "clip"],
|
||||
"sampler_name": ["1", "sampler_name"],
|
||||
"scheduler": ["2", "scheduler"],
|
||||
"steps": ["1", "recommended_steps"],
|
||||
"cfg": ["1", "recommended_cfg"],
|
||||
"template": "landscape"
|
||||
},
|
||||
"outputs": {
|
||||
"positive": "CONDITIONING",
|
||||
"negative": "CONDITIONING",
|
||||
"sampler_params": "DICT"
|
||||
},
|
||||
"pos": [700, 100]
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"type": "EmptyLatentBatch",
|
||||
"title": "Create Test Latents",
|
||||
"category": "ComfyAssets/📦 Latents",
|
||||
"inputs": {
|
||||
"preset": "1216×832 (SDXL Landscape)",
|
||||
"batch_size": 4
|
||||
},
|
||||
"outputs": {
|
||||
"latent": "LATENT"
|
||||
},
|
||||
"pos": [100, 300]
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"type": "KSampler",
|
||||
"title": "Generate with Optimal Settings",
|
||||
"inputs": {
|
||||
"model": ["model", "model"],
|
||||
"positive": ["3", "positive"],
|
||||
"negative": ["3", "negative"],
|
||||
"latent_image": ["4", "latent"],
|
||||
"sampler_name": ["1", "sampler_name"],
|
||||
"scheduler": ["2", "scheduler"],
|
||||
"steps": ["1", "recommended_steps"],
|
||||
"cfg": ["1", "recommended_cfg"],
|
||||
"seed": 42
|
||||
},
|
||||
"outputs": {
|
||||
"latent": "LATENT"
|
||||
},
|
||||
"pos": [1000, 200]
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"type": "PlotParameters",
|
||||
"title": "Visualize Parameters",
|
||||
"category": "ComfyAssets/🧰 xyz-helpers",
|
||||
"inputs": {
|
||||
"sampler_params": ["3", "sampler_params"],
|
||||
"plot_type": "bar",
|
||||
"x_axis": "parameter_name",
|
||||
"y_axis": "value",
|
||||
"title": "Sampler Configuration Analysis",
|
||||
"show_grid": true
|
||||
},
|
||||
"outputs": {
|
||||
"plot_image": "IMAGE"
|
||||
},
|
||||
"pos": [700, 400]
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"type": "DisplayAny",
|
||||
"title": "Show Schedule Curve",
|
||||
"category": "ComfyAssets/🔍 Debug",
|
||||
"inputs": {
|
||||
"input": ["2", "schedule_curve"],
|
||||
"mode": "tensor shape"
|
||||
},
|
||||
"pos": [400, 400]
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
"type": "VAEDecode",
|
||||
"title": "Decode Results",
|
||||
"inputs": {
|
||||
"samples": ["5", "latent"],
|
||||
"vae": ["model", "vae"]
|
||||
},
|
||||
"outputs": {
|
||||
"image": "IMAGE"
|
||||
},
|
||||
"pos": [1300, 200]
|
||||
},
|
||||
{
|
||||
"id": "9",
|
||||
"type": "KikoSaveImage",
|
||||
"title": "Save Comparison",
|
||||
"category": "ComfyAssets/💾 Images",
|
||||
"inputs": {
|
||||
"images": ["8", "image"],
|
||||
"filename_prefix": "sampler_comparison",
|
||||
"format": "WEBP",
|
||||
"quality": 90,
|
||||
"popup": true
|
||||
},
|
||||
"pos": [1600, 200]
|
||||
}
|
||||
],
|
||||
"workflow_notes": {
|
||||
"purpose": "Compare and optimize sampler/scheduler combinations for best quality",
|
||||
"features": [
|
||||
"Automatic sampler selection based on model",
|
||||
"Scheduler optimization with visualization",
|
||||
"Parameter analysis and plotting",
|
||||
"Batch generation for comparison"
|
||||
],
|
||||
"tips": [
|
||||
"Try different quality_preset values",
|
||||
"Use visualize=true to see noise schedules",
|
||||
"Compare results across multiple seeds"
|
||||
],
|
||||
"attribution": "xyz_helpers nodes adapted from comfyui-essentials-nodes"
|
||||
}
|
||||
}
|
||||
+73
-8
@@ -3,19 +3,48 @@ KikoTools package initialization and node registry
|
||||
Handles automatic discovery and registration of all ComfyAssets tools
|
||||
"""
|
||||
|
||||
from .tools.resolution_calculator import ResolutionCalculatorNode
|
||||
from .tools.width_height_selector import WidthHeightSelectorNode
|
||||
from .tools.seed_history import SeedHistoryNode
|
||||
from .tools.sampler_combo import SamplerComboNode, SamplerComboCompactNode
|
||||
from .tools.empty_latent_batch import EmptyLatentBatchNode
|
||||
from .tools.kiko_save_image import KikoSaveImageNode
|
||||
from .tools.image_to_multiple_of import ImageToMultipleOfNode
|
||||
from .tools.gemini_prompt import GeminiPromptNode
|
||||
from .tools.batch_list_converter import (
|
||||
ImageBatchToImageListNode,
|
||||
ImageListToImageBatchNode,
|
||||
LatentBatchToLatentListNode,
|
||||
LatentListToLatentBatchNode,
|
||||
)
|
||||
from .tools.batch_prompts import BatchPromptsNode
|
||||
from .tools.display_any import DisplayAnyNode
|
||||
from .tools.display_text import DisplayTextNode
|
||||
from .tools.embedding_autocomplete import KikoEmbeddingAutocomplete
|
||||
from .tools.empty_latent_batch import EmptyLatentBatchNode
|
||||
from .tools.gemini_prompt import GeminiPromptNode
|
||||
from .tools.image_scale_down_by import ImageScaleDownByNode
|
||||
from .tools.image_to_multiple_of import ImageToMultipleOfNode
|
||||
from .tools.kiko_film_grain import KikoFilmGrainNode
|
||||
from .tools.kiko_purge_vram import KikoPurgeVRAM
|
||||
from .tools.kiko_workflow_timer import KikoWorkflowTimerNode
|
||||
from .tools.kiko_save_image import KikoSaveImageNode
|
||||
from .tools.local_image_loader import LocalImageLoaderNode
|
||||
from .tools.model_downloader import ModelDownloaderNode
|
||||
from .tools.resolution_calculator import ResolutionCalculatorNode
|
||||
from .tools.sampler_combo import SamplerComboCompactNode, SamplerComboNode
|
||||
from .tools.seed_history import SeedHistoryNode
|
||||
from .tools.text_input import TextInputNode
|
||||
from .tools.width_height_selector import WidthHeightSelectorNode
|
||||
from .tools.width_height_to_vec2 import WidthHeightToVec2Node
|
||||
from .tools.xyz_helpers import (
|
||||
FluxSamplerParamsNode,
|
||||
LoRAFolderBatchNode,
|
||||
PlotParametersNode,
|
||||
SamplerSelectHelperNode,
|
||||
SchedulerSelectHelperNode,
|
||||
TextEncodeSamplerParamsNode,
|
||||
)
|
||||
|
||||
# ComfyUI node registration mappings
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ImageBatchToImageList": ImageBatchToImageListNode,
|
||||
"ImageListToImageBatch": ImageListToImageBatchNode,
|
||||
"LatentBatchToLatentList": LatentBatchToLatentListNode,
|
||||
"LatentListToLatentBatch": LatentListToLatentBatchNode,
|
||||
"BatchPrompts": BatchPromptsNode,
|
||||
"ResolutionCalculator": ResolutionCalculatorNode,
|
||||
"WidthHeightSelector": WidthHeightSelectorNode,
|
||||
"SeedHistory": SeedHistoryNode,
|
||||
@@ -24,12 +53,33 @@ NODE_CLASS_MAPPINGS = {
|
||||
"EmptyLatentBatch": EmptyLatentBatchNode,
|
||||
"KikoSaveImage": KikoSaveImageNode,
|
||||
"ImageToMultipleOf": ImageToMultipleOfNode,
|
||||
"ImageScaleDownBy": ImageScaleDownByNode,
|
||||
"GeminiPrompt": GeminiPromptNode,
|
||||
"DisplayAny": DisplayAnyNode,
|
||||
"DisplayText": DisplayTextNode,
|
||||
"TextInput": TextInputNode,
|
||||
"KikoFilmGrain": KikoFilmGrainNode,
|
||||
"KikoPurgeVRAM": KikoPurgeVRAM,
|
||||
"KikoLocalImageLoader": LocalImageLoaderNode,
|
||||
"KikoModelDownloader": ModelDownloaderNode,
|
||||
"SamplerSelectHelper": SamplerSelectHelperNode,
|
||||
"SchedulerSelectHelper": SchedulerSelectHelperNode,
|
||||
"TextEncodeSamplerParams": TextEncodeSamplerParamsNode,
|
||||
"FluxSamplerParams": FluxSamplerParamsNode,
|
||||
"PlotParameters+": PlotParametersNode,
|
||||
"LoRAFolderBatch": LoRAFolderBatchNode,
|
||||
"WidthHeightToVec2": WidthHeightToVec2Node,
|
||||
"KikoWorkflowTimer": KikoWorkflowTimerNode,
|
||||
# Note: KikoEmbeddingAutocomplete is not registered as a node
|
||||
# It's a settings-only feature accessed through ComfyUI settings menu
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ImageBatchToImageList": "Image Batch to Image List",
|
||||
"ImageListToImageBatch": "Image List to Image Batch",
|
||||
"LatentBatchToLatentList": "Latent Batch to Latent List",
|
||||
"LatentListToLatentBatch": "Latent List to Latent Batch",
|
||||
"BatchPrompts": "Batch Prompts",
|
||||
"ResolutionCalculator": "Resolution Calculator",
|
||||
"WidthHeightSelector": "Width Height Selector",
|
||||
"SeedHistory": "Seed History",
|
||||
@@ -38,9 +88,24 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"EmptyLatentBatch": "Empty Latent Batch",
|
||||
"KikoSaveImage": "Kiko Save Image",
|
||||
"ImageToMultipleOf": "Image to Multiple of",
|
||||
"ImageScaleDownBy": "Image Scale Down By",
|
||||
"GeminiPrompt": "Gemini Prompt Engineer",
|
||||
"DisplayAny": "Display Any",
|
||||
"DisplayText": "Display Text",
|
||||
"TextInput": "Text Input",
|
||||
"KikoFilmGrain": "Film Grain",
|
||||
"KikoPurgeVRAM": "Kiko Purge VRAM",
|
||||
"KikoLocalImageLoader": "Local Image Loader",
|
||||
"KikoModelDownloader": "Model Downloader 🌐",
|
||||
"SamplerSelectHelper": "Sampler Select Helper",
|
||||
"SchedulerSelectHelper": "Scheduler Select Helper",
|
||||
"TextEncodeSamplerParams": "Text Encode for Sampler Params",
|
||||
"FluxSamplerParams": "Flux Sampler Parameters",
|
||||
"PlotParameters+": "Plot Parameters",
|
||||
"LoRAFolderBatch": "LoRA Folder Batch",
|
||||
"WidthHeightToVec2": "Width Height to VEC2",
|
||||
"KikoWorkflowTimer": "Workflow Timer",
|
||||
# KikoEmbeddingAutocomplete removed - settings only, not a node
|
||||
}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""AnyType for wildcard input matching in ComfyUI nodes."""
|
||||
|
||||
|
||||
class AnyType(str):
|
||||
"""A special type that matches any input type in ComfyUI."""
|
||||
|
||||
def __ne__(self, other):
|
||||
return False
|
||||
@@ -20,7 +20,7 @@ class ComfyAssetsBaseNode:
|
||||
- Consistent return type handling
|
||||
"""
|
||||
|
||||
CATEGORY = "ComfyAssets"
|
||||
CATEGORY = "🫶 ComfyAssets"
|
||||
|
||||
def validate_inputs(self, **kwargs) -> None:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Tool registry for KikoTools.
|
||||
|
||||
This module provides the central registration system for all KikoTools nodes.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""Central registry for all KikoTools."""
|
||||
|
||||
def __init__(self):
|
||||
self.tools: Dict[str, Any] = {}
|
||||
self.node_classes: Dict[str, Any] = {}
|
||||
|
||||
def register_tool(self, tool_name: str, node_class: Any) -> None:
|
||||
"""Register a tool and its node class.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool
|
||||
node_class: The ComfyUI node class
|
||||
"""
|
||||
self.tools[tool_name] = node_class
|
||||
|
||||
# Also register by class name for ComfyUI
|
||||
class_name = node_class.__name__
|
||||
self.node_classes[class_name] = node_class
|
||||
|
||||
def discover_tools(self) -> None:
|
||||
"""Automatically discover and load all tools in the tools directory."""
|
||||
tools_dir = Path(__file__).parent.parent / "tools"
|
||||
|
||||
if not tools_dir.exists():
|
||||
return
|
||||
|
||||
for tool_dir in tools_dir.iterdir():
|
||||
if tool_dir.is_dir() and not tool_dir.name.startswith("_"):
|
||||
self._load_tool(tool_dir.name)
|
||||
|
||||
def _load_tool(self, tool_name: str) -> None:
|
||||
"""Load a single tool module.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool directory
|
||||
"""
|
||||
try:
|
||||
# Try to import the tool's node module
|
||||
module = importlib.import_module(f"kikotools.tools.{tool_name}.node")
|
||||
|
||||
# Look for node classes (classes with ComfyUI node attributes)
|
||||
for attr_name in dir(module):
|
||||
attr = getattr(module, attr_name)
|
||||
if (
|
||||
isinstance(attr, type)
|
||||
and hasattr(attr, "INPUT_TYPES")
|
||||
and hasattr(attr, "FUNCTION")
|
||||
):
|
||||
self.register_tool(tool_name, attr)
|
||||
|
||||
# If the tool has settings, register them
|
||||
if hasattr(attr, "SETTINGS"):
|
||||
from .settings import settings_registry
|
||||
|
||||
settings_registry.register_tool_settings(
|
||||
tool_name,
|
||||
getattr(
|
||||
attr,
|
||||
"DISPLAY_NAME",
|
||||
tool_name.replace("_", " ").title(),
|
||||
),
|
||||
attr.SETTINGS,
|
||||
)
|
||||
|
||||
except ImportError:
|
||||
# Tool might not have a node.py file yet
|
||||
pass
|
||||
|
||||
def get_node_class_mappings(self) -> Dict[str, Any]:
|
||||
"""Get node class mappings for ComfyUI registration."""
|
||||
return self.node_classes.copy()
|
||||
|
||||
def get_node_display_name_mappings(self) -> Dict[str, str]:
|
||||
"""Get display name mappings for ComfyUI."""
|
||||
mappings = {}
|
||||
for class_name, node_class in self.node_classes.items():
|
||||
if hasattr(node_class, "DISPLAY_NAME"):
|
||||
mappings[class_name] = node_class.DISPLAY_NAME
|
||||
else:
|
||||
# Generate a display name from class name
|
||||
mappings[class_name] = class_name.replace("Kiko", "").replace(
|
||||
"Node", ""
|
||||
)
|
||||
return mappings
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Settings registry for KikoTools.
|
||||
|
||||
This module provides a centralized settings management system for all KikoTools.
|
||||
Tools can register their settings, which are then exposed in ComfyUI's settings UI.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingDefinition:
|
||||
"""Definition of a single setting."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
type: str # "boolean", "combo", "number", "string", "custom"
|
||||
default: Any
|
||||
description: Optional[str] = None
|
||||
options: Optional[Union[List[Any], Dict[str, Any]]] = None
|
||||
min_value: Optional[float] = None
|
||||
max_value: Optional[float] = None
|
||||
step: Optional[float] = None
|
||||
on_change: Optional[str] = None # JavaScript callback as string
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolSettings:
|
||||
"""Settings collection for a single tool."""
|
||||
|
||||
tool_name: str
|
||||
display_name: str
|
||||
settings: List[SettingDefinition] = field(default_factory=list)
|
||||
|
||||
|
||||
class SettingsRegistry:
|
||||
"""Central registry for all KikoTools settings."""
|
||||
|
||||
def __init__(self):
|
||||
self.tools: Dict[str, ToolSettings] = {}
|
||||
self.settings_by_id: Dict[str, SettingDefinition] = {}
|
||||
|
||||
def register_tool_settings(
|
||||
self, tool_name: str, display_name: str, settings: Dict[str, Dict[str, Any]]
|
||||
) -> None:
|
||||
"""Register settings for a tool.
|
||||
|
||||
Args:
|
||||
tool_name: Internal tool identifier (e.g., "embedding_autocomplete")
|
||||
display_name: Display name for the tool (e.g., "Embedding Autocomplete")
|
||||
settings: Dictionary of setting configurations
|
||||
{
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Enable embedding autocomplete"
|
||||
},
|
||||
"max_suggestions": {
|
||||
"type": "combo",
|
||||
"default": 20,
|
||||
"options": [10, 20, 50],
|
||||
"description": "Maximum number of suggestions"
|
||||
}
|
||||
}
|
||||
"""
|
||||
tool_settings = ToolSettings(tool_name, display_name)
|
||||
|
||||
for setting_key, config in settings.items():
|
||||
# Generate fully qualified setting ID
|
||||
setting_id = f"kikotools.{tool_name}.{setting_key}"
|
||||
|
||||
# Create display name with branding
|
||||
setting_name = f"🫶 {display_name}: {setting_key.replace('_', ' ').title()}"
|
||||
|
||||
setting_def = SettingDefinition(
|
||||
id=setting_id,
|
||||
name=setting_name,
|
||||
type=config.get("type", "string"),
|
||||
default=config.get("default"),
|
||||
description=config.get("description"),
|
||||
options=config.get("options"),
|
||||
min_value=config.get("min"),
|
||||
max_value=config.get("max"),
|
||||
step=config.get("step"),
|
||||
on_change=config.get("on_change"),
|
||||
)
|
||||
|
||||
tool_settings.settings.append(setting_def)
|
||||
self.settings_by_id[setting_id] = setting_def
|
||||
|
||||
self.tools[tool_name] = tool_settings
|
||||
|
||||
def get_setting(self, setting_id: str) -> Optional[SettingDefinition]:
|
||||
"""Get a setting definition by ID."""
|
||||
return self.settings_by_id.get(setting_id)
|
||||
|
||||
def get_tool_settings(self, tool_name: str) -> Optional[ToolSettings]:
|
||||
"""Get all settings for a tool."""
|
||||
return self.tools.get(tool_name)
|
||||
|
||||
def generate_frontend_registration(self) -> str:
|
||||
"""Generate JavaScript code for frontend settings registration."""
|
||||
js_lines = [
|
||||
"// Auto-generated KikoTools settings registration",
|
||||
"// This file is automatically generated by the settings registry",
|
||||
"",
|
||||
"import { app } from '../../scripts/app.js';",
|
||||
"",
|
||||
"app.registerExtension({",
|
||||
" name: 'kikotools.settings',",
|
||||
" async init() {",
|
||||
" // Register all KikoTools settings",
|
||||
]
|
||||
|
||||
for tool_name, tool_settings in self.tools.items():
|
||||
js_lines.append(f" // {tool_settings.display_name} settings")
|
||||
|
||||
for setting in tool_settings.settings:
|
||||
js_lines.append(" app.ui.settings.addSetting({")
|
||||
js_lines.append(f' id: "{setting.id}",')
|
||||
js_lines.append(f' name: "{setting.name}",')
|
||||
js_lines.append(
|
||||
f" defaultValue: {self._js_value(setting.default)},"
|
||||
)
|
||||
js_lines.append(f' type: "{setting.type}",')
|
||||
|
||||
if setting.description:
|
||||
js_lines.append(f' tooltip: "{setting.description}",')
|
||||
|
||||
if setting.type == "combo" and setting.options:
|
||||
js_lines.append(" options: (value) => {")
|
||||
js_lines.append(
|
||||
f" const options = {json.dumps(setting.options)};"
|
||||
)
|
||||
js_lines.append(" return options.map(opt => ({")
|
||||
js_lines.append(" value: opt,")
|
||||
js_lines.append(" text: String(opt),")
|
||||
js_lines.append(" selected: opt === value")
|
||||
js_lines.append(" }));")
|
||||
js_lines.append(" }},")
|
||||
|
||||
if setting.type == "number":
|
||||
if setting.min_value is not None:
|
||||
js_lines.append(f" min: {setting.min_value},")
|
||||
if setting.max_value is not None:
|
||||
js_lines.append(f" max: {setting.max_value},")
|
||||
if setting.step is not None:
|
||||
js_lines.append(f" step: {setting.step},")
|
||||
|
||||
if setting.on_change:
|
||||
js_lines.append(" onChange(value) {")
|
||||
js_lines.append(f" {setting.on_change}")
|
||||
js_lines.append(" }")
|
||||
|
||||
js_lines.append(" }});")
|
||||
js_lines.append("")
|
||||
|
||||
js_lines.extend([" }", "});", ""])
|
||||
|
||||
return "\n".join(js_lines)
|
||||
|
||||
def _js_value(self, value: Any) -> str:
|
||||
"""Convert Python value to JavaScript literal."""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
elif isinstance(value, str):
|
||||
return f'"{value}"'
|
||||
elif value is None:
|
||||
return "null"
|
||||
else:
|
||||
return str(value)
|
||||
|
||||
def save_frontend_settings(
|
||||
self, output_path: str = "web/js/kikoSettings.js"
|
||||
) -> None:
|
||||
"""Save the generated frontend settings to a file."""
|
||||
js_content = self.generate_frontend_registration()
|
||||
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
with open(output_path, "w") as f:
|
||||
f.write(js_content)
|
||||
|
||||
def get_all_settings(self) -> Dict[str, Any]:
|
||||
"""Get all registered settings as a dictionary."""
|
||||
result = {}
|
||||
for tool_name, tool_settings in self.tools.items():
|
||||
result[tool_name] = {
|
||||
"display_name": tool_settings.display_name,
|
||||
"settings": {
|
||||
setting.id.split(".")[-1]: {
|
||||
"type": setting.type,
|
||||
"default": setting.default,
|
||||
"description": setting.description,
|
||||
"options": setting.options,
|
||||
}
|
||||
for setting in tool_settings.settings
|
||||
},
|
||||
}
|
||||
return result
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Batch/List conversion tool for ComfyUI."""
|
||||
|
||||
from .node import (
|
||||
ImageBatchToImageListNode,
|
||||
ImageListToImageBatchNode,
|
||||
LatentBatchToLatentListNode,
|
||||
LatentListToLatentBatchNode,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ImageBatchToImageListNode",
|
||||
"ImageListToImageBatchNode",
|
||||
"LatentBatchToLatentListNode",
|
||||
"LatentListToLatentBatchNode",
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Pure tensor split/join functions for batch-list conversions."""
|
||||
|
||||
import torch
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
def split_image_batch(images: torch.Tensor) -> List[torch.Tensor]:
|
||||
"""Split [B,H,W,C] image batch into list of [1,H,W,C] tensors."""
|
||||
return [images[i : i + 1] for i in range(images.shape[0])]
|
||||
|
||||
|
||||
def join_image_batch(image_list: List[torch.Tensor]) -> torch.Tensor:
|
||||
"""Join list of image tensors into single [B,H,W,C] batch."""
|
||||
return torch.cat(image_list, dim=0)
|
||||
|
||||
|
||||
def split_latent_batch(
|
||||
latent: Dict[str, torch.Tensor],
|
||||
) -> List[Dict[str, torch.Tensor]]:
|
||||
"""Split latent dict into list of single-item latent dicts.
|
||||
|
||||
Preserves all keys (e.g. noise_mask, batch_index). Tensor values whose
|
||||
first dimension matches the batch size of ``samples`` are sliced along
|
||||
dim-0; all other values are copied as-is to every item.
|
||||
"""
|
||||
samples = latent["samples"]
|
||||
batch_size = samples.shape[0]
|
||||
result: List[Dict[str, torch.Tensor]] = []
|
||||
for i in range(batch_size):
|
||||
item: Dict[str, torch.Tensor] = {}
|
||||
for key, value in latent.items():
|
||||
if isinstance(value, torch.Tensor) and value.shape[0] == batch_size:
|
||||
item[key] = value[i : i + 1]
|
||||
else:
|
||||
item[key] = value
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def join_latent_batch(
|
||||
latent_list: List[Dict[str, torch.Tensor]],
|
||||
) -> Dict[str, torch.Tensor]:
|
||||
"""Join list of latent dicts into single batched latent dict.
|
||||
|
||||
Tensor values that were sliced during split are concatenated along dim-0.
|
||||
Non-tensor values are taken from the first item.
|
||||
"""
|
||||
result: Dict[str, torch.Tensor] = {}
|
||||
first = latent_list[0]
|
||||
for key in first:
|
||||
if isinstance(first[key], torch.Tensor):
|
||||
result[key] = torch.cat([lat[key] for lat in latent_list], dim=0)
|
||||
else:
|
||||
result[key] = first[key]
|
||||
return result
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Batch/List conversion nodes for ComfyUI."""
|
||||
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import torch
|
||||
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
split_image_batch,
|
||||
join_image_batch,
|
||||
split_latent_batch,
|
||||
join_latent_batch,
|
||||
)
|
||||
|
||||
|
||||
class ImageBatchToImageListNode(ComfyAssetsBaseNode):
|
||||
"""Split an IMAGE batch [B,H,W,C] into a list of individual images."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "INT")
|
||||
RETURN_NAMES = ("images", "count")
|
||||
OUTPUT_IS_LIST = (True, False)
|
||||
FUNCTION = "split_batch"
|
||||
CATEGORY = "🫶 ComfyAssets/📦 Latents"
|
||||
|
||||
def split_batch(self, images: torch.Tensor) -> Tuple[List[torch.Tensor], int]:
|
||||
image_list = split_image_batch(images)
|
||||
count = len(image_list)
|
||||
self.log_info(f"Split image batch of {count} into list")
|
||||
return (image_list, count)
|
||||
|
||||
|
||||
class ImageListToImageBatchNode(ComfyAssetsBaseNode):
|
||||
"""Join a list of IMAGE tensors into a single batched IMAGE [B,H,W,C]."""
|
||||
|
||||
INPUT_IS_LIST = True
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "INT")
|
||||
RETURN_NAMES = ("images", "count")
|
||||
FUNCTION = "join_batch"
|
||||
CATEGORY = "🫶 ComfyAssets/📦 Latents"
|
||||
|
||||
def join_batch(self, images: List[torch.Tensor]) -> Tuple[torch.Tensor, int]:
|
||||
batch = join_image_batch(images)
|
||||
count = batch.shape[0]
|
||||
self.log_info(f"Joined {count} images into batch")
|
||||
return (batch, count)
|
||||
|
||||
|
||||
class LatentBatchToLatentListNode(ComfyAssetsBaseNode):
|
||||
"""Split a LATENT batch into a list of individual latent dicts."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"latent": ("LATENT",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LATENT", "INT")
|
||||
RETURN_NAMES = ("latents", "count")
|
||||
OUTPUT_IS_LIST = (True, False)
|
||||
FUNCTION = "split_batch"
|
||||
CATEGORY = "🫶 ComfyAssets/📦 Latents"
|
||||
|
||||
def split_batch(
|
||||
self, latent: Dict[str, torch.Tensor]
|
||||
) -> Tuple[List[Dict[str, torch.Tensor]], int]:
|
||||
latent_list = split_latent_batch(latent)
|
||||
count = len(latent_list)
|
||||
self.log_info(f"Split latent batch of {count} into list")
|
||||
return (latent_list, count)
|
||||
|
||||
|
||||
class LatentListToLatentBatchNode(ComfyAssetsBaseNode):
|
||||
"""Join a list of LATENT dicts into a single batched LATENT."""
|
||||
|
||||
INPUT_IS_LIST = True
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"latents": ("LATENT",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LATENT", "INT")
|
||||
RETURN_NAMES = ("latent", "count")
|
||||
FUNCTION = "join_batch"
|
||||
CATEGORY = "🫶 ComfyAssets/📦 Latents"
|
||||
|
||||
def join_batch(
|
||||
self, latents: List[Dict[str, torch.Tensor]]
|
||||
) -> Tuple[Dict[str, torch.Tensor], int]:
|
||||
batch = join_latent_batch(latents)
|
||||
count = batch["samples"].shape[0]
|
||||
self.log_info(f"Joined {count} latents into batch")
|
||||
return (batch, count)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ImageBatchToImageList": ImageBatchToImageListNode,
|
||||
"ImageListToImageBatch": ImageListToImageBatchNode,
|
||||
"LatentBatchToLatentList": LatentBatchToLatentListNode,
|
||||
"LatentListToLatentBatch": LatentListToLatentBatchNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ImageBatchToImageList": "Image Batch to Image List",
|
||||
"ImageListToImageBatch": "Image List to Image Batch",
|
||||
"LatentBatchToLatentList": "Latent Batch to Latent List",
|
||||
"LatentListToLatentBatch": "Latent List to Latent Batch",
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Batch Prompts node for loading and processing prompts from text files."""
|
||||
|
||||
from .node import BatchPromptsNode
|
||||
|
||||
__all__ = ["BatchPromptsNode"]
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Logic module for Batch Prompts node."""
|
||||
|
||||
import os
|
||||
from typing import List, Tuple, Dict, Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_prompts_from_file(file_path: str) -> List[str]:
|
||||
"""
|
||||
Load prompts from a text file where prompts are separated by '---'.
|
||||
|
||||
Args:
|
||||
file_path: Path to the text file containing prompts
|
||||
|
||||
Returns:
|
||||
List of prompts (each prompt may be multi-line)
|
||||
"""
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Split by --- separator
|
||||
prompts = content.split("---")
|
||||
|
||||
# Clean up prompts - remove leading/trailing whitespace but preserve internal formatting
|
||||
cleaned_prompts = []
|
||||
for prompt in prompts:
|
||||
prompt = prompt.strip()
|
||||
if prompt: # Only add non-empty prompts
|
||||
cleaned_prompts.append(prompt)
|
||||
|
||||
logger.info(f"Loaded {len(cleaned_prompts)} prompts from {file_path}")
|
||||
return cleaned_prompts
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading prompts from {file_path}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_prompt_at_index(
|
||||
prompts: List[str], index: int, wrap: bool = True
|
||||
) -> Tuple[str, int]:
|
||||
"""
|
||||
Get prompt at specified index with optional wrapping.
|
||||
|
||||
Args:
|
||||
prompts: List of prompts
|
||||
index: Index to retrieve
|
||||
wrap: Whether to wrap around to beginning when index exceeds list length
|
||||
|
||||
Returns:
|
||||
Tuple of (prompt text, actual index used)
|
||||
"""
|
||||
if not prompts:
|
||||
return ("", 0)
|
||||
|
||||
if wrap:
|
||||
actual_index = index % len(prompts)
|
||||
else:
|
||||
actual_index = min(index, len(prompts) - 1)
|
||||
|
||||
return (prompts[actual_index], actual_index)
|
||||
|
||||
|
||||
def get_next_prompt(
|
||||
prompts: List[str], current_index: int, wrap: bool = True
|
||||
) -> Tuple[str, int]:
|
||||
"""
|
||||
Get the next prompt in sequence.
|
||||
|
||||
Args:
|
||||
prompts: List of prompts
|
||||
current_index: Current prompt index
|
||||
wrap: Whether to wrap around to beginning
|
||||
|
||||
Returns:
|
||||
Tuple of (next prompt text, next index)
|
||||
"""
|
||||
if not prompts:
|
||||
return ("", 0)
|
||||
|
||||
next_index = current_index + 1
|
||||
|
||||
if wrap:
|
||||
next_index = next_index % len(prompts)
|
||||
else:
|
||||
next_index = min(next_index, len(prompts) - 1)
|
||||
|
||||
return (prompts[next_index], next_index)
|
||||
|
||||
|
||||
def get_prompt_preview(prompt: str, max_length: int = 100) -> str:
|
||||
"""
|
||||
Get a preview of a prompt, truncated if necessary.
|
||||
|
||||
Args:
|
||||
prompt: Full prompt text
|
||||
max_length: Maximum length for preview
|
||||
|
||||
Returns:
|
||||
Preview string
|
||||
"""
|
||||
if len(prompt) <= max_length:
|
||||
return prompt
|
||||
|
||||
return prompt[:max_length] + "..."
|
||||
|
||||
|
||||
def parse_prompt_file_list(file_list_str: str) -> List[str]:
|
||||
"""
|
||||
Parse a comma-separated list of prompt file paths.
|
||||
|
||||
Args:
|
||||
file_list_str: Comma-separated file paths
|
||||
|
||||
Returns:
|
||||
List of file paths
|
||||
"""
|
||||
if not file_list_str:
|
||||
return []
|
||||
|
||||
files = []
|
||||
for file_path in file_list_str.split(","):
|
||||
file_path = file_path.strip()
|
||||
if file_path:
|
||||
files.append(file_path)
|
||||
|
||||
return files
|
||||
|
||||
|
||||
def merge_prompts_from_multiple_files(file_paths: List[str]) -> List[str]:
|
||||
"""
|
||||
Load and merge prompts from multiple files.
|
||||
|
||||
Args:
|
||||
file_paths: List of file paths
|
||||
|
||||
Returns:
|
||||
Combined list of all prompts
|
||||
"""
|
||||
all_prompts = []
|
||||
|
||||
for file_path in file_paths:
|
||||
prompts = load_prompts_from_file(file_path)
|
||||
all_prompts.extend(prompts)
|
||||
|
||||
logger.info(f"Merged {len(all_prompts)} prompts from {len(file_paths)} files")
|
||||
return all_prompts
|
||||
|
||||
|
||||
def get_batch_info(prompts: List[str], current_index: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Get information about current batch processing state.
|
||||
|
||||
Args:
|
||||
prompts: List of prompts
|
||||
current_index: Current prompt index
|
||||
|
||||
Returns:
|
||||
Dictionary with batch information
|
||||
"""
|
||||
total = len(prompts)
|
||||
|
||||
return {
|
||||
"current_index": current_index,
|
||||
"total_prompts": total,
|
||||
"progress": f"{current_index + 1}/{total}" if total > 0 else "0/0",
|
||||
"percentage": (current_index / total * 100) if total > 0 else 0,
|
||||
"remaining": total - current_index - 1 if total > 0 else 0,
|
||||
"is_complete": current_index >= total - 1 if total > 0 else True,
|
||||
}
|
||||
|
||||
|
||||
def validate_prompt_file(file_path: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate that a prompt file exists and is readable.
|
||||
|
||||
Args:
|
||||
file_path: Path to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not file_path:
|
||||
return (False, "No file path provided")
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
return (False, f"File not found: {file_path}")
|
||||
|
||||
if not os.path.isfile(file_path):
|
||||
return (False, f"Path is not a file: {file_path}")
|
||||
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
f.read(1) # Try to read one character
|
||||
return (True, "")
|
||||
except Exception as e:
|
||||
return (False, f"Cannot read file: {str(e)}")
|
||||
|
||||
|
||||
def format_prompt_for_display(prompt: str, index: int, total: int) -> str:
|
||||
"""
|
||||
Format a prompt for display with index information.
|
||||
|
||||
Args:
|
||||
prompt: Prompt text
|
||||
index: Current index
|
||||
total: Total number of prompts
|
||||
|
||||
Returns:
|
||||
Formatted display string
|
||||
"""
|
||||
header = f"[Prompt {index + 1}/{total}]"
|
||||
separator = "-" * len(header)
|
||||
|
||||
return f"{header}\n{separator}\n{prompt}"
|
||||
|
||||
|
||||
def split_prompt_into_positive_negative(
|
||||
prompt: str, negative_prefix: str = "Negative:"
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Split a prompt into positive and negative parts.
|
||||
|
||||
Args:
|
||||
prompt: Full prompt text
|
||||
negative_prefix: Prefix that marks the negative prompt section
|
||||
|
||||
Returns:
|
||||
Tuple of (positive_prompt, negative_prompt)
|
||||
"""
|
||||
# Look for negative prompt marker
|
||||
negative_lower = negative_prefix.lower()
|
||||
prompt_lower = prompt.lower()
|
||||
|
||||
if negative_lower in prompt_lower:
|
||||
# Find the actual position (case-insensitive search)
|
||||
idx = prompt_lower.index(negative_lower)
|
||||
positive = prompt[:idx].strip()
|
||||
negative = prompt[idx + len(negative_prefix) :].strip()
|
||||
return (positive, negative)
|
||||
|
||||
# No negative prompt found
|
||||
return (prompt, "")
|
||||
|
||||
|
||||
def create_batch_queue(
|
||||
prompts: List[str], batch_size: int = 1, randomize: bool = False
|
||||
) -> List[List[int]]:
|
||||
"""
|
||||
Create a queue of prompt indices for batch processing.
|
||||
|
||||
Args:
|
||||
prompts: List of prompts
|
||||
batch_size: Number of prompts per batch
|
||||
randomize: Whether to randomize the order
|
||||
|
||||
Returns:
|
||||
List of batches, where each batch is a list of prompt indices
|
||||
"""
|
||||
if not prompts:
|
||||
return []
|
||||
|
||||
indices = list(range(len(prompts)))
|
||||
|
||||
if randomize:
|
||||
import random
|
||||
|
||||
random.shuffle(indices)
|
||||
|
||||
batches = []
|
||||
for i in range(0, len(indices), batch_size):
|
||||
batch = indices[i : i + batch_size]
|
||||
batches.append(batch)
|
||||
|
||||
return batches
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Batch Prompts node for ComfyUI."""
|
||||
|
||||
import os
|
||||
from typing import Tuple
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
load_prompts_from_file,
|
||||
get_prompt_at_index,
|
||||
get_next_prompt,
|
||||
get_prompt_preview,
|
||||
get_batch_info,
|
||||
validate_prompt_file,
|
||||
split_prompt_into_positive_negative,
|
||||
)
|
||||
from .state_manager import STATE_MANAGER
|
||||
|
||||
|
||||
class BatchPromptsNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Batch Prompts node for loading and iterating through prompts from text files.
|
||||
|
||||
Loads prompts from a text file where prompts are separated by '---' markers,
|
||||
provides iteration control, and outputs both current and next prompts with
|
||||
optional positive/negative splitting.
|
||||
"""
|
||||
|
||||
# Class variable to cache loaded prompts
|
||||
_prompt_cache = {}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define the input types for the ComfyUI node."""
|
||||
# Try to get input folder path
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
folder_paths.get_input_directory()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"prompt_file": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "prompts.txt",
|
||||
"multiline": False,
|
||||
"tooltip": "Path to text file containing prompts separated by '---'",
|
||||
},
|
||||
),
|
||||
"index": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 9999,
|
||||
"step": 1,
|
||||
"tooltip": "Current prompt index (0-based)",
|
||||
},
|
||||
),
|
||||
"auto_increment": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Automatically increment index after each execution",
|
||||
},
|
||||
),
|
||||
"wrap_around": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Wrap to first prompt after reaching the end",
|
||||
},
|
||||
),
|
||||
"split_negative": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Split prompts into positive/negative at 'Negative:' marker",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"reload_file": (
|
||||
"BOOLEAN",
|
||||
{"default": False, "tooltip": "Force reload file from disk"},
|
||||
),
|
||||
"show_preview": (
|
||||
"BOOLEAN",
|
||||
{"default": True, "tooltip": "Show prompt preview in console"},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "INT", "INT", "STRING")
|
||||
RETURN_NAMES = (
|
||||
"positive",
|
||||
"negative",
|
||||
"full_prompt",
|
||||
"next_prompt",
|
||||
"current_index",
|
||||
"total_prompts",
|
||||
"batch_info",
|
||||
)
|
||||
FUNCTION = "process_batch_prompts"
|
||||
CATEGORY = "🫶 ComfyAssets/📝 Text"
|
||||
|
||||
def process_batch_prompts(
|
||||
self,
|
||||
prompt_file: str,
|
||||
index: int,
|
||||
auto_increment: bool,
|
||||
wrap_around: bool,
|
||||
split_negative: bool,
|
||||
reload_file: bool = False,
|
||||
show_preview: bool = True,
|
||||
) -> Tuple[str, str, str, str, int, int, str]:
|
||||
"""
|
||||
Process batch prompts from file.
|
||||
|
||||
Args:
|
||||
prompt_file: Path to prompt file
|
||||
index: Current prompt index
|
||||
auto_increment: Whether to auto-increment index
|
||||
wrap_around: Whether to wrap around at end
|
||||
split_negative: Whether to split positive/negative prompts
|
||||
reload_file: Force reload from disk
|
||||
show_preview: Show prompt preview in console
|
||||
|
||||
Returns:
|
||||
Tuple of (positive, negative, full_prompt, next_prompt, current_index, total_prompts, batch_info)
|
||||
"""
|
||||
try:
|
||||
# Handle file path first to get a consistent key
|
||||
if not os.path.isabs(prompt_file):
|
||||
# Try to resolve relative to ComfyUI input directory
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
full_path = os.path.join(input_dir, prompt_file)
|
||||
except Exception:
|
||||
# Fallback to current directory
|
||||
full_path = os.path.abspath(prompt_file)
|
||||
else:
|
||||
full_path = prompt_file
|
||||
|
||||
# Use persistent state manager for tracking execution
|
||||
if auto_increment:
|
||||
# Use file-based persistent state
|
||||
actual_index = STATE_MANAGER.increment_execution_count(full_path)
|
||||
print(
|
||||
f"[BatchPrompts] Auto-increment: using index {actual_index} for {os.path.basename(prompt_file)}"
|
||||
)
|
||||
else:
|
||||
actual_index = index
|
||||
print(f"[BatchPrompts] Manual mode: using index {actual_index}")
|
||||
|
||||
# Validate file
|
||||
is_valid, error_msg = validate_prompt_file(full_path)
|
||||
if not is_valid:
|
||||
self.handle_error(f"Invalid prompt file: {error_msg}")
|
||||
|
||||
# Load prompts (with caching)
|
||||
cache_key = full_path
|
||||
if reload_file or cache_key not in self._prompt_cache:
|
||||
prompts = load_prompts_from_file(full_path)
|
||||
if not prompts:
|
||||
self.handle_error(f"No prompts found in file: {prompt_file}")
|
||||
self._prompt_cache[cache_key] = prompts
|
||||
# Reset execution count when reloading file
|
||||
if reload_file:
|
||||
STATE_MANAGER.reset_execution_count(full_path)
|
||||
self.log_info(f"Loaded {len(prompts)} prompts from {prompt_file}")
|
||||
else:
|
||||
prompts = self._prompt_cache[cache_key]
|
||||
|
||||
# Get current prompt using the determined index
|
||||
current_prompt, used_index = get_prompt_at_index(
|
||||
prompts, actual_index, wrap_around
|
||||
)
|
||||
|
||||
# Get next prompt
|
||||
next_prompt_text, next_index = get_next_prompt(
|
||||
prompts, used_index, wrap_around
|
||||
)
|
||||
|
||||
# Split positive/negative if requested
|
||||
if split_negative:
|
||||
positive, negative = split_prompt_into_positive_negative(current_prompt)
|
||||
else:
|
||||
positive = current_prompt
|
||||
negative = ""
|
||||
|
||||
# Get batch info
|
||||
batch_info_dict = get_batch_info(prompts, used_index)
|
||||
batch_info_str = (
|
||||
f"Prompt {batch_info_dict['current_index'] + 1} of {batch_info_dict['total_prompts']} "
|
||||
f"({batch_info_dict['percentage']:.1f}% complete)"
|
||||
)
|
||||
|
||||
# Show preview if requested
|
||||
if show_preview:
|
||||
preview = get_prompt_preview(positive, 80)
|
||||
self.log_info(
|
||||
f"Current prompt [{used_index + 1}/{len(prompts)}]: {preview}"
|
||||
)
|
||||
|
||||
# No need to manually reset - the modulo operation in get_prompt_at_index handles wrapping
|
||||
|
||||
return (
|
||||
positive,
|
||||
negative,
|
||||
current_prompt,
|
||||
next_prompt_text,
|
||||
used_index,
|
||||
len(prompts),
|
||||
batch_info_str,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.handle_error(f"Error processing batch prompts: {str(e)}")
|
||||
# Return empty values on error
|
||||
return ("", "", "", "", 0, 0, "Error")
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""
|
||||
Check if node inputs have changed.
|
||||
This ensures the node re-executes when needed.
|
||||
"""
|
||||
# Import time to ensure unique value each check
|
||||
import time
|
||||
|
||||
# Return current timestamp to guarantee the node is seen as changed
|
||||
# This forces re-execution on every workflow run
|
||||
return str(time.time())
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Simple Batch Prompts node for ComfyUI - debugging version."""
|
||||
|
||||
import os
|
||||
from typing import Tuple
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
load_prompts_from_file,
|
||||
get_prompt_at_index,
|
||||
split_prompt_into_positive_negative,
|
||||
)
|
||||
|
||||
# Global counter that persists across all executions
|
||||
GLOBAL_COUNTER = {"count": 0}
|
||||
|
||||
|
||||
class SimpleBatchPromptsNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Simplified Batch Prompts node for debugging.
|
||||
Uses a global counter to ensure prompts change.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define the input types for the ComfyUI node."""
|
||||
return {
|
||||
"required": {
|
||||
"prompt_file": ("STRING", {"default": "prompts.txt"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING", "INT")
|
||||
RETURN_NAMES = ("positive", "negative", "index")
|
||||
FUNCTION = "get_next_prompt"
|
||||
CATEGORY = "🫶 ComfyAssets/📝 Text"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""Force re-execution every time."""
|
||||
GLOBAL_COUNTER["count"] += 1
|
||||
return GLOBAL_COUNTER["count"]
|
||||
|
||||
def get_next_prompt(self, prompt_file: str) -> Tuple[str, str, int]:
|
||||
"""Get the next prompt in sequence."""
|
||||
# Resolve file path
|
||||
if not os.path.isabs(prompt_file):
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
full_path = os.path.join(input_dir, prompt_file)
|
||||
except ImportError:
|
||||
full_path = os.path.abspath(prompt_file)
|
||||
else:
|
||||
full_path = prompt_file
|
||||
|
||||
# Load prompts
|
||||
prompts = load_prompts_from_file(full_path)
|
||||
if not prompts:
|
||||
return ("No prompts found", "", 0)
|
||||
|
||||
# Get current prompt based on global counter
|
||||
index = GLOBAL_COUNTER["count"] % len(prompts)
|
||||
current_prompt, _ = get_prompt_at_index(prompts, index, wrap=True)
|
||||
|
||||
# Split positive/negative
|
||||
positive, negative = split_prompt_into_positive_negative(current_prompt)
|
||||
|
||||
print(
|
||||
f"[SimpleBatchPrompts] Counter={GLOBAL_COUNTER['count']}, Index={index}, Prompt={positive[:30]}..."
|
||||
)
|
||||
|
||||
return (positive, negative, index)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""State management for batch prompts using file persistence."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
class StateManager:
|
||||
"""Manages persistent state for batch prompt execution."""
|
||||
|
||||
def __init__(self):
|
||||
# Use temp directory for state files
|
||||
self.state_dir = Path(tempfile.gettempdir()) / "comfyui_batch_prompts"
|
||||
self.state_dir.mkdir(exist_ok=True)
|
||||
self.state_file = self.state_dir / "execution_state.json"
|
||||
|
||||
def get_state(self) -> Dict[str, Any]:
|
||||
"""Load state from file."""
|
||||
if self.state_file.exists():
|
||||
try:
|
||||
with open(self.state_file, "r") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
def save_state(self, state: Dict[str, Any]):
|
||||
"""Save state to file."""
|
||||
try:
|
||||
with open(self.state_file, "w") as f:
|
||||
json.dump(state, f)
|
||||
except Exception as e:
|
||||
print(f"[BatchPrompts] Failed to save state: {e}")
|
||||
|
||||
def get_execution_count(self, file_path: str) -> int:
|
||||
"""Get execution count for a specific file."""
|
||||
state = self.get_state()
|
||||
counts = state.get("execution_counts", {})
|
||||
return counts.get(file_path, 0)
|
||||
|
||||
def increment_execution_count(self, file_path: str) -> int:
|
||||
"""Increment and return execution count for a file."""
|
||||
state = self.get_state()
|
||||
counts = state.get("execution_counts", {})
|
||||
current = counts.get(file_path, 0)
|
||||
counts[file_path] = current + 1
|
||||
state["execution_counts"] = counts
|
||||
self.save_state(state)
|
||||
return current
|
||||
|
||||
def reset_execution_count(self, file_path: str):
|
||||
"""Reset execution count for a file."""
|
||||
state = self.get_state()
|
||||
counts = state.get("execution_counts", {})
|
||||
counts[file_path] = 0
|
||||
state["execution_counts"] = counts
|
||||
self.save_state(state)
|
||||
|
||||
|
||||
# Global state manager instance
|
||||
STATE_MANAGER = StateManager()
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Logic for DisplayAny node - displays any input value or tensor shape."""
|
||||
|
||||
from typing import Any, List, Union
|
||||
from typing import Any, List
|
||||
|
||||
|
||||
def get_tensor_shapes(input_value: Any) -> List[List[int]]:
|
||||
@@ -48,6 +48,15 @@ def format_display_value(input_value: Any, mode: str = "raw value") -> str:
|
||||
return "No tensors found in input"
|
||||
|
||||
# Default to raw value display
|
||||
# Try to format as JSON for better readability
|
||||
try:
|
||||
import json
|
||||
|
||||
if isinstance(input_value, (dict, list)):
|
||||
return json.dumps(input_value, indent=2)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return str(input_value)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""DisplayAny node for ComfyUI - displays any input value or tensor information."""
|
||||
|
||||
from typing import Any, Dict, Tuple
|
||||
from typing import Any, Dict
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
from .logic import format_display_value, validate_display_mode
|
||||
@@ -38,6 +38,7 @@ class DisplayAnyNode(ComfyAssetsBaseNode):
|
||||
return True
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
CATEGORY = "🫶 ComfyAssets/👁️ Display"
|
||||
RETURN_NAMES = ("display_text",)
|
||||
FUNCTION = "display"
|
||||
OUTPUT_NODE = True # This node displays output in the UI
|
||||
@@ -61,6 +62,6 @@ class DisplayAnyNode(ComfyAssetsBaseNode):
|
||||
|
||||
# Return both UI display and result
|
||||
return {
|
||||
"ui": {"text": display_text},
|
||||
"ui": {"text": [display_text]}, # UI expects array
|
||||
"result": (display_text,),
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class DisplayTextNode(ComfyAssetsBaseNode):
|
||||
RETURN_NAMES = ("text",)
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "display_text"
|
||||
CATEGORY = "ComfyAssets"
|
||||
CATEGORY = "🫶 ComfyAssets/👁️ Display"
|
||||
|
||||
DESCRIPTION = """
|
||||
Displays text in the UI with a copy-to-clipboard feature.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Embedding Autocomplete tool for KikoTools."""
|
||||
|
||||
from .node import KikoEmbeddingAutocomplete
|
||||
|
||||
__all__ = ["KikoEmbeddingAutocomplete"]
|
||||
@@ -0,0 +1,291 @@
|
||||
"""KikoEmbeddingAutocomplete node for ComfyUI.
|
||||
|
||||
Provides autocomplete functionality for embeddings and LoRAs in text inputs.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Dict, List, Any
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
except ImportError:
|
||||
# For testing outside ComfyUI environment
|
||||
folder_paths = None
|
||||
|
||||
|
||||
class KikoEmbeddingAutocomplete:
|
||||
"""Node that provides embedding autocomplete functionality."""
|
||||
|
||||
DISPLAY_NAME = "🫶 Embedding Autocomplete Settings"
|
||||
CATEGORY = "🫶 ComfyAssets"
|
||||
|
||||
# Settings definition for the settings registry
|
||||
SETTINGS = {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Enable autocomplete",
|
||||
},
|
||||
"show_embeddings": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Show embeddings in autocomplete",
|
||||
},
|
||||
"show_loras": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Show LoRAs in autocomplete",
|
||||
},
|
||||
"embedding_trigger": {
|
||||
"type": "text",
|
||||
"default": "embedding:",
|
||||
"description": "Trigger text for embeddings (e.g., 'embedding:', 'emb:', or custom)",
|
||||
},
|
||||
"lora_trigger": {
|
||||
"type": "text",
|
||||
"default": "<lora:",
|
||||
"description": "Trigger text for LoRAs (e.g., '<lora:', 'lora:', or custom)",
|
||||
},
|
||||
"quick_trigger": {
|
||||
"type": "text",
|
||||
"default": "em",
|
||||
"description": "Quick trigger to show embeddings (e.g., 'em', 'emb', or disabled with '')",
|
||||
},
|
||||
"min_chars": {
|
||||
"type": "combo",
|
||||
"default": 2,
|
||||
"options": [1, 2, 3, 4, 5],
|
||||
"description": "Minimum characters before showing suggestions",
|
||||
},
|
||||
"max_suggestions": {
|
||||
"type": "combo",
|
||||
"default": 20,
|
||||
"options": [5, 10, 15, 20, 30, 50, 100],
|
||||
"description": "Maximum number of suggestions to display",
|
||||
},
|
||||
"sort_by_directory": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Group suggestions by directory",
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define input types for the node."""
|
||||
return {
|
||||
"required": {},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
RETURN_NAMES = ()
|
||||
FUNCTION = "update_settings"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, **kwargs):
|
||||
return True
|
||||
|
||||
def __init__(self):
|
||||
self.embeddings_cache = None
|
||||
self.loras_cache = None
|
||||
|
||||
def update_settings(self, unique_id=None):
|
||||
"""Update settings display.
|
||||
|
||||
This node serves as a settings indicator.
|
||||
Actual settings are configured in ComfyUI Settings menu.
|
||||
"""
|
||||
# This node doesn't actually process anything
|
||||
# It's just a visual indicator that autocomplete is available
|
||||
return ()
|
||||
|
||||
def refresh_cache(self):
|
||||
"""Refresh the cache of embeddings and LoRAs."""
|
||||
print("[KikoEmbeddingAutocomplete] Refreshing cache...")
|
||||
self.embeddings_cache = self.get_embeddings()
|
||||
self.loras_cache = self.get_loras()
|
||||
print(
|
||||
f"[KikoEmbeddingAutocomplete] Cached {len(self.embeddings_cache)} embeddings, {len(self.loras_cache)} LoRAs"
|
||||
)
|
||||
|
||||
def get_embeddings(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of available embeddings."""
|
||||
embeddings = []
|
||||
|
||||
# Get embedding files from ComfyUI's folder system
|
||||
try:
|
||||
print("[KikoEmbeddingAutocomplete] Getting embeddings list...")
|
||||
if folder_paths is None:
|
||||
return embeddings
|
||||
embedding_files = folder_paths.get_filename_list("embeddings")
|
||||
print(
|
||||
f"[KikoEmbeddingAutocomplete] Found {len(embedding_files)} embedding files"
|
||||
)
|
||||
for file in embedding_files:
|
||||
name = os.path.splitext(file)[0]
|
||||
embeddings.append(
|
||||
{
|
||||
"name": name,
|
||||
"file": file,
|
||||
"type": "embedding",
|
||||
"display": f"embedding:{name}",
|
||||
"value": f"embedding:{name}",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error loading embeddings: {e}")
|
||||
|
||||
return embeddings
|
||||
|
||||
def get_loras(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of available LoRAs."""
|
||||
loras = []
|
||||
|
||||
# Get LoRA files from ComfyUI's folder system
|
||||
try:
|
||||
if folder_paths is None:
|
||||
return loras
|
||||
lora_files = folder_paths.get_filename_list("loras")
|
||||
for file in lora_files:
|
||||
name = os.path.splitext(file)[0]
|
||||
loras.append(
|
||||
{
|
||||
"name": name,
|
||||
"file": file,
|
||||
"type": "lora",
|
||||
"display": f"<lora:{name}:1.0>",
|
||||
"value": f"<lora:{name}:1.0>",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error loading LoRAs: {e}")
|
||||
|
||||
return loras
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""Check if the node needs to be re-executed."""
|
||||
# Always re-execute if refresh is True
|
||||
if kwargs.get("refresh", False):
|
||||
return float("NaN")
|
||||
|
||||
# Check if embeddings/loras folders have changed
|
||||
try:
|
||||
if folder_paths is None:
|
||||
return 0
|
||||
embeddings_path = folder_paths.get_folder_paths("embeddings")[0]
|
||||
loras_path = folder_paths.get_folder_paths("loras")[0]
|
||||
|
||||
# Return combined modification time
|
||||
return os.path.getmtime(embeddings_path) + os.path.getmtime(loras_path)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
class KikoEmbeddingAutocompleteAPI:
|
||||
"""API endpoints for embedding autocomplete."""
|
||||
|
||||
@staticmethod
|
||||
def get_suggestions(
|
||||
prefix: str,
|
||||
max_results: int = 20,
|
||||
include_embeddings: bool = True,
|
||||
include_loras: bool = True,
|
||||
case_sensitive: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get autocomplete suggestions for a given prefix.
|
||||
|
||||
Args:
|
||||
prefix: The text prefix to match
|
||||
max_results: Maximum number of results to return
|
||||
include_embeddings: Include embeddings in results
|
||||
include_loras: Include LoRAs in results
|
||||
case_sensitive: Use case-sensitive matching
|
||||
|
||||
Returns:
|
||||
List of suggestion dictionaries
|
||||
"""
|
||||
suggestions = []
|
||||
|
||||
# Normalize prefix for matching
|
||||
match_prefix = prefix if case_sensitive else prefix.lower()
|
||||
|
||||
# Get embeddings
|
||||
if include_embeddings:
|
||||
try:
|
||||
if folder_paths is None:
|
||||
embedding_files = []
|
||||
else:
|
||||
embedding_files = folder_paths.get_filename_list("embeddings")
|
||||
for file in embedding_files:
|
||||
name = os.path.splitext(file)[0]
|
||||
match_name = name if case_sensitive else name.lower()
|
||||
|
||||
# Check for match
|
||||
if match_name.startswith(match_prefix):
|
||||
suggestions.append(
|
||||
{
|
||||
"name": name,
|
||||
"type": "embedding",
|
||||
"display": f"embedding:{name}",
|
||||
"value": f"embedding:{name}",
|
||||
"priority": 1 if match_name == match_prefix else 0,
|
||||
}
|
||||
)
|
||||
elif match_prefix in match_name:
|
||||
suggestions.append(
|
||||
{
|
||||
"name": name,
|
||||
"type": "embedding",
|
||||
"display": f"embedding:{name}",
|
||||
"value": f"embedding:{name}",
|
||||
"priority": -1,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error loading embeddings: {e}")
|
||||
|
||||
# Get LoRAs
|
||||
if include_loras:
|
||||
try:
|
||||
if folder_paths is None:
|
||||
lora_files = []
|
||||
else:
|
||||
lora_files = folder_paths.get_filename_list("loras")
|
||||
for file in lora_files:
|
||||
name = os.path.splitext(file)[0]
|
||||
match_name = name if case_sensitive else name.lower()
|
||||
|
||||
# Check for match
|
||||
if match_name.startswith(match_prefix):
|
||||
suggestions.append(
|
||||
{
|
||||
"name": name,
|
||||
"type": "lora",
|
||||
"display": f"<lora:{name}:1.0>",
|
||||
"value": f"<lora:{name}:1.0>",
|
||||
"priority": 1 if match_name == match_prefix else 0,
|
||||
}
|
||||
)
|
||||
elif match_prefix in match_name:
|
||||
suggestions.append(
|
||||
{
|
||||
"name": name,
|
||||
"type": "lora",
|
||||
"display": f"<lora:{name}:1.0>",
|
||||
"value": f"<lora:{name}:1.0>",
|
||||
"priority": -1,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error loading LoRAs: {e}")
|
||||
|
||||
# Sort by priority and name
|
||||
suggestions.sort(key=lambda x: (-x["priority"], x["name"]))
|
||||
|
||||
# Limit results
|
||||
return suggestions[:max_results]
|
||||
@@ -93,14 +93,14 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LATENT", "INT", "INT")
|
||||
RETURN_NAMES = ("latent", "width", "height")
|
||||
RETURN_TYPES = ("LATENT", "INT", "INT", "INT")
|
||||
RETURN_NAMES = ("latent", "width", "height", "batch_size")
|
||||
FUNCTION = "create_empty_latent"
|
||||
CATEGORY = "ComfyAssets"
|
||||
CATEGORY = "🫶 ComfyAssets/📦 Latents"
|
||||
|
||||
def create_empty_latent(
|
||||
self, preset: str, width: int, height: int, batch_size: int
|
||||
) -> Tuple[Dict[str, torch.Tensor], int, int]:
|
||||
) -> Tuple[Dict[str, torch.Tensor], int, int, int]:
|
||||
"""
|
||||
Create empty latent tensor with specified dimensions and batch size.
|
||||
|
||||
@@ -111,7 +111,7 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
|
||||
batch_size: Number of latents in the batch
|
||||
|
||||
Returns:
|
||||
Tuple containing (latent dictionary with 'samples' tensor, width, height)
|
||||
Tuple containing (latent dict, width, height, batch_size)
|
||||
"""
|
||||
try:
|
||||
# Extract original preset name from formatted string if needed
|
||||
@@ -160,7 +160,7 @@ class EmptyLatentBatchNode(ComfyAssetsBaseNode):
|
||||
f"(pixel dims: {final_width}×{final_height})"
|
||||
)
|
||||
|
||||
return (latent_dict, final_width, final_height)
|
||||
return (latent_dict, final_width, final_height, batch_size)
|
||||
|
||||
except Exception as e:
|
||||
# Handle any unexpected errors gracefully
|
||||
|
||||
@@ -85,5 +85,5 @@
|
||||
"gemma-3n-e2b-it": "Gemma 3n E2B",
|
||||
"gemini-2.5-flash-lite": "Gemini 2.5 Flash-Lite"
|
||||
},
|
||||
"timestamp": 1754142231.0568295
|
||||
}
|
||||
"timestamp": 1754568195.1098156
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ class GeminiPromptNode(ComfyAssetsBaseNode):
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("prompt", "negative_prompt")
|
||||
FUNCTION = "generate_prompt"
|
||||
CATEGORY = "ComfyAssets"
|
||||
CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
|
||||
|
||||
DESCRIPTION = """
|
||||
Analyzes images using Google's Gemini AI to generate optimized prompts.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Image Scale Down By tool for ComfyUI."""
|
||||
|
||||
from .node import ImageScaleDownByNode
|
||||
|
||||
__all__ = ["ImageScaleDownByNode"]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Core logic for ImageScaleDownBy tool."""
|
||||
|
||||
import torch.nn.functional as F
|
||||
from torch import Tensor
|
||||
|
||||
|
||||
def scale_down_image(image: Tensor, scale_by: float) -> Tensor:
|
||||
"""Scale down an image by a given factor.
|
||||
|
||||
Args:
|
||||
image: Input image tensor of shape (batch, height, width, channels)
|
||||
scale_by: Scale factor between 0.01 and 1.0
|
||||
|
||||
Returns:
|
||||
Scaled down image tensor
|
||||
"""
|
||||
batch, height, width, channels = image.shape
|
||||
|
||||
# Calculate new dimensions
|
||||
new_height = int(height * scale_by)
|
||||
new_width = int(width * scale_by)
|
||||
|
||||
# Ensure minimum size of 1x1
|
||||
new_height = max(1, new_height)
|
||||
new_width = max(1, new_width)
|
||||
|
||||
# Convert from BHWC to BCHW for interpolation
|
||||
image_chw = image.permute(0, 3, 1, 2)
|
||||
|
||||
# Scale down the image using bilinear interpolation
|
||||
scaled = F.interpolate(
|
||||
image_chw,
|
||||
size=(new_height, new_width),
|
||||
mode="bilinear",
|
||||
align_corners=False,
|
||||
antialias=True,
|
||||
)
|
||||
|
||||
# Convert back to BHWC
|
||||
return scaled.permute(0, 2, 3, 1)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""ComfyUI node implementation for ImageScaleDownBy."""
|
||||
|
||||
from typing import Dict, Any, Tuple
|
||||
|
||||
from torch import Tensor
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
from .logic import scale_down_image
|
||||
|
||||
|
||||
class ImageScaleDownByNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Scales down images by a specified factor.
|
||||
|
||||
Reduces image dimensions proportionally using bilinear interpolation
|
||||
with antialiasing for smooth downscaling.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"scale_by": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.5,
|
||||
"min": 0.01,
|
||||
"max": 1.0,
|
||||
"step": 0.01,
|
||||
"display": "number",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
|
||||
RETURN_NAMES = ("images",)
|
||||
FUNCTION = "scale_down"
|
||||
|
||||
def scale_down(self, images: Tensor, scale_by: float) -> Tuple[Tensor]:
|
||||
"""
|
||||
Scale down images by the specified factor.
|
||||
|
||||
Args:
|
||||
images: Input image tensor
|
||||
scale_by: Scale factor between 0.01 and 1.0
|
||||
|
||||
Returns:
|
||||
Tuple containing scaled down image tensor
|
||||
"""
|
||||
try:
|
||||
self.validate_inputs(images=images, scale_by=scale_by)
|
||||
|
||||
# Scale down the images
|
||||
scaled_images = scale_down_image(images, scale_by)
|
||||
|
||||
_, new_height, new_width, _ = scaled_images.shape
|
||||
_, orig_height, orig_width, _ = images.shape
|
||||
|
||||
self.log_info(
|
||||
f"Scaled down images from {orig_height}x{orig_width} "
|
||||
f"to {new_height}x{new_width} (scale factor: {scale_by})"
|
||||
)
|
||||
|
||||
return (scaled_images,)
|
||||
|
||||
except Exception as e:
|
||||
self.handle_error(f"Failed to scale down images: {str(e)}", e)
|
||||
|
||||
def validate_inputs(self, **kwargs) -> None:
|
||||
"""Validate inputs for ImageScaleDownBy node."""
|
||||
images = kwargs.get("images")
|
||||
scale_by = kwargs.get("scale_by")
|
||||
|
||||
if images is None:
|
||||
raise ValueError("Images input is required")
|
||||
|
||||
if not isinstance(images, Tensor) or len(images.shape) != 4:
|
||||
raise ValueError(
|
||||
f"Expected image tensor with shape (batch, height, width, channels), "
|
||||
f"got shape {images.shape if isinstance(images, Tensor) else 'non-tensor'}"
|
||||
)
|
||||
|
||||
if scale_by <= 0 or scale_by > 1.0:
|
||||
raise ValueError(f"scale_by must be between 0.01 and 1.0, got {scale_by}")
|
||||
@@ -36,6 +36,7 @@ class ImageToMultipleOfNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "process"
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .node import KikoFilmGrainNode
|
||||
|
||||
__all__ = ["KikoFilmGrainNode"]
|
||||
@@ -0,0 +1,221 @@
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
|
||||
def rgb_to_ycbcr(rgb: torch.Tensor) -> torch.Tensor:
|
||||
"""
|
||||
Convert RGB tensor to YCbCr color space.
|
||||
|
||||
Args:
|
||||
rgb: Tensor of shape [B, H, W, C] in range [0, 1]
|
||||
|
||||
Returns:
|
||||
YCbCr tensor of same shape
|
||||
"""
|
||||
ycbcr = rgb.detach().clone()
|
||||
r, g, b = rgb[:, :, :, 0], rgb[:, :, :, 1], rgb[:, :, :, 2]
|
||||
|
||||
# ITU-R BT.709 coefficients
|
||||
ycbcr[:, :, :, 0] = 0.2126 * r + 0.7152 * g + 0.0722 * b # Y
|
||||
ycbcr[:, :, :, 1] = -0.1146 * r - 0.3854 * g + 0.5 * b # Cb
|
||||
ycbcr[:, :, :, 2] = 0.5 * r - 0.4542 * g - 0.0458 * b # Cr
|
||||
|
||||
return ycbcr
|
||||
|
||||
|
||||
def ycbcr_to_rgb(ycbcr: torch.Tensor) -> torch.Tensor:
|
||||
"""
|
||||
Convert YCbCr tensor to RGB color space.
|
||||
|
||||
Args:
|
||||
ycbcr: Tensor of shape [B, H, W, C]
|
||||
|
||||
Returns:
|
||||
RGB tensor of same shape in range [0, 1]
|
||||
"""
|
||||
rgb = ycbcr.detach().clone()
|
||||
y, cb, cr = ycbcr[:, :, :, 0], ycbcr[:, :, :, 1], ycbcr[:, :, :, 2]
|
||||
|
||||
rgb[:, :, :, 0] = y + 1.5748 * cr # R
|
||||
rgb[:, :, :, 1] = y - 0.1873 * cb - 0.4681 * cr # G
|
||||
rgb[:, :, :, 2] = y + 1.8556 * cb # B
|
||||
|
||||
return torch.clamp(rgb, 0, 1)
|
||||
|
||||
|
||||
def apply_gaussian_blur(tensor: torch.Tensor, kernel_size: int) -> torch.Tensor:
|
||||
"""
|
||||
Apply Gaussian blur to a tensor using PyTorch operations.
|
||||
|
||||
Args:
|
||||
tensor: Tensor of shape [B, H, W, C]
|
||||
kernel_size: Size of the Gaussian kernel (must be odd)
|
||||
|
||||
Returns:
|
||||
Blurred tensor of same shape
|
||||
"""
|
||||
if kernel_size <= 1:
|
||||
return tensor
|
||||
|
||||
# Ensure kernel size is odd
|
||||
kernel_size = kernel_size if kernel_size % 2 == 1 else kernel_size + 1
|
||||
|
||||
# Create Gaussian kernel
|
||||
sigma = kernel_size / 3.0
|
||||
x = torch.arange(kernel_size, dtype=torch.float32) - kernel_size // 2
|
||||
gauss = torch.exp(-x.pow(2) / (2 * sigma**2))
|
||||
gauss = gauss / gauss.sum()
|
||||
|
||||
# Create 2D kernel
|
||||
kernel = gauss.unsqueeze(0) * gauss.unsqueeze(1)
|
||||
kernel = kernel.unsqueeze(0).unsqueeze(0)
|
||||
|
||||
# Apply blur per channel
|
||||
batch_size, h, w, channels = tensor.shape
|
||||
tensor_reshaped = tensor.permute(0, 3, 1, 2) # [B, C, H, W]
|
||||
|
||||
# Expand kernel for all channels
|
||||
kernel = kernel.repeat(channels, 1, 1, 1)
|
||||
|
||||
# Apply convolution with padding
|
||||
padding = kernel_size // 2
|
||||
blurred = F.conv2d(tensor_reshaped, kernel, padding=padding, groups=channels)
|
||||
|
||||
return blurred.permute(0, 2, 3, 1) # Back to [B, H, W, C]
|
||||
|
||||
|
||||
def generate_grain_texture(
|
||||
batch_size: int, height: int, width: int, scale: float, seed: int
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
Generate base grain texture at specified scale.
|
||||
|
||||
Args:
|
||||
batch_size: Number of images in batch
|
||||
height: Target height
|
||||
width: Target width
|
||||
scale: Scale factor for grain size (larger = coarser grain)
|
||||
seed: Random seed for reproducibility
|
||||
|
||||
Returns:
|
||||
Grain texture tensor of shape [B, H/scale, W/scale, 3]
|
||||
"""
|
||||
torch.manual_seed(seed)
|
||||
|
||||
grain_height = max(1, int(height / scale))
|
||||
grain_width = max(1, int(width / scale))
|
||||
|
||||
# Generate random noise
|
||||
grain = torch.rand(batch_size, grain_height, grain_width, 3)
|
||||
|
||||
return grain
|
||||
|
||||
|
||||
def apply_film_grain(
|
||||
image: torch.Tensor,
|
||||
scale: float = 0.5,
|
||||
strength: float = 0.5,
|
||||
saturation: float = 0.7,
|
||||
toe: float = 0.0,
|
||||
seed: int = 0,
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
Apply film grain effect to an image with improved algorithms.
|
||||
|
||||
Improvements over original:
|
||||
- Better color space conversion using ITU-R BT.709 coefficients
|
||||
- More efficient Gaussian blur using PyTorch convolutions
|
||||
- Improved grain mixing with better channel weighting
|
||||
- Preserves alpha channel if present
|
||||
- Better memory efficiency
|
||||
|
||||
Args:
|
||||
image: Input tensor of shape [B, H, W, C] in range [0, 1]
|
||||
scale: Grain size (0.25-2.0, higher = coarser grain)
|
||||
strength: Grain intensity (0.0-10.0)
|
||||
saturation: Color saturation of grain (0.0-2.0)
|
||||
toe: Lift blacks/shadows (-0.2-0.5)
|
||||
seed: Random seed for reproducibility
|
||||
|
||||
Returns:
|
||||
Image with film grain applied
|
||||
"""
|
||||
if strength == 0.0:
|
||||
return image
|
||||
|
||||
# Handle empty batch
|
||||
if image.shape[0] == 0:
|
||||
return image
|
||||
|
||||
result = image.detach().clone()
|
||||
has_alpha = image.shape[-1] == 4
|
||||
|
||||
# Generate grain texture
|
||||
grain = generate_grain_texture(
|
||||
image.shape[0], image.shape[1], image.shape[2], scale, seed
|
||||
)
|
||||
|
||||
# Convert to YCbCr for better grain application
|
||||
grain_ycbcr = rgb_to_ycbcr(grain)
|
||||
|
||||
# Apply different blur kernels to each channel for more realistic grain
|
||||
# Y channel - fine detail
|
||||
grain_ycbcr[:, :, :, 0] = apply_gaussian_blur(
|
||||
grain_ycbcr[:, :, :, 0:1], kernel_size=3
|
||||
).squeeze(-1)
|
||||
|
||||
# Cb channel - medium blur for color noise
|
||||
grain_ycbcr[:, :, :, 1] = apply_gaussian_blur(
|
||||
grain_ycbcr[:, :, :, 1:2], kernel_size=15
|
||||
).squeeze(-1)
|
||||
|
||||
# Cr channel - slightly less blur
|
||||
grain_ycbcr[:, :, :, 2] = apply_gaussian_blur(
|
||||
grain_ycbcr[:, :, :, 2:3], kernel_size=11
|
||||
).squeeze(-1)
|
||||
|
||||
# Convert back to RGB
|
||||
grain = ycbcr_to_rgb(grain_ycbcr)
|
||||
|
||||
# Center grain around 0 and apply strength
|
||||
grain = (grain - 0.5) * strength
|
||||
|
||||
# Apply channel-specific weighting for more realistic film grain
|
||||
# Film grain is typically stronger in blue channel, moderate in red
|
||||
grain[:, :, :, 0] *= 2.0 # Red channel
|
||||
grain[:, :, :, 1] *= 1.0 # Green channel (reference)
|
||||
grain[:, :, :, 2] *= 3.0 # Blue channel
|
||||
|
||||
# Add 1 to make it multiplicative
|
||||
grain = grain + 1.0
|
||||
|
||||
# Apply saturation control
|
||||
# Extract luminance for desaturation mixing
|
||||
luminance = grain[:, :, :, 1:2] # Use green channel as approximation
|
||||
grain = grain * saturation + luminance * (1 - saturation)
|
||||
|
||||
# Interpolate grain to match image size if needed
|
||||
if grain.shape[1] != image.shape[1] or grain.shape[2] != image.shape[2]:
|
||||
grain = F.interpolate(
|
||||
grain.permute(0, 3, 1, 2),
|
||||
size=(image.shape[1], image.shape[2]),
|
||||
mode="bilinear",
|
||||
align_corners=False,
|
||||
).permute(0, 2, 3, 1)
|
||||
|
||||
# Apply grain using screen blend mode: 1 - (1 - image) * grain
|
||||
# This preserves highlights better than multiply
|
||||
if has_alpha:
|
||||
# Only apply to RGB channels
|
||||
result[:, :, :, :3] = 1 - (1 - result[:, :, :, :3]) * grain
|
||||
else:
|
||||
result = 1 - (1 - result[:, :, :, :3]) * grain
|
||||
|
||||
# Apply toe adjustment (lift blacks)
|
||||
if has_alpha:
|
||||
result[:, :, :, :3] = result[:, :, :, :3] * (1 - toe) + toe
|
||||
else:
|
||||
result = result * (1 - toe) + toe
|
||||
|
||||
# Ensure output is in valid range
|
||||
return torch.clamp(result, 0, 1)
|
||||
@@ -0,0 +1,123 @@
|
||||
import torch
|
||||
from typing import Dict, Any, Tuple
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
from .logic import apply_film_grain
|
||||
|
||||
|
||||
class KikoFilmGrainNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Apply realistic film grain effect to images.
|
||||
|
||||
This node simulates the grain patterns found in analog film photography.
|
||||
It provides controls for grain size, intensity, color saturation, and
|
||||
shadow lifting (toe) to achieve various film looks.
|
||||
|
||||
Improvements over reference implementation:
|
||||
- More efficient PyTorch-based blur operations
|
||||
- Better memory management for large batches
|
||||
- Preserves alpha channel when present
|
||||
- Improved grain mixing algorithm
|
||||
- ITU-R BT.709 color space conversion
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"scale": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.5,
|
||||
"min": 0.25,
|
||||
"max": 2.0,
|
||||
"step": 0.05,
|
||||
"display": "slider",
|
||||
"description": "Grain size - smaller values create finer grain",
|
||||
},
|
||||
),
|
||||
"strength": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.5,
|
||||
"min": 0.0,
|
||||
"max": 10.0,
|
||||
"step": 0.01,
|
||||
"display": "slider",
|
||||
"description": "Intensity of the grain effect",
|
||||
},
|
||||
),
|
||||
"saturation": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.7,
|
||||
"min": 0.0,
|
||||
"max": 2.0,
|
||||
"step": 0.01,
|
||||
"display": "slider",
|
||||
"description": "Color saturation of the grain (0=monochrome)",
|
||||
},
|
||||
),
|
||||
"toe": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.0,
|
||||
"min": -0.2,
|
||||
"max": 0.5,
|
||||
"step": 0.001,
|
||||
"display": "slider",
|
||||
"description": "Lift blacks/shadows for a film-like look",
|
||||
},
|
||||
),
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 0xFFFFFFFF, # 2**32 - 1
|
||||
"description": "Random seed for grain pattern generation",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "apply_grain"
|
||||
CATEGORY = "🫶 ComfyAssets/💾 Images"
|
||||
DESCRIPTION = "Apply realistic film grain effect with customizable parameters"
|
||||
|
||||
def apply_grain(
|
||||
self,
|
||||
image: torch.Tensor,
|
||||
scale: float,
|
||||
strength: float,
|
||||
saturation: float,
|
||||
toe: float,
|
||||
seed: int,
|
||||
) -> Tuple[torch.Tensor]:
|
||||
"""
|
||||
Apply film grain effect to the input image.
|
||||
|
||||
Args:
|
||||
image: Input image tensor [B, H, W, C]
|
||||
scale: Grain size factor (0.25-2.0)
|
||||
strength: Grain intensity (0.0-10.0)
|
||||
saturation: Color saturation of grain (0.0-2.0)
|
||||
toe: Shadow lifting amount (-0.2-0.5)
|
||||
seed: Random seed for reproducibility
|
||||
|
||||
Returns:
|
||||
Tuple containing the processed image tensor
|
||||
"""
|
||||
result = apply_film_grain(
|
||||
image=image,
|
||||
scale=scale,
|
||||
strength=strength,
|
||||
saturation=saturation,
|
||||
toe=toe,
|
||||
seed=seed,
|
||||
)
|
||||
|
||||
return (result,)
|
||||
@@ -0,0 +1,3 @@
|
||||
from .node import KikoPurgeVRAM
|
||||
|
||||
__all__ = ["KikoPurgeVRAM"]
|
||||
@@ -0,0 +1,130 @@
|
||||
import gc
|
||||
from typing import Dict, Tuple
|
||||
|
||||
try:
|
||||
import torch
|
||||
|
||||
TORCH_AVAILABLE = True
|
||||
except ImportError:
|
||||
TORCH_AVAILABLE = False
|
||||
|
||||
try:
|
||||
import comfy.model_management as mm
|
||||
|
||||
COMFY_AVAILABLE = True
|
||||
except ImportError:
|
||||
COMFY_AVAILABLE = False
|
||||
|
||||
|
||||
def get_memory_stats() -> Dict[str, float]:
|
||||
stats = {
|
||||
"cuda_available": False,
|
||||
"free_mb": 0,
|
||||
"total_mb": 0,
|
||||
"used_mb": 0,
|
||||
"used_percent": 0,
|
||||
}
|
||||
|
||||
if TORCH_AVAILABLE and torch.cuda.is_available():
|
||||
stats["cuda_available"] = True
|
||||
free, total = torch.cuda.mem_get_info()
|
||||
free_mb = free / (1024 * 1024)
|
||||
total_mb = total / (1024 * 1024)
|
||||
used_mb = total_mb - free_mb
|
||||
|
||||
stats["free_mb"] = free_mb
|
||||
stats["total_mb"] = total_mb
|
||||
stats["used_mb"] = used_mb
|
||||
stats["used_percent"] = (used_mb / total_mb) * 100 if total_mb > 0 else 0
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def purge_memory(mode: str = "soft", unload_models: bool = False) -> float:
|
||||
before_stats = get_memory_stats()
|
||||
|
||||
if mode == "soft":
|
||||
# Basic garbage collection and cache clearing
|
||||
gc.collect()
|
||||
if TORCH_AVAILABLE and torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
elif mode == "aggressive":
|
||||
# Multiple passes of garbage collection with full cache clearing
|
||||
gc.collect()
|
||||
gc.collect()
|
||||
if TORCH_AVAILABLE and torch.cuda.is_available():
|
||||
torch.cuda.synchronize()
|
||||
torch.cuda.empty_cache()
|
||||
torch.cuda.ipc_collect()
|
||||
|
||||
elif mode == "models_only":
|
||||
# Only unload models
|
||||
if COMFY_AVAILABLE:
|
||||
mm.unload_all_models()
|
||||
mm.soft_empty_cache()
|
||||
gc.collect()
|
||||
|
||||
elif mode == "cache_only":
|
||||
# Only clear cache without garbage collection
|
||||
if TORCH_AVAILABLE and torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
# Handle model unloading for non-model-specific modes
|
||||
if unload_models and mode not in ["models_only"]:
|
||||
if COMFY_AVAILABLE:
|
||||
mm.unload_all_models()
|
||||
mm.soft_empty_cache()
|
||||
|
||||
after_stats = get_memory_stats()
|
||||
freed_mb = before_stats["used_mb"] - after_stats["used_mb"]
|
||||
|
||||
return max(0, freed_mb)
|
||||
|
||||
|
||||
def format_memory_report(
|
||||
before: Dict[str, float], after: Dict[str, float], mode: str, elapsed_ms: float
|
||||
) -> str:
|
||||
if not before.get("cuda_available", True):
|
||||
return (
|
||||
"Memory Purge Report\n"
|
||||
"-------------------\n"
|
||||
"CUDA not available - CPU memory management only\n"
|
||||
f"Mode: {mode}\n"
|
||||
f"Time: {elapsed_ms:.1f}ms"
|
||||
)
|
||||
|
||||
freed_mb = before["used_mb"] - after["used_mb"]
|
||||
|
||||
report = [
|
||||
"Memory Purge Report",
|
||||
"-------------------",
|
||||
f"Mode: {mode}",
|
||||
f"Memory Freed: {freed_mb:.1f} MB",
|
||||
f"Before: {before['used_mb']:.1f} MB used ({before['used_percent']:.1f}%)",
|
||||
f"After: {after['used_mb']:.1f} MB used ({after['used_percent']:.1f}%)",
|
||||
f"Time: {elapsed_ms:.1f}ms",
|
||||
]
|
||||
|
||||
return "\n".join(report)
|
||||
|
||||
|
||||
def should_purge(threshold_mb: int) -> Tuple[bool, str]:
|
||||
if threshold_mb <= 0:
|
||||
return True, ""
|
||||
|
||||
stats = get_memory_stats()
|
||||
|
||||
if not stats["cuda_available"]:
|
||||
return True, "CUDA not available, proceeding with CPU memory management"
|
||||
|
||||
if stats["used_mb"] >= threshold_mb:
|
||||
return (
|
||||
True,
|
||||
f"Memory usage ({stats['used_mb']:.1f} MB) exceeds threshold ({threshold_mb} MB)",
|
||||
)
|
||||
else:
|
||||
return (
|
||||
False,
|
||||
f"Memory usage ({stats['used_mb']:.1f} MB) below threshold ({threshold_mb} MB)",
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
import time
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
try:
|
||||
from ...base.base_node import ComfyAssetsBaseNode as BaseNode
|
||||
from ...base.any_type import AnyType
|
||||
except ImportError:
|
||||
# Fallback for testing environment
|
||||
from kikotools.base.base_node import ComfyAssetsBaseNode as BaseNode
|
||||
from kikotools.base.any_type import AnyType
|
||||
from .logic import get_memory_stats, purge_memory, format_memory_report, should_purge
|
||||
|
||||
any_type = AnyType("*")
|
||||
|
||||
|
||||
class KikoPurgeVRAM(BaseNode):
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"required": {
|
||||
"anything": (any_type, {}),
|
||||
"mode": (
|
||||
["soft", "aggressive", "models_only", "cache_only"],
|
||||
{
|
||||
"default": "soft",
|
||||
"tooltip": "Purge mode: soft (basic), aggressive (thorough), models_only (unload models), cache_only (clear cache)",
|
||||
},
|
||||
),
|
||||
"report_memory": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Generate detailed memory usage report",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"memory_threshold_mb": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 48000,
|
||||
"step": 100,
|
||||
"tooltip": "Only purge if memory usage exceeds this threshold (0 = always purge)",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (any_type, "STRING")
|
||||
RETURN_NAMES = ("passthrough", "memory_report")
|
||||
FUNCTION = "purge_vram"
|
||||
CATEGORY = "🫶 ComfyAssets/🛠️ Utils"
|
||||
OUTPUT_NODE = True
|
||||
DESCRIPTION = "Purge VRAM to free up GPU memory during workflow execution. Passes through any input unchanged."
|
||||
|
||||
def purge_vram(
|
||||
self,
|
||||
anything: Any,
|
||||
mode: str,
|
||||
report_memory: bool,
|
||||
memory_threshold_mb: int = 0,
|
||||
) -> Tuple[Any, str]:
|
||||
# Check if we should purge based on threshold
|
||||
should_run, threshold_msg = should_purge(memory_threshold_mb)
|
||||
|
||||
if not should_run:
|
||||
if report_memory:
|
||||
return anything, f"Memory purge skipped: {threshold_msg}"
|
||||
else:
|
||||
return anything, ""
|
||||
|
||||
# Get before stats
|
||||
before_stats = get_memory_stats() if report_memory else None
|
||||
start_time = time.time()
|
||||
|
||||
# Determine if we should unload models
|
||||
unload_models = mode in ["models_only", "aggressive"]
|
||||
|
||||
# Perform memory purge
|
||||
purge_memory(mode=mode, unload_models=unload_models)
|
||||
|
||||
# Calculate elapsed time
|
||||
elapsed_ms = (time.time() - start_time) * 1000
|
||||
|
||||
# Generate report if requested
|
||||
if report_memory:
|
||||
after_stats = get_memory_stats()
|
||||
report = format_memory_report(before_stats, after_stats, mode, elapsed_ms)
|
||||
if threshold_msg and memory_threshold_mb > 0:
|
||||
report = f"{threshold_msg}\n\n{report}"
|
||||
else:
|
||||
report = ""
|
||||
|
||||
# Pass through the input unchanged
|
||||
return anything, report
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"KikoPurgeVRAM": KikoPurgeVRAM}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"KikoPurgeVRAM": "Kiko Purge VRAM"}
|
||||
@@ -10,7 +10,6 @@ from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
import torch
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
import time
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
@@ -22,9 +21,53 @@ except ImportError:
|
||||
return "./output"
|
||||
|
||||
|
||||
def get_next_counter(output_dir: str, prefix: str) -> int:
|
||||
"""
|
||||
Get next available counter value from persistent counter file
|
||||
|
||||
This prevents file overwrites when the node is called multiple times
|
||||
within the same second by maintaining a persistent counter.
|
||||
|
||||
Args:
|
||||
output_dir: Directory to store counter file
|
||||
prefix: Filename prefix to create unique counter per prefix
|
||||
|
||||
Returns:
|
||||
Next available counter value
|
||||
"""
|
||||
# Create a safe counter filename
|
||||
safe_prefix = "".join(c for c in prefix if c.isalnum() or c in "._-")
|
||||
counter_file = os.path.join(output_dir, f".{safe_prefix}_counter.txt")
|
||||
|
||||
# Read current counter
|
||||
counter = 0
|
||||
if os.path.exists(counter_file):
|
||||
try:
|
||||
with open(counter_file, "r") as f:
|
||||
content = f.read().strip()
|
||||
counter = int(content) if content else 0
|
||||
except (ValueError, IOError):
|
||||
# If file is corrupted or unreadable, start from 0
|
||||
counter = 0
|
||||
|
||||
# Increment counter
|
||||
counter += 1
|
||||
|
||||
# Save updated counter
|
||||
try:
|
||||
with open(counter_file, "w") as f:
|
||||
f.write(str(counter))
|
||||
except IOError:
|
||||
# If we can't write the counter file, continue anyway
|
||||
# Better to risk overwrites than to fail completely
|
||||
pass
|
||||
|
||||
return counter
|
||||
|
||||
|
||||
def get_save_image_path(
|
||||
filename_prefix: str,
|
||||
batch_number: int,
|
||||
counter: int,
|
||||
format_ext: str,
|
||||
output_dir: str,
|
||||
subfolder: str = "",
|
||||
@@ -34,13 +77,13 @@ def get_save_image_path(
|
||||
|
||||
Args:
|
||||
filename_prefix: Base filename prefix
|
||||
batch_number: Batch index for multiple images
|
||||
counter: Persistent counter to ensure unique filenames
|
||||
format_ext: File extension (.png, .jpg, .webp)
|
||||
output_dir: Output directory path
|
||||
subfolder: Optional subfolder within output directory
|
||||
|
||||
Returns:
|
||||
Tuple of (full_path, relative_filename)
|
||||
Tuple of (full_path, preview_filename, relative_subfolder)
|
||||
"""
|
||||
# Split filename_prefix into directory path and actual filename prefix
|
||||
# This allows for directory structures like "kittybear/anime/images/kittybear"
|
||||
@@ -53,9 +96,10 @@ def get_save_image_path(
|
||||
) # Only sanitize problematic chars for filenames
|
||||
safe_prefix = "".join(c for c in safe_prefix if c.isalnum() or c in "._-")
|
||||
|
||||
# Create unique filename with timestamp to avoid conflicts
|
||||
timestamp = int(time.time())
|
||||
filename = f"{safe_prefix}_{timestamp:010d}_{batch_number:05d}{format_ext}"
|
||||
# Create unique filename with counter to avoid conflicts
|
||||
# Using counter instead of timestamp+batch_number prevents overwrites
|
||||
# when multiple images are processed separately
|
||||
filename = f"{safe_prefix}_{counter:05d}{format_ext}"
|
||||
|
||||
# Handle subfolder and prefix directory (but not the filename part)
|
||||
path_components = []
|
||||
@@ -262,13 +306,17 @@ def process_image_batch(
|
||||
results = []
|
||||
enhanced_data = []
|
||||
|
||||
for batch_number, image_tensor in enumerate(images):
|
||||
for image_tensor in images:
|
||||
# Convert tensor to PIL Image
|
||||
img = convert_tensor_to_pil(image_tensor)
|
||||
|
||||
# Generate save path
|
||||
# Get next counter value to ensure unique filenames
|
||||
# This counter persists across node calls, preventing overwrites
|
||||
counter = get_next_counter(output_dir, filename_prefix)
|
||||
|
||||
# Generate save path with persistent counter
|
||||
filepath, preview_filename, relative_subfolder = get_save_image_path(
|
||||
filename_prefix, batch_number, format_ext, output_dir, ""
|
||||
filename_prefix, counter, format_ext, output_dir, ""
|
||||
)
|
||||
|
||||
# Save with format-specific settings
|
||||
|
||||
@@ -95,6 +95,7 @@ class KikoSaveImageNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
CATEGORY = "🫶 ComfyAssets/💾 Images"
|
||||
FUNCTION = "save_images"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
KikoWorkflow Timer - Display real-time execution timer for ComfyUI workflows.
|
||||
|
||||
Provides a visual timer that tracks workflow execution duration with
|
||||
millisecond precision.
|
||||
"""
|
||||
|
||||
from .node import KikoWorkflowTimerNode
|
||||
|
||||
__all__ = ["KikoWorkflowTimerNode"]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
KikoWorkflow Timer Node
|
||||
|
||||
A display-only node that shows real-time execution timing for ComfyUI workflows.
|
||||
The timer is managed entirely on the frontend via WebSocket events.
|
||||
"""
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
|
||||
|
||||
class KikoWorkflowTimerNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
A UI node that displays a real-time timer for workflow execution.
|
||||
|
||||
The timer starts when execution begins and stops when the workflow
|
||||
completes, showing the total elapsed time in MM:SS:mmm format.
|
||||
|
||||
This is a display-only node with no inputs or outputs - all timing
|
||||
logic is handled by the JavaScript frontend via WebSocket events.
|
||||
"""
|
||||
|
||||
DISPLAY_NAME = "Workflow Timer"
|
||||
CATEGORY = "🫶 ComfyAssets/🛠️ Utils"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "execute"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def execute(self, **kwargs):
|
||||
"""
|
||||
Execute method - returns empty since this is a display-only node.
|
||||
|
||||
The actual timer functionality is handled entirely by the JavaScript
|
||||
frontend which hooks into ComfyUI's WebSocket events.
|
||||
|
||||
Args:
|
||||
**kwargs: Hidden parameters (prompt, unique_id)
|
||||
|
||||
Returns:
|
||||
Empty dict - no outputs
|
||||
"""
|
||||
return {}
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Local Image Loader tool for KikoTools."""
|
||||
|
||||
from .node import LocalImageLoaderNode
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"KikoLocalImageLoader": LocalImageLoaderNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"KikoLocalImageLoader": "Local Image Loader",
|
||||
}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"last_path": "/home/vito/ai-apps/ComfyUI/output/vids",
|
||||
"saved_paths": [
|
||||
"/home/vito/ai-apps/ComfyUI-3.12/output/2025-05-01",
|
||||
"/home/vito/ai-apps/ComfyUI-3.12/output/",
|
||||
"/home/vito/Downloads/vito",
|
||||
"/home/vito/ai-apps/ComfyUI/output/2025-06-08",
|
||||
"/home/vito/ai-apps/ComfyUI/output",
|
||||
"/home/vito/Downloads",
|
||||
"/home/vito/Downloads/images"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Core logic for Local Image Loader."""
|
||||
|
||||
import os
|
||||
import json
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from typing import Tuple, Dict, Any, List
|
||||
|
||||
|
||||
def get_supported_extensions() -> Dict[str, List[str]]:
|
||||
"""Get supported file extensions by type."""
|
||||
return {
|
||||
"image": [".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp"],
|
||||
"video": [".mp4", ".webm", ".mov", ".mkv", ".avi"],
|
||||
"audio": [".mp3", ".wav", ".ogg", ".flac"],
|
||||
}
|
||||
|
||||
|
||||
def load_image_from_path(path: str) -> Tuple[torch.Tensor, Dict[str, Any]]:
|
||||
"""
|
||||
Load an image from the given path and convert it to a tensor.
|
||||
|
||||
Args:
|
||||
path: Path to the image file
|
||||
|
||||
Returns:
|
||||
Tuple of (image tensor, metadata dict)
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"File not found: {path}")
|
||||
|
||||
with Image.open(path) as img:
|
||||
# Convert to appropriate format
|
||||
if "A" in img.getbands():
|
||||
img_out = img.convert("RGBA")
|
||||
else:
|
||||
img_out = img.convert("RGB")
|
||||
|
||||
# Convert to tensor
|
||||
img_array = np.array(img_out).astype(np.float32) / 255.0
|
||||
image_tensor = torch.from_numpy(img_array)[None,]
|
||||
|
||||
# Collect metadata
|
||||
metadata = {
|
||||
"filename": os.path.basename(path),
|
||||
"width": img.width,
|
||||
"height": img.height,
|
||||
"mode": img.mode,
|
||||
"format": img.format,
|
||||
}
|
||||
|
||||
# Check for embedded metadata
|
||||
if "parameters" in img.info:
|
||||
metadata["parameters"] = img.info["parameters"]
|
||||
if "prompt" in img.info:
|
||||
try:
|
||||
metadata["prompt"] = json.loads(img.info["prompt"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
metadata["prompt"] = img.info["prompt"]
|
||||
if "workflow" in img.info:
|
||||
try:
|
||||
metadata["workflow"] = json.loads(img.info["workflow"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
metadata["workflow"] = img.info["workflow"]
|
||||
|
||||
return image_tensor, metadata
|
||||
|
||||
|
||||
def scan_directory(
|
||||
directory: str,
|
||||
show_videos: bool = False,
|
||||
show_audio: bool = False,
|
||||
sort_by: str = "name",
|
||||
sort_order: str = "asc",
|
||||
hide_dot_folders: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Scan a directory for supported media files.
|
||||
|
||||
Args:
|
||||
directory: Directory path to scan
|
||||
show_videos: Include video files
|
||||
show_audio: Include audio files
|
||||
sort_by: Sort criteria ('name', 'date', 'size')
|
||||
sort_order: Sort order ('asc', 'desc')
|
||||
hide_dot_folders: Hide folders starting with a dot
|
||||
|
||||
Returns:
|
||||
List of file information dictionaries
|
||||
"""
|
||||
if not os.path.isdir(directory):
|
||||
raise NotADirectoryError(f"Not a directory: {directory}")
|
||||
|
||||
extensions = get_supported_extensions()
|
||||
items = []
|
||||
|
||||
for item in os.listdir(directory):
|
||||
# Skip dot folders/files if hide_dot_folders is enabled
|
||||
if hide_dot_folders and item.startswith("."):
|
||||
continue
|
||||
|
||||
full_path = os.path.join(directory, item)
|
||||
|
||||
try:
|
||||
stats = os.stat(full_path)
|
||||
item_data = {
|
||||
"path": full_path,
|
||||
"name": item,
|
||||
"mtime": stats.st_mtime,
|
||||
"size": stats.st_size,
|
||||
}
|
||||
|
||||
if os.path.isdir(full_path):
|
||||
items.append({**item_data, "type": "dir"})
|
||||
else:
|
||||
ext = os.path.splitext(item)[1].lower()
|
||||
item_type = None
|
||||
|
||||
if ext in extensions["image"]:
|
||||
item_type = "image"
|
||||
elif show_videos and ext in extensions["video"]:
|
||||
item_type = "video"
|
||||
elif show_audio and ext in extensions["audio"]:
|
||||
item_type = "audio"
|
||||
|
||||
if item_type:
|
||||
items.append({**item_data, "type": item_type})
|
||||
|
||||
except (PermissionError, FileNotFoundError):
|
||||
continue
|
||||
|
||||
# Sort items
|
||||
reverse = sort_order == "desc"
|
||||
if sort_by == "date":
|
||||
items.sort(key=lambda x: x["mtime"], reverse=reverse)
|
||||
elif sort_by == "size":
|
||||
items.sort(key=lambda x: x.get("size", 0), reverse=reverse)
|
||||
else: # name
|
||||
items.sort(key=lambda x: x["name"].lower(), reverse=reverse)
|
||||
|
||||
# Directories first
|
||||
items.sort(key=lambda x: x["type"] != "dir")
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def search_files(
|
||||
root_directory: str,
|
||||
query: str,
|
||||
show_videos: bool = False,
|
||||
show_audio: bool = False,
|
||||
max_results: int = 100,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Recursively search for files matching the query.
|
||||
|
||||
Args:
|
||||
root_directory: Root directory to start search
|
||||
query: Search query (case-insensitive filename match)
|
||||
show_videos: Include video files
|
||||
show_audio: Include audio files
|
||||
max_results: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of file information dictionaries
|
||||
"""
|
||||
if not os.path.isdir(root_directory):
|
||||
raise NotADirectoryError(f"Not a directory: {root_directory}")
|
||||
|
||||
if not query or len(query.strip()) == 0:
|
||||
return []
|
||||
|
||||
extensions = get_supported_extensions()
|
||||
results = []
|
||||
query_lower = query.lower().strip()
|
||||
|
||||
def search_recursive(directory: str) -> None:
|
||||
"""Recursively search directory."""
|
||||
if len(results) >= max_results:
|
||||
return
|
||||
|
||||
try:
|
||||
items = os.listdir(directory)
|
||||
except (PermissionError, FileNotFoundError):
|
||||
return
|
||||
|
||||
for item in items:
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
|
||||
full_path = os.path.join(directory, item)
|
||||
|
||||
try:
|
||||
# Check if item name matches query
|
||||
if query_lower not in item.lower():
|
||||
# If directory, search inside
|
||||
if os.path.isdir(full_path):
|
||||
search_recursive(full_path)
|
||||
continue
|
||||
|
||||
stats = os.stat(full_path)
|
||||
item_data = {
|
||||
"path": full_path,
|
||||
"name": item,
|
||||
"directory": directory,
|
||||
"mtime": stats.st_mtime,
|
||||
"size": stats.st_size,
|
||||
}
|
||||
|
||||
if os.path.isdir(full_path):
|
||||
results.append({**item_data, "type": "dir"})
|
||||
# Continue searching inside matching directories
|
||||
search_recursive(full_path)
|
||||
else:
|
||||
ext = os.path.splitext(item)[1].lower()
|
||||
item_type = None
|
||||
|
||||
if ext in extensions["image"]:
|
||||
item_type = "image"
|
||||
elif show_videos and ext in extensions["video"]:
|
||||
item_type = "video"
|
||||
elif show_audio and ext in extensions["audio"]:
|
||||
item_type = "audio"
|
||||
|
||||
if item_type:
|
||||
results.append({**item_data, "type": item_type})
|
||||
|
||||
except (PermissionError, FileNotFoundError):
|
||||
continue
|
||||
|
||||
search_recursive(root_directory)
|
||||
|
||||
# Sort by name
|
||||
results.sort(key=lambda x: x["name"].lower())
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def create_empty_tensor() -> torch.Tensor:
|
||||
"""Create an empty tensor for when no image is selected."""
|
||||
return torch.zeros(1, 1, 1, 4)
|
||||
@@ -0,0 +1,363 @@
|
||||
"""Local Image Loader node for ComfyUI."""
|
||||
|
||||
import os
|
||||
import json
|
||||
import torch
|
||||
from typing import Dict, Any, Tuple
|
||||
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import load_image_from_path, create_empty_tensor
|
||||
|
||||
NODE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
SELECTIONS_FILE = os.path.join(NODE_DIR, "selections.json")
|
||||
CONFIG_FILE = os.path.join(NODE_DIR, "config.json")
|
||||
|
||||
|
||||
def load_selections() -> Dict[str, Any]:
|
||||
"""Load node selections from file."""
|
||||
if not os.path.exists(SELECTIONS_FILE):
|
||||
return {}
|
||||
try:
|
||||
with open(SELECTIONS_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return {}
|
||||
|
||||
|
||||
def save_selections(data: Dict[str, Any]) -> None:
|
||||
"""Save node selections to file."""
|
||||
try:
|
||||
with open(SELECTIONS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
print(f"KikoLocalImageLoader: Error saving selections: {e}")
|
||||
|
||||
|
||||
def load_config() -> Dict[str, Any]:
|
||||
"""Load configuration from file."""
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def save_config(data: Dict[str, Any]) -> None:
|
||||
"""Save configuration to file."""
|
||||
try:
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
except Exception as e:
|
||||
print(f"KikoLocalImageLoader: Error saving config: {e}")
|
||||
|
||||
|
||||
class LocalImageLoaderNode(ComfyAssetsBaseNode):
|
||||
"""Node for loading images from local filesystem with a visual gallery interface."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
"""Define input types for the node."""
|
||||
return {
|
||||
"required": {},
|
||||
"hidden": {"unique_id": "UNIQUE_ID"},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (
|
||||
"IMAGE",
|
||||
"STRING",
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"image",
|
||||
"info",
|
||||
)
|
||||
FUNCTION = "load_media"
|
||||
CATEGORY = "🫶 ComfyAssets/💾 Images"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""Check if node state has changed."""
|
||||
if os.path.exists(SELECTIONS_FILE):
|
||||
return os.path.getmtime(SELECTIONS_FILE)
|
||||
return float("inf")
|
||||
|
||||
def load_media(self, unique_id: str) -> Tuple[torch.Tensor, str]:
|
||||
"""
|
||||
Load selected media based on node's unique ID.
|
||||
|
||||
Args:
|
||||
unique_id: Unique identifier for this node instance
|
||||
|
||||
Returns:
|
||||
Tuple of (image tensor, info string)
|
||||
"""
|
||||
image_tensor = create_empty_tensor()
|
||||
info_string = ""
|
||||
|
||||
selections = load_selections()
|
||||
node_selections = selections.get(str(unique_id), {})
|
||||
|
||||
# Load image if selected
|
||||
image_selection = node_selections.get("image")
|
||||
if image_selection and image_selection.get("path"):
|
||||
image_path = image_selection["path"]
|
||||
if os.path.exists(image_path):
|
||||
try:
|
||||
image_tensor, metadata = load_image_from_path(image_path)
|
||||
info_string = json.dumps(metadata, indent=4, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
print(f"KikoLocalImageLoader: Error loading image: {e}")
|
||||
|
||||
return (image_tensor, info_string)
|
||||
|
||||
|
||||
# Setup API routes
|
||||
try:
|
||||
import server
|
||||
from aiohttp import web
|
||||
import urllib.parse
|
||||
import io
|
||||
from PIL import Image
|
||||
from .logic import scan_directory
|
||||
|
||||
prompt_server = server.PromptServer.instance
|
||||
|
||||
@prompt_server.routes.post("/kiko_local_image_loader/set_node_selection")
|
||||
async def set_node_selection(request):
|
||||
"""API endpoint to set node selection."""
|
||||
try:
|
||||
data = await request.json()
|
||||
node_id = str(data.get("node_id"))
|
||||
path = data.get("path")
|
||||
media_type = data.get("type")
|
||||
|
||||
if not all([node_id, path, media_type]):
|
||||
return web.json_response(
|
||||
{"status": "error", "message": "Missing required data."}, status=400
|
||||
)
|
||||
|
||||
selections = load_selections()
|
||||
if node_id not in selections:
|
||||
selections[node_id] = {}
|
||||
|
||||
selections[node_id][media_type] = {"path": path}
|
||||
save_selections(selections)
|
||||
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
@prompt_server.routes.get("/kiko_local_image_loader/get_saved_paths")
|
||||
async def get_saved_paths(request):
|
||||
"""API endpoint to get saved directory paths."""
|
||||
config = load_config()
|
||||
return web.json_response({"saved_paths": config.get("saved_paths", [])})
|
||||
|
||||
@prompt_server.routes.post("/kiko_local_image_loader/save_paths")
|
||||
async def save_paths(request):
|
||||
"""API endpoint to save directory paths."""
|
||||
try:
|
||||
data = await request.json()
|
||||
paths = data.get("paths", [])
|
||||
config = load_config()
|
||||
config["saved_paths"] = paths
|
||||
save_config(config)
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
@prompt_server.routes.get("/kiko_local_image_loader/images")
|
||||
async def get_local_images(request):
|
||||
"""API endpoint to get images from a directory."""
|
||||
directory = request.query.get("directory", "")
|
||||
|
||||
if not directory or not os.path.isdir(directory):
|
||||
return web.json_response({"error": "Directory not found."}, status=404)
|
||||
|
||||
# Normalize path to remove trailing slashes and resolve relative paths
|
||||
directory = os.path.normpath(directory)
|
||||
|
||||
# Save last path
|
||||
config = load_config()
|
||||
config["last_path"] = directory
|
||||
save_config(config)
|
||||
|
||||
show_videos = request.query.get("show_videos", "false").lower() == "true"
|
||||
show_audio = request.query.get("show_audio", "false").lower() == "true"
|
||||
hide_dot_folders = (
|
||||
request.query.get("hide_dot_folders", "true").lower() == "true"
|
||||
)
|
||||
|
||||
page = int(request.query.get("page", 1))
|
||||
per_page = int(request.query.get("per_page", 50))
|
||||
sort_by = request.query.get("sort_by", "name")
|
||||
sort_order = request.query.get("sort_order", "asc")
|
||||
|
||||
try:
|
||||
items = scan_directory(
|
||||
directory,
|
||||
show_videos,
|
||||
show_audio,
|
||||
sort_by,
|
||||
sort_order,
|
||||
hide_dot_folders,
|
||||
)
|
||||
|
||||
# Get parent directory
|
||||
parent_directory = os.path.dirname(directory)
|
||||
if parent_directory == directory:
|
||||
parent_directory = None
|
||||
|
||||
# Paginate results
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
paginated_items = items[start:end]
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"items": paginated_items,
|
||||
"total_pages": (len(items) + per_page - 1) // per_page,
|
||||
"current_page": page,
|
||||
"current_directory": directory,
|
||||
"parent_directory": parent_directory,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@prompt_server.routes.get("/kiko_local_image_loader/get_last_path")
|
||||
async def get_last_path(request):
|
||||
"""API endpoint to get last used directory path."""
|
||||
return web.json_response({"last_path": load_config().get("last_path", "")})
|
||||
|
||||
@prompt_server.routes.get("/kiko_local_image_loader/list_directories")
|
||||
async def list_directories(request):
|
||||
"""API endpoint to list directories for autocomplete."""
|
||||
path = request.query.get("path", "")
|
||||
|
||||
try:
|
||||
# Handle empty path - show root or common starting points
|
||||
if not path:
|
||||
# Return filesystem root
|
||||
if os.name == "nt": # Windows
|
||||
import string
|
||||
|
||||
drives = [
|
||||
f"{d}:\\"
|
||||
for d in string.ascii_uppercase
|
||||
if os.path.exists(f"{d}:\\")
|
||||
]
|
||||
return web.json_response({"directories": drives})
|
||||
else: # Unix/Linux/Mac
|
||||
return web.json_response({"directories": ["/"]})
|
||||
|
||||
# Normalize the path
|
||||
path = os.path.expanduser(path) # Handle ~ for home directory
|
||||
|
||||
# If path ends with separator, list contents of that directory
|
||||
if path.endswith(os.sep) or (os.name == "nt" and path.endswith("/")):
|
||||
if os.path.isdir(path):
|
||||
try:
|
||||
entries = os.listdir(path)
|
||||
dirs = []
|
||||
for entry in entries:
|
||||
full_path = os.path.join(path, entry)
|
||||
if os.path.isdir(full_path):
|
||||
dirs.append(full_path)
|
||||
dirs.sort(key=lambda x: x.lower())
|
||||
return web.json_response(
|
||||
{"directories": dirs[:50]}
|
||||
) # Limit results
|
||||
except PermissionError:
|
||||
return web.json_response(
|
||||
{"directories": [], "error": "Permission denied"}
|
||||
)
|
||||
else:
|
||||
return web.json_response({"directories": []})
|
||||
|
||||
# Otherwise, find matching directories in parent
|
||||
parent_dir = os.path.dirname(path)
|
||||
basename = os.path.basename(path).lower()
|
||||
|
||||
if not parent_dir:
|
||||
# Handle root level on Unix
|
||||
if path.startswith("/"):
|
||||
parent_dir = "/"
|
||||
basename = path[1:].lower()
|
||||
else:
|
||||
return web.json_response({"directories": []})
|
||||
|
||||
if os.path.isdir(parent_dir):
|
||||
try:
|
||||
entries = os.listdir(parent_dir)
|
||||
dirs = []
|
||||
for entry in entries:
|
||||
full_path = os.path.join(parent_dir, entry)
|
||||
if os.path.isdir(full_path) and entry.lower().startswith(
|
||||
basename
|
||||
):
|
||||
dirs.append(full_path)
|
||||
dirs.sort(key=lambda x: x.lower())
|
||||
return web.json_response(
|
||||
{"directories": dirs[:50]}
|
||||
) # Limit results
|
||||
except PermissionError:
|
||||
return web.json_response(
|
||||
{"directories": [], "error": "Permission denied"}
|
||||
)
|
||||
|
||||
return web.json_response({"directories": []})
|
||||
except Exception as e:
|
||||
return web.json_response({"directories": [], "error": str(e)})
|
||||
|
||||
@prompt_server.routes.get("/kiko_local_image_loader/thumbnail")
|
||||
async def get_thumbnail(request):
|
||||
"""API endpoint to get image thumbnail."""
|
||||
filepath = request.query.get("filepath")
|
||||
if not filepath or ".." in filepath:
|
||||
return web.Response(status=400)
|
||||
|
||||
filepath = urllib.parse.unquote(filepath)
|
||||
if not os.path.exists(filepath):
|
||||
return web.Response(status=404)
|
||||
|
||||
try:
|
||||
img = Image.open(filepath)
|
||||
has_alpha = img.mode == "RGBA" or (
|
||||
img.mode == "P" and "transparency" in img.info
|
||||
)
|
||||
img = img.convert("RGBA") if has_alpha else img.convert("RGB")
|
||||
img.thumbnail([320, 320], Image.LANCZOS)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
format, content_type = (
|
||||
("PNG", "image/png") if has_alpha else ("JPEG", "image/jpeg")
|
||||
)
|
||||
img.save(buffer, format=format, quality=90 if format == "JPEG" else None)
|
||||
buffer.seek(0)
|
||||
|
||||
return web.Response(body=buffer.read(), content_type=content_type)
|
||||
except Exception as e:
|
||||
print(f"KikoLocalImageLoader: Error generating thumbnail: {e}")
|
||||
return web.Response(status=500)
|
||||
|
||||
@prompt_server.routes.get("/kiko_local_image_loader/view")
|
||||
async def view_image(request):
|
||||
"""API endpoint to view full image."""
|
||||
filepath = request.query.get("filepath")
|
||||
if not filepath or ".." in filepath:
|
||||
return web.Response(status=400)
|
||||
|
||||
filepath = urllib.parse.unquote(filepath)
|
||||
if not os.path.exists(filepath):
|
||||
return web.Response(status=404)
|
||||
|
||||
try:
|
||||
return web.FileResponse(filepath)
|
||||
except Exception:
|
||||
return web.Response(status=500)
|
||||
|
||||
except ImportError:
|
||||
# Server not available during testing
|
||||
pass
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"57": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI-3.12/output/2025-05-01/HiDream_00001_.png"
|
||||
}
|
||||
},
|
||||
"58": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI-3.12/output/CharacterName_00016_.png"
|
||||
}
|
||||
},
|
||||
"18": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00005.png"
|
||||
}
|
||||
},
|
||||
"445": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/ComfyUI_00002_.png"
|
||||
}
|
||||
},
|
||||
"23": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00098_.png"
|
||||
}
|
||||
},
|
||||
"69": {
|
||||
"image": {
|
||||
"path": "/home/vito/Downloads/KikoSave_00086.png"
|
||||
}
|
||||
},
|
||||
"52": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/kiko/XXX/images/kiko_00038_.png"
|
||||
}
|
||||
},
|
||||
"38": {
|
||||
"image": {
|
||||
"path": "/home/vito/Downloads/vito/IMG_20160422_163419.jpg"
|
||||
}
|
||||
},
|
||||
"170": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00326_.png"
|
||||
}
|
||||
},
|
||||
"214": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00329_.png"
|
||||
}
|
||||
},
|
||||
"299": {
|
||||
"image": {
|
||||
"path": "/home/vito/Downloads/images/KikoSave_00016.png"
|
||||
}
|
||||
},
|
||||
"527": {
|
||||
"image": {
|
||||
"path": "/home/vito/Downloads/ComfyUI_temp_sktzg_00012_.png"
|
||||
}
|
||||
},
|
||||
"522": {
|
||||
"image": {
|
||||
"path": "/home/vito/Downloads/KikoSave_00086.png"
|
||||
}
|
||||
},
|
||||
"517": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00005.png"
|
||||
}
|
||||
},
|
||||
"144": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/2025-04-24/ComfyUI_00002_.png"
|
||||
}
|
||||
},
|
||||
"569": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00008.png"
|
||||
}
|
||||
},
|
||||
"136": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00005.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Model Downloader Tool for ComfyUI-KikoTools
|
||||
|
||||
Downloads models from CivitAI, HuggingFace, and custom URLs.
|
||||
"""
|
||||
|
||||
from .node import ModelDownloaderNode
|
||||
|
||||
__all__ = ["ModelDownloaderNode"]
|
||||
|
||||
# Node registration
|
||||
NODE_CLASS_MAPPINGS = {"KikoModelDownloader": ModelDownloaderNode}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"KikoModelDownloader": "Model Downloader 🌐"}
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Base downloader class with common functionality"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable
|
||||
from urllib.parse import urlparse, unquote
|
||||
import os
|
||||
|
||||
try:
|
||||
import comfy.model_management
|
||||
|
||||
COMFY_AVAILABLE = True
|
||||
except ImportError:
|
||||
COMFY_AVAILABLE = False
|
||||
|
||||
|
||||
class BaseDownloader(ABC):
|
||||
"""Abstract base class for all downloaders"""
|
||||
|
||||
def __init__(self, token: Optional[str] = None):
|
||||
"""Initialize downloader with optional API token
|
||||
|
||||
Args:
|
||||
token: Optional API token for authentication
|
||||
"""
|
||||
self.token = token
|
||||
self._progress_callback: Optional[Callable[[int, int, str], None]] = None
|
||||
|
||||
def set_progress_callback(self, callback: Callable[[int, int, str], None]) -> None:
|
||||
"""Set callback function for progress updates
|
||||
|
||||
Args:
|
||||
callback: Function(downloaded_bytes, total_bytes, message)
|
||||
"""
|
||||
self._progress_callback = callback
|
||||
|
||||
def report_progress(self, downloaded: int, total: int, message: str = "") -> None:
|
||||
"""Report download progress to callback
|
||||
|
||||
Args:
|
||||
downloaded: Bytes downloaded so far
|
||||
total: Total bytes to download
|
||||
message: Optional status message
|
||||
"""
|
||||
if self._progress_callback:
|
||||
self._progress_callback(downloaded, total, message)
|
||||
|
||||
def check_interrupt(self) -> None:
|
||||
"""Check if processing has been interrupted by user
|
||||
|
||||
Raises:
|
||||
comfy.model_management.InterruptProcessingException: If user cancelled
|
||||
"""
|
||||
if COMFY_AVAILABLE:
|
||||
comfy.model_management.throw_exception_if_processing_interrupted()
|
||||
|
||||
def extract_filename(self, url: str, default: str = "downloaded_file") -> str:
|
||||
"""Extract filename from URL
|
||||
|
||||
Args:
|
||||
url: URL to extract filename from
|
||||
default: Default filename if extraction fails
|
||||
|
||||
Returns:
|
||||
Extracted or default filename
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
path = unquote(parsed.path)
|
||||
filename = os.path.basename(path)
|
||||
|
||||
# Remove query parameters from filename
|
||||
if "?" in filename:
|
||||
filename = filename.split("?")[0]
|
||||
|
||||
# Validate filename
|
||||
if filename and len(filename) > 0 and "." in filename:
|
||||
return filename
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return default
|
||||
|
||||
def extract_filename_from_header(self, content_disposition: str) -> Optional[str]:
|
||||
"""Extract filename from Content-Disposition header
|
||||
|
||||
Args:
|
||||
content_disposition: Content-Disposition header value
|
||||
|
||||
Returns:
|
||||
Extracted filename or None
|
||||
"""
|
||||
try:
|
||||
if "filename=" in content_disposition:
|
||||
filename = content_disposition.split("filename=")[1]
|
||||
# Remove quotes and whitespace
|
||||
filename = filename.strip().strip('"').strip("'")
|
||||
return filename
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def validate_output_path(self, output_path: str) -> bool:
|
||||
"""Validate and create output path if needed
|
||||
|
||||
Args:
|
||||
output_path: Directory path to validate
|
||||
|
||||
Returns:
|
||||
True if valid
|
||||
|
||||
Raises:
|
||||
ValueError: If path exists but is not a directory
|
||||
"""
|
||||
path = Path(output_path)
|
||||
|
||||
if path.exists():
|
||||
if not path.is_dir():
|
||||
raise ValueError(
|
||||
f"Output path {output_path} exists but is not a directory"
|
||||
)
|
||||
return True
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return True
|
||||
|
||||
def should_download(self, file_path: str, force: bool = False) -> bool:
|
||||
"""Check if file should be downloaded
|
||||
|
||||
Args:
|
||||
file_path: Full path to file
|
||||
force: Force download even if file exists
|
||||
|
||||
Returns:
|
||||
True if should download, False if file exists and force=False
|
||||
"""
|
||||
if force:
|
||||
return True
|
||||
|
||||
return not Path(file_path).exists()
|
||||
|
||||
def format_size(self, size_bytes: int) -> str:
|
||||
"""Format file size in human-readable format
|
||||
|
||||
Args:
|
||||
size_bytes: Size in bytes
|
||||
|
||||
Returns:
|
||||
Formatted size string (e.g., "5.00 MB")
|
||||
"""
|
||||
for unit in ["B", "KB", "MB", "GB"]:
|
||||
if size_bytes < 1024.0:
|
||||
return f"{size_bytes:.2f} {unit}"
|
||||
size_bytes /= 1024.0
|
||||
return f"{size_bytes:.2f} TB"
|
||||
|
||||
def calculate_speed(self, bytes_downloaded: int, elapsed_seconds: float) -> float:
|
||||
"""Calculate download speed in MB/s
|
||||
|
||||
Args:
|
||||
bytes_downloaded: Number of bytes downloaded
|
||||
elapsed_seconds: Time elapsed in seconds
|
||||
|
||||
Returns:
|
||||
Download speed in MB/s
|
||||
"""
|
||||
if elapsed_seconds <= 0:
|
||||
return 0.0
|
||||
|
||||
mb_downloaded = bytes_downloaded / (1024 * 1024)
|
||||
return mb_downloaded / elapsed_seconds
|
||||
|
||||
@abstractmethod
|
||||
def download(
|
||||
self,
|
||||
url: str,
|
||||
output_path: str,
|
||||
filename: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> str:
|
||||
"""Download file from URL
|
||||
|
||||
Args:
|
||||
url: URL to download from
|
||||
output_path: Directory to save file
|
||||
filename: Optional filename override
|
||||
force: Force re-download if file exists
|
||||
|
||||
Returns:
|
||||
Path to downloaded file
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Must be implemented by subclass
|
||||
"""
|
||||
raise NotImplementedError("Subclasses must implement download()")
|
||||
@@ -0,0 +1,341 @@
|
||||
"""CivitAI downloader implementation"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
from typing import Optional, Dict, Any
|
||||
from urllib.parse import urlparse, parse_qs, unquote
|
||||
|
||||
from .base import BaseDownloader
|
||||
|
||||
try:
|
||||
import comfy.model_management
|
||||
|
||||
COMFY_AVAILABLE = True
|
||||
InterruptProcessingException = comfy.model_management.InterruptProcessingException
|
||||
except ImportError:
|
||||
COMFY_AVAILABLE = False
|
||||
# Fallback exception type that will never be raised
|
||||
InterruptProcessingException = type(
|
||||
"InterruptProcessingException", (Exception,), {}
|
||||
)
|
||||
|
||||
|
||||
CHUNK_SIZE = 1638400
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
API_BASE = "https://civitai.com/api/v1"
|
||||
MAX_RETRIES = 3
|
||||
RETRY_DELAY = 5
|
||||
|
||||
|
||||
class CivitAIDownloader(BaseDownloader):
|
||||
"""Downloader for CivitAI models"""
|
||||
|
||||
def __init__(self, token: Optional[str] = None):
|
||||
"""Initialize CivitAI downloader
|
||||
|
||||
Args:
|
||||
token: Optional CivitAI API token
|
||||
"""
|
||||
super().__init__(token)
|
||||
|
||||
def _make_request(
|
||||
self, url: str, headers: Optional[Dict[str, str]] = None
|
||||
) -> urllib.request.Request:
|
||||
"""Create HTTP request with authentication
|
||||
|
||||
Args:
|
||||
url: URL to request
|
||||
headers: Optional additional headers
|
||||
|
||||
Returns:
|
||||
urllib Request object
|
||||
"""
|
||||
if headers is None:
|
||||
headers = {}
|
||||
|
||||
headers["User-Agent"] = USER_AGENT
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
|
||||
return urllib.request.Request(url, headers=headers)
|
||||
|
||||
def _parse_civitai_url(self, url: str) -> Dict[str, Optional[int]]:
|
||||
"""Extract model and version IDs from CivitAI URL
|
||||
|
||||
Args:
|
||||
url: CivitAI URL to parse
|
||||
|
||||
Returns:
|
||||
Dict with 'model_id' and 'version_id' keys
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
result = {"model_id": None, "version_id": None}
|
||||
|
||||
# Handle different URL patterns
|
||||
# 1. Direct API download URL: /api/download/models/123456
|
||||
if "/api/download/models/" in url:
|
||||
match = url.split("/api/download/models/")[-1].split("?")[0]
|
||||
if match.isdigit():
|
||||
result["version_id"] = int(match)
|
||||
return result
|
||||
|
||||
# 2. Model page URL: /models/123456 or /models/123456/model-name
|
||||
if "/models/" in url:
|
||||
parts = parsed.path.split("/")
|
||||
if "models" in parts:
|
||||
idx = parts.index("models")
|
||||
if idx + 1 < len(parts) and parts[idx + 1].isdigit():
|
||||
result["model_id"] = int(parts[idx + 1])
|
||||
|
||||
# 3. Version specific URL with ?modelVersionId=789012
|
||||
query_params = parse_qs(parsed.query)
|
||||
if "modelVersionId" in query_params:
|
||||
version_id = query_params["modelVersionId"][0]
|
||||
if version_id.isdigit():
|
||||
result["version_id"] = int(version_id)
|
||||
|
||||
return result
|
||||
|
||||
def get_model_details(self, model_id: int) -> Dict[str, Any]:
|
||||
"""Get model details from API
|
||||
|
||||
Args:
|
||||
model_id: CivitAI model ID
|
||||
|
||||
Returns:
|
||||
Model details dictionary
|
||||
|
||||
Raises:
|
||||
Exception: If API request fails
|
||||
"""
|
||||
url = f"{API_BASE}/models/{model_id}"
|
||||
request = self._make_request(url)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request) as response:
|
||||
return json.loads(response.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
raise Exception(f"Model {model_id} not found")
|
||||
raise Exception(f"API request failed: {e}")
|
||||
|
||||
def download(
|
||||
self,
|
||||
url: str,
|
||||
output_path: str,
|
||||
filename: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> str:
|
||||
"""Download file from CivitAI
|
||||
|
||||
Args:
|
||||
url: CivitAI URL to download
|
||||
output_path: Directory to save file
|
||||
filename: Optional filename override
|
||||
force: Force re-download if file exists
|
||||
|
||||
Returns:
|
||||
Path to downloaded file
|
||||
|
||||
Raises:
|
||||
Exception: If download fails
|
||||
"""
|
||||
# Validate output path
|
||||
self.validate_output_path(output_path)
|
||||
|
||||
# Validate that URL is from civitai.com domain
|
||||
parsed_url = urlparse(url)
|
||||
if parsed_url.netloc not in ("civitai.com", "www.civitai.com"):
|
||||
raise ValueError(
|
||||
f"Invalid URL: Only civitai.com URLs are supported, got {parsed_url.netloc}"
|
||||
)
|
||||
|
||||
# Convert web URL to API URL if needed
|
||||
if "/api/download/models/" not in url:
|
||||
ids = self._parse_civitai_url(url)
|
||||
|
||||
# If we have a version ID, use it directly
|
||||
if ids["version_id"]:
|
||||
url = f"https://civitai.com/api/download/models/{ids['version_id']}"
|
||||
# If we only have a model ID, get the latest version
|
||||
elif ids["model_id"]:
|
||||
try:
|
||||
model_details = self.get_model_details(ids["model_id"])
|
||||
if model_details.get("modelVersions"):
|
||||
version_id = model_details["modelVersions"][0]["id"]
|
||||
url = f"https://civitai.com/api/download/models/{version_id}"
|
||||
else:
|
||||
raise Exception(
|
||||
f"No versions found for model {ids['model_id']}"
|
||||
)
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to get model details: {e}")
|
||||
else:
|
||||
raise Exception("Could not parse model or version ID from URL")
|
||||
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
|
||||
# Disable automatic redirect handling
|
||||
class NoRedirection(urllib.request.HTTPErrorProcessor):
|
||||
def http_response(self, request, response):
|
||||
return response
|
||||
|
||||
https_response = http_response
|
||||
|
||||
request = urllib.request.Request(url, headers=headers)
|
||||
opener = urllib.request.build_opener(NoRedirection)
|
||||
|
||||
try:
|
||||
response = opener.open(request)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
raise Exception(
|
||||
"Authentication required. Please provide a valid API token."
|
||||
)
|
||||
elif e.code == 403:
|
||||
raise Exception(
|
||||
"Access forbidden. The model might be restricted or require special permissions."
|
||||
)
|
||||
elif e.code == 404:
|
||||
raise Exception(
|
||||
"Model not found. The URL might be incorrect or the model was removed."
|
||||
)
|
||||
elif e.code == 429:
|
||||
raise Exception(
|
||||
"Rate limited. Please wait a moment before trying again."
|
||||
)
|
||||
else:
|
||||
raise Exception(f"HTTP error {e.code}: {e.reason}")
|
||||
|
||||
# Handle redirects
|
||||
if response.status in [301, 302, 303, 307, 308]:
|
||||
redirect_url = response.getheader("Location")
|
||||
|
||||
# Handle relative redirects
|
||||
if redirect_url.startswith("/"):
|
||||
base_url = urlparse(url)
|
||||
redirect_url = f"{base_url.scheme}://{base_url.netloc}{redirect_url}"
|
||||
|
||||
# Extract filename from redirect URL if not provided
|
||||
if not filename:
|
||||
parsed_url = urlparse(redirect_url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
content_disposition = query_params.get(
|
||||
"response-content-disposition", [None]
|
||||
)[0]
|
||||
|
||||
if content_disposition and "filename=" in content_disposition:
|
||||
filename = unquote(
|
||||
content_disposition.split("filename=")[1].strip('"')
|
||||
)
|
||||
else:
|
||||
# Fallback: extract filename from URL path
|
||||
path = parsed_url.path
|
||||
if path and "/" in path:
|
||||
filename = path.split("/")[-1]
|
||||
else:
|
||||
filename = "downloaded_file.safetensors"
|
||||
|
||||
response = urllib.request.urlopen(redirect_url)
|
||||
elif response.status == 404:
|
||||
raise Exception("File not found")
|
||||
elif response.status != 200:
|
||||
raise Exception(f"Download failed with status {response.status}")
|
||||
|
||||
# Use provided filename or extracted filename
|
||||
if not filename:
|
||||
filename = self.extract_filename(url, default="model.safetensors")
|
||||
|
||||
output_file = os.path.join(output_path, filename)
|
||||
|
||||
# Check if should download
|
||||
if not self.should_download(output_file, force):
|
||||
print(f"File already exists: {output_file}")
|
||||
return output_file
|
||||
|
||||
total_size = response.getheader("Content-Length")
|
||||
if total_size is not None:
|
||||
total_size = int(total_size)
|
||||
|
||||
print(f"Downloading: {filename}")
|
||||
print(f"Destination: {output_file}")
|
||||
if total_size:
|
||||
print(f"Size: {self.format_size(total_size)}")
|
||||
|
||||
# Download with progress
|
||||
try:
|
||||
with open(output_file, "wb") as f:
|
||||
downloaded = 0
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
chunk_start_time = time.time()
|
||||
buffer = response.read(CHUNK_SIZE)
|
||||
chunk_end_time = time.time()
|
||||
|
||||
if not buffer:
|
||||
break
|
||||
|
||||
downloaded += len(buffer)
|
||||
f.write(buffer)
|
||||
chunk_time = chunk_end_time - chunk_start_time
|
||||
|
||||
# Check for user cancellation
|
||||
self.check_interrupt()
|
||||
|
||||
# Calculate speed
|
||||
speed = self.calculate_speed(len(buffer), chunk_time)
|
||||
|
||||
# Report progress
|
||||
if total_size is not None:
|
||||
progress = downloaded / total_size
|
||||
sys.stdout.write(
|
||||
f'\r[{"=" * int(progress * 50):<50}] {progress * 100:.2f}% - {speed:.2f} MB/s'
|
||||
)
|
||||
sys.stdout.flush()
|
||||
self.report_progress(
|
||||
downloaded, total_size, f"{speed:.2f} MB/s"
|
||||
)
|
||||
else:
|
||||
sys.stdout.write(
|
||||
f"\rDownloaded: {self.format_size(downloaded)} - {speed:.2f} MB/s"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
self.report_progress(downloaded, 0, f"{speed:.2f} MB/s")
|
||||
|
||||
end_time = time.time()
|
||||
time_taken = end_time - start_time
|
||||
hours, remainder = divmod(time_taken, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
if hours > 0:
|
||||
time_str = f"{int(hours)}h {int(minutes)}m {int(seconds)}s"
|
||||
elif minutes > 0:
|
||||
time_str = f"{int(minutes)}m {int(seconds)}s"
|
||||
else:
|
||||
time_str = f"{int(seconds)}s"
|
||||
|
||||
sys.stdout.write("\n")
|
||||
print(f"✓ Download completed in {time_str}")
|
||||
print(f"✓ File saved as: {output_file}")
|
||||
|
||||
# Verify file size
|
||||
actual_size = os.path.getsize(output_file)
|
||||
if total_size and actual_size != total_size:
|
||||
raise Exception(
|
||||
f"Download incomplete. Expected {total_size} bytes, got {actual_size} bytes"
|
||||
)
|
||||
|
||||
return output_file
|
||||
except InterruptProcessingException:
|
||||
# Clean up partial download on interrupt
|
||||
if os.path.exists(output_file):
|
||||
os.remove(output_file)
|
||||
raise InterruptProcessingException("Download interrupted")
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Custom URL downloader - best effort for direct download links"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Optional
|
||||
|
||||
from .base import BaseDownloader
|
||||
|
||||
try:
|
||||
import comfy.model_management
|
||||
|
||||
COMFY_AVAILABLE = True
|
||||
InterruptProcessingException = comfy.model_management.InterruptProcessingException
|
||||
except ImportError:
|
||||
COMFY_AVAILABLE = False
|
||||
# Fallback exception type that will never be raised
|
||||
InterruptProcessingException = type(
|
||||
"InterruptProcessingException", (Exception,), {}
|
||||
)
|
||||
|
||||
|
||||
CHUNK_SIZE = 1638400
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
|
||||
|
||||
class CustomDownloader(BaseDownloader):
|
||||
"""Best-effort downloader for custom/direct URLs"""
|
||||
|
||||
def __init__(self, token: Optional[str] = None):
|
||||
"""Initialize custom downloader
|
||||
|
||||
Args:
|
||||
token: Optional authentication token (will be sent as Bearer token)
|
||||
"""
|
||||
super().__init__(token)
|
||||
|
||||
def download(
|
||||
self,
|
||||
url: str,
|
||||
output_path: str,
|
||||
filename: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> str:
|
||||
"""Download file from custom URL
|
||||
|
||||
Args:
|
||||
url: Direct download URL
|
||||
output_path: Directory to save file
|
||||
filename: Optional filename override
|
||||
force: Force re-download if file exists
|
||||
|
||||
Returns:
|
||||
Path to downloaded file
|
||||
|
||||
Raises:
|
||||
Exception: If download fails
|
||||
"""
|
||||
# Validate output path
|
||||
self.validate_output_path(output_path)
|
||||
|
||||
# Determine filename
|
||||
if not filename:
|
||||
filename = self.extract_filename(
|
||||
url, default="downloaded_model.safetensors"
|
||||
)
|
||||
|
||||
output_file = os.path.join(output_path, filename)
|
||||
|
||||
# Check if should download
|
||||
if not self.should_download(output_file, force):
|
||||
print(f"File already exists: {output_file}")
|
||||
return output_file
|
||||
|
||||
# Prepare headers
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
|
||||
# Add authentication if token provided
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
|
||||
# Create request
|
||||
request = urllib.request.Request(url, headers=headers)
|
||||
|
||||
try:
|
||||
# First request to check if file exists and get metadata
|
||||
response = urllib.request.urlopen(request)
|
||||
|
||||
# Try to extract filename from Content-Disposition header if not provided
|
||||
if not filename:
|
||||
content_disposition = response.getheader("Content-Disposition")
|
||||
if content_disposition:
|
||||
extracted_filename = self.extract_filename_from_header(
|
||||
content_disposition
|
||||
)
|
||||
if extracted_filename:
|
||||
filename = extracted_filename
|
||||
output_file = os.path.join(output_path, filename)
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
raise Exception(
|
||||
"Authentication required. Please provide a valid token if needed."
|
||||
)
|
||||
elif e.code == 403:
|
||||
raise Exception(
|
||||
"Access forbidden. The URL might require authentication or special permissions."
|
||||
)
|
||||
elif e.code == 404:
|
||||
raise Exception("File not found. Please check the URL.")
|
||||
elif e.code == 429:
|
||||
raise Exception(
|
||||
"Rate limited. Please wait a moment before trying again."
|
||||
)
|
||||
else:
|
||||
raise Exception(f"HTTP error {e.code}: {e.reason}")
|
||||
except urllib.error.URLError as e:
|
||||
raise Exception(f"Network error: {e.reason}")
|
||||
|
||||
# Get file size
|
||||
total_size = response.getheader("Content-Length")
|
||||
if total_size is not None:
|
||||
total_size = int(total_size)
|
||||
|
||||
print(f"Downloading: {filename}")
|
||||
print(f"Destination: {output_file}")
|
||||
if total_size:
|
||||
print(f"Size: {self.format_size(total_size)}")
|
||||
else:
|
||||
print("Size: Unknown")
|
||||
|
||||
# Download with progress
|
||||
try:
|
||||
with open(output_file, "wb") as f:
|
||||
downloaded = 0
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
chunk_start_time = time.time()
|
||||
buffer = response.read(CHUNK_SIZE)
|
||||
chunk_end_time = time.time()
|
||||
|
||||
if not buffer:
|
||||
break
|
||||
|
||||
downloaded += len(buffer)
|
||||
f.write(buffer)
|
||||
chunk_time = chunk_end_time - chunk_start_time
|
||||
|
||||
# Check for user cancellation
|
||||
self.check_interrupt()
|
||||
|
||||
# Calculate speed
|
||||
speed = self.calculate_speed(len(buffer), chunk_time)
|
||||
|
||||
# Report progress
|
||||
if total_size is not None:
|
||||
progress = downloaded / total_size
|
||||
sys.stdout.write(
|
||||
f'\r[{"=" * int(progress * 50):<50}] {progress * 100:.2f}% - {speed:.2f} MB/s'
|
||||
)
|
||||
sys.stdout.flush()
|
||||
self.report_progress(
|
||||
downloaded, total_size, f"{speed:.2f} MB/s"
|
||||
)
|
||||
else:
|
||||
sys.stdout.write(
|
||||
f"\rDownloaded: {self.format_size(downloaded)} - {speed:.2f} MB/s"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
self.report_progress(downloaded, 0, f"{speed:.2f} MB/s")
|
||||
|
||||
end_time = time.time()
|
||||
time_taken = end_time - start_time
|
||||
hours, remainder = divmod(time_taken, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
if hours > 0:
|
||||
time_str = f"{int(hours)}h {int(minutes)}m {int(seconds)}s"
|
||||
elif minutes > 0:
|
||||
time_str = f"{int(minutes)}m {int(seconds)}s"
|
||||
else:
|
||||
time_str = f"{int(seconds)}s"
|
||||
|
||||
sys.stdout.write("\n")
|
||||
print(f"✓ Download completed in {time_str}")
|
||||
print(f"✓ File saved as: {output_file}")
|
||||
|
||||
# Verify file size if known
|
||||
actual_size = os.path.getsize(output_file)
|
||||
if total_size and actual_size != total_size:
|
||||
print(
|
||||
f"⚠ Warning: Downloaded size ({actual_size} bytes) doesn't match expected size ({total_size} bytes)"
|
||||
)
|
||||
# Don't raise error for custom URLs as size mismatch might be acceptable
|
||||
|
||||
return output_file
|
||||
except InterruptProcessingException:
|
||||
# Clean up partial download on interrupt
|
||||
if os.path.exists(output_file):
|
||||
os.remove(output_file)
|
||||
raise
|
||||
@@ -0,0 +1,137 @@
|
||||
"""URL detection and downloader selection logic"""
|
||||
|
||||
from __future__ import annotations
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .base import BaseDownloader
|
||||
|
||||
|
||||
class DownloaderType(Enum):
|
||||
"""Types of supported downloaders"""
|
||||
|
||||
CIVITAI = "civitai"
|
||||
HUGGINGFACE = "huggingface"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class URLDetector:
|
||||
"""Detects URL type and returns appropriate downloader"""
|
||||
|
||||
def detect(self, url: Optional[str]) -> DownloaderType:
|
||||
"""Detect which downloader to use based on URL
|
||||
|
||||
Args:
|
||||
url: URL to analyze
|
||||
|
||||
Returns:
|
||||
DownloaderType enum value
|
||||
|
||||
Raises:
|
||||
ValueError: If URL is invalid or empty
|
||||
"""
|
||||
if not url:
|
||||
raise ValueError("URL cannot be empty")
|
||||
|
||||
url = url.strip()
|
||||
if not url:
|
||||
raise ValueError("URL cannot be empty")
|
||||
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
raise ValueError("Invalid URL format")
|
||||
except Exception:
|
||||
raise ValueError("Invalid URL")
|
||||
|
||||
# Check for CivitAI
|
||||
if self._is_civitai_url(url, parsed):
|
||||
return DownloaderType.CIVITAI
|
||||
|
||||
# Check for HuggingFace
|
||||
if self._is_huggingface_url(url, parsed):
|
||||
return DownloaderType.HUGGINGFACE
|
||||
|
||||
# Default to custom downloader
|
||||
return DownloaderType.CUSTOM
|
||||
|
||||
def _is_civitai_url(self, url: str, parsed) -> bool:
|
||||
"""Check if URL is from CivitAI
|
||||
|
||||
Args:
|
||||
url: Full URL string
|
||||
parsed: Parsed URL object
|
||||
|
||||
Returns:
|
||||
True if CivitAI URL
|
||||
"""
|
||||
# Validate exact domain match to prevent subdomain attacks
|
||||
if parsed.netloc not in ("civitai.com", "www.civitai.com"):
|
||||
return False
|
||||
|
||||
# Check for API download endpoint
|
||||
if "/api/download/models/" in url:
|
||||
return True
|
||||
|
||||
# Check for model page
|
||||
if "/models/" in url:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_huggingface_url(self, url: str, parsed) -> bool:
|
||||
"""Check if URL is from HuggingFace
|
||||
|
||||
Args:
|
||||
url: Full URL string
|
||||
parsed: Parsed URL object
|
||||
|
||||
Returns:
|
||||
True if HuggingFace URL
|
||||
"""
|
||||
# Validate exact domain match to prevent subdomain attacks
|
||||
# Support both main domain and CDN domains
|
||||
allowed_domains = (
|
||||
"huggingface.co",
|
||||
"www.huggingface.co",
|
||||
"cdn.huggingface.co",
|
||||
"cdn-lfs.huggingface.co",
|
||||
)
|
||||
if parsed.netloc in allowed_domains:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_downloader(
|
||||
self, url: str, api_token: Optional[str] = None
|
||||
) -> "BaseDownloader":
|
||||
"""Get appropriate downloader instance for URL
|
||||
|
||||
Args:
|
||||
url: URL to download from
|
||||
api_token: Optional API token for authentication
|
||||
|
||||
Returns:
|
||||
Appropriate downloader instance
|
||||
|
||||
Raises:
|
||||
ValueError: If URL is invalid
|
||||
"""
|
||||
downloader_type = self.detect(url)
|
||||
|
||||
if downloader_type == DownloaderType.CIVITAI:
|
||||
from .civitai import CivitAIDownloader
|
||||
|
||||
return CivitAIDownloader(token=api_token)
|
||||
|
||||
elif downloader_type == DownloaderType.HUGGINGFACE:
|
||||
from .huggingface import HuggingFaceDownloader
|
||||
|
||||
return HuggingFaceDownloader(token=api_token)
|
||||
|
||||
else: # CUSTOM
|
||||
from .custom import CustomDownloader
|
||||
|
||||
return CustomDownloader(token=api_token)
|
||||
@@ -0,0 +1,271 @@
|
||||
"""HuggingFace downloader implementation"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse, quote
|
||||
|
||||
from .base import BaseDownloader
|
||||
|
||||
try:
|
||||
import comfy.model_management
|
||||
|
||||
COMFY_AVAILABLE = True
|
||||
InterruptProcessingException = comfy.model_management.InterruptProcessingException
|
||||
except ImportError:
|
||||
COMFY_AVAILABLE = False
|
||||
# Fallback exception type that will never be raised
|
||||
InterruptProcessingException = type(
|
||||
"InterruptProcessingException", (Exception,), {}
|
||||
)
|
||||
|
||||
|
||||
CHUNK_SIZE = 1638400
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
|
||||
|
||||
class HuggingFaceDownloader(BaseDownloader):
|
||||
"""Downloader for HuggingFace models"""
|
||||
|
||||
def __init__(self, token: Optional[str] = None):
|
||||
"""Initialize HuggingFace downloader
|
||||
|
||||
Args:
|
||||
token: Optional HuggingFace API token
|
||||
"""
|
||||
super().__init__(token)
|
||||
|
||||
def _parse_huggingface_url(self, url: str) -> dict:
|
||||
"""Parse HuggingFace URL to extract repo and file information
|
||||
|
||||
Args:
|
||||
url: HuggingFace URL
|
||||
|
||||
Returns:
|
||||
Dict with 'repo_id', 'filename', 'revision' keys
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
parts = parsed.path.strip("/").split("/")
|
||||
|
||||
result = {"repo_id": None, "filename": None, "revision": "main"}
|
||||
|
||||
# Handle blob URLs (web UI format) - convert to resolve format
|
||||
# /{username}/{repo}/blob/{revision}/{file_path}
|
||||
if len(parts) >= 5 and "blob" in parts:
|
||||
blob_idx = parts.index("blob")
|
||||
if blob_idx >= 2:
|
||||
# Extract repo_id (username/repo)
|
||||
result["repo_id"] = "/".join(parts[:blob_idx])
|
||||
# Extract revision
|
||||
if blob_idx + 1 < len(parts):
|
||||
result["revision"] = parts[blob_idx + 1]
|
||||
# Extract filename (everything after revision)
|
||||
if blob_idx + 2 < len(parts):
|
||||
result["filename"] = "/".join(parts[blob_idx + 2 :])
|
||||
|
||||
# Standard HF URL format: /{username}/{repo}/resolve/{revision}/{file_path}
|
||||
elif len(parts) >= 5 and "resolve" in parts:
|
||||
resolve_idx = parts.index("resolve")
|
||||
if resolve_idx >= 2:
|
||||
# Extract repo_id (username/repo)
|
||||
result["repo_id"] = "/".join(parts[:resolve_idx])
|
||||
# Extract revision
|
||||
if resolve_idx + 1 < len(parts):
|
||||
result["revision"] = parts[resolve_idx + 1]
|
||||
# Extract filename (everything after revision)
|
||||
if resolve_idx + 2 < len(parts):
|
||||
result["filename"] = "/".join(parts[resolve_idx + 2 :])
|
||||
|
||||
# Alternative CDN format: Extract what we can
|
||||
elif "cdn" in parsed.netloc:
|
||||
# CDN URLs might have different structure
|
||||
# Try to extract filename from path
|
||||
if len(parts) > 0:
|
||||
result["filename"] = parts[-1]
|
||||
|
||||
return result
|
||||
|
||||
def _construct_download_url(
|
||||
self, repo_id: str, filename: str, revision: str = "main"
|
||||
) -> str:
|
||||
"""Construct HuggingFace download URL
|
||||
|
||||
Args:
|
||||
repo_id: Repository ID (username/repo)
|
||||
filename: File path within repo
|
||||
revision: Branch/tag/commit (default: main)
|
||||
|
||||
Returns:
|
||||
Download URL
|
||||
"""
|
||||
# URL encode the filename to handle special characters
|
||||
encoded_filename = quote(filename, safe="/")
|
||||
return f"https://huggingface.co/{repo_id}/resolve/{revision}/{encoded_filename}"
|
||||
|
||||
def download(
|
||||
self,
|
||||
url: str,
|
||||
output_path: str,
|
||||
filename: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> str:
|
||||
"""Download file from HuggingFace
|
||||
|
||||
Args:
|
||||
url: HuggingFace URL to download
|
||||
output_path: Directory to save file
|
||||
filename: Optional filename override
|
||||
force: Force re-download if file exists
|
||||
|
||||
Returns:
|
||||
Path to downloaded file
|
||||
|
||||
Raises:
|
||||
Exception: If download fails
|
||||
"""
|
||||
# Validate output path
|
||||
self.validate_output_path(output_path)
|
||||
|
||||
# Parse URL to get file information
|
||||
url_info = self._parse_huggingface_url(url)
|
||||
|
||||
# Convert blob URL to resolve URL if needed
|
||||
if url_info["repo_id"] and url_info["filename"]:
|
||||
download_url = self._construct_download_url(
|
||||
url_info["repo_id"], url_info["filename"], url_info["revision"]
|
||||
)
|
||||
print(f"[HuggingFace] Converted URL to: {download_url}")
|
||||
else:
|
||||
# Use original URL if parsing failed
|
||||
download_url = url
|
||||
|
||||
# Determine filename
|
||||
if not filename:
|
||||
if url_info["filename"]:
|
||||
# Use just the basename from the URL
|
||||
filename = os.path.basename(url_info["filename"])
|
||||
else:
|
||||
filename = self.extract_filename(url, default="model.safetensors")
|
||||
|
||||
output_file = os.path.join(output_path, filename)
|
||||
|
||||
# Check if should download
|
||||
if not self.should_download(output_file, force):
|
||||
print(f"File already exists: {output_file}")
|
||||
return output_file
|
||||
|
||||
# Prepare headers
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
|
||||
# Create request with converted download URL
|
||||
request = urllib.request.Request(download_url, headers=headers)
|
||||
|
||||
try:
|
||||
response = urllib.request.urlopen(request)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
raise Exception(
|
||||
"Authentication required. Please provide a valid HuggingFace token."
|
||||
)
|
||||
elif e.code == 403:
|
||||
raise Exception(
|
||||
"Access forbidden. The model might be gated or require special permissions."
|
||||
)
|
||||
elif e.code == 404:
|
||||
raise Exception(
|
||||
"File not found. The URL might be incorrect or the file was removed."
|
||||
)
|
||||
elif e.code == 429:
|
||||
raise Exception(
|
||||
"Rate limited. Please wait a moment before trying again."
|
||||
)
|
||||
else:
|
||||
raise Exception(f"HTTP error {e.code}: {e.reason}")
|
||||
except urllib.error.URLError as e:
|
||||
raise Exception(f"Network error: {e.reason}")
|
||||
|
||||
# Get file size
|
||||
total_size = response.getheader("Content-Length")
|
||||
if total_size is not None:
|
||||
total_size = int(total_size)
|
||||
|
||||
print(f"Downloading: {filename}")
|
||||
print(f"Destination: {output_file}")
|
||||
if total_size:
|
||||
print(f"Size: {self.format_size(total_size)}")
|
||||
|
||||
# Download with progress
|
||||
try:
|
||||
with open(output_file, "wb") as f:
|
||||
downloaded = 0
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
chunk_start_time = time.time()
|
||||
buffer = response.read(CHUNK_SIZE)
|
||||
chunk_end_time = time.time()
|
||||
|
||||
if not buffer:
|
||||
break
|
||||
|
||||
downloaded += len(buffer)
|
||||
f.write(buffer)
|
||||
chunk_time = chunk_end_time - chunk_start_time
|
||||
|
||||
# Check for user cancellation
|
||||
self.check_interrupt()
|
||||
|
||||
# Calculate speed
|
||||
speed = self.calculate_speed(len(buffer), chunk_time)
|
||||
|
||||
# Report progress
|
||||
if total_size is not None:
|
||||
progress = downloaded / total_size
|
||||
sys.stdout.write(
|
||||
f'\r[{"=" * int(progress * 50):<50}] {progress * 100:.2f}% - {speed:.2f} MB/s'
|
||||
)
|
||||
sys.stdout.flush()
|
||||
self.report_progress(
|
||||
downloaded, total_size, f"{speed:.2f} MB/s"
|
||||
)
|
||||
else:
|
||||
sys.stdout.write(
|
||||
f"\rDownloaded: {self.format_size(downloaded)} - {speed:.2f} MB/s"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
self.report_progress(downloaded, 0, f"{speed:.2f} MB/s")
|
||||
|
||||
end_time = time.time()
|
||||
time_taken = end_time - start_time
|
||||
hours, remainder = divmod(time_taken, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
if hours > 0:
|
||||
time_str = f"{int(hours)}h {int(minutes)}m {int(seconds)}s"
|
||||
elif minutes > 0:
|
||||
time_str = f"{int(minutes)}m {int(seconds)}s"
|
||||
else:
|
||||
time_str = f"{int(seconds)}s"
|
||||
|
||||
sys.stdout.write("\n")
|
||||
print(f"✓ Download completed in {time_str}")
|
||||
print(f"✓ File saved as: {output_file}")
|
||||
|
||||
# Verify file size
|
||||
actual_size = os.path.getsize(output_file)
|
||||
if total_size and actual_size != total_size:
|
||||
raise Exception(
|
||||
f"Download incomplete. Expected {total_size} bytes, got {actual_size} bytes"
|
||||
)
|
||||
|
||||
return output_file
|
||||
except InterruptProcessingException:
|
||||
# Clean up partial download on interrupt
|
||||
if os.path.exists(output_file):
|
||||
os.remove(output_file)
|
||||
raise
|
||||
@@ -0,0 +1,155 @@
|
||||
"""ComfyUI Model Downloader Node"""
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
from .detector import URLDetector
|
||||
|
||||
|
||||
class ModelDownloaderNode(ComfyAssetsBaseNode):
|
||||
"""ComfyUI node for downloading models from CivitAI, HuggingFace, and custom URLs"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define input types for the node"""
|
||||
return {
|
||||
"required": {
|
||||
"url": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"placeholder": "https://civitai.com/... or https://huggingface.co/...",
|
||||
},
|
||||
),
|
||||
"save_path": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "models/checkpoints",
|
||||
"multiline": False,
|
||||
"placeholder": "Path to save downloaded models",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"filename": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"placeholder": "Leave empty for auto-detection",
|
||||
},
|
||||
),
|
||||
"api_token": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"placeholder": "API token (CivitAI or HuggingFace)",
|
||||
},
|
||||
),
|
||||
"force_download": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"label_on": "Force Redownload",
|
||||
"label_off": "Skip if Exists",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "download_model"
|
||||
CATEGORY = "🫶 ComfyAssets/🛠️ Utils"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def download_model(
|
||||
self,
|
||||
url: str,
|
||||
save_path: str,
|
||||
filename: str = "",
|
||||
api_token: str = "",
|
||||
force_download: bool = False,
|
||||
):
|
||||
"""Download model from URL
|
||||
|
||||
Args:
|
||||
url: URL to download from
|
||||
save_path: Directory to save file
|
||||
filename: Optional filename override
|
||||
api_token: Optional API token
|
||||
force_download: Force re-download if file exists
|
||||
|
||||
Returns:
|
||||
Dictionary with 'ui' key for ComfyUI display
|
||||
"""
|
||||
# Validate inputs
|
||||
if not url or not url.strip():
|
||||
error_msg = "URL cannot be empty"
|
||||
return {"ui": {"text": [error_msg]}}
|
||||
|
||||
if not save_path or not save_path.strip():
|
||||
error_msg = "Save path cannot be empty"
|
||||
return {"ui": {"text": [error_msg]}}
|
||||
|
||||
url = url.strip()
|
||||
save_path = save_path.strip()
|
||||
filename = filename.strip() if filename else None
|
||||
api_token = api_token.strip() if api_token else None
|
||||
|
||||
try:
|
||||
# Detect downloader type and get appropriate downloader
|
||||
detector = URLDetector()
|
||||
downloader_type = detector.detect(url)
|
||||
|
||||
print(
|
||||
f"\n[Model Downloader] Detected downloader type: {downloader_type.value}"
|
||||
)
|
||||
print(f"[Model Downloader] URL: {url}")
|
||||
print(f"[Model Downloader] Save path: {save_path}")
|
||||
if filename:
|
||||
print(f"[Model Downloader] Filename: {filename}")
|
||||
if force_download:
|
||||
print("[Model Downloader] Force download: enabled")
|
||||
|
||||
# Get downloader instance
|
||||
downloader = detector.get_downloader(url, api_token=api_token)
|
||||
|
||||
# Download file
|
||||
file_path = downloader.download(
|
||||
url=url, output_path=save_path, filename=filename, force=force_download
|
||||
)
|
||||
|
||||
message = f"Successfully downloaded to {file_path}"
|
||||
print(f"[Model Downloader] {message}")
|
||||
|
||||
return {"ui": {"text": [message]}}
|
||||
|
||||
except ValueError as e:
|
||||
error_msg = f"Invalid URL: {str(e)}"
|
||||
print(f"[Model Downloader] Error: {error_msg}")
|
||||
return {"ui": {"text": [error_msg]}}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Download failed: {str(e)}"
|
||||
print(f"[Model Downloader] Error: {error_msg}")
|
||||
return {"ui": {"text": [error_msg]}}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(
|
||||
cls, url, save_path, filename="", api_token="", force_download=False
|
||||
):
|
||||
"""Force re-evaluation on every execution or when inputs change"""
|
||||
# Include hash of inputs plus timestamp to force execution
|
||||
# This ensures the node re-runs even if the download failed previously
|
||||
import time
|
||||
import hashlib
|
||||
|
||||
# Create a unique hash based on non-sensitive inputs and current time
|
||||
# Note: api_token is excluded to avoid sensitive data in hash
|
||||
# The token doesn't affect cache invalidation - URL changes are sufficient
|
||||
input_str = f"{url}|{save_path}|{filename}|{force_download}|{time.time()}"
|
||||
return hashlib.sha256(input_str.encode()).hexdigest()
|
||||
|
||||
|
||||
# Node display name
|
||||
NODE_DISPLAY_NAME = "Model Downloader 🌐"
|
||||
@@ -60,6 +60,7 @@ class ResolutionCalculatorNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "INT")
|
||||
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
|
||||
RETURN_NAMES = ("width", "height")
|
||||
FUNCTION = "calculate_resolution"
|
||||
|
||||
|
||||
@@ -53,17 +53,16 @@ class SamplerComboCompactNode(ComfyAssetsBaseNode):
|
||||
"min": 1.0,
|
||||
"max": 15.0,
|
||||
"step": 0.5,
|
||||
"display": "slider",
|
||||
"tooltip": "CFG",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
|
||||
RETURN_TYPES = (SAMPLERS, SCHEDULERS, "INT", "FLOAT")
|
||||
RETURN_NAMES = ("sampler", "scheduler", "steps", "cfg")
|
||||
FUNCTION = "get_combo"
|
||||
CATEGORY = "ComfyAssets"
|
||||
CATEGORY = "🫶 ComfyAssets/🌀 Samplers"
|
||||
|
||||
def get_combo(
|
||||
self, sampler: str, sched: str, steps: int, cfg: float
|
||||
@@ -83,27 +82,13 @@ class SamplerComboCompactNode(ComfyAssetsBaseNode):
|
||||
try:
|
||||
# Use the same validation logic but with compact interface
|
||||
result = get_sampler_combo(sampler, sched, steps, cfg)
|
||||
# Create the sampler object
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
sampler_obj = comfy.samplers.sampler_object(result[0])
|
||||
except ImportError:
|
||||
# Return sampler name for testing
|
||||
sampler_obj = result[0]
|
||||
return (sampler_obj, result[1], result[2], result[3])
|
||||
# Return the sampler name as string, not object
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# Graceful fallback
|
||||
self.handle_error(f"Error in compact combo: {str(e)}")
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
sampler_obj = comfy.samplers.sampler_object("euler")
|
||||
except ImportError:
|
||||
# Return sampler name for testing
|
||||
sampler_obj = "euler"
|
||||
return (sampler_obj, "normal", 20, 7.0)
|
||||
return ("euler", "normal", 20, 7.0)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of the compact node."""
|
||||
|
||||
@@ -58,17 +58,16 @@ class SamplerComboNode(ComfyAssetsBaseNode):
|
||||
"min": 0.0,
|
||||
"max": 20.0,
|
||||
"step": 0.5,
|
||||
"display": "slider",
|
||||
"tooltip": "CFG scale (0-20)",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
|
||||
RETURN_TYPES = (SAMPLERS, SCHEDULERS, "INT", "FLOAT")
|
||||
RETURN_NAMES = ("sampler_name", "scheduler", "steps", "cfg")
|
||||
FUNCTION = "get_sampler_combo"
|
||||
CATEGORY = "ComfyAssets"
|
||||
CATEGORY = "🫶 ComfyAssets/🌀 Samplers"
|
||||
|
||||
def get_sampler_combo(
|
||||
self, sampler_name: str, scheduler: str, steps: int, cfg: float
|
||||
@@ -98,33 +97,18 @@ class SamplerComboNode(ComfyAssetsBaseNode):
|
||||
f"steps={steps}, cfg={cfg}. "
|
||||
f"Using safe defaults: euler, normal, 20 steps, CFG 7.0"
|
||||
)
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
sampler = comfy.samplers.sampler_object("euler")
|
||||
except ImportError:
|
||||
# Return mock object for testing
|
||||
sampler = "euler"
|
||||
return (sampler, "normal", 20, 7.0)
|
||||
return ("euler", "normal", 20, 7.0)
|
||||
|
||||
# Process and return the combo
|
||||
result = get_sampler_combo(sampler_name, scheduler, steps, cfg)
|
||||
|
||||
# Create the sampler object
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
sampler = comfy.samplers.sampler_object(result[0])
|
||||
except ImportError:
|
||||
# Return sampler name for testing
|
||||
sampler = result[0]
|
||||
|
||||
self.log_info(
|
||||
f"Configured sampler combo: {result[0]}, {result[1]}, "
|
||||
f"{result[2]} steps, CFG {result[3]}"
|
||||
)
|
||||
|
||||
return (sampler, result[1], result[2], result[3])
|
||||
# Return the sampler name as string, not object
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# Handle any unexpected errors gracefully
|
||||
@@ -135,14 +119,7 @@ class SamplerComboNode(ComfyAssetsBaseNode):
|
||||
f"{self.__class__.__name__}: Error processing sampler combo: {str(e)}. "
|
||||
f"Using safe defaults: euler, normal, 20 steps, CFG 7.0"
|
||||
)
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
sampler = comfy.samplers.sampler_object("euler")
|
||||
except ImportError:
|
||||
# Return mock object for testing
|
||||
sampler = "euler"
|
||||
return (sampler, "normal", 20, 7.0)
|
||||
return ("euler", "normal", 20, 7.0)
|
||||
|
||||
def validate_inputs(
|
||||
self, sampler_name: str, scheduler: str, steps: int, cfg: float
|
||||
|
||||
@@ -10,14 +10,14 @@ def generate_random_seed() -> int:
|
||||
Generate a cryptographically strong random seed value.
|
||||
|
||||
Returns:
|
||||
Random integer in the valid ComfyUI seed range
|
||||
Random integer in the valid ComfyUI seed range (0 to 2**32 - 1)
|
||||
"""
|
||||
return random.randint(0, 0xFFFFFFFFFFFFFFFF)
|
||||
return random.randint(0, 0xFFFFFFFF) # 2**32 - 1
|
||||
|
||||
|
||||
def validate_seed_value(seed: Any) -> bool:
|
||||
"""
|
||||
Validate that a seed value is within acceptable range.
|
||||
Validate that a seed value is within acceptable range (0 to 2**32 - 1).
|
||||
|
||||
Args:
|
||||
seed: Seed value to validate
|
||||
@@ -30,7 +30,7 @@ def validate_seed_value(seed: Any) -> bool:
|
||||
|
||||
try:
|
||||
seed_int = int(seed)
|
||||
return 0 <= seed_int <= 0xFFFFFFFFFFFFFFFF
|
||||
return 0 <= seed_int <= 0xFFFFFFFF # 2**32 - 1
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
@@ -54,11 +54,11 @@ def sanitize_seed_value(seed: Any) -> int:
|
||||
try:
|
||||
seed_int = int(seed)
|
||||
|
||||
# Clamp to valid range
|
||||
# Clamp to valid range (0 to 2**32 - 1)
|
||||
if seed_int < 0:
|
||||
seed_int = 0
|
||||
elif seed_int > 0xFFFFFFFFFFFFFFFF:
|
||||
seed_int = 0xFFFFFFFFFFFFFFFF
|
||||
elif seed_int > 0xFFFFFFFF:
|
||||
seed_int = 0xFFFFFFFF
|
||||
|
||||
return seed_int
|
||||
|
||||
|
||||
@@ -27,25 +27,27 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
{
|
||||
"default": 12345,
|
||||
"min": 0,
|
||||
"max": 0xFFFFFFFFFFFFFFFF,
|
||||
"max": 0xFFFFFFFF, # 2**32 - 1
|
||||
"control_after_generate": True,
|
||||
"tooltip": "Seed value for generation processes. "
|
||||
"History UI tracks all changes automatically.",
|
||||
"Use 'control after generate' to set behavior after each run.",
|
||||
},
|
||||
),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT",)
|
||||
RETURN_NAMES = ("seed",)
|
||||
FUNCTION = "output_seed"
|
||||
CATEGORY = "ComfyAssets"
|
||||
CATEGORY = "🫶 ComfyAssets/🌱 Seeds"
|
||||
|
||||
def output_seed(self, seed: int) -> Tuple[int]:
|
||||
def output_seed(self, seed: int, **kwargs) -> Tuple[int]:
|
||||
"""
|
||||
Output the seed value for use in other nodes.
|
||||
|
||||
Args:
|
||||
seed: Input seed value
|
||||
**kwargs: Accepts legacy parameters (e.g. mode) for backward compatibility
|
||||
|
||||
Returns:
|
||||
Tuple containing the seed value
|
||||
@@ -53,7 +55,6 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
try:
|
||||
# Validate and sanitize the seed
|
||||
if not validate_seed_value(seed):
|
||||
# Log the validation error but don't raise
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -64,11 +65,9 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
return (12345,)
|
||||
|
||||
clean_seed = sanitize_seed_value(seed)
|
||||
|
||||
return (clean_seed,)
|
||||
|
||||
except Exception as e:
|
||||
# Handle any unexpected errors gracefully
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -142,7 +141,7 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
Returns:
|
||||
Range information string
|
||||
"""
|
||||
max_seed = 0xFFFFFFFFFFFFFFFF
|
||||
max_seed = 0xFFFFFFFF # 2**32 - 1
|
||||
return f"Valid range: 0 to {max_seed:,} ({hex(max_seed)})"
|
||||
|
||||
@classmethod
|
||||
@@ -166,7 +165,7 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
Returns:
|
||||
True if seed is in valid range
|
||||
"""
|
||||
return 0 <= seed <= 0xFFFFFFFFFFFFFFFF
|
||||
return 0 <= seed <= 0xFFFFFFFF # 2**32 - 1
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of the node."""
|
||||
@@ -178,6 +177,6 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
f"SeedHistoryNode("
|
||||
f"category='{self.CATEGORY}', "
|
||||
f"function='{self.FUNCTION}', "
|
||||
f"max_seed={hex(0xFFFFFFFFFFFFFFFF)}"
|
||||
f"max_seed={hex(0xFFFFFFFF)}" # 2**32 - 1
|
||||
f")"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Text Input tool for ComfyUI."""
|
||||
|
||||
from .node import TextInputNode, NODE_DISPLAY_NAME
|
||||
|
||||
__all__ = ["TextInputNode", "NODE_DISPLAY_NAME"]
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Text Input node implementation."""
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
|
||||
|
||||
class TextInputNode(ComfyAssetsBaseNode):
|
||||
"""Provides a text input field for manual text entry in ComfyUI workflows."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define input types for the node."""
|
||||
return {
|
||||
"required": {
|
||||
"text": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": True,
|
||||
"default": "",
|
||||
"dynamicPrompts": True,
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("text",)
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "🫶 ComfyAssets/📝 Text"
|
||||
|
||||
DESCRIPTION = """
|
||||
Simple text input field for entering text manually.
|
||||
|
||||
Features:
|
||||
- Multiline text editing
|
||||
- Supports wildcards and dynamic prompts
|
||||
- Direct connection to CLIP text encoders
|
||||
- Unicode and special character support
|
||||
|
||||
Use Cases:
|
||||
- Positive/negative prompts
|
||||
- Custom text for workflows
|
||||
- Manual text editing
|
||||
- Prompt templates
|
||||
"""
|
||||
|
||||
def execute(self, text):
|
||||
"""Process the input text and return it.
|
||||
|
||||
Args:
|
||||
text: Input text from the widget
|
||||
|
||||
Returns:
|
||||
Tuple containing the text
|
||||
"""
|
||||
return (text,)
|
||||
|
||||
|
||||
# Node display name
|
||||
NODE_DISPLAY_NAME = "Text Input"
|
||||
@@ -85,7 +85,7 @@ class WidthHeightSelectorNode(ComfyAssetsBaseNode):
|
||||
RETURN_TYPES = ("INT", "INT")
|
||||
RETURN_NAMES = ("width", "height")
|
||||
FUNCTION = "get_dimensions"
|
||||
CATEGORY = "ComfyAssets"
|
||||
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
|
||||
|
||||
def get_dimensions(self, preset: str, width: int, height: int) -> Tuple[int, int]:
|
||||
"""
|
||||
|
||||
@@ -78,7 +78,47 @@ PRESET_METADATA: Dict[str, PresetMetadata] = {
|
||||
"Portrait",
|
||||
"SDXL portrait 5:12 - very tall portrait",
|
||||
),
|
||||
"704×1408": PresetMetadata(
|
||||
704,
|
||||
1408,
|
||||
"1:2",
|
||||
0.5,
|
||||
0.99,
|
||||
"SDXL",
|
||||
"Portrait",
|
||||
"SDXL portrait 1:2 - extreme tall portrait",
|
||||
),
|
||||
"960×1024": PresetMetadata(
|
||||
960,
|
||||
1024,
|
||||
"15:16",
|
||||
0.938,
|
||||
0.98,
|
||||
"SDXL",
|
||||
"Portrait",
|
||||
"SDXL near-square portrait - subtle portrait",
|
||||
),
|
||||
"720×1280": PresetMetadata(
|
||||
720,
|
||||
1280,
|
||||
"9:16",
|
||||
0.5625,
|
||||
0.92,
|
||||
"SDXL",
|
||||
"Portrait",
|
||||
"SDXL portrait 9:16 - vertical video/mobile",
|
||||
),
|
||||
# SDXL Presets - Landscape
|
||||
"1024×960": PresetMetadata(
|
||||
1024,
|
||||
960,
|
||||
"16:15",
|
||||
1.067,
|
||||
0.98,
|
||||
"SDXL",
|
||||
"Landscape",
|
||||
"SDXL near-square landscape - subtle landscape",
|
||||
),
|
||||
"1152×896": PresetMetadata(
|
||||
1152,
|
||||
896,
|
||||
@@ -119,6 +159,26 @@ PRESET_METADATA: Dict[str, PresetMetadata] = {
|
||||
"Landscape",
|
||||
"SDXL landscape 12:5 - very wide landscape",
|
||||
),
|
||||
"1728×576": PresetMetadata(
|
||||
1728,
|
||||
576,
|
||||
"3:1",
|
||||
3.0,
|
||||
1.0,
|
||||
"SDXL",
|
||||
"Landscape",
|
||||
"SDXL landscape 3:1 - extreme wide panoramic",
|
||||
),
|
||||
"1280×720": PresetMetadata(
|
||||
1280,
|
||||
720,
|
||||
"16:9",
|
||||
1.778,
|
||||
0.92,
|
||||
"SDXL",
|
||||
"Landscape",
|
||||
"SDXL landscape 16:9 - HD widescreen video",
|
||||
),
|
||||
# FLUX Presets - High Quality
|
||||
"1920×1080": PresetMetadata(
|
||||
1920,
|
||||
@@ -283,6 +343,97 @@ PRESET_METADATA: Dict[str, PresetMetadata] = {
|
||||
"Banner",
|
||||
"Vertical banner 1:3 - extreme tall banner",
|
||||
),
|
||||
# Qwen Presets
|
||||
"1328×1328": PresetMetadata(
|
||||
1328,
|
||||
1328,
|
||||
"1:1",
|
||||
1.0,
|
||||
1.76,
|
||||
"Qwen",
|
||||
"Square",
|
||||
"Qwen square 1:1 - optimized square",
|
||||
),
|
||||
"1664×928": PresetMetadata(
|
||||
1664,
|
||||
928,
|
||||
"16:9",
|
||||
1.793,
|
||||
1.54,
|
||||
"Qwen",
|
||||
"Landscape",
|
||||
"Qwen landscape 16:9 - widescreen format",
|
||||
),
|
||||
"928×1664": PresetMetadata(
|
||||
928,
|
||||
1664,
|
||||
"9:16",
|
||||
0.558,
|
||||
1.54,
|
||||
"Qwen",
|
||||
"Portrait",
|
||||
"Qwen portrait 9:16 - vertical format",
|
||||
),
|
||||
"1472×1104": PresetMetadata(
|
||||
1472,
|
||||
1104,
|
||||
"4:3",
|
||||
1.333,
|
||||
1.62,
|
||||
"Qwen",
|
||||
"Landscape",
|
||||
"Qwen landscape 4:3 - classic landscape",
|
||||
),
|
||||
"1104×1472": PresetMetadata(
|
||||
1104,
|
||||
1472,
|
||||
"3:4",
|
||||
0.750,
|
||||
1.62,
|
||||
"Qwen",
|
||||
"Portrait",
|
||||
"Qwen portrait 3:4 - classic portrait",
|
||||
),
|
||||
"1584×1056": PresetMetadata(
|
||||
1584,
|
||||
1056,
|
||||
"3:2",
|
||||
1.500,
|
||||
1.67,
|
||||
"Qwen",
|
||||
"Landscape",
|
||||
"Qwen landscape 3:2 - photography standard",
|
||||
),
|
||||
"1056×1584": PresetMetadata(
|
||||
1056,
|
||||
1584,
|
||||
"2:3",
|
||||
0.667,
|
||||
1.67,
|
||||
"Qwen",
|
||||
"Portrait",
|
||||
"Qwen portrait 2:3 - portrait photography",
|
||||
),
|
||||
"2080×688": PresetMetadata(
|
||||
2080,
|
||||
688,
|
||||
"3:1",
|
||||
3.023,
|
||||
1.43,
|
||||
"Qwen",
|
||||
"Landscape",
|
||||
"Qwen experimental landscape 3:1 - ultra-wide",
|
||||
),
|
||||
"688×2080": PresetMetadata(
|
||||
688,
|
||||
2080,
|
||||
"1:3",
|
||||
0.331,
|
||||
1.43,
|
||||
"Qwen",
|
||||
"Portrait",
|
||||
"Qwen experimental portrait 1:3 - ultra-tall",
|
||||
),
|
||||
}
|
||||
|
||||
# Legacy compatibility - maintain old preset dictionaries
|
||||
@@ -304,6 +455,12 @@ ULTRA_WIDE_PRESETS: Dict[str, Tuple[int, int]] = {
|
||||
if v.model_group == "Ultra-Wide"
|
||||
}
|
||||
|
||||
QWEN_PRESETS: Dict[str, Tuple[int, int]] = {
|
||||
k: (v.width, v.height)
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "Qwen"
|
||||
}
|
||||
|
||||
# Combined preset options for ComfyUI dropdown
|
||||
PRESET_OPTIONS: Dict[str, Tuple[int, int]] = {
|
||||
"custom": (0, 0), # Special case for custom dimensions
|
||||
@@ -386,6 +543,22 @@ PRESET_CATEGORIES = {
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "Ultra-Wide" and v.category == "Banner"
|
||||
],
|
||||
# Qwen Categories
|
||||
"Qwen Square": [
|
||||
k
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "Qwen" and v.category == "Square"
|
||||
],
|
||||
"Qwen Portrait": [
|
||||
k
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "Qwen" and v.category == "Portrait"
|
||||
],
|
||||
"Qwen Landscape": [
|
||||
k
|
||||
for k, v in PRESET_METADATA.items()
|
||||
if v.model_group == "Qwen" and v.category == "Landscape"
|
||||
],
|
||||
}
|
||||
|
||||
# Legacy compatibility - preset descriptions
|
||||
@@ -398,6 +571,7 @@ MODEL_RECOMMENDATIONS = {
|
||||
"Ultra-Wide": [
|
||||
k for k, v in PRESET_METADATA.items() if v.model_group == "Ultra-Wide"
|
||||
],
|
||||
"Qwen": [k for k, v in PRESET_METADATA.items() if v.model_group == "Qwen"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Width Height to VEC2 converter node
|
||||
Converts width and height inputs to VEC2 tuple for jovi_glsl and similar nodes
|
||||
"""
|
||||
|
||||
from .node import WidthHeightToVec2Node
|
||||
|
||||
__all__ = ["WidthHeightToVec2Node"]
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Width Height to VEC2 Node
|
||||
|
||||
Converts width and height inputs to a VEC2 tuple for use with
|
||||
nodes like jovi_glsl that expect vector inputs.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Tuple, Union
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
|
||||
|
||||
class WidthHeightToVec2Node(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Convert width and height values to VEC2 format.
|
||||
|
||||
Accepts INT, FLOAT, STRING, or ANY types and outputs a VEC2 tuple
|
||||
suitable for nodes expecting vector inputs.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
"""Define ComfyUI input interface."""
|
||||
return {
|
||||
"required": {
|
||||
"width": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1024,
|
||||
"min": 1,
|
||||
"max": 8192,
|
||||
"step": 1,
|
||||
"tooltip": "Width value (x component of VEC2)",
|
||||
},
|
||||
),
|
||||
"height": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1024,
|
||||
"min": 1,
|
||||
"max": 8192,
|
||||
"step": 1,
|
||||
"tooltip": "Height value (y component of VEC2)",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("VEC2",)
|
||||
RETURN_NAMES = ("vec2",)
|
||||
FUNCTION = "convert_to_vec2"
|
||||
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
|
||||
|
||||
def convert_to_vec2(
|
||||
self,
|
||||
width: Union[int, float, str, Any],
|
||||
height: Union[int, float, str, Any],
|
||||
) -> Tuple[Tuple[int, int]]:
|
||||
"""
|
||||
Convert width and height to VEC2 tuple.
|
||||
|
||||
Args:
|
||||
width: Width value (will be converted to int)
|
||||
height: Height value (will be converted to int)
|
||||
|
||||
Returns:
|
||||
Tuple containing the VEC2 tuple (width, height)
|
||||
"""
|
||||
try:
|
||||
# Convert to integers, handling various input types
|
||||
w = self._to_int(width, "width")
|
||||
h = self._to_int(height, "height")
|
||||
|
||||
# Clamp values to valid range
|
||||
w = max(1, min(8192, w))
|
||||
h = max(1, min(8192, h))
|
||||
|
||||
self.log_info(f"Converted to VEC2: ({w}, {h})")
|
||||
|
||||
# Return as tuple wrapped in tuple (ComfyUI return format)
|
||||
return ((w, h),)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to convert to VEC2: {str(e)}"
|
||||
self.handle_error(error_msg, e)
|
||||
|
||||
def _to_int(self, value: Any, name: str) -> int:
|
||||
"""
|
||||
Convert a value to integer.
|
||||
|
||||
Args:
|
||||
value: Value to convert (int, float, str, or any)
|
||||
name: Parameter name for error messages
|
||||
|
||||
Returns:
|
||||
Integer value
|
||||
|
||||
Raises:
|
||||
ValueError: If conversion fails
|
||||
"""
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
elif isinstance(value, float):
|
||||
return int(value)
|
||||
elif isinstance(value, str):
|
||||
try:
|
||||
# Try parsing as float first (handles "1024.0")
|
||||
return int(float(value.strip()))
|
||||
except ValueError:
|
||||
raise ValueError(f"Cannot convert {name} string '{value}' to integer")
|
||||
else:
|
||||
# Try generic conversion for ANY type
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError(
|
||||
f"Cannot convert {name} of type {type(value).__name__} to integer"
|
||||
)
|
||||
|
||||
|
||||
# Node registration
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"WidthHeightToVec2": WidthHeightToVec2Node,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"WidthHeightToVec2": "Width Height to VEC2",
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"""XYZ Helpers module for ComfyUI."""
|
||||
|
||||
from .sampler_select_helper import SamplerSelectHelperNode
|
||||
from .scheduler_select_helper import SchedulerSelectHelperNode
|
||||
from .text_encode_sampler_params import TextEncodeSamplerParamsNode
|
||||
from .flux_sampler_params import FluxSamplerParamsNode
|
||||
from .plot_sampler_params import PlotParametersNode
|
||||
from .lora_folder_batch import LoRAFolderBatchNode
|
||||
|
||||
__all__ = [
|
||||
"SamplerSelectHelperNode",
|
||||
"SchedulerSelectHelperNode",
|
||||
"TextEncodeSamplerParamsNode",
|
||||
"FluxSamplerParamsNode",
|
||||
"PlotParametersNode",
|
||||
"LoRAFolderBatchNode",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user