Compare commits
201
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c42149d424 | ||
|
|
55bce1004b | ||
|
|
63a8596b81 | ||
|
|
11494c747c | ||
|
|
88a9238ee2 | ||
|
|
3879f18d79 | ||
|
|
64f70b6970 | ||
|
|
ae0fb7f229 | ||
|
|
ddf73a667e | ||
|
|
46999c8d90 | ||
|
|
852864a270 | ||
|
|
c182c775f4 | ||
|
|
02108be2d1 | ||
|
|
2a9fbe78c7 | ||
|
|
5a8a8b62e4 | ||
|
|
335cc53961 | ||
|
|
c5e00c130b | ||
|
|
78f1498ea8 | ||
|
|
c65f3aad03 | ||
|
|
9a55c23282 | ||
|
|
e90bde5849 | ||
|
|
49523e10de | ||
|
|
8e6cf4233a | ||
|
|
6301cf90f8 | ||
|
|
f87eb959b3 | ||
|
|
7783ed8a16 | ||
|
|
726fcee761 | ||
|
|
98f3defe50 | ||
|
|
143a6c47f6 | ||
|
|
76a52765c7 | ||
|
|
f308b23631 | ||
|
|
d65c11c76c | ||
|
|
1879f42f83 | ||
|
|
c2d85d4ba3 | ||
|
|
2246484b99 | ||
|
|
2a637322b9 | ||
|
|
684e61638b | ||
|
|
a0b132ef7b | ||
|
|
935e94b651 | ||
|
|
7513479419 | ||
|
|
865dde6cdf | ||
|
|
30fc6aa7a7 | ||
|
|
a055543d16 | ||
|
|
78fd20d89a | ||
|
|
4b66592715 | ||
|
|
dc3fcf65e2 | ||
|
|
8196e65906 | ||
|
|
c8de8f26d6 | ||
|
|
10027fcef9 | ||
|
|
60ce078aed | ||
|
|
37c00b633c | ||
|
|
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 |
@@ -0,0 +1,28 @@
|
||||
# Files excluded from the Comfy Registry archive (gitignore syntax).
|
||||
# Everything here is development-only; the published node needs
|
||||
# __init__.py, kikotools/, web/, examples/, README, LICENSE, pyproject.toml.
|
||||
|
||||
# Test suite and tooling
|
||||
tests/
|
||||
scripts/
|
||||
.github/
|
||||
Makefile
|
||||
pytest.ini
|
||||
mypy.ini
|
||||
.flake8
|
||||
.pre-commit-config.yaml
|
||||
.secrets.baseline
|
||||
requirements-dev.txt
|
||||
|
||||
# Reference / scratch code not imported by the node
|
||||
example.py
|
||||
|
||||
# Agent and contributor docs
|
||||
CLAUDE.md
|
||||
CONTRIBUTING.md
|
||||
CODE_OF_CONDUCT.md
|
||||
|
||||
tools/
|
||||
coverage/
|
||||
TESTING.md
|
||||
AGENTS.md
|
||||
@@ -1,7 +1,8 @@
|
||||
[flake8]
|
||||
max-line-length = 127
|
||||
max-complexity = 10
|
||||
exclude =
|
||||
exclude =
|
||||
node_modules,
|
||||
.git,
|
||||
__pycache__,
|
||||
.mypy_cache,
|
||||
@@ -11,8 +12,9 @@ exclude =
|
||||
build,
|
||||
dist,
|
||||
*.egg-info,
|
||||
.tox
|
||||
ignore =
|
||||
.tox,
|
||||
referance
|
||||
ignore =
|
||||
# W503: line break before binary operator (conflicts with Black)
|
||||
W503,
|
||||
# E203: whitespace before ':' (conflicts with Black)
|
||||
@@ -32,4 +34,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
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"required_status_checks": {
|
||||
"strict": true,
|
||||
"contexts": [
|
||||
"py3.10 on ubuntu-latest", "py3.11 on ubuntu-latest", "py3.12 on ubuntu-latest", "py3.13 on ubuntu-latest",
|
||||
"py3.10 on windows-latest", "py3.11 on windows-latest", "py3.12 on windows-latest", "py3.13 on windows-latest",
|
||||
"py3.10 on macos-latest", "py3.11 on macos-latest", "py3.12 on macos-latest", "py3.13 on macos-latest",
|
||||
"coverage gate (85%)", "frontend syntax", "frontend workflows and coverage", "code-quality"
|
||||
]
|
||||
},
|
||||
"enforce_admins": true,
|
||||
"required_pull_request_reviews": null,
|
||||
"restrictions": null,
|
||||
"allow_force_pushes": false,
|
||||
"allow_deletions": false
|
||||
}
|
||||
@@ -7,4 +7,8 @@ updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/tools/testing"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
@@ -13,15 +13,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-quality-${{ hashFiles('**/requirements-dev.txt') }}
|
||||
@@ -31,6 +31,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Format check with Black
|
||||
@@ -93,6 +94,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 +142,10 @@ jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
@@ -164,16 +173,17 @@ jobs:
|
||||
architecture:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Check architecture compliance
|
||||
@@ -279,6 +289,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@v7
|
||||
with:
|
||||
submodules: true
|
||||
- name: Publish Custom Node
|
||||
|
||||
@@ -10,55 +10,28 @@ on:
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run full test suite
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
|
||||
# Run comprehensive tests before release
|
||||
from kikotools.base import ComfyAssetsBaseNode
|
||||
from kikotools.tools.resolution_calculator.logic import extract_dimensions, calculate_scaled_dimensions
|
||||
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode
|
||||
import torch
|
||||
|
||||
print('Running pre-release validation...')
|
||||
|
||||
# Test all major functionality
|
||||
node = ResolutionCalculatorNode()
|
||||
|
||||
# Test various scenarios
|
||||
test_cases = [
|
||||
(torch.randn(1, 512, 512, 3), 2.0),
|
||||
(torch.randn(1, 1024, 1024, 3), 1.5),
|
||||
(torch.randn(1, 1216, 832, 3), 1.53), # User scenario
|
||||
]
|
||||
|
||||
for i, (image, scale) in enumerate(test_cases):
|
||||
width, height = node.calculate_resolution(scale, image=image)
|
||||
print(f'✓ Test case {i+1}: {image.shape[2]}×{image.shape[1]} → {width}×{height} (scale: {scale})')
|
||||
assert width % 8 == 0 and height % 8 == 0
|
||||
|
||||
print('🎉 All pre-release tests passed!')
|
||||
"
|
||||
- name: Run full test suite and coverage gate
|
||||
run: python -m pytest tests --cov=kikotools --cov-report=term-missing
|
||||
|
||||
- name: Extract version from tag
|
||||
id: get_version
|
||||
@@ -100,7 +73,7 @@ jobs:
|
||||
### 📊 Technical Details
|
||||
|
||||
- **Nodes**: 1 (Resolution Calculator)
|
||||
- **Test Coverage**: 100%
|
||||
- **Python Coverage Gate**: 85% combined statement/branch coverage
|
||||
- **Python Support**: 3.8+
|
||||
- **ComfyUI Compatibility**: Latest
|
||||
|
||||
@@ -114,7 +87,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 }}
|
||||
@@ -147,4 +120,4 @@ jobs:
|
||||
echo "- Resolution Calculator tool"
|
||||
echo "- Complete documentation"
|
||||
echo "- Example workflows"
|
||||
echo "- 100% test coverage"
|
||||
echo "- Enforced Python coverage and frontend regression checks"
|
||||
|
||||
+109
-444
@@ -11,471 +11,136 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
name: py${{ matrix.python-version }} on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: [3.8, 3.9, "3.10", "3.11", "3.12"]
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements-dev.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
# The default Linux torch wheel bundles CUDA (~2.5 GB); tests only need CPU.
|
||||
# Windows and macOS default wheels are already CPU-only.
|
||||
- name: Install CPU torch (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run core functionality tests
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
- name: Run tests
|
||||
run: python -m pytest tests --junitxml=test-results/pytest.xml
|
||||
|
||||
# Test imports
|
||||
from kikotools.base import ComfyAssetsBaseNode
|
||||
from kikotools.tools.resolution_calculator.logic import extract_dimensions, calculate_scaled_dimensions
|
||||
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode
|
||||
import torch
|
||||
|
||||
print('✓ All imports successful')
|
||||
|
||||
# Test base node
|
||||
assert ComfyAssetsBaseNode.CATEGORY.startswith('ComfyAssets')
|
||||
print('✓ Base node tests passed')
|
||||
|
||||
# Test dimension extraction
|
||||
mock_image = torch.randn(1, 1216, 832, 3)
|
||||
width, height = extract_dimensions(image=mock_image)
|
||||
assert width == 832 and height == 1216
|
||||
print('✓ Dimension extraction tests passed')
|
||||
|
||||
# Test scaling
|
||||
new_width, new_height = calculate_scaled_dimensions(832, 1216, 1.5)
|
||||
assert new_width % 8 == 0 and new_height % 8 == 0
|
||||
print('✓ Scaling tests passed')
|
||||
|
||||
# Test node interface
|
||||
node = ResolutionCalculatorNode()
|
||||
result_width, result_height = node.calculate_resolution(1.5, image=mock_image)
|
||||
assert isinstance(result_width, int) and isinstance(result_height, int)
|
||||
assert result_width % 8 == 0 and result_height % 8 == 0
|
||||
print('✓ Node interface tests passed')
|
||||
|
||||
# Test user scenario
|
||||
width, height = node.calculate_resolution(1.53, image=mock_image)
|
||||
print(f'✓ User scenario test: 832×1216 → {width}×{height} (1.53x scale)')
|
||||
|
||||
print('🎉 All tests passed!')
|
||||
"
|
||||
|
||||
- name: Test Width Height Selector
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
|
||||
# Test Width Height Selector imports
|
||||
from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode
|
||||
from kikotools.tools.width_height_selector.presets import PRESET_OPTIONS, PRESET_METADATA
|
||||
from kikotools.tools.width_height_selector.logic import get_preset_dimensions
|
||||
|
||||
print('✓ Width Height Selector imports successful')
|
||||
|
||||
# Test preset structure
|
||||
assert len(PRESET_OPTIONS) > 0
|
||||
assert 'custom' in PRESET_OPTIONS
|
||||
assert len(PRESET_METADATA) > 0
|
||||
print('✓ Preset structure tests passed')
|
||||
|
||||
# Test node interface
|
||||
node = WidthHeightSelectorNode()
|
||||
input_types = node.INPUT_TYPES()
|
||||
assert 'required' in input_types
|
||||
assert 'preset' in input_types['required']
|
||||
assert 'width' in input_types['required']
|
||||
assert 'height' in input_types['required']
|
||||
print('✓ Node interface tests passed')
|
||||
|
||||
# Test formatted presets
|
||||
preset_options = input_types['required']['preset'][0]
|
||||
assert 'custom' in preset_options
|
||||
formatted_count = len([opt for opt in preset_options if ' - ' in opt and 'MP' in opt])
|
||||
assert formatted_count > 0
|
||||
print(f'✓ Found {formatted_count} formatted presets')
|
||||
|
||||
# Test dimension calculation
|
||||
result = node.get_dimensions('1024×1024', 512, 512)
|
||||
assert result == (1024, 1024)
|
||||
print('✓ Dimension calculation tests passed')
|
||||
|
||||
# Test formatted preset dimensions
|
||||
formatted_preset = '1024×1024 - 1:1 (1.1MP) - SDXL'
|
||||
result = node.get_dimensions(formatted_preset, 512, 512)
|
||||
assert result == (1024, 1024)
|
||||
print('✓ Formatted preset tests passed')
|
||||
|
||||
# Test preset extraction
|
||||
extracted = node._extract_preset_name(formatted_preset)
|
||||
assert extracted == '1024×1024'
|
||||
print('✓ Preset extraction tests passed')
|
||||
|
||||
print('🎉 All Width Height Selector tests passed!')
|
||||
"
|
||||
|
||||
- name: Test Sampler Combo
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
|
||||
# Test Sampler Combo imports
|
||||
from kikotools.tools.sampler_combo.node import SamplerComboNode
|
||||
from kikotools.tools.sampler_combo.logic import (
|
||||
get_sampler_combo, validate_sampler_settings, SAMPLERS, SCHEDULERS
|
||||
)
|
||||
|
||||
print('✓ Sampler Combo imports successful')
|
||||
|
||||
# Test node interface
|
||||
node = SamplerComboNode()
|
||||
input_types = node.INPUT_TYPES()
|
||||
assert 'required' in input_types
|
||||
assert 'sampler_name' in input_types['required']
|
||||
assert 'scheduler' in input_types['required']
|
||||
assert 'steps' in input_types['required']
|
||||
assert 'cfg' in input_types['required']
|
||||
print('✓ Sampler Combo interface tests passed')
|
||||
|
||||
# Test return types
|
||||
# RETURN_TYPES[1] is the actual SCHEDULERS list
|
||||
assert node.RETURN_TYPES[0] == 'SAMPLER'
|
||||
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/🌀 Samplers'
|
||||
print('✓ Sampler Combo return types tests passed')
|
||||
|
||||
# Test sampler combo functionality
|
||||
result = node.get_sampler_combo('euler', 'normal', 20, 7.0)
|
||||
assert result == ('euler', 'normal', 20, 7.0)
|
||||
print('✓ Sampler combo functionality tests passed')
|
||||
|
||||
# Test validation
|
||||
assert validate_sampler_settings('euler', 'normal', 20, 7.0) == True
|
||||
print('✓ Sampler validation tests passed')
|
||||
|
||||
# Test available samplers and schedulers
|
||||
samplers = node.get_available_samplers()
|
||||
schedulers = node.get_available_schedulers()
|
||||
assert len(samplers) > 0
|
||||
assert len(schedulers) > 0
|
||||
assert 'euler' in samplers
|
||||
assert 'normal' in schedulers
|
||||
print(f'✓ Found {len(samplers)} samplers and {len(schedulers)} schedulers')
|
||||
|
||||
print('🎉 All Sampler Combo tests passed!')
|
||||
"
|
||||
|
||||
- name: Test Seed History
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
|
||||
# Test Seed History imports
|
||||
from kikotools.tools.seed_history.node import SeedHistoryNode
|
||||
from kikotools.tools.seed_history.logic import (
|
||||
generate_random_seed, validate_seed_value, sanitize_seed_value
|
||||
)
|
||||
|
||||
print('✓ Seed History imports successful')
|
||||
|
||||
# Test node interface
|
||||
node = SeedHistoryNode()
|
||||
input_types = node.INPUT_TYPES()
|
||||
assert 'required' in input_types
|
||||
assert 'seed' in input_types['required']
|
||||
print('✓ Seed History interface tests passed')
|
||||
|
||||
# Test return types
|
||||
assert node.RETURN_TYPES == ('INT',)
|
||||
assert node.RETURN_NAMES == ('seed',)
|
||||
assert node.CATEGORY == 'ComfyAssets/🌱 Seeds'
|
||||
print('✓ Seed History return types tests passed')
|
||||
|
||||
# Test seed output functionality
|
||||
result = node.output_seed(12345)
|
||||
assert result == (12345,)
|
||||
print('✓ Seed output functionality tests passed')
|
||||
|
||||
# Test seed validation
|
||||
assert validate_seed_value(12345) == True
|
||||
assert validate_seed_value(-1) == False
|
||||
print('✓ Seed validation tests passed')
|
||||
|
||||
# Test seed generation
|
||||
new_seed = generate_random_seed()
|
||||
assert isinstance(new_seed, int)
|
||||
assert validate_seed_value(new_seed) == True
|
||||
print('✓ Seed generation tests passed')
|
||||
|
||||
# Test seed sanitization
|
||||
clean_seed = sanitize_seed_value(12345)
|
||||
assert clean_seed == 12345
|
||||
print('✓ Seed sanitization tests passed')
|
||||
|
||||
# Test node helper methods
|
||||
assert node.is_seed_in_range(12345) == True
|
||||
assert node.is_seed_in_range(-1) == False
|
||||
assert node.get_default_seed() == 12345
|
||||
print('✓ Seed helper methods tests passed')
|
||||
|
||||
print('🎉 All Seed History tests passed!')
|
||||
"
|
||||
|
||||
- name: Test error handling for all tools
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
|
||||
print('=== Testing Error Handling for All Tools ===')
|
||||
|
||||
# Test Resolution Calculator error handling
|
||||
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode
|
||||
res_node = ResolutionCalculatorNode()
|
||||
|
||||
try:
|
||||
res_node.calculate_resolution(2.0) # No input provided
|
||||
assert False, 'Should have raised ValueError'
|
||||
except ValueError:
|
||||
print('✓ Resolution Calculator error handling test passed')
|
||||
|
||||
try:
|
||||
res_node.calculate_resolution(0.0) # Invalid scale
|
||||
assert False, 'Should have raised ValueError'
|
||||
except ValueError:
|
||||
print('✓ Resolution Calculator scale factor validation test passed')
|
||||
|
||||
# Test Width Height Selector error handling
|
||||
from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode
|
||||
wh_node = WidthHeightSelectorNode()
|
||||
|
||||
# Test invalid preset fallback
|
||||
result = wh_node.get_dimensions('invalid_preset', 800, 600)
|
||||
assert result == (800, 600) # Should fallback to custom dimensions
|
||||
print('✓ Width Height Selector invalid preset handling test passed')
|
||||
|
||||
# Test Sampler Combo error handling
|
||||
from kikotools.tools.sampler_combo.node import SamplerComboNode
|
||||
sampler_node = SamplerComboNode()
|
||||
|
||||
# Test with invalid sampler (should use safe defaults)
|
||||
result = sampler_node.get_sampler_combo('invalid_sampler', 'normal', 20, 7.0)
|
||||
assert result == ('euler', 'normal', 20, 7.0) # Safe defaults
|
||||
print('✓ Sampler Combo invalid input handling test passed')
|
||||
|
||||
# Test Seed History error handling
|
||||
from kikotools.tools.seed_history.node import SeedHistoryNode
|
||||
seed_node = SeedHistoryNode()
|
||||
|
||||
# Test invalid seed value (should use fallback)
|
||||
result = seed_node.output_seed(-1) # Invalid negative seed
|
||||
assert result == (12345,) # Fallback seed
|
||||
print('✓ Seed History invalid seed handling test passed')
|
||||
|
||||
print('🎉 All error handling tests passed for all tools!')
|
||||
"
|
||||
|
||||
- name: Test ComfyUI integration readiness for all tools
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.getcwd())
|
||||
|
||||
print('=== Testing ComfyUI Integration for All Tools ===')
|
||||
|
||||
# Test Resolution Calculator
|
||||
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode
|
||||
res_class = ResolutionCalculatorNode
|
||||
|
||||
assert hasattr(res_class, 'INPUT_TYPES')
|
||||
assert hasattr(res_class, 'RETURN_TYPES')
|
||||
assert hasattr(res_class, 'RETURN_NAMES')
|
||||
assert hasattr(res_class, 'FUNCTION')
|
||||
assert hasattr(res_class, 'CATEGORY')
|
||||
|
||||
input_types = res_class.INPUT_TYPES()
|
||||
assert 'required' in input_types
|
||||
assert 'optional' in input_types
|
||||
assert 'scale_factor' in input_types['required']
|
||||
assert 'image' in input_types['optional']
|
||||
assert 'latent' in input_types['optional']
|
||||
|
||||
assert res_class.RETURN_TYPES == ('INT', 'INT')
|
||||
assert res_class.RETURN_NAMES == ('width', 'height')
|
||||
assert res_class.CATEGORY.startswith('ComfyAssets/')
|
||||
print('✓ Resolution Calculator ComfyUI integration passed')
|
||||
|
||||
# Test Width Height Selector
|
||||
from kikotools.tools.width_height_selector.node import WidthHeightSelectorNode
|
||||
wh_class = WidthHeightSelectorNode
|
||||
|
||||
assert hasattr(wh_class, 'INPUT_TYPES')
|
||||
assert hasattr(wh_class, 'RETURN_TYPES')
|
||||
assert hasattr(wh_class, 'RETURN_NAMES')
|
||||
assert hasattr(wh_class, 'FUNCTION')
|
||||
assert hasattr(wh_class, 'CATEGORY')
|
||||
|
||||
input_types = wh_class.INPUT_TYPES()
|
||||
assert 'required' in input_types
|
||||
assert 'preset' in input_types['required']
|
||||
assert 'width' in input_types['required']
|
||||
assert 'height' in input_types['required']
|
||||
|
||||
assert wh_class.RETURN_TYPES == ('INT', 'INT')
|
||||
assert wh_class.RETURN_NAMES == ('width', 'height')
|
||||
assert wh_class.CATEGORY.startswith('ComfyAssets/')
|
||||
print('✓ Width Height Selector ComfyUI integration passed')
|
||||
|
||||
# Test Sampler Combo
|
||||
from kikotools.tools.sampler_combo.node import SamplerComboNode
|
||||
sampler_class = SamplerComboNode
|
||||
|
||||
assert hasattr(sampler_class, 'INPUT_TYPES')
|
||||
assert hasattr(sampler_class, 'RETURN_TYPES')
|
||||
assert hasattr(sampler_class, 'RETURN_NAMES')
|
||||
assert hasattr(sampler_class, 'FUNCTION')
|
||||
assert hasattr(sampler_class, 'CATEGORY')
|
||||
|
||||
input_types = sampler_class.INPUT_TYPES()
|
||||
assert 'required' in input_types
|
||||
assert 'sampler_name' in input_types['required']
|
||||
assert 'scheduler' in input_types['required']
|
||||
assert 'steps' in input_types['required']
|
||||
assert 'cfg' in input_types['required']
|
||||
|
||||
assert sampler_class.CATEGORY.startswith('ComfyAssets/')
|
||||
print('✓ Sampler Combo ComfyUI integration passed')
|
||||
|
||||
# Test Seed History
|
||||
from kikotools.tools.seed_history.node import SeedHistoryNode
|
||||
seed_class = SeedHistoryNode
|
||||
|
||||
assert hasattr(seed_class, 'INPUT_TYPES')
|
||||
assert hasattr(seed_class, 'RETURN_TYPES')
|
||||
assert hasattr(seed_class, 'RETURN_NAMES')
|
||||
assert hasattr(seed_class, 'FUNCTION')
|
||||
assert hasattr(seed_class, 'CATEGORY')
|
||||
|
||||
input_types = seed_class.INPUT_TYPES()
|
||||
assert 'required' in input_types
|
||||
assert 'seed' in input_types['required']
|
||||
|
||||
assert seed_class.RETURN_TYPES == ('INT',)
|
||||
assert seed_class.RETURN_NAMES == ('seed',)
|
||||
assert seed_class.CATEGORY.startswith('ComfyAssets/')
|
||||
print('✓ Seed History ComfyUI integration passed')
|
||||
|
||||
print('🎉 All tools ComfyUI integration readiness tests passed!')
|
||||
"
|
||||
|
||||
test-package-structure:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v5
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
python-version: "3.10"
|
||||
name: python-${{ matrix.os }}-${{ matrix.python-version }}
|
||||
path: test-results/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: Test package structure
|
||||
run: |
|
||||
# Check required files exist
|
||||
test -f README.md || (echo "README.md missing" && exit 1)
|
||||
test -f LICENSE || (echo "LICENSE missing" && exit 1)
|
||||
test -f requirements-dev.txt || (echo "requirements-dev.txt missing" && exit 1)
|
||||
|
||||
# Check directory structure
|
||||
test -d kikotools || (echo "kikotools directory missing" && exit 1)
|
||||
test -d kikotools/base || (echo "kikotools/base directory missing" && exit 1)
|
||||
test -d kikotools/tools || (echo "kikotools/tools directory missing" && exit 1)
|
||||
test -d kikotools/tools/resolution_calculator || (echo "resolution_calculator directory missing" && exit 1)
|
||||
test -d kikotools/tools/width_height_selector || (echo "width_height_selector directory missing" && exit 1)
|
||||
test -d kikotools/tools/sampler_combo || (echo "sampler_combo directory missing" && exit 1)
|
||||
test -d kikotools/tools/seed_history || (echo "seed_history directory missing" && exit 1)
|
||||
test -d tests || (echo "tests directory missing" && exit 1)
|
||||
test -d examples || (echo "examples directory missing" && exit 1)
|
||||
test -d web || (echo "web directory missing" && exit 1)
|
||||
|
||||
# Check key files
|
||||
test -f kikotools/__init__.py || (echo "kikotools/__init__.py missing" && exit 1)
|
||||
test -f kikotools/base/base_node.py || (echo "base_node.py missing" && exit 1)
|
||||
|
||||
# Resolution Calculator files
|
||||
test -f kikotools/tools/resolution_calculator/node.py || (echo "resolution_calculator node.py missing" && exit 1)
|
||||
test -f kikotools/tools/resolution_calculator/logic.py || (echo "resolution_calculator logic.py missing" && exit 1)
|
||||
|
||||
# Width Height Selector files
|
||||
test -f kikotools/tools/width_height_selector/node.py || (echo "width_height_selector node.py missing" && exit 1)
|
||||
test -f kikotools/tools/width_height_selector/logic.py || (echo "width_height_selector logic.py missing" && exit 1)
|
||||
test -f kikotools/tools/width_height_selector/presets.py || (echo "width_height_selector presets.py missing" && exit 1)
|
||||
|
||||
# Sampler Combo files
|
||||
test -f kikotools/tools/sampler_combo/node.py || (echo "sampler_combo node.py missing" && exit 1)
|
||||
test -f kikotools/tools/sampler_combo/logic.py || (echo "sampler_combo logic.py missing" && exit 1)
|
||||
|
||||
# Seed History files
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
echo "✓ Package structure tests passed"
|
||||
|
||||
test-documentation:
|
||||
coverage:
|
||||
name: coverage gate (85%)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Test documentation completeness
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
# Check documentation files for all tools
|
||||
test -f examples/documentation/resolution_calculator.md || (echo "Resolution calculator docs missing" && exit 1)
|
||||
test -f examples/workflows/resolution_calculator_example.json || (echo "Resolution calculator workflow missing" && exit 1)
|
||||
test -f examples/documentation/width_height_selector.md || (echo "Width height selector docs missing" && exit 1)
|
||||
test -f examples/workflows/width_height_selector_example.json || (echo "Width height selector workflow missing" && exit 1)
|
||||
test -f examples/documentation/sampler_combo.md || (echo "Sampler combo docs missing" && exit 1)
|
||||
test -f examples/workflows/sampler_combo_example.json || (echo "Sampler combo workflow missing" && exit 1)
|
||||
test -f examples/documentation/seed_history.md || (echo "Seed history docs missing" && exit 1)
|
||||
test -f examples/workflows/seed_history_example.json || (echo "Seed history workflow missing" && exit 1)
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
# Check README has key sections
|
||||
grep -q "Installation" README.md || (echo "README missing Installation section" && exit 1)
|
||||
grep -q "Quick Start" README.md || (echo "README missing Quick Start section" && exit 1)
|
||||
grep -q "Development" README.md || (echo "README missing Development section" && exit 1)
|
||||
grep -q "Contributing" README.md || (echo "README missing Contributing section" && exit 1)
|
||||
# fail_under = 85 lives in pyproject.toml [tool.coverage.report]
|
||||
- name: Measure coverage
|
||||
run: python -m pytest tests --cov=kikotools --cov-report=term-missing --cov-report=html --cov-report=xml
|
||||
|
||||
echo "✓ Documentation tests passed"
|
||||
- name: Upload coverage reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: python-coverage
|
||||
path: |
|
||||
htmlcov/
|
||||
coverage.xml
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
js-check:
|
||||
name: frontend syntax
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
# Check every shipped module, including files without behavioral tests.
|
||||
- name: Check web/*.js parse as ES modules
|
||||
run: |
|
||||
for f in web/*.js; do
|
||||
node --input-type=module --check < "$f" || { echo "Syntax error in $f"; exit 1; }
|
||||
done
|
||||
|
||||
frontend:
|
||||
name: frontend workflows and coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
- name: Install Python test dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements-dev.txt
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
cache-dependency-path: tools/testing/package-lock.json
|
||||
- run: npm ci --prefix tools/testing --ignore-scripts
|
||||
- run: npx --prefix tools/testing playwright install --with-deps chromium firefox webkit
|
||||
- name: JavaScript regressions, Chromium workflows, and coverage gate
|
||||
run: npm run --prefix tools/testing test:coverage
|
||||
- name: Firefox and WebKit workflows
|
||||
run: npm run --prefix tools/testing test:browser -- --project firefox --project webkit
|
||||
- name: Shared UI design preview interactions
|
||||
run: node tools/testing/node_modules/@playwright/test/cli.js test --config docs/ui-preview/playwright.config.mjs
|
||||
- name: Upload frontend reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-test-reports
|
||||
path: |
|
||||
tools/testing/test-results/
|
||||
coverage/js/
|
||||
!coverage/js/raw/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
+12
@@ -159,7 +159,19 @@ test_images/
|
||||
test_outputs/
|
||||
experiments/
|
||||
.claude/
|
||||
.serena
|
||||
|
||||
# Gemini model cache
|
||||
.gemini_models_cache.json
|
||||
referance/
|
||||
|
||||
# Runtime state written inside the package (repo is symlinked into custom_nodes)
|
||||
kikotools/tools/local_image_loader/config.json
|
||||
kikotools/tools/local_image_loader/selections.json
|
||||
gemini_config.json
|
||||
|
||||
# Frontend test tooling and reports
|
||||
node_modules/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
+21
-4
@@ -4,10 +4,9 @@
|
||||
repos:
|
||||
# Python code formatting with Black
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 25.1.0
|
||||
rev: 26.10.1
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.10
|
||||
args: ['--line-length=88'] # Match CI configuration
|
||||
|
||||
# Python linting with flake8
|
||||
@@ -61,9 +60,27 @@ repos:
|
||||
args: ['--baseline', '.secrets.baseline']
|
||||
exclude: '^(tests/|\.git/)'
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: python-regressions
|
||||
name: Python regression suite
|
||||
entry: python -m pytest tests
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
stages: [pre-push]
|
||||
- id: frontend-regressions
|
||||
name: JavaScript regression suite
|
||||
entry: npm test --prefix tools/testing
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
stages: [pre-push]
|
||||
|
||||
# Configuration for specific hooks
|
||||
default_stages: [pre-commit]
|
||||
default_language_version:
|
||||
python: python3.10
|
||||
python: python3
|
||||
|
||||
# Run hooks on all files by default
|
||||
fail_fast: false
|
||||
@@ -81,4 +98,4 @@ exclude: |
|
||||
.*\.egg-info/|
|
||||
venv/|
|
||||
env/
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
`kikotools/tools/<tool>/logic.py` contains reusable logic; `node.py` defines the
|
||||
ComfyUI node. Shared classes live in `kikotools/base/`. Register nodes in
|
||||
`kikotools/__init__.py`; the root `__init__.py` exposes the plugin and API routes.
|
||||
`web/` contains browser extensions. Tests live in `tests/unit/`, `tests/js/`, and
|
||||
`tests/browser/`. Examples are in `examples/`.
|
||||
|
||||
## Development Commands
|
||||
|
||||
Install Python dependencies with `python -m pip install -r requirements-dev.txt`.
|
||||
Run `python -m pytest tests` or `make test` for the complete Python suite.
|
||||
Use `python -m pytest tests --cov=kikotools` for the 85% statement/branch gate.
|
||||
Format with `black .`; use `flake8 .` for lint findings.
|
||||
|
||||
Frontend tooling needs Node.js 24 and `npm ci --prefix tools/testing
|
||||
--ignore-scripts`. Run `npm test --prefix tools/testing` for JavaScript tests,
|
||||
`npm run --prefix tools/testing test:coverage` for coverage and Chromium workflows,
|
||||
and `npm run --prefix tools/testing test:browser` for all browser projects.
|
||||
See `TESTING.md` for browser installation, environment overrides, and artifacts.
|
||||
|
||||
## Coding Style & Naming
|
||||
|
||||
Use four-space Python indentation and Black's 88-character formatting.
|
||||
Use `snake_case` for Python functions/modules, `PascalCase` for classes, and
|
||||
`UPPER_CASE` for constants. Keep node interfaces separate from logic. Preserve
|
||||
existing ComfyUI lifecycle hooks when extending widgets and clean up timers,
|
||||
listeners, and observers when nodes are removed.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Write regression tests first and verify failure before implementing fixes.
|
||||
Name Python tests `test_*.py` and JavaScript tests `*.test.mjs`; put browser
|
||||
workflows in `tests/browser/*.spec.mjs`. Test behavior and persistence, including
|
||||
failure recovery. Keep fixtures disposable and external services mocked.
|
||||
Never lower coverage floors to make a change pass.
|
||||
|
||||
## Commits & Pull Requests
|
||||
|
||||
Use Conventional Commits, such as `fix(local_image_loader): preserve error
|
||||
status`. Link the tracked issue, describe observable behavior, and report the
|
||||
commands and results used for validation. Include screenshots for visual changes.
|
||||
Required checks must pass before merging. Keep credentials and runtime files out
|
||||
of commits; restart ComfyUI after installing Python changes.
|
||||
+4
-1
@@ -357,9 +357,12 @@ Brief description of changes.
|
||||
|
||||
### Review Process
|
||||
|
||||
See [TESTING.md](TESTING.md) for automated suites, coverage gates, required merge
|
||||
checks, and the remaining ComfyUI/GPU/service boundaries.
|
||||
|
||||
1. **Automated Checks**: CI runs tests and quality checks
|
||||
2. **Code Review**: Maintainer reviews code and provides feedback
|
||||
3. **Testing**: Manual testing in ComfyUI environment
|
||||
3. **Testing**: Run relevant automated suites; use live ComfyUI checks for host, GPU, service, or visual changes
|
||||
4. **Merge**: After approval, changes are merged to main
|
||||
|
||||
## 🎨 Code Style Guidelines
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ComfyUI-KikoTools Development Makefile
|
||||
|
||||
.PHONY: help install test test-fast lint format type-check quality-check clean setup dev-test release-test
|
||||
.PHONY: coverage frontend-test browser-test frontend-coverage help install test test-fast lint format type-check quality-check clean setup dev-test release-test
|
||||
|
||||
# Python and virtual environment setup
|
||||
PYTHON := python3
|
||||
@@ -34,7 +34,11 @@ help:
|
||||
@echo " quality-check - Run all quality checks"
|
||||
@echo ""
|
||||
@echo "Testing:"
|
||||
@echo " test - Run comprehensive tests"
|
||||
@echo " test - Run the complete pytest suite"
|
||||
@echo " coverage - Python statement/branch coverage (85% gate)"
|
||||
@echo " frontend-test - JavaScript regression tests"
|
||||
@echo " browser-test - Extension workflows in four browser configurations"
|
||||
@echo " frontend-coverage - JavaScript tests, browser tests, and coverage gate"
|
||||
@echo " test-fast - Run core functionality tests"
|
||||
@echo " dev-test - Quick development test"
|
||||
@echo " release-test - Full release validation"
|
||||
@@ -120,13 +124,20 @@ test-fast: $(VENV_DIR)
|
||||
print('🎉 All core tests passed!'); \
|
||||
"
|
||||
|
||||
test: test-fast
|
||||
@echo "Running comprehensive test suite..."
|
||||
@echo "✅ Test case 1: 512×512 → 1024×1024 (scale: 2.0)"
|
||||
@echo "✅ Test case 2: 1024×1024 → 1536×1536 (scale: 1.5)"
|
||||
@echo "✅ Test case 3: 832×1216 → 1272×1864 (scale: 1.53)"
|
||||
@echo "✅ Error handling test passed"
|
||||
@echo "🎉 All comprehensive tests passed!"
|
||||
test: $(VENV_DIR)
|
||||
@$(PYTHON_CMD) -m pytest tests
|
||||
|
||||
coverage: $(VENV_DIR)
|
||||
@$(PYTHON_CMD) -m pytest tests --cov=kikotools --cov-report=term-missing --cov-report=html
|
||||
|
||||
frontend-test:
|
||||
npm test --prefix tools/testing
|
||||
|
||||
browser-test:
|
||||
npm run --prefix tools/testing test:browser
|
||||
|
||||
frontend-coverage:
|
||||
npm run --prefix tools/testing test:coverage
|
||||
|
||||
release-test: quality-check test
|
||||
@echo "Running release validation..."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,6 +33,14 @@ ComfyUI-KikoTools provides carefully crafted, production-ready nodes grouped und
|
||||
| [🤖 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 |
|
||||
| [🗂️ Project File Path](#️-project-file-path) | Build safe, organized save prefixes like `MyProject/images/image` | 🛠️ Utils |
|
||||
|
||||
### 🧰 xyz-helpers Tools
|
||||
|
||||
@@ -35,6 +50,8 @@ Advanced parameter management tools adapted from [comfyui-essentials-nodes](http
|
||||
|------|-------------|----------|
|
||||
| [🎛️ 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 |
|
||||
| [🔁 Checkpoint Folder Iterator](#-checkpoint-folder-iterator) | Load one checkpoint per queued run from a folder | 🧰 xyz-helpers |
|
||||
| [🧩 Model Compare Sheet](#-model-compare-sheet) | Save each run's image and rebuild a labeled contact sheet | 🧰 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 |
|
||||
@@ -218,6 +235,41 @@ 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.
|
||||
|
||||
@@ -251,6 +303,28 @@ Automated batch processing for multiple LoRA models from folders.
|
||||
|
||||

|
||||
|
||||
#### 🔁 Checkpoint Folder Iterator
|
||||
Run the same prompt through every checkpoint in a folder, one model per queued run.
|
||||
|
||||
- **folder**: relative to `models/checkpoints`, or an absolute path anywhere on disk; regex include/exclude filters
|
||||
- **index** with `control_after_generate`: set 0 + *increment*, then queue a batch count equal to the checkpoint count
|
||||
- Queuing past the last checkpoint fails that run with a clear message instead of wrapping around, so the finished sweep is never restarted or archived by accident
|
||||
- Outputs MODEL/CLIP/VAE for the indexed file plus `ckpt_name`, `label`, `index`, `count`
|
||||
- Only one checkpoint is loaded at a time, so large folders do not exhaust RAM
|
||||
|
||||
#### 🧩 Model Compare Sheet
|
||||
Output node that pairs with Checkpoint Folder Iterator.
|
||||
|
||||
- Saves each run's image(s) to `output/model_compare/<run_name>/<NN>_<label>.png`
|
||||
- Rebuilds `grid.png` from every tile saved so far, each labeled with its filename
|
||||
- `columns`, `tile_width`, `font_size` control the layout; the grid is also returned as IMAGE
|
||||
- The node preview (and anything else that reads it) gets `grid_preview.png`, at most 1024 px wide; `grid.png` keeps full resolution
|
||||
- After the last queued run the preview shows the complete sheet
|
||||
- Wire Project File Path into `run_name` for `project/date/sweep` folders
|
||||
- A run at index 0 moves tiles already in the folder to `<run_name>_<timestamp>`, so an interrupted sweep never leaks into the new grid
|
||||
- Rerunning a nonzero index replaces its complete batch, even when the label or batch size changes; other indexes are preserved
|
||||
- Replacement PNGs are fully written before existing tiles are changed; a failed tile write preserves the prior tiles and grid
|
||||
|
||||
#### 📊 Plot Parameters
|
||||
Visual analysis tool for understanding parameter relationships and effects.
|
||||
|
||||
@@ -311,6 +385,163 @@ Unified interface for text encoding and sampler parameter management.
|
||||
- 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
|
||||
|
||||
#### 🗂️ Project File Path
|
||||
Builds an organized save prefix to wire into a save node's `filename_prefix`.
|
||||
|
||||
- **Structure**: `root/project_name/[YYYY-MM-DD/]subfolder/filename`, e.g. `MyProject/2026-10-13/images/image`
|
||||
- **Date Folder**: Optional toggle inserts today's date after the project; the node re-runs when the date changes
|
||||
- **Safe Paths**: Unsafe characters are removed, spaces become underscores, and `..` segments are dropped so paths stay inside the output folder
|
||||
- **Nested Folders**: Use `a/b` in `subfolder` for deeper structure
|
||||
- **Extensions**: Known file extensions (`.png`, `.mp4`, ...) are stripped from `filename`; `render_v1.5` is left as is
|
||||
- **Separator**: `auto`, `forward_slash` or `backslash`
|
||||
|
||||
Based on the idea of the Project File Path Generator from djz-nodes.
|
||||
|
||||
### 🔤 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:**
|
||||
@@ -332,7 +563,7 @@ Unified interface for text encoding and sampler parameter management.
|
||||
### 🔧 Architecture Highlights
|
||||
|
||||
- **Modular Design**: Each tool is self-contained and independently testable
|
||||
- **Test-Driven Development**: 100% test coverage with comprehensive unit tests
|
||||
- **Test-Driven Development**: Regression tests for Python logic, JavaScript extensions, and browser workflows
|
||||
- **Clean Interfaces**: Standardized input/output patterns across all tools
|
||||
- **Separation of Concerns**: Clear distinction between logic, UI, and integration layers
|
||||
- **SOLID Principles**: Extensible architecture following software engineering best practices
|
||||
@@ -545,12 +776,17 @@ Example workflow available: [xyz_helpers_lora_testing.json](examples/workflows/x
|
||||
| **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 |
|
||||
|
||||
@@ -791,8 +1027,8 @@ Following **Test-Driven Development (TDD)**:
|
||||
# Test structure
|
||||
tests/
|
||||
├── unit/ # Individual component tests
|
||||
├── integration/ # ComfyUI workflow tests
|
||||
└── fixtures/ # Test data and workflows
|
||||
├── js/ # Extension and coverage-gate regression tests
|
||||
└── browser/ # Browser workflows and disposable host fixtures
|
||||
```
|
||||
|
||||
### Adding New Tools
|
||||
@@ -833,7 +1069,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
|
||||
|
||||
@@ -844,16 +1080,18 @@ MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 📈 Stats
|
||||
|
||||
- **Nodes**: 16 (10 core tools + 6 xyz-helpers)
|
||||
- **Categories**: 8 emoji-based categories for better organization
|
||||
- **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**: 8+ (swap buttons, history UI, popup viewers, parameter visualization)
|
||||
- **AI Integration**: Gemini API with 40+ model support
|
||||
- **Test Coverage**: 100% (300+ comprehensive tests)
|
||||
- **Test Coverage**: Enforced 85% Python statement/branch coverage and per-file frontend coverage floors
|
||||
- **Python Version**: 3.8+
|
||||
- **ComfyUI Compatibility**: Latest
|
||||
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow, google-generativeai for Gemini)
|
||||
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow, google-genai for Gemini)
|
||||
|
||||
## 🙏 Attribution
|
||||
|
||||
@@ -879,3 +1117,8 @@ All adaptations maintain compatibility while adding new features and optimizatio
|
||||
[⭐ Star this repo](https://github.com/ComfyAssets/ComfyUI-KikoTools) • [🐛 Report Bug](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues) • [💡 Request Feature](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues)
|
||||
|
||||
</div>
|
||||
|
||||
## Automated testing
|
||||
|
||||
See [TESTING.md](TESTING.md) for Python and JavaScript tests, cross-browser
|
||||
workflows, coverage gates, CI reports, and required merge checks.
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
# Testing and merge confidence
|
||||
|
||||
Use regression tests before fixing a bug. Confirm that the test fails on the
|
||||
original implementation, then run the relevant suites after the fix.
|
||||
|
||||
## What runs
|
||||
|
||||
| Layer | Real components | Replaced boundaries |
|
||||
| --- | --- | --- |
|
||||
| Python | Tool logic, tensors, image files, API validation | ComfyUI services and external models where needed |
|
||||
| JavaScript | Production extension hooks and callback behavior | Minimal host objects; controlled timers for focused regressions |
|
||||
| Browser | All shipped extension imports and lifecycle registration; real plugin startup, HTTP routes, PNG thumbnails, selection persistence, and image loading | ComfyUI graph/widgets/settings/event interfaces and its saved-image serving endpoint |
|
||||
|
||||
Browser workflows cover swaps, local image browsing/selection, pagination,
|
||||
sorting, hidden folders, directory autocomplete, saved paths,
|
||||
denied paths and network recovery, display output, seed persistence/cleanup, and
|
||||
workflow timing, autocomplete insertion, and saved-image previews. They run in Chromium, Firefox, WebKit, and touch Chromium.
|
||||
The harness binds to localhost and writes configuration, selections, and fixture
|
||||
images only inside a temporary directory. External browser requests fail tests;
|
||||
custom-word downloads are disabled in the fixture. No credentials, model
|
||||
weights, running ComfyUI, or GPU are required.
|
||||
|
||||
## Run locally
|
||||
|
||||
Use Python 3.10–3.13 and Node.js 24. Install the CPU PyTorch wheel on Linux if you
|
||||
do not need CUDA, then install the development dependencies:
|
||||
|
||||
```sh
|
||||
python -m pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
python -m pip install -r requirements-dev.txt
|
||||
python -m pytest tests
|
||||
python -m pytest tests --cov=kikotools --cov-report=term-missing
|
||||
npm ci --prefix tools/testing --ignore-scripts
|
||||
npx --prefix tools/testing playwright install --with-deps chromium firefox webkit
|
||||
npm test --prefix tools/testing
|
||||
npm run --prefix tools/testing test:coverage
|
||||
npm run --prefix tools/testing test:browser -- --project firefox --project webkit
|
||||
```
|
||||
|
||||
`make test` runs the full pytest suite and propagates failures. `make coverage`,
|
||||
`make frontend-test`, `make browser-test`, and `make frontend-coverage` provide
|
||||
shortcuts. The Python Make targets use the existing virtual-environment setup.
|
||||
Install local commit and push hooks with `pre-commit install --hook-type
|
||||
pre-commit --hook-type pre-push`. The push hooks run Python and JavaScript
|
||||
regressions; CI additionally runs all browser workflows and coverage gates.
|
||||
For a quick browser check, use `test:browser -- --project chromium`.
|
||||
`KIKO_TEST_PYTHON` selects a Python executable with the development dependencies;
|
||||
`KIKO_TEST_PORT` changes the default port 8766. Servers are never reused.
|
||||
Unsupported Linux distributions may lack WebKit libraries; Ubuntu CI validates
|
||||
all engines.
|
||||
|
||||
## Coverage and diagnosis
|
||||
|
||||
The standalone shared UI design preview has a separate Playwright configuration.
|
||||
Run `node tools/testing/node_modules/@playwright/test/cli.js test --config
|
||||
docs/ui-preview/playwright.config.mjs` after installing the browser dependencies.
|
||||
It runs in the existing frontend CI job and writes screenshots/traces under
|
||||
`tools/testing/test-results/ui-preview/`. See
|
||||
[the preview guide](docs/ui-preview/README.md) for local review and its limits.
|
||||
These checks cover a mockup, not production widget or renderer compatibility.
|
||||
|
||||
Python CI checks all twelve Python/OS combinations and enforces 85% combined
|
||||
statement/branch coverage. Every production `web/*.js` file appears in frontend
|
||||
coverage, including code without behavioral coverage. V8 unit coverage and
|
||||
Chromium desktop/touch coverage are merged through c8. The committed per-file
|
||||
floors are an honest starting point, not a claim that every feature is tested.
|
||||
|
||||
`npm run --prefix tools/testing coverage:update` reruns the suites and raises
|
||||
floors with two percentage points of runtime allowance. It cannot lower existing
|
||||
floors; new source files require 80% line coverage when added with this command.
|
||||
Missing measurements, missing baselines, and per-file regressions fail CI.
|
||||
|
||||
CI artifacts include Python JUnit results, Python HTML/XML coverage, and
|
||||
`frontend-test-reports` with frontend HTML coverage and browser failure traces
|
||||
and screenshots. Open traces using Playwright's `show-trace` command. Local
|
||||
frontend coverage is at `coverage/js/index.html`.
|
||||
|
||||
Real ComfyUI/LiteGraph compatibility, GPU execution, cloud inference, network
|
||||
model downloads, and visual design still need targeted live checks when changed.
|
||||
The browser host is a test double; these workflows do not run the real ComfyUI
|
||||
frontend or certify every node's UI. Type checking and security jobs retain their
|
||||
existing advisory behavior.
|
||||
|
||||
## Required merge checks
|
||||
|
||||
The manifest in `.github/branch-protection.json` requires all Python matrix jobs,
|
||||
Python coverage, frontend syntax, frontend workflows/coverage, and the existing
|
||||
`code-quality` job (including Black and fatal lint checks). Administrators follow
|
||||
the same checks and branches must be current with main. No extra human approval
|
||||
is required. Workflows run on every pull request without path filters.
|
||||
|
||||
After the checks pass, an administrator applies and verifies the manifest:
|
||||
|
||||
```sh
|
||||
gh api --method PUT repos/ComfyAssets/ComfyUI-KikoTools/branches/main/protection \
|
||||
--input .github/branch-protection.json
|
||||
gh api repos/ComfyAssets/ComfyUI-KikoTools/branches/main/protection/required_status_checks
|
||||
```
|
||||
|
||||
Keep the manifest synchronized with job names when changing CI.
|
||||
+90
-3
@@ -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():
|
||||
@@ -21,7 +105,7 @@ def get_version():
|
||||
try:
|
||||
pyproject_path = Path(__file__).parent / "pyproject.toml"
|
||||
if pyproject_path.exists():
|
||||
content = pyproject_path.read_text()
|
||||
content = pyproject_path.read_text(encoding="utf-8")
|
||||
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
@@ -34,7 +118,10 @@ def get_version():
|
||||
print()
|
||||
print(f"\033[94m[ComfyUI-KikoTools] Version:\033[0m {get_version()}")
|
||||
for node_key, display_name in NODE_DISPLAY_NAME_MAPPINGS.items():
|
||||
print(f"🫶 \033[94mLoaded:\033[0m {display_name}")
|
||||
# ASCII only: a Windows console using a legacy code page cannot print emoji,
|
||||
# and a UnicodeEncodeError here would abort loading the whole node.
|
||||
ascii_name = display_name.encode("ascii", "ignore").decode().strip()
|
||||
print(f" \033[94mLoaded:\033[0m {ascii_name}")
|
||||
print(f"\033[94mTotal: {len(NODE_CLASS_MAPPINGS)} tools loaded\033[0m")
|
||||
print()
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# ComfyAssets UI foundation
|
||||
|
||||
Status: proposed. This document defines the implementation sequence; it does not
|
||||
claim that the current extensions meet these requirements.
|
||||
|
||||
Tracking: [KikoTools #81](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues/81)
|
||||
and [PromptManager #186](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/186).
|
||||
|
||||
## Problem and intended result
|
||||
|
||||
KikoTools extensions independently construct controls, styles, windows, and host
|
||||
integration. The Local Image Loader combines these concerns in
|
||||
`web/kiko_local_image_loader.js`. PromptManager has useful `--pm-*` theme tokens,
|
||||
but its pages and dialogs have different behavior and lifecycle requirements.
|
||||
|
||||
Build an original ComfyAssets foundation so both projects can offer reusable
|
||||
controls, consistent editor layouts, shared settings/help/window behavior, and
|
||||
tested Classic/Nodes 2.0 compatibility. Both projects are one toolset and must
|
||||
look identical. Preserve existing node interfaces, workflows, routes, and features.
|
||||
|
||||
Architecture inspiration: [Pixaroma's editor framework](https://github.com/pixaroma/ComfyUI-Pixaroma/blob/78745d0860649d796032265d50711601e70f748e/js/framework/index.mjs)
|
||||
and [shared infrastructure](https://github.com/pixaroma/ComfyUI-Pixaroma/blob/78745d0860649d796032265d50711601e70f748e/js/shared/index.mjs).
|
||||
Credit this inspiration in the shipped documentation. Write our own implementation
|
||||
and visual design; do not copy or vendor Pixaroma code, assets, or styles.
|
||||
|
||||
## Boundaries
|
||||
|
||||
| Layer | Owns | Must remain independent of |
|
||||
| --- | --- | --- |
|
||||
| Controls | Buttons, fields, selections, accessible labels and state | Node names, HTTP routes, renderer internals |
|
||||
| Layout | Title, toolbar, workspace, optional sidebar and status slots | Gallery fetching and workflow serialization |
|
||||
| Services | Settings, help, floating windows, cleanup scopes | Product colors and node-specific data |
|
||||
| ComfyUI adapter | DOM widget attachment, renderer changes, zoom/resize, graph edits | Product layouts and themes |
|
||||
| Product controllers | API calls, selection, commands, existing persistence | Renderer-specific implementation details |
|
||||
| Theme | Scoped tokens, typography, spacing, colors, focus and interaction states | Business behavior |
|
||||
|
||||
Use native DOM modules and semantic controls for the pilot. Introduce no frontend
|
||||
framework just for this work. Controls receive values and callbacks; controllers
|
||||
retain state. Mounting returns an idempotent disposer. Disposal cancels pending
|
||||
requests and timers, disconnects observers, removes listeners, and closes owned
|
||||
windows. Late asynchronous responses cannot update a removed or superseded view.
|
||||
|
||||
Keep ComfyUI imports behind the host adapter. Detect supported capabilities and
|
||||
subscribe to host changes where available. Any fallback watcher belongs in the
|
||||
adapter, has one explicit owner, and stops on disposal. Preserve existing host
|
||||
hooks and their return values. Do not scatter renderer checks through controls.
|
||||
|
||||
## Independent installation and shared source
|
||||
|
||||
During the pilot, keep the original source in KikoTools under `web/ui/`, with
|
||||
product controllers outside that directory. Avoid a premature published package
|
||||
or a second implementation in PromptManager.
|
||||
|
||||
Before PromptManager adopts controls, extract the proven core into one versioned
|
||||
ComfyAssets source package. Use it as a pinned build-time dependency. Commit the
|
||||
generated browser distribution, license, version, source revision, and integrity
|
||||
metadata in each consumer so ordinary git/Manager installations require neither
|
||||
npm nor a second custom node pack. A documented update command must regenerate
|
||||
the distribution; CI must reject hand-edited or stale output. Package hosting
|
||||
and release automation are decisions for that extraction PR.
|
||||
|
||||
Each consumer uses relative imports and its own root namespace. Avoid global
|
||||
custom-element registration and mutable page-wide singletons in the pilot.
|
||||
Settings and storage keys include a product identifier. Styles remain below the
|
||||
consumer root. Floating windows inherit that consumer's tokens even when attached
|
||||
to the document body. Different bundled versions must coexist without selecting
|
||||
whichever version happened to load first. Test this explicitly during extraction.
|
||||
|
||||
## Agreed styling and theme direction
|
||||
|
||||
Both projects share exactly the same identity: controls, typography, spacing,
|
||||
colors and interaction states. Follow ComfyUI's light/dark mode with our own
|
||||
styling. Use ComfyUI-style blue for the primary accent and a strong, readable red
|
||||
for errors and destructive actions. Pixaroma's orange is not our accent.
|
||||
|
||||
The maintainer's screenshot references establish compact, uniform buttons,
|
||||
pill switches, segmented selections, structured editable rows, and correct
|
||||
resizing. Existing styles are migration context, not the target. Define shared
|
||||
semantic tokens and present the [interactive pilot mockup](ui-preview/README.md)
|
||||
for review before production migration. Keep behavioral APIs independent of theme.
|
||||
|
||||
Follow host light/dark changes while mounted. Standalone PromptManager pages
|
||||
need an explicit theme/settings bridge because a separate
|
||||
page does not inherit its CSS variables. Any light/dark or accent choices must
|
||||
preserve readable text and focus indicators.
|
||||
|
||||
Review the pilot in light and dark themes, at narrow and wide node sizes, and
|
||||
with keyboard focus visible before extending the style system to other nodes.
|
||||
Record screenshots and concrete contrast measurements; do not treat screenshot
|
||||
existence as proof of accessibility. No global resets or host-wide style overrides.
|
||||
|
||||
## Pilot: Local Image Loader
|
||||
|
||||
Keep browsing, autocomplete, saved paths, sorting, hidden-folder filtering,
|
||||
pagination, refresh, image/video/audio selection, and existing API error handling.
|
||||
Keep node names, inputs/outputs, widget serialization, and persisted settings
|
||||
compatible. Characterize existing saved workflows before changing implementation.
|
||||
|
||||
First extract the shell, button, select, text field, checkbox, and status controls
|
||||
actually used by this node. Add sliders and number fields when a second editor
|
||||
needs them. Windows/help/settings services follow after the pilot; do not add
|
||||
unused abstractions to satisfy a component checklist.
|
||||
|
||||
## Acceptance and test strategy
|
||||
|
||||
Use TDD for each behavioral slice: record a failing regression or new contract
|
||||
test, implement the minimum change, then run affected suites and required gates.
|
||||
Characterization tests should pass before refactoring. Never weaken existing
|
||||
coverage thresholds. Extend source discovery and coverage to nested `web/ui/`
|
||||
modules before introducing them; the current collector only includes `web/*.js`.
|
||||
|
||||
| Boundary | Required evidence |
|
||||
| --- | --- |
|
||||
| Controls/services | Unit tests for state, callbacks, disposal, settings isolation and repeat mount/unmount |
|
||||
| Product workflows | Existing browser fixture tests plus request races, errors and preserved selection/settings |
|
||||
| Real host | Actual ComfyUI frontend, graph, settings, widgets and workflow serialization; no substituted host objects |
|
||||
| Visual/keyboard | Theme screenshots, keyboard operation, visible focus, accessible names and measured contrast |
|
||||
| Distribution | Both packs separately and together, with matching and different UI package versions |
|
||||
|
||||
The real-host harness must pin ComfyUI and frontend versions and record them in
|
||||
test artifacts. Select and document a baseline that actually supports both
|
||||
renderers before claiming compatibility. Test initial Classic and Nodes 2.0,
|
||||
switching both ways without reload, zoom/pan/resize, node delete/recreate,
|
||||
save/reload of an existing workflow, and duplicate-node behavior.
|
||||
|
||||
Opening or closing a view, changing theme, and moving a floating window must not
|
||||
create graph edits. Committed node value changes must use the host's normal edit
|
||||
path and participate in undo/redo. Assert serialized values and history behavior;
|
||||
do not use canvas redraw as evidence of correct graph persistence.
|
||||
|
||||
Run the pinned baseline as a PR check once reproducible. Keep the existing fast
|
||||
fixtures and cross-browser checks. Run a separately pinned newer-host matrix on
|
||||
a schedule to detect upstream changes without silently changing PR dependencies.
|
||||
Missing renderer support must fail or be reported as unsupported, never silently
|
||||
skip the renderer and report compatibility. Model inference and GPU execution
|
||||
are outside this UI suite; startup and UI interaction should use disposable data.
|
||||
|
||||
## Delivery sequence
|
||||
|
||||
1. **Proposal:** agree on these boundaries, pilot behavior, and test evidence.
|
||||
2. **Host harness:** add reproducible real-ComfyUI tests for the existing loader;
|
||||
report any existing failures separately before the refactor.
|
||||
3. **Pilot:** make nested source coverage exhaustive; add tokens, minimal controls,
|
||||
shell, adapter and loader migration in reviewable TDD slices. Include visual
|
||||
evidence and preserve the existing suites.
|
||||
4. **Shared behavior:** migrate the Kiko image viewer; extract settings/help/window
|
||||
services from demonstrated needs and test cleanup, persistence and keyboard use.
|
||||
5. **Second consumer:** extract and version the core, add reproducibility checks,
|
||||
then migrate one PromptManager dialog and its theme bridge. Prove standalone
|
||||
and combined installation before wider adoption.
|
||||
6. **Incremental adoption:** migrate remaining editors individually. Remove old
|
||||
code only after replacement behavior is covered; document package updates and
|
||||
compatibility support.
|
||||
|
||||
Each PR links its tracked issue, includes validation commands/results, and states
|
||||
which real-host and visual checks were performed. Keep runtime changes out of the
|
||||
proposal PR. No backend or workflow schema change is planned for the UI pilot.
|
||||
@@ -0,0 +1,60 @@
|
||||
# ComfyAssets UI preview
|
||||
|
||||
Interactive design review for [KikoTools #81](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues/81)
|
||||
and [PromptManager #186](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/186).
|
||||
This standalone mockup changes no shipped nodes, APIs, settings, or workflows.
|
||||
|
||||
## Open the preview
|
||||
|
||||
From the KikoTools repository:
|
||||
|
||||
```sh
|
||||
python3 -m http.server 8769 --bind 127.0.0.1 --directory docs/ui-preview
|
||||
```
|
||||
|
||||
Open <http://127.0.0.1:8769/>. Stop the server with Ctrl+C.
|
||||
No build, ComfyUI installation, or external requests are needed. Paths, files,
|
||||
gallery images and prompt rows are sample content; actions affect this page only.
|
||||
Browse and refresh intentionally reuse the same samples. Nothing is persisted.
|
||||
|
||||
## What to try
|
||||
|
||||
- Switch Dark/Light to simulate ComfyUI's mode. Production will follow the host;
|
||||
this review toggle is not a new product theme setting.
|
||||
- Change Preview node width and Thumbnail size. Also resize the browser.
|
||||
- Select and clear images, filter file names, switch Grid/List and change sorting.
|
||||
- Operate switches with the keyboard; add, disable, remove and reset prompt rows.
|
||||
- Show the error example and Retry to compare the red treatment against blue.
|
||||
|
||||
Both product samples use exactly the same visual tokens and controls. Blue
|
||||
`#0877bb` and red `#d52430` are review candidates chosen to keep white button text
|
||||
readable; they are not claimed to be exact colors sampled from the references.
|
||||
Give feedback on these colors, density, borders, control proportions and resize
|
||||
behavior before production migration.
|
||||
|
||||
Captured review views: [dark](screenshots/dark.png), [light](screenshots/light.png),
|
||||
and [narrow](screenshots/narrow.png). These show the current candidate design,
|
||||
not production screenshots or automatic visual regression baselines.
|
||||
|
||||
## Automated review checks
|
||||
|
||||
With Node.js 24 and the existing frontend test dependencies installed:
|
||||
|
||||
```sh
|
||||
npm ci --prefix tools/testing --ignore-scripts
|
||||
npx --prefix tools/testing playwright install chromium firefox
|
||||
node tools/testing/node_modules/@playwright/test/cli.js test \
|
||||
--config docs/ui-preview/playwright.config.mjs --project chromium --project firefox
|
||||
```
|
||||
|
||||
Stop the manual preview server first; the suite starts its own disposable server
|
||||
on port 8769. CI also runs WebKit and touch Chromium using the same configuration.
|
||||
The tests cover basic interactions, resizing, keyboard operation, theme
|
||||
consistency and text contrast on sampled surfaces. Screenshots are written under
|
||||
`tools/testing/test-results/ui-preview/` locally. The suite does not certify screen
|
||||
reader usability, all state/border contrast, or real ComfyUI compatibility.
|
||||
|
||||
See the [foundation proposal](../shared-ui-foundation.md) for the renderer adapter,
|
||||
real-host tests and incremental migration plan. Pixaroma's control organization
|
||||
inspired this direction; this preview's code, styles and sample illustrations are
|
||||
original and include none of his implementation or assets.
|
||||
@@ -0,0 +1,79 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>Local Image Loader · ComfyAssets UI preview</title>
|
||||
<link rel="stylesheet" href="preview.css">
|
||||
<script type="module" src="preview.mjs"></script>
|
||||
</head>
|
||||
<body class="ca-preview">
|
||||
<svg class="ca-symbols" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="folder" viewBox="0 0 24 24"><path d="M3 6h7l2 3h9v11H3Z"/></symbol>
|
||||
<symbol id="refresh" viewBox="0 0 24 24"><path d="M20 10a8 8 0 1 0-1 8M20 4v6h-6"/></symbol>
|
||||
<symbol id="up" viewBox="0 0 24 24"><path d="m6 11 6-6 6 6M12 5v15"/></symbol>
|
||||
<symbol id="star" viewBox="0 0 24 24"><path d="m12 3 3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1Z"/></symbol>
|
||||
<symbol id="grid" viewBox="0 0 24 24"><path d="M3 3h7v7H3ZM14 3h7v7h-7ZM3 14h7v7H3ZM14 14h7v7h-7Z"/></symbol>
|
||||
<symbol id="list" viewBox="0 0 24 24"><path d="M9 5h12M9 12h12M9 19h12M3 5h1M3 12h1M3 19h1"/></symbol>
|
||||
<symbol id="check" viewBox="0 0 24 24"><path d="m5 12 5 5L20 7"/></symbol>
|
||||
<symbol id="alert" viewBox="0 0 24 24"><path d="m12 3 10 18H2ZM12 9v5M12 17v1"/></symbol>
|
||||
</svg>
|
||||
<header class="ca-page-header">
|
||||
<a class="ca-wordmark" href="#main"><span class="ca-brand-mark" aria-hidden="true">C<span>A</span></span> ComfyAssets</a>
|
||||
<span class="ca-caption">Shared UI / Design preview 01</span>
|
||||
</header>
|
||||
<main id="main" class="ca-page-main">
|
||||
<div class="ca-intro">
|
||||
<div><h1>One set of tools. One design.</h1><p>Local Image Loader + PromptManager · compact controls, shared behavior, your ComfyUI theme.</p></div>
|
||||
<span class="ca-preview-badge">Interactive mockup</span>
|
||||
</div>
|
||||
<section class="ca-review-controls" aria-label="Preview controls">
|
||||
<fieldset class="ca-segments"><legend>ComfyUI theme preview</legend>
|
||||
<label><input type="radio" name="theme" value="dark" checked><span>Dark</span></label>
|
||||
<label><input type="radio" name="theme" value="light"><span>Light</span></label>
|
||||
</fieldset>
|
||||
<label class="ca-width-control" for="node-width">Preview node width <input id="node-width" type="range" min="320" max="800" step="20" value="800"><output for="node-width" data-width>800 px</output></label>
|
||||
<span class="ca-review-hint">Try resizing and switching themes</span>
|
||||
</section>
|
||||
<div class="ca-review-grid">
|
||||
<div class="ca-canvas">
|
||||
<article class="ca-node ca-loader" data-loader data-product-sample>
|
||||
<header class="ca-titlebar"><h2><span class="ca-node-dot" aria-hidden="true"></span>Local Image Loader</h2><span class="ca-product-label">KikoTools</span></header>
|
||||
<div class="ca-ports"><span class="ca-port">directory</span><span>media <i aria-hidden="true"></i></span></div>
|
||||
<div class="ca-node-body">
|
||||
<div class="ca-row ca-favorites"><label for="saved-paths" class="ca-visually-hidden">Saved paths</label><select id="saved-paths"><option value="/input/references">References</option><option value="/input/portraits">Portraits</option><option value="/input">All input files</option></select><button type="button" data-save-path><svg><use href="#star"/></svg>Save path</button></div>
|
||||
<form class="ca-path-form"><label for="directory" class="ca-field-label">Directory</label><div class="ca-path-row"><button type="button" class="ca-icon-button" aria-label="Parent folder" data-parent><svg><use href="#up"/></svg></button><input id="directory" value="/input/references" spellcheck="false"><button type="submit" class="ca-primary"><svg><use href="#folder"/></svg>Browse</button></div></form>
|
||||
<div class="ca-options">
|
||||
<label class="ca-switch" for="hide-dot"><input id="hide-dot" type="checkbox" role="switch" checked>Hide dot folders</label>
|
||||
<div class="ca-row ca-sort"><label for="sort">Sort</label><select id="sort"><option value="name">Name</option><option value="date">Date</option><option value="size">Size</option></select><button type="button" class="ca-icon-button" aria-label="Sort descending" aria-pressed="false" data-sort-order>↑</button><button type="button" class="ca-icon-button" aria-label="Refresh sample folder" data-refresh><svg><use href="#refresh"/></svg></button></div>
|
||||
</div>
|
||||
<div class="ca-gallery-tools"><label for="filter" class="ca-visually-hidden">Filter sample files</label><input id="filter" type="search" placeholder="Filter files…"><fieldset class="ca-segments ca-view-options"><legend class="ca-visually-hidden">Gallery layout</legend><label><input type="radio" name="view" value="grid" checked><span><svg aria-hidden="true"><use href="#grid"/></svg>Grid</span></label><label><input type="radio" name="view" value="list"><span><svg aria-hidden="true"><use href="#list"/></svg>List</span></label></fieldset></div>
|
||||
<div class="ca-error" role="alert" hidden><svg aria-hidden="true"><use href="#alert"/></svg><span><strong>Folder unavailable</strong><br>Check the path and try again.</span><button type="button" data-retry>Retry</button></div>
|
||||
<div class="ca-gallery" data-gallery data-view="grid"></div>
|
||||
<p class="ca-empty" data-empty hidden>No matching sample files.</p>
|
||||
<div class="ca-gallery-footer"><span data-count>6 sample files</span><span class="ca-page-count">Page 1 of 1</span></div>
|
||||
<div class="ca-thumbnail-control"><label for="thumbnail-size">Thumbnail size</label><input id="thumbnail-size" type="range" min="112" max="208" step="8" value="144"><output for="thumbnail-size" data-thumbnail-size>144 px</output></div>
|
||||
<div class="ca-selection"><span class="ca-selection-icon" aria-hidden="true"><svg><use href="#check"/></svg></span><div><span class="ca-field-label">Selected image</span><strong data-selection>No image selected</strong></div><button type="button" data-clear disabled>Clear</button></div>
|
||||
</div>
|
||||
<footer class="ca-node-status" role="status" data-status>Sample folder ready · choose an image</footer>
|
||||
</article>
|
||||
<p class="ca-canvas-caption">Same component system at every node size. All files shown here are demo content.</p>
|
||||
</div>
|
||||
<aside class="ca-reference">
|
||||
<div class="ca-reference-heading"><h2>The same family</h2><p>Same controls. Same spacing. Same blue.</p></div>
|
||||
<article class="ca-node ca-prompt-sample" data-product-sample>
|
||||
<header class="ca-titlebar"><h3><span class="ca-node-dot" aria-hidden="true"></span>Prompt editor</h3><span class="ca-product-label">PromptManager</span></header>
|
||||
<div class="ca-node-body">
|
||||
<div class="ca-prompt-rows" data-prompt-rows></div>
|
||||
<div class="ca-row"><button type="button" data-add-row>+ Add row</button><button type="button" data-reset-rows disabled>Reset</button></div>
|
||||
</div>
|
||||
</article>
|
||||
<section class="ca-reference-panel"><h3>One accent, clear states</h3><div class="ca-state-buttons"><button type="button" class="ca-primary" data-primary-example>Primary action</button><button type="button" class="ca-danger" data-error-example>Show error example</button><button type="button" disabled>Unavailable</button></div><p class="ca-muted-sample">Blue for active controls.<br>Strong red when something needs attention.</p></section>
|
||||
<section class="ca-reference-panel ca-design-notes"><h3>What to review</h3><ul><li>Button weight and spacing</li><li>Blue and red in both themes</li><li>Switches and segmented choices</li><li>Narrow and wide node layouts</li></ul><p>This page previews appearance and interactions. ComfyUI renderer compatibility comes in the integration phase.</p></section>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="ca-page-footer"><span>ComfyAssets · KikoTools + PromptManager</span><span>Original implementation · inspired by Pixaroma’s thoughtful controls</span></footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig, devices } from '../../tools/testing/node_modules/@playwright/test/index.mjs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = fileURLToPath(new URL('./', import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
outputDir: '../../tools/testing/test-results/ui-preview',
|
||||
workers: 1,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: 0,
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:8769',
|
||||
viewport: { width: 1440, height: 1050 },
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
webServer: {
|
||||
command: 'python3 -m http.server 8769 --bind 127.0.0.1',
|
||||
cwd: root,
|
||||
url: 'http://127.0.0.1:8769/',
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { browserName: 'chromium' } },
|
||||
{ name: 'firefox', use: { browserName: 'firefox' } },
|
||||
{ name: 'webkit', use: { browserName: 'webkit' } },
|
||||
{ name: 'touch', use: { ...devices['Pixel 7'], browserName: 'chromium' } },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--ca-page: #19191c;
|
||||
--ca-canvas: #202023;
|
||||
--ca-surface: #2b2b2e;
|
||||
--ca-title: #242427;
|
||||
--ca-field: #202023;
|
||||
--ca-control: #37373b;
|
||||
--ca-hover: #45454b;
|
||||
--ca-text: #eeeeF1;
|
||||
--ca-muted: #b5b5bd;
|
||||
--ca-line: #48484f;
|
||||
--ca-control-border: #7d7d87;
|
||||
--ca-accent: #0877bb;
|
||||
--ca-accent-hover: #096ba6;
|
||||
--ca-accent-text: #ffffff;
|
||||
--ca-focus: #5bc2ff;
|
||||
--ca-error: #d52430;
|
||||
--ca-error-bg: #391d23;
|
||||
--ca-error-text: #ffadb3;
|
||||
--ca-grid: #29292d;
|
||||
--ca-shadow: 0 3px 9px #00000030;
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--ca-page: #f4f4f6;
|
||||
--ca-canvas: #ededf0;
|
||||
--ca-surface: #ffffff;
|
||||
--ca-title: #f6f6f8;
|
||||
--ca-field: #f5f5f7;
|
||||
--ca-control: #eeeeF1;
|
||||
--ca-hover: #e2e2e8;
|
||||
--ca-text: #23232b;
|
||||
--ca-muted: #5c5c68;
|
||||
--ca-line: #d4d4dc;
|
||||
--ca-control-border: #85858f;
|
||||
--ca-focus: #0066a6;
|
||||
--ca-error-bg: #fff0f1;
|
||||
--ca-error-text: #a20d20;
|
||||
--ca-grid: #e1e1e7;
|
||||
--ca-shadow: 0 3px 9px #1b1b2612;
|
||||
}
|
||||
.ca-preview { margin: 0; background: var(--ca-page); color: var(--ca-text); font-size: 13px; line-height: 1.5; }
|
||||
.ca-preview :where(article, section, header, footer, div, input, button, select, textarea, fieldset, label, aside, main) { box-sizing: border-box; }
|
||||
.ca-preview :where(button, input, select, textarea) { font: inherit; color: inherit; accent-color: var(--ca-accent); }
|
||||
.ca-preview :where(button, select, input:not([type="radio"], [type="range"], [role="switch"]), textarea) { border: 1px solid var(--ca-control-border); border-radius: 5px; min-block-size: 30px; }
|
||||
.ca-preview button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 4px 11px; background: var(--ca-control); cursor: pointer; white-space: nowrap; }
|
||||
.ca-preview button:hover:not(:disabled) { background: var(--ca-hover); }
|
||||
.ca-preview button:disabled { opacity: .42; cursor: default; }
|
||||
.ca-preview svg:not(.ca-symbols) { inline-size: 15px; block-size: 15px; flex-shrink: 0; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.ca-symbols { position: absolute; inline-size: 0; block-size: 0; overflow: hidden; }
|
||||
.ca-preview :where(input:not([type="radio"], [type="range"], [role="switch"]), select, textarea) { background: var(--ca-field); padding: 4px 9px; min-inline-size: 0; }
|
||||
.ca-preview select { padding-inline-end: 22px; }
|
||||
.ca-preview :where(input, textarea)::placeholder { color: var(--ca-muted); opacity: 1; }
|
||||
.ca-preview :where(button, input, select, textarea, a):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
|
||||
.ca-preview .ca-primary { background: var(--ca-accent); color: var(--ca-accent-text); border-color: var(--ca-accent); }
|
||||
.ca-preview .ca-primary:hover:not(:disabled) { background: var(--ca-accent-hover); }
|
||||
.ca-preview .ca-danger { background: var(--ca-error); color: #ffffff; border-color: var(--ca-error); }
|
||||
.ca-preview .ca-danger:hover:not(:disabled) { background: #b51c28; }
|
||||
.ca-page-header, .ca-page-footer { max-inline-size: 1280px; margin-inline: auto; padding: 24px 32px; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
||||
.ca-page-header { border-block-end: 1px solid var(--ca-line); }
|
||||
.ca-wordmark { display: inline-flex; align-items: center; gap: 10px; text-decoration: none; color: var(--ca-text); font-size: 17px; font-weight: 650; letter-spacing: -.4px; }
|
||||
.ca-brand-mark { color: var(--ca-accent); font-weight: 800; letter-spacing: -3px; font-size: 22px; padding-inline-end: 4px; }
|
||||
.ca-brand-mark span { color: var(--ca-text); }
|
||||
.ca-caption, .ca-page-footer, .ca-review-hint, .ca-canvas-caption { color: var(--ca-muted); font-size: 12px; }
|
||||
.ca-page-main { max-inline-size: 1280px; margin-inline: auto; padding: 32px; }
|
||||
.ca-intro { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-block-end: 24px; }
|
||||
.ca-intro h1 { font-size: 25px; line-height: 1.3; font-weight: 600; letter-spacing: -.7px; margin: 0 0 8px; }
|
||||
.ca-intro p { color: var(--ca-muted); margin: 0; }
|
||||
.ca-preview-badge { color: var(--ca-muted); border: 1px solid var(--ca-line); padding: 4px 9px; border-radius: 4px; font-size: 11px; }
|
||||
.ca-review-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 24px; padding: 16px 0; border-block: 1px solid var(--ca-line); margin-block-end: 24px; }
|
||||
.ca-segments { border: 0; padding: 0; margin: 0; display: inline-flex; align-items: center; gap: 2px; min-inline-size: 0; }
|
||||
.ca-segments legend { float: inline-start; margin-inline-end: 12px; color: var(--ca-muted); }
|
||||
.ca-segments label { position: relative; display: inline-flex; min-inline-size: 24px; min-block-size: 30px; cursor: pointer; }
|
||||
.ca-segments input { position: absolute; inset: 0; opacity: 0; margin: 0; inline-size: 100%; block-size: 100%; cursor: pointer; }
|
||||
.ca-segments label > span { display: inline-flex; gap: 5px; align-items: center; justify-content: center; padding: 4px 12px; background: var(--ca-field); border: 1px solid var(--ca-control-border); border-radius: 4px; }
|
||||
.ca-segments input:checked + span { color: var(--ca-accent-text); background: var(--ca-accent); border-color: var(--ca-accent); }
|
||||
.ca-segments input:focus-visible + span { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
|
||||
.ca-width-control { display: inline-flex; align-items: center; gap: 12px; flex-wrap: wrap; color: var(--ca-muted); }
|
||||
.ca-preview input[type="range"] { min-inline-size: 60px; block-size: 30px; margin: 0; cursor: pointer; }
|
||||
.ca-width-control input { inline-size: 120px; }
|
||||
.ca-preview output { font-variant-numeric: tabular-nums; font-size: 12px; min-inline-size: 44px; }
|
||||
.ca-review-hint { margin-inline-start: auto; }
|
||||
.ca-review-grid { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 24px; align-items: start; }
|
||||
.ca-canvas { padding: 26px 18px 14px; border: 1px solid var(--ca-line); border-radius: 8px; background-color: var(--ca-canvas); background-image: linear-gradient(var(--ca-grid) 1px, transparent 1px), linear-gradient(90deg, var(--ca-grid) 1px, transparent 1px); background-size: 16px 16px; min-inline-size: 0; }
|
||||
.ca-node { background: var(--ca-surface); border: 1px solid var(--ca-line); border-radius: 8px; box-shadow: var(--ca-shadow); overflow: hidden; }
|
||||
.ca-loader { inline-size: min(100%, var(--ca-preview-width, 800px)); margin-inline: auto; container-type: inline-size; }
|
||||
.ca-titlebar { display: flex; align-items: center; justify-content: space-between; gap: 8px; background: var(--ca-title); padding: 9px 12px; border-block-end: 1px solid var(--ca-line); }
|
||||
.ca-titlebar :where(h2, h3) { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 550; margin: 0; }
|
||||
.ca-node-dot { inline-size: 8px; block-size: 8px; border-radius: 50%; background: var(--ca-accent); flex-shrink: 0; }
|
||||
.ca-product-label { color: var(--ca-muted); font-size: 10px; white-space: nowrap; }
|
||||
.ca-ports { display: flex; align-items: center; justify-content: space-between; padding: 5px 10px; font-size: 11px; color: var(--ca-muted); }
|
||||
.ca-ports span { display: flex; align-items: center; gap: 6px; }
|
||||
.ca-ports i { inline-size: 6px; block-size: 6px; border: 1px solid var(--ca-control-border); background: var(--ca-focus); border-radius: 50%; }
|
||||
.ca-node-body { padding: 12px; display: grid; gap: 14px; }
|
||||
.ca-row { display: flex; align-items: center; gap: 7px; min-inline-size: 0; }
|
||||
.ca-favorites select { flex: 1; }
|
||||
.ca-field-label { color: var(--ca-muted); font-size: 11px; display: block; margin-block-end: 5px; }
|
||||
.ca-path-row { display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; gap: 6px; }
|
||||
.ca-preview .ca-icon-button { min-inline-size: 30px; padding-inline: 6px; }
|
||||
.ca-options { display: flex; align-items: center; justify-content: space-between; gap: 8px 16px; flex-wrap: wrap; }
|
||||
.ca-switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; min-block-size: 30px; font-size: 12px; }
|
||||
.ca-switch input { appearance: none; position: relative; inline-size: 36px; min-block-size: 24px; border: 1px solid var(--ca-control-border); border-radius: 20px; background: var(--ca-control); margin: 0; cursor: pointer; flex-shrink: 0; }
|
||||
.ca-switch input::before { content: ""; position: absolute; inline-size: 16px; block-size: 16px; background: var(--ca-muted); inset-block-start: 3px; inset-inline-start: 3px; border-radius: 50%; }
|
||||
.ca-switch input:checked { background: var(--ca-accent); border-color: var(--ca-accent); }
|
||||
.ca-switch input:checked::before { background: #fff; inset-inline-start: 15px; }
|
||||
.ca-sort { font-size: 12px; color: var(--ca-muted); }
|
||||
.ca-sort select { inline-size: 90px; color: var(--ca-text); }
|
||||
.ca-gallery-tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.ca-gallery-tools > input { flex: 1; inline-size: 120px; }
|
||||
.ca-gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--ca-thumbnail-size, 144px)), 1fr)); gap: 8px; }
|
||||
.ca-preview .ca-file { position: relative; padding: 4px; display: flex; flex-direction: column; gap: 0; background: var(--ca-field); border-color: var(--ca-control-border); white-space: normal; text-align: start; overflow: hidden; min-inline-size: 0; }
|
||||
.ca-preview .ca-file[aria-pressed="true"] { border: 2px solid var(--ca-focus); padding: 3px; }
|
||||
.ca-file-name { inline-size: 100%; font-size: 11px; padding: 6px 3px 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.ca-file-meta { inline-size: 100%; color: var(--ca-muted); font-size: 10px; padding: 1px 3px 4px; }
|
||||
.ca-file-check { display: none; position: absolute; inset-block-start: 9px; inset-inline-end: 9px; background: var(--ca-accent); color: white; border: 1px solid white; border-radius: 3px; inline-size: 20px; block-size: 20px; text-align: center; }
|
||||
.ca-file[aria-pressed="true"] .ca-file-check { display: block; }
|
||||
.ca-scene { position: relative; display: block; inline-size: 100%; aspect-ratio: 1.5; overflow: hidden; border-radius: 3px; background: linear-gradient(170deg, #719ca5, #b9cfbf 55%, #46647b 56%, #203947); }
|
||||
.ca-scene::before, .ca-scene::after { content: ""; position: absolute; inset: 12% -8% 25%; clip-path: polygon(0 100%, 35% 5%, 48% 43%, 64% 20%, 100% 100%); background: #40596c; }
|
||||
.ca-scene::after { inset: 38% -20% -15% 20%; background: #1e3549; }
|
||||
.ca-scene-dune { background: linear-gradient(150deg, #e2c9a8, #8b8d98); }
|
||||
.ca-scene-dune::before { background: #b79a83; clip-path: polygon(0 100%, 75% 10%, 100% 90%); }
|
||||
.ca-scene-dune::after { background: #705f64; clip-path: polygon(0 60%, 80% 20%, 100% 100%, 0 100%); }
|
||||
.ca-scene-forest { background: linear-gradient(160deg, #9eb7a9, #3f6259); }
|
||||
.ca-scene-forest::before { background: #45665b; clip-path: polygon(0 100%, 16% 5%, 27% 85%, 45% 0, 61% 100%, 80% 10%, 100% 100%); }
|
||||
.ca-scene-forest::after { background: #243e39; }
|
||||
.ca-scene-coast { background: linear-gradient(170deg, #a3bfce 40%, #4e8a9e 41%, #294c65); }
|
||||
.ca-scene-coast::before { background: #a1b1a0; clip-path: polygon(0 0, 100% 80%, 0 100%); }
|
||||
.ca-scene-coast::after { background: #526f69; clip-path: polygon(0 30%, 70% 100%, 0 100%); }
|
||||
.ca-scene-studio { background: linear-gradient(135deg, #b0a5b6, #5e5d78); }
|
||||
.ca-scene-studio::before { inset: 18% 28%; border-radius: 40px 40px 0 0; clip-path: none; background: #d2c2be; }
|
||||
.ca-scene-studio::after { inset: 40% 35% 0; clip-path: none; border-radius: 40px 40px 0 0; background: #636378; }
|
||||
.ca-scene-night { background: linear-gradient(140deg, #49516a, #202a42); }
|
||||
.ca-scene-night::before { background: #6a6580; }
|
||||
.ca-scene-night::after { background: #273044; }
|
||||
.ca-gallery[data-view="list"] { grid-template-columns: 1fr; }
|
||||
.ca-preview .ca-gallery[data-view="list"] .ca-file { display: grid; grid-template-columns: 64px minmax(0, 1fr); column-gap: 8px; justify-content: start; }
|
||||
.ca-gallery[data-view="list"] .ca-scene { grid-row: span 2; }
|
||||
.ca-gallery[data-view="list"] .ca-file-meta { align-self: start; }
|
||||
.ca-gallery-footer { display: flex; justify-content: space-between; gap: 8px; font-size: 11px; color: var(--ca-muted); }
|
||||
.ca-thumbnail-control { display: flex; align-items: center; gap: 10px; color: var(--ca-muted); font-size: 11px; }
|
||||
.ca-thumbnail-control input { flex: 1; }
|
||||
.ca-selection { border-block-start: 1px solid var(--ca-line); padding-block-start: 12px; display: flex; align-items: center; gap: 10px; min-inline-size: 0; }
|
||||
.ca-selection > div { flex: 1; min-inline-size: 0; }
|
||||
.ca-selection strong { font-size: 12px; font-weight: 500; overflow-wrap: anywhere; }
|
||||
.ca-selection .ca-field-label { margin-block-end: 0; }
|
||||
.ca-selection-icon { color: var(--ca-focus); }
|
||||
.ca-node-status { padding: 7px 12px; border-block-start: 1px solid var(--ca-line); background: var(--ca-title); color: var(--ca-muted); font-size: 11px; }
|
||||
.ca-canvas-caption { text-align: center; margin: 14px 6px 0; }
|
||||
.ca-reference { display: grid; gap: 20px; }
|
||||
.ca-reference-heading h2 { margin: 0 0 4px; font-size: 14px; font-weight: 600; }
|
||||
.ca-reference-heading p { margin: 0; color: var(--ca-muted); font-size: 12px; }
|
||||
.ca-prompt-sample .ca-titlebar { align-items: start; flex-direction: column; gap: 4px; }
|
||||
.ca-prompt-sample .ca-titlebar .ca-product-label { padding-inline-start: 16px; }
|
||||
.ca-prompt-rows { display: grid; gap: 8px; }
|
||||
.ca-prompt-row { background: var(--ca-field); border: 1px solid var(--ca-line); padding: 8px; border-radius: 5px; display: grid; gap: 8px; }
|
||||
.ca-prompt-row .ca-row { justify-content: space-between; }
|
||||
.ca-preview .ca-prompt-row textarea { inline-size: 100%; min-block-size: 78px; resize: vertical; font-size: 12px; }
|
||||
.ca-reference-panel { padding: 16px; border: 1px solid var(--ca-line); border-radius: 6px; background: var(--ca-surface); }
|
||||
.ca-reference-panel h3 { margin: 0 0 12px; font-size: 12px; font-weight: 600; }
|
||||
.ca-state-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.ca-state-buttons button { flex: 1 1 100%; }
|
||||
.ca-muted-sample { color: var(--ca-muted); background: var(--ca-surface); font-size: 12px; margin: 12px 0 0; }
|
||||
.ca-design-notes ul { padding-inline-start: 16px; color: var(--ca-muted); line-height: 1.9; font-size: 12px; }
|
||||
.ca-design-notes p { font-size: 11px; color: var(--ca-muted); border-block-start: 1px solid var(--ca-line); padding-block-start: 10px; margin-block-end: 0; }
|
||||
.ca-error { display: flex; align-items: center; gap: 10px; padding: 12px; background: var(--ca-error-bg); color: var(--ca-error-text); border: 1px solid var(--ca-error); border-radius: 5px; font-size: 12px; }
|
||||
.ca-error span { flex: 1; }
|
||||
.ca-error strong { font-weight: 600; }
|
||||
.ca-preview [hidden] { display: none; }
|
||||
.ca-empty { text-align: center; padding: 32px 12px; color: var(--ca-muted); }
|
||||
.ca-page-footer { border-block-start: 1px solid var(--ca-line); padding-block: 18px; flex-wrap: wrap; font-size: 11px; }
|
||||
.ca-visually-hidden { position: absolute; inline-size: 1px; block-size: 1px; clip-path: inset(50%); overflow: hidden; white-space: nowrap; }
|
||||
@container (max-width: 400px) {
|
||||
.ca-options { align-items: stretch; }
|
||||
.ca-sort { flex: 1; }
|
||||
.ca-sort select { flex: 1; }
|
||||
.ca-gallery { grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--ca-thumbnail-size, 144px)), 1fr)); }
|
||||
.ca-options > .ca-switch { flex-basis: 100%; }
|
||||
}
|
||||
@media (max-width: 1000px) { .ca-review-grid { grid-template-columns: minmax(0, 1fr); } .ca-reference { grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); } .ca-reference-heading { grid-column: 1 / -1; } }
|
||||
@media (max-width: 600px) { .ca-page-main { padding: 20px 12px; } .ca-page-header, .ca-page-footer { padding-inline: 16px; } .ca-caption { display: none; } .ca-canvas { padding: 14px 6px; } .ca-review-hint { margin-inline-start: 0; } .ca-intro h1 { font-size: 22px; } .ca-width-control { gap: 8px; } }
|
||||
@media (pointer: coarse) { .ca-preview :where(button, select), .ca-segments label > span { min-block-size: 40px; } .ca-switch { min-block-size: 40px; } }
|
||||
@media (forced-colors: active) { :root, :root[data-theme="light"] { --ca-accent: Highlight; --ca-accent-text: HighlightText; --ca-focus: Highlight; --ca-control-border: ButtonText; --ca-line: CanvasText; } .ca-file[aria-pressed="true"] { outline: 2px solid Highlight; } .ca-switch input:checked::before { background: HighlightText; } }
|
||||
@@ -0,0 +1,142 @@
|
||||
const root = document.documentElement;
|
||||
const loader = document.querySelector('[data-loader]');
|
||||
const gallery = document.querySelector('[data-gallery]');
|
||||
const status = document.querySelector('[data-status]');
|
||||
const selection = document.querySelector('[data-selection]');
|
||||
const clear = document.querySelector('[data-clear]');
|
||||
const error = document.querySelector('.ca-error');
|
||||
const filter = document.querySelector('#filter');
|
||||
const path = document.querySelector('#directory');
|
||||
const sort = document.querySelector('#sort');
|
||||
let selected = '';
|
||||
let descending = false;
|
||||
const files = [
|
||||
{ name: 'alpine-lake.png', scene: 'lake', size: '1536 × 1024', order: 3 },
|
||||
{ name: 'desert-dunes.png', scene: 'dune', size: '1536 × 1024', order: 6 },
|
||||
{ name: 'forest-study.png', scene: 'forest', size: '1024 × 1536', order: 4 },
|
||||
{ name: 'coastal-light.png', scene: 'coast', size: '1536 × 1024', order: 1 },
|
||||
{ name: 'studio-shapes.png', scene: 'studio', size: '1024 × 1024', order: 2 },
|
||||
{ name: 'night-ridge.png', scene: 'night', size: '1536 × 1024', order: 5 },
|
||||
];
|
||||
|
||||
function renderFiles() {
|
||||
const visible = files.filter(file => file.name.includes(filter.value.toLowerCase()));
|
||||
visible.sort((a, b) => {
|
||||
const order = sort.value === 'name' ? a.name.localeCompare(b.name)
|
||||
: sort.value === 'size' ? a.size.localeCompare(b.size) : a.order - b.order;
|
||||
return descending ? -order : order;
|
||||
});
|
||||
gallery.replaceChildren(...visible.map(file => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'ca-file';
|
||||
button.setAttribute('aria-label', `Select ${file.name}`);
|
||||
button.setAttribute('aria-pressed', String(file.name === selected));
|
||||
const scene = document.createElement('span');
|
||||
scene.className = `ca-scene ca-scene-${file.scene}`;
|
||||
scene.setAttribute('aria-hidden', 'true');
|
||||
const check = document.createElement('span');
|
||||
check.className = 'ca-file-check';
|
||||
check.textContent = '✓';
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
const name = document.createElement('span');
|
||||
name.className = 'ca-file-name';
|
||||
name.textContent = file.name;
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'ca-file-meta';
|
||||
meta.textContent = `${file.size} · PNG`;
|
||||
button.append(scene, check, name, meta);
|
||||
button.addEventListener('click', () => {
|
||||
selected = file.name;
|
||||
selection.textContent = file.name;
|
||||
clear.disabled = false;
|
||||
for (const item of gallery.children) item.setAttribute('aria-pressed', String(item === button));
|
||||
status.textContent = `Selected ${file.name} · demo selection`;
|
||||
});
|
||||
return button;
|
||||
}));
|
||||
document.querySelector('[data-empty]').hidden = visible.length > 0;
|
||||
document.querySelector('[data-count]').textContent = `${visible.length} sample files`;
|
||||
}
|
||||
|
||||
document.querySelectorAll('[name="theme"]').forEach(input => {
|
||||
input.addEventListener('change', () => { root.dataset.theme = input.value; });
|
||||
});
|
||||
document.querySelector('#node-width').addEventListener('input', event => {
|
||||
loader.style.setProperty('--ca-preview-width', `${event.target.value}px`);
|
||||
document.querySelector('[data-width]').value = `${event.target.value} px`;
|
||||
});
|
||||
document.querySelectorAll('[name="view"]').forEach(input => {
|
||||
input.addEventListener('change', () => { gallery.dataset.view = input.value; });
|
||||
});
|
||||
document.querySelector('#thumbnail-size').addEventListener('input', event => {
|
||||
gallery.style.setProperty('--ca-thumbnail-size', `${event.target.value}px`);
|
||||
document.querySelector('[data-thumbnail-size]').value = `${event.target.value} px`;
|
||||
});
|
||||
filter.addEventListener('input', renderFiles);
|
||||
sort.addEventListener('change', renderFiles);
|
||||
document.querySelector('[data-sort-order]').addEventListener('click', event => {
|
||||
descending = !descending;
|
||||
event.currentTarget.setAttribute('aria-pressed', String(descending));
|
||||
event.currentTarget.setAttribute('aria-label', descending ? 'Sort ascending' : 'Sort descending');
|
||||
event.currentTarget.textContent = descending ? '↓' : '↑';
|
||||
renderFiles();
|
||||
});
|
||||
document.querySelector('#hide-dot').addEventListener('change', event => {
|
||||
status.textContent = event.target.checked ? 'Dot folders hidden · sample content unchanged' : 'Dot folders visible · no hidden folders in this sample';
|
||||
});
|
||||
function browse() {
|
||||
error.hidden = true;
|
||||
status.textContent = `Previewing ${path.value || '/input'} · sample content only`;
|
||||
renderFiles();
|
||||
}
|
||||
document.querySelector('.ca-path-form').addEventListener('submit', event => { event.preventDefault(); browse(); });
|
||||
document.querySelector('[data-refresh]').addEventListener('click', browse);
|
||||
document.querySelector('[data-retry]').addEventListener('click', browse);
|
||||
document.querySelector('#saved-paths').addEventListener('change', event => { path.value = event.target.value; browse(); });
|
||||
document.querySelector('[data-save-path]').addEventListener('click', () => {
|
||||
if (!path.value.trim()) { status.textContent = 'Enter a directory before saving a sample path'; return; }
|
||||
const presets = document.querySelector('#saved-paths');
|
||||
if (![...presets.options].some(option => option.value === path.value)) presets.add(new Option(path.value, path.value));
|
||||
presets.value = path.value;
|
||||
status.textContent = 'Path saved for this preview session';
|
||||
});
|
||||
document.querySelector('[data-parent]').addEventListener('click', () => {
|
||||
path.value = path.value.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/';
|
||||
browse();
|
||||
});
|
||||
clear.addEventListener('click', () => {
|
||||
selected = '';
|
||||
selection.textContent = 'No image selected';
|
||||
clear.disabled = true;
|
||||
renderFiles();
|
||||
status.textContent = 'Selection cleared';
|
||||
});
|
||||
document.querySelector('[data-error-example]').addEventListener('click', () => {
|
||||
error.hidden = false;
|
||||
status.textContent = 'Error example shown · use Retry to return';
|
||||
});
|
||||
document.querySelector('[data-primary-example]').addEventListener('click', () => {
|
||||
status.textContent = 'Primary action example · blue is shared across both tools';
|
||||
});
|
||||
let nextRow = 1;
|
||||
const rows = document.querySelector('[data-prompt-rows]');
|
||||
const reset = document.querySelector('[data-reset-rows]');
|
||||
function addRow() {
|
||||
const id = nextRow++;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ca-prompt-row';
|
||||
row.innerHTML = `<div class="ca-row"><label class="ca-switch" for="prompt-enabled-${id}"><input id="prompt-enabled-${id}" type="checkbox" role="switch" checked>Row ${id}</label><button type="button" class="ca-icon-button" aria-label="Remove row ${id}">×</button></div><label for="prompt-${id}" class="ca-visually-hidden">Prompt row ${id}</label><textarea id="prompt-${id}" placeholder="Write a prompt…"></textarea>`;
|
||||
row.querySelector('button').addEventListener('click', () => {
|
||||
row.remove();
|
||||
reset.disabled = rows.children.length === 1 && rows.firstChild.querySelector('textarea').value === '';
|
||||
document.querySelector('[data-add-row]').focus();
|
||||
});
|
||||
row.querySelector('textarea').addEventListener('input', () => { reset.disabled = false; });
|
||||
row.querySelector('input').addEventListener('change', event => { row.querySelector('textarea').disabled = !event.target.checked; reset.disabled = false; });
|
||||
rows.append(row);
|
||||
}
|
||||
document.querySelector('[data-add-row]').addEventListener('click', () => { addRow(); reset.disabled = false; rows.lastChild.querySelector('textarea').focus(); });
|
||||
reset.addEventListener('click', () => { rows.replaceChildren(); nextRow = 1; addRow(); reset.disabled = true; document.querySelector('[data-add-row]').focus(); });
|
||||
addRow();
|
||||
renderFiles();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 153 KiB |
@@ -0,0 +1,95 @@
|
||||
import { test, expect } from '../../../tools/testing/node_modules/@playwright/test/index.mjs';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('theme preview updates native color scheme and both product samples together', async ({ page }) => {
|
||||
await page.getByRole('radio', { name: 'Light', exact: true }).check();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
||||
await expect(page.locator('html')).toHaveCSS('color-scheme', 'light');
|
||||
const samples = page.locator('[data-product-sample]');
|
||||
expect(await samples.count()).toBe(2);
|
||||
const backgrounds = await samples.evaluateAll(elements => elements.map(el => getComputedStyle(el).backgroundColor));
|
||||
expect(new Set(backgrounds).size).toBe(1);
|
||||
await page.getByRole('radio', { name: 'Dark', exact: true }).check();
|
||||
await expect(page.locator('html')).toHaveCSS('color-scheme', 'dark');
|
||||
});
|
||||
|
||||
test('gallery selection, filtering and layout controls operate on sample content', async ({ page }) => {
|
||||
await expect(page.getByRole('button', { name: 'Select alpine-lake.png' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Select alpine-lake.png' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Select alpine-lake.png' })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.locator('[data-selection]')).toContainText('alpine-lake.png');
|
||||
await page.getByRole('radio', { name: 'List', exact: true }).check();
|
||||
await expect(page.locator('[data-gallery]')).toHaveAttribute('data-view', 'list');
|
||||
await page.getByLabel('Filter sample files').fill('dune');
|
||||
await expect(page.locator('[data-gallery] button')).toHaveCount(1);
|
||||
await page.getByLabel('Filter sample files').fill('missing');
|
||||
await expect(page.getByText('No matching sample files.')).toBeVisible();
|
||||
});
|
||||
|
||||
test('prompt rows support editing, disabling, removing and reset', async ({ page }) => {
|
||||
await page.getByRole('button', { name: '+ Add row', exact: true }).click();
|
||||
await page.getByLabel('Prompt row 2', { exact: true }).fill('A quiet lake at dawn');
|
||||
await page.getByRole('switch', { name: 'Row 2', exact: true }).uncheck();
|
||||
await expect(page.getByLabel('Prompt row 2', { exact: true })).toBeDisabled();
|
||||
await page.getByRole('button', { name: 'Remove row 1', exact: true }).click();
|
||||
await expect(page.getByLabel('Prompt row 1', { exact: true })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Reset', exact: true }).click();
|
||||
await expect(page.getByLabel('Prompt row 1', { exact: true })).toBeEnabled();
|
||||
await expect(page.getByLabel('Prompt row 1', { exact: true })).toHaveValue('');
|
||||
await expect(page.getByRole('button', { name: 'Reset', exact: true })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('switches support keyboard and error example has an actionable recovery', async ({ page }) => {
|
||||
const toggle = page.getByRole('switch', { name: 'Hide dot folders' });
|
||||
await toggle.focus();
|
||||
await page.keyboard.press('Space');
|
||||
await expect(toggle).not.toBeChecked();
|
||||
await expect(toggle).toBeFocused();
|
||||
await expect(toggle).toHaveCSS('outline-style', 'solid');
|
||||
await page.getByRole('button', { name: 'Show error example' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('Folder unavailable');
|
||||
await page.getByRole('button', { name: 'Retry' }).click();
|
||||
await expect(page.getByRole('alert')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('node controls resize without horizontal overflow and range supports keyboard', async ({ page }) => {
|
||||
for (const width of [320, 460, 800]) {
|
||||
await page.getByLabel('Preview node width').fill(String(width));
|
||||
await page.getByLabel('Preview node width').dispatchEvent('input');
|
||||
expect((await page.locator('[data-loader]').boundingBox()).width).toBeLessThanOrEqual(width);
|
||||
const overflow = await page.locator('[data-loader]').evaluate(el => el.scrollWidth - el.clientWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
}
|
||||
await page.setViewportSize({ width: 375, height: 850 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
|
||||
await page.screenshot({ path: test.info().outputPath('narrow.png'), fullPage: true });
|
||||
const size = page.getByLabel('Thumbnail size');
|
||||
await size.focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(size).toHaveValue('152');
|
||||
});
|
||||
|
||||
test('text, accent and error treatments have measurable contrast in both themes', async ({ page }) => {
|
||||
const luminance = color => {
|
||||
const channels = color.match(/[\d.]+/g).slice(0, 3).map(Number).map(value => {
|
||||
const channel = value / 255;
|
||||
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
||||
};
|
||||
for (const theme of ['Light', 'Dark']) {
|
||||
await page.getByRole('radio', { name: theme, exact: true }).check();
|
||||
for (const selector of ['.ca-node', '.ca-primary', '.ca-danger', '.ca-muted-sample']) {
|
||||
const colors = await page.locator(selector).first().evaluate(el => {
|
||||
const style = getComputedStyle(el);
|
||||
return [style.color, style.backgroundColor];
|
||||
});
|
||||
const [a, b] = colors.map(luminance).sort((x, y) => y - x);
|
||||
expect((a + 0.05) / (b + 0.05), `${theme} ${selector}`).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
await page.screenshot({ path: test.info().outputPath(`${theme.toLowerCase()}.png`), fullPage: true });
|
||||
}
|
||||
});
|
||||
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.
|
||||
|
||||
@@ -149,4 +149,4 @@ base_shift: 0.4
|
||||
- **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.
|
||||
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
|
||||
|
||||
@@ -21,7 +21,7 @@ Get your free Gemini API key from [Google AI Studio](https://makersuite.google.c
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install google-generativeai
|
||||
pip install google-genai
|
||||
```
|
||||
|
||||
### 3. Configure API Key
|
||||
@@ -204,6 +204,6 @@ Errors are displayed in the prompt output for easy debugging.
|
||||
- Verify API key is valid
|
||||
- Image might be too large (resize if needed)
|
||||
|
||||
**Import error for google-generativeai**:
|
||||
- Run `pip install google-generativeai` in your ComfyUI environment
|
||||
- Restart ComfyUI after installation
|
||||
**Import error for google-genai**:
|
||||
- Run `pip install google-genai` in your ComfyUI environment
|
||||
- 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.
|
||||
@@ -13,6 +13,7 @@ This node is based on work from [comfyui-essentials-nodes](https://github.com/cu
|
||||
- **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`
|
||||
@@ -33,6 +34,11 @@ This node is based on work from [comfyui-essentials-nodes](https://github.com/cu
|
||||
|-----------|------|---------|-------------|
|
||||
| `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
|
||||
@@ -72,6 +78,16 @@ LoRAFolderBatch → Processing Pipeline
|
||||
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
|
||||
@@ -86,6 +102,41 @@ Each LoRA is tested with ALL strength values:
|
||||
- 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
|
||||
@@ -207,6 +258,7 @@ batch_mode: sequential
|
||||
- **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.
|
||||
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
|
||||
|
||||
@@ -109,12 +109,12 @@ Parameter Grid → PlotParameters → Analysis Display
|
||||
x_axis: "guidance"
|
||||
y_axis: "perceived_quality"
|
||||
|
||||
# Step efficiency analysis
|
||||
# Step efficiency analysis
|
||||
x_axis: "steps"
|
||||
y_axis: "generation_time"
|
||||
|
||||
# LoRA impact assessment
|
||||
x_axis: "lora_strength"
|
||||
x_axis: "lora_strength"
|
||||
y_axis: "style_adherence"
|
||||
```
|
||||
|
||||
@@ -231,4 +231,4 @@ Compare multiple generation runs to identify optimal parameters.
|
||||
- **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.
|
||||
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
|
||||
|
||||
@@ -257,4 +257,4 @@ Compare all compatible samplers for specific model/prompt combination.
|
||||
- **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.
|
||||
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
|
||||
|
||||
@@ -297,4 +297,4 @@ Progress through schedulers from fast to quality for different use cases.
|
||||
- **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.
|
||||
Original implementation by cubiq in [comfyui-essentials-nodes](https://github.com/cubiq/ComfyUI_essentials). Adapted and maintained by the ComfyAssets team.
|
||||
|
||||
@@ -142,7 +142,7 @@ steps: 30-40
|
||||
cfg: 7-8
|
||||
sampler: dpmpp_3m_sde
|
||||
|
||||
# Speed over quality
|
||||
# Speed over quality
|
||||
steps: 10-15
|
||||
cfg: 5-6
|
||||
sampler: euler
|
||||
@@ -307,4 +307,4 @@ Very High (50+): Diminishing returns
|
||||
- **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.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,4 +166,4 @@
|
||||
],
|
||||
"attribution": "xyz_helpers nodes adapted from comfyui-essentials-nodes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+63
-12
@@ -3,28 +3,51 @@ 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.image_scale_down_by import ImageScaleDownByNode
|
||||
from .tools.gemini_prompt import GeminiPromptNode
|
||||
from .tools.batch_list_converter import (
|
||||
ImageBatchToImageListNode,
|
||||
ImageListToImageBatchNode,
|
||||
LatentBatchToLatentListNode,
|
||||
LatentListToLatentBatchNode,
|
||||
)
|
||||
from .tools.batch_prompts import BatchPromptsNode
|
||||
from .tools.checkpoint_folder_iterator import CheckpointFolderIteratorNode
|
||||
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.project_file_path import ProjectFilePathNode
|
||||
from .tools.model_compare_sheet import ModelCompareSheetNode
|
||||
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,
|
||||
FluxSamplerParamsNode,
|
||||
PlotParametersNode,
|
||||
LoRAFolderBatchNode,
|
||||
)
|
||||
|
||||
# ComfyUI node registration mappings
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ImageBatchToImageList": ImageBatchToImageListNode,
|
||||
"ImageListToImageBatch": ImageListToImageBatchNode,
|
||||
"LatentBatchToLatentList": LatentBatchToLatentListNode,
|
||||
"LatentListToLatentBatch": LatentListToLatentBatchNode,
|
||||
"BatchPrompts": BatchPromptsNode,
|
||||
"ResolutionCalculator": ResolutionCalculatorNode,
|
||||
"WidthHeightSelector": WidthHeightSelectorNode,
|
||||
"SeedHistory": SeedHistoryNode,
|
||||
@@ -37,15 +60,32 @@ NODE_CLASS_MAPPINGS = {
|
||||
"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,
|
||||
"CheckpointFolderIterator": CheckpointFolderIteratorNode,
|
||||
"ModelCompareSheet": ModelCompareSheetNode,
|
||||
"WidthHeightToVec2": WidthHeightToVec2Node,
|
||||
"KikoWorkflowTimer": KikoWorkflowTimerNode,
|
||||
"KikoProjectFilePath": ProjectFilePathNode,
|
||||
# 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",
|
||||
@@ -58,12 +98,23 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"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",
|
||||
"CheckpointFolderIterator": "Checkpoint Folder Iterator",
|
||||
"ModelCompareSheet": "Model Compare Sheet",
|
||||
"WidthHeightToVec2": "Width Height to VEC2",
|
||||
"KikoWorkflowTimer": "Workflow Timer",
|
||||
"KikoProjectFilePath": "Project File Path",
|
||||
# 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,106 @@
|
||||
"""Scan ComfyUI model folders (loras, checkpoints, ...) for .safetensors files."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_model_roots(folder_key: str) -> List[str]:
|
||||
"""Return every configured root directory for a folder_paths key."""
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
entry = folder_paths.folder_names_and_paths.get(folder_key)
|
||||
return list(entry[0]) if entry else []
|
||||
except (ImportError, AttributeError, IndexError, TypeError):
|
||||
return []
|
||||
|
||||
|
||||
def is_within(path: str, root: str) -> bool:
|
||||
"""True if realpath `path` equals or is inside realpath `root`."""
|
||||
try:
|
||||
real_path, real_root = os.path.realpath(path), os.path.realpath(root)
|
||||
return os.path.commonpath([real_path, real_root]) == real_root
|
||||
except ValueError: # different drives or mixed absolute/relative
|
||||
return False
|
||||
|
||||
|
||||
def resolve_scan_targets(
|
||||
folder_path: str, folder_key: str
|
||||
) -> List[Tuple[str, Optional[str]]]:
|
||||
"""
|
||||
Resolve a folder setting to (directory, base_root) pairs to scan.
|
||||
|
||||
base_root is the model root the directory lives in, or None when the
|
||||
directory is an absolute path outside every known root.
|
||||
"""
|
||||
roots = get_model_roots(folder_key)
|
||||
|
||||
if os.path.isabs(folder_path):
|
||||
base = next((r for r in roots if is_within(folder_path, r)), None)
|
||||
return [(folder_path, base)]
|
||||
|
||||
targets = []
|
||||
for root in roots:
|
||||
candidate = os.path.join(root, folder_path)
|
||||
if not is_within(candidate, root):
|
||||
logger.warning(f"Folder escapes the {folder_key} directory: {folder_path}")
|
||||
continue
|
||||
targets.append((candidate, root))
|
||||
return targets
|
||||
|
||||
|
||||
def scan_model_entries(folder_path: str, folder_key: str) -> List[Tuple[str, str]]:
|
||||
"""Return naturally sorted (relative_name, absolute_path) for every .safetensors."""
|
||||
entries: Dict[str, str] = {}
|
||||
for directory, base in resolve_scan_targets(folder_path, folder_key):
|
||||
if not os.path.isdir(directory):
|
||||
continue
|
||||
for root, _, files in os.walk(directory):
|
||||
for file in files:
|
||||
if not file.endswith(".safetensors"):
|
||||
continue
|
||||
full = os.path.join(root, file)
|
||||
try:
|
||||
rel = os.path.relpath(full, base or directory)
|
||||
except ValueError:
|
||||
rel = os.path.relpath(full, directory)
|
||||
entries.setdefault(rel.replace("\\", "/"), full)
|
||||
return [(name, entries[name]) for name in natural_sort(list(entries))]
|
||||
|
||||
|
||||
def natural_sort(items: List[str]) -> List[str]:
|
||||
"""Sort strings so embedded numbers compare numerically (v2 < v10)."""
|
||||
|
||||
def natural_key(text):
|
||||
parts = []
|
||||
for part in re.split(r"(\d+)", text):
|
||||
if not part:
|
||||
continue
|
||||
parts.append((0, int(part)) if part.isdigit() else (1, part.lower()))
|
||||
return parts
|
||||
|
||||
return sorted(items, key=natural_key)
|
||||
|
||||
|
||||
def filter_by_pattern(
|
||||
names: List[str], include_pattern: str = "", exclude_pattern: str = ""
|
||||
) -> List[str]:
|
||||
"""Keep names matching include_pattern (if set) and not exclude_pattern (if set)."""
|
||||
filtered = list(names)
|
||||
if include_pattern:
|
||||
try:
|
||||
include_re = re.compile(include_pattern)
|
||||
filtered = [f for f in filtered if include_re.search(f)]
|
||||
except re.error as e:
|
||||
logger.error(f"Invalid include pattern: {e}")
|
||||
if exclude_pattern:
|
||||
try:
|
||||
exclude_re = re.compile(exclude_pattern)
|
||||
filtered = [f for f in filtered if not exclude_re.search(f)]
|
||||
except re.error as e:
|
||||
logger.error(f"Invalid exclude pattern: {e}")
|
||||
return filtered
|
||||
@@ -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,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", encoding="utf-8") 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", encoding="utf-8") 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()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Checkpoint Folder Iterator module."""
|
||||
|
||||
from .node import CheckpointFolderIteratorNode
|
||||
|
||||
__all__ = ["CheckpointFolderIteratorNode"]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Logic for the Checkpoint Folder Iterator node."""
|
||||
|
||||
import os
|
||||
from typing import List, Sequence, Tuple, TypeVar
|
||||
|
||||
from ...base.model_folders import filter_by_pattern, scan_model_entries
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def list_checkpoint_entries(
|
||||
folder_path: str, include_pattern: str = "", exclude_pattern: str = ""
|
||||
) -> List[Tuple[str, str]]:
|
||||
"""(name, absolute_path) pairs for every checkpoint, filtered, in natural order.
|
||||
|
||||
The absolute path is what gets loaded, so folders outside the configured
|
||||
checkpoints roots work too; the name is relative to the root (or to the
|
||||
folder itself when it lives outside every root) and is used for labels.
|
||||
"""
|
||||
entries = scan_model_entries(folder_path, "checkpoints")
|
||||
names = [name for name, _ in entries]
|
||||
kept = set(filter_by_pattern(names, include_pattern, exclude_pattern))
|
||||
return [(name, path) for name, path in entries if name in kept]
|
||||
|
||||
|
||||
def list_checkpoints(
|
||||
folder_path: str, include_pattern: str = "", exclude_pattern: str = ""
|
||||
) -> List[str]:
|
||||
"""Checkpoint names (relative to the checkpoints roots) in natural order."""
|
||||
entries = list_checkpoint_entries(folder_path, include_pattern, exclude_pattern)
|
||||
return [name for name, _ in entries]
|
||||
|
||||
|
||||
def pick_checkpoint(items: Sequence[T], index: int) -> T:
|
||||
"""Return items[index], or raise when the sweep has run past the end.
|
||||
|
||||
Wrapping would silently restart the sweep (and, downstream, archive the
|
||||
grid that just finished), so an over-queued run fails loudly instead.
|
||||
"""
|
||||
if not items:
|
||||
raise ValueError("No checkpoints found")
|
||||
if not 0 <= index < len(items):
|
||||
raise ValueError(
|
||||
f"Index {index} is past the end: only {len(items)} checkpoints matched. "
|
||||
"The sweep is complete; reset index to 0 to start a new one."
|
||||
)
|
||||
return items[index]
|
||||
|
||||
|
||||
def label_for(ckpt_name: str) -> str:
|
||||
"""Label drawn under a tile for this checkpoint. Default: the file stem."""
|
||||
return os.path.splitext(os.path.basename(ckpt_name))[0]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Checkpoint Folder Iterator node for ComfyUI."""
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import label_for, list_checkpoint_entries, pick_checkpoint
|
||||
|
||||
|
||||
class CheckpointFolderIteratorNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Load one checkpoint from a folder per queued run.
|
||||
|
||||
Set index to 0 with "increment" and queue as many runs as there are
|
||||
checkpoints: each run loads the next file, so only one model is in
|
||||
memory at a time. A run past the last checkpoint fails with a clear
|
||||
error rather than wrapping around, so over-queuing never restarts the
|
||||
sweep. Pairs with Model Compare Sheet to build a labeled grid.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"folder": (
|
||||
"STRING",
|
||||
{
|
||||
"default": ".",
|
||||
"multiline": False,
|
||||
"tooltip": "Folder relative to models/checkpoints (or absolute)",
|
||||
},
|
||||
),
|
||||
"index": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 9999,
|
||||
"control_after_generate": True,
|
||||
"tooltip": "Which checkpoint to load; errors past the end",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"include_pattern": (
|
||||
"STRING",
|
||||
{"default": "", "tooltip": "Regex to include (empty = all)"},
|
||||
),
|
||||
"exclude_pattern": (
|
||||
"STRING",
|
||||
{"default": "", "tooltip": "Regex to exclude (empty = none)"},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP", "VAE", "STRING", "STRING", "INT", "INT")
|
||||
RETURN_NAMES = ("model", "clip", "vae", "ckpt_name", "label", "index", "count")
|
||||
FUNCTION = "load"
|
||||
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
|
||||
|
||||
def load(
|
||||
self,
|
||||
folder: str,
|
||||
index: int,
|
||||
include_pattern: str = "",
|
||||
exclude_pattern: str = "",
|
||||
) -> Tuple[object, object, object, str, str, int, int]:
|
||||
import folder_paths
|
||||
import comfy.sd
|
||||
|
||||
entries = list_checkpoint_entries(folder, include_pattern, exclude_pattern)
|
||||
ckpt_name, ckpt_path = pick_checkpoint(entries, index)
|
||||
self.log_info(f"Loading {index + 1}/{len(entries)}: {ckpt_name}")
|
||||
|
||||
model, clip, vae = comfy.sd.load_checkpoint_guess_config(
|
||||
ckpt_path,
|
||||
output_vae=True,
|
||||
output_clip=True,
|
||||
embedding_directory=folder_paths.get_folder_paths("embeddings"),
|
||||
)[:3]
|
||||
return model, clip, vae, ckpt_name, label_for(ckpt_name), index, len(entries)
|
||||
@@ -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]]:
|
||||
@@ -54,7 +54,7 @@ def format_display_value(input_value: Any, mode: str = "raw value") -> str:
|
||||
|
||||
if isinstance(input_value, (dict, list)):
|
||||
return json.dumps(input_value, indent=2)
|
||||
except:
|
||||
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,7 +38,7 @@ class DisplayAnyNode(ComfyAssetsBaseNode):
|
||||
return True
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
CATEGORY = "ComfyAssets/👁️ Display"
|
||||
CATEGORY = "🫶 ComfyAssets/👁️ Display"
|
||||
RETURN_NAMES = ("display_text",)
|
||||
FUNCTION = "display"
|
||||
OUTPUT_NODE = True # This node displays output in the UI
|
||||
|
||||
@@ -19,7 +19,7 @@ class DisplayTextNode(ComfyAssetsBaseNode):
|
||||
RETURN_NAMES = ("text",)
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "display_text"
|
||||
CATEGORY = "ComfyAssets/👁️ Display"
|
||||
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/📦 Latents"
|
||||
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
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
{
|
||||
"models": [
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-2.5-pro-preview-03-25",
|
||||
"gemini-2.5-flash-preview-05-20",
|
||||
"gemini-2.5-pro-preview-05-06",
|
||||
"gemini-2.5-pro-preview-06-05",
|
||||
"gemini-2.5-flash-lite-preview-06-17",
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-001",
|
||||
"gemini-2.0-flash-lite-001",
|
||||
"gemini-2.0-flash-lite",
|
||||
"gemini-2.5-flash-preview-tts",
|
||||
"gemini-2.5-pro-preview-tts",
|
||||
"gemini-2.0-flash-preview-image-generation",
|
||||
"gemini-2.0-flash-exp",
|
||||
"gemini-2.0-flash-exp-image-generation",
|
||||
"gemini-2.0-flash-lite-preview-02-05",
|
||||
"gemini-2.0-flash-lite-preview",
|
||||
"gemini-2.0-pro-exp",
|
||||
"gemini-2.0-pro-exp-02-05",
|
||||
"learnlm-2.0-flash-experimental",
|
||||
"gemini-1.5-pro-latest",
|
||||
"gemini-1.5-pro-002",
|
||||
"gemini-1.5-pro",
|
||||
"gemini-1.5-flash-latest",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.5-flash-002",
|
||||
"gemini-1.5-flash-8b",
|
||||
"gemini-1.5-flash-8b-001",
|
||||
"gemini-1.5-flash-8b-latest",
|
||||
"gemini-2.0-flash-thinking-exp-01-21",
|
||||
"gemini-2.0-flash-thinking-exp",
|
||||
"gemini-2.0-flash-thinking-exp-1219",
|
||||
"gemma-3-1b-it",
|
||||
"gemma-3-4b-it",
|
||||
"gemma-3-12b-it",
|
||||
"gemma-3-27b-it",
|
||||
"gemma-3n-e4b-it",
|
||||
"gemma-3n-e2b-it",
|
||||
"gemini-exp-1206"
|
||||
],
|
||||
"descriptions": {
|
||||
"gemini-1.5-pro-latest": "Gemini 1.5 Pro Latest",
|
||||
"gemini-1.5-pro-002": "Gemini 1.5 Pro 002",
|
||||
"gemini-1.5-pro": "Gemini 1.5 Pro",
|
||||
"gemini-1.5-flash-latest": "Gemini 1.5 Flash Latest",
|
||||
"gemini-1.5-flash": "Gemini 1.5 Flash",
|
||||
"gemini-1.5-flash-002": "Gemini 1.5 Flash 002",
|
||||
"gemini-1.5-flash-8b": "Gemini 1.5 Flash-8B",
|
||||
"gemini-1.5-flash-8b-001": "Gemini 1.5 Flash-8B 001",
|
||||
"gemini-1.5-flash-8b-latest": "Gemini 1.5 Flash-8B Latest",
|
||||
"gemini-2.5-pro-preview-03-25": "Gemini 2.5 Pro Preview 03-25",
|
||||
"gemini-2.5-flash-preview-05-20": "Gemini 2.5 Flash Preview 05-20",
|
||||
"gemini-2.5-flash": "Gemini 2.5 Flash",
|
||||
"gemini-2.5-flash-lite-preview-06-17": "Gemini 2.5 Flash-Lite Preview 06-17",
|
||||
"gemini-2.5-pro-preview-05-06": "Gemini 2.5 Pro Preview 05-06",
|
||||
"gemini-2.5-pro-preview-06-05": "Gemini 2.5 Pro Preview",
|
||||
"gemini-2.5-pro": "Gemini 2.5 Pro",
|
||||
"gemini-2.0-flash-exp": "Gemini 2.0 Flash Experimental",
|
||||
"gemini-2.0-flash": "Gemini 2.0 Flash",
|
||||
"gemini-2.0-flash-001": "Gemini 2.0 Flash 001",
|
||||
"gemini-2.0-flash-exp-image-generation": "Gemini 2.0 Flash (Image Generation) Experimental",
|
||||
"gemini-2.0-flash-lite-001": "Gemini 2.0 Flash-Lite 001",
|
||||
"gemini-2.0-flash-lite": "Gemini 2.0 Flash-Lite",
|
||||
"gemini-2.0-flash-preview-image-generation": "Gemini 2.0 Flash Preview Image Generation",
|
||||
"gemini-2.0-flash-lite-preview-02-05": "Gemini 2.0 Flash-Lite Preview 02-05",
|
||||
"gemini-2.0-flash-lite-preview": "Gemini 2.0 Flash-Lite Preview",
|
||||
"gemini-2.0-pro-exp": "Gemini 2.0 Pro Experimental",
|
||||
"gemini-2.0-pro-exp-02-05": "Gemini 2.0 Pro Experimental 02-05",
|
||||
"gemini-exp-1206": "Gemini Experimental 1206",
|
||||
"gemini-2.0-flash-thinking-exp-01-21": "Gemini 2.5 Flash Preview 05-20",
|
||||
"gemini-2.0-flash-thinking-exp": "Gemini 2.5 Flash Preview 05-20",
|
||||
"gemini-2.0-flash-thinking-exp-1219": "Gemini 2.5 Flash Preview 05-20",
|
||||
"gemini-2.5-flash-preview-tts": "Gemini 2.5 Flash Preview TTS",
|
||||
"gemini-2.5-pro-preview-tts": "Gemini 2.5 Pro Preview TTS",
|
||||
"learnlm-2.0-flash-experimental": "LearnLM 2.0 Flash Experimental",
|
||||
"gemma-3-1b-it": "Gemma 3 1B",
|
||||
"gemma-3-4b-it": "Gemma 3 4B",
|
||||
"gemma-3-12b-it": "Gemma 3 12B",
|
||||
"gemma-3-27b-it": "Gemma 3 27B",
|
||||
"gemma-3n-e4b-it": "Gemma 3n E4B",
|
||||
"gemma-3n-e2b-it": "Gemma 3n E2B",
|
||||
"gemini-2.5-flash-lite": "Gemini 2.5 Flash-Lite"
|
||||
},
|
||||
"timestamp": 1754568195.1098156
|
||||
}
|
||||
@@ -30,7 +30,7 @@ def tensor_to_pil(tensor: np.ndarray) -> Image.Image:
|
||||
image_array = (tensor * 255).astype(np.uint8)
|
||||
|
||||
# Convert to PIL
|
||||
return Image.fromarray(image_array, mode="RGB")
|
||||
return Image.fromarray(image_array)
|
||||
|
||||
|
||||
def image_to_base64(image: Image.Image, format: str = "PNG") -> str:
|
||||
@@ -65,7 +65,7 @@ def get_api_key() -> Optional[str]:
|
||||
os.path.dirname(__file__), "..", "..", "..", "gemini_config.json"
|
||||
)
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, "r") as f:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
api_key = config.get("api_key")
|
||||
except Exception:
|
||||
@@ -115,30 +115,24 @@ def analyze_image_with_gemini(
|
||||
else:
|
||||
system_prompt = PROMPT_TEMPLATES.get(prompt_type, PROMPT_TEMPLATES["flux"])
|
||||
|
||||
# Here we would normally make the API call to Gemini
|
||||
# For now, we'll import the google-generativeai library
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
from google import genai
|
||||
except ImportError:
|
||||
return (
|
||||
"",
|
||||
"google-generativeai library not installed. Please run: pip install google-generativeai",
|
||||
"google-genai library not installed. Please run: pip install google-genai",
|
||||
)
|
||||
|
||||
try:
|
||||
# Configure Gemini
|
||||
genai.configure(api_key=api_key)
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
# Create model
|
||||
model = genai.GenerativeModel(model_name)
|
||||
|
||||
# Generate content
|
||||
response = model.generate_content(
|
||||
[
|
||||
response = client.models.generate_content(
|
||||
model=model_name,
|
||||
contents=[
|
||||
system_prompt,
|
||||
pil_image,
|
||||
"Analyze this image and generate an appropriate prompt according to the instructions.",
|
||||
]
|
||||
],
|
||||
)
|
||||
|
||||
# Extract text from response
|
||||
|
||||
@@ -59,10 +59,10 @@ def _fetch_models_from_api(
|
||||
Tuple of (model_names_list, model_descriptions_dict)
|
||||
"""
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
from google import genai
|
||||
except ImportError:
|
||||
if not silent:
|
||||
logger.error("google-generativeai not installed")
|
||||
logger.error("google-genai not installed")
|
||||
return [], {}
|
||||
|
||||
# Get API key
|
||||
@@ -77,15 +77,15 @@ def _fetch_models_from_api(
|
||||
return [], {}
|
||||
|
||||
try:
|
||||
genai.configure(api_key=api_key)
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
models = []
|
||||
descriptions = {}
|
||||
|
||||
# Fetch all models
|
||||
for model in genai.list_models():
|
||||
for model in client.models.list():
|
||||
# Only include models that support generateContent
|
||||
if "generateContent" in model.supported_generation_methods:
|
||||
if "generateContent" in (model.supported_actions or []):
|
||||
# Remove "models/" prefix from name
|
||||
model_name = model.name.replace("models/", "")
|
||||
models.append(model_name)
|
||||
@@ -154,7 +154,7 @@ def _load_cache() -> Optional[Dict]:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(CACHE_FILE, "r") as f:
|
||||
with open(CACHE_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Check if cache is expired
|
||||
@@ -176,7 +176,7 @@ def _save_cache(models: List[str], descriptions: Dict[str, str]) -> None:
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
with open(CACHE_FILE, "w") as f:
|
||||
with open(CACHE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save cache: {e}")
|
||||
|
||||
@@ -51,7 +51,7 @@ class GeminiPromptNode(ComfyAssetsBaseNode):
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("prompt", "negative_prompt")
|
||||
FUNCTION = "generate_prompt"
|
||||
CATEGORY = "ComfyAssets/🧠 Prompts"
|
||||
CATEGORY = "🫶 ComfyAssets/🧠 Prompts"
|
||||
|
||||
DESCRIPTION = """
|
||||
Analyzes images using Google's Gemini AI to generate optimized prompts.
|
||||
@@ -63,7 +63,7 @@ Supports multiple prompt formats:
|
||||
- Video: Motion and temporal descriptions for video generation
|
||||
|
||||
Requires Gemini API key (set GEMINI_API_KEY env var or provide in node).
|
||||
Install: pip install google-generativeai
|
||||
Install: pip install google-genai
|
||||
"""
|
||||
|
||||
def generate_prompt(
|
||||
|
||||
@@ -35,7 +35,7 @@ class ImageScaleDownByNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
CATEGORY = "ComfyAssets/🖼️ Resolution"
|
||||
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
|
||||
RETURN_NAMES = ("images",)
|
||||
FUNCTION = "scale_down"
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class ImageToMultipleOfNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
CATEGORY = "ComfyAssets/🖼️ Resolution"
|
||||
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "process"
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .node import KikoFilmGrainNode
|
||||
|
||||
__all__ = ["KikoFilmGrainNode"]
|
||||
@@ -0,0 +1,224 @@
|
||||
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]
|
||||
"""
|
||||
# Local generator: never touch the global RNG state
|
||||
generator = torch.Generator()
|
||||
generator.manual_seed(int(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, generator=generator)
|
||||
|
||||
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
|
||||
"""
|
||||
# Only short-circuit when nothing at all would change (toe still applies)
|
||||
if strength == 0.0 and toe == 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,107 @@
|
||||
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."
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, *args: Any, **kwargs: Any) -> float:
|
||||
"""Always re-run: a purge must never be skipped by ComfyUI caching."""
|
||||
return float("nan")
|
||||
|
||||
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,70 @@ 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", encoding="utf-8") 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", encoding="utf-8") 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 _ensure_inside_output_dir(
|
||||
folder: str, output_dir: str, prefix_dir: str, subfolder: str
|
||||
) -> None:
|
||||
"""Raise ValueError unless folder resolves to a location inside output_dir."""
|
||||
real_root = os.path.realpath(output_dir)
|
||||
real_folder = os.path.realpath(folder)
|
||||
try:
|
||||
inside = os.path.commonpath([real_root, real_folder]) == real_root
|
||||
except ValueError: # e.g. different drives on Windows
|
||||
inside = False
|
||||
if not inside:
|
||||
raise ValueError(
|
||||
"Resolved output folder is outside the output directory "
|
||||
f"(prefix directory {prefix_dir!r}, subfolder {subfolder!r})"
|
||||
)
|
||||
|
||||
|
||||
def get_save_image_path(
|
||||
filename_prefix: str,
|
||||
batch_number: int,
|
||||
counter: int,
|
||||
format_ext: str,
|
||||
output_dir: str,
|
||||
subfolder: str = "",
|
||||
@@ -34,16 +94,21 @@ 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"
|
||||
if os.path.isabs(filename_prefix) or os.path.isabs(subfolder or ""):
|
||||
raise ValueError(
|
||||
"Absolute paths are not allowed in filename_prefix or subfolder: "
|
||||
f"{filename_prefix!r}"
|
||||
)
|
||||
prefix_dir = os.path.dirname(filename_prefix)
|
||||
prefix_name = os.path.basename(filename_prefix)
|
||||
|
||||
@@ -53,9 +118,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 = []
|
||||
@@ -70,6 +136,9 @@ def get_save_image_path(
|
||||
|
||||
full_output_folder = os.path.join(*path_components)
|
||||
|
||||
# Reject anything that would resolve outside the output directory
|
||||
_ensure_inside_output_dir(full_output_folder, output_dir, prefix_dir, subfolder)
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(full_output_folder, exist_ok=True)
|
||||
|
||||
@@ -221,7 +290,7 @@ def process_image_batch(
|
||||
popup: bool = True,
|
||||
prompt: Optional[Dict] = None,
|
||||
extra_pnginfo: Optional[Dict] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""
|
||||
Process and save a batch of images with specified format settings
|
||||
|
||||
@@ -237,7 +306,7 @@ def process_image_batch(
|
||||
extra_pnginfo: Additional PNG metadata
|
||||
|
||||
Returns:
|
||||
List of saved image information dicts
|
||||
Tuple of (ComfyUI preview result dicts, enhanced info dicts)
|
||||
"""
|
||||
# Get output directory
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
@@ -262,13 +331,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,7 +95,7 @@ class KikoSaveImageNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
CATEGORY = "ComfyAssets/💾 Images"
|
||||
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,344 @@
|
||||
"""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, Optional, Iterable
|
||||
|
||||
|
||||
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 resolve_path(path: Any) -> Optional[str]:
|
||||
"""Resolve a user supplied path with realpath (symlinks and ``..``).
|
||||
|
||||
Returns None for anything that is not a usable non-empty string.
|
||||
"""
|
||||
if not isinstance(path, str) or not path or "\0" in path:
|
||||
return None
|
||||
return os.path.realpath(path)
|
||||
|
||||
|
||||
def is_within_roots(path: Any, roots: Iterable[Any]) -> bool:
|
||||
"""Return True if the resolved path is inside (or equal to) one of the roots.
|
||||
|
||||
Uses os.path.commonpath on realpath-resolved values, so sibling
|
||||
directories sharing a string prefix, ``..`` and symlinks cannot escape.
|
||||
"""
|
||||
real = resolve_path(path)
|
||||
if real is None:
|
||||
return False
|
||||
for root in roots:
|
||||
real_root = resolve_path(root)
|
||||
if real_root is None:
|
||||
continue
|
||||
try:
|
||||
if os.path.commonpath([real, real_root]) == real_root:
|
||||
return True
|
||||
except ValueError: # different drives / mixed absolute and relative
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def is_too_broad_root(path: Any, home: Optional[str] = None) -> bool:
|
||||
"""Return True if path is unusable or too broad to be a saved root.
|
||||
|
||||
Refuses filesystem/drive roots, the home directory itself and any
|
||||
ancestor of it (e.g. /home), since those would expose every media file
|
||||
a user owns. Folders inside home are fine.
|
||||
"""
|
||||
real = resolve_path(path)
|
||||
if real is None:
|
||||
return True
|
||||
if os.path.dirname(real) == real: # "/" or a drive root like "C:\\"
|
||||
return True
|
||||
real_home = os.path.realpath(home or os.path.expanduser("~"))
|
||||
try:
|
||||
return os.path.commonpath([real, real_home]) == real
|
||||
except ValueError: # different drives
|
||||
return False
|
||||
|
||||
|
||||
def is_allowed_media_path(
|
||||
path: Any, roots: Iterable[Any], kinds: Iterable[str] = ("image",)
|
||||
) -> bool:
|
||||
"""Return True if path is a media file of the given kinds inside a root.
|
||||
|
||||
``kinds`` are keys of get_supported_extensions() (image/video/audio).
|
||||
The extension is checked on the resolved path so a symlink named
|
||||
``x.png`` pointing at a non-media file is refused.
|
||||
"""
|
||||
real = resolve_path(path)
|
||||
if real is None:
|
||||
return False
|
||||
extensions = get_supported_extensions()
|
||||
allowed = {e for k in kinds for e in extensions.get(k, [])}
|
||||
if os.path.splitext(real)[1].lower() not in allowed:
|
||||
return False
|
||||
return is_within_roots(real, roots)
|
||||
|
||||
|
||||
def is_allowed_image_path(path: Any, roots: Iterable[Any]) -> bool:
|
||||
"""Return True if path is an image file located inside one of the roots."""
|
||||
return is_allowed_media_path(path, roots, ("image",))
|
||||
|
||||
|
||||
def list_subdirectories(path: str, limit: int = 50) -> List[str]:
|
||||
"""List sub-directory paths (never files) for the folder picker.
|
||||
|
||||
If ``path`` ends with a separator, its child directories are returned;
|
||||
otherwise sibling directories whose name starts with the last component.
|
||||
May raise PermissionError.
|
||||
"""
|
||||
if path.endswith(os.sep) or (os.name == "nt" and path.endswith("/")):
|
||||
parent_dir, prefix = path, ""
|
||||
else:
|
||||
parent_dir = os.path.dirname(path)
|
||||
prefix = os.path.basename(path).lower()
|
||||
if not parent_dir:
|
||||
return []
|
||||
|
||||
if not os.path.isdir(parent_dir):
|
||||
return []
|
||||
|
||||
dirs = [
|
||||
os.path.join(parent_dir, entry)
|
||||
for entry in os.listdir(parent_dir)
|
||||
if entry.lower().startswith(prefix)
|
||||
and os.path.isdir(os.path.join(parent_dir, entry))
|
||||
]
|
||||
dirs.sort(key=lambda x: x.lower())
|
||||
return dirs[:limit]
|
||||
|
||||
|
||||
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,409 @@
|
||||
"""Local Image Loader node for ComfyUI."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import torch
|
||||
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
create_empty_tensor,
|
||||
is_allowed_image_path,
|
||||
is_allowed_media_path,
|
||||
is_too_broad_root,
|
||||
is_within_roots,
|
||||
list_subdirectories,
|
||||
load_image_from_path,
|
||||
resolve_path,
|
||||
scan_directory,
|
||||
)
|
||||
|
||||
try:
|
||||
from aiohttp import web
|
||||
except ImportError: # pragma: no cover - aiohttp ships with ComfyUI
|
||||
web = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MEDIA_KINDS = ("image", "video", "audio")
|
||||
MAX_PER_PAGE = 200
|
||||
DEFAULT_PER_PAGE = 50
|
||||
MAX_DIRECTORY_SUGGESTIONS = 50
|
||||
|
||||
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:
|
||||
logger.error("Error saving selections: %s", 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:
|
||||
logger.error("Error saving config: %s", e)
|
||||
|
||||
|
||||
def get_allowed_roots() -> List[str]:
|
||||
"""Directories whose contents may be served: saved paths + ComfyUI in/out."""
|
||||
roots: List[str] = []
|
||||
saved = load_config().get("saved_paths", [])
|
||||
if isinstance(saved, list):
|
||||
# Skip broad roots saved before save_paths refused them
|
||||
roots.extend(
|
||||
p for p in saved if isinstance(p, str) and p and not is_too_broad_root(p)
|
||||
)
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
for getter in ("get_input_directory", "get_output_directory"):
|
||||
value = getattr(folder_paths, getter)()
|
||||
if isinstance(value, str) and value:
|
||||
roots.append(value)
|
||||
except Exception: # folder_paths missing or misbehaving outside ComfyUI
|
||||
pass
|
||||
return roots
|
||||
|
||||
|
||||
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"):
|
||||
# Load the resolved path we validated, so a symlink swapped after
|
||||
# the check can't redirect the read.
|
||||
image_path = resolve_path(image_selection["path"])
|
||||
if not is_allowed_image_path(image_path, get_allowed_roots()):
|
||||
# selections.json may have been tampered with
|
||||
logger.warning("Refusing to load disallowed path: %s", image_path)
|
||||
elif 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:
|
||||
logger.error("Error loading image: %s", e)
|
||||
|
||||
return (image_tensor, info_string)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API route handlers (module level so they can be tested without a server)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _error(status: int, message: str):
|
||||
return web.json_response({"status": "error", "error": message}, status=status)
|
||||
|
||||
|
||||
def _check_file(filepath: Any, kinds: Tuple[str, ...] = ("image",)):
|
||||
"""Validate a requested file. Returns (resolved_path, error_response)."""
|
||||
if not isinstance(filepath, str) or not filepath or "\0" in filepath:
|
||||
return None, web.Response(status=400)
|
||||
real = resolve_path(filepath)
|
||||
if not is_allowed_media_path(real, get_allowed_roots(), kinds):
|
||||
return None, web.Response(status=403)
|
||||
if not os.path.isfile(real):
|
||||
return None, web.Response(status=404)
|
||||
return real, None
|
||||
|
||||
|
||||
def _parse_paging(query) -> Tuple[int, int]:
|
||||
"""Parse page/per_page; raises ValueError on garbage."""
|
||||
page = int(query.get("page", 1))
|
||||
per_page = int(query.get("per_page", DEFAULT_PER_PAGE))
|
||||
if page < 1:
|
||||
raise ValueError("page must be >= 1")
|
||||
return page, max(1, min(per_page, MAX_PER_PAGE))
|
||||
|
||||
|
||||
async def set_node_selection(request):
|
||||
"""Store the selected image for a node (image files in allowed roots only)."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return _error(400, "Invalid JSON.")
|
||||
if not isinstance(data, dict):
|
||||
return _error(400, "Invalid payload.")
|
||||
|
||||
node_id = data.get("node_id")
|
||||
path = data.get("path")
|
||||
media_type = data.get("type")
|
||||
if (
|
||||
node_id is None
|
||||
or not isinstance(path, str)
|
||||
or not path
|
||||
or "\0" in path
|
||||
or not isinstance(media_type, str)
|
||||
or not media_type
|
||||
):
|
||||
return _error(400, "Missing required data.")
|
||||
|
||||
# The frontend also selects video/audio; load_media only ever reads images.
|
||||
if not is_allowed_media_path(path, get_allowed_roots(), MEDIA_KINDS):
|
||||
return _error(403, "Path not allowed.")
|
||||
|
||||
selections = load_selections()
|
||||
selections.setdefault(str(node_id), {})[media_type] = {"path": path}
|
||||
save_selections(selections)
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
|
||||
async def get_saved_paths(request):
|
||||
"""Return saved directory paths."""
|
||||
return web.json_response({"saved_paths": load_config().get("saved_paths", [])})
|
||||
|
||||
|
||||
async def save_paths(request):
|
||||
"""Persist the list of saved directory paths."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return _error(400, "Invalid JSON.")
|
||||
paths = data.get("paths", []) if isinstance(data, dict) else None
|
||||
if not isinstance(paths, list) or not all(isinstance(p, str) for p in paths):
|
||||
return _error(400, "paths must be a list of strings.")
|
||||
too_broad = [p for p in paths if is_too_broad_root(p)]
|
||||
if too_broad:
|
||||
return _error(
|
||||
400,
|
||||
f"Folder too broad to share: {', '.join(too_broad)}. "
|
||||
"Pick a folder inside your home directory or another media folder.",
|
||||
)
|
||||
config = load_config()
|
||||
config["saved_paths"] = paths
|
||||
save_config(config)
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
|
||||
async def get_local_images(request):
|
||||
"""List images and sub-directories of a directory inside an allowed root."""
|
||||
query = request.query
|
||||
directory = query.get("directory", "")
|
||||
if not directory or "\0" in directory:
|
||||
return _error(400, "Directory is required.")
|
||||
try:
|
||||
page, per_page = _parse_paging(query)
|
||||
except ValueError:
|
||||
return _error(400, "Invalid page or per_page.")
|
||||
|
||||
if not is_within_roots(directory, get_allowed_roots()):
|
||||
return _error(403, "Directory not allowed.")
|
||||
real = resolve_path(directory)
|
||||
if not os.path.isdir(real):
|
||||
return _error(404, "Directory not found.")
|
||||
|
||||
# Normalize path (trailing slashes etc.) but keep the user-visible spelling
|
||||
directory = os.path.normpath(directory)
|
||||
|
||||
config = load_config()
|
||||
config["last_path"] = directory
|
||||
save_config(config)
|
||||
|
||||
show_videos = query.get("show_videos", "false").lower() == "true"
|
||||
show_audio = query.get("show_audio", "false").lower() == "true"
|
||||
hide_dot_folders = query.get("hide_dot_folders", "true").lower() == "true"
|
||||
|
||||
try:
|
||||
items = scan_directory(
|
||||
directory,
|
||||
show_videos,
|
||||
show_audio,
|
||||
query.get("sort_by", "name"),
|
||||
query.get("sort_order", "asc"),
|
||||
hide_dot_folders,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Error scanning directory %s", directory)
|
||||
return _error(500, "Error reading directory.")
|
||||
|
||||
# Do not offer navigation above the allowed roots
|
||||
parent_directory = os.path.dirname(directory)
|
||||
if parent_directory == directory or not is_within_roots(
|
||||
parent_directory, get_allowed_roots()
|
||||
):
|
||||
parent_directory = None
|
||||
|
||||
start = (page - 1) * per_page
|
||||
return web.json_response(
|
||||
{
|
||||
"items": items[start : start + per_page],
|
||||
"total_pages": (len(items) + per_page - 1) // per_page,
|
||||
"current_page": page,
|
||||
"current_directory": directory,
|
||||
"parent_directory": parent_directory,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def get_last_path(request):
|
||||
"""Return the last used directory path."""
|
||||
return web.json_response({"last_path": load_config().get("last_path", "")})
|
||||
|
||||
|
||||
async def list_directories(request):
|
||||
"""List directory names for the folder-picker autocomplete.
|
||||
|
||||
Trade-off: this is how users browse to a location that is not yet a saved
|
||||
root, so it is intentionally NOT restricted to the allowed roots. It only
|
||||
ever returns directory paths (never files), capped at
|
||||
MAX_DIRECTORY_SUGGESTIONS; file contents stay protected by the root and
|
||||
extension checks on every other route.
|
||||
"""
|
||||
path = request.query.get("path", "")
|
||||
if "\0" in path:
|
||||
return web.json_response({"directories": []})
|
||||
|
||||
try:
|
||||
if not path:
|
||||
if os.name == "nt":
|
||||
import string
|
||||
|
||||
drives = [
|
||||
f"{d}:\\"
|
||||
for d in string.ascii_uppercase
|
||||
if os.path.exists(f"{d}:\\")
|
||||
]
|
||||
return web.json_response({"directories": drives})
|
||||
return web.json_response({"directories": [os.sep]})
|
||||
|
||||
dirs = list_subdirectories(os.path.expanduser(path), MAX_DIRECTORY_SUGGESTIONS)
|
||||
return web.json_response({"directories": dirs})
|
||||
except PermissionError:
|
||||
return web.json_response({"directories": [], "error": "Permission denied"})
|
||||
except Exception:
|
||||
logger.exception("Error listing directories for %s", path)
|
||||
return web.json_response(
|
||||
{"directories": [], "error": "Error listing directories"}
|
||||
)
|
||||
|
||||
|
||||
async def get_thumbnail(request):
|
||||
"""Return a thumbnail of an allowed image."""
|
||||
real, error = _check_file(request.query.get("filepath"))
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(real) as img:
|
||||
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()
|
||||
fmt, content_type = (
|
||||
("PNG", "image/png") if has_alpha else ("JPEG", "image/jpeg")
|
||||
)
|
||||
img.save(buffer, format=fmt, quality=90 if fmt == "JPEG" else None)
|
||||
return web.Response(body=buffer.getvalue(), content_type=content_type)
|
||||
except Exception:
|
||||
logger.exception("Error generating thumbnail for %s", real)
|
||||
return web.Response(status=500)
|
||||
|
||||
|
||||
async def view_image(request):
|
||||
"""Serve a full-size allowed media file (image, video or audio)."""
|
||||
real, error = _check_file(request.query.get("filepath"), MEDIA_KINDS)
|
||||
if error is not None:
|
||||
return error
|
||||
return web.FileResponse(real)
|
||||
|
||||
|
||||
# Setup API routes
|
||||
try:
|
||||
import server
|
||||
|
||||
prompt_server = server.PromptServer.instance
|
||||
_routes = prompt_server.routes
|
||||
_prefix = "/kiko_local_image_loader"
|
||||
_routes.post(f"{_prefix}/set_node_selection")(set_node_selection)
|
||||
_routes.get(f"{_prefix}/get_saved_paths")(get_saved_paths)
|
||||
_routes.post(f"{_prefix}/save_paths")(save_paths)
|
||||
_routes.get(f"{_prefix}/images")(get_local_images)
|
||||
_routes.get(f"{_prefix}/get_last_path")(get_last_path)
|
||||
_routes.get(f"{_prefix}/list_directories")(list_directories)
|
||||
_routes.get(f"{_prefix}/thumbnail")(get_thumbnail)
|
||||
_routes.get(f"{_prefix}/view")(view_image)
|
||||
except ImportError:
|
||||
# Server not available during testing
|
||||
pass
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Model Compare Sheet module."""
|
||||
|
||||
from .node import ModelCompareSheetNode
|
||||
|
||||
__all__ = ["ModelCompareSheetNode"]
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Logic for the Model Compare Sheet node: tile files on disk into a labeled grid."""
|
||||
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from ...base.model_folders import is_within, natural_sort
|
||||
|
||||
GRID_FILENAME = "grid.png"
|
||||
PREVIEW_FILENAME = "grid_preview.png"
|
||||
PREVIEW_WIDTH = 1024
|
||||
BACKGROUND = (28, 28, 28)
|
||||
LABEL_COLOR = (235, 235, 235)
|
||||
|
||||
|
||||
def resolve_run_dir(output_dir: str, run_name: str) -> str:
|
||||
"""Directory for one compare run, always strictly inside output/model_compare.
|
||||
|
||||
run_name may be nested ("project/date/sweep"). Every segment must be a plain
|
||||
name: no empty, "." or ".." parts, on either separator, and the resolved
|
||||
path must stay below model_compare even after symlinks are followed.
|
||||
"""
|
||||
parts = re.split(r"[\\/]", run_name)
|
||||
if not run_name or os.path.isabs(run_name) or ":" in run_name:
|
||||
raise ValueError(f"Invalid run_name: {run_name!r}")
|
||||
if any(part in ("", ".", "..") for part in parts):
|
||||
raise ValueError(f"Invalid run_name: {run_name!r}")
|
||||
base = os.path.join(output_dir, "model_compare")
|
||||
run_dir = os.path.join(base, *parts)
|
||||
if not is_within(run_dir, base) or os.path.realpath(run_dir) == os.path.realpath(
|
||||
base
|
||||
):
|
||||
raise ValueError(f"Invalid run_name: {run_name!r}")
|
||||
return run_dir
|
||||
|
||||
|
||||
def tile_filename(index: int, label: str, batch_index: int, batch_size: int) -> str:
|
||||
"""'03_label.png', or '03_label_2.png' for the second image of a batch."""
|
||||
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", label)
|
||||
suffix = f"_{batch_index + 1}" if batch_size > 1 else ""
|
||||
return f"{index:02d}_{safe}{suffix}.png"
|
||||
|
||||
|
||||
def label_from_filename(filename: str) -> str:
|
||||
stem = os.path.splitext(os.path.basename(filename))[0]
|
||||
return re.sub(r"^\d+_", "", stem)
|
||||
|
||||
|
||||
def list_tiles(run_dir: str) -> List[str]:
|
||||
"""PNG tiles in a run directory in natural order, excluding the grid itself."""
|
||||
names = [
|
||||
f
|
||||
for f in os.listdir(run_dir)
|
||||
if f.lower().endswith(".png")
|
||||
and f not in (GRID_FILENAME, PREVIEW_FILENAME)
|
||||
and os.path.isfile(os.path.join(run_dir, f))
|
||||
]
|
||||
return [os.path.join(run_dir, f) for f in natural_sort(names)]
|
||||
|
||||
|
||||
def replace_index_tiles(
|
||||
run_dir: str, index: int, staged_dir: str, names: List[str]
|
||||
) -> None:
|
||||
"""Publish one index's staged tiles, restoring old files on a rename failure."""
|
||||
previous_dir = os.path.join(staged_dir, "previous")
|
||||
os.mkdir(previous_dir)
|
||||
prefix = f"{index:02d}_"
|
||||
old_tiles = [
|
||||
path
|
||||
for path in list_tiles(run_dir)
|
||||
if os.path.basename(path).startswith(prefix)
|
||||
]
|
||||
moved: List[Tuple[str, str]] = []
|
||||
published: List[str] = []
|
||||
try:
|
||||
for path in old_tiles:
|
||||
backup = os.path.join(previous_dir, os.path.basename(path) + ".previous")
|
||||
os.replace(path, backup)
|
||||
moved.append((path, backup))
|
||||
for name in names:
|
||||
destination = os.path.join(run_dir, name)
|
||||
os.replace(os.path.join(staged_dir, name + ".tmp"), destination)
|
||||
published.append(destination)
|
||||
except OSError:
|
||||
for path in published:
|
||||
os.remove(path)
|
||||
for original, backup in reversed(moved):
|
||||
os.replace(backup, original)
|
||||
raise
|
||||
|
||||
|
||||
def archive_previous_sweep(run_dir: str) -> Optional[str]:
|
||||
"""Rename a run folder that already holds tiles to '<run_dir>_<timestamp>'.
|
||||
|
||||
Called when a sweep starts over (index 0) so stale tiles from an
|
||||
interrupted sweep never end up in the new grid. Returns the archive path,
|
||||
or None when there was nothing to archive.
|
||||
"""
|
||||
if not os.path.isdir(run_dir) or not list_tiles(run_dir):
|
||||
return None
|
||||
stamp = datetime.fromtimestamp(os.path.getmtime(run_dir)).strftime("%Y%m%d-%H%M%S")
|
||||
archive = f"{run_dir}_{stamp}"
|
||||
n = 1
|
||||
while os.path.exists(archive):
|
||||
n += 1
|
||||
archive = f"{run_dir}_{stamp}_{n}"
|
||||
os.rename(run_dir, archive)
|
||||
return archive
|
||||
|
||||
|
||||
def _font(font_size: int) -> ImageFont.ImageFont:
|
||||
try:
|
||||
return ImageFont.truetype("DejaVuSans.ttf", font_size)
|
||||
except OSError:
|
||||
return ImageFont.load_default(size=font_size)
|
||||
|
||||
|
||||
def _fit_label(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> str:
|
||||
while text and draw.textlength(text, font=font) > max_width:
|
||||
text = text[:-2] + "…" if len(text) > 1 else ""
|
||||
return text
|
||||
|
||||
|
||||
def build_contact_sheet(
|
||||
paths: List[str], columns: int, tile_width: int, font_size: int
|
||||
) -> Image.Image:
|
||||
"""Tile the images at `paths` into a grid with each file's label underneath."""
|
||||
tiles = []
|
||||
for path in paths:
|
||||
with Image.open(path) as im:
|
||||
im = im.convert("RGB")
|
||||
tile_height = max(1, round(im.height * tile_width / im.width))
|
||||
tiles.append((im.resize((tile_width, tile_height), Image.LANCZOS), path))
|
||||
|
||||
band = font_size * 2
|
||||
cell_height = max(t.height for t, _ in tiles) + band
|
||||
cols = min(len(tiles), columns)
|
||||
rows = math.ceil(len(tiles) / cols)
|
||||
sheet = Image.new("RGB", (cols * tile_width, rows * cell_height), BACKGROUND)
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
font = _font(font_size)
|
||||
|
||||
for i, (tile, path) in enumerate(tiles):
|
||||
x = (i % cols) * tile_width
|
||||
y = (i // cols) * cell_height
|
||||
sheet.paste(tile, (x, y))
|
||||
label = _fit_label(draw, label_from_filename(path), font, tile_width - 8)
|
||||
text_x = x + (tile_width - draw.textlength(label, font=font)) / 2
|
||||
text_y = y + tile.height + (band - font_size) / 2
|
||||
draw.text((text_x, text_y), label, fill=LABEL_COLOR, font=font)
|
||||
return sheet
|
||||
|
||||
|
||||
def preview_of(sheet: Image.Image) -> Image.Image:
|
||||
"""A copy of the sheet no wider than PREVIEW_WIDTH, for the node preview and galleries."""
|
||||
if sheet.width <= PREVIEW_WIDTH:
|
||||
return sheet
|
||||
height = max(1, round(sheet.height * PREVIEW_WIDTH / sheet.width))
|
||||
return sheet.resize((PREVIEW_WIDTH, height), Image.LANCZOS)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Model Compare Sheet node for ComfyUI."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
GRID_FILENAME,
|
||||
PREVIEW_FILENAME,
|
||||
archive_previous_sweep,
|
||||
build_contact_sheet,
|
||||
list_tiles,
|
||||
preview_of,
|
||||
replace_index_tiles,
|
||||
resolve_run_dir,
|
||||
tile_filename,
|
||||
)
|
||||
|
||||
|
||||
class ModelCompareSheetNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Save each run's image into output/model_compare/<run_name>/ and rebuild a
|
||||
labeled contact sheet from every tile saved so far. Pair with Checkpoint
|
||||
Folder Iterator: after the last queued run the grid holds every model.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"label": ("STRING", {"forceInput": True}),
|
||||
"index": ("INT", {"forceInput": True}),
|
||||
"run_name": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "illustrious",
|
||||
"tooltip": "Subfolder of output/model_compare for this run",
|
||||
},
|
||||
),
|
||||
"columns": ("INT", {"default": 5, "min": 1, "max": 32}),
|
||||
"tile_width": ("INT", {"default": 512, "min": 64, "max": 4096}),
|
||||
"font_size": ("INT", {"default": 28, "min": 8, "max": 256}),
|
||||
},
|
||||
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("grid",)
|
||||
FUNCTION = "collect"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
|
||||
|
||||
def collect(
|
||||
self,
|
||||
images: torch.Tensor,
|
||||
label: str,
|
||||
index: int,
|
||||
run_name: str,
|
||||
columns: int,
|
||||
tile_width: int,
|
||||
font_size: int,
|
||||
prompt: Optional[Dict] = None,
|
||||
extra_pnginfo: Optional[Dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
import folder_paths
|
||||
|
||||
if images.shape[0] == 0:
|
||||
raise ValueError("images must contain at least one image")
|
||||
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
run_dir = resolve_run_dir(output_dir, run_name)
|
||||
metadata = PngInfo()
|
||||
if prompt is not None:
|
||||
metadata.add_text("prompt", json.dumps(prompt))
|
||||
for key, value in (extra_pnginfo or {}).items():
|
||||
metadata.add_text(key, json.dumps(value))
|
||||
|
||||
parent = os.path.dirname(run_dir)
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
with TemporaryDirectory(prefix=".model-compare-", dir=parent) as staging:
|
||||
names = []
|
||||
for i, image in enumerate(images):
|
||||
array = (image.cpu().numpy().clip(0, 1) * 255).round().astype(np.uint8)
|
||||
name = tile_filename(index, label, i, images.shape[0])
|
||||
with Image.fromarray(array) as tile:
|
||||
tile.save(
|
||||
os.path.join(staging, name + ".tmp"),
|
||||
format="PNG",
|
||||
pnginfo=metadata,
|
||||
)
|
||||
names.append(name)
|
||||
if index == 0:
|
||||
archived = archive_previous_sweep(run_dir)
|
||||
if archived:
|
||||
self.log_info(f"New sweep: previous tiles moved to {archived}")
|
||||
os.makedirs(run_dir, exist_ok=True)
|
||||
replace_index_tiles(run_dir, index, staging, names)
|
||||
|
||||
sheet = build_contact_sheet(list_tiles(run_dir), columns, tile_width, font_size)
|
||||
sheet.save(os.path.join(run_dir, GRID_FILENAME))
|
||||
preview_of(sheet).save(os.path.join(run_dir, PREVIEW_FILENAME))
|
||||
self.log_info(f"Contact sheet: {run_dir}/{GRID_FILENAME}")
|
||||
|
||||
grid = torch.from_numpy(np.asarray(sheet).astype(np.float32) / 255.0)[None]
|
||||
subfolder = os.path.relpath(run_dir, output_dir)
|
||||
return {
|
||||
"ui": {
|
||||
"images": [
|
||||
{
|
||||
"filename": PREVIEW_FILENAME,
|
||||
"subfolder": subfolder,
|
||||
"type": "output",
|
||||
}
|
||||
]
|
||||
},
|
||||
"result": (grid,),
|
||||
}
|
||||
@@ -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,483 @@
|
||||
"""Base downloader class with common functionality"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, List, Optional, Sequence, Union
|
||||
from urllib.parse import urlparse, unquote
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DOWNLOAD_TIMEOUT = 60 # seconds, applied to every network operation
|
||||
CHUNK_SIZE = 1638400
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
ALLOWED_SCHEMES = ("http", "https")
|
||||
PART_SUFFIX = ".part"
|
||||
# Only model weights and inert data files may be written; never code.
|
||||
ALLOWED_FILE_EXTENSIONS = frozenset(
|
||||
{
|
||||
".safetensors",
|
||||
".sft",
|
||||
".ckpt",
|
||||
".pt",
|
||||
".pt2",
|
||||
".pth",
|
||||
".bin",
|
||||
".gguf",
|
||||
".onnx",
|
||||
".model",
|
||||
".json",
|
||||
".yaml",
|
||||
".yml",
|
||||
".txt",
|
||||
}
|
||||
)
|
||||
# folder_paths entries that hold code rather than models.
|
||||
EXCLUDED_FOLDER_NAMES = frozenset({"custom_nodes"})
|
||||
|
||||
try:
|
||||
import comfy.model_management
|
||||
|
||||
COMFY_AVAILABLE = True
|
||||
except ImportError:
|
||||
COMFY_AVAILABLE = False
|
||||
|
||||
|
||||
def validate_url_scheme(url: str) -> None:
|
||||
"""Raise ValueError unless url uses an allowed scheme (http/https)."""
|
||||
scheme = urlparse(url or "").scheme.lower()
|
||||
if scheme not in ALLOWED_SCHEMES:
|
||||
raise ValueError(
|
||||
f"Unsupported URL scheme '{scheme}'. Only http and https are allowed."
|
||||
)
|
||||
|
||||
|
||||
def validate_filename(name: Optional[str]) -> str:
|
||||
"""Validate a user-supplied filename.
|
||||
|
||||
Raises:
|
||||
ValueError: If the name is empty, a dot name, or contains path parts.
|
||||
"""
|
||||
if (
|
||||
not name
|
||||
or name in (".", "..")
|
||||
or os.path.basename(name) != name
|
||||
or "/" in name
|
||||
or "\\" in name
|
||||
or "\x00" in name
|
||||
):
|
||||
raise ValueError(f"Invalid filename: {name!r}")
|
||||
return name
|
||||
|
||||
|
||||
def safe_server_filename(name: Optional[str], default: str) -> str:
|
||||
"""Reduce a server-derived filename to a safe basename, or use default."""
|
||||
if name:
|
||||
candidate = os.path.basename(name.replace("\\", "/"))
|
||||
try:
|
||||
return validate_filename(candidate)
|
||||
except ValueError:
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
"""Only follow http(s) redirects and drop credentials on cross-host hops."""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
if urlparse(newurl).scheme.lower() not in ALLOWED_SCHEMES:
|
||||
raise urllib.error.HTTPError(
|
||||
newurl, code, "Redirect to disallowed URL scheme", headers, fp
|
||||
)
|
||||
new_request = super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
if new_request is not None and (
|
||||
urlparse(newurl).netloc.lower() != urlparse(req.full_url).netloc.lower()
|
||||
):
|
||||
new_request.remove_header("Authorization")
|
||||
return new_request
|
||||
|
||||
|
||||
class _NoRedirectProcessor(urllib.request.HTTPErrorProcessor):
|
||||
"""Return every response as-is so redirects can be handled manually."""
|
||||
|
||||
def http_response(self, request, response):
|
||||
return response
|
||||
|
||||
https_response = http_response
|
||||
|
||||
|
||||
def _folder_paths():
|
||||
"""Return the folder_paths module, or None when unavailable."""
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
return folder_paths
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def _default_model_roots() -> List[str]:
|
||||
"""Return ComfyUI's models dir plus every registered model folder.
|
||||
|
||||
Covers extra_model_paths.yaml entries on other drives.
|
||||
"""
|
||||
fp = _folder_paths()
|
||||
if fp is None:
|
||||
return []
|
||||
roots: List[str] = []
|
||||
models_dir = getattr(fp, "models_dir", None)
|
||||
if isinstance(models_dir, str) and models_dir:
|
||||
roots.append(models_dir)
|
||||
registry = getattr(fp, "folder_names_and_paths", None)
|
||||
if isinstance(registry, dict):
|
||||
for name, entry in registry.items():
|
||||
if name in EXCLUDED_FOLDER_NAMES:
|
||||
continue
|
||||
try:
|
||||
paths = entry[0]
|
||||
except (TypeError, IndexError, KeyError):
|
||||
continue
|
||||
if isinstance(paths, (list, tuple)):
|
||||
roots.extend(p for p in paths if isinstance(p, str) and p)
|
||||
return roots
|
||||
|
||||
|
||||
def _default_base_path() -> Optional[str]:
|
||||
"""Return ComfyUI's base path used to resolve relative save paths."""
|
||||
fp = _folder_paths()
|
||||
base = getattr(fp, "base_path", None) if fp is not None else None
|
||||
return base if isinstance(base, str) and base else None
|
||||
|
||||
|
||||
class BaseDownloader(ABC):
|
||||
"""Abstract base class for all downloaders"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: Optional[str] = None,
|
||||
models_root: Union[str, Sequence[str], None] = None,
|
||||
):
|
||||
"""Initialize downloader with optional API token
|
||||
|
||||
Args:
|
||||
token: Optional API token for authentication
|
||||
models_root: Directory (or list of directories) downloads must
|
||||
stay inside. Defaults to ComfyUI's models directory plus all
|
||||
registered model folders.
|
||||
"""
|
||||
self.token = token
|
||||
if isinstance(models_root, str):
|
||||
models_root = [models_root]
|
||||
self._models_root = list(models_root) if models_root else None
|
||||
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
|
||||
"""
|
||||
if not content_disposition:
|
||||
return None
|
||||
match = re.search(r"filename\*\s*=\s*[^']*'[^']*'([^;]+)", content_disposition)
|
||||
if match:
|
||||
return unquote(match.group(1).strip()) or None
|
||||
match = re.search(
|
||||
r'filename\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^;]+))', content_disposition
|
||||
)
|
||||
if match:
|
||||
value = next(g for g in match.groups() if g is not None)
|
||||
return value.strip() or None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_within(path: str, root: str) -> bool:
|
||||
try:
|
||||
return os.path.commonpath([root, path]) == root
|
||||
except ValueError: # different drives
|
||||
return False
|
||||
|
||||
def resolve_output_dir(self, output_path: str) -> str:
|
||||
"""Resolve output_path and ensure it lives inside the models root.
|
||||
|
||||
Returns:
|
||||
Real (symlink-resolved) directory path, created if missing.
|
||||
|
||||
Raises:
|
||||
ValueError: If the path escapes the models directory.
|
||||
"""
|
||||
roots = self._models_root or _default_model_roots()
|
||||
if not roots:
|
||||
raise ValueError("Models directory is unavailable; refusing to download")
|
||||
if not os.path.isabs(output_path):
|
||||
output_path = os.path.join(_default_base_path() or os.getcwd(), output_path)
|
||||
real_dir = os.path.realpath(output_path)
|
||||
real_roots = [os.path.realpath(r) for r in roots]
|
||||
if not any(self._is_within(real_dir, r) for r in real_roots):
|
||||
raise ValueError(
|
||||
f"Save path must be inside a ComfyUI models directory "
|
||||
f"({', '.join(real_roots)})"
|
||||
)
|
||||
self.validate_output_path(real_dir)
|
||||
return real_dir
|
||||
|
||||
def open_url(self, request: urllib.request.Request, follow_redirects: bool = True):
|
||||
"""Open a request with scheme checks, timeout and safe redirects."""
|
||||
validate_url_scheme(request.full_url)
|
||||
handler = _SafeRedirectHandler if follow_redirects else _NoRedirectProcessor
|
||||
opener = urllib.request.build_opener(handler)
|
||||
return opener.open(request, timeout=DOWNLOAD_TIMEOUT)
|
||||
|
||||
@staticmethod
|
||||
def parse_content_length(response: Any) -> Optional[int]:
|
||||
"""Return Content-Length as int, or None if absent/invalid."""
|
||||
value = response.getheader("Content-Length")
|
||||
try:
|
||||
return int(value) if value is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def save_response(
|
||||
self,
|
||||
response: Any,
|
||||
output_file: str,
|
||||
total_size: Optional[int],
|
||||
strict_size: bool = True,
|
||||
) -> str:
|
||||
"""Stream response to output_file atomically via a .part file.
|
||||
|
||||
The .part file is removed on any failure (including interrupts).
|
||||
|
||||
Raises:
|
||||
ValueError: If output_file is not a model/data file type.
|
||||
"""
|
||||
extension = os.path.splitext(output_file)[1].lower()
|
||||
if extension not in ALLOWED_FILE_EXTENSIONS:
|
||||
raise ValueError(
|
||||
f"Refusing to save file type '{extension or 'none'}': "
|
||||
f"only model files ({', '.join(sorted(ALLOWED_FILE_EXTENSIONS))})"
|
||||
)
|
||||
part_file = output_file + PART_SUFFIX
|
||||
try:
|
||||
downloaded = 0
|
||||
start_time = time.time()
|
||||
with open(part_file, "wb") as f:
|
||||
while True:
|
||||
chunk_start = time.time()
|
||||
buffer = response.read(CHUNK_SIZE)
|
||||
chunk_time = time.time() - chunk_start
|
||||
if not buffer:
|
||||
break
|
||||
downloaded += len(buffer)
|
||||
f.write(buffer)
|
||||
self.check_interrupt()
|
||||
speed = self.calculate_speed(len(buffer), chunk_time)
|
||||
self._report_chunk(downloaded, total_size, speed)
|
||||
sys.stdout.write("\n")
|
||||
|
||||
actual_size = os.path.getsize(part_file)
|
||||
if total_size and actual_size != total_size:
|
||||
message = (
|
||||
f"Download incomplete. Expected {total_size} bytes, "
|
||||
f"got {actual_size} bytes"
|
||||
)
|
||||
if strict_size:
|
||||
raise Exception(message)
|
||||
logger.warning(message)
|
||||
|
||||
os.replace(part_file, output_file)
|
||||
logger.info(
|
||||
"Download completed in %.0fs: %s", time.time() - start_time, output_file
|
||||
)
|
||||
return output_file
|
||||
except BaseException:
|
||||
if os.path.exists(part_file):
|
||||
os.remove(part_file)
|
||||
raise
|
||||
finally:
|
||||
close = getattr(response, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
|
||||
def _report_chunk(
|
||||
self, downloaded: int, total_size: Optional[int], speed: float
|
||||
) -> None:
|
||||
"""Write console progress and notify the callback."""
|
||||
if total_size:
|
||||
progress = downloaded / total_size
|
||||
sys.stdout.write(
|
||||
f'\r[{"=" * int(progress * 50):<50}] {progress * 100:.2f}% - {speed:.2f} MB/s'
|
||||
)
|
||||
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"
|
||||
)
|
||||
self.report_progress(downloaded, 0, f"{speed:.2f} MB/s")
|
||||
sys.stdout.flush()
|
||||
|
||||
def raise_for_http_status(self, code: int, reason: str, hints: dict) -> None:
|
||||
"""Raise a friendly Exception for an HTTP error status."""
|
||||
message = hints.get(code)
|
||||
if message is None and code == 429:
|
||||
message = "Rate limited. Please wait a moment before trying again."
|
||||
raise Exception(message or f"HTTP error {code}: {reason}")
|
||||
|
||||
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,238 @@
|
||||
"""CivitAI downloader implementation"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
from typing import Optional, Dict, Any
|
||||
from urllib.parse import urlparse, parse_qs, unquote, urljoin
|
||||
|
||||
from .base import (
|
||||
USER_AGENT,
|
||||
BaseDownloader,
|
||||
safe_server_filename,
|
||||
validate_filename,
|
||||
validate_url_scheme,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_BASE = "https://civitai.com/api/v1"
|
||||
REDIRECT_STATUSES = (301, 302, 303, 307, 308)
|
||||
|
||||
HTTP_ERROR_HINTS = {
|
||||
401: "Authentication required. Please provide a valid API token.",
|
||||
403: "Access forbidden. The model might be restricted or require special permissions.",
|
||||
404: "Model not found. The URL might be incorrect or the model was removed.",
|
||||
}
|
||||
|
||||
|
||||
class CivitAIDownloader(BaseDownloader):
|
||||
"""Downloader for CivitAI models"""
|
||||
|
||||
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 self.open_url(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_url_scheme(url)
|
||||
if filename:
|
||||
validate_filename(filename)
|
||||
output_dir = self.resolve_output_dir(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}"
|
||||
request = urllib.request.Request(url, headers=headers)
|
||||
|
||||
try:
|
||||
response = self.open_url(request, follow_redirects=False)
|
||||
except urllib.error.HTTPError as e:
|
||||
self.raise_for_http_status(e.code, e.reason, HTTP_ERROR_HINTS)
|
||||
except urllib.error.URLError as e:
|
||||
raise Exception(f"Network error: {e.reason}")
|
||||
|
||||
if response.status in REDIRECT_STATUSES:
|
||||
redirect_url = response.getheader("Location")
|
||||
response.close()
|
||||
if not redirect_url:
|
||||
raise Exception("Redirect response did not include a Location header")
|
||||
redirect_url = urljoin(url, redirect_url)
|
||||
validate_url_scheme(redirect_url)
|
||||
|
||||
if not filename:
|
||||
filename = self._filename_from_redirect(redirect_url)
|
||||
|
||||
# The signed redirect target gets no credentials
|
||||
try:
|
||||
response = self.open_url(
|
||||
urllib.request.Request(
|
||||
redirect_url, headers={"User-Agent": USER_AGENT}
|
||||
)
|
||||
)
|
||||
except urllib.error.HTTPError as e:
|
||||
self.raise_for_http_status(e.code, e.reason, HTTP_ERROR_HINTS)
|
||||
except urllib.error.URLError as e:
|
||||
raise Exception(f"Network error: {e.reason}")
|
||||
elif response.status != 200:
|
||||
code = response.status
|
||||
response.close()
|
||||
self.raise_for_http_status(
|
||||
code, "Download failed", {**HTTP_ERROR_HINTS, 404: "File not found"}
|
||||
)
|
||||
|
||||
if not filename:
|
||||
filename = safe_server_filename(
|
||||
self.extract_filename(url, default="model.safetensors"),
|
||||
"model.safetensors",
|
||||
)
|
||||
|
||||
output_file = os.path.join(output_dir, filename)
|
||||
if not self.should_download(output_file, force):
|
||||
response.close()
|
||||
logger.info("File already exists: %s", output_file)
|
||||
return output_file
|
||||
|
||||
total_size = self.parse_content_length(response)
|
||||
logger.info("Downloading %s to %s", filename, output_file)
|
||||
return self.save_response(response, output_file, total_size)
|
||||
|
||||
def _filename_from_redirect(self, redirect_url: str) -> str:
|
||||
"""Derive a safe filename from a CivitAI redirect URL."""
|
||||
parsed = urlparse(redirect_url)
|
||||
disposition = parse_qs(parsed.query).get("response-content-disposition", [""])[
|
||||
0
|
||||
]
|
||||
name = self.extract_filename_from_header(unquote(disposition))
|
||||
if not name:
|
||||
name = parsed.path.rsplit("/", 1)[-1]
|
||||
return safe_server_filename(name, "downloaded_file.safetensors")
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Custom URL downloader - best effort for direct download links"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
from .base import (
|
||||
USER_AGENT,
|
||||
BaseDownloader,
|
||||
safe_server_filename,
|
||||
validate_filename,
|
||||
validate_url_scheme,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_FILENAME = "downloaded_model.safetensors"
|
||||
|
||||
HTTP_ERROR_HINTS = {
|
||||
401: "Authentication required. Please provide a valid token if needed.",
|
||||
403: "Access forbidden. The URL might require authentication or special permissions.",
|
||||
404: "File not found. Please check the URL.",
|
||||
}
|
||||
|
||||
|
||||
class CustomDownloader(BaseDownloader):
|
||||
"""Best-effort downloader for custom/direct URLs
|
||||
|
||||
The optional token is sent as a Bearer token and is dropped automatically
|
||||
if the server redirects to a different host.
|
||||
"""
|
||||
|
||||
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 (http or https)
|
||||
output_path: Directory to save file (must be inside models dir)
|
||||
filename: Optional filename override (plain name, no path parts)
|
||||
force: Force re-download if file exists
|
||||
|
||||
Returns:
|
||||
Path to downloaded file
|
||||
|
||||
Raises:
|
||||
ValueError: For bad scheme, filename or save path
|
||||
Exception: If download fails
|
||||
"""
|
||||
validate_url_scheme(url)
|
||||
if filename:
|
||||
validate_filename(filename)
|
||||
output_dir = self.resolve_output_dir(output_path)
|
||||
|
||||
# Fast path: a known filename lets us skip the request entirely
|
||||
if filename:
|
||||
output_file = os.path.join(output_dir, filename)
|
||||
if not self.should_download(output_file, force):
|
||||
logger.info("File already exists: %s", output_file)
|
||||
return output_file
|
||||
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
request = urllib.request.Request(url, headers=headers)
|
||||
|
||||
try:
|
||||
response = self.open_url(request)
|
||||
except urllib.error.HTTPError as e:
|
||||
self.raise_for_http_status(e.code, e.reason, HTTP_ERROR_HINTS)
|
||||
except urllib.error.URLError as e:
|
||||
raise Exception(f"Network error: {e.reason}")
|
||||
|
||||
if not filename:
|
||||
header_name = self.extract_filename_from_header(
|
||||
response.getheader("Content-Disposition") or ""
|
||||
)
|
||||
filename = safe_server_filename(
|
||||
header_name, self.extract_filename(url, default=DEFAULT_FILENAME)
|
||||
)
|
||||
output_file = os.path.join(output_dir, filename)
|
||||
if not self.should_download(output_file, force):
|
||||
response.close()
|
||||
logger.info("File already exists: %s", output_file)
|
||||
return output_file
|
||||
|
||||
total_size = self.parse_content_length(response)
|
||||
logger.info("Downloading %s to %s", filename, output_file)
|
||||
|
||||
# Size mismatch is only a warning for arbitrary URLs
|
||||
return self.save_response(response, output_file, total_size, strict_size=False)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user